5
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Web フロントエンドしかやってこなかった人がバックエンドに入門する

Last updated at Posted at 2024-09-13

この記事は書きかけです。

概要

 皆さん Web はお好きでしょうか。筆者はずっとフロントと esbuild プラグインばかりを書いてきてバックエンドはサボってきたのでそろそろバックエンドを触れるようになろうとおもいました。この記事はバックエンドに入門するために行ったことの記録です。

 今回は Docker Compose を用いてすべてのコンポーネントを作成します。すなわち nginx、API サーバー (Go)、データベース (PostgreSQL)、Redis をそれぞれ別のコンテナで作成しルーティングします。

 成果物のレポジトリは以下のものになります。

Step 1. 仮設 API サーバーを作る

サーバーを書く

 今回は REST API サーバーを Go で書いていきます。まず api ディレクトリで go mod init main してプロジェクトを作り、main.go にサーバーのコードを記述していきます。一旦 /ping に対するリクエストに応答するだけのサーバーを作ります。簡単のためコマンドライン引数を扱わずに環境変数を使います。

api/main.go
package main

import (
   "fmt"
   "log"
   "net/http"
   "os"
)

func ping(w http.ResponseWriter, req *http.Request) {
   fmt.Fprintf(w, "pong")
}

func main() {
   addr := os.Getenv("ip")
   port := os.Getenv("port")

   if addr == "" {
   	addr = "0.0.0.0"
   }
   if port == "" {
   	port = "8080"
   }

   http.HandleFunc("/ping", ping)
   log.Printf("Server listening on %v:%v", addr, port)
   log.Fatal(http.ListenAndServe(addr+":"+port, nil))
}

 この状態で go run main.go をするとサーバーの待ち受けが開始します。問題がなければ localhost:8080/ping に GET することで pong と返ってくるはずです。

私の環境では docker compose でログを見るとき fmt.Printf を使うとフラッシュされずログが出力されませんでしたが、log.Printf を使うことで出力されるようになりました。

Docker, Docker Compose の設定

 次に開発環境の Dockerfile を書きます。練習なので root ユーザーでいいとします。 Go のインストールや PATH の設定を行っておきます。

api/Dockerfile
FROM ubuntu:24.04 AS base

RUN apt update && apt upgrade -y


FROM base AS dev

ARG USERNAME

WORKDIR /tmp
RUN apt install -y wget tar
RUN wget https://go.dev/dl/go1.23.0.linux-amd64.tar.gz
RUN tar -C /usr/local -xzf go1.23.0.linux-amd64.tar.gz
ENV PATH $PATH:/usr/local/go/bin

WORKDIR /api

CMD ["/bin/bash"]

 次にプロジェクトのルートディレクトリで docker-compose.yml を書きます。

docker-compose.yml
services:
 api-dev:
   build:
     context: './api'
     dockerfile: './Dockerfile'
     target: dev
   volumes:
     - type: bind
       source: ./api
       target: /api
   working_dir: /api
   ports:
     - target: 8081
       published: 8081
   tty: true
   stdin_open: true

 これにより開発用のコンテナの設定ができました。今回はローカルにある go を使ってしまうのであまり使いませんが......。コンテナを立ち上げて go run すると待受が開始するはずです。今回はコンテナの 8080 ポートをホストの 8080 ポートに転送しているので先程と同様に localhost:8080 でアクセスできるはずです。

$ docker compose up api-dev -d
$ docker compose exec api-dev /usr/local/go/bin/go run /api/main.go
$ curl localhost:8080/ping
# pong

本番環境を作る

 開発環境ができたので本番環境を作っていきます。Dockerfile に build ステージを追加してビルドを実行します。さらに prod ステージを追加して build ステージで作成したバイナリをコピーしてきます。これにより build ステージに開発環境は入らずバイナリだけが存在する状況にします。

api/Dockerfile
# 追加
FROM dev AS build

COPY . /api
RUN go clean
RUN go build -o /tmp/main /api/main.go


FROM base AS prod

COPY --from=build /api/main /api/server

CMD ["/api/server"]

 docker-compose.yml にもサービスを追加しておきます。

