2026年3月29日 星期日

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

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

模擬環境:

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

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

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

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

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

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

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

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

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

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

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

  </dependencies>

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

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

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

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

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

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

package com.config;

import javax.sql.DataSource;

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

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

@Configuration
public class DBConfig {

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

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

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

package com.bean;

public class MemberBean {

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

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

	public String getName() {
		return name;
	}

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

	public String getEmail() {
		return email;
	}

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

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

package com.bean;

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

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

	public int getId() {
		return id;
	}

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

	public Instant getCreatedDate() {
		return createdDate;
	}

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

	public int getMemberId() {
		return memberId;
	}

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

	public String getDetail() {
		return detail;
	}

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

設定 DAO 的部份 :

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

package com.dao;

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

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

import com.bean.MemberBean;

@Repository
public class MemberDAO {

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

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

package dao;

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

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

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

public class PurchaseOrderDAOTest extends BaseDBTest {

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

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

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

CREATE DATABASE database1;
CREATE DATABASE database2;

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

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

DECLARE @sql NVARCHAR(MAX) = '';

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

EXEC sp_executesql @sql;

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

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

OPEN index_cursor;
FETCH NEXT FROM index_cursor INTO @tableName;

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

CLOSE index_cursor;
DEALLOCATE index_cursor;

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

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

OPEN FullTextCatalogsCursor;
FETCH NEXT FROM FullTextCatalogsCursor INTO @CatalogName;

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

CLOSE FullTextCatalogsCursor;
DEALLOCATE FullTextCatalogsCursor;

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

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

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

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

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

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

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

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

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

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

package dao;

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

import javax.sql.DataSource;

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

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

@SpringJUnitWebConfig(SpringApplicationConfig.class)
public class BaseDBTest {

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

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

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

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

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

package dao;

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

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

public class MemberDAOTest extends BaseDBTest {

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

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

package dao;

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

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

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

public class PurchaseOrderDAOTest extends BaseDBTest {

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

源碼下載分享:

    testcontainers-test.zip

參考資料:

  1. HikariCP


2025年12月6日 星期六

在 Windows 環境使用 Docker 指令控制 WSL2 中的 Docker (不是 Docker Desktop) 的設定方式

 Windows 的環境,
並且已在 WSL2 上安裝了 Docker 情況下 (不是 Docker Desktop),

我們一般可以在 WSL 2 上直接執行 Docker 命令,例如 docker ps, docker version 等。

但如果我們想在 WSL 2 外部,也就是使用 CMD 或 PowerShell 來執行 Docker 命令的話,
就必須做些額外設定才能達成。

我們的目標有兩個:

  1. 讓 WSL 2 中的 Docker daemon 能將 Port 暴露給外部。
  2. 在 Windows 下要有 Docker CLI 讓 Windows 能使用 Docker 命令,並且要告訴 Docker CLI 要把命令傳給哪一個 Port。

為了讓 Docker daemon 能夠被 Windows 存取,首先我們要先進入 WSL 2 中,
找到或自行建立 /etc/docker/daemon.json,內容改為:

{
   "hosts": ["tcp://127.0.0.1:2375", "unix:///var/run/docker.sock"]
} 

/etc/docker/daemon.json 設定了 Docker daemon 的連接方式,也就是接受命令的方式,
unix:///var/run/docker.sock 是原本在 WSL 2 中 Docker 指令直接連接的目標。
tcp://127.0.0.1:2375 則是我們多加的,讓 WSL 2 外部能以 tcp localhost (127.0.0.1) + 2375 Port 的方式連接 Docker daemon。

將 Docker daemon 監聽 2375 Port,接著我們要以下指令重新啟動 Docker daemon service :

#service - 較舊的用法 (在現在其實也是去呼叫 systemctl)

service docker restart

#systemctl - 較新的用法

systemctl restart docker

如果設定了 daemon.json 後發現 docker service 無法正常重啟或啟動,
通常是因為 DOcker 本身的 /lib/systemd/system/docker.service 中的
ExecStart 指令設定跟 /etc/docker/daemon.json 互相衝突。

在 /lib/systemd/system/docker.service 中有一句像這樣的設定 (Docker version 29.0.2)

ExecStart=/usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock

其中 -H fd:// 是Systemd 的 Socket Activation (套接字激活) 功能,
關於 Socket Activation 可以參考:

  1. 一次socket activation的探索体验-CSDN博客
  2. Systemd 的 socket activation 机制 | Zwlin's Blog
簡單的來說就是 systemd 預先建立了 socket 監聽請求,讓 Docker 不用自行建立 socket ,而是使用 systemd 建立的 socket,請求不會直接傳給 Docker daemon+, 而是被 systemd 的 socket 攔截了下來,當 socket 收到請求時才會去啟動 Docker daemon (如果 Docker daemon 還沒被啟動的話),類似 lazy 啟動的感覺。

但我們現在不想要 systemd socket 攔截我們的請求,而是想要能直接傳送請求給我們在
/etc/docker/daemon.json 中的那些監聽設定,所以我們必須要重新覆蓋掉
/lib/systemd/system/docker.service  中的 ExecStart 設定。

我們需要去建立

/etc/systemd/system/docker.service.d/override.conf

內容如下:

[Service]
ExecStart=
ExecStart=/usr/bin/dockerd

這裡注意到我們須要寫兩行的 ExecStart=,第一行用來清除
/lib/systemd/system/docker.service
的 ExecStart 內容,第二行用來定設定我們要的自定義內容。

之後再執行以下指令 :

讓 systemd 重新載入 /etc/systemd/system/docker.service.d/override.conf 的設定

systemctl daemon-reload

重啟 Docker daemon

systemctl restart docker

sudo systemctl daemon-reload

sudo systemctl restart docker

以上都做完以後,從 Windows 就可以用 127.0.0.1:2375 存取 Docker daemon 了,
我們可以在 WSL 2 中用以下命令檢查 2375 Port 有沒有被監聽:

netstat -nl | grep 2375

ss -lntp | grep 2375

再來要注意的是有時 Windows 可能會有保留某些範圍的 Port 做特別用途而不給使用,
就算 Port 有被監聽還是無法正常讓 Windows 跟 Docker daemon 連接。

這時可以用以下指令來檢查:

netsh interface ipv4 show excludedportrange protocol=tcp

例如指令結果如果是如下:

Protocol tcp Port Exclusion Ranges

Start Port    End Port
----------    --------
      2211        2310
      2311        2410
      2511        2610
      2611        2710
      5357        5357
      7749        7848
     10824       10923
     14846       14945
     50000       50059     *

* - Administered port exclusions.

可以看到 2375 Port 是在 2311 ~ 2410 中被系統保留不能使用 ,
所以這時我們就要改設定其他 Port,不能用 2375。

可參考 Port 2375 not listening · Issue #3546 · docker/for-win

最後,雖然 Windows 也可連上 Docker daemon 了,除非你想直接底層用 Socket 連接 (例如 Testcontainers 這個 Java Library 可以做到),
不然還是裝上 Docker CLI 讓其提供方便的 Docker 指令給 Windows 使用。

我們先去下載相應 Docker 版本的 Docker CLI for Windows,下載網址如下:

https://download.docker.com/win/static/stable/x86_64/

下載後會是一個 zip 檔,解壓縮後得到一個資料夾,裡面有 docker.exe, dockerd.exe, 等檔案。

先設定環境變數的 Path 到資料夾路徑讓我們可以方便使用 Docker CLI 指令後,
還需要設定 DOCKER_HOST 環境變數讓 Docker CLI 知道要去哪裡連接 Docker daemon,
我們設定如下環境變數:

DOCKER_HOST : tcp://127.0.0.1:2375

然後再重新打開 PowerShell 或 cmd 試著執行 Docker 指令,就可以成功連上 Docker daemon 並執行 Docker 指令了,

例如執行 docker version:

docker version

應該就可以成功看到 Docker 的 version 資訊了。

參考資料:

  1. How to run tests with TestContainers in WSL2 without Docker Desktop
  2. 如何移除 Docker Desktop 並在 Windows 與 WSL 2 改安裝 Docker Engine | The Will Will Web
  3. 一次socket activation的探索体验-CSDN博客
  4. Systemd 的 socket activation 机制 | Zwlin's Blog
  5. service - Unable to start docker after configuring hosts in daemon.json - Stack Overflow
  6. Port 2375 not listening · Issue #3546 · docker/for-win


2025年10月29日 星期三

AngularJS - Component 使用紀錄

紀錄一下 AngularJs 的 Component 使用方法

HTML :
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.8.2/angular.min.js"></script>

<div ng-app="app" ng-controller="controller as ctrl">
  <attach-uploader some-variable="ctrl.parentvariable"
                   some-event="ctrl.parentFunction(text1, text2)">
  </attach-uploader>
</div>
JS :
var app = angular.module("app", []);
app.controller("controller", [function() {
   var self = this;
   this.parentvariable = "xxx";
   this.parentFunction = function(someText1, someText2) {
      console.log(someText1);
      console.log(someText2);
   }
}]);
app.component("attachUploader", {
  template: `<div>
	        <div ng-repeat="uploader in $ctrl.uploaderList track by $index">
                   <input type="file"
                          ng-attr-name="attachFile_{{$index}}"
                          file-validator="fileType:'png';"
                          /> 
                   <button ng-click="$ctrl.addUploader()">+</button>
                </div>
                <button ng-click="$ctrl.onXxxEvent('aaa', 'bbb')">Test button</button>
	      </div>`,
  bindings: {
     someVariable: "=",
     someEvent: "&" // function 的綁定
  },
  //controllerAs: "ctrl", //預設是  $ctrl
  controller: ["otherServiece", function (otherService) {
    var self = this

    self.maxUploader = 5
    self.uploaderList = [{}]
    
    console.log('Test1: ' + self.someVariable); //會是 undefined
    self.$onInit = function() {
       console.log('Test1: ' + self.someVariable); //會是 xxx,要在 $onInit() 裡才會得到值
	}
    
    self.$onChanges = function() {
       //component 的輸入有變化時會呼叫
    }
    
    self.$doCheck = function() {
       //component 每一次的 Digest Circle 都會呼叫,適合用在偵測 AngularJs 不會去偵測 (就是不會觸發 $onChanges) 的物件上的變化 
    }
    
    self.onXxxEvent = function(text1, text2) {
       self.someEvent({text1: text1, text2: text2}); //傳遞含有參數資訊的 Map 給 parent 的 function
    }

    self.addUploader = function () {
      if (self.uploaderList.length >= self.maxUploader) {
        return
      }
      self.uploaderList.push({})
    }
  }],
});

2025年10月5日 星期日

以屬性為條件,只要符合任何一條即成立的規則條件設計分享

 跟上次分享的文章:

以屬性為條件且屬性有階層關係的的規則可擴充權限設計分享

類似,

不過這次的需求比較簡單,是做出類似 URL Redirect 的規則設計,
一樣是有多個可能會日後動態增加的屬性,
只要符合一個條件即符合規則。

案例:

  1. 公司會收到客服詢問案件 (暫簡稱 Case),詢問案件會被標記上各種屬性,例如 : 語言 (lang)、是問哪個產品的問題 (productId) 等。
  2. 不同的屬性值可以設成一個 assign 規則,用來決定把客服詢問案件指派 (assign) 給哪個負責人員 (staffId)。
  3. 每個規則可以 assign 給不同的 staffId,但只會有一個規則被選用,規則可以按某個順序排,先符合的規則先被選用。

直接上實作部份範例,Dabase 採用 PostgreSql、程式部份採用 Java:

首先是 Database Table 設定的部份:

--設定 Case assign 規則及其規則要 assign 給哪個 staffId
CREATE TABLE case_assign_rule (
	id SERIAL PRIMARY KEY,
	assignee_staff_id INT	
);

--設定 Case 屬性,可用來判斷要選取哪個 Case assign rule 
CREATE TABLE case_assign_rule_type (
	id SERIAL PRIMARY KEY,
	label VARCHAR NOT NULL
);

--設定 Case assign rule,這裡沒有設定優先級,暫且先用 case_assign_rule_id 來當作優先級,
--越小的優先。
--Note:
--一個規則為把同一個 case_assign_rule_id 的多個規則用 AND 的方式結合在一起成為一個規則
--例如: case_assign_rule_id = 1 assign 給 staffId = 111 有兩條規則: 
--"productid 等於 100" 和 "langId 不等於 3"
-- 等同於 case_assign_rule_id = 1: "productid 等於 100" 且 "langId 不等於 3" 時 assign 給 staffId = 111
CREATE TABLE case_assign_rule_assignment_condition (
	case_assign_rule_id INT,
	case_assign_rule_type_id INT,
	is_equal_case_assign_rule_type_value BOOLEAN,
	case_assign_rule_type_value INT,
	PRIMARY KEY(case_assign_rule_id, case_assign_rule_type_id, is_equal_case_assign_rule_type_value, case_assign_rule_type_value)
);

如果想要設定兩條規則 (以下用 "==" 代表 "等於"、用 "!=" 代表 "不等於"):

  1. productId = 100 且 langId != 3 時, assign 給 staffId = 111。
  2. productId = 100 時 assign 給 staffId = 123。
這樣的話當 Case 的 productId = 100 且 langId != 3 時就會選用第一個 assign rule 被 assign 給 staffId = 111。
而如果 Case 的 product = 100 但  langId == 3 時就會選用第二個 rule 被 assign 給 staffId = 123。

我們可以這樣設定:

--設定 Case assign rule condition
--設定 productId = 100 且 langId != 3 時, assign 給 staffId = 111。
INSERT INTO case_assign_rule_assignment_condition(case_assign_rule_id,
	                                          case_assign_rule_type_id,
	                                          is_equal_case_assign_rule_type_value,
	                                          case_assign_rule_type_value)
            VALUES(1, 1, true, 100);
INSERT INTO case_assign_rule_assignment_condition(case_assign_rule_id,
	                                          case_assign_rule_type_id,
	                                          is_equal_case_assign_rule_type_value,
	                                          case_assign_rule_type_value)
            VALUES(1, 2, false, 3);

--設定 productId = 100 時 assign 給 staffId = 123。
INSERT INTO case_assign_rule_assignment_condition(case_assign_rule_id,
	                                          case_assign_rule_type_id,
	                                          is_equal_case_assign_rule_type_value,
	                                          case_assign_rule_type_value)
            VALUES(2, 1, true, 100);


再來是 Java 程式判斷的部份:

CASE_ASSIGN_RULE_TYPE.java:

package constant;

public enum CASE_ASSIGN_RULE_TYPE {
	LANG_ID(1), PRODUCT_ID(2);
	
	private int id;
	
	private CASE_ASSIGN_RULE_TYPE(int id) {
        this.id = id;
    }
	
	public static CASE_ASSIGN_RULE_TYPE getCaseAssignRuleTypeById(int id, CASE_ASSIGN_RULE_TYPE defaultEnum) {
        for (CASE_ASSIGN_RULE_TYPE value : values()) {
            if (value.id == id) {
            	return value;
            }
        }
        
        return defaultEnum;
	}
}

CaseAssignRuleConditionBean.java:

package bean;

import constant.CASE_ASSIGN_RULE_ASSIGNEE_TYPE;

public class CaseAssignRuleConditionBean {
	private int caseAssignRuleId;
	private CASE_ASSIGN_RULE_ASSIGNEE_TYPE caseAssignRuleAssigneeType;
	private boolean isEqualCaseAssignRuleTypeValue;
	private int caseAssigneeTypeValue;

	public int getcaseAssignRuleId() {
		return caseAssignRuleId;
	}

	public void setcaseAssignRuleId(int caseAssignRuleId) {
		this.caseAssignRuleId = caseAssignRuleId;
	}

	public CASE_ASSIGN_RULE_ASSIGNEE_TYPE getcaseAssignRuleAssigneeType() {
		return caseAssignRuleAssigneeType;
	}

	public void setcaseAssignRuleAssigneeType(CASE_ASSIGN_RULE_ASSIGNEE_TYPE caseAssignRuleAssigneeType) {
		this.caseAssignRuleAssigneeType = caseAssignRuleAssigneeType;
	}
	
	public void setcaseAssignRuleAssigneeType(int caseAssignRuleAssigneeTypeId) {
		this.caseAssignRuleAssigneeType = CASE_ASSIGN_RULE_ASSIGNEE_TYPE.getcaseAssignRuleAssigneeTypeById(caseAssignRuleAssigneeTypeId, null);
	}

	public boolean getIsEqualCaseAssignRuleTypeValue() {
		return isEqualCaseAssignRuleTypeValue;
	}

	public void setIsEqualCaseAssignRuleTypeValue(boolean isEqualCaseAssignRuleTypeValue) {
		this.isEqualCaseAssignRuleTypeValue = isEqualCaseAssignRuleTypeValue;
	}

	public int getCaseAssigneeTypeValue() {
		return caseAssigneeTypeValue;
	}

	public void stCaseAssigneeTypeValue(int caseAssigneeTypeValue) {
		this.caseAssigneeTypeValue = caseAssigneeTypeValue;
	}
}

CaseAssignRuleBean.java:

package bean;

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

public class CaseAssignRuleBean {
	private int id;
	private int caseStaffAccountId;
	private List<CaseAssignRuleConditionBean> caseAssignRuleConditionList;

	public int getId() {
		return id;
	}

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

	public int getCaseStaffAccountId() {
		return caseStaffAccountId;
	}

	public void setCaseStaffAccountId(int caseStaffAccountId) {
		this.caseStaffAccountId = caseStaffAccountId;
	}

