分类 实战 下的文章

在前段时间,我负责的推送中台出现了一个非常严重的 Bug,差点造成线上服务宕机。

我简单来描述一下当时的场景。

  1. 我们使用个推来来推送我们的 APP 通知,以及用户画像,用户分析等等。
  2. 每一次在 app 启动都会访问一个接口来判断用户信息,然后绑定标签,这个动作会触发 curl 函数访问个推的 API。
  3. 结果悲剧了,个推北京机房宕机。

我们当时的主要技术栈是 PHP,我们线上 curl 请求默认超时时间是 60 秒,这就导致我们的数据库链接近乎被打满。

由于 PHP-FPM 在脚本执行完成后才会释放进程,这也包括其中的资源,curl 请求的默认时间是 60 秒,这样也就意味着在 60 秒内,该进程也会被一直阻塞,直到超时为止,而 PDO 的生命周期是跟着 FPM 走的,除非调用手动 close() 这样才会关闭。

MySQL 连接断开也是有默认超时时间的,如果在超时时间范围内,客户端没有主动关闭,该连接会一直存在,但是 MySQL 的连接数是有限的,如果连接被打满那么新的连接将无法建,整个服务 GG 。

如果这个时候再有流量进来,那么就只有宕机一条死路了,于是立即找到运维修改默认配置,然后重启 php-fpm 释放 MYSQL 连接。

处理方式以及断路器

经过这一次的线上事故,总结出了一点,对于任何第三方提供的接口,都不要完全信赖!,都要去考虑如果对方出现问题,我这里应该如何操作!

经过复盘我们当时存在以下问题:

  1. 环境配置不透明,开发基本不关注线上配置。
  2. 没有考虑第三方失败该如何进行处理。
  3. 架构设计不完善,此类功能完全也可以异步化。

第一点是由于工作经验不足或者缺乏沟通导致的,很好解决,当这个事情解决之后,团队对线上的配置情况和运维一起进行了一次梳理。

第二点,我们选择了熔断器,首先说一下什么是熔断器。

第三点,对业务进行了梳理,将强依赖接口改成了消息队列进行异步处理。

de8a07841802fdcd985039e8afb1e58b

图 1.1 熔断器

如果你看过自己家中的电闸开关的话就会看到上图这个东西, 当电流正常情况下熔断器是关闭状态电流就可以正常通过,如果电流负载过大为了保护家用电器就会开启,然后关闭电源保证电器安全。

在我们的代码中熔断器其实就是一个设计模式,我们可以通过很简单的代码就可以实现熔断器。

图 1.2 无熔断器流程图

图 1.2 中是在没有熔断器我们在开发这类业务的时候,可能会对第三方服务进行 try catch 来判断异常,如果有异常在反复几次调用,多次尝试失败后,可能就直接返回报错,提供有损的服务,像上面我提到的那场事故中这个问题就会被放大,因为 60 秒后才会触发超时,也就意味着,如果重试三次那么该接口的耗时就会增加到 3 分钟,正常情况下是不会有业务接口能够接收三分钟这样耗时的。

当我们加上熔断器后,那么流程就会变成图1.3 那样。

图 1.3

引入断路器后,不再是 app server 直接访问第三方服务,而是将访问委托给 fuse 熔断器来进行,断路器判断当前的开闭状态来判断是否要发起请求。

  1. 如果是处于关闭状态,也就是第三方服务可以正常访问,那就直接访问第三方服务,
  2. 如果熔断器处于开启状态,那么正面目前第三方服务不可用,则直接调用备用函数,这也是微服务中一个重要的概念——服务降级

利用 Redis Sorted Set 实现简单的熔断器

由于我们使用的 PHP-FPM,这也就导致我们的 PHP 代码是无法常驻内存的,这个时候,就需要使用 Redis 来进行记数,在数据结构方面我们选择使用有序集合。

keyscoremember
circuit10pushMessage2User
circuit1pushMessage2List

score 用来存放失败的次数,member 用来存放失败的函数。

