美文网首页
多语言翻译组件的升级之路

多语言翻译组件的升级之路

作者: rookie0peng | 来源:发表于2021-07-05 22:00 被阅读0次

    国际化的定义各不相同。这是用于 W3C 国际化活动材料的高级工作定义。有些人使用其他术语(例如全球化)来指代同一概念。
    国际化是产品、应用程序或文档内容的设计和开发,它可以为不同文化、地区或语言的目标受众轻松本地化。
    国际化(Internationalization)通常用英文写成i18n,其中 18 是英文单词中i和n之间的字母数。[1]

    本文主要基于java针对语言国际化进行阐述。

    任何一个面向全世界的软件都会面临多语言国际化的问题,对于java web应用,要实现国际化功能,就是在数据展示给用户之前,替换成对应的语言。

    1.使用spring自带的i18n(国际化)

    这个比较简单,在网上找一找到处都是教程,这里将会手动配置一次spring i18n国际化来介绍一下。
    以下是笔者使用的spring boot版本号.

    <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starters</artifactId>
    <version>2.2.5.RELEASE</version>
    
    1-1.在properties或yml资源文件里面配置i18n

    笔者用的是yml文件,可以自行转换成properties文件格式。

    • basename:以逗号分隔的基名列表(本质上是一个完全限定的类路径位置),每个基名都遵循ResourceBundle约定,对基于斜杠的位置提供宽松的支持。 如果它不包含包限定符(例如“org.mypackage”),它将从类路径根目录解析。
    • cache-duration:加载的资源包文件缓存持续时间。 如果没有设置,捆绑包将被永久缓存。 如果没有指定持续时间后缀,将使用秒。
    # i18n
    spring:
      messages:
        basename: i18n/messages
        cache-duration: 60
    
    1-2.在resources文件夹下新增i18n文件夹,并新建相应的国际化文件

    spring容器启动的时候,会根据配置的basename去对应的路径加载资源文件到MessageSource里,至于是怎么加载到MessageSource里的,在这里就不展开阐述了。

    文件配置如图 1-1所示。

    注意:红色框 里的就是i18n资源文件的配置,绿色框 是idea自动生成的文件夹,实际并不存在,无视就行。

    图 1-1 i18n资源文件.png
    # messages.properties
    test.name=test rookie0peng
    test.string=test string {0}
    test.date=test date {0, date}
    test.time=test time {0, time}
    
    # messages_en.properties
    test.name=test rookie0peng
    test.string=test string {0}
    test.date=test date {0, date}
    test.time=test time {0, time}
    
    # messages_zh.properties
    test.name=测试阿鹏
    test.string=测试字符串{0}
    test.date=测试日期{0, date}
    test.time=测试时间{0, time}
    
    1-3.在代码中使用i18n进行国际化

    这里演示的是比较简单的手动参数替换,还有更好一些的方法,比如说在响应数据写入流的时候进行参数替换。

    @RestController
    @RequestMapping("test")
    public class TestController {
    
        @Autowired
        private MessageSource messageSource;
    
        @ApiOperation(value = "随便打印数据")
        @RequestMapping(path = "console", method = RequestMethod.POST)
        public Map<String, String> console() {
            Map<String, String> map = new LinkedHashMap<>();
            //1.测试无参替换
            map.put("test.name", messageSource.getMessage("test.name", null, LocaleContextHolder.getLocale()));
            //2.测试字符串参数替换
            Object[] stringParam = new Object[] {"1"};
            map.put("test.string", messageSource.getMessage("test.string", stringParam, LocaleContextHolder.getLocale()));
            //3.测试日期参数替换
            Object[] dateParam = new Object[] {new Date()};
            map.put("test.date", messageSource.getMessage("test.date", dateParam, LocaleContextHolder.getLocale()));
            //3.测试时间参数替换
            Object[] timeParam = new Object[] {new Date()};
            map.put("test.time", messageSource.getMessage("test.time", timeParam, LocaleContextHolder.getLocale()));
            return map;
        }
    }
    
    1-4.测试spring i18n

    启动项目后,查看调用/test/console接口返回的数据,调用三次,分别设置header中的语言:默认、中文、英文。
    笔者用的是idea的HTTP Client,以下是请求参数:

    ### 默认——测试spring i18n
    POST {{baseUrl}}/test/console
    Content-Type: application/json
    
    {
    
    }
    
    ### 中文——测试spring i18n
    POST {{baseUrl}}/test/console
    Content-Type: application/json
    Accept-Language: zh-CN,zh;q=0.9
    
    {
    
    }
    
    ### 英文——测试spring i18n
    POST {{baseUrl}}/test/console
    Content-Type: application/json
    Accept-Language: en;q=0.9
    
    {
    
    }
    

    以下是响应参数。

    {
      "test.name": "测试阿鹏",
      "test.string": "测试字符串1",
      "test.date": "测试日期2021年7月3日",
      "test.time": "测试时间下午4:41:27"
    }
    
    {
      "test.name": "测试阿鹏",
      "test.string": "测试字符串1",
      "test.date": "测试日期2021年7月3日",
      "test.time": "测试时间下午4:41:27"
    }
    
    {
      "test.name": "test rookie0peng",
      "test.string": "test string 1",
      "test.date": "test date Jul 3, 2021",
      "test.time": "test time 4:41:32 PM"
    }
    
    1-5.分析执行结果
    • 1-5-1.测试默认测试中文的响应数据是一样的,可以确定系统默认使用的中文环境。
    • 1-5-2.调用getMessage()方法时,不传第2个参数就是无参替换;否则,反之。
    • 1-5-3.使用有参替换时,还可以在properties文件里加入date、time等参数,spring可以自动格式化成对应的日期和时间。
    1-6.结论

    这里只是简单的演示了spring i18n的功能,对于一些简单的场景已然满足需要,如果需要进行扩展的话,有几种思路。

    • 1-6-1.如果使用了nacos等配置中心,则需要去注册中心手动拉取i18n的properties文件内容,并加载到应用程序的内存里,也可以在本地用户文件夹存放一份。
    • 1-6-2.如果需要一些正则翻译的话,则需要自己动手写正则替换的表达式。
    • 1-6-3.该例子展示的是在controller里进行替换,更好一点的方式是在filter,甚至是在响应数据写入流的时候进行替换,比如说指定某个响应对象的某个属性的序列化类(@JsonSerialize(using = TestJsonSerializer.class)),则该字段序列化的时候就会使用TestJsonSerializer.class进行序列化。在这个类里面就可以针对性的做我们想要的替换了。

    2.自定义i18n

    spring i18n是挺好用的,但是面对复杂的业务需求,还不够强大。比如,用户想添加一种语言;递归替换;布局可以自定义,用户添加布局字段时,针对该项目或组织的不同地区的人员,设置不同的翻译内容等等。

    基于各种各样的原因,扩展i18n已是必须要做的事。

    那么怎么扩展呢?国际化的本质就是将key替换成不同语言的value,这句话中有几个关键点:key、替换、语言、value。其中key语言value都是名词,代表着具体的数据;替换是动词,代表具体的翻译逻辑。那我们就需要针对这几个点进行设计与实现。

    2-1.设计数据表

    思路:通过一种语言和键找到对应的值。表结构设计比较简单,key、value、语言各建一个表,如图 2-1所示。

    图 2-1 国际化表结构设计.png

    其中每个表只展示了主键编号字段,其实还有一些字段没展示出来,比如code、name,这些可以根据自己的风格去设计。如果是多租户的系统,在每张表后面加入对应的租户id,即可进行数据隔离。

    • 2-1-1.如果用户新增的字段需要翻译,往语言键里增加一条数据,以及往语言值里增加与语言定义相同数量的记录即可。
    • 2-1-2.如果用户新增语言定义,则往语言值里面增加与语言键相同数量的记录即可。
    • 2-1-3.更新、删除同理。
    2-2.数据缓存设计

    在一个面向世界的应用里面,翻译的频率是很高的,而且随着时间的流逝,翻译的数据肯定会越来越多,如果每次响应数据的翻译都去查询数据库的话,那势必会造成数据库性能以及应用本身性能的浪费。对于这种修改频率不算高的数据,咱们可以缓存起来,用空间换时间。

    这里打算用两级缓存的设计来适应该翻译场景,一级是redis,二级是应用内存。

    • 2-2-1.将用到的数据从数据库缓存在redis里面,并且生成一个更新标志放入redis。
    • 2-2-2.应用获取翻译数据的时候先判断redis更新标志是否为空。
      • 2-2-2-1.为空,则代表redis尚未缓存翻译数据,将翻译数据从数据库拉取到内存,且推送到redis。
      • 2-2-2-2.不为空,则代表redis已缓存翻译数据,然后再比对redis的更新标志和应用内存的更新标志是否一致。
        • 2-2-2-2-1.不一致,则说明翻译数据已经改变,需要从redis重新拉取一次翻译数据,缓存在应用内存中。
        • 2-2-2-2-2.一致,则说明翻译数据尚未改变,可以直接使用应用内存中的翻译数据。
    • 2-2-3.将最后拿到的翻译数据(key-value)返回给实现翻译逻辑的组件。

    如图 2-2所示。

    图 2-2 国际化两级缓存设计.png

    代码如下所示。

    /**
     * <pre>
     *  @description: 语言数据组件
     *  @author: rookie0peng
     *  @date: 2021/7/4 21:28
     *  </pre>
     */
    public interface LanguageDataComponent {
    
        /**
         * 通过语言定义和键获取值
         * @param definition 语言定义
         * @param key 键
         * @return optional -> 值
         */
        Optional<String> getValue(String definition, String key);
    
        /**
         * 更新语言键值对映射
         * @param definition 语言定义
         */
        void updateKeyValueMap(String definition);
    }
    
    @Service("languageDataComponent")
    public class LanguageDataComponentImpl implements LanguageDataComponent {
    
        /**
         * 语言定义 -> (key, value)
         */
        private static final Map<String, Map<String, String>> DEFINITION_TO_KEY_VALUE_MAP = new ConcurrentHashMap<>();
    
        /**
         * 一天
         */
        private static final long ONE_DAY = 86400;
    
        /**
         * 更新标志
         */
        private static final String CHANGE_FLAG = "CHANGE_FLAG";
    
        @Autowired
        private RedisTemplate<String, ?> redisTemplate;
    
        @Override
        public Optional<String> getValue(String definition, String key) {
            if (isEmpty(definition) || isEmpty(key))
                return Optional.empty();
            Map<String, String> key2ValueMap = DEFINITION_TO_KEY_VALUE_MAP.get(definition);
            return isNull(key2ValueMap) ? Optional.empty() : Optional.ofNullable(key2ValueMap.get(key));
        }
    
        @Override
        public void updateKeyValueMap(String definition) {
            if (isEmpty(definition))
                return;
            //1.获取redis更新标志和本地更新标志
            String redisChangeFlag = this.getRedisChangeFlag(definition);
            String localChangeFlag = this.getLocalChangeFlag(definition);
            //2.redis更新标志为空,则从数据库获取数据并分别推送到redis和应用内存,注意缓存击穿、缓存穿透和缓存雪崩的情况
            if (isEmpty(redisChangeFlag)) {
                this.deleteByKey(definition);
                this.getAndPutAllData(definition);
                return;
            }
            //3.如果不相等,则从redis拉取数据并保存到应用内存
            if (!redisChangeFlag.equals(localChangeFlag)) {
                Map<String, String> key2ValueMap = redisTemplate.opsForHash()
                        .entries(definition).entrySet().stream()
                        .collect(Collectors.toMap(entry -> objectToString(entry.getKey()),
                                entry -> objectToString(entry.getValue())));
                DEFINITION_TO_KEY_VALUE_MAP.put(definition, key2ValueMap);
            }
        }
    
        /**
         * 获取redis的更新标志
         * @param definition 语言定义
         * @return redis的更新标志
         */
        private String getRedisChangeFlag(String definition) {
            Object o = redisTemplate.opsForHash().get(definition, CHANGE_FLAG);
            return StringUtil.objectToString(o);
        }
    
        /**
         * 获取本地更新标志
         * @param definition 语言定义
         * @return 本地的更新标志
         */
        private String getLocalChangeFlag(String definition) {
            Map<String, String> key2ValueMap = DEFINITION_TO_KEY_VALUE_MAP.get(definition);
            return isNull(key2ValueMap) ? null : key2ValueMap.get(CHANGE_FLAG);
        }
    
        /**
         * 通过语言定义删除数据
         * @param definition 语言定义
         */
        private void deleteByKey(String definition) {
            redisTemplate.delete(definition);
        }
    
        /**
         * 获取与推送翻译数据
         * @param definition 语言定义
         * @return 翻译数据
         */
        private Map<String, String> getAndPutAllData(String definition) {
            //1.从数据库查询翻译数据
            Map<String, String> databaseKeyValueMap = this.getLanguageDataFromDatabase(definition);
            Map<String, String> key2ValueMap = isEmpty(databaseKeyValueMap) ? new ConcurrentHashMap<>()
                    : new ConcurrentHashMap<>(databaseKeyValueMap);
            //2.设置更新标志
            key2ValueMap.put(CHANGE_FLAG, UUIDUtil.randomUUID());
            //3.将数据推送到redis
            byte[] byteKey = definition.getBytes(StandardCharsets.UTF_8);
            Map<byte[], byte[]> byteMap = key2ValueMap.entrySet().stream()
                    .collect(Collectors.toMap(
                            entry -> entry.getKey().getBytes(StandardCharsets.UTF_8),
                            entry -> entry.getValue().getBytes(StandardCharsets.UTF_8)));
            redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
                connection.hMSet(byteKey, byteMap);
                return null;
            });
            Long expire = redisTemplate.getExpire(definition);
            if (expire == null || expire < 300L)
                redisTemplate.expire(definition, ONE_DAY, TimeUnit.SECONDS);
            //4.将数据放到应用内存
            DEFINITION_TO_KEY_VALUE_MAP.put(definition, key2ValueMap);
            return key2ValueMap;
        }
    
        /**
         * 从数据库获取数据
         * @param definition 语言定义
         * @return 数据
         */
        private Map<String, String> getLanguageDataFromDatabase(String definition) {
            //TODO 补充从数据库查询的代码
            Map<String, String> map = new ConcurrentHashMap<>();
            if (Locale.SIMPLIFIED_CHINESE.toLanguageTag().equals(definition)) {
                map.put("test.key1", "这是第一个值");
                map.put("test.key2", "这是第二个值");
            } else {
                map.put("test.key1", "the first value");
                map.put("test.key2", "the second value");
            }
            return map;
        }
    }
    
    2-3.将替换逻辑嵌入spring的filter或者序列化

    笔者在这里只演示简单的key->value替换,至于递归替换、正则替换可以自行考虑加上。

    • 2-3-1.当一个请求进来的时候,首先需要做一些前置处理。
      • 2-3-1-1.根据请求的语言设置当前线程的语言环境。
      • 2-3-1-2.更新一次当前应用内存的语言缓存数据。
    • 2-3-2.当返回响应的时候,通过序列化对响应数据进行替换。

    代码如下所示。

    /**
     * <pre>
     *  @description: 语言对象
     *  @author: rookie0peng
     *  @date: 2021/1/18 17:17
     *  </pre>
     */
    public class Language {
    
        /**
         * 语言编码, 默认中文简体
         */
        private String localeTag;
    
        public Language() {
    
        }
    
        public Language(String localeTag) {
            this.localeTag = localeTag;
        }
    
        public String getLocaleTag() {
            return localeTag;
        }
    
        public void setLocaleTag(String localeTag) {
            this.localeTag = localeTag;
        }
    
        @Override
        public String toString() {
            return "Language{" +
                    "localeTag='" + localeTag + '\'' +
                    '}';
        }
    }
    
    /**
     * <pre>
     *  @description: 基于线程上下文的语言工具类
     *  @author: rookie0peng
     *  @date: 2021/1/18 17:16
     *  </pre>
     */
    public class LanguageContextUtil {
    
        private static final ThreadLocal<Language> CONTEXT = new ThreadLocal<>();
    
        /**
         * 设置语言信息
         * @param language 语言信息
         */
        public static void set(Language language) {
            CONTEXT.set(language);
        }
    
        /**
         * 获取语言信息
         * @return 语言信息
         */
        public static Language get() {
            return CONTEXT.get();
        }
    
        /**
         * 移除语言信息
         */
        public static void remove() {
            CONTEXT.remove();
        }
    }
    
    /**
     * <pre>
     *  @description: 语言过滤器
     *  @author: rookie0peng
     *  @date: 2021/1/18 17:16
     *  </pre>
     */
    @Component
    public class LanguageFilter implements Filter {
    
        @Autowired
        private LanguageDataComponent languageDataComponent;
    
        @Override
        public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
                throws IOException, ServletException {
            try {
                //1.设置当前线程的语言环境
                String localeTag;
                Locale locale = servletRequest.getLocale();
                LanguageContextUtil.set(isNull(locale) || isNull(localeTag = locale.toLanguageTag())
                        ? new Language(Locale.SIMPLIFIED_CHINESE.toLanguageTag()) : new Language(localeTag));
                //2.更新语言数据
                languageDataComponent.updateKeyValueMap(LanguageContextUtil.get().getLocaleTag());
                filterChain.doFilter(servletRequest, servletResponse);
            } finally {
                LanguageContextUtil.remove();
            }
        }
    }
    
    /**
     * <pre>
     *  @description: i18n序列化
     *  @author: rookie0peng
     *  @date: 2021/7/4 21:28
     *  </pre>
     */
    public class I18nJsonSerializer<T> extends JsonSerializer<T> {
    
        @Override
        public void serialize(T t, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {
            //1.获取语言组件
            LanguageDataComponent languageDataComponent = SpringUtil.getBean(LanguageDataComponent.class);
            //2.通过 语言标志 和 键 获取对应的 值
            Optional<String> valueOptional = languageDataComponent.getValue(LanguageContextUtil.get().getLocaleTag(),
                    StringUtil.objectToString(t));
            //3.写入流
            if (valueOptional.isPresent())
                jsonGenerator.writeString(valueOptional.get());
            else
                jsonGenerator.writeString(StringUtil.objectToString(t));
        }
    }
    
    public class TestTranslateVO implements Serializable {
    
        private static final long serialVersionUID = -273426001439788094L;
    
        private String code;
    
        /**
         * 指定i18n序列化类
         */
        @JsonSerialize(using = I18nJsonSerializer.class)
        private String name;
    
        public TestTranslateVO() {
    
        }
    
        public TestTranslateVO(String code, String name) {
            this.code = code;
            this.name = name;
        }
    
        public String getCode() {
            return code;
        }
    
        public void setCode(String code) {
            this.code = code;
        }
    
        public String getName() {
            return name;
        }
    
        public void setName(String name) {
            this.name = name;
        }
    
        @Override
        public String toString() {
            return "TestTranslateVO{" +
                    "code='" + code + '\'' +
                    ", name='" + name + '\'' +
                    '}';
        }
    }
    
    /**
     * <pre>
     *  @description: 通过接口测试i18n
     *  @author: rookie0peng
     *  @date: 2021/1/20 13:07
     *  </pre>
     */
    @Api(tags = "测试接口-作者:rookie0peng")
    @RestController
    @RequestMapping("test")
    public class TestController {
    
        @Autowired
        private MessageSource messageSource;
    
        @ApiOperation(value = "随便打印数据")
        @RequestMapping(path = "console", method = RequestMethod.POST)
        public Map<String, String> console() {
            Map<String, String> map = new LinkedHashMap<>();
            //1.测试无参替换
            map.put("test.name", messageSource.getMessage("test.name", null, LocaleContextHolder.getLocale()));
            //2.测试字符串参数替换
            Object[] stringParam = new Object[] {"1"};
            map.put("test.string", messageSource.getMessage("test.string", stringParam, LocaleContextHolder.getLocale()));
            //3.测试日期参数替换
            Object[] dateParam = new Object[] {new Date()};
            map.put("test.date", messageSource.getMessage("test.date", dateParam, LocaleContextHolder.getLocale()));
            //3.测试时间参数替换
            Object[] timeParam = new Object[] {new Date()};
            map.put("test.time", messageSource.getMessage("test.time", timeParam, LocaleContextHolder.getLocale()));
            return map;
        }
    
        @ApiOperation(value = "测试自定义i18n")
        @RequestMapping(path = "test/custom-i18n", method = RequestMethod.POST)
        public List<TestTranslateVO> testCustomI18n() {
            //test.key1和test.key2在LanguageDataComponentImpl.getLanguageDataFromDatabase方法中定义了
            //test.key3用来测试不翻译的场景
            return ListUtil.newArrayList(
                    new TestTranslateVO("test.key1", "test.key1"),
                    new TestTranslateVO("test.key2", "test.key2"),
                    new TestTranslateVO("test.key3", "test.key3"));
        }
    }
    
    2-4.测试自定义i18n

    启动项目后,查看调用test/custom-i18n接口返回的数据,调用三次,分别设置header中的语言:默认、中文、英文。
    笔者用的是idea的HTTP Client,以下是请求参数:

    ### 默认——测试自定义i18n
    POST {{baseUrl}}/test/test/custom-i18n
    Content-Type: application/json
    
    {
    
    }
    
    ### 中文——测试自定义i18n
    POST {{baseUrl}}/test/test/custom-i18n
    Content-Type: application/json
    Accept-Language: zh-CN,zh;q=0.9
    
    {
    
    }
    
    ### 英文——测试自定义i18n
    POST {{baseUrl}}/test/test/custom-i18n
    Content-Type: application/json
    Accept-Language: en;q=0.9
    
    {
    
    }
    

    以下是返回参数。

    [
      {
        "code": "test.key1",
        "name": "这是第一个值"
      },
      {
        "code": "test.key2",
        "name": "这是第二个值"
      },
      {
        "code": "test.key3",
        "name": "test.key3"
      }
    ]
    
    [
      {
        "code": "test.key1",
        "name": "这是第一个值"
      },
      {
        "code": "test.key2",
        "name": "这是第二个值"
      },
      {
        "code": "test.key3",
        "name": "test.key3"
      }
    ]
    
    [
      {
        "code": "test.key1",
        "name": "the first value"
      },
      {
        "code": "test.key2",
        "name": "the second value"
      },
      {
        "code": "test.key3",
        "name": "test.key3"
      }
    ]
    
    2-5.分析执行结果
    • 2-5-1.测试默认测试中文的响应数据是一样的,可以确定系统默认使用的中文环境。
    • 2-5-2.对于使用了@JsonSerialize(using = I18nJsonSerializer.class)注解的属性,会根据key自动替换成对应的值。
    • 2-5-3.根据key没找到值时,还是会使用原本的key。
    2-6.结论

    这里只是简单的演示了自定义i18n的功能,但是已然支持用户新增语言、自定义翻译后的值、多机部署等。如果想要支持正则替换、递归翻译也可以自行扩展。

    3.总结

    这里演示了两种i18n的实现方案,具体想用哪种就见仁见智了。图方便,开箱即用,那就选spring i18n;图灵活,可扩展性强,那就选自定义i18n。自然,肯定还有很多我没想到的方案,如果可以的话,欢迎大家在评论区指点。

    参考

    本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!

    相关文章

      网友评论

          本文标题:多语言翻译组件的升级之路

          本文链接:https://www.haomeiwen.com/subject/lxfdultx.html