一种很新的编程方式

“传新”的编程方式

PHP 8 官方开始支持「注解」了,以前使用的 hyperf 框架也升级到了 3.0 版本,并且将原来的注解改成了官方注解方式,于是在新一代的 PHP 开发中,编程方式有了巨大的改变,注解应该是从 JAVA 传过来的,在 PHP 8 之前,注解只能用「反射」的方式来实现,这种“民间注解”现在已经被官方注解取代了。

让我们康康下面一段 登录接口 的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
#[DuplicateLockAnnotation]
#[FrequentLimitAnnotation(perTimes: 5, ttl: 60)]
#[ModulePermissionAnnotation(
module: ModuleConstant::MODULE_SYSTEM,
key: ModuleConstant::SYSTEM_ENABLED_LOGIN,
required: Constant::ENABLED,
message: '未开放登录'
)]
#[RequestMapping(path: "signin", methods: "post")]
public function signinSubmit(SigninFormRequest $request)
{
return $this->responser->signinSubmit($request);
}

上面是一个控制器内的方法,而下面这段代码就是注解:

1
2
3
4
5
6
7
8
9
#[DuplicateLockAnnotation]
#[FrequentLimitAnnotation(perTimes: 5, ttl: 60)]
#[ModulePermissionAnnotation(
module: ModuleConstant::MODULE_SYSTEM,
key: ModuleConstant::SYSTEM_ENABLED_LOGIN,
required: Constant::ENABLED,
message: '未开放登录'
)]
#[RequestMapping(path: "signin", methods: "post")]

注解类似注释,但与注释不同的是注解有「实际作用」而不是给码农看的而已,注解会被解析,有具体的功能用途,比如前面写的 AOP 面向切面编程,插入一段注解就会被当做一个「切面」,而且可以在注解上传入一些参数,总之,注解有很多功能性的用途。

回到正题,这段登录接口的代码与传统编程有何不同?

如果是传统编程,需要写成下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public function signinSubmit()
{
# 检测是否重复提交,防止表单重复提交,比如用 redis 加一个临时的“锁”
...

# 检测是否多次提交(防止用户的密码被暴力破解)也是用 redis 加锁的方式
...

# 获取表单提交的参数
$params = $this->request->all();

# 判断表单是否填写完整,检测邮箱格式是否正确等
...

# 判断后台是否关闭了登录功能,比如系统在维护的时候防止用户登入
...

return $this->responser->signinSubmit($request);
}

类似上面这样,我们需要很多个 if 来判断一些参数信息,这样代码不仅不美观,而且代码重复性也会很高,因为其他地方也会有 redis 加锁防止表单重复提交的情况,如果每个地方都来上这么一句,身心都会受不鸟。

这个时候如果是传统的编程方式就会考虑「封装」重复的代码,比如写一个专门判断 redis 锁的封装类,但还是没办法避免不断 if 判断的情况,代码会十分杂乱不堪。这个时候,如果不想写 if 判断条件就可以用到 AOP(切面编程)了,在提交表单的时候拦截请求,如果符合要求再放过去,不符合要求就返回失败的结果。

首先,我们需要拦截表单填写不正确的情况,比如用户提交了空表单或者填写了错误的邮箱格式,这个时候可以用依赖注入的方式直接过滤掉数据不正确的情况:

1
2
3
4
public function signinSubmit(SigninFormRequest $request)
{
return $this->responser->signinSubmit($request);
}

上面的 SigninFormRequest 就是依赖注入,然后需要实现这个注入类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<?php

declare(strict_types=1);

namespace App\Request\Auth;

use Hyperf\Validation\Request\FormRequest;

class SigninFormRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}

/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'email' => 'required|email|between:6,128',
'password' => 'required|between:6,128',
];
}

public function attributes(): array
{
return [
'email' => '邮箱',
'password' => '密码',
];
}
}

这个依赖注入类会被 Hyperf 框架处理,在提交请求的时候进行判断,提交的参数必须满足 rules 中规定的数据格式,比如 email 字段是必须的,并且还得是邮箱格式,同时将字符长度限制在 6 到 128 位的长度。

如果不符合这种格式就会抛出一个错误,然后全局捕捉这个错误,将错误的信息格式化返回即可。

这样就解决掉了烦人的表单数据过滤问题了。

接着就是第二烦人的情况,我们希望用户填写完表单,然后点击「一下」登录,但有时候会出现一种情况,比如我快速点击了登录两次(用户手滑之类的),那么就会请求两次登录接口,虽然登录接口连续请求两次没有什么严重的后果,但是其他接口就不一定了,比如每日签到如果快速点击两次就会请求两次签到接口,很可能会导致并发问题发放了两次签到奖励,这种情况就是我们不希望发生的。

因此,我们需要加上一个「锁」来防止用户重复提交,而常规的锁就是用 redis 写入一个简单的变量,再判断是否有这个变量,如果有就阻止提交,一般情况下会这么写:

1
2
3
4
5
6
7
8
9
10
11
$redis = RedisUtil::instance()->getRedis();
if($redis->exists('lock')) {
# 返回操作失败的提示
...
}

# 写入一个简单的变量,过期时间设置为3秒
$redis->setEx('lock', 3, 1);

# 进入下一步
...

上述就是一个简单的 redis 锁的实现方式,写入一个过期时间 3 秒的变量,相当于锁住 3 秒的时间,而 redis 变量会在 3 秒后自动过期,因此 3 秒之后就可以再次提交了,但是这样依然很「繁琐」,还是要用大量的 if 来判断。

