6 months ago

本篇文章,咱們將要在說明另一個在 php 實現 reactor 模式的東西swoole

本篇文章分為以下幾個章節 :

  • Swoole 的架構
  • Swoole 非阻塞 I/O 的處理
  • Swoole 的 Coroutine
  • Swoole 實際使用的注意事項

swoole 的架構

swoole 官網寫到 :

event-driven asynchronous & coroutine-based concurrency networking communication engine with high performance written in C and C++ for PHP.

swoole 它是一個用 c++ 所寫的 php extension,一個非常高效能的通訊引擎,而它能達到高效能的基礎在於以下幾個重點 :

  • event-drivent
  • coroutine

一句話來說他可以幹麻。

它可以讓我們建立一個高效能的網路服務

下面就是它實現非阻塞 I/O 的架構圖。

當它建立一個 http server 以後,你會看到產生出下圖這些 process 與 thread,基本上可以分為幾個部份 :

  • Master Process : 用來管理 reactor thread,主要就是當 client 發送一個 http 請求後,由它來決定那個 reactor thread 來接客。
  • Reactor Thread : 每個 thead 中都有使用 I/O 多路復用的技術來監聽多個 socket,當有事件(讀或寫資料)進來時,會發送給某個 worker process 來處理。
  • Manager Process : 用來管理 worker process,也是用來決定那個 worker process 來接客。
  • Worker Process : 實際運行 php 代碼的地方,它這裡也有使用 I/O 多路復用技術來監聽 socket。這裡提供同步阻塞或是異步非阻塞操作。
  • Task Process : 一樣 php 代碼運行的地方,它會接受由 worker 丟過來的任務(開發者自已撰寫),通常都是一些 cpu 密集的運算。這裡只能同步阻塞操作。

上面是主要的架構,然後我們來理一下所謂的阻塞 I/O 會在那幾個地方,而 swoole 又是如何解決。

Swoole 非阻塞 I/O 的處理

這裡基本上可以分成兩個地方來看。

高併發建立連線的地方

swoole 這裡是在 reactor thread 實作 reactor 模式來解決,而 reactor 最主要透過的技術為 I/O 多路復用技術來做到大量 socket I/O 監控,並且在將事情丟給 worker process 處理。

順到說一下,與 nodejs 或 reactPHP 相比,swoole 這裡勝在它開啟多個 thread 來監控多條連線,理論上它這樣應該可以承受的比 nodejs 與 reactPHP 更多的併發量。

Worker process 中大量的 db 或 redis 操作

swoole 有提供幾個機制可以給 worker process 實現非阻塞 I/O 操作。

  • reactor 模式 (網路 I/O)
  • 丟 task worker 處理 (網路、檔案 I/O)
  • 底層使用 aio 處理 (檔案 I/O)
reactor 模式 (網路 I/O)

首先第一個 reactor 模式就與 reactor thread 的機制一樣,都是會使用 I/O 多路復用來監聽 socket,當有事件進來時,就執行 handler (callback)。

下面為範例,當在 worker 執行了這段程式碼以後,會將連 redis 的這條 socket 丟到 reactor 來進行監控,當有事件進來時,會執行 callback。

而這就是所謂的異步非阻塞。( 異步: callback 非阻塞: 不會卡住 process )

<?php

$client = new swoole_redis;
$client->connect('127.0.0.1', 6379, function (swoole_redis $client, $result) {
    // callback function
    if ($result === false) {
        echo "connect to redis server failed.\n";
        return;
    }
    $client->set('key', 'swoole', function (swoole_redis $client, $result) {
        var_dump($result);
    });
});

而所謂的同步阻塞的範例如下,假設你在 worker 中直接使用 predis 來操作 redis,那這就是所謂的同步阻塞。

同步就是依順序走,而阻塞就是指會在 $cient->get("foo") 那會卡住整個 process。

<php

$client = new Predis\Client();
$client->set('foo', 'bar');
$value = $client->get('foo');
丟 task worker 處理 (網路、檔案 I/O)

而下面為處理 file 的範例,但這裡要注意,這種處理檔案的方式受限於 task process 的數量,假設我們 task 的數量為二,那當如果開啟三個 task 來處理檔案時,第三個就會無法處理。

<?php

$server = new swoole_http_server("0.0.0.0", 9501);

$server->set(array(
    'worker_num' => 1,
    'task_worker_num' => 2
));

$server->on('request', function ($request, $response) use ($server) {

    // ----------------------------------------------------
    var_dump('wait...');
    $server->task("hi", 0);
    var_dump('done !...');
    // ----------------------------------------------------

    $response->header("Content-Type", "text/html; charset=utf-8");
    $response->end("<h1>Hello Swoole. #".rand(1000, 9999)."</h1>");
});


$server->on('task', function($server, $task_id, $from_id, $data){
    $request = file_get_contents('file 位置');
});

$server->on('Finish', function (swoole_server $serv, $task_id, $data) {
    echo "Task#$task_id finished, data_len=".strlen($data).PHP_EOL;
});

$server->start();
底層使用 aio 處理 (檔案 I/O)

這裡注意要將 runtime 的 coroutine 開啟,才能讓 file_get_contents 使用底層 aio 來完成同步非阻塞操作。

<?php
Swoole\Runtime::enableCoroutine(true);

go(function () {
    $fp = file_get_contents("test.log");
    fclose($fp);
});
Q&A 為什麼 Reactor 的機制無法處理 filesystem 的東西呢 ?

目前筆者只知道,如果你將檔案的 file descriptor 註冊到 I/O 多路復用(epoll) 中,會發生以下的錯誤:

EPERM The target file fd does not support epoll.

這也是為什麼需要使用其它 process 來處理,而在 nodejs 中碰到這種檔案類型的操作,則是開啟 thread 來進行處理,詳細可看此篇文章。

Nodejs 之運行機制原理 - 馬克筆

Swoole 中的 Coroutine

swoole 中有提供一個 coroutine 的功能,那它是做什麼呢 ?

它可以讓 worker 做到同步非阻塞的操作

剛剛上面有說 worker 可以提供以下兩種 I/O 操作型式 :

  • 同步阻塞 ( 同步:程式碼順序走 阻塞: 會卡住整個 process )
  • 異步非阻塞 ( 異步: callback 機制 非阻塞: 不會卡住整個 process )

但實際上它還有提供第三種 :

  • 同步非阻塞 ( 同步: 程式碼順序走 非阻塞: 不會卡住整個 process )

而實現的方法就是使用 coroutine 也就是協程。

它系統內的運行流程如下:

coroutine 使用範例

使用情景如下程式碼,那在 go 裡面的這段程式碼就會開一個被稱為 coroutine 協程的東西來處理這一段,這樣就可以做到同步非阻塞操作。

<?php

go(function () {
    $redis = new Swoole\Coroutine\Redis();
    $redis->connect('127.0.0.1', 6379);
    $val = $redis->get('key');
});

而如果沒有用 coroutine 的話,那就只能做到異步非阻塞操作,如下程式碼,也就是一堆 callback。

<?php

$client = new swoole_redis;
$client->connect('127.0.0.1', 6379, function (swoole_redis $client, $result) {
    $client->GET('key', 'swoole', function (swoole_redis $client, $result) {
        var_dump($result);
    });
});

Q&A - Coroutine 是實現非阻塞 I/O 的技術嗎 ?

不是。

coroutine 很多人會說它是一個比 thread 還較更小單位的操作單位,所以就會想,那我每一個阻塞 I/O 就開一個協程來處理,那這樣不就可以實現非阻塞 I/O 操作了嗎 ? 就像 multi thread 原理一樣,而且我協程更省資源。

NoNoNo ~

process 為操作系統的最小資源管理單位,而 thread 為操作系統最小操作單位。

而 coroutine 是一個比 thread 更小的操作單位,但是它的操作是用戶 (application)所操作,而不是像 thread 一樣是操作系統所操作。

而 I/O 的操作是指操作系統的操作,當一個阻塞 I/O 執行時,讓 process 阻塞的是操作系統,所以如果你在一個協程內開啟一個阻塞 I/O 它仍然會卡住整個 process。

coroutine 嚴格來說只能幫你做到同步非阻塞,非阻塞還是需要有 reactor 機制來實現。

備註: go 語言的 coroutine 就比較特殊了,這之後會另開篇章討論。

Swoole 的實際使用注意

與 reactPHP 的範例一樣,我們一樣使用 swoole 來建立一個 http server。

<?php

$server = new swoole_http_server("0.0.0.0", 9501);

$server->set(array(
    'worker_num' => 1,
    'task_worker_num' => 0
));

$server->on('request', function ($request, $response) {

    // ----------------------------------------------------
    var_dump('wait...');
    $request = file_get_contents('http://127.0.0.1:3000');
    var_dump('done !...');
    // ----------------------------------------------------

    $response->header("Content-Type", "text/html; charset=utf-8");
    $response->end("<h1>Hello Swoole. #".rand(1000, 9999)."</h1>");
});

$server->on('task', function($server, $task_id, $from_id, $data){
});

$server->start();
如果這時發兩條 http 請求會如何呢 ?

答案: 1 次 wait ( 因為第一次就卡住 process )

主要的原因基本上在於,你沒將這 I/O 操作丟到這個 worker 的 reactor 機制中,所以這個 I/O 操作仍然會是阻塞的。

然後有些人在測試時可能會收到二次 wait,但是有一點你要注意,那就是worker_num你設多少,如果你設2,它當然可以收到二次,因為是由不同的 process 接受,但是接下來的第三次,你絕對收不到。

正確版

首先第一種是官方現在推薦使用 coroutine 的同步非阻塞的寫法。

<?php

$server = new swoole_http_server("0.0.0.0", 9501);

$server->set(array(
    'worker_num' => 2,
    'task_worker_num' => 0
));

$server->on('request', function ($request, $response) {

    // ----------------------------------------------------
    var_dump('wait...');
    $client = new Swoole\Coroutine\Http\Client('127.0.0.1', 3000);
    $client->get('/');
    echo $client->body;
    var_dump('done !...');
    // ----------------------------------------------------
});

$server->start();

而另一種就是使用 callback 的異步非阻塞的寫法。

<?php

$server = new swoole_http_server("0.0.0.0", 9501);

$server->set(array(
    'worker_num' => 2,
    'task_worker_num' => 0
));

$server->on('request', function ($request, $response) {

    // ----------------------------------------------------
    var_dump('wait...');
    $client = new swoole_http_client('127.0.0.1', 3000);
    $client->get('/', function ($client) {
        echo "Length: " . strlen($client->body) . "\n";
        echo $client->body;
    });
    var_dump('done !...');
    // ----------------------------------------------------
});

