PHP大型网站开发之列表页开发范式

在设计模式里,有一个设计思想,“找出变化并封装之。”这一思想所蕴含的意义非常丰富,而且富有启发性。而在敏捷开发中,也推崇拥抱变化,快速响应。
知道这些思想的人很多,但真正理解的人很少。能理解这些思想的,并且最终加以实践并持久坚持推行的人则更少。更糟糕的是,实际项目开发过程中,尤其在大型网站系统开发过程中,业务需求以一种无序、突发、紧急的姿态,要求迭代到系统中。面对来自业务和市场的压力,内部系统亟待重构和优化这样内外交迫的局部,软件开发工程师就更加没有时间,也没有精力尝试“找出变化并封装之”这一思想带来的启发。如果,软件开发工程连这一思想都不知道的话,情况会更为严峻。
列表页就是一个富含变化的战场。它也是业务上兵家必争之地。这是由于列表页本身的性质决定的。可以说,列表页是整个网站系统中,流量最大的页面。更进一步,用户做出选择的地方就是在列表页。对于同一位用户,这是零和选择,如果用户选择了A,他就不会选择B;如果选择了B,就不会再选择C。当然,用户选择的次数通过都会稍微比一次多一点。以电商平台为例,对于琳琅满目的商品,用户通常都会选择在前面的商品进行浏览,然后做出选择。极少会有用户拉到页面底部查看后面的商品,更不用说一页页翻下去。现在的时代,是一个快节奏的时代,我们往往都需要在短时间内做出选择。无疑排在前面的选项,会有更大的概率被用户选中。一旦选择,进入到详情面,那就是具体信息展示的事情了。
可以毫不夸张地说,列表页在整体产品开发周期中,所占用的时间、资源、精力会远大于其他页面。不管是在电商平台系统,还是在招聘网站系统,抑或是国际租车平台。列表页的需求,一个接一个;改版的计划,一波接一波,足以让开发团队和测试团队应接不暇。
作为软件开发工程师,我们要始终致力于为业务创造更多的价值。那么对于列表页,我们应该怎么做才能支撑绵绵不断的变化呢?这其中又有哪些开发范式?这些将是本节将要重点讨论的话题。

1 四大排序与排序微架构

透过业务繁华的表象,列表页的本质上是针对集合的基本操作,主要有三大操作:排序、筛选和映射。
我们先来探索排序操作,因为这部分是关乎业务核心价值的关键环节。排在前面的元素,更能获取通往终端用户的绿卡。不管是用户,还是供应商、合作伙伴、公司高管、活动方,对列表页的排序都非常关注。因为列表页的排序,在极大程度上决定了流量的分配。如果说,得流量者得天下,那么就更能明白为什么大家都对列表页的排序关注度如此之高,如此敏感,足以牵动人们的每一根神经。
列表页的排序,不容小觑。
根据多年的开发经验,列表页的排序,自底往上,分为四大类
默认的基础排序
二次干预的运营排序
推荐算法的实时排序
特定专题的活动排序

这四类排序,是层层往上的。先有基础的排序,再有干预的排序,接着是推荐算法的排序,最后是针对某些特定元素置顶的操作。下面分别详细说明,以及各类排序的实现要点和开发范式。

2 默认的基础排序

第一道排序。是默认的基础排序。在数据初次录入时,此基础排序所需要的依据就已经存在了,不管录入的方式是手动的还是自动的,是由用户产生的还是供应商制作的。例如用人单位在发布招聘岗位信息时的发布时间,在招聘网站的列表时就可以根据发布时间进行降序排序。
通常,基础排序数据,和业务的基本信息存放在一起,即存放在数据库同一张表里。这里,结合数据库自身的排序,在获取数据时就已经实现了基础排序。这一个环节很简单,也是网站通用的技术分部,并不是我们讨论的重点。
我们关注的是,网站以此基础排序为起点,在不断迭代和演进中,能否友好地支持后面一连串的扩展排序。当预见列表页存在不止一种排序时,我们应当如何设计?既能满足业务的需求,又能减轻开发和维护的成本,还能兼顾调试、排查和快速定位问题。这是一个值得思考的问题。如果处理不当,系统将陷入混乱、臃肿,随之而来的是产品人员急于对系统的优化却得不到响应的矛盾激发。
为充实接下来的一系列排序介绍,继续以前面的迷你招聘网站为例,在PHP招聘列表页,有以下基础排序后的招聘岗位数据。

<?php
//  ./model/JobModel.php 文件
class JobModel /** extends Model **/{
    public function getJobList($city, $type) {
        return array(
            array(
                'id' => 21,
                'job_name' => 'PHP软件开发工程师', 
                'job_snapshot' => '6k-8k / 经验1年以下 / 大专 / 全职', 
            ),
            array(
                'id' => 22,
                'job_name' => '高级PHP工程师', 
                'job_snapshot' => '12k-23k / 经验3-5年 / 本科 / 全职', 
            ),
            array(
                'id' => 23,
                'job_name' => '资深PHP开发工程师', 
                'job_snapshot' => '15k-30k / 经验5-10年 / 本科及以上 / 全职', 
            ),
            array(
                'id' => 24,
                'job_name' => 'PHP程序员', 
                'job_snapshot' => '4k-5k / 广州 / 经验1年以下 / 大专 / 全职', 
            ),
            array(
                'id' => 25,
                'job_name' => '高级PHP开发工程师', 
                'job_snapshot' => '14k-20k / 广州 / 经验1-3年 / 本科 / 全职',
            ),
        );
    }
}

