Gunosy Tech Blog

Gunosy Tech Blogは株式会社Gunosyのエンジニアが知見を共有する技術ブログです。

DenoでTodoリストAPIを作ってみた

この記事はGunosy Advent Calendar 2021の10日目の記事です。

前回の記事は上村さんの「ニュース記事配信のパーソナライズロジックのオフライン実験では何を見ているのか?」でした。

はじめに

こんにちは。広告技術部のjohnmanjiro(@johnmanjiro)です。最近はシンオウ地方を旅しています。

早速ですが、みなさんはDenoをご存知でしょうか?Denoは、Node.jsの作者であるRyan Dahlによって作成された新しいJavaScript/TypeScriptのランタイムです。 Ryan氏がNode.jsで後悔していることを踏まえて開発され、2020年5月13日にv1.0がリリースされました。 webpackのようなパッケージ管理がなかったり、デフォルトでTypeScriptが使えたりするなど、Node.jsから様々な点が変更されています。

今回はアドベントカレンダーということで、以前から気になっていたDenoを使ってTODOリストを扱う簡単なAPIを作ってみました。 環境はMacを想定していて、フレームワークにはoakを使っています。

Denoに興味がある人の参考になれば幸いです。

インストール

DenoはHomebrewでインストールすることができます。

$ brew install deno

VSCodeの拡張機能

インストール

今回はVSCodeを使って開発しました。Denoを扱う際には、拡張機能を入れることをお勧めします。

marketplace.visualstudio.com

有効化

Command + Shift + P で開くウィンドウで、Deno: Initialize Workspace Configuration を実行します。 すると、.vscodeディレクトリ配下に下記のようなsettings.jsonというファイルが作成されます。

{
  "deno.enable": true,
  "deno.lint": true,
  "deno.unstable": true
}

このままの状態でも問題ないのですが、ファイルを保存した際にフォーマッタが効くように設定を追加します。 そうです、Denoには標準でフォーマッタがついてます!もう導入に手こずる必要はありません。

最終的なsettings.jsonは下記です。

{
  "deno.enable": true,
  "deno.lint": true,
  "deno.unstable": true,
  "deno.formatOnSave": true,
  "deno.defaultFormatter": "denoland.vscode-deno"
}

APIの実装

今回実装したAPIのコードはこちらで公開しています。

簡単に構成を説明すると、下記のようになります。

  • models: TODOのモデルとTODOを保存・更新するRepository
  • controllers: リクエストを受けるhandler
  • routes: ルーティングの定義
  • app: アプリケーションのエントリポイント

パッケージ管理

具体的な実装に入る前に、Denoでのパッケージ管理について述べます。

Denoにはパッケージマネージャがないため、外部パッケージを使用する際は下記のように直接URLを指定します。

// app.ts
import { Application } from "https://deno.land/x/oak/mod.ts";

しかし、複数のファイルで都度URLを指定していると、パッケージをアップグレードする際にすべてのURLを書き換える必要がでてきます。

この問題を回避するために用いられるのがdeps.tsファイル*1もしくはimport maps*2です。

deps.ts

下記のようにdeps.tsに外部パッケージをまとめることで、一箇所だけの変更でアップグレードできるようになります。

// deps.ts
export { Application, Router, Status } from "https://deno.land/x/oak/mod.ts";
export { assertEquals } from "https://deno.land/std@0.65.0/testing/asserts.ts";

実際に外部パッケージを使うコードでは下記のようにimportします。

// app.ts
import { Application } from "./deps.ts";

import maps

import mapsは下記のようなimport_map.jsonを定義することで、実際のURLとimport時に使う名前を管理することができます。 deps.tsと同様、一箇所だけの変更でアップグレードできます。

{
  "imports": {
    "oak": "https://deno.land/x/oak/",
    "testing": "https://deno.land/std@0.65.0/testing/",
  }
}

こちらの方がnpmのパッケージ名に近いかもしれませんね。 読み込み側では下記のようにimportします。

// app.ts
import { Application } from "oak/mod.ts";

今回はdeps.tsを使用しました。

modelとrepository

では実装に入っていきましょう。

まず、todo.tsにTODOを表す型を定義します。

// models/todo.ts
export interface Todo {
  title: string;
  content?: string;
  isDone: boolean;
}

さらに、TODOを管理するためのrepositoryを実装します。今回は簡単に実装したので、外部DBなどは使わずにMapを保存先として使用しています。 また、コードは簡単のために一部だけを記載しています。

