大家好,我是Java烘焙师,这次分享一些技术和商业上的思考。
从第一天起,就应该支持国际化和本地化,否则越到后期改造成本越高。等代码和DB里到处是硬编码的文本、日期格式、写死"元"的金额字段时,就很难重构了。跟商务开拓层面的困难相比,技术层面反而是简单的,别再只盯着那一亩三分地,卷存量了。
先说一下经济常识:美国是全球最大消费市场,欧盟跟中国也在同一量级。按语言使用人数排序,前几大语言是英语、中文、印地语、西语、法语、阿拉伯语。如果只支持国内市场,就错失了更大的市场了。
国际化(internationalization,简写为i18n)解决"通用"问题,系统层面兼容多语言、时间、货币。本地化(localization,简写为l10n)解决"定制"问题,针对特定地区做特殊逻辑。先说国际化。
国际化
多语言
基本常识
字符编码应当统一用UTF-8,前端页面、后端服务、数据库存储都是。前端声明charset,MySQL用utf8mb4(三字节的utf8是残缺版,如果文本包含emoji表情,就可能展示出错),JDK 18起默认编码也终于改成UTF-8了。
语言存在区域变种:比如简体中文、繁体中文,美国英语、英国英语,拉美西语、西班牙西语。所以locale是"语言_地区",只记语言不够。
还有展示异化:比如RTL,即从右往左展示,阿拉伯语就是这样。
固定文本、模板文本
相对固定的UI文案做成key、value配置,可变部分用占位符。Java标准做法是ResourceBundle加properties文件,占位符交给MessageFormat:
properties
# messages_zh_CN.properties
login.welcome=欢迎回来,{0}!你上次登录是{1,date,long}。
# messages_en_US.properties
login.welcome=Welcome back, {0}! Your last login was on {1,date,long}.
java
ResourceBundle messages = ResourceBundle.getBundle("messages", locale);
String text = MessageFormat.format(messages.getString("login.welcome"), username, lastLogin);
{1,date,long}这种占位符能带日期格式,日期会按目标locale的惯例渲染。
某些语言有复数,一个小技巧是做成同时兼容单数、复数,比如写成几天就是{0} day(s)
动态内容
动态内容需要存储在DB的多语言表里,比如用户发的帖子、评论,商家发的商品信息,内容不固定,可能包含文本、图片、视频。现在有了AI翻译,文本翻译准确率大幅提升,连图片里的文字,视频里的声音也能翻译替换了。
表结构上,多语言信息需要增加一个locale字段。
翻译策略上,业界有两种代表做法,按照实际需求选择。
- 推特:手动选择帖子翻译,翻译后存储结果,后来的人直接命中缓存。优点是按需翻译和存储,冷门内容无需处理;缺点是要用户手动点一下。
- 亚马逊:全局切换语种,商品、评价预先翻译好。优点是用户体验好,进去就是母语;缺点是耗费存储,某些小语种可能几乎没人用。
时间
基本常识
各地区的惯用写法不同,年月日、日月年、月日年都有。比如美国人习惯用月日年,欧洲人习惯用日月年。
时间戳与时区无关,是距离1970年1月1号流逝的时间,数据库中应该记录时间戳,而非datetime,避免时区带来的麻烦。
有的区域在特定时间范围内还实行夏令时,每年有两天,"凌晨2点"要么出现两次要么不存在。
日期格式
借助JDK的DateTimeFormatter实现按区域惯例展示。
java
LocalDate date = LocalDate.of(2026, 9, 26);
DateTimeFormatter f = DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG);
f.withLocale(Locale.US).format(date); // September 26, 2026
f.withLocale(Locale.UK).format(date); // 26 September 2026
f.withLocale(Locale.GERMANY).format(date); // 26. September 2026
时区
存储数据时用时间戳,展示时明确指定时区,不要依赖默认值。因为JVM默认时区会随部署环境变化,比如容器里是UTC、宿主机里是东八区。
java
Instant now = Instant.now(); // 与时区无关
ZonedDateTime sh = now.atZone(ZoneId.of("Asia/Shanghai"));
ZonedDateTime ny = now.atZone(ZoneId.of("America/New_York"));
夏令时不用自己算,时区数据库里带着规则:ZoneRules.isDaylightSavings、nextTransition。定时任务注意,cron按服务器本地时间跑,夏令时切换日会少跑或跑两次,跨国任务统一用UTC调度。
货币
基本常识
金额数字应当使用BigDecimal,而非Integer或Long类型,更不能是浮点数。比较两个金额数字要用compareTo,而不是equals(equals除了比较数值,还会比较scale精度)。
货币用java.util.Currency表示,比如欧元EUR在欧元区流通,如德国、法国、西班牙、意大利等;美元USD、日元JPY、英镑GBP有各自的地盘。
表结构上,金额字段需要配套一个币种字段。
金额精度
日元最低精度是元,美元最低精度是分。每种货币的小数位是法定的,JDK里已经记好了:
java
Currency.getInstance("JPY").getDefaultFractionDigits(); // 0
Currency.getInstance("USD").getDefaultFractionDigits(); // 2
入库统一存到足够细的精度,展示、业务计算时再按币种法定精度取整。
金额展示
千位分隔各国有各国的规矩,国内还有"万"和"亿"的习惯。借助NumberFormat实现金额精度、展示:
java
NumberFormat fmt = NumberFormat.getCurrencyInstance(Locale.US);
fmt.format(new BigDecimal("1234.5")); // $1,234.50
NumberFormat compact =
NumberFormat.getCompactNumberInstance(Locale.CHINA, NumberFormat.Style.SHORT);
compact.format(123456); // 12万
汇率
借助开源组件JavaMoney(JSR 354)实现汇率换算,它的参考实现Moneta自带ECB汇率Provider,数据源就是欧洲央行的参考汇率,每个工作日更新(非实时汇率),内部也做了缓存:
java
MonetaryAmount usd = Money.of(new BigDecimal("9.99"), "USD");
MonetaryAmount cny = usd.with(MonetaryConversions.getConversion("CNY", "ECB"));
如果要计算实时汇率,还得接入银行或支付机构。
还有一点容易被忽略:下单时刻的汇率要随订单落库,因为订单金额是既定事实,不能跟随汇率波动。
本地化
国际化支持是为了通用,而本地化则是根据当地法律法规、或风俗习惯做的定制逻辑。有的本地化功能关乎合规,直接影响到能否在当地正常开门营业,多了解一些案例可以少走弯路。
法律法规、风俗习惯
欧盟在互联网、AI等技术领域,跟中美相比是落后许多的,但法律法规走在了前头:比如GDPR、数字服务法、AI法案,时不时给企业开罚单。技术上意味着欧洲用户数据要留在欧洲、删除权要落实到底层存储。
数据留在欧洲,已经不是文案问题了:涉及多区域部署、用户按区域路由、跨区同步只同步非用户数据。这是最体现系统架构设计价值的地方了。
美国保护数字版权,比如DMCA,规定"通知-下架"机制,权利人一发通知,平台就得删,直接塑造了所有UGC平台的内容处理流程。
中东地区:比如不吃猪肉、着装不能过于暴露,需要尊重当地习俗。
度量衡
世界上绝大部分地区都用的是公制单位,比如厘米、毫升、克、摄氏度。但是美国是个例外,仍然在用英制单位(连英国自己都基本改成公制了),比如英寸、液量盎司、华氏度。
处理思路和金额一致:存储统一用公制,展示时按用户locale换算成英制。
反例
品牌出海翻车的案例,有的已经写进营销教科书的反例了。比如1990年代,国内某日化品牌,在国内做得不错,出口到欧美时发现品牌名在当地语言里是负面禁忌词,根本没法上架,导致损失惨重。这些需要专人+本地人评估,做好前期准备工作。
再举一个最近的例子:闹钟的节假日跳过。国内假期,闹铃需要考虑调休,而不是只看工作日。某国际手机大厂直到近期才支持,而国内手机厂商早就支持了。只能认为是不够重视中国市场、态度比较傲慢。
总结
以上就是国际化+本地化支持的思考了。世界是多样的,有各自的风土人情,前人踩过的坑,就没必要再掉进去了。