2017年2月26日 星期日

Koa 2 起手式!

Standard


在 Node.js 的世界裡,說到今天最潮的 Web Framework,肯定就是 Koa!其用了最新的 JavaScript 語法和特性,來改善 Web Framework 的設計。只不過,Koa 雖然相對於其他舊的 Web Framework 來說有相當多的進步,但很多人卻相當討厭 Koa 的 Generator 設計,尤其是那些「*」符號,那不知所謂的 yield 也讓很多人不舒服。所以至今仍然有許多人在使用 express 來當作自己的 Web Framework,寧可繼續使用那老派的 callback 設計,而不肯嘗試 Koa。

隨著 ECMAScript 標準的進步,Koa 才剛被開發出來沒多久,原本的開發團隊就立即著手打造 Koa 2 ,開始更進一步採用更新的 JavaScript 特性,以 async/await 語法重新打造了全新且更簡潔的框架。可惜的是,由於 async/await 語法一直遲遲沒有被 JavaScript 引擎原生支援,因此總需要靠 babel 編譯打包程式碼後,才能正常跑在 Node.js 之上。這讓 Koa 2 一直無限期處於非穩定版,讓原開發者從開發的一開始,就打算等到 V8 和 Node.js 開始原生支援 async/await 後,才會被以穩定版(stable)的姿態釋出。

所以,即使 Koa 2 到了今天已經相當穩定,也開始有不少人使用在自己的線上服務,卻一直無限期處於非穩定版的狀態。

另外,由於 Koa 2 大量使用 Async/Await,如果你還對 Async/Await 的使用還不熟悉,建議在閱讀本文之前,先閱讀舊文「JavaScript 好用的 async 異步函數!」來學習如何使用。

學習 Koa 的好時機來囉


總算,日前 Node.js v7.6.0 釋出後已經正式宣布原生支援了 async/await 語法,而且不需要額外的參數選項。伴隨著這個消息,Koa 2.0 也隨即正式釋出了!

Node.js 內建支援 ES7 的 async/await 真的是非常棒的消息!過去我們使用 async/await,都還需要 babel 的協助才能正常跑在舊版的 Node.js,不但開發上相當麻煩,非原生的各種 ES7 特性也浪費不少額外的記憶體和效能,這樣的問題在斤斤計較效能的 Server 環境下,更是讓人頭痛。

如今 Node.js 的原生支援,讓我們已經不需要再擔心種種問題,讓我們可以得到簡潔的程式碼和兼顧效能,現在就是準備轉換到 Koa 2 的最好時機!:-)

安裝 Koa 2


現在,我們終於可以直接使用 NPM 命令安裝 Koa 2:

npm install koa

開發第一個應用程式


如果你有開發過 Koa 或 Express 的網站應用程式,Koa 2 的寫法其實相當雷同,差別是 Express 使用的是普通函數當 callback、Koa 是使用 Generator Function,而 Koa 2 是使用 Async Function。

一個簡單的範例如下:

const Koa = require('koa');

const app = new Koa();

app.use(async function(ctx) {
    ctx.body = 'Hello World';
});

app.listen(3001);

當 ctx.body 被設定一個內容後,連線就會回傳該內容回瀏覽器。在這範例中,無論發什麼要求給 Server ,都會得到「Hello World」的回傳。

註:如果你使用過 Koa,會發現 Koa 2 已經不再使用 this 關鍵字,而是改成一個 context 物件代入到函數之中。

使用異步函數打造的 Middleware


koa.use() 將用來載入 Middleware,所有連線工作都會經過 Middleware 處理。這也是為什麼,前一個例子裡,我們使用 koa.use() 設定了一個處理函數後,所有連線透會通過該函數進行處理並回傳同樣的值。

要注意的是,該函數是一個「異步函數(Async Function)」,要用到 async 關鍵字來宣告:

app.use(async function() {
    // ...
});

註:如果你有過 express 開發經驗,對於 koa.use() 會相當熟悉,Koa 同樣支援了 Middleware 的架構,你可以將過去的程式輕易移植到這新的框架上。

自訂 Router 和路徑管理


之前的範例直接使用 koa.use(),會將所有的連線都導入同一個處理函數,輸出同一個結果。若我們想要自訂不同的路徑,讓不同路徑用不同的處理函數,將需要額外安裝「koa-router」模組:

npm install koa-router

然後可以用 koa-router 來管理對外的連線路徑:

const Koa = require('koa');
const Router = require('koa-router');

const app = new Koa();
const router = Router();

// 設定根路徑的處理函數
router.get('/', async function(ctx) {
    ctx.body = 'Hello World';
});

app.use(router.routes());

app.listen(3001);

接收 QueryString 資料


QueryString 可說是歷史悠久且非常常見的傳值方法,藉由一個網址後面加上一個「?」字元後,就可以使用鍵值(Key/Value)來進行資料傳遞,並用「&」區隔多組資料。一個簡單的實際應用如下:

http://my_server/send?name=fred&msg=Hello

取得資料的方法如下:

ctx.query.name
ctx.query.msg

接收 body 資料


當我們使用「POST」或「PUT」方法,我們就可以利用 body 傳送一些資料到伺服器,像是網頁表單時常使用這樣的傳值方法。若想要取得 body 的資料,必須先安裝「koa-bodyparser」模組。

截至本文釋出為止,該模組還沒有隨著 Koa 2 推出正式支援的版本,所以預設下載回來的版本還是支援舊的 Koa,所以必須指定版本號「next」:

npm install koa-bodyparser@next

當然,你也可以用「koa-convert」模組將舊的 Koa Middleare 直接轉換給 Koa 2 使用:

npm install koa-convert

使用 koa.use() 載入 koa-bodyparser,koa 就會自動在處理連線時使用它解析 body:

var bodyParser = require('koa-bodyparser');

// 若想使用 koa-convert 進行轉換,要先載入模組:
// const convert = require('koa-convert');
// 再以 convert(bodyParser()) 包裝
app.use(bodyParser());

然後可以在路徑處理函數中,正常取得 body 內的資訊:

ctx.request.body.name
ctx.request.body.msg

錯誤處理


在 Koa 2 中,可以透過 ctx.throw() 進行錯誤處理,並回傳狀態值和內容給客戶端,他會中斷目前的處理函數,實際使用情境如下:


router.get('/api/v1/user', async function(ctx) {

    // 檢查 Token,若有問題回傳 400 HTTP StatusCode
    if (ctx.query.token == '123')
        ctx.throw(400);

    // 若已經拋出 400 的狀態,接下來的程式不會被執行
    ctx.body = 'Hello World';
});

加入多個 Middleware


所有的連線要求可以透過一系列、不只一個 Middleware 來進行處理,我們可以利用多次 koa.use() 來加入多個 Middleware,多個 Middleware 可以用來做到很多功能,例如記錄和顯示每個連線的狀態。

加入多個 Middleware 的範例如下:

const Koa = require('koa');

const app = new Koa();

app.use(async function(ctx, next) {
    // 略過這個 Middleware,讓下一個 Middleware 來接著處理
    await next();
});

app.use(async function(ctx) {
    ctx.body = 'Hello World';
});

app.listen(3001);

加入 Logger 來記錄連線要求


koa-logger 是一個能顯示連線要求狀態的第三方 Middleware,可以先透過 NPM 安裝它:

npm install koa-logger

然後可以直接以 app.use() 引用:

const Koa = require('koa');
const logger = require('koa-logger');

const app = new Koa();

// 加入 logger 在其他的 Middleware 之前
app.use(logger());

app.use(async function(ctx) {
    ctx.body = 'Hello World';
});

app.listen(3001);

然後,你的應用程式就會輸出漂亮的連線要求訊息:

  <-- GET /api/v1/hello
  --> GET /api/v1/hello 200 8,257ms 2b

