ReactとGoで作るWebアプリの自分向けテンプレート

ReactとGoを使ってWebアプリを書くことが少しだけ増えたんですが、自分向けのメモ程度に作ったテンプレートを公開しておきます。

sandboxというリポジトリに気になったことを実験して放り込んでいます。
今回も特に頻繁に使うものでもないし、メンテする予定もないのでSandboxに置いておきました。

github.com

テンプレートのゴール

ゴールとしては、以下のように設定しました。

  • 最終的な成果物(本番向け)は、単一コンテナイメージになること
  • 開発でもコンテナを使うこと
  • 開発で利用するコンテナは、両方ともホットリロードされること

また、以下のような項目はやらない、もしくは考慮しないようにしています。

ディレクトリ構成

ディレクトリ構成はいたってシンプルな構成になっています。

.
├── Dockerfile # 本番向けのコンテナイメージ
├── backend # バックエンドアプリ(Golang)
├── frontend # フロントエンドアプリ(Vite+React+TypeScript)
└── docker-compose.yaml # 開発用のdocker-compose.yaml

dockerディレクトリで各種Dockerfileを管理するのも良いのですが、今回はシンプルさのためだけにこういった構成にしています。

frontendディレクトリを作成する

frontendディレクトリは、簡単に書くと

  1. Vite用のプロジェクトを作成する
  2. 開発用コンテナの定義を書く(Dockerfileなど)
  3. Viteの設定ファイルを書き換えてバックエンドにリクエストをプロキシさせる

のみで完結します。

1. Vite用のプロジェクトを作成する

ViteのScaffolding Your First Vite Projectを参考にしながら、frontendディレクトリを作成します。 以下は例ですが、package.jsonのnameがfrontendになってしまうので注意してください。

docker run \
  -it \
  --rm \
  -v $PWD:/app \ # カレントディレクトリをマウント
  -w /app \ # マウントしたディレクトリをWorkingDirectoryとして指定してこくことでcdなどは不要
  node:16-bullseye \
  yarn create vite frontend --template react-ts

2. 開発用コンテナの定義を作成する

開発で利用するフロントエンド用のコンテナのDockerfileとentrypoint.shを定義しておきます。
yarn install(npm iなど)をentrypoint.shで実行していますが、個人のお好みの場所でやっていいと思います。

FROM node:16-bullseye

# ENTRYPOINT.shを設定する
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]

ENV APP_PATH /app
WORKDIR $APP_PATH

CMD ["yarn", "dev"]

Dockerfileに関しては特筆した何かはないので、必要に応じてカスタマイズすることで、 キャッシュの最適化のしやすさなどの恩恵が得られると思います。

entrypoint.shでは以下のようにyarn installを実行して、CMDに指定されているコマンドを実行するようにしています。

#!/usr/bin/env bash

yarn install --flat --frozen-lockfile

exec "$@" # CMDに指定されているコマンドを実行

3. Viteの設定ファイルを書き換えてバックエンドにリクエストをプロキシさせる

frontend/vite.config.tsを書き換えて、バックエンドアプリにリクエストをプロキシさせます。
これを書いておかないと、CORSでハマるのは間違いないので適切に設定しておくことをオススメします。

これが終わればフロントエンド側の準備は終了です。

export default defineConfig({
  server: {
    host: '0.0.0.0',
    port: parseInt(process.env.PORT || '3000'),
    proxy:{
      '/api': {
        target: process.env.API_URL, // docker-compose.yamlで指定したURLに対してリクエストを投げさせる
        changeOrigin: true
      }
    }
  },
})

backendディレクトリを作成する

バックエンド側は特に依存するライブラリを追加するわけではないので、2ステップほどで完了です。

1. main.goを作成する

適当にmain.goを作成しておきます。
ここでポイントとしては、最終成果物を単一イメージにするため、Viteでビルドされたファイルを返せるようにhttp.FileServerを使うようにしておきます。

package main

import (
    "os"
    "log"
    "net/http"
    "encoding/json"
)

type Pod struct {
    Namespace string `json:"namespace"`
    Name string `json:"name"`
    Status string `json:"status"`
}

