分类 XTS框架 下的文章

批量加载关系对象和属性

Relation Load 的性能问题

长期以来,我们一直使用ORM技术进行数据库操作。相比直接用PDO写SQL并操作结果数组,利用ORM类有诸多好处,其中最好用的两点在于:

  1. 相比数组,Model类提供了一个编写getter和setter的地方,可以更灵活地处理和转化数据库中的字段。
  2. 利用关系加载技术让可以直接像访问普通属性一样访问相关的Model对象,无需再编写相应SQL。

然而一直以来,上述两种常见的ORM用法,存在严重的性能问题。因为getter和自动关系加载都针对单个对象操作,ORM库常常会为一页的记录生成上百条SQL查询语句,执行起来耗时较长。

比如要查询以下报表:

学员姓名 手机号 所属分校 所在高校 所在班级 负责老师 最近沟通

报表中的学员姓名、手机号来自student表,所属分校来自school表,所在班级来自class表,所在高校来自colleage表,负责老师来自teacher表,最近沟通来自contact_latest表。表关系如下:

-----------
| teacher |
-----------
     ^
     |
-----------
| school  |
-----------
     ^
     |
-----------     ------------------
| student | --> | contact_latest |
-----------     ------------------
     |
     V
-----------
|  class  |
-----------
     |
     V
------------
| colleage |
------------

