Remixアプリケーションをホストするには?

Back4app Containers Remix カバー

この記事では、Remixを使い始めるために必要な知識を全て学びます。Remixの長所と短所、Remixアプリの作り方、そして最後にRemixのデプロイ方法を紹介します。

Remixとは?

Remixは、高速で洗練された、弾力性のあるアプリケーションを構築するための最新のフルスタックウェブフレームワークです。サーバーサイドレンダリング(SSR)のパワーを活用し、動的なWebアプリケーションのロード時間の短縮とSEOの改善を可能にします。

Remixを使えば、開発者は統一されたコードベースの中でクライアントサイドとサーバーサイドのロジックをシームレスに統合することができる。他の人気のあるReactフレームワークと比較して、Remixは多くの点で異なっています。それには以下が含まれる:

  1. ファイルベースのルーティングroutesディレクトリ内のファイルが特定のルートを表す)
  2. 従来のフォーム処理(HTMLとHTTPリクエストを使用、PHPと同様)
  3. 状態管理(状態はサーバーにのみ保存される)
  4. データロード(コンポーネントから切り離される)

Remixは2020年に誕生し、当初は年間ライセンス料で利用可能だった。その後、2021年10月にRemixチームはプロジェクトのオープンソース化を決定。現在はMITライセンスで利用可能だ。

2022年末、Remixは21億ドルでShopifyに買収された。

Remixのメリット

Remixフレームワークを使う主な利点を見てみよう。

ファイルベースのナビゲーション

Remixは、強力なクライアントサイドルーティングソリューションであるReact Routerの上に構築されています。実際、RemixはReact Routerを作ったのと同じ開発チームによって作られました。

フレームワークはファイルベースのナビゲーションシステムを利用し、コード編成を単純化する。開発者はルート、コンポーネント、リソースを特定のファイルやディレクトリに関連付けることができます。

例を挙げよう:

app/
└── routes/
    ├── $noteId.tsx              // matches: /<noteId>/
    ├── $noteId_.destroy.tsx     // matches: /<noteId>/destroy
    ├── $noteId_.edit.tsx        // matches: /<noteId>/edit
    ├── _index.tsx               // matches: /
    └── create.tsx               // matches: /create

そして何より、以下のようなネストされたルーティングをサポートしています。 .その結果、ロード時間が短縮され、エラー処理などが改善される!

サーバーサイド・レンダリング(SSR)

Remixはサーバーサイド・レンダリングのパワーを活用しています。

従来のReactアプリでは、データは通常クライアントサイドで取得され、その後DOMに注入される。これはクライアントサイド・レンダリング(CSR)として知られている。

一方、Remixは異なるアプローチを取る。まずバックエンドでデータを取得し、取得したデータでHTMLをレンダリングし、それをクライアントに提供する。

サーバーサイド・レンダリングは、パフォーマンスが向上し、よりSEOに適したアプリになる傾向がある。

フォーム処理

Remixは、フォーム・ハンドリングを基本に立ち返らせる。

たくさんの制御されたコンポーネントやJavaScriptを使う代わりに、伝統的なHTMLフォームとHTTPリクエストを使っている。

フォームが送信されると、特定のルートにHTTPリクエストが送信され、サーバー側でaction()関数を使って処理されます。これは古き良きPHPと同じように動作します。

つまり、Remixはフォームの処理にJavaScriptを全く必要としません。これは素晴らしいことですが、フォームのバリデーションやエラー表示が少し難しくなるかもしれません。

国家経営

ここでいうステートとは、サーバーとクライアントのデータを同期させることである。

Remixはステート管理を簡単にします。Redux、Zustand、React Query、Apollo、その他のクライアントサイドの状態管理ライブラリが不要になります。

Remixを使用する場合、すべての状態はサーバーによって処理される。そのため、同期処理は必要ありません。

サーバーからクライアントへのデータの受け渡しは、loader()action()といったさまざまな関数を通じて行われる。

トランジションと楽観的UI

Remixトランジションは、事前にデータを読み込み、アニメーションを使用することで、ページ間の移動をスムーズかつ高速にします。

オプティミスティックUIは、ユーザーのアクションを即座に反映し、変更を確認する前に表示することで、ウェブサイトの応答性を高める。