func handleApiPods(w http.ResponseWriter, r *http.Request) {
    pods := []Pod{
        Pod {
            Namespace: "default",
            Name: "test-xxx",
            Status: "Running",
        },
        Pod {
            Namespace: "kube-system",
            Name: "coredns-yyy",
            Status: "Running",
        },
    }

    w.Header().Set("Content-Type", "application/json")
    result, err := json.Marshal(pods)
    if err != nil {
        w.WriteHeader(http.StatusInternalServerError)
        return
    }
    w.Write(result)
}

func main() {
    http.Handle("/", http.FileServer(http.Dir("./public"))) # 本番向けイメージでViteでビルドされたファイルを返せるようにする
    http.HandleFunc("/api/pods", handleApiPods) # テスト用のエンドポイント

    port := os.Getenv("PORT")
    if (len(port) == 0) {
        port = "8080"
    }
    log.Println("Listening on :" + port + "...")
    err := http.ListenAndServe(":" + port, nil)
    if err != nil {
        log.Fatal(err)
    }
}

2. 開発用コンテナの定義を作成する

フロントエンドと同様にDockerfileとentrypoint.shを作成します。
Golang側でもできるだけ、ホットリロードしてほしいのでAirをインストールしておきます。

FROM golang:1.17-bullseye

COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]

ENV APP_PATH /go/src/app
WORKDIR $APP_PATH

# ホットリロードさせたいのでAirを入れる
RUN go install github.com/cosmtrek/air@latest
CMD ["air"]

また、こちらでも同様にentrypoint.shで依存関係を解決させます。
依存ライブラリが多い場合は、docker-compose.yamlでvolumesの定義をしっかり書いておいた方が良さそうです。

#!/usr/bin/env bash

go mod tidy

exec "$@"

これらのファイルができれば一旦は完了です。 go.modやgo.sumは開発用コンテナ起動時に作成されるはずですが、できない場合は自分でgo mod initなどを実行してください。

開発用のdocker-compose.yamlを作成する

frontendディレクトリとbackendディレクトリを作り終えたので、後はdocker-compose.yamlを作れば開発に必要なファイルの作成は終わりです。
ここもかなり手抜きをしているので、ボリュームのキャッシュなどをうまく利用したい人は随時追記するのが良いです。

version: '3.9'

services:
  frontend:
    build:
      context: frontend
    environment:
      PORT: 3000
      API_URL: http://backend:8080 # backendコンテナにリクエストされるように設定
    ports:
      - 3000:3000
    volumes:
      - ./frontend:/app
  backend:
    build:
      context: backend
    environment:
      PORT: 8080
    volumes:
      - ./backend:/go/src/app

これで開発環境は整いました。 ViteやAirを使っているため、どちらのコンテナでもうまくホットリロードができ、コンテナを一々再起動する手間がかなり省けているように感じています。

開発環境にアクセスする

docker-compose up -d を実行して、開発環境を起動します。 起動後に http://localhost:3000` にアクセスするとフロントエンドアプリが表示され、
http://localhost:3000/api/pods にアクセスするとバックエンドアプリにリクエストがいくのが確認できると思います。

本番向けのDockerfileを作成する

さて、本番向けのDcokerfileを作成します。
こちらも特筆するようなことはないですが、フロントエンド・バックエンドをそれぞれビルドして、最終成果物のイメージには必要最小限のファイルのみを渡すようにしています。
scratchにしていることで、最終成果物のファイルは数MB程度のイメージになるため、非常に軽量です。

# Backend
FROM golang:1.17-bullseye as backend
WORKDIR /go/src/app
# 今回は外部ライブラリを使用していないため、go.sumが存在しないが、存在する場合は以下のようにしておく
# COPY backend/go.mod backend/go.sum ./
COPY backend/go.mod ./
RUN go mod tidy
COPY backend .
ARG CGO_ENABLED=0
ARG GOOS=linux
ARG GOARCH=amd64
RUN go build \
    -o /go/bin/main \
    -ldflags '-s -w'

# Frontend
FROM node:16-bullseye as frontend
WORKDIR /app
COPY frontend/package.json frontend/yarn.lock ./
RUN yarn install --frozen-lockfile --ignore-optional
COPY frontend .
RUN yarn build

