我想强烈地键入上下文.状态对象由deno中的oak提供.我已经看到了这种方法是如何工作的(例如Deno oak v10.5.1 context.cookies never being set),但还没有在我自己的代码中实现它.

我的目标是访问强类型上下文.在我的每个中间件中声明.

这是上下文的接口.状态:

interface State {
    userID: number;
    sessionID: number;
}

这将是我用于设置上下文的中间件功能之一.我试图访问上下文状态属性的状态:

const contextMiddleware = async (context: Context, next: () => Promise<unknown>): Promise<void> => {
    context.state.userID = 123;
    context.state.sessionID = 456;
    await next();
    delete context.state.userID;
    delete context.state.sessionID;
}

我的问题是,在contextMiddleware函数中,这两个属性的类型是any,而不是预期的数字类型.此外,intellisense无法识别这些属性以实现自动补全.

我已经看到,解决方案可能是将IState接口作为泛型传递给调用中间件的应用程序和路由对象,然后将contextMiddlewate函数设置为从oak import mod键入RouterMiddleware或具有相同泛型的中间件.ts.

这可能看起来像这样,但目前不起作用:

import {
  Application,
  type Context,
  type Middleware,
  Router,
} from "https://deno.land/x/oak@v10.6.0/mod.ts";

interface State {
    userID: number;
    sessionID: number;
}

const contextMiddleware: Middleware<State, Context<State, State>> = async (context: Context, next: () => Promise<unknown>): Promise<void> => {
    context.state.userID = 123;
    context.state.sessionID= 456;
    await next();
    delete context.state.userID;
    delete context.state.sessionID;
}

const defaultRouter = new Router<State>();

defaultRouter
  .use(contextMiddleware)
  .get("/(.*)", (context: Context) => {
    context.response.status = 404;
    context.response.body = "Endpoint not available!";
  });

const app = new Application<State>();

app.use(defaultRouter.routes(), defaultRouter.allowedMethods());

app.addEventListener("listen", ({ hostname, port, secure }) => {
  console.log(
    `Listening on ${secure ? "https://" : "http://"}${
      hostname || "localhost"
    }:${port}`,
  );
});

await app.listen({ port: 8080 });

我错过了什么?

提前感谢您的帮助!

推荐答案

由于有多少Oak的类型使用generics(通常带有默认类型参数),特别是它们创建新类型的方式(在某些情况下,不允许您提供在具有默认值的插槽中使用的类型,而是使用默认值),强类型Oak中间件可能会非常复杂-尤其是当您的主要目标是严格的类型安全性时.

注意,Oak是面向中间件的web服务器的一个方便API.它的主要目的是处理样板文件,帮助您专注于应用程序代码,公平地说,它做出了一些惊人的妥协,在这方面提供了非常好的类型.然而,在某些地方,Oak的类型系统中使用的默认值似乎偏向于方便而不是安全,因此记住这一点很重要.

泛型有时会让人觉得很难,而其中一个潜在的棘手部分是,Oak中并不是只有一种"上下文"类型(这取决于上下文的使用位置),而且——非常重要的是——不仅仅有一种"状态"类型(Oak允许您 for each 请求/响应周期潜在地创建唯一的状态数据,为您提供用于初始化它的策略选项——下面将对此进行详细介绍).

让我们从所涉及的依赖类型的底部开始,逐步获得完整的理解.从app开始:

Application的类型如下所示:

class Application<AS extends State = Record<string, any>> extends EventTarget {
  constructor(options?: ApplicationOptions<AS, ServerRequest>);
  // --- snip ---
  state: AS;
  // --- snip ---
  use<S extends State = AS>(
    middleware: Middleware<S, Context<S, AS>>,
    ...middlewares: Middleware<S, Context<S, AS>>[]
  ): Application<S extends AS ? S : (S & AS)>;
  use<S extends State = AS>(
    ...middleware: Middleware<S, Context<S, AS>>[]
  ): Application<S extends AS ? S : (S & AS)>;
  // --- snip ---
}

