记录一下最新学到的东西

前言

原本只打算用一年去做游戏,结果硬是拖了三年,现在总算做出来了,最后还是变成自己一个人在做,真是世事无常大肠包小肠,感兴趣的可以看看实机演示:https://www.bilibili.com/video/BV1XT411f7Pw/

最近重拾了这个博客,升级了下 hexo 的版本,并且也更换了主题与游戏博客保持一致,又新购了一个域名 huotutu.com,准备搭起来做一个小窝,研究前沿技术的同时顺便可以加入付费功能嘿嘿嘿~

好了,直接进入正题。

PHP 是最好的语言吗?

因为使用 Unity 开发游戏,因此接触了 C#,结果现在重新撸 PHP,发现很不习惯了,比如 PHP 是弱类型语言,这样在编写代码的时候经常会很不规范,传来的值是什么,返回的值是什么都不清楚,就跟开盲盒一样……现在最新的 PHP 已经开始重视这点了,为了更加规范,PHP 也必须要注重变量类型和返回值的声明了。

第二点,PHP 的变量类型比较少,没有 Dictionary(字典),这个类型在开发游戏的时候非常好用,虽然 PHP 可以用关联数值实现相同的效果,但因为上面的第一点,会出现很复杂的情况,比如在 C# 可以用字典这么保存变量,声明一个敌人的模型数据:

1
2
3
4
5
6
7
8
9
10
11
// 敌人模型数据
public class Format_Enemy
{
public string id;

// 敌人的等级
public int level;

// 敌人的模型
public string prefab;
}

接着,当游戏载入时,读取本地 json 文件,格式如下:

1
2
3
4
[
{"id": "goblin", "level": 1, "prefab": "Goblin"},
{"id": "monkey", "level": 3, "prefab": "Monkey"}
]

enemy.json 文件以数组形式保存 model 类型的变量,接着将敌人数据载入字典:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 声明一个字典用来存储敌人模型数据
Dictionary<string, Format_Enemy> models = new Dictionary<string, Format_Enemy>();

// 初始化
public void InitLoad()
{
// 这里是读取json文件的方法
var items = ModelUtil.GetInstance().Load<List<Format_Enemy>>("enemy");

// 循环将敌人id作为键名保存到字典
foreach (var item in items)
{
models.Add(item.id, item);
}

Debug.Log("敌人模板数据载入完成");
}

调用的时候就非常简单了:

1
2
3
4
var monkey = $models["monkey"];

// 输出monkey敌人的等级
Debug.Log(monkey.level);

因为字典是以 key-object 的形式保存数据,通过 model[id] 的形式获得的是一个 object 对象,因而可以直接调用对象的属性,可是 PHP 就不能这么做了,如下为 PHP 的写法:

1
2
3
4
5
6
7
$enemies = [
'goblin' => ['id' => 'goblin', 'level' => 1, 'prefab' => 'goblin'],
'monkey' => ['id' => 'money', 'level' => 1, 'prefab' => 'money'],
];

$monkey = $enemies['monkey'];
var_dump($monkey['level']);

调用方法如出一辙,可是区别在于,PHP 是弱类型的语言,这里的所有数据都是“凭空”输入的,因此在编译器上面无法判断是否出错,而在 C# 有类的约束,可以直接用 . 调出属性,如果有写错的地方,编译器第一时间就会报错。

PHP 的数组没有任何约数,甚至可以直接凭空捏造一个 key

1
2
$arr['abc'] = 123;
var_dump($arr);

上面是一段没有任何问题的 PHP 代码,输出结果是:

1
2
3
4
array(1) {
["abc"]=>
int(123)
}

不需要任何声明可以凭空调用,这既是优点也是缺点,优点是写起来很方便,缺点是撸代码很不规范,如下所示:

1
2
3
4
5
6
7
8
9
function updateGoods(array $goods)
{
if ($goods['status'] == true) {
// ... 处理逻辑
}

$price = $goods['price'];
var_dump($price);
}

上面是一个简单的数组调用,通过传入商品数据,在方法内更新商品状态,因为传入的是一个数组(关联数值),数组的键名完全就是凭空写进去的,没有任何约数,不知道的人根本不懂得应该传入什么键名,这样的代码后期无法维护,而当一个方法所需要的参数较多时,应该封装成类作为参数传入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class  GoodsData
{
public bool $status;
public int $price;
}

function updateGoods(GoodsData $goods)
{
if ($goods->status == true) {
// ... 处理逻辑
}

$price = $goods->price;
var_dump($price);
}

这样才能起到约束作用,没有约束就没有规范,没有规范就难以维护,C# 虽然写起来更舒服一点,但是开发网站还是得 PHP 效率高,光是等编译就能节省一大堆的时间了。

注解

注解是新时代编程的主流,PHP 8 也很快就跟上了,所谓的注解,其实就是通过「反射」获取到注释的内容,再通过解析获得想要的结果。先来说一下什么是反射,反射就是程序获取到自身属性的行为,这么说很难理解,看下面的一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 这是一个类
*/
class TestClass
{
private string $name;

public function test(string $a, int $b): void
{
var_dump($a, $b);
}
}

这是 PHP 里面的一个类,有一个 string 类型的私有属性 name,还有一个简单的方法,那么问题来了,我们怎么获取到注释信息?你没看错,我们现在要获取这个类的注释……正常来说,哪有这么奇怪的需求啊?注释不是写给码农看的吗?我们要拿注释做什么呢?关于这个问题,下文会说明,但是先解决眼前的问题——我们到底该如何通过代码拿到注释信息呢?

我们顶多也就知道如何拿到类的名称,比如 PHP 8 里面最新加入了一个获取类名称的方法:

1
2
3
4
5
$obj = new TestClass();
var_dump(get_debug_type($obj));

// 输出结果
string(9) "TestClass"

可是老师从来没教过我们怎么获取注释……正确答案是:通过反射机制(Reflection)。反射就是程序获取自身属性的一种行为,不仅是注释,类有哪些属性,哪些方法等等都能给你查的明明白白,通过反射机制,类文件的任何标点符号都不会放过,一切尽收眼底~是不是很兴♂奋?

首先是类的注释,也就是在类声明上方的注释部分,为了通过反射获取类的注释,需要实例化反射类 ReflectionClass(PHP 自带):

1
2
3
4
$reflection = new ReflectionClass ( TestClass::class );
$ref = $reflection->getDocComment();

var_dump($ref);

输出结果:

1
2
3
string(26) "/**
* 这是一个类
*/"

接下来,我们可以来点更“变态”的,我想要拿到类的方法,想知道它的参数名称!

全部都要看光光!

1
2
3
4
5
6
$reflection = new ReflectionClass (TestClass::class);
$res = $reflection->getMethod('test');
$params = $res->getParameters();
foreach ($params as $param) {
var_dump($param->getType()->getName() . '_' . $param->getName());
}

输出结果:

1
2
string(8) "string_a"
string(5) "int_b"

不仅是变量类型,形参的名字都拿到了,这就是反射的神奇之处,它可以获取到自身的属性,同时还可以改变属性的值,例如上面的 name 属性是私有变量,按照我们的常规思想是无法通过调用属性来改变值的,但是通过反射却可以,为了方便演示,增加了一个 show 方法用来输出 name 的值:

1
2
3
4
5
6
7
8
9
class TestClass
{
private string $name;

public function show(): void
{
var_dump($this->name);
}
}

接着,通过反射来修改 name 的值并调用 show 方法查看结果:

1
2
3
4
5
6
7
8
9
10
11

$obj = new TestClass();

$reflection = new ReflectionClass ($obj);
$res = $reflection->getProperty('name');
$res->setAccessible(true);
$res->setValue($obj, 'abc');
$obj->show();

// 输出结果
string(3) "abc"

神奇吧!!!在无法访问私有变量的情况下,竟然可以通过反射修改私有变量的值。
emmm……仔细一想,虽然反射很牛杯,但是我一个只会增删改查的咸鱼,在开发中有什么用呢?
完全就是多此一举嘛~

如果想要修改变量的值,为什么不能直接写个 get-set 方法呢?
确实如此,反射在我们日常工作的用途并不大,也不会有多少人接触。
反射机制可以获取变量类型、方法名称、参数、注释信息等等,这些在日常处理业务几乎没有作用。
但是它在设计框架、构筑开发流程中非常重要!
就好比我们日常基本接触不到设计模式,可设计模式却是框架的核心。
而现代的主流框架已经开始利用反射机制实现独特的开发方式,现在很热门的“依赖注入”就是通过反射实现的,比如下面这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class TestController
{
private TestService $service;

public function __construct()
{
$this->service = new TestService();
}
}

class TestService
{
public function show(): void
{
var_dump('hell world');
}
}

现在有一个 TestService 类,我们想在 TestController 调用,需要手动 new 的方式在构造方法进行赋值,但是在 Laravel 框架里,却可以这样:

1
2
3
4
5
6
7
8
9
class TestController
{
private TestService $service;

public function __construct(TestService $service)
{
$this->service = $service;
}
}

看到了吗?现在不需要手动 new 创建对象了,因为当你在构造方法里设置了一个参数,在 Laravel 框架的作用下就会自动实例化这个对象进行赋值,这就叫做依赖注入,正如其名,你需要的“依赖”会被框架“注入”。

你可能会说,这不就是少写了一段代码吗?
实际上,别看着小小的变化,却涉及到整个编程流程方式的改变。

一个基本控制器如下,内有一个 index 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class TestController
{
private TestService $service;

public function __construct(TestService $service)
{
$this->service = $service;
}

public function index(Request $request)
{
$params = $request->all();
}
}

这个方法通过依赖注入获取到了 Request 对象,而在 Laravel 中,这个对象保存了请求的信息,我们可以直接从这个对象获取到所有请求参数,是不是很方便?这样做既让编程更加“优雅”,同时也减少了系统之间的耦合度。

依赖注入是框架帮你实现的,上面我介绍了反射的原理,你完全可以自己写一个依赖注入来试试~

框架并不是简单的函数封装,而是提供一个编程的环境。

依赖注入和控制反转,还有反射……一大堆新的名词,老实说我十分反感,如果不能把复杂的问题简单的讲述,那说明理解不到位,或者故意把简单的说成复杂的,搞得好像很高大上一样,说的就是金融行业……如果你是初次看到这些名词,不用担心,因为你现在已经学 会了。

我不推荐你去搜这些专有名词啥意思,因为看完你会更懵逼——by 傻瓜式编程指南(兔兔著)

现在你已经知道了,依赖注入就是框架帮你注♂入一些东西,比如你想要的类的对象,只要写在方法的形参上,框架就会帮你实例化,你直接调用就完事。请参考 N 年之前我写的火兔引擎(开发框架),我在设计框架的时候就已经明确了目标:编程的最高境界是“无码”。

换句话说,除非你是非常喜欢写代码的人(高情商),否则,框架会自动帮你处理很多琐碎的事情,因此你不需要写太多的代码。市面上的 PHP 框架多如牛毛,什么阿猫阿狗阿兔都想“自创框架”,比如大名鼎鼎的 Fire Rabbit Engine,就是阿兔本兔原创的框架。

