服务器开发系列 2

title: 服务器开发系列 2
date: 2017-9-13 11:27:46

经过 3 周的疯狂加班后, 服务器开发节奏终于可以放一放了, 也有空可以用「外在」的角度来好好看一下这次项目.

使用 swoole 裸写 tcp server

在没有「瑞士军刀」(熟悉的框架)的情况下, 裸写 swoole 就变成了下面这样:

  • swoole tcp server 基本骨架

require_once __DIR__ . '/../vendor/autoload.php'; // composer autoload
require_once __DIR__ . '/config.php'; // 配置文件

//---------------------server
$serv = new swoole_server("0.0.0.0", 9999);
$serv->set([
    'worker_num'            => 4,
    'task_worker_num'       => 8,
//    'daemonize'             => true,
    'pid'                   => __DIR__ . '/server.pid',
    'log_file'              => __DIR__ . '/../log/swoole.log',

    // 固定包头协议
    'open_length_check'     => 1,       // 开启协议解析
    'package_length_type'   => 'N',     // 长度字段的类型
    'package_length_offset' => 4,       // 第N个字节是包长度的值
    'package_body_offset'   => 8,       // 第N个字节开始计算长度
    'package_max_length'    => 2000000, // 协议最大长度
]);

// swoole table 是最开始的方案, 可以作为全局共享内存使用
//$swooleTable = new swoole_table('100000'); // 最多 10w 同时连接
//$swooleTable->column('auth', swoole_table::TYPE_INT, '1');
//$swooleTable->create();
//$serv->table = $swooleTable;

$serv->zoneId = $config['zone_id']; // 使用配置
$serv->userinfo = []; // 保存信息

// 必须在 onWorkerStart 回调中创建 redis/mysql 连接
$serv->on('workerStart', 'onWorkerStart');

// mysql 连接池
$serv->on('task', 'onTask');
$serv->on('finish', 'onFinish');

$serv->on('connect', 'onConnect');
$serv->on('receive', 'onReceive'); // 消息处理
$serv->on('close', 'onClose');

$serv->start();
  • 消息处理

这里使用了很多 function , 来分离逻辑

function onReceive(swoole_server $serv, $fd, $from_id, $data)
{
    //  decode() 函数用来解析协议, 使用 autoload psr-4 来加载
    $data = decode($data);
    // 每个不同的消息都对应不同的函数来处理
    if ($data['msg_type'] == 0) {
        function_msg0($serv, $fd, $data);
    } else if ($data['msg_type'] == 1) {
        function_msg1($serv, $fd, $data);
    }
}
  • 必须在 onWorkerStart 回调中创建 redis/mysql 连接

可以参考这篇 wiki: 是否可以共用1个redis或mysql连接
下面在每个 worker 进程启动时初始化了 2 个 redis 连接, 一个用来做 cache, 一个用来做 pub/sub

function onWorkerStart(swoole_server $serv, $id){
    // 只在 worker 进程中使用
    if ($id < $serv->setting['worker_num']) {
        // cache
        $cache = new \Redis();
        $cache->connect($config['cache']['host'], $config['cache']['port'], $config['cache']['timeout']);
        $cache->auth($config['cache']['auth']);
        $serv->cache = $cache;

        // pub
        $pub = new \Redis();
        $pub->connect($config['pub_sub']['host'], $config['pub_sub']['port'], $config['pub_sub']['timeout']);
        $pub->auth($config['pub_sub']['auth']);
        $serv->pub = $pub;
    }
}

// 之后就可以直接这样使用了
$serv->cache->set('key1', 'value1');
$serv->pub->publish('topic1', 'data1');
  • mysql 连接池

虽说标题叫连接池, 这里其实只实例化了一个 mysql 连接对象, 原理类似