$server->start();

參考資料

 
6 months ago

上一篇文章PHP 的 Web 運行原理 ( 2 ) - 非阻塞 I/O 之 Reactor 模式我們理解到實現非阻塞 I/O 的 reactor 模式以後,接下來本篇文章我們將來要說明,在 php 中的 reactor 實現reactPHP

本篇文章分為以下三個章節 :

  • reactPHP 基本概念
  • reactPHP 非阻塞 I/O 實現
  • reactPHP 使用時注意事項

reactPHP 基本概念

reactPHP 官網寫這一段話 :

Event-driven, non-blocking I/O with PHP

它是一個用 php 所寫的 libaray,可以幫助我們做以下的事情 :

  • 可以建立一個非阻塞 I/O 的網路服務。
  • 可以建立一個定時排程服務。

http server 的範例

下面就是官網首頁的範例,我們可以用它簡單的建立一個非阻塞 I/O 的 http server,就如同 nodejs 一樣。

$loop = React\EventLoop\Factory::create();

$server = new React\Http\Server(function (Psr\Http\Message\ServerRequestInterface $request) {
    return new React\Http\Response(
        200,
        array('Content-Type' => 'text/plain'),
        "Hello World!\n"
    );
});

$socket = new React\Socket\Server(8080, $loop);
$server->listen($socket);

echo "Server running at http://127.0.0.1:8080\n";

$loop->run();
排程服務的範例
$loop = React\EventLoop\Factory::create();

// 5 秒後執行
$loop->addPeriodicTimer(5, function () {
    echo "Hi Mark";
});

$loop->run();

reactPHP 非阻塞 I/O 實現

那 reactPHP 它是如何實現非阻塞 I/O 模式呢 ?

它就是如我們標題所說,它使用reactor 模式來實現非阻塞 I/O 模式,並且它與 nodejs 一樣使用 single process 的 reactor 模式。

備註: 為什麼 single process 可以做到非阻塞 I/O 請參考前一篇文章。

架構圖如下,就是一個 reactor 機制,其中它的 process 裡面就有所謂的 I/O 多路復用技術。

而我們實際上的運行機制如下圖:

接這我們來看看,如果是段 reactor 模式的程式碼,那麼它的運行原理流程會如下 :

  1. 建立 event loop。
  2. 將 addPeriodicTimer 5 秒後要執行的事件丟到 queue 中。
  3. 執行 event loop。(也就是開始跑無窮迴圈)
  4. event loop 在 5 秒時,發現有個要執行的任務 callback function
  5. 執行 echo "Hi Mark"。
  6. 將立 http 請求,並將它丟到 queue 中。
  7. event loop 當發現 I/O 操作有回復時,則執行 callback function
  8. echo $chunk。
(1) $loop = React\EventLoop\Factory::create();
    $client = new React\HttpClient\Client($loop);

    // 5 秒後執行
