2024年7月29日 星期一

使用 JUnit5 Runner + TestNg + Dabase Rider 進行 Database 測試

這篇要來紀錄使用 JUnit5 Runner + TestNg + Dabase Rider 進行 Database 測試的方法。

首先,用 JUnit5 來跑 TestNg 的方法可以參考這篇 使用 JUnit5 同時跑 JUnit5 和 TestNg 的 Test Case,只要使用 testng-engine 就可以了。

再來,因為 Database Rider (可以先參考之前的 使用 DBUnit 進行 Database 測試 和 使用 Dabase Rider 進行 Database 測試 這兩篇) 的 Annotation (註解,例如 @DBUnit、@Dataset 這些) 是用 @ExtendWith(DBUnitExtension.class) 的方式以 Extension 來跟 JUnit5協作,
但 TestNg 是看不懂這些 Database Rider 的 Annotation 的,
所以我們要用如 DBUnit 最普通的方式那樣,
自己用程式碼執行的方式 (就是不用 Annotation)來進行 DataSet 的建立、寫資料進 Database 等操作。

下面直接示範程式碼:

Maven Dependency 的 pom.xml:

<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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<groupId>hugo</groupId>
	<artifactId>junittest</artifactId>
	<version>0.0.1-SNAPSHOT</version>

	<properties>
		<maven.compiler.target>11</maven.compiler.target>
		<maven.compiler.source>11</maven.compiler.source>
	</properties>

	<build>
		<plugins>
			<!-- Maven 執行 test 的 plugin,surefire 多用於單元測試 (unit test), failsafe
			多用於整合測試 (integration test)  -->
			<plugin>
				<artifactId>maven-surefire-plugin</artifactId>
				<version>3.1.2</version>
			</plugin>
			<plugin>
				<artifactId>maven-failsafe-plugin</artifactId>
				<version>3.1.2</version>
			</plugin>
		</plugins>
	</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.testng/testng -->
		<dependency>
			<groupId>org.testng</groupId>
			<artifactId>testng</artifactId>
			<version>7.10.2</version>
			<scope>test</scope>
		</dependency>

		<!-- 使用 testng-engine 讓 JUnit5 可以跑 TestNg 的 Test Case -->
		<!-- https://mvnrepository.com/artifact/org.junit.support/testng-engine -->
		<dependency>
			<groupId>org.junit.support</groupId>
			<artifactId>testng-engine</artifactId>
			<version>1.0.5</version>
			<scope>test</scope>
		</dependency>
		
		<!-- https://mvnrepository.com/artifact/com.github.database-rider/rider-junit5 -->
		<dependency>
		    <groupId>com.github.database-rider</groupId>
		    <artifactId>rider-junit5</artifactId>
		    <version>1.42.0</version>
		    <scope>test</scope>
		</dependency>
		
		<!-- Database Reider 輸出 XLSX Dataset 需要 poi-ooxml -->
		<!-- https://mvnrepository.com/artifact/org.apache.poi/poi-ooxml -->
		<dependency>
		    <groupId>org.apache.poi</groupId>
		    <artifactId>poi-ooxml</artifactId>
		    <version>5.3.0</version>
		</dependency>

	</dependencies>

</project>

TestNgDatabaseRiderTest.java:

package junittest;

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

import org.dbunit.DatabaseUnitException;
import org.testng.annotations.AfterTest;
import org.testng.annotations.BeforeTest;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;

import com.github.database.rider.core.api.dataset.DataSetFormat;
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.dsl.RiderDSL;
import com.github.database.rider.core.exporter.DataSetExporter;

public class TestNgDatabaseRiderTest {

	static final String testResourceFolderPath = "";
	static final String backupDatasetResourcePath = "/backupDataset.xlsx";
	static final String testDatasetResourcePath = "/testDataset.xlsx";
	
	static final String jdbcDriverName = "org.postgresql.Driver";
	static final String databaseUrl = "jdbc:postgresql://localhost:5432/xxxDatabase";
	static final String databaseUsername = "username";
	static final String databasePassword = "password";
	
	static final String[] includeTableList = new String[] {"xxxTable1", 
														   "xxxTable2", 
														   "xxxTable3"};
                                                           
    static String[] statementsForExecuteAfterDataSet = new String[]{"SELECT setval('xxxTable1_xxxField_seq', (SELECT coalesce(MAX(xxxField), 0) + 1 FROM xxxTable1), false)",
            	   									   		        "SELECT setval('xxxTable2_xxxField_seq', (SELECT coalesce(MAX(xxxField), 0) + 1 FROM xxxTable2), false)"};

	@BeforeTest
	static void beforeAll() {
		//在所有 Test Case 執行之前先備份當前要用到的 Database Tables,
		//例如 export 成 XLSX 檔案
		try {			
			URL backupDatasetResourceFolderUrl = TestNgDatabaseRiderTest.class.getClassLoader().getResource(testResourceFolderPath);
			File backupDatasetResourceFolder = Paths.get(backupDatasetResourceFolderUrl.toURI()).toFile();
			String backupDatasetResourceFilePath = backupDatasetResourceFolder.getAbsolutePath() + backupDatasetResourcePath.replace("/", File.separator);
			
			DataSetExporter.getInstance().export(getConnection(),
												 new DataSetExportConfig()
												 	 .dataSetFormat(DataSetFormat.XLSX)
												 	 //可以設定只要 Export 某些 Table
												 	 .includeTables(includeTableList)
												 	 .queryList(new String[] {})
												 	 .outputFileName(backupDatasetResourceFilePath));
		} catch (Exception e) {
			e.printStackTrace();
		}
	}

	@BeforeMethod
	//在每一次的 Test Case 執行之前把 Test Dataset 檔案內容匯入當下 Database 中
	void beforeEach() throws DatabaseUnitException, SQLException {
		System.out.println("before each");
		
        try (Connection conn = getConnection();) {
			RiderDSL.withConnection(conn)
			    	.withDataSetConfig(new DataSetConfig(testResourceFolderPath + testDatasetResourcePath)				    		       
			    				   	//實測 executeStatementsBefore 和 executeStatementsAfter 效果不好,
                                   	// executeStatementsBefore 會在 DataSet 塞入 Database 前被執行,但我想要在 DataSet 塞入 Database 後執行。
                                   	// executeStatementsAfter 我目前測不出來什麼時候才會被執行,可能我理解有誤?
                                   	//.executeStatementsBefore("")
                                   	//.executeStatementsAfter("")
                                   	)
			    	.withDBUnitConfig(new DBUnitConfig()
                    				 .cacheConnection(false) //這個很重要,因為預設是 true,有可能會造成不預期的意外,例如使用了 cache 的 connection 但不知 connection 可能早被 close 了
					   			  	 .addDBUnitProperty("escapePattern", "\"?\"")
			    				  	 .addDBUnitProperty("caseSensitiveTableNames", true)
			    				  	 .addDBUnitProperty("allowEmptyFields", true))
	    	.createDataSet();
        }
        
        //你可以在這指定一些需要執行的前置做業 Sql 語句給 executeStatementsBefore,
        //例如使用 PostgreSQL 時,DatabaseOperation.CLEAN_INSERT.execute() 會把
        //遞增欄位的遞增值重設成 1,造成之後測試 insert 時發生問題 (id 重覆等之類的),
		//這時就會需要在這做例如如下 Sql 語句來設置正確的遞增值:
		//SELECT setval('xxxTable_xxxField_seq', (SELECT coalesce(MAX(xxxField), 0) + 1 FROM xxxTable), false)
        for (String statement : statementsForExecuteAfterDataSet) {
			try (Connection conn = getConnection();
				 PreparedStatement pstmt = conn.prepareStatement(statement);) {

				try (ResultSet rs = pstmt.executeQuery();) {
				}
			} catch (SQLException e) {
				Assert.fail("Exception occured: " + getExceptionDetail(e));
			}
		}
	}

	@AfterMethod
	void afterEach() {
	}

	@AfterTest
	//在所有 Test Case 測試完後,用之前 Export 出來的 Backup Dataset 匯回去到 Database 中
	static void afterAll() throws DatabaseUnitException, SQLException {
		System.out.println("afterAll");
		
        try (Connection conn = getConnection();) {
			RiderDSL.withConnection(conn)
        	    	.withDataSetConfig(new DataSetConfig(testResourceFolderPath + backupDatasetResourcePath)
        	    				   		.executeStatementsBefore("SELECT setval('xxxTable1_xxxField_seq', (SELECT coalesce(MAX(xxxField), 0) + 1 FROM xxxTable1), false)",
			    						   						 "SELECT setval('xxxTable2_xxxField_seq', (SELECT coalesce(MAX(xxxField), 0) + 1 FROM xxxTable2), false)"))
        	    	.withDBUnitConfig(new DBUnitConfig()
                			      	  .cacheConnection(false) //這個很重要,因為預設是 true,有可能會造成不預期的意外,例如使用了 cache 的 connection 但不知 connection 可能早被 close 了
        			   			  	  .addDBUnitProperty("escapePattern", "\"?\"")
        	    				  	  .addDBUnitProperty("caseSensitiveTableNames", true)
        	    				  	  .addDBUnitProperty("allowEmptyFields", true))
        	    	.createDataSet();
        }
                
        for (String statement : statementsForExecuteAfterDataSet) {
			try (Connection conn = getConnection();
				 PreparedStatement pstmt = conn.prepareStatement(statement);) {

				try (ResultSet rs = pstmt.executeQuery();) {
				}
			} catch (SQLException e) {
				Assert.fail("Exception occured: " + getExceptionDetail(e));
			}
		}        
	}
	
    //TestNg 可以使用 dependsOnMethods 來設定要在哪個 Test methods 之後才執行
	@Test(dependsOnMethods = {"test2"})
	void test() {
		assertEquals(1, 1, "Should be equal.");
		System.out.println("TestNg: test1");
	}
	
	@Test
	void test2() {
		assertEquals(1, 1, "Should be equal.");
		System.out.println("TestNg: test2");
	}
	
	static Connection getConnection() {
		Connection connection = null;
		
		try {
			Class.forName(jdbcDriverName);
			connection = DriverManager.getConnection(databaseUrl, databaseUsername, databasePassword);
		} catch (ClassNotFoundException | SQLException e) {
			e.printStackTrace();
		}
		
		return connection;
	}
}

參考資料:

  1. DataSet Executor
  2. RiderDSL

2024年7月28日 星期日

使用 JUnit5 同時跑 JUnit5 和 TestNg 的 Test Case

通常來講 Java 的 Unit Test 只會用一個 Framework 來跑,
例如使用JUnit5、或著是使用 TestNg 等,一次只會用一種 Framework,
不過 JUnit5 提供一個 JUnit Platform 可供其他第三方的 Unit Test framework去 implement,
讓 JUnit5 可以跑其他的 Unit Test framework,
這樣我們就可以同時跑專案裡的 JUnit5 和 TestNg 的測試案例了。

下面展示範例:

Maven Dependency 的 pom.xml:

<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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<groupId>hugo</groupId>
	<artifactId>junittest</artifactId>
	<version>0.0.1-SNAPSHOT</version>

	<properties>
		<maven.compiler.target>11</maven.compiler.target>
		<maven.compiler.source>11</maven.compiler.source>
	</properties>

	<build>
		<plugins>
			<plugin>
				<artifactId>maven-surefire-plugin</artifactId>
				<version>3.1.2</version>
			</plugin>
			<plugin>
				<artifactId>maven-failsafe-plugin</artifactId>
				<version>3.1.2</version>
			</plugin>
		</plugins>
	</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.testng/testng -->
		<dependency>
			<groupId>org.testng</groupId>
			<artifactId>testng</artifactId>
			<version>7.10.2</version>
			<scope>test</scope>
		</dependency>

		<!-- 使用 testng-engine 讓 JUnit5 可以跑 TestNg 的 Test Case -->
		<!-- https://mvnrepository.com/artifact/org.junit.support/testng-engine -->
		<dependency>
			<groupId>org.junit.support</groupId>
			<artifactId>testng-engine</artifactId>
			<version>1.0.5</version>
			<scope>test</scope>
		</dependency>

	</dependencies>

</project>

兩個 Test Case Class,一個使用 JUnit5、另一個使用 TestNg:

Juit5Test.java (使用 JUnit5):

package junittest;

import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;

public class Juit5Test {
	
	@BeforeAll
	static void beforeAll() {
		System.out.println("Junit5: beforeAll");
	}
	
	@Test
	void test() {
		assertEquals(1, 1, "Should be equal.");
		System.out.println("Junit5: test1");
	}
	
	@Test
	void test2() {
		assertEquals(1, 1, "Should be equal.");
		System.out.println("Junit5: test2");
	}
}

TestNgTest.java (使用 TestNg):

package junittest;

import static org.testng.Assert.assertEquals;

import org.testng.annotations.BeforeTest;
import org.testng.annotations.Test;
public class TestNgTest{

	@BeforeTest
	static void beforeAll() {
		System.out.println("TestNg: beforeAll");
	}
	
	//TestNg 可以使用 dependsOnMethods 來設定要在哪個 Test methods 之後才執行
	@Test(dependsOnMethods = {"test2"})
	void test() {
		assertEquals(1, 1, "Should be equal.");
		System.out.println("TestNg: test1");
	}
	
	@Test
	void test2() {
		assertEquals(1, 1, "Should be equal.");
		System.out.println("TestNg: test2");
	}
	
}

接下來我們就可以使用 Maven 的 test 指令來跑 Test Case:

mvn clean test

以下是跑完的結果,可以看到 JUnit5 和 TestNg 的 Test Case 都有被成功執行

[INFO] -------------------------------------------------------
[INFO]  T E S T S
[INFO] -------------------------------------------------------
SLF4J: Failed to load class "org.slf4j.impl.StaticLoggerBinder".
SLF4J: Defaulting to no-operation (NOP) logger implementation
SLF4J: See http://www.slf4j.org/codes.html#StaticLoggerBinder for further details.
[INFO] Running junittest.Juit5Test
Junit5: beforeAll
Junit5: Junit5: OK1
Junit5: Junit5: OK2
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.028 s -- in junittest.Juit5Test
[INFO] Running junittest.TestNgTest
TestNg: beforeAll
TestNg: TestNg: OK2
TestNg: TestNg: OK1
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.005 s -- in junittest.TestNgTest
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0

The engine’s main intention is integration with build tools like Gradle and Maven. Hence, custom suites specified via testng.xml files are not supported.

  1. TestNG Engine for the JUnit Platform
  2. JUnit Jupiter Episode 3 - Running JUnit 4 / JUnit 5 / TestNG Tests in a Maven project.
  3. TestNg and Junit not running in same gradle project

2024年7月24日 星期三

使用 Dabase Rider 進行 Database 測試

上一篇 使用 DBUnit 進行 Database 測試 介紹了 DBUnit,
這篇要來介紹 Database Rider。

Database Rider 是一個基於 DBUnit 的專案,它擴展了 DBUnit 原有的功能,並整合了 JUnit 5,
增加了註解 (Annotation) 的使用方式,讓使用上更為便利,
並且也支援了更多的 Dataset 檔輸出格式,像是除了原有的 XML, XLS,也多了 XLSX, JSON, YML ,不過實測還是 XLS, XLSX 比較好用,YML, JSON 還是會有一些跟 DBUnit 一樣的特殊字元問題,例如無法正確處理 \v (\u000B) 等。

以下直接演示實際的程式碼範例:

Maven Dependency 設定:

<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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<groupId>hugo</groupId>
	<artifactId>test</artifactId>
	<version>0.0.1-SNAPSHOT</version>

	<properties>
	    <maven.compiler.target>11</maven.compiler.target>
	    <maven.compiler.source>11</maven.compiler.source>
	</properties>

	<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/com.github.database-rider/rider-junit5 -->
		<dependency>
		    <groupId>com.github.database-rider</groupId>
		    <artifactId>rider-junit5</artifactId>
		    <version>1.42.0</version>
		    <scope>test</scope>
		</dependency>
		
		<!-- Database Reider 輸出 XLSX Dataset 需要 poi-ooxml -->
		<!-- https://mvnrepository.com/artifact/org.apache.poi/poi-ooxml -->
		<dependency>
		    <groupId>org.apache.poi</groupId>
		    <artifactId>poi-ooxml</artifactId>
		    <version>5.3.0</version>
		</dependency>
		
		<!-- 此例以 PostgreSQL 為例,所以有使用 PostgreSQL Driver 的 Dependency -->
		<!-- https://mvnrepository.com/artifact/org.postgresql/postgresql -->
		<dependency>
		    <groupId>org.postgresql</groupId>
		    <artifactId>postgresql</artifactId>
		    <version>42.7.1</version>
		</dependency>

	</dependencies>
</project>

主要的單元測試程式:

package test;

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

import org.dbunit.DatabaseUnitException;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;

import com.github.database.rider.core.api.configuration.DBUnit;
import com.github.database.rider.core.api.connection.ConnectionHolder;
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.exporter.DataSetExportConfig;
import com.github.database.rider.core.exporter.DataSetExporter;
import com.github.database.rider.junit5.DBUnitExtension;

@ExtendWith(DBUnitExtension.class)
@DBUnit(escapePattern = "\"?\"",
	    caseSensitiveTableNames = true,
	    allowEmptyFields = true)
//@DBRider(dataSourceBeanName = "xxx_DataSource") //如果有使用 Spring,可能會需要指定 Spring 設定的 Data Source Name,因為 Database-Rider 會偵測到有 Spring 去抓 Datasource
class DBRiderTest {

	static final String testResourceFolderPath = "";
	static final String backupDatasetResourcePath = "/backupDataset.xlsx";
	static final String testDatasetResourcePath = "/testDataset.xlsx";
	
	static final String jdbcDriverName = "org.postgresql.Driver";
	static final String databaseUrl = "jdbc:postgresql://localhost:5432/xxxDatabase";
	static final String databaseUsername = "username";
	static final String databasePassword = "password";
	
	static final String[] includeTableList = new String[] {"xxxTable1", 
														   "xxxTable2", 
														   "xxxTable3"};
	
	//Database Rider 會用 Java Reflection (反射) 的方式從 connectionHolder
	//中呼叫 getConnection()來得到 Connection
	private static ConnectionHolder connectionHolder = new ConnectionHolder() {

		@Override
		public Connection getConnection() throws SQLException {
			try {
				Class.forName(jdbcDriverName);
			} catch (ClassNotFoundException e) {
				e.printStackTrace();
			}
			
			return DriverManager.getConnection(databaseUrl, databaseUsername, databasePassword);
		}
	};

	@BeforeAll
	static void beforeAll() {
		//在所有 Test Case 執行之前先備份當前要用到的 Database Tables,
		//例如 export 成 XLSX 檔案
		try {			
			URL backupDatasetResourceFolderUrl = DBRiderTest.class.getClassLoader().getResource(testResourceFolderPath);
			File backupDatasetResourceFolder = Paths.get(backupDatasetResourceFolderUrl.toURI()).toFile();
			String backupDatasetResourceFilePath = backupDatasetResourceFolder.getAbsolutePath() + backupDatasetResourcePath.replace("/", File.separator);
			
			DataSetExporter.getInstance().export(connectionHolder.getConnection(),
												 new DataSetExportConfig()
												 	 .dataSetFormat(DataSetFormat.XLSX)
													 //可以設定只要 Export 某些 Table
												 	 .includeTables(includeTableList)
												 	 .queryList(new String[] {})
												 	 .outputFileName(backupDatasetResourceFilePath));
		} catch (Exception e) {
			e.printStackTrace();
		}
	}

	/*
	@BeforeEach
	//在每一次的 Test Case 執行之前把 Test Dataset 檔案內容匯入當下 Database 中    
	@DataSet(value = testResourceFolderPath + testDatasetResourcePath,
    		 //雖然 Database Rider 有提供 executeStatementsBefore 和 executeStatementsAfter 參數,
             //但實測好像不太有效果,
             //因為例如我想在 DataSet 塞入 Database 後執行 PostgreSQL 的 setval() 重設遞增值 等 Sql 語句,
             //但 executeStatementsBefore 的 Sql 會在 Dataset 塞入 Database 之前執行,
             //而 executeStatementsAfter 好像都不會被執行,可能我理解有誤,
             //就算直接在 method 中 (例如 beforeEach() 裡) 執行 Sql 語句,它也會在 DataSet 塞入 Database 之前執行。
			 //所以最後我想如果有需要在 DataSet 塞入 Database 後執行的 Sql,
             //可能要放棄使用 @DataSet 的 Annotation (註解) 方式,
             //改成直接用程式碼直接執行 DataSet 塞入 Database 動作再執行我們要的 Sql 語句,
             //下方示範一個實測可以成功的 beforeEach() 和 afterAll()。
			 //SELECT setval('xxxTable_xxxField_seq', (SELECT coalesce(MAX(xxxField), 0) + 1 FROM xxxTable), false)
			 executeStatementsBefore = {"SELECT setval('xxxTable1_xxxField_seq', (SELECT coalesce(MAX(xxxField), 0) + 1 FROM xxxTable1), false)",
				                        "SELECT setval('xxxTable2_xxxField_seq', (SELECT coalesce(MAX(xxxField), 0) + 1 FROM xxxTable2), false)"})
	void beforeEach_donnoWhyNotWork() throws DatabaseUnitException, SQLException {
	}
    */
    
