Dockerを使ってGCPをローカルでエミュレートする(GCS版)

## 目次
* [概要](#概要)
* [環境](#環境)
* [処理内容](#処理内容)
* [設定内容](#設定内容)
* [実装内容](#実装内容)
* [前準備](#前準備)
* [実行結果](#実行結果)
* [参考](#参考)

## 概要
本記事は、クラウドで提供されるリソースをDocker内に作成しバッチ開発を行う一連について記載しております。
クラウドと連携が必要な機能についても、Dockerを使うことでローカルでの開発や動作確認等を容易にすることができます。
今回はファイルの連携先に、[GoogleCloudStorage](https://cloud.google.com/storage?hl=ja)のエミュレータである、[fake-gcs-server](https://github.com/fsouza/fake-gcs-server)を利用しております。
[ソースコード](https://github.com/omoikane245/loader-example)だけ見たい方はこちらから。
※AWSをご利用の方は、[LocalStack](https://localstack.cloud/)というものもあるので、そちらで代用は可能そうです。
※GoogleCloudStorageは、以降は「GCS」と略しております。

## 環境
* Kotlin 1.6.21
* SpringBoot 3.0.1
※ SpringBatchを使用したかったが、思いのほか大変であったため、今回はCommandLineRunnerで代用しております。
* Docker 20.10.21
* Docker-Compose 2.13.0

## 処理内容
今回作成したバッチの大まかな処理内容です。
バッチ処理に必要なMySQLとGCSは、Dockerで用意しております。
※取込ファイルについては、[CKAN](https://ckan.open-governmentdata.org/)で提供されている、[福岡市の小学校給食の献立](https://ckan.open-governmentdata.org/dataset/401307_syo_format)を借用しております。
```mermaid
sequenceDiagram
    participant batch as バッチ処理
    participant mysql as MySQL
    participant gcs as GoogleCloudStorage
        batch ->> batch: 開始ログ出力
        batch ->>+ mysql: 全件削除
        batch ->>+ gcs: 取込ファイルダウンロード
        gcs -) batch: 取込ファイル連携
        NOTE left of gcs: 取込ファイル
        batch ->>+ mysql: 全件取込
        batch ->> batch: 終了ログ出力
```


取込ファイルの内容は、以下のようなレイアウトなっている。
故に今回取り込みを行う対象は、ヘッダー行を除いた62032行が対象となる。
```bash
ma7k5@DESKTOP-451NI4R MINGW64 ~/Projects/IdeaProjects/loader-example/config/gcp/cloud-storage/data
$ file -i 202102kondate.csv
202102kondate.csv: text/csv; charset=utf-8

ma7k5@DESKTOP-451NI4R MINGW64 ~/Projects/IdeaProjects/loader-example/config/gcp/cloud-storage/data
$ wc -l 202102kondate.csv
62033 202102kondate.csv

ma7k5@DESKTOP-451NI4R MINGW64 ~/Projects/IdeaProjects/loader-example/config/gcp/cloud-storage/data
$ head -n 3 202102kondate.csv
都道府県コード又は市区町村コード,NO,都道府県名,市区町村名,コース名,学校名,献立日,献立番号,料理名,明細番号,食品区分,食品名
401307,2102000001,福岡県,福岡市,イ,香住丘小学校,2021-02-01,1,牛乳,1,おもな材料,牛乳
401307,2102000002,福岡県,福岡市,イ,香住丘小学校,2021-02-01,2,麦ご飯,1,おもな材料,精白米
```

## 設定内容
```docker-compose.yml
version: "3"

services:
  mysql: # データベース
    container_name: test-db
    image: mysql:5.7
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: test
      MYSQL_USER: test
      MYSQL_PASSWORD: test
      LANG: C.UTF-8
      LANGUAGE: ja_JP
      TZ: 'Asia/Tokyo'
    command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
    volumes:
      - ./config/mysql/my.cnf:/etc/mysql/conf.d/my.cnf
      - ./config/mysql/sql:/docker-entrypoint-initdb.d
    ports:
      - "3306:3306"
  flyway: # テーブル作成及び初期データ投入用
    container_name: test-flyway
    image: boxfuse/flyway:5.1.4
    depends_on:
      - mysql
    volumes:
      - ./config/flyway/conf:/flyway/conf
      - ./config/flyway/migration:/flyway/sql
    entrypoint: >
      /bin/sh -c "
      until (/flyway/flyway clean) do echo 'waiting ...' & sleep 1; done;
      /flyway/flyway migrate;
      exit $$?;
      "
  cloud-storage: # GCS用
    container_name: test-cloud-storage
    image: fsouza/fake-gcs-server:1.43.0
    tty: true
    ports:
      - "4443:4443"
    volumes:
      - ./config/gcp/cloud-storage/data:/data/test
      - ./config/gcp/cloud-storage/storage:/storage
    command: -scheme http -public-host ${URL:-localhost}:4443

volumes:
  database:
    driver: local
```

* MySQLの設定
MySQL5.6以降から、セキュリティの問題でloose-local-infile=1とallowLoadLocalInfile=trueの指定が必要になった。
指定がないとエラーになるので注意が必要です。
```local.env
DATABASE_URL=jdbc:mysql://localhost:3306/test?allowLoadLocalInfile=true
DATABASE_USERNAME=test
DATABASE_PASSWORD=test
GCP_STORAGE_URL=http://0.0.0.0:4443
GCP_PROJECT_ID=test
GCP_BUCKET_NAME=test
GCP_DOWNLOAD_PATH=C:/test/download
```

```application.yml
spring:
  application:
    name: Loader-Example
  main:
    banner-mode: off
    log-startup-info: false
    web-application-type: none # ここが無効化の設定
... 中略
```

```my.cnf
[mysqld]
character-set-server=utf8mb4
collation-server=utf8mb4_unicode_ci

[client]
default-character-set=utf8mb4
loose-local-infile=1
```

* DDL
```sql
CREATE TABLE TBL_SCHOOL_LUNCH (
  PUBLIC_ORGANIZATION_CD CHAR(6) NOT NULL COMMENT '全国地方公共団体コード',
  ID CHAR(10) NOT NULL COMMENT 'ID',
  PREFECTURE_NAME VARCHAR(3) NOT NULL COMMENT '都道府県名',
  CITY_NAME VARCHAR(3) NOT NULL COMMENT '市区町村名',
  COURSE_NAME CHAR(1) NOT NULL COMMENT 'コース名',
  SCHOOL_NAME VARCHAR(20) NOT NULL COMMENT '学校名',
  MENU_DATE DATE NOT NULL COMMENT '献立日',
  MENU_NO INT NOT NULL COMMENT '献立番号',
  DISH_NAME VARCHAR(100) COMMENT '料理名',
  ITEM_NO INT NOT NULL COMMENT '明細番号',
  FOOD_CATEGORY VARCHAR(100) NOT NULL COMMENT '食品区分',
  FOOD_NAME VARCHAR(100) NOT NULL COMMENT '食品名',
  CREATED_BY VARCHAR(100) NOT NULL COMMENT '作成者',
  CREATED_AT DATETIME NOT NULL COMMENT '作成日時' DEFAULT CURRENT_TIMESTAMP,
  UPDATED_BY VARCHAR(100) COMMENT '更新者',
  UPDATED_AT DATETIME COMMENT '更新日時' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY(ID)
) ENGINE = InnoDB, COMMENT '給食献立管理テーブル';
```

## 実装内容
* JobRunner.kt
コマンドライン引数で渡されるジョブIDから、application.ymlに指定されているクラスにリフレクションしてメイン処理を実行している。
詳細を確認したい方は、[GitHubのソースコード](https://github.com/he23inw3/loader-example)をご確認お願い致します。
```JobRunner.kt
package com.example.loaderexample

import com.example.loaderexample.constant.JobProperty
import com.example.loaderexample.constant.JobStatus
import com.example.loaderexample.job.JobExecutor
import com.example.loaderexample.util.ConfigUtil
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.config.AutowireCapableBeanFactory
import org.springframework.boot.CommandLineRunner
import org.springframework.stereotype.Component
import java.io.IOException
import kotlin.system.exitProcess

@Component
class JobRunner(
    private val autowiredCapableBeanFactory: AutowireCapableBeanFactory,
	private val jobProperty: JobProperty,
	private val configUtil: ConfigUtil
): CommandLineRunner {

	companion object {
		private val logger = LoggerFactory.getLogger(this::class.java)
		private const val DEFAULT_JOB_ID = "BE-BATCH000"
	}

	override fun run(vararg args: String?) {
		var jobId = ""
		var jobName = ""
		var status = JobStatus.FAILED

		try {
			// パラメータ設定
			jobId = args[0]?.trim() ?: DEFAULT_JOB_ID
			jobName = configUtil.getProperty("$jobId.job-name")

			// 開始ログ
			logger.info(configUtil.getLogMessage("BE0001", jobId, jobName))

			// 対象ジョブ実行
			val executor = Class.forName(configUtil.getProperty("$jobId.class-name"))
				.getConstructor(JobProperty::class.java, ConfigUtil::class.java)
				.newInstance(jobProperty, configUtil) as JobExecutor
			executor.jobId = jobId
			executor.jobName = jobName
			autowiredCapableBeanFactory.autowireBean(executor)
			status = executor.execute()
		} catch (e: IOException) {
			logger.error(configUtil.getLogMessage("BE9996"), e)
		} catch (e: Exception) {
			logger.error(configUtil.getLogMessage("BE9999", e.message), e)
		} finally {
			// 終了ログ
			logger.info(configUtil.getLogMessage("BE0002", jobId, jobName, status.code.toString()))

			// 終了
			exitProcess(status.code)
		}
    }
}
```

* GoogleCloudStorageConfig.kt
GCSへのアクセスを行うにあたって、バケット名等の設定が必要になります。
本クラスでは、クライアントクラスへプロジェクトID等を設定してDIコンテナへ登録して使うようにしております。
```GoogleCloudStorageConfig.kt
package com.example.loaderexample.config

import com.example.loaderexample.constant.GoogleCloudConstants
import com.google.cloud.NoCredentials
import com.google.cloud.storage.Storage
import com.google.cloud.storage.StorageOptions
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

@Configuration
class GoogleCloudStorageConfig(
	private val googleCloudConstants: GoogleCloudConstants
) {

	@Bean
	fun storage(): Storage {
		return StorageOptions.newBuilder()
			.setHost(googleCloudConstants.url)
			.setProjectId(googleCloudConstants.projectId)
			.setCredentials(NoCredentials.getInstance())
			.build()
			.service
			?: throw Exception("GoogleCloudStorageクライアント設定エラー")
	}
}
```

* GoogleCloudStorageUtil.kt
実際にファイルをダウンロードしているところです。
[FileChannel#transferFrom](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/nio/channels/FileChannel.html#transferFrom(java.nio.channels.ReadableByteChannel,long,long))でファイル内容をコピーしています。

```GoogleCloudStorageUtil.kt
package com.example.loaderexample.util

import com.example.loaderexample.constant.GoogleCloudConstants
import com.google.cloud.storage.BlobId
import com.google.cloud.storage.Storage
import org.springframework.stereotype.Component
import java.io.FileOutputStream
import java.io.IOException
import java.nio.file.Path
import kotlin.io.path.pathString
import kotlin.jvm.Throws

@Component
class GoogleCloudStorageUtil(
	private val storage: Storage,
	private val googleCloudConstants: GoogleCloudConstants
) {

	@Throws(IOException::class)
	fun download(fileName: String): Path {
		val path = Path.of("${googleCloudConstants.downloadPath}/$fileName")
		storage.reader(BlobId.of(googleCloudConstants.bucketName, fileName)).use { rc ->
			FileOutputStream(path.pathString).use { fos ->
				fos.channel.transferFrom(rc,0, Long.MAX_VALUE)
			}
		}
		return path
	}
}
```

* SchoolLunchMapper.xml
MyBatisでLOAD DATAするサンプルを散見できませんでしたので、こちらに備忘録的に記載いたします。
取り込んだファイルをそのままテーブルに挿入しています。
```SchoolLunchMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
  "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.loaderexample.mapper.SchoolLunchMapper">
  <insert id="loadData" parameterType="string">
    LOAD DATA LOCAL INFILE #{filePath}
      INTO TABLE TBL_SCHOOL_LUNCH
    FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"' IGNORE 1 LINES
      (@1,@2,@3,@4,@5,@6,@7,@8,@9,@10,@11,@12)
    SET
      PUBLIC_ORGANIZATION_CD = @1,
      ID = @2,
      PREFECTURE_NAME = @3,
      CITY_NAME = @4,
      COURSE_NAME = @5,
      SCHOOL_NAME = @6,
      MENU_DATE = @7,
      MENU_NO = @8,
      DISH_NAME = @9,
      ITEM_NO = @10,
      FOOD_CATEGORY = @11,
      FOOD_NAME = @12,
      CREATED_BY = #{operatorId},
      UPDATED_BY = #{operatorId};
  </insert>

  <delete id="deleteAll">
    DELETE FROM TBL_SCHOOL_LUNCH;
  </delete>
</mapper>
```

## 前準備
バッチを動かしてみたい方向けに、実行前は以下の作業が必要です。
* Git Cloneでソースをダウンロード
* [取込ファイル](https://ckan.open-governmentdata.org/dataset/401307_syo_format)を用意する
```bash
ma7k5@DESKTOP-451NI4R MINGW64 ~/Projects/IdeaProjects/loader-example/config/gcp/cloud-storage/data
$ pwd
/c/Users/ma7k5/Projects/IdeaProjects/loader-example/config/gcp/cloud-storage/data

ma7k5@DESKTOP-451NI4R MINGW64 ~/Projects/IdeaProjects/loader-example/config/gcp/cloud-storage/data
$ ls -lh
total 7.3M
-rw-r--r-- 1 ma7k5 197609 7.3M Jan  8 22:27 202102kondate.csv
```
* docker-compose up -d でコンテナを立ち上げる。
* 後はbootRunで動かすだけになります。

## 実行結果
* バッチ実行前のテーブル内容
```bash
mysql> -- バッチ実行前
mysql> SELECT COUNT(*) FROM TBL_SCHOOL_LUNCH;
+----------+
| COUNT(*) |
+----------+
|        0 |
+----------+
1 row in set (0.00 sec)
```

* バッチ実行時のログ
```bash
2023-03-15 21:02:43　INFO  o.s.b.d.e.DevToolsPropertyDefaultsPostProcessor - Devtools property defaults active! Set 'spring.devtools.add-properties' to 'false' to disable
2023-03-15 21:02:43　INFO  o.s.b.d.e.DevToolsPropertyDefaultsPostProcessor - For additional web related logging consider setting the 'logging.level.web' property to 'DEBUG'
2023-03-15 21:02:46　INFO  o.s.b.d.a.OptionalLiveReloadServer - LiveReload server is running on port 35729
2023-03-15 21:02:46　INFO  c.e.l.JobRunner$Companion - [BE-BATCH001] 給食献立の情報更新処理を開始します。
2023-03-15 21:02:46　INFO  com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Starting...
2023-03-15 21:02:46　INFO  com.zaxxer.hikari.pool.HikariPool - HikariPool-1 - Added connection com.mysql.cj.jdbc.ConnectionImpl@5b02fb47
2023-03-15 21:02:46　INFO  com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Start completed.
2023-03-15 21:02:46　INFO  c.e.l.r.SchoolLunchRepository$Companion - 全件削除実行。対象テーブル=TBL_SCHOOL_LUNCH
2023-03-15 21:02:46　INFO  c.e.l.r.SchoolLunchRepository$Companion - ファイルダウンロード実行。ファイル名=202102kondate.csv
2023-03-15 21:02:47　INFO  c.e.l.r.SchoolLunchRepository$Companion - 全件取込実行。対象テーブル=TBL_SCHOOL_LUNCH,取込ファイル名=202102kondate.csv
2023-03-15 21:02:48　INFO  c.e.l.JobRunner$Companion - [BE-BATCH001] 給食献立の情報更新処理を終了します。[STATUS: 1]
2023-03-15 21:02:48　INFO  com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown initiated...
2023-03-15 21:02:48　INFO  com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown completed.
```

* バッチ実行後のテーブル内容
```bash
mysql> -- バッチ実行後
mysql> SELECT COUNT(*) FROM TBL_SCHOOL_LUNCH;
+----------+
| COUNT(*) |
+----------+
|    62032 |
+----------+
1 row in set (0.05 sec)

mysql> 
mysql> SELECT * FROM TBL_SCHOOL_LUNCH LIMIT 2\G
*************************** 1. row ***************************
PUBLIC_ORGANIZATION_CD: 401307
                    ID: 2102000001
       PREFECTURE_NAME: 福岡県
             CITY_NAME: 福岡市
           COURSE_NAME: イ
           SCHOOL_NAME: 香住丘小学校
             MENU_DATE: 2021-02-01
               MENU_NO: 1
             DISH_NAME: 牛乳
               ITEM_NO: 1
         FOOD_CATEGORY: おもな材料
             FOOD_NAME: 牛乳
            CREATED_BY: BE-BATCH001
            CREATED_AT: 2023-01-13 00:07:11
            UPDATED_BY: BE-BATCH001
            UPDATED_AT: 2023-01-13 00:07:11
*************************** 2. row ***************************
PUBLIC_ORGANIZATION_CD: 401307
                    ID: 2102000002
       PREFECTURE_NAME: 福岡県
             CITY_NAME: 福岡市
           COURSE_NAME: イ
           SCHOOL_NAME: 香住丘小学校
             MENU_DATE: 2021-02-01
               MENU_NO: 2
             DISH_NAME: 麦ご飯
               ITEM_NO: 1
         FOOD_CATEGORY: おもな材料
             FOOD_NAME: 精白米
            CREATED_BY: BE-BATCH001
            CREATED_AT: 2023-01-13 00:07:11
            UPDATED_BY: BE-BATCH001
            UPDATED_AT: 2023-01-13 00:07:11
2 rows in set (0.00 sec)
mysql>
```

## 参考
[mermaid](https://qiita.com/Qiita/items/c07f3262d8f3b25f06c9)
[fake-gcs-server](https://github.com/fsouza/fake-gcs-server)
[MySQL load-data-local-security](https://dev.mysql.com/doc/refman/5.6/ja/load-data-local-security.html)