心智备份

Laravel 项目开发规范

2022-06-20 · 22 min read
PHP Laravel

路由器

路由闭包

绝不 在路由配置文件里书写『闭包路由』或者其他业务逻辑代码,因为一旦使用将无法使用 路由缓存

路由器要保持干净整洁,绝不 放置除路由配置以外的其他程序逻辑。

控制器方法定义

路由中的控制器方法定义,必须 使用 Controller::class 这种方式加载。

✅ 正确

Route::get('/photos', [PhotosController::class, 'index'])->name('photos.index');

❌ 错误的例子:

Route::get('/photos', 'PhotosController@index')->name('photos.index');

这样做 IDE 可以加代码索引。有两个好处:

  1. 支持点击跳转到方法;
  2. 支持重构。

Restful 路由

必须 优先使用 Restful 路由,配合资源控制器使用,见 文档

超出 Restful 路由的,应该 模仿上图的方式来定义路由。

resource 方法正确使用

一般资源路由定义:

Route::resource('photos', PhotosController::class);

等于以下路由定义:

Route::get('/photos', [PhotosController::class, 'index'])->name('photos.index');
Route::get('/photos/create', [PhotosController::class, 'create'])->name('photos.create');
Route::post('/photos', [PhotosController::class, 'store'])->name('photos.store');
Route::get('/photos/{photo}', [PhotosController::class, 'show'])->name('photos.show');
Route::get('/photos/{photo}/edit', [PhotosController::class, 'edit'])->name('photos.edit');
Route::put('/photos/{photo}', [PhotosController::class, 'update'])->name('photos.update');
Route::delete('/photos/{photo}', [PhotosController::class, 'destroy'])->name('photos.destroy');

使用 resource 方法时,如果仅使用到部分路由,必须 使用 only 列出所有可用路由:

Route::resource('photos', PhotosController::class, ['only' => ['index', 'show']]);

绝不 使用 except,因为 only 相当于白名单,相对于 except 更加直观。路由使用白名单有利于养成『安全习惯』。

单数 or 复数?

资源路由路由 URI 必须 使用复数形式,如:

  • /photos/create
  • /photos/{photo}

错误的例子如:

  • /photo/create
  • /photo/{photo}

路由模型绑定

在允许使用路由 模型绑定 的地方 必须 使用。

模型绑定代码 必须 放置于 app/Providers/RouteServiceProvider.php 文件的 boot 方法中:

    public function boot()
    {
        Route::bind('user_name', function ($value) {
            return User::where('name', $value)->first();
        });

        Route::bind('photo', function ($value) {
            return Photo::find($value);
        });

        parent::boot();
    }

注:如果使用了 {id} 作为参数,Laravel 已经默认做了绑定。

全局路由器参数

出于安全考虑,应该 使用全局路由器参数限制,详见 文档

必须RouteServiceProvider 文件的 boot 方法里定义模式:

/**
 * 定义你的路由模型绑定, pattern 过滤器等。
 *
 * @return void
 */
public function boot()
{
    Route::pattern('id', '[0-9]+');

    parent::boot();
}

模式一旦被定义,便会自动应用到所有使用该参数名称的路由上:

Route::get('users/{id}', [UsersController::class, 'show']);
Route::get('photos/{id}', [PhotosController::class, 'show']);

只有在 id 为数字时,才会路由到控制器方法中,否则 404 错误。

路由命名

除了 resource 资源路由以外,其他所有路由都 必须 使用 name 方法进行命名。

必须 使用『资源前缀』作为命名规范,如下的 users.follow,资源前缀的值是 users.

Route::post('users/{id}/follow', [UsersController::class, 'follow'])->name('users.follow');

获取 URL

获取 URL 必须 遵循以下优先级:

  1. $model->link()
  2. route 方法
  3. url 方法

在 Model 中创建 link() 方法:

public function link($params = [])
{
	$params = array_merge([$this->id], $params);
	return route('models.show', $params);
}

所有单个模型数据链接使用:

$model->link();

// 或者添加参数
$model->link($params = ['source' => 'list'])

『单个模型 URI』经常会发生变化,这样做将会让程序更加灵活。

除了『单个模型 URI』,其他路由 必须 使用 route 来获取 URL:

$url = route('profile', ['id' => 1]);

无法使用 route 的情况下,可以 使用 url 方法来获取 URL:

url('profile', [1]);

Service 模式

介绍

项目中的大部分业务逻辑,都应该封装到 Service 层。这不仅能更好地组织代码,还方便单元测试。