トランジションと楽観的なUIは、知覚される待ち時間を減らし、即座にフィードバックを提供することで、ユーザーエクスペリエンスを大幅に向上させる。

Remixの限界

Remixは素晴らしいが、いくつかの制限がある。

コンプレックス

Remixは最も使いやすいフレームワークではないし、ドキュメントも(執筆時点では)ベストではない。

また、このフレームワークは、従来のReactとは異なる多くのことを行う。React開発者であれば、Remixの様々なコンセプトを理解するのに時間がかかるかもしれない。

人気薄

Remixはまだ比較的新しいフレームワークだ。公開は2021年10月にさかのぼる。Next.jsのような競合と比べると、成熟度は低く、実戦的でもありません。

GitHubのスターを見ると、Remix(27kスター)はNext.js(120kスター)よりずっと人気がないことがわかる。この記事を書いている時点では、RemixはShopify以外のテック大手には採用されていない。

密な結合

Remixアプリはフロントエンドとバックエンドが緊密に結合しています。このアプローチは小規模なプロジェクトでは有効ですが、フロントエンドとバックエンドを分離した柔軟性を好む開発者もいます。

さらに、フロントエンドとバックエンドを分離することで、コードの保守性を高め、テストを容易にすることができる。

SSG、ISRなし

Remixは静的サイト生成(SSG)や増分静的再生(ISR)をサポートしていません。

Remixアプリケーションのデプロイ方法は?

このセクションでは、Remixアプリケーションをビルドしてデプロイします。

前提条件

フォローするために必要なもの

  • TypeScriptの基本的な理解
  • Docker(およびコンテナ化技術)の使用経験
  • Node.jsとJavaScript IDEをマシンにインストールする。
  • マシンにインストールされたDocker Desktop

プロジェクト概要

この記事を通して、私たちはノートのウェブ・アプリに取り組むことになる。このウェブアプリでは、ユーザーがノートを作成、取得、編集、削除できるようにします。

バックエンドにはBack4app BaaSを利用し、フロントエンドにはRemixフレームワークを使う。フロントエンドをコーディングしたら、Back4app Containersにデプロイする。

最終的にはこのようになる:

Back4appリミックスノート

まずはノートのウェブ・アプリを見ながら進めることをお勧めする。この記事を読めば、自分のRemixアプリケーションをデプロイできるようになるはずだ。

バックエンド

このセクションでは、Back4appを使ってアプリのバックエンドを構築する。

アプリ作成

まずはBack4appアカウントにログインしてください。

サインインすると、”My Apps “ポータルにリダイレクトされます。バックエンドを作成するには、まずBack4appアプリを作成する必要があります。それには “Build new app “をクリックします。

Back4app Apps Dashboard

Back4appは2つのソリューションを提供しています:

  1. Backend as a Service (BaaS) — 本格的なバックエンド・ソリューション
  2. Containers as a Service (CaaS) — Dockerベースのコンテナ・オーケストレーション・プラットフォーム

バックエンドで作業しているので、”Backend as a Service “を選択する。

Back4app BaaS Create

アプリに名前を付け、他はデフォルトのままにして、「Create」をクリックします。

Back4app Create アプリの詳細

プラットフォームは、バックエンドに必要なすべてをセットアップするのに時間がかかる。これには、データベース、アプリケーション・インターフェース、スケーリング、セキュリティなどが含まれる。

アプリの準備ができたら、アプリのリアルタイムデータベースビューにリダイレクトされます。

Back4appデータベースビュー

データベース

続いて、データベースを扱おう。

今回は比較的シンプルなアプリを作るので、必要なモデルは1つだけだ。Noteを作成するには、画面左上の “Create a class “ボタンをクリックします。

名前をNoteとし、”Public Read and Write Enabled “を有効にし、”Create class & add column “をクリックする。

Back4app データベースクラスの作成

次に、以下の列を追加する:

+-----------+--------------+----------------+----------+
| Data type | Name         | Default value  | Required |
+-----------+--------------+----------------+----------+
| String    | emoji        | <leave blank>  | yes      |
+-----------+--------------+----------------+----------+
| String    | title        | <leave blank>  | yes      |
+-----------+--------------+----------------+----------+
| File      | content      | <leave blank>  | no       |
+-----------+--------------+----------------+----------+

