フューチャー技術ブログ

Go 1.16のembedとgo-swaggerを組み合わせてフルスタック自動生成フレームワークを作る

TIGの伊藤真彦です。

渋川さんが投稿された

やりたいこと

私の最近の仕事はgo-swaggerによるバックエンドAPI開発です。本流はバックエンドですが、必要に応じてクラウドインフラを弄ったり、ちょっとしたフロントエンドアプリケーションを作ったりといった動き方で働いています。

ある時、go-swaggerで作ったバックエンドAPIの資産を使って、ちょっとした開発者向けアプリケーションを作りたくなりました。

ローカル環境でサーバーとフロントエンドアプリケーションを両方起動すると、サーバーがlocalhost:3000 フロントエンドがlocalhost:8080を占拠してしまいます。また、フロントエンドとバックエンドのポートが異なることにより、フロントエンドからのリクエストを処理するためにはCORSの設定が必要になってしまいます。そして単純に2つのアプリケーションを起動するのが面倒だなと感じました。

そこで、go-swaggerが生成するものをハックして、フロントエンドの成果物と今まで作ってきたバックエンドAPIを同じポートで抱えつつ、APIを叩くクライアントサイドのコードもswagger.yamlから自動生成するようなアプリケーション開発に挑戦してみました。

バックエンド開発

説明のために、まずはバックエンドの資産を作ります。

詳しい作り方、説明はgo-swaggerでhello worldをお読みください。

今回はこのようなディレクトリ構成でアプリケーションを作ります。

swagger.yaml
server
├─gen
 └─get_greeting_handler.go
swagger.yaml
---
swagger: '2.0'
info:
version: 1.0.0
title: Greeting Server
paths:
/hello:
get:
produces:
- text/plain
parameters:
- name: name
required: false
type: string
in: query
description: defaults to World if not given
operationId: getGreeting
responses:
200:
description: returns a greeting
schema:
type: string
description: contains the actual greeting as plain text

上記記事と同じswagger.yamlを用意して、serverパッケージを生成します。

swagger generate server -a factory -A factory -t server/gen -f ./ swagger.yaml

get_greeting_handler.goはログ出力だけ少し追加しました。

get_greeting_handler.go
func GetGreeting(p factory.GetGreetingParams) middleware.Responder {
payload := "hello go"
if p.Name != nil {
payload = *p.Name
}
log.Printf("GetGreeting is called, return %s\n", payload)
return factory.NewGetGreetingOK().WithPayload(payload)
}

localhost:3000/helloでapiが動くことを確認します。

cd server
go run .\gen\cmd\factory-server\main.go --host 0.0.0.0 --port 3000

フロンエンド開発

バックエンドの用意ができたら、上記のAPIを叩くためのサンプルアプリケーションを作ります。
まずはVue.jsでのHello Worldアプリケーションを生成します。
こちらも過去記事Electronの使い方 Web開発の技術でデスクトップアプリを作ろうで詳しく説明しています。

フロントエンドアプリケーションを下記の構成で生成します。

swagger.yaml
app
└─frontend
server
├─gen
 └─get_greeting_handler.go
npm install -g @vue/cli
mkdir app
cd app
vue create frontend

アプリケーションを起動し、localhost://8080でフロントエンドアプリケーションが起動することを確認します。

cd frontend
npm run serve

フロントエンドにAPI Clientを実装する

作成したフロントエンドアプリケーション向けに、TypeScriptのAPIクライアントを自動生成します。

cd app
npx -p @openapitools/openapi-generator-cli@cli-4.1.3 openapi-generator generate -g typescript-axios -i ../../swagger.yaml -o ./frontend/src/client-axios -p modelPropertyNaming=snake_case --enable-post-process-file

/frontend/src/client-axiosディレクトリにコードが生成されます。
.eslintignoreに追加するなど、linterの設定を適宜追加してエラーが起きないようにすることを推奨します。

生成したコードを利用するようにapp\frontend\src\App.vueを更新します。

App.vue
<template>
<div id="app">
<img alt="Vue logo" src="./assets/logo.png">
<h1>{{message}}</h1>
</div>
</template>

<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { DefaultApi } from './client-axios'

@Component({})
export default class App extends Vue {
message = ""

mounted():void {
const api = new DefaultApi().getGreeting("hello Vue + Go + OpenAPI");
api.then((resp: any) => {
this.message = resp.data;
})
}
}
</script>

<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
margin-top: 60px;
}
</style>

app\frontend\src\client-axios\base.tsでAPIの接続先を設定できるので、任意のポート、パスに書き換えます。

base.tsの設定箇所

このままnpm run serveでアプリケーションを起動すると、存在しないAPIにアクセスし、通信に失敗する状態になります。

Vueアプリ起動画面 ブラウザの開発者画面で通信エラーが発生している

この状態のアプリケーションを、バックエンドAPIと繋ぎこみます。

フロントエンドとバックエンドAPIを統合する

まずは作成したフロントエンドアプリケーションをビルドし、アセットファイルを準備します。

