Screeps 实现数据层与业务层分离
基础知识
阅读本文前,请先了解一下 Screeps存储基础知识,关系型数据库(聚集索引与非聚集索引),ORM的概念。另外,笔者使用Typescript,因此对象有强类型的概念。
可以先翻到最下面,看下最终实现的效果。
前言
Screeps中有许多跨tick存储数据的方法:Memory, Global, InterShardMemory, RawMemory, 闭包等等。笔者作为后端开发,在直接使用上面的方法书写业务代码(涉及操作游戏对象的代码)的时候,产生了强烈的违和感。这种违和感来源于业务层与数据层之间的强耦合性,最常见的情况是,业务代码没几行,数据读写操作写了一大堆(初始化,空值校验,缓存,查询等)。如果能够将业务层与数据层分离,业务层只关心业务,读写层只关心读写,将会大大提高开发的效率,降低系统的耦合性。
业务层的需求
业务层在常规读写数据时,有些共同的需求:
-
增删改查对象——类似数据库的引擎
-
查询往往通过单个或多个维度(按房间,按职责,按任务)——索引
-
强类型的对象——ORM对象映射
分层设计
分层设计.JPG考虑到Screeps和常规后端应用的差异,在数据存储上做了很大程度的简化(没有并发,没有事务,不需要锁),只满足了业务层需求的几个功能点,后续可以扩展。
对象集合管理器
功能:
-
创建对象集合及索引(默认自带主键的聚集索引,支持添加聚集或非聚集的单列索引,可以中途添加索引)
-
向集合添加对象(同时修改索引)
-
修改集合中的对象(同时修改索引)
-
从集合删除对象(同时修改索引)
-
通过id获取对象
-
通过属性获取对象(有索引时优先使用索引)
-
(开发中) 指定存储介质(global, Memory等),以及重启策略
注意事项:
-
对象集合必须包含主键列,id
-
对象集合管理器中,不存储集合的元数据(描述这个集合的数据)
-
对象集合管理器接受及返回的对象,均为包含了{id: string} 的对象,不做其他类型校验
代码示例:
// DatasetManager.ts
import { Logger } from "./Logger";
function EnsureCreated(route: string): boolean {
if (!Memory.datasets) Memory.datasets = {};
if (!Memory.datasets[route]) {
Memory.datasets[route] = {};
return false;
} else {
return true;
}
}
export class DatasetManager {
/**
* 在Memory中创建数据集
* @param {string} route 路径
* @param {Entity[]} entities 数据
* @param {IndexConfig[]} indexConfigs? 索引结构
* @param {boolean} reset? 是否清空重建
*/
public static Create(route: string, entities: Entity[], indexConfigs?: IndexConfig[], reset?: boolean) {
let created = EnsureCreated(route);
if (!created || reset) {
// 主键默认聚集索引
Memory.datasets[route]["id"] = {
clusterd: true,
data: {}
};
if (indexConfigs) {
for (const indexConfig of indexConfigs) {
Memory.datasets[route][indexConfig.indexName] = {
clusterd: indexConfig.clustered,
data: {}
};
}
}
Logger.info(`Dataset:${route} created.`, "DatasetManager", "Create");
} else {
if (indexConfigs) {
let currentIndexes = Object.keys(Memory.datasets[route]);
let newIndexConfigs = indexConfigs.filter(config => !currentIndexes.includes(config.indexName));
if (newIndexConfigs.length > 0) {
const entities = _.flattenDeep(Object.values(Memory.datasets[route]["id"].data));
for (const indexConfig of newIndexConfigs) {
Memory.datasets[route][indexConfig.indexName] = {
clusterd: indexConfig.clustered,
data: {}
};
}
for (const entity of entities) {
for (const indexConfig of newIndexConfigs) {
const indexName = indexConfig.indexName;
const value = (entity as any)[indexName];
if (value == undefined) {
continue;
}
const index = Memory.datasets[route][indexName];
if (index.clusterd) {
// 聚集索引全量存储
if (index.data[value]) {
index.data[value].push(entity);
} else {
index.data[value] = [entity];
}
} else {
// 非聚集只存储主键列
if (index.data[value]) {
index.data[value].push(entity.id);
} else {
index.data[value] = [entity.id];
}
}
}
}
}
}
}
// 插入数据
for (const entity of entities) {
this.Add(route, entity);
}
}
/**
* 向数据集添加数据
* @param {string} route
* @param {Entity} entity
*/
public static Add(route: string, entity: Entity) {
let created = EnsureCreated(route);
if (!created) {
Logger.error(`Adding ${JSON.stringify(entity)} to a non-existing dataset:${route}`, "DatasetManager", "Add");
return;
}
const indexNames = Object.keys(Memory.datasets[route]);
for (const indexName of indexNames) {
const value = (entity as any)[indexName];
if (value == undefined) {
continue;
}
const index = Memory.datasets[route][indexName];
if (index.clusterd) {
// 聚集索引全量存储
if (index.data[value]) {
index.data[value].push(entity);
} else {
index.data[value] = [entity];
}
} else {
// 非聚集只存储主键列
if (index.data[value]) {
index.data[value].push(entity.id);
} else {
index.data[value] = [entity.id];
}
}
}
}
public static Remove(route: string, entity: Entity) {
let created = EnsureCreated(route);
if (!created) {
Logger.error(`Removing ${JSON.stringify(entity)} from a non-existing dataset:${route}`, "DatasetManager", "Remove");
return;
}
const indexNames = Object.keys(Memory.datasets[route]);
for (const indexName of indexNames) {
const value = (entity as any)[indexName];
const index = Memory.datasets[route][indexName];
// 空值或者数据不存在
if (!value || !index.data[value]) {
continue;
}
if (indexName == "id") {
// 主键直接删除记录
delete index.data[value];
} else {
//index.data[value] = _.filter(index.data[value],(id)=>{id != entity.id})
_.remove(index.data[value], (x) => (x == entity.id));
if (index.data[value].length == 0) delete index.data[value];
}
}
}
public static Update(route: string, entity: Entity) {
let created = EnsureCreated(route);
if (!created) {
Logger.error(`Updating ${JSON.stringify(entity)} from a non-existing dataset:${route}`, "DatasetManager", "Update");
return;
}
const indexNames = Object.keys(Memory.datasets[route]);
for (const indexName of indexNames) {
const value = (entity as any)[indexName];
const index = Memory.datasets[route][indexName];
// 空值或者数据不存在
if (!value || !index.data[value]) {
continue;
}
// 只更新聚集索引
if (!index.clusterd) {
continue;
}
if (indexName == "id") {
index.data[value] = [entity]
} else {
index.data[value] = _.remove(index.data[value], (x) => (x.id == entity.id));
index.data[value].push(entity);
}
}
}
public static GetById<T>(route: string, id: string): T | undefined {
EnsureCreated(route);
if (Memory.datasets[route]["id"]) {
let result = Memory.datasets[route]["id"].data[id];
return result[0] as T;
}
return undefined;
}
public static GetByProperty<T>(route: string, property: string, value: any): T[] {
EnsureCreated(route);
const index = Memory.datasets[route][property];
if (index) {
let result = Memory.datasets[route][property].data[value];
if (!result) return [];
// 聚集的直接返回
if (index.clusterd) {
return result as T[];
} else {
let lookup = [];
// 非聚集联查id索引
for (const id of result as string[]) {
let entity = Memory.datasets[route]["id"].data[id][0];
lookup.push(entity);
}
return lookup as T[];
}
} else {
if (Memory.datasets[route]["id"]) {
let data = _.flattenDeep(Object.values(Memory.datasets[route]["id"].data));
return data.filter(e => e[property] == value) as T[];
} else {
return [] as T[];
}
}
}
public static GetByProperties<T>(route: string, pairs: { property: string, value: any }[]): T[] {
// 多条件联查,贼复杂,先不整了。
EnsureCreated(route);
if (pairs.length == 0) return [] as T[];
if (Memory.datasets[route]["id"]) {
let result = this.GetByProperty<T>(route, pairs[0].property, pairs[1].property) as any[];
for (let i = 1; i < pairs.length; i++) {
const pair = pairs[i];
result = result.filter((e) => e[pair.property] == pair.value);
}
return result as T[];
} else {
return [] as T[];
}
}
}
业务上下文
功能:
-
定义对象集合的数据结构,索引
-
初始化对象集合
-
封装增删改查接口,出入参设置为强类型对象(这步相当于ORM)
-
索引列单独设置查询方法
-
强类型对象的快捷创建方法
代码示例:
// StoreInTransitContext.ts
import { DatasetManager } from "../utils/DatasetManager";
import { ContextBase } from "./ContextBase";
interface StoreInTransit extends Entity {
gameObjectId: string,
taskId: string,
direction: "in" | "out",
resourceType: ResourceConstant,
amount: number,
createTick: number
}
export class StoreInTransitContext extends ContextBase {
static route: string = "storeInTransit";
public static Initialize() {
DatasetManager.Create(this.route, [], [{
// 查询储量必定使用,高频率,直接聚集索引
clustered: true,
indexName: "gameObjectId"
}, {
clustered: false,
indexName: "taskId"
}, {
clustered: false,
indexName: "createTick"
}], false);
}
public static Get(id: string) {
return DatasetManager.GetById<StoreInTransit>(this.route, id);
}
public static Add(entity: StoreInTransit) {
DatasetManager.Add(this.route, entity);
}
public static Remove(entity: StoreInTransit) {
DatasetManager.Remove(this.route, entity);
}
public static Update(entity: StoreInTransit) {
DatasetManager.Update(this.route, entity);
}
public static GetByGameObjectId(gameObjectId: string) {
return DatasetManager.GetByProperty<StoreInTransit>(this.route, "gameObjectId", gameObjectId);
}
public static GetByTaskId(taskId: string) {
return DatasetManager.GetByProperty<StoreInTransit>(this.route, "taskId", taskId);
}
}
哪些列需要索引?如何选择聚集索引与非聚集索引?
索引本质上是空间换取时间的策略,索引能够加速查询,但是会影响增删改的效率。一般情况下,数据的查询次数远大于数据修改的次数,所以对于常用的查询维度,都推荐使用索引。
这些常用的维度中,必定使用到的维度,可以直接建立聚集索引,而频率较低的维度,使用非聚集索引。值得注意的一点是,如果使用了Memory作为存储介质,Memory每tick都会序列化和反序列化,如果使用了聚集索引,对象集合的大小会增加一倍,序列化反序列化的时间也会增加,在总的cpu消耗上是否划算,还需要后续的测试。如果使用的是global则不太需要考虑数据量的问题,但是需要考虑数据丢失重启的问题。
业务层调用业务上下文方法
代码示例:
// test.ts
// 初始化上下文(建表建索引,新增索引)
StoreInTransitContext.Initialize();
const mySpawns = GameContext.mySpawns;
if (mySpawns.length >= 2) {
let spawn1 = mySpawns[0];
let spawn2 = mySpawns[1];
// 创建对象
let store1 = StoreInTransitContext.Create(spawn1.id, "task1", "out", "energy", 300);
let store2 = StoreInTransitContext.Create(spawn2.id, "task2", "out", "energy", 100);
StoreInTransitContext.Add(store1);
StoreInTransitContext.Add(store2);
// 获取对象
let store3 = StoreInTransitContext.Get(store1.id);
if (store3) {
// 修改对象
store3.taskId = "task1";
store3.amount = 500;
StoreInTransitContext.Update(store3);
}
// 通过索引获取对象,这里会返回[store2,store3]
let stores = StoreInTransitContext.GetByTaskId("task1");
// 移除对象
StoreInTransitContext.Remove(store2);
}
从上面的代码,可以看出一些直观的好处:
-
索引可配置,可以中途添加,非常灵活。
-
对象增删改时,索引自动更新,不需要任何额外处理。
-
可以统一做查询优化(类似于 query optimizer),针对多维度的查询,可以使用更优的索引组合。
-
实现了业务层和数据层之间的解耦合。
对本文有任何意见或者建议,或者有任何处理数据层的心得,都欢迎评论留言,互相交流探讨。