AS类型参数具有类型别名Stateconstraint,如下所示:

type State = Record<string | number | symbol, any>;

(基本上就是"任何普通物体").现在,让我们看看用于创建应用程序的选项:ApplicationOptions,如下所示:

interface ApplicationOptions <S, R extends ServerRequest> {
  contextState?:
    | "clone"
    | "prototype"
    | "alias"
    | "empty";
  keys?: KeyStack | Key[];
  logErrors?: boolean;
  proxy?: boolean;
  serverConstructor?: ServerConstructor<R>;
  state?: S;
}

我将跳过不相关的类型:ServerConstructorKeyStack(Oak的主模块甚至没有导出,所以您必须自己跟踪类型(糟糕!)).

定义如何创建上下文状态的策略的选项是ApplicationOptions#contextState,内联文档对此进行了说明(也使用略有不同的语言here进行了描述):

确定在创建新上下文时应如何应用应用程序中的状态.值"clone"将状态设置为应用程序状态的克隆.不会复制任何不可克隆或不可枚举的属性.值"prototype"表示应用程序的状态将用作上下文状态的原型,这意味着上下文状态的浅属性不会反映在应用程序的状态中.值"alias"表示应用程序的.state和上下文的.state将是对同一对象的引用.值"empty"将使用空对象初始化上下文的.state.

默认值为"clone".

在实例化应用程序时,我们将保留此未定义(使用默认值).对这些算法的讨论超出了问题的范围,但重要的是,您要知道这会如何影响代码的行为,因 for each 设置都会为上下文生成不同的状态对象.要查看该值是如何创建的源,请从here开始.

Because you don't have any application-level state,创建应用程序(具有严格的类型安全性)如下所示:

// This means "an object with no property names and no values" (e.g. `{}`)
type EmptyObject = Record<never, never>;
type AppState = EmptyObject;

const app = new Application<AppState>({ state: {} });

注意,您也可以这样实例化它:

const app = new Application<AppState>();

Oak will create the empty object for you,但我更希望我的代码是显式的.

如果您在上面提到Oak用于泛型类型参数AS的默认类型(如果您没有提供),您将看到它是Record<string, any>.这种 Select 不是非常类型安全的,但可以使使用未知或动态数据更加方便.类型安全性和便利性并不总是相互矛盾的,但通常情况就是这样.

以上内容对于以下所有内容都很重要:这意味着在每个路由和中间件中,应该使用类型AppState来代替应用程序状态泛型类型参数(Oak使用参数名AS):否则,Oak提供不太安全的默认值.

现在,让我们看看您计划在每个请求-响应周期中使用的状态类型(Oak称之为上下文):

type ContextState = {
  sessionID: number;
  userID: number;
};

您可能注意到,我在您的示例中重命名了状态类型.当然,欢迎您随意命名程序变量和类型,但我想鼓励您不要在每个类型前面加IT.你可能在其他人的代码中看到过这一点,但在我看来,这只是噪音:这就像在程序中的每个变量前加v(例如vDatevNamevAmount等):根本没有必要这样做.相反,我鼓励你遵循官方的打字惯例,在PascalCase个字母中使用有意义的名字.

接下来,让我们看看Oak's Context.看起来是这样的:

class Context<S extends AS = State, AS extends State = Record<string, any>> {
  constructor(
    app: Application<AS>,
    serverRequest: ServerRequest,
    state: S,
    secure?,
  );
  // --- snip ---
  app: Application<AS>; // { state: AS }
  // --- snip ---
  state: S;
  // --- snip ---
}

如您所见,上下文状态类型和应用程序状态类型不一定相同.

而且,除非你另有指示,(可能是不安全的)橡树设置他们这样(甚至更不安全,为Record<string, any>).

让我们再看看Oak提供的the other context type:RouterContext.此上下文类型在路由中使用,是一种更窄的类型,包含有关路由信息的附加信息:路径、参数等.它看起来像这样:

