面向海外用户的赛事官网和多语言推广页面,很多团队都是在项目排期后期才补国际化能力,结果往往是最初用硬编码文案搭的页面全部推倒重来。这篇文章围绕「多语言国际化(i18n)与本地化(l10n)的完整落地」展开,从 locale 概念、语言包设计,到 Spring Boot 后端的语言协商、前端语言切换、西班牙语本地化的特殊坑点,给出一套可复制的代码与排查清单。无论你是刚接触国际化的学生开发者,还是正在给业务系统加多语言支持的后端工程师,都可以照着本文一步步跑通。
1. 多语言国际化的核心概念与业务场景
1.1 i18n 与 l10n 到底有什么区别
很多新手会把“国际化”和“本地化”当成同一件事,实际工程里这是两个阶段、两拨人、两套产出。国际化(internationalization,常缩写为 i18n,因为首尾字母 i 和 n 之间还有 18 个字符)指的是在代码和架构层面为多语言做好准备:把文案从代码里抽出来、把日期数字货币的格式化交给 locale、把布局做成可伸缩的。它的产出是“能力”。本地化(localization,l10n)指的是针对某个具体语言和地区,把文案翻译好、把格式适配好、把文化习惯处理好。它的产出是“语言包和翻译资产”。
这个区分不是学术洁癖,而是直接影响排期和分工。如果一开始没做 i18n,等到临时要加西班牙语版本,你会发现所有中文文案都散落在 JSP、Vue 组件、Controller 的return "报名成功"里,前端后端加起来几百处硬编码,翻译团队拿到的是没法下手的代码。反过来,只要骨架搭好了,新增一个语种本质上只是新增三个.properties文件和一个 JSON 文件,代码一行都不用改。
所以判断一个项目国际化做得好不好,有个很朴素的标准:新增一个语种需要改多少个 Java 文件或组件文件?答案应该是零。本文后面所有的设计,都是围绕这个标准展开的。
1.2 locale 标记与语言协商机制
locale 是国际化里最基础的数据结构,它描述的不是“语言”,而是“语言 + 地区 + 可选的书写变体”。同样是西班牙语,西班牙本土用es-ES,墨西哥用es-MX,两者在词汇和日期习惯上都有差异。工程上一般使用 BCP 47 语言标签,格式是语言-地区,语言用小写、地区用大写。
| 语言标签 | 含义 | 常见差异点 |
|---|---|---|
zh-CN | 简体中文(中国大陆) | 日期2025年6月30日,数字千分位用逗号 |
zh-TW | 繁体中文(台湾地区) | 词汇差异较大,如“软件/軟體” |
en-US | 美式英语 | 日期June 30, 2025,货币前置$1,234.56 |
en-GB | 英式英语 | 日期30 June 2025,与美式顺序不同 |
es-ES | 西班牙西班牙语 | 日期30 de junio de 2025,小数点用逗号 |
es-MX | 墨西哥西班牙语 | 数字格式与es-ES也有细微差别 |
语言协商(content negotiation)解决的是“这次请求该用哪个 locale”的问题。HTTP 标准里,浏览器会通过Accept-Language请求头发一串按权重排序的语言列表,例如Accept-Language: es-ES,es;q=0.9,en;q=0.8。服务端按优先级匹配自己支持的语言,匹配不上就走默认语言。这是最“无感”的方案,用户什么都不用点,浏览器语言是西语就自动返回西语页面。
但真实业务里,语言协商不能只靠请求头。用户可能是中文浏览器但想刷西语内容,也可能是同一台电脑被多个用户共用,这时候就需要显式切换:URL 参数?lang=es、Cookie 记住选择、或者用户账号里存一个偏好字段。成熟的方案通常是三层叠加:URL 参数优先级最高(分享链接时能带上语言),其次是 Cookie(记住用户上次的选择),最后才是Accept-Language(兜底猜测)。
1.3 哪些场景必须提前规划多语言
第一类是面向海外用户的展示型站点,比如赛事官网、活动推广页、招生宣传页。这类页面文案量大、更新频繁,运营同学经常要改,所以文案必须和代码解耦,最好能通过配置中心或 CMS 下发。
第二类是带表单和校验的业务系统,比如线上报名、投稿、问卷调查。这类系统的难点不在页面文案,而在后端返回的校验消息和错误码。如果后端直接返回“手机号格式不正确”,前端无论怎么做多语言都没用,因为这句话是后端拼好的中文。
第三类是内容分发类场景,比如留学生社群里分享的活动资讯、多语言的活动规则说明。这类场景还要额外考虑用户生成内容(UGC)的处理,用户提交的西语文本里可能带重音字符á é í ó ú ñ,编码处理不当就会变成乱码。
这三类场景的共同结论是:国际化不是前端一个组件库能解决的事,它是一条从前端渲染、HTTP 协商、后端消息源到数据库存储的完整链路。下面我们就从环境准备开始,一步步把它搭起来。
2. 环境准备与项目结构
2.1 运行环境与技术选型
本文的实战案例使用 Spring Boot 作为后端,前端为了让大家能直接复制运行,采用原生 HTML + JavaScript,不引入任何构建工具。这样你不需要装 Node 环境,启动 Spring Boot 就能访问页面。实际项目中如果你用 Vue 或 React,把本文前端那部分的思路平移到i18n插件上即可,后端部分完全不用改。
环境说明如下,具体版本请以你本地或公司现有项目的实际版本为准,本文重点演示配置思路和代码结构,不绑定某一特定小版本:
| 项目 | 说明 |
|---|---|
| 操作系统 | Windows / macOS / Linux 均可 |
| JDK | 建议使用长期支持版本,Spring Boot 3.x 需要 JDK 17 及以上 |
| 构建工具 | Maven 或 Gradle,本文示例用 Maven |
| 框架 | Spring Boot Web + Validation |
| 数据库 | 本案例不涉及,文案存储在资源文件中 |
| 浏览器 | Chrome / Edge / Firefox 任意现代浏览器 |
| IDE | IntelliJ IDEA 或 VS Code,能用文本编辑器也行 |
这里有个容易被忽略的点:JDK 版本决定了字符集处理的默认行为。JDK 18 之后默认字符集统一为 UTF-8,之前的版本在某些平台上会取系统默认编码。如果你的项目跑在 JDK 8 或 11 上,读取.properties文件时就必须显式指定UTF-8,否则西班牙语的重音字符会变成问号或乱码。这不是理论问题,是实际排查中最常见的一类 bug。
2.2 示例项目目录结构
先把整体骨架看清楚,后面每一节讲的都是往这些文件里填内容。目录结构如下:
i18n-demo/ ├── pom.xml └── src └── main ├── java/com/example/i18ndemo │ ├── I18nDemoApplication.java │ ├── config │ │ ├── LocaleConfig.java │ │ └── MessageSourceConfig.java │ ├── controller │ │ └── RegistrationController.java │ └── dto │ └── RegistrationRequest.java └── resources ├── application.yml ├── i18n │ ├── messages.properties │ ├── messages_zh_CN.properties │ ├── messages_en_US.properties │ └── messages_es_ES.properties └── static ├── index.html ├── app.js └── locales ├── zh-CN.json ├── en-US.json └── es-ES.jsoni18n目录放的是后端业务文案和校验消息,static/locales放的是前端静态 UI 文案。这个分工不是随意的,第 5 节会专门解释为什么不能把两边合成一份。
2.3 添加 Maven 依赖
依赖不多,核心就是 Web 和参数校验:
<!-- 文件路径:pom.xml --> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> </dependencies>如果你的项目需要完整的 ICU 消息语法(比如复数、选择、嵌套占位符),还需要额外引入 ICU4J。这里先说明结论:Spring 默认使用的java.text.MessageFormat不支持plural语法,直接写{0, plural, one{...} other{...}}会抛异常。想要 ICU 语法就得换实现,本文第 3 节会给一个不依赖 ICU4J 也能处理复数的替代方案。
3. 语言包设计与核心配置原理
3.1 语言包命名规则与 fallback 链
Spring 通过ResourceBundle的命名约定来定位语言包,文件名格式是basename_语言_地区.properties。查找时的回退顺序大概是这样的:messages_es_ES.properties→messages_es.properties→messages.properties。最后那个不带任何后缀的messages.properties是兜底文件,它必须存在,而且必须写默认语言的内容。
这个回退链在实际项目中非常有用。假设你支持简体中文、英语、西班牙语三种语言,但西班牙语只翻译了 80%,剩下 20% 还没翻。这时那些缺失的 key 会沿着回退链一路往下找,最终落到默认语言上。页面不会报错,只是夹杂几行中文。比起整个页面 500 错误,这种降级体验明显更好。
不过要注意一个坑:回退不能滥用到掩盖问题。如果你把messages.properties写成中文,然后把所有中文都留在兜底文件里、语种文件只放差异部分,短期看没问题,长期会变成“每个语种文件里有一堆 key 缺失,谁也不知道哪些是真没翻、哪些是故意复用”。推荐的做法是:兜底文件写默认语言(比如中文)的全量key,其余语种文件也写全量 key,缺失的用 TODO 注释标记,配合脚本做 key 对齐检查。
3.2 占位符、复数与性别一致性
文案里带变量是最常见的需求,比如“还剩 3 个名额”。占位符用{0}、{1}这种索引形式,参数通过数组传入:
# 文件路径:src/main/resources/i18n/messages_zh_CN.properties registration.remaining=当前还剩 {0} 个名额但一到西班牙语,问题就来了:西语的单复数变化比英语更严格,1 plaza disponible和3 plazas disponibles形态完全不同。由于 Spring 默认的MessageFormat不支持 ICU 的plural语法,工程上最常见的做法是后缀约定 + 代码判断:
# 文件路径:src/main/resources/i18n/messages_es_ES.properties registration.remaining.one=Queda {0} plaza disponible registration.remaining.other=Quedan {0} plazas disponibles然后在代码里根据数量选择 key。这个方案看起来土,但它零依赖、可测试、翻译同学一看就懂,比引入整套 ICU 更可控。如果你的项目语种超过五种、复数规则复杂(比如阿拉伯语有六种复数形态),那还是老老实实引入 ICU4J:
<dependency> <groupId>com.ibm.icu</groupId> <artifactId>icu4j</artifactId> </dependency>另一个中文开发者容易忽略的点是性别一致性。西班牙语的形容词、冠词要跟名词的阴阳性保持一致,比如el usuario registrado(阳性,已注册的用户)和la usuaria registrada(阴性)。如果文案里出现“欢迎你,XXX”这种带人称的表达,翻译成西语时就可能没法用中性形式表达。解决办法有两个:一是文案设计上尽量避开带性别的形容词,用名词短语替代;二是改用“欢迎”这类无性别词汇。这个约束要在写中文文案阶段就告诉翻译团队,等到翻译完再回来改,返工成本很高。
3.3 日期、数字、货币的本地化格式
这是国际化里最容易出事故的部分,因为格式错了页面不一定报错,但用户会看懵。同一个日期2025-06-30,在三种 locale 下的表现完全不同:
| 内容 | zh-CN | en-US | es-ES |
|---|---|---|---|
| 长日期 | 2025年6月30日 | June 30, 2025 | 30 de junio de 2025 |
| 数字 1234.56 | 1,234.56 | 1,234.56 | 1.234,56 |
| 货币 | ¥1,234.56 | $1,234.56 | 1.234,56 € |
| 百分比 | 85% | 85% | 85 %(有空格) |
注意西班牙语的两个细节:小数点和千分位符号跟中文英语是反过来的,货币符号在数字后面并带一个空格。如果你的前端用手工字符串拼接amount.toFixed(2),西语用户看到的就是错的。正确做法是交给语言运行时自带的格式化 API:
- 后端 Java:
DateTimeFormatter.ofLocalizedDate(...).withLocale(locale)、NumberFormat.getCurrencyInstance(locale) - 前端 JS:
Intl.DateTimeFormat(locale)、Intl.NumberFormat(locale, { style: 'currency', currency: 'EUR' })
这些 API 的格式化规则来自 CLDR 数据,随语言运行时更新,比手写映射表靠谱得多。
3.4 三个高频误区
第一个误区是在语言包里拼接句子。比如把"用户"和"登录成功"拆成两个 key,前端拼起来显示。中文能拼,英语勉强能拼,到了西语语序一变就全乱了。原则是:一个完整的句子(包括它的标点)对应一个 key。
第二个误区是把 HTML 标签写进语言包。welcome=<b>欢迎</b> {0}这种写法在前后端分离的项目里会带来 XSS 风险,而且翻译同学容易改坏标签。更稳妥的做法是语言包只放纯文本,样式通过前端组件控制,确实需要强调某段文字时用占位符拆分成多段渲染。
第三个误区是忘记处理编码。.properties文件传统的默认编码是 ISO-8859-1,Spring Boot 通过spring.messages.encoding指定为 UTF-8 来解决。但如果你自己new PropertyResourceBundle(inputStream)手动读,就必须显式用 UTF-8 构造 Reader,否则ñ和重音字符全部乱码。
4. 完整实战:后端语言协商 + 前端语言切换
这一节是全文的核心,我们做一个完整的“赛事报名提示”小功能:用户打开页面能看到标题、剩余名额、报名截止日期,可以切换中文、英语、西班牙语,后端返回的消息和日期格式跟随语言变化。
4.1 创建启动类与基础配置
先写启动类,这部分没有特殊逻辑:
// 文件路径:src/main/java/com/example/i18ndemo/I18nDemoApplication.java package com.example.i18ndemo; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class I18nDemoApplication { public static void main(String[] args) { SpringApplication.run(I18nDemoApplication.class, args); } }接着是application.yml。这里的关键是basename指向i18n/messages,注意不要带.properties后缀,也不要在前面加classpath:,Spring Boot 会自动处理:
# 文件路径:src/main/resources/application.yml server: port: 8080 spring: messages: basename: i18n/messages encoding: UTF-8 fallback-to-system-locale: false cache-duration: 3600逐行解释一下。encoding: UTF-8解决中文和西语重音字符的乱码问题,必须写。fallback-to-system-locale: false表示找不到匹配语言时不要用服务器操作系统的 locale 兜底,而是直接用默认语言,否则在中文 Windows 服务器上部署、用户请求fr,返回的可能就是莫名其妙的结果。cache-duration: 3600让语言包缓存一小时,避免每次请求都读文件;但副作用是你改了.properties后必须重启或等一小时才生效,开发阶段可以设成 0。
4.2 编写三套语言包
三套语言包的 key 必须完全一致,这是最容易出错的地方。先看中文:
# 文件路径:src/main/resources/i18n/messages_zh_CN.properties app.title=国际赛事报名平台 registration.title=赛事报名 registration.remaining.one=当前还剩 {0} 个名额 registration.remaining.other=当前还剩 {0} 个名额 registration.deadline=报名截止日期:{0} registration.success=报名提交成功,请留意邮箱通知 registration.email.blank=邮箱不能为空 registration.email.format=邮箱格式不正确英语版本:
# 文件路径:src/main/resources/i18n/messages_en_US.properties app.title=International Competition Registration registration.title=Competition Registration registration.remaining.one=Only {0} spot left registration.remaining.other=Only {0} spots left registration.deadline=Registration deadline: {0} registration.success=Your registration has been submitted. Please check your email. registration.email.blank=Email must not be empty registration.email.format=Invalid email format西班牙语版本,注意复数和重音字符:
# 文件路径:src/main/resources/i18n/messages_es_ES.properties app.title=Plataforma de inscripción a competiciones internacionales registration.title=Inscripción a la competición registration.remaining.one=Queda {0} plaza disponible registration.remaining.other=Quedan {0} plazas disponibles registration.deadline=Fecha límite de inscripción: {0} registration.success=Tu inscripción se ha enviado correctamente. Revisa tu correo electrónico. registration.email.blank=El correo electrónico no puede estar vacío registration.email.format=El formato del correo electrónico no es válido再补一个兜底文件messages.properties,内容与中文版本一致即可(用于未知 locale 的降级)。这里要提醒一点:西语文案里inscripción、límite、electrónico都带重音符号,如果编辑器保存为 ANSI 或 GBK 编码,这些字符会被破坏,务必确认文件编码是 UTF-8。
4.3 配置 LocaleResolver 与拦截器
Spring Boot 提供了三种LocaleResolver:AcceptHeaderLocaleResolver(只看请求头,无法切换)、SessionLocaleResolver(存 Session,重启丢失)、CookieLocaleResolver(存 Cookie,可持久化)。我们需要用户能主动切换语言,所以选CookieLocaleResolver,再用LocaleChangeInterceptor捕获?lang=参数写入 Cookie:
// 文件路径:src/main/java/com/example/i18ndemo/config/LocaleConfig.java package com.example.i18ndemo.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.LocaleResolver; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import org.springframework.web.servlet.i18n.CookieLocaleResolver; import org.springframework.web.servlet.i18n.LocaleChangeInterceptor; import java.time.Duration; import java.util.Locale; @Configuration public class LocaleConfig implements WebMvcConfigurer { @Bean public LocaleResolver localeResolver() { CookieLocaleResolver resolver = new CookieLocaleResolver("lang"); resolver.setDefaultLocale(Locale.SIMPLIFIED_CHINESE); resolver.setCookieMaxAge(Duration.ofDays(30)); resolver.setCookiePath("/"); return resolver; } @Override public void addInterceptors(InterceptorRegistry registry) { LocaleChangeInterceptor interceptor = new LocaleChangeInterceptor(); interceptor.setParamName("lang"); registry.addInterceptor(interceptor); } }这段代码有几个细节值得展开。new CookieLocaleResolver("lang")指定了 Cookie 名字,前端和后端要对齐。setDefaultLocale是兜底语言,当浏览器Accept-Language和 Cookie 都匹配不上支持的语言时用它。setCookieMaxAge(Duration.ofDays(30))让用户的选择保留 30 天,这个参数用Duration重载需要注意版本,老版本 Spring 只接受int秒数,如果你的项目报方法不存在,改成resolver.setCookieMaxAge(60 * 60 * 24 * 30)即可。
LocaleChangeInterceptor默认会拦截所有请求,任何人访问?lang=xx都会改变语言。如果这个参数对外暴露有安全考量,可以interceptor.setParamName("lang")之外再加setHttpMethods("GET")限制,或者用registry.addInterceptor(interceptor).addPathPatterns("/api/**")收窄作用范围。
4.4 自定义 MessageSource 与业务接口
如果你只用application.yml里的spring.messages配置,Spring Boot 会自动装配ResourceBundleMessageSource,够用。但一旦你想加自定义逻辑(比如从数据库动态取文案、加解密、加埋点),就需要自己声明MessageSourceBean:
// 文件路径:src/main/java/com/example/i18ndemo/config/MessageSourceConfig.java package com.example.i18ndemo.config; import org.springframework.context.MessageSource; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.support.ReloadableResourceBundleMessageSource; @Configuration public class MessageSourceConfig { @Bean(name = "messageSource") public MessageSource messageSource() { ReloadableResourceBundleMessageSource source = new ReloadableResourceBundleMessageSource(); source.setBasename("classpath:i18n/messages"); source.setDefaultEncoding("UTF-8"); source.setFallbackToSystemLocale(false); source.setCacheSeconds(3600); return source; } }这里有一个必须知道的坑:一旦你自己声明了名为messageSource的 Bean,application.yml中的spring.messages.*全部失效,两套配置不会合并。很多同学改完 yml 发现没生效,就是因为项目里已经有自定义 MessageSource 了。反过来说,如果你想保留 yml 配置,就别声明这个 Bean。
然后写业务接口。注意方法参数直接声明Locale locale,Spring MVC 会自动用LocaleResolver解析好的 locale 注入进来:
// 文件路径:src/main/java/com/example/i18ndemo/controller/RegistrationController.java package com.example.i18ndemo.controller; import org.springframework.context.MessageSource; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.time.format.FormatStyle; import java.util.LinkedHashMap; import java.util.Locale; import java.util.Map; @RestController @RequestMapping("/api/registration") public class RegistrationController { private final MessageSource messageSource; public RegistrationController(MessageSource messageSource) { this.messageSource = messageSource; } @GetMapping("/notice") public Map<String, Object> notice(Locale locale, @RequestParam(defaultValue = "3") int remaining) { Map<String, Object> result = new LinkedHashMap<>(); result.put("locale", locale.toString()); result.put("title", messageSource.getMessage("registration.title", null, locale)); String remainingKey = remaining == 1 ? "registration.remaining.one" : "registration.remaining.other"; result.put("remaining", messageSource.getMessage( remainingKey, new Object[]{remaining}, locale)); LocalDate deadline = LocalDate.now().plusDays(30); String formatted = DateTimeFormatter .ofLocalizedDate(FormatStyle.LONG) .withLocale(locale) .format(deadline); result.put("deadline", messageSource.getMessage( "registration.deadline", new Object[]{formatted}, locale)); return result; } }messageSource.getMessage(key, args, locale)是核心 API。当 key 不存在时会抛NoSuchMessageException,生产环境更推荐用三参数版本getMessage(key, args, defaultMessage, locale)给一个默认值,避免因为漏翻一个 key 导致整个接口 500。日期格式化用了FormatStyle.LONG,中文输出2025年7月30日,西语输出30 de julio de 2025,不需要自己写映射表。
4.5 参数校验消息的多语言处理
表单校验是最能体现前后端分离痛点的场景。@NotBlank默认返回英文的must not be blank,我们要让它跟随语言变化,需要把message写成{key}形式引用语言包:
// 文件路径:src/main/java/com/example/i18ndemo/dto/RegistrationRequest.java package com.example.i18ndemo.dto; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; public class RegistrationRequest { @NotBlank(message = "{registration.email.blank}") @Email(message = "{registration.email.format}") private String email; public String getEmail() { return email; } public void setEmail(String email) { this.email = email; } }注意jakarta.validation的包名,Spring Boot 3 之后从javax.validation迁移到了jakarta.validation,如果你用的是 Spring Boot 2.x,需要改回javax.validation,这是升级时最常踩的包名坑。另外,{key}形式能否被正确解析为语言包里的文案,取决于容器里MessageSource是否正确注册,也就是上一节说的那个 Bean。如果校验消息显示的是原始 key 而不是翻译后的文案,八成是 MessageSource 没装配好或者 key 拼错了。
4.6 前端语言包与切换逻辑
前端负责静态 UI 文案,用 JSON 文件存储:
{ "page.title": "国际赛事报名平台", "page.switch": "切换语言", "page.submit": "立即报名", "page.loading": "加载中..." }{ "page.title": "Plataforma de inscripción a competiciones internacionales", "page.switch": "Cambiar idioma", "page.submit": "Inscribirse ahora", "page.loading": "Cargando..." }页面结构很简单,重点是app.js里的加载与渲染逻辑:
<!-- 文件路径:src/main/resources/static/index.html --> <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>i18n demo</title> <style> body { font-family: system-ui, sans-serif; padding: 32px; line-height: 1.8; } .bar { margin-bottom: 24px; } button { padding: 6px 14px; margin-right: 8px; cursor: pointer; } </style> </head> <body> <div class="bar"> <button>// 文件路径:src/main/resources/static/app.js const LANG_MAP = { zh: 'zh-CN', en: 'en-US', es: 'es-ES' }; const state = { lang: localStorage.getItem('lang') || 'zh', dict: {} }; async function loadDict(lang) { const locale = LANG_MAP[lang]; const res = await fetch(`/locales/${locale}.json`); if (!res.ok) { throw new Error(`语言包加载失败: ${locale}`); } return res.json(); } function t(key) { return state.dict[key] || key; } function renderStatic() { document.documentElement.lang = LANG_MAP[state.lang]; document.getElementById('page-title').textContent = t('page.title'); document.getElementById('submit-btn').textContent = t('page.submit'); } async function loadNotice() { const box = document.getElementById('notice'); box.textContent = t('page.loading'); try { const res = await fetch(`/api/registration/notice?lang=${state.lang}&remaining=1`); const data = await res.json(); box.textContent = `${data.title} | ${data.remaining} | ${data.deadline} | ${data.locale}`; } catch (e) { box.textContent = e.message; } } async function switchLang(lang) { state.lang = lang; localStorage.setItem('lang', lang); state.dict = await loadDict(lang); renderStatic(); await loadNotice(); } document.querySelectorAll('button[data-lang]').forEach(btn => { btn.addEventListener('click', () => switchLang(btn.dataset.lang)); }); (async function init() { state.dict = await loadDict(state.lang); renderStatic(); await loadNotice(); })();这段代码用textContent而不是innerHTML渲染文案,是一个有意为之的选择:语言包内容来自外部文件,用innerHTML会引入 XSS 风险,尤其当语言包将来改成从服务端下发、或者包含用户昵称这类动态内容时。
4.7 运行验证与预期结果
启动I18nDemoApplication,控制台看到Tomcat started on port 8080后,用浏览器访问http://localhost:8080/index.html。默认会展示中文界面。
直接用命令行验证后端协商更直观,用 curl 带上不同的Accept-Language:
curl -H "Accept-Language: es-ES" "http://localhost:8080/api/registration/notice?remaining=1"预期返回:
{ "locale": "es_ES", "title": "Inscripción a la competición", "remaining": "Queda 1 plaza disponible", "deadline": "Fecha límite de inscripción: 30 de julio de 2025" }再把remaining改成5:
curl -H "Accept-Language: es-ES" "http://localhost:8080/api/registration/notice?remaining=5"此时remaining字段应该变成Quedan 5 plazas disponibles,复数的n出现了。这一步验证通过,说明后缀约定的复数方案和语言包加载都正常。
最后验证切换参数和 Cookie:
curl -i "http://localhost:8080/api/registration/notice?lang=en&remaining=2"响应头里应该能看到Set-Cookie: lang=en,之后再请求不带lang参数,返回的仍然是英文。如果你发现 Cookie 没写进去,检查cookiePath和前端请求的路径是否匹配。
5. 常见问题与排查思路
国际化的问题有个共同特征:页面不报错,但内容不对。所以排查时要学会从“现象”反推“哪一层的 locale 或 key 出了问题”。下面这张表覆盖了绝大部分高频故障:
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
文案显示成registration.title这样的原始 key | key 拼写错误,或对应语种文件里没有这个 key | 用脚本对比各语种 key 差异,补齐缺失项 |
中文正常,西语显示???或乱码 | .properties文件不是 UTF-8 编码 | 编辑器另存为 UTF-8,确认spring.messages.encoding |
改了 yml 里的spring.messages没生效 | 项目里已自定义messageSourceBean,覆盖了自动配置 | 二选一,删掉自定义 Bean 或把配置搬进 Java 代码 |
?lang=es切换无效 | 拦截器没注册,或参数名与setParamName不一致 | 检查WebMvcConfigurer是否生效、是否被其他配置覆盖 |
日期显示成2025-07-30而不是本地化格式 | 用了DateTimeFormatter.ofPattern固定模板 | 换成ofLocalizedDate(...).withLocale(locale) |
西语数字1234.56显示成1,234.56 | 前端用字符串拼接或toFixed手工格式化 | 改用Intl.NumberFormat(locale) |
| 校验消息永远是英文 | @NotBlank的 message 没写成{key},或 Validation 依赖缺失 | 引入spring-boot-starter-validation,改成占位符形式 |
| 新增语种后部分页面还是中文 | 该语种未在supportedLocales或前端LANG_MAP中注册 | 前后端都要注册,缺一不可 |
除了表格里的通用问题,还有几个值得单独说的场景。
乱码排查的正确顺序。先确认文件本身编码(用file -i messages_es_ES.properties或编辑器状态栏),再确认 JVM 读文件时用的编码,最后确认 HTTP 响应的Content-Type是否带charset=UTF-8。三步任何一步错都会出现乱码,但现象略有不同:文件编码错是永久的,重启也没用;读文件编码错是启动后固定错;响应头缺失是部分浏览器正常部分浏览器乱码。
语言切换“时好时坏”。这类问题通常出在缓存。ResourceBundle有内置缓存,ReloadableResourceBundleMessageSource也有cacheSeconds。开发时改了文案没生效,先把setCacheSeconds(0),验证完再改回来。另外浏览器的localStorage也会记住上次的语言,测试切换功能时记得清一下,否则你会以为切换没生效,其实是它读到了缓存值。
生产环境文案更新流程。语言包打在 jar 包里,改文案就得重新发版,这在运营频繁改文案的场景下非常痛苦。解决办法是把语言包外置到配置目录,用setBasename("file:/opt/app/i18n/messages")指向外部文件,配合cacheSeconds实现热加载。这样运维改文件、等缓存过期即可生效,不需要重启。当然,这要求对配置文件目录做好权限控制和备份,不能让无关人员随便改。
6. 最佳实践与工程建议
6.1 文案分层的边界要划清楚
前面提到后端放业务消息、前端放静态 UI 文案,这个划分的依据是谁来决定这段文字。标题、按钮、导航这类纯展示文案,前端自己就能决定,放 JSON 文件里加载最快,也不需要发后端版本。而校验消息、业务状态提示、带业务数据的模板(比如“还剩 N 个名额”),必须由后端生成,因为只有后端知道 N 是多少、状态码是什么。
如果把这两类混在一起,会出现两种典型问题。一是前端把校验文案写死,后端加了新校验规则就不同步;二是后端把页面标题也管起来,每改一个字都要发版。更细的做法是让后端只返回错误码 + 参数,由前端根据错误码查自己的语言包渲染,这样后端完全不用管语言,Accept-Language都可以不要。这种方案适合前端高度自治的团队,但要求错误码体系维护得非常严谨。
6.2 key 的命名规范与可维护性
推荐用「模块.对象.属性」的三段式命名,比如registration.email.format、app.nav.home。这种命名的好处是排序后同类 key 会聚在一起,方便查漏。不要用msg1、msg2这种序号命名,一旦要删改就没人知道哪个能删。
另外建议给 key 加上上下文注释,因为翻译同学看不到页面,只看一句“提交”根本不知道是按钮还是标题。可以约定在.properties里这样写:
# 页面顶部报名按钮文案,动作用语,长度限制 10 字符 registration.submit=立即报名英文和西语文件里也保留同样的注释,翻译时对照着翻。这个习惯看起来麻烦,但在文案量超过两百条之后,能省掉大量来回确认的沟通成本。
6.3 前后端 locale 必须同源
一个隐蔽的 bug 是前后端 locale 不一致。用户在前端点了西语,前端加载了es-ES的 JSON,但请求后端时忘了带lang参数,而后端 Cookie 里存的是zh-CN。结果页面框架是西语,业务提示是中文,用户直接懵。
解决办法是让 locale 只有一个「源头」。推荐的做法是:前端切换语言时,同时写localStorage和发起一次带?lang=xxx的请求让后端落 Cookie,之后所有请求都依赖 Cookie 自动携带。更严格一点,可以在请求头里显式带Accept-Language,由前端统一维护,后端以请求头为准。两种都可以,关键是团队内要统一约定,不能有的接口看 Cookie、有的接口看请求头。
6.4 性能与缓存策略
语言包本身很小,通常几 KB 到几十 KB,但请求量大时也是开销。三个优化点:
第一,后端MessageSource一定要开缓存,cacheSeconds设成 3600 甚至更大,生产环境不要设 0。第二,前端语言包按需加载,不要一次性把十个语种的 JSON 全打进主包,用户只用得上一个。第三,静态资源加长缓存,语言包文件名带上版本号或哈希,避免用户拿到旧文案。
如果是高并发场景,还要注意ResourceBundle的类加载器缓存问题。在应用服务器里做热部署或多次 reload 时,ResourceBundle可能持有旧的类加载器引用,导致内存泄漏。这种问题通常出现在本地开发环境反复重启时,生产环境单次启动不受影响,但如果你的应用需要动态加载插件,就要留意。
6.5 测试与回归
国际化功能必须有自动化测试兜底,最少要覆盖三件事:key 完整性(各语种 key 集合一致)、locale 解析正确性(请求头、Cookie、参数三种来源的优先级)、关键格式(日期数字在目标 locale 下的输出)。
key 完整性用一个简单的单元测试就能实现:加载所有语种的Properties,比较keySet()差异,把缺失的 key 打印出来。这个测试成本极低,但能拦住 90% 的“上线才发现某个提示是中文”的问题。建议把它接到 CI 里,每次提交都跑一遍。
格式相关的测试要注意断言的写法。不要断言完整的日期字符串(因为LocalDate.now()每天都在变),而是断言格式化后的字符串包含特定分隔词,比如西语结果包含de,中文结果包含年。或者干脆把日期作为方法参数传入,测试时传固定值,这样断言可以写得很精确。
6.6 安全与合规边界
最后提几点容易被忽略的安全问题。语言包如果支持从外部文件或数据库动态加载,必须校验来源,不能让语言包内容变成注入入口。前端渲染一律用textContent,除非内容经过白名单过滤,否则不要用innerHTML。?lang=参数要做白名单校验,只接受注册过的语种代码,防止有人用超长字符串或特殊字符构造请求。涉及用户提交的多语言文本(比如活动留言、报名备注),入库和展示都要按 UTF-8 处理,并做长度限制,避免存储被异常字符撑爆。
7. 总结与学习路线
到这里,一套可运行的多语言方案就跑通了。回顾一下关键节点:语言包用basename_语言_地区.properties的命名约定实现自动查找和降级回退;CookieLocaleResolver配合LocaleChangeInterceptor让用户能主动切换语言并持久化选择;MessageSource负责业务文案与参数插值,DateTimeFormatter和Intl系列 API 负责日期数字的本地化;前端用 JSON 语言包承载静态 UI 文案,与后端的业务消息划清边界。这套结构的核心价值在于:新增一个语种只需要加文件、加注册,不用改业务代码。
如果要继续深入,建议按这条路线走。先吃透 Java 的ResourceBundle加载机制和类加载器缓存,理解为什么会有“改了文件不生效”这种诡异现象;然后看 CLDR 数据,搞明白日期数字格式的规则来源,这会让你在遇到“某个地区的格式很奇怪”时不再抓瞎;接着研究 ICU MessageFormat,掌握复数、选择、嵌套占位符的完整语法,为将来支持更复杂的语种做准备;最后可以看看前端框架生态里的 i18n 方案(如 Vue I18n、react-i18next),重点理解它们的懒加载、命名空间和插值机制,这些概念和本文讲的后端方案是一一对应的。
实际项目落地时,优先盯住三个风险点:一是文案层的 key 完整性,这是最容易在迭代中失控的地方,务必上 CI 检查;二是前后端 locale 的同源问题,切换语言后一定要点几个带后端请求的按钮验证;三是编码问题,涉及非 ASCII 字符的配置文件和数据库字段,全部显式声明 UTF-8。这三个点守住了,国际化功能基本不会出大事故。剩下的就是翻译质量和文案打磨,那属于产品和运营的活儿了。建议你先把本文的 demo 跑起来,然后试着加一个es-MX语种,体会一下“只加文件不改代码”的顺畅感。