在 Koa 2 裡使用 Mongoose


異步函數使用 await 關鍵字對 Promise、Thunk 進行等待,使開發者不再需要用到大量的 callback function,讓程式碼比較不會「橫著長大」。所以,只要 Mongoose 可以在做任何工作時,回傳一個 Promise 物件,我們就可以在 Koa 2 中使用 await 等它完成。

還好,Mongoose 有支援這個功能,但我們得使用 .exec() 這個方法來取得 Promise:

router.get('/api/v1/user', async function(ctx) {

    // 利用 exec() 取得 Promise,然後以 await 等待完成
    ctx.body = await Users.find({}).exec();
});

函數宣告的習慣改變


在本文的範例中,仍然還是使用「function()」這樣的函數宣告方式,但很多開發者為了減少程式碼,大量改用 Arrow Function(箭頭函數)來宣告函數,所以你會大量看到這樣的情況:

router.get('/api/v1/user', async (ctx) => {
    // ...
});

因為多數情況下,改用 Arrow Function 來宣告是沒有問題的,所以很多懶惰的開發者都會這樣使用。但建議你,如果有空時,還是要了解這種函數與普通函數宣告的差別。

後記


這篇文章其實卡很久了,一直遲疑著要不要在正式穩定版之前公開,剛好趁著這幾天 Node.js v7.6.0 和 Koa 2 正式版釋出,所有的顧慮就沒有啦。:-D

2017年2月3日 星期五

Node.js 也可以使用 Protocol Buffers!

Standard

Protocol Buffers (protobuf)」是一套 Google 所提出的結構化資料的包裝技術,讓資料便於網路傳輸或交換,如同常見的 JSON 和 XML 等技術一般。但相對於其他常見技術,protobuf 設計上更易於用來包裝二進位資料,應用在串流(Streaming)技術上,在資料包裝上也更為節省空間,在包裝或解析上也更有效率。

註一:若採用 JSON,由於原本的設計上並無法處理二進位資料,所以如果要包裝二進位資料,傳統做法會將資料轉換成 base64 的格式,再以字串(String)的格式儲存。因為這等於二次包裝資料,導致處理上非常沒有效率。

註二:與 Google Protocol Buffers 類似的技術還有 MessagePack 及 Facebook 採用的 Apache Thrift,有興趣的人可以自行參考比較。

跨語言的優點