(2) $loop->addPeriodicTimer(5, function () {
(4)   
(5)     echo "Hi Mark";
(6)     $request = $client->request('GET', 'https://github.com/');
        $request->on('response', function ($response) {
            $response->on('data', function ($chunk) {
(7)             echo $chunk;
            });
            $response->on('end', function() {
                echo 'DONE';
            });
        });

(3)     $loop->run();

使用時注意事項

reactPHP 理論上是 php 版的 nodejs 但實際使用要注意,不是。

假設我們使用 reactPHP 然後來寫一個 http server。

然後當 server 收到一個請求時,它會再使用file_get_contents這個方法去打某個 api,這 api 需要一分鐘左右才能回應。

<?php 

require __DIR__ . '/vendor/autoload.php';

$loop = React\EventLoop\Factory::create();

$server = new React\Http\Server(function (Psr\Http\Message\ServerRequestInterface $request) use ($client) {

    // ----------------------------------------------------
    var_dump('wait...');
    // 假設這一段要執行一分鐘。
    $response = file_get_contents('http://127.0.0.1:3000');
    var_dump('done !...');
    // ----------------------------------------------------

    return new React\Http\Response(
        200,
        array('Content-Type' => 'text/plain'),
        "Hello World!\n"
    );
});

$socket = new React\Socket\Server(8080, $loop);
$server->listen($socket);

echo "Server running at http://127.0.0.1:8080\n";

$loop->run();
假設往這個使用 reactPHP 寫的 http server,發送兩次請求,上面的 wait 會看到幾次呢 ?

答案是 : 1 次

因為第一次請求時,就將整個 process 給阻塞住了,所以第二個請求完全無法處理。

它不是說它有提供非阻塞 I/O 的功能嗎 ?

嗯對的,它有提供,但是前提假設為,你要使用它。

你在寫 nodejs 時,上面這個相似範例 process 就不會被阻塞住,因為它在給 v8 處理完 binding c++ 階段時,會將所有的 I/O 操作執行完並且丟到 event loop 機制中,所以在 nodejs 操作 I/O 時,你什麼都沒做,就會是非阻塞的 I/O 操作。

而在 reactPHP 中,你要自已處理又或是使用它所提供的 http client 才能讓變成非阻塞 I/O 的操作。如下範例。

<?php 

require __DIR__ . '/vendor/autoload.php';

$loop = React\EventLoop\Factory::create();

$client = new React\HttpClient\Client($loop);

$server = new React\Http\Server(function (Psr\Http\Message\ServerRequestInterface $request) use ($client) {

    // ----------------------------------------------------
    var_dump('wait...');
    $request = $client->request('GET', 'http://127.0.0.1:3000');
    $request->on('response', function ($response) {
    $response->on('data', function ($chunk) {
        echo $chunk;
    });
    $response->on('end', function() {
        echo 'DONE';
    });
    
    var_dump('done !...');
    // ----------------------------------------------------

    return new React\Http\Response(
        200,
        array('Content-Type' => 'text/plain'),
        "Hello World!\n"
    );
});

$socket = new React\Socket\Server(8080, $loop);
$server->listen($socket);

echo "Server running at http://127.0.0.1:8080\n";

$loop->run();

結論

本篇文章中我們學習了幾個重點:

reactPHP 基本概念

就是這段話。

Event-driven, non-blocking I/O with PHP

reactPHP 非阻塞 I/O 實現

它使用single process 的 reactor 模式來實現。

使用時注意事項

在使用它時注意,你要確保你所有的 I/O 操作,都有丟到 event loop 中,否則,它仍然是阻塞 I/O 操作,就如同範例一樣。

參考資料

 
6 months ago

前篇: PHP 的 Web 運行原理 ( 1 )

上面一篇文章中,我們有提到兩種 php 的 web 運行模式moduelfast_cgi模式,它們在某種情況下,都會有些問題,而我們這篇文章就是要來理解是碰到什麼問題,然後又是如何解決呢 ?

  • Reactor 模式想解決的問題
  • Reactor 模式原理
  • Reactor 的使用注意事項

Reactor 模式想解決的問題

使用 moduel 與 fast_cgi 模式 的 web server 模式基本上會有兩個問題存在。

1. 高併發請求,會爆 !

如下面這張圖一樣,它每一個 http 請求都需要使用一個 process 或是 thread 來進行處理,而每一台機器的 process 與 thread 的數量都有限制,且操作系統進行 process 或 thread 上文文切換時非常耗的資源。

2. 服務如果是大量 I/O 操作會很浪費資源 !

ex. 讀 db 或 redis 啥的

主要耗資源的地方在於,每個 process 開啟後,大部份的時間者是在等待 I/O 的處理,而 CPU 都是閒在那。

上面兩個是看到的現象,而真正的問題點在於 :

為什麼每個請求都需要開啟一個 process 或 thread 來處理呢 ?

主要的原因在於,在 linux 底層中,我們使用了阻塞 I/O 方法,來讀取 http 傳送過來的資料。如下範例程式碼。socket网络编程中read与recv

data = read(sockfd); // 這裡會阻塞整個 process
handler(data);

它就會一直停在那一行 read 等待資料進來,也就是說整個 process 為了監聽 socket 有沒有資料,就卡在那了。

這也是為什麼每一條 http(I/O) 請求,都需要啟一個 process 或 thread 來處理某條 socket 的原因。

備註:
每一個 http 請求基本上就是會建立一條 socket,而 sockfd 又代表此 socket 的 File descriptor,不熟悉網路的友人可以用上面這幾個關鍵字來查詢。

Reactor 模式原理

基本上高併發 I/O 這個問題,目前比較常用的解法為 :

Reactor 模式

reactor 模式可以幫助我們建立非阻塞 I/O 模式,也就是不需要開多個 thread 或 process 來處理 I/O 操作。

它的概念如下圖 :

上圖架構中,最重要的就是 I/O 多路復用 (Multiplexing)這部份,基本上它是系統底層所提供的功能,它可以幫助我們監控所有有註冊的 socket,當它有事件進來以後,就會由指定的 handler 來進行處理。

下面為 reactor 模式的概念碼。而事實上這就是 nodejs 中我們常聽到的 event loop 的機制。

但這裡要注意,epoll_wait 本身是一個阻塞方法,也就是說執行它,整個 process 會被卡住。有寫過 nodejs 的人在這裡應該會有疑問,等等會解答。

while(true){
    events = epoll_wait();  // 這裡會取得到 I/O 讀取資料的事件資訊 ex. read
    for (int i=0; i < events.size(); i++){
        handler(events[i]);
    }
}

備註 :
I/O 多路復用 (Multiplexing) 不同的平台有不同的實作,epoll(linux)、kqueue(Mac)、IOCP(Window)。

Multiplexing 與 Handler 是不同的 process 嗎 ? 還是相同的 ?

答案是都可以。

像 nodejs 就是屬於 multiplexing 與 handler 都是在同一個 process 運行,而 php swoole 則是屬於 multiplexing 與 handler 在不同 process 運行。

Multiplexing 不是需要一個 process 一直監聽所有 socket 嗎 ? 那為什麼 nodejs 可以單個 process 同時做到監聽與處理呢 ?

嗯之前我也有這個疑問。

當初我的想法是如下概念碼一樣,它會在 epoll_wait 阻塞住整個 process 來監聽,然後在有事件時,丟給某個 thread 或其它 process 的 handler 來處理。

while(true){
   events = epoll_wait(); // 它會一直停在這裡,等某個 socket 有事件進來。
   for (int i=0; i < socket.size(); i++){
        handler(events[i])
   }
}

epoll_wait 的確是阻塞 I/O 操作沒錯,但是它事實上有提供一個參數 timeout,也就是如果這段時間沒有資料,它就會回傳個 null 或啥 -1 的,反正就是和你說沒資料進來,而這時你就可以繼續往下處理。

備註: 可以拉到最下看看 libvu 所提供的 timeout 計算方式。

while(true){
   events = epoll_wait(timeout); // 注意 timeout 。
   for (int i=0; i < sockets.size(); i++){
        handler(sockets[i])
   }

   // 取出接下來 event queue 中要處理的工作。
   worker = queue.poll();
   handler(worker);
}

Reactor 模式的使用注意

下面為 reactor 模式的概念碼。

while(true){
    events = epoll_wait();  // 這裡會取得到 I/O 讀取資料的事件資訊 ex. read
    for (int i=0; i < events.size(); i++){
        handler(events[i]);
    }
}
假設我們在 handler 執行一段 cpu 密集工作的話會如何呢 ?

嗯就是會整個 process 卡住,也就是說除非計算完成,不然什麼事情都不能做。

所以別忘了 reactor 的使用重點 :

reactor 是用來解決 I/O 密集的模式,但無法處理 CPU 密集的工作

所以在基本上要先理解清除你的系統是大部份的工作才能決定要什麼模式來處理。

  • CPU 密集處理 => multi process 或 multi thread 模式。
  • I/O 密集處理 => reactor 模式

結論

本篇文章中我們說到以下幾個重點:

Reactor 模式想解決的問題

大量 I/O 操作的情況,會導致傳統的 multi process web 模式倒站問題。

Reactor 架構

重點就是I/O 多路復用有了它我們才能一個 process 監控多個 socket。

Reactor 的使用注意

在 reactor 模式的系統下,不要執行大量 cpu 運算的工作,會導致整個 process 卡住,大量 cpu 運算請另開 process 或 thread 來處理。

參考資料

libuv 的 timeout 計算參考

下面為 libuv 的 timeout 計算的方法,

libuv timeout 原始碼

int uv_backend_timeout(const uv_loop_t* loop) {
  if (loop->stop_flag != 0)
    return 0;

  if (!uv__has_active_handles(loop) && !uv__has_active_reqs(loop))
    return 0;

  if (!QUEUE_EMPTY(&loop->idle_handles))
    return 0;

  if (!QUEUE_EMPTY(&loop->pending_queue))
    return 0;

  if (loop->closing_handles)
    return 0;

  return uv__next_timeout(loop);
}

而下面為 uv__next_timeout 的源始碼。

int uv__next_timeout(const uv_loop_t* loop) {
  const struct heap_node* heap_node;
  const uv_timer_t* handle;
  uint64_t diff;

  heap_node = heap_min(timer_heap(loop));
  if (heap_node == NULL)
    return -1; /* block indefinitely */

  handle = container_of(heap_node, uv_timer_t, heap_node);
  if (handle->timeout <= loop->time)
    return 0;

  diff = handle->timeout - loop->time;
  if (diff > INT_MAX)
    diff = INT_MAX;

  return (int) diff;
}
 
8 months ago

socket.io 是一套可以讓我們快速與簡單的建立一套,讓 client 與 server 可以雙向溝通的 Libary,而當我們使用它來建立一個 message server 後,通常在一定的使用量以後,會開始的考慮要加機器來進行擴展,同時間也會建立一台 load balance 的應用來分散請求。

而這時如果你選擇使用AWS ALB (Application Load Balancer)來建立 load balance 你會發現它有個很大的問題,那就是 :

使用非瀏覽器(未處理 cookie )的 client 無法使用 polling 來建立連線

接下來我們將慢慢的來探討原因為何,並且來想想是否有什麼解法呢 ?

本篇文章架構如下 :

  • 原因
  • 解法

原因

為什麼非瀏覽器的 Client 無法使用 polling 來建立連線呢 ?

這裡我們就要先從 socket.io 建立連線的流程開始說啟。

Socket.io 建立連線原理

假設我們在已經在 server 端使用 socket.io 來建立起 message server,然後接下來我們要在 client 端使用socket.io client來建立連線。

var socket = require('socket.io-client')('http://localhost');

而 socket.io client 這裡主要提供了兩種 transport 讓我們 client 與 server 可以互相的傳遞資料 :

  • polling
  • websocket

這兩者差別在於 polling 就是一直用 http 長連線去 server 看看有沒有資料,而 websocket 是有資料時 server 會自動的推送。

那如果我們在建立連線時,沒有設定要用那個,那它會選用那種方式呢 ?

它會先使用 polling 來進行連線與資料傳輸,然後等到確定可以升級 websocket 後,就會將它轉成使用 websocket,然後將原本 polling 的那條給移除。

建立連線的概念圖如下,而這個建立連程的核心就是 :

sid 也就是 session id

使用 AWS ALB Load Balance

接下來我們建立 aws alb 來檔我們的 load Balance 架構如下圖,這裡提醒一下,如果是使用 alb 來建立連線,那當連線是屬於 websocket 時,這條 websocket 連線實際上是,client 到 alb 一條,而 alb 在到 server 一條這樣,而不是 client 直接與 server 建立一條 websocket。

當然有人會說那這樣貧頸不是會在 alb 上,但目前已知,它有提供自動 auto scalling 的機制,這裡就先暫時的相信 aws。

然後接下來你會發現這連線有很高的機率連線無法建立。

當你使用下面的 client 來建立連線時,會發現很高的機率無法建立。

var socket = require('socket.io-client')('http://localhost');

問題就在於 sid

基本上建立一條完整可運行的 socket.io 連線時,至少可能會產生的三次請求。

  1. polling 連線建立,取得 sid。
  2. 升級為 ws (它會帶 sid 發送請求)。
  3. 使用 polling 看看是否有資料 (它會帶 sid 發送請求)。

然後你想想,如果你第一次建立連線時,是在 server A 取得 sid,那你第二、三次使用時,如果連到 server B 或 C 時,server 會知道你要連的 socket 是誰嗎 ? 然後接下來 client 就會以為連線斷了,就自動的幫你斷線了,這就是失敗的原因。

使用 AWS Sticky Session 的問題

然後有用過 aws alb 的人就會說,你可以使用它提供的 sticky session 功能,它可以讓某個 client 在一定的時間內的請求,都發送到同一台 server 上。

這樣應該是可以解決問題沒錯。

但是筆者那時一直試怎麼樣都無法成功建立。

後來發現,問題是出在 :

aws sticky session 是使用 cookie 來處理

而筆者是用 nodejs 加上 socket.io client 來建立連線,因此如果沒有像瀏覽器一樣的幫我們自動處理 cookie, 那這個 sticky session 就會失效。

解法

事實上從上面的原因探討可以得知問題的核心在於兩點 :

  1. polling 建立連線與請求資料,都會發送多條 http,並且用 sid 來標示這條連線,所以如果發送到沒有 sid 的那台 server 就會失敗。

  2. aws sticky session 在非瀏覽器情況(未處理 cookie) 時會失效。

這我們就來想一想假設我們真的需要支援非瀏覽器情況要如何解決呢 ?

筆者這裡有想到幾種解法,然後分為以下兩類。

第一類 : 還沒有用戶在開始使用時

1. 強制讓 Client 端使用 Websocket 連線

首先第一個方法,就是直接指定 client 端直接強制使用 websocket 連線,socket.io client 強制使用的方法如下 :

var socket = require('socket.io-client')('http://localhost',{
  'transports': ['websocket']
});

這樣在建立連線時它就直接使用 websocket 來建立,而由於 websocket 屬於永久連線,也就是說一但你在 server A 建立完連線後,你接下來傳送的請求,都是一定是透過那台 server 來進行處理,因此就不會有上述的問題。

2. 讓 Client 處理 Cookie

假設如果你的 client 或 server 其中一方沒有辦法提供 websocket 協議,而你真的只能使用 polling 來處理,那你就需要想辦法讓 client 處理 cookie。

處理原理如下:

  1. server 在 http 表頭設定 set-cookie 表頭
  2. application 中如果收到 response 中有 set-cookie 就將它取出並存成某個暫存檔。
  3. 每當 application 要發送 http 請求時,就自動的將它帶到 header 中。

事實上,上面的過程就是瀏覽器運行 cookie 的原理,而我們就只是實作一次。

第二類 : 已經有用戶開始使用且無法強制所有人更新

1. 不要使用 AWS ALB

不要使用 aws alb 來處理 load Balance,而選用其它的應用如 nginx,因為它有提到 ip hash 的分配演算法,它可以讓同一個 ip 都打到同一台。

但是這種方案就有幾個麻煩處 :

  • 你要自已處理 nginx 的 HA 機制。
  • 你需要注意 nginx 單機的最大連線量 (也就是連線貧頸會出現在 nginx 這台)

別問我 alb 如何處理貧頸,反正官網說可以自動 scalling。

2. 使用 AWS ALB 分流 Websocket 與 Polling

但這種情況有個假設,那就的是 polling 的使用量不多。

這種情況的做法順序如下 :

  1. 將之後新出版本的 client 都強制使用 websocket。
  2. 在 alb 將 websocket 與 polling 進行分流 (alb 可以根據 path 或 header 分流)。
  3. 等到大部份的用啟都慢慢的升級到新版本後,那 polling 的那可以慢慢的下掉。

架構圖如下 :

但是這裡要注意,你的 polling 用的 server 只能有一台,這也是為什麼要假設 polling 使用量不多,因為它無法擴展。

結論

最後來說說感想。

socket.io client 使用了 polling 與 websocket 方法來幫助我們建立與 client 與 server 的即時溝通,即時不支援 websocket 它也可以用 polling 來處理,但是我覺得這同時間也是雙刃劍,因為這代表你在做任何事情時,你都要考慮兩種情況是否可以運行。

最後筆者建議,真的準備要開始使用 socketio 的人並且要支援不同 client 的人,請直接指定使用 websocket,這樣真的會省下不少麻煩,現階段大部份的 client 應該是都有提供 websocket 的處理,沒有就叫他滾 !

參考資料 Socket.io 建立連線實際過程

它的建立連線實際過程如下。

以下看到的資訊都是使用 socket.io 所提供的 debug log 所取得,開啟方式如下。

DEBUG=* node xxxx.js

首先他會發送以下的 url 來請求建立 socket 連線。

xhr open 
GET:http://localhost:8080/socket.io/EIO=3&transport=polling&t=MckJUI4&b64=1

接下來它會收到一段回應,其它他包含了以下幾個資訊 :

  • sid : session id,接下 polling 的 http 請求都會帶它。
  • uprades : 表示可以升級為 websocket。
  • pingInterval : 每幾 ms 會發送一次 ping packet,用來確定這條連線還存在。
  • pingTimeout : 如果超過此 ms 沒有收到 pong,這代表此連線已斷。
polling got data 

{
  "sid":"Va6Nnm8ia_8ioOAvAAAA",
  "upgrades":["websocket"],
  "pingInterval":25000,
  "pingTimeout":5000
}

然後執行到了這裡基本上這條連線的 socket 就算完成了。

socket receive: 
type "open", data "{"sid":"Va6Nnm8ia_8ioOAvAAAA","upgrades":["websocket"],"pingInterval":25000,"pingTimeout":5000}"

socket open

然後接下來它會嘗試的進行升級,它事實上會發一個 ping 給 server。

engine.io-client:socket starting upgrade probes +1ms
engine.io-client:socket probing transport "websocket"
engine.io-client:socket creating transport "websocket"
engine.io-client:socket probe transport "websocket" opened
ws://localhost:8080/socket.io/?EIO=3&transport=websocket&sid=Va6Nnm8ia_8ioOAvAAAA

但是注意,在升級的中間,polling 還是會繼續的去 server 看看有沒有資料。

engine.io-client:polling-xhr xhr poll
  engine.io-client:polling-xhr xhr open GET: http://localhost:8080/socket.io/?EIO=3&transport=polling&t=MckJUJ3&b64=1&sid=Va6Nnm8ia_8ioOAvAAAA
  engine.io-client:polling-xhr xhr data null

然後接下偵測到 server 有回收到升級用的 pong 回來,因此正式的將 transport 轉換成 websocket,並且將 polling 關掉。到了這時基本上 websocket 連線就就算正式的完成了。

engine.io-client:socket probe transport "websocket" pong
engine.io-client:socket pausing current transport "polling"
engine.io-client:socket changing transport and sending upgrade packet
engine.io-client:socket setting transport websocket +0ms
engine.io-client:socket clearing existing transport polling
 
9 months ago

Nodejs 出來時它的官網寫這以下的描述 :

Node.js is a JavaScript runtime built on Chrome’s V8 JavaScript engine. Node.js uses an event-driven, non-blocking I/O model that makes it lightweight and efficient. Node.js’ package ecosystem, npm, is the largest ecosystem of open source libraries in the world.

簡而言之 Nodejs 是運行在 V8 javascript 引擎,並且使用 Event driven 與 non-blocking I/O 模式所建立出來的東東。

而這裡我們就要深入的來理解 Nodejs 的運行機制。

  • Nodejs 核心設計 - 非阻塞 I/O 模式
  • Nodejs 架構與運行
  • Nodejs 為什麼需要使用 thread ?

Nodejs 核心設計 - 非阻塞 I/O 模式

上面介紹文有提到,它是以 非阻塞 non-blocking I/O 模式所建立出來的,但為什麼它要選用非阻塞 I/O 呢 ?

它想解決什麼問題呢 ?

它最原始想解決的就是 :

阻塞 I/O 模式在大併發請求下的貧頸。

首先我們先來看看傳統的阻塞 blocking I/O 模式問題

阻塞 I/O (Blocking I/O BIO) 伺服器的運行

首先我們先說明一下 I/O 這東西,I/O 是指輸入與輸出,只要是與外部記憶體或設置的溝通都算是 I/O 操作,像進行 http 請求或是讀檔案這種,都算是 I/O 操作。

而所謂的阻塞 I/O就是指,當執行 I/O 的操作會阻塞,也就是直到操作完成後,才會執行下一段指令,更準備的說法是,阻塞就是指這個 thread 或 process 無法處理其它事情,就算它 CPU 閒閒的也是一樣。

下面為一段模擬碼,process 或 thread 會在執行完 socket.read() 取得完資料後,才會執行下一段,這就是阻塞 I/O,而大部份的 I/O 在沒有特別處理的話,都是阻塞 I/O。

// 直到讀取完資料後,才會執行下一段。
var data = socket.read();

console.log(data);

而傳統阻塞 I/O 伺服器在收到一個 http(I/O) 後,每一個 http 會開啟一個 thread 或 process 來處,

為什麼呢 ?

因為每當建立一個連線,一定需要一個 process 來監聽 socket,然後那個 process 就阻塞住。如下程式碼,這一段在建立連線前,就需要先執行,而等到資料進來時,才會往下做。

request = socket.read(); // 這裡會阻塞住
doSomething(request); //有資料時才執行

因此大部份的傳統阻塞 I/O 伺服器在收到一個 http 請求後,都會開啟一個 thread 或 process 來進行處理,因為這樣就不會卡住了。

阻塞 I/O 伺服器的問題

上面有提到傳統的阻塞 I/O 伺服器每收到一個 http 請求就會開啟一個 thread 來運行。

這就是問題。

因為 thread 是很貴的資源

主要有以下的原因 :

  • 在 linux 系統下 thread 本質就是 process 創建和銷毀都非常的耗成本。
  • 它會暫用不少的 memory。
  • thread 的切換上下文成本很高。

所以當如果連線數來個十萬或上百萬的,那麼阻塞式 I/O 一定會倒給你看。

而這也是為什麼 Nodejs 要以非阻塞 I/O (Non-Blocking)為核心來進行設計。

非阻塞 I/O 模式 (Non-Blocking I/O NIO)

由於阻塞 I/O 有以下所提到的問題,因此後來就發展出所謂的非阻塞 I/O 模式 (Non-Blocking I/O NIO) 模式,他想完成的事情如下。

而實作 NIO 的設計模式有以下兩個 :

  • Reactor
  • proactor

我們此篇的主軸為Reactor,主要的原因為 Nodejs 主要就是使用 Reactor 來建立它的架構。( proactor 會開另一篇 )

它主要的概念圖就是咱們所謂的 event loop 機制,如下圖 :

簡單的說它有一個 Event loop 會一直不斷的去 Event Queue 中 check 是否有 I/O 事件,如果有的話就將它丟到指定的 handler 去,如下概念碼。

while(true){

    events = sockets.fetchIOEvents();
    
    for (var i=0; i < events.length; i++){
        if(events[0].type === 'write'){
            writeHandler(event[0].data);
        }
        
        if(events[0].type === 'read'){
            readHandler(event[0].data);
        }
        
        if(events[0].type === 'accept'){
            acceptHandler(event[0].dadta);
        }
    }
}

但這裡有問題想問問。

為什麼可以取得到 I/O 事件呢 ?

上面不是有提到要從 socket 中取得資料,需要使用 linux 底層的 socket.read() 阻塞方法,那為什麼概念碼的 fetchIOEvents 可以取得到所有 socket 的事件呢 ? 不是一個 process 只能監聽一個 socket 嗎 ?

主要的答案在於 :

多路複用( Multiplexing ) 技術,而各系統的實作為 epoll(linux)、kqueue(Mac)、IOCP(Window)

這幾個方法的功用就是,它們可以幫我們監控所有 socket 的 I/O 操作,當某些 socket 有事件產生時(ex. 有資料進來時)會自動的將它相關資料推送到一個 event queue 中,然後你可以使用它提供的方法,來取得事件相關資料。

我們這裡以 epoll 來說明它的使用方法,基本上它提供三個方法。

int epoll_create(int size); 
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); 
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);
  • epoll_create : 它就是建立一個 epoll 對像,然後它的 size 就是 kernal 保証可以監控的最大 file descitpor 數。
  • epoll_ctl : 它就是將 socket 加入到 epoll 的監控中的方法。
  • epoll_wait : 它在給定的 timeout 時間內,如果監控的 socket有產生事件,則會返回事件。

下面為我們使用它的範例碼,這個範例的功用就是讓 epoll 監聽你有註冊的 socket,然後當有事件產生時,就可能從 epoll_wait 取得相對應的 socket 與事件,最後再將此事件執行到對應的 event handler。

struct events[10];

// 建立一個 epoll 用 file descriptor
epollfd = epoll_create();

// 註冊讓 epoll 監聽某 socket 的 EPOLLIN 事件
epoll_ctl(epollfd, EPOLL_CTL_ADD, socket, EPOLLIN);

while(true){

    // 如果 epoll queue 中有事件產生,則會回傳產生事件的 socket 與 events。
    have_events_fds = epoll_wait( epollfd, events, MAX_EVENTS, -1 );

    // 讀取每個有產生事件的 socket,並執行對應的 eventHandler。
    for(int i=0; i < have_events_fds; i++){
        eventHandler(events[n]);
    }
}

阻塞 I/O 與非阻塞 I/O 的比較圖

這章節最後,我們來看看阻塞 I/O 與非阻塞 I/O 的比較圖。

阻塞 : 監控 socket,然後就開始卡住整個 process,直到有資料進來後才結束。
非阻塞 : 不斷的去問 socket 有沒有資料。

問個問題,非阻塞不斷的去尋問有沒有資料不會很耗資源嗎 ?

嗯對會,所以你實際上看 libuv 程式碼 event loop 這個地方,你會發現注意到,它事實上有給一個 timeout 參數,它會根據某些條件阻塞在那裡,不會一直重複的去問。

while(true){
……

timeout = 0;
    if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT)
      timeout = uv_backend_timeout(loop);

    uv__io_poll(loop, timeout); // 這裡

……
}

Nodejs 架構與基本運行

Nodejs 的基本組合與運行如下。其中 libuv 就是實現非阻塞 I/O 的核心庫,它讓我們可以跨平台的實現非阻塞 I/O。

根據上圖如果有一段程式碼執行下去,那它的運行流程如下 :

  1. 使用 V8 引擎解析 javascript 語法。
  2. 解析後呼叫對應對 node C++ 程式碼。
  3. 將所有同步的程式碼運行完。
  4. libuv 建立起 event loop 並且不斷的去輪詢 event queue 來執行那些可以呼叫的異步操作 callback。

我們簡單的看一下範例

假設我們有一段程式碼如下 :

console.log('Hi');

setTimeout(() => console.log('fuck u'), 0);

console.log('Mark');

它的運行流程為:

  1. 執行同步程式碼 console.log('Hi')。
  2. 將 setTimeout 事件與 callback 丟到 Event Queue 中。
  3. 執行同步程式碼 console.log('Mark')。
  4. 開啟執行 event loop。
  5. 發現 event queue 中有需要執行的 time 事件,執行 console.log('fuck u')。

所以最後輸出的結果為 :

// Hi

// Mark

// fuck u
這裡問個問題 ~ Nodejs 是單進程的架構,可是為什麼架構圖中最後有 worker thread 的東西呢 ?

這就是我們接下來要章節要說明的東西。

Nodejs 為什麼需要使用 thread ?

根據官方文件,可以知道,有以下幾個東西需要使用 thread 來處理。

首先來說說 cpu 密集的這兩個CryptoZlib加密與壓縮套件,為什麼需要丟到 thread 做呢 ? 主要的原因為,基本上正常的運算都是屬於同步程式碼,也就是說會在 event loop 前執行,如果這時運算太花時間,那就代表他會卡住 event loop 運行,那如果是 callback 的呢 ? 它應該是在 event loop 內執行吧 ? 嗯沒錯,但問題就是,當它執行時,它就會卡 event loop,不要忘了 event loop 只是一個 while,然裡面執行的東西還是同步,包含 epoll 操作也是同步且阻塞的。

那接下來看 I/O-intensive 的DNSFileSystem

我們先來說說FileSystem的情況。

首先基本上 I/O 阻塞來源為 :

I/O 阻塞來源 = Network I/O + File I/O

那為什麼在 Nodejs 中,可以做到非阻塞呢 ?

因為他們兩個都有解法可處理。

libuv 解決 I/O 阻塞方法 = Network I/O (epoll) + File I/O (thread)

Network I/O 可以用 epoll 來處理

但重點是為什麼 File I/O 無法用 epoll 來處理呢 ?

目前筆者只知道,如果你將檔案的 file descriptor 註冊到 epoll 中,會發生以下的錯誤:

EPERM The target file fd does not support epoll.

簡單來說就是 epoll 不支援檔案類型的 file descriptor 監控。

這也是為什麼 Nodejs 實際上會偷偷的開幾個 worker thread 來處理 file system 這件事情。

file I/O 可以理解,但為什麼 dns 他不是 network I/O 嗎 ? 那為什麼它還需要 thread 來處理呢。

筆者覺得主要的原因在於,DNS 的操作事實上為 :

Network I/O + File I/O

這或需就是為什麼它需使用 thread 來完成非阻塞 I/O。

不過以上只是推測,不代表是正確答案。

注意 nodejs 只能說架構是非阻塞,但不代表不會阻塞

我們有以下兩個假設:

  • Thread number: 3
  • test.txt 讀檔時間: 2 sec

那你執行以下的程式碼。

const fs = require('fs');

const start = process.hrtime();

for (var i = 1; i <= 3; i++) {
  ((id) => {
    fs.readdir('test.txt', () => {
      let end = process.hrtime(start);
      console.log(util.format('read file %d finished in %ds', id, end[0] + end[1] / 1e9));
    });
  })(i);
}

然後你就會發結果如下,實際上在讀三個檔案時,就已經阻塞了,原因在於我們只有三個 thread,它們都在忙錄,因此第四個才會需要花 4 秒才完成。

read file 1 finished in 2 sec
read file 2 finished in 2 sec
read file 3 finished in 2 sec
read file 4 finished in 4 sec

參考資料


 
10 months ago

這篇文章雖然主題為PHP 的 Web 運行原理,但是比較白話文的說,事實上是想要理解這件事情 :

一個 http 請求進來後,php 到底是如何運行呢 ?

要理解這件事情,有個最基本的觀念要先理解,那就是下面這段指令,它到底是如何運行的。

php index.php

然後接下來才能在理解 Web 是如何用 php 來處理。

這篇文章將分為以下幾個章節 :

  • 執行 php index.php 它是如何運行的呢 ?
  • 三種用 PHP 來處理 HTTP 的模式
  • Web PHP 應用組合與問題

執行 php index.php 它是如何運行的呢 ?

假設我們在 Terminal 執行了如下的指令。

php index.php

那實際上它的運行流程會如下圖,而這張圖也代表 PHP 的基本運行架構。

  • SAPI ( Server Application Programming Interface ) : 它就是一個應用環境PHP 核心的一個 Interface,會有這層主要的原因在於,不同的應用環境,例如命令行環境(就是在 Terminal 執行 php) 或 Web 環境都需要不同的 PHP 環境配置,如果沒有這一層就代表 PHP 本身要針對不同的環境來考慮設計兼容,這也是為什麼會有 SAPI 的目的。
  • main : 它是 php 所有操作的整合者。
  • Zend 引擎 : 它就是將咱們編寫的 PHP 程式碼解釋成可以執行的 opcode 碼,其中 PHP7 與 PHP5 有速度上的飛升原因就在於此,PHP7 大幅度的優化了 Zend 引擎。
  • Extension : 它是 PHP 內核所提供的一套擴充 PHP 功能的方式,大部份都是使用 C/C++ 所撰寫,基本上可以分為 PHP extension 與 Zend extension。

三種用 PHP 來處理 HTTP 的模式

那如果改成 Web 情況下,上面那張圖會變成什麼樣子呢 ? 如下圖,就只是將 Terminal 那改成 Web Server。

然後 Web 模式基本上可分為三種模式 :

  1. 模組模式
  2. CGI 模式 (這只是過渡期,現在沒啥在用)
  3. Fast-CGI 模式

Module 模式

在早期開發 PHP 時,大部份的人應該都用過 LAMP 這東西,它就是 Linux + Apache + MySQL + PHP 這些東組合。

然後在通常會在 Apache Web Server 上設置一段指令 :

LoadModule php5(or 7)_module modules/mod_php5.so

這一段就是讓 Apache 把 php 當成一個模塊來處理。

在種情況下,一個 http 請求它是如何的運行呢 ?

運行模式下圖 :

  1. 瀏覽器發送一個 http 請求到 Apache Web Server。
  2. Apache 收到以後,如果發現是 .php 的請求,則會使用 php5_module 來解析 php。
  3. php5_module 接下來會將 php 代碼丟到 sapi 來進行一些針對 Apache 的環境設定 然後最後在由 zedn 來處理這段程式碼。

但這裡有個問題。

那假如我不用 Apache 呢 ?

這就是為什麼會有下面這個模式的原因。

CGI 模式

雖然 LAMP 方案在當時很流行,但是並不是每個人都想使用 Apache,而且比較正確的說法,應該是說,不是所有的 Web Server 都有建立 PHP 模組,那這種情況下要怎麼辦呢 ?

這時就有所謂的CGI 協議

CGI 定義了讓 Web Server 與請求處理程序(ex. php)溝通的標準。

它的運行流程如下圖 :

  1. 瀏覽器發送一個 http 請求到 Web Server
  2. Web Server 產生出一個已實作 CGI 協議的 child process (ex. php-cgi)
  3. 子進程解析 php.ini 進行初使化環境。
  4. 處理 http 請求,並返回給 Web Server。
  5. 關閉子進程。

這裡有幾點事情要注意一下

首先實作 CGI 協議的 child process,這裡,這句話事實上也代表了不限制任何語言,而 php-cgi 就只是一個使用 php 來實作 CGI 協議的程式,它可以用來解析從 Web Server 傳送過來的 CGI 請求,並且用 php 來處理。

這種模式有什麼缺點呢 ?

上面使用這種 CGI 模式的缺點就在於,它每一個 http 請求就需要 fork 一個 CGI 子進程來處理,然後大量的請求一定會倒。

這也是為什麼之後會出現一個叫Fast-CGI的協議。

Fast-CGI

Fast-CGI它也是個協定,它運作的標準如下 :

  1. 先產生一個 master 進程。
  2. 解析 php.ini 來初使化環境。
  3. 然後預先建立多個 worker 進程。
  4. 當有一個請求時會分配給某個 worker 來處理。
  5. 當 worker 不夠用時,master 會在建立 worker,而當 worker 閒置太多時,master 也會減少 worker。

而這裡就要提到一個新名詞PHP-FPM,它就是根據 Fast-CGI 協議所實作進程管理器。

所以當一個 http 請求進來時,使際上的運作會如下圖 :

  1. 一個 http 請求從瀏覽器送到 Web Server。
  2. Web Server 會將此請求包含旁 Fast-CGI 協議標準,發送給 PHP-FPM 所管理的 Master 進程。
  3. 接下來 Master 進程會將請求在發送給某個 worker 來處理。
  4. 最後 worker 處理完後返回結果,然後在等待接客。

Web PHP 應用組合與問題

根據咱們上述學的模式,基本上現今 PHP Web 的組合大至上可以分為以下兩種 :

  1. Apache + php module
  2. Nginx + php-fpm

在早期的時後,基本上一定是選擇 Nginx + php-fpm 這種類型來開發 PHP Web 應用,主要的原因在於使用 moduel 模式的 PHP Web 它就只是單線程的應用,這也代表一定會有效能壓力,但是之後 Apache 提供了一個叫 MPM (Multi-Processing Module) 就解決了這個問題。

或需有人認為 Nginx 一定比較快,因為它有 Event Loop 機制,可以不塞車,但是問題是處理 PHP 的地方是在另個 process 中和 Nginx Event Loop 也就沒啥過係。

所以目前我也不敢保證說選那個比較好,這方面還需要更深入的探討。

那這種二模式有什麼問題呢 ?

比較大的問題是 :

它每一個 worker 同時間只能處理一個 http 請求

雖然它已經比 CGI 模式下還可以處理更多的請求,但它還是有一定的瓶頸在。

而這就是下一篇文章中要 Swoole 的原因。

參考資料

 
11 months ago

什麼是 Laravel Facade ?

在一般情況咱們如果要使用物件的某個方法可能會寫成如下 :

<?php

$userService = $app->make('UserService');
$userService->createUser();

但是有時後你會看到如下的程式碼 :

<?php

UserService::createUser();

而這就是 Laravel 所提供的 Facade 語法糖,而 Facade 實際上是一種設計模式。

Facade(外觀) 設計模式

Facade 設計模式基本的定義如下 :

定義一個高層級的接口,客戶端只能透過它來與子系統進行溝通。

畫成概念圖大概長的如下,客戶端當要使用某個子系統所提供的功能時,不會直接去使用,而是會透過 Facade 來進行操作。

程式碼範例

假設咱們現在有個功能是用使用 LineSDK 來將訊息推送到 Line 取,然後咱們假設 sdk 的程式碼如下。

<?php

interface IMessage 
{
    public function push();
}


class LineSDK implements IMessage 
{
    public function push()
    {
        var_dump('I push a message to line');
    }
}

然後我們這裡會在寫一個 Facade 來讓我們的系統來使用。

<?php
class MessageFacade
{
    private $sdk; 

    public function __construct(IMessage $sdk)
    {
        $this->sdk = $sdk;
    }

    public function push()
    {
        $this->sdk->push();
    }
}

最後這個時候客戶端想要使用時,就會透過 Facade 來進行發送訊息,如下程式碼。

<?php

// 備註,在 laravel 時這裡嚴格來說會寫成
// $app->make('Imessage')
// 而不會看到 linesdk 這東西
$sdk = new MessageFacade(new LineSDK());
$sdk->push();
這樣有什麼好處 ?

當 sdk 進行修改時,你就不需要修改應用層的也方,只要修改 Facade 就好。

但這樣還是要改啊 ? 只是換個地方改而以 ?

那假設你有十個地方直接使用 SDK 呢 ? 那這樣不就代表你十個地方就要改,而如果使用 Facade 就只有一個地方要修改。

Laravel Facade 是如何實現的呢 ?

就單來說,它就如下圖所示,主要的核心就是在 Facade 這個抽象類別,它裡面會定義一個 php 的 __callStatic 方法,它被執行到的時機為,這個類別中被呼叫靜態方法時,他就會被執行到。

<?php

abstract class Facade {
    public static function __callStatic($method, $args)
    {
        $instance = static::getFacadeAccessor();
        return $instance::$method(...$args);
    }
}
<?php

class MessageFacade extends Facade
{
    protected static function getFacadeAccessor(){
        return new LineSDK(); 
    }
}

class LineSDK 
{
    public function push()
    {
        var_dump('I push a message to line');
    }
}
<?php

include 'Facades/MessageFacade.php';

MessageFacade::push();

Laravel Facade 的爭論

它們的爭論點就是 :

是否要使用 Laravel Facade

這裡大至上會分成兩派 :

使用 Laravel Facade

這一派的程式碼會寫的如下 :

<?php
class Log
{
  
    public function __construct()
    {
    }

    public function send(log): void
    {
        LogService::send(log);
    }  
}

不使用 Laravel Facade

而這一派的程式碼會寫的如下 :

<?php

class Log
{
    private ILogService $logService;
  
    public function __construct(ILogService $logService)
    {
      $this->logService = $logService;
    }

    public function send(log): void
    {
      $this->logService->send(log);
    }  
}

支持與反對的看法

首先支持使用 Facade 這一派的他們提出以下使用後的優點 :

  1. 簡潔的程式碼。
  2. 乾淨的建構子。
  3. 更高的可測試性與彈性。

但相對的反對派提出了一下問題 :

  1. 相依了 Facade,這樣事實上就打破了當初建立 container 來想解決的事情。
  2. 要知道這個類別中有使用那些外部套件,那就只能一個一個找。
  3. IDE 的不友好。

順到說一下,就我各人的看法我比較偏向不使用 Laravel Facade,主要是因為我比較在意一個類別的相依性控制,而每當我需要知道這個類別有啥相依時,不用讓我一個一個去慢慢的找,對我來說有點浪費時間。

順到說一下,在下面的參考資料裡包含了大部份吵架的文章,有興趣的人可以去看看。

備註 : 關於使用 Laravel Facade 有更高的可測試性說明

在 Laravel Facade 中,如果你有一段程式碼直接的使用 Laravel Facade 如下 :

<?php

public function getIndex()
{
    Event::fire('foo', ['name' => 'Dayle']);

    return 'All done!';
}

然後你這時要進行測試時,可以使用shouldReceive的方法來模擬 Event :

<?php

public function testGetIndex()
{
    Event::shouldReceive('fire')->once()->with('foo', ['name' => 'Dayle']);

    $this->call('GET', '/');
}

參考資料

 
11 months ago

什麼是 Laravel Service Provider ?

上一篇文章『PHP Laravel 的 Container 理解』中咱們學習到了 Laravel 的 Container 是一種用來解決依賴與耦合的概念,它建立了一個容器並且在裡面定義好抽像與實際類別的對應,最後就可以自動的進行依賴性注入。如下偽程式碼。

<?php
$containter = require('Container');

// 建立抽象與實體類別的對應
$containter->bind(ILogService, AWSLogServcie::class);

$log = $container->make(Log::class);

$log->send('log....');

其中上面的bind就是可以在這個容器內建立一個抽象類別舉實體類別的對應,也就是說如果後來要實體化有實作
ILogService 的類別,那他就會實體化 AWSLogServcie 出來。

那 Service Provider 是什麼 ?

它就個註冊與管理 Container 內服務的地方。

下面的程式碼為 Laravel 專案的 Service Provider,其中有兩個重要的方法bootregister

  • register : 它就是用來寫 bind 的地方。
  • boot : 它就是當 register 結束以後會執行的方法。
<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Bootstrap any application services.
     *
     * @return void
     */
    public function boot()
    {
        //
    }

    /**
     * Register any application services.
     *
     * @return void
     */
    public function register()
    {
        $this->app->bind(
            PostRepositoryInterface::class,
            PostRepository::class
        );
    }
}
那什麼時後會需要使用 boot ?

