Contents

细说spring-boot-x

特别说明:通常,我们会将SpringBoot写作SpringBoot,而此处写作spring-boot是因项目的确就叫做spring-boot-x。以下简称项目。

项目地址:https://github.com/OpeningO/spring-boot-x

源起

娱坛有云一人一首成名曲,虽互联网技术与娱之文艺不同,但个人仍以为技术与艺类同,皆为高雅之事。故成此项目,是以个人有利器,便于而后开疆扩土。

总览

https://img.shields.io/maven-central/v/org.openingo.boot/spring-boot-x.svg

更新说明

  • 支持分布式锁、幂等处理 [2021.8.16更新] since v5.1.0
  • 重构整个包,发布4.0.0.RELEASE版本 [ 2021.6.30更新 ]
  • 优化分布式id生成器,隔离默认的redis配置及Zookeeper配置,让id生成与业务完全分离 [ 2021.6.30更新 ] since v3.2.0.RELEASE

特性清单

  • 手动事务管理 [2021.6.29更新]

  • 分布式id生成器gedid,DidLoader [ 2021.6.25更新 ]

  • Safety工具 [ 2021.6.25更新 ] [merge to jdkits since v5.1.0]

  • 请求日志,包括请求源、请求目标、请求参数、处理时间、错误异常等信息;

  • 请求响应参数的自动装配(映射);

  • 跨域的配置;

  • 嵌入SpringBoot的异常处理机制,可以将原来的错误信息中插入其他信息、或将其解析或转换为其他信息;

  • SpringBootstarter动态装配或在yml中配置相关特性;

  • 简化的Redis操作;

  • 提炼ElasticsearchHighlevelClient常用操作;

  • feign的请求头参数的处理:合并上下游的请求头参数,并发场景的数据处理策略;

  • 基于DruidHikari的动态路由RoutingDataSource

  • SpringBoot应用的配置信息的自动拷贝;

  • 更多继续完善… …

  • 引入依赖

    1
    2
    3
    4
    5
    
    <dependency>
        <groupId>org.openingo.boot</groupId>
        <artifactId>spring-boot-x</artifactId>
        <version>${spring-boot-x.version}</version>
    </dependency>
    

细说特性

分布式锁

基于redis实现

1
2
3
4
5
6
7
8
final Lock a = DistributedLock.newLock("a");
try {
  final boolean b = a.tryLock();
  Assert.isTrue(b, "get lock error");
  return "ok => " + b;
} finally {
  a.unlock();
}

幂等处理

提供了两个注解

  • org.openingo.spring.boot.extension.idempotent.annotation.Idempotent 用于指定某方法是否需要幂等处理;

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    
    @Documented
    @Target({ElementType.METHOD})
    @Retention(RetentionPolicy.RUNTIME)
    public @interface Idempotent {
    
    	/**
       * the key spring el
       */
    	String keyEl() default "";
    
    	/**
       * expire minutes
       */
    	long expireMinutes() default 5L;
    }
    
  • org.openingo.spring.boot.extension.idempotent.annotation.IdempotentKey 用于从方法参数中指定幂等标识;

    1
    2
    3
    4
    5
    
    @Documented
    @Target({ElementType.PARAMETER, ElementType.FIELD})
    @Retention(RetentionPolicy.RUNTIME)
    public @interface IdempotentKey {
    }
    
  • 基于redis实现

1
2
3
4
5
@Idempotent(keyEl = "#userEntity.id", expireMinutes = 1)
@PostMapping("/idempotent")
public String hello(@RequestBody UserEntity userEntity) {
  ...
}

手动事务管理更新 [2021.8.16] since 5.1.0

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Autowired
private TransactionTemplateX transactionTemplateX;

private UserEntity saveUser(UserEntity user) {
  return userRepo.save(user);
}

private void saveWithEx(UserEntity user) {
  saveUser(user);
  throw new RuntimeException("error");
}

public void saveEx(UserEntity user) {
  transactionTemplateX.txRun(() -> this.saveWithEx(user));
}

public void saveExe(UserEntity user) {
  UserEntity ret = transactionTemplateX.txCall(() ->  {
    return this.saveUser(user);
  });
  ret ...
}

分布式Id生成器 [ 2021.6.25更新 ] since 3.0.0.RELEAE

