イノベーション エンジニアブログ


株式会社イノベーションのエンジニアたちの技術系ブログです。ITトレンド・List Finderの開発をベースに、業務外での技術研究などもブログとして発信していってます!


このエントリーをはてなブックマークに追加

Serverless Framework + TypeScript でサーバーレスマイクロサービスを作る その2

SREチームの城田です。

前回 ECMAScript2015(ES6)でサーバレスマイクロサービスを作るということで
その第一回目の記事を書きました。

Serverless Framework + ECMAScript2015 でサーバーレスマイクロサービスを作る その1
http://tech.innovation.co.jp/2017/07/09/Serverless-Framework-E-C-M-A-Script2015-1.html

しかし、
ES6完全準拠の TypeScript が、Microsoft製なのにGoogleの標準言語に採用され、
ES6にはない 抽象クラス や インターフェース、型宣言などにも対応していることから、
内容を見直して、今回からTypeScriptを全面採用して書き直しをしました。

また、
今回は新たにエンドユーザがアクセスするwebviewプラットフォームの
足がかりを作成しました。

ES6をTypeScriptでリファクタリング

TypeScript ts-loader をインストール

TypeScriptに対応するためモジュールを入れます。

$ npm install --save-dev typescript ts-loader
+ typescript@2.4.2
+ ts-loader@2.3.3
added 51 packages, removed 1 package and updated 5 packages in 5.837s

webpackの設定

TypeScript用に書き換えます。

$ vi webpack.config.js

module.exports = {
	entry: './handler.ts',
	target: 'node',
	module: {
		loaders: [{
			test: /\.ts$/,
			loader: 'ts-loader'
		}]
	},
	output: {
		libraryTarget: 'commonjs',
		path: __dirname + '/.webpack',
		filename: 'handler.js'
	}
};

ts-loaderの設定

最終的に ES5(JavaScript) に変換する指定と、node_modules配下の除外指定です。

$ vi tsconfig.json

{
  "compilerOptions": {
    "target": "es5",
    "module": "commonjs"
  },
  "exclude": [
    "node_modules"
  ]
}

Serverless Frameworkの設定

function名を変更しました。

$ vi serverless.yml

service: account-pf
plugins:
  - serverless-webpack
provider:
  name: aws
  runtime: nodejs6.10
  stage: dev
  region: us-east-1
  iamRoleStatements:
    - Effect: "Allow"
      Action:
        - "ec2:CreateNetworkInterface"
        - "ec2:DescribeNetworkInterfaces"
        - "ec2:DeleteNetworkInterface"
      Resource:
        - "*"
  vpc:
    securityGroupIds:
      - sg-xxxxxxxx
    subnetIds:
      - subnet-xxxxxxxx
      - subnet-yyyyyyyy
functions:
  auth:
    handler: handler.register
    events:
      - http:
          path: auth
          method: post

TypeScriptでコーディング

ハンドラーの拡張子が js なので ts に変更してから作成します。

$ mv handler.js handler.ts
$ vi handler.ts

import ClientController from "./app/controllers/ClientController.ts";

export function register(event, context, callback): void
{
	const controller: ClientController = new ClientController;
	callback(null, controller.register());
}

ハンドラーはオブジェクトを作ってメソッドを実行し、コールバックしているだけです。

コントローラーを作成します。

$ vi ./app/controllers/ClientController.ts

import BaseController        from "../core/BaseController.ts";
import ServiceRegisterClient from "../services/ServiceRegisterClient.ts";
import Response              from "../libs/Response.ts";

class ClientController extends BaseController
{
	constructor()
	{
		super();
	}

	public register(): object
	{
		const service: ServiceRegisterClient = new ServiceRegisterClient;
		const response: object = service.register();

		return Response.output(response);
	}
}

export default ClientController;

BaseControllerとサービス層のServiceRegisterClient、
ライブラリに置くResponseをインポートしています。

今のところサービス層のビジネスロジックの実行結果を
レスポンスアウトプットしているだけになりました。

ビジネスロジックは以下の感じです。

$ vi ./services/ServiceRegisterClient.ts

import ClientModel from "../models/ClientModel.ts";
import Utils       from "../libs/Utils.ts";