    @BeforeEach
	static void beforeEach() throws DatabaseUnitException, SQLException {
		try (Connection conn = connectionHolder.getConnection();) {
        	RiderDSL.withConnection(conn)
			    	.withDataSetConfig(new DataSetConfig(testResourceFolderPath + testDatasetResourcePath))
			    	.withDBUnitConfig(new DBUnitConfig()
                                  	  .cacheConnection(false) //這個很重要,因為預設是 true,有可能會造成不預期的意外,例如使用了 cache 的 connection 但不知 connection 可能早被 close 了
					   			  	  .addDBUnitProperty("escapePattern", "\"?\"")
			    				  	  .addDBUnitProperty("caseSensitiveTableNames", true)
			    				  	  .addDBUnitProperty("allowEmptyFields", true))
			    	.createDataSet();
		}
        
		try (Connection conn = connectionHolder.getConnection();
				 PreparedStatement pstmt = conn.prepareStatement("SELECT setval('xxxTable_xxxField_seq', (SELECT coalesce(MAX(xxxField), 0) + 1 FROM xxxTable), false)");) {
		
			try (ResultSet rs = pstmt.executeQuery();) {
			}
		} catch (SQLException e) {
			Assert.fail("Exception occured: " + getExceptionDetail(e));
		}
	}
    
    

	@AfterEach
	void afterEach() {
	}

	@AfterAll
	static void afterAll() throws DatabaseUnitException, SQLException {
    	try (Connection conn = connectionHolder.getConnection();) {
			RiderDSL.withConnection(conn)
			    	.withDataSetConfig(new DataSetConfig(testResourceFolderPath + backupDatasetResourcePath))
			    	.withDBUnitConfig(new DBUnitConfig()
                				  	  .cacheConnection(false)
					   			  	  .addDBUnitProperty("escapePattern", "\"?\"")
			    				  	  .addDBUnitProperty("caseSensitiveTableNames", true)
			    				  	  .addDBUnitProperty("allowEmptyFields", true))
			    	.createDataSet();
		}
        
		try (Connection conn = connectionHolder.getConnection();
				 PreparedStatement pstmt = conn.prepareStatement("SELECT setval('xxxTable_xxxField_seq', (SELECT coalesce(MAX(xxxField), 0) + 1 FROM xxxTable), false)");) {
		
			try (ResultSet rs = pstmt.executeQuery();) {
			}
		} catch (SQLException e) {
			Assert.fail("Exception occured: " + getExceptionDetail(e));
		}
	}
	
	@Test
	void test() {
		//做你想做的 Test
	}
}

參考資料:

  1. Database Rider
  2. GitHub - database-rider/database-rider: Database testing made easy!

使用 DBUnit 進行 Database 測試

錄使用 DBUnit 進行跟 Database 有關的 Unit Test 的方法,
在這裡我使用的是 JUnit 5 版本 + JDK 11。

DBUnit 是一個可以幫助我們對專案資料庫 (Database)  部份進行單元測試的 (Unit Test) 的工具,
在實務上我們通常會希望能以真實的資料庫連線來進行測試 DAO Class,不是只是用 Mock 的方式來模擬資料庫的輸入輸出。

DBUnit 的中心思想就是在做測試之前把當下的 Database 資料輸出成 XML 或 XLS 檔備份起來 (可以用程式輸出、也可以自己手寫編輯製作),
並準備一份測試用的資料檔案 (跟備份的一樣,只是是另一個檔案,當然你要用一樣的也行),
然後在每個測試要執行之前,都先把測試資料寫入 Database 中 (預設通常是會先把 Database 相關的 Table 資料刪掉,可以設定其他行為) 再進行測試,
等最後全部的測試案例都測試完畢後,再把備份的資料寫回 Database 以復原資料。

雖然可以用備份資料復原 Database 的資料,但並不保證會跟之前完全一模一樣,例如可能地增值沒設對、外鍵設定跑掉、資料重寫後有問題跟之前不一樣、資料回復失敗丟失資料等,所以不建議在正式線上環境下使用,只在測試環境下用就好。

以下直接展示使用的測試程式範例:

Maven Dependency 設定範例:

<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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<groupId>hugo</groupId>
	<artifactId>test</artifactId>
	<version>0.0.1-SNAPSHOT</version>

	<properties>
	    <maven.compiler.target>11</maven.compiler.target>
	    <maven.compiler.source>11</maven.compiler.source>
	</properties>

	<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.dbunit/dbunit -->
		<dependency>
		    <groupId>org.dbunit</groupId>
		    <artifactId>dbunit</artifactId>
		    <version>2.8.0</version>
		    <scope>test</scope>
		</dependency>
		
		<!-- DBUnit 輸出 XLS Dataset 需要 4.1.1 (4.1.2 好像也可以) 版本的 poi -->
		<!-- https://mvnrepository.com/artifact/org.apache.poi/poi -->
		<dependency>
		    <groupId>org.apache.poi</groupId>
		    <artifactId>poi</artifactId>
		    <version>4.1.1</version>
		</dependency>
		
		<!-- 此例以 PostgreSQL 為例,所以有使用 PostgreSQL Driver 的 Dependency -->
		<!-- https://mvnrepository.com/artifact/org.postgresql/postgresql -->
		<dependency>
		    <groupId>org.postgresql</groupId>
		    <artifactId>postgresql</artifactId>
		    <version>42.7.1</version>
		</dependency>

	</dependencies>
</project>

單元測試主程式 :

package test;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.file.Paths;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;

import org.dbunit.DatabaseUnitException;
import org.dbunit.database.AmbiguousTableNameException;
import org.dbunit.database.DatabaseConfig;
import org.dbunit.database.DatabaseConnection;
import org.dbunit.database.IDatabaseConnection;
import org.dbunit.database.QueryDataSet;
import org.dbunit.database.search.TablesDependencyHelper;
import org.dbunit.dataset.DataSetException;
import org.dbunit.dataset.IDataSet;
import org.dbunit.dataset.excel.XlsDataSet;
import org.dbunit.dataset.xml.FlatXmlDataSet;
import org.dbunit.dataset.xml.FlatXmlDataSetBuilder;
import org.dbunit.operation.DatabaseOperation;
import org.dbunit.util.search.SearchException;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

class DBUnitTest {
	
	//此例以 PostgreSQL 為例
	static String dbUrl = "jdbc:postgresql://localhost:5432/xxxDatabase";
	static String dbUsername = "username";
	static String dbPasswordString = "password";
	
	static String jdbcDriverName = "org.postgresql.Driver";
	static String testResourceFolderPath = "";
	//此例以 XLS 做為 Dataset file 為例,因為 XML 有特殊字完 \v (\u00B), Surrogate Pair Emoji 等無法處理正確的問題
	static String backupDatasetResourcePath = "/backupDataset.xls";
	static String testDatasetResourcePath = "/testDataset.xls";
	static String backupDatasetResourceFilePath;
	static String testDatasetResourceFilePath;

	static IDatabaseConnection getIDatabaseConnection() {
		IDatabaseConnection iDatabaseConnection = null;
		
		try {
			Class.forName(jdbcDriverName);
			Connection jdbcConnection = DriverManager.getConnection(dbUrl, dbUsername, dbPasswordString);
			iDatabaseConnection = new DatabaseConnection(jdbcConnection);
			
			//這邊我多設定了一條 PROPERTY_ESCAPE_PATTERN 的設定,
	        //它可以讓 DBUnit 在 select database table column 的名字時,用你自訂的 pattern 來做 select,
	        //例如我希望用雙引號包住 column name,例如 desc 是 sql 關鍵字,當成 column name 做 select 是要用雙引號包住,
	        //例子: select "desc" from xxxTable,
	        //這時就可以設定 PROPERTY_ESCAPE_PATTERN 值為 "\"?\""
	        DatabaseConfig databaseConfig = iDatabaseConnection.getConfig();
			databaseConfig.setProperty(DatabaseConfig.PROPERTY_ESCAPE_PATTERN, "\"?\"");
			databaseConfig.setProperty(DatabaseConfig.FEATURE_ALLOW_EMPTY_FIELDS, "true");
		} catch (ClassNotFoundException | SQLException | DatabaseUnitException e) {
			e.printStackTrace();
		}
		
		return iDatabaseConnection;
	}
	
	@BeforeAll
	static void beforeAll() {
		//在所有 Test Case 執行之前先備份當前要用到的 Database Tables,
		//例如 export 成 XLS 檔案
		try {
			URL testDatasetResourceFolderUrl = DBUnitTest.class.getClassLoader().getResource(testResourceFolderPath);
			File testDatasetResourceFolder = Paths.get(testDatasetResourceFolderUrl.toURI()).toFile();
			backupDatasetResourceFilePath = testDatasetResourceFolder.getAbsolutePath() + backupDatasetResourcePath.replace("/", File.separator);
			testDatasetResourceFilePath = testDatasetResourceFolder.getAbsolutePath() + testDatasetResourcePath.replace("/", File.separator);
			
			IDatabaseConnection iDatabaseConnection = getIDatabaseConnection();
			
			// partial database export, 只 export 部份 Table
	        QueryDataSet partialDataSet = new QueryDataSet(iDatabaseConnection);
	        partialDataSet.addTable("xxxTable", "SELECT * FROM xxxTable");
	        //輸出 XLS (Excel 97-2003 的格式)格式的檔案,可以用 Excel 開起來
	        XlsDataSet.write(partialDataSet, new FileOutputStream(backupDatasetResourceFilePath));
	        //也可以 Export XML 格式的檔案
	        //FlatXmlDataSet.write(partialDataSet, new FileOutputStream(backupDatasetResourceFilePath));
	
	        // full database export, 全部 Table 都 export
	        //IDataSet fullDataSet = connection.createDataSet();
	        //XlsDataSet.write(fullDataSet, new FileOutputStream(backupDatasetResourceFilePath));
	        
	        // dependent tables database export: export table X and all tables that
	        // have a PK which is a FK on X, in the right order for insertion
	        // Export 一個 Table 加上所有有指向這 Table 欄位外鍵的其他 Table
	        //String[] depTableNames = TablesDependencyHelper.getAllDependentTables(connection, "xxxTable");
			//IDataSet depDataSet = connection.createDataSet(depTableNames);
	        //XlsDataSet.write(depDataSet, new FileOutputStream(backupDatasetResourceFilePath));
		} catch (DatabaseUnitException | IOException | URISyntaxException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}
	}
	
	@BeforeEach
	void beforeEach() {
		//在每一次的 Test Case 執行之前把 Test Dataset 檔案內容匯入當下 Database 中
		try {
	        IDatabaseConnection iDatabaseConnection = getIDatabaseConnection();
	        
	        IDataSet dataSet = new XlsDataSet(new File(testDatasetResourceFilePath));
			//XML 是用 FlatXmlDataSetBuilder().build() 取得 IDataSet
			//IDataSet dataSet = new FlatXmlDataSetBuilder().build(new File(testDatasetResourceFilePath));
        
			//DatabaseOperation.CLEAN_INSERT 代表會先把 Database 相關 Table 清空再匯入 Test Dataset
			DatabaseOperation.CLEAN_INSERT.execute(iDatabaseConnection, dataSet);
			
			//你可以在這再執行一些需要的前置做業 Sql 語句,
			//例如使用 PostgreSQL 時,DatabaseOperation.CLEAN_INSERT.execute() 會把
			//遞增欄位的遞增值重設成 1,造成之後測試 insert 時發生問題 (id 重覆等之類的),
			//這時就會需要在這做例如如下 Sql 語句來設置正確的遞增值:
			//SELECT setval('xxxTable_xxxField_seq', (SELECT coalesce(MAX(xxxField), 0) + 1 FROM xxxTable), false)
		} catch (DatabaseUnitException | SQLException | IOException e) {
			e.printStackTrace();
		}
	}
	
