由于有多少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
类型参数具有类型别名State
的constraint,如下所示:
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;
}
我将跳过不相关的类型:ServerConstructor
和KeyStack
(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;
};
您可能注意到,我在您的示例中重命名了状态类型.当然,欢迎您随意命名程序变量和类型,但我想鼓励您不要在每个类型前面加I
或T
.你可能在其他人的代码中看到过这一点,但在我看来,这只是噪音:这就像在程序中的每个变量前加v
(例如vDate
、vName
、vAmount
等):根本没有必要这样做.相反,我鼓励你遵循官方的打字惯例,在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提供的用于创建它们的实用程序(Middleware
和RouterMiddleware
)与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>
(将其所有成员设置为可选).这有两个原因:
这些属性在函数开始时的上下文状态中不存在(此时状态只是一个空对象)
它们在函数结束时从上下文状态中删除-如果您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服务器框架内发生了很多事情,并且以类型安全的方式进行操作更为复杂!