前幾個月改版時,我決定用 Elixir 來實作 OAuth server + Proxy,這是一門結合了 Erlang VM 和 Ruby 語法的程式語言,可以很容易運用 Erlang 的特性做出低延遲、高並發且高容錯度的系統,又不用學習 Erlang 比較特殊的 Prolog 式語法(但是你可能還是多少要懂 Erlang 語法,因為很多時候你會直接運用 Erlang library)。
Erlang 的這些強大特性拿來做 OAuth server + Proxy 似乎有些大材小用,不過因為我爽,所以就決定用 Elixir 來寫了。
OAuth Server
實作 OAuth 的部分很無聊就不在本文贅述了,我強烈推薦 Yu-Cheng Chuang 大大寫的 OAuth 2.0 筆記,搭配 RFC 6479 spec 很快就能實作出符合規格的 OAuth server。
Proxy
接著就是今天的重頭戲 Proxy,用 Elixir 實作可能不會是你的最佳選擇,所以看看就好,不要模仿。
首先必須先選個 HTTP client,在 Node.js 有個非常強大的 request,而 Elixir 有:
或是 Erlang:
Elixir 的 library 因為經過封裝而損失了一些比較底層的功能,所以我決定直接使用 Erlang library,這時我就瞭解到學會 Erlang 的重要性,因為有些 library 是沒有寫文件的,必須直接讀原始碼才能瞭解如何運用。
hackney
hackney 是我第一個接觸的 library,它是這幾個 library 裡更新最勤勞,而且在 Elixir 中使用也比較不突兀,用起來最順手的 library,但是因為一些已知問題(#191, #267,可能會在 hackney 2.0 解決),所以我決定尋求其他 library。
ibrowse
ibrowse 是這裡頭第二靠譜的 library,但是運用上比 hackney 麻煩一些,要事先把 binary 轉成 list,而且可能是 HTTP 規格實作上的差異導致有些 request 無法正確完成。
lhttpc
已停止維護。
fusco
宣稱還在早期開發階段,然而已經超過兩年沒有任何 commit,而且沒有文件,是給人用的嗎?
gun
與 cowboy 系出同門,都是 Nine Nines 的作品,感覺相當不錯,可惜的是使用到了 cowlib 1.3.0,和 cowboy 1 使用的 cowlib 1.0 衝突,因此無法使用。
shotgun
因為 gun 沒辦法用,所以 shotgun 自然也用不了了。
調整效能
既然 Erlang 世界裡沒有其他更好的選擇了,那麼我唯一能做的就只有慢慢壓榨出效能,一開始的 proxy 很陽春,在網路上找到的大部分範例都這樣實作:
defmodule Proxy do
import Plug.Conn
def init(opts), do: opts
def call(conn, _) do
{:ok, client} = :hackney.request(method_to_atom(method), make_url(url), conn.req_headers, :stream, [])
conn
|> write_proxy(client)
|> read_proxy(client)
end
defp method_to_atom(method) do
method |> String.downcase |> String.to_atom
end
defp make_url(conn) do
base = "http://localhost:4000" <> conn.request_path
case conn.query_string do
"" -> base
qs -> base <> "?" <> qs
end
end
defp write_proxy(conn, client) do
case read_body(conn, []) do
{:ok, body, conn} ->
:hackney.send_body(client, body)
conn
{:more, body, conn} ->
:hackney.send_body(client, body)
write_proxy(conn, client)
end
end
defp read_proxy(conn, client) do
{:ok, status, headers, client} = :hackney.start_response(client)
{:ok, body} = :hackney.body(client)
%{conn | resp_headers: headers}
|> send_resp(status, body)
end
end
很明顯有些地方可以改善:
靜態 method_to_atom
method_to_atom
函數雖然簡單,只是把 method
改成小寫後再轉為 atom,但如果能夠節省每次的轉換開銷的話就能快些。
for method <- ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] do
defp method_to_atom(unquote(method)) do
unquote(method |> String.downcase |> String.to_atom)
end
end
Stream response
:hackney.body
會一次讀完所有 response body,但邊讀邊寫想必更有效率。我的做法是先判斷 transfer-encoding: chunked
header,如果存在的話就以 chunk 形式回傳。
defp read_body(conn, client) do
{:ok, status, headers, client} = :hackney.start_response(client)
case List.Keyfind(headers, "transfer-encoding", 0) do
{_, "chunked"} ->
conn
|> send_chunked(status)
|> stream_body(client)
_ ->
{:ok, body} = :hackney.body(client)
conn |> send_resp(status, body)
end
end
defp normalize_headers(headers) do
Enum.map(headers, fn {k, v} ->
{String.downcase(k), v}
end)
end
defp stream_body(conn, client) do
case :hackney.stream_body(client) do
{:ok, body} ->
{:ok, conn} = chunk(conn, body)
stream_body(conn, client)
:done -> conn
end
end
Async response
hackney 加上 async
選項後,可以用 receive
來一步步的接收到 status, headers 和 body,但實際上使用會碰到許多問題(#224, #267),因此作罷。
直接使用 Cowboy
看來 hackney 方面已經沒什麼好調整了,只好把觸手伸到 Plug 上了,透過 Plug 送 body 需要額外的開銷,那麼直接使用 Cowboy 說不定會更快?以這樣的想法不斷琢磨後,最後的成品就是 PlugProxy。
forward "/v2", to: PlugProxy, upstream: "http://localhost:4000"
使用上很簡單,不過實際上浪費了我很多時間,而且效能也真的不算多好,中途遇到一些 hackney 的坑都讓我想另外造一個 HTTP client 的輪子了,用 Node.js 的 request 解決可能簡單的多吧哈哈。
const request = require('request');
req.pipe(request.get('http://localhost:4000')).pipe(res);
後記
在改版完成的一個月後,我就回老家種田了,就和朋友一起去極上爆音體驗震撼人心(物理)的ガルパン+聖地巡禮了,旅遊真他媽爽啊!