顯示具有 Spring 標籤的文章。 顯示所有文章
顯示具有 Spring 標籤的文章。 顯示所有文章

2026年3月29日 星期日

在 Windows 的環境下使用 Eclipse 在 Java Unit Test 中使用 TestContainers 做測試 - Docker 用 WSL2 的方式安裝 (不用 Docker Desktop)

根據上一篇的
在 Windows 環境使用 Docker 指令控制 WSL2 中的 Docker (不是 Docker Desktop) 的設定方式
我們設定好讓 Windows 可以使用 Docker 指令控制 WSL2 的 Docker 之後,
就可以使用 TestContainers 來進行測試,
在這篇文裡我會展示一個使用 TestContainers 進行 Database 的 Unit Test 範例。

模擬環境:

  1. TestContainers version 我使用 2.0.3 版。
  2. 以使用 Maven 的 Spring MVC 專案為例 (這裡使用了 No-xml 的配置方式,可以參考 No XML for Java EE Spring Application,不過使用了較新的 JDK, Spring 版本,所以有部份修改 ) 。
  3. 使用 JDK 20。
  4. 在 Unit Test 中使用 TestContainers 測試 MsSql (SqlServer) DAO method,MsSql 有設定 full-text search 環境,可以測試如 CONTAINS, FREETEXT 等語法。
  5. 在測試中模擬了兩個 Database, database1 和 database2。
  6. 使用了 HikariCP connection pool。

首先是在 pom.xml 裡引入需要的 LIbrary :

/pom.xml (主要是 <dependencyManagement> 和 <dependency> 的部份) :
<?xml version="1.0" encoding="UTF-8"?>

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>my.test</groupId>
  <artifactId>testcontainers-test</artifactId>
  <version>0.1</version>
  <packaging>war</packaging>

  <name>testcontainers-test Maven Webapp</name>
  <!-- FIXME change it to the project's website -->
  <url>http://www.example.com</url>

  <properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <maven.compiler.source>20</maven.compiler.source>
    <maven.compiler.target>20</maven.compiler.target>
  </properties>
  
  <dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-framework-bom</artifactId>
            <version>7.0.5</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
        
        <!-- Source: https://mvnrepository.com/artifact/org.testcontainers/testcontainers-bom -->
		<dependency>
		    <groupId>org.testcontainers</groupId>
		    <artifactId>testcontainers-bom</artifactId>
		    <version>2.0.3</version>
		    <type>pom</type>
		    <scope>import</scope>
		</dependency>
		
		<!-- Source: https://mvnrepository.com/artifact/org.junit/junit-bom -->
		<dependency>
		    <groupId>org.junit</groupId>
		    <artifactId>junit-bom</artifactId>
		    <version>6.0.3</version>
		    <type>pom</type>
		    <scope>import</scope>
		</dependency>
    </dependencies>
  </dependencyManagement>

  <dependencies>
    <!-- Source: https://mvnrepository.com/artifact/org.junit.jupiter/junit-jupiter -->
	<dependency>
	    <groupId>org.junit.jupiter</groupId>
	    <artifactId>junit-jupiter</artifactId>
	    <scope>test</scope>
	</dependency>
	
	<!-- https://mvnrepository.com/artifact/jakarta.servlet/jakarta.servlet-api -->
	<dependency>
	    <groupId>jakarta.servlet</groupId>
	    <artifactId>jakarta.servlet-api</artifactId>
	    <version>6.1.0</version>
	</dependency>
	
    <!-- https://mvnrepository.com/artifact/org.springframework/spring-webmvc -->  
    <dependency>
    	<groupId>org.springframework</groupId>
    	<artifactId>spring-webmvc</artifactId>
	</dependency>
	
	<!-- https://mvnrepository.com/artifact/org.springframework/spring-jdbc -->
	<dependency>
	    <groupId>org.springframework</groupId>
	    <artifactId>spring-jdbc</artifactId>
	</dependency>
	
	<!-- https://mvnrepository.com/artifact/org.springframework/spring-test -->
	<dependency>
	    <groupId>org.springframework</groupId>
	    <artifactId>spring-test</artifactId>
	    <scope>test</scope>
	</dependency>
	
	<!-- https://mvnrepository.com/artifact/org.apache.commons/commons-dbcp2 -->
	<dependency>
    	<groupId>org.apache.commons</groupId>
    	<artifactId>commons-dbcp2</artifactId>
    	<version>2.9.0</version>
	</dependency>
	
	<!-- Source: https://mvnrepository.com/artifact/com.zaxxer/HikariCP -->
	<dependency>
	    <groupId>com.zaxxer</groupId>
	    <artifactId>HikariCP</artifactId>
	    <version>7.0.2</version>
	</dependency>
	
	<!-- Source: https://mvnrepository.com/artifact/com.microsoft.sqlserver/mssql-jdbc -->
	<dependency>
	    <groupId>com.microsoft.sqlserver</groupId>
	    <artifactId>mssql-jdbc</artifactId>
	    <version>13.2.1.jre11</version>
	</dependency>
	
	<!-- https://mvnrepository.com/artifact/org.testcontainers/testcontainers -->
	<dependency>
	    <groupId>org.testcontainers</groupId>
	    <artifactId>testcontainers</artifactId>
	    <scope>test</scope>
	</dependency>

	<!-- https://mvnrepository.com/artifact/org.testcontainers/junit-jupiter -->
	<dependency>
	    <groupId>org.testcontainers</groupId>
	    <artifactId>testcontainers-junit-jupiter</artifactId>
	    <scope>test</scope>
	</dependency>

	<!-- https://mvnrepository.com/artifact/org.testcontainers/testcontainers-postgresql -->
	<!-- 如果是使用 Postgresql, testcontainers 也有相應配合的 dependency 可用 -->
	<dependency>
	    <groupId>org.testcontainers</groupId>
	    <artifactId>testcontainers-postgresql</artifactId>
	    <scope>test</scope>
	</dependency>

	<!-- https://mvnrepository.com/artifact/org.testcontainers/testcontainers-mssqlserver -->
	<dependency>
	    <groupId>org.testcontainers</groupId>
	    <artifactId>testcontainers-mssqlserver</artifactId>
	    <scope>test</scope>
	</dependency>

  </dependencies>

  <build>
    <finalName>testcontainers-test</finalName>
    <pluginManagement><!-- lock down plugins versions to avoid using Maven defaults (may be moved to parent pom) -->
      <plugins>
        <plugin>
          <artifactId>maven-clean-plugin</artifactId>
          <version>3.1.0</version>
        </plugin>
        <!-- see http://maven.apache.org/ref/current/maven-core/default-bindings.html#Plugin_bindings_for_war_packaging -->
        <plugin>
          <artifactId>maven-resources-plugin</artifactId>
          <version>3.0.2</version>
        </plugin>
        <plugin>
          <artifactId>maven-compiler-plugin</artifactId>
          <version>3.8.0</version>
        </plugin>
        <plugin>
          <artifactId>maven-surefire-plugin</artifactId>
          <version>2.22.1</version>
        </plugin>
        <plugin>
          <artifactId>maven-war-plugin</artifactId>
          <version>3.2.2</version>
        </plugin>
        <plugin>
          <artifactId>maven-install-plugin</artifactId>
          <version>2.5.2</version>
        </plugin>
        <plugin>
          <artifactId>maven-deploy-plugin</artifactId>
          <version>2.8.2</version>
        </plugin>
      </plugins>
    </pluginManagement>
  </build>
</project>
再來先來設定一下 production 真實環境 Database 的 properties,例如 driver, url, username, password 等,我們可以用 Spring 的 @Value 將值讀進來,
不過這裡我只是想要展示 UnitTest 的部份,
不用管實際的環境情況,所以值可以隨便設定,
在 UnitTest 時,我們可以用 @DynamicPropertySource 在 Spring Bean 被裝配之前覆蓋掉 Spring properties 的值,改變 @Value 讀進來的值。

/src/main/java/com/properties/db.properties :
#mssql db properties
db.mssql.driver=com.microsoft.sqlserver.jdbc.SQLServerDriver
db.mssql.port=xxx-port
db.mssql.username=xxx-user
db.mssql.password=xxx-password

db.mssql.database1.url=jdbc:sqlserver:///xxxUrl:xxxPort;databaseName=database1
db.mssql.database2.url=jdbc:sqlserver:///xxxUrl:xxxPort;databaseName=database2

在 db.properties 中設定了兩個 Database,database1 和 database2。

接下來我要設定 DataSource 給 Spring 去裝配,

/src/main/java/com/config/DBConfig.java :

package com.config;

import javax.sql.DataSource;

import org.apache.commons.dbcp2.BasicDataSource;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;

@Configuration
public class DBConfig {

	// database1 的設定
	@Bean
	public DataSource database1DataSource(@Value("${db.mssql.driver}") String dbDriver,
			                                  @Value("${db.mssql.port}") String dbPort,
			                                  @Value("${db.mssql.username}") String dbUsername,
			                                  @Value("${db.mssql.password}") String dbPassword,
				                              @Value("${db.mssql.database1.url}") String dbUrl) {
		
		//這裡練習使用 HikariCP 做 connection pool
		HikariConfig dataSourceConfig = new HikariConfig();
		dataSourceConfig.setDriverClassName(dbDriver);
		dataSourceConfig.setJdbcUrl(dbUrl);
		dataSourceConfig.setUsername(dbUsername);
		dataSourceConfig.setPassword(dbPassword);
		dataSourceConfig.setConnectionTestQuery("SELECT 1");
		
		DataSource dataSource = new HikariDataSource(dataSourceConfig);		
		//如果沒有要使用其他 Connection pool 的話,也可以直接使用 BasicDataSource
		//DataSource dataSource = new BasicDataSource();		
		
		return dataSource;
	}
	
	@Bean
	public NamedParameterJdbcTemplate database1JdbcTemplate(@Qualifier("database1DataSource") DataSource datasource) {
		return new NamedParameterJdbcTemplate(datasource);
	}
	
	@Bean
	public DataSourceTransactionManager database1TxManager(@Qualifier("database1DataSource") DataSource datasource) {
		return new DataSourceTransactionManager(datasource);
	}
	
	// database2 的設定
	@Bean
	public DataSource database2DataSource(@Value("${db.mssql.driver}") String dbDriver,
			                                  @Value("${db.mssql.port}") String dbPort,
			                                  @Value("${db.mssql.username}") String dbUsername,
			                                  @Value("${db.mssql.password}") String dbPassword,
				                              @Value("${db.mssql.database2.url}") String dbUrl) {
		
		HikariConfig dataSourceConfig = new HikariConfig();
		dataSourceConfig.setDriverClassName(dbDriver);
		dataSourceConfig.setJdbcUrl(dbUrl);
		dataSourceConfig.setUsername(dbUsername);
		dataSourceConfig.setPassword(dbPassword);
		dataSourceConfig.setConnectionTestQuery("SELECT 1");
		
		DataSource dataSource = new HikariDataSource(dataSourceConfig);
		return dataSource;
	}
	
	@Bean
	public NamedParameterJdbcTemplate database2JdbcTemplate(@Qualifier("database2DataSource") DataSource datasource) {
		return new NamedParameterJdbcTemplate(datasource);
	}
	
	@Bean
	public DataSourceTransactionManager database2TxManager(@Qualifier("database2DataSource") DataSource datasource) {
		return new DataSourceTransactionManager(datasource);
	}
}

做一下 代表 Database Table Data 的 Bean 的設定 :

/src/main/java/com/bean/MemberBean.java :

package com.bean;

public class MemberBean {

	private int id;
	private String name;
	private String email;
	
	public int getId() {
		return id;
	}

	public void setId(int id) {
		this.id = id;
	}

	public String getName() {
		return name;
	}

	public void setName(String name) {
		this.name = name;
	}

	public String getEmail() {
		return email;
	}

	public void setEmail(String email) {
		this.email = email;
	}
}

/src/main/java/com/bean/PurchaseOrderBean.java :

package com.bean;

import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;

public class PurchaseOrderBean {
	private int id;
	private Instant createdDate;
	private int memberId;
	private String detail;

	public int getId() {
		return id;
	}

	public void setId(int id) {
		this.id = id;
	}

	public Instant getCreatedDate() {
		return createdDate;
	}

	public void setCreatedDate(Instant createdDate) {
		this.createdDate = createdDate;
	}
	public void setCreatedDate(String offsetDatetimeStr) {
		DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSSSSSS xxx");
		Instant instantDate = OffsetDateTime.parse(offsetDatetimeStr, dtf).toInstant();
		this.createdDate = instantDate;
	}

	public int getMemberId() {
		return memberId;
	}

	public void setMemberId(int memberId) {
		this.memberId = memberId;
	}

	public String getDetail() {
		return detail;
	}

	public void setDetail(String detail) {
		this.detail = detail;
	}
}

設定 DAO 的部份 :

/src/main/java/com/dao/MemberDAO.java :

package com.dao;

import java.sql.ResultSet;
import java.sql.SQLException;

import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.dao.IncorrectResultSizeDataAccessException;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository;

import com.bean.MemberBean;

@Repository
public class MemberDAO {

	private NamedParameterJdbcTemplate database1JdbcTemplate;
	
	public MemberDAO(@Qualifier("database1JdbcTemplate") NamedParameterJdbcTemplate database1JdbcTemplate) {
		this.database1JdbcTemplate = database1JdbcTemplate;
	}
	
	public MemberBean queryMemberByName(String name) {
		String sql = "SELECT * FROM member WHERE name = :name";
		
		MapSqlParameterSource sqlParams = new MapSqlParameterSource()
				                          .addValue("name", name);
		
		try {
			return database1JdbcTemplate.queryForObject(sql, sqlParams, new RowMapper<MemberBean>() {
	
				@Override
				public MemberBean mapRow(ResultSet rs, int rowNum) throws SQLException {
					MemberBean member = new MemberBean();
					member.setId(rs.getInt("id"));
					member.setName(rs.getString("name"));
					member.setEmail(rs.getString("email"));
					
					return member;
				}
				
			});
		} catch (IncorrectResultSizeDataAccessException e) {
			return null;
		}
	}
}