搭建骨架

我们先来按照上一小节的内容先将熔断器的挂架代码打起来。

<?php

class CircuitBreaker
{
    private $zSetKey = 'circuit';

    public function invoke(object $class, string $method, array $params, callable $fallback)
    {
        try {
            return $class->$method(...$params);

        } catch (Throwable $exception) {
            return $fallback(); //函数降级
        }
    }
}

CircuitBreaker 就是我们的熔断器类。

其中设计了一个 invoke 方法,这个方法就是上面讲到的代理请求,我们来通过 invoke 方法来让熔断器代理我们的请求。

  • $class: 我们首先要将类实例化后传入到 invoke中,这样的好处是进行依赖翻转,让熔断器可以通用。
  • $method: 是该类需要执行的方法。
  • $params: 是执行该方法时候需要传入的参数。
  • $callback:是一个闭包,当熔断器被打开,或者出现异常的时候,我们需要对服务进行降级。

下面是如何调用。

<?php

require 'CircuitBreaker.php';

class Pusher
{
    public function pushMessage2User($username, $message): bool
    {
        echo "使用 Pusher 进行发送 $username [$message]";
        return true;
    }
}

$c = new CircuitBreaker();
$messageData = ['maksim', 'hello world!'];
$pusher = new Pusher();
$c->invoke($pusher, 'pushMessage2User', $messageData, function () use ($messageData) {
    echo "使用站内信进行发送 $messageData[0] $messageData[1]";
    return true;
});

这个时候就相当于是一个简单的代理模式,由 CircuitBreaker 去执行 Pusher,并且捕获异常,如果出现异常就用降级函数。

在这里,我们模拟发送通知的业务场景,如果 Pusher 类发送失败了,那么我们就降级使用站内信的方式进行发送,当然也可以扔到一个队列里面,等业务恢复后在开启异步消息队列将消息推送出去。

为熔断器添加计数器

接下里,我们对 CircuitBreaker 进行修改,增加错误计数。

public function invoke(object $class, string $method, array $params, callable $fallback)
    {
        try {
            return $class->$method(...$params);
            
        } catch (Throwable $exception) {
            $member = get_class($class) . '_' . $method;
            $this->redis->zIncrBy($this->zSetKey, 1, $member);

            return $fallback(); //函数降级
        }
    }

这里的代码非常简单,当代码发生异常后,我们直接增加 redis 计数器,下面我们使用 Redis 的延迟队列来实现熔断器开关状态的切换。

首先,我们需要设置失败的阀值,当失败次数达到阀值后不再调用原函数。

public $failCount = 3;

private function isFail($member) {
    if ($this->redis->zScore($this->zSetKey, $member) >= $this->failCount ) {
        return true;
    }
    return false;
}
public function invoke(object $class, string $method, array $params, callable $fallback)
    {
        $member = get_class($class) . '_' . $method;
        try {
            if ($this->isFail($member)) {
                return $fallback();
            }
            return $class->$method(...$params);
            
        } catch (Throwable $exception) {
   
            $this->redis->zIncrBy($this->zSetKey, 1, $member);

            return $fallback(); //函数降级
        }
    }

实现熔断器的三种状态

现在我们就实现了熔断器的打开,接下来,我们需要实现熔断器的关闭,但是只有这两种状态熔断器是无法正常运作的,在日常生活中,当熔断器跳闸后,我们判断没有问题后会手动去尝试开启熔断器,同样在代码中,我们也需要一个这个样的过程,我们需要让熔断器再去尝试着去访问原来的方法,如果满足可关闭状态,那么把熔断器关掉。

而在这个试探的这个状态就是半开状态。

  1. 关:调用原函数,失败后计数器+1,并返回降级函数,到达一定阀值后进入“开”状态;
  2. 开:也就是直接返回降级函数,不调用原函数,但是我们可以给失败函数一些机会,于是出现半开状态,不过需要设定一定的定时器,一定时间后进入半开状态。
  3. 半开状态:处理半开妆容是熔断器的核心,调用函数时候有一定数量的调用时进入降级函数,有一定数量进入的是真实函数。如果函数已经稳定,那么就进入关状态。