boot 這個方法的執行時機為 register 執行完以後,那它什麼時後要用到他呢 ? 我覺得比較好的定義如下 :

在實際上使用這個 service 前,所需要做的前處理。

例如下面的 Laravel 官網授權章節所寫的範例,在要使用 AuthService 前它需要先將一些 policy 先註冊,這時就很適合寫在 boot 這個方法裡面。

Laravel-授權

<?php

namespace App\Providers;

use Illuminate\Contracts\Auth\Access\Gate as GateContract;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;

class AuthServiceProvider extends ServiceProvider
{
    /**
     * 註冊任何應用程式的認證或授權服務。
     *
     * @param  \Illuminate\Contracts\Auth\Access\Gate  $gate
     * @return void
     */
    public function boot(GateContract $gate)
    {
        $this->registerPolicies($gate);

        $gate->define('update-post', function ($user, $post) {
            return $user->id === $post->user_id;
        });
    }
}

或是

<?php
class DatabaseServiceProvider extends ServiceProvider
{
    /**
     * Bootstrap the application events.
     *
     * @return void
     */
    public function boot()
    {
        Model::setConnectionResolver($this->app['db']);

        Model::setEventDispatcher($this->app['events']);
    }
    
    public function register()
    {
        Model::clearBootedModels();
        $this->registerConnectionServices();
        $this->registerEloquentFactory();
        $this->registerQueueableEntityResolver();
    }
}

