业务思考|项目国际化处理

最近在做国际化相关的工作,需求是在「English」状态用户的名称和部门的名称显示英文字段,实体中有 name 和 englishName,当用户选择英语时 name 需要被填充 englishName,这种情况不像系统字段的国际化,系统字段的各个语言是固定的,而用户的英文名称是变动的。为了处理用户的不同语言选择需要写很多 if 或者 switch,因此当 VO、DOT、Model 对象多了就会出现很多形式相似的代码,像下面这样。

业务系统会有多处与下面雷同的代码,因为不同的 VO、DTO 需要不同的处理。

    public void i18nHandler(UserVO vo, User user){
        LanguageEnum language = currentLanguage();
        switch (language){
            case EN:
                String englishName = user.getEnglishName();
                if(StringUtils.isNotEmpty(englishName)){
                    vo.setName(englishName);
                }
                break;
            case JP:
                String jpName = user.getJpName();
                if(StringUtils.isNotEmpty(jpName)){
                    vo.setName(jpName);
                }
            case ZH:
            default:
                // 缺省默认中文
        }
    }

当改动上游的方法时就会出现许多雷同的这样的代码,因为 VO、DTO 都要做适配,没有办法上游业务多样而且变化较快。

一个优化的方案,使用 Java 的反射技术对字段进行注入,可以减少 6 成的代码量,降低开发的复杂度。demo 如下,重要的几点在代码块的注释里。

/**
 * 1、通过反射注入成员变量
 *      优:降低代码复杂性,易维护扩展
 *      缺:性能差
 * 2、线程安全性
 *      1)无状态方法是线程安全的
 *      2)线程间不共享变量,线程安全
 *      3)保证传入参数不可变加强线程安全
 * 3、类型注入
 *      1)无法注入基本数据类型
 *      2)注入不正确对类型,通过类型判断提前制止
 */
public class TestI18n {

    final static Set<String> BASIC_VARIABLES = new HashSet<>(Arrays.asList("int", "long", "byte", "char", "short", "float", "double"));
    enum Sex {MAN, WOMAN}

    static String currentLanguage = "ZH";

    static class Student {
        private String name;
        private Sex sex;
        private Long money;
        private Student[] friends;

        public Student(String name, Sex sex, Long money) {
            this.name = name;
            this.sex = sex;
            this.money = money;
        }

        public Student() {

        }

        @Override
        public String toString() {
            return "Student{" +
                    "name='" + name + '\'' +
                    ", sex=" + sex +
                    ", money=" + money +
                    ", friends=" + Arrays.toString(friends) +
                    '}';
        }
    }

    public static void main(String[] args) {
        Map<String, String> nameMap = new HashMap<>();
        nameMap.put("EN", "tom");
        nameMap.put("ZH", "汤姆");

        Map<String, Sex> sexMap = new HashMap<>();
        sexMap.put("EN", Sex.WOMAN);
        sexMap.put("ZH", Sex.MAN);

        Map<String, Long> moneyMap = new HashMap<>();
        moneyMap.put("EN", 100L);
        moneyMap.put("ZH", 100000L);

        Map<String, Student[]> friendMap = new HashMap<>();
        Student[] enFriends = new Student[]{new Student("marry", Sex.WOMAN, 1L),
                new Student("pigger", Sex.MAN, 2L)};
        Student[] zhFriends = new Student[]{new Student("张富贵", Sex.MAN, 2L)};
        friendMap.put("EN", enFriends);
        friendMap.put("ZH", zhFriends);

        long start = System.currentTimeMillis();
        Student stu = new Student();

        currentLanguage = "ZH";
        i18nHandle("name", stu, nameMap);
        i18nHandle("sex", stu, sexMap);
        i18nHandle("money", stu, moneyMap);
        i18nHandle("friends", stu, friendMap);
        System.out.println(stu);

        currentLanguage = "EN";

        i18nHandle("name", stu, nameMap);
        i18nHandle("sex", stu, sexMap);
        i18nHandle("money", stu, moneyMap);
        i18nHandle("friends", stu, friendMap);
        System.out.println(stu);
        System.out.println(System.currentTimeMillis() - start);


        // output:
//        Student{name='汤姆', sex=MAN, money=100000, friends=[Student{name='张富贵', sex=MAN, money=2, friends=null}]}
//        Student{name='tom', sex=WOMAN, money=100, friends=[Student{name='marry', sex=WOMAN, money=1, friends=null}, Student{name='pigger', sex=MAN, money=2, friends=null}]}
    }

    /**
     * example:
     *      User user = new User();
     *      user.setName("xxx");
     *      ...
     *      // i18n
     *      Map<String, String> map = new HashMap<>(2);
     *      map.put("EN", "tom");
     *      map.put("ZH", "汤姆");
     *      i18nHandle("name", user, map);
     */
    public static void i18nHandle(final String fieldName, final Object injectObject, Map<String, ?> languageValueMap) {
        if (null == fieldName || injectObject == null || languageValueMap == null) {
            throw new IllegalArgumentException("参数为null");
        }
        try {
            Field field = injectObject.getClass().getDeclaredField(fieldName);
            field.setAccessible(true);
            if (BASIC_VARIABLES.contains(field.getType().getSimpleName())) {
                throw new RuntimeException("不允许基本数据类型注入成员变量");
            }
            Object value = languageValueMap.get(currentLanguage);
            if(value == null){
                // log warn
                return;
            }
            field.set(injectObject, value);
        } catch (NoSuchFieldException | IllegalAccessException e) {
            throw new IllegalArgumentException("成员变量不存在, 注入失败");
        }
    }
}

