まだプログラマーですが何か?

プログラマーネタとアスリートネタ中心。たまに作成したウェブサービス関連の話も http://twitter.com/dotnsf

2020/01

ソフトウェア開発時の、特に機能検証テストを意識した開発手法である TDD(Test-Driven Development : テスト駆動開発)をサンプルを含めて紹介します。 一応、自分の業務で使っている手法であり、ブログという形でアウトプットすることで自分の理解を深めることも目的の1つです。向き不向きあることは理解した上で「こういう開発手法もある」と理解してもらえればと。


【TDD とは?】
文字通りの「テスト中心に進めていく開発」手法です。具体的な例は後述しますが、「開発してからテストを考える」のではなく、「テストありきで開発する」というものです。

ここでの「テスト」とは「開発したソフトウェアプログラムが意図した通りに動くことを確認・検証すること」です。作って終わり、だとバグが多く残っていて、酷い場合はまともに動かないケースもあったりするのですが、そういったことがないように開発作業の一部としてテストを行います。テスト結果の評価方法にもよりますが、許容できないと判断された場合はテスト結果を元にプログラムを修正する必要が生じます。

また多くの場合、自動化できることを意識してテストを準備/実行します。自動化されたテストを CI/CD の環境に組み込むことで「テストに通ったものだけがデプロイされる」ことを実現します。

TDD はフロントエンド(UI)/バックエンドに共通した考えですが、個人的には API を中心としたバックエンド開発に向いている手法だと感じています。理由はテスト自体をアプリケーション開発の一部のように(同じプログラミング言語で)開発でき、テストを作ること自体がプログラミングの一部となり、バージョン管理を含めたソースコード管理がしやすくなるためです。またフロントエンドのテストは同一プログラミング言語では難しかったり、別ツールを使う場合は前述のメリットを享受しにくくなると感じています。 といったこともあり、以下はバックエンド開発を前提とした TDD の説明を行います。

TDD のフレームワークはプログラミング言語ごとに多くあります。後述のサンプルではプログラミング言語環境として一般的な Node.js + Express を使う前提で、テストフレームワークには Mocha(mochajs.org) を、またテスト中のアサーションと呼ばれるメソッドを実行するための Chai(chaijs.com) を併用した例を紹介します。


【TDD をサンプルで実践】
今回、以下のような REST API を開発することになった、とします。社内で多く使われるアプリケーションの機能の一部として「ユーザーの登録、検索、更新、削除」が必要で、これらを実現するような REST API を開発することになりました(認証や認可の話は今回の対象外とします):
REST API機能説明
POST /api/userユーザーの登録POST データを使ってユーザーを新規に登録する。既存ユーザーIDが指定されている場合はエラーとする
GET /api/user/{id}ユーザーの検索{id} で示されるIDを持つユーザーを検索する。存在しないIDが指定されている場合はエラーとする
PUT /api/user/{id}ユーザーの更新{id} で示されるIDを持つユーザーの情報を POST データを使って更新する。存在しないIDが指定されている場合はエラーとする
DELETE /api/user/{id}ユーザーの削除{id} で示されるIDを持つユーザーの情報を削除する。存在しないIDが指定されている場合はエラーとする


ユーザー情報自体は id(string) と name(string) だけを持つものとします:
 例: { id: "001", name: "木村桂" }

例えば POST /api/user を実行して id="100", name="K.Kimura" のユーザーを作成する場合のポストデータは以下のような JSON オブジェクト(の文字列)となります:
 例: { id: "100", name: "K.Kimura" }

同じ id="100" の既存データを name="Kei Kimura" に更新する場合は PUT /api/user/100 の REST API を実行し、以下のようなデータを送信します:
 例: { id: "100", name: "Kei Kimura" }

また全ての REST API の結果は HTTP レスポンス本文内に JSON 文字列で返され、結果の JSON オブジェクトには status 属性が付与されます。この status 値が true の場合は成功、false の場合は何らかのエラーが発生しているものとします:
 成功例: { status: true }
 失敗例: { status: false, error: "エラーの詳細・・" }


これらのような仕様の REST API を実装することが目的であるとします(本ブログでは上2つの GET および POST の API 開発に絞って紹介します)。以下では TDD 手法でこれらの API を開発する様子を紹介します。



【TDD 手法による開発手順】
では上述の REST API を TDD 手法を使って開発してみます。まずは検索機能である GET /api/user/{id} の API を作ってみることにします。

TDD では文字通り "Test-Driven" な開発を行っていくことが特徴です。具体的にはいきなり API の開発に取り掛かるのではなく、最初に「これから作るもののあるべき挙動」を考え、その内容をテストケースとしてプログラムに記述します。まずは GET /api/user/{id} のあるべき挙動を考えることにしましょう。