	public List<CaseAssignRuleConditionBean> getCaseAssignRuleConditionList() {
		if (caseAssignRuleConditionList == null) {
			this.caseAssignRuleConditionList = new ArrayList<>();
		}
		return caseAssignRuleConditionList;
	}

	public void setCaseAssignRuleConditionList(List<CaseAssignRuleConditionBean> caseAssignRuleConditionList) {
		this.caseAssignRuleConditionList = caseAssignRuleConditionList;
	}
}

CaseStaffBean.java:

package bean;

public class CaseStaffBean {
	int accountId;
	
	public int getAccountId() {
		return accountId;
	}
	public void setAccountId(int accountId) {
		this.accountId = accountId;
	}
}

CaseAssignRuleDAO.java:

package dao;

import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;

import javax.sql.DataSource;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Repository;

import bean.CaseStaffBean;
import bean.CaseAssignRuleBean;
import bean.CaseAssignRuleConditionBean;
import constant.CASE_ASSIGN_RULE_TYPE;
import bean.CaseBean;

@Repository
public class CaseAssignRuleDAO {

	private static CaseAssignRuleDAO instance;

	private static DataSource postgreSQLDataSource;
	private static JdbcTemplate postgreSQLJdbcTemplate;
	
	private static userDAO userDAO;

	private CaseAssignRuleDAO() {
	}

	@Autowired
	private void setUp(CaseAssignRuleDAO instance,
					   @Qualifier("postgreSQLDataSource") DataSource postgreSQLDataSource,
					   @Qualifier("postgreSQLJdbcTemplate") JdbcTemplate postgreSQLJdbcTemplate,
					   userDAO userDAO) {
		CaseAssignRuleDAO.instance = instance;

		CaseAssignRuleDAO.postgreSQLDataSource = postgreSQLDataSource;
		CaseAssignRuleDAO.postgreSQLJdbcTemplate = postgreSQLJdbcTemplate;
		CaseAssignRuleDAO.userDAO = userDAO;
	}

	public static synchronized CaseAssignRuleDAO getInstance() {
		if (instance == null) {
			throw new RuntimeException(
					CaseAssignRuleDAO.class.getName() + " instance is not initialized correctly.");
		}
		return instance;
	}

    //查詢一個 Case 應該要 assign 給哪一個 CaseStaff
	public CaseStaffBean queryCaseStaffByCaseAssignRule(CaseBean case) {
		
		List<CaseAssignRuleBean> caseAssignRuleList = querycaseAssignRuleList();
		
		//決定 Case 的屬性應該要選擇哪一個 Case assign rule
		CaseAssignRuleBean chosenCaseAssignRule = caseAssignRuleList.stream()
		                     .filter(caseAssignRule ->
		                     			caseAssignRule.getcaseAssignRuleConditionList()
		                     							 .stream()
														 //Case assign rule 要被選擇的條件是它的 condition 都要被滿足(allMatch)
		                     							 .allMatch(caseAssignRuleCondition -> {
														              //比較 langId
						                    	                	  if (CASE_ASSIGN_RULE_TYPE.LANG_ID == caseAssignRuleCondition.getcaseAssignRuleAssigneeType()) {
																	      //如果條件要求 typeValue 要 "等於"
						                    	                		  if (caseAssignRuleCondition.getIsEqualCaseAssignRuleTypeValue()) {
						                    	                			  return caseAssignRuleCondition.getAssigneeTypeValue() == case.getLangId();
						                    	                		  }
																		  //如果條件要求 typeValue 要 "不等於"
						                    	                		  return caseAssignRuleCondition.getAssigneeTypeValue() != case.getLangId();
						                    	                	  }
				                    	                	  
															          //比較 productId
						                    	                	  if (CASE_ASSIGN_RULE_TYPE.PRODUCT_ID == caseAssignRuleCondition.getcaseAssignRuleAssigneeType()) {
						                    	                		  //如果條件要求 productId 要 "等於"
																		  if (caseAssignRuleCondition.getIsEqualCaseAssignRuleTypeValue()) {
						                    	                			  return caseAssignRuleCondition.getAssigneeTypeValue() == case.getProductId();
						                    	                		  }
																		  //如果條件要求 productId 要 "不等於"
						                    	                		  return caseAssignRuleCondition.getAssigneeTypeValue() != case.getProductId();
						                    	                	  }
						                    	                	  
																	  //如果執行到這裡代表規則設定了不應該存在的 case_type,在這裡我視為不符合條件。
						                    	                	  return false;
		                     							 			})
		                    		 )
									 //以第一個找到的 Case assign rule 為主
									 .findFirst().orElse(null);
		
		if (chosenCaseAssignRule == null) {
			return null;
		}
		
		return userDAO.queryCaseStaffByAccountId(chosenCaseAssignRule.getAssigneeStaffAccountId());
	}

    //查詢所有的 case_assign_rule
	public List<CaseAssignRuleBean> querycaseAssignRuleList() {
		String sql = "SELECT * FROM case_assign_rule ORDER BY id";

		return postgreSQLJdbcTemplate.query(sql, new RowMapper<CaseAssignRuleBean>() {

			@Override
			public CaseAssignRuleBean mapRow(ResultSet rs, int rowNum) throws SQLException {
				CaseAssignRuleBean caseAssignRule = new CaseAssignRuleBean();
				caseAssignRule.setId(rs.getInt("id"));
				caseAssignRule.setAssigneeStaffAccountId(rs.getInt("assignee_staff_id"));
				caseAssignRule.setcaseAssignRuleConditionList(
						querycaseAssignRuleConditionListByRuleId(caseAssignRule.getId()));

				return caseAssignRule;
			}

		});
	}

