2022-06-20技术00
请注意,本文编写于 221 天前,最后修改于 61 天前,其中某些信息可能已经过时。

目录

路由器
路由闭包
控制器方法定义
Restful 路由
resource 方法正确使用
单数 or 复数?
路由模型绑定
全局路由器参数
路由命名
获取 URL
Service 模式
介绍
ModelService
ModelService 方法命名
其他 Service
存放目录
目录组织
Service 方法无状态
模型规范
放置位置
继承基类
命名规范
模型关联
利用 Trait 来扩展数据模型
Repository
关于 SQL 文件
作用域
数据层无状态
目录分层
模型事件
控制器规范
资源控制器
单数 or 复数?
保持短小精炼
扩展器里的注释
私有方法
死方法和注释代码
API 设计规范
参考资料
API 版本控制
RESTful API
1. 使用 URL 定位资源
2. Laravel 中使用资源路由
3. 使用 HTTP 动词描述操作
4. 使用 HTTP 状态码进行通讯
强制 User-Agent
单数 or 复数?

laravel-zi-yong-yi-lai.jpg

路由器

路由闭包

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

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

控制器方法定义

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

✅ 正确

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

❌ 错误的例子:

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

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

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

Restful 路由

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

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

resource 方法正确使用

一般资源路由定义:

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

等于以下路由定义:

php
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 列出所有可用路由:

php
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 方法中:

php
    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 方法里定义模式:

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

    parent::boot();
}

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

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

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

路由命名

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

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

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

获取 URL

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

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

在 Model 中创建 link() 方法:

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

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

php
$model->link();

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

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

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

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

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

php
url('profile', [1]);

Service 模式

介绍

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

ModelService

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

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

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

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

ModelService 方法命名

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

php
$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 类无状态。

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

❌ 错误的例子:

php
// 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
    ]);
}

✅正确的例子

php
// 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/ 文件夹中。

命名空间:

php
namespace App\Models;

继承基类

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

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
<?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 全局作用域 允许我们为给定模型的所有查询添加默认的条件约束。

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

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

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

数据层无状态

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

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

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

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

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

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

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

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

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

php
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 应该使用以下来定义资源路由:

php
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() 还可以使用以下方法来定制具体使用的路由:

php
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 ,然后使用自定的状态码,例如说 :

json
{
    // 数据不存在
    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}

本文作者:TyrantGenesis

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!