	@AfterEach
	void afterEach() {
	}
	
	@AfterAll
	static void afterAll() {
		//在所有 Test Case 測試完後,用之前 Export 出來的 Backup Dataset 匯回去到 Database 中
		try {
			IDatabaseConnection iDatabaseConnection = getIDatabaseConnection();
	        
	        IDataSet dataSet = new XlsDataSet(new File(backupDatasetResourceFilePath));
        
			DatabaseOperation.CLEAN_INSERT.execute(iDatabaseConnection, dataSet);
			
			//你可以在這再執行一些需要的前置做業 Sql 語句
		} catch (DatabaseUnitException | SQLException | IOException e) {
			e.printStackTrace();
		}
	}
	
	@Test
	void test() {
		//做你想做的 Test
	}
}

我實測的情況是,目前 DBUnit 所產生的 XML Dataset 有一些問題,

例如如果 Dtabase 內容裡有紀錄包含對 XML 來說是 Invalid Character 的特殊字元的話,
例如:

  • \v :垂直定位(\u000B,在 NotePad++ 中可以用 \013 配合正規表示法的搜尋找到)
  • 一些使用到 Surrogate Pair 的 Emoji (可參考這篇 Code Point, Surrogate Pair 和 HTML Unicode 編碼),例如:👍 (Java 用 "\uD83D\uDC4D" 表示、HTML 用 &#128077;  表示)
這時 XML 就會沒辦法正常使用,也就是 DBUnit 沒辦法倒回 Database 中。
而實測 XLS似乎是目前最沒有問題的格式,雖然不能用記事本軟體打開,但可以用 Excel 打開,還可以接受),不過要導出 XLS 直接用 XlsDataSet.write() 取代 FlatXmlDataSet.write 的話會出現 java.lang.NoClassDefFoundError 錯誤,
參考到這篇文章 在使用dbunit导出数据到Excel中时遇到的问题 - xm3530 - 博客园 也有一樣的狀況,文章中說要自行導入缺失的 lib ,最後需要引入  4.1.1 (4.1.2 好像也可以)的 poi Dependency 解決。

題外話我有找到一個以 DBUnit 為基礎的 Database Rider (官網Github 專案) 專案,
它整合了 DBUnit 和 JUnit ,支援 JUnit 5,並且支持了產出其也格式的 Dataset,
例如: XML, XLS, XLSX, JSON, YML (實測 JSON 也會有例如 \v 特殊字元等的問題,而  YML 如果資料太大會有產出檔案大小的限制)。

我對 Database Rider 做了一些相關的使用紀錄,請參考這篇 使用 Dabase Rider 進行 Database 測試

參考資料:

  1. Getting Started
  2. Core Components
  3. dataset - import/export xml for dbunit - Stack Overflow
  4. 在使用dbunit导出数据到Excel中时遇到的问题 - xm3530 - 博客园

2024年7月17日 星期三

JUnit 5 - 安裝及使用

這裡紀錄下 Java 的 JUnit 5 使用方式,注意 JUnit 5 跟 JUnit 4 有比較大的不同,
例如 Annotation 不同、使用的 asserEquals() 等 method 輸入參數和使用的 package 都有所不同,
詳細可參考 

從 JUnit 4 轉移至 JUnit 5:重要差異與好處. JUnit 5 令人注目的改善與新功能 | by Du Spirit | Java Magazine 翻譯系列 | Medium。

Junit 5 = JUnit Platform + JUnit Jupiter + JUnit Vintage

  • Junit Jupiter : 
    • JUnit 5 的主要 Dependency,內部依賴包括了跟 @Test 注解等相關的 junit-jupiter-api 和跟運行測試相關的 junit-jupiter-engine (其實 junit-jupiter-engine 也依賴了 junit-jupiter-api)
  • Junit Vintage :
    • Junit Vintage 是用來運行舊版的 JUnit 4 用的,如果專案沒有使用到 Junit 4 的話可以不使用,不過如果專案沒有使用 Jnit 4 又加上 Junit Vintage 的 Dependency 的話可能會跟 Junit 5 衝突產生如下錯誤 : "TestEngine with ID 'junit-vintage' failed to discover tests"
  • Junit Platform :
    • junit-platform-runner 是專門給一些只支援 Junit 4 但不支援 Junit 5 的 System 或 IDE 用的, 例如舊版不支援 JUnit 5 的 Eclipse,需要為其加上 junit-platform-runner 這個 dependency 並 搭配 @RunWith(JunitPlatform.class) 後, 對專案按右鍵選 Run As 才會有 Junit Test 的按鈕可選。 <dependency>

在 Maven 的 pom.xml 加上:

<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">
  ......
  
  <build>
    ......
	<!-- 如果不是標準的 maven 專案結構,請在這裡自行設定 test case 的資料夾和相關 test resource 的資料夾 -->
    <testSourceDirectory>test</testSourceDirectory>
    <testResources>
		<testResource>
			<directory>test</directory>
		</testResource>
	</testResources>
	
    <plugins>
	  ......
	  <!-- Maven 執行 test 的 plugin,surefire 多用於單元測試 (unit test), failsafe 多用於整合測試 (integration test)  -->
	  <plugin>
		<artifactId>maven-surefire-plugin</artifactId>
		<version>3.1.2</version>
      </plugin>
      <plugin>
        <artifactId>maven-failsafe-plugin</artifactId>
        <version>3.1.2</version>
      </plugin>
	  ......
    </plugins>
  </build>

  <dependencyManagement>
	  <dependencies>
	      ......
		  <!-- 可以在這統一設定 junit 相關 dependency 的 version,之後後面就不用特別指定 version -->
		  <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 -->
	<!-- JUnit 5 的主要 Dependency,內部依賴包括了跟 @Test 注解等相關的 junit-jupiter-api 和跟運行測試相關的 junit-jupiter-engine (其實 junit-jupiter-engine 也依賴了 junit-jupiter-api) -->
	<dependency>
	    <groupId>org.junit.jupiter</groupId>
	    <artifactId>junit-jupiter</artifactId>
	    <scope>test</scope>
	</dependency>
	
	<!-- https://mvnrepository.com/artifact/junit/junit -->
	<!-- 這是舊版的 JUnit 4 (JUnit 5 以前),可以不使用,不過如果舊專案有用也可以留著
	<dependency>
	    <groupId>junit</groupId>
	    <artifactId>junit</artifactId>
	    <version>4.13</version>
	    <scope>test</scope>
	</dependency>
	-->
	
	<!-- https://mvnrepository.com/artifact/org.junit.vintage/junit-vintage-engine -->
	<!-- junit-vintage-engine 是用來運行舊版的 JUnit 4 用的,可以不使用,不過如果舊專案有用也可以留著,
    如果沒有使用 Jnit 4 的話可能會跟 Junit 5 衝突產生如下錯誤
    TestEngine with ID 'junit-vintage' failed to discover tests
	<dependency>
	    <groupId>org.junit.vintage</groupId>
	    <artifactId>junit-vintage-engine</artifactId>
	    <scope>test</scope>
	</dependency>
	-->
    
    <!-- https://mvnrepository.com/artifact/org.junit.platform/junit-platform-runner -->
	<!-- junit-platform-runner 是專門給一些只支援 Junit 4 但不支援 Junit 5 的 System 或 IDE 用的,
            例如舊版不支援 JUnit 5 的 Eclipse,需要為其加上 junit-platform-runner 這個 dependency 並
            搭配 @RunWith(JunitPlatform.class) 後,
            對專案按右鍵選 Run As 才會有 Junit Test 的按鈕可選。
	<dependency>
		<groupId>org.junit.platform</groupId>
		<artifactId>junit-platform-runner</artifactId>
		<scope>test</scope>
	</dependency>
	-->
	......
  </dependencies>  
</project>

測示範例:

package test.dao;

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;



class DaoTest {
	
	@BeforeAll
	static void beforeAll() {
		System.out.println("beforeAll");
	}
	
	@BeforeEach
	void beforeEach() {
		System.out.println("beforeEach");
	}
	
	@Test
	@DisplayName("test1")
	void test1() {
		System.out.println("test1");
		assertEquals(1, 1, "OK");
	}
	
	@Test
	@DisplayName("test2")
	void test2() {
		System.out.println("test2");
		assertEquals(1, 1, "OK");
	}
	
	@AfterEach
	void afterEach() {
		System.out.println("afterEach");
	}
	
	@AfterAll
	static void afterAll() {
		System.out.println("afterAll");
	}
}

上述執行的順序會是如下,@BeforeAll 會在所有 @Test 之前執行、@AfterAll 則是在所有 @Test 之後執行、@BeforeEach 會在每一個 @Test 之前執行、@AfterEach 會在每一個 @Test 之後執行:

  1. beforeAll
  2. beforeEach
  3. test1
  4. afterEach
  5. beforeEach
  6. test2
  7. afterEach
  8. afterAll

參考資料:

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

Angular 語法、功能、工具、指令等紀錄

此篇文使用的各工具版本為:

  • Angular CLI: 18.0.1
  • Node: 20.13.1
  • Package Manager: npm 10.5.2

Angular-CLI:

好用的 Angular 官方工具,可用 node.js 使用

npm install -g @angular/cli

安裝。

以下介紹下一些指令:

檢查 Angular-CLI, Angular 等版本:

ng version

ng new 指令可用來建立初始 Angular 專案檔案結構:
ng new {專案名稱}


ng serve 指令可以開啟預設的 http://localhost:4200 簡易 server 來測試網頁

ng serve


ng generate 指令可建立各種 Angular 元件,例如 Component, Directive 等,例:ng generate component {component 名稱}

ng generate component {component 名稱}
ng generate directive {directive 名稱}
ng generate service {service 名稱}

編譯手包佈署時要用的最終程式,詳細參數可參考 ng build • Angular

ng build
-------------------------------------------------------------------------------------------------------------

Standalone Component:
Angular 14 後推出 Standalone Component (也包括 Standalone Directive 等)
並主推它們,
之後使用 Angular-CLI 建立元件時會預設使用 Standalone。

使用 Standalone Component 的好處是其不須依附於 NgModule 上。

以往我們會使用 NgModule 來管理各個 Component, Directive 等,
但在例如 Component 的 HTML 中如果使用了某另一個 Component 時,
我們會比較難找到是用了哪一個 Component,例如:

AppComponent 的 Component Class:

import { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {  
}

AppComponent 的 HTML:

<xxx-component></xxx-component>
其中光看 AppComponent 我們無法馬上看出 <xxx-component> 到底是哪個 component,
這代表我們要去找到 AppCompoment 所屬的的 NgModule ,
去看看 app.module.ts 中 declarations 了哪些 Component,
然後一個個去看那些 Component 有哪個的 selector 是 xxx-comonent,
例:
main.ts:
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';

bootstrapApplication(AppComponent, appConfig)
  .catch((err) => console.error(err));

app.module.ts:
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { AppComponent } from './app.component';
import { OtherComponent1 } from './other1.component';
import { OtherComponent2 } from './other2.component';
// 可能更多 .........

@NgModule({
  declarations: [
    AppComponent,
    OtherComponent1,
    OtherComponent2
	// 可能更多 .........
  ],
  imports: [
    BrowserModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

但如果使用 Standalone Component,我們就不須要 NgModule、不需要 app.module.ts,
我們可以直接把 Standalone Component 常成根元件而不是 NgModule。

而使用 Standalone Component 的 Component 必須顯示的 import Standalone Component 進來,
例如假設 <xxx-component> 的 Component 是一個 Standalone Component,
,其 Class 長得像這樣,注意到 @Component 修飾子裡有加上 standalone: true:

import { Component } from '@angular/core';

@Component({
  selector: 'xxx-component',
  standalone: true,
  imports: [],
  templateUrl: './app.xxx-component.html',
  styleUrl: './app.xxx-component.scss'
})
export class AppXxxComponent {
  
}

上例 AppComponent 的 Component Class 就要改寫成:

import { Component } from '@angular/core';
import { XxxComponent } from './xxx-component.component';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [XxxComponent],
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {  
}

可以看到必須 imports XxxComponent 才能使用 XxxComponent 這個 Standalone Component,
這時我們就可以很清楚地知道 AppComonent 使用了 XxxComponent,
不用再辛苦地去找 XxxComponent 到底在哪裡。

另一個可以注意到的是 AppComponent 也加了 standalone: true,
所以 AppComponent 也是一個 Standalone Component,
我們就可以把 AppComponent 直接當作 Root Component 設定給 main.ts,像這樣:

import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';

bootstrapApplication(AppComponent, appConfig)
  .catch((err) => console.error(err));

-------------------------------------------------------------------------------------------------------------
Interpolations (插值、或稱內嵌):
使用雙重大括弧 {{}} 來設定 Interpolations,以顯示 component 裡的 data,
例:
HTML:

{{myData}} 

-------------------------------------------------------------------------------------------------------------

Property Binding, Attribute Binding, Class and Style Binding:
這三種 Binding 都是使用中括弧來設定,且都是單向綁定,
當 HTML DOM 上的值被改變時,不會影響到 Component 中對應物件的值。
例如 <input type="text" [value]="inputValue"/> 的 Value 被使用者在畫面中修改後,
Component 中的 inputValue 物件值並不會被改變。

下面各別介紹:

Property Binding:
綁定 DOM 的 Property,注意跟 Attribute 不同,例如 <td> 的跨行 Property 是 colSpan,而對應的 Attribute 是 colspan。

範例:

import { Component } from '@angular/core';

@Component({
  selector: 'app-xxx',
  standalone: true,
  imports: [],
  template: `
              <a [href]="link">Link</a>
              <img [src]="imgSrc"/>
              <input type="text" [value]="inputValue"/>
              <table>
                  <tr>
                      <td [colSpan]="1+1">xxx</td>
                  </tr>
                  <tr>
                      <td>yyy</td>
                      <td>yyy</td>
                  </tr>
              </table>
            `,
  styleUrl: './xxx.component.scss'
})
export class XxxComponent {
  link = "https://www.cyberlink.com";
  imgSrc = "https://dl-file.cyberlink.com/web/stat/edms/prog/bar/img/Cyberlink.svg";
  inputValue = "xxx";
}

Attribute Binding:
綁定 Attribute,用 [attr.{要設定的 Attribute Name}] 來表示,可以跟上面 Property Binding 做比較,這邊要用 Attribute 的 colspan 而不是 Property 的 colSpan。

範例:

import { Component } from '@angular/core';

@Component({
  selector: 'app-xxx',
  standalone: true,
  imports: [],
  template: `
              <div [attr.aria-label]="ariaLabel">xxx</div>
              <table>
                  <tr>
                      <td [attr.colspan]="1+1">xxx</td>
                  </tr>
                  <tr>
                      <td>yyy</td>
                      <td>yyy</td>
                  </tr>
              </table>
            `,
  styleUrl: './xxx.component.scss'
})
export class XxxComponent {
  ariaLabel = "xxx";
}

Class and Style Binding:
綁定 Class Name 和 Style,可以接受多種輸入,詳細可以參考官方文件 Class and style binding • Angular
例如單個 Class Name 可以接受 Boolean 值做輸入、
多個 Class Name 可以用以空格分隔的 Class List String 或 Map (Key 代表 Class Name,Value 為 Boolean 值,代表要不要設定這個 Class)。

單個 Style 可以接受 String,
多個 Style 可以接受 inline css 的 String
或是 Map (Key 要用給 Javascript 用的 Style Property Name,不可以用給 CSS StyeSheet 用的 Property Name)

範例:

import { Component } from '@angular/core';

@Component({
  selector: 'app-xxx',
  standalone: true,
  imports: [],
  //templateUrl: './xxx.component.html',
  template: `
              <div [class.xxx-class-name]="isAddXxxClass">XXX</div>
              <div [class]="classListStr">XXX</div>
              <div [class]="classList">XXX</div>
              <div [class]="classMap">XXX</div>

              <div [style.background-color]="backgroundColor">xxx</div>
              <div [style.backgroundColor]="backgroundColor">xxx</div>
              <div [style]="styleListStr">xxx</div>
              <div [style]="styleMap">xxx</div>
            `,
  styleUrl: './xxx.component.scss'
})
export class XxxComponent {
  isAddXxxClass = true;
  classListStr = "class-1 class-2";
  classList = ["class-1", "class-2"];
  classMap = {
    "class-1" : true,
    "class-2" : false
  }

  backgroundColor = "#ff00ff";
  styleListStr = "background-color: #ff00ff; font-size: 20px;";
  styleMap = {
    backgroundColor : "#ff00ff",
    fontSize : "20px"
  };
}

-------------------------------------------------------------------------------------------------------------

Event Binding (事件綁定):
使用小括弧 () 來設定 Event Binding,類似於 AngularJS 的 ng-{Event 名} (ng-click, ng-change, etc....),
例:

import { Component } from '@angular/core';

@Component({
  selector: 'app-xxx',
  standalone: true,
  imports: [],
  template: `<input type="text" (input)="onInput($event)"/>
             <button (click)="onClick()">Button</button>`,
  styleUrl: './xxx.component.scss'
})
export class XxxComponent {
  onInput(event: Event) {
    console.log("Inpute Event.");
    console.log((event.currentTarget as HTMLInputElement).value);
  }

  onClick() {
    console.log("Click Event.");
  }
}

-------------------------------------------------------------------------------------------------------------

Two Way Data Binding (雙向綁定):
可參考官方文件 Two-way binding • Angular 和 Directives • Overview - ngModel • Angular

先來看比較原生的實作方式會比較好理解其中的原理。

比如我們現在有兩個 Component,Parent 和 Child,
ParentComponent 裡面使用了 ChildComponent,
ParentComponent 把一個 Property 的值輸入至 ChildComponent 中,
ChildComponent 會在值被改變時主動用 EventEmitter 送出 Event 出來給 ParentComponent,
ParentComponent 收到後就可以做相應的處理,
而 [(xxx)]="yyy"  語法是 [xxx]="yyy" (xxxChange)="yyy = $event;"
的合併簡易寫法。

範例:

Parent Component :

import { Component } from '@angular/core';
import { ChildComponent } from '../child/child.component';

@Component({
  selector: 'app-parent',
  standalone: true,
  imports: [ChildComponent],
  template: `
    <app-child [xxx]="inputValue" (xxxChange)="inputValue = $event;"></app-child>
    <app-child [(xxx)]="inputValue"></app-child>
  `,
  styleUrl: './parent.component.scss'
})
export class ParentComponent {
  inputValue = "";
}

Child Component :

import { Component, EventEmitter, Input, Output } from '@angular/core';

@Component({
  selector: 'app-child',
  standalone: true,
  imports: [],
  template: `
    <input type="text" [value]="xxx" (input)="onInput($event)"/>
  `,
  styleUrl: './child.component.scss'
})
export class ChildComponent {
  @Input() xxx!: string;
  @Output() xxxChange = new EventEmitter<string>();

  onInput(event: Event) {
    var value = (<HTMLInputElement>event.currentTarget).value;
    this.xxxChange.emit(value);
  }
}

實作上通常會使用 ngModel 來幫忙,跟上述一樣可以用非簡易跟簡易的兩種作法:

  1. 非簡易作法:比較麻煩的作法,但有時想在賦值前做一些處理時可用到,
    例如先把使用者在<input>中輸入的值改成全大寫後再賦值給 Component 的 Property 值。
    因為有用到 ngModel (其中寫好了 event 向外傳遞的過程等),要 Import FormModule 才能使用,
    先把 Component 的 Property 輸入給 ngModel 這個 Angular 內建的 Directive,
    ngModel 偵測值的改變,當值發生改變時用 EventEmitter 把值用 EventEmitter 向外送出一個 ngModelChange 的 Event 給外層 Component,
    然後外層 Component 接受到 ngModelChange 事件後,可以由 $event 得到改變後的值,
    這時我們就可以執行我們要的操作。

    範例:
    import { Component } from '@angular/core';
    import { FormsModule } from '@angular/forms';
    
    @Component({
      selector: 'app-xxx',
      standalone: true,
      imports: [FormsModule],
      //templateUrl: './xxx.component.html',
      template: `
            <input type="text" [ngModel]="inputValue" (ngModelChange)="inputValue = $event;"/> {{inputValue}}
      `,
      styleUrl: './xxx.component.scss'
    })
    export class XxxComponent {
      inputValue = "xxx";
    }
  2. 簡易作法:比較簡潔的作法,並且也是官方的推薦雙向綁定寫法,
    使用 Angular 提供的 Banana in Box 語法,用 [(ngModel)]="xxx" 來實現,
    是上一個麻煩寫法的簡易寫法,一樣有用到 ngModel,所以要 import FormModule。

    範例:
    import { Component } from '@angular/core';
    import { FormsModule } from '@angular/forms';
    
    @Component({
      selector: 'app-xxx',
      standalone: true,
      imports: [FormsModule],
      //templateUrl: './xxx.component.html',
      template: `
            <input type="text" [(ngModel)]="inputValue"/>
      `,
      styleUrl: './xxx.component.scss'
    })
    export class XxxComponent {
      inputValue = "xxx";
    }
    
    

-------------------------------------------------------------------------------------------------------------
Template Reference Variable (樣板參考變數):
使用 # 來標識 Template Reference Variable,
就可以在 Component Class 中建立一個指向特定對像的變數,根據標識的地方可以指向不同的對像,例如:

  1. 如果在 Component (元件)上聲明變數,該變數就會引用該組件實例。
  2. 如果在標準的 HTML DOM 標記上聲明變數,該變數就會引用該元素。
  3. 如果你在 <ng-template> 元素上聲明變數,該變數就會引用一個 TemplateRef 實例來代表此樣板。
  4. 如果該變數在右側指定了一個名字,比如 #var="ngForm",那麼該變數就會指向標識的元素上具有這個 exportAs 名字的 Directive 或 Component。
    可以參考 [Angular 大師之路] exportAs - 取得 directive 實體的方法 | 全端開發人員天梯
    例如一個 Directive 可以設定 exportAs 來讓 Host DOM 所處的 Component 可以存取 Directive Instnace。
    範例:
    import { Component } from '@angular/core';
    import { FormsModule } from '@angular/forms';
    import { Directive, HostBinding } from '@angular/core';
    
    @Directive({
      selector: '[xxx-directive]',
      standalone: true,
      exportAs: "xxxDirective"
    })
    export class MyDirectiveDirective {
      //會為此 Directive 依附的 DOM 加上 background-color style
      @HostBinding("style.backgroundColor") backgroundColor = "#00FF00";
    
      constructor() { }
    
      sayHello() {
        console.log("Hello!");
      }
    }
    
    @Component({
      selector: 'app-xxx',
      standalone: true,
      imports: [FormsModule, MyDirectiveDirective],
      template: `
        <div xxx-directive #abc="xxxDirective" (click)="abc.sayHello()">XYZ</div>
      `,
      styleUrl: './xxx.component.scss'
    })
    export class XxxComponent {
    }

 詳細可參考 Angular - 理解樣板變數

例下面的範例可以在 <button> 被按下時改變 #myTemplateVariable 標識的 <p> 的 innerHTML:

<p #myTemplateVariable>xxxxx ......</p>
<button (click)="myTemplateVariable.innerHTML = 'xxxxx clicked'">click me</button>

下面這個範例 Component 有 Import Form Module,
而其中的 ngForm 這個 Directive 因為有設定 <form> 為 selector 的關係,
所以 <form> 上會存在 ngForm 這個 Directive ,
而因為 ngForm Directive 本身有設定 exportAs: "ngForm" 的關係,
所以使用 #myForm="ngForm" 就可以指向在 <form> 上面的 ngForm Directive,進而取得 valid 等參數或去操作 form。

#firstName 和 #lastName 分別指向其上的 ngModel

** 注意:如果沒寫 "ngForm" ,只寫了 #myForm 的話,myForm 就會變成只指向 <form> 這個 HTML element DOM:

<form #myForm="ngForm">
    <div>First Name : <input type="text" name="firstName" [(ngModel)]="formData.firstName" #firstName="ngModel" required/></div>
    <div style="color: #ff0000;" [hidden]="firstName.valid || firstName.pristine">First name is required!</div>
    <div>Last Name : <input type="text" name="lastName" [(ngModel)]="formData.lastName" #lastName="ngModel" required/></div>
    <div style="color: #ff0000;" [hidden]="lastName.valid || lastName.pristine">Last name is required!</div>
    <div>Is From valid: {{myForm.form.valid}}</div>
</form>
-------------------------------------------------------------------------------------------------------------
Directives:
可用 Angluar CLI 的
ng generate directive {Directive 名稱}

分成
Attribute Directive (可參考 Attribute directives • Angular)
Structural Directive (可參考 Structural directives • Angular)

Directive 跟 Component 不一樣,沒有它自己的 template ,
主要是依附在其他的 Componenet 或者是 DOM 上,
selector 就是 css selector,可以指定 class, attribute 等,
通常是用來對被依附的 DOM 進行各項操作,
例如加新的 class 之類的,
這裡示範下 Attribute Directive 的用法。
(Angular 內建的 *ngIf, *ngFor 也是一種 Directive ,不過是屬於 Structural Directives)。

範例:
import { Component, Directive, ElementRef, HostBinding, HostListener, Input  } from '@angular/core';

@Directive({
  selector: '[xxx-directive]',
  standalone: true,
  exportAs: "xxxDirective"
})
export class XxxDirective {
  //使用 @HostBinding 設定一開始 color 的範例
  //可以跟 @Input 一起用,此例讓 defaultColor 可以接受來自 Host 來的值
  @HostBinding("style.color") @Input() defaultColor = "#FF0000";

  //可以用 ElementRef.nativeElement 來取得宿主 (Host) DOM 的 HtmlElement 物件
  constructor(private elementRef: ElementRef) {//
  }

  //可以使用 @HostListener 來設定宿主 DOM 的 EventBinding
  @HostListener("mouseenter") onMouseenter() {
    this.elementRef.nativeElement.style.backgroundColor = "#00FF00";
  }

  @HostListener("mouseleave") onMouseleave() {
    this.elementRef.nativeElement.style.backgroundColor = "";
  }

  sayHello() {
    console.log("Hello!");
  }
}

@Component({
  selector: 'app-xxx',
  standalone: true,
  imports: [XxxDirective],//
  //templateUrl: './xxx.component.html',
  template: `
    <div xxx-directive defaultColor="#0000FF" #xxxDir="xxxDirective" (click)="xxxDir.sayHello()">XYZ</div>
  `,
  styleUrl: './xxx.component.scss'
})
export class XxxComponent {
}

這裡示範 Structural Directive,例如 *ngIf 和 *ngFor ,
要注意必須先 import CommonModule (或是各別 import NgIf 和 NgFor) 才能正常使用。
Structural Directive 跟 Attribute Directive 不一樣的地方是它主要會改變 DOM 結構,
實作使用 ng-template 配合 Directive 來達成,而 *ngIf, *ngFor 這種寫法是 Angular 的語法糖,
自己也可以自己實作客製的 Structural Directive,詳細可參考 Structural directives • Angular 和 [Angular 大師之路] 自己的樣板語法自己做 (Structural Directives) | 全端開發人員天梯

Component Class:

import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'app-my-component',
  standalone: true,
  imports: [CommonModule],
  templateUrl: './my-component.component.html',
  styleUrl: './my-component.component.scss'
})
export class MyComponentComponent {
  isShow: boolean = true;
  myItemList: {id: number, name: string}[] = [{
    id: 1,
    name: "11"
  },{
    id: 2,
    name: "22"
  },{
    id: 3,
    name: "33"
  }];
}

my-component.component.html:

<div *ngIf="isShow">*ngIf test</div>

<li *ngFor="let myItem of myItemList">
    {{myItem.id}} : {{myItem.name}}
</li>

-------------------------------------------------------------------------------------------------------------
@-Syntax for Control Flow:
Angular 17 推出了 @-Syntax for Control Flow,
可以以更底層、不使用 Directive 的方式做出一樣的功能,
而且不用依附在 DOM 上面,以下示範介紹 @if, @else if, @else, @for, @switch 等:

Component Class:

import { Component } from '@angular/core';

@Component({
  selector: 'app-xxx',
  standalone: true,
  imports: [],
  templateUrl: './xxx.component.html',
  styleUrl: './xxx.component.scss'
})
export class XxxComponent {
  condition1: boolean = true;
  condition2: boolean = false;

  str = "A";
  strA = "A";
  strB = "B";

  myItemList: {id: number, name: string}[] = [{
    id: 1,
    name: "11"
  },{
    id: 2,
    name: "22"
  },{
    id: 3,
    name: "33"
  }];
}

my-component.component.html:

@if (condition1) {
    <p>condition 1</p>
}
@else if (condition2) {
    <p>condition 2</p>
} @else {
    <p>other conditions</p>
}

@switch (str) {
    @case (strA) {
        <p>A</p>
    }
    @case (strB) {
        <p>B</p>
    }
    @default {
        <p>other</p>
    }
}

@for (myItem of myItemList; track myItem.id) {
    <li>{{myItem.id}} : {{myItem.name}}</li>
}

-------------------------------------------------------------------------------------------------------------
@Input:
當一個 Parent Component 使用了另一個 Child Component,
並且 Parent Component 想傳遞某個值進 Child Component,
並且想要在 Child Component 中顯示、操作修改這個值時,
我們就要在 Child Component 中使用 @Input 來告訴 Angular 這個值是從外部傳入的。

例如:

my-component.component.ts (我們的 Parent Component Class):

import { Component } from '@angular/core';
import { SubComponentComponent } from '../sub-component/sub-component.component';

@Component({
  selector: 'app-my-component',
  standalone: true,
  imports: [SubComponentComponent],
  templateUrl: './my-component.component.html',
  styleUrl: './my-component.component.scss'
})
export class MyComponentComponent {
  myItemList: {id: number, name: string}[] = [{
    id: 1,
    name: "11"
  },{
    id: 2,
    name: "22"
  },{
    id: 3,
    name: "33"
  }];

  selectedMyItem = this.myItemList[1];
}
my-component.component.html:
<app-sub-component [item]="selectedMyItem"></app-sub-component>
sub-component.component.ts (我們的 Child Component Class):
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-sub-component',
  standalone: true,
  imports: [CommonModule, FormsModule],
  templateUrl: './sub-component.component.html',
  styleUrl: './sub-component.component.scss'
})
export class SubComponentComponent {
  @Input() item?: {id: number, name: string};
}
sub-component.component.html:
<div *ngIf="item">
    Id: <input type="text" [(ngModel)]="item.id"/><br/>
    Name: <input type="text" [(ngModel)]="item.name"/>