cd app/frontend
npm run build

app\frontend\distディレクトリに成果物一式が生成されます。

生成したファイルをgo:embedで埋め込み起動するようなmain.goを下記の構成で作成します。

swagger.yaml
app
├─frontend
└─main.go
server
├─gen
 └─get_greeting_handler.go
main.go
package main

import (
"embed"
"io/fs"
"log"
"net/http"
"os"

"github.com/go-openapi/loads"
flags "github.com/jessevdk/go-flags"

"regexp"
"server/gen/restapi"
"server/gen/restapi/factory"
)

var proxyRegexp = regexp.MustCompile(`^/api`)

//go:embed frontend/dist/*
var static embed.FS

func main() {

swaggerSpec, err := loads.Embedded(restapi.SwaggerJSON, restapi.FlatSwaggerJSON)
if err != nil {
log.Fatalln(err)
}

api := factory.NewFactoryAPI(swaggerSpec)
server := restapi.NewServer(api)
defer server.Shutdown()

parser := flags.NewParser(server, flags.Default)
parser.ShortDescription = "Greeting Server"
parser.LongDescription = swaggerSpec.Spec().Info.Description
server.ConfigureFlags()
for _, optsGroup := range api.CommandLineOptionsGroups {
_, err := parser.AddGroup(optsGroup.ShortDescription, optsGroup.LongDescription, optsGroup.Options)
if err != nil {
log.Fatalln(err)
}
}

if _, err := parser.Parse(); err != nil {
code := 1
if fe, ok := err.(*flags.Error); ok {
if fe.Type == flags.ErrHelp {
code = 0
}
}
os.Exit(code)
}

// serve swagger api server.
server.ConfigureAPI()
http.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
// remove "/api" fron api path for swagger api
r.URL.Path = proxyRegexp.ReplaceAllString(r.URL.Path, "")
server.GetHandler().ServeHTTP(w, r)
})

// serve frontend HTML.
public, err := fs.Sub(static, "frontend/dist")
if err != nil {
panic(err)
}
http.Handle("/", http.FileServer(http.FS(public)))

log.Println("listening on localhost:3000...")
// NOTE: if you want to use another port, you also have to modify app\frontend\src\client-axios\base.ts
log.Fatal(http.ListenAndServe(":3000", nil))
}

※そのまま使用するとserverパッケージのimportに失敗します、ご自身の環境での適切なパスに指定するか、go.modでreplaceしてください”
このファイルは、自動生成されたserver\gen\cmd\factory-server\main.goをベースに拡張したファイルです。

生成したフロントエンドのコードをgo:embedで埋め込みます。
詳しくはGo 1.16からリリースされたgo:embedとはをお読みください。

main.go
//go:embed frontend/dist/*
var static embed.FS

埋め込んだファイルを利用できるようにHTTPハンドラを設定します。

main.go
// serve frontend HTML.
public, err := fs.Sub(static, "frontend/dist")
if err != nil {
panic(err)
}
http.Handle("/", http.FileServer(http.FS(public))

一方、go-swaggerで生成したバックエンドAPIのロジックはapi/helloのパスでアクセスできるように退避させつつ読み込みます。

main.go
// serve swagger api server.
server.ConfigureAPI()
http.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
// remove "/api" fron api path for swagger api
r.URL.Path = proxyRegexp.ReplaceAllString(r.URL.Path, "")
server.GetHandler().ServeHTTP(w, r)
})

本来であればserver.Serve()でポート番号の指定などを解釈しつつ、バックエンドAPIが起動するところを、上記の方法でうまく利用できました。

main.go
var proxyRegexp = regexp.MustCompile(`^/api`)

パスにapi/の文字列が存在するとバックエンドAPIが元々期待しているパスパターンと一致しないため、正規表現を用いて除外しています。
余談ですが正規表現のMustCompileは関数内で行うと、呼び出されるたびに毎回コンパイルが走るため、グローバル変数に持たせる事が推奨されています。

アプリケーションの起動

これでアプリケーションが完成しました。
完成したアプリケーションを起動してみます。

cd app
go run main.go
Vueアプリ起動画面

無事にアプリケーションが起動し、バックエンドAPIからのレスポンスを表示できました。

開発者ツールで正常に起動している

ブラウザのデバッグコンソールでバックエンドAPIとの疎通に成功している事が確認できます。
あとは出来上がったファイルをgo buildすれば単一バイナリで動くフルスタックWebアプリケーションの完成です。

まとめ

  • go-swaggerで生成したバックエンドサーバーと、任意のフロントエンドWebアプリケーションを同じポートで起動することは可能。
  • クライアントサイドからバックエンドに繋ぐためのAPIクライアントも自動生成できる。
  • go:embedを利用することで、単一バイナリとしてビルドすることが可能。

大規模アプリケーションをこの構成で作成するには若干邪道な雰囲気を感じますが、手早くアプリケーションを開発したいGopherのみなさんにおススメの手法でした。