这算是 TypeScript 的奇技淫巧吗?算是吧,有什么价值?为了印证屎山是香的这一说法,必须要应用这些工程技巧。总之,无论碰到多么高的屎山,都应该遵守开闭原则去扩展,而不应该推倒重写。

上次在生产环境中应用了一种奇怪的作法,对一个老项目进行扩展:发现一些不同的类之间有某些共同的特点,为了不改已有代码,就通过在新的文件中定义了一个新的方法来实现对不同类的通用扩展。目前已经上线一年多了,仍在平稳运行:

需求

还是这种老项目,它大量使用了 AWS DynamoDB,并使用一个库封装了对 DynamoDB 的常用操作,比如增删改查等等。项目中各种其他的类,都是继承自这个 库的 BaseDynamoDbService。然而,在最近的一个需求中,得删除已有表中的主键对应的所有记录,但这个方法在库中没有。写这个方法本身不难,令人纠结的是放在哪个地方?想来想去,还是觉得放在 BaseDynamoDbService 中最自然,纠结是因为这个 BaseDynamoDbService 文件在库中。

我想起 C# 中的 partial class,它支持将同一个类的不同方法分散存储在不同的文件中,据说 TypeScript 借鉴了很多 C# 的设计,那么 TypeScript 有没有对 partial class 的支持呢?然而,目前还没有原生的支持。

分析

既然没有原生支持,就只能另辟蹊径了。TypeScript 本质上还是 JavaScript,而 JavaScript 是非常灵活的。别说要扩展库中的类,就是要扩展语言中内置的类,也是非常容易的,那就是使用 prototype。要给哪个类扩展新方法,只需要把该方法写在该类的 prototype 上即可。要用在 TypeScript 当中,并获得类型支持和智能提示的话,可以使用 interface。

解决方案

为了不修改库代码,但是又要给项目中众多继承了 BaseDynamoDbService 的类赋予新的方法,可以新建一个文件,比如叫做 base.service.ts,然后在这个文件里实现对 BaseDynamoDbService 的扩展。先将库中的 BaseDynamoDbService 导入,然后以库的名称声明一个模块,并在这个模块下定义一个和 BaseDynamoDbService 同名的接口。在这个接口里,声明一下要扩展的方法。最后,通过 prototype 的形式,去实现这些新的方法。

最后的扩展文件内容结构如下:

typescript import { BaseDynamoDbService } from @/common/base

declare module @/common/base { export interface BaseDynamoDbService { newMethod(param) } }

BaseDynamoDbService.prototype.newMethod = function(param) { }

具体代码改动

对于这个具体的例子,我们要实现根据主键搜索出来的所有记录,并且将它们全部删除的功能。

测试先行

我们先建立一个测试文件。为了更通用,准备先扩展一个方法,从 DynamoDb Table 的 AttributeMap 对象中获取主键信息,从而不需要手动指定 Key,准备给这个方法起名叫 getKeyFromAttributeMap,就先来测试这个方法,给定一个假的 DynamoDb Table 的 AttributeMap,验证可以正确获取到 Key。这里以一个名字叫做 PostService 的服务为例,它是继承自 BaseDynamoDbService 的一个子类,大致长这样: typescript import { AttributeMap, CreateTableInput, ScanInput, UpdateTimeToLiveInput } from aws-sdk/clients/dynamodb

export class PostService extends BaseDynamoService { constructor(private readonly awsService: AWSService) { super(post-table, awsService) }

protected getTableConfig(): Partial { const TableName = this.table const AttributeDefinitions = [ { AttributeName: operationType, AttributeType: S, }, { AttributeName: postId, AttributeType: S, }, ] const KeySchema = [ { AttributeName: operationType, KeyType: HASH, }, { AttributeName: postId, KeyType: RANGE, }, ] return { TableName, AttributeDefinitions, KeySchema } } }

