Docker未経験の素人がSpring + MySQLで開発環境を構築したら

# はじめに
　Dockerを用いてSpring BootとMySQLの開発環境を構築し、ごく簡単なTODOアプリを作成しました。自身で勉強しながらの環境構築なので、Dockerfileやdocker-compose.ymlといった設定ファイルは、必ずしも最適な記述方法ではないということをご了承ください。
　今回作成したプロジェクトはGitHub上に公開しております。

https://github.com/be834194/Container-Practice

# 前提
+ Mavenを用いたSpringプロジェクトが作成できる
+ gitインストール済みで、githubアカウントを持っている
+ VS Codeとjavaの拡張機能インストール済み
+ Dockerインストール済み

　各アプリのインストールや初期設定に関しましては、本記事の趣旨から外れてしまうため説明いたしません。ご了承ください。

# 筆者の環境
+ java 17
+ jdk 17
+ maven 3.8.3
+ VS Code 1.68.1
+ Docker 20.10.16

# Dockerとは
　コンテナ型の仮想環境を作成、配布、実行するためのプラットフォーム。アプリの開発環境をコード化することで、ユーザ間での環境の違いを解消したり、環境の準備に掛かる時間を短縮することが出来る。

## コンテナ化までの流れ
　公式ドキュメントに分かりやすい図が載ってありますので、ご参考ください。

https://docs.docker.jp/get-started/overview.html#docker-architecture

　超～～～ザックリ説明すると、
1. Dockerクライアントが、**Dockerデーモン(バックグラウンドで動いてるアプリ)** にコンテナ化の処理を依頼
2. Dockerデーモンが、**Dockerレジストリ(オンライン上のイメージ保存場所)** から**イメージ(コンテナに必要なものを記載したひな形)** を取得
3. 取得したイメージから**コンテナ(Dockerの実行環境)** が作成される
という流れです。

# コンテナで単純なアプリを動かしてみる
　最初はデータベースとの連携を考慮せず、htmlを返すだけのSpringアプリを作成し、それをコンテナで起動させます。

## Springプロジェクトの作成
　下記の設定でプロジェクトを作成したあと、別のプロジェクトディレクトリ下(今回はContainer-Practiceというフォルダ)に入るようにします。STSでプロジェクトを作成してコピーしても、Spring Initializrからダウンロードしたプロジェクトを解凍する方法でも大丈夫です。

![qiita-docker.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2448205/1b80a3d7-7f27-bb12-46bc-84babdb209a5.png)

![qiita-docker2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2448205/75f6eb1d-676b-d91f-b8c6-b45aac08f921.png)

　プロジェクト作成後、Controllerクラスとhtmlを追加で作成します。

```IndexController.java
@Controller
public class IndexController {

    @GetMapping("/")
	public String showIndex(Model model) {
		return "Index";
	}
    
}
```

```Index.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>container_practice</title>
    <meta charset="UTF-8">
	<meta name="viewport" content="width=device-width">
</head>
<body>
<h1>Hello Docker</h1>
</body>
</html>
```

## アプリのコンテナ化に必要なファイルを作成
　pom.xmlやsrcフォルダがある階層にDockerfileを、プロジェクトディレクトリの直下(webappと同じ階層)にdocker-compose.ymlを作成します。
　ファイル作成後のディレクトリ構成は下記の通りです。
```
Container-Practice
├─docker-compose.yml
│
└─webapp
   ├─Dockerfile
   ├─src
   │  ├─main
   │  │  ├─java
   │  │  │  └─com
   │  │  │     └─practice
   │  │  │         └─container
   │  │  │           ├─ContainerPracticeApplication.java
   │  │  │           │
   │  │  │           └─controller
   │  │  │　　　　　　　└─IndexController.java        
   │  │  └─resources
   │  │      ├─static
   │  │      └─templates
   │  │　　　　　└─Index.html
   │  └─test
   │    └─～//省略
   ～//target等は省略
   └─pom.xml
```