状态值的定义如下:

define("BreakerStateOpen" , 1);         // 开
define("BreakerStateClose" , 2);         // 关,这是默认值
define("BreakerStateHalfOpen" , 3);     // 半开

private funcion getState($member) {
    $getSocre = $this->redis->zScore($this->zSetKey, $member);
    if ($getScore >= $this->failCOunt {
        return BreakerStateOpen;
    }
    if ($getScore < 0) {
        return BreakerStateHalfOpen; // 如果值是 -1 则代表是半开状态
    }
   return BreakerStateClose;
}

接下来,利用定时器来完成进入半开状态,在 PHP-FPM 模式下定时器我们可以通过 Redis 实现,实现的方式有两种:

第一种:利用一个带有过期时间的 key 来完成,一旦进入“开”状态后,插入一个 key,设置过期时间,然后利用过期通知的方式来完成。

第二种:延迟队列,依然使用 Sorted Set 来完成,key 设置为 circuit_open,插入时间戳,编写一个死循环程序,反复监听。

通过这两种方式,监听过期时间,到期后把 circuit 对应的 member 的 score 设置为 -1,即代表是半开状态。

在这里我们通过延迟队列的方式来进行完成,如果不了解延迟队列可以通过《PHP 利用 Redis Sorted Set 实现延迟队列》这片文章来了解其原理。

<?php
  
while(true) {
    $members = $redis->zRangeByScore("circuit_open", "-inf", time(), ['limit' => [0, 10]]);
    
    if (count($members) > 0) {
        foreach ($members as $member) {
              $redis->zAdd('circuit', -1, $member);
        }
        
        //删掉 open key
          $redis->zRem("circuit_open", ...$members);
    }
    
    usleep(500 * 1000);//休眠 500 毫秒
}

这样一来,我们就可以使用死循环程序来不断的设置半开状态了。

接下来我们在 invoke 方法来处理,当熔断器进入到开状态时那么就设置一个定时器。

public function invoke(object $class, string $method, array $params, callable $fallback)
    {
        $member = get_class($class) . '_' . $method;
        $currentState = $this->getState($member); //获取当前状态
        try {
            if ($currentState == BreakerStateOpen)  {
                return $fallback();
            } else if ($currentState == BreakerStateHalfOpen) {
                //如果是半开状态
                if (rand(0, 100) % 2 == 0) {
                    return $fallback();
                } else {
                    $result = $class->$method(...$params);
                    // 半开状态下仍然要加计数器,目的是为了让他归 0
                    $this->redis->zIncrBy($this->zSetKey, 1, $member); 
                    return $result;
                }
            }
            return $class->$method(...$params);
            
        } catch (Throwable $exception) {
            if ($currentState == BreakerStateClose) {
                 //切换到开
                   $socre = $this->redis->zIncrBy($this->zSetKey, 1, $member);
                if ($score == $this->failCount) {
                    //增加队列
                       $this->redis->zAdd($this->zSetKey_open, time() + $this->openTime, $member);
                }
            }
            
            if ($currentState == BreakerStateHalfOpen) {
                $this->redis->zIncrBy($this->zSetKey, $this->failCount, $member); // 将熔断器重新设置为开状态
                $this->redis->zAdd($this->zSetKey_open, time() + $this->openTime, $member); //再次开启定时器
            }   
            return $fallback(); //函数降级
        }
    }

在这段代码中,我们重点关注第 10 行到第 16 行,这里“随机”让其访问真实函数,如果函数正常,则将计数器归 0,让其进入到关闭状态。

在第 22 行开始增加了对状态的判断,如果当前处于关闭状态,那么久打开,如果现在是半开状态,那证明半开尝试失败了,需要重新进入打开状态。

这里的半开状态的处理很简单,其实正常的熔断器还需要包括采样分析,来判断当前是否满足开合条件,同时上面的“随机”也无法控制到底有多少流量进入到正常函数,所以我们还需要增加真正的概率算法,将通过的流量控制在一个我们可以接受的范围内。

延迟队列是我们在业务中经常遇到的场景,例如订单过期,定时发布,定时推送等等。

在 Zset里面,每一个成员都有一个所谓的分数:score,把当前时间作为分数,因为 Zset 是有序的,时间越小的排名越靠前。所以使用Zset作为延时队列就充分利用了score。

我们还是用订单例子来距离,如果订单超过 24 小时未付款,那么久取消订单,这是在电商业务中最常见的处理模式。

那么在 redis 中的数据个是就如图 1.1。

图 1.1

而程序执行流程如下图

  1. 用户下单后将订单信息插入到数据库中
  2. 将数据插入到 redis 中,结构如图 1.1
  3. 启动死循环,定时从 zset 中获取接近当前时间戳的值,然后逻辑判断是否更新数据。

接下来我们回顾一下 zset相关的命令:

ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count]

