Laravel 项目开发规范

1. 建立开发规范之目的

对于框架设计而言,灵活是件好事,能提供给开发者不同的选项,能让框架适用更多的场景。

但对于团队开发来说,大部分时候,更多的选项反而是累赘。因为每个人都可能写出不一样的代码,这无疑增加了项目维护的难度,影响效率。如果是在一个中大型的商业项目开发中,团队中有着几个甚至十几个开发者,没有规范的情况下,开发者会根据各自的喜好去选择,有时甚至出现一个开发者尝试多个选项的可能,就会造成整个团队产出的代码可读性极低,代码结构混乱,也为后面的项目代码的维护带来了难度。

建立开发规范的目的在于通过统一的代码风格,保证代码的易读性、可维护性。开发规范一旦统一,所有团队成员严格遵守,你会发现,其他成员写的代码就如你自己写的一样,编码愉悦感提高了,整个项目代码阅读起来更加流畅,工作效率自然也会因此提高,同时代码的健壮性也得到了保障。

2. 必须遵循的开发原则

  • DRY ———— 「Don't Repeat Yourself」, 不写重复的逻辑代码。
  • KISS ———— 「Keep it Simple, Stupid」, 提倡简单易读的代码,不写高深、晦涩难懂的代码,不过度设计。
  • 约定大于配置 ———— 「Convention Over Configuration」,优选选择框架及社区提倡的做法,不过度配置。
  • Restful ———— 利用「资源化概念」和标准的 HTTP 动词来组织程序。
  • MVC - Model, View, Controller ,以 MVC 为核心,严格控制 Controller 的可读性和代码行数。

除了这些原则外,还有不要强制,但非常有用的设计原则,如 SOLID 设计原则(S:单一职责原则、O:开/闭原则、L:里氏替换原则、I:接口隔离原则、D:依赖倒置原则)、组合优于继承等。

3. 项目目录结构规范

laravel-app/
├── app/
│   ├── Console/                # Artisan 命令
│   │   ├── Development/        # 存放开发专用命令
│   │   ├── LongPulling/        # 存放死循环执行的命令(可选)
│   │   ├── OneTime/            # 存放一次性命令
│   │   ├── Schedule/           # 存放计划任务
│   │   └── .                   # 根目录存放一般命令(在非常复杂的项目中,应该再按照业务逻辑进行分组)
│   ├── Exceptions/             # 异常处理
│   ├── Http/
│   │   ├── Controllers/        # 控制器
│   │   ├── Middleware/         # 中间件
│   │   └── Requests/           # 表单请求验证
│   ├── Models/                 # Eloquent 模型
│   │   ├── Auth/               # 示例:存放用户、角色、权限相关的模型
│   │   ├── Order/              # 示例:存放订单相关的模型
│   │   └── Payment/            # 示例:存放支付渠道相关的的模型
│   ├── Providers/              # 服务提供者
│   └── Services/               # 业务逻辑处理
│       ├── Auth/               # 示例:存放登录、授权相关的业务逻辑文件
│       └── Payment/            # 示例:存放支付相关的业务逻辑文件
├── bootstrap/                  # 应用启动脚本
├── config/                     # 配置文件
├── database/
│   ├── migrations/             # 数据库迁移
│   │   ├── Auth/               # 示例:存放用户、角色、权限相关的迁移文件
│   │   └── Payment/            # 示例:存放支付方式、订单相关的迁移文件
│   └── seeders/                # 数据填充
├── public/                     # 公开静态文件
├── resources/
│   ├── views/                  # Blade 视图
│   │   ├── layouts/            # 示例:页面布局的视图文件
│   │   ├── common/             # 示例:存放页面通用元素的视图文件
│   │   ├── pages/              # 示例:简单的页面存放文件夹,如:about、contact 等的视图文件
│   │   └── resources/         # 示例:对应 Restful 路由的资源路径名称,以 URI photos/create 为例,对应 create.blade.php 文件,存放在文件夹 photos 下。
│   │       ├── Auth/           # 示例:存放用户、角色、权限相关的视图文件
│   │       └── Payment/        # 示例:存放支付方式、订单相关的视图文件
│   └── lang/                   # 多语言文件
├── routes/                     # 路由定义
├── storage/                    # 应用存储(日志、缓存)
└── tests/                      # 测试代码
    ├── Feature/                # 示例:存放功能测试、集成测试的测试代码
    └── Unit/                   # 示例:存放单元测试代码,
        ├── API/                # 示例:存放 API 接口的测试代码
        ├── Web/                # 示例:存放 web 接口的测试代码
        ├── Admin/              # 示例:存放管理后台接口的测试代码
        ├── Command/            # 示例:存放命令行接口的测试代码
        ├── Job/                # 示例:存放任务接口的测试代码
        ├── Fixtures/           # 示例:测试中使用到样例数据,必须使用子目录存储,绝不直接放置于此目录下
        ├── Job/                # 示例:存放任务接口的测试代码
        └── Fixtures/           # 示例:测试中使用到样例数据,必须使用子目录存储,

