2025年1月7日 星期二

使用 MS SQL CTE (Common Table Expression) 的遞迴功能

MS Sql Server 有提供 CTE (Common Table Expression),它可以讓我們用 WITH 語法來建立暫時的 Table。

例如:

WITH numRange(num) AS (
	SELECT 1
)
SELECT *
FROM numRange

查詢結果是:

1

而因為 CTE 可以在 AS () 中參考自已本身,
所以可以達到遞迴的效果,今天就是要來紀錄一下使用的方法。

在 AS () 中,會由兩種成員組成,分別是放在前面的錨點成員 (anchor) 和放在後面的遞迴成員 (recursive),錨點成員是用來設定初始的 Table 內容,不可參考到 CTE 本身的 Table。

而遞迴成員會參考到 CTE Table 本身 (準確來說是參考到上一次迭代得到的資料表)。

錨點成員跟遞迴成員必須要用 UNION ALL 連接。

在每一次的遞迴迭代中,遞迴成員會參考到上一次得到的資料表,而做完 SELECT 後會產生出一份屬於這次迭代的資料表給下一次迭代的遞迴成員使用,迭代會一直進行下去直到超過次數上限 (可以用 OPTION (MAXRECURSION X) 來設定上限,X是次數,設 0 代表無限) 或是產生不出資料表為止 (就是 SELECT 的結果為空集合)。

當所有迭代執行結束後,它會把所有產生出的資料表結合起來得到最後的結果資料表。

下面舉個例子:

WITH numRange(num) AS (
	SELECT 1

	UNION ALL

	SELECT num + 1
	FROM numRange
	WHERE (num + 1) <= 3
)
SELECT *
FROM numRange

查詢結果是:

1
2
3

一開始初始第1次迭代的資料表是:

1

第2次迭代產生的資料表是

2

第3次迭代產生的資料表是

3

而第4次因為 SQL 語句 (此時 num 參考上一次、也就是第三次迭代的 num 是 3)

SELECT num + 1
	FROM numRange
	WHERE (num + 1) <= 3

得到的結果集為空,所以迭代結束。

接著下面來看另一個範例,
我們有一張表叫做 family,欄位是 name, father, mother,分別代表家族成員的名字、父親名字、母親名字,假設名字都不會重覆,所以名字可以當做 id 主鍵來做 JOIN。

表的內容如下:

name mother father
小孩1 媽媽 爸爸
小孩2 媽媽 爸爸
媽媽1 外婆 外公
爸爸 祖父 祖母
祖父 NULL NULL
祖母 NULL NULL
外公 NULL NULL
外婆 NULL NULL

現在我們想找出特定人的所有長輩及其的父母、還有長輩和特定人的輩份距離,就可以像下面這樣查詢,範例是要找出"小孩1"的長輩及其父母和長輩跟"小孩1"的輩份關係,相關的說明也都已寫在範例的注釋中:

-- 建立資料表
CREATE TABLE family(
   name NVARCHAR(100),
   mother NVARCHAR(100),
   father NVARCHAR(100)
);

-- 塞資料
INSERT INTO family(name, mother, father)
VALUES(N'小孩1', N'媽媽', N'爸爸'),
      (N'小孩2', N'媽媽', N'爸爸'),
	  (N'媽媽', N'外婆', N'外公'),
	  (N'爸爸', N'祖父', N'祖母'),
	  (N'祖父', NULL, NULL),
	  (N'祖母', NULL, NULL),
	  (N'外公', NULL, NULL),
	  (N'外婆', NULL, NULL);