注意排序是按照 score 从小到大排序的,这也是我们想要的,其中 min 和 max 可以是 -inf 和 +inf。

  • -inf 分数的最低值
  • +inf 分数的最高值
ZRANGEBYSCORE order:delay_queue -inf 5000  WINTHSCORES

这代表你不知道最低值是多少,取值范围是 <= 5000

ZRANGEBYSCORE order:delay_queue -inf current_timestamp WINTHSCORES limit 0 10

代表取 10 条最小等于当前时间的所有内容。

我们还是使用命令行脚本来模拟入队操作。

<?php

$orderId = $argv[1] ?? '';
if (empty($orderId)) {
    exit("请填写要压入队列的数据");
}
$redis = new Redis();

$redis->connect('127.0.0.1', 6379);
$redis->auth('123456');
$redis->select(0);
$queueName = 'order:delay_queue';

$redis->zAdd($queueName, time(), $orderId);

执行代码,向队列中插入四条数据。

# 执行入队操作
➜ php zset_client.php O20190101
➜ php zset_client.php O20190102
➜ php zset_client.php O20190103
➜ php zset_client.php O20190104

# redis 查看数据
127.0.0.1:6379> zrange order:delay_queue 0 -1 withscores
1) "O20190101"
2) "1667213048"
3) "O20190102"
4) "1667213049"
5) "O20190103"
6) "1667213050"
7) "O20190104"
8) "1667213051"

接下来我们来实现消费端的代码:

<?php


$redis = new Redis();

$redis->connect('127.0.0.1', 6379);
$redis->auth('123456');
$redis->select(0);
$queueName = 'order:delay_queue';

while (true) {

    $orderIds = $redis->zRangeByScore($queueName, "-inf", time(), ['limit' => [0, 10]]);
    $ids = implode(',', $orderIds);

    if (!empty($ids)) {
        //模拟执行 SQL 语句,4 代表已经取消
        $sql = "update `orders` set `status` = 4 where in ({$ids})";
        echo "$sql" . PHP_EOL;
        //执行其他业务逻辑,例如推送通知等操作
    }

    $redis->zRem($queueName, ...$orderIds);
    usleep(500 * 1000);
}

这里需要注意我们在更新数据库的时候不要一条一条的更新,同时这些代码只是用来演示基本原理,还有好多需要处理的内容。

  1. 处理程序的退出,监听信号
  2. 尽量使用异步方法处理业务逻辑,可以尝试使用 swoole,workman 带有多进程管理的框架或者已经封装好的类库。

Redis 本身是用来做缓存的,但是其中有一些特性是我们可以用来完成消息队列的功能,比如如果能够容忍数据的丢失,并且持久化方面要求不是很高的场景下完成任务的分布式处理。

队列的基本实现

Redis列表是简单的字符串列表,按照插入顺序排序。你可以添加一个元素到列表的头部(左边)或者尾部(右边),而我们取出的时候可以从头部或者尾部来获取数据。

我们可以先头lpush 插入数据,然后再rpop来取数据,来模拟队列的先进先出。

