使用Cordova和Node.js构建简单的移动服务的混合应用程序

这是Fujitsu Advent Calendar第15天的文章。

首先

我将说明组合智能手机和Web服务以创建移动服务的步骤。
作为起点,我已创建了一个可以从实施到测试都可以进行的流程,并且打算让其具备可扩展性。

如果有人说他正在使用更好的方法,请告诉我,我会很高兴的。

我们的目标群体是以下的人。

    • JavaScript使える!という人

 

    • これからスマートデバイス向けのアプリを作りたいなーと思っている人

 

    いろいろ情報はあるけど何から始めたらいいんだ?と思っている人

目前在市面上有许多方便的库和框架,如Babel、ES2016、Gulp、Webpack、Bower、React等等,但是它们混合在一起就很难理解,所以本文中我们使用纯JavaScript来实现。因此,它变得非常麻烦和过时。
只有测试部分使用了一些工具。
Web服务不使用最近流行的无服务器,而是使用nodejs+express。

有许多其他人介绍了一些看起来不错的库和框架,可以用来构建单页应用程序。

创建的东西

    スマホから写真を撮ってサーバにアップロードして閲覧するサービス

尽管称为移动服务,但实际上是基于传统服务器的延伸,因此我们选择了简单的情况作为对象。

请提供中文句子。

    • クライアント

写真を取る
写真をアップロードする

サーバ

アップロードされた写真を保存する
保存された写真の一覧を返却する

已经创建好的源代码分别保存在sample-app和sample-server中。

准备

我们将使用以下工具。

    • ツール

nodejs
git
Android-SDK(今回はAndroidを対象にするので)

请自行谷歌以查找各个安装方法(偷懒)。

安装了Node.js后,请安装以下软件包。用于测试和调试。

npm install -g cordova node-inspector mocha-pahtomjs istanbul

构成

完成的图大致是这个样子。

应用程序(JavaScript)<–>网络服务(JavaScript)

这个应用是一个混合应用程序,Web服务是使用nodejs开发的,因此只需使用JavaScript开发和维护。学习成本也会降低。
项目的目录结构如下所示。

┬ sample-app       ・・・ハイブリッドアプリ(クライアント)
└ sample-server    ・・・Webサービス(サーバ)

混合式应用程序

我們將開發一個混合式應用程式。
混合式應用程式是指使用HTML+JavaScript+CSS來實現的應用程式。
雖然有很多種表達方式,但在這裡我們稱所有使用操作系統平台語言實現的應用程式為原生應用程式。

    • メリット

AndroidやiOSでもWindowsでも(ほぼ)同じコードで動作するので保守性が高い
JavaScriptなので学習コストが低い

デメリット

Nativeアプリに比べてWebViewが間に挟まっている分パフォーマンスが低い

要求性能的应用程序,使用原生的效果更好。
最近,WebView的性能有所提升,而且借助Cordova插件,只需在特定部分使用原生方式进行实现,因此混合应用程序并不是完全被抛弃的选项。

做好准备

首先,我們將準備應用程式開發的工作。我們將使用 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目录。

添加能够在Windows和Mac上运行的Android操作系统平台来开发应用程序。

cordova platform add android

首先试着在默认状态下运行应用程序。
如果在实体设备上运行,请确保使用USB将其连接到电脑上。

cordova build
cordova run

如果在智能手机屏幕上显示Cordova图标并显示”DEVICE IS READY”的文字,则表示成功。

当使用Cordova构建混合应用时,一旦插件等初始化过程完成,将触发deviceready事件。
我们将在deviceready事件触发后实现应用的逻辑。

实现照片拍摄并上传的功能

为了在Cordova中使用原生应用程序可用的功能(如相机和传感器信息获取),我们使用插件。利用该插件功能来实现拍照并上传图片的功能。

在这里我们将使用Camera插件和File Transfer插件来实现获取照片的处理。

cordova plugin add cordova-plugin-camera cordova-plugin-file-transfer

执行此命令将安装插件。
插件可分为官方和第三方插件,也可以自行创建。
考虑到经常需要自己创建插件以供业务使用,希望能够在另一篇文章中做介绍。

安装插件后,您需要编辑HTML和JavaScript源代码。

编写测试代码

首先是编写测试代码。
在执行npm init过程中会被询问一些问题,但是因为之后可以进行编辑,所以默认选项就可以。
另外,由于本次涉及到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

在之前,我正在创建Cordova插件的模拟。随着测试用例数量增多,管理变得困难,所以我们应该在适当的时机进行分割。如果一开始就尝试完美地进行分割,反而会变得杂乱,所以一开始可以将它们合并在一起。

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

请添加简单的用户界面元素。
另外,由于使用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文件中,主要是描述与用户界面相关的处理。
将用户界面和逻辑分开的原因是,如果将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中,我们不执行与用户界面相关的处理。

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服务的说明。
虽然有一些boilerplate模板,比如Yeoman,但我认为如果不懂的人碰触到它,很难理解,所以在这里我们不使用它。

创建模板

Web服务将使用nodejs+Express进行实现。因为可以使用与应用程序相同的语言进行实现,所以引入成本较低。

创建一个名为sample-server的目录,并安装所需的软件包。
在npm init的过程中会询问一些内容,但由于后续可以进行编辑,所以默认设置即可。如果您当前在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服务功能

以下是要实施的两个功能。

    • クライアントからアップロードされた画像ファイルを保存する機能

 

    保存された画像を一覧表示する機能

考试代码

首先,我们会安装所需的软件包,然后实施每个功能的测试。

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

这是服务器的基本组成部分。
在这里还可以进行中间件的配置等,但本次没有特别设置。

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 文件夹中创建了相应的文件,或者通过显示按钮可以获取照片路径列表,那么就算成功了。

希望之后能够继续进行部署和运维工作,如果有机会的话。

bannerAds