function onTask($serv, $task_id, $from_id, $data) {
    static $link = null;
    if ($link == null) {
        $link = mysqli_connect($config['mysql']['host'], $config['mysql']['user'], $config['mysql']['password'], $config['mysql']['database']);
        if (!$link) {
            $link = null;
            return;
        }
    }
    // 这里做了一层封装, 需要在原有 sql 语句上加标记来判断是什么类型的语句, 可以优化
    list($queryType, $sql) = explode('|', $data); 
    $result = $link->query($sql);
    if ($result) {
        if ($queryType == 'select') {
            $result = $result->fetch_all(MYSQLI_ASSOC);
        } else if ($queryType == 'insert') {
            $result = mysqli_insert_id($link);
        }
        return $result;
    }
}

function onFinish($serv, $data)
{
    //
}

// 之后就可以直接这样使用了
$res = $serv->taskWait('select|select name from user where id=xxx'); // 这里是同步阻塞

使用订阅时踩到的坑

swoole 中订阅的实现, 可以先参考这篇 blog: 如何实现从 Redis 中订阅消息转发到 WebSocket 客户端

同样还是要在 onWorkerStart 回调函数中启动 redis sub

function onWorkerStart(swoole_server $serv, $id){
//        if ($id == 0) { // 只启动一个 sub
            $sub = new swoole_redis(); // swoole_redis 支持异步
            $sub->on('message', function (swoole_redis $redis, $result) use ($serv, $config) {
                if ($result[0] == 'message') {
                    list($userId, $status) = explode(':', $result[2]);
                    // 解析出 fd, 用来给 client 发送消息
                    $userFd = $serv->userinfo[$userId]['fd'] ?? 0;
                    if ($userId && $userFd && in_array($status, [0, 1, 2])) {
                        foreach ($serv->connections as $fd) {
                            if ($userFd == $fd) { // 只发给对应客户端

                                // 省略业务逻辑

                                // 给 client 发送消息
                                $serv->send($fd, encode('foo'));
                                break;
                            }
                        }
                    }
                }
            });
            $sub->connect($config['pub_sub']['host'], $config['pub_sub']['port'], function (swoole_redis $redis, $result) use ($config) {
                $redis->auth($config['pub_sub']['auth'], function (swoole_redis $redis, $result) use ($config) {
                    $redis->subscribe('game_result_'. $config['zone_id']);
                });
            });
//        }
    }
}

仔细看代码, 会发现有这样一行注释 「只启动一个 sub」, 这是由业务决定的, 收到订阅消息的时候, 只需要转发给特定的用户.
但是, 在 onWorkerStart 回调函数中启动, 并不能实现. 原因我们需要先了解一下 swoole 的进程模型:

  • server 启动时, 首先会开启一个 master 进程
  • master 进程启动 manager 进程和 reactor 线程
  • reactor 线程, 用来管理 tcp 连接和 tcp 数据的收发
  • manager 进程用来管理 worker 进程和 task_worker 进程, 根据上面的 worker_num/task_worker_num 配置
  • worker 进程处理 reactor 线程转发过来的数据, 处理完业务逻辑后, 将数据发给 reactor 线程, 由 reactor 线程转发给用户
  • worker 进程会将耗时任务投递给 task_worker 进程, task_worker 进程处理完后触发 onFinish 事件回调

所以, 我们可以根据使用 $work_id < $serv->setting['worker_num'] 来判断我们当前是在 worker 进程还是 task_worker 进程

第一版在写的时候, 我是按照业务需求, 来限定只在 $work_id = 0 的进程上开启 redis sub. 但是, 问题马上就来了: 当前用户的 fd, 并不一定在 $work_id = 0 的进程上, 这就会导致下面这段代码失效:

foreach ($serv->connections as $fd) { // $serv 其实对应的当前的 worker 进程
    if ($userFd == $fd) {
        // do something
    }
}

但是, 如果不加 $work_id = 0 的限制, 就会导致我们开了多少个 worker 进程, 就会有多少个 sub, 导致消息的重复订阅, 重复的业务逻辑处理.

