打開測試的黑盒子:gomock 的設計與生命週期

打開測試的黑盒子:gomock 的設計與生命週期

2023 年 6 月 28 號,rsc 提交 github.com/golang/mock 最後的 commit,他修改 README,加入

Update, June 2023: This repo and tool are no longer maintained. Please see go.uber.org/mock for a maintained fork instead.

更新,2023 年 6 月:此倉庫及工具已不再維護。請改用 go.uber.org/mock 這個持續維護的分支。

同年稍早的 5 月 18 號,在 commit f0fd852 中,sywhang 替 github.com/uber-go/mock 的 README 加上一段

This project originates from Google's golang/mock repo. Unfortunately, Google no longer maintains this project, and given the heavy usage of gomock project within Uber, we've decided to fork and maintain this going forward at Uber.

此專案起源於 Google 的 golang/mock 倉庫。不幸的是,Google 不再維護此專案,鑑於 gomock 專案在 Uber 內部的廣泛使用,我們決定在 Uber 進行分支並持續維護。

這起 gomock 封存與轉移維護的小事件,倒是提醒我,平常都把 gomock 當成測試工具在用(它的確是),沒想過黑魔法也有源頭。這重要嗎?說實在也不重要,不知道 gomock 的原理,還是能寫出高覆蓋率的測試。

既然如此,我們還需要知道 gomock 的原理嗎?這就像在問為什麼要看(其他的)原始碼一樣。開發者的日常充滿各種「它可以這樣用嗎?」的時刻,面對複雜的需求,有時需要回到原點,用另一種的角度檢視原本熟悉的事物,例如在 README Building Stubs 一節中,gomock 提到一則用例

func TestFoo(t *testing.T) {
  ctrl := gomock.NewController(t)

  m := NewMockFoo(ctrl)

  // Does not make any assertions. Executes the anonymous functions and returns
  // its result when Bar is invoked with 99.
  m.
    EXPECT().
    Bar(gomock.Eq(99)).
    DoAndReturn(func(_ int) int {
      time.Sleep(1*time.Second)
      return 101
    }).
    AnyTimes()

  // Does not make any assertions. Returns 103 when Bar is invoked with 101.
  m.
    EXPECT().
    Bar(gomock.Eq(101)).
    Return(103).
    AnyTimes()

  SUT(m)
}

它嘗試告訴開發者,gomock 可以當成 stub 用,當 Bar() 的參數是 99 或 101 時,mock 會分別回覆 101 跟 103。可是如果改用 100 當參數呢?拿掉 Return() 會發生什麼事?又或者移除 AnyTimes() 的話呢?雖然看起來很類似,但它們會分別對應到三種不同的報錯邏輯--非預期呼叫、回傳值錯誤與預期未實現。

於是乎有了這篇,正文開始前先來個小提醒:我想知道 gomock 如何施展魔法,但不打算討論軟體測試的工程實務,如果你想知道如何建立 Go 的單元測試,可以參考我另一篇文〈初探 Go 的單元測試:兼談 Stub 跟 Mock〉

框架與原生架構

先來看個最簡單的 gomock 使用範例

func TestFoo(t *testing.T) {
  ctrl := gomock.NewController(t)

  m := NewMockFoo(ctrl)

  m.
    EXPECT().
    Bar(99)

  SUT(m)
}

為了聚焦在 gomock,我們把受測程式(System under test)的邏輯放入 SUT,mock 用依賴注入傳進 SUT 中,這也是 mock 的標準用法,能分離受測邏輯與測試邏輯。

這段程式中有兩個值得注意的點,首先是 gomock.NewController 接受 gomock.TestReporter 的 interface,傳回一個 *gomock.Controller ;再來是 NewMockFoo 吃進剛剛創建的 *gomock.Controller ,回傳最後要用的 mock。

你應該會好奇,什麼是 TestReporter 跟 Controller?這兩個東西的用途是什麼?

TestReporter

在函式庫中,TestReporter 的定義是

