ws-springx

忆此些年来作为Javaer的coding经历,也遇到不少通用性的功能实现,闲来作此库以沉淀之,就叫做ws-springx吧

Github: https://github.com/WindMo/ws-springx

AOP

MethodPeeper方法观测器

在编写业务aop组件时,我们通常以@Aspect + 切点表达式的方式去开发。实际开发中会存在以下问题:

  • 被增强的类或方法往往在少数,但需要特别单独的写一套基于Aspect的切面组件
  • 需要开发者足够细心,如果切点表达式没写好,可能在这个版本能用,在后续版本中新增了其它类也满足了切点,就会出现意外的增强导致bug
  • 开发者编写通知(增强)逻辑时,容易忽略异常或者不正确的处理异常,导致“吞异常”的情况出现。现象如目标方法正常执行返回,但是上层调用却得不到正确的结果。或者目标方法执行时抛出了异常,但是切面吞掉了异常,返回了一个自以为的结果,上层仍接收到了返回,但是这和方法预期是不符的,即目标方法没有满足原先的预期约束和返回。
  • 增强的逻辑对目标方法的执行过程来说,往往更像是一种观察、窥探,而不是修改其原先的入参、执行逻辑和结果

使用MethodPeeper能更简便的编写aop组件逻辑,而不用担心上面的问题,

例:

使用@EnableMethodPeek开启MethodPeeper支持

1
2
3
4
5
6
7
@EnableMethodPeek
@SpringBootApplication
public class SpringxApp {
public static void main(String[] args) {
SpringApplication.run(SpringxApp.class, args);
}
}

在bean的public方法上使用@ExposurePoint注解来暴露被观察方法,我们称之为暴露点

1
2
3
4
5
6
7
8
9
@Slf4j
@Service
public class NormalService {
@ExposurePoint("consumerString")
public String consumerString(String str) {
log.info("consumerString, str : {}", str);
return "consumerString: " + str;
}
}

使用@Peeper注解声明观察者并指明观察的bean的class,使用@PeekPoint注解标记观测点,并通过返回ReturnValuePeeper对象来观测目标方法的返回值或异常。

1
2
3
4
5
6
7
8
9
10
11
@Slf4j
@Peeper(value = NormalService.class)
@Component
public class NormalServiceAdvice {
@PeekPoint("consumerString")
public ReturnValuePeeper<String> consumerString(String str) {

log.info("method consumerString str: {} - Args Advice", str);
return (v, ex) -> log.info("return-consumerString: {}, ex: {}", v, ex);
}
}

ReturnValuePeeper

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 返回值观测者,实现此接口用于观测{@linkplain ws.spring.aop.annotation.ExposurePoint 暴露点}方法的返回值
*
* @author WindShadow
* @see ws.spring.aop.annotation.ExposurePoint
* @see MethodPeeper
*/

@FunctionalInterface
public interface ReturnValuePeeper<T> {

/**
* 观测暴露点方法的返回值
*
* @param returnValue 目标方法执行成功的返回值(void 的返回值为null)
* @param ex 目标方法执行期间抛出的异常,无异常时其值为 null
*/
void peekReturnValue(@Nullable T returnValue, @Nullable Exception ex);
}

若调用NormalService.consumerString,参数为”abc”,其日志输出将如下:

1
2
3
method consumerString str: abc - Args Advice
consumerString, str : abc
return-consumerString: consumerString: abc, ex: null

此外,你可以使用以下方式编写观测点方法。结合Order注解可以编排观测点方法的执行顺序。指定asyn 属性@PeekPoint(asyn = true)来表明对于返回值的观测是异步的,这样不会使暴露点方法执行的太久。

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
@Slf4j
@Peeper(value = NormalService.class)
@Component
public class NormalServiceAdvice {

// case1: 声明参数,声明ReturnValuePeeper返回值,既观测参数=且异步观察执行结果
@PeekPoint(value = "consumerString", asyn = true)
public ReturnValuePeeper<String> consumerStringAync(String str) {

log.info("method consumerString str: {} - Args Advice", str);
return (v, ex) -> log.info("return-consumerString: {}, ex: {}", v, ex);
}

// case2: 不声明参数,声明ReturnValuePeeper返回值,既只观察执行结果
@PeekPoint("consumerString")
public ReturnValuePeeper<String> consumerString() {

log.info("method consumerString - Args Advice");
return (v, ex) -> log.info("return-consumerString: {}, ex: {}", v, ex);
}

// case3: 声明参数,不声明ReturnValuePeeper返回值,既只观察参数
@PeekPoint("consumerString")
public void consumerStringVoid(String str) {

log.info("method consumerStringVoid str: {} - Args Advice", str);
}

// case4: 不声明参数,不声明ReturnValuePeeper返回值,既不观测参数也不观察执行结果
@Order(1)
@PeekPoint("consumerString")
public void consumerStringVoid() {

log.info("method consumerStringVoid - Advice");
}
}

