结构型模式包括:适配器、桥接、组合、装饰器、外观、享元、代理

这类模式介绍如何将对象和类组装成较大的结构,并同时保持结构的灵活和高效。

第五节、外观模式

一、外观模式介绍


外观模式也叫门面模式,主要解决的是降低调用方的使用接口的复杂逻辑组合。这样调用方与实际的接口提供方提供了一个中间层,用于包装逻辑提供API接口。有些时候外观模式也被用在中间层,对服务中的通用性复杂逻辑进行中间件层包装,让使用方可以只关心业务开发。

这样的模式也常见: 比如以前注册账号需要填写很多信息,现在注册账号只需要使用手机号或者微信一键登录

二、案例场景模拟


在本案例中我们模拟一个将所有服务接口添加白名单的场景

⼀般情况下对于外观模式的使⽤通常是⽤在复杂或多个接⼝进⾏包装统⼀对外提供服务上,此种使⽤⽅式也相对简单在我们平常的业务开发中也是最常⽤的。你可能经常听到把这两个接⼝包装⼀下,但在本例⼦中我们把这种设计思路放到中间件层,让服务变得可以统⼀控制。

1.场景模拟工程

这是一个SpringBoot的HelloWorld工程,在工程中提供了查询用户信息的接口HelloWorldController.queryUserInfo,为后续扩展此接口的白名单过滤做准备。

2.场景简述

2.1定义基础查询接口
@RestController
public class HelloWorldController {

@Value("${server.port}")
private int port;

/**
* @DoDoor 自定义注解
* key:需要从入参取值的属性字段,如果是对象则从对象中取值,如果是单个值则直接使用
* returnJson:预设拦截时返回值,是返回对象的Json
*
* http://localhost:8080/api/queryUserInfo?userId=1001
* http://localhost:8080/api/queryUserInfo?userId=levtio
*/

@RequestMapping(path = "/api/queryUserInfo", method = RequestMethod.GET)
public UserInfo queryUserInfo(@RequestParam String userId) {
return new UserInfo("eric:" + userId, 21, "天津市南开区南开大学");
}

}

这里提供了一个基本的查询服务,通过入参userId,查询用户信息。后续就需要在这里扩展白名单,只有指定用户才可以查询,其他用户不能查询。

2.2设置Application启动类
@SpringBootApplication
@Configuration
public class HelloWorldApplication {
public static void main(String[] args) {
SpringApplication.run(HelloWorldApplication.class, args);
}
}

这里是通用的SpringBoot启动类。需要添加的是一个配置注解@Configuration,为了后续可以读取白名单配置。

三、直接实现

对于这种场景最简单的方法就是直接修改代码。

累加if块即可实现。

1.工程结构

以上的实现是模拟一个Api接口类,在里面添加白名单功能,但类似此类的接口会有很多都需要修改,所以这也是不推荐使用此种方式的重要原因。

2.代码实现


public class HelloWorldController {

public UserInfo queryUserInfo(@RequestParam String userId) {

// 做白名单拦截
List<String> userList = new ArrayList<String>();
userList.add("1001");
userList.add("levtio");
userList.add("juis");
if (!userList.contains(userId)) {
return new UserInfo("1111", "非白名单可访问用户拦截!");
}

return new UserInfo("eric:" + userId, 22, "天津市南开区南开大学");
}

}

在这里白名单的代码占据了一大块,但它又不是业务中的逻辑,而是因为我们上线过程中需要做的开量前测试验证。

四、外观模式重构代码

重构核心是使用外观模式,结合SpringBoot中的自定义starter中间件开发的方式,统一处理所有需要白名单的地方。

后续实现设计知识:

  1. SpringBoot的starter中间件开发方式
  2. 面向切面编程和自定义注解的使用
  3. 外部自定义配置信息的透传,SpringBoot与Spring不同,对于此类方式获取白名单配置存在差异

1.工程结构


外观模式模型结构

  1. 以上是外观模式的中间件实现思路,右侧是为了获取配置文件,左侧是对于切面的处理。
  2. 外观模式可以使对接口的包装提供出接口服务,也可以是对逻辑的包装通过自定义注解对接口提供服务能力。

2.代码实现

2.1配置服务类
public class StarterService {
private String userStr;
public StarterService(String userStr){
this.userStr = userStr;
}
public String[] split(String separatorChar){
return this.userStr.split(separatorChar);
}
}

以上类的内容较简单,只是为了获取配置信息。

2.2配置类注解定义
@ConfigurationProperties("levtio.door")
public class StarterServiceProperties {
private String userStr;

public String getUserStr() {
return userStr;
}

public void setUserStr(String userStr) {
this.userStr = userStr;
}

}

用于定义好后续在application.yml中添加levtio.door的配置信息

2.3自定义配置类信息获取
@Configuration
@ConditionalOnClass(StarterService.class)
@EnableConfigurationProperties(StarterServiceProperties.class)
public class StarterAutoConfigure {

@Autowired
private StarterServiceProperties starterServiceProperties;
@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty(prefix = "levtio.door", value = "enabled", havingValue = "true")
StarterService starterService(){
return new StarterService(starterServiceProperties.getUserStr());
}
}

