创建可扩展的 BFF 函数

上一小节展示了如何在文件中导出一个简单的 BFF 函数。在更复杂的场景下,每个 BFF 函数可能需要做独立的类型校验,前置逻辑等。

因此,Modern.js 暴露了 Api,支持通过该 API 来创建 BFF 函数,通过这种方式创建的 BFF 函数能方便的进行功能拓展。

示例

注意
  • Api 函数只能在 ts 项目中使用,无法在纯 js 项目中使用。
  • 操作符函数(如下述 GetQuery 等)依赖 zod,需要先在项目中安装。
pnpm add zod

一个由 Api 函数创建的 BFF 函数由以下几部分组成:

  • Api(),定义接口的函数。
  • Get(path?: string),指定接口路由。
  • Query(schema: T)Redirect(url: string),扩展接口,如指定接口入参。
  • Handler: (...args: any[]) => any | Promise<any>,接口处理请求逻辑的函数。

服务端可以定义接口的入参与类型,根据类型,服务端在运行时会做自动的类型校验:

api/lambda/user.ts
import { Api, Post, Query, Data } from '@modern-js/plugin-bff/server';
import { z } from 'zod';

const UserSchema = z.object({
  name: z.string().min(2).max(10),
  email: z.string().email(),
});

const DataSchema = z.object({
  phone: z.string(),
});

export const addUser = Api(
  Post('/user'),
  Query(UserSchema),
  Data(DataSchema),
  async ({ query, data }) => ({
    name: query.name,
    phone: data.phone,
  }),
);
注意

使用 Api 函数的文件,要保证所有的代码逻辑都放在函数内。如函数外做 console.log、使用 fs 等操作都是不允许的。

浏览器端同样可以使用一体化调用的方式,拥有静态类型提示:

routes/page.tsx
import { addUser } from '@api/user';

addUser({
  query: {
    name: 'modern.js',
    email: 'modern.js@example.com',
  },
  data: {
    phone: '12345',
  },
});

接口路由

如下面示例,你可以通过 Get 函数指定路由和 HTTP Method:

api/lambda/user.ts
import { Api, Get, Query, Data } from '@modern-js/plugin-bff/server';

// 指定接口路由,Modern.js 默认设置 `bff.prefix` 为 `/api`,
// 因此该接口路由为 `/api/user`,Http Method 为 GET。
export const getHello = Api(
  Get('/hello'),
  Query(HelloSchema),
  async ({ query }) => query,
);

当未指定路由时,接口路由根据文件约定定义,如下面示例,函数写法下,有代码路径 api/lambda/user.ts,会注册相应的接口 /api/user

api/lambda/user.ts
import { Api, Get, Query, Data } from '@modern-js/plugin-bff/server';

// 未指定接口路由,根据文件约定和函数名,该接口为 api/user,Http Method 为 get。
export const get = Api(Query(UserSchema), async ({ query }) => query);
Info

Modern.js 推荐基于文件约定去定义接口,保持项目中路由清晰。具体规则可见函数路由

除了 Get 函数外,你可以使用以下函数定义 Http 接口:

函数说明
Get(path?: string)接受 Get 请求
Post(path?: string)接受 POST 请求
Put(path?: string)接受 PUT 请求
Delete(path?: string)接受 DELETE 请求
Patch(path?: string)接受 PATCH 请求
Head(path?: string)接受 HEAD 请求
Options(path?: string)接受 OPTIONS 请求

请求

以下为请求相关的操作符,操作符可以组合使用,但需符合 Http 协议,如 get 请求无法使用 Data 操作符。

查询参数 Query

使用 Query 函数可以定义 query 的类型,使用 Query 函数后,接口处理函数的入参中就可以拿到 query 信息,前端请求函数的入参中可以加入 query 字段:

api/lambda/user.ts
// 服务端代码
import { Api, Query } from '@modern-js/plugin-bff/server';
import { z } from 'zod';

const UserSchema = z.object({
  name: z.string().min(2).max(10),
  email: z.string().email(),
});

export const get = Api(Query(UserSchema), async ({ query }) => ({
  name: query.name,
}));
routes/page.tsx
// 前端代码
get({
  query: {
    name: 'modern.js',
    email: 'modern.js@example.com',
  },
});

Query 参数类型转换

URL query 参数默认是字符串类型,如果需要数字类型,需要使用 z.coerce.number() 进行类型转换:

api/lambda/user.ts
import { Api, Get, Query } from '@modern-js/plugin-bff/server';
import { z } from 'zod';

const QuerySchema = z.object({
  id: z.string(),
  page: z.coerce.number().min(1).max(100), // 使用 z.coerce.number() 转换字符串到数字
  status: z.enum(['active', 'inactive']),
});