### Dockerfile
　コンテナに必要なものを記載した、拡張子のないファイル。ベースになるイメージをもとに実行する処理(インポートやアプリの起動など)を記述する。

http://docs.docker.jp/engine/reference/builder.html

```Dockerfile
#Dockerfileは、「命令 引数」 という形で記述する
#必ずFROM命令で開始する

#イメージのベースを指定
FROM maven:3.8.5-eclipse-temurin-17-alpine AS builder
#作業ディレクトリの指定
WORKDIR /srv
#COPY 〇〇 ×× で、イメージのファイルシステム上のパス(××)に〇〇を追加する
COPY ./src ./src
COPY ./pom.xml .
#イメージ作成時に、/bin/sh -c ○○の形でコマンドが実行される
RUN mvn package

#FROMは複数記述できる(それ以前の命令で作られた状態が、クリアになることに注意)
FROM openjdk:17
#--from=別名 で、構築したイメージを参照できる
COPY --from=builder /srv/target/Container-Practice-0.0.1-SNAPSHOT.jar /target/Container-Practice.jar
#コンテナ起動時に実行するコマンドを指定
ENTRYPOINT ["java", "-jar", "/target/Container-Practice.jar"]
```

### docker-compose
　複数のコンテナを管理できるツール。例えば、docker-composeを使うことで「webアプリのコンテナ」と「データベースのコンテナ」の連携が可能になる。
※今は「webアプリのコンテナ」だけの設定を記述します。

http://docs.docker.jp/reference/compose-file/toc.html

```docker-compose.yml
version: '3.8' #スキーマバージョンの定義
services: #実行したいサービス(またはコンテナ)一覧を定義
  app: #サービス名
    build: #構築時に適用するオプション
      context: ./webapp
      dockerfile: Dockerfile
    working_dir: /srv #作業ディレクトリの指定
    ports: #ホスト側:コンテナ側 でポートを指定
      - 8080:8080
    tty: true #コンテナが正常終了せず、起動したままになる
    volumes: #ローカルファイルをDockerコンテナ内にマウントし、データを永続化する
      #[ソース:]ターゲット[:モード]
      - ./webapp:/srv:cached
```

## いざ実行！
　ターミナルでプロジェクトディレクトリまで移動し、コマンドを実行します。
```
$ docker-compose build #イメージ作成
$ docker-compose up -d #イメージをもとにコンテナ起動
```
　コマンドが問題なく実行されれば、localhost:8080/ で「Hello Docker」が表示されます。また、「docker logs コンテナ名」というコマンドで、アプリのログを確認することが出来ます。

![qiita-docker3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2448205/e652d72b-0fbf-d8cc-02ca-0f1b3e6fe3f5.png)

　コンテナを削除、停止させるコマンドは以下の通りです。
```
$ docker-compose stop #コンテナの停止
$ docker-compose down #コンテナを削除
```

# 複数のコンテナでアプリを動かしてみる
　MySQLのコンテナを作成し、webアプリのコンテナと連携させます。

## データベースのコンテナ化に必要なファイルを作成&修正
　プロジェクトディレクトリの直下(webappと同じ階層)に「mysql」というフォルダを作成し、そこに設定ファイルを用意します。
　ファイル作成後のディレクトリ構成は下記の通りです。関係ない階層は省略していますがご了承ください。
```
Container-Practice
├─mysql
│  ├─data
│  ├─init.sql
│  └─mysql.cnf
├─webapp
│  └─Dockerfile
└─docker-compose.yml
```

### init.sql
　コンテナ起動時に実行されるsqlを用意します。
```init.sql
create table IF NOT EXISTS todo (
    todoid   integer auto_increment primary key,
    todoname varchar (255) not null
);
```

