PHP大型网站开发之详情页开发范式

前面我们已经讨论了首页和列表页的开发范式,这一节将要讲解的是详情页开发范式。
需要注意的是,实际上,不管是首页、列表页还是详情页,它们之间的范式是通用的,可以互相穿插使用。只不过为了阅读上的方便,我们将与之关联较为密切的范式归类到了对应的页面范式内。例如,下面将要讲到的信息聚合、页面预览和智能工厂下的多版本共存,同样也适用于列表页,或首页。另一方面,数据模板和数据预热等,也可应用于详情页。

1 信息聚合

在详情页,呈现给用户的是详细的信息,包括但不限于参数规格、内容描述、套餐说明、附加评论。详情页的信息聚合是指需要整合多方信息,将商品的售前信息,或用户产生的内容,或收集汇总的数据全部汇聚在一起,并展示在详情页。

迷你信息与扩展字段

先来看详情页信息存储与划分的方式。需要多少信息量取决于业务场景的需求。而在某一领域业务内,最终的产品形态又会分为多个种类。例如,招聘网站的招聘信息可以分为社招类、校招类;旅游类的产品可分为跟团游、自由游、景点门票等;电商平台的商品可分为家电、美妆、图书、食品等。而这些详情信息,不管分类数量多少,参考共性与可变性分析,不同分类之间也会存在通用的字段、公共的基础信息。在这里,在开发详情页时,所提取出来的共性信息,我们称之为迷你信息。
迷你信息,则意味着一个原则:按需获取。这个原则看似简单,但作用非常明显,却又很容易被人忽略。在数据存储时,我们会推荐把详情页的基础部分的信息存放在一张主表里面,剩下的其他扩展信息、可选字段、后续迭代增加的额外附加数据,则存放到另外一张副表内,做到主次分明。这做样是有一定道理的。避免将主要和次要的数据放在一起,可以减少稀疏矩阵的情况,优化存储空间,并且也方便后期的维护工作。
在把核心的基础信息存放在主表后,根据“按需获取”这一原则,不管数据是通过数据库查询的,还是通过远程接口获取的,建议先根据详情页的唯一标识获取迷你信息,即先获取最小部分的信息,然后进行判断和决策,最后根据具体的分类以及需求,再获取对应扩展的信息。这样做的好处是,一方面,从性能上,获取迷你信息的速度会更快,涉及的资源和开销会更少;另一方面,在取到迷你信息后,再对具体分类的扩展信息单独分开处理,既符合“开放-封闭”原则,又符合正交性、关注点分离等原则。特别当网站系统经过几年的时间不断迭代后,如果前期全部的业务规则与逻辑处理都混在一起,势必会对日期的开发和维护造成巨大的阻力。“牵一发而动全身”,是我对这一现象的总结,在这背后的实际情况是,除了当年或者多年以来一直维护的开发工程师外,几乎没人敢踏进这片“雷区”,稍不小心,改动了某一行代码,就影响了整个详情页的最终展示。
再次小结一下,对于详情页的信息,要划分共性和可变性的信息,在存储上分主表和副表,最后在代码实现层面,在获取信息时,要分离迷你信息与扩展信息的获取。这一经验,虽然简单,但作用巨大,值得参考。

谨防富文本字段

可以发现,不管是博客论坛的文章页,还是电商平台的详情页;不管是虚拟类的,还是与实体类相关的,在详情页上或多或少都需要使用富文本字段。富文本字段是指可以存放HTML格式的字段,并且原样输出在详情页上,而不会被转码转义。
富文本字段对于内容编辑和展示有一定的便利性,但这一便利性是建立在不安全性和脆弱性之上的。所以,对于开放富文本的字段,要保持警惕的态度,并以保守的姿态对待。如果不是非得使用富文本字段不可,尽量建议不要轻易采用。因为富文本字段对于数据格式和规范都难以做到统一,其随机性和不确定性将会是日后维护的难点。
再回来刚才所说的两个缺点,使用富文本会有两个风险点:一个是来自外部的攻击或恶意使用,即产生不安全性;一个是来自内部人工录入失误或者系统缺少检测校验而导致最终HTML标签不闭合或非法,影响详情页的正常展示,即产生了脆弱性。不安全性典型的案例是XSS攻击,通俗的解释是,恶意用户将一段Javascript代码嵌入到富文本字段中,当在详情页展示时,通过浏览器运行这段不怀好意的Javascript代码,从而达到不良的企图。这些恶意的行为,可能是引导用户跳转到一个钓鱼网站,或者暗地里偷偷收集当前用户的COOKIE信息而伪造登录信息,不一而足。至于脆弱性,也是一个惹人争议的“事故多发地段”。在过往大型网站系统开发和维护的过程中,我不止一次看到,因为录入的富文本信息数据不符合HTML格式而导致页面显示错乱的情况发生,又或者因此前定义约定不明确导致显示未达到预期的期望和要求。例如换行符,是应用使用Linux的换行符”\n”,还是应该使用”
“HTML标签?
说到显示错乱,就顺便说一下编码问题。在开发网站系统时,有一个常见的问题就是编码问题。如果处理不好,就会造成页面显示乱码的情况。目前使用的主流编码是UTF8格式,并且要在以下这些方面做到编码统一:
数据库编码,包括数据库、数据库表、数据库表字段的编码
数据库查询和操作时设定的编码
PHP代码文件本身的编码格式
PHP代码运行过程中所使用或设定的编码
HTML页面响应时头部信息的编码