另外,Protocol Buffers 最大的優點,就是擁有跨程式語言的設計,提供了一個標準通用的 .proto 定義方法,讓我們定義資料結構和格式。只需要載入這些我們事先準備好的資料定義,就可以輕易生成給不同語言(如:C++、C#、Go、Java、Objective-C 或 Python)用的資料解析器、包裝方法,讓我們可以在不同的語言之間,解析或包裝相同的結構資料。

Protocol Buffers 的使用場景?


若在純粹的 Web 應用下,大多數情況,我們不需要處理二進位資料,或是需要非常精準的資料格式,也不會進行單筆高流量的資料交換,所以使用 JSON 或 XML 已經足以。但若你的應用有串流、二進位資料的需求,Protocol Buffers 就是你可以考慮的選擇。

像是筆者在一些公司專案中,會運用 Message Queuing 進行各種訊息資料傳遞,以達成各種資料處理需求。但由於訊息資料內可能有大大小小等各種資料形式和資料型態需求,導致 JSON 包裝已經完全不敷使用,甚至有效能上的疑慮,這時就會採用 Prorocol Buffers 來打包資料。

安裝 ProtoBuf.js


Google 官方其實並沒有實作 JavaScript 版本的 Protocol Buffers 支援,但還好廣大的社群已經有高手開發出 JavaScript 的模組「ProtoBuf.js」,除了在 Node.js 上可以使用以外,甚至可以在瀏覽器中使用

所以,如果想在 Node.js 裡使用,可以直接透過 NPM 安裝模組:

npm install protobufjs

補註:Protocol Buffers v3.0.0 beta 2 開始官方支援 JavaScript,未來有機會轉用官方的版本。

使用 .proto 定義自己的資料格式


開始使用 Protocol Buffers 的第一個步驟,就是建立一個 .proto 檔來描述定義一個自己的資料格式相當簡單,一個簡單的定義如下。

Product.proto 內容:

package Ecommerce;

message Product {
    bool available = 1; // 是否上架(布林值)
    string name = 2;    // 產品名稱(字串)
    string desc = 3;    // 產品說明(字串)
    float price = 4;    // 價格(浮點數)
}

實際上 Protocol Buffers 支援了更多資料格式,有興趣的人可以自行參考官方所整理的表格:「Scalar Value Types」。

使用我們定義的 .proto 來包裝資料


若要包裝資料,要先載入 .proto 檔案裡的資料定義,然後使用此定義去進行接下來的工作,而 ProtoBuf.js 提供了一個 encode 方法來進行資料包裝。

由於經過 Protocol Buffers 包裝後的資料是二進位格式,所以 ProtoBuf.js 提供了 finish 方法輸出成 Node.js 的 Buffer 格式:

var ProtoBuf = require('protobufjs');

// 載入 Product.proto 檔案
ProtoBuf.load('Product.proto', function(err, root) {
    if (err)
        throw err;

    // 並取得 Product 資料定義
    var Product = root.lookup('Ecommerce.Product');
    
    // 準備包裝的資料
    var data = {
        available: true,
        name: 'ApplePen',
        desc: 'The combination of Apple and Pen',
        price: 100.0
    };
    
    // 包裝資料後回傳 Buffer 格式(二進位形態)
    var msgBuffer = Product.encode(data).finish();
});

解開已包裝的資料


若我們有一個已包裝過的資料(無論是從哪裡收到的資料),可以直接使用 decode 方法去解開它:

var ProtoBuf = require('protobufjs');

// 載入 Product.proto 檔案
ProtoBuf.load('Product.proto', function(err, root) {
    if (err)
        throw err;

    // 並取得 Product 資料定義
    var Product = root.lookup('Ecommerce.Product');
    
    // 解開
    var data = Product.decode(msgBuffer);
});

二進位資料形態的欄位


前面提到,Protocol Buffers 可以包裝二進位資料,若我們想要設定某個欄位為二進位的資料,可以將其資料型態設為「bytes」:

package MyTest;

message Example {
    bytes binData = 1; 
}

然後,當我們在包裝資料時,該欄位應該是一個 Buffer 的物件:

var msgBuffer = Example
    .encode({
        binData: new Buffer('This is binary data')
    })
    .finish();

解開時,該欄位會是一個 Buffer 物件:

var data = Example.decode(msgBuffer);

// 將 Buffer 內容轉成字串形式輸出
console.log(data.binData.toString());

ProtoBuf.js 的效能表現


Protocol Buffers 這類的技術,不外乎就是把一個執行期的 JavaScript 物件,轉換包裝成二進位、字串等資料格式,使資料訊息便於透過網路和其他媒介傳送。實務上,與 JavaScript 物件轉成 JSON 字串是同樣的意思。

所以若要評估這樣技術的效能,最實際的方式就是測試、比較他們的「轉換」的效率,ProtoBuf.js 官方提供了一些「效能測試」,方便我們在自己機器上進行 Protocol Buffers 與原生 JSON 處理的效能比較。

從官方的測試結果來看,從資料包裝的速度,ProtoBuf.js 的效能快過於「JSON.stringify」將近一倍,如果是轉成二進位形式(to Buffer)更是快三倍左右;從解開包裝的速度來看,ProtoBuf.js 效能則是「JSON.parse」的三至四倍效能以上。

整體比較起來,ProtoBuf.js 則是比純 JSON 的處理快上一倍以上。

節錄官方 Github 上的測試結果(機器:i7-2600K。Node.js 版本:6.9.1):

benchmarking encoding performance ...

Type.encode to buffer x 547,361 ops/sec ±0.27% (94 runs sampled)
JSON.stringify to string x 310,848 ops/sec ±0.73% (92 runs sampled)
JSON.stringify to buffer x 173,608 ops/sec ±1.51% (86 runs sampled)

      Type.encode to buffer was fastest
   JSON.stringify to string was 43.5% slower
   JSON.stringify to buffer was 68.7% slower

benchmarking decoding performance ...

Type.decode from buffer x 1,294,378 ops/sec ±0.86% (90 runs sampled)
JSON.parse from string x 291,944 ops/sec ±0.72% (92 runs sampled)
JSON.parse from buffer x 256,325 ops/sec ±1.50% (90 runs sampled)

    Type.decode from buffer was fastest
     JSON.parse from string was 77.4% slower
     JSON.parse from buffer was 80.3% slower

benchmarking combined performance ...

Type to/from buffer x 254,126 ops/sec ±1.13% (91 runs sampled)
JSON to/from string x 122,896 ops/sec ±1.29% (90 runs sampled)
JSON to/from buffer x 88,005 ops/sec ±0.87% (89 runs sampled)

        Type to/from buffer was fastest
        JSON to/from string was 51.7% slower
        JSON to/from buffer was 65.3% slower

benchmarking verifying performance ...

Type.verify x 6,246,765 ops/sec ±2.00% (87 runs sampled)

benchmarking message from object performance ...

Type.fromObject x 2,892,973 ops/sec ±0.70% (92 runs sampled)

benchmarking message to object performance ...

Type.toObject x 3,601,738 ops/sec ±0.72% (93 runs sampled)

其他使用場景?


只要你有需要跟其他系統、服務、外部程式進行資料交換,Protocol Buffers 就有他適用的地方。

舉例來說,現在很多人開始採用 WebSocket 取代傳統的 Socket,使得 WebSocket 不再只是應用在瀏覽器之中,甚至可能是各種機器與機器之間的溝通。在這種情況下,其中交換、傳遞的資訊可能不是普通純文字這麼簡單,也很有可能是二進位類型、串流形式的資料,導致 JSON 可能因此不適合用於當作其中的資料交換格式。這時,就可以 Protocol Buffers 與 WebSocket 搭配使用。

不只如此,在這 IoT 當道的年代,在這訊息技術滿天飛的年代 AMQP、MQTT 等各種通訊技術下,以及需要許多爆量資料收集分析的場景,Protocol Buffers 也很有發揮的空間。

後記


要注意的是,Protocol Buffers 雖然是個好東西,但並非是個用來完全取代 JSON 的解決方案,JSON 仍有其可讀性高、易操作及通用性高等優點。在多數 API 設計的場景之下,JSON 仍然是最好的選擇。

2017年1月25日 星期三

上手使用 JavaScript 的 Map、Reduce 吧!

Standard

雖然有些概念類似甚至可以相通,但這裡並不是指常聽到的「MapReduce」,本文目的不是要討論如何運用 MapReduce 這樣的架構去處理大資料庫。這裡真正要討論的是,如何使用 JavaScript 裡陣列(Array)中的 .map() 和 .reduce() 方法,並把一些常見的使用方法和情境描述出來大家進行參考。

很多人對這兩個方法不習慣,原因不外乎是這兩種方法本來就不是一個非常直覺的東西,在大多數 JavaScript 語言的開發情境中,其實也沒有非得使用的理由。但不得不說,習慣了這兩個對陣列操作的方法,程式碼會變得簡潔,也更容易能處理一整批的資料。有時也能順便學習到一些「Functional Programming」會用到的概念,無論是在改善程式品質,還是投資自己的角度上,都有相當好處。

從最簡單的遍歷陣列開始


面對一個陣列裡的一堆資料,我們一定是從遍歷開始,一一處理裡面的每一筆資料。你也許已經非常熟悉如何遍歷陣列,最常見的不外乎就是兩種做法。

使用 for-loop:

var myArr = [ 1, 2, 3 ];

for (var index in myArr) {
    console.log(myArr[index]);
}

使用陣列內建的 forEach 方法:

var myArr = [ 1, 2, 3 ];

myArr.forEach(function(element) {
    console.log(element);
});

使用 .map() 對每個陣列元素加工


有些時候,我們想對每個陣列元素(Element)進行加工處理,於是最土法煉鋼的方法大概就是這樣:

幫每個元素加一:

var myArr = [ 1, 2, 3 ];

for (var index in myArr) {
    myArr[index] = myArr[index] + 1;
}

// [ 2, 3, 4 ]
console.log(myArr);

這時你可以使用 .map() 方法來達成同樣目的:

var myArr = [ 1, 2, 3 ];

var newArr = myArr.map(function(element) {
    return element + 1;
});

// [ 2, 3, 4 ]
console.log(newArr);

.map() 會將每一個元素代入處理函數,而處理函數回傳的值,會被收集組成一個新的陣列,這個新的陣列元素數量會和原本陣列的一樣。換句話說,同樣是對陣列加工後得到結果,它會回傳一個新的、加工過後的陣列,而不會修改原本的陣列內容。

使用 .map() 進行資料校正處理


當我們了解 .map() 的運作原理後,可以使用它做到更多資料處理的事,例如資料的校正或過濾。

舉例來說,若是我們得到一個包含許多數值的陣列,而我們想限定這些數值不得超過我們設定的上限值,這時我們可以這樣處理,來得到一個經過檢查校正過後的資料結果:

var myArr = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ];

var newArr = myArr.map(function(element) {
    // 數值大於五的數值視為五
    if (element > 5)
        return 5;
        
    return element;
});

// [ 1, 2, 3, 4, 5, 5, 5, 5, 5, 5 ]
console.log(newArr);

使用 .reduce() 進行數值加總


處理陣列資料的工作中,其中一項最常見的就是數值加總,或是進行統計運算。同樣的,若你使用土法煉鋼的做法,大致上如下:

var myArr = [ 1, 2, 3 ];
var result = 0;

for (var index in myArr) {
    result += myArr[index];
}

// 6
console.log(result);

若使用 .reduce(),可以這樣寫:

var myArr = [ 1, 2, 3 ];

// 處理每個元素後等待回傳結果,第一次處理時代入初始值 0
var result = myArr.reduce(function(prev, element) {
    // 與之前的數值加總,回傳後代入下一輪的處理
    return prev + element;
}, 0);

// 6
console.log(result);

我們可以看到,改用 .reduce() 之後,陣列元素的加總計算,不會再一直存取到外部的 result 變數,而是算完結果後才將結果統計結果回傳。這樣做的好處,是不會再跨 Scope 去存取外部的變數,這對 JavaScript 這種有複雜 Scope 設計的語言來說,程式碼不會到處去污染。

把 .map() 和 .reduce() 串接起來吧!


這兩種方法都是用來處理陣列,所以我們可以輕易地串接兩者,以前面的例子來說,可以先對陣列資料進行校正和加工,然後對資料進行收斂和加總:

var myArr = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ];

var result = myArr
    .map(function(element) {
        // 數值大於五的數值視為五
        if (element > 5)
            return 5;
            
        return element;
    })
    .reduce(function(prev, element) {
        // 與之前的數值加總,回傳後代入下一輪的處理
        return prev + element;
    }, 0);

// 40
console.log(result);

利用 .reduce() 進行陣列扁平化


如果你開始查 .reduce() 的資料,應該會看到一些 MDN 文件,會提到一些相當實用的功能,其中一個就是扁平化陣列的應用。簡單來說,就是將一個複雜的陣列,扁平化成一維,這在很多資料處理或數值計算上相當有用。

var myArr = [
    [ 1, 2 ],
    [ 3, 4, 5 ],
    [ 6, 7, 8 ]
];