/src/main/java/com/dao/PurchaseOrderDAO.java :

package dao;

import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoField;
import java.util.List;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;

import com.bean.PurchaseOrderBean;
import com.dao.PurchaseOrderDAO;

public class PurchaseOrderDAOTest extends BaseDBTest {

	private PurchaseOrderDAO purchaseOrderDAO;
	DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss xxx").withZone(ZoneId.of("+0000"));
	
	@Autowired
	public PurchaseOrderDAOTest(PurchaseOrderDAO purchaseOrderDAO) {
		this.purchaseOrderDAO = purchaseOrderDAO;
	}
	
	@Test
	void testQueryPurchaseOrderListByMemberId() {
		JdbcTemplate database2JdbcTemplate = jdbcTemplateMap.get(DATABASE.database2);
		
		//捨棄毫秒部分以避免毫秒部份的精確度從 SQL 查詢回來的時間與測試用的時間不相等的問題
		Instant testCreatedDate = Instant.now().with(ChronoField.NANO_OF_SECOND, 0);
		int testMemberId = 3;
		
		List<PurchaseOrderBean> purchaseOrderList = purchaseOrderDAO.queryPurchaseOrderListByMemberId(testMemberId);
		Assertions.assertNotNull(purchaseOrderList);
		Assertions.assertTrue(purchaseOrderList.isEmpty());
		
		//
		//執行 SQL Update 時,直接以 String 的方式傳入避免時區可能錯誤的問題
		database2JdbcTemplate.update("INSERT INTO purchase_order(created_date, member_id) VALUES(?, ?)", dtf.format(testCreatedDate), testMemberId);
		
		purchaseOrderList = purchaseOrderDAO.queryPurchaseOrderListByMemberId(testMemberId);
		Assertions.assertNotNull(purchaseOrderList);
		Assertions.assertEquals(1, purchaseOrderList.size());
		
		PurchaseOrderBean purchaseOrder = purchaseOrderList.get(0);
		Assertions.assertEquals(testMemberId, purchaseOrder.getMemberId());
		Assertions.assertEquals(testCreatedDate, purchaseOrder.getCreatedDate());
	}
	
	@Test
	void testQueryPurchaseOrderListByDetailKeyword() {
		JdbcTemplate database2JdbcTemplate = jdbcTemplateMap.get(DATABASE.database2);
		
		Instant testCreatedDate = Instant.now().with(ChronoField.NANO_OF_SECOND, 0);
		int testMemberId = 111;
		String testDetail = "Hi, how are you?";
		String keyword = "hi";
		
		String sql = "INSERT INTO purchase_order(created_date, member_id, detail) VALUES(?, ?, ?)";
		database2JdbcTemplate.update(sql, dtf.format(testCreatedDate), testMemberId, testDetail);
		
		List<PurchaseOrderBean> purchaseOrderList = purchaseOrderDAO.queryPurchaseOrderListByDetailKeyword(keyword);
		Assertions.assertNotNull(purchaseOrderList);
		Assertions.assertTrue(purchaseOrderList.size() == 1);
		
		PurchaseOrderBean purchaseOrder = purchaseOrderList.get(0);
		Assertions.assertEquals(testMemberId, purchaseOrder.getMemberId());
		Assertions.assertEquals(testCreatedDate, purchaseOrder.getCreatedDate());
		Assertions.assertEquals(testDetail, purchaseOrder.getDetail());
	}
}

基本的專案內容都做好了以後,就可以來進行 Unit Test 的部份了,
為了方便建立測試用的 Database 環境,
例如建立要測試用的 Database, Table, View, Stored Procedure, Index, Full-Text Search 之類的,
我先把建立測試環境用的 SQL先寫好並以下面的結構放好:

/src/test/resources/sql/databases_create.sql (建立好所需的 Database) :

CREATE DATABASE database1;
CREATE DATABASE database2;

/src/test/resources/sql/tables_drop.sql (移除所有的 Database) :

--刪除目前 Database 下的所有 Table

DECLARE @sql NVARCHAR(MAX) = '';

SELECT @sql += 'DROP TABLE ' + QUOTENAME(table_schema) + '.' + QUOTENAME(table_name) + ';' + CHAR(13)
FROM information_schema.tables
WHERE table_type = 'base table'
AND table_schema = 'dbo';

EXEC sp_executesql @sql;

-- 刪除 Database 下的所有 Full-Text Index
DECLARE @tableName NVARCHAR(MAX);
DECLARE @sqlCommand NVARCHAR(MAX);

DECLARE index_cursor CURSOR FOR
SELECT QUOTENAME(t.name) AS TableName
FROM sys.tables t
     INNER JOIN sys.fulltext_indexes fti ON t.object_id = fti.object_id;

OPEN index_cursor;
FETCH NEXT FROM index_cursor INTO @tableName;

WHILE @@FETCH_STATUS = 0
BEGIN
    SET @sqlCommand = 'DROP FULLTEXT INDEX ON ' + @tableName + ';';
    EXEC sp_executesql @sqlCommand;
    FETCH NEXT FROM index_cursor INTO @tableName;
END

CLOSE index_cursor;
DEALLOCATE index_cursor;

-- 刪除 Database 下的所有 Full-Text Index Catelogs
DECLARE @CatalogName NVARCHAR(MAX);
DECLARE @CatalogCommand NVARCHAR(MAX);

DECLARE FullTextCatalogsCursor CURSOR FOR
SELECT QUOTENAME(name) AS CatalogName
FROM sys.fulltext_catalogs;

OPEN FullTextCatalogsCursor;
FETCH NEXT FROM FullTextCatalogsCursor INTO @CatalogName;

WHILE @@FETCH_STATUS = 0
BEGIN
    SET @CatalogCommand = 'DROP FULLTEXT CATALOG ' + @CatalogName;
    EXEC sp_executesql @CatalogCommand;
    FETCH NEXT FROM FullTextCatalogsCursor INTO @CatalogName;
END

CLOSE FullTextCatalogsCursor;
DEALLOCATE FullTextCatalogsCursor;

/src/test/resources/sql/databases/database1/tables/member.sql (建立 member 這個 Table,包括要的 Index, Full Text Index) :

CREATE TABLE member (
	id INT IDENTITY(1,1) PRIMARY KEY,
	name NVARCHAR(200) NOT NULL,
	email NVARCHAR(200) NOT NULL
);

/src/test/resources/sql/databases/database2/tables/purchase_order.sql (建立 purchase_order 這個 Table) :

CREATE TABLE purchase_order (
	id INT IDENTITY(1,1) PRIMARY KEY,
	created_date DATETIMEOFFSET NOT NULL,
	member_id INT NOT NULL,
	detail NVARCHAR(1000) NULL
);

-- 設定 Full-Text Index
-- 先把 Primary Key Index name 查出來
DECLARE @primaryKeyIndex NVARCHAR(100)
SELECT @primaryKeyIndex = i.name
FROM sys.indexes i
WHERE i.[object_id] = OBJECT_ID('purchase_order')
      AND i.is_primary_key = 1;

-- 建立 Full Text Catalog      
CREATE FULLTEXT CATALOG [full_text_catalog_purchase_order] WITH ACCENT_SENSITIVITY = ON

-- 建立 Full Text Index
DECLARE @sql NVARCHAR(MAX)
SET @sql = N'CREATE FULLTEXT INDEX ON purchase_order KEY INDEX ' + @primaryKeyIndex + N' ON (full_text_catalog_purchase_order) WITH (CHANGE_TRACKING AUTO)'
EXEC sp_executesql @sql

-- 把 column (可設定多個) 加到 Full Text Index 裡 
ALTER FULLTEXT INDEX ON purchase_order ADD ([detail])
ALTER FULLTEXT INDEX ON purchase_order ENABLE

建立一個 BaseDBTest.java 把 Unit Test 的基礎設定先寫好,
包括用 TestContainers 啟動 MSSQL Docker container, 建立 Database, Table, Index 等,
詳細的註解都寫在程式碼中。

/src/test/java/dao/BaseDBTest.java :

package dao;

import java.io.IOException;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import javax.sql.DataSource;

import org.junit.jupiter.api.
;
import org.junit.jupiter.api.AfterEach;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig;
import org.testcontainers.images.builder.ImageFromDockerfile;
import org.testcontainers.mssqlserver.MSSQLServerContainer;
import org.testcontainers.utility.DockerImageName;

import com.config.SpringApplicationConfig;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;

@SpringJUnitWebConfig(SpringApplicationConfig.class)
public class BaseDBTest {

	//用一個 enum 來定義需要的 Database,這樣在程式碼中要使用 Database 的地方就可以直接用 enum 的方式來使用,會比直接用 String 來得更有彈性和可讀性
	enum DATABASE {
		database1("database1"), database2("database2");
		
		private String databaseName;
		
		private DATABASE(String databaseName) {
			this.databaseName = databaseName;
		}
		
		public static DATABASE fromDatabaseName(String databaseName) {
			for (DATABASE db : DATABASE.values()) {
				if (db.getDatabaseName().equalsIgnoreCase(databaseName)) {
					return db;
				}
			}
			return null;
		}
		
		public String getDatabaseName() {
			return databaseName;
		}
		
	}
	
	// TestContainers 啟動的 MSSQL Docker container
	private static MSSQLServerContainer mssqlServerDockerContainer;
	//MSSQL Docker container 一開始建立好我們取得的 jdbcTemplate,
	//用來建立其他 Unit Test 所需要的 Database
	private static JdbcTemplate mssqlJdbcTemplate;
	
	//用來存放對應各 Database 的 jdbcTemplate,用 Database enum 當 key 增加可讀性
	static Map<DATABASE, JdbcTemplate> jdbcTemplateMap = new HashMap<>();
	
	//@DynamicPropertSource 會在 Spring Context 啟動前執行,
	//這樣就可以在 Spring Context 啟動前先啟動 TestContainers 的 MSSQL Docker container 
	//並將實際的參數覆蓋掉 db.properties 中的參數,
	//確保在測試時用 @Value 取得的值是正確的 TestContainers Docker container 的參數,
	//例如 username, password, port, url 等等
	@DynamicPropertySource
	static void setUpMssqlEnvironment(DynamicPropertyRegistry registry) throws IOException, URISyntaxException {
		//使用 TestContainers docker pull image 下來並啟動 Container
		//如果需要 full-text search 功能,則需要自己 build image 並在 image 中安裝 full-text search 的套件,範例如下:
		ImageFromDockerfile mssqlFtsImage = new ImageFromDockerfile()
											.withDockerfileFromBuilder(builder ->
												builder.from("mcr.microsoft.com/mssql/server:2022-latest")
														.user("root")
														//# Install dependencies - these are required to make changes to apt-get below
														.run("apt-get update")
														.run("apt-get install -yq gnupg gnupg2 gnupg1 curl apt-transport-https")
														//# Install SQL Server package links
														.run("curl https://packages.microsoft.com/keys/microsoft.asc -o /var/opt/mssql/ms-key.cer")
														.run("apt-key add /var/opt/mssql/ms-key.cer")
														.run("curl https://packages.microsoft.com/config/ubuntu/22.04/mssql-server-2022.list -o /etc/apt/sources.list.d/mssql-server.list")
														.run("apt-get update")
														//# Install SQL Server full-text-search - this only works if you add the packages references into apt-get above
														.run("apt-get install -y mssql-server-fts")
														//# Cleanup
														.run("apt-get clean")
														.run("rm -rf /var/lib/apt/lists")
														//# Run SQL Server process
														.entryPoint("/opt/mssql/bin/sqlservr")
														.build()
											);

		String builtImageName = mssqlFtsImage.get();
		DockerImageName dockerImageName = DockerImageName.parse(builtImageName)
		                                  .asCompatibleSubstituteFor("mcr.microsoft.com/mssql/server");
		
		mssqlServerDockerContainer = new MSSQLServerContainer(dockerImageName)
				                     //如果沒有需要 full-text search 的功能,則可以直接使用官方 image,範例如下:
				                     //new MSSQLServerContainer("mcr.microsoft.com/mssql/server:2019-CU14-ubuntu-20.04")
			 	 					 .acceptLicense()
								 	 .withUrlParam("trustServerCertificate", "true")
								 	 .withPassword("testPassword123#")
								 	 .withEnv(Map.of("MSSQL_PID", "Standard"))
								 	 .withEnv(Map.of("MSSQL_AGENT_ENABLED", "true"))
								 	 .withEnv(Map.of("TZ", "America/Los_Angeles"));
		
		mssqlServerDockerContainer.start();
		
		//將 TestContainers 建立的 Mssql Docker container 的各項實際參數 (username, passowrd, url, port 等)
		//覆蓋 db.properties 中設定的值,這樣在測試時用 @Valve 取得的值就會是被覆蓋掉的值
		registry.add("db.mssql.driver", mssqlServerDockerContainer::getDriverClassName);
		registry.add("db.mssql.username", mssqlServerDockerContainer::getUsername);
		registry.add("db.mssql.password", mssqlServerDockerContainer::getPassword);
		registry.add("db.mssql.port", () -> mssqlServerDockerContainer.getMappedPort(1433));
		
		registry.add("db.mssql.database1.url", () -> mssqlServerDockerContainer.getJdbcUrl() + ";databaseName=database1");
		registry.add("db.mssql.database2.url", () -> mssqlServerDockerContainer.getJdbcUrl() + ";databaseName=database2");
		
		//建立一開始 Database 的 Datasource 和 jdbcTemplate
		HikariConfig dataSourceConfig = new HikariConfig();
		dataSourceConfig.setDriverClassName(mssqlServerDockerContainer.getDriverClassName());
		dataSourceConfig.setJdbcUrl(mssqlServerDockerContainer.getJdbcUrl());
		dataSourceConfig.setUsername(mssqlServerDockerContainer.getUsername());
		dataSourceConfig.setPassword(mssqlServerDockerContainer.getPassword());
		dataSourceConfig.setConnectionTestQuery("SELECT 1");
		
		DataSource mssqlDataSource = new HikariDataSource(dataSourceConfig);
		
		mssqlJdbcTemplate = new JdbcTemplate(mssqlDataSource);
		
		//建立需要的 Database
		String dbCreateSql = Files.readString(Paths.get(BaseDBTest.class.getClassLoader().getResource("sql/databases_create.sql").toURI()), StandardCharsets.UTF_8);
		mssqlJdbcTemplate.update(dbCreateSql);
		
		//設定各 Database 的 jdbcTemplate 到 JdbcTemplate 中方便之後取用
		setJdbcTemplateForDatabases();
		//為各 Database 建立需要的 Table
		createTables();
	}
	