### mysql.conf
　mysqlの設定を変更します。文字コードと認証方法を変更しています。
```mysql.conf
[mysqld]
character-set-server=utf8mb4
collation-server=utf8mb4_unicode_ci
default-authentication-plugin = mysql_native_password

[client]
default-character-set=utf8mb4
```

### docker-compose.yml
　docker-composeにdbコンテナの設定や、appコンテナの依存関係を追記します。
```docker-compose.yml
version: '3.8' #スキーマバージョンの定義
services: #実行したいサービス(またはコンテナ)一覧を定義
  app: #サービス名(spring boot)
    build: #構築時に適用するオプション
      context: ./webapp
      dockerfile: Dockerfile
    working_dir: /srv #作業ディレクトリの指定
    ports: #ホスト側:コンテナ側 でポートを指定
      - 8080:8080
    tty: true #コンテナが正常終了せず、起動したままになる
    volumes: #ローカルファイルをDockerコンテナ内にマウントし、データを永続化する
      #[ソース:]ターゲット[:モード]
      - ./webapp:/srv:cached
    depends_on: #サービス間の依存関係を指定
      db:
        condition: service_started #db起動時に、appが起動する
    networks:
      - app-net
  db: #サービス名(mysql)
    image: mysql:8.0
    volumes:
      - ./mysql/data:/var/lib/mysql
      - ./mysql/init.sql:/docker-entrypoint-initdb.d/init.sql
      - ./mysql/mysql.cnf:/etc/mysql/conf.d/my.cnf
    ports: #ローカルでmysqld.exeが動いているため、ホスト側のポートを3307に変更
      - "3307:3306"
    environment: #作成するDB名,ルートユーザのパスワード,DBを操作するユーザ,タイムゾーン
      MYSQL_DATABASE: todo_list
      MYSQL_ROOT_PASSWORD: root
      MYSQL_USER: todo_user
      MYSQL_PASSWORD: docker_ci
      TZ: Asia/Tokyo
    networks:
      - app-net
networks: #ネットワークを設定し、コンテナ間の通信を行う
  app-net: #ネットワーク名
    driver: bridge
```
　MySQLの特定のディレクトリにファイルをマウントすることで、テーブルの初期化&データの永続化を行っています。MySQLのディレクトリ構成については下記の記事が参考になりました。

https://qiita.com/yoheiW@github/items/bcbcd11e89bfc7d7f3ff

## 依存関係を追加
　データベースへのアクセスにはmybatisを使います。テストコードを書くのに必要なライブラリも一緒に追加しています。
```pom.xml
		<dependency>
			<groupId>org.mybatis.spring.boot</groupId>
			<artifactId>mybatis-spring-boot-starter</artifactId>
			<version>2.2.2</version>
		</dependency>
		<dependency>
            <artifactId>mybatis-spring-boot-starter-test</artifactId>
           	<version>2.2.0</version>
            <scope>test</scope>
        </dependency>
		<dependency>
			<groupId>mysql</groupId>
			<artifactId>mysql-connector-java</artifactId>
			<scope>runtime</scope>
		</dependency>
		<dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>mysql</artifactId>
            <version>1.15.3</version>
            <scope>test</scope>
        </dependency>
        <dependency>
           	<groupId>org.testcontainers</groupId>
           	<artifactId>junit-jupiter</artifactId>
            <version>1.15.3</version>
       	    <scope>test</scope>
        </dependency>
		<dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-api</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-engine</artifactId>
            <scope>test</scope>
        </dependency>
		<dependency>
        	<groupId>org.mockito</groupId>
        	<artifactId>mockito-inline</artifactId>
        </dependency>
        <dependency>
        	<groupId>org.mockito</groupId>
        	<artifactId>mockito-core</artifactId>
        </dependency>

        <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <configuration>
                    <junitArtifactName>junit:junit</junitArtifactName>
                    <encoding>UTF-8</encoding>
                    <inputEncoding>UTF-8</inputEncoding>
                    <outputEncoding>UTF-8</outputEncoding>
                    <argLine>-Dfile.encoding=UTF-8</argLine>
                    <skipTests>true</skipTests>
                </configuration>
        </plugin>
```

