Laravel Eloquent 自定义查询作用域:封装复杂业务查询逻辑
大家好,今天我们来深入探讨 Laravel Eloquent 的一个强大特性:自定义查询作用域(Scopes)。在实际开发中,我们经常会遇到需要重复使用的复杂查询逻辑。如果每次都将这些逻辑散落在控制器或其他地方,会导致代码冗余、难以维护。自定义查询作用域就是为了解决这个问题而生的。它允许我们将常用的查询逻辑封装成可重用的方法,使代码更加清晰、易于维护。
1. 什么是查询作用域?
查询作用域本质上是 Eloquent 模型上的一个方法,它接受一个查询构建器实例作为参数,并可以对该构建器进行修改,添加额外的查询约束。这些约束会被自动应用到所有使用该作用域的查询中。
例如,假设我们有一个 Post 模型,我们需要经常查询已发布的文章。我们可以定义一个 published 作用域来简化这个查询:
<?php
namespace AppModels;
use IlluminateDatabaseEloquentModel;
class Post extends Model
{
public function scopePublished($query)
{
return $query->where('is_published', true);
}
}
现在,我们就可以像这样使用这个作用域:
$posts = Post::published()->get(); // 获取所有已发布的文章
2. 全局作用域与局部作用域
Laravel 提供了两种类型的查询作用域:
- 全局作用域 (Global Scopes): 全局作用域会在模型的所有查询中自动应用,除非显式地移除它。这对于应用一些通用的约束,例如软删除,非常有用。
- 局部作用域 (Local Scopes): 局部作用域需要显式地调用才能应用到查询中。它们更适合于特定场景的查询约束。
3. 局部作用域的创建与使用
我们已经看到了一个局部作用域的例子。要创建一个局部作用域,需要在模型类中定义一个以 scope 开头的方法,后面跟着作用域名。该方法接受一个 $query 参数(查询构建器实例),以及任何其他需要传递给作用域的参数。
3.1 定义局部作用域
让我们创建一个更复杂的例子。假设我们有一个 Product 模型,并且需要根据价格范围进行查询。我们可以定义一个 priceRange 作用域:
<?php
namespace AppModels;
use IlluminateDatabaseEloquentModel;
class Product extends Model
{
public function scopePriceRange($query, $minPrice, $maxPrice)
{
return $query->whereBetween('price', [$minPrice, $maxPrice]);
}
}
3.2 使用局部作用域
现在,我们可以像这样使用 priceRange 作用域:
$products = Product::priceRange(10, 50)->get(); // 获取价格在 10 到 50 之间的产品
3.3 链式调用
局部作用域可以链式调用,使查询更加灵活:
$products = Product::where('category_id', 1)
->priceRange(20, 100)
->orderBy('name')
->get(); // 获取 category_id 为 1,价格在 20 到 100 之间,并按名称排序的产品
4. 全局作用域的创建与使用
全局作用域更加强大,但也需要更加谨慎地使用,因为它们会影响模型的所有查询。
4.1 创建全局作用域类
要创建一个全局作用域,我们需要创建一个类,该类实现 IlluminateDatabaseEloquentScope 接口。该接口定义了一个 apply 方法和一个可选的 extend 方法。
<?php
namespace AppScopes;
use IlluminateDatabaseEloquentBuilder;
use IlluminateDatabaseEloquentModel;
use IlluminateDatabaseEloquentScope;
class ActiveScope implements Scope
{
/**
* 应用作用域到给定的 Eloquent 查询构建器。
*
* @param IlluminateDatabaseEloquentBuilder $builder
* @param IlluminateDatabaseEloquentModel $model
* @return void
*/
public function apply(Builder $builder, Model $model)
{
$builder->where('is_active', true);
}
/**
* 在给定的 Eloquent 查询构建器上添加扩展。
*
* @param IlluminateDatabaseEloquentBuilder $builder
* @return void
*/
public function extend(Builder $builder)
{
$builder->macro('withoutActive', function (Builder $builder) {
return $builder->withoutGlobalScope($this);
});
}
}
在这个例子中,ActiveScope 确保所有查询只返回 is_active 字段为 true 的记录。 extend 方法允许我们定义一个 withoutActive 方法,用于临时移除该全局作用域。
4.2 应用全局作用域
要应用全局作用域,我们需要在模型类的 boot 方法中注册它。
<?php
namespace AppModels;
use AppScopesActiveScope;
use IlluminateDatabaseEloquentModel;
class Product extends Model
{
protected static function boot()
{
parent::boot();
static::addGlobalScope(new ActiveScope);
}
}
现在,所有对 Product 模型的查询都会自动应用 ActiveScope,只返回 is_active 为 true 的产品。
4.3 移除全局作用域
有时候,我们需要在特定的查询中忽略全局作用域。 这可以通过 withoutGlobalScope 方法来实现:
$products = Product::withoutGlobalScope(ActiveScope::class)->get(); // 获取所有产品,包括 is_active 为 false 的产品
或者,如果我们使用了 extend 方法定义了 withoutActive 宏,我们可以这样使用:
$products = Product::withoutActive()->get(); // 获取所有产品,包括 is_active 为 false 的产品
5. 使用匿名全局作用域
从 Laravel 7.x 开始,可以使用匿名全局作用域,这可以简化全局作用域的定义。
<?php
namespace AppModels;
use IlluminateDatabaseEloquentModel;
use IlluminateDatabaseEloquentBuilder;
class Product extends Model
{
protected static function boot()
{
parent::boot();
static::addGlobalScope('active', function (Builder $builder) {
$builder->where('is_active', true);
});
}
}
匿名全局作用域不需要单独创建一个类,直接在 addGlobalScope 方法中定义作用域的逻辑。 移除匿名全局作用域也需要使用其名称:
$products = Product::withoutGlobalScope('active')->get();
6. 最佳实践与注意事项
- 命名规范: 局部作用域方法名以
scope开头,并使用驼峰命名法 (例如:scopePublished)。 - 职责单一: 每个作用域应该只负责一个明确的查询约束。
- 避免过度使用全局作用域: 全局作用域会影响所有查询,过度使用可能会导致意外的结果。
- 测试: 为自定义作用域编写单元测试,确保其行为符合预期。
- 可读性: 作用域应该易于理解和维护,避免过于复杂的逻辑。
- 考虑性能: 复杂的查询作用域可能会影响查询性能,需要进行优化。
7. 实际应用场景示例
让我们看一些实际应用场景的例子,来更好地理解如何使用自定义查询作用域。
7.1 软删除
Laravel 内置了软删除功能,可以使用全局作用域来实现。 假设我们有一个 User 模型,并且使用了软删除。我们可以创建一个 WithoutTrashedScope 用于在某些查询中包含已软删除的用户。
<?php
namespace AppScopes;
use IlluminateDatabaseEloquentBuilder;
use IlluminateDatabaseEloquentModel;
use IlluminateDatabaseEloquentScope;
class WithoutTrashedScope implements Scope
{
public function apply(Builder $builder, Model $model)
{
$builder->withoutTrashed(); // 应用 withoutTrashed 方法
}
public function extend(Builder $builder)
{
$builder->macro('withTrashed', function (Builder $builder) {
return $builder->withTrashed(); // 覆盖 withTrashed 方法
});
}
}
在 User 模型中注册该全局作用域:
<?php
namespace AppModels;
use AppScopesWithoutTrashedScope;
use IlluminateDatabaseEloquentModel;
use IlluminateDatabaseEloquentSoftDeletes;
class User extends Model
{
use SoftDeletes;
protected static function boot()
{
parent::boot();
static::addGlobalScope(new WithoutTrashedScope);
}
}
现在,默认情况下,所有 User 模型的查询都会排除已软删除的用户。 如果我们需要包含已软删除的用户,可以使用 withTrashed() 方法:
$users = User::withTrashed()->get(); // 获取所有用户,包括已软删除的用户
7.2 多租户应用
在多租户应用中,每个租户的数据应该相互隔离。可以使用全局作用域来自动过滤查询,只返回当前租户的数据。
假设我们有一个 TenantScope,它根据当前租户 ID 过滤查询。
<?php
namespace AppScopes;
use IlluminateDatabaseEloquentBuilder;
use IlluminateDatabaseEloquentModel;
use IlluminateDatabaseEloquentScope;
use IlluminateSupportFacadesAuth;
class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model)
{
$user = Auth::user(); // 假设用户已登录,并且可以获取租户 ID
if ($user && $user->tenant_id) {
$builder->where('tenant_id', $user->tenant_id);
}
}
}
在需要进行租户隔离的模型中注册该全局作用域:
<?php
namespace AppModels;
use AppScopesTenantScope;
use IlluminateDatabaseEloquentModel;
class Product extends Model
{
protected static function boot()
{
parent::boot();
static::addGlobalScope(new TenantScope);
}
}
7.3 根据用户角色进行过滤
假设我们有一个 Task 模型,并且需要根据用户的角色来过滤任务。例如,管理员可以查看所有任务,而普通用户只能查看自己分配的任务。
我们可以创建一个 UserTasksScope,它根据用户的角色来添加不同的查询约束。
<?php
namespace AppScopes;
use IlluminateDatabaseEloquentBuilder;
use IlluminateDatabaseEloquentModel;
use IlluminateDatabaseEloquentScope;
use IlluminateSupportFacadesAuth;
class UserTasksScope implements Scope
{
public function apply(Builder $builder, Model $model)
{
$user = Auth::user();
if ($user && $user->role === 'admin') {
// 管理员可以查看所有任务
} else {
$builder->where('user_id', $user->id); // 普通用户只能查看自己分配的任务
}
}
}
在 Task 模型中注册该全局作用域:
<?php
namespace AppModels;
use AppScopesUserTasksScope;
use IlluminateDatabaseEloquentModel;
class Task extends Model
{
protected static function boot()
{
parent::boot();
static::addGlobalScope(new UserTasksScope);
}
}
8. 代码示例总结
为了方便大家理解,下面用表格的形式总结了上面代码示例中使用到的方法:
| 方法名 | 作用 |
|---|---|
scopePublished |
定义一个局部作用域,用于查询已发布的文章 (is_published 为 true)。 |
scopePriceRange |
定义一个局部作用域,用于查询价格在指定范围内的产品。 |
addGlobalScope |
在模型中注册一个全局作用域。 |
withoutGlobalScope |
移除一个全局作用域,允许查询忽略该作用域的约束。 |
apply |
全局作用域的 apply 方法,用于向查询构建器添加约束。 |
extend |
全局作用域的 extend 方法,用于向查询构建器添加自定义宏,例如 withoutActive。 |
withoutTrashed |
用于从查询结果中排除已软删除的记录 (需要 SoftDeletes trait)。 |
withTrashed |
用于在查询结果中包含已软删除的记录 (需要 SoftDeletes trait)。 注意:AppScopesWithoutTrashedScope 中的extend方法覆盖了withTrashed方法,使得withTrashed()可以重新包含软删除的数据。 |
macro |
用于向查询构建器添加自定义宏。 |
whereBetween |
添加一个 WHERE 子句,用于匹配给定范围内的值。 |
orderBy |
添加一个 ORDER BY 子句,用于对查询结果进行排序。 |
9. 灵活运用查询作用域提升代码质量
自定义查询作用域是 Laravel Eloquent 中一个非常强大的工具,可以帮助我们封装复杂的查询逻辑,提高代码的可读性、可维护性和可重用性。 通过合理地使用局部作用域和全局作用域,我们可以编写出更加优雅和高效的 Laravel 应用程序。
最后的思考:代码的艺术在于精简和重用
希望今天的讲解能够帮助大家更好地理解和使用 Laravel Eloquent 的自定义查询作用域。 记住,编写高质量的代码不仅仅是实现功能,更重要的是让代码易于理解、易于维护和易于扩展。 自定义查询作用域正是帮助我们实现这一目标的重要工具。