docker-compose.yml
 # 追記
 services:
  api:
    build:
      context: './api'
      dockerfile: './Dockerfile'
      target: prod
    ports:
      - target: 8080
        published: 8080

 これで docker compose up api するだけでサーバーが待ち受けしてくれるようになりました。

Air を導入する

 Air は Go のライブリロードを提供するツールです。ソースファイルの変更が検出されると再ビルド・実行を自動で行ってくれます。API サーバーの編集を試行錯誤しているときに便利です。

$ go install air
$ air init

次に Dockerfile を編集します。

WORKDIR /tmp
RUN apt install -y wget tar
RUN wget https://go.dev/dl/go1.23.0.linux-amd64.tar.gz
RUN tar -C /usr/local -xzf go1.23.0.linux-amd64.tar.gz
ENV GOPATH /root/go
ENV PATH $PATH:/usr/local/go/bin:$GOPATH

RUN go install github.com/air-verse/air@latest

WORKDIR /api
COPY ./go.mod ./go.sum ./
RUN go mod download

CMD ["/root/go/bin/air"]

2. nginx を設定する

Docker Compose のコンテナ間ネットワークの動作確認

 コンテナ間ネットワークの知識がなかったので動作確認をしました。まず適当にクライアント用コンテナを用意します。

client/Dockerfile
FROM ubuntu:24.04

RUN apt update && apt upgrade -y
RUN apt install -y iputils-ping net-tools dnsutils curl wget
docker-compose.yml
# 追記
services:
  client:
    build:
      context: './client'
      dockerfile: './Dockerfile'
    command: /bin/bash
    tty: true
    stdin_open: true

クライアントコンテナ内で色々実行してみます

$ ping api -c 3
PING api (172.23.0.3) 56(84) bytes of data.
64 bytes from web-backend-practice-api-1.web-backend-practice_default (172.23.0.3): icmp_seq=1 ttl=64 time=0.039 ms
64 bytes from web-backend-practice-api-1.web-backend-practice_default (172.23.0.3): icmp_seq=2 ttl=64 time=0.044 ms
64 bytes from web-backend-practice-api-1.web-backend-practice_default (172.23.0.3): icmp_seq=3 ttl=64 time=0.043 ms

--- api ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2047ms
rtt min/avg/max/mdev = 0.039/0.042/0.044/0.002 ms
$ nslookup api
Server:         127.0.0.11
Address:        127.0.0.11#53

Non-authoritative answer:
Name:   api
Address: 172.23.0.3
$ curl api:8080/ping
pong

 docker コマンドでネットワークも見てみます

$ docker network ls
NETWORK ID     NAME                                      DRIVER    SCOPE
b177407351c4   bridge                                    bridge    local
158aba97f065   host                                      host      local
83d9f6d78a10   none                                      null      local
d0c0268df987   web-backend-practice_default              bridge    local

 api と client が同一の bridge ネットワークに接続されているということがわかります。DNS が設定されていて apiclient のホスト名を IP アドレスに解決してくれるみたいです。

nginx を導入

docker-compose.yml
# 追記
services: 
  nginx:
    image: nginx:latest
    ports:
      - target: 3000
        published: 3000
    depends_on:
      - api
    tty: true
    stdin_open: true

 この状態で localhost:3000 にアクセスすると nginx の welcome ページが表示されます。次にリバースプロキシを設定して /api へのアクセスを API サーバーへ転送するようにします。設定ファイルを記述し /etc/nginx/conf.d にマウントします。

nginx/server.conf
server {
    listen 3000;
    server_name localhost;

    location /api/ {
        proxy_pass http://api:8080/;
    }
}
docker-compose.yml
services: 
 nginx:
   image: nginx:latest
   ports:
     - target: 3000
       published: 3000
+   volumes:
+     - type: bind
+       source: './nginx'
+       target: '/etc/nginx/conf.d'
   depends_on:
     - api
   tty: true
   stdin_open: true