一种常见的声明方式,PeekPoint观测点方法与ExposurePoint暴露点方法的方法名、参数类型和参数顺序一致时,无需额外声明它们的名称,这将由默认的取名逻辑确定。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public interface SomeService {

@ExposurePoint
String proccess(String arg);
}

@Peeper(value = SomeService.class)
@Service
public class SomeServiceAdvice {

@PeekPoint
public ReturnValuePeeper<String> proccess(String arg) {
// do peek
}
}

EnumValue

设计一个枚举类时,我会遇到枚举内部属性与枚举值本身存在一一对应的情况,如

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
public enum OperateSys {
Windows("code_100"),
Linux("code_200");

private final String code;

OperateSys(String code) {
this.code = code;
}

public String getCode() {
return code;
}

private static final Map<String, OperateSys> MAPPING = new HashMap<>();

static {

for (OperateSys os : OperateSys.values()) {
MAPPING.put(os.getCode(), os);
}
}

public static OperateSys resolve(String code) {
return MAPPING.get(code);
}
}

为了更方便通过code获取OperateSys枚举,通常需要对外提供一个resolve方法。这样通过枚举方法getCode与静态方法resolve,我们可以在code与OperateSys之间进行来回转换。

随着枚举越来越多,这种固定式写法也会不停地复制粘贴。

使用EnumValue接口与EnumValues工具类可以解决这个问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 枚举类型实现此接口提供一个value值反映枚举本身,
* 你可以通过{@linkplain EnumValues}来操作实现了该接口的枚举
*
* @author WindShadow
* @see EnumValues
*/

public interface EnumValue<T> {

/**
* 获取枚举的对应的其它类型的值,该值不能为null,多次调用必须返回同一个值,即满足{@linkplain Object#hashCode() hashCode}和{@linkplain Object#equals(Object) equals比较}的结果相同
*
* @return the value, not null
*/
@NonNull
T getValue();
}

OperateSys枚举改造如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public enum OperateSys implements EnumValue<String> {
Windows("code_100"),
Linux("code_200");

private final String code;

OperateSys(String code) {
this.code = code;
}

public String getCode() {
return code;
}

@Override
public String getValue() {
return getCode();
}
}

使用EnumValues操作枚举

1
2
3
4
5
OperateSys os = EnumValues.getEnum(OperateSys.class, "code_100");
List<OperateSys> enums = EnumValues.getEnums(OperateSys.class, "code_100", "code_200");
Map<String, OperateSys> valueMap = EnumValues.getValueMap(OperateSys.class);
Set<String> valueSet = EnumValues.getValueSet(OperateSys.class);
boolean b = EnumValues.complyWith(OperateSys.class, "code_300"); // false

注册spring转换器

1
2
3
4
5
DefaultConversionService service = new DefaultConversionService();
ConverterRegistry registry = service;
EnumValues.registerEnumValueConverter(OperateSys.class, registry);
service.canConvert(OperateSys.class, String.class); // true
service.canConvert(String.class, OperateSys.class); // true

Log

Logback日志扩展

log4j中可以通过file-permissions配置日志文件拥有的系统权限

1
2
3
4
5
6
<appender name="RollingFile" class="org.apache.log4j.RollingFileAppender">
<file value="logs/app.log">
<param name="file-permissions" value="600" />
</file>
<!-- 其他配置 -->
</appender>

而logback中没有此类配置,因此扩展Appender和RollingPolicy以实现