## Entity,Repository等を作成
　タスクを取得、追加、削除できるようなRepositoryを作成します。
<details>
<summary>Todo.java</summary>

```Todo.java
public class Todo {

    private int todoId;

    private String todoName;

    public Todo(){
    }

    public Todo(int todoId, String todoName){
        this.todoId = todoId;
        this.todoName = todoName;
    }
    //getter & setter
```
</details>

<details>
<summary>TodoRepository.java</summary>

```TodoRepository.java
@Mapper
public interface TodoRepository {

    List<Todo> findTodoList();
    void insertTodo(Todo todo);
    void deleteTodo(int id);

}
```
</details>

<details>
<summary>TodoRepository.xml</summary>

```TodoRepository.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.practice.container.repository.TodoRepository">

    <select id = "findTodoList" resultType="com.practice.container.entity.Todo">
        SELECT
            todoid,todoname
        FROM
            todo
        ORDER BY
            todoid
    </select>
    
    <insert id = "insertTodo" parameterType="com.practice.container.entity.Todo">
        INSERT INTO
            todo(todoname)
        VALUES(#{todoName})
    </insert>
    
    <delete id = "deleteTodo" parameterType = "int">
        DELETE
        FROM
            todo
        WHERE
            todoid = #{todoId}
    </delete>
    
</mapper>
```

</details>

　application.propertiesにデータベースに関する設定を記述します。
　spring.datasource.urlには、「DBのコンテナ名:3306/データベース名」のように書きます。
```src/main/resources/application.properties
spring.datasource.url=jdbc:mysql://container-practice-db-1:3306/todo_list
spring.datasource.driver-class-name =com.mysql.cj.jdbc.Driver
spring.datasource.username=todo_user
spring.datasource.password=docker_ci

spring.sql.init.mode=always
```

　テスト時に実行するsqlを作成します。
```src/test/resources/schema.sql
create table todo (
    todoid   integer auto_increment primary key,
    todoname varchar (255) not null
);

insert into 
    todo_list.todo(todoid,todoname)
values
    (1,"服をクリーニングに出す"),
    (2,"きのこのマリネを作る");
```

## Repositoryのテスト
　TodoRepositoryのテストコードを書いていきます。テスト用にデータベースを用意する必要がありますが、今回は**Testcontainers**というライブラリを使っていきます。Testcontainersを利用すると、JUnitテスト内でDockerコンテナを起動できるようになります。

https://javadoc.io/doc/org.testcontainers/junit-jupiter/latest/org/testcontainers/junit/jupiter/Testcontainers.html