学习一门新的框架要不少时间成本,要我说的话,对大部分人来说,框架只要选择开发起来“舒服”的就行了,大部分情况下都不需要注意性能问题,小公司注重的是开发效率,指不定过完年公司就倒闭了 233,我以前上班的时候就是一个“项目杀手”,做一个项目死一个,入职一年,弄死的项目少则三个,多则五个!

其实是因为老板自己都不知道想干嘛就胡乱尝试,今天刚废了一个项目,第二天又看上新的东西,脑袋一热一拍屁股就决定要开发新项目了,很荣幸……当时的技术主管全丢给我了。最后,我离职了,原因是给的钱不多吗?不是,那个时候我才刚刚毕业,并不看重钱,而是每个项目都是我在负责,就好像辛辛苦苦养大的孩子突然夭折了……说多了都是泪。

所以,当一个项目立项的时候,如果负责人非得选用性能最好,开发舒适度最烂的时候,应该想想,这是不是一种傲慢,自信的认为这个项目一定能成功呢?小公司根本不在乎性能,只要快速构建项目,快速上线才是王道!尤其是老板催你进度的时候,那跟催命似的,到时候后悔就来不及了。

回归主题,接下来开始介绍什么是“注解”。注解其实就是注释的一种,原本注释是给人看的。现在,一些走在时尚前端的码农研究出了新的花活——让程序也能看注释。

例如 Java 里面有这种代码:

1
2
3
@Override
@Deprecated
@SuppressWarnings

下面是我网上 copy 来的冒泡排序:

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
public class BubbleSort implements IArraySort {

@Override
public int[] sort(int[] sourceArray) throws Exception {
// 对 arr 进行拷贝,不改变参数内容
int[] arr = Arrays.copyOf(sourceArray, sourceArray.length);

for (int i = 1; i < arr.length; i++) {
// 设定一个标记,若为true,则表示此次循环没有进行交换,也就是待排序列已经有序,排序已经完成。
boolean flag = true;

for (int j = 0; j < arr.length - i; j++) {
if (arr[j] > arr[j + 1]) {
int tmp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = tmp;

flag = false;
}
}

if (flag) {
break;
}
}
return arr;
}
}

直接在方法上方加了一个 @Override,这个就是注解,它的作用是检查该方法是否是重写方法。如果发现其父类,或者是引用的接口中并没有该方法时,会报编译错误。

这股时尚的潮流也 卷到 PHP 那边去了,于是,主打注解开发为亮点的 PHP 框架陆续出现,前有 Swoft,后有 Hyperf,Hyperf 的作者好像之前就是 Swoft 里的,后来单飞了,当然也有很多吃瓜事件,咱也不了解,至于为什么选择 Hyperf 而不是 Swoft,主要还是因为我个人对 Laravel 的偏爱,Hyperf 的代码几乎跟 Laravel 完全相似,相当于 Swoole 版的 Laravel,关于吃瓜问题就不多展开,技术本身无好坏,唯有人。本人也不了解真相就不多说了,看下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?php
declare(strict_types=1);

namespace App\Controller;

use Hyperf\HttpServer\Contract\RequestInterface;
use Hyperf\HttpServer\Annotation\AutoController;

/**
* @AutoController()
*/
class IndexController
{
// Hyperf 会自动为此方法生成一个 /index/index 的路由,允许通过 GET 或 POST 方式请求
public function index(RequestInterface $request)
{
// 从请求中获得 id 参数
$id = $request->input('id', 1);
return (string)$id;
}
}

通过写注释的方式来定义路由,是不是很新鲜?除此之外,注解也可以实现上面提到的依赖注入,比如我们需要一个类的实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* @AutoController()
*/
class IndexController
{
/**
* @Inject()
* @var UserService
*/
private $userService;

// /index/info
public function info(RequestInterface $request)
{
$id = $request->input('id', 1);
return $this->userService->getInfoById((int)$id);
}
}

现在只要加入一个 Inject 注解,并且用 var 来声明变量的类型,框架就会自动为你注♂入这个类的实例,在 PHP 8 以前,注解都是非官方的,只是民间通过反射机制实现了 JAVA 类似的注解,如下所示:

1
2
3
4
5
/**
* @param Foo $argument
* @see https:/xxxxxxxx/xxxx/xxx.html
*/
function dummy($Foo) {}

而且这种注解一般没有什么实际的意义,就是一种人为的规定,比如 @param 我们视为一个参数,当然你也可以写成 @canshu,这都是可以的,因为这就是一个注释而已,注释在程序中不会被认为是代码,你怎么写都可以。

然后自己再写一个解析注释的代码:

1
2
3
$ref = new ReflectionFunction("dummy");
$doc = $ref->getDocComment();
$see = substr($doc, strpos($doc, "@see") + strlen("@see "));

上面是 PHP 最早的民间注解实现原理,而第一次尝试用注解作为开发的 Hyperf 框架很快就流行起来,习惯了这种开发方式会变得很愉♂悦,直到 PHP 8 开始,官方实现了 PHP 的注解:

1
2
3
#[Params("Foo", "argument")]
#[See("https://xxxxxxxx/xxxx/xxx.html")]
function dummy($argument) {}

简单地说:
注释(comment)是给人看的,程序看不懂;
注解(annotation)既是给人看的,程序也看得懂。

我们可以通过注解实现依赖注入等许多功能,人有多大胆,地有多大产,Hyperf 的做法属实是一种大胆的尝试了,无论是路由、控制器、中间件等等,都可以用注解的方式书写。