為什麼要使用 Service Provider 呢 ?

上面我們大概理解了 Service Provider 以後,那接下來我們就來思考一件事情。

為什麼要使用 Service Provider 呢 ?

我們先假設沒有 Service Provider,然後來看看程式碼會變成什麼樣子。

假設我們已經產生了 container,就如下程式碼的 $app 。

<?php
public/index.php

$app = require_once __DIR__.'/../bootstrap/app.php';

$app->bind(IUserService:class, UserESrvice:class);
$app->bind(IMessageService:class, MessageService:class);

事實上現在這樣是還沒什麼問題,那如果是這樣呢 ?

<?php
public/index.php

$app = require_once __DIR__.'/../bootstrap/app.php';

$app->bind(IUserService:class, UserService:class);
$app->bind(IMessageService:class, MessageService:class);

$app->singleton('redis', function ($app) {
     $config = $app->make('config')->get('database.redis', []);
     return new RedisManager($app, Arr::pull($config, 'client', 'predis'), $config);
});

$app->bind('redis.connection', function ($app) {
     return $app['redis']->connection();
});

$app->singleton('cache', function ($app) {
     return new CacheManager($app);
});

$app->singleton('cache.store', function ($app) {
     return $app['cache']->driver();
});

 
$this->app->singleton('db', function ($app) {
     return new DatabaseManager($app, $app['db.factory']);
});
        
