【问题标题】:Typescript typing for decorator middleware patterns装饰器中间件模式的打字稿
【发布时间】:2021-05-22 22:39:41
【问题描述】:

我正在考虑 Node 中间件(在各种框架中)。中间件通常会为请求或响应对象添加一个属性,然后任何在其后注册的中间件都可以使用该属性。

这个模型的一个问题是你没有得到有效的打字。

为了演示,这里有一个高度简化的此类框架模型。在这种情况下,一切都是同步的,每个中间件都收到一个请求,并且还必须返回一个(可能会改变)。

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

Typescript playground link

这是服务器端 Javascript 框架中非常常见的模式。不变这会引发错误:

Property 'foo' does not exist on type 'Req'.

对此的标准解决方案是使用接口声明合并:

interface Req {
    path: string;
    method: string;
}
interface Req {
    foo: string;
}

但这也有缺点。这全局性地改变了类型,我们实际上是在对打字稿撒谎。在调用第一个中间件之前,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 函数,它会变得更加复杂,但为了这个问题,让我们把它排除在外。

更广泛地说,我的问题是......当一个伟大的基于服务器端中间件的框架是专门为 Typescript 编写的时,它会是什么样子。或者,也许还有其他更有效的模式,我只是太盲目了,看不到它。

【问题讨论】:

    标签: javascript node.js typescript express


    【解决方案1】:

    很好的问题。 如果我处于你的位置,我会创建一个扩展 Request 接口的新接口。

    interface MyReq extends Request {
      foo: string;
    }
    
    getMovies(req: MyReq, res: Response): void {
      req.foo = "Hi there setting foo";
      res.json(this.movieService.getMovies());
    }
    

    这对于像您这样的大型项目来说更具可持续性。我很快在我打开的小项目上测试了它,它工作得很好,而且没有欺骗编译器:)。看看吧:

    让我知道你的想法。 干杯。

    【讨论】:

    • 您的样本的问题是,保证res 具有MyReq 类型。是什么确保调用了 foo 属性?我假设装饰器有效地将请求视为any
    • 嗨,请忽略装饰器,然后回到您的问题:您不会有任何保证,因为res 只是MyReq 类型的参数,这意味着在编译时不会发生任何事情.请注意,在运行时 Typescript 的行为就像 Javascript,因此如果 req 参数接收到另一个类型的对象,它将一直工作,直到应用程序尝试访问其上的属性 foo
    • 我的问题的真正意义是,我希望在编译期间保证reqMyReq。听起来您的解决方案不会这样做。
    【解决方案2】:

    有趣的问题,我没有完整的答案,但我认为有一些方法可以解决这个问题。

    第一种方法

    如果你想定义一个更通用的中间件,你可以说它是一个简单的函数:

    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 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)
    

    这种方法的好处是,如果您的第二个中间件不能与您第一个的输出一起工作,它会告诉您有关情况。而且这个错误会很明显。

    除了必须单独写出每个中间件参数之外,缺点是输出类型为unknown。如果事先不知道要注入多少个中间件,这很难解决。


    第二种方法,“减少”中间件

    所以,下一个问题可能是:是否可以确定 N 个中间件的“总”类型,组合起来。回顾我们之前的中间件:

    const m1 = (s: InitialContext) => s.path
    const m2 = (s: string) => Number(s)
    const m3 = (s: number) => `${s}${s}`
    

    您可以说这 3 个部分的组合类型是 Middleware&lt;InitialContext, string&gt;,因为第一个函数以 InitialContext 作为输入,最后一个函数以 string 作为输出。

    将它们与如下所示的函数结合是可能的:

    function reduceMiddleware<T extends Middleware<any, any>[]>(...middleware: T) {
      return middleware.reduce((a, b) => {
        return ((input: any) => b(a(input)))
      }) as any
    }
    

    我们可以给这个函数一个输出类型吗?

    我们可以使用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) =&gt; DateMiddleware&lt;string, Date&gt; 作为输出。

    但是,我们需要组合的不是 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 reduceMiddleware<T extends Middleware<any, any>[]>(...middleware: T): Combine<T> {
      return middleware.reduce((a, b) => {
        return ((input: any) => b(a(input)))
      }) as any
    }
    

    但是,请注意,此函数在内部不是类型安全的。我们必须用 any 覆盖结果才能在没有编译器错误的情况下运行它。但是,这样做可以让我们在其余代码中具有类型安全性:

    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'
    })
    

    或者我们可以设置一些更大的应用程序,使用这个中间件来处理请求/响应周期。

    请注意,虽然这种计算类型会检查中间件的输入/输出是否匹配,但它不会真正给您任何错误消息。它会给你未知的类型,当你使用组合函数时,它可能会给你一些类型错误。没有任何类型提示可以帮助您解决这个问题,所以肯定有改进的方法。


    所以回答你的问题:是的,有一些选项可以以更安全的方式使用中间件。在 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>
    

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 2018-02-12
      • 2020-12-24
      • 2018-06-21
      • 2019-07-29
      • 2016-05-08
      • 1970-01-01
      • 1970-01-01
      • 2017-12-19
      相关资源
      最近更新 更多