この API は id を指定して、その指定された id を持つユーザーが登録されていたら status=true でユーザー情報を返す、指定された id を持つユーザーが登録されていなかったらエラー扱いとして status=false でエラー情報を返す、という挙動になるはずです。

例えば既に id="001" というユーザーが登録済みで id="100" というユーザーは登録されていなかった場合のそれぞれの挙動としては、以下の (1), (2) のようになるはずです:
(1) GET /api/user/001 → 成功する(status=true の結果が返る)はず
(2) GET /api/user/100 → 失敗する(status=false の結果が返る)はず


この (1), (2) それぞれの実行とその予想される結果をまずテストとして記述します。 その次に該当の API を開発し、完成したら最初に記述したテストを実行して期待通りの結果になるかどうかを検証します。これが TDD による開発の基本的な流れとなります。


【TDD の準備】
上述の順序に従って実際の開発を行うのですが、その前に各種フレームワークの準備となる作業を紹介しておきます。

TDD による開発をはじめる直前までを実装したプロジェクトをこちらに用意しておきました:
https://github.com/dotnsf/tdd-sample.git


git clone するか、ダウンロード&展開してプロジェクトを展開します。またこの後すぐにテストが実行できるように必要なライブラリをインストールしておきます:
$ npm install


まずは package.json ファイルの説明をしておきます:
{
    "name": "tdd-sample",
    "version": "0.0.1",
    "scripts": {
        "start": "node app.js",
        "lint": "eslint",
        "test": "mocha --exit **/*.spec.js"
    },
    "dependencies": {
        "body-parser": "1.17.x",
        "cf-deployment-tracker-client": "^0.1.4",
        "express": "4.17.x"
    },
    "repository": {},
    "engines": {
        "node": "8.x"
    },
    "devDependencies": {
        "chai": "^4.2.0",
        "eslint": "^6.8.0",
        "mocha": "^7.0.0",
        "supertest": "^4.0.2"
    }
}

TDD に関わる部分を青字にしておきました。まず dependencies に cf-deployment-tracker-client モジュールを、devDependencies に chai, mocha, supertest モジュールを追加しています。これらを使ってテストを記述および実装します。 また scripts の中で "test": "mocha --exit **/*.spec.js" という要素を記述していますが、これは「**.js というファイル向けのテストが **.spec.js というファイルに記述されていて、これを実行して npm test コマンドでテストする」ことを宣言しています。

実際のソースコードに関わるファイルは app.js と routes/users.js の2つです。app.js には Express を使って HTTP サーバーとして稼働するための最小限のコードが書かれています:
//. app.js
var express = require( 'express' ),
    app = express();
var users = require( './routes/users' );

//. ユーザー関連 API を /api/user 以下にルーティング
app.use( '/api/user', users );

//. ポート番号を決めて起動
var port = process.env.port || 8080;
app.listen( port );
console.log( "server stating on " + port + " ..." );

最小限のコードに青字で書かれた部分が追加されています。青字部分は routes/users.js ファイルを読みこみ、その中に書かれている内容を使って /api/user 以下へのリクエストのルーティングを処理する、という定義になっています。端的に言うと今回テストしようとしているユーザー登録関連 API の中身が routes/users.js に書かれている、という定義をしていることになります。

そしてその routes/users.js ファイルの中身が以下のなります:
//. users.js
var express = require( 'express' ),
    bodyParser = require( 'body-parser' ),
    router = express.Router();

//. ポストデータを JSON で受け取る
router.use( bodyParser.urlencoded( { extended: true } ) );
router.use( bodyParser.json() );

//. 検索 API : GET /api/user/:id
router.get( '/:id', function( req, res ){
});

//. 新規作成 API : POST /api/user
router.post( '/', function( req, res ){
});

module.exports = router;

この中では '/:id' への GET リクエスト(GET /api/user/:id)と '/' への POST リクエスト(POST /api/user)へのハンドラが定義されています(中身がないので、このまま実行してもエラーになります)。本来は PUT や DELETE についてもここで記述するべきですが、本ブログではこの2つまでを TDD で開発する様子を紹介するので、この2つを記載しておきました。続きを自分で作る場合は是非追加してチャレンジしてみてください。


【テストケースの実装】
では実際に TDD を使って開発作業の続きを行っていきます。API そのものは routes/users.js の中身(route.get ハンドラと route.post ハンドラの中身)を書いて実装していくことになるのですが、前述のように TDD ではテストケースを先に定義します。上記の緑で書いた (1), (2) のテストケースを先に作って、その後で routes/users.js を作っていく、という順序になります。

ではまずはテストケースを用意します。users.js のテストケースは users.spec.js に作る、という宣言をしているので、routes/users.spec.js というファイルを以下の内容で新規に作成します:
//. users.spec.js
var request = require( 'supertest' ),
    chai = require( 'chai' ),
    app = require( '../app' );

chai.should();