proxy_pass の最後にスラッシュ / をつけると /api/foo/barapi:8080/foo/bar へ転送されるようになります。スラッシュを付けないと api:8080/api/foo/bar へ転送されるようになります。私はこれに 15 分ぐらい嵌りました。

 ここまで設定すると localhost:3000/api/ping へのアクセスで pong と返ってくるようになります。

3. DB を追加する

Postgres を導入する

 続いて DBMS を追加します。今回は PostgreSQL を使用します。理由はなんとなくモダンな気がするからです。まずは公式の Postgres イメージを用いて db サービスを追加します。DB サーバーのユーザー名、パスワード、データベース名は .env ファイルに書いておきます (Compose は勝手に .env ファイルを読み込んでくれます)。POSTGRES_USER, POSTGRES_DB 環境変数を設定しておくことで DB サーバー起動時にユーザー・データベースがあるかを調べない場合には作成してくれます。

docker-compose.yaml
# 追記
db:
  image: postgres:16
  ports:
    - target: 5432
      published: 5432
    environment:
      POSTGRES_USER: $POSTGRES_USER
      POSTGRES_PASSWORD: $POSTGRES_PASSWORD
      POSTGRES_DB: $POSTGRES_DB

次に api サービスと api-dev サービスにも環境変数の設定を追加します。nginx の設定時と同様にホスト名に db を指定すると立ち上がっている postgres コンテナに接続できます。

docker-compose.yml
  services:
    api:
      build:
        context: './api'
        dockerfile: './Dockerfile'
        target: prod
+     depends_on:
+       - db
+     environment:
+       DB_HOST: db
+       DB_PORT: 5432
+       DB_USER: $POSTGRES_USER
+       DB_PASS: $POSTGRES_PASSWORD
+       DB_NAME: $POSTGRES_DB
      ports:
        - target: 8080
          published: 8080
  # ...
    api-dev:
      build:
        context: './api'
        dockerfile: './Dockerfile'
        target: dev
      volumes:
        - type: bind
          source: ./api
          target: /api
+     depends_on:
+       - db
+     environment:
+       PORT: 8081
+       DB_HOST: db
+       DB_PORT: 5432
+       DB_USER: $POSTGRES_USER
+       DB_PASS: $POSTGRES_PASSWORD
+       DB_NAME: $POSTGRES_DB
      working_dir: /api
      ports:
        - target: 8081
          published: 8081
  # ...

4. API を作成する

 次に API サーバーに DB を使う機能を実装していきます。まずはできるだけ簡単な実装をするために ORM や大規模なルーターライブラリを使わずに実装していきます。今回はパスパラメータを用意に扱えるようにするために gorilla/mux を使用します。

サーバーの設計 (概要)

 Repository モデルでは、サーバーを次のようなコンポーネントに階層的に分離して構成します。

  • Router: HTTP リクエストをパスなどに基づいて Controller に振り分ける
  • Controller (Handler): HTTP リクエストのパース・レスポンスの作成を行う
    • 処理の詳細は Usecase に分離する
  • Usecase: 各リクエストに対応する一連の処理を行う
  • Repository: DB に対する処理を行う

例えばユーザーと商品、ユーザーの購入した商品の発送状況を管理する API は次のように構成することができそうです。

サーバーの扱うデータのモデルを設計

 今回は ToDo アプリを作ってみます。API の使用者から見たユーザーと ToDo を次のように定義します。ユーザーは name によって識別され、ToDo は ref によって識別されます。

データベースの設計

 データベースでは API の外側とは異なりユーザー・ToDo は primary key である id によって識別するようにします。これは idSERIAL (連番; MySQL でいう AUTO INCREMENT) であることから id が推測されるのを回避するためです。
 今回は DB の migration ファイルのような形で作成します。このスクリプトは冪等 (ソフトウェアの文脈での冪等 = 何度実行しても結果が変わらない) です。

#!/bin/bash
set -euo pipefail

function query {
    psql -d "$POSTGRES_DB" -U "$POSTGRES_USER" -w -c "$1"
}

query 'DROP TABLE IF EXISTS users CASCADE;'
query 'CREATE TABLE IF NOT EXISTS users(
    id SERIAL PRIMARY KEY,
    name VARCHAR(255) NOT NULL UNIQUE,
    display_name VARCHAR(255) NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);'

