俺の管理画面 2023年冬

面白法人カヤック技術部の谷脇です。私は元気です。

この記事は面白法人グループ Advent Calendar 2023の5日目のエントリーです。

というわけでこの記事では、現環境(私が取り組んでいる業務のこと)ベストの管理画面の技術選択について考えたことを書き連ねていきます。

前提知識

管理画面の定義

ここで読者と私の目線を合わせるため、この記事上での管理画面の定義をしておきます。

管理画面はサービスの運営上必要な操作やデータの閲覧をまとめたWebアプリケーションです。また、このWebアプリケーションは一般ユーザーには開放されておらず、サービス運営者側のみ閲覧と操作が可能となっている、とします。

管理画面を作る動機

ここではTonamelの管理画面について、考えて導入したことを書きます。

tonamel.com

Tonamelはゲーム大会やイベントを開催するためのプラットフォームです。Webアプリケーションです。管理画面が影響する要素としては、イベント参加者側が操作する画面とイベント管理者側が操作する画面と2種類あります。従来はイベント管理者側にどんどん機能を付け足していった結果、管理画面はあまり必要ありませんでした。しかしユースケースが増えるにつれて、以下に挙げるようなものについて、サービス運営者のみが操作できる画面が欲しくなってきました。

  • 特定のイベント管理者のみに特殊な機能を有効にするような機能のフラグ管理
    • 従来はバックエンドエンジニアが実行するバッチスクリプトや、コードと一緒にデプロイする設定ファイルで行っていた
  • サイト内で選択可能なゲームのリストの管理
    • 従来はGoogle Spreadsheetで管理していたものを本番環境に流し込むバッチをエンジニアが実行していた
  • 特定のイベント向けに作ったが、まだイベント管理者側には出していない機能の操作

Tonamelの本体アプリケーション側の構成

先に述べる技術スタックの選定にも影響するので、本体アプリケーション側の技術スタックについても記述します。

  • フロントエンド: Nuxt.js
    • 主に管理画面はバックエンドエンジニアが作る予定だったので、Nuxt.jsやVue.jsを採用するにしても理由としては「フロントエンドエンジニアに助けを求められる」というのが挙げられる
    • 結果的に管理画面にNuxt.jsは採用しなかった
  • バックエンド: Go
    • マイクロサービス構成
      • イベント管理, トーナメント表管理, 決済管理 などのマイクロサービスに分かれている
  • フロントエンドとバックエンド間の通信: GraphQL

俺の考えた最強の管理画面スタック 2023年冬版

というわけで現状、今のチームではこれが正解ではないかというのを発表します。

データ編集および閲覧方法

本体アプリの一部のマイクロサービスと同じコードベースに管理画面用GraphQLエンドポイントをくっつける。

理由

  • 一般ユーザー向けAPIエンドポイントはGraphQLをしゃべるマイクロサービシーズで構成されている
  • できるだけ本体アプリと同じ技術スタックを使って管理画面の機能追加で新たなスキルを必要としないようにしたい
  • エンドポイントは分離して間違って一般ユーザー向けAPIエンドポイントから管理画面用の機能を使えないようにする

管理画面用サーバー

GoおよびGoの標準テンプレートエンジンで作ったHTMLを吐くサーバーを作る、いわゆるMPAな構成。

理由

  • できるだけモダンなフロントエンドで要求されるような知識を用いずに作りたい
    • HTMLが分かればOKぐらいでやりたい
  • 普段使っているスタックの延長線上のものを使いたい

Tips: GraphQLクライアントにgenqlientを使う

genqlientは、GoのGraphQLクライアントです。queryやmutationをファイルに定義すると、リクエストを行うためのコードを生成します。structなどにもバインディングするため、GraphQLサーバーのためのコードを生成するgqlgenと開発体験が似ています。

例として、以下のようなアカウントを取得するqueryを定義します。スキーマは別であります。

query getAccount($id: ID!) {
  account(id: $id) {
    id
    name
  }
}

するとこれがそのまま関数名となり、getAccount(context.Context, graphql.Client, id string)となるような関数として利用できます。

フロントエンドJSライブラリ

htmxおよび、Alpine.jsを用いて構築する。

理由

  • できるだけhtml/templateを使ってHTMLのみで機能するように構築する
  • ページングやダイアログ・snackbarなど体験が向上しつつも実装のコスパがよいところだけhtmxやAlpine.jsを使う
  • フロントエンド開発のための特別なビルド用の設定や構築(webpackやviteなど)を必要としない

Tips: htmxがこのスタックの中で最推し

htmxは、aタグやformタグのsubmitをFetch APIに置き換えた上で、レスポンスの結果帰ってきたHTMLを指定したタグの中のHTMLと置き換えるライブラリです。jQueryのときに使われていたPjaxにも似ていますが、挙動をJavaScriptで指定せずに、タグのattributeで指定するのが特徴です。

以下はGoのテンプレートエンジンと組み合わせたときの例です。アカウントというデータの表示と名前の書き換え部分を示しています。

あとで#accountの部分だけレンダリングできるように、text/templateblockを使用しています。blockdefineでテンプレートを定義したと同時にtemplateですぐに利用する記法です。

{{ block "account_block" . }}
<div id="account">
  <p>{{ .Account.Name }}</p>
</div>
{{ end }}

<form method="POST" action="/target">
  <input type="hidden" name="id" value="{{ .Account.ID }}">
  <input type="text" name="name">
  <button type="submit" hx-post="/target" hx-on="click" hx-target="#account" hx-swap="outerHTML">送信</button>
</form>

また、このHTMLを返すGoのハンドラーでは以下のようにしています。フレームワークはechoを使っています。エラーハンドリングやバリデーションは省略しています。

