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 系統),例如有可能分隔符會是 "/" 或 "\" 。

參考資料:

2022年1月25日 星期二

使用 Java 讀取 Neo4j 的查詢結果

Neo4j 是一套原生實現 (底層設計就是為了圖資料庫設計,而不是用例如一般關聯式資料庫去模擬) 圖資料庫(Graph Database) 的工具

Neo4j Community Edition 版本可以到 Neo4j 官網的下載中心 免費下載使用,
除了有啟動 Neo4j server 的功能外還提供了以網頁存取的方便介面 。

下載 Neo4j Community Edition 後,把下載下來的 zip 檔解壓縮,
進入到資料夾裡的 bin 資料夾,用命令列模式 (command line)
打上

neo4j console

指令後,可以用瀏覽器到 http://localhost:7474/browser/ 看到 UI 介面,提供各種功能,例如執行語法及可視化結果,預設登入 Database 的帳號密碼會都是 "neo4j" ,可以自行修改。

這邊紀錄下使用 Java 去讀取 Neo4j 查詢結果的方法,首先來看下需要的依賴 Maven Dependency:

<!-- https://mvnrepository.com/artifact/org.neo4j.driver/neo4j-java-driver -->
	<dependency>
	    <groupId>org.neo4j.driver</groupId>
	    <artifactId>neo4j-java-driver</artifactId>
	    <version>4.4.2</version>
	</dependency>
	
	<!-- https://mvnrepository.com/artifact/org.neo4j/neo4j-jdbc-driver -->
	<dependency>
	    <groupId>org.neo4j</groupId>
	    <artifactId>neo4j-jdbc-driver</artifactId>
	    <version>4.0.4</version>
	    <scope>runtime</scope>
	</dependency>
    
   <!-- https://mvnrepository.com/artifact/org.neo4j/neo4j -->
	<!-- neo4j embedded version -->
	<dependency>
	    <groupId>org.neo4j</groupId>
	    <artifactId>neo4j</artifactId>
	    <version>4.4.2</version>
	</dependency>
    
以上三個 neo4j 的 dependency 可以擇一使用,
可依你想要存取 neo4j 的方式來選擇,分述如下:
  1. org.neo4j.driver 的 neo4j-java-driver :
    使用 Driver 的方式來存取 neo4j ,有較接近 neo4j 原生結構的類別可操作使用,
    需要跟已開啟的 neo4j server 做連線,使用像例如
    neo4j://localhost:7687
    這樣的方式來操縱 neo4j 資料庫。
  2. org.neo4j 的 neo4j-jdbc-driver :
    使用 JDBC 的方式來存 neo4j ,
    需要跟已開啟的 neo4j server 做連線,使用像例如
    jdbc:neo4j:bolt://localhost:7687?user=xxx,password=xxx,scheme=basic
    的方式操縱 neo4j 資料庫。
    跟 neo4j-java-driver 比起來,neo4j-jdbc-driver 沒有接近 neo4j 原生結構的類別可操作使用,
    只能使用 jdbc 的 (Map) ResultSet.getObject() 方式等存取 
  3. org.neo4j 的 neo4j :
    使用 嵌入式(embedded) 的方式來存取本地端的 neo4j 資料庫檔案,
    可以直接處理本地端 neo4j 資料庫檔案 (通常為一個資料夾),
    不需開啟 neo4j server 做連線,適合用在無 server 的環境,
    有較接近 neo4j 原生結構的類別可操作使用,
    例如可直接對整個 neo4j-community-4.4.2 資料夾及指定 Database 名稱來做存取。
接下來我們先來建立一個簡單的 neo4j 資料庫內容,
內容為一個英雄人物曾當過哪些英雄的關係圖,像是這個樣子:
Bruce Wayne -[hasBeenHero] -> Batman
Dick Grayson -[hasBeenHero] -> Batman
Dick Grayson -[hasBeenHero] -> Nightwing
Dick Grayson -[hasBeenHero] -> Robin

建構資料的語法如下
//create "Persion" nodes
MERGE (bruceWayne:Person {name: 'Bruce Wayne'})
MERGE (dickGrayson:Person {name: 'Dick Grayson'})
//create "Hero" nodes
WITH bruceWayne,
	 dickGrayson, 
	 [
	 	{name: 'Batman'},
	 	{name: 'Nightwing'},
	 	{name: 'Robin'}
	 ] AS heros
//create and set relatoinship
FOREACH (hero in heros | 
    CREATE (h:Hero) SET h = hero
    CREATE (dickGrayson)-[:hasBeenHero]->(h)
)
WITH bruceWayne
MATCH (batman:Hero{name:'Batman'})
CREATE (bruceWayne)-[:hasBeenHero]->(batman)
用視覺化來看的話資料庫結果會如下圖:

接下來我們來看看要如何用 Java 把資料庫的 Node 和 Relationship 都查出來,
以下直接上 Java 程式碼,實現了三個 method ,分別對應了上述的三種存取 neo4j Database 的方式,分別是:
queryByDriver() 對應 org.neo4j.driver 的 neo4j-java-driver
queryByJdbc() 對應 org.neo4j 的 neo4j-jdbc-driver
queryByEmbeddedMode() 對應 org.neo4j 的 neo4j

 Neo4jTest.java :
package main;

import java.io.File;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.neo4j.dbms.api.DatabaseManagementService;
import org.neo4j.dbms.api.DatabaseManagementServiceBuilder;
import org.neo4j.driver.AuthTokens;
import org.neo4j.driver.Driver;
import org.neo4j.driver.GraphDatabase;
import org.neo4j.driver.Record;
import org.neo4j.driver.Result;
import org.neo4j.driver.Session;
import org.neo4j.driver.types.Node;
import org.neo4j.driver.types.Relationship;
import org.neo4j.graphdb.GraphDatabaseService;
import org.neo4j.graphdb.Transaction;
import org.neo4j.kernel.impl.core.NodeEntity;
import org.neo4j.kernel.impl.core.RelationshipEntity;

public class Neo4jTest {
	
	public static void main(String... args) throws Exception {
		String databaseName = "neo4j";
		String userName = "neo4j";
		String password = "neo4j";
		String neo4jServerUrl = "localhost:7687";
		Path neo4jDBHomeDirectoryPath = Paths.get(ClassLoader.getSystemResource("neo4j-community-4.4.2").toURI());
		//or if the database directory is located on other path:
		//Path neo4jDBHomeDirectoryPath = Paths.get("D:\\xxx\\yyy\\neo4j-community-4.4.2");
		
		queryByDriver("neo4j://" + neo4jServerUrl, userName, password);
		queryByJdbc(neo4jServerUrl, userName, password);
		queryByEmbeddedMode(neo4jDBHomeDirectoryPath, databaseName);
		
		System.out.println("Done");
	}
	
	public static void queryByDriver(String uri, String username, String password) {		
		
		try (Driver driver = GraphDatabase.driver(uri, AuthTokens.basic(username, password));
				Session session = driver.session();) {
			
			List<String> dataList = session.readTransaction(tx -> {
				List<String> resultList = new ArrayList<String>();
				
				Result result = tx.run("MATCH (person:Person)-[relationship:hasBeenHero]->(hero:Hero) "
									 + "RETURN * "
									 + "ORDER BY person.name, hero.name");
				
				while(result.hasNext()) {					
					Record record = result.next();
					Node person = record.get("person").asNode();
					Relationship relationship = record.get("relationship").asRelationship();
					Node hero = record.get("hero").asNode();
					
					String resultStr = person.get("name").asString() + " -" + relationship.type() + "-> " + hero.get("name").asString();
//					System.out.println(resultStr); // who -hasBeenHero-> whatHero
					resultList.add(resultStr);
				}
				
				return resultList;
			});
			
			for (String data : dataList) {
				System.out.println(data);
				/* output:
				   Bruce Wayne -hasBeenHero-> Batman
				   Dick Grayson -hasBeenHero-> Batman
				   Dick Grayson -hasBeenHero-> Nightwing
				   Dick Grayson -hasBeenHero-> Robin
				*/
			}
		}catch(Exception e) {
			e.printStackTrace();
		}
	}
	
	public static void queryByJdbc(String uri, String username, String password) {		
		try (Connection con = DriverManager.getConnection("jdbc:neo4j:bolt://" + uri + "?user=" + username + ",password=" + password +",scheme=basic");
				PreparedStatement pstmt = con.prepareStatement("MATCH (person:Person)-[relationship:hasBeenHero]->(hero:Hero) "
															 + "RETURN * "
															 + "ORDER BY person.name, hero.name");
				ResultSet rs = pstmt.executeQuery();
				){
			
			while(rs.next()) {
				Map person = (Map) rs.getObject("person");				
				Map relationship = (Map) rs.getObject("relationship");
				Map hero = (Map) rs.getObject("hero");
				String resultStr = person.get("name") + " -" + relationship.get("_type") + "-> " + hero.get("name");
				System.out.println(resultStr);
				/* output:
				   Bruce Wayne -hasBeenHero-> Batman
				   Dick Grayson -hasBeenHero-> Batman
				   Dick Grayson -hasBeenHero-> Nightwing
				   Dick Grayson -hasBeenHero-> Robin
				*/				
			}
		}catch(Exception e) {
			e.printStackTrace();
		}
	}
	
