作為一個開發向的IT狗,應該要對一些DevOps技術有實踐上的認知,所以我把自身的經驗寫下來。希望可以有一個整體上的實踐方案以供討論和改進。

雖然我覺得願意看的人不多,看完後願意試的人更少,試完願意分享意見和改進的更希少。如果你是那個少少少少的希有人群,有興趣可以在github提交修正。

Github - 程序員的自我修養

Docker 101

雖然Docker面世很久了,但筆者也是最近才摸清Docker一些概念和實務上的用途。趁著還有一些記憶,分享一些筆記。好讓未正式用過的朋友少走一些冤枉路。

Docker是輕量VM?

首先,第一個最大的普遍認知錯誤,就是認為Docker是輕量VM。

但其實它不是VM(更不是輕量VM),它其實只是Linux上幫忙起Process的Daemon,官方叫作Docker Engine。個人感覺就像是在Host OS Kernal上的一個Playground (Sandbox會不會pro一點?)。我們平時最常使用的,是請Docker Engine根據Docker Image生成Container。而這些Container,因為一些特性,我習慣對非核心用戶解釋為【分身】。

整個生態系統中,最特別的是Container和Container之間,本身是獨立的。它們之間沒有Network, Storge也不會共用。除非你刻意連結起,否則它們理論上不會互相污染。但因為Kernal本身也是直接用Host OS,所以還是有機會被Hack而互相污染。

Docker 可以打包萬用Linux Applicaiton?

第二個認知錯誤,就是認為Docker Container有persistent storage,可以看成VM,做一些Statefull application。

最陽春的Persistent storage是存在的,但並不是預期你會用到Production上面。因為Production大家考量的是做Cluster(例如Docker Swarm, K8s, KIND),隨時增加或刪除多個相同Image來源的Container(分身),以應付大流量。而經過Cluster的網絡routing mesh,即使同一個IP的Network request,也並不會一定派到同一個Container上。

Docker Run?

第三個,不是認知錯誤,而是入坑起點問題。大部份教學,都會教大家怎樣包裝自己的Docker image,然後經docker run,產生container。

但其實一個application,並不是單一container可以包辦的事。例如,一個Wordpress網站,就會分了為兩個不同的Service(服務)來運作 來運作。而同一個Service,在需要的情況下,可以由一個或多個分身(Container)組成。

回到Wordpress的例子,應該看由成以下各部份組成的系統

  • 一個資料庫的Service,靠一個Container作為資料儲存。
  • 一個Web Service,靠一個或多個Php (Apache) Container來應對使用者Http Request。
  • 經Docker Network把上面兩個Service連在一起。而且Web Service會對外開放Port,但資料庫就只會對內開放Port。

而一般的docker run,需要自行把上述所有事情串連起來。需要學習的command多,也很難感受整體的架構。個人覺得,在testing / development 環境下,容易上手的入門方式應該是docker compose。它可以同時行起多個Service/Container,提供同一個內部Network,把串連的動作簡化。更重要的是,docker compose主要是靠yaml file來做設定。讓新用戶可以更容易地重現別人做的好設定。

把command line 的程式打包成Docker Image

平時我們用別人的Docker image,都以網絡服務為主,也就是,他們的image通常以常駐,打開某個網絡端口(UDP/TCP Port)提供服務。

但像nodejs, java等,其實是一些command line程式。它們其實可以一次性地執行後,回傳結果。

這次我們就以一個mdbook程式為例子,介紹打包的流程。

第一件事,在Docker image中,安裝或編譯binary

# Dockerfile
FROM ubuntu:22.04

RUN apt-get update && apt-get install cargo -y && cargo install mdbook

ENV PATH="${PATH}:/root/.cargo/bin"

VOLUME /opt/mdbookdata
WORKDIR /opt/mdbookdata
ENTRYPOINT ["mdbook"]

打包成image,給他一個名字,例如mdbook:beta

sudo docker build -t mdbook:beta ./

在Dockerfile中,我選擇使用 ENTRYPOINTexec 格式,因為我需要後期在 docker run 動態加入參數。

例如,要把參數 --version 傳入container,讓mdbook就可以看到該參數。

sudo docker run --rm -it -v $(pwd):/opt/mdbookdata mdbook:beta --version

但在docker image中,它是使用root來執行。如果你直接執行build,那麼它的輸出資料夾 book/ 的擁有人就會變成了root。你要用指定 --user $(id -u):$(id -g) 來指定執行者。

sudo docker run --user $(id -u):$(id -g) --rm -it -v $(pwd):/opt/mdbookdata mdbook:beta build

還有,如果不想每次都打這麼長的指令,你可以在 ~/.bashrc 中做別名(alias)。

# edit ~/.bashrc
alias mdbook='sudo docker run --user $(id -u):$(id -g) --rm -it -v $(pwd):/opt/mdbookdata mdbook:beta $1'

這樣就可以簡化執行指令。

# let .bashrc take effect without logout login
source ~/.bashrc
# run command through alias
mdbook --version
# it support multiple arguments
mdbook build --dest-dir ./dest

Distribution Registry

Docker Tag 命名

一般來講,同一個docker image會提供多個不同的版本,每個版本會附予不同的tag,以作標識。但以docker image的維護者來講,它的tag通常代表的是自己程式的版本號。不過這個版本號卻存在很多變數,就讓筆者好好地逐一說明。

程式的版本號

在沒有Docker的年代,其實所有軟件在發佈時,都會標示版本號,方便使用方明確追蹤問題,自行選擇升級、降級以解決相容性問題。大家要重現問題,也能清清楚地重現。所以docker image的tag,在某程度,都是代表發佈自己的程式版本號。但以前的年代,軟件底層的依賴,例如OS層面的共享程式庫,則不在發佈的管控中,所以過去的程式,在跨電腦安裝時,都會出現缺少某些共享庫的問題。而使用了Docker後,image以內的共享庫的都會在打包的那一刻固定和發佈,就不會有漏的問題。

庫更新,怎麼辦

上面說到image可以打包共享庫,但問題是共享庫也會有安全性更新問題,那麼對docker image的維護者來講,它自己的tag又該如何命名?

因為庫的量可大可少,所以一般來說,都不可能完全把各個庫的版本號寫在自己的tag上。退而求其次,就是用"版本號+日期",庫的細版本號,就存在原始碼當中。Ubuntu 就是這樣的例子。

不過"版本號+日期"的命名方式真的方便嗎?每次下遊用戶想更新去最近版本,都要自己找一次最近的日期。這樣對很多用戶來講都不夠方便。所以docker又提供了一個重tag的功能。例如ubuntu:noble,在早些時候指著noble-20240904.1,然後過幾天,又指向更新的noble-20241009。更常見的是latest,每次image都預設會存在,docker也希望大家會定期更新這個tag,讓大家可以更易地找到最新版本。

註: 這跟git tag有所不同,git tag並不預期會變的。當協作者收到tag後,那怕上遊刻意更新tag指針,協作者沒有刪除原tag之前,都不會知道tag更新去了哪裏。

我們該如何選

在發佈方和引用方來講,引用時可以明確使用唯一的"版本號+日期",對穩定性來講是有意義的。不過多多少少,會產生額外的時間成本。發佈方來說,就是多用了一些儲存空間,方便引用方可以隨時找到舊(庫)版本。而引用方,就要手動修改引用號,作為驗收依據,自動更新的難度比較大。

但對於自動更新要求比較大的情況下,可能就是使用latest或者會隨時更新的share tag(共用tag)比較實際。但我們也依然要定一些方式去版本更新記錄,例如:同時使用

  • beta
  • latest
  • archive

每日自動更新beta,只有所有測試都通過時,才把archive指向現在的latest,再把latest指向現在的beta。這樣做的好處是,核心的docker stack檔案改變的機會較少,也可以免除docker swarm做太細緻的權限管理。

draft

Offical Doc

Garbage collection on registry

config

storage:
  filesystem:
    rootdirectory: /var/lib/registry
  delete:
    enabled: true

command

bin/registry garbage-collect --delete-untagged=true /etc/docker/registry/config.yml

some image keep using same tag to keep check latest version; but older blob is not able to be deleted according to normal manifest garbage collection. Must use --delete-untagged flag to delete unused blob

Docker In Production

大部份在大公司工作的朋友,在公開的應用情景中,應該都會直接選擇Public Cloud(公有雲)的Kubernetes作為Production(投產環境)。但對於澳門的中、小微企,要找一個附合個人資料保護法的Public Cloud,可真難。想要簡單地靠自己架設本地Kubernetes,也不是一件易事。退而求其次,想要快速擁有一個Cluster(群集),Docker是一個最直觀的選擇。

之前筆者都介紹過Swarm mode的一些指令,但指令只是執行的手法,怎樣管理你的App還是有不同的選擇。

把App轉成Swarm mode 還是把底層程式變成Swarm mode?

雖然筆者對於Docker Swarm Mode的資歷尚淺,但由於後期更動的難點越來越多,筆者很想早一點討論其中不同操作的差異

Docker Swarm

Docker Swarm Mode其實是Docker提供的一個Cluster(群集)環境。在其中運行的Image,都可以比較方便地隨時分身到不同的node(節點)上,對於提高負載或可用性,都是一個不錯的解決。

只要該Image跑起的Container是Stateless(前後兩次執行的結果互不相干涉),或者是把Stateful的部份(有干涉的部份)外包到第三方(例如儲存空間使用NFS,或記憶體暫存改為Key-Value Database),就可以方便地運作在Docker Swarm mode上。

部署Docker Swarm的選項

Docker Swarm可以把Image變成分身Container,但並沒有硬性改變傳統App操作方式。大部份App在執行時,都需要另一個底層程式的支緩。例如

  • Php Web App,需要底層php fpm + nginx或apache
  • Java Web App,就需要java + Tomcat

所以在發佈App時,可以選擇把

  • App直接打包成Image
  • 只把底層程式打包在Image中(例如Tomcat),再用Docker Volume的方式讓Container可以起動App。

兩者有何差別?

就信心層面上,一定是把App直接打包成Image實際一點。因為這樣可以極大地減少測試環境和正式環境的差異而出現的問題。筆者一開始也不完全讚成,但也越來越傾向這種做法。

在解釋筆者為何有這個結論前,先條列式地對比一下兩種差別。

事項打包App成為Image打包底層程式成為Image
打包複雜度需要把App用到的一些環境變數引入設定Image的entrypoint中,方便配合不同的環境可以改變App的行為。
打包次數根據App數量有關。
比較靈活,但比較需要學習和試錯。
底層程式統一設定環境變數,其中所有App都會使用類似或相同的設定,設定方式跟傳統方式無異。
打包次數根據底層程式數量有關。
比較死版,但要試錯的成本較低
發佈流程打包App成Image。再靠Docker Swarm設定Image有多少分身。多個Image的協調,靠著Swarm完成。原來的底層程式已存在於Docker Swarm中,只需把新建或更新了的App放入不同分身的儲存空間,讓底層程式動態跑起App。但若底層程式也要需要因應App數量而改變,有機會底層程式也需要重新打包。
管理複雜度每個App都是獨立的,代表有任何更新也是獨立更新。
在微服務的協作環境中,需要管理員從Image層面為每個App設定網絡(network)或開放端口(Port)。但每個App可以設定不同的分身數量,靈活性一定比只打包底層程式要高。
使用同一個底層程式Image的App都會使用同一個網路和端口設定。
在微服務的協作環境中,管理員要應付的設定數量一定比打包App要少。
但由於是Conatiner分身是針對底層程式,所以若然某個App有不同需求,就要重新設定另一套底層程式。
資源消耗每個App獨立,代表資料消耗也是獨立的。總消耗就是倍數增長。但可以經Docker限定每個Image的使用量因為底層程式共用,多少有可以省略的地方。資源限制沿用底層程式的傳統邏輯。

上述幾個點,最後其實都是複雜度和靈活度的取捨。雖然打包App的工序更多,但提供的靈活性也更多。如果考慮要從傳統模式中過渡,方便與完成不懂Docker的同事協作,就首選打包底層程式。如果考慮可重複性和信心保證,還是打包App比較直接,要複制一個環境到另一個環境,也比較易測試。

打包底層程式的做法不是大問題,不過一定要考慮的是多個App之間是怎樣建構、多點佈署該如何實現,設定檔的備份又該如何做。因為Image內沒有太多關於佈署的設定,想偷賴也不行。若設定檔、多個App都底層程式都一起打包到同一個Image中,這其實是一個多Service的小系統。除非多個App很緊密連接,不然不推薦這種做法。因為每個App有更新,都會讓所有App重啟,就會對實際使用帶來很大的不便。

到底如何選?

如果大家上正式的Docker課程,Docker導師通常會推薦為每個App打包成獨立Image,因為底層程式的Overhead(釋譯成上頭成本?)通常不大,例如底層程式是Tomcat、Apache、Nignx這類網頁伺服器,重量級的開銷並不是因為多幾個Web Engine的分身造成,通常都是因為業務本身。但如果你講的底層程式是資料庫等的大型程式,才可能會有明顯的差異。

實務上的建議,就是必需考慮自身的經驗,到底那個方案自己比較有把握。獨立打包App,在正式環境也需要考慮跟蹤問題的情況,多個不同App要溝通,需要了解Container網絡。如果打包底層程式,所有App都可以當成是本機下運行,會讓你更有信心追蹤問題,也是一個很好的出發點。到了有需要彈性改變不同App的分身數量,才轉向獨立打包的做法。

筆者最初也是走打包底層程式的方向,到了自己有信心試用Docker Swarm,才走向獨立打包的做法。筆者親身經驗,因為到了Docker Swarm,網段會變得暴增,這跟公司現有的內部網絡相衝的機會就會變多。在Swarm建立初時,筆者並沒有意識到這件事,所以當初排查問題,也花了一些時間才知道要向網段衝突上著手。

另一個出自Docker導師實務上的建議,就是正式環境中不要做用Docker compose,應該使用Docker Swarm。那怕Swarm只有一個節點,也應該用Swarm,導師的主要理據是Swarm有Rolling Update (滾動更新)的機制。同一個node也可以有多個分身,每個分身輪流更新,就不會出現大中斷的情況。筆者就自身經驗,Tomcat可以同時容納一個App的多個版本,Nginx也有Failover(故障轉移)等,如果你很熟悉這些功能,就不一定要需要靠Swarm去提供。也就是可以按自己步調去慢慢改變。

CI/CD系統的參與

CI/CD 全稱是continuous integration (CI) 和 continuous delivery (CD),字面上代表的持續地集成和發佈,實體上就是某台伺服器自動發佈APP。因為使用到Docker Cluster,不論前述什麼選擇,都會有多個node(節點)的出現。要發佈App,總不能一個個node逐個登入設定。所以我們需要一些CI/CD工具,把這個過程都自動化。

在筆者的認知上,CI/CD系統,由兩個部份組成,一個是取得Source Code(程式原始碼)的過程,一個是編譯或發佈Source Code的過程。Gitlab,Github,BitBucket等大型的代碼庫供應商,它們天生為了保存Source Code而提供服務的。不少CI/CD系統都可以跟它們整合,它們提供了存取Source Code的部份,剩下你只要能提供編譯或發佈的伺服器就好。

如果作為小型開發團隊,很少會有意願去自己花錢養一個編譯或發佈的伺服器。(極端地,如果我就是一人團隊,我用自己電腦編譯和發佈就好,伺服器能做的,我自己也能做。)好消息的是,Github提供了一個叫Github Action的CI/CD系統,即使你沒有自己的編譯專用的伺服器,Github Action也可以用Docker Image,提供一個臨時的編譯程序,用完就刪掉。詳細功能還請各位先查看官方教學,筆者也暫時只能零星使用經驗,無法給出有意思的架構。

如果對智慧財產權有高度重視,Source Code不能存放在公開的伺服器,那麼Gitlab Enterprise Edtion則是一個好選擇。運用Gitlab ee,你可以用自己的機器,造一個純本地的庫存伺服器。更強的是,它內建也有CI/CD系統,只要你有間置的伺服器,就可以作為編譯使用。筆者也是從這個方向著手,架設了自己的Gitlab Runner(Gitlab CI/CD系統)。在這裏,就分享一下與Docker Swarm整理的概念。

對於前述兩種選擇,GitLab Runner都可以做得到

  • 底層程式打包成Image並運行在Swarm mode上,每次發佈的是App Binary(執行檔或核心檔案)。
  • 把App直接打包成Image,並運行在Swarm mode上,每次發佈的是App Image。

CI/CD - 打包底層程式成為Image

在這個選擇下,其實就跟傳統自動化發佈的做法類似,只是發佈時,要多個node報行更新指令。如果你使用的底層程式原本就有支援多版本並行,這樣更新時就不用太操心rollback(回滾?)等操作。若系統不支援多版本並行,為求簡化,若遇到要rollback的情況,重跑過去舊的CI/CD操作也是一個做法。當然,我們也可以經過一些備份的操作,來保存被代替的程式,若在發佈過程中出問題,也可以手動重來,不過整件事就越來越複雜。

筆者發佈的基本思路是

  • 使用docker image,編譯和打包App Binary。
    • 使docker image做編譯的好處是,你可以比較放心地假設每次編譯時,你的編譯環境都是乾淨的。
  • 傳送上述的結果至生產環境可以取用的地方。
  • 跳入生產環境執行更新指令
# file .gitlab-ci.yml
# need Docker executor, the gitlab runner agent need docker permission
stages:
  - deploy

