使用Docker+Spring Boot+MySQL+Flyway+Spock搭建全容器基础的本地开发环境

KINTO Technologies葵タの2021年のアドベントカレンダー- Qiitaの14日目の記事です。

写这篇文章的原因

最初的目标是让后端开发人员了解并体验Spock数据驱动测试的优势。

然而,在建立撰写该文章所需的环境时,我开始认为前面的应用程序开发环境建立过程也是有需求的,甚至更多人需要作为知识来学习。

由于我们公司的系统平台基本上都是基于云端容器服务的,因此即使是在本地开发,也不需要在Mac上安装MySQL,而是最好从一开始就采用容器化构建的方式,这样不仅在DevOps方面更容易进行迁移。

基于以上的原因,我们决定最终将文章的主题设定为所述标题。

希望有人愿意阅读

    • うっかりクラウドベースの会社に転職しちゃったオンプレ系エンジニア

 

    • 開発はコンテナベースでできてるけどテストまでコンテナベースで動かしたい

 

    テストコードを書けと言われるけどJUnit書きづらいし読みづらいしなんかイヤ

环境

MacBook Pro
Docker Desktop for Mac 4.9.1 (已经付费啦…)
Java 17
Gradle 7.3.1
Spring Boot 2.6.1
Groovy 3.0
Spock 2.0
Testcontainers 1.16.2

示例源代码

 

行动概述

REST API -> REST 应用程序接口

http://localhost:8080/v1/cars/{price}

您可以获取到KINTO最受欢迎车型(截至2021年12月)中,指定价格或更低月租费的车辆数据。

【KINTO】是从丰田推出的一种汽车订阅服务。

在MySQL容器的表中,存放着以下的汽车数据。

名称未設定2.png

考试内容

    • HTTPステータス200でレスポンスが返るか

 

    価格パターンによるデータ駆動テスト

建造过程

开发REST API

Spring Boot的类结构看起来是这样的。

.
├── java
│   └── com
│       └── example
│           └── restapitestbyspock
│               ├── RestApiTestBySpockApplication.java
│               ├── application
│               │   └── CarController.java
│               ├── domain
│               │   ├── model
│               │   │   └── Car.java
│               │   ├── repository
│               │   │   └── CarRepository.java
│               │   └── service
│               │       └── CarService.java
│               └── infrastructure
│                   ├── entity
│                   │   └── CarEntity.java
│                   └── repository
│                       ├── CarJpaRepository.java
│                       └── CarRepositoryImpl.java
└── resources
    ├── application.yml
    ├── static
    └── templates

使用Spring Data JPA的查询生成功能,获取“按照价格升序排列的月租车辆数据”,这些车辆的价格小于等于指定的价格或更低。

由于没有其他特别棘手的事情,所以我只会简单地贴上代码。

域名层

@Data
@Builder
public class Car {
    @Id
    private Integer id;
    private String name;
    private Integer price;
}
public interface CarRepository {

    List<Car> findByPriceLessThanEqualOrderByPriceAsc(Integer price);

}
@Service
@RequiredArgsConstructor
public class CarService {

    @NonNull
    private final CarRepository carRepository;

    public List<Car> findByPriceLessThanEqualOrderByPriceAsc(Integer price) {
        return this.carRepository.findByPriceLessThanEqualOrderByPriceAsc(price);
    }

}

基礎設施層

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "cars")
public class CarEntity {
    @Id
    private Integer id;
    private String name;
    private Integer price;

    public Car toDomainCar() {
        return Car.builder()
                .id(this.id)
                .name(this.name)
                .price(this.price)
                .build();
    }
}
public interface CarJpaRepository extends JpaRepository<CarEntity, Integer> {
    List<CarEntity> findByPriceLessThanEqualOrderByPriceAsc(Integer price);
}
@Repository
@RequiredArgsConstructor
public class CarRepositoryImpl implements CarRepository {

    @NonNull
    private final CarJpaRepository carJpaRepository;