$ redis-cli -h 127.0.0.1
127.0.0.1:6379> auth 123456
OK
127.0.0.1:6379> select 0
OK
127.0.0.1:6379> lpush order:queue SN20190801
(integer) 1
127.0.0.1:6379> lpush order:queue SN20190802
(integer) 2
127.0.0.1:6379> lpush order:queue SN20190803
(integer) 3
127.0.0.1:6379> rpop order:queue
"SN20190801"
127.0.0.1:6379> rpop order:queue
"SN20190802"
127.0.0.1:6379> rpop order:queue
"SN20190803"
127.0.0.1:6379> rpop order:queue
(nil)

如果想要批量处理,我们可以使用 LRANGE 命令:

127.0.0.1:6379> lpush order:queue SN20190801
(integer) 1
127.0.0.1:6379> lpush order:queue SN20190802
(integer) 2
127.0.0.1:6379> lpush order:queue SN20190803
(integer) 3
127.0.0.1:6379> lrange order:queue 0 10
1) "SN20190803"
2) "SN20190802"
3) "SN20190801"

基于这三个命令,我们就可以实现一个异步的消息队列了,我们首先来模拟消息队列的生产,最终我们的消费模型就如图 1.1。

未命名绘图.drawio (1)

图 1.1 消费模型

<?php

// 接收参数
$orderId = $argv[1] ?? '';

if (empty($orderId)) {
    exit("请填写要压入队列的数据");
}

// 新建 redis 连接
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redis->auth('123456');
$redis->select(1);

try {
    echo $redis->lPush('order:queue', $orderId) . PHP_EOL;
} catch (Exception $exception) {
    //捕获异常,出现异常情况应该进行处理重传或者报警等等
    echo $exception->getMessage() . PHP_EOL;
}
// 释放资源
$redis->close();

这段代码非常简单,通过 $argv 来获取参数,也就是 order_id,通知消费端,该 id 有数据变更,要进行一些异步操作。

处理端我们使用 brPop 来阻塞获进行消费就可以了。

<?php

$redis = new Redis();

$redis->connect('127.0.0.1', 6379);
$redis->auth('123456');
$redis->select(1);

while (true) {
//阻塞获取,10 秒后没有获取到证明超时
    $result = $redis->brPop("order:queue", 10);
    //$result 包含两个返回值
    //$result[0] redis key 的名称 order:queue
    //$result[1] redis value 也就是我们压入队列的数据
    if ($result && $result[0]) {
        //业务处理逻辑
        echo "接收到订单号为 [" . $result[1] . "] 的订单,开始执行业务逻辑";
        usleep(500 * 1000); // 休眠500 毫秒,让出 CPU 时间片
    } else {
        continue;
    }
}

首先是使用 while 死循环来模拟一直在消费, brPop 设置一个十秒的阻塞,如果阻塞过了十秒就放弃获取,执行下一次逻辑,在处理完成后,我们要 usleep 500 毫秒,让出 CPU 时间片避免 CPU 资源被该进程一直占用。

这样一来一个简单的 redis 异步消息队列就实现好了,到这一步最好不要直接在生产环境这么写。

我们还需要处理 kill 信号来实现优雅退出,因为 kill 信号是直接杀死进程,这个时候任务可能正在处于执行状态,这种处理方式是不可重入的,因为你无法确定某个操作环节会出错。

利用bRPopLPush命令补救数据丢失

如果你不能容忍消息的丢失或者持久化,我们可以使用比较成熟的 MQ,例如 RocketMQ,RabbitMQ 或者 kafka,这些 MQ 都可以比较好的解决这个问题。

bRPopLPush 是 redis 提供给我们的一个命令,改命令从列表中取出最后一个元素,并插入到另外一个列表的头部; 如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止。

我们可以利用这个提醒替换 BRPOP ,在获取队列内容的时候将其丢到一个备份 key 中,当消息执行结束后,再将备份的数据从备份 key 中删除。

<?php

$redis = new Redis();