以前我最喜欢的是 Laravel 框架,而现在 Hyperf 是基于 swoole 的框架,而且框架的设计者应该也是 Laravel 的重度爱好者,从 Laravel 转 Hyperf 几乎没有任何学习成本,最新的火兔小窝(huotutu.com)将使用 v3.0 版本开发~

好了,以上就是关于注解的说明,注解(Annotation)并不属于程序的代码,而是一种开发方式,就是通过特殊的格式定义注释,好让系统看得懂,因为注解的存在,注释也变成了一种开发方式,经典例子就是 Hyperf 框架。

框架并不是简单的函数封装,选择什么框架开发项目就决定了你的开发方式,开发方式是由框架设计者决定的,Hyperf 框架也不过是 swoole 框架的一个可选方案,只是因为我喜欢 Laravel 的开发方式,所以选择它。

可以缩减写代码工作量的框架都是好框架,现在 ChatGPT 火爆,甚至还能帮你写代码,以后说不定还会被 AI 取代。

协程

双叒叕是一个令人头痛的新名词!
先让我们来看摊煎饼的数学问题,假设一个锅每次只能摊两个煎饼,煎饼要求两面摊到金黄,单面摊到金黄需要 1 分钟,现在有三张饼要摊,请问把三张饼摊到两面金黄需要多久?

正常人的思维:先弄 A 和 B 两张饼,两面都金黄一共需要 2 分钟,接着再下 C,两面金黄又需要 2 分钟,总计需要 4 分钟。

机智的人可能发现问题了,A 和 B 在锅里的时候,C 是空闲的,当 A 和 B 摊熟了,C 一个饼占了整个锅(一个锅是可以放两张饼的),那是不是有点浪费资源了?于是,聪明的人就有了新的方法:

第一分钟,A 和 B 下锅,把一面摊到金黄,接着把 B 拿出来放到一旁,把 C 下锅;
第二分钟,A 两面全熟拿起来放到盘子,再把 B 翻过来继续摊,接着把刚才摊了一面的 B 下锅;
第三分钟,BC 两面全熟,收锅关火!

同样的锅,同样是三张饼,用这种方法竟然节约了 1 分钟!这是因为锅一直都是保持两张饼,而第一种方法,锅会出现空闲状态,原本能同时摊两张饼,却只摊了一张,这就是浪费时间的因素。

所谓的协程就是这种原理,最大化的利用程序的效率,锅就是服务器,为了把服务器的性能榨干就不能让它闲着,要永远让它“干活”。要知道,程序的执行速度是不同的,有的程序执行起来很慢(如读取 Excel),有的很快(给变量赋值),我们都知道程序是从上至下执行的,前面的代码没执行完毕,后面的代码就会一直处于等待状态,这就是浪费“资源”,就像上面的锅一样,原本可以同时摊两张饼,可一张饼占了整个锅,效率自然就低了。

例如 PHP 代码:

1
2
echo 'hello';
echo 'world';

这段代码为什么永远都是按顺序输出 helloworld 呢?正是因为程序是自上而下执行的,而且这两段代码执行速度很快,所以瞬间就输出完毕了,接着我们再模拟一下,代码执行慢的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
function loadFile()
{
echo 'hello';
sleep(1);
}

function sayWorld()
{
echo 'world';
}

loadFile();
sayWorld();

这段代码同样是输出两个单词,但是会发现因为 sleep 的存在,第一个 hello 输出完毕之后等了一秒才输出 world,两个单词的输出顺序依然没变,这是因为程序无论如何都是从上往下执行的,而这种「等待」完全就是不必要的,两个函数没有任何关联,为什么不能先让下面的代码进行输出呢?

就好比医院挂号,按照 1、2、3、4、5 顺序排列,1 号第一个进入窗口办理,很快就办完了,接下来轮到 2 号,可他身份证忘记带了,就打电话叫家人送过来,那么此时问题来了……现在确实是轮到 2 号办理手续,但是等他家人送身份证过来要半小时,后面的 3~5 号以及办理挂号的工作人员就这么干等着吗?那是不是可以通融一下,在 2 号的身份证送来之前,先让后面的人办理?等身份证到了,再让 2 号继续办理也不迟呀!

正常的程序可不会这么“通情达理”,程序是一定严格按照从上至下的顺序执行的,2 号蛮横的说:现在轮到我挂号,我没办完,凭什么让你们先办啊?所以此时必须等到 2 号身份证送来,后面的 3~5 号才能继续办理。

谁排到队谁就拥有“挂号权”,轮到 2 号挂号了,医院又没规定挂号的最大时间,那么自然 2 号有权等到自己身份证送来,后面的人就是得干等着没办法,虽然不通人情,但这也无可奈何,因为程序可不知道你下面的代码是不是跟上面的有关,万一执行顺序乱了就报错啦~

协程就是这个问题的解决方案:让你决定程序的控制权。
程序不知道该不该先让后面的代码执行,可是你知道呀!那交给你来控制不就完事了吗?

先来说一个 PHP 的关键词 yield,这个关键词并不是协程,而是用来生成迭代器(又叫生成器)的,迭代器就是一个能够被循环的对象,如果理解不了就直接认为是一个数组就好,例如下面这样:

1
2
3
4
$items = ['a', 'b', 'c'];
foreach ($items as $item) {
var_dump($item);
}

不只是数组,只要实现了迭代器接口(Iterator)的类都可以被 foreach 循环,下面尝试自定义一个迭代器(注意,PHP 8.1 不支持):

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
44
45
46
47
<?php