最后,再强调一次,要谨防富文本字段。

2 页面预览

在前面介绍首页开发范式时,我们已经讨论过页面预览,但当时的侧重点是缓存数据在预览模式下的穿透访问,关于缓存使用与否的问题。而这次的页面预览关注的是另一个维度,即预览数据的来源。根据过往的工作经验与总结,预览数据的来源可以是:
从数据库中读取草稿预览数据
通过第三方接口获取预览数据
将预览数据直接通过POST方式传递

下面分别说明。
最基本也是最常见的方式是,从数据库的草稿表读取当前编辑的临时数据,然后在页面展示。这时,需要考虑的是如何绕过缓存,并且在不影响现在原始数据的前提下,完成预览功能。
如果系统的层级划分较多,分为前台、接口中间层、后台的话,详情页的数据需要过经内部的第三方接口来获取。因此,在正式推送之前,在预览时,也是通过第三方接口来获取预览数据。提前预见这些预览的方式,能方便我们对将要发生的事情做好规划和准备。
还有一种预览方式是直接通过POST方式来传递需要预览的数据。这时的场景是,编辑人员在管理后台修改更新数据时,可以通过一个神奇的“预览”功能,把当前编辑中的数据整包空投到详情页,从而在不需经过后端系统一系列的关卡就直接预览。这种方式,方便快捷,但因为缺少后端系统的统一规则处理和输出,存在数据展示有误的风险。
另外一点需要注意的是,不管是何种方式的预览数据,都难以进行一下的操作。因为这些预览数据只是模拟、非正式、临时性的数据,无法根据这些预览数据下单或投放简历。

3 智能工厂下的多版本共存

以详情页为例,作为最终信息展示的落地页,也是终端用户查看具体信息的场地,会存在多套版本的情况,以满足不同场景的需要。根据不同的维度,可以有不同的分类。
例如,根据领域业务自身的特点和规则,可以细分品类和部落。一如前面所说的电商平台中的家电、美妆、图书、食品等商品。如果根据时间节点划分,则可以得出不同时期、不同阶段的状态,例如活动前、活动中、活动后,这是以时间为主线的切分。最后,还可以是根据ABTest进行的新功能或版本的测试和人群分流。以上种种,或交错,或组合,在还不考虑分销商、合作伙伴、定制化渠道的情况下,就已经存在组合爆炸了。那么在面对这种形势,在开发详情页时,又该采用何种开发范式才能有效应对呢?
答案是:工厂方法模式。
针对这种分支众多,有不同的页面路径,在实现过程中,会充斥着大量的if判断语句。更为严重的是,很容易会改动了其他路径的变量而影响到了另一个路径的变量,而且也不方便扩展。明显地,我们至少看到了两个代码异味:脆弱性和僵化性。要想得出优质的设计,我们首先需要深入理解业务的领域需求。遇到问题,考虑一下是否有合适的设计模式可以使用是非常有益处的。这里,我们果断采用了工厂方法来处理这个问题。
因为工厂方法可以担负复杂的创建过程、封装业务变化,更为重要的是:将创建和使用分离。最后一点尤其重要的,因为在复杂的创建之后,我们得到了一个更为清晰、各业务线独立的蓝图。这样既利于产品线的拓展,又不用担心新来的开发人员稍微的改动而引发一系列的连锁反应。经总结,我们发现适合使用工厂方法创建控制器的场景:
待创建的实例由动态配置决定
可能的路径
多分支、多路径
复杂的创建
完整性的业务规则创建

