重學 Java 設計模式:實戰外觀模式「基於SpringBoot開發門面模式中介軟體,統一控制介面白名單場景」

小傅哥發表於2020-06-12

作者:小傅哥
部落格:https://bugstack.cn

沉澱、分享、成長,讓自己和他人都能有所收穫!?

一、前言

你感受到的容易,一定有人為你承擔不容易

這句話更像是描述生活的,許許多多的磕磕絆絆總有人為你提供躲雨的屋簷和避風的港灣。其實程式設計開發的團隊中也一樣有人只負責CRUD中的簡單呼叫,去使用團隊中高階程式設計師開發出來的核心服務和介面。這樣的程式設計開發對於初期剛進入程式設計師行業的小夥伴來說鍛鍊鍛鍊還是不錯的,但隨著開發的日子越來越久一直做這樣的事情就很難得到成長,也想努力的去做一些更有難度的承擔,以此來增強個人的技術能力。

沒有最好的程式語言,語言只是工具

刀槍棍棒、斧鉞鉤叉、包子油條、盒子麻花,是語言。五郎八卦棍、十二路彈腿、洪家鐵線拳,是設計。記得葉問裡有一句臺詞是:金山找:今天我北方拳術,輸給你南方拳術了。葉問:你錯了,不是南北拳的問題,是你的問題。所以當你程式設計開發寫的久了,就不會再特別在意用的語言,而是為目標服務,用最好的設計能力也就是程式設計的智慧做出做最完美的服務。這也就是程式設計人員的價值所在!

設計與反設計以及過渡設計

設計模式是解決程式中不合理、不易於擴充套件、不易於維護的問題,也是幹掉大部分ifelse的利器,在我們常用的框架中基本都會用到大量的設計模式來構建元件,這樣也能方便框架的升級和功能的擴充套件。但!如果不能合理的設計以及亂用設計模式,會導致整個程式設計變得更加複雜難維護,也就是我們常說的;反設計過渡設計。而這部分設計能力也是從實踐的專案中獲取的經驗,不斷的改造優化摸索出的最合理的方式,應對當前的服務體量。

二、開發環境

  1. JDK 1.8
  2. Idea + Maven
  3. SpringBoot 2.1.2.RELEASE
  4. 涉及工程三個,可以通過關注公眾號bugstack蟲洞棧,回覆原始碼下載獲取(開啟獲取的連結,找到序號18)
工程 描述
itstack-demo-design-10-00 場景模擬工程;模擬一個提供介面服務的SpringBoot工程
itstack-demo-design-10-01 使用一坨程式碼實現業務需求
itstack-demo-design-10-02 通過設計模式開發為中介軟體,包裝通用型核心邏輯

三、外觀模式介紹

外觀模式,圖片來自 refactoringguru.cn

外觀模式也叫門面模式,主要解決的是降低呼叫方的使用介面的複雜邏輯組合。這樣呼叫方與實際的介面提供方提供方提供了一箇中間層,用於包裝邏輯提供API介面。有些時候外觀模式也被用在中介軟體層,對服務中的通用性複雜邏輯進行中介軟體層包裝,讓使用方可以只關心業務開發。

那麼這樣的模式在我們的所見產品功能中也經常遇到,就像幾年前我們註冊一個網站時候往往要新增很多資訊,包括;姓名、暱稱、手機號、QQ、郵箱、住址、單身等等,但現在註冊成為一個網站的使用者只需要一步即可,無論是手機號還是微信也都提供了這樣的登入服務。而對於服務端應用開發來說以前是提供了一個整套的介面,現在註冊的時候並沒有這些資訊,那麼服務端就需要進行介面包裝,在前端呼叫註冊的時候服務端獲取相應的使用者資訊(從各個渠道),如果獲取不到會讓使用者後續進行補全(營銷補全資訊給獎勵),以此來拉動使用者的註冊量和活躍度。

四、案例場景模擬

場景模擬;所有服務新增白名單校驗

在本案例中我們模擬一個將所有服務介面新增白名單的場景