// 將所有元素都與之前代入的陣列相接起來,第一次處理時代入初始值空陣列
var newArr = myArr.reduce(function(arr, element) {
    // ex: [ 1, 2 ].concat([ 3, 4, 5 ])
    return arr.concat(element);
}, []);

// [ 1, 2, 3, 4, 5, 6, 7, 8 ]
console.log(newArr);

所以這個處理函數將會被執行三次:

  1. 將空陣列與 [ 1, 2 ] 相接起來後回傳
  2. 將被代入的 [ 1, 2 ] 與 [ 3, 4, 5 ] 相接起來後回傳
  3. 將被代入的 [ 1, 2, 3, 4, 5 ] 與 [ 6, 7, 8 ] 相接起來後回傳

利用 .reduce() 進行資料歸納和統計吧!


我們也可以利用 .reduce() 配合上物件操作,對陣列的內容進行統計工作:

var myArr = [
    'C/C++',
    'JavaScript',
    'Ruby',
    'Java',
    'Objective-C',
    'JavaScript',
    'PHP'
];

// 計算出每種語言出現過幾次
var langStatistics = myArr.reduce(function(langs, langName) {
    if (langs.hasOwnProperty(langName)) {
        langs[langName]++
    } else {
        langs[langName] = 1;
    }
    
    return langs;
}, {});

// { 'C/C++': 1, 'JavaScript': 2, 'Ruby': 1, 'Java': 1, 'Objective-C': 1, 'PHP': 1 }
console.log(langStatistics);

如果想要處理的資料是 Object 的形式怎麼辦?


運用 Object.keys() 這樣的技巧,我們可以把 .map() 或 .reduce() 結合使用到 Object 的資料上使用,這樣就可以對 Object 資料進行相同的統計運算或數值計算。

var data = {
    'Fred': 1,
    'Leon': 2,
    'Wesley': 3,
    'Chuck': 4,
    'Denny': 5
};

// 使用 Object.keys() 取得包含所有 key 的陣列
var result = Object.keys(data).reduce(function(prev, name) {
    // 利用 key 取得原始物件中的值,然後加總
    return data[name] + prev;
}, 0);

// 15
console.log(result);

你在寫啥?結合 ECMAScript 6 後,世界都不一樣了。


ES6 已經上了實際的戰場,當 .map()/.reduce() 方法加上箭頭函數(Arrow Function
),然後又配合上 JavaScript 語言的特性,整個程式碼將變得更為簡短乾淨。

let newArr = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ].map((value) => value + 1);

當箭頭函數只有一個參數時,可以省去括號「()」:

let newArr = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ].map(value => value + 1);

註:不過,對於不習慣的人來說,更難閱讀了。但在開放原始碼和社群的圈子裡,因為已經被大量使用,所以最好趕快習慣它,會方便你更容易看懂坊間的各種「新」程式碼。

後記


當然,濫用 map/reduce 也可能會造成程式碼難以閱讀,無論是哪一種程式的技巧,這肯定都是一個問題。但至於什麼時候該用,什麼時候不該用,並不在本文範疇,個人認為,我們得先熟練使用這兩種方法,用熟了,再接著探討「好的使用情境」才有意義。因為很多人不熟悉,又不敢亂用,就更沒有機會習慣它了。

所以,先別想太多,嘗試習慣使用它們吧!

2016年12月31日 星期六

Node.js 小密技:以 Readline 核心模組一行行讀取檔案內容

Standard

最近參與了一些關於資料處理的專案,處理了很多各式各樣的原始資料(Raw Data)或各種不同格式的資料,於是使用到了 Node.js 上的一些小技巧。像是一行行讀取檔案內容這件事,就隱藏了一些技巧。

對很多人來說,處理的檔案內容都不大,如果用 Node.js 來一行行讀取檔案內容,不外乎就是將整個檔案讀出後再進行切割,做法大致上如下:
var fs = require('fs');

fs.readFile('example.txt', function(err, data) {

    // 以換行字元作為切割點,將內容切成一個大陣列
    var lines = data.split('\n');

    lines.forEach(function(line) {
        // 一行行處理
    });
});
但有些時候,由於檔案並不小,若又牽涉到運算,不可能整個檔案都讀出到記憶體上才進行切割,這時就得用到 Stream(資料流)機制,將檔案一段段讀出來進行處理。然後,為了進行一行行的切割,我們會自己做這樣的機制,先將一段段讀取出來的檔案內容放到緩衝區(Buffer),然後找到換行字元進行切斷取出,然後再繼續讀取檔案,重複這樣的過程直到檔案結尾。

的確,實做這樣的機制有點麻煩,所以其實能利用 Node.js 現成內建的核心模組 Readline 來做到切割資料流中一行字串的工作。因為常見的 Readline 用法都是拿來做終端機字元模式下的命令列操作,所以許多人沒有想到可以這樣使用 Readline。作法其實很簡單,就把 Readline 的 input 從標準輸入(Standard Input)換成我們的檔案讀取資料流就可以。

完整做法如下:
var fs = require('fs');
var readline = require('readline');

// 建立檔案讀取資料流
var inputStream = fs.createReadStream('example.txt');

// 將讀取資料流導入 Readline 進行處理 
var lineReader = readline.createInterface({ input: inputStream });
lineReader.on('line', function(line) {

    // 取得一行行結果
    console.log('NEW LINE', line);
});

後記

其實這樣的 Readline 用法,在 Node.js 官方 API 文件上可以看到,只不過是不久前才被加進去的,在文件的最後面。:-P

參考連結:https://nodejs.org/api/readline.html

2016年12月28日 星期三

產品開發玩技術很過癮!實作 QML 動畫背景!

Standard

由於最近在開發自己的產品,又開始重操舊業,開發起 Linux 系統的相關應用和嵌入式技術。為了這個產品,精心開發了一個使用者介面,除了動手把驅動程式搞定、圖形化介面搞定,也調教效能、改善系統架構。

開發自己的產品很過癮,愛怎麼搞就怎麼搞!於是,看到死板的背景覺得很不舒服,就在思考是否可以跑個動畫背景呢?

因為使用的是 QML 技術來開發 UI,最直接的想法,就是用 QtMultimedia 的 MediaPlayer 無限循環播放一個影片,當作動畫背景:
MediaPlayer {
    id: bg;
    source: 'bg.mov';
    loops: MediaPlayer.Infinite;
    autoPlay: true;
}

VideoOutput {
    anchors.fill: parent;
    source: bg;
}

當然,我們選擇的背景影片,是一個開頭跟結尾一樣的影片,如果正確循環播放,會無縫接軌的變成一個順暢的動畫背景。

然而,結果不如預期,碰到了一個問題,那就是每當背景影片播放到最後時,會畫面變成全黑,然後才再一次重新開始播放,沒辦法「無縫接軌」。仔細暸解以後,發現 MediaPlayer 元件是 QMediaPlayer 的 QML Type 實作,所有秘密都藏在 QMediaPlayer 之中。因為 QMediaPlayer 預設所有的通知事件,都是固定以 1000ms(1秒)的頻率來觸發,這代表,當 QML 元件發現影片播完時,通常已經是播完以後的事了,所以畫面一定會因為影片結束而變黑,然後 QML 元件才發現影片結束,重新進行播放。

知道緣由後,我們可以從事件更新頻率下手,讓 QML 元件發現影片播完的時間更接近實際影片結束的時間,但這必須動用到 C/C++ 的實作,因為 QMediaPlayer 的事件更新頻率無法以純 QML 的方法修改。

C/C++ 完整應用程式的實作如下,我們把更新頻率調高為每 100ms 一次:
#include <QGuiApplication>
#include <QQmlApplicationEngine>
#include <QQuickWindow>
#include <QMediaObject>
#include <QMediaPlayer>