// A TestReporter is something that can be used to report test failures.  It
// is satisfied by the standard library's *testing.T.
type TestReporter interface {
	Errorf(format string, args ...any)
	Fatalf(format string, args ...any)
}

「用於報告測試失敗」,我們把鏡頭加快一點,直接拉到 controller.go,會看到

	for _, call := range failures {
		ctrl.T.Errorf("missing call(s) to %v", call)
	}

因此 TestReporter 的用途很明確了,當測試失敗時,gomock 會調用 TestReporter,告訴你出問題的原因,通常是用 stdio 印出訊息,於是你會在 terminal 中看見出了哪些問題。

TestReporter 刻意設計成相容原生測試架構,也就是跟 testing.T 相容。我們在範例程式中看到,設置 gomock 的第一步,是將 testing.T 傳給 gomock,這不是偶然,而是它要去接管原生架構的測試流程。你可能會想:儘管我們將 testing.T 傳給了 gomock,可是既然它不是原生架構,又怎麼知道要在「什麼時間點」調用 TestReporter 呢?這是個好問題,也是 Controller 存在的原因。

Controller

關於 Controller,在 gomock 的原始碼中,可以找到一段註解

A Controller represents the top-level control of a mock ecosystem.  It defines the scope and lifetime of mock objects, as well as their expectations.  It is safe to call Controller's methods from multiple goroutines. Each test should create a new Controller.

Controller 代表模擬生態系統的頂層控制。它定義了模擬對象的範圍和生命週期,以及它們的期望。從多個 goroutine 調用 Controller 的方法是安全的。每個測試都應該創建一個新的 Controller。

先來釐清幾種不同的邏輯,跟受測程式有關的邏輯,會放在 SUT 中,你可以當它是實際會執行的程式碼,例如

func SUT(foo Foo) {
  foo.Bar(100)
}

跟測試有關的邏輯,會放在 foo_test.go 內某個測試函數底下,例如要設定呼叫與回傳的「期待」,它會是

func TestFoo(t *testing.T) {
  //...
  m.
    EXPECT().
    Bar(99)
}

這些期待的管理、比對,以及錯誤訊息的格式,這都是 Controller 負責的範疇。可以說 Controller 負責所有跟 mock 有關的邏輯。

Graceful degradation

我們進一步細看 Controller 初始化的流程,當開發者將 TestReporter 傳給 NewController 時,它會進行幾件事

func NewController(t TestReporter, opts ...ControllerOption) *Controller {
	h, ok := t.(TestHelper)
	if !ok {
		h = &nopTestHelper{t}
	}
	ctrl := &Controller{
		T:             h,
		expectedCalls: newCallSet(),
	}
	// ...
	if c, ok := isCleanuper(ctrl.T); ok {
		c.Cleanup(func() {
			ctrl.T.Helper()
			ctrl.finish(true, nil)
		})
	}

	return ctrl
}

開頭跟 TestHelper 有關的段落稱為「優雅降級(Graceful degradation)」,它會確認拿到的結構是否符合 TestHelper interface

// TestHelper is a TestReporter that has the Helper method.  It is satisfied
// by the standard library's *testing.T.
type TestHelper interface {
	TestReporter
	Helper()
}

從定義可以清楚看到,TestHelper 是在原本 TestReporter 的基礎上,多了一個 Helper(),這是 Go 1.9 新加入的 func,用於跳過測試輔助工具的顯示

The new (*T).Helper and (*B).Helper methods mark the calling function as a test helper function. When printing file and line information, that function will be skipped. This permits writing test helper functions while still having useful line numbers for users.

新的 (*T).Helper 和 (*B).Helper 方法會將呼叫的函式標記為測試輔助函式。在列印檔案和行號資訊時,該函式會被跳過。這允許撰寫測試輔助函式,同時仍能為使用者提供有用的行號資訊。

這個功能很有用,因為當我們在查看測試結果時,想知道的是 SUT 或測試案例失敗的行數,而不是 gomock 的行數。如果不假思索直接印出,會把框架的資訊一起放入。呼叫 Helper 可以讓 gomock 變透明,然而麻煩的點是,這個功能是 1.9 版才加入,強制呼叫 Helper 會造成 1.9 版前的測試無法執行。