以上代码是对配置的获取操作,主要是对注解的定义:@Configuration,@ConditionalOnClass,@EnableConfigurationProperties

@ConditionalOnClass 是 Spring Boot 中的一个条件注解,用于指定在特定类存在时,才会进行配置或初始化操作。

@EnableConfigurationProperties 是一个 Spring Boot 注解,用于启用对配置属性类的支持。

2.4切面注解定义
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface DoDoor {
String key() default "";
String returnJson() default "";
}

  1. 定义了外观模式门面注解,后续就是此注解添加到需要扩展白名单的方法上。
  2. 这里提供了两个入参:key: 获取某个字段例如用户ID、returnJson: 确定白名单拦截后返回的具体内容。

@Retention(RetentionPolicy.RUNTIME) 是一个 Java 元注解,用于指定注解的保留策略。
在 Java 中,注解默认的保留策略是 RetentionPolicy.CLASS,这意味着注解被编译器保留在编译后的字节码文件中,但不会在运行时保留。而使用 @Retention(RetentionPolicy.RUNTIME) 注解,则表示注解将在运行时保留,并可以通过反射机制在运行时获取和处理这些注解。

@Target(ElementType.METHOD) 是一个 Java 元注解,用于指定注解可以应用的目标元素类型。
在 Java 中,注解可以用于类、接口、方法、字段等不同的元素上。使用 @Target(ElementType.METHOD) 注解表示该注解只能应用于方法(Method)上,而不能应用于其他元素,如类或字段。

2.5白名单切面逻辑
@Aspect
@Component
public class DoJoinPoint {
private Logger logger = LoggerFactory.getLogger(DoJoinPoint.class);
@Autowired
private StarterService starterService;
@Pointcut("@annotation(org.levtio.demo.design.door.annotation.DoDoor)")
public void aopPoint(){}
@Around("aopPoint()")
public Object doRouter(ProceedingJoinPoint jp) throws Throwable{
// 获取内容
Method method = getMethod(jp);
DoDoor door = method.getAnnotation(DoDoor.class);
// 获取字段值
String keyValue = getFiledValue(door.key(), jp.getArgs());
logger.info("levtio door handler method:{} value:{}", method.getName(), keyValue);
if (null == keyValue || "".equals(keyValue)){
return jp.proceed();
}
// 配置内容
String[] split = starterService.split(",");
// 白名单过滤
for (String str:
split) {
if (keyValue.equals(str)){
return jp.proceed();
}
}
// 拦截
return returnObject(door, method);
}
private Method getMethod(JoinPoint jp)throws NoSuchMethodException{
Signature sig = jp.getSignature();
MethodSignature methodSignature = (MethodSignature) sig;
return getClass(jp).getMethod(methodSignature.getName(), methodSignature.getParameterTypes());
}

private Class<? extends Object> getClass(JoinPoint jp) {
return jp.getTarget().getClass();
}
// 返回对象
private Object returnObject(DoDoor doGate, Method method) throws IllegalAccessException, InstantiationException{
Class<?> returnType = method.getReturnType();
String returnJson = doGate.returnJson();
if ("".equals(returnJson)){
return returnType.newInstance();
}
return JSON.parseObject(returnJson, returnType);
}
// 获取属性值
private String getFiledValue(String filed, Object[] args){
String filedValue = null;
for (Object arg:
args) {
try {
if (null == filedValue || "".equals(filedValue)){
filedValue = BeanUtils.getProperty(arg, filed);
} else {
break;
}
}catch (Exception e){
if (args.length == 1){
return args[0].toString();
}
}
}
return filedValue;
}
}

这里包含内容较多,核心逻辑主要是Object doRouter(ProceedingJoinPoint jp),接下来我们分别介绍

@Pointcut(“@annotation(org.levtio.demo.design.door.annotation.DoDoor)”)
定义切面,这里采用的是注解路径,也就是所有的加入这个注解的方法都会被切面进行管理。

getFiledValue
获取指定key也就是获取入参中的某个属性,这里主要是获取用户ID,通过ID进行拦截校验

returnObject
返回拦截后的转换对象,也就是说当非白名单用户访问时则返回一些提示信息。

doRouter
切面核心逻辑,这一部分主要是判断当前访问的用户ID是否为白名单用户,如果是则放行jp.proceed();否则返回自定义的拦截提示信息。

3.测试验证

测试会在demo-design-10-00中进行操作,通过引入jar包,配置注解的方式进行验证。

3.1引入中间件pom配置
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>demo-design-10-02</artifactId>
<version>2.1.2.RELEASE</version>
</dependency>

打包中间件工程,给外部提供jar包服务。

3.2配置application.yml
# 自定义中间件配置
levtio:
door:
enabled: true
userStr: 1001,levtio,juis #白名单用户ID,多个逗号隔开

这里主要是加入了白名单的开关和白名单的用户ID,使用逗号隔开。