	// Don't need to start server manually, it will start server by itself.
	// Don't need username and password.
	public static void queryByEmbeddedMode(Path neo4jDBHomeDirectoryPath, String databaseName) {
		DatabaseManagementService databaseManagementService = new DatabaseManagementServiceBuilder(neo4jDBHomeDirectoryPath).build();
		GraphDatabaseService graphDatabaseService = databaseManagementService.database(databaseName);
		
		// Registers a shutdown hook for the Neo4j instance so that it
	    // shuts down nicely when the VM exits (even if you "Ctrl-C" the
	    // running application).
//	    Runtime.getRuntime().addShutdownHook( new Thread()
//	    {
//	        @Override
//	        public void run()
//	        {
//	        	databaseManagementService.shutdown();
//	        }
//	    } );
	    
	    try(Transaction tx = graphDatabaseService.beginTx();){
	    	org.neo4j.graphdb.Result result = tx.execute("MATCH (person:Person)-[relationship:hasBeenHero]->(hero:Hero) "
	    											   + "RETURN * "
	    											   + "ORDER BY person.name, hero.name");
			
			while(result.hasNext()) {				
				Map<String,Object> record = result.next();
				NodeEntity person = (NodeEntity) record.get("person");
				RelationshipEntity relationship = (RelationshipEntity) record.get("relationship");
				NodeEntity hero = (NodeEntity) record.get("hero");
				String resultStr = person.getProperty("name") + " -" + relationship.getType().name() + "-> " + hero.getProperty("name");
				System.out.println(resultStr);
				/* output:
				   Bruce Wayne -hasBeenHero-> Batman
				   Dick Grayson -hasBeenHero-> Batman
				   Dick Grayson -hasBeenHero-> Nightwing
				   Dick Grayson -hasBeenHero-> Robin
				*/
			}
	    }catch(Exception e) {
	    	e.printStackTrace();
	    }
	    
		databaseManagementService.shutdown();
	}
}
可以注意到的是,只有 queryByDriver() 和 queryByJdbc() 需要提供帳號及密碼,並且需要連接一個已經啟動的 neo4j Database server。
而 queryByEmbeddedMode() 不需帳號、密碼,也不需要一個已經啟動的 neo4j Database server,
它會直接對本地端的 neo4j Database folder 做存取,因為它會自己啟動 DB server ,
所以不要用例如上述的 neo4j console 指令啟動 server,不然可能會出現
Exception in thread "main" java.lang.RuntimeException: Error starting Neo4j database server at D:\xxx\yyy\neo4j-community-4.4.2\data\databases
的錯誤訊息。

原碼下載分享:

2022年1月3日 星期一

Java 物件序列化 (Object Serialize/Deserializ)

Java 可以使用序列化/反序列化的技術將物件實體 (Object Instance) 
轉成位元組格式資料 (通常用 byte array 表示) 再轉回來,
方便我們將物件實體保持起來等之後將其還原回物件實體,
或將其轉文字等用 http request 送給其他 server 並在server 端接收並還原。

Note:

物件必須要實作 Serializable 介面才能被序列化。
這篇文紀錄下 Java  如何物件的序列化(Serialize)/反序列化(Deserialize),
以下程式碼展示了兩個範例,
分別為將物件以檔案的方式
及以文字的方式進行序列化/反序列化。

而不管是將物件序列化成何種型式(例如檔案或文字),
概念都是一樣的,就是以物件被序列化成位元組格式
及將位元組反序列化回物件。

需要注意的是,如果想將物件序列化成文字 (String) 的話,
因為位元組轉成文字後,有可能會因為編碼等問題轉不回原來的位元組資料,
這時可先將位元組資料用例如 Base64 編碼得到字串來當做序列化後的字串保存起來,
之後要反序列化回物件時,把字串用 Base64 解碼成位元組格式,
再將位元組格式的資料反序列化回物件即可。

以下為程式碼範例 (jdk-11):

先建一個簡單的測試用 Class, Person.java:
Person.java:
package myTest;

import java.io.Serializable;

public class Person implements Serializable{
	private static final long serialVersionUID = 1L;
	
	private String name;
	
	public Person(String name) {
		this.name = name;
	}
	
	public void sayHello() {
		System.out.println("Hello, I'm " + name + ".");
	}
}

再來是序列化/反序列化的程式碼:
TestObjectSerialize.java:
package myTest;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.util.Base64;

public class TestObjectSerialize {