    @Override
    public List<Car> findByPriceLessThanEqualOrderByPriceAsc(Integer price) {
        return this.carJpaRepository.findByPriceLessThanEqualOrderByPriceAsc(price)
                .stream().map(CarEntity::toDomainCar)
                .collect(Collectors.toList());
    }
}

应用层

@RestController
@RequiredArgsConstructor
@RequestMapping(path = "/v1/cars")
public class CarController {

    @NonNull
    private final CarService carService;

    @GetMapping("/{price}")
    @ResponseStatus(HttpStatus.OK)
    public List<Car> findByPriceLessThanEqualOrderByPriceAsc(@PathVariable("price") Integer price) {
        return this.carService.findByPriceLessThanEqualOrderByPriceAsc(price);
    }
}

安装Docker Desktop

现在让我们开始准备容器化的工作吧。

Docker桌面版适用于Mac和Windows系统

请根据上述内容选择并下载、安装到您的设备中。

Docker容器的配置。

配置文件的结构如下所示。

.
├── docker
│   ├── flyway
│   │   ├── conf
│   │   │   └── flyway.conf
│   │   └── sql
│   │       ├── V1.0.0__schema.sql
│   │       └── V1.1.0__data.sql
│   ├── log
│   │   └── mysql
│   │       └── mysqld.log
│   ├── mysql
│   │   ├── Dockerfile
│   │   └── conf.d
│   │       └── my.cnf
│   └── spring
│       └── Dockerfile
├── docker-compose.yml

我們將以Docker Compose為基礎進行配置文件的實現。我們想要建立一個結構,從Spring Boot連接到MySQL並使用Flyway進行遷移管理,因此我們需要以下3個容器映像。

    • MySQL

 

    • Flyway

 

    Spring Boot
version: "3.7"
services:
  dbserver:
    container_name: mysql-db
    build:
      context: ./docker/mysql
      dockerfile: Dockerfile
    image: chig1215/mysql:latest
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_USER: chig1215
      MYSQL_PASSWORD: chig1215
      MYSQL_DATABASE: kinto
    restart: always
    command: --default-authentication-plugin=mysql_native_password
    ports:
      - "3306:3306"
    volumes:
      - ./docker/mysql/conf.d:/etc/mysql/conf.d
      - ./docker/log/mysql:/var/log/mysql
      - mysql_db:/var/lib/mysql
  flyway-repair:
    container_name: flyway-repair
    image: flyway/flyway
    command: repair
    volumes:
      - ./docker/flyway/conf:/flyway/conf
    depends_on:
      - dbserver
  flyway-migration:
    container_name: flyway-migration
    image: flyway/flyway
    command: -url=jdbc:mysql://dbserver -schemas=kinto -user=chig1215 -password=chig1215 -connectRetries=60 migrate
    volumes:
      - ./docker/flyway/conf:/flyway/conf
      - ./docker/flyway/sql:/flyway/sql
    depends_on:
      - flyway-repair
  spring:
    container_name: spring-app
    build: ./docker/spring
    depends_on:
      - flyway-migration
    ports:
      - "8080:8080"
    volumes:
      - .:/app
    environment:
      spring.datasource.driverClassName: "com.mysql.cj.jdbc.Driver"
      spring.datasource.url: "jdbc:mysql://dbserver/kinto"
      spring.datasource.username: "chig1215"
      spring.datasource.password: "chig1215"
    working_dir: /app
    command: sh -c "java -jar ./build/libs/rest-api-test-by-spock-0.0.1-SNAPSHOT.jar"
volumes:
  mysql_db:
    driver: local

我們將通過註解來解釋每個容器的設置。

