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