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

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

  1. 我的工具箱 🪜
  2. 2023.11.07
  3. 8 min read

缘起

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

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

IDEA 中如何实现 i18n

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

/**
 * 特定作用域捆绑包的基类(例如“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 类:

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 文件, 一定不能错:

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

使用方式:

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

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

实现逻辑

我需要的功能:

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

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

@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;
    }
}

创建绑定类:

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

测试一下:

@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());
    }
}

输出:

[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]
IntelliJ IDEA 学习笔记