GithubHelp home page GithubHelp logo

cloudycity.github.io's People

Contributors

cloudycity avatar

Watchers

 avatar  avatar

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编写的静态博客框架,快速、简洁、扩展丰富。

搭建完成后使用非常简单:

  1. 在hexo项目目录下的\source\_posts\中用Markdown语法写博文;
    qq 20190214100921
  2. 生成静态文件(Hexo将MD文件其解析成HTML文件)并部署。
    qq 20190214101614

搭建

本教程将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客户端,通知的对象是根据渠道而定。可选的通知渠道有广播、短信、邮件、数据库或自定义渠道。
  • 广播可以直接由事件触发或由通知触发,这里采用的是后者。

流程

image

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,至少一个子项被勾选时,父项会进入半选中/选中状态。
semMqO.gif

改为true即可实现【只选中父项,不选任何子项】,后面自行实现联动。
semCrT.gif

<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事件实现【联动选择】

semBdg.gif

<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>

seKkdS.gif

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中定义了所有默认特性,默认的nodeProviderFallbackNodeProvider, 其实是一个集合,将按顺序尝试获取。其中集合中的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;
    }
}

image

image

存储请求

这里推荐 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 [];
        });
    }

    // ...

检索请求

image

image

:::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文章

qq 20190214112539

使用GitHub图床非常简单,把图片文件拖到编辑框就行了~

导入文章

$ hexo migrate github-issue 你的Github用户名/你存放hexo的仓库名

例如
qq 20190216110245
然后生成部署即可。

插件扩展

该插件会根据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

Xnip2020-01-08_17-46-56

使用github issue存放评论

安装插件

Next主题自带gitment,主文件是/themes/next/source/js/src/gitment.browser.js

如果是其他主题,需要安装gitment,主文件是/node_modules/gitment/dist/gitment.browser.js

$ npm install gitment --save

创建授权应用

qq 20190214114441
点击这里创建,名称随意,两个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作者Yikunfork的分支,用webpack打包,覆盖gitment主文件。

Next主题可参考本博客的gitment.browser.js

Next主题可参考我写的替换脚本gitment.sh

主题修改

这里使用的是Next主题,需要修改三个模板文件,将hexo-migrator-github-issue插件获取到的issue-number作为id,供gitment插件中的loadMeta()使用。

  1. \themes\next\layout\_partials\comments\gitment.swig
  2. \themes\next\layout\_script\comments\gitment.swig
  3. \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中的流程。

image

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 - 加密变量 中配置。

image

这个目录就是编译后静态文件要部署的目录,需要给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

image

:::

回到部署机

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 - 流水线中点击运行流水线即可。

image

自动触发

首先检查Runner设置的触发条件,如下图配置,每当受保护分支有新的提交推送,便会触发流水线。

image

其他

如果其他前端项目也是用这个方式部署,可以取消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语法进行扩展。

扩展支持的语法

这里要扩展的是三个常用的语法:

  1. INSERT IGNORE INTO
  2. REPLACE INTO
  3. INSERT INTO ... ON DUPLICATE KEY UPDATE

扩展Builder类

Laravel官方已经在Illuminate\Database包中提供了扩展实例,需要创建自定义的GrammarBuilderConnection类,然后由服务提供者注册。

自定义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以获得提示。
image
QQ截图20190412161418

封装成组件

参照使用satis搭建私有Composer库

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_jsneat_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

image

构建输出合并与压缩后的文件:

image

更新模板

修改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' %}

然后在各模板中将引入合并前静态文件的部分注释掉,这里不详细列出,只放出一个例子:

image

完成后重新部署:

hexo g && gulp && hexo d

引用效果:

image

图片懒加载

可以显著加速文章页。

安装插件:

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)

最终效果

首页

image

文章页

image

剩下的瓶颈主要是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地址:

image

Github

添加CNAME文件

source分支中的source目录下新建一个CNAME文件,内容为绑定的域名:

image

然后重新部署博客。

申请HTTPS

到仓库设置中的Github Page子项中勾选Enforce HTTPS

