Laradock DNMP 环境搭建

Laradock 是什么?

Laradock.io 是一堆 Dockfiledocker-compose.yml 的文件集合,用于一键构建 DNMP 环境。

Laradock 封装了一个 Workspace 工作区镜像作为开发环境,里面包含了丰富且实用的工具集:PHP-CLI、Composer、Git、Linuxbrew、Node、V8JS、Gulp、SQLite、xDebug、Envoy、Deployer、Vim、Yarn、SOAP、Drush 等等。

CentOS 中 Laradock 的使用

1. 安装 Docker

http://www.runoob.com/docker/centos-docker-install.html

2. 安装 Docker-Compose

# 注意 URL 里的版本号
curl -L https://github.com/docker/compose/releases/download/1.23.2/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose
chmod +x /usr/local/bin/docker-compose

3. 下载 Laradock

A. 单应用
cd /home/wwwroot/ai_gmall_server
git submodule add https://github.com/Laradock/laradock.git

目录结构:

- ai_gmall_server
    - laradock
    - app
    - public
    - ...
B. 多应用
cd /home/wwwroot/
git clone https://github.com/Laradock/laradock.git

目录结构:

- wwwroot
    - laradock
    - ai_gmall_server
    - ai_stadium_server

注:多应用时,需要配置 nginx vhost 才能访问到,配置文件在 laradock/nginx/sites/*.conf

4. Laradock 环境配置

初始化 laradock/.env 配置:

cd laradock
cp env-example .env
vim .env

注意修改其中的以下关键选项:

# 应用持久化数据保存目录(映射在宿主机的数据卷)
# 用于保存 MySQL 产生的数据、Redis 快照文件等
DATA_PATH_HOST=~/.laradock/ai_gmall/data

# 定义容器前缀
# 当一台宿主机有多个 laradock 单应用时,需要设置前缀进行区分
COMPOSE_PROJECT_NAME=laradock_ai_gmall

# 端口修改(按需)
NGINX_HOST_HTTP_PORT=1322
NGINX_HOST_HTTPS_PORT=13443

# 关闭 PHPRedis 扩展,我们用 Predis
WORKSPACE_INSTALL_PHPREDIS=false
PHP_FPM_INSTALL_PHPREDIS=false

# MySQL 版本
# 注意默认 latest 为 8.0,这里改为 5.7
MYSQL_VERSION=5.7
MYSQL_DATABASE=ai_gmall
MYSQL_USER=gmall
MYSQL_PASSWORD=gmall.1322
MYSQL_PORT=3306
MYSQL_ROOT_PASSWORD=gmall.1322

# 设置中国镜像源
CHANGE_SOURCE=true
WORKSPACE_NPM_REGISTRY=https://registry.npm.taobao.org
WORKSPACE_COMPOSER_REPO_PACKAGIST=https://packagist.phpcomposer.com

# 设置中国时区
WORKSPACE_TIMEZONE=Asia/Shanghai

# 安装 NodeJS
WORKSPACE_INSTALL_NODE=true

# 启用 PHP OPCache
PHP_FPM_INSTALL_OPCACHE=true

以下可选配置:

# 安装 Swoole
WORKSPACE_INSTALL_SWOOLE=true
PHP_FPM_INSTALL_SWOOLE=true

# phpMyAdmin
PMA_USER=default
PMA_PASSWORD=secret
PMA_ROOT_PASSWORD=secret
PMA_PORT=8080

# phpRedisAdmin
REDIS_WEBUI_USERNAME=laradock
REDIS_WEBUI_PASSWORD=laradock
REDIS_WEBUI_CONNECT_HOST=redis
REDIS_WEBUI_CONNECT_PORT=6379
REDIS_WEBUI_PORT=9987

5. Nginx 虚机配置

打开 laradock/nginx/sites/default.conf,修改 server_name 为实际域名,去除 default_server 指定。

如需启用 HTTPS,则把证书放到 laradock/nginx/ssl/ 目录里,并在对应的配置中引入。

如果一套环境包含多应用,那么还需要给 Nginx 增加 vhost 配置:

# 里面可以添加 server 段
vim laradock/nginx/sites/new-app.conf

# 修改完配置必须重启 Nginx
docker-compose restart nginx

6. PHP-FPM 设置

(1) 修改 laradock/php-fpm/phpX.X.ini (X.X 为 PHP 版本号) 设置 disable_functions 等参数。

(2) 修改 laradock/php-fpm/laravel.ini 设置 memory_limit 等参数。

(3) 修改 laradock/php-fpm/opcache.ini 确保参数按如下设置:

opcache.enable="1"
opcache.memory_consumption="256"
opcache.use_cwd="1"
opcache.max_file_size="0"
opcache.max_accelerated_files = 30000
opcache.validate_timestamps="0"
opcache.save_comments=0
opcache.revalidate_freq="0"

强烈建议:生产环境 validate_timestamps=0,每次 PHP 文件变更都应平滑重启 PHP-FPM。

# 平滑重启
docker-compose exec php-fpm bash -c "kill -USR2 1"

# 强制重启(不推荐)
docker-compose restart php-fpm

7. Laravel 应用内配置

修改项目里的 ai_gmall_server/.env 文件,将其中的 DB_HOSTREDIS_HOST 改为以下值:

DB_HOST=mysql
REDIS_HOST=redis
QUEUE_HOST=beanstalkd

然后进行应用初始化(必做步骤):

cd /home/wwwroot/ai_gmall_server
chmod -R 777 storage bootstrap/cache

cd laradock
docker-compose exec [--user=laradock] workspace \
    bash -c "composer install --optimize-autoloader"

7. Redis 配置

配置文件 laradock/redis/redis.conf,修改以下项并重启 Redis

# 设置密码
requirepass 密码

# 绑定 IP
# 也可以注释掉
bind 0.0.0.0

# 修改持久化频率
save 900 1
save 300 10
save 60 50000

重启 Redis 的命令是:

docker-compose restart redis

8. MySQL 配置

配置文件 laradock/mysql/my.cnf,增加或修改以下项并重启 MySQL

# @see http://dev.mysql.com/doc/mysql/en/server-system-variables.html

[client]
default-character-set = utf8mb4

[mysql]
default-character-set = utf8mb4
max_allowed_packet = 256M

[mysqld]
init_connect = 'SET NAMES utf8mb4'
group_concat_max_len = 102400
character-set-server = utf8mb4
sql-mode = "STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION"

重启 MySQL 的命令是:

docker-compose restart mysql

在目录 laradock/mysql/docker-entrypoint-initdb.d/ 里的 *.sql 文件,会在MySQL 容器初次创建时自动执行。 我们可以把应用初始化所需的 SQL 放在里面。

容器初次创建:指 $DATA_PATH_HOST/data/mysql 文件夹第一次创建时,如果该文件夹已存在,则不会执行。

如果就是需要手动执行一些 SQL,那么可以进入 MySQL 容器:

docker-compose exec mysql bash
mysql -u root -p < /xxx.sql

MySQL 默认安装的版本是 latest,即 8.0,如想更换,可以修改 laradock/.env 中的 MYSQL_VERSION 常量,例如改为 MYSQL_VERSION=5.7

更换 MySQL 版本的坑

如果之前已装了 latest 版本,现在换成 5.7,那么在重新构建新的 MySQL 镜像前,必须先清空旧的数据卷:$DATA_PATH_HOST/data/mysql,否则新的 MySQL 会启动失败。

或者一并删除旧容器及其数据卷,命令如下:

# 把旧容器和数据卷一并删除
rm -rf ~/.laradock/ai_gmall/data/mysql

# 修改 `MYSQL_VERSION` 常量
cd laradock
vim .env

# 构建新的镜像
docker-compose build --no-cache mysql

# 启动新的容器
docker-compose up --no-deps mysql

9. Laravel WebSocket Echo Server

修改 laradock/laravel-echo-server/laravel-echo-server.json 文件:

{
    "authHost": "localhost",
    "authEndpoint": "/broadcasting/auth",
    "clients": [],
    "database": "redis",
    "databaseConfig": {
        "redis": {
            "port": "6379",
            "host": "redis"
        }
    },
    "devMode": true,
    "host": null,
    "port": "6001",
    "protocol": "http",
    "socketio": {},
    "sslCertPath": "",
    "sslKeyPath": ""
}

每次修改完都需重启该容器:

docker-compose restart laravel-echo-server

另外,容器版的 laravel-echo-server 无需 Supervisord 来守护,因为:

  1. 容器的主进程只要发生中断,容器也会自动终止。
  2. docker-compose.ymllaravel-echo-server 的『退出后的重启策略』是始终重启:restart: always

以上就可以确保 laravel-echo-server 服务会一直保持,达到了和 Supervisord 一样的目的。

10. Supervisord + Laravel Horizon

Laradock 并没有单独的 Supervisord 容器,而是顺便夹带在 laravel-horizonphp-worker 两个容器里,这样会导致无法监听其他进程(例如 PHP iNotify),这并不是一个好的设计。解决办法是把 Supervisord 以及待监听的进程都装到 Workspace 容器里。

在目录 laradock/workspace/supervisor.d/ 里新建 *.conf 文件,格式如下:

[program:AI_GMall_Server_Horizon]
process_name=%(program_name)s_%(process_num)02d
autostart=true
autorestart=true
redirect_stderr=true
command=php /var/www/artisan horizon
stdout_logfile=/var/www/storage/logs/supervisord_horizon.out

重启 Supervisord 的命令是:

docker-compose exec workspace bash -c 'supervisorctl reload'

重启 Laravel Horizon 的命令是:

docker-compose exec workspace bash -c 'php artisan horizon:terminate'

11. 计划任务

Laradock 的计划任务有两种实现方式:

A. 通过系统 Crond 实现

打开文件 laradock/workspace/crontab/laradock,确保里面每条命令的 执行者 和项目文件的拥有者一样,否则会导致无权执行:

# 以下执行者为 root 用户
* * * * * root /usr/bin/php /var/www/artisan schedule:run >> /dev/null 2>&1

原理:在 Workspace 容器创建时,会把 laradock/workspace/crontab/ 里的所有文件(文件名随意)都复制到容器的 /etc/cron.d/ 目录里(注:Linux 系统里,/etc/cron.d//etc/crontab 的扩展目录,都是系统级计划任务),系统会每分钟都会扫描这些文件并按策略执行。

扩展阅读:Linux Crontab 计划任务拾遗

B. 通过 Supervisord 实现

新增文件 laradock/workspace/supervisord.d/laravel-scheduler.conf

[program:laravel-scheduler]
process_name=%(program_name)s_%(process_num)02d
command=/bin/sh -c "while [ true ]; do (php /var/www/artisan schedule:run --verbose --no-interaction &); sleep 60; done"
autostart=true
autorestart=true
numprocs=1
user=laradock
redirect_stderr=true

疑问待验证:这种单进程阻塞的写法,假设一个任务执行超过10分钟,那么期间本该执行的其他任务是否会被遗漏?

99. 正式启动 Laradock

docker-compose up -d \
    nginx \
    mysql phpmyadmin \
    redis redis-webui \
    laravel-echo-server

注:php-fpmnginxdepends_on 依赖项,Workspacephp-fpm 的依赖项,所以这两个容器会自动创建并启动,不需要写出来。

100. 应用初始化

在项目目录先执行:

cd /home/wwwroot/ai_gmall_server
chmod -R 777 storage bootstrap/cache

cd laradock
docker-compose exec workspace \
    bash -c "composer install --optimize-autoloader"

经验:如果访问出现 500 错误,那大多数情况是没有执行 composer install 导致。

相关访问 URL 入口:

  • WebServer: http://localhost:80
  • WebSocketServer http://localhost:6001
  • phpMyAdmin: http://localhost:8080
  • phpRedisAdmin: http://localhost:9987

日常使用手册

1. 执行 Compose 命令的位置

执行 docker-compose 命令默认会在当前目录查找 docker-compose.yml,如果找不到就会报执行失败,否则请用 -f 参数指定位置,例如:

docker-compose -f docker-compose-XXXX.yml up -d ...

2. 如何执行 php artisan 命令?

先进入 Workspace 工作区容器:

docker-compose exec [--user=laradock] workspace bash

缺省是以 root 身份进入容器,用 --user=laradock 可以指定为其他用户。要求项目工作区文件的拥有者必须与进入者身份一致,否则会导致一些文件读写权限的问题。

进入 Workspace 容器后,就可自由运行 php artisan 命令了。

3. 如何更新 PHP 代码?

应用代码仍然是放在宿主机的 /home/wwroot 目录里,是以数据卷形式挂载在容器里的,所以对文件的操作还是和之前一样。

例如应用初始化时,我们可以直接在宿主机的 /home/wwwroot/ai_gmall_server 目录里执行:

chmod -R 777 storage bootstrap/cache

更新脚本 ~/sh/update_ai_gmall_server.sh 如下:

#! /bin/bash
CODE_DIR=/home/wwwroot/ai_gmall_server

# 更新代码
cd $CODE_DIR
git checkout .
git pull origin
git submodule update

\cp envs/all_in_one/.env .env

cd laradock
docker-compose exec workspace \
    bash -c "composer install --optimize-autoloader && php artisan horizon:terminate"

# 平滑重启 PHP-FPM
docker-compose exec php-fpm bash -c "kill -USR2 1"

4. 什么时候必须重新构建镜像?

如果修改了 laradock/docker-compose.ymllaradock/.env 或相关组件的 Dockerfile 文件,那么就需要重建指定的镜像和容器使之生效。

假设我们修改了 laradock/workspace/Dockerfile,然后重建并重启:

docker-compose build workspace
docker-compose up --no-deps --force-recreate -d workspace

或者:
docker-compose up --build --no-deps --force-recreate -d workspace

注意:重建会一并重建 depends_on 依赖镜像,加上参数 --no-deps 表示只需重建指定镜像,不要牵连依赖镜像。如果重建时想忽略缓存,也可以加上 --no-cache 参数。

此时切忌用 docker-compose down,因为 down停止并删除所有 up 启动的容器,即使这些容器没有修改过。

5. 如何安装更多 PHP 扩展?

安装 PHP 扩展需要同时修改 PHP-FPMPHP-CLI 两个容器,并重建镜像:

  • PHP-FPM : php-fpm/Dockerfile-XX (XX 是 PHP 版本号)
  • PHP-CLI : workspace/Dockerfile

6. 生产环境的注意事项

生产环境应该用另一份 docker-compose-prod.yml,移除 docker-compose.yml 中的相关 ports 选项,保证的 MySQL/Redis 端口不暴露在公网。

查看文档:多个 docker-compose.yml 文件共享配置

参考资料

WebSocket 协议握手流程

HTTP 1.1 的 keep-alive?

WebSocket 协议解决了服务器与客户端全双工通信的问题。

  1. 信息只能单向传送为单工
  2. 信息能双向传送但不能同时双向传送称为半双工
  3. 信息能够同时双向传送则称为全双工

握手流程

客户端请求头

GET /chat HTTP/1.1
Connection: Upgrade                             # 通知服务器协议升级
Upgrade: websocket                              # 协议升级为websocket协议
Host: server.example.com:7001                   # 升级协议的服务主机:端口地址
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==     # 下文解释
Sec-WebSocket-Protocol: chat, superchat         # 子协议(应用层协议层协议)
Sec-WebSocket-Version: 13                       # WebSocket 协议版本必须是13
Origin: http://example.com

其中 Upgrade: websocketConnection: Upgrade 表示

Sec-WebSocket-Key

其中 Sec-WebSocket-Key 是客户端随机生成的一个 Base64 值,模拟生成算法如下:

def _create_sec_websocket_key():
    randomness = os.urandom(16)
    return base64encode(randomness).decode('utf-8').strip()

Sec-WebSocket-Protocol

字段表示客户端可以接受的子协议类型,也就是在 WebSocket 协议上的应用层协议类型。 上面可以看到客户端支持 chat 和 superchat 两个应用层子协议,当服务器接受到这个字段后要从中选出一个子协议返回给客户端。

服务端响应头

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=
Sec-WebSocket-Protocol: chat

Upgrade 和 Connection

服务器告知客户端协议已成功升级为 WebSocket 协议,用来完善 HTTP 升级响应。

Sec-WebSocket-Accept

服务端拿到客户端的 Sec-WebSocket-Key 后,跟 MAGIC 全局字符串常量拼接,再经过 sha1base64_encode 后得出 Sec-WebSocket-Accept,模拟生成算法如下:

const MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
$accept = base64encode(sha1($key . MAGIC));

echo $accpet;

注意:MAGIC 魔术字符串是 RFC6455 官方定义的一个固定字符串,官方就是这么任性,不得修改。

客户端拿到服务端响应的 Sec-WebSocket-Accept 后,会拿自己之前生成的 Sec-WebSocket-Key 用相同算法算一次,如果匹配,则握手成功。然后判断 HTTP Response 状态码是否为 101(切换协议),如果是,则建立连接,大功告成。

Sec-WebSocket-Protocol

表示服务器最终选择的一个应用层子协议

WebSocket & Socket.io

Socket.io

Socket.io 是一个开源的 JS 实时通信库,包括了客户端和服务端。以下是最简单的 WebSocket 通信流程,分为四个角色:

  1. PHP App Server
  2. WebSocket Server (NodeJS) – Socket.io Server
  3. WebSocket Client (HTML) – Socket.io Client
  4. Redis (订阅&发布)

PHP - 应用服务器

// 连接 Redis
$redis = new Redis;
$redis->connect('127.0.0.1', 6379);
$redis->auth('密码');

// 发布消息
$redis->publish('频道名', '消息载体');

注意:发布时的『消息内容』可以是一个 JSON 字符串,例如 Laravel 里代码如下:

$payload = json_encode([
    'event'  => $event,
    'data'   => $payload,
    'socket' => Arr::pull($payload, 'socket'),  // 排我标识,后面再说
]);

$redis->publish('频道名', $payload);

Socket.io - WebSocket 服务器

npm install --save ioredis
npm install --save socket.io

单文件 server.js 代码如下:

var app = require('http').createServer(function (req, res) {
  res.writeHead(200);
  res.end('');
});

var io = require('socket.io')(app);

app.listen(7002, function () {
  console.log('WebSocketServer is running!');
});

io.on('connection', function (socket) {
  console.log(socket);
  console.log('connected');
  socket.on('message', function (message) {
    console.log(message);
  });
  socket.on('disconnect', function () {
    console.log('disconnected');
  });
});

var Redis = require('ioredis');
var redis = new Redis({
  port: 6379,
  host: '127.0.0.1',
  password: '密码',
});

redis.psubscribe('*', function (err, count) {
  console.log('err: ' + err + ', count: ' + count);
});

redis.on('pmessage', function (subscrbed, channel, message) {
  console.log('subscrbed: ' + subscrbed + ', channel: ' + channel + ', message: ' + message);
  // 这里须和 App Server 约定,消息载体为 JSON 字符串
  message = JSON.parse(message);
  // 拼接完整的『频道名:事件名』
  var eventName = channel + ':' + message.event;
  // 给客户端广播消息
  io.emit(eventName, message.data);
});

Socket.io - WebSocket 客户端

socket.io-client API 手册

引入 socket.io-client 库:

<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.1.1/socket.io.js"></script>

可以直接到 CDN 下载:https://cdnjs.com/libraries/socket.io

也可通过 npm 安装以下库:

npm install socket.io-client

然后从以下路径中拷贝出来:

node_modules/socket.io-client/dist/socket.io.js

Javascript 部分代码:

// @see https://socket.io/docs/client-api/
const socket = io('https://dev.api.gaopeng.com:7002');

socket.on('connect', function (socket) {
  // 即 X-Socket-ID
  console.log(socket.id);
});

socket.on('事件名称', function (event) {
  console.log(event);
});

注:监听的『事件名称』即 io.emit() 时的 eventName 变量(即『频道名:事件名』完整字符串)

值得一提

1、广播的排我发布是如何实现的?

客户端 X-Socket-ID

2、微信小程序如何使用 WebSocket?

由于 Socket.io 在使用过程中会给 client 植入 cookie 完成验证,而微信小程序不支持 cookie,所以就需要修改 Socket.io 客户端。

用法与浏览器端一致:

const io = require('./yout_path/weapp.socket.io.js')

const socket = io('http://localhost:8000')

socket.on('news', d => {
  console.log('received news: ', d)
})

socket.emit('news', {
  title: 'this is a news'
})

3、Socket.io Server 负载均衡

https://socket.io/docs/using-multiple-nodes

通过增加 socket.io-redis 可以在多个 Socket.io Server 节点中传递事件。

4、安卓客户端的 Socket.io Client

https://socket.io/blog/native-socket-io-and-android

5、客户端向服务器、客户端之间发消息

参考 laravel-echo client 里的 Echo.whisper(eventName, data),代码如下:

this.socket.emit('client event', {
  channel: this.name,
  event: 'client-' + eventName,
  data: data
});

Socket.io Server: 通过 socket.broadcast.emit() 发布排我广播

io.on('connection', function(socket){
  socket.broadcast.emit('hi');
});

参考文章

Composer 生产服性能优化指南

Level-1 优化:生成 classmap

生产环境一定要执行该命令,为啥呢?

composer dump-autoload -o(-o 等同于 --optimize

原理:

这个命令的本质是将 PSR-4/PSR-0 的规则转化为 classmap 规则(classmap 中包含了所有类名与类文件路径的对应关系)避免了加载器再去文件系统中遍历查找文件产生不必要的 IO。

当加载器找不到目标类时,仍旧会根据 PSR-4/PSR-0 的规则去文件系统中查找。

Level-2 优化:权威的(Authoritative)classmap

如果想实现加载器找不到类时即停止,那可以采用 Authoritative classmap:

composer dump-autoload -a (-a 等同于 --classmap-authoritative

原理:

执行这个命令隐含的也执行了 Level-1 的命令, 即同样也是生成了 classmap,区别在于当加载器在 classmap 中找不到目标类时,不会再去文件系统中查找(即隐含的认为 classmap 中就是所有合法的类,不会有其他的类了,除非法调用)

注意: 如果你的项目在运行时会生成类,使用这个优化策略会找不到这些新生成的类。

Level-1 Plus 优化:使用 APCu Cache

在生产环境下,这个策略一般也会与 Level-1 一起使用, 执行:

composer dump-autoload -o --apcu

APCu 是 APC 去除 opcode 缓存后的精简版,只用于本机数据缓存(共享内存使得数据在多进程间可共享)。

这样,即使生产环境下生成了新的类,只需要文件系统中查找一次即可被缓存 , 弥补了 Level-2/A 的缺陷。

如何选择 & 小结

如果项目在运行时不会生成新的类文件,那么使用 Level-2/A,否则使用 Level-1 及 Level-1 Plus。

Level-2 的优化基本都是 Level-1 优化的补充,Level-2 主要是决定在 classmap 中找不到目标类时是否继续找下去。

Level-1 Plus 主要是在提供了一个缓存机制,将在 classmap 中找不到时,将从文件系统中找到的文件路径缓存起来,加速后续查找的速度。

参考文章

Composer 自动加载原理分析

Composer 自带的几种 autoloader(加载器)

原文参考:https://docs.phpcomposer.com/04-schema.html#autoload

  • PSR-4 autoloading
  • PSR-0 autoloading
  • Classmap generation
  • Files includes

推荐使用 PSR-4,使用更简洁,另外当增加新的类文件时,无需重新生成 autoloader,Composer 会根据类名自动定位文件路径。

(1) PSR-4 Autoloading

当执行 composer install/update 时,会生成 vendor/composer/autoload_psr4.php 文件

{
    "autoload": {
        "psr-4": {
            "Monolog\\": "src/",
            "Vendor\\Namespace\\": ""
        }
    }
}

如果你需要搜索多个目录中一个相同的前缀,你可以将它们指定为一个数组,例:

{
    "autoload": {
        "psr-4": { "Monolog\\": ["src/", "lib/"] }
    }
}

如果想设置一个目录作为任何命名空间的 fallback 查找目录,可以使用空的前缀,像这样:

{
    "autoload": {
        "psr-4": { "": "src/" }
    }
}

请注意:命名空间的申明应该以 \ 结束,以确保 autoloader 能够准确响应。例: Foo 将会与 FooBar 匹配,然而以反斜杠结束就可以解决这样的问题, Foo\ 和 FooBar\ 将会被区分开来,另外:JSON 里要写双斜杠只是因为须在双引号里转义。

(2) PSR-0 Autoloading

composer install/update 过程中,会生成 vendor/composer/autoload_namespaces.php 文件。

{
    "autoload": {
        "psr-0": {
            "Monolog\\": "src/",
            "Vendor\\Namespace\\": "src/",
            "Vendor_Namespace_": "src/"
        }
    }
}

如果你需要搜索多个目录中一个相同的前缀,你可以将它们指定为一个数组,例:

{
    "autoload": {
        "psr-0": { "Monolog\\": ["src/", "lib/"] }
    }
}

PSR-0 方式并不仅限于申明命名空间,也可以是精确到类级别的指定。这对于只有一个类在全局命名空间的类库是非常有用的(如果 PHP 源文件也位于包的根目录)。例如,可以这样申明:

{
    "autoload": {
        "psr-0": { "UniqueGlobalClass": "" }
    }
}

如果想设置一个目录作为任何命名空间的 fallback 查找目录,可以使用空的前缀,像这样:

{
    "autoload": {
        "psr-0": { "": "src/" }
    }
}

(3) Classmap generation

composer install/update 过程中,扫描指定目录(同样支持直接精确到文件)中所有的 .php.inc 文件中的类,建立类名和类文件的映射,以路径层级作为命名空间,生成 vendor/composer/autoload_classmap.php 文件。

我们可以用 classmap 生成不遵循 PSR-0/4 规范的类库路径映射。

{
    "autoload": {
        "classmap": [
            "database/seeds",
            "database/factories"
        ],
    }
}

注意:文件 autoload_classmap.php 还有个巧妙的用途,在执行 composer dump-autoload -o 时,也会冗余存储扫描得出的 PSR-4/0 规则的类文件映射 —— 所以生产服必须启用。

(4) File includes

用于加载某些全局的特定文件,通常作为函数库的载入方式(而非类库)。

{
    "autoload": {
        "files": ["src/MyLibrary/functions.php"]
    }
}

(5) include-path (Legacy)

设置一个目录列表,这是一个过时的做法,用于支持老项目,相当于给 PHP 设置 set_include_path 的扫描目录。

Composer 如何根据类名查找到文件的?

查找顺序是 classmap -> psr4 -> psr0,如图:

image

源代码 vendor/composer/ClassLoader.php 如下:

public function findFile($class)
{
    // class map lookup
    if (isset($this->classMap[$class])) {
        return $this->classMap[$class];
    }

    if ($this->classMapAuthoritative || isset($this->missingClasses[$class])) {
        return false;
    }

    if (null !== $this->apcuPrefix) {
        $file = apcu_fetch($this->apcuPrefix.$class, $hit);
        if ($hit) {
            return $file;
        }
    }

    $file = $this->findFileWithExtension($class, '.php');

    // Search for Hack files if we are running on HHVM
    if (false === $file && defined('HHVM_VERSION')) {
        $file = $this->findFileWithExtension($class, '.hh');
    }

    if (null !== $this->apcuPrefix) {
        apcu_add($this->apcuPrefix.$class, $file);
    }

    if (false === $file) {
        // Remember that this class does not exist.
        $this->missingClasses[$class] = true;
    }

    return $file;
}

其中 PSR-4 的加载方法 findFileWithExtension 代码如下:

private function findFileWithExtension($class, $ext)
{
    // PSR-4 lookup
    $logicalPathPsr4 = strtr($class, '\\', DIRECTORY_SEPARATOR) . $ext;

    $first = $class[0];
    if (isset($this->prefixLengthsPsr4[$first])) {
        $subPath = $class;
        while (false !== $lastPos = strrpos($subPath, '\\')) {
            $subPath = substr($subPath, 0, $lastPos);
            $search = $subPath . '\\';
            if (isset($this->prefixDirsPsr4[$search])) {
                $pathEnd = DIRECTORY_SEPARATOR . substr($logicalPathPsr4, $lastPos + 1);
                foreach ($this->prefixDirsPsr4[$search] as $dir) {
                    if (file_exists($file = $dir . $pathEnd)) {
                        return $file;
                    }
                }
            }
        }
    }

    // ....
}

PSR-4 找文件的大致流程是从尾部开始倒着切割类名和命名空间,依次去 autoload_psr4.php 里匹配找出指定命名空间对应的 src 目录,然后把类名拼接在 src 目录后,就是完整的文件路径。

例如 composer.json 中 PSR-4 规则定义为:

{
    "autoload": {
        "psr-4": {
            "App\\": "application/",
            "App\\Models\\" : "application_models/" # 子目录可以另立山头,不一定要放在 app/ 目录里
        }
    }
}

那么类 \App\Controller\Foo\BarController 查找流程是:

  1. 先找 autoload.psr-4 里有没有定义 \App\Controller\Foo\ 对应的目录
  2. 没有的话,继续找有没有定义 \App\Controller\ 对应的目录,一直找到 \App 目录
  3. 然后把目录 \App 切割出来,其余部分 Controller\Foo\BarController 即类名
  4. 拼接成最终的文件路径:application/类名.php(须把类名中的反斜杠 \\ 替换为 DIRECTORY_SEPARATOR

生产服加载优化

综上所述,ClassMap 的查找是最高效的,但缺点是每次有新增的类,都得通过 composer dump-autoload 重新生成,开发时不方便。而 PSR-4 虽然查找遍历灵活,但查找起来运算较多,还有 file_exists 等 IO 操作,总体效率有待加强。

那么生产环境在确定文件不会有动态新增的前提下,我们可以这样优化:

composer dump-autoload -o 或者 --optimize

这条命令的作用是扫描 composer.json 设置的 PSR-4/0 对应目录下所有类文件,把类名和文件路径都冗余记录在 autoload_classmap.php 文件里,以最简单粗暴的方式定位到类所在的文件,内容如下:

$vendorDir = dirname(dirname(__FILE__));
$baseDir = dirname($vendorDir);

return array(
    'App\\Models\\Article' => $baseDir . '/app/models/Article.php',
    'App\\Controllers\\BaseController' => $baseDir . '/app/controllers/BaseController.php',
    'App\\Controllers\\HomeController' => $baseDir . '/app/controllers/HomeController.php',
);

四合一文件 autoload_static.php 的作用

PHP 5.6 以后,为了优化加载大数组,Composer 把上述四个文件合并成了一个 autoload_static.php 文件。

Optimized the autoloader initialization using static loading on PHP 5.6 and above, this reduces the load time for large classmaps to almost nothing

为什么要定义 composerRequire 这个方法?

为了隔离作用域,防止变量被污染: 想象一下,如果有人在 autoload_files 中的文件中写了 $this 或者 self 那就屎了。

composerRequire 里为什么用的是 require 而不是 require_once?

因为 Composer 的开发者认为 require_once 效率低下,而且认为 vendor/autoload.php 为第一等公民,不论什么框架,都必须在入口第一行就引入。

所以 require 足以满足绝大多数场景,后面作者又为了满足避免重复引入的需求,增加了 $GLOBALS 全局数组来做去重,他觉得这样仍然比 require_once 靠谱。

https://github.com/composer/composer/pull/4186

参考文章

Laravel Broadcaster 进阶使用 & 原理分析

上一篇简单介绍了什么是 Laravel 广播,本篇我们来剖析一下 Laravel 广播的原理,以及使用时的注意事项。

正好看到一篇老外写的搭建攻略,也非常不错:

https://medium.com/@dennissmink/laravel-echo-server-how-to-24d5778ece8b

开始使用

Laravel App Server - 应用服务端

修改 .envBROADCAST_DRIVER = redis,同时启用 QUEUE_DRIVER 队列服务,广播队列应独立一条,默认走 default 队列。

因为所有广播事件 App\Events\* 只要实现了 ShowBroadcast 接口,那么都强制走队列,如果想立即发送,则改成实现 ShowBroadcastNow 接口。

代码示例

App\Events\RealTimeStatsUpdated

/**
 * 实时数据广播更新
 *
 * @author JiangJian <silverd@sohu.com>
 */

