员工 API 开发(上)
上篇教程学院君给大家演示了基于 TDD 开发部门 API,今天,我们来演示员工 API 的开发。
支付类型
在正式开发之前,我们先做一些准备工作,主要是关于支付类型的处理。在 API 设计中,我们提到,员工按照工资支付方式分为两种类型:
- 工薪制:每月支付日,按年薪/12发放薪资给员工
- 小时工:每月支付日,按工作时长(小时)x时薪发放薪资给员工
显然,我们可以通过枚举定义支付法方式类型:
<?php
enum PaymentTypes: string
{
case SALARY = 'salary';
case HOURLY_RATE = 'hourlyRate';
}
枚举是 PHP 8.1 新增的特性。
然后引入策略模式实现基于支付类型的支付策略,把每种支付类型都抽象成独立的策略类,其中包含类型、总包、月付金额等方法:
<?php
namespace App\Payment;
use App\Models\Employee;
abstract class PaymentType
{
public function __construct(protected readonly Employee $employee)
{
}
abstract public function monthlyAmount(): int;
abstract public function type(): string;
abstract public function amount(): int;
}
<?php
namespace App\Payment;
use App\Enums\PaymentTypes;
class Salary extends PaymentType
{
public function monthlyAmount(): int
{
// todo 待实现
return 0;
}
public function type(): string
{
return PaymentTypes::SALARY->value;
}
public function amount(): int
{
return $this->employee->salary;
}
}
<?php
namespace App\Payment;
use App\Enums\PaymentTypes;
class HourlyRate extends PaymentType
{
public function monthlyAmount(): int
{
// todo 待实现
return 0;
}
public function type(): string
{
return PaymentTypes::HOURLY_RATE->value;
}
public function amount(): int
{
return $this->employee->hourly_rate;
}
}
monthlyAmount
方法的具体实现留到支付 API 开发去实现,这里先返回 0 作为默认值。
最后,在枚举类中,采用工厂模式基于枚举值创建支付类型对应的策略类实例:
<?php
namespace App\Enums;
use App\Models\Employee;
use App\Payment\HourlyRate;
use App\Payment\PaymentType;
use App\Payment\Salary;
enum PaymentTypes: string
{
case SALARY = 'salary';
case HOURLY_RATE = 'hourlyRate';
public function makePaymentType(Employee $employee): PaymentType
{
return match($this) {
self::SALARY => new Salary($employee),
self::HOURLY_RATE => new HourlyRate($employee),
};
}
}
这样一来,就可以将员工模型 Employee
的 payment_type
属性直接转化成对应的 PaymentType
实例了:
public function getPaymentTypeAttribute(): PaymentType
{
return PaymentTypes::from($this->original['payment_type'])->makePaymentType($this);
}
拿到 PaymentType
后就能调用对应实例方法获取该支付类型每月应付薪资了,可以看到,经过这么一通抽象和封装,提供给上层调用的代码非常优雅,且扩展性也很好,如果要新增支付类型,只需要在底层枚举类和策略类新增一个类型适配即可,上层调用代码不需要做任何更改。
创建/更新员工
有了支付类型的技术储备后,就可以来编写员工相关的 API 了。创建和更新员工基本流程是一样的,可以合并到一起,对于新建/更新员工表单请求参数,有以下约束条件:
- 唯一有效的邮箱地址
- 支付类型是
salary
/hourlyRate
- 支付薪水不能为空
表单请求验证
按照 TDD 的基本流程,我们先来编写创建/更新员工 API 对应的测试用例(Upsert = updte + insert):
<?php
use App\Models\Department;
use App\Models\Employee;
use App\Models\User;
use Laravel\Sanctum\Sanctum;
use function Pest\Laravel\postJson;
it('should return 422 if email is invalid', function (?string $email) {
Sanctum::actingAs(User::factory()->create(), ['*']);
Employee::factory([
'email' => 'taken@example.com',
])->create();
postJson(route('employees.store'), [
'fullName' => 'Test Employee',
'email' => $email,
'departmentId' => Department::factory()->create()->uuid,
'jobTitle' => 'BE Developer',
'paymentType' => 'salary',
'salary' => 75000 * 100,
])->assertInvalid(['email']);
})->with([
'taken@example.com',
'invalid',
null,
'',
]);
it('should return 422 if payment type is invalid', function () {
Sanctum::actingAs(User::factory()->create(), ['*']);
postJson(route('employees.store'), [
'fullName' => 'Test Employee',
'email' => 'test@example.com',
'departmentId' => Department::factory()->create()->uuid,
'jobTitle' => 'BE Developer',
'paymentType' => 'invalid',
'salary' => 75000 * 100,
])->assertInvalid(['paymentType']);
});
it('should return 422 if salary missing when payment type is salary', function (string $paymentType, ?int $salary) {
Sanctum::actingAs(User::factory()->create(), ['*']);
postJson(route('employees.store'), [
'fullName' => 'Test Employee',
'email' => 'test@example.com',
'departmentId' => Department::factory()->create()->uuid,
'jobTitle' => 'BE Developer',
'paymentType' => $paymentType,
'salary' => $salary,
])->assertInvalid(['salary']);
})->with([
['paymentType' => 'salary', 'salary' => null],
['paymentType' => 'salary', 'salary' => 0],
]);
it('should return 422 if hourly rate missing when payment type is hourly rate', function (string $paymentType, ?int $hourlyRate) {
Sanctum::actingAs(User::factory()->create(), ['*']);
postJson(route('employees.store'), [
'fullName' => 'Test Employee',
'email' => 'test@example.com',
'departmentId' => Department::factory()->create()->uuid,
'jobTitle' => 'BE Developer',
'paymentType' => $paymentType,
'hourlyRate' => $hourlyRate,
])->assertInvalid(['hourlyRate']);
})->with([
['paymentType' => 'hourlyRate', 'hourlyRate' => null],
['paymentType' => 'hourlyRate', 'hourlyRate' => 0],
]);
这里我们依次编写了验证邮箱、支付类型、支付薪水字段无效等不同场景的多个用例。此时运行测试用例,肯定是不通过的,因为还没有编写任何业务代码。
由于主要是做表单字段验证,所以我们先创建一个表单请求类 UpsertEmployeeRequest
,定义相关的字段验证规则:
<?php
namespace App\Http\Requests;
use App\Enums\PaymentTypes;
use App\Models\Department;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Enum;
class UpsertEmployeeRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
return true;
}
public function getDepartment(): Department
{
return Department::where('uuid', $this->departmentId)->firstOrFail();
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, mixed>
*/
public function rules()
{
return [
'fullName' => ['required', 'string'],
'email' => [
'required',
'email',
Rule::unique('employees', 'email')->ignore($this->employee),
],
'departmentId' => ['required', 'string', 'exists:departments,uuid'],
'jobTitle' => ['required', 'string'],
'paymentType' => [
'required',
new Enum(PaymentTypes::class),
],
'salary' => ['required_if:paymentType,salary', 'integer', 'min:0', 'not_in:0'],
'hourlyRate' => ['required_if:paymentType,hourlyRate', 'integer', 'min:0', 'not_in:0'],
];
}
}
这个表单请求类既要负责创建请求的验证、又要负责更新请求的验证,所以在 email
唯一性验证规则里,我们们需要排除自身这条记录,否则只是 unique
在更新场景里会验证失败。
另外,支付类型 paymentType
这个字段通过枚举进行验证即可,字段值必须在枚举值中,否则会验证失败,而 salary
和 hourlyRate
都要依赖 paymentType
进行对应的验证。
创建/更新员工用例
接下来编写真正的创建/更新业务代码,还是通过测试用例驱动:
it('should store an employee with payment type salary', function () {
Sanctum::actingAs(User::factory()->create(), ['*']);
$employee = postJson(route('employees.store'), [
'fullName' => 'John Doe',
'email' => 'test@example.com',
'departmentId' => Department::factory()->create()->uuid,
'jobTitle' => 'BE Developer',
'paymentType' => 'salary',
'salary' => 75000 * 100,
])->json('data');
expect($employee)
->attributes->name->toBe('John Doe')
->attributes->email->toBe('test@example.com')
->attributes->jobTitle->toBe('BE Developer')
->attributes->payment->type->toBe('salary')
->attributes->payment->amount->toBe(75000 * 100);
});
it('should store an employee with payment type hourly rate', function () {
Sanctum::actingAs(User::factory()->create(), ['*']);
$employee = postJson(route('employees.store'), [
'fullName' => 'John Doe',
'email' => 'test@example.com',
'departmentId' => Department::factory()->create()->uuid,
'jobTitle' => 'BE Developer',
'paymentType' => 'hourlyRate',
'hourlyRate' => 30 * 100,
])->json('data');
expect($employee)
->attributes->name->toBe('John Doe')
->attributes->email->toBe('test@example.com')
->attributes->jobTitle->toBe('BE Developer')
->attributes->payment->type->toBe('hourlyRate')
->attributes->payment->amount->toBe(30 * 100);
});
这里面我们会通过断言响应数据结构和对应字段值来判定测试用例是否通过,和上篇教程一样,我们还是假设所有 API 响应数据是遵循 JSON API 规范的。
下面我们来编写业务代码让上述所有测试用例通过。
Employee DTO
在 API 设计中,学院君提到会在代码里应用一些 DDD 的技术理念,这里我们将以员工创建/更新流程为例,进行相关的技术演示和概念落地。在 DDD 中,一般会通过 DTO 对请求数据进行封装后传递给领域层,然后在领域层处理完毕后,将结果再转化为 DTO 的数据格式返回。这样做的一个好处是统一结构化用户请求参数,以及屏蔽领域层的数据结构细节。
在 PHP 里面,一个经常被诟病的问题就是各种请求参数都用数组表示,你永远不知道传入的参数中到底包含什么数据,这给系统维护和迭代带来巨大困扰,尤其是中大型系统,同时也成了很多线上问题的重灾区,我们可以引入 DTO 来规范和解决这个问题,以员工请求数据为例,我们可以创建一个名为 EmployeeData
的 DTO 类来装载员工表单数据:
<?php
namespace App\DTOs;
use App\Http\Requests\UpsertEmployeeRequest;
use App\Models\Department;
use Illuminate\Http\Request;
class EmployeeData
{
public function __construct(
public readonly string $fullName,
public readonly string $email,
public readonly Department $department,
public readonly string $jobTitle,
public readonly string $paymentType,
public readonly ?int $salary,
public readonly ?int $hourlyRate,
) {}
public static function formData(UpsertEmployeeRequest $request): self
{
return new static(
$request->fullName,
$request->email,
$request->getDepartment(),
$request->jobTitle,
$request->paymentType,
$request->salary,
$request->hourlyRate,
);
}
}
在这个 DTO 中,需要定义一个 fromRequest
方法将请求字段和 DTO 属性做一一映射,虽然增加了代码量,但是却为日后的系统维护打下了良好的基础。
UpsertEmployeeAction
传统 MVC 模式 是一种分层架构,在实际业务开发中,会面临业务逻辑应该放在哪里的困扰,如果放到控制器里面会导致控制器的臃肿,如果放到模型类里面会造成模型类的臃肿(胖模型),而且如果是与数据库无关的业务逻辑也不适合放到模型类。
经过这么多年的工程实践和演化,也推出了很多新的架构模式,比如独立出一个服务层,把控制器里的业务逻辑都放到 Service 面,让控制器瘦身,同时也提高了代码的复用性,服务不仅可以被控制器调用,也可以被命令行、消息队列调用,这也是目前很多公司的架构模式,而在模型类之上又添加一个仓储模式,专门负责与数据库的交互,以让模型类瘦身:
当然,DDD 和微服务已经把这一套玩的很熟了,有很成熟的架构模式,不过不是本篇教程的重点,后面学院君会在新版微服务架构中详细介绍这一块。
不过随着业务的不断迭代,Service 也面临着类似的代码臃肿问题,DDD 里面有应对之道,不过我们今天不会把 DDD 那一套东西引入到这个 CRM 系统,毕竟这是个小项目,没必要,我们将从设计原则出发,使用 Laravel 自带的 Action 来解决这个问题,Laravel 试图通过 Action 来解决代码复用和单一职责问题,以创建/更新员工为例,我们创建一个对应的 UpsertEmployeeAction
来编写员工创建和更新的业务代码:
<?php
use App\DTOs\EmployeeData;
use App\Models\Employee;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
class UpsertEmployeeAction
{
/**
* @throws ValidationException
*/
public function execute(Employee $employee, EmployeeData $employeeData): Employee
{
$this->validate($employeeData);
$employee->full_name = $employeeData->fullName;
$employee->email = $employeeData->email;
$employee->department_id = $employeeData->department->id;
$employee->job_title = $employeeData->jobTitle;
$employee->payment_type = $employeeData->paymentType;
$employee->salary = $employeeData->salary;
$employee->hourly_rate = $employeeData->hourlyRate;
$employee->save();
return $employee;
}
/**
* @throws ValidationException
*/
private function validate(EmployeeData $employeeData): void
{
$rules = [
$employeeData->paymentType => [
'required',
'numeric',
Rule::notIn([0]),
]
];
$validator = Validator::make([
'paymentType' => $employeeData->paymentType,
'salary' => $employeeData->salary,
'hourlyRate' => $employeeData->hourlyRate,
], $rules);
$validator->validate();
}
}
可以看到,不需要引入 Service 层,我们通过 Action 也可以保持代码复用和控制器的简单干净。
Employee Resource
我们将使用 JSON:API Resourse 扩展包来保证 API 接口返回的数据是遵循 JSON API 规范的,因此需要创建一个继承自 TiMacDonald\JsonApi\JsonApiResource
的 EmployeeResource
,然后编写属性转化方法:
<?php
namespace App\Http\Resources;
use TiMacDonald\JsonApi\JsonApiResource;
class EmployeeResource extends JsonApiResource
{
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
* @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable
*/
public function toAttributes($request): array
{
return [
'name' => $this->full_name,
'email' => $this->email,
'jobTitle' => $this->job_title,
'payment' => [
'type' => $this->payment_type->type(),
'amount' => $this->payment_type->amount(),
],
];
}
}
Employee Controller
最后我们需要在控制器中把以上表单请求验证、DTO、Action、Resource 编排到一起,完成请求验证、业务处理、响应返回的整个流程:
<?php
namespace App\Http\Controllers;
use App\Actions\UpsertEmployeeAction;
use App\DTOs\EmployeeData;
use App\Http\Requests\UpsertEmployeeRequest;
use App\Http\Resources\EmployeeResource;
use App\Models\Employee;
use Illuminate\Http\Request;
class EmployeeController extends Controller
{
public function __construct(
private readonly UpsertEmployeeAction $upsertEmployee
) {
}
...
/**
* Store a newly created resource in storage.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function store(UpsertEmployeeRequest $request)
{
$employee = $this->upsertEmployee->execute(
new Employee(),
EmployeeData::fromRequest($request)
);
return EmployeeResource::make($employee)->response();
}
...
/**
* Update the specified resource in storage.
*
* @param \Illuminate\Http\Request $request
* @param int $id
* @return \Illuminate\Http\Response
*/
public function update(UpsertEmployeeRequest $request, Employee $employee)
{
$employee = $this->upsertEmployee->execute(
$employee,
EmployeeData::fromRequest($request)
);
return response()->noContent();
}
...
}
在 routes/api.php
中添加员工资源的 API 路由:
Route::apiResource('employees', EmployeeController::class);
让测试用例通过
再次运行测试用例,就可以看到测试通过了:
下篇教程,学院君将给大家演示通过 TDD 驱动获取员工资源,嵌套关联资源,以及支持过滤排序的 JSON API 接口开发。
2 Comments
捉虫
EmployeeData 文章里面得方法是formData,之后调用和Github 代码里面是 fromRequest
对应到模型里,payment再分2个表是比较合适的(抽象模型),特别是当表字段有较多不同时,但如果冗余的不多也没什么大问题
return [ 'name' => $this->full_name, 'email' => $this->email, 'jobTitle' => $this->job_title, 'payment' => [ 'type' => $this->payment_type->type(), 'amount' => $this->payment_type->amount(), ], ];