环境搭建

在编码之前,我们先要准备好工具。
不论是 osx、linux 还是 windows,我们都需要先准备好以下环境:

  • PHP(推荐使用 php7+ )
  • Mysql
  • redis

我们使用 PHP 作为编程语言,来操作消息队列。
另外,分别使用 mysql、redis 来作为消息队列的队列容器。

分析与设计

目录总览

|--- Producer.php
|--- Consumer.php
|--- Queue.php
|--- Driver
|---|--- QueueI.php
|---|--- MysqlDriver.php
|---|--- RedisDriver.php
|---|--- Job.php
|--- queue.sql

文件作用介绍

  • Producer.php
    生产者,用于生成消息
  • Consumer.php
    消费者,用于消费消息
  • Queue.php
    队列操作类,为生产者和消费者提供一组统一的消息队列操作接口
  • Driver/QueueI.php
    队列操作接口,规范为Queue.php提供具体服务的驱动类,所有驱动类必须实现此接口,以确保有能力为Queue.php提供底层服务
  • Driver/MysqlDriver.php
    Mysql队列操作驱动,负责与Mysql的底层操作
  • Driver/RedisDriver.php
    Redis队列操作驱动,负责与Redis的底层操作
  • Driver/Job.php
    统一的消息结构(数据格式),可以参考下面《数据格式》的介绍理解此文件的作用
  • queue.sql
    这是一个 sql 文件,里面是 mysql 消息队列表的表结构

数据格式

接着,我们需要统一「消息」的格式。
我们知道,生产者和消费者,同时依赖于消息队列,它们对消息进行处理的时候,则依赖于消息格式。
统一的消息格式,就像是面向对象中的接口,使调用方和实现方,都在规范轨迹之内工作。
所以,我们添加了一个文件:

  • Job.php

调用流程

自己实现access token_自己实现access token

实现框架

接口

Driver/QueueI.php

<?php
// +----------------------------------------------------------------------
// | QueueI.php
// +----------------------------------------------------------------------
// | Description: queue interface
// +----------------------------------------------------------------------
// | Time: 2018/12/19 上午11:17
// +----------------------------------------------------------------------
// | Author: Object,半醒的狐狸
// +----------------------------------------------------------------------

namespace Driver;

interface QueueI
{

    /**
     * @return array
     * 查询tubes列表
     * 一个系统需要多个队列,它们可能分别用于存储短信、邮件等,它们互相隔离。
     */
    public function tubes(): array;

    /**
     * @param Job $job
     * @return Job
     * 向队列中存储一个消息(任务)
     */
    public function put(Job $job): Job;

    /**
     * @param string $tube 需要指定从哪个队列接收任务
     * @return Job
     * 从队列接收一个消息(任务)
     */
    public function reserve(string $tube): Job;

    /**
     * @param Job $job
     * @return bool
     * 删除某个消息(任务)
     */
    public function delete(Job $job): bool;

    /**
     * @param string $tube
     * @return array
     * 获取某个队列中的消息列表
     */
    public function jobs(string $tube): array;
}

工具类

Queue.php

<?php
// +----------------------------------------------------------------------
// | Queue.php
// +----------------------------------------------------------------------
// | Description: 队列工具
// +----------------------------------------------------------------------
// | Time: 2018/12/19 上午11:15
// +----------------------------------------------------------------------
// | Author: Object,半醒的狐狸
// +----------------------------------------------------------------------

class Queue
{

    /**
     * @param string $driver
     * @param array $options
     * 初始化
     */
    public static function init($driver = 'Mysql',$options = [])
    {
        $class = "Driver\\$driver";
        self::$driver = new $class($options);
    }

    public static function tubes(): array
    {
        return self::$driver->tubes();
    }

    public static function put(Job $job): Job
    {
        return self::$driver->put($job);
    }

    public static function reserve(string $tube = 'default'): Job
    {
        return self::$driver->reserve($tube);
    }

    public static function jobs(string $tube = 'default'): array
    {
        return self::$driver->jobs($tube);
    }

    public static function delete(Job $job): bool
    {
        return self::$driver->delete($job);
    }
}

数据对象

Driver/Job.php

