PythonまたはGoでDockerコンテナに立ち上げたMySQLサーバーと通信する方法

データベースを構築するにあたって，DockerコンテナでDBもアプリも動くといいですよね？ローカルで動かす際にdockerコンテナ内で処理が完結するのでシームレスに通信することができる．
本記事ではPythonまたはGoを用いてDockerコンテナ上のMySQLサーバーと通信する方法について記載する．
![スクリーンショット 2024-07-05 13.21.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3757442/e36ccd6a-bc6c-5805-2adb-e506be6011b3.png)

今回は以下の知識はある前提で話をする
**SQLの文法**

https://qiita.com/tarakokko3233/items/f038fbaccdd92fb0308a

**Dockerの基本**

https://qiita.com/tarakokko3233/items/6f6d03bd6d1e69c9e67e

**仮想環境とDockerコンテナの違い**

https://qiita.com/tarakokko3233/items/aa732d049d16e50f5d07


# ディレクトリ構造の確認
```md:Pythonの方はこちらで
.
├── .env
├── docker-compose.yml
├── mysql
│   └── Dockerfile
└── python
    ├── Dockerfile
    ├── main.py
    └── requirements.txt
```
```md:Goの方はこちらで
├── .env
├── docker-compose.yml
├── mysql
│   └── Dockerfile
└── golang
    ├── Dockerfile
    ├── go.mod
    ├── go.sum
    └── main.go
```

このような構造であることを前提に作成していく．

# 環境変数の設定
まず,`.env`ファイルに必要な環境変数を設定する.

:::note info
**環境変数を設定するメリット**
アプリケーションとデータベースの接続情報を安全に管理できる.
:::

```.env
MYSQL_ROOT_PASSWORD=rootpassword
MYSQL_DATABASE=testdb
MYSQL_USER=user
MYSQL_PASSWORD=password
DB_HOST=db
DB_PORT=3306
```
この情報を使って，MySQLサーバーとPythonまたはGoでやりとりする．

# docker環境の構築
`docker-compose.yml`ファイルで,MySQLサービスとアプリケーションサービスを定義する.環境変数は.envファイルから読み込む.
`python-app`または`golang-app`サービスの`command`で`${COMMAND:-export}`を定義しているのはこの後コマンドライン引数でデータを挿入`import`するか抽出`export`するかを指定し，`.env`から読み出すためである．

>`${COMMAND:-export}`は何も指定しなければデフォルトでデータを抽出`export`する．

また，環境変数の情報はdockervolumeに格納しておく．

Dockerfileでは使用する言語とバージョン，依存関係のインストールをする．実行コマンドは`docker-compose.yml`に指定するので不要である．

まず`mysql`ディレクトリに`Dockerfile`を作成する．ここではmysqlのバージョンのみ指定する．
```Dockerfile
FROM mysql:8.0
```

## Pythonバージョン
ルートに`docker-compose.yml`ファイルを定義する．
```docker-compose.yml
version: '3'

services:
  db:
    build: ./mysql
    env_file: .env
    environment:
      - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}
      - MYSQL_DATABASE=${MYSQL_DATABASE}
      - MYSQL_USER=${MYSQL_USER}
      - MYSQL_PASSWORD=${MYSQL_PASSWORD}
    ports:
      - "3306:3306"

  python_app:
     build:
       context: ./python
       dockerfile: Dockerfile
     env_file: .env
     volumes:
     - ./.env:/app/.env
     command: python /app/main.py ${COMMAND:-export}
     depends_on:
       - db
```
`python`ディレクトリで`Dockerfile`ファイルを定義する．
```Dockerfile
FROM python:3.9

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

```

## Goバージョン
ルートに`docker-compose.yml`ファイルを定義する．
```docker-compose.yml
version: '3'

services:
  db:
    build: ./mysql
    env_file: .env
    environment:
      - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}
      - MYSQL_DATABASE=${MYSQL_DATABASE}
      - MYSQL_USER=${MYSQL_USER}
      - MYSQL_PASSWORD=${MYSQL_PASSWORD}
    ports:
      - "3306:3306"

  golang_app:
    build:
      context: ./golang
      dockerfile: Dockerfile
    env_file: .env
    volumes:
    - ./.env:/app/.env
    command: /app/main ${COMMAND:-export}
    depends_on:
      - db
```