/* (1) のテスト */
describe( 'GET /api/user/001', function(){
  it( 'should return user object', async function(){
    var result = await request( app ).get( '/api/user/001' );
    result.statusCode.should.equal( 200 );
    result.body.status.should.equal( true );
  });
});

/* (2) のテスト */
describe( 'GET /api/user/100', function(){
  it( 'should return 404 for non-existing user', async function(){
    var result = await request( app ).get( '/api/user/100' );
    result.statusCode.should.equal( 404 );
    result.body.status.should.equal( false );
  });
});

特に緑で記述した部分で実際にテストを行っています。ここでの記述方法についての詳細は別途参照いただきたいのですが、実際にテストしたい内容と照らし合わせてなんとなく理解できるのではないかと思っています。(1) のテストでは(id="001" というユーザーが存在している前提で) GET /api/user/001 という API を実行し、その結果はステータスコードが 200 で、実行結果の JSON に { status: true, .. } という要素が含まれていることを確認してテスト成功とみなす、という内容を記述しています。 また (2) のテストでは(id="100" というユーザーが存在していない前提で) GET /api/user/100 という API を実行し、その結果はステータスコードが 404 で、実行結果の JSON に { status: false, .. } という要素が含まれていることを確認してテスト成功とみなす、としています。GET /api/user/{id} の API が正しく動いていればこうなるはず、という仕様を JavaScript で記述した例だと思ってください。

このテストケースを用意した上で実際の API を開発します。実際の API は routes/users.js ファイルの router.get( '/:id', ... で始まるハンドラーの中身として記述していきます。今回は以下のような内容の routes/users.js にしました(元のファイルに追加した部分をで記載しています):
//. users.js
var express = require( 'express' ),
    bodyParser = require( 'body-parser' ),
    router = express.Router();

//. ポストデータを JSON で受け取る
router.use( bodyParser.urlencoded( { extended: true } ) );
router.use( bodyParser.json() );

//. 本来は RDB などに格納するべき情報だが、この例では配列変数を使って簡易的に保存する
var users = [
  { id: "001", name: "K.Kimura" },
  { id: "002", name: "dotnsf" }
];

//. 検索 : GET /api/user/:id
router.get( '/:id', function( req, res ){
  var id = req.params.id;
  if( id ){
    var user = findOne( id );
    if( !user ){
      return res.status( 404 ).json( { status: false, error: 'user with id ' + id + ' not existed.' } );
    }else{
      return res.json( { status: true, user: user } );
    }
  }else{
    return res.status( 404 ).json( { status: false, error: 'parameter id required.' } );
  }
});

//. 新規作成 : POST /api/user
router.post( '/', function( req, res ){
});

//. id からユーザーを検索して返す
function findOne( id ){
  var u = null; //. 見つからない場合は null を返す
  users.forEach( function( user ){
    if( user.id == id ){
      u = user;
    }
  });

  return u;
}

module.exports = router;

まずこの API で扱うユーザー情報ですが、本来はデータベース等に格納したり、そこから取り出して取得するものだと理解しています。もちろん実際にそのように記述してもいいのですが、今回は TDD の解説を主目的としており、直接関係ない部分でコードを複雑化することは目的ではないため、この部分を簡易的に実装することにしました。具体的には上述のようにコード内に users という配列変数を用意し、この変数の中にユーザー情報が格納されていて、必要に応じて追加・更新・削除ができるような形で API を実装しています(この仕様によりアプリケーションを再起動するたびに中身は初期化されます)。またテストは id="001" のユーザーは存在していて、id="100" のユーザーは存在していない前提で実施されるため、id="001" のユーザーははじめから作っておくことにしました(同じ理由で id="100" のユーザーは作らないようにしてください)。

その上で router.get( '/:id', ... で始まるハンドラーの中身を API の実装として記述しています。まず URL の一部として指定される id の値を取得し、その値を id として持つユーザーを(findOne 関数で)検索し、null でないオブジェクトが返ってきたら、その結果を実行結果として { status: true, user: (実行結果) } の形で返すようにしています(特に指定していない時のステータスコードは 200 です)。指定した id のユーザーが存在しなかったり、id が指定されていなかった場合はステータスコード 404 を返しつつ、{ status: false, error: (エラーの原因) } という結果を返すようにしました。これで GET /api/user/{id} の実装例としては最低限動くものが作れました。

この後は実際にテストを実行するのですが、その前に app.js にも一箇所変更の必要があります。テスト時にはアプリケーション本体である app.js が実行された上でテストケースを確認することになります。この作業を効率的に行うために app.js をテストケースから呼び出して実行できるように最後に数行コードを追加します(赤字部分を追加):
//. app.js
var express = require( 'express' ),
    app = express();
var users = require( './routes/users' );

//. ユーザー関連 API を /api/user 以下にルーティング
app.use( '/api/user', users );

//. ポート番号を決めて起動
var port = process.env.port || 8080;
app.listen( port );
console.log( "server stating on " + port + " ..." );

//. テスト用に app 全体をエクスポート
require('cf-deployment-tracker-client').track();
module.exports = app;

【テストの実行】
ではテストケースと API 両方の準備ができたので、実際にテストを実行してみます。package.json 内にテスト用の scripts が用意されているので "npm test" コマンドを実行することで *.spec.js というファイルを見つけて *.js のテストを行います(青字部分がテスト結果です。またこの段階のプロジェクトでは routes/users.spec.js というファイルを見つけて、routes/users.js ファイルのテストだけが行われますが、ファイルが増えても同様のコマンドでまとめてテストできます):
$ npm test

    :

server stating on 8080 ...


  GET /api/user/001
    ✓ should return user object

  GET /api/user/100
    ✓ should return 404 for non-existing user


  2 passing (53ms)

この結果にはテストファイル(routes/users.spec.js)内の describe 関数で書かれた実行内容と、it 関数で書かれた期待結果が2つずつ表示され(つまり2つのテストが実施され)、どちらも pass したことを意味しています。つまり用意したテストに耐えうる API が開発できた、ということになります。

ちなみにわざとエラーを起こすようなテストケース(実在する id="002" のユーザーが見つからない想定)を用意して実行すると次のような結果になり、テストに通らなかったことがわかります:
npm test

    :

server stating on 8080 ...


  GET /api/user/001
    ✓ should return user object (40ms)

  GET /api/user/002
    1) should return 404 for non-existing user


  1 passing (82ms)
  1 failing

  1) GET /api/user/002
       should return 404 for non-existing user:

      AssertionError: expected 200 to equal 404
      + expected - actual

      -200
      +404

      at Context. (routes/users.spec.js:21:30)
      at 
      at process._tickCallback (internal/process/next_tick.js:189:7)