-- 進行查詢
WITH elderRelation(elder, elder_mother, elder_father, level) AS (
   -- 這裡是錨點成員,建立一開始的初始資料集
   -- 例如此例是找出 "小孩1" 的父母及其父母和小孩與父母的輩份相差數

   -- 只要沒有參考到 elderRelation CTE 本身就是屬於錨點成員
   ---------- 錨點成員 - 開始 ----------

   -- 查詢"小孩1"的母親及其父母資料
   SELECT parent.name, parent.mother, parent.father, 1
   FROM family person INNER JOIN family parent ON person.mother = parent.name
   WHERE person.name = N'小孩1'

   UNION ALL

   -- 查詢"小孩1"的父親及其父母資料
   SELECT parent.name, parent.mother, parent.father, 1
   FROM family person INNER JOIN family parent ON person.father = parent.name
   WHERE person.name = N'小孩1'

   -- 上述只是展示錨點成員也可以自己視需要用 UNION 組合多個 Table,
   -- 上述範例也可以用一次查詢完成,例如:
   --SELECT parent.name, parent.mother, parent.father, 1
   --FROM family person INNER JOIN family parent ON person.mother = parent.name OR person.father = parent.name
   --WHERE person.name = N'小孩1'

   ---------- 錨點成員 - 結束 ----------

   -- 用 UNION ALL 與遞迴成員組合
   UNION ALL

   -- 下面因為開始參考到 elderRelation 這個 CTE 本身,所以是遞迴成員
   ---------- 遞迴成員 - 開始 ----------
   -- 找出 elderRelation 母親的父母人員資料 
   SELECT family.name, family.mother,  family.father, level + 1
   FROM elderRelation INNER JOIN family ON elderRelation.elder_mother = family.name

   UNION ALL

   -- 找出 elderRelation 父親的父母人員資料
   SELECT family.name, family.mother,  family.father, level + 1
   FROM elderRelation INNER JOIN family ON elderRelation.elder_father = family.name

   -- 上述只是展示遞迴成員也可以自己視需要用 UNION 組合多個 Table,
   -- 上述範例也可以用一次查詢完成,例如:
   --
   --SELECT family.name, family.mother,  family.father, level + 1
   --FROM elderRelation INNER JOIN family ON elderRelation.elder_mother = family.name OR elderRelation.elder_father = family.name

   ---------- 遞迴成員 - 結束 ----------
)
SELECT *
FROM elderRelation
OPTION (MAXRECURSION 0); -- 可以視需要設置遞迴的最大允許次數,0代表無限大

查詢結果如下:

elder elder_mother elder_father level
媽媽 外婆 外公 1
爸爸 祖父 祖母 1
祖父 NULL NULL 2
祖母 NULL NULL 2
外婆 NULL NULL 2
外公 NULL NULL 2

參考資料:

  1. WITH common_table_expression (Transact-SQL)
  2. 利用 MAXRECURSION來突破CTE預設遞迴次數
  3. [SQL] 使用 CTE 遞迴查詢 (PostgreSQL / MSSQL)
  4. [SQL Server] CTE RECURSIVE (遞迴)製作月曆

2025年1月2日 星期四

Jaspersoft Report 要如何將 Java List 送進 Report 當 Datasource 和 Parameter

在使用 Jaspersoft Report 時,除了直接在 jrxml 檔中設定 SQL 語法去 Database 查資料以外,
也可以在 Java 程式中先去 Database 中查資料,查好後再把資料送去給 Jaspersoft 產生報表。

在每個子報表 (Sub Report) 的資料來源需求都是同一個查詢,頂多 SQL Where 條件不一樣時,
這種方式可以只要用 Java 端查詢一次資料送給各子報表,
各個子報表再使用 Filter 功能去實現自己的 Where 條件篩選,
就可以避免每個子報表都要進行一次 SQL 查詢而影響效能。

以下紀錄一下使用範例。

在 Java 中:

//自己想辦法查出資料成一個 List 物件
List dataset = getDataset();

//把 List 放進 reportParams Map 中
Map reportParams = new HashMap();
reportParams.put("dataset", dataset);

//把 reportParams Map 當參數送進 Jaspersoft Report 中
//然後子報表 (Sub Report) 可以把 Dataset 包裝成 Datasource 使用
JasperReport jasperReport = JasperCompileManager.compileReport("myReport.jrxml"));
//Dataset 也可以作為 Data source 送給主報表 (Main Report)使用
JRDataSource dataSource = new JRMapCollectionDataSource(dataset);
JasperPrint jasperPrint = JasperFillManager.fillReport(jasperReport, reportParams, dataSource);

在 Main Report 的 jrxml 檔中:

<!--設定名為 dataset 的 parameter 跟 Java 傳進來的 dataset 參數作對應  -->
<parameter name="dataset" class="java.util.Collection"/>