query 'DROP TABLE IF EXISTS todos CASCADE;'
query 'CREATE TABLE IF NOT EXISTS todos ( 
    id SERIAL PRIMARY KEY,
    ref UUID DEFAULT gen_random_uuid(),
    user_id INTEGER NOT NULL,
    title VARCHAR(255) NOT NULL,
    done BOOLEAN NOT NULL DEFAULT FALSE,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);'

API の設計

 API は次のように設計します。

メソッド エンドポイント 機能 body
(any) /ping `pong と返答する
POST /users ユーザーを作成 {"name": string, "display_name": string}
DELETE /users/{userName} ユーザーを削除
GET /users/{userName}/todos ユーザーの ToDo 一覧を取得
POST /users/{userName}/todos ユーザーの ToDo を作成 {"name": string}
PATCH /users/{userName}/todos/{todoRef} ToDo の状態を更新 {"done": boolean }
DELETE /users/{userName}/todos/{todoRef} ToDo を削除

モデルの作成

 Go のコードでは DB に対応する entity.User, entity.Todo と API に対応する model.User, model.Todo を作成します。

repository の作成

 ユーザーの ToDo 一覧を取得する機能を例として見ていきます。repository は DB を操作する役割を持っており、このメソッドはユーザーの ID を指定して ToDo の一覧を返します。ポイントは次の点です。

  • sql.DB.Query で複数行のクエリを発行できる
    • クエリ内に $1 のように指定して可変長引数で実際の値を指定する
      • SQL injection 対策
    • sql.DB.QueryRow で単一行のクエリを発行できる
  • sql.Rows.Next で次の行を選択できる
  • sql.Rows.Scan で現在の行をパースできる
    • sql.Rows.Scan でパースする順番は sql.DB.Query で指定した順になる
type TodoRepository struct {
	db *sql.DB
}

func (r *TodoRepository) ListByUserID(userID uint) ([]entity.Todo, error) {
    // クエリを発行
	rows, err := r.db.Query(`SELECT id, ref, user_id, title, done FROM todos WHERE user_id = $1;`, userID)
	if err != nil {
		return nil, err
	}

    // クエリの結果の各行をパース
	todos := make([]entity.Todo, 0)
	for rows.Next() {
		todo := entity.Todo{}
		if err = rows.Scan(&todo.ID, &todo.Ref, &todo.UserID, &todo.Title, &todo.Done); err != nil {
			return nil, err
		}

		todos = append(todos, todo)
	}

	return todos, nil
}

usecase の作成

 次に usecase を見ていきます。usecase では API の入力である userName を受け取って API の出力である model.Todo のリストを返しています。複数の repository の機能を呼び出すことで一連の処理を実装しています。

type TodoUsecase struct {
	userRepo UserRepository
	todoRepo TodoRepository
}

func (u *TodoUsecase) ListByUser(userName string) ([]model.Todo, error) {
    // ユーザー名からユーザー ID を取得
	user, err := u.userRepo.GetByName(userName)
	if err == sql.ErrNoRows {
		return nil, NoUserError
	} else if err != nil {
		return nil, err
	}

    // ユーザー ID から ToDo 一覧を取得
	todoEntities, err := u.todoRepo.ListByUserID(user.ID)
	if err != nil {
		return nil, err
	}

    // ToDo の entity から model に変換
	todos := make([]model.Todo, 0, len(todoEntities))
	for _, todoEntity := range todoEntities {
		todos = append(todos, *model.NewTodoFromEntity(&todoEntity, userName))
	}

	return todos, nil
}

handler (contoller) を作成

 handler では HTTP リクエストを処理しますが、実装の詳細は usecase に投げます。以下の例は少々複雑ですが、ToDo 一覧機能に対して net/httphttp.Handler を実装しています。

type ListByUserNameHandler struct {
	todoUsecase TodoUsecase
	UserNameKey string
}

func NewListByUserNameHandler(todoUsecase TodoUsecase, userNameKey string) ListByUserNameHandler {
	return ListByUserNameHandler{
		todoUsecase: todoUsecase,
		UserNameKey: userNameKey,
	}
}

func (h ListByUserNameHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    // パスパラメータの解析
	vars := mux.Vars(r)

	userName := vars[h.UserNameKey]
	if userName == "" {
		handler.RespondError(w, "User name must not be empty.", http.StatusBadRequest)
		return
	}

    // ビジネスロジックは usecase に隠蔽
	todos, err := h.todoUsecase.ListByUser(userName)
	if err == NoUserError {
		handler.RespondError(w, "User not found.", http.StatusNotFound)
		return
	} else if err != nil {
		log.Printf("Failed to get user. %v", err)
		handler.RespondError(w, "Failed to get user.", http.StatusInternalServerError)
		return
	}

    // JSON を 200 で返す
	res, err := json.Marshal(todos)
	if err != nil {
		log.Printf("Failed to marshal JSON: %v", err)
		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json; charset=UTF-8")
	w.WriteHeader(http.StatusOK)
	w.Write(res)
}

リクエストしてみる

 他の機能も実装していき簡易的なスクリプトで思った通りの結果になっているかを確認します。

PROTOCOL="${PROTOCOL:-http}"
HOST="${HOST:-localhost:8080}"
ORIGIN="$PROTOCOL://$HOST"

# ユーザーを作成
curl -sSL \
    -X POST \
    -H "Content-Type: application/json" \
    -d '{"name": "user1", "display_name": "User 1"}' \
    "$ORIGIN/users"

# ToDo を作成
curl -sSL \
    -X POST \
    -H "Content-Type: application/json" \
    -d '{"title": "Todo 1"}' \
    "$ORIGIN/users/user1/todos"

# ToDo 一覧を取得
curl -sSL \
    -X GET \
    "$ORIGIN/users/user1/todos"

5. 負荷テスト

 K6 を使って負荷テストをしてみます。

 細かい実装やフロントより先に k6 を使ってみたかったのでやります。まずは /api/ping に対する負荷テストを書いてみます。GitHub からバイナリをダウンロードし、k6 new ping.js でテンプレートを生成します (Docker にすればよかったですね)。

次の設定ファイルはユーザー数が 0 -(10秒)→ 100 -(10秒)→ 200 -(10秒)→ 0 と変化するように設定したものです。options に設定したものはコマンドライン引数で上書きできるようなので、複数のテストで同じケースにすることができそうですね。

k6/ping.js
import http from "k6/http";
import { sleep } from "k6";

export const options = {
  stages: [
    { duration: "10s", target: 100 },
    { duration: "10s", target: 200 },
    { duration: "10s", target: 0 },
  ],
};

export default function () {
  http.get("http://localhost:3000/api/ping");
  sleep(1);
}

実行結果


          /\      |‾‾| /‾‾/   /‾‾/
     /\  /  \     |  |/  /   /  /
    /  \/    \    |     (   /   ‾‾\
   /          \   |  |\  \ |  (‾)  |
  / __________ \  |__| \__\ \_____/ .io

     execution: local
        script: ping.js
        output: -

     scenarios: (100.00%) 1 scenario, 200 max VUs, 1m0s max duration (incl. graceful stop):
              * default: Up to 200 looping VUs for 30s over 3 stages (gracefulRampDown: 30s, gracefulStop: 30s)


     data_received..................: 520 kB 17 kB/s
     data_sent......................: 276 kB 9.1 kB/s
     http_req_blocked...............: avg=65.95µs min=625ns    med=4.79µs  max=37.13ms p(90)=14.38µs p(95)=284.98µs
     http_req_connecting............: avg=49.92µs min=0s       med=0s      max=37.02ms p(90)=0s      p(95)=196.66µs
     http_req_duration..............: avg=2.54ms  min=507.41µs med=1.93ms  max=13.99ms p(90)=4.82ms  p(95)=5.82ms
       { expected_response:true }...: avg=2.54ms  min=507.41µs med=1.93ms  max=13.99ms p(90)=4.82ms  p(95)=5.82ms
     http_req_failed................: 0.00%  ✓ 0          ✗ 3134
     http_req_receiving.............: avg=78.46µs min=4.14µs   med=52.86µs max=3.1ms   p(90)=159.8µs p(95)=229.75µs
     http_req_sending...............: avg=31.5µs  min=2.21µs   med=17.73µs max=1.58ms  p(90)=62.26µs p(95)=104.86µs
     http_req_tls_handshaking.......: avg=0s      min=0s       med=0s      max=0s      p(90)=0s      p(95)=0s
     http_req_waiting...............: avg=2.43ms  min=486.89µs med=1.85ms  max=13.6ms  p(90)=4.67ms  p(95)=5.67ms
     http_reqs......................: 3134   103.412894/s
     iteration_duration.............: avg=1s      min=1s       med=1s      max=1.03s   p(90)=1s      p(95)=1s
     iterations.....................: 3134   103.412894/s
     vus............................: 14     min=14       max=199
     vus_max........................: 200    min=200      max=200


running (0m30.3s), 000/200 VUs, 3134 complete and 0 interrupted iterations
default ✓ [======================================] 000/200 VUs  30s

送られたHTTP リクエスト数が 3134、平均待ち時間が 2.54ms、失敗したリクエストの数が 0 であることなどがわかります。

 今度は TODO リストを取得する負荷テストを書いてみます。setup 関数で初期化処理、teardown 関数で終了処理を書くことができます。初期化処理は VU 共通のため複数ユーザーを生成しておきます。

k6/listTodo.js
import http from "k6/http";
import { sleep } from "k6";

export const options = {
  stages: [
    { duration: "10s", target: 100 },
    { duration: "10s", target: 200 },
    { duration: "10s", target: 0 },
  ],
};

const numUsers = 10;
const numTodos = 10;

export function setup() {
  const userIds = [];
  for (let i = 0; i < numUsers; i++) {
    const res = http.post(`http://localhost:3000/api/users?name=user${i}`);
    const userId = res.json().id;

    if (typeof userId !== "number") {
      throw new Error("userId is not a number");
    }

    for (let i = 0; i < numTodos; i++) {
      http.post(`http://localhost:3000/api/users/${userId}/todos?title=aaa`);
    }

    userIds.push(userId);
  }

  return { userIds };
}