这是一份从数据库表获取的数据,已经根据发布时间从后到前降序排序。数组中的每一份数据分别表示一个招聘岗位,id表示招聘岗位的ID,job_name表示招聘岗位名称,job_snapshot为招聘岗位的关键描述。而招聘信息详情页的网址格式为:/detail-{招聘岗位ID}.html。
此时,列表页控制器的实现代码如下。假定当前城市为广州。

<?php
//  ./controller/ListController.php 文件
require_once dirname(__FILE__) . '/../model/JobModel.php';

class ListController {

    public function show() {
        $type = $_GET['type'];
        $city = 'gz';

        $jobModel = new JobModel();
        $job = $jobModel->getJobList($city, $type);

        // 视图渲染
        include(dirname(__FILE__) . '/../view/list.html');
    }
}

这时,列表页的展示效果如下:

图3 迷你招聘网站列表页

请注意,这里有一个小技巧和小窍门。为了进行SEO优化,或出于对安全性的考虑,网站会采用伪静态的做法。例如这里的列表页的访问地址是:http://dev.job.com/list-php.html,其访问文件的后缀是html,隐藏了PHP实现这一信息,并且更容易被搜索引擎收录。因为搜索引擎喜欢变化不频繁的页面,而html文件则是静态网页。但是背后是怎么实现的呢?
关键在于Nginx的规则重写。使用rewrite,可以将特定规则的链接,重定向到指定的文件进行处理。

server {
    server_name dev.job.com;
    ……

    if (!-e $request_filename) {
        rewrite ^/list-(.*).html$ /list.php?type=$1 last;
    }
}

做好这些基础准备后,接下来就可以拥抱排序这一富有变化的领域了。

3 二次干预的运营排序

默认的基础排序,是根据数据最初录入的信息,结合业务自身的规则进行的排序。这一环节也可以包含用户在页面功能操作的排序。接下来是第二部分的排序,即二次干预的运营排序。
出于内部排序的需要,或者是与供应商之间的合作关系,又或者是战略伙伴的要求,对于基础排序后的列表数据,我们要拥有二次干预的能力。比如,根据权重、评分、特定分类进行综合的二次排序。而这些与排序有关的因素,可以通过内部的管理后台或者运营平台进行可视化的管理和操作,以及Excel的导入和导出。这一块倒不是我们讨论的重点,我们关注的是如何有效整合多套排序,快速响应业务的需求。
在这次运营排序中,我们先按平常的开发模式简单实现第一个版本,顺便熟悉多维排序的实现思路。这些基础性的内容,会为下面的排序演进做好铺垫。随后,我们会在迭代推荐算法的实时排序时,对现在的排序体系进行重构,使之更加灵活、优雅、易于扩展。当最后实现活动排序时,我们会回顾单元测试的应用,并重新审视整套排序微架构的设计。
再次根据刚学到的数据模板,先对前面实现的列表页控制器进行局部重构,把招聘岗位列表信息的获取,转移到轻量级数据模板中。驾轻就熟,此次局部重构可以分为三小步。
第一小步,创建列表页查询对象,封装部分所需要的列表页参数。

<?php
// ./data/ListQuery.php 文件
require_once dirname(__FILE__) . '/dataprovider/DataQuery.php';

class ListQuery extends DataQuery {
    // 所在城市,如:gz=广州,sz=深圳
    public $city;

    // 岗位类型,如:php, java
    public $type;

    public function __construct() {
        // 默认城市
        $this->city = 'gz';

        // 预览时指定的城市
        if (!empty($_GET['city'])) {
            $this->city = $_GET['city'];
        }

        $this->type = $_GET['type'];

        parent::__construct();
    }
}

第二小步,使用轻量级数据模板,实现对招聘岗位列表数据的获取与缓存封装。

<?php
//  ./data/JobListData.php 文件
require_once dirname(__FILE__) . '/dataprovider/LightWeightDataProvider.php';
require_once dirname(__FILE__) . '/../model/JobModel.php';

class JobListData extends LightWeightDataProvider {

    protected function doGetData(DataQuery $query, &$trace = '') {
        // 全球追踪器:数据库
        $trace .= 'D';

        $model = new JobModel();
        return $model->getJobList($query->city, $query->type);
    }

    protected function getCacheKey(DataQuery $query) {
        return 'job_list_city__' . $query->city . '_type_' . $query->type;
    }
}

第三小步,改写控制类的调用,从对原始的参数和Model模型层的使用,切换成查询对象以及数据供给器的搭配应用。

<?php
// ./controller/ListController.php 
require_once dirname(__FILE__) . '/../data/ListQuery.php';
require_once dirname(__FILE__) . '/../data/JobListData.php';

class ListController {