interface RouterContext <
  R extends string,
  P extends RouteParams<R> = RouteParams<R>,
  S extends State = Record<string, any>,
> extends Context<S> {
  captures: string[];
  matched?: Layer<R, P, S>[];
  params: P;
  routeName?: string;
  router: Router;
  routerPath?: string;
}

这里我不讨论RouteParams类型:这是一个有点复杂的递归类型实用程序,它试图从路由字符串文字参数R构建一个类型安全的路由参数对象.

如您所见,该类型扩展了Context类型(extends Context<S>),但它仅为其提供上下文状态类型(S),而未定义应用程序状态类型(AS),这导致使用默认类型(Record<string, any>).这是这里看到的第一个示例,其中Oak Select 不提供创建类型安全代码的方法.然而,我们可以制作自己的版本.

⚠️ 重要提示:Layer类不是从Oak中定义的模块导出的,即使它用于Oak的公共面向(导出)类型(同样,非常糟糕!).这使得我们无法在自定义的更强大的上下文类型中使用(除非我们手动重新创建该类型),因此我们必须这样做(真令人头痛!).

import {
  type Context,
  type RouteParams,
  Router,
  type RouterContext,
  type State as AnyOakState,
} from "https://deno.land/x/oak@v10.6.0/mod.ts";

type EmptyObject = Record<never, never>;

interface RouterContextStrongerState<
  R extends string,
  AS extends AnyOakState = EmptyObject,
  S extends AS = AS,
  P extends RouteParams<R> = RouteParams<R>,
> extends Context<S, AS> {
  captures: string[];
  matched?: Exclude<RouterContext<R, P, S>["matched"], undefined>;
  params: P;
  routeName?: string;
  router: Router;
  routerPath?: string;
}

这种自定义类型在创建类型安全的路由中间件功能时非常重要,因为Oak提供的用于创建它们的实用程序(MiddlewareRouterMiddleware)与RouterContext类型遇到了相同的默认类型参数使用问题,它们如下所示:

interface Middleware<
  S extends State = Record<string, any>,
  T extends Context = Context<S>,
> {
  (context: T, next: () => Promise<unknown>): Promise<unknown> | unknown;
}

interface RouterMiddleware<
  R extends string,
  P extends RouteParams<R> = RouteParams<R>,
  S extends State = Record<string, any>,
> {
  (
    context: RouterContext<R, P, S>,
    next: () => Promise<unknown>,
  ): Promise<unknown> | unknown;
  param?: keyof P;
  router?: Router<any>;
}

这个答案已经涵盖了很多内容(我们还没有完成!),因此,现在是一个很好的时间来回顾我们目前的情况:

import {
  Application,
  type Context,
  type RouteParams,
  Router,
  type RouterContext,
  type State as AnyOakState,
} from "https://deno.land/x/oak@v10.6.0/mod.ts";

type EmptyObject = Record<never, never>;
type AppState = EmptyObject;

const app = new Application<AppState>({ state: {} });

type ContextState = {
  userID: number;
  sessionID: number;
};

interface RouterContextStrongerState<
  R extends string,
  AS extends AnyOakState = EmptyObject,
  S extends AS = AS,
  P extends RouteParams<R> = RouteParams<R>,
> extends Context<S, AS> {
  captures: string[];
  matched?: Exclude<RouterContext<R, P, S>["matched"], undefined>;
  params: P;
  routeName?: string;
  router: Router;
  routerPath?: string;
}

所有这些都涵盖了,我们终于能够开始回答您的直接问题,即"如何在Deno中强类型Oak上下文状态对象?"

现在,真正的答案是(您可能会发现这令人沮丧):"这取决于中间件功能中发生的事情,以及调用它们的顺序(链接在一起)".这是因 for each 中间件都可以改变状态,而新状态将是下一个中间件接收的状态.这就是为什么这个问题如此复杂的原因,也是Oak默认使用Record<string, any>的原因.

那么,让我们看看您的示例中发生了什么,并创建类型来表示它.您有两个中间件功能,它们都在一个路由上.