npm ERR! Test failed.  See above for more details.

【API の拡張とテストの再実行】
一応、ここまでの手順で最小限度の TDD が体験できているのですが、少しだけプラスアルファの要素を含めてみます。先程は GET (検索)の API を作ったので、続いて POST (新規作成)の API も TDD で開発してみます。

GET の時と同様にして、まずは POST API の仕様を考え、その仕様をもとにテストケースを考えます。先程作成済みの GET API とそのテストケースを使って、今回は以下のような挙動になるべきである、と考えたとします:
((2) で id="100" のユーザーが存在していないことを確認しているので、ここに続けて)
(3) POST /api/user/100 → 成功する(status=true の結果が返る)はず
(4) GET /api/user/100 → 成功する(status=true の結果が返る)はず

つまり (2) で id="100" のユーザーが存在していないことを確認した上で、(3) で id="100" のユーザーを作成し、(4) で (2) と全く同じテストを実行して今度はユーザーが見つかる、という挙動になることをテストで確認することにします。

この仕様のため、routes/users.spec.js を以下のように変更します(書き加えた部分を赤字で示しています):
//. users.spec.js
var request = require( 'supertest' ),
    chai = require( 'chai' ),
    app = require( '../app' );

chai.should();

/* (1) のテスト */
describe( 'GET /api/user/001', function(){
  it( 'should return user object', async function(){
    var result = await request( app ).get( '/api/user/001' );
    result.statusCode.should.equal( 200 );
    result.body.status.should.equal( true );
  });
});

/* (2) のテスト */
describe( 'GET /api/user/100', function(){
  it( 'should return 404 for non-existing user', async function(){
    var result = await request( app ).get( '/api/user/100' );
    result.statusCode.should.equal( 404 );
    result.body.status.should.equal( false );
  });
});

/* (3) のテスト */
describe( 'POST /api/user', function(){
  it( 'should create user object', async function(){
    var result = await request( app ).post( '/api/user' )
      .send( { id: '100', name: 'Linux' } );
    result.statusCode.should.equal( 200 );
    result.body.status.should.equal( true );
  });
});

/* (4) のテスト((2) と全く同じ内容で結果が異なるはず) */
describe( 'GET /api/user/100', function(){
  it( 'should return user object', async function(){
    var result = await request( app ).get( '/api/user/100' );
    result.statusCode.should.equal( 200 );
    result.body.status.should.equal( true );
  });
});

このテストケースを満たすように POST の API を開発していきます(場合によっては GET API にも手を加えていきます)。今回はポストされたデータから id を取り出し、既存ユーザーが存在していないことを確認した上で users 変数に push するだけの実装にしてみました:
//. users.js
var express = require( 'express' ),
    bodyParser = require( 'body-parser' ),
    router = express.Router();

//. ポストデータを JSON で受け取る
router.use( bodyParser.urlencoded( { extended: true } ) );
router.use( bodyParser.json() );

