问题描述
我正在考虑Node中间件(在各种框架中).中间件通常会向请求或响应对象添加属性,然后在其后注册的任何中间件都可以使用该属性.
I am thinking about Node middlewares (in various frameworks). Often middlewares will add a property to a request or response object, which is then available for usage by any middlewares registered after it.
此模型的一个问题是您无法有效输入.
One issue with this model is that you don't get effective typing.
为了演示,这里是这种框架的高度简化的模型.在这种情况下,所有事物都是同步的,每个中间件都收到一个请求,并且还必须返回一个(可能已更改).
To demonstrate, here's a highly simplified model of such a framework. In this case, everything in synchronous, and each middleware receives a Request and must also return one (potentially altered).
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
添加新属性,第二个使用该属性:
To demonstrate the application, I'm defining 2 middlewares. The first one adds a new property to Req
and the second one uses that property:
const app = new App();
app.use( req => {
req.foo = 'bar';
});
app.use( req => {
console.log(req.foo);
}
这是服务器端Javascript框架中非常常见的模式.不变地会引发错误:
This is a very common pattern in server-side Javascript frameworks. Unaltered this throws an error:
Property 'foo' does not exist on type 'Req'.
对此的标准解决方案是使用接口声明合并:
The standard solution to this is using interface declaration merging:
interface Req {
path: string;
method: string;
}
interface Req {
foo: string;
}
但这有缺点.这在全局范围内对类型进行了更改,而我们实际上是在对打字稿撒谎.在调用第一个中间件之前,Req类型将没有 .foo
属性.
This has drawbacks though. This globally makes the change to the types, and we're actually lying to typescript. The Req type will not have the .foo
property until the first middleware is called.
所以,我认为这只是Express,Koa,Curveball中的样子",但是我想知道如果我们编写一个新的框架是否可以使用其他模式.
So, I think this is just 'the way it is' in Express, Koa, Curveball, but I'm wondering if a different pattern is possible if we wrote a new framework.
我认为不可能实现此伪代码示例:
I don't think it's possible to implement this pseudo code example:
app.use(mw1); // Uses default signature
app.use(mw2); // Type of `req` is now the return type of `mw1`.
我认为通过链接有可能 :
I'm thinking it might be possible with chaining:
app
.use(mw1)
.use(mw2);
但是我很难完全掌握如何做.另外,如果有 next
函数,它会变得更加复杂,但是为了解决这个问题,请忽略它.
But I'm having a really hard time fully grasping how. Plus it gets even more complex if there is a next
function, but lets leave that out for the sake of this question.
更广泛地讲,我的问题是...当特别考虑到Typescript编写时,基于服务器端中间件的出色框架会是什么样子?另外,也许还有其他更有效的模式,但我实在是太盲目看不到了.
More broadly, my question is ... what would a great server-side middleware-based framework look like, when it's written specifically with Typescript in mind. Alternatively, maybe there are other patterns that are even more effective and I'm just too blind to see it.
推荐答案
有趣的问题,我没有一个完整的答案,但是我认为有一些方法可以解决这个问题.
Interesting question, I don't have a full answer but I think there are some ways to approach this.
第一种方法
如果您定义的中间件更加通用,则可以说这是一个简单的功能:
If you'd define middleware a bit more generic, you could just say it's a simple function:
type Middleware<In, Out> = (input: In) => Out
在HTTP环境中,您将使用某种包含请求数据作为输入的上下文:
In an HTTP environment you would use some sort of context that contains the request data as input:
interface InitialContext {
method: 'GET' | 'POST'
path: string
query?: Record<string, string>
headers?: Record<string, string>
}
我想说第一个中间件元素应该将 InitialContext
转换为其他内容.如果您想变得灵活,那可以是任何事情.实际上,您可能想扩展 InitialContext
.现在,我只是坚持这样的想法,即中间件可以将 InitialContext
转换为任何东西,而下一个中间件需要对此进行处理.
I would say the first middleware element should transform the InitialContext
to something else. If you want to be flexible, it could be anything. More practically, you'd probably want to extend InitialContext
. For now I'll just stick with the idea that middleware can transform the InitialContext
into anything, and the next middleware needs to deal with that.
一种方法可能是只创建一个接受许多中间件函数的函数,然后返回最后一块中间件的结果.您只需添加所需数量的中间件即可:
An approach could be to just create a function that takes in a number of middleware functions and return the result of the final piece of middleware. You'd just add as many pieces of middleware as you need:
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,但是当然可以使用某些代码生成将其扩展为任意有限数量.它的工作原理:
This is limited to 4, but could of course be extended to any finite number using some code generation. It works:
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)
这种方法的好处是,如果您的第二个中间件与您的第一个中间件不兼容,它将告诉您.这个错误将非常清楚.
The nice thing about this approach is that if your second piece of middleware doesn't work with the output of your first, it'll tell you about it. And this error will be pretty clear.
除了必须分别写出每个中间件参数之外,缺点是输出类型为 unknown
.如果您不预先知道将要注入多少中间件,这将很难解决.
The downside, apart from having to write out each middleware argument separately, is that the output type is unknown
. This is hard to solve if you don't know upfront how many pieces of middleware will be injected.
第二种方法,减少"中间件
因此,下一个问题可能是:是否可以确定组合在一起的N个中间件的总计"类型.回顾我们之前的中间件:
So, the next question could be: is it possible to determine the 'total' type of N pieces of middleware, combined. Looking back at our previous pieces of middleware:
const m1 = (s: InitialContext) => s.path
const m2 = (s: string) => Number(s)
const m3 = (s: number) => `${s}${s}`
您可以说这3个部分的组合类型为 Middleware< InitialContext,string>
,因为第一个函数的输入为 InitialContext
,最后一个函数的输入为字符串
作为输出.
You could say the combined type of these 3 pieces is Middleware<InitialContext, string>
because the first function has InitialContext
as input and the last one has string
as output.
通过如下所示的功能可以将它们组合在一起:
Combining them would be possible with a function that looks like this:
function reduceMiddleware<T extends Middleware<any, any>[]>(...middleware: T) {
return middleware.reduce((a, b) => {
return ((input: any) => b(a(input)))
}) as any
}
我们可以为该函数提供输出类型吗?
Can we give this function an output type?
我们可以使用 infer
和条件类型将两种中间件类型组合在一起:
We could combine two middleware types together using infer
and a conditional type:
type TwoIntoOne<T> = T extends [Middleware<infer A, any>, Middleware<any, infer B>] ? Middleware<A, B> : unknown
使用以下命令对此进行测试:
Testing this with the following:
type Combined = TwoIntoOne<[Middleware<string, number>, Middleware<number, Date>]>
将给我们(input:string)=>日期
或 Middleware< string,Date>
作为输出.
但是,我们需要结合的不是2个,而是N个中间件.这意味着我们需要某种形式的循环,或者我们一起减少类型.减少可以使用递归来实现,而递归是Typescript类型所支持的!
However, we need to combine not 2 but N pieces of middleware. That means we need either some sort of loop, or we reduce the types together. Reducing can be achieved using recursion, and recursion is something that Typescript types do support!
因此,我们可以使用递归来创建一些将N个类型组合为单个类型的类型:
So, we could use recursion to make some type that combines N types into a single type:
type Combine<T> = T extends [Middleware<infer Ain, infer Aout>, Middleware<infer Aout, infer Bout>, ... infer Rest] ?
Combine<[Middleware<Ain, Bout>, ...Rest]> : T
此最终类型将包装在数组中,我们可以进行简单的调整以将其解包:
This final type will be wrapped in an array, we can make a simple adjustment to unwrap it:
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>
我们可以在之前创建的函数中使用此泛型:
We can use this generic in our function that we made earlier:
function reduceMiddleware<T extends Middleware<any, any>[]>(...middleware: T): Combine<T> {
return middleware.reduce((a, b) => {
return ((input: any) => b(a(input)))
}) as any
}
但是,请注意,此函数在内部不是类型安全的.我们必须用any覆盖结果,以便能够在没有编译器错误的情况下运行此结果.但是,这样做可以使我们在其余代码中具有类型安全性:
However, note that internally this function is not type safe. We have to overwrite the result with any to be able to run this without compiler errors. However, doing that allows us to have type-safety in the rest of our code:
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>
我们可以简单地直接使用带有某些上下文作为输入的中间件:
We could simply use this middleware directly with some context as input:
const output = combined({
method: 'GET',
path: '/blabla'
})
或者我们可以建立一个更大的应用程序,使用该中间件来处理请求/响应周期.
Or we could set up some bigger application that uses this single piece of middleware to handle a request/response cycle.
请注意,尽管此计算类型将检查中间件的输入/输出是否匹配,但实际上不会给您任何错误消息.它会为您提供未知的类型,并且在使用组合函数时可能会给您一些类型错误.确实没有任何类型提示可以帮助您解决此问题,因此肯定有改进的方法.
Note that although this calculated type will check if the input/output of the middleware matches up, it will not really give you any error message. It'll give you the unknown type and that will probably give you some type error when you're using the combined function. There are not really any type hints to help you solve this, so there's definitely ways to improve.
因此,回答您的问题:是的,有一些选项可以以更安全的类型与中间件一起使用.将其与OOP方法相结合可能会很棘手,但是使用单个中间件而不是N进行操作已经使这更加可行.
So to answer your question: yes, there are options to work with middleware in a more type-safe way. Combining this in an OOP approach can be tricky, but working with a single piece of middleware instead of N already makes this a bit more doable.
一种非常简单的方法是在构造函数中接受一个中间件,并期望用户将中间件reducer作为单独的步骤使用:
A very simple approach would just be to accept one piece of middleware in the constructor and expect the user to use the middleware reducer as a separate step:
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>
这篇关于装饰器中间件模式的打字稿打字的文章就介绍到这了,希望我们推荐的答案对大家有所帮助,也希望大家多多支持!