4. 代码规范

4.1 代码风格

代码风格建议遵循 PSR-12 规范
PHPStorm 安装代码风格检测插件 php-cs-fixer,安装教程

4.1.1 变量
  1. 使用有意义且可读的变量名,变量名遵循驼峰命名规范。

    // 坏代码示例
    $date = date('y-m-d');
    
    // 好代码示例
    $currentDate = date('y-m-d');
    
    // 从变量名可知,$date 是一个日期,但不知是当天日期还是某个特定时间的日期,相比于 $date,$currentDate更能体现其具体使用场景。
  2. 不要添加不需要的上下文。如果你的 类或对象 已经有明确的含义,不要在变量中再次重复它。

    // 坏代码
    class Car
    {
        public $carMake;
    
        public $carModel;
    
        public $carColor;
    
        //...
    }
    
    // 好代码
    class Car
    {
        public $make;
    
        public $model;
    
        public $color;
    
        //...
    }
4.1.2 分支判断
  1. 避免嵌套太深(最多不超过三层)并尽早返回。

    # 坏代码示例
    function fibonacci(int $n)
    {
        if ($n < 50) {
            if ($n !== 0) {
                if ($n !== 1) {
                    return fibonacci($n - 1) + fibonacci($n - 2);
                }
                return 1;
            }
            return 0;
        }
        return 'Not supported';
    }
    
    # 好代码示例
    function fibonacci(int $n): int
    {
        if ($n === 0 || $n === 1) {
            return $n;
        }
    
        if ($n >= 50) {
            throw new Exception('Not supported');
        }
    
        return fibonacci($n - 1) + fibonacci($n - 2);
    }
