[英]Typescript typing for decorator middleware patterns
我正在考虑 Node 中间件(在各种框架中)。 通常中间件会在请求或响应 object 中添加一个属性,然后任何注册的中间件都可以使用该属性。
这个 model 的一个问题是你没有得到有效的打字。
为了演示,这里有一个高度简化的 model 这样的框架。 在这种情况下,一切都是同步的,每个中间件都会收到一个请求,并且还必须返回一个(可能会改变)。
interface Req {
path: string;
method: string;
}
type Middleware = (request: Req) => Req;
class App {
mws: Middleware[] = [];
use(mw: Middleware) {
this.mws.push(mw);
}
run(): Req {
let request = {
path: '/foo',
method: 'POST'
}
for(const mw of this.mws) {
request = mw(request);
}
return request;
}
}
为了演示应用程序,我定义了 2 个中间件。 第一个向Req
添加一个新属性,第二个使用该属性:
const app = new App();
app.use( req => {
req.foo = 'bar';
});
app.use( req => {
console.log(req.foo);
}
这是服务器端 Javascript 框架中非常常见的模式。 不变这会引发错误:
Property 'foo' does not exist on type 'Req'.
对此的标准解决方案是使用接口声明合并:
interface Req {
path: string;
method: string;
}
interface Req {
foo: string;
}
不过这也有缺点。 这全局性地改变了类型,我们实际上是在对 typescript 撒谎。 在调用第一个中间件之前,Req 类型将没有.foo
属性。
所以,我认为这只是 Express、Koa、Curveball 中的“本来面目”,但我想知道如果我们编写一个新框架是否有可能采用不同的模式。
我认为不可能实现这个伪代码示例:
app.use(mw1); // Uses default signature
app.use(mw2); // Type of `req` is now the return type of `mw1`.
我认为链接可能是可能的:
app
.use(mw1)
.use(mw2);
但我很难完全掌握如何。 另外,如果有next
function,它会变得更加复杂,但为了这个问题,让我们忽略它。
更广泛地说,我的问题是......当一个伟大的基于服务器端中间件的框架是专门为 Typescript 编写的时,它会是什么样子。 或者,也许还有其他更有效的模式,我只是太盲目了,看不到它。
有趣的问题,我没有完整的答案,但我认为有一些方法可以解决这个问题。
第一种方法
如果你定义的中间件更通用一点,你可以说它是一个简单的 function:
type Middleware<In, Out> = (input: In) => Out
在 HTTP 环境中,您将使用某种包含请求数据作为输入的上下文:
interface InitialContext {
method: 'GET' | 'POST'
path: string
query?: Record<string, string>
headers?: Record<string, string>
}
我会说第一个中间件元素应该将InitialContext
转换为其他内容。 如果你想要灵活,它可以是任何东西。 更实际地,您可能想要扩展InitialContext
。 现在我只坚持中间件可以将InitialContext
转换为任何东西的想法,下一个中间件需要处理这个问题。
一种方法可能是只创建一个 function,它接受许多中间件函数并返回最终中间件的结果。 您只需根据需要添加任意数量的中间件:
function requestHandler<
M1,
M2,
M3,
M4,
>(
context: InitialContext,
middleware1?: Middleware<InitialContext, M1>,
middleware2?: Middleware<M1, M2>,
middleware3?: Middleware<M2, M3>,
middleware4?: Middleware<M3, M4>,
) {
if (!middleware1) return context
if (!middleware2) return middleware1(context)
if (!middleware3) return middleware2(middleware1(context))
if (!middleware4) return middleware3(middleware2(middleware1(context)))
return middleware4(middleware3(middleware2(middleware1(context))))
}
这仅限于 4,但当然可以使用一些代码生成扩展到任何有限数。 有用:
const m1 = (s: InitialContext) => s.path
const m2 = (s: string) => Number(s)
const m3 = (s: number) => `${s}${s}`
const response = requestHandler({
method: 'GET',
path: '/blabla'
}, m1, m2, m3)
这种方法的好处是,如果您的第二个中间件不能与您的第一个中间件的 output 一起使用,它会告诉您。 这个错误会很清楚。
除了必须单独写出每个中间件参数之外,缺点是 output 类型unknown
。 如果您事先不知道将注入多少个中间件,这很难解决。
第二种方法,“减少”中间件
因此,下一个问题可能是:是否可以确定 N 个中间件的“总”类型,组合起来。 回顾我们之前的中间件:
const m1 = (s: InitialContext) => s.path
const m2 = (s: string) => Number(s)
const m3 = (s: number) => `${s}${s}`
你可以说这 3 部分的组合类型是Middleware<InitialContext, string>
因为第一个 function 有InitialContext
作为输入,最后一个有string
作为 output。
将它们与如下所示的 function 结合是可能的:
function reduceMiddleware<T extends Middleware<any, any>[]>(...middleware: T) {
return middleware.reduce((a, b) => {
return ((input: any) => b(a(input)))
}) as any
}
我们可以给这个 function 一个 output 类型吗?
我们可以使用infer
和条件类型将两种中间件类型组合在一起:
type TwoIntoOne<T> = T extends [Middleware<infer A, any>, Middleware<any, infer B>] ? Middleware<A, B> : unknown
使用以下方法对此进行测试:
type Combined = TwoIntoOne<[Middleware<string, number>, Middleware<number, Date>]>
会给我们(input: string) => Date
或Middleware<string, Date>
作为 output。
但是,我们需要组合的不是 2 个而是 N 个中间件。 这意味着我们需要某种循环,或者我们一起减少类型。 减少可以使用递归来实现,而递归是 Typescript 类型所支持的!
因此,我们可以使用递归来创建将 N 个类型组合成一个类型的类型:
type Combine<T> = T extends [Middleware<infer Ain, infer Aout>, Middleware<infer Aout, infer Bout>, ... infer Rest] ?
Combine<[Middleware<Ain, Bout>, ...Rest]> : T
这个最终类型将被包装在一个数组中,我们可以做一个简单的调整来解开它:
type Unwrap<T> = T extends [infer A] ? A : unknown
type Combine<T> = T extends [Middleware<infer Ain, infer Aout>, Middleware<infer Aout, infer Bout>, ... infer Rest] ?
Combine<[Middleware<Ain, Bout>, ...Rest]> :
Unwrap<T>
我们可以在我们之前制作的 function 中使用这个泛型:
function reduceMiddleware<T extends Middleware<any, any>[]>(...middleware: T): Combine<T> {
return middleware.reduce((a, b) => {
return ((input: any) => b(a(input)))
}) as any
}
但是,请注意,在内部这个 function 不是类型安全的。 我们必须用 any 覆盖结果才能在没有编译器错误的情况下运行它。 但是,这样做可以让我们在代码的 rest 中具有类型安全性:
const m1 = (s: InitialContext) => s.path
const m2 = (s: string) => Number(s)
const m3 = (s: number) => `${s}${s}`
const combined = reduceMiddleware(
m1, m2, m3
)
// Calculated type:
// const combined: Middleware<InitialContext, string>
我们可以简单地使用这个中间件直接使用一些上下文作为输入:
const output = combined({
method: 'GET',
path: '/blabla'
})
或者我们可以设置一些更大的应用程序,使用这个中间件来处理请求/响应周期。
请注意,尽管此计算类型将检查中间件的输入/输出是否匹配,但它不会真正给您任何错误消息。 它会给你未知的类型,当你使用组合的 function 时,它可能会给你一些类型错误。 没有任何类型提示可以帮助您解决这个问题,所以肯定有改进的方法。
所以回答你的问题:是的,有一些选项可以以更类型安全的方式使用中间件。 在 OOP 方法中结合这一点可能会很棘手,但使用单个中间件而不是 N 已经使这更加可行。
一个非常简单的方法是在构造函数中接受一个中间件,并期望用户将中间件 reducer 作为一个单独的步骤使用:
const combined = reduceMiddleware(
m1, m2, m3
)
class App<In, Out> {
middleware: Middleware<In, Out>
constructor(middleware: Middleware<In, Out>) {
this.middleware = middleware
}
}
const app = new App(combined)
// Calculated type:
// const app: App<InitialContext, string>
声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.