1. 程式人生 > 其它 >介面隔離原則介紹

介面隔離原則介紹

目錄介紹

  • 00.問題思考分析
  • 01.前沿簡單介紹
  • 02.如何理解介面隔離原則
  • 03.介面理解為一組API介面集合
  • 04.介面理解為單個API介面或函式
  • 05.介面理解為OOP中的介面概念
  • 06.總結一下分享
  • 07.思考一道課後題

00.問題思考分析

  • 01.什麼叫作介面隔離法則,它和麵向物件中的介面有何區別?

01.前沿簡單介紹

  • 學習了 SOLID 原則中的單一職責原則、開閉原則和裡式替換原則,今天我們學習第四個原則,介面隔離原則。它對應 SOLID 中的英文字母“I”。
  • 對於這個原則,最關鍵就是理解其中“介面”的含義。那針對“介面”,不同的理解方式,對應在原則上也有不同的解讀方式。
  • 除此之外,介面隔離原則跟我們之前講到的單一職責原則還有點兒類似,所以今天我也會具體講一下它們之間的區別和聯絡。

02.如何理解介面隔離原則

  • 介面隔離原則的英文翻譯是“ Interface Segregation Principle”,縮寫為 ISP。Robert Martin 在 SOLID 原則中是這樣定義它的:“Clients should not be forced to depend upon interfaces that they do not use。”直譯成中文的話就是:客戶端不應該強迫依賴它不需要的介面。其中的“客戶端”,可以理解為介面的呼叫者或者使用者。
  • 實際上,“介面”這個名詞可以用在很多場合中。生活中我們可以用它來指插座介面等。在軟體開發中,我們既可以把它看作一組抽象的約定,也可以具體指系統與系統之間的 API 介面,還可以特指面向物件程式語言中的介面等。
  • 前面我提到,理解介面隔離原則的關鍵,就是理解其中的“介面”二字。在這條原則中,我們可以把“介面”理解為下面三種東西:
    • 一組 API 介面集合
    • 單個 API 介面或函式
    • OOP 中的介面概念

03.介面理解為一組API介面集合

  • 還是結合一個例子來講解。微服務使用者系統提供了一組跟使用者相關的 API 給其他系統使用,比如:註冊、登入、獲取使用者資訊等。具體程式碼如下所示:
    public interface UserService {
      boolean register(String cellphone, String password);
      boolean login(String cellphone, String password);
      UserInfo getUserInfoById(long id);
      UserInfo getUserInfoByCellphone(String cellphone);
    }
    
    public class UserServiceImpl implements UserService {
      //...
    }
    
  • 現在,我們的後臺管理系統要實現刪除使用者的功能,希望使用者系統提供一個刪除使用者的介面。這個時候我們該如何來做呢?你可能會說,這不是很簡單嗎,我只需要在 UserService 中新新增一個 deleteUserByCellphone() 或 deleteUserById() 介面就可以了。這個方法可以解決問題,但是也隱藏了一些安全隱患。
  • 刪除使用者是一個非常慎重的操作,我們只希望通過後臺管理系統來執行,所以這個介面只限於給後臺管理系統使用。如果我們把它放到 UserService 中,那所有使用到 UserService 的系統,都可以呼叫這個介面。不加限制地被其他業務系統呼叫,就有可能導致誤刪使用者。
  • 當然,最好的解決方案是從架構設計的層面,通過介面鑑權的方式來限制介面的呼叫。不過,如果暫時沒有鑑權框架來支援,我們還可以從程式碼設計的層面,儘量避免介面被誤用。我們參照介面隔離原則,呼叫者不應該強迫依賴它不需要的介面,將刪除介面單獨放到另外一個介面 RestrictedUserService 中,然後將 RestrictedUserService 只打包提供給後臺管理系統來使用。具體的程式碼實現如下所示:
    public interface UserService {
      boolean register(String cellphone, String password);
      boolean login(String cellphone, String password);
      UserInfo getUserInfoById(long id);
      UserInfo getUserInfoByCellphone(String cellphone);
    }
    
    public interface RestrictedUserService {
      boolean deleteUserByCellphone(String cellphone);
      boolean deleteUserById(long id);
    }
    
    public class UserServiceImpl implements UserService, RestrictedUserService {
      // ...省略實現程式碼...
    }
    
  • 在剛剛的這個例子中,我們把介面隔離原則中的介面,理解為一組介面集合,它可以是某個微服務的介面,也可以是某個類庫的介面等等。在設計微服務或者類庫介面的時候,如果部分介面只被部分呼叫者使用,那我們就需要將這部分介面隔離出來,單獨給對應的呼叫者使用,而不是強迫其他呼叫者也依賴這部分不會被用到的介面。

