Simple Lists:用老派方式打造的極簡待辦清單 App(伺服器端 Go,無 JavaScript)
摘要:本文將說明我為何以及如何用 Go 打造 Simple Lists,這是一個極簡的待辦清單網頁 App。它採用老派的做法:由伺服器端渲染 HTML,透過傳統的
GET與POST搭配 HTML 表單,不使用任何 JavaScript。
我一直想再做個小小的 side project:單純為了創造與寫程式的樂趣而動手做點東西。我也想做個對自己或家人可能有用的東西,於是就做了一個小小的待辦清單 App。我知道市面上已經有成千上萬的待辦清單 App,但我就是喜歡動手打造東西,尤其是符合小網路理念的東西。
如果你的電腦已經安裝了 Go,就能非常輕鬆地在本地端執行這個 App(第一次下載與建置會花幾秒鐘):
$ git clone https://github.com/benhoyt/simplelists.git
Cloning into 'simplelists'...
...
$ cd simplelists
$ go run .
2021/09/28 20:51:14 listening on http://localhost:8080請參考原始碼,我們會在下面進一步說明。接下來就深入看看吧!
功能

Simple Lists 的功能並不多:
- 你可以建立新的清單。每個清單在網址中都有一組隨機 ID,因此算是半私密的:只有知道網址的人才能存取該清單。
- 你可以在清單中新增項目。
- 你可以將項目劃掉(也可以復原)。
- 你可以從清單中刪除項目。
- 在手機上也能正常使用。
- 可防範跨站腳本(XSS)與跨站請求偽造(CSRF)攻擊。
選用功能(我自己的實例有啟用這些):
- 如果啟動伺服器時將環境變數
SIMPLELISTS_LISTS設為1,首頁就會顯示所有清單的列表,並允許你刪除清單。 - 如果啟動伺服器時設定了環境變數
SIMPLELISTS_USERNAME與SIMPLELISTS_PASSHASH,就必須使用帳號與密碼登入才能存取網站。(這是我個人使用的,所以只有一組使用者。)
為了保持簡單,Simple Lists 不提供以下功能:
- 無法重新排序清單中的項目。
- 無法編輯項目,只能刪除後重新新增。
- 沒有花俏的顏色、圖片或樣式。
就像我說的,Simple Lists,貴在簡單。但對我來說已經夠用了!
沒有 JS(不是 Node JS!)
其實一開始我有用了一點點客戶端 JavaScript,想讓操作更流暢一點。但除了我本來就比較喜歡用 Go 而不是 JavaScript 之外,我也發現根本不需要客戶端腳本——只要把東西做得小而快,多幾次重新載入頁面根本不是問題。
所以我把原本那一點點 JavaScript 全部拿掉,改為直接用 HTTP POST 方法搭配最傳統的 HTML 表單來修改待辦清單資料庫的狀態。這樣做的好處是,你只需要用 Go,再加上一點 HTML 即可。完全沒有任何一行 JavaScript,也不需要像 Babel 或 Webpack 這種花俏的建置工具。
對於只用過客戶端 JavaScript 來實現互動的人,讓我們快速看一下這是怎麼運作的。
對於每個清單項目(放在 <ul> 無序清單中),我們使用以下的 HTML:
<li style="margin: 0.7em 0">
<form style="display: inline;" action="/update-done"
method="POST" enctype="application/x-www-form-urlencoded">
<input type="hidden" name="csrf-token" value="{{ $.Token }}">
<input type="hidden" name="list-id" value="{{ $.List.ID }}">
<input type="hidden" name="item-id" value="{{ .ID }}">
<input type="hidden" name="done" value="on">
<button id="done-{{ .ID }}" style="width: 1.7em"> </button>
<label for="done-{{ .ID }}">{{ .Description }}</label>
</form>
<form style="display: inline;" action="/delete-item"
method="POST" enctype="application/x-www-form-urlencoded">
<input type="hidden" name="csrf-token" value="{{ $.Token }}">
<input type="hidden" name="list-id" value="{{ $.List.ID }}">
<input type="hidden" name="item-id" value="{{ .ID }}">
<button style="padding: 0 0.5em; border: none; background: none;
color: #ccc" title="Delete Item">✕</button>
</form>
</li>看起來好像做了不少事,但其實並不難。每個項目有兩個表單——一個用來更新項目的「已完成」旗標(把它劃掉),另一個用來刪除該項目。
HTML 表單不一定非得是那種包含文字輸入框和檔案上傳欄位、很重的 UI 元件,它也可以只是幾個隱藏的資料欄位加上一個按鈕。就像 delete-item 表單所示,按鈕是可以加上樣式的——在這個例子中,讓它看起來像一個漂亮的 ✕ 圖示。
在第一個表單中,你會注意到按鈕上有一個 id 屬性。它透過 <label> 的 for 屬性與標籤連結,因此項目的說明文字就成了「劃掉」按鈕的標籤——你只要點擊或輕觸清單項目標籤上的任何地方,瀏覽器就會幫你點擊按鈕,將該項目劃掉。瀏覽器在沒有 JavaScript 的情況下也能做很多事!
就是要用 Go
Go 標準函式庫設計得有多周到,總是讓我印象深刻,尤其是跟我熟悉的另一個語言 Python 相比。這個伺服器唯一的相依套件只有 SQLite 資料庫驅動程式,以及算是準標準函式庫的 golang.org/x/crypto/bcrypt 套件,用來提供幾個 bcrypt 密碼雜湊函式。
在我的其他一些專案中,我使用的是熱門的 mattn/go-sqlite3 SQLite 資料庫驅動程式,表現很不錯。不過在這個專案裡,我想試試 modernc.org/sqlite 驅動程式。這是相當厲害的工程成果:將 SQLite 完全用純 Go 移植(沒有 CGo 綁定),透過把 SQLite 的 C 原始碼轉譯成 Go 來完成。產生出來的 Go 程式碼當然慘不忍睹,但運作得很好,而且不需要 C 編譯器,這在 Windows 上以及要交叉編譯時很有幫助。
我試著用符合慣例的方式來寫 Go 程式碼。舉例來說,我到處都使用回傳錯誤的方式,即使在很想直接 panic 的地方也一樣(例如資料庫程式碼:這些在行程內執行的 SQLite 查詢,應該只有在我把查詢寫錯時才會失敗吧?)。
我還是不太喜歡 Go 錯誤處理的冗長,特別是在 HTTP 請求處理器中,return 還要自己佔一行。一行程式碼,卻要配四行錯誤處理。這不會讓程式碼變得難以理解,只是多了點雜訊。舉例來說,比較一下 server.go 中的這段處理器程式碼:
list, err := s.model.GetList(id)
if err != nil {
s.internalError(w, "fetching list", err)
return
}
if list == nil {
http.NotFound(w, r)
return
}在 Python 中,資料庫錯誤只會直接拋出例外,由你的網頁框架捕捉(並回傳 Internal Server Error),而找不到資料的檢查也會更簡潔一些,像這樣:
lst = self.model.get_list(list_id)
if lst is None:
raise HTTPStatus(NotFound)在 Go 中你也可以用 panic 來達到類似效果,但那就完全不符合慣例了。Go 的錯誤處理本來就不簡潔,越早不再為此糾結越好。
簡單的 ServeMux 路由
我曾深入寫過關於在 Go 中各種 HTTP 路由做法的文章,原本也打算使用正則表達式對照表的做法,或是 chi 路由器。
不過,因為這次是從零開始打造一個網頁 App,我可以完全掌控 URL 結構,所以決定簡化做法。我已經太習慣 REST 風格的 URL 結構,這樣做感覺有點怪——但務實地想,DELETE /lists/{list-id}/items/{item-id} 到底比在 body 中帶上清單與項目 ID 的 POST /delete-item 好在哪裡?而且如果你根本沒用 JavaScript,又何必需要一個基於 JSON 的 API?
一旦做了這個決定,我就能直接使用 Go 內建、簡單的 http.ServeMux 型別來處理 URL 路由。我透過把問題本身設計掉,簡化了架構,也減少了相依性。
關於 ServeMux 唯一有點奇怪的地方是,根路徑模式 "/" 就像所有以斜線結尾的模式一樣,不只會匹配 /,也會匹配其下的所有路徑。所以你需要明確地檢查這一點。以下是這個 App 完整的路由程式碼:
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" { // because "/" pattern matches /*
s.home(w, r)
} else {
http.NotFound(w, r)
}
})
mux.HandleFunc("/sign-in", csrf(s.signIn))
mux.HandleFunc("/sign-out", s.signedIn(csrf(s.signOut)))
mux.HandleFunc("/lists/", s.signedIn(s.showList))
mux.HandleFunc("/create-list", s.signedIn(csrf(s.createList)))
mux.HandleFunc("/delete-list", s.signedIn(csrf(s.deleteList)))
mux.HandleFunc("/add-item", s.signedIn(csrf(s.addItem)))
mux.HandleFunc("/update-done", s.signedIn(csrf(s.updateDone)))
mux.HandleFunc("/delete-item", s.signedIn(csrf(s.deleteItem)))signedIn 函式是一個中介層包裝,會加上帳號/密碼驗證。預設情況下(以及在展示站台上),帳號驗證是關閉的,isSignedIn 永遠回傳 true:
func (s *Server) signedIn(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !s.isSignedIn(r) {
location := "/?return-url=" + url.QueryEscape(r.URL.Path)
http.Redirect(w, r, location, http.StatusFound)
return
}
h(w, r)
}
}csrf 函式也是一個中介層包裝,提供跨站請求偽造的防護。它會包裝指定的處理器,確保 HTTP 方法是 POST,且 csrf-token cookie 中的 CSRF token 與 csrf-token 表單欄位中的 token 相符。這確保了表單動作只能從 Simple Lists 本身的頁面提交:
func csrf(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
w.Header().Set("Allow", "POST")
http.Error(w, "405 method not allowed",
http.StatusMethodNotAllowed)
return
}
token := r.FormValue("csrf-token")
cookie, err := r.Cookie("csrf-token")
if err != nil || token != cookie.Value {
http.Error(w, "invalid CSRF token or cookie",
http.StatusBadRequest)
return
}
h(w, r)
}
}資料庫處理
資料庫的選擇根本不用想。我一直很推崇 SQLite,以這個專案的規模來說,用它再適合不過了。如果是更大的專案,我唯一會考慮的替代方案是 PostgreSQL,因為我信任它、也很喜歡它(速度快、文件完整,還有像 JSON 支援這類優秀的功能)。
因為我想盡量減少相依性(而且我不太喜歡大多數的 ORM),所以這個專案我決定只使用標準函式庫的 database/sql 套件。我曾短暫考慮過使用 sqlx——我確實喜歡它對 struct 和 slice 的處理——但對於這麼小的資料庫模型來說,幫助並不大。
以下是 db.go 中的幾個函式,讓你感受一下實際寫起來是什麼樣子。GetList 會抓取單一清單,而 getListItems 這個輔助函式則會抓取該清單的所有項目:
// GetList fetches one list and returns it, or nil if not found.
func (m *SQLModel) GetList(id string) (*List, error) {
row := m.db.QueryRow(`
SELECT id, name
FROM lists
WHERE id = ? AND time_deleted IS NULL
`, id)
var list List
err := row.Scan(&list.ID, &list.Name)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
list.Items, err = m.getListItems(id)
return &list, err
}
func (m *SQLModel) getListItems(listID string) ([]*Item, error) {
rows, err := m.db.Query(`
SELECT id, description, done
FROM items
WHERE list_id = ? AND time_deleted IS NULL
ORDER BY id
`, listID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []*Item
for rows.Next() {
var item Item
err = rows.Scan(&item.ID, &item.Description, &item.Done)
if err != nil {
return nil, err
}
items = append(items, &item)
}
return items, rows.Err()
}如你所見,QueryRow 用來從資料表中抓取單一列,使用起來很直覺。Query 則用來抓取多列,就稍微麻煩一點——你必須手動走訪每一列、對每一列做 Scan、檢查掃描錯誤,然後把掃描到的項目附加到 slice 中。喔,別忘了還要 Close rows 物件。我試著找方法簡化這個流程,但在只用 database/sql 的情況下,若不用反射,這大概就是你能做到最好的程度了(如果你有更好的方法,請告訴我)。
盡量減少相依性固然不錯,但這確實是一種取捨。如果你需要做大量查詢,我會推薦使用前面提到的 sqlx 函式庫。用了 sqlx 之後,getListItems 這個輔助函式就能省去所有走訪與 Scan 的樣板程式碼:
func (m *SQLModel) getListItems(listID string) ([]*Item, error) {
var items []*Item
err := m.db.Select(&items, `
SELECT id, description, done
FROM items
WHERE list_id = ? AND time_deleted IS NULL
ORDER BY id
`, listID)
return items, err
}另一方面,用 database/sql 執行會改變資料的查詢就很簡單,因為你不需要掃描列。以下是刪除清單中某個項目的做法(我通常偏好軟刪除,這樣之後還有機會復原):
// DeleteItem (soft) deletes the given item in a list.
func (m *SQLModel) DeleteItem(listID, itemID string) error {
_, err := m.db.Exec(`
UPDATE items
SET time_deleted = CURRENT_TIMESTAMP
WHERE list_id = ? AND id = ?
`, listID, itemID)
return err
}手動依賴注入
在展示我實際如何實作測試之前,我想先簡短聊聊 Go 的介面與依賴注入。「注入依賴」本身是件好事,但依賴注入框架和函式庫卻很讓人頭痛:它們通常使用執行時期的反射,繞過了型別檢查,也讓你在 IDE 中(甚至用肉眼)很難追蹤程式碼。不過幸好,手動組裝依賴既安全又簡單。
在 Go 中,你通常不會在提供實作的套件中定義介面,而是在使用它的套件中定義(而且該介面可能只包含實作所提供方法的一部分)。
Simple Lists 的程式碼結構非常扁平,全部都在同一個套件中:main。但為了展示在較大專案中會怎麼做,我在 server.go 中、靠近使用處的地方定義了一個資料庫的 Model 介面。以下是 Server 所需要的介面,以及用來建立伺服器實例的 NewServer 函式的簽章:
// Model is the database model interface used by the server.
type Model interface {
GetLists() ([]*List, error)
CreateList(name string) (string, error)
DeleteList(id string) error
GetList(id string) (*List, error)
AddItem(listID, description string) (string, error)
UpdateDone(listID, itemID string, done bool) error
DeleteItem(listID, itemID string) error
CreateSignIn() (string, error)
IsSignInValid(id string) (bool, error)
DeleteSignIn(id string) error
}
// Logger is the logger interface used by the server.
type Logger interface {
Printf(format string, v ...interface{})
}
// NewServer creates a new server with the specified dependencies.
func NewServer(
model Model,
logger Logger,
timezone string,
username string,
passwordHash string,
showLists bool,
) (*Server, error) {
...
}然後在 main.go 中,我們把所有東西組起來:
func main() {
...
db, err := sql.Open("sqlite", *dbPath)
exitOnError(err)
model, err := NewSQLModel(db)
exitOnError(err)
server, err := NewServer(model, log.Default(), *timezone,
*username, passwordHash, *showLists)
exitOnError(err)
...
}以我的看法(兩種做法我都用過),在 main 中明確地把所有東西組起來要好得多:這就是普通的 Go 程式碼,有型別檢查,對 IDE 也很友善。
為資料庫模型使用介面,讓我們能輕鬆地為測試建立 mock 或 fake 的資料庫實作。當你的正式環境程式碼使用像 PostgreSQL 或 MongoDB 這類較重的外部資料庫時,這會很有用。不過在這個例子中,測試時我們使用的是真正的 SQLite 資料庫(雖然是 in-memory 的),所以甚至不需要寫 fake。以下是在測試中組裝伺服器的方式:
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("opening database: %v", err)
}
model, err := NewSQLModel(db)
if err != nil {
t.Fatalf("creating model: %v", err)
}
server, err := NewServer(model, nullLogger{}, "Pacific/Auckland",
"", "", true)
if err != nil {
t.Fatalf("creating server: %v", err)
}測試
我喜歡在測試中盡量直接對真正的資料庫執行。如果能以低成本在容器中啟動一個 PostgreSQL 資料庫,或是能使用 in-memory 資料庫,你就是在對真正的東西做測試。
在我們的例子中,使用的是 in-memory 的 SQLite 資料庫(:memory:),所以一切都在同一個行程內執行,甚至不會寫入磁碟。完全不需要寫 fake 或使用 mock——用 SQLite 跑測試非常快速。如果正式環境用的是 PostgreSQL,我大概還是會在測試中使用 SQLite 來保持速度,只針對兩者之間有差異的查詢做覆寫即可。
這些測試也相當接近端對端測試,所以測的是功能,而非實作細節。每個測試都會呼叫 Server.ServeHTTP 處理器,並使用 net/http/httptest 套件來記錄 HTTP 回應。
以下是測試片段的樣子:
func TestServer(t *testing.T) {
...
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatalf("creating cookie jar: %v", err)
}
// Fetch homepage
var csrfToken string // CSRF token stays same for entire session
{
recorder := serve(t, server, jar, "GET", "/", nil)
ensureCode(t, recorder, http.StatusOK)
forms := parseForms(t, recorder.Body.String())
ensureInt(t, len(forms), 1)
ensureString(t, forms[0].Action, "/create-list")
csrfToken = forms[0].Inputs["csrf-token"]
if csrfToken == "" {
t.Fatal("csrf-token input not found")
}
}
// Create list
var listID string
var listIDs []string
{
form := url.Values{}
form.Set("csrf-token", csrfToken)
form.Set("name", "Shopping List")
recorder := serve(t, server, jar, "POST", "/create-list", form)
ensureCode(t, recorder, http.StatusFound)
location := recorder.Result().Header.Get("Location")
ensureRegex(t, location, "/lists/[a-z]{10}")
listID = location[7:]
listIDs = append(listIDs, listID)
}
...我在這裡做了一個簡化的設計選擇,這個做法大概無法擴展到大型專案:所有測試都寫在單一、循序執行的 TestServer 函式中,後面的測試會依賴前面的測試結果。我之所以能這樣做,是因為執行測試非常快(go test 顯示只要 10 毫秒),而且這樣可以避免在每個子測試一開始都要重做像建立清單這類共同的設置步驟。
如果是在更大的程式碼庫中,我會把設置步驟放到輔助函式中,並使用 子測試,透過 t.Run 將每個測試部分作為子測試來執行。
一直重複 if got != want { t.Fatalf("got %v, want %v", got, want) } 這樣的區塊有點煩,所以我把它們抽成了輔助函式。我也可以使用眾多斷言函式庫中的一個(我偏好 gocheck,因為它的 API 很小),但自己寫幾個小小的 ensure* 輔助函式就能避免引入額外的相依性,也很簡單。
因為這不是 JSON API,我寫了一個 parseForms 輔助函式,使用 golang.org/x/net/html 套件來解析回應主體中的 HTML 表單。這讓我們可以取出之後會用到的各種欄位,例如 CSRF token。
用來執行並記錄請求的 serve 輔助函式如下:
// serve records a single HTTP request and returns the response recorder.
func serve(t *testing.T, server *Server, jar http.CookieJar,
method, path string, form url.Values,
) *httptest.ResponseRecorder {
t.Helper()
var body io.Reader
if form != nil {
body = strings.NewReader(form.Encode())
}
r, err := http.NewRequest(method, "http://localhost"+path, body)
if err != nil {
t.Fatalf("creating request: %v", err)
}
if form != nil {
r.Header.Add("Content-Type", "application/x-www-form-urlencoded")
}
for _, c := range jar.Cookies(r.URL) {
r.Header.Add("Cookie", c.Name+"="+c.Value)
}
recorder := httptest.NewRecorder()
server.ServeHTTP(recorder, r)
jar.SetCookies(r.URL, recorder.Result().Cookies())
return recorder
}請注意使用了 net/http/cookiejar,以確保我們會把先前請求中設定的 cookie 傳遞給後續的請求。CSRF cookie 就是透過這種方式自動處理的。
這些 Go 測試涵蓋了大部分基本的伺服器端功能。不過,它們不會測試版面與 UI,所以我在做更動時,也會在本地端用瀏覽器快速手動測試一遍。Simple Lists 只有少數幾個功能,所以全部跑過一遍只要一分鐘。
精簡的 HTML 與 CSS
我使用 Go 的 html/template 套件來處理 HTML 樣板。它有點古怪(例如,我會希望它的運算式語法是 Go 運算式的子集),但一旦讀過文件,就不難上手。要注意的是,大部分的樣板文件其實是在 text/template 的文件中。
我們的 HTML 非常簡單:只有兩個頁面(首頁和清單頁)。html/template 套件支援「區塊」,可以重用樣板,但直接讓兩個樣板之間有一點重複反而更簡單。
請參考完整的樣板原始碼。請注意 meta viewport 標籤,它讓版面在手機上也能漂亮地呈現——Simple Lists 是「完全響應式」的!
<meta name="viewport" content="width=device-width, initial-scale=1">HTML 使用了少量的 CSS 來為清單元素和按鈕加上樣式。以這個規模的 App 來說,我發現直接使用行內 CSS 最簡單,例如用來移除邊框並設定 delete-item 按鈕的顏色。注意按鈕標籤使用了 Unicode 的 ✕——誰還需要圖示呢!
<button style="padding: 0 0.5em; border: none; background: none;
color: #ccc" title="Delete Item">✕</button>如果我要打造更大的 App,我大概會想使用 CSS 類別或其他機制,讓這些樣式更容易重用,並集中在同一個地方定義。
安全性
這個 App 尚未經過安全性審查,所以我在這方面不做任何保證,儘管我已經盡量小心。以下幾點值得注意:
- 如前所述,它透過確保
csrf-tokencookie 與csrf-token表單欄位相符來實作 CSRF 防護。 - 它使用 Go 的樣板函式庫,會自動防範跨站腳本(XSS)攻擊。
- 它使用 Go 的
database/sql函式庫搭配參數化查詢,因此能抵禦 SQL 注入攻擊。 - SQLite 本身經過了大量的測試,而我們使用的
modernc.org/sqlite套件也是用同一套龐大的測試集來測試的。 - Go 的 HTTP 伺服器以安全且經過實戰考驗而聞名。
展示站台上的帳號/密碼驗證是關閉的,但在我們家人使用的個人實例上我有啟用。只有一組帳號與密碼,但對我個人使用來說已經足夠。
在這個模式下,帳號是以命令列參數傳給伺服器,而經 bcrypt 雜湊後的密碼則透過環境變數傳入。顯然,如果要支援多位使用者,我們會想把帳號和雜湊後的密碼存到資料庫中。工作階段,也就是「登入」記錄,會儲存在一個簡單的 sign_ins 資料庫資料表中,並在 90 天後到期。
舉例來說,以下是 signIn 請求處理器的程式碼。它從表單欄位中取得帳號與密碼,使用 bcrypt 的 CompareHashAndPassword 函式來檢查密碼,如果登入有效,就會建立一筆 sign_ins 資料列並設定一個 sign-in cookie。
func (s *Server) signIn(w http.ResponseWriter, r *http.Request) {
username := strings.TrimSpace(r.FormValue("username"))
password := r.FormValue("password")
returnURL := r.FormValue("return-url")
if returnURL == "" {
returnURL = "/"
}
if username != s.username || bcrypt.CompareHashAndPassword(
[]byte(s.passwordHash), []byte(password)) != nil {
location := "/?error=sign-in&return-url=" +
url.QueryEscape(returnURL)
http.Redirect(w, r, location, http.StatusFound)
return
}
id, err := s.model.CreateSignIn()
if err != nil {
s.internalError(w, "creating sign in", err)
return
}
cookie := &http.Cookie{
Name: "sign-in",
Value: id,
MaxAge: 90 * 24 * 60 * 60,
Path: "/",
Secure: r.URL.Scheme == "https",
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
}
http.SetCookie(w, cookie)
http.Redirect(w, r, returnURL, http.StatusFound)
}結論
我非常享受打造 Simple Lists 的過程,而且它已經對我們家人的共享清單很有幫助:生日與聖誕節清單、待看電影清單等等。我喜歡保持小巧、快速、輕盈,我相信在這裡已經做到了。
使用 Go 非常有趣:靜態型別檢查、快速的編譯速度、優秀的標準函式庫,以及用於格式化和執行測試的絕佳工具。而且交叉編譯與部署非常容易。只要執行 GOOS=linux GOARCH=amd64 go build,幾秒鐘後(即使你是在 macOS 或 Windows 上開發)就能得到一個可以複製到正式伺服器的 Linux 執行檔。
我也很喜歡它在完全沒有 JavaScript 的情況下依然相當好用。現在的 HTML 本身就能做很多事,所以在你拿出 React 之前,不妨三思一下。
希望你喜歡這篇文章或從中學到了些什麼。如果你有任何回饋,歡迎告訴我!
隨機一篇部落格
留言
登入後參與討論