int main(int argc, char *argv[])
{
    QGuiApplication app(argc, argv);

    QQmlApplicationEngine engine;
    engine.load(QUrl(QStringLiteral("qrc:/resources/App.qml")));

    // Getting background component by using background objectName
    QObject *obj = static_cast<QObject *>(engine.rootObjects().first());
    QObject *background = obj->findChild<QObject *>("background");

    // Set NotifyInterval to 100ms
    QMediaPlayer *player = qvariant_cast<QMediaPlayer *>(background->property("mediaObject"));
    player->setNotifyInterval(100);

    return app.exec();
}

除此之外,為了可以順利取得 QML 中的 MediaPlayer 元件,我們需要幫其設定一個「objectName」作為識別,讓 C/C++ 這的原生程式可以搜尋的到該元件:
MediaPlayer {
    id: bg;
    objectName: 'background';
    source: 'bg.mov';
    loops: MediaPlayer.Infinite;
    autoPlay: true;
}

雖然我們縮短了每次更新的間隔時間,調到了 100ms,已經非常接近了影片結束時間,但仍然可能會發生問題。所以保險起見,我們可以多做些檢查工作,在影片結束前 100ms 左右時,就讓他重頭開始播放一次影片。
MediaPlayer {
    id: bg;
    objectName: 'background';
    source: 'bg.mov';
    loops: MediaPlayer.Infinite;
    autoPlay: true;

    onPositionChanged: {
        if (position >= duration - 100) {
            bg.seek(0);
        }
    }
}

理論上,這樣做會損失不到 100ms 長度的動畫,但人通常感覺不出來這麼短的損失,而且是前方還有 UI 介面的情況之下。但如果你仍然有感,可以考慮把頻率改為 50ms 或更少。

最後就可以享受會動的背景啦!

2016年7月27日 星期三

JavaScript 好用的 async 異步函數!

Standard

先聲明,async 異步函數是 ECMAScript 第七版(ES7)才被支援的語法和特性,目前 ES7 還沒有被大多數的 JavaScript Engine 所實作,如果你要使用,需要用到 babel 這類工具,先把此程式編譯轉換,讓其可在舊版本 JavaScript Engine 上執行。

如果你覺得以 co 模組來操作 Generator 很好用,你可以想像 async 異步函數就是原生的 co,幾乎是同樣的使用方式,同樣的使用概念,只不過不再需要使用 generator 和 yield 這類語法。如果你是個過不了在函數上有個醜陋「*」符號這一關的人,async 異步函數的使用方式應該會讓你感覺到舒服許多。

什麼是 async 異步函數(async functions)?


異步函數使用方式其實和一般的函數一樣,只不過在這函數之內的程式,可以用 await 的語法去執行並等待異步工作(如:Promise)而不需要使用到骯髒的 callback function。宣告並使用一個 async 異步函數,就是在定義函數時加上「async」,然後直接執行這個函數即可,簡單的範例如下:

async function myAsyncFunc() {
    console.log('Hello async functions!');
}

myAsyncFunc();

搭配 Promise 的使用


Promise 通常被大量用來管理非同步的工作,並讓開發者容易管理錯誤拋出等機制,一個典型的 Promise 使用如下:

var task = new Promise(function(resolve, reject) {

    // 執行一個非同步的工作,完成後呼叫帶入的 callback
    doAsyncTask(function(err) {

        // 有問題呼叫 reject,並帶入錯誤值
        if (err)
            return reject(err);

        // 成功呼叫 resolve 並帶入回傳值
        resolve('VALUE');
    });
});

// 使用 then 去執行並等待工作完成,成功會呼叫 callback,失敗則用 catch 去接收。
task
    .then(function(val) {
        console.log(val);
    })
    .catch(function(err) {
        console.log(err);
    });

如果在「異步函數」中呼叫以 Promise 包裝的工作,可以直接使用 await 語法:

async function myAsyncFunc() {
    var val = await task;
    console.log(val);
}

myAsyncFunc();
你會看到在異步函數中,程式邏輯會以「像是阻塞的方式進行」,await 會等到工作完成後,將回傳值回傳,然後才繼續下一行工作。要注意的是,因為看起來像程式會阻塞,熟悉 JavaScript 的人會不自覺開始害怕事件引擎被鎖死,但實際上 await 是以非同步的方式在進行,並不會卡住或影響事件引擎的運作。

搭配 Thunk 的使用


什麼是 Thunk?簡單來說就是一個處理函數,完成時會呼叫 callback 函數表示完成,實務上最常的用法會在外面包一層函數,創造一個 Closure,一個簡單的 Thunk 如下:

function myThunkFunc(thing) {

    return function(done) {

        setTimeout(function() {
            console.log(thing);
            done(null, 'World');
        }, 1000);
    };
}

異步函數裡面,我們可以這樣使用它:
async function myAsyncFunc() {
    var val = await myThunkFunc('Hello');
    console.log(val);
}

myAsyncFunc();

等待其他異步函數完成工作


當然,await 除了可以吃 Thunk 和 Promise 之外,也可以處理並等待其他的「異步函數」,如下:
async function anotherAsyncFunc(thing) {
    var val = await myThunkFunc(thing);
    return val;
}

async function myAsyncFunc() {
    var val = await anotherAsyncFunc('Hello');
    console.log(val);
}

myAsyncFunc();

錯誤處理


當 Promise 的 reject() 被呼叫,或是 Thunk 的 callback 函數被呼叫時,第一個參數不是 null,就代表這個異步工作是有錯誤發生的,如果要從 await 偵測這些錯誤訊息,需要使用 try-catch 去接這些錯誤訊息。

async function myAsyncFunc() {
    try {
        var val = await myThunkFunc('Hello');
    } catch(e) {
        console.log(e);
    }
}

myAsyncFunc();

舒服!到處使用異步函數


一旦你熟悉如何使用異步函數,你可以到處使用。其實他就像一般的函數一樣,他可以被當成一個 callback 來使用,像是下面這個例子,就把它當成 Promise 的處理函數:

var task = new Promise(async function(resolve, reject) {
    try {
        await doAsyncTask();
    } catch(e) {
        return reject(e);
    }

    resolve();
});

後記


如果你是原本就在使用 co 模組的人,應該會發現 async/await 根本就是一樣的東西,對你來說根本無痛,唯一有點麻煩的是,目前 JavaScript 仍然還沒有原生支援,需要 babel 一類的編譯器才能使用。但有不少人看重程式碼的簡潔和漂亮,已經大量使用了。

另外提到,Koa 2.0 因為完全採用 async/await 的方式,無限期處於不穩定版本。等到 async/await 被原生支援那一天, Koa 2.0 穩定版就會推出了,相信這一天就快要到來。

2016年6月6日 星期一

下一代的框架:Koa 1.0 起手式

Standard


身為 Node.js 使用者的你,還在使用 Express 嗎?快來使用下一代的 Web Framework 吧!Koa 是由 Express 的開發者們出來所開發的新網站框架,嘗試採用了最新的 ECMAScript 6 語法,讓開發者可以用更簡約的方式,開發網站應用程式,讓程式碼更好維護之外,也能受益於最新的語言特性。

穩定版與不穩定版


現在的 Koa 分為 1.0 和 2.0 兩個版本,1.0 使用 ES6 的 Generator 特性,也是目前的 stable 版本,而 2.0 採用 ES7+ 的 async/await,據 Koa 官方說法,2.0 穩定度可以用於實際產品,只是在 ECMAScript 7 規格正式敲定,且 JavaScript V8 Engine 推出原生的實作之前,將無限期處於 unstable 的狀態。也就是說,若你想要在你自己的專案上使用 2.0,你必須使用 babel 這一類的編譯器,因為裡面用到了 ES7 的語法。

本篇文章的重點將放在 Koa 1.0 之上,畢竟在 ECMAScript 7 還處於草案階段的現在,很難說未來會不會有什麼改變。

