如果你是一个以 php 为主的开发人员,只会依赖现成的框架进行增删改查,想提高自己又不知道从何下手,你可以研究一下这个在线教育开源项目:酷瓜云课堂,这个项目以php + js 为主,负责主要的业务逻辑,部署自动化等用到了 shell 脚本,也用到了不少主流中间件和服务,从点带面带你了解新东西,学会举一反三,你可以学到不少的东西。不要拘泥于语言和框架,你换成其他的语言,除了语言这部分,其他的东西也是大同小异的,变通和消化知识才是学习的终极目标。

酷瓜云课堂-开源知识付费解决方案

项目介绍

酷瓜云课堂,依托腾讯云基础服务架构,采用 C 扩展框架 Phalcon 开发,致力互联网课程点播,互联网课程直播,局域网课程点播,局域网课程直播,垂直于在线教育解决方案。

项目文档

意见反馈

通过这个项目我能学到那些方面的知识呢?

容器化编排

这个项目用到了php,mysql,nginx,redis,beanstalk,xunsearch等等。想想这些服务的安装配置就是一个头疼且枯燥的事情,所以我们使用了docker 和 docker-compose 来组织编排这些服务。我们把这个项目的容器化自动化单独设了一个项目:course-tencent-cloud-docker

客户端实现

这个项目的移动端对于性能和丝滑程度没有那么高的要求,所以我们选择了使用uniapp来实现多客户端,这个还算是一个比较有性价比的东西吧,当然也不是宣传的什么一套代码多端编译(有时条件编译写的你想吐)。我们把这个项目的移动端单独设了一个项目:course-tencent-cloud-app

数据库整体设计

数据结构是一个系统的基石和灵魂,对于以增删改查的业务系统来说,数据库毫无疑问是无比重要的。如何设计表,如何设计关联性,如何规划索引,如何做好预留,这个都是数据库设计该考虑的问题。这个项目中,对业务字段用途都做了很详细的说明(参看Models目录下的实体定义)。

业务逻辑的实现

比如如何限制多人同时使用一个账号登录,后面登录的踢出前面登录的,这也是很常见的一个需求吧?能力强的人自己思考思考能够实现,新手司机们可能是一头雾水。你可以对照这个项目的功能点,去看看各个功能点是如何实现的,如果是你你又会怎么实现,参考别人的思路是学习的重要途径。

计划任务的实现

很多业务逻辑的实现离不开计划任务,这个项目中没有使用 swoole 或者 workman 之类的定时器,使用的是linux 系统自带的 crontab+peppeocchi/php-cron-scheduler 来统一管理计划任务脚本。

(1)sheduler.php 统一任务脚本

use GO\Scheduler;

$scheduler = new Scheduler();

$script = __DIR__ . '/console.php';

$bin = '/usr/local/bin/php';

$scheduler->php($script, $bin, ['--task' => 'vod_event', '--action' => 'main'])
->everyMinute(5);

$scheduler->php($script, $bin, ['--task' => 'sync_learning', '--action' => 'main'])
->everyMinute(7);

$scheduler->run();

(2)crontab 设置执行频率

* * * * * www-data /usr/local/bin/php /var/www/html/ctc/scheduler.php 1>/dev/null 2>&1

缓存系统的实现

缓存可以大大减轻数据库压力,也可以为数据交换提供便利,这个项目里面使用了 opache 缓存,文件缓存,redis 缓存等。比如 session 共享,访问频率限制,商品秒杀就是基于 redis 的特性实现的。

(1)访问频率限制

public function checkRateLimit()
    {
        $settings = $this->getSettings('security.throttle');

        $settings['interval'] = max($settings['interval'], 60);
        $settings['rate_limit'] = max($settings['rate_limit'], 60);

        if ($settings['enabled'] == 0) {
            return true;
        }

        $cache = $this->getCache();

        $sign = $this->getRequestSignature();

        $cacheKey = $this->getCacheKey($sign);

        if ($cache->ttl($cacheKey) < 1) {
            $cache->save($cacheKey, 0, $settings['interval']);
        }

        $rateLimit = $cache->get($cacheKey);

        if ($rateLimit >= $settings['rate_limit']) {
            return false;
        }

        $cache->increment($cacheKey, 1);

        return true;
    }