クラスを作成したら、データベースにサンプルデータを入力します。絵文字、タイトル、内容を入力して、いくつかのノートを作成します。または、このデータベースのエクスポートをインポートします。

Back4appデータベースへの登録

最高だ!

コードを書かずにバックエンドを作ることに成功した。

Backend as a Serviceの詳細については、Backend as a Serviceとは?

フロントエンド

この記事のセクションでは、Remixフレームワークを使ってアプリのフロントエンドを構築する。

クリエート・Remix

Remixプロジェクトをブートストラップする最も簡単な方法は、create-remixユーティリティを使うことです。このツールは本番用のRemixプロジェクトを作成します。

ディレクトリ構造や依存関係の管理、バンドラーのセットアップなどを行う。

以下のコマンドを実行して、新しいRemixプロジェクトを作成します:

$ npx create-remix@latest remix-notes

Initialize a new git repository? Yes
Install dependencies with npm?   Yes

create-remixを使ったことがなければ、自動的にインストールされる。

プロジェクトが作成されたら、アクティブ・ディレクトリをそのプロジェクトに変更する:

$ cd remix-notes

最後に、開発サーバーを起動する:

$ npm run dev

お気に入りのウェブブラウザを開き、http://localhost:5173。デフォルトのRemixランディングページが表示されます。

TailwindCSS

私たちの生活を少し楽にするために、TailwindCSSを使おう。TailwindCSSはユーティリティ・ファーストのフレームワークで、プレーンなCSSを書くことなくフロントエンドを素早く構築できる。

まず、npmを使ってインストールする:

$ npm install -D tailwindcss postcss autoprefixer

次に、tailwindcss initを実行する:

$ npx tailwindcss init --ts -p

これでプロジェクトが設定され、プロジェクトルートにtailwind.config.tsファイルが作成されます。

ユーティリティ・クラスがどのファイルで使われるかをTailwindに知らせるために、contentプロパティをこのように変更する:

// tailwind.config.ts

import type {Config} from "tailwindcss";

export default {
  content: ["./app/**/*.{js,jsx,ts,tsx}"],  // new
  theme: {
    extend: {},
  },
  plugins: [],
} satisfies Config;

appディレクトリにtailwind.cssという新しいファイルを以下の内容で作成する:

/* app/tailwind.css */

@tailwind base;
@tailwind components;
@tailwind utilities;

最後に、リンクを通して root.tsxにインポートする:

// app/root.tsx

// other imports
import {LinksFunction} from "@remix-run/node";
import stylesheet from "~/tailwind.css?url";

export const links: LinksFunction = () => [
  { rel: "stylesheet", href: stylesheet },
];

// ...

それだけだ!

TailwindCSSのインストールに成功しました。

ルート

我々のウェブ・アプリは以下のエンドポイントを持つ:

  1. / すべての音符を表示
  2. /ユーザーがノートを作成できるようにする
  3. /特定のメモを表示
  4. //deleteは、ユーザーが特定のメモを削除できるようにする。

これらのルートを定義するには、appフォルダ内に以下のようなディレクトリ構造を作成する:

app/
└── routes/
    ├── $noteId.tsx
    ├── $noteId_.destroy.tsx
    ├── $noteId_.edit.tsx
    ├── _index.tsx
    └── create.tsx

お察しの通り、動的パラメーターには接頭辞$が使われ、/の代わりに.が使われる。

ビュー

続いて、ビューを実装してみよう。

コードをより型安全にするために、Noteデータベース・クラスに似たNoteという名前のインターフェイスを作成する。

storeという名前のフォルダを作成し、その中に以下の内容のNoteModel.tsを作成する:

// app/store/NoteModel.ts

export default interface NoteModel {
  objectId: string;
  emoji: string;
  title: string;
  content: string;
  createdAt: Date;
  updatedAt: Date;
}

次に、_index.tsx$noteId.tsxcreate.tsxのビューコードを貼り付けます:

// app/routes/_index.tsx

import {Link, NavLink} from "@remix-run/react";
import NoteModel from "~/store/NoteModel";

const notes = [
  {objectId: "1", emoji: "📝", title: "My First Note"},
  {objectId: "2", emoji: "📓", title: "My Second Note"},
  {objectId: "3", emoji: "📔", title: "My Third Note"},
] as NoteModel[];