class Rabbit implements Iterator
{
// 这里我定义了一个 int 类型的指数物,用来标记当前进度,类似数组的下标
private int $pos;

public function __construct()
{
$this->pos = 0;
}

// 当前返回的值
public function current()
{
return $this->pos;
}

// 将指数物向下移动一位,在foreach中每次调用都会前进一次
public function next()
{
$this->pos++;
}

// 迭代器的key(类似关联数值的键名)
public function key()
{
return $this->pos;
}

// 判断是否可以继续向下执行,如果数据已经没了,就返回false,这里小于10是随便写的,让它打印0~10个数
public function valid()
{
return $this->pos <= 10;
}

// 重置指数物,该方法在迭代器执行一次就无法调用,否则报错
public function rewind()
{
$this->pos = 0;
}
}

$rabbit = new Rabbit();
foreach ($rabbit as $item) {
var_dump($item);
}

上面我定义了一个迭代器 Rabbit,通过 foreach 循环依次输出 0~10。任何类只要实现了 Iterator 迭代器接口就可以被 foreach 循环输出,接着我们的主角 yield 关键词要出现了:

1
2
3
4
5
6
7
8
9
10
11
function rabbit()
{
for ($i = 0; $i <= 10; $i++) {
yield $i;
}
}

$items = rabbit();
foreach ($items as $item) {
var_dump($item);
}

上面的结果也是依次输出 0~10,这就是 yield 关键词的作用,它看起来像是 return,但又不全是,因为 return 一旦返回函数就会结束,而 yield 返回了数字,但却没有退出函数,不仅如此,yield 的返回值是一个可以被循环体调用的迭代器。

接着再看有意思的一组数据,我们稍加修改代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
function rabbit()
{
for ($i = 0; $i <= 10; $i++) {
var_dump('a' . $i);
yield $i;
var_dump('b' . $i);
}
}

$items = rabbit();
foreach ($items as $item) {
var_dump($item);
}

yield 关键词上面和下面分别进行输出,我们再运行一次查看结果:

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
string(2) "a0"
int(0)
string(2) "b0"
string(2) "a1"
int(1)
string(2) "b1"
string(2) "a2"
int(2)
string(2) "b2"
string(2) "a3"
int(3)
string(2) "b3"
string(2) "a4"
int(4)
string(2) "b4"
string(2) "a5"
int(5)
string(2) "b5"
string(2) "a6"
int(6)
string(2) "b6"
string(2) "a7"
int(7)
string(2) "b7"
string(2) "a8"
int(8)
string(2) "b8"
string(2) "a9"
int(9)
string(2) "b9"
string(3) "a10"
int(10)
string(3) "b10"

这里发现了奇怪的地方:

1
2
3
string(2) "a0"
int(0)
string(2) "b0"

我们的循环体是这样的:

1
2
3
4
5
for ($i = 0; $i <= 10; $i++) {
var_dump('a' . $i);
yield $i;
var_dump('b' . $i);
}

如果按照程序从上往下执行的观点,难道不是应该输出:

1
2
3
string(2) "a0"
string(2) "b0"
int(0)

这样才对吗?可为什么却先跳到:

1
2
3
4
$items = rabbit();
foreach ($items as $item) {
var_dump($item); // 这个地方输出 int(0)
}

也就是说,程序是先返回了 0 这个数,接着再执行下面的 var_dump 输出 b,因为 yield 的存在导致程序的执行顺序发生了变化,接着继续演示 yield 是如何手动控制执行顺序的:

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

function action_1()
{
var_dump('action_1_1');
yield;
var_dump('action_1_2');
}

function action_2()
{
var_dump('action_2_1');
yield;
var_dump('action_2_2');
}

$action_1 = action_1();
$action_2 = action_1();

$action_1->current();
$action_2->current();

var_dump('ok');

$action_1->next();
$action_2->next();

上面有两个函数 action_1action_2,通常情况下,如果没有 yield 关键词:

1
2
var_dump('action_1_1');
var_dump('action_1_2');

这段代码会一股脑的全部输出,但是因为有了 yield 关键词,我们就可以手动调用 next 方法让它往下执行,如果你不调用 next,那它就会在 yield 处返回,不再往下执行。

结果输出:

1
2
3
4
5
string(10) "action_1_1"
string(10) "action_1_1"
string(2) "ok"
string(10) "action_1_2"
string(10) "action_1_2"

可以发现,因为 yield 返回的是一个生成器,倘若我们执行了 next 方法,它才会继续向下执行,否则,它就会“卡”在 yield 的地方,这就是我们可以手动调控程序执行顺序的原因了。

换句话说,yield 就跟断点一样,我们想让程序在哪停下,就在哪用 yield 返回就行了,但是与 return 不同的是 yield 返回的是控制权,并不是让程序中断,这是利用了 yield 返回的是一个迭代器的原理实现的。

协程与多线程很容易混淆,我们会误以为是不是用 yield 改变了控制权,程序就可以不会再阻塞执行了呢?如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function test()
{
var_dump('1');
sleep(3);
yield;
var_dump('2');
yield;
var_dump('3');
}

$test = test();
$test->current();
$test->next();
$test->next();

结果我们发现,在输出第一个“1”的时候,程序还是卡了 3 秒才继续输出,换句话说,这个代码依然是同步执行的,上面的代码如果不执行完毕,还是会卡在执行过程,那这个 yield 到底有什么用呢?它还不是卡住了吗?

回到摊煎饼的问题,现在只有一口锅,一口锅最多每次只能摊两个煎饼,那么为了提高效率,可以按照上面三分钟的摊法,还有一种方法——再多加一口锅,两口锅就可以同时摊 4 个煎饼了!

再加一口锅即代表多线程,「协程」不是多线程,这两者很容易混淆,多线程是再切出一条线程处理程序,而「协程」还是在那个线程,只是我们通过任务调度的方式提高执行效率,还是那条线程,还是原来的味道!