	//在每個測試方法之後都執行一次,確保每個測試方法執行時 Database 中的 Table 都是乾淨的狀態
	@AfterEach
	void refreshTables() throws IOException, URISyntaxException {
		dropDbTables();
		createTables();
	}
	
	@AfterAll
	static void closeDockerContainers() {
		//將 TestContainer 開啟的 Container 停掉,
        //不過官方有說 TestContainers 預設會使用 Ryuk Container (除非你因例如權限原因等不能使用 Ryuk 而設定了環境變數 TESTCONTAINERS_RYUK_DISABLED=true)
        //自動進行 Container 的清理,所以也可以不用手動執行 stop
		mssqlServerDockerContainer.stop();
	}
	
	//設定各 Database 的 jdbcTemplate 到 JdbcTemplate 中方便之後取用
	static void setJdbcTemplateForDatabases() {

		for (DATABASE db : DATABASE.values()) {
			HikariConfig dataSourceConfig = new HikariConfig();
			dataSourceConfig.setDriverClassName(mssqlServerDockerContainer.getDriverClassName());
			dataSourceConfig.setJdbcUrl(mssqlServerDockerContainer.getJdbcUrl() + ";databaseName=" + db.getDatabaseName());
			dataSourceConfig.setUsername(mssqlServerDockerContainer.getUsername());
			dataSourceConfig.setPassword(mssqlServerDockerContainer.getPassword());
			dataSourceConfig.setConnectionTestQuery("SELECT 1");
			
			DataSource dataSource = new HikariDataSource(dataSourceConfig);
						
			JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource);
			jdbcTemplateMap.put(db, jdbcTemplate);
		}
	}
	
	//為各 Database 建立需要的 Table
	static void createTables() throws IOException, URISyntaxException {
		List<Path> databasesResourcePathList = Files.list(Paths.get(BaseDBTest.class.getClassLoader().getResource("sql/databases").toURI()))
                                                         .collect(Collectors.toList());
		
		for (Path databasesResourcePath : databasesResourcePathList) {
			String databaseName = databasesResourcePath.getFileName().toString();
			JdbcTemplate jdbcTemplate = jdbcTemplateMap.get(DATABASE.fromDatabaseName(databaseName));
			
			Path tablesFolderPath = databasesResourcePath.resolve("tables");
			if (!Files.exists(tablesFolderPath)) {
				continue;
			}
			List<Path> tableCreateSqlPathList = Files.list(tablesFolderPath)
					                            .collect(Collectors.toList());
			for (Path tableCreateSqlPath : tableCreateSqlPathList) {
				String databasesCreateSql = Files.readString(tableCreateSqlPath, StandardCharsets.UTF_8);
				jdbcTemplate.update(databasesCreateSql);
			}
		}
	}
	
	//Drop 所有 Database 下的所有 Table
	//包括 Drop 所有的 Index, Full Text Index, Full Text Catalog 等
	void dropDbTables() throws IOException, URISyntaxException {
		Path tablesDropSqlPath = Paths.get(BaseDBTest.class.getClassLoader().getResource("sql/tables_drop.sql").toURI());
		
		List<Path> databasesResourcePathList = Files.list(Paths.get(BaseDBTest.class.getClassLoader().getResource("sql/databases").toURI()))
                                               .collect(Collectors.toList());

		for (Path databasesResourcePath : databasesResourcePathList) {
			String databaseName = databasesResourcePath.getFileName().toString();
			JdbcTemplate jdbcTemplate = jdbcTemplateMap.get(DATABASE.fromDatabaseName(databaseName));
			
			String tablesDropSql = Files.readString(tablesDropSqlPath, StandardCharsets.UTF_8);
			jdbcTemplate.update(tablesDropSql);
		}
	}
}

/src/test/java/dao/MemberDAOTest.java :

package dao;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;

import com.bean.MemberBean;
import com.dao.MemberDAO;

public class MemberDAOTest extends BaseDBTest {

	private MemberDAO memberDAO;
	
	@Autowired
	public MemberDAOTest(MemberDAO memberDAO) {
		this.memberDAO = memberDAO;
	}
	
	@Test
	void testQueryMemberByName() {
		JdbcTemplate database1JdbcTemplate = jdbcTemplateMap.get(DATABASE.database1);
		
		String testName = "testName";
		String testEmail = "xxx@xxx.com";
		
		MemberBean member = memberDAO.queryMemberByName(testName);
		Assertions.assertNull(member);
		
		database1JdbcTemplate.update("INSERT INTO member(name, email) VALUES(?, ?)", testName, testEmail);
		member = memberDAO.queryMemberByName(testName);
		Assertions.assertNotNull(member);
		Assertions.assertEquals(testName, member.getName());
		Assertions.assertEquals(testEmail, member.getEmail());
	}
}

/src/test/java/dao/PurchaseOrderDAOTest.java :

package dao;

import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoField;
import java.util.List;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;

import com.bean.PurchaseOrderBean;
import com.dao.PurchaseOrderDAO;

public class PurchaseOrderDAOTest extends BaseDBTest {

	private PurchaseOrderDAO purchaseOrderDAO;
	DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss xxx").withZone(ZoneId.of("+0000"));
	
	@Autowired
	public PurchaseOrderDAOTest(PurchaseOrderDAO purchaseOrderDAO) {
		this.purchaseOrderDAO = purchaseOrderDAO;
	}
	
	@Test
	void testQueryPurchaseOrderListByMemberId() {
		JdbcTemplate database2JdbcTemplate = jdbcTemplateMap.get(DATABASE.database2);
		
		//捨棄毫秒部分以避免毫秒部份的精確度從 SQL 查詢回來的時間與測試用的時間不相等的問題
		Instant testCreatedDate = Instant.now().with(ChronoField.NANO_OF_SECOND, 0);
		int testMemberId = 3;
		
		List<PurchaseOrderBean> purchaseOrderList = purchaseOrderDAO.queryPurchaseOrderListByMemberId(testMemberId);
		Assertions.assertNotNull(purchaseOrderList);
		Assertions.assertTrue(purchaseOrderList.isEmpty());
		
		//
		//執行 SQL Update 時,直接以 String 的方式傳入避免時區可能錯誤的問題
		database2JdbcTemplate.update("INSERT INTO purchase_order(created_date, member_id) VALUES(?, ?)", dtf.format(testCreatedDate), testMemberId);
		
		purchaseOrderList = purchaseOrderDAO.queryPurchaseOrderListByMemberId(testMemberId);
		Assertions.assertNotNull(purchaseOrderList);
		Assertions.assertEquals(1, purchaseOrderList.size());
		
		PurchaseOrderBean purchaseOrder = purchaseOrderList.get(0);
		Assertions.assertEquals(testMemberId, purchaseOrder.getMemberId());
		Assertions.assertEquals(testCreatedDate, purchaseOrder.getCreatedDate());
	}
	
	@Test
	void testQueryPurchaseOrderListByDetailKeyword() {
		JdbcTemplate database2JdbcTemplate = jdbcTemplateMap.get(DATABASE.database2);
		
		Instant testCreatedDate = Instant.now().with(ChronoField.NANO_OF_SECOND, 0);
		int testMemberId = 111;
		String testDetail = "Hi, how are you?";
		String keyword = "hi";
		
		String sql = "INSERT INTO purchase_order(created_date, member_id, detail) VALUES(?, ?, ?)";
		database2JdbcTemplate.update(sql, dtf.format(testCreatedDate), testMemberId, testDetail);
		
		List<PurchaseOrderBean> purchaseOrderList = purchaseOrderDAO.queryPurchaseOrderListByDetailKeyword(keyword);
		Assertions.assertNotNull(purchaseOrderList);
		Assertions.assertTrue(purchaseOrderList.size() == 1);
		
		PurchaseOrderBean purchaseOrder = purchaseOrderList.get(0);
		Assertions.assertEquals(testMemberId, purchaseOrder.getMemberId());
		Assertions.assertEquals(testCreatedDate, purchaseOrder.getCreatedDate());
		Assertions.assertEquals(testDetail, purchaseOrder.getDetail());
	}
}

源碼下載分享:

    testcontainers-test.zip

參考資料:

  1. HikariCP
  2. 在 Linux 上安裝 SQL Server 全文檢索搜尋
  3. Installing MSSQL server using docker with full text search support


2024年12月31日 星期二

Java Database Unit Test - Spring + Database Rider

紀錄一下使用 Spring 搭配 Database Rider 的注意事項。

下面直接看範例,相關的說明也寫在注解上了。

最主要要注意的地方是,
如果不使用 Dabase Rider 的 Annotation 的話,跟之前這篇
使用 Dabase Rider 進行 Database 測試
的寫法是差不多的,只差在取得 Connection 的地方可以直接使用 Spring 幫我們裝配好的

JdbcTemplate 來取得 Datasource,再使用
DataSourceUtils.getConnection(dataSource) 取得 Connection。

如果想使用 Database Rider 的 Annotation 的話,需要使用
@DBRider(dataSourceBeanName = "test_DataSource")
這個 Annotaton 來設定 dataSourceBeanName,
@DBRider 本身就是 @ExtendWith(DBUnitExtension.class) 和 @Test 的組合,
但多提供了我們設定 dataSourceBeanName 的地方,
我們只要把設定在 Spring 中需要的 dataSourceBeanName 設定給 @DBRider,
之後 Database Rider 的 @DataSet, @ExpectedDataSet 這些 Annotation 就會直接用dataSourceBeanName 去找出 Spring 裝配好的 dataSource 進行 Database 連線,
所以不用再像這篇
使用 Dabase Rider 進行 Database 測試
一樣特別去設定 ConnectionHolder。

package test.dao;

import java.io.File;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.file.Paths;
import java.sql.Connection;
import java.sql.SQLException;

import javax.sql.DataSource;

import org.dbunit.DatabaseUnitException;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.junit.jupiter.api.TestInstance.Lifecycle;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceUtils;
import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig;
import com.github.database.rider.core.api.configuration.DBUnit;
import com.github.database.rider.core.api.dataset.DataSet;
import com.github.database.rider.core.api.dataset.DataSetFormat;
import com.github.database.rider.core.api.dataset.ExpectedDataSet;
import com.github.database.rider.core.api.exporter.DataSetExportConfig;
import com.github.database.rider.core.configuration.DBUnitConfig;
import com.github.database.rider.core.configuration.DataSetConfig;
import com.github.database.rider.core.configuration.ExpectedDataSetConfig;
import com.github.database.rider.core.dsl.RiderDSL;
import com.github.database.rider.core.exporter.DataSetExporter;
import com.github.database.rider.junit5.api.DBRider;

//讀取配置檔讓 Spring 處理 Bean 的依賴裝配
@SpringJUnitWebConfig(locations = {"file:WebContent/WEB-INF/mvc-config.xml"})
//如果要使用 Database Rider 的 Annotation (@Dataset, @ExpectedDataSet 等)
//才需要使用 @DBRider,
//@DBRider 內包含 @ExtendWith(DBUnitExtension.class) 和 @Test,
//並且多了一個 dataSourceBeanName 屬性,用來指定要使用的 Spring DataSourceBean 名稱
@DBRider(dataSourceBeanName = "test_DataSource")
@DBUnit(cacheConnection = false,
		allowEmptyFields = true)
@TestInstance(Lifecycle.PER_CLASS)
class DBRiderTest {
	
	//如果不使用 Database Rider 的 Annotation,
	//可以自行依需要取得 JdbcTemplate,進而取得 Datasource 和 Jdbc Connection
	@Autowired
	@Qualifier("test_JdbcTemplate")
	JdbcTemplate test_JdbcTemplate;
	
	final String testResourceFolderPath = "";
	final String backupDatasetResourcePath = "/backupDataset.xlsx";
	final String testDatasetResourcePath = "/testDataset.xlsx";
	
	final String[] includeTableList = new String[] {"test_table"};
	
	//因為 @ExportDataSet 似乎無法使用在 @BeforeAll 及 @AfterAll 上,
	//所以這裡不使用 Annotation,直接使用 API 進行 backup dataset 的 export
	@BeforeAll
	void exportBackupDataset() throws SQLException, DatabaseUnitException, URISyntaxException {
		URL backupDatasetResourceFolderUrl = DBRiderTest.class.getClassLoader().getResource(testResourceFolderPath);
		File backupDatasetResourceFolder = Paths.get(backupDatasetResourceFolderUrl.toURI()).toFile();
		String backupDatasetResourceFilePath = backupDatasetResourceFolder.getAbsolutePath() + backupDatasetResourcePath.replace("/", File.separator);
		
		DataSource dataSource = test_JdbcTemplate.getDataSource();
		Connection conn = DataSourceUtils.getConnection(dataSource);
		DataSetExporter.getInstance().export(conn,
											 new DataSetExportConfig()
											 .dataSetFormat(DataSetFormat.XLSX)
											 .includeTables(includeTableList)
											 .queryList(new String[] {})
											 .outputFileName(backupDatasetResourceFilePath));
		
		DataSourceUtils.releaseConnection(conn, dataSource);
	}
	
