目次
はじめに
表題のとおり、Docker で React + Go + PostgreSQL を組み合わせたアプリの開発環境を作ってみたので、その手順を書き残しておく
前提
以下のツールはあらかじめインストールしておくこと
- docker (docker desktop)
 - go
 - node.js
 - npm
 - postgresql (psql)
 - java
 
Windows で試す場合は WSL と ubuntu もインストールして、ubuntu で作業すれば構築できた
手順
以下の手順内のコマンドは基本的にプロジェクトのルート(reactapp.comフォルダ)から始めるように書いている
フォルダ移動が必要な場合は都度 cd コマンドも書いているので、見落とさないように注意してほしい
Reactインストール
まず React アプリを生成する
アプリ名は reactapp.com で生成してからフォルダ名を app に変更する
create-react は node はバージョン 14以上が必要になる
| 
					 1 2 3 4 5 6 7 8 9 10 11 12 13  | 
						# フォルダ作成 mkdir -p reactapp.com cd reactapp.com # Reactアプリ生成 npx create-react-app reactapp.com --template typescript # フォルダ名変更 mv reactapp.com app cd app # Reactアプリ起動 npm start  | 
					
React アプリの初期画面を確認したら React は停止する(CTRL+C)

Reactコンポーネント開発
React コンポーネントは storybook で確認しながら作る
Storybookインストール
app フォルダに移動してから storybook と material-ui をインストールする
| 
					 1 2 3 4 5 6 7  | 
						cd app # storybook npx storybook init # material-ui npm install @mui/material @emotion/react @emotion/styled  | 
					
コンポーネント作成
HelloWorld.tsx と HelloWorld.stories.tsx を作る
app/src/components/HelloWorld.tsx
| 
					 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27  | 
						import React from 'react'; import Button from '@mui/material/Button'; import CircularProgress from '@mui/material/CircularProgress'; export interface HelloWorldProps {     event: (callback: (m:string) => void) => Promise<void> } export const HelloWorld = (props: HelloWorldProps) => {     const [loading, setLoading] = React.useState(false);     const [message, setMessage] = React.useState("Push");     const onClick = () => {         setLoading(true)         props.event((m:string) => {             setLoading(false)             setMessage(m);         })     }     return (         <Button variant="contained" disabled={loading} onClick={onClick} sx={{width:200,height:36}}>             {!loading && <div>{message}</div>}             {loading && <CircularProgress size="1.5rem" color="success"/>}         </Button>     ) }  | 
					
app/src/components/HelloWorld.stories.tsx
| 
					 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19  | 
						import React from 'react'; import { ComponentStory, ComponentMeta } from '@storybook/react'; import { HelloWorld } from './HelloWorld'; export default {   title: 'Components/HelloWorld',   component: HelloWorld, } as ComponentMeta<typeof HelloWorld>; const Template: ComponentStory<typeof HelloWorld> = (args) => <HelloWorld {...args} />; export const Default = Template.bind({}); Default.args = {   event: async (callback: (m:string) => void) => {     const wait = async (ms: number) => new Promise(resolve => setTimeout(resolve, ms));     await wait(1000)     callback("OK")   }, };  | 
					
storybook起動
| 
					 1 2 3 4  | 
						cd app # storybook起動 npm run storybook  | 
					