MySQL:MySQL is a relational database management system. MySQL是一种关系型数据库管理系统。

  dbserver:
    container_name: mysql-db  # コンテナ名(Docker Desktop上はこの名前で表示される)
    build:
      context: ./docker/mysql # Dockerfileを含むディレクトリへのパス
      dockerfile: Dockerfile  # Dockerfile名
    image: chig1215/mysql:latest # イメージ名
    environment:
      MYSQL_ROOT_PASSWORD: root # 環境変数(rootユーザのパスワード)
      MYSQL_USER: chig1215      # 環境変数(ユーザ)
      MYSQL_PASSWORD: chig1215  # 環境変数(パスワード)
      MYSQL_DATABASE: kinto     # 環境変数(データベース名)
    restart: always # 再起動ポリシー
    command: --default-authentication-plugin=mysql_native_password  # mysql_native_password を使用したネイティブ認証
    ports:
      - "3306:3306" # ポートマッピング
    volumes:
      - ./docker/mysql/conf.d:/etc/mysql/conf.d # mysql.confディレクトリのマッピング
      - ./docker/log/mysql:/var/log/mysql       # mysqld.logディレクトリのマッピング
      - mysql_db:/var/lib/mysql                 # データ永続化ボリュームのマッピング
# DBの永続化先
volumes:
  mysql_db:
    driver: local

飞越

  flyway-repair:
    container_name: flyway-repair # コンテナ名(Docker Desktop上はこの名前で表示される)
    image: flyway/flyway          # イメージ名
    command: repair               # 前回のSQLエラー解消(サンプルコンテンツのため。本番稼働アプリでは不要)
    volumes:
      - ./docker/flyway/conf:/flyway/conf # flyway.confディレクトリのマッピング
    depends_on:
      - dbserver  # MySQLコンテナが起動した後に起動させる
  flyway-migration:
    container_name: flyway-migration  # コンテナ名(Docker Desktop上はこの名前で表示される)
    image: flyway/flyway              # イメージ名
    # MySQLの接続先を指定してマイグレーションを実行する(host:port部分はコンテナ名を指定する)
    command: -url=jdbc:mysql://dbserver -schemas=kinto -user=chig1215 -password=chig1215 -connectRetries=60 migrate
    volumes:
      - ./docker/flyway/conf:/flyway/conf # flyway.confディレクトリのマッピング
      - ./docker/flyway/sql:/flyway/sql   # マイグレーションSQLファイルディレクトリのマッピング
    depends_on:
      - flyway-repair # repairが完了した後に起動させる

春季启动

  spring:
    container_name: spring-app  # コンテナ名(Docker Desktop上はこの名前で表示される)
    build: ./docker/spring      # Dockerfileを含むディレクトリへのパス
    depends_on:
      - flyway-migration        # マイグレーションが完了した後に起動させる
    ports:
      - "8080:8080"             # ポートマッピング
    volumes:
      - .:/app                  # ボリュームマッピング
    environment:
      # MySQLの接続設定
      spring.datasource.driverClassName: "com.mysql.cj.jdbc.Driver"
      spring.datasource.url: "jdbc:mysql://dbserver/kinto"  # host:port部分はコンテナ名を指定する
      spring.datasource.username: "chig1215"
      spring.datasource.password: "chig1215"
    working_dir: /app # 作業ディレクトリ
    # jarから起動
    command: sh -c "java -jar ./build/libs/rest-api-test-by-spock-0.0.1-SNAPSHOT.jar"

请参考每个容器的Dockerfile和conf文件中的示例源代码。

确认行动

应用程序的构建

 $ ./gradlew clean build

让我们确认在build文件夹中创建了一个jar文件。

.
├── build
│   ├── libs
│   │   ├── rest-api-test-by-spock-0.0.1-SNAPSHOT-plain.jar
│   │   └── rest-api-test-by-spock-0.0.1-SNAPSHOT.jar

启动容器

$ docker-compose up --build

如果启动成功,Docker Desktop的Containers/Apps应该以以下方式显示。

名称未設定.png

如果成功启动,通过GET请求REST API并返回如下结果。

http://localhost:8080/v1/cars/20000
[{
	"id": 3,
	"name": "ルーミー",
	"price": 14630
}, {
	"id": 7,
	"name": "ヤリス2WD",
	"price": 14960
}, {
	"id": 2,
	"name": "RAIZE",
	"price": 16170
}, {
	"id": 10,
	"name": "プリウス",
	"price": 18700
}, {
	"id": 4,
	"name": "アクア",
	"price": 19580
}]