	public static void main(String[] args) {
		Person person = new Person("Hugo");
		person.sayHello(); //Hello, I'm Hugo.
		
		writeObjectToFile(person, "D:\\MyClass");
		Person deSerializedObject = readObjectFromFile(Person.class, "D:\\MyClass");
		deSerializedObject.sayHello(); //Hello, I'm Hugo.
		
		String serializedObjectString = writeObjectToString(person);
		Person deSerializedObject2 = readObjectFromString(Person.class, serializedObjectString);
		deSerializedObject2.sayHello(); //Hello, I'm Hugo.
		
		System.out.println("Done");
	}
	
	//----- Serialize/Desrialize Object through file -----//
	public static <T> void writeObjectToFile(T object, String filePath){
		try (FileOutputStream fileOutputStream = new FileOutputStream(filePath);
			 ObjectOutputStream objectOutputStream = new ObjectOutputStream(fileOutputStream);){
			
			objectOutputStream.writeObject(object);
		}catch(IOException e) {
			e.printStackTrace();
		}
	}
	
	public static <T> T readObjectFromFile(Class<T> clazz, String filePath){
		T deserializedObject = null;
		
		try (
				FileInputStream fileInputStream = new FileInputStream(filePath);
				ObjectInputStream objectInputStream = new ObjectInputStream(fileInputStream);
			){
			
			deserializedObject = (T) objectInputStream.readObject();
		}catch(IOException | ClassNotFoundException e) {
			e.printStackTrace();
		}
		
		return deserializedObject;
	}
	
	//----- Serialize/Desrialize Object through String -----//
	//Because Object can be serilaized to String
	//, the String can stored in anywhere or be transfer to other server 
	//and be deserialized to original Object from String.
	//Notice: After serializing Object to Byte Array, you should use some way like Base64Encoding to
	//encode the Byte Array to String,
	//because the String you got directly from Byte Array might not be transfered to original Byte Array.
	public static <T> String writeObjectToString(T object){
		String serializedObjectString = "";

		try (
				ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
				ObjectOutputStream objectOutputStream = new ObjectOutputStream(byteArrayOutputStream);
			){
			
			objectOutputStream.writeObject(object);
			serializedObjectString = new String(Base64.getEncoder().encode(byteArrayOutputStream.toByteArray()), "UTF-8");
		} catch (IOException e) {
			e.printStackTrace();
		}
		
		
		return serializedObjectString;
	}
	
	public static <T> T readObjectFromString(Class<T> clazz, String serializedObjectString){
		T deserializedObject = null;
		
		try (
				ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(Base64.getDecoder().decode(serializedObjectString.getBytes("UTF-8")));
				ObjectInputStream objectInputStream = new ObjectInputStream(byteArrayInputStream);
			){
			
			deserializedObject = (T) objectInputStream.readObject();
		} catch (IOException | ClassNotFoundException e) {
			e.printStackTrace();
		}
		
		return deserializedObject;
	}

}

2021年12月29日 星期三

使用 Gradle 建立 Fat Jar 的範例

使用 Gradle 將專案做成 Fat Jar 型式的 build.gradle 範列:

Note:

Fat Jar 為把所有 Dependency 都包在一起的一種 Jar 包。
有幾種不同的實現,其中 Unshaded 的方式為把依賴的 jar 都解開來,
並一起包進最後的 Jar 包中。

Note:

以下 build.gradle 範例使用了 Gradle Shadow plugin 來打包,
但非必須,只使用 Gradle 自帶的 jar task 也可以打包 fat jar,
只是此範例因依賴了 log4j2,因為 log4j2 在打包 fat jar 時會有
多個 Log4j2Plugins.dat 檔被不正常合併的問題
(每個 log4j plugin 的 Log4j2Plugins.dat 被合成一個檔,但內容互相蓋掉而沒有將內容正確合併),
所以使用了 Gradle Shadow plugin 的 Log4j2PluginsCacheFileTransformer 來解決。

此範例使用了 Gradle 7.3.3 版,建立 Fat Jar 的指令為:
./gradlew clean shadowJar

bundle.gradle :
plugins {
    // Apply the java-library plugin to add support for Java Library
    id 'java-library'
    id 'application'
    id 'com.github.johnrengelman.shadow' version '7.1.2'
}

java {
    sourceCompatibility = JavaVersion.VERSION_11
    targetCompatibility = JavaVersion.VERSION_11
}

mainClassName = "main.Main"

repositories {
    mavenCentral()
}

configurations {
    externalLibs
}