还有没有更好的方案,因为这样改在上游仍然有多处代码变动,因为使用 user 数据的上游业务有很多,尤其是页面回显的查询操作。

更好的解决办法是将字段类型转换放到下游,即国际化操作下沉,在用户服务做国际化处理,上游获取数据后直接就可以用了,不必再做转换。我们的用户数据获取有两处 rpc 和 es,在这两处转换后上游需要变动的就很少了。


后续,以上方案可以满足国际化的基本需求,但不够灵活。在让我师傅瞧后又有了方案的优化版本,这个版本的灵活性和扩展性更强。

对比上个方案,增加了一个类的语言与中英文字段映射关系,这个关系可以通过一个 Builder 创建,处理国际化操作由原先给对象设置国际化的值变为从对象中解析出国际化的值(中、英分别提取不同的字段),此外,方法可以根据默认支持的对象进行国际化处理,也可传入语言字段映射关系提取。

语言字段映射构建类:

    public static class LanguageFieldNameMap {
        // language-fieldName map
        private Map<String, String> map;
        // 缺省语言
        public final static String DEFAULT_LANGUAGE = "DEFAULT_LANGUAGE";

        private LanguageFieldNameMap(){

        }

        public static LanguageFieldNameMap builder(){
            LanguageFieldNameMap languageFieldNameMap = new LanguageFieldNameMap();
            languageFieldNameMap.map = new HashMap<>();
            return languageFieldNameMap;
        }

        public Map<String, String> build(){
            return map;
        }

        public LanguageFieldNameMap setEnglishFieldName(String fieldName){
            map.put(LanguageEnum.EN.getName(), fieldName);
            return this;
        }

        public LanguageFieldNameMap setChineseFieldName(String fieldName){
            map.put(LanguageEnum.ZH.getName(), fieldName);
            return this;
        }

        /** 缺省处理 */
        public LanguageFieldNameMap setDefaultFieldName(String fieldName){
            map.put(LanguageFieldNameMap.DEFAULT_LANGUAGE, fieldName);
            return this;
        }

        // other setXXXFieldName
    }

    // 使用方法
    LanguageFieldNameMap.builder()
                    .setChineseFieldName("name")
                    .setEnglishFieldName("englishName")
                    .setDefaultFieldName("name")
                    .build()

解析方法

    /**
     * 提取对象在不同语言的字段值。
     *
     * @param obj                   提取属性对象
     * @param languageFieldNameMap  语言-字段名map映射,可以通过LanguageFieldNameMap内部类快速生成
     * @return 映射不存在,字段名不存在返回null,注意兜底
     */
    public static <T> T decode4LanguageByObject(Object obj, Map<String, String> languageFieldNameMap){
        LanguageEnum currentLang = currentLanguage();
        return decode4LanguageByObject(obj, languageFieldNameMap, currentLang);
    }

    /**
     * 根据预设的「语言-字段名」map提取对象在不同语言的字段值。
     * 注意:一律不支持取protobuf生成的Java对象。
     *
     * 支持的类:
     * <pre>
     * 	User
     * </pre>
     *
     * @param obj   提取属性对象
     * @return 映射不存在,字段名不存在返回null,注意兜底
     */
    public static <T> T defaultDecode4LanguageByObject(Object obj) {
        LanguageEnum currentLang = currentLanguage();
        Map<String, String> langFieldNameMap = getDefaultLanguageFieldNameMap(obj);
        if(langFieldNameMap == null){
            throw new RuntimeException("没有默认的语言-字段名映射map,请查看 getDefaultLanguageFieldNameMap");
        }

        return decode4LanguageByObject(obj, langFieldNameMap, currentLang);
    }

    /**
     * 根据语言获取对象的某个字段,语言与字段的映射关系需要以map提供, 需要始终提供默认值。
     *
     * @param obj                   提取字段的对象, 非空
     * @param languageFieldNameMap  语言-字段名映射, 非空
     * @return 如果语言-字段名映射中不存在用户当前使用的语言,返回null;
     *         如果字段在提取值的类中不存在,返回null;
     */
    private static <T> T decode4LanguageByObject(Object obj, Map<String, String> languageFieldNameMap,
                                                 LanguageEnum currentLanguage){

        if(obj == null || languageFieldNameMap == null) {
            log.error("args is null. i18n解析返回null");
            return null;
        }

        // 不支持当语言,中文兜底
        if(!SUPPORT_LANGUAGES.contains(currentLanguage)){
            currentLanguage = LanguageEnum.ZH;
        }

        String fieldName = languageFieldNameMap.get(currentLanguage.getName());

        // 语言-字段名不存在,默认语言兜底
        boolean isDefaultLang = false;
        if(StringUtils.isEmpty(fieldName)){
            fieldName = languageFieldNameMap.get(LanguageFieldNameMap.DEFAULT_LANGUAGE);
            isDefaultLang = true;
            log.warn("当前语言字段映射不存在,转为默认语言。当前语言:{}", currentLanguage.name());
            if(StringUtils.isEmpty(fieldName)){
                log.warn("默认语言映射不存在,国际化处理返回 null");
                return null;
            }
        }

        T fieldValue = (T) getObjectFieldValue(obj, fieldName);
        // 非默认语言,字段取值null,尝试默认字段
        if(fieldValue == null && !isDefaultLang) {
            String defaultFieldName = languageFieldNameMap.get(LanguageFieldNameMap.DEFAULT_LANGUAGE);
            fieldValue = (T) getObjectFieldValue(obj, defaultFieldName);
            if(fieldValue == null){
                log.warn("默认语言映射当字段值不存在,国际化处理返回 null");
            }
        }
        return fieldValue;
    }

先到这里,如果有更好的方案,欢迎留言交流。