Screeps 实现数据层与业务层分离

基础知识

阅读本文前,请先了解一下 Screeps存储基础知识,关系型数据库(聚集索引与非聚集索引),ORM的概念。另外,笔者使用Typescript,因此对象有强类型的概念。

可以先翻到最下面,看下最终实现的效果。

前言

Screeps中有许多跨tick存储数据的方法:Memory, Global, InterShardMemory, RawMemory, 闭包等等。笔者作为后端开发,在直接使用上面的方法书写业务代码(涉及操作游戏对象的代码)的时候,产生了强烈的违和感。这种违和感来源于业务层与数据层之间的强耦合性,最常见的情况是,业务代码没几行,数据读写操作写了一大堆(初始化,空值校验,缓存,查询等)。如果能够将业务层与数据层分离,业务层只关心业务,读写层只关心读写,将会大大提高开发的效率,降低系统的耦合性

业务层的需求

业务层在常规读写数据时,有些共同的需求:

  1. 增删改查对象——类似数据库的引擎

  2. 查询往往通过单个或多个维度(按房间,按职责,按任务)——索引

  3. 强类型的对象——ORM对象映射

分层设计

分层设计.JPG

考虑到Screeps和常规后端应用的差异,在数据存储上做了很大程度的简化(没有并发,没有事务,不需要锁),只满足了业务层需求的几个功能点,后续可以扩展。

对象集合管理器

功能:

  1. 创建对象集合及索引(默认自带主键的聚集索引,支持添加聚集或非聚集的单列索引,可以中途添加索引)

  2. 向集合添加对象(同时修改索引)

  3. 修改集合中的对象(同时修改索引)

  4. 从集合删除对象(同时修改索引)

  5. 通过id获取对象

  6. 通过属性获取对象(有索引时优先使用索引)

  7. (开发中) 指定存储介质(global, Memory等),以及重启策略

注意事项:

  1. 对象集合必须包含主键列,id

  2. 对象集合管理器中,不存储集合的元数据(描述这个集合的数据)

  3. 对象集合管理器接受及返回的对象,均为包含了{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[];
        }
    }
}

业务上下文

功能:

  1. 定义对象集合的数据结构,索引

  2. 初始化对象集合

  3. 封装增删改查接口,出入参设置为强类型对象(这步相当于ORM)

  4. 索引列单独设置查询方法

  5. 强类型对象的快捷创建方法

代码示例:

// 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);
}

从上面的代码,可以看出一些直观的好处:

  1. 索引可配置,可以中途添加,非常灵活。

  2. 对象增删改时,索引自动更新,不需要任何额外处理。

  3. 可以统一做查询优化(类似于 query optimizer),针对多维度的查询,可以使用更优的索引组合。

  4. 实现了业务层和数据层之间的解耦合。

对本文有任何意见或者建议,或者有任何处理数据层的心得,都欢迎评论留言,互相交流探讨。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,383评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,522评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,852评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,621评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,741评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,929评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,076评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,803评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,265评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,582评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,716评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,395评论 4 333
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,039评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,798评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,027评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,488评论 2 361
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,612评论 2 350

推荐阅读更多精彩内容