PHP大型网站开发之首页开发范式

首页,是产品的门面,也是大部分用户访问的第一个页面。它的重要性,不言而喻。
若想获得用户的青睐,提升用户体验,有力支撑业务功能,设计首页时有一定的开发范式。首页主要关注的是对分类信息的展示,以及热门数据信息的推荐,最为重要的是需要提供列表页的访问入口。
为了更具体地理解首页开发范式在实际业务场景的应用,我们以开发招聘网站为例,结合其业务进行说明。不难发现,当应聘者进入我们的招聘网站时,他会首先进入首页,定位到自己所在的城市,并点击与自己相关的职位,然后进行到招聘列表页。接着再从列表页点击感兴趣的招聘岗位进入详情页,最后投递自己精心制作的简历,期待面试的通知和安排。这就是我们招聘网站的主要业务,项目名称就叫:迷你招聘。

1 前期准备工作

以接下来的实现过程中,虽然是从零到一开发,但我们暂时不使用任何框架,而是基于前面介绍的、已实现的3+1套数据模板为核心技术,塑造招聘网站企业级系统的雏形,从而窥探抽象的范式、思想和概念如何落地到业务项目中、如何有机融入现有系统。

迷你招聘网站项目源代码:job-master

在前期,需要完成一些基本的项目准备工作。在这里,我们创建了一个Git代码仓库,存放项目的相关代码。通过Nginx在本地配置搭建了一个站点,网站地址是:http://dev.job.com/。因为这一节重点关注的是企业级网站数据的规划与实现,以及首页的开发范式,所以对业务数据的最终来源和深入的领域规则也暂时放一边,取而代之的是使用模拟的数据来表示。
最终,迷你招聘首页的实现效果截图如下:

图1 迷您招聘网站首页

2 规划你的数据

在招聘网站的首页,从上到下,主要有以下三部分数据:

顶部广告
职位分类
热门职位

根据数据稳定-访问象限分布图,以及结合前面所学的3+1套数据模板,我们稍微分析一下,对数据进行规划设计。
顶部广告,是属于稳定且低频访问的数据,虽然首页的流量也很高,但前面的顶部广告只是首页这一个页面使用到,并不是全部页面都需要,因此相对来说是低频访问的数据。顶部广告数据是稳定的,是因为通常内部广告的更新速度不会非常频繁,即便有更新,也可以接受一定时间内的延迟,例如几分钟的缓存时间。因此,顶部广告适宜采用轻量级数据模板。此外,这部分的广告信息由管理后台录入,并更新到数据库,前台通过数据库查询可获取此部分数据。
职位分类,也是属于稳定且低频访问的数据。它与顶部广告的性质类似。它之所以也是稳定的,是因为招聘类的职位分类一旦确定后,市场内在短时间内很少会有新的专业出现。但随时着公司的业务发展,可能会在不同的阶段拓展更多的职位。整体而言,这部分的数据也是稳定的。而且也是通过管理后台录入,前台从数据库查询即可。职位分类也适宜采用轻量级数据模板。
最后是我们的热门职位模块,这部分的数据与前面两份数据不同。它是非常活跃的,并且由大数据推荐算法根据用户画像实时提供。由于是千人千面的差异化推荐,因为热门职位适宜采用实时数据模板。
再用表格来总结整理,对于我们本次的招聘网站首页的数据规则,就更为明确了。

表1 迷你招聘首页的数据规划

 

首页数据

 

 

采用的数据模板

 

 

最终数据来源

 

 

顶部广告

 

 

轻量级数据模板

 

 

数据库

 

 

职位分类

 

 

轻量级数据模板

 

 

数据库

 

 

热门职位

 

 

实时数据模板

 

 

大数据推荐算法,远程接口

 

关于轻量级数据模板和实时数据模板,我们在前面均已实现并已掌握,接下来就是关于如何使用了。

3 基本实现思路

首页查询对象

