Go 和 Elixir 都是我非常喜歡的編程語言,這次來對比下它們實現一個原生極簡 HTTP 服務的過程。
Go 語言標準庫自帶了網絡服務庫,只需要簡單幾行代碼就可以實現一個網絡服務,這也是一開始它吸引我的一個方面。而 Elixir 標準庫本身沒有網絡服務的庫,而是通過 Plug
庫提供了一套標準網絡服務編寫規范,雖然它不是標準庫,但也是由官方開發和維護的。
新建工程
首先我們從新建工程開始。Go 并沒有什么嚴格的工程管理工具和規范,我們只需要新建一個 .go
文件就可以開始編程了。Elixir 程序的運行方式就比較多樣了,既可以做為腳本直接運行,也可以在交互式環境中運行,還可以編譯成 beam
文件加載到 Beam 虛擬機運行。而且 Elixir 還提供了工程管理工具 Mix
,用來創建和運行工程,以及打包測試等。這里我們需要一個 OTP 應用,因此我們使用 mix
來創建工程。
Go | Elixir |
---|---|
新建 main.go | mix new example --sup |
對于 Elixir 來說,我們還需要在 mix.exs
中添加 plug_cowboy
依賴:
defp deps do[{:plug_cowboy, "~> 2.0"}]
end
然后運行 mix deps.get
來獲取依賴。
處理器
Web 應用的關鍵是”處理器”,用來處理具體的 http 請求。
在 Go 語言中,處理器是一個接口:
type Handler interface {ServeHTTP(http.ResponseWriter, *http.Request)
}
我們只需要實現一個簽名為 func(w http.ResponseWriter, r *http.Request)
的函數即可。
package mainimport "net/http"func main() {http.ListenAndServe(":8080", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {w.Write([]byte("Hello World from Go!\n"))},))
}
而在 Elixir 中,我們需要實現的是 Plug
行為,它包含以下兩個函數:
@callback init(opts) :: opts
@callback call(conn :: Plug.Conn.t(), opts) :: Plug.Conn.t()
首先我們在 lib/example
目錄下創建一個 hello_world_plug.ex
文件,然后定義一個模塊來實現 Plug
行為。
defmodule Example.HelloWorldPlug doimport Plug.Conndef init(options), do: optionsdef call(conn, _opts) doPlugconn|> put_resp_content_type("text/plain")|> send_resp(200, "Hello World from Elixir!\n")end
end
然后在 application.ex
的 start
函數中添加我們的應用。
def start(_type, _args) dochildren = [# Starts a worker by calling: Example.Worker.start_link(arg)# {Example.Worker, arg}{Plug.Cowboy, scheme: :http, plug: Example.HelloWorldPlug, options: [port: 8081]}]# See https://hexdocs.pm/elixir/Supervisor.html# for other strategies and supported optionsopts = [strategy: :one_for_one, name: Example.Supervisor]Supervisor.start_link(children, opts)end
最后運行 mix run --no-halt
啟動應用即可。
可以看到在 Go 語言中,對于 HTTP 連接的讀寫分別由 http.ResponseWriter
和 http.Request
承擔,而在 Elixir 中則全部統一到了 Plug.Conn
結構體中。實際上這也是許多第三方 Go 語言 Web 框架的實現方式,它們通常叫做 Context
。
路由
路由器用來分別處理不同路徑下的 HTTP 請求,也就是將不同路徑的請求分發給不同的處理器,它本身本質上也是一個處理器。
在 Go 1.22 之前,標準庫的路由器功能都還比較簡單,只能匹配 HTTP 路徑,不能匹配 HTTP 方法。直到 Go 1.22, http.ServeMux
終于迎來了升級,支持匹配 HTTP 方法,路徑參數,通配符等。那些以前只能通過第三方庫實現的功能,也能通過標準庫實現了。以下是摘自官網的 Go 1.22 release note:
Enhanced routing patterns
HTTP routing in the standard library is now more expressive. The patterns used by?
net/http.ServeMux
?have been enhanced to accept methods and wildcards.Registering a handler with a method, like?
"POST /items/create"
, restricts invocations of the handler to requests with the given method. A pattern with a method takes precedence over a matching pattern without one. As a special case, registering a handler with?"GET"
?also registers it with?"HEAD"
.Wildcards in patterns, like?
/items/{id}
, match segments of the URL path. The actual segment value may be accessed by calling the?Request.PathValue
?method. A wildcard ending in “…”, like?/files/{path...}
, must occur at the end of a pattern and matches all the remaining segments.A pattern that ends in “/” matches all paths that have it as a prefix, as always. To match the exact pattern including the trailing slash, end it with?
{$}
, as in?/exact/match/{$}
.If two patterns overlap in the requests that they match, then the more specific pattern takes precedence. If neither is more specific, the patterns conflict. This rule generalizes the original precedence rules and maintains the property that the order in which patterns are registered does not matter.
This change breaks backwards compatibility in small ways, some obvious—patterns with “{” and “}” behave differently— and some less so—treatment of escaped paths has been improved. The change is controlled by a?
GODEBUG
?field named?httpmuxgo121
. Set?httpmuxgo121=1
?to restore the old behavior.
為了保持兼容性, ServeMux
并沒有提供諸如 Get(...)
, Post(...)
這樣的方法,還是通過 Handle
和 HandleFunc
來注冊路由,只是將 HTTP 方法集成到了路徑中。
目前 Go 的最新版本是 1.24,離 Go 1.22 也已過去了兩個大版本,因此我們就以新版本為例來進行對比。
package mainimport "net/http"func main() {http.HandleFunc("GET /hello", func(w http.ResponseWriter, r *http.Request) {w.Write([]byte("Hello World from Go!\n"))})http.ListenAndServe(":8080", nil)
}
對 Elixir 來說,路由器也是 Plug
,我們需要使用 use Plug.Router
來導入一些有用的宏。在 lib/example
目錄下新建一個 router.ex
文件。
defmodule Example.Router douse Plug.Routerplug(:match)plug(:dispatch)get "/" dosend_resp(conn, 200, "Welcome!")endget("/hello", to: Example.HelloWorldPlug)match _ dosend_resp(conn, 404, "Oops!")end
end
這里我們首先用 plug(:match)
和 plug(:dispatch)
插入兩個內置的 Plug
用來匹配和分發路由。之后我們就可以使用 get
, post
和 match
等宏來編寫處理函數了。除了直接用 :do
來書寫處理程序,還可以通過 :to
來指定 Plug
。除了支持模塊 Plug
,也可以是函數 Plug
。函數 Plug
是一個簽名與 call
函數相同的函數。
最后我們把 application.ex
中的 start
函數中的 Plug
換成 Example.Router
。
def start(_type, _args) dochildren = [# Starts a worker by calling: Example.Worker.start_link(arg)# {Example.Worker, arg}# {Bandit, plug: Example.HelloWorldPlug, scheme: :http, port: 8080}{Plug.Cowboy, scheme: :http, plug: Example.Router, options: [port: 8081]}]# See https://hexdocs.pm/elixir/Supervisor.html# for other strategies and supported optionsopts = [strategy: :one_for_one, name: Example.Supervisor]Supervisor.start_link(children, opts)
end
Elixir 的路由十分靈活,表達能力也更強。
中間件
Go 的中間件是一個接收 http.HandlerFunc
并返回 http.HandlerFunc
的函數。比如我們實現一個記錄日志的中間件。
func main() {http.HandleFunc("GET /hello", logHttp(func(w http.ResponseWriter, r *http.Request) {w.Write([]byte("Hello World from Go!\n"))},))http.ListenAndServe(":8080", nil)
}func logHttp(handler http.HandlerFunc) http.HandlerFunc {return func(w http.ResponseWriter, r *http.Request) {log.Printf("%s %s\n", r.Method, r.URL.Path)handler(w, r)}
}
Elixir 的中間件還是 Plug
,這里我們實現一個叫 log_http
的函數 Plug
。
defmodule Example.Router douse Plug.Routerplug(:match)plug(:dispatch)plug(:log_http)get "/" dosend_resp(conn, 200, "Welcome!")endget("/hello", to: Example.HelloWorldPlug)match _ dosend_resp(conn, 404, "Oops!")enddef log_http(conn, _opts) dorequire LoggerLogger.info("#{conn.method} #{conn.request_path}")connend
end
總結
以上就是原生極簡 HTTP 服務在 Go 和 Elixir 中的實現。雖然 Elixir 的代碼量更多,但是其功能和表現力也更強。Go 勝在簡潔,但是過于簡潔,相比于 Elixir,語言表現力還是差了一點。
如果要實現更龐大的 Web 應用,Go 有許多優秀的 Web 框架可供選擇,比如 Gin,Echo等等,太多了。而 Elixir 則有鼎鼎大名的大殺器 Phoenix。