🤗

Deno+ReactでUIを作りGoでembedして動かす

2022/07/01に公開

GoとDenoはシンプルでとても好きです。ReactもUIライブラリとして申し分無いスマートさでとても気に入っているのですが、いざ動かそうと思うとNodeが必要となってきます。それはそれで問題無いのですが、せっかくなら好きなDenoから利用したいので、今回はReactをDenoでバンドルし、Goでembedして動かします。

出来上がったものがこちらです。
https://github.com/wablerfam/go_deno_react_example
以下で少し補足します。

0. 方針

esm.sh経由でReactを利用できるので、React+tsxをDenoで作成すること自体は問題ないはずですが、以下の検討も必要です。

  • バンドラーをどうするか
    Nodeであればesbuild(Vite)やwebpackなど様々な候補があるので状況に応じて選別が可能ですが、Denoから利用するとなると限られてきます。幸いDenoは"deno.land/x"にesbuildと、esbuildをDeno経由で使うためのローダーが存在するのでそちらを利用します。

https://deno.land/x/esbuild@v0.14.48
https://deno.land/x/esbuild_deno_loader@0.5.0

  • バックエンド(Go)とフロントエンド(Deno(TypeScript))で型をどのように共有するか
    バックエンドもDenoで作る、もしくはGraphqlやOpenAPI、GPRCであれば定義ファイルからバックエンド、フロントエンドのコードを自動生成する方針が取れるのでそちらでもいいかなと思うのですが、Denoをどこまでサポートしているのか不明なことと、今回は簡単なアプリケーションであることから、Goのモデル定義を単にTypeScript用に変換したものをフロントエンドから利用することとします。変換には以下を利用することとしました。

https://github.com/gzuidhof/tygo

  • ゴールをどうするか
    フロントエンドからバックエンドに向けて最低限の型を意識しながらfetchし、取得した結果をスタイリングして表示するところまでを今回ゴールとします。

1. バックエンドとフロントエンドで利用する型の作成

まずはtygoでの利用を意識しつつ、バックエンド側でjsonタグ付きのモデルを作成。

go/model/model.go
package model

type User struct {
	ID   int64  `json:"id"`
	Name string `json:"name"`
}

tygo用の設定ファイルを作成して、フロントエンド用のtsファイルを自動生成します。

tygo.yaml
packages:
  - path: "go_deno_react_example/go/model"
    output_path: "js/model.ts"
tygo generate
js/model.ts
// Code generated by tygo. DO NOT EDIT.

//////////
// source: model.go

export interface User {
  id: number /* int64 */;
  name: string;
}

2. フロントエンドの作成

単にfetchするだけでは寂しいのでReact Queryを利用します。スタイリングには公式にも記載のある、
https://deno.land/manual/jsx_dom/twind
twindを利用してTailwindな装飾を施すこととします。

Denoではモジュール管理についてdeps.tsやimport mapsによる方式がサポートされますが、個人的にimport mapsによる管理のほうがシンプルで好みなので、今回はこちらを利用します。

import_map.json
{
  "imports": {
    "react": "https://esm.sh/react@18.2.0",
    "react-dom": "https://esm.sh/react-dom@18.2.0",
    "react-query": "https://esm.sh/react-query@3.39.1",
    "twind": "https://esm.sh/twind@0.16.17"
  }
}
js/index.tsx
import React from "react";
import ReactDOM from "react-dom";
import { QueryClient, QueryClientProvider, useQuery } from "react-query";
import { tw } from "twind";

import { User } from "./model.ts";

function Home() {
  const { data } = useQuery<User[], Error>("users", async () => {
    const res = await fetch("/users");
    return res.json();
  });

  if (!data) {
    return <div>not found</div>;
  }

  return (
    <div>
      <p className={tw`text-red-500`}>Hello. {data[0].name}</p>
    </div>
  );
}

const queryClient = new QueryClient();

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Home />
    </QueryClientProvider>
  );
}

ReactDOM.render(<App />, document.getElementById("root"));

