Spring 3.1 版本引入基于 annotation 的 cache 技术,提供了一套抽象的缓存实现方案,通过注解方式使用缓存,基于配置的方式灵活使用不同缓存组件。代码具有相当的灵活性和扩展性,本文基于 Spring 5.x 源码一起分析 Spring Cache 的代码艺术。

开启 Spring Cache

想让 Spring 提供 Cache 能力很简单,只需要在启动类加上 @EnableCaching 注解即可:

@Configuration
@EnableCaching
public class ServerMain { }

通过在启动类上添加 EnableCaching 注解将 Cache 相关的组件注入到 Spring 启动中,通过 Proxy 或者 AspectJ 的方式获取 Cache 对应的执行信息。

如果你希望在启动时修改一些 Cache 底层对应的基础管理信息,可以通过在启动类上覆盖 CachingConfigurerSupport 提供的相关方法来实现:

@EnableCaching
@SpringBootApplication
public class WebDemoApplication extends CachingConfigurerSupport { public static void main(String[] args) {
SpringApplication.run(WebDemoApplication.class, args);
} @Override
public CacheManager cacheManager() {
return super.cacheManager();
} @Override
public KeyGenerator keyGenerator() {
return super.keyGenerator();
}
}

上面的示例代码中重写了 CacheManager 和 KeyGenerator 两个类的构建实现,分别实现的功能是 Cache 管理方式和 Cache key 生成方式。

从 Bean 加载机制了解 Spring 的 Cache 执行管理

从启动类入手我们很容易看到整体的 Cache 管理方式:

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Import({CachingConfigurationSelector.class})
public @interface EnableCaching {
boolean proxyTargetClass() default false; AdviceMode mode() default AdviceMode.PROXY; int order() default 2147483647;
}
  • proxyTargetClass:false,表示使用 JDK 代理,true 表示使用 cglib 代理。

  • mode:指定 AOP 的模式,当值为 AdviceMode.PROXY 时表示使用 Spring aop,当值为当值为AdviceMode.ASPECTJ 时,表示使用 AspectJ。

EnableCaching 使用时导入 CachingConfigurationSelector 类,我们看看加载了什么信息:

public class CachingConfigurationSelector extends AdviceModeImportSelector<EnableCaching> {

    public String[] selectImports(AdviceMode adviceMode) {
switch(adviceMode) {
case PROXY:
return this.getProxyImports();
case ASPECTJ:
return this.getAspectJImports();
default:
return null;
}
}
......
......
...... static {
ClassLoader classLoader = CachingConfigurationSelector.class.getClassLoader();
jsr107Present = ClassUtils.isPresent("javax.cache.Cache", classLoader);
jcacheImplPresent = ClassUtils.isPresent("org.springframework.cache.jcache.config.ProxyJCacheConfiguration", classLoader);
} }

可以看到主要做了一件事,通过代理方式的不同加载对应的 CacheConfiguration :

  • 如果是 JDK Proxy,加载 AutoProxyRegistrar 类和 ProxyCachingConfiguration 类;
  • 如果是 AspectJ,则加载 AspectJCachingConfiguration 类。

Spring Boot 默认使用 JDK Proxy,我们就看 JDK Proxy 的使用。

AutoProxyRegistrar 的作用就是创建代理对象,内部通过调用 AopConfigUtils.registerAutoProxyCreatorIfNecessary(registry) 方法在 IOC 容器中注册一个 AutoProxyCreator。最终注入到容器中的 AutoProxyRegistrar 是一个 InfrastructureAdvisorAutoProxyCreator 类型:

@Nullable
public static BeanDefinition registerAutoProxyCreatorIfNecessary(BeanDefinitionRegistry registry, @Nullable Object source) {
return registerOrEscalateApcAsRequired(InfrastructureAdvisorAutoProxyCreator.class, registry, source);
}

InfrastructureAdvisorAutoProxyCreator 类型只会为基础设施类型的 Advisor 自动创建代理对象。它只会去找符合条件的 bean创建代理,从源码可见只有 role 为 BeanDefinition.ROLE_INFRASTRUCTURE 的满足条件。

ProxyCachingConfiguration 创建了 3 个 bean,CacheOperationSource 关注如何获取所有拦截的切面,

CacheInterceptor 解决要对拦截到的切面做什么。