每个表,都会实现 BaseDynamoDbService 的 getTableConfig 方法,以获取一些基本配置信息,其中就包括了 KeySchema。于是测试用例就是通过传入一个 AttributeMap 实例,期待得到 getTableConfig 指定的 KeySchema 实例:

typescript describe(PostService, () => { const ps = new PostService(mockedAws)

it(gets key from attribute map, async () => {
    const map = {
        operationType: {
            S: xyz,
        },
        operationId: {
            S: abc,
        },
        scenarioType: {
            S: def,
        },
        postId: {
            S: fakeId,
        },
        memberId: {
            S: 123,
        },
    }

    const key = ps.getKeyFromAttributeMap(map)
    expect(key).toStrictEqual({
        operationType: {
            S: xyz,
        },
        postId: {
            S: fakeId,
        },
    })

}

}

对 getKeyFromAttributeMap 的实现

直接测试肯定是失败的,因为 BaseDynamoDbService 里根本没有 getKeyFromAttributeMap 这个方法。如前所述,我们准备将这个方法定义在一个新文件中,所以要让测试通过,不仅需要定义出这个方法,还要注意,将 PostService 文件中的对 BaseDynamoDbService 的引用,改成从这个新文件中引用。好在,由于我们扩展后这个基类还是同一个类,因此不需要修改其他部分。

typescript // 从库中引入要扩展的基类 import { BaseDynamoDbService } from @/common/base

// 以这个库名作为模块名 declare module @/common/base { // 以基类的名称作为接口名 export interface BaseDynamoDbService { // 声明新的扩展方法 getKeyFromAttributeMap(map) } }

// 用 Prototype 方式实现新的方法 BaseDynamoService.prototype.getKeyFromAttributeMap = function (map) { return this.getTableConfig() // 由于我们要扩展的是同一个类,可以直接用 this 引用该类中的已有方法 .KeySchema?.map((k) => ({ key: k.AttributeName, value: map[k.AttributeName] })) .reduce((prev, next) => { prev[next.key] = next.value return prev }, {}) }

在这个测试通过后,就验证了这种方式的确可以正常工作。

接下来就实现删除所有查询到的结果部分,仍然先写相关的测试。

迭代对 aws dynamodb 的 mock

其实呀,看以上的测试用例非常简单,但实际上有个挑战,就是代码对 AWS sdk 是有依赖的,具体地说是依赖 aws sdk 中的 dynamodb。但是这个挑战在之前的文章中已经部分克服了:

但是现在要写关于删除元素的测试了,这是之前没有涵盖到的,现在需要了,就来先完善一下这个 dynamodb 的 mock。实际上,对这个 mock 的完善也不是一步到位的,比如在用到了分页查询时,才增加 mock 中对于 query 的分页查询支持,不过为了省掉这些啰嗦的细节,这里直接给出相对上次,对这个 mock 做的改动:

首先增加了 deleteItem 的 mock 实现,注意,由于我们的测试并不需要真正的删除元素,只是模拟返回被删除的元素,故这个实现是直接返回查询到的结果: typescript deleteItem: jest.fn().mockImplementation((params) => { return { promise: async () => { // 直接返回一个查询的返回结果 return await mockDynamoDB.query(params) }, } }),

然后将 query 的 mock 改成了:

typescript query: jest.fn().mockImplementation((params) => { return { promise: () => { const res = mockDynamoDB.items.filter( queryByOperationKeyAndScenarioType( params.ExpressionAttributeValues[:opKey], params.ExpressionAttributeValues[:scType] ) )

            if (params.ExclusiveStartKey) {
                return Promise.resolve({
                    Items: res,
                })
            }

            return Promise.resolve({
                Items: res,
                // 这个逻辑是为了增加分页而加的,但是很简陋,一切为了测试需要,没有实现额外多余的逻辑
                LastEvaluatedKey: res.length < mockDynamoDB.items.length ? res.length + 1 : null,
            })
        },
    }
}

添加一个继承自基类的测试子类

有了 dynamodb mock 的增强,现在写一个删除一页数据的测试。因为删除数据是扩展到 BaseDynamoDbService 这个基类上的,所以我们期待它可以应用在任意继承于 BaseDynamoDbService 的子类中。于是先写一个测试类,继承自 BaseDynamoDbService: typescript import BaseDynamoService from ./base.service import { Mock } from ts-mockery import { DynamoDB } from aws-sdk // 增强后的 dynamoDb mock import { mockDynamoDB } from ../../test/mocks/aws

// 这里 mockAwsService 同前一个测试文件中的 const mockAwsService = Mock.of({ async getDynamoDB(): Promise { return mockDynamoDB }, })

class SUT extends BaseDynamoDbService { constructor() { super(test-table, mockAwsService) }

// 这是基类中声明的抽象方法,在子类中必须给出具体实现
protected getTableConfig(): Partial<DynamoDB.CreateTableInput> {
    const TableName = this.table
    const AttributeDefinitions = [
        {
            AttributeName: operationKey,
            AttributeType: S,
        },
        {
            AttributeName: scenarioType,
            AttributeType: S,
        },
    ]
    const KeySchema = [
        {
            AttributeName: operationKey,
            KeyType: HASH,
        },
        {
            AttributeName: scenarioType,
            KeyType: RANGE,
        },
    ]
    return { TableName, AttributeDefinitions, KeySchema }
}

// 这是基类中声明的抽象方法,在子类中必须给出具体实现
protected toAttributeMap(record): DynamoDB.AttributeMap {
    // 这里用到的 toS 等方法,是库定义在 BaseDynamoDbService 中的,做了一些字段封装
    return {
        operationKey: this.toS(record.operationKey),
        scenarioType: this.toS(record.scenarioType),
    }
}

// 这是基类中声明的抽象方法,在子类中必须给出具体实现
protected toInstance(item: DynamoDB.AttributeMap) {
    return { operationKey: item.operationKey.S, scenarioType: item.scenarioType.S }
}

}

测试删除一页数据

有了这个测试子类,我们先测试删除第一页数据,aws sdk 对于查询出的数据,如果一页查询完毕了,是不返回 nextFrom 的,代表没有更多数据了。用例如下:

typescript it(deletes one page without nextFrom, async () => { // 准备一个只有一个元素的伪表 mockDynamoDB.items = [ { operationKey: { S: key1, }, scenarioType: { S: type1, }, }, ]

const sut = new SUT()

const res = await sut.deleteOnePage(
    {
        ExpressionAttributeValues: {
            :opKey: { S: key1 },
            :scType: { S: type1 },
        },
    },
    // 每页1个元素
    1
)

expect(res).toEqual(null)

})

然后,实现一个基本的删除一页的实现代码,但是描述起来有点过于烦琐。直接上第二个测试吧,测试查询到的数据多于一页的场景,这时,aws 会返回一个 nextFrom 值,以备应用程序再去查下一页时,可以从这一条记录开始去查询。用例如下:

typescript it(deletes one page with nextFrom, async () => { // 准备两个元素的伪表 mockDynamoDB.items = [ { operationKey: { S: key1, }, scenarioType: { S: type1, }, },

    {
        operationKey: {
            S: key2,
        },
        scenarioType: {
            S: type2,
        },
    },
]

const sut = new SUT()

const res = await sut.deleteOnePage(
    {
        ExpressionAttributeValues: {
            :opKey: { S: key1 },
            :scType: { S: type1 },
        },
    },
    // 每页查询一条数据 
    1
)

// 这个 Mg== 是基类中的方法将 aws 返回的数据计算出来的哈希值。
// 由于每页只查询1条数据,而我们的伪表中有2条,所以一页查询不完,期待 aws 会返回 nextFrom
expect(res).toEqual(Mg==)

})

实现删除一页数据

由测试用例可以看出,我们的删除一页数据,期待的入参是主键值,以及每页大小。返回的是查询结果中 aws 返回的 nextFrom 值,以指示是否还有更多数据(待删除)。在前面的 base.service.ts 中添加如下代码:

typescript ... export interface BaseDynamoDbService { getKeyFromAttributeMap(map)

// 第三个参数 from 可选。如果传了,就从这个位置开始查询并删除
deleteOnePage(params, pageSize, from?)

} ...

BaseDynamoDbService.prototype.deleteOnePage = async function (params, pageSize, from?) { const onePageOfRecords = await this.queryTable(params, pageSize, from)

onePageOfRecords.data.forEach((record) => {
    const deletingParams = { Key: this.getKeyFromAttributeMap(this.toAttributeMap(record)) }

    return this.deleteItem(deletingParams)
        .then((res) => logDebug(删除了: , record, res))
        .catch((err) => logError(尝试删除 , params, pageSize, from,  时碰到错误: , err))
})

return onePageOfRecords.nextFrom

}

测试删除所有数据

由于这里是先查询出记录,再去做删除。而查询又是分页查询的,所以要么使用递归要么使用循环,去一页一页地删除数据。无论怎么实现,测试用例都一样:

typescript it(deletes all pages, async () => { // 还是准备了有两条记录的伪表 mockDynamoDB.items = [ { operationKey: { S: key1, }, scenarioType: { S: type1, }, },

    {
        operationKey: {
            S: key1,
        },
        scenarioType: {
            S: type1,
        },
    },
]

// 复位 deleteItem 的计数
mockDynamoDB.deleteItem.mockReset()

const sut = new SUT()
const res = await sut.deleteAllPages(
    {
        ExpressionAttributeValues: {
            :opKey: { S: key1 },
            :scType: { S: type1 },
        },
    },
    // 通过指定页大小为1,就验证了多页场景
    1
)

expect(res).toEqual(undefined)

// 因为有两页,每页一条数据,我们验证删除元素的操作被执行了两次,以证明每页的删除操作都执行了
expect(mockDynamoDB.deleteItem).toHaveBeenCalledTimes(2)

})

实现多页删除

本来使用了 while 循环来实现,后来还是改成了递归,发现代码更简洁。即先实现一个刚好通过测试的代码,在重构代码环节,改成了递归。最终在 base.service.ts 中添加了这样的代码:

typescript ... export interface BaseDynamoDbService { getKeyFromAttributeMap(map)

deleteOnePage(params, pageSize, from?)

deleteAllPages(params, pageSize, from?)

} ...

BaseDynamoDbService.prototype.deleteAllPages = async function (params, pageSize, from?) { const nextFrom = await this.deleteOnePage(params, pageSize, from)

if (nextFrom) {
    // 递归调用
    return await this.deleteAllPages(params, pageSize, nextFrom)
}

return undefined

}

上线

就这么有信心地上线了,通过查看日志输出,新写的逻辑如期运行了。毕竟:
image.png

最后的思考🤔

TypeScript 或者说 JavaScript 使用 prototype 可以轻松实现 partial class 的效果。那么 C# 是怎么实现的呢?现在很好奇,如果你知道,欢迎留言告诉我。

警告

在 JavaScript 中使用 prototype 简直可以为所欲为。比如在 JavaScript 对 class 提供支持之前,就有通过 prototype 的方式来实现类和继承。这里又利用它实现了 C#
中的 partial class 效果,但是不要总是使用它!

我曾经在开源库 flot 做一个 feature 时,也使用了对其中的类的 prototype 做扩展方式来增加新功能,但是被 flot 的维护者(在谷歌工作的大神)指出虽然 99% 的情况下没有问题,但仍然建议直接定义新的函数,而不要去污染该类的 prototype。这个建议非常中肯,有时候 prototype 被修改了而别的开发者不知道,就会有“意外的惊喜”。
image.png

image.png

image.png