深入理解GatewayWorker框架

序言

本文只是結合GatewayWorker和Workerman的官方文檔和源碼,深入了解執行過程。以便更深入的了解并使用

GatewayWorker基于Workerman開發的一個項目框架。Register進程負責保存Gateway進程和BusinessWorker進程的地址,建立兩者的連接。Gateway進程負責維持客戶端連接,并轉發客戶端的數據給BusinessWorker進程處理,BusinessWorker進程負責處理實際的業務邏輯(默認調用Events.php處理業務),并將結果推送給對應的客戶端。Register、Gateway、BusinessWorker進程都是繼承Worker類實現各自的功能,所以了解GatewayWorker框架的內部執行過程,需要優先理解Worker的內容

GatewayWorker目錄結構

├── Applications // 這里是所有開發者應用項目
│   └── YourApp  // 其中一個項目目錄,目錄名可以自定義
│       ├── Events.php // 開發者只需要關注這個文件
│       ├── start_gateway.php // gateway進程啟動腳本,包括端口        號等設置
│       ├── start_businessworker.php // businessWorker進程啟動  腳本
│       └── start_register.php // 注冊服務啟動腳本
│
├── start.php // 全局啟動腳本,此腳本會依次加載Applications/項目/start_*.php啟動腳本
│
└── vendor    // GatewayWorker框架和Workerman框架源碼目  錄,此目錄開發者不用關心

start.php 為啟動腳本,在該腳本中,統一加載start_gateway.php start_businessworker.php start_register.php進程腳本,最后通過Worker::runAll();運行所有服務。

工作原理

1、Register、Gateway、BusinessWorker進程啟動
2、Gateway、BusinessWorker進程啟動后向Register服務進程發起長連接注冊自己
3、Register服務收到Gateway的注冊后,把所有Gateway的通訊地址保存在內存中
4、Register服務收到BusinessWorker的注冊后,把內存中所有的Gateway的通訊地址發給BusinessWorker
5、BusinessWorker進程得到所有的Gateway內部通訊地址后嘗試連接Gateway
6、如果運行過程中有新的Gateway服務注冊到Register(一般是分布式部署加機器),則將新的Gateway內部通訊地址列表將廣播給所有BusinessWorker,BusinessWorker收到后建立連接
7 、如果有Gateway下線,則Register服務會收到通知,會將對應的內部通訊地址刪除,然后廣播新的內部通訊地址列表給所有BusinessWorker,BusinessWorker不再連接下線的Gateway
8、至此Gateway與BusinessWorker通過Register已經建立起長連接
9、客戶端的事件及數據全部由Gateway轉發給BusinessWorker處理,BusinessWorker默認調用Events.php中的onConnect onMessage onClose處理業務邏輯。
10、BusinessWorker的業務邏輯入口全部在Events.php中,包括onWorkerStart進程啟動事件(進程事件)、onConnect連接事件(客戶端事件)、onMessage消息事件(客戶端事件)、onClose連接關閉事件(客戶端事件)、onWorkerStop進程退出事件(進程事件)

1 Register、Gateway、BusinessWorker進程啟動

項目根目錄下的start.php 為啟動腳本,在該腳本中,加載start_gateway.php start_businessworker.php start_register.php進程腳本,完成各個服務的Worker初始化:

// 加載所有Applications/*/start.php,以便啟動所有服務
foreach(glob(__DIR__.'/Applications/*/start*.php') as $start_file)
{
    require_once $start_file;
}

最后通過Worker::runAll();運行所有服務。

// 運行所有服務
Worker::runAll();
運行所有服務,先看一遍runAll()方法的執行內容
public static function runAll()
{
    // 檢查運行環境
    self::checkSapiEnv();
    //初始化環境變量
    self::init();
    // 解析命令
    self::parseCommand();
    // 嘗試以守護進程模式運行
    self::daemonize();
    // 初始化所有worker實例,主要是監聽端口
    self::initWorkers();
    // 初始化所有信號處理函數
    self::installSignal();
    // 保存主進程pid
    self::saveMasterPid();
    // 展示啟動界面
    self::displayUI();
    // 創建子進程(worker進程),然后給每個子進程綁定loop循環監聽事件tcp
    self::forkWorkers();
    // 嘗試重定向標準輸入輸出
    self::resetStd();
    // 監控所有子進程(worker進程)
    self::monitorWorkers();
}