public class ProxyCachingConfiguration extends AbstractCachingConfiguration {

    @Bean(
name = {"org.springframework.cache.config.internalCacheAdvisor"}
)
@Role(2)
public BeanFactoryCacheOperationSourceAdvisor cacheAdvisor(CacheOperationSource cacheOperationSource, CacheInterceptor cacheInterceptor) {
BeanFactoryCacheOperationSourceAdvisor advisor = new BeanFactoryCacheOperationSourceAdvisor();
advisor.setCacheOperationSource(cacheOperationSource);
advisor.setAdvice(cacheInterceptor);
if (this.enableCaching != null) {
advisor.setOrder((Integer)this.enableCaching.getNumber("order"));
} return advisor;
}
......
}

BeanFactoryCacheOperationSourceAdvisor 是 Spring Cache 自己实现的 Advisor,会对所有能取出 CacheOperation 的方法执行 CacheInterceptor 这个 Advice。

小知识:

Spring AOP 的创建过程本质是实现一个 BeanPostProcessor,在创建 bean 的过程中创建 Proxy,并且为 Proxy 绑定所有适用于该 bean 的 advisor,最终暴露给容器。

Spring 中 AOP 几个关键的概念 advisor, advice, pointcut

advice = 切面拦截中插入的行为

pointcut = 切面的切入点

advisor = advice + pointcut

BeanFactoryCacheOperationSourceAdvisor内部的切入点实现类是 CacheOperationSourcePointcut,切入的逻辑如下:

abstract class CacheOperationSourcePointcut extends StaticMethodMatcherPointcut implements Serializable {

    private class CacheOperationSourceClassFilter implements ClassFilter {
private CacheOperationSourceClassFilter() {
} public boolean matches(Class<?> clazz) {
if (CacheManager.class.isAssignableFrom(clazz)) {
return false;
} else {
CacheOperationSource cas = CacheOperationSourcePointcut.this.getCacheOperationSource();
return cas == null || cas.isCandidateClass(clazz);
}
}
}
}

可以看到切入点主要就是判断被切入的方法上是否有注解:CacheOperationSourcePointcut.this.getCacheOperationSource()

ProxyCachingConfiguration 中还有另一个值得关注的类 - AnnotationCacheOperationSource

@Bean
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public CacheOperationSource cacheOperationSource() {
return new AnnotationCacheOperationSource();
}

CacheOperationSource 持有一个 CacheAnnotationParser 列表。 CacheAnnotationParser 只有一个实现类:SpringCacheAnnotationParser

public class SpringCacheAnnotationParser implements CacheAnnotationParser, Serializable {

    private static final Set<Class<? extends Annotation>> CACHE_OPERATION_ANNOTATIONS = new LinkedHashSet<>(8);

    static {
CACHE_OPERATION_ANNOTATIONS.add(Cacheable.class);
CACHE_OPERATION_ANNOTATIONS.add(CacheEvict.class);
CACHE_OPERATION_ANNOTATIONS.add(CachePut.class);
CACHE_OPERATION_ANNOTATIONS.add(Caching.class);
}
......
@Nullable
private Collection<CacheOperation> parseCacheAnnotations(
DefaultCacheConfig cachingConfig, AnnotatedElement ae, boolean localOnly) { Collection<? extends Annotation> anns = (localOnly ?
AnnotatedElementUtils.getAllMergedAnnotations(ae, CACHE_OPERATION_ANNOTATIONS) :
AnnotatedElementUtils.findAllMergedAnnotations(ae, CACHE_OPERATION_ANNOTATIONS));
if (anns.isEmpty()) {
return null;
} final Collection<CacheOperation> ops = new ArrayList<>(1);
anns.stream().filter(ann -> ann instanceof Cacheable).forEach(
ann -> ops.add(parseCacheableAnnotation(ae, cachingConfig, (Cacheable) ann)));
anns.stream().filter(ann -> ann instanceof CacheEvict).forEach(
ann -> ops.add(parseEvictAnnotation(ae, cachingConfig, (CacheEvict) ann)));
anns.stream().filter(ann -> ann instanceof CachePut).forEach(
ann -> ops.add(parsePutAnnotation(ae, cachingConfig, (CachePut) ann)));
anns.stream().filter(ann -> ann instanceof Caching).forEach(
ann -> parseCachingAnnotation(ae, cachingConfig, (Caching) ann, ops));
return ops;
}
......
}