    public function show() {
        // 列表页查询对象
        $listQuery = new ListQuery();

        $jobListData = new JobListData();
        $job = $jobListData->getData($listQuery);

        // 视图渲染
        include(dirname(__FILE__) . '/../view/list.html');
    }
}

完成这三小步的局部重构后,再次访问列表页,功能正常。这里,再稍微延伸说明一下。首先,在重构过程中,不应该同时新增功能,即要么戴着“重构”既有功能的帽子,要么戴着“开发”新增功能的帽子,不应同时戴两顶,否则容易造成思维上的混乱。其次,大家在遇到这些代码时,不要轻易跳过,或者翻过去。我也阅读过不少技术类的书籍,里面也会粘贴很多实现代码,影响了阅读,同时不能把思想更好的表达出来。因此,我对于本书的代码编排,会尽量做到精简,同时又会兼顾内容连贯和表达上的完整性,并且保证关键的信息不会丢失。
刚刚完成的这三小步,对于已经有着实际开发经验并熟悉前面数据供给器设计思路的同学,应该很容易理解。即便不看上面提供的源代码,自己也可以从零到一实现一遍。但重点是,我们要明白完成了这次局部重构之后,我们要做什么。或者说,我们为什么要局部重构?
因为我们要进行二次干预的运营排序。
还记得数据供给器在设计时,提供的回调函数吗?通过这个钩子,我们可以进行额外的操作,一如这里的运营排序。这才是我们的重点。

class JobListData extends LightWeightDataProvider {
    ……
    protected function afterGetData(DataQuery $query, &$data) {
        // 进行运营排序
    }
}

在对迷你招聘网站的招聘岗位列表进行运营排序时,需要综合考虑两个因素:内部设置的权重,以及招聘岗位的评分。权重由运营部门维护和配置,而评分则是根据岗位的待遇、招聘要求、企业的规模、应聘者的评价等得出的评分,评分越高,表示招聘岗位越优质。权重最大值为100,评分最大值为10。这两个因素的排序规则如下:
规则一:评分大的排前面,评分小的排后面,如果评分相等则再比较权重
规则二:权重大的排前面,权重小的排后面,如果权重相等则保持原来的排序

与此同时,我们的招聘岗位信息补充了评分和权重后的数据如下:

表2 带权重和评分的招聘岗位

ID

招聘岗位名称

评分

权重

21

PHP软件开发工程师

8.8

90

22

高级PHP工程师

9.2

80

23

资深PHP开发工程师

8.8

92

24

 

PHP程序员

8.6

95

25

高级PHP开发工程师

9.3

88

实现评分和权重这两条规则的排序并不难,但正如前面讨论,如果你已熟悉对usort()函数的使用,要抵制住一开始就不加考虑而选择使用usort()来实现。先放下键盘,稍微了解下array_multisort()这一函数的使用说明,以及它的示例。将招聘岗位里面每个元素的评分和权重分别依次提取出来,最后可以进行多维度的综合排序。

class JobListData extends LightWeightDataProvider {
    protected function afterGetData(DataQuery $query, &$data) {
        // 进行运营排序
        $scores = array();
        $weights = array();

        foreach ($data as $it) {
            $scores[] = $it['score'];
            $weights[] = $it['weight'];
        }

        // 多维度排序
        array_multisort($scores, SORT_DESC, $weights, SORT_DESC, $data);
    }
}

注意到,这段操作是实时计算的。此运行环境便于程序可以根据一些时间节点做出即时的处理,并且只有当用户来访问时才会触发。
此时,刷新招聘岗位的列表页,可以看到进行运营干预排序后的顺序变化。

图4 运营排序后的列表页

到这里,我们又完成一次需求迭代,实现了二次干预的运营排序。正当我们举杯相庆时,殊不知又一波新排序需求即将来临,而且来势凶凶。那就是——

4 推荐算法的实时排序

随着近几年大数据、神经网络、推荐算法、人工智能的兴起,越来越多企业都将推荐算法作为人群精准推送的一大利器。经过模型训练,以及用户画像的分析,在用户浏览网站时根据其喜好进行动态排序,做到千人千面。推荐算法由大数据部门提供和实现,而如何接入到上层业务则是我们PHP开发团队要做的工作。
再一次,当迭代的需求越来越多,变化越来越快时,我们就应当重复审视当前的架构以及实现方式。它是否能贴合业务发展的需要?它是否能有力支撑需求的快速迭代?这些都是非常值得我们思考的。系统的变化点在哪里,代码热区又在哪里?系统会因为我们将要添加的代码而变得更加臃肿还是更加清晰了?
软件开发是一个需要频繁沟通、密切协作,以及高智力的过程。如果系统变得越来越难以理解、难以维护,需求迭代的速度越来越步履维艰,线上故障和风险一个接一个,这时我们不应该把责任归咎为领域业务的复杂,而是应该深刻思考:我们的代码写对了吗?
我敢断定,作为一名软件开发工程师,大部分的同学在此时此刻,基于前面已经实现的排序基础,当需要新增推荐算法的实时排序时,都会“自然”地这样编写代码……