ModelService

Model 的操作,涉及到业务逻辑的,绝不放置于控制器方法或模型文件中。

控制器方法只处理请求逻辑。模型只处理模型定义,以及数据关联逻辑。

业务逻辑必须封装到对应的 ModelService 类中。

例如 LearnKu.com 的 Reply 模型,用户发布 Reply 时需要的逻辑,如发送通知给话题的作者,或者增加话题的评论数等操作,放置于 ReplyService 类的 create 方法。

ModelService 方法命名

必须参照 Laravel Model 的方法来命名,如:

$reply_service->create();
$reply_service->all();
$reply_service->update();
$reply_service->delete();

其他 Service

其他类型的类,都应该使用 Service 来封装,例如说:

  • 请求第三方接口的类(SendCloudService)

  • 图片处理的工具类(ImageService)

  • 包含业务逻辑的类(对 Elasticsearch 封装的 SearchService )

存放目录

所有的 Service 类都必须存放于 app/Services 目录中(注意是复数)。

目录组织

应该避免直接将 Service 类放置于 app/Services 目录下,应该考虑通过业务逻辑,将其归类于子目录中。如:

Auth —— 存放登录、授权相关的 Service;
Payment —— 存放支付相关的 Service;
Book —— 存放课程相关的 Service.

Service 方法无状态

必须 做到 Service 类无状态。

无状态意味着是无论在控制器方法、命令行、测试代码中,皆可调用。

❌ 错误的例子:

// CommentService
public function create($content)
{
    return Comment::create([
        'content' => $content,
        'user_id' => Auth::user()->id
    ]);
}

// PostService
public function update(Request $request)
{
    return $this->comments()->create([
        'content' => $request->get('content'),
        'category_id' => $request->category_id
        'user_id' => Auth::user()->id
    ]);
}

✅正确的例子

// CommentService
public function create($content, $user)
{
    return Comment::create([
        'content' => $content,
        'user_id' => $user->id
    ]);
}

// PostService
public function create($content, $category_id, $user)
{
    return Post::create([
        'content' => $content,
        'category_id' => $category_id,
        'user_id' => $user->id
    ]);
}

模型规范

放置位置

所有的数据模型文件,都 必须 存放在:app/Models/ 文件夹中。

命名空间:

namespace App\Models;

继承基类

所有的 Eloquent 数据模型必须 继承统一的基类 App\Models\Model,此基类存放位置为 /app/Models/Model.php,内容参考以下:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model as EloquentModel;

class Model extends EloquentModel
{
    public function scopeRecent($query)
    {
        return $query->orderBy('id', 'desc');
    }
	
	public function scopeOlder($query)
	{
	  return $query->orderBy('id', 'asc');
	}
	
	public function scopeByUser($query, User $user)
	{
	  return $query->where('user_id', $user->id);
	}
}

以 Photo 数据模型作为例子继承 Model 基类:

<?php

namespace App\Models;

class Photo extends Model
{
    protected $fillable = ['id', 'user_id'];

    public function user()
    {
        return $this->belongsTo(User::class);
    }
}

命名规范

数据模型相关的命名规范:

  • 数据模型类名 必须 为「单数」, 如:App\Models\Photo
  • 类文件名 必须 为「单数」,如:app/Models/Photo.php
  • 数据库表名字 必须 为「复数」,多个单词情况下使用「Snake Case」 如:photos, my_photos
  • 数据库表迁移名字 必须 为「复数」,如:2014_08_08_234417_create_photos_table.php
  • 数据填充文件名 必须 为「复数」,如:PhotosTableSeeder.php
  • 数据库字段名 必须 为「Snake Case」,如:view_count, is_vip
  • 数据库表主键 必须 为「id」
  • 数据库表外键 必须 为「resource_id」,如:user_id, post_id
  • 数据模型变量 必须 为「resource_id」,如:$user_id, $post_id

模型关联

数据关联内部 必须 使用「resource_id」,假如 User 模型有 id 和 UUID 两个唯一字段,其他模型关联 User 必须 使用 id 字段。也就是在其他模型的数据表里,使用 user_id 字段。

利用 Trait 来扩展数据模型

模型间,相同的模型逻辑,例如 User 和 Topic 都有一个 settings JSON 字段,用来实现单个模型的设置功能,应该 利用 Trait 来实现逻辑代码。

所有模型 Traits 必须存放于app/Models/Traits 目录下。