4.1.3 函数
  1. 函数名应该语义化。

    // 坏代码示例
    class Email
    {
        //...
    
        public function handle(): void
        {
            mail($this->to, $this->subject, $this->body);
        }
    }
    
    $message = new Email(...);
    // 这是什么? 消息的句柄? 我们现在正在写入文件吗?
    $message->handle();
    
    // 好代码示例
    class Email
    {
        //...
    
        public function send(): void
        {
            mail($this->to, $this->subject, $this->body);
        }
    }
    
    $message = new Email(...);
    // 清晰明了
    $message->send();
  2. 函数参数最多不应超过3个。

    • 一旦一个方法拥有三个以上的参数将会导致组合爆炸,在进行测试时需要使用每个单独的参数测试大量不同的情况。
    • 零参数是理想的情况。 一两个参数是可以的,三个应该避免。除此之外的任何东西都应该合并
    • 通常,如果您有两个以上参数这说明你的函数做的事情太多了。如果不是,大多数情况下一个更高级别的对象就足以作为一个参数。
  3. 一个函数应该只做一件事,同时不要使用标志位作为函数参数。

    // 坏代码,函数做了两件事,创建临时文件或者创建正式文件
    function createFile(string $name, bool $temp = false): void
    {
        if ($temp) {
            touch('./temp/' . $name);
        } else {
            touch($name);
        }
    }
    
    // 好代码:应该基于职责做拆分,拆分为两个函数
    function createFile(string $name): void
    {
        touch($name);
    }
    
    function createTempFile(string $name): void
    {
        touch('./temp/' . $name);
    }
  4. 避免副作用。避免副作用意味着,尽量不要再函数内使用全局变量或者使用引用传递的参数。

    // 坏代码示例
    // 通过以下函数引用的全局变量。
    // 如果我们有另外一个使用这个名字函数,现在它将是一个数组,它可能会破坏它。
    $name = 'Ryan McDermott';
    
    function splitIntoFirstAndLastName(): void
    {
        global $name;
    
        $name = explode(' ', $name);
    }
    
    splitIntoFirstAndLastName();
    
    var_dump($name);
    // ['Ryan', 'McDermott'];
    // 好代码示例
    function splitIntoFirstAndLastName(string $name): array
    {
        return explode(' ', $name);
    }
    
    $name = 'Ryan McDermott';
    $newName = splitIntoFirstAndLastName($name);
    
    var_dump($name);
    // 'Ryan McDermott';
    
    var_dump($newName);
    // ['Ryan', 'McDermott'];
  5. 封装条件,且函数命名时避免使用否定条件。

    // 坏代码示例
    if ($article->state === 'published') {
        // ...
    }
    // 好代码示例
    if ($article->isPublished()) {
        // ...
    }
    
    private function isPublished()
    {
        return $article->state === 'published';
    }
    // 坏代码示例
    function isDOMNodeNotPresent(DOMNode $node): bool
    {
        // ...
    }
    
    if (! isDOMNodeNotPresent($node)) {
        // ...
    }
    // 好代码示例
    function isDOMNodePresent(DOMNode $node): bool
    {
        // ...
    }
    
    if (isDOMNodePresent($node)) {
        // ...
    }
4.1.4 类与对象
  • public 方法和属性对于更改是最危险的,因为一些外部代码可能很容易依赖它们,而您无法控制哪些代码依赖它们。 类中的修改对类的所有用户都是危险的。
  • protected 修饰符与 public 一样危险,因为它们在任何子类的范围内都可用。 这实际上意味着 public 和 protected 之间的区别仅在于访问机制,但封装保证保持不变。类中的修改对所有后代类都是危险的。
  • private 修饰符保证代码 仅在单个类的边界内修改是危险的(您可以安全地修改并且不会有 叠叠乐效应).

4.2 配置信息与环境变量

  1. 因 .env 不会被纳入版本控制器中,所以本地 .env 文件里添加变量时必须同步到 .env.example 文件中,以免影响其他项目参与者的工作。
  2. 所有程序配置信息必须通过 config() 来读取,所有的 .env 配置信息必须通过 env() 来读取,不在配置文件以外的范围使用 env()。原因在于:

    • 定义分明,config() 是配置信息,env() 只是用来区分不同环境。
    • 统一放置于 config 中还可以利用框架的配置信息缓存功能(php artisan config:cahce)来提高运行效率,当配置信息被缓存后,.env 文件将不会被加载,所以env() 函数将读取不到 .env 文件中的内容。
    • 代码健壮性, config() 在 env() 之上多出来一个抽象层,会使代码更加健壮,更加灵活。