(2)商品秒杀锁定

class Lock
{

    /**
     * @param string $itemId
     * @param int $expire
     * @return bool|string
     * @throws RedisException
     */
    public static function addLock($itemId, $expire = 10)
    {
        if (empty($itemId) || $expire <= 0) {
            return false;
        }

        /**
         * @var RedisCache $cache
         */
        $cache = Di::getDefault()->getShared('cache');

        $redis = $cache->getRedis();

        $lockId = Text::random(Text::RANDOM_ALNUM, 16);

        $keyName = self::getLockKey($itemId);

        $result = $redis->set($keyName, $lockId, ['nx', 'ex' => $expire]);

        return $result ? $lockId : false;
    }

    /**
     * @param string $itemId
     * @param string $lockId
     * @return bool
     * @throws RedisException
     */
    public static function releaseLock($itemId, $lockId)
    {
        if (empty($itemId) || empty($lockId)) {
            return false;
        }

        /**
         * @var RedisCache $cache
         */
        $cache = Di::getDefault()->getShared('cache');

        $redis = $cache->getRedis();

        $keyName = self::getLockKey($itemId);

        $redis->watch($keyName);

        /**
         * 监听key防止被修改或删除,提交事务后会自动取消监控,其他情况需手动解除监控
         */
        if ($lockId == $redis->get($keyName)) {
            $redis->multi()->del($keyName)->exec();
            return true;
        }

        $redis->unwatch();

        return false;
    }

    public static function getLockKey($itemId)
    {
        return sprintf('_LOCK_:%s', $itemId);
    }

消息队列的实现

这个项目中,对于一些特定事件和消息通知使用了消息队列来实现,消息队列使用的是中规中矩的 beanstalk 中间件,没有很复杂但是很强大,该有的也都有,消息队列的历史记录在 mysql 里面,生成一个任务就有一条记录,然后推送到 beanstalk。命令行模式下起了两个相关进程消费这些消息,使用了 supervisor 监控这两个进程,如果进程有问题会自动重启。

(1)task 后置操作自动推送消息

public function afterCreate()
{
        $beanstalk = $this->getBeanstalk();

        $tube = $this->isNoticeTask($this->item_type) ? 'notice' : 'main';

        $beanstalk->putInTube($tube, $this->id);
 }

(2)supervisor 监控配置

[program:queue-main-worker]
command=/usr/local/bin/php /var/www/html/ctc/console.php queue main_worker
user=www-data
autostart=true
autorestart=true
redirect_stderr=true
startretries=30
startsecs=10

[program:queue-notice-worker]
command=/usr/local/bin/php /var/www/html/ctc/console.php queue notice_worker
user=www-data
autostart=true
autorestart=true
redirect_stderr=true
startretries=30
startsecs=10

(3)消费队列 worker

/**
     * 启动main消费队列
     *
     * @command php console.php queue main_worker
     */
    public function mainWorkerAction()
    {
        $tube = 'main';

        echo "------{$tube} worker start ------" . PHP_EOL;

        $beanstalk = $this->getBeanstalk();

        $logger = $this->getLogger('queue');

        $config = $this->getConfig();

        while (true) {
            $job = $beanstalk->reserveFromTube($tube, 60);
            if ($job instanceof BeanstalkJob) {
                $taskId = $job->getBody();
                if ($config->get('env') == ENV_DEV) {
                    $logger->debug("tube:{$tube}, task:{$taskId} handling");
                }
                try {
                    $manager = new MainQueue();
                    $manager->handle($taskId);
                    $job->delete();
                } catch (\Throwable $e) {
                    $logger->error("tube:{$tube}, task:{$taskId} exception " . kg_json_encode([
                            'file' => $e->getFile(),
                            'line' => $e->getLine(),
                            'message' => $e->getMessage(),
                        ]));
                }
            } else {
                $this->keepDbConnection();
            }
        }
    }

注意: 命令行下,过长时间会导致数据库链接丢失,所以隔断时间向数据库发个消息(简单做个查询即可),保持链接的可用。

全文检索的实现

全文检索的实现,我们没有使用 elasticsearch,也没有使用 sphinx,而采用了国产的 xunsearch,原因就是简单够用,对中小项目来说非常友好。

(1)文档定义

project.name = article
project.default_charset = UTF-8

server.index = xunsearch:8383
server.search = xunsearch:8384

[id]
type = id

[title]
type = title

[cover]
type = string

[summary]
type = body

[category_id]
type = string
index = self
tokenizer = full

[owner_id]
type = string
index = self
tokenizer = full

[create_time]
type = string

[tags]
type = string

[category]
type = string

[owner]
type = string

[view_count]
type = string

[comment_count]
type = string

[like_count]
type = string

[favorite_count]
type = string

(2)索引操作

class ArticleIndexTask extends Task
{