namespace App\Events;

use Cache;
use Illuminate\Broadcasting\Channel;
use Illuminate\Queue\SerializesModels;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;

class RealTimeStatsUpdated implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    /**
     * 广播指定队列
     *
     * @var string
     */
    public $broadcastQueue = 'broadcasts';

    public $shopId;
    public $apiUri;
    public $shotAt;
    public $extras = [];

    public function __construct(int $shopId, string $apiUri, int $shotAt, array $extras = [])
    {
        $this->shopId = $shopId;
        $this->apiUri = $apiUri;
        $this->shotAt = $shotAt;
        $this->extras = $extras;
    }

    /**
     * 广播频道
     *
     * @return Channel|array
     */
    public function broadcastOn()
    {
        return new PrivateChannel('gmall.shop.' . $this->shopId);
    }

    /**
     * 广播事件名
     *
     * @return string
     */
    public function broadcastAs()
    {
        return 'real-time-stats.updated';
    }

    /**
     * 广播载体 payload 数据
     *
     * @return array
     */
    public function broadcastWith()
    {
        return [
            'api_uri' => $this->apiUri,
            'date'    => date('Y-m-d', $this->shotAt),
            'extras'  => $this->extras,
        ];
    }

    /**
     * 决定是否应该广播此事件
     *
     * @return bool
     */
    public function broadcastWhen()
    {
        // 同一事件冷却5秒
        return Cache::add('RealTimeStatsUpdated:' . $this->apiUri, 1, now()->addSeconds(5));
    }
}