maven-deploy:
  image: maven:3-eclipse-temurin-17
  stage: deploy
  script:
    - "mvn clean compile package -Pprod -Dmaven.test.skip=true"
    # ${ID_RSA} is a rsa private key file location, it can skip password input during ssh or scp operation. 
    # you need to pre-config production serevr auth before exec this script
    - "chmod og= ${ID_RSA}"
    - "scp -i ${ID_RSA} -o StrictHostKeyChecking=no app.war YOUR_ACCOUNT@PRODUCTION_SERVER_1:./"
    - "ssh -i ${ID_RSA} -o StrictHostKeyChecking=no YOUR_ACCOUNT@PRODUCTION_SERVER_1 UPDATE_COMMAND"
    - "scp -i ${ID_RSA} -o StrictHostKeyChecking=no app.war YOUR_ACCOUNT@PRODUCTION_SERVER_2:./"
    - "ssh -i ${ID_RSA} -o StrictHostKeyChecking=no YOUR_ACCOUNT@PRODUCTION_SERVER_2 UPDATE_COMMAND"

這裏有些隱藏的管理成本,如果你生產環境中有多個node,最後那幾行指令就要多抄幾次。

CI/CD - 打包App成為Image

在這個選擇下,對比傳統自動化發佈的做法,現在要多做一步,就是要包裝自己的Image。不過好處是docker swarm有提供監測工具,在發佈過程每個分身會逐個更新,前一個分身更新成功後才會到下一個分身更新。而且 rollback等的操作,你可以靠docker做到。即是要手動rollback,也可以透過更正docker tags來達到,所以整體上來說沒有比傳統的麻煩。

筆者發佈的基本思路是

  • 編譯App Binary。
  • 打包成docker image。
  • 經docker上傳image。
  • 跳入生產環境執行更新指令。
# file .gitlab-ci.yml
# need Shell executor, the gitlab runner agent also need docker permission
stages:
  - deploy

docker-deploy:
  stage: deploy
  script:
    - "mvn clean compile package -Pprod -Dmaven.test.skip=true"
    - "docker image build -t YOUR_IMAGE_PRIVATE_REPO ./"
    - "docker image push YOUR_IMAGE_PRIVATE_REPO"
    # ${ID_RSA} is a rsa private key file location, it can skip password input during ssh or scp operation. 
    # you need to pre-config production serevr auth before exec this script
    - "chmod og= ${ID_RSA}"
    - "ssh -i ${ID_RSA} -o StrictHostKeyChecking=no YOUR_ACCOUNT@PRODUCTION_SERVER SWARM_UPDATE_COMMAND"

對比傳統自動化發佈的做法,最後的更新指令,只要執行一次就可以。當然,原本在Docker Swarm中要管理的事還是要好好管理。

CI/CD - 備註事項

雖然CI/CD可以幫忙簡化更新的過程,但實際操作會比上述的例子複雜一些。因為通常對非技術型的外界用戶來說,一個Web App會包含很多不同的功能。上述的例仔,在實際情況下可能需要拆解成很多微服務來進行。所以對管理上還是有相當的挑戰。

Swarm mode 上線

之前一直都討論Image 的打包形式,現在聊聊部署上線時的一些指令。

Docker Service

swarm mode 主要通過"docker service" 指令去產生一堆可以在不同節點上運行的container。為了更加形象地講,我把container稱為Image的分身。

docker service createdocker container run的感覺很像,兩者都可以指定image

# swarm mode
$ docker swarm init
$ docker service create --name nginx_s nginx

# container mode
$ docker container run -d --name nginx_c nginx

兩者的差別在於docker service 可以指定多少個分身,可以隨時加減數目,而且如果你有多過一台機器,分身就會在不同的機器上遊走。而docker container就是只對本機有操作,也不會散播到其他機器。

# swarm mode
$ docker service create --replicas=2 --name nginx_s nginx
$ docker service ls

ID             NAME      MODE         REPLICAS   IMAGE          PORTS
uro4rwy6nelh   nginx_s   replicated   2/2        nginx:latest

$ docker service update --replicas=5 nginx_s
$ docker service ls

ID             NAME      MODE         REPLICAS   IMAGE          PORTS
uro4rwy6nelh   nginx_s   replicated   5/5        nginx:latest

# container mode
$ docker container run -d --name nginx_c1 nginx
$ docker container run -d --name nginx_c2 nginx
$ docker container run -d --name nginx_c3 nginx
$ docker container run -d --name nginx_c4 nginx
$ docker container run -d --name nginx_c5 nginx
$ docker container ls
CONTAINER ID   IMAGE     COMMAND                  CREATED          STATUS         PORTS     NAMES
c45771f06612   nginx     "/docker-entrypoint.…"   7 seconds ago    Up 6 seconds   80/tcp    nginx_c5
a587a718da3a   nginx     "/docker-entrypoint.…"   9 seconds ago    Up 9 seconds   80/tcp    nginx_c4
079f206f8645   nginx     "/docker-entrypoint.…"   9 seconds ago    Up 9 seconds   80/tcp    nginx_c3
e10dc525fd22   nginx     "/docker-entrypoint.…"   10 seconds ago   Up 9 seconds   80/tcp    nginx_c2
dcaa2b4bb3de   nginx     "/docker-entrypoint.…"   10 seconds ago   Up 9 seconds   80/tcp    nginx_c1

在建立網段時也差不多,service需要的是overlay network,而container用一般network就可以。

# swarm mode
$ docker network create --driver overlay nginx_s_gateway
$ docker service update --network-add name=nginx_s_gateway,alias=gateway nginx_s
$ docker service ps nginx_s
ID             NAME            IMAGE          NODE         DESIRED STATE   CURRENT STATE             ERROR     PORTS
fxqtheyvr914   nginx_s.1       nginx:latest   dockertest   Running         Running 33 seconds ago
u0pvj1leoizw    \_ nginx_s.1   nginx:latest   dockertest   Shutdown        Shutdown 33 seconds ago
q7arumjlxduv   nginx_s.2       nginx:latest   dockertest   Running         Running 36 seconds ago
kurlwqfmopbg    \_ nginx_s.2   nginx:latest   dockertest   Shutdown        Shutdown 37 seconds ago
zd0zlkhxafv0   nginx_s.3       nginx:latest   dockertest   Running         Running 40 seconds ago
3kapr00fs6pt    \_ nginx_s.3   nginx:latest   dockertest   Shutdown        Shutdown 40 seconds ago
5o4afd3whygo   nginx_s.4       nginx:latest   dockertest   Running         Running 35 seconds ago
oxocropolbo8    \_ nginx_s.4   nginx:latest   dockertest   Shutdown        Shutdown 35 seconds ago
x5y94jf3ok51   nginx_s.5       nginx:latest   dockertest   Running         Running 38 seconds ago
cgld3au0w1i9    \_ nginx_s.5   nginx:latest   dockertest   Shutdown        Shutdown 39 seconds ago

# container mode
$ docker network create nginx_c_gateway
$ docker network connect --alias gateway nginx_c_gateway nginx_c1
$ docker network connect --alias gateway nginx_c_gateway nginx_c2
$ docker network connect --alias gateway nginx_c_gateway nginx_c3
$ docker network connect --alias gateway nginx_c_gateway nginx_c4
$ docker network connect --alias gateway nginx_c_gateway nginx_c5

不過比較大的差異是service會停了原有的分身,重開新的分身去加入網段。所以上面的docker service ps nginx_s執行結果,就有一半是停掉的。

類似地,docker service也不能單獨地停掉分身,頂多只能調整--replicas=NUMBER,來控制分身數量。而單機則可以經過docker container stop來暫停分身。

Docker Stack

同樣地,在單機管理container時,可以通過內建的docker compose指令配搭docker-compose.yaml檔案,管理多個有協作關係的container。Swarm mode也可以通過docker stack去管理yaml檔案中的多個有協作關係的service。

因為docker compose已支援v3及Compose Specification標準;但docker stack暫時只能支援到v3格式,所以說yaml檔案不能完全照搬。但指令上行為是差不多的。

docker stack deploy -c setting.yaml stack-name
docker stack rm stack-name

docker compose -f setting.yaml up -d
docker compose down

docker stack也跟docker service類似,沒有隨時叫停的功能,而docker compose 就可以暫時叫停分身docker compose -f setting.yaml stop

yaml例子待補完

Docker Service Rollback

詳見SwarmModeRollback.md

Swarm mode 上線 - 2

系統上線一段日後,總會遇到有需要更改的時候,我們一般要用的程式,可以經過Docker Image包裝,變得無痛。但Docker Swarm本身要有改動,就不是那麼的直白。

以筆者的經驗,若果Swarm mode的各位node都原地升級版本,其實都不難。但若果那些最基本的設定,例如node1的IP換了,那基本上等於要整個Swarm mode砍掉從來。

筆者最近就需要將整個Docker Swarm中多個node都一起轉IP。原本以為關掉改主機IP,Docker Swarm可以隨時重起。但問題是,在改IP過程中,其他Node2、Node3發現Node1的不見了,若然Node2、Node3經過cold start,它們的Docker daemon根本行不起來。若然要在這一刻硬來,就需要整個Docker daemon重設。這比當初以為的只刪除Docker Swarm要來得恐怖。

比較好一點的做法,應該是在改IP之前,先把Swarm mode中各個stack (service, network, volume)手動移除,然後把Swarm mode解除。

在Docker相關事項都變成可以獨立運作後,再做主機的更新

# delete stack and leave swarm in node 3, node 2, node 1
# don't shutdown docker in node 2,3 if node 1 is not available
docker stack ls
docker stack rm YOUR_STACK_NAME

# to be confirm if any dingling network, service is still there.
# not sure how stack handling re-deploy problem when something removed from yaml file
docker network ls
docker network rm YOUR_STACK_NETWORK

docker service ls
docker service rm YOUR_STACK_SERVICE

docker volume ls
docker volume rm YOUR_STACK_VOLUME

docker swarm leave --force

## begin host changes
## finish host changes, re-create cluster

# in node 1, init new swarm with new ip
docker swarm init --advertise-addr YOUR_NEW_NODE1_IP
# in node 1, get new manager token
docker swarm join-token manager
# in node 2 and node 3, join node 1 with new token
docker swarm join --token XXXXX YOUR_NEW_NODE1_IP:2377

Swarm mode 上線 - 3 | Rollback 回滾

Docker Swarm提供了一個很方便的rollback功能。針對swarm service的config都可以用。官方提供了一些rollback config的例子。

今天筆者今天就來個自己更常見的例子,rollback image

例設我們使用docker stack和yaml檔來操作docker service。

# nginx-rollback.yaml
services:
  nginx:
    image: nginx:1.25.2
    ports:
      - 8080:80
    deploy:
      replicas: 2
      update_config:
        delay: 1s
      restart_policy:
        condition: any

首次建立nginx service, 當前版本會是nginx:1.25.2

> sudo docker stack deploy -c nginx-rollback.yaml nginx-test
### system output
Creating network nginx-test_default
Creating service nginx-test_nginx

> sudo docker service ps nginx-test_nginx
### system output
ID             NAME                 IMAGE          NODE      DESIRED STATE   CURRENT STATE           ERROR     PORTS
kn6o6p45rxtq   nginx-test_nginx.1   nginx:1.25.2   node2     Running         Running 8 minutes ago
x1qa0tmhh8w6   nginx-test_nginx.2   nginx:1.25.2   node1     Running         Running 8 minutes ago

然後嘗試更新nginx:1.25.3

# nginx-rollback.yaml
services:
  nginx:
    image: nginx:1.25.3
    ports:
      - 8080:80
    deploy:
      replicas: 2
      update_config:
        delay: 1s
      restart_policy:
        condition: any
> sudo docker stack deploy -c nginx-rollback.yaml nginx-test
### system output
Updating service nginx-test_nginx (id: nlure4s6ipnzuy4qki66dz8o9)

> sudo docker service ps nginx-test_nginx
### system output
ID             NAME                     IMAGE          NODE      DESIRED STATE   CURRENT STATE             ERROR     PORTS
83m9iyc2xg5q   nginx-test_nginx.1       nginx:1.25.3   node2     Running         Running 3 seconds ago
kn6o6p45rxtq    \_ nginx-test_nginx.1   nginx:1.25.2   node2     Shutdown        Shutdown 7 seconds ago
8a9vidondmyo   nginx-test_nginx.2       nginx:1.25.3   node1     Ready           Preparing 2 seconds ago
x1qa0tmhh8w6    \_ nginx-test_nginx.2   nginx:1.25.2   node1     Shutdown        Running 2 seconds ago

假設我們發現nginx:1.25.3有些副作用不是我們想要的,可以經docker service rollback回到上次的版本,即是nginx:1.25.2

> sudo docker service rollback nginx-test_nginx
###
nginx-test_nginx
rollback: manually requested rollback
overall progress: rolling back update: 2 out of 2 tasks
1/2: running   [==================================================>]
2/2: running   [==================================================>]
verify: Service converged

> sudo docker service ps nginx-test_nginx
ID             NAME                     IMAGE          NODE      DESIRED STATE   CURRENT STATE                 ERROR     PORTS     
0mm3hzp0aaok   nginx-test_nginx.1       nginx:1.25.2   node2     Running         Running about a minute ago
83m9iyc2xg5q    \_ nginx-test_nginx.1   nginx:1.25.3   node2     Shutdown        Shutdown about a minute ago
kn6o6p45rxtq    \_ nginx-test_nginx.1   nginx:1.25.2   node2     Shutdown        Shutdown 10 minutes ago
cb1pdqw0bmyx   nginx-test_nginx.2       nginx:1.25.2   node1     Running         Running about a minute ago
8a9vidondmyo    \_ nginx-test_nginx.2   nginx:1.25.3   node1     Shutdown        Shutdown about a minute ago
x1qa0tmhh8w6    \_ nginx-test_nginx.2   nginx:1.25.2   node1     Shutdown        Shutdown 10 minutes ago

Swarm mode 上線 - 3 | Rollback 回滾

Docker Swarm提供了一個很方便的rollback功能。針對swarm service的config都可以用。官方提供了一些rollback config的例子。

今天筆者今天就來個自己更常見的例子,rollback image

例設我們使用docker stack和yaml檔來操作docker service。

# nginx-rollback.yaml
services:
  nginx:
    image: nginx:1.25.2
    ports:
      - 8080:80
    deploy:
      replicas: 2
      update_config:
        delay: 1s
      restart_policy:
        condition: any

首次建立nginx service, 當前版本會是nginx:1.25.2

> sudo docker stack deploy -c nginx-rollback.yaml nginx-test
### system output
Creating network nginx-test_default
Creating service nginx-test_nginx

> sudo docker service ps nginx-test_nginx
### system output
ID             NAME                 IMAGE          NODE      DESIRED STATE   CURRENT STATE           ERROR     PORTS
kn6o6p45rxtq   nginx-test_nginx.1   nginx:1.25.2   node2     Running         Running 8 minutes ago
x1qa0tmhh8w6   nginx-test_nginx.2   nginx:1.25.2   node1     Running         Running 8 minutes ago

然後嘗試更新nginx:1.25.3

# nginx-rollback.yaml
services:
  nginx:
    image: nginx:1.25.3
    ports:
      - 8080:80
    deploy:
      replicas: 2
      update_config:
        delay: 1s
      restart_policy:
        condition: any
> sudo docker stack deploy -c nginx-rollback.yaml nginx-test
### system output
Updating service nginx-test_nginx (id: nlure4s6ipnzuy4qki66dz8o9)

> sudo docker service ps nginx-test_nginx
### system output
ID             NAME                     IMAGE          NODE      DESIRED STATE   CURRENT STATE             ERROR     PORTS
83m9iyc2xg5q   nginx-test_nginx.1       nginx:1.25.3   node2     Running         Running 3 seconds ago
kn6o6p45rxtq    \_ nginx-test_nginx.1   nginx:1.25.2   node2     Shutdown        Shutdown 7 seconds ago
8a9vidondmyo   nginx-test_nginx.2       nginx:1.25.3   node1     Ready           Preparing 2 seconds ago
x1qa0tmhh8w6    \_ nginx-test_nginx.2   nginx:1.25.2   node1     Shutdown        Running 2 seconds ago

假設我們發現nginx:1.25.3有些副作用不是我們想要的,可以經docker service rollback回到上次的版本,即是nginx:1.25.2

> sudo docker service rollback nginx-test_nginx
###
nginx-test_nginx
rollback: manually requested rollback
overall progress: rolling back update: 2 out of 2 tasks
1/2: running   [==================================================>]
2/2: running   [==================================================>]
verify: Service converged

> sudo docker service ps nginx-test_nginx
ID             NAME                     IMAGE          NODE      DESIRED STATE   CURRENT STATE                 ERROR     PORTS     
0mm3hzp0aaok   nginx-test_nginx.1       nginx:1.25.2   node2     Running         Running about a minute ago
83m9iyc2xg5q    \_ nginx-test_nginx.1   nginx:1.25.3   node2     Shutdown        Shutdown about a minute ago
kn6o6p45rxtq    \_ nginx-test_nginx.1   nginx:1.25.2   node2     Shutdown        Shutdown 10 minutes ago
cb1pdqw0bmyx   nginx-test_nginx.2       nginx:1.25.2   node1     Running         Running about a minute ago
8a9vidondmyo    \_ nginx-test_nginx.2   nginx:1.25.3   node1     Shutdown        Shutdown about a minute ago
x1qa0tmhh8w6    \_ nginx-test_nginx.2   nginx:1.25.2   node1     Shutdown        Shutdown 10 minutes ago

Schedule Job with Docker

在Linux底下,crontab是一個最簡單建立Schedule Job的方法。大家用crontab -e 就可以進入設定。

# crontab -e
*/1 * * * * /opt/run.sh

其中每個星號,順序代表的是分、時、日、月、星期。上面的例子就是不論何月何日何時,只要每一分鐘就執行一次/opt/run.sh

Singleton Job