多线程是利用 CPU 多线程处理任务的优势,而协程是将单个线程的性能压榨到极致。

那么这个 yield 到底哪里神奇了啊?
它本身就是在一个线程里面执行的,它的作用只是对程序进行“调度”而已,并不能解决程序阻塞问题。

正如上面的例子,多线程是加了一口锅,而协程是合理的利用这口锅,使它保持着最高效率——同时摊两个饼!
用多线程可以解决效率低下的问题,那为什么还要协程呢?

线程本身就是一种资源,协程就是最大化利用这条线程的性能,一口锅明明可以同时摊两个煎饼,为什么非要再开一个新锅呢?诚然,摊一个煎饼两面金黄要 2 分钟,摊三个煎饼,只要你拿出三口锅,时间就缩短到了 2 分钟了,岂不是比 3 分钟还快?那你家里可能有矿才能这么霍霍……

这就是痛点所在了:协程就是为了解决资源利不充分的问题。

利用协程的特性可以让锅一直都在摊饼,能同时摊两张,那就一定同时摊两张,一刻都不许闲着,像极了煤老板压榨矿工。为了实现这个“监工”需要一个名为“调度器”的东西,调度器的作用就是依次执行所有协程任务,直到所有协程任务结束了,调度器才结束:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

function create_task($max)
{
for ($i = 0; $i < $max; $i++) {
yield $i;
}
}

$task1 = create_task(3);
$task2 = create_task(5);

do {
var_dump($task1->current());
var_dump($task2->current());

$task1->next();
$task2->next();
} while ($task1->valid() || $task2->valid());

上面的例子中,create_task 创建了两个数值不等的任务,所谓调度器其实就是 do-while 循环体,valid 方法判断这个任务是否结束,如果两个任务全部结束才跳出这个循环(调度器),但是因为我们给这两个任务不同的最大值,导致两个任务执行的次数不一样,第一个任务只返回 3 个值,第二个任务却返回了 5 个值,最后输出结果如下:

1
2
3
4
5
6
7
8
9
10
int(0)
int(0)
int(1)
int(1)
int(2)
int(2)
NULL
int(3)
NULL
int(4)

前面三个数字,两个任务输出一模一样的结果,但是第一个任务从第四个数字开始就返回 Null 了,这是因为这个任务已经结束了,所以不再有新的返回值。同理,如果再添加任务三、任务四……那么这个调度器依然是依次执行所有任务,直到任务全部结束才跳出循环。

这就是协程的全部概念了……也就是说,协程是通过调度器实现依次执行全部的任务,这些任务绝对是不能阻塞进程的!否则任务阻塞了,后面的也得等在那边,所以如果使用协程,一定不能使用阻塞的代码,否则跟没用一样,协程的作用就是:把所有的煎饼都摊在锅里,这个锅能同时摊多少个煎饼,它就往里面塞多少个煎饼,而且雨露均沾,每个煎饼都只摊 1 秒,然后以迅雷不及掩耳之势换下一个煎饼,就这样,每个煎饼每次只摊 1 秒,直到全部的煎饼两面金黄为止。

上面的 1 秒只是一个比喻,实际上程序的运行速度比这个快多了。

协程的作用是控制程序的执行权限,通过权限转移的方式结合死循环(调度器)让每个任务每次循环都运行一次,直到所有任务结束才退出循环。

上面的调度器也是举个栗子罢了,实际的调度器会优化性能,比方说上面 3 次循环结束了,后面全部都是 NULL,实际上应该把已经完成的任务从循环体去掉,避免重复执行节约性能,这里就不对调度器详细展开了。

关于协程,我也是搞得很懵逼,最开始以为它跟多线程异步处理一样,就是把一些费时的任务切出去,然后执行下一段代码,其实并不是,如果两者混淆就理解不了协程了,协程……并不是异步处理,它也不是什么程序代码,它只是一种控制权转移。

协程的调度器其实是利用协程的控制权转移让所有的代码放到一块轮番切换执行,直到全部的代码执行完成。它本身就是一段阻塞程序(用死循环来实现调度器,任务不全部结束就跳不出这个死循环),它只是在循环执行这些任务罢了。

协程本身并不是为你提供异步环境,它就是一个执行权限的转移而已,而它之所以能够异步执行……是因为你写的代码是异步的啊,不是协程的调度器是异步的,你写了异步代码,它就异步执行,你写的不是异步代码,它就会阻塞,只要把这个搞清楚就不会懵逼了。

就跟打扑克一样,发牌的人每次都给在座的各位发一张扑克,直到所有玩家手牌满了才会停下,这就是协程的调度器,每次都只执行一次任务,然后遇到 yield 就立即切换到下一个任务,直到所有任务全部完成,这里的 yield 实现协程是 PHP 系统自带的,如果想要通过第三方实现,可以安装 swoole 扩展。

AOP(面向切面编程)

关于协程就抛到一边吧,因为理解起来很不容易,写完协程现在都凌晨一点半了,接下来看轻松一点的 AOP 编程,这也是现在很热门的编程思想,它就跟 OOP(面向对象编程)一样是现在广大码农喜欢的新理念。

切面(Aspect)是一个新的名词,理解起来非常简单,火腿肠吃过吧?现在我们有一根很♂粗的火腿肠,用刀从中间切开,然后塞一粒玉米进去,现在它就变成玉米热狗肠了!

真特喵的黑心,一粒玉米也敢叫玉米肠?

好了,结束,这就是面向切面编程。
用刀切开火腿肠,一刀两断,火腿肠赤果果的肉体的展示在你的面前,形成了两个完美的 Aspect(切面)。
面向切面编程,就是向这个切开的面塞入一粒玉米。