在访问页面时,需要用到一些用户参数。这些输入的参数,可能是出现在链接后面,以GET参数表示,又或者是保存在用户浏览器,隐藏在COOKIE中。对于招聘首页,需要用到的参数有用户选择的城市city参数。为此,我们针对首页先创建IndexQuery查询类,并在里面添加城市city参数。

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

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

基于数据模板的实现

接下来,分别是首页三部分数据基于数据模板的实现。先来看顶部广告数据。创建AdData广告数据类,并继承轻量级数据模板LightWeightDataProvider基类,实现具体获取广告数据的细节,并指定缓存的key。

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

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

        $model = new AdModel();
        return $model->getTopBanner($query->city);
    }

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

可以说,数据类是基于数据规划和管理策略的代理层。这一层是我们重点关注的。至于数据库Model模型层的实现,以及背后的数据库表设计与数据库操作,暂且通过模拟的数据来表示。以下是AdModel类的实现代码。

<?php
// ./model/AdModel.php 文件
class AdModel /** extends Model **/{
    public function getTopBanner($city) {
        // 假设从数据库获取的数据如下……
        return array(
            'title' => '技术类校招专场',
            'desc' => '专为应届毕业生准备的招聘专场,快来看看吧!',
            'button' => '马上进入',
            'link' => '/act-1.html',
        );
    }
}

准备好广告数据后,就可以按MVC模式,在控制层获取数据并渲染视图。

<?php
// ./controller/IndexController.php 文件
require_once dirname(__FILE__) . '/../data/IndexQuery.php';
require_once dirname(__FILE__) . '/../data/AdData.php';

class IndexController {
    public function index() {
        // 首页查询参数对象
        $query = new IndexQuery();

        // 顶部广告
        $adData = new AdData();
        $ad = $adData->getData($query);

        include(dirname(__FILE__) . '/../view/index.html');
    }
}

作为完整的实现过程,再来看下视图模板./view/index.html的代码。

  <!-- 顶部广告 -->
  <div class="jumbotron">
    <div class="container">
      <br />

      <h2><?php echo $ad['title']; ?></h2>
      <p><?php echo $ad['desc']; ?></p>
      <p><a class="btn btn-default btn-lg" href="<?php echo $ad['link']; ?>" role="button"><?php echo $ad['button']; ?> &raquo;</a></p>
    </div>
  </div>

以首页查询参数为起点,从数据层,到模型层,再到控制层,最后到首页的视图模板,经过这一系列快速的、基本的实现,再次访问迷你招聘网站的首页,可以呈现以下运行效果。

图2 招聘首页的顶部广告

职位分类的实现过程和顶部广告的实现类似,这里不再赘述。至于热门职位,实现也和此类似,唯一不同的,热门职位使用的是实时数据模板。所以,在创建HotJobData热门数据类后,需要继承RealTimeDataProvider类。同时,在标识全球追踪器时,用字母A表示从远程接口获取。

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

class HotJobData extends RealTimeDataProvider {
    protected function doGetData(DataQuery $query, &$trace = '') {
        // 全球追踪器:远程接口
        $trace .= 'A';

        $model = new JobModel();
        return $model->getHotJob($query->city);
    }
}

完整的核心实现

完成顶部广告AdData、职位分类CategoryData、热门职位HotJobData这三分部的数据规划与实现后,在控制层,再把这些网站数据元素综合串联起来,就能实现首页功能的有机组合。结合全球追踪器,可以得到更加完备的体系。虽然目前还只是一个首页,但正所谓,麻雀虽小,五脏俱全。在首页的背后,是设计巧妙的微型系统,基于此微架构,可以延伸出大比例的结构,逐步演进,快速迭代。一环扣一环,环环相扣。我们先来讲首页控制层黏合后的实现代码,后面再来探讨在这基础上可以进行哪些扩展。
如果一个系统是经过精心设计的,那么它的代码是优雅、清晰,并且是简单的。它是那么容易理解,以致于不用过多解释。

<?php
// ./controller/IndexController.php 文件
require_once dirname(__FILE__) . '/../data/IndexQuery.php';
require_once dirname(__FILE__) . '/../data/AdData.php';
require_once dirname(__FILE__) . '/../data/CategoryData.php';
require_once dirname(__FILE__) . '/../data/HotJobData.php';

class IndexController {

    public function index() {
        // 首页查询参数对象
        $query = new IndexQuery();

        // 全球追踪器
        $trace = 'Index';

        // 顶部广告
        $trace .= '|AdData-';
        $adData = new AdData();
        $ad = $adData->getData($query, $trace);

        // 职位分类
        $trace .= '|CategoryData-';
        $cateData = new CategoryData();
        $category = $cateData->getData($query, $trace);

        // 热门职位
        $trace .= '|HotJobData-';
        $hotData = new HotJobData();
        $job = $hotData->getData($query, $trace);

        // 通过头部输出全球追踪器
        header('TRACE: ' . $trace);

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

就如上述首页控制层IndexController类的代码,遵循程序“输入-处理-输出‘这三大模块的顺序,在index()方法的最前面,我们创建了一个首页查询参数对象,完成初始化后,用户选择的城市会存放在IndexQuery::$city类属性。注意这个查询对象,是针对整个首页都适用的,可以把全部的参数扩展到IndexQuery,如果确实有需要,也可以进行查询子类的细分。
对比前面顶部广告单一模块的调用代码,当时没有使用全球追踪器。回顾前面的代码,是这样的,没有进行数据的追踪。

        // 顶部广告
        $adData = new AdData();
        $ad = $adData->getData($query);

这是因为当时我们关注是局部的实现,而现在则是通盘的考虑。为了观察全部数据经过的节点,可视化还原其链路,我们引入了全球追踪器$trace,并逐层往下传递收集。升级后的调用代码如下:

       // 顶部广告
        $trace .= '|AdData-';
        $adData = new AdData();
        $ad = $adData->getData($query, $trace);

只需要在原来的基础上,把数据的名称累加起来,并往下传递,晚点就可以收割成果了。

        // 通过头部输出全球追踪器
        header('TRACE: ' . $trace);

当底层已经支持,业务上层也实现对其调用后,再次访问首页,在浏览器内按F12进行调试,并切换到网络,可以发现在响应头部有以下追踪的信息:

TRACE:"Index|AdData-LD|CategoryData-LD|HotJobData-A"

根据这一条追踪信息,我们可以从专业的角度进行现场解说:你看啊,对于网站首页,顶部广告数据先从轻量级模板获取,因为没有缓存,穿透到了数据库查询。接着是职位分类信息,也是先从轻量级模板获取,后穿透到数据库获取。而最后的热门职位,则每次都是实时从远程接口获取到个性化推荐的数据。
不言而喻!
当这一切妥当后,我们就能看到了前面首页的运行效果。是不是感觉很奇妙,很简单?啊哈,软件开发,本来就很简单。
为了给读者一个更加整体的轮廓,我把本次项目的目录结构,以及相关的注释补充如下。

job$ tree
.
├── controller                  # 控制层
│   └── IndexController.php     # 首页控制器
├── data                        # 数据层
│   ├── AdData.php              # 顶部广告数据
│   ├── CategoryData.php        # 职位分类数据
│   ├── dataprovider            # 数据供给器
│   │   ├── DataProvider.php
│   │   ├── DataQuery.php
│   │   ├── LightWeightDataProvider.php
│   │   └── RealTimeDataProvider.php
│   ├── HotJobData.php          # 热门职位数据
│   └── IndexQuery.php          # 首页查询对象
├── model                       # 模型层
│   ├── AdModel.php
│   ├── CategoryModel.php
│   └── JobModel.php
├── public                      # 对外访问入口
│   ├── css
│   │   └── bootstrap.min.css
│   ├── index.php               # 首页入口
│   └── js
│       └── bootstrap.min.js
└── view                        # 视图层
    └── index.html

这只是一个开始,下面内容更精彩。

4 从完成到完善

至此为止,我们已经完成了招聘首页的功能开发。注意,这里只是说完成,而不是完善。上面一系列的开发,构建了一个初级的网站,但离完善的企业网站还有一段很长的距离,纵使引入了一些往常极少应用到的新技艺。那怎样做才更完善呢?
很多技术类的书籍,都会对PHP的基础语法,以及基本的项目开发作详细的介绍和说明,但对于如何有效地综合应用这些基础追求更完善的系统,则鲜有记载。我们也不缺乏核心的技术、高级的技术,但缺少把这些技术融入到项目业务中,产生更大的价值。
如何才能达到完善的状态呢?首先要从完善的定义开始。只有清楚知道一个完善的系统需要哪些特性时,我们才能更好地去构建它。这需要一些思维的转变。那就是考虑我们的用户需要什么,他们的诉求是什么,以及他们期待什么。
正如绝大部分的网站一样,它的用户其实不止一种。就迷你招聘网站而言,众所周知的是,它的最终用户是应聘者,那些想找工作的人。这部分是招聘网站的主要用户人群,也是核心的人群。但除此之外呢?还有就是我们这些软件开发工程师,我们需要开发、调试、排查故障,满足这些需求应该提供哪些技术支持呢?又比如企业内部的运营部门,在修改首页顶部广告时,他们希望能够在正式发布前可以先进行预览,以校对投放的广告信息在首页最终呈现的效果是否正确。但首页现在是没有预览功能的。
针对预览这一诉求,让我们向完善迈出第一步。

预览功能

很多网站,都需要能够提前预览的功能。预览的功能,仿佛能给人一种预知未来的能力,穿越到未来的某个时刻,看到将来的网站。当具备以下这几个条件时,我们离预览功能就远了。
第一,要支持指定访问参数的设置
第二,要支持当前时间的设定
第三,要支持缓存使用与否的控制

以招聘首页的预览功能为例,支持指定访问参数的设置,即意味着我们可以通过某种方式轻松切换当前选择的城市,即便初始时已确定当前的城市。为简单起见,假设选择的城市保存在浏览器COOKIE中,预览时则可以通过GET参数来进行覆盖。以下升级后的代码体现了这一点。

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

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

        // 用户真实选择的城市
        if (!empty($_COOKIE['city'])) {
            $this->city = $_COOKIE['city'];
        }

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

        parent::__construct();        
    }
}

在很多行业中,其领域业务是与时间有密切关系的。例如电商平台的商品,只有到了开售时间才能开始售卖。又如电影票的购买也要等到开映前的一段时间才能开始。但迷你招聘网站暂时还没有和时间有关的业务场景,暂时跳过。至于缓存使用与否的控制,则是绝大部分系统都需要的基础特性。关于这一点,我们前面没有实现。但没关系,我们拥有架构提升的能力,可以通过修改底层源代码来优化。
首先,对于查询对象的基类,添加两个开关,一个是缓存读开关cacheWrite,一个是缓存写开关cacheRead。默认情况都是开启状态。

class DataQuery {
    public $cacheWrite = true;     // 缓存读开关
    public $cacheRead = true;      // 缓存写开关
}

同样地,我们可以通过GET参数为预览时指定缓存使用与否的控制。为添加DataQuery添加构造函数,并在里面实现此逻辑。

class DataQuery {

    public $cacheWrite = true;     // 缓存读开关
    public $cacheRead = true;      // 缓存写开关

    public function __construct() {
        if (isset($_GET['cacheWrite'])) {
            $this->cacheWrite = $_GET['cacheWrite'] == 0 ? false : true;
        }

        if (isset($_GET['cacheRead'])) {
            $this->cacheRead = $_GET['cacheRead'] == 0 ? false : true;
        }
    }
}

只有当明确指定不需要缓存时,才把缓存的读写开关设置为关闭。
至此,我们可以阶段性快速验证目前所做的修改是否对已有的功能有影响,并且核对新增的功能是否能正常工作。临时修改IndexController首页控制器,将查询对象打印出来。

class IndexController {

    public function index() {
        // 首页查询参数对象
        $query = new IndexQuery();
        // TODO:临时调试,调完即删
        var_dump($query);die(); 

        ……
    }
}

再次访问前面,并指定预览时的城市,以及缓存开关的控制状态。例如访问:
http://dev.job.com/?city=sz&cacheWrite=0&cacheRead=0

可以得到输出的结果是:

object(IndexQuery)#2 (3) {
  ["city"]=>
  string(2) "sz"
  ["cacheWrite"]=>
  bool(false)
  ["cacheRead"]=>
  bool(false)
}

而默认情况输出的是:

object(IndexQuery)#2 (3) {
  ["city"]=>
  string(2) "gz"
  ["cacheWrite"]=>
  bool(true)
  ["cacheRead"]=>
  bool(true)
}

通过新增city、cacheWrite、cacheRead这三个预览时的参数,我们可以指定网站访问的上下文,分别从业务维度、技术维度,甚至时间维度框定系统所运行的时机和场合。通俗一点来讲,就是我们通过仿真的手段,塑造了期待发生的场景。接下来,就是如何把演员放进来,让他们按照我们新编排的剧本来演。
打开编辑器,进入到DataProvider数据供给器,修改既有的代码,把缓存读写开关的控制扩展进去。

abstract class DataProvider {
    public function getData(DataQuery $query, &$trace = '') {
       ……
        // 从缓存中获取(追加读开关控制)
        $data = $query->cacheRead ? $cache->get($key) : NULL;
        if ($data === NULL) {
            ……
            // 写入到缓存(追加写开关控制)
            if ($query->cacheWrite) {
                $cache->set($key, $data, $this->getCacheExpireTime($query));
            }
        }
        ……
    }
}

完成对底层数据供给器的优化后,通过预览参数访问首页,如果在响应头部看到的追踪信息TRACE类似如下,则说明预览功能升级完毕!
TRACE:"Index|AdData-LD|CategoryData-LD|HotJobData-A"
预览功能虽然说是一个小功能,但能基于现有的系统平滑升级而不至于需要大动干弋,不会牵一发而动全身,做到这一点就很难了。另一个视角,作为不懂任何编程技术的运营部门,他们不关心技术本身的难易程度,只会关心他们想得到的效果是否可以满足。只有设身处地地为他人考虑,为用户着想,为其他合作部门考虑,我们开发和维护的系统,才能更加完善。

数据预热

与预览功能相对的一面是,我们希望系统可以尽量使用高效缓存,因为在高并发时,哪怕一次数据库的穿透操作就有可能引起”雪崩“的发生。这时采用的策略就是进行数据预热,将所需要用到的数据提前生成并同步到缓存中。
数据预热,并不是指把数据从数据源获取出来然后写入到缓存中这么简单。对于大型企业级网站系统而言,要全面考虑,精心设计,按一定的设计和计划严格实施和执行,并思考由此解决方案而触发的新问题和挑战,以及对于新问题的下一步应对措施是什么。从问题域到解空间,再从解空间到问题域,如此循环,不断精进、不断收敛,方能造就更加完善的系统。
各位从事软件开发的同学,应该意识到,好的设计是友好的。它不会通过否定过去来抬高自己,也不会以损坏、排斥既有的功能、组件或模块来强硬协作而融入到系统中。恰恰相反,它会兼顾并传承古老的文化,进而创新。当它消失在历史舞台时,系统也不会因为它的离去而阵痛不已,因为它离去时仍然会留下芬香。Linux世界中的shell命令就是一个好设计,shell命令通过管道相互协作,并且可以通过简单的、既有的命令,衍生更加强大的命令。在自然办中,这样的例子更是随处可见。一如海洋中波涛汹涌的海浪,每一片浪花的倒下,都是为后面的浪花作铺垫。于是有了”长江后浪推前浪“。
回到正题,为了实现数据预览,新的设计方案是什么?我们又怎么基于既有的系统进行升级和扩展?由此引发的新问题是什么,又该如何面对和解决?答案即将揭晓。
为了能提前生成数据,并进行数据预热,避免数据库穿透,我们需要准备两份缓存数据。一份缓存数据是当前用户正在访问使用的缓存数据,一份是用于保存提前生成的缓存数据。并且这两份缓存数据,随着时间的推移,循环交替切换。再具体一点,就是以10分钟为间隔,依次轮询切换这两份缓存。
此时,要把时间这一因素扩展到数据供给器。实现起来,可以分为两步。第一步,修改查询对象基类DataQuery,添加当前请求时间这一类属性。同时,在构造函数初始化时,对当前请求时间进行默认初始化和预览时的初始化。

class DataQuery {
    ……
    public $nowTime;               // 当前请求时间戳

    public function __construct() {
         ……
        // 默认&预览初始化
        $this->nowTime = $_SERVER['REQUEST_TIME'];
        if (isset($_GET['nowTime'])) {
            $this->nowTime = $_GET['nowTime'];
        }
    }
}

注意到,默认初始化时,使用的是$_SERVER[‘REQUEST_TIME’]变量,而不是time()函数。这样做有两个好处,一个是性能上会更优,一个是变量更容易修改和模拟。
接下来第二步,是修改数据供给器的底层代码,让它支持两份缓存数据能随时间推移不断循环切换。

abstract class DataProvider {
    public function getData(DataQuery $query, &$trace = '') {
        ……
        $key = $this->getCacheKey($query);

        // 为缓存添加后缀,支持每10分钟,切换一份缓存
        $key .= '_' . intval(date('i', $query->nowTime) / 10 % 2);

        // 从缓存中获取(追加读开关控制)
        $data = $query->cacheRead ? $cache->get($key) : NULL;
        ……
    }
}

大家不要着急往下看,而是应该细细品读新增的代码,它的写法,以及它的意思。因为这些代码都是曾经惨痛的项目经验换来的。以前,最初的时候,我们是这样实现的:

        // 为缓存添加后缀,支持每10分钟,切换一份缓存
        $key .= '_' . date('YmdH') . intval(date('i') / 10) . '0'

得到的缓存后缀类似是:201807010000、201807010010、201807010020、201807010030、……、201807012350。即每隔10分钟,缓存的后缀版本会不断累加。到最后,因为当时使用的是Memcached缓存,而缓存key是不断增加的,导致Memcached的空间使用率非常高。假设使用的是文件缓存,同样会存在类似的问题,即缓存的文件会越来越多,如果不定期清理的话。相比于Memcached,文件缓存还相对容易清理,而Memcached的清理则麻烦一些。基于这个历史教训,我们改进了原来的缓存后缀版本号,改为只有0和1这两个后缀。非常简单。
以首页顶部广告数据为例,AdData::getCacheKey()方法中提供的缓存key是: ‘topbanner_ad
‘ . $query->city。那么添加缓存key后缀后,最终的key为top_banner_ad_gz_0或top_banner_ad_gz_1。

class AdData extends LightWeightDataProvider {
    protected function getCacheKey(DataQuery $query) {
        return 'top_banner_ad_' . $query->city;
    }
}

完成台前的准备工作后,跟着就是幕后的筹备工作了。
假设当前时间是2018年7月2号16点40分01秒,应聘者访问了我们的迷你招聘网站,此时缓存的后缀版本号为数字0,包括顶部广告、职位分类这两份数据。由于热门职位是实时获取的,不需要缓存。但正在使用的这两份数据,顶部广告top_banner_ad_gz_0和职位分类category_job_type_0,是在用户访问之前就已经预热好了。让时光倒流10分钟,回到16点30分,并把镜头定位到服务器的计划任务上,我们就知道在幕后发生了什么事情。
在16点30分时,网站用户访问和使用的数据是顶部广告top_banner_ad_gz_1和职位分类category_job_type_1,注意这时的缓存后缀版本号是数字1。通过后台定时计划任务,我们可以提前生成下一个轮询版本的缓存数据,而不会影响当前正在使用的数据。这也是符合关注点分离的原则,把终端用户使用数据与技术上准备的数据分离,互不干扰。
此时,轻量级数据模板也要细化一下。考虑到数据要提前10分钟生成,接着又有10分钟的访问期,而轻量级数据模板原来的缓存时间默认统一为10分钟,明显是不够的。因此,要加时,额外追加10分钟,即变成20分钟,才能保证提前生成的数据,到用户访问时仍然保持有效。

abstract class LightWeightDataProvider extends DataProvider {
    protected function getCacheExpireTime(DataQuery $query) {
        return 1200; // 备注:从原来10分钟,调整成20分钟
    }
}

与此同时,使用适配者模式,把轻量级缓存类LightWeightCache继承于简单实现的文件缓存类FileCache。文件缓存的实现没有什么难度,这里只是作为演示说明使用。例如,顶部广告的缓存文件路径为./cache/top_banner_ad_gz_0.dat,保存的数据需要序列化后再存储,并且最前面的数字表示有效的时间戳。

$ cat ./cache/top_banner_ad_gz_0.dat 
1530522938a:4:{s:5:"title";s:21:"技术类校招专场";s:4:"desc";s:63:"专为应届毕业生准备的招聘专场,快来看看吧!";s:6:"button";s:12:"马上进入";s:4:"link";s:11:"/act-1.html";}

在命中了缓存的情况下,全球追踪器在响应头部的输出类似如下,没有D节点标识,因为没有穿透到数据库。

TRACE:"Index|AdData-L|CategoryData-L|HotJobData-A"

但是要注意的是,如果你在线上集群服务器上使用文件缓存,要考虑到缓存文件同步的问题。此时可以使用scp的方式来同步到各应用服务器。
完成了对底层升级改造这一阶段性的操作后,就要开始实现计划任务的脚本编写了。思路很简单,对于全部的城市进行遍历,然后提前生成顶部广告和职位分类的数据。前面我们已经做了很多铺垫,打下了坚实的基础,再来实现数据预热的功能就很简单了。创建对首页数据进行提前预热的计划任务脚本./crontab/build_index_data.php,并放置以下PHP代码。

<?php
// ./crontab/build_index_data.php 文件
/**
 * 对首页数据进行提前预热
 */

require_once dirname(__FILE__) . '/../data/IndexQuery.php';
require_once dirname(__FILE__) . '/../data/AdData.php';
require_once dirname(__FILE__) . '/../data/CategoryData.php';

// 穿越到未来,往前推移10分钟
$_SERVER['REQUEST_TIME'] = time() + 600;

// 全部城市,例如只有广州gz和深圳sz
$allCitys = array('gz', 'sz');

// 待提前预热的数据
$dataList = array(
    new AdData(),       // 顶部广告
    new CategoryData(), // 职位分类
);

foreach ($allCitys as $city) {
    // 首页查询参数对象
    $query = new IndexQuery();
    // 指定所在城市
    $query->city = $city;

    // 不读缓存,但提前写入缓存
    $query->cacheRead = false;
    $query->cacheWrite = true;

    // 提前预热数据
    foreach ($dataList as $obj) {
        $obj->getData($query);
    }
}

echo 'done!', PHP_EOL;

上面代码非常容易理解。在手动引入必要的类文件后,我们准备了业务上需要的全部城市列表,同时也准备了技术上需要的全部待提前预热的数据对象。接下来就是一个业务循环,以及嵌套一个技术循环,将相关的业务参数和技术参数设定后,就可以进行数据的预热了。非常简单!
假设当前时间为2018年7月2号18点整,此时访问迷你招聘首页后,可以在缓存文件目录看到有两份缓存数据。

$ tree ./cache/
./cache/
├── category_job_type_0.dat
└── top_banner_ad_gz_0.dat

0 directories, 2 files

此时,如果执行刚编写完成的计划任务脚本build_index_data.php,例如:

$ php ./crontab/build_index_data.php
done!

再次对比文件缓存目录下的内容,就可以发现提前生成了三份数据:category_job_type_1、top_banner_ad_gz_1和top_banner_ad_sz_1。是不是很有趣?

$ tree ./cache/
./cache/
├── category_job_type_0.dat
├── category_job_type_1.dat
├── top_banner_ad_gz_0.dat
├── top_banner_ad_gz_1.dat
└── top_banner_ad_sz_1.dat

0 directories, 5 files

等时钟指向18点10分时,用户访问首页时,就能从提前生成并预热的数据中获益了。
一切都调试完毕,测试通过后,就可以在服务器上添加以下计划任务,以便每10分钟能自动进行数据预热的工作,而无需要人工干预。
*/10 * * * * /usr/bin/php /path/to/job/crontab/build_index_data.php > /dev/null
回顾我们这一节所做的事,可以说,每一步都是顺其自然的,前后连贯,因为好的设计本来就是这样,能够友好、自然、快速地融入到现有的系统。我们的网站也经受起了流量的冲击,有下一波用户访问到来之前,我们已经几乎事无巨细布置好了阵形。这俨然是一座强大的工事,分工明确,密切协作,而且也很严谨。
但就在这时,正当我们觉得可以松了一口气时,运营部门突然找到你,说:现在首页的顶部广告有个文案配置错误了,我们已经更正,能否帮忙刷新一下?
预热的数据已经全部准备就绪,虽然这里只是开放了两个城市,但实际上真正的业务需要的范围更广,处理起来难度更大。而且,除了要刷新提前生成的预热数据外,还要同步更新当前正在使用的缓存数据。也就是说,全部的缓存数据,不管是现在正在使用的,还是已经提前生成将要使用的,都要刷新一遍。这时,应该怎么应对?
很简单,只需稍微修改一下前面的build_index_data.php脚本即可。把时间的设置,追加命令行参数的时间设定。

// 穿越到未来,往前推移10分钟
$_SERVER['REQUEST_TIME'] = isset($argv[1]) ? strtotime($argv[1]) : time() + 600;

然后,通过命令行的方式,指定具体的时间并执行:

$ php ./crontab/build_index_data.php '2018-07-02 19:30:00'
done!

就可以达到想要的效果啦!
到目前为止,我们的招聘首页,从简单到复杂,从完成到完善。除了完成基本的业务功能外,我们还考虑到了性能上的优化,紧急刷新的应急处理方案,页面预览功能的支持。纵使如此,有待挖掘的贴心设计还有很多。书不尽言,暂且告一段落。

5 世界杯,缓存开关与完美

在编写这一小节时,正值2018俄罗期世界杯直播中。在这场外,有很多世界杯积分竞猜的活动,对于我这个足球门汉外来说,足球比赛的结果本质上就是两个比分,但基于这两个比分的竞猜玩法却有很多种。比如猜输赢、猜比分、猜让球……这让我联想到,如果我们对于软件开发,也能怀着一颗这样”爱玩“的心,估计我们设计和开发的系统,功能会更多样,也会更有趣。
就像在查询对象里面的缓存读开关和缓存写开关,虽说是两个开关,但两两组合起来就有四种情况。罗列出来,似乎没什么特别之外,无非就是:
读缓存、写缓存
读缓存、不写缓存
不读缓存、写缓存
不读缓存、不写缓存

甚至连小学生都能把全部的组合回答出来。但作为专业的软件开发工程师,我们有没想过,有没深入思考过,这四种组合背后的意义?答案在前面章节中已经提供了。
当缓存的读开关和写开关都开启时,那就是我们最终用户正常访问的场景;当缓存不读却有写时,则是数据预热的时机,这时会把数据提前生成并放入缓存;当缓存既不读也不写时,则是预览的时候,是为了让业务运营团队能够提前预览核对,也是为了让技术开发人员能在线上进行故障排查。这一微妙之处,还须我们自己慢慢体会。如人饮水,冷暖自知。
在Facebook的文化,有句话是:完成,胜于完美。
诚然,完美遥不可及,但我们不应由此而放弃对完美的追求。但要知道的是,即便达到了完美的境界,那也只是一瞬间的状态。更何况网站系统开发本来就没有完美这一说。而通过不断思考、不断实践、不断验证,我们越完善,就能离完美越靠近。
魔鬼隐藏在细节中,而天使就站在魔鬼的背后。

发表评论