2022年12月11日 星期日

CSS display grid 和 flex 對於子元素 width 的差別

 CSS 的 display grid 和 flex 對於子元素的 width 行為不太一樣,
主要差別是:

Grid:
先以父元素的寬度計算子元素均分後的寬度,子元素各自再用 width 屬性計算出自己最後的寬度,width 是百分比時,最後寬度是以子元素分到的寬度再乘以百分比得到。

Flex:
先以父元素的寬度計算各子元素的寬度,width 是百分比時,寬度是父元素的寬度乘以百分比來得到,之後再用 Flex 排版的方式決定是否寬度要進行延展、縮小或不變 ( 例如 flex-grow、flex-shrink、flex-basis 等設定)。

以下用實際例子來展示:

HTML:

<div class="grid stripes-background">
  <div>grid item1</div>
  <div>grid item2</div>
</div>

<div class="flex stripes-background">
  <div>flex item1</div>
  <div>flex item2</div>
</div>

CSS:

.stripes-background {
  background-image: linear-gradient(45deg, #000 25%, transparent 25%, transparent 50%, #000 50%, #000 75%, transparent 75%, #fff);
  background-size: 50px 50px;
}

.grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
}

.flex {
  background-color: yellow;
  display: flex;
}

.grid div, .flex div {
  border: 1px solid black;
  width: 50%;
  background-color: rgba(0, 256, 0, 0.7);
}

JsFiddle 的線上範例

範例中有兩個區塊,分別使用了 display grid 和 flex,並在父元素和子元素上畫上背景來較好的分辨子元素寬度的分配。

可以看到子元素被設定了 width: 50%;,

在 grid 佈局中,子元素先以父元素的寬度用 flex 佈局計算好了應得的寬度,也就是
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
,然後才用應得的寬度乘上 50% 得到最後的寬度。

而 flex 佈局中,子元素先以父元素的寬度乘上 50% 得到應得的寬度,之後才進行 flex 佈局,而此例剛好沒有要對子元素寬度進行增縮。

簡單地來說,

Grid 是先在父元素把分配的空間格線畫好,再把子元素放進各空間中,此時子元素的 100% 寬度就是所處空間的寬度,最後各子元素再根據自己的 width 屬性設定計算寬度。

而 Flex 是子元素先根據自己的 width 屬性計算寬度,100% 寬度就是父元素的寬度,接著再用 Flex 進行排版決定各子元素在空間上的排列位置,最後再看有沒有要用 flex-grow、flex-shrink、flex-basis 等設定增縮子元素的寬度。

2022年11月27日 星期日

練習自製簡單的 Javascript Promise 物件

繼  jQuery的Callbacks 及 jQuery之Deferred的原理(參看源碼) 兩篇文後,
今天想以自己的想法參考兩篇文來自製一個簡單的、API 介面仿照
Javascript 原生 Promise 物件的 MyPromise 物件,
主要是希望能在自己實作的過程中,能夠更理解對程式的邏輯及設計思路。

在實作之前,首先要來簡單的介紹 Javascript 原生的 Promise:
Promise 的功能及性質類似 JQuery 的 $.Deferred() 及 Angularjs 的 $q 等,
在早期瀏覽器還沒有實作 Promise 物件時,JQuery 就已經自己實作了 Deferred ,
可以用來方便地管理非同步異步操作。

建立 Promise 物件的方法是 new 關鍵字,例如:

var promise = new Promise(function(resolve, reject){
    setTimeout(function(){
    	var resolvedData = 1;
    	resolve(resolvedData);
    }, 1000);
});

Promise 的建構式 (constructor) 接受一個函式,函式可以得到兩個各叫做 resolve 和 reject 的函式,
當異步操作完成後,可以執行 resolve(resolvedData) 函式,並可以送入一個想要傳遞給下一個異步操作的 resovledData 資料作參數。

當 resolve() 被執行後,可以以串接的方式用 then() 函式來串接下一個想要執行的異步操作,
例如:

var promise = new Promise(function(resolve, reject){
	setTimeout(function(){
		console.log(1);
		resolve(2);
	}, 3000);
})
.then(function(resolvedData){
	setTimeout(function(){
		console.log(resolvedData);
		resolve(3);
	}, 2000);
})
.then(function(resolvedData){
	setTimeout(function(){
		console.log(resolvedData);
		resolve();
	}, 1000);
});

上面的例子會在 3 秒後在 console 印出 1,之後再 2 秒印出2,再 1 秒印出3

原生的 Promise 實際上瀏覽器是如何實作它們的我並不清楚,
不過在這篇文中我嘗試試著以自己的想法模擬實現部份的介面功能來作為練習,
主要實現的部份有:

  1. 自己實作的 Class 名稱名命為 MyPromise
  2. 能夠以
    new Promise(function xxxFunction(resolve){
    	//do somthing
    	resolve(resolvedData);
    }).then(function(resolvedData){
    	//do somthing
    	return new Promise(function(resolve){
    		//do somthing
    	});
    }).then(function(resolvedData){
    	//do somthing
    	return new Promise(function(resolve){
    		//do somthing
    	});
    })
    的方式建立 Promise 的物件,並以傳入 xxxFunction(resolve) 的 resolve 函式來 resolve Promise,其中可以傳入 resolvedData 資料到下一個 Promise 中,下一個 Promise 用 then(fujction (rsolvedData)) 來接收 resolvedDatat 並可以繼續回傳新的 Promise 來以同樣的方式用 resolve 串接到下個 Promise。
  3. 只實現 resolve 相關的部分,忽略了 reject 的部份,也省略了一些如 Promise.all, Promise.race 等的函式實作。
  4. 實現方式參考了 JQuery 的 Deferred 物件實作思路,使用一個佇列列隊 (queue) 的方式來實現,其中:
    resolve(resolvedData) 相當於是執行佇列中的所有函式,並且之後有函式被添加至佇列時也會馬上被執行,resovledData 會被儲存起來供要執行佇列中的函式時作為輸入用。
    then() 被執行時會回傳一個 Promise,這裡暫且稱為 Promise2 ,
    Promise2 可以繼續地用 then() 往下串接。
    then() 的行為是把要執行的函式添加至佇列中,
    這裡暫稱要執行的函式為 thenFun(),
    thenFunc() 被執行後會回傳一另一個 Promise,這裡暫且稱為 Promise3,
    我們會把 Promise2 的 resolve() 函式加進 Promise3 的佇列中,
    這樣在 Promise3 在 thenFunc() 被執行 reolsve() 後,就會執行被放在 Promise3 佇列中的 Promise2 的 resolve(),
    進而讓設定在 Promise2 佇列中的函式被執行。
下面是實現的程式碼和使用的範例:
function MyPromise(callbackFunc){
  var promise = this;
  
  promise.status = "pending"; //pending, resolved
  promise.callbackQueue = [];
  promise.resolvedData = null;
  
  promise.addCallbackFuncToQueue = function(callbackFunc){
  	promise.callbackQueue.push(callbackFunc);
    if (promise.status == "resolved"){
    	promise.fireCallbackFuncInQueue();
    }
  }

  promise.resolve = function(resolvedData){
  	if (promise.status != "resolved"){
      promise.resolvedData = resolvedData;
      promise.status = "resolved";
      promise.fireCallbackFuncInQueue();
    }
  }
  
  promise.fireCallbackFuncInQueue = function(){
    while(promise.callbackQueue.length > 0){
      var callbackFuncToFire = promise.callbackQueue.shift();
      callbackFuncToFire.call(promise, promise.resolvedData);
    }
  }
  
  promise.then = function(thenCallbackFunc){
  	return new MyPromise(function(resolve){
    	promise.addCallbackFuncToQueue(function(data){
      	var returnedPromiseFromThenCallbackFunc = thenCallbackFunc.call(promise, data);
        returnedPromiseFromThenCallbackFunc.addCallbackFuncToQueue(resolve);
      });
    });
  }

  callbackFunc.call(promise, this.resolve);
}
/////////////////////////////////////////////////
new MyPromise(function(resolve){
	console.log(1);
  setTimeout(function(){
  	resolve(2);
  }, 2000);
})
.then(function(data){	
  return new MyPromise(function(resolve){
  	console.log(data);
  	setTimeout(function(){
  	resolve(3);
  }, 1000);
  });
})
.then(function(data){	
  return new MyPromise(function(resolve){
  	console.log(data);
  	resolve();
  });
});
JSFiddle 的線上範例:


參考資料:

2022年11月25日 星期五

Microfost 官方 JDBC driver 的 getMoreResults Method (int current) 不支援以 Statement.KEEP_CURRENT_RESULT = 2 做為輸入值

Java 的 java.sql.CallableStatement 類別有一個 叫做 getMoreResults(int current) 的 method,
可以傳入 int current 作為參數,
int current 的值可以使用 java.sql.Statement 的一些 static 屬性值,
例如:

Statement.CLOSE_CURRENT_RESULT = 1

Statement.KEEP_CURRENT_RESULT = 2

但要注意不是每一個 JDBC driver 都支援所有值,
例如 Microfost 官方 getMoreResults Method (int) 這篇文有說到其官方 JDBC driver

(com.microsoft.sqlserver.jdbc.SQLServerDrive)
並不支援
Statement.KEEP_CURRENT_RESULT :
    ==> KEEP_CURRENT_RESULT (not supported by the JDBC driver)




2022年11月23日 星期三

配合 Fetch 使用 MediaSource 播放 FMP4 - 影片輸出使用 jsp 或 servlet

 html 裡用 <video> 播放 MP4 影片檔時,
需要完全的讀取完影片檔才能播放,如果影片本身較長檔案大小較大時,
就會需要等待較長的時間等影片讀取完後才能開始播放,
因為 <video> 需要讀完影片檔中含有影片資訊的 moov (Movie Box) 區塊才會開始播放影片。

我們可以使用 Bento4 工具來查看影片中的檔案結構,
使用 Bento4 的 mp4dump 命令執行以下指令:

D:\Bento4\bin\mp4dump xxx_video.mp4

就可以在命令列視窗中看到影片的結構,

通常的 MP4 除了一開始的含有影片版本協議等資訊的 ftyp (File Type Box) 區塊,
接著的會是一個含有影片資訊的 moov (Movie Box) 區塊和一個含有實際影片數據的 mdat (Media Data Box) 區塊 (其他區塊可先忽略),像這樣:

ftyp + mdat + moov

mdat 和 moov 的擺放位置順序可以換,如果 moov 在 mdat 之後的話,
<video> 會需要在讀取完 moov 後才會開始播放影片。

如果想要提前 <video> 的影片開始播放時間的話,有幾種解決方法:

  1. 第一個方法是把 moov 放到 mdat 之前,
    讓 <video> 先讀取到 moov 的影片資訊,
    這樣 <video> 就可以不用等到影片完全載入完就開始播放影片,
    檔案結構會像這樣:

    ftype + moov + mdat

    使用 ffmpeg 工具執行以下指令就可以將影片的 moov 搬到 mdat 前面,可參考"ffmpeg 常用命令"這篇文章:
    D:\ffmpeg\bin\ffmpeg -i "normal_video.mp4" -movflags faststart -acodec copy -vcodec copy "moov_moved_video.mp4"
    第二個方法是把 MP4 轉成 FMP4 (fragmented mp4),
  2. FMP4 跟一般的 MP4 不同,
    除了有 moov 以外,還有 moof (movie fragment) 。
    一般的 MP4 檔案結構為一個 moov 和一個 mdat,
    而 FMP4 的檔案結構把影片分割成多個片段 (fragment),
    每個片段由一組 moof 和一個 mdat 組成,
    moof 包含了後面跟著的 mdat 的資訊,
    所以 <video> 可以在讀取影片時,先對讀到的片段進行播放,再一邊繼續讀取剩餘的片段,
    檔案結構會像這樣:

    ftype + moof + mdat + moof + mdat + moof + mdat + ........

    使用 Bento4 工具的 mp4fragment 命令可以方便地將 MP4 影片轉成 FMP4,
    執行以下指令即可:
    D:\Bento4\bin\mp4fragment "normal_video.mp4" "fragmented_video.mp4"
    也可使用 ffmpeg 工具來進行 FMP4 的轉檔,以下是兩種我在網路上找到的指令,
    參數有點不同,不過親自實測都是可以成功轉檔的,
    但我使用轉出來的檔案實作邊下載邊播放時有點問題,
    有時會出現
    Uncaught (in promise) DOMException: Failed to execute 'appendBuffer' on 'SourceBuffer': The HTMLMediaElement.error attribute is not null.
    的錯誤訊息,所以我還是會先以用 Bento4 轉檔的檔案來做實作:
    D:\ffmpeg\bin\ffmpeg -i "normal_video.mp4" -strict -2 -movflags frag_keyframe+empty_moov "fragmented_video.mp4"

    D:\ffmpeg\bin\ffmpeg -i "normal_video.mp4" -movflags empty_moov+default_base_moof+frag_keyframe "fragmented_video.mp4"