我们可以从任何地方切开火腿肠,在任何切面塞入玉米,甚至辣椒等等。
面向切面编程可以理解成「嵌入式」代码,你写一段代码,然后嵌入到任何其他代码里面。

PHP 里面有一个 trait 可以复用代码,但这并不是切面,这只是复用代码,切面编程一定存在『拦截』。

其实你早就用过 AOP 编程了,举个栗子就是 Laravel 的中间件,中间件会「拦截」请求,满足条件才进入下一步操作,如果不满足条件,那就 403 Forbidden,或者其他什么,总之,你通过中间件拦截请求,判断是否满足条件的这种行为就已经是面向切面编程了。

第二个栗子,如果你是前端开发,那么应该用过 Vue 框架,生命周期函数应该知道,就是那个什么 beforeCreatecreated 之类的东西,如果你是 Unity 开发,那么也知道 Monobehaviour 也有生命周期函数,如 AwakeStart 等等,生命周期函数就是 AOP 的理念。

AOP 编程的核心理念就是「拦截请求」,通俗的说就是 “当 xxx 时,做 xxx”,这就是 AOP 编程的核心思想了,如下面一个更新用户积分的例子:

1
2
3
4
5
6
7
8
9
class  UserController
{
public function update()
{
$params = request()->all();
$model = User::find($params['id']);
$model->update(['score' => $params['score']]);
}
}

该控制器提供了一个更新用户积分的方法,只要传来 idscore 分数参数就能找到对应的用户并且更新积分,但这样有不少问题,首先没有任何权限验证,岂不是人人都可以利用这个接口来改自己的分数?第二个,积分流水没有任何记录,万一以后跟用户产生纠纷怎么办?为了解决这个问题(先别想中间件的事情),我们需要进行如下两项改动:

1、对权限进行验证,只有管理员才能更新用户数据
2、对积分流水进行记录

首先假设有一个 checkAdmin 方法,用来判断是否是管理员,该方法只需要传入一个 jwt-token 参数,如果是管理员就返回 true,否则返回 false,还有一个方法 saveLog 用来保存积分流水:

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

class UserController
{
public function update()
{
$params = request()->all();

if (!$this->checkAdmin($params['token'])) {
return response()->json(['message' => '没有修改权限']);
}

$model = User::find($params['id']);
$model->update(['score' => $params['score']]);

saveLog($params['id'], $model->score);

return response()->json(['message' => '修改成功']);
}

function saveLog($userID, $score)
{
ScoreLog::create(['user_id' => $userID, 'score' => $score]);
}

function checkAdmin($jwtToken)
{
// ... 此处省略逻辑
return true;
}
}

现在看起来安全多了,接下来,如果有一个修改用户所属用户组的接口:

1
2
3
4
5
6
7
8
9
10
public function changeGroup()
{
$params = request()->all();

if (!$this->checkAdmin($params['token'])) {
return response()->json(['message' => '没有修改权限']);
}

// ...逻辑代码省略
}

又得判断一次权限……很是繁琐,现在毫无疑问你会说出用中间件消灭这些重复的代码。没错,中间件处理权限验证是非常方便的,所有需要验证权限的请求先经过中间件,通过中间件过滤不符合条件的请求。

中间件就是一个嵌入式的代码,当请求进来的时候,为什么不会直接进入到控制器呢?因为 Laravel 框架对请求进行了拦截,只有通过你设定的中间件请求才会进入 Controller,否则就过滤掉,如果用生命周期来形容,就是 before 阶段就进行了处理,请求还没进入 Controller 就先被过滤掉了!

Laravel 的中间件如何实现拦截请求可以看我开发 FireRabbitEngine 的相关文章

中间件拦截不符合要求的请求,这就是通过切面“嵌入”一段代码,只要封装一个拦截器就可以实现这个功能了,但实际上拦截请求有很多种方式,接下来以 Hyperf 为例,演示如何通过切面的方式执行设计好的代码,假设有一个控制器:

1
2
3
4
5
6
7
8
#[AutoController(prefix: '/test')]
class TestController
{
public function test()
{
return 'test';
}
}

现在我们要在这个控制器中嵌入其他代码,只需要声明一个切面:

1
2
3
4
5
6
7
8
9
10
11
12
13
#[Aspect]
class TestAspect extends AbstractAspect
{
public array $classes = [
'App\Controller\Front\TestController::test'
];

public function process(ProceedingJoinPoint $proceedingJoinPoint)
{
var_dump('insert');
return $proceedingJoinPoint->process();
}
}

classes 数组内加入想要插入的控制器方法,在 process 方法里面写入想要插入的代码,这段代码会在访问 /test 路由的时候一并被执行,注意,这里一定要返回:

1
return $proceedingJoinPoint->process();

这段代码是返回原控制器的处理结果,它跟 Laravel 的中间件几乎一模一样,如果你嵌入了多个切面,那么必须要全部返回,只要有一个没有返回程序就不会继续执行。

Hyperf 是以注解的方式开发的,我们还可以用注解的方式插入切面,声明一个注解,该注解还带有一个参数,参数在后面可以获取到:

1
2
3
4
5
6
7
8
namespace App\Annotation;

use Hyperf\Di\Annotation\AbstractAnnotation;

#[\Attribute] class TestAnnotation extends AbstractAnnotation
{
public string $val;
}

接着修改切面,把 classes 注释掉,改用注解的方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class TestAspect extends AbstractAspect
{
public array $classes = [
// 'App\Controller\Front\TestController::test'
];

public array $annotations = [
TestAnnotation::class,
];

public function process(ProceedingJoinPoint $proceedingJoinPoint)
{
// 这里拿到注解传来的参数
$val = $proceedingJoinPoint->getAnnotationMetadata()->method[TestAnnotation::class];
var_dump($val->val);

return $proceedingJoinPoint->process();
}
}

