Core · 开发应用 · 服务端

服务端多语言

服务端生成的邮件、通知和导出内容使用 server/locales/。它与 client/locales/ 独立加载:页面上能翻译的 key,服务端仍需声明。

下面以“报表就绪通知”为例,分别按请求语言和接收人语言生成消息。示例 App 可通过命令运行,安装方法见示例源码与运行。它只生成文案,不读取真实报表或发送邮件。

添加和加载语言资源

定义文案与插值参数

在 server/locales/en-US.ts 的已有对象中增加 reports,保留其他文案和类型导出。以下先省略后文的插件覆盖配置:

TypeScript
import type { LocaleResource } from '@nocobase/i18n';

const enUS = {
  reports: {
    ready: 'Report {{name}} is ready.',
    subject: 'Your report: {{name}}',
    notReady: 'Report {{name}} is not ready yet.',
  },
};
export type AppServerResource = LocaleResource<typeof enUS>;
export default enUS;

在 server/locales/zh-CN.ts 中补齐相同的键:

TypeScript
import type { AppServerResource } from './en-US.js';

const zhCN = {
  reports: {
    ready: '报表 {{name}} 已准备好。',
    subject: '你的报表:{{name}}',
    notReady: '报表 {{name}} 尚未准备好。',
  },
} satisfies AppServerResource;
export default zhCN;

LocaleResource 约束键结构,译文可以不同。{name} 是插值参数;各语言保持相同的参数名,调用时传入报表名,不要把报表名拼进 key。类型检查不能代替插值结果检查。

查看语言资源源码。运行 pnpm demo request zh-CN 会把 Q3 插入中文消息;改为 pnpm demo request en-US "Sales summary",会得到 Report Sales summary is ready.。

注册语言与设置默认语言

默认模板的 server/locales/index.ts 已包含语言加载器:

TypeScript
import type { LocaleLoaders } from "@nocobase/i18n";
const locales: LocaleLoaders = {
  "en-US": () => import("./en-US.js"),
  "zh-CN": () => import("./zh-CN.js"),
};
export default locales;

server/runtime.ts 通过 locales 接入该入口。添加语言时,需要增加资源文件及其 loader。插件提供的翻译不会自动增加应用可选语言;前后端的资源列表也需要分别维护。

默认语言在应用的 config.yml 中设置,例如:

YAML
i18n:
  defaultLocale: en-US

也可以使用 APP_DEFAULT_LOCALE,配置规则见应用配置。默认语言决定没有匹配偏好时的回退,不会替换用户已保存的语言选择。

示例以 App 入口 中的 i18n.defaultLocale 设置英文默认值,并注册真实的 I18nProvider 和语言资源。运行 pnpm demo request fr-FR,因为应用未声明法语,会返回英文消息。示例插件虽然提供日语资源,pnpm demo request ja-JP 仍回退到英文。

在 HTTP 请求中使用翻译

按当前请求语言生成文案

默认模板在 server/app.ts 中先挂载会话中间件,再挂载 i18nHttpMiddleware。后者解析请求语言并等待资源加载,路由随后用 getRequestTranslator(c) 取得应用的翻译函数,用 getRequestLocale(c) 读取实际语言。

以下是 report-messages.ts 中报表预览接口的独立写法。放入 server/routes/ 后,按HTTP 路由与接口注册到 server/routes/index.ts:

TypeScript
import { Hono } from 'hono';
import { z } from 'zod';
import {
  apiErrorHandler, apiErrorResponses, apiValidator,
  dataResponse, defineApiRoutes, describeRoute,
} from '@nocobase/app-server/router';
import {
  getRequestLocale, getRequestTranslator,
} from '@nocobase/i18n/server';

export default defineApiRoutes(() => {
  const router = new Hono();
  router.onError(apiErrorHandler);
  router.get(
    '/report-message-preview',
    describeRoute({
      operationId: 'previewReportMessage',
      summary: 'Preview report wording',
      security: [],
      responses: {
        200: dataResponse(z.object({
          locale: z.string(), message: z.string(),
        })),
        ...apiErrorResponses,
      },
    }),
    apiValidator('query', z.strictObject({
      name: z.string().trim().min(1).max(80),
    })),
    (c) => c.json({
      data: {
        locale: getRequestLocale(c),
        message: getRequestTranslator(c)(
          'reports.ready', { name: c.req.valid('query').name },
        ),
      },
    }),
  );
  return router;
});