class JobListData extends LightWeightDataProvider {
    protected function afterGetData(DataQuery $query, &$data) {
        ……
        // 多维度排序
        array_multisort($scores, SORT_DESC, $weights, SORT_DESC, $data);

        // 推荐算法的实时排序
        // TODO:在后面紧接着编程实现……
    }
}

在实现运营排序的后面紧接着实现推荐算法的排序,有什么不妥呢?这是因为,可以预见到,列表页的排序是频繁改动的地方,也就是所谓的代码热区。几乎每次迭代或多或少都需要改动、调整或者新加排序方式。在JobListData::afterGetData()这一函数里,将会是列表页排序的温床,但这并不表示任何与排序相关的代码都适合放在这里面。秉着关注点分离的原则,以及单一职责原则,更推荐的做法是将不同的排序方案封装成单独的职能类,把实现和调用分离后,JobListData::afterGetData()则会演进成精瘦的客户端,只需要一行或两行代码就能完成整套的排序需求。
所以,借此推荐算法排序的新需求,我们开始进行列表页排序的架构设计。也正如人们所说的,架构是演进出来的,而不是一开始就能设计出来的。当然,既要设计,也要演进,理论与实际相结合,才能像双螺旋结构那样不断进化。
现在,我们先把推荐算法这一具体需求暂且放在一边,而关注新排序架构的搭建。
在我非常喜欢的《设计模式解析》一书中,Alsn和James为我们提供了共性和可变性分析、三种视角与抽象类之间的关系,其中三种视角是指:概念视角、规约视角、实现视角。这一认知,对于应对复杂的业务开发,非常有启发性。接下来的新排序架构,也是在这一思想指导下慢慢浮现出来的。
首先,在共性分析方面,包含了概念视角以及规约视角。而概念视角是指,站在业务需求的角度看待,业务上需要什么?在这里是列表页的多套排序。那么,能不能用一句话来描述?能,即:对用户访问的招聘岗位列表,进行运营排序、推荐算法排序、以及特定主题的活动排序。“做正确的事情,比把事情做正确更重要。”只有当我们明确知道最终的需求是什么,才能更有针对性去分析、设计和实现。
既然如此,我们看下能不能用一行代码高度概括此本质需求,不仅能清晰表达自身的目的,同时在代码实现上是可行的。

class JobListData extends LightWeightDataProvider {
    protected function afterGetData(DataQuery $query, &$data) {
        // 1. 指定上下文
        // 2. 装载排序插件
        // 3. 进行排序
        ListSortEngine::context($query)->load('BusinessListSort')->load('RecommendListSort')->sort($data);
    }
}

如上面代码所示,用一行代码表示是可行的。为了进行列表页排序,我们引入了列表排序引擎,然后指定上下文,装载运营排序和推荐算法排序这两个插件,最后进行综合排序。这一行代码,不仅释意,而且灵活度高,对于排序插件的装载非常便巧,想加则加,欲减则减,还可以自由调配排序方案的先后顺序。
从最终的实现效果,再往回反推我们的设计与实现。因此,接下来就是共性分析的另一方面——规约设计。借鉴Linux的管科道概念,以及插件“即插即用”的设计理念,通过统一的排序接口,可以把多个不同的插件通过类似管道这样的方式串联起来,使之协同工作。既能保持每个排序插件的独立性和正交性,又能自由组合、有机搭配,做到高内聚、低耦合。
“针对接口编程,而不是针对实现编程。”以下是列表页排序的接口规约,很简单。ListSort抽象类定义了排序接口,并保存了查询参数对象这一上下文信息,以便为进行具体的排序提供必要的数据。

<?php
//  ./data/sort/ListSort.php 文件
/** 
 * 排序接口规约
 */
abstract class ListSort {
    protected $query;

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

    abstract public function sort($list);
}

接下来,通过重构方式,把前面实现的运营排序,封装到一个新的运营排序类BusinessListSort。

<?php
// ./data/sort/BusinessListSort.php 文件
require_once dirname(__FILE__) . '/ListSort.php';

/** 
 * 运营排序
 */
class BusinessListSort extends ListSort {
    public function sort($list) {
        // 进行运营排序
        $scores = array();
        $weights = array();

        foreach ($list as $it) {
            $scores[] = $it['score'];
            $weights[] = $it['weight'];
        }

        // 多维度排序
        array_multisort($scores, SORT_DESC, $weights, SORT_DESC, $list);

        return $list;
    }
}

同时,为本次新增的推荐算法排序创建RecommendListSort,先提供空实现,后面我们再来完善此需求。

<?php
// ./data/sort/RecommendListSort.php 文件
require_once dirname(__FILE__) . '/ListSort.php';

/** 
 * 推荐算法排序
 */
class RecommendListSort extends ListSort {
    public function sort($list) {
        return $list;
    }
}

到这里,我们又一次进行了重构,不过这一次比前面的改动更大一点。前面是基本的脚本事务型的写法,而这一次则是面向企业级系统的工程式开发范式。但距离恢复我们招聘列表页正常访问还差最后两步。第一步,实现列表页排序引擎;第二步,完善对排序引擎的调用。
对于列表页排序引擎的实现,它的实现并不复杂,将必要的参数保存起来,然后在最终进行排序时遍历全部排序插件,委托排序。ListSortEngine的实现代码如下,但关键的不是代码本身,而是代码背后所蕴含的设计。ListSortEngine在客户端调用和具体的排序插件实现之间建立了一条桥梁,使得排序这一领域的复杂性能分解成更简单的单元和模块,从而更加清晰、易懂。