注意: 业务逻辑请使用 ModelService 模式来组织代码。

Repository

绝不 使用 Repository,因为我们不是在写 JAVA 代码,太多封装就成了「过度设计(Over Designed)」,极大降低了编码愉悦感,使用 MVC 够傻够简单。

代码的可读性,维护和开发的便捷性,直接关系到程序员开发时的愉悦感,直接影响到项目推进效率和程序 Debug 速度。

关于 SQL 文件

  • 绝不 使用命令行或者 PHPMyAdmin 直接创建索引或表。必须 使用 数据库迁移 去创建表结构,并提交版本控制器中;
  • 绝不 为了共享对数据库更改就直接导出 SQL,所有修改都 必须 使用 数据库迁移 ,并提交版本控制器中;
  • 绝不 直接向数据库手动写入伪造的测试数据。必须 使用 数据填充 来插入假数据,并提交版本控制器中。

作用域

Laravel 的 Model 全局作用域 允许我们为给定模型的所有查询添加默认的条件约束。

所有的全局作用域都 必须 统一使用 闭包定义全局作用域,如下:

/**
 * 数据模型的启动方法
 *
 * @return void
 */
protected static function boot()
{
	parent::boot();

	static::addGlobalScope('age', function(Builder $builder) {
		$builder->where('age', '>', 200);
	});
}

数据层无状态

先看一段代码,以下是 Post 模型里创建文章评论的方法:

	public function createComment($content)
    {
        return $this->comments()->create([
            'content' => $content,
            'user_id' => Auth::user()->id
        ]);
    }

注意 Auth::user()->id ,在数据层里使用当前登录用户状态,是默认假设这段代码永远是在 Web 用户请求下执行的。

然而事实并非如此,有时候你可能会在命令行下触发调用这个 createComment() 方法,有时候是管理员在后台触发,有时候是队列里触发。

一个最佳实践的做法是, 绝不 在数据层里使用用户登录状态信息。如果需要用户信息,必须 将其作为依赖进行传参,如以上代码可修改为:

	public function createComment($content, $user)
    {
        return $this->comments()->create([
            'content' => $content,
            'user_id' => $user->id
        ]);
    }

在有需要的地方调用时,以参数传入:

Post::createComment($content, Auth::user())

命令行书写某些特殊逻辑时,例如使用 1 号用户的身份创建评论:

Post::createComment($content, User::find(1))

数据层,也就是模型里,不能跟用户的登录状态挂钩。

目录分层

如果是一个长期维护的项目,必须 为模型文件按业务逻辑做分层。

一个长期维护的项目,很容易就会出现几十上百的表,每个表对应一个 Model 文件。笔者曾维护过一个项目,两百多个 Model 文件,app/models 目录完全没法看。

如果你能预期到 Model 文件会很多,那就 必须 做好目录划分,按照业务逻辑来分,以 LearnKu.com 为例,app/models 的目录结构如下:

├── Book
│   ├── Article.php
│   └── Book.php
├── Community
│   ├── Reply.php
│   └── Topic.php
└── Project
    ├── ProjectAuthor.php
    └── Project.php

模型事件

应该 尽量避免使用 Laravel 的 模型事件

使用模型事件的问题在于,其职能很难界定,所有的业务逻辑都能写到模型事件中。

而我们在项目中,业务逻辑代码规都封装到 Service 层,开发者在书写业务逻辑代码时,就会面临两种选择。

例如说 ReplyService 类的 create 方法,将创建评论时需要的逻辑,如发送通知给话题的作者,或者增加话题的评论数等操作,放置于此方法中,效果跟放在 ReplyObserver 中是一样的。

不一样的是, ReplyService 是显示地书写业务逻辑,代码可读性比模型事件更高。

模型事件另一个缺点就是,模型操作,附带太多逻辑,有太多的 Side Effect,并且是隐性的。模型操作是一个使用频率很高的功能,在有些场景中,你就想创建一个 Reply,但是不想通知到用户,例如说 Seed 时。虽然 Laravel 有提供模型方法让你暂时关闭模型事件,但这在实践中,我见过太多开发者经常会忘记此操作。

控制器规范

资源控制器

必须 优先使用 Restful 资源控制器

单数 or 复数?

必须 使用资源的复数形式,如:

  • 类名:PhotosController
  • 文件名:PhotosController.php

错误的例子:

  • 类名:PhotoController
  • 文件名:PhotoController.php

保持短小精炼

