Docker 環境と Go Gin で最小 API を実装する：MongoDB

# Docker 環境と Go Gin で最小 API を実装する：MongoDB

こんにちは、@studio_meowtoon です。今回は、WSL の Ubuntu 24.04 で Go Gin Web アプリケーションを作成し、最小限の REST API を実装する方法を紹介します。
![gin_and_mongodb_on_docker.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3206449/7c454da9-77e3-ef2a-7052-6019a0146e32.png)

## 目的

Windows 11 の Linux でクラウド開発します。

こちらから記事の一覧がご覧いただけます。

https://qiita.com/studio_meowtoon/items/6119ab05d8b13527efd8

## 実現すること

ローカル環境の Ubuntu の Docker 環境で、Dockerfile からビルドした Go Gin サービスのカスタムコンテナーと MongoDB データベースコンテナーを起動します。

**ネイティブイメージ形式のアプリをコンテナーとして起動**
```mermaid
flowchart LR
    terminal --> app[app]
    subgraph Ubuntu
        subgraph Docker
            subgraph app-container[api-todo-gin]
                subgraph app[app]
                    gin
                end
            end
            subgraph db-container[mongodb-todo]
                subgraph mongodb
                    db_todo
                end
            end
            app[app] <--> db_todo
        end
    end
style Ubuntu fill:#FFBCA4, stroke:#E0460F
style Docker fill:#EEFDFF, stroke:#74B9DC
style app-container fill:#FFD7E4, stroke:#E13C73
style db-container fill:#FFD7E4, stroke:#E13C73
style app fill:#ECECFF, stroke:#9674DC
style gin fill:#FFFFDE, stroke:#B4AE43
style mongodb fill:#FFFFDE, stroke:#B4AE43
```

**実行環境**

|要素|概要|
|:--|:--|
|terminal|ターミナル|
|Ubuntu|OS|
|Docker|コンテナー実行環境|

**API サービス コンテナー**

|要素|概要|
|:--|:--|
|api-todo-gin|API サービス カスタムコンテナー|
|app|ネイティブイメージ アプリケーション|
|gin|Web サーバー機能を含む|

**データベース コンテナー**

|要素|概要|
|:--|:--|
|mongodb-todo|データベースコンテナー|
|mongodb|DB サーバー|
|db_todo|データベース|

## 技術トピック

### Gin とは？

<details>
<summary>こちらを展開してご覧いただけます。</summary>

### [Gin (ジン)](https://gin-gonic.com/ja/docs/)

:::note info
Gin は、Go 言語向けの軽量で高速な Web フレームワークです。

|キーワード|内容|
|:--|:--|
|高速性|Gin は非常に高速で軽量なフレームワークであり、HTTP ルーティングやハンドリングの性能が優れています。これにより、高負荷なアプリケーションでも高い処理能力を発揮します。|
|最小構成|Gin は最小限の機能を提供するため、不要な機能やコードが含まれません。これにより、アプリケーションのサイズや起動時間を最小限に抑えることができます。|
|パフォーマンス重視|Gin はルーティングやミドルウェアの処理において、最適化されたアプローチを採用しています。これにより、アプリケーション全体のパフォーマンスが向上します。|
|豊富なミドルウェア|Gin はミドルウェアのサポートを提供し、リクエスト/レスポンスの前後で機能を拡張できます。認証、ロギング、エラーハンドリングなど、多くのミドルウェアが用意されています。|
|学習しやすさ|シンプルなルーティングとクリーンなコード構造により、新しい開発者が迅速に学び始め、プロジェクトに参加することが容易です。|
|活発なコミュニティ|Gin は人気があり、アクティブなコミュニティが存在します。ドキュメントやリソースが豊富で、問題を解決するためのサポートが得られます。|

:::

</details>

## 開発環境

+ Windows 11 Home 23H2 を使用しています。