$redis->connect('127.0.0.1', 6379);
$redis->auth('123456');
$redis->select(1);

$orderQueueKey = 'order:queue';
$backupQueueKey = 'order:queue:backup';

// 程序启动优先处理 backup key 中的数据
while (true) {
    $result = $redis->brPop($backupQueueKey, 1);
    if ($result && $result[0]) {
        //业务处理逻辑
        echo "接收到订单号为 [" . $result[1] . "] 的订单,开始执行业务逻辑" . PHP_EOL;
        sleep(3); // 模拟耗时操作
        echo "处理完成" . PHP_EOL;
        usleep(500 * 1000); // 休眠500 毫秒,让出 CPU 时间片
    } else {
        break;
    }
}

while (true) {

//阻塞获取,10 秒后没有获取到证明超时
    $result = $redis->bRPopLPush("order:queue", "order:queue:backup", 10);

    if ($result) {
        //业务处理逻辑
        echo "接收到订单号为 [" . $result . "] 的订单,开始执行业务逻辑" . PHP_EOL;
        sleep(3); // 模拟耗时操作
        echo "处理完成" . PHP_EOL;
        //处理完成后取出key
        $redis->lPop($backupQueueKey);
        
        usleep(500 * 1000); // 休眠500 毫秒,让出 CPU 时间片
    } else {
        continue;
    }
}

我们首先在真正的处理逻辑前增加了对备份key 的处理,让程序启动后先处理之前没有梳理完成的数据。

在最下方处理队列的地方也做了改动,在处理完成后取出备份 key 中的数据。

网上有很多数据模式的文章,这里不会按照已经成型的书和文章来进行编写,这样没有任何意义,在应用设计模式的时候一定要根据自己业务和使用的语言来进行编写。

在工厂模式中,我们基于商城的案例编写了一个书、狗和酒的工厂案例,在线我们对需求进行一次迭代,老板决定每个商品的 getList 方法都要额外加上狗的信息(狗本身除外)。

当我们接到这个需求的时候,首先第一个想法肯定是这样实现:

include 'autoload.php';
//$object = new Books();
$book = ProductFactory::getProduct('book');
$bookList = $book->getList();
$dog = ProductFactory::getProduct('dog');
$dogList = $dog->getList();

接下来,我们利用设计模式来设计一下,不过首先我们需要解决一个问题,目前的代码虽然都有 getList 但是却并不是一个硬性的规范,如果现在商城增加品类了,例如增加了一个 Phone 交给了一个新的开发人员去开发,获取列表的方法它给写成了 getPhones 那么我们对应的 ProductFactory 在实现上可能就会存在很大的麻烦。

对于约束,我们可以先定义个接口:

<?php

interface IProduct
{
    public function getList();
} 

这样无论以后我们新增什么品类都使用这个方法去进行创建,我们目前已有的三个类都去 implements ,接下来我们对工厂模式进行一些改进。

<?php

class ProductFactory
{
    public static function getProduct($type)
    {
        switch ($type) {
            case "book" :
                $obj = new Books();
                break;
            case "dog" :
                $obj = new Dogs();
                break;
            case "wine" :
                $obj = new Wines();
                break;
            default:
                $obj = null;
        }
        if (is_subclass_of($obj, "IProduct")) {
            return $obj;
        }
        return null;
    }
}

我们在最后增加了 is_subclass_of 判断,该函数可以判断当前的变量是否是某一个类、抽象类、接口的子类,这样可以让我们的工厂更加健全,避免返回的对象不满足 IProduct 的约束,可以减少运行时的报错,因为你无法保证其他人修改工厂方法的时候放进来的类到底满不满足我们的业务要求。

如果要实现我们现在的需求,需要用到注册树模式,我们在这里叫它数据中你想你模式,我们把数据全部都放到数据中心中进行统一的管理,取数据的时候也从数据中心取,坚决不合类本身私自交往。

<?php

class ProductDataCenter
{
    public static $objectList = [];