如下使用AccessControlRollingFileAppender、AccessControlBasedRollingPolicy,在filePermissions结点中你可以使用数字或表达式配置日志文件权限

1
2
3
4
5
6
7
8
9
10
11
12
13
<appender name="ROOT_WS" class="ws.spring.log.AccessControlRollingFileAppender">
<file>${LOG_HOME}/xxx.log</file>
<encoder>
<pattern>${FILE_LOG_PATTERN}</pattern>
<charset>${LOG_CHARSET}</charset>
</encoder>
<rollingPolicy class="ws.spring.log.AccessControlBasedRollingPolicy">
<!-- 扩展的属性,设置归档日志文件权限 -->
<filePermissions>600</filePermissions>
</rollingPolicy>
<!-- 扩展的属性,设置日志文件权限 -->
<filePermissions>rw-------</filePermissions>
</appender>

Text

Escaper文本转义器

文本转义器,通过Escaper与Escape注解的组合,你可以自定义实现自己的转义器,通常用于在字符串类型的方法参数上进行转义。这可以使你无需在方法实现上额外处理参数

使用@EnableEscape开启Escaper支持

1
2
3
4
5
6
7
8
@EnableEscape
@SpringBootApplication
public class SpringxApp {

public static void main(String[] args) {
SpringApplication.run(SpringxApp.class, args);
}
}

例:实现SqlEscape注解,来对参数进行转义,避免恶意参数造成sql注入

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
@FunctionalInterface
public interface Escaper {

String escape(String value);
}

public class SqlEscaper implements Escaper {

public static String escapeSqlValue(String value) {

if (StringUtils.hasText(value)) {

StringBuilder sb = new StringBuilder();
for (char c : value.toCharArray()) {

if (c == '_' || c == '%' || c == '\\') {
sb.append('\\');
}
sb.append(c);
}
return sb.toString();
}
return value;
}

@Override
public String escape(String value) {
return escapeSqlValue(value);
}
}

