今回は、これまで3記事を使って行ってきたDBコンテナについての集大成的なアプリを作って見ようと考えています。
今回作るシステムは、今までの記事にしてきたDockerの機能を全部のせにしたようなものになります。
処理の流れは以下のようなイメージになります。
- ブラウザからGOコンテナ内のAPIサーバーにリクエスト
- APIサーバーが同じブリッジネットワーク上のPostgreSQLコンテナに対してSQL操作
- PostgreSQLコンテナから受け取ったデータをAPIサーバーがブラウザに返す
今回作るアプリの構成として、大きく分けて以下の2つのコンテナを生成します。
- APIサーバー : Go言語
- SQLデータベース : PostgreSQL
フォルダも各コンテナに分けて、以下のような構成のフォルダ・ファイル構造になる想定です。
とりあえずファイルの中身は空でいいので、同じフォルダ構造になるようにtodoAppの構成を作成してください。
todoApp/
├── goApi/ <- APIサーバー
│ ├── .devcontainer/
│ │ └── devcontainer.json
│ ├── Dockerfile
│ └── main.go
└── pgdb/ <- SQLデータベース
├── .env
├── Dockerfile
└── init.sql
まず、pgdb
フォルダをルートディレクトリとして作業を開始します
また、この節で実行したコンテナはこの記事が終わるまで動かし続けるので、ボリュームを使う必要はないのですが、実際の運用を考えてマウントしておきます。
init.sqlファイルの作成
以下のER図を参考にSQLステートメントを作成していきます。
今回の本題はテーブルの構造の考え方の話ではないので、詳しい解説はしません。
SQLステートメントとして以下の内容をinit.sql
ファイルを記述します。
-- プロジェクトテーブルの作成
CREATE TABLE Projects (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- タスクテーブルの作成
CREATE TABLE Tasks (
id SERIAL PRIMARY KEY,
project_id INT NOT NULL,
title VARCHAR(100) NOT NULL,
description TEXT,
status VARCHAR(20) DEFAULT 'To Do',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES Projects(id)
);
これで、Dockerfileでこのinit.sql
を指定のディレクトリにコピーすれば、コンテナを始めて起動したときに自動でinit.sql
が読み込まれ、テーブルが追加されます。
Dockerfile、.envファイルの作成
Dockerfile
と.env
ファイルは前の記事で作成したpostgresql:14
のものと同じで大丈夫です。
FROM postgres:14
# 初期化スクリプトをコンテナ内にコピー
COPY init.sql /docker-entrypoint-initdb.d/
POSTGRES_USER=user
POSTGRES_PASSWORD=pass
POSTGRES_DB=my-db
ブリッジネットワークの作成
今回の構成では、APIサーバーコンテナとの通信があるので、サーバーが通信できる範囲を限るために、ブリッジネットワーク
を作成して、APIサーバー
とSQLコンテナ
だけがブリッジネットワークでつながっている状態にします。
作成するブリッジネットワークの名前はdb-bridge
とします。
# コマンド
docker network create db-bridge
# 出力
c1933433bdf9aeae8d0762499d7e09fc4c321f6312f4eaedc5d762c10b61540b
これで、ブリッジネットワークの作成ができました。
コンテナ生成時(runの場合は同時に実行される)にブリッジネットワークにdb-bridge
を紐づけ忘れないように注意してください。
ボリュームの作成
データベースコンテナを消してもデータベース自体は削除されないように、データを保持しておくボリュームを作成します。
この章の最初にも話しましたが、今回はデータベースコンテナを止めないので意味はありませんが、通常は使うはずなので練習として使用します。
# コマンド
docker volume create db-volume
# 出力
db-volume
イメージのビルド、コンテナの実行
my-pg-db
という名前でイメージをビルドします。
docker image build -t my-pg-db .
pgdb
という名前でコンテナを生成して実行します。
デタッチドモードで実行しているので、コマンド実行後コンソールには何も表示されませんが、裏では動いています。
docker container run --name pgdb --network db-bridge --env-file .env -v db-volume:/var/lib/postgresql/data --rm -d my-pg-db
確認
実行中のサーバーコンテナにpsqlを使って接続
こうすることで、psqlを終了させても、コンテナ自体のプロセスに影響を出さないようにしています。
docker container exec -it pgdb psql -U user -d my-db
テーブルの確認
\d テーブル名
コマンドを実行して以下のような出力が出れば確認終了です。
# コマンド
my-db-# \d projects
# 出力
id | integer | | not null | nextval('projects_id_seq'::regclass)
name | character varying(100) | | not null |
description | text | | |
created_at | timestamp without time zone | | | CURRENT_TIMESTAMP
updated_at | timestamp without time zone | | | CURRENT_TIMESTAMP
# コマンド
my-db-# \d tasks
# 出力
id | integer | | not null | nextval('tasks_id_seq'::regclass)
project_id | integer | | not null |
title | character varying(100) | | not null |
description | text | | |
status | character varying(20) | | | 'To Do'::character varying
created_at | timestamp without time zone | | | CURRENT_TIMESTAMP
updated_at | timestamp without time zone | | | CURRENT_TIMESTAMP
次は、goApi
をカレントディレクトリにしてください。
APIサーバーのコードは、DevConainerに接続してから記述していきます。
今回はGoの開発環境をホストOSにインストールしなくてもいいようにDevContainer
を使って環境&実行環境を構築しようと思います。
開発環境なので実際にサービスとしてリリースする場合は、マルチステージビルド
などを使いシンプルな環境で、ビルドしたバイナリだけを動かすコンテナを用意してください
devcontainer.jsonの作成
まずは、DevContainerを使うために必要なdevcontainer.json
を記述していきます。
コンテナが接続するブリッジネットワークを指定したいので、runArgs
で実行時オプションを指定する必要があります。
{
"name": "Go API Dev",
"build": {
"dockerfile": "../Dockerfile"
},
"customizations": {
"vscode": {
"extensions": [
"golang.go"
]
}
},
"runArgs": [
"--network=db-bridge" // ブリッジネットワークを指定
]
}
ホストOSの外からもアクセスしたい場合は、以下の記述をrunArgs
に追加して、ポートバインディングを明示的に行ってください。
{
// === 省略 ===
"runArgs": [
"--network=db-bridge",
"-p", // 追加
"8080:8080" // 追加
]
}
Dockerfileの作成
DockerfileにはFROM
しか記述していません。
とりあえず拡張性を持たせるために使っています。
FROM golang:1.23.2
Dev Containerで接続
goApiディレクトリでDevContainerを使ってコンテナを実行接続します。
接続方法がわからない方は、以下の記事を参考にしてみてください
Go プロジェクトの構築(DevContainer内)
これ移行GoAPIプロジェクトでは、DevContainer内のターミナルでコマンドを実行しています。
(ホストOS側のターミナルを使ってもうまく動きません)
まずは状態を確認するために、VSCodeのターミナルを立ち上げて、pwd
コマンドを実行してディレクトリを表示してみてくだいさい。
以下のようなパス出力が出れば問題ありません。
# コマンド
pwd
# 出力
/workspaces/goApi
Goのライブラリ管理用のツールgomod
を初期化します。
以下のコマンドを実行すると、goApiディレクトリ内にgo.mod
というファイルが生成されます。
go mod init goApi
PostgreSQLに接続するために使用するpq
というGoのライブラリをインストールします。
ライブラリのインストールにはgo.mod
ファイルがディレクトリにある状態で、go get
コマンドを使います。
このコマンド実行時に初めてライブラリを導入したので、新しくgo.sum
というファイルが生成されると思います。
これ移行はこのファイルにライブラリの情報が追記されていきます。
go get -u github.com/lib/pq
サーバーとしてルーティングを楽に記述するために使用するmux
をインストールします。
go get -u github.com/gorilla/mux
main.goを作成(DevContainer内)
ではここから、Go言語を使った簡単なAPIサーバーを作ります。
とりあえず、main.go
に以下の内容を記述します。
細かい処理は今回の解説の範囲外になりますが、GoでAPIサーバーを作る解説はまた別の記事で行う予定です。
とりあえず今はコピペしてもらってエラーが出なければOKです。
package main
import (
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"github.com/gorilla/mux"
_ "github.com/lib/pq"
)
const (
host = "pgdb"
port = 5432
user = "user"
password = "pass"
dbname = "my-db"
)
var db *sql.DB
type Project struct {
ID int `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
}
type Task struct {
ID int `json:"id"`
ProjectID int `json:"project_id"`
Title string `json:"title"`
Description string `json:"description"`
Status string `json:"status"`
}
func main() {
initDB()
r := mux.NewRouter()
// ルート定義
r.HandleFunc("/projects", getProjects).Methods("GET")
r.HandleFunc("/projects", createProject).Methods("POST")
r.HandleFunc("/projects/{id:[0-9]+}", updateProject).Methods("PUT")
r.HandleFunc("/projects/{id:[0-9]+}", deleteProject).Methods("DELETE")
r.HandleFunc("/tasks", getTasks).Methods("GET")
r.HandleFunc("/tasks", createTask).Methods("POST")
r.HandleFunc("/tasks/{id:[0-9]+}", updateTask).Methods("PUT")
r.HandleFunc("/tasks/{id:[0-9]+}", deleteTask).Methods("DELETE")
// サーバーを8080ポートで開始
log.Fatal(http.ListenAndServe("0.0.0.0:8080", r))
}
func initDB() {
psqlInfo := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
host, port, user, password, dbname)
var err error
db, err = sql.Open("postgres", psqlInfo)
if err != nil {
log.Fatalf("Failed to open DB connection: %v", err)
}
if err = db.Ping(); err != nil {
log.Fatalf("Failed to ping DB: %v", err)
}
fmt.Println("Connected to the database!")
}
// ---- CRUD for Projects ----
func getProjects(w http.ResponseWriter, r *http.Request) {
rows, err := db.Query("SELECT id, name, description FROM Projects")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
var projects []Project
for rows.Next() {
var project Project
if err := rows.Scan(&project.ID, &project.Name, &project.Description); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
projects = append(projects, project)
}
json.NewEncoder(w).Encode(projects)
}
func createProject(w http.ResponseWriter, r *http.Request) {
var project Project
if err := json.NewDecoder(r.Body).Decode(&project); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
err := db.QueryRow("INSERT INTO Projects (name, description) VALUES ($1, $2) RETURNING id",
project.Name, project.Description).Scan(&project.ID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(project)
}
func updateProject(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, _ := strconv.Atoi(vars["id"])
var project Project
if err := json.NewDecoder(r.Body).Decode(&project); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
_, err := db.Exec("UPDATE Projects SET name = $1, description = $2 WHERE id = $3",
project.Name, project.Description, id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func deleteProject(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, _ := strconv.Atoi(vars["id"])
_, err := db.Exec("DELETE FROM Projects WHERE id = $1", id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---- CRUD for Tasks ----
func getTasks(w http.ResponseWriter, r *http.Request) {
projectID := r.URL.Query().Get("project_id")
if projectID == "" {
http.Error(w, "project_id is required", http.StatusBadRequest)
return
}
rows, err := db.Query("SELECT id, project_id, title, description, status FROM Tasks WHERE project_id = $1", projectID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
var tasks []Task
for rows.Next() {
var task Task
if err := rows.Scan(&task.ID, &task.ProjectID, &task.Title, &task.Description, &task.Status); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
tasks = append(tasks, task)
}
json.NewEncoder(w).Encode(tasks)
}
func createTask(w http.ResponseWriter, r *http.Request) {
var task Task
if err := json.NewDecoder(r.Body).Decode(&task); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
err := db.QueryRow("INSERT INTO Tasks (project_id, title, description, status) VALUES ($1, $2, $3, $4) RETURNING id",
task.ProjectID, task.Title, task.Description, task.Status).Scan(&task.ID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(task)
}
func updateTask(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, _ := strconv.Atoi(vars["id"])
var task Task
if err := json.NewDecoder(r.Body).Decode(&task); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
_, err := db.Exec("UPDATE Tasks SET title = $1, description = $2, status = $3 WHERE id = $4",
task.Title, task.Description, task.Status, id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func deleteTask(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, _ := strconv.Atoi(vars["id"])
_, err := db.Exec("DELETE FROM Tasks WHERE id = $1", id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
実行(DevContainer内)
最後にApiサーバーを起動します。
DevContaienr内のターミナルで以下のコマンドを実行してください。
出力としてConnected to the database!
と出力されれば正常に動作しています。
# コマンド
go run main.go
# 出力
Connected to the database!
もし、Failed to ping DB: dial tcp: lookup pgdb on 127.0.0.11:53: no such host exit status 1
このようなエラーが表示されてしまった場合は、データベースへの接続に失敗しているので、DBコンテナが起動しているか確認してください。
Postmanやその他のツールを使って、REST APIを使う感覚で以下の例のようなリクエストを送ってみてください
以下のコードの例は、各エンドポイントに対するリクエスト例をcurl
を使って行う例になります。
プロジェクト関連のリクエスト
curl -X GET http://localhost:8080/projects
- レスポンス例:
[
{
"id": 1,
"name": "First Project",
"description": "This is the first project"
},
{
"id": 2,
"name": "Second Project",
"description": "This is the second project"
}
]
curl -X POST http://localhost:8080/projects -H "Content-Type: application/json" -d '{"name":"New Project","description":"Project description"}'
- リクエストボディ:
{
"name": "New Project",
"description": "Project description"
}
- レスポンス例:
{
"id": 3,
"name": "New Project",
"description": "Project description"
}
curl -X PUT http://localhost:8080/projects/1 -H "Content-Type: application/json" -d '{"name":"Updated Project","description":"Updated description"}'
- リクエストボディ:
{
"name": "Updated Project",
"description": "Updated description"
}
- レスポンス: ステータスコード 204(成功時、内容なし)
curl -X DELETE http://localhost:8080/projects/1
- レスポンス: ステータスコード 204(成功時、内容なし)
タスク関連のリクエスト
curl -X GET "http://localhost:8080/tasks?project_id=1"
- レスポンス例:
[
{
"id": 1,
"project_id": 1,
"title": "First Task",
"description": "This is the first task",
"status": "To Do"
},
{
"id": 2,
"project_id": 1,
"title": "Second Task",
"description": "This is the second task",
"status": "In Progress"
}
]
curl -X POST http://localhost:8080/tasks -H "Content-Type: application/json" -d '{"project_id":1,"title":"New Task","description":"New task description","status":"To Do"}'
- リクエストボディ:
{
"project_id": 1,
"title": "New Task",
"description": "New task description",
"status": "To Do"
}
- レスポンス例:
{
"id": 3,
"project_id": 1,
"title": "New Task",
"description": "New task description",
"status": "To Do"
}
curl -X PUT http://localhost:8080/tasks/1 -H "Content-Type: application/json" -d '{"title":"Updated Task","description":"Updated description","status":"Done"}'
- リクエストボディ:
{
"title": "Updated Task",
"description": "Updated description",
"status": "Done"
}
- レスポンス: ステータスコード 204(成功時、内容なし)
curl -X DELETE http://localhost:8080/tasks/1
- レスポンス: ステータスコード 204(成功時、内容なし)
今回は、サーバーとデータベースをつなげて一通りの動作ができるアプリを作成しました。
次回はこの作業を更に簡単にできるDocker Compose
についてまとめていこうと考えています。