Golang 1.25 testing/synctest 初體驗:告別在測試中寫 time.Sleep 的日子
前言 在我們目前的應用程式架構中,由於高度與 Kubernetes 耦合,服務啟動與運作期間需要頻繁地去讀取 K8s 中的 ConfigMap。為了達成配置熱更新(Hot Reload),我們引入了 Kubernetes client-go 中的 informers 機制來監聽 ConfigMap 的 CRUD 事件。 雖然 K8s 官方提供了 fake client 讓我們能測試 informers 的邏輯,但在 Service Code 的層級,我們往往需要封裝一層更適合業務邏輯的 ConfigWatcher。Golang 引以為傲的輕量級 Goroutine 與 Channel 搭配非常適合用來處理這種非同步的事件傳遞。 然而,一旦涉及到 Goroutine 的非同步測試,「時間」往往就成了最大的敵人。 遇到的問題:不穩定的測試與魔法數字 為了模擬 ConfigMap 的變更通知,我們定義了一個 ConfigMapWatcher 介面與對應的 Event 結構: const ( ConfigMapUpdateEventTypeAdded ConfigMapUpdateEventType = iota ConfigMapUpdateEventTypeModified ConfigMapUpdateEventTypeDeleted ) type ConfigMapUpdateEvent struct { Name string Type ConfigMapUpdateEventType Value map[string]string } type ConfigMapWatcher interface { Watch(ctx context.Context, eventCh chan<- ConfigMapUpdateEvent) error } 接著,我們很自然地在 testing 中實作了一個 fake 物件來模擬事件發送: type fakeConfigMapWatcher struct { injectCh chan ConfigMapUpdateEvent watchErr error watchOnce sync.Once } func newFakeConfigMapWatcher() *fakeConfigMapWatcher { return &fakeConfigMapWatcher{ injectCh: make(chan ConfigMapUpdateEvent), } } func (f *fakeConfigMapWatcher) sendEvent(event ConfigMapUpdateEvent) { f.injectCh <- event } func (f *fakeConfigMapWatcher) Watch(ctx context.Context, eventCh chan<- ConfigMapUpdateEvent) error { if f.watchErr != nil { return f.watchErr } f.watchOnce.Do(func() { go func() { for { select { case <-ctx.Done(): return case e := <-f.injectCh: eventCh <- e } } }() }) return nil } 問題來了,當我們在寫單元測試時,呼叫 sendEvent 將事件送入 channel 後,消費者端(也就是我們的業務邏輯 Goroutine)並不會「立刻」收到並處理完成。為了確保 assert 斷言執行時,業務邏輯已經跑完了,我們被迫在測試中加入 time.Sleep: ...
RouterOS v7.21 container + App 組合拳,你的下一台 Router 也可以是 All In One Homelab
前言 自從 RouterOS 在 v7.13 引入 container 功能後,這為我們這些 Homelab 愛好者開啟了一扇新的大門。我們終於可以在路由器上原生執行 container,這意味著一些網路相關的基礎設施服務(Infrastructure Services)可以從原本的伺服器中遷移出來。 以我為例,過去我的 Adguard Home 是 deploy 在我的 Homelab 伺服器群中。然而,作為一個喜歡「折騰」的工程師,Homelab 難免會因為硬體更換、系統升級或是實驗新架構而需要重啟。每當這時候,家裡的網路 DNS 解析就會中斷,隨之而來的就是家人的抱怨。 為了解決這個單點故障(SPOF)的問題,我決定將 Adguard Home 改為 deploy 在 Router (RB5009UG) 上。這樣做不僅減少了物理上的 latency,更重要的是將「網路服務」與「應用服務」解耦,避免因為 Homelab 的維護而導致全家斷網。 這是遷移後的狀態: [user@RB5009UG] > container print Flags: R - RUNNING Columns: NAME, ROOT-DIR, INTERFACE, MOUNTLISTS, ENTRYPOINT # NAME ROOT-DIR INTERFACE MOUNTLISTS ENTRYPOINT 0 R b51e0424-8fce-4c3d-b4bb-6493f76aa24b /usb1/container/adguardhome adguardhome adguardhomeConfig /opt/adguardhome/AdGuardHome RouterOS v7.21 的新變革:App 而在最近發布的 RouterOS v7.21 中,Mikrotik 給我們帶來了更方便的 container 部署方式,他們稱呼為 App。 ...
Container Image Optimization 那些年我們寫錯的 Dockerfile
最近在檢視公司內部的專案時,針對其中一個 container image 進行了優化。在一個簡單的 commit 後,我們的 image size 從 1.82GB 修正到了 1.18GB。 透過 dive 查看,Image Efficiency 更是從不及格的 69% 飆升到了 99%。 這讓我回想起過去寫 Dockerfile 時,常常因為不了解 Docker Layer 的機制,或是為了寫起來「方便」,而踩到了許多效能與安全的地雷。 致命的 chown 這次優化的核心,其實源自於一個非常常見的操作:修改檔案權限。 在我們的案例中,Dockerfile 原本是這樣寫的: # Bad Practice: recursive chown after copy FROM ubuntu:22.04 WORKDIR /app COPY . . # ... install dependencies ... RUN dpkg -i packages/*.deb # Change ownership for security reasons RUN groupadd -r appuser && useradd -r -g appuser appuser RUN chown -R appuser:appuser /app 看起來邏輯很正確:把檔案複製進去,安裝套件,最後為了安全性將檔案權限交給非 root 使用者。 ...
Golang 1.26 新特性在數量上史無前例的多
隨著時間來到 2026 年初,Go 語言迎來了 1.26 版本的更新。如果說 Go 1.18 的泛型是語言層面的重大變革,那麼 Go 1.26 則是在「數量」與「廣度」上讓人感到驚艷的一次釋出。從語言特性的語法糖、標準庫的實用擴充,到 Runtime 效能的顯著提升(Green Tea GC),甚至是實驗性的 SIMD 支援,這次的更新內容豐富到讓人目不暇給。 本文將挑選其中幾個我認為對日常開發最重要、或最有趣的改動來進行介紹。 語言層面的改動 new(expr):終於不用再寫輔助函數了 在 Go 1.26 之前,如果我們想要取得一個基本型別(如 int, bool, string)的 pointer,通常需要宣告一個變數或者寫一個輔助函數。這在定義 struct 的 literal 時特別煩人,尤其是當 struct 欄位是 *bool 或 *int 用來區分「零值」與「未設定」的時候。 回顧過去,我們為了這個小需求付出了不少努力: 在 Go 1.18 泛型出現之前:我們經常需要定義一堆如 Int64Ptr(v int64) *int64 或 Float64Ptr(v float64) *float64 的輔助函數(AWS SDK 的使用者應該對此非常熟悉)。 匿名函數大法:如果不想要定義全域的輔助函數,有時甚至會看到像 enabled := func(b bool) *bool { return &b }(true) 這種冗長且難讀的寫法。 泛型時代:雖然可以用一個通用的 ptr[T] 解決,但還是需要額外的程式碼。 以前我們可能需要這樣做: func ptr[T any](v T) *T { return &v } type Config struct { Enabled *bool } conf := Config{ Enabled: ptr(true), } 在 Go 1.26 中,內建的 new 函數得到了增強,現在它不僅接受型別,還可以直接接受表達式(Expression)。 ...
從 GrapheneOS 回歸原廠:Google Pixel 5 刷機紀錄
最近整理抽屜時,翻出了這台陪伴我一段時間的 Google Pixel 5。 身為一個喜歡折騰的開發者,早在它退役成為備用機時,我就第一時間把它刷成了 GrapheneOS。不得不說,GrapheneOS 在隱私保護和安全性上做得真的很好,沒有 Google Play Services 的純淨體驗也別有一番風味(雖然依賴 Sandboxed Google Play 還是能解決大部分 App 的問題)。 但就在前幾天,或許單純是想念 Pixel 原生系統的那些獨家功能(原生相機等等),我決定讓它「回歸原廠」。 原本以為又要經歷一番 adb 和 fastboot 的指令轟炸,還要自己去 Google 官網下載好幾 GB 的 Factory Image 壓縮檔,解壓後再祈禱 flash-all.sh 不要噴錯。 沒想到現在 Google 官方提供了一個方便的網頁工具:Android Flash Tool。 這篇文章簡單紀錄一下如何從 GrapheneOS 這種 Custom ROM,透過官方工具刷回 Stock Firmware 的過程。 為什麼選擇 Android Flash Tool? 以前刷機的標準作業流程(SOP): 搜尋正確的 Factory Image(還要對型號,買到電信商鎖定版就哭哭)。 下載 Android SDK Platform-Tools。 解鎖 Bootloader。 執行 flash-all script。 遇到驅動程式問題、傳輸線問題、路徑問題… 現在的 Android Flash Tool 直接把這些步驟簡化了: WebUSB: 直接透過瀏覽器與手機溝通,不用煩惱太底層的驅動設定(至少在 macOS/Linux 上是這樣)。 Auto Detect: 自動偵測裝置型號,自動下載對應的最新版韌體。 Fool-proof: 圖形化介面引導你開啟 USB Debugging 和 OEM Unlocking。 事前準備 雖然工具很強大,但基本的準備還是要有的: ...
2025 Recap
當兩個人意見不一致,可能其中一個人是錯的。如錯錯的人是你,難道你不想知道嗎? 出自 Ray Dalio 的《原則》一書,是我 2025 年對自己的提問。 接續 2023 年展望,與 2024 Recap,用這篇文章來紀錄今年的自己。 可以搭配今年度最常聽的音樂 Again (Yui Acoustic Version) 工作 去年把工作放到最後,今年則是放到第一位。今年換到了新的公司、新的工作崗位,從十幾位 RD 的新創公司,換到了有一千多位 RD 的大公司。即便已經沒有過去擔憂的「過度官僚」文化,但仍在職也不好多說什麼。 整體來說目前待的團隊,是夾雜著無趣卻也有趣的產品,無趣的點在於產品本身並不性感,目標客群是企業客戶,幾乎沒有什麼創新的概念。有趣的點在於,如何在有限的時間內拓展產品線,並且以 UX 的角度來思考企業客戶如何使用產品,並花費更多時間在前期的 design,而不是單就功能進行開發,時常會需要花費八成的時間在前期規劃,真正的開發時間就是靠 AI(LLM) 解決。 隨著團隊拓展,我也承擔了 mentor 的責任,第一次帶新人多少有些徬徨與無助。隨著團隊擴展,漸漸的開始反思現代軟體開發,一件一個人能完成的事情被拆成四五個人都要有貢獻,導致交付速度不升反降,這樣真的是正確的嗎?我想還需要更多的時間積累才能找到答案。 不得不提的就是,目前的公司有「近乎無限」的 AI 資源(特別指 LLM),讓手速不再成為寫程式的瓶頸。與前東家不同的是,這間公司是在今年才開始轉型 AI First,並且透過 sharing & AI Hackathon 來推廣 AI 在內部開發與產品上的使用。不過觀察下來,年初的 AI 使用率應該是不高的,就連我這個 AI 質疑論者,都能被邀請去 All Hands 進行 AI 使用的 session。 健康 今年在健康上只能說完全荒廢,與去年底期許的目標,不僅完全沒有維持,更糟糕的是因為通勤距離變遠了,導致運動時間大幅下降。 但… 我想這一切都只是藉口,單純只是變懶惰、沒了動力。只能把希望寄託在 2026 年,能透過「12周做完一年工作」這本書內提到的方法,不設定目標,而是設定行動,先把行動給完成再看看怎麼推進了。 旅遊 隨著離開新創公司,又回到了下班不認識的那個自己,今年多數旅遊都是跟 Murphy 兩人,連爬山也變少了,希望 2026 年能投入更多時間在旅遊上。 ...
Helm Smart Resource:讓你的 Chart 學會與既有資源和平共處
前言 在 Kubernetes 的世界裡,Helm 無疑是管理應用程式部署的霸主。它標準化了資源的定義,讓我們可以用宣告式的方式管理整套系統。然而,在真實世界的維運場景中,事情往往沒那麼單純。 我們常遇到一種尷尬的情況:某些資源(例如 Database 的 Secret、外部系統的 ConfigMap)可能在 Helm Chart 安裝之前就已經由維運人員手動建立,或是由另一個流程(如 Terraform)預先準備好了。 這時候,如果直接執行 helm install,往往會收到 “resource already exists” 的錯誤;如果使用 helm upgrade --install,又擔心 Helm 會覆蓋掉這些既有設定。 這篇文章將分享一種「Smart Resource」的設計模式,透過 Helm 的 lookup 函數與樣板邏輯,讓你的 Chart 能夠聰明地判斷:「這東西是我管的嗎?如果是,我才動它;如果不是,我就尊重現狀。」 核心難題:Ownership 在 Kubernetes 中,資源的「所有權」觀念至關重要。Helm 預設認為它 release 中的所有資源都應該由它全權管理。但當我們需要與外部資源協作時,我們需要更細緻的控制。 我們的目標很明確: 若資源不存在:建立它,並標記為 Helm 管理。 若資源已存在且由 Helm 管理:更新它(Patch/Merge)。 若資源已存在但由外部管理:保持原狀,不進行覆蓋或刪除。 為了達成這個目標,我們需要一個輔助函數來判斷資源的歸屬權。 實作細節 1. 定義所有權檢查 首先,我們在 _helpers.tpl 中定義一個檢查函數。Helm 會在它建立的資源上打上特定的 Annotations(meta.helm.sh/release-name 和 meta.helm.sh/release-namespace)。我們可以利用這一點來判斷資源是否屬於當前的 Release。 {{/* Check if a resource is owned by this Helm release Returns "true" or "false" as string for stable piping */}} {{- define "visionone-filesecurity.isOwnedByRelease" -}} {{- $resource := .resource -}} {{- $releaseName := .releaseName -}} {{- $releaseNamespace := .releaseNamespace -}} {{- $owned := and $resource (hasKey $resource.metadata "annotations") (eq (get $resource.metadata.annotations "meta.helm.sh/release-name") $releaseName) (eq (get $resource.metadata.annotations "meta.helm.sh/release-namespace") $releaseNamespace) -}} {{ printf "%t" $owned }} {{- end -}} 這段程式碼邏輯很簡單:只有當資源存在,且其 Annotations 中的 Release Name 與 Namespace 都與當前 Release 相符時,才視為「Owned」。 ...
Interface 不是有開就好:從一個 PR 來看抽象化的重要性
前言 最近團隊正在開發一個新產品,其中一個核心功能需要 client 與 server 之間進行即時、雙向的溝通。經過一番技術評估,我們決定採用 WebSocket 來實現這個需求。 身為一個良好習慣的開發團隊,我們在開發初期就導入了依賴注入(Dependency Injection),希望透過界面(Interface)來解耦商業邏輯與具體的實作,這樣不僅能提高程式碼的可測試性,未來在更換底層實作時也能更加輕鬆。 一切聽起來都很美好,直到我在一次 Code Review 中,看到了一段熟悉的程式碼。 一個 PR 的故事 在我們的 Domain Layer,也就是處理核心商業邏輯的地方,我看到同事定義了下面這個 interface: // package/to/domain/service.go // WebSocketService defines the interface for websocket communication. type WebSocketService interface { // StartAndLinsten starts the service and listens for incoming messages. StartAndLinsten(ctx context.Context) error // Send sends a message to the client. Send(ctx context.Context, message any) error // ... other methods } 第一眼看過去,好像沒什麼大問題。有名稱、有方法、也確實是個 interface。然而,當我細看 WebSocketService 這個命名時,總覺得哪裡怪怪的。 於是我在 PR 上留下了這樣的 comment: 這個界面主要是抽象化 client 與 server 間的互動,不應該侷限於 WebSocket 這個 Protocol。假如我們未來要換成使用 socket.io 或是 gRPC stream,是不是連 domain 層的 interface 也要跟著改動? ...
監控你的執行檔:初探 watchexec
前端開發有 liveserver,後端開發有 air,那 TUI 開發呢?本文記錄了我在開發 Bubbletea 應用時,從 air 轉向 watchexec 的心路歷程,以及如何使用這個通用工具來優雅地實現終端機應用的熱重載。
用 Golang Bubbletea 打造終端機應用:從 Hello World 到多頁面架構
探索如何使用 Golang 的 Bubbletea 函式庫,基於 Elm 架構,從零開始打造一個互動式終端機應用(TUI)。本文將從一個簡單的計數器範例,逐步引導你建構出一個類似 Web 應用的多頁面架構,並分享整個生命週期中的關鍵概念與注意事項。