:::note info
[WSL](https://learn.microsoft.com/ja-jp/windows/wsl/about) の Ubuntu を操作しますので macOS の方も参考にして頂けます。  
:::

<details>

WSL (Microsoft Store アプリ版) [※ こちらの関連記事からインストール方法をご確認いただけます](https://qiita.com/studio_meowtoon/items/cd0c4a607d451dbf5430)

```sh
> wsl --version
WSL バージョン: 2.2.4.0
カーネル バージョン: 5.15.153.1-2
WSLg バージョン: 1.0.61
```

Ubuntu [※ こちらの関連記事からインストール方法をご確認いただけます](https://qiita.com/studio_meowtoon/items/cd0c4a607d451dbf5430)

```sh
$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description:    Ubuntu 24.04 LTS
Release:        24.04
Codename:       noble
```

Docker [※ こちらの関連記事からインストール方法をご確認いただけます](https://qiita.com/studio_meowtoon/items/3bceee25ac67cc73ebbe)

```sh
$ docker --version
Docker version 27.0.3, build 7d4bcd8
```

</details>

:::note warn
この記事では基本的に **Ubuntu のターミナルで操作**を行います。**Vim を使用してコピペする方法**をはじめて学ぶ人のために、以下の記事で手順を紹介しています。ぜひ挑戦してみてください。
:::

https://qiita.com/studio_meowtoon/items/7771ad48676926e41463

## 作成する REST API の仕様

|エンドポイント|HTTPメソッド|説明|リクエストBody|レスポンスBody|
|:--|:--|:--|:--|:--|
|/todos         |GET   |すべての ToDo アイテムを取得します。|None         |ToDo アイテムの配列|
|/todos/complete|GET   |完了した ToDo アイテムを取得します。|None         |ToDo アイテムの配列|
|/todos/{id}    |GET   |ID で ToDo アイテムを取得します。   |None         |ToDo アイテム|
|/todos         |POST  |新しい ToDo アイテムを追加します。  |ToDo アイテム|ToDo アイテム|
|/todos/{id}    |PUT   |既存の ToDo アイテムを更新します。  |ToDo アイテム|None|
|/todos/{id}    |DELETE|ID で ToDo アイテムを削除します。   |None         |None|

## データベース コンテナーの起動

こちらの記事で、ToDo アプリ用の NoSQL データベースを作成し、Docker コンテナーとして起動する手順をご確認いただけます。

https://qiita.com/studio_meowtoon/items/ac170257fac6c772c7e2

データベース コンテナーが起動していることを確認します。

```sh
$ docker ps
CONTAINER ID   IMAGE        COMMAND                   CREATED          STATUS          PORTS                                           NAMES
6b411c62bc95   mongo-base   "docker-entrypoint.s…"   41 minutes ago   Up 41 minutes   0.0.0.0:27017->27017/tcp, :::27017->27017/tcp   mongodb-todo
```

:::note alert
コンテナー間通信するために **net-todo** という Docker ネットワークをあらかじめ作成しています。ご注意ください。
:::

## REST API を実装する手順

### Go 言語のインストール

Go 言語をインストールします。

```sh
$ sudo apt update
$ sudo apt install golang
```

:::note warn

Go 言語をアンインストール場合には以下のコマンドを実行します。

```sh
$ sudo apt remove --purge golang-go golang-*
$ sudo apt autoremove --purge
$ sudo apt clean
```

:::

バージョンを確認します。

```sh
$ go version
go version go1.22.2 linux/amd64
```

### プロジェクトの作成

プロジェクトフォルダーを作成します。  
※ ~/tmp/restapi-gin をプロジェクトフォルダーとします。

```sh
$ mkdir -p ~/tmp/restapi-gin
$ cd ~/tmp/restapi-gin
```

### モデルファイルの作成

:::note alert
このシリーズの記事で、**RDBMS** と **NoSQL** の両方で**同じ API 操作**を行います。そのため、ID フィールドは数値型ではなく文字列として定義しています。統一性を保ちつつ異なるデータストアで動作することを目指しています。ご了承ください。
:::

**todo.go** ファイルを作成します。

```sh
$ mkdir -p model
$ vim model/todo.go
```

```golang:model/todo.go
package model

import (
    "encoding/json"
    "log"
    "os"
    "time"

    "go.mongodb.org/mongo-driver/bson"
    "go.mongodb.org/mongo-driver/bson/primitive"
    "go.mongodb.org/mongo-driver/mongo"
    "go.mongodb.org/mongo-driver/mongo/options"
)

// データベース接続に接続する関数
func ConnectDb() (*mongo.Collection, error) {
    var op *options.ClientOptions = options.Client().ApplyURI("mongodb://" +
        os.Getenv("DB_USER") + ":" +
        os.Getenv("DB_PASSWORD") + "@" +
        os.Getenv("DB_HOST") + ":" +
        os.Getenv("DB_PORT"),
    )
    cli, err := mongo.Connect(nil, op) // *mongo.Client, error
    if err != nil {
        log.Fatal(err)
        return nil, err
    }
    var co *mongo.Collection = cli.Database(os.Getenv("DB_NAME")).Collection("todos")
    return co, nil
}

// ToDo エンティティを表す構造体
type Todo struct {
    Id            primitive.ObjectID `json:"id,omitempty" bson:"_id,omitempty"`
    Content       string             `json:"content,omitempty" bson:"content,omitempty"`
    CreatedDate   time.Time          `json:"created_date,omitempty" bson:"created_date,omitempty"`
    CompletedDate *time.Time         `json:"completed_date,omitempty" bson:"completed_date,omitempty"`
}

// JSON マーシャリング時に呼ばれる完了日に null を挿入するためのメソッド
func (t *Todo) MarshalJSON() ([]byte, error) {
    type Alias Todo
    if t.CompletedDate == nil {
        return json.Marshal(&struct {
            *Alias
            CompletedDate *time.Time `json:"completed_date"`
        }{
            Alias:         (*Alias)(t),
            CompletedDate: nil,
        })
    }
    return json.Marshal((*Alias)(t))
}

// ドキュメントID を "_id" に変換するメソッド
func (t *Todo) ToMongo() bson.M {
    return bson.M{
        "_id":            t.Id,
        "content":        t.Content,
        "created_date":   t.CreatedDate,
        "completed_date": nil,
    }
}

// ドキュメントID を "id" に変換するメソッド
func (t *Todo) FromMongo() bson.M {
    return bson.M{
        "id":             t.Id,
        "content":        t.Content,
        "created_date":   t.CreatedDate,
        "completed_date": nil,
    }
}
```

### アプリケーションファイルの作成

**main.go** ファイルを作成します。

```sh
$ vim main.go
```

ファイルの内容

```golang:main.go
package main

import (
    "errors"
    "log"
    "os"
    "time"

    "github.com/gin-contrib/cors"
    "github.com/gin-gonic/gin"
    "go.mongodb.org/mongo-driver/bson"
    "go.mongodb.org/mongo-driver/bson/primitive"
    "go.mongodb.org/mongo-driver/mongo"

    "api-todo/model"
)

func main() {
    // gin のデフォルトエンジンを作成
    var r *gin.Engine = gin.Default()

    // データベース接続・コレクション取得
    co, err := model.ConnectDb() // *mongo.Collection, error
    if err != nil {
        log.Fatal(err)
    }

    // ミドルウェアにコレクションを設定
    r.Use(setCollection("todos", co))

    // CORS 設定：適切に修正してください。
    var conf cors.Config = cors.DefaultConfig()
    conf.AllowAllOrigins = true
    conf.AllowCredentials = true
    conf.AllowMethods = []string{"GET", "POST", "PUT", "DELETE"}
    conf.AllowHeaders = []string{"Origin", "Authorization", "Content-Type"}
    r.Use(cors.New(conf))

    // ToDo アプリケーションの API エンドポイント定義
    r.GET("/todos", getAllTodos)
    r.GET("/todos/complete", getCompletedTodos)
    r.GET("/todos/:id", getTodo)
    r.POST("/todos", createTodo)
    r.PUT("/todos/:id", updateTodo)
    r.DELETE("/todos/:id", deleteTodo)

    // API_PORT 環境変数からポート番号を取得
    var port string = os.Getenv("API_PORT")
    if port == "" {
        port = "5000"
    }

    // アプリケーションを指定されたポートで起動
    err = r.Run(":" + port)
    if err != nil {
        log.Fatal(err)
    }
}

// すべての ToDo アイテムを取得します。
func getAllTodos(ctx *gin.Context) {
    var todos []model.Todo
    co, ok := getCollection("todos", ctx) // *mongo.Collection, bool
    if !ok {
        return
    }
    cursor, err := co.Find(nil, bson.M{}) // *mongo.Cursor, error
    if handleError(ctx, 500, "Internal Server Error", err) {
        return
    }
    defer cursor.Close(nil)
    for cursor.Next(nil) {
        var todo model.Todo
        err = cursor.Decode(&todo)
        if handleError(ctx, 500, "Internal Server Error", err) {
            return
        }
        todos = append(todos, todo)
    }
    ctx.JSON(200, todos)
}

// 完了した ToDo アイテムを取得します。
func getCompletedTodos(ctx *gin.Context) {
    var filter primitive.M = bson.M{"completed_date": bson.M{"$ne": nil}}
    var todos []model.Todo
    co, ok := getCollection("todos", ctx) // *mongo.Collection, bool
    if !ok {
        return
    }
    cursor, err := co.Find(nil, filter) // *mongo.Cursor, error
    if handleError(ctx, 500, "Internal Server Error", err) {
        return
    }
    defer cursor.Close(nil)
    for cursor.Next(nil) {
        var todo model.Todo
        err = cursor.Decode(&todo)
        if handleError(ctx, 500, "Internal Server Error", err) {
            return
        }
        todos = append(todos, todo)
    }
    ctx.JSON(200, todos)
}

// ID で ToDo アイテムを取得します。
func getTodo(ctx *gin.Context) {
    var id string = ctx.Param("id")
    var todo model.Todo
    objId, err := primitive.ObjectIDFromHex(id) // primitive.ObjectID, error
    if handleError(ctx, 400, "Invalid ID", err) {
        return
    }
    co, ok := getCollection("todos", ctx) // *mongo.Collection, bool
    if !ok {
        return
    }
    err = co.FindOne(nil, bson.M{"_id": objId}).Decode(&todo)
    if handleError(ctx, 404, "Todo not found", err) {
        return
    }
    ctx.JSON(200, todo)
}

// 新しい ToDo アイテムを追加します。
func createTodo(ctx *gin.Context) {
    var create model.Todo
    err := ctx.BindJSON(&create)
    if handleError(ctx, 400, "Invalid input", err) {
        return
    }
    create.Id = primitive.NewObjectID()
    create.CreatedDate = time.Now()
    co, ok := getCollection("todos", ctx) // *mongo.Collection, bool
    if !ok {
        return
    }
    _, err = co.InsertOne(nil, create.ToMongo())
    if handleError(ctx, 500, "Internal Server Error", err) {
        return
    }
    ctx.JSON(201, create.FromMongo())
}

// 既存の ToDo アイテムを更新します。
func updateTodo(ctx *gin.Context) {
    var id string = ctx.Param("id")
    objId, err := primitive.ObjectIDFromHex(id) // primitive.ObjectID, error
    if handleError(ctx, 400, "Invalid ID", err) {
        return
    }
    var update model.Todo
    err = ctx.BindJSON(&update)
    if handleError(ctx, 400, "Invalid input", err) {
        return
    }
    co, ok := getCollection("todos", ctx) // *mongo.Collection, bool
    if !ok {
        return
    }
    var exist model.Todo
    err = co.FindOne(nil, bson.M{"_id": objId}).Decode(&exist)
    if handleError(ctx, 404, "Todo not found", err) {
        return
    }
    update.Id = exist.Id
    update.CreatedDate = exist.CreatedDate
    _, err = co.ReplaceOne(nil, bson.M{"_id": objId}, update)
    if handleError(ctx, 500, "Internal Server Error", err) {
        return
    }
    ctx.JSON(200, update)
}

// ID で ToDo アイテムを削除します。
func deleteTodo(ctx *gin.Context) {
    var id string = ctx.Param("id")
    objId, err := primitive.ObjectIDFromHex(id) // primitive.ObjectID, error
    if handleError(ctx, 400, "Invalid ID", err) {
        return
    }
    co, ok := getCollection("todos", ctx) // *mongo.Collection, bool
    if !ok {
        return
    }
    _, err = co.DeleteOne(nil, bson.M{"_id": objId})
    if handleError(ctx, 500, "Internal Server Error", err) {
        return
    }
    ctx.JSON(204, nil)
}

// gin のコンテキストにコレクションを保持する関数
func setCollection(name string, co *mongo.Collection) gin.HandlerFunc {
    return func(c *gin.Context) {
        c.Set(name, co)
        c.Next()
    }
}

// gin のコンテキストにコレクションを取得する関数
func getCollection(name string, ctx *gin.Context) (*mongo.Collection, bool) {
    value, exists := ctx.Get(name) // any, bool
    if !exists {
        handleError(ctx, 500, "Internal Server Error", errors.New("Collection not found in context."))
        return nil, false
    }
    co, ok := value.(*mongo.Collection) // *mongo.Collection, bool
    if !ok {
        handleError(ctx, 500, "Internal Server Error", errors.New("Failed to cast collection."))
        return nil, false
    }
    return co, true
}

// カスタムのエラーハンドリング関数
func handleError(ctx *gin.Context, code int, msg string, err error) bool {
    if err != nil {
        log.Println(msg + ": " + err.Error())
        ctx.JSON(code, gin.H{"error": "An internal server error occurred."})
        return true
    }
    return false
}
```

### パッケージの追加

プロジェクトを初期化します。

```sh
$ go mod init api-todo
```

パッケージをインストールします。

```sh
$ go get -u \
    github.com/gin-gonic/gin \
    github.com/gin-contrib/cors \
    go.mongodb.org/mongo-driver/bson \
    go.mongodb.org/mongo-driver/bson/primitive \
    go.mongodb.org/mongo-driver/mongo \
    go.mongodb.org/mongo-driver/mongo/options
```

バージョンを確認します。

```sh
$ go list -m github.com/gin-gonic/gin
github.com/gin-gonic/gin v1.10.0
$ go list -m github.com/gin-contrib/cors
github.com/gin-contrib/cors v1.7.2
$ go list -m go.mongodb.org/mongo-driver
go.mongodb.org/mongo-driver v1.16.1
```

### アプリのビルドと起動

環境変数を作成します。

```sh
export DB_HOST=localhost
export DB_PORT=27017
export DB_NAME=db_todo
export DB_USER=root
export DB_PASSWORD=password
```

:::note alert
この環境変数が**一時的なもの**であることに注意してください。
:::

アプリを起動します。  
※ アプリを停止するときは ctrl + C を押します。

```sh
$ export API_PORT=5000 && go run main.go
```

:::note info
ここまでの手順で、**Ubuntu** でアプリを起動することができました。
:::

### アプリの動作確認

別ターミナルから curl コマンドで確認します。

:::note warn
必要な場合、jq をインストールします。

```sh
$ sudo apt update
$ sudo apt install jq
```

:::

#### GET: /todos エンドポイントの動作確認

すべての ToDo アイテムを取得します。

```sh
$ curl -s http://localhost:5000/todos | jq '.'
```

レスポンス

```sh
[
// 省略
  {
    "id": "66b7f3866c2fb3b3a7149f4a",
    "content": "運動する",
    "created_date": "2024-08-10T23:11:02.524Z",
    "completed_date": "2024-08-10T23:11:02.524Z"
  },
  {
    "id": "66b7f3866c2fb3b3a7149f4b",
    "content": "本を読む",
    "created_date": "2024-08-10T23:11:02.524Z",
    "completed_date": null
  },
  {
    "id": "66b7f3866c2fb3b3a7149f4c",
    "content": "請求書を支払う",
    "created_date": "2024-08-10T23:11:02.524Z",
    "completed_date": null
  },
// 省略
```

:::note info
すべての ToDo アイテムを取得できています。
:::

#### GET: /todos/complete エンドポイントの動作確認

完了した ToDo アイテムを取得します。

```sh
$ curl -s http://localhost:5000/todos/complete | jq '.'
```

レスポンス

```sh
[
  {
    "id": "66b7f3866c2fb3b3a7149f48",
    "content": "食材を買う",
    "created_date": "2024-08-10T23:11:02.524Z",
    "completed_date": "2024-08-10T23:11:02.524Z"
  },
  {
    "id": "66b7f3866c2fb3b3a7149f49",
    "content": "報告書を仕上げる",
    "created_date": "2024-08-10T23:11:02.524Z",
    "completed_date": "2024-08-10T23:11:02.524Z"
  },
  {
    "id": "66b7f3866c2fb3b3a7149f4a",
    "content": "運動する",
    "created_date": "2024-08-10T23:11:02.524Z",
    "completed_date": "2024-08-10T23:11:02.524Z"
  }
]
```

:::note info
完了した ToDo アイテムを取得できています。
:::

#### GET: /todos/{id} エンドポイントの動作確認

ID で ToDo アイテムを取得します。

:::note warn
id: 66b7f3866c2fb3b3a7149f4f は、その都度適切な値を設定してください。
:::

```sh
$ curl -s http://localhost:5000/todos/66b7f3866c2fb3b3a7149f4f | jq '.'
```

レスポンス

```sh
{
  "id": "66b7f3866c2fb3b3a7149f4f",
  "content": "コードを書く",
  "created_date": "2024-08-10T23:11:02.524Z"
}
```

:::note info
ID で ToDo アイテムを取得できています。
:::

#### POST: /todos エンドポイントの動作確認

新しい ToDo アイテムを追加します。

```sh
$ curl -s -X POST http://localhost:5000/todos \
    -H 'Content-Type: application/json; charset=utf-8' \
    -d \
'{
    "content": "昼寝をする"
}' | jq '.'
```

レスポンス

```sh
{
  "completed_date": null,
  "content": "昼寝をする",
  "created_date": "2024-08-11T09:56:51.377707042+09:00",
  "id": "66b80c5361708660bc0490fd"
}
```

:::note info
新しい ToDo アイテムが追加できています。
:::

#### PUT: /todos/{id} エンドポイントの動作確認

既存の ToDo アイテムを更新します。

:::note warn
id: 66b80c5361708660bc0490fd は、その都度適切な値を設定してください。
:::

```sh
$ curl -s -X PUT http://localhost:5000/todos/66b80c5361708660bc0490fd \
    -H 'Content-Type: application/json; charset=utf-8' \
    -d \
'{
    "content": "窓を開ける"
}' | jq '.'
```

レスポンス

```sh
{
  "id": "66b80c5361708660bc0490fd",
  "content": "窓を開ける",
  "created_date": "2024-08-11T00:56:51.377Z"
}
```

:::note info
ToDo アイテムが更新できています。
:::

#### DELETE: /todos/{id} エンドポイントの動作確認

ID で ToDo アイテムを削除します。

:::note warn
id: 66b80c5361708660bc0490fd は、その都度適切な値を設定してください。
:::

```sh
$ curl -s -X DELETE http://localhost:5000/todos/66b80c5361708660bc0490fd | jq '.'
```

レスポンス

```sh
※なし
```

:::note warn
レスポンスはありませんが、DB のコレクションを確認すると対象のレコードが削除されています。
:::

:::note info
ここまでの手順で、最小限の **CRUD 操作**を行う **REST API** をアプリに実装できました。
:::

### コンテナーイメージの作成

Dockerfile を作成します。

```sh
$ vim Dockerfile
```

ファイルの内容

```Dockerfile:Dockerfile
# build the app.
FROM golang:1.22 AS build-env

# set the working dir.
WORKDIR /app

# copy the go module dependency files.
COPY go.mod go.sum ./

# download the go module dependencies.
RUN go mod download

# copy the app source code.
COPY . .

# build the go app binary.
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app main.go

# set up the container.
FROM debian:12-slim

# set the working dir.
WORKDIR /app

# copy the built app binary from the build-env.
COPY --from=build-env /app/app ./app

# expose the port.
EXPOSE 5000

# command to run the app.
CMD ["./app"]
```

Docker デーモンを起動します。

```sh
$ sudo service docker start
```

:::note warn
Docker 環境をお持ちでない場合は、以下の関連記事から Docker Engine のインストール手順をご確認いただけます。
:::

https://qiita.com/studio_meowtoon/items/3bceee25ac67cc73ebbe

コンテナーイメージをビルドします。

```sh
$ docker build \
    --no-cache \
    --tag api-todo-gin:latest .
```

コンテナーイメージを確認します。

```sh
$ docker images | grep api-todo-gin
api-todo-gin    latest    077f0bad8f71   17 seconds ago   91.5MB
```

:::note info
ここまでの手順で、ローカル環境の **Docker** にアプリの**カスタムコンテナーイメージ**をビルドすることができました。
:::

### コンテナーを起動

ローカルでコンテナーを起動します。
※ コンテナーを停止するときは ctrl + C を押します。

:::note alert
コンテナー間通信するために **net-todo** という Docker ネットワークをあらかじめ作成しています。ご注意ください。
:::

```sh
$ docker run --rm \
    --publish 5000:5000 \
    --name api-local \
    --net net-todo \
    --env DB_HOST=mongodb-todo \
    --env DB_PORT=27017 \
    --env DB_NAME=db_todo \
    --env DB_USER=root \
    --env DB_PASSWORD=password \
    api-todo-gin
```

:::note info
ここまでの手順で、ローカル環境の **Docker** でアプリの**カスタムコンテナー**を起動することができました。
:::

### コンテナーの動作確認

別ターミナルから curl コマンドで確認します。
※ ID で ToDo アイテムを取得します。

:::note warn
id: 66b7f3866c2fb3b3a7149f4f は、その都度適切な値を設定してください。
:::

```sh
$ curl -s http://localhost:5000/todos/66b7f3866c2fb3b3a7149f4f | jq '.'
```

レスポンス

```sh
{
  "id": "66b7f3866c2fb3b3a7149f4f",
  "content": "コードを書く",
  "created_date": "2024-08-10T23:11:02.524Z"
}
```

:::note info
ここまでの手順で、ターミナルにレスポンスが表示され、**JSON データ**を取得することができました。
:::

コンテナーの状態を確認してみます。

```sh
$ docker ps
CONTAINER ID   IMAGE          COMMAND                   CREATED              STATUS              PORTS                                           NAMES
50f487b8daf8   api-todo-gin   "./app"                   About a minute ago   Up About a minute   0.0.0.0:5000->5000/tcp, :::5000->5000/tcp       api-local
6b411c62bc95   mongo-base     "docker-entrypoint.s…"   3 hours ago          Up 3 hours          0.0.0.0:27017->27017/tcp, :::27017->27017/tcp   mongodb-todo
```

### コンテナーに接続

別ターミナルからコンテナーに接続します。

```sh
$ docker exec -it api-local /bin/bash
```

コンテナー接続後にディレクトリを確認します。
※ コンテナーから出るときは ctrl + D を押します。

```
# pwd
/app
# ls -lah
total 16M
drwxr-xr-x 1 root root 4.0K Aug 11 01:50 .
drwxr-xr-x 1 root root 4.0K Aug 11 02:00 ..
-rwxr-xr-x 1 root root  16M Aug 11 01:50 app
```

top コマンドで状況を確認します。

```
# apt update
# apt install procps
# top
top - 02:04:08 up  3:01,  0 user,  load average: 0.00, 0.04, 0.07
Tasks:   3 total,   1 running,   2 sleeping,   0 stopped,   0 zombie
%Cpu(s):  0.1 us,  0.1 sy,  0.0 ni, 99.8 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st
MiB Mem :  15949.2 total,  14549.9 free,   1145.5 used,    563.5 buff/cache
MiB Swap:   4096.0 total,   4096.0 free,      0.0 used.  14803.7 avail Mem

    PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
      1 root      20   0 1236044  11200   8308 S   0.3   0.1   0:00.08 app
     12 root      20   0    4188   3480   2972 S   0.0   0.0   0:00.02 bash
    203 root      20   0    8560   4568   2700 R   0.0   0.0   0:00.00 top
```

コンテナーの情報を表示してみます。

```cs
# cat /etc/*-release
PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
NAME="Debian GNU/Linux"
VERSION_ID="12"
VERSION="12 (bookworm)"
VERSION_CODENAME=bookworm
ID=debian
HOME_URL="https://www.debian.org/"
SUPPORT_URL="https://www.debian.org/support"
BUG_REPORT_URL="https://bugs.debian.org/"
```

:::note warn
このコンテナーは Debian GNU/Linux をベースに作成されています。つまり、Debian GNU/Linux と同じように扱うことができます。
:::

## おまけ：Swagger 装備

### ライブラリの追加

swag コマンドをインストールします。

```sh
$ go install github.com/swaggo/swag/cmd/swag@latest
$ export PATH=$PATH:/home/$USER/go/bin
```

gin-swagger パッケージをインストールします。

```sh
$ go get -u github.com/swaggo/gin-swagger
$ go get -u github.com/swaggo/files
```

### コードの修正

コードに以下の内容を追記します。

```golang:main.go ※一部分
import (
    // 省略
    "api-todo/docs"
    swaggerfiles "github.com/swaggo/files"
    ginSwagger "github.com/swaggo/gin-swagger"
    // 省略
)

func main() {
    // 省略

    // Swagger の設定
    docs.SwaggerInfo.Title = "Todo API"
    docs.SwaggerInfo.Version = "0.1.0"
    r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerfiles.Handler))
    // 省略
}

// @Summary Get All Todos
// @Description すべての ToDo アイテムを取得します。
// @Tags todos
// @Accept json
// @Produce json
// @Success 200 {array} model.Todo
// @Router /todos [get]
func getAllTodos(ctx *gin.Context) {
    // 省略
}

// @Summary Get Completed Todos
// @Description 完了した ToDo アイテムを取得します。
// @Tags todos
// @Accept json
// @Produce json
// @Success 200 {array} model.Todo
// @Router /todos/complete [get]
func getCompletedTodos(ctx *gin.Context) {
    // 省略
}

// @Summary Get Todo
// @Description ID で ToDo アイテムを取得します。
// @Tags todos
// @Accept json
// @Produce json
// @Param id path string true "Todo ID"
// @Success 200 {object} model.Todo
// @Router /todos/{id} [get]
func getTodo(ctx *gin.Context) {
    // 省略
}

// @Summary Create Todo
// @Description 新しい ToDo アイテムを追加します。
// @Tags todos
// @Accept json
// @Produce json
// @Param todo body model.Todo true "Todo to create"
// @Success 201 {object} model.Todo
// @Router /todos [post]
func createTodo(ctx *gin.Context) {
    // 省略
}

// @Summary Update Todo
// @Description ToDo アイテムを更新します。
// @Tags todos
// @Accept json
// @Produce json
// @Param id path string true "Todo ID"
// @Param todo body model.Todo true "Todo to update"
// @Success 200 {object} model.Todo
// @Router /todos/{id} [put]
func updateTodo(ctx *gin.Context) {
    // 省略
}

// @Summary Delete Todo
// @Description ID で ToDo アイテムを削除します。
// @Tags todos
// @Accept json
// @Produce json
// @Param id path string true "Todo ID"
// @Success 204 {object} nil
// @Router /todos/{id} [delete]
func deleteTodo(ctx *gin.Context) {
    // 省略
}
```

<details>
<summary>コードの全体を表示する</summary>

```golang:main.go
package main

import (
    "errors"
    "log"
    "os"
    "time"

    "github.com/gin-contrib/cors"
    "github.com/gin-gonic/gin"
    swaggerfiles "github.com/swaggo/files"
    ginSwagger "github.com/swaggo/gin-swagger"
    "go.mongodb.org/mongo-driver/bson"
    "go.mongodb.org/mongo-driver/bson/primitive"
    "go.mongodb.org/mongo-driver/mongo"

    "api-todo/model"
    "api-todo/docs"
)

func main() {
    // gin のデフォルトエンジンを作成
    var r *gin.Engine = gin.Default()

    // データベース接続・コレクション取得
    co, err := model.ConnectDb() // *mongo.Collection, error
    if err != nil {
        log.Fatal(err)
    }

    // ミドルウェアにコレクションを設定
    r.Use(setCollection("todos", co))

    // CORS 設定：適切に修正してください。
    var conf cors.Config = cors.DefaultConfig()
    conf.AllowAllOrigins = true
    conf.AllowCredentials = true
    conf.AllowMethods = []string{"GET", "POST", "PUT", "DELETE"}
    conf.AllowHeaders = []string{"Origin", "Authorization", "Content-Type"}
    r.Use(cors.New(conf))

    // ToDo アプリケーションの API エンドポイント定義
    r.GET("/todos", getAllTodos)
    r.GET("/todos/complete", getCompletedTodos)
    r.GET("/todos/:id", getTodo)
    r.POST("/todos", createTodo)
    r.PUT("/todos/:id", updateTodo)
    r.DELETE("/todos/:id", deleteTodo)

    // Swagger の設定
    docs.SwaggerInfo.Title = "Todo API"
    docs.SwaggerInfo.Version = "0.1.0"
    r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerfiles.Handler))

    // API_PORT 環境変数からポート番号を取得
    var port string = os.Getenv("API_PORT")
    if port == "" {
        port = "5000"
    }

    // アプリケーションを指定されたポートで起動
    err = r.Run(":" + port)
    if err != nil {
        log.Fatal(err)
    }
}

// @Summary Get All Todos
// @Description すべての ToDo アイテムを取得します。
// @Tags todos
// @Accept json
// @Produce json
// @Success 200 {array} model.Todo
// @Router /todos [get]
func getAllTodos(ctx *gin.Context) {
    var todos []model.Todo
    co, ok := getCollection("todos", ctx) // *mongo.Collection, bool
    if !ok {
        return
    }
    cursor, err := co.Find(nil, bson.M{}) // *mongo.Cursor, error
    if handleError(ctx, 500, "Internal Server Error", err) {
        return
    }
    defer cursor.Close(nil)
    for cursor.Next(nil) {
        var todo model.Todo
        err = cursor.Decode(&todo)
        if handleError(ctx, 500, "Internal Server Error", err) {
            return
        }
        todos = append(todos, todo)
    }
    ctx.JSON(200, todos)
}

// @Summary Get Completed Todos
// @Description 完了した ToDo アイテムを取得します。
// @Tags todos
// @Accept json
// @Produce json
// @Success 200 {array} model.Todo
// @Router /todos/complete [get]
func getCompletedTodos(ctx *gin.Context) {
    var filter primitive.M = bson.M{"completed_date": bson.M{"$ne": nil}}
    var todos []model.Todo
    co, ok := getCollection("todos", ctx) // *mongo.Collection, bool
    if !ok {
        return
    }
    cursor, err := co.Find(nil, filter) // *mongo.Cursor, error
    if handleError(ctx, 500, "Internal Server Error", err) {
        return
    }
    defer cursor.Close(nil)
    for cursor.Next(nil) {
        var todo model.Todo
        err = cursor.Decode(&todo)
        if handleError(ctx, 500, "Internal Server Error", err) {
            return
        }
        todos = append(todos, todo)
    }
    ctx.JSON(200, todos)
}

// @Summary Get Todo
// @Description ID で ToDo アイテムを取得します。
// @Tags todos
// @Accept json
// @Produce json
// @Param id path string true "Todo ID"
// @Success 200 {object} model.Todo
// @Router /todos/{id} [get]
func getTodo(ctx *gin.Context) {
    var id string = ctx.Param("id")
    var todo model.Todo
    objId, err := primitive.ObjectIDFromHex(id) // primitive.ObjectID, error
    if handleError(ctx, 400, "Invalid ID", err) {
        return
    }
    co, ok := getCollection("todos", ctx) // *mongo.Collection, bool
    if !ok {
        return
    }
    err = co.FindOne(nil, bson.M{"_id": objId}).Decode(&todo)
    if handleError(ctx, 404, "Todo not found", err) {
        return
    }
    ctx.JSON(200, todo)
}

// @Summary Create Todo
// @Description 新しい ToDo アイテムを追加します。
// @Tags todos
// @Accept json
// @Produce json
// @Param todo body model.Todo true "Todo to create"
// @Success 201 {object} model.Todo
// @Router /todos [post]
func createTodo(ctx *gin.Context) {
    var create model.Todo
    err := ctx.BindJSON(&create)
    if handleError(ctx, 400, "Invalid input", err) {
        return
    }
    create.Id = primitive.NewObjectID()
    create.CreatedDate = time.Now()
    co, ok := getCollection("todos", ctx) // *mongo.Collection, bool
    if !ok {
        return
    }
    _, err = co.InsertOne(nil, create.ToMongo())
    if handleError(ctx, 500, "Internal Server Error", err) {
        return
    }
    ctx.JSON(201, create.FromMongo())
}

// @Summary Update Todo
// @Description ToDo アイテムを更新します。
// @Tags todos
// @Accept json
// @Produce json
// @Param id path string true "Todo ID"
// @Param todo body model.Todo true "Todo to update"
// @Success 200 {object} model.Todo
// @Router /todos/{id} [put]
func updateTodo(ctx *gin.Context) {
    var id string = ctx.Param("id")
    objId, err := primitive.ObjectIDFromHex(id) // primitive.ObjectID, error
    if handleError(ctx, 400, "Invalid ID", err) {
        return
    }
    var update model.Todo
    err = ctx.BindJSON(&update)
    if handleError(ctx, 400, "Invalid input", err) {
        return
    }
    co, ok := getCollection("todos", ctx) // *mongo.Collection, bool
    if !ok {
        return
    }
    var exist model.Todo
    err = co.FindOne(nil, bson.M{"_id": objId}).Decode(&exist)
    if handleError(ctx, 404, "Todo not found", err) {
        return
    }
    update.Id = exist.Id
    update.CreatedDate = exist.CreatedDate
    _, err = co.ReplaceOne(nil, bson.M{"_id": objId}, update)
    if handleError(ctx, 500, "Internal Server Error", err) {
        return
    }
    ctx.JSON(200, update)
}

// @Summary Delete Todo
// @Description ID で ToDo アイテムを削除します。
// @Tags todos
// @Accept json
// @Produce json
// @Param id path string true "Todo ID"
// @Success 204 {object} nil
// @Router /todos/{id} [delete]
func deleteTodo(ctx *gin.Context) {
    var id string = ctx.Param("id")
    objId, err := primitive.ObjectIDFromHex(id) // primitive.ObjectID, error
    if handleError(ctx, 400, "Invalid ID", err) {
        return
    }
    co, ok := getCollection("todos", ctx) // *mongo.Collection, bool
    if !ok {
        return
    }
    _, err = co.DeleteOne(nil, bson.M{"_id": objId})
    if handleError(ctx, 500, "Internal Server Error", err) {
        return
    }
    ctx.JSON(204, nil)
}

// gin のコンテキストにコレクションを保持する関数
func setCollection(name string, co *mongo.Collection) gin.HandlerFunc {
    return func(c *gin.Context) {
        c.Set(name, co)
        c.Next()
    }
}

// gin のコンテキストにコレクションを取得する関数
func getCollection(name string, ctx *gin.Context) (*mongo.Collection, bool) {
    value, exists := ctx.Get(name) // any, bool
    if !exists {
        handleError(ctx, 500, "Internal Server Error", errors.New("Collection not found in context."))
        return nil, false
    }
    co, ok := value.(*mongo.Collection) // *mongo.Collection, bool
    if !ok {
        handleError(ctx, 500, "Internal Server Error", errors.New("Failed to cast collection."))
        return nil, false
    }
    return co, true
}

// カスタムのエラーハンドリング関数
func handleError(ctx *gin.Context, code int, msg string, err error) bool {
    if err != nil {
        log.Println(msg + ": " + err.Error())
        ctx.JSON(code, gin.H{"error": "An internal server error occurred."})
        return true
    }
    return false
}
```

</details>

Swag でドキュメントを生成します。

```sh
$ swag init
2024/08/11 11:27:51 Generate swagger docs....
2024/08/11 11:27:51 Generate general API Info, search dir:./
2024/08/11 11:27:51 create docs.go at docs/docs.go
2024/08/11 11:27:51 create swagger.json at docs/swagger.json
2024/08/11 11:27:51 create swagger.yaml at docs/swagger.yaml
```

### Swagger を確認

Web ブラウザで Swagger の URL を確認します。

```sh
http://localhost:5000/swagger/index.html
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3206449/26821506-1c00-4b20-83e2-9d4be81b4cd6.png)

:::note info
これらのツールを活用することにより、より手軽に API を操作できます。
:::

## まとめ

WSL Ubuntu の Docker 環境で、Go Gin の最小 API を実装することができました。

:::note alert
この記事の実装例は1つのアプローチに過ぎず、必ずしも正しい方法とは限りません。他にも多様な方法がありますので、さまざまな情報を照らし合わせて検討してみてください。
:::

どうでしたか？　WSL Ubuntu で、Go Gin Web アプリケーションを手軽に起動できます。ぜひお試しください。今後も Go の開発環境などを紹介しますので、ぜひお楽しみにしてください。