从这里看分布式id使用说明

Safety 工具 [ 2021.6.25更新 ] since 3.0.0.RELEAE

已合并到jdkits,since v5.1.0

封装了ReentrantLock,具体查看源码org.openingo.spring.safety.Safety

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
public class UseSafety {
  
  private final Safety safety = new Safety();
	
  public void action() {
    this.safety.safetyRun(() -> ...);
  }
  
  public <T> T call() {
    return this.safety.safetyCall(() -> ...);
  }
}

请求日志

先看看效果

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
****************************************************************
:: SpringApplicationX :: for current request report information 
****************************************************************
Client IP  : 172.15.11.240 
Request Time  : 2021-01-09T10:03:43.202 
Controller  : orgg.openingo.x.controller.EsRestClientXController.(EsRestClientXController.java:1)
URI  : http://localhost:18080/esx/recommend?q=%E5%8C%97 
Handler(Action)  : recommends
Method  : GET
Processing Time  : 0.0s
Header(s)  : [user-agent:"PostmanRuntime/7.26.5", accept:"*/*", cache-control:"no-cache", postman-token:"102da4af-27a4-4d04-bdcd-f8738c1dfc25", host:"localhost:18080", accept-encoding:"gzip, deflate, br", connection:"keep-alive"]
Body  : "北"
UrlQuery  : q=%E5%8C%97
Parameter(s)  : q=北, 
Exception  : [qicz] ElasticsearchStatusException[Elasticsearch exception [type=index_not_found_exception, reason=no such index [qicz]]]
----------------------------------------------------------------

以上请求是从Postman发起的一个请求,对此请求的各类参数都进行了一一罗列:

  • 源(Client IP);
  • 发起时间(Request Time);
  • 请求的handler(对应的Controller、Action);
  • 请求方式(Method);
  • 处理时间(Processing Time);
  • 请求头(Header);
  • 请求体(Body);
  • 参数(Parameters);
  • 此次请求出现的异常(如没有异常此项自然就没有了)。

如何使用?

  • 引入项目依赖及spring-boot-starter-aop,在启动类上加入@EnableExtension即可;

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    
    /**
     * App
     *
     * @author Qicz
     */
    @SpringBootApplication
    @EnableExtension
    public class App {
    
        public static void main(String[] args) throws InterruptedException {
            SpringApplicationX.run(App.class, args);
            SpringApplicationX.applicationInfo();
        }
    }
    
  • 可配置?

    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    openingo:
      http:
        request:
          cors:
            allowed-header: "*"
            enable: true
            allowed-all: true
          log:
            enable: true
    

    如上即可对log及cors进行配置。

请求响应参数的自动装配(映射)

通常情况下,我们会将后端处理的结果按照固定的格式包装之后,再返回给前端,所以在Controller需要对处理结果进行统一的包装处理,于是有了下面的这种代码:

1
2
3
4
5
@GetMapping("/resp")
public RespData resp() {
    Object data = service....
    return RespData.success(data);
}

这种统一的包装处理,既然是统一行为,那么必然是可以进行自动的统一的处理方式。于是有了这个请求响应参数的自动装配(映射)。先看看使用了这个特性之后,我们的代码变成了什么样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
 * AutoRespController
 *
 * @author Qicz
 */
@RestController
@RequestMapping("/auto")
@AutoMappingRespResult
public class AutoRespController {

    @GetMapping("/int")
    public Integer toInt() {
        return 123;
    }

    @GetMapping("/void")
    public void toVoid() {

    }

    @GetMapping("/exception")
    public void toException() {
        throw new ServiceException("异常了");
    }
}

可以看到,我们添加了一个@AutoMappingRespResult注解,在方法体,并无什么特别的了。

如何使用?

  • 引入项目依赖,在启动类上加入@EnableExtension
  • 在需要包装的Controller上加入@AutoMappingRespResult即可。

特别说明

使用@AutoMappingRespResult后都将使用org.openingo.jdkits.http.RespData进行数据包装。而org.openingo.jdkits.http.RespData是可以对返回的数据进行动态配置的。默认情况下,返回scsmdata,如果需要可以使用org.openingo.jdkits.http.RespData.Config修改它们为其他任意值。

嵌入SpringBoot的异常处理机制