<?php
// ./data/sort/ListSortEngine.php 文件
/**
 * 列表页排序引擎
 */
class ListSortEngine {

    protected $query = null;
    protected $plugins = array();

    public static function context($query) {
        return new self($query);
    }

    protected function __construct($query) {
        $this->query = $query;
    }

    public function load($plugin) {
        $this->plugins[] = $plugin;
        return $this;
    }

    public function sort(&$list) {
        foreach ($this->plugins as $plugin) {
            $obj = new $plugin($this->query);
            $list = $obj->sort($list);
        }

        return $list;
    }
}

最后,完善JobListData::afterGetData()这一客户端调用的代码。补充必要的文件引入,其实这只是本次示例的需要,实际项目开发时会有自动加载机制代替这一手工引入。此部分的代码不再赘述。
完成最后一步后,招聘列表页又恢复如初了。虽然表面上看不出变化,但本质上已经明显有所改善。当然,新的设计方案会比前面单刀直入的方式稍微复杂,实现的周期和成本也要更大一点,但是解决方案的复杂性应该是与问题域的复杂性成一定比例的。问题越复杂,解决方案就要更加完善。
回到本节主题,在新的排序架构下,添加推荐算法的排序,就水到渠成了。打开RecommendListSort类,并在里面实现具体的排序。这里的推荐排序,又会有些差异,因为要把现有的招聘岗位的全部ID透传给推荐算法服务,然后再根据接口返回的ID新序列进行重新排序。

<?php
// ./data/sort/RecommendListSort.php 文件
require_once dirname(__FILE__) . '/ListSort.php';

/** 
 * 推荐算法排序
 */
class RecommendListSort extends ListSort {
    public function sort($list) {
        $oldIds = array();
        $oldList = array(); // 以招聘id为索引的列表

        foreach ($list as $it) {
            $oldIds[] = $it['id'];
            $oldList[$it['id']] = $it;
        }

        $newIds = $this->callRecommendSortApi($oldIds);

        $newList = array();
        foreach ($newIds as $id) {
            // 场景1:推荐算法有的,原列表没有的,则忽略
            if (!isset($oldList[$id])) {
                continue;
            }

            // 场景2:推荐算法有的,原列表也有的,则按推荐算法排序
            $newList[] = $oldList[$id];

            // 划除
            unset($oldList[$id]);
        }

        // 场景3:推荐算法没有的,原列表有的,则置后
        $newList = array_merge($newList, $oldList);

        return array_values($newList);
    }
}

这里有两个要点,第一个是需要调用远程的推荐算法接口,将现有的招聘岗位ID列表透传给推荐算法,然后接口再返回新的ID序列。对于这部分,我们暂且使用模拟的方式来实现。例如,我们最初的推荐算法还是很笨的,只会懂得把奇数的ID排在前面,偶数的ID排在后面。第二个要点是原有ID序列与新ID序列的结合,这里又要考虑三种情况:
场景1:推荐算法有的,原列表没有的,则忽略
场景2:推荐算法有的,原列表也有的,则按推荐算法排序
场景3:推荐算法没有的,原列表有的,则置后
这三个场景也在代码中做了相应的注释。这也提醒了我们开发人员,在进行企业级系统开发时,要尽量把功能做完善,多考虑不同的场景,增强系统的容错性。例如这里的推荐算法排序,应该只能干预招聘列表的排序,而不应影响招聘信息的数量变化。特别对于推荐算法没有返回的,但原招聘岗位列表存在,需要保留,至于置后还是置前,则可以和业务商定。
例如,假设原有的岗位ID序列为:
21、22、23、24、25
推荐算法返回的岗位ID新序列为:
23、24、25、21、26(原来没有)、27(原来没有)
由于原来的22没有返回,因此要保留在最后。另一方面,虽然推荐算法额外返回了26、27,但由于我们没有这两个ID的招聘岗位信息,则忽略。最终,结合推荐算法后的ID序列为:
23(按推荐算法排序)、24(按推荐算法排序)、25(按推荐算法排序)、21(按推荐算法排序)、22(保留并置后)

图5 推荐算法排序示例

仔细想一下,以上这些场景都是有可能发生的。毕竟在两个系统之间进行通讯,信息可能会有延迟,又或者推荐算法系统出现故障或缺陷导致部分或全部ID返回失败。做最坏的打算,做最充足的准备。
完成推荐算法的实时排序接入后,让我们把镜头移向下一个排序——

5 特定专题的活动排序

