Java 国际化实战——多语言应用的正确架构,不只是 i18n 配置
Java 国际化实战——多语言应用的正确架构,不只是 i18n 配置
适读人群:有多语言业务需求或准备开发国际化产品的 Java 开发者 | 阅读时长:约 15 分钟 | 核心价值:从真实项目出发,给出多语言应用的完整技术方案,包括动态切换、数据库多语言、数字货币格式化
大多数国际化教程告诉你的是:
创建
messages.properties文件,用@Value("${msg.key}")或MessageSource读取,完成。
这只是国际化最简单的一层,离真正的多语言产品差得远。
2023 年我们把一个产品出海,做了完整的国际化改造,踩了很多坑。这篇文章把我觉得最重要的几个方面写出来。
Spring 国际化的基础:正确配置 MessageSource
先把基础做对:
package com.example.i18n;
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ReloadableResourceBundleMessageSource;
import org.springframework.web.servlet.LocaleResolver;
import org.springframework.web.servlet.i18n.AcceptHeaderLocaleResolver;
import java.util.Arrays;
import java.util.Locale;
@Configuration
public class I18nConfig {
@Bean
public MessageSource messageSource() {
ReloadableResourceBundleMessageSource messageSource =
new ReloadableResourceBundleMessageSource();
// 文件放在 resources/i18n/ 目录下
messageSource.setBasename("classpath:i18n/messages");
messageSource.setDefaultEncoding("UTF-8");
// 找不到 key 时,不抛异常,返回 key 本身(方便开发期排查)
messageSource.setUseCodeAsDefaultMessage(true);
// 可以热重载(不重启加载新文案)——生产慎用
// messageSource.setCacheSeconds(60);
return messageSource;
}
@Bean
public LocaleResolver localeResolver() {
// 根据请求头 Accept-Language 确定语言
AcceptHeaderLocaleResolver resolver = new AcceptHeaderLocaleResolver();
// 支持的语言列表
resolver.setSupportedLocales(Arrays.asList(
Locale.CHINESE,
Locale.ENGLISH,
Locale.JAPANESE,
new Locale("ko") // 韩语
));
// 默认语言
resolver.setDefaultLocale(Locale.CHINESE);
return resolver;
}
}文件结构:
resources/
i18n/
messages.properties # 默认(中文)
messages_zh_CN.properties # 简体中文
messages_en_US.properties # 美式英语
messages_ja_JP.properties # 日语踩坑实录一:Locale 切换需要传递到所有层,不只是 Controller
一个常见的错误:在 Controller 里获取了 Locale,但传到 Service 层就没了,Service 里调用 MessageSource 用的是系统默认 Locale。
解法是用 LocaleContextHolder,Spring MVC 在处理请求时会自动设置,但如果你有异步处理,就需要手动传递:
package com.example.i18n;
import org.springframework.context.MessageSource;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.stereotype.Component;
import java.util.Locale;
/**
* 封装 MessageSource,自动使用当前请求的 Locale
*/
@Component
public class MessageHelper {
private final MessageSource messageSource;
public MessageHelper(MessageSource messageSource) {
this.messageSource = messageSource;
}
public String getMessage(String code) {
// 从 LocaleContextHolder 获取当前请求的 Locale
Locale locale = LocaleContextHolder.getLocale();
return messageSource.getMessage(code, null, code, locale);
}
public String getMessage(String code, Object... args) {
Locale locale = LocaleContextHolder.getLocale();
return messageSource.getMessage(code, args, code, locale);
}
}
// 在 Service 里直接用
// @Service
// public class UserService {
// @Autowired
// private MessageHelper msg;
//
// public void someMethod() {
// String greeting = msg.getMessage("greeting.hello", "张三");
// }
// }异步场景下的 Locale 传递:
package com.example.i18n;
import org.springframework.context.i18n.LocaleContext;
import org.springframework.context.i18n.LocaleContextHolder;
import java.util.Locale;
import java.util.concurrent.CompletableFuture;
import java.util.function.Supplier;
/**
* 异步任务中保持 Locale 传递
* 异步线程不继承父线程的 LocaleContextHolder,需要显式传递
*/
public class LocaleAwareAsync {
/**
* 在异步执行中传递当前 Locale
*/
public static <T> CompletableFuture<T> supplyAsync(Supplier<T> supplier) {
// 在提交任务时,捕获当前的 Locale
LocaleContext capturedLocale = LocaleContextHolder.getLocaleContext();
return CompletableFuture.supplyAsync(() -> {
try {
// 在新线程里设置 Locale
LocaleContextHolder.setLocaleContext(capturedLocale);
return supplier.get();
} finally {
// 清理,不要污染线程池里的线程状态
LocaleContextHolder.resetLocaleContext();
}
});
}
}数据库中的多语言内容
消息文案放在 properties 文件里,但产品内容(商品名称、分类名称、用户填写的内容)需要存在数据库里,这是另一个问题。
常见方案有两种:
方案一:多列(适合语言数量固定、较少)
CREATE TABLE product (
id BIGINT PRIMARY KEY,
name_zh VARCHAR(200),
name_en VARCHAR(200),
name_ja VARCHAR(200),
name_ko VARCHAR(200),
price DECIMAL(10,2)
);简单,查询方便,但每加一个语言要改表结构。
方案二:多行翻译表(适合语言数量多或动态)
CREATE TABLE product (
id BIGINT PRIMARY KEY,
price DECIMAL(10,2)
);
CREATE TABLE product_translation (
product_id BIGINT,
locale VARCHAR(10), -- 'zh_CN', 'en_US', 'ja_JP'
name VARCHAR(200),
description TEXT,
PRIMARY KEY (product_id, locale),
FOREIGN KEY (product_id) REFERENCES product(id)
);package com.example.i18n;
import org.springframework.stereotype.Service;
import java.util.Optional;
@Service
public class ProductService {
private final ProductRepository productRepo;
private final ProductTranslationRepository translationRepo;
public ProductService(ProductRepository productRepo,
ProductTranslationRepository translationRepo) {
this.productRepo = productRepo;
this.translationRepo = translationRepo;
}
/**
* 获取产品,自动选择对应语言的翻译
* 如果找不到指定语言,回退到英语,再找不到就用第一个可用翻译
*/
public ProductVO getProduct(Long productId, String locale) {
Product product = productRepo.findById(productId)
.orElseThrow(() -> new RuntimeException("产品不存在"));
// 按优先级查找翻译
ProductTranslation translation =
translationRepo.findByProductIdAndLocale(productId, locale)
.or(() -> translationRepo.findByProductIdAndLocale(productId, "en_US"))
.or(() -> translationRepo.findByProductIdAndLocale(productId, "zh_CN"))
.or(() -> translationRepo.findFirstByProductId(productId))
.orElse(null);
ProductVO vo = new ProductVO();
vo.setId(product.getId());
vo.setPrice(product.getPrice());
if (translation != null) {
vo.setName(translation.getName());
vo.setDescription(translation.getDescription());
}
return vo;
}
}数字、货币、日期的格式化
这一点经常被忽视,但对用户体验影响很大:
- 数字:
1,234,567.89(英语)vs1.234.567,89(德语) - 货币:
$1,234.56(美元)vs¥1,234(人民币) - 日期:
March 15, 2024(英语)vs2024年3月15日(中文)
package com.example.i18n;
import java.math.BigDecimal;
import java.text.NumberFormat;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.util.Currency;
import java.util.Locale;
/**
* 本地化格式化工具
*/
public class LocaleFormatter {
/**
* 格式化货币
*/
public static String formatCurrency(BigDecimal amount, String currencyCode, Locale locale) {
NumberFormat format = NumberFormat.getCurrencyInstance(locale);
format.setCurrency(Currency.getInstance(currencyCode));
return format.format(amount);
}
/**
* 格式化数字(不带货币符号)
*/
public static String formatNumber(double number, Locale locale) {
return NumberFormat.getNumberInstance(locale).format(number);
}
/**
* 格式化日期
*/
public static String formatDate(LocalDate date, Locale locale) {
return DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG)
.withLocale(locale)
.format(date);
}
public static void main(String[] args) {
BigDecimal price = new BigDecimal("1234.56");
// 中文环境
System.out.println(formatCurrency(price, "CNY", Locale.CHINA)); // ¥1,234.56
System.out.println(formatDate(LocalDate.now(), Locale.CHINA)); // 2024年3月15日
// 英文环境
System.out.println(formatCurrency(price, "USD", Locale.US)); // $1,234.56
System.out.println(formatDate(LocalDate.now(), Locale.US)); // March 15, 2024
// 德语环境
Locale german = Locale.GERMANY;
System.out.println(formatCurrency(price, "EUR", german)); // 1.234,56 €
System.out.println(formatDate(LocalDate.now(), german)); // 15. März 2024
}
}踩坑实录二:文案管理用 properties 文件效率很低
团队做大了,翻译人员不会直接编辑 properties 文件。我们最开始的工作流是:
开发写 properties → 导出 Excel → 发给翻译团队 → 翻译填好 → 开发手动合并回 properties
这个流程极其低效,而且容易出错(合并时容易漏,格式问题导致乱码)。
后来我们引入了本地化平台(我们用的是自建的,也有开源选项如 Weblate、Tolgee),翻译内容直接在平台上管理,通过 API 或 CI 脚本同步到代码库。
如果你们的产品要做认真的多语言,很早就要考虑这个问题,而不是等到"翻译团队反映流程太痛苦"的时候才改。
踩坑实录三:RTL(从右到左)语言没有考虑
阿拉伯语、希伯来语是从右到左书写的,不只是文字翻译的问题,UI 布局也是镜像的:按钮在右边、图标在左边的布局,在 RTL 语言下要反过来。
这个问题在后端其实不大,主要是前端的事。但后端要做的一件事是:在接口返回用户配置的语言时,同时返回文字方向(ltr 或 rtl),让前端知道如何渲染。
package com.example.i18n;
import java.util.Locale;
import java.util.Set;
public class LocaleUtils {
// RTL 语言列表
private static final Set<String> RTL_LANGUAGES = Set.of(
"ar", "he", "fa", "ur", "yi", "dv"
);
public static boolean isRtl(Locale locale) {
return RTL_LANGUAGES.contains(locale.getLanguage());
}
public static String getTextDirection(Locale locale) {
return isRtl(locale) ? "rtl" : "ltr";
}
}国际化是一个系统工程,文案翻译只是最表层的一部分。真正的国际化产品还要考虑时区、货币、文化差异(比如颜色的文化含义、节假日逻辑等)。把基础打好,后续才能扩展。