class ServiceRegisterClient
{
	public register(): object
	{
		const STATUS_SUCCESS = "ok";
		const STATUS_FAILURE = "ng";

		let model   : ClientModel;
		let result  : object;
		let token   : string;
		let message : string;
		let status  : string;

		try {
			token = Utils.generateUniqString();
			model = new ClientModel;
			model
				.setToken(token)
				.save();

			status = STATUS_SUCCESS;
		}
		catch (exception) {
			status = STATUS_FAILURE;
			message = exception;
		}
		finally {
			model.disconnect();
			result = {
				status  : status,
				token   : token,
				message : message
			};
		}

		return result;
	}
}

export default ServiceRegisterClient;

トークンを作成して、ClientModelにセットして、saveしています。

モデル側は以下のような感じです。

$ vi ./app/models/ClientModel.ts

import DatabaseModel from "../core/DatabaseModel.ts";

class ClientModel extends DatabaseModel
{
	public table       : string = "clients";
	public primaryKey  : string = "client_id";
	private token      : string;
	private clientId   : number;
	public params      : any;

	constructor()
	{
		super();
	}

	public setToken(token: string): ClientModel
	{
		this.params.token = token;
		return this;
	}
	public setClientId(clientId: number): ClientModel
	{
		this.params[this.primaryKey] = clientId;
		return this;
	}

	public getToken(): string
	{
		return this.params.token;
	}
	public getClientId(): number
	{
		return this.params[this.primaryKey];
	}
}

export default ClientModel;

saveメソッドは継承元の抽象クラスで実装しています。

$ vi ./app/core/DatabaseModel.ts

import * as mysql from "mysql";
import dsn        from "../configs/db.conf.ts";

abstract class DatabaseModel
{
	private db         : any;
	private savedData  : object;
	public table       : string;
	public primaryKey  : string;
	public params      : any;

	constructor()
	{
		this.db = mysql.createConnection(dsn);
	}

	private setSavedData(savedData: object): DatabaseModel
	{
		this.savedData = savedData;
		return this;
	}

	private getSavedData(): object
	{
		return this.savedData;
	}

	public save(): DatabaseModel
	{
		let sql : string;

		this.setSavedData(this.params);

		if (this.params[this.primaryKey]){
			sql = `UPDATE ${this.table} SET ? WHERE ${this.primaryKey} = ?`;
			this.db.query(sql, [this.getSavedData(), this.params[this.primaryKey]]);
		} else {
			sql = `INSERT INTO ${this.table} SET ?`;
			this.db.query(sql, [this.getSavedData()]);
		}

		return this;
	}

	public disconnect(): void
	{
		this.db.end();
	}
}

export default DatabaseModel;

Serverless Frameworkでデプロイ

いつも通り、これだけでデプロイできちゃいます。

$ sls deploy -v

動作確認

APIエンドポイントに対してcurlコマンドでPOSTリクエストをしてみました。

$ curl -X POST https://**********.execute-api.us-east-1.amazonaws.com/dev/auth
{"status":"ok","token":"5d1e99b79d734c94b65a8c274b6b00e7"}%

tokenが発行され、DBに格納されました。

mysql> select client_id, token, created_at from clients;
+-----------+----------------------------------+---------------------+
| client_id | token                            | created_at          |
+-----------+----------------------------------+---------------------+
|       119 | 5d1e99b79d734c94b65a8c274b6b00e7 | 2017-08-20 06:38:49 |
+-----------+----------------------------------+---------------------+

webviewプラットフォームの作成

開発環境整備

webviewの部分を作成していきます。
Twig と Request も入れておきます。

$ sls create --template aws-nodejs --name webview-pf
$ npm install --save-dev typescript webpack ts-loader serverless-webpack
$ npm install --save twig request

TypeScript、webpackの設定は account-pf と同じです。

$ vi webpack.config.js

module.exports = {
    entry: './handler.ts',
    target: 'node',
    module: {
        loaders: [{
            test: /\.ts$/,
            loader: 'ts-loader'
        }]
    },
    output: {
        libraryTarget: 'commonjs',
        path: __dirname + '/.webpack',
        filename: 'handler.js'
    }
};

$ vi tsconfig.json

{
  "compilerOptions": {
    "target": "es5",
    "module": "commonjs"
  },
  "exclude": [
    "node_modules"
  ]
}

Serverless Framework の設定

view周りで新たに必要な設定がいくつかあります。

$ vi serverless.yml

service: webview-pf