    /**
     * 搜索测试
     *
     * @command: php console.php article_index search {query}
     * @param array $params
     * @throws \XSException
     */
    public function searchAction($params)
    {
        $query = $params[0] ?? null;

        if (!$query) {
            exit('please special a query word' . PHP_EOL);
        }

        $result = $this->searchArticles($query);

        var_export($result);
    }

    /**
     * 清空索引
     *
     * @command: php console.php article_index clean
     */
    public function cleanAction()
    {
        $this->cleanArticleIndex();
    }

    /**
     * 重建索引
     *
     * @command: php console.php article_index rebuild
     */
    public function rebuildAction()
    {
        $this->rebuildArticleIndex();
    }

    /**
     * 清空索引
     */
    protected function cleanArticleIndex()
    {
        $handler = new ArticleSearcher();

        $index = $handler->getXS()->getIndex();

        echo '------ start clean article index ------' . PHP_EOL;

        $index->clean();

        echo '------ end clean article index ------' . PHP_EOL;
    }

    /**
     * 重建索引
     */
    protected function rebuildArticleIndex()
    {
        $articles = $this->findArticles();

        if ($articles->count() == 0) return;

        $handler = new ArticleSearcher();

        $doc = new ArticleDocument();

        $index = $handler->getXS()->getIndex();

        echo '------ start rebuild article index ------' . PHP_EOL;

        $index->beginRebuild();

        foreach ($articles as $article) {
            $document = $doc->setDocument($article);
            $index->add($document);
        }

        $index->endRebuild();

        echo '------ end rebuild article index ------' . PHP_EOL;
    }

    /**
     * 搜索文章
     *
     * @param string $query
     * @return array
     * @throws \XSException
     */
    protected function searchArticles($query)
    {
        $handler = new ArticleSearcher();

        return $handler->search($query);
    }