export const getUser = Api(
  Get('/user'),
  Query(QuerySchema),
  async ({ query }) => {
    return {
      id: query.id,
      page: query.page, // page 是 number 类型
      status: query.status,
    };
  },
);
注意

URL query 参数都是字符串类型,如果需要数字类型,需要使用 z.coerce.number() 进行转换,而不是直接使用 z.number()

传递数据 Data

使用 Data 函数可以定义接口传递数据的类型,使用 Data 后,接口处理函数的入参中就可以拿到接口数据信息。

Caution

使用 Data 函数的话,必须遵循 HTTP 协议,HTTP Method 为 Get 或 Head 时,无法使用 Data 函数。

api/lambda/user.ts
import { Api, Data } from '@modern-js/plugin-bff/server';
import { z } from 'zod';

const DataSchema = z.object({
  name: z.string(),
  phone: z.string(),
});

export const post = Api(Data(DataSchema), async ({ data }) => ({
  name: data.name,
  phone: data.phone,
}));
routes/page.tsx
// 前端代码
post({
  data: {
    name: 'modern.js',
    phone: '12345',
  },
});

路由参数 Params

路由参数可以实现动态路由,并且从路径中获取参数。可以通过 Params<T>(schema: z.ZodType<T>) 指定路径参数

import { Api, Get, Params } from '@modern-js/plugin-bff/server';
import { z } from 'zod';

const UserSchema = z.object({
  id: z.string(),
});

export const queryUser = Api(
  Get('/user/:id'),
  Params(UserSchema),
  async ({ params }) => ({
    name: params.id,
  }),
);

请求头 Headers

可以通过 Headers<T>(schema: z.ZodType<T>) 函数定义接口需要的请求头,并通过一体化调用传递请求头:

import { Api, Headers } from '@modern-js/plugin-bff/server';
import { z } from 'zod';

const headerSchema = z.object({
  token: z.string(),
});

export const queryUser = Api(Headers(headerSchema), async ({ headers }) => ({
  name: headers.token,
}));

参数校验

如前面提到的,当使用 QueryData 等函数定义接口时,服务端会根据这些函数传入的 schema,对前端传入的数据做自动的校验。

当校验失败时,可以通过 Try/Catch 捕获错误:

try {
  const res = await postUser({
    query: {
      user: 'modern.js',
    },
    data: {
      message: 'hello',
    },
  });
  return res;
} catch (error) {
  console.log(error.data.code); // VALIDATION_ERROR
  console.log(JSON.parse(error.data.message));
}

同时,可以通过 error.data.message 获取完整的错误信息:

[
  {
    code: 'invalid_string',
    message: "Invalid email",
    path: [0, 'user'],
    validation: "email"
  },
];

中间件 Middleware

可以通过 Middleware 操作符设置函数中间件,函数中间件会在校验和接口逻辑之前执行。

Info

Middleware 操作符可以配置多次,中间件的执行顺序为从上至下

import { Api, Query, Middleware } from '@modern-js/plugin-bff/server';
import { z } from 'zod';

const UserSchema = z.object({
  name: z.string().min(2).max(10),
  email: z.string().email(),
});

export const get = Api(
  Query(UserSchema),
  Middleware(async (c, next) => {
    console.info(`access url: ${c.req.url}`);
    await next();
  }),
  async ({ query }) => ({
    name: query.name,
  }),
);

数据转换 Pipe

Pipe 操作符可以传入一个函数,在中间件和校验完成之后执行,主要有以下场景可以使用:

  1. 对请求携带的查询参数或数据进行转换。
  2. 对请求的数据进行自定义校验,如果校验失败,可以选择抛出异常,或者直接返回错误的信息。
  3. 希望只做校验,不执行接口逻辑,(如前端不做单独的校验,使用接口做校验,但在一些场景下又不希望接口逻辑执行)可以在此函数中终止后续的执行。

Pipe 定义转换函数,转换函数的入参是接口请求携带的 querydataheaders,返回值会传递给下一个 Pipe 函数或接口处理函数作为入参,所以返回值的数据结构一般需和入参相同。

Info

Pipe 操作符可以配置多次,函数的执行顺序为从上至下,前一个函数的返回值,是后一个函数的入参。

import { Api, Query, Pipe } from '@modern-js/plugin-bff/server';
import { z } from 'zod';

const UserSchema = z.object({
  name: z.string().min(2).max(10),
  email: z.string(),
});

export const get = Api(
  Query(UserSchema),
  Pipe<{
    query: z.infer<typeof UserSchema>;
  }>(input => {
    const { query } = input;
    if (!query.email.includes('@')) {
      query.email = `${query.email}@example.com`;
    }
    return input;
  }),
  async ({ query }) => ({
    name: query.name,
  }),
);

同时,

import { Api, Query, Pipe } from '@modern-js/plugin-bff/server';
import { z } from 'zod';