$this->app->singleton('db.factory', function ($app) {
     return new ConnectionFactory($app);
});

你可以發現這個檔案已經開發有點腫大,而且這樣在多人開發時,你會發現一直的 merge conflict 。

然後接下來,你可能在使用 db 前還需要設定一下東西,然後這個檔案變的如下。

<?php
public/index.php

$app = require_once __DIR__.'/../bootstrap/app.php';

$app->bind(IUserService:class, UserService:class);
$app->bind(IMessageService:class, MessageService:class);

$app->singleton('redis', function ($app) {
     $config = $app->make('config')->get('database.redis', []);
     return new RedisManager($app, Arr::pull($config, 'client', 'predis'), $config);
});

$app->bind('redis.connection', function ($app) {
     return $app['redis']->connection();
});

$app->singleton('cache', function ($app) {
     return new CacheManager($app);
});

$app->singleton('cache.store', function ($app) {
     return $app['cache']->driver();
});

$this->app->singleton('db', function ($app) {
     return new DatabaseManager($app, $app['db.factory']);
});
        
$this->app->singleton('db.factory', function ($app) {
     return new ConnectionFactory($app);
});

Model::setConnectionResolver($this->app['db']);
Model::setEventDispatcher($this->app['events']);

這時你就會很明顯的注意到有幾項缺點 :

  • 這個檔案太腫大了,每個人都需要修改到他。
  • 這個檔案做太多事情了,要產生 container、要註冊服務、註冊服務的前處理。

也就是因為這些原因,因此 Laravel 就產生了 Service Provider,並且它基本上是會根據模組來產生不同的 Provider,像以上面的範例就可以分拆成 db、cache、redis 等 provider。

某些方面這就違反了 SRP(Single Responsibility Principle)單一責任原則

雖然上述的原則是用在類別或方法上,但是我覺得以概念上來看,也可以用在上面這種狀況。

Laravel Service Provider 流程原始碼分析

接下來這章節我們要來理解一下 Laravel 是什麼時後註冊 Service provider,並且它內部是如何執行。

在 Laravel 中所有一切的源頭就是創建 Container,所以就從這裡開始看。

php artisan serve

首先產生完 containter ($app) 以後,接下來 Laravel 會在實體化 $kernel,它可以說是所有操作的核心模式,然後接下來 handler 它會執行所有 input 進來的東西,而這裡面就有用來處理 service provider 的地方。

#!/usr/bin/env php
<?php

define('LARAVEL_START', microtime(true));

require __DIR__.'/vendor/autoload.php';

$app = require_once __DIR__.'/bootstrap/app.php';

$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);

$status = $kernel->handle(
    $input = new Symfony\Component\Console\Input\ArgvInput,
    new Symfony\Component\Console\Output\ConsoleOutput
);

$kernel->terminate($input, $status);

exit($status);

Kernel 原始碼

這裡在執行 handler 裡面有個我們要注意的東西就是 bootstraps,這東東你可以想成它就是要完成一件事情所需要做的事情,其中我們要看的為 RegisterProviders 這個 bootstrap。

<?php
namespace Illuminate\Foundation\Console;

class Kernel implements KernelContract
{
   /**
     * The bootstrap classes for the application.
     *
     * @var array
     */
    protected $bootstrappers = [
        \Illuminate\Foundation\Bootstrap\LoadEnvironmentVariables::class,
        \Illuminate\Foundation\Bootstrap\LoadConfiguration::class,
        \Illuminate\Foundation\Bootstrap\HandleExceptions::class,
        \Illuminate\Foundation\Bootstrap\RegisterFacades::class,
        \Illuminate\Foundation\Bootstrap\SetRequestForConsole::class,
        \Illuminate\Foundation\Bootstrap\RegisterProviders::class,
        \Illuminate\Foundation\Bootstrap\BootProviders::class,
    ];