為了使用 Helper,又不打破向前兼容,gomock 檢查傳入的 *testing.T 是否實現 Helper,如果沒有,會重新包裝成 nopTestHelper,給予一個空的 Helper func,這樣後續呼叫 Helper 還是能呼叫,只是不會有效果。

Cleanup

NewController 還會初始化 Controller,並將 ctrl.finish 註冊到 Cleanup 中。

	if c, ok := isCleanuper(ctrl.T); ok {
		c.Cleanup(func() {
			ctrl.T.Helper()
			ctrl.finish(true, nil)
		})
	}

Cleanup 的用處是讓測試結束時,會自動執行 callback 函式。還記得前面提到 gomock 不是原生架構,要如何知道調用的時間點嗎?關鍵就是利用 Cleanup。在 testing/testing.go 中能看到,Cleanup 會把 gomock 的函式註冊到 c.cleanups 中

// Cleanup registers a function to be called when the test (or subtest) and all its
// subtests complete. Cleanup functions will be called in last added,
// first called order.
func (c *common) Cleanup(f func()) {

	// skip...

	c.mu.Lock()
	defer c.mu.Unlock()
	c.cleanups = append(c.cleanups, fn)
}

Cleanup 是 Go 1.14 新加入的 Method,在 Go 1.14 前,如果想要確認「期待」是否有發生,需要在測項最後手動調用 Finish() ;Go 1.14 後,調用的時間點改由框架負責,使用起來更直接。

有意思的是,儘管在 Cleanup 出現後,gomock 跟原生架構結合得更加緊密,我們還是能在 gomock 的 sample 中發現殘留的痕跡(魔術師忘記收的機關箱?),例如在 user_test.go 這個檔案中,可以發現如下測項

func TestExpectCondForeignFour(t *testing.T) {
	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	mockIndex := NewMockIndex(ctrl)
	mockIndex.EXPECT().ForeignFour(gomock.Cond(func(x imp_four.Imp4) bool {
		return x.Field == "Cool"
	}))

	mockIndex.ForeignFour(imp_four.Imp4{Field: "Cool"})
}

假設你跟我一樣好奇,可以試著手動註解 defer ctrl.Finish() ,你會發現測項還是能正常執行。

管理期待

現在我們知道了 gomock 會用 TestReporter 跟 Cleanup 來整合原生生態,但最重要的一點,mock 的期待與回應是如何管理的呢?這時要來介紹另一個新成員:Recorder。

gomock 有個 CLI 工具稱為 mockgen,能使用原始碼中的 interface,自動產生對應的 mock。典型的 mock 結構是

// MockFoo is a mock of Foo interface.
type MockFoo struct {
	ctrl     *gomock.Controller
	recorder *MockFooMockRecorder
	isgomock struct{}
}

看到 ctrl 欄位,應該不用多說,這是用來存 Controller。前面講過 Controller 會負責管理期待,因此在創建 mock 時也要把它放進來,稍後會看到它如何派上用場

// NewMockFoo creates a new mock instance.
func NewMockFoo(ctrl *gomock.Controller) *MockFoo {
	mock := &MockFoo{ctrl: ctrl}
	mock.recorder = &MockFooMockRecorder{mock}
	return mock
}

另個重要欄位就是 recorder 了。 Recorder 是 mock 跟 Controller 間的橋樑,表面上看來,它跟 mock 同樣,都實現了要模仿的 interface。然而實際上,當開發者調用 Recorder 時,它會偷偷將訊息記錄到 Controller 中,好讓 Controller 能跟真實調用進行比對。

用講的有點抽象,同樣舉個例子。對於 Foo 的 mock

func TestFoo(t *testing.T) {
  // ...
  m.
    EXPECT().
    Bar(gomock.Eq(99)).
    Return(101)
  // ...
}

當執行 EXPECT() 後,在調用鏈上,回傳的是 Recorder 而不是 mock

// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockFoo) EXPECT() *MockFooMockRecorder {
	return m.recorder
}

因此,當調用 Recorder 的 Bar() 時,實際做的事情是

// Bar indicates an expected call of Bar.
func (mr *MockFooMockRecorder) Bar(arg0 any) *gomock.Call {
	mr.mock.ctrl.T.Helper()
	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Bar", reflect.TypeOf((*MockFoo)(nil).Bar), arg0)
}

Recorder 呼叫 Controller 的 RecordCallWithMethodType 函式

// RecordCallWithMethodType is called by a mock. It should not be called by user code.
func (ctrl *Controller) RecordCallWithMethodType(receiver any, method string, methodType reflect.Type, args ...any) *Call {
	ctrl.T.Helper()

	call := newCall(ctrl.T, receiver, method, methodType, args...)

	ctrl.mu.Lock()
	defer ctrl.mu.Unlock()
	ctrl.expectedCalls.Add(call)

	return call
}

而 Controller 會將這個呼叫製作成 Call 結構,存放到內部儲存空間。意思是,每次對 Recorder 的調用,都會在 Controller 中創建一個 Call,這個 Call 會用 Method 名稱當鍵,放在「預期呼叫」中,日後要知道實際呼叫是否都有符合預期,只要檢查「預期呼叫」有沒有這筆 Call。

實際上的邏輯也差不多,當呼叫 mock 的 Bar()

// Bar mocks base method.
func (m *MockFoo) Bar(arg0 string) string {
	m.ctrl.T.Helper()
	ret := m.ctrl.Call(m, "Bar", arg0)
	ret0, _ := ret[0].(string)
	return ret0
}

會調用 Controller 的 Call()

// Call is called by a mock. It should not be called by user code.
func (ctrl *Controller) Call(receiver any, method string, args ...any) []any {
		// ...
		expected, err := ctrl.expectedCalls.FindMatch(receiver, method, args)
		if err != nil {
			// ...
			ctrl.T.Fatalf("Unexpected call to %T.%v(%v) at %s because: %s", receiver, method, stringArgs, origin, err)
		}
		// ...
		if expected.exhausted() {
			ctrl.expectedCalls.Remove(expected)
		}
        //...
}

如果 FindMatch 出錯,在預期呼叫中找不到 Bar(),表示對 Bar() 的呼叫是個非預期呼叫。Controller 會用 TestReporter 回報錯誤,結束當前執行。

知道原理,然後呢?

重複一下我們已經知道的事情:

  • gomock 的 Finish() 會確認「期待是否發生」
  • Finish() 在 Go 1.14 前,需要手動調用,Go 1.14 後,框架會自動調用
  • 如果 mock 有「非預期調用」,會在當下回報錯誤,結束執行
  • 所有「期待」都放在 Controller 內部結構中,由 Controller 管理

這些資訊對我們有什麼幫助?有的,我們來看一個比較現實,但也比較複雜的案例。為了更容易管理測試,有些專案會使用 github.com/stretchr/testify/suite,它的測例類似

func TestServiceSuite(t *testing.T) {
	suite.Run(t, new(ServiceSuite))
}

func (suite *ServiceSuite) SetupSuite() {
	app := fx.New(
		fx.Supply(
			fx.Annotate(suite.T(), fx.As(new(gomock.TestReporter))),
		),
		fx.Provide(
			gomock.NewController,
			foo.NewFooMock,
		),
		fx.Populate(
			&suite.foo,
		),
	)
	app.Start(context.Background())
	suite.app = app
}

func (suite *ServiceSuite) TestBar() {
	suite.foo.
  	  	EXPECT().
      	Bar().
      	AnyTimes()

	SUT()
}

testify/suite 中,測試用「套組(suite)」來分類,每個套組下有一堆案例,例如 ServiceSuite 下有 TestBar 這個案例。這種結構跟物件導向的相容性很好,我們可以針對每個物件建立一個套組,然後再針對物件下的 Method 建立案例,這些案例都可以享有共同的初始化流程(SetupSuite),省去你搭建手腳架的功夫,在上面的例子中,我們使用 fx 來處理依賴注入。而如果你想進一步細分各案例,可以再把表格驅動測試放進去。