SpringBoot的错误处理是借助org.springframework.boot.web.servlet.error.DefaultErrorAttributes进行的,常看到的信息如下:

1
2
3
4
5
6
7
{
    "timestamp": "2020-07-13T05:49:06.071+0000",
    "status": 500,
    "error": "Internal Server Error",
    "message": "testing exception",
    "path": "/ex"
}

进行项目的嵌入异常处理后,可以得到以下的信息:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
{
    "timestamp": "2020-07-13T05:49:06.071+0000",
    "status": 500,
    "error": "Internal Server Error",
    "exception": "org.openingo.spring.exception.ServiceException",
    "message": "testing exception",
    "path": "/ex",
    "handler": "public java.util.Map org.openingo.x.controller.UserController.ex()",
    "openingo.error": {
        "ex": "org.openingo.spring.exception.ServiceException: testing exception",
        "em": "testing exception",
        "error": "Internal Server Error",
        "ec": "ERROR_CODE"
    }
}

如何使用?

  • 引入项目依赖,在启动类上加入@EnableExtension,即可使用;

  • 可配置?

    1
    2
    3
    4
    
    openingo:
      http:
          error:
            enable: true
    

    如上即可对error进行配置。

扩展异常处理

默认情况下项目使用org.openingo.spring.http.request.error.DefaultServiceErrorAttributes提供异常嵌入处理,可以扩展其对异常进行拓展处理,如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
/**
 * BusinessErrorAttributes
 *
 * @author Qicz
 */
@Component
public class BusinessErrorAttributes extends DefaultServiceErrorAttributes {

    /**
     * Decorate exception error code, custom for your business logic.
     * <code>
     * <pre>
     * public Object decorateExceptionCode(Exception exception) {
     *    if (exception instanceof IndexOutOfBoundsException) {
     *      return 123;
     *    }
     *   return super.decorateExceptionCode(exception);
     * }
     * </pre>
     * </code>
     *
     * @param exception the exception that got thrown during handler execution
     */
    @Override
    public Object decorateExceptionCode(Exception exception) {
        if (exception instanceof IndexOutOfBoundsException) {
            return 123;
        }
        if (exception instanceof JsonProcessingException) {
            return 345;
        }
        if (exception instanceof MethodArgumentNotValidException
                || exception instanceof ConstraintViolationException
                || exception instanceof BindException
                || exception instanceof HttpMessageNotReadableException
                || exception instanceof MissingServletRequestPartException
                || exception instanceof MissingServletRequestParameterException
                || exception instanceof MultipartException) {
            return 1234;
        }
        return super.decorateExceptionCode(exception);
    }

    /**
     * Decorate error attributes, add extension attributes etc.
     * the {@code errorAttributes} that has exception, handler, message,
     * error, timestamp, status, path params.
     *
     * @param errorAttributes        error attributes
     * @param serviceErrorAttributes service error attributes
     */
    @Override
    public void decorateErrorAttributes(Map<String, Object> errorAttributes, Map<String, Object> serviceErrorAttributes) {
        super.decorateErrorAttributes(errorAttributes, serviceErrorAttributes);
        //serviceErrorAttributes.putAll(errorAttributes);
    }
}

简化的Redis操作

默认情况下对redis的操作需要使用redisTemplate提供的繁琐操作,各种opsFor...,使用简化方式之后业务代码中会将此类统统干掉,而且将大部分的操作都按照redis官方的command的方式进行了对齐,更容易理解和使用它们。先来看看使用的效果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@GetMapping("/save")
public String save() {
    try {
        KeyNamingKit.set("openingo");
        stringKeyRedisTemplateX.set("name", "Qicz");
        return "ok";
    } finally {
        KeyNamingKit.remove();
    }
}

在业务中,通常我们会对写入redis的数据进行region的处理,也就是从key的角度对数据进行划分,以上看到的KeyNamingKit就是进行了这样的操作。当然了,仅仅使用KeyNamingKit是不能完成这个功能的,还需要定义一个keyNamingPolicy,当然项目已提供了一个默认的keyNamingPolicy

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
 * DefaultKeyNamingPolicy
 *
 * @author Qicz
 */
public class DefaultKeyNamingPolicy implements IKeyNamingPolicy {