```TodoRepositoryTest.java
@AutoConfigureTestDatabase(replace = Replace.NONE) //組み込みデータベースの設定を無効
@MybatisTest
@Testcontainers //テストコンテナを有効にする。宣言したコンテナは、テストメソッド間で共有される
@Transactional
public class TodoRepositoryTest {

    @Autowired
    TodoRepository todoRepository;

    @Container
    private static final MySQLContainer<?> mysql = 
        new MySQLContainer<>(DockerImageName.parse("mysql:8.0")) //指定された文字列からDockerイメージ名を解析
                                            .withUsername("todo_user")
                                            .withPassword("docker_ci")
                                            .withDatabaseName("todo_list"); 
    
    @DynamicPropertySource
    static void setup(DynamicPropertyRegistry registry) {
        //起動中のコンテナへ接続するための情報を設定
        //add(String name,Supplier<Object>)
        registry.add("spring.datasource.url", mysql::getJdbcUrl); //メソッド参照
    }

    @Test
    void isRunning() throws Exception{
        assertTrue(mysql.isRunning());
    }

    @Test
    void getAllTodoList() throws Exception{
        List<Todo> todoArr = todoRepository.findTodoList();
        assertEquals(2,todoArr.size());
        assertEquals(1,todoArr.get(0).getTodoId());
        assertEquals("服をクリーニングに出す",todoArr.get(0).getTodoName());
        assertEquals(2,todoArr.get(1).getTodoId());
        assertEquals("きのこのマリネを作る",todoArr.get(1).getTodoName());
    }

    @Test
    void isInserted() throws Exception{
        Todo todo = new Todo();
        todo.setTodoName("腕立て15回を2セット");
        todoRepository.insertTodo(todo);

        List<Todo> todoArr = todoRepository.findTodoList();
        assertEquals(3,todoArr.get(2).getTodoId());
        assertEquals("腕立て15回を2セット",todoArr.get(2).getTodoName());
    }

    @Test
    void isDeleted() throws Exception{
        todoRepository.deleteTodo(2);

        List<Todo> todoArr = todoRepository.findTodoList();
        assertEquals(1,todoArr.size());
    }
```
　ソースコード内でもコメントしていますが、Testcontainersを使用する際の要点をまとめました。
・テストクラスに@Testcontainersを付与する
・@Containerを付与したメソッドで、Dockerイメージを作成
・@DynamicPropertySourceで、起動中のコンテナへ接続するための情報を追加
・staticで宣言したコンテナは、テスト実行前に起動し、テスト終了後に停止する
→テストメソッド間で、共通のDockerコンテナが使用できる。

　テストの実行は、VS Codeのテスト(ビーカーマーク)から行いました。また、ソースコードを右クリック→「Run Java」でもテストが実行されます。
![qiita-docker4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2448205/52065267-48a6-9099-d3a8-b67500186c62.png)

## アプリとDBの連携確認のため、再び実行！
　docker-composeコマンドを実行して、xmlファイルやpripetiesファイルの記述に誤りが無ければ、正しく起動するはずです。(起動しても単純なhtmlしか表示されないので、DBと連携出来た実感が薄いですが。。。)

# 画面上での操作確認を行う
　Repositoryを作成しDBとの連携確認が出来たところで、いよいよ画面上でTODOの追加や削除が出来るようにコードを書いていきます。

## Service作成、ControlelrとHtmlを修正
<details>
<summary>TodoService.java</summary>

```TodoService.java
@Service
@Transactional
public class TodoService {

    private final TodoRepository todoRepository;

    public TodoService(TodoRepository todoRepository){
        this.todoRepository = todoRepository;
    }

    public List<Todo> findTodoList(){
        List<Todo> todoArr = todoRepository.findTodoList();
        return todoArr;
    }

    public String insertTodo(Todo todo){
        todoRepository.insertTodo(todo);
        return "タスクを追加しました";
    }

    public String deleteTodo(int id){
        todoRepository.deleteTodo(id);
        return "タスク" + id + "番を削除しました";
    }

}
```
</details>

<details>
<summary>IndexController.java</summary>

```IndexController.java
@Controller
public class IndexController {

	private final TodoService todoService;

	public IndexController(TodoService todoService){
		this.todoService = todoService;
	}

    @GetMapping("/")
	public String showIndex(@ModelAttribute("todoForm") Todo todo,Model model) {
		List<Todo> todoArr = todoService.findTodoList();
		model.addAttribute("todoArr", todoArr);
		return "Index";
	}

	@PostMapping("/add")
	public String insertTask(@ModelAttribute("todoForm") Todo todo,
	                         RedirectAttributes model){
		String message = todoService.insertTodo(todo);
		model.addFlashAttribute("message", message);
		return "redirect:/";
	}

	@PostMapping("/complete")
	public String deleteTask(int todoId,
	                         RedirectAttributes model){
		String message = todoService.deleteTodo(todoId);
		model.addFlashAttribute("message", message);
		return "redirect:/";
	}
    
}
```
</details>