image

:::warning
添加CNAME文件之后,项目设置中会自动识别域名。刚识别完域名的1分钟内还无法勾选Enforce HTTPS,勾选之后要等几个小时才能生效。
:::

后续

更新相关配置

如果你的博客有插件涉及域名要记得修改,例如:

  1. gitment插件用到的授权应用需要重新创建,详见hexo使用github issue存放文章与评论

  2. Next主题的文章访问计数需要去leancloud的项目添加安全域名

image

Github Hexo的百度收录问题


cover: https://www.sekkeistudio.com/blog/wp-content/uploads/2019/10/SEO-banner.png

Github屏蔽了百度的爬虫,所以部署在Github PageHexo无法被百度收录,如果你绑定了自定义域名的话,这里有几个曲线救国方案。

私有主机

将博客部署到自己的机器上。

对象存储

将博客上传到对象存储。

国内托管

在国内的代码托管平台部署Hexo,这里推荐Coding,流程与部署到Github基本一致。

分线路解析

Github Page + Coding Page 为例。

部署多项目

Coding的项目只需要master分支,Hexo_config.yml增加仓库地址,然后执行hexo d便可以同时部署到两个项目。

image

DNS解析

注意:如果要开启HTTPS,解析之前要先确保Coding Page的SSL证书已经申请通过,后面会说明原因。

默认解析到coding.me,境外解析到github.io

image

然后百度的爬虫在大陆就会访问到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语法 的功能为例。

image

在组件项目(这里称为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

image

使用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

image

这里的web就是索引输出目录。

使用docker (方法二)

$ docker run --rm -it -v /build:/build composer/satis

自动索引

最简单粗暴的方法是用crontab,但这里推荐使用GitLab Web Hook:

  1. 在satis项目创建一个接口执行创建索引的操作。

image

<?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);
  1. 在组件的GitLab仓库中增加Web Hook

image

每次组件推送新提交之后都会触发钩子请求satis创建索引。

配置Nginx (可选)

satis.json中的homepage指向索引输出目录,然后就可以访问satis的界面。

image

添加部署密钥 (可选)

如果satis所在机器没有权限拉取gitlab仓库,需要生成key并加入到gitlabDeploy Keys(只读)中。

image

从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时才需要加上。
:::

然后正常引入即可:

image

image

References

使用satis自建私有镜像

Laravel5.8+Dingo+JWT+Swagger 开发API


cover: https://i.pinimg.com/originals/bd/c4/7c/bdc47c05192f45e6fe2704b3c52995e7.jpg

在Laravel5.8中使用“Laravel必知必会”的两个轮子dingo/apitymon/jwt-auth以及文档系统swagger-api/swagger-ui开发一个规范优雅的API。

Dingo

安装

composer require dingo/api

image
组件包含自动包发现配置,无需手动注册Provider

配置

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

最终效果

访问上面配置的路由地址

image

点击Authorize按钮,value填入Authorization头的值即可在生产环境的文档页面请求接口。

image

:::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

image

打包生产环境文件,即mars_minigame_sdk.min.js

npm run build

image

使用SDK

以微信开发工具为例

image

References

用 webpack 写一个简单的 JS SDK

VSCode FTP与SSH插件推荐


cover: https://miro.medium.com/max/1200/1*YkgFq4CT5rRyz46K3uxHtQ.png

因为一些不可抗力因素,我偶尔要直接在线上改文件,这时就要在几个工具间反复切换(xftp下载,sublime编辑,xftp上传,xshell执行),比较繁琐。最近刚好改用VSCode,在市场找到两个比较合适的插件,在一个编辑器内即可完成上述流程,非常顺滑。

qq 20190228155615

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 ./

image

自动执行

通过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钩子,从而实现自动检查。

image

使用命令行提交被拒:

image

正常安装钩子后,使用PHPStorm提交时将出现Run Git hooks选项并且默认勾选,如果没有,就重启大法。

image

使用PHPStorm提交被拒:

image