<?php
// +----------------------------------------------------------------------
// | Job.php
// +----------------------------------------------------------------------
// | Description: 任务对象
// +----------------------------------------------------------------------
// | Time: 2018/12/19 下午3:19
// +----------------------------------------------------------------------
// | Author: Object,半醒的狐狸
// +----------------------------------------------------------------------

namespace Driver;

class Job
{
    public $id = null;
    public $tube;
    public $status;
    public $job_data;
    public $attempts;
    public $sort;
    public $reserved_at;
    public $available_at;
    public $created_at;

    public static $field = [
        'id', 'tube', 'status',
        'job_data', 'attempts',
        'sort', 'reserved_at',
        'available_at', 'created_at',
    ];

    public static $field_string
        = 'id,tube,status,job_data,attempts,sort,' .
        'reserved_at,available_at,created_at';

    public static function arr2job($jobs)
    {
        $real_jobs = [];
        foreach ($jobs as $v) {
            if (!is_array($v)) {
                $v = json_decode($v, true);
            }
            $real_jobs[] = new Job($v);
        }
        return $real_jobs;
    }

    public function __construct(array $data = [])
    {
        foreach ($data as $k => $v) {
            $this->$k = $v;
        }
        $this->created_at = time();
        $this->available_at = $this->created_at;
    }

    public function isEmpty()
    {
        return $this->job_data ? false : true;
    }
}

## 实现 Mysql 驱动和 Redis 驱动

Mysql 驱动

数据库表结构

Driver/queue.sql