//. 本来は RDB などに格納するべき情報だが、この例では配列変数を使って簡易的に保存する
var users = [
  { id: "001", name: "K.Kimura" },
  { id: "002", name: "dotnsf" }
];

//. 検索 : GET /api/user/:id
router.get( '/:id', function( req, res ){
  var id = req.params.id;
  if( id ){
    var user = findOne( id );
    if( !user ){
      return res.status( 404 ).json( { status: false, error: 'user with id ' + id + ' not existed.' } );
    }else{
      return res.json( { status: true, user: user } );
    }
  }else{
    return res.status( 404 ).json( { status: false, error: 'parameter id required.' } );
  }
});

//. 新規作成 : POST /api/user
router.post( '/', function( req, res ){
  var id = req.body.id;
  if( id ){
    var user = findOne( id );
    if( user ){
      return res.status( 400 ).json( { status: false, error: 'user with id ' + id + ' already existed.' } );
    }else{
      users.push( req.body );
      return res.json( { status: true } );
    }
  }else{
    return res.status( 400 ).json( { status: false, error: 'parameter id required.' } );
  }
});

//. id からユーザーを検索して返す
function findOne( id ){
  var u = null; //. 見つからない場合は null を返す
  users.forEach( function( user ){
    if( user.id == id ){
      u = user;
    }
  });

  return u;
}

module.exports = router;

そして開発したコードをテストし、全てのテストコードが期待通りに動くことを確認します:
$ npm test

    :

server stating on 8080 ...


  GET /api/user/001
    ✓ should return user object (49ms)

  GET /api/user/100
    ✓ should return 404 for non-existing user

  POST /api/user
    ✓ should create user object (120ms)

  GET /api/user/100
    ✓ should return user object


  4 passing (203ms)

動きました!これで POST の API も開発できました。 以降、同様にして PUT(更新)や DELETE(削除)の API も、まず仕様を決めて、その仕様が満たすべきテストケースを考えて routes/users.spec.js に記述してから routes/users.js で API を開発してテスト、、、を繰り返す形で開発を進めていきます。これが TDD の典型的な開発手順となります。


【TDD への印象】
上でも少し触れていますが、この TDD という開発手法に対する自分の印象を記載しておきます。

まず開発体制が開発者とテスターとにきっちりと別れていないような場合(開発者=テスターの場合)であっても、この方法であれば、ある程度は客観的なテストが成立すると考えています。開発したものに対して、開発者がテストケースを考えるとどうしても漏れが出てしまったり、そもそも同一者によるテストを信用していいかどうかの問題も出てきます。その点 TDD では開発する前にテストケースを考えることで、同一者であってもある程度客観性が担保されたテストが可能になります。

また開発者とテスターが別れているケースでもメリットはあります。テスターを「開発トレーニングの一環」として考える場合、テスターもプログラミングによってテストを記述することになり、開発環境の構築や開発、デバッグの経験を積むことができるようになります。

一方で、特にこの手法の場合は REST API 開発のテストには向いていると思うのですが、フロントエンド側のテストを行うことが難しくなります。フロントエンドの HTML を取得して、その中身を解析して正誤を判断する、ことができないことはないと思いますが、そこまでのテストケースを作ることが大きな開発内容となってしまい、テストケースそのものが正しく作られているかどうかの問題も出てきてしまうと考えました。どうしてもバックエンド(REST API)向けのテストだと感じています。



最近のほとんど全てのスマートフォン(以後「スマホ」)にジャイロセンサーが搭載されています。これによってスマホの傾きや加速度に基づく動きをスマホ自身で(つまりスマホ内のアプリケーションで)取得できるようになっています。

この機能は HTML(と JavaScript )をベースとするウェブアプリケーションからでも使うことができます。具体的には iPhone と Android によって若干の実装の違いがあったり、iPhone の場合は iOS のバージョンによって挙動が(結構頻繁に)変わったりして対応が大変といえば大変ですが、センサーの値を取得すること自体は JavaScript で可能です。つまりネイティブのコードを使わず、ウェブのアプリケーションからであってもスマートフォン自体の挙動を検知することが可能なのです。この辺りの詳細に関する情報は先日のブログにまとめているので、JavaScript の実装についてはこちらを参照ください:
iOS(特に 13 以降)でのモーションセンサー有効化


このモーションセンサー機能を視覚的に体験できるようなサービス(というか HTML ファイル)を作って公開してみました。

まず HTML ファイル自体はこちらの index.html ファイルを参照ください。基本的には外部ファイルを使わずに index.html ファイル1つだけで実装しています:
https://github.com/dotnsf/mos/


また実際の挙動を確認するにはこちらの URL に(スマホのウェブブラウザで)アクセスしてみてください:
https://dotnsf.github.io/mos/


使い方は、上述の github の README.md にも記載してありますが、一応こちらでも紹介しておきます。まずは利用するスマホが iPhone(iOS) で、そのバージョンが iOS 12.2 以上 13 未満の場合は事前に設定 - Safari で「モーションと画面の向きのアクセス」を ON に設定しておく必要がある点に注意してください:
2020011001


その上で上記 URL にスマホのウェブブラウザでアクセスします。ここで利用するスマホが iOS 13 以上の場合は以下のような「センサーの有効可」というバナーが上部についた画面が表示されるので、このバナーをタップします:
2020011101


すると「動作と方向へのアクセス」を許可するかどうか聞かれるので「許可」を選択してください:
2020011002


これでセンサーを利用する準備が完了です。iOS 13 以上でここまでの手順を実行するか、スマホが Android や iOS 13 未満の場合ははじめから以下のようなピンクの矩形が表示されるはずです:
2020011102


このピンクの矩形部分をホールド(指で押さえて、離さない状態)している間だけスマホのモーションセンサー値を取得します。試しにほんの一瞬だけタップしてすぐ離してみてください。以下のような2つの折れ線グラフが表示されるはずです↓:
img_taponce


これらの折れ線グラフですが、上はピンクの矩形部分をホールドしていた間の3軸ジャイロセンサーによる3次元加速度の推移です。上記例では8つのデータが記録されていますが、ほんの一瞬だけでもかなりの量のデータを取得します。ちなみに x が左右方向、y が上下方向、z が前後方向への加速度を表しています。

また下は同様に矩形部分をホールドしていた間のデバイスの傾きの推移(単位:度)です。上の例では前後方向が約50度、左右方向が約-5度の状態であったことがわかります。


もう少し実践的(?)な動きをしてみます。試しにスマートフォンを持って、その場で上半身だけ走る時のように両手を交互に前後に動かしてみた(足は全く動かさず)時に記録してみた時のグラフが以下です↓:
img_jog1


下のグラフから大きく手を振っていたことでスマホ自体が前後/左右方向へ傾いていたことがわかります。なおこの時は大きく3回手を前後させました。

次に同じようにその場で走るのですが、今度はその場で足も動かしてみました。実際には走らないのですが、トレッドミルを使っている時のようにその場で走る足踏みをしながら手を前後に動かす、という動作をした時の様子です(ちなみにこの時は5回手を前後させています)↓:
img_jog2

その場で走る動作をしているため上下運動が加わり、腕の前後運動のグラフと y 軸方向(上下方向)の加速度のグラフが同じような動きになっていることがわかります。

そして今度は「実際に走る」動作をしながら測定してみました↓:
img_jog3

実際に走って横方向への移動が加わっているため、x 軸方向への加速度が大きくなっていることがわかります。


また、今度はスマホを持って「縄跳び」をするような動きをしながら測定してみました↓:
img_nawatobi


この例では3回縄跳びを跳ぶような動作をしましたが、縄跳びを回す動きの様子が前後/左右のスマホの傾きとして現れており、ジャンプして着地する時の様子が y 軸の加速度に現れている様子が確認できます。


と、まあこんな感じのことが HTML と JavaScript だけで実現できています。このサンプルでは取得したモーションデータを単にグラフ化して表示しているだけですが、より実践的にはここで取得したデータを MQTT で送信してデータベースに記録したり、記録した内容を AI の学習データに使ったり、VR アプリに応用したり、・・・といったバックエンド連携と合わせての応用が考えられます。そんな実践で使えるモーションデータを自分のスマホを使って取得するサンプルとして紹介しました。改良に興味ある方やこのアプリの詳しい実装内容に興味ある方は index.html ファイルの中を参照いただければと思っています。


 

2020 年最初のブログとなりました。本年もよろしくおねがいします。


自分が作って公開するアプリの中にスマホのモーションセンサーを使うものが少なからず存在しています。モーションセンサーとはスマホの傾きや加速度を3軸(3次元)で取得するもので、この値を取得することで「スマホは今どんな姿勢なのか?(まっすぐなのか、傾いているのか、傾いているとしたらどの方向にどのくらい傾いているのか、・・)またどのような挙動をしているのか?(止まっているのか、動いているのか、動いているとしたらどの方向にどのような加速度を持って動いているのか、・・)」といった情報を取得することができ、これによってスマホをコントローラーのように扱うことができるようになるものです(例: 左に傾けたら左矢印が押された時と同じ挙動にする、など)。いろいろ面倒なネイティブアプリケーションを作らなくても、ウェブの(HTML と JavaScript の)アプリケーションでもセンサー情報が取得ができて VR アプリケーションみたいなものも作れる、といった便利さがありました。

ただ最近になって、少し前に作ったモーションセンサー対応アプリが現在のスマホでは動かなくなっていることに気づきました。これはデバイスそのものの問題ではなく、OS(iOS や Android)側のセキュリティ強化によるものが原因でした。昔の OS を使っている場合はセンサーがそのまま動くけど、新しい OS を使っていると同様には動かない、といった現象に遭遇するわけです。

