[DevOps] 手把手帶您輕鬆管理 Windows 服務 (Windows Service) — 從建立到授權完整指南

在 Windows 環境下運行應用程式時,將其註冊為系統服務能大幅提升管理效率。
這篇文章將介紹 NSSM(Non-Sucking Service Manager)這個強大工具,
手把手教你如何快速建立 Windows 服務。
除了介紹 NSSM 與基礎的服務建立步驟,我們還會帶你瞭解 Windows 服務背後的權限原理,
探討如何安全地授予一般權限使用者開啟或關閉指定服務的權限,讓團隊協作更靈活,DevOps 流程更順暢。無論你是初學者或經驗豐富的系統管理員,這篇文章都能幫助你提升 Windows 服務管理的效率,讓你對 Windows 服務有更近一步的瞭解。

TL;DR

使用 NSSM (Non-Sucking Service Manager) 註冊服務
nssm 為了以後設定方便,使用指令來操作

假設我有一個 Windows 服務,名叫 MyService 你可以這麼做
(執行以下指令需使用 cmd 系統管理員權限)

nssm install "MyService" "C:\Java\bin\java.exe" "-jar C:\MyService\app.jar"
nssm set MyService AppDirectory "C:\MyService\"
nssm set MyService Description "This is my service"

每行指令說明:

  • 使用 nssm 註冊服務,用雙引號把所需要的參數括弧包起來
  • 設定程式起始路徑
  • 設定服務的說明

然後是最關鍵的調整權限:
(使用系統管理員的 Powershell 來執行)

Adjust-ServicePermissions.ps1 -Username myuser -ServiceName MyService

程式片段在此:
https://gist.github.com/j796160836/72346b43a315055caeebb69d7c3db76f

用法很簡單,就二個參數:

  • Username 帶入指定的使用者(一般使用者)的帳號名稱
  • ServiceName 帶入指定的 Windows 服務名稱

會給你對應的提示,按下 y 開始執行套用。

這邊已經把程式用 Script 包裝好了,帶入所需的參數即可。

接下來,我們來細講這些東西


nssm 服務管理器介紹與使用

nssm 當初的取名很有趣:Non-Sucking Service Manager
因為原作者覺得Windows 內建的 Windows 服務註冊工具實在都太難用了,
太爛、太 Suck 了!所以原作者想寫一個不難用的 (Non-Sucking) 服務管理器,故得名。

大部分的一般應用程式,都可以用 nssm 來註冊 Windows 服務。
nssm 他有 GUI 圖形介面,但為了以後設定方便,甚至做成 init scripts,建議還是使用指令來操作,以下也都是介紹指令。

你可以用以下的操作:

nssm 註冊安裝服務

假設你要單獨執行的指令 (測試指令) 如下:

C:\Java\bin\java.exe -jar C:\MyService\app.jar

註冊服務

欲把上述指令註冊成 Windows 服務,名字叫做 MyService
(這邊要用 cmd 系統管理員執行)

nssm install MyService "C:\Java\bin\java.exe" "-jar C:\MyService\app.jar"

要用雙引號把所需要的參數括弧包起來

設定服務起始路徑 (Startup Path)

設定程式起始路徑
(這邊要用 cmd 系統管理員執行)

nssm set MyService AppDirectory "C:\MyService\"

設定服務說明

設定 MyService 服務的說明
(這邊要用 cmd 系統管理員執行)

nssm set MyService Description "This is my service"

這二個指令都很直覺,就不細講了

移除服務

如果不小心弄錯了,可以用這個指令解除註冊
(這邊要用 cmd 系統管理員執行)

nssm remove MyService

實測只限於使用 nssm 註冊的服務


授予一般使用者開關指定服務的權限(手動步驟)

通常來說,Windows 服務只能管理員帳號 (Administrators)
才能做開關,但這樣權限實在太大了

基於「最小化權限原則」,我們能不能讓一般使用者,針對特定服務授予開啟與關閉的權限呢?

答案是可以的!但實在有點複雜…
我們先列出手動操作的步驟。

Step 1. 列出使用者的 sid

在 cmd 執行該指令,取得 sid

wmic useraccount where name='Tony' get sid

(假設你建立的一般使用者叫做 Tony)

記錄一下過程

wmic useraccount where name='Tony' get sid

SID
S-1-5-xx-xxxxxxxx-xxxxxxxxx-xxxxxxxxxx-xx07

你就會得到類似這樣的 SID 先記下來備用

Step 2. 列出預設權限

先記下預設權限,這很重要 (以下呈現的結果供參考,以你實際的為主)

在 cmd 執行該指令,列出指定服務的權限

sc sdshow myService

D:(A;;CCLCSWRPWPDTLOCRRC;;;SY)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)(A;;CCLCSWLOCRRC;;;IU)(A;;CCLCSWLOCRRC;;;SU)

在 cmd 執行該指令,列出 Service Control Manager (SCM) 的權限

sc sdshow SCMANAGER

D:(A;;CC;;;AU)(A;;CCLCRPRC;;;IU)(A;;CCLCRPRC;;;SU)(A;;CCLCRPWPRC;;;SY)(A;;KA;;;BA)(A;;CC;;;AC)

Step 3. 手工調整權限

主要觀念就是:
把預設的權限都留下,這些是系統管理員 (Administrator) 使用的。
我們手動再加上我們需要的權限

權限分為 D: 開頭的區域與 S: 開頭的區域,在拼組字串時要注意

我們把預設權限

D:(A;;CCLCSWRPWPDTLOCRRC;;;SY)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)(A;;CCLCSWLOCRRC;;;IU)(A;;CCLCSWLOCRRC;;;SU)

D: 開頭的區域,加上這段

(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;S-1-5-xx-xxxxxxxx-xxxxxxxxx-xxxxxxxxxx-xx07)

這邊的 S-1-5-xx-xxxxxxxx-xxxxxxxxx-xxxxxxxxxx-xx07 就是剛剛查的 sid
記得要保留 S: 開頭的區域

變成這樣

D:(A;;CCLCSWRPWPDTLOCRRC;;;SY)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)(A;;CCLCSWLOCRRC;;;IU)(A;;CCLCSWLOCRRC;;;SU)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;S-1-5-xx-xxxxxxxx-xxxxxxxxx-xxxxxxxxxx-xx07)S:(AU;FA;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;WD)

串上 sc sdset 指令,變成這樣
(這段需要系統管理員的命令提示字元 (cmd) 才能執行)

sc sdset myService D:(A;;CCLCSWRPWPDTLOCRRC;;;SY)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)(A;;CCLCSWLOCRRC;;;IU)(A;;CCLCSWLOCRRC;;;SU)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;S-1-5-xx-xxxxxxxx-xxxxxxxxx-xxxxxxxxxx-xx07)S:(AU;FA;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;WD)

SCMANAGER 的部分也是如此

預設權限

D:(A;;CC;;;AU)(A;;CCLCRPRC;;;IU)(A;;CCLCRPRC;;;SU)(A;;CCLCRPWPRC;;;SY)(A;;KA;;;BA)(A;;CC;;;AC)

D: 開頭的區域,加上這段

(A;;CCLCSWRPWPDTLOCRRC;;;S-1-5-xx-xxxxxxxx-xxxxxxxxx-xxxxxxxxxx-xx07)

變成

D:(A;;CC;;;AU)(A;;CCLCRPRC;;;IU)(A;;CCLCRPRC;;;SU)(A;;CCLCRPWPRC;;;SY)(A;;KA;;;BA)(A;;CC;;;AC)(A;;CCLCSWRPWPDTLOCRRC;;;S-1-5-xx-xxxxxxxx-xxxxxxxxx-xxxxxxxxxx-xx07)

最後串上 sc sdset 指令,並執行
(這段需要系統管理員的命令提示字元 (cmd) 才能執行)

sc sdset SCMANAGER D:(A;;CC;;;AU)(A;;CCLCRPRC;;;IU)(A;;CCLCRPRC;;;SU)(A;;CCLCRPWPRC;;;SY)(A;;KA;;;BA)(A;;CCLCSWRPWPDTLOCRRC;;;S-1-5-xx-xxxxxxxx-xxxxxxxxx-xxxxxxxxxx-xx07)

Step 4. 驗證

使用 cmd 指令執行開啟服務,關閉服務

net start MyService
net stop MyService

或使用 PowerShell:

Start-Service -Name MyService
Stop-Service -Name MyService

應該都要能夠執行

再來使用 Ansible 來測試

playbook.yml

- name: Windows Service testing
  hosts: jenkins
  gather_facts: no
  tasks:
    - name: Stop service
      ansible.windows.win_service:
        name: MyService
        state: stopped
    - name: Start service
      ansible.windows.win_service:
        name: MyService
        start_mode: delayed
        state: started

inventory

[jenkins]
192.168.1.3 ansible_user=MY_USERNAME ansible_password='MY_PASSWORD' ansible_connection=winrm ansible_winrm_transport=basic ansible_winrm_server_cert_validation=ignore ansible_port=5985

執行 ansible playbook

export ANSIBLE_HOST_KEY_CHECKING=False && ansible-playbook -v -i inventory playbook.yml

授予一般使用者開關指定服務的權限(程式步驟)

剛剛以上很複雜的步驟,我已經幫你包成 Adjust-ServicePermissions.ps1 程式了
(使用系統管理員的 Powershell 來執行)

Adjust-ServicePermissions.ps1 -Username myuser -ServiceName MyService

程式片段在此:
https://gist.github.com/j796160836/72346b43a315055caeebb69d7c3db76f

用法二個參數:

  • Username 帶入指定的使用者(一般使用者)的帳號名稱
  • ServiceName 帶入指定的 Windows 服務名稱

會給你對應的提示,按下 y 開始執行套用。

如果有成功的話,恭喜你!與自動化更近一步!

Troubleshooting

補充一下,若執行 Powershell 遇到權限問題