每查询一页的报表数据,假设一页50条记录,需要生成两条select student的SQL(一条count一条分页选取,在之后的关系加载过程中,需要再生成250条select语句,用于为每条student记录查询其它相关记录,虽然每条关联记录都是用主键id查询。

用with方法提升性能

引发上述性能问题的关键原因在于,每个ORM记录对象单独做关系加载时,并不知道其它对象也要做相似的关系加载,所以只能生成大量的单条查询SQL。这样在和数据库通讯RTT值高的情况下,性能下降尤其明显。

为了解决这种性能问题,x2ts的从2.5版开始,向Model类新增了一个with方法,它允许我们定义一系列需要批量载入的关系属性,并在下一次many或者sql调用时生效。比如:

X::model('student')->with(
    'school.master',
    'contact_latest',
    ['class', 'colleage']
)->many('user_id IS NOT NULL', [], 0, 50);

上面这段代码会在载入50条学员记录之后,自动载入和它们关联的分校、老师、班级、高校、联系记录。总共生成6条SQL语句,除student外的5条关联查询SQL,都使用IN查询取得集合数据,有效避免了大量单条查询语句的生成。

with方法接收一个或者多个参数,每个参数是一条关联查询定义,可以是字符串、也可以是数组,还可以是x2ts\db\orm\BatchLoader接口的实例。

with方法的参数说明

字符串描述的是关联关系的名称,可以用点分隔做次级加载。比如:

'school.master'

表示关系载入school属性,然后再载入schoolmaster属性。


数组描述的是多级加载关系。['class', 'colleage']数组等价于'class.colleage'字符串。


有时会对某一级关系对象的多个下级存在加载需求,可以用关联数组实现。比如:

['school' => ['master', 'creator']]

表示关系载入school之后,再载入schoolmastercreator两个属性。


关联数组的key可以用点分隔表示多级,规则和字符串一样,key必需是数组中的最后一项。比如:

['school.master' => ['role', 'ext_info']]

表示关系载入school后,再载入schoolmaster属性,然后载入masterroleext_info属性。

这种写法等价于

['school', 'master' => ['role', 'ext_info']]

多层数组可以相互嵌套,比如:

[
    'class.colleage' => [[
            'creator' => [
                'role',
                'ext_info',
            ],
        ], [
            'master' => 'role'
        ],
        'location',
    ]
]

表示要载入class属性,以及加载classcolleage属性,然后加载colleagecreatormasterlocation属性,还要加载creatorroleext_info属性,masterrole属性。

用with批量关系加载的错误和异常

只有BelongToRelationHasOneRelation实现了批量关系加载。如果声明对HasManyRelation或者ManyManyRelation作批量关系加载,会抛出MethodNotImplementException异常。

指定关系加载时的名称应当是关系名称,如果在Model的relations数组中没有定义该名称,则会抛出UnresolvableRelationException

自定义批量加载器

前面的例子全部都在进行关系加载,有的时候数据表上没有建立外键关系,但我们通过自己写的getter实现单条数据的载入,也面临类似的性能问题。比如:

class QuestionFilter extends x2ts\db\orm\Model {
    public function getSubject() {
        return X::model('subject')->load($this->subject_id);
    }
}

如果我们查询出QuestionFilter的列表,然后需要对每条记录单独查询所属学科。从而产生大量select语句,性能不佳。

可以通过自定义批量加载器实现关联关系的批量加载。

// 重新定义getter和setter,允许写入subject属性
class QuestionFilter extends x2ts\db\orm\Model {
    private $subject;
    public function getSubject() {
        return $this->subject ?? 
            X::model('subject')->load($this->subject_id);
    }

    public function setSubject($s) {
        if ($s instanceof Subject) {
            $this->subject = $S;
        }
    }
}

//实现一个自定义的批量加载器
class QFSubjectLoader implements x2ts\db\orm\BatchLoader {
    public function name():string {
        return 'subject';
    }

    /**
     * @param Model[] $models
     * @param array $subLoaders
     *
     * @return void
     */
    public function batchLoadFor($models, $subLoaders) {
        $subjectIds = [];
        foreach ($models as $model) {
            $subjectIds[] = $model->subject_id;
        }
        $inStr = implode(',', $subjectIds);
        $subjects = X::model('subject')
            ->with(...$subLoaders)
            ->many("id IN ($inStr)");
        $subjectMap = [];
        foreach ($subjects as $subject) {
            $subjectMap[$subject->id] = $subject;
        }
        foreach($models as $model) {
            $model->subject = $subjectMap[$model->subject_id] ?? null;
        }
    }
}

//使用自定义的批量加载器
$filters = X::model('question_filter')
    ->with(new QFSubjectLoader())
    ->many('user_id=:uid', [':uid' => $currentUser->id], 0, 50);

批量加载器的name方法定义了加载器的属性名,batchLoadFor方法负责批量载入相关数据,并写入到$models各元素中,$subLoaders需要透传到当前属性Model的with方法里,实现次级批量加载。

如果自定义的批量加载器不支持次级批量加载,或者当前载入的不是Model对象,则应该忽略$subLoaders,如果$subLoaders不为空,应输出warning

使用ReferenceBatchLoader加载器

上面介绍的学科可以用自定义加载器载入,然而这种没有外键却需要加载相关Model对象的情况非常常见,所以x2ts框架提供了一个ReferenceBatchLoader加载器,以解决这种使用场景。

上例中的QFSubjectLoader可以不必自己开发,直接用ReferenceBatchLoader

$filters = X::model('question_filter')
    ->with(new ReferenceBatchLoader(
        'subject',
        'subject_id',
        X::model('subject')
    ))
    ->many('user_id=:uid', [':uid' => $currentUser->id], 0, 50);

ReferenceBatchLoader的构造函数接收三个参数:第一个是加载器的名称,也是加载进来之后的属性名,需要自己为该属性写好getter和setter,或者可以直接声明public的属性;第二个参数是当前Model对象中保存外部对象的主键的属性名;第三个参数是要加载的外部Model的一个对象实例。

如果当前对象的外部引用字段为空,或者引用的id在数据库里找不到,批量加载器会给相应属性赋值为null

在平行空间中运行程序

介绍

思考这样一个实例:老师答疑时要下发通知给学员的App。把老师提交的答案保存到数据库只需50ms,调用第三方的WebService接口完成推送却需要等待500ms乃至数秒!我们希望老师可以马上看到答案提交成功,并且开始为下一位学员答疑,不必等待App推送完成。x2ts框架提供了一个新的功能来达成这一目的,你可以提交一段代码,让这段代码在独立的进程中运行,不会阻塞php-fpm。用起来就像这样:

X::parallel(function($userId, $title, $message) {
    $ret = X::curl->post(...); //请求推送接口
    X::logger()->info($ret);
})->run(
    $askSubject->user_id,
    $askSubject->author . '回答了你的问题',
    $askSubject->first_ask
);

例子中的代码把调用推送接口的代码写在了一个匿名函数中,然后调用run方法把参数传过去,这个匿名函数会在另一个进程中运行。这里的“另一个进程”,就是Parallel Runner的守护进程。

使用说明

想要使用Parallel Runner首先要用composer引入库:

composer require x2ts/x2ts-parallel

然后在配置文件中加入组件配置:

'parallel'     => [
    'class'     => \x2ts\parallel\Runner::class,
    'singleton' => true,
    'conf'      => [
        'name'       => 'sk-parallel',
        'sock'       => '/var/run/sk/parallel.sock',
        'pid'        => '/var/run/sk/parallel.pid',
        'lock'       => '/var/run/sk/parallel.lock',
        'workerNum'  => 16,
        'backlog'    => 128,
        'maxRequest' => 500,
    ],
],

X类中加入parallel方法申明

/**
 * @method static x2ts\parallel\Runner parallel(Closure $func = null)
 */
class X extends x2ts\ComponentFactory {}

编写Parallel Runner启动脚本:

<?php
// cliroot/parallel.php
require_once dirname(__DIR__) . '/webroot/xts.php';
X::parallel()->start();

最后,启动Parallel Runner守护进程:

mkdir /var/run/sk
chown -R www-data:www-data /var/run/sk
php cliroot/parallel.php

现在就可以像前面的例子一样,把程序放到Parallel Runner中运行了。

限制

因为匿名函数其实是在Parallel Runner进程中运行,所以必然会有一些限制:

  1. 不能使用use关键字引入外层变量,因为在另一个进程中没有此匿名函数的执行上下文。
  2. 不能使用$this变量,理由和上一条相同,但可以在内部的匿名类定义中使用$this,此时$this指向该匿名类的实例。
  3. 不能按引用传递变量,因为变量实际会通过IPC的方式发到另一个进程,按引用传递变量是没有意义的。
  4. 不能传递不可被序列化的变量,因为要通过IPC的方式发送到另一进程,resource和不可序列化的object不能作为参数传递。
  5. 不能期待返回值,因为程序在另一个进程中运行,执行结果只能通过修改数据库、Redis等方式反映,所有return的数据都会被丢弃。
  6. 不能期待echo输出,因为程序在另一个进程中运行,echo输出的内容并不会显示在页面上,因为是守护进程,也不会输出到屏幕,需要输出信息供阅读和调试,请通过日志输出。

x2ts的事件总线

有的时候我们会需要在主线流程之外处理一些支线任务,比如用户注册的主线任务是检查和保存用户的各种资料,同时存在支线任务是给邀请此用户来注册的另一用户发送一条邀请成功通知。直观的写法是把发送通知的代码直接追加在注册程序的保存命令之后,这样的写法一个还不打紧,再多写几个就会破坏现有程序的可维护性,比如注册就赠送一个体验产品订单,还有注册成功要向统计系统发个消息通知……

基于事件模型来写这种支线程序可以让代码组织得更好。注册部分的代码只专注于注册该干的事情——检查数据、创建用户对象、设置用户资料、写入数据库,然后触发“用户注册”事件,后面的通知、馈赠、统计等支线行为,应该由监听用户注册事件的一众程序来处理,它们可以写在关联事件监听器的地方,也可以写在相应业务的模块里。

function notifyInvitor(event\user\Register $ev) {
    $user = $ev->user;
    if ($user->invitation) {
        //发送通知给邀请的用户
    }
}
X::bus()
    ->on('user.register', 'notifyInvitor')
    ->on('user.register', ['Product', 'presentTrial'])
    ->on('user.register', ['Statistic', 'userRegister'];

在用户注册的主业务代码中,只需要触发此事件即可:

//各种检查和赋值
$user->save();
X::bus()->dispatch(new event\user\Register([
    'dispatcher' => $this,
    'user'       => $user,
]));

x2ts框架的事件也已转向使用事件总线。所谓事件总线,意思是所有事件都在挂载在同一个对象上,并且所有事件都在同一个对象上触发,这样一来,开发事件处理函数的人无需了解事件触发的程序细节,只要根据文档选择适当的事件,即可开发回调函数,完成支线任务。所有的x2ts框架事件,都定义在x2ts开头的命名空间下;所有的应用层事件,都定义在event开头的命名空间下,代码保存在项目的protected/event目录中。

许多时候事件处理代码只是一些小函数,没有完整的class或者模块承载,为了更好的管理这些函数,项目加入了一个event\Setup类,位于protected/event/Setup.php里,在这个类里可以写静态方法作为事件监听器函数。还有一个静态方法setup()负责注册这些事件监听器。事件监听写的书写原则是为每一个任务编写一个静态方法,名称和要做的事情相符,比如notifyInvitor表示此方法会发送通知给邀请人,sendAskAnsweredNotify表示发送答疑回复通知等等。

x2ts日志的一些变化

这次重构x2ts框架的时候,日志模块也在大调之列。最主要的变化当属放弃了原来的直接写入日志文件的做法,改为使用monolog作为日志处理模块。monolog把记录日志这件事划分成了LoggerHandlerFormatter三个部分。Logger负责和应用程序对接,提供诸如noticewarning这样的简便接口,也提供addRecord这样的通用接口。Handler负责日志的处理和输出,可以输出到stderr,也可以把文件、网络、命名管道、syslog、消息队列、PHPConsole等等作为输出日志的目标。Formatter负责日志输出前的格式化,可以把日志文本和元信息(比如时间、进程号等)格式化成指定的样子。

以前日志函数在x2ts\Toolkit中提供,一个是log还有一个是trace,现在Logger是一个独立的x2ts组件类,它是核心组件之一,不论是否在配置中定义始终都可以使用(如果没有定义输出目标,默认会输出到stderr)。在日志消息等级方面,以前的DEBUG、NOTICE、WARNING、ERROR共4个级别被扩展成monolog所定义的8个级别:DEBUG、INFO、NOTICE、WARNING、ERROR、CRITICAL、ALERT、EMERGENCY。

调用日志的方式也改为使用logger组件:

X::logger()->info($someMessage);

为了方便日志在各种Handler中输出,原来日志中夹带的Unix Console颜色字符从日志中去掉了,现在的日志文件只有纯文本数据。如果想看带颜色的日志需要使用开发机上的colorlog脚本。

tail -f app.log | colorlog

现在日志的输出等级依然是debug模式输出DEBUG以上的日志,非debug模式输出NOTICE级别以上的日志。在新的级别体系中,INFO级别的日志可以用于区分框架输出的trace log

x2ts最近的几个大动作

最近在将x2ts由git submodule使用方式重构到composer上,顺便检讨之前设计不合理的地方并加以修订。预计将会有以下几大调整:

  1. 使用composer管理依赖
  2. 遵循PSR-1开发规范
  3. 使用PSR-4 autoload机制
  4. 日志模块基于monolog实现
  5. 配置不再保存于static变量中,迁移到Configuration类
  6. 加入事件支持和全局事件总线
  7. 路由器切分为路由和规则两部分
  8. Router和Action类中的事件改向全局事件总线触发
  9. 除MVC外的组件迁移到单独的库中,作为可选模块使用
  10. 加入Unit Test