代码关键配置

如需私有频道,修改 config/app.php 中引入(取消注释) App\Providers\BroadcastServiceProvider

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Broadcast;

class BroadcastServiceProvider extends ServiceProvider
{
    public function boot()
    {
        // 私有频道和存在频道鉴权专用
        // 其中的 /broadcasting/auth 仅检测是否游客
        Broadcast::routes(['middleware' => 'api']);

        // 具体的业务范围鉴权
        // 例如只有订单主人才能监听订单事件
        require base_path('routes/channels.php');
    }
}

附:踩坑说明

Laravel 这里原始代码为 Broadcast::routes(),即使用的缺省中间件 ['middleware' => 'web'],表示鉴权访问 /broadcasting/auth 时会走 web 中间件,我们要改成 ['middleware' => 'api'],否则 web 中间件里的 VerifyCsrfToken 验证过不去。或者 CSRF 排除掉 /broadcasting/auth 这个路由也可以解决,如下:

class VerifyCsrfToken extends Middleware
{
    protected $except = [
        '/broadcasting/auth',
    ];
}

私信通知广播

Notifaction 消息通知可以很轻松的支持广播,走的是私有频道。

私信频道名称定义

私信频道默认的频道名为 $notifiable 对象的类名App\Models\Users.{$uid},如果觉得太长了,可以在 User 模型类中重新定义这个频道名:

class User extends Authenticatable
{
    use Notifiable;

    /**
     * 接收用户的频道广播通知.
     *
     * @return string
     */
    public function receivesBroadcastNotificationsOn()
    {
        // 这里的频道名必须跟 `routes/channels.php` 定义的鉴权路由一致
        // 也必须跟客户端 `EchoClient.private(频道名)` 监听的频道名一致
        return 'users.' . $this->id;
    }
}

在具体 Notifacation 实例中跟广播有关的方法定义(仔细看代码注释):

App\Notifications\ShopAlarm

namespace App\Notifications;

use Illuminate\Notifications\Messages\BroadcastMessage;

class ShopAlarm extends Notification
{
    // 广播通知类型
    // 缺省为类名:App\Notifications\ShopAlarm
    public function broadcastType()
    {
        return $this->msgType;
    }

    /**
     * 广播 Payload 数据
     *
     * @param  mixed  $notifiable
     * @return BroadcastMessage|array
     */
    public function toBroadcast($notifiable)
    {
        $message = new BroadcastMessage([
            'shop_id'    => $this->shop->id,
            'type'       => $this->msgType,
            'message'    => $this->message,
            'created_at' => $GLOBALS['_DATE'],
        ]);

        // 所有广播都必须走队列(框架如此,不可修改)
        // 默认走的 default 队列,建议指定一下,换成其他
        return $message->onQueue('broadcasts');
    }

附:踩坑说明

消息通知广播事件实例,最终会被 Illuminate\Notifications\Events\BroadcastNotificationCreated 类包装,它会覆盖我们在 Notification 类定义的载体中的两个字段 idtype,证据如下:

namespace Illuminate\Notifications\Events;

class BroadcastNotificationCreated implements ShouldBroadcast
{
    public function broadcastWith()
    {
        return array_merge($this->data, [
            'id' => $this->notification->id,
            'type' => $this->broadcastType(),
        ]);
    }
}

所以我们定义通知类时注意避开,或者重写 broadcastType() 定义,否则缺省为 Notification 的类名:App\Notification\ShopAlarm

Laravel Echo Server Socket.io 服务端

首先服务端上安装 NodeJS:http://nvm.sh 然后全局安装:

npm install -g laravel-echo-server

Socket.io Server 官方文档:https://github.com/tlaverdure/laravel-echo-server

NodeJs server for Laravel Echo broadcasting with Socket.io. 支持 Pusher、Redis、HTTP 驱动传递消息

安装配置

按照官方文档生成并配置 laravel-echo-server.json 后(建议按环境区分该配置文件),例如 envs/对应环境/laravel-echo-server.json,注意修改以下几个关键字段:

{
  // 后端
  "authHost": "https://dev.api.gmall.gaopeng.com",
  // 订阅驱动
  "database": "redis",
  // 订阅的 Redis 服务器,务必后端配置保持一致
  "databaseConfig": {
    // @see https://github.com/luin/ioredis/blob/HEAD/API.md#new_Redis
    "redis": {
      "port": "6379",
      "host": "127.0.0.1",
      "password": "gaopeng.123",
      "db": 0  // 注意:PUB/SUB跟数据库编号无关,Redis 同时也负责存储『存在频道』信息
    },
    // ...
  },
  // 调试模式(会输出控制台日志,生产服应关闭)
  "devMode": false,
  // WS 服务接受一切本机IP地址
  "host": null,
  // WS 缺省端口
  "port": "6001",
  // WS + SSL = WSS
  "protocol": "https",
  "sslCertPath": "/usr/local/nginx/conf/ssl/api.gmall.gaopeng.com.crt",
  "sslKeyPath": "/usr/local/nginx/conf/ssl/api.gmall.gaopeng.com.key",
}

然后把 laravel-echo-server start 命令加入到 supervisord 中守护

[program:AI_GMall_WebSocketServer]
process_name=%(program_name)s
autostart=true
autorestart=true
redirect_stderr=true
command=laravel-echo-server start --dir=/home/wwwroot/ai_gmall_server/envs/prod
stdout_logfile=/home/wwwlogs/supervisord_ai_gmall_websocket.out

故障排查心得

问题:假设突然发现生产服的 Websocket 实时消息不正常工作了

