Screeps 游戏指南

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

2021-07-23  本文已影响0人  masterkeze

基础知识

阅读本文前,请先了解一下 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. 实现了业务层和数据层之间的解耦合。

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

上一篇下一篇

猜你喜欢

热点阅读