安裝 Koa


由於 Koa 需要用到 ECMAScript 6 的語言特性,請先檢查你的 Node.js 版本,最少為 0.12 以上,如果你已經使用了 Node.js 4.0 或更高版本,請不用擔心這個問題。

然後透過 NPM 即可安裝模組:
npm install koa

開發第一個應用程式


開發 Koa 應用程式非常容易,下面是程式碼範例:
var koa = require('koa');

var app = koa();

app.use(function *() {
    this.body = 'Hello World';
});

app.listen(3001);
跑起來後,用瀏覽器連入 3001 埠即可看到「Hello World」的字樣,因為 this.body 的內容,將會被輸出到前端瀏覽器上。

使用 Generator 打造的 Middleware


koa.use() 將用來載入 Middleware,所有連線工作都會經過 Middleware 處理。所以前一個例子裡,我們使用 koa.use() 設定了一個處理函數,該函數會用來處理所有連線工作。

要注意的是,在 function 後面有一個「*」的符號,這代表這個函數是一個 Generator 函數,所以這函數裡面的程式將可以使用 Generator 的語言特性。若您不知道 Generator 是什麼,可以參考過去的舊文「快樂玩 ES6 Generator,從 co 起手式開始」。

【註一】如果你有過 express 開發經驗,對於 koa.use() 會相當熟悉,Koa 同樣支援了 Middleware 的架構,你可以將過去的程式輕易移植到這新的框架上。
【註二】Koa 底層使用 co 來操作 Generator,若你覺得 Generator 太過艱澀,只需要了解 co 的使用即可。

加入多個 Middleware


所有的連線要求可以透過一系列、不只一個 Middleware 來處理,我們可以利用多次 koa.use() 來使用它,範例如下:
var koa = require('koa');

var app = koa();

app.use(function *(next) {
    yield next;
});

app.use(function *() {
    this.body = 'Hello World';
});

app.listen(3001);

一個 Middleware 可以透過 yield 傳入 next 參數,讓連線要求進入到下一個 Middleware 被處理。

自訂 Router 和路徑管理


之前的範例直接使用 koa.use(),會將所有的連線都導入同一個處理函數,輸出同一個結果。若我們想要自訂不同的路徑,讓不同路徑用不同的處理函數,將需要額外安裝「koa-router」模組:
npm install koa-router

然後可以直接修改我們的程式碼如下:
var koa = require('koa');
var Router = require('koa-router');

var app = koa();
var router = new Router();

// 針對不同路徑套用不同處理函數
router.get('/', function *() {
    this.body = 'HOME';
});

router.get('/myapi', function *() {
    this.body = 'API';
});

// 載入自訂的 router
app.use(router.middleware());
app.listen(3001);
範例中只有使用到「GET」方法,如果要用來開發 Restful API 或是處理一些表單上傳的工作,可以依樣畫葫蘆使用 router.post、router.put 或 router.del 方法。

接收 QueryString 的資料


QueryString 可說是歷史悠久且非常常見的傳值方法,藉由一個網址後面加上一個「?」字元後,就可以使用鍵值(Key/Value)來進行資料傳遞,並用「&」區隔多組資料。一個簡單的實際應用如下:
http://my_server/send?name=fred&msg=Hello

取得資料的方法如下:
console.log(this.request.query.name);
console.log(this.request.query.msg);

接收 body 的資料


當我們使用「POST」或「PUT」方法,我們就可以利用 body 傳送一些資料到伺服器,像是網頁表單時常使用這樣的傳值方法。若想要取得 body 的資料,必須先安裝一個「koa-bodyparser」模組:
npm install koa-bodyparser

使用 koa.use() 載入 koa-bodyparser,koa 就會自動在處理連線時使用它解析 body:
var bodyParser = require('koa-bodyparser');

app.use(bodyParser());

然後可以在路徑處理函數中,正常取得 body 內的資訊:
console.log(this.request.body.name);
console.log(this.request.body.msg);

靜態文件支援


除了一般動態網頁外,我們也會在網頁中嵌入 CSS、前端的 JavaScript 和圖片等靜態檔案,這些檔案在瀏覽器載入頁面時,同時間也要提供瀏覽器能取得。為了達成這功能,可以使用「koa-static」來達成:
npm install koa-static

然後可以直接加入這個 middleware:
var serve = require('koa-static');

app.use(serve(__dirname + '/public'));

其中要帶入路徑參數,告訴 koa-static 去哪個目錄尋找對應的靜態檔案,範例中是設定為此程式同一個目錄下的 public 目錄。

Session 支援


要使用 Session 要先安裝 koa-session:
npm install koa-session

然後就可以在處理函數中使用 this.session 這個物件來存放資料:

var koa = require('koa');
var Router = require('koa-router');
var session = require('koa-session');

var app = koa();
var router = new Router();

// 設定一組金鑰,用來加密 session
app.keys = [ '$*&!@#$^)*(DSIJCH(*&@#' ];

// 載入 session middleware
app.use(session(app));

// 每次連線就將計數器加一
app.use(function *(next) {
    if (this.session.counter)
        this.session.counter = 0;

    this.session.counter++;

    yield next;
});

router.get('/', function *() {
    // 回傳顯示計數器的值
    this.body = this.session.counter;
});

// 載入自訂的 router
app.use(router.middleware());
app.listen(3001);

這範例會在瀏覽器每次連線時,把 session 內的計數器加一,所以若是我們重複整理這個頁面,會看到數字不斷增長。

要注意的是,使用 session 前,我們要為 app.keys 設一組金鑰(Key), koa-session 會使用這組 Key 加密我們的 session 資料。

後記


還在使用 express 嗎?別老土了。(笑)

2016年5月11日 星期三

MakerBoard: 自幹 MT7688 模擬器!簡報釋出!

Standard

使用 MTK LinkIt Smart 7688 這類開發板時,總是很痛苦,由於儲存空間不大,記憶體也不大,常常在開發的過程中飽受折磨。於是我們開始思考如何可以在自己的電腦上,模擬一個 MT7688 的環境,在有充沛資源的機器上進行開發。就這樣,前陣子開發了一個小小的 Open Source 工具專案「MakerBoard」,並在 MakerCup 的共筆網站發表「沒有板子也可以玩的 7688 模擬器!」。

雖然這樣一個小小的模擬器運用了 VM 和 Container 相關技術。但其實主要概念並不難,這次 5/10 在台大的開放原始碼課程中,就簡單從 MakerBoard 這專案出發,然後說明了一下怎麼樣自己打造一個簡單的 Container,並利用 QEMU 來進行 Binary Translation 的工作。

簡報釋出,請自行服用:

2015年12月13日 星期日

從 Maker 出發並反思:於是我們成立了 MakerCup!

Standard

Maker 一詞近年來翻紅,有人稱「自造者」,有人稱「創客」,以代工起家的國內產業,覺得 Maker 風潮是一個維持舊有工業地位的方法和機會,更將其引伸成軟硬整合、創業模式,無一不紛紛出來插手,想佔一塊地,分一杯羹。有些媒體將 Maker 塑造成有專業技術能力的人們,彷彿與一般人有很大的鴻溝。種種因素,自然越來越多人不了解 Maker 是什麼了。

但我們認為真正的 Maker 並不是擁有厲害能力的人,而是願意動手落實的人。

為什麼我們要成立 MakerCup?

我們想聚集純粹想動手、交流的朋友,並讓更多人參與並體驗 Maker 的世界。

事實上,Maker 的定義很簡單,凡是能打造、做東西的人,都能稱為 Maker。做菜的廚師,是個 Maker;編織衣服的人,是個 Maker;畫家,也是個 Maker。當然,寫軟體、做電子電路的人,以及各種設計師,通通都算是 Maker。無論在什麼領域,Maker 精神強調的是動手去實現、完成,去參與過程、瞭解過程,進而讓自己更有能力去打造出更多創意十足的東西。更重要的是,在這種不設限的旅程,能讓我們都具備著跨領域思考的能力。