PS C:\Users\user\Downloads> .\Adjust-ServicePermissions.ps1
.\Adjust-ServicePermissions.ps1 : C:\Users\user\Downloads\Adjust-ServicePermissions.ps1 檔案無法載入。檔案 C:\Users
\user\Downloads\Adjust-ServicePermissions.ps1 未經數位簽署。您無法在目前的系統上執行此指令碼。如需有關執行指令碼及
設定執行原則的詳細資訊,請參閱 about_Execution_Policies (網址為 http://go.microsoft.com/fwlink/?LinkID=135170)。
位於 線路:1 字元:1
+ .\Adjust-ServicePermissions.ps1
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : SecurityError: (:) [], PSSecurityException
    + FullyQualifiedErrorId : UnauthorizedAccess

那你就在 Powershell 設定這個
(使用系統管理員的 Powershell 來執行)

Set-ExecutionPolicy RemoteSigned

SDDL (安全性描述元定義語言) 學習

這邊我們就深入探討一下 SDDL (Security Descriptor Definition Language, 安全性描述元定義語言) 與剛剛講的這些東西,若沒有要深入調整也沒關係,瞭解原理總是好的。
安全描述符 (Security Descriptor) 定義了服務的權限,包括了誰可以存取服務以及可以執行哪些操作。

SDDL 結構

SDDL 字串由兩個主要部分組成:

  1. DACL (Discretionary Access Control List):以 D: 開頭,定義了對象的存取控制條目 (ACE)。
  2. SACL (System Access Control List):以 S: 開頭,定義了審核條目。

DACL 部分

D: 開頭的字串表示 DAC (Discretionary Access Control List),後面是一組 ACE (Access Control Entries, 對象的存取控制條目),每個 ACE 定義了誰擁有什麼權限。

ACE 結構

每個 ACE 的結構如下:

(A;;<Permissions>;;;<SID>)
  • A:表示這是一個允許 (Allow) 的 ACE。
  • <Permissions>:定義授予的權限。
  • <SID>:定義授權的安全主體 (Security Identifier)。

分析自行建立的服務 (myService) 的權限

這是剛剛 sc sdshow myService 所出現的字串

D:(A;;CCLCSWRPWPDTLOCRRC;;;SY)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)(A;;CCLCSWLOCRRC;;;IU)(A;;CCLCSWLOCRRC;;;SU)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;S-1-5-xx-xxxxxxxx-xxxxxxxxx-xxxxxxxxxx-xx07)S:(AU;FA;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;WD)

分析 DACL (myService)

我們把 D: 開頭的部分拿出來

D:(A;;CCLCSWRPWPDTLOCRRC;;;SY)
  (A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)
  (A;;CCLCSWLOCRRC;;;IU)
  (A;;CCLCSWLOCRRC;;;SU)
  (A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;S-1-5-xx-xxxxxxxx-xxxxxxxxx-xxxxxxxxxx-xx07)
  1. (A;;CCLCSWRPWPDTLOCRRC;;;SY)

    • SY:表示 LocalSystem 帳戶。
    • CCLCSWRPWPDTLOCRRC:這是一組權限,分別代表:
      • CC:建立子項目(CREATE_CHILD)。
      • LC:列出子項目(LIST_CHILDREN)。
      • SW:寫入(SELF_WRITE)。
      • RP:讀取參數(READ_PROPERTY)。
      • WP:寫入參數(WRITE_PROPERTY)。
      • DT:刪除樹(DELETE_TREE)。
      • LO:列出項目(LIST_OBJECT)。
      • CR:控制存取(CONTROL_ACCESS)。
      • RC:讀取安全描述元(READ_CONTROL)。
    • 簡而言之:LocalSystem 帳戶擁有完全控制權限。
  2. (A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)

    • BA:表示 Administrators 群組。
    • CCDCLCSWRPWPDTLOCRSDRCWDWO:這是一組更高級的權限,包含:
      • CC:建立子項目(CREATE_CHILD)。
      • DC:刪除子項目(DELETE_CHILD)。
      • LC:列出子項目(LIST_CHILDREN)。
      • SW:寫入(SELF_WRITE)。
      • RP:讀取參數(READ_PROPERTY)。
      • WP:寫入參數(WRITE_PROPERTY)。
      • DT:刪除樹(DELETE_TREE)。
      • LO:列出項目(LIST_OBJECT)。
      • CR:控制存取(CONTROL_ACCESS)。
      • SD:刪除(STANDARD_DELETE)。
      • RC:讀取安全描述元(READ_CONTROL)。
      • WD:修改存取控制清單(WRITE_DAC)。
      • WO:修改擁有者(WRITE_OWNER)。
    • 簡而言之:Administrators 群組擁有完全控制權限。
  3. (A;;CCLCSWLOCRRC;;;IU)

    • IU:表示互動使用者 (Interactive Users)。
    • CCLCSWLOCRRC:這是一組有限的權限,允許讀取和列舉操作。
      • CC:建立子項目(CREATE_CHILD)。
      • LC:列出子項目(LIST_CHILDREN)。
      • SW:寫入(SELF_WRITE)。
      • LO:列出項目(LIST_OBJECT)。
      • CR:控制存取(CONTROL_ACCESS)。
      • RC:讀取安全描述元(READ_CONTROL)。
    • 簡而言之:互動使用者擁有基本的讀取和列舉權限。
  4. (A;;CCLCSWLOCRRC;;;SU)

    • SU:表示服務使用者 (Service Users)。
    • CCLCSWLOCRRC:同上,與互動使用者相同的權限。
    • 簡而言之:服務使用者擁有基本的讀取和列舉權限。
  5. (A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;S-1-5-xx-xxxxxxxx-xxxxxxxxx-xxxxxxxxxx-xx07)

    • 這組是我們新增的權限
    • S-1-5-xx-xxxxxxxx-xxxxxxxxx-xxxxxxxxxx-xx07:就是剛剛查詢的 sid。
    • CCDCLCSWRPWPDTLOCRSDRCWDWO:同 Administrators,就不贅述了。
    • 簡而言之:新增這個一般使用者,擁有完全控制權限,也是我們要達到的效果。

SACL 部分 (myService)

S: 表示 SACL (System Access Control List),定義了審核條目。

我們把 S: 開頭的部分拿出來

S:(AU;FA;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;WD)

SACL 結構

(AU;FA;<Permissions>;;;<SID>)
  • AU:表示審核條目 (Audit Entry)。
  • FA:表示完全瀏覽 (Full Access)。
  • <Permissions>:定義需要審核的權限。
  • <SID>:定義審核的對象。

分析 SACL

SACL 就相對沒那麼重要,不過還是帶一下

S:(AU;FA;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;WD)
  • AU:表示審核條目。
  • FA:表示完全瀏覽。
  • CCDCLCSWRPWPDTLOCRSDRCWDWO:定義了需要審核的權限,與 Administrators 的權限相同。
  • WD:表示 Everyone 群組。
  • 簡而言之:對 Everyone 群組的所有操作進行審核。

分析 SCMANAGER 的權限

接下來繼續看 sc sdshow SCMANAGER 命令顯示的安全描述符定義語言 (SDDL) 字串。

SCMANAGER 是服務控制管理器 (Service Control Manager)

D:(A;;CC;;;AU)(A;;CCLCRPRC;;;IU)(A;;CCLCRPRC;;;SU)(A;;CCLCRPWPRC;;;SY)(A;;KA;;;BA)(A;;CC;;;AC)S:(AU;FA;KA;;;
WD)(AU;OIIOFA;GA;;;WD)

讓我們逐段分析這些字串。

DACL 部分 (SCMANAGER)

DACL 包含了多個瀏覽控制項 (ACE),每個項目用括號包圍:

D:(A;;CC;;;AU)
  (A;;CCLCRPRC;;;IU)
  (A;;CCLCRPRC;;;SU)
  (A;;CCLCRPWPRC;;;SY)
  (A;;KA;;;BA)
  (A;;CC;;;AC)
  (A;;CCLCSWRPWPRC;;;S-1-5-xx-xxxxxxxx-xxxxxxxxx-xxxxxxxxxx-xx07)
  1. (A;;CC;;;AU)

    • A:允許 (Allow)
    • CC:建立子項目(CREATE_CHILD)權限
    • AU:已驗證的用戶 (Authenticated Users)
  2. (A;;CCLCRPRC;;;IU)

    • A:允許 (Allow)
    • CCLCRPRC:多個權限的組合:
      • CC:建立子項目(CREATE_CHILD)。
      • LC:列出子項目(LIST_CHILDREN)。
      • RP:讀取參數(READ_PROPERTY)。
      • RC:讀取安全描述元(READ_CONTROL)。
    • IU:互動式用戶 (Interactive Users)
  3. (A;;CCLCRPRC;;;SU)

    • A:允許 (Allow)
    • CCLCRPRC:同上
    • 適用於 SU:服務用戶 (Service Users)
  4. (A;;CCLCRPWPRC;;;SY)

    • A:允許 (Allow)
    • CCLCRPWPRC 同上並加上
      • WP:寫入參數(WRITE_PROPERTY)。
    • SY:系統 (System)
  5. (A;;KA;;;BA)

    • A:允許 (Allow)
    • KA:所有權限 (KEY_ALL_ACCESS)。
    • BA:內建管理員 (Built-in Administrators)
  6. (A;;CC;;;AC)

    • A:允許 (Allow)
    • CC:建立子項目(CREATE_CHILD)。
    • AC:所有應用程序包 (All Application Packages)
  7. (A;;CCLCSWRPWPRC;;;S-1-5-xx-xxxxxxxx-xxxxxxxxx-xxxxxxxxxx-xx07)

    • 這組是我們新增的權限
    • A:允許 (Allow)
    • CCLCSWRPWPRC:這組權限為:
      • CC:建立子項目(CREATE_CHILD)。
      • LC:列出子項目(LIST_CHILDREN)。
      • SW:寫入(SELF_WRITE)。
      • RP:讀取參數(READ_PROPERTY)。
      • WP:寫入參數(WRITE_PROPERTY)。
      • RC:讀取安全描述元(READ_CONTROL)。
    • S-1-5-xx-xxxxxxxx-xxxxxxxxx-xxxxxxxxxx-xx07:就是剛剛查詢的 sid。

SACL 部分解析 (SCMANAGER)

S:(AU;FA;KA;;;WD)
  (AU;OIIOFA;GA;;;WD)