停止容器

$ docker-compose down

小休一下

咖啡休息

考试开发

好,終於到了最初的主題,Spock的登場。

由于本次我们想要以容器为基础进行测试,因此我们将同时使用Testcontainers。

班级构成是这样的。

.
└── src
    └── test
        ├── groovy
        │   └── com
        │       └── example
        │           └── restapitestbyspock
        │               └── application
        │                   └── CarControllerTest.groovy
        ├── java
        │   └── com
        │       └── example
        │           └── restapitestbyspock
        │               └── helper
        │                   └── test
        │                       └── MySQLContainerContextInitializer.java
        └── resources

添加必要的库来构建测试版本

我会在build.gradle文件中按照以下方式进行追加。

plugins {
    id 'org.springframework.boot' version '2.6.1'
    id 'io.spring.dependency-management' version '1.0.11.RELEASE'
    id 'java'
    id 'groovy' // 追記(SpockはGroovyで記述するため)
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    maven { url 'https://repo.spring.io/release' }
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-rest'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-jdbc'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'javax.persistence:javax.persistence-api'
    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'mysql:mysql-connector-java'
    annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
    annotationProcessor 'org.projectlombok:lombok'
    // ここから追記
    testImplementation 'org.springframework.boot:spring-boot-test:2.6.2'
    testImplementation 'org.springframework.boot:spring-boot-starter-test:2.6.2'
    testImplementation 'org.spockframework:spock-core:2.0-groovy-3.0'
    testImplementation 'org.spockframework:spock-spring:2.0-groovy-3.0'
    testImplementation 'org.testcontainers:testcontainers:1.16.2'
    testImplementation 'org.testcontainers:mysql:1.16.2'
    testImplementation 'com.jayway.jsonpath:json-path:2.6.0'
}

test {
    useJUnitPlatform()
}

bootBuildImage {
    builder = 'paketobuildpacks/builder:tiny'
    environment = ['BP_NATIVE_IMAGE': 'true']
}

// 追記(FlywayのマイグレーションSQLをテストリソースとして使いたいため)
sourceSets.test {
    resources.srcDirs = ["src/test/resources", "docker"]
}

用于测试数据库容器配置的辅助类

@SuppressWarnings({"rawtypes", "unchecked"})
public class MySQLContainerContextInitializer
        implements ApplicationContextInitializer<ConfigurableApplicationContext> {

    private static final Logger LOGGER = LoggerFactory.getLogger(MySQLContainerContextInitializer.class);

    private static final MySQLContainer MYSQL =
            new MySQLContainer("mysql:latest") {
                {
                    withDatabaseName("kinto");
                    withUsername("chig1215");
                    withPassword("chig1215");
                    withExposedPorts(3306);
                    withLogConsumer(new Slf4jLogConsumer(LOGGER));
                    withClasspathResourceMapping(
                            "mysql/conf.d",
                            "/etc/mysql/conf.d", BindMode.READ_ONLY);
                    withClasspathResourceMapping(
                            "flyway/sql",
                            "/docker-entrypoint-initdb.d", BindMode.READ_ONLY);
                }
            };

    static {
        MYSQL.start();
    }

    @Override
    public void initialize(ConfigurableApplicationContext applicationContext) {
        TestPropertyValues.of("spring.datasource.url=" + MYSQL.getJdbcUrl())
                .applyTo(applicationContext.getEnvironment());
    }
}

为了在Spring启动时执行操作,我们将创建一个实现ApplicationContextInitializer接口的类。

接続信息与docker-compose.yml文件中的设置相同。
mysql.conf目录和迁移SQL目录在层次上是一样的。

