Denoの学習用にRESTfulなAPIサーバーを作ってみたのでまとめます。
今回作るもの
DenoとServestを使ってRESTfulなAPIサーバーを作り、GCPのCloud Runへデプロイするまでです。
(GIFではcURLで実行していますが、もちろんプラウザでも確認できます)
今回記載するサンプルコードは全て以下GitHubリポジトリにあります。もし、動かなければこちらを参照してみてください。
Denoとは?
Denoは、Node.jsに変わる新しいJavaScriptのランタイムです。
ネイティブでTypeScriptをサポートしています。また、package.json
での依存管理は不要で、URLから直接パッケージをimportしてキャッシュに保存します。ローカルに巨大な node_modules
を持つ必要もないです。
その他詳細な情報は以下にとても分かりやすく記載されていました。
- The Deno Handbook: A TypeScript Runtime Tutorial with Code Examples
- Denoとはなにか - 実際につかってみる - Qiita
- Denoの登場でNode.jsの時代は終わるのか? - Qiita
Hello Deno 🦕
最初にDenoのインストールから、簡単なスクリプトの実行までを行います。
まず、HomebrewでDenoをインストールします。
brew install deno
これだけで完了です。
早速、実行してみましょう。
$ deno run https://deno.land/std/examples/welcome.ts
Welcome to Deno 🦕
「Welcome to Deno 🦕」 と表示されれば成功です。
ちなみにwelcome.tsの中身は、console.log('Welcome to Deno 🦕')
だけです。
https://deno.land/std/examples/welcome.ts
ServestでHello World!
今回はstdのHttpライブラリではなく、DenoでプログレッシブなHTTPサーバーを構築することができるServestを使います。
任意のディレクトリにmain.tsを作りましょう。
import {
createApp,
} from "https://servestjs.org/@v1.0.0/mod.ts";
const app = createApp();
app.handle("/", async (req) => {
await req.respond({
status: 200,
headers: new Headers({
"content-type": "text/plain",
}),
body: "hello World!",
});
});
app.listen({ port: 8888 });
実行してみます。
$ deno run --allow-net app/main.ts
I[2020-05-23T12:13:59.669Z] servest:router listening on :8888
http://localhost:8888 にアクセスすると、
hello world
と表示されるはずです。
コードをみると、createApp()
でappを作り、app.handle("/", async ...)
でリクエストをハンドリング、req.respond({...})
でレスポンスを返しています。
そして、app.listen({ port: 8888 })
でport:8888でリクエストを待っているのが分かります。
Node.jsのExpressのような書き味ですね。
RESTfulなAPIサーバーの構築
簡単なブログ投稿システムをイメージして、RESTfulなAPIサーバーを作ってみます。
Modelの作成
まずModelを作ります。
app/models
に以下post.ts
を作成しましょう。
import { v4 } from "https://deno.land/std/uuid/mod.ts";
export class Post {
public id: string;
public createdAt: string
constructor(public title: string, public content: string) {
this.id = v4.generate();
this.createdAt = (new Date()).toLocaleDateString()
}
toJson() {
return {
id: this.id,
title: this.title,
content: this.content,
createdAt: this.createdAt
};
}
}
// DBでの永続化はせずインメモリで管理
export const posts = [] as Post[];
// postsの初期記事追加
export const initializedPosts = () => {
posts.push(new Post('Hello Blog', 'My first post.'))
}
initializedPosts();
記事のモデルとなるPostクラスを定義しています。
今回は説明を簡易にするため、インメモリでpostsを保持し、最初のサンプル記事を追加する処理を書いています。
Controllerの作成
続いて、リクエストごとのハンドリングを行うControllerを作成します。
app/controllers
にpostController.ts
を作成します。
import {
ServerRequest,
} from "https://servestjs.org/@v1.0.0/mod.ts";
import { posts, Post } from "../models/post.ts";
type PostPayload = {
id: string;
title: string;
content: string;
};
export const getAllPosts = async (req: ServerRequest) => {
await req.respond({
status: 200,
headers: new Headers({
"content-type": "application/json",
}),
body: JSON.stringify(posts),
});
};
export const getPost = async (req: ServerRequest) => {
const [_, id] = req.match;
await req.respond({
status: 200,
headers: new Headers({
"content-type": "application/json",
}),
body: JSON.stringify(posts.find((p) => p.id === id)),
});
};
export const createPost = async (req: ServerRequest) => {
const bodyJson = (await req.json()) as PostPayload;
const post = new Post(bodyJson.title, bodyJson.content);
posts.push(post);
await req.respond({
status: 200,
headers: new Headers({
"content-type": "application/json",
}),
body: JSON.stringify(post),
});
};
export const deletePost = async (req: ServerRequest) => {
const [_, id] = req.match;
const index = posts.findIndex((p) => p.id === id);
posts.splice(index, 1);
await req.respond({
status: 204,
});
};
export const updatePost = async (req: ServerRequest) => {
const [_, id] = req.match;
const bodyJson = (await req.json()) as PostPayload;
const index = posts.findIndex((p) => p.id === id);
posts[index].title = bodyJson.title;
posts[index].content = bodyJson.content;
await req.respond({
status: 200,
headers: new Headers({
"content-type": "application/json",
}),
body: JSON.stringify(posts[index]),
});
};
先ほど作成したPostモデルから、posts
をimportしてそれぞれの関数でpostsを処理しています。
関数名の通り、記事のCRUD処理を行っています。
updatePost
やdeletePost
では、const [_, id] = req.match;
でリクエストのURLパラーメータから、idを取得しています。
レスポンスは、req.respond()
でstatus
、headers
、body
を持つオブジェクトを返すだけです。
Routerの作成
次に、ControllerをURLにマッピングするRouterを作成します。
app/router.ts
を作成しましょう。
import {
createRouter,
contentTypeFilter,
} from "https://servestjs.org/@v1.0.0/mod.ts";
import {
getAllPosts,
getPost,
createPost,
deletePost,
updatePost,
} from "./controllers/postsController.ts";
export const routes = () => {
const router = createRouter();
router.get("posts", getAllPosts);
router.get(new RegExp("^posts/(.+)"), getPost);
router.post("posts", contentTypeFilter("application/json"), createPost);
router.put(
new RegExp("^posts/(.+)"),
contentTypeFilter("application/json"),
updatePost,
);
router.delete(new RegExp("^posts/(.+)"), deletePost);
return router;
};
createRouter()
でrouterのインスタンスを作成して、router.get(..)
などでURLと、処理をマッピングしています。
処理自体は、先ほど書いたControllerからimportした関数です。
routerの関数名が、post, delete, putなどのHTTPリクエストに対応しています。
new RegExp("^posts/(.+)")
で正規表現でリクエストを絞ることも可能です。
ここでは、記事更新のPUTリクエストと、記事削除のDELETEリクエストでURLパラメータを受け取るために利用しています。
Appの作成
最後に、main.ts
でAppのエントリーポイントを作成します。
最初のHello worldを以下の用に書き換えましょう。
import { createApp } from "https://servestjs.org/@v1.0.0/mod.ts";
import { routes } from "./router.ts";
const app = createApp();
app.route("/", routes());
app.listen({ port: 8080 });
createApp()
でAppを作成し、app.route()
で先ほど定義したrouterを渡してURLをマッピングしています。
これで準備は完了です。
実行
サーバーを起動してみます。
$ deno run --allow-net src/main.ts
以下でCRUD処理を試してみてください。
記事一覧の取得
$ curl --request GET \
--url http://localhost:8080/posts
個別記事の取得
$ curl --request GET \
--url http://localhost:8080/posts/{POST_ID}
記事の投稿
$ curl --request POST \
--url http://localhost:8080/posts \
--header 'content-type: application/json' \
--data '{"title": "sample","content": "Laborum mollit duis ad consequat."}'
記事のアップデート
$ curl --request PUT \
--url http://localhost:8080/posts/{POST_ID} \
--header 'content-type: application/json' \
--data '{"title": "update","content": "Labore minim sit et id aliquip ad voluptate nisi mollit incididunt id irure enim."}'
記事の削除
$ curl --request DELETE \
--url http://localhost:8080/posts/{POST_ID}
無事RESTfulなAPIサーバーが作成できましたね 🎉
Cloud Runへのデプロイ
最後にここまでで作ったアプリをCloud Runにデプロイします。
Cloud Runはサーバーレスでフルマネージドなコンテナ実行環境です。
以降の説明は、gcloud cliや、GCPプロジェクトの作成は完了している前提で進めます。
まず、ワークスペースにDockerfileを作成します。
ベースとなるImageはこちらのリポジトリのものです。
FROM hayd/alpine-deno:1.0.1
ENV PORT 8080
ENV HOST 0.0.0.0
WORKDIR /app
USER deno
COPY . .
CMD ["run", "--allow-net", "--allow-env", "app/main.ts"]
次に、cloud-runの設定ファイルcloud-build.ymlを作成します。
steps:
- name: gcr.io/cloud-builders/docker
args: ['build','-f','Dockerfile','-t','gcr.io/qiita-demo-project/deno-example','.']
images: ['gcr.io/qiita-demo-project/deno-example']
qiita-demo-project
の部分は、GCPのプロジェクト名。
deno-example
の部分は、今回Container Repositoryへ登録するコンテナ名です。
あとはgcloud cliを使ってデプロイします。
まずワークスペースでプロジェクトの初期化を行います。
initで設定するプロジェクトは、先ほどcloud-build.ymlに書いたプロジェクト名を選択してください。
$ gcloud auth login
$ gcloud init
gcloudのプロジェクト設定が終わったらいよいよデプロイ作業です。
まず、gcloud build
コマンドで、Docker ImageをContainer Repositoryに登録します。
# --projectの部分は適宜変更してください
$ gcloud builds submit --project "qiita-demo-project" --config=./cloud-build.yml
成功すると、GCPのContainer RepositoryにImageが登録されているはずです。
そして、登録したコンテナをCloud Runにデプロイします。
# --project, --imageの部分は適宜変更してください
$ gcloud beta run deploy qiita-demo \
--region us-central1 --allow-unauthenticated --project \
"qiita-demo-project" --image gcr.io/qiita-demo-project/deno-example
target platformの選択肢が出たら [1] Cloud Run (fully managed
を選択してください。
成功すると最後にURLが出力されるはずです。
GCPのCloud Runコンソールも確認してみましょう。
無事アクセス出来ましたか?
これでCloud Runへのデプロイも完了です 🎉
終わりに
以上、「Deno🦕でRESTfulなAPIサーバーを立ててCloud Runへデプロイ」でした。本当に触りまでしか使ってませんが、Denoはとても可能性感じますね。
今後もちょこちょこ使っていきたいです。
Denoで困ったときは、日本のDenoコミュニティのSlackに参加し質問するととても親切に教えてくれるのでこちらもおすすめです。