SACL 部分定義了審計規則:

  1. (AU;FA;KA;;;WD)

    • AU:審計 (Audit)
    • FA:AUDIT_FAILURE(失敗審計)
    • KA:KEY_ALL_ACCESS (所有權限)
    • WD:所有人 (Everyone)
  2. (AU;OIIOFA;GA;;;WD)

    • AU:審計
    • OIIOFA
      • OI:物件繼承 (OBJECT_INHERIT)。
      • IO:僅繼承(INHERIT_ONLY)。
      • FA:失敗審計 (AUDIT_FAILURE)。
    • GA:一般通用存取 (GENERIC_ALL)。
    • WD:所有人 (Everyone)

說明

這個安全描述符表明:

  • 管理員擁有完全控制權
  • 系統有廣泛的讀取、寫入和控制權限
  • 已認證用戶、交互式用戶和服務用戶有有限的瀏覽權限
  • 所有應用程式包可以建立子項目
  • 任何人嘗試完全瀏覽並失敗時會被記錄審計訊息

總結

這段 SDDL 字串的含義是:

  1. DACL
    • LocalSystem 帳戶擁有完全控制權限。
    • Administrators 群組擁有完全控制權限。
    • 互動使用者和服務使用者擁有基本的讀取和列舉權限。
  2. SACL
    • 對 Everyone 群組的所有操作進行審核。

這些設定用於控制服務的安全性,確保只有授權的使用者或群組可以執行特定操作,並記錄未經授權的瀏覽嘗試。

權限設定小結

myService 服務

  • 關鍵權限:
    • LocalSystem 和 Administrators:完全控制
    • 互動使用者和服務使用者:僅讀取和列舉
    • 特定使用者 (指定SID):完全控制
      -審核:追蹤所有使用者的操作嘗試

SCMANAGER 服務

  • 權限分層:
    • 管理員和系統:高級存取權限
    • 已驗證/互動/服務使用者:有限權限
    • 所有應用程式包:僅建立子項目
    • 特定使用者:自定義權限集
  • 審核:記錄失敗操作,含繼承規則

這種分層設計確保服務安全性,將完全控制權限限制在管理員和系統中,同時能為特定使用者提供自定義權限。

參考資料

後記:因為 SDDL 部份的參考資料有一點少,
有部分概念部分使用 AI 輔助,如有錯誤,還煩請不吝指正。

[教學] x64 PC 工控主機裸機直裝 OpenWRT (Bare Metal Install OpenWRT)

緣由

一切的本來是可以不用這麼複雜的。

之前入手了一台 4 Port Ethernet 的工業電腦,
本來安裝 Proxmox VE (這過程很曲折,可以另外寫一篇)然後做網卡直通 (NIC Passthrough),

但苦惱目前這顆 CPU: Intel Celeron J1900
支援 Intel® Virtualization Technology (VT-x),可以做虛擬化(可以開虛擬機 VM)
但不支援 Intel® Virtualization Technology for Directed I/O (VT-d)

對 Intel Celeron J1900 有興趣的話可以看 這裡

file

但這個在網卡直通是必要項,故心一橫,直接裸機 (Bare metal) 安裝 OpenWRT 吧!

這中間也遇到有的沒的坑,就寫了一篇文章來分享。

備註:Directed I/O (VT-d) 是一種虛擬化技術,讓虛擬機 (VM) 直接控制實體網卡,
繞過 Hypervisor 層以實現極致的網路性能、低延遲和低 CPU 佔用率。

那我們就開始吧!


🖥️ 前置準備

需準備的硬體

  • CPU:x86_64 的 CPU (Intel 或 AMD 皆可)
  • RAM:建議 ≥ 1 GB
  • Disk:建議 ≥ 8 GB SSD/eMMC (屆時會 整顆格式化掉 要注意)
  • Ethernet:64-bit 映像檔支援 Intel 與 Realtek Ethernet 晶片組(工業電腦常見的 Intel i210/i225/i350 均有良好支援)

範例使用的硬體

  • CPU: Intel Celeron J1900 (x86_64)
  • RAM: 4GB
  • HDD: 64GB SSD
  • Ethernet: Intel(R) PRO/1000 Network Connection x 4


(圖片來源)

工具準備

  • 一台 Windows/Linux/macOS 電腦,開啟 SMB (網路上的芳鄰) 分享
  • 一支做好 Ubuntu Live USB 隨身碟(≥ 2 GB)

Step 0: 製作 Ubuntu Live USB

file
(圖片來源)

  1. 下載 Ubuntu ISO 檔案

前往 Ubuntu 官網
下載所需版本的 ISO 檔案,建議下載 LTS (Long-Term Support) 版本,
選擇 Intel or AMD 64-bit architecture 的版本

file

  1. 使用 Balena Etcher 製作 Live USB

下載 Balena Etcher
安裝並開啟 Balena Etcher
點擊「Flash from file」選擇下載的 Ubuntu ISO
點擊「Select Target」選擇 USB 隨身碟

插入至少 8GB 大小的 USB 隨身碟

點擊「Flash」開始寫入(需要輸入系統密碼)
等待完成即可

file

Step 1: 全機備份

如果原本硬碟有資料,可以做磁碟備份,
以免到時候反悔要處理有得處理,
如果是全新安裝可以跳過這段

備份的步驟

使用 Ubuntu Live CD (Live USB) 開機

掛載 SMB (網路上的芳鄰的共享資料夾)

sudo apt update -y
# 安裝 SMB/CIFS 協議
sudo apt install -y cifs-utils

# 新增一個 smb_share 資料夾(名稱可以自己取)
sudo mkdir /mnt/smb_share
# 掛載你的 SMB 目錄
sudo mount -t cifs -o username=YOUR-USERNAME //192.168.1.10/ /mnt/smb_share

確認磁碟資訊

bashlsblk -o NAME,SIZE,FSTYPE,MOUNTPOINT,LABEL
# 或
fdisk -l

使用 dd 全磁碟複製(最完整,含 MBR/GPT/EFI)

# 備份
sudo dd if=/dev/sda of=/mnt/smb_share/disk_backup.img bs=4M status=progress conv=fsync

ddrescue(更安全、有錯誤處理)

# 安裝
sudo apt-get install gddrescue

# 備份到映像檔(含 log 檔方便中斷續傳)
sudo ddrescue -d -r3 /dev/sda /mnt/smb_share/disk_backup.img /mnt/smb_share/disk_backup.log

參數說明:

  • -d:直接讀取(bypass cache)
  • -r3:遇到壞軌重試 3 次
  • log 檔讓你可以中斷後繼續

備註: dd 會備份整顆磁碟(包含空白空間),映像檔大小 = 磁碟總容量
要準備足夠的空間

還原的步驟

還原方式

使用 dd 還原

# 從映像檔還原到磁碟
sudo dd if=/mnt/smb_share/disk_backup.img of=/dev/sda bs=4M status=progress conv=fsync

使用 ddrescue 還原

# ddrescue 還原
sudo ddrescue -d /mnt/smb_share/disk_backup.img /dev/sda /mnt/smb_share/disk_backup.log

Step 2:下載正確的 OpenWRT 映像檔

前往官方下載頁面:https://downloads.openwrt.org/releases/

選擇最新穩定版(目前為 25.12.x),路徑為:

releases → [版本號] → targets → x86 → 64

file

UEFI 模式必須下載帶 -efi 的檔案:

檔名 說明
generic-ext4-combined-efi.img.gz 推薦,可擴展分區
generic-squashfs-combined-efi.img.gz 唯讀 rootfs,類似嵌入式路由器

UEFI 系統必須使用 64-bit EFI OpenWRT 映像,大多數新板子需要 UEFI,Legacy BIOS 支援已較罕見。

這次下載的檔案檔名是

openwrt-25.12.2-x86-64-generic-ext4-combined-efi.img.gz

僅供參考

驗證映像完整性 (Optional)

下載完成後,在終端機執行 sha256 驗證(與官網 sha256sums 核對):

# Linux / macOS
sha256sum openwrt-25.12.2-x86-64-generic-ext4-combined-efi.img

# Windows (PowerShell)
Get-FileHash openwrt-25.12.2-x86-64-generic-ext4-combined-efi.img -Algorithm SHA256

Step 3:確認並設定 BIOS

進入 BIOS 設定(通常按 Delete / F2 / F12):

這邊只是列個大概,每家 BIOS 設定選項有些許不同

  • 關閉 Secure Boot(Security → Secure Boot → Disabled)
  • 確認開機模式為 UEFI(Boot → Boot Mode → UEFI only)
  • 調整開機順序:將 USB 設為第一優先
  • 儲存並重啟(Save & Exit)

若 OpenWRT 無法載入,請確認已停用 Secure Boot,並確認 USB 裝置已設為優先開機裝置。

Step 4:將 OpenWRT 映像寫入磁碟

使用 Live CD (Live USB) 開機

確認磁碟資訊

bashlsblk -o NAME,SIZE,FSTYPE,MOUNTPOINT,LABEL
# 或
fdisk -l