dependencies {
    // This dependency is exported to consumers, that is to say found on their compile classpath.
    api 'org.apache.commons:commons-math3:3.6.1'

    // This dependency is used internally, and not exposed to consumers on their own compile classpath.
    implementation 'com.google.guava:guava:28.0-jre'

    // Use JUnit test framework
    testImplementation 'junit:junit:4.12'
    
    // https://mvnrepository.com/artifact/javax.mail/javax.mail-api
	implementation group: 'javax.mail', name: 'javax.mail-api', version: '1.6.2'
	
	// https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-core
	implementation group: 'org.apache.logging.log4j', name: 'log4j-core', version: '2.17.1'
	
	// https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-api
	implementation group: 'org.apache.logging.log4j', name: 'log4j-api', version: '2.17.1'
	
	// https://mvnrepository.com/artifact/org.slf4j/slf4j-api
	implementation group: 'org.slf4j', name: 'slf4j-api', version: '1.7.25'
	
	// https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-slf4j-impl
	implementation group: 'org.apache.logging.log4j', name: 'log4j-slf4j-impl', version: '2.17.1'
   
   //external libs, for example: xxx.dll
   externalLibs files('xxxExternalLib1, xxxExternalLib2')
}

shadowJar{
  transform(com.github.jengelman.gradle.plugins.shadow.transformers.Log4j2PluginsCacheFileTransformer)
  archiveFileName = "${baseName}.${extension}"
}

jar {
    manifest {
        attributes(
        	'Main-Class': 'main.Main',
        	"Multi-Release": true
    	)
    }
    from configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) }    	
    from configurations.externalLibs.collect { it }
}
--------------------------------------------------------------
上列 Fat Jar 的 bundle.gradle 內容中,Gradle Shadow plugin 會讀取 jar task 裡的配置。
在 jar task 中,需要加入以下兩條設定來將依賴放到最終的 Jar 檔裡,
否則會只有專案本身的程式被編譯而已:
from configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) }    	
from configurations.externalLibs.collect { it }
編譯後的 Jar 檔會被放在專案的
/build/lib 資料夾中,名稱可以用如以下的設定自行修改:
archiveFileName = "${baseName}.${extension}"

Note:

還有其他各種參數值可以使用,例如:${baseName}, ${appendix}, ${version}, ${classifier}, ${extension} 等
--------------------------------------------------------------

執行 Gradle 指令除了用自己在電腦上安裝的 Gradle 以外 (可能會跟專案用的版本不同),
也可使用專案中自帶的 Gradle Wrapper 來執行 Gradle 指令,
好處是可以使用跟專案開發時一樣本的 Gradle,
並且就算自己電腦上沒有安裝 Gradle 也可以執行,例如:

./gradlew clean build

如果想更改專案用的 Gradle 版本,可執行以下指令,例如要更改成 7.3.3 版:
./gradlew wrapper --gradle-version 7.3.3

可以查看專案目錄中的 
/gradle/wrapper/gradle-wrapper.properties
,其中 distributionUrl 屬性值會有此專案用的 Gradle 資訊,
當電腦中沒有相應版本的 Gradle 時,它會自行下載相應版本

參考資料:

2021年12月25日 星期六

使用 VBScript 上傳檔案( multipart/form-data http post)

這裡紀錄下使用 VBscript 上傳檔案 (httpPost multipart/form-data) 的方法,
在這邊是照著 multipart/form-data 協議來手動刻出所需的封包格式,
詳細可以參考:

這裡我們用 WSF (Window Script File) 檔配合 VBscript 程式來實作,
將以下程式碼存成 UTF-8 編碼格式的 .wsf 檔,
並在命令列模式(command line) 下執行 wscript 或 cscript 用 WSH (Window Script Host) 去跑程式即可,例如:
csript xxx.wsf
要注意的是,因為我們將檔存成了 UTF-8 編碼格式,
所以在 .wsf 檔中必需在 xml 聲明 (XML declaration) 中標示 encoding="UTF-8",
例如:
<?xml version="1.0" encoding="UTF-8"?>
以下為程式碼範例,
建立了一個 uploadFile(filePath, uploadTo) 函式來傳送檔案到 uploadTo 指定的 url,
其中 "http://localhost:8080/uploadFile.do" 是接收 httpPost request 的 server,這裡不做討論,可以參考這篇文,"使用 Java 上傳檔案(發送 enctype=multipart/form-data 的 HttpPost")。
在 uploadFile() 函式中,也可以看到傳了一個中文參數的範例 (uploadData.AddForm)。

uploadFile.wsf:
<?xml version="1.0" encoding="UTF-8"?>
<package>
<job id="xxx">

