IDEA插件开发中的国际化配置实践:轻量级ResourceBundle应用

缘起

最近一段时间在开发 IDEA 插件, UI 界面需要使用到国际化配置, 于是就看了看 IDEA 是怎么实现的, 发现很简单, 正好能用到框架开发上.

打算为每个 atom-kernel 模块配置一个国际化配置, 同时将错误信息配置化. 因为是框架底层的组件, 如果使用 Spring Boot 的 i18n 实现就太重了, 因此需要一种超轻量级的实现方式.

IDEA 中如何实现 i18n

IDEA 使用 ResourceBundle 这个类实现了 i18n, 源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
/**
* 特定作用域捆绑包的基类(例如“vcs”捆绑包、“aop”捆绑包等)。
* 使用模式:
* 创建一个扩展该类并为当前类构造函数提供目标bundle路径的类;
* 可选地在子类中创建静态facade方法-创建单个共享实例并委托给它的getMessage(String,Object…)
*
* @author Denis Zhdanov
*/
public abstract class AbstractBundle {
private static final Logger LOG = Logger.getInstance("#com.intellij.AbstractBundle");
private Reference<ResourceBundle> myBundle;
@NonNls private final String myPathToBundle;

protected AbstractBundle(@NonNls @NotNull String pathToBundle) {
myPathToBundle = pathToBundle;
}

@NotNull
public String getMessage(@NotNull String key, @NotNull Object... params) {
// CommonBundle.message() 主要用于参数替换与空值处理, 快捷键标识等
return CommonBundle.message(getBundle(), key, params);
}

private ResourceBundle getBundle() {
ResourceBundle bundle = com.intellij.reference.SoftReference.dereference(myBundle);
if (bundle == null) {
bundle = getResourceBundle(myPathToBundle, getClass().getClassLoader());
myBundle = new SoftReference<>(bundle);
}
return bundle;
}

private static final Map<ClassLoader, Map<String, ResourceBundle>> ourCache =
ConcurrentFactoryMap.createWeakMap(k -> ContainerUtil.createConcurrentSoftValueMap());

/**
* 使用 ResourceBundle.Control 来实现 i18n
*/
public static ResourceBundle getResourceBundle(@NotNull String pathToBundle, @NotNull ClassLoader loader) {
Map<String, ResourceBundle> map = ourCache.get(loader);
ResourceBundle result = map.get(pathToBundle);
if (result == null) {
try {
ResourceBundle.Control control = ResourceBundle.Control.getControl(ResourceBundle.Control.FORMAT_PROPERTIES);
result = ResourceBundle.getBundle(pathToBundle, Locale.getDefault(), loader, control);
}
catch (MissingResourceException e) {
LOG.info("Cannot load resource bundle from *.properties file, falling back to slow class loading: " + pathToBundle);
ResourceBundle.clearCache(loader);
result = ResourceBundle.getBundle(pathToBundle, Locale.getDefault(), loader);
}
map.put(pathToBundle, result);
}
return result;
}
}

实现自己的 Bundle 类:

1
2
3
4
5
6
7
8
9
10
11
12
public class IdeBundle extends AbstractBundle {
public static String message(@NotNull @PropertyKey(resourceBundle = BUNDLE) String key, @NotNull Object... params) {
return INSTANCE.getMessage(key, params);
}

public static final String BUNDLE = "messages.IdeBundle";
private static final IdeBundle INSTANCE = new IdeBundle();

private IdeBundle() {
super(BUNDLE);
}
}

添加配置文件:

还需要在 classpath 中添加一个 messages 目录, 然后添加 IdeBundle.properties 配置文件, 或者可以直接使用 IDEA 新增资源包, 需要注意的是, messages.IdeBundle 表示在 messages 目录下的 IdeBundle.properties 文件, 一定不能错:

1
2
error.malformed.url=Malformed url: {0}
browsers.explorer=Internet Explorer

使用方式:

1
2
3
4
# 有占位符的
IdeBundle.message("error.malformed.url", "xxx")
# 无占位符
IdeBundle.message("browsers.explorer")

从上面可以看出, 直接 JDK 自带的 ResourceBundle 类实现了国际化和占位符替换的功能, 刚好符合我的要求, 因此打算使用这种方式来实现一下.

实现逻辑

我需要的功能:

  1. 国际化;
  2. 占位符替换;

IDEA 的 AbstractBundle 有很多我不需要的功能, 做了一些简单的修改后完全符合我的要求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
@Slf4j
public abstract class AbstractBundle {
private static final Map<ClassLoader, Map<String, ResourceBundle>> CACHE =
ConcurrentFactoryMap.createWeakMap(k -> new ConcurrentSoftValueHashMap<>());
@NonNls
private final String myPathToBundle;
private Reference<ResourceBundle> myBundle;

@Contract(pure = true)
protected AbstractBundle(@NonNls @NotNull String pathToBundle) {
this.myPathToBundle = pathToBundle;
}

/**
* 获取延迟加载的字符串
*
* @param key 键
* @param params 参数
* @return Supplier字符串
*/
@NotNull
public Supplier<String> getLazyMessage(@NotNull String key, Object... params) {
return () -> this.getMessage(key, params);
}

/**
* 获取字符串
*
* @param key 键
* @param params 参数
* @return 字符串
*/
@NotNull
public String getMessage(@NotNull String key, Object... params) {
return message(this.getResourceBundle(), key, params);
}

/**
* 获取本地化字符串
*
* @param bundle 资源包
* @param key 键
* @param params 参数
* @return 字符串
*/
@Nls
@NotNull
public static String message(@NotNull ResourceBundle bundle, @NotNull String key, Object... params) {
return messageOrDefault(bundle, key, null, params);
}

/**
* 获取资源包
*
* @return 资源包
*/
public ResourceBundle getResourceBundle() {
ResourceBundle bundle = SoftReference.dereference(this.myBundle);
if (bundle == null) {
bundle = this.getResourceBundle(this.myPathToBundle, this.getClass().getClassLoader());
this.myBundle = new SoftReference<>(bundle);
}
return bundle;
}

/**
* 获取资源包
*
* @param pathToBundle 资源包路径
* @param loader 类加载器
* @return 资源包
*/
@NotNull
public ResourceBundle getResourceBundle(@NotNull String pathToBundle, @NotNull ClassLoader loader) {
Map<String, ResourceBundle> map = CACHE.get(loader);
ResourceBundle result = map.get(pathToBundle);
if (result == null) {
try {
ResourceBundle.Control control = ResourceBundle.Control.getControl(ResourceBundle.Control.FORMAT_PROPERTIES);
result = this.findBundle(pathToBundle, loader, control);
} catch (MissingResourceException e) {
log.info("无法从 *.properties 文件中加载资源包,降级为慢的类加载: " + pathToBundle);
ResourceBundle.clearCache(loader);
result = ResourceBundle.getBundle(pathToBundle, Locale.getDefault(), loader);
}
map.put(pathToBundle, result);
}
return result;
}

/**
* 查找资源包
*
* @param pathToBundle 资源包路径
* @param loader 类加载器
* @param control 控制
* @return 资源包
*/
protected ResourceBundle findBundle(@NotNull String pathToBundle, @NotNull ClassLoader loader,
@NotNull ResourceBundle.Control control) {
return ResourceBundle.getBundle(pathToBundle, Locale.getDefault(), loader, control);
}

/**
* 获取本地化字符串或默认值
*
* @param bundle 资源包
* @param key 键
* @param defaultValue 默认值
* @param params 参数
* @return 字符串
*/
public static String messageOrDefault(@Nullable ResourceBundle bundle,
@NotNull String key,
@Nullable String defaultValue,
@NotNull Object... params) {
if (bundle != null) {
String value;
try {
value = bundle.getString(key);
} catch (MissingResourceException e) {
value = useDefaultValue(bundle, key, defaultValue);
}
return postprocessValue(bundle, value, params);
}

return defaultValue;
}

/**
* 使用默认值
*
* @param bundle 资源包
* @param key 键
* @param defaultValue 默认值
* @return 字符串
*/
@NotNull
static String useDefaultValue(ResourceBundle bundle, @NotNull String key, @Nullable String defaultValue) {
if (defaultValue != null) {
return defaultValue;
}

log.error("在资源包中 {} 未找到键: [{}]", bundle.getBaseBundleName(), key);
return StringPool.NULL_STRING;
}

/**
* 后处理值
*
* @param bundle 资源包
* @param value 值
* @param params 参数
* @return 后处理后的值
*/
static String postprocessValue(@NotNull ResourceBundle bundle, @NotNull String value, @NotNull Object @NotNull [] params) {
if (params.length > 0 && value.indexOf('{') >= 0) {
if (value.contains("{0")) {
Locale locale = bundle.getLocale();
try {
MessageFormat format = locale != null ? new MessageFormat(value, locale) : new MessageFormat(value);
OrdinalFormat.apply(format);
value = format.format(params);
} catch (IllegalArgumentException e) {
value = "!format 错误: `" + value + "`!";
}
} else {
value = StrFormatter.format(value, params);
}
}

return value;
}
}

创建绑定类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public final class CoreBundle extends DynamicBundle {
@NonNls
private static final String BUNDLE = "i18n.CoreBundle";
private static final CoreBundle INSTANCE = new CoreBundle();

@Contract(pure = true)
private CoreBundle() {
super(BUNDLE);
}

@NotNull
public static String message(@NotNull String key, Object... params) {
return INSTANCE.getMessage(key, params);
}

public static @NotNull Supplier<String> messagePointer(@NotNull String key, Object... params) {
return INSTANCE.getLazyMessage(key, params);
}
}

上面的代码基本上是固定的写法, 只需要修改 BUNDLE 即可, 然后创建国际化配置文件:

20241229154732_JF2bLLEZ.webp

测试一下:

1
2
3
4
5
6
7
8
9
10
11
12
@Slf4j
class CoreBundleTest {
@Test
void test_1() {
// 有占位符但是没有参数, 占位符原样输出
log.info("{}", CoreBundle.message("code.param.verify.error"));
// 不存在的 key
log.info("{}", CoreBundle.message("aaa"));
// 正常输出
log.info("{}", CoreBundle.messagePointer("code.param.verify.error", "aaaaa").get());
}
}

输出:

1
2
3
4
[main] INFO io.github.atom.kernel.core.CoreBundleTest - 参数校验失败: [{}]
[main] ERROR io.github.atom.kernel.core.bundle.AbstractBundle - 在资源包 [i18n.CoreBundle] 未找到键: [aaa]
[main] INFO io.github.atom.kernel.core.CoreBundleTest - N/A
[main] INFO io.github.atom.kernel.core.CoreBundleTest - 参数校验失败: [aaaaa]