	//因為 @DataSet 似乎無法使用在 @BeforeAll 及 @AfterAll 上,
	//所以這裡不使用 Annotation,直接使用 API 進行 backup dataset 的 import
	@AfterAll
	void importBackupDataset() {
		//直接利用 Spring Inject 裝配好的 JdbcTemplate,並取得 DataSource 和 Connection
		DataSource dataSource = test_JdbcTemplate.getDataSource();
		Connection conn = DataSourceUtils.getConnection(dataSource);
		RiderDSL.withConnection(conn)
				.withDataSetConfig(new DataSetConfig(testResourceFolderPath + backupDatasetResourcePath)
						           //依需求設定 import dataset 要執行的動作,
						           //例如: Sql Server (MS Sql) 要 Insert/Update 主鍵時需要 SET IDENTITY_INSERT test_table ON
						           .executeStatementsBefore("")) 
				.withDBUnitConfig(new DBUnitConfig()
						          .addDBUnitProperty("allowEmptyFields", true))
				.createDataSet();
	
		DataSourceUtils.releaseConnection(conn, dataSource);
		//do other things
	}
	
	//使用 @DataSet 來 import test dataset
	@BeforeEach
	@DataSet(value = "testDataset.xlsx",
			 //依需求設定 import dataset 要執行的動作,
	         //例如: Sql Server (MS Sql) 要 Insert/Update 主鍵時需要 SET IDENTITY_INSERT test_table ON
	         executeStatementsBefore = {""})
	void importTestDataset() {
	}
	
	@Test
	void test() {
		System.out.println("test");
		Assertions.assertTrue(true);
	}
	
	//使用 @ExpectedDataSet Annotation 比較 database table 的資料是否和 expected dataset 一致
	@Test
	@ExpectedDataSet(value = "expectedDataset.xlsx")
	void testExpectedDatasetByAnnotation() throws DatabaseUnitException, SQLException {
		//做你想做的 Test
		//例如以下是比較 Actual dataset 和 expected dataset 的範例 (請再自己準備一個 expected dataset file):
		//自己修改一下 database table 的資料
		test_JdbcTemplate.update("UPDATE test_table SET title = 'ABC' WHERE id = 2");
		
		//因為使用了 @ExpectedDataSet Annotation,所以這裡就不需要再多寫用 RiderDSL 等的程式碼了
	}
	
	//也可以不使用用 @ExpectedDataSet Annotation,直接使用 RiderDSL 來比較 database table 的資料是否和 expected dataset 一致
	@Test 
	void testExpectedDataset() throws DatabaseUnitException, SQLException {
		//做你想做的 Test		
		//例如以下是比較 Actual dataset 和 expected dataset 的範例 (請再自己準備一個 expected dataset file):
		//自己修改一下 database table 的資料
		test_JdbcTemplate.update("UPDATE test_table SET title = 'ABC' WHERE id = 2");
		
		//比較 database table 的資料是否和 expected dataset 一樣
		DataSource dataSource = test_JdbcTemplate.getDataSource();
		Connection conn = DataSourceUtils.getConnection(dataSource);
		RiderDSL.withConnection(conn)
			    .withDataSetConfig(new DataSetConfig("expectedDataset.xlsx"))
			    .withDBUnitConfig(new DBUnitConfig()
					              .addDBUnitProperty("escapePattern", "\"?\"")
					              .addDBUnitProperty("caseSensitiveTableNames", true)
					              .addDBUnitProperty("allowEmptyFields", true))
		        .expectDataSet(new ExpectedDataSetConfig());
		
		DataSourceUtils.releaseConnection(conn, dataSource);
	}
}

2024年8月28日 星期三

Spring - 使用 MockMVC 做 Controller 的 Unit Test - MockMvcBuilders.webAppContextSetup(webApplicationContext ) 與 MockMvcBuilders.standaloneSetup(controller) 的差別

在 SpringMVC Project中,如果想要對 Controller 做 Unit Test 的話,

通常會使用 Spring 提供的 MockMvc 來達成,
獲取了 MockMvc 後,其提供了模擬 HttpPost, HttpGet 等 HttpRequest 去觸發 Controller 中的 Method 以供測試,範例程式碼如下:  

MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.post("/xxx/xxx/xxx.do")
									  .session(new MockHttpSession())
									  .param("xxx", "yyy")
									  ).andReturn();
String responseBody = mvcResult.getResponse().getContentAsString();

Spring 提供了兩個 Method 供我們取得 MockMvc ,分別是: MockMvcBuilders.webAppContextSetup(webApplicationContext)

MockMvcBuilders.standaloneSetup(controller)。

MockMvcBuilders.webAppContextSetup(webApplicationContext) 需要的參數是 webApplicationContext,
webApplicationContext 可以用指定 Spring Config XML file 、指令 Config Class 、自己手動獲取 (例如自己使用 new XmlWebApplicationContext() 或 new AnnotationConfigWebApplicationContext() 方式做設定取得) 來取得,
此時 Spring 會幫我們建立整個 Spring Application 的上下文 (Context)。
如果使用 Junit 5 , Spring 提供了一個 SpringExtension  的 Junit 5 Extension 可以使用,
它可以幫我們建立 Spring Application 上下文,並讓我們可以在 Unit Test 中使用 @Autowired 取得裝配好的 Bean。
因為會建立整個 Spring Application ,所以會花比較久的時間,
並且如果想要用 Mock 去取代某些依賴 Bean 的話 (例如 Controller 裡面用 @Autowired 設定的特定 Service),需要自己用 ReflectionTestUtils 以反射(Reflection)去處理。
因為整個 Spring Application 的上下文都被建立起來,Spring Bean 都被裝配完成的關係,
此方法比較接近整合測試 (Integration Test) 而不像單元測試 (Unit Test)。

MockMvcBuilders.standaloneSetup(controller) 只需要特定待測的 Controller (可以多個) 做為參數,
此時 Spring 只會以做為參數的 Controller 來建立 mockMvc,
因為此方法不會設定 Spring Config,所以 Spring 也不會知道如何裝配 Controller 中的依賴,
裝配依賴要自己實做,
我們可以使用 Mockito 提供的 @Mock 和 @Injects 註解來實現 Mock 依賴裝配,
被 @Mock 標注的 Bean 能以 Mock Bean 的方式 Inject 進被 @Injects 標注的 Bean 中。
此外,因為不用建立整個 Spring Application 的關係,此方式執行的速度比較快。
也因為只關注待測的 Controller ,其他的都是 Mock Bean,所以比較接近單元測試 (Unit Test)。

下面展示一下程式範例,直接看程式應該會比較清楚了解,相關的說明也已寫在了註解中。

這裡範例借用了上一篇 No XML for Java EE Spring Application 的設定,
順便複習一下使用 No XML 的方式來設定 Java EE Spring Application,
所以此例不存在 web.xml 和 Spring Config File。

專案結構如下圖:


pom.xml :
<?xml version="1.0" encoding="UTF-8"?>

<project xmlns="http://maven.apache.org/POM/4.0.0"
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>

	<groupId>test</groupId>
	<artifactId>springunittest</artifactId>
	<version>0.1</version>
	<packaging>war</packaging>

	<name>springunittest Maven Webapp</name>
	<!-- FIXME change it to the project's website -->
	<url>http://www.example.com</url>

	<properties>
		<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
		<maven.compiler.source>11</maven.compiler.source>
		<maven.compiler.target>11</maven.compiler.target>
	</properties>

	<build>
		<finalName>springunittest</finalName>
		<pluginManagement><!-- lock down plugins versions to avoid using Maven
			defaults (may be moved to parent pom) -->
			<plugins>
				<plugin>
					<artifactId>maven-clean-plugin</artifactId>
					<version>3.1.0</version>
				</plugin>
				<!-- see
				http://maven.apache.org/ref/current/maven-core/default-bindings.html#Plugin_bindings_for_war_packaging -->
				<plugin>
					<artifactId>maven-resources-plugin</artifactId>
					<version>3.0.2</version>
				</plugin>
				<plugin>
					<artifactId>maven-compiler-plugin</artifactId>
					<version>3.8.0</version>
				</plugin>
				<plugin>
					<artifactId>maven-surefire-plugin</artifactId>
					<version>3.1.2</version>
				</plugin>
				<plugin>
					<artifactId>maven-failsafe-plugin</artifactId>
					<version>3.1.2</version>
				</plugin>
				<plugin>
					<artifactId>maven-war-plugin</artifactId>
					<version>3.2.2</version>
				</plugin>
				<plugin>
					<artifactId>maven-install-plugin</artifactId>
					<version>2.5.2</version>
				</plugin>
				<plugin>
					<artifactId>maven-deploy-plugin</artifactId>
					<version>2.8.2</version>
				</plugin>
			</plugins>
		</pluginManagement>
	</build>

	<dependencyManagement>
		<dependencies>
			<dependency>
				<groupId>org.junit</groupId>
				<artifactId>junit-bom</artifactId>
				<version>5.10.3</version>
				<type>pom</type>
				<scope>import</scope>
			</dependency>
		</dependencies>
	</dependencyManagement>

	<dependencies>
		<!-- https://mvnrepository.com/artifact/org.junit.jupiter/junit-jupiter -->
		<dependency>
			<groupId>org.junit.jupiter</groupId>
			<artifactId>junit-jupiter</artifactId>
			<scope>test</scope>
		</dependency>

		<!-- https://mvnrepository.com/artifact/org.mockito/mockito-junit-jupiter -->
		<dependency>
			<groupId>org.mockito</groupId>
			<artifactId>mockito-junit-jupiter</artifactId>
			<version>5.10.0</version>
			<scope>test</scope>
		</dependency>

		<!-- https://mvnrepository.com/artifact/javax.servlet/javax.servlet-api -->
		<dependency>
			<groupId>javax.servlet</groupId>
			<artifactId>javax.servlet-api</artifactId>
			<version>4.0.1</version>
		</dependency>

		<!-- https://mvnrepository.com/artifact/org.springframework/spring-webmvc -->
		<dependency>
			<groupId>org.springframework</groupId>
			<artifactId>spring-webmvc</artifactId>
			<version>5.3.18</version>
		</dependency>

		<!-- https://mvnrepository.com/artifact/org.springframework/spring-jdbc -->
		<dependency>
			<groupId>org.springframework</groupId>
			<artifactId>spring-jdbc</artifactId>
			<version>5.3.18</version>
		</dependency>
		
		<!-- https://mvnrepository.com/artifact/org.springframework/spring-test -->
		<dependency>
		    <groupId>org.springframework</groupId>
		    <artifactId>spring-test</artifactId>
		    <version>5.3.18</version>
		    <scope>test</scope>
		</dependency>
	</dependencies>
</project>

src/main/java/com/config/WebInitializer.java :

package com.config;

import javax.servlet.ServletContext;
import javax.servlet.ServletRegistration;

import org.springframework.web.WebApplicationInitializer;
import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
import org.springframework.web.context.support.XmlWebApplicationContext;
import org.springframework.web.servlet.DispatcherServlet;

//Servlet Container (例如 Tomcat) 會在 Web 應用啟動時尋找實作 WebApplicationInitializer 的 Class 並執行 onStartup()
public class WebInitializer implements WebApplicationInitializer {
	
	@Override
	public void onStartup(ServletContext servletContext) {
		
		//可以用 AnnotationConfigWebApplicationContext 設定 Class Config
		//或是用 XmlWebApplicationContext 設定 XML Config 
		//來建立 Spring Application 上下文 (Context)
		AnnotationConfigWebApplicationContext springWebApplicationContext = new AnnotationConfigWebApplicationContext();
		springWebApplicationContext.register(SpringApplicationConfig.class); //這裡可以指定 Spring Class Config
//		XmlWebApplicationContext springWebApplicationContext = new XmlWebApplicationContext();
//		springWebApplicationContext.setConfigLocation("classpath:application-config.xml"); //這裡可以指定 Spring XML Config
		
		DispatcherServlet dispatcherServlet = new DispatcherServlet(springWebApplicationContext);
		
		ServletRegistration.Dynamic springDispatcherServlet = servletContext.addServlet("springDispatcherServlet", dispatcherServlet);
		springDispatcherServlet.setLoadOnStartup(1);
		springDispatcherServlet.addMapping("/");
	}
}

src/main/java/com/config/SpringApplicationConfig.java :

package com.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.view.InternalResourceViewResolver;

@EnableWebMvc
@Configuration
@ComponentScan(basePackages = "com.*")
public class SpringApplicationConfig {
	
	@Bean
	public InternalResourceViewResolver setupViewResolver() {
		InternalResourceViewResolver viewResolver = new InternalResourceViewResolver();
		viewResolver.setPrefix("/WEB-INF/view/");
		viewResolver.setSuffix(".jsp");
		
		return viewResolver;
	}
}

src/main/java/com/controller/TestController.java :

package com.controller;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

import com.service.TestService;

@Controller
public class TestController {
	
	@Autowired
	TestService testService;
	
	@ResponseBody
	@RequestMapping("/test")
	public String test() {
		return "HIHI";
	}
	
	@RequestMapping("/hello")
	public String hello() {
		return "hello";
	}
	
	@ResponseBody
	@RequestMapping("/grettingWithNameByMemberId")
	public String grettingWithNameByMemberId(int memberId) {
		return "Hello, " + testService.getNameByMemberId(memberId);
	}
	
}

src/main/java/com/service/TestService.java :

package com.service;

import org.springframework.stereotype.Service;

@Service
public class TestService {

	public String getNameByMemberId(int memberId) {
		return "realName";
	}
}

src/test/java/test/ControllerTestOnlySpecificController.java :

package test;

import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.mockito.ArgumentMatchers.anyInt;

import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.junit.jupiter.api.TestInstance.Lifecycle;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import com.controller.TestController;
import com.service.TestService;