这时候, 就必要了解一下, swoole 提供的 Process 进程管理模块, 我们只需要单独起一个进程, 用来维护 sub 就好了

使用 swoole 裸写 server 发现的问题

很明显, 上面的业务逻辑还不够复杂, 使用的服务也不多, 但是整个开发下来的不舒适感是非常明显:

  • 开发环境和测试环境的搭建: 编译 swoole, 安装 redis/mysql
  • 配置管理: 快速开发时写死到业务里, 到优化时抽到 config.php 配置文件中
  • 服务部署: 开始尝试官方 wiki 里 systemd daemonize 的方案, 结果产生了大量僵尸进程
  • 连接池: 如果需要更高的性能, 连接池会很有必要, 无论是 redis 还是 mysql
  • 协议处理: 我们使用了 固定包头 + protobuf 的自定义协议, 将协议和业务分离开才是良好的设计
  • 学习成本: 官方 wiki 第一次读产生大致映像, 第二次边读边实现 wiki 中的例子, 第三次根据业务需求去细读 wiki 相应章节. 但是 wiki 尽管接近 1400 页, 还是会有新问题.

相比而言, php 的 web 框架如此之多, MVC 大行其道, 是否也有 php 的 server 框架, 可以解决上面这些共性问题呢?

这里推荐一下 swoole distribution, 重构的时候选择了这个框架, 简单说一下优点:

  • docker 配置开发环境, 不过 docker for window 通过目录挂载会导致无法热更新(当然也有方案解决)
  • 不依赖其他服务(systemd supervisor)进行服务化部署
  • Pack 模块解决协议解析
  • 经典的 MVC 结构, 只需要稍微修改 route, 就可以在 controller 和 model 中书写业务逻辑
  • 自带连接池, 修改配置文件即可
  • 没错, 还有协程
$value = yield $this->redis_pool->getCoroutine()->get('key1');
  • 没错, 还有 Process
namespace app\Process;

use Server\Components\Process\Process;

class MyProcess extends Process
{
    public function start($process)
    {
        parent::start($process);

        // 可以把 redis sub 的逻辑放这里了
    }

    // 可以在 controller 中使用 rpc 调用此方法
    public function getData()
    {
        return '123';
    }
}

写在最后

确实, 之前并没有写过服务器, 一直停留在「纸上谈兵」的阶段, 真正写起来的时候才发现「这活真累」.
不过, 那些年你读的书, 刷的技术 blog, 参加的技术大会, 总归是有用的, 拦在入门地方的, 并不是语言, 而是这个领域的「基础」, 这些你都可以通过这些方式获取, 缺的是需要自己将它系统化.
当然, 接着就是 coding, practice makes perfect 对一线程序员会一直有用.

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,793评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,567评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,342评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,825评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,814评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,680评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,033评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,687评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,175评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,668评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,775评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,419评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,020评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,978评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,206评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,092评论 2 351
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,510评论 2 343

推荐阅读更多精彩内容

  • 本文示例代码详见:https://github.com/52fhy/swoole_demo。 简介 Swoole是...
    jiancaigege阅读 793评论 1 6
  • 前文再续,就书接上一回,随着与Server、TCP、Protocol的邂逅,Swoole终于迎来了自己的故事,今天...
    蜗牛淋雨阅读 1,709评论 1 14
  • 并发IO问题一直是服务器端编程中的技术难题,从最早的同步阻塞直接Fork进程,到Worker进程池/线程池,到现在...
    零一间阅读 1,705评论 1 34
  • 一场事变九一八,铁蹄踏入我中华。 活体实验细菌战,无辜百姓遭戮杀。 惨无人道屠南京,三十万众染黄沙。 奸淫掳掠恶做...
    缘wxh阅读 278评论 4 18
  • 也许是你心灵如饴和敬畏的脸庞 也许是甘霖同时打落你我的身上 也许是苍穹下你读懂了我的悲伤 也许是爱神会拯救我永久的...
    思绛阅读 128评论 0 0