    public static function set($k, $v)
    {
        self::$objectList[$k] = $v;
    }

    public static function get($key)
    {
        return self::$objectList[$key] ?? [];
    }
}

ProductDataCenter 的职责非常简单,就是存储我们的产品列表,对外提供两个方法:

  • set 存值

接下来我们继续改造工厂方法,工厂不再继续返回值,而是向 ProductDataCenter 中增加数据。

<?php

class ProductFactory
{
    public static function getProduct($type)
    {
        switch ($type) {
            case "book" :
                $obj = new Books();
                break;
            case "dog" :
                $obj = new Dogs();
                break;
            case "wine" :
                $obj = new Wines();
                break;
            default:
                $obj = null;
        }
        if (is_subclass_of($obj, "IProduct")) {
            // 把创建的对象放到数据中心
            ProductDataCenter::set($type, $obj);
        }
    }
}

这样一来,我们在外面调用的时候就变成了这个样子:

<?php

include 'autoload.php';
//$object = new Books();
ProductFactory::getProduct('book');

$data = ProductDataCenter::get;
var_dump($data);

但是目前还是没有解决我们的需求,我们还需要继续修改数据中心。

<?php

class ProductDataCenter
{
    public static $objectList = [];

    public static function set($k, $v)
    {
        self::$objectList[$k] = $v;
    }

    public static function __callStatic($name, $arguments)
    {
        $result = [];
        foreach (self::$objectList as $k => $v) {
            if (method_exists($v, $name)) {
                $ret = $v->$name($arguments);

                if ($ret) {
                    foreach ($ret as $item) {
                        $result[] = $item;
                    }

                }
            }
        }
        if (count($result) > 0) {
            return $result;
        }
    }
}

在这里,我们增加了一个 __callStatic 魔术方法,这个魔术方法的作用是,当调用静态方法不存在的时候就会走到这个函数。

其主要作用就是从注册树种取出 Proudct 类,然后执行 getList 方法,并且将在注册树中存在的 Product 都执行一遍,组成成我们想要的数据返回回去。

现在如果我们想要展示 Book 和 Dog 的数据只需要这样获取即可:

<?php

include 'autoload.php';
//$object = new Books();
ProductFactory::getProduct('book');
ProductFactory::getProduct('dog');
$data = ProductDataCenter::getList();
var_export($data);

## 执行结果如下:
array (
  0 => 
  array (
    'product_id' => 101,
    'product_name' => 'java 从入门到精通',
  ),
  1 => 
  array (
    'product_id' => 102,
    'product_name' => 'PHP 从入门到精通',
  ),
  2 => 
  array (
    'product_id' => 201,
    'product_name' => '泰迪',
  ),
  3 => 
  array (
    'product_id' => 202,
    'product_name' => '金毛',
  ),
)%

既然是注册树模式,就还需要有从树上摘除的操作:

public static function remove($key)
{
        unset(self::$objectList[$key]);
}

这一节就结束了。

在正式开始之前,我们先来编写一个自动加载功能,这样我们就不用老是 include 文件了。

<?php

define('ROOT_PATH', str_replace('\\', '/', realpath(dirname(__FILE__) . '/')) . "/");
function autoload($className)
{
    $classPath = ROOT_PATH . 'src' . DIRECTORY_SEPARATOR . $className . '.class.php';
    include $classPath;
}

spl_autoload_register('autoload');
  • 首先我们声明一个 ROOT_PATH,用来帮助我们定位当前目录
  • autoload 函数,用来自动文件的函数,其中定义了加载规则,我们统一将代码放到 src 目录下,每个类文件的后缀为 .class.php
  • 通过 spl_autoload_register 来做自动加载注册,当 php 运行后如果发现当前没有要用得类就会触发 autoload 函数进行文件加载。

我们的项目目录最终结构如下:

├── autoload.php
├── index.php
└── src
    └── xxx.class.php

好了我们回到正题。很多人觉着设计模式在实际代码中离自己很远,希望看完这个系列的文章后,能够利用设计模式改善自己的代码。