    /**
     * if {@code KeyNamingKit.getNaming()} is "null" return key,
     * otherwise return {@code KeyNamingKit.getNaming()}+{@code KeyNamingKit.NAMING_SEPARATOR}+key
     * @param key
     * @return wrapper key
     */
    @Override
    public String getKeyName(String key) {
        String naming = KeyNamingKit.get();
        if (ValidateKit.isNull(naming)) {
            return key;
        }
        if (!naming.endsWith(KeyNamingKit.NAMING_SEPARATOR)) {
            naming = naming + KeyNamingKit.NAMING_SEPARATOR;
        }
        return naming + key;
    }
}

如何使用?

  • 引入项目依赖及spring-boot-starter-data-redis,在启动类上加入@EnableExtension,即可使用;

  • 可配置?

    1
    2
    3
    
    openingo:
      redis:
        enable: true
    

自定义keyNamingPolicy

按照如下即可

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
/**
 * KeyNamingPolicy
 *
 * @author Qicz
 */
@Data
public class KeyNamingPolicy implements IKeyNamingPolicy {

    @Override
    public String getKeyName(String key) {
        String s = KeyNamingKit.get();
        if (ValidateKit.isNotNull(s)) {
            return s + ":qicz:" + key;
        }
        return "qicz:" + key;
    }
}

在操作前使用KeyNamingKit.set将当前操作的region写入,如此处的openingo

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@GetMapping("/save")
public String save() {
    try {
        KeyNamingKit.set("openingo");
        stringKeyRedisTemplateX.set("name", "Qicz");
        return "ok";
    } finally {
        KeyNamingKit.remove();
    }
}

Elasticsearch之HighlevelClient常用操作

常用操作即

  • saveOrUpdate类
  • delete类
  • search类:分页处理,随机推荐等等
  • 同步处理、异步处理

如下:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
/**
 * EsRestClientXController
 *
 * @author Qicz
 */
@RestController
@RequestMapping("/esx")
public class EsRestClientXController {

    @Autowired
    RestHighLevelClientX restHighLevelClientX;

    @SneakyThrows
    @GetMapping("/recommend")
    public List<Map> recommends(String q) {
        HighlightBuilder highlightBuilder = new HighlightBuilder();
        // 如果该属性中有多个关键字 则都高亮
        highlightBuilder.requireFieldMatch(true);
        highlightBuilder.field("name");
        highlightBuilder.field("addr");
        highlightBuilder.preTags("<span style='color:red'>");
        highlightBuilder.postTags("</span>");

        BoolQueryBuilder boolQueryBuilder = new BoolQueryBuilder();

        q = "*" + q + "*";
        boolQueryBuilder.must(QueryBuilders.wildcardQuery("name", q));
        boolQueryBuilder.should(QueryBuilders.wildcardQuery("addr", q));
        return this.restHighLevelClientX.randomRecommend(Map.class, "qicz", 3, boolQueryBuilder, highlightBuilder);
    }

    @GetMapping("/add")
    public String addIndex() {
        try {
            MappingsProperties mappingsProperties = MappingsProperties.me();
            mappingsProperties.add(MappingsProperty.me().name("id").type("long"));
            mappingsProperties.add(MappingsProperty.me().name("name").textType().analyzer("ik_smart"));
            restHighLevelClientX.createIndex("aaabc", null, mappingsProperties);
        } catch (IOException e) {
            e.printStackTrace();
        }
        return "ok";
    }

    @PostMapping("/put")
    public String putDoc(@RequestBody Map user) {
        boolean ret = false;
        IndexRequest indexRequest = new IndexRequest("qicz");
        indexRequest.id(user.get("id").toString());
        try {
            indexRequest.source(JacksonKit.toJson(user), XContentType.JSON);
            ret = this.restHighLevelClientX.saveOrUpdate(indexRequest);
        } catch (IOException e) {
            e.printStackTrace();
        }

        return ret ? indexRequest.id() : null;
    }

    @GetMapping("/user/{id}")
    public Map findOneById(@PathVariable("id") Integer id) {
        Map<String, Object> ret = null;
        try {
            ret = this.restHighLevelClientX.findAsMapById("qicz", id.toString());
        } catch (IOException e) {
            e.printStackTrace();
        }
        return ret;
    }