在專案不斷壯大發展的路上,每一次發版上線都需要進行測試,而這部分測試驗證一般會進行白名單開量或者切量的方式進行驗證。那麼如果在每一個介面中都新增這樣的邏輯,就會非常麻煩且不易維護。另外這是一類具備通用邏輯的共性需求,非常適合開發成元件,以此來治理服務,讓研發人員更多的關心業務功能開發。

一般情況下對於外觀模式的使用通常是用在複雜或多個介面進行包裝統一對外提供服務上,此種使用方式也相對簡單在我們平常的業務開發中也是最常用的。你可能經常聽到把這兩個介面包裝一下,但在本例子中我們把這種設計思路放到中介軟體層,讓服務變得可以統一控制。

1. 場景模擬工程

itstack-demo-design-10-00
└── src
    ├── main
    │   ├── java
    │   │   └── org.itstack.demo.design
    │   │       ├── domain
    │   │       │	└── UserInfo.java
    │   │       ├── web	
    │   │       │	└── HelloWorldController.java
    │   │       └── HelloWorldApplication.java
    │   └── resources	
    │       └── application.yml	
    └── test
        └── java
            └── org.itstack.demo.test
                └── ApiTest.java
  • 這是一個SpringBootHelloWorld工程,在工程中提供了查詢使用者資訊的介面HelloWorldController.queryUserInfo,為後續擴充套件此介面的白名單過濾做準備。

2. 場景簡述

2.1 定義基礎查詢介面

@RestController
public class HelloWorldController {

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

    /**
     * key:需要從入參取值的屬性欄位,如果是物件則從物件中取值,如果是單個值則直接使用
     * returnJson:預設攔截時返回值,是返回物件的Json
     *
     * http://localhost:8080/api/queryUserInfo?userId=1001
     * http://localhost:8080/api/queryUserInfo?userId=小團團
     */
    @RequestMapping(path = "/api/queryUserInfo", method = RequestMethod.GET)
    public UserInfo queryUserInfo(@RequestParam String userId) {
        return new UserInfo("蟲蟲:" + userId, 19, "天津市南開區旮旯衚衕100號");
    }

}
  • 這裡提供了一個基本的查詢服務,通過入參userId,查詢使用者資訊。後續就需要在這裡擴充套件白名單,只有指定使用者才可以查詢,其他使用者不能查詢。

2.2 設定Application啟動類

@SpringBootApplication
@Configuration
public class HelloWorldApplication {

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

}
  • 這裡是通用的SpringBoot啟動類。需要新增的是一個配置註解@Configuration,為了後續可以讀取白名單配置。

五、用一坨坨程式碼實現

一般對於此種場景最簡單的做法就是直接修改程式碼

累加if塊幾乎是實現需求最快也是最慢的方式,是修改當前內容很快,是如果同類的內容幾百個也都需要如此修改擴充套件和維護會越來越慢。

1. 工程結構

itstack-demo-design-10-01
└── src
    └── main
        └── java
            └── org.itstack.demo.design
                └── HelloWorldController.java
  • 以上的實現是模擬一個Api介面類,在裡面新增白名單功能,但類似此類的介面會有很多都需要修改,所以這也是不推薦使用此種方式的重要原因。

2. 程式碼實現

public class HelloWorldController {

    public UserInfo queryUserInfo(@RequestParam String userId) {

        // 做白名單攔截
        List<String> userList = new ArrayList<String>();
        userList.add("1001");
        userList.add("aaaa");
        userList.add("ccc");
        if (!userList.contains(userId)) {
            return new UserInfo("1111", "非白名單可訪問使用者攔截!");
        }

        return new UserInfo("蟲蟲:" + userId, 19, "天津市南開區旮旯衚衕100號");
    }

}
  • 在這裡白名單的程式碼佔據了一大塊,但它又不是業務中的邏輯,而是因為我們上線過程中需要做的開量前測試驗證。
  • 如果你日常對待此類需求經常是這樣開發,那麼可以按照此設計模式進行優化你的處理方式,讓後續的擴充套件和摘除更加容易。