在已有应用中,请求 <APP_BASE_PATH>/api/report-message-preview?name=Q3,传入 Accept-Language: zh-CN。没有会话语言覆盖时,响应为:

JSON
{
  "data": {
    "locale": "zh-CN",
    "message": "报表 Q3 已准备好。"
  }
}

示例命令 pnpm demo request zh-CN 会通过 Application.fetch 执行这个请求,不监听端口。该接口公开预览文案,security: [] 是接口文档声明;若改为读取真实报表,还需按业务添加认证和数据授权。

会话语言、请求头与默认语言

已保存的会话语言优先。没有会话偏好时,中间件按 Accept-Language 的优先级寻找支持的语言;没有匹配项时使用默认语言。会话里已有不支持的语言值时,该值经运行时解析并回退,不再用请求头覆盖它。

运行 pnpm demo session zh-CN:演示脚本 先通过示例接口把英文保存到真实会话,再带着 Cookie 和中文请求头请求预览接口。结果仍是 locale: "en-US" 和 Report Q3 is ready.。去掉 Cookie 的新请求则使用中文。

示例的 /demo-locale 仅用于复现会话优先级,偏好保存在当前进程内。现有应用继续使用自己的语言切换入口,见客户端多语言。

按接收人语言生成后台消息

获取运行时与固定语言翻译器

后台任务没有当前 HTTP 请求。让调用方传入接收人偏好,通过 i18nToken 注入运行时,再按这个语言生成文案。report-message.ts 定义服务:

TypeScript
import metadata from "../../package.json" with { type: "json" };
import type { I18nRuntime } from "@nocobase/i18n";
import { createServiceToken } from "@nocobase/service-provider";

export class ReportMessageService {
  constructor(private readonly i18n: I18nRuntime) {}

  async render(recipientLocale: string, name: string) {
    const locale = this.i18n.resolveLocale(recipientLocale);
    await this.i18n.ensureLocaleLoaded(locale);
    const t = this.i18n.getFixedT(metadata.name, locale);
    return {
      locale,
      subject: t("reports.subject", { name }),
      message: t("reports.ready", { name }),
    };
  }
}
export const reportMessageToken =
  createServiceToken<ReportMessageService>("report-message");

resolveLocale() 将偏好解析为支持的语言;ensureLocaleLoaded() 加载资源;getFixedT() 将翻译函数绑定到命名空间和语言。顺序不能省略,否则尚未加载的译文可能回退为默认文案或 key。命名空间使用应用 package.json 的 name。

ReportMessageProvider 负责注入运行时:

TypeScript
import type { Application } from "@nocobase/app-server/application";
import { i18nToken } from "@nocobase/app-server/i18n";
import { ServiceProvider } from "@nocobase/service-provider";
import {
  ReportMessageService,
  reportMessageToken,
} from "../services/report-message.js";

export default class ReportMessageProvider extends ServiceProvider<Application> {
  readonly name = "report-message";
  override register() {
    this.app.container.singleton(
      reportMessageToken,
      (container) => new ReportMessageService(container.resolve(i18nToken)),
    );
  }
}

在已有应用的 server/providers/index.ts 中加入该 Provider,保留其他 Provider。server/services/ 只是组织代码的目录,不会自动注册服务。示例的 App 入口 已完成注册。

运行 pnpm demo background zh-CN,命令从容器取得服务,在没有请求上下文时输出:

JSON
{
  "locale": "zh-CN",
  "subject": "你的报表:Q3",
  "message": "报表 Q3 已准备好。"
}

并发生成不同语言的消息

应用启动后,后台任务或命令可以复用服务,为不同接收人同时生成消息。以下调用方式见演示脚本:

TypeScript
import { reportMessageToken } from './server/services/report-message.js';

// app has completed app.start().
const service = app.container.resolve(reportMessageToken);
const messages = await Promise.all([
  service.render('en-US', 'Q3'),
  service.render('zh-CN', 'Q3'),
  service.render('fr-FR', 'Q3'),
]);

运行 pnpm demo concurrent,三条消息依次为 Report Q3 is ready.、报表 Q3 已准备好。、Report Q3 is ready.,实际语言依次为 en-US、zh-CN、en-US。

每次调用都显式固定接收人语言,不调用共享运行时的 changeLanguage()。这样多个任务和 HTTP 请求不会争用一个全局“当前语言”。接收人偏好应由业务数据提供,不应统一取触发任务的用户语言。

覆盖插件的服务端文案

在应用语言对象的 overrides 下,以插件的完整包名为键覆盖对应服务端文案。例如,给英文资源增加:

TypeScript
// Inside the default export of server/locales/en-US.ts:
overrides: {
  '@example/report-delivery': {
    delivery: {
      ready: 'Your report {{name}} is available in the customer portal.',
    },
  },
},

中文资源在相同位置增加 ready: '报表 {{name}} 已可在客户门户中查看。'。应用覆盖在插件资源加载后应用;未覆盖的键保留插件原文,不需要修改插件文件或 node_modules/。

请求处理器显式指定插件命名空间,Hono 不会根据文件位置推断它:

TypeScript
const t = getRequestTranslator(c, '@example/report-delivery');
return c.json({ data: {
  locale: getRequestLocale(c),
  message: t('delivery.ready', { name: c.req.valid('query').name }),
  channel: t('delivery.channel'),
} });

运行 pnpm demo override zh-CN,消息变为“报表 Q3 已可在客户门户中查看。”,未覆盖的 delivery.channel 仍为插件提供的“客户门户”。

示例插件资源、应用覆盖和请求入口共同演示这个结果。@example/report-delivery 是示例内注册的资源插件;在自己的应用中替换为真实插件包名和键。插件声明方式见插件服务端多语言。

处理面向用户的错误提示

API 错误的 reason、domain 和 HTTP 状态保持稳定,message 保留面向开发者的英文说明。需要由服务端返回用户提示时,在 localizedMessage 中提供实际语言和译文。

以下摘自 report-not-ready 处理器,该处理器使用前面相同的查询参数校验和 apiErrorHandler:

TypeScript
import { ApiError } from '@nocobase/app-server/router';
import {
  getRequestLocale, getRequestTranslator,
} from '@nocobase/i18n/server';

// Inside the validated /report-not-ready handler:
throw new ApiError({
  status: 'FAILED_PRECONDITION',
  reason: 'REPORT_NOT_READY',
  domain: 'reports',
  message: 'The requested report is not ready.',
  localizedMessage: {
    locale: getRequestLocale(c)!,
    message: getRequestTranslator(c)(
      'reports.notReady', { name: c.req.valid('query').name },
    ),
  },
});

运行 pnpm demo error zh-CN,会故意触发报表未就绪状态,返回 HTTP 400、reason: "REPORT_NOT_READY"、domain: "reports",以及:

JSON
{
  "locale": "zh-CN",
  "message": "报表 Q3 尚未准备好。"
}

改为英文请求时,仅用户提示的语言变化;程序仍按稳定标识处理错误,不比较译文。客户端也可以按错误标识自行翻译,见请求校验与错误处理。

验证翻译与语言回退

在完整模板应用中,pnpm nocobase locales check 比较前后端语言文件是否对应,不检查 loader、翻译键或插值结果。pnpm typecheck 结合资源类型检查键结构。示例未安装模板 CLI,使用 pnpm typecheck 和 pnpm test。

运行时检查要使用真实资源和中间件:示例测试 覆盖中英文插值、请求头优先级、会话隔离、默认语言回退、插件覆盖,以及并发请求和后台消息。还检查缺少中间件时的明确错误、非法输入,以及本地化前后保持一致的业务错误标识。

不支持的语言与缺失的 key 是两种情况:前者回退到应用默认语言;后者依次尝试默认语言和英文资源,均不存在时可能返回 key。测试通过独立的不完整资源复现缺失翻译,正常应用仍应补齐各语言。

示例源码与运行

完整源码与 README包含版本要求、用法与源码对应关系,以及每个命令的预期结果。示例使用 Node.js 24、pnpm 11.7.0、App Server 3.0.0-beta.5 和 I18n 2.0.0-beta.1,依赖版本与默认模板 1.0.0-beta.85 对齐。

Bash
git clone https://github.com/nocobase/docs-examples-v3.git
cd docs-examples-v3
git checkout 44cc65d121ea8cebf26fee77c924119cc57f4aad
cd apps/server-localization
pnpm install --frozen-lockfile
pnpm demo request zh-CN
pnpm test

这是直接组合 NocoBase Application 和 Provider 的最小示例,不是完整默认模板。命令在同一进程中执行请求或服务调用,结束时关闭应用,不需要浏览器、数据库或邮件服务。将代码用于已有应用时,保留模板的运行时、配置和中间件,仅合并语言资源、路由和 Provider 注册。

NocoBase 文档助手

关于 NocoBase,你想了解什么?

每次提问独立回答,不会参考之前的问答。请提供完整背景。