必须 保持控制器文件代码行数最小化,还有可读性。一般来讲,一个方法不应该超过 20 行代码,业务逻辑比较多,请封装到一个 Service 类里。

扩展器里的注释

不应该 为「方法」书写很明显的注释,这要求方法取名要足够合理,不需要过多注释。

应该 为一些复杂的逻辑代码块书写注释,主要介绍产品逻辑 - 为什么要这么做。,最重要的,写好上下文。

私有方法

不应该 在控制器中书写「私有方法」,控制器里 应该 只存放「路由动作方法」。

多余的业务逻辑,请封装到 Service 类中。

死方法和注释代码

绝不 遗留「死方法」,就是没有用到的方法,控制器里的所有方法,都应该被使用到,否则应该删除。

绝不 在控制器里批量注释掉代码,无用的逻辑代码就必须清除掉。

项目中会使用 Git 来做版本控制,删了后面也可以从记录中找到,无需将这些无用的代码留在项目中。

API 设计规范

参考资料

首先请熟悉以下的两个文档:

API 设计上有无法抉择的地方,应该参考 GitHub 的 API 文档:

GitHub 的 RESTful API 设计是业内比较知名的。

API 版本控制

所有的 API,早期设计时都 必须 考虑版本控制。

随着业务的发展,需求的不断变化,API 的迭代是必然的,很可能当前版本正在使用,而我们就得开发甚至上线一个不兼容的新版本,为了让旧用户可以正常使用,为了保证开发的顺利进行,我们需要控制好 API 的版本。

将版本号直接加入 URL 中:

  https://api.example.com/v1
  https://api.example.com/v2
  https://api.example.com/v3

RESTful API

开发 API 时,必须使用 RESTful 规范来架构 API。

具体规则下面罗列出来。

1. 使用 URL 定位资源

必须使用 URL 定位资源的规则。

在 RESTful 的架构中,所有的一切都表示资源,每一个 URL 都代表着一种资源,资源应当是一个名词,而且大部分情况下是名词的复数,尽量不要在 URL 中出现动词。

先来看看 GitHub 的 例子

GET /issues                                      列出所有的 issue
GET /orgs/:org/issues                            列出某个项目的 issue
GET /repos/:owner/:repo/issues/:number           获取某个项目的某个 issue
POST /repos/:owner/:repo/issues                  为某个项目创建 issue
PATCH /repos/:owner/:repo/issues/:number         修改某个 issue
PUT /repos/:owner/:repo/issues/:number/lock      锁住某个 issue
DELETE /repos/:owner/:repo/issues/:number/lock   解锁某个 issue

例子中冒号开始的代表变量,例如 /repos/summerblue/larabbs/issues

在 GitHub 的实现中,我们可以总结出:

  • 资源的设计可以嵌套,表明资源与资源之间的关系。

  • 大部分情况下我们访问的是某个资源集合,想得到单个资源可以通过资源的 id 或 number 等唯一标识获取。

  • 某些情况下,资源会是单数形式,例如某个项目某个 issue 的锁,每个 issue 只会有一把锁,所以它是单数。

❌ 错误的例子:

POST https://api.example.com/createTopic
GET https://api.example.com/topic/show/1
POST https://api.example.com/topics/1/comments/create
POST https://api.example.com/topics/1/comments/100/delete

✅ 正确的例子:

POST https://api.example.com/topics
GET https://api.example.com/topics/1
POST https://api.example.com/topics/1/comments
DELETE https://api.example.com/topics/1/comments/100

2. Laravel 中使用资源路由

Laravel 应该使用以下来定义资源路由:

Route::apiResource('users', UserController::class);

以上等同于:

Verb          Path                        Action  Route Name
GET           /users                      index   users.index
POST          /users                      store   users.store
GET           /users/{user}               show    users.show
PUT|PATCH     /users/{user}               update  users.update
DELETE        /users/{user}               destroy users.destroy

如果你不使用 apiResource() 方法,控制器方法 必须 按照以上的指纹来定义路由。

apiResource() 还可以使用以下方法来定制具体使用的路由:

Route::apiResource('photos', PhotoController::class)->only([
    'index', 'show'
]);

Route:: apiResource('photos', PhotoController::class)->except([
    'create', 'store', 'destroy'
]);

3. 使用 HTTP 动词描述操作

必须使用 HTTP 动词来描述操作,绝不单一的使用 POST 来处理所有逻辑。

HTTP 设计了很多动词,来表示不同的操作,RESTful 很好的利用的这一点,我们需要正确的使用 HTTP 动词,来表明我们要如何操作资源。