    /**
     * 查找文章
     *
     * @return ResultsetInterface|Resultset|ArticleModel[]
     */
    protected function findArticles()
    {
        return ArticleModel::query()
            ->where('published = 1')
            ->andWhere('deleted = 0')
            ->execute();
    }

}

数据自动备份

数据的重要性这里就不强调了,假如那天你的数据库被黑客锁了,问你要高额的赎金,而你又没有最近数据的备份,要数据还是乖乖交钱?

这个项目里面使用 mysqldump 做定时备份,结合腾讯云的 coscmd 命令行工具,实现备份上传到腾讯云存储。

#!/usr/bin/env bash

#备份保留天数
KEEP_DAYS=15

#COSCMD命令路径(绝对路径)
COS_CMD=/usr/local/bin/coscmd

#COS配置文件路径(绝对路径)
COS_CONF_PATH=/root/.cos.conf

#本地目录(绝对路径,末尾不带"/")
LOCAL_DIR=/root/ctc-docker/mysql/data/backup

#远程目录(绝对路径,末尾不带"/")
REMOTE_DIR=/backup/database

docker exec -i ctc-mysql bash <<'EOF'

#数据库名称
DB_NAME=ctc

#数据库用户
DB_USER=ctc

#数据库密码
DB_PWD=1qaz2wsx3edc

#备份目录(末尾不带"/")
backup_dir=/var/lib/mysql/backup

#创建备份目录
if [ ! -d ${backup_dir} ]; then
  mkdir -p ${backup_dir}
fi

#导出数据
mysqldump --no-tablespaces -u ${DB_USER} -p${DB_PWD} ${DB_NAME} | gzip > ${backup_dir}/${DB_NAME}-$(date +%Y-%m-%d).sql.gz

exit
EOF

#删除过期备份
find ${LOCAL_DIR} -mtime +${KEEP_DAYS} -name "*.sql.gz" | xargs rm -f

#同步备份
echo y | ${COS_CMD} -c ${COS_CONF_PATH} upload -rs --delete ${LOCAL_DIR}/ ${REMOTE_DIR}/

升级更新的实现

(1)如果每次更新都要上传文件,比较异同,那未免太痛苦了。

(2)如果每次更新都要导入 xxx.sql 文件,那执行过那些操作如何管控呢。

我这个项目中,以git版本控制的方式应对(1)的问题,以migration数据迁移的方式应对(2)的问题,以shell 脚本为载体,执行相关程序调用,实现自动升级更新。

#!/usr/bin/env bash

#docker目录
DOCKER_DIR=/root/ctc-docker

#ctc目录
CTC_DIR=${DOCKER_DIR}/html/ctc

#static.sh
static_sh=${DOCKER_DIR}/static.sh

#config.php
config_php=${CTC_DIR}/config/config.php

#静态文件版本号
static_version=$(date +%s)

#成功信息输出
success_print() {
  echo -e "\033[32m $1 \033[0m"
}

#失败信息输出
error_print() {
  echo -e "\033[31m $1 \033[0m"
}

if [ ! -d "${CTC_DIR}" ]; then
  error_print "\n------ ctc dir not existed ------\n"
fi

#拉取docker更新
cd ${DOCKER_DIR} && git pull --no-rebase

#拉取ctc源码更新
cd ${CTC_DIR} && git pull --no-rebase

#同步静态资源文件
if [ -f "${static_sh}" ]; then
    bash "${static_sh}"
fi

#更新静态资源版本号
sed -i "s/\$config\['static_version'\].*/\$config['static_version'] = '${static_version}';/g" "${config_php}"

docker exec -i ctc-php bash <<'EOF'

#切换到ctc目录
cd /var/www/html/ctc || exit

#安装依赖包
composer install --no-dev
composer dump-autoload --optimize

#数据迁移
vendor/bin/phinx migrate

#程序升级
php console.php upgrade

#重启队列
supervisorctl restart queue-main-worker
supervisorctl restart queue-notice-worker

exit
EOF

success_print "\n------ upgrade finished -------\n"

自动部署上线

主流的代码托管平台都提供相关的 webhook,我们以 gitee 为例,在代码提交到远程的时候触发自动部署上线,也就是调用执行上一步中的升级更新。

gitee-webhooks

webhook 执行的脚本如下:

<?php

$baseDir = '/root/ctc-docker';

$secret = '1qaz2wsx3edc';

$branch = 'master';

$requestBody = file_get_contents('php://input');

if (empty($requestBody)) {
    header('HTTP/1.1 400 Bad Request');
    die('Bad Request');
}

$payload = json_decode($requestBody, true);

if (!isset($payload['timestamp']) || !isset($payload['sign'])) {
    header('HTTP/1.1 400 Bad Request');
    die('Invalid Payload');
}

$content = "{$payload['timestamp']}\n{$secret}";

$mySign = base64_encode(hash_hmac('sha256', $content, $secret, true));

if ($payload['sign'] != $mySign) {
    header('HTTP/1.1 403 Permission Denied');
    die('Permission Denied');
}

if ($payload['ref'] == "refs/heads/{$branch}" && $payload['total_commits_count'] > 0) {

    $result = shell_exec("bash {$baseDir}/upgrade.sh");

    echo $result;
}

整站迁移实现

如果说服务器搬家又要重复环境配置,系统安装,导入导出等等操作,那必定是一件痛苦的事情。这个项目里面使用脚本完成了相关操作,分整站备份和还原两部分。

(1)整站备份
(2)整站还原


鸠摩智首席音效师
472 声望9 粉丝

身强体健,龙精虎猛的活着。