<!-- 用 JRMapCollectionDataSource 把 $P{dataset} 包裝成一個新的 DataSource 傳給 Sub Report  -->
<!-- 不把 Main Report 的 DataSource 直接傳給 Sub Report 是因為 DataSource 被讀取 (Consume) 後就不能再讀了, -->
<!-- 會造成 Sub Report 把 Main Report 的 DataSource 吃掉讓 Main Report 讀不到資料。  -->
<subreport>

...........

 <dataSourceExpression><![CDATA[new net.sf.jasperreports.engine.data.JRMapCollectionDataSource($P{dataset})]]></dataSourceExpression>

............

</subreport>

在 Sub Report 的 jrxml 檔中:

<!-- 在 Sub Report 中可以用 <filterExpression> 的功能過濾篩選資料,實現像 SQL Where 的效果 -->
<!-- 例如下面範例: -->
<!-- 從 Main Report 得到的 DataSource 取得的 Dataset 中, -->
<!-- 有名為 groupName 的 String 欄位 (field), 設定 field 去接收它,例如值可能有 1, 2, 3, 或 4 -->
<field name="groupName" class="java.lang.String"/>
<!-- 設定 parameter 去接收 Main Report 傳給 Sub Report 的另一個名為 groupNameForFilter 的參數,例如值為 3 代表想篩選出只是 3 的 DataSet -->
<parameter name="groupNameForFilter" class="java.lang.String"/>
<!-- 用 filterExpression 功能去設定篩選條件為:$F{groupName} 要等於 $P{groupNameForFilter}  -->
<filterExpression><![CDATA[$F{groupName}.equals($P{groupNameForFilter})]]></filterExpression>

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年12月4日 星期三

Javascript - 使用 Array.splice() 交換 List 中的兩 Item 的位置

紀錄一下使用 Javascript 的 Array.splice() 交換 List 中的兩個 Item 位置的方法,
例如現在想要交換 list 中 index1 和 index2 位置的 item ,
可以如下達成:

var list = [1,2,3,4]; //list = [1,2,3,4]
var index1 = 0;
var index2 = 2;
list[index1] = list.splice(index2, 1, list[index1])[0]; //list = [3,2,1,4]

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年8月25日 星期日

Webpack 使用 Bebel 並用 @babel/preset-typescript 使用 typescript

記錄下在使用 Webpack 時,
如何使用 Bebel 並用 @babel/preset-typescript 的方式配合使用 Typescript 的功能

將 @babel/preset-typescript 設定在 babel-loader 的 presets 裡就好了。

package.json:

webpack.config.js:

{
  "name": "xxx-proejct",
  "scripts": {
    "deploy": "webpack"
  },
  "devDependencies": {
    "@babel/core": "7.18.10",
    "@babel/preset-env": "7.18.10",
    "@babel/preset-typescript": "^7.24.7",
    "babel-loader": "8.2.5",
    "css-loader": "5.0.1",
    "mini-css-extract-plugin": "1.3.3",
    "sass": "1.54.9",
    "sass-loader": "10.1.0",
    "style-loader": "3.3.1",
    "webpack": "5.74.0",
    "webpack-cli": "4.10.0"
  }
}

webpack.config.js:

var path = require('path');
var MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = {
    entry: {
        index_js: {
            import: "./src/app.js",
            filename: "js/index_bundle.js"
        }
    },
    output: {
        path: __dirname + "/dist"
    },
    watch: false,
    devtool : 'source-map',
    mode : "production",
    resolve : {
     extensions : ['.js', '.ts'],
     modules: [path.resolve(__dirname, '.'), 'node_modules']
    },
    module: {
        rules : [{
            test : /\.(ts|js)$/,
            use : {
                loader : 'babel-loader',
                options : {
                    presets : ['@babel/preset-env', '@babel/preset-typescript']
                }
            }
        },
        {
          test: /\.s[ac]ss$/,
          use: [        
               MiniCssExtractPlugin.loader,
               "css-loader", "sass-loader"
              ]
        }]
    },
    plugins: [new MiniCssExtractPlugin({
        filename: "./css/index_bundle.css"
    })],
    externals: {
        angular: "angular"
    }
}