4
5

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 するだけでサーバーが待ち受けしてくれるようになりました。

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
  # ...

DB を使う API を作成する

この節は需要な部分だけを記述しています。変更全体はこのコミットを参照してください。

 DB を使う最小限の API を作成していきます。今回は例に倣って TODO アプリを作っていきます。この TODO アプリはユーザー情報を格納するテーブルと TODO を格納するテーブルがあり、TODO は 1 人のユーザーに紐づいています。

 なお一旦は ORM を用いず database/sql ベタ書きで実装していきます。またマイグレーションツールなども用いずに作成していきます。パスのフィルタリングやユーザー ID の一致の検証なども省いていきます。これらは後々実装します。

 まずテーブルは次のように作成することにします。

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

CREATE TABLE IF NOT EXISTS todos (
   id SERIAL NOT NULL,
   user_id INT 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,
   PRIMARY KEY (id),
   FOREIGN KEY (user_id) REFERENCES users(id)
);

database/sqlsql.DB インスタンスを作成します。今回は適当にラップした MyDB 構造体を用意して適当なメソッドを生やしていくことにします。postgres ドライバーを使用するためには github.com/lib/pq が必要なことに注意が必要です。

api/mydb/mydb.go
type DBInit struct {
	Host     string
	Port     string
	User     string
	Password string
	Name     string
}

func Open(init DBInit) (*MyDB, error) {
	db, err := sql.Open(
		"postgres",
		fmt.Sprintf(
			"host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
			init.Host,
			init.Port,
			init.User,
			init.Password,
			init.Name,
		),
	)

	return &MyDB{db: db}, err
}

 ユーザーの追加は次のように行います。ポイントは db.Exec を使用していない点です。db.Exec は第一戻り値を用いて result.LastInsertId() のように最後に追加したエントリの ID が取れますが postgres は対応していないため、RETURNING 句を用いて作成したエントリの ID を選択します。SELECT 文の結果と同様に Scan メソッドを使うことで結果をパースすることができます。

func (myDB *MyDB) AddUser(name string) (int, error) {
	db := myDB.db

	row := db.QueryRow(`INSERT INTO users (name) VALUES ($1) RETURNING id;`, name)

	var id int
	err := row.Scan(&id)
	if err != nil {
		return 0, err
	}

	return int(id), nil
}

API ハンドラは次のように作成します。http.RequestURL フィールドを解析することでクエリパラメータを取得できます。

func AddUser(db *mydb.MyDB) http.HandlerFunc {
   return func(w http.ResponseWriter, r *http.Request) {
   	query := r.URL.Query()
   	if query == nil {
   		log.Println("Invalid request")
   		http.Error(w, "Invalid request", http.StatusBadRequest)
   		return
   	}
   	name := query.Get("name")

   	id, err := db.AddUser(name)
   	if err != nil {
   		log.Println("Failed to add user: %v", err)
   		http.Error(w, "Failed to add user", http.StatusInternalServerError)
   		return
   	}

   	fmt.Fprintf(w, "{id:%d,name:%s}", id, name)
   }
}

パスパラメータを使うには gorilla/mux を利用します。手動で解析するより遥かに簡単です。以下のようにハンドラを定義し、追加することができます。

func AddTodo(db *mydb.MyDB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		vars := mux.Vars(r)

		userIDstr, ok := vars["userID"]
		if !ok {
			log.Println("Invalid request")
			http.Error(w, "Invalid request", http.StatusBadRequest)
			return
		}

		userID, err := strconv.Atoi(userIDstr)
		if err != nil {
			log.Println("Invalid user id: %v", err)
			http.Error(w, "Invalid user id", http.StatusBadRequest)
		}

		// ...
r.HandleFunc("/todos/{userID}", handler.ListTodo(db))

 このようにすべてのメソッドを実装するとユーザーの追加・TODO の追加・取得ができるようになります!

$ curl -X POST 'localhost:8081/users?name=user5'
# {id:2}

$ curl "localhost:8081/users/2/todos"
# {todos:[]}

$ curl -X POST 'localhost:8081/users/2/todos?title=todo1'
# {id:1}

$ curl -X POST 'localhost:8081/users/2/todos?title=todo2'
# {id:2}

$ curl "localhost:8081/users/2/todos"
# [{"id":1,"user_id":2,"title":"todo1","done":false},{"id":2,"user_id":2,"title":"todo2","done":false}]

 この勢いでいい感じに TODO の done を書き換えるエンドポイントやユーザー・TODO を削除するエンドポイントも生やします。

4. 負荷テスト

 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 のワーカー数が足りなくなるといった挙動が観察できた面白いです。

5. セッションの実装

これ以降は未実装です。

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

6. キャッシュの実装

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

7. ORM を使う

  • SQLBoiler の導入

  • 適当にフロントを作る

  • nginx でルーティング

9. 自動スケール

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

  • 適当
    • 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
    • お金がかかるので......

参考記事・文献

4
5
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
4
5