这个时候就可以用到 AOP 编程方式了,只需要在请求之前进行拦截,然后判断是否存在 redis 锁,如果存在就阻止下一步操作,而是提前返回失败的结果即可,如下所示,写一个用来拦截请求的切面类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<?php
/**
* @author FireRabbit
* @date 2023/3/11 23:34
*/


namespace App\Aspect\Front;

use App\Annotation\DuplicateLockAnnotation;
use App\Constant\Constant;
use App\Util\RedisUtil;
use Hyperf\Di\Annotation\Aspect;
use Hyperf\Di\Aop\AbstractAspect;
use Hyperf\Di\Aop\ProceedingJoinPoint;
use function get_client_ip;
use function multitude_error_response;

#[Aspect]
class DuplicateLockAspect extends AbstractAspect
{
public array $annotations = [
DuplicateLockAnnotation::class,
];

public function process(ProceedingJoinPoint $proceedingJoinPoint)
{
$cache = RedisUtil::instance()->redis();

$className = substr(strrchr($proceedingJoinPoint->className, "\\"), 1);
$keyName = Constant::CACHE_KEY_DUPLICATELOCK_KEY . $className . ':' . $proceedingJoinPoint->methodName . ':' . get_client_ip();

if ($cache->exists($keyName)) {
return multitude_error_response('操作太频繁,请稍等一会');
}

$annontation = $proceedingJoinPoint->getAnnotationMetadata()->method[DuplicateLockAnnotation::class];
$ttl = $annontation->ttl;
$cache->setEx($keyName, $ttl, 1);

return $proceedingJoinPoint->process();
}
}

上述切面类指定了一个注解:DuplicateLockAnnotation,这个注解后面会用到,而 redis 变量需要一个键名,为了「全自动」化,这里直接使用当前的类名和方法名作为 redis 变量的名字,并且为了让变量名短一点(少占用内存)去掉了命名空间。

最后 redis 变量会被自动命名为:类名:方法名:ip,然后再判断有没有这个变量就可以了,因为是自动获取当前的类名,所以这个切面可以用在「任何一个方法」,而不会与其他锁发生冲突,并且是根据用户的 IP 地址来加锁,这个锁就只针对这个 IP,而不会影响到其他用户。

接着再写一个注解类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php
/**
* @author FireRabbit
* @date 2023/3/11 23:34
*/


namespace App\Annotation;

use Hyperf\Di\Annotation\AbstractAnnotation;

#[\Attribute] class DuplicateLockAnnotation extends AbstractAnnotation
{
/**
* 锁的时间
* @var int
*/
public int $ttl = 3;
}

注解是可以传入参数的,这里传入锁的时间参数,并且指定一个默认值 3 秒,然后登录接口的代码就可以进行一番优化了:

1
2
3
4
5
#[DuplicateLockAnnotation(ttl: 5)]
public function signinSubmit(SigninFormRequest $request)
{
return $this->responser->signinSubmit($request);
}

只需要在方法上面加上这个注解就可以,并且还可以在注解上指定锁的时间参数,上述将锁的时间设置为 5 秒,即这个方法要 5 秒才能请求一次,好了,以后其他方法如果要防止表单重复提交,只要加上一行注解就可以,不再需要写很多 if 来判断了!

上面两个问题就是编程的一大痛点,表单的校验、请求的校验,如果是在平时需要写一大堆判断的代码,而现在可以通过依赖注入和切面编程全自动化处理,机械重复的劳动只需要一行代码就搞定!心情愉悦!

由于注解可以传参,因此以后很多校验的操作全部可以在注解完成了,比如下面是判断全局是否开放了登录功能:

1
2
3
4
5
6
#[ModulePermissionAnnotation(
module: ModuleConstant::MODULE_SYSTEM,
key: ModuleConstant::SYSTEM_ENABLED_LOGIN,
required: Constant::ENABLED,
message: '未开放登录'
)]

这是一个「通用」的注解,module 参数传入对应的模块信息,key 传入对应的键,required 则为预期的值,如果不满足预期的值就会抛出一个异常,并且返回 message 的提示信息。

这样就实现了完全的参数化开发方式,只需要配置注解参数,而不需要手动写逻辑代码。

未来的期待

注解编程离我的梦想「无码」开发越来越近了。

编程的最高境界是无码。——火兔语录

所谓的无码就是指 没有代码,虽然作为一名码农,敲代码是日常,但是敲久了也会厌烦,特别是那种重复的机械化劳动,未来的编程方式我猜会是「参数编程」,即通过一些命令行自动生成代码文件(如 Laravel 的 artisan 命令就可以实现自动生成控制器、类等文件模板),而逻辑的实现可以完全由参数进行配置,再写一个解析器解析这些参数进行对应的处理,最后,编程就只需要像配置 excel 表格一样简单了。

结尾

注解开发虽然解放了双手,并且还实现了代码的解耦,但是这种编程感觉太丑了,复杂一点的请求可能要在方法上面写一大堆的注解,像叠罗汉一样……不过,反正用户也不知道后台的代码长啥样,但是一些有强迫症的码农可能就比较难受了。

文章作者: 火烧兔子
文章链接: http://huotuyouxi.com/2023/06/19/2023-news-3/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 火兔游戏工作室