@Target({ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Escape(SqlEscaper.class) // 指明使用的转义器
public @interface SqlEscape {
}

// 如在mapper中使用
public interface SomeMapper {
List<String> selectXxByKeywords(@SqlEscape String keywords); // "abc_" -> "abc\_"
}

Validation扩展

扩展了常用的Validation校验注解,部分如下:

  • @IPv4

    约束字符串为IPv4地址格式

  • @MAC

    约束字符串为MAC地址格式

  • @UUID

    约束字符串为UUID格式

  • @StringRange

    约束字符串的可选范围

    1
    public void aMethod(@StringRange({"aaa","bbb"}) String str) // str值只能为"aaa"或"bbb"
  • @EnumRange

    约束枚举值的可选范围

    1
    2
    3
    4
    5
    6
    public enum MyEnum {
    // 金木水火土
    JIN,MU,SHUI,HUO,TU
    }

    public void aMethod(@EnumRange(enumType = MyEnum.class, enums = {"JIN","MU","SHUI"}) MyEnum param)

树节点工具

对于实现前向结点接口的pojo,可以通过Nodes工具类来为其构建对应的树形数据结构

1
2
3
4
5
6
7
public interface ForwardNode<K> {

K fetchKey(); // 获取当前结点的key

@Nullable
K fetchParentKey(); // 获取父结点的key
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 这是一个常见的部门表对应的结构体,每个部门的ID是唯一的
@NoArgsConstructor
@Data
@ToString
public static class Dept implements ForwardNode<String> {
private String deptId;
private String deptName;
private String parentDeptId;
@Override
public String fetchKey() {
return getDeptId();
} @Override
public String fetchParentKey() {
return getParentDeptId();
}
}

通过Nodes工具类将一个部门集合转换成一颗部门树

1
2
3
4
5
6
7
8
9
List<Dept> depts = Arrays.asList(
new Dept("H1", "name1", null),
new Dept("H2-1", "name2-1", "H1"),
new Dept("H2-2", "name2-2", "H1"),
new Dept("H3", "name2-2", "H2-1"),
new Dept("H4", "name2-2", "H2-2"));

TreeNode<Dept> node = Nodes.transToSingleTree(depts);
System.out.println(JacksonUtils.toJson(node));

输出将如下

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
{
"identity": {
"deptId": "H1",
"depName": "name1"
},
"children": [
{
"identity": {
"deptId": "H2-1",
"depName": "name2-1",
"parentDeptId": "H1"
},
"children": [
{
"identity": {
"deptId": "H3",
"depName": "name2-2",
"parentDeptId": "H2-1"
}
}
]
},
{
"identity": {
"deptId": "H2-2",
"depName": "name2-2",
"parentDeptId": "H1"
},
"children": [
{
"identity": {
"deptId": "H4",
"depName": "name2-2",
"parentDeptId": "H2-2"
}
}
]
}
]
}

需要注意的是上述操作可行的前提是根部门只有一个,否则你应该使用Nodes.transformToTrees(depts)方法来构建多棵部门树。

根节点的确定:当某个节点无法在给定的集合中找到对应的父节点时,其成为树的根节点。

若pojo类未实现ForwardNode接口,可使用adapterTransToSingleTree方法,提供对应的Function接口来提取对应的key来进行树节点的构建

1
2
// 例
TreeNode<Dept> node = Nodes.adapterTransToSingleTree(depts, Dept::getDeptId, Dept::getParentDeptId);

更多操作见Nodes具体实现

Web

FormModel表单绑定

FormModel通常解决表单提交带有不同前缀相同后缀的参数绑定到java对象的痛点
假设有如下表单提交:

参数 参数值
user.name WindShadow
user.desc 一名软件工程师
city.name 北京
city.desc 中国首都

你可以使用 FormModel 注解进行如下参数映射,其中 @FormModel User user 的声明等价于 @FormModel(prefix = "user", separator = ".", required = true) User user

1
2
3
4
@GetMapping("/from-model")
public RestResponse<String> formModelBind(@FormModel User user, @FormModel City city) {
// ...
}

不仅如此,你可以使用@Validated 参数进行校验,类似其它spring的参数绑定机制,同样支持Optional对象的参数声明,即以下参数声明都是有效的

1
2
3
4
5
6
7
8
@GetMapping("/from-model")
public RestResponse<String> formModelBind(@Validated @FormModel User user) { /* */ }

@GetMapping("/from-model")
public RestResponse<String> formModelBind(@FormModel Optional<User> user) { /* */ }

@GetMapping("/from-model")
public RestResponse<String> formModelBind(@Validated @FormModel Optional<User> user) { /* */ }

注:FormModel 不支持基础类型与其包装类的绑定以及String类型,因为使用@RequestParam注解就可以达到相同的效果

SingleEntity单属性实体

用于表示单一结构的对象,支持动态json key,常用于web环境中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public interface SingleBean<T> {
T getValue();
}

@JsonDeserialize(as = JacksonSingleEntity.class)
public interface SingleEntity<T> implements SingleBean<T> {
// ...
static <T> SingleEntity<T> of(String key, T value) {

JacksonSingleEntity<T> entity = new JacksonSingleEntity<>();
entity.putJsonValue(key, value);
return entity;
}
}

在设计接口响应数据结构时,如果遇到仅需要响应一个仅有一个键值对的简单json结构时,如{"name": "tom"}{"size": 100}{"flag": true}等,往往key名称不同,为了避免定义过多的相似结构的pojo类,可以使用SingleEntity来解决此问题。

同理,也可用于接收request body中的数据,且支持@Validated + @Valid方式的方法级别校验

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Validated
@RestController
@RequestMapping("/simple-body")
public class SimpleBodyEntityController {

@PostMapping("/request")
public RestResponse<String> simpleRequestBody(@Valid @RequestBody SingleEntity<@NotBlank String> reqBody) {
System.out.println(reqBody.getValue());
//...
}

@GetMapping("/response")
public RestResponse<SingleEntity<String>> simpleResponseBody() {
String name = "tom";
return GlobalRest.SUCCESS.of(SingleEntity.of("name", name));
// SimpleBodyEntity 将json序列化为 {"name": "tom"} 置于RestResponse中
}
}

SingleEntity继承了SingleBean接口,ws-springx 提供对SingleBean的validation提取器的支持,故而应用中有了SingleBean对象的 JSR-303的校验支持。