accountPostHandlerはHTMLのformとしてリクエストが来たときと、htmxのリクエストとして来たときと両対応するようにしています。htmxのドキュメントでは、htmxでのリクエストの場合、常にHX-Requestヘッダーにtrueがセットされるため、この区別に利用しています。

func main() {
    e := echo.New()
    // define template renderer
    e.GET("/account", accountGetHandler)
    e.POST("/account", accountPostHandler)

    e.Start(":8080", nil)
}

type accountPageData struct {
    Account *Account
}

func accountGetHandler(c echo.Context) error {
    ctx := c.Request().Context()
    var id string
    if err := echo.QueryParamBinder(c).String("id", &id).BindError(); err != nil {
        return fmt.Errorf("failed to echo.QueryParamBinder(c).String: %w", err)
    }
    account, err := fetchAccount(ctx, id)
    if err != nil {
        return fmt.Errorf("failed to fetchAccount: %w", err)
    }
    apd := &accountPageData{
        Account: account,
    }
    return c.Render(http.StatusOK, "account.html", apd)
}

func accountPostHandler(c echo.Context) error {
    ctx := c.Request().Context()
    type Form struct {
        ID   string `form:"id"`
        Name string `form:"name"`
    }
    var f Form
    if err := c.Bind(&f); err != nil {
        return fmt.Errorf("failed to c.Bind: %w", err)
    }
    account, err := modifyAccount(ctx, f.ID, f.Name)
    if err != nil {
        return fmt.Errorf("failed to modifyAccount: %w", err)
    }
    apd := &accountPageData{
        Account: account,
    }
    if c.Request().Header.Get("HX-Request") == "true" {
        return c.Render(http.StatusOK, "account_block", apd)
    }
    return c.Render(http.StatusOK, "account.html", apd)
}

単一リソースの表示と編集では通常のフォーム送信と挙動があまり変わらないです。ですが、ページングなどで利用すると画面遷移を挟まないため、体験が向上するのを感じられます。また、管理画面内のゲーム情報やイベント情報の検索でインクリメンタルサーチを実装しています。htmxのドキュメントにあるActive Searchの例を参考にしています。絞り込みが逐次行われるので、管理画面を利用した際の体験が良くなります。

htmxはHTMLをサーバーがレンダリングするタイプのアプリケーションに簡単に導入できるので、皆さんも試してはいかがでしょうか。

CSSフレームワーク

BeerCSSを採用。

理由

  • Bootstrapは記述量が多く、使わないコンポーネントも多い
  • Tailwindはビルドスタックが必要
  • Twindは良い選択肢だが、CSSに関する知識が必要であり、また既製品のスタイリングがされたコンポーネントを使いたい
  • BeerCSSはコンポーネントも必要最低限に限られているのと、Bootstrapに対して記述量が少なくて済む
    • BeerCSSだけだと足りない場面は今後おそらく出てくると思われますが、現状は十分なのと、足りなくなったらその時だけTwindでコンポーネントを自作するなど考えます

Tips: BeerCSSのsnackbarとAlpine.jsを組み合せて再利用可能なsnackbarを作る

BeerCSSにあるコンポーネントでsnackbarがあります。Tonamelの管理画面では、以下のようにデータの変更が出来た場合に使っています。htmxによるSPA動作では画面遷移が挟まりません。snackbarのようなフィードバックをするUIがないと、操作したあとにリクエストが受理されたかどうかがわからないためです。

snackbarが出る様子。時間経過で自動的に閉じている

頻繁に出るパターンなので、以下のテンプレートを作ってどこからでも利用できるようにしています。dicthasKeyなどのヘルパー関数はMasterminds/sprigを組み込んで使えるようにしています。

{{ define "snackbar" }}
<div class="snackbar bottom active{{- if hasKey . `Class` }} {{ .Class }}{{- end }}" x-show="show" x-data="{ show: true }" @click="show = false" x-init="setTimeout(() => { show = false }, 3000)">
  {{ .Message }}
</div>
{{ end }}

呼び出すときはこのような感じで使っています。

{{ if .Succeeded }}
  {{ template "snackbar" dict "Message" "アカウントの変更に成功しました。" "Class" "primary" }}
{{ end }}

リクエストが成功した際、テンプレートへ渡すstructのfieldSucceededがtrueとなるようにしておきます。htmxでこのテンプレート呼び出しを含む部分がDOMへ組み込まれた際に、自動でsnackbarが表示されます。その後、Alpine.jsの@clickの効果によりsnackbar自体がclickされて表示されなくなります。また、Alpine.jsのx-initで表示から3秒経過しての非表示化も行っています。

Alpine.jsを使わなくてもBeerCSSにはui("<selector>", 3000)と書けば非表示化されます。1ですが、タグにいちいちidなどを定義するのは大変だなと感じて、Alpine.jsで行うようにしています。

このようにAlpine.jsはワンポイントで使うようにとどめています。

まとめ

  • Goをメインの開発言語として使っているので、Goで管理画面も作る
  • GoだからといってAPIサーバーではなくHTMLをレンダリングする
  • SPA的な動きはhtmxを活用する
  • フロントエンドで実現したい動きはAlpine.jsをワンポイントで使う

これが2023年冬の私の回答です。みなさまの考えた管理画面はいかかがでしょうか? もっとフロントエンドスタックを信じろなどの意見はあるかとおもいます。そういった意見も皆さんのブログなどでお聞かせください。そのときにこの記事へのリンクをきっかけとして貼っていただけると幸いです。

この冬も管理画面をやっていきましょう。

カヤックではこんな感じで推しのライブラリやアーキテクチャを紹介してくれるエンジニアを募集しています!

hubspot.kayac.com