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

2026年3月29日 星期日

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

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

模擬環境:

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

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

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

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

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

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

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

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

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

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

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

  </dependencies>

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

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

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

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

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

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

package com.config;

import javax.sql.DataSource;

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

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

@Configuration
public class DBConfig {

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

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

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

package com.bean;

public class MemberBean {

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

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

	public String getName() {
		return name;
	}

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

	public String getEmail() {
		return email;
	}

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

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

package com.bean;

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

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

	public int getId() {
		return id;
	}

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

	public Instant getCreatedDate() {
		return createdDate;
	}

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

	public int getMemberId() {
		return memberId;
	}

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

	public String getDetail() {
		return detail;
	}

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

設定 DAO 的部份 :

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

package com.dao;

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

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

import com.bean.MemberBean;

@Repository
public class MemberDAO {

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

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

package dao;

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

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

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

public class PurchaseOrderDAOTest extends BaseDBTest {

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

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

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

CREATE DATABASE database1;
CREATE DATABASE database2;

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

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

DECLARE @sql NVARCHAR(MAX) = '';

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

EXEC sp_executesql @sql;

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

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

OPEN index_cursor;
FETCH NEXT FROM index_cursor INTO @tableName;

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

CLOSE index_cursor;
DEALLOCATE index_cursor;

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

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

OPEN FullTextCatalogsCursor;
FETCH NEXT FROM FullTextCatalogsCursor INTO @CatalogName;

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

CLOSE FullTextCatalogsCursor;
DEALLOCATE FullTextCatalogsCursor;

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

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

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

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

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

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

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

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

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

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

package dao;

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

import javax.sql.DataSource;

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

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

@SpringJUnitWebConfig(SpringApplicationConfig.class)
public class BaseDBTest {

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

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

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

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

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

package dao;

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

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

public class MemberDAOTest extends BaseDBTest {

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

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

package dao;

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

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

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

public class PurchaseOrderDAOTest extends BaseDBTest {

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

源碼下載分享:

    testcontainers-test.zip

參考資料:

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


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年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