NestJS 国际化实战总结

前端存在国际化,后端也存在国际化。

前端 React 使用 react-i18next,vue 使用 vue-i18n; 后端 NestJS 就使用 nestjs-i18n

i18n: 是 internationalization 的缩写,是通过取第一个字母(i),最后一个字母(n),以及中间18个字符组成的。

下面来了解 nest-i18n 在 Nestjs 中是如何使用的。

本篇需要了解 nestjs 的基本语法

基本使用

先安装依赖

bash 复制代码
pnpm add nestjs-i18n --save # 版本号为:^10.5.1

然后就是定义一个全局 i18n 模块

ts 复制代码
// i18m.module.ts
import { Module, Global } from "@nestjs/common"
import { I18nModule, QueryResolver, HeaderResolver } from "nestjs-i18n"
import * as path from "path"
import { I18nNestService } from "./i18n.service"
​
@Global()
@Module({
  imports: [
    I18nModule.forRoot({
      // 默认语法
      fallbackLanguage: "zh",
      // 国际化文件配置路径
      loaderOptions: {
        path: path.join(__dirname, "../../i18n/"),
        watch: true,
      },
      // 支持 query 或者 header 传递 lang 参数(有顺序问题)
      resolvers: [new QueryResolver(["lang", "l"]), new HeaderResolver(["lang"])],
    }),
  ],
  providers: [I18nNestService],
  exports: [I18nNestService],
})
export class I18nNestModule {}
  • fallbackLanguage 就是配置一种默认语法;比如 en 或者 zh; 这个属性值根据你的配置文件(src/i18n)里面的文件目录保持一致。比如我的:
  • loaderOptions: 就是加载对应的配置文件;一般来说 i18n 放在 src 目录下,但是我的 i18n.module.ts 放在 src/core/i18n 下面,那么就需要退两级来找到 i18n 配置文件。

    ts 复制代码
    loaderOptions: {
      // 退了两级
      path: path.join(__dirname, "../../i18n/"),
      watch: true,
    },
  • resolvers: 解析器。也就是从哪里读取当前语言信息;QueryResolver 从 query 中读取(比如: ?lang=en 或者是 ?l=zh);HeaderResolver 从 header 中读取(在 header 里面加上 lang 属性即可)。当然还有其他的解析器。

  • @Global 把 I18nNestModule 定义成一个全局的模块,后续的 I18nService 可以直接导入使用。那么看看 I18nService

ts 复制代码
// i18n.service.ts
​
import { Injectable } from "@nestjs/common"
import { I18nService, I18nContext } from "nestjs-i18n"
​
@Injectable()
export class I18nNestService {
  constructor(private readonly i18n: I18nService) {}
​
  t(key: string, options?: Record<string, any>): string {
    return this.i18n.t(key, {
      ...options,
      // 读取语言信息
      lang: I18nContext.current().lang,
    })
  }
}

那么在使用的使用,直接传递 key 值就行了;如:this.i18n.t('system.hello')

当配置好之后,就可以启动 nest 项目了。对了,还差一步,nest 项目启动会打成一个 dist 包,但是 i18n 没有引入,不会被打入进包,需要配置一下自动复杂。

json 复制代码
// nest-cli.json
{
  "$schema": "https://json.schemastore.org/nest-cli",
  "collection": "@nestjs/schematics",
  "sourceRoot": "src",
  "compilerOptions": {
    "deleteOutDir": true,
    // 新增(自动复制国际化静态资源)
    "assets": [{ "include": "i18n/**/*", "watchAssets": true }]
  }
}

上面准备工作做完了,来测试一下接口的返回值

ts 复制代码
import { Controller, Get, Inject } from "@nestjs/common"
import { I18nNestService } from "src/core/i18n/i18n.service"
​
@Controller("auth")
export class AuthController {
​
  @Inject(I18nNestService)
  i18n: I18nNestService
​
  @Get("test")
  test() {
    return this.i18n.t("system.test")
  }
}

直接使用 I18nNestService,调用 t 函数传递一个 key 即可。

json 复制代码
// i18n/en/system
{
  "test": "hello"
}
// i18n/zh/system
{
  "test": "你好"
}

默认是中文zh(因为上面注册模块时,配置了默认值)

当在 query 上设置 lang 属性,属性值为 en (header 也是一样的)

还有一种情况,针对 post 请求 dto 的验证,那么该怎么写呢?

定义一个 dto

