Laravel 5.5 along with XYZ

最近的新项目后端将基于 Laravel 5.5 构建。

本文主要总结使用 Laravel 5.5,及配套的一些组件在进行开发遇到的一些值得记录的东西,以及坑。

Laravel 5.5

重拾 Tinker

这货真的很好用,Laravel 引入的所有类有很多方法名我们其实是不知道的,但是在 tinker 中可以被全部补全出来。

当然除了补全 Laravel 中的类方法和全局函数,也可以和 php -a 一样补全 PHP 内置的函数,且能少些不少语法。

此外,php artisan tinker 后面还可以带要 include 的文件名,这对想要运行小脚本来验证和解决小问题很有用。

总之,对开发期间帮助性很强,十分推荐。

缓存

停用缓存(开发环境)

删除 bootstrap/cache/*.php

php artisan config:cacheenv() 获取为 null

重新运行 php artisan config:clear

路由

自定义 API 路由文件

为了方便管理 API,希望把 API 的路由文件分离到不同的文件,期望 Laravel 自动载入 routes/api/ 目录下的所有路由文件。

修改 App\Providers\RouteServiceProvider,新增 $apiRoutesPath 属性并修改 mapApiRoutes() 方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
protected $apiRoutesPath = 'api';

protected function mapApiRoutes()
{
$routeRegistrar = Route::prefix('api')
->middleware('api')
->namespace($this->namespace);

load_phps(
route_path($this->apiRoutesPath),
function ($file) use ($routeRegistrar) {
$routeRegistrar->group($file);
}
);
}

其中 load_phps() 是我自定义的全局辅助函数,在 Laravel 启动时已经载入,定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function load_phps(string $path, \Closure $callable) {
if (! file_exists($path)) {
excp("PHP files path not exists: {$path}");
}

$result = [];
$fsi = new \FilesystemIterator($path);
foreach ($fsi as $file) {
if ($file->isFile()) {
if ('php' == $file->getExtension()) {
$result[$file->getPathname()] = $callable($file);
}
} elseif ($file->isDir()) {
$_path = $path.'/'.$file->getBasename();
load_phps($_path, $callable);
}
}

unset($fsi);

return $result;
}

授权

路由定义中间件 middleware('auth:api') 代表什么意思?

auth 代表 App\Http\Kernel$routeMiddleware 属性中的定义的别名,api 代表则代表传递给 auth 中间件的门卫参数,代表使用 config/auth.php 中的 guards.api 授权驱动。

如果没有中间件参数,middleware('auth'),则会使用 config/auth.php 中的默认门卫 (defaults.guard) 来认证用户。

为什么 Auth::user() 可以获取当前的认证用户?

config/auth.php 中的有一个 defaults 属性配置,当 guardapi 时,通过 Auth::user() 获取的就是通过 API 门卫认证过的接口用户,此时也等价于 Auth::guard('api')->user();

同样地,当 defaults.guardweb 时,Auth::user() 获取的是 Web 门卫认证过的 WEB 用户,此时也等价于 Auth::guard('web')->user();

如何使用 Laravel 默认的 API 门卫的 token 驱动?

Laravel 5.5 的 config/auth.php 中,guards.api.driver=token 指的是基于数据库的 Token 认证,文档中没有说明怎么使用,但是在一些小项目中还是可以一用的。

下面是启用步骤:

  • users 表中新增 api_token 字段
1
$table->string('api_token')->nullable();
  • 路由定义中使用 auth:api 中间件
1
2
3
Route::middleware('auth:api')->get('users/{user}', function ($user) {
return User::find($user);
});
  • 在请求的查询参数中,使用 api_token 参数
1
curl -v http://localhost/api/users/1?api_token=xxxx

See:

如何自定义认证失败返回信息?
1
2
3
4
5
6
7
8
protected function unauthenticated($request, AuthenticationException $exception)
{
if ($request->expectsJson()) {
return \API::from(401, 'UNAUTHENTICATED');
}

return redirect()->guest(route(env('UNAUTHENTICATED_REDIRECT_ROUTE', 'login')));
}

门面

如何自定义门面?

  • 创建自定义门面
1
2
3
4
5
6
7
8
9
10
11
namespace App\Facades;

use Illuminate\Support\Facades\Facade;

class A extends Facade
{
protected static function getFacadeAccessor()
{
return 'a';
}
}
  • 创建门面背后的实际类
1
2
3
4
5
6
7
8
9
namespace App\Classes;

class A
{
public function sth()
{
return 'do something here.';
}
}
  • 关联门面和实际类

关联上面两者是在服务提供者的 register() 方法中处理的,既可以在默认的 App\Providers\AppServiceProvider 中进行,也可以自己通过 php artisan make:provider AServiceProvider 创建一个服务提供者,然后在新建的服务提供者中注册。

1
2
3
4
5
6
public function register()
{
$this->app->bind('a', function() {
return new App\Classes\A();
});
}
  • 设置门面别名
1
2
3
4
5
6
7
8
9
10
// config/app.php

'providers' => [
// 如果是在新建的服务提供者中注册的 这里也要把服务提供者的类空间写在这里
// App\Providers\AServiceProvider::class,
],

'aliaes' => [
'A' => App\Facades\A::class,
],

至此,就可以通过 \A::sth() 门面来调用 App\Classes\A 的逻辑了。

See: https://laravel-china.org/topics/3265/laravel-53-add-custom-facade-steps

门面返回的始终是单例?

无论在服务提供者注册类的时候,使用的是 $this->app->singleton() 还是 $this->app->bind(),只要你是通过门面调用该类,那么始终是返回同一个类实例,因为门面内部保存了一份实例化列表。

更具体点说,Facade::resolveFacadeInstance 保存了已经解析过的实例。

See: https://github.com/laravel/ideas/issues/1088

模型

自定义模型属性

在一个模型控制器获取一个主要的模型数据后,往往还需要和其他接口或模型或计算结果进行聚合,为了方便直接返回一个模型,可以考虑动态地往模型现有数据中添加其他数据。

  • 创建一个 AttrCustomable trait
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<?php

// Make Model capable to add custom attributes in Laravel 5.5

namespace App\Traits;

trait AttrCustomable
{
private $customAttributes = [];

public function addCustomAttribute(string $key, $value)
{
$this->customAttributes[$key] = $value;

return $this;
}

public function toArray()
{
$data = parent::toArray();

$data = array_filter(array_merge($data, $this->customAttributes));

return $data;
}
}
  • 在模型里使用该 trait
1
2
3
4
5
6
7
8
<?php

namespace App\Models;

class User extends Model
{
use AttrCustomable;
}
  • 在控制器里添加聚合数据
1
2
3
4
5
6
7
8
9
10
11
12
13
<?php

namespace App\Http\Controllers\Api\V1;

class User
{
public function show()
{
return $this->user()
->addCustomAttribute('pionts', 10000)
->addCustomAttribute('foo', 'bar');
}
}

获取隐藏属性

假设在 User 模型的某个方法内,要访问 hidden 过的属性 password,有如下几种方式:

  • 通过模型的 makeVisible 方法 (5.1 是 withHidden)
1
2
3
$this->makeVisible('password');    // 使 password 属性可见
echo $this->password; // 输出用户密码
$this->makeHidden('password'); // 使 password 属性隐藏
  • 通过模型的 getAttributes 方法
1
2
$allAttrs = $this->getAttributes();    // 直接获得模型所有属性(无视可见性设置)
echo $allAttrs['password']; // 输出用户密码
  • 如果模型实现了 Illuminate\Contracts\Auth\Authenticatable 接口,则还可以通过该接口提供的 getAuthPassword 方法只获取密码属性
1
2
3
4
5
6
7
class User extends Model implements \Illuminate\Contracts\Auth\Authenticatable
{
public function index()
{
echo $this->getAuthPassword(); // 输出用户密码
}
}

如何判断模型是否存在

1
$mode->exists;    // !!! 属性调用而非方法

定义资源路由后控制器模型注入失效?

  • 路由定义:
1
2
3
4
5
6
7
8
$router->group([
'prefix' => 'members',
'middleware' => [
'api.auth',
],
], function ($router) {
$router->resource('address', 'MemberAddress');
});

生成路由定义如下:

1
2
3
4
5
- GET|HEAD     /members/address             MemberAddress@index
- POST /members/address MemberAddress@store
- GET|HEAD /members/address/{address} MemberAddress@show
- PUT|PATCH /members/address/{address} MemberAddress@update
- DELETE /members/address/{address} MemberAddress@destroy
  • 以对应的 MemberAddress 控制器的 show 方法举例:

请求 GET /members/address/1,其中 1 号的记录在数据库是实际存在的,可在方法中不是期望的输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
use App\Models\MemberAddress as Address;

public function show(Address $address)
{
dd($address->exists); // 输出 false !!!

dd(func_get_args()); // 输出如下:

array:2 [▼
0 => MemberAddress {#365 ▶}
1 => "1"
]
}

结果显示,这里在向控制器方法自动注入模型的时候,参数和模型没有对应上,导致 $address 只是个空模型,这当然不是我们想要的。

在 dingo api GitHub 的这个 issue 的提示下,去翻了 bindings 这个中间件的源码后找到了我想要的解决办法:

参见:Illuminate\Routing\ImplicitRouteBinding@resolveForRoute

  • 修改 MemberAddress 控制器的构造方法如下:
1
2
3
4
5
6
7
8
public function __construct()
{
$this
->middleware('id_filter:address,\App\Models\MemberAddress')
->only([
'show', 'update', 'destroy'
]);
}
  • 新增中间件并注册到 App\Http\Kernel:
1
2
3
4
protected $routeMiddleware = [
// ...
'id_filter' => \App\Http\Middleware\IDFilter::class,
];
  • IDFilter 中间件内容如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
<?php

// Filter ID for resource routes
// - 1. Check if has given id key
// - 2. Check id value illegality
// - 3. Implicit bind route parameters segment (id key) with given model
// @cjli

namespace App\Http\Middleware;

class IDFilter
{
// TODO
// !!! Find a better way to handle method params count
public function handle(
$request,
\Closure $next,
string $idkey,
string $class = null,
string $abortOn404 = 'no',
string $forget = 'no'
)
{
$route = $request->route();
$id = ($route->parameters[$idkey] ?? false);

if (empty_safe($id) || (! ispint($id)) || ($id < 1)) {
abort(403, "Bad id value of `{$idkey}`: {$id}");
}

if ($class) {
if (! class_exists($class)) {
abort(503, 'Model class not exists: '.$class);
}

$model = app($class);
$_model = $model->where($model->getRouteKeyName(), $id)->first();

if (ci_equal('yes', $abortOn404) && (! $_model)) {
abort(404, 'Model object not found: '.get_class($model));
}

if (ci_equal('yes', $forget)) {
$route->forgetParameter($idkey);
} else {
$route->setParameter($idkey, $_model ?? $model);
}
}

return $next($request);
}
}

其中,getRouteKeyName 默认返回 id 可以在相应模型中重写这个方法实现自定义。

之所以不直接用 Laravel 提供的默认 bindings 中间件,原因有两点:

  • 没有提供(或者我暂时未找到) route segment 过滤的东西,默认找不到直接返回了 404,而我要的是既能正确提示路由参数格式不对,以及在参数格式正确但找不到数据库记录时,仍然要注入一个空模型到控制器方法。
  • 使用了模型的 resolveRouteBinding() 方法,该方法在关联不上路由参数时返回 null 导致进入控制器后还需要再实例化一遍。

Massive Assign

1
2
3
4
5
6
protected $fillable = [
// ...
];

// Or simply:
protected $guarded = [];

使用锁的场景

通常在并发场景下,为了防止某个资源被重复创建/更新,在同时存在查询-更新/创建操作的时候必要使用悲观锁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$amount = 100;
\DB::beginTransction();

// 锁住该用户对应的行记录,防止并发中的其他请求修改该用户状态
$user = \App\Models\User::lockForUpdate()->find(1); # 不可读不可写
$user = \App\Models\User::sharedLock()->find(1); # 可读不可写

$log = \App\Models\BalanceLog::insert([
'before' => $user->balance,
'create_at' => time(),
'amount' => $amount,
]);

$user->balance += $amount;
$user->save();
\DB::commit();

如果不使用 lockForUpdate() 或者 sharedLock() 来锁住该用户对应的记录,那么在多个相同请求到来时,该用户的余额和日志会被更新//创建多条,这明显不是我们想要的。

lockForUpdate() 创建的锁将在本次事务结束后释放,且如果记录不存在,lockForUpdate() 则不会起到什么作用。

关于事务深度

由于行锁必须再一个事务中才有效,因此下面的写法是不起作用的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public function ifUserExists(int $id)
{
if ($user = \App\Models\User::sharedLock()->find($id)) {
return $user;
}

return false;
}

public function transfer()
{
\DB::beginTransaction();
// 下面的写法在并发场景下仍然会有重放 BUG
if (($userA = $this->ifUserExists(1)) && ($userB = $this->ifUserExists(2))) {
$userA->balance -= 100;
$userB->balance += 100;
if ($userA->save() && $userB->save()) {
return \DB::commit();
}
}

\DB::rollBack(); // !!! back 的 `B` 必须要大写(略坑)
}

如果出现了不方便把事务涉及的所有代码在一个方法或位置写完的情况,如上所示,可以在查询欲锁定记录的时候就开启事务,等到真正要提交事务时,使用事务深度来检查,改变代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public function ifUserExists(int $id)
{
if (0 === \DB::transactionLevel()) {
\DB::beginTransaction();
}

if ($user = \App\Models\User::sharedLock()->find($id)) {
return $user;
}

return false;
}

public function transfer()
{
if (0 === \DB::transactionLevel()) {
\DB::beginTransaction();
}

if (($userA = $this->ifUserExists(1)) && ($userB = $this->ifUserExists(2))) {
$userA->balance -= 100;
$userB->balance += 100;
if ($userA->save() && $userB->save()) {
return \DB::commit();
}
}

\DB::rollBack(); // !!! back 的 `B` 必须要大写(略坑)
}

当然,这里只是提供一种思路,应该还有其他解决办法。

注意:每执行 DB::rollBack()DB::commit() 一次,事务深度便减少一次;每执行 DB::beginTransaction(),事务深度便增加一次。

清除队列中可能由任务失败导致的未回滚事务

1
2
3
4
5
6
7
//  App\Providers\AppServiceProvider@boot()

Queue::looping(function () {
while (DB::transactionLevel() > 0) {
DB::rollBack();
}
});

迁移

doctrine/dbal 修改表字段为 tinyInteger 报错?

https://github.com/laravel/framework/issues/8840

1
2
3
4
5
6
7
8
Schema::table('member_address', function (Blueprint $table) {
$table
// ->unsignedTinyInteger('is_default') // will trigger a doctrine/dbal error
->boolean('is_default') // use tinyint as default (wtf)
->default(0)
->comment('是否为默认收货地址')
->change();
});

Laravel 中类成员修饰符必须为 public 的情况

  • 控制器的路由直连方法,路由直接关联的控制器方法非 public 执行会报错。
  • 模型的 $timestamps
  • 队列事件监听者的 $connection$queue,如果不是 public,则将选择默认连接和默认队列名(!!! 好坑)。

调度器/Schedule

正确使用 $schedule->job()

1
2
3
4
5
// app/Console/Kernel.php
protected function schedule(Schedule $schedule)
{
$schedule->job((new \App\Jobs\Foo))->everyMinute();
}

在上面的代码中,调度器每次执行 job() 方法后,只是把一个新的 App\Jobs\Foo 任务放到了队列,并非实际执行该任务,因此,任务 Foo 依然需要在队列监听器中执行。

如果要在 job() 方法中指定队列名和连接,可以通过 (new \App\Jobs\Foo)->dispatch()->onConnection('redis')->onQueue('bar') 实现。

不过会 schedule:run 执行时会报一条错:

In BoundMethod.php line 135:

Method Illuminate\Foundation\Bus\PendingDispatch::handle() does not exist

指定队列连接名称

指定队列连接为 database

1
php artisan queue:listen database --queue xxx

withoutOverlapping() 的任务未被执行?

可能由于一些异常的原因,导致了调度的任务未被正常结束,默认 mutex 锁对应的缓存文件未能被正常删除,导致 24 小时内跳过执行。

1
php artisan cache:clear

此外,可以在 withoutOverlapping($minutes) 参数中,根据任务实际耗时情况,设置一个合理的缓存时间。

事件/Event

简单实现 Queued Event Listener 负载均衡

默认的 Queued Event Listener 类中只能指定一个特定的 $queue 值,如果一个事件发生后有很多后续任务需要被处理,单队列处理速度可能比较慢,这时可以将工作量散列到一批队列中并行处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 0. Bind Event and listener
protected $listen = [
'App\Events\TestEvent' => [
'App\Listeners\TestEventListener',
],
];

// 1. Prepare Job Worker
class TestJobWorker
{
// ...
}

// 2. Trigger event
event(new \App\Events\TestEvent);

// 3. Listener => As Job handler Master
public function handle($event)
{
$workers = 10;

// 一个事件对应一个待处理连续事件
dispatch(new \App\Jobs\TestJobWorker)
->onConnection('take_redis_for_example')
->onQueue('queue_job_prefix_'.($event->getPartionId() % $workers));

// 一个事件对应多个待处理连续事件
for ($i = 0; $i < $workers; ++$i) {
dispatch(new \App\Jobs\TestJobWorker)
->onConnection('take_redis_for_example')
->onQueue('queue_job_prefix_'.$i);
}
}

// 4. Process corresponding queues
php artisan queue:work --queue=queue_job_prefix_0 --timeout=0
php artisan queue:work --queue=queue_job_prefix_1 --timeout=0
php artisan queue:work --queue=queue_job_prefix_2 --timeout=0
# ...

这个例子简单借鉴了 master-worker 形式,放弃了将 Listener 自身加入队列,然后在单个队列中处理的方式,而是作为任务分配者将任务派发到多个队列中并行执行。

自定义 Validator

Laravel 自带的 Validator 方法基本够用,但是对我来说仍然是欠缺了一些,比如对闭包的检查。下面就以这个小需求延时如何实现自定义校验器。

  • 自定义 Validator 类:app/Custom/Validator.php
1
2
3
4
5
6
7
8
9
10
11
12
13
<?php

// Custom validator extends from laravel default

namespace App\Custom;

class Validator extends \Illuminate\Validation\Validator
{
public function validateClosure($attribute, $value, $params)
{
return $value instanceof \Closure;
}
}
  • 启用 ValidatorServiceProvider:app/Providers/ValidatorServiceProvider.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use App\Custom\Validator;

class ValidatorServiceProvider extends ServiceProvider
{
public function boot()
{
\Validator::resolver(function ($translator, $data, $rules, $messages) {
return new Validator($translator, $data, $rules, $messages);
});
}

public function register()
{
}
}
  • 配置:config/app.php
1
2
3
4
5
'providers' => [
// ...
App\Providers\ValidatorServiceProvider::class,
// ...
]
  • 提示语:resources/lang/en/validation.php
1
2
3
4
5
return [
// ...
'closure' => 'The :attribute is not a valid Closure',
// ...
];
  • 使用和基本校验器使用方式一致:
1
'callback' => 'required|closure'

自定义校验器提示语

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
if (! fe('validate')) {
function validate(
array $data = [], array $rules = [], array $messages = []
) {
$validator = validator($data, $rules, $messages);

if ($validator->fails()) {
return $validator->errors()->first();
}

return true;
}
}

validate([
'nric' => '111222333',
], [
'nric' => 'required|string|min:18',
], [
'nric.required' => '身份证号码不能为空',
'nric.min' => '请输入正确的身份证号码 :nric',
]);

自定义维护模式提示语

1
php artisan down --message '系统升级中,稍后再试...'

测试

本地 HTTP 测试环境配置

默认情况,Laravel 读取 config('app.url') 来作为测试地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// vendor/laravel/framework/src/Illuminate/Foundation/Testing/Concerns/MakesHttpRequests.php

protected function prepareUrlForRequest($uri)
{
if (Str::startsWith($uri, '/')) {
$uri = substr($uri, 1);
}

if (! Str::startsWith($uri, 'http')) {
$uri = config('app.url').'/'.$uri;
}

return trim($uri, '/');
}

config('app.url') 会去 config/app.phpurl 参数:

1
'url' => env('APP_URL', 'http://localhost'),

因此得出,在默认情况下,Laravel HTTP 测试环境最终是由 .env 里面的 APP_URL 决定的。

当然了,如果由于某些原因(比如 .env 不可靠导致测试结果随机坏掉)需要自定义,除了修改 .env 文件外,还可以通过以下方式进行修改:

  • 修改 phpunit.xml

It seems that the test suite picks up the values from the .env file, and that doesn’t always work out well when running Laravel’s feature tests (especially when POST-ing to an endpoint).

I haven’t had the time to dig into it and file a formal bug report yet, but I have been able to get around it by explicitly setting the APP_URL environment variable in my phpunit.xml file, within the node:

1
2
3
4
5
6
<?xml version="1.0" encoding="UTF-8"?>
<phpunit ...>
<php>
<env name="APP_URL" value="http://example.com"/>
</php>
</phpunit>

This explicitly sets the APP_URL to “http://example.com” within the testing environment, overriding anything that might be in your local .env file.

  • tests/TestCase.php
1
protected $baseUrl = 'http://example.com';
  • Force/Hard-code host
1
\URL::forceRootUrl('http://dev.myappnamehere');

phpunit 指定测试某个用例

比如我想要只测试正在开发中的 FooV1Test,命令如下:

1
./vendor/bin/phpunit --filter FooV1Test

Laravel-Admin

这是个专门写后台管理系统的轮子。简而言之,是用 PHP 同时写后台页面和后端逻辑。

优点是将 Web 前端组件很好地封装到后端,使后端人员可以不用操心页面上的东西,要什么页面元素按照文档照做就行了,这么一来,写一个后台只需要后端就够了。

无权访问?

查看登录的账户在 admin_user_permissions 有无关联的记录。

如何重复初始化 Laravel-Admin 初始化数据?

开发环境有时候难免会需要推倒重来,如何不通过导入 SQL 来初始化 Laravel-Admin 的初始化数据呢?

其实很简单,Laravel-Admin 在进行安装的时候会运行 Encore\Admin\Auth\Database\AdminTablesSeeder 这个 Seeder,因此只需要重新执行该 Seeder 类的 run() 方法就行了。

1
2
3
4
5
6
7
8
<?php
// database/seeds/AdminSeeder.php

use Encore\Admin\Auth\Database\AdminTablesSeeder;

class AdminSeeder extends AdminTablesSeeder
{
}

然后调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// database/seeds/DatabaseSeeder.php

use Illuminate\Database\Seeder;
<?php

use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
public function run()
{
$this->call([
AdminSeeder::class,
]);
}
}

如何更新关联模型的表单的?

通过 Encore\Admin\Controllers\ModelFormupdate() 方法调用 Encore\Admin\FormupdateRelation() 来实现的。

控制器中的表单方法举例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public function form()
{
return Admin::form(Member::class, function (Form $form) {
$form->disableReset();
$form->display('id', 'ID');

$form->text('user.name', '昵称');
$form->select('user.sex', '性别')->options([
'sir' => '先生',
'lady' => '女士',
'unknown' => '未知',
]);

$form->display('mobile', '手机号码');
$form
->select('status', '状态')
->options($this->getMemberStatusList(false));
});
}

这个例子中,主模型是 Member,它有一个 user() 方法,返回的是 blongsTo 关系:

1
2
3
4
5
6
7
public function user()
{
return $this->belongsTo(User::class);

// return $this->__user
// ?: ($this->__user = $this->belongsTo(User::class));
}

v1.5.9 中没有加入对 belongsTo 关系的支持,对这种关系的更新需要手动添加,见:https://github.com/z-song/laravel-admin/issues/1168

重写 ModelForm@destroy

默认的 destory 方法太实在了,真的是从数据库物理删除来的。此外,有些表不是简单根据 ID来删除的,有些关系不得不同时处理,总之,这方法必须重写。软删除大法好。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public function destroy($id)
{
return Member::whereIn('id', explode(',', $id))->update([
'status' => 'deleted',
])
? response()->json([
'status' => true,
'message' => trans('admin.delete_succeeded'),
])
: response()->json([
'status' => false,
'message' => trans('admin.delete_failed'),
]);
}

Method Encore\Admin\Grid::__toString() must not throw an exception, caught Error: Call to a member function newFromBuilder() on null

$grid 所在闭包中必须至少要有一列。

  • 会报错
1
2
3
4
5
6
7
8
9
10
protected function grid()
{
return Admin::grid(MemberHcmcoinLog::class, function (Grid $grid) {
$grid->paginate(10);

$grid->actions(function ($actions) {
$actions->disableDelete();
});
});
}
  • 不会报错
1
2
3
4
5
6
7
8
9
10
11
12
13
protected function grid()
{
return Admin::grid(MemberHcmcoinLog::class, function (Grid $grid) {
$grid->paginate(10);

$grid->actions(function ($actions) {
$actions->disableDelete();
});

$grid->id('日志ID')->sortable();
$grid->member_id('member_id', '会员ID');
});
}

好气呀,我是如何找到这种报错原因的?当然是猜的看代码看出来的。(:P

表格

禁用批量删除

由于 $grid->disableBacthActions() 已被弃用,所以要禁用批量删除可以这么做:

1
2
3
4
5
6
7
8
9
10
11
12
13
return Admin::grid(MemberHcmcoinLog::class, function (Grid $grid) {
// 完全禁用批量操作
$grid->tools(function ($tools) {
$tools->batch(function ($batch) {
$batch->disableDelete();
});
});

// 或者 简单粗暴 JS 控制
Admin::script('$(".grid-batch-0").parent().parent().hide()');

// ...
}

禁用操作按钮

1
2
3
4
5
6
7
8
// 最干净
$grid->disableActions();

// 局部禁用
$grid->actions(function ($actions) {
$actions->disableEdit();
$actions->disableDelete();
});

查询条件

所有 Laravel 自身支持的数据库查询方法都可以通过 $grid->model() 来调用,比如要表格默认倒序查询:

1
$grid->model()->orderByDesc('id');

表单

表单验证:拉黑手机号
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$form->text('mobile')->help('要拉黑的手机号')->rules(function ($form) {
$rule = 'required|mobile_zh';

if ($id = ($form->model()->id ?? false)) {
if ($exists = Blacklist::whereId('!=', $id)
->pluck('mobile')
) {
$rule .= '|not_in:'.implode(',', $exists->toArray());
}
} else {
$rule .= '|unique:ylh_account_blacklist,mobile';
}

return $rule;
}, [
'required' => '手机号码不能为空',
'mobile_zh' => '手机号码格式不正确',
'unique' => '该手机号码已被拉黑',
'not_in' => '该手机号码已被拉黑',
]);
省市区县四级联动
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
$model    = $form->model();
$province = $model->province_id ?: -1;
$city = $model->city_id ?: -2;
$county = $model->county_id ?: -3;
$town = $model->town_id ?: -4;

$provinces = Area::whereIdOrParentId(
$province, 0
)->pluck('name as text', 'id');

$cities = Area::whereIdOrParentId(
$city, $province
)->pluck('name as text', 'id');

$counties = Area::whereIdOrParentId(
$county, $city
)->pluck('name as text', 'id');

$towns = Area::whereIdOrParentId(
$town, $county
)->pluck('name as text', 'id');

if ($form->model()->id) {
$form->select('province_id', '所在省')->options($provinces);
$form->select('city_id', '所在市')->options($cities);
$form->select('county_id', '所在区县')->options($counties);
$form->select('town_id', '所在镇/街道')->options($towns);
} else {
$form
->select('province_id', '选择省')
->options($provinces)
->load('city_id', '/api/arealist');

$form
->select('city_id', '选择市')
->options($cities)
->load('county_id', '/api/arealist');

$form
->select('county_id', '选择区/县')
->options($counties)
->load('town_id', '/api/arealist');

$form
->select('town_id', '镇/街道')
->options($towns);
}
一对多二级联动

父级选项是单项数据,子级选项是多项数据,且两者是一对多的关系:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$form
->select('parent_id', '父分类')
->rules(function () {
// 校验规则
$rules = 'in:__ERROR__';
}, [
// 自定义提示语
'in' => '你的输入有些错误',
])
->options(function ($parentId) {
if ($parent = Parent::find($parentId)) {
return [$parent->id => $parent->text];
}
return [0 => '默认父分类'];
})
// ajax() 需要返回一个 Laravel 分页响应
->ajax('/path/to/parent_api')

// load() 需要返回一个简单的 id => text 数组列表
->load('child_ids', '/path/to/child_ids');

$form->multipleSelect('child_ids', '子分类集合');

上面的需要注意的有两点:

  • ajax()load() 需要 API 返回的数据格式是不一样的。(坑
  • child_ids 在提交时会有一个 null 值,需要在 ModelForm@store() 方法里面做下过滤:
1
2
3
4
5
6
7
public function store()
{
$ids = array_filter(request->all());

// parent::store();
// ...
}

DI 找不到模型?

检查路由参数字符串和控制器方法参数名是否一致。

自定义提示信息

错误信息

1
2
3
4
5
6
$error = new MessageBag([
'title' => '输入有误',
'message' => '手机号码为空或格式不正确',
]);

return back()->withInput()->with(compact('error'));

提示语

1
2
3
admin_toastr('hello ...');

return redirect()->to(route('path_to_one_route'));

PJAX 提示接口返回值

1
2
3
4
return response()->json([
'status' => true,
'message' => trans('admin.delete_succeeded'),
]);

/auth/logs PHP Fatal error: Allowed memory size of 134217728 bytes exhausted

这可能是因为 users 表记录太多导致的,因为后台系统一般只会有几个管理员,因此 Laravel-Admin 在首页把所有管理员日志都取出来了。导致报错的代码如下:

1
2
3
4
// vendor/encore/laravel-admin/src/Controllers/LogController.php

$filter->equal('user_id')->select(Administrator::all()->pluck('name', 'id'));
$filter->equal('method')->select(array_combine(OperationLog::$methods, OperationLog::$methods));

修改为:

1
2
3
4
5
6
7
8
9
$filter->equal('user_id', '管理员 ID');
$filter->equal('method', '请求方式')->select([
'GET' => '查询/获取',
'POST' => '创建/新增',
'PUT' => '覆盖更新',
'PATCH' => '局部更新',
'DELETE' => '删除',
'OPTIONS' => '跨域预检请求',
]);

Dingo API

虽然对于 Laravel 5.5 来说,要实现 Dingo API 的所有功能易如反掌,但之所以选择 Dingo API,主要是因为已经有很多的人用它,相信它封装得更标准,更简便,更稳定,不想花时间重复造轮子罢了。

如果使用过程发现并不好用,或者不太适合我们的项目,这样改造起来也有针对性。

优缺点

  • 优点:可以自动生成文档

Dingo API 使用提供了 api:docs 这个 artisan 命令,可以自动为 API 生成版本文档。

  • 缺点:文档生成不够智能,代码中注释量增多

虽然在定义 API 端点的时候指已经明了版本号和路由,但是为了生成更好看的 API 文档,仍然需要在类和方法前面重新定义路由的类型和路径,已经版本号。这样一来,代码中要添加很多额外的注释。

  • 缺点:api:cache 会缓存除了 API 以外的所有路由。

期望只缓存通过 Dingo API 定义好的路由。

FAQ

  • versiongroup 的别名
1
2
// 获得版本号为 v1 的路由列表
dd(version('v1'));

如何访问指定版本的接口?

在配置 Dingo API 的时候,有个配置项叫做 API_VERSION 的配置,这个配置的作用是作为默认版本号,即客户端当没有指定。

在 HTTP 请求头中添加 Accept 字段,举例说明:

1
2
3
curl -X GET http://api.example.com/user \
-H 'Authorization: Bearer ??TOKEN??' \
-H 'Accept: application/vnd.{API_SUBTYPE}.{VERSION_NUMBER}+json' \

其中,API_SUBTYPE 就是.env 中配置的 API_SUBTYPEVERSION_NUMBER 就是使用 version() 方法定义过的版本号,vndAPI_STANDARDS_TREE 推荐的值,json 代表客户端期望服务器返回的数据格式。

如何自定义错误提示信息 ‘Failed to authenticate because of bad credentials or an invalid authorization header’?

找到 \App\Providers\RouteServiceProvider@mapApiRoutes() 并添加:

1
2
3
4
5
6
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;

app('Dingo\Api\Exception\Handler')
->register(function (UnauthorizedHttpException $e) {
return api_response_i18n(401, 'UNAUTHENTICATED');
});

see: https://github.com/dingo/api/wiki/Errors-And-Error-Responses

如何正确使用自动刷新 Token 机制?

需要前后端协作好:后端要根据当此请求携带的 Token 自动生成一条新 Token,并返回给前端;前端要做好自动使用后端每次返回的更新 Token 来作为下次 API 请求的 Token。

tymon/jwt-auth 认证驱动为例,在 Tymon\JWTAuth\Providers\AbstractServiceProvider 中已经定义好 $middlewareAliases 中间件别名如下:

1
2
3
4
5
6
protected $middlewareAliases = [
'jwt.auth' => Authenticate::class,
'jwt.check' => Check::class,
'jwt.refresh' => RefreshToken::class, // 重新刷新 TOKEN
'jwt.renew' => AuthenticateAndRenew::class,
];

可以直接在路由定义或控制器构造函数中使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 路由中使用
dingo()->version('v1', [
'namespace' => 'App\Http\Controllers\Api\V1',
'middleware' => [
'jwt.refresh',
],
], function ($router) {
// ...
});

// 在控制器中使用
public function __construct()
{
$this->middleware('jwt.refresh');
}

每次请求 API 成功后,均会在响应头中的 Authorization 字段,其值便是刷新后的 Token。

如何使用 dingo Router 中 name() 定义过的 API 路由?

Dingo 定义过的的命名路由是不能用 Laravel 自带的 route 方法获取完整 URL 的,只能用 Dingo 的 URL 生成器。

举例说明:

1
2
3
4
5
6
7
8
9
10
11
12
// 定义微信授权登录回调路由
$router
->post('{user}/wechat', 'Passport@oauthloginFromWechat')
->name('callback.oauth.wechat');

// 错误写法
route('callback.oauth.wechat'); // 提示找不到路由

// 正确写法
app('Dingo\Api\Routing\UrlGenerator')
->version('v1')
->route('callback.oauth.wechat', ['user' => 1]);

参考