はいさい!ちゅらデータぬオースティンやいびーん!
概要
AWS AmplifyのCLIを通して、API GatewayとLambdaを用いたRESTful APIを作る際に、サーバーレスのExpressと、TypeScriptを使う方法を紹介します。
背景
まず、サーバーレスのExpressをなぜLambdaで使うのかですが、単純に筆者は、Expressに慣れており、Expressならさっさと書けるからです。筆者と同様な作者はいるかと思いますが、いかがでしょうか?
その他に、Expressで書くと、Lambdaを脱出する必要があったら、コードを再利用することもできるので、おすすめです。
実際、Expressをサーバーレスで実行するために、オープンソースのプロジェクトがあり、現役で開発が進んでいます。それほど人気な書き方・実装方法だと筆者は捉えています。
そして、なぜTypeScriptをLambdaに導入するかですが、こちらは、コードの品質を上げるためです。型がないバックエンド開発を筆者はしたくないのです。JavaScriptだけだと、バックエンド開発においてはどうしても物足りなさを感じます。
ただ、Amplify CDKを使っているプロジェクトにおいて、どうやってTypeScriptを導入できるかが明確でないので、正式ドキュメントも混ぜて解説していきたいと思いました。
Amplifyプロジェクトをお持ちでない方は、前回の記事でまずAmplifyのプロジェク開設をしてから本記事をお読みになることをお勧めします。
RESTFul APIをAmplifyプロジェクトに追加する
まずは、Amplify CLIを使って、RESTFul APIを追加します。Amplify CLIで追加すると、テンプレートのコードを生成してくれるので、出発しやすくなります。
以下のコマンドを実行して、追加の設定を始めます。
amplify add api
最初のダイアログでREST
を選択します。
APIのリソース名を付け、APIのパスも設定します。今回は便宜的に/todos
にしますが、任意です。
次はLambda関数の関数名を設定します。
そして、Runtimeの選択では、NodeJS
を選択し、templateでは、Serverless ExpressJS
を選択します。
詳細設定はn
で大丈夫なのですが、仮にy
を入力すると、Lambda関数のLayers、Secret Managerのアクセス、環境変数など、様々な設定ができます。
ちなみに、これらの設定は後からもできるのでこの段階でやらなくてもいいです。
次、APIへのアクセスに認証を必要とするかの質問ですが、こちらはCognitoのUser Poolと併用している場合、Cognitoで認証を経ているユーザーのみAPIへアクセスできるように制限することができます。
今は制限しませんが、公開されるので、ご注意ください。
他のパスも追加するか聞かれますが、不要なので、n
すると、ローカルの設定にAPIの設定が入りましたが、AmplifyのAWSクラウドに反映されていないので、以下のコマンドを実行して反映させます。
amplify push
実行すると、今回加える変更(API GatewayとLambda関数の追加)が表示されます。y
で実行してみましょう。
成功すると、API Gatewayのエンドポイントが出力されます。
アクセスしてみるとLambda Expressのテンプレートのままのレスポンスが来ます。
ターミナルに出力されたエンドポイントに/todos
を足さないと、missing token
というエラーが返ってくるのでご注意!
これでAPIの開設は終わり、TypeScriptを導入する準備ができました。
ExpressのLambda関数にTypeScriptを追加する
これからは、上記のAPI追加で生成されたLambda関数にTypeScriptを導入していきます。ここからの説明は、Amplify正式ドキュメントを参考にしています。
Lambda関数用のpackage.json
とtsconfig.json
を作成する
まずは、TypeScriptをコンパイルするためのパッケージをインストールする必要があるので、先ほど生成されたLambda関数のダイレクトリにcd
で入りましょう。
新しいLambda関数は./amplify/backend/function
の中に入っています。筆者の場合は以下のようなコマンドを実行しました。
cd amplify/backend/function/AustinTSExpressLambdaFunc/
cd
ができたら、package.json
を作ります。
yarn init
そして、必要なパッケージをインストールします。
yarn add -D typescript @types/node @types/express @types/aws-serverless-express esbuild express body-parser aws-sdk @vendia/serverless-express
tsconfig.json
も同じダイレクトリに作成します。
touch tsconfig.json
中身は以下の推奨設定でいいかと思います。AWSのLambdaでTypeScriptを使用する方法について解説する正式ドキュメントから拝借させていただいています!
baseUrl
およびinclude
の設定では/lib
というフォルダーを指していますが、これから作ります。ここにTypeScriptのファイルを入れていきます。
{
"compilerOptions": {
"target": "es2020",
"strict": true,
"preserveConstEnums": true,
"noEmit": false,
"sourceMap": false,
"module":"commonjs",
"moduleResolution":"node",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"outDir": "./src",
"allowJs": true,
"lib": ["dom", "es6"],
"baseUrl": "./lib",
},
"exclude": ["node_modules"],
"include": ["./lib/**/*.ts"],
}
/lib
に既存のExpressの.js
ファイルを移動し、.ts
に変換する
次に行うのは、テンプレートで生成されたExpressの.js
ファイルを/lib/*.ts
に移動しつつ、拡張子を変えることです。
mkdir lib
mv src/*.js lib/
cd lib
for f in *.js; do mv -- "$f" "${f%.js}.ts"; done
cd ..
二つのファイルのために上記のようなコマンドはOver killだと思いますが...それはさておき、これでは必要なファイルをsrc
から抽出できたので、残っているファイルは削除しましょう。
src
のフォルダー自体は必要なので、再度作っておきます。
rm -rf src/
mkdir src
ExpressのテンプレートのTypeScriptエラーを解消する
これからは、テンプレートのファイルをTypeScriptらしい書き方に直すのと、エラーを解消していきます。
元々あったapp.jsからコメントを削除すればこうなります。
const express = require('express')
const bodyParser = require('body-parser')
const awsServerlessExpressMiddleware = require('aws-serverless-express/middleware')
const app = express()
app.use(bodyParser.json())
app.use(awsServerlessExpressMiddleware.eventContext())
app.use(function(req, res, next) {
res.header("Access-Control-Allow-Origin", "*")
res.header("Access-Control-Allow-Headers", "*")
next()
});
app.get('/todos', function(req, res) {
res.json({success: 'get call succeed!', url: req.url});
});
app.get('/todos/*', function(req, res) {
res.json({success: 'get call succeed!', url: req.url});
});
app.post('/todos', function(req, res) {
// Add your code here
res.json({success: 'post call succeed!', url: req.url, body: req.body})
});
app.post('/todos/*', function(req, res) {
// Add your code here
res.json({success: 'post call succeed!', url: req.url, body: req.body})
});
app.put('/todos', function(req, res) {
// Add your code here
res.json({success: 'put call succeed!', url: req.url, body: req.body})
});
app.put('/todos/*', function(req, res) {
// Add your code here
res.json({success: 'put call succeed!', url: req.url, body: req.body})
});
app.delete('/todos', function(req, res) {
// Add your code here
res.json({success: 'delete call succeed!', url: req.url});
});
app.delete('/todos/*', function(req, res) {
// Add your code here
res.json({success: 'delete call succeed!', url: req.url});
});
app.listen(3000, function() {
console.log("App started")
});
module.exports = app
これを以下のように修正します。
import express from "express";
import bodyParser from "body-parser";
const app = express();
app.use(bodyParser.json());
app.use(function (req, res, next) {
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Headers", "*");
next();
});
app.get("/todos", function (req, res) {
res.json({ success: "get call succeed!", url: req.url });
});
app.get("/todos/*", function (req, res) {
res.json({ success: "get call succeed!", url: req.url });
});
app.post("/todos", function (req, res) {
res.json({ success: "post call succeed!", url: req.url, body: req.body });
});
app.post("/todos/*", function (req, res) {
res.json({ success: "post call succeed!", url: req.url, body: req.body });
});
app.put("/todos", function (req, res) {
res.json({ success: "put call succeed!", url: req.url, body: req.body });
});
app.put("/todos/*", function (req, res) {
res.json({ success: "put call succeed!", url: req.url, body: req.body });
});
app.delete("/todos", function (req, res) {
res.json({ success: "delete call succeed!", url: req.url });
});
app.delete("/todos/*", function (req, res) {
res.json({ success: "delete call succeed!", url: req.url });
});
app.listen(3000, function () {
console.log("App started");
});
export default app;
index.js
で以下のようになっていますが、こちらも修正します。
const awsServerlessExpress = require('aws-serverless-express');
const app = require('./app');
/**
* @type {import('http').Server}
*/
const server = awsServerlessExpress.createServer(app);
/**
* @type {import('@types/aws-lambda').APIGatewayProxyHandler}
*/
exports.handler = (event, context) => {
console.log(`EVENT: ${JSON.stringify(event)}`);
return awsServerlessExpress.proxy(server, event, context, 'PROMISE').promise;
};
aws-serverless-express
ではなく、元々の@vendia/serverless-express
を使うようにしていますが、これはAWSに依存するものを減らすためです。
import serverlessExpress from "@vendia/serverless-express";
import app from "./app";
exports.handler = serverlessExpress({ app });
これで全てのエラーは解消されているので、ビルド手順に入ります!
esbuild
でLambda関数のTypeScriptコードをコンパイルする
ここまでくると、あとはビルドするのみ!
AWS正式ドキュメントでは、esbuildの使用が推奨されているので、こちらでもそうします。
先ほど、新しく作成したpackage.json
のscripts
に以下のコマンドを追加します。
{
"name": "AustinTSExpressLambdaFunc",
"version": "1.0.0",
"main": "src/index.js",
"license": "MIT",
"devDependencies": {
"@types/aws-serverless-express": "^3.3.5",
"@types/express": "^4.17.13",
"@types/node": "^18.6.3",
"@vendia/serverless-express": "^4.10.1",
"aws-sdk": "^2.1185.0",
"body-parser": "^1.20.0",
"esbuild": "^0.14.51",
"express": "^4.18.1",
"typescript": "^4.7.4"
},
"scripts": {
"build": "esbuild ./lib/index.ts --bundle --minify --external:aws-sdk --sourcemap --platform=node --target=es2020 --outfile=./src/index.js"
}
}
ここで重要なのは、package.json
ではaws-sdk
のパッケージをインストールしていますが、これはTypeScriptのインタプリターのためのみで、バンドルに含めたくないことです。
それゆえに、esbuildのコマンドで--external:aws-sdk
と、バンドルから排除しています。
なぜかというと、aws-sdk
はLambda関数のNodeJS環境なら、インストールせずに使うことができます。また、含めると、ものすごく大きくなり、重くなります。
デプロイできるようにする
最後に、amplify push
のコマンドを実行した時に、自動的にビルドしてくれるようにします。
ルート・ダイレクトリーのpackage.json
にscript
を追加する
Amplifyプロジェクトのルートダイレクトリにある./package.json
に特別なscript
コマンドを追加する必要があります。
ありがたいことに、正式ドキュメントにそのやり方が書いてあります。
しかし、注意していただきたいことがあります。複数のpackage.json
があります。これから追加するscript
は、Lambda関数のamplify/backend/function/AustinTSExpressLambdaFunc/package.json
ではなく、ルートダイレクトリーにある主要な./package.json
に加えなければなりません。
筆者は、これを理解できずに数時間を無駄にしながらも懸命にパソコンとAmazonを憎み続けました。
{
"name": "amplify-qiita",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"scripts": {
"build": "webpack --config webpack.prod.config.js",
"start": "webpack serve",
"amplify:AustinTSExpressLambdaFunc": "cd amplify/backend/function/AustinTSExpressLambdaFunc/ && yarn build"
},
"devDependencies": {
"@babel/core": "^7.18.9",
"@babel/preset-env": "^7.18.9",
"babel-loader": "^8.2.5",
"file-loader": "^6.2.0",
"html-webpack-plugin": "^5.5.0",
"mini-css-extract-plugin": "^2.6.1",
"ts-loader": "^9.3.1",
"typescript": "^4.7.4",
"webpack": "^5.74.0",
"webpack-cli": "^4.10.0",
"webpack-dev-server": "^4.9.3",
"webpack-manifest-plugin": "^5.0.0"
}
}
amplify:<Lambda関数名>
という名前のscriptを追加し、先ほど追加したbuild
のコマンドを実行するようにします。
Amplifyのバックエンドをプッシュしてみる
Lambda関数のソースコードを自動的にコンパイルしてくれる設定ができたので、うまくデプロイできるか試してみましょう。
amplify push
Lambda関数のFunction
の変更を検知してくれているので、y
を入力してプッシュします。
問題発生!M1 Macbookをご利用の皆様ご注意を。
筆者はApple SiliconのARMチップで開発していますが、上記のコマンドを実行すると以下のようなエラーが出ます。
原因は、Amplify CLI
にあります。
筆者の場合は、Amplify CLIがIntel
のプロセスでRosetta
に翻訳されながら実行されており、darwin-64
の環境になっているのに対して、ローカルで実行するyarn
は、M1ネイティブでdarwin-arm64
の環境になっているのです。
これでバグるわけです。
対応策は以下のように、package.json
のscript
にインストール済みのパッケージを削除して再インストールするコマンドを追加することです。
{
"name": "amplify-qiita",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"scripts": {
"build": "webpack --config webpack.prod.config.js",
"start": "webpack serve",
"amplify:AustinTSExpressLambdaFunc": "cd amplify/backend/function/AustinTSExpressLambdaFunc/ && rm -rf node_modules && yarn install && yarn build"
},
"devDependencies": {
"@babel/core": "^7.18.9",
"@babel/preset-env": "^7.18.9",
"babel-loader": "^8.2.5",
"file-loader": "^6.2.0",
"html-webpack-plugin": "^5.5.0",
"mini-css-extract-plugin": "^2.6.1",
"ts-loader": "^9.3.1",
"typescript": "^4.7.4",
"webpack": "^5.74.0",
"webpack-cli": "^4.10.0",
"webpack-dev-server": "^4.9.3",
"webpack-manifest-plugin": "^5.0.0"
}
}
上記の変更を加えた上でもう一度amplify push
を実行してみます。
無事に成功しました!よかったさ
M1、好きだけど、時々困りますねぇ。
エンドポイントにアクセスしてみる
デプロイできたわけですので、エンドポイントにもう一度アクセスしてみましょう。問題なくデプロイできていれば、依然と全く同じレスポンスが帰ってくるはずです。
おお!TypeScript、使えるぞ!かりゆし
Lambda関数のコンソールも確認してみると、esbuildらしいコードがindex.js
に入っています。
まとめ
これまで、Amplify CLIでRESTful APIを作り、Expressを使ったLambda関数のソースコードをTypeScriptに変換して、それをamplify push
でコンパイルできるようにしてきましたが、いかがでしょうか?
やはり、TypeScriptは筆者の幸せの源ですので、どこでもいつでも便利に使えるようにしたいのですが、Amplifyのバックエンドでも使えるのはこの上なく喜ばしいことです。
筆者と同じくTypeScript愛好家もいらっしゃると思いますので、どんどん色んなプロジェクトに入れていきましょう!