問題是,實際情況下,你想執行程式的時間都不一定會少於1分鐘。所以你總是有機會上一個job未跑完,下一個job就開始了。為了保障自已,需要一些參考機制,去決定是否讓job開始跑。

有些情況,可能你會想用job server去做監管,但若只為單線執行的工作,起一個job server還是會增加管理上的複雜性。

最簡單的做法,就是根據不同的程式語言,使用file lock(鎖上)的機制,先上鎖,再做事。但要注意考慮有沒有出現異常情況,令你自己反鎖自己。即是你的process死了,但不懂自己解鎖,這樣以後你也不能再執行了。

在Linux Bash Shell下,就有一個很簡單的做法,就是使用flock指令。用它的最大好處,就是從OS層面下,去鎖上。只要process結束了,不論正常還是不正常結束,都會自動解鎖。

以下例子就是在執行/opt/run.sh前,先要取得/tmp/run.lockfile的鎖。如果沒法取鎖,就自動放棄執行後面的指令。

flock -n /tmp/run.lockfile /opt/run.sh
# crontab -e
*/1 * * * * flock -n /tmp/run.lockfile /opt/run.sh

Timeout

引入singleton的概念後,其實會引發另一個問題。因為異常的情況,還有機會是不生不死,process hang。所以我們還需要設定一個最大的執行時間,讓你的process在異常的情況下,被強行清走。

例如,ping指令在linux預設是永遠不會自動停止的,可以模擬process hang的情況。如果我們想定時從外部收走ping process,就可以使用timeout指令。以下指令就是2分鐘後殺指ping process。

# in file /opt/run.sh
timeout 2m ping localhost
# to check process id, you could use
# > ps aux | grep ping
# you will see two different id for ping and timeout

配合errorcode使用,你可能還會在想在timeout時送出一個email通知自已。

# in file /opt/run.sh
timeout 2m ping localhost
exitCode=$?
if [[ $exitCode -eq 124 ]]; then
    echo "timeout"
    # send email alert with timeout
elif [[ $exitCode -gt 0 ]]; then
    echo "exit with error"
    # send email alert with error exit
else
    echo "exit normal"
fi

配合docker使用,你可能需要考慮signal怎樣傳遞。

在筆者測試的環境中,似乎SIGTERM會被擋,也有可能是SIGTERM太強,它只把前景的docker container run收走,但其內的ping process還在docker daemon中行走。所以最後改用SIGINT,讓docker container run可以好好地把SIGINT傳入其內。

# It seems that docker captured the SIGTERM. Send SIGINT instead

# in file /opt/run.sh
timeout --signal=SIGINT 10s docker container run --rm pingtest -c 20
exitCode=$?
if [[ $exitCode -eq 124 ]]; then
    echo "timeout"
    # send email alert with timeout
elif [[ $exitCode -gt 0 ]]; then
    echo "exit with error"
    # send email alert with error exit
else
    echo "exit normal"
fi

還有一個常見的問題,就是中間夾雜了sudo,這樣前面的signal也是傳不過去,都被sudo擋了。所以只能把整段script都變成sudo或直接使用root執行;又或者把你的user加入docker group,執行docker command時就不用sudo。

# not work if wrapped sudo inside
timeout -signal=SIGINT 10s sudo docker container run --rm pingtest -c 20
timeout 10s sudo docker container run --rm pingtest -c 20

# workaround, add sudo outside of the whole script;
sudo /opt/run.sh

Full demo, github repo cronjobWithDocker

Docker Variable control

我們在Docker Image的打包時,最簡單當然就是每個步驟都使用最新版本。例如Docker Base Image,大家可能選用latest tag,安裝linux package (Linux包),也可能就apt install / yum 安裝最新的穩定版本。但如果我們想要更好地做測試,就要使用指定版本,方便追蹤問題。而Docker在打包和運行時,都有不同的方式讓大家定義或覆寫指定參數。

Docker build arg

我們先從打包Image開始。

例如我們需要使用一個Base image為 ubuntu,版本預設為22.04,但有需要時可以經build指令覆寫,可以這樣寫

ARG ubuntu_version=22.04
FROM ubuntu:${ubuntu_version}
# default ubuntu_version=22.04
docker image build -t test2204 ./
# or overwrite by --build-arg
docker image build -t test2404 --build-arg="ubuntu_version=24.04"

雖然Dockerfile的RUN指令都是使用linux shell,但在Dockerfile中想表達條件控制(if else statment)就不太易看。在外部加入script做控制,是另一個可行的後備選擇,它更可以連image名字也進行參數化。

# in bash script, you also can
if [ $beta == true ]
then
    ubuntu_version=24.04
else
    ubuntu_version=22.04
fi

docker image build -t test:${ubuntu_version} --build-arg ubuntu_version=${ubuntu_version}

Docker Container Run and Docker Compose

一般來講,Linux Container 在執行時,就等於進入Linux Shell。也就是,我們可以使用Shell中的環境變數。

我們在打包Image前,已經可以在Dockerfile中定義自己的ENV數參(也就是環境變數)。與前面的Build Arg有所不同的是,ENV是定義在Dockerfile中,在Container運行時以環境變數的形式存在,它也可以在運行中被改變。而Arg,則只在打包Image時存在,運行期間就不存在了。(當然,你在打包時,用Arg傳入Env,以運到這個目的。)

另一個更特別的性質是,那怕ENV沒有定義在Dockerfile中,我們運行時也可以加入更多的環境變數,大家就當成是一般Linux操作,隨時在自己的shell中加入變數。

# -e, --env for inline variable
# --env-file for file
docker container run -e MYVAR1 --env MYVAR2=foo --env-file ./env.list ubuntu bash

同樣地Docker compose,也支援環境變數。筆者建議environment可以使用Array格式,日後可以更方便地直接改為env_file。

# docker-compose.yaml
services:
  ubuntu:
    image: ubuntu:22.04
    environment:
      - RACK_ENV=development
      - SHOW=true
      - USER_INPUT

上述的寫法沒有任何問題,不過如果你的docker-compose.yaml是放在git等版本控制中,你更新環境變數就有可能會影響到其他人,這時你就會想轉成env_file。

docker-compose.yaml預設就會讀當前資料夾的.env,就算不存在,也可以正常運行。(當然,大家的Image/Container應該要有預設值)

# docker-compose.yaml
services:
  ubuntu:
    image: ubuntu:22.04
    # if env_file is not defined, it will load .env.
    # or you can load the specific file.
    # env_file:
    #   - ./a.env 

env_file內,每一行就是一個變數

# .env or a.env
RACK_ENV=development
SHOW=true
USER_INPUT

使用預設的.env還有一個好處,就是我們可以把docker-compose.yaml也變成受環境變數控制。

# docker-compose.yaml with variable control, only works in default .env
services:
  ubuntu:
    image: ubuntu:${ubuntu_version}
# .env
ubuntu_version=22.04

Oracle Database in Docker

雖然筆者之前有提過,Docker並不是萬能,Docker在管理有狀態應用(Stateful Application)的情況下,只能走單機路線。但因為Docker實在很方便,所以連Oracle Database這類強狀態應用也有出Docker版本。當然,它在預設的情況下,只能在單機下操作。

不過即使在單機操作下,還是有一些跟其他Docker Image有差異的地方,需要特別拿出來聊聊。

假設根據官方的教學,跑起了一個oracle19c的Docker Container。再查看當中的Process,你會發現有一個內部PID為1的runOracle.sh

sudo docker container exec -it oracle19c top
## top output
##    PID USER      PR  NI    VIRT    RES    SHR S  %CPU %MEM     TIME+ COMMAND
##      1 oracle    20   0   11712   2756   2480 S   0.0  0.0   0:00.04 runOracle.sh
sudo docker container top oracle19c
## UID                 PID                 PPID                C                   STIME               TTY                 TIME                CMD
## 54321               1069154             1069135             0                   Sep14               ?                   00:00:00            /bin/bash /opt/oracle/runOracle.sh

在Docker中這個PID為1的Process是很重要的,它是判斷整個Container有沒有運行的依據。它就是當初在Docker Image中Entrypoint或CMD指定的那個指令生起的Process。Docker daemon要進行停止指令,要停止container時,也是對著PID為1的那個process來處理。

一般的情況下,如果PID為1的那個process可以無腦地停了、重開,那一切都好辦。但在Oracle Database的情況下,就不適合。因為Database始乎都是有交易概念的(Transaction),它的停止並不是殺了process就了事,它還要考慮HDD操作中,有那些可以被考慮為完成,有那些下次要還原(undo)、重做(redo)。如果殺了process就等於Oracle 的Shutdown Abort,有機會下次開機會,就會有交易異常而且無法決定該如何操作。

大家需要先進入Docker container,經sqlplus進行必要的關閉Database指令。但此時,PID為1的那個process,其實還在進行中,在Docker 層面,它就像是Docker Container還在正常運行中,只是Database離線了。又因為sqlplus關閉Database並不是馬上有結果的,所以在整體關閉時可能需要串連command。就像

# in container
echo "shutdown immediate;" | sqlplus -s / as sysdba && kill 1

# in docker host, by script
sudo docker container exec -it oracle19c /bin/sh -c 'echo "shutdown immediate;" | sqlplus -s / as sysdba' \
	&& sudo docker container stop oracle19c

Notes about customizing cloud image for Multipass

前言

原本筆者只是想做docker cluster,但因為在實機中建VM極其麻煩,所以就研究了好一陣子如何快速起VM。

以下是筆者有初步研究,但未有完全實行的方向。

  • Hyper-V (2023-08-17 更新)
    • 有預設的Ubuntu template,但只有ubuntu desktop版,沒有server版。Desktop gui顯得浪費資源
    • clone VM很費時,需要通過Export,Import達到Clone效果。若好好地處理各VM的HDD存放位置,Export,Import也不錯。
    • 沒有自帶的cloud-init,需要自己想辦法做一些Clone後置的處理。
    • 若要走cloud image + cloud-init,可能需要Windows ADK,並配合qemu做轉換。
  • Windows Subsystem Linux
    • 起VM很方便,但同一個Linux version只有一個instance
    • 可能需要需要通過Export,Import Instance來達到Clone效果
    • 可能沒有fix ip。可能對於起docker swarm不利。
  • Virtual box (2024/01/31 更新)
    • 沒有Ubuntu template
    • 若要clone的話就變得跟Hyper-V差不多。
    • Virtual Box, Hyper-V應該要跟Vagant結合,這樣可以取代multipass + packer的組合。
    • Vagent原本就提供了很多Image Template,官方稱為Box,但主要以供給Virtual Box使用為主。若你需要於Hyper-V執行特定的Linux,可能需要找一些第三方提供的Image Template。

經過一輪資料搜集,發現了一個Ubuntu multipass engine,聲稱可以跨平台快速起VM。裏面有一些很吸引的功能,可以自己建立images、使用固定IP。

那怕即使是沒有snapshot,在自定義images的配合下預裝docker,要隨時加減cluster node都是一件容易的事。

Ubuntu multipass

參考軟硬件需求

醜話說在前頭。經過一輪測試,multipass最大的問題,就是custom image、fix IP都只能在ubuntu中才能使用。以下是筆者成功實現的軟硬件架構。

  • 你可以使用實體主機,BIOS提供Virtualization,在實機上安裝Ubuntu,最後行起multipass。
    • 參考配置: 實機:CPU 10 core, 32G RAM, 1T HDD
  • 你可以使用實機主機,BIOS提供Virtualization,在實機上安裝Windows 10 和Hyper-V,並在Hyper-V上安裝ubuntu,並對該Ubuntu提供ExposeVirtualizationExtensions。
    • 參考配置: 實機:CPU 10 core, 32G RAM, 1T HDD;VM Ubuntu: CPU 4 core,10G RAM,128G HDD

重點

詳細的Proof of Concept,筆者記錄了在

在這裏就補充一些重點。

  • packer是使用cloud-init和qemu的技術,行起template中指定的cloud image (在筆者的例子中就是ubuntu-22.04-server-cloudimg-amd64.img)
  • 大家可以定義image行起後進行一些操作,而那些操作都是經過qemu vnc、ssh進去操作的。
  • 操作完後就會直接儲存當時的image。所以在操作結束之前,盡可能地刪cache或刪去不要的user / group settings。
  • 最後生成的image,還是一個cloud image。若要再運行它,必需要使用支援cloud-init的VM來讀取。
  • cloud-init是用來指定初次運行時要設定的事,例如:hdd size, user account password, ssh key import等。
  • 使用工具cloud-localds可以生成一個seed.img,這樣qemu也可以cloud-init。
  • Hyper-V應該也可以經過類似方式,進行cloud-init,但筆者未有去實測。如有更簡便的方法請告知。
  • multipass預設就已經有cloud-init,在ubuntu就可以直接執行。
  • multipass也可以設定不同的cloud-init參數。

成品

最後筆者就選擇了用packer用來預裝docker,經mulitpass無腦起VM,再使用 shell script 對多個node設定docker,達到即時起docker node的功能。這樣就減省了VM的安裝時間,也省去了docker的安裝問題。

說到底,如果只想測試docker cluster,其實windows, macOS中的multipass也可以實現相同的功能。因為安裝docker那些都可以經過shell script自動化,只是每次重複操作,都變得相當慢。另外,因為multipass在windows, macOS不支援fix ip,對於指定docker cluster interface又會再多一重功夫。

在Ubuntu Server 22.04上安裝Multipass並配置固定IP的注意事項

安裝Multipass很容易的,但配置固定IP就不是了。

另一個問題是官方文件都認為設定固定IP不是Multipass的範圍,它不想講太多。 但根據我的經驗,它的官方例子在Ubuntu Server 22.04並不能用。 (它可能可以在Ubuntu桌面上運行吧,但我不確定。)

以下是我在不斷踩坑後找到的解決方案。

通過snap安裝Multipass

簡單,無腦

sudo snap install multipass

在主機上配置Virtual Bridge

很重要,很重要,很重要,在所有操作之前停止multipass。然後使用"network-manager"包安裝命令工具"nmcli"。

sudo snap stop multipass.multipassd
sudo apt-get update && sudo apt-get install network-manager

修改NetworkManager配置,以便它可以管理所有bridge interface。預設情況下,有很多類型的接口它都不管的。您需要在'unmanaged-devices'行的末尾添加 ",except:type:bridge"。

sudo vim /usr/lib/NetworkManager/conf.d/10-globally-managed-devices.conf

NetworkManager Config Example

[keyfile]
unmanaged-devices=*,except:type:wifi,except:type:gsm,except:type:cdma,except:type:bridge

然後重新運行NetworkManager.service,並添加具有固定IP範圍的Bridge network interface。

sudo systemctl reload NetworkManager.service 
sudo nmcli connection add type bridge con-name localbr ifname localbr \
    ipv4.method manual ipv4.addresses 10.13.31.1/24

現在,如果您使用ip a檢查主機所有網卡,新network interface應該已經出現(但有機會是處於關閉狀態)。

將Multipass切換到lxd

Multipass網絡功能目前只在lxd後端上提供。

sudo snap start multipass.multipassd
# 在轉driver前,先刪掉所有VM。轉了之後就無法管控之前的VM
multipass delete --all && multipass purge
multipass set local.driver=lxd

以下指令與官方教學相同。

在VM中創建額外的網卡

在創建新的VM實例時創建額外的網卡。

# 建立新VM時指定它的網卡和mac地址
multipass launch --name test1 --network name=localbr,mode=manual,mac="52:54:00:4b:ab:cd"

# 修改設定檔,對應mac地址使用固定IP
multipass exec -n test1 -- sudo bash -c 'cat << EOF > /etc/netplan/10-custom.yaml
network:
    version: 2
    ethernets:
        extra0:
            dhcp4: no
            match:
                macaddress: "52:54:00:4b:ab:cd"
            addresses: [10.13.31.13/24]
EOF'

# 重啟VM實例網絡。
multipass exec -n test1 -- sudo netplan apply

# 然後,您應該在VM上看到固定IP列表。
multipass info test1

使用 Multipass 建立Docker Cluster

以下流程,假設各位已經

  • 在Ubuntu Server中開設了virtual bridge 供Multipass設定Static IP,並且network interface定為 localbr
  • 使用Packer template制成docker.img , 並存放於當前資料夾內

使用docker.img 起三個node,並使用network interface localbr,各有一個指定的mac address

multipass launch file://$PWD/docker.img --name node21 --network name=localbr,mode=manual,mac="52:54:00:4b:ab:21"
multipass launch file://$PWD/docker.img --name node22 --network name=localbr,mode=manual,mac="52:54:00:4b:ab:22"
multipass launch file://$PWD/docker.img --name node23 --network name=localbr,mode=manual,mac="52:54:00:4b:ab:23"

對運行中的三個node,為它們設定static ip

multipass exec -n node21 -- sudo bash -c 'cat << EOF > /etc/netplan/10-custom.yaml
network:
    version: 2
    ethernets:
        extra0:
            dhcp4: no
            match:
                macaddress: "52:54:00:4b:ab:21"
            addresses: [10.13.31.21/24]
EOF'

multipass exec -n node22 -- sudo bash -c 'cat << EOF > /etc/netplan/10-custom.yaml
network:
    version: 2
    ethernets:
        extra0:
            dhcp4: no
            match:
                macaddress: "52:54:00:4b:ab:22"
            addresses: [10.13.31.22/24]
EOF'

multipass exec -n node23 -- sudo bash -c 'cat << EOF > /etc/netplan/10-custom.yaml
network:
    version: 2
    ethernets:
        extra0:
            dhcp4: no
            match:
                macaddress: "52:54:00:4b:ab:23"
            addresses: [10.13.31.23/24]
EOF'