   /**
     * Run the console application.
     *
     * @param  \Symfony\Component\Console\Input\InputInterface  $input
     * @param  \Symfony\Component\Console\Output\OutputInterface  $output
     * @return int
     */
    public function handle($input, $output = null)
    {
        try {
            // 執行每個 boostrap
            $this->bootstrap();

            return $this->getArtisan()->run($input, $output);
        } catch (Exception $e) {
            $this->reportException($e);

            $this->renderException($output, $e);

            return 1;
        } catch (Throwable $e) {
            $e = new FatalThrowableError($e);

            $this->reportException($e);

            $this->renderException($output, $e);

            return 1;
        }
    }
    
       /**
     * Bootstrap the application for artisan commands.
     *
     * @return void
     */
    public function bootstrap()
    {
        if (! $this->app->hasBeenBootstrapped()) {
            $this->app->bootstrapWith($this->bootstrappers());
        }

        $this->app->loadDeferredProviders();

        if (! $this->commandsLoaded) {
            $this->commands();

            $this->commandsLoaded = true;
        }
    }
}

RegisterProviders 原始碼

RegisterProviders 就是一個定義好的 boostrap 類別,它主要就是呼叫 container 來註冊 service provider。

<?php

namespace Illuminate\Foundation\Bootstrap;

use Illuminate\Contracts\Foundation\Application;

class RegisterProviders
{
    /**
     * Bootstrap the given application.
     *
     * @param  \Illuminate\Contracts\Foundation\Application  $app
     * @return void
     */
    public function bootstrap(Application $app)
    {
        $app->registerConfiguredProviders();
    }
}

Container registerConfiguredProviders 原始碼

這一段原始碼中,laravel 會先將在 config/app.php 裡面有的 provider 先組合出包含 namespace 的 service provider 陣列,然後最在在丟給 ProviderRepository 的 load 方法來進行讀取。

<?php

    /**
     * Register all of the configured providers.
     *
     * @return void
     */
    public function registerConfiguredProviders()
    {
        $providers = Collection::make($this->config['app.providers'])
                        ->partition(function ($provider) {
                            return Str::startsWith($provider, 'Illuminate\\');
                        });

        // 這裡看起來應該是去拿所有 packet 裡面的 provider
        $providers->splice(1, 0, [$this->make(PackageManifest::class)->providers()]);

        (new ProviderRepository($this, new Filesystem, $this->getCachedServicesPath()))
                    ->load($providers->collapse()->toArray());
    }

ProviderRepository 原始碼

這一段程式碼就是實際執行 service proivder 裡面方法的地方,它的執行流程如下。

  1. 讀取 boostrap/cache/services.php。
  2. 判斷是否重新產生 cache 檔。
  3. 從 cache 檔中判斷每個 servcie provider 是那一類型,然後分別執行。

基本上 cache 檔中 service provider 被分為三種類別,它們的特點如下 :

  • when : 當某個事件被執行的時後,才會執行 service provider。
  • eager : 直接執行 service provider。
  • deferred : 等到要執行 make 前,才會執行 service provider。
<?php
   /**
     * Register the application service providers.
     *
     * @param  array  $providers
     * @return void
     */
    public function load(array $providers)
    {
        // 讀取 boostrap/cache/services.php
        $manifest = $this->loadManifest();

        // 產生 boostrap/cache/services.php
        if ($this->shouldRecompile($manifest, $providers)) {
            $manifest = $this->compileManifest($providers);
        }

        foreach ($manifest['when'] as $provider => $events) {
            $this->registerLoadEvents($provider, $events);
        }

        foreach ($manifest['eager'] as $provider) {
            $this->app->register($provider);
        }

        $this->app->addDeferredServices($manifest['deferred']);
    }

備註

關於 boostrap/cache/services.php

這個檔案基本上組合如下圖,共有分為四個部份,它們的功用如下。

  • provider : 包含所有 config/app.php 裡有註冊的 service providers,基本上它的功用就是用來判斷 cache 檔案要不要進行修。
  • eager : service provider 類型,它會在 load 時執行 service provider。
  • deferred : service provider 類型,它會在 make 時執行 service provider。
  • when : service provider 類型,它會在收到某個事件時執行 service provider。

將 servcie provider 修改為 deferred 時記得砍 cache

Laravel 在判斷直接使用 cache 時有兩個必要條件 :

  • 有 cache 檔。
  • cache 檔的 providers 於 config/app.php 的 providers 是相同的。

所以如果你這時修改了某個 provider 的 deferred 時,依然符合上述兩個條件,因為你只是改變 proivder 的屬性而不是名稱,因此還會繼續使用 cache 檔。

所以這時記得要砍掉 cache 檔讓它進行重建。

參考資料

 
12 months ago

Container 是什麼 ?

Laravel Container 是什麼呢 ? 我們先來理解 Container 容器 是什麼。

容器抽象一點概念是指用來裝東西的載體,向菜籃也算個容器,而在 Laravel 中所代表的意思就是指 :

裡面裝了一堆可以用的服務載體,就叫 Container。

像我們每當要執行 Laravel 時,都會先執行下面這段程式碼,其中 $app 就是我們的 Container,然後接下來會使用 Container 來實體化一些物件,例如 $kernel。

<?php
public/index.php

$app = require_once __DIR__.'/../bootstrap/app.php';

/*
|--------------------------------------------------------------------------
| Run The Application
|--------------------------------------------------------------------------
|
| Once we have the application, we can handle the incoming request
| through the kernel, and send the associated response back to
| the client's browser allowing them to enjoy the creative
| and wonderful application we have prepared for them.
|
*/

$kernel = $app->make(Illuminate\Contracts\Http\Kernel::class);

$response = $kernel->handle(
    $request = Illuminate\Http\Request::capture()
);

$response->send();

$kernel->terminate($request, $response);

為什麼要使用 Container ?

上面我們理解 Container 是做什麼用以後,接下來我們要來想想一件事情。

為什麼 Laravel 要使用 Container 呢,為什麼上面的要實體化 $knernel 時,不使用 new Knernel() 這種實體化的方式呢 ?

因為它想解決依賴與耦合。

這就是 Conainter 想解決的事情。

(高)依賴與耦合

高依賴與耦合 : 程式碼中綁死了某個模組,如下面程式碼綁死了 Log Service。

假設有一段程式碼如下 :

<?php

class Log
{
    public function send(log): void
    {
      $awsLogService = new AWSLogService();
      $awsLogService->send(log);
    }  
}

class AWSLogService
{
    public function send(log): void
    {
       ....
    }
}

但假設今天我們要將 Log 改傳到 GCP ( Google 雲端 ),那我們程式碼要修改成如下 :

<?php

class Log
{
    public function send(log): void
    {
      //$awsLogService = new AWSLogService();
      //$awsLogService->send(log);
      
      $gcpLogService = new GCPLogService();
      $gcpLogService->send(log);
    }  
}

class GCPLogService
{
    public function send(log): void
    {
       ....
    }
}

// 使用

$log = new Log();
$log->send('log.....');

從上面程式碼中,我們可以注意到我們沒當要換個服務時,都需要修改程式碼,並且這裡還有一個缺點,你要如何做單元測試 ? 程式碼裡面完全的綁死了 AWSLogService 或是 GCPLogService,沒有地方可以給我們進行替換,沒辦法替換就代表我們在做測試時,只能真的將資料丟到 AWS 或 GCP。

(低) 依賴與耦合

然後由於有上面說的缺點,因此會將程式碼改成如下。基本上就是將 LogService 改成由使用這個物件時來決定是用選擇 AWS 還是 GCP,並且這兩個 service 都實作同一個 ILogService 的 interface。

<?php

class Log
{
    private ILogService $logService;
  
    public function __construct(ILogService $logService)
    {
      $this->logService = $logService;
    }

    public function send(log): void
    {
      $this->logService->send(log);
    }  
}

class GCPLogService implements ILogService
{
    public function send(log): void
    {
       ....
    }
}

class AWSLogService implements ILogService
{
    public function send(log): void
    {
       ....
    }
}

interface ILogService 
{
    public function send();
}

// 使用
$log = new Log(new AWSLogServcie());
$log->send('log......');

好接下來在拉回主題。

為什麼要使用 Laravel Container ?

上面我們的範例程式碼最後要執行時,會如下 :

<?php

$log = new Log(new AWSLogServcie());
$log->send('log......');

這樣事實上沒什麼問題。

但是如果這一段程式碼有很多地方使用怎麼辦 ? 有沒有可能系統中統一都要使用 AWS 的,但是其中一個地方忘了改,而不小心使用到 GCP ? 嗯這是有可能發生的。

還有另一個問題,這一段程式碼本身就依賴了Log這個類別,這樣事實上還是沒有解決依賴的問題。

因此 Laravel 建立了 Container,並且會在開啟服務時,先行註冊好,例如下面偽代碼。只要在這個 conatiner 內部的 class 都會根據它註冊好的東西來進行處理。

<?php

$containter = require('Container');

// 它會在這一段先將 ILogService 綁定好,如果 construct 中有使用到它的,將會將它實體化為 // AWSLogServcie。 
$containter->bind(ILogService, AWSLogServcie::class);

// 實體化 Log 類別。
$log = $container->make(Log::class);

$log->send('log....');
那有兩個類別,它們內部有使用相同抽像類別,但這時它們實際上要使用不同的類別要怎麼處理呢 ?

Laravel 官網有給個範例如下,Photo 與 Video 都有使用到 Filesystem 這個抽象類別,但它們實際上要使用不一樣的類別,則可以使用如下的方法來進行指定。

<?php

$this->app->when(PhotoController::class)
          ->needs(Filesystem::class)
          ->give(function () {
              return Storage::disk('local');
          });

$this->app->when(VideoController::class)
          ->needs(Filesystem::class)
          ->give(function () {
              return Storage::disk('s3');
          });

Contextual Bindings (上下文绑定)

Laravel 如何建立 Container ?

這裡我們就要開始來研究一下 Laravel Container 的原始碼。

首先最一開始是這裡,它會實體化一個 $app conatiner。

<?php

$app = new Illuminate\Foundation\Application(
    $_ENV['APP_BASE_PATH'] ?? dirname(__DIR__)
);

接下來我們來看一下 Illuminate\Foundation\Application 的程式碼。這裡可以知道 Application 繼承了 Container 這個類別。

<?php

