フューチャー技術ブログ

Goで作ったロジックにWebUIをつけてGitHubページに公開する

ちょっとしたツールをGoで作ってみたのですが、わざわざインストールしなくてもいいようにWebのUIをつけてブラウザで使えるようにしてみました。作ってみたのは以下のツールで、Markdownのリスト形式でざっと下書きしたテーブルの設計をSQLとか、PlantUMLとかMermaid.js形式のERDの図にします。

https://shibukawa.github.io/md2sql/

ウェブフロントエンド部分はNext.jsの静的サイトで、GoはWASMにしてロードして実行しています。WASMを使うのは初めてなのであえて選んでみました。

GoをWASM化する

もともとCLIツールは作っておりました。CLIのメインはcmd/md2sql/main.goで作っていました。この中でやっていることは

  • kingpin.v2のオプションパース
  • 指定されたファイルを読み込み(あるいは標準入力)
  • パース
  • 指定の形式変換

です。このうち、Web化する場合は後者の2個だけ必要ですし、コマンドラインオプションのパースとかは不要なので、WASM化用のmain.goを別途作ります。それがcmd/wasm/main.goです。JSから呼ばれる関数はjs.Valueで引数を受け取るエントリー関数を用意しておきます。ConvertToSQL()がこれにあたります。そしてJS側から呼べるように、js.Global()に作ったAPIを追加します。

//go:build wasm

package main

import (
"bytes"
"strings"
"syscall/js"

"github.com/shibukawa/md2sql"
)

func ConvertToSQL(this js.Value, args []js.Value) any {
if len(args) < 1 {
return map[string]any{
"ok": false,
"message": "first argument should be markdown source.",
}
}
tables, err := md2sql.Parse(strings.NewReader(args[0].String()))
if err != nil {
return map[string]any{
"ok": false,
"message": err.Error(),
}
}
var buf bytes.Buffer
md2sql.DumpSQL(&buf, tables, md2sql.PostgreSQL)
return map[string]any{
"ok": true,
"result": buf.String(),
}
}

// Mermaid/PlantUML変換は省略

func main() {
c := make(chan struct{})
js.Global().Set("md2sql", js.ValueOf(map[string]any{
"toSQL": js.FuncOf(ConvertToSQL),
"toMermaid": js.FuncOf(ConvertToMermaid),
"toPlantUML": js.FuncOf(ConvertToPlantUML),
}))
<-c // 終了しないようにブロック
}

次のコマンドでwasmが生成されることを確認しておきます。

$ GOOS=js GOARCH=wasm go build -o md2sql.wasm

実行時にローダーも必要なのでwasm_exec.jsを取得しておきます。

$ cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" .

Webの画面を作る

/cmd/frontendをつくるようにcreate-next-appを実行し、最近お気に入りのTailwind.CSSとdaisyUIの組み合わせで、ページトップのスクリーンショットのような画面を作りました。テキストボックスに入れられたソースコードをuseRefの変数に一時変数に入れておいて、generateボタンを押されたらGoコードを呼び出して実行します。

まず、Next.jsが動くページは、ドメイン(shibukawa.github.io)直下ではなく、/md2sql/というフォルダの中で動くのでbasePathを設定します。ついでに、静的サイト生成してアップするので画像の最適化もオフにしておきます。

/cmd/frontend/next.config.js
const nextConfig = {
basePath: '/md2sql', // 追加
reactStrictMode: true,
swcMinify: true,
images: { // 追加
unoptimized: true
}
}

ついでにロードするWASMが公開する関数の型定義を宣言します。

/cmd/frontend/md2sql.d.ts
type f = (src: string) => { ok: true, result: string} | {ok: false, message: string};

declare var md2sql:{
toSQL: f,
toMermaid: f,
toPlantUML: f,
};

tsconfig.jsonにこの追加したmd2sql.d.tsを追加しておきます。最初next-env.d.tsに追加してやっていたのですが、このファイルってビルドのたびに再生成されてしまうので消えてしまいます。

/cmd/frontend/tsconfig.json
{
"include": ["next-env.d.ts", "md2sql.d.ts", "**/*.ts", "**/*.tsx"],
}

wasm_exec.jsはNext.jsのpublicフォルダに入れておきます。

ビルド周りもいろいろ書き換えておきます。静的サイト生成なので、next build後にnext exportも実行するのと、GitHubの制約でリポジトリのルート以下の/docsフォルダに生成されたファイルを移動、.nojekyllファイルをその中に作る、というのを一緒にやります。ついでにGoのビルドもここに入れておきました。

さっとやったのでWindowsでは動かない書き方をしています。すみません。Windowsだったらshelljsとかcrossenvを使ってください。

/cmd/frontend/package.json
{
"scripts": {
"prebuild": "cd ../wasm && GOOS=js GOARCH=wasm go build -o ../frontend/public/md2sql.wasm",
"build": "next build",
"postbuild": "next export && mv out ../../docs && touch ../../docs/.nojekyll"
}
}

繋げる部分のコード

wasm_exec.jsをロードして実行するコードを書きます。Next.jsでは任意のページ内とかコンポーネント内で宣言しておけば、ページのヘッダー部分に<script>タグを作って遅延ロードしてくれるnext/scriptコンポーネントがあるのでこれを使います。一応このwasm_exec.jsの型定義も入れようと思えば入れられますが、今回はts-ignoreで済ませてしまいました。定型文ですし。GitHubページのプロジェクトページなのでjsもwasmもパスが/md2sql/以下にある想定で書きます。

/cmd/frontend/pages/index.tsx
import Script from 'next/script'

:中略

{ /* Load web assembly */ }
<Script id="exec-wasm" src="/md2sql/wasm_exec.js" onLoad={() => {
// @ts-ignore
const go = new Go();
WebAssembly.instantiateStreaming(fetch("/md2sql/md2sql.wasm"), go.importObject).then((result) => {
go.run(result.instance);
});
}}/>

WASMのロジックは生成のコールバックが呼ばれた時に呼び出します。型定義があるのでその通りに呼んであげればOKです。本当はエラーはトーストとかでポップアップさせた方が良いけどとりあえず雑にコンソールに書いてます。

/cmd/frontend/pages/index.tsx
const generate = useCallback(() => {
switch (format) {
case "sql":
const r1 = md2sql.toSQL(src.current);
if (r1.ok) {
setResult(r1.result);
} else {
console.error(r1.message);
}
break;
// 以下略
}
}, [format])

接点としてはこの「起動時のロード」と、ローダーが登録した関数の呼び出しだけですので、あとはウェブフロントエンド作れる人には特に問題なく進められると思います。

まとめ

思ったよりもWASM化が簡単にできました。作業時間の半分はGitHubページのフォルダがルート直下じゃないことで起きる問題のトラブルシュートでした。繋ぐ部分を作ってローカルで試すのは思ったよりもすぐでした。

なお、標準のGoコンパイラでやっていますので生成されるwasmファイルは大きめ(5.5MB、gzip時に1.3MB)ですが、TinyGoを使えばもっと小さいものが作れますが、標準のGoの方が互換性が高いというメリットはあります。以前はgopher.jsを使ったりしたこともありますが、標準処理系でできるのはありがたいですね。まあ、あちらは.jsになるのでローダーが不要というメリットはあります。

今後も、小さいな補助ツールを作ったらウェブで簡単に実行できるようにしていこうと思いました。

参考