    @DeleteMapping("/user/{id}")
    public boolean deleteById(@PathVariable("id") Integer id) {
        boolean ret = false;
        try {
            ret = this.restHighLevelClientX.deleteByDocId("qicz", id.toString());
        } catch (IOException e) {
            e.printStackTrace();
        }
        return ret;
    }

    @GetMapping("/user/find")
    public Page<?> find(String q) {
        SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();

        BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
        //q = "q+-&&||!(){}[]^\"~*?:\\";
        q = QueryParser.escape(q);
        boolQueryBuilder.should(QueryBuilders.queryStringQuery(q));
        sourceBuilder.query(boolQueryBuilder);

        HighlightBuilder highlightBuilder = new HighlightBuilder();
        highlightBuilder.requireFieldMatch(true); //如果该属性中有多个关键字 则都高亮
        highlightBuilder.field("docType");
        highlightBuilder.field("docTitle");
        highlightBuilder.field("docContent");
        highlightBuilder.preTags("<span style='color:red'>");
        highlightBuilder.postTags("</span>");

        sourceBuilder.from(1);
        sourceBuilder.size(10);
        sourceBuilder.highlighter(highlightBuilder);
        try {
            return this.restHighLevelClientX.searchForPage(Object.class, "qicz", sourceBuilder, 1, 10);
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }
}

如何使用?

  • 引入项目依赖及spring-boot-starter-data-elasticsearchelasticsearch-rest-high-level-client,在启动类上加入@EnableExtension,即可使用。

基于Druid和Hikari的动态路由RoutingDataSource

spring官方提供了org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource用于支持动态路由,但其对数据源本身的处理很少,故基于此对其从根上进行了扩展,于是有了org.openingo.spring.datasource.routing.RoutingDataSource,可以便捷的加入移除关闭数据源,且支持DruidHikari

如何使用?

  • 引入项目依赖(使用druid时加入其依赖),在启动类上加入@EnableExtension,即可使用;

  • 示例:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    
    /**
     * DataSourceService
     *
     * @author Qicz
     */
    @Service
    @Slf4j
    public class DataSourceService implements IDataSourceService {
    
        @Autowired
        RoutingDataSource routingDataSource;
    
        @Autowired
        DruidDataSource dataSource;
    
        @Override
        public void switchDataSource(String name) throws SQLException {
            try {
                System.out.println("======before======"+name);
                routingDataSource.getConnection();
                System.out.println(routingDataSource.getCurrentUsingDataSourceProvider().hashCode());
                RoutingDataSourceHolder.setCurrentUsingDataSourceKey(name);
                routingDataSource.getConnection();
                System.out.println("======after======");
                System.out.println(routingDataSource.getCurrentUsingDataSourceProvider().hashCode());
            } finally {
                RoutingDataSourceHolder.clearCurrentUsingDataSourceKey();
            }
        }
    
        @Override
        public void add(String name) {
            //routingDataSource.setAutoCloseSameKeyDataSource(false);
            DruidDataSourceProvider druidDataSourceProvider = new DruidDataSourceProvider(dataSource.getUrl(), dataSource.getUsername(), dataSource.getPassword());
            druidDataSourceProvider.startProviding();
            routingDataSource.addDataSource(name, druidDataSourceProvider);
        }
    }
    

SpringBoot应用的配置信息的自动拷贝

通常情况下,我们的SpringBoot应用都会有各种的配置文件或yamlproperties,而在项目部署时有需要将他们进行外部化,便于动态配置,项目的此特性就基于此而实现。

如何使用?

  • 引入项目依赖;

  • 在启动类中使用SpringApplicationX,如下:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    
    /**
     * App
     *
     * @author Qicz
     */
    @SpringBootApplication
    public class App {
    
        public static void main(String[] args) throws InterruptedException {
            SpringApplicationX.run(App.class, args);
            SpringApplicationX.applicationInfo();
        }
    }
    

若仅拷贝资源,使用java -jar xx.jar ccp 即可。

总结

以上对项目的现有的核心功能进行了简单说明,具体可查看项目源码或待后续在针对性的详细说明实现思路。

此项目在设计之初,经过了多番反复的调整,才有了现在的模样,在打磨过程中,对SpringBoot又进一步的加深的了解,提升了许多。接下来,会继续将常见的功能不断的完善和加入。