cloudycity.github.io's People
cloudycity.github.io's Issues
搭建hexo博客
cover: https://pic2.zhimg.com/v2-7827197bfa0023932d428633d41372fd_720w.jpg?source=172ae18b
Hexo是一种使用Node.js编写的静态博客框架,快速、简洁、扩展丰富。本文介绍如何搭建Hexo博客,并部署到GitHub Page。
前言
个人认为,写博客是一个程序猿记录和总结的最好方式,虽然写博文没有使用笔记软件记录那么方便,需要更加严谨,但我觉得这其实是好事,可以迫使你更加认真的去钻研,而不只是浅尝辄止。长期坚持下来,可以改变你的学习态度,受益匪浅。
不过我本来并不打算写这篇博文,因为网上已经有太多的教程,没有必要重复造轮子。但是我在搭建过程遇到很多问题,花了不少时间,所以还是决定记录下来。
简介
Hexo是一种使用Node.js编写的静态博客框架,快速、简洁、扩展丰富。
搭建完成后使用非常简单:
- 在hexo项目目录下的\source\_posts\中用Markdown语法写博文;
- 生成静态文件(Hexo将MD文件其解析成HTML文件)并部署。
搭建
本教程将Hexo部署到GitHub Page ,可以节省域名和空间的费用。
安装与配置Git
如果你是初次使用,安装时一路Next即可,然后打开Git Bash
进行用户配置。
$ git config --global user.name 你的用户名
$ git config --global user.email 你的邮箱
生成SSH公钥。
$ ssh-keygen
首先 ssh-keygen
会确认密钥的存储位置(默认是 .ssh/id_rsa
),然后它会要求你输入两次密钥口令。如果你不想在使用密钥,直接回车即可。
接下来使用cat
命令查看公钥,下一步会用到。
$ cat ~/.ssh/id_rsa.pub
创建GitHub仓库
创建一个仓库,名称必须是你的GitHub用户名.github.io
,例如我的是仓库名为CloudyCity.github.io
。
然后进入Setting页面将上一步生成的公钥添加进去,以获得让你的电脑获得访问仓库的权限。
安装与配置Hexo
接下来就可以开始正题了,安装后使用管理员身份打开Git Bash
。
$ npm install hexo-cli -g
进入你打算存放本地仓库的文件夹,初始化Hexo,并安装依赖。
$ hexo init
之后可以查看Hexo版本。
$ hexo -v
Hexo 3.0 之后需要另外安装deployer
才能部署。
$ npm install hexo-deployer-git --save
现在可以先进行本地测试,首先生成静态页面。
$ hexo g
然后启用本地服务,用浏览器打开 localhost:4000,如果没问题继续往下。
$ hexo s
打开Hexo文件夹下的*_config.yml*,这里只说明关于部署的配置项,其他配置项请移步至Hexo文档 。
deploy:
type: git
repository: [email protected]:CloudyCity/CloudyCity.github.io.git
branch: master
将这里的repository
的值改为你的GitHub仓库的SSH地址即可。
激动人心的时刻来了,执行部署。
$ hexo d
如果你上面的步骤的都没问题的话,这时候在浏览器直接访问你的仓库名 (例:https://cloudycity.github.io) 就可以看到你的博客了。
上传Hexo的源码
打开Hexo文件夹,创建Git本地仓库
$ git init
创建新分支
$ git branch source
切换成source分支
$ git checkout source
追踪所有文件
$ git add .
提交更改(到本地仓库)
$ git commit -m 'first time upload source'
添加远程仓库,这里也是键入你的GitHub仓库的SSH地址
$ git remote add origin [email protected]:CloudyCity/CloudyCity.github.io.git
推送到远程仓库
$ git push origin source
如果没遇到问题,那恭喜你成功将Hexo源码推送到你在GitHub的名称为你的GitHub用户名.github.io
的仓库的source分支中!这样以后你换一台设备亦可以继续写博文啦~(当然你还是需要添加新设备的公钥到GitHub中)
需要注意的是,因为文章存放在source分支中,所以每次写好一篇新文章,应该提交并推送到远程分支中。
下载Hexo的源码
这里假如你换了一台设备,打开你打算作为本地仓库的文件夹,创建本地仓库,生成与添加公钥的步骤不再赘述。
克隆远程仓库
$ git clone [email protected]:CloudyCity/CloudyCity.github.io.git
切换source分支
$ git checkout source
源码中的package.json
已保存依赖信息,这里只需要自动安装即可。
$ npm install
这样子就再次搭建好Hexo环境啦~
最后
搭建完成只是开始,坚持用心写文章才是最难的,大家一起加油。
另外,hexo还有非常多好看的主题和有用的插件哦~
Laravel5.2 DB Builder 子查询的优雅写法
cover: https://cdn.educba.com/academy/wp-content/uploads/2020/05/Laravel-Query-Builder.jpg
Laravel5.2的DB Builder还没有fromSub()
与joinSub()
,Sql涉及子查询时,比较优雅的写法是利用toSql()
与mergeBindings()
。
From
SELECT
*
FROM
( SELECT * FROM t1 WHERE c1 = xxx AND c2 = xxx GROUP BY c1 ) AS sub
WHERE
c1 = xxx
AND c2 = xxx;
$subQuery = DB::table('t1')->where('c1', 'xxx')->where('c2', xxx)->groupBy('c1');
$query = DB::table(DB::raw("({$subQuery->toSql()}) as sub"))
->select(['*'])
->where('c1', 'xxx')
->where('c2', 'xxx');
->mergeBindings($subQuery)
->get();
Join
SELECT
t1.*,
sub.c4
FROM
t1
LEFT JOIN ( SELECT * FROM t2 GROUP BY c1, c2 ) AS sub ON t1.c1 = t2.c1
AND t1.c2 = t2.c2
$subQuery = DB::table('t2')->groupBy('c1', 'c2');
$query = DB::table('t1')
->select(['t1.*', 'sub.c4'])
->leftJoin(DB::raw("({subQuery->toSql()}) as sub"), function ($join) {
/** @var JoinClause $join */
$join->on('t1.c1', '=', 't2.c1')
->on('t1.c2', '=', 't2.c2');
})
->mergeBindings($subQuery)
->get();
Laravel8 实现消息通知
cover: https://res.cloudinary.com/practicaldev/image/fetch/s--z9joCtos--/c_imagga_scale,f_auto,fl_progressive,h_420,q_auto,w_1000/https://thepracticaldev.s3.amazonaws.com/i/km70pecom0524qttto7z.jpeg
Laravel从5.3起,提供了广播、通知两个模块,可以很方便的实现消息通知。这里以评论回复通知为例进行介绍。
简单介绍下概念
- 广播的对象是Socket客户端,通知的对象是根据渠道而定。可选的通知渠道有广播、短信、邮件、数据库或自定义渠道。
- 广播可以直接由事件触发或由通知触发,这里采用的是后者。
流程
Laravel部分
用户
namespace App\Models\Sys;
use App\Models\Model;
use Illuminate\Notifications\Notifiable;
class User extends Model
{
use Notifiable;
protected $table = 'user';
/**
* 用户接收广播通知的通道.
*
* @return mixed
*/
public function receivesBroadcastNotificationsOn()
{
return 'user.' . $this->id;
}
}
评论
namespace App\Models\Common;
use App\Models\Model;
use App\Models\Sys\User;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class Comment extends Model
{
protected $table = 'comment';
protected $fillable = [
'content', 'uid', 'commentable_id', 'commentable_type'
];
protected $with = [
'user'
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'uid');
}
public function commentable(): MorphTo
{
return $this->morphTo();
}
}
观察者
namespace App\Observers;
use App\Models\Common\Comment;
use App\Notifications\CommentCreated;
class CommentObserver extends Observer
{
/**
* Handle the User "created" event.
*
* @param Comment $comment
* @return void
*/
public function created(Comment $comment)
{
event(new CommentCreated($comment));
}
}
注册观察者
namespace App\Providers;
use App\Models\Common\Comment;
use App\Observers\CommentObserver;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*
* @return void
*/
public function register()
{
//
}
/**
* Bootstrap any application services.
*
* @return void
*/
public function boot()
{
Comment::observe(CommentObserver::class);
}
}
事件
namespace App\Events;
use App\Models\Common\Comment;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class CommentCreated
{
use Dispatchable, SerializesModels;
/**
* @var Comment
*/
public $comment;
/**
* Create a new event instance.
*
* @param $comment
*/
public function __construct($comment)
{
$this->comment = $comment;
}
}
监听器
namespace App\Listeners;
use App\Events\CommentCreated as CommentCreatedEvent;
use App\Models\Gs\IssueLog;
use App\Models\Sys\User;
use App\Notifications\CommentCreated;
class NotifyComment extends Listener
{
/**
* Handle the notification.
*
* @param CommentCreatedEvent $event
*/
public function handle(CommentCreatedEvent $event)
{
// 相关用户
$users = [];
if ($event->comment->commentable instanceof IssueLog) {
// 工单跟进者
$user[$event->comment->commentable->user->id] = $event->comment->commentable->user;
// 工单创建者
$user[$event->comment->commentable->creator->id] = $event->comment->commentable->creator;
// 工单报表评论者
foreach ($event->comment->commentable->comments as $cmt) {
$users[$cmt->user->id] = $users[$cmt->user->id] ?? $cmt->user;
}
}
// 发送通知
foreach ($users as $user) {
/** @var User $user */
if ($user->id != $event->comment->uid) {
$user->notify(new CommentCreated($event->comment));
}
}
}
}
:::tip
Laravel从5.8.9开始支持事件发现,无需手动注册事件及对应监听器。
:::
通知
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use App\Models\Common\Comment;
use \Illuminate\Notifications\Notification
class CommentCreated extends BaseNotification implements ShouldQueue
{
use Queueable;
protected $comment;
/**
* Get the notification's delivery channels.
*
* @return array
*/
public function via()
{
return ['broadcast', 'database'];
}
/**
* Create a new notification instance.
*
* @param Comment $comment
*/
public function __construct(Comment $comment)
{
$this->comment = $comment;
}
/**
* Get the array representation of the notification.
*
* @return array
*/
public function toArray()
{
return [
'commentable' => $this->comment->commentable,
'commentable_type' => get_class($this->comment->commentable),
'comment' => $this->comment
];
}
}
NodeJS部分
安装依赖
npm i laravel-echo laravel-echo-server pm2 socket.io-client
启动Larvel Echo Server
启动脚本
queue-listen.sh
#!/usr/bin/env bash
php artisan queue:listen --tries=1
socket.sh
#!/usr/bin/env bash
node_modules/laravel-echo-server/bin/server.js start
使用PM2调度
node_modules/pm2/bin/pm2 start queue-listen.sh
node_modules/pm2/bin/pm2 start socket.sh
node_modules/pm2/bin/pm2 monit # 监控进程输出,便于开发调试
# 后续调度可使用stop/start/restart all
:::tip
你可以使用Supervisor代替PM2。
:::
:::warning
PM2启动的进程与用户关联,使用同一个户进行操作,避免重复启动
:::
Laravel Echo监听通知
全局变量声明
resources/js/bootstrap.js
/**
* Echo exposes an expressive API for subscribing to channels and listening
* for events that are broadcast by Laravel. Echo and event broadcasting
* allows your team to easily build robust real-time web applications.
*/
import Echo from 'laravel-echo';
window.io = require('socket.io-client');
window.Echo = new Echo({
broadcaster: 'socket.io',
host: window.location.hostname + (process.env.MIX_APP_ENV === 'local' ? ':6001' : ''),
auth: {
headers: {}
},
});
启动监听
主视图
<script>
Echo.private('user.' + uid)
.notification((e) => {
// do something
});
</script>
ElementUI Tree组件实现完美的联动选择
cover: https://pic4.zhimg.com/v2-a1e6c95e17fe8619c25bf3dbfebfb012_1440w.jpg?source=172ae18b
Element Tree组件默认支持【联动选择】,但是无法【只选中父项,不选任何子项】,这里介绍如何实现完美的联动选择。
关闭自带的【联动选择 】特性
ElTree的check-strictly
属性默认为false
,至少一个子项被勾选时,父项会进入半选中/选中状态。
改为true
即可实现【只选中父项,不选任何子项】,后面自行实现联动。
<el-form-item label="授权">
<el-tree
ref="permissionTree"
:data="permissionTree"
:props="treeProps"
:default-checked-keys="checkedIds"
node-key="id"
:expand-on-click-node="false"
default-expand-all
check-on-click-node
check-strictly
show-checkbox
/>
</el-form-item>
监听check事件实现【联动选择】
<el-form-item label="授权">
<el-tree
ref="permissionTree"
:data="permissionTree"
:props="treeProps"
:default-checked-keys="checkedIds"
node-key="id"
:expand-on-click-node="false"
default-expand-all
check-on-click-node
check-strictly
show-checkbox
@check="handleCheckNode"
/>
</el-form-item>
handleCheckNode(obj) {
if (this.$refs.permissionTree.getCheckedKeys().indexOf(obj.id) !== -1) {
this.checkParentNode(obj); // 勾选父项
this.checkChildrenNode(obj, true); // 全选子项
} else {
if (obj.children) {
this.checkChildrenNode(obj, false); // 所有子项反选
}
}
},
checkChildrenNode(obj, isChecked) {
this.$refs.permissionTree.setChecked(obj.id, isChecked);
if (obj.children) {
for (let i = 0; i < obj.children.length; i++) {
this.checkChildrenNode(obj.children[i], isChecked);
}
}
},
checkParentNode(obj) {
const node = this.$refs.permissionTree.getNode(obj);
if (node.parent.key !== undefined) {
this.$refs.permissionTree.setChecked(node.parent, true);
this.checkParentNode(node.parent);
}
},
自由选择和关联选择共存
以权限树为例,在某些授权场景,用户希望只选中父项。经过第2步的处理已经能实现,但是体验较差(需要选择反选子项)。
这时候没有联动选择反而效率更高,所以【自由选择】和【关联选择】两个特性如果能共存,就能应对更多场景。
实现很简单,只需要将监听的事件改成node-check
即可。点击节点是关联选择,点击勾选框是自由选择(即最原始的勾选框)。
<el-form-item label="授权">
<el-tree
ref="permissionTree"
:data="permissionTree"
:props="treeProps"
:default-checked-keys="checkedIds"
node-key="id"
:expand-on-click-node="false"
default-expand-all
check-on-click-node
check-strictly
show-checkbox
@node-check="handleCheckNode"
/>
</el-form-item>
PHP项目在MySQL使用顺序的UUID
cover: https://files.speakerdeck.com/presentations/ac4260cccd9b41418e61426acf0f9e73/slide_22.jpg
分布式系统的数据库都离不开UUID。在RFC 4122标准中各版本的UUID都有存在一些缺点,简单概括就是长而乱序。其中乱序则是通过顺序的UUID来减少影响。
本文介绍什么是顺序的UUID,以及如何在PHP中生成。
标准UUID的缺点
长度为36位
太长基本是无解的。虽然转换成字节型,可以将长度从36位减少为16位,但是不具备可读性,查询出来后需要再转换为字符串才可读。不过我们可以去掉4个无意义的短横线,将长度减少为32位。
乱序
所有UUID都不是单调递增的。
在InnoDB中,如果将UUID作为主键插入,数据会散列在硬盘中。对比自增主键,数据库会更大、查询会更慢。另外,InnoDB所有的次要键都会包括主键,长度为36位(或16位)的UUID也仍将被包含在每一个次要键中,将大幅增加索引大小,意味着增加内存占用。
所以,UUID不适合作为InnoDB表的主键,建议只是将UUID作为作为次要键,使用唯一约束。
不过乱序则是通过顺序的UUID来减少影响。
什么是顺序的UUID
顺序的UUID,AKA按时间排序的UUID、单调递增的UUID,是RFC 4122 Version 1 UUID的变种,在RFC 4122 Version 6草稿被提出。需注意的是该草稿并未通过,所以不能直接将其称为Version 6
,并且在获取社区支持上可能不如官方标准。
顺序的UUID相对于Version 1
加上了一个特性:新生成的值总是大于已生成的值。
这个特性可以减少插入UUID和重新编排索引的IO开销。
:::warning
即便如此,顺序UUID的长度并未变化,意味着它仍不适合作为InnoDB表的主键。
:::
如何生成顺序的UUID
这里推荐使用10k star的组件ramsey/uuid, 里面已实现了按时间排序的UUID。
引入组件
composer require ramsey/uuid;
快速生成
use Ramsey\Uuid\Uuid;
$uuid = Uuid::uuid6();
$uuid->toString(); // 36位字符串
$uuid->getByte(); // 16位字节型
:::warning
虽然组件文档很严谨将按时间排序的UUID归为非官方标准,但组件中还是沿用了草稿的version 6
命名,所以本文具有时效性,如果RFC标准发生更新了,请以组件文档为主。
:::
生成流程解析
这里以4.0.1
版本为例(为便于阅读进行了重排版)作简单的解析。
组件遵循RFC 4122
以及草稿的规范,uuid6()
也是uuid()
的变种,也是接收节点$node
与时钟序列$clockSeq
两个参数。
public static function uuid6(?Hexadecimal $node = null, ?int $clockSeq = null): UuidInterface
{
return self::getFactory()->uuid6($node, $clockSeq);
}
这两个参数最后在vendor/ramsey/uuid/src/Generator/DefaultTimeGenerator.php
中使用
:::details Click to see more
public function generate($node = null, ?int $clockSeq = null): string
{
if ($node instanceof Hexadecimal) {
$node = $node->toString();
}
$node = $this->getValidNode($node);
if ($clockSeq === null) {
try {
// This does not use "stable storage"; see RFC 4122, Section 4.2.1.1.
$clockSeq = random_int(0, 0x3fff);
} catch (Throwable $exception) {
throw new RandomSourceException(
$exception->getMessage(),
(int) $exception->getCode(),
$exception
);
}
}
$time = $this->timeProvider->getTime();
$uuidTime = $this->timeConverter->calculateTime(
$time->getSeconds()->toString(),
$time->getMicroseconds()->toString()
);
$timeHex = str_pad($uuidTime->toString(), 16, '0', STR_PAD_LEFT);
if (strlen($timeHex) !== 16) {
throw new TimeSourceException(sprintf(
'The generated time of \'%s\' is larger than expected',
$timeHex
));
}
$timeBytes = (string) hex2bin($timeHex);
return $timeBytes[4] . $timeBytes[5] . $timeBytes[6] . $timeBytes[7]
. $timeBytes[2] . $timeBytes[3]
. $timeBytes[0] . $timeBytes[1]
. pack('n*', $clockSeq)
. $node;
}
/**
* Uses the node provider given when constructing this instance to get
* the node ID (usually a MAC address)
*
* @param string|int|null $node A node value that may be used to override the node provider
*
* @return string 6-byte binary string representation of the node
*
* @throws InvalidArgumentException
*/
private function getValidNode($node): string
{
if ($node === null) {
$node = $this->nodeProvider->getNode();
}
// Convert the node to hex, if it is still an integer.
if (is_int($node)) {
$node = dechex($node);
}
if (!ctype_xdigit((string) $node) || strlen((string) $node) > 12) {
throw new InvalidArgumentException('Invalid node value');
}
return (string) hex2bin(str_pad((string) $node, 12, '0', STR_PAD_LEFT));
}
:::
如果$clockSeq
为null,则默认使用一个0~16383范围内的随机数。
:::warning
对于默认的时钟序列,组件未遵循RFC标准的稳定存储,因为这部分需要额外的数据存储。如果你的节点在每微秒都有很高的生成频率,就需要自行维护。目前组件支持相同节点在每微秒生成16384个不重复的UUID,目前地球上应该还没出现这个频率以上的场景。
:::
如果$node
为null,将调用默认的nodeProvider
获取。
而vendor/ramsey/uuid/src/FeatureSet.php
中定义了所有默认特性,默认的nodeProvider
为FallbackNodeProvider
, 其实是一个集合,将按顺序尝试获取。其中集合中的SystemNodeProvider
是获取系统的MAC地址,RandomNodeProvider
是使用随机数。
/**
* Returns a node provider configured for this environment
*/
private function buildNodeProvider(): NodeProviderInterface
{
if ($this->ignoreSystemNode) {
return new RandomNodeProvider();
}
return new FallbackNodeProvider(new NodeProviderCollection([
new SystemNodeProvider(),
new RandomNodeProvider(),
]));
}
:::warning
如果你的分布式项目运行在Docker容器中,要注意:如果没有启动参数,按照Docker的默认分配机制,会导致每个PHP容器的MAC地址一致,而此组件默认使用MAC地址,所以最终导致节点一致,提高UUID的重复几率。
:::
Refrences
[0] RFC 4122
[1] Storing UUID Values in MySQL
[2] ramsey/uuid doc
Laravel使用RequestID的简易方案
cover: https://bs-uploads.toptal.io/blackfish-uploads/blog/article/content/cover_image_file/cover_image/268618/retina_500x200__0602_Publications_Engineering__Laravel_Passport_Part_1-3_Zara_Newsletter___blog-11e3d466c241197a1a2fc0428ff1ef05.png
一些大型Web系统通常会给请求分配一个UUID,便于追踪定位线上的问题。
这部分的功能可以细分为:
- 分配UUID
- 存储请求
- 检索请求
分配UUID
这部分比较简单,写个中间件并注册即可:
<?php
namespace App\Http\Middleware;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Laravel\Telescope\Watchers\RequestWatcher;
use Ramsey\Uuid\Uuid;
class AppendRequestId
{
/**
* 为请求分配一个ID
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle(Request $request, \Closure $next)
{
$uuid = Uuid::uuid6()->toString();
$request->headers->set('X-Request-ID', $uuid);
/** @var Response $response */
$response = $next($request);
$response->headers->set('X-Request-ID', $uuid);
if ($response instanceof JsonResponse) {
$response->setData(tap($response->getData(), function ($data) use ($uuid) {
$data->request_id = $uuid;
}));
}
return $response;
}
}
存储请求
这里推荐 Laravel 官方组件 Telescope , 安装过程见官方文档。
:::warning
Telescope
会对服务器性能有一些影响,不过你可以通过.env
管理Watcher
,确保线上只开启必要的Watcher
,可以减少影响。
:::
Telescope
使用Tag
进行检索,所以我们需要把上面生成的UUID保存到Tag
中。
<?php
namespace App\Providers;
use App\Models\System\Admin;
use Illuminate\Support\Facades\Gate;
use Laravel\Telescope\IncomingEntry;
use Laravel\Telescope\Telescope;
use Laravel\Telescope\TelescopeApplicationServiceProvider;
class TelescopeServiceProvider extends TelescopeApplicationServiceProvider
{
/**
* Register any application services.
*
* @return void
*/
public function register()
{
// ...
Telescope::tag(function (IncomingEntry $entry) {
if ($entry->type === 'request' && $requestId = request()->headers->get('x-request-id', null)) {
return [$requestId];
}
return [];
});
}
// ...
检索请求
:::tip
细心的朋友可能会发现Telescope
本身有UUID,那直接用这个作为请求ID岂不美哉?可惜的是,这个UUID在发送响应给客户端之后才产生的,没办法利用起来,参考 Issue。
:::
hexo使用github issue存放文章与评论
cover: https://i.imgur.com/P2E0Xkw.jpg
Hexo
默认文章存放于source
分支,没有评论系统,不过都可以通过插件进行扩展。本文介绍如何将hexo
的文章与评论存放于Github Issue中(issue内容为博客内容,issue评论为博客评论)。
使用github issue存放文章
这种方案主要优点是可以直接使用GitHub的图床。
注意: 19年10月开始githubusercontent.com
域名被墙,需要科学才能看到图片
安装插件
$ npm install hexo-migrator-github-issue --save
新建issue文章
使用GitHub图床非常简单,把图片文件拖到编辑框就行了~
导入文章
$ hexo migrate github-issue 你的Github用户名/你存放hexo的仓库名
插件扩展
该插件会根据issue的label为文章生成标签,如果要想生成分类和置顶参数,需要做一些修改。
我提了一个PR还没通过,这里直接使用我fork的分支替换。
curl -fsSL https://raw.githubusercontent.com/CloudyCity/hexo-migrator-github-issue/master/index.js > node_modules/hexo-migrator-github-issue/index.js
使用github issue存放评论
安装插件
Next主题自带gitment,主文件是/themes/next/source/js/src/gitment.browser.js
。
如果是其他主题,需要安装gitment
,主文件是/node_modules/gitment/dist/gitment.browser.js
。
$ npm install gitment --save
创建授权应用
点击这里创建,名称随意,两个url都填hexo博客的url,创建完成就得到应用ID和密钥。
创建新仓库
在GitHub中创建一个新仓库,其issue将用于存放评论。
配置
在Next主题的配置文件_config.yaml
中更改以下配置
gitment:
enable: true
githubID: 你的github用户名
repo: 博客所在仓库
ClientID: 应用ID
ClientSecret: 密钥
lazy: false
修改CORS转发代理
_utils.http.post('https://gh-oauth.imsun.net', {
原作者提供的转发服务目前正常,无需更换
插件修改
此插件原本是使用另一个仓库存放评论,现在的需求是用博客所在仓库的对应文章issue中存放评论,需要修改loadMeta()
,让插件使用id
获取issue。
这里使用hexo-migrator-github-issue
作者Yikun
fork的分支,用webpack
打包,覆盖gitment
主文件。
Next
主题可参考本博客的gitment.browser.js
非Next
主题可参考我写的替换脚本gitment.sh
主题修改
这里使用的是Next主题,需要修改三个模板文件,将hexo-migrator-github-issue
插件获取到的issue-number
作为id
,供gitment
插件中的loadMeta()
使用。
\themes\next\layout\_partials\comments\gitment.swig
\themes\next\layout\_script\comments\gitment.swig
\themes\next\layout\_third-party\comments\gitment.swig
{% if theme.gitment.enable %}
{% set id = page.number %}
{% set owner = theme.gitment.githubID %}
可以将主题加入版本控制,更换机器后就不需要再操作一遍,不过也会导致gitment
的密钥泄漏:(
最后
最终效果参照本文章对应issue
如果大家觉得上面两个插件好用的话可以去给插件的项目点个星支持开发者哦~
hexo-migrator-github-issue
gitment
GitLab CI/CD简单实践
cover: https://cdn-images-1.medium.com/max/1920/1*eAujgqR-ts4hJyIdnxpcCg.png
feature: true
针对一个前端项目仓库,使用简单、低成本的方式进行CI/CD。
简介
GitLab CI/CD通过GitLab Runner实现。
流程为:由GitLab触发流水线,通知安装在部署机上的Runner,然后Runner配置的Executor执行gitlab-ci.yml
中的流程。
GitLab推荐用docker作为Executor,但考虑到实际项目情况与需求,我是用shell作为Executor。
所以,在GitLab的管理界面以外,我们需要做以下三件事:
- 在仓库中配置
gitlab-ci.yml
- 在部署机上配置
Runner
- 在部署机上授予
gitlab-runner
用户在部署目录的写权限
流程
CI/CD配置文件
首先推荐一个npm包:install-changed,用于判断依赖是否发生更新,手动部署时既能跳过不必要的npm install
,也能保证不漏掉必要的npm install
。
npm i install-changed
在package.json
添加命令
"scripts": {
"pre-run": "install-changed"
考虑紧急情况可能需要手动部署,先写一个部署脚本。
deploy.sh
# 变量
if [[ -n $1 ]]; then
path=$1
else
echo "缺少输出目录"
exit 1;
fi
# 当 未安装依赖 或 依赖列表更新 时安装依赖
npm run pre-run || npm ci --no-progress --registry=https://registry.npm.taobao.org
# 编译
npm run build
# 平滑覆盖
cp -r dist/* ${path}/ && find ${path} -mmin +1 -type f -delete
自动构建部署时复用部署脚本
gitlab-ci.yml
stages:
- build
build:
stage: build
only:
- master
script:
- sh deploy.sh $RELEASE_PATH
注意这里用到一个环境变量RELEASE_PATH
,需要在GitLab仓库页的 设置 - CI/CD - 加密变量 中配置。
这个目录就是编译后静态文件要部署的目录,需要给gitlab-runner
用户写权限。
Runner
安装
GitLab推荐的是Runner版本与GitLab版本保持一致,有些高版本Runner能与低版本GitLab协作,但不能用新特性。
我的GitLab版本为10.7.3,Runner版本为13.4.1。
进入部署机
yum install gitlab-runner
注册
首先打开GitLab仓库页中的 设置 - CI/CD - Runners设置
:::details Click to see more
:::
回到部署机
sudo gitlab-runner register \
--non-interactive \
--url "填上图中的URL" \
--registration-token "填上图中的注册令牌" \
--executor "shell" \
--description "shell-runner" \
--tag-list "shell" \
--run-untagged="true" \
之后就会像上图一样,【当前项目有效可用的 runner】下方会出现刚刚注册的Runner。
测试
手动触发
在GitLab仓库页面的 CI/CD - 流水线中点击运行流水线即可。
自动触发
首先检查Runner设置的触发条件,如下图配置,每当受保护分支有新的提交推送,便会触发流水线。
其他
如果其他前端项目也是用这个方式部署,可以取消Runner设置中的【锁定到当前项目】,这样就可以复用这个runner,只需要每个仓库配置不同的环境变量RELEASE_PATH即可。
Laravel/Lumen 扩展DB Builder语法
cover: https://www.georgebuckingham.com/content/images/2020/10/laravel-upserts.jpg
截至5.6
版本,Laravel
的DB Builder都没有方法实现忽略插入
与插入或更新
(5.7
版本新增updateOrInsert()
)。除了自己封装多一层之外,还有一个更优雅的解决方案是对Builder语法进行扩展。
扩展支持的语法
这里要扩展的是三个常用的语法:
INSERT IGNORE INTO
REPLACE INTO
INSERT INTO ... ON DUPLICATE KEY UPDATE
扩展Builder类
Laravel
官方已经在Illuminate\Database
包中提供了扩展实例,需要创建自定义的Grammar
、Builder
、Connection
类,然后由服务提供者注册。
自定义Grammar
:::details Click to see more
<?php
namespace App\Library\Database\Query\Grammars;
use Illuminate\Database\Query\Grammars\MySqlGrammar as Grammar;
use App\Library\Database\Query\MySqlBuilder as Builder;
class MySqlGrammar extends Grammar
{
/**
* Compile a replace into statement into SQL.
*
* @link https://dev.mysql.com/doc/refman/5.5/en/replace.html
*
* @param \App\Library\Database\Query\MySqlBuilder $query
* @param array $values
* @return string
*/
public function compileReplace(Builder $query, array $values)
{
// Essentially we will force every insert to be treated as a batch insert which
// simply makes creating the SQL easier for us since we can utilize the same
// basic routine regardless of an amount of records given to us to insert.
$table = $this->wrapTable($query->from);
if (!is_array(reset($values))) {
$values = [$values];
}
$columns = $this->columnize(array_keys(reset($values)));
// We need to build a list of parameter place-holders of values that are bound
// to the query. Each insert should have the exact same amount of parameter
// bindings so we will loop through the record and parameterize them all.
$parameters = [];
foreach ($values as $record) {
$parameters[] = '(' . $this->parameterize($record) . ')';
}
$parameters = implode(', ', $parameters);
return "REPLACE INTO $table ($columns) VALUES $parameters";
}
/**
* Compile an insert ignore statement into SQL.
*
* @link https://dev.mysql.com/doc/refman/5.5/en/insert.html
*
* @param \App\Library\Database\Query\MySqlBuilder $query
* @param array $values
* @return string
*/
public function compileInsertIgnore(Builder $query, array $values)
{
// Essentially we will force every insert to be treated as a batch insert which
// simply makes creating the SQL easier for us since we can utilize the same
// basic routine regardless of an amount of records given to us to insert.
$table = $this->wrapTable($query->from);
if (!is_array(reset($values))) {
$values = [$values];
}
$columns = $this->columnize(array_keys(reset($values)));
// We need to build a list of parameter place-holders of values that are bound
// to the query. Each insert should have the exact same amount of parameter
// bindings so we will loop through the record and parameterize them all.
$parameters = [];
foreach ($values as $record) {
$parameters[] = '(' . $this->parameterize($record) . ')';
}
$parameters = implode(', ', $parameters);
return "INSERT IGNORE INTO $table ($columns) VALUES $parameters";
}
/**
* Compile an insert update statement into SQL.
*
* @link https://dev.mysql.com/doc/refman/8.0/en/insert-on-duplicate.html
* @link https://gist.github.com/RuGa/5354e44883c7651fd15c
*
* @param \App\Library\Database\Query\MySqlBuilder $query
* @param array $values
* @return string
*/
public function compileInsertUpdate(Builder $query, array $values)
{
// Essentially we will force every insert to be treated as a batch insert which
// simply makes creating the SQL easier for us since we can utilize the same
// basic routine regardless of an amount of records given to us to insert.
$table = $this->wrapTable($query->from);
$columnNames = array_keys(reset($values));
$columns = $this->columnize($columnNames);
$parameters = implode(',', array_map(function ($row) {
return '(' . $this->parameterize($row) . ')';
}, $values));
$updates = implode(',', array_map(function ($columnName) {
return $this->wrap($columnName) . ' = VALUES(' . $this->wrap($columnName) . ')';
}, $columnNames));
return "INSERT INTO $table ($columns) VALUES $parameters ON DUPLICATE KEY UPDATE $updates";
}
}
:::
自定义Builder
:::details Click to see more
<?php
namespace App\Library\Database\Query;
use Illuminate\Database\Query\Builder as QueryBuilder;
class MySqlBuilder extends QueryBuilder
{
/**
* The database query grammar instance.
*
* @var Grammars\MySqlGrammar
*/
public $grammar;
/**
* Insert a new record into the database, replace on primary key conflict.
*
* @param array $values
* @return bool
*/
public function replace(array $values)
{
if (empty($values)) {
return true;
}
// Since every insert gets treated like a batch insert, we will make sure the
// bindings are structured in a way that is convenient for building these
// inserts statements by verifying the elements are actually an array.
if (!is_array(reset($values))) {
$values = [$values];
}
// Since every insert gets treated like a batch insert, we will make sure the
// bindings are structured in a way that is convenient for building these
// inserts statements by verifying the elements are actually an array.
else {
foreach ($values as $key => $value) {
ksort($value);
$values[$key] = $value;
}
}
// We'll treat every insert like a batch insert so we can easily insert each
// of the records into the database consistently. This will make it much
// easier on the grammars to just handle one type of record insertion.
$bindings = [];
foreach ($values as $record) {
foreach ($record as $value) {
$bindings[] = $value;
}
}
$sql = $this->grammar->compileReplace($this, $values);
// Once we have compiled the insert statement's SQL we can execute it on the
// connection and return a result as a boolean success indicator as that
// is the same type of result returned by the raw connection instance.
$bindings = $this->cleanBindings($bindings);
return $this->connection->insert($sql, $bindings);
}
/**
* Insert a new record into the database, update on primary key conflict.
*
* @param array $values
* @return bool
*/
public function insertUpdate(array $values)
{
if (empty($values)) {
return true;
}
// Since every insert gets treated like a batch insert, we will make sure the
// bindings are structured in a way that is convenient for building these
// inserts statements by verifying the elements are actually an array.
if (!is_array(reset($values))) {
$values = [$values];
} // Sort the keys in each row alphabetically for consistency
else {
foreach ($values as $key => $value) {
ksort($value);
$values[$key] = $value;
}
}
// We'll treat every insert like a batch insert so we can easily insert each
// of the records into the database consistently. This will make it much
// easier on the grammars to just handle one type of record insertion.
$bindings = [];
foreach ($values as $record) {
foreach ($record as $value) {
$bindings[] = $value;
}
}
$sql = $this->grammar->compileInsertUpdate($this, $values);
// Once we have compiled the insert statement's SQL we can execute it on the
// connection and return a result as a boolean success indicator as that
// is the same type of result returned by the raw connection instance.
$bindings = $this->cleanBindings($bindings);
return $this->connection->insert($sql, $bindings);
}
/**
* Insert a new record into the database, discard on primary key conflict.
*
* @param array $values
* @return bool
*/
public function insertIgnore(array $values)
{
if (empty($values)) {
return true;
}
// Since every insert gets treated like a batch insert, we will make sure the
// bindings are structured in a way that is convenient for building these
// inserts statements by verifying the elements are actually an array.
if (!is_array(reset($values))) {
$values = [$values];
}
// Since every insert gets treated like a batch insert, we will make sure the
// bindings are structured in a way that is convenient for building these
// inserts statements by verifying the elements are actually an array.
else {
foreach ($values as $key => $value) {
ksort($value);
$values[$key] = $value;
}
}
// We'll treat every insert like a batch insert so we can easily insert each
// of the records into the database consistently. This will make it much
// easier on the grammars to just handle one type of record insertion.
$bindings = [];
foreach ($values as $record) {
foreach ($record as $value) {
$bindings[] = $value;
}
}
$sql = $this->grammar->compileInsertIgnore($this, $values);
// Once we have compiled the insert statement's SQL we can execute it on the
// connection and return a result as a boolean success indicator as that
// is the same type of result returned by the raw connection instance.
$bindings = $this->cleanBindings($bindings);
return $this->connection->insert($sql, $bindings);
}
}
:::
自定义Connection
<?php
namespace App\Library\Database;
use App\Library\Database\Query\MySqlBuilder as Builder;
use App\Library\Database\Query\Grammars\MySqlGrammar as QueryGrammar;
use Illuminate\Database\MySqlConnection as Connection;
class MySqlConnection extends Connection
{
/**
* Get the default query grammar instance.
*
* @return \Illuminate\Database\Grammar|\Illuminate\Database\Query\Grammars\MySqlGrammar
*/
protected function getDefaultQueryGrammar()
{
return $this->withTablePrefix(new QueryGrammar);
}
/**
* Get a new query builder instance.
*
* @return \App\Library\Database\Query\MySqlBuilder
*/
public function query()
{
return new Builder(
$this, $this->getQueryGrammar(), $this->getPostProcessor()
);
}
}
自定义Provider
<?php
namespace App\Providers;
use App\Library\Database\MySqlConnection;
use Illuminate\Support\ServiceProvider;
class DatabaseServiceProvider extends ServiceProvider
{
/**
* Override the default connection for MySQL. This allows us to use `replace` etc.
*
* @link https://stidges.com/extending-the-connection-class-in-laravel
* @link https://gist.github.com/VinceG/0fb570925748ab35bc53f2a798cb517c
*
* @return void
*/
public function boot()
{
Connection::resolverFor('mysql', function ($connection, $database, $prefix, $config) {
return new MySqlConnection($connection, $database, $prefix, $config);
}); // 5.4及以上版本使用此方式绑定
}
/**
* Register the service provider.
*
* @return void
*/
public function register()
{
$this->app->bind('db.connection.mysql', MySqlConnection::class); // 5.4版本以下使用此方式绑定
}
}
注册Provider
Laravel
在config/app.php
里的providers
中添加新的Provider类:
'providers' => [
// 其他的服务提供者
App\Providers\AppServiceProvider::class,
],
Lumen
在bootstrap/app.php
中手动绑定:
// 扩展的查询语法:Replace、InsertIgnore、InsertUpdate
$app->register(App\Providers\DatabaseServiceProvider::class);
return $app;
使用Demo
$data = [
'name' => 'soap',
'status' => 'kia'
];
\DB::connection('cod8')->table('141_members')->insertUpdate($data);
注意这里输入\DB::connection()->table()->
之后IDE不会提示扩展的方法,只能在封装类中声明是自定义的Builder以获得提示。
封装成组件
Refrences
https://stidges.com/extending-the-connection-class-in-laravel
https://gist.github.com/VinceG/0fb570925748ab35bc53f2a798cb517c
https://github.com/art-institute-of-chicago/data-service-images
Laravel优雅地支持多SMS
cover: https://miro.medium.com/max/1200/1*TqJCpGhLCZixYW6Y8xMVbg.jpeg
小公司可能为了节省短信成本,会选择多家短信服务商,但短信本身的模板不会太多。本文介绍下Laravel
下如何优雅地实现这种场景。
依赖
Laravel社区一个很赞的枚举组件
composer require bensampo/laravel-enum
这里以腾讯短信为例
composer require tencentcloud/tencentcloud-sdk-php
接口
现在多数短信服务端都是采用模板,发送更快,所以这里接口直接按这种模式设计。
如果需要接入未使用模版的服务商,可以自行实现模板(用sprintf()
替换字符串),相当于做了使用模板的服务商所做的工作。
<?php
namespace App\Contracts;
interface Sms
{
/**
* 发送短信
*
* @param $phone
* @param $templateId
* @param $templateParams
* @return bool
*/
public function send($phone, $templateId, $templateParams);
/**
* 批量发送短信
*
* @param array $phones
* @param $templateId
* @param $templateParams
* @return bool
*/
public function batchSend($phones, $templateId, $templateParams);
/**
* 返回余额
*
* @return float
*/
public function getBalance();
/**
* 获取模版
*
* @param string $for 模板用途
* @return int
*/
public function getTemplateId($for);
}
枚举
每当要接入一个服务商,首先增加一个枚举,后面在服务,模型,界面选项都使用该枚举。
<?php
namespace App\Enums;
use BenSampo\Enum\Enum;
/**
* 短信服务商
*
* @method static static TENCENT()
*/
final class SmsProvider extends Enum
{
public const TENCENT = 'tencent';
public static function getDescription($value): string
{
if ($value === self::TENCENT) {
return '腾讯';
}
return parent::getDescription($value);
}
}
配置文件
config/sms.php
<?php
return [
// 默认服务商
'default_provider' => env('SMS_DEFAULT_PROVIDER'),
// 各服务商配置
'tencent' => [
'app_id' => env('SMS_TENCENT_APP_ID'),
'secret_id' => env('SMS_TENCENT_SECRET_ID'),
'secret_key' => env('SMS_TENCENT_SECRET_KEY'),
'template_ids' => [
'captcha' => env('SMS_TENCENT_TEMPLATE_IDS_CAPTCHA'),
],
]
];
服务实现
创建基类,构造时从配置中初始化模板参数。
:::details Click to see more
<?php
namespace App\Services\Sms;
use App\Contracts\Sms;
abstract class BaseSms implements Sms
{
/**
* 服务商名称
*
* @var string
*/
protected $name;
/**
* 短信模板ID映射
*
* @var array
*/
protected $templateIds;
/**
* 短信模板用途集合
*
* @var array
*/
protected $templateUses = ['captcha'];
/**
* BaseSms constructor.
*/
public function __construct()
{
foreach ($this->templateUses as $templateUse) {
$this->templateIds[$templateUse] = config("system.sms.{$this->name}.template_ids.{$templateUse}}");
}
}
/**
* 获取模版
*
* @param $for
* @return int|mixed|null
* @throws \Exception
*/
public function getTemplateId($for)
{
if (empty($this->templateIds[$for])) {
throw new \Exception("{$for}短信模板不存在");
}
return $this->templateIds[$for];
}
}
:::
每个服务类只需要在构造函数中做好参数初始化,并实现三个方法即可。
:::details Click to see more
<?php
namespace App\Services\Sms;
use App\Enums\SmsProvider;
use TencentCloud\Common\Credential;
use TencentCloud\Common\Profile\ClientProfile;
use TencentCloud\Common\Profile\HttpProfile;
use TencentCloud\Sms\V20190711\Models\SendSmsRequest;
use TencentCloud\Sms\V20190711\Models\SmsPackagesStatisticsRequest;
use TencentCloud\Sms\V20190711\SmsClient;
class TencentSms extends BaseSms
{
protected $appId;
protected $secretId;
protected $secretKey;
/**
* TencentSMS constructor.
*/
public function __construct()
{
$this->name = SmsProvider::TENCENT;
$this->appId = config('system.sms.tencent.app_id');
$this->secretId = config('system.sms.tencent.secret_id');
$this->secretKey = config('system.sms.tencent.secret_key');
parent::__construct();
}
/**
* 发送短信
*
* @param $phone
* @param $templateId
* @param $templateParams
* @return bool
*/
public function send($phone, $templateId, $templateParams)
{
return $this->batchSend([$phone], $templateId, $templateParams);
}
/**
* 批量发送短信
*
* @param array $phones
* @param $templateId
* @param $templateParams
* @return bool
*/
public function batchSend($phones, $templateId, $templateParams)
{
// 处理参数
$phones = is_array($phones) ? $phones : [$phones];
$phones = array_map('addPhoneAreaCode', $phones);
$templateParams = array_map('strval', $templateParams);
// 实例化一个证书对象,入参需要传入腾讯云账户secretId,secretKey
$cred = new Credential($this->secretId, $this->secretKey);
$httpProfile = new HttpProfile();
$httpProfile->setEndpoint("sms.tencentcloudapi.com");
$clientProfile = new ClientProfile();
$clientProfile->setHttpProfile($httpProfile);
// 实例化要请求产品(以cvm为例)的client对象
$client = new SmsClient($cred, "ap-guangzhou");
// 实例化一个请求对象
$req = new SendSmsRequest();
$req->fromJsonString(json_encode([
'PhoneNumberSet' => $phones,
'TemplateID' => $templateId,
'Sign' => config('system.sms.tencent.sign'),
'TemplateParamSet' => $templateParams,
'SmsSdkAppid' => $this->appId,
]));
// 通过client对象调用想要访问的接口,需要传入请求对象
$res = $client->SendSms($req);
foreach ($res->SendStatusSet as $sendStatus) {
/** @var \TencentCloud\Sms\V20190711\Models\SendStatus $sendStatus */
if ($sendStatus->Code != 'Ok') {
return false;
}
}
return true;
}
/**
* 返回余额
*
* @return float
*/
public function getBalance()
{
$cred = new Credential($this->secretId, $this->secretKey);
$httpProfile = new HttpProfile();
$httpProfile->setEndpoint("sms.tencentcloudapi.com");
$clientProfile = new ClientProfile();
$clientProfile->setHttpProfile($httpProfile);
$client = new SmsClient($cred, "", $clientProfile);
$req = new SmsPackagesStatisticsRequest();
$params = ['Limit' => 100, 'Offset' => 0, 'SmsSdkAppid' => $this->appId];
$params = json_encode($params);
$req->fromJsonString($params);
$resp = $client->SmsPackagesStatistics($req);
$smsPackagesStatisticsSet = $resp->SmsPackagesStatisticsSet;
$maxSmsPackagesStatistics = $this->getMaxSmsPackagesStatistics($smsPackagesStatisticsSet);
// 当前套餐包剩余使用量
$remainingNumber = $maxSmsPackagesStatistics->AmountOfPackage - $maxSmsPackagesStatistics->CurrentUsage;
$balance = $remainingNumber * 0.05;
return $balance;
}
/**
* 从集合中查找出剩余短信条数最大的套餐包
*
* @param $smsPackagesStatisticsSet
* @return mixed | object
*/
protected function getMaxSmsPackagesStatistics($smsPackagesStatisticsSet)
{
if (empty($smsPackagesStatisticsSet) || !is_array($smsPackagesStatisticsSet)) {
return $smsPackagesStatisticsSet;
}
$maxRemainingNumber = 0; // 最大套餐包剩余使用量
$maxSmsPackagesStatistics = null; // 剩余短信条数最大的套餐包
foreach ($smsPackagesStatisticsSet as $smsPackagesStatistics) {
// 当前套餐包剩余使用量
$remainingNumber = $smsPackagesStatistics->AmountOfPackage - $smsPackagesStatistics->CurrentUsage;
if ($remainingNumber >= $maxRemainingNumber) {
$maxRemainingNumber = $remainingNumber;
$maxSmsPackagesStatistics = $smsPackagesStatistics;
}
}
return $maxSmsPackagesStatistics;
}
}
:::
服务提供者与发送类
分为两类场景:调用方始终使用同一个服务商、调用方可能使用不同服务商。
调用方始终使用同一个服务商
服务提供者
扩展绑定Sms
接口到实现上,并绑定Sender
的单例。
<?php
namespace App\Providers;
use App\Contracts\Sms;
use App\Services\Sms\TencentSms
use Illuminate\Support\ServiceProvider;
class SmsServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*
* @return void
*/
public function register()
{
//
}
/**
* Bootstrap any application services.
*
* @return void
*/
public function boot()
{
$this->app->extend(Sms::class, function ($service, $app) {
$smsProvider = config('system.sms.provider');
if ($smsProvider === SmsProvider::TENCENT) {
return new TencentSMS();
}
return new TencentSms();
});
$this->app->singleton(Sender::class, function ($app) {
return new Sender($app->make(Sms::class));
});
}
}
发送类
<?php
namespace App\Services\Sms;
use App\Enums\SmsProvider;
use Illuminate\Support\Facades\Redis;
class Sender
{
/**
* @var TencentSms
*/
protected $sms;
public function __construct(Sms $smsProvider)
{
$this->smsProvider = $smsProvider;
}
/**
* 发送验证码
*
* @template 验证码 {1},15分钟内有效,请勿向任何人泄露。
* @param int|string $phone
* @param int $length 验证码长度, 范围4-6
* @param int $ttl 有效时间(分钟)
* @return bool
* @throws \Exception
*/
public function sendCaptcha($phone, $length = 4, $ttl = 15)
{
$captcha = substr(mt_rand(100000, 999999), 0, $length);
$templateId = $this->sms->getTemplateId('captcha');
Redis::setex("captcha:{$phone}", $ttl * 60, $captcha);
return $this->sms->send($phone, $templateId, [$captcha]);
}
}
调用示例
自动注入获取单例
use App\Services\Sms\Sender;
public function index(Request $request, Sender $sender)
{
// do some work and send a message
$sender->sendCaptcha();
}
直接解析获取单例
use App\Services\Sms\Sender;
$sender = resolve(Sender::class);
$sender->sendCaptcha();
调用方可能使用不同服务商
服务提供者
每个服务使用单例,采用延迟加载。
<?php
namespace App\Providers;
use App\Services\Sms\TencentSms;
use Illuminate\Contracts\Support\DeferrableProvider;
use Illuminate\Support\ServiceProvider;
class SmsServiceProvider extends ServiceProvider implements DeferrableProvider
{
/**
* Register any application services.
*
* @return void
*/
public function register()
{
$this->app->singleton(TencentSms::class, function () {
return new TencentSms();
});
}
/**
* Bootstrap any application services.
*
* @return void
*/
public function boot()
{
//
}
/**
* Get the services provided by the provider.
*
* @return array
*/
public function provides()
{
return [TencentSms::class];
}
}
发送类
调用方传入服务商的枚举值。
<?php
namespace App\Services\Sms;
use App\Enums\SmsProvider;
use Illuminate\Support\Facades\Redis;
class Sender
{
/**
* @var TencentSms
*/
protected $sms;
public function __construct($smsProvider = null)
{
$smsProvider = $smsProvider ?: config('system.sms.default_provider');
if ($smsProvider === SmsProvider::TENCENT) {
$this->sms = resolve(TencentSms::class); // 获取单例
}
}
/**
* 发送验证码
*
* @template 验证码 {1},15分钟内有效,请勿向任何人泄露。
* @param int|string $phone
* @param int $length 验证码长度, 范围4-6
* @param int $ttl 有效时间(分钟)
* @return bool
* @throws \Exception
*/
public function sendCaptcha($phone, $length = 4, $ttl = 15)
{
$captcha = substr(mt_rand(100000, 999999), 0, $length);
$templateId = $this->sms->getTemplateId('captcha');
Redis::setex("captcha:{$phone}", $ttl * 60, $captcha);
return $this->sms->send($phone, $templateId, [$captcha]);
}
}
调用示例
use App\Enums\SmsProvider;
use App\Services\Sms\Sender;
$sender = new Sender(SmsProvider::TENCENT);
$sender->sendCaptcha();
Hexo提高加载速度
cover: https://guanqr.com/images/speed-up-hexo.png
Hexo作为纯静态博客最大的优点就是快,但要真正的快起来你可能需要做这些事情。
托管平台(可选)
直接在Coding等国内平台托管Page
项目,修改国内线路解析。(可参照Github Hexo的百度收录问题)
Coding绑定了域名需定期申请SSL证书,所以本博放弃此项。
CDN加速
这里只是针对第三方静态文件的加速,修改Next
主题配置文件_config.yml
如下:
:::details Click to see more
vendors:
# Internal path prefix. Please do not edit it.
_internal: lib
# Internal version: 2.1.3
jquery: https://cdn.bootcss.com/jquery/2.1.3/jquery.min.js
# Internal version: 2.1.5
# See: http://fancyapps.com/fancybox/
fancybox: https://cdn.bootcss.com/fancybox/2.1.5/jquery.fancybox.min.js # https://cdn.bootcss.com/fancybox/2.1.5/jquery.fancybox.pack.js
fancybox_css: https://cdn.bootcss.com/fancybox/2.1.5/jquery.fancybox.min.css
# Internal version: 1.0.6
# See: https://github.com/ftlabs/fastclick
fastclick: https://cdn.bootcss.com/fastclick/1.0.6/fastclick.min.js
# Internal version: 1.9.7
# See: https://github.com/tuupola/jquery_lazyload
lazyload: https://cdn.bootcss.com/jquery_lazyload/1.9.7/jquery.lazyload.min.js
# Internal version: 1.2.1
# See: http://VelocityJS.org
velocity: https://cdn.bootcss.com/velocity/1.2.1/velocity.min.js
# Internal version: 1.2.1
# See: http://VelocityJS.org
velocity_ui: https://cdn.bootcss.com/velocity/1.2.1/velocity.ui.min.js
# Internal version: 0.7.9
# See: https://faisalman.github.io/ua-parser-js/
ua_parser:
# Internal version: 4.6.2
# See: http://fontawesome.io/
fontawesome: https://cdn.bootcss.com/font-awesome/4.6.2/css/font-awesome.min.css
# Internal version: 1
# https://www.algolia.com
algolia_instant_js:
algolia_instant_css:
# Internal version: 1.0.2
# See: https://github.com/HubSpot/pace
# Or use direct links below:
# pace: //cdn.bootcss.com/pace/1.0.2/pace.min.js
# pace_css: //cdn.bootcss.com/pace/1.0.2/themes/blue/pace-theme-flash.min.css
pace: https://cdn.bootcss.com/pace/1.0.2/pace.min.js
pace_css: https://cdn.bootcss.com/pace/1.0.2/themes/blue/pace-theme-flash.min.css
:::
:::warning
针对自定义的静态文件(main.min.js和main.min.css)需要自行使用融合CDN,国内都需要备案域名,本博的.me
域名没有备案资质,但我不太想将静态文件放到其他域名,所以放弃加速这类文件。
:::
压缩文件
hexo-neat
安装
npm install hexo-neat --save
配置
修改项目_config.yml
。
neat_enable: true
neat_html:
enable: true
exclude:
neat_css:
enable: false
exclude:
- '**/*.min.css'
neat_js:
enable: false
mangle: true
output:
compress:
exclude:
- '**/*.min.js'
:::tip后面会使用gulp
合并与压缩js与css,所以此处只用neat
压缩了html。如果你不想使用合并js与css,此处可以打开neat_js
、neat_css
。
:::
gulp
gulp
是自动化构建工具,这里主要是用来合并、压缩js与css。
构建配置
Next
主题目录自带了gulpfile.coffee
,这里直接修改:
:::details Click to see more
fs = require('fs')
path = require('path')
gulp = require('gulp')
jshint = require('gulp-jshint') # js语法检查
jslish = require('jshint-stylish') # js语法检查输出美化
stylint = require('gulp-stylint') # stylus语法检查
stylish = require('stylint-stylish') # stylus语法检查输出美化
yaml = require('js-yaml')
concat = require('gulp-concat') # js合并
uglify = require("gulp-uglify") # js压缩
concatCss = require('gulp-concat-css') # css合并
cleanCss = require('gulp-clean-css') # css压缩
# js语法检查 https://jshint.com/docs/options/
gulp.task 'lint:js', ->
return gulp.src path.join(__dirname, './source/js/**/*.js')
.pipe jshint()
.pipe jshint.reporter()
# stylus语法检查 https://github.com/SimenB/stylint
gulp.task 'lint:stylus', ->
return gulp.src path.join(__dirname, '/source/css/**/*.styl')
.pipe stylint({config: path.join(__dirname, '.stylintrc')})
.pipe stylint.reporter(stylish)
# 配置文件检验
gulp.task 'validate:config', (cb) ->
themeConfig = fs.readFileSync path.join(__dirname, '_config.yml')
try
yaml.safeLoad(themeConfig)
cb()
catch error
cb new Error(error)
# 语言文件检验
gulp.task 'validate:languages', (cb) ->
languagesPath = path.join __dirname, 'languages'
languages = fs.readdirSync languagesPath
errors = []
for lang in languages
languagePath = path.join languagesPath, lang
try
yaml.safeLoad fs.readFileSync(languagePath), {
filename: path.relative(__dirname, languagePath)
}
catch error
errors.push error
if errors.length == 0
cb()
else
cb(errors)
# 合并压缩js
# gulp.task 'minify:js', ['lint:js'], (cb) ->
gulp.task 'minify:js', (cb) ->
return gulp.src([
path.join(__dirname, 'source/js/src/utils.js'),
path.join(__dirname, 'source/js/src/motion.js'),
path.join(__dirname, 'source/js/src/gitment.browser.js'),
path.join(__dirname, 'source/js/src/bootstrap.js'),
path.join(__dirname, 'source/js/src/scrollspy.js'),
path.join(__dirname, 'source/js/src/post-details.js'),
]).pipe concat('main.min.js')
.pipe uglify()
.pipe gulp.dest path.join __dirname, '../../public/js'
# 合并压缩js
# gulp.task 'minify:css', ['lint:stylus'], (cb) ->
gulp.task 'minify:css', (cb) ->
return gulp.src([
path.join(__dirname, '../../public/css/main.css'),
path.join(__dirname, 'source/css/src/gitment.css'),
]).pipe concat('main.min.css')
.pipe cleanCss()
.pipe gulp.dest path.join __dirname, '../../public/css'
# 执行
gulp.task 'default', [
'validate:config',
'validate:languages'
'minify:js',
'minify:css',
]
:::
:::tip
Next
主题默认的gulpfile.coffee
只是进行规范检查,不过此处修改后默认关闭,按需开启。
:::
:::warning
合并js时尽量保持模板中引入的顺序,避免不必要错误。
:::
在项目根目录下新建gulpfile.js
:
require('coffeescript/register')
require('./themes/next/gulpfile.coffee')
安装相关依赖
安装构建任务用到的依赖。
npm install gulp -g
npm install coffeescript --save-dev # 用于在gulpfile.js中引入gulpfile.coffee
npm install gulp-clean-css --save-dev # css压缩
npm install gulp-concat --save-dev # js合并
npm install gulp-concat-css --save-dev # css合并
npm install gulp-jshint --save-dev # js语法检查
npm install gulp-stylint --save-dev # css语法检查
npm install gulp-uglify --save-dev # js压缩
npm install jshint --save-dev # js压缩核心
npm install jshint-stylish --save-dev # js语法检查输出美化
npm install stylint-stylish --save-dev # css语法检查输出美化
执行构建
项目根目录执行gulp
:
构建输出合并与压缩后的文件:
更新模板
修改themes/next/layout/_partials/head.swig
:
<link href="{{ font_awesome_uri }}" rel="stylesheet" type="text/css" />
<link href="{{ url_for(theme.css) }}/main.css?v={{ theme.version }}" rel="stylesheet" type="text/css" /> <link href="{{ url_for(theme.css) }}/main.min.css?v={{ theme.version }}" rel="stylesheet" type="text/css" />
{% if theme.favicon.apple_touch_icon %} {% if theme.favicon.apple_touch_icon %}
修改themes/next/layout/_layout.swig
:
{% include '_scripts/vendors.swig' %}
<script type="text/javascript" src="{{ url_for(theme.js) }}/main.min.js?v={{ theme.version }}"></script>
{% include '_scripts/commons.swig' %} {% include '_scripts/commons.swig' %}
然后在各模板中将引入合并前静态文件的部分注释掉,这里不详细列出,只放出一个例子:
完成后重新部署:
hexo g && gulp && hexo d
引用效果:
图片懒加载
可以显著加速文章页。
安装插件:
npm install hexo-lazyload-image --save
修改项目配置文件_config.yml
:
# 图片懒加载
lazyload:
enable: true
onlypost: false
loadingImg: /images/loading/loading.gif #如果不填写图片则使用默认的图片
精简功能
功能涉及多个请求时会比较明显得拖慢加载速度,例如不蒜子
、DaoVice
,要考量这些功能是否真(hua)的(er)需(bu)要(shi)。(像我已经关掉了DaoVice
,只留着不蒜子
计算站点UV)
最终效果
首页
文章页
剩下的瓶颈主要是Gitment
、不蒜子
。
:::tip
不同网络环境下的加速效果也会不同,以上效果是我在家中的加速效果。在公司的加速效果就差了很多。
:::
GitHub Hexo绑定域名
cover: https://cdn.searchenginejournal.com/wp-content/uploads/2018/10/shutterstock_781493554.png
火星救援,刻不容缓!
域名
比较流行的是.me
顶级域名,简洁直观,不过没有备案资质。
这里我用的就是cloudycity.me
。
购买
关于购买,这里有个非常赞的域名注册商比价网站哪煮迷。我是在西部数码注册的,首年20续费98。
解析
添加两条CNAME规则将@
与www
都指向Github page
地址:
Github
添加CNAME文件
在source
分支中的source
目录下新建一个CNAME
文件,内容为绑定的域名:
然后重新部署博客。
申请HTTPS
到仓库设置中的Github Page
子项中勾选Enforce HTTPS
:::warning
添加CNAME
文件之后,项目设置中会自动识别域名。刚识别完域名的1分钟内还无法勾选Enforce HTTPS
,勾选之后要等几个小时才能生效。
:::
后续
更新相关配置
如果你的博客有插件涉及域名要记得修改,例如:
-
gitment
插件用到的授权应用需要重新创建,详见hexo使用github issue存放文章与评论 -
Next
主题的文章访问计数需要去leancloud的项目添加安全域名
Github Hexo的百度收录问题
cover: https://www.sekkeistudio.com/blog/wp-content/uploads/2019/10/SEO-banner.png
Github
屏蔽了百度的爬虫,所以部署在Github Page
的Hexo
无法被百度收录,如果你绑定了自定义域名的话,这里有几个曲线救国方案。
私有主机
将博客部署到自己的机器上。
对象存储
将博客上传到对象存储。
国内托管
在国内的代码托管平台部署Hexo
,这里推荐Coding,流程与部署到Github
基本一致。
分线路解析
以Github Page
+ Coding Page
为例。
部署多项目
Coding
的项目只需要master
分支,Hexo
的_config.yml
增加仓库地址,然后执行hexo d
便可以同时部署到两个项目。
DNS解析
注意:如果要开启HTTPS,解析之前要先确保Coding Page
的SSL证书已经申请通过,后面会说明原因。
默认解析到coding.me
,境外解析到github.io
。
然后百度的爬虫在大陆就会访问到Coding Page
,就能正常收录了。
:::warning
Coding Page
的一大缺点是SSL证书每三个月需要手动申请一次,并且每次申请时需要先将境外指向Github Page
的规则先暂停(因为Coding
的认证服务器在境外)。
:::
总结
多线路解析看上去是比较优雅的解决方案。但是如果要开启HTTPS的话,相对于GitHub Page
的自动延期SSL证书,Coding Page
需要定期手动申请确实比较麻烦。至于其他国内的托管平台,Gitcafe
目前无法访问,Gitee Page
没有开启HTTPS的选项。
使用satis搭建私有Composer库
cover: https://magently.com/app/uploads/2019/08/satis-1.jpg
人类的本质是复读机。
最近整理项目,将一些复用的轮子封装成Composer组件,但不便于放在packgist.org,所以需要搭建私有的Composer库。
开发composer组件
以之前的一遍文章 Laravel/Lumen 扩展DB Builder语法 的功能为例。
在组件项目(这里称为A项目)根目录创建composer.json
。
{
"name": "xuyang/laravel-builder-ext",
"type": "library",
"description": "扩展Laravel DB Builder的查询语法",
"keywords": ["laravel", "db", "builder", "extend"],
"license": "MIT",
"authors": [
{
"name": "cloudycity",
"email": "[email protected]"
}
],
"minimum-stability": "dev",
"require": {
"php": ">=5.4.0",
"laravel/framework" : "^5.2"
},
"require-dev": {
"phpunit/phpunit": "4.*"
},
"autoload": {
"psr-4": { "Xuyang\\LaravelBuilderExt\\": "src/" }
},
"extra": {
"branch-alias": {
"dev-master": "1.0.0-dev"
}
}
}
最后传上gitlab
。
搭建satis并索引组件
安装satis
使用composer (方法一)
$ cd /data/www/
$ composer create-project composer/satis --keep-vcs
使用docker (方法二)
$ docker pull composer/satis
配置satis
进入到satis项目根目录下创建satis.json
。
{
"name": "私有Composer库",
"homepage": "http://satis.example.com",
"repositories": [
{ "type": "git", "url": "ssh://[email protected]/sub/laravel-builder-ext.git" }
],
"require": {
"company/package": "dev-master"
}
}
创建索引
使用composer (方法一)
$ php bin/satis build satis.json ./web-v
这里的web
就是索引输出目录。
使用docker (方法二)
$ docker run --rm -it -v /build:/build composer/satis
自动索引
最简单粗暴的方法是用crontab
,但这里推荐使用GitLab Web Hook
:
- 在satis项目创建一个接口执行创建索引的操作。
<?php
$valid_token = '3.1415926535857'; // GitLab web hook的token
$valid_ip = ['127.0.0.1']; // GitLab服务器IP
$client_token = $_SERVER['HTTP_X_GITLAB_TOKEN'];
$client_ip = $_SERVER['REMOTE_ADDR'];
if ($client_token !== $valid_token) die('Token mismatch!');
if (!in_array($client_ip, $valid_ip)) die('Ip mismatch!');
$ouptut = [];
exec("cd /data/www/satis/; php bin/satis build satis.json ./web -v 2>&1", $output); // 创建索引的命令
var_dump($output);
- 在组件的
GitLab
仓库中增加Web Hook
每次组件推送新提交之后都会触发钩子请求satis创建索引。
配置Nginx (可选)
将satis.json
中的homepage
指向索引输出目录,然后就可以访问satis的界面。
添加部署密钥 (可选)
如果satis
所在机器没有权限拉取gitlab
仓库,需要生成key并加入到gitlab
的Deploy Keys
(只读)中。
从satis引入composer组件
修改使用A项目组件的项目(这里称B项目)的composer.json
,加上私有库源:
...
"config": {
"preferred-install": "dist",
"secure-http": false
},
"repositories": [{
"type": "composer",
"url": "http://satis.example.com"
}]
}
:::tip
"secure-http": false
是可选项,当你的私有库地址不支持https时才需要加上。
:::
然后正常引入即可:
References
Laravel5.8+Dingo+JWT+Swagger 开发API
cover: https://i.pinimg.com/originals/bd/c4/7c/bdc47c05192f45e6fe2704b3c52995e7.jpg
在Laravel5.8中使用“Laravel必知必会”的两个轮子dingo/api
和tymon/jwt-auth
以及文档系统swagger-api/swagger-ui
开发一个规范优雅的API。
Dingo
安装
composer require dingo/api
配置
php artisan vendor:publish --provider="Dingo\Api\Provider\LaravelServiceProvider"
无特殊需求直接通过.env
文件配置
# Dingo Api
API_STANDARDS_TREE=x
API_SUBTYPE=mp_admin
API_PREFIX=api # 前缀
API_VERSION=v1 # 默认版本
API_NAME="Mp Admin API" # 名称
API_CONDITIONAL_REQUEST=false # 条件请求
API_STRICT=false # 严格模式,开启时请求头必要带标准的Accept信息
API_DEFAULT_FORMAT=json
API_DEBUG=true # 调试
由于子域已被占用,这里采用前缀的格式。接口地址格式为:https://admin.mp.example.com/api
路由
/* @var \Dingo\Api\Routing\Router $api */
$api = app('Dingo\Api\Routing\Router');
$api->version('v1', function () use ($api) {
$api->group(['middleware' => ['api', 'bindings']], function () use ($api) {
// 系统调试日志
$api->get('dev-logs', ['as' => 'dev-logs', 'uses' => '\Rap2hpoutre\LaravelLogViewer\LogViewerController@index', 'middleware' => ['auth']]);
// 验证
$api->group(['prefix' => 'auth'], function () use ($api) {
$api->post('login', ['as' => 'auth.login', 'uses' => 'App\Http\Controllers\AuthController@login']);
$api->get('me', ['as' => 'auth.me', 'uses' => 'App\Http\Controllers\AuthController@me']);
$api->post('refresh', ['as' => 'auth.refresh', 'uses' => 'App\Http\Controllers\AuthController@refresh']);
$api->post('logout', ['as' => 'auth.logout', 'uses' => 'App\Http\Controllers\AuthController@logout']);
$api->put('reset-pwd', ['as' => 'auth.reset-pwd', 'uses' => 'App\Http\Controllers\AuthController@resetPwd']);
});
// 系统
$api->group(['prefix' => 'sys'], function () use ($api) {
// 当前用户能看到的菜单与拥有的权限(别名)
$api->get('menu', ['as' => 'sys.menu', 'uses' => 'App\Http\Controllers\System\AdminController@menu']);
// 系统资源
$api->resource('permissions', 'App\Http\Controllers\System\PermissionController', ['names' => 'sys.permissions']);
$api->resource('roles', 'App\Http\Controllers\System\RoleController', ['names' => 'sys.roles']);
$api->resource('admins', 'App\Http\Controllers\System\AdminController', ['names' => 'sys.admins']);
});
...
});
});
:::warning
所有控制器都需要完整的命名空间,不支持为群组配置命名空间
:::
异常
定义异常
Dingo已经定义了接口场景下常用的异常,所以相关异常可以继承\Dingo\Api\Exception\
下的异常。例如:
class UpdateResourceFailedException extends \Dingo\Api\Exception\UpdateResourceFailedException
{
protected $message = '更新失败';
public function __construct($message = null, $errors = null, \Exception $previous = null, $headers = [], $code = 0) {
$message = $message ?? $this->getMessage();
parent::__construct($message, $errors, $previous, $headers, $code);
}
}
自定义异常响应
Dingo会先于Laravel自带的Handle获取Symfony\Component\HttpKernel\Exception
,所以在系统Handle::render()中处理不了这些异常。需要这样:
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*
* @return void
*/
public function register()
{
// 优化显示验证异常
app('Dingo\Api\Exception\Handler')->register(function (ValidationException $exception) {
$error = $exception->validator->errors()->first();
throw new ValidationHttpException($error);
});
}
...
Transformers
目前通过Eloquent ORM的$casts属性来自动转化字段类型,还没复杂的需求需要用到Transformers。
JWT
安装
Laravel 5.5以上需要使用1.0.0版本
composer require "tymon/jwt-auth:1.0.0-rc.4.1"
在config/app.php
中手动注册Provider
'providers' => [
...
Tymon\JWTAuth\Providers\LaravelServiceProvider::class,
]
配置
发布配置
php artisan vendor:publish --provider="Tymon\JWTAuth\Providers\LaravelServiceProvider"
生成密钥(可选)
php artisan jwt:secret
配置ENV
# JWT Oauth
JWT_SECRET=F5C5Qodnaa78PGFTFGhWgt7cNaHCOcXTI6SdtfuCfjHpotu7uwmlTy8HlbvsXeNt #64位密钥
JWT_TTL=1440 #Token过期时间
使用验证
定义一个控制器基类,在构造函数中指定验证中间件即可。
class Controller extends BaseController
{
use AuthorizesRequests, DispatchesJobs, ValidatesRequests, Helpers;
/**
* Create a new AuthController instance.
*
* @return void
*/
public function __construct()
{
if (needAuth()) { // 助手函数,开发环境不验证Token
$this->middleware('auth:api', ['except' => ['login']]);
}
}
}
之后所有路由到此控制器子类的请求必须带有Authorization头,值为Bearer $token
才能通过检验。
:::warning
只有继承这个基类的控制器才会进行检验,如果不是其子类需要在路由中指定中间件,例如上面路由中dev-logs
的例子。
:::
验证控制器
:::details Click to see more
class AuthController extends Controller
{
public function login(LoginRequest $request)
{
$credentials = $request->only(['email', 'password']);
$auth = auth();
if (!$token = $auth->attempt($credentials)) {
return response()->json(['error' => 'Unauthorized', 'status_code' => 401], 401);
}
return $this->respondWithToken($token);
}
public function me()
{
return response()->json(auth()->user());
}
public function logout()
{
auth()->logout();
return response()->json(['message' => 'Successfully logged out']);
}
public function refresh()
{
/** @noinspection PhpUndefinedMethodInspection */
return $this->respondWithToken(auth()->refresh());
}
public function resetPwd(ResetPwdRequest $request)
{
$oldPassword = $request->get('old_password');
$newPassword = $request->get('new_password');
// 检查旧密码
$admin = auth()->user();
if (!password_verify($oldPassword, $admin->password)) {
throw new AuthenticationException('密码错误');
}
// 更新密码
$admin->update([
'password' => password_hash($newPassword, PASSWORD_DEFAULT)
]);
return success();
}
protected function respondWithToken($token)
{
/** @noinspection PhpUndefinedMethodInspection */
return $this->response->array([
'access_token' => $token,
'token_type' => 'bearer',
'expires_in' => auth()->factory()->getTTL() * 60
])->withHeader('Authorization', $token);
}
}
:::
Swagger
这里采用l5-swagger
扩展,集成了swagger-ui
(使用json配置的文档系统)和php-swagger
(使用代码注释生成json配置)。
安装
composer require "darkaonline/l5-swagger:5.8.*"
在config/app.php
中手动注册Provider
'providers' => [
...
L5Swagger\L5SwaggerServiceProvider::class,
]
配置
发布配置与视图模板
php artisan vendor:publish --provider "L5Swagger\L5SwaggerServiceProvider"
修改配置config/l5-swagger.php
return [
'api' => [
/*
|--------------------------------------------------------------------------
| Edit to set the api's title
|--------------------------------------------------------------------------
*/
'title' => '后台Api文档', # 文档页面的标题
],
'routes' => [
/*
|--------------------------------------------------------------------------
| Route for accessing api documentation interface
|--------------------------------------------------------------------------
*/
'api' => 'api/docs', # 文档页面的路由
/*
|--------------------------------------------------------------------------
| Route for accessing parsed swagger annotations.
|--------------------------------------------------------------------------
*/
'docs' => 'docs', # 路由别名
...
在.env
中添加
# Swagger
SWAGGER_VERSION=3.0 # php-swagger的版本,不同版本注释写法不同!
L5_SWAGGER_GENERATE_ALWAYS=true # 自动生成文档json,不要在生产环境打开此项
最后记得将文档的json配置加入.gitignore
...
/storage/api-docs/api-docs.json
编写Swagger注释
这里不详细介绍php-swagger
的注释语法,只放出几个例子:
swagger.php
<?php
/**
*
* @OA\OpenApi(
* security={
* {
* "Bearer":{}
* }
* },
* @OA\Server(
* url=L5_SWAGGER_CONST_HOST
* )
* )
*
* @OA\Info(
* version="1.0",
* title="小程序后台Api文档",
* @OA\Contact(
* name="火星救援网络科技有限公司",
* url="http://www.example.com/"
* )
* )
*
* @OA\SecurityScheme(
* securityScheme="Bearer",
* type="apiKey",
* name="Authorization",
* in="header",
* )
*
*/
swagger-tags.php
/**
* @OA\Tag(
* name="Auth",
* description="验证模块"
* )
*
* @OA\Tag(
* name="System.Permission",
* description="系统模块中的权限管理"
* )
*
* @OA\Tag(
* name="System.Role",
* description="系统模块中的角色管理"
* )
*
* @OA\Tag(
* name="System.Admin",
* description="系统模块中的用户管理"
* )
*/
AuthController.php
class AuthController extends Controller
{
/**
* @OA\Post(
* path="/auth/login",
* summary="获取凭证",
* tags={"Auth"},
* description="通过账号密码获取Access Token",
* @OA\Parameter(
* name="email",
* in="query",
* required=true,
* @OA\Schema(
* type="string"
* ),
* example="[email protected]",
* description="邮箱"
* ),
* @OA\Parameter(
* name="password",
* in="query",
* required=true,
* @OA\Schema(
* type="string"
* ),
* description="密码"
* ),
* @OA\Response(
* response=200,
* description="返回凭证",
* ref="#/components/schemas/Token"
* ),
* @OA\Response(
* response=401,
* description="账号不存在或密码错误"
* )
* )
*
* @param LoginRequest $request
* @return \Illuminate\Http\JsonResponse
*/
public function login(LoginRequest $request) {
...
:::tip
注释可以写在项目任意php文件中,建议是有归属语义的注释写在归属代码上(例如接口注释写在控制器中,模型注释写在模型中),公共语义的注释一样单独写在一个php文件中。
:::
生成文档json
php artisan l5-swagger:generate
最终效果
访问上面配置的路由地址
点击Authorize
按钮,value
填入Authorization
头的值即可在生产环境的文档页面请求接口。
:::tip
注释可以写在项目任意php文件中,建议是有归属语义的注释写在归属代码上(例如接口注释写在控制器中,模型注释写在模型中),公共语义的注释一样单独写在一个php文件中。
:::
结语
至此,三个轮子在入门应用就介绍完了,这些轮子可以让你更快速的搭建API,专注于业务逻辑。
References
[0] dingo/api
[1] tymon/jwt-auth
[2] swagger-api/swagger-ui
使用Webpack开发JS Sdk
cover: https://pic1.zhimg.com/v2-76676bf647344f92c34a94e3dcc11d38_720w.jpg?source=172ae18b
最近项目需要开发一个JS Sdk,为了保证可读性与可维护性,决定按模块开发,最后使用webpack打包。
文件结构
|-- config
| |-- webpack.base.js
| |-- webpack.development.js
| |-- webpack.production.js
|-- dist
| |-- mars_minigame_sdk.js
| |-- mars_minigame_sdk.min.js
|-- node_modules
|-- package-lock.json
|-- package.json
|-- src
| |-- index.js # SDK入口
| |-- lib
| | `-- util.js # 工具函数
| |-- sdk # 各渠道SDK
| |-- qq.js
| |-- wechat.js
|-- webpack.config.js
安装依赖
npm install -g webpack webpack-cli
npm install --save-dev webpack webpack-cli webpack-merge uglifyjs-webpack-plugin babel-loader
配置webpack
config\webpack.base.js
const path = require('path')
module.exports = {
entry: {
'mars_minigame_sdk': '@/index.js'
},
output: {
path: path.resolve(__dirname, '../dist'),
filename: '[name].min.js',
library: 'mars_mg_sdk',
libraryTarget: "umd"
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: "babel-loader"
}
}
]
},
resolve: {
alias: {
'@': path.resolve('src')
}
}
}
config\webpack.development.js
const merge = require("webpack-merge");
module.exports = {
devtool: 'source-map',
output: {
filename: '[name].js',
}
}
config\webpack.production.js
const merge = require("webpack-merge");
module.exports = {
output: {
filename: '[name].min.js',
}
}
webpack.config.js
const merge = require('webpack-merge');
const baseConfig = require('./config/webpack.base');
const developmentConfig = require('./config/webpack.development');
const productionConfig = require('./config/webpack.production');
module.exports = mode => {
if (mode === "production") {
return merge(baseConfig, productionConfig, { mode });
}
return merge(baseConfig, developmentConfig, { mode });
}
package.json
{
"name": "mars_mg_sdk",
"version": "1.0.0",
"description": "火星救援小游戏SDK",
"private": true,
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "npm run dev",
"dev": "webpack --watch --env development",
"build": "webpack --env production",
"server": "webpack-dev-server --open --env development"
},
"repository": {
"type": "git",
"url": "ssh://[email protected]/lcpd/mars_mg_sdk.git"
},
"keywords": [
"火星救援",
"小游戏",
"SDK"
],
"author": "Mars Developer",
"license": "MIT",
"dependencies": {},
"devDependencies": {
"@babel/core": "^7.5.4",
"babel-loader": "^8.0.6",
"uglifyjs-webpack-plugin": "^2.1.3",
"webpack": "^4.35.3",
"webpack-cli": "^3.3.5",
"webpack-dev-server": "^3.7.2",
"webpack-merge": "^4.2.1"
}
}
业务代码
index.js
const qqSdk = require('./sdk/qq');
const wechatSdk = require('./sdk/wechat');
const util = require('./lib/util');
window.apiHost = 'https://api.mp.mars.com/';
module.exports = {
init: function (e) {},
login: function (e) {},
pay: function (e) {}
}
sdk\wechat.js
const util = require('../lib/util');
module.exports = {
init: function (e) {},
login: function (e) {},
pay: function (e) {}
}
打包
打包开发环境文件,即打包成mars_minigame_sdk.js
。启动命令参数加了--watch
,文件改动时会自动打包。
npm run dev
打包生产环境文件,即mars_minigame_sdk.min.js
npm run build
使用SDK
以微信开发工具为例
References
VSCode FTP与SSH插件推荐
cover: https://miro.medium.com/max/1200/1*YkgFq4CT5rRyz46K3uxHtQ.png
因为一些不可抗力因素,我偶尔要直接在线上改文件,这时就要在几个工具间反复切换(xftp
下载,sublime
编辑,xftp
上传,xshell
执行),比较繁琐。最近刚好改用VSCode
,在市场找到两个比较合适的插件,在一个编辑器内即可完成上述流程,非常顺滑。
SSH FS
如果你直接在市场搜FTP
的话,那些插件都是和xftp
一样,每次修改文件都需要先下载。万一忘了,就可能丢失部分代码。而这款SSH FS
是利用ssh
模拟文件系统,所以你每次打开时,文件内容与目录机构都是最新的。
SSHExtension
这个插件也比较简单,就是直接使用VSCode
的终端执行ssh
命令。不过ssh
配置是共用了另一款ftp
插件ftp-simple
的配置文件,所以也需要一起安装。
最后
VSCode
真香,不像Sublime
那么折腾。
PHP强制规范检查的最佳实践
cover: https://d1xmlzncnovrpo.cloudfront.net/sites/default/files/public/blog-image/PHP_CodeSniffer-Ignoring-Standards.jpg
feature: true
规范是团队中最重要的部分之一,多数团队是靠自觉+review维护规范,而强制检查是最有效的方案。
本文就水一下介绍下PHP_CodeSniffer在项目中强制执行的最佳实践。
规范检查和表单检验一样,服务端是必须要检查的,客户端检查主要是优化使用体验。
客户端
引入组件
$ composer require 'squizlabs/php_codesniffer' --dev
自定义规范
如果你的项目用的是Laravel
框架,会发现它并没有完全遵守PSR2规范,这时候就需要使用自定义规范。
在项目根目录创建phpcs.xml
作为自定义规范,这里放出我的项目的例子。
<?xml version="1.0"?>
<ruleset name="MyStandard">
<description>基于PSR2,去掉部分规则</description>
<arg name="tab-width" value="4"/>
<rule ref="PSR2">
<!-- 不限制行的长度 -->
<exclude name="Generic.Files.LineLength"/>
<!-- 跳过的目录 -->
<exclude-pattern>bootstrap/cache/*</exclude-pattern>
<exclude-pattern>node_modules/*</exclude-pattern>
<exclude-pattern>public/*</exclude-pattern>
<exclude-pattern>resources/*</exclude-pattern>
<exclude-pattern>storage/*</exclude-pattern>
<exclude-pattern>vendor/*</exclude-pattern>
</rule>
<!-- Laravel Migration & Seeder 没有命名空间 -->
<rule ref="PSR1.Classes.ClassDeclaration">
<exclude-pattern>database/*</exclude-pattern>
</rule>
</ruleset>
配置命令
我的使用场景只检查php,为了加快执行速度,跳过了部分目录。
加上这些参数之后命令会比较长,先加入到composer.json的脚本中,方便执行。
composer.json
"scripts": {
"phpcs": [
"./vendor/bin/phpcs --extensions='php' --standard='./phpcs.xml' --ignore='*/bootstrap/*,*/docker/*,*/node_modules/*,*/public/*,*/resources/*,*/storage/*,*/vendor/*,_ide_helper*,*.blade.php' -p"
],
"phpcbf": [
"./vendor/bin/phpcbf --extensions='php' --standard='./phpcs.xml' --ignore='*/bootstrap/*,*/docker/*,*/node_modules/*,*/public/*,*/resources/*,*/storage/*,*/vendor/*,_ide_helper*,*.blade.php' -p"
]
}
手动执行
$ composer phpcs ./
$ composer phpcbf ./
自动执行
通过pre-commit
钩子,在提交时自动执行phpcs
进行检查,如果未通过检查将阻止提交。
在项目根目录创建git-pre-commit-hook
:
#!/bin/sh
PROJECT=`php -r "echo dirname(dirname(dirname(realpath('$0'))));"`
STAGED_FILES_CMD=`git diff --cached --name-only --diff-filter=ACMR HEAD | grep \\\\.php`
# Determine if a file list is passed
if [ "$#" -eq 1 ]
then
oIFS=$IFS
IFS='
'
SFILES="$1"
IFS=$oIFS
fi
SFILES=${SFILES:-$STAGED_FILES_CMD}
echo "Checking PHP Lint..."
for FILE in $SFILES
do
php -l -d display_errors=0 $PROJECT/$FILE
if [ $? != 0 ]
then
echo "Fix the error before commit."
exit 1
fi
FILES="$FILES $PROJECT/$FILE"
done
if [ "$FILES" != "" ]
then
echo "Running Code Sniffer. Code standard PSR2."
composer phpcs -- $FILES
if [ $? != 0 ]
then
echo "Fix the error before commit!"
echo "Run"
echo " composer phpcbf -- $FILES"
echo "for automatic fix or fix it manually."
exit 1
fi
fi
exit $?
同样是在项目根目录创建git-hook-setup.sh
,用于安装钩子:
#!/bin/sh
if [ -e .git/hooks/pre-commit ];
then
PRE_COMMIT_EXISTS=1
else
PRE_COMMIT_EXISTS=0
fi
cp ./git-pre-commit-hook .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
if [ "$PRE_COMMIT_EXISTS" = 0 ];
then
echo "Pre-commit git hook is installed!"
else
echo "Pre-commit git hook is updated!"
fi
在composer.json
的钩子中加入git钩子的安装脚本 禁止套娃
"scripts": {
"install-hooks": [
"sh ./git-hook-setup.sh"
],
"post-install-cmd": [
"@install-hooks"
],
}
每一位开发者在克隆项目后都会执行composer install
,安装完依赖就会自动安装git钩子,从而实现自动检查。
使用命令行提交被拒:
正常安装钩子后,使用PHPStorm提交时将出现Run Git hooks
选项并且默认勾选,如果没有,就重启大法。
使用PHPStorm提交被拒:
:::tip
虽然客户端有办法跳过自动检查,不过后面服务端也会进行检查,不必担心。客户端检查主要是提高用户体验,也可以杜绝不规范的提交。
:::
PHPStorm协同
正常来说,在引入PHPCS组件后,PHPStorm会自动发现配置。如果没有自动配置,需要在 Languages & Frameworks > PHP > Quality Tools > Code Sniffer 中配置。
不过这时候默认是使用PSR2规范,需要手动选择一下自定义的规范。
在 Editor > Inspections > PHP > Quality tools > PHP Code Sniffer validation 中,将Coding standard
改为了Custom
,并点击冒号图标,选中项目根目录的phpcs.xml
。
保存后重启PHPStorm以生效。
服务端
精力有限,这里只介绍我们团队用的GitLab的方案。其他的托管仓库只要支持Hook都能实现。
GitLab
首先进入GitLab所在终端,找到存放所有仓库的目录。
$ cat /etc/gitlab/gitlab.rb |grep git_data_dirs -A 4
git_data_dirs({
"default" => {
"path" => "/data/gitlab"
}
})
然后进入项目所在仓库目录,创建custom_hooks
目录,并在该目录中创建pre-receive
:
:::details Click to see more
#!/usr/bin/env bash
# 创建临时目录
TMP_DIR=$(mktemp -d)
# 删除临时目录(脚本退出时)
trap 'rm -rf "$TMP_DIR"' exit
# 警告数
warnings_count=0
# 错误数
errors_count=0
# 自定义规范
custom_standard='phpcs.xml'
# 空hash
EMPTY_REF='0000000000000000000000000000000000000000'
# Colors
PURPLE='\033[35m'
RED='\033[31m'
RED_BOLD='\033[1;31m'
YELLOW='\033[33m'
YELLOW_BOLD='\033[1;33m'
GREEN='\033[32m'
GREEN_BOLD='\033[1;32m'
BLUE='\033[34m'
BLUE_BOLD='\033[1;34m'
COLOR_END='\033[0m'
while read oldrev newrev ref
do
# 当push新分支的时候oldrev会不存在,删除时newrev就不存在
if [[ $oldrev != $EMPTY_REF && $newrev != $EMPTY_REF ]]; then
echo -e "\n${PURPLE}CodeSniffer check result:${COLOR_END}"
# 取最新版本的自定义规则,不存在则使用PSR2
git show $newrev:$custom_standard > $TMP_DIR/$custom_standard
if [[ $? != 0 ]]; then
standard='PSR2'
else
standard=$TMP_DIR/$custom_standard
fi
# 被检查了的文件数
checked_file_count=0
# 找出哪些文件被更新了
for line in $(git diff-tree -r $oldrev..$newrev | grep -oP '.*\.(php)' | awk '{print $5$6}')
do
# 文件状态
# D: deleted
# A: added
# M: modified
status=$(echo $line | grep -o '^.')
# 不检查被删除的文件
if [[ $status == 'D' ]]; then
continue
fi
# 文件名
file=$(echo $line | sed 's/^.//')
# 为文件创建目录
mkdir -p $(dirname $TMP_DIR/$file)
# 保存文件内容
git show $newrev:$file > $TMP_DIR/$file
output=$(phpcs --standard=$standard --colors --encoding=utf-8 -n -p $TMP_DIR/$file)
warning=$(echo $output | grep -oP '([0-9]+) WARNING' | grep -oP '[0-9]+')
error=$(echo $output | grep -oP '([0-9]+) ERROR' | grep -oP '[0-9]+')
if [[ $warning || $error ]]; then
msg="${file}: "
if [[ $warning > 0 ]]; then
msg="$msg${YELLOW_BOLD}${warning}${COLOR_END} ${YELLOW}warnings${COLOR_END} "
let "warnings_count = warnings_count + 1"
fi
if [[ $error > 0 ]]; then
msg="$msg${RED_BOLD}${error}${COLOR_END} ${RED}errors${COLOR_END}"
let "errors_count = errors_count + 1"
fi
echo -e " $msg"
fi
let "checked_file_count = checked_file_count + 1";
done
if [[ $checked_file_count == 0 ]]; then
echo -e " ${BLUE_BOLD}No file was checked.${COLOR_END}"
elif [[ $warnings_count == 0 && $errors_count == 0 ]]; then
echo -e "${GREEN_BOLD}$(cowsay 'Congratulations!!!')${COLOR_END}"
elif [[ $errors_count == 0 ]]; then
echo -e " ${BLUE}Good job, no errors were found!!!${COLOR_END}"
fi
fi
done
if [[ $warnings_count > 0 || $errors_count > 0 ]]; then
echo -e "${RED}$(cowsay -f dragon 'PHPCS Check Error!!!')${COLOR_END}"
exit 1
fi
exit 0
:::
之后客户端推送提交时,便会执行这个钩子进行检查。
推送通过:
推送被拒:
如果使用PHPStorm的UI进行推送,被拒是不提示原因的,不过相信大家心里一般都有b数的。
_____________________________
< 你心里没点b数吗? >
-----------------------------
\ ^__^
\ (oo)\_______
(__)\ )\/\
||----w |
|| ||
额外规则
phpcs
自带的规则集比较少,这里推荐一组规则集slevomat/coding-standard,最新版本做了自动安装,使用体验非常丝滑。
composer require slevomat/coding-standard --dev
如果你的项目中有使用ORM、数据库的字段有使用下划线、并且希望变量名强制使用小驼峰,那目前截至v3.5的phpcs
自带规则Squiz.NamingConventions.ValidVariableName.NotCamelCaps
将无法实现你的需求,会把$user->created_at
误判为不规范,参照Issue#1773
等phpcs
发布v4.0可能会修复,我写了个组件cloudycity/coding-standard,可以暂时解决这个问题。
composer require cloudycity/coding-standard --dev
最终的规则集
:::details Click to see more
<?xml version="1.0"?>
<ruleset name="MyStandard">
<!-- 新版本不需要手动安装第三方规则 -->
<!-- <config name="installed_paths" value="vendor/slevomat/coding-standard"/> -->
<description>基于PSR2与slevomat的规则</description>
<arg name="tab-width" value="4"/>
<rule ref="PSR2">
<!-- 不限制行的长度 -->
<exclude name="Generic.Files.LineLength"/>
<!-- 跳过的目录 -->
<exclude-pattern>bootstrap/cache/*</exclude-pattern>
<exclude-pattern>node_modules/*</exclude-pattern>
<exclude-pattern>public/*</exclude-pattern>
<exclude-pattern>resources/*</exclude-pattern>
<exclude-pattern>storage/*</exclude-pattern>
<exclude-pattern>vendor/*</exclude-pattern>
</rule>
<!-- Laravel Migration & Seeder 没有命名空间 -->
<rule ref="PSR1.Classes.ClassDeclaration">
<exclude-pattern>database/*</exclude-pattern>
</rule>
<!-- 变量名采用驼峰 -->
<rule ref="CloudyCityCodingStandard.NamingConventions.ValidVariableName.NotCamelCaps"/>
<!-- 字符串连接符前后空格 -->
<rule ref="Squiz.Strings.ConcatenationSpacing">
<properties>
<property name="spacing" value="1"/>
</properties>
</rule>
<!-- 确保函数前后空行,首个/最后函数除外 -->
<rule ref="Squiz.WhiteSpace.FunctionSpacing">
<properties>
<property name="spacing" value="1"/>
<property name="spacingBeforeFirst" value="0"/>
<property name="spacingAfterLast" value="0"/>
</properties>
</rule>
<!-- 操作符前后空格 -->
<rule ref="Squiz.WhiteSpace.OperatorSpacing">
<properties>
<property name="ignoreNewlines" value="false"/>
<property name="ignoreSpacingBeforeAssignments" value="false"/>
</properties>
</rule>
<!-- 单行数组空格 -->
<rule ref="SlevomatCodingStandard.Arrays.SingleLineArrayWhitespace"/>
<!-- 多行数组元素强制以逗号结尾 -->
<rule ref="SlevomatCodingStandard.Arrays.TrailingArrayComma"/>
<!-- 禁止数组隐式创建 -->
<rule ref="SlevomatCodingStandard.Arrays.DisallowImplicitArrayCreation"/>
<!-- 禁止自动生成的注释 -->
<rule ref="SlevomatCodingStandard.Commenting.ForbiddenComments">
<properties>
<property
name="forbiddenCommentPatterns"
type="array"
value="~^Created by \S+\.\z~i,"/>
</properties>
</rule>
</ruleset>
:::
GitLab的兼容
只要在gitlab的机器上也用composer引入slevomat/coding-standard即可。
授予执行权限
chmod a+x vendor/squizlabs/php_codesniffer/bin/phpcs
chmod a+x vendor/squizlabs/php_codesniffer/bin/phpcbf
修改pre-receive
中的执行命令
output=$(/usr/local/bin/php7 /data/gitlab/repositories/php/demo.git/custom_hooks/vendor/squizlabs/php_codesniffer/bin/phpcs --standard=$standard --colors --encoding=utf-8 -n -p $TMP_DIR/$file)
最后
上面提到的主要是针对单个仓库的强制规范检查策略,主要是我目前团队的各个项目的规范都有不同。
如果你的各项目都有相同的规范,可以将规范提取为一个组件,服务端也可以使用全局Hook,不必为每一个仓库加Hook。
References
PHP_CodeSniffer
Git Hooks
GitLab Server Hooks
客户端Hook自动安装脚本
服务端Hook脚本
Laravel5.2 使用队列记录接口日志
cover: https://hackthestuff.com/uploads/posts/laravel-queue.jpg
最近在开发一个新接口时,需要记录请求与响应的日志。为了提高接口响应速度,记录日志这个环节就由异步队列来完成。项目采用Laravel5.2,队列驱动使用Redis。
配置
Laravel 5.2自带的redis队列驱动中会用到watch
命令,所以没办法直接使用集群。要么自己写驱动,要么队列单独使用另外的主备redis。我这边是采用后者。
增加redis连接配置
.env
// 队列驱动
QUEUE_DRIVER=redis
// 集群
REDIS_HOST=xxx.xxx.xxx.xxx
REDIS_PASSWORD=xxx
REDIS_PORT=xxxx
// 主备
QUEUE_REDIS_HOST=xxx.xxx.xxx.xxx
QUEUE_REDIS_PASSWORD=xxx
QUEUE_REDIS_PORT=xxxx
config/database.php
'redis' => [
'cluster' => false,
'default' => [
'host' => env('REDIS_HOST', 'localhost'),
'password' => env('REDIS_PASSWORD', null),
'port' => env('REDIS_PORT', 6379),
'database' => 0,
],
'queue' => [
'host' => env('QUEUE_REDIS_HOST', 'localhost'),
'password' => env('QUEUE_REDIS_PASSWORD', null),
'port' => env('QUEUE_REDIS_PORT', 6379),
'database' => 0,
],
],
指定队列使用的redis连接
config/queue.php
'default' => env('QUEUE_DRIVER', 'sync'),
...
'connections' => [
'redis' => [
'driver' => 'redis',
'connection' => 'queue',
'queue' => 'default',
'expire' => 60,
],
],
...
创建Worker
php artisan make:job SaveMatchLog
与自动计划的Command
类一样,在handle()
中编写具体业务代码。
:::details Click to see more
class SaveMatchLog extends Job implements ShouldQueue
{
use InteractsWithQueue, SerializesModels;
/**
* 请求的订单号
* @var string
*/
private $orderNo;
/**
* 请求时间
* @var array
*/
private $time;
/**
* 请求IP
* @var array
*/
private $ip;
/**
* 客户端系统信息
* @var array
*/
private $userAgent;
/**
* 响应信息
* @var array
*/
private $msg;
/**
* saveMatchLog constructor.
* @param Request $req
* @param \Illuminate\Http\JsonResponse $res
*/
public function __construct($req, $res)
{
$this->orderNo = $req->get('order_no');
$this->time = date('Y-m-d H:i:s', $req->server->get('REQUEST_TIME'));
$this->ip = $req->getClientIp();
$this->userAgent = $req->header('user-agent');
$this->msg = $res->getData();
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
// 日志内容
$content = $this->getLogContent();
// 输出日志
$logWriter = new Writer(new Logger('match'));
$logWriter->useDailyFiles(storage_path('logs/match/match.log'));
$logWriter->info($content);
}
public function failed()
{
$content = $this->getLogContent();
\Log::error('记录请求响应日志到logs/match.log失败,日志将记录在系统日志logs/laravel.log中');
\Log::info($content);
}
/**
* @return string
*/
private function getLogContent()
{
return json_encode([
'orderNo' => $this->orderNo,
'time' => $this->time,
'ip' => $this->ip,
'userAgent' => $this->userAgent,
'msg' => $this->msg,
], JSON_UNESCAPED_UNICODE);
}
}
:::
添加到队列
接口的Controller
如下
/**
* 接口入口
* @param Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function match(Request $request)
{
$response = $this->_match($request);
$job = (new SaveMatchLog($request, $response));
$this->dispatch($job); // 使用队列存储日志,提高接口响应速度
return $response;
}
/**
* @param Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function _match(Request $request)
{
// do sth
}
监听队列
采用supervisor
来管理监听进程
进程配置
$ cat /etc/supervisor/conf.d/laravel-worker.conf
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /data/www/newadminpk/artisan queue:work redis --sleep=3 --tries=3 --daemon
autostart=true
autorestart=true
user=www
numprocs=1
redirect_stderr=true
stdout_logfile=/data/www/newadminpk/storage/logs/worker.out.log
stderr_logfile=/data/www/newadminpk/storage/logs/worker.err.log
引入配置
$ tail /etc/supervisord/supervisord.conf
[include]
files = ./conf.d/*.conf
启动supervisor
sudo supervisord -c supervisor.conf
启动队列监听器
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start laravel-worker:*
$ ps -ef | grep artisan | grep -v grep
www 21064 3186 0 14:49 ? 00:00:01 php /data/www/newadminpk/artisan queue:work redis --sleep=3 --tries=3 --daemon
其他
Walle配置
这个项目通过Walle
部署,需要在项目配置的post-release
中配置以下
sudo supervisorctl restart laravel-worker:*
否则每次发布新版本后,Worker还是会将日志保存到上一个版本的路径下,导致日志丢失(这一点只要熟悉Walle的原理就知道原因了)。
ELK优化
目前日志处理与检索还属于低频需求,后期可以考虑采用ELK改进写入日志之后的流程。
用Docker搭建LNMP环境
cover: https://xmyunwei.com/wp-content/uploads/2020/10/docker-lnmp.png
人类的本质是复读机。
安装docker
查看内核版本
$ uname -r
3.10.0-957.el7.x86_64
更新yum包
$ yum update
添加包源
$ yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
Loaded plugins: fastestmirror, langpacks
adding repo from: https://download.docker.com/linux/centos/docker-ce.repo
grabbing file https://download.docker.com/linux/centos/docker-ce.repo to /etc/yum.repos.d/docker-ce.repo
repo saved to /etc/yum.repos.d/docker-ce.repo
查看版本
$ yum list docker-ce --showduplicates | sort -r
Loading mirror speeds from cached hostfile
Loaded plugins: fastestmirror, langpacks
docker-ce.x86_64 3:18.09.8-3.el7 docker-ce-stable
docker-ce.x86_64 3:18.09.7-3.el7 docker-ce-stable
docker-ce.x86_64 3:18.09.6-3.el7 docker-ce-stable
docker-ce.x86_64 3:18.09.5-3.el7 docker-ce-stable
docker-ce.x86_64 3:18.09.4-3.el7 docker-ce-stable
docker-ce.x86_64 3:18.09.3-3.el7 docker-ce-stable
docker-ce.x86_64 3:18.09.2-3.el7 docker-ce-stable
docker-ce.x86_64 3:18.09.1-3.el7 docker-ce-stable
docker-ce.x86_64 3:18.09.0-3.el7 docker-ce-stable
docker-ce.x86_64 18.06.3.ce-3.el7 docker-ce-stable
docker-ce.x86_64 18.06.2.ce-3.el7 docker-ce-stable
docker-ce.x86_64 18.06.1.ce-3.el7 docker-ce-stable
docker-ce.x86_64 18.06.0.ce-3.el7 docker-ce-stable
docker-ce.x86_64 18.03.1.ce-1.el7.centos docker-ce-stable
docker-ce.x86_64 18.03.0.ce-1.el7.centos docker-ce-stable
docker-ce.x86_64 17.12.1.ce-1.el7.centos docker-ce-stable
docker-ce.x86_64 17.12.0.ce-1.el7.centos docker-ce-stable
docker-ce.x86_64 17.09.1.ce-1.el7.centos docker-ce-stable
docker-ce.x86_64 17.09.0.ce-1.el7.centos docker-ce-stable
docker-ce.x86_64 17.06.2.ce-1.el7.centos docker-ce-stable
docker-ce.x86_64 17.06.1.ce-1.el7.centos docker-ce-stable
docker-ce.x86_64 17.06.0.ce-1.el7.centos docker-ce-stable
docker-ce.x86_64 17.03.3.ce-1.el7 docker-ce-stable
docker-ce.x86_64 17.03.2.ce-1.el7.centos docker-ce-stable
docker-ce.x86_64 17.03.1.ce-1.el7.centos docker-ce-stable
docker-ce.x86_64 17.03.0.ce-1.el7.centos docker-ce-stable
Available Packages
安装
$ yum install docker-ce-18.03.1.ce
启动
$ systemctl start docker
设置开机启动 (可选)
$ systemctl enable docker
Created symlink from /etc/systemd/system/multi-user.target.wants/docker.service to /usr/lib/systemd/system/docker.service.
查看
$ docker version
Client:
Version: 18.03.1-ce
API version: 1.37
Go version: go1.9.5
Git commit: 9ee9f40
Built: Thu Apr 26 07:20:16 2018
OS/Arch: linux/amd64
Experimental: false
Orchestrator: swarm
Server:
Engine:
Version: 18.03.1-ce
API version: 1.37 (minimum version 1.12)
Go version: go1.9.5
Git commit: 9ee9f40
Built: Thu Apr 26 07:23:58 2018
OS/Arch: linux/amd64
Experimental: false
安装docker-compose
从github安装
$ curl -L "https://github.com/docker/compose/releases/download/1.24.1/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
给予执行权限
$ chmod +x /usr/local/bin/docker-compose
查看
$ docker-compose --version
docker-compose version 1.24.1, build 4667896b
搭建LNMP
这里直接用yeszao/dnmp的配置。
选取目录
选择一个存放容器配置的目录(只是配置文件位置,镜像与容器在/var/lib/docker
目录中)
$ cd ~ && mkdir dnmp && cd dnmp
拉取配置
$ git clone [email protected]:yeszao/dnmp.git # 这里用的是v2.0.4的tag
$ cp env.sample .env && cp docker-compose.sample.yml docker-compose.yml
修改配置
这里docker-compose.yml
只开启了nginx
、php
、mysql8
和redis
,.env
给php加了一些扩展。
# .env
PHP_VERSION=7.2.19
PHP_PHP_CONF_FILE=./conf/php.ini
PHP_FPM_CONF_FILE=./conf/php-fpm.conf
PHP_LOG_DIR=./log/php
PHP_EXTENSIONS=pdo_mysql,mysqli,mbstring,gd,curl,opcache,redis,imap,swoole,xhprof,acpu
所有配置文件都在~/dnmp/services/
目录中,所有日志都在~/dnmp/log
中,具体见yeszao/dnmp。
执行构建
在配置文件处执行
$ docker-compose up -d
Creating network "dnmp_default" with the default driver
Creating redis ... done
Creating mysql ... done
Creating php ... done
Creating nginx ... done
初始构建需要一定时间,如果构建时无法下载组件,需要给容器指定dns服务器。
- 查看宿主机
nameserver
$ cat /etc/resolv.conf
# Your system has been configured with 'manage-resolv-conf' set to true.
# As a result, cloud-init has written this file with configuration data
# that it has been provided. Cloud-init, by default, will write this file
# a single time (PER_ONCE).
#
nameserver 183.60.83.19
nameserver 183.60.82.98
- 新建或修改
/etc/default/docker
DOCKER_OPTS="--dns 183.60.83.19"
- 重启
docker
systemctl restart docker
查看容器
$ docker-compose ps
Name Command State Ports
----------------------------------------------------------------------------------------
mysql docker-entrypoint.sh mysqld Up 0.0.0.0:3306->3306/tcp, 33060/tcp
nginx nginx -g daemon off; Up 0.0.0.0:443->443/tcp, 0.0.0.0:80->80/tcp
php docker-php-entrypoint php-fpm Up 9000/tcp, 9501/tcp
redis redis-server /etc/redis.conf Up 0.0.0.0:6379->6379/tcp
配置命令别名
从bash.alias.sample
提取需要的别名到~/.bashrc
或~/.zshrc
中,然后重新source
生效
alias dnginx='docker exec -it nginx /bin/sh'
alias dphp='docker exec -it php /bin/sh'
alias dmysql='docker exec -it mysql /bin/bash'
alias dredis='docker exec -it redis /bin/sh'
php () {
tty=
tty -s && tty=--tty
docker run \
$tty \
--interactive \
--rm \
--volume $PWD:/www:rw \
--workdir /www \
dnmp_php php "$@"
}
composer() {
tty=
tty -s && tty=--tty
docker run \
$tty \
--interactive \
--rm \
--user www-data:www-data \
--volume ~/dnmp/data/composer:/tmp/composer \
--volume $(pwd):/app \
--workdir /app \
dnmp_php composer "$@"
}
References
WSL2暴露端口到局域网
最近又回到Windows开发环境,有个用到NetPoll的包只能在WSL或VM跑,而WSL2和宿主机是NAT模式,局域网内默认无法访问到WSL2中的端口...
解决方案挺多的,这里采用最简单的一种:
:: 固定WSL对于宿主机的IP
wsl -d Ubuntu-20.04 -u root ip addr add 192.168.50.16/24 broadcast 192.168.50.255 dev eth0 label eth0:1
:: 固定宿主机对于WSL的IP
netsh interface ip add address "vEthernet (WSL)" 192.168.50.88 255.255.255.0
:: 端口映射
netsh interface portproxy add v4tov4 listenport=38830 connectaddress=192.168.50.16 connectport=38830 listenaddress=0.0.0.0 protocol=tcp
该配置重启后会失效,存为bat
文件,开启自启动即可。
如何更好的通过IDE代码检查
cover: https://reinteractive.com/assets/services/inspect/reinteractive-code-inspect-c5d765882bb6372e935ed243f658df90f04b84e62000c9887be744db44933ca3.jpg
我身边有很多同事都没有重视代码检查,每次看他们IDE右侧都是一边红黄棕交错的“线谱”,特别难受。让自己的代码通过IDE的检查,可以避免很多低级的错误。下面分享下如何更好地通过Intellij系列IDE的代码检查。
目标是什么
目标是无任何notice,包括英文的拼写检查。
为什么要达成目标
通过代码检查可以:
- 一定程度上规范开发
- 更容易发现bug,避免低级错误
如何达成目标
遵守必要规则
至于判断【规则是否必要】的标准因人而异,但确定是【必要规则】就必须遵守,这是代码检查的意义。
关闭不必要规则
全局关闭
不必要的检查规则,例如No data sources configured
、SQL dialect detection
等等,可以将其关闭。
在Inspection Notice
处按Alt+Enter
打开Inspection setting
:
去掉勾:
效果:
单次忽略
有些规则是必要的,但因为某些原因我们无法遵守。可以选择Suppress for statement
,将会自动在Notice前加入一行注释告诉IDE忽略掉此处的检查。
引导IDE
某些场景规则是必要的,我们也遵守了,只是IDE没有get到仍标出Notice,这时候就需要想办法引导IDE去get到。
添加注释
以常见的闭包Notice为例,内部都会报warning:
使用/** @var 变量类型 $var */
声明
添加助手文件
IDE无法识别Laravel
的Facade
的静态调用方式
这时候需要创建额外的助手文件ide-helper.php
,里面存放类声明:
IDE识别后:
P.S. 助手文件本身是可以忽略检查,即选择suppress all for file
项。
拼写检查
英文拼写检查的优先级比较低,但能遵守的话可以避免一些奇怪的命名
使用下划线或驼峰通过检查
将专有名词加入字典
给随机字符串添加忽略注释
导出/导入配置
导出配置以备份、与团队成员共享
如何引用同一级查询的列别名作为新列
cover: https://fs.buttercms.com/resize=width:940/BzVPXsRRQh2CF2GF8uKF
火星救援,刻不容缓!
示例表:
mysql> SELECT * FROM core;
+--------+--------+--------+
| field1 | field2 | field3 |
+--------+--------+--------+
| 1 | 2 | 3 |
| 4 | 5 | 6 |
+--------+--------+--------+
2 rows in set (0.00 sec)
MySQL
中无法直接执行以下查询:
mysql> SELECT
-> field1 + field2 AS col1,
-> col1 + field3 AS col2
-> FROM
-> core;
ERROR 1054 (42S22): Unknown column 'col1' in 'field list'
但有三种方案实现这个效果。
子查询
mysql> SELECT
-> col1,
-> col1 + field3 AS col2
-> FROM
-> ( SELECT field1 + field2 AS col1, field3 FROM core ) AS subQuery;
+------+------+
| col1 | col2 |
+------+------+
| 3 | 6 |
| 9 | 15 |
+------+------+
2 rows in set (0.00 sec)
用户变量
mysql> SELECT
-> @col1 := ( field1 + field2 ) AS col1,
-> @col1 + field3 AS col2
-> FROM
-> core;
+------+------+
| col1 | col2 |
+------+------+
| 3 | 6 |
| 9 | 15 |
+------+------+
2 rows in set, 1 warning (0.00 sec)
这里结果显示了一个warning: SELECT里的表达式在被发送到mysql客户端时才会被执行,所以[用户变量](https://dev.mysql.com/doc/refman/5.6/en/user-variables.html)在`HAVING`、`GROUP BY`、`ORDER BY`中无法使用。
引用别名
mysql> SELECT
-> field1 + field2 AS col1,
-> ( SELECT col1 ) + field3 AS col2
-> FROM
-> core;
+------+------+
| col1 | col2 |
+------+------+
| 3 | 6 |
| 9 | 15 |
+------+------+
2 rows in set (0.00 sec)
与`用户变量`类似,在`GROUP BY`中无法使用。
在GROUP BY
里中使用引用:
mysql> SELECT
-> MAX( field1 ) AS col1,
-> ( SELECT col1 ) AS col2
-> FROM
-> core
-> GROUP BY
-> field1;
ERROR 1247 (42S22): Reference 'col1' not supported (reference to group function)
References
https://stackoverflow.com/questions/6081436/how-to-alias-a-field-or-column-in-mysql#
Eloquent自动维护int类型的时间
cover: https://cdn.shouts.dev/wp-content/uploads/2020/08/14223540/some-useful-timestamp-tricks-in-laravel.png
火星救援,刻不容缓!
Laravel Eloquent提供了自动维护created_at
、updated_at
的功能,时间的默认类型为格式化的字符串(Carbon\Carbon::toString()的返回值
)。
如果表设计是存储Unix时间戳,可以通过一个trait支持。
namespace App\Models;
trait UseUnixTimestamp
{
public function freshTimestamp() {
return time();
}
public function fromDateTime($value) {
return $value;
}
public function getDateFormat() {
return 'U';
}
}
namespace App\Models;
use UseUnixTimestamp;
class MyModel extends Model
{
use UseUnixTimestamp;
//
}
使用Laravel-Mix构建Asset
cover: https://repository-images.githubusercontent.com/76991633/43a4fe80-025e-11eb-8b88-bf742e4412a7
公司几个后台老项目的asset都没有构建方案,直接放到版本控制仓库里,臃肿凌乱,加载速度也堪忧。
最近开发一个新后台,决定改用laravel-mix构建asset。
改造前的文件结构(Git)
:::details Click to see more
├── public
│ ├── asset
│ │ ├── adminlte
│ │ │ ├── css
│ │ │ │ ├── adminlte.min.css
│ │ │ │ ├── adminlte.min.css.map
│ │ │ │ └── alt
│ │ │ │ ├── adminlte.components.min.css
│ │ │ │ ├── adminlte.components.min.css.map
│ │ │ │ ├── adminlte.core.min.css
│ │ │ │ ├── adminlte.core.min.css.map
│ │ │ │ ├── adminlte.extra-components.min.css
│ │ │ │ ├── adminlte.extra-components.min.css.map
│ │ │ │ ├── adminlte.pages.min.css
│ │ │ │ ├── adminlte.pages.min.css.map
│ │ │ │ ├── adminlte.plugins.min.css
│ │ │ │ └── adminlte.plugins.min.css.map
│ │ │ ├── img
│ │ │ │ └── AdminLTELogo.png
│ │ │ ├── js
│ │ │ │ ├── adminlte.min.js
│ │ │ │ ├── adminlte.min.js.map
│ │ │ │ └── customize.js
│ │ │ └── plugins
│ │ │ ├── bootstrap
│ │ │ │ ├── css
│ │ │ │ │ ├── bootstrap.min.css
│ │ │ │ │ └── bootstrap.min.css.map
│ │ │ │ └── js
│ │ │ │ ├── bootstrap.bundle.min.js
│ │ │ │ ├── bootstrap.bundle.min.js.map
│ │ │ │ ├── bootstrap.min.js
│ │ │ │ └── bootstrap.min.js.map
│ │ │ ├── bootstrap-iconpicker
│ │ │ │ ├── css
│ │ │ │ │ └── bootstrap-iconpicker.min.css
│ │ │ │ └── js
│ │ │ │ └── bootstrap-iconpicker.bundle.min.js
│ │ │ ├── bootstrap-treeview
│ │ │ │ ├── css
│ │ │ │ │ └── bootstrap-treeview.min.css
│ │ │ │ └── js
│ │ │ │ └── bootstrap-treeview.min.js
│ │ │ ├── datatables
│ │ │ │ ├── css
│ │ │ │ │ ├── dataTables.bootstrap4.min.css
│ │ │ │ │ └── select.bootstrap.min.css
│ │ │ │ ├── extensions
│ │ │ │ │ ├── datatables-fixedcolumns
│ │ │ │ │ │ ├── css
│ │ │ │ │ │ │ └── fixedColumns.bootstrap4.min.css
│ │ │ │ │ │ └── js
│ │ │ │ │ │ ├── dataTables.fixedColumns.min.js
│ │ │ │ │ │ └── fixedColumns.bootstrap4.min.js
│ │ │ │ │ ├── datatables-fixedheader
│ │ │ │ │ │ ├── css
│ │ │ │ │ │ │ └── fixedHeader.bootstrap4.min.css
│ │ │ │ │ │ └── js
│ │ │ │ │ │ ├── dataTables.fixedHeader.min.js
│ │ │ │ │ │ └── fixedHeader.bootstrap4.min.js
│ │ │ │ │ └── datatables-responsive
│ │ │ │ │ ├── css
│ │ │ │ │ │ └── responsive.bootstrap4.min.css
│ │ │ │ │ └── js
│ │ │ │ │ ├── dataTables.responsive.min.js
│ │ │ │ │ └── responsive.bootstrap4.min.js
│ │ │ │ └── js
│ │ │ │ ├── dataTables.bootstrap4.min.js
│ │ │ │ └── jquery.dataTables.min.js
│ │ │ ├── daterangepicker
│ │ │ │ ├── css
│ │ │ │ │ └── daterangepicker.min.css
│ │ │ │ └── js
│ │ │ │ └── daterangepicker.min.js
│ │ │ ├── datetimepicker
│ │ │ │ ├── css
│ │ │ │ │ └── bootstrap-datetimepicker.min.css
│ │ │ │ └── js
│ │ │ │ └── bootstrap-datetimepicker.min.js
│ │ │ ├── distpicker
│ │ │ │ └── distpicker.min.js
│ │ │ ├── echarts
│ │ │ │ └── echarts.min.js
│ │ │ ├── fontawesome-free
│ │ │ │ ├── css
│ │ │ │ │ └── all.min.css
│ │ │ │ ├── js
│ │ │ │ │ └── all.min.js
│ │ │ │ ├── sprites
│ │ │ │ │ ├── brands.svg
│ │ │ │ │ ├── regular.svg
│ │ │ │ │ └── solid.svg
│ │ │ │ └── webfonts
│ │ │ │ ├── fa-brands-400.eot
│ │ │ │ ├── fa-brands-400.svg
│ │ │ │ ├── fa-brands-400.ttf
│ │ │ │ ├── fa-brands-400.woff
│ │ │ │ ├── fa-brands-400.woff2
│ │ │ │ ├── fa-regular-400.eot
│ │ │ │ ├── fa-regular-400.svg
│ │ │ │ ├── fa-regular-400.ttf
│ │ │ │ ├── fa-regular-400.woff
│ │ │ │ ├── fa-regular-400.woff2
│ │ │ │ ├── fa-solid-900.eot
│ │ │ │ ├── fa-solid-900.svg
│ │ │ │ ├── fa-solid-900.ttf
│ │ │ │ ├── fa-solid-900.woff
│ │ │ │ └── fa-solid-900.woff2
│ │ │ ├── icheck
│ │ │ │ ├── css
│ │ │ │ │ ├── all.css
│ │ │ │ │ ├── flat
│ │ │ │ │ │ ├── _all.css
│ │ │ │ │ │ ├── aero.css
│ │ │ │ │ │ ├── aero.png
│ │ │ │ │ │ ├── [email protected]
│ │ │ │ │ │ ├── blue.css
│ │ │ │ │ │ ├── blue.png
│ │ │ │ │ │ ├── [email protected]
│ │ │ │ │ │ ├── flat.css
│ │ │ │ │ │ ├── flat.png
│ │ │ │ │ │ ├── [email protected]
│ │ │ │ │ │ ├── green.css
│ │ │ │ │ │ ├── green.png
│ │ │ │ │ │ ├── [email protected]
│ │ │ │ │ │ ├── grey.css
│ │ │ │ │ │ ├── grey.png
│ │ │ │ │ │ ├── [email protected]
│ │ │ │ │ │ ├── orange.css
│ │ │ │ │ │ ├── orange.png
│ │ │ │ │ │ ├── [email protected]
│ │ │ │ │ │ ├── pink.css
│ │ │ │ │ │ ├── pink.png
│ │ │ │ │ │ ├── [email protected]
│ │ │ │ │ │ ├── purple.css
│ │ │ │ │ │ ├── purple.png
│ │ │ │ │ │ ├── [email protected]
│ │ │ │ │ │ ├── red.css
│ │ │ │ │ │ ├── red.png
│ │ │ │ │ │ ├── [email protected]
│ │ │ │ │ │ ├── yellow.css
│ │ │ │ │ │ ├── yellow.png
│ │ │ │ │ │ └── [email protected]
│ │ │ │ │ ├── futurico
│ │ │ │ │ │ ├── futurico.css
│ │ │ │ │ │ ├── futurico.png
│ │ │ │ │ │ └── [email protected]
│ │ │ │ │ ├── line
│ │ │ │ │ │ ├── _all.css
│ │ │ │ │ │ ├── aero.css
│ │ │ │ │ │ ├── blue.css
│ │ │ │ │ │ ├── green.css
│ │ │ │ │ │ ├── grey.css
│ │ │ │ │ │ ├── line.css
│ │ │ │ │ │ ├── line.png
│ │ │ │ │ │ ├── [email protected]
│ │ │ │ │ │ ├── orange.css
│ │ │ │ │ │ ├── pink.css
│ │ │ │ │ │ ├── purple.css
│ │ │ │ │ │ ├── red.css
│ │ │ │ │ │ └── yellow.css
│ │ │ │ │ ├── minimal
│ │ │ │ │ │ ├── _all.css
│ │ │ │ │ │ ├── aero.css
│ │ │ │ │ │ ├── aero.png
│ │ │ │ │ │ ├── [email protected]
│ │ │ │ │ │ ├── blue.css
│ │ │ │ │ │ ├── blue.png
│ │ │ │ │ │ ├── [email protected]
│ │ │ │ │ │ ├── green.css
│ │ │ │ │ │ ├── green.png
│ │ │ │ │ │ ├── [email protected]
│ │ │ │ │ │ ├── grey.css
│ │ │ │ │ │ ├── grey.png
│ │ │ │ │ │ ├── [email protected]
│ │ │ │ │ │ ├── minimal.css
│ │ │ │ │ │ ├── minimal.png
│ │ │ │ │ │ ├── [email protected]
│ │ │ │ │ │ ├── orange.css
│ │ │ │ │ │ ├── orange.png
│ │ │ │ │ │ ├── [email protected]
│ │ │ │ │ │ ├── pink.css
│ │ │ │ │ │ ├── pink.png
│ │ │ │ │ │ ├── [email protected]
│ │ │ │ │ │ ├── purple.css
│ │ │ │ │ │ ├── purple.png
│ │ │ │ │ │ ├── [email protected]
│ │ │ │ │ │ ├── red.css
│ │ │ │ │ │ ├── red.png
│ │ │ │ │ │ ├── [email protected]
│ │ │ │ │ │ ├── yellow.css
│ │ │ │ │ │ ├── yellow.png
│ │ │ │ │ │ └── [email protected]
│ │ │ │ │ ├── polaris
│ │ │ │ │ │ ├── polaris.css
│ │ │ │ │ │ ├── polaris.png
│ │ │ │ │ │ └── [email protected]
│ │ │ │ │ └── square
│ │ │ │ │ ├── _all.css
│ │ │ │ │ ├── aero.css
│ │ │ │ │ ├── aero.png
│ │ │ │ │ ├── [email protected]
│ │ │ │ │ ├── blue.css
│ │ │ │ │ ├── blue.png
│ │ │ │ │ ├── [email protected]
│ │ │ │ │ ├── green.css
│ │ │ │ │ ├── green.png
│ │ │ │ │ ├── [email protected]
│ │ │ │ │ ├── grey.css
│ │ │ │ │ ├── grey.png
│ │ │ │ │ ├── [email protected]
│ │ │ │ │ ├── orange.css
│ │ │ │ │ ├── orange.png
│ │ │ │ │ ├── [email protected]
│ │ │ │ │ ├── pink.css
│ │ │ │ │ ├── pink.png
│ │ │ │ │ ├── [email protected]
│ │ │ │ │ ├── purple.css
│ │ │ │ │ ├── purple.png
│ │ │ │ │ ├── [email protected]
│ │ │ │ │ ├── red.css
│ │ │ │ │ ├── red.png
│ │ │ │ │ ├── [email protected]
│ │ │ │ │ ├── square.css
│ │ │ │ │ ├── square.png
│ │ │ │ │ ├── [email protected]
│ │ │ │ │ ├── yellow.css
│ │ │ │ │ ├── yellow.png
│ │ │ │ │ └── [email protected]
│ │ │ │ └── js
│ │ │ │ └── icheck.min.js
│ │ │ ├── ionicons
│ │ │ │ └── icon.min.css
│ │ │ ├── jquery
│ │ │ │ └── jquery.min.js
│ │ │ ├── jquery-validation
│ │ │ │ ├── additional-methods.min.js
│ │ │ │ ├── jquery.validate.min.js
│ │ │ │ └── locazation
│ │ │ │ └── messages_zh.min.js
│ │ │ ├── jsonview
│ │ │ │ ├── css
│ │ │ │ │ └── jquery.jsonview.min.css
│ │ │ │ └── js
│ │ │ │ └── jquery.jsonview.min.js
│ │ │ ├── layer
│ │ │ │ ├── layer.js
│ │ │ │ ├── mobile
│ │ │ │ │ ├── layer.js
│ │ │ │ │ └── need
│ │ │ │ │ └── layer.css
│ │ │ │ └── theme
│ │ │ │ └── default
│ │ │ │ ├── icon-ext.png
│ │ │ │ ├── icon.png
│ │ │ │ ├── layer.css
│ │ │ │ ├── loading-0.gif
│ │ │ │ ├── loading-1.gif
│ │ │ │ └── loading-2.gif
│ │ │ ├── layui
│ │ │ │ ├── css
│ │ │ │ │ ├── layui.css
│ │ │ │ │ ├── layui.mobile.css
│ │ │ │ │ └── modules
│ │ │ │ │ ├── code.css
│ │ │ │ │ ├── laydate
│ │ │ │ │ │ └── default
│ │ │ │ │ │ └── laydate.css
│ │ │ │ │ └── layer
│ │ │ │ │ └── default
│ │ │ │ │ ├── icon-ext.png
│ │ │ │ │ ├── icon.png
│ │ │ │ │ ├── layer.css
│ │ │ │ │ ├── loading-0.gif
│ │ │ │ │ ├── loading-1.gif
│ │ │ │ │ └── loading-2.gif
│ │ │ │ ├── font
│ │ │ │ │ ├── iconfont.eot
│ │ │ │ │ ├── iconfont.svg
│ │ │ │ │ ├── iconfont.ttf
│ │ │ │ │ ├── iconfont.woff
│ │ │ │ │ └── iconfont.woff2
│ │ │ │ ├── lay
│ │ │ │ │ └── modules
│ │ │ │ │ ├── carousel.js
│ │ │ │ │ ├── code.js
│ │ │ │ │ ├── colorpicker.js
│ │ │ │ │ ├── element.js
│ │ │ │ │ ├── flow.js
│ │ │ │ │ ├── form.js
│ │ │ │ │ ├── jquery.js
│ │ │ │ │ ├── laydate.js
│ │ │ │ │ ├── layedit.js
│ │ │ │ │ ├── layer.js
│ │ │ │ │ ├── laypage.js
│ │ │ │ │ ├── laytpl.js
│ │ │ │ │ ├── mobile.js
│ │ │ │ │ ├── rate.js
│ │ │ │ │ ├── slider.js
│ │ │ │ │ ├── table.js
│ │ │ │ │ ├── transfer.js
│ │ │ │ │ ├── tree.js
│ │ │ │ │ ├── upload.js
│ │ │ │ │ └── util.js
│ │ │ │ ├── layui.all.js
│ │ │ │ └── layui.js
│ │ │ ├── moment
│ │ │ │ ├── moment-with-locales.min.js
│ │ │ │ └── moment.min.js
│ │ │ ├── overlayScrollbars
│ │ │ │ ├── css
│ │ │ │ │ └── OverlayScrollbars.min.css
│ │ │ │ └── js
│ │ │ │ └── OverlayScrollbars.min.js
│ │ │ ├── paste
│ │ │ │ └── paste.min.js
│ │ │ ├── select2
│ │ │ │ ├── css
│ │ │ │ │ ├── select2-bootstrap4.min.css
│ │ │ │ │ └── select2.min.css
│ │ │ │ └── js
│ │ │ │ ├── i18n
│ │ │ │ │ ├── build.txt
│ │ │ │ │ ├── en.js
│ │ │ │ │ ├── zh-CN.js
│ │ │ │ │ └── zh-TW.js
│ │ │ │ ├── select2.full.min.js
│ │ │ │ └── select2.min.js
│ │ │ ├── summernote
│ │ │ │ ├── css
│ │ │ │ │ └── summernote-bs4.min.css
│ │ │ │ ├── font
│ │ │ │ │ ├── summernote.eot
│ │ │ │ │ ├── summernote.ttf
│ │ │ │ │ ├── summernote.woff
│ │ │ │ │ └── summernote.woff2
│ │ │ │ ├── js
│ │ │ │ │ └── summernote-bs4.min.js
│ │ │ │ └── lang
│ │ │ │ └── summernote-zh-CN.min.js
│ │ │ └── tempusdominus-bootstrap-4
│ │ │ ├── css
│ │ │ │ └── tempusdominus-bootstrap-4.min.css
│ │ │ └── js
│ │ │ └── tempusdominus-bootstrap-4.min.js
│ │ └── common
│ │ ├── css
│ │ │ └── base.css
│ │ ├── images
│ │ │ ├── 401.gif
│ │ │ ├── 404.png
│ │ │ ├── 404_cloud.png
│ │ │ ├── addpic.jpg
│ │ │ ├── admineap.png
│ │ │ ├── avatar.jpg
│ │ │ └── btnclose.png
│ │ └── js
│ │ ├── base-form.js
│ │ ├── base-modal.js
│ │ ├── base-render.js
│ │ ├── base.js
│ │ └── dataTables.js
│ ├── favicon.ico
│ ├── index.php
│ ├── robots.txt
│ ├── storage -> /Users/lyc/Sites/gsadmin.ggxx/storage/app/public
│ ├── vendor
│ │ ├── horizon
│ │ │ ├── app-dark.css
│ │ │ ├── app.css
│ │ │ ├── app.js
│ │ │ ├── img
│ │ │ │ ├── favicon.png
│ │ │ │ ├── horizon.svg
│ │ │ │ └── sprite.svg
│ │ │ └── mix-manifest.json
│ │ └── telescope
│ │ ├── app-dark.css
│ │ ├── app.css
│ │ ├── app.js
│ │ ├── favicon.ico
│ │ └── mix-manifest.json
│ └── web.config
:::
改造后的文件结构(Git)
├── public
│ ├── .htaccess
│ ├── favicon.ico
│ ├── favicon.ico
│ ├── index.php
│ ├── robots.txt
│ ├── web.config
├── resources
│ ├── js
│ │ ├── app.js
│ │ ├── bootstrap.js
│ │ └── util.js
│ ├── sass
│ │ └── app.scss
│ └── views
├── package.json
├── package-lock.json
└── webpack.mix.js
改造过程
移除asset
将public目录下的asset全部移出版本控制库,并在.gitignore
中添加忽略。
使用npm引入依赖
在npm仓库中找到之前用到的所有组件,例如admin-lte
npm i admin-lte -D
整理后的package.json
:::details Click to see more
{
"private": true,
"scripts": {
"dev": "npm run development",
"development": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js",
"watch": "npm run development -- --watch",
"watch-poll": "npm run watch -- --watch-poll",
"hot": "cross-env NODE_ENV=development node_modules/webpack-dev-server/bin/webpack-dev-server.js --inline --hot --disable-host-check --config=node_modules/laravel-mix/setup/webpack.config.js",
"prod": "npm run production",
"production": "cross-env NODE_ENV=production node_modules/webpack/bin/webpack.js --no-progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js"
},
"devDependencies": {
"@fortawesome/fontawesome-free": "^5.15.1",
"@ttskch/select2-bootstrap4-theme": "^1.3.4",
"admin-lte": "^3.0.5",
"axios": "^0.19",
"bootstrap": "^4.5.3",
"bootstrap-iconpicker-latest": "^1.12.0",
"bower": "^1.8.8",
"browser-sync": "^2.26.12",
"browser-sync-webpack-plugin": "^2.0.1",
"cross-env": "^7.0",
"datatables.net": "^1.10.22",
"datatables.net-bs4": "^1.10.22",
"datatables.net-buttons": "^1.6.5",
"datatables.net-buttons-bs4": "^1.6.5",
"datatables.net-fixedcolumns": "^3.3.2",
"datatables.net-fixedcolumns-bs4": "^3.3.2",
"datatables.net-fixedheader": "^3.1.7",
"datatables.net-fixedheader-bs4": "^3.1.7",
"datatables.net-responsive": "^2.2.6",
"datatables.net-responsive-bs4": "^2.2.6",
"datatables.net-select-bs4": "^1.3.1",
"daterangepicker": "^3.1.0",
"distpicker": "^2.0.6",
"echarts": "^4.9.0",
"icheck": "^1.0.2",
"ionicons": "^2.0.1",
"jquery": "^3.5.1",
"jquery-datatables-checkboxes": "^1.2.12",
"jquery-jsonview": "^1.2.3",
"jquery-validation": "^1.19.2",
"jszip": "^3.5.0",
"laravel-mix": "^5.0.7",
"layui-src": "^2.5.5",
"lodash": "^4.17.19",
"moment": "^2.29.1",
"overlayscrollbars": "^1.13.0",
"paste.js": "0.0.21",
"pc-bootstrap4-datetimepicker": "^4.17.51",
"resolve-url-loader": "^3.1.0",
"sass": "^1.15.2",
"sass-loader": "^8.0.0",
"sc-bootstrap-treeview": "^1.2.4",
"select2": "^4.0.13",
"summernote": "^0.8.18",
"toastr": "^2.1.4",
"vue-template-compiler": "^2.6.12"
},
"dependencies": {
}
}
:::
维护asset主文件
建议使用IDE编辑,路径会有跳转提示,不容易出错。
bootstrap.js
window._ = require('lodash');
/**
* We'll load jQuery and the Bootstrap jQuery plugin which provides support
* for JavaScript based Bootstrap features such as modals and tabs. This
* code may be modified to fit the specific needs of your application.
*/
window.$ = window.jQuery = require('jquery');
window.moment = require('moment');
window.echarts = require('echarts');
window.toastr = require('toastr');
window.JSZip = require('jszip');
require('bootstrap/dist/js/bootstrap.bundle');
require('admin-lte');
require('datatables.net');
require('datatables.net-bs4');
require('datatables.net-responsive');
require('datatables.net-responsive-bs4');
require('datatables.net-fixedheader');
require('datatables.net-fixedheader-bs4');
require('datatables.net-fixedcolumns');
require('datatables.net-fixedcolumns-bs4');
require('datatables.net-buttons');
require('datatables.net-buttons/js/buttons.colVis.min');
require('datatables.net-buttons/js/buttons.flash.min');
require('datatables.net-buttons/js/buttons.html5.min');
require('datatables.net-buttons-bs4');
require('@fortawesome/fontawesome-free/js/all');
require('select2/dist/js/select2.full');
require('jquery-validation');
require('jquery-validation/dist/localization/messages_zh');
require('jquery-jsonview');
require('jquery-datatables-checkboxes');
require('bootstrap-iconpicker-latest/dist/js/bootstrap-iconpicker.bundle.min');
require('pc-bootstrap4-datetimepicker');
require('daterangepicker');
require('distpicker');
require('sc-bootstrap-treeview/dist/bootstrap-treeview.min');
require('icheck');
require('summernote');
require('paste.js');
require('overlayscrollbars');
/**
* We'll load the axios HTTP library which allows us to easily issue requests
* to our Laravel back-end. This library automatically handles sending the
* CSRF token as a header based on the value of the "XSRF" token cookie.
*/
window.axios = require('axios');
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
let token = document.head.querySelector('meta[name="csrf-token"]');
if (token) {
window.axios.defaults.headers.common['X-CSRF-TOKEN'] = token.content;
}
app.scss
@import "~admin-lte/build/scss/AdminLTE";
@import "~datatables.net-bs4/css/dataTables.bootstrap4.css";
@import "~datatables.net-select-bs4/css/select.bootstrap4.css";
@import "~datatables.net-responsive-bs4/css/responsive.bootstrap4.css";
@import "~datatables.net-fixedheader-bs4/css/fixedHeader.bootstrap4.css";
@import "~datatables.net-fixedcolumns-bs4/css/fixedColumns.bootstrap4.css";
@import "~datatables.net-buttons-bs4/css/buttons.bootstrap4.css";
@import "~select2/dist/css/select2.css";
@import "~@ttskch/select2-bootstrap4-theme/dist/select2-bootstrap4.css";
@import "~pc-bootstrap4-datetimepicker/build/css/bootstrap-datetimepicker.css";
@import "~bootstrap-iconpicker-latest/dist/css/bootstrap-iconpicker.css";
@import "~sc-bootstrap-treeview/dist/bootstrap-treeview.min.css";
@import "~jquery-jsonview/dist/jquery.jsonview.css";
@import "~daterangepicker/daterangepicker.css";
@import "~icheck/skins/all.css";
@import "~summernote/dist/summernote-bs4.css";
@import "~layui-src/dist/css/layui.css";
@import "~ionicons/css/ionicons.css";
@import "~overlayscrollbars/css/OverlayScrollbars.css";
@import "~toastr/build/toastr.css";
// 以下是自定义的样式
:::warning
正常来说,这里时不需要编译adminlte的scss,可以直接引入css,加快编译速度。但是不这样做的话,我后续做自动化构建会遇到问题)
:::
维护构建配置
我这里将两个composer组件horizon
、telescope
的asset做了复制目录动作,也就不需要用版本控制维护。如果做自动构建,要记得先安装composer组件再执行构建。
const mix = require('laravel-mix');
/*
|--------------------------------------------------------------------------
| Mix Asset Management
|--------------------------------------------------------------------------
|
| Mix provides a clean, fluent API for defining some Webpack build steps
| for your Laravel application. By default, we are compiling the Sass
| file for the application as well as bundling up all the JS files.
|
*/
mix.copy('node_modules/admin-lte/dist/img/AdminLTELogo.png', 'public/images/vendor/admin-lte/AdminLTELogo.png')
.copyDirectory('vendor/laravel/horizon/public', 'public/vendor/horizon')
.copyDirectory('vendor/laravel/telescope/public', 'public/vendor/telescope')
.copyDirectory('node_modules/layui-src/dist', 'public/vendor/layui');
mix.js('resources/js/app.js', 'public/js')
.scripts([
'resources/js/util.js',
], 'public/js/util.js')
.sass('resources/sass/app.scss', 'public/css')
.extract()
.version();
if (mix.inProduction()) {
mix.disableNotifications();
}
:::warning
截至发文时间,layui的结构还不能很好的支持webpack打包[issue],所以这里直接复制了目录,视图中单独引入。
:::
执行构建
npm i
npm run prod
引入构建后的文件
index.blade.php
<link rel="shortcut icon" type="image/x-icon" href="{{asset('images/vendor/admin-lte/AdminLTELogo.png')}}"
media="screen"/>
<link rel="stylesheet" href="{{mix('css/app.css')}}">
...
<script src="{{mix('/js/manifest.js')}}"></script>
<script src="{{mix('/js/vendor.js')}}"></script>
<script src="{{mix('js/app.js')}}"></script>
<!-- LayUI 注意是LayUI的结构不规范导致需要单独引入 其他组件请使用Laravel-mix维护 -->
<script src="{{asset('vendor/layui/layui.all.js')}}"></script>
<!-- common 工具类组件单独引入方便调用 -->
<script src="{{asset('js/util.js')}}"></script>
...
Laravel 接收PUT/PATCH/DELETE请求的值
cover: https://img-medianova.mncdn.com/wp-content/uploads/sites/8/2020/01/laravel-1130x356.png
最近在调试PUT
接口时,控制器中Request
对象无法正常获取表单内容,发现这是Symfony组件的经典问题。
What
当时的场景是前端ajax使用PUT方式提交了一个multipart/form-data
的请求,但是Request
对象无法使用get()
获取到数据。
Why
Laravel的Illuminate\Http\Request
对象继承是Symfony\Component\HttpFoundation\Request
,在获取请求表单内容的代码如下[1]:
public static function createFromGlobals()
{
$request = self::createRequestFromFactory($_GET, $_POST, [], $_COOKIE, $_FILES, $_SERVER);
if (0 === strpos($request->headers->get('CONTENT_TYPE'), 'application/x-www-form-urlencoded')
&& \in_array(strtoupper($request->server->get('REQUEST_METHOD', 'GET')), ['PUT', 'DELETE', 'PATCH'])
) {
parse_str($request->getContent(), $data);
$request->request = new ParameterBag($data);
}
return $request;
}
由于PHP原生只支持$_GET
、$_POST
,所以PUT
、PATCH
、DELETE
的表单内容要从$request->getContent()
中解析,但这里进行解析有一个条件是请求头Content-Type
为application/x-www-form-urlencoded
,所以当我使用multipart/form-data
的Content-Type
进行PUT
请求时,Request
对象就无法正常解析。
How
解决方案有以下三个:
请求前
- 将
Content-Type
设置为application/x-www-form-urlencoded
- 覆盖请求头
method
: 将请求头method
设为POST
,同时将请求头X-HTTP-METHOD-OVERRIDE
或_method
(url参数或请求体皆可)设为PUT
/PATCH
/DELETE
。
Laravel官方给出的建议方案[[2](https://laravel.com/docs/5.2/helpers#method-method-field)]也是方案二中的后者。
if (! function_exists('method_field')) {
/**
* Generate a form field to spoof the HTTP verb used by forms.
*
* @param string $method
* @return \Illuminate\Support\HtmlString
*/
function method_field($method)
{
return new HtmlString('<input type="hidden" name="_method" value="'.$method.'">');
}
}
Symfony包中默认是禁用$httpMethodParameterOverride
,而Laravel中默认开启。禁用时,只能通过请求头X-HTTP-METHOD-OVERRIDE
覆盖。开启时,覆盖的优先级为:请求头X-HTTP-METHOD-OVERRIDE
> 请求体_method
> url参数_method
> 请求头method
[3]
public function getMethod()
{
if (null === $this->method) {
$this->method = strtoupper($this->server->get('REQUEST_METHOD', 'GET'));
if ('POST' === $this->method) {
if ($method = $this->headers->get('X-HTTP-METHOD-OVERRIDE')) {
$this->method = strtoupper($method);
} elseif (self::$httpMethodParameterOverride) {
$this->method = strtoupper($this->request->get('_method', $this->query->get('_method', 'POST')));
}
}
}
return $this->method;
}
请求后
- 继承Request对象补充对
multipart/form-data
类型的内容解析
Reference
https://learnku.com/laravel/t/14028/how-does-laravel-put-receive-values#reply56870
Laravel-Websockets组件实践
cover: https://laravelnews.imgix.net/images/laravel-websockets.png?ixlib=php-3.3.0
feature: true
给Laravel项目搭建Websocket服务时,不想用pusher
这种在线服务? 觉得用laravel-echo-server
监听事件不方便? 不想用swoole
自己实现? 那你可以考虑Laravel官方推荐的laravel-websockets
组件。
该组件有以下优点:
- 免费
- 使用Pusher驱动
- 方便继承扩展,实现流程控制
引入
composer require "beyondcode/laravel-websockets:^2.0"
composer require digitaltolk/multiple-broadcaster
:::tip
1.x版本存在一些问题,扩展的时候有一些麻烦,组件作者也是推荐2.x版本
:::
安装
php artisan vendor:publish --provider="BeyondCode\LaravelWebSockets\WebSocketsServiceProvider" --tag="migrations"
php artisan migrate
php artisan vendor:publish --provider="BeyondCode\LaravelWebSockets\WebSocketsServiceProvider" --tag="config"
自定义
需求
- 后台用户和普通用户的服务互不干扰
- 监听普通用户的行为
事件
创建几个基础事件。
UserEnterChannel
: 用户订阅channelUserExitChannel
: 用户取消订阅channelUserAppBackground
: 用户APP切换至后台UserAppForeground
: 用户APP切换至前台台
继承BeyondCode\LaravelWebSockets\Server\WebSocketHandler
,监听客户端消息,触发UserAppBackground
、UserAppForeground
事件。
:::warning
注意WebSocketHandler@onOpen($connection)中可以获取的信息非常有限,无法识别出用户。组件自带的事件NewConnection和ConnectionClosed也只有appId和socketId。
:::
:::details Click to see more
<?php
namespace App\Services\WebSockets\Server;
use App\Events\UserAppBackground;
use App\Events\UserAppForeground;
use App\Services\WebSockets\ChannelManagers\LocalChannelManager;
use BeyondCode\LaravelWebSockets\Server\WebSocketHandler as BaseWebSocketHandler;
use Ratchet\ConnectionInterface;
use Exception;
use Ratchet\RFC6455\Messaging\MessageInterface;
/**
* Class Handler
* @package App\WebSockets
* @link
*/
class WebSocketHandler extends BaseWebSocketHandler
{
/**
* The channel manager.
*
* @var LocalChannelManager
*/
protected $channelManager;
/**
* Handle the socket opening.
*
* @param \Ratchet\ConnectionInterface $connection
* @return void
* @throws \Exception
*/
public function onOpen(ConnectionInterface $connection)
{
parent::onOpen($connection);
}
/**
* Handle the websocket close.
*
* @param \Ratchet\ConnectionInterface $connection
* @return void
*/
public function onClose(ConnectionInterface $connection)
{
parent::onClose($connection);
}
/**
* Handle the websocket errors.
*
* @param \Ratchet\ConnectionInterface $connection
* @param Exception $exception
* @return void
*/
public function onError(ConnectionInterface $connection, Exception $exception)
{
parent::onError($connection, $exception);
}
/**
* Handle the incoming message.
*
* @param \Ratchet\ConnectionInterface $connection
* @param \Ratchet\RFC6455\Messaging\MessageInterface $message
* @return void
*/
public function onMessage(ConnectionInterface $connection, MessageInterface $message)
{
$payload = json_decode($message->getPayload());
if ($payload->event == 'client-app.background') {
$user = getUserByChannel($payload->channel);
UserAppBackground::dispatch($user->id, $user->gid);
} elseif ($payload->event == 'client-app.foreground') {
$user = getUserByChannel($payload->channel);
UserAppForeground::dispatch($user->id, $user->gid);
}
parent::onMessage($connection, $message);
}
}
:::
频道
主要改写PrivateChanel
,触发UserEnterChannel
和UserExitChannel
事件。
:::details Click to see more
<?php
namespace App\Services\WebSockets\Channels;
use App\Events\UserEnterChannel;
use App\Events\UserExitChannel;
use App\Services\WebSockets\ChannelManagers\LocalChannelManager;
use BeyondCode\LaravelWebSockets\Channels\PrivateChannel as BasePrivateChannel;
use BeyondCode\LaravelWebSockets\DashboardLogger;
use BeyondCode\LaravelWebSockets\Events\SubscribedToChannel;
use BeyondCode\LaravelWebSockets\Events\UnsubscribedFromChannel;
use BeyondCode\LaravelWebSockets\Server\Exceptions\InvalidSignature;
use Ratchet\ConnectionInterface;
use stdClass;
class PrivateChannel extends BasePrivateChannel
{
/**
* @var LocalChannelManager
*/
protected $channelManager;
/**
* Subscribe to the channel.
*
* @see https://pusher.com/docs/pusher_protocol#presence-channel-events
* @param \Ratchet\ConnectionInterface $connection
* @param \stdClass $payload
* @return bool
* @throws InvalidSignature
*/
public function subscribe(ConnectionInterface $connection, stdClass $payload): bool
{
$this->verifySignature($connection, $payload);
// 针对特殊频道
if (isUserChannel($payload->channel)) {
// 识别用户
$user = getUserByChannel($payload->channel);
// 触发事件
UserEnterChannel::dispatch($user->id, $user->gid);
// 保存在成员数组中
$this->channelManager
->userJoinedPrivateChannel($connection, $user, $this->getName(), $payload)
->then(function ($users) use ($connection) {
$connection->send(json_encode([
'event' => 'pusher_internal:subscription_succeeded',
'channel' => $this->getName(),
'data' => '[]',
]));
})
->then(function () use ($connection, $user, $payload) {
// The `pusher_internal:member_added` event is triggered when a user joins a channel.
// It's quite possible that a user can have multiple connections to the same channel
// (for example by having multiple browser tabs open)
// and in this case the events will only be triggered when the first tab is opened.
$this->channelManager
->getMemberSockets($user->id, $connection->app->id, $this->getName())
->then(function ($sockets) use ($payload, $connection, $user) {
if (count($sockets) === 1) {
$memberAddedPayload = [
'event' => 'pusher_internal:member_added',
'channel' => $this->getName(),
'data' => $payload->channel_data,
];
$this->broadcastToEveryoneExcept(
(object) $memberAddedPayload,
$connection->socketId,
$connection->app->id
);
SubscribedToChannel::dispatch(
$connection->app->id,
$connection->socketId,
$this->getName(),
$user
);
}
DashboardLogger::log($connection->app->id, DashboardLogger::TYPE_SUBSCRIBED, [
'socketId' => $connection->socketId,
'channel' => $this->getName(),
'duplicate-connection' => count($sockets) > 1,
]);
});
});
}
return parent::subscribe($connection, $payload);
}
/**
* Unsubscribe connection from the channel.
*
* @param \Ratchet\ConnectionInterface $connection
* @return bool
*/
public function unsubscribe(ConnectionInterface $connection): bool
{
$truth = parent::unsubscribe($connection);
$this->channelManager
->getChannelUser($connection, $this->getName())
->then(function ($user) {
$user = @json_decode($user);
if ($user) {
// 触发事件
UserExitChannel::dispatch($user->id, $user->gid);
}
return $user;
})
->then(function ($user) use ($connection) {
if (! $user) {
return;
}
$this->channelManager
->userLeftPrivateChannel($connection, $user, $this->getName())
->then(function () use ($connection, $user) {
// The `pusher_internal:member_removed` is triggered when a user leaves a channel.
// It's quite possible that a user can have multiple connections to the same channel
// (for example by having multiple browser tabs open)
// and in this case the events will only be triggered when the last one is closed.
$this->channelManager
->getMemberSockets($user->id, $connection->app->id, $this->getName())
->then(function ($sockets) use ($connection, $user) {
if (count($sockets) === 0) {
$memberRemovedPayload = [
'event' => 'pusher_internal:member_removed',
'channel' => $this->getName(),
'data' => json_encode([
'uid' => $user->id,
]),
];
$this->broadcastToEveryoneExcept(
(object) $memberRemovedPayload,
$connection->socketId,
$connection->app->id
);
UnsubscribedFromChannel::dispatch(
$connection->app->id,
$connection->socketId,
$this->getName(),
$user
);
}
});
});
});
return $truth;
}
}
:::
涉及的几个助手函数
:::details Click to see more
<?php
if (!function_exists('getUserChannelPrefix')) {
/**
* 获取玩家的频道前缀
*
* @return string
*/
function getUserChannelPrefix(): string
{
return 'private-user.';
}
}
if (!function_exists('isUserChannel')) {
/**
* 判断是否为玩家用户的频道
*
* @param string $channel
* @return bool
*/
function isUserChannel(string $channel): bool
{
$userChannelPrefix = getUserChannelPrefix();
$isUserChannel = Str::startsWith($channel, $userChannelPrefix);
return $isUserChannel;
}
}
if (!function_exists('getIdsByChannel')) {
/**
* 根据玩家频道名获取各ID
*
* @param string $channel
* @return stdClass
*/
function getUserByChannel(string $channel): stdClass
{
$userChannelPrefix = getUserChannelPrefix();
$idString = str_replace($userChannelPrefix, '', $channel);
list($uid, $gid) = explode('.', $idString);
$user = new stdClass();
$user->id = $uid;
$user->gid = $gid;
return $user;
}
}
:::
继承BeyondCode\LaravelWebSockets\ChannelManagers\LocalChannelManager
,实现userJoinedPrivateChannel
、userLeftPrivateChannel
、getChannelUser
三个方法,在PrivateChannel
中使用。
:::details Click to see more
<?php
namespace App\Services\WebSockets\ChannelManagers;
use App\Services\WebSockets\Channels\Channel;
use App\Services\WebSockets\Channels\PresenceChannel;
use App\Services\WebSockets\Channels\PrivateChannel;
use BeyondCode\LaravelWebSockets\ChannelManagers\LocalChannelManager as BaseLocalChannelManager;
use BeyondCode\LaravelWebSockets\Helpers;
use Illuminate\Support\Str;
use Ratchet\ConnectionInterface;
use stdClass;
use React\Promise\PromiseInterface;
class LocalChannelManager extends BaseLocalChannelManager
{
/**
* The list of users that joined the presence channel or private channel.
*
* private channel: key => UserObject
* presence channel: key => [ socketId => UserObject ]
* @var array
*/
protected $users = [];
/**
* The list of users by socket and their attached id.
*
* @var array
*/
protected $userSockets = [];
/**
* Get the channel class by the channel name.
*
* @param string $channelName
* @return string
*/
protected function getChannelClassName(string $channelName): string
{
if (Str::startsWith($channelName, 'private-')) {
return PrivateChannel::class;
}
if (Str::startsWith($channelName, 'presence-')) {
return PresenceChannel::class;
}
return Channel::class;
}
/**
* Get a member from a private channel based on connection.
*
* @param \Ratchet\ConnectionInterface $connection
* @param string $channel
* @return \React\Promise\PromiseInterface
*/
public function getChannelUser(ConnectionInterface $connection, string $channel): PromiseInterface
{
$user = $this->users["{$connection->app->id}:{$channel}"] ?? null;
return Helpers::createFulfilledPromise($user);
}
/**
* Handle the user when it joined a private channel.
*
* @param \Ratchet\ConnectionInterface $connection
* @param stdClass $user
* @param string $channel
* @param stdClass $payload
* @return PromiseInterface[bool]
*/
public function userJoinedPrivateChannel(ConnectionInterface $connection, stdClass $user, string $channel, stdClass $payload): PromiseInterface
{
$this->users["{$connection->app->id}:{$channel}"] = json_encode($user);
$this->userSockets["{$connection->app->id}:{$channel}:{$user->id}"][] = $connection->socketId;
used($payload);
return Helpers::createFulfilledPromise(true);
}
/**
* Handle the user when it left a presence channel.
*
* @param \Ratchet\ConnectionInterface $connection
* @param stdClass $user
* @param string $channel
* @return PromiseInterface[bool]
*/
public function userLeftPrivateChannel(ConnectionInterface $connection, stdClass $user, string $channel): PromiseInterface
{
unset($this->users["{$connection->app->id}:{$channel}"]);
$deletableSocketKey = array_search(
$connection->socketId,
$this->userSockets["{$connection->app->id}:{$channel}:{$user->id}"]
);
if ($deletableSocketKey !== false) {
unset($this->userSockets["{$connection->app->id}:{$channel}:{$user->id}"][$deletableSocketKey]);
if (count($this->userSockets["{$connection->app->id}:{$channel}:{$user->id}"]) === 0) {
unset($this->userSockets["{$connection->app->id}:{$channel}:{$user->id}"]);
}
}
return Helpers::createFulfilledPromise(true);
}
}
:::
配置
修改websockets.php,使用继承后的类,并配置多app。
:::details Click to see more
<?php
return [
/*
|--------------------------------------------------------------------------
| Dashboard Settings
|--------------------------------------------------------------------------
|
| You can configure the dashboard settings from here.
|
*/
'dashboard' => [
'port' => env('LARAVEL_WEBSOCKETS_PORT', 6001),
'domain' => env('LARAVEL_WEBSOCKETS_DOMAIN'),
'path' => env('LARAVEL_WEBSOCKETS_PATH', 'websockets'),
'middleware' => [
'web',
\BeyondCode\LaravelWebSockets\Dashboard\Http\Middleware\Authorize::class,
],
],
'managers' => [
/*
|--------------------------------------------------------------------------
| Application Manager
|--------------------------------------------------------------------------
|
| An Application manager determines how your websocket server allows
| the use of the TCP protocol based on, for example, a list of allowed
| applications.
| By default, it uses the defined array in the config file, but you can
| anytime implement the same interface as the class and add your own
| custom method to retrieve the apps.
|
*/
'app' => \BeyondCode\LaravelWebSockets\Apps\ConfigAppManager::class,
],
/*
|--------------------------------------------------------------------------
| Applications Repository
|--------------------------------------------------------------------------
|
| By default, the only allowed app is the one you define with
| your PUSHER_* variables from .env.
| You can configure to use multiple apps if you need to, or use
| a custom App Manager that will handle the apps from a database, per se.
|
| You can apply multiple settings, like the maximum capacity, enable
| client-to-client messages or statistics.
|
*/
'apps' => [
[
'id' => 'admin-app-id',
'name' => 'ADMIN',
'host' => null,
'key' => 'admin-app-key',
'secret' => 'admin-app-secret',
'path' => null,
'capacity' => null,
'enable_client_messages' => true,
'enable_statistics' => true,
'allowed_origins' => [
// env('LARAVEL_WEBSOCKETS_DOMAIN_ADMIN'),
],
],
[
'id' => 'api-app-id',
'name' => 'API',
'host' => null,
'key' => 'api-app-key',
'secret' => 'api-app-secret',
'path' => null,
'capacity' => null,
'enable_client_messages' => true,
'enable_statistics' => true,
'allowed_origins' => [
// env('LARAVEL_WEBSOCKETS_DOMAIN'),
],
],
],
/*
|--------------------------------------------------------------------------
| Broadcasting Replication PubSub
|--------------------------------------------------------------------------
|
| You can enable replication to publish and subscribe to
| messages across the driver.
|
| By default, it is set to 'local', but you can configure it to use drivers
| like Redis to ensure connection between multiple instances of
| WebSocket servers. Just set the driver to 'redis' to enable the PubSub using Redis.
|
*/
'replication' => [
'mode' => 'local',
'modes' => [
/*
|--------------------------------------------------------------------------
| Local Replication
|--------------------------------------------------------------------------
|
| Local replication is actually a null replicator, meaning that it
| is the default behaviour of storing the connections into an array.
|
*/
'local' => [
/*
|--------------------------------------------------------------------------
| Channel Manager
|--------------------------------------------------------------------------
|
| The channel manager is responsible for storing, tracking and retrieving
| the channels as long as their members and connections.
|
*/
'channel_manager' => \App\Services\WebSockets\ChannelManagers\LocalChannelManager::class,
/*
|--------------------------------------------------------------------------
| Statistics Collector
|--------------------------------------------------------------------------
|
| The Statistics Collector will, by default, handle the incoming statistics,
| storing them until they will become dumped into another database, usually
| a MySQL database or a time-series database.
|
*/
'collector' => \BeyondCode\LaravelWebSockets\Statistics\Collectors\MemoryCollector::class,
],
'redis' => [
'connection' => env('WEBSOCKETS_REDIS_REPLICATION_CONNECTION', 'default'),
/*
|--------------------------------------------------------------------------
| Channel Manager
|--------------------------------------------------------------------------
|
| The channel manager is responsible for storing, tracking and retrieving
| the channels as long as their members and connections.
|
*/
'channel_manager' => BeyondCode\LaravelWebSockets\ChannelManagers\RedisChannelManager::class,
/*
|--------------------------------------------------------------------------
| Statistics Collector
|--------------------------------------------------------------------------
|
| The Statistics Collector will, by default, handle the incoming statistics,
| storing them until they will become dumped into another database, usually
| a MySQL database or a time-series database.
|
*/
'collector' => \BeyondCode\LaravelWebSockets\Statistics\Collectors\RedisCollector::class,
],
],
],
'statistics' => [
/*
|--------------------------------------------------------------------------
| Statistics Store
|--------------------------------------------------------------------------
|
| The Statistics Store is the place where all the temporary stats will
| be dumped. This is a much reliable store and will be used to display
| graphs or handle it later on your app.
|
*/
'store' => \BeyondCode\LaravelWebSockets\Statistics\Stores\DatabaseStore::class,
/*
|--------------------------------------------------------------------------
| Statistics Interval Period
|--------------------------------------------------------------------------
|
| Here you can specify the interval in seconds at which
| statistics should be logged.
|
*/
'interval_in_seconds' => 60,
/*
|--------------------------------------------------------------------------
| Statistics Deletion Period
|--------------------------------------------------------------------------
|
| When the clean-command is executed, all recorded statistics older than
| the number of days specified here will be deleted.
|
*/
'delete_statistics_older_than_days' => 60,
],
/*
|--------------------------------------------------------------------------
| Maximum Request Size
|--------------------------------------------------------------------------
|
| The maximum request size in kilobytes that is allowed for
| an incoming WebSocket request.
|
*/
'max_request_size_in_kb' => 250,
/*
|--------------------------------------------------------------------------
| SSL Configuration
|--------------------------------------------------------------------------
|
| By default, the configuration allows only on HTTP. For SSL, you need
| to set up the the certificate, the key, and optionally, the passphrase
| for the private key.
| You will need to restart the server for the settings to take place.
|
*/
'ssl' => [
'local_cert' => env('LARAVEL_WEBSOCKETS_SSL_LOCAL_CERT', null),
'capath' => env('LARAVEL_WEBSOCKETS_SSL_CA', null),
'local_pk' => env('LARAVEL_WEBSOCKETS_SSL_LOCAL_PK', null),
'passphrase' => env('LARAVEL_WEBSOCKETS_SSL_PASSPHRASE', null),
'verify_peer' => env('APP_ENV') === 'production',
'allow_self_signed' => env('APP_ENV') !== 'production',
],
/*
|--------------------------------------------------------------------------
| Route Handlers
|--------------------------------------------------------------------------
|
| Here you can specify the route handlers that will take over
| the incoming/outgoing websocket connections. You can extend the
| original class and implement your own logic, alongside
| with the existing logic.
|
*/
'handlers' => [
'websocket' => \App\Services\WebSockets\Server\WebSocketHandler::class,
'health' => \BeyondCode\LaravelWebSockets\Server\HealthHandler::class,
'trigger_event' => \BeyondCode\LaravelWebSockets\API\TriggerEvent::class,
'fetch_channels' => \BeyondCode\LaravelWebSockets\API\FetchChannels::class,
'fetch_channel' => \BeyondCode\LaravelWebSockets\API\FetchChannel::class,
'fetch_users' => \BeyondCode\LaravelWebSockets\API\FetchUsers::class,
],
/*
|--------------------------------------------------------------------------
| Promise Resolver
|--------------------------------------------------------------------------
|
| The promise resolver is a class that takes a input value and is
| able to make sure the PHP code runs async by using ->then(). You can
| use your own Promise Resolver. This is usually changed when you want to
| intercept values by the promises throughout the app, like in testing
| to switch from async to sync.
|
*/
'promise_resolver' => \React\Promise\FulfilledPromise::class,
];
:::
修改broadcasting.php,以实现同时在两个app中进行广播
:::details Click to see more
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Broadcaster
|--------------------------------------------------------------------------
|
| This option controls the default broadcaster that will be used by the
| framework when an event needs to be broadcast. You may set this to
| any of the connections defined in the "connections" array below.
|
| Supported: "pusher", "redis", "log", "null"
|
*/
'default' => env('BROADCAST_DRIVER', 'null'),
/*
|--------------------------------------------------------------------------
| Broadcast Connections
|--------------------------------------------------------------------------
|
| Here you may define all of the broadcast connections that will be used
| to broadcast events to other systems or over websockets. Samples of
| each available type of connection are provided inside this array.
|
*/
'connections' => [
'pusher' => [
'driver' => 'multiple',
'connections' => ['pusher-for-admin', 'pusher-for-api'],
'options' => [
'cluster' => 'mt1',
'useTLS' => true,
'encrypted' => true,
'host' => '127.0.0.1',
'port' => 6001,
'scheme' => 'http',
],
],
'pusher-for-admin' => [
'driver' => 'pusher',
'key' => 'admin-app-key',
'secret' => 'admin-app-secret',
'app_id' => 'admin-app-id',
'options' => [
'cluster' => 'mt1',
'useTLS' => true,
'encrypted' => true,
'host' => '127.0.0.1',
'port' => 6001,
'scheme' => 'http',
],
],
'pusher-for-api' => [
'driver' => 'pusher',
'key' => 'api-app-key',
'secret' => 'api-app-secret',
'app_id' => 'api-app-id',
'options' => [
'cluster' => 'mt1',
'useTLS' => true,
'encrypted' => true,
'host' => '127.0.0.1',
'port' => 6001,
'scheme' => 'http',
],
],
'redis' => [
'driver' => 'redis',
'connection' => 'default',
],
'log' => [
'driver' => 'log',
],
'null' => [
'driver' => 'null',
],
],
];
:::
最后将ENV文件中的BROADCAST_DRIVER
改为pusher即可。
启动
php artisan websockets:serve
客户端接入
后台Nodejs
import Echo from 'laravel-echo';
window.Pusher = require('pusher-js');
window.Echo = new Echo({
broadcaster: 'pusher',
key: 'admin-app-key',
wsHost: window.location.hostname,
wsPort: 6001,
forceTLS: false,
disableStats: true,
});
Android 使用 pusher-websocket-java SDK
IOS 使用 pusher-websocket-swift SDK
连接参数
host: {Websocket服务域名} (我这边和laravel项目域名是同一个)
port: 6001
app_key: api-app-key
auth_url: {laravel项目域名}/broadcasting/auth
:::danger
注意pusher-websocket-swift要使用8.0.0版本,9.x版本有bug无法连接服务
:::
MySQL自动创建分区
cover: https://www.astralweb.com.tw/wp-content/uploads/2016/10/Mysql-03-1024x538.jpg
MySQL分区表有很多优点,不过使用前提是要实现自动创建分区,否则不存在分区时无法插入新数据。
身边多数人的习惯是写脚本crontab执行,但其实最简单方式还是用mysql存储过程处理,减少处理步骤。
以按天分区为例(裁剪函数为TO_DAYS
)
存储过程如下
# 注意下面的client需要改成对应的用户
delimiter |
drop PROCEDURE if exists `ADD_DAILY_PARTITION`;
CREATE
DEFINER = `client`@`%` PROCEDURE `ADD_DAILY_PARTITION`(in _TABLE_NAME varchar(50))
begin
# 已创建的最后一个分区
declare CURRENT_PARTITION_NAME varchar(9);
# 已创建的最后一个分区边界
declare CURRENT_BORDER timestamp;
# 期望创建的最后一个分区
declare END_PARTITION_NAME varchar(9);
# 期望创建的最后一个分区边界
declare END_BORDER timestamp;
# 分区边界
declare BORDER varchar(8);
# 循环次数
declare LOOP_NUM int;
# 循环次数限制
declare LOOP_LIMIT int;
# 分区创建提前量
declare LEAD_DAYS tinyint;
# 当前DB
declare DB_NAME varchar(50);
set LEAD_DAYS = 7;
set LOOP_NUM = 0;
set LOOP_LIMIT = 1000;
# 查询当前数据库
select database() into DB_NAME;
# 查询最后一个分区
select PARTITION_NAME
into CURRENT_PARTITION_NAME
from information_schema.partitions
where TABLE_SCHEMA = DB_NAME
and TABLE_NAME = _TABLE_NAME
order by PARTITION_ORDINAL_POSITION desc
limit 1;
set END_PARTITION_NAME = DATE_FORMAT(NOW() + interval LEAD_DAYS day, 'p%Y%m%d');
set END_BORDER = DATE_FORMAT(REPLACE(END_PARTITION_NAME, 'p', ''), '%Y%m%d') + interval 1 day;
set CURRENT_BORDER = DATE_FORMAT(REPLACE(CURRENT_PARTITION_NAME, 'p', ''), '%Y%m%d') + interval 1 day;
# 在当前分区后循环创建新分区,截至期望分区
while CURRENT_BORDER < END_BORDER and LOOP_NUM < LOOP_LIMIT
do
set LOOP_NUM = LOOP_NUM + 1;
set CURRENT_PARTITION_NAME = DATE_FORMAT(CURRENT_BORDER, 'p%Y%m%d');
set CURRENT_BORDER = CURRENT_BORDER + interval 1 day;
set BORDER = DATE_FORMAT(CURRENT_BORDER, '%Y%m%d');
set @SQL = CONCAT('ALTER TABLE `', DB_NAME, '`.`', _TABLE_NAME, '`', ' ADD PARTITION ( PARTITION ',
CURRENT_PARTITION_NAME, ' VALUES LESS THAN ( TO_DAYS("', BORDER, '") ) )');
prepare STMT from @SQL;
execute STMT;
deallocate prepare STMT;
end while;
end |
delimiter ;
存储过程中包含循环次数上限,避免创建表时的当前分区与目标分区相隔太久引起创建分区时间过长。
定时器如下,将所有相同分区方案的表都加入即可。
delimiter |
drop event if exists `ADD_DAILY_PARTITION`;
create event `ADD_DAILY_PARTITION` on schedule every 1 day do
begin
call ADD_DAILY_PARTITION('woo_my_table_awesome_1');
call ADD_DAILY_PARTITION('woo_my_table_awesome_2');
call ADD_DAILY_PARTITION('woo_my_table_awesome_3');
end |
delimiter ;
启用定时器后,只要MySQL服务不可用时间没超过提前量天数,即可确保持续得自动创建分区。
Recommend Projects
-
React
A declarative, efficient, and flexible JavaScript library for building user interfaces.
-
Vue.js
🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
-
Typescript
TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
-
TensorFlow
An Open Source Machine Learning Framework for Everyone
-
Django
The Web framework for perfectionists with deadlines.
-
Laravel
A PHP framework for web artisans
-
D3
Bring data to life with SVG, Canvas and HTML. 📊📈🎉
-
Recommend Topics
-
javascript
JavaScript (JS) is a lightweight interpreted programming language with first-class functions.
-
web
Some thing interesting about web. New door for the world.
-
server
A server is a program made to process requests and deliver data to clients.
-
Machine learning
Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.
-
Visualization
Some thing interesting about visualization, use data art
-
Game
Some thing interesting about game, make everyone happy.
Recommend Org
-
Facebook
We are working to build community through open source technology. NB: members must have two-factor auth.
-
Microsoft
Open source projects and samples from Microsoft.
-
Google
Google ❤️ Open Source for everyone.
-
Alibaba
Alibaba Open Source for everyone
-
D3
Data-Driven Documents codes.
-
Tencent
China tencent open source team.