export default function ({ userIds }) {
  const userId = userIds[Math.floor(Math.random() * userIds.length)];

  http.get(`http://localhost:3000/api/users/${userId}/todos`);

  sleep(1);
}

 試しに VU 数を増やしてみると nginx のワーカー数が足りなくなるといった挙動が観察でき面白いです。

これ以降は未実装です。

ORM を使う

  • SQLBoiler or gorm

API 仕様書を使う

  • OpenAPI

Router を使う

  • echo

セッションの実装

  • redis を使ってセッションを実装
  • TODO へのアクセスをユーザーでフィルタリング
  • ログインは実装しない (このユーザーのセッションをくださいって言ったらもらえる)
    • 名前を unique にする

キャッシュの実装

  • redis を使ってキャッシュしてみる

自動スケール

  • API サーバー分散できるようにする
  • 分散 DB を使う (かも)
  • 実際に auto scaling するかは微妙

(おまけ) フロントを作る

  • 適当
    • React か Next か Lit か VanillaJS か......
    • Svelte とか Remix 使ってみてもいい
    • Next とか Remix 使うには小さすぎるかも

(おまけ) Server sent events

  • 複数タブで TODO を編集したら同期する
  • フロントでの optimistic update が正当化される

TODO

  • DB を永続化する (volume を使う)
  • API サーバー起動時に DB サーバーの起動を待つようにする
  • nginx 起動時に API サーバー起動を待つようにする or なくても起動するようにする
  • CI を書く

他にやりたかったこと

  • SQL のパフォーマンスチューニング
    • ある程度でかくて複雑な DB じゃないとできないため
  • GraphQL での API 実装
    • graph 使うほどデータ自体が複雑でないため
  • AWS などへの CD
    • お金がかかるので......

参考記事・文献

5
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?