CREATE TABLE `queues` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  `tube` varchar(30) NOT NULL DEFAULT 'default',
  `status` enum('ready','reserved') DEFAULT 'ready',
  `job_data` text NOT NULL,
  `attempts` int(11) NOT NULL DEFAULT '0',
  `sort` int(10) NOT NULL DEFAULT '100',
  `reserved_at` int(11) DEFAULT NULL,
  `available_at` int(11) DEFAULT NULL,
  `created_at` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `queues_index` (`tube`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8

字段意义可以参考《实现框架》一节中的 Job.php 注释
为tube加上索引,可以提升根据 tube 接收 job 的效率

驱动逻辑

Driver/MysqlDriver.php

<?php
// +----------------------------------------------------------------------
// | MysqlDriver.php
// +----------------------------------------------------------------------
// | Description: mysql驱动
// +----------------------------------------------------------------------
// | Time: 2018/12/19 上午11:17
// +----------------------------------------------------------------------
// | Author: Object,半醒的狐狸
// +----------------------------------------------------------------------

namespace Driver;

class MysqlDriver implements QueueI
{
    private $conn; // 数据库连接
    private $config; // 配置
    private $table; // 表
    private $select_suffix; // 查询的前缀
    private $delete_suffix; // 删除的前缀
    private $update_suffix; // 更新的前缀
    private $insert_suffix; // 插入的前缀

    public function __construct($options = [])
    {

        $this->config = $options;

        $this->conn = new \PDO(
            $this->config['dsn'],
            $this->config['username'],
            $this->config['password']
        );

        $field_string = Job::$field_string;
        $this->table = $this->config['table'];
        $this->select_suffix = "SELECT {$field_string} FROM {$this->table}";
        $this->delete_suffix = "DELETE FROM {$this->table}";
        $this->update_suffix = "UPDATE {$this->table}";
        $this->insert_suffix = "INSERT INTO {$this->table}";
    }

    public function tubes(): array
    {
        $sql = "SELECT `tube` FROM {$this->table} GROUP BY `tube`";
        $res = $this->conn->query($sql);
        if (!$res) {
            throw new \PDOException('查询错误:' . $sql . '-错误提示:' . json_encode($statement->errorInfo()));
        }

        return $res->fetchAll(\PDO::FETCH_ASSOC);
    }

    public function delete(Job $job): bool
    {
        if (!$job->id) {
            throw new \Exception('job id 不能为空');
        }
        $sql = "{$this->delete_suffix} WHERE id = :id";
        $statement = $this->conn->prepare($sql);
        $res = $statement->execute([':id' => $job->id]);
        return $res;
    }

    public function jobs(string $tube): array
    {
        $sql = "{$this->select_suffix} WHERE tube = :tube";
        $statement = $this->conn->prepare($sql);
        $res = $statement->execute([':tube' => $tube]);
        if (!$res) {
            throw new \PDOException('查询错误:' . $sql . '-错误提示:' . json_encode($statement->errorInfo()));
        }

        return Job::arr2job($statement->fetchAll(\PDO::FETCH_ASSOC));
    }

    public function put(Job $job): Job
    {
        // 组装sql
        $sql = "{$this->insert_suffix}";
        $field = '';
        $prepare = '';
        $value = [];
        foreach (Job::$field as $v) {
            if ($job->$v) {
                $field .= "{$v},";
                $prepare .= ":{$v},";
                $value[":{$v}"] = $job->$v;
            }
        }
        $field = '(' . trim($field, ',') . ')';
        $prepare = '(' . trim($prepare, ',') . ')';
        $sql = "{$sql} {$field} VALUES {$prepare}";

        // 执行sql
        $statement = $this->conn->prepare($sql);
        $res = $statement->execute($value);

        // 结果
        if (!$res) {
            throw new \PDOException("插入错误:" . $sql . '-错误提示:' . json_encode($statement->errorInfo()));
        }
        $job->id = $this->conn->lastInsertId();

        return $job;
    }

    public function reserve(string $tube): Job
    {
        $time = time();
        $over_time = $time - $this->config['ttr'];
        $sql = "{$this->select_suffix} WHERE (status = 'ready' OR (status = 'reserved' AND reserved_at <= {$over_time})) AND available_at <= {$time} AND tube = :tube ORDER BY sort limit 1";
        $statement = $this->conn->prepare($sql);
        $res = $statement->execute([':tube' => $tube]);
        if (!$res) {
            throw new \PDOException('查询错误:', $sql);
        }

        if ($data = $statement->fetch()) {
            $job = new Job($data);
            $attempts = $job->attempts + 1;
            $time = time();
            $sql = "{$this->update_suffix} SET status='reserved',attempts = {$attempts},reserved_at = {$time} WHERE id = {$job->id}";
            $rows = $this->conn->exec($sql);
            if ($rows <= 0) {
                throw new \PDOException('更新出错:' . $sql . '-错误提示:' . json_encode($statement->errorInfo()));
            }

            return $job;
        }

        return new Job();
    }
}

关于mysql的实现,主要观察其实现接口每个方法的sql与流程,特别是reserve方法,当接收成功之后,还需更新该条消息的状态。

Redis 驱动

在这里,我们使用 redis 的 list 和 sorted-set 来实现数据的存储,因为它本身具备顺序和去重的特性。

redis 服务的搭建与操作,此处不做介绍,这里主要模拟实现消息队列

另外,你的 PHP 环境还需开启 Redis 扩展。
接下来,是驱动的实现。
Driver/RedisDriver.php

<?php
// +----------------------------------------------------------------------
// | RedisDriver.php
// +----------------------------------------------------------------------
// | Description: redis驱动
// +----------------------------------------------------------------------
// | Time: 2018/12/19 上午11:17
// +----------------------------------------------------------------------
// | Author: Object,半醒的狐狸
// +----------------------------------------------------------------------

namespace Driver;

class RedisDriver implements QueueI
{

    private $conn;
    private $config;
    private $tubes_key;

    public function __construct($options = [])
    {
        $this->conn = new \Redis();
        $this->conn->connect($options['ip'], $options['port']);
        if (isset($options['password'])) {
            $this->conn->auth($options['password']);
        }
        $this->tubes_key = $options['tubes'];
    }

    public function tubes(): array
    {
        // 使用 sorted-set 存储当前拥有的队列,比如你 default、test、sms 队列
        return $this->conn->zRange($this->tubes_key, 0, -1);
    }

    public function jobs(string $tube): array
    {
        return Job::arr2job($this->conn->lRange($tube, 0, -1));
    }

    public function put(Job $job): Job
    {
        // 维护 tube 集合,可实现不重复
        $this->conn->zAdd($this->tubes_key, 1, $job->tube);

        // 用 list 存储队列内容,返回的队列长度,就是这个 job 在 list 中的下标
        if ($id = $this->conn->lPush($job->tube, json_encode($job))) {
            $job->id = $id;
        } else {
            throw new \RedisException('插入失败');
        }
        return $job;
    }

    public function delete(Job $job): bool
    {
        // 在 redis 的 list 中不可使用 lRem 来删除具体项,具体原因,在后面测试一节描述
        return true;
    }

    public function reserve(string $tube): Job
    {
        // redis 的rPop在接收时就会将 job 从 list 中删除,所以,没有 reserve 状态
        if ($data = $this->conn->rPop($tube)) {
            $job = json_decode($data, true);
        }
        return new Job($job ?? []);
    }
}

实现生产者和消费者

自动加载

boot.php

<?php
// +----------------------------------------------------------------------
// | boot.php
// +----------------------------------------------------------------------
// | Description: 自动加载
// +----------------------------------------------------------------------
// | Time: 2018/12/19 下午5:51
// +----------------------------------------------------------------------
// | Author: Object,半醒的狐狸
// +----------------------------------------------------------------------

define('ROOT',realpath('./'));

spl_autoload_register(function ($class) {
    $file_path = str_replace('\\', '/', $class);
    $file = $file_path . '.php';
    if (!file_exists($file)) {
        throw new Exception('找不到文件:' . $file);
    }

    include_once $file;
    return true;
});

Mysql 队列生产者

Producer.php

<?php
// +----------------------------------------------------------------------
// | Producer.php
// +----------------------------------------------------------------------
// | Description: 生产者
// +----------------------------------------------------------------------
// | Time: 2018/12/19 下午3:05
// +----------------------------------------------------------------------
// | Author: Object,半醒的狐狸
// +----------------------------------------------------------------------

include_once 'boot.php';

try {

    Queue::init('Mysql', [
        'dsn' => 'mysql:host=mysql;dbname=test',
        'username' => 'root',
        'password' => 'root',
        'table' => 'queues',
        'ttr' => 60,
    ]); // 队列初始化

    // 生产者放入消息

    $job = new Driver\Job([
        'job_data' => json_encode(['order_id' => time(), 'user_id' => 0001]),
        'tube' => 'test'
    ]);
    $job = Queue::put($job);

} catch (Exception $e) {
    var_dump($e->getMessage());
}

Mysql 队列消费者

Consumer.php

<?php
// +----------------------------------------------------------------------
// | Consumer.php
// +----------------------------------------------------------------------
// | Description: 消费者
// +----------------------------------------------------------------------
// | Time: 2018/12/19 下午4:55
// +----------------------------------------------------------------------
// | Author: Object,半醒的狐狸
// +----------------------------------------------------------------------

include_once 'boot.php';

try {

    Queue::init('Mysql', [
        'dsn' => 'mysql:host=mysql;dbname=test',
        'username' => 'root',
        'password' => 'root',
        'table' => 'queues',
        'ttr' => 60,
    ]);

    while (1) {
        // 死循环,使进程一直在cli中运行,不断从消息队列读取数据
        $job = Queue::reserve('test');
        if (!$job->isEmpty()) {
            echo $job->job_data . PHP_EOL;
            sleep(2);
            if (Queue::delete($job)) {
                echo "job was deleted" . PHP_EOL;
            } else {
                echo "delete failed" . PHP_EOL;
            }
        }
    }

} catch (Exception $e) {
    var_dump($e->getMessage());
}

Redis 队列生产者

RedisProducer.php

<?php
// +----------------------------------------------------------------------
// | RedisProducer.php
// +----------------------------------------------------------------------
// | Description: 生产者
// +----------------------------------------------------------------------
// | Time: 2018/12/19 下午3:05
// +----------------------------------------------------------------------
// | Author: Object,半醒的狐狸
// +----------------------------------------------------------------------

include_once 'boot.php';

try {

    Queue::init('Redis', [
        'ip' => 'redis',
        'port' => 6379,
        'tubes' => 'tubes'
    ]); // 队列初始化

    // 生产者放入消息
    $job = new Driver\Job([
        'job_data' => json_encode(['order_id' => time(), 'user_id' => '0001']),
        'tube' => 'default'
    ]);
    $job = Queue::put($job);
    echo $job->id . PHP_EOL;

} catch (Exception $e) {
    var_dump($e->getMessage());
}

Redis 队列消费者

RedisConsumer.php

<?php
// +----------------------------------------------------------------------
// | RedisConsumer.php
// +----------------------------------------------------------------------
// | Description: 消费者
// +----------------------------------------------------------------------
// | Time: 2018/12/19 下午4:55
// +----------------------------------------------------------------------
// | Author: Object,半醒的狐狸
// +----------------------------------------------------------------------

include_once 'boot.php';

try {

    Queue::init('Redis', [
        'ip' => 'redis',
        'port' => 6379,
        'tubes' => 'tubes'
    ]); // 队列初始化

    while (1) {
        // 死循环,使进程一直在cli中运行,不断从消息队列读取数据
        $job = Queue::reserve('default');
        if (!$job->isEmpty()) {
            echo $job->job_data . PHP_EOL;
            sleep(2);
            if (Queue::delete($job)) {
                echo "job was deleted" . PHP_EOL;
            } else {
                echo "delete failed" . PHP_EOL;
            }
        }
    }

} catch (Exception $e) {
    var_dump($e->getMessage());
}

测试

环境

首先,确定好你的环境已经拥有这些内容:

  • php
  • redis
  • mysql
  • phpredis 扩展

而接下来,我们通过运行 Mysql 和 Redis 各自的生产者、消费者,来看消息队列是否已经在工作。

实例化 Queue

打开生产者和消费者,我们可以看到有这么一段代码:

Queue::init('Mysql', [
        'dsn' => 'mysql:host=mysql;dbname=test',
        'username' => 'root',
        'password' => 'root',
        'table' => 'queues',
        'ttr' => 60,
    ]); // 队列初始化

或:

Queue::init('Redis', [
        'ip' => 'redis',
        'port' => 6379,
        'tubes' => 'tubes'
    ]); // 队列初始化

如果前面实现环节,我们有达成共识,那么,相信这里你一定已经知道,这段代码是用来传入连接参数的。
你需要根据你运行的环境,去单独设置它。

测试步骤

  • 打开 4 个 client ( cmd , terminal )窗口
  • 在其中两个命令行中,分别运行如下命令:
php Consumer.php // 运行 Mysql 消费者
php RedisConsumer.php // 运行 Redis 消费者

运行成功后,这两个窗口将处于运行状态,两个消费者都会始终处于 reserve 循环中,直到接收到数据,才会输出。

  • 再另外两个窗口,运行如下命令:
php Producer.php
php RedisProducer.php

小结

到此为止,我们的模拟消息队列就成功了。
我们可以看到,当我们运行生产者的时候,各自对应的消费者窗口中,就会弹出相关的信息。
这就是一个简单的消息队列案例。
但是,这里需要注意,这只是一个模拟案例,它帮助我们理解消息队列,但并不能应用于实际项目中。
我们可以推理一下,消息队列,本该可以使用在高并发的场景,但我们现在实现的消息队列,它还有许多漏洞,根本无法应用在实战场景中。
比如:

  • mysql 驱动的 reserve 方法,它先接收,接收成功后再修改 job 的状态,这在高并发的情况下,将导致我们的 job 被重复接收,因为在「接收」和「修改状态」两个环节中间,可能会有另一个「请求」进来,此时的 job 还处于 ready 状态
  • redis 驱动的 reserve 方法,使用了 redis 的 rPop 操作,这个方法,会在取出数据的同时,将数据从 list 中删除,那么,如果我们消费者处理失败,这个数据就有可能丢失
  • redis 驱动的 put 方法,使用了 lPush 操作,此操作执行成功后,将会返回 list 的长度,我们的 redis 驱动会使用这个长度来作为该 job 的下标( id )。然而, list 的长度会不断变化,并发场景下,你 put 成功后,可能还在处理其他流程,此进程并未结束,此时若有消费者接收走一个消息,则 list 的长度就发生了变化,你在 put 时的 id 就无法与 list 中的数据对应起来