React Queryのdata型に、tygoから生成した型(User型)を定義したことによって、dataにUser型が付きキチンと補完が効くようになりました。これでバックエンドと連携する簡単なフロントエンドの作成は完了です。

3. バンドラーの作成

次にesbuildを利用したバンドラーの作成です。まずバンドルファイルを呼び出すための簡単なHTMLを作成して、

public/index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width" />
    <title>go_deno_react_example</title>
  </head>
  <body>
    <div id="root"></div>
    <script src="bundle.js"></script>
  </body>
</html>

バンドルツールを以下のように作成しました。

tools/build.ts
import * as esbuild from "esbuild";
import { denoPlugin } from "esbuild_deno_loader";
import { copySync } from "fs/copy.ts";
import { ensureDirSync } from "fs/mod.ts";

ensureDirSync("dist");

// Copy Public
copySync("public/", "dist", { overwrite: true });

// ESBuild
const importMapURL = new URL("../import_map.json", import.meta.url);

await esbuild.build({
  plugins: [denoPlugin({ importMapURL })],
  entryPoints: ["js/index.tsx"],
  outfile: "dist/bundle.js",
  bundle: true,
  format: "esm",
});

esbuild.stop();

このとき、バンドルツール用にimport mapsに必要なモジュールを追加しています。

import_map.json
{
  "imports": {
    "fs/": "https://deno.land/std@0.145.0/fs/",
    "esbuild": "https://deno.land/x/esbuild@v0.14.47/mod.js",
    "esbuild_deno_loader": "https://deno.land/x/esbuild_deno_loader@0.5.0/mod.ts",
    "react": "https://esm.sh/react@18.2.0",
    "react-dom": "https://esm.sh/react-dom@18.2.0",
    "react-query": "https://esm.sh/react-query@3.39.1",
    "twind": "https://esm.sh/twind@0.16.17"
  }
}

あとは作成したバンドルツールを実行すれば、

deno run -A tools/build.ts

フロントエンド用の成果物がdistディレクトリに作成され、これにてフロントエンドの作成は完了となります。deno runコマンドのオプションを省略するためにdeno.jsoncを編集しているので、こちらは先述の完成品リポジトリをご確認頂ければと思います。

4. バックエンドの作成

バックエンドはフロントエンドのfetch先を意識したエンドポイントの作成と、distディレクトリをembedしたサーバを作成していきます。今回はサーバフレームワークにechoを利用し、最終的に以下が完成しました。

main.go
package main

import (
	"embed"
	"net/http"

	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"

	"go_deno_react_example/go/model"
)

//go:embed dist
var dist embed.FS

func main() {
	e := echo.New()
	e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
		Root:       "dist",
		Filesystem: http.FS(dist),
	}))

	e.GET("/users", func(c echo.Context) error {
		var users []*model.User
		user := &model.User{
			ID:   1,
			Name: "user1",
		}
		users = append(users, user)
		return c.JSON(200, users)
	})
	e.Logger.Fatal(e.Start(":1323"))
}

あとはmain.goを実行した後、

go run main.go

http://localhost:1323にアクセスすれば、Reactで作成したUIが表示されます。もちろんgo buildすれば、distディレクトリを含んだ単一バイナリファイルが作成されます。

5. まとめ

というわけで個人的に好きなものだけを利用して、完璧とまではいかないものの、ある程度はやりたいことが実現できました。Goについて今回の内容で特筆することはありませんが、Denoフロントエンドについては、Nodeの利用無しでも簡単なものなら利用できるかもしれません。

Nodeと比較しDenoを使う利点は各所で説明されているので省きますが、それ以外で思ったこととして、上述完成品に示す通り、今回のような構成であればGoのタスクランナーをMakefileからdeno.json(c): {tasks}にしてしまうのも有りかなと。フロントエンドの設定ファイルにバックエンドのタスクを記載するのは避けたほうがいいですが、Makefile程の自由が無いおかげである程度規則的に書けるのが気に入っているところです。

というわけでGoはGoの魅力を、DenoはDenoの魅力を意識してアプリケーションを作ってみました。ちょっと普段と違った感じがして中々楽しいですね。

Discussion