</div>

這邊要注意一下,Child Component 的 item 在 Angular 一開始建構各元件時,
可能會是 undefined 的,所以我們要用 *ngIf (或@if) 來設定有 item 值時才顯示 item,
不然 typescript 編譯時會有
NG2: Object is possibly 'undefined'.
錯誤。

-------------------------------------------------------------------------------------------------------------
@Output:
@Input 的用途是讓 Parent Component 向 Child Component 輸入資料,
而 @Output 剛好相反,是用來讓 Child Component 向 Parent Component 送出資料,
在前面 Two-way Binding 的範例有用到,這邊再貼一次,
主要就是用 @Output 修飾 Child Component 的 EventEmitter property,
然後 Child Component 主動地用 EventEmitter 將資料以 Event 的形式向 Parent Component 送,
Parent Component 就可以用 Event Binding 的方式接收資料。

範例:

Parent Component :

import { Component } from '@angular/core';
import { ChildComponent } from '../child/child.component';

@Component({
  selector: 'app-parent',
  standalone: true,
  imports: [ChildComponent],
  template: `
    <app-child [xxx]="inputValue" (xxxChange)="inputValue = $event;"></app-child>
    <app-child [(xxx)]="inputValue"></app-child>
  `,
  styleUrl: './parent.component.scss'
})
export class ParentComponent {
  inputValue = "";
}

Child Component :

import { Component, EventEmitter, Input, Output } from '@angular/core';

@Component({
  selector: 'app-child',
  standalone: true,
  imports: [],
  template: `
    <input type="text" [value]="xxx" (input)="onInput($event)"/>
  `,
  styleUrl: './child.component.scss'
})
export class ChildComponent {
  @Input() xxx!: string;
  @Output() xxxChange = new EventEmitter<string>();