//預設 Junit 會為每個測試方法建立新的 Class Instance 來避免各測試方法間互相影響,
//所以 @BeforeAll 和 @AfterAll 只能標注在 static method 上。
//如果有需求需要把 @BeforeAll, @AfterAll 設定在 non-static method 上的話,
//可以設定 Lifecycle.PER_CLASS (預設是 Lifecycle.PER_METHOD),此時 JUnit 會改成只建立一次 Class Instance,
//並在同一個 Class Instance 中執行測試方法,
//這樣我們就可以把 @BeforeAll, @AfterAll 標注在 non-static method 上 
@TestInstance(Lifecycle.PER_CLASS) //這裡只是舉例紀錄一下用法
public class ControllerTestOnlySpecificController {

	AutoCloseable closeableMocks;
	
	MockMvc mockMvc;
	
	//被標注 @InjectMocks 的 Bean 中,
	//如果有用到 Spring 裝配的 Bean,
	//被 @Mock 標注的 Bean 會被 Inject 到被標注 @InjectMocks 的 Bean 中。
	//例如此例的 TestController 有用到 TestService,
	//因為 TestController 被標注 @InjectMocks 和
	//TestService 被標注了 @Mock,
	//所以 TestService 會被 Inject 到 TestController 中
	@InjectMocks
	TestController mockTestController;
	
	@Mock
	TestService mockTestService;
	
	@BeforeAll
	void beforeAll() {
	}
	
	@AfterAll
	void afterAll() throws Exception {
	}
	
	@BeforeEach
	void beforeEach() {
		//使用 MockitoAnnotations.openMocks() 來讓 @InjectMocks, @Mock 等註解產生作用
		closeableMocks = MockitoAnnotations.openMocks(this);
		//這裡沒有讓 Spring 建立 Spring Application 上下文 (Context),所以也不用指定 Spring Config,
		//這裡只有讓 Spring 根據特定 Controller(可以指定多個) 建立只為測試 Controller 的 mockMvc,
		//必需自行設定 Controller 中會用到的所有依賴行為,例如此例的 TestService,
		//因為沒有建立整個 Spring Application,所以執行速度比較快,
		//且因為 Controller 中的所有相關依賴都沒有被 Spring 裝配起來,只關注 Controller 功能本身,
		//所以比較偏向 Unit Test 而不是 Integration Test 
		mockMvc = MockMvcBuilders.standaloneSetup(mockTestController).build();
	}
	
	@AfterEach
	void afterEach() throws Exception {
		closeableMocks.close();
	}
	
	@Test
	void grettingWithNameByMemberIdTest() throws Exception {
		//可以用 MockHttpSession 模擬 Session
		MockHttpSession mockSession = new MockHttpSession();
		
		String mockName = "mockName";
		String expectedReturnString = "Hello, " + mockName;
		//可以設宣 mockTestService.querySomeDataById(int id) 要回傳什麼值
		//注意:如果參數是 int,不能用 any() 代替,因為 int 不是 Object,此時 any() 會判斷用 null 回傳,
		//而造成 null 無法被轉型成 int 造成 NullPointerException
		when(mockTestService.getNameByMemberId(anyInt())).thenReturn(mockName);
		//使用 MockMvc 樣擬 Http Request 觸發 Controller,此例模擬 Http Post
		MvcResult mvcResult = mockMvc.perform(post("/grettingWithNameByMemberId")
										      .session(mockSession)
										      .param("memberId", "12345")
										     ).andReturn();
		String responseBody = mvcResult.getResponse().getContentAsString();
		Assertions.assertEquals(expectedReturnString, responseBody, "Should get correct data.");
	}
}

src/test/java/test/ControllerTestRunningWholeSpringApplication.java :

package test;

import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.mockito.ArgumentMatchers.anyInt;

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.junit.jupiter.api.TestInstance.Lifecycle;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mockito;
import org.mockito.MockitoAnnotations;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

import com.config.SpringApplicationConfig;
import com.controller.TestController;
import com.service.TestService;

//使用以下設定後,Spring 會幫我們以設定的 Spring Config 建立完整的 Spring Application 上下文 (Context),
//我們就可以得到實際裝配好的 Spring Bean。
//嚴格說這比較像是整合測試(Integration Test)而不是單元測試(Unit test),
//單元測試應該要只測試 Controller 本身,其中依賴的其他 Bean (例如 TestService) 不應該由 Spring 幫忙裝配
@ExtendWith(SpringExtension.class)
@WebAppConfiguration
@ContextConfiguration(classes = SpringApplicationConfig.class)
//也可以指定 Spring XML 的位置
//@ContextConfiguration({"file:xxx/xxx/spring-config.xml"})
/**
 * @ExtendWith + @WebAppConfiguration + @ContextConfiguration = @SpringJUnitWebConfig
 * 上述程式碼等同於:
 * @SpringJUnitWebConfig(SpringApplicationConfig.class)
 */
@TestInstance(Lifecycle.PER_CLASS)
public class ControllerTestRunningWholeSpringApplication {

AutoCloseable closeableMocks;
	
	MockMvc mockMvc;
	
	@Autowired
	TestController testController;
	
	@Autowired
	TestService testService;

	@BeforeAll
	void beforeAll(WebApplicationContext webApplicationContext) {
		//webApplicationContext 也可以用 @Autowired 得到
		mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build();
	}
	
	@BeforeEach
	void beforeEach() {
		closeableMocks = MockitoAnnotations.openMocks(this);
	}
	
	@AfterEach
	void afterEach() throws Exception {
		closeableMocks.close();
	}
	
	@Test
	void grettingWithNameByMemberIdTest() throws Exception {
		MockHttpSession mockSession = new MockHttpSession();
		
		String mockName = "mockName";
		String expectedReturnString = "Hello, " + mockName;
		//如果要對特定 Controller 的依賴用 Mock 替換來測試,例如 TestService,
		//可以用 ReflectionTestUtils 使用反射(Reflection) 來達成,
		//測完後別忘了把原來的依賴換回去,不要影響其他他測試方法。
		TestService mockTestService = Mockito.mock(TestService.class);
		ReflectionTestUtils.setField(testController, "testService", mockTestService);
		when(mockTestService.getNameByMemberId(anyInt())).thenReturn(mockName);
		
		//使用 MockMvc 樣擬 Http Request 觸發 Controller,此例模擬 Http Post
		MvcResult mvcResult = mockMvc.perform(post("/grettingWithNameByMemberId")
										      .session(mockSession)
										      .param("memberId", "12345")
										     ).andReturn();
		String responseBody = mvcResult.getResponse().getContentAsString();
		Assertions.assertEquals(expectedReturnString, responseBody, "Should get correct data.");

		//把 TestController 原來的依賴換回去,不要影響其他他測試方法。
		ReflectionTestUtils.setField(testController, "testService", testService);
	}
}

源碼下載分享:
spring-unit-test.7z

參考資料:

  1. 第 5 章 Spring 应用的测试 - 《Java 研发自测》
  2. Setup Choices :: Spring Framework
  3. 测试实例生命周期 | Junit 5官方文档中文版
  4. 菜鳥工程師 肉豬: Mockito @Mock與@InjectMocks的差別
  5. The SpringJUnitConfig and SpringJUnitWebConfig Annotations in Spring 5 – Spring5中的SpringJUnitConfig和SpringJUnitWebConfig注解

2024年8月27日 星期二

No XML for Java EE Spring Application

這裡來紀錄一下如何不使用 XML 配置檔改用程式的方式來設定 Java EE + Spring 的 Web Application。

首先來看一下專案的檔案結構如下圖:




因為不用 XML 了的關係,所以我們這次範例專案中不會特別放 Java EE 的 web.xml 和 Spring 的 Config XML。

pom.xml (設定了一些要用到的 Dependency) :
<?xml version="1.0" encoding="UTF-8"?>

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>my.test</groupId>
  <artifactId>javaee-spring-no-xml-test</artifactId>
  <version>0.1</version>
  <packaging>war</packaging>

  <name>javaee-spring-no-xml-test Maven Webapp</name>
  <!-- FIXME change it to the project's website -->
  <url>http://www.example.com</url>

  <properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <maven.compiler.source>11</maven.compiler.source>
    <maven.compiler.target>11</maven.compiler.target>
  </properties>

  <dependencies>
    <dependency>
      <groupId>junit</groupId>
      <artifactId>junit</artifactId>
      <version>4.11</version>
      <scope>test</scope>
    </dependency>
    
    <!-- https://mvnrepository.com/artifact/javax.servlet/javax.servlet-api -->
	<dependency>
	    <groupId>javax.servlet</groupId>
	    <artifactId>javax.servlet-api</artifactId>
	    <version>4.0.1</version>
	</dependency>
	
    <!-- https://mvnrepository.com/artifact/org.springframework/spring-webmvc -->  
    <dependency>
    	<groupId>org.springframework</groupId>
    	<artifactId>spring-webmvc</artifactId>
    	<version>5.3.18</version>
	</dependency>
	
	<!-- https://mvnrepository.com/artifact/org.springframework/spring-jdbc -->
	<dependency>
	    <groupId>org.springframework</groupId>
	    <artifactId>spring-jdbc</artifactId>
	    <version>5.3.18</version>
	</dependency>

  </dependencies>

  <build>
    <finalName>javaee-spring-no-xml-test</finalName>
    <pluginManagement><!-- lock down plugins versions to avoid using Maven defaults (may be moved to parent pom) -->
      <plugins>
        <plugin>
          <artifactId>maven-clean-plugin</artifactId>
          <version>3.1.0</version>
        </plugin>
        <!-- see http://maven.apache.org/ref/current/maven-core/default-bindings.html#Plugin_bindings_for_war_packaging -->
        <plugin>
          <artifactId>maven-resources-plugin</artifactId>
          <version>3.0.2</version>
        </plugin>
        <plugin>
          <artifactId>maven-compiler-plugin</artifactId>
          <version>3.8.0</version>
        </plugin>
        <plugin>
          <artifactId>maven-surefire-plugin</artifactId>
          <version>2.22.1</version>
        </plugin>
        <plugin>
          <artifactId>maven-war-plugin</artifactId>
          <version>3.2.2</version>
        </plugin>
        <plugin>
          <artifactId>maven-install-plugin</artifactId>
          <version>2.5.2</version>
        </plugin>
        <plugin>
          <artifactId>maven-deploy-plugin</artifactId>
          <version>2.8.2</version>
        </plugin>
      </plugins>
    </pluginManagement>
  </build>
</project>

src/main/com/config/WebInitializer.java (Java EE Web Application 的啟動設定,取代 web.xml) :
package com.config;

import javax.servlet.ServletContext;
import javax.servlet.ServletRegistration;

import org.springframework.web.WebApplicationInitializer;
import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
import org.springframework.web.context.support.XmlWebApplicationContext;
import org.springframework.web.servlet.DispatcherServlet;

//Servlet Container (例如 Tomcat) 會在 Web 應用啟動時尋找實作 WebApplicationInitializer 的 Class 並執行 onStartup()
public class WebInitializer implements WebApplicationInitializer {
	
	@Override
	public void onStartup(ServletContext servletContext) {
		
		//可以用 AnnotationConfigWebApplicationContext 設定 Class Config
		//或是用 XmlWebApplicationContext 設定 XML Config 
		//來建立 Spring Application 上下文 (Context)
		AnnotationConfigWebApplicationContext springWebApplicationContext = new AnnotationConfigWebApplicationContext();
		springWebApplicationContext.register(SpringApplicationConfig.class); //這裡可以指定 Spring Class Config
//		XmlWebApplicationContext springWebApplicationContext = new XmlWebApplicationContext();
//		springWebApplicationContext.setConfigLocation("classpath:application-config.xml"); //這裡可以指定 Spring XML Config
		
		DispatcherServlet dispatcherServlet = new DispatcherServlet(springWebApplicationContext);
		
		ServletRegistration.Dynamic springDispatcherServlet = servletContext.addServlet("springDispatcherServlet", dispatcherServlet);
		springDispatcherServlet.setLoadOnStartup(1);
		springDispatcherServlet.addMapping("/");
		/** 上述 code 相當於 web.xml 中的以下 DispatcherServlet 設定
		 * <servlet>
				<servlet-name>springDispatcherServlet</servlet-name>
				<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
				<init-param>
					<param-name>contextConfigLocation</param-name>
					<param-value>/xx/xx/application-config.xml</param-value>
					</init-param>
				<load-on-startup>1</load-on-startup>
			</servlet>
			<servlet-mapping>
				<servlet-name>springDispatcherServlet</servlet-name> 
				<url-pattern>/</url-pattern>
			</servlet-mapping>
		 */
	}
}
src/main/com/config/SpringApplicatoinConfig.java (Spring Config 設定,取代 Spring XML Config File) :
package com.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.view.InternalResourceViewResolver;

@EnableWebMvc
@Configuration
@ComponentScan(basePackages = "com.*")
public class SpringApplicationConfig {
	
	@Bean
	public InternalResourceViewResolver setupViewResolver() {
		InternalResourceViewResolver viewResolver = new InternalResourceViewResolver();
		viewResolver.setPrefix("/WEB-INF/view/");
		viewResolver.setSuffix(".jsp");
		
		return viewResolver;
		
		/** 上述 code 相當於 Spring Config XML 的如下 InternalResourceViewResolver 設定
		 * <bean id="viewResolver" class="org.springframework.web.servlet.view.InternalResourceViewResolver"> 
		        <property name="prefix">
		            <value>/WEB-INF/view/</value>
		        </property>
		        <property name="suffix">
		            <value>.jsp</value>
		        </property>
		    </bean>
		 */
	}
}


src/main/com/controller/TestController.java (測試用的 Controller) :
package com.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

@Controller
public class TestController {
	
	@ResponseBody
	@RequestMapping("/test")
	public String test() {
		return "HIHI";
	}
	
	@RequestMapping("/hello")
	public String hello() {
		return "hello";
	}
	
}