multipass exec -n node21 -- sudo netplan apply
multipass exec -n node22 -- sudo netplan apply
multipass exec -n node23 -- sudo netplan apply

使用node21作為Leader (Manager),與其他兩個node一起組成Cluster

multipass exec -n node21 -- sudo docker swarm init --advertise-addr 10.13.31.21
multipass exec -n node21 -- sudo docker swarm join-token manager

managerToken=$(multipass exec -n node21 -- sudo docker swarm join-token manager -q)
multipass exec -n node22 -- sudo docker swarm join --token $managerToken 10.13.31.21:2377
multipass exec -n node23 -- sudo docker swarm join --token $managerToken 10.13.31.21:2377

Cluster就建立完成。

若想刪掉重來

multipass delete node21
multipass delete node22
multipass delete node23
multipass purge

備註

在直正使用時,大部份時間還需要做port forwarding。multipass沒有自己的port forward,可以用ssh tunnel來模擬。

例如把Ubuntu Server的8080指向node21的8080,可以這樣

sudo ssh -i /var/snap/multipass/common/data/multipassd/ssh-keys/id_rsa -L 0.0.0.0:8080:10.13.31.21:8080 ubuntu@10.13.31.21

完整的script可以參考initDockerCluster.sh

沒有Bare Metal Ubuntu或者沒有static ip也可以參考initDockerClusterWithoutStaticIp.sh。只是因為network brandwidth問題,我就不會在每次更新時都測試。

Vmware下建立Docker Cluster

之前都使用Multipass作為Proof of Concept,自己做測試用。直正上Production,Network環境就多少有點差異。

假設大家為Application Admin,但無條件處理Vmware層面上的事項,只可以從VM內部install / setup application。

安裝Docker

script 都來自Docker 官方網,筆者微調了一些auto accept選項。

sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg

sudo install -m 0755 -d /etc/apt/keyrings
curl -kfsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg

echo \
  "deb [arch="$(dpkg --print-architecture)" signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
  "$(. /etc/os-release && echo "$VERSION_CODENAME")" stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

假設三台VM已經安裝docker,ip分別為 10.13.31.21, 10.13.31.22, 10.13.31.23。

在其中一台VM上,例如:ip 10.13.31.21上,

sudo docker swarm init --advertise-addr 10.13.31.21 --data-path-port=7777

與前述Multipass不同的是,這裏的data-path-port要自定義,因為預設的port 4789在Vmware的有特殊同途。

之後部份就跟傳統做法一樣,先取得manager join token, 然後在其他VM上使用該token加入cluster

# at 10.13.31.21
sudo docker swarm join-token manager

# at 10.13.31.22, 10.13.31.23, 
sudo docker swarm join --token XXXXXXXXXXXXXXXXXXXXXXXXXX 10.13.31.21:2377

這樣,在swarm上的application,就會自動在10.13.31.21, 10.13.31.22, 10.13.31.23,上遊走。

即使你的app container目前是跑在10.13.31.23:8080上,但因為swarm mode routing mesh,你經過10.13.31.21:8080都可以連到該app。

如果你只是做stateless app load balance分流,這樣就足夠了,不用考慮ip fail over。但如果你要做到ip fail over,還要額外設定keepalive virtual ip,這個virtual ip會自動依付到某台活著的VM上,這樣外界才不會連到一個死ip上。又或者額外建一台load balancer,可以偵測到swarm node上那台機還活著,從而達到fail over效果。但這台load balancer也有一些穩定性要求,若然大家只是用一個普通的nginx做load balance,還是會有單點故障問題(single point of failure)。

自己架設Docker的共享儲存空間

Docker很好用,在單機環境下真的很好用。Docker原本的設計,是為了快速迭代而設計成Image的。在一般設定下,每次新建或重建container,都會根據Image重設一下各方面的環境,包括儲存空間。重設CPU,Memory,大家都很易理解,但重設儲存空間,真的不是每一個使用情況都可以這樣。

又或者說,未必所有使用情況都會有一個第三方的儲存空間可以用。所以良心的Docker在單機環境下也有提供bind mount或是docker named volume,作為可以長期保存,不受container生死的影響,以達到長期存在Data的存在。

單機-儲存空間

單機情況下很簡單,就用一個docker compose做例子

services:
  nginx:
    image: nginx:1.23
    restart: always
    ports:
      - 80:80
      - 443:443
    volumes:
      - ./html/:/usr/share/nginx/html/
      - nginxlogs:/var/log/nginx/

volumes:
  nginxlogs:

其中html就是一個bind mount,而nginxlogs就是一個docker named volume,兩者都可以長期保存data,除非各位自己手動刪除,否則不會因為container的興亡而不見了。

但有兩個很重要的分別

  • bind mount,直接跟host os連接,實際上是每次folder有更新,docker都要同步host和container之間的資料。
    • bind mount在linux下很暢順,因為大部份docker image/container原本就是linux engine,所以folder mount真的可以互通。
    • bind mount在windows / mac下,就會不斷抄資料。面對大量檔案,例如node_module,就會有速度上的問題
  • docker named volume,就是docker 分離一些獨立空間,然後再綁到container上
    • 相對bind mount,即使在windows / mac下,都沒有那個速度上的問題。筆者猜測,即使是獨立空間,其實本身都已經限定在linux enginx下,所以沒有需要抄資料。
    • 但在windows / mac下,因應docker 底層建立Linux VM的技術不同,你可能沒法在windows / mac預設環境下直接讀取docker named volume。
    • 若要讀取docker named volume,最好的做法,還是連上docker container,然後用docker cp 來抄回資料。一但抄資料,其實都會有速度上問題,不過docker cp是手動決定何時做的,不做docker cp,其實container也是可以用。

Cluster (Docker Swarm) - 儲存空間

雖然良心的bind mount和named volume解決了單機上的儲存問題,但到了cluster環境,就沒有可以跨機同步儲存空間的做法,要做就自己建立。

筆者也稍為研究了一下同步的問題,不過對技術真的很有要求。所以退而求其次,筆者還是選擇簡單的第三方儲存空間。就是做一個可以分享存取的NAS。

建立nfs

linux下要安裝nfs其實很簡單,不過要注意資料夾和防火牆權限。

以下安裝教學以ubunut 22.04為例,記得把下面的YOUR_DOCKER_NODE_ADDRESS_RANGE轉為你的真實IP段落

apt update && apt install nfs-kernel-server ufw -y

mkdir -p /mnt/nfs_share
chown -R nobody:nogroup /mnt/nfs_share/
chmod 777 /mnt/nfs_share/

echo "/mnt/nfs_share YOUR_DOCKER_NODE_ADDRESS_RANGE/24(rw,sync,no_subtree_check)" >> /etc/exports

exportfs -a

systemctl restart nfs-kernel-server

ufw allow from YOUR_DOCKER_NODE_ADDRESS_RANGE/24 to any port nfs
ufw allow ssh
ufw enable

修改docker compose

最後,你在原來的docker-compose的docker volume上加driver_opts就大功告成。

記得把下面的YOUR_NFS_IP轉為你的真實IP

volumes:
  nginxlogs:
    driver_opts:
      type: "nfs"
      o: "nfsvers=4,addr=YOUR_NFS_IP,nolock,soft,rw"
      device: ":/mnt/nfs_share/nginxlogs"

docker syslog

平常大家在做單機app時,寫log有很多選擇,最簡單就是寫在檔案中。但在docker container裏面,寫檔案時要注意怎樣保留log檔,避免因為重建container時不見了。

docker 大部份官方預設image,都把log導向至stdout和stderr。這是方便docker做管理,也方便大家使用統一的docker logs指令來查看,即使到了Swarm mode底下,docker service logs也是同樣原理,使用差異不大,頂多就是不保證log的實時性。

如果網路延遲不計較的話,最大問題也是logs怎樣保存的做法。預設就是container刪走的時候,logs也會一借走。單機模式下,沿用最普遍的方法寫log的做法不是不可行,只是考慮到在極端情況下,同一個node(節點)中,有可能同時運作同一個service(服務)的多個分身(replica),這裏它們寫檔案時就有機會互相搶佔。

筆者認為,比較合理的是外部提供的服務,例如syslog,把寫檔的操作交給節點的Host OS處理。然後就保證好每筆log都會是一條完整的記錄。

以下就以linux Host裏面的syslog,為大家簡介一下設定的步驟。

設定docker 導向 syslog

把該主機的docker daemon (/etc/docker/daemon.json),設定使用syslog driver,並以特定的方式編寫syslog tag。

//file: /etc/docker/daemon.json
{
  "log-driver": "syslog",
  "log-opts": {
    "tag": "dockercontainer/{{.ImageName}}/{{.Name}}/{{.ID}}"
  }
}

無腦設定已完成,重啟docker就可以了。

sudo systemctl restart docker