第一个功能:

  • 为上下文状态对象的属性指定一些值,然后
  • 等待所有后续匹配的中间件完成(await next()就是这么做的),然后
  • 从上下文状态对象中删除这些属性

它的强类型版本如下所示:

我们还为next函数创建了一个类型别名NextFn,这样我们就不必一直为其他中间件键入整个函数签名.

type NextFn = () => Promise<unknown>;

async function assignContextStateValuesAtTheBeginningAndDeleteThemAtTheEnd(
  context: Context<Partial<ContextState>, AppState>,
  next: NextFn,
): Promise<void> {
  context.state.userID = 123;
  //            ^? number | undefined
  context.state.sessionID = 456;
  //            ^? number | undefined
  await next(); // Wait for subsequent middleware to finish
  delete context.state.userID;
  delete context.state.sessionID;
}

注意,我在ContextState上使用了类型实用程序Partial<Type>(将其所有成员设置为可选).这有两个原因:

  1. 这些属性在函数开始时的上下文状态中不存在(此时状态只是一个空对象)

  2. 它们在函数结束时从上下文状态中删除-如果您try 删除非可选属性,您将得到以下TS诊断错误:The operand of a 'delete' operator must be optional. deno-ts(2790)

另一个函数位于第一个函数之后的同一中间件链中,但仅匹配GET个请求和匹配/(.*)个的路由.

