tsc-multi 的運作方式其實非常簡單,就是同時執行多個 TypeScript compiler 平行運作而已;除此之外,我還對 compiler 動了一點手腳。
ts.System
ts.System
是 TypeScript 用來和作業系統互動的 interface,其中包含了跟檔案系統 (file system) 有關的 method。因為 TypeScript 預設的輸出檔案的副檔名是 .js
,為了要變更輸出檔案的副檔名,我修改了 ts.System
裡跟讀寫檔案有關的 method。
function rewritePath(path) { if (path.endsWith(".js") return path.replace(/\.js$/, ".mjs"); return path;}const sys: ts.System = { ...ts.sys, fileExists(path) { return ts.sys.fileExists(rewritePath(path)) || ts.sys.fileExists(path); }, readFile(path, encoding) { return ts.sys.readFile(rewritePath(path), encoding) ?? ts.sys.readFile(path, encoding); }, writeFile(path, data, writeBOM) { ts.sys.writeFile(rewritePath(path), data, writeBOM); }, deleteFile(path) { ts.sys.deleteFile(rewritePath(path)); }};
我修改了 fileExists
, readFile
, writeFile
, deleteFile
這四個 method,上面是簡化過的版本,詳細內容可參考原始碼。
因為輸出檔案的副檔名被改寫了,為了讓 CommonJS 和 ESM 能夠 import 到正確的檔案,必須在 import 路徑加上副檔名。
這個部份我用 transformer 的形式來實作,在 transformer 內,可以把 TypeScript AST 替換成任意的程式碼。以這次的案例來說,我們需要替換的 node 有以下四種。
// ESM import (ImportDeclaration)import foo from "./foo";// ESM export (ExportDeclaration)export foo from "./foo";// ESM dynamic import (CallExpression)import("./foo");// CommonJS require (CallExpression)require("./foo");
從上面這四種 node 可以取得 import 路徑,如果是相對路徑的話(開頭是 ./
或 ../
),就是需要修改的路徑。
在 Node.js 裡,import 路徑可能會是檔案或資料夾,但是 ESM 的 import 路徑一定要加上副檔名,所以必須要把資料夾的 import 路徑加上 /index.js
。
// Inputimport "./file";import "./dir";// Outputimport "./file.js";import "./dir/index.js";
總結來說,可以把修改 import 路徑的部分統整成以下程式碼。
function updateModuleSpecifier(sourceFile: ts.SourceFile, node: ts.Expression) { if (!ts.isStringLiteral(node) || !isRelativePath(node.text)) return node; if (isDirectory(sourceFile, node.text)) { return ts.factory.createStringLiteral( `${node.text}/index${options.extname}` ); } const ext = extname(node.text); const base = ext === ".js" ? trimSuffix(node.text, ".js") : node.text; return ts.factory.createStringLiteral(`${base}${options.extname}`);}
詳細內容可參考原始碼。
一開始 tsc-multi 在小規模的專案(例如 Kosko 和 kubernetes-models)使用時,都沒有任何異常。可是一旦在 Dcard 這種大規模的 monorepo 使用時,就很容易發生問題。
主要原因是 TypeScript 在使用 Project References 功能時,為了要加快未來編譯的速度,會寫入 .tsbuildinfo
檔案,內容包含了目前的 build state,檔案大小大約會是幾百 KB。
因為 tsc-multi 會同時執行多個 TypeScript compiler,在寫入 TS build info 時,其他 compiler 可能就會剛好讀取寫入到一半的檔案,這種問題在一般的電腦上通常不會發生,但是在 CI 等資源有限的環境下偶爾會觸發。
我的解決方法是變更 tsconfig.json
的 tsBuildInfoFile
設定,讓 TypeScript compiler 不會同時寫入到同一個路徑。
const host = ts.createSolutionBuilderHost();host.getParsedCommandLine = (path: string) => { const config = ts.getParsedCommandLineOfConfigFile(path, {}, ts.sys); config.options.tsBuildInfoFile = `${basePath}${data.extname}.tsbuildinfo`; return config;};
詳細內容可參考原始碼。
]]>這篇文章會介紹如何讓 Node.js package 能夠同時支援 CommonJS 和 ESM,以及使用 ESM 時的注意事項。
以最低支援版本來區分。
Node.js 10 以上:
.js
副檔名.mjs
副檔名package.json
加上 module
Node.js 12 以上:
.cjs
副檔名.mjs
副檔名package.json
加上 module
和 exports
,type
可設定為 module
正常來說,比較建議的做法是 CommonJS 一律採用 .cjs
副檔名,ESM 一律採用 .mjs
副檔名,這樣就能避免 Node.js 用 package.json
的 type
來判斷,但是在以下情況下會出問題。
如果你需要 require package 裡的路徑的話,在 Node.js 10 可能就會出問題。
舉例來說,當 require package 的時候,Node.js 會根據 package.json
裡設定的 main
來決定路徑,所以不管副檔名是什麼都無所謂,只要內容是 CommonJS 就好。
// 假設 package.json 的內容是 {"main": "index.cjs"} 的話require('example');// -> node_modules/example/index.cjs
但如果是 require package 裡的路徑的話,就不會去參考 package.json
的設定了,如果 require 時沒有加上副檔名的話,就會根據 require.extensions
來尋找對應檔案。
// 預設只支援 .js, .json, .noderequire('example/foo');// -> node_modules/example/foo.js// -> node_modules/example/foo.json// -> node_modules/example/foo.node
如果把 CommonJS 檔案都一律改成 .cjs
副檔名的話,就會找不到對應檔案。
其中一種解決方法是在路徑後加上副檔名,但這樣就需要改寫現有的 require。
require('example/foo.cjs');// -> node_modules/example/foo.cjs
另一種方法則是升級到 Node.js 12 以上,從 12.7.0 開始支援 export map,從 12.16.0 開始不用加 --experimental-exports
。如果 package.json
裡有指定 exports
的話,Node.js 就會改用 export map 來決定路徑。
{ "exports": { ".": { "import": "./index.mjs", "require": "./index.cjs" }, "./foo": { "import": "./foo.mjs", "require": "./foo.cjs" } }}
require('example/foo');// -> node_modules/example/foo.cjs
Jest 為了要實作 mock 機制,所以有自己一套 module resolve 和 import 的機制,在 import 外部 package 路徑的情況下,似乎不會使用 moduleFileExtensions
設定,而是使用 .js
副檔名,我用過的其中一種解決方法是設定 moduleNameMapper
,手動在 import 路徑後加上 .cjs
副檔名。
{ "moduleNameMapper": { "^example/(.+)$": "example/$1.cjs" }}
Jest 的 ESM 支援還在實驗階段,如果需要執行 ESM 檔案的話需要加上 NODE_OPTIONS=--experimental-vm-modules
,目前建議還是使用 CommonJS,並使用 .js
副檔名。
如果要同時 import CommonJS 和 ESM package 的話,唯一的方法就是使用 import
,舊有的 require
只支援 CommonJS,import
和 require
相比有很多不同的地方,細節可以參考官方文件,本文只會說明一些我覺得重要的部分。
Import 檔案路徑時,一定要加上副檔名,import
不會根據 require.extensions
來判斷支援哪些副檔名。此外,也不能直接 import
資料夾,必須加上 /index.js
。
require('./path')import './path.js';require('./dir');import './dir/index.js';
__filename
和 __dirname
__filename
和 __dirname
這兩個變數只有 CommonJS 才支援,在 ESM 裡必須改用標準的 import.meta.url
,兩者的內容會有一點點不一樣,需要透過 url
package 裡的 fileURLToPath
和 pathToFileURL
來轉換。
__filename// /workspace/test.js__dirname// /workspaceimport.meta.url// file:///workspace/test.jsfileURLToPath(import.meta.url)// /workspace/test.jsnew URL('.', import.meta.url);// file:///workspace/
在 CommonJS 裡,到處都可以直接 require
;但是在 ESM 裡,只有最外層可以用 import
,其他地方只能使用 async 的 import()
,有些地方可能會因此而必須改成 async function。
除了檢查 Node.js 版本以外,另一個檢測方法就是利用 import
支援 data:
protocol 的特性,來檢查現有環境是否支援 ESM,這是從 ava 參考來的。
const supportsESM = async () => { try { await import('data:text/javascript,'); return true; } catch {} return false;};
需要注意的是,使用 TypeScript 時,如果設定為 CommonJS module 的話,import
會被轉為 require
,所以建議改為 ESNext module 或改用 JavaScript。
目前有幾種方法可以把 TypeScript 編譯成 CommonJS 和 ESM 檔案。
這應該是最簡單的方法,只要把原本的 tsc
指令切成兩個然後同時執行就好了。
tsc -m commonjstsc -m esnext
讓 tsc 輸出 ESM,然後再用 Babel 產生 CommonJS 檔案。
{ "plugins": ["@babel/plugin-transform-modules-commonjs"]}
以上這兩種方法需要額外寫 script,如果要支援 monorepo 的話就更加痛苦了,所以我花了一點時間把工作時用的 build tool 重新改寫成 tsc-multi,之後會在下一篇文章介紹,用法大概會像是這樣。
{ "targets": [ { "extname": ".mjs", "module": "esnext" }, { "extname": ".js", "module": "commonjs" } ], "projects": ["packages/*/tsconfig.json"]}
]]>這篇文章是官網上 Kosko 1.0 Released 的中文翻譯版。關於 Kosko 本身,除了官網,也可以參考這篇文章。
自從上一個穩定版本 v0.9 已經過了好一段時間了。最近我決定開始實作工作上一直都想用的一些功能,希望這些功能也能幫助到你。
從 v1.0 開始,component 內的 array 和 function 會被展開,這功能對於在不同 component 之間共用 manifest 會很有用。
舉例來說,通常在 Kubernetes 裡,資料庫會由一個 Deployment
和一個 Service
組成。如果要在 component 裡使用資料庫的話,在 v1.0 之前,必須要自行展開這兩個 manifest;在 v1.0 之後,就能自動展開了。
這樣的話,就可以把資料庫當成單一資源,在 component 到處使用了。
function createDatabase() { return [new Deployment(), new Service()];}// v1.0 之前module.exports = [new Deployment(), ...createDatabase()];// v1.0 之後module.exports = [new Deployment(), createDatabase()];
ValidationError
包含更詳細的資訊在 v1.0 之前,ValidationError
只包含 path
和 index
,有時可能會難以定位問題;在 v1.0 之後,ValidationError
加上了 apiVersion
、kind
、namespace
和 name
。以下是新的錯誤訊息的範例。
ValidationError: data.metadata.annotations['dependencies'] should be string- path: ".../components/config-api"- index: [0]- kind: "apps/v1/Deployment"- name: "config-api" at resolveComponent (.../node_modules/@kosko/generate/src/generate.ts:81:15) at resolveComponent (.../node_modules/@kosko/generate/src/generate.ts:59:28) at Object.generate (.../node_modules/@kosko/generate/src/generate.ts:134:30) at generateHandler (.../node_modules/@kosko/cli/src/commands/generate/index.ts:156:18) at handler (.../node_modules/@kosko/cli/src/commands/generate/index.ts:200:20) at Object.run (.../node_modules/@kosko/cli/src/index.ts:12:3)
如果你過去已經使用 Kubernetes 一陣子的話,可能會像我一樣有一堆 Kubernetes 的 YAML 檔。現在你不需要把這些 YAML 改寫成 JavaScript 了,可以試用看看新的 package @kosko/yaml
。
@kosko/yaml
會讀取 YAML 檔案,並建立對應的 kubernetes-models class,所以你的 manifest 就能使用 Kubernetes OpenAPI schema 來驗證。
const { loadFile } = require("@kosko/yaml");module.exports = loadFile("manifest.yaml");
這個功能有 “nested manifests” 的支援才會更完善,所以別忘了先更新到 Kosko v1.0 喔。
更多資訊請參考文件。
@kosko/generate
Manifest.index
和 ValidationError.index
的型別由 number
改為 number[]
。Manifest.data
的型別由 any
改為 unknown
。這段只會寫在這邊,不會放在原文上。
我在實作完上述的功能之後,又花了一點時間稍微改了下文件,原本有些只放在 examples
資料夾裡的東西,現在也放到文件裡了,所以整體來說應該會變得更易讀。我之後應該會再花一點時間來研究如何基於 @kosko/yaml
來實作 Helm 的支援。
我目前使用 Moor 做為 ORM,這個 library 的預設使用方式是在前景連接資料庫,在大多數情況下不會有效能問題,是最簡單的使用方式。
LazyDatabase _openConnection() { return LazyDatabase(() async { final dir = await getApplicationDocumentsDirectory(); final file = File(join(dir.path, 'db.sqlite')); return VmDatabase(file); });}
一開始實作下載功能時,我是在前景和背景分別連接資料庫,雖然兩方都可以正常讀取和寫入資料,但是無法監聽資料變動,而且如果同時寫入同一筆資料的話,可能會引發 lock。
以我的例子來說,當背景正在下載時,雖然可以正常把進度寫入到資料庫,但是前景不會觸發更新;當前景暫停或取消下載時,如果背景作業也剛好正在寫入下載進度的話,則會造成死鎖。
關於這個問題,在 GitHub 上有相關的 issue 討論,結論是,如果前景和背景同時執行 migration 的話,可能會造成資料不一致。如果背景確定不會執行 migration,且背景不會干涉前景的話,那就無須特別處理,前景和背景各自連接資料庫即可,否則需要利用 SendPort
/ ReceivePort
讓前景和背景共用同一個 MoorIsolate
。
在 Dart 裡面,所有的程式都在 Isolate
裡執行,不同的 Isolate
之間如果要通訊的話,就要透過 ReceivePort
/SendPort
來傳送和接收訊息。除此之外,Dart 還有另一個 IsolateNameServer
class,用來註冊 global 的 SendPort
。
舉例來說,假設有兩個 isolate 要通訊,其中一個是接收端,另一個則是發送端。
首先接收端要先建立一個 ReceivePort
,然後在 IsolateNameServer
註冊 ReceivePort.sendPort
。這裡要注意的是,如果 port 已經被註冊的話,必須要先移除原本註冊的 port,否則新註冊的 port 不會覆蓋掉原本舊的 port。
final receivePort = ReceivePort();receivePort.listen((msg) { // Message received});// 如果要覆蓋的話,必須要先移除原本註冊的 port// IsolateNameServer.removePortNameMapping('example');IsolateNameServer.registerPortWithName(receivePort.sendPort, 'example');
註冊完成後,發送端就可以用指定的名稱來搜尋已註冊的 port。
final port = IsolateNameServer.lookupPortByName('example');port?.send('ping');
首先,必須讓 Moor 產生 Isolate 相關的程式碼,在專案根目錄的 build.yaml
新增以下內容後,重跑 flutter pub run build_runner build
即可。
targets: $default: builders: moor_generator: options: generate_connect_constructor: true
接著要改寫 Database class。
// 這個 class 用來包裝要傳到 isolate 的資料class _Request { _Request(this.sendPort, this.targetPath); final SendPort sendPort; final String targetPath;}void _startBackground(_Request request) { // 建立新的 VmDatabase final executor = VmDatabase(File(request.targetPath)); // 因為目前的函數已經在背景 isolate 執行了,所以這邊直接讓 Moor 在目前的 isolate 啟動 final moorIsolate = MoorIsolate.inCurrent( () => DatabaseConnection.fromExecutor(executor), ); // 把 moorIsolate 回傳給 sendPort request.sendPort.send(moorIsolate);}Future<MoorIsolate> _createMoorIsolate() async { // 資料庫檔案的路徑 final dir = await getApplicationDocumentsDirectory(); final path = join(dir.path, 'db.sqlite'); // 建立新的 ReceivePort final receivePort = ReceivePort(); // 在新的 isolate 裡執行 _startBackground await Isolate.spawn( _startBackground, _Request(receivePort.sendPort, path), ); // 等待 receivePort 回傳的 MoorIsolate return await receivePort.first as MoorIsolate;}@UseMoor()class Database extends _$Database { // 這個新的 factory 函數用來從 DatabaseConnection 產生 Database instance Database.connect(DatabaseConnection connection) : super.connect(connection);}Future<void> main() async { final isolate = await _createMoorIsolate(); final db = Database.connect(await isolate.connect()); // 現在可以照常使用 db 了}
為了要讓背景能夠共用前景的資料庫連接,我在前景資料庫連接成功後,註冊一個 ReceivePort
用來傳送 MoorIsolate.connectPort
。
const _requestPortName = 'database.request';const _instancePortName = 'database.instance';void shareIsolate(MoorIsolate isolate) { // 建立一個 ReceivePort final requestPort = ReceivePort(); // 監聽 requestPort 的事件,當接收到事件時,把 connectPort 回傳給 instancePort requestPort.listen((message) { final instancePort = IsolateNameServer.lookupPortByName(_instancePortName); instancePort?.send(isolate.connectPort); }); // 移除先前註冊的 requestPort IsolateNameServer.removePortNameMapping(_requestPortName); // 註冊 requestPort IsolateNameServer.registerPortWithName( requestPort.sendPort, _requestPortName);}
背景方面則是先去尋找前景註冊的 port,如果有的話就對該 port 發送事件並等待回傳的 connectPort
,否則就建立一個新的 MoorIsolate
。
Future<MoorIsolate> reuseIsolate() async { // 尋找已註冊的 requestPort final requestPort = IsolateNameServer.lookupPortByName(_requestPortName); if (requestPort == null) return null; // 建立一個 ReceivePort 用來接收 connectPort final instancePort = ReceivePort(); try { // 註冊 instancePort IsolateNameServer.registerPortWithName( instancePort.sendPort, _instancePortName); // 對 requestPort 發送事件 requestPort.send(null); // 等待回傳的 connectPort final connectPort = await instancePort.first as SendPort; // 利用剛剛回傳的 connectPort 建立 MoorIsolate return MoorIsolate.fromConnectPort(connectPort); } finally { // 最後,移除並關閉 instancePort IsolateNameServer.removePortNameMapping(_instancePortName); instancePort.close(); }}
]]>RDB parser 本身其實不會太複雜,redis-rdb-tools 的作者很貼心地提供了詳細的文件說明 RDB 的格式。在 RDB 裡,每個 key 大概會長得像這樣:
FD/FC $ttl$value-type$string-encoded-key$encoded-value
整個 RDB 檔案除了開頭和結尾的一些 metadata 以外,大致上都是由這樣的 key 組成的,所以讀取起來很輕鬆,所有值前面都會標明長度,看到特定的 byte 就停下來,我大概花一週左右把初版的 RDB parser 寫完,API 長得像這樣:
parser := NewParser(file)for { data, err := parser.Next() if err == io.EOF { break } if err != nil { panic(err) } // do something}
就是一個很典型的 iterator 的形狀,這樣就不需要等到整個檔案都 parse 完才回傳結果。
測試時拿一些小型的 RDB 檔案(小於 1 MB)來 parse 的話大概都沒什麼問題,但實際上正式環境是由 16 個大約 1.7 GB 的 RDB 組成的,初版的 parser 大約需要花一分鐘左右才能 parse 完一個檔案,如果要每個 RDB 都 parse 的話就需要大約 15 分鐘。
雖然說實際上這個 parser 每天只會在半夜跑一次,就算讓它放著跑也無所謂,但我還是很好奇究竟為何這麼耗時,單檔 1.7 GB 照理來說應該不需要花這麼多時間來 parse。
我一開始以為原因是因為每個 key 都 parse 的話會很花時間,如果我只需要其中 20% 的資訊,卻花了其他沒必要的功夫來處理其他 key 的話,很顯然會浪費很多時間,所以我做了一個 key 的過濾機制,API 大概像這樣:
parser := NewParser(file)parser.KeyFilter(func(key string) bool { return key == "example"})
使用者可以自訂 KeyFilter
函數,如果 return false
的話就會直接跳過那個 key 長度的 bytes。
我原本以為這樣就能解決效能問題了,但事情卻不如我想像,即便 KeyFilter
永遠 return false
,也就是 filter 所有 key,速度還是沒差多少,這令我更好奇背後的原因了。
[]byte
就在這時我看到了 Dave Cheney 寫的 Building a high performance JSON parser,這篇文章描述了如何從頭開始做一個高效能的 JSON parser,讓我收穫最多的就是關於讀取的這段。
我在這邊大概介紹一下概念,詳細可以參考那篇文章或是 github.com/pkg/json。
Go 的 io.Reader
interface 長得像這樣。
type Reader interface { Read(p []byte) (n int, err error)}
使用方式很簡單,給 Read
方法一個 []byte
,Reader 就會讀取資料並把資料塞到 []byte
裡,並回傳它塞了多少個 byte 進去。
初版 RDB parser 的寫法很無腦,就是需要多少長度我就分配多少長度的 []byte
。
buf := make([]byte, 1)n, err := reader.Read(buf)
這種寫法一般來說沒什麼問題,只是比較沒有效率,需要頻繁的 allocate。例如說 RDB 裡長度多半會是 1~4 bytes,長度在每個 key 或是各種 data type 裡都會出現,那麼我就必須每次都 allocate 這種長度非常小的 []byte
。
文章裡提到的解決方法就是一次分配一塊 buffer,一次讀取更多資料,然後自己維護 buffer 裡資料的 offset 和 length,視情況需要擴張 buffer 的長度,這樣就不需要每次都 allocate 新的 []byte
,只有在 []byte
擴張或是 []byte
轉 string
的時候才會 allocate。
首先先把 []byte
切成三個區塊,從 0 到 Offset 之間是已經消化完的資料,從 Offset 到 Length 是已經從 io.Reader
讀取但尚未使用的資料,最後從 Length 到 Capacity 則是 []byte
的剩餘空間。
每次從這塊 buffer 讀取資料時,都會把 Offset 往右推進,如果 Offset 超過 Length 的話,則會從 io.Reader
讀取新的資料,這時會有四種狀況。
如果資料還能夠塞得進剩餘空間,那就會直接從 io.Reader
讀取資料,並更新 Length。
如果資料塞不下剩餘空間了,但小於 Capacity 的話,就會把 Offset 歸 0,然後讀取資料。
如果資料比 Capacity 還大的話,就會擴充 buffer 的空間。
我把 buffer 的擴充上限設定為 4096 bytes,如果資料大於這個大小的話,我就會直接 allocate 新的 []byte
,不會把資料放到 buffer 裡,這樣能避免某些太大的 value 把 buffer 撐得太大。
具體實作可以參考 rdb-go
的 byte_reader.go
,或是 pkg/json
的 reader.go
。
最後來比較一下改用 buffer 前後的差異,機器規格如下:
Benchmark 有兩個部分,一個是測試用的小檔案:
empty_database
- 完全空的 RDB(10 B)parser_filters
- 包含各種資料型態(1.2 KB)linked_list
- 一個 1000 個元素的 list(50 KB)另一個部分則是正式環境的 RDB,大約 1.7 GB。
首先是初版 RDB parser:
BenchmarkParser/empty_database-8 4782122 258 ns/op 64 B/op 5 allocs/opBenchmarkParser/parser_filters-8 13935 85142 ns/op 37856 B/op 1441 allocs/opBenchmarkParser/linkedlist-8 3426 355550 ns/op 274337 B/op 6025 allocs/op
1m0.3905848sAlloc = 0 MiB TotalAlloc = 11105 MiB Sys = 139 MiB NumGC = 3053
改用 buffer 後的結果:
BenchmarkParser/empty_database-8 2588905 388 ns/op 632 B/op 5 allocs/opBenchmarkParser/parser_filters-8 17988 67288 ns/op 38640 B/op 877 allocs/opBenchmarkParser/linkedlist-8 3628 329772 ns/op 274921 B/op 5020 allocs/op
18.9372338sAlloc = 3 MiB TotalAlloc = 11542 MiB Sys = 206 MiB NumGC = 3171
從小檔測試的部分可以看出雖然在 empty_database
的部分改用 buffer 後反而會更差,但是在其他情況下會好很多,原因是因為 buffer 的初始大小是 512 bytes,所以如果 RDB 小於 512 bytes 的話反而會 allocate 多餘的空間,但實際上不可能用在這麼小的 RDB,所以可以忽略。
在正式環境測試中,可以看到時間從一分鐘縮減到只需要 18 秒,改用 buffer 的效果十分顯著,雖然 TotalAlloc
(總共 allocate 的大小)和 NumGC
(GC 次數)沒差多少,推測大概是因為 []byte
轉 string
的 allocation。
除了自己實作 buffer 以外,利用 Go 內建的 bufio.Reader 也是一種選擇,但使用時必須要謹慎,我在測試時雖然能夠得出和上面差不多的效能,但是 TotalAlloc
和 NumGC
會暴增三倍,所以還是決定自己實作了。
在反覆嘗試的時候讓我學到了一些關於 Go 的效能最佳化的方法,推薦大家可以去看看文章內提到的原文,以及原文作者引用的一些相關連結。
如果剛好像我一樣有 RDB parser 的需求的話,歡迎試用看看我寫的 rdb-go。
]]>EH Redux 有一個功能就是能夠使用音量鍵來控制圖片翻頁,這項功能因為目前 Flutter 還沒有官方支援,所以必須要在 Android/iOS 這邊自己寫程式去補足。
MethodChannel 用於單次的非同步執行,這次我會用在切換音量控制是否開啟,因為 Flutter 在 Android 上預設只會有一個 FlutterActivity,無論在哪個畫面背後實際上都是同一個 activity,而音量控制只會用在圖片瀏覽的畫面上,所以必須動態切換音量控制,否則在其他畫面上也會受到影響。
首先是 Android 的部分,在 MainActivity
裡實作 configureFlutterEngine
方法,然後在裡面建立一個 MethodChannel
,用來監聽從 Flutter 傳來的事件。
// 用這個變數來控制要不要攔截 keydown 事件private var interceptKeyDownEnabled = falseoverride fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) // Method channel 的名字可以隨便取,只要確保在 Android/iOS 和 Flutter 兩邊用的名字一致就好了 MethodChannel(flutterEngine.dartExecutor, "com.example/method").setMethodCallHandler { call, result -> when (call.method) { "interceptKeyDown" -> { interceptKeyDownEnabled = true // 如果成功的話就用 result.success 回傳結果 result.success(true) // 失敗的話則是用 result.error 回傳錯誤 // result.error("ERROR_CODE", "error message", null) } "uninterceptKeyDown" -> { interceptKeyDownEnabled = false result.success(true) } else -> { // 其他沒有 handle 到的 method 就回傳 result.notImplemented result.notImplemented() } } }}override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { if (interceptKeyDownEnabled) { // 攔截 keydown 事件 return true } return super.onKeyDown(keyCode, event)}
接下來是 Flutter 的部分,這部分比較簡單,用 MethodChannel
的 invokeMethod
就能執行上面在 Android 定義的程式碼。
final methodChannel = MethodChannel('com.example/method');// 開始攔截 keydown 事件await methodChannel.invokeMethod('interceptKeyDown');// 停止攔截 keydown 事件await methodChannel.invokeMethod('uninterceptKeyDown');
到此為止就能夠從 Flutter 切換音量控制了。
EventChannel 讓 Flutter 能夠監聽從 Android/iOS 傳來的事件,這次會用在監聽 keydown 事件。
首先是 Android 的部分,在原本的 configureFlutterEngine
額外新增了一個 EventChannel
,並用 Rx subject 來傳遞 keydown 事件。
private var interceptKeyDownEnabled = false// 用 Rx 來傳遞事件,也可以改用其他類似的 libraryprivate val keyDownSubject = PublishSubject.create<String>()override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) MethodChannel(flutterEngine.dartExecutor, "com.example/method").setMethodCallHandler { call, result -> // ... } // Event channel 的名字可以隨便取,只要確保在 Android/iOS 和 Flutter 兩邊用的名字一致就好了 EventChannel(flutterEngine.dartExecutor, "com.example/event").setStreamHandler(object : StreamHandler { var dispose: Disposable? = null override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { // 開始訂閱事件 dispose = keyDownSubject.subscribeBy ( onNext = { events?.success(it) }, onError = { events?.error("KEY_DOWN_EVENT", it.message, it) }, onComplete = { events?.endOfStream() } ) } override fun onCancel(arguments: Any?) { // 停止訂閱事件 dispose?.dispose() dispose = null } })}override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { if (interceptKeyDownEnabled) { // 在按下音量 +/- 鍵的時候,送事件到 Rx subject 並攔截 keydown 事件 when (keyCode) { KeyEvent.KEYCODE_VOLUME_DOWN -> { keyDownSubject.onNext("volumeDown") return true } KeyEvent.KEYCODE_VOLUME_UP -> { keyDownSubject.onNext("volumeUp") return true } } } return super.onKeyDown(keyCode, event)}
接下來是 Flutter 的部分,用 EventChannel
的 receiveBroadcastStream
就能接收事件。
final eventChannel = EventChannel('com.example/event');// 訂閱事件final subscription = eventChannel.receiveBroadcastStream().listen((event) { final code = event as String; // ...});// 取消訂閱subscription.cancel();
這樣就能從 Flutter 監聽音量鍵的事件了。實際上的範例可以參考 EH Redux 的 MainActivity.kt 和 key_event.dart;更複雜一點的可以參考 hardware_buttons,它同時實作了 Android 和 iOS 的部分。
上上週的時候把 P5S 玩完一輪了,這真的是一款非常優秀的遊戲,與其說是無雙,不如說更像動作 RPG,需要花一點時間適應;而劇情上也很不錯,補完了一些原本在本傳裡戲份比較少的角色的劇情,像是佑介和春,感覺角色更加生動了。
那麼究竟是為什麼明明遊戲都玩完了,卻還是沒有繼續開發 app 呢,主要是因為最近接觸到赤井はあと拍的一堆狂氣廢片後,讓我開始踏入 Hololive 的坑,又開始浪費時間看 Vtuber 了😜。我預計從這周末開始應該就會重啟開發,應該吧。
]]>最近心血來潮,決定重新開始學習打從一年前就想玩玩看的 Flutter,試試看能不能做出我廢棄多年的 E-Hentai 閱讀器 for Android。
Flutter 是 Google 開發的跨平台 UI toolkit,可以同時支援 Android、iOS 和 Web,其原理就是用 canvas 來繪製所有的 UI,不需要像 React Native 一樣得在 UI 和 JavaScript engine 兩邊互相溝通而導致效能問題。
另一個優勢就是 Flutter 本身已經提供了非常完整的 UI library,無論是 Android 或 iOS 風格皆有對應的元件可直接取用,雖然有些時候可能會發現和原生的 UI 在外觀或是動畫上有些微妙的差異,但整體來說已經非常實用了。
本文會以 Web 的角度來分析 Flutter 的優缺點,因為我比較熟 React,所以主要會拿它來做比較。
Flutter 和 React 一樣都是採用 Declarative 的形式來建構 UI,這似乎是最近越來越流行的做法。Android 現在有 Jetpack Compose,iOS 有 SwiftUI。
和傳統的 Imperative 比較的話,最明顯的差別就是,不需要在狀態更新的時候手動更新對應的元素;Declarative 只要定義好介面,程式就會自動去判斷哪些元素需要更新。
React 可以用 JSX,語法會比較接近 HTML,在編譯時會把它轉換成對應的 JavaScript。
<div className="foo">Hello</div>React.createElement('div', {className: 'foo'}, 'Hello')
Flutter 就沒有提供這種語法,所有 widget 都是 class。
// new 可以省略new Text('Hello')
由於 Flutter 不是用 CSS 來宣告元件的樣式,而是把各種樣式實作在不同的 widget 上。舉例來說 padding 就有一個獨立的 widget。
Padding( padding: EdgeInsets.all(8), child: Text('Hello'))
有些情況如果 widget 層級過深的話,比起 JSX 或 HTML 來說,要搬移或是修改會稍微困難一點。好在 IDE 提供了非常方便的功能,可以輕鬆的修改 widget 層級。
Flutter 的 widget 分為兩種:StatelessWidget
是不儲存狀態的 widget,類似 React 的 function component。這種 widget 不需要管理任何狀態或生命週期,只需要把 UI 建構出來就好了,會在屬性變動的時候自動更新。
class Foo extends StatelessWidget { const Foo({ Key key, this.name, }) : super(key: key); final String name; @override Widget build(BuildContext context) { return Text('Hello $name'); }}
StatefulWidget
則是類似 React 的 class component,本身可儲存狀態,也有生命週期。這種 widget 會分為兩個 class,一個是用來建立狀態的 StatefulWidget
class,另一個則是儲存狀態用的 State
class。在 State
class 裡,可以用 setState
來更新狀態。
class Foo extends StatefulWidget { @override _FooState createState() => _FooState();}class _FooState extends State<Foo> { int count = 0; @override Widget build(BuildContext context) { return FlatButton( child: Text('You have clicked $count times'), onPressed: () { setState(() { count++; }); }, ); }}
Flutter 本身對於 hot reload 的支援非常好,任何改變幾乎都能在兩秒左右就反映在裝置上,即便是有儲存狀態的 StatefulWidget
,也能把狀態保留下來更新裡面的內容,即便是部分特殊情況,通常也能用 hot restart 的方式重開,無需等待重新編譯的時間,大幅增進了開發效率。
Flutter 採用 Dart 做為開發的程式語言,雖然和 Go 一樣都是由 Google 出品,但兩者的差異非常大,各有優缺點。我覺得 Google 應該融合這兩個程式語言的優點,再開發一個更好的版本。
目前 Dart 所有型別都是 nullable 的(Null safety 仍在 tech preview,我還沒用過),就連原始型別(primitive types,如 boolean
, int
, double
)也是 nullable,這點和我用過的 JavaScript 或 Go 不同,導致一開始踩到一些雷。
目前 Dart 有幾種方法可以緩解這個問題,一種是以 @required
annotation 來標示必須的變數,這樣在 IDE 或 analyzer 都能透過靜態檢查確認有沒有設值。
void foo({@required String bar}) {}
另一種則是用 assert
function 檢查,但是這個只能在開發模式下使用,在正式環境時會被完全忽略掉。
assert(value != null);
不過 Dart 有個優點,就是支援 optional chaining 和 nullish coalescing。這些功能稍微緩解了 nullable 的問題,讓平常習慣寫 TypeScript 的我感到非常親切。
foo?.bar?.baz ?? value;a ??= value;
Dart 和 Go 一樣,都很依賴 code generating。我覺得 Dart 用到 code generating 的頻率更勝於 Go,例如:
這些 library 是我這次寫 app 有用到的,它們都非常依賴 code generating,相比之下 Go 多半依賴於反射。這樣的好處是執行時效能更好、更加安全,但壞處就是每次改動都需要重新跑 codegen,像我寫的這個小 app 每次重跑都需要半分鐘。
Dart 本身也提供了很多好用的工具,像 Go 一樣,Dart 也有 dartfmt,用來格式化程式碼,這樣可以讓多數用 Dart 寫的程式看起來都很接近。
除此之外,還有 dartanalyzer,用來檢查語法問題,這個工具本身就內建了很多規則,但多半都需要手動開啟,我現在是用 lint,它本身開啟了很多有用的規則。
我原本以為這次寫 Flutter 可以完全不需要碰到任何 Android 的原生部分,結果有些部分還是得寫一些 Kotlin 才能實作。
Flutter 雖然提供了方法可以隱藏上方的狀態列(status bar)和下方的導覽列(navigation bar)。
// 把 top 或 bottom 從這個 array 移除掉的話,就能隱藏對應的系統狀態/導覽列SystemChrome.setEnabledSystemUIOverlays([ SystemUiOverlay.top, SystemUiOverlay.bottom,]);
然而現在很多手機螢幕會有瀏海或挖洞,在 Android 顯示和隱藏系統狀態列的時候,會導致整個畫面跳動,必須要在 Android 設定才可以讓畫面延伸到最上方。
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
另一個問題就是預設下方的系統導覽列是黑色的,如果要改透明的話也要在 Android 這邊設定。(參考:flutter#34678, flutter#40974)
<item name="android:windowTranslucentStatus">true</item><item name="android:windowTranslucentNavigation">true</item>
window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
硬體按鈕(例如音量鍵、返回鍵等)目前官方還不支援,雖然已經有現成的 hardware_buttons 套件能夠直接使用,但因為我想研究看看 Flutter 和 Android 之間的通訊,所以就自己實作看看了。我覺得實際上不會很難實作,只是相對來說可能除錯比較麻煩一點而已。
Flutter 對於平台本身的通訊有兩種,一種是 MethodChannel,用於單次的非同步執行;另一種則是 EventChannel,用來監聽連續的事件。
因為這部分似乎寫起來會有點長,我決定放在之後的文章,各位如果有興趣的話可以先看官方的教學或範例。
目前為止 Flutter 大概寫了三週左右,我覺得其實開發體驗意外的和 React 蠻接近的,兩者都提供了 declarative UI 和 hot reload,也有 Redux 或 MobX 可以用。
差別大概在於 JavaScript 生態系實在太龐大,有時候選擇困難,光是挑個 library 可能就會浪費好幾天;寫 Flutter 就沒這種困擾了,本身套件庫沒那麼龐大,且每個套件都有評分和 Flutter Favorite 標誌做為參考,相對來說比較好選擇。
最後各位如果有興趣的話,可以下載 EH Redux 來玩玩看,雖然最近沉迷於 P5S 所以開發會停滯幾週,但目前除了下載以外的主要功能大致上都完成了,如果在使用時有遇到問題的話歡迎到 GitHub 留 issue。
]]>本篇接續 Yarn 2 和 Monorepo 提到的部屬的部分,因為 monorepo 裡包含了很多套件和網站,如果直接在根目錄執行 docker build
把整個 monorepo 打包成 Docker image 的話,勢必會做出大於 1 GB 而且內含一堆無用垃圾的 Docker image;為了要讓 Docker image 能夠最小化,必須只打包正式環境會需要用到的套件,確保不會浪費任何空間和時間。
我把建構 Docker image 的步驟分為:
這部分對於 Yarn 2 來說非常容易,在擴充套件裡可以直接讀取整個 monorepo 的狀態,可以參考 yarn workspaces focus
的原始碼,這個指令是用來只安裝特定 workspace 需要用到的套件,剛好和我需要的功能相同。
import { Configuration, Project, Cache } from '@yarnpkg/core';// 讀取設定const configuration = await Configuration.find(this.context.cwd, this.context.plugins);const { project } = await Project.find(configuration, this.context.cwd);// 取得指定的 workspaceconst workspace = project.getWorkspaceByIdent( structUtils.parseIdent('@foo/bar'),);const requiredWorkspaces = new Set([workspace]);for (const ws of requiredWorkspaces) { // scope 可以是 `dependencies`, `devDependencies` // 因為我們只需要正式環境會用到的套件,所以這邊只用 `dependencies` const deps = ws.manifest.getForScope('dependencies').values(); // 把相依的 workspace 新增到 `requiredWorkspaces` 裡 for (const dep of deps) { const workspace = project.tryWorkspaceByDescriptor(dep); if (workspace) { requiredWorkspaces.add(workspace); } }}// 接著把 project 裡所有 workspace 的 manifest (package.json) 都清理一遍for (const ws of project.workspaces) { // 如果這個 workspace 在正式環境會用到,那麼只清掉 `devDependencies` if (requiredWorkspaces.has(ws)) { ws.manifest.devDependencies.clear(); } else { // 否則就把所有的 dependencies 都清掉 ws.manifest.dependencies.clear(); ws.manifest.devDependencies.clear(); ws.manifest.peerDependencies.clear(); }}
透過上面的程式碼,就能得到正式環境需要用到的 workspaces。接下來,我會重跑一遍 yarn install
,因為已經有快取了,所以不需要花多少時間,這是為了產生更新後的 yarn.lock
,並了解有哪些 .yarn/cache
的檔案會被用到。
// 讀取現有快取const cache = await Cache.find(configuration);// 解析 dependencies// 這部分相當於 `yarn install` 裡的 `Resolution Step`await project.resolveEverything({ report, cache });// 下載 dependencies// 這部分相當於 `yarn install` 裡的 `Fetch Step`await project.fetchEverything({ report, cache });// 執行完上面兩個步驟後,就能產生新的 `yarn.lock`const newLockFile = project.generateLockFile();// 也能知道有哪些 cache 會被用到for (const file of cache.markedFiles) {}
除了相依套件外,還需要把 workspaces 的原始碼也複製到 Docker image 裡,為了精簡需要複製的檔案量,可以參考 yarn pack
的原始碼,我在這邊用 packUtils
取得檔案列表,然後再複製到指定的資料夾裡。
import { packUtils } from '@yarnpkg/plugin-pack';// `prepareForPack` 是用來執行 `prepack` 和 `postpack` 等 lifecycle hooks 的await packUtils.prepareForPack(workspace, { report }, async () => { // 取得檔案列表 const files = await packUtils.genPackList(workspace); // 如果想要把檔案壓成壓縮檔的話,可以用 `genPackStream` const stream = await packutils.genPackStream(workspace);});
最後,檔案會被分成兩個部分複製到不同的資料夾,一個是 manifests
,用來儲存 yarn install
需要用到的檔案,像是 package.json
, yarn.lock
和快取;另一個部分則是 workspaces 的原始碼,也就是上面 yarn pack
產生的結果。
以下是 Dockerfile
的範例:
FROM node:12-alpine AS builderWORKDIR /workspaceCOPY manifests ./RUN yarn install --immutableRUN rm -rf .yarn/cacheFROM node:12-alpineWORKDIR /workspaceCOPY --from=builder /workspace ./COPY packs ./CMD yarn workspace @foo/bar start
在寫完 Yarn 2 那篇文章後,我花了一些時間把內部使用的 Yarn 擴充套件整理了一下並開源,各位可以試用看看:yarn-plugin-docker-build。
]]>上週試著用 Tailwind CSS 重新打造了網誌的主題,一開始使用的時候,覺得一直翻文件很煩,因為大部分的 CSS 規則大概都知道怎麼寫,卻得要翻文件才知道對應的 class;但寫了一段時間後,開始覺得還不錯,大部分的 class 都很容易預測,也很容易根據需求客製變數或外掛。
跟 Bootstrap 或 Semantic UI 這類 UI library 相比,Tailwind CSS 不提供現成的元件(另有提供須付費的 Tailwind UI),而是把每個 CSS 規則都寫成單獨的 class,因此即便完全不寫 CSS,只要在 HTML 中設定 class,也很容易能夠拼湊出想要的樣式,但缺點就是還是需要有基本的 CSS 知識才能上手。
首先用 npm 或 yarn 安裝 tailwindcss。
npm install tailwindcss
可以搭配 PostCSS 使用。
const postcss = require('postcss');postcss([ require('tailwindcss'), require('autoprefixer')]);
Tailwind 本身只需要在 CSS 的開頭加上下列語法就能使用,這包含了 normalize.css 和所有可能會用到的 class,展開來大約會有數萬行之多。但是無須擔心,在正式環境下,Tailwind 會利用 PurgeCSS 把沒用到的 class 都清掉,像是本網誌在正式環境下,尚未壓縮過的 CSS 大約不到千行。
@tailwind base;@tailwind components;@tailwind utilities;
引入基本 class 後,就能直接在 HTML 使用。舉例來說,一個按鈕可以寫成:
<button class="rounded p-4 border-indigo-500 text-base"></button>
上面的 HTML 裡,每個 class 都各自代表了下列的 CSS 規則。
.rounded { border-radius: .25rem }.p-4 { padding: 1rem }.border-indigo-500 { border-color: #667EEA }.text-base { font-size: 1rem }
這就是 Tailwind 提倡的 Utility-First 概念,因為每個 class 都非常基本,因此很容易組合,不需要特地想每個元件的 class name,也不會每加一個新元件就多一個 class,只要直接使用 Tailwind 提供的 class 就好。
只要在原本的 class 前面加上 prefix,就能支援 pseudo class 和 responsive design。
<button class="rounded p-4 border-indigo-500 text-base hover:font-bold lg:text-lg"></button>
舉例來說,在原本的按鈕加上兩個新的 class hover:font-bold lg:text-lg
。它們分別代表:
.hover\:font-bold:hover { font-weight: bold;}@media (min-width: 1024px) { .lg\:text-lg { font-size: 1.125rem; }}
如果要重複使用元件的話,也可以在 CSS 使用 @apply
directive。舉例來說,上面的按鈕就可以寫成:
.btn { @apply rounded p-4 border-indigo-500 text-base;}
有些情況直接寫 CSS 會更方便,例如 nested elements,或是 pseudo elements(::before
, ::after
),本網站左上角和右下角的「」,以及暗色系的捲軸就是這樣實作的。
對我來說 Tailwind CSS 的確能提升開發效率,大幅節省了我寫 CSS 的時間,幾乎大部分的元件都能直接在 HTML 寫 class 就好。
它本身的命名系統也很不錯,顏色在 background-color
、color
、border-color
等各種情境下都是通用的,所以只需要記下 prefix 就好。我還另外設定了顏色的 alias,這樣就能在任何地方使用指定的顏色,例如 bg-accent
、text-accent
,一看名字就知道意思。
數字的部分變化就稍微多一點,margin
和 padding
是以數字命名,font-size
則是用 sm
、lg
、xl
命名,line-height
甚至是數字和名詞混用。我在使用的時候覺得這些單位的選擇都很符合我的需求,或許這些以非數字來命名的 class 就是設計者認為的最佳單位?
今年初隨著公司的 repo 越來越多,我們決定把 web 前端部分轉為 monorepo 的形式,一開始花了一段時間研究各個 monorepo 方案的利弊,最後決定基於 Yarn 2 打造一套自用的工具。這篇文章會大概分析一些我試過的 monorepo 方案的優缺點,以及最後用 Yarn 2 的成果。
Lerna 是我一開始比較熟悉的方案,在 Kosko 和 kubernetes-models-ts 都有用到,算是 JavaScript monorepo 非常普遍的選擇。
lerna version
才能符合我們的需求。node_modules
共用避免浪費空間。yarn workspace @scope/a add @scope/b
總是會試圖從 npm 下載 package,而不是先安裝 local 版本 (yarnpkg/yarn#4878)。pnpmfile.js
客製 pnpm install
的行為,可用來限制 dependencies 版本或是竄改 package.json
。Rush 是微軟推出的 JavaScript monorepo 方案,設計更加嚴謹且繁瑣。
node_modules
,因此所有 workspace 都能直接引用,即便沒有寫在 package.json
裡。pnpm 則是會把所有 dependencies 都裝到另外的資料夾,再用 symlink 連結到各個 workspace 的 node_modules
。Bazel 是 Google 推出的跨語言 monorepo 方案,很強大也很複雜,對於我們來說,只是要支援 JavaScript 卻要寫這麼多設定,實在讓人頭痛。
在我研究的這段期間,Yarn 2 剛好推出了 RC 版,相較於 Yarn 1 變化非常大,詳細內容可以參考 Introducing Yarn 2。
現在 yarn workspaces foreach
的功能更完善,有點接近 Lerna。
yarn workspaces foreach --parallel --interlaced --topological run ...
Workspace 之間相互引用時,不再出現上面提到的 yarn add
問題。
yarn workspace @scope/a add @scope/b
透過新功能 Constraints,可以限制 dependencies 的版本,在官方的 constraints.pro
可以看到許多有趣的範例。
例如下面這段可以用來確保每個 workspace 所用的 dependencies 版本統一。
gen_enforced_dependency(WorkspaceCwd, DependencyIdent, DependencyRange2, DependencyType) :- workspace_has_dependency(WorkspaceCwd, DependencyIdent, DependencyRange, DependencyType), workspace_has_dependency(OtherWorkspaceCwd, DependencyIdent, DependencyRange2, DependencyType2), DependencyRange \= DependencyRange2.
所有功能幾乎都是以擴充套件的形式實作的,官方本身提供了一些非常好用的擴充套件。我們用到了:
@types/
套件。yarn workspaces foreach
功能。如果要自己實作擴充套件也非常簡單,透過 Yarn 2 的 API 可以輕鬆地得到每個 workspace 的狀態。我們自己也實作了一些簡單的擴充套件:
tsconfig.json
的 references
。Yarn 2 預設會啟用 Zero-Installs (Plug’n’Play),也就是把所有 dependencies 安裝到 .yarn
資料夾,完全消滅了 node_modules
的存在,藉此解決效能和 node_modules
占用太多硬碟空間的問題。
這個功能需要 toolchain 的配合,因為它徹底改寫了 Node.js 的 module resolution 機制,雖然目前很多主流的工具都支援了 PnP,但是 VSCode 目前沒有辦法預覽套件內容,因為 Yarn 2 用 zip 儲存套件,VSCode 雖然能夠解析路徑,但無法讀取 zip 檔的內容 (microsoft/vscode#75559)。
目前的解法是關閉 Zero-Installs 功能,在 .yarnrc.yml
設定 nodeLinker
即可。
nodeLinker: node-modules
Yarn 2 比起 Yarn 1 也並非完全沒有缺點,Yarn 2 在 yarn install
會切分成多個步驟,分別是 Resolution、Fetch、Link。Resolution 和 Fetch 得益於新的設計會把所有 packages 儲存在 .yarn/cache
資料夾所以非常快,但是 Link 階段就慢一些,平均大概需要 30 秒至一分鐘以上,或許開啟 Zero-Installs 會快一些?
Yarn 2 會在 .yarn
儲存用 Webpack 編過的 Yarn 本體和擴充套件,大約佔 3 MB,這樣的好處是可以確保在不同環境下使用的 Yarn 版本都完全相同,缺點就是在 repo 裡會多了一些額外的檔案。
Yarn 官方更是把整個 .yarn/cache
資料夾都 commit 到 Git 上,這樣的好處或許是能夠直接省去 fetch packages 的時間,但 git clone 的時間應該也會更長。
我們把部屬流程分成了三塊:測試→編譯→發佈。
在這個階段會對整個 monorepo 進行 lint 和 unit tests,目前整個過程需時大約不到 3 分鐘,所以沒有拆開來執行。
這個階段相對來說非常耗時,在執行前會用 yarn changed
來檢查 workspace 以及其依賴的套件有沒有變動,如果沒有的話就會直接跳過不做,藉此可以省下時間和成本。在圖中可以看到有些 job 花的時間特別短,就是因為那些沒有變動的部分都直接跳過了。
檢查的 script 如下,只需要三行,非常簡短。
if ! yarn changed list --git-range "$GIT_COMMIT_RANGE" | grep -q "$WORKSPACE_NAME"; then circleci-agent step haltfi
在編譯完成後,會將 Docker image 部屬到測試環境,確保測試環境和 master branch 同步。
最後要發佈到正式環境時,會利用 semantic-release 更新版號,把測試環境的 Docker image 複製到正式環境上,一切就大功告成了。
]]>目前敝社的開發流程基於 GitHub flow,大致上如上圖所示。要開發新的功能時,會從主要分支 master
開出新的功能分支 feature
,功能開發完成後會提出 pull request,審核通過後就會合併進 master
,並部署到 staging 伺服器上。
通常在審核 pull request 時,我們僅會檢視程式碼和附帶的測試,如果一切順利而且 CI 測試通過的話,就會直接合併進 master
。然而在某些情況下,特別是以前端極少測試的狀況來說,只看程式碼有時無法看出問題所在,必須實際執行才能確保程式能夠正常運作,有時也可能需要 PM 確認程式符合 spec,或是讓設計師確認程式符合設計稿。
對於工程師來說,要把程式執行起來並不困難,但對於其他人來說,光是設定環境可能就是件讓人頭痛的事了。我們常用的方法是直接讓他們透過區網或 ngrok 連到工程師的電腦上,有時甚至為了上 staging 伺服器就直接合併進 master
了。讓還沒審核通過的程式碼進入 master
不僅可能造成 staging 伺服器的異常,也有可能會影響其他工程師的開發進度。
為了解決這個問題,我想到的解決方法是利用 webhook 接收 GitHub 的 pull request 事件,在建立新的 pull request 時,自動把程式碼部署到 Kubernetes 上,讓使用者可以透過子網域測試 pull request,有點類似 Netlify 的 Deploy Preview,能夠在 pull request 建立時自動部屬到子網域 deploy-preview-42--yoursitename.netlify.com
。
Pullup 在收到 pull request 事件時,會以原資源為基準複製新的資源,並自動在 pull request 更新時一併更新資源。以上圖為例,通常大部分部屬在 Kubernetes 裡的服務都會包含 Deployment
和 Service
,Pullup 能更新 Deployment 所使用的 Docker image,並修改 Service 的 selector,確保 pr1.example.dev
能存取到新服務。
當 pull request 被合併或關閉時,Pullup 會利用 Garbage Collection 刪除已部屬的資源,避免資源浪費。
以下指令會在 pullup
namespace 中安裝 Pullup 相關的 CRD 和各種必要元件。
kubectl apply -f https://github.com/tommy351/pullup/releases/latest/download/pullup-deployment.yml
你可在 deployment 資料夾中檢視原始碼,YAML 檔中包含:
更詳細的說明請參考文件。
常見的使用範例是部屬新的 Deployment 並搭配相對應的 Service。下面的範例會更新 Deployment 的 image 和 labels,並修改 Service 的 selector。除了範例裡的欄位以外,你也可以更新其他既有欄位,Pullup 會使用類似 kustomize 的策略去建立資源。
apiVersion: pullup.dev/v1alpha1kind: Webhookmetadata: name: examplespec: repositories: - type: github name: tommy351/pullup resources: - apiVersion: apps/v1 kind: Deployment metadata: name: example spec: # 更新 selector 和 labels 以避免和原資源混淆 selector: matchLabels: app: "{{ .Name }}" template: metadata: labels: app: "{{ .Name }}" spec: - name: foo # 更新 image image: "tommy351/foo:{{ .Spec.Head.SHA }}" - apiVersion: v1 kind: Service metadata: name: example spec: # 對應到新 Deployment 的 labels selector: app: "{{ .Name }}"
如果想要自動建立子網域,因為 Pullup 僅能用於建立新資源,無法修改現有的 Ingress,可以改用 Contour 的 IngressRoute。Contour 利用 Envoy 實作了 Ingress controller,它提供獨立的 IngressRoute 資源比較容易以 pull request 為單位建立子網域。
apiVersion: pullup.dev/v1alpha1kind: Webhookmetadata: name: examplespec: resources: # 中略 - apiVersion: contour.heptio.com/v1beta1 kind: IngressRoute metadata: name: example spec: virtualhost: fqdn: "{{ .Name }}.example.dev" routes: - match: / services: - name: "{{ .Name }}" port: 80
利用 cert-manager 自動申請並更新 TLS 憑證,以下僅提供 Certificate 部分的範例,詳細的設定方式請參照文件。
直接申請 wildcard TLS 憑證,優點是只需要申請一次憑證就可用於所有 pull request,缺點則是每次申請憑證耗時需數分鐘,且僅能利用 DNS-01 驗證網域。
apiVersion: certmanager.k8s.io/v1alpha1kind: Certificatemetadata: name: examplespec: secretName: example-tls issuerRef: name: letsencrypt-prod kind: Issuer commonName: *.example.dev dnsNames: ["*.example.dev", ".example.dev"] acme: config: # Wildcard 憑證只能利用 DNS-01 驗證 - dns01: provider: cloudflare domains: ["*.example.dev", ".example.dev"]
如果你無法透過 DNS-01 驗證,可以試試看只申請單一網域的 TLS 憑證,通常每次申請憑證耗時不到一分鐘,比 wildcard 憑證快速許多。
apiVersion: pullup.dev/v1alpha1kind: Webhookmetadata: name: examplespec: resources: # 中略 - apiVersion: certmanager.k8s.io/v1alpha1 kind: Certificate metadata: name: "{{ .Name }}" spec: secretName: "{{ .Name }}-tls" issuerRef: name: letsencrypt-prod kind: Issuer commonName: "{{ .Name }}.example.dev" dnsNames: ["{{ .Name }}.example.dev"] acme: config: # 可以用 DNS-01 或 HTTP-01 驗證 - dns01: provider: cloudflare domains: ["{{ .Name }}.example.dev"]
這是我第一次開發 Kubernetes controller,一開始花了很多時間摸索,中途才發現了 controller-runtime 和 Operators SDK,大幅地節省了我的開發時間,之後預計再寫一篇關於 controller-runtime 的基本教學,以及 Pullup 實際上如何應用 controller-runtime。
]]>敝社從 2016 年就開始 Kubernetes,應該能算是相當早期的使用者了,也因此我們累積了一堆的 Kubernetes YAML 設定檔,從某個時間開始 staging 和 production 環境的設定檔更開始分裂,自此以來一直無法合併。因此這次的目標就是:
一開始我先從 awesome-kubernetes 尋找現有的設定管理工具,以下列出一些我覺得還不錯的工具以及它們的優缺點。
因為現有工具對我來說都有些不足的地方,所以我最後決定根據 ksonnet 的概念,並稍微調整一些部份讓我用起來更順手一些:
相較於 ksonnet 來說砍了很多功能,所以實際上實作並沒有花太多時間,麻煩的是把現有的上百個 YAML 檔轉換成 JavaScript、整合 staging 和 production 環境並實際在 Kubernetes 上測試,大約花了 5 週才完成所有工作,最後的結果非常可觀。
npm install kosko -g
kosko init examplecd examplenpm install
# 輸出到 consolekosko generate# Apply 到 Kubernetes cluster 上kosko generate | kubectl apply -f -
其實在執行 kosko generate
時也會順帶驗證,這個指令只是用來方便在 CI 上跑測試時不會把設定輸出到 log。
kosko validate
# 單一檔案kosko migrate -f nginx-deployment.yml# 資料夾kosko migrate -f nginx
預設的資料夾結構參考 ksonnet,components
資料夾用來放 manifests,environments
則是各環境的參數。
.├── components│ ├── nginx.js│ └── postgres.js├── environments│ ├── staging│ │ ├── index.js│ │ ├── nginx.js│ │ └── postgres.js│ └── production│ ├── index.js│ ├── nginx.js│ └── postgres.js├── kosko.toml└── templates
但實際使用時發現這種結構在 components 過多時使用起來必須要在 components
和 environments
兩個資料夾來回,不太方便,所以最後加上了自訂路徑的功能。
[paths.environment]component = "components/#{component}/#{environment}"
上述的設定改變了 component environments 的檔案路徑,變成了下列的結構:
.├── components│ ├── nginx│ │ ├── index.js│ │ ├── staging.js│ │ └── production.js│ └── postgres│ ├── index.js│ ├── staging.js│ └── production.js├── environments│ ├── staging.js│ └── production.js├── kosko.toml└── templates
為了能夠驗證設定是否符合 schema,我根據 Kubernetes 的 OpenAPI specification 產生了相對應的 TypeScript。不僅能夠在編譯時找出一些基本的型別錯誤,即使沒有使用 TypeScript 也能透過 JSON schema 驗證設定。
下面列出一些開發時遇到的問題:
JSON 實際上是沒有 undefined
型別的,雖然 JSON.stringify
會直接忽略,但是 js-yaml 卻不會,所以我必須在 toJSON()
函數裡刪除所有 undefined
的欄位。
在 Kubernetes 裡有一種特殊型別叫做 int-or-string
,雖然在 JSON schema 是 string
,但在 TypeScript 必須轉為 string | number
,不然編譯器常會報錯。舉例來說,Service
中的 targetPort
就是常見的情況,它同時可以是 port number (int) 或 named port (string)。
new Service({ spec: { ports: [{ port: 80, targetPort: 80 }] }});
最後炫耀一下,在支援 TypeScript 的編輯器裡寫設定有多爽 😎
一開始其實是打算用 ksonnet 的,但是必須要另外學 jsonnet 很麻煩。開始造輪子大約一個月後發現 ksonnet 竟然停止維護了,不禁感嘆幸好當初選擇了自己造輪子?
其他使用 Kubernetes 的大大們可能也會遇到設定管理的問題,不知道各位是怎麼解決的?是使用官方的 kustomize?還是也自己開發工具?又是如何管理 secrets 呢?如果可以的話,希望能互相交流。
]]>之後發現了 Quramy/lerna-yarn-workspaces-example 這個範例,裡頭用了 Yarn、Lerna 和 TypeScript,在這之中 Yarn 並不是必須的可以忽視,重點是在 TypeScript 3.0 推出的 Project References,這個功能讓 TypeScript 能夠知道各個模組之間的依賴關係,因此自動解決了編譯順序的問題。
要使用 Project References 的話必須在 tsconfig.json
加上下列選項。
{ "compilerOptions": { "composite": true, "declaration": true, "rootDir": "src", "outDir": "dist" }}
composite
- 讓 TypeScript 能夠快速找到被引用專案的位置。declaration
- 編譯定義檔 (.d.ts
)。rootDir
- 設定專案的根目錄,預設是 tsconfig.json
的所屬資料夾。outDir
- 編譯的輸出路徑。並在要引用的地方加上 references
。
{ "references": [ { "path": "../x-core" } ]}
然後在執行 tsc
時加上 -b
選項以及要編譯的專案路徑,就能順利編譯。
tsc -b packages/x-core packages/x-cli
因為專案比較多,所以我另外寫了一個 script 自動找出所有需要編譯的專案路徑。
"use strict";const spawn = require("cross-spawn");const globby = require("globby");const { dirname } = require("path");const TSC = "tsc";const pkgs = globby.sync("packages/*/tsconfig.json").map(dirname);const args = ["-b", ...pkgs, ...process.argv.slice(2)];console.log(TSC, ...args);spawn.sync(TSC, args, { stdio: "inherit"});
執行 tsc
時加上 --watch
就能夠監看檔案變化並自動重新編譯。
tsc -b packages/x-core packages/x-cli --watch
執行 tsc
時加上 --clean
則是能夠自動根據 outDir
設定清除編譯後的檔案。
tsc -b packages/x-core packages/x-cli --clean
可以在 tommy351/kosko 看到實際運用的範例。
]]>前幾個月改版時,我決定用 Elixir 來實作 OAuth server + Proxy,這是一門結合了 Erlang VM 和 Ruby 語法的程式語言,可以很容易運用 Erlang 的特性做出低延遲、高並發且高容錯度的系統,又不用學習 Erlang 比較特殊的 Prolog 式語法(但是你可能還是多少要懂 Erlang 語法,因為很多時候你會直接運用 Erlang library)。
Erlang 的這些強大特性拿來做 OAuth server + Proxy 似乎有些大材小用,不過因為我爽,所以就決定用 Elixir 來寫了。
實作 OAuth 的部分很無聊就不在本文贅述了,我強烈推薦 Yu-Cheng Chuang 大大寫的 OAuth 2.0 筆記,搭配 RFC 6479 spec 很快就能實作出符合規格的 OAuth server。
接著就是今天的重頭戲 Proxy,用 Elixir 實作可能不會是你的最佳選擇,所以看看就好,不要模仿。
首先必須先選個 HTTP client,在 Node.js 有個非常強大的 request,而 Elixir 有:
或是 Erlang:
Elixir 的 library 因為經過封裝而損失了一些比較底層的功能,所以我決定直接使用 Erlang library,這時我就瞭解到學會 Erlang 的重要性,因為有些 library 是沒有寫文件的,必須直接讀原始碼才能瞭解如何運用。
hackney 是我第一個接觸的 library,它是這幾個 library 裡更新最勤勞,而且在 Elixir 中使用也比較不突兀,用起來最順手的 library,但是因為一些已知問題(#191, #267,可能會在 hackney 2.0 解決),所以我決定尋求其他 library。
ibrowse 是這裡頭第二靠譜的 library,但是運用上比 hackney 麻煩一些,要事先把 binary 轉成 list,而且可能是 HTTP 規格實作上的差異導致有些 request 無法正確完成。
已停止維護。
宣稱還在早期開發階段,然而已經超過兩年沒有任何 commit,而且沒有文件,是給人用的嗎?
與 cowboy 系出同門,都是 Nine Nines 的作品,感覺相當不錯,可惜的是使用到了 cowlib 1.3.0,和 cowboy 1 使用的 cowlib 1.0 衝突,因此無法使用。
因為 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) endend
很明顯有些地方可以改善:
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) endend
: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) endenddefp normalize_headers(headers) do Enum.map(headers, fn {k, v} -> {String.downcase(k), v} end)enddefp stream_body(conn, client) do case :hackney.stream_body(client) do {:ok, body} -> {:ok, conn} = chunk(conn, body) stream_body(conn, client) :done -> conn endend
hackney 加上 async
選項後,可以用 receive
來一步步的接收到 status, headers 和 body,但實際上使用會碰到許多問題(#224, #267),因此作罷。
看來 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);
在改版完成的一個月後,我就回老家種田了,就和朋友一起去極上爆音體驗震撼人心(物理)的ガルパン+聖地巡禮了,旅遊真他媽爽啊!
在開始之前,先稍微解釋一下何謂 Ansible 的 inventory,inventory 即代表伺服器,在 Ansible 中,可把伺服器列在 inventory file 中,藉此來分類伺服器,例如:
[webservers]1.2.3.4 ansible_ssh_user=john5.6.7.8 ansible_ssh_user=john[dbservers]9.10.11.12 ansible_ssh_user=mary
然而伺服器一多,管理 inventory file 就顯得有些麻煩,這時可以利用 dynamic inventory,只要把 inventory file 指定為可執行檔,Ansible 就能從執行檔的輸出中取得 inventory 資料。
Ansible 官方提供了各種主流主機商的 dynamic inventory,可以直接取用,而 GCE 當然也沒有缺席:http://docs.ansible.com/ansible/guide_gce.html。
為了要從 Google API 動態取得伺服器資料,必須申請一個服務帳戶,在申請之前,請先確認 Google Compute Engine 的 API 權限是否已經開啟。
接著,申請一個服務帳戶,並順便建立私密金鑰。雖然 Ansible 官方教學使用 P12 金鑰,但其實 P12 金鑰已經棄用了,建議改用 JSON 金鑰,還可以省去金鑰解密的步驟。
準備好服務帳戶和 JSON 金鑰之後,在 https://github.com/ansible/ansible/tree/devel/contrib/inventory 下載 gce.ini
和 gce.py
兩個檔案,並放到 inventory
資料夾裡,別忘了要在 gce.py
加上執行權限。
chmod +x inventory/gce.py
安裝 libcloud,因為 libcloud 0.20.0 有個 bug 會使得認證失敗,所以必須安裝小於 0.20 的版本。
pip install "apache-libcloud<0.20"
接著修改 gce.ini
的設定,在檔案底部可以看到這三行:
gce_service_account_email_address =gce_service_account_pem_file_path =gce_project_id =
gce_service_account_email_address
- 服務帳戶的 Email 位址gce_service_account_pem_file_path
- 金鑰路徑(雖然名稱是 pem_file
,但是也可使用 JSON)gce_project_id
- 專案 ID修改完成後應該會像這樣:
gce_service_account_email_address = deploy@dcard-staging.iam.gserviceaccount.comgce_service_account_pem_file_path = credentials/dcard-staging-d6a7cf380e10.jsongce_project_id = dcard-staging
可以執行 gce.py
看看是否正確設定:
inventory/gce.py --list
並確認 Ansible 可以透過 SSH 存取伺服器:
ansible -i inventory/gce.py -m setup all
如果無法存取的話,可以試著透過 Google 官方提供的 gcloud 工具設定或是把 public key 新增到中繼資料頁面。
gce.py
預設提供了主機位置、規格、作業系統等分類,但實際上不太夠用,可在 Google Developer Console 中新增標籤,這樣 Ansible 就能透過標籤篩選伺服器。
例如在伺服器加上 mq-server
和 staging
兩個標籤:
就能在 playbook 中使用 tag_mq-server
和 tag_staging
。
整合到 CI 聽起來很簡單,但實際上有些坑,因為 CI 的環境比較難 debug,Travis CI 又沒提供 SSH session 的功能,在 debug 時只能不斷 push 然後期待數分鐘後能收到好的結果,我一度想要放棄 Travis CI 轉換到比較容易使用的 Semaphore,最後還是花了幾天終於試出正確設定。
第一個問題就是 Ansible 根本連跑都跑不起來,因為 gce.py
沒辦法抓到 pycrypto,紅色的那行字可能會讓你以為是權限問題,並試著照它說的用 chmod -x
,然而這樣只會在 chmod +x
和 chmod -x
之間不斷切換而已,這錯誤訊息根本標錯重點了吧。
我試了很多不同的方法來裝 pycrypto,然而並沒有什麼卵用。
sudo apt-get install python-devsudo easy_install pycryptosudo pip install pycrypto
最後發現根本不是 pycrypto 有沒有安裝的問題,因為 pycrypto 本來就裝在系統中了,解法意外的簡單,只要使用 sudo 權限執行 ansible 即可。
sudo ansible-playbook -i inventory/gce.py api.yml
解決了 pycrypto 的問題之後,以為這樣就能夠順利部署了,然而卻出現了 SSH 連線失敗的問題。
我反覆檢查了好幾次 SSH key,也確認 GCE 的設定無誤,但就是無法連線,最後又花了一天才找出原因,gcloud 在建立 SSH 連線時會自動幫目前的使用者建立新帳戶,所以每個使用者可能會有不同的 SSH key,而我們現在再來重溫一次 SSH key 設定頁面吧。
注意到前面的「使用者名稱」欄位了嗎?這就是使用者對應的 SSH key,一旦知道這個奇怪的坑之後,就非常容易解決了,只要在 Ansible 執行時加上 -u
參數指定使用者即可:
sudo ansible-playbook -i inventory/gce.py -u SkyArrow api.yml
最近偶然在 PlayStation Store 上找到《奧丁領域》,原本並沒有購買這部遊戲的計畫,不過剛好有點閒錢於是就爽快的買下來了,一玩之後樂不釋手,最近正遊玩到妖精女王的篇章。
]]>DocSearch 是 Algolia 最近提供的一個免費搜尋引擎服務,只要登錄網站,他們就會爬好整個網站,使用者只要把 JavaScript 腳本貼到適當的位置上即可使用,和 Swiftype 差不多,只是搜尋結果更好,而且速度更快。
這篇文章可能看起來很像業配文,不過我真的沒收這家公司的錢啦 XD。
在我登錄網站後,客服不到一小時就寄信跟我聯絡了,確認了一些事情後很快的就幫我設定好搜尋引擎,我只要把 JavaScript 腳本貼上即可使用,之後的各種要求也很快就回覆了,最後我得以在 12/31 前把所有東西都處理完成。
各位可以在 hexo.io 看到最後的成果,搜尋速度很快,就算是中文搜尋精準度也相當不錯,然而目前的缺點就是所有的設定都必須經過客服,我想可能要等一段時間才會解決吧。
]]>好久不見,在 資服創新競賽 結束後,我耍廢了好一段時間,更準確來說,從校內專題比賽結束後就開始耍廢了 XD,Hexo 的開發停滯這麼久真是不好意思 てへぺろ(・ω<)。
我在暑假時開始進入 Dcard,利用 React + Redux + React Router 寫了一個和現行網站完全分離的行動版網站,那時候 Redux 的文件還非常不齊全,很多東西都得自己摸索,不過拜 @tomchentw 所賜,解決了很多架構上的問題。
然而在我跑去寫 V2 API 的這兩個月,Redux 竟然從 1.0.0 升級到 3.0.4 了!React Router 也終於推出 1.0.0 了!原本以為新版 Admin panel 也能沿用相同的配置,沒想到還是有一些部分得推倒重來,不禁令人感嘆前端變化之快。
先說說 Redux 究竟有什麼差別吧,其實 Redux 本身的修改倒還好,只要看 Changelog 就 OK 的程度,但是它周邊的東西就麻煩了。react-redux 從 0.2.1 跳到 4.0.0,此外還多了一堆伙伴,Redux 生態圈的形成也他媽的太快了吧!
react-redux 本身的 API 很簡單,但是 Changelog 卻能出現好幾次的 Breaking Change,這東西是有這麼容易 Break 逆!
主要的差別在於 connect
function 變得更好用了,過去只能用來綁定 props,現在也能綁定 action,如此一來就能省去 bindActionCreators
的過程了。
@connect(state => ({ // props}), { // actions})
另一點則是 Provider
class,原本的寫法實在不怎麼優雅,this.props.children
傳入的是一個函數,而現在可以直接傳入 React element。
React.render( <Provider store={store}> <App/> </Provider>, document.getElementById('root'));
redux-router 是用來整合 Redux 和 React Router 的,他做的事情很簡單,就是把 Router 的資料存在 Redux store 裡,但是實際上用起來卻有不少問題,所以我先說結論:
改用 redux-simple-router,不然就不要把 Router 狀態存在 Redux store 裡。
你一定會很好奇我怎麼會去推薦一個版號連 0.1 都不到的超新星套件,但是 redux-router 這貨真的很坑,為了你的肝指數著想,請等到 1.0.0 正式版推出後再考慮它吧。
說了這麼多,這東西究竟有什麼問題呢?咱們來一一列舉吧:
getRoutes
參數半壞,你如果想把 Redux store 傳進 routes 裡的話,自己實作比較保險。接著比較實際的程式碼吧:
import React from 'react';import {render} from 'react-dom';import {createStore, compose} from 'redux';import {ReduxRouter, reduxReactRouter} from 'redux-router';import {createHistory} from 'history';const store = compose( middlewares, reduxReactRouter({ routes, createHistory }))(createStore)(rootReducer, initialState);render( <Provider store={store}> <ReduxRouter/> </Provider>, document.getElementById('root'));
import React from 'react';import {render} from 'react-dom';import {createStore, compose} from 'redux';import {createHistory} from 'history';import {syncReduxAndRouter} from 'redux-simple-router';import {Router} from 'react-router';const history = createHistory();const store = compose( middlewares)(createStore)(rootReducer, initialState);syncReduxAndRouter(history, store);render( <Provider store={store}> <Router routes={routes} history={history}/> </Provider>, document.getElementById('root'));
redux-router 需要在 createStore 時就加入 reduxReactRouter
middleware,而 redux-simple-router 則是在 store 創建完成後才同步 router 狀態;redux-router render 時使用 ReduxRouter
元件,而 redux-simple-router 直接使用 React Router 的 Router
元件。由於後者非常簡單,也減少了錯誤發生的機率。
這東西真的很炫,它可以在頁面中顯示側欄來顯示 action 的動態,還可以復原某些 action 對 store 的變更,使用起來非常簡單,讓我愛不釋手。
import React from 'react';import {render} from 'react-dom';import {createStore, compose} from 'redux';import {persistState} from 'redux-devtools';import {createDevTools} from 'redux-devtools';import LogMonitor from 'redux-devtools-log-monitor';import DockMonitor from 'redux-devtools-dock-monitor';const DevTools = createDevTools( <DockMonitor toggleVisibilityKey='H' changePositionKey='Q'> <LogMonitor/> </DockMonitor>);const store = compose( DevTools.instrument(), persistState( window.location.href.match( /[?&]debug_session=([^&]+)\b/ ) ))(createStore)(rootReducer, initialState);render( <Provider store={store}> <DevTools/> </Provider>, document.getElementById('root'));
React Router 從 0.13 到 1.0 的差別非常大:
Link
元件能從 named route 自動產生完整的路徑,現在必須自己來willTransitionTo
=> onEnter
, willTransitionFrom
=> onLeave
我從暑假時因為 React context 的關係就開始用 React 0.14 + React Router 1.0 beta 了,所以轉換起來比較無痛,但是 1.0 beta 和正式版還是有些 API 差別的,因為一言難盡,還是看 Changelog 吧。
原本我使用的是 React Hot Loader,這種方法其實用起來也沒什麼大問題,只是得另外開個 webpack-dev-server。不過原作者決定廢棄 React Hot Loader,又另外開了個 React Transform,所以我也只好轉移到 React Transform 了。相較上面幾個大改變來說,這只是編譯過程的改變而已,只需要改 Webpack 和 Babel 的配置即可。
首先把 webpack-dev-server 相關的東西都移除掉,安裝:
npm install babel-plugin-react-transform --save-devnpm install react-transform-catch-errors --save-devnpm install react-transform-hmr --save-devnpm install redbox-react --save-devnpm install webpack-dev-middleware --save-devnpm install webpack-hot-middleware --save-dev
以下程式碼使用的是 Express,如果你用的是 Koa 的話,請把 webpack-dev-middleware
改成 koa-webpack-dev-middleware
,webpack-hot-middleware
改成 koa-webpack-hot-middleware
,這兩個 middleware 的使用方式會有些差異,不過大致上是差不多的。
import webpack from 'webpack';import webpackDevMiddleware from 'webpack-dev-middleware';import webpackHotMiddleware from 'webpack-hot-middleware';import webpackConfig from './config';const compiler = webpack(webpackConfig);app.use(webpackDevMiddleware(compiler, { noInfo: true, publicPath: webpackConfig.output.publicPath}));app.use(webpackHotMiddleware(compiler));
import webpack from 'webpack';export default { entry: { main: [ 'webpack-hot-middleware/client', './src/main' ] }, module: { loaders: [ { test: /\.jsx?$/, loaders: ['babel'], exclude: /node_modules/, query: { plugins: ['react-transform'], extra: { 'react-transform': [ { transform: 'react-transform-hmr', imports: ['react'], locals: ['module'] }, { transform: 'react-transform-catch-errors', imports: ['react', 'redbox-react'] } ] } } } ] }, plugins: [ new webpack.HotModuleReplacementPlugin(), new webpack.NoErrorsPlugin(), new webpack.optimize.DedupePlugin(), new webpack.optimize.OccurenceOrderPlugin() ]};
Babel 在上個月也從 5 升級到 6 了,但現在還有很多問題:
import *
的行為和過去不同,這有可能是上一點的問題如果你現在 Babel 5 用得好好的,那就別折騰了。
你可以在 tommy351/redux-example 看到完整的 Universal Redux example。
前端的變化這麼快實在太可怕了,明年說不定又有新玩意了,我還是回去寫後端壓壓驚吧。
題圖是本季動畫「緋弾のアリアAA」,我原本以為這是和本傳差不多的動畫,沒想到一看就讓我震驚了,花了兩天把原作漫畫讀完,在某些方面來說,這的確和本傳差不多:
目前動畫除了有些設定變更和大☆崩★壞的第五話以外,日常場景都挺不錯的,看來動畫工房的百合日常比較穩定。
]]>最近在寫專題時,嘗試了許多新東西,例如改用 Go 來寫 API Server,還試了最近很潮的 Isomorphic JavaScript,如果未來有時間的話可能會針對這個主題再另外寫一篇文章,今天先寫最近遇到的一個怪問題。
各位可能有聽過 Fetch 這個新一代的標準,它簡化了瀏覽器麻煩的 XMLHttpRequest API,而且支援 Promise。
在開發時,Fetch 運行順利,然而上線後卻會發生 Illegal Invocation 錯誤,更弔詭的是,這種錯誤只在 Chrome 上發生。(我沒有在 Safari 或 Opera 上試過)
我把原始碼都翻過了遍,上網搜尋關於 Illegal Invocation 也大都是關於 jQuery 的解答,在苦心搜尋幾小時就快放棄時,終於在 GitHub 上解答:matthew-andrews/isomorphic-fetch#20
解決方式非常簡單,就是把原本的:
import fetch from 'isomorphic-fetch';
改成:
import fetch_ from 'isomorphic-fetch';const fetch = fetch_.bind(this);
只要把 import 進來的 fetch
綁定 this
,就能解決這個詭異的問題了。
題圖是本季新番「Show By Rock」,雖然一開始的展開很腦殘,不過之後的劇情卻非常有趣,重點是,每個角色都好可愛啊!
]]>長久以來,Hexo 官網都是由我手動在本機產生靜態檔案後,再 push 到 GitHub 上。這種方式對於簡單的網誌來說或許很輕鬆,但是對於偶爾會有 Pull Request 的專案來說就比較麻煩了。
在合併了 Pull Request 後,我必須自行把最新的 commit 拉到本機後再手動部署,有時比較忙就會擺爛,因此你會發現,雖然 Pull Request 已經被合併了,Hexo 網站本身卻仍未更新的情況。
在開始之前,請先申請 Travis CI 帳號,把你的 GitHub repo 新增到 Travis CI 上,如果還沒建立 .travis.yml
的話,請先製作一個新的 .travis.yml
。
因此,我花了一個晚上嘗試出了透過免費的 Travis CI 服務自動佈署的方法,首先你必須用 ssh-keygen
製作一個 SSH Key,供 GitHub 當作 Deploy key 使用。
ssh-keygen -t rsa -C "your_email@example.com"
在製作 SSH key 時,請把 passphrase 留空,因為在 Travis 上輸入密碼很麻煩,我目前還找不到比較簡便的方式,如果各位知道的話歡迎提供給我。
當 SSH key 製作完成後,複製 Public key 到 GitHub 上的 Deploy key 欄位,如下:
首先,安裝 Travis 的命令列工具:
gem install travis
在安裝完畢後,透過命令列工具登入到 Travis:
travis login --auto
如此一來,我們就能透過 Travis 提供的命令列工具加密剛剛所製作的 Private key,並把它上傳到 Travis 上供日後使用。
假設 Private key 的檔案名稱為 ssh_key
, Travis 會加密並產生 ssh_key.enc
,並自動在 .travis.yml
的 before_install
欄位中,自動插入解密指令。
travis encrypt-file ssh_key --add
正常來說 Travis 會自動解析目前的 repo 並把 Private key 上傳到相對應的 repo,但有時可能會秀逗,這時你必須在指令後加上 -r
選項來指定 repo 名稱,例如:
travis encrypt-file ssh_key --add -r hexojs/site
把剛剛製作的 ssh_key.enc
移至 .travis/ssh_key.enc
,並在 .travis
資料夾中建立 ssh_config
檔案,指定 Travis 上的 SSH 設定。
Host github.comUser gitStrictHostKeyChecking noIdentityFile ~/.ssh/id_rsaIdentitiesOnly yes
因為剛剛修改了 ssh_key.enc
的位址,所以我們要順帶修改剛剛 Travis 在 .travis.yml
幫我們插入的那條解密指令。請注意,不要照抄這段指令,每個人的環境變數都不一樣。
openssl aes-256-cbc -K $encrypted_06b8e90ac19b_key -iv $encrypted_06b8e90ac19b_iv -in .travis/ssh_key.enc -out ~/.ssh/id_rsa -d
這條指令會利用 openssl 解密 Private key,並把解密後的檔案存放在 ~/.ssh/id_rsa
,接著指定這個檔案的權限:
chmod 600 ~/.ssh/id_rsa
然後,把 Private key 加入到系統中:
eval $(ssh-agent)ssh-add ~/.ssh/id_rsa
記得剛剛我們製作的 ssh_config
檔案嗎?別忘了把他複製到 ~/.ssh
資料夾:
cp .travis/ssh_config ~/.ssh/config
為了讓 git
操作能順利進行,我們必須先設定 git
的使用者資訊:
git config --global user.name "Tommy Chen"git config --global user.email tommy351@gmail.com
最後的結果可能如下,如果你和我一樣使用 Hexo 的話可以參考看看:
language: node_jsnode_js: - "0.10"before_install: # Decrypt the private key - openssl aes-256-cbc -K $encrypted_06b8e90ac19b_key -iv $encrypted_06b8e90ac19b_iv -in .travis/ssh_key.enc -out ~/.ssh/id_rsa -d # Set the permission of the key - chmod 600 ~/.ssh/id_rsa # Start SSH agent - eval $(ssh-agent) # Add the private key to the system - ssh-add ~/.ssh/id_rsa # Copy SSH config - cp .travis/ssh_config ~/.ssh/config # Set Git config - git config --global user.name "Tommy Chen" - git config --global user.email tommy351@gmail.com # Install Hexo - npm install hexo@beta -g # Clone the repository - git clone https://github.com/hexojs/hexojs.github.io .deployscript: - hexo generate - hexo deploybranches: only: - master
已經好久沒更新網誌了,這次的題圖維持傳統,和本文完全沒有任何關係,而是這季一部名為「ユリ熊嵐」的動畫,從標題完全看不出來在演啥小,就算看了內容也不懂!不過這動畫有種神奇的魔力會讓人想看下去呢,真不可思議。
]]>最近在公司負責 Dashboard 的開發,除了從資料庫挖資料以外,還得想盡辦法從其他來源找到更多的資料,而其中一個資料來源就是 Google Analytics。
我過去都是在瀏覽器上使用 OAuth,這次花了一個下午才研究出如何在伺服器的 OAuth 用法,途中碰壁了好幾次,以下我將從頭到尾介紹如何接上 Google API,全文將以 Node.js 示範,其中原理可運用在其他程式語言,或 Google 其他服務的 API。
首先,在 Google Developer Console 建立專案,並開啟「Analytics API」的存取權,到此為止都還是小菜一碟,跟不上的可以洗洗睡了,接下來才是正題。
為了獲得 API 的存取權,我們必須申請一個新的用戶端 ID。請進入側邊欄中的「API 和驗證」→「憑證」頁面,你將可以看到一個預先建立的「Compute Engine 和 App Engine」的用戶端 ID,那個一點屁用都沒有,別管它。
點選橘色按鈕「建立新的用戶端 ID」後,會跳出一個視窗,請選擇「服務帳戶」,並按下藍色按鈕「建立用戶端 ID」。
經過數秒後,新的用戶端 ID 便建立完成,同時會跳出一個 JSON 檔案的下載視窗,該檔案是用來存放 Private key 的,非常重要,請妥善保存。
服務帳戶到此為止便建立完成,你可在頁面下方看到熱騰騰剛建好的服務帳戶,你現在可以關掉這個頁面了,因為稍後的操作都會圍繞在剛剛下載的 JSON 檔案;如果你不小心遺失了 Private key,可以使用「Generate new JSON key」按鈕建立一組新的 Public/Private key。
Google API 採用 OAuth 2.0 認證,然而認證方式與我們平常所熟悉的方式不同,現在許多網站上都會有「以 Google 帳號登入」的按鈕,依照簡易的操作步驟即可認證;不過在伺服器上可沒有按鈕讓你按,於是我們必須透過剛剛申請的服務帳戶進行認證。
上圖來自 Google Developers,完美解釋了接下來的流程,首先,我們必須建立 JWT(JSON Web Token),並使用 JWT 向 Google 索取 Token,之後方可存取 Google API。
一個完整的 JWT 應具備以下內容:
{Base64url encoded header}.{Base64url encoded claim set}.{Base64url encoded signature}
第一部分是 Header,此部份用來指示 JWT 所使用的演算法及類型,在存取 Google API 時,我們使用 RSA SHA256 演算法,因此內容為:
{ "alg": "RS256", "typ": "JWT"}
第二部分是 Claim set,此部份是 JWT 的主要資料部分,內容如下:
{ "iss": "761326798069-r5mljlln1rd4lrbhg75efgigp36m78j5@developer.gserviceaccount.com", "scope": "https://www.googleapis.com/auth/analytics.readonly", "aud": "https://accounts.google.com/o/oauth2/token", "iat": 1328550785, "exp": 1328554385}
名稱 | 說明 |
---|---|
iss | Client Email,即為剛剛下載的 JSON private key 中的 client_email 欄位。 |
scope | 應用程式所請求的資料範圍,因為我們需要取得 Google Analytics 的資料,因此為 https://www.googleapis.com/auth/analytics.readonly 。 |
aud | 認證目標。在此為 https://accounts.google.com/o/oauth2/token 。 |
iat | 此請求的發起時間(秒)。 |
exp | Token 的過期時間(秒),最大時間為 iat (發起時間)的 1 小時後。 |
在介紹第三部分之前,請先將前兩部分的資料以 Base64 方式編碼,並以 .
串接。如果你不知道怎麼在 Node.js 內進行 Base64 編碼,可參考以下程式碼:
new Buffer(str).toString('base64');
第三部分是 Signature,即是將前兩部分的編碼字串以 Private key 加密後的結果,你可從剛剛下載的 JSON private key 中的 private_key
欄位取得 Private key,並參考以下的程式碼取得加密字串。
var crypto = require('crypto');crypto.createSign('sha256').update(jwt).sign(privateKey, 'base64');
完成後,以 .
串接所有部分就是一個正確的 JWT 了。接著請使用 JWT 向 Google 索取 Token,POST 到 https://accounts.google.com/o/oauth2/token
,並加上以下資料:
名稱 | 說明 |
---|---|
grant_type | 認證類型。在這裡為 urn:ietf:params:oauth:grant-type:jwt-bearer ,別忘了 URL 編碼。 |
assertion | 就是剛剛建立的 JWT。 |
以下使用 request 為例:
var request = require('request'), querystring = require('querystring');request.post('https://accounts.google.com/o/oauth2/token', { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: querystring.stringify({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: jwt }), encoding: 'utf8'}, function(err, res, body){ // ...});
若所有資料正確無誤的話,應該可得到以下回應:
{ "access_token" : "1/8xbJqaOZXSUZbHLl5EOtu1pxz3fmmetKx9W8CV4t79M", "token_type" : "Bearer", "expires_in" : 3600}
其中 access_token
就是我們需要的 Token,而 expires_in
代表這個 Token 的有效時間(秒);簡而言之,這個 Token 的有效時間只有 1 小時(3600 秒)。
一旦取得 Token 後,一切都變得簡單了,但是別忘了把閱覽權限開放給服務帳戶的 Client Email,否則將無法透過 API 存取資料。
此外,還必須取得「資源數據編號」,別搞錯,這個可不是追蹤編號喔!完成後,使用以下方式即可取得資料。
Authorization: Bearer {oauth2-token}GET https://www.googleapis.com/analytics/v3/data/ga ?ids=ga:{id} &start-date=2008-10-01 &end-date=2008-10-31 &metrics=ga:sessions,ga:bounces
這篇文章最難的部份大概就是如何產生出 JWT,其他部分就只是向 Google 請求資料而已,沒什麼好解釋的,所以我接下來就寫些廢話吧。
這季有一部雖然看起來很普通,而且連名稱也有「普通」這詞的動畫《普通の女子校生が【ろこどる】やってみた》,我原本以為這只是一部普通的地區推銷動畫,沒想到女二卻是一名サイコレズ!如果各位喜歡百合的話,請務必要看看這部動畫!
週末時我花了一小時的時間把筆電升級到 Yosemite Beta,接下來我大概會寫一篇文章講述關於此系統的心得;7 月 22 日起,.moe 網域開放一般註冊,於是我買了 maji.moe,未來大概會開放子網域的申請服務,目前正在研究如何利用 Go 語言架站。
]]>