Laravel 启动指南(六)
原文:
zh.annas-archive.org/md5/d0c72cd35a2ef551cf4f36bed0d4e4e2译者:飞龙
第十三章:编写 API
Laravel 开发者面临的最常见任务之一是创建 API,通常是 JSON 和 REST 或类似 REST 的,允许第三方与 Laravel 应用程序的数据进行交互。
Laravel 让与 JSON 工作变得非常容易,并且它的资源控制器已经围绕 REST 动词和模式进行了结构化。在本章中,您将学习一些基本的 API 编写概念,Laravel 提供的编写 API 的工具,以及在编写您的第一个 Laravel API 时需要考虑的一些外部工具和组织系统。
REST-Like JSON API 的基础知识
表述性状态转移(REST)是一种用于构建 API 的架构风格。技术上来说,REST 或者是一个广义定义,几乎可以适用于整个互联网,或者是一个如此具体的东西,以至于没有人真正使用它,所以不要让自己被定义或与书呆子争论所困扰。在 Laravel 的世界中,当我们谈论 RESTful 或类似 REST 的 API 时,通常是指具有以下几个共同特征的 API:
-
它们围绕可由 URI 唯一表示的“资源”进行组织,比如
/cats表示所有猫,/cats/15表示 ID 为 15 的单个猫等。 -
主要使用 HTTP 动词(
GET/cats/15与DELETE /cats/15)与资源进行交互。 -
它们是无状态的,这意味着请求之间没有持久的会话身份验证;每个请求必须唯一验证自己。
-
它们是可缓存且一致的,这意味着每个请求(除了少数特定于经过身份验证的用户的请求)无论请求者是谁,都应该返回相同的结果。
-
它们返回 JSON。
最常见的 API 模式是为每个 Eloquent 模型创建一个唯一的 URL 结构,将其公开为 API 资源,并允许用户使用特定的动词与该资源进行交互并获取 JSON 返回。示例 13-1 展示了一些可能的示例。
示例 13-1. 常见的 REST API 端点结构
GET /api/cats
[
{
id: 1,
name: 'Fluffy'
},
{
id: 2,
name: 'Killer'
}
]
GET /api/cats/2
{
id: 2,
name: 'Killer'
}
POST /api/cats with body:
{
name: 'Mr Bigglesworth'
}
(creates new cat)
PATCH /api/cats/3 with body:
{
name: 'Mr. Bigglesworth'
}
(updates cat)
DELETE /api/cats/2
(deletes cat)
这让你了解到我们可能与 API 交互的基本集合。让我们深入了解如何通过 Laravel 实现它们。
控制器组织和 JSON 返回
Laravel 的 API 资源控制器类似于普通资源控制器(参见“资源控制器”),但修改为与 RESTful API 路由对齐。例如,它们排除了 create() 和 edit() 方法,这两者在 API 中是不相关的。让我们从这里开始。首先,我们将为我们的资源创建一个新的控制器,并将其路由到 /api/dogs:
php artisan make:controller Api/DogController --api
示例 13-2 展示了我们的 API 资源控制器的样子。
示例 13-2. 生成的 API 资源控制器
<?php
namespace App\Http\Controllers\Api;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
class DogController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index()
{
//
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request)
{
//
}
/**
* Display the specified resource.
*/
public function show(string $id)
{
//
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, string $id)
{
//
}
/**
* Remove the specified resource from storage.
*/
public function destroy(string $id)
{
//
}
}
文档块几乎讲述了整个故事。index() 列出所有狗,show() 列出单个狗,store() 存储新狗,update() 更新狗,destroy() 删除狗。
让我们快速制作一个模型和一个迁移,以便我们可以处理它:
php artisan make:model Dog --migration
php artisan migrate
太棒了!现在我们可以填充我们的控制器方法了。
这些代码示例工作所需的数据库要求
如果您希望我们在这里编写的代码实际上起作用,您将希望在迁移中添加一个名为name的string()列,另一个名为breed,并将这些列添加到 Eloquent 模型的fillable属性,或者只是将该模型的guarded属性设置为空数组([])。稍后的示例还将需要weight、color的列,以及bones和friends的关系。
我们可以利用 Eloquent 的一个很棒的特性:如果您输出一个 Eloquent 结果集合,它会自动将自己转换为 JSON(使用__toString()魔术方法,如果您感兴趣的话)。这意味着,如果您从路由返回一个结果集合,您实际上将返回 JSON。因此,正如示例 13-3 所示,这将是您写过的一些最简单的代码。
示例 13-3. Dog实体的示例 API 资源控制器
...
class DogController extends Controller
{
public function index()
{
return Dog::all();
}
public function store(Request $request)
{
return Dog::create($request->only(['name', 'breed']));
}
public function show(string $id)
{
return Dog::findOrFail($id);
}
public function update(Request $request, string $id)
{
$dog = Dog::findOrFail($id);
$dog->update($request->only(['name', 'breed']));
return $dog;
}
public function destroy(string $id)
{
Dog::findOrFail($id)->delete();
}
}
Artisan 的make:model命令还有一个--api标志,您可以传递以生成与上述相同的 API 特定控制器:
php artisan make:model Dog --api
如果您想要一次性生成迁移、seeder、factory、policy、资源控制器以及存储和更新表单请求,并在一条命令中使用--all标志:
php artisan make:model Dog --all
示例 13-4 展示了我们如何在路由文件中链接它。正如您所见,我们可以使用Route::apiResource()自动将所有这些默认方法映射到相应的路由和 HTTP 动词。
示例 13-4. 绑定资源控制器的路由
// routes/api.php
Route::namespace('App\Http\Controllers\Api')->group(function () {
Route::apiResource('dogs', DogController::class);
});
就是这样!您的第一个 Laravel RESTful API。当然,您需要更多的细微差别:分页、排序、认证和更好定义的响应头。但这是其他一切的基础。
读取和发送标头
REST API 通常使用标头读取和发送非内容信息。例如,对 GitHub 的任何 API 请求都将返回详细说明当前用户的速率限制状态的标头:
X-RateLimit-Limit: 5000
X-RateLimit-Remaining: 4987
X-RateLimit-Reset: 1350085394
同样,许多 API 允许开发人员使用请求头自定义其请求。例如,GitHub 的 API 使用Accept头很容易定义要使用的 API 版本:
Accept: application/vnd.github.v3+json
如果您将v3更改为v2,GitHub 将将您的请求传递到其 API 的第 2 版本。
让我们快速学习如何在 Laravel 中同时做这两件事。
在 Laravel 中发送响应标头
我们在第十章已经详细讨论了这个主题,但这里是一个快速的复习。一旦您有了一个响应对象,您可以使用header(*$headerName*, *$headerValue*)添加一个标头,就像在示例 13-5 中所见。
示例 13-5. 在 Laravel 中添加响应头
Route::get('dogs', function () {
return response(Dog::all())
->header('X-Greatness-Index', 12);
});
很简单易行。
在 Laravel 中读取请求标头
如果您有一个传入请求,读取任何给定标头也很简单。示例 13-6说明了这一点。
示例 13-6。在 Laravel 中读取请求标头
Route::get('dogs', function (Request $request) {
var_dump($request->header('Accept'));
});
现在您可以读取传入的请求标头并在 API 响应中设置标头了,让我们看看如何自定义您的 API。
Eloquent 分页
分页是大多数 API 需要考虑特殊说明的第一个地方。Eloquent 提供了一个分页系统,直接连接到任何页面请求的查询参数。我们在第六章中已经简要介绍了分页组件,但这里是一个快速的复习。
任何 Eloquent 调用都提供了一个paginate()方法,您可以在其中传递希望每页返回的项目数。然后,Eloquent 会检查页面查询参数的 URL,并且如果设置了,将其视为用户在分页列表中的位置(多少页)的指示器。
要使您的 API 路由准备好自动化的 Laravel 分页,请在调用 Eloquent 查询的路由中使用paginate()而不是all()或get();类似于示例 13-7。
示例 13-7。一个分页的 API 路由
Route::get('dogs', function () {
return Dog::paginate(20);
});
我们已经定义了 Eloquent 应该从数据库中获取 20 个结果。根据page查询参数设置的内容,Laravel 将准确知道为我们拉取哪 20 个结果:
GET /dogs - Return results 1-20
GET /dogs?page=1 - Return results 1-20
GET /dogs?page=2 - Return results 21-40
注意,paginate()方法也适用于查询构建器调用,如示例 13-8所示。
示例 13-8。在查询构建器调用中使用paginate()方法
Route::get('dogs', function () {
return DB::table('dogs')->paginate(20);
});
不过,这里有一些有趣的地方:当您将其转换为 JSON 时,它不仅会返回 20 个结果。相反,它将构建一个响应对象,自动向最终用户传递一些有用的与分页相关的详细信息,并且从我们的调用中显示可能的响应,缩减为仅三条记录以节省空间。
示例 13-9。来自分页数据库调用的示例输出
{
"current_page": 1,
"data": [
{
'name': 'Fido'
},
{
'name': 'Pickles'
},
{
'name': 'Spot'
}
]
"first_page_url": "http://myapp.com/api/dogs?page=1",
"from": 1,
"last_page": 2,
"last_page_url": "http://myapp.com/api/dogs?page=2",
"links": [
{
"url": null,
"label": "« Previous",
"active": false
},
{
"url": "http://myapp.com/api/dogs?page=1",
"label": "1",
"active": true
},
{
"url": null,
"label": "Next »",
"active": false
}
],
"next_page_url": "http://myapp.com/api/dogs?page=2",
"path": "http://myapp.com/api/dogs",
"per_page": 20,
"prev_page_url": null,
"to": 2,
"total": 4
}
排序和过滤
尽管 Laravel 中有关于分页的约定和一些内置工具,但没有关于排序的内容,因此您必须自己解决。我将在这里快速给出一个代码示例,并且我将类似于 JSON API 规范(在下面的侧边栏中描述)样式化查询参数。
您的 API 结果排序
首先,让我们设置排序结果的能力。我们从示例 13-10开始,只能按单列和单方向排序。
示例 13-10。最简单的 API 排序
// Handles /dogs?sort=name
Route::get('dogs', function (Request $request) {
// Get the sort query parameter (or fall back to default sort "name")
$sortColumn = $request->input('sort', 'name');
return Dog::orderBy($sortColumn)->paginate(20);
});
我们在示例 13-11中添加了反转的能力(例如?sort=-weight)。
示例 13-11。单列 API 排序,带有方向控制
// Handles /dogs?sort=name and /dogs?sort=-name
Route::get('dogs', function (Request $request) {
// Get the sort query parameter (or fall back to default sort "name")
$sortColumn = $request->input('sort', 'name');
// Set the sort direction based on whether the key starts with -
// using Laravel's starts_with() helper function
$sortDirection = str_starts_with($sortColumn, '-') ? 'desc' : 'asc';
$sortColumn = ltrim($sortColumn, '-');
return Dog::orderBy($sortColumn, $sortDirection)
->paginate(20);
});
最后,在示例 13-12 中,我们也为多列(例如,?sort=name,-weight)执行相同操作。
示例 13-12. JSON API 风格的排序
// Handles ?sort=name,-weight
Route::get('dogs', function (Request $request) {
// Grab the query parameter and turn it into an array exploded by ,
$sorts = explode(',', $request->input('sort', ''));
// Create a query
$query = Dog::query();
// Add the sorts one by one
foreach ($sorts as $sortColumn) {
$sortDirection = str_starts_with($sortColumn, '-') ? 'desc' : 'asc';
$sortColumn = ltrim($sortColumn, '-');
$query->orderBy($sortColumn, $sortDirection);
}
// Return
return $query->paginate(20);
});
正如您所看到的,这并不是最简单的过程,您可能希望围绕重复的过程构建一些辅助工具,但我们正在逐步构建 API 的可定制性,使用逻辑和简单的功能。
过滤您的 API 结果
在构建 API 时,另一个常见任务是仅过滤出特定数据子集。例如,客户端可能会要求列出吉娃娃犬的列表。
JSON API 在这里没有为我们提供任何优秀的语法建议,除了我们应该使用filter查询参数。让我们沿着排序语法的思路,将所有内容放入单一键中——也许是?filter=breed:chihuahua。您可以在示例 13-13 中看到如何做到这一点。
示例 13-13. API 结果的单个过滤器
Route::get('dogs', function () {
$query = Dog::query();
$query->when(request()->filled('filter'), function ($query) {
[$criteria, $value] = explode(':', request('filter'));
return $query->where($criteria, $value);
});
return $query->paginate(20);
});
注意,在示例 13-13 中,我们使用request()辅助函数而不是注入$request实例。两者功能相同,但有时在闭包内工作时,request()辅助函数可能更方便,这样您就不必手动传递变量。
而且,仅仅是为了好玩,在示例 13-14 中,我们允许多个过滤器,例如?filter=breed:chihuahua,color:brown。
示例 13-14. API 结果的多个过滤器
Route::get('dogs', function (Request $request) {
$query = Dog::query();
$query->when(request()->filled('filter'), function ($query) {
$filters = explode(',', request('filter'));
foreach ($filters as $filter) {
[$criteria, $value] = explode(':', $filter);
$query->where($criteria, $value);
}
return $query;
});
return $query->paginate(20);
});
转换结果
我们已经介绍了如何对结果集进行排序和过滤。但现在,我们依赖于 Eloquent 的 JSON 序列化,这意味着我们会返回每个模型的每个字段。
当您序列化一个数组时,Eloquent 提供了一些便捷工具来定义应显示哪些字段。您可以在第五章中阅读更多内容,但其主要思想是,如果您在 Eloquent 类上设置了$hidden数组属性,则该数组中列出的任何字段都不会显示在序列化的模型输出中。您还可以设置一个$visible数组,定义允许显示的字段。或者您还可以覆盖或模仿模型上的toArray()函数,以制定自定义输出格式。
另一个常见模式是为每种数据类型创建一个转换器。转换器很有帮助,因为它们让您拥有更多控制权,将与 API 特定逻辑隔离开来,使模型本身更一致,即使模型及其关系在未来发生变化。
有一个非常棒但复杂的软件包,Fractal,它设置了一系列方便的结构和类来转换您的数据。
API 资源
在过去,当我们在 Laravel 中开发 API 时,我们遇到的第一个挑战之一是如何转换我们的数据。最简单的 API 可以将 Eloquent 对象作为 JSON 返回,但是大多数 API 很快就会超出这种结构的需求。我们应该如何将我们的 Eloquent 结果转换为正确的格式?如果我们想要嵌入其他资源或只在需要时这样做,或者添加计算字段或隐藏某些字段不在 API 中显示,但在其他 JSON 输出中显示呢?API 特定的转换器是解决方案。
现在我们可以访问一个名为Eloquent API 资源的功能,它们是定义如何将给定类的 Eloquent 对象(或 Eloquent 对象集合)转换为 API 结果的结构。例如,您的Dog Eloquent 模型现在有一个Dog资源,其责任是将每个Dog实例转换为相应的Dog形状的 API 响应对象。
创建一个资源类
让我们通过这个Dog示例来看一下如何转换我们的 API 输出。首先,使用 Artisan 命令make:resource来创建您的第一个资源:
php artisan make:resource Dog
这将在app/Http/Resources/Dog.php中创建一个新的类,其中包含一个方法:toArray()。您可以在 Example 13-15 中看到文件的样子。
Example 13-15. 生成的 API 资源
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class Dog extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return parent::toArray($request);
}
}
我们在这里使用的toArray()方法可以访问两个重要的数据片段。首先,它可以访问 Illuminate 的Request对象,因此我们可以根据查询参数、头信息和其他重要信息来自定义我们的响应。其次,它可以通过在$this上调用其属性和方法来访问整个 Eloquent 对象,正如您在 Example 13-16 中所见。
Example 13-16. Dog模型的简单 API 资源
class Dog extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'breed' => $this->breed,
];
}
}
要使用这个新资源,您需要更新返回单个Dog的任何 API 端点,以包装您的新资源响应,就像在 Example 13-17 中看到的那样。
Example 13-17. 使用简单的Dog资源
use App\Dog;
use App\Http\Resources\Dog as DogResource;
Route::get('dogs/{dogId}', function ($dogId) {
return new DogResource(Dog::find($dogId));
});
资源集合
现在,让我们谈谈当您从给定 API 端点返回多个实体时会发生什么。这可以通过 API 资源的collection()方法来实现,正如您在 Example 13-18 中所见。
Example 13-18. 使用默认的 API 资源集合方法
use App\Dog;
use App\Http\Resources\Dog as DogResource;
Route::get('dogs', function () {
return DogResource::collection(Dog::all());
});
此方法遍历传递给它的每个条目,使用DogResource API 资源进行转换,然后返回集合。
对于许多 API 来说,这可能已经足够了,但是如果您需要自定义结构或向您的集合响应添加元数据,您可能需要创建一个自定义的 API 资源集合。
为了做到这一点,让我们再次使用make:resource Artisan 命令。这次我们将其命名为DogCollection,这表明这是一个 API 资源集合,而不仅仅是一个 API 资源:
php artisan make:resource DogCollection
这将生成一个非常类似于 API 资源文件的新文件,位于app/Http/Resources/DogCollection.php,再次包含一个方法:toArray()。您可以在示例 13-19 中查看文件的外观。
示例 13-19. 生成的 API 资源集合
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\ResourceCollection;
class DogCollection extends ResourceCollection
{
/**
* Transform the resource collection into an array.
*
* @return array<int|string, mixed>
*/
public function toArray(Request $request): array
{
return parent::toArray($request);
}
}
就像使用 API 资源一样,我们可以访问请求和底层数据。但与 API 资源不同的是,我们处理的是一组项目而不是单个项目,因此我们将访问(已转换的)集合作为$this->collection。请参阅示例 13-20 了解示例。
示例 13-20. 用于Dog模型的简单 API 资源集合
class DogCollection extends ResourceCollection
{
public function toArray(Request $request): array
{
return [
'data' => $this->collection,
'links' => [
'self' => route('dogs.index'),
],
];
}
}
嵌套关系
任何 API 的更复杂方面之一是关系如何嵌套。使用 API 资源的最简单方法是将一个键添加到返回的数组中,该键设置为 API 资源集合,就像示例 13-21 中一样。
示例 13-21. 简单包含的 API 关系
public function toArray(Request $request): array
{
return [
'name' => $this->name,
'breed' => $this->breed,
'friends' => Dog::collection($this->friends),
];
}
警告
如果您尝试在示例 13-21 中的代码并收到 502 错误,则是因为您尚未首先加载父资源上的“friends”关系。继续阅读以了解如何解决此问题,但在处理此资源时,以下是如何使用with()方法急加载该关系:
return new DogResource(Dog::with('friends')->find($dogId));
您可能还希望这是一个条件属性;您可以选择仅在请求中请求它或仅在已经预加载到传递给 Eloquent 对象上时才嵌套它。请参阅示例 13-22。
示例 13-22. 有条件地加载 API 关系
public function toArray(Request $request): array
{
return [
'name' => $this->name,
'breed' => $this->breed,
// Only load this relationship if it's been eager loaded
'bones' => BoneResource::collection($this->whenLoaded('bones')),
// Or only load this relationship if the URL asks for it
'bones' => $this->when(
$request->get('include') == 'bones',
BoneResource::collection($this->bones)
),
];
}
使用分页与 API 资源
只需像将一组 Eloquent 模型传递给资源一样,您也可以传递一个分页器实例。请参阅示例 13-23。
示例 13-23. 将分页器实例传递给 API 资源集合
Route::get('dogs', function () {
return new DogCollection(Dog::paginate(20));
});
如果您传递一个分页器实例,转换后的结果将具有包含分页信息(first页,last页,prev页和next页)和有关整个集合的元信息的附加链接。
您可以查看示例 13-24 以查看此信息的外观。在此示例中,我通过调用Dog::paginate(2)将每页项数设置为 2,以便更容易地查看链接的工作方式。
示例 13-24. 带有分页链接的样本分页资源响应
{
"data": [
{
"name": "Pickles",
"breed": "Chorkie"
},
{
"name": "Gandalf",
"breed": "Golden Retriever Mix"
}
],
"links": {
"self": "http://gooddogbrant.com/api/dogs",
"first": "http://gooddogbrant.com/api/dogs?page=1",
"last": "http://gooddogbrant.com/api/dogs?page=3",
"prev": null,
"next": null
},
"meta": {
"current_page": 1,
"data": [
{
"name": "Pickles",
"breed": "Chorkie",
},
{
"name": "Gandalf",
"breed": "Golden Retriever Mix",
}
],
"first_page_url": "http://gooddogbrent.com/api/dogs?page=1",
"from": 1,
"last_page": 3,
"last_page_url": "http://gooddogbrent.com/api/dogs?page=3",
"links": [
{
"url": null,
"label": "« Previous",
"active": false
},
{
"url": "http://gooddogbrent.com/api/dogs?page=1",
"label": "1",
"active": true
},
{
"url": "http://gooddogbrent.com/api/dogs?page=2",
"label": "Next »",
"active": false
}
],
"next_page_url": null,
"path": "http://gooddogbrent.com/api/dogs",
"per_page": 3,
"to": 3,
"total": 9
}
}
有条件地应用属性
您还可以指定响应中的某些属性仅在满足特定测试时应用,如示例 13-25 所示。
示例 13-25. 有条件地应用属性
public function toArray(Request $request): array
{
return [
'name' => $this->name,
'breed' => $this->breed,
'rating' => $this->when(Auth::user()->canSeeRatings(), 12),
];
}
API 资源的更多自定义
data 属性包装的默认形状可能不是你喜欢的方式,或者你可能发现自己需要为响应添加或自定义元数据。查看 资源文档 以获取有关如何自定义 API 响应的每个方面的详细信息。
API 身份验证
Laravel 提供了两个主要工具来认证 API 请求:Sanctum(推荐使用)和 Passport(功能强大但非常复杂,通常过于复杂)。
使用 Sanctum 进行 API 身份验证
Sanctum 是 Laravel 的一个 API 身份验证系统,专为两个任务而建:为你的高级用户生成简单的令牌,以便与你的 API 交互,并允许 SPA 和移动应用程序依附于你现有的身份验证系统。它不像 OAuth 2.0 那样可配置,但非常接近,并且在设置和配置方面成本要低得多。
使用 Sanctum 有几种方式。你可以允许高级用户在管理面板直接为你的 API 生成令牌,这与许多面向开发者的 SaaS 服务类似。你也可以允许用户访问一个特殊的登录页面直接获取令牌,这对于将移动应用程序认证到你的 API 是有用的。此外,你还可以与你的 SPA 集成,使用 Sanctum 的特殊功能之一,直接挂接到 Laravel 基于 cookie 的身份验证会话中,完全不需要管理令牌。
让我们看看如何安装 Sanctum,然后在每个上下文中如何使用它。
安装 Sanctum
Sanctum 已预安装在新的 Laravel 项目中。如果你的项目没有安装它,你需要手动安装并发布其配置文件。
composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrate
对于任何你想保护的使用 Sanctum 的路由,附加 auth:sanctum 中间件:
Route::get('clips', function () {
return view('clips.index', ['clips' => Clip::all()]);
})->middleware('auth:sanctum');
手动发放 Sanctum 令牌
如果你想在你的应用程序中构建工具来为用户提供认证 API 的令牌,这里是你需要采取的步骤。
首先确保你的 User 模型使用了 HasApiTokens 特性(在新项目上,它已经有了):
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
}
接下来,构建一个用户界面,允许用户生成一个令牌。你可以在他们的设置页面上放置一个按钮,上面写着“生成新令牌”,弹出一个模态框询问该令牌的昵称,然后将结果发布到这个表单:
Route::post('tokens/create', function () {
$token = auth()->user()->createToken(request()->token_name);
return view('tokens.created', ['token' => $token->plainTextToken]);
});
你也可以通过引用 user 对象的 tokens 属性列出用户拥有的所有令牌:
Route::get('tokens', function () {
return view('tokens.index', ['tokens' => auth()->user()->tokens]);
});
Sanctum 令牌能力
基于令牌的 API 身份验证的一种常见安全模式是,只允许用户生成具有特定特权的令牌,以减少如果令牌被 compromise 的潜在损害。
如果你想为此构建一个系统,你可以定义(基于业务逻辑或用户偏好)创建时每个令牌拥有的“能力”。将一个字符串数组传递给 createToken() 方法,每个字符串代表该令牌拥有的一个能力。
$token = $user->createToken(
request()->token_name, ['list-clips', 'add-delete-clips']
);
然后,您的代码可以直接检查已验证用户的令牌(如 Example 13-26 中所示),或通过中间件(如 Example 13-27 中所示)。
Example 13-26. 根据令牌能力手动检查用户访问权限
if (request()->user()->tokenCan('list-clips')) {
// ...
}
Example 13-27. 使用中间件根据令牌范围限制访问
// routes/api.php
Route::get('clips', function () {
// Access token has both the "list-clips" and "add-delete-clips" abilities
})->middleware(['auth:sanctum','abilities:list-clips,add-delete-clips']);
// or
Route::get('clips', function () {
// Access token has at least one of the listed abilities
})->middleware(['auth:sanctum','ability:list-clips,add-delete-clips'])
注意
如果您希望使用 Sanctum 的中间件检查功能,则需要将以下两行添加到App\Http\Kernel的middlewareAliases属性中。
'abilities' => \Laravel\Sanctum\Http\Middleware\
CheckAbilities::class,
'ability' => \Laravel\Sanctum\Http\Middleware\
CheckForAnyAbility::class,
SPA 认证
如果您计划使用 Sanctum 与 SPA 进行身份验证,则首先需要采取一些步骤来设置您的 Laravel 应用程序和您的 SPA。
Laravel 应用准备工作
首先,在app/Http/Kernel.php中的api中间件组取消注释EnsureFrontendRequestsAreStateful类。
'api' => [
\Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
// Other API middleware here
],
其次,在 Sanctum 配置中更新“stateful”域的列表。这些是您的 SPA 可以发出请求的所有域。您可以直接在config/sanctum.php中修改它们,或者将逗号分隔的域列表添加到您的*.env*文件中的SANCTUM_STATEFUL_DOMAINS键。
SPA 应用准备工作
在允许用户登录您的应用之前,您的 SPA 应请求 Laravel 设置一个 CSRF cookie,大多数 JavaScript HTTP 客户端(如 Axios)将在以后的每个请求中传递它。
axios.get('/sanctum/csrf-cookie').then(response => {
// Handle login
});
您可以登录到您的 Laravel 登录路由,无论是您自己创建的路由还是由类似 Fortify 的现有工具提供的路由。未来的请求将通过 Laravel 为您设置的会话 cookie 进行验证。
移动应用认证
这是允许您的移动应用用户对基于 Sanctum 的应用进行认证的工作流程:在您的移动应用中请求用户的电子邮件(或用户名)和他们的密码。将这些信息与设备的名称一起发送(从设备的操作系统中读取设备名称;例如,“Matt's iPhone”),发送到您在后端自己创建的路由,该路由将验证他们的登录,并(假设登录有效)创建并返回一个令牌,正如您可以从 Example 13-28 直接看到的文档中获取的内容。
Example 13-28. 用于接受基于 Sanctum 的应用的移动应用登录的路由
Route::post('sanctum/token', function (Request $request) {
$request->validate([
'email' => 'required|email',
'password' => 'required',
'device_name' => 'required',
]);
$user = User::where('email', $request->email)->first();
if (! $user || ! Hash::check($request->password, $user->password)) {
throw ValidationException::withMessages([
'email' => ['The provided credentials are incorrect.'],
]);
}
return $user->createToken($request->device_name)->plainTextToken;
});
未来对 API 的请求应在Authorization标头中传递Bearer类型的令牌。
进一步的配置和调试
如果您在安装 Sanctum 方面遇到任何问题或想要自定义 Sanctum 的任何功能,请查阅Sanctum 文档获取更多信息。
使用 Laravel Passport 进行 API 身份验证
Passport(通过 Composer 引入的第一方包,必须安装)可轻松在您的应用程序中设置一个功能齐全的 OAuth 2.0 服务器,包括管理客户端和令牌的 API 和 UI 组件。
OAuth 2.0 简介
OAuth 是 RESTful API 中最常用的认证系统。不幸的是,这是一个过于复杂的主题,我们无法在此深入讨论。有关更多信息,请参阅 Matt Frost 撰写的关于 OAuth 和 PHP 的优秀书籍 Integrating Web Services with OAuth and PHP (php[architect])。
OAuth 最简单的概念是:由于 API 是无状态的,我们不能依赖于正常的基于会话的身份验证方式,这种方式在普通的基于浏览器的查看会话中使用,用户登录后,其验证状态保存在会话中以供后续查看使用。相反,API 客户端需要向认证端点发出单个调用,并执行某种握手来证明自己的身份。然后,它将获得一个令牌,必须在以后的每个请求中(通常通过 Authorization 标头)发送以证明其身份。
OAuth 有几种不同的授权类型,“授权”基本上意味着有几种不同的场景和交互类型可以定义认证握手。不同的项目和不同类型的最终消费者将需要不同的授权。
Passport 提供了将基本的 OAuth 2.0 认证服务器添加到您的 Laravel 应用程序中所需的一切,具有更简单和强大的 API 和界面。
安装 Passport
Passport 是一个独立的包,因此您的第一步是安装它。我将在这里总结步骤,但您可以在Passport 文档中获取更详细的安装说明。
首先,使用 Composer 导入它:
composer require laravel/passport
Passport 导入了一系列迁移,因此使用 php artisan migrate 运行这些迁移以创建 OAuth 客户端、作用域和令牌所需的表。
接下来,使用 php artisan passport:install 运行安装程序。这将为 OAuth 服务器创建加密密钥(storage/oauth-private.key 和 storage/oauth-public.key),并在数据库中插入我们的个人和密码授权类型令牌的 OAuth 客户端(稍后将介绍)。
您需要将 Laravel\Passport\HasApiTokens trait 导入到您的 User 模型中;这将为每个 User 添加与 OAuth 客户端和令牌相关的关系,以及一些与令牌相关的辅助方法。
最后,在 config/auth.php 中添加一个名为 api 的新认证守卫;将提供者设置为 users,驱动程序设置为 passport。
现在,您已经拥有了一个完全功能的 OAuth 2.0 服务器!您可以使用 php artisan passport:client 创建新的客户端,并且您可以使用 /oauth 路由前缀下的 API 来管理您的客户端和令牌。
要在您的 Passport 认证系统后面保护路由,请将 auth:api 中间件添加到路由或路由组中,如示例 13-29 所示。
示例 13-29. 使用 Passport 认证中间件保护 API 路由
// routes/api.php
Route::get('/user', function (Request $request) {
return $request->user();
})->middleware('auth:api');
要对这些受保护的路由进行身份验证,您的客户端应用程序将需要通过Authorization标头中的Bearer令牌传递令牌(我们将很快介绍如何获取)。示例 13-30 展示了如果您正在使用 Laravel 包含的 HTTP 客户端进行请求,会是什么样子。
示例 13-30. 使用Bearer令牌进行样本 API 请求
use Illuminate\Support\Facades\Http;
$response = Http::withHeaders(['Accept' => 'application/json'])
->withToken($accessToken)
->get('http://tweeter.test/api/user');
现在,让我们更详细地看看它是如何工作的。
Passport 的 API
Passport 在您的应用程序中通过/oauth路由前缀公开了一个 API。该 API 提供两个主要功能:首先,通过 OAuth 2.0 授权流(/oauth/authorize和/oauth/token)授权用户,其次,允许用户管理其客户端和令牌(其余路由)。
这是一个重要的区别,特别是如果您对 OAuth 不熟悉。每个 OAuth 服务器都需要公开允许消费者使用您的服务器进行身份验证的能力;这就是该服务的全部意义。但是 Passport 还公开了用于管理 OAuth 服务器客户端和令牌状态的 API。这意味着您可以轻松构建一个前端,让用户在您的 OAuth 应用程序中管理其信息。Passport 实际上附带了基于 Vue 的管理组件,您可以直接使用或作为灵感。
我们将介绍 API 路由,让您可以管理客户端和令牌,以及 Passport 提供的 Vue 组件,使其变得简单易用,但首先让我们深入了解用户可以使用 Passport 保护的 API 进行身份验证的各种方式。
Passport 可用的授权类型
Passport 使您可以以四种不同的方式对用户进行身份验证。其中两种是传统的 OAuth 2.0 授权(密码授权和授权码授权),另外两种是 Passport 独有的便利方法(个人令牌和同步器令牌)。
密码授权
密码授权虽然比授权码授权方式更少见,但要简单得多。如果您希望用户能够直接使用其用户名和密码在您的 API 上进行身份验证,例如,如果您的公司为自己的 API 消耗具有移动应用程序,您可以使用密码授权。
使用密码授权类型,获取令牌只需一步:将用户的凭据发送到/oauth/token路由,就像示例 13-31 中那样。
示例 13-31. 使用密码授权类型进行请求
// routes/web.php in the *consuming application*
Route::get('tweeter/password-grant-auth', function () {
// Make call to "Tweeter," our Passport-powered OAuth server
$response = Http::post('http://tweeter.test/oauth/token', [
'grant_type' => 'password',
'client_id' => config('tweeter.id'),
'client_secret' => config('tweeter.secret'),
'username' => 'matt@mattstauffer.co',
'password' => 'my-tweeter-password',
'scope' => '',
]);
$thisUsersTokens = $response->json();
// Do stuff with the tokens
});
此路由将返回一个access_token,一个refresh_token和两个元数据:token_type和expires_in(本章后面将讨论)。您现在可以保存这些令牌以用于 API 进行身份验证(访问令牌)和以后请求更多令牌(刷新令牌)。
请注意,我们将用于密码授权类型的 ID 和密钥将是我们 Passport 应用程序中oauth_clients数据库表中的那些在其名称与我们 Passport 授权客户端名称匹配的行中的 ID 和密钥。当您运行passport:install时,您还将在此表中看到两个默认生成的客户端条目:“Laravel 个人访问客户端”和“Laravel 密码授权客户端”。
授权码授权
最常见的 OAuth 2.0 授权工作流程也是 Passport 支持的最复杂的工作流程。让我们想象我们正在开发一个类似 Twitter 但用于声音片段的应用程序;我们将其称为 Tweeter。我们再想象另一个网站,一个名为 SpaceBook 的科幻迷社交网络。SpaceBook 的开发人员希望让人们将他们的 Tweeter 数据嵌入到他们的 SpaceBook 新闻源中。我们将在我们的 Tweeter 应用程序中安装 Passport,以便其他应用程序 - 例如 SpaceBook - 可以允许他们的用户使用他们的 Tweeter 信息进行身份验证。
在授权码授权类型中,每个消费网站 - 例如这个例子中的 SpaceBook - 需要在我们的 Passport 启用的应用程序中创建一个客户端。在大多数情况下,其他站点的管理员将在 Tweeter 拥有用户帐户,我们将为他们构建工具来在那里创建客户端。但是首先,我们可以为 SpaceBook 的管理员手动创建一个客户端:
php artisan passport:client
Which user ID should the client be assigned to?:
> 1
What should we name the client?:
> SpaceBook
Where should we redirect the request after authorization?
[http://tweeter.test/auth/callback]:
> http://spacebook.test/tweeter/callback
New client created successfully.
Client ID: 4
Client secret: 5rzqKpeCjIgz3MXpi3tjQ37HBnLLykrgWgmc18uH
要回答第一个问题,您需要知道每个客户端都需要分配给您应用程序中的一个用户。假设用户#1 正在编写 SpaceBook;他们将是我们创建的这个客户端的“所有者”。
一旦我们运行了这个命令,我们就有了 SpaceBook 客户端的 ID 和密钥。在这一点上,SpaceBook 可以使用这个 ID 和密钥来构建工具,允许一个个体 SpaceBook 用户(也是 Tweeter 用户)从 Tweeter 获取授权令牌,以便当 SpaceBook 希望代表该用户进行 API 调用到 Tweeter 时使用。示例 13-32 说明了这一点。 (这和后面的示例假设 SpaceBook 也是一个 Laravel 应用程序;它们还假设 SpaceBook 的开发人员创建了一个在config/tweeter.php中返回我们刚刚创建的 ID 和密钥的文件。)
示例 13-32. 消费者应用程序将用户重定向到我们的 OAuth 服务器
// In SpaceBook's routes/web.php:
Route::get('tweeter/redirect', function () {
$query = http_build_query([
'client_id' => config('tweeter.id'),
'redirect_uri' => url('tweeter/callback'),
'response_type' => 'code',
'scope' => '',
]);
// Builds a string like:
// client_id={$client_id}&redirect_uri={$redirect_uri}&response_type=code
return redirect('http://tweeter.test/oauth/authorize?' . $query);
});
当用户访问 SpaceBook 中的该路由时,他们现在将被重定向到我们 Tweeter 应用中的/oauth/authorize Passport 路由。此时他们将看到一个确认页面 - 您可以通过运行此命令使用默认的 Passport 确认页面:
php artisan vendor:publish --tag=passport-views
这将发布视图到resources/views/vendor/passport/authorize.blade.php,您的用户将看到图 13-1 中显示的页面。
图 13-1. OAuth 授权码批准页面
一旦用户选择接受或拒绝授权,Passport 将将用户重定向回提供的redirect_uri。在示例 13-32 中,我们设置了redirect_uri为url('tweeter/callback'),因此用户将被重定向回*spacebook.test/tweeter/cal…
批准请求将包含一个代码,我们的消费者应用程序回调路由现在可以使用它从我们的启用 Passport 的应用程序 Tweeter 获取令牌。 拒绝请求将包含一个错误。 SpaceBook 的回调路由可能类似于示例 13-33。
示例 13-33。示例消费应用程序中的授权回调路由
// In SpaceBook's routes/web.php:
Route::get('tweeter/callback', function (Request $request) {
if ($request->has('error')) {
// Handle error condition
}
$response = Http::post('http://tweeter.test/oauth/token', [
'grant_type' => 'authorization_code',
'client_id' => config('tweeter.id'),
'client_secret' => config('tweeter.secret'),
'redirect_uri' => url('tweeter/callback'),
'code' => $request->code,
]);
$thisUsersTokens = $response->json();
// Do stuff with the tokens
});
SpaceBook 开发者在这里做的是使用 Laravel HTTP 客户端构建 HTTP 请求,到 Tweeter 的/oauth/token Passport 路由。 然后,他们发送一个POST请求,其中包含用户批准访问时收到的授权码,Tweeter 将返回一个包含几个键的 JSON 响应:
access_token
SpaceBook 将要保存的该用户令牌。 此令牌是用户将来用于认证到 Tweeter 的请求时使用的。 (使用Authorization标头)。
refresh_token
如果您决定将您的令牌设置为过期,则 SpaceBook 将需要的令牌。 默认情况下,Passport 的访问令牌有效期为一年。
expires_in
直到access_token过期的秒数(需要刷新)。
token_type
您获取的令牌类型将是Bearer;这意味着您在未来的所有请求中传递一个带有名称为Authorization和值为Bearer *YOURTOKENHERE*的标头。
现在您已经拥有执行基本授权代码流所需的所有工具。 我们将稍后介绍如何为客户和令牌构建管理员面板,但首先,让我们快速查看其他授权类型。
个人访问令牌
授权码授予适用于用户的应用程序,密码授予适用于您自己的应用程序,但是如果您的用户想要为自己创建令牌以测试您的 API 或在开发其应用程序时使用什么? 这就是个人令牌的用途。
创建个人访问客户端
要创建个人令牌,您需要在数据库中拥有个人访问客户端。 运行php artisan passport:install将已经添加一个,但是如果出于任何原因需要生成一个新的个人访问客户端,您可以运行php artisan passport:client --personal:
`php` `artisan` `passport:client` `--personal`
What should we name the personal access client?
[My Application Personal Access Client]:
> `My` `Application` `Personal` `Access` `Client`
Personal access client created successfully.
个人访问令牌并不是“授权”类型;这里没有 OAuth 规定的流程。 相反,它们是 Passport 添加的便捷方法,可以轻松在系统中注册一个单一客户端,该客户端仅用于便捷地为开发者用户创建便利令牌。
例如,也许您有一个正在开发名为 RaceBook(马拉松选手专用的社交网络)的竞争对手 SpaceBook 的用户,他们希望在开始编码之前先玩一玩 Tweeter API,以弄清它的工作原理。这个开发者能够使用授权码流程创建令牌吗?还没有——他们甚至还没有写任何代码呢!这就是个人访问令牌的用途。
您可以通过 JSON API 创建个人访问令牌,我们稍后会介绍,但您也可以直接在代码中为您的用户创建一个:
// Creating a token without scopes
$token = $user->createToken('Token Name')->accessToken;
// Creating a token with scopes
$token = $user->createToken('My Token', ['place-orders'])->accessToken;
您的用户可以像使用授权码授予流程创建的令牌一样使用这些令牌。我们将在“护照范围”中详细讨论作用域。
Laravel 会话认证的令牌(同步令牌)
还有一种方法让您的用户获取访问 API 的令牌,这是 Passport 添加的另一种便利方法,而普通的 OAuth 服务器不提供。这种方法是当您的用户已经通过常规方式登录到您的 Laravel 应用程序,并且您希望您应用程序的 JavaScript 能够访问 API 时使用的。重新使用授权码或密码授予流程重新认证用户会很麻烦,因此 Laravel 提供了一个辅助方法。
如果您将Laravel\Passport\Http\Middleware\CreateFreshApiToken中间件添加到您的web中间件组(在app/Http/Kernel.php中),Laravel 发送给您的经过身份验证的用户的每个响应都会附带一个名为laravel_token的 cookie。这个 cookie 是一个包含有关 CSRF 令牌编码信息的 JSON Web Token(JWT)。现在,如果您在 JavaScript 请求中使用X-CSRF-TOKEN标头发送正常的 CSRF 令牌,并且在任何您做的 API 请求中也发送X-Requested-With标头,API 将会比较您的 CSRF 令牌与此 cookie,并像处理任何其他令牌一样对您的用户进行身份验证。
Laravel 捆绑的默认 JavaScript 引导设置为您设置了这个标头,但如果您使用不同的框架,您需要手动设置它。示例 13-36 展示了如何在 jQuery 中设置它。
示例 13-36. 设置 jQuery 通过所有 Ajax 请求传递 Laravel 的 CSRF 令牌和X-Requested-With标头
$.ajaxSetup({
headers: {
'X-CSRF-TOKEN': "{{ csrf_token() }}",
'X-Requested-With': 'XMLHttpRequest'
}
});
如果您将CreateFreshApiToken中间件添加到您的web中间件组,并且在每个 JavaScript 请求中传递这些标头,那么您的 JavaScript 请求将能够访问您的 Passport 保护的 API 路由,而不必担心授权码或密码授予的任何复杂性。
护照作用域
如果您熟悉 OAuth,您可能已经注意到我们还没有详细讨论作用域。到目前为止,我们所涵盖的所有内容都可以通过作用域进行定制——但在进一步讨论作用域之前,让我们先快速了解一下什么是作用域。
在 OAuth 中,作用域 是定义的一组权限,不是“可以做所有事情”。例如,如果你曾经获取过 GitHub API 令牌,你可能会注意到一些应用只想访问你的姓名和电子邮件地址,一些应用想要访问你所有的仓库,还有一些应用想要访问你的 Gists。这些都是“作用域”,它允许用户和消费者应用定义消费者应用需要执行其工作的访问权限。
如 示例 13-37 所示,你可以在 AuthServiceProvider 的 boot() 方法中定义应用的作用域。
示例 13-37. 定义 Passport 作用域
// AuthServiceProvider
use Laravel\Passport\Passport;
...
public function boot(): void
{
...
Passport::tokensCan([
'list-clips' => 'List sound clips',
'add-delete-clips' => 'Add new and delete old sound clips',
'admin-account' => 'Administer account details',
]);
}
一旦你定义了作用域,消费者应用可以定义它请求访问的作用域。只需在初始重定向中的 scope 字段添加一个空格分隔的令牌列表,如 示例 13-38 所示。
示例 13-38. 请求授权以访问特定作用域
// In SpaceBook's routes/web.php:
Route::get('tweeter/redirect', function () {
$query = http_build_query([
'client_id' => config('tweeter.id'),
'redirect_uri' => url('tweeter/callback'),
'response_type' => 'code',
'scope' => 'list-clips add-delete-clips',
]);
return redirect('http://tweeter.test/oauth/authorize?' . $query);
});
当用户尝试授权此应用时,它将展示请求的作用域列表。这样,用户就会知道“SpaceBook 请求查看你的电子邮件地址”还是“SpaceBook 请求访问以你身份发布、删除你的帖子和发送消息给你的朋友”。
你 你可以使用中间件或在 User 实例上检查作用域。示例 13-39 展示了如何在 User 上进行检查。
示例 13-39. 检查用户验证的令牌是否可以执行指定的操作
Route::get('/events', function () {
if (auth()->user()->tokenCan('add-delete-clips')) {
//
}
});
你也可以使用两个中间件,scope 和 scopes。在你的应用中使用它们,只需将它们添加到 app/Http/Kernel.php 文件中的 $middlewareAliases:
'scopes' => \Laravel\Passport\Http\Middleware\CheckScopes::class,
'scope' => \Laravel\Passport\Http\Middleware\CheckForAnyScope::class,
你现在可以使用中间件,如 示例 13-40 所示。scopes 需要用户令牌上包含所有定义的作用域才能访问路由,而 scope 只需要用户令牌上包含至少一个定义的作用域。
示例 13-40. 使用中间件基于令牌作用域限制访问
// routes/api.php
Route::get('clips', function () {
// Access token has both the "list-clips" and "add-delete-clips" scopes
})->middleware('scopes:list-clips,add-delete-clips');
// or
Route::get('clips', function () {
// Access token has at least one of the listed scopes
})->middleware('scope:list-clips,add-delete-clips')
如果你没有定义任何作用域,应用将像不存在一样工作。然而,一旦你使用了作用域,你的消费者应用必须明确定义它们请求的作用域。此规则的一个例外是,如果你使用的是密码授权类型,你的消费者应用可以请求 * 作用域,这会使令牌获得对所有内容的访问权限。
部署 Passport
第一次部署你的 Passport 支持的应用时,Passport API 在你为应用生成密钥之前不会起作用。这可以通过在生产服务器上运行 php artisan passport:keys 来完成,这将生成 Passport 用于生成令牌的加密密钥。
自定义 404 响应
Laravel 为普通 HTML 视图提供可自定义的错误消息页面,但您还可以为带有 JSON 内容类型的调用自定义默认的 404 回退响应。为此,请将 Route::fallback() 调用添加到您的 API 中,如示例 13-41 所示。
示例 13-41. 定义回退路由
// routes/api.php
Route::fallback(function () {
return response()->json(['message' => 'Route Not Found'], 404);
})->name('api.fallback.404');
触发回退路由
如果您想要自定义 Laravel 捕获“未找到”异常时返回的路由,可以使用 respondWithRoute() 方法更新异常处理程序,如示例 13-42 所示。
示例 13-42. 当捕获“未找到”异常时调用回退路由
// App\Exceptions\Handler
use Illuminate\Support\Facades\Route;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Illuminate\Http\Request;
public function register(): void
{
$this->renderable(function (NotFoundHttpException $e, Request $request) {
if ($request->isJson()) {
return Route::respondWithRoute('api.fallback.404');
}
});
}
测试
幸运的是,在 Laravel 中测试 API 实际上比测试几乎任何其他东西都简单。
我们在第十二章中详细介绍了这一点,但有一系列针对 JSON 进行断言的方法。结合全栈应用程序测试的简单性,您可以快速轻松地编写 API 测试。查看示例 13-43 中的常见 API 测试模式。
示例 13-43. 常见的 API 测试模式
...
class DogsApiTest extends TestCase
{
use WithoutMiddleware, RefreshDatabase;
public function test_it_gets_all_dogs()
{
$dog1 = Dog::factory()->create();
$dog2 = Dog::factory()->create();
$response = $this->getJson('api/dogs');
$response->assertJsonFragment(['name' => $dog1->name]);
$response->assertJsonFragment(['name' => $dog2->name]);
}
}
请注意,我们使用 WithoutMiddleware 来避免担心身份验证问题。如果需要,您可以单独进行身份验证测试(有关身份验证的更多信息,请参见第九章)。
在这个测试中,我们向数据库中插入了两只Dog,然后访问 API 路由以列出所有Dog,确保两者都出现在输出中。
您可以在这里简单轻松地覆盖所有 API 路由,包括修改POST和PATCH等操作。
测试 Passport
您可以使用 Passport 门面上的 actingAs() 方法来测试您的作用域。查看示例 13-44 以查看 Passport 中测试作用域的常见模式。
示例 13-44. 测试作用域访问
public function test_it_lists_all_clips_for_those_with_list_clips_scope()
{
Passport::actingAs(
User::factory()->create(),
['list-clips']
);
$response = $this->getJson('api/clips');
$response->assertStatus(200);
}
TL;DR
Laravel 专注于构建 API,并简化了与 JSON 和 RESTful API 的工作。有一些约定,如分页,但关于 API 的具体排序、身份验证或其他内容的定义大部分由您决定。
Laravel 提供了身份验证和测试工具,易于操作和读取头信息,并处理 JSON,甚至在直接从路由返回时自动将所有 Eloquent 结果编码为 JSON。
Laravel Passport 是一个单独的包,使得在 Laravel 应用中创建和管理 OAuth 服务器变得简单。
第十四章:存储和检索
我们在 第五章 中讨论了如何在关系数据库中存储数据,但可以在本地和远程存储中存储更多数据。本章将涵盖文件系统和内存存储、文件上传和操作、非关系数据存储、会话、缓存、日志记录、Cookie 和全文搜索。
本地和云文件管理器
Laravel 通过 Storage 门面和一些辅助函数提供一系列文件操作工具。
Laravel 的文件系统访问工具可以连接到本地文件系统以及 S3、Rackspace 和 FTP。S3 和 Rackspace 文件驱动程序由 Flysystem 提供,并且可以简单地添加额外的 Flysystem 提供者,如 Dropbox 或 WebDAV,到您的 Laravel 应用程序中。
配置文件访问
Laravel 文件管理器的定义位于 config/filesystems.php 中。每个连接称为“磁盘”,示例 14-1 列出了开箱即用的磁盘。
示例 14-1 默认可用的存储磁盘
...
'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app'),
'throw' => false,
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => env('APP_URL').'/storage',
'visibility' => 'public',
'throw' => false,
],
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => false,
],
],
storage_path() 辅助函数
在 示例 14-1 中使用的 storage_path() 辅助函数链接到 Laravel 配置的存储目录,即 storage/。将任何内容传递给它,都将添加到目录名称的末尾,因此 storage_path('public') 将返回字符串 storage/public。
local 磁盘连接到您的本地存储系统,并假定它将与存储路径的 app 目录进行交互,即 storage/app。
public 磁盘也是一个本地磁盘(虽然您可以根据需要更改它),用于应用程序提供的文件。它默认为 storage/app/public 目录,如果您希望使用此目录向公众提供文件,则需要在 public/ 目录中添加符号链接(symlink)。幸运的是,有一个 Artisan 命令将 public/storage 映射为从 storage/app/public 读取文件的服务:
php artisan storage:link
s3 磁盘显示了 Laravel 如何连接到基于云的文件存储系统。如果您曾连接到 S3 或任何其他云存储提供者,这将很熟悉;传递您的密钥和秘密以及定义您正在使用的“文件夹”的一些信息,S3 中是区域和存储桶。
使用 Storage 门面
在 config/filesystem.php 中,您可以设置默认磁盘,这将在未指定磁盘时使用任何时候调用 Storage 门面。要指定磁盘,请在门面上调用 disk('*diskname*'):
Storage::disk('s3')->get('file.jpg');
所有文件系统都提供以下方法:
get('*file.jpg*')
检索 *file.jpg* 文件。
json('*file.json*', $flags)
检索 *file.json* 文件并解码其 JSON 内容。
put('*file.jpg*', *$contentsOrStream*)
将给定的文件内容放到 *file.jpg*。
putFile('*myDir*', *$file*)
将提供文件的内容(以 Illuminate\Http\File 或 Illuminate\Http\UploadedFile 实例的形式)放置到 *myDir* 目录中,但 Laravel 管理整个流程和文件命名
exists('*file.jpg*')
返回一个布尔值,指示 *file.jpg* 是否存在
getVisibility('*myPath*')
获取给定路径的可见性(“public”或“private”)
setVisibility('*myPath*')
设置给定路径的可见性(“public”或“private”)
copy('*file.jpg*', '*newfile.jpg*')
将 *file.jpg* 复制到 *newfile.jpg*
move('*file.jpg*', '*newfile.jpg*')
将 *file.jpg* 移动到 *newfile.jpg*
prepend('*my.log*', '*log text*')
在 *my.log* 的开头添加 log text 内容
append('*my.log*', '*log text*')
在 *my.log* 的末尾添加 log text 内容
delete('*file.jpg*')
删除 *file.jpg*
size('*file.jpg*')
返回 *file.jpg* 的字节大小
lastModified('*file.jpg*')
返回 *file.jpg* 上次修改的 Unix 时间戳
files('*myDir*')
返回目录 *myDir* 中的文件名数组
allFiles('*myDir*')
返回目录 *myDir* 及其所有子目录中的文件名数组
directories('*myDir*')
返回目录 *myDir* 中的目录名数组
allDirectories('*myDir*')
返回目录 *myDir* 及其所有子目录中的目录名数组
makeDirectory('*myDir*')
创建一个新目录
deleteDirectory('*myDir*')
删除 *myDir*
readStream('*my.log*')
获取用于读取 *my.log* 的资源
writeStream('*my.log*', $resource)
使用流写入新文件(*my.log*)
注入一个实例
如果您希望注入一个实例而不是使用 File 门面,可以类型提示或注入 Illuminate\Filesystem\Filesystem,您将拥有相同的所有方法可用。
添加额外的 Flysystem 提供者
如果您想添加额外的 Flysystem 提供者,您需要“扩展” Laravel 的本机存储系统。在某个服务提供者中——可以是 AppServiceProvider 的 boot() 方法,但为每个新绑定创建一个唯一的服务提供者会更合适——使用 Storage 门面添加新的存储系统,如 示例 14-2 中所示。
示例 14-2. 添加额外的 Flysystem 提供者
// Some service provider
public function boot(): void
{
Storage::extend('dropbox', function ($app, $config) {
$client = new DropboxClient(
$config['accessToken'], $config['clientIdentifier']
);
return new Filesystem(new DropboxAdapter($client));
});
}
基本文件上传和操作
Storage 门面的更常见用法之一是接受来自应用程序用户的文件上传。让我们看一下在 示例 14-3 中的常见工作流程。
示例 14-3. 常见用户上传工作流程
...
class DogController
{
public function updatePicture(Request $request, Dog $dog)
{
Storage::put(
"dogs/{$dog->id}",
file_get_contents($request->file('picture')->getRealPath())
);
}
}
我们将put()到名为dogs/id的文件中,并且我们从上传的文件中获取我们的内容。每个上传的文件都是SplFileInfo类的后代,它提供了一个getRealPath()方法,返回文件位置的路径。因此,我们获取用户上传文件的临时上传路径,用file_get_contents()读取它,并传递给Storage::put()。
由于我们在这里可以使用此文件,我们可以在存储之前对文件进行任何操作——如果是图像,则使用图像处理包进行调整大小,验证并拒绝不符合我们标准的文件,或者其他任何操作。
如果我们想要将同一文件上传到 S3,并且我们的凭据存储在config/filesystems.php中,我们可以简单地调整示例 14-3 以调用Storage::disk('s3')->put();现在我们将上传到 S3。查看示例 14-4 以查看更复杂的上传示例。
示例 14-4. 使用 Intervention 的文件上传的更复杂示例
...
class DogController
{
public function updatePicture(Request $request, Dog $dog)
{
$original = $request->file('picture');
// Resize image to max width 150
$image = Image::make($original)->resize(150, null, function ($constraint) {
$constraint->aspectRatio();
})->encode('jpg', 75);
Storage::put(
"dogs/thumbs/{$dog->id}",
$image->getEncoded()
);
}
我在示例 14-4 中使用了一个称为Intervention的图像库,仅作为示例;您可以使用任何您想要的库。重要的是,在存储之前,您有自由对文件进行任意操作。
使用 Uploaded File 的 store()和 storeAs()方法
您还可以使用文件本身存储已上传的文件。在示例 7-18 中了解更多信息。
简单文件下载
就像Storage简化了接受用户上传的任务一样,它也简化了将文件返回给用户的任务。查看示例 14-5 以获取最简单的示例。
示例 14-5. 简单文件下载
public function downloadMyFile()
{
return Storage::download('my-file.pdf');
}
会话
会话存储是我们在 Web 应用程序中用于在页面请求之间存储状态的主要工具。Laravel 的会话管理器支持使用文件、Cookie、数据库、Memcached 或 Redis、DynamoDB 或内存数组作为会话驱动程序(在页面请求后过期,仅适用于测试)。
您可以在config/session.php中配置所有会话设置和驱动程序。您可以选择是否加密会话数据,选择使用哪个驱动程序(file是默认值),并指定更多特定于连接的详细信息,如会话存储的长度以及要使用哪些文件或数据库表。查看会话文档以了解您需要为所选择的驱动程序准备的特定依赖关系和设置。
会话工具的一般 API 允许您基于单个键保存和检索数据:例如session()->put('*user_id*')和session()->get('*user_id*')。确保避免将任何内容保存到flash会话键中,因为 Laravel 在内部使用它进行闪存(仅在下一个页面请求中可用)会话存储。
访问会话
访问会话的最常见方法是使用Session门面:
Session::get('user_id');
但是你也可以在任何给定的 Illuminate Request 对象上使用 session() 方法,就像在 示例 14-6 中所示的那样。
示例 14-6. 在 Request 对象上使用 session() 方法
Route::get('dashboard', function (Request $request) {
$request->session()->get('user_id');
});
或者你可以像在 示例 14-7 中那样,注入 Illuminate\Session\Store 的实例。
示例 14-7. 注入会话支持类
Route::get('dashboard', function (Illuminate\Session\Store $session) {
return $session->get('user_id');
});
最后,你可以使用全局 session() 辅助函数。使用无参数获取会话实例,使用单个字符串参数从会话中“获取”,或者使用数组“存入”会话,如 示例 14-8 中所示。
示例 14-8. 使用全局的 session() 辅助函数
// Get
$value = session()->get('key');
$value = session('key');
// Put
session()->put('key', 'value');
session(['key', 'value']);
如果你是 Laravel 新手并不确定该使用哪个方法,我建议使用全局辅助函数。
会话实例上可用的方法
最常见的两个方法是 get() 和 put(),但让我们来看看每个可用方法及其参数:
session()->get(*$key*, *$fallbackValue*)
get() 方法从会话中获取提供的键的值。如果该键没有对应的值,它将返回备用值(如果没有提供备用值,则返回 null)。备用值可以是一个简单的值或一个闭包,正如下面的示例所示:
$points = session()->get('points');
$points = session()->get('points', 0);
$points = session()->get('points', function () {
return (new PointGetterService)->getPoints();
});
session()->put(*$key*, *$value*)
put() 将提供的值存储在会话中的提供键下:
session()->put('points', 45);
$points = session()->get('points');
session()->push(*$key*, *$value*)
如果你的会话值是数组,你可以使用 push() 方法向数组中添加一个值:
session()->put('friends', ['Saúl', 'Quang', 'Mechteld']);
session()->push('friends', 'Javier');
session()->has(*$key*)
has() 检查是否在提供的键上设置了一个值:
if (session()->has('points')) {
// Do something
}
你也可以传递一个键的数组,只有所有键存在时它才返回 true。
session()->has() 和空值
如果设置了会话值但该值为 null,session()``->``has() 将返回 false。
session()->exists(*$key*)
exists() 检查是否在提供的键上设置了一个值,类似于 has(),但不同的是,即使设置的值为 null,它也将返回 true:
if (session()->exists('points')) {
// returns true even if 'points' is set to null
}
session()->all()
all() 返回会话中的所有内容的数组,包括框架设置的值。你可能会在键如 _token(CSRF 令牌)、_previous(上一页,用于 back() 重定向)和 flash(闪存存储)下看到值。
session()->only()
only() 返回会话中仅指定值的数组。
session()->forget(*$key*),session()->flush()
forget() 删除先前设置的会话值。flush() 删除每个会话值,即使是框架设置的值也会被删除。
session()->put('a', 'awesome');
session()->put('b', 'bodacious');
session()->forget('a');
// a is no longer set; b is still set
session()->flush();
// Session is now empty
session()->pull(*$key*, *$fallbackValue*)
pull() 和 get() 相同,不同之处在于后者在从会话中获取值后将其删除。
session()->regenerate()
并不常见,但如果你需要重新生成会话 ID,可以使用 regenerate() 方法。
闪存会话存储
还有三种我们尚未介绍的方法,它们都与 闪存会话存储 有关。
会话存储的一种非常常见的模式是设置一个值,你希望它仅在下一个页面加载时可用。例如,你可能想存储像“成功更新帖子。”这样的消息。你可以手动获取该消息,然后在下一个页面加载时清除它,但如果你经常使用这种模式,会变得浪费。引入闪存会话存储:预期仅在单个页面请求期间存在的键。
Laravel 会为你处理这些工作,你只需使用flash()而不是put()。以下是这里的有用方法:
session()->flash(*$key*, *$value*)
flash()设置会话键为提供的值,仅用于下一个页面请求。
session()->reflash(), session()->keep(*$key*)
如果你需要上一页的闪存会话数据保留一个更多的请求,你可以使用reflash()来恢复所有数据到下一个请求或keep(*$key*)来只恢复一个单一的闪存值到下一个请求。keep()也可以接受一个键的数组来刷新。
缓存
缓存的结构与会话非常相似。你提供一个键,Laravel 为你存储它。最大的区别在于缓存中的数据是应用程序级别的,而会话中的数据是用户级别的。这意味着缓存更常用于存储来自数据库查询、API 调用或其他可以稍微“过时”的缓慢查询的结果。
缓存配置设置可以在config/cache.php中找到。就像会话一样,你可以为任何驱动程序设置特定的配置详细信息,并选择哪一个将成为默认值。Laravel 默认使用file缓存驱动程序,但你也可以使用 Memcached 或 Redis、APC、DynamoDB 或数据库,或编写自己的缓存驱动程序。查看缓存文档了解你选择使用的驱动程序需要准备的特定依赖和设置。
访问缓存
就像会话一样,有几种访问缓存的方法。你可以使用外观:
$users = Cache::get('users');
或者你可以从容器中获取一个实例,如示例 14-9。
示例 14-9. 注入缓存实例
Route::get('users', function (Illuminate\Contracts\Cache\Repository $cache) {
return $cache->get('users');
});
你还可以使用全局cache()助手,如示例 14-10。
示例 14-10. 使用全局cache()助手
// Get from cache
$users = cache('key', 'default value');
$users = cache()->get('key', 'default value');
// Put for $seconds duration
$users = cache(['key' => 'value'], $seconds);
$users = cache()->put('key', 'value', $seconds);
如果你是 Laravel 的新手,不确定该使用哪个,我建议使用全局助手。
缓存实例上可用的方法
让我们看看可以在Cache实例上调用的方法:
cache()->get(*$key*, *$fallbackValue*),
cache()->pull(*$key*, *$fallbackValue*)
get()使得轻松检索任何给定键的值。pull()与get()相同,只是在检索后移除缓存的值。
cache()->put(*$key*, *$value*, *$secondsOrExpiration*)
put() 为指定的键设置值,并在给定秒数后过期。如果你愿意设置一个到期日期/时间而不是秒数,你可以将 Carbon 对象作为第三个参数传递:
cache()->put('key', 'value', now()->addDay());
cache()->add(*$key*, *$value*)
add() 类似于 put(),但如果值已存在,add() 不会设置它。此外,该方法返回一个布尔值,指示值是否实际被添加:
$someDate = now();
cache()->add('someDate', $someDate); // returns true
$someOtherDate = now()->addHour();
cache()->add('someDate', $someOtherDate); // returns false
cache()->forever(*$key*, *$value*)
forever() 会将一个值永久保存在缓存中,对应特定的键;它和 put() 相同,除了这些值永远不会过期(直到用 forget() 移除它们)。
cache()->has(*$key*)
has() 返回一个布尔值,指示提供的键是否存在值。
cache()->remember(*$key*, *$seconds*, *$closure*),
cache()->rememberForever(*$key*, *$closure*)
remember() 提供了一个单一的方法来处理非常常见的流程:查看是否存在某个键的缓存值,如果不存在,则以某种方式获取该值,保存到缓存中,并返回它。
remember() 允许你提供一个键来查找,应该保存的秒数以及一个闭包来定义如何查找它,以防该键没有设置值。rememberForever() 相同,只是它不需要你设置应保存的秒数。看下面的例子,了解 remember() 的常见用户场景:
// Either returns the value cached at "users" or gets "User::all()",
// caches it at "users", and returns it
$users = cache()->remember('users', 7200, function () {
return User::all();
});
cache()->increment(*$key*, *$amount*), cache()->decrement(*$key*, *$amount*)
increment() 和 decrement() 允许你在缓存中增加和减少整数值。如果给定键没有值,它将被视为 0,如果你向增加或减少传递第二个参数,它将按该数量增加或减少,而不是按 1。
cache()->forget(*$key*), cache()->flush()
forget() 的工作方式与 Session 的 forget() 方法相同:传递一个键,它将清除该键的值。flush() 将清空整个缓存。
Cookies
你可能期望 cookie 能像会话和缓存一样工作。对于这三者,我们都提供了一个外观和全局助手,而我们对它们的心理模型也是相似的:你可以以同样的方式获取或设置它们的值。
但由于 cookie 本质上与请求和响应相关联,你需要以不同的方式与 cookie 交互。让我们简要看看使 cookie 不同的地方。
Laravel 中的 Cookies
在 Laravel 中,cookie 可以存在三个地方。它们可以通过请求进入,这意味着用户在访问页面时拥有 cookie。你可以使用 Cookie 外观或从请求对象中读取它。
它们还可以与响应一起发送,这意味着响应将指示用户的浏览器保存 cookie 以备将来访问。在返回响应对象之前,你可以通过将 cookie 添加到响应对象中来实现这一点。
最后,一个 cookie 可以被排队。如果您使用 Cookie 门面设置一个 cookie,您必须将它放入“CookieJar”队列中,并且它将由 AddQueuedCookiesToResponse 中间件从响应对象中移除并添加。
访问 cookie 工具
您可以在三个位置获取和设置 cookie:Cookie 门面、cookie() 全局辅助函数以及请求和响应对象。
cookie 门面
Cookie 门面提供了最全面的选项,不仅可以读取和创建 cookie,还可以将它们排队以添加到响应中。它提供以下方法:
Cookie::get(*$key*)
要获取请求中带有的 cookie 值,只需运行 Cookie::get('*cookie-name*')。这是最简单的选择。
Cookie::has(*$key*)
您可以使用 Cookie::has('*cookie-name*') 检查请求中是否带有 cookie,该方法返回一个布尔值。
Cookie::make(*...params*)
如果您想要在任何地方制作一个 cookie 而不将其排队,可以使用 Cookie::make()。这样做的最可能用途是制作一个 cookie,然后手动将其附加到响应对象,我们稍后会讨论这一点。
下面是 make() 方法的参数顺序:
-
$name是 cookie 的名称。 -
$value是 cookie 的内容。 -
$minutes指定 cookie 应该存活多少分钟。 -
$path是 cookie 应该有效的路径。 -
$domain列出 cookie 应该工作的域。 -
$secure表示 cookie 是否只能通过安全的(HTTPS)连接传输。 -
$httpOnly表示 cookie 是否仅通过 HTTP 协议访问。 -
$raw表示是否应无需 URL 编码地发送 cookie。 -
$sameSite表示 cookie 是否可供跨站点请求使用;选项有lax、strict或null。
Cookie::make()
返回一个 Symfony\Component\HttpFoundation\Cookie 的实例。
cookie 的默认设置
Cookie 门面实例使用的 CookieJar 从会话配置中读取其默认值。因此,如果您在 config/session.php 中更改会话 cookie 的任何配置值,那么您使用 Cookie 门面创建的所有 cookie 都将应用相同的默认值。
Cookie::queue(*Cookie || params*)
如果你使用 Cookie::make(),仍然需要将 cookie 附加到响应中,我们稍后会讨论这个问题。Cookie::queue() 与 Cookie::make() 的语法相同,但是它会将创建的 cookie 加入队列,由中间件自动附加到响应中。
如果您愿意,您也可以将您自己创建的 cookie 直接传递给 Cookie::queue()。
这是在 Laravel 中向响应添加 cookie 的最简单方法:
Cookie::queue('dismissed-popup', true, 15);
当您排队的 cookie 不会被设置时
Cookies 只能作为响应的一部分返回。因此,如果您使用 Cookie 门面添加了 cookie,然后响应未正确返回——例如,如果使用 PHP 的 exit() 或其他停止执行脚本的方法——则不会设置 cookie。
cookie() 全局辅助函数
如果调用 cookie() 时不带参数,cookie() 全局辅助函数将返回一个 CookieJar 实例。然而,Cookie 门面上存在的两个最方便的方法——has() 和 get()——仅存在于门面上,而不是 CookieJar 上。因此,在这种情况下,我认为全局辅助函数实际上不如其他选项有用。
cookie() 全局辅助函数最有用的任务是创建一个 cookie。如果将参数传递给 cookie(),它们将直接传递给 Cookie::make() 的等效函数,因此这是创建 cookie 的最快方法:
$cookie = cookie('dismissed-popup', true, 15);
注入实例
您还可以在应用程序的任何地方注入一个 Illuminate\Cookie\CookieJar 实例,但您将面临此处讨论的相同限制。
请求和响应对象中的 Cookies
由于 cookie 作为请求的一部分进入,并作为响应的一部分设置,这些 Illuminate 对象实际上是它们实际存在的位置。Cookie 门面的 get()、has() 和 queue() 方法只是与 Request 和 Response 对象交互的代理。
因此,与 cookie 交互的最简单方法是从请求中获取 cookie 并将其设置到响应中。
从请求对象中读取 Cookies
一旦您有了 Request 对象的副本——如果您不知道如何获取它,只需尝试 app('request')——您可以使用 Request 对象的 cookie() 方法读取其 cookie,如 示例 14-11 中所示。
示例 14-11. 从 Request 对象中读取 cookie
Route::get('dashboard', function (Illuminate\Http\Request $request) {
$userDismissedPopup = $request->cookie('dismissed-popup', false);
});
如您在本例中所见,cookie() 方法有两个参数:cookie 的名称和可选的回退值。
在响应对象上设置 Cookies
一旦您的 Response 对象准备就绪,您可以像 示例 14-12 中那样,在其上使用 cookie() 方法向响应添加 cookie。
示例 14-12. 在 Response 对象上设置 cookie
Route::get('dashboard', function () {
$cookie = cookie('saw-dashboard', true);
return Response::view('dashboard')
->cookie($cookie);
});
如果您是 Laravel 的新手,并且不确定使用哪种选项,我建议您在 Request 和 Response 对象上设置 cookie。这需要更多工作,但如果未来的开发人员不理解 CookieJar 队列,会导致更少的意外。
日志记录
到目前为止,在本书中我们已经看到了一些关于日志的简短示例,当我们讨论其他概念如容器和门面时,让我们简要看看除了 Log::info('Message') 之外的日志选项。
日志的目的是增加可发现性,或者说增加您理解应用程序当前状态的能力。
日志是您的代码为了理解应用程序执行过程中发生的事情而生成的短消息,有时会以人类可读的形式嵌入一些数据。每个日志必须以特定的级别捕获,这可以从emergency(发生了非常严重的事情)到debug(几乎没有意义的事情发生)不等。
没有任何修改,您的应用程序将会将任何日志语句写入到位于storage/logs/laravel.log的文件中,并且每个日志语句看起来都会有点像这样:
[2018-09-22 21:34:38] local.ERROR: Something went wrong.
您可以看到我们在一行上有日期、时间、环境、错误级别和消息。但是,默认情况下,Laravel 还会记录任何未捕获的异常,这种情况下您将在一行中看到整个堆栈跟踪。
在接下来的部分中,我们将介绍如何记录、为何记录以及如何在其他地方(例如 Slack)记录。
何时以及为何使用日志
日志最常见的用例是作为一种准一次性记录的记录,记录了您后来可能关心的事情,但您明确不需要程序化访问的事物。日志更多地用于了解应用程序中正在发生的情况,而不是创建您的应用程序可以消费的结构化数据。
例如,如果您希望编写代码以记录每次用户登录并对其进行有趣的处理,那么这是一个logins数据库表的使用案例。但是,如果您对这些登录有一种随意的兴趣,但又不确定您是否在编程上关心或需要这些信息,您可以只是在其上放置一个debug或info级别的日志并忘记它。
当您需要在发生问题时或在某个特定时间点查看某些东西的值,或者其他情况下时,日志也很常见。在代码中放置一个日志语句,从日志中获取您需要的数据,然后要么将其保留在代码中以备后用,要么再次删除它。
写入日志
在 Laravel 中编写日志条目的最简单方法是使用Log门面,并使用该门面上与您希望记录的严重级别匹配的方法。这些级别与RFC 5424中定义的相同:
Log::emergency($message);
Log::alert($message);
Log::critical($message);
Log::error($message);
Log::warning($message);
Log::notice($message);
Log::info($message);
Log::debug($message);
可选的,您还可以传递第二个参数,这是一个连接数据的数组:
Log::error('Failed to upload user image.', ['user' => $user]);
不同的日志目标可能会以不同的方式捕获此附加信息,但是在默认的本地日志中看起来像这样(尽管它将只是日志中的一行):
[2018-09-27 20:53:31] local.ERROR: Failed to upload user image. {
"user":"[object] (App\\User: {
\"id\":1,
\"name\":\"Matt\",
\"email\":\"matt@tighten.co\",
\"email_verified_at\":null,
\"api_token\":\"long-token-here\",
\"created_at\":\"2018-09-22 21:39:55\",
\"updated_at\":\"2018-09-22 21:40:08\"
})"
}
日志通道
与 Laravel 的许多其他方面(文件存储、数据库、邮件等)一样,您可以配置日志以使用一个或多个预定义的日志类型,这些类型在配置文件中定义。使用每种类型涉及向特定的日志驱动程序传递各种配置详细信息。
这些日志类型被称为频道,并且您将有stack、single、daily、slack、stderr、syslog和errorlog等选项。每个频道连接到一个驱动程序;可用的驱动程序包括stack、single、daily、slack、syslog、errorlog、monolog和custom。
我们将在这里介绍最常见的频道:single、daily、slack和stack。要了解更多有关驱动程序和可用频道的完整列表,请查看日志文档。
单一频道
single频道将每个日志条目写入单个文件,您将在path键中定义它。您可以在示例 14-13 中查看其默认配置:
示例 14-13. single频道的默认配置
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
],
这意味着它只会记录debug级别或更高级别的事件,并将它们全部写入单个文件storage/logs/laravel.log。
日志频道
daily频道每天生成一个新文件。你可以在示例 14-14 中查看其默认配置。
示例 14-14. daily频道的默认配置
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => 14,
],
它类似于single,但现在我们可以设置在清理之前要保留多少天的日志,并且日期将附加到我们指定的文件名中。例如,前述配置将生成名为storage/logs/laravel-.log的文件。
Slack 频道
slack频道使得将你的日志(或更可能的是特定的日志)发送到 Slack 变得简单。
它还说明您不仅限于 Laravel 默认提供的处理程序。我们将在下一节中介绍这一点,但这不是自定义 Slack 实现;这只是 Laravel 构建一个连接到 Monolog Slack 处理程序的日志驱动程序,如果您可以使用任何 Monolog 处理程序,那么您有很多选项可用。
默认配置显示在示例 14-15 中。
示例 14-15. slack频道的默认配置
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => 'Laravel Log',
'emoji' => ':boom:',
'level' => env('LOG_LEVEL', 'critical'),
],
stack 频道
stack频道是应用程序默认启用的频道。其默认配置显示在示例 14-16 中。
示例 14-16. stack频道的默认配置
'stack' => [
'driver' => 'stack',
'channels' => ['single'],
'ignore_exceptions' => false,
],
stack频道允许你将所有日志发送到多个频道(列在channels数组中)。因此,虽然这是默认在你的 Laravel 应用中配置的频道,因为它的channels数组默认设置为single,实际上你的应用只是使用了single日志频道。
但是如果您希望所有info级别及以上的内容都进入日常文件,而critical及更高级别的日志消息进入 Slack,使用stack驱动程序非常简单,正如示例 14-17 所示。
示例 14-17. 自定义stack驱动器
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => ['daily', 'slack'],
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => 'info',
'days' => 14,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => 'Laravel Log',
'emoji' => ':boom:',
'level' => 'critical',
],
]
写入特定日志频道
有时您可能希望在调用Log门面时精确控制哪些日志消息应放在哪里。您可以通过指定频道来实现这一点:
Log::channel('slack')->info("This message will go to Slack.");
高级日志配置
如果您想要自定义如何将每个日志发送到每个频道,或实现自定义 Monolog 处理程序,请查看 logging docs 以了解更多。
使用 Laravel Scout 进行全文搜索
Laravel Scout 是一个独立的包,您可以将其引入您的 Laravel 应用程序中,以为您的 Eloquent 模型添加全文搜索功能。Scout 可以轻松地索引和搜索您的 Eloquent 模型内容;它配备了用于 Algolia、Meilisearch 和数据库(MySQL/PostgreSQL)的驱动程序,但也有其他提供者的社区包。我假设您正在使用 Algolia。
安装 Scout
首先,在任何 Laravel 应用程序中引入包:
composer require laravel/scout
接下来,您将需要设置您的 Scout 配置。运行此命令:
php artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider"
并将您的 Algolia 凭证粘贴到 config/scout.php 中。
最后,安装 Algolia SDK:
composer require algolia/algoliasearch-client-php
标记您的模型以进行索引
在您的模型中(我们将使用 Review,例如书评),导入 Laravel\Scout\Searchable 特性。
您可以使用 toSearchableArray() 方法定义哪些属性可搜索(默认镜像 toArray()),并使用 searchableAs() 方法定义模型索引的名称(默认为表名)。
Scout 订阅您标记模型上的创建/删除/更新事件。当您创建、更新或删除任何行时,Scout 将同步这些更改到 Algolia。它将根据您的配置使用队列,将这些更改同步或异步进行队列处理。
搜索您的索引
Scout 的语法很简单。例如,要查找任何包含 Llew 一词的 Review:
Review::search('Llew')->get();
您也可以像使用常规的 Eloquent 调用那样修改您的查询:
// Get all records from the Review that match the term "Llew",
// limited to 20 per page and reading the page query parameter,
// just like Eloquent pagination
Review::search('Llew')->paginate(20);
// Get all records from the Review that match the term "Llew"
// and have the account_id field set to 2
Review::search('Llew')->where('account_id', 2)->get();
这些搜索返回什么?一组从数据库中重新生成的 Eloquent 模型。ID 存储在 Algolia 中,Algolia 返回匹配的 ID 列表;然后 Scout 从数据库中提取这些记录,并将它们作为 Eloquent 对象返回。
您无法完全访问 SQL WHERE 命令的复杂性,但它提供了比较检查的基本框架,如此处代码示例中所示。
队列和 Scout
此时,您的应用程序将在修改任何数据库记录时向 Algolia 发送 HTTP 请求。这可能会迅速减慢您的应用程序,这就是为什么 Scout 使得将其所有操作推送到队列中变得容易。
在 config/scout.php 中,将 queue 设置为 true,使这些更新可以异步索引。现在,您的全文索引在“最终一致性”下运行;您的数据库记录将立即接收更新,并且搜索索引的更新将被排队并根据队列工作程序的速度快速更新。
执行无索引操作
如果您需要执行一组操作并避免触发响应的索引,请在您的模型上使用 withoutSyncingToSearch() 方法包装这些操作。
Review::withoutSyncingToSearch(function () {
// Make a bunch of reviews, e.g.
Review::factory()->count(10)->create();
});
有条件地对模型进行索引
有时,您可能只想在满足某些条件时索引记录。您可以在模型类上使用shouldBeSearchable()方法来实现这一点:
public function shouldBeSearchable()
{
return $this->isApproved();
}
通过代码手动触发索引
如果您想手动触发对模型的索引,可以在应用程序中使用代码或通过命令行完成。
要从代码中手动触发索引,请在任何 Eloquent 查询的末尾添加searchable(),它将索引该查询中找到的所有记录:
Review::all()->searchable();
你还可以选择将查询范围限制为只索引你想要的记录。但是,Scout 足够智能,可以插入新记录并更新旧记录,因此你可能选择重新索引模型数据库表的整个内容。
您还可以在关系方法上运行searchable():
$user->reviews()->searchable();
如果要取消索引任何符合相同查询链条件的记录,只需使用unsearchable()即可:
Review::where('sucky', true)->unsearchable();
通过 CLI 手动触发索引
您还可以使用 Artisan 命令触发索引:
php artisan scout:import "App\Review"
这将分块处理所有Review模型并对其进行索引。
HTTP 客户端
Laravel 的 HTTP 客户端并不完全是存储机制,但它是检索机制,说实话,我不确定它在这本书中还适合什么其他位置。让我们开始吧!
HTTP 客户端使您的 Laravel 应用程序可以通过简单而清晰的界面调用POST、GET等方式与外部 Web 服务和 API 进行通信。
如果您曾经使用过 Guzzle,您会理解它可以做什么,您还可能理解为什么简单的界面值得一提:Guzzle 功能强大,但也非常复杂,多年来变得越来越复杂。
使用 HTTP Facade
大多数时候,如果您正在使用 HTTP 客户端,您将依赖于其外观,直接在外观上调用get()和post()等方法。查看示例 14-18 以获取示例。
示例 14-18. HTTP Facade 的基本用法示例
use Illuminate\Support\Facades\Http;
$response = Http::get('http://my-api.com/posts');
$response = Http::post('http://my-api.com/posts/2/comments', [
'title' => 'I loved this post!',
]);
从 HTTP Facade 调用返回的$response是Illuminate\Http\Client\Response的一个实例,它提供了一套方法来检查响应。您可以查看文档获取完整列表,但也可以在示例 14-19 中看到一些常见方法。
示例 14-19. HTTP Client Response 对象上常用的方法
$response = Http::get('http://my-api.com/posts');
$response->body(); // string
$response->json(); // array
$response->json('key', 'default') // string
$response->successful(); // bool
正如你从示例 14-18 中看到的,你可以在POST请求中发送数据,但还有许多其他方法可以在请求中发送数据。
再次,这里是一些常见示例,您可以在文档中看到更多:
$response = Http::withHeaders([
'X-Custom-Header' => 'header value here'
])->post(/* ... */);
$response = Http::withToken($authToken)->post(/* ... */);
$response = Http::accept('application/json')->get('http://my-api.com/users');
处理错误和超时以及检查状态
默认情况下,HTTP 客户端在请求失败时将等待 30 秒,并且不会重试。但您可以自定义客户端响应意外情况的许多方面。
要定义超时时间,请链接timeout()并传递应等待的秒数:
$response = Http::timeout(120)->get(/* ... */);
如果您期望尝试失败,您可以定义客户端应该重试每个请求的次数,使用retry()链式方法:
$response = Http::retry($retries, $millisecondsBetweenRetries)->post(/* ... */);
响应对象上的其他一些方法允许我们检查请求是否成功以及我们收到了什么 HTTP 状态码;以下是其中一些:
$response->successful(); // 200 or 300
$response->failed(); // 400 or 500 errors
$response->clientError(); // 400 errors
$response->serverError(); // 500 errors
// A few of the specific checks we can run for given status codes
$response->ok(); // 200 OK
$response->movedPermanently(); // 301 Moved Permanently
$response->unauthorized(); // 401 Unauthorized
$response->serverError(); // 500 Internal Server Error
您还可以定义一个回调函数,在发生错误时运行:
$response->onError(function (Response $response) {
// handle error
});
测试
测试大多数这些功能就像在您的测试中使用它们一样简单;无需模拟或存根。默认配置已经可以工作了—例如,查看phpunit.xml,查看您的会话驱动程序和缓存驱动程序已设置为适合测试的值。
但是,在您尝试测试它们之前,有一些方便的方法和一些需要注意的地方。
文件存储
测试文件上传可能有点麻烦,但是按照这些步骤进行操作,一切将变得清晰。
上传虚假文件
首先,让我们看看如何手动创建一个Illuminate\Http\UploadedFile对象,以便在我们的应用程序测试中使用(示例 14-20)。
示例 14-20. 创建用于测试的假UploadedFile对象
public function test_file_should_be_stored()
{
Storage::fake('public');
$file = UploadedFile::fake()->image('avatar.jpg');
$response = $this->postJson('/avatar', [
'avatar' => $file,
]);
// Assert the file was stored
Storage::disk('public')->assertExists("avatars/{$file->hashName()}");
// Assert a file does not exist
Storage::disk('public')->assertMissing('missing.jpg');
}
我们已经创建了一个新的UploadedFile实例,引用我们的测试文件,现在我们可以使用它来测试我们的路由。
返回虚假文件
如果您的路由期望真实文件存在,有时使其可测试的最佳方法是使该真实文件实际存在。假设每个用户都必须有个人资料图片。
首先,让我们为用户设置模型工厂,使用 Faker 复制图片,如在示例 14-21 中所见。
示例 14-21. 使用 Faker 返回虚假文件
public function definition ()
{
return [
'picture' => fake()->file(
base_path('tests/stubs/images'), // Source directory
storage_path('app'), // Target directory
false, // Return just filename, not full path
),
'name' => fake()->name(),
];
};
Faker 的file()方法从源目录中选择一个随机文件,将其复制到目标目录,然后返回文件名。因此,我们刚刚从tests/stubs/images目录中选择了一个随机文件,将其复制到storage/app目录,并将其文件名设置为我们的User上的picture属性。此时,我们可以在期望User具有图片的路由测试中使用User,如示例 14-22 中所示。
示例 14-22. 断言图像的 URL 已回显
public function test_user_profile_picture_echoes_correctly()
{
$user = User::factory()->create();
$response = $this->get(route('users.show', $user->id));
$response->assertSee($user->picture);
}
当然,在许多情况下,您可以只在那里生成一个随机字符串,甚至不复制文件。但是,如果您的路由检查文件是否存在或对文件运行任何操作,则这是您的最佳选择。
会话
如果您需要断言会话中已设置了某些内容,可以在每个测试中使用 Laravel 提供的一些方便方法。所有这些方法都在Illuminate\Testing\TestResponse对象的测试中可用:
assertSessionHas(*$key*, *$value = null*)
断言会话对特定键有值,并且如果传递了第二个参数,则该键具有特定值:
public function test_some_thing()
{
// Do stuff that ends up with a $response object...
$response->assertSessionHas('key', 'value');
}
assertSessionHasAll(*array $bindings*)
如果传递了一个键/值对的数组,断言所有键都等于所有值。如果一个或多个数组条目只是一个值(具有 PHP 的默认数值键),则仅检查该值是否存在于会话中:
$check = [
'has',
'hasWithThisValue' => 'thisValue',
];
$response->assertSessionHasAll($check);
assertSessionMissing(*$key*)
断言会话对于特定键没有值。
assertSessionHasErrors(*$bindings = []*, *$format = null*)
断言会话具有一个errors值。这是 Laravel 用于从验证失败中返回错误的关键。
如果数组只包含键,它将检查这些键是否设置了错误:
$response = $this->post('test-route', ['failing' => 'data']);
$response->assertSessionHasErrors(['name', 'email']);
你还可以传递这些键的值,并且可选地传递一个*$format*,以验证这些错误消息是否按预期返回:
$response = $this->post('test-route', ['failing' => 'data']);
$response->assertSessionHasErrors([
'email' => '<strong>The email field is required.</strong>',
], '<strong>:message</strong>');
缓存
对于使用缓存的功能进行测试并没有什么特别的地方 —— 只需要去做:
Cache::put('key', 'value', 900);
$this->assertEquals('value', Cache::get('key'));
Laravel 默认在您的测试环境中使用array缓存驱动程序,它只是将您的缓存值存储在内存中。
Cookies
如果您需要在应用程序测试中测试路由之前设置 cookie 怎么办?您可以使用withCookies()方法在请求中设置 cookies。要了解更多,请查看第十二章。
在测试期间排除您的 cookie 加密
如果你的测试中的 cookies 不起作用,除非你将它们排除在 Laravel 的 cookie 加密中间件之外。您可以通过教EncryptCookies中间件暂时禁用这些 cookies 来实现这一点:
use Illuminate\Cookie\Middleware\EncryptCookies;
...
$this->app->resolving(
EncryptCookies::class,
function ($object) {
$object->disableFor('cookie-name');
}
);
// ...run test
这意味着您可以设置一个 cookie,并使用类似示例 14-23 来检查它。
示例 14-23. 对 cookies 运行单元测试
public function test_cookie()
{
$this->app->resolving(EncryptCookies::class, function ($object) {
$object->disableFor('my-cookie');
});
$response = $this->call(
'get',
'route-echoing-my-cookie-value',
[],
['my-cookie' => 'baz']
);
$response->assertSee('baz');
}
如果您想测试响应是否设置了 cookie,可以使用assertCookie()来检查该 cookie:
$response = $this->get('cookie-setting-route');
$response->assertCookie('cookie-name');
或者您可以使用assertPlainCookie()来测试 cookie 并断言它未加密。
日志
测试某个特定日志是否已写入的最简单方法是针对Log外观进行断言(详细了解请参阅“模拟其他外观”)。示例 14-24 展示了这个工作原理。
示例 14-24. 对Log外观进行断言
// Test file
public function test_new_accounts_generate_log_entries()
{
Log::shouldReceive('info')
->once()
->with('New account created!');
// Create a new account
$this->post(route('accounts.store'), ['email' => 'matt@mattstauffer.com']);
}
// AccountController
public function store()
{
// Create account
Log::info('New account created!');
}
也有一个名为Log Fake的包,扩展了此处展示的外观测试可以做的事情,并允许您针对日志编写更多定制的断言。
Scout
如果您需要测试使用 Scout 数据的代码,您可能不希望您的测试触发索引操作或从 Scout 读取数据。只需向您的 phpunit.xml 添加一个环境变量来禁用 Scout 与 Algolia 的连接:
<env name="SCOUT_DRIVER" value="null"/>
HTTP 客户端
使用 Laravel 的 HTTP 客户端的一个不可思议的好处是,它使您能够在测试中以最小的配置来伪造响应。
最简单的选项是运行Http::fake(),它将为您每次调用返回一个空的成功响应。
不过,您还可以自定义您希望从 HTTP 客户端调用返回的具体响应,就像您在示例 14-25 中看到的那样。
示例 14-25. 通过 URL 自定义对 HTTP 客户端的响应
Http::fake([
// Return a JSON response for a particular API
'my-api.com/*' => Http::response(['key' => 'value'], 200, $headersArray),
// Return a string response for all other endpoints
'*' => Http::response('This is a fake API response', 200, $headersArray),
]);
如果需要定义针对特定端点(或符合特定端点模式)的请求遵循特定顺序,可以如 示例 14-26 所示进行定义。
示例 14-26. 定义针对特定端点的响应序列
Http::fake([
// Return a sequence of responses for consecutive calls to this API
'my-api.com/*' => Http::sequence()
->push('Initial string response', 200)
->push(['secondary' => 'response'], 200)
->pushStatus(404),
]);
您还可以对应用程序发送到特定端点的数据进行断言,如 示例 14-27 所示。
示例 14-27. 对应用程序发出的调用进行断言
Http::fake();
Http::assertSent(function (Request $request) {
return $request->hasHeader('X-Custom-Header', 'certain-value') &&
$request->url() == 'http://my-api.com/users/2/comments' &&
$request['name'] == 'New User';
});
TL;DR
Laravel 提供了简单的接口来执行许多常见的存储操作:文件系统访问、会话、Cookie、缓存和搜索。无论您使用哪个提供者,每个 API 都是相同的,这是 Laravel 通过允许多个“驱动程序”提供相同公共接口实现的。这使得根据环境或应用程序需求的变化简单切换提供者成为可能。