起動したら storybook にアクセス( http://localhost:6006/ )して、HelloWorld コンポーネントを確認する
PUSH ボタンを押すと 1秒後に表示が OK に変わる

コンポーネント開発はこれで完了(ソースコードの説明は省く)
storybook は停止する(CTRL+C)
API設計
OAS (Open API Specification) で API を設計して、prism でモックサーバを作成する
OAS作成
まず、api.yaml を作成する
api/spec/api.yaml
| 
					 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57  | 
						openapi: 3.0.0 info:   title: api   version: '1.0'   description: reactapp.com   contact:     name: SAT.inc servers:   - url: 'http://localhost:3001' paths:   '/message/{id}':     parameters:       - schema:           type: string         name: id         in: path         required: true         description: Get message by ID     get:       summary: ''       operationId: get-message-by-id       responses:         '200':           description: OK           content:             application/json:               schema:                 $ref: '#/components/schemas/message'               examples:                 Example 1:                   value:                     text: Bonjour!       description: get message       tags:         - reactapp tags:   - name: reactapp components:   schemas:     message:       title: message       x-stoplight:         id: qwiwvgem2k86c       type: object       x-examples:         Example 1:           text: Bonjour!       properties:         id:           type: integer           description: message ID         text:           type: string           description: message text       required:         - id         - text  | 
					
モックサーバ起動
OAS から prism でモックサーバを起動する
| 
					 1 2 3 4 5  | 
						# prism sudo npm install -g @stoplight/prism-cli # モックサーバ起動 prism mock ./api/spec/api.yaml -p 3001  | 
					
モックサーバが起動したらブラウザで http://localhost:3001/message/1 へアクセスすると、{"text":"Bonjour!"} が表示される(OAS に設定した Example がそのまま返ってくる)
モックサーバは起動したまま、次に進む
Reactアプリ開発
コンポーネントを組み合わせ、モックサーバの API を呼びながら React アプリを開発する
API クライアントコード生成
まず、React で使う API クライアントコードを、OAS から生成する(コードは ./app/src/api に出力する)
| 
					 1 2 3 4 5  | 
						# openapi-generator sudo npm install -g @openapitools/openapi-generator-cli # APIクライアントコード生成 sudo openapi-generator-cli generate -g typescript-axios -i ./api/spec/api.yaml -o ./app/src/api  | 
					
パッケージインストール
HTTP クライアントに axios を、ルーティングに react-router-dom を使うので、app にインストールする
| 
					 1 2 3 4 5 6 7  | 
						cd app # axios npm install axios # react-router-dom npm install react-router-dom  | 
					
ページ実装
HelloWorld コンポーネントを使ってページ HelloWorldPage.tsx を作る
app/src/pages/HelloWorldPage.tsx
| 
					 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43  | 
						import React from 'react'; import { HelloWorld } from '../components/HelloWorld'; import { ReactappApi } from "../api/api" import Box from '@mui/material/Box'; export const HelloWorldPage = () => {     const event = async (callback: (m:string) => void) => {         const wait = async (ms: number) => new Promise(resolve => setTimeout(resolve, ms));         await wait(1000)         var message = ""         try {             const api = new ReactappApi()             const res = await api.getMessageById("1")             switch (res.status) {                 case 200:                     if (res.data.text != null) {                         message = res.data.text                     }                     break;                 default:                     alert("Error!")             }         } catch (err) {             console.log(err)             alert("Error!")         }         callback(message)     }     return (         <Box sx={{m:1}}>             <Box sx={{m:1}}>                 Please push this button             </Box>             <Box sx={{m:1}}>                 <HelloWorld event={event} />             </Box>         </Box>     ) }  | 
					
また、HelloWorldPage.tsx をホームページにするために App.tsx の内容を書き換える
app/src/App.tsx
| 
					 1 2 3 4 5 6 7 8 9 10 11 12 13 14  | 
						import { BrowserRouter, Routes, Route} from "react-router-dom"; import { HelloWorldPage } from "./pages/HelloWorldPage" function App() {   return (     <BrowserRouter>       <Routes>         <Route path="/" element={<HelloWorldPage />} />       </Routes>     </BrowserRouter>   ); } export default App;  | 
					
React起動
React を起動して、PUSH ボタンの動作を確認する
| 
					 1 2 3 4  | 
						cd app # React起動 npm start  | 
					
PUSH ボタンを押すとモックサーバの API を呼び出し、レスポンス {"text":"Bonjour!"} の text をボタンに表示する

React アプリ開発はこれで完了。モックサーバと React は停止する(CTRL+C)
データベース
API 開発の前にデータベースを用意する
PostgreSQLコンテナ
Dockerfile と docker-compose を用意する
db/Dockerfile
| 
					 1 2  | 
						FROM postgres:alpine ENV LANG ja_JP.utf8  | 
					
docker-compose.yml
| 
					 1 2 3 4 5 6 7 8 9 10 11 12 13 14  | 
						version: '3' services:   db:     build: ./db     ports:       - 5432:5432     environment:       - POSTGRES_DB=reactapp       - POSTGRES_USER=pguser       - POSTGRES_PASSWORD=password     volumes:       - reactapp-db:/var/lib/postgresql/data volumes:   reactapp-db:  | 
					
コンテナを起動する
| 
					 1 2  | 
						# DBコンテナ起動 docker-compose up -d db  | 
					
マイグレーション
migrate モジュールを使ってデータベースに message テーブルを作成する
まず、up と down のファイルを作る
db/migration/0001_create_message_table.up.sql
| 
					 1 2 3 4  | 
						CREATE TABLE IF NOT EXISTS message (    id serial PRIMARY KEY,    message TEXT NOT NULL );  | 
					
db/migration/0001_create_message_table.down.sql
| 
					 1  | 
						DROP TABLE IF EXISTS message;  | 
					
migrate をインストールして、マイグレーションを実施する
| 
					 1 2 3 4 5  | 
						# migrate go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest # マイグレーション実施 migrate -database "postgres://pguser:password@localhost:5432/reactapp?sslmode=disable" -path ./db/migration up  | 
					
サンプルデータ
psql コマンドでサンプルデータを投入する
db/sample/sample.sql
| 
					 1 2 3 4 5 6 7  | 
						INSERT INTO public.message (message) VALUES      ('Hello World!'),      ('Salve mundus!'),      ('Bonjour le monde!'),      ('Hallo Welt!'),      ('Ciao mondo!'),      ('Saluton mondo!');  | 
					
| 
					 1 2  | 
						# サンプルデータインポート psql -h localhost -U pguser -d reactapp -f ./db/sample/sample.sql  | 
					
確認
DBeaver や TablePlus を使って DB に接続して、データを確認する
| Name | Value | 
|---|---|
| Host | localhost | 
| Port | 5432 | 
| User | pguser | 
| Password | password | 
| Database | reactapp | 

DB コンテナは API 開発で使うので、起動したまま次に進む
API開発
コンテナのDBを使いながら、go で API を実装する
SQLBoiler
まず、SQLBoiler でデータベースの ORM コードを生成する
設定ファイルを作成して、
db/config/database.toml
| 
					 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15  | 
						pkgname  = "models" output   = "api/sqlboiler/models" wipe     = true no-tests = true add-enum-types = true [psql]   dbname  = "reactapp"   host    = "localhost"   port    = 5432   user    = "pguser"   pass    = "password"   schema  = "public"   sslmode = "disable"   blacklist = ["migrations", "other"]  | 
					
sqlboiler でコードを生成する
コードは api/sqlboiler/models に出力される
| 
					 1 2 3 4 5 6  | 
						# SQL Boiler go install github.com/volatiletech/sqlboiler/v4@latest go install github.com/volatiletech/sqlboiler/v4/drivers/sqlboiler-psql@latest # ORMコード生成 sqlboiler psql -c ./db/config/database.toml  | 
					
oapi-codegen
次は oapi-codegen で OAS から API のコードを生成する
出力先フォルダ api/oapi/codegen は先に作っておく
| 
					 1 2 3 4 5 6 7 8 9  | 
						# oapi-codegen go install github.com/deepmap/oapi-codegen/cmd/oapi-codegen@latest # 出力フォルダ作成 mkdir -p api/oapi/codegen # APIコード生成 oapi-codegen -old-config-style -generate chi-server -package codegen -o ./api/oapi/codegen/api.gen.go ./api/spec/api.yaml oapi-codegen -old-config-style -generate types -package codegen -o ./api/oapi/codegen/model.gen.go ./api/spec/api.yaml  | 
					
API実装
以下の 4つのファイルを作成する
api/main.go
| 
					 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18  | 
						package main import (     "log"     "net/http"     "reactapp.com/api/oapi" ) func main() {     var httpServer http.Server     oapi.SetupHandler()     log.Println("start http listening :3001")     httpServer.Addr = ":3001"     log.Println(httpServer.ListenAndServe()) }  | 
					
api/oapi/handler.go
| 
					 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35  | 
						package oapi import (     "net/http"     "reactapp.com/api/oapi/codegen"     "github.com/go-chi/chi/v5"     "github.com/go-chi/cors" ) type API struct{} func SetupHandler() {     r := chi.NewRouter()     r.Use(cors.Handler(cors.Options{         AllowedOrigins:   []string{"https://*", "http://*"},         AllowedMethods:   []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},         AllowedHeaders:   []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},         ExposedHeaders:   []string{"Link"},         AllowCredentials: false,         MaxAge:           300,     }))     r.Group(func(r chi.Router) {         r.Options("/*", func(w http.ResponseWriter, r *http.Request) {             w.Header().Set("Access-Control-Allow-Headers", "*")             w.Header().Set("Access-Control-Allow-Origin", "*")             w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")         })     })     http.Handle("/", codegen.HandlerFromMux(&API{}, r)) }  | 
					
api/oapi/get_message.go
| 
					 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50  | 
						package oapi import (     "context"     "database/sql"     "encoding/json"     "net/http"     "os"     "github.com/volatiletech/sqlboiler/v4/queries/qm"     "reactapp.com/api/oapi/codegen"     "reactapp.com/api/sqlboiler/models"     _ "github.com/lib/pq" ) func (api *API) GetMessageById(w http.ResponseWriter, r *http.Request, id string) {     ctx := context.Background()     db, err := sql.Open(os.Getenv("DBDRIVER"), os.Getenv("DBDSN"))     if err != nil {         http.Error(w, err.Error(), http.StatusInternalServerError)         return     }     defer db.Close()     m, err := models.Messages(         qm.Where("id = ?", id),     ).One(ctx, db)     if err != nil {         http.Error(w, err.Error(), http.StatusNoContent)         return     }     res := codegen.Message{         Id:   m.ID,         Text: m.Message,     }     b, err := json.Marshal(res)     if err != nil {         http.Error(w, err.Error(), http.StatusInternalServerError)         return     }     if _, err := w.Write(b); err != nil {         http.Error(w, err.Error(), http.StatusInternalServerError)         return     } }  | 
					
api/oapi/get_message_test.go
| 
					 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100  | 
						package oapi import (     "context"     "encoding/json"     "fmt"     "io/ioutil"     "net/http"     "net/http/httptest"     "os"     "testing"     "github.com/go-chi/chi/v5"     "github.com/stretchr/testify/assert"     "github.com/stretchr/testify/require"     "reactapp.com/api/oapi/codegen" ) func newTestRouter() *chi.Mux {     router := chi.NewRouter()     router.Route("/", func(subr chi.Router) {         codegen.HandlerFromMux(&API{}, subr)     })     return router } func TestGetMessage(t *testing.T) {     router := newTestRouter()     url := "/message/%d"     tests := []struct {         name   string         req    func() (*http.Request, error)         assert func(*httptest.ResponseRecorder, *http.Request)     }{         {             name: "404 Not Found",             req: func() (*http.Request, error) {                 return http.NewRequest(http.MethodPost, "http://example.com", nil)             },             assert: func(w *httptest.ResponseRecorder, r *http.Request) {                 assert.Equal(t, http.StatusNotFound, w.Result().StatusCode)             },         },         {             name: "500 Internal Server Error",             req: func() (*http.Request, error) {                 return http.NewRequest(http.MethodGet, fmt.Sprintf(url, 1), nil)             },             assert: func(w *httptest.ResponseRecorder, r *http.Request) {                 require.Equal(t, http.StatusInternalServerError, w.Result().StatusCode)             },         },         {             name: "204 No Content",             req: func() (*http.Request, error) {                 os.Setenv("DBDRIVER", "postgres")                 os.Setenv("DBDSN", "host=localhost port=5432 dbname=reactapp user=pguser password=password sslmode=disable")                 return http.NewRequest(http.MethodGet, fmt.Sprintf(url, 0), nil)             },             assert: func(w *httptest.ResponseRecorder, r *http.Request) {                 require.Equal(t, http.StatusNoContent, w.Result().StatusCode)             },         },         {             name: "200 OK",             req: func() (*http.Request, error) {                 return http.NewRequest(http.MethodGet, fmt.Sprintf(url, 3), nil)             },             assert: func(w *httptest.ResponseRecorder, r *http.Request) {                 require.Equal(t, http.StatusOK, w.Result().StatusCode)                 b, err := ioutil.ReadAll(w.Body)                 require.NoError(t, err)                 actual := codegen.Message{}                 require.NoError(t, json.Unmarshal(b, &actual))                 assert.Equal(t, "Bonjour le monde!", actual.Text)             },         },     }     for _, tt := range tests {         tt := tt         t.Run(tt.name, func(t *testing.T) {             ctx := context.Background()             w := httptest.NewRecorder()             r, err := tt.req()             if assert.NoError(t, err) {                 r = r.WithContext(ctx)                 router.ServeHTTP(w, r)                 tt.assert(w, r)             }         })     } }  | 
					
go mod tidy で使用している go のモジュールをすべてインストールする
| 
					 1 2 3 4  | 
						cd api go mod init reactapp.com/api go mod tidy  | 
					
ユニットテスト実施
| 
					 1 2 3 4  | 
						cd api/oapi    # ユニットテスト実施 go test -v  | 
					
以下のように出力されれば OK
| 
					 1 2 3 4 5 6 7 8 9 10 11 12  | 
						=== RUN   TestGetMessage === RUN   TestGetMessage/404_Not_Found === RUN   TestGetMessage/500_Internal_Server_Error === RUN   TestGetMessage/204_No_Content === RUN   TestGetMessage/200_OK --- PASS: TestGetMessage (0.02s)     --- PASS: TestGetMessage/404_Not_Found (0.00s)     --- PASS: TestGetMessage/500_Internal_Server_Error (0.00s)     --- PASS: TestGetMessage/204_No_Content (0.01s)     --- PASS: TestGetMessage/200_OK (0.01s) PASS ok      reactapp.com/api/oapi   0.019s  | 
					
API 実装はこれで完了。DB コンテナはいったん停止する
| 
					 1  | 
						docker-compose down  | 
					
Dockerコンテナ
React アプリと API サーバも Docker コンテナで動かして、アプリ全体を結合する
Reactコンテナ
React コンテナの Dockerfile を作成する
app/Dockerfile
| 
					 1 2 3 4 5 6 7 8 9  | 
						FROM node:16.16.0 WORKDIR /app COPY ./*.json ./ COPY ./src ./ COPY ./public ./ RUN npm cache clean --force  RUN npm ci CMD ["npm", "start"]  | 
					
APIコンテナ
API コンテナの Dockerfile を作成する
api/Dockerfile
| 
					 1 2 3 4  | 
						FROM golang:1.19.0 ADD ./ /go/api WORKDIR /go/api CMD ["go", "run", "main.go"]  | 
					
docker-compose
React サーバと API サーバを docker-compose に追加する
docker-compose.yml
| 
					 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30  | 
						version: '3' services:   db:     build: ./db     ports:       - 5432:5432     environment:       - POSTGRES_DB=reactapp       - POSTGRES_USER=pguser       - POSTGRES_PASSWORD=password     volumes:       - reactapp-db:/var/lib/postgresql/data   api:     build: ./api     ports:       - 3001:3001     environment:       - DBDRIVER=postgres       - DBDSN=host=db port=5432 dbname=reactapp user=pguser password=password sslmode=disable   app:     build: ./app     ports:       - 3000:3000     volumes:       - /app/node_modules       - ./app:/app     environment:       - NODE_ENV=development volumes:   reactapp-db:  | 
					
コンテナ起動
すべてのコンテナをビルドして、起動する
| 
					 1 2 3 4 5  | 
						# ビルド docker-compose build # 起動 docker-compose up -d  | 
					
確認
ブラウザでアプリ( http://localhost:3000/ )にアクセスする
PUSH ボタンを押すと API を介して DB からメッセージデータを取得して、1秒後に Hello World! とボタンに表示される

以上で、React ⇔ API ⇔ DB がそれぞれ連携したアプリがすべて Docker コンテナで動作する環境が出来上がった
おわりに
ちなみにローカルのコードを修正してもコンテナには反映されないので、コンテナ停止してビルドして再起動しなければならない
なので、コンポーネント/React アプリ/API はそれぞれ独立して開発して、コンテナは結合しての動作確認に使う、ということになる? それはそれで面倒な気がする
それと、この環境だと API が CORS に引っかかって Cookie や Session が使えない。対応方法は下の記事を参照してほしい