src/main/webapp/WEB-INF/view/hello.jsp (測試用的 JSP View) : 
<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Hello</title>
</head>
<body>
Say hello!
</body>
</html>

可以自己跑一個 Tomcat 起來試,應該可以成功存取以下 url

  1. http://localhost:8080/index.jsp (直接存取 webapp 下的 JSP Resource)
  2. http://localhost:8080/hello (會觸發 TestController.hello(),Request 會導向 webapp/WEB-INF/view/hello.jsp)
  3. http://localhost:8080/test (會觸發 TestController.test())

源碼下載分享:
javaee-spring-no-xml-test.7z


參考資料:

  1.  NO XML - Spring MVC Java-based configuration in 9 steps || Spring Annotation || Part 3

2024年7月17日 星期三

JavaEE - 使用 ContentCachingRequestWrapper 和 ContentCachingResponseWrapper 在 filter 中取得 request 和 response 的內容

 JavaEE 中,如果 HttpServletResponse 的內容一經使用 OutputStream 等方式被讀取的話,
因為資料流只能被讀取一次,
會讓之後在輸入內容到前端時發生不預期的錯誤,
所以不能只接讀取,
如果要獲得 HttpServletRequest, HttpServletResponse 的內容的話,例如拿來做 log 紀錄等用途時會需要,
這時可以使用 Spring 的
ContentCachingRequestWrapper

ContentCachingResponseWrapper
這兩個工具來幫忙,
以下用一個 Filter 實作來做範例:

package xxx.api.filter;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.util.ContentCachingRequestWrapper;
import org.springframework.web.util.ContentCachingResponseWrapper;

@WebFilter(filterName = "apiFilter", urlPatterns = "/xxx/xxx.do")
public class ApiFilter extends OncePerRequestFilter {

	private Logger logger = LoggerFactory.getLogger("xxxLogger");
	
	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
			throws ServletException, IOException {
		
		Instant tic = Instant.now();
		
		ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper(request);
        ContentCachingResponseWrapper responseWrapper = new ContentCachingResponseWrapper(response);
		
		filterChain.doFilter(requestWrapper, responseWrapper); //不用原來的 Request 和 Response,改把用 Wrapper 封裝的 Request 和 Response 放到 filterChain 中繼續傳遞
		
		long durationMillis = Duration.between(tic, Instant.now()).toMillis();
		
		String requestBody = new String(requestWrapper.getContentAsByteArray(), StandardCharsets.UTF_8);
		
		byte[] responseByteArr = responseWrapper.getContentAsByteArray();
		String responseStr = new String(responseByteArr, StandardCharsets.UTF_8);
		
		String loggerMsg = requestWrapper.getRemoteAddr() +
						   "|" + requestWrapper.getMethod() +
						   "|" + requestWrapper.getRequestURI() +
						   "|" + StringUtils.defaultIfBlank(requestWrapper.getQueryString(), "")  +
						   "|" + responseWrapper.getStatus() +
						   "|" + durationMillis +
						   "|" + requestBody +
						   "|" + responseStr +
					       "|" + responseByteArr.length;
		logger.info(loggerMsg);
		
		responseWrapper.copyBodyToResponse(); //要記得執行這段,不然 HttpServletResponse 會沒有資料輸出
	}

}

說明:
ContentCachingRequestWrapper 和 ContentCachingResponseWrapper 會把
HttpServletRequest 和 HttpServletResponse 包起來,內部用了 ByteArrayInputStream 和
ByteArrayOutputStream 等取得 HttpServletRequest 和 HttpServletResponse 的內容,
可以重覆取得而不用怕影響到 HttpServletRequest 和 HttpservletResponse 的輸入、輸出流。

這邊要注意的是,我們在 Filter 的最後要記得呼叫

responseWrapper.copyBodyToResponse()

不然 HttpServletResponse 會沒有資料輸出。

參考資料:

  1. 【Spring Boot】第16課-使用 Filter 擷取請求與回應

2024年2月15日 星期四

Java EE Spring JDBC tmeplate 執行 PostgreSQL 的 Function

這篇要來紀錄如何使用 Java EE Spring JDBC template 來執行
PostgreSQL 的 Function。

首先我們先建立一個測試用的 Table:

兩個 PostgreSQL Function,如以下 SQL 語法:
CREATE TABLE IF NOT EXISTS public.testTable
(
    id integer NOT NULL,
	title text NOT NULL,
	PRIMARY KEY(id) 
)
並且塞入一些資料:
INSERT INTO public.testTable(id, title) VALUES(1, '111');
INSERT INTO public.testTable(id, title) VALUES(2, '222');
INSERT INTO public.testTable(id, title) VALUES(3, '333');
然後建立三個 Function,以下三個 Function 的作用基本一樣,都是接受傳入的 param_id 變數,然後進行
SELECT * FROM testTable WHERE id = param_id
的查詢,不同的是傳回的 type 不同,呼叫 Functoin 的方式也有所不同,
可以注意到的是,有兩個 Function 的名稱一樣 (都取名為 queryUsingReturnedRefcursor),不過 Function 簽名不同 (傳入參數不同),這是為了演示 PostgreSQL 能夠支持同名 Function 故意取名的,類似 Java 的 Function Overload:
  1. queryUsingReturnedSetof(param_id integer):
    CREATE OR REPLACE FUNCTION queryUsingReturnedSetof(param_id integer) RETURNS SETOF testTable AS $$
      SELECT * FROM testTable WHERE id = param_id;
    $$ LANGUAGE sql;
    
    說明:
    此 Function 傳回值 type 為 SETOF,相當於傳回 Table 的資料行集合,呼叫 Function 的方式為如下 SQL 語法:
    SELECT * FROM queryUsingReturnedSetof(2);
  2. queryUsingReturnedRefcursor(param_id integer):
    CREATE OR REPLACE FUNCTION queryUsingReturnedRefcursor(param_id integer) RETURNS refcursor AS $$
    DECLARE ref refcursor;
    BEGIN
      OPEN ref FOR SELECT * FROM testTable WHERE id = param_id;
      RETURN ref; 
    END;
    $$ LANGUAGE plpgsql;
    
    說明:
    此 Function 傳回值 type 為 refcursor,為一個 cursor ,不是 Table 的資料行集合,直接呼叫的話只會得到一個 cursor ,必須要配合 FETCH 指令才能遍歷每一行的資料,例如以下語法:
    SELECT queryUsingReturnedRefcursor(2); FETCH ALL IN "<unnamed portal 6>";
    其中,<unnamed portal 6> 是被回傳的 cursor 名稱,不過在實際上每次執行 Function 後回傳的 cursor name 是會變化的,每次都不一樣,
    而執行完 Function 後, cursor 就消失了,導致執行 FETCH 時會有
    ERROR: cursor "<unnamed portal xxx>" does not exist 
    的錯誤,所以上面那樣的語法沒辦法正確地得到查詢結果資料行,
    要解決上面的問題,可以看下一個 Functoin 的說明,
    其想法是把想要的 cursor name 傳進 Function 以指定回傳的 cursor name。

    不過如果是用 Java 去呼叫的話,是可以成功執行並得到資料行的,
    之後可以看到我們可以用 Java 去指定 cursor name。
  3. queryUsingReturnedRefcursor(ref refcursor, param_id integer):
    CREATE OR REPLACE FUNCTION queryUsingReturnedRefcursor(ref refcursor, param_id integer) RETURNS refcursor AS $$
    BEGIN
      OPEN ref FOR SELECT * FROM testTable WHERE id = param_id;
      RETURN ref; 
    END;
    $$ LANGUAGE plpgsql;
    
    說明:
    此 Function 傳回值 type 為 refcursor,跟上一個 Fucntion 一樣,只差在多傳進一個指定的 cursor name,這樣我們就可以用已知的 cursor name 來取得資料行,呼叫方式如下:
    SELECT queryUsingReturnedRefcursor('xxx_cur', 2); FETCH ALL IN "xxx_cur";
    其中 xxx_cur 可以隨意更換成想要的 cursor name。
接下來是 Java EE Spring 的部份了。
下圖為測試專案的檔案結構,紅框處的是主要專案檔案:






因為重要的地方主要是 DAO 使用 JdbcTemplate 的部份,
為了簡單起見,所以這裡只展示 DAO 的部份,也就是 com.dao.MyDAO.java,
其他 Java EE Spring、Data Source 、使用到的 lib (使用 Maven POM 檔設定) 等的設定可以參考文章最後的源碼下載分享

MyDAO.java :
package com.dao;

import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Types;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.SqlParameter;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.simple.SimpleJdbcCall;
import org.springframework.stereotype.Repository;

@Repository
public class MyDAO {

	@Autowired
	@Qualifier("postgresql_JdbcTemplate")
	private JdbcTemplate postgresql_JdbcTemplate;
	
	public List<Map> queryUsingReturnedSetof(int id) {
		List<Map> dataList = postgresql_JdbcTemplate.query("SELECT * FROM queryUsingReturnedSetof(?);", new RowMapper<Map>() {		
	        @Override
	        public Map mapRow(ResultSet rs, int rowNumber) throws SQLException {
	        	Map<String, Object> map = new HashMap<>();
	        	
	        	map.put("id", rs.getInt("id"));
	        	map.put("title", rs.getString("title"));
	        	
	            return map;
	        }
	    }, id);
		
		//for test
		for (Map data : dataList) {
			System.out.println(data.get("id") + ", " + data.get("title"));
		}
		
		return dataList;
	}
	
	public List<Map> queryUsingReturnedRefcursor(int id) {
		String customCursorName = "xxx_cur";
		
		SimpleJdbcCall simpleJdbcCall = new SimpleJdbcCall(postgresql_JdbcTemplate)
				                        .withProcedureName("queryUsingReturnedRefcursor")
				                        .declareParameters(new SqlParameter("id", Types.INTEGER))
				                        .withoutProcedureColumnMetaDataAccess()
				                        .returningResultSet(customCursorName, new RowMapper<Map>() {
				                	        @Override
				                	        public Map mapRow(ResultSet rs, int rowNumber) throws SQLException {
				                	        	Map<String, Object> map = new HashMap<>();
				                	        	
				                	        	map.put("id", rs.getInt("id"));
				                	        	map.put("title", rs.getInt("title"));
				                	        	
				                	            return map;
				                	        }
				                	    });
		
		Map returnedMapContainsCursorName = simpleJdbcCall.execute(new MapSqlParameterSource().addValue("id", id));
		List<Map> dataList = (List) returnedMapContainsCursorName.get(customCursorName);
		
		//for test
		for (Map data : dataList) {
			System.out.println(data.get("id") + ", " + data.get("title"));
		}
		
		return dataList;
	}
}

說明:
queryUsingReturnedSetof(int id) 對應到 PostgreSQL 中的 queryUsingReturnedSetof(param_id integer) 這個 Function。

queryUsingReturnedRefcursor(int id) 對應到 PostgreSQL 中的 queryUsingReturnedRefcursor(param_id integer) 這個 Function,可以看到它可以讓我們指定一個回傳的 refCursor name,
simpleJdbcCall.execute() 並不直接回傳 data list 資料,而是一個 Map,
就像上面我們直接用 Sql 語法查詢那樣回傳的 refCursor 的資料,
我們需要再用指定的 refCursor name 當作 Key 從 Map 中才能獲取真正的 data list 資料。

源碼下載分享:

2024年1月1日 星期一

SpringMVC 動態設定 Properties 給 $Value 用的方法 - 使用 PropertySourcesPlaceholderConfigurer

這篇要來記錄在 Spring MVC 中,
要如何動態地設定 Properties 給 $Value 用的方法。

我們都知道在 spring MVC 中,如果設定了 PropertySourcesPlaceholderConfigurer ,
我們可以把 property file 中的 Key-Value 值讀進來,
之後在 @Component, @Controller, @Service, @Bean 等 Spring 為我們裝配的物件中,
就可以使用 @Value 來取得 property value,例如:

PropertySourcePlaceholderConfigurer 的設定,有兩種方法,使用 xml 或 java config:

  1. 使用 xml:
  2. mvc-config.xml:

    <bean id="propertyConfigurer" class="org.springframework.context.support.PropertySourcesPlaceholderConfigurer">
    	<property name="locations">
    		<list>
    			<value>classpath:xxx1.properties</value>
    			<value>classpath:xxx2.properties</value>
    		</list>
    	</property>
    	<property name="ignoreUnresolvablePlaceholders" value="true" />
    </bean>
    
  3. 使用 java config:
  4. import java.io.IOException;
    import java.util.Properties;
    
    import org.springframework.beans.BeansException;
    import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.context.annotation.PropertySource;
    import org.springframework.context.support.PropertySourcesPlaceholderConfigurer;
    import org.springframework.core.env.Environment;
    
    @Configuration
    @PropertySource("classpath:xxx1.properties")
    @PropertySource("classpath:xxx2.properties")
    public class MyPropertyConfiguration {
    	
    	@Bean
    	public static PropertySourcesPlaceholderConfigurer setPropertySourcesPlaceholderConfigurer() {
    		PropertySourcesPlaceholderConfigurer propertySourcesPlaceholderConfigurer = new PropertySourcesPlaceholderConfigurer();
    		propertySourcesPlaceholderConfigurer.setIgnoreUnresolvablePlaceholders(true);
    		return propertySourcesPlaceholderConfigurer;
    	}
    }
    
    

Java 中就可以使用 @Value:

@Component
public class Test
	...
	@Value("${xxxPropertyKey}")
	String xxxPropertyKey;
	...
}

但是這樣子因為 property file 的內容本身是寫死的,
如果今天我們想要注入的 @Value 值是專案啟動時動態決定的,
例如從 Database 中讀取而來、從環境變數 (Environment) 中取得、從網路上讀取 (例如 AWS 的 Parameter Store 服務) 等等,
就不能這樣設定了。