# App
FROM scratch
WORKDIR /app
COPY --from=backend /go/bin/main ./main
COPY --from=frontend /app/dist ./public
CMD ["./main"]

以下のようなコマンドでイメージをビルドして、動作確認すれば分かるかと思いますが、単一イメージで動作できるようになっています。

docker build -t react-golang .
docker run --rm -p 8080:8080 react-golang

おわりに

自分向けのReactとGoで作るWebアプリテンプレートを簡単に紹介しました。 説明はかなり省いていることもあるので、sandboxリポジトリの内容を見ながら、動かしながら試してもらえると良さそうです。

NotionのDBにページを自動作成するスクリプトを供養する

NotionのDBにページを自動作成するスクリプトGitHub Actionsで組んでいましたが、使わなくなったのでここに供養します。

今回供養するスクリプトの概要としては、「毎日、日報を書くページを作る」ようなスクリプトです。

f:id:corrupt952:20220219012251p:plain

スクリプトの大まかな流れとしては、

  1. 指定したDBのIDに対してページを作成する
  2. 作成するページのタイトルを担当者名にし、日付にスクリプトが実行された日を入れる

というシンプルな流れです。

require 'active_support/all'

require_relative './notion_client.rb'

class Health
  DATEBASE_ID = ENV['DB_ID']

  def daily
    current = Time.now.in_time_zone('Asia/Tokyo').to_date
    create '太郎', current
    create '次郎' current
  end

  private

  def create(name, date)
    cli.create_page(DATEBASE_ID, {
      Name: {
        title: [
          {
            text: {
              content: name
            }
          }
        ]
      },
      "日付": {
        date: {
          start: date
        }
      }
    })
  end

  def cli
    @cli ||= NotionClient.new(ENV['NOTION_API_KEY'])
  end
end

読み込んでいるnotion_client.rbは、自作の薄いラッパーですが、以下のようなものです。

require 'uri'
require 'json'

require 'faraday'

# FYI: https://developers.notion.com/reference
class NotionClient
  BASE_URL = 'https://api.notion.com/'
  CLIENT_VERSION = '2021-05-13'

  def initialize(api_key)
    @api_key = api_key
  end

  def create_page(database_id, properties = {})
    body = {
      parent: {
        database_id: database_id
      },
      properties: properties
    }

    Faraday.post(
      File.join(BASE_URL, 'v1/pages'),
      JSON.dump(body),
      request_headers,
    )
  end

  def list_pages(database_id, filter: {}, sort: {})
    body = {}
    body[:filter] = filter unless filter.empty?
    body[:sort] = sort unless sort.empty?

    Faraday.post(
      File.join(BASE_URL, 'v1/databases', database_id, 'query'),
      JSON.dump(body),
      request_headers,
    )
  end

  private

  def request_headers
    {
      "Content-Type": "application/json",
      "Notion-Version": CLIENT_VERSION,
      "Authorization": "Bearer #{@api_key}"
    }
  end
end

これを書いた当初は、まだAPIの一般公開もされておらず、Gemなどもなかったので軽いラッパーみたいなスクリプトを書いていました。
今から新規にAPI叩くのであれば、Gemを使った方がいいかなと思っています。

Mermaidで図を書く時に個人的に気をつけていること

先日、GitHubでもMermaidに対応をしたことが発表されました。

github.blog

今回は、GitHubや他サービスでMermaidを書く時に個人的に気をつけていることを書いておきます。
ここに書いてあることは、PlantUMLで書いているときにも気をつけているポイントになります。

全部フローチャートの話になります。

図の流れは、LR(左から右)にしておく

特に理由がなければ、フローチャートの流れはLRにしておきます。 LRにしておく理由は2つあり、

  • PRやドキュメントの文章は、ほとんどが左から右への流れなので、フローチャートでの視線の動かし方も同様にする
  • 構造が深くない構造であれば横長の方が収まりが良い

あまり論理的な話ではないですが、こういうポイントでLRにしていることが多いです。

graph LR

User --> LB
LB --> Nginx
Nginx --> Puma

逆にTB(上から下)にする時もあります。

  • 構造が深く横に収まらない、もしくは複雑化する

こういったケースの時は、TBにすることが多いです。

ノードとリレーションの定義は別々にする