class Application extends Container implements ApplicationContract, HttpKernelInterface
{
    public function __construct($basePath = null)
    {
        if ($basePath) {
            $this->setBasePath($basePath);
        }
        $this->registerBaseBindings();
        $this->registerBaseServiceProviders();
        $this->registerCoreContainerAliases();
    }

}

laravel5.7-container

然後 Container 類別中,有兩個方法是重點那就是bindmake

bind

建立抽象與實體的綁定表

bind 使用方式

基本上分為以下四種 :

<?php

// 1. 類別綁定 clouse
App::bind('UserRepository', function()
{
    return new AWSUserRepository;
});

// 2. 抽像類別綁定實際類別
App::bind('UserRepositoryInterface', 'DbUserRepository');

// 3. 實際類別綁定
APP::bind('UserRepository')

// 4. singleton 綁定
App::singleton('UserRepository', function()
{
    return new AWSUserRepository;
});
原始碼解析

laravel5.7-container-bind

<?php

/**
     * Register a binding with the container.
     *
     * @param  string  $abstract
     * @param  \Closure|string|null  $concrete
     * @param  bool  $shared
     * @return void
     */
    public function bind($abstract, $concrete = null, $shared = false)
    {
        $this->dropStaleInstances($abstract);
       
        // 例如這種 APP::bind('UserRepository') 的註冊,就會執行這一段。
        if (is_null($concrete)) {
            $concrete = $abstract;
        }
        
        // 如果是上面那種情況或是沒有 Closure,就直接產生一個 Closure。
        if (! $concrete instanceof Closure) {
            $concrete = $this->getClosure($abstract, $concrete);
        }

        // 綁定,就是用一個 HashTable 來建立綁定對應。
        $this->bindings[$abstract] = compact('concrete', 'shared');
        
        // 如果此類別已被 resolve 則進行 rebound。
        if ($this->resolved($abstract)) {
            $this->rebound($abstract);
        }
    }
    
        /**
     * Get the Closure to be used when building a type.
     *
     * @param  string  $abstract
     * @param  string  $concrete
     * @return \Closure
     */
    protected function getClosure($abstract, $concrete)
    {
        return function ($container, $parameters = []) use ($abstract, $concrete) {
            if ($abstract == $concrete) {
                return $container->build($concrete);
            }

            return $container->make($concrete, $parameters);
        };
    }

make

產生實際的實體物件

使用方法
<?php

$app->make('UserRepository');
原始碼解析

laravel5.7-container-make
laravel5.7-containier-resolve

<?php

 /**
     * Resolve the given type from the container.
     *
     * @param  string  $abstract
     * @param  array  $parameters
     * @return mixed
     */
    public function make($abstract, array $parameters = [])
    {
        return $this->resolve($abstract, $parameters);
    }


 /**
     * Resolve the given type from the container.
     *
     * @param  string  $abstract
     * @param  array  $parameters
     * @return mixed
     */
    protected function resolve($abstract, $parameters = [])
    {
        $abstract = $this->getAlias($abstract);

        $needsContextualBuild = ! empty($parameters) || ! is_null(
            $this->getContextualConcrete($abstract)
        );


        // 如果此抽象類別已經實體化了,且 construct 沒使用其它外部注入,則回傳此物件。
        if (isset($this->instances[$abstract]) && ! $needsContextualBuild)
        {
            return $this->instances[$abstract];
        }

        $this->with[] = $parameters;

        // 這個地方有兩種情況
        // 1. 從抽象類別的建構式取出有使用的類別,並回傳。
        // 2. 如果沒有,則從 bindings 中找出對應的實體類別。
        $concrete = $this->getConcrete($abstract);
        
        // isBuildable => true
        // 1. $concrete 與 $abstract 為相同 (也就直接使用類別來綁定)
        // 
        // isBuildable => false
        // 1. 直接使用介面。 
        // 2. $abstract 本身內部還有依賴的外部套件。
        if ($this->isBuildable($concrete, $abstract)) {
            $object = $this->build($concrete);
        } else {
            $object = $this->make($concrete);
        }
        
        // 不太懂
        foreach ($this->getExtenders($abstract) as $extender) {
            $object = $extender($object, $this);
        }

        // 註冊的類別如果被指定為 singleton 就要 cache 它。
        if ($this->isShared($abstract) && ! $needsContextualBuild) {
            $this->instances[$abstract] = $object;
        }

        $this->fireResolvingCallbacks($abstract, $object);

        // 記錄那個類別已經被 resolve
        $this->resolved[$abstract] = true;

        array_pop($this->with);

        return $object;
    }

    /**
     * Determine if the given concrete is buildable.
     *
     * @param  mixed   $concrete
     * @param  string  $abstract
     * @return bool
     */
    protected function isBuildable($concrete, $abstract)
    {
        return $concrete === $abstract || $concrete instanceof Closure;
    }

參考資料

 
12 months ago

在之前筆者的這篇文章中:

一個基於 AWS Elasticsearch 的用戶行為 log 系統建立

在們學習了如何使用 AWS 的相關工具來建立一個用戶行為的 LOG 分析系統。

但是這篇文章中所提到的架構有個問題。

這個版本有什麼問題呢 ?

那就是在某些情況下它會一直噴以下錯誤 :

ServiceUnavailableException: Slow down.

那為什麼會一直噴 Slow down 呢 ?

會發生這個的原因在於,我們有採到 aws firehose 的限制,如下:
Amazon Kinesis Data Firehose 有以下限制。

如果将 Direct PUT 配置为数据源,每个 Kinesis Data Firehose 传输流 都会受以下限制的约束:

* 对于 美国东部(弗吉尼亚北部)、美国西部(俄勒冈) 和 欧洲(爱尔兰):5,000 条记录/秒;2,000 个事务/秒;5 MB/秒。
* 对于 欧洲 (巴黎)、亚太地区(孟买)、美国东部(俄亥俄州)、欧洲(法兰克福)、南美洲(圣保罗)、亚太区域(首尔)、欧洲 (伦敦)、亚太区域(东京)、美国西部(加利福尼亚北部)、亚太区域(新加坡)、亚太区域(悉尼) 和 加拿大 (中部):1000 条记录/秒;1000 个事务/秒;1 MB/秒。


! 注意
当 Kinesis Data Streams 配置为数据源时,此限制不适用,Kinesis Data Firehose 可无限扩展和缩小。

來源 : 官網

加強版

原本的版本如下圖。

然後我們會將它修改成如下圖,就是在資料源與 firehose 之間多增加了 data stream。

使用 AWS data stream 有以下幾個好處 :

  • 可以自由的調整傳輸限制。(這樣就可以解決上述的問題)
  • 未來如果有其它單位想要接受這個資料源,那只要請對方接上這個 data stream,它就可以受到資料了。

AWS Kinesis Data Stream 申請

事實上就只有兩個東西要填寫Stream NameShard Number

其中這裡簡單的說一下 Shard 概念。

Stream Shard (碎片)

在 AWS kinesis data stream 中有個 shard 的概念,它就是指 stream 的子集合。

每條 stream 都是由 1 至 n 個 shard 所組合成,這樣有幾個好處 :

  • 在傳輸資料給 stream 時,可以將傳輸量平均的分散給不同 shard,這樣可以避免觸碰到每個 shard 的傳輸限制。
  • 你可以指定那一些類型的資料傳輸到 A Shard,那些類型的資料傳輸到 B Shard,這樣有助於你放便管理資料流。

Shard 的限制

上面有提到每個 stream 都有傳輸限制,這裡我們就來看一下它的限制有那些。

以下從 Aws 官網擷取 :

  • 單一碎片每秒可擷取多達 1 MiB 的資料 (包括分割區索引鍵) 或每秒寫入 1,000 筆記錄。同樣地,如果您將串流擴展到 5,000 個碎片,串流每秒即可擷取多達 5 GiB 或每秒 500 萬筆記錄。若您需要更多的擷取容量,可以使用 AWS Management Console 或 UpdateShardCount API 輕鬆擴展串流中的碎片數目。
  • GetRecords 每次呼叫可從單一碎片擷取最多 10 MiB 的資料,每次呼叫最多 10,000 筆記錄。每呼叫一次 GetRecords 即計為一筆讀取交易。
  • 每個碎片每秒可支援最多 5 筆讀取交易。每筆讀取交易可提供多達 10,000 筆記錄,每筆交易的上限為 10 MiB。
  • 每個碎片透過 GetRecords 每秒可支援最多 2 MiB 的總資料讀取速率。如果呼叫 GetRecords 傳回 10 MiB,在接下來的 5 秒內發出的後續呼叫將擲回例外狀況。
如何將資料傳輸到指定的 Shard

下面為一段 nodejs 寫入資料到 stream 的範例碼,其中注意到PartitionKey這個東東,它就是可以幫助你指定到想要的 Shard。

'use strict';

const AWS = require('aws-sdk');
const streamName = process.env['AWS_KINESIS_STREAM'];
const uuidv1 = require('uuid/v1');

const kinesis = new AWS.Kinesis({region: process.env['AWS_KINESIS_REGION']});

module.exports = {
  putRecord: (packet) => {
    return new Promise((resolve, reject) => {
    // 多加換行符號是因為這樣才能在 aws athena 進行解析

      const recordParams = {
        Data: JSON.stringify(packet) + '\n',
        StreamName: streamName,
        PartitionKey: uuidv1()
      };

      kinesis.putRecord(recordParams, (err) => {
        if (err) {
          reject(err);
        }
        resolve();
      });
    });
  }
};

PartitionKey基本上就是用來讓 AWS kinesis 來決定你要去那一個 Shard。

假設你的文件 A 傳輸時 PartitionKey 設為 GroupA 這個文字,那它就會跑到某個 Shard A 去,如果這時再傳輸個文件 B 並且 PartitionKey 也設定為 GroupA,那這一份文件也會傳輸到 Shard A。

所以當你想將同一類型的文件,都傳輸到同一個 Shard 時,記得將 PartitionKey 設為相同。

但如果是想將它平均分散到每一個 Shard 呢 ?

事實上有兩個方法,首先第一種方法就是每一丟資料時,先去抓這個 stream 看它有幾個 shards,然後再根據它的數量,來隨機產生個數字,例如有 4 個 shards 那你每次丟資料時,就從 1 ~ 4 隨機產生一個數字,然後再將它設到 PartitionKey 中,那這樣基本上就會平均分配。

而另一種方法就是每一次的 PartitionKey 都使用 uid 來設定,這樣也可以將他平均的進行分配。

不過我是比較建議用第二種,因為第一種每一次都要去 AWS 那抓取 stream 裡的 shards 大小,這樣太耗時間了。

參考資料