ReactとGoで作るWebアプリの自分向けテンプレート
ReactとGoを使ってWebアプリを書くことが少しだけ増えたんですが、自分向けのメモ程度に作ったテンプレートを公開しておきます。
sandboxというリポジトリに気になったことを実験して放り込んでいます。
今回も特に頻繁に使うものでもないし、メンテする予定もないのでSandboxに置いておきました。
- テンプレートのゴール
- ディレクトリ構成
- frontendディレクトリを作成する
- backendディレクトリを作成する
- 開発用のdocker-compose.yamlを作成する
- 開発環境にアクセスする
- 本番向けのDockerfileを作成する
- おわりに
テンプレートのゴール
ゴールとしては、以下のように設定しました。
- 最終的な成果物(本番向け)は、単一コンテナイメージになること
- 開発でもコンテナを使うこと
- 開発で利用するコンテナは、両方ともホットリロードされること
また、以下のような項目はやらない、もしくは考慮しないようにしています。
ディレクトリ構成
ディレクトリ構成はいたってシンプルな構成になっています。
. ├── Dockerfile # 本番向けのコンテナイメージ ├── backend # バックエンドアプリ(Golang) ├── frontend # フロントエンドアプリ(Vite+React+TypeScript) └── docker-compose.yaml # 開発用のdocker-compose.yaml
dockerディレクトリで各種Dockerfileを管理するのも良いのですが、今回はシンプルさのためだけにこういった構成にしています。
frontendディレクトリを作成する
frontendディレクトリは、簡単に書くと
- Vite用のプロジェクトを作成する
- 開発用コンテナの定義を書く(Dockerfileなど)
- 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で組んでいましたが、使わなくなったのでここに供養します。
今回供養するスクリプトの概要としては、「毎日、日報を書くページを作る」ようなスクリプトです。
スクリプトの大まかな流れとしては、
- 指定したDBのIDに対してページを作成する
- 作成するページのタイトルを担当者名にし、日付にスクリプトが実行された日を入れる
というシンプルな流れです。
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や他サービスで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
スクリプトは、shebangでRubyとして実行されるようにしている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から催促のメールがきていたので切り替えました。 その時のメモになります。
流れ
公式ドキュメントを参考にしながら、以下の流れで対応しました。
- プラン変更時の差分を確認
- 組織の設定変更
- プランの変更
プラン変更時の差分を確認
今回はBusiness Starterに切り替えるので、以下のページで差分を確認しました。
具体的な差分については引用しますが、
- Chat の管理機能 - 履歴のオンとオフを切り替えることはできません。また、ユーザーに Chat の招待状を自動的に承諾させることはできません。
- 高度なエンドポイント管理 - 会社所有のモバイル デバイスを設定したり、モバイル デバイスに個別にアプリを配信したりすることはできません。
- 組織のブランディング - Google ドキュメント、スプレッドシート、スライド、フォーム、サイトのカスタム テンプレートを作成、使用することはできなくなります。テンプレートから作成されたドキュメントは残ります。
- Chat スペースの高度な機能 機能 - 外部ユーザーが参加できるスペースを作成することはできなくなります。既存のスペースは残り、ユーザーはこれらのスペースにメンバーの追加または削除といった変更を加えることができます。
以下の4つになります。 このうち私が影響を受ける範囲は、「高度なエンドポイント管理」のみでした。
プランの変更画面でもどういった差分があるのかは確認できます。
組織の設定変更
「高度なエンドポイント管理」のみ影響があることが分かったので、以下を参考しながら作業をしました。
特に問題はないと思いますが、設定漏れがあると面倒臭そうです。
プランの変更
ドキュメント通りに設定変更すれば問題ないので特筆することはないですが、割当ライセンス数と請求金額だけはチェックしておいた方が良いです。