既然過程才是最重要的,我們便開始思考怎麼樣讓更多人交流,交流技術、能力,共同發展和探討更多的知識。我們不應該只是追求一時且短暫的成果,滿足政府或代工產業想要立即成果的 KPI,更或是不應該鑽牛角尖盲目追求頂尖技能,而是讓更多人參與、動手,普遍瞭解更多不同的事物和技能。

於是, MakerCup 這個社群出現了,每週四都會舉辦一場分享交流活動或是小聚會,讓 Maker 平日下班或閒暇時,可以來走走坐坐,輕鬆喝點小飲料,或是現場做點東西:
https://www.facebook.com/groups/MakerCup/

我們希望,這個社群將如一碗太古時代的生命濃湯一般,熬煮出真正的 Maker 生命。

延續黑客松台灣的精神

還記得這一年,我們籌辦了整個年度的「黑客松台灣(Hackathon Taiwan)」,每個月都有 300 至 500 人的大型創作活動,讓不敢踏出來的年輕學子、上班感到無聊的人、及很少離開自己專業領域的朋友,走出來到活動上以「能力會友」。這一年的過程,讓大家的成果,從簡陋成長到真正的創意或產品,從簡單的技術到複雜的應用,從小設計到跨領域的整合。

想當初,很多人剛開始嘲笑我們的成果都像玩具,勸我們不要再辦下去,請大家白吃白喝沒有意義。但一年以後的今天,事實證明我們是對的,黑客松台灣的參加者們,有最堅實的創意、能力和執行力,能解決各式各樣的問題,就算去號稱 Maker 聖地的中國深圳,也絲毫不遜色。

更重要的是所有人都樂在其中,並把這份能力和喜悅,帶回原本的工作崗位上。

同樣的精神,同樣的想法,我們一樣將在 MakerCup 落實。我們希望讓更多人來交流,讓更多人來學習動手,共同成長,而不將只是各式各樣的發表會而已。

我們所見、期待的未來?

從商業角度,許多傳統代工廠,在初面對 Maker 時,都誤將 Maker 當作了新的客人,期望 Maker 能產出點子、找到客人,然後下單。事實上,Maker 並不應該是代工廠下的消費者,而是橫向整合者,將不同領域、需求及客群,重新整頓和安排設計,然後創造出各種新的產業型態。而對於代工廠,精緻化並不再是唯一選擇,成為各行各業的技術供應者亦是一種出路。

所以我們相信,新的世代和市場潮流,不是築一道高牆,將 Maker 拒於專業的工廠門外,而是讓 Maker 視野做廣、扎根,讓大家愛上來台灣當一個 Maker,做出許多前所未有的成果或產品。未來,肯定會有更多的企業投入、民間組織投入,技術上也會有更多模組化解決方案,或是各類的知識交流,甚至是文化交流,來支撐這樣的整合性變革。

不可否認,在 Maker 的年代,什麼產業都將會是科技業,也都會是混血兒產業,誰能迎合這樣多族群共榮,誰就能在這時代中發光發熱。

歡迎加入我們!

MakerCup 社群是由黑客松台灣(Hackathon Taiwan)的部分成員共同推動的,感謝背後有更多合作單位或是朋友的陸續協助和資助,如黑客松台灣講師發起的創作學校「LetSchool」、「聯發科 MediaTek」、「Seeed Studio」、「緯創 Wistron」、「台灣品牌協會」、「台灣土地開發」及「卡市達創業加油站」。不久的將來,還有「Node.js Party」、「IoT Taiwan 社群」、「MakerBot」或是「品酒社群」在這一望無際的場地裡當鄰居。

更多需要感謝的朋友們,不勝枚舉,也歡迎更多人共襄盛舉這樣具有台灣風味的「圓山社群觀光夜市」。

不多說了,先來一杯 Maker 吧!

2015年11月1日 星期日

Lantern 專案:快速打造屬於自己的 Isomorphic 網站服務

Standard

話說,Isomorphic 一直是 Node.js 開發者的夢想,期望同一套程式碼前後端都可以使用,大幅簡化程式碼和加速開發。此外,動態網頁的 SEO 問題也可以同時獲得解決,許多效能問題也可以得到改善。但是,要實現 Isomorphic 的架構,有很多的問題得先解決,會花大量時間在前期工作上,往往讓許多開發者頭痛。

儘管頭痛,仍然阻止不了大家往 Isomorphic 的世界前進,我也因此建立了一個專案「Lantern」,希望能讓更多人能以 Isomorphic 架構,快速建構出自己的網站服務,省去許多前期工作的時間。該專案是一個網站服務的樣板,實作了會員系統、權限管理、第三方登入、多國語系和送信機制等功能,在使用者介面上也做了一個還算美觀的介面。基本上,開發者只要 clone 下來,然後修改設定檔或改改介面、增加點功能,就可以快速完成一個屬於自己的全新網站服務。

最特別的是,「Lantern」整合了現今所有最新的技術和概念,包括了 Koa、React、FLUX、ES6/7+、Webpack 以及 Semantic UI,大量運用了 Generator、class 及 decorator 等最新 JavaScript 語言特性來簡化設計。所以,如果你想要接觸最新的技術,完全可以透過修改「Lantern」專案來學習和熟悉。

目前「Lantern」支援 Facebook 剛發佈的最新 React v0.14+ 和 react-router 1.0.0+,也避免使用像 redux 這類反 FLUX 原始設計的框架,讓原本熟悉 React 和 FLUX 架構的開發者,可以快速上手。也提供一些常見的 Extension,方便開發者寫出前後端通用的程式碼,大多數情況下,開發者不需思考程式碼運行在前端還是後端。

快速安裝使用

若想要使用「Lantern」,方式很簡單,先從 Github 取得程式碼:
git clone git@github.com:cfsghost/lantern.git

安裝必要之 NPM 模組:
npm install

使用 webpack 編譯專案(若要正式上線,可加上 -p 選項來編譯):
webpack

運行網站服務:
node app.js

最後可以使用瀏覽器開啟網址,確認是否成功:
http://localhost:3001/

修改設定檔

一般情況,你無需做任何設定就可以把服務跑起來,但如果你需要修改網站名稱、使用自己的第三方登入設定以及電子郵件伺服器,可以修改 Lantern 的設定檔。設定檔是 JSON 的格式,相當容易修改。


  1. 只要進入到「configs」目錄
  2. 把「general.json.default」複製一份並更名為「general.json」
  3. 修改「general.json」內的設定
  4. 重啟服務

目錄架構

如果你想要開始客製化網站服務,需要先簡單理解「Lantern」的目錄架構。
  • src - 主要程式
    • js - 頁面部分的程式
    • img - 存放圖片
    • less - CSS 原始碼
    • translations - 存放多國語言的對應表
  • routes - 主要為 Restful API
  • lib - 後端的相關函式庫(資料庫、第三方認證、發送電子郵件等功能)
  • models - 資料庫 Schema

快速上手開發

首先記得,只要你修改了「src」底下的任何檔案,你必須重新執行「webpack」來進行編譯。或是可以跑一個「webpack -w」在背景,讓 webpack 在檔案有變更的時候自動重新編譯程式碼:
webpack -w

一般來說,我們會從頁面修改和增減開始進行客製化工作。由於「Lantern」是採用 React 來繪製頁面,所有的頁面程式都將放在「src/js/components」底下,只要看到副檔名為「.jsx」的檔案,就分別是各種畫面上的元件。

建立新的頁面

