快速上手多人游戏服务器开发。后续会基于 Google Agones
,更新相关 K8S
运维、大规模快速扩展专用游戏服务器的文章。拥抱️原生 Cloud-Native!
系列
状态处理
在 Colyseus
中,room handlers
是 有状态(stateful
) 的。每个房间都有自己的状态。状态的突变会自动同步到所有连接的客户端。
序列化方法
-
Schema
(default)
状态同步时
- 当
user
成功加入room
后,他将从服务器接收到完整状态。 - 在每个
patchRate
处,状态的二进制补丁会发送到每个客户端(默认值为50ms
) - 从服务器接收到每个补丁后,在客户端调用
onStateChange
。 - 每种序列化方法都有自己处理传入状态补丁的特殊方式。
Schema
SchemaSerializer
是从 Colyseus 0.10
开始引入的,它是默认的序列化方法。
Schema
结构只用于房间的状态(可同步数据)。对于不能同步的算法中的数据,您不需要使用 Schema
及其其他结构。
服务端
要使用 SchemaSerializer
,你必须:
- 有一个扩展
Schema
类的状态类 - 用
@type()
装饰器注释你所有的可同步属性 - 为您的房间实例化状态(
this.setState(new MyState())
)
import { Schema, type } from "@colyseus/schema";
class MyState extends Schema {
@type("string")
currentTurn: string;
}
原始类型
这些是您可以为 @type()
装饰器提供的类型及其限制。
如果您确切地知道 number
属性的范围,您可以通过为其提供正确的原始类型来优化序列化。
否则,请使用 "number"
,它将在序列化过程中添加一个额外的字节来标识自己。
Type | Description | Limitation |
---|---|---|
"string" |
utf8 strings | maximum byte size of 4294967295
|
"number" |
auto-detects the int or float type to be used. (adds an extra byte on output) |
0 to 18446744073709551615
|
"boolean" |
true or false
|
0 or 1
|
"int8" |
signed 8-bit integer |
-128 to 127
|
"uint8" |
unsigned 8-bit integer |
0 to 255
|
"int16" |
signed 16-bit integer |
-32768 to 32767
|
"uint16" |
unsigned 16-bit integer |
0 to 65535
|
"int32" |
signed 32-bit integer |
-2147483648 to 2147483647
|
"uint32" |
unsigned 32-bit integer |
0 to 4294967295
|
"int64" |
signed 64-bit integer |
-9223372036854775808 to 9223372036854775807
|
"uint64" |
unsigned 64-bit integer |
0 to 18446744073709551615
|
"float32" |
single-precision floating-point number |
-3.40282347e+38 to 3.40282347e+38
|
"float64" |
double-precision floating-point number |
-1.7976931348623157e+308 to 1.7976931348623157e+308
|
子 schema 属性
您可以在 "root" 状态定义中定义更多自定义数据类型,如直接引用(direct reference
)、映射(map
)或数组(array
)。
import { Schema, type } from "@colyseus/schema";
class World extends Schema {
@type("number")
width: number;
@type("number")
height: number;
@type("number")
items: number = 10;
}
class MyState extends Schema {
@type(World)
world: World = new World();
}
ArraySchema
ArraySchema
是内置 JavaScript
Array 类型的可同步版本。
可以从数组中使用更多的方法。看看数组的 MDN 文档。
示例:自定义 Schema
类型的数组
import { Schema, ArraySchema, type } from "@colyseus/schema";
class Block extends Schema {
@type("number")
x: number;
@type("number")
y: number;
}
class MyState extends Schema {
@type([ Block ])
blocks = new ArraySchema<Block>();
}
示例:基本类型的数组
您不能在数组内混合类型。
import { Schema, ArraySchema, type } from "@colyseus/schema";
class MyState extends Schema {
@type([ "string" ])
animals = new ArraySchema<string>();
}
array.push()
在数组的末尾添加一个或多个元素,并返回该数组的新长度。
const animals = new ArraySchema<string>();
animals.push("pigs", "goats");
animals.push("sheeps");
animals.push("cows");
// output: 4
array.pop()
从数组中删除最后一个元素并返回该元素。此方法更改数组的长度。
animals.pop();
// output: "cows"
animals.length
// output: 3
array.shift()
从数组中删除第一个元素并返回被删除的元素。这个方法改变数组的长度。
animals.shift();
// output: "pigs"
animals.length
// output: 2
array.unshift()
将一个或多个元素添加到数组的开头,并返回数组的新长度。
animals.unshift("pigeon");
// output: 3
array.indexOf()
返回给定元素在数组中的第一个下标,如果不存在则返回 -1
const itemIndex = animals.indexOf("sheeps");
array.splice()
通过删除或替换现有元素和/或在适当位置添加新元素来更改数组的内容。
// find the index of the item you'd like to remove
const itemIndex = animals.findIndex((animal) => animal === "sheeps");
// remove it!
animals.splice(itemIndex, 1);
array.forEach()
迭代数组中的每个元素。
this.state.array1 = new ArraySchema<string>('a', 'b', 'c');
this.state.array1.forEach(element => {
console.log(element);
});
// output: "a"
// output: "b"
// output: "c"
MapSchema
MapSchema
是内置 JavaScript Map 类型的一个可同步版本。
建议使用 Maps
按 ID
跟踪您的游戏实体(entities
),例如玩家(players
),敌人(enemies
)等。
"目前仅支持字符串 key":目前,MapSchema
只允许您提供值类型。key
类型总是 string
。
import { Schema, MapSchema, type } from "@colyseus/schema";
class Player extends Schema {
@type("number")
x: number;
@type("number")
y: number;
}
class MyState extends Schema {
@type({ map: Player })
players = new MapSchema<Player>();
}
map.get()
通过 key
获取一个 map
条目:
const map = new MapSchema<string>();
const item = map.get("key");
OR
//
// NOT RECOMMENDED
//
// This is a compatibility layer with previous versions of @colyseus/schema
// This is going to be deprecated in the future.
//
const item = map["key"];
map.set()
按 key
设置 map
项:
const map = new MapSchema<string>();
map.set("key", "value");
OR
//
// NOT RECOMMENDED
//
// This is a compatibility layer with previous versions of @colyseus/schema
// This is going to be deprecated in the future.
//
map["key"] = "value";
map.delete()
按 key
删除一个 map
项:
map.delete("key");
OR
//
// NOT RECOMMENDED
//
// This is a compatibility layer with previous versions of @colyseus/schema
// This is going to be deprecated in the future.
//
delete map["key"];
map.size
返回 MapSchema
对象中的元素数量。
const map = new MapSchema<number>();
map.set("one", 1);
map.set("two", 2);
console.log(map.size);
// output: 2
map.forEach()
按插入顺序遍历 map
的每个 key/value
对。
this.state.players.forEach((value, key) => {
console.log("key =>", key)
console.log("value =>", value)
});
"所有 Map 方法":您可以从 Maps 中使用更多的方法。看一看 MDN 文档的 Maps。
CollectionSchema
"CollectionSchema
仅用 JavaScript 实现":目前为止,CollectionSchema
只能用于 JavaScript
。目前还不支持 Haxe
,c#
,LUA
和 c++
客户端。
CollectionSchema
与 ArraySchema
的工作方式相似,但需要注意的是您无法控制其索引。
import { Schema, CollectionSchema, type } from "@colyseus/schema";
class Item extends Schema {
@type("number")
damage: number;
}
class Player extends Schema {
@type({ collection: Item })
items = new CollectionSchema<Item>();
}
collection.add()
将 item
追加到 CollectionSchema
对象。
const collection = new CollectionSchema<number>();
collection.add(1);
collection.add(2);
collection.add(3);
collection.at()
获取位于指定 index
处的 item
。
const collection = new CollectionSchema<string>();
collection.add("one");
collection.add("two");
collection.add("three");
collection.at(1);
// output: "two"
collection.delete()
根据 item
的值删除 item
。
collection.delete("three");
collection.has()
返回一个布尔值,无论该 item
是否存在于 set
中。
if (collection.has("two")) {
console.log("Exists!");
} else {
console.log("Does not exist!");
}
collection.size
返回 CollectionSchema
对象中的元素数量。
const collection = new CollectionSchema<number>();
collection.add(10);
collection.add(20);
collection.add(30);
console.log(collection.size);
// output: 3
collection.forEach()
对于 CollectionSchema
对象中的每个 index/value
对,forEach()
方法按插入顺序执行所提供的函数一次。
collection.forEach((value, at) => {
console.log("at =>", at)
console.log("value =>", value)
});
SetSchema
"SetSchema
只在 JavaScript 中实现":SetSchema
目前只能在 JavaScript
中使用。目前还不支持 Haxe
,C#
,LUA
和 C++
客户端。
SetSchema
是内置 JavaScript Set 类型的可同步版本。
"更多":你可以从 Sets
中使用更多的方法。看一下 MDN 文档的 Sets。
SetSchema
的用法与 [CollectionSchema
] 非常相似,最大的区别是 Sets
保持唯一的值。Sets
没有直接访问值的方法。(如collection.at())
import { Schema, SetSchema, type } from "@colyseus/schema";
class Effect extends Schema {
@type("number")
radius: number;
}
class Player extends Schema {
@type({ set: Effect })
effects = new SetSchema<Effect>();
}
set.add()
向 SetSchema
对象追加一个 item
。
const set = new CollectionSchema<number>();
set.add(1);
set.add(2);
set.add(3);
set.at()
获取位于指定 index
处的项。
const set = new CollectionSchema<string>();
set.add("one");
set.add("two");
set.add("three");
set.at(1);
// output: "two"
set.delete()
根据项的值删除项。
set.delete("three");
set.has()
返回一个布尔值,无论该项是否存在于集合中。
if (set.has("two")) {
console.log("Exists!");
} else {
console.log("Does not exist!");
}
set.size
返回 SetSchema
对象中的元素数量。
const set = new SetSchema<number>();
set.add(10);
set.add(20);
set.add(30);
console.log(set.size);
// output: 3
过滤每个客户端的数据
"这个特性是实验性的":@filter()
/@filterChildren()
是实验性的,可能无法针对快节奏的游戏进行优化。
过滤旨在为特定客户端隐藏状态的某些部分,以避免在玩家决定检查来自网络的数据并查看未过滤状态信息的情况下作弊。
数据过滤器是每个客户端和每个字段(或每个子结构,在 @filterChildren
的情况下)都会触发的回调。如果过滤器回调返回 true
,字段数据将为该特定客户端发送,否则,数据将不为该客户端发送。
请注意,如果过滤函数的依赖关系发生变化,它不会自动重新运行,但只有在过滤字段(或其子字段)被更新时才会重新运行。请参阅此问题以了解解决方法。
@filter()
property decorator
@filter()
属性装饰器可以用来过滤掉整个 Schema 字段。
下面是 @filter()
签名的样子:
class State extends Schema {
@filter(function(client, value, root) {
// client is:
//
// the current client that's going to receive this data. you may use its
// client.sessionId, or other information to decide whether this value is
// going to be synched or not.
// value is:
// the value of the field @filter() is being applied to
// root is:
// the root instance of your room state. you may use it to access other
// structures in the process of decision whether this value is going to be
// synched or not.
})
@type("string") field: string;
}
@filterChildren()
属性装饰器
@filterChildren()
属性装饰器可以用来过滤出 arrays
、maps
、sets
等内部的项。它的签名与 @filter()
非常相似,只是在 value
之前增加了 key
参数 — 表示 ArraySchema
、MapSchema
、CollectionSchema
等中的每一项。
class State extends Schema {
@filterChildren(function(client, key, value, root) {
// client is:
//
// the current client that's going to receive this data. you may use its
// client.sessionId, or other information to decide whether this value is
// going to be synched or not.
// key is:
// the key of the current value inside the structure
// value is:
// the current value inside the structure
// root is:
// the root instance of your room state. you may use it to access other
// structures in the process of decision whether this value is going to be
// synched or not.
})
@type([Cards]) cards = new ArraySchema<Card>();
}
例子: 在一场纸牌游戏中,每张纸牌的相关资料只应供纸牌拥有者使用,或在某些情况下(例如纸牌已被丢弃)才可使用。
查看 @filter()
回调签名:
import { Client } from "colyseus";
class Card extends Schema {
@type("string") owner: string; // contains the sessionId of Card owner
@type("boolean") discarded: boolean = false;
/**
* DO NOT USE ARROW FUNCTION INSIDE `@filter`
* (IT WILL FORCE A DIFFERENT `this` SCOPE)
*/
@filter(function(
this: Card, // the instance of the class `@filter` has been defined (instance of `Card`)
client: Client, // the Room's `client` instance which this data is going to be filtered to
value: Card['number'], // the value of the field to be filtered. (value of `number` field)
root: Schema // the root state Schema instance
) {
return this.discarded || this.owner === client.sessionId;
})
@type("uint8") number: number;
}
向后/向前兼容性
向后/向前兼容性可以通过在现有结构的末尾声明新的字段来实现,以前的声明不被删除,但在需要时被标记为 @deprecated()
。
这对于原生编译的目标特别有用,比如 C#, C++, Haxe 等 — 在这些目标中,客户端可能没有最新版本的 schema 定义。
限制和最佳实践
- 每个
Schema
结构最多可以容纳64
个字段。如果需要更多字段,请使用嵌套的Schema
结构。 -
NaN
或null
数字被编码为0
-
null
字符串被编码为""
-
Infinity
被编码为Number.MAX_SAFE_INTEGER
的数字。 - 不支持多维数组。了解如何将一维数组用作多维数组
-
Arrays
和Maps
中的项必须都是同一类型的实例。 -
@colyseus/schema
只按照指定的顺序编码字段值。-
encoder
(服务器)和decoder
(客户端)必须有相同的schema
定义。 - 字段的顺序必须相同。
-
客户端
Callbacks
您可以在客户端 schema
结构中使用以下回调来处理来自服务器端的更改。
onAdd (instance, key)
onRemove (instance, key)
-
onChange (changes)
(onSchema
instance) -
onChange (instance, key)
(on collections:MapSchema
,ArraySchema
, etc.) listen()
"C#, C++, Haxe":当使用静态类型语言时,需要根据 TypeScript schema
定义生成客户端 schema
文件。参见在客户端生成 schema
。
onAdd (instance, key)
onAdd
回调只能在 maps
(MapSchema
)和数组(ArraySchema
)中使用。调用 onAdd
回调函数时,会使用添加的实例及其 holder
对象上的 key
作为参数。
room.state.players.onAdd = (player, key) => {
console.log(player, "has been added at", key);
// add your player entity to the game world!
// If you want to track changes on a child object inside a map, this is a common pattern:
player.onChange = function(changes) {
changes.forEach(change => {
console.log(change.field);
console.log(change.value);
console.log(change.previousValue);
})
};
// force "onChange" to be called immediatelly
player.triggerAll();
};
onRemove (instance, key)
onRemove
回调只能在 maps (MapSchema
) 和 arrays (ArraySchema
) 中使用。调用 onRemove
回调函数时,会使用被删除的实例及其 holder
对象上的 key
作为参数。
room.state.players.onRemove = (player, key) => {
console.log(player, "has been removed at", key);
// remove your player entity from the game world!
};
onChange (changes: DataChange[])
onChange
对于直接Schema
引用和集合结构的工作方式不同。关于集合结构 (array,map 等)的 onChange,请点击这里
您可以注册 onChange
来跟踪 Schema
实例的属性更改。onChange
回调是由一组更改过的属性以及之前的值触发的。
room.state.onChange = (changes) => {
changes.forEach(change => {
console.log(change.field);
console.log(change.value);
console.log(change.previousValue);
});
};
你不能在未与客户端同步的对象上注册 onChange
回调。
onChange (instance, key)
onChange
对于直接Schema
引用和collection structures
的工作方式不同。
每当 primitive 类型(string
, number
, boolean
等)的集合更新它的一些值时,这个回调就会被触发。
room.state.players.onChange = (player, key) => {
console.log(player, "have changes at", key);
};
如果您希望检测 non-primitive 类型(包含 Schema
实例)集合中的更改,请使用onAdd
并在它们上注册 onChange
。
"onChange
、onAdd
和 onRemove
是 exclusive(独占) 的":onAdd
或 onRemove
期间不会触发 onChange
回调。
如果在这些步骤中还需要检测更改,请考虑注册 `onAdd` 和 `onRemove`。
.listen(prop, callback)
监听单个属性更改。
.listen()
目前只适用于JavaScript/TypeScript
。
参数:
-
property
: 您想要监听更改的属性名。 -
callback
: 当property
改变时将被触发的回调。
state.listen("currentTurn", (currentValue, previousValue) => {
console.log(`currentTurn is now ${currentValue}`);
console.log(`previous value was: ${previousValue}`);
});
.listen()
方法返回一个用于注销监听器的函数:
const removeListener = state.listen("currentTurn", (currentValue, previousValue) => {
// ...
});
// later on, if you don't need the listener anymore, you can call `removeListener()` to stop listening for `"currentTurn"` changes.
removeListener();
listen
和 onChange
的区别是什么?
.listen()
方法是单个属性上的 onChange
的简写。下面是
state.onChange = function(changes) {
changes.forEach((change) => {
if (change.field === "currentTurn") {
console.log(`currentTurn is now ${change.value}`);
console.log(`previous value was: ${change.previousValue}`);
}
})
}
客户端 schema 生成
这只适用于使用静态类型语言(如 C#、C++ 或 Haxe)的情况。
在服务器项目中,可以运行 npx schema-codegen
自动生成客户端 schema
文件。
npx schema-codegen --help
输出:
schema-codegen [path/to/Schema.ts]
Usage (C#/Unity)
schema-codegen src/Schema.ts --output client-side/ --csharp --namespace MyGame.Schema
Valid options:
--output: fhe output directory for generated client-side schema files
--csharp: generate for C#/Unity
--cpp: generate for C++
--haxe: generate for Haxe
--ts: generate for TypeScript
--js: generate for JavaScript
--java: generate for Java
Optional:
--namespace: generate namespace on output code
Built-in room » Lobby Room
"大厅房间的客户端 API 将在 Colyseus 1.0.0 上更改":
- 内置的大厅房间目前依赖于发送消息来通知客户可用的房间。当
@filter()
变得稳定时,LobbyRoom
将使用state
代替。
服务器端
内置的 LobbyRoom
将自动通知其连接的客户端,每当房间 "realtime listing" 有更新。
import { LobbyRoom } from "colyseus";
// Expose the "lobby" room.
gameServer
.define("lobby", LobbyRoom);
// Expose your game room with realtime listing enabled.
gameServer
.define("your_game", YourGameRoom)
.enableRealtimeListing();
在 onCreate()
,onJoin()
,onLeave()
和 onDispose()
期间,会自动通知 LobbyRoom
。
如果你已经更新了你房间的metadata
,并且需要触发一个 lobby
的更新,你可以在元数据更新之后调用 updateLobby()
:
import { Room, updateLobby } from "colyseus";
class YourGameRoom extends Room {
onCreate() {
//
// This is just a demonstration
// on how to call `updateLobby` from your Room
//
this.clock.setTimeout(() => {
this.setMetadata({
customData: "Hello world!"
}).then(() => updateLobby(this));
}, 5000);
}
}
客户端
您需要通过从 LobbyRoom
发送给客户端的信息来跟踪正在添加、删除和更新的房间。
import { Client, RoomAvailable } from "colyseus.js";
const client = new Client("ws://localhost:2567");
const lobby = await client.joinOrCreate("lobby");
let allRooms: RoomAvailable[] = [];
lobby.onMessage("rooms", (rooms) => {
allRooms = rooms;
});
lobby.onMessage("+", ([roomId, room]) => {
const roomIndex = allRooms.findIndex((room) => room.roomId === roomId);
if (roomIndex !== -1) {
allRooms[roomIndex] = room;
} else {
allRooms.push(room);
}
});
lobby.onMessage("-", (roomId) => {
allRooms = allRooms.filter((room) => room.roomId !== roomId);
});
Built-in room » Relay Room
内置的 RelayRoom
对于简单的用例非常有用,在这些用例中,除了连接到它的客户端之外,您不需要在服务器端保存任何状态。
通过简单地中继消息(将消息从客户端转发给其他所有人) — 服务器端不能验证任何消息 — 客户端应该执行验证。
RelayRoom
的源代码非常简单。一般的建议是在您认为合适的时候使用服务器端验证来实现您自己的版本。
服务器端
import { RelayRoom } from "colyseus";
// Expose your relayed room
gameServer.define("your_relayed_room", RelayRoom, {
maxClients: 4,
allowReconnectionTime: 120
});
客户端
请参阅如何注册来自 relayed room
的玩家加入、离开、发送和接收消息的回调。
连接到房间
import { Client } from "colyseus.js";
const client = new Client("ws://localhost:2567");
//
// Join the relayed room
//
const relay = await client.joinOrCreate("your_relayed_room", {
name: "This is my name!"
});
在玩家加入和离开时注册回调
//
// Detect when a player joined the room
//
relay.state.players.onAdd = (player, sessionId) => {
if (relay.sessionId === sessionId) {
console.log("It's me!", player.name);
} else {
console.log("It's an opponent", player.name, sessionId);
}
}
//
// Detect when a player leave the room
//
relay.state.players.onRemove = (player, sessionId) => {
console.log("Opponent left!", player, sessionId);
}
//
// Detect when the connectivity of a player has changed
// (only available if you provided `allowReconnection: true` in the server-side)
//
relay.state.players.onChange = (player, sessionId) => {
if (player.connected) {
console.log("Opponent has reconnected!", player, sessionId);
} else {
console.log("Opponent has disconnected!", player, sessionId);
}
}
发送和接收消息
//
// By sending a message, all other clients will receive it under the same name
// Messages are only sent to other connected clients, never the current one.
//
relay.send("fire", {
x: 100,
y: 200
});
//
// Register a callback for messages you're interested in from other clients.
//
relay.onMessage("fire", ([sessionId, message]) => {
//
// The `sessionId` from who sent the message
//
console.log(sessionId, "sent a message!");
//
// The actual message sent by the other client
//
console.log("fire at", message);
});
Colyseus 的最佳实践
这一部分需要改进和更多的例子!每一段都需要有自己的一页,有详尽的例子和更好的解释。
- 保持你的 room 类尽可能小,没有游戏逻辑
- 使可同步的数据结构尽可能小
- 理想情况下,扩展
Schema
的每个类应该只有字段定义。 - 自定义 getter 和 setter 方法可以实现,只要它们中没有游戏逻辑。
- 理想情况下,扩展
- 你的游戏逻辑应该由其他结构来处理,例如:
- 了解如何使用命令模式。
- 一个
Entity-Component
系统。我们目前缺少一个与Colyseus
兼容的ECS
包,一些工作已经开始尝试将ECSY
与@colyseus/schema
结合起来。
为什么?
- Models (
@colyseus/schema
) 应该只包含数据,不包含游戏逻辑。 - Rooms 应该有尽可能少的代码,并将动作转发给其他结构
命令模式有几个优点,例如:
- 它将调用该操作的类与知道如何执行该操作的对象解耦。
- 它允许你通过提供一个队列系统来创建一个命令序列。
- 实现扩展来添加一个新的命令很容易,可以在不改变现有代码的情况下完成。
- 严格控制命令的调用方式和调用时间。
- 由于命令简化了代码,因此代码更易于使用、理解和测试。
用法
安装
npm install --save @colyseus/command
在您的 room
实现中初始化 dispatcher
:
import { Room } from "colyseus";
import { Dispatcher } from "@colyseus/command";
import { OnJoinCommand } from "./OnJoinCommand";
class MyRoom extends Room<YourState> {
dispatcher = new Dispatcher(this);
onCreate() {
this.setState(new YourState());
}
onJoin(client, options) {
this.dispatcher.dispatch(new OnJoinCommand(), {
sessionId: client.sessionId
});
}
onDispose() {
this.dispatcher.stop();
}
}
const colyseus = require("colyseus");
const command = require("@colyseus/command");
const OnJoinCommand = require("./OnJoinCommand");
class MyRoom extends colyseus.Room {
onCreate() {
this.dispatcher = new command.Dispatcher(this);
this.setState(new YourState());
}
onJoin(client, options) {
this.dispatcher.dispatch(new OnJoinCommand(), {
sessionId: client.sessionId
});
}
onDispose() {
this.dispatcher.stop();
}
}
命令实现的样子:
// OnJoinCommand.ts
import { Command } from "@colyseus/command";
export class OnJoinCommand extends Command<YourState, {
sessionId: string
}> {
execute({ sessionId }) {
this.state.players[sessionId] = new Player();
}
}
// OnJoinCommand.js
const command = require("@colyseus/command");
exports.OnJoinCommand = class OnJoinCommand extends command.Command {
execute({ sessionId }) {
this.state.players[sessionId] = new Player();
}
}
查看更多
Refs
中文手册同步更新在:
- https:/colyseus.hacker-linner.com
我是为少
微信:uuhells123
公众号:黑客下午茶
加我微信(互相学习交流),关注公众号(获取更多学习资料~)