:::tip
虽然客户端有办法跳过自动检查,不过后面服务端也会进行检查,不必担心。客户端检查主要是提高用户体验,也可以杜绝不规范的提交。
:::

PHPStorm协同

正常来说,在引入PHPCS组件后,PHPStorm会自动发现配置。如果没有自动配置,需要在 Languages & Frameworks > PHP > Quality Tools > Code Sniffer 中配置。

image

不过这时候默认是使用PSR2规范,需要手动选择一下自定义的规范。
Editor > Inspections > PHP > Quality tools > PHP Code Sniffer validation 中,将Coding standard改为了Custom,并点击冒号图标,选中项目根目录的phpcs.xml

image

保存后重启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

:::

之后客户端推送提交时,便会执行这个钩子进行检查。

推送通过:

image

推送被拒:

image

如果使用PHPStorm的UI进行推送,被拒是不提示原因的,不过相信大家心里一般都有b数的。

image

 _____________________________ 
< 你心里没点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即可。

在哪里引入都可以,这边选择和钩子同一个目录。
image

授予执行权限

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。

配置

QQ截图20190312152843
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 只开启了nginxphpmysql8redis.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服务器。

  1. 查看宿主机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
  1. 新建或修改/etc/default/docker
DOCKER_OPTS="--dns 183.60.83.19"
  1. 重启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

Get Docker CE for CentOS
Install Docker Compose
yeszao/dnmp

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,包括英文的拼写检查。

image

为什么要达成目标

通过代码检查可以:

  1. 一定程度上规范开发
  2. 更容易发现bug,避免低级错误

如何达成目标

遵守必要规则

至于判断【规则是否必要】的标准因人而异,但确定是【必要规则】就必须遵守,这是代码检查的意义。

关闭不必要规则

全局关闭

不必要的检查规则,例如No data sources configuredSQL dialect detection等等,可以将其关闭。

Inspection Notice处按Alt+Enter打开Inspection setting

image

去掉勾:

image

效果:

image

单次忽略

有些规则是必要的,但因为某些原因我们无法遵守。可以选择Suppress for statement,将会自动在Notice前加入一行注释告诉IDE忽略掉此处的检查。

引导IDE

某些场景规则是必要的,我们也遵守了,只是IDE没有get到仍标出Notice,这时候就需要想办法引导IDE去get到。

添加注释

以常见的闭包Notice为例,内部都会报warning:

image

使用/** @var 变量类型 $var */声明

image

添加助手文件

IDE无法识别LaravelFacade的静态调用方式

image

这时候需要创建额外的助手文件ide-helper.php,里面存放类声明:

image

IDE识别后:

image

P.S. 助手文件本身是可以忽略检查,即选择suppress all for file项。

拼写检查

英文拼写检查的优先级比较低,但能遵守的话可以避免一些奇怪的命名

使用下划线或驼峰通过检查

image

将专有名词加入字典

image

image

给随机字符串添加忽略注释

image

image

image

导出/导入配置

导出配置以备份、与团队成员共享

image

如何引用同一级查询的列别名作为新列


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_atupdated_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组件horizontelescope的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,所以PUTPATCHDELETE的表单内容要从$request->getContent()中解析,但这里进行解析有一个条件是请求头Content-Typeapplication/x-www-form-urlencoded,所以当我使用multipart/form-dataContent-Type进行PUT请求时,Request对象就无法正常解析。

How

解决方案有以下三个:

请求前

  1. Content-Type设置为application/x-www-form-urlencoded
  2. 覆盖请求头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;
    }

请求后

  1. 继承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: 用户订阅channel
  • UserExitChannel: 用户取消订阅channel
  • UserAppBackground: 用户APP切换至后台
  • UserAppForeground: 用户APP切换至前台台

继承BeyondCode\LaravelWebSockets\Server\WebSocketHandler,监听客户端消息,触发UserAppBackgroundUserAppForeground事件。

:::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,触发UserEnterChannelUserExitChannel事件。

:::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,实现userJoinedPrivateChanneluserLeftPrivateChannelgetChannelUser三个方法,在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 photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo 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.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.