🦕

DenoとNode.jsのモノレポでいい感じの開発環境を作りたかった

2023/12/20に公開

Deno Advent Calendar 2023の20日目の記事です。

https://qiita.com/advent-calendar/2023/deno

APIサーバー側をDeno、Webサーバー側をHono、と気になる技術を利用してモノレポ構成でアプリケーションを作ります。

タイトルで察するところはあるかもしれませんが、現時点ではDenoランタイムのアプリケーションとNode.jsランタイムのアプリケーションのモノレポ構成にはいくつか課題が見つかりました。

構成要素

APIサーバー

  • Deno
  • Fresh

Webサーバー

  • Node.js
  • React
  • Hono (予定)

※この記事ではAPIサーバー側のDenoの構成要素や設定に主に触れますが、Webサーバー側のHonoには特に触れません。というかまだ開発していません。

Deno向けWebフレームワークFreshを利用する

作成するアプリケーションは、某旧Twitterのような簡単な投稿アプリを想定しています。
ひとまず投稿一覧を取得する想定で開発をしてみます。

Fresh の公式DocsのGetting Startedの通りにプロジェクトを作成します。
https://fresh.deno.dev/docs/getting-started/create-a-project

sh
deno run -A -r https://fresh.deno.dev # api/ という名前で作成
cd api
deno task start

DBマイグレーションの作成

https://deno.land/x/nessie@2.0.11
を使います。

prismaを利用したかったのですが、現在は限定的な利用しかサポートされていないようです。
他のORMライブラリも探したのですが、めぼしいものは見つかりませんでした。

nessieの初期化

sh
deno run -A --unstable https://deno.land/x/nessie/cli.ts init --mode config --dialect mysql
mkdir db/migration

マイグレーションファイル作成

sh
deno run -A --unstable https://deno.land/x/nessie/cli.ts make:migration create_posts

マイグレーションファイルのサンプル

api/db/migrations/20231130211810_create_posts.ts
import {
  AbstractMigration,
  Info,
  ClientMySQL,
} from "https://deno.land/x/nessie@2.0.11/mod.ts";

export default class extends AbstractMigration<ClientMySQL> {
  /** Runs on migrate */
  async up(_info: Info): Promise<void> {
    await this.client.query(
      `
CREATE TABLE posts (
  uuid varchar(36) DEFAULT (uuid()) PRIMARY KEY,
  text varchar(255) NOT NULL,
  created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
`
    );
  }

  /** Runs on rollback */
  async down(_info: Info): Promise<void> {
    await this.client.query(`DROP TABLE posts;`);
  }
}

マイグレーションの実施

sh
deno run -A --unstable https://deno.land/x/nessie/cli.ts migrate

APIの仮実装

コントローラー

api/routes/api/posts/index.ts
import { HandlerContext, Handlers } from '$fresh/server.ts'
import { PostResponseService } from '../../../application/PostResponseService.ts'

export const handler: Handlers = {
  GET: async (req: Request, ctx: HandlerContext): Promise<Response> => {
    return await index(req, ctx)
  },
}

/**
 * 投稿の一覧を返す
 */
const index = async (
  _req: Request,
  _ctx: HandlerContext,
): Promise<Response> => {
  const response = await new PostResponseService().findAll()
  return new Response(JSON.stringify(response))
}

サービスクラス

api/application/PostResponseService.ts
import { Post } from '../domain/post/entities/Post.ts'
import { PostRepository } from '../infrastructure/repositories/PostRepository.ts'

export type PostResponse = {
  id: string
  text: string
  createdAt: Date
  updatedAt?: Date
}

/**
 * 投稿のAPI返却用サービス
 */
export class PostResponseService {
  /**
   * API返却用の投稿一覧を取得する
   */
  public async findAll(): Promise<PostResponse[]> {
    const posts = await new PostRepository().findAll()
    return posts.map((post) => this.convertResponse(post))
  }

  /**
   * 投稿のレスポンスをAPI返却用に変換する
   */
  private convertResponse(post: Post): PostResponse {
    return {
      id: post.getId(),
      text: post.getText().getValue(),
      createdAt: post.getCreatedAt(),
      updatedAt: post.getUpdatedAt(),
    }
  }
}

リポジトリクラス

api/infrastructure/repositories/PostRepository.ts
import { Post } from '../../domain/post/entities/Post.ts'
import { PostRepositoryInterface } from '../../domain/post/repositories/PostRepositoryInterface.ts'

/**
 * 投稿のリポジトリ
 */
export class PostRepository implements PostRepositoryInterface {
  async findAll() {
    // TODO DBから取得する処理を要実装
    return await [
      new Post('afd3nd', '投稿文章1', new Date()),
      new Post('1sns32', '投稿文章2', new Date(), new Date()),
    ]
  }
}