但是,在实现工厂方法时,我们需要应对返回值的微妙区别。通常,当创建失败时,可以是:
抛出异常
返回NULL
返回一个默认实例
返回空对象(Nulll Object模式,即无作为的假对象)

具体应用使用何种策略,具体可视业务场景而定。
简单回顾了工厂方法模式的相关知识后,接下来就是如何使用工厂方法解决我们在详情页或者其他页面遇到的多版本共存的棘手问题。继续以迷你招聘网站的详情页为例,进行解说。
首先,通过UML静态类结构图,我们可以提前以全局的视角了解整个详情页的设计,以及工厂方法的具体应用。

详情页静态类结构图

在开发网站系统时,开一点时间进行预先设计是有必要的。当前流行的敏捷开发会让人误以为不需要文档,也不需要进行预告设计,但恰当、清晰的文档和恰如其分的设计都是不可缺少的部分,与项目代码一样,也应作为最终的输出交付物之一。
DetailFactory是可以根据一个招聘岗位ID,创建一个详情页控制器实例的工厂方法。在这里面,封装了复杂的创建过程,包括对于业务逻辑规则的封装,也包括了对于异常情况的处理机制。因此,在这里我们把它称为智能工厂方法。正因为有了智能工厂方法,复杂的领域业务才得以清晰化,拥抱正交性。在我从事软件开发行业的这几年,我发现,不管是首页,还是列表页,抑或是详情页;不管是电商平台,还是支撑性的增值服务,还是小型项目,智能工厂方法通过将创建与使用分离,而大大降低了维护的成本,同时明显提升了系统的可用性。
根据唯一的ID,从外到内,接下来就是详情页控制器的类继承体系。根据不同的派生关系,这里是允许有多重继承的,当继承分支和层级较多时,我们就能将其整理为一棵决策树。反过来,决策树的推断过程也可以封装在智能工厂方法内。好的设计,是既能前进,亦能后退的。正如前面所说,在应用工厂方法时,要注意创建失败的情况。故而,这里提供了正常的校招类详情页DetailSchoolController和社招类详情页DetailSocialController,还提供了默认的详情页DetailDefaultController。这样的话,即便录入的招聘分类有误,最终也能降级显示,而非直接粗暴提示404 Not Found或运行错误。
再往下,就是我们基于前面轻量级数据模板的迷你详情数据和扩展详情数据。这部分最终会依赖于连接器、数据库或者高效缓存。由于这部分已经有所介绍,不再赘述。
这就是我们基于智能工厂方法的设计模型,概括来说,可以分为三个子模块:智能工厂方法、详情页控制器继承体系、基于轻量级数据模板的详情数据。模块与模块之间,都是以单向关联的方式相知相识。这也是符合领域驱动设计所说的单向关联类。
了解完详情页的设计模型后,再来探索它的代码模型。这一次我们关注的是核心详情页代码背后体现的设计思想,而不是具体的实现过程。因此,接下来的代码将会根据前面设计模型的主线依次加以说明。基础性的代码暂不过多关注。
一个迷你招聘网站的详情页链接,如下:

http://dev.job.com/detail-25.html

结合Nginx的rewrite规则,可以将上面的静态页面重定向到detail.php入口文件。记得修改完后要重启Nginx。

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

有意思的是详情页的入口文件的编写方式。我觉得,从不推荐到强烈推荐,可以有四种写法。
第一种,就是典型事务型脚本。使用面向过程的编程范式,把实现的代码、调用的代码和入口代码全部放在同一个PHP文件内。这种方式,原始,混乱,复杂,臃肿。极其不推荐。
第二种,是根据前面的设计模式,提纲挈领,把关键的客户端调用代码,以精简的代码表达了出来。

<?php
// ./public/detail.php 文件
require dirname(__FILE__) . '/../data/DetailQuery.php';
require dirname(__FILE__) . '/../controller/DetailFactory.php';

$query = new DetailQuery();

$controller = DetailFactory::createController($query);
$controller->render();

虽然第二种写法比第一种写法明显好了很多。我们看代码,不应该只看表面,而是应该深入思考它为什么这么写,以及这些代码前后所蕴藏的思想、原则和模式。秉着不断追求完善的态度,可以对第二种写法进行优化,去掉$controller
临时变量,并且使用连贯接口的调用方式,最后得出第三种写法,那就是——

$query = new DetailQuery();

DetailFactory::createController($query)->render();