plugins:
  - serverless-webpack

provider:
  name: aws
  runtime: nodejs6.10
  stage: dev
  region: us-east-1
  iamRoleStatements:
    - Effect: "Allow"
      Action:
        - "ec2:CreateNetworkInterface"
        - "ec2:DescribeNetworkInterfaces"
        - "ec2:DeleteNetworkInterface"
      Resource:
        - "*"
  vpc:
    securityGroupIds:
      - sg-xxxxxxxx
    subnetIds:
      - subnet-xxxxxxxx
      - subnet-yyyyyyyy

functions:
  webview:
    handler: handler.webview
    events:
      - http:
          path: "{page}"
          method: get
          integration: lambda
          response:
            headers:
              Content-Type: "'text/html'"
            template: $input.path('$')

path: "{page}" でルーティングを行い、
レスポンスは Content-Type を指定して、
HTMLを出力するようにしています。

コーディング

ハンドラーはaccount-pfとほぼ変わりありませんが、
eventからpath名のpageを取得し、
viewControllerに渡しています。

$ vi handler.ts

import ViewController from "./app/controllers/ViewController.ts";

export function webview(event, context, callback): void
{
        const controller: ViewController = new ViewController;
        const html: string = controller.view(event.path.page);

        callback(null, html);
}

今回はコントローラにビジネスロジックを書いてしまいました。
Twig を採用して作成してみました。

$ vi ./app/controllers/ViewController.ts

import * as Twig    from "twig";
import * as request from "request";

class ViewController
{
	public view(page: string): string
	{
		let template: Twig;
		let output  : string;

		const routes: Object = {
			index   : true,
			register: true
		};

		if (routes[page]) {
			eval(`this.${page}()`);

			template = Twig.twig({
				data: require(`../views/${page}.ts`).default()
			});
			output = template.render({page: page})
		}

		return output;
	}

	public index(): void
	{
	}

	public register(): void
	{
		request.post('https://**********.execute-api.us-east-1.amazonaws.com/dev/auth', (error, response, body) => {
			console.log(body);
		});
	}
}

export default ViewController;

テンプレートを呼び出しています。
registerの場合は、account-pfにPOSTリクエストを送信しています。

viewテンプレートはこんな感じです。

$ vi ./app/views/index.ts

export default () => `<!DOCTYPE html>
<html lang="en">
  <head>
	<title>{{ page }} Page</title>
  </head>
  <body>
    <h1>{{ page }} Page</h1>
    <form action="/dev/register" method="get">
        <input type="submit" value="register">
    </form>
  </body>
</html>
`;

$ vi ./app/views/register.ts

export default () => `<!DOCTYPE html>
<html lang="en">
  <head>
	<title>{{ page }} Page</title>
  </head>
  <body>
    <h1>{{ page }} OK done!</h1>
  </body>
</html>
`;

動作確認

webviewプラットフォームにブラウザでアクセス

index.png

registerボタンを押します。

register.png

RDSのデータを確認します。

mysql> select client_id,token,created_at from clients order by created_at desc limit 1;
+-----------+----------------------------------+---------------------+
| client_id | token                            | created_at          |
+-----------+----------------------------------+---------------------+
|       120 | bbc1a04939384b6d84a6075b68deecc9 | 2017-08-20 06:55:28 |
+-----------+----------------------------------+---------------------+

ちゃんと動いています。

所感

今回 Vue.js や React を採用しなかったのは(また気が変わるかもしれませんが)
Twigで充分で、シンプルに保ちたかったからです。

また、
TwigのテンプレートファイルがTypeScriptの関数になっているのは、
全てTypeScriptとして記述し、
ES5でAWSにデプロイして完全にサーバーレス且つワンソースにしたかったためです。

テンプレートファイルだけS3などに置く手法もありますが、
ソースコードを一箇所に集約できなくなります。

Serverless Frameworkのデプロイトリガーで、
S3デプロイをしてくれるモジュールなども試してみましたが、
安定していなかったため(また気が変わるかもしれませんが)
今回はこの形で落ち着きました。

Serverless Framework + TypeScriptで、
サーバレスでサービスを提供するのが盛り上がって来てる感覚があります。

既に小規模なサービスはプロダクション環境でも採用例が上がってきているようですし、
今後の動向に目を光らせながら、自分に合ったやり方を探して行きたいと思います。