    //把特定 case_assign_rule id 的 condition list 查出來。
	public List<CaseAssignRuleConditionBean> querycaseAssignRuleConditionListByRuleId(int caseAssignRuleId) {
		String sql = "SELECT * " + "FROM case_assign_rule_assignment_condition " + "WHERE case_assign_rule_id = ?";

		return postgreSQLJdbcTemplate.query(sql, new RowMapper<CaseAssignRuleConditionBean>() {

			@Override
			public CaseAssignRuleConditionBean mapRow(ResultSet rs, int rowNum) throws SQLException {
				CaseAssignRuleConditionBean caseAssignRuleCondition = new CaseAssignRuleConditionBean();
				caseAssignRuleCondition.setcaseAssignRuleId(rs.getInt("case_assign_rule_id"));
				caseAssignRuleCondition
						.setcaseAssignRuleAssigneeType(rs.getInt("case_assign_rule_assignee_type_id"));
				caseAssignRuleCondition.setIsEqualAssigneeTypeValue(rs.getBoolean("is_equal_assignee_type_value"));
				caseAssignRuleCondition.setAssigneeTypeValue(rs.getInt("assignee_type_value"));

				return caseAssignRuleCondition;
			}

		}, caseAssignRuleId);
	}
}

接下來如果日後有更多的屬性想要來供判斷、以及有更多的 rule 想要設定給不同的 Case Staff 時,就只要再加資料至 case_assign_rule_type, case_assign_rule, case_assign_rule_assignment_condition 這三個 Database Table 就可以了。

2025年10月4日 星期六

以屬性為條件且屬性有階層關係的的規則可擴充權限設計分享

最近公司有如下需求:

需求:

  1. 權限有多個,要可動態增減 (假設互相獨立,例如先不要有 PERMISSION_ALL 包含 PERMISSION_READ、PERMISSION_WRITE 這種)
  2. 條件有多個,要可動態增減,條件可以有優先層級關係
案例:

  1. 假設現在有一個權限叫做 PERMISSION_BLOG_ARTICLE_EDIT。
  2. 假設 user 的屬性有 accountId, groupId,user 只會屬於一個 group,group 可包含多個 user。
  3. 可以設定 accountId = x 時 Allow 或 Deny 權限,groupId = x 時 Allow 或 Deny 權限。
  4. 在檢查權限時,設定 accountId 層級比 groupId 大,會先檢查 accountId 有沒有權限,如果有權限 (Allow) 就有權限,如果沒有權限 (Deny) 就沒有權限,
    如果不確定的話 (代表此權限沒有設定 accountId 相關的規則) 就去檢查 groupId 有沒有權限。
    如果所有要檢查的屬性都檢查完了還是不確定是否有權限,視為沒有權限。

網上查到了 屬性型存取控制 (Attribute-based Access Control , ABAC),並以此為靈感,最後以以下方式實作了並在此紀綠分享:

Database 的 Table 可以如下設定 (以 PostgreSQL 為例):

-- 各種 permission
CREATE TABLE permission (
 id SERIAL PRIMARY KEY, -- permission id
 label VARCHAR NOT NULL -- 為 permission 取個名字方便辨視
);

-- permission 的 權限設定細節
CREATE TABLE permission_assignment_condition (
 permission_assignee_type_id INT, -- 代表對哪一個 user 屬性去設定 rule
 is_equal_assignee_type_value BOOLEAN, -- 代表 user 屬性要 "等於" 還是 "不等於" assignee_type_value
 assignee_type_value INT, -- 代表 user 屬性要 "等於" 還是 "不等於" 某個值
 is_allowed_permission BOOLEAN, -- 代表此規則是 Allow 還是 Deny 權限
 permission_id INT, -- 此規則是對哪一個權限做設定
 PRIMARY KEY(permission_assignee_type_id, is_equal_assignee_type_value, assignee_type_value, permission_id)
);


-- user 的各種屬性,例如 accountId, groupId 等
CREATE TABLE permission_assignee_type (
 id SERIAL PRIMARY KEY, -- 屬性 id
 label VARCHAR NOT NULL -- 為屬性取名方便辨視
);

如果想要設定:

  1. groupId = 3 對 PERMISSION_BLOG_ARTICLE_EDIT 為 Deny (就是 groupId = 3 沒有權限)。
  2. accountId = 100 對 PERMISSION_BLOG_ARTICLE_EDIT  為 Allow (但是 accountId 有權限,即使他的 groupId = 3 也是有權限,也就是 accountId 的層級比 groupId 大)。

我們就可以這樣設定:

-- 設定 permission
INSERT INTO permission(id, label) VALUES(1, 'PERMISSION_BLOG_ARTICLE_EDIT');

-- 設定 permission 規則要判斷的屬性
INSERT INTO permission_assignee_type(id, label) VALUES(1, accountId);
INSERT INTO permission_assignee_type(id, label) VALUES(2, groupId);

--設定 permission 的判斷規則
--設定 accountId = 100 ALLOW PERMISSION_BLOG_ARTICLE_EDIT 
INSERT INTO permission_assignment_condition('permission_assignee_type_id',
                                            'is_equal_assignee_type_value',
                                            'assignee_type_value',
                                            'is_allowed_permission',
                                            'permission_id')
			VALUES(1, true, 100, true, 1);
			
--設定 groupId = 3 DENY PERMISSION_BLOG_ARTICLE_EDIT 
INSERT INTO permission_assignment_condition('permission_assignee_type_id',
                                            'is_equal_assignee_type_value',
                                            'assignee_type_value',
                                            'is_allowed_permission',
                                            'permission_id')
			VALUES(2, true, 3, false, 1);

有了設定好的資料後,我們就可以用程式來判斷一個 User 是否有特定的權限,以下由 Java 來做例子,只要執行 PermissionDAO 的 isUserHasPermission(UserBean user, PERMISSION permission) 就可以得知此 User 有沒有被授權特別 permission 的權限:

UserBean.java:

     package bean;

import java.util.Date;

public class UserBean {
	int accountId;
	int groupId;
	
	public int getAccountId() {
		return accountId;
	}
	
	public void setAccountId(int accountId) {
		this.accountId = accountId;
	}
	
	public int getGroupId() {
		return groupId;
	}
	public void setGroupId(int groupId) {
		this.groupId = groupId;
	}
}

PERMISSION.java :

package constant;

public enum PERMISSION {
	PERMISSION_BLOG_ARTICLE_EDIT(1, "PERMISSION_BLOG_ARTICLE_EDIT");
	