先来解释一个概念,幂等性,指一次和多次请求某一个资源应该具有同样的副作用,也就是一次访问与多次访问,对这个资源带来的变化是相同的。

常用的动词及幂等性

动词 描述 是否幂等
GET 获取资源,单个或多个
POST 创建资源
PUT 更新资源,客户端提供完整的资源数据
PATCH 更新资源,客户端提供部分的资源数据
DELETE 删除资源

为什么 PUT 是幂等的而 PATCH 是非幂等的,因为 PUT 是根据客户端提供了完整的资源数据,客户端提交什么就替换什么,而 PATCH 有可能是根据客户端提供的参数,动态的计算出某个值,例如每次请求后资源的某个参数减 1,所以多次调用,资源会有不同的变化。

另外需要注意的是,GET 请求对于资源来说是不安全的,绝不 通过 GET 请求改变(更新或创建)资源。

真实使用中,为了方便统计类的数据,会有一些例外情况,例如帖子详情,记录访问次数,每调用一次,访问次数 +1。这种情况下可以考虑页面展示成功后,再次调用一个 POST 请求去更新阅读数。

4. 使用 HTTP 状态码进行通讯

必须利用 HTTP 状态码和客户端进行通讯。

有一些 API 的设计,不论接口的状态成功与否,都会返回 200 ,然后使用自定的状态码,例如说 :

{
    // 数据不存在
    error_code: 30404
}

这种方法是不可取的。

HTTP 状态码是行业标准,意味着成千上万开发者都在认同和使用这套规则,意味着他们写出来的 HTTP 通讯程序(类库)也在使用这套规则。所以没有必要,也不该重新发明自己的一套规则。

HTTP 提供了丰富的状态码供我们使用,正确的使用状态码可以让响应数据更具可读性。、

  • 200 OK - 对成功的 GET、PUT、PATCH 或 DELETE 操作进行响应。也可以被用在不创建新资源的 POST 操作上
  • 201 Created - 对创建新资源的 POST 操作进行响应。应该带着指向新资源地址的 Location 头
  • 202 Accepted - 服务器接受了请求,但是还未处理,响应中应该包含相应的指示信息,告诉客户端该去哪里查询关于本次请求的信息
  • 204 No Content - 对不会返回响应体的成功请求进行响应(比如 DELETE 请求)
  • 304 Not Modified - HTTP 缓存 header 生效的时候用
  • 400 Bad Request - 请求异常,比如请求中的 body 无法解析
  • 401 Unauthorized - 没有进行认证或者认证非法
  • 403 Forbidden - 服务器已经理解请求,但是拒绝执行它
  • 404 Not Found - 请求一个不存在的资源
  • 405 Method Not Allowed - 所请求的 HTTP 方法不允许当前认证用户访问
  • 410 Gone - 表示当前请求的资源不再可用。当调用老版本 API 的时候很有用
  • 415 Unsupported Media Type - 如果请求中的内容类型是错误的
  • 422 Unprocessable Entity - 用来表示校验错误
  • 429 Too Many Requests - 由于请求频次达到上限而被拒绝访问

强制 User-Agent

强制客户端在请求时,必须发送 User-Agent 信息。

User-Agent 信息包含两部分,客户端信息 + 版本,使用斜杆分隔:

User-Agent: Mixin Bot iOS/2.1.37
User-Agent: Mixin Bot Android/2.1.22
User-Agent: MixPay PHP SDK/2.1.22
User-Agent: MixPay GO SDK/2.1.22

API 后端接收到 User-Agent 数据后可以暂时不做处理,但是后续有特殊的业务需求时,可以针对某个客户端具体到版本,进行特殊的数据处理。

常见的使用场景,是废弃客户端:例如一个银行 APP,升级了交易时的加密算法,低于 5.0 版本的客户端因为安全原因,必须废弃。针对此情况,可通过后端 API 判断 User-Agent 标头,对低于 5.0 的版本的客户端请求,返回专属的数据,如 APP 首页的第一个 Banner 显示请升级客户端,安全升级无法使用的提示。

现实生产中,有些客户端用户会关闭系统的应用自动更新功能,多版本客户端是无法避免的问题。有了 User-Agent ,我们可以更加灵活的做针对性处理。

单数 or 复数?

资源路由路由 URI 必须 使用复数形式,如:

  • /photos/create

  • /photos/{photo}

错误的例子如:

  • /photo/create

  • /photo/{photo}