  1. 首先开启调试模式 laravel-echo-server.jsondevMode=true,这样控制台才会输出日志。
  2. 再查看控制台日志文件 supervisord_ai_gmall_websocket.out,日志里会有连接记录、断开记录、广播的事件发布记录等。

可能的原因:

  • 如果是 WebSocket Server 连接失败,则 Chrome 控制台会红色报错
  • 如果是私有频道授权失败,则 Chrome - Network - WS 里的 Frames 页里会有 subscription_error 的提示
  • 如果还不行,则可能是 Echo Server 和 App Server 之间的通信出错,如果用的 Redis 广播驱动,那么确保双方连的同一台 Redis 服务器且 Redis 服务器正常可用。
  • 如果还不行,请确保频道名称是否跟客户端监听的一致,有可能是 Redis Key Prefix 导致频道名不匹配。

附:私有、存在频道鉴权原理

https://laravel.com/docs/5.6/broadcasting#authorizing-channels

以 API 服务器为例子,鉴权标识为请求头里的 api_token,形式如:Authorization: Bearer ABCDEFG

鉴权步骤:

1、Echo Client 把 api_token 通过 Websocket 协议发至 Echo Server 端。

2、Echo Server 端再通过 HTTP 请求向 App Server 的 http://{authHost}/broadcasting/auth 发起鉴权请求(这个 URL 定义在 laravel_echo_server.json 中)。

3、App Server 代码里通过 $request->user() 获取当前用户实例,注意这里 $request->user($guard = null) 等同于 Auth::user(),只是一种解耦注入的写法。$guard 不填则使用定义在 config/auth.php 中的默认守护器 api

1) 第一步鉴权,检测是否游客(只检测 $request->user() 是否空值) 2) 第二步鉴权,检测频道业务权限(例如是否一个店长才能收到该门店通知)