export default function Index() {
  return (
        <>
      <Link to={`/create`}>
        <div className="bg-blue-500 hover:bg-blue-600 text-lg font-semibold text-white
         px-4 py-3 mb-2 border-2 border-blue-600 rounded-md"
        >
          + Create
        </div>
      </Link>
      {notes.map(note => (
        <NavLink key={note.objectId} to={`/${note.objectId}`}>
          <div className="hover:bg-slate-200 text-lg font-semibold
            px-4 py-3 mb-2 border-2 border-slate-300 rounded-md"
          >
            {note.emoji} {note.title}
          </div>
        </NavLink>
      ))}
    </>
  );
}
// app/routes/$noteId.tsx

import {Form} from "@remix-run/react";
import NoteModel from "~/store/NoteModel";

const note = {
  objectId: "1", emoji: "📝", title: "My First Note", content: "Content here.",
  createdAt: new Date(), updatedAt: new Date(),
} as NoteModel;

export default function NoteDetails() {
  return (
    <>
      <div className="mb-4">
        <p className="text-6xl">{note.emoji}</p>
      </div>
      <div className="mb-4">
        <h2 className="font-semibold text-2xl">{note.title}</h2>
        <p>{note.content}</p>
      </div>
      <div className="space-x-2">
        <Form
          method="post" action="destroy"
          onSubmit={(event) => event.preventDefault()}
          className="inline-block"
        >
          <button
            type="submit"
            className="bg-red-500 hover:bg-red-600 font-semibold text-white
              p-2 border-2 border-red-600 rounded-md"
          >
            Delete
          </button>
        </Form>
      </div>
    </>
  );
}
// app/routes/create.tsx

import {Form} from "@remix-run/react";

export default function NoteCreate() {
  return (
    <>
      <div className="mb-4">
        <h2 className="font-semibold text-2xl">Create Note</h2>
      </div>
      <Form method="post" className="space-y-4">
        <div>
          <label htmlFor="emoji" className="block">Emoji</label>
          <input
            type="text" id="emoji" name="emoji"
            className="w-full border-2 border-slate-300 p-2 rounded"
          />
        </div>
        <div>
          <label htmlFor="title" className="block">Title</label>
          <input
            type="text" id="title" name="title"
            className="w-full border-2 border-slate-300 p-2 rounded"
          />
        </div>
        <div>
          <label htmlFor="content" className="block">Content</label>
          <textarea
            id="content" name="content"
            className="w-full border-2 border-slate-300 p-2 rounded"
          />
        </div>
        <div>
          <button
            type="submit"
            className="bg-blue-500 hover:bg-blue-600 font-semibold
              text-white p-2 border-2 border-blue-600 rounded-md"
          >
            Create
          </button>
        </div>
      </Form>
    </>
  );
}

このコードでは、派手なことは何もしていない。JSXとTailwindCSSを組み合わせてユーザーインターフェースを作っただけです。

お気づきかもしれないが、すべてのコンポーネントは制御されていない(useState()を使っていない)。その上、実際のHTMLフォームを使っています。

これは、RemixフレームワークがReactとは異なり、HTTPリクエストを使ってPHPと同様にフォームを処理するためだ。

Parse

Back4appベースのバックエンドに接続する方法は複数あります。以下の方法があります:

  1. RESTful API
  2. GraphQL API
  3. Parse SDK

最も簡単で堅牢な方法は、Parse SDKです。Parse SDK はソフトウェア開発キットで、データを簡単に照会・操作するためのユーティリティクラスやメソッドを多数提供しています。

まずはnpm経由でParseをインストールする:

$ npm install -i parse @types/parse

プロジェクト・ルートに.envファイルを次のように作成する:

# .env

PARSE_APPLICATION_ID=<your_parse_app_id>
PARSE_JAVASCRIPT_KEY=<your_parse_js_key>

確実に交換する<your_parse_app_id><your_parse_js_key>を実際の認証情報に置き換えてください。認証情報を取得するには、アプリに移動し、サイドバーの “App Settings > Server & Security “を選択します。

次に、Parseをroot.tsxファイルの先頭で次のように初期化する:

// app/root.tsx

// other imports
import Parse from "parse/node";