这一节讲述的是四大排序最后的活动排序。它的需求背景是,公司出于活动的需要,或者与特定合作方的战略要求,需要把指定的某个品牌单独置顶。同样地,我们的重点不在于这个排序业务本身,更多考虑的是通过支持新的排序所引发的思考和不断改进。
可以说,前面新排序系统实现了高效开发,当需要增加一个新的排序方式时,例如这里的特定专题的活动排序,根据开放-封闭原则,只需要修改调用,然后再遵循排序接口扩展实现活动排序,即可优雅完成此需求。首先,追加对TopListSort活动置顶排序插件的装载。

class JobListData extends LightWeightDataProvider {
    protected function afterGetData(DataQuery $query, &$data) {
        // 1. 指定上下文 -> 2. 装载排序插件 -> 3. 进行排序
        ListSortEngine::context($query)
            ->load('BusinessListSort')->load('RecommendListSort')->load('TopListSort')
            ->sort($data);
    }
}

然后,扩展实现活动排序的具体逻辑。创建TopListSort活动排序类,继承ListSort抽象类,并实现。这一层属于可变性分析的实现视角。一如前面的运营排序、推荐算法排序。这些都是属于变化的区域,各有各的不同。

<?php
// ./data/sort/TopListSort.php 文件
require_once dirname(__FILE__) . '/ListSort.php';

/** 
 * 活动排序
 */
class TopListSort extends ListSort {
    public function sort($list) {
        // TODO: 待实现
        return $list;
    }
}

那么这一节,延伸讨论什么主题呢?跟随开发效率而来的,就是我们始终关注的项目质量。是时候回来讲讲自动化单元,以及系统可测试性了。这并不是说我们前面没有关注质量,而是为了不让主题过于分散,我们逐个来讨论。同时,除了讨论质量外,我们还会稍微探讨企业级系统开发过程中如何友好支持测试、线上故障排查。
可以看到,各个排序插件都是能单独测试的,它们本身就具备了可测试性。以运营排序插件为例,我们可以构建需要待排序的序列和评分、权重数据,然后进行运营排序,最后验证新的排序,也就是前面所介绍的构建-操作-检验模式。

<?php
// ./tests/data/sort/BusinessListSort_Test.php 文件
require_once dirname(__FILE__) . '/../../../data/sort/BusinessListSort.php';

class BusinessListSort_Test extends PHPUnit_Framework_TestCase {
    public function testSort() {
        // 第一步:构建
        $list = array(
            array('id' => 21, 'score' => 7, 'weight' => 90),
            array('id' => 22, 'score' => 8, 'weight' => 80),
            array('id' => 23, 'score' => 9, 'weight' => 70),
        );

        // 第二步:操作
        $plugin = new BusinessListSort(null);
        $newList = $plugin->sort($list);

        // 第三步:检验
        $this->assertEquals(23, $newList[0]['id']);
        $this->assertEquals(22, $newList[1]['id']);
        $this->assertEquals(21, $newList[2]['id']);
    }
}

这样,我们就能如法炮制,为推荐算法排序,以及将要实现的活动排序,进行独立的测试,以确保交付的排序插件是高度可信、能在恶劣情况下也能正常工作的。完成上面单元测试的编写后,执行PHPUnit单元测试,就能看到我们熟悉的结果输出了。

$ phpunit ./tests/data/sort/BusinessListSort_Test.php
PHPUnit 4.3.4 by Sebastian Bergmann.

Time: 37 ms, Memory: 6.25Mb

OK (1 test, 3 assertions)

正交性塑造了可测试性,而可测试性又反推了正交性的形成。在完成了单个排序插件的测试后,就可以将焦点转向排序引擎的验证。这一块非常有趣,因为其中的测试很微妙。
首先,撇开现有的三个具体的排序插件,回到前面重构时,只有列表页排序引擎的时代。为了验证排序引擎这一机制能否正常工作,我们先为单元测试添加两个排序插件:逆转排序,和随机排序。

// 逆转排序
class ListSortReverse extends ListSort {
    public function sort($list) {
        return array_reverse($list);
    }
}

// 随机排序
class ListSortShuffle extends ListSort {
    public function sort($list) {
        shuffle($list);
        return $list;
    }
}

接下来,我们要验证在多个排序插件的共同排序后,不管中间如何排序,最终排序后的列表的数量应当保持不变,并且原来的列表元素一个也不能少,一个也不能多。以下测试用例保证了这一点。

class ListSortEngine_Test extends PHPUnit_Framework_TestCase {

    public function testSort() {
        $list = array('A', 'B', 'C');
        $newList = $list;

        ListSortEngine::context(null)
            ->load('ListSortReverse')->load('ListSortShuffle')
            ->sort($newList);

        // 排序后,数量应当不变; 且一个也不能少,一个也不能多
        $this->assertCount(3, $newList);
        $this->assertCount(0, array_diff($list, $newList));
    }
}

此外,还要保证单个排序插件在装载后,能被正确地调用,以及返回正确的结果。以下测试用例则是针对逆转排序插件的调用,并且验证排序后的序列符合期望返回的结果。

class ListSortEngine_Test extends PHPUnit_Framework_TestCase {
    public function testSortAlone() {
        $list = array('A', 'B', 'C');

        ListSortEngine::context(null)->load('ListSortReverse')->sort($list);

        $this->assertEquals(array('C', 'B', 'A'), $list);
    }
}