const UserSchema = z.object({
  name: z.string().min(2).max(10),
  email: z.string().email(),
});

export const get = Api(
  Query(UserSchema),
  Pipe<{
    query: z.infer<typeof UserSchema>;
  }>((input, end) => {
    const { query } = input;
    const { name, email } = query;
    if (!email.startsWith(name)) {
      return end({
        message: 'email must start with name',
      });
    }
    return input;
  }),
  async ({ query }) => ({
    name: query.name,
  }),
);

如果需要对响应做更多自定义操作,可以给 end 函数传入一个函数,函数的入参是 Hono 的 Context (c),可以对 c.reqc.res 进行操作:

import { Api, Query, Pipe } from '@modern-js/plugin-bff/server';
import { z } from 'zod';

const UserSchema = z.object({
  name: z.string().min(2).max(10),
  email: z.string().email(),
});

export const get = Api(
  Query(UserSchema),
  Pipe<{
    query: z.infer<typeof UserSchema>;
  }>((input, end) => {
    const { query } = input;
    const { name, email } = query;
    if (!email.startsWith(name)) {
      return end(c => {
        c.res.status = 400;
        c.res.body = {
          message: 'email must start with name',
        };
      });
    }
    return input;
  }),
  async ({ query }) => ({
    name: query.name,
  }),
);

响应

以下为响应相关操作符,通过响应操作符可以对响应进行处理。

状态码 HttpCode

可以通过 HttpCode(statusCode: number) 函数指定接口返回的状态码

import { Api, Query, Data, HttpCode } from '@modern-js/plugin-bff/server';
import { z } from 'zod';

const UserSchema = z.object({
  name: z.string().min(2).max(10),
  email: z.string().email(),
});

const DataSchema = z.object({
  phone: z.string(),
});

export const post = Api(
  Query(UserSchema),
  Data(DataSchema),
  HttpCode(202),
  async ({ query, data }) => {
    someTask({
      user: {
        ...query,
        ...data,
      },
    });
  },
);

响应头 SetHeaders

支持通过 SetHeaders(headers: Record<string, string>) 函数设置响应头

import { Api, Get, SetHeaders } from '@modern-js/plugin-bff/server';

export default Api(
  Get('/hello'),
  SetHeaders({
    'x-log-id': 'xxx',
  }),
  async () => 'Hello World!',
);

重定向 Redirect

支持通过 Redirect(url: string) 对接口做重定向:

import { Api, Get, Redirect } from '@modern-js/plugin-bff/server';

export default Api(
  Get('/hello'),
  Redirect('https://modernjs.dev/'),
  async () => 'Hello Modern.js!',
);

请求上下文

如上面所述,通过操作符可以执行接口处理函数的入参,获得 querydataparams 等。但有时我们需要获得更多请求上下文的信息,此时可以通过 useHonoContext 获取:

api/lambda/user.ts
import { Api, Get, Query } from '@modern-js/plugin-bff/server';
import { useHonoContext } from '@modern-js/server-runtime';
import { z } from 'zod';

const UserSchema = z.object({
  name: z.string().min(2).max(10),
  email: z.string().email(),
});

export const queryUser = Api(
  Get('/user'),
  Query(UserSchema),
  async ({ query }) => {
    const c = useHonoContext();
    const userAgent = c.req.header('user-agent');
    return {
      name: query.name,
      userAgent,
    };
  },
);

常见问题

是否可以使用 ts 代替 zod schema

如果你想使用 ts ,而不是 zod schema,可以使用 ts-to-zod,先将 ts 转为 zod schema,然后使用转换后的 schema。

我们选用 zod ,而不是纯粹的 ts 定义入参类型信息的原因是:

  • zod 学习成本足够低。
  • 在校验这个场景,zod schema 拥有比 ts 更强的表达能力。
  • zod 更容易扩展。
  • 在运行时获取 ts 静态类型信息的方案都不够成熟。

具体可以参考不同方案的比较,可以参考为什么使用 zod ,如果有更多的想法和疑问,也欢迎联系我们。

更多实践

为接口添加 http 缓存

在前端开发中,有些服务端接口(如一些配置接口)响应时间会比较久,但其实长时间无需更新,针对这类接口我们可以设置 HTTP 缓存以提高页面的性能:

import { Api, SetHeaders } from '@modern-js/plugin-bff/server';

export const get = Api(
  // 缓存使用一体化调用或者 fetch 进行请求才会生效
  // 在 1s 内,缓存不做验证,直接返回响应
  // 1s-60s 内获取先返回旧的缓存信息,同时重新发起验证请求,使用新值填充缓存
  SetHeaders({
    'Cache-Control': 'max-age=1, stale-while-revalidate=59',
  }),
  async () => {
    await wait(500);
    return 'Hello Modern.js';
  },
);