04.介面理解為單個API介面或函式

  • 現在我們再換一種理解方式,把介面理解為單個介面或函式(以下為了方便講解,我都簡稱為“函式”)。那介面隔離原則就可以理解為:函式的設計要功能單一,不要將多個不同的功能邏輯在一個函式中實現。接下來,我們還是通過一個例子來解釋一下。
    public class Statistics {
      private Long max;
      private Long min;
      private Long average;
      private Long sum;
      private Long percentile99;
      private Long percentile999;
      //...省略constructor/getter/setter等方法...
    }
    
    public Statistics count(Collection<Long> dataSet) {
      Statistics statistics = new Statistics();
      //...省略計算邏輯...
      return statistics;
    }
    
  • 在上面的程式碼中,count() 函式的功能不夠單一,包含很多不同的統計功能,比如,求最大值、最小值、平均值等等。按照介面隔離原則,我們應該把 count() 函式拆成幾個更小粒度的函式,每個函式負責一個獨立的統計功能。拆分之後的程式碼如下所示:
    public Long max(Collection<Long> dataSet) { //... }
    public Long min(Collection<Long> dataSet) { //... } 
    public Long average(Colletion<Long> dataSet) { //... }
    // ...省略其他統計函式...
    
  • 不過,你可能會說,在某種意義上講,count() 函式也不能算是職責不夠單一,畢竟它做的事情只跟統計相關。我們在講單一職責原則的時候,也提到過類似的問題。實際上,判定功能是否單一,除了很強的主觀性,還需要結合具體的場景。
  • 如果在專案中,對每個統計需求,Statistics 定義的那幾個統計資訊都有涉及,那 count() 函式的設計就是合理的。相反,如果每個統計需求只涉及 Statistics 羅列的統計資訊中一部分,比如,有的只需要用到 max、min、average 這三類統計資訊,有的只需要用到 average、sum。而 count() 函式每次都會把所有的統計資訊計算一遍,就會做很多無用功,勢必影響程式碼的效能,特別是在需要統計的資料量很大的時候。所以,在這個應用場景下,count() 函式的設計就有點不合理了,我們應該按照第二種設計思路,將其拆分成粒度更細的多個統計函式。
  • 介面隔離原則跟單一職責原則有點類似,不過稍微還是有點區別。
    • 單一職責原則針對的是模組、類、介面的設計
    • 介面隔離原則相對於單一職責原則,一方面它更側重於介面的設計,另一方面它的思考的角度不同。它提供了一種判斷介面是否職責單一的標準:通過呼叫者如何使用介面來間接地判定。如果呼叫者只使用部分介面或介面的部分功能,那介面的設計就不夠職責單一。

05.介面理解為OOP中的介面概念

  • 還可以把“介面”理解為 OOP 中的介面概念,比如 Java 中的 interface。我還是通過一個例子來給你解釋。假設我們的專案中用到了三個外部系統:Redis、MySQL、Kafka。每個系統都對應一系列配置資訊,比如地址、埠、訪問超時時間等。為了在記憶體中儲存這些配置資訊,供專案中的其他模組來使用,我們分別設計實現了三個 Configuration 類:RedisConfig、MysqlConfig、KafkaConfig。具體的程式碼實現如下所示。注意,這裡我只給出了 RedisConfig 的程式碼實現,另外兩個都是類似的,我這裡就不貼了。
    public class RedisConfig {
        private ConfigSource configSource; //配置中心(比如zookeeper)
        private String address;
        private int timeout;
        private int maxTotal;
        //省略其他配置: maxWaitMillis,maxIdle,minIdle...
    
        public RedisConfig(ConfigSource configSource) {
            this.configSource = configSource;
        }
    
        public String getAddress() {
            return this.address;
        }
        //...省略其他get()、init()方法...
    
        public void update() {
          //從configSource載入配置到address/timeout/maxTotal...
        }
    }
    
    public class KafkaConfig { //...省略... }
    public class MysqlConfig { //...省略... }
    
  • 現在,我們有一個新的功能需求,希望支援 Redis 和 Kafka 配置資訊的熱更新。所謂“熱更新(hot update)”就是,如果在配置中心中更改了配置資訊,我們希望在不用重啟系統的情況下,能將最新的配置資訊載入到記憶體中(也就是 RedisConfig、KafkaConfig 類中)。但是,因為某些原因,我們並不希望對 MySQL 的配置資訊進行熱更新。
  • 為了實現這樣一個功能需求,我們設計實現了一個 ScheduledUpdater 類,以固定時間頻率(periodInSeconds)來呼叫 RedisConfig、KafkaConfig 的 update() 方法更新配置資訊。具體的程式碼實現如下所示:
    public interface Updater {
      void update();
    }
    
    public class RedisConfig implemets Updater {
      //...省略其他屬性和方法...
      @Override
      public void update() { //... }
    }
    
    public class KafkaConfig implements Updater {
      //...省略其他屬性和方法...
      @Override
      public void update() { //... }
    }
    
    public class MysqlConfig { //...省略其他屬性和方法... }
    
    public class ScheduledUpdater {
        private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();;
        private long initialDelayInSeconds;
        private long periodInSeconds;
        private Updater updater;
    
        public ScheduleUpdater(Updater updater, long initialDelayInSeconds, long periodInSeconds) {
            this.updater = updater;
            this.initialDelayInSeconds = initialDelayInSeconds;
            this.periodInSeconds = periodInSeconds;
        }
    
        public void run() {
            executor.scheduleAtFixedRate(new Runnable() {
                @Override
                public void run() {
                    updater.update();
                }
            }, this.initialDelayInSeconds, this.periodInSeconds, TimeUnit.SECONDS);
        }
    }
    
    public class Application {
      ConfigSource configSource = new ZookeeperConfigSource(/*省略引數*/);
      public static final RedisConfig redisConfig = new RedisConfig(configSource);
      public static final KafkaConfig kafkaConfig = new KakfaConfig(configSource);
      public static final MySqlConfig mysqlConfig = new MysqlConfig(configSource);
    
      public static void main(String[] args) {
        ScheduledUpdater redisConfigUpdater = new ScheduledUpdater(redisConfig, 300, 300);
        redisConfigUpdater.run();
        
        ScheduledUpdater kafkaConfigUpdater = new ScheduledUpdater(kafkaConfig, 60, 60);
        redisConfigUpdater.run();
      }
    }
    
  • 剛剛的熱更新的需求我們已經搞定了。現在,我們又有了一個新的監控功能需求。通過命令列來檢視 Zookeeper 中的配置資訊是比較麻煩的。所以,我們希望能有一種更加方便的配置資訊檢視方式。為了實現這樣一個功能,我們還需要對上面的程式碼做進一步改造。改造之後的程式碼如下所示:
    public interface Updater {
      void update();
    }
    
    public interface Viewer {
      String outputInPlainText();
      Map<String, String> output();
    }
    
    public class RedisConfig implemets Updater, Viewer {
      //...省略其他屬性和方法...
      @Override
      public void update() { //... }
      @Override
      public String outputInPlainText() { //... }
      @Override
      public Map<String, String> output() { //...}
    }
    
    public class KafkaConfig implements Updater {
      //...省略其他屬性和方法...
      @Override
      public void update() { //... }
    }
    
    public class MysqlConfig implements Viewer {
      //...省略其他屬性和方法...
      @Override
      public String outputInPlainText() { //... }
      @Override
      public Map<String, String> output() { //...}
    }
    
    public class SimpleHttpServer {
      private String host;
      private int port;
      private Map<String, List<Viewer>> viewers = new HashMap<>();
      
      public SimpleHttpServer(String host, int port) {//...}
      
      public void addViewers(String urlDirectory, Viewer viewer) {
        if (!viewers.containsKey(urlDirectory)) {
          viewers.put(urlDirectory, new ArrayList<Viewer>());
        }
        this.viewers.get(urlDirectory).add(viewer);
      }
      
      public void run() { //... }
    }
    
    public class Application {
        ConfigSource configSource = new ZookeeperConfigSource();
        public static final RedisConfig redisConfig = new RedisConfig(configSource);
        public static final KafkaConfig kafkaConfig = new KakfaConfig(configSource);
        public static final MySqlConfig mysqlConfig = new MySqlConfig(configSource);
        
        public static void main(String[] args) {
            ScheduledUpdater redisConfigUpdater =
                new ScheduledUpdater(redisConfig, 300, 300);
            redisConfigUpdater.run();
            
            ScheduledUpdater kafkaConfigUpdater =
                new ScheduledUpdater(kafkaConfig, 60, 60);
            redisConfigUpdater.run();
            
            SimpleHttpServer simpleHttpServer = new SimpleHttpServer(“127.0.0.1”, 2389);
            simpleHttpServer.addViewer("/config", redisConfig);
            simpleHttpServer.addViewer("/config", mysqlConfig);
            simpleHttpServer.run();
        }
    }
    
  • 至此,熱更新和監控的需求我們就都實現了。我們來回顧一下這個例子的設計思想。
    • 設計了兩個功能非常單一的介面:Updater 和 Viewer。ScheduledUpdater 只依賴 Updater 這個跟熱更新相關的介面,不需要被強迫去依賴不需要的 Viewer 介面,滿足介面隔離原則。同理,SimpleHttpServer 只依賴跟檢視資訊相關的 Viewer 介面,不依賴不需要的 Updater 介面,也滿足介面隔離原則。
  • 你可能會說,如果我們不遵守介面隔離原則,不設計 Updater 和 Viewer 兩個小介面,而是設計一個大而全的 Config 介面,讓 RedisConfig、KafkaConfig、MysqlConfig 都實現這個 Config 介面,並且將原來傳遞給 ScheduledUpdater 的 Updater 和傳遞給 SimpleHttpServer 的 Viewer,都替換為 Config,那會有什麼問題呢?我們先來看一下,按照這個思路來實現的程式碼是什麼樣的。
    public interface Config {
      void update();
      String outputInPlainText();
      Map<String, String> output();
    }
    
    public class RedisConfig implements Config {
      //...需要實現Config的三個介面update/outputIn.../output
    }
    
    public class KafkaConfig implements Config {
      //...需要實現Config的三個介面update/outputIn.../output
    }
    
    public class MysqlConfig implements Config {
      //...需要實現Config的三個介面update/outputIn.../output
    }
    
    public class ScheduledUpdater {
      //...省略其他屬性和方法..
      private Config config;
    
      public ScheduleUpdater(Config config, long initialDelayInSeconds, long periodInSeconds) {
          this.config = config;
          //...
      }
      //...
    }
    
    public class SimpleHttpServer {
      private String host;
      private int port;
      private Map<String, List<Config>> viewers = new HashMap<>();
     
      public SimpleHttpServer(String host, int port) {//...}
      
      public void addViewer(String urlDirectory, Config config) {
        if (!viewers.containsKey(urlDirectory)) {
          viewers.put(urlDirectory, new ArrayList<Config>());
        }
        viewers.get(urlDirectory).add(config);
      }
      
      public void run() { //... }
    }
    
  • 這樣的設計思路也是能工作的,但是對比前後兩個設計思路,在同樣的程式碼量、實現複雜度、同等可讀性的情況下,第一種設計思路顯然要比第二種好很多。為什麼這麼說呢?主要有兩點原因。
    • 首先,第一種設計思路更加靈活、易擴充套件、易複用。因為 Updater、Viewer 職責更加單一,單一就意味了通用、複用性好。比如,我們現在又有一個新的需求,開發一個 Metrics 效能統計模組,並且希望將 Metrics 也通過 SimpleHttpServer 顯示在網頁上,以方便檢視。這個時候,儘管 Metrics 跟 RedisConfig 等沒有任何關係,但我們仍然可以讓 Metrics 類實現非常通用的 Viewer 介面,複用 SimpleHttpServer 的程式碼實現。具體的程式碼如下所示:
    public class ApiMetrics implements Viewer {//...}
    public class DbMetrics implements Viewer {//...}
    
    public class Application {
        ConfigSource configSource = new ZookeeperConfigSource();
        public static final RedisConfig redisConfig = new RedisConfig(configSource);
        public static final KafkaConfig kafkaConfig = new KakfaConfig(configSource);
        public static final MySqlConfig mySqlConfig = new MySqlConfig(configSource);
        public static final ApiMetrics apiMetrics = new ApiMetrics();
        public static final DbMetrics dbMetrics = new DbMetrics();
        
        public static void main(String[] args) {
            SimpleHttpServer simpleHttpServer = new SimpleHttpServer(“127.0.0.1”, 2389);
            simpleHttpServer.addViewer("/config", redisConfig);
            simpleHttpServer.addViewer("/config", mySqlConfig);
            simpleHttpServer.addViewer("/metrics", apiMetrics);
            simpleHttpServer.addViewer("/metrics", dbMetrics);
            simpleHttpServer.run();
        }
    }
    
    • 第二種設計思路在程式碼實現上做了一些無用功。因為 Config 介面中包含兩類不相關的介面,一類是 update(),一類是 output() 和 outputInPlainText()。理論上,KafkaConfig 只需要實現 update() 介面,並不需要實現 output() 相關的介面。同理,MysqlConfig 只需要實現 output() 相關介面,並需要實現 update() 介面。但第二種設計思路要求 RedisConfig、KafkaConfig、MySqlConfig 必須同時實現 Config 的所有介面函式(update、output、outputInPlainText)。除此之外,如果我們要往 Config 中繼續新增一個新的介面,那所有的實現類都要改動。相反,如果我們的介面粒度比較小,那涉及改動的類就比較少。

06.總結一下分享

  • 1.如何理解“介面隔離原則”?
    • 理解“介面隔離原則”的重點是理解其中的“介面”二字。這裡有三種不同的理解。
    • 如果把“介面”理解為一組介面集合,可以是某個微服務的介面,也可以是某個類庫的介面等。如果部分介面只被部分呼叫者使用,我們就需要將這部分介面隔離出來,單獨給這部分呼叫者使用,而不強迫其他呼叫者也依賴這部分不會被用到的介面。
    • 如果把“介面”理解為單個 API 介面或函式,部分呼叫者只需要函式中的部分功能,那我們就需要把函式拆分成粒度更細的多個函式,讓呼叫者只依賴它需要的那個細粒度函式。
    • 如果把“介面”理解為 OOP 中的介面,也可以理解為面向物件程式語言中的介面語法。那介面的設計要儘量單一,不要讓介面的實現類和呼叫者,依賴不需要的介面函式。
  • 2.介面隔離原則與單一職責原則的區別
    • 單一職責原則針對的是模組、類、介面的設計。介面隔離原則相對於單一職責原則,一方面更側重於介面的設計,另一方面它的思考角度也是不同的。
    • 介面隔離原則提供了一種判斷介面的職責是否單一的標準:通過呼叫者如何使用介面來間接地判定。如果呼叫者只使用部分介面或介面的部分功能,那介面的設計就不夠職責單一。

07.思考一道課後題

  • java.util.concurrent 併發包提供了 AtomicInteger 這樣一個原子類,其中有一個函式 getAndIncrement() 是這樣定義的:給整數增加一,並且返回未増之前的值。
  • 我的問題是,這個函式的設計是否符合單一職責原則和介面隔離原則?為什麼?
    /**
     * Atomically increments by one the current value.
     * @return the previous value
     */
    public final int getAndIncrement() {//...}
    

開源專案:https://github.com/yangchong211/YCAppTool

開源部落格:https://github.com/yangchong211/YCBlogs