我們必須自己實作 PropertySourcesPlaceholderConfigurer 並覆寫掉 (Override) 它的
postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory)
方法,在裡面實作增添修改 property key-value 值的程式邏輯,
實作的範例如下:

Java:

import java.io.IOException;
import java.util.Properties;

import org.springframework.beans.BeansException;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.PropertySource;
import org.springframework.context.support.PropertySourcesPlaceholderConfigurer;
import org.springframework.core.env.Environment;

@Configuration
@PropertySource("classpath:xxx1.properties")
@PropertySource("classpath:xxx2.properties")
public class MyParameterStorePropertyConfiguration {
	
	//如果原本這邊就有做讀 xxx1.properties 和 xxx2.properties 的 PropertySourcesPlaceholderConfigurer 設定,
	//不能因為多做了 setMyParameterStorePropertySourcesPlaceholderConfigurer_2() 就把 setMyParameterStorePropertySourcesPlaceholderConfigurer_1() 拿掉,
	//不然 xxx1.properties 和 xxx2.properties 的 property 都會被
	//setMyParameterStorePropertySourcesPlaceholderConfigurer_2() 移掉,因為
	//setMyParameterStorePropertySourcesPlaceholderConfigurer_2() 中已經用新的 Properties 取代了原本從 xxx1.properties 和 xxx2.properties 讀到的 property
	@Bean
	public static PropertySourcesPlaceholderConfigurer setMyParameterStorePropertySourcesPlaceholderConfigurer_1() {
		PropertySourcesPlaceholderConfigurer propertySourcesPlaceholderConfigurer = new PropertySourcesPlaceholderConfigurer();
		propertySourcesPlaceholderConfigurer.setIgnoreUnresolvablePlaceholders(true);
        //如果不想用 @PropertySource,也可以直接用程式設定,像下面這樣
        //propertySourcesPlaceholderConfigurer.setLocations(new ClassPathResource("xx1.properties"), new ClassPathResource("xx2.properties"));
		return propertySourcesPlaceholderConfigurer;
	}
	
	@Bean
	public static PropertySourcesPlaceholderConfigurer setMyParameterStorePropertySourcesPlaceholderConfigurer_2(Environment environment) {
    	//可視需要看要不要取得環境變數 (Environment)
		PropertySourcesPlaceholderConfigurer propertySourcesPlaceholderConfigurer = new PropertySourcesPlaceholderConfigurer() {
			
			@Override
			public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
				Properties properties = new Properties();				

				//動態地設定 property,key-value 值可能從其他地方來,而非寫死的 property file,
				//例如從 Database 中讀取而來、從環境變數 (Environment) 中取得、從網路上讀取 (例如 AWS 的 Parameter Store 服務) 等等
				//do your logic code here to get xxxKey1, xxxValue1, xxxKey2, xxxValue2, ......
				properties.put(xxxKey1, xxxValue1);
				properties.put(xxxKey2, xxxValue2);
				//......
				
				setProperties(properties);
				super.postProcessBeanFactory(beanFactory);
			}
		};
		propertySourcesPlaceholderConfigurer.setIgnoreUnresolvablePlaceholders(true);
		
		
		return propertySourcesPlaceholderConfigurer;
	}
}

參考資料:

  1. 注入設定檔
  2. Spring PropertyPlaceholderConfigurer load from DB
  3. Spring @Configuration作用

2023年6月14日 星期三

Java GraphQL 實作練習 - 不使用 SpringBoot,只使用一般 Spring MVC

這篇文要來紀錄一下 Java 的 GraphQL 純 Spring MVC 實作練習,
因為網路上的許多範例都是使用 SpringBoot 來實作,
所以在這裡紀錄一下使用純 Spring MVC 、不使用 SpringBoot 的方法。

在這裡想要實作的情境是:
我們有 Author、和 Book 兩種資料,
Author 和 Book 都有 id、name 屬性,
Author 還有 bookIdList 屬性,是一個 Array,紀錄著此 Author 撰寫的 Book 的 id。


我們要實作兩個 query 和一個 mutation,分別是:
Author  getAuthorById(id) :
用 Author id 查 Author。
回傳的 bookList 可以給一個 prefix 參數,它可以為回傳的 book name 前面加上 prefix
(這只是用來練習如何在 Java GraphQL server 端取得 Query 的參數)。

Book  getBookById(id) :
用 Book id 查 Book,

Author updateAuthorName(id, name):
修改特定 id 的 Author 的 name。

GraphQL 的 Schema 設計如下,其中 Date 是我們自訂的 type,之後會建立 GraphQLScalarType 類別來解析它:

scalar Date

type Book {
	id: ID!
	name: String
    publishDate: Date	
}

type Author {
	id: ID!
	name: String
	bookList(prefix: String): [Book]
}

type Query {
  getAuthorById(id: ID!): Author
  getBookById(id: ID!): Book
}

type Mutation {
    updateAuthorName(id: ID!, name: String!): Author
}

Query Request 的例子如下:

query {
  getAuthorById(id: 3) {
    id
    name
    bookList(prefix: "test") {
        id
        name
        publishDate
    }
  }
  
  getBookById(id: 33) {
    id
    name
    publishDate
  }
}

Mutation Request 的例子如下:

mutation {
    updateAuthorName(id: 3, name: "Author 333") {
        id
        name
    }
}

以下開始實作,這裡使用 Opoen JDK 11,
先建立一個 archetype 為 maven-archetype-webapp 的 Java Dynamic Web Application Maven 專案,可以參考這裡
首先先來看一下專案的結構設計,如下圖所示:

接著開始說明,

因為我們要使用 Spring MVC 和 GraphQL 的 library,所以在 pom.xml 加上以下 dependency:

pom.xml (部份內容):

<!-- https://mvnrepository.com/artifact/org.springframework/spring-webmvc -->
	<dependency>
	    <groupId>org.springframework</groupId>
	    <artifactId>spring-webmvc</artifactId>
	    <version>5.1.3.RELEASE</version>
	</dependency>

	<!-- https://mvnrepository.com/artifact/org.springframework/spring-context-support -->
	<dependency>
	    <groupId>org.springframework</groupId>
	    <artifactId>spring-context-support</artifactId>
	    <version>5.1.3.RELEASE</version>
	</dependency>
	
	<!-- https://mvnrepository.com/artifact/javax.servlet/javax.servlet-api -->
	<dependency>
	    <groupId>javax.servlet</groupId>
	    <artifactId>javax.servlet-api</artifactId>
	    <version>3.0.1</version>
	    <scope>provided</scope>
	</dependency>
	
	<!-- https://mvnrepository.com/artifact/org.aspectj/aspectjweaver -->
	<dependency>
	    <groupId>org.aspectj</groupId>
	    <artifactId>aspectjweaver</artifactId>
	    <version>1.9.6</version>
	</dependency>
    
    <!-- https://mvnrepository.com/artifact/com.graphql-java/graphql-java -->
	<dependency>
	    <groupId>com.graphql-java</groupId>
	    <artifactId>graphql-java</artifactId>
	    <version>20.2</version>
	</dependency>
	
	<!-- https://mvnrepository.com/artifact/com.graphql-java-kickstart/graphql-java-tools -->
	<dependency>
	    <groupId>com.graphql-java-kickstart</groupId>
	    <artifactId>graphql-java-tools</artifactId>
	    <version>13.0.3</version>
	</dependency>

接著在 web.xml 裡設定把流量導向 Spring MVC:

/src/main/webapp/WEB-INF/web.xml:

<!DOCTYPE web-app PUBLIC
 "-//Sun Microsystems, Inc.//DTD Web Application 2.3//EN"
 "http://java.sun.com/dtd/web-app_2_3.dtd" >