六、外觀模式重構程式碼

接下來使用外觀器模式來進行程式碼優化,也算是一次很小的重構。

這次重構的核心是使用外觀模式也可以說門面模式,結合SpringBoot中的自定義starter中介軟體開發的方式,統一處理所有需要白名單的地方。

後續接下來的實現中,會涉及的知識;

  1. SpringBoot的starter中介軟體開發方式。
  2. 面向切面程式設計和自定義註解的使用。
  3. 外部自定義配置資訊的透傳,SpringBoot與Spring不同,對於此類方式獲取白名單配置存在差異。

1. 工程結構

itstack-demo-design-10-02
└── src
    ├── main
    │   ├── java
    │   │   └── org.itstack.demo.design.door
    │   │       ├── annotation
    │   │       │	└── DoDoor.java	
    │   │       ├── config
    │   │       │	├── StarterAutoConfigure.java
    │   │       │	├── StarterService.java
    │   │       │	└── StarterServiceProperties.java
    │   │       └── DoJoinPoint.java
    │   └── resources	
    │       └── META_INF
    │           └── spring.factories
    └── test
        └── java
            └── org.itstack.demo.test
                └── ApiTest.java

門面模式模型結構

門面模式模型結構

  • 以上是外觀模式的中介軟體實現思路,右側是為了獲取配置檔案,左側是對於切面的處理。
  • 門面模式可以是對介面的包裝提供出介面服務,也可以是對邏輯的包裝通過自定義註解對介面提供服務能力。

2. 程式碼實現

2.1 配置服務類

public class StarterService {

    private String userStr;

    public StarterService(String userStr) {
        this.userStr = userStr;
    }

    public String[] split(String separatorChar) {
        return StringUtils.split(this.userStr, separatorChar);
    }

}
  • 以上類的內容較簡單只是為了獲取配置資訊。

2.2 配置類註解定義

@ConfigurationProperties("itstack.door")
public class StarterServiceProperties {

    private String userStr;

    public String getUserStr() {
        return userStr;
    }

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

}
  • 用於定義好後續在 application.yml 中新增 itstack.door 的配置資訊。

2.3 自定義配置類資訊獲取

@Configuration
@ConditionalOnClass(StarterService.class)
@EnableConfigurationProperties(StarterServiceProperties.class)
public class StarterAutoConfigure {

    @Autowired
    private StarterServiceProperties properties;

    @Bean
    @ConditionalOnMissingBean
    @ConditionalOnProperty(prefix = "itstack.door", value = "enabled", havingValue = "true")
    StarterService starterService() {
        return new StarterService(properties.getUserStr());
    }

}
  • 以上程式碼是對配置的獲取操作,主要是對註解的定義;@Configuration@ConditionalOnClass@EnableConfigurationProperties,這一部分主要是與SpringBoot的結合使用。

2.4 切面註解定義

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface DoDoor {

    String key() default "";

    String returnJson() default "";

}
  • 定義了外觀模式門面註解,後續就是此註解新增到需要擴充套件白名單的方法上。
  • 這裡提供了兩個入參,key:獲取某個欄位例如使用者ID、returnJson:確定白名單攔截後返回的具體內容。

2.5 白名單切面邏輯

@Aspect
@Component
public class DoJoinPoint {

    private Logger logger = LoggerFactory.getLogger(DoJoinPoint.class);

    @Autowired
    private StarterService starterService;