フォーマット周りの課題

実現したいこと

DenoアプリケーションではDeno標準で用意されているフォーマッタを利用したい。

sh
deno fmt

このフォーマッタは api/配下の deno.json に記載されている以下のルールを解釈します。

api/deno.json
{
  "fmt": {
    "lineWidth": 100,
    "semiColons": false,
    "singleQuote": true
  },
}

一方、Node.jsアプリケーションではPrettierを利用したい。

sh
npm run prettier --write

こちらは app/配下のおなじみ .prettierrc のルールを参照します。

.app/prettierrc
{
  "printWidth": 100,
  "semi": false,
  "singleQuote": true,
}

それぞれコマンドを実行してフォーマットする分には何も問題ありません。
CI/CDでもそれぞれ動かしてあげれば良いので問題はないでしょう。

VSCodeの自動整形

私はこの自動整形には頼り切っていて、もうこれがない開発は考えられません。
スペースやインデント、改行等に必要以上悩む必要はなく、それは保存時に勝手にやってくれます。
本当に楽。

.vscode/setting.json
{
  "editor.formatOnSave": true,
}

この自動整形時に使用するフォーマッタは、 "editor.defaultFormatter" で定義できます。

Denoのフォーマッタを利用する際は、

.vscode/setting.json
{
  "editor.formatOnSave": true,
  "editor.defaultFormatter": "denoland.vscode-deno",
}

Prettierを利用する際は、

.vscode/setting.json
{
  "editor.formatOnSave": true,
  "editor.defaultFormatter": "esbenp.prettier-vscode",
}

のようにします。

困りました。

このプロジェクトではDenoを使うAPIアプリケーションも、Pretterを使うWebアプリケーションもあります。プロジェクトルートの .vscode/settings.json には一体何を指定しましょう。

とれる選択肢としてはいくつか考えられます。

  1. api/とapp/に 別の .vscode/settings.json を置く
    1. に加えてそれぞれのフォルダをVSCodeのマルチワークスペースで開く
  2. Denoのフォーマッタを利用するのを諦めて共通してPrettierを使う
  3. プロジェクトルートの .vscode/settings.json をコメントアウトして開発する

順番に見てみます。

1. api/とapp/に 別の .vscode/settings.json を置く

シンプルに駄目でした。

chatGPTに聞いてもこの回答を返されたのですが、VSCodeの .vscode/settings.json はプロジェクトルートにあるものを見るため、api/とapp/に置いたところで何の意味もありませんでした。

2. 1. に加えてそれぞれのフォルダをVSCodeのマルチワークスペースで開く

一番可能性がありましたが、惜しくも駄目でした。

ワークスペースで複数のディレクトリを開く方式を取れば、それぞれのルートディレクトリに置いた .vscode/settings.json を見てくれるようです。そのため、api/配下ではDenoのフォーマッタを利用するようになったのですが、一点、問題が発生しました。フォーマッタがdeno.jsonの設定内容を見てくれなくなりました。

どこかにソース(issue)があったと思うのですが忘れてしまいました。今後のDenoのアップデートで更新されることを望むばかりです。

3. Denoのフォーマッタを利用するのを諦めて共通してPrettierを使う

まぁ、動きました。

でも折角デフォルトでフォーマッタがあるのにPretterを使うっていうのも嫌ですよね。。。
ローカルでPretterを使う以上フォーマッタを揃えるためにもCI/CDでもPretterを使うようにしなくてはいけないこともあり、採用するまでには至りませんでした。

4. プロジェクトルートの .vscode/settings.json をコメントアウトして開発する

まさかの採用したのはこちらの方法です。

モノレポでDenoとNode.jsを動かすための設定はまだ整備されていない、という結論に至ったため、ローカルの開発環境で複雑な部分を吸収する方法を取ることにしました。

つまりは私の .vscode/settings.json は以下です。

.vscode/settings.json
{
  "editor.formatOnSave": true,
  /* app側の開発を行う場合はこちらのコメントアウトを外します */
  "editor.defaultFormatter": "esbenp.prettier-vscode",

  /* api側の開発を行う場合はこちらのコメントアウトを外します */
  // "editor.defaultFormatter": "denoland.vscode-deno",

  /* deno settings */
  "deno.lint": true,
  "deno.enable": true,
  "deno.enablePaths": ["./api"],
  "deno.config": "./api/deno.jsonc"
}

まとめ

今回はいい感じの開発環境を作るには至りませんでしたが、今後のDenoのアップデートにも期待しつつこれからも触っていこうと思います。最後まで読んでいただきありがとうございました。

Discussion