说起国际化,真的是老生常谈了。后端有各种i18n的依赖组件,springboot本身也支持i18n的设置,前端vue也有i18n的设置,这些常规操作就不提了,大家可以去搜索其他博客,写的都很详细。
本篇博客主要写的是业务内容国际化。举一个最常用最简单的例子,学生选课,课程有"语文","数学","英语"。这个课程也是一张业务表,随着课程的增多数据是逐渐增多的。一个学生要查看自己选择的课程时,如何根据语言进行国际化的反显"数学"还是"mathematics"。
最开始我拿到这个需求的时候,很挠头,怎么办,难得不是把这个需求做出来,这个需求实现得方式很多:
- 多建冗余字段,把”数学“和”mathematics“都存到表里,这样有明显得缺点,语言增多时需要一直在表里加字段。
- 建一张code、language、value的对应关系,查询数据的时候根据code和language进行value的匹配,这种缺陷也很明显,业务侵入性很强。
我要做的事情是让业务开发人员在无感知的情况下或侵入性很小的情况下把需求实现。提到侵入性小,大家很容易联想到切面编程AOP。我个人认为AOP最好用的地方就是能拿到自定义注解,通过在java类或者java方法上增加注解,在切面获取引入的东西并将我们相要的东西织入。
灵感一来,我们就开干。
一、建表,并存储基础数据
表的作用是能将各种code对应的各种语言的各种value进行匹配,建表比写在配置文件的好处是显而易见的,因为我们做的是业务内容的国际化,而不是定死的几个值得国际化,我们需要根据业务动态得调整内容。这个表的数据可以开一个接口,业务数据发生变化时,可以直接调用这个接口,对表中数据进行更新。
表结构如下:
LANGUAGE_ID 主键
LANGUAGE_KEY 存在业务表中得业务标识
LANGUAGE 语言标识
LANGUAGE_VALUE 国际化后的值
MODEL 模块名称,主要防止KEY重复,同一个key在不同的业务中代表的含义不同。
以上面选课为例,该表存放的值为
1 course math en mathematics
2 course math zh-CN 数学
二、获取表中数据放入缓存
数据咱们都有了,怎么把数据拿出来用呢,每次查库?肯定不现实,我们应该提前把准备好,放在缓存中,谁想用直接取。缓存有多种方式。我们做jvm和redis两种,让大家做选择,追求效率就用jvm缓存,不求效率就用redis,对本身服务影响小一些。
1、首先定义实体类
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;/*** 系统语言ResultDTO*/
@Getter
@Setter
@ToString
public class SysLanguageConfig {private Long languageId;private String model;private String languageKey;private String language;private String languageValue;private long currentPage;private long pageSize;
}
2、获取数据并缓存的配置类
package com.cnhtc.hdf.wf.common.i18n;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.xxl.job.core.context.XxlJobHelper;
import com.xxl.job.core.handler.annotation.XxlJob;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.dao.DataAccessException;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.connection.RedisStringCommands;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.types.Expiration;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.util.CollectionUtils;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StopWatch;import java.util.*;
import java.util.concurrent.*;@Configuration
@EnableFeignClients(clients = {SysLanguageConfigService.class})
@Slf4j
public class LanguageCahceConfigration {public static ConcurrentHashMap<String, String> localCacheMap = new ConcurrentHashMap<>(); //本地存储缓存的map/*** 存储redis 热点数据*/public static ConcurrentHashMap<String, String> redisHotspotCacheMap = new ConcurrentHashMap<>();public final static String CACHE_KEY_JOIN_SYMBOL = "_";private static Boolean i18n;@Value("${i18nPageSize: 5000}")private Integer i18nPageSize;@Value("${i18nEnableInitDataParallel: false}")private Boolean i18nEnableInitDataParallel;/*** 是否开启redis热点数据缓存,默认不开启*/private static Boolean i18nEnableRedisHotspotCache;/*** 开启缓存模式* local MAP* redis*/private static String i18nEnableCacheMode;private final static String CACHE_MODE = "local";/*** 多少个元素拆分一个List*/private final Integer splitListSize = 10;/*** 批量插入 条数*/private final Integer REDIS_BATCH_SAVE_SIZE = 5000;/*** 失效时间*/private final long EXPIRE_SECONDS = 3600 * 1000;@Autowiredprivate SysLanguageConfigService sysLanguageConfigService;@Beanpublic SysLanguageConfigServiceFallback sysLanguageConfigServiceFallback() {return new SysLanguageConfigServiceFallback();}public LanguageCahceConfigration() {System.out.println("------------------- 加载 LanguageCahceConfigration ----------------------------------");}@Scheduled(initialDelay = 1000, fixedRateString = "${i18nScheduledFixedRate:3600000}")public void setLanguageCacheMap() {if (i18n) {if (!CACHE_MODE.equals(i18nEnableCacheMode)) {return;}CopyOnWriteArrayList<SysLanguageConfig> allList = new CopyOnWriteArrayList<>();StopWatch sw = new StopWatch();try {sw.start("数据查询");if (i18nEnableInitDataParallel) {this.selectDataCompletableFuture(allList);} else {this.selectData(allList);}sw.stop();} catch (Exception e) {e.printStackTrace();allList.clear();}log.debug("allList size = {}", allList.size());sw.start("本地缓存");localCacheMap.clear();localCacheMap.putAll(this.getCacheDataMap(allList));sw.stop();log.warn("初始化i18n 缓存耗时 , {}", sw.prettyPrint());log.warn("初始化i18n 缓存总耗时 , {}", sw.getTotalTimeSeconds());}}/*** 循环查询数据** @param allList 数据集合*/private void selectData(CopyOnWriteArrayList<SysLanguageConfig> allList) {int page = 1;boolean isContinue = false;do {SysLanguageConfig sysLanguageConfig = new SysLanguageConfig();sysLanguageConfig.setCurrentPage(page);sysLanguageConfig.setPageSize(i18nPageSize);Page<SysLanguageConfig> result = sysLanguageConfigService.getAll(sysLanguageConfig);if (result != null && !CollectionUtils.isEmpty(result.getRecords())) {allList.addAll(result.getRecords());if (result.getPages() > page) {isContinue = true;page = page + 1;} else {isContinue = false;}}} while (isContinue);}/*** 异步分页查询数据** @param allList 数据集合* @throws Exception 异常*/private void selectDataCompletableFuture(CopyOnWriteArrayList<SysLanguageConfig> allList) throws Exception {Page<SysLanguageConfig> result = this.getData();if (result != null && result.getPages() > 0) {allList.addAll(result.getRecords());if (result.getPages() > 1) {ForkJoinPool pool = new ForkJoinPool();List<Integer> pageList = new ArrayList<>();for (int i = 2; i <= result.getPages(); i++) {pageList.add(i);}List<List<Integer>> partition = Lists.partition(pageList, splitListSize);for (List<Integer> pages : partition) {List<CompletableFuture<Void>> futureList = new ArrayList<>();for (Integer page : pages) {SysLanguageConfig param = new SysLanguageConfig();param.setCurrentPage(page);param.setPageSize(i18nPageSize);CompletableFuture<Void> future = CompletableFuture.runAsync(() ->allList.addAll(sysLanguageConfigService.getAll(param).getRecords()), pool);futureList.add(future);}CompletableFuture<Void> allSources = CompletableFuture.allOf(futureList.toArray(new CompletableFuture[futureList.size()]));allSources.get();}}}}/*** 获取数据* @return Page<SysLanguageConfig>*/private Page<SysLanguageConfig> getData(){SysLanguageConfig sysLanguageConfig = new SysLanguageConfig();sysLanguageConfig.setCurrentPage(1);sysLanguageConfig.setPageSize(i18nPageSize);return sysLanguageConfigService.getAll(sysLanguageConfig);}/*** 批量插入并设置 失效时间,但是性能慢** @param map 数据*/private void redisPipelineInsert(ConcurrentHashMap<String, String> map) {StringRedisTemplate stringRedisTemplate = ApplicationContextUtil.getBean(StringRedisTemplate.class);RedisSerializer<String> serializer = stringRedisTemplate.getStringSerializer();stringRedisTemplate.executePipelined(new RedisCallback<String>() {@Overridepublic String doInRedis(RedisConnection connection) throws DataAccessException {map.forEach((key, value) -> {connection.set(serializer.serialize(key), serializer.serialize(value), Expiration.seconds(EXPIRE_SECONDS), RedisStringCommands.SetOption.UPSERT);});return null;}}, serializer);}/*** 批量插入后 异步设置失效时间** @param map 数据*///@Asyncpublic void setExpire(ConcurrentHashMap<String, String> map) {StringRedisTemplate stringRedisTemplate = ApplicationContextUtil.getBean(StringRedisTemplate.class);map.forEach((k, v) -> stringRedisTemplate.expire(k, EXPIRE_SECONDS, TimeUnit.SECONDS));}/*** 刷新redis缓存*/@XxlJob("i18nRefreshRedisCache")public void refreshRedisCache() {XxlJobHelper.log("回调任务开始");if (i18n) {if (CACHE_MODE.equals(i18nEnableCacheMode)) {log.error("i18n国际化配置本地缓存,请勿用redis刷新缓存");}CopyOnWriteArrayList<SysLanguageConfig> allList = new CopyOnWriteArrayList<>();StopWatch sw = new StopWatch();try {sw.start("数据查询");if (i18nEnableInitDataParallel) {this.selectDataCompletableFuture(allList);} else {this.selectData(allList);}sw.stop();} catch (Exception e) {e.printStackTrace();allList.clear();}sw.start("redis缓存");StringRedisTemplate stringRedisTemplate = ApplicationContextUtil.getBean(StringRedisTemplate.class);if (ObjectUtils.isEmpty(stringRedisTemplate)) {throw new BaseException(ErrorEnum.NOTHROWABLE_ERROR, "StringRedisTemplate is null");}redisHotspotCacheMap.clear();ConcurrentHashMap<String, String> cacheDataMap = this.getCacheDataMap(allList);List<Map<String, String>> maps = splitMap(cacheDataMap, REDIS_BATCH_SAVE_SIZE);// multiSet 批量插入,key值存在会覆盖原值maps.forEach(data -> stringRedisTemplate.opsForValue().multiSet(data));sw.stop();log.warn("初始化i18n redis缓存耗时 , {}", sw.prettyPrint());log.warn("初始化i18n redis缓存总耗时 , {}", sw.getTotalTimeSeconds());}XxlJobHelper.log("回调任务结束");}/*** 刷新local缓存*///@XxlJob("i18nRefreshLocalCache")public ResponseDTO refreshLocalCache() {if (CACHE_MODE.equals(i18nEnableCacheMode)) {this.setLanguageCacheMap();return new ResponseDTO(SysErrEnum.SUCCESS);}return new ResponseDTO(SysErrEnum.ERROR.code(), "i18n国际化配置Redis缓存,请勿用本地刷新缓存");}/*** 获取缓存数据** @param allList* @return*/private ConcurrentHashMap<String, String> getCacheDataMap(List<SysLanguageConfig> allList) {ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();allList.parallelStream().forEach(config -> map.put(config.getModel() + CACHE_KEY_JOIN_SYMBOL + config.getLanguageKey() + CACHE_KEY_JOIN_SYMBOL + config.getLanguage(), config.getLanguageValue()));log.warn("map size:{}", map.size());return map;}/*** 获取缓存数据值** @param key key* @return value*/public static String getCacheValueByKey(String key) {if (i18n) {String value;if (CACHE_MODE.equals(i18nEnableCacheMode)) {value = localCacheMap.get(key);log.debug("多语言转换:本地缓存数量 = {}, key = {}", +localCacheMap.values().size(), key);} else {if (i18nEnableRedisHotspotCache) {if (redisHotspotCacheMap.containsKey(key)) {value = redisHotspotCacheMap.get(key);} else {StringRedisTemplate stringRedisTemplate = ApplicationContextUtil.getBean(StringRedisTemplate.class);value = stringRedisTemplate.opsForValue().get(key);if (StringUtils.isNotBlank(value)) {// 缓存热点数据redisHotspotCacheMap.put(key, value);}}} else {StringRedisTemplate stringRedisTemplate = ApplicationContextUtil.getBean(StringRedisTemplate.class);value = stringRedisTemplate.opsForValue().get(key);}}return value;}return null;}@Value("${i18nEnableCacheMode: local}")private void setI18nEnableCacheMode(String i18nEnableCacheMode) {LanguageCahceConfigration.i18nEnableCacheMode = i18nEnableCacheMode;}@Value("${i18nEnableRedisHotspotCache: false}")private void setI18nEnableRedisHotspotCache(Boolean i18nEnableRedisHotspotCache) {LanguageCahceConfigration.i18nEnableRedisHotspotCache = i18nEnableRedisHotspotCache;}@Value("${i18n: false}")private void setI18n(Boolean i18n) {LanguageCahceConfigration.i18n = i18n;}/*** Map拆分 (指定分组大小)** @param map Map* @param chunkSize 每个分组的大小 (>=1)* @param <K> Key* @param <V> Value* @return 子Map列表*/private <K, V> List<Map<K, V>> splitMap(Map<K, V> map, int chunkSize) {if (Objects.isNull(map) || map.isEmpty() || chunkSize < 1) {//空map或者分组大小<1,无法拆分return Collections.emptyList();}int mapSize = map.size(); //键值对总数int groupSize = mapSize / chunkSize + (mapSize % chunkSize == 0 ? 0 : 1); //计算分组个数List<Map<K, V>> list = Lists.newArrayListWithCapacity(groupSize); //子Map列表if (chunkSize >= mapSize) { //只能分1组的情况list.add(map);return list;}int count = 0; //每个分组的组内计数Map<K, V> subMap = Maps.newHashMapWithExpectedSize(chunkSize); //子Mapfor (Map.Entry<K, V> entry : map.entrySet()) {if (count < chunkSize) {//给每个分组放chunkSize个键值对,最后一个分组可能会装不满subMap.put(entry.getKey(), entry.getValue());count++; //组内计数+1} else {//结束上一个分组list.add(subMap); //当前分组装满了->加入列表//开始下一个分组subMap = Maps.newHashMapWithExpectedSize(chunkSize); //新的分组subMap.put(entry.getKey(), entry.getValue()); //添加当前键值对count = 1; //组内计数重置为1}}list.add(subMap); //添加最后一个分组return list;}
}
整段代码其中区分了本地缓存、redis缓存等等,还有就是查刚才数据库表里得数据,因为我们才用了微服务得架构,所以获取数据得部分是通过feign的方式获取的,大家可以替换成自己的方法。另外,开启redis缓存的部分可以取舍,没必要这么完善,保留一种即可。本地缓存的定时任务是springboot的,redis的定时任务是xxl-job的,这些技术栈都可以替换。
其中最重要的一点,redis比本地缓存慢很多,100条数据的国际化反显,速度会差20倍。为什么差怎么多,接下来就到关键内容了。
三、注解定义
注解定义的意义就是在序列化的时候,能通过注解拿到切入点并获取注解的内容
import java.lang.annotation.*;
import com.fasterxml.jackson.annotation.JacksonAnnotationsInside;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;@Documented
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@JacksonAnnotationsInside
@JsonSerialize(using = I18nSerializer.class
)
public @interface I18n {String model() default "common";String language() default "";String key() default "";
}
四、基于JsonSerializer的序列化处理,进行国际化转换
大家现在都在用springboot的restController,也就是说,前后端分离之后,前后端的交互就是json,在controller返回的内容其实就是一个实体对象或者集合,那这个实体对象或者集合是怎么转换成json的,就是通过springboot中引入的jackson来实现的,具体实现原理不多说。
我们只需要知道,写一个子类,来继承JsonSerializer和实现ContextualSerializer就能实现序列化的时候进行织入操作。
其中language是通过header从前端传递过来的。
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.BeanProperty;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.ser.ContextualSerializer;
import com.stdp.hdf.wf.common.core.constants.Constants;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.beanutils.BeanUtils;
import org.apache.commons.lang.StringUtils;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.util.Map;
import java.util.Objects;@Slf4j
public class I18nSerializer extends JsonSerializer<String> implements ContextualSerializer {private String model;private String language;private String key;public I18nSerializer(String model, String language, String key) {this.model = model;this.language = language;this.key = key;}public I18nSerializer() {}@Overridepublic void serialize(String s, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {String requestLanguage = null;String mapkey = s;if (StringUtils.isBlank(language)) {requestLanguage = getLanguage();} else {requestLanguage = language;}if (StringUtils.isNotBlank(requestLanguage)) {if (StringUtils.isNotBlank(key)) {Object o = jsonGenerator.getCurrentValue();mapkey = getPropertyValue(o, key).toString();}String keyString = model + LanguageCahceConfigration.CACHE_KEY_JOIN_SYMBOL + mapkey + LanguageCahceConfigration.CACHE_KEY_JOIN_SYMBOL + requestLanguage;String keyName = LanguageCahceConfigration.getCacheValueByKey(keyString);if (StringUtils.isBlank(keyName)) {keyName = s;}jsonGenerator.writeString(keyName);} else {jsonGenerator.writeString(s);}}@Overridepublic JsonSerializer<?> createContextual(SerializerProvider serializerProvider, BeanProperty beanProperty) throws JsonMappingException {if (beanProperty != null) { // 为空直接跳过if (Objects.equals(beanProperty.getType().getRawClass(), String.class)) { // 非 String 类直接跳过I18n i18n = beanProperty.getAnnotation(I18n.class);if (i18n == null) {i18n = beanProperty.getContextAnnotation(I18n.class);}if (i18n != null) { // 如果能得到注解,就将注解的 value 传入 I18nSerializerreturn new I18nSerializer(i18n.model(), i18n.language(), i18n.key());}}return serializerProvider.findValueSerializer(beanProperty.getType(), beanProperty);}return serializerProvider.findNullValueSerializer(beanProperty);}public String getLanguage() {//直接从request中获取language信息RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();if (requestAttributes == null) {return null;}HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();return request.getHeader(Constants.LANGUAGE);}public Object getPropertyValue(Object t, String objProperty) {Map<String, String> objMap = null;try {objMap = BeanUtils.describe(t);if (objMap.get(objProperty) != null) {return objMap.get(objProperty);}return "";} catch (Exception e) {e.printStackTrace();}return "";}
}
五、使用
还是以选课为例,返回的json信息,CourseName自动就转成了对应的语言。
@Getter
@Setter
@ToString
public Course implements Serializable {private String courseCode; //课程编号 @I18n(model = "course",key = "courseCode")private String courseName; //课程名称}