4.3 路由

  1. 不在路由定义文件中书写「闭包路由」或者其他业务逻辑代码,因为路由缓存 并不会作用在基于闭包的路由。
  2. 路由定义文件中要保持干净整洁,绝不放置除路由配置以外的其他程序逻辑。
  3. 路由定义的方式存在多种,推荐使用控制器路由的 Laravel 8+ 推荐写法(使用命名空间或自动导入),推荐使用下面示例代码的第二种写法或第三种写法。

    // 第一种写法:指向控制器方法
    Route::get('/user', 'UserController@index')->name('user.index');
    
    // 第二种写法:Laravel 8+ 推荐写法(使用命名空间或自动导入)
    use App\Http\Controllers\UserController;
    
    Route::get('/user', [UserController::class, 'index'])->name('user.index');
    
    // 第三种写法:基于第二种写法,并且同时有多个路由用到相同的 Controller
    use App\Http\Controllers\OrderController;
    
    Route::controller(OrderController::class)->group(function () {
        Route::get('/orders/{id}', 'show');
        Route::post('/orders', 'store');
    });
  4. 优先使用 Restful 路由,配合资源控制器路由一起使用,Restful 路由的 uri 应该使用复数形式,例如 uri 应该声明为 /photos/{photo},而不是 /photo/{photo}。

    例如声明以下的一个资源路由,将会注册如下表格所示的路由。

    use App\Http\Controllers\PhotoController;
    
    Route::resource('photos', PhotoController::class);
    HTTP请求方式URI操作路由名称
    GET/photosindexphotos.index
    GET/photos/createcreatephotos.creat
    POST/photosstorephotos.store
    GET/photos/{photo}showphotos.show
    GET/photos/{photo}/editeditphotos.edit
    PUT/PATCH/photos/{photo}updatephotos.update
    DELETE/photos/{photo}destroyphotos.destroy

    不难想到,有时可能需要定义一个嵌套的资源型路由。例如,照片资源可能被添加了多个评论。那么可以在路由中使用 . 符号来声明资源型控制器。这将注册下表格所示的路由。

    use App\Http\Controllers\PhotoCommentController;
    
    Route::resource('photos.comments', PhotoCommentController::class);
    HTTP请求方式URI操作路由名称
    GET/photos/{photo}/commentsindexphotos.comments.index
    GET/photos/{photo}/comments/createcreatephotos.comments.creat
    POST/photos/{photo}/commentsstorephotos.comments.store
    GET/photos/{photo}/comments/{comment}showphotos.comments.show
    GET/photos/{photo}/comments/{comment}/editeditphotos.comments.edit
    PUT/PATCH/photos/{photo}/comments/{comment}updatephotos.comments.update
    DELETE/photos/{photo}/comments/{comment}destroyphotos.comments.destroy

    注意:如果您需要向资源控制器添加超出默认资源路由集的其他路由,则应在调用 Route::resource 方法之前定义这些路由;否则,由 resource 方法定义的路由可能会无意中优先于您的补充路由。

4.4 控制器

  1. 优先使用Restful 资源控制器
  2. 控制器命名规范,必须使用资源的复数形式,如:

    • 类名:PhotosController
    • 文件名:PhotosController.php
  3. 在控制器内不能声明私有方法,控制器内所有的方法都是外部可访问的表示路由动作的公有方法,并且控制器里的所有方法,都应该被使用到,否则应该删除,也绝对不在控制器里批量注释掉代码,无用的逻辑代码就必须清除掉。。
  4. 控制器内不应包含业务逻辑,业务逻辑的实现应该在业务逻辑层去实现。
  5. 不应该为「方法」书写很明显的注释,这要求方法取名要足够合理,不需要过多注释。应该为一些复杂的逻辑代码块书写注释,主要介绍产品逻辑 - 为什么要这么做。
  6. 表单数据校验应该放到控制器层(如果是非复杂表单的字段检验,可直接在方法内进行校验,否则需单独写一个表单验证类),验证通过后再将请求转发给业务逻辑层。

    /**
     * 存储一篇新的博客文章。
    *
    * @param  \Illuminate\Http\Request  $request
    * @return \Illuminate\Http\Response
    */
    public function store(Request $request)
    {
        $validated = $request->validate([
            'title' => 'required|unique:posts|max:255',
            'body' => 'required',
        ]);
    
        // 博客文章验证通过...
    }

4.5 表单验证

  1. 使用表单请求 - FormRequest 类 来处理控制器里的表单验证。
  2. 验证类命名规则,FormRequest表验证类命名必须按照控制器方法来命名,且必须添加模型的前缀,类名称的 Request 后缀也是必须的,这方便了编辑器开始打开文件。如:

    • UserCreateRequest
    • UserUpdateRequest

