Escapin は,DynamoDB Table や S3 Bucket,Lambda Function 等の AWS のリソースの操作を JavaScript 上での単純なオブジェクト変数や関数の操作にマップすることで,よりビジネスロジックの実装に集中できるようなプログラミング体験を提供します.
Escapin はServerless Frameworkと連携させるために,コンパイルの過程で AWS リソースの作成・管理のための定義ファイル serverless.yml
を生成・編集します.
export const foo: table = {};
export const bar: bucket = {};
上記のようなエクスポートされた空のオブジェクト変数が宣言されている場合,型アノテーションで指定された通りに
- foo: DynamoDB Table
- bar: S3 Bucket
を作成するような serverless.yml
の設定を自動生成します.
この変数に対する操作
foo[id] = bar;
baz = foo[id];
qux = Object.keys(foo);
delete foo[id];
は以下のような同期的記述にコンパイルされます. DynamoDB の TableName,S3 の BucketName には,変数名の後にランダム UUID が自動で追加されます.
import { DynamoDB } from "aws-sdk";
// foo[id] = bar;
new DynamoDB().putItem({
TableName: "foo-9fe932f9-32e7-49f7-a341-0dca29a8bb32",
Item: {
key: { S: id },
type: { S: typeof bar },
value: {
S:
typeof bar === "object" || typeof bar === "function"
? JSON.stringify(bar)
: bar,
},
},
});
// baz = foo[id];
const temp = new DynamoDB().getItem({
TableName: "foo-9fe932f9-32e7-49f7-a341-0dca29a8bb32",
Item: {
key: { S: id },
},
});
baz =
temp === null || temp.Item === undefined
? undefined
: _temp.Item.type.S === "object" || temp.Item.type.S === "function"
? JSON.parse(temp.Item.value.S)
: temp.Item.value.S;
// qux = Object.keys(foo);
qux = new DynamoDB().scan({
TableName: "csv-9fe932f9-32e7-49f7-a341-0dca29a8bb32",
ExpressionAttributeNames: { "#ky": "key" },
ProjectionExpression: "#ky",
});
// delete foo[id];
new DynamoDB().deleteItem({
TableName: "csv-9fe932f9-32e7-49f7-a341-0dca29a8bb32",
Key: { key: { S: id } },
});
export function handler(req) {
if (errorOccured()) {
throw new Error("An error occured");
}
return { message: "Succeeded" };
}
のような関数が定義されている場合,「3. API のエクスポート」による API 仕様とのバインドを経て
- Lambda Function
- API Gateway の REST API
が作成されるような sererless.yml
の定義を自動生成し,以下のようにコンパイルされます.
export function handler(req, context, callback) {
if (errorOccured()) {
callback(new Error("An error occured."));
return;
}
callback(null, { message: "Succeeded" });
return;
}
ソースコード中で,OpenAPI Specification 2.0 に準拠した API 仕様ファイルを import
文でインポートすることできます.
ファイルの場所は HTTP URI またはプロジェクトフォルダからの相対ファイルパスで指定します.
import api from "http://path/to/swagger.yaml";
上記でインポートした API の各々の呼び出しは,変数 api
のメンバ操作,メンバ関数呼び出しとして記述することが出来ます.
以下の各々の HTTP メソッドと操作が対応します.
- GET <--> メンバ参照: MemberExpression
- POST <--> メンバ関数呼び出し: CallExpression(MemberExpression, *)
- PUT <--> メンバへの代入: AssignmentExpression(MemberExpression, *)
- DELETE <--> メンバの削除: UnaryExpression('delete', MemberExpression)
以下に,HTTP メソッド,パス,ヘッダ,ボディ,およびそれに対応する記述を示します.
メソッド | パス | ヘッダ | ボディ | 記述例 |
---|---|---|---|---|
GET |
/items |
items = api.items; |
||
GET |
/items/:id |
item = api.items[id]; |
||
GET |
/items/:id/props |
props = api.items[id].props; |
||
GET |
/items/:id?foo=bar |
item = api.items[id] [ { foo: 'bar' } ] ; |
||
GET |
/items/:id?foo=bar |
baz: qux |
item = api.items[id] [ { foo: 'bar', baz: 'qux' } ] ; |
|
POST |
/:domain/messages |
{ quux: 'corge' } |
api. domain[domain].messages ( { quux: 'corge' } ) ; ※ |
|
POST |
/items |
{ quux: 'corge' } |
api.items ( { quux: 'corge' } ) ; |
|
POST |
/items/:id?foo=bar |
baz: qux |
{ quux: 'corge' } |
api.items[id] [ { foo: 'bar', baz: 'qux' } ] ( { quux: 'corge' } ) ; |
PUT |
/items/:id |
baz: qux |
{ quux: 'corge' } |
api.items[id] [ { baz: 'qux' } ] = { quux: 'corge' }; |
DELETE |
/items/:id |
delete api.items[id]; |
この対応を基に,変数 api
のメンバ操作,メンバ関数呼び出しを HTTP クライアントを用いた記述にコンパイルします.
※パスパラメータから始まる場合のみ,api.<パラメータ名>[変数]
とします.
http://path/to/swagger.yaml
が以下のような内容だったとします.
swagger: "2.0"
info:
title: Awesome API
description: An awesome API
version: "1.0.0"
host: "api.endpoint.com"
schemes:
- http
basePath: /v1
produces:
- application/json
consumes:
- application/json
paths:
/items/{id}:
post:
description: Do some task regarding an item
parameters:
- name: id
in: path
type: string
required: true
description: Item ID
- name: foo
in: query
type: string
required: true
- name: baz
in: header
type: string
required: true
- name: params
in: body
schema:
$ref: "#/definitions/Params"
responses:
"200":
description: Succeeded
schema:
$ref: "#/definitions/Message"
definitions:
Params:
type: object
properties:
quux:
type: string
Message:
type: object
properties:
message:
type: string
この場合,
import api from "http://path/to/swagger.yaml";
api.items[id][{ foo: "bar", baz: "qux" }]({ quux: "corge" });
は,以下のような同期的記述にコンパイルされます.
import request from "request";
const { _res, _body } = request({
uri: `http://api.endpoint.com/v1/items/${id}`,
method: "post",
contentType: "application/json",
json: true,
qs: {
foo: "bar",
},
headers: {
baz: "qux",
},
body: {
quux: "corge",
},
});
アプリ自身の公開する API 仕様が以下の swagger.yaml
のように定義されていたとします.
swagger: "2.0"
info:
title: Awesome API
description: An awesome API
version: "1.0.0"
host: "api.endpoint.com"
schemes:
- https
basePath: /v1
produces:
- application/json
consumes:
- application/json
paths:
/items/{id}:
post:
x-escapin-handler: index.handleItem
description: Do some task regarding an item
parameters:
- name: id
in: path
type: string
required: true
description: Item ID
- name: foo
in: query
type: string
required: true
- name: baz
in: header
type: string
required: true
- name: params
in: body
schema:
$ref: "#/definitions/Params"
responses:
"200":
description: Succeeded
schema:
$ref: "#/definitions/Message"
definitions:
Params:
type: object
properties:
quux:
type: string
Message:
type: object
properties:
message:
type: string
上記の x-escapin-handler: index.handleItem
は,POST /items/{id}
が下記の index.js
のエクスポートされた関数 handleItem()
に対応することを示しています.
各パラメータは, <第一引数>.<パラメータのinの値>.<パラメータ名>
で取得することができます.
export function handleItem(req) {
const id = req.path.id;
const foo = req.query.foo;
const baz = req.header.baz;
const quux = req.body.quux;
if (errorOccured()) {
throw new Error("An error occured.");
}
return { message: "Succeeded" };
}
(AWS Lambda を用いる場合) 上記の index.js
は,以下のようにコンパイルされます.
export function handleItem(req, context, callback) {
const id = req.path.id;
const foo = req.query.foo;
const baz = req.header.baz;
const quux = req.body.quux;
if (errorOccured()) {
callback(new Error("An error occured."));
return;
}
callback(null, { message: "Succeeded" });
return;
}
Escapin では,コールバック関数,async
, await
, Promise
を用いた非同期処理を全く意識せずにプログラムを記述することが出来ます.
ライブラリ request
, aws-sdk
については,コールバック関数を引数に持つ関数名を自動で抽出出来ており,以下のような同期的記述で書いておけば自動的にasync
, await
, Promise
を用いた記述に変換されます.
コールバック関数の入れ子で記述されているコードがあったとします.
function func() {
call(arg, (err, data1, data2) => {
if (err) {
handleError(err);
} else {
doSomething(data1, data2);
}
});
}
このコードを同期的記述に変換すると以下のようになります.
function func() {
try {
const { data1, data2 } = call(arg);
doSomething(data1, data2);
} catch (err) {
handleError(err);
}
}
さらに,上記コードはasync
, await
, Promise
を用いた記述に変換されます.
async function func() {
try {
const _data = await new Promise((resolve, reject) => {
call(arg, (err, _data1, _data2) => {
if (err) reject(err);
else resolve({ _data1, _data2 });
});
});
doSomething(_data._data1, _data._data2);
} catch (err) {
handleError(err);
}
}
上記のような同期的記述は,制御構文の中に記述しても正しく変換できます.
for (const item of api.call(arg)) {
doSomething(item);
}
は,以下のようにコンパイルされます.
const _data = await new Promise((resolve, reject) => {
api.call(arg, (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
for (const item of _data) {
doSomething(item);
}
for (const arg of args) {
api.call(arg);
}
は,以下のようにコンパイルされます.
const _promises = [];
for (const arg of args) {
_promises.push(
(async () => {
await new Promise((resolve, reject) => {
api.call(arg, (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
})()
);
}
await Promise.all(_promises);
let sum = 0;
for (const arg of args) {
sum += api.call(arg);
}
は,for 文の外部変数に依存するため,以下のようにコンパイルされます.
let sum = 0;
for (const arg of args) {
const _data = await new Promise((resolve, reject) => {
api.call(arg, (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
sum += _data;
}
while ((data = api.call(arg)) === null) {
doSomething(data);
}
は,以下のようにコンパイルされます.
let _data = await new Promise((resolve, reject) => {
api.call(arg, (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
while ((data = _data) === null) {
doSomething(data);
_data = await new Promise((resolve, reject) => {
api.call(arg, (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
}
if (api.call(arg)) {
doSomething();
} else if (api.call2(arg)) {
doSomething2();
}
は,以下のようにコンパイルされます.
const _data = await new Promise((resolve, reject) => {
api.call(arg, (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
if (_data) {
doSomething();
} else {
let _data2 = await new Promise((resolve, reject) => {
api.call2(arg, (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
if (_data2) {
doSomething2();
}
}
switch (api.call(arg)) {
case "foo":
api.call2(arg);
break;
case "bar":
api.call3(arg);
break;
default:
break;
}
は,以下のようにコンパイルされます.
let _promise;
const _data = await new Promise((resolve, reject) => {
api.call(arg, (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
switch (_data) {
case "foo":
await new Promise((resolve, reject) => {
api.call2(arg, (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
break;
case "bar":
await new Promise((resolve, reject) => {
api.call3(arg, (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
break;
default:
break;
}
現状, map
, forEach
のみ並行動作するコードに変換されます.
args.map((arg) => api.call(arg));
args.forEach((arg) => api.call(arg));
は,以下のようにコンパイルされます.
map
はコールバック関数を非同期処理し,全てを await
しています.
一方で forEach
は 各コールバック関数の終了を全て待たずに次の行に進む ことに注意してください.
全ての iteration を終わらせて次に進みたい場合は, forEach
の代わりに for-of
文をご利用ください.
await Promise.all(args.map(async (arg) => await api.call(arg)));
args.forEach(async (arg) => await api.call(arg));
それ以外の関数は,コールバック関数内で非同期処理の終了を待つ,以下のような記述にコンパイルされます( deasync
という同期化ライブラリを用います).
import deasync from "deasync";
args.some((arg) => {
let _data;
let done = false;
new Promise((resolve, reject) => {
api.call(arg, (err, data) => {
if (err) reject(err);
else resolve(data);
});
}).then((data) => {
_data = data;
done = true;
});
deasync.loopWhile((_) => !done);
return _data;
});
同期的記述は未だにコールバック関数の指定が必要なレガシーなライブラリに対して非常に有効ですが, コードの全てを同期的記述で書く必要はありません.
今時のよくメンテされたライブラリでは,デフォルトで Promise
を返却する関数も多くなってきています.
そのような関数には await
を予め付与しておくことで,コンパイル後もそのまま正しい形で残ります.
例えば,
args.map(arg => await promisifiedFunc(arg));
という記述は,このままでは動作しませんが 「コールバック関数を引数に持つ関数 (Array.prototype.forEach 等)」と同様に以下のようにコンパイルされます.
await Promise.all(args.map(async (arg) => await promisifiedFunc(arg)));