但為了日後管理方便,能把docker log放進獨立的一個檔案中,會更易找問題。所以我們可以進一步設定syslog。我們以Ubuntu 22.04為例,可以在/etc/rsyslog.d/下增加一個設定檔(/etc/rsyslog.d/*.conf),指定看到syslog tag以dockercontainer為首的記錄,都要獨立抽出來。

# file: /etc/rsyslog.d/51-docker.conf
:syslogtag,startswith,"dockercontainer" -/var/log/dockercontainer.log

為免有檔案權限問題,手動指定檔案的所有權後,才正式重啟syslog。然後所有相關記錄都會寫在/var/log/dockercontainer.log

sudo touch /var/log/dockercontainer.log
sudo chown syslog:adm /var/log/dockercontainer.log
sudo systemctl restart rsyslog

滾滾滾滾滾動的log檔

檔案一天一天地長大,如果可以,還是自動清掉太舊的記錄為妙。Linux Syslog,通常也會配著logrotate使用。

筆者亦以Ubuntu 22.04為例子,做了個最簡單的自動滾Log功能。目標就是當log檔案大於1M後,就要重開log檔。舊的log檔最多保留7份,多了就刪掉最舊的。

# file: /etc/logrotate.d/rsyslog-dockercontainer
/var/log/dockercontainer.log
{
        rotate 7
        size 1M
        missingok
        notifempty
        compress
        delaycompress
        sharedscripts
        postrotate
                /usr/lib/rsyslog/rsyslog-rotate
        endscript
}

加了設定後,什麼都不用重啟,因為它是Ubuntu 的排程動作,到執行時就會以最新的設定檔執行,詳見/etc/cron.daily/logrotate.

有需要手動測試的話,需要手動呼叫/usr/sbin/logrotate。加入-d參數後,會被視為debug mode,這是官方的說法,但因為debug mode沒有執行效果,更加像是linux中常見的dry run mode。

# dry run only, no effect
sudo /usr/sbin/logrotate -d /etc/logrotate.conf
# do the real task and show verbose output
sudo /usr/sbin/logrotate -v /etc/logrotate.conf

Git Co-Work Flow

雖然git面世已很久,但相當一部份澳門朋友都是solo man,很少合作寫code,對git branch始終都有些恐懼。所以這次來解召一個基本原則,至少你不會爛了code救不回來。

若然大家未熟悉git,初次利用git合作寫program,請盡量減少使用共同分支(branch),可以極大地減少問題。

第一個大原則 - 建立一條自己分支

在一個repo中,為自己建立一條分支(branch),可以減少Remote repo中有人比你先commit,而令你push失敗的情況。

git clone xxx
git checkout -b YOUR_BRANCH

# 提交你的改動
git add FILE_OR_FOLDER
git commit
# 上傳
git push origin YOUR_BRANCH

除非你的隊友故意你用的分支名先commit,又或者你自己有幾台電腦,幾台一起做改動。不然push 應該不會有問題。

第二個大原則 - 用fetch取代pull

很多人在取用Remote Repo的更新時,都會使用pull。但pull其實是fetch及merge的混合,而且merge還要考慮source branch是那條分支的問題,若然大家都有一條獨立branch,那麼這個無腦pull並不存於每人只有一台電腦下的多人協作中。

fetch的過程中,還可以加入參數--prune,順便依照Remote Repo的指示,同步刪掉本機中一些不再存在的origin/branch。

# 不要用pull,用fetch看看server的最新改動
git fetch origin --prune
# 跟上述command類似,只是在fetch的途中,把main和 origin/main自動同步
git fetch origin main:main --prune

第三個大原則 - Merge前先Commit

經過前述fetch後,其實他人的改動並未加入自己的分支中,必需經過merge才會出現。但並不是沒有conflict就無腦merge。 假若自己有改動,未commit,應該老虎蟹都先commit。這是為了在merge後,還有機會可以無腦reset,回到之前那個commit。這就像是做任何更新前,先做backup。

# 看看有沒有未commit的改動,若有,先commit。
git status
git add FILE_OR_FOLDER
git commit

# 把別人的改動加到自己的分支中, 若有衝突請,git會提示你去修正
git merge origin/OTHER_BRANCH

# 檢查merge後的code有沒有問題,沒有就可以上傳
git push origin YOUR_BRANCH

第四個大原則 - 由某個特定的人來管理master或main branch

main branch(以前叫master branch),是他人下載時的預設分支,也是Github、Gitlab的預設顯示分支。所以該分支存放著的source code,應該在代表信心度比較高。

在協作的環境中,每人都有自己分支,那就代表要有一位人員做管理,他負責checkout main, 然後合併其他已驗證的分支。

# 用fetch看看server的最新改動
git fetch origin --prune

# 看看自己分支有沒有未commit的改動,若有,先commit。你不應在main中改動,否則後述部份會有機會亂了。
git status
git add FILE_OR_FOLDER
git commit

# Optional,切換到他人的分支,執行程式以驗證更新
git checkout origin/SOMEONE_BRANCH
# 檢視差異,若發現有任何問題,例如他的分支落後於main,或很大機會出現衝突,可以叫對方先更新到最新狀況,再重來一次
git diff main origin/SOMEONE_BRANCH

# 有信心,就可以合併到main中
git checkout main && git merge origin/SOMEONE_BRANCH
git push origin main

# 回到你原來的分支中
git checkout YOUR_BRANCH

在某些比較嚴僅的環境中(例如Github、Gitlab),main分支可能會被系統機制鎖定,必需通過系統內建的Pull Request(或Merge Request),才能通過審核,合併到main。另外,也有一些關於開發上的Git workflow,主要針對功能管理、版本發佈、錯誤修正等控制。有機會再為大家介紹。

希望以上的流程,可以有效且容易地讓大家協作。如果有任何command錯誤或更新,都可以經Github Pull Request通知筆者。

github flow - github 開發流程

那些年那個很穩定卻又不受歡迎的 git flow 開發流程

多年前,朋友就向筆者介紹git的團隊整操作流程。筆者深思過後,的確實用,那些年的git-flow,很美滿,由開發、測試,到發佈、修補漏動(backport),都有清楚明確的指引。

原作者連結:git-flow

大家如果沒有更複雜的需求,真的可以照搬,筆者也很推這一個模型。

但在長期推廣下,筆者發現大部份人其實都不熟git的基本操作,什至連git graph也不看,現在看git flow,就更不可能接受。那怕是有常用git的個人團隊,也是不怎使用分支模型。

前一兩年,筆者也不懂,筆者也努力地簡化git flow。例如把master和develop合而為一,但最後也是少有人可以接受,很多人還是卡在分支那邊,對checkout、merge還是很陌生。在跟更多不同人的協作過後,筆者總於意會到一件事。其實大部份人,只想知道最後、最新的狀態,只會更新 master / main ,也因為個人開發,所以連衝突也不會有,更不需要使用merge。那怕是少型團隊,頂多也是維護main的衝突,間中用用merge,而checkout還是用不著。

其實這個情況,並不限於小型團隊。因為 web app 和 DevOps 的流行,所以越來越少機會要維護多個舊的穩定版本。大家都專心於最後一個開發及發佈版本就完事,用戶的某個版本有問題?更新到最新版本吧。(註:越底層的應用開發模式,因為相容性問題,不可能只保留一個穩定版本。)

那麼我們就大力簡化吧 - github flow 開發流程

既然大部份情況,大家都只在乎 main / master / 預設分支,那我們也沒有必要跟著複雜的 git flow 走。但在 DevOps 的角度下,為保證 main / master 穩定性,大家還是至少要遵守branching 、pull (merge) request 、code review 、auto test 原則 。

github就最簡單的branching 、 pull request 、 code review 提出了它們的 github flow doc, github flow

簡而言之,就是每個人在開發時,都先從 main 起一個新分支,不斷更新。待合適的時候,就透過 pull requst,向原項目負責人提出申請,只要項目負責人點頭,就可以把改動傳入 main 中。又因為Github 原本的定位在於個人與個人之間的協作,初時已經需要通過fork建立獨立的倉庫,那怕你不愛分支也必需分支。所以 pull request,code review 的作用更明顯,後逐的協作更理所當然。

但若果回到公司團隊協,Github flow 就應該像筆者之前提出協作方案,各自起分支,最後由某個人守門,把所有結果放到 main 中。(前文連結

Github flow 沒有提及的發佈 - 佈署 | Release - Deployment

在上文中,經過 pull request 、 code review 、 auto test ,道理上,開發者可以做的都已經做過了,然後就是等待發佈 - Release。

對於單純的庫類型的程式碼,筆者認為,的確沒有事可以再做,實務上就是直接找人其他程多員試用最新版本,看看有沒有問題。只要 main / master 上,明確的表示版本號的變更,就差不多等於直接發佈。有需要提供binary版本的,就還需要觸發上載binary的流程,但這個跟 pull request 觸發 auto test 差不多, auto test 成功後就上載。

對於服務類型的程式碼,例如 Web App 等,直接發佈到正式環境還是有些不妥吧?始終會即時影響到業務,我們至少有個測試場,經用戶做實際的業務操作去驗收。但這個時機,應該是在Github flow的什麼時候做?

在原始的git flow中,有一個叫做 develop 的相對穩定分支,僅次於 main 。它是功能開發完成後第一次pull request 的地方,我們可以用這個概念來做自動發佈到測試場。但若在github flow 中加入了這個 develop / uat / staging 分支,其實就等於複雜地回到過去傳統的 git flow中,對好多新手來講難以接受。Github flow 的成功簡化,其實很大依賴著自動化測試。現在的測試用例,並不再限於單元測試。就連整合測試,也可以經Docker等容器化技術去做,只要我們的自動化測試有足夠信心,就可以發佈。但反觀我們的 Web App 例子,我們認為自動化測試難似涵蓋所有情境,也難以開發。所以我們還在有個時間發佈到測試場,進行人工測試。

pull request + 快速迭代

筆者結合自己的經驗,配上國外討論區 Stack overflow 的內容,筆者認為Github flow上進行 pull request 後,就是最好的發佈測試場時機。所以我們需要盡快進行驗收測試,完成後在Git commit上加上Tag,以示通過驗收測試,可以發佈正式環境的版本。

不過這個模式是有一個很重要的前題假設:快速迭代。當我們驗收完成後,盡可能快地發佈到正式環境,不然會阻礙下一個功能的pull request驗收,或是覆蓋了上一個pull request的驗收環境。

用反面的例子來說明,如果我們有很多功能需要驗收,或變化很多,或存在多輪的里程碑開發,我們就不適宜那上述模式。最保險的做法,還是回到傳統的 git flow ,引入 develop / uat / staging 分支。但如果大家還是那麼討厭傳統 git flow,筆者還是有另一個提議。

既不想回到傳統 git flow ,但又需要慬㥀的考慮驗收發佈流程

如果開發的功能變化比較大,需要多方面協調、測試、驗收,經歷多次里程碑後,才有一個對外發佈的版本,大家可以考慮分開 Repository 做開發。例如: v1,v2的 Repository 完全獨立。 v1 是已發佈的版本,有獨立的測試場,任何即時候需要修正,就在v1的 Repository 做 pull request。 v2 則是未發佈版本,亦有獨立的測試場。加入任何新功能後,就在v2的 Repository 做 pull request,用自己專用的測試場做驗收。到 v2 正式發佈後, v1 就封存處理,再開一個 v3 作為下一個大版本的開發。這個模式,那怕在庫類型的程式碼也用得上。

這樣做的好處是 git Repository 和歷史記錄都會獨立,自動發佈的腳本程也會簡單明確一些。壞處則是 v1 v2 難以做功能對比,我們只能靠人腦記著 v1 有沒有什麼後期加入的修正和功能,需要同步移植到 v2 中 (相對的,著是同一個Repository,可以利用merge 功能確保 v1 有的,v2 都己處理,只是必需要很懂處理版本衝突問題。

git submodule

初次實務上使用submodule來同時管理幾個project的更新。如果有任何理解上的錯誤,請在github中提issue或pull request。

why submodule

假設你的團隊中有三個人,A君做A Project,B君做B Project,C君做Main Project。如果可以,A,B各提供已編譯的Binary或Library,給C君直接使用就最好。

但要做到好好管理,A,B都要有自己的發佈系統,即是把Binary上傳到某個分享Repo中,這樣C君就能有條理地通過IDE或Compile工具下載對應的版本。如果是javascript,Repo可能就是npm repo,如果是java,可能就是maven repo。這亦代表A,B君對程式編譯、打包、版本命名等都要很熟悉,不能一輩子都命名為v1.0.0。

如果團隊對這些都不熟悉,C君還有什麼方法呢?其實靠著Submodule的功能,C君也可以硬把A,B的Source code取出,做最後打包。

這跟A、B君自己把source code壓縮然後Email寄給C君是有不同的。因為這樣C君並不清楚A,B的git脈絡:C君需要自己做好A、B的版本記錄。想要只回滾A,B的版本普不容易。但經過git Submodule後,C君可以清楚知道現在正使用的是A、B的那一個commit版本。假如有一天,A、B、C三個都更新了,但發現合起來時就跑不動。C君可以保持A、C的版本不變,單獨提取B的某個版本進行測試。當然,你可以說原本Email也可以這樣管理,但始終你不清楚B的版本記錄,Email的日期並不代表Source Code的進度。(因為有時候,Bug Fix是針對舊版本的做更新,新功能的Email日期反而比Bug Fix要早)

同理,如果大家要連結多個沒有發佈系統的文字資料,也可以利用Submodule。例如筆者正在編輯一本書,當中不同的主題,就是使用Submodule的功能串連起。

Command

馬上看來來Submodule可以怎樣做。 假設你已經知道git 怎樣用,也起了git repo。假設你是C君,進入你的本機repo資料夾內,使用submodule參數。

cd SOMEWHREE_IN_MAIN_FOLDER
git submodule add SUBMODLE_REPO_LINK
cd MAIN_FOLDER
git add -u && git commit -m 'Add sub module'

上面的效果,就是把C君當前repo的狀態,連結到B君submodule當時預設分枝(default branch)的最後一個commit 中。然後C君在自己的repo怎樣更新,它引用到B君的submodule版本都不會變。

直到某一刻,B君說他加了一個穩定的新功能,請C也連帶更新一下。C君也做好自己的準備後,使用submodule參數進行更新。

cd MAIN_FOLDER
git submodule update --remote
# run any unit test, integration test, confirm that it works after submodule update, then commit
git add -u && git commit -m 'update sub module and auto checkout'

注意,如果C君有多於一個submodule,上述指令會全部一口氣更新。另外,如果你覺得B君的最新版本不能用,還是可以針對B君取得特定的版本。

cd SUBMODULE_FOLDER
git checkout SUBMODULE_COMMIT
cd MAIN_FOLDER
git add -u && git commit -m 'update sub module to specific commit'

如果,你是D君,直接下載A君的多module project,那在初次下載時,submodule還是空的,需要先init,再update。

git clone ...
ls SUBMODULE_FOLDER
# SUBMODULE_FOLDER will be empty
cd MAIN_FOLDER
git submodule init
git submodule update
ls SUBMODULE_FOLDER
# submodule code should be there

如果你同時是A,B,C君,其實多個module也是你自己管理的,你可以直接進入各個submodule commit。但要記得,main module也要commit才會連上最新的submodule。

cd SUBMODULE_FOLDER
# make some change
git add -u && git commit -m 'update in submodule only'
# remeber to push submodule to origin
cd MAIN_FOLDER
git add -u && git commit -m 'update main module to ref new submodule commit'
# remeber to push main module to origin

不過筆者還是建議大家分開使用。不要全部混在一起,因為太多同步問題會漏。

git 分支整合的時機

不知道大家的開發團隊、專案規模有多大,但只要系統或程式已發佈,同時又要做維護更新,專安的git庫都至少會有兩條分枝:

  1. 新功能 - main / feature
  2. 最新的穩定發佈版本 - Release / v1.x.x

最好的情況下,在開發完新功能之前,穩定版本都沒有需要緊急修正的地方,開發者可以專心開發新功能(main / feature)。然而這個情況並不能經常維持。

情況1:有Bug要馬上修正

最常見到的情況,就是穩定發佈版本有瑕疵,可以經過小修小改來止血,由v1.x.x ⇒ v1.x.y,這些可能對用戶來說,是沒有太大感覺的改動。不過對於開發流程,就免不了由v1.x.y整合(merge)回main時,出現修改衝突的問題。

建議

若屬於日後不再需要的改動,不需於整合到main中, 當然什麼都不用做。但若屬於必要的更新,就需要早早整合到main中。整合雖然痛苦,但延後整合沒有好處。以筆者的經驗,每次整合時有衝突,而越早整合越有條件知道該取用自動混合的那個版本。以整合工具的語言來說,就是更容易的作出use mine / use theirs / edit。

情況2: 不同功能之間有衝突

上述情況1,已經算是可控的。主要因為穩定發佈版本都只會接受小修小改,大改都會直接在main中開當為新功能開發。當你有多個很重要的功能在不同時期被提出,而有些功能你沒有信心在下個發佈中提出,你就會選擇以獨立分支來實現不同的功能,最後選擇信心度高、權重也比較高的功能來發佈。這樣的好處是你可以有限時間先完成最必要的功能,但問題是多個功能分支之間,更容易地有衝突,後期也需要很廢心力地整合。

建議

少做資料夾層面的改動,因為git rename的功能並不是萬能的,會令很多git自動選擇版變得不可讀。筆者的經驗,就是錯把後端和前端的資料夾混在一起,令後端的一些重命名影響到前端。前端也因為有重寫的需要,對資料夾結構大改。最後結果就是很多看不懂的git自動選擇版。有一些有選對,但有一些就選錯。

可以做一些事前處理,來減經痛苦。在筆者的資料夾問題情境,在把後端將要整合的多個commit中,挑選最早前沒有命名問題的commit先整合一次。然後前端先手動模擬後端的人工命名,自行commit一次,最後再把後端剩餘的commit再做整合。這個做法不是完全解決問題,但至少可以讓use mine / use theirs / edit更新易理解。

而另一個建議是,縮短發佈週期,逼使其他開發中的功能越早做整合,也逼使每個功能不要做太大規模的改動。如果真的做大規模改動,就要有心理準備要多次重要的整合。

情況3: 多個穩定發佈版本需要同時維護

若然大家面對的工作規模真的很大,同時有多個版運行版本,就如gitlab,每一個月都有一個新功能版本(16.0.x, 16.1.x, 16.2.x,… 16.9.x),但它不會強逼大家更新,對於過去一段時間的功能版本,也會推出安全性更新(前述的x會不斷修正問題)。

這是一個很負責任的發佈模式,不過對於開發者來講就一定很地獄。因為16.0.x的安全更新並不能無痛地整合到16.9.x中,可能每個版本重新人工修改還要來得穩健。

建議

各個分支人工修改可能更適合。最後就是取決於商業政策的考量,到底公司願意為已發佈的功能版本提供多久的支援。就以gitlab為例,其實它也只承諾維持兩三個月前的功能版本。是否會backport到多個月之前的版本,就看問題的嚴重性和backport難易度。

也分享一些筆者朋友的經驗,他們開發的是軟件跟硬件整理的軟件庫。但因為硬件有限制,例如庫的大小、算力的差異,所以最後分支多到爆炸。這也是軟硬整合的痛,問題暫時無解。除非老闆肯放棄市場。

Git - 持續整合策略 | Git - Continuous integration strategy

對於原始碼的管理,平常筆者也有在用gitlab的Continuous integration,針對每次提交(commit),都會有自動編譯和測試。但當一個專案中,有很多關聯庫(dependency library)的引用時,光是專案中每個commit 行auto build就不夠用了。更嚴重的是,若然大家有很多微服務micro service,它們的更新不會反映在commit中。

所以定期重跑動動編譯和測試,是筆者認為可以緩解關聯更新的問題,至少可以提高知道問題所在。

Night Build的原意

筆者先做了一些功課,參考別人怎樣思考Night build (定期重新編譯)這件事。

  1. 每次整合新功能到穩定分支(stable branch)之前,都需要做自動測試。
  2. 當專案複雜性越來越大,每次自動測試都把全部測試跑一次,就會遇到效能瓶頸。
  3. 所以考慮commit時做單元測試(unit test),然後每個固定的時間問隔做整合測試(integration test)。那個固定的時間間隔就是Night build。

Night Build之於integration test

筆者原始的問題並不是來自於效能瓶頸,而是涉及關聯性更新問題。這些要麼就有是經code base 層面引發關聯性自動試測,要麼就是Night build重複測試。

這兩個功能,gitlab都有提供,只是筆者初步構想下,Night build比較易設定,也乎合原始的定位。

因為要考慮micro service的於沙盒環境的部署,最簡易的Night build只需要一個共用的環境就夠。但也同樣意味著,Night build需要進行多個不同的分支測試,就需要多個不同的環境。

另外Night build的測時時機也是一個問題,因為測試當下,並不能百份百對應關聯micro services的提交狀況,大家就更需要做好發佈的版本號語意管理。

Night Build 實務操作上的注意點

Night build第一個要注意的問題,就是要確保同一個commit,真的可以重複建設。一般來說,大家的目標只在運行測試,而自動測試不具破壞性,就基本可以重複的。而如果測試當中包含發佈測試版本,那就還要考慮重複發佈有沒有生效或造成附作用。

以Java maven為例,重複發佈測試版本需要遵守特定的規則,版本號需要以SNAPSHOT結尾,這是為讓maven每天都會重新下載它們的包。而沒有SNAPSHOT結尾的,就只會做一次性下載,減少重複下載造成的資源浪費。若真遇著不支援重複發佈的情況,就需要以日期時間做版本號,就像vscode的某些插件,就是以時間截結尾以作為區分。

Night build另一個要注意的問題,就是開發圖隊何時進行下一輪開發,這會決定何時有新的版本號。扣除上述因為工具不支援的而引發的副作用,還要考慮沒有更新而發生的問題。

有個尷尬情況是,團隊在發佈現行版本時,release commit與main有機會是同一個commit(也就是未有進行下一輪開發)。若不斷重複發佈,有沒有變相發佈了一些沒有預期的功能?例如Docker image,官方大力建議每日自動發佈。當底層的image更新後,頂層引用它們的image,也可以重新發佈,保持安全性。但這樣做的問題,就是頂層的同一個版本號,昨日與今日的運行結果也可能不一樣。這對追蹤問題,並不友好。

所以大家做分支整合時,要預先對版本號作好規劃。然後還要留意Night build不應與release commit重疊。版本號大家做好語意管理,再加上alpha / beta / SNAPSHOT等區分Night build版本,應該就足夠了。而commit重疊問題,就要留意開發週期,Night build要麼就比release早一個commit(即在release時,不推進Night build),要麼晚一個commit(即馬上規劃下一個版本號進行Night build)。

之前看了一位git大神的演講,提及一個叫MONO Repository的使用情況。後期找資料之後,才發現到這是一個公司成長後的一個重大的挑戰。

何謂MONO Repository

git的傳統,就是為每一個獨立的專案,建立一個新的Repository (中譯:倉庫)。這個很直觀,獨立專案,獨立管理。從零開始有很多好處,Repo體積通常會小一點,因為其內的東西都是緊密相關。做更新處理時,維護人員也更清楚自己的影響程度。這種架構方式,就叫Multi Repository。基本上,大家預設也是會走這個模式。

但當公司規模一直變大,多個專案可能不再獨立,各個專案或多或少都有一些關聯性。當任一專案更新,都有機會影響到其他人。如果公司使用Micro Service (微服務),就更有機會提早遇到。每次更新時,要跨專案地找出影響範圍原本就已經不容易,現在每個專案獨立地存放在不同的倉庫中,每個倉庫的更新速度不一樣,想要找到合適的地方、合適的時間點推出更新,更是困難。

所以,就有公司就提出,將所有專案都放在同一個Mono Repository中,方便用工具去檢查更新影響。相比Multi Repository,這樣做還可以保證同一個改動可以發生中同一個Commit中,可以讓跨專案的團隊可以即時合作(強逼修改別人的專案)。但這樣使一定會有很技術問題出現。跨專案團隊不可能每個專案都熟悉,因為不熟悉而引起的副作用一定會有,所以Main / Master分支出現有缺陷的機會提高了。亦有人提出,使用Mono架構,還必要使用trunk base分支模式。也就是那些新功能,雖然要創建分支開發,但亦要盡早整合到Main / Master中。這才能讓不同的團隊盡早知道問題,並解決問題。

除了開發模式更具挑戰外,Mono架構對git的效能也有很大影響。因為多專案混合,Repository的大小基本都會很大。每個git指令都會變慢,所以必需做一些週期性的cache,讓git graph, git status這樣日常操作變得暢順。同樣地,持續整合/發佈需要作出調整。不過這些筆者就不在這邊詳述了,有興趣朋友可以到git 大神的Youtube觀看。

So You Think You Know Git - FOSDEM 2024

註:據筆者的資料搜集,很多大公司(Software龍頭)都有使用Mono Repository去做集中管理。只不過筆者不知道如何Fact check,就不在這裏提了。

Git Worktree

看了Git 大神的影片 part two,才知道原來切換git分支還是有不同的做法。傳統中,我們使用git checkout BRANCH_NAME_1 來切換到我們想要的分支。通常這樣做,代表我們放棄原來的工作環境,換到另一個工作環境中。

這樣做很好,對不對?

是的。但有些時候,我們只是被逼離開原本的工作環境,跳到一個過去的分支/節點去查一些東西,或者修正一些東西。更什的是我們原本的工作環境都還是混亂狀態下,我們不想做commit(提交),我們只好用git stash,暫時將工作環境存起,然後再git checkout BRANCH_NAME_1。在你想做的事做完後,再git checkout OLD_BRANCH

看起來其實也沒有很麻煩,是不是?

但其實當你的專案有一定大小,你在不同版本跳來跳去,你的IDE就會不斷地重新編譯。更不幸的是,當你的不同版本中有模組數量的差異,弱一點的IDE,什至會攪死它的cache,之後就會發生鬼打牆。為解決IDE引發的問題,筆者有時會直接cp -r YOUR_PROJECT TEMP_PROJECT,在一個新資料夾下另起爐灶。那就是有兩個不同的資料夾裝載著你的專案。

這樣應該沒有問題了吧,是不是?這次是真的可以了,扣除了筆者個人健忘的問題,就沒什麼問題了。

不知大家有沒有經驗,連續commit了幾次,但最後一次commit卻忘了push(與伺服器同步),然後就跳到其他地方繼續工作。如果我們在同一個git repository下,我們commit了但忘了push,即使我們git checkout去了其他分支,用git GUI畫出commit graph時,也至少可以提醒筆者有一個未與伺服器同步的分支。但如果當初我們用的是cp,那就沒戲唱了,什至乎當初複制了去哪裏都忘了。(當你老闆同時要你跟多個專案,健忘真的很容易發生。)

這問題有解嗎?有的,git在2.5版本以後,就提供了一個git worktree的指令。它有點像cp 指令,更重要的是,它打通了兩個資料夾下的隱藏資料庫.git,當大家在那兩個資料夾底下,都可以看到另一方的存在。大家可以用git branch -agit log --oneline --graph來看看。

詳細的指令介紹:git worktree

git 大神的影片 Part 2

有時候,我們修正一系統檔案,例如某個commit中,多了一個不該放的檔案,又或者想修改該commit的作者,我們就要追搜到某個commit,然後用rebase隨個改。

例如本次repo,有一個githubAction.md,因為錯誤原因,被加到了main中,也藏了很久。如果我們想連根拔起,我們需要加出它第一次出現的commit。

$ git log githubAction.md
commit 60ccd70f6b768138cbe23c93ffcfa32574ce895c

那我們就以它前一個commit作為rebase的根據,進行逐個commit修正。

$ git rebase -i 60ccd70f6b768138cbe23c93ffcfa32574ce895c^
pick 60ccd70 draft some content
pick e2ee9a3 add some senario.
pick b91afc1 refine submodule;
pick 98cd366 add notes about submodule specific checkout;
pick 064b06f test directly commit in submodule main
pick 7b648d2 update git submodules notes
pick 556f25e add notes about merge timing
pick 5244804 Create git-continuous-integration-strategy.md
pick 107e486 add more pratical nodes about ci;
pick d93cbee add mono repo challenge
pick 1c471b6 add worktree notes
pick 9063ccb notes about different of git flow and github flow;
pick b72e89e Update github-flow.md, add ref more link
pick 0b8f2a9 draft github flow release problem
pick 8b333fc finalize github flow release strategy

在rabase選項中,把需要改的commit由pick改為edit。(rebase會以舊到新顯示)。然後儲存。例如

edit 60ccd70 draft some content
edit e2ee9a3 add some senario.
edit b91afc1 refine submodule;
pick 98cd366 add notes about submodule specific checkout;
pick 064b06f test directly commit in submodule main
pick 7b648d2 update git submodules notes
pick 556f25e add notes about merge timing
pick 5244804 Create git-continuous-integration-strategy.md
pick 107e486 add more pratical nodes about ci;
pick d93cbee add mono repo challenge
pick 1c471b6 add worktree notes
pick 9063ccb notes about different of git flow and github flow;
pick b72e89e Update github-flow.md, add ref more link
pick 0b8f2a9 draft github flow release problem
pick 8b333fc finalize github flow release strategy

我們第一次會在60ccd70,我們作出想要的改動,然後經amend去改掉60ccd70

$ rm githubAction.md
$ git add -u
$ git commit --amend --author="newuser <newemail>"

確定無誤的話,就可以去下一步,即是到了e2ee9a3

$ git rebase --continue

因為已經rebase過,你此時看到的不會再是hash不再是e2ee9a3,而是自動rebase完的e2ee9a3。若大家有東西要改,就使用commit --amend。如果沒有東西要改,也沒有conflict,可以繼續rebase --continue下去。

Spring Boot - Maven Cheat sheet

基礎

刪除所有結果,全部重新編譯

mvn clean compile

跑起用Spring boot寫的main class,運行Spring boot context。

mvn spring-boot:run
# or
mvn clean compile spring-boot:run

執行測試用例,預設只會測試test資料夾下以某些命名規則的class(例如class名以Tests或Test結尾的class,其他命名規則筆者未有能力一一驗證)

mvn test
# or
mvn clean compile test

多Profile、多組件、多測試

使用-P指定編譯時的選用pom.xml中的project.profiles.profile參數。也可以用此來傳遞到spring profile,使得編譯後的spring war預設選擇特定profile。

mvn clean compile -PmvnProfile
# or
mvn clean compile spring-boot:run -PmvnProfile

使用-pl限定mvn指令只對某個子組件生效,但有時候子組件之間也有引用關係,所以需要再額外加上-am參數(--also-make)

mvn clean compile spring-boot:run -pl SUBMODULE_NAME -am

使用-Dtest=限定只執行某個class的測試用例,或單個測試函數。(可以無視class名的命名規則)

mvn test -Dtest=TEST_CLASS_NAME
# or
mvn test -Dtest=TEST_CLASS_NAME#TES_METHOD_NAME

若屬於多組件情況下,其他子模組找不到同樣名稱的測試,會測試失敗。需要再加上-Dsurefire.failIfNoSpecifiedTests=false

mvn test -pl SUBMODULE_NAME -am -Dtest=TEST_CLASS_NAME -Dsurefire.failIfNoSpecifiedTests=false
# or
mvn test -pl SUBMODULE_NAME -am -Dtest=TEST_CLASS_NAME#TES_METHOD_NAME -Dsurefire.failIfNoSpecifiedTests=false

打包

在本機電腦中,把java變成jar或者war。通常用於自行發佈的環境中。

mvn package

例外情況

強行把一個第三方jar,種到本機電腦中的.m2/repository

# copy from https://maven.apache.org/guides/mini/guide-3rd-party-jars-local.html
mvn install:install-file -Dfile=<path-to-file> -DgroupId=<group-id> -DartifactId=<artifact-id> -Dversion=<version> -Dpackaging=<packaging>

有時特定Profile沒法成功執行測試用例,或者你認為有些測試問題不影響使用,需要跳過package中的test。

mvn package -Dmaven.test.skip=true  # won't compile test folder
mvn package -DskipTests=true # compile, but won't run

若想預設特定submodule為不執行test。可以修改對應的pom.xml

	<profiles>
		<profile>
			<id>dev</id>
			<properties>
				<maven.test.skip>true</maven.test.skip>
			</properties>
			<activation>
				<activeByDefault>true</activeByDefault>
			</activation>
		</profile>
	</profiles>

此後有需要測試,則要手動執行

mvn test -pl SUBMODULE_NAME -am -Dmaven.test.skip=false

若你不想整個submodule 跳過,想逐個class跳過。可以

		<plugins>
			<plugin>
				<groupId>org.apache.maven.plugins</groupId>
				<artifactId>maven-surefire-plugin</artifactId>
				<version>3.2.5</version>
				<configuration>
					<excludes>
						<exclude>**/SpecialTest.java</exclude>
					</excludes>
				</configuration>
			</plugin>
		</plugins>

此後有需要測試,則要手動執行指定特定class。但你只能一個個class手動測試。

mvn clean compile test -pl SUBMODULE_NAME -am -Dtest=SpecialTest -Dsurefire.failIfNoSpecifiedTests=false

Web Socket vs http/1.1 vs http/2

http/1.1

現時最基本的http protocol。但它效能不算很好的原因,在於每次都要起連線,先Client Request,再Server Response。雖然它可以使用Keep-Alive header,同一個TCP/IP連線中連續做多次request/response循環,但重點是它需要先後request/response,所以當中有一個response長度很大,就要慢慢等它完結。

http/2

沿用http/1.1的大部份東西,包括status code等,但傳送的方法做了更改。

在傳統的Client Server行為中,假設第一個Client request是去問Server取得主網頁index.html。在Client得知完整的index.html response後,分析其中有一個main.css,才後知後覺的再問server去取main.css,這才有了第二個request。

http/2,就是一種Server有主導權的機制。Server明明知道index.html會連著main.css,所以在第一次回傳時,把main.css跟index.html一起回傳,即是回傳比Client原本查詢範圍更多的結果。即它可以主動推送資料,不用等Client去做二次操作。

Web Socket

原本在http/1.1的時代,就有人提出了升級的玩法。Web socket就是把http 1.1的request/response循環,在第一次握手後,硬改為TCP/IP 的傳統Socket機制。Client/Server可以隨時Read/Write。但跟傳統TCP Socket的差別在於,Web Socket可以保證Read時可以讀取一個完整內容,而傳統TCP Socket每次Read都可能只有該內容的一部份,要多次Read及計算長度才能確保讀完完整的內容。

因為建立了Socket,長期維持連線,再加上多工機制,所以十分適合作為實時傳輸用。

Spring boot implementation

筆者查看了一下Spring boot的教學,http/2只有一些基本文件支援描述,但沒有簡單無腦可以的教學。但對於Web Socket,倒是有的。

STOMP (Simple / Streaming Text Orientated Messaging Protocol)是Web Socket內的一個實現方式,Spring boot提供的就是STOMP底下的Web Socket。筆者實驗後,感覺就是傳統的http 1.1升級Web Socket的做法。

基於Http2 的Web Socket升級版

筆者之前亦跟好友聊過一下Web Socket、Http 2的差異,經好友梳理,其實Web Socket也不一定是Http 1.1,現在Http 2也開始可以這樣玩了。

好友轉發的Medium的文章描述下,IETF在2018年後也制定了新的玩法RFC8441,Web Socket可以經 http 2轉化出來。

無奈筆者還受困於Spring http/1.1的限制內,未能為大家介紹更多。

Reference

Spring Boot Multiple DataSource

Example Code

Lambda 表達式之可讀性

Java作為一個真OOP物件導向的程式,在設計和編寫上是很嚴謹,什至是囉嗦的程度。近年很多Programmer因為各種原因,都放棄Java跳船去其他語言。

Javascript是其中一個很多人的選擇,因為Javascript有nodejs的加持,在Web世界下,可以同時走frontend、backend路線。而Javacript亦有一個很明顯的特性,就是大部份的library都以callback的型式出現。另外,Javascript也讓很多人覺得很簡潔,這除了是因為它沒有強型態的規限外,另一個原因也是因為有callback的大量使用。

Function Pointer

其實callback,籠統一點講就是在一個function A傳入另一個function pointer B。而編寫function A的作者,並初期並不知道function pointer B的實際操作會是什麼。A作者只是強調在特別定時候,它就會使用這個function pointer B。而這種把function pointer 傳來傳去的做法,就可以看成是Functional Programming的基礎。

Functional Programming除了把function pointer 當成是一等公民以外,還有很多附加要求,例如:

  • Pure Function: 它只會使用到自己的Local Variable本地變數,這樣它的作用域就鎖死在Function內部,就不會有副作用。
    • 傳統的OOP,Class中不少變數會以Class Attribute型式存在,雖然它們可能是private attribute,但還是獨立於Function外,這樣各Function的操作,都要靠作者好好地記著Class Attribute的狀態。
  • Nested Functions: 與普通程式語言類似,很多情況下都需要local variable,而Function Programming要足夠好用的話,就需要彈性地在function裏定義local function pointer。

Java Lambda 表達式

其實從Java 8開始,就有提供Lambda表達式,這是一個可以制作匿名function pointer的方法。所以硬要講,Java也可以做Functional Programming。

但必需要盡早強調的是,Java經常性地使用class attribute,它們很多時候都會引申請狀態的概念。即是在它們必需經過特定步驟後,class attribute才會有特定的意義。也就是Lambda表達式想保持Pure Function的特性,它可以使用的時期就有很大限制。

但我們還有必要使用Lambda嗎? 以筆者的經驗來講,它還是有作用的,特別在於它可以改善Class Function的閱讀性。

例如下面一個Java Class。它是一個工廠,提供一個服務可以生產一堆車。那些車而需要經過特定檢測,才能推出。

public class Factory {
    // ex1
    public static List<Car> generateListOfCarByForLoop() {
        List<Car> tempCars = new ArrayList<>();
        // many other logic
        // many other logic
        // many other logic
        List<Car> passTestCars = new ArrayList<>();
        for (Car car : tempCars) {
            if (car.getWheels().size() == 4) {
                // many other check logics
                // many other check logics
                // many other check logics
                passTestCars.add(car);
            }
        }
        return passTestCars;
    }
}

中間的for loop可以用lambda來改寫。

    // ex2
    public static List<Car> generateListOfCarByLamda() {
        List<Car> cars = new ArrayList<>();
        // many other logic
        // many other logic
        // many other logic
        cars = cars.stream().filter((car) -> {
            if (car.getWheels().size() == 4) {
                // many other check logics
                // many other check logics
                // many other check logics
                return true;
            }
            return false;
        }).toList();
        return cars;
    }

有人會說,上述ex2只是形式上改變了,沒有特別易讀。就像ex3這樣,把特定邏輯抽成獨立function,才是真正的易讀,對嗎?

   // ex3
    public static List<Car> generateListOfCarByForLoopFunction() {
        List<Car> tempCars = new ArrayList<>();
        // many other logic
        // many other logic
        // many other logic
        List<Car> passTestCars = filterCarsByWheelsSize(tempCars, 4);
        return passTestCars;
    }

    private static List<Car> filterCarsByWheelsSize(List<Car> originalList, int targetSize) {
        List<Car> passTestCars = new ArrayList<>();
        for (Car car : originalList) {
            if (car.getWheels().size() == targetSize) {
                // many other check logics
                // many other check logics
                // many other check logics
                passTestCars.add(car);
            }
        }
        return passTestCars;
    }

上述ex3是一個有效的改進。如果大家不計較傳入參數的先後順序及交互影響的話,就已經很足夠。

但如果大家對於多參數的解讀又怎樣?

private static List<Car> someotherfunction(List<Car> cars, List<Wheel> wheels)

大家又會不會突然停住,想想到底是cars影響wheels,還是wheels影響cars?

對於多參數的function來講,相互影響就會越來越多,但使用Lambda的話,可以針對性地表達這是一個Predicate Lambda。

    // ex4
    public static List<Car> generateListOfCarByLamdaComposition() {
        List<Car> cars = new ArrayList<>();
        // many other logic
        // many other logic
        // many other logic
        List<Wheel> wheels = new ArrayList<>(4);
        cars = cars.stream().filter(
            filterCarByWheelSizePredicate(wheels)
        ).toList();
        return cars;
    }

    private static Predicate<Car> filterCarByWheelSizePredicate(List<Wheel> wheels){
        return (car) -> {
            if (car.getWheels().size() == wheels.size()) {
                // many other check logics
                // many other check logics
                // many other check logics
                return true;
            }
            return false;
        };
    }

就最後的ex4版本,可以很明確的知道是cars被Predicate所作用

如果大家還有其他使用Lambda的明顯好處,也可以一起來Github分享大家的Code

Lambda 表達式之可讀性2 - Java Sorting

對於習慣寫SQL的朋友來講,排序是件很容易的事,通常在SQL的結尾,先後加上不同欄位名稱,就可以有排序效果。例如:

select car.size, car.NumberOfWheels
from car
order by car.size, car.NumberOfWheels

即使要改變排序的條件,先以car.NumberOfWheels再car.size,那改一改就好

select car.size, car.NumberOfWheels
from car
order by car.NumberOfWheels, car.size

但對於Java來說,就不是這麼容易的一回事。很多依賴SQL的商用開發者,可能也不記得Java是怎樣做Sorting interface的。但對於NoSQL的世代來講,Database應該視為一個Storage Engine。某些排序還是要靠Program層面做,例如傳統的Java就需要提供一個回傳-1, 0, 1的Function,以決定A應該排在B前面,還是相等,還是排在B後面。

    public static void sortExample(){
        List<Car> cars = new ArrayList<>();
        // ... many cars
        cars.sort(ChainComparator.getOldSchoolComparator());
    }

    private static Comparator<Car> getOldSchoolComparator(){
        return (a, b)->{
            Double aCarSize = a.getSize();
            Double bCarSize = b.getSize();
            if (aCarSize.compareTo(bCarSize) != 0) {
                return aCarSize.compareTo(bCarSize);
            } else { // if tied
                Integer aNumOfWheel = a.getWheels().size();
                Integer bNumOfWheel = b.getWheels().size();
                return aNumOfWheel.compareTo(bNumOfWheel);
            }
        };
    }

上述例子雖然已使用Lambda去簡化寫法,但實際上如果排序欄位很多,就會出現一個很長的表達式。而且也很難去改寫中間的先後次序,例如怎樣才能很輕易地把numOfWheel改到放在carSize前面。即使我們有辦法把分段邏輯都抽入個別Function裏面,那個If的結構也是抽不走。

在Java 8 Lambda出現後,其實Comparator也有提供新的寫法,它可以連在一起繼續延伸,讓平手、再查一下條件的情況簡化了。這也是讓Dynamic Sorting變得有可能。

    public static void sortExample(){
        List<Car> cars = new ArrayList<>();
        // ... many cars
        cars.sort(ChainComparator.getComparatorChain());
    }
	private static Comparator<Car> getComparatorCarSize(){
        return (aCar, bCar)->{
            Double aCarSize = aCar.getSize();
            Double bCarSize = bCar.getSize();
            return aCarSize.compareTo(bCarSize);
        };
    }

    private static Comparator<Car> getComparatorNumOfWheels(){
        return (aCar, bCar)->{
            Integer aNumOfWheel = aCar.getWheels().size();
            Integer bNumOfWheel = bCar.getWheels().size();
            return aNumOfWheel.compareTo(bNumOfWheel);
        };
    }

    private static Comparator<Car> getComparatorChain(){
        return ChainComparator.getComparatorCarSize()
            .thenComparing(ChainComparator.getComparatorNumOfWheels());
    }

上述的例子,可能還是沒有太體現出Lambda的好處,主要是Java型態的問題,上面那樣寫我們每次都要重複地編寫適合Car的Comparator,就變得有點囉唆。但貼心的Comparator還有提供進一步的Lambda結構。

    public static void sortExample(){
        List<Car> cars = new ArrayList<>();
        // ... many cars
        cars.sort(ChainComparator.getComparatorChain2());
    }

    private static Comparator<Double> getComparatorDouble(){
        return (aCarSize, bCarSize)->{
            return aCarSize.compareTo(bCarSize);
        };
    }

    private static Comparator<Integer> getComparatorInteger(){
        return (aNumOfWheel, bNumOfWheel)->{
            return aNumOfWheel.compareTo(bNumOfWheel);
        };
    }

    private static Comparator<Car> getComparatorChain2(){
        Comparator<Car> chainedComparator = Comparator.comparing(
            car->car.getSize(), // converter
            ChainComparator.getComparatorDouble() // reuse exisiting comparator
        );
        chainedComparator = chainedComparator.thenComparing(
            car->car.getWheels().size(), // converter
            ChainComparator.getComparatorInteger() // reuse exisiting comparator
        );
        return chainedComparator;
    }

上述的例子中,getComparatorDouble,getComparatorInteger可能是別人寫好的Comparator,它們不是針對Car來使用的。但我們還是可以經過Comparator.comparing的介面,硬把Car轉為Double或Integer,然後就可以重用別人準備好的getComparatorDouble,getComparatorInteger。

Github Code

Spring Boot 01 - 萬物始於Spring boot context

筆者早些時候向一位朋友討論,為何Java那麼不受歡迎。朋友一句就回答,Java煩爆,沒有人會喜歡。

老實講,Java在句法上,實在囉唆。但以筆者的經驗,即使使用其他語言和開發框架,在實戰到一定複雜程度下,其實也一樣煩爆。

而現在的Java框架中,就以Spring boot的入門門檻低。筆者從Spring boot 1.x用到現在的3.x,也真的感受到更多的簡化,所以筆者也加入一起推廣Spring boot的行列。筆者將會通過一系列最小的可執行程式,為大家講解Spring在Web和資料庫上的應用。

所以現在就不廢話,馬上開壇作法

快速下戴模版

使用Spring initializr,可以很容易就建立一個以Spring boot starter為底的java project。大家可以使用Spring 官網又或是vscode plugin 快速地建立一個maven或gradle project。筆者較為熟悉maven,就以maven起一個範例。

在使用Spring initializr有幾件事必需要指定的:

  • Spring boot version: 3.x.y 或以上
  • Language: java
  • Group Id: 請選擇有意思的域名,如果你用github,可以選 io.github.yourusername
  • artifactId: 這個範例的名字,例如commandline
  • Packaging type: 本次使用jar,日後若開發web 應用,可以使用war
  • Java version: 17或以上

之後就不用選了。若你經官網起範例,你會得到一個zip檔,下載後解壓縮。若你使用vscode插件,最後插件會叫有一個位置儲存。它們都是最後也是會得到同一樣範例Java project。

你使用Vscode,Intellij打開,IDE都會自動辨識到它是java maven project,同時會顯示java和maven結構。道理上你用Intellij 應該可以無腦開始編譯(Community 或Ultimate版都可以), Vscode有安裝Extension Pack for Java也會開始自動編譯。不想麻煩,也可以試用Github Codespaces - java。Github Codespaces其實就是一個雲上的vscode,經網頁可以連到Github VM內的vscode,所以它也會有齊Extension Pack for Java等插件。

筆者最後也會上載已完成的範例,它也可以在Github Codespaces上以Java執行或繼續開發。

打開project中的pom.xml,它為我們添加了兩個很重要的lib

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter</artifactId>
		</dependency>
		...
		...
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>
			</plugin>

spring-boot-starter是重中之重,它定義了怎樣動態地設定日後的其他lib,它是讓我們可以無腦設定的一個關鍵。(但若大家有很多客制化的設定,就要返撲歸真地逐個lib叫起)。

maven在預設情況下,只會負責編譯和打包目前的project原始碼。所有相關依賴(就是xml中的dependency),並不會自動包起。而spring-boot-maven-plugin,就是幫我們把相關依據都包在一起,讓你的jar可以獨立行起來。

註: 若大家在開發lib jar,並不是一個獨立執行的jar,也就是原始碼上沒有main函數,大家就不應該引用spring-boot-starter和spring-boot-maven-plugin。

我們繼續看其他原始碼,整個資料夾就像以下那樣。

.
|-- HELP.md
|-- pom.xml
`-- src
    |-- main
    |   |-- java
    |   |   `-- io
    |   |       `-- github
    |   |           `-- macauyeah
    |   |               `-- springboot
    |   |                   `-- tutorial
    |   |                       `-- commandline
    |   |                           `-- CommandlineApplication.java
    |   `-- resources
    |       `-- application.properties
    `-- test
        `-- java
            `-- io
                `-- github
                    `-- macauyeah
                        `-- springboot
                            `-- tutorial
                                `-- commandline
                                    `-- CommandlineApplicationTests.java

CommandlineApplication是我們有main函數的java class。我像可以經過IDE運行main又或者下指令mvn spring-boot:run來執行。

正式開始我們的Commandline開發

我們在CommandlineApplication.class中,加入新的程式碼,實現ApplicationRunner和它的函數run。

package io.github.macauyeah.springboot.tutorial.commandline;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
// other import

@SpringBootApplication
public class CommandlineApplication implements ApplicationRunner {
	static Logger LOG = LoggerFactory.getLogger(CommandlineApplication.class);

	public static void main(String[] args) {
		SpringApplication.run(CommandlineApplication.class, args);
	}

	@Override
	public void run(ApplicationArguments args) throws Exception {
		args.getOptionNames().stream().forEach(optionName -> {
			LOG.debug("option name:" + optionName);

			args.getOptionValues(optionName).forEach(optionValue -> {
				LOG.debug("option values:" + optionValue);
			});
		});
		LOG.debug("program end.");
	}
	// ...

這個run函數很直白,就是更好地演譯main中的String[] args。

但大家還要看清楚,這個main並沒有直接執行run。其實它是靠SpringApplication.run及@SpringBootApplication,跑一堆自動設定,最後因為傳入CommandlineApplication.class是一個Spring 可以處理的ApplicationRunner,所以才呼叫它的CommandlineApplication.run。

換個講法,如果今天做的是web應用,傳入去的就會是SpringBootServletInitializer,這個SpringBootServletInitializer也不一定跟main是同一個class。

如果大家有興趣,可以經過反編譯器,點入@SpringBootApplication看它的原始碼,你就可以看到它其實代表了很多自動化的東西。如果我們只做一些在同一個模組下生效的事情,《自動化》極大地降低了大家入門門檻。一般來講,如果大家不在意程式碼的複用度,比較少機會自行設定,自動化已經很有用。而隨著系統規模增加,多模組就慢慢地顯得重要,在大家了解完基本的Spring後,著者再從測試用途test case入手,為大家介紹如何手動設定。

Source Code

Commandline Application

Spring Boot 02 - 快速接入Database的選擇: Spring Data JPA

快速下戴模版

使用Spring initializr,可以很容易就建立一個以Spring boot starter為底的java project。大家可以使用Spring 官網又或是vscode plugin 快速地建立一個maven或gradle project。筆者較為熟悉maven,就以maven起一個範例。

在使用Spring initializr有幾件事必需要指定的:

  • Spring boot version: 3.x.y 或以上
  • Language: java
  • Group Id: 請選擇有意思的域名,如果你用github,可以選 io.github.yourusername
  • artifactId: 這個範例的名字,例如spring-boot-data-basic
  • Packaging type: 本次使用jar,日後若開發web 應用,可以使用war
  • Java version: 17或以上
  • Dependency: Spring Data JPA, Spring Boot DevTools

這次不像過去順利,因為這裏欠缺了Database連線資料,為了方便測試,我們先在pom.xml加入

<dependencies>
	<dependency>
		<groupId>com.h2database</groupId>
		<artifactId>h2</artifactId>
		<scope>runtime</scope>
	</dependency>
</dependencies>

h2與spring的整合很好。即使用什麼都不設定,直接運行mvn spring-boot:run,都可以成功執行了。但如果可以,在application.properties加入資料庫設定,會方便日後移植到其他常用的資料庫品版牌。

# src/main/resources/application.properties
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.url=jdbc:h2:mem:testdb;
spring.datasource.usename=random
spring.datasource.password=random

然後我們就可以做靠Spring Data JPA去生資料庫的表 (table)。Spring Data JPA預設使用的是Hibernate。假設,我們有一個表叫APPLE。我們就可以開一個class Apple和一個interface AppleRepo去接它。

// src/main/java/io/github/macauyeah/springboot/tutorial/springbootdatabasic/Apple.java
@Entity
public class Apple {
    @Id
    String uuid;
    Double weight;
	// getter setter
}

// src/main/java/io/github/macauyeah/springboot/tutorial/springbootdatabasic/AppleRepo.java
public interface AppleRepo extends JpaRepository<Apple, String>{
    // no content here
}

注意,因為不同需要,AppleRepo可能繼承不同的XXXRepository,它們大部份都是用來觸發寫入資料庫的指令。而這個也晚除了直接存取Hibnerate EntityManager的需要。

亦因為我們現在用的是h2Database,其實資料表並不存在。我們需要在執行Spring Boot時,同步先建立表,所以在application.properties 加入自動建表的設定。

# src/main/resources/application.properties
spring.jpa.generate-ddl=true
spring.jpa.hibernate.ddl-auto=update

然後在Spring Boot Context的環境下,可以隨時執行寫入的操作。

	@Autowired
	private AppleRepo appleRepo;

	public void saveApple() {
		Apple apple = new Apple();
		apple.setUuid(UUID.randomUUID().toString());
		apple.setWeight(100.0);
		appleRepo.save(apple);
	}

Source Code

spring boot data basic

因為h2Database只是用作測試用,所以spring-boot執行完,資料庫就會被刪除。而上述原始碼當中,還附上了一些dump sql的方法,至少可以讓大家驗證己儲存的結果。

Spring Boot 03 - 做好Database的模組化及測試用例

這節,我們將會使用spring-data-jpa,寫一個業務上的資料庫模組,提供資料表的存取,讓你的好同僚可以直接使用。這樣可以在多模組的環境中,減少同一個資料表在不同地方重複又重複地重定義。將來要更新,也可以使用jar檔的方式發佈。

下戴模版

我們跟上節一樣,使用Spring Initializr (Maven) 下載模版,但細節筆者就不再講啦。Dependency主要選擇

  • H2 Database
  • Spring Data JPA

對pom.xml作一些微調,並把spring-boot-start-data-jpa,h2改為只在測試中生效。

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-data-jpa</artifactId>
			<scope>test</scope>
		</dependency>
		<dependency>
			<groupId>com.h2database</groupId>
			<artifactId>h2</artifactId>
			<scope>test</scope>
		</dependency>

並把Java檔案搬一搬位置

# old location
src/main/java/io/github/macauyeah/springboot/tutorial/springbootdatatest/SpringBootDataTestApplication.java
src/main/resources/application.properties
# new location
src/test/java/io/github/macauyeah/springboot/tutorial/springbootdatatest/SpringBootDataTestApplication.java
src/test/resources/application.properties

以上的操作,主要是因為我們的目標是提供Schema,或者叫資料表規格。其他用於做連線的操作,我們不需要打包在jar內。所以把那些次要的東西都放在test資料夾中。我們這時可以先用mvn test指令,確保一切功能還是正常。

Entity folder

然後我們入正題,在pom.xml中加入hibernate-core,spring-data-jpa,

		<dependency>
			<groupId>org.springframework.data</groupId>
			<artifactId>spring-data-jpa</artifactId>
		</dependency>
		<dependency>
			<groupId>org.hibernate.orm</groupId>
			<artifactId>hibernate-core</artifactId>
		</dependency>

然後在main資料夾下加入 Entity、Repository,例如前述用過的Apple和AppleRepo,最後資料夾就像是這樣。

.
|-- pom.xml
|-- src
|   |-- main
|   |   `-- java
|   |       `-- io
|   |           `-- github
|   |               `-- macauyeah
|   |                   `-- springboot
|   |                       `-- tutorial
|   |                           `-- springbootdatatest
|   |                               |-- Apple.java
|   |                               `-- AppleRepo.java
|   `-- test
|       |-- java
|       |   `-- io
|       |       `-- github
|       |           `-- macauyeah
|       |               `-- springboot
|       |                   `-- tutorial
|       |                       `-- springbootdatatest
|       |                           |-- SpringBootDataTestApplication.java
|       |                           `-- SpringBootDataTestApplicationTests.java
|       `-- resources
|           `-- application.properties

然後我們在Test Case中使用AppleRepo

@SpringBootTest
class SpringBootDataTestApplicationTests {
	@Autowired
	AppleRepo appleRepo;

	@Test
	void contextLoads() {
		Apple apple = new Apple();
		apple.setUuid(UUID.randomUUID().toString());
		apple.setWeight(100.0);
		apple.setGravity(1000.0);
		appleRepo.save(apple);
	}
}

這個跟前述02-spring-data-jpa最大的差別,就是我們的main中只有Entity相關的Class,我們發佈jar,別人引用我們的class,別人不會解發其他不相干的商業邏輯。假如發佈02的例子,因為Spring有自動初始化Component的原因,很可能會誤觸發02中的BasicApplicationRunner.java

Source Code

spring boot data test

Spring Boot 04 - 進入 http json api 世代

本節,我們將會建立一個http服務,提供json api讓程式訪問。

下戴模版

我們跟上節一樣,使用Spring Initializr (Maven) 下載模版,但細節筆者就不再講啦。Dependency主要選擇

  • Spring Web
  • Spring Boot DevTools

下載後,可以直接運行測試,可以用指令 mvn test 或經IDE運行。Spring會至少測試下能不能成功取用預設的8080端口。

Controller

我們若要實作 http json api,需要在 spring 中加入一個類,附註為 @RestController ,那方便起見,類名我們也命名為 XXXController 吧。作為示範,我們弄一個 HomeController.java ,裏面有最常見的 http GET, POST功能。

// src/main/java/io/github/macauyeah/springboot/tutorial/springbootwebapibasic/controller/HomeController.java
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;

// ... other import

@RestController
@RequestMapping("/api")
public class HomeController {
    @GetMapping("/someRecord/{uuid}")
    public Map<String, String> readSomeRecord(@PathVariable String uuid) {
        return Map.of("ret", "your uuid:" + uuid);
    }

    @PostMapping("/someRecord")
    public Map<String, String> createSomeRecord(@RequestBody Map<String, String> requestBody) {
        HashMap<String, String> ret = new HashMap<>(requestBody);
        ret.put("ret", "got your request");
        return ret;
    }
}

HomeController裏,完整的URL 其實為:

  • GET http://localhost:8080/api/someRecord/{uuid}
  • POST http://localhost:8080/api/someRecord

URL中的api之後的路徑,都是定義在 HomeController 中,而前半的8080及context path,是使用預設值。在正式環境下,可能隨時會被重新定義。但我們做本地測試,只需要驗證預設值就可以了。

我們真的運行起程式mvn clean compile spring-boot:run,再使用最簡測試工具進行測試。Windows的朋友,可以選擇Postman作為測試,它有圖形介面。而linux的朋友,請用curl,預設安裝都會有。下列為方便表示測試參數,筆者選用curl。

測試GET,其中1234會自動對應到spring裏的uuid。

curl http://localhost:8080/api/someRecord/1234

# return
{"ret":"your uuid:1234"}

測試 POST,其中的 -d 參數,會對應 spring裏的 @RequestBody, -H 參數則是設定 http header 的意思,我們就使用約定俗成的 json 作為 header 。

curl -X POST http://localhost:8080/api/someRecord -H "Content-Type: application/json" -d '{"requst":"did you get it?"}'

# return
{"requst":"did you get it?","ret":"got your request"}

上面的兩個操作,都回傳了我們輸入的資訊,這代表了我們成功用spring架起了http json api,而且正常讀入資訊。

Test Case

雖然我們可以正常地架起 api,但每次開發都要 postman / curl這種工具額外試一次,其實也有一些成本。而且 api 數量變大,或經多次修改後,就重複人手執行,就變得相當討厭。

面對這個問題,筆者會建議寫測試用例,即是Test Case,而且用Spring內置的@SpringBootTest來寫。

產生一個空的Test類,vscode中,最簡單可以Source Action => Generate Test,然後加入這次要測試的參數。

// src/test/java/io/github/macauyeah/springboot/tutorial/springbootwebapibasic/controller/HomeControllerTest.java
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.RequestBuilder;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultHandlers;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;

@SpringBootTest
@AutoConfigureMockMvc
public class HomeControllerTest {
    @Autowired
    private MockMvc mockMvc;

    @Test
    void testGetSomeRecord() throws Exception {
        RequestBuilder requestBuilder = MockMvcRequestBuilders.get("/api/someRecord/1234")
                .contentType(MediaType.APPLICATION_JSON);
        this.mockMvc.perform(requestBuilder)
                .andExpect(MockMvcResultMatchers.jsonPath("$.ret").value("your uuid:1234"))
                .andDo(MockMvcResultHandlers.print());
    }

    @Test
    void testPostSomeRecord() throws Exception {
        String request = """
                {"requst":"did you get it?"}
                    """;
        RequestBuilder requestBuilder = MockMvcRequestBuilders.post("/api/someRecord")
                .contentType(MediaType.APPLICATION_JSON)
                .content(request);
        this.mockMvc.perform(requestBuilder)
                .andExpect(MockMvcResultMatchers.jsonPath("$.requst").value("did you get it?"))
                .andExpect(MockMvcResultMatchers.jsonPath("$.ret").value("got your request"))
                .andDo(MockMvcResultHandlers.print());
    }
}

最後就是執行 mvn test 或經IDE運行,應該都會得到所有測試都通過的結果。

mvn test
# other test result ...
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.368 s -- in io.github.macauyeah.springboot.tutorial.springbootwebapibasic.controller.HomeControllerTest
# other test result ...

上面的程式碼很多,我們逐一來。

  • @SpringBootTest 寫在類的外面,代表執行這個測試類時,需要運行起整個Spring程序,當然也包括http的部份。
  • @AutoConfigureMockMvc 寫在類的外面,代表執行這個測試類時,可以模擬一些發向自己的 http 請求。
  • @Autowired private MockMvc mockMvc 寫在類的裏面,因為之前有定義了可以模擬 http 的請求,Spring在運行時為大家提供了那個所謂的模擬http client的實例。
  • MockMvcRequestBuilders,則是建造要測試的URL及Header參數。
  • MockMvcResultMatchers,則是檢查回傳的結果是否如遇期的一樣。
  • 為何這個http client叫模擬 - Mock ? 因為在測試用例中,可能連Controller 內部依賴組件也需要進一步模擬,這樣才能把測試目標集中在Controller裏,這也是單元測試的原意。只是本次的例子看不出模擬與否的差別。而且它還模擬了很多東西,例如權限,只是本篇沒有演示怎麼做。權限功能等將在後述的篇章中解紹。
  • MockMvcResultMatchers.jsonPath(),這是用來檢測json的結構是否跟預期一樣。有些網路上的其他例子會簡寫成 jsonPath() ,但因為vscode IDE的自動import功能比較差,筆者還是保留傳統的寫法。

如果大家覺得@SpringBootTest很難,想折衷地把其他測試方法,那麼把 postman / curl好好管理起來,每次修改完程式,都完整地執行一次 postman / curl ,也可以達到測試的效果。只不過大家還是要好好學會整合 postman / curl,知道如何檢測json結構,什麼時候有錯,什麼時候叫測試通過,所以也要花一樣功夫來實現。

最後,大家千萬不要因為測試難寫而逃課,因為寫測試絕對地可以減輕日後重執行的工作量。除非你的程式碼即用即棄,否則都建議寫測試。(測試跟寫文檔不一樣,有了測試也不能沒有文檔。好消息的是,文檔現在越來越多自動生成的工具,我們日後再找機會介紹。)

Source Code

spring boot web api basic

Spring Boot 05 - 為 http json api 加入登入要求

本節,我們將為之前的http服務,加入認證機制,只有在資料庫現存的用戶可以登入及訪問我們的json api。

下戴模版

慣例,我們用Spring Initializr (Maven) 下載模版,Dependency主要選擇

  • Spring Web
  • Spring Boot DevTools
  • Spring Security

Controller

跟上節一樣,我們起一個Controller,為簡化測試,我們只做http GET api。

//src/main/java/io/github/macauyeah/springboot/tutorial/springbootwebapidata/controller/HomeController.java
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api")
public class HomeController {
    @GetMapping("/someRecord/{uuid}")
    public Map<String, String> readSomeRecord(@PathVariable String uuid) {
        return Map.of("ret", "your uuid:" + uuid);
    }
}

準備我們的test case,但這次我們預期它應該要出現登入失敗的結果。

//src/test/java/io/github/macauyeah/springboot/tutorial/springbootwebapidata/controller/HomeControllerTest.java
@SpringBootTest
@AutoConfigureMockMvc
public class HomeControllerTest {
    @Autowired
    private MockMvc mockMvc;

    @Test
    void testNoLogin() throws Exception {
        RequestBuilder requestBuilder = MockMvcRequestBuilders.get("/api/someRecord/1234")
                .contentType(MediaType.APPLICATION_JSON);
        this.mockMvc.perform(requestBuilder)
                .andExpect(MockMvcResultMatchers.status().is4xxClientError())
                .andExpect(MockMvcResultMatchers.jsonPath("$.ret").doesNotExist())
                .andDo(MockMvcResultHandlers.print());
    }
}

在我們執行上述的測試,test case 成功過了。我們的基本設定跟上一節其實沒有多大改動,為何現在http api會回傳狀態 401?

那是因為我們在依賴中加了,Spring Security,它配合了Spring Web,就會自動為所有api加入權限檢測。我們的測試中,沒有任何用戶登入,當然會出現 http 401。為了讓我們可以好好管理誰可以使用api,我們就來設定一定Security。

我們加一個WebSecurityConfig.java,暫時指定所有的訪問路徑都必需有USER權限,並且用 http basic的方式登入。

//src/main/java/io/github/macauyeah/springboot/tutorial/springbootwebapidata/config/WebSecurityConfig.java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(authorizeHttpRequests -> {
            authorizeHttpRequests.requestMatchers("/**").hasRole("USER");
            // 所有的訪問路徑都必需有USER權限
        });
        http.httpBasic(Customizer.withDefaults());
        // 使用http basic作為登入認證的方式
        return http.build();
    }
}

上述例子,只是擋了沒有權限的人,我們還需要讓有登入身份的用戶可以成得取限User權限。

我們繼續修改,WebSecurityConfig,加入只在記憶體有效的InMemoryUser

import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;

import org.springframework.security.provisioning.InMemoryUserDetailsManager;

public class WebSecurityConfig {
    //..
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
        // 我們的密碼不應該明文儲,比較保險,我們使用BCrypt演算法,為密碼做單向加密。
    }
    @Bean
    public UserDetailsService userDetailsService() {
        UserDetails user = User.withUsername("admin")
                .password(passwordEncoder().encode("pass"))
                .roles("USER").build();
        // 我們在記憶中體,加入一個測試用的User,它的名字為admin,密碼為pass,權限為User
        return new InMemoryUserDetailsManager(user);
    }

    

然後加入新的測試,直接模擬Role。結果是通過的。

//src/test/java/io/github/macauyeah/springboot/tutorial/springbootwebapidata/controller/HomeControllerTest.java
    @Test
    void testLoginWithRoles() throws Exception {
        RequestBuilder requestBuilder = MockMvcRequestBuilders.get("/api/someRecord/1234")
                .contentType(MediaType.APPLICATION_JSON).with(
                        SecurityMockMvcRequestPostProcessors.user("someone")
                                .roles("USER", "ADMIN"));
                                // 沒有使用密碼,只使用Role
        this.mockMvc.perform(requestBuilder)
                .andExpect(MockMvcResultMatchers.status().is2xxSuccessful())
                .andExpect(MockMvcResultMatchers.jsonPath("$.ret").value("your uuid:1234"))
                .andDo(MockMvcResultHandlers.print());
    }

再來一個測試,改用密碼登入,分別輸入錯的和正確的密碼。

    @Test
    void testLoginWithWrongPasswordAndNoRole() throws Exception {
        RequestBuilder requestBuilder = MockMvcRequestBuilders.get("/api/someRecord/1234")
                .header("Authorization", "Basic randompass")
                // 輸入錯的密碼,應該回傳http 401 Unauthorized
                .contentType(MediaType.APPLICATION_JSON);
        this.mockMvc.perform(requestBuilder)
                .andExpect(MockMvcResultMatchers.status().is4xxClientError())
                .andDo(MockMvcResultHandlers.print());
    }

    @Test
    void testLoginWithPassword() throws Exception {
        RequestBuilder requestBuilder = MockMvcRequestBuilders.get("/api/someRecord/1234")
                .header("Authorization", "Basic YWRtaW46cGFzcw==")
                // http basic 就是把 admin:pass 轉成base64
                .contentType(MediaType.APPLICATION_JSON);
        this.mockMvc.perform(requestBuilder)
                .andExpect(MockMvcResultMatchers.status().is2xxSuccessful())
                .andExpect(MockMvcResultMatchers.jsonPath("$.ret").value("your uuid:1234"))
                .andDo(MockMvcResultHandlers.print());

    }

最後,當然是正確的密碼才能通過。若果大家還是半信半疑,我們可以跑起真的正服務(IDE RUN或mvn spring-boot:run),然後用curl去試。

curl http://localhost:8080/api/someRecord/1234
// failed with 401
curl -u "admin:pass" http://localhost:8080/api/someRecord/1234
// successed

使用SQL Database讀取用戶登入資訊

一般而言,我們不可能把所有用戶登資訊打在InMemoryUser中,通常背後有一個資料庫儲存所有的用戶資訊,我們在登入時,讀取它來做對比檢證。

為此,我們在maven中,加入

  • Spring Data JPA
  • h2 database (或任何你的資料庫,如mysql 、 sql server)

最後一步,我們把InMemoryUser去掉,改為從資料庫讀取。因為原始碼太多,就不全部貼上。最主要的是WebSecurityConfig.java要關掉之前的UserDetailsService,改為提供一個UserServiceImpl類,它會實現UserDetailsService的功能。

@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
    // 把原來的Bean先變成註解,其他不變

    // @Bean
    // public UserDetailsService userDetailsService() {
    //     UserDetails user = User.withUsername("admin")
    //             .password(passwordEncoder().encode("pass"))
    //             .roles("USER").build();

    //     return new InMemoryUserDetailsManager(user);
    // }
}
// spring-boot-tutorial/spring-boot-web-api-data/src/main/java/io/github/macauyeah/springboot/tutorial/springbootwebapidata/config/UserServiceImpl.java

// other import
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;

@Service
public class UserServiceImpl implements UserDetailsService {
    @Autowired
    PasswordEncoder passwordEncoder;
    @Autowired
    UserRepo userRepo;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 因為我們資料庫沒有資料,為了方便測試密碼的加密,我們在java code上直接插入一筆資料。
        UserEntity defaultUser = new UserEntity();
        defaultUser.setUsername("admin");
        defaultUser.setPassword(passwordEncoder.encode("pass"));
        defaultUser.setRole("USER");
        defaultUser.setUuid(UUID.randomUUID().toString());
        userRepo.save(defaultUser);
        // 上述為測試用插入資料,不應該出現在正式使用環境中。


        UserEntity user = userRepo.findOneByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException(username + " not found"));
        // 找找資料庫有沒有正在登入的該名使用者username
        List<SimpleGrantedAuthority> authorities = List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole()));
        LOG.debug("got user uuid:{}, username:{}, role:{} from database", user.getUuid(), username, user.getRole());
        // 如果前面的 findOneByUsername 有結果回傳,我們就給它一個ROLE_XXX的權限。
        return new User(username, user.getPassword(), authorities);
        // 這裏從沒有檢查過密碼是否有匹配,全部交給Spring Security去做
    }
}

//spring-boot-tutorial/spring-boot-web-api-data/src/main/java/io/github/macauyeah/springboot/tutorial/springbootwebapidata/entity/UserEntity.java
// spring-boot-tutorial/spring-boot-web-api-data/src/main/java/io/github/macauyeah/springboot/tutorial/springbootwebapidata/repo/UserRepo.java

上述段落中,筆者省略了UserEntity和UserRepo,它們只是一般的spring-data-jpa概念,有需要可以經文末的連結查看完全原始碼。最需要注意的,是UserEntity的password欄位,在資料庫中是以加密的方式儲存。我們在配匹登入者與資料庫記錄時,也沒有自行檢驗密碼的需要。我們只是在加密過的密碼回傳給Spring Security,Spring框架會自行把登入者輸入的密碼與加密了的密碼作比較。

Source Code

spring boot web api data

Spring Boot 06 - Spring Boot Web 調試工具

之前兩節,都一直在講怎樣寫code,也介紹了Test Case的好。若為初次接觸,Spring有很多設定需要摸索,若開始時就設定錯誤,對不少人來講都會有很大打擊。在這裏,筆者就介紹一些vscode和spring的工具,可以讓IDE多幫忙一下,減少走歪路的機會。

vscode插件

以下兩個插件,都在於提示用戶設定。

  • Spring Boot Dashboard (vscjava.vscode-spring-boot-dashboard)
    • 可以那它來運作spring boot app,省去找尋main 位置的麻煩
    • 綜覽整個程式中的所有Bean (Bean是一個很重要的元素,日後會再提及)
    • 若程式為Spring boot web,可以顯示所 http endpoint。
  • Spring Boot Tools (vmware.vscode-spring-boot)
    • 檢查設定檔的設定值有沒有寫錯 (application*.properties, application*.yml)
    • 綜覽檔案中的有以@為首的與spring相關的元素(檔案很大時就會有用)
    • 可以在IDE運行spring時,查看@元素的bean資訊 (not works ?, 加了actuator也是沒有看見)
  • Spring Initializr(vscjava.vscode-spring-initializr)
    • 經網絡初始化spring 專案的依賴引用設定
  • Maven for Java (vscjava.vscode-maven)
    • 若大家在使用Spring Initializr時,選取了maven作管理工具,那麼這插件就可以在後續幫忙更新引用。
    • 若專案的Spring 及㡳層引用有變,vscode也需要它來引用更新。
    • 這是java 開發工具包(vscjava)的其中一員,它的其他插件也可以順帶安裝。

調試工具 - open api / swagger-ui

如果我們在開發Web http API ,其實都是為了該某個客戶端使用。但如果該客端明白我們的API該怎樣使用,大家總不會逐個連結,自行編寫使用手冊及範例吧。所以就有了open api 和 swagger-ui 的誕生 。

open api,就是一個公認的使用手冊標準,我們只要在spring-web中加入 springdoc-openapi-starter-webmvc-ui 的程式庫,就可以自動為我們的controller 生成 open api 的說明檔。

更強大的是,這個程式庫可以利用剛生成的open api,配上 swagger-ui ,自動生成一個可供測試的頁面。這個頁面可以供碼農們直接操作,也會產生對應的 curl 指令,讓碼農們可以在任何的主機上重複。這樣,那麼是沒有太多解釋的說明文檔也可以使用。

做法很簡單,在pom.xml中加入依賴。

    <dependency>
        <groupId>org.springdoc</groupId>
        <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
        <version>2.5.0</version>
    </dependency>

然後我們就可以加入Controller,運行 Spring 後,我們可以在 http://localhost:8080/swagger-ui/index.html 找到 swagger 的頁面,然後就可以在 ui 上測試API了。

躲在Proxy背後的 swagger

如果你跟筆者一樣,使用 code-server 或 github codespaces ,你就不能很隨意地連接到 8080 端口,只能經過Http Proxy去訪問。這樣 open api的原有的設定就不合用了。

這時我們需要自行修改 open api 的 bean,加入我們真正的根路徑。然後筆者使用 code-server,而IDE只會在port 9000上執行,它對外的前置路徑會是 http://localhost:9000/proxy/8080/

@Bean
public OpenAPI customOpenAPI() {
    Server server = new Server();
    server.setUrl("http://localhost:9000/proxy/8080/");
    return new OpenAPI().servers(List.of(server));
}

現在訪問 http://localhost:9000/proxy/8080/swagger-ui/index.html,還是會有一些問題,你會看到 "Failed to load remote configuration." 。但你此時可以在 "explore" 搜尋欄位內貼上 http://localhost:9000/proxy/8080/v3/api-docs,再一次搜尋檔案,就回復正常了。

註:如果你熟習Nginx這類Reverse Proxy 的概念,你的環境可以直接修改 Request Header,可以在Proxy中加入X-Forwarded-*,就不用煩惱寫Java Bean了,也不用手動在explore裏重新修正api-docs的位置。詳見 https://springdoc.org/index.html#how-can-i-deploy-springdoc-openapi-starter-webmvc-ui-behind-a-reverse-proxy

Controller的繼承

Spring Controller的 @ 標記 (Annotation) ,其實支援繼承的。經Spring 生成的 api docs,也有如何效果。例如以下程式碼

public class ParentController {
    @GetMapping("/postfix")
    public String postfix(){
        return "this is postfix";
    }
}

@RestController
@RequestMapping("/api")
public class ChildController extends ParentController {
    @GetMapping("/direct")
    public String directCall() {
        return "direct result";
    }
}

在ChildController的實例中,它會有兩個API,分別是

  • /api/direct
  • /api/prefix

它支援Java Function Overwrite(覆寫),但不能改 @ 標記,以下就是一個錯的例子

@RestController
@RequestMapping("/api")
public class ChildController extends ParentController {
    @GetMapping("/Overwrite") // 把這個 @ 行刪了才能正常執行
    public String postfix(){
        return "this is Overwrite";
    }
}

Source Code

spring boot web api doc

Spring Boot 06 - 回顧 Spring Web + Spring Data 架構

前述幾章,一直跟著範例來寫,道理上都不難。在繼續講解其他功能之前,我們還是保險地把過去的資訊整理好,方便大家在此基礎上開支散葉。

最簡流程圖

graph TD;
    application[一個有main function,而且有@SpringBootApplication 附註的class];
    controller[所有@Controller附註的class會被轉成Http Servlet];
    repository["所有繼承 CurdRepositry 或 JpaRepository 的Interface"];
    repoInstance["CurdRepositry 或 JpaRepository 的實例"];
    entity["含有@Entity附註的class"];
    businessLogic["程序員根據業務需求,以某個方式生成、更新、刪除Entity的實例,並經過 JpaRepository 寫到資料庫中"];
    dbTable["資料庫Table(表)"];
    SpringBootServletInitializer[一個繼承SpringBootServletInitializer的class];
    application-->|自動偵測|SpringBootServletInitializer;
    SpringBootServletInitializer-->|自動設定|controller;
    application-->|自動偵測|repository;
    controller-->|引用實列|repoInstance;
    repository-->|"一對一操作 (Save, Delete, Find)"|entity;
    repository-->|自動設定|repoInstance;
    businessLogic<-->|讀寫|dbTable;
    controller-->businessLogic;
    repoInstance-->businessLogic;
    entity-->businessLogic;

@Controller會自動生成http endpoint,@Entity則會對應生成資料庫的表。我們可以在任何Class中,包括Controller,經過自動註冊,叫Repository去記錄@Entity。

在Spring中,@Service,@Bean都被。

controller, service, test what,

inheritance?

no test if inheritance?