  onInput(event: Event) {
    var value = (<HTMLInputElement>event.currentTarget).value;
    this.xxxChange.emit(value);
  }
}
-------------------------------------------------------------------------------------------------------------
Service (服務):
可以用 Angular-CLI 的以下指令建立:
ng generate service {Service 名稱}

Service 會使用 @Injectable 的 Annotation (註解) 來設定,
我們可以把 Service 注入到多個不同的 Component Constructre function 中,
這樣就可以讓多個 Component 共用同個 Service 來共享資訊、
或者把資料的取得移到 Service 裡來讓 Component 可以專注在自己的其他邏輯功能上。

範例:

my-service.service.ts (我們建立的 Service) :
import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root'
})
export class MyServiceService {

  constructor() { }

  getDataList(): {id: number, name: string}[] {
    return [{
      id: 1,
      name: "11"
    },{
      id: 2,
      name: "22"
    },{
      id: 3,
      name: "33"
    }];
  }
}
Component Class (使用 Service 取得資料):
import { Component, OnInit } from '@angular/core';
import { MyServiceService } from '../service/my-service.service';

@Component({
  selector: 'app-my-component',
  standalone: true,
  imports: [],
  templateUrl: './my-component.component.html',
  styleUrl: './my-component.component.scss'
})
export class MyComponentComponent implements OnInit {
  myItemList: {id: number, name: string}[] = [];
  selectedMyItem?:{id: number, name: string};

  constructor(private myService: MyServiceService) {
  }

  ngOnInit(): void {
    this.myItemList = this.myService.getDataList();
    this.selectedMyItem = this.myItemList[1];
  }
}
-------------------------------------------------------------------------------------------------------------
Observable (可觀察):
Angular 有很多異步操作 (非同步操作 或稱 Asynchronous) 都利用到了
RxJs 的 Observable ,
其類似於 Javascript 的 Promise、 JQuery 的 Deferred, Promise 或 AngularJS 的 $q, promise,
可以處理 Asynchronous 非同步的動作,例如非同步的 HttpGet 資料取得,
將資料包裹在 Observable 物件中,
然後我們就可以用 Observable 的 subscribe() 來非同步的處理動作。
範例:

my-component.component.ts :
import { Component, OnInit } from '@angular/core';
import { MyServiceService } from '../service/my-service.service';

@Component({
  selector: 'app-my-component',
  standalone: true,
  imports: [],
  templateUrl: './my-component.component.html',
  styleUrl: './my-component.component.scss'
})
export class MyComponentComponent implements OnInit {
  myItemList: {id: number, name: string}[] = [];
  selectedMyItem?:{id: number, name: string};

  constructor(private myService: MyServiceService) {
  }

  ngOnInit(): void {
    this.myService.getDataListAsync().subscribe((dataList: {id: number, name: string}[]) => {
      this.myItemList = dataList;
      this.selectedMyItem = this.myItemList[1];
    });
  }
}

my-service.service.ts:
import { Injectable } from '@angular/core';
import { Observable, delay, of} from 'rxjs';

@Injectable({
  providedIn: 'root'
})
export class MyServiceService {

  constructor() { }
  
  getDataListAsync(): Observable<{id: number, name: string}[]> {
    let dataList: {id: number, name: string}[] = [{
      id: 1,
      name: "11"
    },{
      id: 2,
      name: "22"
    },{
      id: 3,
      name: "33"
    }];

    return of(dataList).pipe(delay(5000)); //模擬網路傳輸,延遲 5 秒再送出資料
  }
}

-------------------------------------------------------------------------------------------------------------
Routing (路由):
Routing (路由) 可以指定不同的 url 去載入不同的 Component ,作用類似於 AngularJS 的 <ui-view>, $urlRouterProvider, $stateProvider 那些,下面示範:

main.ts (程式主要進入點):
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';

bootstrapApplication(AppComponent, appConfig)
  .catch((err) => console.error(err));
在 main.ts 中,可以看到 bootstrapApplication() 設定了一個作為 root component 的 AppComponent 和一個 config 設定用的 appConfig,我們來看一下 appConfig 的內容:

app.config.ts :
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';

import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes)]
};
app.config.ts 把一個實作了 ApplicationConfig interface export 出來,用到了 provideRouter(routes),其中 routes 就是我們要來設定 Route Rule (路由規則) 的地方,先來看一下 app.routes.ts 的內容。

app.routes.ts (路由規則) :
import { Routes } from '@angular/router';
import { MyComponentComponent } from './my-component/my-component.component';
import { MyComponent2Component } from './my-component-2/my-component-2.component';

export const routes: Routes = [
    {
        path: "my-component",
        component: MyComponentComponent
    }, {
        path: "my-component-2",
        component: MyComponent2Component
    }, {
        path: "",
        redirectTo: "my-component",
        pathMatch: "full"
    }
];
這裡我們設定了預設會轉到的 path 是 /my-component,然後當 path 是 /my-component 時使用 MyComponentComponent 這個 Component,當 path 是 /my-component-2 時使用 MyComponent2Component。

我們先看一下 MyComponentComponent 和 MyComponent2Component 的內容。

my-component.component.ts (就是 MyComponent):
import { Component } from '@angular/core';

@Component({
  selector: 'app-my-component',
  standalone: true,
  imports: [],
  template: `<p>my-component works!</p>`,
  styleUrl: './my-component.component.scss'
})
export class MyComponentComponent {

}
my-component-2.component.ts (就是 MyComponent2Component ) :
import { Component } from '@angular/core';

@Component({
  selector: 'app-my-component-2',
  standalone: true,
  imports: [],
  template: `<p>my-component-2 works!</p>`,
  styleUrl: './my-component-2.component.scss'
})
export class MyComponent2Component {

}
兩者的內容都一樣,只是在 html template 上印出不同句子以示區別而已。

接著最後就是作為 root 的 AppComponent

app.component.ts (就是 AppComponent,為了) :
import { Component } from '@angular/core';
import { RouterLink, RouterOutlet } from '@angular/router';
import {MyComponentComponent} from './my-component/my-component.component';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [RouterOutlet, RouterLink, MyComponentComponent],
  //templateUrl: './app.component.html', //也可以把 html 寫在另一個 html 檔裡
  template: `
              <div><a routerLink="/my-component">My Component</a></div>
              <div><a routerLink="/my-component-2">My Component 2</a></div>
              <router-outlet></router-outlet>
            `,
  styleUrl: './app.component.scss'
})
export class AppComponent {
  title = 'my-angular-application';
}

在 AppComponent 中,因為此例使用了 routerLink 和 <router-outlet> ,所以我們要  import RouterLink 和 RouterOutlet 進來。

routerLink 可以作為 DOM 的屬性來設定對應到 Route 規則的 path 到 <a> 上,<a> 會被賦予相應的 href,當 <a> 被按下時,就會改變瀏覽器網址列的網址,
而當網址符合設定的 Route 規則時,
<router-outlet> 就會載入相應的 Component。

-------------------------------------------------------------------------------------------------------------

使用 ActivatedRoute 讀取 url path 中的參數:
使用 ActivatedRoute 可以讀我們方便的讀取 url path 中的參數,
例如如果我們有 Route 規則設定了如下:

app.routes.ts:

import { Routes } from '@angular/router';
import { MyComponentComponent } from './my-component/my-component.component';
import { MyComponent2Component } from './my-component-2/my-component-2.component';

export const routes: Routes = [
    {
        path: "my-component/:id",
        component: MyComponentComponent
    }, {
        path: "my-component-2",
        component: MyComponent2Component
    }, {
        path: "",
        redirectTo: "my-component/1",
        pathMatch: "full"
    }
];

path 中的冒號(:)表示 :id 是一個佔位符,它符合像 /my-component/1, /my-component/2 等這樣的 path。

接著在 MyComponent 中我們可以如下地使用 ActivateRoute.snapshot.paramMap.get("id") 取出 :id 佔位符的值,如 /my-component/1 這個 path 的 id 就會是 1、/my-component/2 就是 2。

my-component.component.ts :

import { Component } from '@angular/core';
import { ActivatedRoute } from '@angular/router';

@Component({
  selector: 'app-my-component',
  standalone: true,
  imports: [],
  template: `<p>my-component works!</p>`,
  styleUrl: './my-component.component.scss'
})
export class MyComponentComponent {

  constructor(private route: ActivatedRoute) {

  }

  ngOnInit(): void {
    console.log(this.route.snapshot.paramMap.get("id")); // 印出 url path 中 :id 佔位符的實際值
  }
}

-------------------------------------------------------------------------------------------------------------

使用 Location 可以操作一些跟瀏覽器相關的操作,例如回到上一頁 (跟直接連到上一頁的網址不同,會直接影響瀏覽歷史紀錄,就跟瀏覽器的上一頁操作一樣),一樣以 MyComponent 為例,下例的 goBack() 函式如果被呼叫的話就可以回到上一頁:

my-component.component.ts :

import { Component } from '@angular/core';
import { Location } from '@angular/common';

@Component({
  selector: 'app-my-component',
  standalone: true,
  imports: [],
  template: `<p>my-component works!</p>`,
  styleUrl: './my-component.component.scss'
})
export class MyComponentComponent {

  constructor(private location: Location) {

  }

  goBack(): void {
    this.location.back();
  }
}

-------------------------------------------------------------------------------------------------------------

HttpClient:

Angular 提供了 HttpClient 工具來幫助進行 HTTP 呼叫,以 MyComponent 為例:

my-component.component.ts:

import { HttpClient } from '@angular/common/http';
import { Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs';

@Component({
  selector: 'app-my-component',
  standalone: true,
  imports: [],
  template: `<p>my-component works!</p>`,
  styleUrl: './my-component.component.scss'
})
export class MyComponentComponent implements OnInit{

  constructor(private http: HttpClient) {

  }

  ngOnInit(): void {
    this.getData().then((data) => {
      console.log(data);
    });
    this.sendData();
  }

  getData(): Promise<string> {
    //HttpClient.get() 回傳的是 RxJs 的 Observable,要呼叫 Observable 的 subscribe() 才會執行 HTTP GET 呼叫,
    //所以這裡為了之後可以利用 subscribe(data) 的 data ,使用了 Promise 把 http.get() 包起來並把 data 放入 resolve(data) 中,
    //之後可以用 Promise.then((data) => {}) 來得到 data。
    return new Promise<string>((resolve) => {      
      this.http.get("https://xxx.xxx.xxx/xxx", { responseType: "text" }) //沒給 responseType 的話預設是 JSON           
             .subscribe((data: string) => {
                resolve(data);
             });
    });
  }

  sendData(): Promise<void> {
     //HttpClient.post() 回傳的是 RxJs 的 Observable,要呼叫 Observable 的 subscribe() 才會執行 HTTP POST 呼叫
     //所以這裡為了將 subscribe(data) 的 data 回傳,使用了 Promise 把 http.get() 包起來並把 data 回傳。
     //這邊展示如果 resolve() 沒有想要塞值的話,在 typescript 裡可以用 new Promise<void> 來指定 void ,不然 typescript 會報錯。
     return new Promise<void>((resolve) => {
        this.http.post("https://yyy.yyy.yyy/yyy", {data: "tttest"})
                 .subscribe(() => {
                    resolve();
                 });
     });   
  }

}


在 MyComponent 中我們先在 constructor 中注入了 HttpClient 來使用,示範了利用 HttpClient 來進行 HTTP GET 和 HTTP POST 的呼叫,
需要注意到的是,HttpClient.get() 和 HttpClient.post() 回傳的是 RxJs 的 Observable ,屬於 Cold Observable ,不像 Hot Observable 不管有沒有被 subscribe() 都會執行內容, Cold Observable 需要被執行 subscribe() 才會執行內容,詳細可以參考這篇 [RxJS] Cold Observable v.s Hot Observable | 全端開發人員天梯,比較是屬於 RxJS 的東西。

-------------------------------------------------------------------------------------------------------------

Form (表單) 相關

Angular 的 Form 可以用兩種方法來製作,分成 Template-Driven Form (樣板驅動表單) 和 Reactive Form (回應式表單) 兩種,
Template-Driven Form 跟 AngularJs 的方法較為相似,適合簡單的 Form,實作也較簡單易懂。Reactive Form 適合較複雜的 Form,實作一些複雜功能會較有彈性,複用性比較高、也較易於測試。下面分別示範:

Template-Driven Form (樣板驅動表單) :
可參考 Angular - 建立樣板驅動表單
下面直接給範例程式,說明都放在註解中

my-form.component.ts

import { Component } from '@angular/core';
import { FormsModule, NgForm } from '@angular/forms';

@Component({
  selector: 'app-my-form',
  standalone: true,
  imports: [FormsModule], /* 使用到了 FormsModule */
  templateUrl: './my-form.component.html',
  styleUrl: './my-form.component.scss'
})
export class MyFormComponent {
  formData: { firstName: string, lastName: string } = {
    firstName: "",
    lastName: ""
  };

  submitForm(form: NgForm) {
    console.log("submit!");
    console.log(form.form.valid);
    //因為 NgForm 有把 NgForm.form 上很多屬性複製到 NgForm 本身上,
    //所以直接用 NgForm.valid 也可以存取 NgForm.form.valid
    console.log(form.valid);
    console.log(form.value.firstName);
    console.log(form.value.lastName);
    //NgForm 有些好用的 function,例如重置 Form 狀態的 reset()
    form.reset();
  }
}


my-form.component.html

<p>my-form works!</p>
<!-- 使用 Template Reference Variable, #myForm 來得到 ngForm Directive 的實體對像 -->
 <!-- 在 form 被 submit 時觸發 ngSubmit Event,在這裡可以將 ngForm Directive 實體傳入 -->
<form #myForm="ngForm" 
      (ngSubmit)="submitForm(myForm)">
    <!-- 跟 #myForm 類似,這裡是用 #firstName 取得 ngModel 的實體對像 -->
    <!-- 用 [(ngModel)] 設定雙向繫結 -->
    <div>First Name : <input type="text" name="firstName" 
                                         [(ngModel)]="formData.firstName"
                                         #firstName="ngModel" 
                                         required/></div>
    <!-- 利用得到的名為 firstName 的 ngModel 來存取其 valid, pristine 等值 -->
    <div style="color: #ff0000;" 
        [hidden]="firstName.valid || firstName.pristine">
        First name is required!
    </div>

    <div>Last Name : <input type="text" name="lastName" [(ngModel)]="formData.lastName" #lastName="ngModel" required/></div>
    <div style="color: #ff0000;" [hidden]="lastName.valid || lastName.pristine">Last name is required!</div>

    <div>Is Form valid: {{myForm.valid}}</div>

    <div>
        <!-- 利用取得的名為 myForm 的 ngForm 存取 form 的 valid 等值,也可以用 myForm.valid,因為 form 上的大部份屬性在 ngForm 上都有一部份複本  -->
        <button type="submit" 
                [disabled]="!myForm.form.valid">
                Submit
        </button>
    </div>
</form>

Reactive Form (回應式表單):
可參考 Angular - 回應式表單

my-reactive-form.component.ts

import { CommonModule } from '@angular/common';
import { Component, OnInit } from '@angular/core';
import { FormArray, FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { first } from 'rxjs';

@Component({
  selector: 'app-my-reactive-form',
  standalone: true,
  imports: [ReactiveFormsModule, CommonModule], //在這裡要使用 ReactiveFormsModule
  templateUrl: './my-reactive-form.component.html',
  styleUrl: './my-reactive-form.component.scss'
})
export class MyReactiveFormComponent implements OnInit {
  myForm!: FormGroup;
  
  
  constructor(private formBuilder: FormBuilder) {

  }
  ngOnInit(): void {
    //設定 FormGroup 和 FormControl,跟 Template-Driven Form 不同,
    //Form 的各元件顯示地被建立在 Javascript code 中,
    //可以很方便的操作和控制。
    //值得注意的是,FormGroup 也是可以塞進 FormGroup, FormArray 中的。
    this.myForm = new FormGroup({
      firstName: new FormControl("", Validators.required),
      lastName: new FormControl("", [Validators.required, Validators.maxLength(10)]),
      address: new FormGroup({
        country: new FormControl("", Validators.required),
        city: new FormControl("", Validators.required)
      }),
      favorites: new FormArray([
        new FormControl("", Validators.required)
      ])
    });

    //也可以用 FormBuilder 幫助用較簡化的方式設定 FormGroup 和 FormControl,
    //作用跟上面的程式完全一樣
    /*
    this.myForm = this.formBuilder.group({
      firstName : ["", Validators.required],
      lastName : ["", [Validators.required, Validators.maxLength(10)]],
      address: this.formBuilder.group({
        country : ["", Validators.required],
        city : ["", Validators.required]
      }),
      favorites: this.formBuilder.array([
        this.formBuilder.control("", Validators.required)
      ])
    });
    */
  }

  //在這邊設定一個 getter 來讓我們在 html 中可以直接用 favoritesFormArray 這個屬性名直接存取 favorites 這個 FormArray
  //像是這樣: <div *ngFor="let favorite of favoritesFromArray.controls; let i = index;"></div>
  //**因為不能寫成這樣: <div *ngFor="let favorite of (myForm.get('favorites') as FromArray)?.controls; let i = index;"></div>
  get favoritesFromArray(): FormArray {
    return this.myForm.get("favorites") as FormArray;
  }

  //實作動態加入 FormControl 到 FormArray 中
  addMoreFavoriteField() {
    //(this.myForm.get("favorites") as FormArray).push(new FormControl("", Validators.required));
    //因為有設定 favoritesFromArray 這個 getter,所以也可以用下面寫法達到一樣效果
    this.favoritesFromArray.push(new FormControl("", Validators.required));
  }

  //實作動態從 FormArray 中移除 FormControl
  removeFavoriteField(index: number) {
    //(this.myForm.get("favorites") as FormArray).removeAt(index);
    //因為有設定 favoritesFromArray 這個 getter,所以也可以用下面寫法達到一樣效果
    this.favoritesFromArray.removeAt(index);
  }

  //測試 FormGroup.setValue()
  setValueTest() {
    //FormGroup.setValue() 可以設定子元件的值,如果結構不對 (例如 address 沒用 Object 格式) 或有其他問題會有 Error
    this.myForm.setValue({
      firstName : "FirstName",
      lastName : "LastName",
      address : "country" //這裡 address 的型別錯了,應該是要物件才對,所以執行會有 Error
    });
  }

  //測試 FormGroup.patchValue()
  patchValueTest() {
    //Formgroup.patchValue() 也可以設定子元件的值,但它會盡可能的去設定,如果結構不對 或有其他問題也不會有 Error
    this.myForm.patchValue({
      firstName : "FirstName",
      lastName : "LastName",
      address : "country" //這裡 address 的型別錯了,應該是要物件才對,但執行不會有 Error,會直接惣略此欄位
    });
  }

  submitForm() {
    console.log(this.myForm.value);
  }
}

my-reactive-form.component.html

<p>my-reactive-form works!</p>
<!-- 設定 formGroup, myForm 就是我們在 Component 中設定的 FormGroup 物件 -->
<!-- 在 form 被 submit 時觸發 -->
<form [formGroup]="myForm"
      (ngSubmit)="submitForm()">
    <!-- 設定 formControlName, firstName 就是我們在 Component 中設定的名為 myForm 的 FormGroup 中的名為 firstName 的 FormControl 物件 -->
    <div>First Name : <input type="text" name="firstName" formControlName="firstName"/></div>
    <!-- 用 FormGorup.get(FromControl 的名字) 取得其下的各 FormControl,再取得各 Form control 的 valid, pristine 等值 -->
    <!-- 也可以用 FormGroup.controls 來取得各 FormControl -->
    <div style="color: #ff0000;" 
        [hidden]="myForm.get('firstName')?.valid || myForm.controls['firstName'].pristine">
        First name is required!
    </div>

    <div>Last Name : <input type="text" name="lastName" formControlName="lastName"/></div>
    <div style="color: #ff0000;" [hidden]="myForm.get('lastName')?.valid || myForm.get('lastName')?.pristine">Last name is required!</div>

    <!-- 用 formGroupName 設定 FormGroup 中的 FormGroup 成員 -->
    <div formGroupName="address">
        <div>Country : <input type="text" name="country" formControlName="country"/></div>
        <div>City : <input type="text" name="city" formControlName="city"/></div>
    </div>

    <div>Address Form Status: {{myForm.get('address')?.status}}</div>

    <div>Is From valid: {{myForm.valid}}</div>
    <div>Form Status: {{myForm.status}}</div>

    <!-- 設定 FormArray,其中的 FormController 可以不用有 Key 名稱 -->
    <div formArrayName="favorites">
        <div *ngFor="let favorite of favoritesFromArray.controls; let i = index;">
            <!-- FormArray 中的各子項元件是用 Array index 去做 key,所以要用 index 做 fromControlName 的設定, -->
            <!-- formControlName 外的中括弧代表等號右邊是 Expression ,而非純 String -->
            Favorite {{i}} : <input type="text"  [formControlName]="i"/>
            <button (click)="addMoreFavoriteField()">Add</button>
            <button (click)="removeFavoriteField(i)">Remove</button>
        </div>
    </div>

    <div><button (click)="setValueTest()">Set value test</button></div>
    <div><button (click)="patchValueTest()">Patch value test</button></div>

    <div>
        <!-- 利用取得的名為 myForm 的 ngForm 存取 form 的 valid 等值,也可以用 myForm.valid,因為 form 上的大部份屬性在 ngForm 上都有一部份複本  -->
        <button type="submit" 
                [disabled]="!myForm.valid">
                Submit
        </button>
    </div>
</form>