メンテすることがないドキュメントではほとんどやらないですが、ノードとリレーションの定義は別々にしておくとメンテが楽になります。

例えば以下のような定義を書いた時に、

graph LR

User --> LB
subgraph AWS
  subgraph VPC
    LB --> EC2
    EC2 --> Aurora
    EC2 --> ElastiCache
  end
end

一見良さそうにみえますが、「UserがRoute 53にアクセスする」、「EC2がS3にアクセスする」を追加してみます。
愚直に追加すると以下のような定義になりますが、その描画結果は意図しないものになります。

graph LR

User --> LB
User --> Route53
subgraph AWS
  subgraph VPC
    LB --> EC2
    EC2 --> Aurora
    EC2 --> ElastiCache
    EC2 --> S3
  end
end

Route 53は、AWS外に定義され、S3もVPC内に定義されてしまっています。 意図していない結果なので、Route 53とS3をAWS内に定義されるように変更してみます。

graph LR

User --> LB
subgraph AWS
  User --> Route53
  EC2 --> S3
  subgraph VPC
    LB --> EC2
    EC2 --> Aurora
    EC2 --> ElastiCache
  end
end

さて、意図した結果になっているでしょうか?
今度はUserが、AWS内に移動してしまっていますね。
さてどうやって修正しますか?
私はこの方式の定義だと、どうやって意図した差分になるのかは思いつかないです。

リレーション定義のみだけで書くと、こういったちょっと意図しない・誤解を与えるような図になってしまうことがあります。
こういう問題が起きないようにするために、ノードとリレーションの定義を分割して定義してみます。

graph LR

%% nodes
User
subgraph AWS
  Route53
  S3
  subgraph VPC
    LB
    EC2
    Aurora
    ElastiCache
  end
end

%% relations
User --> LB
User --> Route53
LB --> EC2
EC2 --> Aurora
EC2 --> ElastiCache
EC2 --> S3

冗長な定義に見えますが、ノードとリレーションを分割して定義することで意図しない描画結果になりづらくなりそうというのが分かるかと思います。
こういった理由でメンテをするようなドキュメントに書く場合は、ノードとリレーションを別として定義しておくと良いです。

さいごに

PlantUMLを昔から書いていたので、その時に気をつけていることを簡単にまとめてみました。
シーケンス図であれば、シーケンス図の気をつけていることがあったり、「強調したい部分は強調して、それ以外には装飾しない」といった点もありますが、今回は省いて最低限気をつけていることを書きました。
図の方が理解しやすいことも多いと思うので、うまくMermaidと付き合って開発していけると良いですね。

コンテナにファイルをコピーせずスクリプトを実行する

たぶん役に立たない豆知識です。

ssh接続したリモートホストにファイルをコピーせずにシェルスクリプトを実行する方法を知っている方も多いと思います。

ssh $HOSTNAME bash -s <./main.sh

リモートホストで起動したbashに、ローカルホストのmain.shの内容が標準入力経由で渡されて実行されるテクニックになります。 単純にこのテクニックをコンテナでも使おうという話になります。

Dockerコンテナで実行する

オプションについての詳細は省きますが、runでもexecでも-iオプションをつけて実行するだけです。

# run
docker run --rm -i ubuntu:latest bash -s <./main.sh

# exec
docker exec -i $CONTAINER_NAME bash -s <./main.sh

この時気をつけることなんですが、-tオプションをつけないことが重要です。
-tオプションを付与すると、エラーが表示されます。(エラーが表示されているものの実行されていないかどうかは未確認)

$ docker run --rm -it ubuntu:latest bash -s <./main.sh
the input device is not a TTY

こういったテクニックを使えば、ローカルにあるファイルをコピーやマウントせずとも、
標準入力経由でファイルの内容を渡して実行することができます。

例えばRubyスクリプトであれば、以下のように実行することができます。

docker run --rm -i ruby:3 ruby <./main.rb

他にも標準入力から受け取ることができるツールであれば、大体応用できます。(psqlなど)

Kubernetesのコンテナで実行する

さて、Dockerコンテナでもできたのであれば、コマンド体系が似ているKubernetesで動いているコンテナでもできるでしょうか?

答えは可能です。