可以看到 Parser 的作用就是将注解对应的方法解析成 CacheOperation 存起来。

看到这里我们开始有点眉目,开始知道去哪里找拦截了什么和操作了什么。接下来继续对 CacheOperationCacheInterceptor 进行深入。

CacheOperation

CacheOperationSource 接口中只有一个方法:

public interface CacheOperationSource {
default boolean isCandidateClass(Class<?> targetClass) {
return true;
} @Nullable
Collection<CacheOperation> getCacheOperations(Method method, @Nullable Class<?> targetClass);
}

该接口中只有一个方法:通过接口和类名获得对应的 CacheOperationCacheOperation 是对缓存操作的抽象封装,它的实现类有 3 个:

三个实现类分别对应着 @CacheEvict@CachePut@Cacheable 注解。

CacheInterceptor

CacheInterceptor 实现的功能就是对 目标方法的实际拦截操作:

public class CacheInterceptor extends CacheAspectSupport implements MethodInterceptor, Serializable {

    @Override
@Nullable
public Object invoke(final MethodInvocation invocation) throws Throwable {
Method method = invocation.getMethod(); CacheOperationInvoker aopAllianceInvoker = () -> {
try {
return invocation.proceed();
}
catch (Throwable ex) {
throw new CacheOperationInvoker.ThrowableWrapper(ex);
}
}; Object target = invocation.getThis();
Assert.state(target != null, "Target must not be null");
try {
return execute(aopAllianceInvoker, target, method, invocation.getArguments());
}
catch (CacheOperationInvoker.ThrowableWrapper th) {
throw th.getOriginal();
}
} }

CacheInterceptor 代码很简洁,采用函数的形式封装了真正要执行的函数逻辑,最终把此函数传交给父类的 execute()去执行。很显然最终执行目标方法的是 invocation.proceed()。我们直接去父类 CacheAspectSupport 看相关代码逻辑:

public abstract class CacheAspectSupport extends AbstractCacheInvoker
implements BeanFactoryAware, InitializingBean, SmartInitializingSingleton { protected final Log logger = LogFactory.getLog(getClass()); private final Map<CacheOperationCacheKey, CacheOperationMetadata> metadataCache = new ConcurrentHashMap<>(1024); private final CacheOperationExpressionEvaluator evaluator = new CacheOperationExpressionEvaluator(); @Nullable
private CacheOperationSource cacheOperationSource; private SingletonSupplier<KeyGenerator> keyGenerator = SingletonSupplier.of(SimpleKeyGenerator::new); @Nullable
private SingletonSupplier<CacheResolver> cacheResolver; ......
}

上面摘录出 的一些属性:

Map<CacheOperationCacheKey, CacheOperationMetadata> metadataCache

这个 Map 缓存了所有被注解修饰的类或者方法对应的基本属性信息。

CacheOperationExpressionEvaluator evaluator

解析一些 condition、key、unless 等可以写 el 表达式的处理器。

SingletonSupplier<KeyGenerator> keyGenerator

key 生成器默认使用的 SimpleKeyGenerator,注意 SingletonSupplier 是 Spring5.1 的新类,实现了接口java.util.function.Supplier ,主要是对 null 值进行容错。

接着看相关的方法:

public abstract class CacheAspectSupport extends AbstractCacheInvoker
implements BeanFactoryAware, InitializingBean, SmartInitializingSingleton { // 这个接口来自于 SmartInitializingSingleton 在实例化完所有单例Bean后调用
//可以看到在这里实例化 CacheManager, CacheManager 我们后面会说作用
@Override
public void afterSingletonsInstantiated() {
if (getCacheResolver() == null) {
// Lazily initialize cache resolver via default cache manager...
Assert.state(this.beanFactory != null, "CacheResolver or BeanFactory must be set on cache aspect");
try {
setCacheManager(this.beanFactory.getBean(CacheManager.class));
}
catch (NoUniqueBeanDefinitionException ex) {
throw new IllegalStateException("No CacheResolver specified, and no unique bean of type " +
"CacheManager found. Mark one as primary or declare a specific CacheManager to use.", ex);
}
catch (NoSuchBeanDefinitionException ex) {
throw new IllegalStateException("No CacheResolver specified, and no bean of type CacheManager found. " +
"Register a CacheManager bean or remove the @EnableCaching annotation from your configuration.", ex);
}
}
this.initialized = true;
} //根据CacheOperation 封装CacheOperationMetadata
protected CacheOperationMetadata getCacheOperationMetadata(
CacheOperation operation, Method method, Class<?> targetClass) { CacheOperationCacheKey cacheKey = new CacheOperationCacheKey(operation, method, targetClass);
CacheOperationMetadata metadata = this.metadataCache.get(cacheKey);
if (metadata == null) {
KeyGenerator operationKeyGenerator;
if (StringUtils.hasText(operation.getKeyGenerator())) {
operationKeyGenerator = getBean(operation.getKeyGenerator(), KeyGenerator.class);
}
else {
operationKeyGenerator = getKeyGenerator();
}
CacheResolver operationCacheResolver;
if (StringUtils.hasText(operation.getCacheResolver())) {
operationCacheResolver = getBean(operation.getCacheResolver(), CacheResolver.class);
}
else if (StringUtils.hasText(operation.getCacheManager())) {
CacheManager cacheManager = getBean(operation.getCacheManager(), CacheManager.class);
operationCacheResolver = new SimpleCacheResolver(cacheManager);
}
else {
operationCacheResolver = getCacheResolver();
Assert.state(operationCacheResolver != null, "No CacheResolver/CacheManager set");
}
metadata = new CacheOperationMetadata(operation, method, targetClass,
operationKeyGenerator, operationCacheResolver);
this.metadataCache.put(cacheKey, metadata);
}
return metadata;
} //真正执行目标方法+ 缓存 的实现
@Nullable
protected Object execute(CacheOperationInvoker invoker, Object target, Method method, Object[] args) {
// Check whether aspect is enabled (to cope with cases where the AJ is pulled in automatically)
// 如果已经初始化过(有CacheManager,CacheResolver),执行这里
if (this.initialized) {
//getTargetClass拿到原始Class 解剖代理
Class<?> targetClass = getTargetClass(target);
//简单的说就是拿到该方法上所有的CacheOperation缓存操作,最终一个一个的执行
CacheOperationSource cacheOperationSource = getCacheOperationSource();
if (cacheOperationSource != null) {
// CacheOperationContexts是非常重要的一个私有内部类
// 注意它是复数!不是CacheOperationContext单数
// 所以它就像持有多个注解上下文一样 一个个执行
Collection<CacheOperation> operations = cacheOperationSource.getCacheOperations(method, targetClass);
if (!CollectionUtils.isEmpty(operations)) {
return execute(invoker, method,
new CacheOperationContexts(operations, method, args, target, targetClass));
}
}
}
// 若还没初始化 直接执行目标方法不执行缓存操作
return invoker.invoke();
} //内部调用的 execute 方法
@Nullable
private Object execute(final CacheOperationInvoker invoker, Method method, CacheOperationContexts contexts) {
// 判断是否同步执行
if (contexts.isSynchronized()) {
CacheOperationContext context = contexts.get(CacheableOperation.class).iterator().next();
if (isConditionPassing(context, CacheOperationExpressionEvaluator.NO_RESULT)) {
Object key = generateKey(context, CacheOperationExpressionEvaluator.NO_RESULT);
Cache cache = context.getCaches().iterator().next();
try {
return wrapCacheValue(method, handleSynchronizedGet(invoker, key, cache));
}
catch (Cache.ValueRetrievalException ex) {
// Directly propagate ThrowableWrapper from the invoker,
// or potentially also an IllegalArgumentException etc.
ReflectionUtils.rethrowRuntimeException(ex.getCause());
}
}
else {
// No caching required, only call the underlying method
return invokeOperation(invoker);
}
} // sync=false的情况,走这里 // Process any early evictions beforeInvocation=true的会在此处最先执行~~~ // 最先处理@CacheEvict注解~~~真正执行的方法请参见:performCacheEvict
// context.getCaches()拿出所有的caches,看看是执行cache.evict(key);方法还是cache.clear();
// 需要注意的的是context.isConditionPassing(result); condition条件此处生效,并且可以使用#result
// context.generateKey(result)也能使用#result
// @CacheEvict没有unless属性
processCacheEvicts(contexts.get(CacheEvictOperation.class), true,
CacheOperationExpressionEvaluator.NO_RESULT); // 执行@Cacheable 看看缓存是否能够命中
Cache.ValueWrapper cacheHit = findCachedItem(contexts.get(CacheableOperation.class)); // 如果缓存没有命中,那就准备一个cachePutRequest
// 因为@Cacheable首次进来肯定命中不了,最终肯定是需要执行一次put操作,这样下次进来就能命中
List<CachePutRequest> cachePutRequests = new ArrayList<>();
if (cacheHit == null) {
collectPutRequests(contexts.get(CacheableOperation.class),
CacheOperationExpressionEvaluator.NO_RESULT, cachePutRequests);
} Object cacheValue;
Object returnValue;
// 如果缓存命中了,并且并且没有@CachePut的话,也就直接返回
if (cacheHit != null && !hasCachePut(contexts)) {
cacheValue = cacheHit.get();
returnValue = wrapCacheValue(method, cacheValue);
}
else {
// 有 cachePut 的情况,先执行目标方法再 put 缓存
returnValue = invokeOperation(invoker);
cacheValue = unwrapReturnValue(returnValue);
} // 封装cacheput对象
collectPutRequests(contexts.get(CachePutOperation.class), cacheValue, cachePutRequests); // 真正统一执行 cacheput 的地方
for (CachePutRequest cachePutRequest : cachePutRequests) {
cachePutRequest.apply(cacheValue);
} // 最后才执行 cacheEvict
processCacheEvicts(contexts.get(CacheEvictOperation.class), false, cacheValue); return returnValue;
} //缓存属性上下文对象
private class CacheOperationContexts { private final MultiValueMap<Class<? extends CacheOperation>, CacheOperationContext> contexts;
//检查注解是否配置同步执行的开关
private final boolean sync; public CacheOperationContexts(Collection<? extends CacheOperation> operations, Method method,
Object[] args, Object target, Class<?> targetClass) { //将当前 method 上所有缓存操作封装到一个map 对象中
this.contexts = new LinkedMultiValueMap<>(operations.size());
for (CacheOperation op : operations) {
this.contexts.add(op.getClass(), getOperationContext(op, method, args, target, targetClass));
}
//同步执行与否取决于这个函数
this.sync = determineSyncFlag(method);
} public Collection<CacheOperationContext> get(Class<? extends CacheOperation> operationClass) {
Collection<CacheOperationContext> result = this.contexts.get(operationClass);
return (result != null ? result : Collections.emptyList());
} public boolean isSynchronized() {
return this.sync;
} //只有@Cacheable有sync属性,所以只需要看CacheableOperation即可
private boolean determineSyncFlag(Method method) {
List<CacheOperationContext> cacheOperationContexts = this.contexts.get(CacheableOperation.class);
if (cacheOperationContexts == null) { // no @Cacheable operation at all
return false;
}
boolean syncEnabled = false;
//只要有一个@Cacheable的sync=true了,那就为true 并且下面还有检查逻辑
for (CacheOperationContext cacheOperationContext : cacheOperationContexts) {
if (((CacheableOperation) cacheOperationContext.getOperation()).isSync()) {
syncEnabled = true;
break;
}
}
// 执行sync=true的检查逻辑
if (syncEnabled) {
// sync=true时候,不能还有其它的缓存操作 也就是说@Cacheable(sync=true)的时候只能单独使用
if (this.contexts.size() > 1) {
throw new IllegalStateException(
"@Cacheable(sync=true) cannot be combined with other cache operations on '" + method + "'");
}
//@Cacheable(sync=true)时,多个@Cacheable也是不允许的
if (cacheOperationContexts.size() > 1) {
throw new IllegalStateException(
"Only one @Cacheable(sync=true) entry is allowed on '" + method + "'");
}
// 拿到唯一的一个@Cacheable
CacheOperationContext cacheOperationContext = cacheOperationContexts.iterator().next();
CacheableOperation operation = (CacheableOperation) cacheOperationContext.getOperation();
//@Cacheable(sync=true)时,cacheName只能使用一个
if (cacheOperationContext.getCaches().size() > 1) {
throw new IllegalStateException(
"@Cacheable(sync=true) only allows a single cache on '" + operation + "'");
}
//sync=true时,unless属性是不支持的,并且是不能写的
if (StringUtils.hasText(operation.getUnless())) {
throw new IllegalStateException(
"@Cacheable(sync=true) does not support unless attribute on '" + operation + "'");
}
return true;
}
return false;
}
} }