`golang`ディレクトリで`Dockerfile`ファイルを定義する．
```Dockerfile
FROM golang:1.18

WORKDIR /app

COPY go.mod .
COPY go.sum .
RUN go mod download

COPY . .

RUN go build -o main .


```

# 依存関係の定義
インストールする依存関係のリストを記載しておく．

## Pythonバージョン
アプリケーションディレクトリに`requirements.txt`を作成．
```requirements.txt
mysql-connector-python==8.0.28
python-dotenv==0.19.2
```

## Goバージョン
アプリケーションディレクトリに`go.mod`を作成．
```go.mod
module example.com/yourappname

go 1.18

require (
	github.com/go-sql-driver/mysql v1.7.1
	github.com/joho/godotenv v1.5.1
)
```
この後以下のコマンドを実行する．
```bash
go mod tidy
```
https://go.dev/doc/install

# DB操作用のコードを書く
詳しい説明はコードに記載しているが，概要として...行っていることは以下の通りである．

- 環境変数からDB接続情報を取得
-  MySQLデータベースに接続. 接続失敗時は30回まで再試行
- Userテーブルが存在しない場合, 作成
- コマンドライン引数に基づいて操作を実行
  - import: サンプルユーザーデータをテーブルに挿入
  - export: テーブルの全データを取得し表示

- データベース接続を適切に閉じる.


## Pythonバージョン
```main.py
import mysql.connector
from datetime import date
import time
import os
import sys
from dotenv import load_dotenv

load_dotenv()

def mysql_operations(command):

    # ここでDB接続情報を環境変数から取得
    db_config = {
        'host': os.getenv('DB_HOST'),
        'user': os.getenv('MYSQL_USER'),
        'password': os.getenv('MYSQL_PASSWORD'),
        'database': os.getenv('MYSQL_DATABASE'),
    }

    # 接続再試行用のカウント．30カウントしたら失敗とする．設定する理由は接続情報はあっているのに1回でつながらない場合がよくあるからである
    for _ in range(30):
        try:
            conn = mysql.connector.connect(**db_config)
            break
        except mysql.connector.Error:
            time.sleep(1)
    else:
        print("データベースへの接続に失敗しました")
        return

    cursor = conn.cursor()

    # テーブルを作成するSQLクエリ(テーブルが存在しない場合のみ実行)
    create_table_query = """
    CREATE TABLE IF NOT EXISTS User (
        id INT AUTO_INCREMENT PRIMARY KEY,
        name VARCHAR(255) NOT NULL,
        email VARCHAR(255) NOT NULL
    )
    """

    # ここでSQLを実行(execute)する
    cursor.execute(create_table_query)
    print("テーブルが作成されました")

    # コマンドライン引数がimportならデータ挿入．INSERT命令を実行
    if command == "import":
        insert_query = "INSERT INTO User (name, email) VALUES (%s, %s)"
        users = [
            ("John Doe", "john@example.com"),
            ("Jane Smith", "jane@example.com"),
        ]
        cursor.executemany(insert_query, users)
        conn.commit()
        print(f"{cursor.rowcount}行のデータが挿入されました")

    # コマンドライン引数がexportならデータ抽出．SELECT命令を実行
    elif command == "export":
        select_query = "SELECT * FROM User"
        cursor.execute(select_query)
        results = cursor.fetchall()

        print("ユーザーデータ:")
        for row in results:
            print(f"ID: {row[0]}, Name: {row[1]}, Email: {row[2]}")

    else:
        print(f"無効なコマンドです: {command}")

    #　DBを閉じる
    cursor.close()
    conn.close()

# 実行されたらここが初めに呼び出される
if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("使用方法: python main.py [import|export]")
        sys.exit(1)
        
    # sys.argv[1]でコマンドライン引数を取得
    mysql_operations(sys.argv[1])

```

