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 參考資料 - 好用指令 - 學習紀錄

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

#查詢操作的詳細記錄,包括 checkout, pull, merge 等操作,可以看到操作的 commit id,可以用 git reset <commit_id> --hard 會到某個操作時的狀態,例如可以用來回到 merge 前的狀態等
git reflog

----------------------- 與 設定 相關 --------------------
# Git 不只會偵測檔案內容的變動,也會偵測檔案權限的變動。
Git 如果在多個作業系統下使用,例如 Windows, Linux 等,檔案權限有可能會不一樣而被 Git 偵測出來,此時 git merge, git rebase 等指令可能就會因有檔案變動留在 Unstaged files 狀態內而被擋下來。
可以使用 git diff 來看檔案變動,例如結果如下:
diff --git a/xxxxx.txt b/xxxxx.txt
old mode 100755
new mode 100644
代表 xxxxx.txt 之前與現在的檔案權限不同,
old mode 的 755 代表 Linux 權限,也就是 rwxr-xr-x 權限。
new mode 的 644 也是同理代表 rw-r--r-- 權限。
如果我們只想關心檔案的內容變動,想要 Git 忽略權限的變動的話,我們可以使用指令修改 Git 的設定如下 :
#查詢 Git 的 core.filemode 設定, true 是不忽略檔案權限變動,flase 則是忽略
git config core.filemode
# 設定 core.filemode 為 true 或 false
git config core.filemode true
git config core.filemode false

參考資料:

----------------------- 與 Remote Repository 相關 --------------------
# 在空的資料夾將 <git repository> 的 git project 抓下來
git clone <git repository>

#確認 remote 設定
git remote -v

#設定 remote
git remote set-url <remote 名稱> <Git Repository 位置>

#只增加不覆蓋舊的 remote
git remote set-url --add [--push] <remote 名稱> <Git Repository 位置>
--add : 只增加不覆蓋舊的
--push : 設定給 push 用的 remote

#刪除 remote
git remote set-url --delete [--push] <remote 名稱> <Git Repository 位置>
--push : 設定給 push 用的 remote
-----------------------------------------------------------------------------------------------------
# 上傳分支,-u (或是 --set-upstream) 表示並且追蹤遠端的分支,之後就不用再加 -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 

# 如果有的 remote branch 已被刪除,但 local 還留有 remote 追蹤 branch ,可以使用 git fetch --prune 將之清除
git fetch --prune

#git pull 等同 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>
git switch <commit id>

#建立 Barnch (可選某個 commit id 在上建立 branch)
git branch <branch name> [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

#檢查 <file path> 有沒有符合被 .gitignore 忽略及是符合哪一條規則
git check-ignore <file path> -v

#在 git checkut 於 <branch1> 時,想把 <branch2> merge 到 <branch1>
git merge [--no-ff] [--no-commit] [--merge]
#--no-ff : 不要用 fast-forward 的方式 merge
# --no-commit : 不要 commit,要改變的修改都先放在 Stage files 裡,可以用 git merge --continue 繼續執行 merge 或用 git --abort 取消 merge 
#--merge : 可退回到 merge 操作之前的狀態,並且保留 worktree 的修改。 它會取消當前分支上的合併操作,並將 worktree 和 stage 區恢复到合併之前的狀態。 與 git reset --hard 不同,--merge 不會丟棄未提交的更改。


# 捨棄 <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> (或是某個 commit)的後面
git rebase <branch>

#將 <commit from> 之後 (不包括 <commit from>) 到 <commit to> (或是 <branch name> 的最後一個 commit) 移到 <new base commit> 上,
#例如可以將一個 branch (<branch name> 或你現在所在的 branch) 的從 <commit from> 之後的 commits 都換到 <new base commit> 上,就像是改成從 <new base commit> 上長出 branch 一樣
git rebase --onto <new base commit> <commit from> [<commit to> | branch name>]

#互動式 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 從 stash 還原檔案修改但不刪除 stash,n 是第幾個 stash
git stash apply stash@{n}
# git stash pop 從 stash 還原檔案修改且刪除 stash,n 是第幾個 stash
git stash pop stash@{n}
# 刪除指定 stash,n 是第幾個 stash
git stash drop stash@{n}

#worktree 功能,可以同時以不同資料夾做為工作區,同時開發,同步共享 branch, commit。
#Note: 不能兩個 worktree 同時 checkout 在同一個 branch,例如 worktree1 正在 checkout branch1 時,如果 worktree2 要 checkout branch1 會被擋下來,直到 worktree1 checkout 到其他 branch 才行
#查詢 worktree
git worktree list

#建立新的 worktree 工作區
git worktree add <要建立新工作區的資料夾路徑> <新工作區一開始要 checkout 到哪個 branch>
例:
git worktree add D:\some-other-worktree-dir xxxBranchName

#移除不必要的 worktree 工作區,例如把不用的 worktree 工作區資料夾刪掉以後,可以用 git worktree prune 移掉 worktree 設定,才不會有 checkout branch 被擋的問題
git worktree prune

----------------------- 與 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 的設定,
十分地具有參考的價值。