ts 复制代码
import { IsString, IsNotEmpty, IsNumber } from "class-validator"
import { i18nValidationMessage } from "nestjs-i18n"
​
export class TestDto {
  @IsString({
    message: i18nValidationMessage("system.isString", {
      key: "name",
    }),
  })
  @IsNotEmpty({
    message: i18nValidationMessage("system.notEmpty", {
      key: "name",
    }),
  })
  name: string
​
  @IsNumber(
    {},
    {
      message: i18nValidationMessage("system.isNumber", {
        key: "age",
      }),
    },
  )
  @IsNotEmpty({
    message: i18nValidationMessage("system.notEmpty", {
      key: "age",
    }),
  })
  age: number
}

i18nValidationMessage 是针对国际化动态传递参数的

国际化文件配置信息

json 复制代码
// en/system.json
{
  "notEmpty": "{key} can not be empty",
  "isString": "{key} mast be a string",
  "isNumber": "{key} mast be a number"
}

// zh/system.ts
{
  "notEmpty": "{key}不能为空",
  "isString": "{key}必须是字符串",
  "isNumber": "{key}必须是数字"
}

针对 DTO 的验证,需要使用到 AOP 中的 pipe。

@nestjs/common提供的 ValidationPipe 是无法获取到国际化的错误信息的,需要借助 nestjs-i18n 提供的 I18nValidationPipe。在 main.ts 中使用一下:

ts 复制代码
// main.ts
app.useGlobalPipes(
  new I18nValidationPipe({
    whitelist: true,
    transform: true,
  }),
)

验证 post 请求的 dto

ts 复制代码
import { Controller, Post, Body, Inject } from "@nestjs/common"
import { TestDto } from "./dto/auth.dto"
import { I18nNestService } from "src/core/i18n/i18n.service"

@Controller("auth")
export class AuthController {
  @Inject(I18nNestService)
  i18n: I18nNestService

  @Post("testPost")
  testPost(@Body() user: TestDto) {
    return this.i18n.t("system.test")
  }
}

调用接口,默认情况下,是中文

在 header 中设置 lang 属性,属性值为 en,继续请求

发现效果跟预想的都是一样。

这就是 nestjs-i18n 的大致语法,其实跟前端的使用方法大致一样。

项目中的二次封装

dto 的封装

问题一眼就看出来了

  1. 逻辑清晰,但是代码量比较多
  2. 这里 dto 才两个字段,如果是存在很多字段,这种写法就很不可观了。

那么就需要优化一下。

在这里,自己封装三个装饰器 @Empty()@RequireString()RequireNumber()

ts 复制代码
// validate.decorator.ts

import { IsString, IsNotEmpty, IsNumber } from "class-validator"
import { applyDecorators } from "@nestjs/common"
import { i18nValidationMessage } from "nestjs-i18n"

/**
 * 非空
 */
const Empty = (name?: string) =>
  IsNotEmpty({
    message: args =>
      i18nValidationMessage("system.notEmpty", {
        key: name || args.property,
      })(args),
  })

/**
 * 必填字符串
 */
export const RequireString = (name?: string) =>
  applyDecorators(
    IsString({
      message: args =>
        i18nValidationMessage("system.isString", {
          key: name || args.property,
        })(args),
    }),
    Empty(name),
  )

/**
 * 必填数字
 */
export const RequireNumber = (name?: string) =>
  applyDecorators(
    IsNumber(
      {},
      {
        message: options =>
          i18nValidationMessage("system.isNumber", {
            key: name || options.property,
          })(options),
      },
    ),
    Empty(name),
  )

@nestjs/common 提供了 applyDecorators 函数,用于合并多个装饰器。

那么重新编写 dto 文件

ts 复制代码
export class TestDto {
  @RequireString()
  name: string
  @RequireNumber()
  age: number
}

是不是简洁了很多。可以测试一下,效果是一样的。

犯错记录

针对 i18nValidationMessage 可以动态传递 key 值,但是 key 又是动态获取的,这里遇到了一个卡点,花费了很多时间。

无论是 IsString, IsNumber, IsNotEmpty 都能接受一个 message,其类型是 string | ((validationArguments: ValidationArguments) => string)

这里我们采用函数的形式,函数里面的参数可以动态的拿到属性值,然后传递给 i18nValidationMessage 函数。

i18nValidationMessage 函数的类型:

ts 复制代码
// 简化了一下
function i18nValidationMessage(key: Path<K>, args?: any): (a: ValidationArguments) => string;
  1. 参数一:国际化 key 值
  2. 参数二(可选):动态传递的值(注意类似,虽然类型 any,但是只有传递对象,才有实际效果;传递一个函数,返回一个对象也是不行的)
ts 复制代码
// 那么就直接采用 message 函数形式直接返回 i18nValidationMessage,可以还是报错
IsString({
 message: args =>
   i18nValidationMessage("system.isString", {
     key: name || args.property,
   }),
})