    @Pointcut("@annotation(org.itstack.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("itstack 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) throws NoSuchMethodException {
        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.itstack.demo.design.door.annotation.DoDoor)")

定義切面,這裡採用的是註解路徑,也就是所有的加入這個註解的方法都會被切面進行管理。

getFiledValue

獲取指定key也就是獲取入參中的某個屬性,這裡主要是獲取使用者ID,通過ID進行攔截校驗。

returnObject

返回攔截後的轉換物件,也就是說當非白名單使用者訪問時則返回一些提示資訊。

doRouter

切面核心邏輯,這一部分主要是判斷當前訪問的使用者ID是否白名單使用者,如果是則放行jp.proceed();,否則返回自定義的攔截提示資訊。

3. 測試驗證

這裡的測試我們會在工程:itstack-demo-design-10-00中進行操作,通過引入jar包,配置註解的方式進行驗證。

3.1 引入中介軟體POM配置

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>itstack-demo-design-10-02</artifactId>
</dependency>
  • 打包中介軟體工程,給外部提供jar包服務

3.2 配置application.yml

# 自定義中介軟體配置
itstack:
  door:
    enabled: true
    userStr: 1001,aaaa,ccc #白名單使用者ID,多個逗號隔開
  • 這裡主要是加入了白名單的開關和白名單的使用者ID,逗號隔開。

3.3 在Controller中新增自定義註解

/**
 * http://localhost:8080/api/queryUserInfo?userId=1001
 * http://localhost:8080/api/queryUserInfo?userId=小團團
 */
@DoDoor(key = "userId", returnJson = "{\"code\":\"1111\",\"info\":\"非白名單可訪問使用者攔截!\"}")
@RequestMapping(path = "/api/queryUserInfo", method = RequestMethod.GET)
public UserInfo queryUserInfo(@RequestParam String userId) {
    return new UserInfo("蟲蟲:" + userId, 19, "天津市南開區旮旯衚衕100號");
}
  • 這裡核心的內容主要是自定義的註解的新增@DoDoor,也就是我們的外觀模式中介軟體化實現。
  • key:需要從入參取值的屬性欄位,如果是物件則從物件中取值,如果是單個值則直接使用。
  • returnJson:預設攔截時返回值,是返回物件的Json。

3.4 啟動SpringBoot

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::        (v2.1.2.RELEASE)

2020-06-11 23:56:55.451  WARN 65228 --- [           main] ion$DefaultTemplateResolverConfiguration : Cannot find template location: classpath:/templates/ (please add some templates or check your Thymeleaf configuration)
2020-06-11 23:56:55.531  INFO 65228 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8080 (http) with context path ''
2020-06-11 23:56:55.533  INFO 65228 --- [           main] o.i.demo.design.HelloWorldApplication    : Started HelloWorldApplication in 1.688 seconds (JVM running for 2.934)
  • 啟動正常,SpringBoot已經啟動可以對外提供服務。

3.5 訪問介面介面測試

白名單使用者訪問

http://localhost:8080/api/queryUserInfo?userId=1001

{"code":"0000","info":"success","name":"蟲蟲:1001","age":19,"address":"天津市南開區旮旯衚衕100號"}
  • 此時的測試結果正常,可以拿到介面資料。

非白名單使用者訪問

http://localhost:8080/api/queryUserInfo?userId=小團團

{"code":"1111","info":"非白名單可訪問使用者攔截!","name":null,"age":null,"address":null}
  • 這次我們把userId換成小團團,此時返回的資訊已經是被攔截的資訊。而這個攔截資訊正式我們自定義註解中的資訊:@DoDoor(key = "userId", returnJson = "{\"code\":\"1111\",\"info\":\"非白名單可訪問使用者攔截!\"}")

七、總結

  • 以上我們通過中介軟體的方式實現外觀模式,這樣的設計可以很好的增強程式碼的隔離性,以及複用性,不僅使用上非常靈活也降低了每一個系統都開發這樣的服務帶來的風險。
  • 可能目前你看這只是非常簡單的白名單控制,是否需要這樣的處理。但往往一個小小的開始會影響著後續無限的擴充套件,實際的業務開發往往也要複雜的很多,不可能如此簡單。因而使用設計模式來讓程式碼結構更加乾淨整潔。
  • 很多時候不是設計模式沒有用,而是自己程式設計開發經驗不足導致即使學了設計模式也很難駕馭。畢竟這些知識都是經過一些實際操作提煉出來的精華,但如果你可以按照本系列文章中的案例方式進行學習實操,還是可以增強這部分設計能力的。

八、推薦閱讀

相關文章