找出你的 SSD(通常是 /dev/sda,NVMe 則為 /dev/nvme0n1
先確定是哪顆磁碟,這邊假設是 /dev/sda

先解壓縮

gunzip openwrt-25.12.2-x86-64-generic-ext4-combined-efi.img.gz

會得到 openwrt-25.12.2-x86-64-generic-ext4-combined-efi.img 檔案

然後使用 dd 全磁碟複製 開始安裝
(你沒看錯,就是用 dd 指令)

sudo dd if=/mnt/smb_share/openwrt-25.12.2-x86-64-generic-ext4-combined-efi.img of=/dev/sda bs=4M status=progress conv=fsync
sync

範例輸出:

126123520 bytes copied, 3.25 s, 38.8 MB/s

⚠️ /dev/sdX 請替換為你的 SSD,切勿寫錯裝置 (寫錯會悲劇)
這邊做的是直接「整顆」硬碟做複製,故 /dev/sda (範例值) 後面不需要帶數字

Step 5:擴展 OpenWRT 根目錄分區(強烈建議,一定要做)

OpenWRT 預設 img 映像檔只有約 100~270 MB 的 root 分區 (Partition),
以我的例子來說,我 SSD 有 64GB,但預設只有 29.5M 可以使用
需擴展以便利用完整磁碟

我們再次使用 Live CD (Live USB) 開機

然後使用 parted 指令來查看磁碟區

parted /dev/sda

然後打

print

中間會叫你 Fix 有問題的磁區,你就 Fix
這邊會自動修正 GPT 問題

這邊紀錄一下執行結果

root@OpenWrt:~# parted /dev/sda
GNU Parted 3.6
Using /dev/sda
Welcome to GNU Parted! Type 'help' to view a list of commands.
(parted) p
Model: ATA Kston 64GB (scsi)
Disk /dev/sda: 64.0GB
Sector size (logical/physical): 512B/512B
Partition Table: gpt
Disk Flags:

Number  Start   End     Size    File system  Name  Flags
128     17.4kB  262kB   245kB                      bios_grub
 1      262kB   17.0MB  16.8MB  fat16              legacy_boot
 2      17.0MB  64.0GB  64.0GB  ext4

然後擴展磁碟分區

# 擴展分區(以 /dev/sda2 為例,為 ext4 rootfs)
parted /dev/sda resizepart 2 100%

做完可能要再 重開機,然後再次進入 LiveCD (LiveUSB)

先做檔案系統檢查(必要步驟)

e2fsck -f /dev/sda2

刷新分區表

partprobe /dev/sda

擴展 ext4 檔案系統到分區最大

resize2fs /dev/sda2

可以使用 df 再次查看

df -h

記錄一下執行結果

# df -h
Filesystem                Size      Used Available Use% Mounted on
/dev/root                58.8G    372.6M     58.5G   1% /
tmpfs                     1.9G      1.8M      1.9G   0% /tmp
/dev/sda1                16.0M      6.2M      9.8M  39% /boot
/dev/sda1                16.0M      6.2M      9.8M  39% /boot
tmpfs                   512.0K         0    512.0K   0% /dev

你就會發現 ext4 磁區的可用空間變大了,
如果沒有做 resize2fs 擴展磁區,分區已經撐滿了,但磁區仍然在很小的狀態


Step 6:移除 USB,從 SSD 開機

  1. 重啟機器,移除 USB 隨身碟
  2. UEFI 應自動偵測到 EFI 分區並從 SSD 開機
  3. 開機後看到 OpenWrt 的 GRUB 選單及登入提示即成功

Step 7:初始網路設定(4 Port 主機設定的關鍵步驟)

這邊會看到一堆開機 Log 文字,熟悉的 Linux 登入字樣
OpenWRT 預設會幫你設定

  • eth0LAN(br-lan,IP: 192.168.1.1)
  • eth1WAN(DHCP client)

帳號為 admin 預設沒有密碼,
ssh 有開啟,預設綁到 eth0,
很有可能沒有 LuCI (Web UI) 介面

接下來我們一步一步著手設定你的 OpenWRT

識別網路介面


(圖片來源)

可以用以下步驟來確認網路介面

ip link show
# 或
ls /sys/class/net/

紀錄執行結果

# ip link show
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel master br-lan state UP qlen 1000
    link/ether xx:xx:xx:xx:xx:xx brd ff:ff:ff:ff:ff:ff
3: eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel master br-lan state DOWN qlen 1000
    link/ether xx:xx:xx:xx:xx:xx brd ff:ff:ff:ff:ff:ff
4: eth2: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel master br-lan state DOWN qlen 1000
    link/ether xx:xx:xx:xx:xx:xx brd ff:ff:ff:ff:ff:ff
5: eth3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state DOWN qlen 1000
    link/ether xx:xx:xx:xx:xx:xx brd ff:ff:ff:ff:ff:ff
6: br-lan: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP qlen 1000
    link/ether xx:xx:xx:xx:xx:xx brd ff:ff:ff:ff:ff:ff

你會看到類似 eth0 eth1 eth2 eth3(或 enp* 名稱)

確認哪個 port 對應哪條實體網路線:

# 逐一測試,觀察 Link 狀態
ip link set eth0 up
cat /sys/class/net/eth0/carrier   # 1 = 有連線, 0 = 無連線

OpenWRT x86 預設:

  • eth0LAN(br-lan,IP: 192.168.1.1)
  • eth1WAN(DHCP client)

預設就有設定好 NAT 連線。

以我的例子為例,

  • LAN1 port 是 eth0
  • LAN2 port 是 eth1
  • LAN3 port 是 eth2
  • WAN port 是 eth3

就依序設定好對應的 WAN 與 LAN,可能跟你的情況不同。
這邊你用 Web UI 設定也可以。

這邊還是提供指令版的修改網路設定(1 WAN + 3 LAN)

編輯 /etc/config/network

vi /etc/config/network

內容為

config interface 'loopback'
    option device 'lo'
    option proto 'static'
    option ipaddr '127.0.0.1'
    option netmask '255.0.0.0'

config interface 'lan'
    option type 'bridge'
    option proto 'static'
    option ipaddr '192.168.1.1'
    option netmask '255.255.255.0'
    option ip6assign '60'
    list device 'eth0'
    list device 'eth1'
    list device 'eth2'   # 將 eth1, eth2 也加入 LAN bridge

config interface 'wan'
    option device 'eth3'   # 指定第4個 port 為 WAN
    option proto 'dhcp'    # 或 pppoe

在 OpenWRT 中,你需要明確指定哪個實體 NIC 網卡用於 WAN 或 LAN,
x86 硬體與一般路由器不同,端口角色必須手動設定。

套用設定

/etc/init.d/network restart

Step 8:安裝 LuCI (Web UI) 圖形介面

如果是穩定版 (stable) 預設有安裝 LuCI,
如果你裝的是 SNAPSHOT 版本的話,要手動安裝

因為 OpenWRT 在一個版本之後,改了套件管理程式
我就新舊版指令都列出來

新版指令 (使用 apk 套件管理程式)

apk update
apk add luci

舊版指令 (使用 opkg 套件管理程式)

opkg update
opkg install luci

然後啟動 httpd 伺服器

/etc/init.d/uhttpd enable
/etc/init.d/uhttpd start

之後從 LAN 側的電腦瀏覽器開啟 http://192.168.1.1 即可使用圖形介面。


Step 9:後續基本安全設定

這邊就依照你的需求,安裝所需的軟體
這邊跟一般 Linux 操作類似,就不多敘述了

# 設定 root 密碼
passwd

# 安裝常用工具
opkg update
opkg install curl wget-ssl btop irqbalance

Step 10:(加碼)安裝 Docker

這台範例機器 CPU / RAM / Disk 應該綽綽有餘,
聰明的你,可能會想到要來裝 Docker,沒問題,安排!

新版指令 (使用 apk 套件管理程式)

# 套件更新
apk update
# 安裝 docker
apk add dockerd docker docker-compose luci-app-dockerman

舊版指令 (使用 opkg 套件管理程式)

# 套件更新
opkg update 

# 安裝 docker 套件
opkg install dockerd docker docker-compose luci-app-dockerman

記得啟動服務

/etc/init.d/dockerd enable
/etc/init.d/dockerd start

以上指令就是安裝 Docker 相關套件,說明如下:

  • dockerd:執行容器所需的主要 Docker 守護程式/引擎。
  • docker:用來與守護程式互動的命令列介面 (CLI)。
  • docker-compose:用於定義和執行多容器應用程式的工具。
  • luci-app-dockerman:一個受歡迎的網頁式介面(LuCI 應用),可直接從 OpenWRT 瀏覽器儀表板管理 Docker 容器。


常見問題排除

問題 解決方法
UEFI 開機失敗 確認下載的是 -efi 版本;關閉 Secure Boot
只看到 UEFI Shell 進 BIOS 手動加入開機項:EFI\boot\bootx64.efi
網路 port 認不到 確認晶片是 Intel / Realtek;可能需安裝 kmod-* 驅動
磁碟空間不足 擴展 rootfs 分區
WAN 無法上網 確認 eth 編號對應正確,用 ip linkcarrier 確認

完成以上步驟後,你的工業電腦就會是一台以 UEFI 模式運行的 OpenWrt x86_64 路由器,4 個網路口可以靈活設定為任意的 WAN/LAN 組合。

參考資料

OpenWrt on x86 hardware (PC / VM / server)
https://openwrt.org/docs/guide-user/installation/openwrt_x86openwrt 教學

[教學] 用 Docker 的 buildx 輕鬆多架構編譯 (multi-architecture build)

Docker 可以在程序層級上做為封裝系統環境的技術,並方便部署(或移轉)到別的機器上,
比起可以做到相同目標的 虛擬機 (VM) 比它輕量快速,近期成為熱門技術之一。
這麼好的技術還是有其相關限制的。例如: Docker image (映像檔) 仍受限於 CPU 架構 (architecture),
但標準的 Docker 的指令預設只能編譯一個 CPU 架構 (architecture)。

接下來要介紹算是某種新功能,新的 buildx 指令,讓 Docker 編譯製作 image (映像檔) 時,
能一次編譯各種你要的 CPU 架構的 image。


不知道您有沒有自己做過 Docker image 過?
我們複習一下標準的 Docker 編譯

原有的 docker build 流程

標準的 Docker 編譯如下:

$ docker build . -t MY_ACCOUNT/my-awesome-image:latest

相關說明:

  • . 點 (dot):代表當前目錄,也是預設名稱 Dockerfile 檔案名稱
  • -t 參數:給予 image 名字,如果要上傳到 Registry 倉庫的話,要改成對應名字
    例如: dockerHub 的格式 帳號 / 名稱,這個例子為: MY_ACCOUNT 帳號下的 my-awesome-image 套件,版本為 latest

再來就是用指令登入你的 dockerHub

$ docker login

輸入帳號與密碼

最後,上傳你的 image 到 Registry 倉庫

$ docker push MY_ACCOUNT/my-awesome-image:latest

但如今,宿主 (Host) 的 CPU 架構會因為你的使用伺服器環境的不同而不同
例如:你買了 Apple silicon (M1) 晶片,預設 CPU 架構跑 arm64
又例如:你想把你的 Docker 環境跑在樹莓派 (Raspberry Pi), CPU 架構跑 armv7 或者 arm64

各種情境,你可能會需要把你的 Docker 編譯模組換成能一次編輯多架構的。

新的 buildx 指令

要達成此目的,做法有二種,只講其中一種, docker buildx 指令

docker buildx 算是全新的指令,可以在官網去下載設定。

$ docker buildx version

查看是否有安裝,執行結果如下(我列出我的環境,你的可能會跟我不一樣):

$ docker version
Client:
 Cloud integration: v1.0.22
 Version:           20.10.13
 API version:       1.41
 Go version:        go1.16.15
 Git commit:        a224086
 Built:             Thu Mar 10 14:08:43 2022
 OS/Arch:           darwin/arm64
 Context:           default
 Experimental:      true

Server: Docker Desktop 4.6.0 (75818)
 Engine:
  Version:          20.10.13
  API version:      1.41 (minimum version 1.12)
  Go version:       go1.16.15
  Git commit:       906f57f
  Built:            Thu Mar 10 14:05:37 2022
  OS/Arch:          linux/arm64
  Experimental:     false
 containerd:
  Version:          1.5.10
  GitCommit:        2a1d4dbdb2a1030dc5b01e96fb110a9d9f150ecc
 runc:
  Version:          1.0.3
  GitCommit:        v1.0.3-0-gf46b6ba
 docker-init:
  Version:          0.19.0
  GitCommit:        de40ad0

查看有哪些架構可用

$ docker buildx inspect --bootstrap
Name:   mybuilder
Driver: docker-container

Nodes:
Name:      mybuilder0
Endpoint:  unix:///var/run/docker.sock
Status:    running
Platforms: linux/arm64, linux/amd64, linux/riscv64, linux/ppc64le, linux/s390x, linux/386, linux/mips64le, linux/mips64, linux/arm/v7, linux/arm/v6

以小弟的電腦來說,有這麼多,你也不一定要全部都使用。

我選了幾個常用的

  • linux/amd64:適合 x64 的環境(不管宿主主機是 linux 還是 Mac )
  • linux/arm64:適合 arm64 的環境(例如:Apple silicon (M1) 晶片)
  • linux/arm/v7:適合 arm 環境(例如:樹莓派)

接下來,我們建立一個 builder 並使用(初次安裝執行一次即可)

$ docker buildx create --use

(其實他是建立 builder 與 使用 builder 的簡化版,相關指令我列在下方)

建立 builder

$ docker buildx create --name mybuilder

使用該 builder

$ docker buildx use mybuilder

列出有哪些 builder

docker buildx ls

更新原有 build 指令

我們更新原有 build 指令

$ docker build . -t MY_ACCOUNT/my-awesome-image:latest

變成這樣

$ docker buildx build --load -t MY_ACCOUNT/my-awesome-image:latest --platform linux/amd64 .

(為了開發方便,我們先編譯一個平台架構)

效果二者一樣。

如果 Dockerfile 編好了,準備編譯並打包上傳 dockerhub 可以用以下指令:

$ docker buildx build --push -t MY_ACCOUNT/my-awesome-image:latest --platform linux/amd64,linux/arm64,linux/arm/v7 .

加個 --push 參數就可以在編譯完成的時候,一併做上傳。

瀏覽你的 dockerHub 帳號,會看到多種你選取的平台出現在 dockerHub 中。

小提醒:別使用 docker push 指令,截稿至今,它還不支援多架構映像檔上傳,它只會幫你上傳單一架構而已,
你也可以測試看看。

以上就是這次的內容,希望對大家有幫助。

參考資料

解決 MySQL 資料庫備份還原錯誤 ERROR: ASCII ‘\0’ appeared in the statement

最近遇到一個情境,在 Windows 使用 PowerShell 使用 mysqldump 把 MySQL / MariaDB 資料庫匯出 SQL 檔案,然後在 Linux 環境底下用 mysql 指令匯入,

卻出現一個很莫名錯誤:

$ mysql -u root -p myDatabase < /backup.sql
Enter password: 
ERROR: ASCII '\0' appeared in the statement, but this is not allowed unless option --binary-mode is enabled and mysql is run in non-interactive mode. Set --binary-mode to 1 if ASCII '\0' is expected. Query: '-'.

這原因出現在 PowerShell,的 > 資料流重導向 (I/O Redirection) 語句,
會使用 UTF-16LE 和 CR/LF 換行符號來建立檔案。

這些在 mysql 指令中不認得,他目前只認 UTF-8。
所以要做一些修改。

從 MySQL / MariaDB 資料庫匯出

mysqldump 匯出指令有修改

原本為:(此在 Windows (PowerShell) 環境下執行)

PS> .\mysqldump -h localhost -u root -p myDatabase > backup.sql

改為新指令

PS> .\mysqldump -h localhost -u root -p myDatabase -r backup.sql

改用 -r 指令,讓 mysqldump 「直接操作檔案」,這樣就不會出現這個問題了。

匯入新的 MySQL / MariaDB 資料庫

匯入指令相同:(此在 Linux 環境下執行)

$ mysql -u root -p myDatabase < /backup.sql

這樣就可以正確匯入了。

參考資料

[教學] Google 表單 製作多選複選選項 與限制總量數量(團購表單好用)

因為 Choice Eliminator 2 這個第三方外掛已經不能用了(第三方外掛的廠商已經下線了)
這陣子就在找替代方案,發現這一招還是可用,分享給大家。

本教學會帶入部分 Google 表單與 Google 試算表的使用,
看不懂也不用擔心,會一步一步帶大家建立。

教學步驟


(這是範例成品)

我們在這舉一個例子,假設是一個蛋糕團購表單,有六個選項,
在六個選項中,選擇二個:

  • 原味
  • 抹茶
  • 起司
  • 草莓
  • 芋頭
  • 巧克力

在這團購表單很常見。

Step1. 建立表單

首先先建立一個 Google 表單,

網址:
https://docs.google.com/forms/

按新增

這邊用打入標題、 等聯絡資訊。
用簡答功能打入 姓名、電話、地址 等聯絡資訊。
旁邊有加號可以新增題目,這功能就不贅述。

再來就是重頭戲,新增一個「核取方塊」,
打入 原味、抹茶、起司、草莓、芋頭、巧克力 六個選項

在右下角的點點點選項,選擇「回應驗證」

就會多出一行選項

選擇「選取剛好」,數量打入 2,最後輸入提示訊息

再來點到「回覆」頁籤,右上角一個小按鈕「建立試算表」

選取回應目的地的視窗中
選建立新試算表,輸入名字,然後按「確定」

動畫就像這樣

你就會看到打開了一個 Google 試算表。

Step2. 製作回應表單的試算表

在試算表下方按下「 + 」,建立試算表

我們建立二個新的試算表,名字分別叫做:

  • 選項拆解
  • 統計

像這樣

Step3.「選項拆解」的試算表設計

在「選項拆解」的試算表中,

A1 打入「選項」
B2 ~ G2 依序打入「原味、抹茶、起司、草莓、芋頭、巧克力」等選項

接下來這個騷操作,要一步完成
先選擇 A2 這格,先打一個「 = 」(等號)
然後直接選擇「表單回應 1」,的第一格選項欄位

你會看到公式跳出來

='表單回應 1'!E2

動畫在這:

未來填單有資料的時候就會這樣

意思是說,單引號括起來的是試算表的名字,
意指「表單回應 1」試算表裡的 E2 欄位。

如果你是直接複製公式的話,
要注意單引號與雙引號,還有要注意欄位格子是否正確。

按下 Enter 之後,他會顯示空白的是正常現象,選取上去就會該格子就會有公式出現。

Step3-1.「選項拆解」試算表套用搜尋公式

選到 B2 這格,打上公式:

=IF(IFERROR(FIND(B$1, $A2), 0) > 0, 1, 0)

然後選上 B2 這格,直接拖拉到 G2,讓這列的選項都填滿了公式。

動畫:


如果有興趣的朋友,這邊小小解釋一下這個公式的原理:

FIND() 輸入三個參數:

  • 要找的文字
  • 原始文字
  • 從第幾個字開始找(可忽略不填)

白話文就是在第二個參數中,找第一個參數的文字,在哪個位置,
從第三個參數開始找,第三個參數可忽略,就是從第一個字開始找。

輸出就是在第幾個字出現的位置。

FIND() 的說明文件:
https://support.google.com/docs/answer/3094126?hl=zh-Hant

IFERROR() 輸入二個參數:

  • 要執行的公式
  • 出錯時要顯示的字

第一個參數是執行公式,如果它出錯了,就顯示第二個參數值。

IFERROR() 的說明文件:
https://support.google.com/docs/answer/3093304?hl=zh-Hant

IF() 就是判斷,輸入三個參數:

  • 判斷的公式
  • true 時顯示文字
  • false 時顯示文字

算蠻好理解的。

IF() 的說明文件:
https://support.google.com/docs/answer/3093364?hl=zh-Hant

整段白話文就是:我們把選項拆解,有搜尋到字的欄位標成 1 否則是 0


選取 要估算一下會填單的人數,整列選起來往下拉,讓每列都填上公式。
至於要拉幾列,當然 越多越好

這表格意旨在拆解選項並填到各個對應的格子裡。

Step4.「統計」的試算表設計

切到「統計」的試算表

A1 ~ E1 欄位分別打上「顯示、選項、額滿提示、已達數量、數量上限」
然後 B2 ~ B7 依序分別打上總共有的「原味、抹茶、起司、草莓、芋頭、巧克力」選項,
額滿提示可以打上你想打的額滿提示,範例是在最後加上(完售)字樣。

如果你想隱藏該選項,在額滿提示直接留空白。

然後在 E2 ~ E7 打入你要的數量上限。

Step4-1.「統計」試算表套加總公式

選擇 D2 套入加總公式

=SUM('選項拆解'!B:B)

這句 SUM() 的意思是:做 「選項拆解」試算表裡的 B 欄所有列的加總,

然後如法炮製,依序在 D3 套入公式

=SUM('選項拆解'!C:C)

在 D4 套入公式

=SUM('選項拆解'!D:D)

以此類推,套到 D7

=SUM('選項拆解'!G:G)

這邊重點要注意統計欄位是不是正確

Step4-2.「統計」試算表套顯示判斷公式

選擇 A2 這格,套入公式

=IF(D2<E2,B2,C2)

這個應該很好理解,就是數量統計超過上限時,顯示額滿提示,否則顯示原本的選項名

然後往下拉,填滿到 A7

為何要這樣設計呢?為了配合等下要提到的 Form Ranger 外掛

Step5. 安裝 Form Ranger 表單外掛

在右上方點點點點開「外掛程式」,搜尋「Form Ranger」

也就是這個外掛:
https://workspace.google.com/u/5/marketplace/app/form_ranger/387838027286?hl=zh&pann=forms_addon_widget

安裝,

選擇帳號,

瀏覽授權權限,按下「允許」,

允許這個第三方外掛。

Step6. 使用 Form Ranger 表單外掛

然後回到剛剛做的表單,在右上角多一個小積木
選擇「formRanger — PROD」

點「Start」

會看到你的多選問題

勾選「Populate from range」,然後旁邊按「 + 」(New Range)

在 Select sheet 的頁籤中,

選擇剛剛的回應試算表「蛋糕訂購表單 (回覆)」,按「Select」。

在 Select Ranage 的頁籤中,

Sheet name 選「統計」
Column header 選「顯示」

左側會出現預覽,按下「Next」

最後在 Name range 頁籤中,

Range name 打入一個名稱(名稱辨識用,可隨意填)
然後按下「Save and populate question」

最後的最後,

在 Auto-repopulate questions (自動重新產生問題) 這邊,
記得要把 On form submit (每次表單送出時) 選項給勾起來,
它會在表單送出的時候根據欄位值重新產生表單。

如果資料不同步時,可以手動按下「Update questions list」手動更新表單。

Step7. 發佈連結

表單右上角按下「傳送」,

看到傳送表單的視窗,

選擇「連結」圖示,可以勾「縮短網址」,然後按複製,
這個就是你的表單前台網址。

Step8. 測試!

最後當然是當個客人,來測試一下運作情況啦!

拿剛剛產生的這個網址去瀏覽器真的填看看,也驗證一下會不會有問題,
還是哪裡有做錯的地方。

最後附上筆者隨意填三筆資料的觀察各表單結果。

表單回應

選項拆解

統計

前台表單的樣子

補充

最後的最後,如果你想隱藏額滿的選項,
在額滿提示直接留空白就可以了,選項他會自動消失。

記得時不時注意一下,「選項拆解」試算表套公式的列數,不然會出錯。
這功能不管單選多選都可以適用,選項在對應的地方修改即可,變通一下就有超萬用的表單了。

希望這邊教學對你有幫助。

參考資料

[Android & PHP] 伺服器端 IAP(IAB) 內購通知實作 Real-time Developer Notifications (RTDN)

因為工作需要,研究了這塊,
因為文件有點不連續,又蠻深入的,
左思右想,還是分享出來,給有需要串接 App 內購的朋友,
App 內購在 Android 叫做 In-app Billing (IAB) 在蘋果 iOS 這邊叫做 In-app purchase (IAP),
講得就是一個東西,App 需要購買虛擬商品的時候,必須透過 GooglePlay (Android 平台) 或者蘋果 Apple (iOS 平台) 的金流。

為什麼會需要它?

這邊專注講 Android 平台的 In-app Billing (IAB),

要提到一下 App 內購的流程:

  1. 由使用者按下購買鈕,你的 Android 程式呼叫系統購買頁面
  2. 使用者在系統購買頁面做扣款事宜
    (如果有設定好的話,可能是指紋或者刷臉再次確認,如果第一次使用就會要求你做登入 Google 帳號,輸入信用卡等等的步驟再跳回來)
  3. 你的 Android 程式收到購買狀態(成功/失敗/使用者取消) 並回傳 purchaseToken
  4. 如果購買成功,你的 Android 收到 purchaseToken 之後要 回傳給你的後端伺服器做驗證啟用對應功能給使用者

最後這一步才是關鍵,後端伺服器確認了之後才會開啟對應功能給使用者嘛。
(App 無伺服器的流程是另外一種,而且漏洞很多,先姑且不討論。)

正常流程大致是這樣,但總有一些些例外。

例如:

  • (訂閱型商品常見)每月時間到了系統自動做扣款動作
  • 使用者是在 GooglePlay 商店按的購買而不是在你 App 裡面按
  • Google 那邊刷使用者的卡刷失敗了

等等諸如此類,它並不是發生在你的 App 中,所以你的 App 沒辦法知道那時候訂的商品狀態更新了。

怎麼辦呢?

以前的傳統做法:在 你的 App 啟動時 或者 喚醒 的時候,向 Google 再次確認一次有沒有未處理的購買訂單。

如果訂單不同步怎麼辦?請使用者再次開一次 App 吧!訂單就同步了。

如果你只有一個 Android 平台,
這沒問題,使用者的所有流程都是在你的 App 下進行的,
但如今你的服務可能會有 iOS 平台、 Android 平台、網站…等等的平台。
如果使用者買了之後沒有開 App ,很容易會造成訂單不一致問題。
這個時候就需要內購伺服器通知了。

總結來說,這個 App 內購伺服器通知就是為了解決訂單不同步的問題,
透過伺服器與伺服器對傳通知確認,即時補齊 App 外交易的訂單。

這個 App 內購伺服器通知叫做 Real-time Developer Notifications (RTDN)。

怎麼做?

在使用這個之前,必須先知道 Google 出的 Pub/Sub 模組。
他會透過 Pub/Sub 模組發通知出來,我們可以用 Push (常用) 或者 Pull 方式實作。

Pub/Sub 模組

Pub/Sub 模組它是一個類似廣播模組,如果有需要的話,就設定註冊它,你就會收到來自程式的廣播通知。
一個頻道稱為 Topic
一個訂閱人叫做 Subscription

其中 Subscription 分為 Pull 跟 Push 二種。

  • Pull: 為一個 Queue (佇列) ,幫你 Queue 住所有的訊息直到你打 API 去拉它,
  • Push: 要指定一個 API endpoint 給它,網址就是填你的伺服器 API 位址,他會用文件定義的 API 格式,主動去呼叫你的伺服器。
    (這部分就跟 Apple 概念接近了,這也是最常用的方式。)

官方文件說,一個 app 要獨立一個 Google Cloud project 這點要注意。
在有限額度下,多開 Google Cloud project 帳號應該也無仿。

操作步驟

等等的步驟大致是,
建立一個 Topic 與其對應的 Subscription,然後 Subscription 設定好連接你的伺服器,
設定 app 變更通知要發送到哪一個 Topic。
設定有點小複雜,可能要慢慢研究一下。

到 Google Cloud Console 新增一個 project
(如果有的話就選擇你的 project,假設叫做 my_app_project

Step1. 建立 Topic

到 BigData > Pub/Sub 的頁籤中:
https://console.cloud.google.com/cloudpubsub/topic/list

先建立一個 Topic,
名字自訂,假設叫 my_app_topic

有勾 Add a default subscription 的話,它會自動幫你建立一個 Pull 的 Subscription 叫做 my_app_topic-sub
我們等等會使用 Push 的 Subscription,這個 my_app_topic-sub 的 Pull Subscription 可以先留著,
到時候可以直接在 Cloud Console 這邊直接驗證。

Step2. 測試發送訊息到 Topic

可以先試試看 Topic 發通知跟接收通知的感覺,
在 Pub/Sub > Topic 頁籤中,

點開剛剛建立的 my_app_topic
在 MESSAGES 頁籤,有一個 PUBLISH MESSAGE 按鈕可以按,

按下後看到發送測試訊息的畫面,可以在 Message body 的地方隨意打點字上去,
然後按 publish 發送。

在 Pub/Sub > Subscription 頁籤中,點開自動建立的 my_app_topic-sub

點開 MESSAGES 頁籤,看到列表是空白是正常的,

按下 PULL 更新通知,剛剛發送的訊息就會出現,如果沒有出現,可能要稍等個 1-2分鐘 再按一次(因為它發送跟接收是非同步的)。

Step3. 通知連接你的 app

這邊就是它文件寫的最奇妙的地方了,

在 Pub/Sub > Topic 的頁籤列表中,按下三顆點點 > View premissions 按鈕,

按下 ADD PRINCIPAL 按鈕,

在 New principals 的地方手動輸入 [email protected]
Select a role 的地方手動選擇 Pub/Sub Publisher ,
(這個值是 Google 指定的 Service account 名字,寫在文件裡的,大家都一樣)

然後進入 Google play console,
https://play.google.com/console
並選擇你的 app。

在 Monetization setup 的頁面中,
Real-time developer notifications 區域,
Topic name 的地方, 按照格式 打入剛剛的 Topic 名字:

projects/my_app_project/topics/my_app_topic

(這邊要依照你實際情況來打,每個人不太一樣)

按下 Save changes 然後按下 Send test notification 試試。

Step4. 測試通知發送連接情況

在剛剛 Real-time developer notifications 區域,
按下 Send test notification 試試,
流程跟單獨測試 Topic 類似,
回到剛剛 Pub/Sub > Topic 頁籤中,
在 Pub/Sub > Subscription 頁籤中,點開剛剛的 my_app_topic-sub
按下 PULL 更新通知,會出現測試類似以下的訊息,如果沒有出現,可能要稍等個 1-2分鐘 再按一次(因為它發送跟接收是非同步的)

{"version":"1.0","packageName":"com.myawesome.app","eventTimeMillis":"1638346194077","testNotification":{"version":"1.0"}}

實作伺服器接收端 (PHP)

接收端分成二個方式 Pull 跟 Push 的方式,二個擇一實作即可。
個人比較推薦使用 Push 的方式。

Pull 的方式

以下是 PHP 實作,
如果你不是是用這語言開發,可以參考他其他的 Client library 來實作
https://cloud.google.com/pubsub/docs/reference/libraries#cloud-console

PHP 做法就是使用 composer 引用 cloud-pubsubgoogle/apiclient 套件。

$ composer require google/apiclient
$ composer require google/cloud-pubsub

或者手動在 composer.jsonrequire 區域加上關聯

{
  "config": {
    // ....
  },
  "require": {
    "google/apiclient": "^2.7"
    "google/cloud-pubsub": "^1.34"
  }
}

再執行

$ composer update

你可能需要建立一個 Service account 並給予 Pub/Sub Subscriber 權限
在 Key 的地方按下 Add Key 選擇 json 得到 credentials.json

credentials.json 文件大概長這樣:

{
  "type": "service_account",
  "project_id": "my_app_project",
  "private_key_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBA.......N4M8zLc\n-----END PRIVATE KEY-----\n",
  "client_email": "my-service-account@my_app_project.iam.gserviceaccount.com",
  "client_id": "xxxxxxxxxxxxxxxxxxxxx",
  "auth_uri": "https://accounts.google.com/o/oauth2/auth",
  "token_uri": "https://oauth2.googleapis.com/token",
  "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
  "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/my-service-account%40my_app_project.iam.gserviceaccount.com"
}

credentials.json 放到伺服器能存取到的地方,假設是放在跟 PHP 相同的目錄
就可以新增一個 PubSubClient 物件來抓取 Pull 的通知

<?php
require __DIR__ . '/vendor/autoload.php';

use Google\Cloud\PubSub\PubSubClient;

$pubSub = new PubSubClient([
    'keyFilePath' => __DIR__ . '/credentials.json',
    'projectId' => 'my_app_project'
]);

$subscription = $pubSub->subscription('my_app_topic-sub');

$messages = $subscription->pull();

foreach ($messages as $message) {
    echo $message->data();
}

這個範例就能跟網頁介面一樣,一次拉一些一次拉一些,拉到你想要的訊息,
可能用一個 cronjob 排程讓它固定跑,就可以使用它。

你就可以得到類似這樣的一筆資料:

{
  "version": "1.0",
  "packageName": "com.myawesome.app",
  "eventTimeMillis": "1638375316338",
  "subscriptionNotification": {
    "version": "1.0",
    "notificationType": 13,
    "purchaseToken": "df................CnPIQ",
    "subscriptionId": "com.myawesome.app.one_month_subscription"
  }
}

這個是一個訂閱通知的範例,
根據查表 notificationType = 13 是 SUBSCRIPTION_EXPIRED 也就是訂閱過期了。

根據文件,除了 subscriptionNotification(訂閱通知)之外,它還有 oneTimeProductNotification(單次購賣通知)
細節就不多講了,格式很接近

{
  "version":"1.0",
  "packageName":"com.myawesome.app",
  "eventTimeMillis":"1638375316338",
  "oneTimeProductNotification":
  {
    "version":"1.0",
    "notificationType":1,
    "purchaseToken":"fg................HBbID",
    "sku":"com.myawesome.app.coin"
  }
}

單次購買的 notificationType 與訂閱的 notificationType 定義不同,需注意。

Push 的方式(常用)

你要設計一個 API endpoint 給他,每當訂單狀態有變更時,Google 伺服器它會用 POST 把資料回傳給你

這邊分成二部分,有認證跟沒認證,先解釋乾淨版不需認證的方式。
(這邊的認證是指認證是否真的由 Google 伺服器來的通知,而不是別人假冒的)

格式參考如下:
https://developer.android.com/google/play/billing/rtdn-reference

接收的文件路徑:
https://cloud.google.com/pubsub/docs/push

你會收到來自 Google 伺服器經由 POST 打來的資料,類似這樣:

{
  "message": {
    "attributes": {
      "key": "value"
    },
    "data": "eyAidmVx................GlvbiB9",
    "messageId": "136969346945"
  },
  "subscription": "projects/my_app_project/subscriptions/mysubscription"
}

其中這個 data 欄位是一個 base64 編碼後的字串,解碼 data 欄位可以得到類似以下的資料:

{
  "version": "1.0",
  "packageName": "com.myawesome.app",
  "eventTimeMillis": "1638375316338",
  "subscriptionNotification": {
    "version": "1.0",
    "notificationType": 13,
    "purchaseToken": "df................CnPIQ",
    "subscriptionId": "com.myawesome.app.one_month_subscription"
  }
}

這個是一個訂閱通知的範例,
根據查表 notificationType = 13 是 SUBSCRIPTION_EXPIRED 也就是訂閱過期了

根據文件,除了 subscriptionNotification(訂閱通知)之外,它還有 oneTimeProductNotification(單次購賣通知)
細節就不多講了,格式很接近

{
  "version":"1.0",
  "packageName":"com.myawesome.app",
  "eventTimeMillis":"1638375316338",
  "oneTimeProductNotification":
  {
    "version":"1.0",
    "notificationType":1,
    "purchaseToken":"fg................HBbID",
    "sku":"com.myawesome.app.coin"
  }
}

單次購買的 notificationType 與訂閱的 notificationType 定義不同,需注意。

查找訂單細節(訂閱型資料)

伺服器訂購通知只給你很粗糙的內容,實際內容還需要你另外打 API 達成。

PHP 需 composer 引用 apiclient 套件

$ composer require google/apiclient

或者手動在 composer.jsonrequire 區域加上關聯

{
  "config": {
    // ....
  },
  "require": {
    "google/apiclient": "^2.7"
  }
}

再執行

$ composer update

以下程式碼是一個抓取訂閱訂單的範例:
需要填入 $packageName, $subscriptionId$purchaseToken

$packageName = 'com.myawesome.app';
$subscriptionId = 'com.myawesome.app.one_month_subscription';
$purchaseToken = 'df................CnPIQ';

$client = new \Google_Client();
try {
    $client->setAuthConfig(dirname(__FILE__) . '/credentials.json');
} catch (\Google\Exception $e) {
    throw $e;
}
$client->addScope('https://www.googleapis.com/auth/androidpublisher');
$service = new \Google_Service_AndroidPublisher($client);
$purchase = $service->purchases_subscriptions->get($packageName, $subscriptionId, $purchaseToken);

var_dump($purchase);

大致的資料格式如下(引用官方文件):

{
  "kind": string,
  "startTimeMillis": string,
  "expiryTimeMillis": string,
  "autoResumeTimeMillis": string,
  "autoRenewing": boolean,
  "priceCurrencyCode": string,
  "priceAmountMicros": string,
  "introductoryPriceInfo": {
    object (IntroductoryPriceInfo)
  },
  "countryCode": string,
  "developerPayload": string,
  "paymentState": integer,
  "cancelReason": integer,
  "userCancellationTimeMillis": string,
  "cancelSurveyResult": {
    object (SubscriptionCancelSurveyResult)
  },
  "orderId": string,
  "linkedPurchaseToken": string,
  "purchaseType": integer,
  "priceChange": {
    object (SubscriptionPriceChange)
  },
  "profileName": string,
  "emailAddress": string,
  "givenName": string,
  "familyName": string,
  "profileId": string,
  "acknowledgementState": integer,
  "externalAccountId": string,
  "promotionType": integer,
  "promotionCode": string,
  "obfuscatedExternalAccountId": string,
  "obfuscatedExternalProfileId": string
}

REST 文件在這:
https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.subscriptions
PHP 的資料格式參考(很難看懂,還是參照 REST 文件吧!):
https://developers.google.com/resources/api-libraries/documentation/androidpublisher/v3/php/latest/class-Google_Service_AndroidPublisher_SubscriptionPurchase.html

基本上對應的資料格式會有對應的 Method 可以呼叫。

查找訂單細節(單次購買型資料)

伺服器訂購通知只給你很粗糙的內容,實際內容還需要你另外打 API 達成。

PHP 需 composer 引用 apiclient 套件

$ composer require google/apiclient

或者手動在 composer.jsonrequire 區域加上關聯

{
  "config": {
    // ....
  },
  "require": {
    "google/apiclient": "^2.7"
  }
}

再執行

$ composer update

以下程式碼是一個抓取訂閱訂單的範例:
需要填入 $packageName, $sku$purchaseToken

$packageName = 'com.myawesome.app';
$sku = 'com.myawesome.app.coin';
$purchaseToken = 'fg................HBbID';

$client = new \Google_Client();
try {
    $client->setAuthConfig(dirname(__FILE__) . '/credentials.json');
} catch (\Google\Exception $e) {
    throw $e;
}
$client->addScope('https://www.googleapis.com/auth/androidpublisher');
$service = new \Google_Service_AndroidPublisher($client);
$purchase = $service->purchases_products->get($packageName, $sku, $purchaseToken);

var_dump($purchase);

大致的資料格式如下(引用官方文件):

{
  "kind": string,
  "purchaseTimeMillis": string,
  "purchaseState": integer,
  "consumptionState": integer,
  "developerPayload": string,
  "orderId": string,
  "purchaseType": integer,
  "acknowledgementState": integer,
  "purchaseToken": string,
  "productId": string,
  "quantity": integer,
  "obfuscatedExternalAccountId": string,
  "obfuscatedExternalProfileId": string,
  "regionCode": string
}

REST 文件在這:
https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.products
PHP 的資料格式參考(很難看懂,還是參照 REST 文件吧!):
https://developers.google.com/resources/api-libraries/documentation/androidpublisher/v3/php/latest/class-Google_Service_AndroidPublisher_ProductPurchase.html

基本上對應的資料格式會有對應的 Method 可以呼叫。

回應 (Acknowledge) 訂單已處理(訂閱型)

有一個很重要的就是,伺服器處理完訂單要打 API 跟 Google 伺服器講:商品已送達。
這個行為稱為 Acknowledge

不然它會認為沒有開對應功能給使用者,計時三天後,自動做退費,所以這功能很重要。

$packageName = 'com.myawesome.app';
$gpaOrderId = 'GPA.xxxx-xxxx-xxxx-xxxxx';
$purchaseToken = 'df................CnPIQ';

try {
  $client = new \Google_Client();
  $client->setAuthConfig(dirname(__FILE__) . '/credentials.json');
  $client->addScope('https://www.googleapis.com/auth/androidpublisher'); 
  $service = new \Google_Service_AndroidPublisher($client);

  $postBody = new Google_Service_AndroidPublisher_SubscriptionPurchasesAcknowledgeRequest();
  $postBody->setDeveloperPayload("");
  $response = $service->purchases_subscriptions->acknowledge($packageName, $gpaOrderId, $purchaseToken, $postBody);

  var_dump($response);
} catch (\Google\Exception $e) {
    throw $e;
}

以上是一個簡單範例。

回應 (Acknowledge) 訂單已處理(單次購買型)

伺服器處理完訂單要打 API 跟 Google 伺服器講:商品已送達。
這個行為稱為 Acknowledge

不然它會認為沒有開對應功能給使用者,計時三天後,自動做退費,所以這功能很重要。
原理類似,就不贅述了。

$packageName = 'com.myawesome.app';
$sku = 'com.myawesome.app.coin';
$purchaseToken = 'fg................HBbID';

try {
  $client = new \Google_Client();
  $client->setAuthConfig(dirname(__FILE__) . '/credentials.json');
  $client->addScope('https://www.googleapis.com/auth/androidpublisher'); 
  $service = new \Google_Service_AndroidPublisher($client);

  $postBody = new Google_Service_AndroidPublisher_ProductPurchasesAcknowledgeRequest();
  $postBody->setDeveloperPayload("");
  $response = $service->purchases_products->acknowledge($packageName, $sku, $purchaseToken, $postBody);

  var_dump($response);
} catch (\Google\Exception $e) {
    throw $e;
}

以上就是各種實作細節,細節很多,文件很長,可能要多看幾次。
祝串接愉快。

參考資料

Spring boot 學習筆記 – 使用 Kotlin 做後端開發

學了這麼厲害的 Kotlin 可不可以拿它來開發後端(Backend),答案是當然可以的!
我們使用 Kotlin 跟 Gradle 來做開發
先做一個非常簡單的 API 再來慢慢進階

前端?後端?前台?後台?有差嗎?

在這邊簡單解釋一下 Web 技術這些詞彙的定義,大家也常常搞錯。

  • 前端 (Frontend):由後端 (Backend) 收到資料,透過瀏覽器渲染出來跟使用者互動的介面 (UI) 與程式都稱前端。

  • 後端 (Backend):也就是伺服器 (Server) 的部分,專門處理由使用者的要求,提供對應資料的程式。

  • 前台:大家比較通俗的講法,使用者看得到的操作得到的區域,如同舞台一般。

  • 後台:大家比較通俗的講法,通常是指管理者介面,如同舞台的後台。

那前台、後台跟前端、後端有關係嗎?
答案是:沒有什麼關係。
前台需要有前端(介面),也需要後端(伺服器程式)幫忙,後台也是。

工作上可以細分成:

  • 前台的前端
  • 前台的後端
  • 後台的前端
  • 後台的後端

分別有前端工程師與後端工程師負責開發。
今天的主題是後端開發的主題,所以沒有介面只有資料。

學習目標

  • 手把手製作一個 REST API
  • 熟悉 Spring boot 的 Annotation 寫法
  • 調整 404 頁面

準備開發環境

這邊有三種做法,好壞都不一

  • 直接推薦:InteliJ IDEA Ultimate 付費版
  • Visual studio code 外掛 Spring Initializr plug-ins
  • Spring Tools 4 for Eclipse (前身為 STS3)

💡 小提醒:這個需要付費版 InteliJ IDEA Ultimate, 免費的 Community 會無法正確 Compile,小弟已經親身試過了。

小弟建議使用 InteliJ,因為他的優異程式碼提示、搜尋與重構等功能好過其他的 IDE 編輯器

小提醒:因為 InteliJ IDEA 有可能會小改版,介面可能會稍微長的不太一樣,如果真有找不到選項、或者文章失效歡迎聯繫小弟我。

開新專案

新增SpringBoot專案

New Project,選擇 Spring Initializr

  • Group: <自己填>
  • Artifact: <自己填>
  • Type: Gradle Project (Generate a Gradle based project archive.)
  • Language: Kotlin
  • Packaging: Jar
  • Java Version: 11 (選更新的版本也可以)
  • Version: <自己填>
  • Name: <自己填>
  • Description: <自己填>
  • Package: <自己填>

然後按「Next」。

新增SpringBoot專案

這邊選擇二個:

  • Web > Spring Web
  • Developer Tools > Spring Boot DevTools

不選也沒關係,後面可以自行新增。
按下「Finish」完成建立專案。

如果直接新增完跑的話,Log 大概會長這樣。

瀏覽會長這樣:

資料夾結構

如果都是預設值的話,會看到以下資料夾結構:

  • src/main/kotlin/\<packageName>/
    主要程式碼的目錄
  • src/test/kotlin/\<packageName>/
    測試程式碼的目錄
  • build.gradle.kts
    專案的 gradle 檔案
  • src/main/resources/static
    存放一些靜態資源(例如:JavaScript、css、圖片…等)
  • src/main/resources/templates
    存放一些樣板檔案
  • src/main/resources/application.properties
    參數設定配置檔,存放一些資料連線資訊、雜項設定等等

修改啟動的連接埠 (Port)

找到 application.properties 檔案,加入這句即可

server.port=8086

啟動 Port 就改成 8086 了

其他參數請見:
https://docs.spring.io/spring-boot/docs/current/reference/html/appendix-application-properties.html

增加 Dependencies

打開 build.gradle.kts 找到 dependencies { } 的區塊

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    developmentOnly("org.springframework.boot:spring-boot-devtools")
}

即是 Spring Web 跟 Spring Boot DevTools


觀察檔案

DemoApplication.kt (如果沒改名字的話)
或者有 @SpringBootApplication 標明字樣的檔案
即主要程式,程式的進入點
可以找到一個

fun main(args: Array<String>) {
    runApplication<DemoApplication>(*args)
}

可以找到 main 即程式進入點


寫第一個 Controller 寫一隻 API

著手來寫第一個 Controller

定義資料 Model

我們先定義幾個的 model class,

GeneralMessageResponse 是用來顯示範例 API 資料用的

GeneralMessageResponse.kt

package com.example.demo.model

data class GeneralMessageResponse(val message:String)

就這樣二句,就可以定義好資料 Model。

GeneralErrorResponse 用來顯示錯誤訊息的

GeneralErrorResponse.kt

package com.example.demo.model

data class GeneralErrorResponse(var message:String)

寫一個 Controller

製作一個 class 名叫 HelloController 使用 @RestController 標示,內容如下:

HelloController.kt

package com.example.demo

import com.example.demo.model.GeneralMessageResponse
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

@RestController
class HelloController{

    @RequestMapping("/")
    fun hello(): GeneralMessageResponse {
        return GeneralMessageResponse("Hello, world!")
    }
}

做一個方法 (Method),方法名稱可以自訂,標上 @RequestMapping 標示,回傳一個物件
即回傳的資料

@RequestMapping 上面需標明綁定的 API 路徑(不可重複)
可綁定何種傳送方式 (GET, POST, PUT, DELETE)

可編譯此專案,並瀏覽之
預設會是 http://localhost:8080/

會得到以下結果:

{"message":"Hello, world!"}

新增另外一個 Method

在剛剛的 HelloController 在新增一個方法 (Method),名叫 getHello()
程式碼片段如下:

@RequestMapping("/hello", method = [RequestMethod.GET])
fun getHello(@RequestParam("name", defaultValue = "World") name: String): GeneralMessageResponse {
    return GeneralMessageResponse("Hello, $name")
}

比起剛剛的 Method 多了參數,只接受 GET 方法(使用其他方式存取會錯誤,因為沒定義)
不標明等於接收所有的方式

參數部分使用 @RequestParam 修飾字標明,標上參數名稱,
defaultValue 如果沒填資料的話的預設值為何
然後像剛才一樣,回傳一個 JSON 物件

方式+名稱 的組合不可重複,但可接受同名但不同的方式的 Method
例如這樣是合法的:

@RequestMapping("/hello")
fun hello(): GeneralMessageResponse {
    return GeneralMessageResponse("Hello, world (from default method)!")
}

@RequestMapping("/hello", method = [RequestMethod.GET])
fun getHello(@RequestParam("name", defaultValue = "World") name: String): GeneralMessageResponse {
    return GeneralMessageResponse("Hello, $name (from get)")
}

@RequestMapping("/hello", method = [RequestMethod.POST])
fun postHello(@RequestParam("name", defaultValue = "World") name: String): GeneralMessageResponse {
    return GeneralMessageResponse("Hello, $name (from post)")
}

後面 Restful API 的時候會再提到。


錯誤處理

錯誤處理有二個部分,不太一樣:

  • 執行時出現 Exception 時,可自訂回傳的錯誤訊息
  • 當存取一個不存在的 API 時,預設回的訊息( 404 Not found 頁面)

處理 Exception 錯誤

先新增一個 GeneralErrorResponse 做為錯誤訊息的回應

GeneralErrorResponse.kt

package com.example.demo.model

import com.fasterxml.jackson.annotation.JsonInclude

@JsonInclude(JsonInclude.Include.NON_NULL)
data class GeneralErrorResponse(
    val message:String, 
    val requestUrl: String? = null, 
    val errorStackStace: String? = null
)

新增一個 ThrowableExtension.kt 新增一個 stackTraceAsString() 拿到 stackTrace 的字串

ThrowableExtension.kt

package com.example.demo.utils

import java.io.PrintWriter
import java.io.StringWriter

fun Throwable.stackTraceAsString(): String {
    val errors = StringWriter()
    this.printStackTrace(PrintWriter(errors))
    return errors.toString()
}

最後新增 GlobalExceptionHandler 用來處理全域的 API 錯誤問題

GlobalExceptionHandler.kt

package com.example.demo

import com.example.demo.model.GeneralErrorResponse
import com.example.demo.utils.stackTraceAsString
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.ControllerAdvice
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.ResponseBody
import org.springframework.web.bind.annotation.ResponseStatus
import javax.servlet.http.HttpServletRequest

@ControllerAdvice
class GlobalExceptionHandler {
    @ExceptionHandler(Throwable::class)
    @ResponseStatus(value = HttpStatus.BAD_REQUEST)
    @ResponseBody
    fun handleException(request: HttpServletRequest, error: Exception): GeneralErrorResponse {
        return GeneralErrorResponse("Error occurred!", request.requestURL.toString(), error.stackTraceAsString())
    }
}

這邊使用了 @ControllerAdvice@ExceptionHandler 標示
當然不同情境有不同的用法,這邊示範回傳一個 JSON

最後在 Controller 新增一個測試用的 Exception 方法

@RequestMapping("/testErr")
fun testErr() {
    throw Exception("test exception")
}

然後瀏覽測試之。

處理 404 Not found 頁面

這裡處理當存取一個不存在的 API 時,預設回的資料。
我們再新增一個 Controller 名叫 MyErrorController 實作 ErrorController 介面,
範例如下:

MyErrorController.kt

package com.example.demo

import com.example.demo.model.GeneralErrorResponse
import org.springframework.boot.web.servlet.error.ErrorController
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

@RestController
class MyErrorController : ErrorController {
    override fun getErrorPath(): String {
        return "/error"
    }

    @RequestMapping("/error")
    fun handleError(): GeneralErrorResponse {
        return GeneralErrorResponse("404 Error")
    }
}

這裡需實作 ErrorController 介面,定義錯誤發生的預設 Path 將走去哪裡
這裡使用 "/error"
然後綁定前述的值,回應一個預設訊息。


參考資料