kubectl exec -i $POD_NAME -- bash -s <./main.sh

kubectlの場合は、-tをつけてもDockerと同様にエラーは表示されるものの、
スクリプト内のechoなどで出力した文字列も表示されているので実行されているようです。

まとめ

たぶん役に立たない「コンテナにファイルをコピーせずスクリプトを実行する」方法について紹介しました。
kubectl execに関しては賛否両論がありそうですが、docker run に関してはファイルをコピーせずとも動作確認が気軽にできるので役立つ時が来るかもしれません。

気が向いたら、また役に立たなそうな豆知識を書くかもしれません。

Gitのpre-push hookスクリプトをRubyで書き直した

Git操作時に、事故が起きないように書いていたpre-push用のスクリプトbashからRubyに書き換えました。

このスクリプトは、

  • mainやmasterブランチに意図せずpushしてしまう
  • 意図せずforce系のオプションを付けてpushしてしまう

といった事故を防ぐために設定しています。

GitHub側のリポジトリ設定で保護設定を有効化にしておけば、まずこのようなことはする必要ないですし、
そもそもこういった意図せずmainブランチにpushしてしまうといった操作自体も、まず起こりえません。
ですが、稀にあるエッジケースへの対策として私個人が設定しているものになります。

pre-push

スクリプトは、shebangRubyとして実行されるようにしているpre-pushで、内容は以下のようになります。

#!/usr/bin/env ruby

def main_branch?(branch_name)
  /\A(master|main)\z/.match? branch_name
end

def restrict_branches(branch_name)
  return if /\A(y|yes)\z/ =~ ENV['GIT_ALLOW_PUSH_MAIN_BRANCH']

  fail "Don't push default branch!!! (master or main)" if main_branch? branch_name
end

def use_force_option?(command)
  /--force|-f/.match?(command)
end

def restrict_force_push(command)
  return if /\A(y|yes)\z/ =~ ENV['GIT_ALLOW_FORCE_PUSH']

  fail "Don't use --force option!!!" if use_force_option? command
end

def main
  _, _, remote_ref, _ = STDIN.gets.chomp.split
  branch_name = remote_ref.gsub('refs/heads/', '')
  command = `ps -o command= -p #{Process.ppid}`.chomp

  restrict_branches branch_name
  restrict_force_push command
rescue => e
  STDERR.puts e.message
  exit 1
end
main if __FILE__ == $0

通常pre-pushなどのフックでは、ユーザが実行したコマンドのオプションは渡りません。 ですが、pre-pushが動く時の親プロセスが「ユーザが実行したコマンド」という性質を利用して、forceオプションが含まれいてるかどうかを判別しています。

  command = `ps -o command= -p #{Process.ppid}`.chomp

もっと簡単にオプションを知る方法があれば是非教えてほしいです。

テスト

せっかくRubyに移行したので、比較的副作用が強くないようにメソッドの分割して、テストも書くようにしてみました。 rspecなどのGemを別途インストールするのは正直面倒だったので、minitestでそれっぽく書いています。

require_relative "../test_helper"

load "#{git_root_path}/.config/git/hooks/pre-push"