.
├── docker
│   ├── flyway
│   │   ├── conf
│   │   │   └── flyway.conf
│   │   └── sql
│   │       ├── V1.0.0__schema.sql
│   │       └── V1.1.0__data.sql
│   ├── mysql
│   │   ├── Dockerfile
│   │   └── conf.d
│   │       └── my.cnf

我在这里的话,只能将它复制到src/test/resources吗?我一直在考虑这个问题,但过了一会儿。

// 追記(FlywayのマイグレーションSQLをテストリソースとして使いたいため)
sourceSets.test {
    resources.srcDirs = ["src/test/resources", "docker"]
}

这个意识到了一个好处,顺利地将文件通过应用和测试集中管理了起来。

写试卷

@SpringBootTest
@AutoConfigureMockMvc
@ContextConfiguration(initializers = [MySQLContainerContextInitializer.class])
class CarControllerTest extends Specification {
    @Autowired
    MockMvc mockMvc

    @Unroll
    def "FindByPriceLessThanEqualOrderByPriceAsc HttpStatus"() {

        when:
        def result =
                mockMvc.perform(MockMvcRequestBuilders.get("/v1/cars/30000"))
                        .andReturn().getResponse()

        then:
        result.getStatus() == HttpStatus.OK.value
    }

    @Unroll
    def "FindByPriceLessThanEqualOrderByPriceAsc Data Pattern"() {

        expect:
        mockMvc.perform(MockMvcRequestBuilders.get("/v1/cars/" + price))
                .andExpect(MockMvcResultMatchers.jsonPath("\$.*", Matchers.hasSize(size)))

        where:
        price   || size
        "50000" || 10
        "40000" || 10
        "30000" || 9
        "20000" || 5
        "15000" || 2
        "10000" || 0

    }

}

@ContextConfiguration(initializers = [MySQLContainerContextInitializer.class]) – 采用 @ContextConfiguration 注解,并指定初始化器为 MySQLContainerContextInitializer.class。

通过指定先前创建的Helper类,启动并设置容器的连接信息。
这样,就可以从测试代码中访问用于测试的MySQL容器了。

考试的内容包括以下两项。

    • HTTPステータス200でレスポンスが返るか(FindByPriceLessThanEqualOrderByPriceAsc HttpStatus)

 

    価格パターンによるデータ駆動テスト(FindByPriceLessThanEqualOrderByPriceAsc Data Pattern)

我支持Spock的最大理由无疑是其“易读性”。

        expect:
        mockMvc.perform(MockMvcRequestBuilders.get("/v1/cars/" + price))
                .andExpect(MockMvcResultMatchers.jsonPath("\$.*", Matchers.hasSize(size)))

        where:
        price   || size
        "50000" || 10
        "40000" || 10
        "30000" || 9
        "20000" || 5
        "15000" || 2
        "10000" || 0

就算没有评论,你也知道在测试什么吧。我们将传递给REST API的价格数据模式定义为price,并将应返回的汽车对象数定义为每个数据模式的size。

通过考试

 $ ./gradlew clean test

测试结果将显示在控制台上,并且还会生成报告,所以让我们在浏览器中查看一下。

.
├── build
│   ├── reports
│   │   └── tests
│   │       └── test
│   │           ├── classes
│   │           │   └── com.example.restapitestbyspock.application.CarControllerTest.html
│   │           ├── css
│   │           │   ├── base-style.css
│   │           │   └── style.css
│   │           ├── index.html
│   │           ├── js
│   │           │   └── report.js
│   │           └── packages
│   │               └── com.example.restapitestbyspock.application.html

所有的测试都取得了成功。

名称3設定3.png

测试失败有

名称未設定4.png

请查阅相关资料

https://matsuand.github.io/docs.docker.jp.onthefly/reference/ 的中文内容如下:
https://www.testcontainers.org/ 的中文内容如下:

最后

您对此有何看法?
如果能对大家稍有帮助就非常荣幸了。

我们公司目前正在进行丰田汽车的订阅服务“KINTO”等项目的企划和开发,并正在招聘工程师。
KINTO Technologies 公司网站

bannerAds