旁白:我不确定你这样定义路由的意图是什么(如果你只是想让路由匹配GET个请求,那么你可以使用路由实例化选项RouterOptions#methods来配置).无论如何,您可能会发现了解Oak的文档states很有用,它使用path-to-regexp库来解析这些路由字符串.

这个可能看起来像这样:

function setEndpointNotFound(
  context: RouterContextStrongerState<"/(.*)", AppState, Partial<ContextState>>,
): void {
  context.response.status = 404;
  context.response.body = "Endpoint not available!";
}

如果您希望创建一个中间件函数,其中前一个函数设置了上下文状态属性,则可以使用type guard谓词函数:

function idsAreSet<T extends { state: Partial<ContextState> }>(
  contextWithPartialState: T,
): contextWithPartialState is T & {
  state: T["state"] & Required<Pick<T["state"], "sessionID" | "userID">>;
} {
  return (
    typeof contextWithPartialState.state.sessionID === "number" &&
    typeof contextWithPartialState.state.userID === "number"
  );
}

async function someOtherMiddleware(
  context: Context<Partial<ContextState>, AppState>,
  next: NextFn,
): Promise<void> {
  // In the main scope of the function:
  context.state.userID;
  //            ^? number | undefined
  context.state.sessionID;
  //            ^? number | undefined

  if (idsAreSet(context)) {
    // After the type guard is used, in the `true` path scope:
    context.state.userID;
    //            ^? number
    context.state.sessionID;
    //            ^? number
  } else {
    // After the type guard is used, in the `false` path scope:
    context.state.userID;
    //            ^? number | undefined
    context.state.sessionID;
    //            ^? number | undefined
  }

  await next();
}

是时候创建路由并使用我们的中间件了;这很简单:

const router = new Router<Partial<ContextState>>();
router.use(assignContextStateValuesAtTheBeginningAndDeleteThemAtTheEnd);
router.get("/(.*)", setEndpointNotFound);

在应用程序中使用路由同样简单(直接从您问题中的代码):

app.use(router.routes(), router.allowedMethods());

最后,让我们通过启动服务器来结束.

问题中的代码包括一个应用程序侦听事件回调函数,该函数将服务器地址记录到控制台.我猜是你从the example in the documentation中复制的.如果您想在服务器启动时在控制台的stdout中看到消息,那么这是一个很好的功能.这里有一个使用URL构造函数的微小变化,因此如果您碰巧在默认端口上侦听正在使用的协议(例如,http上的80,https上的443),则该位将从消息中的地址中省略(就像浏览器显示的地址一样).它也恰好与Deno的std库中的serve函数使用的onListen回调兼容:

function printStartupMessage({ hostname, port, secure }: {
  hostname: string;
  port: number;
  secure?: boolean;
}): void {
  if (!hostname || hostname === "0.0.0.0") hostname = "localhost";
  const address =
    new URL(`http${secure ? "s" : ""}://${hostname}:${port}/`).href;
  console.log(`Listening at ${address}`);
  console.log("Use ctrl+c to stop");
}

app.addEventListener("listen", printStartupMessage);

和启动服务器:

await app.listen({ port: 8080 });

以下是以上讨论的完整模块结果:

import {
  Application,
  type Context,
  type RouteParams,
  Router,
  type RouterContext,
  type State as AnyOakState,
} from "https://deno.land/x/oak@v10.6.0/mod.ts";

type EmptyObject = Record<never, never>;
type AppState = EmptyObject;

const app = new Application<AppState>({ state: {} });

type ContextState = {
  userID: number;
  sessionID: number;
};

interface RouterContextStrongerState<
  R extends string,
  AS extends AnyOakState = EmptyObject,
  S extends AS = AS,
  P extends RouteParams<R> = RouteParams<R>,
> extends Context<S, AS> {
  captures: string[];
  matched?: Exclude<RouterContext<R, P, S>["matched"], undefined>;
  params: P;
  routeName?: string;
  router: Router;
  routerPath?: string;
}

type NextFn = () => Promise<unknown>;

async function assignContextStateValuesAtTheBeginningAndDeleteThemAtTheEnd(
  context: Context<Partial<ContextState>, AppState>,
  next: NextFn,
): Promise<void> {
  context.state.userID = 123;
  context.state.sessionID = 456;
  await next();
  delete context.state.userID;
  delete context.state.sessionID;
}

function setEndpointNotFound(
  context: RouterContextStrongerState<"/(.*)", AppState, Partial<ContextState>>,
): void {
  context.response.status = 404;
  context.response.body = "Endpoint not available!";
}

const router = new Router<Partial<ContextState>>();
router.use(assignContextStateValuesAtTheBeginningAndDeleteThemAtTheEnd);
router.get("/(.*)", setEndpointNotFound);

app.use(router.routes(), router.allowedMethods());

// This is not necessary, but is potentially helpful to see in the console
function printStartupMessage({ hostname, port, secure }: {
  hostname: string;
  port: number;
  secure?: boolean;
}): void {
  if (!hostname || hostname === "0.0.0.0") hostname = "localhost";
  const address =
    new URL(`http${secure ? "s" : ""}://${hostname}:${port}/`).href;
  console.log(`Listening at ${address}`);
  console.log("Use ctrl+c to stop");
}

app.addEventListener("listen", printStartupMessage);

await app.listen({ port: 8080 });

这是相当多的信息,但在web服务器框架内发生了很多事情,并且以类型安全的方式进行操作更为复杂!

Typescript相关问答推荐

已定义但从未使用通用推断的错误

Angular 17 -如何在for循环内创建一些加载?

为什么ESLint抱怨通用对象类型?

我应该使用什么类型的React表单onsubmit事件?

如何在第一次加载组件时加载ref数组

如何修复正在处理类型但与函数一起使用时不处理的类型脚本类型

基于键的值的打字动态类型;种类;

与字符串文字模板的结果类型匹配的打字脚本

仅针对某些状态代码重试HTTP请求

PrimeNG日历需要找到覆盖默认Enter键行为的方法

在数据加载后,如何使用NgFor在使用异步管道的可观测对象上生成子组件?

使用2个派生类作为WeakMap的键

布局组件中的Angular 17命名路由出口

正确使用相交类型的打字集

使用条件类型的类型保护

如何过滤文字中的类对象联合类型?

ANGLE订阅要么不更新价值,要么不识别价值变化

是否可以定义引用另一行中的类型参数的类型?

如何使对象同时具有隐式和显式类型

如何提取具有索引签名的类型中定义的键