execute 相关的代码上面注解写的很清晰,大家可以跟着多看几遍。这里还有一点没有说,最终的 get Cache 或者 put Cache 的操作在哪里呢?刚才在 execute 方法中有一句代码:

Cache.ValueWrapper cacheHit = findCachedItem(contexts.get(CacheableOperation.class));

这里我们看到是先查缓存中是否有该 Cache,进去看有个 findCaches 方法:

@Nullable
private Cache.ValueWrapper findInCaches(CacheOperationContext context, Object key) {
for (Cache cache : context.getCaches()) {
Cache.ValueWrapper wrapper = doGet(cache, key);
if (wrapper != null) {
if (logger.isTraceEnabled()) {
logger.trace("Cache entry for key '" + key + "' found in cache '" + cache.getName() + "'");
}
return wrapper;
}
}
return null;
}

重点关注 doGet 方法,可以看到这个方法是父类 AbstractCacheInvoker 中的,同类里面还有 doPut,doEvict 和 doClear。

小知识

@Cacheable 注解 sync=true 的效果

多线程环境下存在多个操作使用相同的参数同步调用相同的 key,默认情况下缓存不锁定任何资源所以可能导致多次计算。对于这种情况,sync 属性可以将底层锁住,使得只有一个线程进行操作,其他线程堵塞直到更新完缓存返回结果。