	private int id;
	private String label;
	
	private PERMISSION(int id, String label) {
        this.id = id;
        this.label = label;
    }
	
	public static PERMISSION getPermissionById(int id, PERMISSION defaultPermission) {
        for (PERMISSION value : values()) {
            if (value.id == id) {
            	return value;
            }
        }
        
        return defaultPermission;
	}
	
	public static PERMISSION getPermissionByLabel(String label, PERMISSION defaultPermission) {
        for (PERMISSION value : values()) {
            if (value.label.equalsIgnoreCase(label)) {
            	return value;
            }
        }
        
        return defaultPermission;
	}
	
	public int getId() {
		return id;
	}
	
	public String getLabel() {
		return label;
	}
}

PERMISSION_ALLOW_STATUS.java

package constant;

public enum PERMISSION_ALLOW_STATUS {
	NO_DECISION, 
	ALLOW,
	DENY;
}

PERMISSION_ASSIGNEE_TYPE.java

package constant;

public enum PERMISSION_ASSIGNEE_TYPE {
	USER_ACCOUNT_ID(1, "USER_ACCOUNT_ID"),
	USER_GROUP_ID(2, "USER_GROUP_ID")
	
	private int id;
	private String label;
	
	private PERMISSION_ASSIGNEE_TYPE(int id, String label) {
        this.id = id;
        this.label = label;
    }
	
	public static PERMISSION_ASSIGNEE_TYPE getPermissionAssigneeTypeById(int id, PERMISSION_ASSIGNEE_TYPE defaultPermissionAssigneeType) {
        for (PERMISSION_ASSIGNEE_TYPE value : values()) {
            if (value.id == id) {
            	return value;
            }
        }
        
        return defaultPermissionAssigneeType;
	}
	
	public static PERMISSION_ASSIGNEE_TYPE getPermissionAssigneeTypeByLabel(String label, PERMISSION_ASSIGNEE_TYPE defaultPermissionAssigneeType) {
        for (PERMISSION_ASSIGNEE_TYPE value : values()) {
            if (value.label.equalsIgnoreCase(label)) {
            	return value;
            }
        }
        
        return defaultPermissionAssigneeType;
	}
	
	public int getId() {
		return id;
	}
	
	public String getLabel() {
		return label;
	}
}

PermissionDAO.java

package dao;

import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Repository;

import bean.UserBean;
import bean.PermissionAssignmentConditionBean;
import constant.PERMISSION;
import constant.PERMISSION_ALLOW_STATUS;
import constant.PERMISSION_ASSIGNEE_TYPE;

@Repository
public class PermissionDAO {
	
	private JdbcTemplate jdbctemplate;
	
	@Autowired
	public PermissionDAO(JdbcTemplate jdbctemplate) {
		this.jdbctemplate = jdbctemplate;
	}
	
	public boolean isUserHasPermission(UserBean user, PERMISSION permission) {
		PERMISSION_ALLOW_STATUS permissionAllowStatus;
		
		//取得特定 permission 的授權規則
		List<PermissionAssignmentConditionBean> permissionAssignmentConditionList = getPermissionAssignmentCondition(permission);
		
		//檢查 permission 授權規則對於此 accountId 符合 Allow, Deny, 還是 還未知 (NO_DECISION)
		permissionAllowStatus = getPermissionAllowStatusByAssigneeType(permissionAssignmentConditionList,
										                               permission,
										                               PERMISSION_ASSIGNEE_TYPE.USER_ACCOUNT_ID,
										                               user.getAccountId());
		
		//如果不是 NO_DECISION ,即代表確定是 ALLOW 或 DENY,即可回傳確定的授權結果。
		if (permissionAllowStatus != PERMISSION_ALLOW_STATUS.NO_DECISION) {
			return permissionAllowStatus == PERMISSION_ALLOW_STATUS.ALLOW;
		}
		
		//如果對於 accountId 的授權是未知狀態 (即授權規則裡沒特別設定),就再檢查 groupId
		permissionAllowStatus = getPermissionAllowStatusByAssigneeType(permissionAssignmentConditionList,
										                               permission,
										                               PERMISSION_ASSIGNEE_TYPE.USER_GROUP_ID,
										                               user.getGroupId());
		
		//跟檢查 accountId 一樣,如果不是 NO_DECISION ,即代表確定是 ALLOW 或 DENY,即可回傳確定的授權結果。
		if (permissionAllowStatus != PERMISSION_ALLOW_STATUS.NO_DECISION) {
			return permissionAllowStatus == PERMISSION_ALLOW_STATUS.ALLOW;
		}
		
		//如果已檢查全部要檢查的屬性,但結果還是 NO_DECISION 時,就視為沒有權限 (DENY)
		return false;
	}
	
	public List<PermissionAssignmentConditionBean> getPermissionAssignmentCondition(PERMISSION permission) {
	    //將特定 permission 的授權規則查出來
		String sql = "SELECT * FROM permission_assignment_condition WHERE permission_id = ?";
		
		return jdbctemplate.query(sql, new RowMapper<PermissionAssignmentConditionBean>() {

			@Override
			public PermissionAssignmentConditionBean mapRow(ResultSet rs, int rowNum) throws SQLException {
				PermissionAssignmentConditionBean permissionAssignmentCondition = new PermissionAssignmentConditionBean();
				permissionAssignmentCondition.setPermissionAssigneeType(rs.getInt("permission_assignee_type_id"));
				permissionAssignmentCondition.setIsEqualAssigneeTypeValue(rs.getBoolean("is_equal_assignee_type_value"));
				permissionAssignmentCondition.setAssigneeTypeValue(rs.getInt("assignee_type_value"));
				permissionAssignmentCondition.setIsAllowedPermission(rs.getBoolean("is_allowed_permission"));
				permissionAssignmentCondition.setPermission(rs.getInt("permission_id"));
				
				return permissionAssignmentCondition;
			}
			
		}, permission.getId());
	}
	