成功把影片轉成 FMP4 後,就可以配合 Javascript 的 Fetch 功能實作邊下載影片檔邊播放的功能,在這裡我要展示一下使用 Javscript 的 Fetch 來邊讀取邊播放來自 servlet (或 jsp 或 nodeJs 等任何你喜歡的 server 程式碼)  的FMP4影片二進位流 (不支援非 FMP4 的影片):

首先是專案檔案結構,其中紅框部份是主要需要的檔案,此專案是一個 Java EE Web Application 專案,使用 Tomcat server 運行:




專案中放了三個測試的影片檔如下:

  1. normal_video.mp4:
    普通的影片檔,格式如:ftyp + mdat + moov
  2. moov_moved_video.mp4:
    把 moov 放到 mdat 之前的影片檔,格式如:ftype + moov + mdat
  3. fragmented_video.mp4:
    轉檔成 FMP4 的影片檔,格式如:ftype + moof + mdat + moof + mdat + moof + mdat + ........

首先是把影片檔用二進位輸出的 servlet, GetVideoBinary.java
/testWeb/src/test/GetVideoBinary.java:
package test;

import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.net.URLConnection;

import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

@WebServlet("/GetVideoBinary")
public class GetVideoBinary extends HttpServlet {
	private static final long serialVersionUID = 1L;
       
    public GetVideoBinary() {
        super();
    }

	protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
		String videoType = request.getParameter("videoType");
		if (videoType == null) {
			videoType = "";
		}
		
		String videoSourceDir = "/source";
		String videoSourceName = "normal_video.mp4";
		switch(videoType) {
			case "moov_moved_video":
				videoSourceName = "moov_moved_video.mp4";
				break;
			case "fragmented_video":
				videoSourceName = "fragmented_video.mp4";
				break;
		}
		String videoSourcePath = videoSourceDir + "/" + videoSourceName;
		
		String contentType = URLConnection.guessContentTypeFromName(videoSourceName);
		if (contentType == null) {
			contentType = "application/octet-stream";
		}
		response.setContentType(contentType);
		
		try (BufferedInputStream inputStream = new BufferedInputStream(this.getClass().getResourceAsStream(videoSourcePath));
			 OutputStream outputStream = response.getOutputStream();
			){
			byte[] buffer = new byte[1024];
			int numRead = 0;
			while ((numRead = inputStream.read(buffer)) != -1) {
				outputStream.write(buffer, 0, numRead);
			}
		}
	}

	protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
		service(request, response);
	}

	protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
		doGet(request, response);
	}

}

在 GetVideoBinary.java 中,接受了一個 "videoType" 參數,來決定要讀取哪一個影片檔做輸出。

再來是主要的測試頁, index.html
/testWeb/WebContent/readableStreamTest/index.html:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Readable Stream Test</title>

<script src="js/fmp4Player.js"></script>
<script>
	readAndPlayFMP4("fragmented_video");
</script>

</head>

<body>
	<div>
		normal_video: 
		<video muted controls autoplay playsInline style="width: 500px;" id="normal_video" src="/GetVideoBinary?videoType=normal_video"></video>
	</div>
	<div>
		moov_moved_video: 
		<video muted controls autoplay playsInline style="width: 500px;" id="moov_moved_video" src="/GetVideoBinary?videoType=moov_moved_video"></video>
	</div>
	<div>
		fragmented_video:
		<video muted controls autoplay playsInline style="width: 500px;" id="fragmented_video"></video>
	</div>
</body>
</html> 

index.html 內容非常簡單,頁面中有三個 <video>, 分別用來播放上述的三個 MP4 影片,
normal_video.mp4 和 moov_moved_video.mp4 是直接設定影片 url 在 <video> 的 src 屬性上,
而 fragmented_video 則是呼叫了 "js/fmp4Player.js" 中的 readAndPlayFMP4 函式來載入影片。

最後我們來看一下 "js/fmp4Player.js",
/readableStreamTest/js/fmp4Player.js:

function readAndPlayFMP4(videoPlayerId){
	//使用 fetch 讀取影片二進位流
	fetch('/GetVideoBinary',
		{
			method : 'POST',
			headers : {
				'Content-Type': 'application/x-www-form-urlencoded'
			},
			body: "videoType=" + videoPlayerId
		}).then(function(response) {
			var videoPlayer = document.getElementById(videoPlayerId);
	
			var mimeCodec = 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"';
	
			if ('MediaSource' in window && MediaSource.isTypeSupported(mimeCodec)) {
				var mediaSource = new MediaSource();
				videoPlayer.src = URL.createObjectURL(mediaSource);
				mediaSource.addEventListener('sourceopen', sourceOpen);
			} else {
				console.error('Unsupported MIME type or codec: ', mimeCodec);
			}
	
			function sourceOpen(_) {
				//console.log(this.readyState); // open
				var mediaSource = this;
				var sourceBuffer = mediaSource.addSourceBuffer(mimeCodec);
				
				var dataSetReductionStatus = []; // 用來顯示目前 dataset 被拆分的狀態以利觀察 Record current data reduction status for debug
				
				var reader = response.body.getReader();
				readNextDataset();
				
				function readNextDataset(){
					// 遞迴地讀取影片的二進位流資料 Read video data recursively
					return reader.read().then(function(response){
						if (response.done) {
							//讀取完畢,已沒有可讀取的影片二進位流資料,告訴 mediaSource 所有影片資料已讀取完畢
							mediaSource.endOfStream(); //tell media source that it reaches the end of stream, all data from steam has been send.
							console.log("All video data handling complete");
							return Promise.resolve();
						}else{
							// response.value is Uint8Array trype
							dataSetReductionStatus.unshift(response.value.byteLength);
							//把這一波讀到的影片資料 (response.value) 交由 handleData() 處理
							return handleData(response.value).then(function(){
								// 這波讀取的影片資料均已處理完畢,開始讀取下一波影片資料
								console.log("A set of data (total: " + response.value.byteLength + ") handling complete");
								readNextDataset();
							});
						}
					});
				}
				
				function handleData(data){
					return new Promise(function(resolve, reject){
						// 在 sourceBuffer.appendBuffer() 之後,必須等到 updateend 事件發生後才能再進行下一次的 sourceBuffer.appendBuffer()
						// Need to wait for "updateend" event to do next sourceBuffer.appendBuffer()
						sourceBuffer.addEventListener('updateend', sourceBufferUpdateendListener);
						function sourceBufferUpdateendListener(_) {
							console.log("updateend");
							dataSetReductionStatus.shift();
							sourceBuffer.removeEventListener('updateend', sourceBufferUpdateendListener);
							resolve();
						}
						
						try {
							// 印出 dataSetReductionStatus 觀察影片資料拆分的狀態
							console.log("dataSetReductionStatus: <-- " + dataSetReductionStatus + " <--");
							console.log("Try to append: " + data.byteLength);						
							sourceBuffer.appendBuffer(data); // sourceBuffer.appendBuffer() 有可能得到 "QuotaExceededError" 例外 May catch 'QuotaExceededError' exception
							console.log(data.byteLength + " data was appended.");
					    } catch (e) {
					    	sourceBuffer.removeEventListener('updateend', sourceBufferUpdateendListener);
					        if (e.name !== 'QuotaExceededError') {
					        	throw e;
				        	}
					        console.log("QuotaExceededError!");
				        	// 嘗試進行拆分影片資料分別處理,例如此例把影片拆分成總大小(byte) 80% 和 20% 大小的兩個部份 Split data and handle each reduced part of data, for example: reduction: 80%.
							var reduction = data.byteLength * 0.8;
							
							if (data.slice(0, reduction).byteLength == 0){
								// 如果拆分後 80% 那部份大小為 0 的話,就不拆分,還是處理 100% 的影片資料,但是等一段時間例如 1 秒後再處理
								// 以遞迴方式處理資料
								console.log("Try to append " + data.byteLength + " after waiting for 1 second.");
								setTimeout(function(){
					        		handleData(data).then(resolve);
								}, 1000);
							}else{
								dataSetReductionStatus.shift();
								dataSetReductionStatus.unshift(data.slice(reduction).byteLength);
								dataSetReductionStatus.unshift(data.slice(0, reduction).byteLength);
								
								// 各別以遞迴方式處理拆分後的資料
								// Handle first part of reduced data
								console.log("Split data");
								handleData(data.slice(0, reduction))
								.then(function(){
									// Handle second part of reduced data
									return handleData(data.slice(reduction));
								})
								.then(resolve);	
							}
					    }
					});
				}
			};		
	});
}

相關的說明已寫在程式碼的註解當中,這邊再做一些說明:

  1. 程式使用了 Javascript 的 fetch() 來進行影片資料的讀取,跟 XMLHttpRequest 不同,
    XMLHttpRequest 只能在讀取完全部資料後進行資料處理,
    但 fetch() 可以使用在 callback 函式中得到的 response 用 response.body.getReader() 來得到
    ReadableStream Reader,可以多次地使用 ReadableStream Reader 的 read() 來讀取部份資料,不用一次全部讀完。
  2. 當進行 sourceBuffer.appendBuffer() 把一段影片資料放到 sourceBuffer 時,
    可能會因為 Buffer 已滿而碰到 QuotaExceededError,
    這時程式會進行資料拆分,例如以 byte 為單位,先處理前 80% 的資料,再處理後 20% 的資料,
    而如果處理各拆分的資料還是碰到 QuotaExceededError 的話,就再繼續遞迴地進行拆分處理,
    最後如果要被處理的資料只有 1 byte,則不再進行拆分,當碰到 QuotaExceededError 時,
    則等待 1 秒鐘讓 Buffer 有空間時再遞迴地進行處理。
  3. 程式使用了 Promise 來處理非同步的影片處理,
    handleData() 會回應一個 Promise 物件,
    當它被 resolve() 時代表一段由 readNextDataset() 讀取到的影片資料被處理完畢,
    因為在處理影片時,有可能會碰到 QuotaExceededError 而需要進行影片資料的拆分,
    還有在 sourceBuffer.appendBuffer() 時需要等待 updateend 事件發生後才行進行下一次的 sourceBuffer.appendBuffer(),
    這些都是非同步的行為,所以使用 Promise 可以較好的處理流程。
  4. 程式中為了方便地觀察每一次由 readNextDataset() 讀取到的影片資料,
    在 sourceBuffer.appendBuffer() 時,資料是被拆分成何種狀態,
    所以建立了一個名為 dataSetReductionStatus 的 Arry 物件來觀察,
    例如一開始要處理的資料是 1024 byte 時,
    dataSetReductionStatus = [1024]
    拆分成 819 byte 和 205 byte 時,則是
    dataSetReductionStatus = [819, 205]。


最後下面是程式執行的樣子,
normal_video.mp4 會等影片全部讀取完才會開始播放,
moov_moved_video.mp4 會在讀到 moov 時就會開始播放,
fragmented_video.mp4 會在影片檔一邊下載一邊一段段的播放各個 fragment (mdat),
連覽器的 console 則是會列印出一些 debug 訊息。



原始碼分享:

  1. readableStreamTest.zip (內含範例使用的三個 MP4 影片檔)

資原分享:

  1. Bento4-SDK-1-6-0-639.x86_64-microsoft-win32.zip
  2. ffmpeg.zip
  3. 在這邊可以免費下載容量較大片長較長的影片做測試用:
    https://www.learningcontainer.com/mp4-sample-video-files-download/
    我範例使用的影片是:
    https://jsoncompare.org/LearningContainer/SampleFiles/Video/MP4/Sample-Video-File-For-Testing.mp4

參考資料:

  1. 5分钟入门MP4文件格式
  2. MediaSource错误:Failed to execute"endOfStream" on "MediaSource": The MediaSource‘s readyState is not “open”.
  3. Exceeding the buffering quota
  4. 4.22 mov, mp4, ismv
  5. JavaScript Promise 全介紹

2022年11月18日 星期五

Linux Shell Script 將以空格間隔的字串轉成 Array 型別變數的方法

在 Linux Shell 中,用空格做間隔的文字並不是一個 Array 變數,
例如:
"a b c"
雖然可以用 for in 來遍歷印出,例如:
string="a b c";

for element in string;
do
    echo ${element};
done
會印出:
a
b
c
但如果是下面想用 index 來得到 Array 值的方式的程式:
string="a b c";