小结

至此我们把上面说过的内容总结一下:

BeanFactoryCacheOperationSourceAdvisor

配置 Cache 的切面和拦截实现。拦截的对象即解析出来的 CacheOperation 对象。

每一个 CacheOperation 在执行的时候被封装为 CacheOperationContext 对象(一个方法可能被多个注解修饰),最终通过 CacheResolver 解析出缓存对象 Cache。

CacheOperation

封装了@CachePut@Cacheable@CacheEvict的属性信息,以便于拦截的时候能直接操作此对象来执行逻辑。

解析注解对应的代码为 CacheOperation 的工作是 CacheAnnotationParser 来完成的。

CacheInterceptor

CacheInterceptor 执行真正的方法执行和 Cache 操作。最终调用其父类提供的四个 do 方法处理 Cache。

以上整体过程为 Spring 启动对相关注解所在类或者方法的拦截和注入,从而实现 Cache 逻辑。限于篇幅本篇暂讨论这些内容,下一篇我们来看 Spring 如何实现对多缓存底层方案的支持(本地 Cache,Redis,Guava Cache,Caffeine Cache)。

最新文章

  1. FP error code老是忘记的看这里:只给出最常用的几个。
  2. CSS学习总结(三)
  3. 7 个顶级的 HTML5 Canvas 动画赏析
  4. jquery+ajax(用ajax.dll)实现无刷新分页
  5. POJ 2185 - Milking Grid (二维KMP)
  6. Asp登陆
  7. C语言连接Oracle (转载)
  8. Android游戏之平台接入的一点记录
  9. Memcache简介
  10. 用SqlBulkCopy批量插入数据到SqlServer数据库表中
  11. 定时任务备份数据库与windows批处理
  12. 团队作业——Alpha冲刺之事后诸葛亮
  13. android dialog设置全屏半透明背景色
  14. eclipse配置逆向工程
  15. springmvc访问项目默认先访问后台再返回首页
  16. bash 调试
  17. scripy
  18. WordPress更换主题空白问题
  19. HDOJ 2013 蟠桃记
  20. idea git 把本地项目上传到github上

热门文章

  1. 【PHP数据结构】其它排序:简单选择、桶排序
  2. 支付宝openssl_sign(): supplied key param cannot be coerced into a private key in
  3. 网站URL Rewrite(伪静态)设置方法
  4. php 日期相关的类 DateInterval DateTimeZone DatePeriod
  5. 『Python』matplotlib划分画布的主要函数
  6. 支持remote write和exemplar的prometheus服务
  7. 简易集成websocket技术实现消息推送
  8. 前端VUE基于gitlab的CI_CD
  9. openlayer 4326与3857坐标互转之Java版
  10. 在开源项目或项目中使用git建立fork仓库