SpringBoot 基于AOP的数据mock功能

171 阅读4分钟

1. 缘由

和同学一起写比赛用web应用。

因为接口已经定好了,但是他的服务还没有真实数据,因此我需要捏一份假数据在SpringBoot的接口里并返回。

但是在java文件里硬编码显得特别麻烦且日后需要修改的地方很多,于是本博客应运而生。

2. 前置知识

Spring AOP

面向切面编程(AOP)通过提供另一种思考程序结构的方式来补充面向对象编程(OOP)。在 OOP 中,模块化的关键单元是类,而在 AOP 中,模块化的单元是切面(aspect)。切面使得可以将跨越多个类型和对象的关注点(如事务管理)进行模块化。(在 AOP 文献中,这些关注点通常被称为“横切关注点”。)

在本需求中,我将拦截所有Controller的调用并返回自定义结果。而AOP功能恰好是我所需要的。

为什么不是拦截器?

拦截器只能做到是/否让url对应的controller继续运行。而在本博客中我不仅需要选择性拦截控制器方法,我还需要修改方法的返回值,让外部看起来就像接口正常返回了一样。


Spring MVC初始化

SpringBoot通过@EnableMvcCofiguration(这通常会在别的注解中被开启)来激活WebMvcConfigurationSupport。此bean会提供一个org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping。内部记录了所有的ControllerMethod与path之间的关系。

在本需求中,我将通过IoC容器获取这个bean,并查找与被拦截函数匹配的path。

3. 设计思路

定义一个配置类。此类用于控制是否启用mock功能。

@Configuration
@ConfigurationProperties(prefix = "mock")
@Slf4j
@Data
public class MockConfig {
    private boolean enable;
    @PostConstruct
    public void warning() {
        log.warn("警告:接口mock已启用,将会屏蔽控制器层转而返回resource包下内容。");
    }
}
​

定义一个切面类。在里面写入我们的环绕通知函数:

@Slf4j
@Aspect
@Component
public class MockAspect {
​
    @SneakyThrows
    //包名可以替换成自己的。
    @Around("execution(* top.kagg886.ruisai.backend.controller..*.*(..))")
    public Object around(ProceedingJoinPoint pjp) {
​
        if (mockConfig.isEnable()) {
            //TODO 详细代码
        }
​
        return pjp.proceed();
    }
}

由于切点的签名一定是方法,所以切点的方法签名可以放心的转成MethodSignature

MethodSignature signature = (MethodSignature) pjp.getSignature();

接下来,我们需要获取注册的controller——endpoint对应记录,并进行查找以挑出我们需要的records:

val entry = requestMappingHandlerMapping.getHandlerMethods().entrySet()
        .stream().filter(it ->
                it.getValue().getMethod().equals(signature.getMethod())
        )
        .findFirst().orElse(null);
​
if (entry == null) {
    return pjp.proceed();
}

别忘了import-bean:

@AutoWired
private final RequestMappingHandlerMapping requestMappingHandlerMapping;

当找不到切点方法时,证明url不存在。此时正常执行以交给Spring处理:

if (entry == null) {
    return pjp.proceed();
}

获取endpoint-path:

var req = String.join("", entry.getKey().getDirectPaths());
​
if (req.startsWith("/")) {
    req = req.substring(1);
}
​
if (req.endsWith("/")) {
    req = req.substring(0, req.length() - 1);
}

获取路径对应的资源:

@Cleanup val stream = Thread.currentThread().getContextClassLoader()
        .getResourceAsStream("mock/" + req + ".txt");
​
if (stream == null) { //不存在资源则正常执行
    log.warn("mock文件查找失败,path: resource://{}", req);
    return pjp.proceed();
}
String value = new String(stream.readAllBytes());
log.warn("mock文件查找成功,path: resource://{}, 内容:{}", req, value);

我们的返回值可能是基本类型,也有可能是Java——Object。对于这些类型,单独编写分支以进行覆盖:

基本数据类型同理,在这里我就不写了

Class<?> returnClass = signature.getReturnType();
​
if (returnClass.equals(String.class)) {
    return value;
}
​
if (returnClass.equals(Integer.class)) {
    return Integer.parseInt(value);
}
​
if (returnClass.equals(Long.class)) {
    return Long.parseLong(value);
}
​
if (returnClass.equals(Boolean.class)) {
    return Boolean.parseBoolean(value);
}
​
return objectMapper.readValue(value, returnClass);

记得import-bean:

@AutoWired
private ObjectMapper objectMapper;

完整的类如下:

@Slf4j
@Aspect
@Component
public class MockAspect {
    private final MockConfig mockConfig;
    private final RequestMappingHandlerMapping requestMappingHandlerMapping;
    private final ObjectMapper objectMapper;
​
    public MockAspect(MockConfig mockConfig, RequestMappingHandlerMapping requestMappingHandlerMapping, ObjectMapper objectMapper) {
        this.mockConfig = mockConfig;
        this.requestMappingHandlerMapping = requestMappingHandlerMapping;
        this.objectMapper = objectMapper;
    }
​
    @SneakyThrows
    @Around("execution(* top.kagg886.ruisai.backend.controller..*.*(..))")
    public Object around(ProceedingJoinPoint pjp) {
​
        if (mockConfig.isEnable()) {
            MethodSignature signature = (MethodSignature) pjp.getSignature();
​
            val entry = requestMappingHandlerMapping.getHandlerMethods().entrySet()
                    .stream().filter(it ->
                            it.getValue().getMethod().equals(signature.getMethod())
                    )
                    .findFirst().orElse(null);
​
            if (entry == null) {
                return pjp.proceed();
            }
​
            var req = String.join("", entry.getKey().getDirectPaths());
​
            if (req.startsWith("/")) {
                req = req.substring(1);
            }
​
            if (req.endsWith("/")) {
                req = req.substring(0, req.length() - 1);
            }
​
            @Cleanup val stream = Thread.currentThread().getContextClassLoader()
                    .getResourceAsStream("mock/" + req + ".txt");
​
            if (stream == null) {
                log.warn("mock文件查找失败,path: resource://{}", req);
                return pjp.proceed();
            }
​
            String value = new String(stream.readAllBytes());
            log.warn("mock文件查找成功,path: resource://{}, 内容:{}", req, value);
​
            Class<?> returnClass = signature.getReturnType();
​
            if (returnClass.equals(String.class)) {
                return value;
            }
​
            if (returnClass.equals(Integer.class)) {
                return Integer.parseInt(value);
            }
​
            if (returnClass.equals(Long.class)) {
                return Long.parseLong(value);
            }
​
            if (returnClass.equals(Boolean.class)) {
                return Boolean.parseBoolean(value);
            }
​
            return objectMapper.readValue(value, returnClass);
​
        }
​
        return pjp.proceed();
    }
}
​

4. 运行结果

定义一个controller:

@RequestMapping
@RestController
public class PingController {

    @PostMapping("/ping")
    public String ping() {
        return "pong!";
    }
}

在resource下新建mock/ping.txt并写入:this pong is response from mock!。

启动boot,调用接口,可以发现请求已经成功被替换。

image.png

5. 缺陷

由于路径的匹配是仅仅对Spring内记录的endpoint做了简单的equals,所以它并不支持对@PathVarible这样的路径进行分支处理。

例如对于endpoint: a/{b}/c,满足该条件的请求会寻找mock/a/{b}/c.txt。而不是将b替换成其他字符串。

在此抛砖引玉,望有人能给出完美的解决方案。