self::init()初始化環境變量中,有以下部分代碼,保存$_idMap從PID映射到工作進程ID

// Init data for worker id.
   self::initId();
protected static function initId()
 {
   foreach (self::$_workers as $worker_id => $worker) {
       $new_id_map = array();
       for($key = 0; $key < $worker->count; $key++) {
          $new_id_map[$key] = isset(self::$_idMap[$worker_id]      [$key]) ? self::$_idMap[$worker_id][$key] : 0;
       }
       self::$_idMap[$worker_id] = $new_id_map;
   }
 }

self::forkWorkers()方法通過循環self::$_workers數組,fork各自worker的count數量的進程。然后通過調用

$worker->run();

運行當前worker實例,在run方法中通過

  if (!self::$globalEvent) {
       $event_loop_class = self::getEventLoopName();
      self::$globalEvent = new $event_loop_class;
      // Register a listener to be notified when server socket is ready to read.
       if ($this->_socketName) {
           if ($this->transport !== 'udp') {
               self::$globalEvent->add($this->_mainSocket, EventInterface::EV_READ,
                   array($this, 'acceptConnection'));
           } else {
               self::$globalEvent->add($this->_mainSocket, EventInterface::EV_READ,
                   array($this, 'acceptUdpConnection'));
           }
       }

獲取一個當前可用的事件輪詢方式,然后根據當前的協議類型添加一個監聽到事件輪詢中
然后,嘗試出發當前進程模型的onWorkerStart回調,此回調會在Gateway類以及BusinessWorker類中都會定義,代碼

 if ($this->onWorkerStart) {
       try {
           call_user_func($this->onWorkerStart, $this);
       } catch (\Exception $e) {
           self::log($e);
           // Avoid rapid infinite loop exit.
           sleep(1);
           exit(250);
       } catch (\Error $e) {
           self::log($e);
           // Avoid rapid infinite loop exit.
           sleep(1);
           exit(250);
       }
   }

最后,執行事件的循環等待socket事件,處理讀寫等操作,代碼

 // Main loop.
   self::$globalEvent->loop();

以上是runAll()方法的部分內容,會在了解GatewayWorker的工作原理的時候用到

2.1 Gateway進程向Register服務進程發起長連接注冊自己

初始化Gateway
$gateway = new Gateway("text://0.0.0.0:8383");

在Gateway類中重寫run方法,當調用runAll()方法啟動進程時,fork進程之后,運行worker實例的時候,會調用到此重寫的run方法

public function run()
{
    // 保存用戶的回調,當對應的事件發生時觸發
    $this->_onWorkerStart = $this->onWorkerStart;
    $this->onWorkerStart  = array($this, 'onWorkerStart');
    // 保存用戶的回調,當對應的事件發生時觸發
    $this->_onConnect = $this->onConnect;
    $this->onConnect  = array($this, 'onClientConnect');
    // onMessage禁止用戶設置回調
    $this->onMessage = array($this, 'onClientMessage');
    // 保存用戶的回調,當對應的事件發生時觸發
    $this->_onClose = $this->onClose;
    $this->onClose  = array($this, 'onClientClose');
    // 保存用戶的回調,當對應的事件發生時觸發
    $this->_onWorkerStop = $this->onWorkerStop;
    $this->onWorkerStop  = array($this, 'onWorkerStop');
    $this->_startTime = time();
    // 運行父方法
    parent::run();
}

定義了$this->onWorkerStart回調,

$this->onWorkerStart  = array($this, 'onWorkerStart');

執行到Worker類中的run()方法時,被觸發。即,上邊提到的
Worker腳本中的run方法

調用Gateway類中的onWorkerStart方法,代碼

public function onWorkerStart()
{
    $this->lanPort = $this->startPort + $this->id;
    if ($this->pingInterval > 0) {
        $timer_interval = $this->pingNotResponseLimit > 0 ? $this->pingInterval / 2 : $this->pingInterval;
        Timer::add($timer_interval, array($this, 'ping'));
    }
    if ($this->lanIp !== '127.0.0.1') {
        Timer::add(self::PERSISTENCE_CONNECTION_PING_INTERVAL, array($this, 'pingBusinessWorker'));
    }
    if (strpos($this->registerAddress, '127.0.0.1') !== 0) {
        Timer::add(self::PERSISTENCE_CONNECTION_PING_INTERVAL, array($this, 'pingRegister'));
    }
    if (!class_exists('\Protocols\GatewayProtocol')) {
        class_alias('GatewayWorker\Protocols\GatewayProtocol', 'Protocols\GatewayProtocol');
    }
    // 初始化 gateway 內部的監聽,用于監聽 worker 的連接已經連接上發來的數據
    $this->_innerTcpWorker = new Worker("GatewayProtocol://{$this->lanIp}:{$this->lanPort}");
    $this->_innerTcpWorker->listen();
    // 重新設置自動加載根目錄
    Autoloader::setRootPath($this->_autoloadRootPath);
    // 設置內部監聽的相關回調
    $this->_innerTcpWorker->onMessage = array($this, 'onWorkerMessage');
    $this->_innerTcpWorker->onConnect = array($this, 'onWorkerConnect');
    $this->_innerTcpWorker->onClose   = array($this, 'onWorkerClose');
    // 注冊 gateway 的內部通訊地址,worker 去連這個地址,以便 gateway 與 worker 之間建立起 TCP 長連接
    $this->registerAddress();
    if ($this->_onWorkerStart) {
        call_user_func($this->_onWorkerStart, $this);
    }
}

$this->startPort : 內部通訊起始端口,假如$gateway->count=4,起始端口為4000,可在gateway啟動腳本中自定義
$this->id : 基于worker實例分配的進程編號,當前從0開始,根據count自增。在fork進程的時候生成

Worker.php
$this->_innerTcpWorker:用于監聽 worker 的連接已經連接上發來的數據。在工作原理5中,BusinessWorker進程得到所有的Gateway內部通訊地址后嘗試連接Gateway以及其他兩者之間的通信(連接,消息,關閉)會被調用
$this->registerAddress(): 代碼中$this->registerAddress是在start_gateway.php初始化Gateway類之后定義的。該端口是Register進程所監聽。此處異步的向Register進程發送數據,存儲當前 Gateway 的內部通信地址

public function registerAddress()
{
    $address                   = $this->lanIp . ':' . $this->lanPort;
    $this->_registerConnection = new AsyncTcpConnection("text://{$this->registerAddress}");
    $this->_registerConnection->send('{"event":"gateway_connect", "address":"' . $address . '", "secret_key":"' . $this->secretKey . '"}');
    $this->_registerConnection->onClose = array($this, 'onRegisterConnectionClose');
    $this->_registerConnection->connect();
}

$this->lanIp: Gateway所在服務器的內網IP

2.2 BusinessWorker進程向Register服務進程發起長連接注冊自己

BusinessWorker類中同樣重寫run方法,定義了$this->onWorkerStart
 public function run()
 {
    $this->_onWorkerStart  = $this->onWorkerStart;
    $this->_onWorkerReload = $this->onWorkerReload;
    $this->_onWorkerStop = $this->onWorkerStop;
    $this->onWorkerStop   = array($this, 'onWorkerStop');
    $this->onWorkerStart   = array($this, 'onWorkerStart');
    $this->onWorkerReload  = array($this, 'onWorkerReload');
    parent::run();
 }

執行Worker類中的run方法,觸發BusinessWorker中的onWorkerStart

protected function onWorkerStart()
{
    if (!class_exists('\Protocols\GatewayProtocol')) {
        class_alias('GatewayWorker\Protocols\GatewayProtocol', 'Protocols\GatewayProtocol');
    }
    $this->connectToRegister();
    \GatewayWorker\Lib\Gateway::setBusinessWorker($this);
    \GatewayWorker\Lib\Gateway::$secretKey = $this->secretKey;
    if ($this->_onWorkerStart) {
        call_user_func($this->_onWorkerStart, $this);
    }
    
    if (is_callable($this->eventHandler . '::onWorkerStart')) {
        call_user_func($this->eventHandler . '::onWorkerStart', $this);
    }

    if (function_exists('pcntl_signal')) {
        // 業務超時信號處理
        pcntl_signal(SIGALRM, array($this, 'timeoutHandler'), false);
    } else {
        $this->processTimeout = 0;
    }

    // 設置回調
    if (is_callable($this->eventHandler . '::onConnect')) {
        $this->_eventOnConnect = $this->eventHandler . '::onConnect';
    }

    if (is_callable($this->eventHandler . '::onMessage')) {
        $this->_eventOnMessage = $this->eventHandler . '::onMessage';
    } else {
        echo "Waring: {$this->eventHandler}::onMessage is not callable\n";
    }

    if (is_callable($this->eventHandler . '::onClose')) {
        $this->_eventOnClose = $this->eventHandler . '::onClose';
    }

    // 如果Register服務器不在本地服務器,則需要保持心跳
    if (strpos($this->registerAddress, '127.0.0.1') !== 0) {
        Timer::add(self::PERSISTENCE_CONNECTION_PING_INTERVAL, array($this, 'pingRegister'));
    }
}

通過connectToRegister方法,發送數據到Register進程,連接服務注冊中心

public function connectToRegister()
{
    $this->_registerConnection = new AsyncTcpConnection("text://{$this->registerAddress}");
    $this->_registerConnection->send('{"event":"worker_connect","secret_key":"' . $this->secretKey . '"}');
    $this->_registerConnection->onClose   = array($this, 'onRegisterConnectionClose');
    $this->_registerConnection->onMessage = array($this, 'onRegisterConnectionMessage');
    $this->_registerConnection->connect();
}

3 Register服務收到Gateway的注冊后,把所有的Gateway的通訊地址保存在內存中

在Register類中,重寫了run方法,定義了當前的

     $this->onConnect = array($this, 'onConnect');
    // 設置 onMessage 回調
    $this->onMessage = array($this, 'onMessage');

    // 設置 onClose 回調
    $this->onClose = array($this, 'onClose');

三個屬性,當Register啟動的進程收到消息時,會觸發onMessage方法

 public function onMessage($connection, $buffer)
{
    // 刪除定時器
    Timer::del($connection->timeout_timerid);
    $data       = @json_decode($buffer, true);
    if (empty($data['event'])) {
        $error = "Bad request for Register service. Request info(IP:".$connection->getRemoteIp().", Request Buffer:$buffer). See http://wiki.workerman.net/Error4 for detail";
        Worker::log($error);
        return $connection->close($error);
    }
    $event      = $data['event'];
    $secret_key = isset($data['secret_key']) ? $data['secret_key'] : '';
    // 開始驗證
    switch ($event) {
        // 是 gateway 連接
        case 'gateway_connect':
            if (empty($data['address'])) {
                echo "address not found\n";
                return $connection->close();
            }
            if ($secret_key !== $this->secretKey) {
                Worker::log("Register: Key does not match ".var_export($secret_key, true)." !== ".var_export($this->secretKey, true));
                return $connection->close();
            }
            $this->_gatewayConnections[$connection->id] = $data['address'];
            $this->broadcastAddresses();
            break;
        // 是 worker 連接
        case 'worker_connect':
            if ($secret_key !== $this->secretKey) {
                Worker::log("Register: Key does not match ".var_export($secret_key, true)." !== ".var_export($this->secretKey, true));
                return $connection->close();
            }
            $this->_workerConnections[$connection->id] = $connection;
            $this->broadcastAddresses($connection);
            break;
        case 'ping':
            break;
        default:
            Worker::log("Register unknown event:$event IP: ".$connection->getRemoteIp()." Buffer:$buffer. See http://wiki.workerman.net/Error4 for detail");
            $connection->close();
    }
}

當$event = ‘gateway_connect’時,是Gateway發來的注冊消息,保存到$this->_gatewayConnections數組中,在通過broadcastAddresses方法將當前$this->_gatewayConnections中所有的Gatewat通訊地址轉發給所有BusinessWorker進程

4 Register服務收到BusinessWorker的注冊后,把內存中所有的Gateway的通訊地址發給BusinessWorker

同第3步中,Register類收到BusinessWorker的注冊時,會觸發onMessage方法中的worker_connect,case選項。
image.png

同時,將當前worker連接加入到$_workerConnections數組中,在通過broadcastAddresses方法將當前$this->_gatewayConnections中所有的Gatewat通訊地址轉發給所有BusinessWorker進程。

5 BusinessWorker進程得到所有的Gateway內部通訊地址后嘗試連接Gateway

在BusinessWoker類的啟動中,通過重寫run方法,定義的啟動onWorkerStart方法中,通過connectToRegister方法注冊服務中心的同時,也定義了onMessage匿名函數,用于接收消息回調。

$this->_registerConnection->onMessage = array($this, 'onRegisterConnectionMessage');

即,當注冊中心發來消息時候,回調到此處

 public function onRegisterConnectionMessage($register_connection, $data)
{
    $data = json_decode($data, true);
    if (!isset($data['event'])) {
        echo "Received bad data from Register\n";
        return;
    }
    $event = $data['event'];
    switch ($event) {
        case 'broadcast_addresses':
            if (!is_array($data['addresses'])) {
                echo "Received bad data from Register. Addresses empty\n";
                return;
            }
            $addresses               = $data['addresses'];
            $this->_gatewayAddresses = array();
            foreach ($addresses as $addr) {
                $this->_gatewayAddresses[$addr] = $addr;
            }
            $this->checkGatewayConnections($addresses);
            break;
        default:
            echo "Receive bad event:$event from Register.\n";
    }
}

其中Register類發來的數據是

$data   = array(
        'event'     => 'broadcast_addresses',
        'addresses' => array_unique(array_values($this->_gatewayConnections)),
    );

這個時候,就會通過checkGatewayConnections方法檢查gateway的這些通信端口是否都已經連接,在通過tryToConnectGateway方法嘗試連接gateway的這些內部通信地址

6 Gateway進程收到BusinessWorker進程的連接消息

同樣,在Gateway進程啟動的時候,觸發的onWorkerStart方法中,也定義了一個內部通訊的onWorkerMessage

$this->_innerTcpWorker->onMessage = array($this, 'onWorkerMessage');

由此來接收BusinessWorker進程發來的連接消息,部分代碼

public function onWorkerMessage($connection, $data)
{
    $cmd = $data['cmd'];
    if (empty($connection->authorized) && $cmd !== GatewayProtocol::CMD_WORKER_CONNECT && $cmd !== GatewayProtocol::CMD_GATEWAY_CLIENT_CONNECT) {
        self::log("Unauthorized request from " . $connection->getRemoteIp() . ":" . $connection->getRemotePort());
        return $connection->close();
    }
    switch ($cmd) {
        // BusinessWorker連接Gateway
        case GatewayProtocol::CMD_WORKER_CONNECT:
            $worker_info = json_decode($data['body'], true);
            if ($worker_info['secret_key'] !== $this->secretKey) {
                self::log("Gateway: Worker key does not match ".var_export($this->secretKey, true)." !== ". var_export($this->secretKey));
                return $connection->close();
            }
            $key = $connection->getRemoteIp() . ':' . $worker_info['worker_key'];
            // 在一臺服務器上businessWorker->name不能相同
            if (isset($this->_workerConnections[$key])) {
                self::log("Gateway: Worker->name conflict. Key:{$key}");
        $connection->close();
                return;
            }
    $connection->key = $key;
            $this->_workerConnections[$key] = $connection;
            $connection->authorized = true;
            return;
        // GatewayClient連接Gateway

將worker的進程連接保存到$this->_workerConnections[$key] = $connection;

7 Gateway進程收到客戶端的連接,消息時,會通過Gateway轉發給worker處理

 // Gateway類的run方法中定義此屬性
 $this->onMessage = array($this, 'onClientMessage');
 
 // 收到客戶端消息的時候出發此函數
 public function onClientMessage($connection, $data)
 {
    $connection->pingNotResponseCount = -1;
    $this->sendToWorker(GatewayProtocol::CMD_ON_MESSAGE, $connection, $data);
 }

在sendToWorker方法中,將數據發給worker進程處理

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,412評論 6 532
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,514評論 3 416
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,373評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,975評論 1 312
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,743評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,199評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,262評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,414評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,951評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,780評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,983評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,527評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,218評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,649評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,889評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,673評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,967評論 2 374

推薦閱讀更多精彩內容

  • Spring Cloud為開發人員提供了快速構建分布式系統中一些常見模式的工具(例如配置管理,服務發現,斷路器,智...
    卡卡羅2017閱讀 134,782評論 18 139
  • 前文再續,就書接上一回,隨著與Server、TCP、Protocol的邂逅,Swoole終于迎來了自己的故事,今天...
    蝸牛淋雨閱讀 1,760評論 1 14
  • 1 什么是MVC MVC模式(Model-View-Controller)是軟件工程中的一種軟件架構模式。 MVC...
    申城墨道閱讀 2,048評論 0 10
  • 標簽:videoautoplay 自動播放controls 顯示控件loop ...
    六月太陽花閱讀 289評論 0 0
  • 戀愛的方式有很多種,有異性戀,同性戀,雙性戀,也有異地戀,軍戀,網戀,甚至還有聳人聽聞的冰戀,而對于普羅大眾來說,...
    拾荒Demo閱讀 549評論 0 4