建立頁面需要修改「src/js/routes.js」,加入一個網址及對應的頁面元件(以 Chatroom.jsx 為例):
module.exports = [
    // 省略 ...
    {
        path: '/chatroom',
        handler: require('./components/Chatroom.jsx')
    }
];

接著可以建立「src/js/components/Chatroom.jsx」檔案,開始設計你的頁面。如果需要使用 FLUX 的機制,可以載入並引入「Lantern」所提供之 decorator 到你的 React 元件上:
import React from 'react';
import { flux } from 'Decorator';

@flux
class MyComponent extends React.Component {
    constructor() {
        super();

        this.state = {
            messages: []
        };
    }

    componentWillMount() {
        this.flux.on('state.Chatroom', this.flux.bindListener(this.onChange));
    }

    componentWillUnmount() {
        this.flux.off('state.Chatroom', this.onChange);
    }

    onChange = () => {
        var store = this.flux.getState('Chatroom');

        this.setState({
            messages: store.messages
        });
    }

    render() {
        return <div>{this.state.messages}</div>;
    }
}

export default MyComponent;

開發自己的 Actions 和 Stores

假設你已經很了解 FLUX 的開發模式,你可以直接開始設計 Action 和 Store。對「Lantern」而言,無論是 Action 和 Store 都是一樣的東西,只不過執行的順序不一樣。

建立 Action(放在 src/js/actions/chatroom.js):
export default function *() {
    this.on('action.Chatroom.say', function *(name, message) {
        this.dispatch('store.Chatroom.addMessage', name + ':' + message);
    });
}; 

建立 Store(放在 src/js/stores/chatroom.js):
export default function *() {
    // 初始化一個 state 用來存放 store 的資料
    var store = this.getState('Chatroom', {
        messages: []
    });

    this.on('store.Chatroom.say', function *(msg) {

        // 加入新訊息到 store
        store.messages.push(msg);

        // State(Store) 已經更新,React 元件會被觸發更新
        this.dispatch('state.Chatroom');
    });
}; 

最後在「actions/index.js」和「stores/index.js」分別載入新建立的 Action 和 Store:
export default {
    // ...省略
    chatroom: require('./chatroom')
}; 

存取 Restful API

「Lantern」提供了統一的方法呼叫 Restful API,無論前端還是後端都可以使用(在 Store 或 Action 中),此外,如果在後端使用呼叫,該方法會自動接續使用者的 Session (登入)狀態,進行 Restful API 存取。使某些使用者登入後才可存取的 API,更為容易被存取。
export default function *() {
    this.on('store.Chatroom.getMessages', function *() {
        var store = this.getState('Chatroom');

        try {
            var res = yield this.request
                .get('/apis/messages')
                .query();

            // 取得聊天室訊息,並更新到 store
            store.messages = res.body;

            // State(Store) 已經更新,React 元件會被觸發更新
            this.dispatch('state.Chatroom');
        } catch(e) {
            switch(e.status) {
            case 500:
            case 400:
                console.log('Something\' wrong');
                break;
            }
        }
    });
};

在畫 React 元件前先預載資料

後端要把畫面送到瀏覽器前,有時需要先資料庫的資料載入,預先植入畫面之中,前端有時也需要預先載入一些資料,以便畫面宣染時有實質內容。我們可以透過載入「@preAction」這個 decorator 來達成這個需求。「@preAction」會在元件初始化前,先去執行一些工作。

底下範例是利用「@preAction」去跑 FLUX 裡的 Action - 「Chatroom.fetchMessages」:
import { preAction } from 'Decorator';

// 相當於 this.flux.dispatch('action.Chatroom.fetchMessages')
@preAction('Chatroom.fetchMessages')
class MyComponent extends React.Component {
    // ...
}

當然可能要預先做的工作不只一項,而且可能要帶入 React 元件的 props 或更多資訊到 Action 中。「@preAction」可以被帶入函數,作更複雜的設計:
@preAction((handle) => {
    handle.doAction('Chatroom.fetchMessages');
    handle.doAction('Chatroom.doSomething', handle.props.blah, 123);
})

因為 Store 會因為「@preAction」而被更新、有資料,這時就可以理所當然地在元件初始化時直接取用 State(Store)的內容。
class MyComponent extends React.Component {
    constructor(props, context) {
        super();

        this.state = {
            messages: context.flux.getState('Chatroom').messages;
        };
    }
    // 省略 ...
}

動態載入 JavaScript 或 CSS 檔案


很多 JavaScript 或 CSS 檔案是隨著 React Component 的載入,才會被動態載入,有時甚至需要照順序載入。此外,通常這樣的機制比較多會被使用在前端瀏覽器的頁面上,同樣的載入程式碼工作,在後端 Rendering 時往往會壞掉而無法通用,這在 Isomorphic 的架構中往往需要特別處理,像是判斷執行期是在前端還是後端,相當麻煩。

為此,「Lantern」提供了「@loader」這個 Decorator,使開發者可以容易引入動態載入的機制,而且不用思考前後端的問題,也可以控制載入順序,或是等待檔案載入完成。

以下範例就是一個載入地圖 API 的範例,載入工作只會在前端執行,不會在後端執行:
import { loader } from 'Decorator';

@loader
class MyMap extends React.Component {

    componentWillMount() {
        // Loader 在後端不會有任何作用
        this.loader.css('https://example.com/css/test.css');
    }

    // componentDidMount 只會在前端觸發
    componentDidMount() {
        this.loader.css('https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.5/leaflet.css');
        this.loader.css('https://api.tiles.mapbox.com/mapbox.js/plugins/leaflet-minimap/v1.0.0/Control.MiniMap.css');

        this.loader.script([
            'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.5/leaflet.js',
            'https://api.tiles.mapbox.com/mapbox.js/plugins/leaflet-minimap/v1.0.0/Control.MiniMap.js'
        ], function() {
            // 初始化地圖 ...
        });
    }

    render() {
        return 
; } }

取得和監聽視窗資訊


為了更方便前端排版,尤其是需要滿版的設計時,我們往往需要得知或監控瀏覽器視窗的大小,通常做法是存取瀏覽器中的「window」物件,並監聽事件來達成。但「window」物件只在瀏覽器上存在,在後端如果存取該物件,會失敗而且有錯誤發生,在以往 Isomorphic 架構中,每次都要特別處理,相當麻煩。因此「Lantern」預設提供了一個名為「Window」的 Store,將這類資訊包裝起來,使 React Component 能輕易存取又不會因在後端或前端而出現問題。

下面範例就是存取 Window 的例子,以及監聽視窗大小改變的事件。
@flux
class MyPage extends React.Component {
    constructor(props, context) {
        super();

        var win = context.flux.getState('Window');
        this.state = {
            winWidth: win.width,
            winHeight: win.height
        };
    }

    componentWillMount() {
        this.flux.on('state.Window', this.flux.bindListener(this.updateDimensions));
    }

    componentWillUnmount() {
        this.flux.off('state.Window', this.updateDimensions);
    }

    updateDimensions = () => {
        var win = this.flux.getState('Window');
        this.setState({
            winWidth: win.width,
            winHeight: win.height
        });
    }

    render() {
        return 
{this.state.winWidth}x{this.state.winHeight}
; } }

看不懂很多 ES6 和 ES7 的東西?

這邊已經整理了一些常用的對應表「ES6 and ES7」,方便開發者理解其中的語法。

更多文件和說明

更多資訊可以參考 Github 上的 Wiki:

後記

其實「Lantern」已經改版了幾次,因為之前在好幾個要上線的專案上,每次都發現有些許不足之處,所以就不斷翻新架構和改進,甚至是優化效能。到目前為止,大致已經算是穩定的狀態,未來的開發方向不外乎是繼續寫 Isomorphic 的 Extension,以及效能優化。

如果你有興趣,歡迎加入並共同改善這個專案。:-)