最开始我们来学最简单的工厂模式,网上有很多关于工厂模式的文章,在这里,我们结合 PHP7 来进一步的演化和演进,我们写设计模式不能按照网上的通用代码,一定要结合语言和项目才能写好,很多同学在网上看到了 Java 的工厂模式,就和 Java 写的一模一样,那为什么不直接使用 Java 而使用 PHP 呢?

我们来看一个非典型的电商系统,刚起步的时候往往只有 1-2 个商品,例如只有图书,此时对的协作模式很简单,开发人员可能只有一个,编写的代码可能就是下面这个样子。

$object = new Books();

在 Book 中包含:

  1. 图书信息的维护
  2. 图书点击量
  3. 图书架构
  4. 图书的订单

如果这种规模确实这么做就可以了,随着项目越来越大,我们的库中会增加的一些其他商品除了图书还增加了酒喝狗,开发人员也越来越多。

# index.php
# 程序员 A 负责 Book 的维护迭代
$obj = new Books();
# 程序员 B 负责 Dog 的维护迭代
$obj = new Dogs();
# 程序员 C 负责 Wine 的维护迭代
$obj = new Wines();

我们大哥比方,假设每个类都有一个获取商品列表的方法:getList();

Books 的代码实现:

<?php

class Books
{
    public function getList()
    {
        return [
            ['product_id' => 101, "product_name" => "java 从入门到精通"],
            ['product_id' => 102, "product_name" => "PHP 从入门到精通"],
        ];
    }
}

Dogs 的代码实现

<?php

class Dogs
{
    public function getList()
    {
        return [
            ['product_id' => 201, "product_name" => "泰迪"],
            ['product_id' => 202, "product_name" => "金毛"],
        ];
    }
}

Wines的代码实现

<?php

class Wines
{
    public function getList()
    {
        return [
            ['product_id' => 301, "product_name" => "红酒"],
            ['product_id' => 302, "product_name" => "白酒"],
        ];
    }
}

正常业务代码中,getList 中的应该从数据库中取,这里为了演示方便直接返回数组。

如果我们做这个开发,这三个商品都应该在库中标记为商品,不过类型不同,很可能呈现的展现形式和业务逻辑是不一样的,所以需要区分成不同的商品。

这么写有没有什么问题?

如果我们把书和狗看成两个独立的子频道,开发起来是没有任何问题的。

但是假设现在有一天图书的货源出问题了,老板临时决定范式访问书的数,统一返回狗的数据。

这个时候程序员可能这么做:

<?php

include 'autoload.php';
//$object = new Books();
$obj = new Dogs();
$list = $obj->getList();

注释掉原有的代码,用新的实体类替换老的实体类,如果项目做大了,这个时候要进行这样的改动会很大。这个时候就需要涉及到工厂模式了。

工厂模式就是将我们类的实例化和取出全部有一个通过工厂方法的参数不同来取出对象,实例化对象的时候就变成了这样。

<?php

include 'autoload.php';

$object = ProductFactory::getProduct('book');
$object->getList();

实现代码如下:

<?php

class ProductFactory
{
    public static function getProduct($type)
    {

        switch ($type) {
            case "book" :
                $obj = new Books();
                break;
            case "dog" :
                $obj = new Dogs();
                break;
            case "wine" :
                $obj = new Wines();
                break;
            default:
                $obj = false;
        }

        return $obj;
    }
}

这样工厂模式就完成了,回到刚才老板说的需求,要替代将 book 的数据提到成 dog,我们只需要修改工厂模式就可以了。

<?php

class ProductFactory
{
    public static function getProduct($type)
    {
        if ($type == "book") {
            $type = "dog";
        }

        switch ($type) {
            case "book" :
                $obj = new Books();
                break;
            case "dog" :
                $obj = new Dogs();
                break;
            case "wine" :
                $obj = new Wines();
                break;
            default:
                $obj = false;
        }

        return $obj;
    }
}

这样一来,我们在获取图书列表的的时候就会被替代成狗的数据。