	private PERMISSION_ALLOW_STATUS getPermissionAllowStatusByAssigneeType(List<PermissionAssignmentConditionBean> permissionAssignmentConditionList,
							                                               PERMISSION permission,
							                                               PERMISSION_ASSIGNEE_TYPE permissionAssigneeType,
							                                               int assigneeValue) {
		for (PermissionAssignmentConditionBean condition : permissionAssignmentConditionList) {
		    //濾掉其他沒有要檢查的 permission (包括 permission 不對 或 permissionAssigneeType 屬性不對) 
			if (permission != condition.getPermission() ||
				permissionAssigneeType != condition.getPermissionAssigneeType()) {
				continue;
			}
			
			//如果授權規則是要屬性值要 "等於" 某值
			if (condition.getIsEqualAssigneeTypeValue()) {
			    //如果屬性值的確等於某值,回傳規則設定的 ALLOW 或 DENY
				if (assigneeValue == condition.getAssigneeTypeValue()) {
					return condition.getIsAllowedPermission() ? PERMISSION_ALLOW_STATUS.ALLOW : PERMISSION_ALLOW_STATUS.DENY;
				}
				//如果屬性值不等於某值,並不代表一定是 DENY,而是 NO_DECISION
				continue;
			}
			
			//如果授權規則是要屬性值要 "不等於" 某值
			//且屬性值的確不等於某值,回傳規則設定的 ALLOW 或 DENY
			if (assigneeValue != condition.getAssigneeTypeValue()) {
				return condition.getIsAllowedPermission() ? PERMISSION_ALLOW_STATUS.ALLOW : PERMISSION_ALLOW_STATUS.DENY;
			}
			//如果屬性值等於某值,並不代表一定是 DENY,而是 NO_DECISION
		}
		
		//找不到相應的 permission + permissionAssignmentType ,視為 NO_DECISION
		return PERMISSION_ALLOW_STATUS.NO_DECISION;
	}
}


這樣的設計也具有擴充性,日後可以依需求增加更多的 permission 和 permission_assignee_type,只要再增加資料進 PERMISSION 和 PERMISSION_ASSIGNEE_TYPE 的 Database Table 即可。

2025年8月6日 星期三

分享用 Git 管理 OneDrive 的方法 (OneDrive + Git + mklink) - Windows

這裡分享一下我對 OneDrive 用 Git 配合 Windows 的 mklink 指令做版本管理的方法

環境:

  1. 我的電腦系統是 Windows11。
  2. 電腦上有登入 Microsoft 帳號的 OneDrive 同步資料夾。
  3. OneDrive 上有程式碼,且是多人共用。

雖然我個人是不太喜歡把程式放在 OneDrive 中,因為 OneDrive 沒有像 Git 一樣的管控概念,比較像是注重同步檔案功能的工具而已,雖然 OneDrive 可以去看檔案的各個版本及修改時間,但是沒有辦法像 Git 一樣很方便地看到哪一批檔案在哪個時間、被誰修改、修改了哪幾行。

OneDrive 也沒有辦法像 Git 一樣先對檔案進行修改不要同步,等修改確定後再同步,也沒辦法開 Branch 做多 feature 開發管理。

不過因為公司有特別需求 (比如檔案使用者有非RD人員不會用 Git、檔案很少修改、程式內容不多之類的) 所以在這部份採用了 OneDrive,為了我自己能夠較好的管理對 OneDrive 裡各 feature 需求的版本控管,我開始想方法用 Git 來對 OneDrive 進行管理。

我的需求是:

  1. 希望能針對不同的 feature 開發建立 branch 來控管並開發,但各 feature branch 在開發時能不修改到 OneDrive 的檔案,希望等到開發完後才將 branch 的修改 merge 至 OneDrive 中的檔案。
  2. 不希望新增目前沒有在 OneDrive 中的不必要檔案,例如 Git 的 .git, .gitnore 等檔案。
  3. OneDrive 中的檔案變動 (例如可能別人修改了檔案) 能夠即時的反映在 Git repository 中,方便我知道別人修改了哪些檔案,在 feature branch merge 時能夠被檔下來得到提醒告知之類的,也要有能處理 conflict 的能力。

最後這是我想到的,利用了 Windows mklink 指令來把 Git repository directory 跟 OneDrive 資料夾同步,並配合 Git worktree 做 feature branch 版控的方法,特此分享:

假設 OneDrive 資料夾位置在

C:\Users\<userName>\xxx-onedrive-folder

先建立資料夾,例如: D:\MyOneDriveRepository\masterBranch
用 git init 把資料夾設定成 git repository,假設一開始的 branch 叫做 master。

然後執行以下指令 (/J 代表 Directory Junction):

mklink /J D:\MyOneDriveRepository\masterBranch\repoLinkToOneDrive C:\Users\<userName>\xxx-onedrive-folder

這樣就會得到一個被建立起來的資料夾:
D:\MyOneDriveRepository\masterBranch\repoLinkToOneDrive

並且 C:\Users\<userName>\xxx-onedrive-folder 和

D:\MyOneDriveRepository\masterBranch\repoLinkToOneDrive
會連結起來成為同步狀態

把 D:\MyOneDriveRepository\masterBranch\repoLinkToOneDrive 連同裡面的檔案都進行第一次的
git commit 就可以開始進行 Git 版本控管了。

因為 OneDrive 可能會跟別人一起合作共用,所以如果在開發新 feature 前我們不希望對 OneDrive 裡的檔案做修改,
也就是說記住不要隨便切換

D:\MyOneDriveRepository\masterBranch

的 branch,讓它永遠在 master branch。

如果有要開發新 feature,我們可以用 git branch <feature branch> 建立新 branch (不要切換過去),例如新 branch 叫 featureBranch,

用 git branch featureBranch 建立新 branch 後,再利用 git worktree 的方式在另外一個資料夾 checkout featureBranch 去開發,
例如開發路徑是 D:\MyOneDriveRepository\otherBranch\repoLinkToOneDrive,
可以執行

git worktree add D:\MyOneDriveRepository\otherBranch featureBranch

這樣就會得到一個被建立的資料夾:

D:\MyOneDriveRepository\otherBranch

我們就可以在裡面開發 featureBranch 的程式了。

最後等 feature 開發完後,可以再回到

D:\MyOneDriveRepository\masterBranch

用 git merge featureBranch --no-ff

來把 featureBranch merge 至 master branch 來改變 OneDrive 的檔案。

這邊放上一張圖解示意圖,可以更好地理解資料夾 Directory Junction 和 Git worktree 等之間的關係:

這樣的作法有幾點好處:

  1. 如果有別人修改了 OneDrive 中的檔案,因為有 Git 管理的關係,我們也可以很容易的發現,例如 git merge 時會因為改到同一個檔案而被擋下來。
  2. 可以把別人的修改 commit 至 master branch 做記錄,雖然不能容易地知道哪幾行是何時被誰修改的有點可惜 (還要特別去 OneDrive 網頁裡查 log 有點太麻煩了 )。
  3. git 生成出來的 .git 檔案不會被上傳到 OneDrive 上,因為我們是對跟 OneDrive 做 Directory Junction 的資料夾的外層資料夾做 git init,所以 .git 並不在跟 OneDrive 做 Directory Junction 的資料夾之中。

參考資料:

  1. 理解 Symbolic Link、Hard Link 與 Directory Junction 的差異之處 | The Will Will Web
  2. git worktree 簡單介紹與使用. 現在可以在同一個專案之中,一次開啟多個不同的 branch 了。 | by Jui Yuan Liou | Medium


2025年8月5日 星期二

Python 學習紀錄

-------------------------- Python 本身相關 -----------------------------------------------------

#Python 的安裝位置可以自行決定,通常預設在
C:\Users\<userName>\AppData\Local\Programs\Python\Python313

#在環境中安裝套件 (預設是全局安裝,如果虛擬環境被啟動就會安裝在虛擬環境)
pip install <package名>

# sys.path 是一個列表,Python 在查找 module 時會從這列表中去找。
# 當用 python ./xxx/yyy.py 時,./xxx 會被加到 sys.path 中。
# 當用 python -m xxx.yyy , . (執行命令時所在的路徑)會被加到 sys.path 中

# -m 參數,把 module 當 script 來執行,後面可接 module name,會把當下執行路徑加到 sys.path 中
#例如:當下在 D:\\my-python,然後要執行 D:\\my-python\\module\\module1.py,命令為
python -m module.module1 (或是 python -m module.module1.py)
#然後因為此時 D:\\my-python 會被加到 sys.path 的關係,在 module1.py 中,就可以直接以 D:\\my-python 為起始目錄來 import 其他 module 了

#為一個專案建立虛擬環境 (virtual environment),會建立一個<虛擬環境名稱>資料夾,其中包括虛擬環境要用的東西,
並且會把<虛擬環境名稱>的全域路徑加到 sys.path 變數中 (因為是全域路徑,所以我想專案移動檔案位置可能會有問題)
pyton -m venv <虛擬環境名稱>
#例:
python -m venv .venv

#啟動虛擬環境 (有些 IDE 可能可以自動識別 .venv,就不用特別下指令啟動虛擬環境)
source .venv/bin/activate

#輸出當前環境安裝 (全域或是虛擬環境中) 的所有套件及其版本到<requirements 套件 list file path>
pip freeze > <requirements 套件 list file path>
#例:
pip freeze > requirements.txt
#讀取<requirements 套件 list file path> 並在環境中安裝套件
pip install <requirements 套件 list file path>
#例:
pip install -r requirements.txt

#官方的套件依賴配置文件,可取代 requirements.txt
pyproject.toml

#依照 pyproject.toml 安裝套件依賴
#參數: -e: 不要把專案原碼放到 .venv/Lib/site-packages 資料夾中
pip install [-e] .

-------------------------- pipx 工具相關 -----------------------------------------------------
官網

pipx — Install and Run Python Applications in Isolated Environments

pipx 是一個可以安裝及管理 Python 工具的工具,它可將要安裝的 Python 工具安裝到不是全域路徑的位置,方便在不汙染全域環境下較好管理工具。

例如 tool_1 依賴 xxx_package v1 ,但 tool_2 依賴 xxx_package v2,如果都裝在全域下可能就會有衝突問題。
工具安裝位置會像這樣:
不安裝到
C:\Users\<userName>\AppData\Local\Programs\Python\Python313\Lib
改安裝到
C:\Users\<user name>\.local\bin

#安裝 pipx 工具
pip intall [--python <python versoin>] pipx

#用 pipx 安裝並管理其他工具
pipx install <其他工具>
#例如安裝 uv
pipx install uv

#例出安裝的所有工具(包括各個的 Python 版本)
pipx list

--------------------------- uv 工具相關 ----------------------------------------------------

官網

uv - An extremely fast Python package and project manager, written in Rust.

uv 是一個可以幫助我們管理專案的套件依賴的工具,隔離各專案避免套件依賴裝在全域造成互相衝突。

#安裝 uv 工具
pip intall uv

#例出管理的 Python
uv python list

#安裝移除特定版本的 Python
uv python install <版本號>
uv python uninstall <版本號>

#設定 uv 預設使用的 Python 版本
uv python pin <版本號>

#初始化專案資料夾
uv init

#建立並使用 venv 虛擬環境,等同 python -m venv .venv 加 source .venv/bin/activate
uv venv

#安裝套件,並修改 pyproject.toml
uv add <套件名>

#移除套件,並修改 pyproject.toml
uv remove <套件名>

#讀取 pyproject.toml、建立虛擬環境 (只有建立沒有啟動)、並安裝依賴
uv sync

#在虛擬環境上下文 (Context) 中執行 Python 檔 (啟動虛擬環境、執行 Python 檔、再退出虛擬環境)
uv run <要執行的 python 檔>

#要使用 -m 參數時,例如要執行 python -m xx.yy.zz.py 時可用
uv run python -m xx.yy.zz

uv build

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