// models/todoRepository.ts
import { Todo } from "./todo.ts";

export default class TodoRepository {
  private todos: Map<number, Todo>;

  constructor() {
    this.todos = new Map<number, Todo>();
  }

  all() {
    return Array.from(this.todos.values());
  }

  find(id: number) {
    return this.todos.get(id);
  }
}

controller

次に、リクエストを取り扱うためのcontrollerを実装します。各アクションで共通のTodoRepositoryを使うため、TodoControllerは関数として返しています。

// controllers/todo.ts
import { RouterContext, Status } from "../deps.ts";
import { Todo } from "../models/todo.ts";
import TodoRepository from "../models/todoRepository.ts";

export const TodoController = () => {
  const todoRepository = new TodoRepository();

  const index = (ctx: RouterContext<"/todos">) => {
    ctx.response.status = Status.OK;
    ctx.response.type = "json";
    ctx.response.body = {
      status: Status.OK,
      data: todoRepository.all(),
    };
  };

  const find = (ctx: RouterCotext<"/todos/:id">) => {
    ctx.response.type = "json";
    const todo = todoRepository.find(+ctx.params.id);
    if (!todo) {
      ctx.response.status = Status.NotFound;
      ctx.response.body = {
        status: Status.NotFound,
      };
    } else {
      ctx.response.status = Status.OK;
      ctx.response.body = {
        status: Status.OK,
        data: todo,
      };
    }
  };

  return {
    index,
    find,
  };
};

コード中のRouterContextはoakで定義されている型です。リクエストのパラメータなどを取得したり、レスポンスを設定することに使います。

router

ここまででリクエストを処理する部分ができたので、実際にAPIのエンドポイントと紐付けます。 oakではRouterを使用してルーティングを定義します。

// routes/todo.ts
import { Router } from "../deps.ts";
import { TodoController } from "../controllers/todo.ts";

const router = new Router();
const todoController = TodoController();

router
  .get("/todos", todoController.index)
  .get("/todos/:id", todoController.find);

export default router;

ここでは、TodoControllerを呼び出した後、それぞれのアクションをHTTPメソッドとエンドポイントに紐づけています。

application

最後にエントリポイントであるapplicationを実装します。

// app.ts
import { Application } from "./deps.ts"
import todos from "./routes/todos.ts"

const app = new Application();

app.use(todos.routes());

await app.listen({ port: 8080 });

useメソッドにtodos.routes()を渡すことで、ルーティングを適用しています。また、最後の行で8080ポートでサーバを立ち上げるようになっています。

実行

ここまででAPIが実装できたので、試しに起動してみましょう。 注意点として、Denoはデフォルトでセキュアに動作するため、ネットワークへのアクセスが禁止されています。したがって、実行時に明示的にネットワークアクセスを許可する必要があります。*3

$ deno run --allow-net app.ts

curlでリクエストを投げたところ、無事レスポンスが返ってきました!(TODOを作成するエンドポイントも実装しています)

f:id:johnmanjiro13:20211208162249p:plain

Denoを使ってみて

今回初めてDenoを触ってみて、いいところがたくさんあるなと思いました。その中でも特に感じたことを4つほど挙げようと思います。

1. 環境構築が楽

インストールからVSCodeの設定まで一瞬でできてしまいました。自分は普段Goを書いているので、標準でフォーマッタが準備されているのは非常に嬉しいです。

2. TypeScriptを標準で使えるのが快適

環境構築に重なりますが、普段JSを書かない自分がNode.jsにTypeScriptを導入しようとすると、なかなか時間がかかります。ですが、Denoは標準でTypeScriptに対応しているので、package.jsonと格闘したりしなくて済むのが非常に快適でした。

3. デフォルトでセキュア

前述した通り、Denoはデフォルトではネットワークやファイルへのアクセスが禁止されています。アプリケーションがそれらへアクセスするためには明示的に許可する必要があります。不要なことはできないほうがいいので、この仕様になっているのはありがたいです。

4. キャラクターがかわいい

なにより恐竜のキャラクターがかわいいです。かわいいキャラクターの言語を書いているというだけで気持ちが明るくなれますね。

ということで、とても楽しく使うことができました。deno installコマンドでCLIも公開することができるので、次はCLIを作ってみようかなと思っています。

ぜひDenoを使ってみてください!