当列表排序引擎、各个排序插件都经过充分测试后,就能保证以高质量交付并上线。以此努力换取的回报则是线上系统平衡、健壮运行,高达99.999% SLA的服务标准。
回过头,使用静态类结构UML图总结我们当前的新排序系统,以及配套的单元测试体系,可以得到浮现式的设计。如下图所示,在左上方,是最终客户端调用的简洁代码,只需要一行代码,就能完成多个排序插件的装配,并完成复杂排序。在上层,是共性分析的概念视角,列表排序引擎把排序插件的装配、初始化和调用全部封装在内部,暴露给外界的则是简单明了的接口。如果把context、load、sort这三个接口串联起来,则是对列表页排序业务的高度概括。再进一步,如果结合DSL,我们还可以得到更精进的设计。例如,使用外部DSL描述使用上述三个排序插件的表示方式可以是:

load BusinessListSort
load RecommendListSort
load TopListSort
sort

继续往下,则是共性分析的规约视角,也就是ListSort排序接口。

图6 新排序系统及其单元测试

值得注意的是,规约视角,既可以属于共性分析,因为它统一了接口签名,又可以属于可变性分析,因为不同的模式下、不同的思想,接口设计的方式又各有不同。例如这里使用了按值传递列表参数,并返回排序后的新列表。但也可以使用结果收集式的方式,通过引用直接修改列表数据。如果把按值传递的方式改为按引用传递的方式,那么接口规约发生变化,随之具体的实现也需要相应调整。
最后新排序系统的第三层是实现视角,这部分是属于可变性分析,因为排序方式是会发生变化的,除了自身的排序规则有调整外,排序插件也可以增加、或者删除、或者顺序调换。例如,先进行大数据推荐排序,再到运营排序,最后到活动排序。
比往常的设计多考虑的是,我们把单元测试也纳入了进来。你可以说此部分是属于事后单元测试,但也可以理解成为测试驱动开发的成果,而我则更倾向于这部分是可测试性的设计。每一个排序插件,都能单独进行测试;对于核心排序引擎,也能充分模拟进行边界测试、异常测试、和Happy Path测试。
回归到本节的主题,让我们通过测试驱动开发的方式,完成特定专题的活动排序需求。编写针对活动排序的单元测试,构建必要的访问参数,然后初始化活动排序插件,最后进行相应的断言,检验ID为22的元素是否能居于第一位。

<?php
// ./tests/data/sort/TopListSort_Test.php 文件
require_once dirname(__FILE__) . '/../../../data/ListQuery.php';
require_once dirname(__FILE__) . '/../../../data/sort/TopListSort.php';

class BusinessListSort_Test extends PHPUnit_Framework_TestCase {

    public function testSort() {
        // 第一步:构建
        $list = array(
            array('id' => 21),
            array('id' => 22),
            array('id' => 23),
        );

        $_GET['type'] = 'php';
        $_GET['top_id'] = 22;

        // 第二步:操作
        $query = new ListQuery();

        $plugin = new TopListSort($query);
        $newList = $plugin->sort($list);

        // 第三步:检验
        $this->assertEquals(22, $newList[0]['id']);
    }
}

此时,运行单元测试,结果是失败的。这没关系,根据意向导向编程,我们可以根据错误提示,完善ListQuery列表页查询类,追加置顶ID参数及其初始化。同时,继续在TopListSort::sort()方法内完成置顶排序操作。实现代码如下,思路是如果待置顶ID存在,则遍历寻找,如果找到则置顶,最后返回新排序的列表。

class TopListSort extends ListSort {
    public function sort($list) {
        if ($this->query->topId <= 0) {
            return $list;
        }

        $topItem = null;
        foreach ($list as $key => $it) {
            if ($it['id'] == $this->query->topId) {
                $topItem = $it;
                unset($list[$key]);
            }
        }

        if ($topItem !== null) {
            array_unshift($list, $topItem);
        }

        return $list;
    }
}

实现完毕后,再次运行单元测试,就可以看到绿色通过啦!
下面,我们将会把刚实现的活动排序与实际业务结合起来。在浏览器将页面切换到迷你招聘网站的首页,还记得我们有一个模块是热门职位吗?通过热门职位,或者说通过外部投放的广告,以热门职位为切换点,吸引用户点击进入我们的网站。但为了给目标用户提供更多职位选择,产品人员希望可以把用户引导到招聘列表页,但同时又需要将用户在广告位看到的招聘岗位放在第一位,避免用户迷茫。这时,就是我们的活动排序大显身手的时候了!
暂且以我们招聘首页为例,把第二个热门职位,即:资深PHP开发工程师,招聘详情ID为23,这一跳转链接从详情页改为跳转到列表页,并且链接为:

http://dev.job.com/list-php.html?top_id=23

点击进入列表页后,经过一系列的排序,以及本节的活动排序,将会看到ID为23的招聘岗位显示在第一位。

图7 置顶拜序的效果