// 可以根据类型分析,i18nValidationMessage 返回的也是一个函数,其类型跟 message 函数返回的类型是一样的,那么就需要进行二次调用
IsString({
 message: args =>
   i18nValidationMessage("system.isString", {
     key: name || args.property,
   })(args)
})

ERROR:最初没有二次调用,找了一半天的错误。

I18nValidationExceptionFilter 的封装

前端错误提示信息,是根据后端返回的 message 来进行提示用的,但是当 dto 不满足条件时,返回的格式是这样的:

message 是一个数组的形式,这对前端展示错误信息是不友好的,message 格式没有统一。

而返回一个数组的形式,是 nestjs-i18n 提供的 I18nValidationExceptionFilter 处理后返回的形式。那只需要在此基础上,继续优化一下就行了。

ts 复制代码
// i18n.filter.ts

import { Request } from "express"
import { I18nValidationExceptionFilter } from "nestjs-i18n"
import { BUSINESS_CODE } from "src/enums"

export class I18nValidationFilter extends I18nValidationExceptionFilter {
  constructor() {
    super({
      //返回简单的格式形式
      detailedErrors: false,
      // 自定义body返回的格式
      responseBodyFormatter: (host, _, errors: any) => {
        // 参数三:errors 就是我们上面看到的数组
        const ctx = host.switchToHttp()
        const request = ctx.getRequest<Request>()

        return {
          success: false,
          timestamp: new Date().toISOString(),
          message: errors.join(", "), // 转化成字符串
          code: BUSINESS_CODE.PARAMS_ERROR,
          path: request.url,
        }
      },
    })
  }
}

然后全局注册一下 filter 即可,就能使用了(全局注册,就不用多说了),继续调用接口

message 就是字符串了,前端就可以直接展示即可。

异常统一国际化处理

针对一些错误请求,比如说:

  • 服务报错
  • 用户不存在
  • 没有权限
  • ...

上面的错误信息,如果都需要国际化,是不是在每个地方都需要转化一下,在进行返回。

其实不需要的,直接在异常拦截中,进行转化,只需要再报错的地方,返回对应的错误信息key值即可。

类似这样的,直接返回 key 值。然后呢,在 http 异常统一处理的时候拦截

ts 复制代码
// 拦截 http 异常处理
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  private logger = new Logger("HttpExceptionFilter")
	
  // 注入 I18nNestService 服务
  @Inject(I18nNestService)
  private i18n: I18nNestService
  
  catch(exception: HttpException, host: ArgumentsHost) {
    
    // ...
    
    const exceptionErrorInfo: any = exception.getResponse()
    const errorMessage = exceptionErrorInfo?.['message']
    response.status(status).json({
      success: false,
      timestamp: new Date().toISOString(),
      // 国际化转化
      message: typeof errorMessage === "string" ? this.i18n.t(errorMessage) : errorMessage,
      code: BUSINESS_CODE.SYSTEM_ERROR,
      path: request.url,
    })
  }
}

就算 key 在国际化文件中没有配置,那么就会原封不动的返回 key 值,所以没有报错风险。

若后续在 nestjs 开发中,如果发现针对国际化的封装,可以继续追加。

总结

nestjs-i18n的使用你学会了吗?

核心 api 就这几个:

  • I18nModule:注册全局配置模块
  • I18nValidationPipe:dto 的拦截
  • I18nContext:获取当前国际化的配置信息
  • i18nValidationMessage:动态传值
  • I18nValidationExceptionFilter:国际化错误拦截处理
相关推荐
Asthenia04128 分钟前
从零了解 Maven 插件:面试官问我对插件的那些事儿
后端
Asthenia041213 分钟前
面试复盘:Maven依赖范围与生命周期/微服务项目pom结构
后端
Seven9732 分钟前
Java24发布,精心总结
java·后端
Asthenia041233 分钟前
博客:面试复盘 - JUnit 相关知识点总结
后端
JavaGuide35 分钟前
社招 Java 中厂面试记录,难度有点大!
java·后端
无奈何杨37 分钟前
基于规则引擎的决策系统开放在线体验了
后端
计算机-秋大田43 分钟前
基于Spring Boot的戒烟网站的设计与实现(LW+源码+讲解)
java·vue.js·spring boot·后端·课程设计
小马爱打代码43 分钟前
SpringBoot 7 种实现 HTTP 调用的方式
spring boot·后端·http
追逐时光者1 小时前
5款高效的文件搜索工具,工作效率提升利器!
后端
油丶酸萝卜别吃1 小时前
springBoot与ElementUI配合上传文件
spring boot·后端·elementui