重點是,你為了讓測試更容易進行,會用 gomock 來模仿外部依賴,於是問題出現了,在上面的測試中,可能潛藏什麼狀況?

首先,Finish() 會在哪個時間點被調用呢?答案是 TestServiceSuite() 結束的時候。因為我們沒有手動調用,而且傳給 Controller 的仍然是 *testing.T ,因此只有在整組套組都執行完畢後,原生架構才會呼叫 Finish() 。這也使得每個案例的呼叫會累積到結束才一次性清理。

讓我們假設有兩個測試案例,案例一應該呼叫 Bar() 兩次,結果它只呼叫了一次,Call 沒被完全滿足,繼續保存在 Controller 中。這時輪到案例二,它的預期是呼叫 Bar() 一次,因此又多加了一筆 Call 到 Controller 中。等到案例二真的呼叫時,因為 Method 名稱相同,它消耗的是案例一殘留的 Call,而不是案例二新加的 Call。整組測試結束後,系統會告訴你,由於案例二的 Call 沒滿足,因此案例二測試失敗。

明明是案例一的問題,系統卻說是案例二。沒注意的話,可能就會在正常的程式碼中找半天還找不到問題了。

問題根源是 Controller 跟測試案例的生命週期錯位,開發者會覺得,單個案例跑完後,應該要知道測試結果了,但實際上,要等整個套組跑完後,Controller 才會檢查「期望」是不是都發生。既然知道原因了,解法也很單純,對齊它們就好,在 testify/suite 中,對齊生命週期最簡單的方式,是利用 Suite 的 Hook 函式

func (suite *ServiceSuite) SetupTest() {
	suite.ctrl = gomock.NewController(suite.T())
	suite.foo = foo.NewFooMock(suite.ctrl)
}

func (suite *ServiceSuite) TearDownTest() {
	suite.ctrl.Finish()
}

這是 testify/suite 的內建機制,每個測試開始前,會先執行 SetupTest() ,建構新的 Controller;等到測試結束後,會執行 TearDownTest() ,手動清理 Controller。如此一來,我們能確保每個案例執行時,用到的 Controller 都是新的(當然它內部的 Call 也是);以及,Controller 會在我們希望的時間點比對期待。

當談論框架時,我們想的是

原本問題到這邊就結束了,畢竟已經知道 gomock 的生命週期了嘛,只要掌握幾個原則,出問題都能找到答案。但是呢,在查資料的途中,意外發現 Chromium 關於 Gmock 的討論,雖然 Gmock 不是 gomock,可是內容提到的 mock 使用問題,把思考從框架應用拉到「應不應該用框架」這種更本位的角度,讀起來非常有趣。

這段討論被標記在 Chrome OS 單元測試最佳實踐中

However, Google-Mock-based tests are often brittle and difficult to maintain. Tests that use Google Mock frequently verify behavior that‘s not directly related to the functionality being tested; when refactoring code later, it then becomes necessary to make changes to unrelated tests. On the other hand, if tests instead underspecify the behavior of mocked objects, Google Mock can produce large amounts of log spam (e.g. Uninteresting mock function call warnings that are logged when a mock’s return values are not specified).

然而,基於 Google Mock 的測試通常較為脆弱且難以維護。使用 Google Mock 的測試經常會驗證與被測試功能不直接相關的行為;當日後重構程式碼時,就必須對不相關的測試進行修改。另一方面,如果測試對模擬物件的行為規範不足,Google Mock 可能會產生大量的日誌垃圾(例如,當未指定模擬回傳值時會記錄的 Uninteresting mock function call 警告)。

這段話的意思是,測試的目的是要驗證邏輯是否正確,但為了覆蓋情境,我們需要讓 mock 能依照輸入給出不同輸出,在預設中,mock 會確認輸入參數,而這很可能不是開發者想要確保的範圍。舉例來說,我們只想知道 Bar() 的回傳值是 101 時會發生什麼事,但這不代表我們想確保 Bar() 的輸入參數要是 99。