for ((i=0; i < ${#string[@]}; i++));
do
    echo ${i};
    echo ${string[${i}]};
done
則會得到輸出:
0
a b c
而不是預期的:
0
a
1
b
2
c

如果要用 index 來得到 Array 值的話,必須先把字串轉成 Array,
使用小括弧即可做到,像這樣 array=(a b c),例如:
string="a b c";
string_array=(${string})

所以正確的寫法應如下:

string="a b c";
string_array=(${string});
for ((i=0; i < ${#string_array[@]}; i++));
do
    echo ${i};
    echo ${string_array[${i}]};
done
上例會印出:
0
a
1
b
2
c

Note:

  1. array[@] 等同 array[*],代表 array 的所有值。
  2. #array[@] 代表取 array 的長度。

2022年11月17日 星期四

Git 參考資料 - 好用指令

Git 好用指令

----------------------- 與 Log 相關 --------------------
# 用指定的 format 印出 從 <commit id> (包含) 到 <commit id2> 的所有 commit
# <commit id>...<commit id2> 可代表 <commit id> 後一個 commit 到 <commit id2> 的所有 commit (不包括  <commit id>),所以
<commit id>^...<commit id2> 代表 <commit id> 的 parent 的後一個 commit ,也就是 <commit id> 自己,到 <commit id2> 的所有 commit
git log --pretty=format:"%H %s" <commit id>^...<commit id2>
----------------------- 與 Remote Repository 相關 --------------------
# 在空的資料夾將 <git repository> 的 git project 抓下來
git clone <git repository>

-----------------------------------------------------------------------------------------------------
# 上傳分支,-u 表示並且追蹤遠端的分支,之後就不用再加 -u
git push <remote name> <branch name> 
git push -u <remote name> <branch name> 

# 只把 remote 的版控抓下來,但沒有要 merge 到 local branch 上, --all 表示抓取所有的 remote
git fetch
git fetch --all 

# 等同 git fetch 和 git merge 的結合,把 remote 的版控抓下來,並 merge 到 local branch 上
git pull
如果想再產生一個 merge commit,建議可加 --rebase 參數,行為會變成
等同 git fetch 和 git rebase 的結合
git pull --rebase

----------------------- 與 Local Repository 相關 --------------------
# 移動 HEAD 到 <commit id> 的 patch
git checkout <commit id>

# 將 <file path> 檔案還原成 <commit id> patch 的內容 (不小心被自己刪掉的檔案也可以),
# <file path> 可以是資料夾路徑或檔案路徑,路徑是從 cmd 指令所在位置開始算
git checkout <commit id> -- <file path>
Ex.: git checkout <commit id> -- *

# 刪除工作區中所有沒有被 tracked 的檔案,參考
git clean
# -n 參數:只顯示執行 git clean 後會被刪除的檔案,不會真得刪除檔案,只是做一次演習,告訴你真得執行 git clean 後會被刪除的檔案
git clean -n
# 刪除指定 <path> 路徑下的沒有被 track 過的檔案
git clean -f <path>
# 刪除當前目錄下沒有被 track 過的檔案和資料夾
git clean -df
# 刪除當前目錄下所有沒有track過的檔案. 不管他是否是.gitignore檔案裡面指定的資料夾和檔案
git clean -xf

# 捨棄 <commit id> 之後的 patch (即等於放棄之前的 commit),並且將 HEAD 移至 <commit id> patch
git reset <commit id>
(工作區檔案內容不被影響)
git reset --hard <commit id>
(工作區檔案內容會被影響而被更新)

# 直接 reset 成指定的 patch,注意: 會捨棄 <commit id> 之後的 commit,
# 不要在 <commit id> 之後的 commit 已 push 至 remote 的情況下使用!!避免影響到同仁,除非此 branch 只有你在用
git reset --hard <commit id> 

# 把 <commit id> 的 patch 複製一份 (原來的 patch 還是會留著) 到目前位置的後面 (一樣會產生新的 commit id),
加 --edit 參數可以用指定的編輯器輸入 message,
加 -x 參數可以自動地加入 (cherry-pick from <xxxCommitId>) 的 message
git cherry-pick <commit id> 
# cherry pick commitB 之前但不包括 commitA 的所有 commit
git cherry-pick <commitA id>...<commitB id>
# cherry pick commitB 之前但不包括 commitA的parent 的所有 commit,
等同於 cherry pick commitA 到 commitB的所有 commit
git cherry-pick <commitA id>^...<commitB id>

#將現在的 patch 接到 <branch> 的後面
git rebase <branch>

#互動式 git rebase
git rebase -i <after this commit>
更改 <after this commit> 之後(不包含 <after this commit>)的 patch,
會使用互動介面(可能是文字編輯器或 console,看你設定),
可改變 patch 順序、合併 patch (squash)、刪除 patch、修改 patch 註解、修改 patch 檔案內容等

git rm <file> --cached
移除 <file> 檔案將之不再被 git 控管 (在本機上不會被刪除),相當於在 repository 中移除檔案,
可以用 .gitignore 檔配合 cmd 指令來從 patch 中清除不想被 git 控管的檔案
for /F "tokens=*" %a in ('git ls-files -ci --exclude-standard') do @git rm --cached "%a"
可用以下指令查看會清除哪些檔案
Linux/MacOS:
git ls-files -ci --exclude-standard -z | xargs -0 git rm --cached

Windows (PowerShell):
git ls-files -ci --exclude-standard | % { git rm --cached "$_" }

# stash 暫存操作,將在工作區的檔案暫存
git stash
git stash pop
----------------------- 與 Git 設定 相關 --------------------
git config --global core.editor "'D:\notePad++\notepad++.exe' -multiInst -nosession"
指定 notePad 為 git 互動模式時用的編輯器

----------------------- 關於 commit, merge 等 message 中同時要輸入雙引號或單引號的方法  -----------
如果 git commit 或 merge 的 message 同時想要輸入雙引號或單引號的話,
可以用單引號圍住 message,然後
用三個雙引號表示一個雙引號,例如: """
用兩個單引號表示一個單引號,例如: ''
範例:
如果 message 是
this is double quote: " and this is single quote: '
可以如下輸入:
git merge xxxBranch -m 'this is double quote: """ and this is single quote: '''

----------------------- 關於 commit, merge 等 message 中要輸入換行的方法  -----------
如果 message 是(aaa 換行再 bbb):
aaa
bbb
可以如下輸入:
git commit -m 'aaa
bbb'
git commit -m "aaa
bbb"

----------------------- git-svn 的指令 (git 跟 svn 共同合做)  -----------
# 從 svn 上把 commit 抓下來,跟 git pull --rebase 功能一樣,只是是從 svn 上抓
git svn rebase
# 將 commit push 至 svn,跟 git push 功能一樣,只是是 push 至 svn
git svn dcommit

參考資料:

  1. SVN Migrate to Git

2022年10月18日 星期二

安裝有 full-text search 功能的 mssql Docker container

如果有使用 mssql 官方的 Docker image 的話,
會發現並沒有預設安裝 Full-Text Search 功能 (可以提供 contains({columnName}, {containsValue}) 等語法),
這時候就要進入 mssql 的 Docker container 手動下指令進行安裝,
或是直接修改 Dockerfile 在裡面寫上安裝 Full-Text Search 功能的指令。

以下分享如何進行 MS Sql Full-Text Search 功能的安裝,
首先可以先用以下 Sql 指令判斷有無已安裝了 Full-Text Search

select FULLTEXTSERVICEPROPERTY('IsFullTextInstalled')

如果查詢結果是 1 就是有安裝,否則就是 0 代表沒安裝。

接著修改 Dockerfile 如下所示:

FROM mcr.microsoft.com/mssql/server:2019-latest

USER root

RUN export DEBIAN_FRONTEND=noninteractive && \
apt-get update --fix-missing && \
apt-get install -y gnupg2 && \
apt-get install -yq curl apt-transport-https && \
curl https://packages.microsoft.com/keys/microsoft.asc | tac | tac | apt-key add - && \
curl https://packages.microsoft.com/config/ubuntu/20.04/mssql-server-2019.list | tac | tac | tee /etc/apt/sources.list.d/mssql-server.list && \
apt-get update

RUN apt-get install -y mssql-server-fts

CMD /opt/mssql/bin/sqlservr

EXPOSE 1433

就可以成功安裝 Full-Text Search 了

參考資料:

  1. Issue Full-Text Search is not installed, or a full-text component cannot be loaded. on windows container #161
  2. How to detect if full text search is installed in SQL Server
  3. 適用于 Microsoft 產品的 Linux 軟體存放庫

2022年10月15日 星期六

避免在用 Maven 建置 war 檔時, minify-maven-plugin 最小化過的檔案在 package phase 被 maven-war-plugin 用 source code 蓋掉

當我們在使用 Maven 的 maven-war-plugin 建置 war 專案時常會發現,
如果有使用一些 javascript/css 最小化 (minify) 的 plugin,
例如 minify-maven-plugin,
minify-maven-plugin 會先執行把 js/css 檔做最小化,
然後 maven-war-plugin 會在 package phase 時從專案把未被最小化的 source code 放到
輸出資料夾中 (包括 war 檔),此時未被最小化的 source code 
會把己被 minify-maven-plugin 最小化的檔案給覆蓋掉,
造成最後輸出資料夾、war 檔裡的檔案沒有成功被最小化。

以下分享一個解決的方法,
可以使用 minify-maven-plugin 的 <webappTargetDir> 設定
把最小化過的檔案放在另一個資料夾下,
例如:   ${basedir}/target/minify (等同  ${project.build.directory}/minify) 資料夾中 ("minify" 資料夾名可自行決定),

<webappTargetDir>${project.build.directory}/minify</webappTargetDir>

然後再使用 maven-war-plugin 的 <webResource> 設定將 ${basedir}/target/minify 資料夾中的
最小化檔案放回至專案輸出資料夾中 (也包括 war 檔裡)

<webResource>
    <directory>${project.build.directory}/minify</directory>
</webResource>

如以下範例:

<plugin>
	<groupId>com.samaxes.maven</groupId>
	<artifactId>minify-maven-plugin</artifactId>
	<version>1.7.6</version>
	<executions>
	  <execution>
		<id>default-minify</id>
		<configuration>
		  <skipMerge>true</skipMerge>
		  <nosuffix>true</nosuffix>
		  <webappSourceDir>${basedir}/WebContent</webappSourceDir>
		  <webappTargetDir>${project.build.directory}/minify</webappTargetDir>
		  <cssSourceDir>./</cssSourceDir>
		  <cssSourceIncludes>
			<cssSourceInclude>**/*.css</cssSourceInclude>
		  </cssSourceIncludes>
		  <cssSourceExcludes>
			<cssSourceExclude>**/*.min.css</cssSourceExclude>
		  </cssSourceExcludes>			  
		  <jsSourceDir>./</jsSourceDir>
		  <jsSourceIncludes>
			<jsSourceInclude>**/*.js</jsSourceInclude>
		  </jsSourceIncludes>
		  <jsSourceExcludes>
			<jsSourceExclude>**/*.min.js</jsSourceExclude>
			<jsSourceExclude>**/node_modules/**/*.js</jsSourceExclude>
			<jsSourceExclude>**/webpack.config.js</jsSourceExclude>				
		  </jsSourceExcludes>
		  <jsEngine>CLOSURE</jsEngine>
		</configuration>
		<phase>prepare-package</phase>
		<goals>
		  <goal>minify</goal>
		</goals>
	  </execution>
	</executions>
</plugin>
<plugin>
	<artifactId>maven-compiler-plugin</artifactId>
	<version>3.8.0</version>
	<configuration>
		<rules>
			<requireJavaVersion>
				<version>11</version>
			</requireJavaVersion>
		</rules>
	  <source>11</source>
	  <target>11</target>        
	  <release>11</release>
	</configuration>
</plugin>
<plugin>
	<artifactId>maven-war-plugin</artifactId>
	<version>3.2.1</version>
	<configuration>
	  <warSourceDirectory>WebContent</warSourceDirectory>
	  <webResources>
		<webResource>
			<directory>${project.build.directory}/minify</directory>
		</webResource>			
	  </webResources>
	  <archive>
		<addMavenDescriptor>false</addMavenDescriptor>
	  </archive>		  
	</configuration>
</plugin>

參考:

  1. How to get maven to build a war with minified files using yuicompressor-maven-plugin
  2. yuicompressor maven plugin and maven-war-plugin
  3. Maven内置属性、POM属性 - Ruthless - 博客园
  4. Adding and Filtering External Web Resources
  5. Minification of JS and CSS Assets with Maven

2022年9月19日 星期一

用 Java 進行 SSH 連線 - 使用 JSch

 在這篇文中要展示如何用 Java 以 JSch 這個 library 來進行 SSH 連線,

首先是用 Maven 引入 library :

<!-- https://mvnrepository.com/artifact/com.jcraft/jsch -->
<dependency>
	<groupId>com.jcraft</groupId>
	<artifactId>jsch</artifactId>
	<version>0.1.55</version>
</dependency>
接著先直接上程式碼:
package test;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import com.jcraft.jsch.ChannelSftp;
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.Session;
import com.jcraft.jsch.SftpATTRS;
import com.jcraft.jsch.SftpException;

public class SftpTest {

	public static void main(String[] args) throws Exception {
		
		String privateKeyPath = "D:\\...\xx.pem"; //your private key file path
		String userName = "xxName"; //your username to access target server
		String host = "xxx.xxx.xxx.xxx"; //hostname of target server
		int port = 22; //target server port
		
		Session session = null;
		
		try (InputStream inputStreamOfPrivateKey = new FileInputStream(new File(privateKeyPath))){			
	        JSch jsch = new JSch();	        
	         
	        jsch.addIdentity(privateKeyPath, inputStreamOfPrivateKey.readAllBytes(), null, null);
	        session = jsch.getSession(userName, host, port);
            //session.setPassword("xxxPassword");
	        session.setConfig("StrictHostKeyChecking", "no");
	        session.setTimeout(60000);
	        session.connect();
			
	        ChannelSftp channelSftp = (ChannelSftp) session.openChannel("sftp");
	        channelSftp.connect();
	        
	        //check if file exists
	        System.out.println(isFileExist(channelSftp, "/xxx/xxx.png"));
	        
	        //check file attributes
	        SftpATTRS fileAttrs = channelSftp.lstat("/xxx/xxx.png");
	        System.out.println(fileAttrs.getSize());
	        
	        //create directory
	        mkdir(channelSftp, "/xxx/xxx");
	        
	        //upload file
	        upload(channelSftp, "D:\\xxx\\xxx.jpg", "/xxx/xxx");

	        channelSftp.exit();
		}catch(Exception e) {
			e.printStackTrace();
		} finally {
			if (session != null && session.isConnected()) {
				session.disconnect();
			}			
		}
		System.out.println("Done");
	}
	
	public static void upload(ChannelSftp channelSftp, String srcFilePath, String targetDirectoryPath) {
    	targetDirectoryPath = targetDirectoryPath.replace("\\", "/");
    	
    	File srcFile = new File(srcFilePath);
    	try (FileInputStream fileInputStream = new FileInputStream(srcFile)){
    		if (isFileExist(channelSftp, targetDirectoryPath)) {
        		channelSftp.cd(targetDirectoryPath);
        	}else {
        		channelSftp.cd("/"); //go to root path
        		
        		String[] targetDirectoryNameList = targetDirectoryPath.split("/");
        		for(String targetDirectoryName : targetDirectoryNameList) {
        			if ("".equals(targetDirectoryName)) {
        				continue;
        			}
        			if (!isFileExist(channelSftp, targetDirectoryName)) {
        				channelSftp.mkdir(targetDirectoryName);
        			}
        			channelSftp.cd(targetDirectoryName);
        		}
        	}
    		channelSftp.put(fileInputStream, srcFile.getName());
    	} catch (IOException | SftpException e) {
			e.printStackTrace();
		}
    }
	
	static void mkdir(ChannelSftp channelSftp, String directoryPath) {
		directoryPath = directoryPath.replace("\\", "/");

    	try {
    		channelSftp.cd("/"); //go to root path
    		
    		String[] directoryNameList = directoryPath.split("/");
    		for(String directoryName : directoryNameList) {
    			if ("".equals(directoryName)) {
    				continue;
    			}
    			if (!isFileExist(channelSftp, directoryName)) {
    				channelSftp.mkdir(directoryName);
    			}
    			channelSftp.cd(directoryName);
    		}
    	} catch (SftpException e) {
			e.printStackTrace();
		}
	}
	
	static boolean isFileExist(ChannelSftp channelSftp, String filePath) {
		boolean isFileExist = false;
		
		try {
			channelSftp.lstat(filePath);
			isFileExist = true;
		} catch (SftpException e) {
			if (e.id == ChannelSftp.SSH_FX_NO_SUCH_FILE) {
				isFileExist = false;
			}
		}
		
		return isFileExist;
	}

}

說明:
上面程式碼的情境是想要 SSH 連線至一台 Linux Server,
且已經設定好 ssh public key 並放在 server 的 .ssh 資料夾裡,
而我們本地的機器上也已經有一個對應的 ssh private key,
所以我們只需使用 private key 來進行連線而不用使用密碼。

session.setConfig("StrictHostKeyChecking", "no");
這行程式碼設定了 StrictHostKeyChecking 為 no,
是因為在 SSH 連線時有時會需要終端機互動,
例如在使用命令式模式指令進行 SSH 連線操作時,
server 可能會跳出一些訊息要你輸入 "yes" 等動作才能進行下一步,
設定 StrictHostKeyChecking 為 no 就可以避免這種互動式操作,
可參考:

在程式碼中可以看到,取得了 ChannelSftp 實例後,
就可以使用它來進行對 server 的操作,例如: cd, pwd, ls, mkdir, ... 等,
就像我們平台用命令列模式指令一樣,當使用了 cd 指令後,
會把我們帶到特定的 server 上的檔案路徑位置,
並且可以以目前所在的位置用相對路徑進行 ls, mkdir, ... 等操作,
當然也可使用絕對路徑進行操作。

在上述程式碼中,
我包裝並實作了 isFileExist(), upload(), mkdir() 等 methild 
來取代直接使用 ChannelSftp 自身的 API,
在實作的 method 中限定了檔案路徑參數必須為絕對路徑以避免不必要的
路徑處理麻煩,
isFileExist() 使用了 ChannelSftp.lstat() 和 ChannelSftp.SSH_FX_NO_SUCH_FILE 的補獲來實作。
upload() 和 mkdir() 使用 isFileExist() 來判斷路徑上資料夾的存在與否,
如果不存在的話則幫忙建立相應的資料夾。

2022年9月18日 星期日

在同一個頁面上建立多個 AngularJs 的 module 及 controller - 使用 angular.bootstrap()

ng-app 是很常見的 AngularJs 頁面 module 的設定,
但這樣的方式會有局限性,
因為 AngularJs 只允許一個頁面使用一個 ng-app,
如果有多個 ng-app 則會錯誤或不正常運作。

不過這並不代表 AngularJs 不能在一個頁面上設定多個 module,
AngularJs 可以使用
angular.bootstrap(element, moduleNameArray)
來在一個頁面上設定多個 module,
其中參數 element 是 module 所在的 DOM Object,
而 moduleNameArray 則是個 array<String>,
內部放著要設定到頁面上的 module 名稱。

以下是一個範例,
頁面上有三個要被設定 module 的 DOM Object,
分別是 id="module_1" 、 id="module_2" 和 id="module_3",
然後我們會先設定兩個  AngularJs module,module 名為 "module_A" 和 "module_B",
各 module 裡面各有一個名為 "controller" 的 controller,
我們要把 module_A 設定到 #module_1 和 #module_2 上,
及把 module_A 和 module_B 一起設定到 #module_3 上。

html:
<div id="module_1">
  module_1:
  <div ng-controller="controller as ctrl">
    <input type="text" ng-model="ctrl.text"/>
  </div>
</div>

<div id="module_2">
  module_2:
  <div ng-controller="controller as ctrl">
    <input type="text" ng-model="ctrl.text"/>
  </div>
  
   <div ng-controller="controller as ctrl">
    <input type="text" ng-model="ctrl.text"/>
  </div>
</div>

<div id="module_3">
  module_3:
  <div ng-controller="controller as ctrl">
    <input type="text" ng-model="ctrl.text"/>
  </div>
  
  <div ng-controller="controller as ctrl">
    <input type="text" ng-model="ctrl.text"/>
  </div>
</div>
Javascript:
angular.module("module_A", [])
.controller("controller", [function(){
	var self = this;
  self.text = "A";
}])

angular.module("module_B", [])
.controller("controller", [function(){
	var self = this;
  self.text = "B";
}]);

angular.bootstrap(document.getElementById("module_1"), ["module_A"]);
angular.bootstrap(document.getElementById("module_2"), ["module_A"]);
angular.bootstrap(document.getElementById("module_3"), ["module_A", "module_B"]);
以下是 Jsfiddle 上的成品:
說明:
首先可以先注意到,在 html 中的
id="module_2" 中
故意擺放了名稱一樣的 controller:
ng-controller="controller as ctrl"
這裡是要展示一個特性,就是即使同一個 module 下有相同 controller 名稱的 controller,
也就是它們都使用了在 javascript 中同一個 module 之下的同樣名為 "controller" 的 controller 設定,
它們的 scope 仍然是不同的,方便想像可以把它們看做是同一個 function 所 new 出來的不同  Class 實體 (Class Instance),
所以它們設定的 ng-model 是各自獨立的,並不會互相連動,
可以從 Jsfiddle 上的成品範例實際使用觀察看看。

再來在 html 中的
id="module_1" 和 id="module_2" 雖然都用了在 javascript 中相同的名為 "module_A" 的 module 設定,
不過它們仍然算是互相獨立的不同 module 個體,跟之前同個 module 下的同名 controller 說明很像,
所以它們彼此的同名 controller 的 ng-model 也是不會互相連動的。

最後是在 html 中的
id="module_3",它被設定了 module_A 和 module_B,
所以它可以得到 module_A 之下及 module_B 之下的 controller 設定,
有點像是一個載入了 module_A 和 module_B 的 module_C 一樣,
可以方便想像成如下:
angular.module("module_C", ["module_A", "module_B"]),
然後當然的,跟上面說明的情況一樣,
id="module_3" 上設定的 module 跟 controller 一樣是獨立於 id="module_1" 和 id="module_2",
事實上,id="module_1"、id="module_2" 和 id="module_3" 上的 module 跟 controller 彼此都是互相獨立無關的,
唯一的共同點就只有都有使用到了 module_A 的設定而已。

最後的結果是範例中四個 <input> 的值彼此都不是互相連動的。
這在如果你想要把相同程式邏輯的 module 做成元件,
並放到頁面上的各處,又不想它們彼此共用 scope (即共用 ng-model 去連動改變 scope 中的變數值) 時,
會是一個可利用的不錯的特性。

是 Luis 作者寫的一篇關於 ng-app 限制的文章,
也一樣用到了 angular.bootstrap 去解決 ng-app 限制的問題,
並在 Github 上實作了一個方便來用 DOM 屬性設定 AngularJs module 的 module 工具,
源碼部份也是使用了一樣的觀念,
找出要設定 module 的 DOM,從屬性中讀到要被設定的 module 名稱後進行 module 的設定,
十分地具有參考的價值。

2022年8月19日 星期五

Git - 如何取消歷史 commit 中對某個檔案的修改紀錄

今天遇到一個情況是,
在 Git 的 commit 歷史紀錄中,
希望把其中有一次的 commit 中的某個檔案修改取消掉。
例如假設我現在狀態最新 commit 是 commit_0,
之後修改了檔案 file_1 和檔案 file_2 並 commit 成 commit_1,
接著又修改其他東西 commit 了 commit_2, commit_3 之類的,
但後來我反悔了,想把 commit_1 中對檔案 file_1 的修改取消掉,
也就是希望當作之前在 commit_1 中沒有修改過 file_1。

在這篇文章中特別紀錄一下上述情境的解法:
首先先列出一下 commit 的紀錄如下:

commit_3 ........
commit_2 ........
commit_1 修改了 file_1, file_2
commit_0 ........

首先要執行以下指令進入 git rebase interaction 互動模式

git rebase -i {commit_0 的 commit id}

接著會跳出編輯器供修改,例如可能是以下內容:

pick {commit_0 的 commitId} ......
pick {commit_1 的 commitId} ......
pick {commit_2 的 commitId} ......
pick {commit_3 的 commitId} ......

因為要修改的是 commit_1,所以我們要把 commit_1 的 "pick" 改成 "edit",
改完後儲存關掉,回到指令視窗,
執行以下指令將 file_1 的內容變回在 commit_1 之前,也就是在 commit_0 時的狀態:

git checkout {commit_0 的 commit id} -- {file_1 的路徑}

再來把已回復內容的 file_1 加進暫存區以進行 commit:

git add {file_1 的路徑}

接著再執行如下的 commit amend 指令,重新 commit 以取代原來的 commit_1 ,
這樣因為對於 commit_0 來說 file_1 在這次的 commit 其內容並沒有任何被修改的地方,
所以新的這個 commit_1 將不會有修改 file_1 的紀錄:

git commit --amend

最後我們再執行如下的 rebase continue 指令讓 rebase 的程序繼續進行下去至結束就行了。

git rebase --continue

參考資料:

  1. While editing a commit in `git rebase -i`, have to revert changes in a single file

2022年7月19日 星期二

MS Sql Server 的 SYSDATETIMEOFFSET()、SWITCHOFFSET() 和 TODATETIMEOFFSET()

在 Microsoft Sql Server 中,
datetime 資料型別是沒有時區資訊的,,
比如 2022-01-01 00:00:00 如果沒有時區資訊的話,
它可以是美國時區的 2022-01-01 00:00:00 ,也可以是台灣時區的 2022-01-01 00:00:00,
對 1970-01-01 00:00:00 UTC+0 的毫秒數間隔是不一樣的。

而 datetimeoffset 資料型別就有時區資訊,
例如 2022-01-01 00:00:00 UTC+8 就是台灣時區的 2022-01-01 00:00:00,
對應到 UTC-8 的時區 就是 2001-12-31 08:00:00 UTC-8,
只是表示方式不同,
但對 1970-01-01 00:00:00 UTC+0 的毫秒數間隔通通都是一樣的。

以下介紹我常用的三個好用 Sql Server 函式,
SYSDATETIMEOFFSET()、SWITCHOFFSET() 和 TODATETIMEOFFSET(),
可以用來對日期格式做不同處理:

SYSDATETIMEOFFSET()
傳回擁有時區資訊的系統目前時間 (格式為 datetimeoffset(7),即小數位數有到 7 位的有時區時間)
例如 print SYSDATETIMEOFFSET() 可印出如下結果:
2022-07-19 20:40:24.8558075 -07:00
跟 SYSDATETIME() 的差別是 SYSDATETIME() 是傳回 datetime2 格式的無時區時間,其印出結果如下:
2022-07-19 20:40:24.8558075
可以看到只差在有無包含時區資訊而已

SWITCHOFFSET(datetimeoffset_expression, timezoneoffset_expression):
對特定有時區資訊 (沒給時區的話會被當做是 UTC+0) 的日期(datetimeoffset_expression) 用指定的時區位移(timezoneoffset_expression)
進行換算並返回相應的 dateoffset 型別結果,例如:
print SWITCHOFFSET('2022-01-01 03:00:00 +07:00', '+08:00')
的結果為:
2022-01-01 04:00:00.0000000 +08:00
可以看到 SWITCHOFFSET() 並不會改變日期的值,
也就是其日期和 1970-01-01 00:00:00 之間差距的毫秒數還是一樣,
指的還是同一個日期,只是用不同的時區格式寫出來而已。

TODATETIMEOFFSET(datetime_expression , timezoneoffset_expression):
對特定無時區資訊 (有給時區的話會被忽略) 的日期(datetime_expression) 加上指定的時區位移(timezoneoffset_expression)
資訊,返回日期和時區組合好後的有時區資訊日期,型別為 datetimeoffset,
例如:
print TODATETIMEOFFSET('2022-01-01 03:00:00 +07:00', '+08:00')
的結果為:
2022-01-01 03:00:00.0000000 +08:00
可以看到只有時區部份的資訊被改變了,表示無時區部份的日期資訊並沒有被改變,
所以其日期和 1970-01-01 00:00:00 之間差距的毫秒數也改變了,
變成用新時區去看日期部份得到的新日期。

參考資料:

  1. Transact-SQL (日期和時間資料類型和函式)
  2. 使用內建函式查詢

2022年7月11日 星期一

自訂 <ol> <li> 的項目標式方式 (list-style-type 配合 @counter-style)

html 中 <ol> <li> 的項目標式方式,可以使用 css 的 list-style-type 屬性配合  @counter-style 來自訂項目顯示方式,這裡演示一個將
list-style-type : trad-chinese-informal
擴展成自己想要的項目清單顯示方式,例如此例把
一、二、三
改成:
(一)、(二)、(三)

源碼如下:
Html:
<ol class="my-custom-list-style">
  <li>1</li>
  <li>2</li>
  <li>3</li>
</ol>
CSS:
ol.my-custom-list-style {
  list-style-type: my-custom-list-style-type;
}

@counter-style my-custom-list-style-type {
  prefix: "(";
  suffix: ")";
  system: extends trad-chinese-informal;
}
成品如下:

2022年5月10日 星期二

紀錄驗證 Google 第三方登入傳入的RSA 256 JWT token 的程式碼

紀錄驗證 Google 第三方登入傳入的RSA 256 JWT token 的程式碼
, 其官方網站有公開 Public key 的 JWK
JWK網址是:https://www.googleapis.com/oauth2/v3/certs

有兩種驗證的方法:

  1. 使用第三方 JWK 驗證相關的 Library 來做驗證 (不限只能驗證 Google 的 JWK,例如也能驗證 Apple 的 JWK)。
  2. 使用 Google 提供的 LIbrary 來做驗證。

首先是第一種,
1. 使用第三方 JWK 驗證相關的 Library 來做驗證 (不限只能驗證 google 的 JWT)。
範例如下:

Maven 的 pom.xml :

<dependency>
	    <groupId>com.auth0</groupId>
	    <artifactId>java-jwt</artifactId>
	    <version>3.18.2</version>
	</dependency>
	
	<dependency>
	    <groupId>com.auth0</groupId>
	    <artifactId>jwks-rsa</artifactId>
	    <version>0.20.0</version>
	</dependency>

可能會需要 javax.xml.bind 這個 lib,因為 jdk 8 以上沒有 jaxb 模塊,詳見: 真正解决方案:java.lang.NoClassDefFoundError: javax/xml/bind/DatatypeConverter

<!-- https://mvnrepository.com/artifact/javax.xml.bind/jaxb-api -->
	<dependency>
	    <groupId>javax.xml.bind</groupId>
	    <artifactId>jaxb-api</artifactId>
	    <version>2.3.0</version>
	</dependency>

Java:

package test;

import java.io.IOException;
import java.net.URL;
import java.security.GeneralSecurityException;
import java.security.KeyFactory;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;

import com.auth0.jwk.Jwk;
import com.auth0.jwk.JwkException;
import com.auth0.jwk.JwkProvider;
import com.auth0.jwk.UrlJwkProvider;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTDecodeException;
import com.auth0.jwt.interfaces.DecodedJWT;

import net.sf.json.JSONObject;

public class JWTTest {
	public static void main(String[] args) throws IOException, GeneralSecurityException, JwkException {
		String token = "someGoogleJwtToken";
		
		verifyToken(token);
		
	}

	public static verifyToken(String token) {
		try {
			DecodedJWT jwt = JWT.decode(token);
			JwkProvider provider = new UrlJwkProvider(new URL("https://www.googleapis.com/oauth2/v3/certs"));
			RSAPublicKey publicKey = (RSAPublicKey) provider.get(jwt.getKeyId()).getPublicKey();
			
			Algorithm algorithm = Algorithm.RSA256(publicKey, null);
			JWTVerifier verifier = JWT.require(algorithm)
					// more validations if needed
					.build();
			jwt = verifier.verify(token);
			
			System.out.println("User Id: " + jwt.getSubject());
			System.out.println("Email: " + jwt.getClaim("email").asString());
		} catch (Exception e) {
			System.out.println("Exception in verifying " + e.toString());
		}
	}
}

再來是第二種,
2. 使用 Google 提供的 LIbrary 來做驗證。
範例如下:

Maven 的 pom.xml :

<dependency>
		<groupId>com.google.api-client</groupId>
		<artifactId>google-api-client</artifactId>
		<version>1.32.1</version>
	</dependency>

Java:

package test;

import java.io.IOException;
import java.security.GeneralSecurityException;
import java.util.Collections;

import com.auth0.jwk.JwkException;
import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken;
import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken.Payload;
import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier;
import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport;
import com.google.api.client.json.gson.GsonFactory;

public class GoogleJWTTest2 {
	public static void main(String[] args) throws IOException, GeneralSecurityException, JwkException {
		String token = "eyJhbGciOiJSUzI1NiIsImtpZCI6ImZjYmQ3ZjQ4MWE4MjVkMTEzZTBkMDNkZDk0ZTYwYjY5ZmYxNjY1YTIiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJuYmYiOjE2NTE4MzA3OTQsImF1ZCI6Ijk2MjE1NTMyNDMyMy1vY3U5MzNkazFiYzY0dGhkM3JrdnVsaXI5MHVya3Nuby5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbSIsInN1YiI6IjEwMjA1NTY1MzA0MDM0ODc0MzExMyIsImVtYWlsIjoiaHVnb2dvNzY0NkBnbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiYXpwIjoiOTYyMTU1MzI0MzIzLW9jdTkzM2RrMWJjNjR0aGQzcmt2dWxpcjkwdXJrc25vLmFwcHMuZ29vZ2xldXNlcmNvbnRlbnQuY29tIiwibmFtZSI6IumDreeAmumahiIsInBpY3R1cmUiOiJodHRwczovL2xoMy5nb29nbGV1c2VyY29udGVudC5jb20vYS0vQU9oMTRHaExOWHdxT2l2cXAyOHdRY3lNZ3FGZERUU0dyYW1sSDQwTDRTejEzZz1zOTYtYyIsImdpdmVuX25hbWUiOiLngJrpmoYiLCJmYW1pbHlfbmFtZSI6IumDrSIsImlhdCI6MTY1MTgzMTA5NCwiZXhwIjoxNjUxODM0Njk0LCJqdGkiOiJmZDFlY2VmMThiNzAyZDIyNzQwNjRjZjdmMzMwOTExZDBlYzQ4NDI0In0.OFc3-NIiSsChOJWgF_SJZ9yWhSpAhY95PSllh7gSqS8YYiBJD6DIZCvbHnL2SLU69lv2kntoR-hG1aQU07ppgGN5xuqJAagvKJ8KSSkxJxSR5qOLFNMBYPghp0zgFybNEAQDTbj3E5zRlemX7w9irEMqkMliRAMDYE3aUkcOrho9X2vd9wJDrkwKmMaLfXa71MVPwIYpsOrg2Gq82nHLw24eM47VRTp3m1sqXdKz9WHgfW2_2y9GB0qn3E8Fo99wBgegRyAlz6UvbTzDNQOUdrvuSALXcrOZzog5rrfW0MinxdVfRbSNRKL0VGMJWzuGefxNqEV-Fu0CTPIqliXf1A";

		verifyToken(token);

	}

	public static boolean verifyToken(String token) {
		try {
			GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder(
					GoogleNetHttpTransport.newTrustedTransport(), new GsonFactory())
							// Specify the CLIENT_ID of the app that accesses the backend:
//							.setAudience(Collections.singletonList(
//									"962155324323-ocu933dk1bc64thd3rkvulir90urksno.apps.googleusercontent.com"))
							// Or, if multiple clients access the backend:
							// .setAudience(Arrays.asList(CLIENT_ID_1, CLIENT_ID_2, CLIENT_ID_3))
							.build();

			// (Receive idTokenString by HTTPS POST)
			GoogleIdToken idToken = verifier.verify(token);
			if (idToken != null) {
				Payload payload = idToken.getPayload();
				// Print user identifier
				String userId = payload.getSubject();
				System.out.println("User ID: " + userId);

				// Get profile information from payload
				String email = payload.getEmail();
				boolean emailVerified = Boolean.valueOf(payload.getEmailVerified());
				String name = (String) payload.get("name");
				String pictureUrl = (String) payload.get("picture");
				String locale = (String) payload.get("locale");
				String familyName = (String) payload.get("family_name");
				String givenName = (String) payload.get("given_name");
				System.out.println(email);
				// Use or store profile information
				// ...

			} else {
				System.out.println("Invalid ID token.");
			}

			return true;
		} catch (Exception e) {
			System.out.println("Exception in verifying " + e.toString());
			return false;
		}
	}
}

2022年3月1日 星期二

使用 RS256 簽名演算法對 JWT 做簽章 - Java

JWT (JSON Web Token) 可以用於儲存不敏感的訊息來讓 client 和 server 端做溝通,

其支援不同的簽名加密演算法,例如對稱加密的 HS256 或 非對稱加密的 RS256 等,
在這篇文章裡,我記錄了一下使用了 RS256 的 JWT 驗證過程。

RS256 結合了 RSASSA-PKCS1-v1_5 和 SHA-256,
RSASSA-PKCS1-v1_5 是一種非對稱加密演算法、
而 SHA-256 是一種雜湊演算法,參考 Digital Signature with RSASSA-PKCS1-v1_5

RS256 跟 HS256 不同的地方是,RS256使用了非對稱加密的RSA演算法,所以擁有一對
Public Key (公鑰) 和 Private Key (私錀),而 HS256 只有一把鑰匙。
在安全上,HS256 需要發佈方(例如提供 API 的 server 端)和驗證方(例如呼叫 API 的 client 端)都要擁有同一把錀匙,如果這把錀匙流露出去讓不法的第三方得知,
第三方將可使用這把錀匙偽造出假訊息的 JWT token 讓 server, client 端無法查覺。

跟只使用了一個密碼的 HS256 不同,RS256 使用了一對公錀與密錀,
提供 API 的 server 方可以以不公開的密錀對 JWT 做加密雜溱演算,
client 端及 server 端都可以用 server 公開出來的公錀來驗證 JWT 的合法性,能確認 JWT 確實為
server 端所發出且沒有被遭到竄改

Note:
 https://jwt.io/ 是一個提供線上 JWT token 產生、驗證範例的網站,想要做測試時還蠻好用的,其也提供了不同的簽名演算法可供選擇。

在這篇文中,我示範了:

  1. 如何產生 RSA 的公錀及私錀。
  2. 如何使用 RSA 的私錀進行 RS256 JWT 的簽發。
  3. 如何使用 RSA 的公錀進行 RS256 JWT 的驗證。
下面直接上程式碼:

首先是有用到的 library dependency, 
pom.xml:

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>test</groupId>
  <artifactId>jwtTest</artifactId>
  <version>0.0.1-SNAPSHOT</version>
  
  <dependencies>
  	<!-- https://mvnrepository.com/artifact/javax.xml.bind/jaxb-api -->
	<dependency>
	    <groupId>javax.xml.bind</groupId>
	    <artifactId>jaxb-api</artifactId>
	    <version>2.3.0</version>
	</dependency>
	
	<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt -->
	<dependency>
	    <groupId>io.jsonwebtoken</groupId>
	    <artifactId>jjwt</artifactId>
	    <version>0.9.1</version>
	</dependency>
  </dependencies>
  
</project>

JwtTest.java:

package main;

import java.io.UnsupportedEncodingException;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.SecureRandom;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.text.SimpleDateFormat;
import java.util.Base64;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.impl.DefaultClaims;

public class JwtTest {

	public static void main(String[] args) throws Exception {
		KeyPair keyPair = generateRsaKeyPair();
        
		String publicKeyStr = "-----BEGIN PUBLIC KEY-----" + new String(Base64.getEncoder().encode(keyPair.getPublic().getEncoded()), "UTF-8") + "-----END PUBLIC KEY-----";
		String privateKeyStr = "-----BEGIN PRIVATE KEY-----" + new String(Base64.getEncoder().encode(keyPair.getPrivate().getEncoded()), "UTF-8") + "-----END PRIVATE KEY-----";
		
		System.out.println("Public Key: " + publicKeyStr);
		System.out.println("Private Key: " + privateKeyStr);
        
        String someProperyValue = "someName";
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");				
		Claims claims = new DefaultClaims();
        claims.setIssuer("issuer")
                .setSubject("test")
                .setExpiration(sdf.parse("2200-01-01"))
                .put("name", someProperyValue);
        
        String generatedRsaJwt = generateRsaJwt(claims, privateKeyStr);
		Claims parsedClaims = parseRsaJwt(generatedRsaJwt, publicKeyStr);
		System.out.println("JWT Token: " + generatedRsaJwt);
		//verify content
		System.out.println(someProperyValue.equals(parsedClaims.get("name", String.class))); //true
	}
	
	static String generateRsaJwt(Claims claims, String privateKeyStr) throws InvalidKeySpecException, UnsupportedEncodingException, NoSuchAlgorithmException {
		String generatedRsaJwt = null;
		
		privateKeyStr = privateKeyStr.replace("-----BEGIN PRIVATE KEY-----", "");
        privateKeyStr = privateKeyStr.replace("-----END PRIVATE KEY-----", "");
        privateKeyStr = privateKeyStr.replaceAll("\r\n", "");
        privateKeyStr = privateKeyStr.replaceAll("\\s+", "");
        
        PKCS8EncodedKeySpec keySpec_private = new PKCS8EncodedKeySpec(Base64.getDecoder().decode(privateKeyStr.getBytes("UTF-8")));
        KeyFactory keyFactory_private = KeyFactory.getInstance("RSA");
        PrivateKey privateKey = keyFactory_private.generatePrivate(keySpec_private);
        
		generatedRsaJwt = Jwts.builder()
        .setHeaderParam("typ", "JWT")
        .setClaims(claims)
        .signWith(SignatureAlgorithm.RS256, privateKey)               
        .compact();
		
		return generatedRsaJwt;
	}
	
	static Claims parseRsaJwt(String jwtToParse, String publicKeyStr) throws NoSuchAlgorithmException, InvalidKeySpecException, UnsupportedEncodingException {
		Claims parsedRsaJwtClaims = null;
		
		publicKeyStr = publicKeyStr.replace("-----BEGIN PUBLIC KEY-----", "");
		publicKeyStr = publicKeyStr.replace("-----END PUBLIC KEY-----", "");
		publicKeyStr = publicKeyStr.replaceAll("\r\n", "");
		publicKeyStr = publicKeyStr.replaceAll("\\s+", "");
		
		X509EncodedKeySpec keySpec_public = new X509EncodedKeySpec(Base64.getDecoder().decode(publicKeyStr.getBytes("UTF-8")));
        KeyFactory keyFactory_public = KeyFactory.getInstance("RSA");
        PublicKey publicKey_public = keyFactory_public.generatePublic(keySpec_public);
        
        Jws<Claims> jws = Jwts.parser()
        					  .setSigningKey(publicKey_public)
        					  .parseClaimsJws(jwtToParse);
        
        parsedRsaJwtClaims = jws.getBody();
		
		return parsedRsaJwtClaims;
	}
	
	static KeyPair generateRsaKeyPair() throws NoSuchAlgorithmException, UnsupportedEncodingException {
		KeyPair keyPair = null;
		
		SecureRandom secureRandom = new SecureRandom();
		secureRandom.setSeed("test".getBytes("UTF-8"));
		
		KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");	
		keyPairGenerator.initialize(2048, secureRandom); // secureRandom is not necessary,
														 // you can also use keyPairGenerator.initialize(2048); for random seed
														 // note: if use same seed for secureRandom, will get same public key and private key.
		keyPair = keyPairGenerator.generateKeyPair();
		
		return keyPair;
	}

}

說明:

  1. generateRsaKeyPair() 會產生一對 RSA Public Key 及 Private Key,SecureRandom 並非必須,只是做個示範,要注意的是 SecureRandom 如果給予一樣的種子 (Seed) ,可能會造成每次產生出一樣的公錀及密錀 (但也有可能不會,看SecureRandom、KeyGenerator 等底層演算法而定)。
  2. generateRsaJwt() 使用私錀為 JWT 做簽章,parseRsaJwt() 使用公錀對 JWT 做驗證,如果有驗證問題的話,例如有人用了另一個錯吳的私錀 (真正的私錀不可流露出去,理論上其他第三方應無法知道真正的私錀) 做假訊息的簽章,在用公錀驗證時就會出現驗證錯的訊息:
    Exception in thread "main" io.jsonwebtoken.SignatureException: JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted.
    

參考資料:

  1. Generating a JWT using an existing private key and RS256 algorithm
  2. 常見簽名算法之SHA256withRSA
  3. 在 Java 使用加密演算法(一):產生與儲存 RSA 的金鑰(2017-07-19 調整)
  4. Create jwt in java using Public key rsa
  5. Digital Signature with RSASSA-PKCS1-v1_5

2022年2月28日 星期一

GitLab CICD - 使用 GitLab Runner

這裡紀錄下使用
GitLab 做 CICD (Continuous Integration/Continuous Deployment, 持續整合、持續佈置)
時的一些佈驟及事項

GitLab 使用了 Job, Gitlab-Runner 及 executor 的設計。

在 Git 專案根目錄上會需要建立一個描述 Job 的 .gitlab-ci.yml 檔案,
其中記載了 CICD 要做的事項流程即要執行的 script 語句。

Gitlab-Runner 會跟 Gitlab repository 連線、檢查有無分派 Job 、並接受 Job , Gitlab-Runner 可能是被按裝在在 Windows 系統上、或是被按裝在 Linux 上、或甚至是在某台 server 跑起來的 Docker 中都有可能。

在 Gitlab-Runner 中,可以在其設定檔 config.toml 中設定多個 Gitlab-executor,當 Gitlab-Runner 接到需要執行的 Job 時,就會啟動相應 (例如 .gitlab-ci.yml 中擁有 tag1, tag2 的 Job 對應到有 tag1, tag2 的 Gitlab-executor) 的 Gitlab-executor。

因為 Gitlab-executor 本身需要跟 Giblab repository 連線,所以需要指定 Gitlab repository 的 SSL 憑證,例如如果專案位於 Gitlab 的 https://xxx.git/... 上,那就要取得 https://xxx.git 的憑證 (例如可以用 Chrome 瀏覽器去下載憑證)。

Gitlab-executor 是主要執行 .gitlab-ci.yml 中 Job 的 script 語句的環境,有分成幾種不同環境的 executor,
例如官方列出的 shell, docker, ssh 等等,可參考 Executors | GitLab
當使用 shell 環境的 Gitlab-executor 時,相當於直接在 Git-Runner 安裝環境上直接執行 script,
例如如果 Git-Runner 裝在 Windows 系統上,相當於直接執行 Windows 的 command line 語句。
或是例如如果 Git-Runner 裝在 Linux 系統 (如果裝在 docker 運行的 Linux 上也是一樣) 上,則相當於直接執行 Linux bash 語句。

當使用 docker 環境的 Gitlab-executor 時,Gitlab-Runner 會在所處環境下以指定的 docker image (可在 .gitab-ci.yml 中指定,或是在 config.toml 中指定預設的 image) 啟動一個指定的 docker container (這時 Gitlab-Runner 所被安裝的環境下必須要事先安裝好 docker),
Job 的 script 會以在這個被啟動的 docker container 下被執行。
例如如果啟動的 docker container 是含有安裝 php 的 container,那就能執行 Job script 的 php 語句。


這裡我以一個例子做示範,
情境是用 docker 啟動兩個 container,
一個運行 Gitlab-Runner,
另一個運行 Linux ubuntu 系統來模擬正式環境程式要佈署到的那台 server,
在這裡 Git project 假設是一個 Maven 專案,我想要利用 GitLab CICD 來對專案進行建構 war 檔 (mvn clean install),並以 SSH 的方式傳送到佈署 server,並以 SSH 連線佈署 server 執行解包 (jar -xvf xxx.war)

以下開始示範:

首先先安裝 Gitlab-Runner,
詳請可參考官網 Install GitLab Runner | GitLab ,
在這邊我以用 docker 來安裝 Gitlab-Runner 示範,
docker 可以使用 gitlab/gitlab-runner:latest 這個 docker image 來安裝 Gitlab-Runner,

安裝完好後,接下來要來進行 Gitlab-executor 的設定,
詳請請參閱官網 Registering runners | GitLab
設定主要以/etc/gitlab-runner/config.toml 設定檔來設定,
如果 Gitlab-Runner 不是用 root 身份啟動的話,檔案就會改放在登入 user 的位置,
例如 ~/.gitlab-runner/config.toml,可參考 Advanced configuration | GitLab 。
如果本來就有已設定好的 config.toml 檔的話,放在 Gitlab-Runner container 中的正確位置就可以了,基本上每次對 config.toml 檔的修改 Gitlab-Runner 都會偵測到並再次載入修改的設定,不太需要重開 Gitlab-Runner,

如果第一次沒有 config.toml 檔存在,想要一個範本的話,可以使用交互模示來進行 register Gitlab-executor,啟動 Gitlab-Runner 後,執行 container 的 gitlab-runner register 指令:
(假設 container 名稱取叫 my-gitlab-runner-container)
docker exec -it my-gitlab-runner-container gitlab-runner register

如果 Gitlab repository 是 https 的話,如之前所述需要設定憑證位置,指令要改成:
docker exec -it my-gitlab-runner-container gitlab-runner register --tls-ca-file=/path/to/tlsCaFile
請把 /path/to/tlsCaFile 改成憑證在 container 中的位置,

設定好以後就會多出 /etc/gitlab-runner/config.toml,
裡面的內容之後可以依自己需求需改
(保持可例如用 docker cp 取出後設定到 docker-compose volume 裡面做持久化之類),
而註冊好的 Gitlab-Executor 也會在 GibLab 網站上各 Project (如果是 Project runner 的話) 的
CICD --> Runner 設定裡面,
需要注意的是,在 GitLab 網站介面中,有時也會把 Gitlab-executor 稱呼成 Runner,
其實可以把 Gitlab-executor 當成是 Gitlab-Runner 在不同環境下的 Job script 執行就可以比較好理解了。

我的模擬目錄如下:

/docker-compose.yml

/gitlab-runner/Dockerfile
/gitlab-runner/config/config.toml
/gitlab-runner/ssh/id_rsa
/gitlab-runner/ssh/id_rsa.pub
/gitlab-runner/ssl/gitLabCA.cer

/online-server/Dockerfile
/online-server/ssh/authorized_keys
/online-server/project/

這邊我就直接貼上 docker-compose 的設定:

docker-compose.yml:

version: '2'
services:
    docker-runner:
        build: ./gitlab-runner
        volumes:
            - /var/run/docker.sock:/var/run/docker.sock
            - ./gitlab-runner/ssl:/data/ssl/
            - ./gitlab-runner/config:/etc/gitlab-runner
    online-server:
        build: ./online-server
        volumes:
            - ./online-server/ssh:/root/.ssh
            - ./online-server/project:/data/project
        ports:
            - "2222:22"


先看 volumes 中的設定,
/var/run/docker.sock:/var/run/docker.sock 設定了 docker socket 的用法,
來讓 Gitlab-Runner 可以像宿主機 (host) 那樣使用 docker 去啟動一個 Gitlab-executor。

./gitlab-runner/ssl:/data/ssl/ 則是把 Gitlab repository 的 SSL 憑證放到 Gitlab-Runner container 中。

而 ./gitlab-runner/config:/etc/gitlab-runner 的 設定則是讓 Gitlab-Runner 的設定檔 config.toml 持久化,不要 container 關閉了以後修改就不見了。

在 /gitlab-runner/ssh 中的 id_rsa 和 id_rsa.pub 是用 ssh-keygen 指令產生出來的 SSH 私鑰 (private key) 和公鑰 (public key),可以用來設定 Gitlab-Executor 在使用 SSH 連線時,不用密碼登入 (這邊其實公鑰用不到,公鑰主要是要放在被 SSH 連線的對方 server 上),
詳細可參考:
在這裡我們沒有對 /gitlab-runner/ssh 設定 volumes 的原因是,
因為 Gitlab-Executor 在執行時會以 "gitlab-runner" 這樣的 user 身份做登入 (不是 root),
所以 id_rsa 必須放在 /home/gitlab-runner/.ssh 資料夾之下,
並且 id_rsa 及它所在的資料夾都必須能為 "gitlab-runner" user 來存取,
所以之後我們會用 Dockerfile 的 ADD 指令把 id_rsa 加進 Gitlab-Runner container 中並設定擁有者和權限。
注意的是,把 id_rsa 放到 Gitlab-Runner container 中這件事只需對 shell 的 executor 做,
因為如果是 docker 的 executor,官方有說可以在 Gitlab 中設定變數 (Variables) 並在 Job 中完成 container 的 SSH private key 設定,可參考 SSH keys when using the Docker executor

現在讓我們來看 /gitlab-runner/Dockerfile 的內容:
/gitlab-runner/Dockerfile :
FROM gitlab/gitlab-runner:latest

RUN mkdir /home/gitlab-runner/.ssh
ADD ["ssh", "/home/gitlab-runner/.ssh"]
RUN chown -R gitlab-runner:gitlab-runner /home/gitlab-runner/.ssh
RUN chmod -R 700 /home/gitlab-runner/.ssh

可以看到在 /gitlab-runner-Dockerfile 中,設定了要啟動的 docker image 為 gitlab/gitlab-runner,
建立了 gitlab-runner 身份的 .ssh 資料夾,
用 ADD 指定放進了 id_rsa,並且用 chown 更改了檔案擁有者、
用了 chmod 改變了檔案權限,700 代表只允設檔案擁有者有 read (讀), write (寫), execute (執行) 的權限,須注意的是不能把 id_rsa 權限設定的太大,例如 777,太大的權限在 SSH 連線時也有可能會被禁止。

在我們看 /gitlab-runner/config/config.toml 之前,
先來看一下 onlin-server 的設定,online-server 是用來模擬一個有開放 SSH 連接並已有安裝好 jdk 的線上佈署環境。 

/online-server/Dockerfile :

FROM ubuntu:latest

RUN apt-get update
RUN apt-get install ssh -y
RUN apt-get install openjdk-11-jdk -y

# RUN echo "root:12345"|chpasswd # we don't need to set password for root because we want to use ssh-key-only-login

RUN sed -i 's/#PermitRootLogin prohibit-password/PermitRootLogin yes/g' /etc/ssh/sshd_config # let root user can login
#RUN sed -i 's/#PermitRootLogin prohibit-password/PermitRootLogin without-password/g' /etc/ssh/sshd_config # let root user can login but only use way without password, like ssh key

RUN sed -i 's/#AuthorizedKeysFile/AuthorizedKeysFile/g' /etc/ssh/sshd_config # let "authorized_keys" file can be used for ssh login
RUN mkdir ~/.ssh
RUN chmod -R 700 ~/.ssh

RUN service ssh start

EXPOSE 22

CMD ["/usr/sbin/sshd","-D"]
在 online-server 的 dockerfile 中,使用了 ubuntu 的 docker image,
並用 apt-get 安裝了 ssh 及  openjdk-11-jdk,
在這邊為了方便是先假設 Gitlab-Executor 會用 root 身份來登入 SSH,
不過在實際上為了安全,通常應會再設定一個非 root 的身份來讓其登入,
因為在這裡不用 root 用密碼登入 (會用 SSH key 登入),所以沒設定密碼,如果要設定 root 的密碼的話,可以用以下指令,請把 12345 換成要的密碼,如果是要其他身份請把 root 換掉:
RUN echo "root:12345"|chpasswd

接著要修改 /etc/ssh/sshd_config 檔的內容,
因為這裡需要讓 root 身份登入,所以需要把內容裡的
#PermitRootLogin prohibit-password 換成
PermitRootLogin yes 或
PermitRootLogin without-password (可以登入,但不能用密碼登入),
可以看到我們使用
sed -i
的指令來做修改。

然後
#AuthorizedKeysFile 也要把註解拿掉,換成
AuthorizedKeysFile

接著建立 .ssh 資料夾並設定權限,要注意的是
RUN mkdir ~/.ssh 
RUN chmod -R 700 ~/.ssh
只適用於讓 root 身份登入的情況,如果需要讓其他身份登入,請照類似
/gitlab-runner/Dockerfile 中的方式,為非 root 身份建立正確位置的 .ssh 資料夾、設定擁有者和權限。

最後用以下命令啟動 SSH service,往外打開 22 port,
並把 container 的生命週期綁在 "/usr/sbin/sshd" 上:
RUN service ssh start 
EXPOSE 22 
CMD ["/usr/sbin/sshd","-D"]

在來我們來看下 Gitlab-Executor 的設定,即 /gitlab-runner/config/config.toml,
/gitlab-runner/config/config.toml :
concurrent = 1
check_interval = 0

[session_server]
  session_timeout = 1800

[[runners]]
  name = "xxx description"
  url = "https://xxx-git"
  token = "xxxxxxxxxxxxxxxxxxxx"
  tls-ca-file = "/data/ssl/gitLabCA.cer"
  executor = "docker"
  [runners.custom_build_dir]
  [runners.cache]
    [runners.cache.s3]
    [runners.cache.gcs]
    [runners.cache.azure]
  [runners.docker]
    tls_verify = false
    image = "ubuntu:latest"
    privileged = false
    disable_entrypoint_overwrite = false
    oom_kill_disable = false
    disable_cache = false
    network_mode = "gitlab-cicd-test_default"
    volumes = ["/c/Users/xxx/.m2/repository:/.m2/repository"]
    shm_size = 0

[[runners]]
  name = "yyy description"
  url = "https://xxx-git/"
  token = "yyyyyyyyyyyyyyyyyyyy"
  tls-ca-file = "/data/ssl/gitLabCA.cer"
  executor = "shell"
  [runners.custom_build_dir]
  [runners.cache]
    [runners.cache.s3]
    [runners.cache.gcs]
    [runners.cache.azure]

在此例 config.toml 中,
有兩個 Gitlab-Executor 被設定,分別是環境為 docker 的 docker executor 和環境為 shell 的 shell executor,環境是用 executor 這個參數來設定,例如 executor = "docker"。
可以看到兩個 executor 都設定了 Gitlab reposotiry SSL 憑證的位置,即 "tls-ca-file" 這個參數設定。

在 docker executor 中,[runners.docker] 裡可以設定一些關於會被啟動的 executor 的 container 的設定,其中例如:
image 參數可設定預設 docker image,如果 GitLab CICD Job 設定檔 .gitlab-ci.yml 的 Job 沒有指定 docker image 的話,就會拿預設 docker image 來用。

volumes 可以設定 Gitlab-executor 的 volumes,在此例中因為我的宿主機是 Windows (我使用了 Windows 系充可以安裝的 Docker Desktop),並且如剛才所說我們是使用了 Docker socket 而非 dind (Docker in Docker) 的方式,Gitlab-Executor 和 Gitlab-Runner 是平行的關係 (即不是在 Gitlab-Runner container 裡又開了一個 Gitlab-Executor container),
所以 volumes 設定的宿主機是 Windows,
在這裡我設定了 Maven repository 的位置做 volumes 給 Docker-executor 用以避免每次執行 CICD 時,mvn clean install 都要再上網抓一次 library ,因為每次 CICD Job 執行完後,啟動起來的 Docker Gitlab-Executor 都會被銷毀掉,裡面的資料沒做特別設定的話也都會消失掉。
要注意的是,Windows 系統的路徑,例如 D:\\xxx 要寫成 /d/xxx,且路徑上不可有空白符號。

network_mode 可以設定 Docker Gitlab-Executor container 使用的 network_mode,
效果等同於 docker run 指令的 --network_mode 參數及 docker-compose.yml 裡的 network_mode 參數。
因為我的 docker-compose.yml 建立起來的 gitlab-runner 和 online-server 這兩個 container 所處的網路名稱為 gitlab-cicd-test_default,
為了讓不是被 docker-compose.yml 建立起來的 Gitlab-Executor container 能夠與 online-server 這個 container 溝通 (之後要用 SSH 去連),所以我用了
network_mode = "gitlab-cicd-test_default"
把 executor container 加進 "gitlab-cicd-test_default" 網路中。

最後來看看 .gitlab-ci.yml 裡面寫了什麼,
.gitlab-ci.yml:
variables:
  MAVEN_OPTS: "-Dmaven.repo.local=/.m2/repository"

cache:
  paths:
    - .m2/repository/

stages:
  - build
  # - test
  - deploy

build-job-docker:
  stage: build
  image: maven:3.6.3-jdk-11
  script:
    - mvn clean install
  tags:
    - docker
  artifacts:
    paths:
      - target/*.war
    expire_in: 1 day
  when: manual

build-job-shell:
  stage: build
  script:
    - mvn clean install
  tags:
    - shell
  artifacts:
    paths:
      - target/*.war
    expire_in: 1 day 
  when: manual

deploy-job-docker-executor:
  stage: deploy
  before_script:
    ##
    ## Install ssh-agent if not already installed, it is required by Docker.
    ## (change apt-get to yum if you use an RPM-based image)
    ##
    - 'command -v ssh-agent >/dev/null || ( apt-get update -y && apt-get install openssh-client -y )'

    ##
    ## Run ssh-agent (inside the build environment)
    ##
    - eval $(ssh-agent -s)

    ##
    ## Add the SSH key stored in SSH_PRIVATE_KEY variable to the agent store
    ## We're using tr to fix line endings which makes ed25519 keys work
    ## without extra base64 encoding.
    ## https://gitlab.com/gitlab-examples/ssh-private-key/issues/1#note_48526556
    ##
    - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -

    ##
    ## Create the SSH directory and give it the right permissions
    ##
    - mkdir -p ~/.ssh
    - chmod 700 ~/.ssh

    ##
    ## Optionally, if you will be using any Git commands, set the user name and
    ## and email.
    ##
    # - git config --global user.email "user@example.com"
    # - git config --global user.name "User name"
  script:
    - cd target
    - apt-get update && apt-get install ssh -y
    - scp -o StrictHostKeyChecking=no xxx.war root@online-server:/data/project
    - ssh -o StrictHostKeyChecking=no root@online-server "cd /data/project && jar -xvf /data/project/xxx.war"
  tags:
    - docker  
  when: manual

deploy-job-ssh-shell-executor:
  stage: deploy
  script:
    - cd target
    - scp xxx.war root@online-server:/data/project
    - ssh -o StrictHostKeyChecking=no root@online-server "cd /data/project && jar -xvf /data/project/xxx.war"
  tags:
    - shell  
  when: manual
說明:
  1. 在這裡,我已經為注冊好的兩個 Gitlab executor 分別設定了 "docker" 和 "shell" 的 tag。
  2. MAVEN_OPTS: "-Dmaven.repo.local=/.m2/repository" 是設定給 Maven 的環境變量,指定 Maven local repository 的存放位置,並利用 cache paths 的設定將期設成 cache,:
    cache:
      paths:
        - .m2/repository/
    
    這樣在一個 Gitlab CICD Pipeline中的 Job 可以共享 Maven 下載的 Library,不用每個 Job 都再下載一次,雖然效果跟 artifacts 很像,不過用途不太一樣,如果要在 Job 之間共享檔案並且希望能在 Gitlab 網頁界面上存取,例如被 CICD 建立 (build) or 佈署 (deploy) 的檔案,通常會使用 artifacts 而非 cache,詳情可參考 How cache is different from artifacts
    需注意的是,以上的 Maven repository 設定只對 Docker Gitlab Executor 有效,代表的是被啟動的 Docker Gitlab Executor Container 的 Maven local repository 位置,
    如果是 Shell Gitlab Executor 的話, Gitlab Runner 本身所處的環境應該會已經先設定好 Maven 的相關配置才對。
  3. 在這裡我只用到了 build, deploy 兩個 stage,所以 test stage 被注解掉了。
    artifacts 設定了要和其他 Job 共享的檔案位置,是對 project 根目錄的相對位置,在這裡 target/*.war 即是 Maven 的 clean install 命令產出的 war 檔位置,expire_in 可設置 artifact 檔留存在 Gitlab 上的時間,在留存期間我們都可以到 Gitlab 上下載。
      artifacts:
        paths:
          - target/*.war
        expire_in: 1 day
    
  4. deploy-job-docker-executor 是一個 Docker Gitlab Executor,before_script 裡設定的語句可以在 script 語句執行之前被執行,在這邊執行的語句是參考了官方的範例:SSH keys when using the Docker executor ,把我們在 Gitlab 中設定 SSH_PRIVATE_KEY 參數設定到了 Docker Gitlab Executor Container 中的 SSH Private Key 應存放位置 (SSH_PRIVATE_KEY 的值即為 id_rsa 裡的 private key 內容)。
  5. deploy-job-docker-executor 的 script 內容為,安裝 SSH 連線用軟體,
    進到 target 資料夾 (裡面有之前設定到 artifact 的 war 檔),
    使用 scp 指令將 war 檔傳到要被布署的 server 上,
    再使用 ssh 指令登入布署 server,用 jar -xvf 指令去解開 war 檔完成佈署。
  6. deploy-job-ssh-shell-executor 是一個 Shell Gitlab Executor,因為已經事先在其所在環境上 (即 Gitlab Runner 所安裝處的環境,此例為 Linux 環境) 設置好配置,所以只要直接執行 script 指令就好,script 指令基本跟 deploy-job-docker-executor 一樣,只差在不用再安裝 SSH 連線軟體。
    需要注意的是,如果 Gitllab Runner 是裝在 Windows 系統上的話,script 裡的語句就會是 Windows cmd 的語法,可能會與 Linux bash 語法稍有不同。
  7. scp 及 ssh 指令的 -o StrictHostKeyChecking=no 參數是告訴 scp, ssh 指令使用"非交互方式" 執行,因為例如在第一次使用 scp, ssh 連線時,會有提示訊息詢問,例如:
    The authenticity of host 'xxx (xxx)' can't be established.
    RSA key fingerprint is yyyyyyy.
    Are you sure you want to continue connecting (yes/no)?
    
    但因為我們沒有辦法在 script 模式下用 yes 或 no 的交互模式,所以這時就可以用
    -o StrictHostKeyChecking=no
    來取消交互方式。
  8. 因為我們的 Gitlab Executor 都是設定手動執行 (when: manual),所以需要到 Gitlab 上自行啟動 CICD Pipeline ,如果一切都順利的話,應就可在被佈署 server 上 (即 online-server) 看到被解包的專案了。
參考資料:

2022年2月9日 星期三

使用 Java 對檔案壓縮成 zip 及對 zip 檔解壓縮

這邊紀錄下使用 Java 壓縮/解壓縮 Zip 的方法,
以下先直接上程式碼:

ZipTest.java:

package main;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.Enumeration;
import java.util.LinkedList;
import java.util.Queue;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;

public class ZipTest {

	public static void main(String[] args) throws IOException {
		String srcFilePath_isFile = "D:\\某檔案.jpg";
		String srcFilePath_isDirectory = "D:\\某資料夾";
		String toZipPath = "D:\\壓縮檔.zip";
		String toUnzipDirPath = "D:\\壓縮檔解開後要輸出到的資料夾";

		//----- zip file -----
		zipFile_onlyForSingleFile(srcFilePath_isFile, toZipPath); //壓縮單一檔案
		zipFile_onlyForSingleFile(srcFilePath_isDirectory, toZipPath); //壓縮單一資料夾,不包括資料夾內的檔案
		zipFile_canAlsoHandleDirectory_stackVersion(srcFilePath_isDirectory, toZipPath); //壓縮檔案或資料夾,使用佇列實現
		zipFile_canAlsoHandleDirectory_recursionVersion(srcFilePath_isDirectory, toZipPath); //壓縮檔案或資料夾,使用遞迴實現
		
		//----- unzip file -----
		unzipFile_byZipFile(toZipPath, toUnzipDirPath); //解壓縮,使用 ZipFile
		unzipFile_byZipInputStream(toZipPath, toUnzipDirPath); //解壓縮,使用 ZipInputStream

		System.out.println("Done");
	}

	/******************** Zip file *****************/
	public static void zipFile_onlyForSingleFile(String srcPath, String toPath) throws IOException {
		File srcFile = new File(srcPath);

		File zipFile = new File(toPath);
		FileOutputStream fileOutputStream = new FileOutputStream(zipFile);
		ZipOutputStream zipOutputStream = new ZipOutputStream(fileOutputStream);

		ZipEntry zipEntry = new ZipEntry(srcFile.getName() + (srcFile.isDirectory() ? File.separator : ""));
		zipOutputStream.putNextEntry(zipEntry);
		if (srcFile.isFile()) {
			// only srcFile is a file (not a directory) needs to write binary content of
			// file
			FileInputStream fileInputStream = new FileInputStream(srcFile);
			zipOutputStream.write(fileInputStream.readAllBytes());
			fileInputStream.close();
		}

		zipOutputStream.close();
		fileOutputStream.close();
	}

	public static void zipFile_canAlsoHandleDirectory_stackVersion(String srcPath, String toPath) throws IOException {
		File srcFile = new File(srcPath);
		String baseFileName = srcFile.getName();
		Path baseFilePath = Paths.get(srcPath);

		File zipFile = new File(toPath);
		FileOutputStream fileOutputStream = new FileOutputStream(zipFile);
		ZipOutputStream zipOutputStream = new ZipOutputStream(fileOutputStream);

		// use Queue to implement a BFS(Breadth-First Search) way to read all files and
		// directory
		Queue<File> fileQueue = new LinkedList<File>();
		fileQueue.add(srcFile);
		while (fileQueue.size() > 0) {
			File firstFileInQueue = fileQueue.poll();
			String relativePath = baseFileName + File.separator + baseFilePath.relativize(firstFileInQueue.toPath());

			if (firstFileInQueue.isFile()) {
				// do zip for file
				FileInputStream fileInputStream = new FileInputStream(firstFileInQueue);

				ZipEntry zipEntry = new ZipEntry(relativePath);
				zipOutputStream.putNextEntry(zipEntry);
				zipOutputStream.write(fileInputStream.readAllBytes());

				fileInputStream.close();
			} else if (firstFileInQueue.isDirectory()) {
				File[] childFileList = firstFileInQueue.listFiles();
				if (childFileList != null && childFileList.length > 0) {
					// add files inside directory into queue
					fileQueue.addAll(Arrays.asList(firstFileInQueue.listFiles()));
				} else {
					// if it is an empty directory,
					// just put a zipEntry and don't need to write binary content (And of course you
					// can't get binary content from a directory.)
					// don't need to do specific thing to non-empty directory because directory will
					// appear in zip when you zip files inside the directory
					ZipEntry zipEntry = new ZipEntry(relativePath + File.separator); // you should add a File.separator
																						// to let zip know it is a
																						// directory

					zipOutputStream.putNextEntry(zipEntry);
				}
			}
		}

		zipOutputStream.close();
		fileOutputStream.close();
	}

	public static void zipFile_canAlsoHandleDirectory_recursionVersion(String srcPath, String toPath)
			throws IOException {
		File zipFile = new File(toPath);
		FileOutputStream fileOutputStream = new FileOutputStream(zipFile);
		ZipOutputStream zipOutputStream = new ZipOutputStream(fileOutputStream);

		zipFile_canAlsoHandleDirectory_recursionVersion_helper(srcPath, srcPath, toPath, zipOutputStream);

		zipOutputStream.close();
		fileOutputStream.close();
	}

	private static void zipFile_canAlsoHandleDirectory_recursionVersion_helper(String basePath, String srcPath,
			String toPath, ZipOutputStream zipOutputStream) throws IOException {
		String baseFileName = new File(basePath).getName();
		Path baseFilePath = Paths.get(basePath);

		File srcFile = new File(srcPath);
		File zipFile = new File(toPath);

		if (srcFile.isFile()) {
			// do zip for file
			String relativePath = baseFileName + File.separator + baseFilePath.relativize(srcFile.toPath());
			FileInputStream fileInputStream = new FileInputStream(srcFile);

			ZipEntry zipEntry = new ZipEntry(relativePath);
			zipOutputStream.putNextEntry(zipEntry);
			zipOutputStream.write(fileInputStream.readAllBytes());

			fileInputStream.close();
		} else if (srcFile.isDirectory()) {
			File[] childFileList = srcFile.listFiles();

			if (childFileList != null && childFileList.length > 0) {
				for (File childFile : childFileList) {
					zipFile_canAlsoHandleDirectory_recursionVersion_helper(basePath, childFile.getPath(), toPath,
							zipOutputStream);
				}

			} else {
				String relativePath = baseFileName + File.separator + baseFilePath.relativize(srcFile.toPath());

				ZipEntry zipEntry = new ZipEntry(relativePath + File.separator);
				zipOutputStream.putNextEntry(zipEntry);
			}
		}
	}
	
	/******************** Unzip file *****************/
	public static void unzipFile_byZipInputStream(String zipFilePath, String toPath) throws IOException {
		File toPathFile = new File(toPath);
		if (!toPathFile.exists()) {
			toPathFile.mkdirs();
		}
		
		FileInputStream fileInputStream = new FileInputStream(zipFilePath);
		ZipInputStream zipInputStream = new ZipInputStream(fileInputStream);
		
		ZipEntry zipEntry = zipInputStream.getNextEntry();
		while(zipEntry != null) {
			File file = new File(toPath + File.separator + zipEntry.getName());
			//check is zip Entry a file or an directory
			//don't use zipEntry.isDirectory() becuase it only use "zipEntry.getName().endsWith("/")" to check
			if (zipEntry.getName().endsWith(File.separator) || zipEntry.getName().endsWith("/")) {				
				if (!file.exists()) {
					file.mkdirs();
				}
			}else {
				if (!file.exists()) {
					if (!file.getParentFile().exists()) {
						file.getParentFile().mkdirs();
					}
					FileOutputStream fileOutputStream = new FileOutputStream(file);
					fileOutputStream.write(zipInputStream.readAllBytes());
					fileOutputStream.close();
				}
			}
			
			zipEntry = zipInputStream.getNextEntry();
		}
		
		zipInputStream.close();
		fileInputStream.close();
	}

	public static void unzipFile_byZipFile(String zipFilePath, String toPath) throws IOException {
		File toPathFile = new File(toPath);
		if (!toPathFile.exists()) {
			toPathFile.mkdirs();
		}
		
		ZipFile zipFile = new ZipFile(zipFilePath);
		Enumeration<? extends ZipEntry> zipEntryEnumeration = zipFile.entries();
		while(zipEntryEnumeration.hasMoreElements()) {
			ZipEntry zipEntry = zipEntryEnumeration.nextElement();	
			File file = new File(toPath + File.separator + zipEntry.getName());
			//check is zip Entry a file or an directory
			//don't use zipEntry.isDirectory() becuase it only use "zipEntry.getName().endsWith("/")" to check
			if (zipEntry.getName().endsWith(File.separator) || zipEntry.getName().endsWith("/")) {				
				if (!file.exists()) {
					file.mkdirs();
				}
			}else {
				if (!file.exists()) {
					if (!file.getParentFile().exists()) {
						file.getParentFile().mkdirs();
					}
					InputStream zipFileInputStream = zipFile.getInputStream(zipEntry);
					FileOutputStream fileOutputStream = new FileOutputStream(file);
					fileOutputStream.write(zipFileInputStream.readAllBytes());
					
					fileOutputStream.close();
					zipFileInputStream.close();
				}
			}		
		}
		zipFile.close();
	}
}

說明:

上述程式碼展示了壓縮及解壓縮的各種不同方法,
zipFile_onlyForSingleFile() 只是展示了基本用法,只處理單一檔案或單一資料夾,
可以注意到幾點:

  1. 當處理資料夾時,只需要放入代表檔案 (或資料夾) 的 ZipEntry
    zipOutputStream.putEntry(zipEntry);
    不需要再寫入檔案的二進位資料,
    zipOutputStream.write(fileInputStream.readAllBytes());
    而如果是處理檔案時就需要再寫入檔案的二進位資料。
  2. 設定 new ZipEntry(String name) 時,需要 name 的參數,
    其代表檔案或資料夾的路徑(連同名字),路徑是相對於壓縮檔 root 位置,
    例如:
    xxx/yyy/zzz/someFile.jpg
    xxx/yyy/zzz/someDirectory/
    要注意如果是資料夾的話,要在最後面加上檔案路徑的分隔符號,例如 "/"

zipFile_canAlsoHandleDirectory_stackVersion() 和
zipFile_canAlsoHandleDirectory_recursionVersion() 展示了
如何壓縮一個內含多檔案(或資料夾)的巢狀結構 (即可能有多層資料夾 ) 資料夾的方法,
原理跟 zipFile_onlyForSingleFile() 一樣,只是對資料夾內的各層資料夾及內部檔案一個個的
去做設定 ZipEntry 的動作,
zipOutputStream.putEntry(zipEntry);
zipOutputStream.write(fileInputStream.readAllBytes());
只是遍歷檔案的實現方式不同而已,
zipFile_canAlsoHandleDirectory_stackVersion() 使用了佇列 (stack) 來實現,
zipFile_canAlsoHandleDirectory_recursionVersion() 使用了遞迴 (resurisive) 來實現。

在解壓縮的部份,展示了兩個方法:
unzipFile_byZipFile() 和
unzipFile_byZipInputStream(),
基本差異不大,只是使用的幫助 Class 不同而已,
unzipFile_byZipFile() 用了 ZipFile,而
unzipFile_byZipInputStream() 用了 ZipInputStream,
需要注意的是,
ZipEntry.isDirectory() 方法不是一個正確獲取 ZipEntry 是否為資料夾的好方法,
我們可以從源碼中可以看到如下程式碼:

public class ZipEntry implements ZipConstants, Cloneable {
..............
	public boolean isDirectory() {
        	return name.endsWith("/");
	}
..............
}

可以發現 isDirectory() 只是單純判斷了 ZipEntry 的 name 後面是否是 "/" 結尾,
但是如果如上述程式,我們在壓縮檔案時用 File.separator 來設定 ZipEntry 的檔案路徑分隔符的話,
判斷 ZipEntry 是否為資料夾就不應只是判斷結尾是否是 "/" ,而是看所在系統而有所不同 (例如 Unix 系統或 Windows 系統),例如有可能分隔符會是 "/" 或 "\" 。

參考資料: