網站列表(PHP企業級系統開發)

7.2 列表頁開發范式在設計模式裡,有一個設計思想,“找出變化並封裝之”。這一思想所蘊含的意義非常豐富,而且富有啟發性。而在敏捷開發中,也推崇擁抱變化,快速響應。知道這些思想的人很多,但真正理解的人很少。能理解這些思想的,並且最終加以實踐並持久堅持推行的人則更少。更糟糕的是,實際項目開發過程中,尤其在大型網站系統開發過程中,業務需求以一種無序、突發、緊急的姿態,要求迭代到系統中。在面對來自業務、產品和市場的壓力,同時面對內部系統亟待重構和優化這樣內外交迫的背景下,軟件開發工程師就更加沒有時間,也沒有精力嘗試“找出變化並封裝之”這一思想帶來的啟發瞭。如果,軟件開發工程連這一思想都不知道的話,局勢則會更為嚴峻。列表頁就是一個富含變化的戰場。它也是業務上的兵傢必爭之地。這是由於列表頁本身的性質決定的。可以說,列表頁是整個網站系統中,訪問量最大的頁面。更進一步,用戶做出選擇的地方就是在列表頁。對於同一位用戶,這是零和選擇,如果用戶選擇瞭A,他就不會選擇B;如果選擇瞭B,就不會再選擇C。當然,用戶選擇的次數通常都會比一次多一點。以電商平臺為例,對於琳瑯滿目的商品,用戶通常都會在訪問列表頁時選擇靠前的商品進行瀏覽,然後做出選擇。極少會有用戶拉到頁面底部查看後面的商品,更不用說一頁頁翻下去。現在的時代,是一個快節奏的時代,我們往往都需要在短時間內做出抉擇。無疑排在前面的選項,會有更大的概率被用戶選中,成為幸運兒。一旦選擇,進入到詳情面,那就是具體信息展示的事情瞭。可以毫不誇張地說,列表頁在整體產品開發周期中,所占用的時間、資源、精力會遠大於其他頁面。不管是在電商平臺系統,還是在招聘網站系統,抑或是國際租車平臺。列表頁的需求,一個接一個;改版的計劃,一波接一波,足以讓開發團隊和測試團隊應接不暇。作為軟件開發工程師,我們要始終致力於為業務創造更多的價值。那麼對於列表頁,我們應該怎麼做才能支撐源源不斷的變化呢?這其中又有哪些開發范式?這些將是本節將要重點討論的話題。7.2.1 四大排序與排序微架構透過業務繁華的表象,列表頁的本質上是針對集合的基本操作。對於集合來說,主要有三大操作:排序、篩選和映射。我們先來探索排序操作,因為這部分是關乎業務核心價值的關鍵環節。排在前面的元素,更能獲取通往終端用戶的綠卡。不管是用戶,還是供應商、合作夥伴、公司高管、活動方,對列表頁的排序都非常關註。因為列表頁的排序,極大程度上決定瞭流量的分配。如果說,得流量者得天下,那麼就更能明白為什麼大傢都對列表頁的排序關註度如此之高,如此敏感,足以牽動人們的每一根神經。列表頁的排序,不容小覷。例如搜索引擎的競價排名。根據多年的開發經驗,列表頁的排序,自底往上,分為四大類:默認的基礎排序二次幹預的運營排序推薦算法的實時排序特定專題的活動排序這四類排序,是層層往上的。先有基礎的排序,再有幹預的排序,接著是推薦算法的排序,最後是針對某些特定元素置頂的操作。下面分別詳細說明,以及各類排序的實現要點和開發范式。7.2.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');
}
}這時,列表頁的展示效果如下:圖7-3 迷你招聘網站列表頁請註意,這裡有一個小技巧和小竅門。為瞭進行SEO優化,或出於對安全性的考慮,網站會采用偽靜態的做法。例如這裡的列表頁的訪問地址是:http://dev.job.com/list-php.html,其訪問文件的後綴是html,隱藏瞭PHP實現這一信息,並且更容易被搜索引擎收錄。因為搜索引擎喜歡變化不頻繁的頁面,而html文件則是靜態網頁。但是背後是怎麼實現的呢?關鍵在於Nginx的規則重寫。通過使用rewrite,可以將特定規則的鏈接,重定向到指定的PHP文件進行處理。server {
server_name dev.job.com;
……

if (!-e $request_filename) {
rewrite ^/list-(.*).html$ /list.php?type=$1 last;
}
}做好這些基礎準備後,接下來就可以擁抱排序這一富有變化的領域瞭。7.2.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) {
// TODO:進行運營排序
}
}假設業務需求方面,在對迷你招聘網站的招聘崗位列表進行運營排序時,需要綜合考慮兩個因素:內部設置的權重,以及招聘崗位的評分。權重由運營部門維護和配置,而評分則是根據崗位的待遇、招聘要求、企業的規模、應聘者的評價等得出的評分,評分越高,表示招聘崗位越優質。權重最大值為100,評分最大值為10。這兩個因素的排序規則如下:規則一:評分大的排前面,評分小的排後面,如果評分相等則再比較權重規則二:權重大的排前面,權重小的排後面,如果權重相等則保持原來的排序與此同時,我們的招聘崗位信息補充瞭評分和權重後的數據如下:表7-2 帶權重和評分的招聘崗位ID招聘崗位名稱評分權重21PHP軟件開發工程師8.89022高級PHP工程師9.28023資深PHP開發工程師8.89224PHP程序員8.69525高級PHP開發工程師9.388正如前面討論,如果你已熟悉對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);
}
}註意到,這段操作是實時計算的。此運行環境便於程序可以根據一些時間節點做出即時的處理,並且隻有當用戶來訪問時才會觸發。此後,再次刷新招聘崗位的列表頁,可以看到進行運營幹預排序後的順序變化。圖7-4 運營排序後的列表頁到這裡,我們又完成一次需求迭代,實現瞭二次幹預的運營排序。正當我們舉杯相慶時,殊不知又一波新排序需求即將來臨,而且來勢洶洶。那就是——7.2.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(保留並置後)圖7-5 推薦算法排序示例仔細想一下,以上這些場景都是有可能發生的。畢竟在兩個系統之間進行通訊,信息可能會有延遲,又或者推薦算法系統出現故障或缺陷導致部分或全部ID返回失敗。做最壞的打算,做最充足的準備。完成推薦算法的實時排序接入後,讓我們把鏡頭移向下一個排序——7.2.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排序接口。圖7-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-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__']; ?>" >查看詳情 »</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" >查看詳情 »</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" >查看詳情 »</a></p>
</div>這樣以後,我們就可以在頁面源代碼中,清晰看到每次排序後的變化情況。無論是對平時功能測試,還是線上故障排查,都大有裨益。而且對於測試人員也非常友好,因為他們可以有一種可視化的方式進行核對和檢驗。甚至測試團隊可以基於此中間排序結果,再結合自動化測試工具進行UI自動化測試。當這一切都逐步完善時,我們看到的,將是系統穩健的身影,以及業務快速增長的大好前景!7.2.6 排序小結就列表排序而言,可以分為四大類,基於原始數據的基礎排序、基於內部屬性的排序、基於第三方系統新序列的排序、以及基於外部條件的排序。我們看待世界的方式,以及對理解的程度,將在某種程度上決定瞭我們對世界的回應。在面對不確定的未來時,在負責企業級系統開發時,我們應當致力於尋找符合領域本質的系統設計,並致力於開發貼合業務場景的功能實現。從完成到完整,從完整到完善,從完善到完美,發揮我們的智慧,投入我們的精力和精力,不斷進取。在這一過程,則需要結合使用:CVA共性和可變性分析、三種視角、設計模式、TDD測試驅動、DSL特定領域語言、管道、插件、引擎……代碼寫得好,不僅僅在於PHP語法有多瞭解,而更多在於對於這些思想、工具、模型、原則的理解和應用有多深刻。就如比如著名的作傢,他的文章寫得好,不僅僅在於他對文章本身的結構、段落、句法有多熟悉,而在於他的人生閱歷以及他對於世界的洞見有多深刻。因此,在日常開發過程中,我們不應隻局限於需求開發本身,而要跳出這個“箱子”進行更全面的考慮。否則,就會慢慢退化為需求開發執行者,編碼的“打印機”。在完成功能開發,滿足業務需求的基礎上,我們還應當考慮系統設計,關註開發效率;還應當考慮可測試性,關註項目質量。有人說,開發效率和項目質量,就像天平兩端,此消彼長。在我看來,不一定,反而效率和質量,兩者皆可得,但要在付出一定的艱辛和努力後才能雙豐收。以上,是我關於列表頁排序的一點心得和總結。


本文出自快速备案,转载时请注明出处及相应链接。

本文永久链接: https://www.xiaosb.com/beian/51469/