對輸入參數的過度依賴會造成測試的可修改性降低,常用 mock 的人應該都遇過,當你修改某處邏輯,雖然不會造成結果變動,卻因為沒有滿足預期呼叫而讓 mock 報錯。到頭來為了確保跟品質無關的部位反而讓軟體難以修改,這就是「脆弱且難以維護」的原因。

討論串中有提到 mock 框架的優點

I see the main benefit of gmock in being able to easily create mocks for interfaces, instead of having to roll a mock implementation of my own every time or maintain a generic mock implementation that can become complex very quickly.

我認為 gmock 的主要優點在於能輕鬆為介面建立 mock,而不必每次都自己寫一個 mock 實作,或維護一個可能很快變得複雜的通用 mock 實作。

可是這同時也帶來架構上的風險

...Even worse, because it is easy to add behavior into a Gmock object, I've seen people script a mock with a long sequence of calls/results.  The test now has moved beyond asserting invariants about API behavior to asserting the specific call sequence the current implementation has

...更糟的是,因為在 Gmock 物件中加入行為很容易,我看到有人用一長串呼叫/回傳結果來編寫 mock。這樣的測試已經不再是斷言 API 行為的不變條件,而是斷言目前實作的特定呼叫序列。

簡單來說,如果物件有依賴,開發者會使用 mock 來取代它。而因為 mock 要模仿物件很容易--幾乎是加一行程式碼而已--開發者會傾向添加 mock 的期待,等到它的行為太過複雜,要轉向其他方式就很麻煩了。

在使用場景上,mock 框架更像是個用於單元測試的臨時方案,它可以省去開發者建構 stub/mock 這類工具的時間,但也有它的架構債要衡量。至於到底該不該用呢?以 Chromium 開發者的觀點,他們沒有禁用 mock 框架,而是將討論串當成負面引用,提醒開發者應該注意 mock 的代價

From this thread, the main positives I hear are that 
a) it gives a consistent, easy, method for creating mocks.
b) since it is consistent, it can be easier to read that ad-hoc mock implementations

The negatives are:
a) it has been often misapplied in situations where mocks are inappropriate (eg., integration tests).
b) as tests grow, people just add complexity to the mock rather than refactor the API to be testable.
c) people who aren't versed in it have to learn a new meta-language.

從這個討論串中,我聽到的主要正面意見是
a) 它提供了一種一致、簡單的建立 mock 的方法。 
b) 由於它是一致的,比臨時的 mock 實作更容易閱讀

缺點是: 
a) 它經常被誤用在不適合使用 mock 的情況(例如整合測試)。
b) 隨著測試規模擴大,人們只是增加 mock 的複雜度,而不是重構 API 以便於測試。
c) 不熟悉它的人必須學習一種新的元語言。

小結

實際看 gomock 的原始碼,可以看到不管是 Go 1.9 的 Helper() 還是 Go 1.14 的 Cleanup() ,Go 語言都在思考如何讓生態系工具更容易整入原生框架。我以前沒有意識到「整合生態系」是什麼意思,現在來看,倒是可以給出個相對明確的定義:讓人更容易對齊原生框架的生命週期。

另個思考是關於 mock 的使用時機,最開始只要有外部依賴,我都傾向用 mock 來解決,但後來發現這不是個萬用解,例如 testcontainers-go 使用執行時容器來處理依賴,能幫助整合測試變得更簡單、更乾淨。你說要因此把原本的 mock 都拆掉,改用容器嗎?那也不盡然。畢竟容器成本還是比較高,開開關關會大幅降低測試速度。

總之呢,上面講的大多數問題,在實務中可能都不會遇到,但如果你發現 mock 用起來跟想的有點不同,或者想到某個有趣的用法,想知道「mock 可以這樣用嗎?」,那可以參考上面提到的一些面向,說不定會有新想法。

Reference

Read more

Weekly Issue 第 2 期:Linux 基金會啟動 FAIR 專案