<web-app>
  <display-name>Archetype Created Web Application</display-name>
  
  <servlet>
    <servlet-name>dispatcher</servlet-name>
    <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
    <init-param>
			<param-name>contextConfigLocation</param-name>
			<param-value>/WEB-INF/mvc-config.xml</param-value>
		</init-param>
    <load-on-startup>1</load-on-startup>
  </servlet>
  
  <servlet-mapping>
    <servlet-name>dispatcher</servlet-name> 
    <url-pattern>/*</url-pattern>
  </servlet-mapping>
</web-app>

設定 Spring MVC 的設定檔:

src/main/webapp/WEB-INF/mvc-config.xml:

<beans xmlns="http://www.springframework.org/schema/beans"  xmlns:security="http://www.springframework.org/schema/security" xmlns:context="http://www.springframework.org/schema/context" xmlns:mvc="http://www.springframework.org/schema/mvc"
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:tx="http://www.springframework.org/schema/tx" xmlns:task="http://www.springframework.org/schema/task"
	xmlns:aop="http://www.springframework.org/schema/aop" xmlns:cache="http://www.springframework.org/schema/cache" xmlns:util="http://www.springframework.org/schema/util"
	xmlns:oxm="http://www.springframework.org/schema/oxm"
	xsi:schemaLocation="
        http://www.springframework.org/schema/beans     
        http://www.springframework.org/schema/beans/spring-beans-3.0.xsd
        http://www.springframework.org/schema/context 
        http://www.springframework.org/schema/context/spring-context-3.0.xsd
        http://www.springframework.org/schema/tx
        http://www.springframework.org/schema/tx/spring-tx-3.0.xsd
        http://www.springframework.org/schema/mvc
        http://www.springframework.org/schema/mvc/spring-mvc-3.0.xsd
        http://www.springframework.org/schema/task
        http://www.springframework.org/schema/task/spring-task-3.0.xsd
        http://www.springframework.org/schema/aop 
        http://www.springframework.org/schema/aop/spring-aop-4.0.xsd
        http://www.springframework.org/schema/cache 
        http://www.springframework.org/schema/cache/spring-cache.xsd
        http://www.springframework.org/schema/util
        http://www.springframework.org/schema/util/spring-util-3.0.xsd
        http://www.springframework.org/schema/oxm 
        http://www.springframework.org/schema/oxm/spring-oxm-3.0.xsd
        http://www.springframework.org/schema/security
        http://www.springframework.org/schema/security/spring-security-4.2.xsd"       
        >
                           	
	<context:component-scan base-package="com.*"/>
	<aop:aspectj-autoproxy/>
	<mvc:annotation-driven/>
</beans>

說明:把 GraphQL 的 Schema 設定在 schema.graphqls 中。

/src/main/resources/schema.graphqls:

scalar Date

type Book {
	id: ID!
	name: String
    publishDate: Date
}

type Author {
	id: ID!
	name: String
	bookList(prefix: String): [Book]
}

type Query {
  getAuthorById(id: ID!): Author
}

type Mutation {
    updateAuthorName(id: ID!, name: String!): Author
}

再來建好需要的 Bean 。

/src/java/com/bean/Author.java:

package com.bean;

import java.util.List;

public class Author {
	private int id;
	private String name;
	private List<Integer> bookIdList;

	public int getId() {
		return id;
	}

	public void setId(int id) {
		this.id = id;
	}
	
	public String getName() {
		return name;
	}

	public void setName(String name) {
		this.name = name;
	}

	public List<Integer> getBookIdList() {
		return bookIdList;
	}

	public void setBookIdList(List<Integer> bookIdList) {
		this.bookIdList = bookIdList;
	}
	
	
}

/src/java/com/bean/Book.java:

package com.bean;

import java.util.Calendar;

public class Book {
	private int id;
	private String name;
    Calendar publishDate;

	public int getId() {
		return id;
	}

	public void setId(int id) {
		this.id = id;
	}

	public String getName() {
		return name;
	}

	public void setName(String name) {
		this.name = name;
	}
    
    public Calendar getPublishDate() {
		return publishDate;
	}

	public void setPublishDate(Calendar publishDate) {
		this.publishDate = publishDate;
	}
}

建好查詢資料和修改資料的 DAO (這邊沒有使用真正的資料庫,只是建假的資料來模擬)。

/src/main/java/com/dao/AuthorDAO.java:

package com.dao;

import java.util.ArrayList;
import java.util.List;

import org.springframework.stereotype.Repository;

import com.bean.Author;

@Repository
public class AuthorDAO {
	private static List<Author> authorList;
	
	public AuthorDAO() {
		authorList  = new ArrayList<Author>();
		
		//Set mock data to simulate data in database.
		Author author = new Author();
		author.setId(1);
		author.setName("Author 1");
		author.setBookIdList(List.of(1, 11));
		authorList.add(author);
		
		author = new Author();
		author.setId(2);
		author.setName("Author 2");
		author.setBookIdList(List.of(2, 22));
		authorList.add(author);
		
		author = new Author();
		author.setId(3);
		author.setName("Author 3");
		author.setBookIdList(List.of(3, 33));
		authorList.add(author);
	}
	
	public Author getAuthorById(int id) {
		return authorList.stream().filter((Author author) -> {
			return author.getId() == id;
		}).findAny().orElse(null);
	}
	
	public Author updateAuthorName(int id, String name) {		
		Author author = getAuthorById(id);
		if (author != null) {
			author.setName(name);
		}
		return author;
	}
}

/src/main/java/com/dao/BookDAO.java:

package com.dao;

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.List;
import java.util.stream.Collectors;

import org.springframework.stereotype.Repository;

import com.bean.Author;
import com.bean.Book;

@Repository
public class BookDAO {
	private static List<Book> bookList;
	
	public BookDAO() {
		bookList = new ArrayList<Book>();
		
		//Set mock data to simulate data in database.
		Book book = new Book();
		book.setId(1);
		book.setName("Book 1");
		book.setPublishDate(getCalendarByStr("2019-01-01T01:00:00+0800"));
		bookList.add(book);
		
		book = new Book();
		book.setId(11);
		book.setName("Book 11");
		book.setPublishDate(getCalendarByStr("2019-01-01T01:01:00+0800"));
		bookList.add(book);
		
		book = new Book();
		book.setId(2);
		book.setName("Book 2");
		book.setPublishDate(getCalendarByStr("2019-01-01T02:00:00+0800"));
		bookList.add(book);
		
		book = new Book();
		book.setId(22);
		book.setName("Book 22");
		book.setPublishDate(getCalendarByStr("2019-01-01T02:02:00+0800"));
		bookList.add(book);
		
		book = new Book();
		book.setId(3);
		book.setName("Book 3");
		book.setPublishDate(getCalendarByStr("2019-01-01T03:00:00+0800"));
		bookList.add(book);
		
		book = new Book();
		book.setId(33);
		book.setName("Book 33");
		book.setPublishDate(getCalendarByStr("2019-01-01T03:03:00+0800"));
		bookList.add(book);
	}
	
	public Book getBookById(int id) {
		return bookList.stream().filter((Book book) -> {
			return book.getId() == id;
		}).findAny().orElse(null);
	}
	
	public List<Book> getBookListByAuthor(Author author) {
		if (author == null || author.getBookIdList() == null || author.getBookIdList().size() == 0) {
			return new ArrayList<Book>();
		}
		
		return author.getBookIdList().stream().map((Integer bookId) -> {
			return bookList.stream().filter((Book book) -> {
				return book.getId() == bookId;
			}).findAny().orElse(null);
		}).filter((Book book) -> {
			return book != null;
		})
		.collect(Collectors.toList());
	}
    
    Calendar getCalendarByStr(String dateStr) {
    	SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ");
    	Calendar date = Calendar.getInstance();
    	try {
			date.setTime(sdf.parse(dateStr));
		} catch (ParseException e) {
			e.printStackTrace();
		}
        return date;
	}
}

我們這邊建一個 GraphqlController.java 來對應至GraphQL server 會收到的 request body JSON,
等一下在 Spring MVC 的 controller 可以直接把 GraphQL request 的值用 GraphqlController.java 來接,
這邊說明一下,Client 端要向 GraphQL 發起請求的時候,可以使用 HTTP GET 及 HTTP POST 的方式:

  1. HTTP GET:
    將參數直接串在網址後面,例如以下例子,不過要注意有些限制,像是無法使用 variables、mutation (雖然官方這樣說,不過其實如果 GraphQL server 是像這篇例子一樣自己實做的話,還是可以自己去設計取得 variables, mutation 實作行為,例如 query=mutation {...}) 等,可參考 Making a GraphQL requests using the GET method
    https://xxx/xxx?query=...&operationName=...
    
  2. HTTP POST:
    將參數 (query、operationName, variables 等) 直接放在 HTTP Body 中 (相當於 Postman 的 raw 格式),例如:
    {
      "query": "...",
      "operationName": "...",
      "variables": { "myVariable": "someValue", ... }
    } 

詳細可以參考這裡的說明: HTTP Methods, Headers, and Body

而 GraphqlController.java 我們把它設計成可對應 HTTP POST Body  的 JSON (有 query, operationName, variables 等屬性)。

/src/main/java/com/query/GraphqlRequestQuery.java:

package com.query;

import java.util.Map;

public class GraphqlRequestQuery {
	String query;
	String operationName;	
	Map<String, Object> variables = new HashMap<>();

	public String getQuery() {
		return query;
	}

	public void setQuery(String query) {
		this.query = query;
	}

	public String getOperationName() {
		return operationName;
	}

	public void setOperationName(String operationName) {
		this.operationName = operationName;
	}

	public Map<String, Object> getVariables() {
		return variables;
	}

	public void setVariables(Map<String, Object> variables) {
		this.variables = variables;
	}
}


建立各個 Resolver 和 Mutation,這會分別對應到 GraphQL 的 query 和 mutation,
說明都寫在注釋裡。

QueryResolver.java 對應到 GraphQL query,裡面會實作 getAuthorById() 和 getBookById() 方法

/src/main/java/com/resolver/QueryResolver.java:

package com.resolver;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import com.bean.Author;
import com.bean.Book;
import com.dao.AuthorDAO;
import com.dao.BookDAO;

import graphql.kickstart.tools.GraphQLQueryResolver;

@Component //對應至 GraphQL request 的 query
public class QueryResolver implements GraphQLQueryResolver {
	
	@Autowired
	AuthorDAO authorDAO;
	
	@Autowired
	BookDAO bookDAO;
	
	//對應至 GraphQL request query 的 getAuthorById
	public Author getAuthorById(int id) {
        return authorDAO.getAuthorById(id);
    }
	
	//對應至 GraphQL request query 的 getBookById
	public Book getBookById(int id) {
        return bookDAO.getBookById(id);
    }
} 

AuthorResolver.java 對應到 GraphQL query 回傳 Author 的 bookList 屬性實作方法,
因為在 QueryResolver.java 中 getAuthorById() 回傳的 Author 裡面找不到 bookList 屬性 (Author.java 只有 bookIdList 和 getBookIdList() 的 getter ,並且 bookIdList 是 int[],也不是 Book[] ),
所以我們必須為 Author 的 bookList 屬性實作值的取得方法。

/src/main/java/com/resolver/AuthorResolver.java:

package com.resolver;

import java.util.List;
import java.util.stream.Collectors;

import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import com.bean.Author;
import com.bean.Book;
import com.dao.BookDAO;

import graphql.kickstart.tools.GraphQLResolver;

@Component //對應至 GraphQL request query 中,Author 屬性值的 Resolver 獲取方式 
public class AuthorResolver implements GraphQLResolver<Author> {
	
	@Autowired
	BookDAO bookDAO;
	
	//對應至 GraphQL request query 中,Author 屬性 bookList 值的 Resolver 獲取方式 
	List<Book> bookList(Author author, String prefix) { //可獲取 Parent (也就是 Author) 的資料和傳入參數 (此例為 prefix)
		List<Book> bookList = bookDAO.getBookListByAuthor(author);
		
		if (StringUtils.isNotBlank(prefix)) {			
			bookList = bookList.stream().map((Book book) -> {
				Book renamedBook = new Book();
				renamedBook.setId(book.getId());
				renamedBook.setName(prefix + "-" + book.getName());
				renamedBook.setPublishDate(book.getPublishDate());
				return renamedBook;
			}).collect(Collectors.toList());
		}
		
		return bookList;
	}
}

MutationResolver.java 對應至 GraphQL mutation,裡面會實作 updateAuthorName

/src/main/java/com/resolver/MutationResolver.java:

package com.resolver;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import com.bean.Author;
import com.dao.AuthorDAO;

import graphql.kickstart.tools.GraphQLMutationResolver;

@Component //對應至 GraphQL request 的 mutation
public class MutationResolver implements GraphQLMutationResolver{
	
	@Autowired
	AuthorDAO authorDAO;
	
	//對應至 GraphQL request mutation 的 updateAuthorName
	public Author updateAuthorName(int id, String name) {
		return authorDAO.updateAuthorName(id, name);
	}
}

接下來我們要來處理自訂的 Date Scalar , 我們這邊建立一個 DateScalarBuilder.java 來回傳自訂實作的 GraphQLScalarType,關於 parseValue() 和 parseLiteral() 的差異可以參考官方這篇 Scalars in GraphQL 和 Stackoverflow 這篇 what's the difference between parseValue and parseLiteral in  GraphQLScalarType 別人的回答
DateScalarBuilder.java:

package com.scalar;

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Calendar;

import graphql.language.StringValue;
import graphql.schema.Coercing;
import graphql.schema.GraphQLScalarType;

public class DateScalarBuilder {
	
	private DateScalarBuilder() {
		
	}
	
	public static GraphQLScalarType getDateScalar() {
		String dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ";
		
		return GraphQLScalarType.newScalar()
				.name("Date")
				.description("A custom scalar that handles date.")
		        .coercing(new Coercing<Calendar, String>() {
		            @Override
		            public String serialize(Object dataFetcherResult) {
                    	//實作從 Java 物件 (此例是 Calendar) 轉成要回傳 graphql response 的 String
		            	Calendar date = (Calendar) dataFetcherResult;
		            	SimpleDateFormat sdf = new SimpleDateFormat(dateFormat);
		                return sdf.format(date.getTime()) ;
		            }

		            @Override
		            public Calendar parseValue(Object input) {
                    	//實作從 Graphql 得到的 String 轉成 Java 物件 (此例是 Calendar)
                        //如果 input 使用了 inline input 的方式,就會使用 parseValue() 來解析
		            	//例如:
		            	/*
		            	 * Request:
		            	 * query myQuery {		            	 * 
		            	 * 	getBookById(id: 1) {
		            	 *  	id
		            	 *  	name 
		            	 * 	}
		            	 * } 
		            	 */
		            	String dateStr = (String) input;
		            	SimpleDateFormat sdf = new SimpleDateFormat(dateFormat);
		            	Calendar date = Calendar.getInstance();
		            	try {
							date.setTime(sdf.parse(dateStr));
						} catch (ParseException e) {
							e.printStackTrace();
						}
		                return date;
		            }

		            @Override
		            public Calendar parseLiteral(Object input) {
                    	//實作從 Graphql 得到的 String 轉成 Java 物件 (此例是 Calendar)
                        //如果 input 使用了 variables 的方式,就會使用 parseLiteral() 來解析
		            	//例如:
		            	/*
		            	 * Request:
		            	 * query myQuery($id: ID) { 
		            	 * 
		            	 * 	getBookById(id: $id) {
		            	 *  	id
		            	 *  	name 
		            	 * 	}
		            	 * }
		            	 * 
		            	 * Variables:
		            	 * {
		            	 * 	"id": 1
		            	 * }
		            	 */
		            	String dateStr = ((StringValue) input).getValue();
		            	SimpleDateFormat sdf = new SimpleDateFormat(dateFormat);
		            	Calendar date = Calendar.getInstance();
		            	try {
							date.setTime(sdf.parse(dateStr));
						} catch (ParseException e) {
							e.printStackTrace();
						}
		                return date;
		            }
		        })
		        .build();
	}
}

GraphqlController.java 是 SpringMVC 的 Controller 設定,
這裡我們設定了 /graphql 為處理 GraphQL request 的 url,
限制只接收 HTTP POST 且 Mime type 為 application/json 格式的 request。

接著讀取 GraphQL Schema 、設定要用的 Resolver 、 Scalar,並配合接收到的 GraphQL request
產生並回傳查詢結果。

src/java/com/controller/GraphqlController.java:

package com.controller;

import java.util.Map;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;

import graphql.ExecutionInput;
import graphql.ExecutionResult;
import graphql.GraphQL;
import graphql.kickstart.tools.SchemaParser;
import graphql.schema.GraphQLSchema;

import com.scalar.DateScalarBuilder;
import com.query.GraphqlRequestQuery;
import com.resolver.AuthorResolver;
import com.resolver.MutationResolver;
import com.resolver.QueryResolver;

@Controller
public class GraphqlController {
	
	@Autowired
	private QueryResolver queryResolver;
	
	@Autowired
	private AuthorResolver authorResolver;
	
	@Autowired
	private MutationResolver mutationResolver;
	
	@RequestMapping(value="/graphql", method = {RequestMethod.POST}, produces = "application/json")
	public @ResponseBody Map<String, Object> myGraphql(@RequestBody GraphqlRequestQuery requestQuery) {
		
		//讀取 GraphQL Schema、設定各 Resolver
		GraphQLSchema graphQLSchema = SchemaParser.newParser()
									  .file("schema.graphqls")
                                      .scalars(DateScalarBuilder.getDateScalar())
									  .resolvers(queryResolver
											     , authorResolver
											     , mutationResolver)
									  .build()
									  .makeExecutableSchema();

		GraphQL graphQL = GraphQL.newGraphQL(graphQLSchema).build();
		
		//將 GraphQL request 的 query (也可視需要取出 operationName, variables) 值取出,準備進行查詢
		ExecutionInput executionInput = ExecutionInput.newExecutionInput()
										.query(requestQuery.getQuery())
                                        .operationName(requestQuery.getOperationName())
                                        .variables(requestQuery.getVariables())
										.build();
		ExecutionResult executionResult = graphQL.execute(executionInput);	
		
		//executionResult.toSpecification() 會回傳一個 Map,
		//裡面包含了查詢結果 data 、 errors、 extensions 等資料
		return executionResult.toSpecification();
	}
}

最後展示一下查詢結果:

  1. 查詢:
    query {
      getAuthorById(id: 3) {
        id
        name
        bookList(prefix: "test") {
            id
            name
            publishDate
        }
      },
      getBookById(id: 33) {
        id
        name
        publishDate
      }
    }
    
    結果:
    {
        "data": {
            "getAuthorById": {
                "id": "3",
                "name": "Author 3",
                "bookList": [
                    {
                        "id": "3",
                        "name": "test-Book 3"
                        "publishDate": "2019-01-01T03:00:00+0800"
                    },
                    {
                        "id": "33",
                        "name": "test-Book 33"
                        "publishDate": "2019-01-01T03:03:00+0800"
                    }
                ]
            },
            "getBookById": {
                "id": "33",
                "name": "Book 33"
                "publishDate": "2019-01-01T03:03:00+0800"
            }
        }
    }
    
  2. 查詢:
    mutation {
        updateAuthorName(id: 3, name: "Author 3 - changed") {
            id
            name
            bookList(prefix: "test") {
                id
                name
                publishDate
            }
        }
    }
    
    結果:
    {
        "data": {
            "updateAuthorName": {
                "id": "3",
                "name": "Author 3 - changed",
                "bookList": [
                    {
                        "id": "3",
                        "name": "test-Book 3"
                        "publishDate": "2019-01-01T03:00:00+0800"
                    },
                    {
                        "id": "33",
                        "name": "test-Book 33"
                        "publishDate": "2019-01-01T03:03:00+0800"
                    }
                ]
            }
        }
    }
    

下載分享:

MyGraphQL.7z

參考資料:

  1. HTTP Methods, Headers, and Body
  2. Making a GraphQL requests using the GET method
  3. GraphQL x Spring WebMVC
  4. Defining a schema
  5. Think in GraphQL :: 2019 iT 邦幫忙鐵人賽