但是否这样就已经足矣?好的代码,是一行也不能多,一行也不能少。如果能一用代码来表示,就不要用两行代码,更不要用五行代码。所以,进一步,我们可以把上面的两行代码精简成一行代码,而不会影响它本来需要表达的目的。通过小步重构,继续去掉$query
临时变量,使得客户端详情页入口只消一行代码,就能完成一系列复杂的处理。第四种写法是:

DetailFactory::createController(new DetailQuery())->render();

在详情页入口文件之后,紧接着需要实现的是详情页控制器智能工厂方法的实现。此时此刻,它的实现逻辑还是相对简单的,但在实际项目中,这部分的实现将会非常有趣,因为它的复杂度和重要性,足以控制详情页的生死和前途。

<?php
//  ./controller/DetailFactory.php 文件
class DetailFactory {

    public static function createController($query) {
        $miniDetailData = new MiniDetailData();
        $miniData = $miniDetailData->getData($query);

        if ($miniData['type'] == 'school') {
            // 校招
            return new DetailSchoolController($query, $miniData);
        } else if ($miniData['type'] == 'social') {
            // 社招
            return new DetailSocialController($query, $miniData);
        }

        // 默认显示
        return new DetailDefaultController($query, $miniData);
    }
}

根据详情页的设计模型,有一个工厂类、详情页控制器抽象类及其三个实现子类。对应的文件目录和结构如下。

job$ tree ./controller/
./controller/
├── detail
│   ├── DetailDefaultController.php
│   ├── DetailSchoolController.php
│   └── DetailSocialController.php
├── DetailController.php
└── DetailFactory.php

此外,还有继承于DataQuery的详情页查询对象类DetailQuery,继承于LightWeightDataProvider的迷你详情页数据类MiniDetailData和更多详情页数据类MoreDetailData。此部分不再赘述。
这里穿插引入全球追踪器的使用。尤其对于多分支、多个业务分类的情况,在全球追踪器的前面增加页面种类的标识作为前缀,对排查问题、故障调试和功能测试都大有帮助。大家不要小看这么一个前缀标识,背后的意义不可小觑。作为专业的工程师,不应只根据页面的表象来断定或者判断它的真正类型,而是要结合充足的“证据”和数据来证明它确切属于哪个分类。这样在发生问题时,我们才能准备、快速定位到对应的分类,以及相关的代码位置,以此延伸的业务干系人。举个例子,有客户反馈说某个列表页上面的商品信息显示有误,我们可以根据全球追踪器的精准信息定位具体的商品部落,随后核对此批漏是属于技术性问题还是属于数据录入问题。如果是录入的信息有误,则可以联系对应的商务部门或同事进行核对和处理修复。
实现的思路是,可以在公共抽象类对全球追踪器作统一处理,包括初始化,以及最后的输出。至于中间的各子分支的定制化处理,则可以通过钩子的方式来特殊扩展。
巧妙的设计,能以一种相互协作、相互促进的姿态,引入到现有的系统中,而不是以一种破坏性的、入侵式的方式强硬融合进来。正如我们在实现详情页的过程中可以发现,当引入新的智能工厂方法时,已有的设计,比如数据模板,能够与新的设计充分有机整合在一起,共同发挥作用。从设计模型到实现模型,一气呵成。

4 爬虫、缓存与归档

纵使完成了详情页的业务功能开发与维护,也使用了合适的缓存体系,网站系统性能良好,但还要提前留意准备一件事情,那就是防爬虫,特别是恶意的爬虫。
很多网站的详情页ID,都是自增式,可遍历的。如果外界的爬虫从ID为1开始,遍历抓取历史的全部详情页,因为去年的详情页数据属于冷数据,没有存在高效缓存中,使得每次访问一次旧的数据,都会穿透访问数据库。如果爬虫抓取的频率过高,则会影响系统本身的正常响应与请求。
如果在业务上,详情页的数据是需要保留很长一段时间,以便用户回访或查看,那么这时就有必要将历史数据进行归档处理。在归档时,要注意几点。第一点,历史数据量较大,在存储时要选取合适的持久化存储方式;第二点,要区别数据归档与页面内容归档,前者只保存历史数据但会与当前最新的展示方式格式不匹配,后者是将整个页面的HTML内容进行归档却会容易遇到以往的CSS样式和Javascript脚本兼容性问题。第三点,要确定历史归档与当前正常访问的边界,即从何时开始,归属为历史数据。

发表评论