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,调用接口,可以发现请求已经成功被替换。
5. 缺陷
由于路径的匹配是仅仅对Spring内记录的endpoint做了简单的equals,所以它并不支持对@PathVarible这样的路径进行分支处理。
例如对于endpoint: a/{b}/c,满足该条件的请求会寻找mock/a/{b}/c.txt。而不是将b替换成其他字符串。
在此抛砖引玉,望有人能给出完美的解决方案。