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 的綁定
  },
  controller: function () {
    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.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

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



2025年6月23日 星期一

關於 Unicode等價性 (Unicode Normalization) - Java - 如何將被拆分的韓文字母正確合併 - Normalizer - NFD, NFC, NFKD, NFKC

因為工作上碰到在儲存使用者輸入的韓文時,得到的是拆分的子音母音而非完整的字,
例如:패스워드 變成 ᄑ ᅢ ᄉ ᅳ ᄋ ᅯ ᄃ ᅳ ,
所以在這裡紀錄一下關於 Java 是如何處理 Unicode等價性 (Unicode Normalization) 的。

先貼上 Wiki 的 Unicode等價性 (Unicode Normalization)  說明描述:

Unicode等價性(英語:Unicode equivalence)是為和許多現存的標準能夠相容,Unicode(統一碼)包含了許多特殊字元。在這些字元中,有些在功能上會和其它字元或字元序列等價。因此,Unicode將一些碼位序列定義成相等的。Unicode提供了兩種等價概念:標準等價和相容等價。前者是後者的一個子集。例如,字元n後接著組合字元~標準等價和相容等價於Unicode字元ñ。而合字ff則只有相容等價於兩個f字元。

Unicode正規化(英語:Unicode normalization)是文字正規化的一種形式,是指將彼此等價的序列轉成同一列序。此序列在Unicode標準中稱作正規形式。對於每種等價概念,Unicode又定義兩種形式,一種是完全合成的,一種是完全分解的。因此,最後會有四種形式,其縮寫分別為:NFC、NFD、NFKC、NFKD。對於Unicode的文字處理程式而言,正規化是很重要的。因為它影響了比較、搜尋和排序的意義。

簡單地說,就是 Unicode等價性定義了有些字元 (或一組字元) 在意義或視覺上可以等價於另一個字元 (或一組字元)。

而 Unicode 正規化 就是字元之前的轉換方式,又分為

  1. NFC (Normalization Form Canonical Composition) :以標準等價方式來分解,然後以標準等價重組之。若是singleton的話,重組結果有可能和分解前不同。
  2. NFD (Normalization Form Canonical Decomposition):以標準等價方式來分解。
  3. NFKC (Normalization Form Compatibility Composition):以相容等價方式來分解。
  4. NFKD (Normalization Form Compatibility Decomposition):以相容等價方式來分解,然後以標準等價重組之。

相容等價標準較寬鬆,函蓋範圍比標準等價大。

例如:

  1. 韓文的字可由母音子音組合而成:ᄑ + ᅢ =  패
  2. 中文有些部首可以跟某些字等價:對 部首的  "⽅" (\u2F45) 做 NFKC 會得到 一般的字 "方" (\u65B9)
  3. 日文的一些複合字:對 "㍿" 做 NFKC 會得到 "株式会社"
  4. 一些符號:對 ㊋ 做 NFKC 會得到 一般的字 "火"
  5. 上標和下標:對 A² 中的 "上標的2" (\u00B2) 做 NFKC 會得到 一般的數字 "2"

在 Java 中可以使用 
Normalizer.normalize("要處理的文字", Normalizer.Form.NFC (或 NFD, NFKC, NFKD));
來處理,下面直接用程式碼示範。

package test;

import java.io.IOException;
import java.text.Normalizer;

public class UnicodeNormalizationTest {

	public static void main(String[] args) throws IOException {
		String str1 = "패스워드";
		String str2 = "패스워드";
		
		System.out.println(str1 + ", " + str2);
		System.out.println(Normalizer.normalize(str1, Normalizer.Form.NFC).equals(str2)); // true
		System.out.println(Normalizer.normalize(str1, Normalizer.Form.NFKC).equals(str2)); // true
		
		/////////////////////
		str1 = "⽅"; // \u2f45
		str2 = "方"; // \u65b9
		
		System.out.println(str1 + ", " + str2);
		System.out.println(Normalizer.normalize(str1, Normalizer.Form.NFC).equals(str2)); // false
		System.out.println(Normalizer.normalize(str1, Normalizer.Form.NFKC).equals(str2)); // true
		
		/////////////////////
		str1 = "㍿"; // \u337f
		str2 = "株式会社";
		
		System.out.println(str1 + ", " + str2);
		System.out.println(Normalizer.normalize(str1, Normalizer.Form.NFC).equals(str2)); // false
		System.out.println(Normalizer.normalize(str1, Normalizer.Form.NFKC).equals(str2)); // true
		
		/////////////////////
		str1 = "²"; // \u00b2
		str2 = "2";
		
		System.out.println(str1 + ", " + str2);
		System.out.println(Normalizer.normalize(str1, Normalizer.Form.NFC).equals(str2)); // false
		System.out.println(Normalizer.normalize(str1, Normalizer.Form.NFKC).equals(str2)); // true
		/////////////////////
		str1 = "㊋"; // \u328b
		str2 = "火";
		
		System.out.println(str1 + ", " + str2);
		System.out.println(Normalizer.normalize(str1, Normalizer.Form.NFC).equals(str2)); // false
		System.out.println(Normalizer.normalize(str1, Normalizer.Form.NFKC).equals(str2)); // true
	}
}

參考資料:

  1. Unicode等價性 - 維基百科,自由的百科全書
  2. Unicode Normalization 文字標準化 | Sean's Note
  3. 从⽅不是方到Unicode正规化NFD, NFC, NFKD, NFKC | 小不的笔记 | 时间之外的往事