これはまあ OS 提供側の事情もあるし、動かなくなったからといって不満ばかり言っていても動くようになるわけでもありません。今後の変更も含めてアプリケーション提供側が対応していくしかないのかなあ、、と半ばあきらめています。

#ただ1つだけ不満を言わせてもらうと、Android はともかく iOS 側の変更の頻度が高すぎて「やってられん!」という気になってしまうのも事実です。 (^^;


といった背景の中で、とりあえず 2020 年1月時点での OS(とバージョン)別モーションセンサー有効化方法をまとめてみました。


【OS/バージョン別対応策】

詳細は後述しますが、対応策の一覧はこちらになります:
OSバージョン対応策
Android9 以上(おそらく全バージョン)不要
以下の【モーションセンサーから情報を取得する JavaScript】の内容を実装すれば取得できる
iOS12.1 以下
12.2 以上 13 未満Safari の設定を変更
13 以上アプリケーション側で requestPermission を実行するよう実装し、ユーザーに「許可」させる


※2020/10/29 追記
iOS 14.0.1 での動作を確認しました。上記「13 以上」の対応内容で取得できました。


【モーションセンサーから情報を取得する JavaScript】
まず全ての OS やバージョンに関係なく、モーションセンサーから情報を取得する JavaScript の実装を紹介します(iOS 12.2 以上の場合は、この JavaScript を実行する前に後述の対応策が必要となります)。

例えばデバイスの姿勢(3次元軸での向き)を取得するには DeviceOrientation オブジェクトを使います(デバイスの挙動・加速度を取得するには DeviceMotion オブジェクトを使いますが、内容は同様なので省略します)。このオブジェクトはジャイロセンサー搭載マシンのブラウザでは有効になっています(ノート PC などジャイロセンサー非搭載機では無効です)。

この DeviceOrientation オブジェクトから値を取得するには deviceorientation イベントに対するハンドラを定義し、ハンドラ内で値を取得する必要があります。具体的には以下のようになります:
  :

//. DeviceOrientationEvent オブジェクトが有効な環境か? をチェック
if( window.DeviceOrientationEvent ){
  //. DeviceOrientationEvent オブジェクトが有効な場合のみ、deviceorientation イベント発生時に deviceOrientaion 関数がハンドリングするよう登録
  window.addEventListener( "deviceorientation", deviceOrientation );
}
  :

//. deviceorientation イベントハンドラ
function deviceOrientation( e ){
  //. 通常の処理を無効にする
  e.preventDefault();

  //. スマホの向きを取得
  var dir = e.alpha;   //. 北極方向に対する向きの角度
  var fb = e.beta;      //. 前後の傾き角度
  var lr = e.gamma;  //. 左右の傾き角度

    :
}

  :

まず window.DeviceOrientationEvent というオブジェクトが有効かどうか(モーションセンサー対応デバイスかどうか)をチェックします。有効な場合はデバイスから定期的に deviceorientaion イベントが発呼されるので、そのイベントが発生した時に呼ばれる関数(ハンドラ) : deviceOrientation() を定義しておきます。

deviceOrientation() 関数はハンドラが呼ばれた時点での情報を持つオブジェクト e を引数として実行されますが、このオブジェクトは3つの要素を持っています。e.alpha が北極方法に対するスマホの向き、e.beta が前後の傾き、そして e.gamma が左右の傾きで、全て角度(単位は度)の値が含まれています。これらの値を取り出すことで、ハンドラが呼ばれたタイミングでのこれらの角度を知ることができるようになる、というものです。なお、deviceOrientation() 関数は1秒に数十回呼び出されます。かなり細かい頻度でデバイスの動きを知ることができるようになっています。


上述の表でも説明しましたが、Android および iOS 12.1 以下であれば上記 JavaScript が記述されたページを HTTPS で開けばそのまま実行できてセンサー値を取得することができるようになります。

iOS 12.2 以上の場合は上記 JavaScript が実行される前に準備的な段階が必要になります。iOS 13 未満か以上かで準備段階の内容が異なるため注意が必要です。


【iOS 12.2 以上 13 未満での事前準備】
iOS 12.2 以上 13 未満の場合(つまり 12.x で x が2以上の場合)はデフォルトで Safari からセンサー値を取得することができないように設定されているため、この設定を変更しておく必要があります。

具体的には iOS の設定 - Safari に「モーションと画面の向きのアクセス」という項目があります。デフォルトではこの設定は OFF になっているはずですが、ここを ON に(緑色になるように)変更しておく必要があります:
2020011001


この変更をしておくだけで、後は Safari を開いて上述の JavaScript が含まれるページを開けば正しく実行され、センサー値を取得することができます。