describe 'Git pre-push hook' do
  describe '#main_branch?' do
    context 'branch_name is main' do
      it 'be true' do
        assert main_branch? 'main'
      end
    end

    context 'branch_name is master' do
      it 'be true' do
        assert main_branch? 'master'
      end
    end

    context 'branch_name is develop' do
      it 'be false' do
        assert ! main_branch?('develop')
      end
    end
  end

  describe '#restrict_branches' do
    context 'branch_name is main' do
      it 'be fail' do
        assert_raises RuntimeError do
          restrict_branches 'main'
        end
      end
    end

    context 'branch_name is develop' do
      it 'be nothing' do
        restrict_branches 'develop'
      end
    end

    context 'branch_name is main && GIT_ALLOW_PUSH_MAIN_BRANCH=yes' do
      it 'be nothing' do
        current = ENV['GIT_ALLOW_PUSH_MAIN_BRANCH']
        ENV['GIT_ALLOW_PUSH_MAIN_BRANCH'] = 'yes'

        restrict_branches 'main'

        ENV['GIT_ALLOW_PUSH_MAIN_BRANCH'] = current
      end
    end
  end

  describe '#use_force_option?' do
    context '--force in command' do
      it 'be true' do
        assert use_force_option? 'git push --force origin main'
      end
    end

    context '-f in command' do
      it 'be true' do
        assert use_force_option? 'git push --force origin main'
      end
    end

    context 'not force push command' do
      it 'be false' do
        assert ! use_force_option?('git push origin master')
      end
    end
  end

  describe '#restrict_force_push' do
    context '--force-with-lease in command' do
      it 'be nothing' do
        assert_raises RuntimeError do
          restrict_force_push 'git push --force-with-lease origin master'
        end
      end
    end

    context '-f in command && GIT_ALLOW_FORCE_PUSH=yes' do
      it 'be nothing' do
        current = ENV['GIT_ALLOW_FORCE_PUSH']
        ENV['GIT_ALLOW_FORCE_PUSH'] = 'yes'

        restrict_force_push 'git push -f origin master'

        ENV['GIT_ALLOW_FORCE_PUSH'] = current
      end
    end

    context 'not force push command' do
      it 'be nothing' do
        restrict_force_push 'git push origin master'
      end
    end
  end
end

Minitestを使うのは初めてなので、正直こういう書き方でいいのか、 やっぱりrspecって便利だなと改めて思うところです。

AWS CDKでCloudFormation StackSetsで展開するIAM Roleを作る

AWS Organizationsで管理している組織に展開するCloudFormation StackSetsのテンプレートを生成するCDKのコードを載せておきます。 今回は例なので、AdministratorAccessというかなり強い権限がついているので、実際には別のマネージドポリシーや自前のポリシーを展開することが多いですね。

import * as cdk from '@aws-cdk/core';
import * as iam from '@aws-cdk/aws-iam';

export class AccountOwnerAccessRoleStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // AccountOwnerAccessRole
    const ownerRole = new iam.Role(this, 'AccountOwnerAccessRole', {
      roleName: 'AccountOwnerAccessRole',
      assumedBy: new iam.AccountPrincipal('XXXX'),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName('AdministratorAccess')
      ],
    });
  }
}

これを書いた後に、cdk synthを実行して出力された結果を何らかの方法で登録・更新すればOK。 Organizationsに対して一括適用したい場合は、Terraformを利用するよりかは、CloudFormation StackSetsを利用した方が良いですね。

GSuite BasicからGoogle Workspace Business Starterに切り替える

Googleから催促のメールがきていたので切り替えました。 その時のメモになります。

流れ

公式ドキュメントを参考にしながら、以下の流れで対応しました。

  1. プラン変更時の差分を確認
  2. 組織の設定変更
  3. プランの変更

support.google.com

プラン変更時の差分を確認

今回はBusiness Starterに切り替えるので、以下のページで差分を確認しました。

support.google.com

具体的な差分については引用しますが、

  • Chat の管理機能 - 履歴のオンとオフを切り替えることはできません。また、ユーザーに Chat の招待状を自動的に承諾させることはできません。
  • 高度なエンドポイント管理 - 会社所有のモバイル デバイスを設定したり、モバイル デバイスに個別にアプリを配信したりすることはできません。
  • 組織のブランディング - Google ドキュメントスプレッドシート、スライド、フォーム、サイトのカスタム テンプレートを作成、使用することはできなくなります。テンプレートから作成されたドキュメントは残ります。
  • Chat スペースの高度な機能 機能 - 外部ユーザーが参加できるスペースを作成することはできなくなります。既存のスペースは残り、ユーザーはこれらのスペースにメンバーの追加または削除といった変更を加えることができます。

以下の4つになります。 このうち私が影響を受ける範囲は、「高度なエンドポイント管理」のみでした。

プランの変更画面でもどういった差分があるのかは確認できます。

f:id:corrupt952:20211117031535p:plain

組織の設定変更

「高度なエンドポイント管理」のみ影響があることが分かったので、以下を参考しながら作業をしました。

support.google.com

特に問題はないと思いますが、設定漏れがあると面倒臭そうです。

プランの変更

ドキュメント通りに設定変更すれば問題ないので特筆することはないですが、割当ライセンス数と請求金額だけはチェックしておいた方が良いです。