<details>
<summary>Index.html</summary>

```Index.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>container_practice</title>
    <meta charset="UTF-8">
	<meta name="viewport" content="width=device-width">
</head>
<body>
<h1>Hello Docker</h1>
<div th:text="${message}"></div>
<form th:action="@{/add}" method="post" th:object="${todoForm}">
    <input type="text" th:field="*{todoName}">
    <input type="submit" value="登録">
</form>
<table th:each="obj:${todoArr}">
    <tr>
        <td th:inline="text">[[${obj.todoId}]]:</td>
        <td th:text="${obj.todoName}"></td>
        <td>
            <form th:action="@{/complete}" method="post">
                <input type="hidden" name="todoId" th:value="${obj.todoId}">
                <input type="submit" value="削除">
            </form>
        </td>
    </tr>
</table>
</body>
</html>
```
</details>

　ServiceクラスやControllerクラスのテストコードは、ここでは割愛させていただきます。気になる方は[githubのソースコード](https://github.com/be834194/Container-Practice/tree/main/webapp/src/test/java/com/practice/container)をご確認ください。
　最終的なプロジェクトの構成は、下記にまとめております。

<details>
<summary>プロジェクトの全体図</summary>

```
Container-Practice
├─docker-compose.yml
│
├─mysql
│  ├─data
│  ├─init.csl
│  └─mysql.cnf
│
└─webapp
    ├─Dockerfile
    ├─src
    │  ├─main
    │  │  ├─java
    │  │  │  └─com
    │  │  │      └─practice
    │  │  │          └─container
    │  │  │              ├─ContainerPracticeApplication.java
    │  │  │              ├─controller
    │  │  │              │  └─IndexController.java
    │  │  │              ├─entity
    │  │  │              │  └─Todo.java
    │  │  │              ├─repository
    │  │  │              │  └─TodoRepository.java
    │  │  │              └─service
    │  │  │                   └─TodoService.java
    │  │  └─resources
    │  │      ├─application.properties
    │  │      ├─com
    │  │      │  └─practice
    │  │      │      └─container
    │  │      │          └─repository
    │  │      │              └─TodoRepository.xml
    │  │      ├─static
    │  │      └─templates
    │  │          └─Index.html
    │  └─test
    │      ├─java
    │      │  └─com
    │      │      └─practice
    │      │          └─container
    │      │              ├─ContainerPracticeApplicationTests.java
    │      │              ├─controller
    │      │              │   ├─IndexControllerTest.java
    │      │              │   └─IndexControllerJoinTest.java
    │      │              ├─repository
    │      │              │   └─TodoRepositoryTest.java
    │      │              └─service
    │      │                   ├─TodoServiceest.java
    │      │                   └─TodoServiceJoinTest.java
    │      └─resources
    │          └─schema.sql
    ～//target等は省略
    └─pom.xml
```
</details>

## いざ動作確認！

```
$ docker-compose build
$ docker-compose up -d
```

![container_practice_-_Google_Chrome_2022-06-18_23-28-34_AdobeExpress_AdobeExpress.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2448205/621195f1-71f0-8136-e473-47db7e49f734.gif)
　期待通りの動作が実現できました！

# おわりに
　サンプルアプリ作成を経て、Dockerで環境を構築するハードルがかなり下がりました。
　学習開始直後はDockerfileやdocker-compose.ymlの書き方が全然分からず、かなり苦戦しました。個人ブログやQiitaの記事を見ても「なぜその設定が必要なのか」「そもそもイメージ名にどういうものが有るのか分からん」という状態でした。公式のリファレンスを見つけてからは、照らし合わせて少しずつ理解を進めていきながら設定ファイルを書いていきました。

　Dockerをこれから勉強する方のとっかかりになれば幸いです。ご意見・ご質問・ご指摘等あれば、コメントで教えていただけますようお願いいたします。
　ここまで読んでいただきありがとうございます。