有些產品看到會覺得行不通,有些產品則相反,只要聽到就覺得是個好主意。Sentry 的產品通常都是後者。我猜有部分,也是因為它們的產品都指向同一個使命:可除錯性。 🗞️ 熱門新聞 Linux Foundation Announces the FAIR Package Manager Project for Open Source Content Management System Stability Linux 基金會啟動 FAIR 專案,為 WordPress 外掛程式提供替代方案。 底下的 Supporting Quotes 可以看看,講話都很客氣,左一句「去中心化」右一句「透明的治理架構」,在講什麼大家都很清楚 😜 。 Uber 與 Airbnb 重塑 VC 玩法,一文看懂 a16z 創辦人

By Ken Chen

Weekly Issue 第 1 期:Stack Overflow 流量大跌

來自阮一峰老師的靈感與嘗試,我會在 Weekly Issue 中記錄每周值得分享的科技內容,周一發刊。多數內容都有刊在我的 X、Threads 或 Facebook 中,你可以追蹤上述社群媒體得到最新消息。這裡的性質更接近單周回顧與歷史歸檔。 🗞️ 熱門新聞 The Pulse #134: Stack overflow is almost dead StackOverflow 的情況比我想的還糟,退化到剛成立三個月的狀況?太要命了。 我自己好奇的是 2020 年的衰退如何引起?平台治理的問題嗎? Cloudflare service outage June 12, 2025 Cloudflare 近幾次中斷事故都有出報告,內容包括背景跟時間軸,還有改善方式,這是很正確也很重要的實踐。 我也曾經遇過幾次重要的服務停機事件,當時都會盡可能擠出時間即時更新 + 出報告。後來服務也的確越來越穩。這種問題很多都是文化層面的問題。 Ask HN: How

By Ken Chen
自訂網域很難嗎?DNS 的限制與實踐

自訂網域很難嗎?DNS 的限制與實踐

自訂網域(Custom Domain)是 SaaS 常見的服務,只是我通常都沒花錢買。某次跟朋友聊天,她想聽聽我對內容平台的觀點,嘰哩呱啦分析完一堆後,我最後建議她,最好還是買個網域: 「你想想看,妳現在投入這麼多心力在經營內容,建立自己的品牌形象。如果妳的網址永遠都掛在別人的平台底下,就像在別人家租房子,雖然方便,但終究不是自己的。」 「有了自訂網域,妳的品牌就是自己的,無論未來平台怎麼變,妳的讀者都能透過固定的網址找到妳,這對品牌來說很重要。就算未來妳想換平台,也不會流失妳辛苦建立起來的流量。」 後來我在 Ghost 官方頁面看到類似說法 If you would like to make your site memorable and easy to find with a branded custom domain, then you can

By Ken Chen
從個人貢獻者到管理者:關於領導的反思

從個人貢獻者到管理者:關於領導的反思

某個下雪天,我拖著病體,組裝一套供使用者簡報之用的破爛系統,莎朗進來發現我在操控台前勉強支撐,她便離開了,幾分鐘後,她端著一鍋湯回來,為我倒了一杯,我的精神為之一振。我問她要做的管理工作那麼多,怎麼會有空做這種事,她向我展露她的招牌微笑,說:「湯姆,這就是管理。」 -《Peopleware: Productive Projects and Teams》 有次跟一名職涯顧問聊天。我提到:「我希望透過打造產品來替別人創造價值,如果有很棒的團隊,我相信自己能辦到。」她問:「團隊是必須的嗎?」我愣住了,隨口說:「因為打造產品需要很多不同的職能……還需要可持續性的運作,對,我想團隊是必要的。」事後回想,她的問題很有趣,現代社會好像把「團隊」和「領導」當成是成功的標配,人力市場也一堆團隊主管的職缺,這是一則現代神話嗎?還是某種工業革命時代的遺產? 身為個人貢獻者的管理者 不是說團隊不重要,只是在現代,你會用不同的角度審視完成目標需要的條件。你想想,如果你是個開發者,自己架網站、

By Ken Chen