Fujitsu Advent Calendar の15日目の記事です。
はじめに
スマホとWebサービスを組み合わせたモバイルサービスの作成手順について説明します。
とっかかりとして実装~テストまでを1本通るものを作成して、あとは拡張できるような形になるようにしたつもりです。
もっと良い方法やってる!と言う方がいれば教えてもらえるとうれしいです。
以下の人を対象としています。
- JavaScript使える!という人
- これからスマートデバイス向けのアプリを作りたいなーと思っている人
- いろいろ情報はあるけど何から始めたらいいんだ?と思っている人
ちまたにはBabelやES2016やGulpやWebpackやBowerやReactなどの便利なライブラリやフレームワークがありますが、混ぜると訳がわからなくなるので本記事ではピュアなJavaScriptで実装しています。なのでとてもめんどくさいレガシーな作りになっています。
テストだけは少しツールを使用しています。
Webサービスは最近流行りのサーバレスではなくnodejs+expressです。
良さげなライブラリやフレームワークはSPAをつくるために使ったものたちなど他の方が紹介されています。
作るもの
- スマホから写真を撮ってサーバにアップロードして閲覧するサービス
モバイルサービスと言っても古くはクラサバの延長なのでシンプルなケースを対象としました。
細かく言うと以下の機能を実装します。
- クライアント
- 写真を取る
- 写真をアップロードする
- サーバ
- アップロードされた写真を保存する
- 保存された写真の一覧を返却する
作成済みのソースはそれぞれsample-appとsample-serverにあります。
準備
以下のツールを使用します。
- ツール
- nodejs
- git
- Android-SDK(今回はAndroidを対象にするので)
それぞれのインストール方法はググってください(サボり)。
nodejsをインストールしたら以下のパッケージをインストールしてください。
テストやデバッグで使用します。
npm install -g cordova node-inspector mocha-pahtomjs istanbul
構成
完成図はこんな感じです。
アプリ(JavaScript) <--> Webサービス(JavaScript)
アプリはハイブリッドアプリ、WebサービスはnodejsなのでJavaScriptだけで開発保守できます。学習コストも低くなりますね。
プロジェクトのディレクトリ構造はこんな感じです。
┬ sample-app ・・・ハイブリッドアプリ(クライアント)
└ sample-server ・・・Webサービス(サーバ)
ハイブリッドアプリ
ハイブリッドアプリを作成します。
ハイブリッドアプリというのはHTML+JavaScript+CSSで実装されたアプリのことです。
(いろいろ言い方はありますがここでは)すべてOSプラットフォームの言語で実装されたアプリをNativeアプリと言います。
- メリット
- AndroidやiOSでもWindowsでも(ほぼ)同じコードで動作するので保守性が高い
- JavaScriptなので学習コストが低い
- デメリット
- Nativeアプリに比べてWebViewが間に挟まっている分パフォーマンスが低い
パフォーマンスが要求されるアプリはNativeの方が良いですね。
ただ最近のWebViewの性能が良くなっていることとCordova Pluginで重たい一部分だけNativeで実装することができるのでハイブリッドアプリも捨てたものではありません。
準備
まずはアプリ開発の準備をします。
cordova
を使用してアプリのひな型を作成します。
cordova create sample-app com.tomochan.sample "Sample App"
cd sample-app
以下のようなディレクトリ構造になります。
sample-app
├ hooks
├ platforms
├ plugins
├ www
| ├ css
| ├ img
| ├ js
| └ index.html
└ config.xml
ひな形の中身を見るといくつかのディレクトリが作成されますが、基本的にwww
ディレクトリ内だけを触ります。
作成するアプリのOSプラットフォームを追加します。
ここではWindowsでもMacでも実行可能なAndroidにします。
cordova platform add android
まずはデフォルトの状態でアプリを実行してみます。
実機で実行する場合はUSBでPCと繋いでおいてください。
cordova build
cordova run
スマホの画面にCordovaのアイコンが表示されてDEVICE IS READYの文字が表示されていれば成功です。
Cordovaを使用したハイブリッドアプリではPluginなどの初期化処理が完了したらdevicereadyイベントが発火します。
アプリのロジックはのこdevicereadyイベントの発火後に実装していきます。
写真を撮ってアップロードする機能の実装
CordovaではNativeアプリで利用可能な機能(カメラやセンサー情報取得など)を利用するためにPluginを使用します。そのPlugin機能を利用して写真を撮ってアップロードする機能を実装します。
ここではCamera pluginとFile Transfer pluginを利用して写真を取得する処理を実装します。
cordova plugin add cordova-plugin-camera cordova-plugin-file-transfer
コマンドを実行するとPluginがインストールされます。
Pluginは公式や3rd partyのPluginがありますが、自分で作成することもできます。
業務で使う場合は自前で作成しないといけないことも多いと思うので別記事で紹介できればと思います。
PluginをインストールしたらHTMLとJavaScript側のソースを編集します。
テストコードの作成
まずはテストコードの作成です。
途中のnpm init
でいろいろ聞かれますがは後で編集できるのでデフォルトでOKです。
また、今回はGUIが絡むのでmocha-phantomjsを利用します。
npm init
npm install --save-dev mocha chai sinon
パッケージをインストールしたらテストを実装します。
testsディレクトリを作成してテストコードを作成します。(大した実装をしないのでテストになっていないですが)
mkdir tests
- tests/test.html
HTMLはmocha-phantomjs公式とほぼ同じです。
で括られている部分がテストで使用する部分です。phantomjsから実行するためにテストフレームワークをnode_modulesから直接参照しています。
<!DOCTYPE html>
<html lang="en">
<head>
<title>Test</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="../node_modules/mocha/mocha.css" rel="stylesheet">
</head>
<body>
<div id="mocha"></div>
<!-- testcode:start -->
<img id="capture-image" />
<ul id="image-list"></ul>
<!-- testcode:end -->
<script src="../node_modules/mocha/mocha.js"></script>
<script src="../node_modules/chai/chai.js"></script>
<script src="../node_modules/sinon/pkg/sinon.js"></script>
<script>
mocha.ui('bdd')
expect = chai.expect
</script>
<script src="../www/js/actions.js"></script>
<script src="../www/js/logics.js"></script>
<script src="config.js"></script>
<script src="spec.js"></script>
<script>
mocha.run()
</script>
</body>
</html>
- tests/spec.js
beforeでCordova PluginのMockを作成しています。
テストケースは数が多くなると管理が難しくなるので頃合いを見て分割していきましょう。最初からきれいに分割しようとすると逆に汚くなるので最初はまとめてしまっても良いと思います。
var assert = chai.assert;
describe('actions.js', function() {
beforeEach(function() {
alert = sinon.spy();
});
afterEach(function() {
var image = document.getElementById('capture-image');
delete image.src;
});
describe('showImage', function() {
it('should show an image', function() {
var uri = 'tests/files/test.jpg';
showImage(uri);
var img = document.getElementById('capture-image');
expect(img.src).to.contains(uri);
});
});
describe('getImageUrl', function() {
var uri = 'tests/files/test.jpg';
var img = document.getElementById('capture-image');
img.src = uri;
var imageUrl = getImageUrl();
expect(imageUrl).to.contains(uri);
});
describe('showImageList', function() {
var images = ['tests/files/test.jpg', 'tests/files/test.jpg'];
showImageList(images);
var list = document.getElementById('image-list');
expect(list.childNodes.length).to.equal(images.length);
});
});
describe('logics.js', function() {
before(function(){
if(navigator && !navigator.camera) {
navigator.camera = {};
navigator.camera.getPicture = function(scb, fcb, opt) {};
}
if(!window.FileTransfer) {
window.FileTransfer = function() {};
window.FileTransfer.prototype.upload = function(src, url, scb, fcb) {};
}
});
beforeEach(function() {
alert = sinon.spy();
});
describe('cameraBtnTouchendEventHandler', function() {
it('should get image url', function() {
uploadBtnTouchendEventHandler();
});
});
describe('cameraSuccessCallback', function() {
it('should get image url', function() {
var uri = 'tests/files/test.jpg';
cameraSuccessCallback(uri);
var img = document.getElementById('capture-image');
expect(img.src).to.contains(uri);
});
});
describe('cameraFailureCallback', function() {
it('should get alert', function() {
cameraFailureCallback('error');
expect(alert.args[0][0]).to.equal('Error: error');
});
});
describe('uploadBtnTouchendEventHandler', function() {
it('should upload image', function() {
uploadBtnTouchendEventHandler();
});
});
describe('uploadSuccessCallback', function() {
it('should success', function() {
uploadSuccessCallback({"responseCode": 200, "response": "", "bytesSent": 100});
expect(alert.args[0][0]).to.equal('success');
});
});
describe('uploadFailureCallback', function() {
it('should get alert', function() {
uploadFailureCallback({"code": -1, "source": "src", "target": "target"});
expect(alert.args[0][0]).to.equal("Error: -1");
});
});
describe('showImagesBtnTouchendEventHandler', function() {
it('should upload image', function() {
showImagesBtnTouchendEventHandler();
});
});
});
- JavaScript (tests/config.js)
テスト時のサーバ接続先を定義します。
サーバ接続先を分離している理由はテスト環境や本番環境で接続先が異なる場合に対応するためです。
ENV = {
"serverurl": "http://localhost:3000/"
};
実装
次に機能を実装します。
既存のソースのいらないところは消してactions.jsとlogics.jsを追加しています。
関数がグローバルだったりファイル外の変数を参照していたりコールバックがうざいなど良くない書き方ではありますが、流行のフレームワークを使用すればかなり解決します。
- www/index.html
簡単なUI要素を追加します。
またXMLHttpRequestでhttpのサーバと通信するのでContent-Security-Policyにconnect-src http:;を追加してください。
~
<meta http-equiv="Content-Security-Policy" content="default-src 'self' data: gap: https://ssl.gstatic.com 'unsafe-eval'; style-src 'self' 'unsafe-inline'; media-src *; img-src 'self' data: content:; connect-src http:;">
~
<body>
<input id="camera-btn" type="button" value="カメラ起動" />
<input id="upload-btn" type="button" value="アップロード" />
<input id="showimages-btn" type="button" value="写真の一覧を表示" />
<div>
<ul id="image-list"></ul>
<img id="capture-image" />
</div>
<script type="text/javascript" src="cordova.js"></script>
<script type="text/javascript" src="js/actions.js"></script>
<script type="text/javascript" src="js/logics.js"></script>
<script type="text/javascript" src="config.js"></script>
</body>
- www/js/actions.js
actions.jsでは主にUI周りの処理を記述しています。
UIとロジックを分離している理由は、ロジックにDOM操作を含むと依存関係が複雑になりテストが難しくなるためです。
function onDeviceReady(evt) {
var cameraBtn = document.getElementById('camera-btn');
cameraBtn.addEventListener('click', cameraBtnTouchendEventHandler);
var uploadBtn = document.getElementById('upload-btn');
uploadBtn.addEventListener('click', uploadBtnTouchendEventHandler);
var showImagesBtn = document.getElementById('showimages-btn');
showImagesBtn.addEventListener('click', showImagesBtnTouchendEventHandler);
}
function showImage(imageUrl) {
var image = document.getElementById('capture-image');
image.src = imageUrl;
}
function getImageUrl() {
var image = document.getElementById('capture-image');
return image.src;
}
function showImageList(images) {
var ul = document.getElementById('image-list');
var li = document.createElement('li');
images.forEach(function(image) {
var l = li.cloneNode();
l.textContent = image;
ul.appendChild(l);
});
}
function showResult(err, msg) {
if(err) {
alert('Error: ' + err);
} else {
alert(msg);
}
}
document.addEventListener('deviceready', onDeviceReady);
- www/js/logics.js
logicsではUI周りの処理は実行しません。
function cameraBtnTouchendEventHandler(evt) {
navigator.camera.getPicture(cameraSuccessCallback, cameraFailureCallback);
}
function cameraSuccessCallback(imageUrl) {
console.log('success');
showImage(imageUrl);
}
function cameraFailureCallback(msg) {
showResult(msg);
}
function uploadBtnTouchendEventHandler(evt) {
var imageUrl = getImageUrl();
var ft = new FileTransfer();
ft.upload(imageUrl, encodeURI(ENV.serverurl + "api/v1/images"),
uploadSuccessCallback, uploadFailureCallback);
}
function uploadSuccessCallback(result) {
showResult(null, 'success');
}
function uploadFailureCallback(error) {
showResult(error.code);
}
function showImagesBtnTouchendEventHandler(evt) {
var xhr = new XMLHttpRequest();
xhr.open('GET', ENV.serverurl + 'api/v1/images');
xhr.onload = function() {
if(xhr.readyState === 4) {
if(xhr.status === 200) {
var images = JSON.parse(xhr.response);
showImageList(images.files);
} else {
showResult(xhr.status);
}
}
};
xhr.send();
}
アプリを実行して写真を撮影すると画面上に写真が表示されたと思います。
次にこれをWebサービスにアップロードします。
アップロードするWebサービスは次章で作成するとしてここではアップロードする処理だけ記述します。
ここではENV.serverurl + api/v1/images
にアップロードするようにしています。
テスト
実装が完了したらテストを実施します。
package.jsonに以下のように記述します。
- package.json (一部抜粋)
"scripts": {
"test": "mocha-phantomjs -R dot tests/test.html"
},
npm run test
と実行するとテストが実行されます。
Android5.0以降であればChromeでデバッグできるのでここではdebugは書いていないです。
実機でデバッグする場合はcordova run
のあとにChromeブラウザを開いてアドレスバーからchrome://inspect/
を開いてください。
Android4以前はCrosswalkを使用して少しいじるとできます。
Webサービス
次にWebサービス側の説明をします。
yeomanなどのboilertemplateがありますが、あれはわかっている人用でわからない人が触るとよくわからないと思うのでここでは使いません。
ひな形を作成
Webサービスはnodejs+Expressで実装します。アプリと同じ言語で実装できるので導入コストが低いですね。
sample-serverディレクトリを作成して、必要なパッケージをインストールします。
途中のnpm init
でいろいろ聞かれますがは後で編集できるのでデフォルトでOKです。
sample-appディレクトリにいる場合は一旦抜けてください。
mkdir sample-server
npm init
npm install --save express multer fs path
npm install --save-dev
以下のようなディレクトリ構造です。
server.jsとapp.jsを分けている理由はAWSなどのサービスと連携する場合のテストでMockを使いやすくするためです(あまり意味はないかも)。
sample-server
├ controllers
| └ uploads.js
├ tests
| └ spec.js
├ app.js
├ server.js
└ package.json
Webサービス機能の実装
実装する機能は以下の2つです。
- クライアントからアップロードされた画像ファイルを保存する機能
- 保存された画像を一覧表示する機能
テストコード
機能それぞれに関するテストを実装します。
まずはテストに必要なパッケージをインストールします。
npm init
npm install --save-dev mocha chai supertest
testsディレクトリを作成してテストコードを作成します。(大した実装をしないのでテストになっていないですが)
mkdir tests
- tests/spec.js
テストではapi/v1/imagesのGETとPOSTのテストを実行しています。
それぞれのレスポンスを確認しています。
var request = require('supertest');
var chai = require('chai');
var assert = chai.assert;
var app = require('../app');
describe("API: ", function() {
describe('/images', function(done) {
it('GET', function() {
request(app)
.get('/api/v1/images')
.expect('Content-Type', /json/)
.expect(200, {"files": []}, done);
});
it('POST', function(done) {
request(app)
.post('/api/v1/images')
.attach('file', 'tests/files/test.jpg')
.expect(200)
.end(function(err, res) {
if (err) return done(err);
done();
});
});
});
});
実装
次に機能を実装します。
Expressの基本的な部分しか使用していないので簡単に説明します。
- app.js
サーバの基本部分です。
middlewareの設定などもここで実施しますが今回は特にしていません。
var express = require('express');
var images = require('./controllers/images');
var app = express();
var router = express.Router();
app.use('/', router);
app.use('/api/v1/images', images);
module.exports = app;
- controllers/images.js
画像の取得とアップロードのAPIの実装部分です。
アップロードされたファイルの保存にはmulterを使用してサーバのuploads/ディレクトリに保存されます。
var express = require('express');
var router = express.Router();
var fs = require('fs');
var multer = require('multer');
var imageDir = 'uploads/';
var upload = multer({"dest": imageDir});
router.get('/', function(req, res) {
fs.readdir(imageDir, function(err, files) {
if(err) {
res.status(500).send();
} else {
res.json({"files": files});
}
});
});
router.post('/', upload.single('file'), function(req, res) {
var image = req.file;
if(!image) {
res.status(400).send();
} else {
res.send();
}
});
module.exports = router;
テスト
実装したソースが問題ないかテストを実施します。
package.jsonにテスト実行コードを書きます。
- package.json (一部抜粋)
"scripts": {
"test": "istanbul cover node_modules/mocha/bin/_mocha --print none --report html -- tests/*.js -R tap",
"testdebug": "mocha --debug --debug-brk ./tests/*.js",
"start": "node server.js",
"debug": "node-debug server.js"
},
テスト実行コードを書いたらnpm run test
と実行します。
テストが完了するとcoverageディレクトリができていてテストのカバレッジ率やテストが通ったルートが確認できます。
本記事のテストはエラー系のテストを実施していないのでカバレッジ率は残念なことになっていますが・・・
debugまたはtestdebugを実行すると5858版ポートで待ち受けするのでエディタなどでデバッグできます。
私はVS Codeのデバッガを利用しています。
実行
テストが完了したらあとは実際に繋げてみます。
sample-app/www/config.jsのserverurlを自分のPCのIPアドレスにします。
3000番を使っているのでファイアウォールは一時的に切っておいてください。
確認できたら起動してください。
var ENV = {
"serverurl": "http://192.168.*.*:3000/"
};
アプリをビルドして実機で実行させます。
cordova build
cordova run
sample-serverディレクトリに移動してサーバを起動します。
npm run start
スマホで写真を撮ってアップロードしてみてください。
sample-server/uploadsにファイルが作成される、または写真の一覧を表示ボタンで写真のパスの一覧が取得できたら成功です。
このあとデプロイ〜運用と続きますがまたの機会にできればと思います。