这就是功能代码开发、自动化单元测试、实际运行效果的完整介绍。
但这样就已经完成了吗?还记得我们坚持的原则吗?我们不仅要做到完成,还要做到完善,甚至要追求极致做到完美。那么下一步是什么? 我们的步伐又将迈向哪一方?
大家都知道,当系统规模越来越大时,业务逻辑越来越复杂时,问题的定位将会变得越来越困难,花费的时间也会越来越长。特别对于上层业务,对于面向用户端的系统,对于上游展示网站,需要排查的问题会更多。有时商务会找到技术人员,询问为什么我设置的运营排序没有生效?结果一查,是因为被后面的活动排序置顶操作覆盖了。而有时,确实是属于我们自身代码的问题,但原因在哪里?我们又该如何快速定位,或者说有什么工具或者技巧帮助我们快速定位吗?
答案是肯定的。下面将分享这一技巧。
辅助排查的技巧,即要将复杂的逻辑可视化,具体实现的方式则可以多种多样。以此列表排序为例,我们可以在页面输出时,追加对每个排序插件的中间结果也进行输出。当然,能否将这些排序中间结果输出,是否会存在安全问题,或者存在敏感信息泄露的问题,你要咨询所在公司的安全部门或者架构组。但即便有风险,我们也可以使用加密或者转换的方式,或者仅在调试模式下才显示的限制,进行强化。
好了,从前往后,先在排序引擎中,追加中间排序结果的纪录。

class ListSortEngine {
    public function sort(&$list) {
        foreach ($this->plugins as $plugin) {
            $obj = new $plugin($this->query);
            $list = $obj->sort($list);

            // 追加中间排序结果的纪录
            foreach ($list as $id => &$itRef) {
                if (!isset($itRef['__SEQ__'])) {
                    $itRef['__SEQ__'] = $id;
                } else {
                    $itRef['__SEQ__'] .= '-' . $id;
                }
            }
            unset($itRef);
        }

        return $list;
    }
}

这里使用了__SEQ__
下标,累加每次排序后的中间结果,其结果类似:1-2-3,分别表示第一次排序后在第一位,第二次排序后在第二位,第三次排序后在第三位。
接着,在视图模板文件./view/list.html中,追加对以上__SEQ__
中间排序结果的输出。为了隐晦一点,放置在a标签的data_seq内。如下:

          <p><a class="btn btn-default" target="_blank" href="/detail-<?php echo $item['id']; ?>.html" role="button" data_seq="<?php echo $item['__SEQ__']; ?>" >查看详情 &raquo;</a></p>

最后,渲染列表页后,就可以看到类似下面这样的效果了。

      <div class="col-md-12">
          <h2>资深PHP开发工程师 <small>15k-30k / 经验5-10年 / 本科及以上 / 全职</small></h2>
          <p><a class="btn btn-default" target="_blank" href="/detail-23.html" role="button" data_seq="2-1-0" >查看详情 &raquo;</a></p>
      </div>
       ……
      </div>
      <div class="col-md-12">
          <h2>PHP程序员 <small>4k-5k / 广州 / 经验1年以下 / 大专 / 全职</small></h2>
          <p><a class="btn btn-default" target="_blank" href="/detail-24.html" role="button" data_seq="4-4-4" >查看详情 &raquo;</a></p>
      </div>

这样以后,我们就可以在页面源代码中,清晰看到每次排序后的变化情况。无论是对平时功能测试,还是线上故障排查,都大有裨益。而且对于测试人员也非常友好,因为他们可以有一种可视化的方式进行核对和检验。甚至测试团队可以基于此中间排序结果,再结合自动化测试工具进行UI自动化测试。当这一切都逐步完善时,我们看到的,将是系统稳健的身影,以及业务快速增长的大好前景!

6 排序小结

就列表排序而言,可以分为四大类,基于原始数据的基础排序、基于内部属性的排序、基于第三方系统新序列的排序、以及基于外部条件的排序。我们看待世界的方式,以及对理解的程度,将在某种程度上决定了我们对世界的回应。在面对不确定的未来时,在负责企业级系统开发时,我们应当致力于寻找符合领域本质的系统设计,并致力于开发贴全业务场景的功能实现。从完成到完整,从完整到完善,从完善到完美,发挥我们的智慧,投入我们的精力,不断进取。
在这一过程,则需要结合使用:CVA共性和可变性分析、三种视角、设计模式、TDD测试驱动、DSL特定领域语言、管道、插件、引擎……代码写得好,不仅仅在于PHP语法有多了解,而更多在于对于这些思想、工具、模型、原则的理解和应用。就如比如著名的作家,他的文章写得好,不仅仅在于他对文章本身的结构、段落、句法有多熟悉,而在于他的人生阅历以及他对于世界的洞见有多深刻。
因此,在日常开发过程中,我们不应只局限于需求开发本身,而要跳出这个“箱子”进行更全面的考虑。否则,就会慢慢退化为需求开发执行者,编码的“打印机”。在完成功能开发,满足业务需求的基础上,我们还应当考虑系统设计,关注开发效率;应当考虑可测试性,关注项目质量。有人说,开发效率和项目质量,就像天平两端,此消彼长。在我看来,不一定,反而效率和质量,两者皆可得,但要在付出一定的艰辛和努力后才能双丰收。以上,是我关于列表页排序的一点心得和总结。

发表评论