4.6 业务逻辑层(Service 层或 Logic 层)

  1. Controller 层只接收和转发请求,Model 层只负责模型定义以及数据关联逻辑。由业务逻辑层负责处理业务逻辑。
  2. Service 中的方法命名参考 Laravel Model 中的方法命名。
  3. 所有的 Service 类都必须存放于 app/Services 目录中,且应避免直接将 Service 类放置于 app/Services 目录下,应该考虑通过业务逻辑,将其归类于子目录中。

    Auth —— 存放登录、授权相关的 Service
    Payment —— 存放支付相关的 Service
    Book —— 存放课程相关的 Service
  4. Service 类必须是无状态的,无状态意味着无论是控制器方法中、命令行、单元测试中,都可调用。

4.7 模型

  1. 所有的数据模型文件,都必须存放在:app/Models/ 文件夹中。且所有的 Eloquent 数据模型都必须继承统一的基类 App\Models\Model,此基类存放位置为 /app/Models/Model.php。
  2. 命名规范

    • 数据模型类名必须为「单数」,如:App\Models\Photo
    • 类文件名必须为「单数」,如:App\Models\Photo.php
    • 数据库表名字必须为「复数」,如:photos,users...
    • 数据库表迁移名字必须为「复数」,如:2014_08_08_234417_create_photos_table.php
    • 数据填充文件名必须为「复数」,如:PhotosTableSeeder.php
    • 数据库字段名必须是遵循蛇形命名法,如:view_count, is_vip
    • 数据库表主键名必须为id
    • 数据库表外键必须为「resource_id」,如:user_id, post_id
  3. 目录分层:为模型文件按业务逻辑做分层。
  4. 尽量避免使用 Laravel 的 模型事件。使用模型事件的问题在于,其职能很难界定,所有的业务逻辑都能写到模型事件中。

4.8 视图

  1. 避免在 resources/views 目录下直接放置视图文件。页面布局文件必须放在 resources/views/layouts 目录下,页面组件必须放在 resources/views/common 目录下,简单页面(如404页面)放在 resources/views/pages 目录下。对应 Restful 路由的资源路径名称,例如以URI photos/create 为例,对应的 create.blade.php 文件,应存放在 resources/views/photos 目录下。
  2. 局部视图文件必须使用 <kdb>_</kdb> 前缀来命名,如:photos/_upload_form.blade.php
  3. 为了和 Restful 路由器和资源控制器保持一致,视图命名也必须使用资源视图的命名方式。例如, 一个完整的 photos 资源对应的视图文件为以下:

    ├── photos
    │   ├── _form.blade.php
    │   ├── create.blade.php
    │   ├── edit.blade.php
    │   ├── index.blade.php
    │   └── show.blade.php

5. Git 工作流规范

6. 服务器部署规范

6.1 Composer 依赖安装规范

  1. 生产环境或预发布环境进行项目部署时,必须使用 --no-dev 来安装项目运行必需依赖。如安装 predis 扩展:

    composer require predis/predis --no-dev
  2. 本地环境或测试环境安装开发专用扩展时,必须使用 --dev 来安装开发依赖。如在本地安装 phpunit 扩展:

    composer require phpunit/phpunit --dev

6.2 Composer 依赖版本更新

  1. 绝对不能在生产服务器执行,composer udpate,原因如下:

    • composer udpate 会根据 composer.json 文件的版本约束拉取最新兼容版本,可能导致依赖包升级到未测试的版本,引入不兼容的变更或bug。同时,这将导致开发环境与生产环境的依赖版本可能因此不一致,当系统出现问题时,排查问题变得困难。
    • 由于依赖包的升级可能导致项目运行报错。
    • 执行composer udpate后,由于依赖已全局更新,回滚到旧版本可能需要重新部署整个项目。
  2. 正确做法是,在开发环境更新依赖(即在本地开发环境执行composer udpate),测试项目依赖更新后的所有功能是否正常。测试无影响后,提交新的 composer.lock 文件到版本控制,确保依赖版本锁定。在生产环境执行composer install,这将严格安装 composer.lock 中记录的版本,保证环境一致性,减少因部署的环境不一致而导致的项目运行问题。

参考文章


kinra
19 声望0 粉丝