如果你是一个以 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 为例,在代码提交到远程的时候触发自动部署上线,也就是调用执行上一步中的升级更新。
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;
}
整站迁移实现
如果说服务器搬家又要重复环境配置,系统安装,导入导出等等操作,那必定是一件痛苦的事情。这个项目里面使用脚本完成了相关操作,分整站备份和还原两部分。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。