const PARSE_APPLICATION_ID = process.env.PARSE_APPLICATION_ID || "";
const PARSE_HOST_URL = "https://parseapi.back4app.com/";
const PARSE_JAVASCRIPT_KEY = process.env.PARSE_JAVASCRIPT_KEY || "";
Parse.initialize(PARSE_APPLICATION_ID, PARSE_JAVASCRIPT_KEY);
Parse.serverURL = PARSE_HOST_URL;

api/backend.tsという別のファイルを作り、バックエンドの通信ロジックからビューをクリーンに保つ。

以下の内容でapi/backend.tsを作成する:

// app/api/backend.ts

import Parse from "parse/node";
import NoteModel from "~/store/NoteModel";

export const serializeNote = (note: Parse.Object<Parse.Attributes>): NoteModel => {
  return {
    objectId: note.id,
    emoji: note.get("emoji"),
    title: note.get("title"),
    content: note.get("content"),
    createdAt: new Date(note.get("createdAt")),
    updatedAt: new Date(note.get("updatedAt")),
  };
}

export const getNotes = async (): Promise<NoteModel[]> => {
  // ...
}

export const getNote = async (objectId: string): Promise<NoteModel | null> => {
  // ...
}

// Grab the entire file from: 
// https://github.com/duplxey/back4app-containers-remix/blob/master/app/api/backend.ts

最後に、バックエンドのデータを取得して操作するためにビューを修正する:

// app/routes/index.tsx

import {json} from "@remix-run/node";
import {Link, NavLink, useLoaderData} from "@remix-run/react";
import {getNotes} from "~/api/backend";

export const loader = async () => {
  const notes = await getNotes();
  return json({notes});
};

export default function Index() {
  const {notes} = useLoaderData<typeof loader>();
  return (
    // ...
  );
}
// app/routes/$noteId.tsx

import {getNote} from "~/api/backend";
import {json, LoaderFunctionArgs} from "@remix-run/node";
import {Form, useLoaderData} from "@remix-run/react";
import {invariant} from "@remix-run/router/history";

export const loader = async ({params}: LoaderFunctionArgs) => {
  invariant(params.noteId, "Missing noteId param");
  const note = await getNote(params.noteId);
  if (note == null) throw new Response("Not Found", {status: 404});
  return json({note});
};

export default function NoteDetails() {
  const {note} = useLoaderData<typeof loader>();
  return (
    // ...
  );
}
// app/routes/create.tsx

import {ActionFunctionArgs, redirect} from "@remix-run/node";
import {Form} from "@remix-run/react";
import {createNote} from "~/api/backend";

export const action = async ({request}: ActionFunctionArgs) => {
  const formData = await request.formData();
  const {emoji, title, content} = Object.fromEntries(formData) 
                                    as Record<string, string>;
  const note = await createNote(emoji, title, content);
  return redirect(`/${note?.objectId}`);
};

export default function NoteCreate() {
  return (
    // ...
  );
}
// app/routes/$noteId_.destroy.tsx

import type {ActionFunctionArgs} from "@remix-run/node";
import {redirect} from "@remix-run/node";
import {invariant} from "@remix-run/router/history";
import {deleteNote} from "~/api/backend";

export const action = async ({params}: ActionFunctionArgs) => {
  invariant(params.noteId, "Missing noteId param");
  await deleteNote(params.noteId);
  return redirect(`/`);
};

コードの詳細

  • データのロードにはRemixloader()関数を使用しています。データが読み込まれたら、json()関数でJSONとしてビューに渡します。
  • Remixのaction()関数を使用して、フォームの送信(POSTなど)を処理します。
  • noteIdがパラメータとしてビューに渡された。

アプリは完全に動作し、Back4appバックエンドと同期しているはずです。いくつかのノートを作成し、編集し、削除することで、すべてが機能していることを確認してください。

Dockerizeアプリ

このセクションでは、RemixフロントエンドをDocker化します。

ドッカーファイル

Dockerfileはプレーン・テキスト・ファイルで、Dockerエンジンがイメージを構築するために実行しなければならないステップの概要を示している。

これらのステップには、作業ディレクトリの設定、ベース・イメージの指定、ファイルの転送、コマンドの実行などが含まれる。

命令は通常、すべて大文字で描かれ、その直後にそれぞれの引数が続く。

すべての手順について詳しく知りたい方は、Dockerfileのリファレンスをご覧ください。

プロジェクト・ルートに以下の内容のDockerfileを作成する:

# Dockerfile

FROM node:20

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .
RUN npm run build

EXPOSE 3000

CMD ["npm", "run", "start"]

このDockerfileは node:20イメージに基づいている。まず作業ディレクトリを設定し、package.jsonをコピーし、依存関係をインストールします。

その後、プロジェクトをビルドし、ポート3000を公開し、アプリを提供する。

.dockerignore

Dockerで作業する場合、通常は可能な限り小さなイメージを構築するように努めるだろう。

今回のプロジェクトには、イメージに含める必要のないファイル(.git、 ビルド、IDE設定など)が含まれているため、それらを除外します。そのために、.dockerignoreファイルを作成します。.dockerignoreファイルは.gitignoreファイルと同様の働きをします。

プロジェクトルートに.dockerignoreファイルを作成します:

# .dockerignore

.idea/
.cache/
build/
node_modules/

.dockerignoreは、プロジェクトのニーズに応じて変更してください。

ビルドとテスト

Dockerイメージをクラウドにプッシュする前に、ローカルでテストすることをお勧めします。

まず、イメージを構築する:

$ docker build -t remix-notes:1.0 .

次に、新しく作成したイメージを使ってコンテナを作成する:

$ docker run -it -p 3000:3000
    -e PARSE_APPLICATION_ID=<your_parse_app_id> 
    -e PARSE_JAVASCRIPT_KEY=<your_parse_javascript_key> 
    -d remix-notes:1.0

確実に交換する<your_parse_app_id><your_parse_javascript_key>を実際のクレデンシャルに置き換えてください。

これでアプリはhttp://localhost:3000 からアクセスできるようになるはずだ。ドッカー化する前と同じように動作するはずです。

GitHubにプッシュする

アプリをBack4app Containersにデプロイするには、まずソースコードをGitHubにプッシュする必要があります。そのためには、以下の手順に従ってください:

  1. GitHubアカウントにログインする(またはサインアップする)。
  2. 新しいGitHubリポジトリを作成する。
  3. ローカルプロジェクトに移動し、初期化します。
  4. すべてのコードをバージョン管理システムに追加する:git add .
  5. リモートのオリジンをgit remote add originで追加します。
  6. git commit -m "initial commit "ですべてのコードをコミットする。
  7. コードをGitHubにプッシュするgit push origin master

アプリのデプロイ

最後のセクションでは、フロントエンドをBack4app Containersにデプロイする。

Back4appアカウントにログインし、”Build new app “をクリックしてアプリ作成プロセスを初期化します。

Back4app アプリ作成

今回はコンテナ化されたアプリをデプロイするので、”Containers as a Service “を選択する。

サービスとしてのBack4appコンテナ

次に、GitHubアカウントとBack4appをリンクし、以前作成したリポジトリをインポートする必要があります。接続したら、リポジトリを選択します。

Back4appリポジトリ選択

Back4app Containersでは高度な設定が可能です。とはいえ、シンプルなアプリの場合、以下の設定で十分だ:

  1. アプリ名:remix-notes(または好きな名前)
  2. 環境変数: parse_application_id,parse_javascript_key

環境変数には、.envファイルで使用した値を使用する。

デプロイの設定が完了したら、[デプロイ]をクリックします。

Back4app 環境設定

デプロイが完了するまでしばらくお待ちください。配備が完了したら、画面左側の緑色のリンクをクリックして、ブラウザでアプリを開いてください。

詳しいチュートリアルについては、Back4appのContainer Remix Documentationをご覧ください。

これで完了です!これでアプリは正常にデプロイされ、提供されたリンクからアクセスできるようになりました。さらに、Back4appはあなたのアプリに無料のSSL証明書を発行しました。

結論

Remixは比較的新しいフレームワークですが、開発者はパワフルなフルスタックのウェブアプリケーションを構築することができます。

このフレームワークは、フォーム処理、状態管理など、ウェブ・アプリケーションの多くの複雑さに対応している。

この記事では、Remixアプリケーションをビルドしてデプロイする方法を学びました。シンプルなバックエンドを作成するためにBack4appを利用し、コンテナ化したアプリケーションをデプロイするためにBack4app Containersを利用できるようになったはずだ。

プロジェクトのソースコードはback4app-containers-remixrepoで公開されています。


Leave a reply

Your email address will not be published.