<script language="VBScript">
<![CDATA[
 uploadFile "D:\未命名.png", "http://localhost:8080/uploadFile.do"
 
 ''''''''''''''''''''''''''''''''''''''''''''''''''''
 Function uploadFile(filePath, uploadTo)
  
  Dim uploadData
  Set uploadData = New XMLUpload
  
  uploadData.Charset = "utf-8" ' see Public Property Let Charset(ByVal strValue)
  uploadData.openWithUrl uploadTo
  
  uploadData.AddForm "param1", "中文參數"  
  uploadData.AddFile "uploadedFile", filePath
  
  Dim responseStr
  responseStr = uploadData.Upload()
  Set uploadData = Nothing
  
  uploadFile = responseStr
 End Function
 
 Class XMLUpload
  Private xmlHttp
  Private objTemp
  Private adTypeBinary, adTypeText
  Private strCharset, strBoundary

  Private Sub Class_Initialize()
   adTypeBinary = 1
   adTypeText = 2
   Set xmlHttp = CreateObject("Msxml2.XMLHTTP")
   Set objTemp = CreateObject("ADODB.Stream")
   objTemp.Type = adTypeBinary
   objTemp.Open
   strCharset = "utf-8"
   strBoundary = GetBoundary()
  End Sub

  Private Sub Class_Terminate()
   objTemp.Close
   Set objTemp = Nothing
   Set xmlHttp = Nothing
  End Sub  
  
  '設置上傳使用的字符集
  Public Property Let Charset(ByVal strValue)
   strCharset = strValue
  End Property
  
  Public Sub openWithUrl(ByVal urlStr)
   xmlHttp.Open "POST", urlStr, False
  End Sub

  '獲取自訂義的表單數據分界線
  Private Function GetBoundary()
   Dim ret(12)
   Dim table
   Dim i
   table = "abcdefghijklmnopqrstuvwxzy0123456789"
   Randomize
   For i = 0 To UBound(ret)
    ret(i) = Mid(table, Int(Rnd() * Len(table) + 1), 1)
   Next
   GetBoundary = "---------------------------" & Join(ret, Empty)
  End Function  

  '添加文本域的名稱和值
  Public Sub AddForm(ByVal strName, ByVal strValue)
   Dim tmp
   tmp = "\r\n--$1\r\nContent-Disposition: form-data; name=""$2""\r\n\r\n$3"
   tmp = Replace(tmp, "\r\n", vbCrLf)
   tmp = Replace(tmp, "$1", strBoundary)
   tmp = Replace(tmp, "$2", strName)
   tmp = Replace(tmp, "$3", strValue)
   objTemp.Write StringToBytes(tmp, strCharset)
  End Sub
  
  '指定字符集的字符串轉字節數組
  Public Function StringToBytes(ByVal strData, ByVal strCharset)
   Dim objFile
   Set objFile = CreateObject("ADODB.Stream")
   objFile.Type = adTypeText
   objFile.Charset = strCharset
   objFile.Open
   objFile.WriteText strData
   objFile.Position = 0
   objFile.Type = adTypeBinary
   If UCase(strCharset) = "UNICODE" Then
    objFile.Position = 2 'delete UNICODE BOM
   ElseIf UCase(strCharset) = "UTF-8" Then
    objFile.Position = 3 'delete UTF-8 BOM
   End If
   StringToBytes = objFile.Read(-1)
   objFile.Close
   Set objFile = Nothing
  End Function

  '設置文件域的名稱/文件名稱/文件MIME類型/文件路徑或文件字節數組
  Public Sub AddFile(ByVal strName, ByVal strFilePath)
   Dim tmp, strFileName, strFileType, strExt   
   
   With CreateObject("Scripting.FileSystemObject")
    If .FileExists(strFilePath) Then
     strFileName = .GetFileName(strFilePath)
     strExt = .GetExtensionName(strFilePath)
    End IF
   End With
   
   With CreateObject("Scripting.Dictionary")
    .Add "php", "application/x-php"
    .Add "vbs", "application/x-vbs"
    .Add "jpe", "image/jpeg"
    .Add "jpg", "image/jpeg"
    .Add "jpeg", "image/jpeg"
    .Add "gif", "image/gif"
    .Add "png", "image/png"
    .Add "bmp", "image/bmp"
    .Add "ico", "image/x-icon"
    .Add "svg", "image/svg+xml"
    .Add "svgz", "image/svg+xml"
    .Add "tif", "image/tiff"
    .Add "tiff", "image/tiff"
    .Add "pct", "image/x-pict"
    .Add "psd", "image/vnd.adobe.photoshop"
    .Add "aac", "audio/x-aac"
    .Add "aif", "audio/x-aiff"
    .Add "flac", "audio/x-flac"
    .Add "m4a", "audio/x-m4a"
    .Add "m4b", "audio/x-m4b"
    .Add "mid", "audio/midi"
    .Add "midi", "audio/midi"
    .Add "mp3", "audio/mpeg"
    .Add "mpa", "audio/mpeg"
    .Add "mpc", "audio/x-musepack"
    .Add "oga", "audio/ogg"
    .Add "ogg", "audio/ogg"
    .Add "ra", "audio/vnd.rn-realaudio"
    .Add "ram", "audio/vnd.rn-realaudio"
    .Add "snd", "audio/x-snd"
    .Add "wav", "audio/x-wav"
    .Add "wma", "audio/x-ms-wma"
    .Add "avi", "video/x-msvideo"
    .Add "divx", "video/divx"
    .Add "flv", "video/x-flv"
    .Add "m4v", "video/mp4"
    .Add "mkv", "video/x-matroska"
    .Add "mov", "video/quicktime"
    .Add "mp4", "video/mp4"
    .Add "mpeg", "video/mpeg"
    .Add "mpg", "video/mpeg"
    .Add "ogm", "application/ogg"
    .Add "ogv", "video/ogg"
    .Add "rm", "application/vnd.rn-realmedia"
    .Add "rmvb", "application/vnd.rn-realmedia-vbr"
    .Add "smil", "application/x-smil"
    .Add "webm", "video/webm"
    .Add "wmv", "video/x-ms-wmv"
    .Add "xvid", "video/x-msvideo"
    .Add "js", "application/javascript"
    .Add "xml", "text/xml"
    .Add "html", "text/html"
    .Add "css", "text/css"
    .Add "txt", "text/plain"
    .Add "py", "text/x-python"
    .Add "pdf", "application/pdf"
    .Add "xhtml", "application/xhtml+xml"
    .Add "zip", "application/x-zip-compressed, application/zip"
    .Add "rar", "application/x-rar-compressed"
    .Add "cmd", "application/cmd"
    .Add "bat", "application/x-bat, application/x-msdos-program"
    .Add "exe", "application/exe, application/x-ms-dos-executable"
    .Add "msi", "application/x-msi"
    .Add "bin", "application/x-binary"
    .Add "crt", "application/x-x509-ca-cert"
    .Add "crl", "application/x-pkcs7-crl"
    .Add "pfx", "application/x-pkcs12"
    .Add "p12", "application/x-pkcs12"
    .Add "odc", "application/vnd.oasis.opendocument.chart"
    .Add "odf", "application/vnd.oasis.opendocument.formula"
    .Add "odb", "application/vnd.oasis.opendocument.database"
    .Add "odg", "application/vnd.oasis.opendocument.graphics"
    .Add "odi", "application/vnd.oasis.opendocument.image"
    .Add "odp", "application/vnd.oasis.opendocument.presentation"
    .Add "ods", "application/vnd.oasis.opendocument.spreadsheet"
    .Add "odt", "application/vnd.oasis.opendocument.tex"
    .Add "docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
    .Add "dotx", "application/vnd.openxmlformats-officedocument.wordprocessingml.template"
    .Add "potx", "application/vnd.openxmlformats-officedocument.presentationml.template"
    .Add "ppsx", "application/vnd.openxmlformats-officedocument.presentationml.slideshow"
    .Add "pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation"
    .Add "xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
    .Add "xltx", "application/vnd.openxmlformats-officedocument.spreadsheetml.template"
    .Add "ppam", "application/vnd.ms-powerpoint.addin.macroEnabled.12"
    .Add "ppa", "application/vnd.ms-powerpoint"
    .Add "potm", "application/vnd.ms-powerpoint.template.macroEnabled.12"
    .Add "ppsm", "application/vnd.ms-powerpoint.slideshow.macroEnabled.12"
    .Add "xlsm", "application/vnd.ms-excel.sheet.macroEnabled.12"
    .Add "pptm", "application/vnd.ms-powerpoint.presentation.macroEnabled.12"
    .Add "dotm", "application/vnd.ms-word.template.macroEnabled.12"
    .Add "docm", "application/vnd.ms-word.document.macroEnabled.12"
    .Add "doc", "application/msword"
    .Add "dot", "application/msword"
    .Add "pps", "application/mspowerpoint"
    .Add "ppt", "application/mspowerpoint,application/powerpoint,application/vnd.ms-powerpoint,application/x-mspowerpoint"
    .Add "xls", "application/vnd.ms-excel"
    .Add "xlt", "application/vnd.ms-excel"

    strFileType = .Item(LCase(strExt))
   End With
   
   tmp = "\r\n--$1\r\nContent-Disposition: form-data; name=""$2""; filename=""$3""\r\nContent-Type: $4\r\n\r\n"
   tmp = Replace(tmp, "\r\n", vbCrLf)
   tmp = Replace(tmp, "$1", strBoundary)
   tmp = Replace(tmp, "$2", strName)
   tmp = Replace(tmp, "$3", strFileName)
   tmp = Replace(tmp, "$4", strFileType)
   
   objTemp.Write StringToBytes(tmp, strCharset)
   objTemp.Write GetFileBinary(strFilePath)
  End Sub
  
  '獲取文件內容的字節數組
  Private Function GetFileBinary(ByVal strPath)
   Dim objFile
   Set objFile = CreateObject("ADODB.Stream")
   objFile.Charset = strCharset
   objFile.Type = adTypeBinary
   objFile.Open   
   objFile.LoadFromFile strPath
   GetFileBinary = objFile.Read(-1)
   objFile.Close
   Set objFile = Nothing
  End Function
  
  Public Sub AddHeader(ByVal strName, ByVal strValue)
   xmlHttp.setRequestHeader strName, strValue
  End Sub
  
  '上傳到指定的URL,并返回服務器應答
  Public Function Upload()
   Call AddEnd   
   xmlHttp.setRequestHeader "Content-Type", "multipart/form-data; boundary=" & strBoundary
   'xmlHttp.setRequestHeader "Content-Length", objTemp.size   
   xmlHttp.Send objTemp
   Upload = xmlHttp.responseText
  End Function
  
  '設置multipart/form-data結束標記
  Private Sub AddEnd()
   Dim tmp
   tmp = "\r\n--$1--\r\n"
   tmp = Replace(tmp, "\r\n", vbCrLf)
   tmp = Replace(tmp, "$1", strBoundary)
   objTemp.Write StringToBytes(tmp, strCharset)
   objTemp.Position = 2
  End Sub
 End Class
]]>
</script>
</job>
</package>

參考資料:

  1. HTTP協議之multipart/form-data請求分析
  2. VBS模拟POST上传文件
  3. File updload in post form in VBS
  4. Issues running JScript or VBScript files with UTF-8 encoding thru Windows Script Host
  5. WSF - Windows Script File XML Format
  6. XML
  7. CDATA
  8. Call 语句

2021年12月20日 星期一

用VBScript 讀本地Outlook信件檔案的收件者

這邊紀錄下利用 VBscript 來
  1. 讀取本地端的 Outlook msg 檔案資訊,移如信件的收信者資訊。
  2. 讀取本地 Outlook 收件夾(或刪除的郵件、寄件備份等資料夾)裡的信件資訊。
以下先上程式碼:
<package>
<job id=XXX>

<script language="VBScript">

parOutlookMsgFile("D:\testOutlookMail.msg")
parseOutlookInboxFolder(6)

'''''''''''''''''''''''''''''''''''''''''''''''''''''

Sub parOutlookMsgFile(msgFilePath)
Dim objOutlook
'Dim objInBoxFolder
'Dim objNameSpace

Set objOutlook = CreateObject("Outlook.Application")
'Set objNameSpace = objOutlook.GetNamespace("MAPI")
'Set objInBoxFolder = objNameSpace.GetDefaultFolder(6)

Dim mail, recips, recip, email_single, pa
Set mail = objOutlook.CreateItemFromTemplate(msgFilePath)
Set recips = mail.Recipients

For Each recip In recips
    Set pa = recip.PropertyAccessor
    email_single = pa.GetProperty("http://schemas.microsoft.com/mapi/proptag/0x39FE001E")
             
    WScript.Echo "Receiver Name: " & recip.Name & ", Receiver Email: " & email_single & ", type: " & recip.Type
Next
End Sub

'''''''''''''''''''''''''''''''''''''''''''''''''''''
Sub parseOutlookInboxFolder(inboxFolderType)
'inboxFolderType:
' received mail inbox: 6
' deleted mail inbox : 3
' sent mail inbox : 5
Dim objOutlook, objInBoxFolder, objNameSpace, objMailItems

Set objOutlook = CreateObject("Outlook.Application")
Set objNameSpace = objOutlook.GetNamespace("MAPI")
Set objInBoxFolder = objNameSpace.GetDefaultFolder(inboxFolderType)

Set objMailItems = objInBoxFolder.Items
Dim i 
i = 1
Dim totalMailCount
totalMailCount = objMailItems.count
While i <= totalMailCount
		Set objMail = objMailItems.Item(i)
		WScript.Echo objMail.Subject
		i = i + 1
Wend

End Sub

</script>

</job>
</package>

說明:
程式碼中有兩個函式,分別是用來讀取單一 Msg 檔資訊的 parOutlookMsgFile()
和 讀取 Outlook 收件夾(或刪除的郵件、寄件備份等資料夾)裡的信件資訊的 parseOutlookInboxFolder()。

在 parOutlookMsgFile() 中,recip.Type 可能有 1 或 2 兩種值, 
Type = 1 代表一般收件者,
Type = 2 代表 cc 副本的收件者。

parseOutlookInboxFolder() 函式可以接收收件夾 type (OlDefaultFolders 形別) 的值,
其中 6 代表收件夾,
3 代表刪除的郵件,