具体代码可见:Illuminate\Broadcasting\Broadcasters\RedisBroadcaster::auth 方法。

Laravel Echo Client - Socket.io 客户端

安装客户端库:https://www.npmjs.com/package/laravel-echo

npm install --save laravel-echo
npm install --save socket.io-client

Vue 内如何使用?

import Echo from 'laravel-echo'
import io from 'socket.io-client';

const EchoClient = new Echo({
  broadcaster: 'socket.io',
  host: 'http://local.api.gmall.gaopeng.com:7002',
  client: io,
  auth: {
    headers: {
      // 重要:用于私有频道鉴权(同 API 用户鉴权)
      Authorization: 'Bearer e05295c388270d7354864c3231ed7e86c791964e',
    },
  },
});

// 公开频道
EchoClient.channel('gmall.borad')
  .listen('.new-message.created', function (event) {
    console.log(event);
  });

// 私有频道
EchoClient.private('gmall.shop.1')
  .listen('.real-time-stats.updated', function (event) {
    console.log(event);
  });

// 私有频道-私信(消息通知)
// 频道名必须和服务端的 `App\Models\User::receivesBroadcastNotificationsOn()` 
// 以及 `routes/channels.php` 定义的频道鉴权路由保持完全一致
EchoClient.private('users.67')
  .notification(function (notification) {
    console.log(notification);
  });

纯网页中如何使用?

<script src="http://115.159.58.121:10088/js/echo.js"></script>
<script src="http://115.159.58.121:10088/js/socket.io.js"></script>
<script>
window.Echo = new Echo({
  broadcaster: 'socket.io',
  host: 'http://' + window.location.hostname + ':6001',
  client: io,
  auth: {
    headers: {
      // ... 用户鉴权信息
    },
  },
});
Echo.private('gmall.shop.1')
  .listen('.real-time-stats.updated', function (event) {
    console.log(event);
  });
</script>

其中的 echo.jssocket.io.js 去哪里下载?

可以自己通过 npm 安装以下库:

npm install laravel-echo
npm install socket.io-client

然后从以下路径中拷贝出来:

node_modules/laravel-echo/dist/echo.js
node_modules/socket.io-client/dist/socket.io.js