接着返回控制器,在想要嵌入的地方插入注解即可:

1
2
3
4
5
6
7
8
#[TestAnnotation(val: 'kkk')]
public function test()
{
$res = 'test-value';
var_dump('controller');

return $res;
}

这样就完成了,在切面里也可以拿到 val 的值。

关于切面有几个注意的地方,process 方法只会执行一次,所以不用担心如果有多个切面会重复执行的问题,放心大胆的 return 即可,第二点,classes 和注解的方式只要写一个即可,我个人比较偏向于用注解的方法,第三,被切入的地方,可以通过 ProceedingJoinPoint $proceedingJoinPoint 变量获取其他数据,比如方法的参数:

1
2
$params = $proceedingJoinPoint->getArguments();
var_dump($params);

那么 AOP 到底有什么用呢?它就是一种编程理念而已,正如最开始说的那样,这只是决定了你开发方式,AOP 可以帮你节省很多重复的劳动,节约时间就是节约生命~中间件就是最好的例子,除此之外,一些数据库的更新操作,记录日志操作也可以用面向切面编程实现,因为这些都是重复性的劳动,全部交给框架去办才是正解。

除此之外,上面的代码几乎没有任何耦合,从而变得十分简洁优雅~

管道

管道(Pipeline)就是因为像水管一样,从水管的这头输入一滴水,经过管道拐来拐去,最后流到你家里,这个过程呢,这滴水可能与其他管道流进来的水混合在一起,你最后得到的这滴水是混合了很多次的水。

去年冬奥会冰墩墩火到爆,如果你没看过冰墩墩的制作过程,那么我在这里科普一下,冰墩墩陶瓷版是全手工制作的,第一道工序就是烧陶瓷了,用模板烧出冰墩墩的模样,烧好的冰墩墩是就是个模具生产出来的粘土人而已,没有任何色彩,因此进入第二道工序,上色!冰墩墩的本体是熊猫,因此只要黑白两种颜色,用毛笔蘸一点颜料慢慢涂满整个冰墩墩,白色的身体,黑色的手手和眼睛鼻子,到这里第二道工序就完成了,涂了色的冰墩墩接下来要送到第三道工序——贴眼睛,眼睛是用贴片式的,把提前准备好的眼睛贴片沾到冰墩墩眼睛处即可,这样第三道工序也完成了,最后一道工序就是给冰墩墩套上宇航员外壳,ok,整个流程结束,冰墩墩制作完成。

现在让我们捋一捋,烧好的冰墩墩陶瓷是原始对象,送到 1~4 道工序加工后变成了一个成品的冰墩墩,用户拿到手的就是加工好的冰墩墩,这个制作流程就叫做「管道」,通过这个管道设计,输入一只无色陶瓷冰墩墩,经过管道出来的就是一只上好色的成品冰墩墩。

管道的理念是从 Unix 系统出来的,比如查看日志的命令:

1
tail -n 300 text.log

上面的命令会查询最新的 300 行日志,但是日志记录的内容太多了,我们如果想要某些关键词,比如 rabbit,可以用:

1
tail -n 300 text.log |grep rabbit

上述代码就会从日志最新的 300 行里过滤出包含 rabbit 关键词的行数,这其实就是一个链式调用,像 javascript 里面的 axios 可以通过如下调用方式:

1
2
3
4
5
6
7
8
9
axios.get('/user', {
params: {
ID: 12345
}
}).then(function (response) {
console.log(response);
}).catch(function (error) {
console.log(error);
});

这段代码表示发起一个 ajax 请求,当返回响应的时候,执行 then 的代码,如果执行过程发生异常就执行 catch 的代码,那么为什么可以用链式调用呢?其实很简单,就是让方法的返回值返回自身即可,用 PHP 来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class TestClass
{
private string $content;

public function __construct(string $content)
{
$this->content = $content;
}

public function add(string $newStr): TestClass
{
$this->content .= $newStr;
return $this;
}

public function show()
{
var_dump($this->content);
}
}

$test = new TestClass('abc');
$test->add('e')->add('f')->add('g')->show();

输出结果为:

1
string(6) "abcefg"

上面演示了最简单的链式调用,当一个对象的方法返回值是自身时,那么是不是可以继续调用其他方法?这就是链式调用的原理。现代主流框架基本都是支持这种链式调用的,比如 Laravel 框架的 ORM:

1
2
3
4
$users = User::select(['id'])->where('score', '>', 100)
->limit(5)
->get()
->toArray();

上面的代表表示查询用户积分大于 100 的用户 id,并且限制只取出 5 个数据,最后再把结果转化成数组形式。

管道还可以设计的更加复杂,例如上一个类执行完的结果,再传递给下一个类当做参数……简单地说,管道就是「传递」的过程,就好比一滴水从源头流进水管,万一水管接到了下水道,那这滴水不是被污染了吗?最后再流进你家里,当然,它也可能流到一根生锈的水管,因此融进了一些铁屑等杂质……诸如此类,这滴水流过什么样的水管就沾染上什么样的颜色,你把自己当成马里奥就行了,你想让管道怎么接就怎么接。

管道有很多实现的方法,比如 Laravel 的中间件,没错,又是它!只要你去研究 Laravel 中间件是如何实现的,你一定会被惊叹到,像洋葱一样一层一层剥开你的皮进入你的心……

总之,管道就是 输入-输出 的过程,如果说切面的核心是拦截,那么管道的核心就是「连接」与「传递」。

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