【iOS 13 以上での事前準備】
さて問題の iOS 13 以上(現在 iOS を普通にアップデートするとこの状態になります)でのケースです。この環境下では前述のような Safari の設定は不要ですが、代わりにユーザーの許可無しに Safari からセンサー値を取得することができないように設定されています。なおこの許可はウェブページごとに許可する必要があるため、事前に Safari で全ページ向けの設定をしておく、ということ自体ができなくなりました。

またセンサー値を取得するページ(の JavaScript)側も個別にユーザーの許可を得るための JavaScript コードを記述して対応する必要があります:
2020011002
(↑このダイアログを出して、「許可」が選ばれないとセンサー値は取得できない)


しかも(まだありますw)この「個別にユーザーの許可を得る」タイミングも面倒です。サービスを提供する側としては「該当の URL を指定してページを開くと同時にユーザーの許可を得たい」と思うわけですが、これが許されていません(汗)。該当ページを開ききって、ユーザーがそのページの中にあるボタンをタップしたタイミングで許可を得るためのダイアログを表示し、許可された場合のみ DeviceOrientationEvent のイベントハンドラが有効になる、という仕様に変わったのでした。正直、開発・運用する側としてはかなり面倒な仕様になってしまいました。。


具体的なコードは以下のようになります。青字部分が元のコードからの変更箇所です:
  :

function ClickRequestDeviceSensor(){
  //. ユーザーに「許可」を求めるダイアログを表示
  DeviceOrientationEvent.requestPermission().then( function( response ){
    if( response === 'granted' ){
      //. 許可された場合のみイベントハンドラを追加できる
      window.addEventListener( "deviceorientation", deviceOrientation );
      //. 画面上部のボタンを消す
      $('#sensorrequest').css( 'display', 'none' );
    }
  }).catch( function( e ){
    console.log( e );
  });
}

//. DeviceOrientationEvent オブジェクトが有効な環境か? をチェック
if( window.DeviceOrientationEvent ){
  //. iOS13 以上であれば DeviceOrientationEvent.requestPermission 関数が定義されているので、ここで条件分岐
  if( DeviceOrientationEvent.requestPermission && typeof DeviceOrientationEvent.requestPermission === 'function' ){
    //. iOS 13 以上の場合、
    //. 画面上部に「センサーの有効化」ボタンを追加
    var banner = '<div  style="z-index: 1; position: absolute; width: 100%; background-color: rgb(0, 0, 0);" onclick="ClickRequestDeviceSensor();" id="sensorrequest"><p style="color: rgb(0, 0, 255);">センサーの有効化</p></div>';
    $('body').prepend( banner );
  }else{
    //. Android または iOS 13 未満の場合、
    //. DeviceOrientationEvent オブジェクトが有効な場合のみ、deviceorientation イベント発生時に deviceOrientaion 関数がハンドリングするよう登録
    window.addEventListener( "deviceorientation", deviceOrientation );
  }
}
  :

//. deviceorientation イベントハンドラ
function deviceOrientation( e ){
  //. 通常の処理を無効にする
  e.preventDefault();

  //. スマホの向きを取得
  var dir = e.alpha;   //. 北極方向に対する向きの角度
  var fb = e.beta;      //. 前後の傾き角度
  var lr = e.gamma;  //. 左右の傾き角度

    :
}

  :

ユーザーにセンサーデータ取得の許可を求めるには DeviceOrientationEvent オブジェクトの requestPermission() 関数を実行します(この関数は iOS13 以降でのみ有効なので、iOS13 未満や Android では実行できません)。そしてそのダイアログで「許可」が選ばれた場合のみイベントハンドラを有効にすることができるようになるので、addEventHander で deviceOrientation() 関数を登録することでセンサー値を取得することができるようになる、というものです。

また requestPermission() 関数は全ての画面がロードされきった後でのみ実行できます。そのため、まず iOS13 以降かどうかを判断し、そうであった場合の画面ロード時にはセンサー値取得の許可を求めるためのダイアログを表示するボタンを用意し、そのボタンがタップされたタイミングで DeviceOrientationEvent.requestPermission() を実行して許可を求めるダイアログを表示して、「許可」が選ばれた場合は deviceOrientaion() 関数を有効にする、という順序でセンサー値を取得しています:
2020011002



・・・というわけで、特に iOS 13 以降で面倒になったブラウザでのモーションセンサー有効化を手順を含めて解説しました。現実問題としてはここまで実装した上でインターネット上のサーバーに公開してはじめて動かすことができるようになるまでも大変だし、モバイルブラウザだとデバッグも大変なので超面倒だなあ、という印象です。 ただ近い将来にこの機能を使って実装したサービスを公開するつもりでいるので、事前にややこしい所だけを説明しておく目的でこのブログエントリを作ったのでした。

このページのトップヘ