## Goバージョン
```main.go
package main

// 依存関係のインポート
import (
	"database/sql"
	"fmt"
	"log"
	"os"
	"time"

	_ "github.com/go-sql-driver/mysql"
	"github.com/joho/godotenv"
)


func main() {
	if len(os.Args) < 2 {
		fmt.Println("使用方法: go run main.go [import|export]")
		os.Exit(1)
	}

    // sys.argv[1]でコマンドライン引数を取得
	command := os.Args[1]

	err := godotenv.Load()
	if err != nil {
		log.Fatal("Error loading .env file")
	}

    // ここでDB接続情報を環境変数から取得
	dbUser := os.Getenv("MYSQL_USER")
	dbPass := os.Getenv("MYSQL_PASSWORD")
	dbName := os.Getenv("MYSQL_DATABASE")
	dbHost := os.Getenv("DB_HOST")
	dbPort := os.Getenv("DB_PORT")

	var db *sql.DB
	dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s", dbUser, dbPass, dbHost, dbPort, dbName)
 
     // 接続再試行用のカウント．30カウントしたら失敗とする．設定する理由は接続情報はあっているのに1回でつながらない場合がよくあるからである
	for i := 0; i < 30; i++ {
		db, err = sql.Open("mysql", dsn)
		if err == nil {
			err = db.Ping()
			if err == nil {
				break
			}
		}
		time.Sleep(time.Second)
	}
	if err != nil {
		log.Fatal("データベースへの接続に失敗しました:", err)
	}
	defer db.Close()

    // テーブルを作成するSQLクエリ(テーブルが存在しない場合のみ実行)ついでに実行(exec)
	_, err = db.Exec(`
    CREATE TABLE IF NOT EXISTS User (
        id INT AUTO_INCREMENT PRIMARY KEY,
        name VARCHAR(255) NOT NULL,
        email VARCHAR(255) NOT NULL
    )
    `)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println("テーブルが作成されました")

    // コマンドライン引数がimportならデータ挿入．INSERT命令を実行
	switch command {
	case "import":
		stmt, err := db.Prepare("INSERT INTO User (name, email) VALUES (?, ?)")
		if err != nil {
			log.Fatal(err)
		}
		defer stmt.Close()

		users := []struct {
			name  string
			email string
		}{
			{"John Doe", "john@example.com"},
			{"Jane Smith", "jane@example.com"},
		}

		for _, user := range users {
			_, err = stmt.Exec(user.name, user.email)
			if err != nil {
				log.Fatal(err)
			}
		}
		fmt.Println("データが挿入されました")

    // コマンドライン引数がexportならデータ抽出．SELECT命令を実行
	case "export":
		rows, err := db.Query("SELECT * FROM User")
		if err != nil {
			log.Fatal(err)
		}
		defer rows.Close()

		fmt.Println("ユーザーデータ:")
		for rows.Next() {
			var id int
			var name, email string

			err := rows.Scan(&id, &name, &email)
			if err != nil {
				log.Fatal(err)
			}
			fmt.Printf("ID: %d, Name: %s, Email: %s\n", id, name, email)
		}

	default:
		fmt.Println("無効なコマンドです。import または export を指定してください。")
	}
}

```
# コマンドライン引数の環境変数を追加
このコードは以下のように実行されることを前提としている．
```md:Pythonの場合
Python main.py import
Python main.py export
```
```md:Goの場合
go main.go import
go main.go export
```

しかし`docker-compose.yml`に直接コマンドライン引数を定義することは不適切なため，`.env`ファイルにコマンドライン引数を定義しておく．

>そのため先ほど`command`は`${COMMAND:-export}`を使って定義した．

`.env`に以下の行を追加する
```.env
COMMAND=import
```
実行時にはここの文字を`import`にするか，`export`にするかを指定して，データを挿入するのか抽出するのかを選択する．

# 実行
これで以下のコマンドを実行してコンテナを立ち上げてアプリケーションを実行する．
```bash
docker compose up
```

# DBサーバーにアクセスしたい場合
DBサーバーに直接アクセスしたい場合は以下のコマンドを実行すること．DBサーバー内でSQLクエリを実行することができる．
```bash
docker compose up -d 
docker compose exec db mysql -u user -p password testdb
```