3.3在Controller中添加自定义注解
@DoDoor(key = "userId", returnJson = "{\"code\":\"1111\",\"info\":\"非白名单可访问用户拦截!\"}")

@RestController
public class HelloWorldController {

@Value("${server.port}")
private int port;

/**
* @DoDoor 自定义注解
* key:需要从入参取值的属性字段,如果是对象则从对象中取值,如果是单个值则直接使用
* returnJson:预设拦截时返回值,是返回对象的Json
*
* http://localhost:8080/api/queryUserInfo?userId=1001
* http://localhost:8080/api/queryUserInfo?userId=levtio
*/

@DoDoor(key = "userId", returnJson = "{\"code\":\"1111\",\"info\":\"非白名单可访问用户拦截!\"}")
@RequestMapping(path = "/api/queryUserInfo", method = RequestMethod.GET)
public UserInfo queryUserInfo(@RequestParam String userId) {
return new UserInfo("eric:" + userId, 21, "天津市南开区南开大学");
}

}

  1. 这里的核心内容主要是自定义的注解添加@DoDoor,也就是我们外观模式中间件化实现。
  2. key:需要从入参取值的属性字段,如果是对象则从对象中取值,如果是单个值则直接使用。
  3. returnJson:预设拦截时返回值,是返回对象的json。
3.4启动SpringBoot
  .   ____          _            __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v2.1.2.RELEASE)

2023-10-08 17:15:21.579 INFO 22224 --- [ main] o.l.demo.design.HelloWorldApplication : Starting HelloWorldApplication on DESKTOP-KJ2KO1K with PID 22224 (E:\projects\learn\java-design\demo\java-design\demo-design-10-00\target\classes started by 罗明东 in E:\projects\learn\java-design\demo\java-design)
2023-10-08 17:15:21.581 INFO 22224 --- [ main] o.l.demo.design.HelloWorldApplication : No active profile set, falling back to default profiles: default
2023-10-08 17:15:22.363 INFO 22224 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat initialized with port(s): 8080 (http)
2023-10-08 17:15:22.378 INFO 22224 --- [ main] o.apache.catalina.core.StandardService : Starting service [Tomcat]
2023-10-08 17:15:22.378 INFO 22224 --- [ main] org.apache.catalina.core.StandardEngine : Starting Servlet engine: [Apache Tomcat/9.0.14]
2023-10-08 17:15:22.382 INFO 22224 --- [ main] o.a.catalina.core.AprLifecycleListener : The APR based Apache Tomcat Native library which allows optimal performance in production environments was not found on the java.library.path: [E:\dev\jdk\corretto-1.8\bin;C:\WINDOWS\Sun\Java\bin;C:\WINDOWS\system32;C:\WINDOWS;C:\WINDOWS\system32;C:\WINDOWS;C:\WINDOWS\System32\Wbem;C:\WINDOWS\System32\WindowsPowerShell\v1.0\;C:\WINDOWS\System32\OpenSSH\;E:\dev\微信web开发者工具\dll;%NVM_H;ME%;E:\dev\node;E:\dev\Git\cmd;E:\dev\apache-maven-3.9.4\bin;E:\dev\cloud\putty\;C:\Users\24455\AppData\Local\Microsoft\WindowsApps;E:\dev\Microsoft VS Code\bin;C:\Users\24455\AppData\Local\JetBrains\Toolbox\scripts;E:\dev\nvm;C:\Users\24455\AppData\Local\Microsoft\WindowsApps;C:\Users\24455\AppData\Local\GitHubDesktop\bin;.]
2023-10-08 17:15:22.438 INFO 22224 --- [ main] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring embedded WebApplicationContext
2023-10-08 17:15:22.438 INFO 22224 --- [ main] o.s.web.context.ContextLoader : Root WebApplicationContext: initialization completed in 829 ms
2023-10-08 17:15:22.609 INFO 22224 --- [ main] o.s.s.concurrent.ThreadPoolTaskExecutor : Initializing ExecutorService 'applicationTaskExecutor'
2023-10-08 17:15:22.687 WARN 22224 --- [ main] ion$DefaultTemplateResolverConfiguration : Cannot find template location: classpath:/templates/ (please add some templates or check your Thymeleaf configuration)
2023-10-08 17:15:22.803 INFO 22224 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 8080 (http) with context path ''
2023-10-08 17:15:22.805 INFO 22224 --- [ main] o.l.demo.design.HelloWorldApplication
3.5访问接口接口测试

白名单用户访问

{
"code": "0000",
"info": "success",
"name": "eric:levtio",
"age": 21,
"address": "天津市南开区南开大学"
}

非白名单用户访问

{
"code": "1111",
"info": "非白名单可访问用户拦截!",
"name": null,
"age": null,
"address": null
}

五、总结

以上我们通过中间件的⽅式实现外观模式,这样的设计可以很好的增强代码的隔离性,以及复⽤性,不仅使⽤上⾮常灵活也降低了每⼀个系统都开发这样的服务带来的⻛险。