デザインパターンの美しさまとめ(デザイン原理)


title: デザインパターンの美しさまとめ(デザイン原則)
date: 2022-10-27 17:31:42
tags:

  • 設計パターンの
    カテゴリ:
  • デザイン モード
    カバー: https://cover.png
    機能: false

記事ディレクトリ


上一篇见:

上一篇介绍了面向对象相关的知识。接下来介绍一些经典的设计原则,其中包括 SOLID、KISS、YAGNI、DRY、LOD 等

1. 单一职责原则(SRP)

1.1 如何理解单一职责原则?

实际上,SOLID 原则并非单纯的 1 个原则,而是由 5个设计原则组成的,它们分别是:单一职责原则、开闭原则、里式替换原则、接口隔离原则和依赖反转原则,依次对应 SOLID 中的 S、O、L、I、D 这 5 个英文字母

单一职责原则的英文是 Single Responsibility Principle,缩写为 SRP

クラスまたはモジュールには単一の責任が必要です.
クラスまたはモジュールは、1 つの責任 (または機能) を完了することのみを担当します.

この原則によって記述されるオブジェクトには、クラス (Class) とモジュール (Module) の 2 つが含まれます。これら 2 つの概念を理解するには、2 つの方法があります。

  • 理解の 1 つは、モジュールをクラスよりも抽象的な概念と考えてください。クラスもモジュールと見なすことができます。
  • 別の理解としては、モジュールをクラスよりも粒度の粗いコード ブロックと考えてください。モジュールには複数のクラスが含まれ、複数のクラスがモジュールを形成します。

理解の仕方に関係なく、単一責任の原則は、この 2 つの記述オブジェクトに適用される場合には同じです。次に、「クラス」設計の観点のみから、この設計原則をどのように適用するかを決定します。「モジュール」については、それ自体で拡張できます

単一責任の原則の定義と説明は非常に単純であり、理解するのは難しくありません。クラスは、1 つの責任または機能を完了することのみを担当します。つまり、大規模で包括的なクラスを設計するのではなく、粒度が小さく単一の機能を持つクラスを設計します。別の観点からは、クラスにはビジネスに関係のない複数の機能が含まれています。つまり、その責任は十分に単一ではなく、より単一の機能とより細かい粒度を持つ複数のクラスに分割する必要があります。

たとえば、クラスには、注文の一部の操作とユーザーの一部の操作の両方が含まれています。注文とユーザーは 2 つの独立したビジネス ドメイン モデルであり、無関係な 2 つの機能を同じクラスに入れることは、単一責任の原則に違反します。単一責任の原則を満たすために、このクラスは、より細かい粒度とより単一の機能を持つ 2 つのクラスに分割する必要があります: 注文クラスとユーザー クラスです。

1.2 クラスの責任が十分かどうかを判断するには?

上記の例から、単一責任の原則を適用するのは難しくないようです。これは、例が極端であり、順序がユーザーとは無関係であることが一目でわかるからです。しかし、ほとんどの場合、クラス内のメソッドが同じタイプの関数に分類されているのか、それとも関連のない 2 つのタイプの関数に分類されているのかを判断するのはそれほど簡単ではありません。実際のソフトウェア開発では、クラスが単一の責任を持っているかどうかを判断するのは困難です。例えば:

ソーシャル製品では、次の UserInfo クラスを使用してユーザー情報を記録します。UserInfo クラスの設計は、単一責任の原則を満たしていると思いますか?

public class UserInfo {
    
    
    private long userId;
    private String username;
    private String email;
    private String telephone;
    private long createTime;
    private long lastLoginTime;
    private String avatarUrl;
    private String provinceOfAddress; // 省
    private String cityOfAddress; // 市
    private String regionOfAddress; // 区
    private String detailedAddress; // 详细地址
    // ... 省略其他属性和方法...
}

この問題については、2 つの異なる見解があります。UserInfo クラスにはユーザーに関連する情報が含まれており、すべての属性とメソッドはユーザーのビジネス モデルに属しており、単一責任の原則を満たしているという観点と、アドレス情報がUserInfo クラス, すべて それは比較的高い割合を占めており, 独立した UserAddress クラスに分割し続けることができます. UserInfo は Address 以外の他の情報のみを保持します. 分割後の 2 つのクラスの責任はより単一です.

実際、選択を行うために、特定のアプリケーション シナリオから離れることはできません。このソーシャル プロダクトで、ユーザーのアドレス情報が他の情報と同様に表示のみに使用される場合、UserInfo の現在の設計は合理的です。ただし、このソーシャル製品がよりよく開発され、後で電子商取引モジュールが製品に追加され、ユーザーのアドレス情報が電子商取引のロジスティクスでも使用される場合は、アドレス情報を UserInfo から分離し、独立してユーザー物流情報(または住所情報、受取情報など)

さらに一歩進んでください。このソーシャル製品を作っている会社がどんどん良くなれば、社内でどんどん他の製品(他のアプリと理解できる)が開発されるでしょう。同社は統一アカウント システムをサポートしたいと考えています。つまり、ユーザーは 1 つのアカウントで社内のすべての製品にログインできます。現時点では、引き続き UserInfo を分割し、本人認証に関連する情報 (Email、Telephone など) を独立したクラスに抽出する必要があります。

このことから、アプリケーション シナリオや需要背景の段階が異なると、同じクラスの責任が単一かどうかの判断が異なる可能性があると結論付けることができます。ある適用シナリオや現在の需要背景では、クラスの設計はすでに単一責任の原則を満たしているかもしれませんが、別の適用シナリオや将来の特定の需要背景を使用すると、それが満たされない可能性があり、継続する必要があります。より細かいクラスに分割

また、異なるビジネスレベルから同じクラスの設計を見ると、クラスが単一の責任を持っているかどうかについて異なる理解が得られます。たとえば、例の UserInfo クラスです。「ユーザー」という業務レベルの観点から、UserInfo に含まれる情報はユーザーに属し、単一責任の原則を満たします。「ユーザー表示情報」「アドレス情報」「ログイン認証情報」など、より細かい業務レベルで見ると、UserInfoは引き続き分割されているはずです。

要約すると、クラスの責任が十分に単一であるかどうかを評価するための明確で定量化可能な基準はなく、これは非常に主観的なものであり、善意者にはさまざまな意見があると言えます。実際、実際のソフトウェア開発では、過度に積極的で過度に設計する必要はありません。したがって、ビジネス ニーズを満たすために、最初に大まかなクラスを作成できます。ビジネスの発展に伴い、粗粒度のクラスが大きくなり、よりコード化された場合、この時点で、粗粒度のクラスをいくつかのより細粒度のクラスに分割できます。これは継続的なリファクタリングと呼ばれます

また、クラスの責任が側面から十分に単一であるかどうかを判断するのに非常に役立ついくつかのヒントもここにあります。さらに、私は個人的に、クラスが単一の責任を持っているかどうかを主観的に考えるよりも、次の判断原則の方が有益で実行可能であると考えています。

  • クラス内のコード、関数、または属性の行が多すぎると、コードの可読性と保守性に影響を与えるため、クラスの分割を検討する必要があります
  • クラスが他のクラスに依存しすぎているか、クラスに依存している他のクラスが多すぎて、高凝集低結合の設計思想に適合していないため、クラスの分割を検討する必要があります。
  • プライベート メソッドが多すぎる場合は、プライベート メソッドを新しいクラスに分離し、より多くのクラスが使用できるようにパブリック メソッドとして設定できるかどうかを検討する必要があります。これにより、コードの再利用性が向上します。
  • クラスに適切な名前を付けるのが難しい、ビジネス用語で要約するのが難しい、または Manager や Context などの一般的な単語でしか名前を付けることができないなど、クラスの責任の定義が適切でない可能性があります。十分にクリア
  • クラス内の多数のメソッドは、クラス内の特定の属性に焦点を当てています.たとえば、UserInfoの例で、メソッドの半分がアドレス情報を操作している場合、これらの属性を分割することを検討すると、対応するメソッドが出てきます.

現時点では、次のような疑問があるかもしれません。上記の判断原則では、クラス内のコード、関数、または属性の行数が多すぎる場合、単一責任の原則を満たしていない可能性があります。行数が多すぎるのは何行のコードですか? 呼び出し可能な関数と属性はいくつありますか?

実際、この質問に定量的に答えるのは簡単ではありません。比較的広く定量化可能な基準は、クラスのコード行数が 200 を超えないようにし、関数と属性の数が 10 を超えないようにすることです。実は別の見方をすれば、あるクラスのコードを読んでいると頭がクラクラしたり、ある機能を実装する際にどの機能を使えばいいのかわからなくなったり、欲しい機能が見つからなかったりします。クラス全体に小さな関数を導入する必要がある場合 (クラスには、この関数の実装に関係のない多くの関数が含まれています)、それはクラスの行数、関数、および属性の数が多すぎることを意味します。多くの

1.3 クラスの責任はできるだけ単純ですか?

単一責任の原則を満たすために、クラスをより小さな部分に分割する方が良いですか? 答えは否定的です。In the following example, the Serialization class implements the serialization and deserialization functions of a simple protocol. 具体的なコードは次のとおりです。

public class Serialization {
    
    
    private static final String IDENTIFIER_STRING = "UEUEUE;";
    private Gson gson;

    public Serialization() {
    
    
        this.gson = new Gson();
    }
    public String serialize(Map<String, String> object) {
    
    
        StringBuilder textBuilder = new StringBuilder();
        textBuilder.append(IDENTIFIER_STRING);
        textBuilder.append(gson.toJson(object));
        return textBuilder.toString();
    }
    public Map<String, String> deserialize(String text) {
    
    
        if (!text.startsWith(IDENTIFIER_STRING)) {
    
    
            return Collections.emptyMap();
        }
        String gsonStr = text.substring(IDENTIFIER_STRING.length());
        return gson.fromJson(gsonStr, Map.class);
    }
}

クラスの責任をより単一にしたい場合は、Serialization クラスを、シリアライゼーションのみを担当する Serializer クラスと、逆シリアル化のみを担当する別の Deserializer クラスにさらに分割します。分割後の具体的なコードは次のとおりです。

public class Serializer {
    
    
    private static final String IDENTIFIER_STRING = "UEUEUE;";
    private Gson gson;

    public Serializer() {
    
    
        this.gson = new Gson();
    }
    public String serialize(Map<String, String> object) {
    
    
        StringBuilder textBuilder = new StringBuilder();
        textBuilder.append(IDENTIFIER_STRING);
        textBuilder.append(gson.toJson(object));
        return textBuilder.toString();
    }
}

public class Deserializer {
    
    
    private static final String IDENTIFIER_STRING = "UEUEUE;";
    private Gson gson;

    public Deserializer() {
    
    
        this.gson = new Gson();
    }
    public Map<String, String> deserialize(String text) {
    
    
        if (!text.startsWith(IDENTIFIER_STRING)) {
    
    
            return Collections.emptyMap();
        }
        String gsonStr = text.substring(IDENTIFIER_STRING.length());
        return gson.fromJson(gsonStr, Map.class);
    }
}

分割後、Serializer クラスと Deserializer クラスの責任はより単一になりますが、新しい問題も発生します。プロトコルの形式が変更された場合、データ識別子が「UEUEUE」から「DFDFDF」に変更された場合、またはシリアライゼーション方法が JSON から XML に変更された場合、Serializer クラスと Deserializer クラスの両方を適宜変更する必要があります。コードのまとまりは明らかに以前ほど良くありません。また、Serializer クラスのプロトコルを変更しただけで、Deserializer クラスのコードを変更するのを忘れると、シリアライズとデシリアライズが一致せず、プログラムが正しく動作しなくなります。コードセックスの保守性が悪い

実際、設計原則または設計パターンのいずれを適用する場合でも、最終的な目標は、コードの可読性、スケーラビリティ、再利用性、および保守性を向上させることです。特定の設計原則を適用することが合理的かどうかを検討する場合、最終的な考慮事項としても使用できます。

2. 開閉原理 (OCP)

著者は個人的に、開閉の原則が SOLID で最も理解し、習得するのが難しいと感じていますが、最も有用な原則でもあります。

  • この原則がわかりにくいのは、「どのようなコード変更を『拡張』と定義し、どのようなコード変更を『改変』と定義し、どのように『開閉原則』を満たしたり、違反したりするか」という理由によるものです。コードを変更することは、必然的に「開閉原則」に違反することを意味するのでしょうか?」 これらの質問は理解しにくいものです。
  • この原則がわかりにくいのは、「『拡張機能の開発と修正のクローズ』をどのように実現するか?スケーラビリティ・可読性を追求しながら、コードに影響を与えないように『オープン・クローズの原則』をプロジェクトに柔軟に適用するにはどうすればよいか」という理由によるものです。など、これらの質問は把握するのがより困難です
  • この原則が最も役立つ理由は、スケーラビリティがコード品質の最も重要な尺度の 1 つであるためです。23 の古典的なデザイン パターンの中で、ほとんどのデザイン パターンはコードのスケーラビリティの問題を解決するために存在し、従うべき主なデザイン原則は開閉の原則です。

2.1 「拡張のためのオープン、変更のためのクローズ」を理解する方法は?

開閉原理の英語の正式名称は Open Closed Principle、略して OCP です。

ソフトウェア エンティティ (モジュール、クラス、関数など) は、拡張に対してオープンである必要がありますが、変更に対してクローズされている必要があります.
ソフトウェア エンティティ (モジュール、クラス、メソッドなど) は、「拡張に対してオープンで、変更に対してクローズ」であるべきです.

ここまでの説明は比較的簡単ですが、具体的に表現すると、新しい機能を追加するということは、既存のコードを変更する(変更するモジュール、クラス、メソッドなど)。例として、これは API インターフェイスでアラームを監視するためのコードです。

その中で AlertRule はアラートルールを格納し、自由に設定することができます。通知は、電子メール、SMS、WeChat、携帯電話などの複数の通知チャネルをサポートするアラーム通知カテゴリです。NotificationEmergencyLevel は、SEVERE (重大)、URGENCY (緊急)、NORMAL (通常)、および TRIVIAL (無関係) を含む、通知の緊急度を示します。さまざまな緊急度レベルは、さまざまな配信チャネルに対応します。

public class Alert {
    
    
    private AlertRule rule;
    private Notification notification;

    public Alert(AlertRule rule, Notification notification) {
    
    
        this.rule = rule;
        this.notification = notification;
    }
    public void check(String api, long requestCount, long errorCount, long duration) {
    
    
        long tps = requestCount / durationOfSeconds;
        if (tps > rule.getMatchedRule(api).getMaxTps()) {
    
    
            notification.notify(NotificationEmergencyLevel.URGENCY, "...");
        }
        if (errorCount > rule.getMatchedRule(api).getMaxErrorCount()) {
    
    
            notification.notify(NotificationEmergencyLevel.SEVERE, "...");
        }
    }
}

上記のコードは非常に単純で、ビジネス ロジックは主にcheck()function。インターフェイスの TPS が事前に設定された最大値を超えた場合、およびインターフェイス要求エラーの数が特定の最大許容値を超えた場合、アラームがトリガーされ、関連する担当者またはインターフェイスのチームに通知されます。

機能を追加する必要がある場合、1 秒あたりのインターフェイス タイムアウト リクエストの数が事前に設定された最大しきい値を超えると、通知を送信するアラームもトリガーされます。このとき、コードを変更するにはどうすればよいですか?主な変更点は 2 つあります。1 つ目は、check()関数。2 つ目はcheck()、関数に新しいアラーム ロジックを追加することです。具体的なコードの変更は次のとおりです。

public class Alert {
    
    
    // ... 省略 AlertRule/Notification 属性和构造函数...
    // 改动一:添加参数 timeoutCount
    public void check(String api, long requestCount, long errorCount, long timeoutCount) {
    
    
        long tps = requestCount / durationOfSeconds;
        if (tps > rule.getMatchedRule(api).getMaxTps()) {
    
    
            notification.notify(NotificationEmergencyLevel.URGENCY, "...");
        }
        if (errorCount > rule.getMatchedRule(api).getMaxErrorCount()) {
    
    
            notification.notify(NotificationEmergencyLevel.SEVERE, "...");
        }
        // 改动二:添加接口超时处理逻辑
        long timeoutTps = timeoutCount / durationOfSeconds;
        if (timeoutTps > rule.getMatchedRule(api).getMaxTimeoutTps()) {
    
    
            notification.notify(NotificationEmergencyLevel.URGENCY, "...");
        }
    }
}

このようなコードの変更には、実際にはかなりの問題があります。一方では、インターフェイスが変更されています。つまり、このインターフェイスを呼び出すコードをそれに応じて変更する必要があります。一方、check()関数、対応する単体テストを変更する必要があります

上記のコード変更は、新しい機能を実現するための「修正」方法に基づいています。オープンとクローズの原則、つまり「拡張にはオープン、変更にはクローズ」に従う場合。では、「拡張」によって同じ機能を実現するにはどうすればよいでしょうか。

最初に、以前の Alert コードをリファクタリングして、よりスケーラブルにします。リファクタリングの内容には、主に次の 2 つの部分が含まれます。

  1. check()関数の複数の入力パラメーターを ApiStatInfo クラスにカプセル化します。
  2. ハンドラの概念を導入し、if 判定ロジックを各ハンドラに分散

具体的なコードの実装は次のとおりです。

public class Alert {
    
    
    private List<AlertHandler> alertHandlers = new ArrayList<>();

    public void addAlertHandler(AlertHandler alertHandler) {
    
    
        this.alertHandlers.add(alertHandler);
    }
    public void check(ApiStatInfo apiStatInfo) {
    
    
        for (AlertHandler handler : alertHandlers) {
    
    
            handler.check(apiStatInfo);
        }
    }
}
public class ApiStatInfo {
    
    // 省略 constructor/getter/setter 方法
    private String api;
    private long requestCount;
    private long errorCount;
    private long durationOfSeconds;
}

public abstract class AlertHandler {
    
    
    protected AlertRule rule;
    protected Notification notification;

    public AlertHandler(AlertRule rule, Notification notification) {
    
    
        this.rule = rule;
        this.notification = notification;
    }
    public abstract void check(ApiStatInfo apiStatInfo);
}
public class TpsAlertHandler extends AlertHandler {
    
    
    public TpsAlertHandler(AlertRule rule, Notification notification) {
    
    
        super(rule, notification);
    }
    @Override
    public void check(ApiStatInfo apiStatInfo) {
    
    
        long tps = apiStatInfo.getRequestCount()/ apiStatInfo.getDurationOfSeconds
        if (tps > rule.getMatchedRule(apiStatInfo.getApi()).getMaxTps()) {
    
    
            notification.notify(NotificationEmergencyLevel.URGENCY, "...");
        }
    }
}
public class ErrorAlertHandler extends AlertHandler {
    
    
    public ErrorAlertHandler(AlertRule rule, Notification notification){
    
    
        super(rule, notification);
    }
    @Override
    public void check(ApiStatInfo apiStatInfo) {
    
    
        if (apiStatInfo.getErrorCount() > rule.getMatchedRule(apiStatInfo.getApi()) {
    
    
            notification.notify(NotificationEmergencyLevel.SEVERE, "...");
        }
    }
}

上記のコードは Alert のリファクタリングです。リファクタリングされた Alert の使用方法は? 次のように、ApplicationContext は、Alert の作成、アセンブリ (alertRule と通知の依存性注入)、および初期化 (ハンドラーの追加) を担当するシングルトン クラスです。

public class ApplicationContext {
    
    
    private AlertRule alertRule;
    private Notification notification;
    private Alert alert;

    public void initializeBeans() {
    
    
        alertRule = new AlertRule(/*. 省略参数.*/); // 省略一些初始化代码
        notification = new Notification(/*. 省略参数.*/); // 省略一些初始化代码
        alert = new Alert();
        alert.addAlertHandler(new TpsAlertHandler(alertRule, notification));
        alert.addAlertHandler(new ErrorAlertHandler(alertRule, notification));
    }
    public Alert getAlert() {
    
     return alert; }
    // 饿汉式单例
    private static final ApplicationContext instance = new ApplicationContext();
    private ApplicationContext() {
    
    
        instance.initializeBeans();
    }
    public static ApplicationContext getInstance() {
    
    
        return instance;
    }
}

public class Demo {
    
    
    public static void main(String[] args) {
    
    
        ApiStatInfo apiStatInfo = new ApiStatInfo();
        // ... 省略设置 apiStatInfo 数据值的代码
        ApplicationContext.getInstance().getAlert().check(apiStatInfo);
    }
}

もう一度見てみましょう. リファクタリングされたコードに基づいて, 上記の新しい機能が追加された場合, 1 秒あたりのインターフェイスのタイムアウト要求の数が特定の最大しきい値を超えたときにアラームが発行されます. コードを変更するにはどうすればよいですか? 主な変更点は次のとおりです。

  1. ApiStatInfo クラスに新しいプロパティ timeoutCount を追加します
  2. 新しい TimeoutAlertHander クラスを追加
  3. ApplicationContext クラスの initializeBeans() メソッドで、アラート オブジェクトに新しい timeoutAlertHandler を登録します。
  4. Alert クラスを使用する場合、check()関数
public class Alert {
    
     // 代码未改动... }

public class ApiStatInfo {
    
    // 省略 constructor/getter/setter 方法
    private String api;
    private long requestCount;
    private long errorCount;
    private long durationOfSeconds;
    private long timeoutCount; // 改动一:添加新字段
}
public abstract class AlertHandler {
    
     // 代码未改动... }
public class TpsAlertHandler extends AlertHandler {
    
     // 代码未改动...}
public class ErrorAlertHandler extends AlertHandler {
    
     // 代码未改动...}

// 改动二:添加新的 handler
public class TimeoutAlertHandler extends AlertHandler {
    
     // 省略代码...}

public class ApplicationContext {
    
    
    private AlertRule alertRule;
    private Notification notification;
    private Alert alert;

    public void initializeBeans() {
    
    
        alertRule = new AlertRule(/*. 省略参数.*/); // 省略一些初始化代码
        notification = new Notification(/*. 省略参数.*/); // 省略一些初始化代码
        alert = new Alert();
        alert.addAlertHandler(new TpsAlertHandler(alertRule, notification));
        alert.addAlertHandler(new ErrorAlertHandler(alertRule, notification));
        // 改动三:注册 handler
        alert.addAlertHandler(new TimeoutAlertHandler(alertRule, notification));
    }
    //... 省略其他未改动代码...
}
public class Demo {
    
    
    public static void main(String[] args) {
    
    
        ApiStatInfo apiStatInfo = new ApiStatInfo();
        // ... 省略 apiStatInfo 的 set 字段代码
        apiStatInfo.setTimeoutCount(289); // 改动四:设置 tiemoutCount 值
        ApplicationContext.getInstance().getAlert().check(apiStatInfo);
    }
}

リファクタリングされたコードは、より柔軟で拡張可能です。新しいアラーム ロジックを追加する場合は、元のcheck()関数。さらに、新しいハンドラー クラスの単体テストを追加するだけでよく、古い単体テストは失敗せず、変更する必要もありません。

2.2 コードを変更することは、開閉の原則に違反することを意味しますか?

上記のリファクタリングされたコードを読んだ後、まだ疑問があるかもしれません: 新しいアラーム ロジックを追加する場合、変更 2 (新しいハンドラー クラスの追加) は変更ではなく拡張に基づいて行われますが、変更 1、3 および 4 はベースに基づいていないようです。拡張ではあるが、変更では 1、3、4 を変更すると、開始と終了の原則に違反しませんか?

1. 変更 1: 新しい属性 timeoutCount を ApiStatInfo クラスに追加します

実際、ApiStatInfo クラスにはプロパティが追加されるだけでなく、対応する getter/setter メソッドも追加されます。次に、この質問は次のように変換されます。クラスに新しいプロパティとメソッドを追加することは、「変更」または「拡張」としてカウントされますか?

Open-Closed Principle の定義: ソフトウェア エンティティ (モジュール、クラス、メソッドなど) は、「拡張に対してオープンで、変更に対してクローズ」である必要があります。定義から、オープンとクローズの原則は、モジュール、クラス、またはメソッド (およびそれらの属性) など、さまざまな粒度のコードに適用できることがわかります。同じコード変更は、粗いコード粒度では「変更」として識別でき、細かいコード粒度では「拡張」として識別できます。たとえば、変更 1 では、属性とメソッドを追加することは、クラスを変更することと同じです. クラスのレベルでは、このコード変更は「変更」として識別できます. しかし、このコード変更は既存の属性とメソッドを変更しません.このレベルのメソッド (およびその属性) は、「拡張」として識別できます。

実際、コードの変更が「改変」なのか「拡張」なのか、ましてや「開閉原則」に違反していないかなどを気にする必要はありません。この原則の元の設計意図に戻ります。元のコードの通常の動作と元の単体テストを破壊しない限り、これは認定されたコード変更であると言えます。

2. 修正 3 および修正 4: ApplicationContext クラスのinitializeBeans()メソッド、alert オブジェクトに新しい timeoutAlertHandler を登録する; Alert クラスを使用する場合は、check()関数入力パラメータの apiStatInfo オブジェクトに timeoutCount の値を設定する必要がある

これらの 2 つの変更は、どのレベル (モジュール、クラス、メソッド) からであっても、メソッド内で行われます。それらは「拡張」と見なすことはできず、徹底的な「変更」と見なすことができます。ただし、一部の変更は避けられず、許容されます。なぜそう言うのですか?

リファクタリングされた Alert コードでは、コア ロジックは Alert クラスとそのハンドラに集中しています。新しいアラート ロジックを追加する場合、Alert クラスを変更する必要はまったくなく、新しいハンドラ クラスを拡張するだけで済みます。Alert クラスと各ハンドラ クラスを「モジュール」と見なすと、モジュール自体は、新しい関数を追加する際のオープンとクローズの原則を完全に満たします。

さらに、モジュール、クラス、またはメソッドのコードを「変更」せずに新しい関数を追加することは不可能です。クラスを実行可能なプログラムに組み込む前に、クラスを作成、アセンブル、および初期化する必要があるため、コードのこの部分の変更は避けられません。やるべきことは、変更操作をより集中させ、より少なく、より高レベルにし、ロジックコードのコアと最も複雑な部分がオープンとクローズの原則を満たすようにすることです。

2.3 「拡張用に開き、変更用に閉じる」を実現する方法は?

先ほどの例では、ハンドラーのセットを導入することで、開閉の原則がサポートされています。複雑なコードの設計と開発の経験があまりない場合、そのようなコード設計のアイデアは想像できないかもしれません。ご想像のとおり、それは理論的な知識と実践的な経験に依存しており、ゆっくりと学び、蓄積する必要があります。

実際、オープンとクローズの原則はコードのスケーラビリティに関するものであり、コードが拡張しやすいかどうかを判断するための「ゴールド スタンダード」です。将来の要件の変更に対応するときに、特定のコードが「拡張に対してオープンで変更に対してクローズ」できる場合、そのコードはより優れたスケーラビリティを持っていることを意味します。したがって、「拡張に対してオープンで変更に対してクローズ」である方法を尋ねることは、スケーラブルなコードの書き方を尋ねることとほぼ同じです。

特定の方法論について話す前に、トップレベルの指針となるイデオロギーを見てみましょう。できるだけスケーラビリティの良いコードを書くためには、拡張、抽象化、カプセル化を常に意識しなければなりません。これらの「潜在意識」は、開発スキルよりも重要かもしれません

コードを書いた後、将来このコードでどのような要件が変更される可能性があるか、コード構造をどのように設計するか、および拡張ポイントを事前に予約して、将来の要件が変更されたときに変更が不要になるように、より多くの時間をかけて考えます。コードと最小限のコードの変更、新しいコードを柔軟に拡張ポイントに挿入して、「拡張に対してオープンで、変更に対してクローズ」にすることができます。

また、コードの可変部分と不変部分を識別した後、可変部分をカプセル化し、変更を分離し、上位システムで使用する抽象的な不変インターフェイスを提供する必要があります。特定の実装が変更された場合、同じ抽象インターフェイスに基づいて新しい実装を拡張し、古い実装を置き換えるだけでよく、上流システムのコードを変更する必要はほとんどありません。

オープンとクローズの原則を実現するためのいくつかのトップレベルの指針となるイデオロギーについて話した後、オープンとクローズの原則をサポートするいくつかのより具体的な方法論を見てみましょう

前述したように、コードのスケーラビリティは、コード品質評価の最も重要な基準の 1 つです。実際、多くの設計原則、設計アイデア、および設計パターンは、コードのスケーラビリティを向上させることを目的としています。特に、コードのスケーラビリティの問題を解決するためにほとんどがまとめられている 23 の古典的なデザイン パターンは、開閉の原則に基づいています。

多くの設計原則、アイデア、およびパターンの中で、コードのスケーラビリティを向上させるために最も一般的に使用される方法は、ポリモーフィズム、依存性注入、実装ではなくインターフェースに基づくプログラミング、およびほとんどの設計パターン (装飾、戦略、テンプレート、チェーンなど) です。責任、州など)。次に、ポリモーフィズム、依存性注入、および実装ではなくインターフェースに基づくプログラミングを使用して、「拡張にオープン、変更にクローズ」を実現する方法に焦点を当てます。

実際、ポリモーフィズム、依存性注入、実装ではなくインターフェイスに基づくプログラミング、および前述の抽象的な意識はすべて同じ設計思想を参照していますが、異なる角度とレベルから説明されています。これはまた、「多くのデザイン原則、アイデア、およびパターンが相互に関連している」という考えを反映しています。

次の例では、コード内の Kafka を介して非同期メッセージが送信されます。このような関数を開発するには、特定のメッセージ キュー (Kafka) とは関係のない一連の非同期メッセージ インターフェイスに関数を抽象化する方法を学びます。すべての上位レベルのシステムは、この一連の抽象インターフェイス プログラミングに依存し、依存性注入を通じてそれらを呼び出します。Kafka を RocketMQ に置き換えるなど、新しいメッセージ キューを置き換える場合、古いメッセージ キューの実装を簡単に取り外して、新しいメッセージ キューの実装を挿入できます。具体的なコードは次のとおりです。

// 这一部分体现了抽象意识
public interface MessageQueue {
    
     //... }
public class KafkaMessageQueue implements MessageQueue {
    
     //... }
public class RocketMQMessageQueue implements MessageQueue {
    
    //...}

public interface MessageFromatter {
    
     //... }
public class JsonMessageFromatter implements MessageFromatter {
    
    //...}
public class ProtoBufMessageFromatter implements MessageFromatter {
    
    //...}

public class Demo {
    
    
    private MessageQueue msgQueue; // 基于接口而非实现编程

    public Demo(MessageQueue msgQueue) {
    
     // 依赖注入
        this.msgQueue = msgQueue;
    }
    // msgFormatter:多态、依赖注入
    public void sendNotification(Notification notification, MessageFormatter msg) {
    
    
        //...
    }
}

2.4 プロジェクトの開始と終了の原則を柔軟に適用する方法は?

「拡張用にオープン、変更用にクローズ」をサポートするコードを作成するための鍵は、拡張ポイントを予約することです。問題は、考えられるすべての拡張ポイントをどのように特定するかということです。

金融システム、電子商取引システム、物流システムなどのビジネス指向のシステムを開発している場合、できるだけ多くの拡張ポイントを特定したい場合は、そのビジネスを十分に理解している必要があります。現在および将来のサポート ビジネス ニーズを知ることができます。フレームワークやコンポーネント、クラスライブラリなど、業務に依存しない汎用的な低レベルシステムを開発する場合、「それらがどのように使われるのか? 今後どのような機能を追加する予定なのか?」を理解する必要があります。ユーザーは将来どのような機能を必要としますか?」などの質問があります。

しかし、「唯一不変のものは変化そのもの」ということわざがあります。業務やシステムを十分に理解していても、すべての拡張ポイントを特定することは不可能です. すべての拡張ポイントを特定し、それらの場所の拡張ポイントを予約できたとしても、そのためのコストは許容できません. 遠く離れた信頼性の低い要求に対して、事前に支払いをしたり過剰に設計したりする必要はありません。

最も合理的なアプローチは、短期的に拡張される可能性のある比較的特定の状況、または要件の変更がコード構造に大きな影響を与える場合、または実装に費用がかからない拡張ポイントについて、コードを記述した後に、スケーラビリティの設計を進めることができます。ただし、将来サポートされるかどうかわからない要件や、実装がより複雑な拡張ポイントについては、需要主導型になるまで待ってから、コードをリファクタリングすることで拡張要件をサポートできます。

また、オープンクローズの原則は無料ではありません。場合によっては、コードのスケーラビリティが可読性と競合します。たとえば、前の Alert の例です。スケーラビリティをよりよくサポートするために、コードがリファクタリングされました. リファクタリングされたコードは以前のコードよりもはるかに複雑であり、理解するのがより困難です. 多くの場合、スケーラビリティと読みやすさの間にはトレードオフがあります。一部のシナリオでは、コードのスケーラビリティが非常に重要であり、一部のコードの可読性を適切に犠牲にすることができます。別のシナリオでは、コードの可読性がより重要であるため、一部のコードのスケーラビリティを適切に犠牲にすることができます。

前のアラートの例では、アラート ルールが多くなく複雑でない場合、check()関数複雑ではなく、コードの行数も多くありません。この種のコード実装のアイデアはシンプルで読みやすく、より合理的な選択です。逆に、アラームルールが多くて複雑な場合、check()関数の if 文やコードロジックが多く複雑になり、対応するコード行数も多くなり、可読性や保守性が低下します。リファクタリングされた 2 番目のコード実装のアイデアは、より合理的な選択です

3. 嘘の代用 (LSP)

3.1 「Li式置換原理」を理解するには?

Liskov Substitution Principle の英訳は、Liskov Substitution Principle、略して LSP です。この原則は、1986 年に Barbara Liskov によって最初に提案されました。元のテキストは次のとおりです。

S が T のサブタイプである場合、T 型のオブジェクトは、プログラムを中断することなく、S 型のオブジェクトに置き換えることができます。

1996 年、Robert Martin は彼の SOLID 原則でこの原則を再説明しました。元のテキストは次のとおりです。

基本クラスへの参照のポインターを使用する関数は、それを知らずに派生クラスのオブジェクトを使用できなければなりません。

综合两者的描述,翻译成中文即:子类对象(object of subtype/derived class)能够替换程序(program)中父类对象(object of base/parent class)出现的任何地方,并且保证原来程序的逻辑行为(behavior)不变及正确性不被破坏

如下例,父类 Transporter 使用 org.apache.http 库中的 HttpClient 类来传输网络数据。子类 SecurityTransporter 继承父类 Transporter,增加了额外的功能,支持传输 appId 和 appToken 安全认证信息

public class Transporter {
    
    
    private HttpClient httpClient;

    public Transporter(HttpClient httpClient) {
    
    
        this.httpClient = httpClient;
    }
    public Response sendRequest(Request request) {
    
    
        // ...use httpClient to send request
    }
}

public class SecurityTransporter extends Transporter {
    
    
    private String appId;
    private String appToken;

    public SecurityTransporter(HttpClient httpClient, String appId, String appToken) {
    
    
        super(httpClient);
        this.appId = appId;
        this.appToken = appToken;
    }
    @Override
    public Response sendRequest(Request request) {
    
    
        if (StringUtils.isNotBlank(appId) && StringUtils.isNotBlank(appToken)) {
    
    
            request.addPayload("app-id", appId);
            request.addPayload("app-token", appToken);
        }
        return super.sendRequest(request);
    }
}

public class Demo {
    
    
    public void demoFunction(Transporter transporter) {
    
    
        Reuqest request = new Request();
        //... 省略设置 request 中数据值的代码...
        Response response = transporter.sendRequest(request);
        //... 省略其他逻辑...
    }
}
// 里式替换原则
Demo demo = new Demo();
demo.demofunction(new SecurityTransporter(/* 省略参数 */););

在上面的代码中,子类 SecurityTransporter 的设计完全符合里式替换原则,可以替换父类出现的任何位置,并且原来代码的逻辑行为不变且正确性也没有被破坏

这样一看,刚刚的代码设计不就是简单利用了面向对象的多态特性吗?多态和里式替换原则说的是不是一回事呢?从刚刚的例子和定义描述来看,里式替换原则跟多态看起来确实有点类似,但实际上它们完全是两回事。为什么这么说呢?

假如需要对 SecurityTransporter 类中 sendRequest() 函数稍加改造一下。改造前,如果 appId 或者 appToken 没有设置,就不做校验;改造后,如果 appId 或者 appToken 没有设置,则直接抛出NoAuthorizationRuntimeException 未授权异常。改造前后的代码对比如下所示:

// 改造前:
public class SecurityTransporter extends Transporter {
    
    
    //... 省略其他代码..
    @Override
    public Response sendRequest(Request request) {
    
    
        if (StringUtils.isNotBlank(appId) && StringUtils.isNotBlank(appToken)) {
    
    
            request.addPayload("app-id", appId);
            request.addPayload("app-token", appToken);
        }
        return super.sendRequest(request);
    }
}

// 改造后:
public class SecurityTransporter extends Transporter {
    
    
    //... 省略其他代码..
    @Override
    public Response sendRequest(Request request) {
    
    
        if (StringUtils.isBlank(appId) || StringUtils.isBlank(appToken)) {
    
    
            throw new NoAuthorizationRuntimeException(...);
        }
        request.addPayload("app-id", appId);
        request.addPayload("app-token", appToken);
        return super.sendRequest(request);
    }
}

変更されたコードでは、親クラスの Transporter オブジェクトが demoFunction() 関数に渡された場合、demoFunction()関数は例外をスローしませんが、demoFunction()サブクラスの SecurityTransporter オブジェクトが関数に渡された場合demoFunction()、例外がスローされる場合があります。コードでスローされる例外はランタイム例外 (Runtime Exception) ですが、コードで明示的にキャプチャして処理する必要はありませんが、サブクラスが親クラスを置き換えて demoFunction 関数に渡すと、プログラム全体が変わります

変更されたコードは引き続き Java のポリモーフィック構文を使用して、親クラスの Transporter をサブクラスの SecurityTransporter に動的に置き換えることができますが、プログラムのコンパイルまたは実行時にエラーは発生しません。ただし、設計思想に関しては、SecurityTransporter の設計は Li スタイルの置き換えの原則に準拠していません。

ポリモーフィズムと Li 型置換は、定義の記述とコードの実装という点では多少似ていますが、異なる角度に焦点を当てています。ポリモーフィズムは、オブジェクト指向プログラミングの主要な機能であり、オブジェクト指向プログラミング言語の構文です。コード実装のアイデアです。Li スタイルの置換は、継承関係でサブクラスを設計する方法をガイドするために使用される設計原則です. サブクラスの設計は、親クラスを置換するときに、元のプログラムのロジックが変更されず、元のプログラムが変更されないようにする必要があります.破壊されません。

3.2 明らかに LSP に違反しているコードは?

実際、Li スタイルの交換原理には、より実用的で有益な別の説明があり、それは「契約による設計」であり、中国語の翻訳は「合意による設計」です。

それは抽象的なように見えますが、さらに解釈すると、サブクラスを設計するときは、親クラスの動作合意 (または合意) に従わなければなりません。親クラスは関数の動作契約を定義し、サブクラスは関数の内部実装ロジックを変更できますが、関数の元の動作契約を変更することはできません。ここでの振る舞いの合意には、関数宣言によって実現される関数、入力、出力、および例外に関する合意、およびコメントに記載されている特別な指示も含まれます。実際、定義における親クラスと子クラスの関係は、インターフェースと実装クラスの関係に置き換えることもできます。Li スタイルの置換原則の違反の例をいくつか以下に示します。

1. サブクラスが親クラスで宣言された関数に違反している

親クラスが提供するsortOrdersByAmount()注文注文を少額から大口の順にソートし、サブクラスはsortOrdersByAmount()注文ソート機能を書き換えて、作成日に従って注文をソートします。サブクラスの設計は、Li スタイルの置換原則に違反しています

2. サブクラスが、入力、出力、および例外に関する親クラスの合意に違反している

親クラスでは、ある関数規約で、操作が失敗するとnullを返し、取得したデータが空の場合は空のコレクション(空コレクション)を返す。サブクラスが関数をオーバーロードした後、実装が変更され、操作が失敗した場合は例外が返され、データを取得できない場合は null が返されます。サブクラスの設計は、Li スタイルの置換原則に違反しています

親クラスでは、関数は入力データが任意の整数であることに同意しますが、サブクラスが実装すると、入力データは正の整数のみが許可され、負の数がスローされます、つまりチェック率親クラスがより厳密な場合、サブクラスの設計はLiスタイルの置換原則に違反します

In the parent class, a certain function contract will only throw ArgumentNullException exceptions, and the design and implementation of the subclass only allow ArgumentNullException exceptions to be throw. スローされた他の例外は、サブクラスが Li 型置換原則に違反する原因となります。

3. サブクラスが、親クラスの注釈に記載されている特別な指示に違反している

親クラスで定義されたwithdraw()現金のコメントは「ユーザーの現金引き出し額は口座残高を超えてはならない…」と書かれてwithdraw()おり、サブクラスで関数を書き換えた後、当座貸越機能はVIP アカウント、つまり、引き出し額がアカウント残高よりも多い場合、このサブクラスの設計は Li スタイルの交換の原則に準拠していません。

上記は、Li スタイルの置換の原則に違反する 3 つの典型的な状況です。さらに、サブクラスの設計と実装が Li スタイルの置換の原則に違反しているかどうかを判断する別のトリックがあります。つまり、親クラスの単体テストを使用してサブクラスのコードを検証します。一部の単体テストの実行に失敗した場合、サブクラスの設計と実装が親クラスの規約に完全に従っておらず、サブクラスが Li スタイルの置換原則に違反している可能性があることを示している可能性があります。

実際、Li スタイルの置換の原則は非常に緩いです。一般的に、違反する可能性はあまりありません

4. インターフェイス分離の原則 (ISP)

4.1 「界面分離原理」を理解するには?

インターフェイス分離の原則の英訳は「Interface Segregation Principle」、略して ISP です。Robert Martin は、SOLID 原則で次のように定義しています。

クライアントは、使用しないインターフェースに依存することを強制されるべきではありません.
クライアントは、使用しないインターフェースに依存することを強制されるべきではありません.

「クライアント」は、インターフェースの呼び出し元またはユーザーとして理解できます

実際、「インターフェース」という用語は多くの状況で使用できます。ソフトウェア開発では、一連の抽象的な規則と見なすことも、システム間の API インターフェイスを具体的に参照することも、オブジェクト指向プログラミング言語のインターフェイスを具体的に参照することもできます。インターフェース分離の原理を理解する鍵は、「インターフェース」という言葉を理解することです。この原則では、「インターフェイス」は次の 3 つとして理解できます。

  • API インターフェイスのコレクション
  • 単一の API インターフェイスまたは関数
  • OOP のインターフェイスの概念

4.1.1 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 {
    
    
    // ... 省略实现代码...
}

在刚刚的这个例子中,把接口隔离原则中的接口,理解为一组接口集合,它可以是某个微服务的接口,也可以是某个类库的接口等等。在设计微服务或者类库接口的时候,如果部分接口只被部分调用者使用,那就需要将这部分接口隔离出来,单独给对应的调用者使用,而不是强迫其他调用者也依赖这部分不会被用到的接口

4.1.2 把“接口”理解为单个 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()関数合理的です。反対に、各統計要件が統計にリストされている統計情報の一部のみを含む場合、たとえば、最大、最小、平均などの 3 種類の統計情報のみを使用する必要がある場合もあれば、平均と最小のみを使用する必要がある場合もあります。和。count()この関数は毎回すべての統計情報を計算し、多くの無駄な作業を行います。これは、特にカウントするデータの量が多い場合に、必然的にコードのパフォーマンスに影響を与えます。したがって、このアプリケーション シナリオでは、count()関数の設計は少し不合理であり、2 番目の設計案に従って、より細かい粒度で複数の統計関数に分割する必要があります。

ここでは、インターフェース分離の原則が単一責任の原則と多少似ていることがわかりますが、それでもいくつかの違いがあります。単一責任の原則は、モジュール、クラス、およびインターフェースの設計を目的としています。単一責任の原則と比較して、インターフェース分離の原則は、一方ではインターフェースの設計により重点を置き、他方では別の観点から考えます。インターフェイスが単一の責任を持っているかどうかを判断するための基準を提供します。つまり、呼び出し元がインターフェイスをどのように使用するかによって間接的に判断します。呼び出し元がインターフェイスの一部またはインターフェイスの機能の一部のみを使用する場合、インターフェイスの設計は単一の責任を持つには十分ではありません

4.1.3 OOPにおけるインターフェースの概念として「インターフェース」を理解する

次の例のように、Java のインターフェイスなど、OOP のインターフェイスの概念として「インターフェイス」を理解します。

プロジェクトで、Redis、MySQL、および Kafka の 3 つの外部システムが使用されているとします。各システムは、アドレス、ポート、アクセス タイムアウトなどの一連の構成情報に対応しています。プロジェクト内の他のモジュールで使用するためにこれらの構成情報をメモリに保存するために、RedisConfig、MysqlConfig、および KafkaConfig の 3 つの構成クラスが設計および実装されています。次のように:

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.newSingleThread
    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);
        redisConfigUpdater.run();
        ScheduledUpdater kafkaConfigUpdater = new ScheduledUpdater(kafkaConfig, 60);
        redisConfigUpdater.run();
    }
}

刚刚的热更新的需求已经搞定了。现在,又有了一个新的监控功能需求。通过命令行来查看 Zookeeper 中的配置信息是比较麻烦的。所以,希望能有一种更加方便的配置信息查看方式

可以在项目中开发一个内嵌的 SimpleHttpServer,输出项目的配置信息到一个固定的 HTTP 地址,比如:http://127.0.0.1:2389/config 。只需要在浏览器中输入这个地址,就可以显示出系统的配置信息。不过,出于某些原因,只想暴露 MySQL 和 Redis 的配置信息,不想暴露 Kafka 的配置信息。为了实现这样一个功能,还需要对上面的代码做进一步改造。改造之后的代码如下所示:

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,2
        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 period) {
    
    
        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 () {
    
     //... }
}

这样的设计思路也是能工作的,但是对比前后两个设计思路,在同样的代码量、实现复杂度、同等可读性的情况下,第一种设计思路显然要比第二种好很多。为什么这么说呢?主要有两点原因

1、第一种设计思路更加灵活、易扩展、易复用

因为 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, 2
        simpleHttpServer.addViewer("/config", redisConfig);
        simpleHttpServer.addViewer("/config", mySqlConfig);
        simpleHttpServer.addViewer("/metrics", apiMetrics);
        simpleHttpServer.addViewer("/metrics", dbMetrics);
        simpleHttpServer.run();
    }
}

2、第二种设计思路在代码实现上做了一些无用功

Config インターフェイスには 2 種類の無関係なインターフェイスが含まれているため、1 つは で、もうupdate()1 つは です理論的には、KafkaConfig はインターフェース、関連するインターフェースは必要ありません。同様に、MysqlConfig は関連する、インターフェースも実装する必要があります。しかし、2 番目の設計アイデアでは、RedisConfig、KafkaConfig、および MySqlConfig が Config のすべてのインターフェイス関数 (update、output、outputInPlainText) を同時に実装する必要があります。さらに、引き続き新しいインターフェースを Config に追加する場合は、すべての実装クラスを変更する必要があります。反対に、インターフェースの粒度が比較的小さい場合、変更に関与するクラスは少なくなります。output()outputInPlainText()update()output()output()update()

5. 依存関係の逆転 (DIP)

前述のように、単一責任の原則と開閉の原則は比較的単純ですが、実際にうまく使用することはより困難です。依存性逆転の原則は正反対です。この原則は比較的簡単に使用できますが、概念を理解するのはより困難です。たとえば、次の質問があります。

  • 「依存関係逆転」の概念とは、「誰と誰」の「どの依存関係」を逆転させるかということです。「逆」という言葉はどのように理解されるべきですか?
  • 他に「制御の反転」と「依存性注入」という 2 つの概念があります。これら2つの概念と「依存関係の逆転」の違いと関係は何ですか? 彼らは同じことを言っていますか?
  • Spring フレームワークの IOC は、これらの概念とどのような関係がありますか?

5.1 制御の反転 (IOC)

Inversion of Control の英訳は Inversion Of Control、略して IOC です。例えば:

public class UserServiceTest {
    
    
    public static boolean doTest() {
    
    
        // ...
    }
    public static void main(String[] args) {
    
    // 这部分逻辑可以放到框架中
        if (doTest()) {
    
    
            System.out.println("Test succeed.");
        } else {
    
    
            System.out.println("Test failed.");
        }
    }
}

上記のコードでは、すべてのフローがプログラマによって制御されます。以下のようなフレームワークを抽象化する場合、フレームワークを使用して同じ機能を実現する方法を見てみましょう。具体的なコードの実装は次のとおりです。

public abstract class TestCase {
    
    
    public void run() {
    
    
        if (doTest()) {
    
    
            System.out.println("Test succeed.");
        } else {
    
    
            System.out.println("Test failed.");
        }
    }
    public abstract void doTest();
}

public class JunitApplication {
    
    
    private static final List<TestCase> testCases = new ArrayList<>();

    public static void register(TestCase testCase) {
    
    
        testCases.add(testCase);
    }
    public static final void main(String[] args) {
    
    
        for (TestCase case: testCases) {
    
    
            case.run();
        }
    }
}

この簡略化されたテスト フレームワークをプロジェクトに導入した後は、フレームワークによって予約された拡張ポイント、つまり TestCase クラスのdoTest()抽象プロセスの実行を担当するmain()関数。具体的なコードは次のとおりです。

public class UserServiceTest extends TestCase {
    
    
    @Override
    public boolean doTest() {
    
    
        // ...
    }
}
// 注册操作还可以通过配置的方式来实现,不需要程序员显示调用 register()
JunitApplication.register(new UserServiceTest();

上記の例は、フレームワークによって「制御の反転」を実装する典型的な例です。このフレームワークは、オブジェクトを組み立てて実行プロセス全体を管理するための拡張可能なコード スケルトンを提供します。プログラマーが開発にフレームワークを使用する場合、プログラマーは自分のビジネスに関連するコードを予約済みの拡張ポイントに追加するだけでよく、フレームワークを使用してプログラム フロー全体の実行を駆動できます。

ここでいう「制御」とは、プログラムの実行の流れを制御することを指し、「反転」とは、フレームワークを使用する前にプログラマがプログラム全体の実行を制御することを指します。フレームワークを使用した後は、プログラム全体の実行フローをフレームワークで制御できます。プロセスの制御は、プログラマーからフレームワークに「反転」されます

実際には制御の反転を実現する方法はたくさんあります. 上記のテンプレート設計パターンに似た方法に加えて、以下の依存性注入などの方法もあります. したがって、制御の反転は特定の実装手法ではありません.フレームワーク レベルで設計を導くために一般的に使用される、比較的一般的な設計の考え方です。

5.2 依存性注入 (DI)

依存性注入は、制御の反転の正反対であり、特定のコーディング手法です。依存性注入の英語訳は依存性注入であり、DI と略されます。この概念については、非常に明確な声明があります。つまり、依存性注入は 25 ドルの値札ですが、実際には 5 セントの価値しかありません。言い換えれば、この概念は非常に「背が高い」ように聞こえますが、実際には、理解して適用するのは非常に簡単です。

依存性注入は 1 つの文に要約できます。メソッドをnew()介して、依存クラス オブジェクトが外部で作成された後、それらはコンストラクター、関数パラメーターなどを介してクラスに渡されます (または注入されます)。

次の例では、Notification クラスがメッセージのプッシュを担当し、MessageSender クラスに依存して、製品のプロモーションや確認コードなどのメッセージをユーザーにプッシュします。依存性注入と非依存性注入を使用してそれを実現します。具体的な実装コードは次のとおりです。

// 非依赖注入实现方式
public class Notification {
    
    
    private MessageSender messageSender;

    public Notification() {
    
    
        this.messageSender = new MessageSender(); // 此处有点像 hardcode
    }
    public void sendMessage(String cellphone, String message) {
    
    
        //... 省略校验逻辑等...
        this.messageSender.send(cellphone, message);
    }
}
public class MessageSender {
    
    
    public void send(String cellphone, String message) {
    
    
        //....
    }
}
// 使用 Notification
Notification notification = new Notification();

// 依赖注入的实现方式
public class Notification {
    
    
    private MessageSender messageSender;

    // 通过构造函数将 messageSender 传递进来
    public Notification(MessageSender messageSender) {
    
    
        this.messageSender = messageSender;
    }
    public void sendMessage(String cellphone, String message) {
    
    
        //... 省略校验逻辑等...
        this.messageSender.send(cellphone, message);
    }
}
// 使用 Notification
MessageSender messageSender = new MessageSender();
Notification notification = new Notification(messageSender);

依存クラス オブジェクトは依存性注入によって渡されます。これにより、コードのスケーラビリティが向上し、依存クラスを柔軟に置き換えることができます。この点は、「開閉原理」のお話でも触れました。もちろん、上記のコードは、プログラミングを実装するのではなく、インターフェイスに基づいて MessageSender をインターフェイスとして定義することもできます。変更されたコードは次のとおりです。

public class Notification {
    
    
    private MessageSender messageSender;
    public Notification(MessageSender messageSender) {
    
    
        this.messageSender = messageSender;
    }
    public void sendMessage(String cellphone, String message) {
    
    
        this.messageSender.send(cellphone, message);
    }
}
public interface MessageSender {
    
    
    void send(String cellphone, String message);
}

// 短信发送类
public class SmsSender implements MessageSender {
    
    
    @Override
    public void send(String cellphone, String message) {
    
    
        //....
    }
}
// 站内信发送类
public class InboxSender implements MessageSender {
    
    
    @Override
    public void send(String cellphone, String message) {
    
    
        //....
    }
}
// 使用 Notification
MessageSender messageSender = new SmsSender();
Notification notification = new Notification(messageSender);

実際、前述の例をマスターするだけで、依存性注入を完全にマスターすることと同じです。その単純さにもかかわらず、依存性注入は非常に有用であり、テスト可能なコードを作成する最も効果的な手段です。

5.3 依存性注入フレームワーク (DI フレームワーク)

依存性注入によって実装された Notification クラスでは、クラス内で new を介して MessageSender オブジェクトを作成するために同様のハード コード メソッドを使用する必要はありませんが、オブジェクトを作成し、オブジェクトを組み立てる (または注入する) 作業は、より多くのオブジェクトに移動するだけです。上位レベルのコードだけであり、プログラマーはそれを自分で実装する必要があります。具体的なコードは次のとおりです。

public class Demo {
    
    
    public static final void main(String args[]) {
    
    
        MessageSender sender = new SmsSender(); // 创建对象
        Notification notification = new Notification(sender);// 依赖注入
        notification.sendMessage("13918942177", " 短信验证码:2346");
    }
}

実際のソフトウェア開発では、プロジェクトによっては数十、数百、さらには数百ものクラスが含まれる場合があり、クラス オブジェクトの作成と依存性注入は非常に複雑になります。作業のこの部分をプログラマーが独自のコードを作成して行うと、エラーが発生しやすく、開発コストが比較的高くなります。オブジェクト作成と依存性注入の作業は特定の業務とは関係なく、フレームワークに抽象化して自動的に完了することができます

このフレームワークが「依存性注入フレームワーク」です。依存性注入フレームワークによって提供される拡張ポイントを使用して、作成する必要があるすべてのクラス オブジェクトとクラス間の依存関係を構成するだけで、フレームワークは自動的にオブジェクトを作成し、オブジェクトのライフ サイクルを管理し、注入に依存することができます。する

実際、Google Guice、Java Spring、Pico Container、Butterfly Container など、既製の依存性注入フレームワークが多数あります。ただし、Spring フレームワーク自体は、Inversion Of Control Container (Inversion Of Control Container) であると主張しています。

実際、両方のステートメントが真実です。ただ、制御反転コンテナーの表現は非常に広範な記述であり、DI 依存性注入フレームワークの表現はより具体的で的を絞ったものです。前述のように制御の反転を実現するには多くの方法があるため、依存性注入以外にもテンプレート パターンなどがあり、Spring フレームワークでの制御の反転は主に依存性注入によって実現されます。ただし、この区別はそれほど明白ではなく、それほど重要でもありません。

5.4 依存性逆転の原則 (DIP)

依存性逆転の原則。Dependency Inversion Principle の英訳は Dependency Inversion Principle、略して DIP です。中国語の翻訳は、依存性逆転の原則と呼ばれることもあります。元のテキストは次のとおりです。

High-level modules shouldn’t depend on low-level modules. Both modules should depend on abstractions. In addition, abstractions shouldn’t depend on details. Details depend on abstractions.
高层模块(high-level modules)不要依赖低层模块(low-level)。高层模块和低层模块应该通过抽象(abstractions)来互相依赖。除此之外,抽象(abstractions)不要依赖具体实现细节(details),具体实现细节(details)依赖抽象(abstractions)

所谓高层模块和低层模块的划分,简单来说就是,在调用链上,调用者属于高层,被调用者属于低层。在平时的业务代码开发中,高层模块依赖底层模块是没有任何问题的。实际上,这条原则主要还是用来指导框架层面的设计,跟前面讲到的控制反转类似。拿 Tomcat这个 Servlet 容器作为例子来解释一下

Tomcat 是运行 Java Web 应用程序的容器。编写的 Web 应用程序代码只需要部署在 Tomcat 容器下,便可以被 Tomcat 容器调用执行。按照之前的划分原则,Tomcat 就是高层模块,编写的 Web 应用程序代码就是低层模块。Tomcat 和应用程序代码之间并没有直接的依赖关系,两者都依赖同一个“抽象”,也就是 Sevlet 规范。Servlet 规范不依赖具体的 Tomcat 容器和应用程序的实现细节,而 Tomcat 容器和应用程序依赖 Servlet 规范

6. KISS 原则

KISS 原则的英文描述有好几个版本,比如下面这几个:

  • Keep It Simple and Stupid.
  • Keep It Short and Simple.
  • シンプルでわかりやすいものにしてください。

しかし、よくよく見てみると、表現したい意味が実は似ていることがわかります。

KISS の原則は、多くのシナリオに適用できる万能の設計原則です。ソフトウェア開発のガイドとしてだけでなく、冷蔵庫、建物、iPhone などの設計など、より広範なシステム設計、製品設計などのガイドとしても使用されることがよくあります。ただし、ここでの焦点は、この原則をコード開発にどのように適用するかです。

コードの可読性と保守性は、コードの品質を測定するための 2 つの非常に重要な基準です。KISS の原則は、コードを読みやすく保守しやすくするための重要な手段です。コードは十分に単純です。つまり、読みやすく理解しやすく、バグを隠すのが難しくなります。バグがあっても比較的修正しやすい

ただし、この原則は、コードを「単純で愚か」に保つように指示するだけであり、「単純で愚か」なコードの種類については言及していません。また、「シンプルでバカ」。したがって、非常に単純に見えますが、実装できません

6.1 より少ないコード行は「単純」ですか?

まず例を見てみましょう。次の 3 つのコードで同じ機能を実現できます。入力文字列 ipAddress が有効な IP アドレスかどうかを確認します。有効な IP アドレスは、「.」で区切られた 4 つの数字で構成されます。数値の各グループの値の範囲は 0 ~ 255 です。数字の最初のグループは特別で、0 は許可されていません

// 第一种实现方式: 使用正则表达式
public boolean isValidIpAddressV1(String ipAddress) {
    
    
    if (StringUtils.isBlank(ipAddress)) return false;

    String regex = "^(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|[1-9])\\."
        + "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)\\."
        + "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)\\."
        + "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)$";
    return ipAddress.matches(regex);
}

// 第二种实现方式: 使用现成的工具类
public boolean isValidIpAddressV2(String ipAddress) {
    
    
    if (StringUtils.isBlank(ipAddress)) return false;

    String[] ipUnits = StringUtils.split(ipAddress, '.');
    if (ipUnits.length != 4) {
    
    
        return false;
    }
    for (int i = 0; i < 4; ++i) {
    
    
        int ipUnitIntValue;
        try {
    
    
            ipUnitIntValue = Integer.parseInt(ipUnits[i]);
        } catch (NumberFormatException e) {
    
    
            return false;
        }
        if (ipUnitIntValue < 0 || ipUnitIntValue > 255) {
    
    
            return false;
        }
        if (i == 0 && ipUnitIntValue == 0) {
    
    
            return false;
        }
    }
    return true;
}

// 第三种实现方式: 不使用任何工具类
public boolean isValidIpAddressV3(String ipAddress) {
    
    
    char[] ipChars = ipAddress.toCharArray();
    int length = ipChars.length;
    int ipUnitIntValue = -1;
    boolean isFirstUnit = true;
    int unitsCount = 0;

    for (int i = 0; i < length; ++i) {
    
    
        char c = ipChars[i];
        if (c == '.') {
    
    
            if (ipUnitIntValue < 0 || ipUnitIntValue > 255) return false;
            if (isFirstUnit && ipUnitIntValue == 0) return false;
            if (isFirstUnit) isFirstUnit = false;
        ipUnitIntValue = -1;
        unitsCount++;
        continue;
    }
    if (c < '0' || c > '9') {
    
    
        return false;
    }
    if (ipUnitIntValue == -1) ipUnitIntValue = 0;
        ipUnitIntValue = ipUnitIntValue * 10 + (c - '0');
    }
    if (ipUnitIntValue < 0 || ipUnitIntValue > 255) return false;
    if (unitsCount != 3) return false;
    return true;
}

最初の実装では正規表現を使用し、わずか 3 行のコードで問題を解決します。コードの行数が最も少ないので、KISS の原則に最も沿っているのでしょうか? 答えは否定的です。コードの行数が最も少なく、最も単純なように見えますが、実際には非常に複雑です。これはまさに正規表現を使用しているからです

正規表現自体は比較的複雑で、バグのない正規表現を作成するのは非常に困難である一方で、すべてのプログラマが正規表現に習熟しているわけではありません。正規表現についてあまり知らない同僚にとって、この正規表現を理解し維持することはより困難です。この実装方法では、コードの可読性や保守性が低下するため、KISS 原則の当初の設計意図から、この実装方法は KISS 原則に準拠していません。

第二种实现方式使用了 StringUtils 类、Integer 类提供的一些现成的工具函数,来处理 IP地址字符串。第三种实现方式,不使用任何工具函数,而是通过逐一处理 IP 地址中的字符,来判断是否合法。从代码行数上来说,这两种方式差不多。但是,第三种要比第二种更加有难度,更容易写出 bug。从可读性上来说,第二种实现方式的代码逻辑更清晰、更好理解。所以,在这两种实现方式中,第二种实现方式更加“简单”,更加符合 KISS 原则

不过可能会说,第三种实现方式虽然实现起来稍微有点复杂,但性能要比第二种实现方式高一些啊。从性能的角度来说,选择第三种实现方式是不是更好些呢?

一般来说,工具类的功能都比较通用和全面,所以,在代码实现上,需要考虑和处理更多的细节,执行效率就会有所影响。而第三种实现方式,完全是自己操作底层字符,只针对 IP 地址这一种格式的数据输入来做处理,没有太多多余的函数调用和其他不必要的处理逻辑,所以,在执行效率上,这种类似定制化的处理代码方式肯定比通用的工具类要高些

不过,尽管第三种实现方式性能更高些,但还是更倾向于选择第二种实现方法。那是因为第三种实现方式实际上是一种过度优化。除非 isValidIpAddress() 函数是影响系统性能的瓶颈代码,否则,这样优化的投入产出比并不高,增加了代码实现的难度、牺牲了代码的可读性,性能上的提升却并不明显

6.2 代码逻辑复杂就违背 KISS 原则吗?

前面提到,并不是代码行数越少就越“简单”,还要考虑逻辑复杂度、实现难度、代码的可读性等。那如果一段代码的逻辑复杂、实现难度大、可读性也不太好,是不是就一定违背 KISS 原则呢?先看下面的代码:

// KMP algorithm: a, b 分别是主串和模式串;n, m 分别是主串和模式串的长度。
public static int kmp(char[] a, int n, char[] b, int m) {
    
    
    int[] next = getNexts(b, m);
    int j = 0;
    for (int i = 0; i < n; ++i) {
    
    
        while (j > 0 && a[i] != b[j]) {
    
     // 一直找到 a[i] 和 b[j]
            j = next[j - 1] + 1;
        }
        if (a[i] == b[j]) {
    
    
            ++j;
        }
        if (j == m) {
    
     // 找到匹配模式串的了
            return i - m + 1;
        }
    }
    return -1;
}

// b 表示模式串,m 表示模式串的长度
private static int[] getNexts(char[] b, int m) {
    
    
    int[] next = new int[m];
    next[0] = -1;
    int k = -1;
    for (int i = 1; i < m; ++i) {
    
    
        while (k != -1 && b[k + 1] != b[i]) {
    
    
            k = next[k];
        }
        if (b[k + 1] == b[i]) {
    
    
            ++k;
        }
        next[i] = k;
    }
    return next;
}

这段代码完全符合刚提到的逻辑复杂、实现难度大、可读性差的特点,但它并不违反 KISS 原则。为什么这么说呢?

KMP アルゴリズムは、高速で効率的であることで知られています。長いテキスト文字列のマッチングの問題 (サイズが数百 MB のテキスト コンテンツのマッチング) に対処する必要がある場合、または文字列マッチングが製品 (Vim や Word などのテキスト エディターなど) のコア機能である場合、または文字列マッチング アルゴリズムはシステム パフォーマンスのボトルネックがある場合は、できるだけ効率的な KMP アルゴリズムを選択する必要があります。KMP アルゴリズム自体には、ロジックが複雑で、実装が難しく、可読性が低いという特徴があります。それ自体が複雑な問題を解決することは、KISS の原則に違反しません。

ただし、通常のプロジェクト開発に伴う文字列マッチングの問題のほとんどは、比較的小さなテキストに関するものです。この場合、プログラミング言語が提供する既製の文字列照合関数を直接呼び出すだけで十分です。文字列の一致を達成するために KMP アルゴリズムと BM アルゴリズムを使用する必要がある場合、それは KISS の原則に違反しています。つまり、特定のビジネス シナリオで KISS 原則を満たす同じコードが、別のアプリケーション シナリオでは満たされない場合があります。

6.3 KISS の原則を満たすコードの書き方

  1. 同僚が理解できない可能性のある手法を使用してコードを実装しないでください。たとえば、前の例の正規表現や、一部のプログラミング言語の過度に高度な構文などです。
  2. 車輪を再発明するのではなく、既存のツール ライブラリを上手に使用してください。これらのクラス ライブラリを自分で実装すると、バグが発生する可能性が高くなり、メンテナンスのコストが高くなることが経験的に証明されています。
  3. 最適化しすぎないでください。コードを最適化し、コードの可読性を犠牲にするために、いくつかのトリックやトリック (たとえば、算術演算の代わりにビット演算を使用する、if-else の代わりに複雑な条件文を使用する、低レベルすぎる関数を使用するなど) を過度に使用しないでください。コード

実際、コードが十分に単純であるかどうかは、非常に主観的な判断です。同じコードですが、単純だと思う人もいれば、それほど単純ではないと考える人もいます。多くの場合、自分で記述したコードは十分に単純に感じられます。そこで、コードがシンプルかどうかを間接的に判断する有効な方法がもう一つあります。それがコードレビューです。コードレビュー中に同僚があなたのコードについて多くの質問をする場合、それはあなたのコードが十分に「単純」ではなく、最適化する必要があることを意味します

開発を行うときは、設計しすぎたり、単純なものに技術的な内容がないと考えたりしてはなりません。実際、複雑な問題が単純な方法で解決できるほど、その人の能力が反映されます。

7.ヤグニ

YAGNI 原則の完全な英語名は、「You Ain't Gonna Need It」です。文字通りの翻訳は次のとおりです。あなたはそれを必要としません。この原則は万能薬でもあります。ソフトウェア開発で使用される場合、現在使用されていない機能を設計しない、現在使用されていないコードを記述しない、という意味です。実際、この原則の核となる考え方は次のとおりです。

たとえば、システムは構成情報を一時的に保存するために Redis のみを使用し、ZooKeeper は将来使用される可能性があります。YAGNI の原則によれば、ZooKeeper を使用する前にコードのこの部分を事前に記述する必要はありません。もちろん、これはコードのスケーラビリティを考慮する必要がないという意味ではありません。拡張ポイントを確保し、必要に応じて ZooKeeper ストレージ構成情報のコードの一部を実装する必要があります。

別の例として、事前に依存関係を必要としない開発パッケージをプロジェクトに導入しないでください。Java プログラマーの場合、依存するクラス ライブラリ (ライブラリ) を管理するために Maven または Gradle がよく使用されます。一部の同僚は、開発中にライブラリ パッケージが失われないようにするために Maven または Gradle 構成ファイルを頻繁に変更し、一般的に使用される多数のライブラリ パッケージを事前にプロジェクトに導入します。実際、そのようなアプローチは YAGNI の原則にも反しています。

YAGNI の原理は、KISS の原理と同じではありません。KISS の原則は「どのように行うか」 (できるだけシンプルに保つ) に関するものであり、YAGNI の原則は「行うかどうか」に関するものです (今必要でない場合は行わないでください)。

8.DRYの原則

英語の説明は次のとおりです。中国語の直訳は次のとおりです。それをプログラミングに適用すると、次のように理解できます: 反復的なコードを書かないでください

しかし、2 つのコードが同じように見える限り、それは DRY 原則に違反しているのでしょうか? 答えは否定的です。これは、多くの人々によるこの原則の誤解です。実際、繰り返されるコードが必ずしも DRY 原則に違反するとは限りません。コード重複の 3 つの典型的なケースは次のとおりです。

  1. 論理的な繰り返しを実装する
  2. 機能的意味反復
  3. コード実行の複製

これら 3 種類のコードの繰り返しには、DRY に違反しているように見えても実際には違反していないものもあれば、違反していないように見えても実際には違反しているものもあります。

8.1 論理的反復の実装

public class UserAuthenticator {
    
    
    public void authenticate(String username, String password) {
    
    
        if (!isValidUsername(username)) {
    
    
            // ...throw InvalidUsernameException...
        }
        if (!isValidPassword(password)) {
    
    
            // ...throw InvalidPasswordException...
        }
        //... 省略其他代码...
    }
    private boolean isValidUsername(String username) {
    
    
        // check not null, not empty
        if (StringUtils.isBlank(username)) {
    
    
            return false;
        }
        // check length: 4~64
        int length = username.length();
        if (length < 4 || length > 64) {
    
    
            return false;
        }
        // contains only lowcase characters
        if (!StringUtils.isAllLowerCase(username)) {
    
    
            return false;
        }
        // contains only a~z,0~9,dot
        for (int i = 0; i < length; ++i) {
    
    
            char c = username.charAt(i);
            if (!(c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.') {
    
    
                return false;
            }
        }
        return true;
    }
    private boolean isValidPassword(String password) {
    
    
        // check not null, not empty
        if (StringUtils.isBlank(password)) {
    
    
            return false;
        }
        // check length: 4~64
        int length = password.length();
        if (length < 4 || length > 64) {
    
    
            return false;
        }
        // contains only lowcase characters
        if (!StringUtils.isAllLowerCase(password)) {
    
    
            return false;
        }
        // contains only a~z,0~9,dot
        for (int i = 0; i < length; ++i) {
    
    
            char c = password.charAt(i);
            if (!(c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.') {
    
    
                return false;
            }
        }
        return true;
    }
}

在上面的代码中,有两处非常明显的重复的代码片段:isValidUserName() 函数和 isValidPassword() 函数。重复的代码被敲了两遍,或者简单 copy-paste 了一下,看起来明显违反 DRY 原则。为了移除重复的代码,对上面的代码做下重构,将 isValidUserName() 函数和 isValidPassword() 函数,合并为一个更通用的函数 isValidUserNameOrPassword()。重构后的代码如下所示:

public class UserAuthenticatorV2 {
    
    
    public void authenticate(String userName, String password) {
    
    
        if (!isValidUsernameOrPassword(userName)) {
    
    
            // ...throw InvalidUsernameException...
        }
        if (!isValidUsernameOrPassword(password)) {
    
    
            // ...throw InvalidPasswordException...
        }
    }
    private boolean isValidUsernameOrPassword(String usernameOrPassword) {
    
    
        // 省略实现逻辑
        // 跟原来的 isValidUsername() 或 isValidPassword() 的实现逻辑一样...
        return true;
    }
}

经过重构之后,代码行数减少了,也没有重复的代码了,是不是更好了呢?答案是否定的

单从名字上看就能发现,合并之后的 isValidUserNameOrPassword() 函数,负责两件事情:验证用户名和验证密码,违反了“单一职责原则”和“接口隔离原则”。实际上,即便将两个函数合并成 isValidUserNameOrPassword(),代码仍然存在问题

因为 isValidUserName()isValidPassword() 两个函数,虽然从代码实现逻辑上看起来是重复的,但是从语义上并不重复。所谓“语义不重复”指的是:从功能上来看,这两个函数干的是完全不重复的两件事情,一个是校验用户名,另一个是校验密码。尽管在目前的设计中,两个校验逻辑是完全一样的,但如果按照第二种写法,将两个函数的合并,那就会存在潜在的问题。在未来的某一天,如果修改了密码的校验逻辑,比如,允许密码包含大写字符,允许密码的长度为 8 到 64 个字符,那这个时候,isValidUserName()
isValidPassword() 的实现逻辑就会不相同。就要把合并后的函数,重新拆成合并前的那两个函数

尽管代码的实现逻辑是相同的,但语义不同,判定它并不违反 DRY 原则。对于包含重复代码的问题,可以通过抽象成更细粒度函数的方式来解决。比如将校验只包含 a~z、0~9、dot 的逻辑封装成 boolean onlyContains(String str, String charlist); 函数

8.2 功能语义重复

同じプロジェクト コードに、次の 2 つの関数があります:isValidIp()checkIfIpValid(). 2 つの関数の名前は異なり、実装ロジックも異なりますが、関数は同じであり、どちらも IP アドレスが正当かどうかを判断するために使用されます。

同じプロジェクトに同じ機能を持つ関数が 2 つある理由は、これら 2 つの関数が 2 人の異なる同僚によって開発され、そのうちの 1 人が既にisValidIp()存在するcheckIfIpValid().アドレスは合法です。同じプロジェクトコードに、以下の2つの関数がありますが、DRY原則に違反していませんか?

public boolean isValidIp(String ipAddress) {
    
    
    if (StringUtils.isBlank(ipAddress)) return false;
    String regex = "^(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|[1-9])\\."
        + "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)\\."
        + "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)\\."
        + "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)$";
    return ipAddress.matches(regex);
}

public boolean checkIfIpValid(String ipAddress) {
    
    
    if (StringUtils.isBlank(ipAddress)) return false;
    String[] ipUnits = StringUtils.split(ipAddress, '.');
    if (ipUnits.length != 4) {
    
    
        return false;
    }
    for (int i = 0; i < 4; ++i) {
    
    
        int ipUnitIntValue;
        try {
    
    
            ipUnitIntValue = Integer.parseInt(ipUnits[i]);
        } catch (NumberFormatException e) {
    
    
            return false;
        }
        if (ipUnitIntValue < 0 || ipUnitIntValue > 255) {
    
    
            return false;
        }
        if (i == 0 && ipUnitIntValue == 0) {
    
    
            return false;
        }
    }
    return true;
}

この例は、前の例の正反対です。最後の例は、コードが論理的な繰り返しを実装しているが、セマンティクスは繰り返されておらず、DRY 原則に違反しているとは見なされていないことです。この例では、2 つのコードの実装ロジックは繰り返されていませんが、意味の重複、つまり関数の重複は DRY 原則に違反していると見なされます。プロジェクトでは、1 つの実装アイデアを統一する必要があり、IP アドレスが合法であるかどうかを判断するために使用されるすべての場所で同じ関数を均一に呼び出す必要があります。

実装の考え方が統一されていないと仮定すると、ある場所でisValidIp()関数checkIfIpValid()、ある場所で関数が呼び出され、コードが奇妙に見えるようになります。これは、コードに「穴を埋める」ことと同じであり、コードのこの部分に慣れていない同僚のために読んでください。同僚は長い間研究して機能は同じだと感じていたかもしれませんが、彼らは少し混乱していたかもしれません.彼らはもっと深い考察があると思ったので、同様の機能を持つ2つの関数を定義し、最終的にそれがコードであることを発見しました.デザインの問題。

さらに、ある日、IP アドレスがプロジェクトで合法であるかどうかの判断ルールが変更された場合、たとえば、255.255.isValidIp()変更されたcheckIfIpValid()関数. または、同じ機能を持つcheckIfIpValid()関数、一部のコードで古い IP アドレスの判定ロジックが引き続き使用され、説明のつかないバグが発生する可能性があります。

8.3 コード実行の複製

最初の 2 つの例の 1 つは論理的な繰り返しを実現するためのもので、もう 1 つは意味的な繰り返しを実現するためのものです。その中で、UserService のlogin()関数は、ユーザーのログインが成功したかどうかを確認するために使用されます。失敗すると例外が返され、成功するとユーザー情報が返されます。具体的なコードは次のとおりです。

public class UserService {
    
    
    private UserRepo userRepo;// 通过依赖注入或者 IOC 框架注入
    public User login(String email, String password) {
    
    
        boolean existed = userRepo.checkIfUserExisted(email, password);
        if (!existed) {
    
    
            // ... throw AuthenticationFailureException...
        }
        User user = userRepo.getUserByEmail(email);
        return user;
    }
}

public class UserRepo {
    
    
    public boolean checkIfUserExisted(String email, String password) {
    
    
        if (!EmailValidation.validate(email)) {
    
    
            // ... throw InvalidEmailException...
        }
        if (!PasswordValidation.validate(password)) {
    
    
            // ... throw InvalidPasswordException...
        }
        //...query db to check if email&password exists...
    }
    public User getUserByEmail(String email) {
    
    
        if (!EmailValidation.validate(email)) {
    
    
            // ... throw InvalidEmailException...
        }
        //...query db to get user by email...
    }
}

上記のコードには論理的重複も意味的重複もありませんが、それでも DRY 原則に違反しています。これは、コードの「実行の重複」によるものです。一緒に見てみましょう、どのコードが繰り返し実行されますか?

繰り返し実行される最も明白な場所の 1 つは、login()関数。checkIfUserExisted()関数が呼び出されたときに 1 回、getUserByEmail()関数が呼び出されたときに 1 回。この問題は比較的簡単に解決できます。UserRepo から検証ロジックを削除し、UserService に配置するだけです。

さらに、コードには比較的隠れた実行の繰り返しがあります. 実際には、login()関数はcheckIfUserExisted()関数をgetUserByEmail()が、データベースからユーザーの電子メール、パスワード、およびその他の情報を取得するために関数を 1 回呼び出すだけで済みます。次に、ユーザー入力と通信します 電子メールとパスワードの情報を比較して、ログインが成功したかどうかを判断します

実際、そのような最適化が必要です。checkIfUserExisted()関数と関数の両方がデータベースにクエリを実行する必要がありgetUserByEmail()、データベースなどの I/O 操作には時間がかかるためです。コードを記述するときは、そのような I/O 操作を最小限に抑える必要があります

今の修正案に従って、コードをリファクタリングし、「繰り返し実行」コードを削除し、電子メールとパスワードを 1 回だけ確認し、データベースに 1 回だけクエリを実行します。リファクタリング後のコードは次のようになります。

public class UserService {
    
    
    private UserRepo userRepo;// 通过依赖注入或者 IOC 框架注入
    public User login(String email, String password) {
    
    
        if (!EmailValidation.validate(email)) {
    
    
            // ... throw InvalidEmailException...
        }
        if (!PasswordValidation.validate(password)) {
    
    
            // ... throw InvalidPasswordException...
        }
        User user = userRepo.getUserByEmail(email);
        if (user == null || !password.equals(user.getPassword()) {
    
    
            // ... throw AuthenticationFailureException...
        }
        return user;
    }
}

public class UserRepo {
    
    
    public boolean checkIfUserExisted(String email, String password) {
    
    
        //...query db to check if email&password exists
    }
    public User getUserByEmail(String email) {
    
    
        //...query db to get user by email...
    }
}

8.4 コードの再利用性

8.4.1 コードの再利用性とは?

まず、コードの再利用性、コードの再利用、DRY の原則の 3 つの概念を区別しましょう。

  • コードの再利用とは、動作を意味します。新しい関数を開発するときは、既存のコードを再利用してみてください
  • 代码的可复用性表示一段代码可被复用的特性或能力:在编写代码的时候,让代码尽量可复用
  • DRY 原则是一条原则:不要写重复的代码

从定义描述上,它们好像有点类似,但深究起来,三者的区别还是蛮大的

“不重复”并不代表“可复用”

在一个项目代码中,可能不存在任何重复的代码,但也并不表示里面有可复用的代码,不重复和可复用完全是两个概念。所以,从这个角度来说,DRY 原则跟代码的可复用性讲的是两回事

“复用”和“可复用性”关注角度不同

代码“可复用性”是从代码开发者的角度来讲的,“复用”是从代码使用者的角度来讲的。比如,A 同事编写了一个 UrlUtils 类,代码的“可复用性”很好。B 同事在开发新功能的时候,直接“复用”A 同事编写的 UrlUtils 类

尽管复用、可复用性、DRY 原则这三者从理解上有所区别,但实际上要达到的目的都是类似的,都是为了减少代码量,提高代码的可读性、可维护性。除此之外,复用已经经过测试的老代码,bug 会比从零重新开发要少

“复用”这个概念不仅可以指导细粒度的模块、类、函数的设计开发,实际上,一些框架、类库、组件等的产生也都是为了达到复用的目的。比如,Spring 框架、Google Guava 类库、UI 组件等等

8.4.2 怎么提高代码复用性?

  1. 减少代码耦合
    对于高度耦合的代码,当希望复用其中的一个功能,想把这个功能的代码抽取出来成为一个独立的模块、类或者函数的时候,往往会发现牵一发而动全身。移动一点代码,就要牵连到很多其他相关的代码。所以,高度耦合的代码会影响到代码的复用性,我们要尽量减少代码耦合
  2. 满足单一职责原则
    如果职责不够单一,模块、类设计得大而全,那依赖它的代码或者它依赖的代码就会比较多,进而增加了代码的耦合。根据上一点,也就会影响到代码的复用性。相反,越细粒度的代码,代码的通用性会越好,越容易被复用
  3. モジュール化
    ここでの「モジュール」とは、クラスの集まりで構成されるモジュールを指すだけでなく、単一のクラスまたは関数として理解することもできます。独立した機能を持つコードをモジュールにカプセル化するのが得意です。独立したモジュールはビルディングブロックのようなもので、再利用が容易で、より複雑なシステムを構築するために直接使用できます
  4. ビジネス ロジックと非ビジネス
    ロジックの分離 ビジネスに依存しないコードが再利用しやすいほど、ビジネス固有のコードは再利用が難しくなります。したがって、ビジネスとは関係のないコードを再利用するために、ビジネス ロジック コードと非ビジネス ロジック コードを分離し、共通のフレームワーク、クラス ライブラリ、コンポーネントなどに抽出します。
  5. 一般的なコード シンキング
    階層化の観点からは、コードが下位になるほど、より多くのモジュールから呼び出される一般的なコードになり、再利用できるように設計する必要があります。一般に、コードが階層化された後、相互呼び出しによる呼び出し関係の混乱を避けるために、上位コードのみが下位コードの呼び出しと同レベル コード間の呼び出しを許可されます。また、下位レベルのコードは上位レベルのコードを呼び出すことができません。したがって、共通コードは可能な限り下位層にシンクする必要があります。
  6. 継承、ポリモーフィズム、抽象化、カプセル化
    オブジェクト指向の機能について話すとき、継承を使用することで、共通コードを親クラスに抽出でき、サブクラスが親クラスのプロパティとメソッドを再利用できることが言及されています。ポリモーフィズムを使用すると、コードの一部のロジックを動的に置き換えて、このコードを再利用可能にすることができます。さらに、抽象化とカプセル化は、オブジェクト指向機能の狭いレベルではなく広いレベルから理解された場合、抽象化され、特定の実装への依存が少なくなり、再利用が容易になります。コードはモジュールにカプセル化され、変数の詳細を隠し、変更されていないインターフェイスを公開するため、再利用が容易になります
  7. アプリケーション テンプレートなどの設計パターン
    一部の設計パターンは、コードの再利用性を向上させることもできます。たとえば、テンプレート モードは、コードの一部を柔軟に置き換えることができるポリモーフィズムを使用して実装され、プロセス テンプレート コード全体を再利用できます。

上記の点に加えて、ジェネリックプログラミングなど、コードの再利用性を向上させることもできるプログラミング言語に関連する機能がいくつかあります。実は、上記の方法に加えて、リユースを意識することも非常に重要です。コードを書くときは、コードのこの部分を抽出して、複数の場所で独立したモジュール、クラス、または関数として使用できるかどうかをもっと考える必要があります。各モジュール、クラス、関数を設計するときは、外部 API を設計するのと同じように再利用性を考えてください。

8.5 弁証法的思考と柔軟な適用

実際、再利用可能なコードを書くのは簡単ではありません。コードを記述するときに再利用可能な要件シナリオが既に存在する場合、再利用可能な要件に従って再利用可能なコードを開発することは難しくありません。ただし、現時点で再利用の必要がなければ、将来同僚が新しい機能を開発するときに再利用できるように、現在記述されているコードに再利用可能な特性があることを願っています。特定の再利用要件がない場合、コードが将来どのように再利用されるかを予測することはより困難になります。

実際、非常に明確な再利用要件がない限り、一時的に未使用の再利用要件に多くの時間、エネルギー、および開発コストを費やすことは推奨されません。これは、前述の YAGNI 原則にも違反しています。

また、「Rule of Three」と呼ばれる有名な原則があります。この原則は、多くの業界やシナリオで使用できます。この原則がここに適用される場合、つまり、初めてコードを書くとき、再利用の必要がなく、将来の再利用の必要性が特に明確でなく、再利用可能なコードの開発コストが比較的高い場合、コードの再利用性を考慮する必要はありません。後で新しい機能を開発するときに、以前に書いたコードを再利用できることがわかったので、このコードをリファクタリングして再利用しやすくします。

つまり、コードを最初に書くときは再利用性を考慮せず、再利用可能なシーンに 2 回目に遭遇したときに、再利用可能にするためにリファクタリングを実行します。「Rule of Three」の「Three」は実際には正確な「3」を指すのではなく、ここでは「2」を指すことに注意してください。

9. デメテルの法則 (LOD)

9.1 「高凝集・疎結合」とは?

「高凝集性、疎結合」は非常に重要な設計思想であり、コードの可読性と保守性を効果的に向上させ、機能変更によるコード変更の範囲を縮小することができます。実際、多くの設計原則は、単一責任の原則、実装プログラミングではなくインターフェースベースなど、コードの「高い結束と疎結合」を実現することを目的としています。

実際、「高い結束力と疎結合」は比較的一般的な設計思想であり、システム、モジュール、クラス、さらには機能など、さまざまな粒度コードの設計と開発を導くために使用でき、また、以下に適用することもできます。マイクロサービス、フレームワーク、コンポーネント、クラス ライブラリなどのさまざまな開発シナリオ。ここでは、この設計思想の適用対象として「クラス」を用いて説明します。

この設計思想では、クラス自体の設計を導くために「高い凝集性」が使用され、クラス間の依存関係の設計を導くために「疎結合」が使用されます。ただし、両者は完全に独立しているわけではありません。凝集度が高いと疎結合が促進され、疎結合には凝集度の高いサポートが必要

「高結束」とは?

いわゆる凝集度が高いとは、類似した機能を同じクラスに配置し、異なる機能を同じクラスに配置しないことを意味します。同様の関数が同時に変更され、同じクラスに配置されることが多く、変更がより集中し、コードの保守が容易になります。実際、前述の単一責任の原則は、コードの高い凝集度を達成するための非常に効果的な設計原則です。

「疎結合」とは?

いわゆる疎結合とは、コード内でクラス間の依存関係が単純かつ明確であることを意味します。2 つのクラスに依存関係がある場合でも、1 つのクラスでコードを変更しても、依存するクラスでコードが変更されることはほとんどないか、ほとんどありません。実際、依存性注入、インターフェイスの分離、実装ではなくインターフェイスに基づくプログラミング、および上記のディミットの法則はすべて、コードの疎結合を実現するためのものです。

「結束」と「結合」の関係

「高い凝集度」は「疎結合」に寄与し、同様に「低い凝集度」は「密結合」にもつながります。下の図に示すように、図の左側の部分のコード構造は「高凝集性、疎結合」であり、右側の部分は正反対の「低凝集性、密結合」です。

ここに画像の説明を挿入

図の左側のコード設計では、クラスの粒度は比較的小さく、各クラスの責任は比較的単一です。類似した機能は 1 つのクラスにまとめられ、類似しない機能は複数のクラスに分割されます。このクラスは独立性が高く、コードのまとまりが良くなります。責任が 1 つであるため、各クラスは少数のクラスに依存し、コードは低結合です。クラスの変更は、依存するクラスのコード変更にのみ影響します。この依存クラスがまだ正常に機能するかどうかをテストするだけで済みます

図の右側のコード設計では、クラスの粒度が比較的大きく、凝集度が低く、関数が大きくて包括的であり、異なる関数が 1 つのクラスにまとめられています。これにより、他の多くのクラスがこのクラスに依存します。このクラスの特定の機能コードを変更すると、それに依存する複数のクラスに影響します。これら 3 つの依存クラスがまだ正常に機能するかどうかをテストする必要があります。これがいわゆる「一本の毛で全身を動かす」

また、図からわかるように、凝集度が高く、結合度が低いコード構造は、より単純で明確であり、したがって、保守性と可読性の点ではるかに優れています。

9.2 「デメテルの法則」の理論的記述

迪米特法则的英文翻译是:Law of Demeter,缩写是 LOD。单从这个名字上来看,完全猜不出这个原则讲的是什么。不过,它还有另外一个更加达意的名字,叫作最小知识原则,英文翻译为:The Least Knowledge Principle。原文如下:

Each unit should have only limited knowledge about other units: only units “closely” related to the current unit. Or: Each unit should only talk to its friends; Don’t talk to strangers.

每个模块(unit)只应该了解那些与它关系密切的模块(units: only units “closely”related to the current unit)的有限知识(knowledge)。或者说,每个模块只和自己的朋友“说话”(talk),不和陌生人“说话”(talk)

大部分设计原则和思想都非常抽象,有各种各样的解读,要想灵活地应用到实际的开发中,需要有实战经验的积累。迪米特法则也不例外。这里对刚刚的定义重新描述一下。为了统一,把定义描述中的“模块”替换成了“类”

不该有直接依赖关系的类之间,不要有依赖;有依赖关系的类之间,尽量只依赖必要的接口(也就是定义中的“有限知识”)

从上面的描述中,可以看出,迪米特法则包含前后两部分,这两部分讲的是两件事情

9.2.1 不该有直接依赖关系的类之间,不要有依赖

如下例,这个例子实现了简化版的搜索引擎爬取网页的功能。代码中包含三个主要的类。其中,NetworkTransporter 类负责底层网络通信,根据请求获取数据;HtmlDownloader 类用来通过 URL 获取网页;Document 表示网页文档,后续的网页内容抽取、分词、索引都是以此为处理对象。具体的代码实现如下所示:

public class NetworkTransporter {
    
    
    // 省略属性和其他方法...
    public Byte[] send(HtmlRequest htmlRequest) {
    
    
        //...
    }
}
public class HtmlDownloader {
    
    
    private NetworkTransporter transporter;// 通过构造函数或 IOC 注入
    public Html downloadHtml(String url) {
    
    
        Byte[] rawHtml = transporter.send(new HtmlRequest(url));
        return new Html(rawHtml);
    }
}
public class Document {
    
    
    private Html html;
    private String url;

    public Document(String url) {
    
    
        this.url = url;
        HtmlDownloader downloader = new HtmlDownloader();
        this.html = downloader.downloadHtml(url);
    }
    //...
}

このコードは「使える」ものであり、私たちが望む機能を実現できますが、「使いやすい」ものではなく、多くの設計上の欠陥があります。

1. NetworkTransporter クラスを最初に見てください。

低レベルのネットワーク通信クラスとして、その機能は HTML のダウンロードだけでなく、可能な限り一般的なものにする必要があります。この観点から、NetworkTransporter クラスの設計はディミットの法則に違反しており、直接的な依存関係を持つべきではない HtmlRequest クラスに依存しています。

NetworkTransporter クラスがディミットの法則を満たすには、どのようにリファクタリングを行う必要がありますか? ここにイメージの比喩があります。今何かを買いに店に行く場合、財布をレジ係に直接渡してレジ係にお金を取らせることは絶対にありませんが、財布からお金を取り出してレジ係に渡します。ここでの HtmlRequest オブジェクトはウォレットに相当し、HtmlRequest 内のアドレス オブジェクトとコンテンツ オブジェクトはお金に相当します。HtmlRequest を NetworkTransporter に直接渡すのではなく、アドレスとコンテンツを NetworkTransporter に渡す必要があります。この考え方によると、NetworkTransporter リファクタリング後のコードは次のようになります。

public class NetworkTransporter {
    
    
    // 省略属性和其他方法...
    public Byte[] send(String address, Byte[] data) {
    
    
        //...
    }
}

2. HtmlDownloader クラスをもう一度見てください。

このクラスの設計に問題はありません。ただし、NetworkTransportersend()関数、このクラスsend()はこの関数を使用しているため、それに応じて変更する必要があります。変更されたコードは次のとおりです。

public class HtmlDownloader {
    
    
    private NetworkTransporter transporter;// 通过构造函数或 IOC 注入
    // HtmlDownloader 这里也要有相应的修改
    public Html downloadHtml(String url) {
    
    
        HtmlRequest htmlRequest = new HtmlRequest(url);
        Byte[] rawHtml = transporter.send(
                htmlRequest.getAddress(), htmlRequest.getContent().getBytes());
        return new Html(rawHtml);
    }
}

3. 最後に、Document クラスを見てください。

このカテゴリには多くの問題があり、主に 3 点です。まず、コンストラクター内のdownloader.downloadHtml()ロジックは複雑で時間がかかるため、コードのテスト容易性に影響を与えるコンストラクターに配置しないでください。2 つ目は、HtmlDownloader オブジェクトがコンストラクターで new を介して作成されることです。これは、実装ではなくインターフェイスに基づいてプログラミングするという設計思想に違反し、コードのテスト容易性にも影響します。第三に、ビジネスの観点からは、Document Web ページのドキュメントは HtmlDownloader クラスに依存する必要はありません。これは Dimit の法則に違反しています。

Document クラスには多くの問題がありますが、変更は比較的簡単で、1 つの変更だけですべての問題を解決できます。変更されたコードは次のとおりです。

public class Document {
    
    
    private Html html;
    private String url;
    public Document(String url, Html html) {
    
    
        this.html = html;
        this.url = url;
    }
    //...
}

// 通过一个工厂方法来创建 Document
public class DocumentFactory {
    
    
    private HtmlDownloader downloader;
    public DocumentFactory(HtmlDownloader downloader) {
    
    
        this.downloader = downloader;
    }
    public Document createDocument(String url) {
    
    
        Html html = downloader.downloadHtml(url);
        return new Document(url, html);
    }
}

9.2.2 依存関係のあるクラス間では、必要なインターフェースのみに依存するようにしてください

次の例では、Serialization クラスがオブジェクトのシリアル化と逆シリアル化を担当します。

public class Serialization {
    
    
    public String serialize(Object object) {
    
    
        String serializedResult = ...;
        //...
        return serializedResult;
    }
    public Object deserialize(String str) {
    
    
        Object deserializedResult = ...;
        //...
        return deserializedResult;
    }
}

このクラスの設計だけを見ると、まったく問題ありません。ただし、特定のアプリケーション シナリオに入れると、さらに最適化する余地がまだあります。プロジェクトで、シリアル化操作のみを使用するクラスと、逆シリアル化操作のみを使用するクラスがあるとします。Dimit のルールの後半にある「依存関係のあるクラス間では、必要なインターフェイスのみに依存するように努める」に基づいており、シリアル化操作のみを使用するクラスは、逆シリアル化インターフェイスに依存するべきではありません。同様に、逆シリアル化操作のみを使用するクラスは、シリアル化インターフェイスに依存するべきではありません

この考え方によれば、Serialization クラスは 2 つの粒度の小さいクラスに分割する必要があります。1 つはシリアライゼーションのみを担当し (Serializer クラス)、もう 1 つはデシリアライゼーションのみを担当します (Deserializer クラス)。分割後、シリアル化操作を使用するクラスは Serializer クラスのみに依存する必要があり、逆シリアル化操作を使用するクラスは Deserializer クラスのみに依存する必要があります。分割後のコードは次のとおりです。

public class Serializer {
    
    
    public String serialize(Object object) {
    
    
        String serializedResult = ...;
        ...
        return serializedResult;
    }
}
public class Deserializer {
    
    
    public Object deserialize(String str) {
    
    
        Object deserializedResult = ...;
        ...
        return deserializedResult;
    }
}

分割後のコードは、ディミットの法則をよりよく満たすことができますが、高い凝集度の設計思想に違反しています。凝集度が高いには、同様の関数を同じクラスに配置する必要があります。これにより、関数が変更されたときに、変更が分散しすぎないようにします。上記の例の場合、たとえば JSON から XML にシリアライゼーションの実装が変更された場合、デシリアライゼーションの実装ロジックも変更する必要があります。分割されていない場合、1 つのクラスのみを変更する必要があります。分割後、2 つのクラスを変更する必要があります。明らかに、この設計アイデアのコード変更の範囲は大きくなっています

結束力の高い設計思想に反したくない、ディミットの法則に反したくない場合、この問題を解決するにはどうすればよいでしょうか。実際、この問題は 2 つのインターフェイスを導入することで簡単に解決できます。具体的なコードは次のとおりです。

public interface Serializable {
    
    
    String serialize(Object object);
}
public interface Deserializable {
    
    
    Object deserialize(String text);
}

public class Serialization implements Serializable, Deserializable {
    
    
    @Override
    public String serialize(Object object) {
    
    
        String serializedResult = ...;
        ...
        return serializedResult;
    }
    @Override
    public Object deserialize(String str) {
    
    
        Object deserializedResult = ...;
        ...
        return deserializedResult;
    }
}
public class DemoClass_1 {
    
    
    private Serializable serializer;
    public Demo(Serializable serializer) {
    
    
        this.serializer = serializer;
    }
    //...
}
public class DemoClass_2 {
    
    
    private Deserializable deserializer;
    public Demo(Deserializable deserializer) {
    
    
        this.deserializer = deserializer;
    }
    //...
}

シリアライゼーションとデシリアライゼーションを含むシリアライゼーション実装クラスは DemoClass_1 のコンストラクターに渡す必要がありますが、依存する Serializable インターフェイスにはシリアライゼーション操作のみが含まれており、DemoClass_1 はシリアライゼーション クラス内のデシリアライゼーション インターフェイスを使用してデシリアライズする最適化操作の認識はありません。これは、ディミットの法則の後半で述べた「限られたインターフェースに依存する」という要件も満たしています。

実際、上記のコード実装の考え方は、「実装ではなくインターフェースに基づいたプログラミング」という設計原則も反映しています. ディミットの法則と組み合わせると、新しい設計原則、つまり「実装ではなく最小限のインターフェースに基づく」と要約できます.最大限の実装「プログラミング」。実際、新しい設計パターンと設計原則は、多数のプラクティスで開発の問題点をまとめて要約したルーチンです。

9.3 弁証法的思考と柔軟な適用

シリアライゼーションとデシリアライゼーションの例に対応すると、クラス全体にはシリアライゼーションとデシリアライゼーションの 2 つの操作のみが含まれ、シリアライゼーション操作のユーザーのみが使用されます。ディミットの法則を満たすために、非常に単純なクラスを 2 つのインターフェイスに分割するのは少し過剰に設計されていますか?

正しいと言えるかどうかだけで、設計原理自体に善悪はありません。設計原則を適用するために設計原則を適用しないでください。設計原則を適用するときは、特定の問題を分析する必要があります。

先ほどのシリアライゼーション クラスには、2 つの操作しか含まれておらず、実際には 2 つのインターフェイスに分割する必要はありません。ただし、シリアライゼーション クラスにさらに関数を追加し、より多くのより優れたシリアライゼーションおよびデシリアライゼーション関数を実装する場合は、この問題を再検討してください。変更後の具体的なコードは次のとおりです。

public class Serializer {
    
     // 参看 JSON 的接口定义
    public String serialize(Object object) {
    
     //... }
    public String serializeMap(Map map) {
    
     //... }
    public String serializeList(List list) {
    
     //... }

    public Object deserialize(String objectString) {
    
     //... }
    public Map deserializeMap(String mapString) {
    
     //... }
    public List deserializeList(String listString) {
    
     //... }
}

このシナリオでは、2 番目の設計案の方が適しています。前のアプリケーション シナリオに基づいているため、ほとんどのコードはシリアライゼーション関数のみを使用する必要があります。これらのユーザーにとって、デシリアライズの「知識」を理解する必要はなく、修正されたシリアライゼーション クラスでは、デシリアライズの「知識」が 1 つの機能から 3 つの機能に変わりました。逆シリアル化操作でコードが変更されたら、シリアル化クラスに依存するすべてのコードが引き続き正常に機能するかどうかを確認してテストする必要があります。結合とテストの負荷を軽減するために、ディミットの法則に従って、デシリアライゼーションとシリアライゼーションの機能を分離する必要があります。

10. 業務システムの開発において、需要分析と設計はどのように行うのですか?

エンジニアにとって、長期的な開発を追求したいのであれば、単にコードを実装するだけでなく、常に実行者の役割に身を置くだけでなく、システムに対して独立して責任を負う能力と、エンド ツー エンド (エンド ツー エンド) ) 完全なシステムを開発します。作業には、初期の需要の伝達と分析、中期的なコードの設計と実装、およびその後のシステムのオンライン メンテナンスなどが含まれます。

ほとんどのエンジニアはビジネス開発を行っています。多くのエンジニアは、ビジネス開発は技術的な内容がなく、成長がないと感じています. それは単なる CRUD であり、ビジネス ロジックを翻訳するものであり、コラムで述べた設計原則、アイデア、およびモデルをまったく使用しません。

ここでは、実際のポイント交換システムの開発を通じて、一方では需要分析からオンライン保守までの業務システムの開発ルーチン全体を示し、他のすべてのシステム開発に類推して適用することができます。コンテンツのビジネス展開には、実際にどのような設計原則、アイデア、およびモデルが含まれていますか?

10.1 要件分析

ポイントは一般的なマーケティング手法であり、タオバオのポイント、クレジットカードのポイント、ショッピングモールの消費ポイントなど、消費を促進し、ユーザーの粘着性を高めるために多くの製品が使用されています。あなたが淘宝網のような e コマース プラットフォームのエンジニアであり、そのプラットフォームにはまだポイント システムがないとします。リーダーは、そのようなシステムの開発を担当してほしいと考えていますが、どのようにしますか?

技術者として、製品設計をどのように行うか? まず、一人で考えないでください。一方で、そうする際に包括的に考えるのは難しい。一方で、一から設計するのも手間がかかります。したがって、私たちは「学ぶ」ことを学ばなければなりません。アインシュタインは「創造性の偉大な秘密は、自分の出自を隠す方法を知っていることだ」と言いました

Taobao などのいくつかの類似製品を見つけて、それらがどのようにポイント システムを設計しているかを確認し、それを自分の製品に使用することができます。または、Taobao を使用してポイントの使用状況を確認するか、Baidu で「Taobao ポイント ルール」を直接検索することもできます。これら 2 つの入力に基づいて、スコアリング システムの設計方法を理解することは基本的に可能です。さらに、自社の製品を十分に理解し、借りてきたものを自社の製品に組み込み、適切なマイクロイノベーションを行う必要があります。

笼统地来讲,积分系统无外乎就两个大的功能点,一个是赚取积分,另一个是消费积分。赚取积分功能包括积分赚取渠道,比如下订单、每日签到、评论等;还包括积分兑换规则,比如订单金额与积分的兑换比例,每日签到赠送多少积分等。消费积分功能包括积分消费渠道,比如抵扣订单金额、兑换优惠券、积分换购、参与活动扣积分等;还包括积分兑换规则,比如多少积分可以换算成抵扣订单的多少金额,一张优惠券需要多少积分来兑换等等

上面给出的只是非常笼统、粗糙的功能需求。在实际情况中,肯定还有一些业务细节需要考虑,比如积分的有效期问题。对于这些业务细节,还是那句话,闷头拍脑袋想是想不全面的。以防遗漏,还是要有方法可寻。除了刚刚讲的“借鉴”的思路之外,还可以通过产品的线框图、用户用例(user case )或者叫用户故事(user story)来细化业务流程,挖掘一些比较细节的、不容易想到的功能点

用户用例有点儿类似单元测试用例。它侧重情景化,其实就是模拟用户如何使用产品,描述用户在一个特定的应用场景里的一个完整的业务操作流程。所以,它包含更多的细节,且更加容易被人理解。比如,有关积分有效期的用户用例,可以进行如下的设计:

  • 用户在获取积分的时候,会告知积分的有效期
  • 用户在使用积分的时候,会优先使用快过期的积分
  • 用户在查询积分明细的时候,会显示积分的有效期和状态(是否过期)
  • 用户在查询总可用积分的时候,会排除掉过期的积分

10.1.1 积分赚取和兑换规则

积分的赚取渠道包括:下订单、每日签到、评论等

积分兑换规则可以是比较通用的。比如,签到送 10 积分。再比如,按照订单总金额的 10% 兑换成积分,也就是 100 块钱的订单可以积累 10 积分。除此之外,积分兑换规则也可以是比较细化的。比如,不同的店铺、不同的商品,可以设置不同的积分兑换比例

对于积分的有效期,可以根据不同渠道,设置不同的有效期。积分到期之后会作废;在消费积分的时候,优先使用快到期的积分

10.1.2 积分消费和兑换规则

ポイントの消費経路には、注文金額の引き落とし、クーポンの引き換え、ポイントの購入への引き換え、アクティビティへの参加によるポイントの引き換えなどがあります。

さまざまな消費チャネルに応じて、さまざまなポイント交換ルールを設定できます。例えば消費控除に換算されるポイントの割合は10%で、10ポイントで1元控除、100ポイントで15元クーポンなどに交換できます。

10.1.3 ポイントとその詳細クエリ

ユーザーの合計ポイント、およびポイントの獲得と消費の履歴を照会します

10.2 システム設計

オブジェクト指向設計はコード レベル (主にクラス) に焦点を当てているのに対して、システム設計はアーキテクチャ レベル (主にモジュール) に焦点を当てています。多くの設計原則とアイデアは、コード設計だけでなく、アーキテクチャ設計にも適用できます。実際、オブジェクト指向設計の 4 つのステップからシステム設計を行うこともできます。

10.2.1 異なるモジュールへの機能の合理的な分割

オブジェクト指向設計の本質は、適切なコードを適切なクラスに配置することです。コードを合理的に分割することで、コードの凝集度が高く結合度が低くなり、クラス間の相互作用が単純明快になり、コード全体の構造が一目でわかるようになるため、コードの品質は悪くありません。オブジェクト指向設計と同様に、システム設計は実際には適切な機能を適切なモジュールに配置します。モジュールを合理的に分割することで、モジュール レベルで高い凝集性と低い結合を実現することもでき、構造が明確になります。

上記のすべての機能ポイントに対して、次の 3 つのモジュール分割方法があります。

1. ポイントの獲得ルートと交換ルール、消費ルートと交換ルール(追加、削除、変更、クエリ)の管理と維持は、ポイント システムに分割されず、上位のマーケティング システムに配置されます。

このように、ポイントシステムは非常にシンプルになり、ポイントの追加、ポイントの削減、ポイントのクエリ、ポイントの詳細のクエリなどを担当するだけで済みます。

たとえば、ユーザーは注文を行うことでポイントを獲得します。注文システムは、メッセージを非同期に送信するか、インターフェースを同期的に呼び出すことによって、注文トランザクションが成功したことをマーケティング システムに通知します。マーケティングシステムは、受信した注文情報に基づいて、注文に対応するポイント交換ルール (変換率、有効期間など) を照会し、注文に交換できるポイント数を計算して、注文のインターフェイスを呼び出します。ユーザーにポイントを追加するポイントシステム

2.ポイント獲得チャネルと交換ルール、消費チャネルと交換ルールの管理と保守は、注文システム、コメントシステム、サインインシステム、交換モール、クーポンシステムなど、関連するさまざまなビジネスシステムに分散しています。

ユーザーが正常に注文すると、注文システムは商品に対応するポイント変換率に従って交換できるポイント数を計算し、ポイント システムを直接呼び出してユーザーにポイントを追加します。

3. すべての機能は、ポイント獲得チャネルと交換ルール、消費チャネルと交換ルールの管理と維持を含むポイント システムに分割されます。

ユーザーが正常に注文した後、注文システムは注文取引が成功したことをポイントシステムに直接通知し、ポイントシステムは注文情報に基づいてポイント交換ルールを照会し、ユーザーにポイントを追加します。

どのモジュール分割が妥当かを判断するには?実際には、高凝集性と低結合性の特性を満たしているかどうかを見て判断できます。機能の変更または追加がチーム、プロジェクト、およびシステム全体で頻繁に完了する必要がある場合は、モジュールの分割が十分に合理的ではなく、責任が十分に明確ではなく、結合が深刻すぎることを意味します。

また、業務知識の結合を避け、下位システムをより一般化するために、一般的に言えば、下位システム(つまり、呼び出されるシステム)には、そのシステムの業務情報が過度に含まれることは望ましくありません。上位システム(つまり、呼び出し側システム)に、下位システムの業務情報が含まれていてもかまいません。例えば、ポイントシステムを呼び出す上位システムとして、注文システム、クーポンシステム、還元モールなどには、ポイント関連のビジネス情報を含めることができます。ただし、順番に、注文、クーポン、償還などに関連する情報をポイント システムに含めすぎないことをお勧めします。

したがって、総合的な検討は、モジュール分割の第 1 および第 2 の方法に傾いています。ただし、どちらを選択しても、ポイント システムは、ポイントの加算、減算、クエリ、およびポイントの詳細の記録とクエリのみを含む同じ作業を担当します。

10.2.2 設計モジュール間の相互作用

オブジェクト指向設計では、クラスを設計した後、クラス間の相互作用を設計する必要があります。システム設計と同様に、システムの責任が分割された後、次のステップはシステム間の相互作用を設計することです。つまり、どのシステムがポイント システムと相互作用し、どのように相互作用するかを決定します。

システム間の対話には 2 つの一般的なモードがあります。1 つは同期インターフェイス呼び出しで、もう 1 つはメッセージ ミドルウェアを使用した非同期呼び出しです。最初の方法は単純で直接的であり、2 番目の方法のデカップリング効果は優れています。

たとえば、ユーザーが正常に注文した後、注文システムはメッセージ ミドルウェアにメッセージをプッシュし、マーケティング システムは注文成功メッセージをサブスクライブして、対応するポイント交換ロジックの実行をトリガーします。このように、注文システムはマーケティング システムから完全に切り離されており、注文システムはポイントに関連するロジックを知る必要がなく、マーケティング システムは注文システムと直接対話する必要もありません。

除此之外,上下层系统之间的调用倾向于通过同步接口,同层之间的调用倾向于异步消息调用。比如,营销系统和积分系统是上下层关系,它们之间就比较推荐使用同步接口调用

10.2.3 设计模块的接口、数据库、业务模型

完成了模块的功能划分,模块之间的交互的设计,接下来再来看,模块本身如何来设计。实际上,业务系统本身的设计无外乎有这样三方面的工作要做:接口设计、数据库设计和业务模型设计

10.3 代码实现

前面把积分赚取和消费的渠道和规则的管理维护工作,划分到了上层系统中,所以,积分系统的功能变得非常简单。相应地,代码实现也比较简单。如果有一定的项目开发经验,那实现这样一个系统并不是件难事。所以,这里重点并不是如何来实现积分系统的每个功能、每个接口,更不是如何编写 SQL 语句来增删改查数据,而是展示一些更普适的开发思想。比如,为什么要分 MVC 三层来开发?为什么要针对每层定义不同的数据对象?以及这其中都蕴含哪些设计原则和思想,知其然知其所以然,做到真正地透彻理解

业务开发包括哪些工作?

实际上,平时做业务系统的设计与开发,无外乎有这样三方面的工作要做:接口设计、数据库设计和业务模型设计(也就是业务逻辑)

数据库和接口的设计非常重要,一旦设计好并投入使用之后,这两部分都不能轻易改动。改动数据库表结构,需要涉及数据的迁移和适配;改动接口,需要推动接口的使用者作相应的代码修改。这两种情况,即便是微小的改动,执行起来都会非常麻烦。因此,在设计接口和数据库的时候,一定要多花点心思和时间,切不可过于随意。相反,业务逻辑代码侧重内部实现,不涉及被外部依赖的接口,也不包含持久化的数据,所以对改动的容忍性更大

10.3.1 设计数据库

数据库的设计比较简单。实际上,只需要一张记录积分流水明细的表就可以了。表中记录积分的赚取和消费流水。用户积分的各种统计数据,比如总积分、总可用积分等,都可以通过这张表来计算得到

ここに画像の説明を挿入

10.3.2 设计接口

接口设计要符合单一职责原则,粒度越小通用性就越好。但是,接口粒度太小也会带来一些问题。比如,一个功能的实现要调用多个小接口,一方面如果接口调用走网络(特别是公网),多次远程接口调用会影响性能;另一方面,本该在一个接口中完成的原子操作,现在分拆成多个小接口来完成,就可能会涉及分布式事务的数据一致性问题(一个接口执行成功了,但另一个接口执行失败了)。所以,为了兼顾易用性和性能,可以借鉴 facade(外观)设计模式,在职责单一的细粒度接口之上,再封装一层粗粒度的接口给外部使用

积分系统需要设计如下这样几个接口:

ここに画像の説明を挿入

10.3.3 业务模型的设计

从代码实现角度来说,大部分业务系统的开发都可以分为 Controller、Service、Repository 三层。Controller 层负责接口暴露,Repository 层负责数据读写,Service 层负责核心业务逻辑,也就是这里说的业务模型

除此之外,前面还提到两种开发模式,基于贫血模型的传统开发模式和基于充血模型的 DDD 开发模式(见:设计模式之美总结(面向对象篇)_凡 223 的博客)。前者是一种面向过程的编程风格,后者是一种面向对象的编程风格。不管是 DDD 还是 OOP,高级开发模式的存在一般都是为了应对复杂系统,应对系统的复杂性。对于这里要开发的积分系统来说,因为业务相对比较简单,所以,选择简单的基于贫血模型的传统开发模式就足够了

从开发的角度来说,可以把积分系统作为一个独立的项目,来独立开发,也可以跟其他业务代码(比如营销系统)放到同一个项目中进行开发。从运维的角度来说,可以将它跟其他业务一块部署,也可以作为一个微服务独立部署。具体选择哪种开发和部署方式,可以参考公司当前的技术架构来决定

実際、ポイント システム ビジネスは比較的単純で、コードの量も少なく、1 つのプロジェクトでマーケティング システムと開発および展開する傾向があります。コードのモジュール化とデカップリングがうまく行われている限り、ポイント関連の業務コードと他の業務コードとの境界は明確であり、カップリングはあまりありません.開発と展開のために独立したプロジェクトに分割する必要がある場合後で、それは難しい必要はありません

10.4 なぜ MVC 3 層開発が必要なのですか?

ほとんどの業務システムの開発は、コントローラー層、サービス層、およびリポジトリー層の 3 つの層に分けることができます。ほとんどの人はこのレイヤード アプローチに同意し、開発の習慣にもなっていますが、なぜレイヤード開発を行うのでしょうか? 多くのビジネスは比較的単純ですが、データの読み取り、ビジネス ロジック、およびインターフェイスの公開をすべて 1 つのコード レイヤーで処理するのは適切ではないでしょうか。

1. 階層化はコードの再利用に役立つ

同じリポジトリが複数のサービスから呼び出される場合があり、同じサービスが複数のコントローラから呼び出される場合があります。たとえば、UserService のgetUserById()インターフェイスは ID を介してユーザー情報を取得するロジックをカプセル化し、ロジックのこの部分は、UserController や AdminController などの複数の Controller で使用できます。サービス層がない場合、各コントローラーはロジックのこの部分を繰り返し実装する必要があり、明らかに DRY 原則に違反します。

2. レイヤー化は変更を分離する役割を果たします

レイヤリングは、抽象化とカプセル化のデザイン アイデアを具現化します。たとえば、リポジトリ層はデータベース アクセスの操作をカプセル化し、抽象的なデータ アクセス インターフェイスを提供します。実装プログラミングではなくインターフェースの設計思想に基づいて、サービス層はリポジトリ層によって提供されるインターフェースを使用し、その下にある層が依存する特定のデータベースを気にしません。MySQL から Oracle、Oracle から Redis など、データベースの入れ替えが必要な場合、リポジトリ層のコードを変更するだけで済み、サービス層のコードは一切変更する必要がありません。

また、Controller、Service、Repository の 3 層のコードは安定度が異なり、変更の原因となるため、コードを 3 層に整理することで効果的に変更を分離することができます。たとえば、リポジトリ レイヤーはデータベース テーブルに基づいており、データベース テーブルが変更される可能性は非常に小さいため、リポジトリ レイヤーのコードは最も安定しており、コントローラー レイヤーは外部使用に適合したインターフェイスを提供します。コードは頻繁に変更されます。レイヤ化後、コントローラ レイヤで頻繁にコードを変更しても、安定したリポジトリ レイヤには影響しません。

3.階層化は、懸念を分離するのに役割を果たすことができます

リポジトリ層は、データの読み取りと書き込みのみに関係しています。サービス層は、データのソースではなく、ビジネス ロジックのみに焦点を当てています。コントローラー層は、外界の処理、データ検証、カプセル化、およびフォーマット変換のみに焦点を当てており、ビジネス ロジックには関心がありません。3つの層の焦点は異なり、層化後、責任が明確に定義され、単一責任の原則により一致し、コードの結束が向上します。

4. 階層化により、コードのテスト容易性を向上させることができます

単体テストは、データベースなどの制御できない外部コンポーネントに依存しません。階層化後、リポジトリ層のコードは、依存性注入を通じてサービス層によって使用されます.コアビジネスロジックを含むサービス層コードをテストするとき、実際のデータベースの代わりにモックデータソースを使用して、サービス層コードに注入できます.

5. システムの複雑さに対応できるレイヤリング

すべてのコードはクラスに入れられ、このクラスのコードは要件の反復により無限に拡張されます。クラスや関数のコードが多すぎると、可読性や保守性が低下します。次に、それを分割する方法を見つけます。分割には、縦方向と横方向の両方があります。横方向の業務による分割がモジュール化、縦方向のプロセスによる分割がここでいう階層化

レイヤー化、モジュール化、OOP、DDD、さまざまな設計パターン、原則、およびアイデアのいずれであっても、それらはすべて、複雑なシステムとシステムの複雑さに対処するように設計されています。単純なシステムの場合、「雄牛のナイフでニワトリを殺す」ということわざのように、実際に役割を果たすことはできません。

10.5 BO、VO、エンティティの存在意義は?

Controller、Service、Repository の 3 つのレイヤーについて、各レイヤーは対応するデータ オブジェクト (VO (View Object)、BO (Business Object)、および Entity (UserVo、UserBo、UserEntity など) を定義します) を定義します。実際の開発では、VO、BO、Entity で多数の繰り返しフィールドが存在する場合があり、3 つに含まれるフィールドもまったく同じです。開発の過程では、ほとんど同じクラスを 3 つ定義し直す必要があることがよくありますが、これは明らかに一種の反復作業です。

各レイヤーが独自のデータ オブジェクトを定義するよりも、共通のデータ オブジェクトを定義する方がよいでしょうか?

実際、主に次の 3 つの理由から、各レイヤーで独自のデータ オブジェクト設計のアイデアを定義することをお勧めします。

  1. VO、BO、およびエンティティはまったく同じではありません。たとえば、パスワード フィールドは UserEntity と UserBo で定義できますが、明らかにパスワード フィールドは UserVo で定義できません。そうしないと、ユーザーのパスワードが公開されます。
  2. VO、BO、エンティティのコードは繰り返されますが、機能的なセマンティクスは繰り返されず、責任の点で異なります。したがって、DRY の原則に違反しているとは見なされません。先ほどDRYの原則についてお話ししましたが、この場合、同じクラスにマージすると、要件の変更により後で分割する必要があるという問題もあります。
  3. 各レイヤー間の結合を最小限に抑え、責任の境界を明確に分割するために、各レイヤーは独自のデータ オブジェクトを保持し、レイヤーはインターフェイスを介して対話します。下位層から上位層にデータが渡されると、下位層のデータオブジェクトが上位層のデータオブジェクトに変換され、処理が続行されます。このような設計は少し面倒ですが、各レイヤーは独自のデータ オブジェクトを定義する必要があり、データ オブジェクト間で変換する必要がありますが、レイヤーは明確です。非常に大規模なプロジェクトでは、構造の明確さが第一です。

VO、BO、エンティティをマージできないため、コードの重複の問題を解決するにはどうすればよいですか?

設計の観点からは、VO、BO、およびエンティティの設計思想は DRY 原則に違反していません.階層化を明確にし、結合を減らすために、いくつかのクラスを維持するコストは容認できません. ただし、コードの重複の問題を解決する方法もいくつかあります。

継承により、コードの重複の問題を解決できます。パブリック フィールドは親クラスで定義できるため、VO、BO、およびエンティティはすべてこの親クラスから継承され、それぞれが一意のフィールドのみを定義します。ここでの継承レベルは非常に浅く単純なので、継承を使用してもコードの可読性と保守性には影響しません。後で、ビジネス ニーズにより、一部のフィールドを親クラスからサブクラスに移動するか、サブクラスから親クラスに抽出する必要がある場合、コードの変更は複雑ではありません。

「組み合わせを多用し、継承を少なくする」という設計思想について話すと、組み合わせによってコードの重複の問題も解決できることが言及されています。したがって、パブリック フィールドをパブリック クラスに抽出することも可能です。VO 、BO、およびエンティティは組み合わせ関係を使用できます。このクラスのコードを再利用するには

コードの重複の問題は解決されましたが、異なるレイヤー間でデータ オブジェクトを変換するにはどうすればよいでしょうか?

下位層のデータがインターフェイス呼び出しを介して上位層に渡された後、対応する上位層のデータ オブジェクト型に変換する必要があります。たとえば、サービス レイヤーは、リポジトリ レイヤーからエンティティを取得した後、それを BO に変換し、ビジネス ロジックの処理を続行します。したがって、開発プロセス全体には、「エンティティから BO」と「BO から VO」の 2 つの変換が含まれます。

変換する最も簡単な方法は、手動でコピーすることです。2 つのオブジェクト、1 つのフィールドと 1 つのフィールド割り当ての間に独自のコードを記述します。しかし、そのようなアプローチは、技術的な内容のない低レベルの労働であることは明らかです。Java には、BeanUtils、Dozer などのさまざまなデータ オブジェクト変換ツールが用意されており ( MapStruct Summary_Fan 223 のブログを参照)、面倒なオブジェクト変換作業を大幅に簡素化できます。開発に他のプログラミング言語を使用する場合は、これらのツール クラスの設計思想を Java で学習し、オブジェクト変換ツール クラスを自分でプロジェクトに実装することもできます。

VO、BO、Entityはいずれも貧血モデルに基づいており、フレームワークや開発ライブラリ(MyBatisなど)との互換性を保つために、各フィールドのsetメソッドも定義する必要があります。これらは、OOP のカプセル化特性に反しており、データが自由に変更されます。だから何をすべきか?

前面也提到过,Entity 和 VO 的生命周期是有限的,都仅限在本层范围内。而对应的 Repository 层和 Controller 层也都不包含太多业务逻辑,所以也不会有太多代码随意修改数据,即便设计成贫血、定义每个字段的 set 方法,相对来说也是安全的

不过,Service 层包含比较多的业务逻辑代码,所以 BO 就存在被任意修改的风险了。但是,设计的问题本身就没有最优解,只有权衡。为了使用方便,只能做一些妥协,放弃 BO 的封装特性,由程序员自己来负责这些数据对象的不被错误使用

10.6 用到的设计原则和思想

高内聚、松耦合 将不同的功能划分到不同的模块,遵从的划分原则就是尽量让模块本身高内聚,让模块之间松耦合
单一职责原则 模块的设计要尽量职责单一,符合单一职责原则。分层的一个目的也是为了更加符合单一职责原则
依赖注入 在 MVC 三层结构的代码实现中,下一层的类通过依赖注入的方式注入到上一层代码中
依赖反转原则 在业务系统开发中,如果通过类似 Spring IOC 这样的容器来管理对象的创建、生命周期,那就用到了依赖反转原则
基于接口而非实现编程 在 MVC 三层结构的代码实现中,Service 层使用 Repository 层提供的接口,并不关心其底层是依赖的哪种具体的数据库,遵从基于接口而非实现编程的设计思想
封装、抽象 分层体现了抽象和封装的设计思想,能够隔离变化,隔离关注点。尽管 VO、BO、Entity 存在代码重复,但功能语义不同,并不违反 DRY 原则
DRY 与继承和组合 为了解决三者之间的代码重复问题,还用到了继承或组合
DRY 面向对象设计 系统设计的过程可以参照面向对象设计的步骤来做。面向对象设计本质是将合适的代码放到合适的类中。系统设计是将合适的功能放到合适的模块中

11. 针对非业务的通用框架开发,如何做需求分析和设计?

11.1 项目背景

応答時間の最大値(max)、最小値(min)、平均値(avg)、パーセンタイル値(percentile)、インターフェイス 呼び出し回数(count)、頻度(tps)など、各種端末への各種表示形式(例:JSON形式、Webページ形式、カスタム表示形式など)での統計結果の出力に対応(コンソール コマンド ライン、HTTP Web ページ、電子メール、ログ ファイル、カスタム出力端子など) を簡単に表示

このような一般的なフレームワークを開発し、さまざまなビジネス システムに適用してリアルタイムのデータ統計の計算と表示をサポートする場合、どのように設計して実装するのでしょうか。

11.2 要件分析

ビジネスとは関係のない機能として、パフォーマンス カウンターは独立したフレームワークまたはクラス ライブラリに開発され、多くのビジネス システムに統合できます。再利用可能なフレームワークとして、機能要件に加えて非機能要件も非常に重要です。したがって、次の 2 つの側面から需要分析を行います。

11.2.1 機能要件分析

テキスト記述の長いリストと比較して、人間の脳は、短く、整理され、分類されたリスト情報を理解しやすくなります。明らかに、今の要件記述はこのルールに準拠していません。1 つずつ「ドライ ストリップ」に分解する必要があります。

  • インターフェース統計: インターフェース応答時間の統計、インターフェース呼び出しの統計などを含みます。
  • 統計の種類: 最大、最小、平均、パーセンタイル、カウント、tps など。
  • 統計情報の表示形式:Json、Html、カスタム表示形式
  • 統計情報表示端末:コンソール、メール、HTTP Webページ、ログ、カスタム表示端末

また、最終的なデータの表示スタイルは、製品設計でよく使われるワイヤーフレーム図を使って描くことができるので、一目瞭然です。具体的なワイヤーフレームは次のとおりです。

ここに画像の説明を挿入

実際、ワイヤフレーム図から、次の隠れた要件を見つけることができます。

  • 統計的トリガー方法: アクティブおよびパッシブを含む.
    アクティブとは、一定の頻度でデータを定期的にカウントし、電子メールのプッシュなど、表示端末にアクティブにプッシュすることを意味します。パッシブとは、ユーザーが統計をトリガーすることを意味します。たとえば、ユーザーは Web ページでカウントする時間間隔を選択し、統計をトリガーし、結果をユーザーに表示します。
  • 统计时间区间:框架需要支持自定义统计时间区间
    比如统计最近 10 分钟的某接口的 tps、访问次数,或者统计 12 月 11 日 00 点到 12 月 12 日 00 点之间某接口响应时间的最大值、最小值、平均值等
  • 统计时间间隔:对于主动触发统计,还要支持指定统计时间间隔
    也就是多久触发一次统计显示。比如,每间隔 10s 统计一次接口信息并显示到命令行中,每间隔 24 小时发送一封统计信息邮件

11.2.2 非功能性需求分析

对于这样一个通用的框架的开发,还需要考虑很多非功能性的需求。具体来说有以下几个比较重要的方面:

  1. 易用性
    易用性听起来更像是一个评判产品的标准。在开发这样一个技术框架的时候,也要有产品意识。框架是否易集成、易插拔、跟业务代码是否松耦合、提供的接口是否够灵活等等,都是应该花心思去思考和设计的。有的时候,文档写得好坏甚至都有可能决定一个框架是否受欢迎

  2. 性能
    对于需要集成到业务系统的框架来说,不希望框架本身的代码执行效率,对业务系统有太多性能上的影响。对于性能计数器这个框架来说,一方面,希望它是低延迟的,也就是说,统计代码不影响或很少影响接口本身的响应时间;另一方面,希望框架本身对内存的消耗不能太大

  3. 扩展性
    这里说的扩展性跟之前讲到的代码的扩展性有点类似,都是指在不修改或尽量少修改代码的情况下添加新的功能。但是这两者也有区别。之前讲到的扩展是从框架代码开发者的角度来说的。这里所说的扩展是从框架使用者的角度来说的,特指使用者可以在不修改框架源码,甚至不拿到框架源码的情况下,为框架扩展新的功能。这就有点类似给框架开发插件。如下例:
    feign 是一个 HTTP 客户端框架,可以在不修改框架源码的情况下,用如下方式来扩展自己的编解码方式、日志、拦截器等

    Feign feign = Feign.builder()
            .logger(new CustomizedLogger())
            .encoder(new FormEncoder(new JacksonEncoder()))
            .decoder(new JacksonDecoder())
            .errorDecoder(new ResponseErrorDecoder())
            .requestInterceptor(new RequestHeadersInterceptor()).build();
    
    public class RequestHeadersInterceptor implements RequestInterceptor {
          
          
        @Override
        public void apply(RequestTemplate template) {
          
          
            template.header("appId", "...");
            template.header("version", "...");
            template.header("timestamp", "...");
            template.header("token", "...");
            template.header("idempotent-token", "...");
            template.header("sequence-id", "...");
        }
    }
    
    public class CustomizedLogger extends feign.Logger {
          
          
        //...
    }
    
    public class ResponseErrorDecoder implements ErrorDecoder {
          
          
        @Override
        public Exception decode(String methodKey, Response response) {
          
          
            //...
        }
    }
    
  4. フォールト トレランス パフォーマンス
    カウンター フレームワークでは、フレームワーク自体の例外によってインターフェイス リクエスト エラーが発生することはありません。したがって、フレームワークに存在する可能性のあるあらゆる種類の異常な状況を考慮し、外部に公開されたインターフェイスによってスローされるすべての実行時および非実行時例外をキャプチャして処理する必要があります。

  5. 汎用性
    フレームワークの再利用性を向上させるために、さまざまなシナリオに柔軟に適用できます。フレームワークを設計するときは、できるだけ一般的なものにする必要があります。インターフェイス統計の要件に加えて、SQL 要求時間の統計やビジネス統計 (支払いの成功率など) などの他のイベントの統計も処理できるかどうかなど、他にどのようなシナリオに適用できるかをさらに考える必要があります。 ) 待つ

11.3 フレームワークの設計

少し複雑なシステムの開発では、どこから始めればよいかわからないと感じている人も多いでしょう。筆者は個人的に、TDD (テスト駆動開発) とプロトタイプ (最小限のプロトタイプ) のアイデアから学ぶのが好きで、最初は単純なアプリケーション シナリオに焦点を当て、この設計に基づいて単純なプロトタイプを実装します。この最小限のプロトタイプ システムは、機能と機能以外の機能の点で完璧ではありませんが、見たり触れたりすることができ、より具体的で抽象的ではありません. より複雑な設計アイデアを効果的に理解するのに役立ちます. それは反復設計です. . ファンデーション

これは、アルゴリズムの問​​題を行うようなものです。一度に最適な解を導き出したい場合は、最初にいくつかのテスト データ セットを記述し、ルールを探してから、それを解くための最も単純なアルゴリズムを考えます。この最も単純なアルゴリズムは、時間と空間の複雑さの点で満足できないかもしれませんが、これに基づいて最適化できるため、思考がよりスムーズになります。

パフォーマンス カウンター フレームワークの開発では、まず、ユーザー登録とログインの 2 つのインターフェイスの最大応答時間と平均応答時間、およびインターフェイス呼び出しの数をカウントするなど、非常に具体的で単純なアプリケーション シナリオに焦点を当てることができます。 JSON形式でコマンドラインに出力されます。現在、この要件はシンプルで具体的かつ明確であり、設計と実装の難しさが大幅に軽減されています。

// 应用场景:统计下面两个接口 (注册和登录)的响应时间和访问次数
public class UserController {
    
    
    public void register(UserVo user) {
    
    
        //...
    }
    public UserVo login(String telephone, String password) {
    
    
        //...
    }
}

インターフェイスの最大値、平均値、およびインターフェイス呼び出しの数を出力するには、最初に各インターフェイス要求の応答時間を収集して保存し、次に一定の時間間隔に従って集計およびカウントし、最後に結果を出力する必要があります。プロトタイプ システムのコード実装では、すべてのコードをクラスに詰め込むことができ、コードの品質、スレッド セーフ、パフォーマンス、スケーラビリティなどを考慮する必要はありません。

最小限のプロトタイプのコード実装を以下に示します。その中で、インターフェイス要求の応答時間とアクセス時間をそれぞれ記録するために2 つのrecordResponseTime()関数が使用されます。関数は、指定された頻度でデータをカウントし、結果を出力しますrecordTimestamp()startRepeatedReport()

public class Metrics {
    
    
    // Map 的 key 是接口名称,value 对应接口请求的响应时间或时间戳;
    private Map<String, List<Double>> responseTimes = new HashMap<>();
    private Map<String, List<Double>> timestamps = new HashMap<>();
    private ScheduledExecutorService executor = Executors.newSingleThreadSchedule;

    public void recordResponseTime(String apiName, double responseTime) {
    
    
        responseTimes.putIfAbsent(apiName, new ArrayList<>());
        responseTimes.get(apiName).add(responseTime);
    }

    public void recordTimestamp(String apiName, double timestamp) {
    
    
        timestamps.putIfAbsent(apiName, new ArrayList<>());
        timestamps.get(apiName).add(timestamp);
    }

    public void startRepeatedReport(long period, TimeUnit unit) {
    
    
        executor.scheduleAtFixedRate(new Runnable() {
    
    
            @Override
            public void run() {
    
    
                Gson gson = new Gson();
                Map<String, Map<String, Double>> stats = new HashMap<>();
                for (Map.Entry<String, List<Double>> entry : responseTimes.entrySet())
                    String apiName = entry.getKey();
                    List<Double> apiRespTimes = entry.getValue();
                    stats.putIfAbsent(apiName, new HashMap<>());
                    stats.get(apiName).put("max", max(apiRespTimes));
                    stats.get(apiName).put("avg", avg(apiRespTimes));
                 }
                for(Map.Entry<String, List<Double>> entry :timestamps.entrySet()) {
    
    
                    String apiName = entry.getKey();
                    List<Double> apiTimestamps = entry.getValue();
                    stats.putIfAbsent(apiName, new HashMap<>());
                    stats.get(apiName).put("count", (double) apiTimestamps.size());
                 }
                System.out.println(gson.toJson(stats));
            }
        },0,period,unit);
    }

    private double max(List<Double> dataset) {
    
     // 省略代码实现 }
    private double avg (List < Double > dataset) {
    
     // 省略代码实现 }
}

最小限のプロトタイプが 50 行未満のコードで実装されました。次に、これを使用して、登録およびログイン インターフェイスの応答時間と訪問回数をカウントする方法を見てみましょう。具体的なコードは次のとおりです。

// 应用场景:统计下面两个接口 (注册和登录)的响应时间和访问次数
public class UserController {
    
    
    private Metrics metrics = new Metrics();

    public UserController() {
    
    
        metrics.startRepeatedReport(60, TimeUnit.SECONDS);
    }
    public void register(UserVo user) {
    
    
        long startTimestamp = System.currentTimeMillis();
        metrics.recordTimestamp("regsiter", startTimestamp);
        //...
        long respTime = System.currentTimeMillis() - startTimestamp;
        metrics.recordResponseTime("register", respTime);
    }
    public UserVo login(String telephone, String password) {
    
    
        long startTimestamp = System.currentTimeMillis();
        metrics.recordTimestamp("login", startTimestamp);
        //...
        long respTime = System.currentTimeMillis() - startTimestamp;
        metrics.recordResponseTime("login", respTime);
    }
}

最小限のプロトタイプのコード実装は大雑把ですが、それは私たちの考えを整理するのに大いに役立ちました。そして今、それに基づいて最終的なフレームワークの設計を行います。以下は、パフォーマンス カウンター フレームワークの大まかなシステム設計図です。ダイアグラムはデザインのアイデアを非常に直感的に反映することができ、他の詳細について考えるために脳のスペースを効果的に解放するのに役立ちます

ここに画像の説明を挿入

図に示すように、フレームワーク全体は、データの取得、保存、集計統計、および表示の 4 つのモジュールに分割されます。各モジュールが担当する作業は、次のように簡単にリストされています。

1. データ収集

各インターフェイス要求の応答時間と要求時間を記録するなど、生データの収集を担当します。データ収集プロセスは、高い耐障害性を備えている必要があり、インターフェイス自体の使いやすさに影響を与えることはできません。また、この部分の関数はフレームワークのユーザーに公開されているため、データ収集 API を設計する際には、その使いやすさを考慮するようにしてください。

2.保管

収集した生データを後の集計統計のために保存する責任があります。データを保存するには、Redis、MySQL、HBase、ログ、ファイル、メモリなど、さまざまな方法があります。データの保存には時間がかかります. インターフェイスのパフォーマンス (応答時間など) への影響を最小限に抑えるために、収集と保存のプロセスは非同期で完了します.

3. 集計統計

最大、最小、平均、ペンセンタイル、カウント、tps などの生データを統計データに集約する責任があります。より多くの集計統計ルールをサポートするために、コードは可能な限り柔軟で拡張可能であることを望んでいます

4.表示

コマンドライン、メール、Webページ、カスタムディスプレイ端末などへの出力など、統計データを特定の形式で端末に表示する責任があります。

オブジェクト指向の分析、設計、および実装について説明したとき、設計段階の最終的なアウトプットはクラスの設計であると述べましたが、同時に、ソフトウェアの設計と開発は反復的なプロセスであると述べました。分析、設計、実装の 3 つの段階の区分が明確でない

11.4 小さなステップで実行し、ステップごとに繰り返す

上記では、フレームワーク全体が、データ収集、ストレージ、集計統計、および表示の 4 つのモジュールに分割されています。さらに、統計トリガー方法 (アクティブ プッシュ、パッシブ トリガー統計)、統計時間間隔 (どの期間のデータがカウントされるか)、および統計時間間隔 (アクティブ プッシュ方法の場合、統計がプッシュされる頻度) も簡素化されました。のデザイン

最小限のプロトタイプは、反復開発の基礎を提供してくれましたが、最終的にフレームワークをどのように見せたいかということにはまだほど遠いものでした。この記事を書いているとき、完璧なフレームワークを書きたいと思って、上記のすべての機能要件を達成しようとしましたが、これは非常に頭の痛い問題であることがわかりました。 「脳の『足りない』感覚。これは10年以上の実務経験を持つ人に当てはまります.経験の浅い開発者にとって、すべての要件を一度に実現することは非常に困難なことです. スムーズに完了できないと、強い欲求不満が生じ、自己否定の気分に陥る可能性があります。

ただし、すべての要件を実現する能力があったとしても、設計に多大な労力と開発時間がかかり、長期にわたってアウトプットが得られず、リーダーは強い制御不能感を抱くでしょう。今日のインターネット プロジェクトでは、小さなステップで実行し、段階的に反復することがより優れた開発モデルです。したがって、このフレームワークは、複数のバージョンで徐々に改善する必要があります。最初のバージョンでは、いくつかの基本的な機能を最初に実装できます。より高度で複雑な機能に対する高い要件はなく、機能以外の要件もありません。反復的な最適化は、後続の v2.0、v3.0 で継続されます。 . バージョン

このフレームワークの開発にあたり、v1.0版では以下の機能のみを暫定的に実装しています。残りの機能は v2.0、v3.0 バージョンのまま

  1. データ収集: 各インターフェース要求の応答時間と要求時間を記録するなど、生データの収集を担当します。
  2. ストレージ: 収集した生データを後で集計するために保存する責任があります。データの保存方法は多数あり、当面はRedisのみをサポートし、収集と保存の2つのプロセスは同期的に実行されます
  3. 集約された統計: 最大、最小、平均、99.9 パーセンタイル、応答時間の 99 パーセンタイル、およびインターフェイス要求と tps の数を含む、生データを統計データに集約する責任があります。
  4. 表示: 統計データを特定の形式で端末に表示する責任があります.当面は、コマンドラインと電子メールへのアクティブなプッシュのみをサポートします. コマンド ラインは、最後の m 秒のデータを n 秒間隔でカウントして表示します (たとえば、60 秒間隔での最後の 60 秒の統計)。前日データの日別メール統計

现在这个版本的需求比之前的要更加具体、简单了,实现起来也更加容易一些。实际上,学会结合具体的需求,做合理的预判、假设、取舍,规划版本的迭代设计开发,也是一个资深工程师必须要具备的能力

在之前,是把面向对象设计与实现分开来讲解,界限划分比较明显。在实际的软件开发中,这两个过程往往是交叉进行的。一般是先有一个粗糙的设计,然后着手实现,实现的过程发现问题,再回过头来补充修改设计。所以,对于这个框架的开发来说,把设计和实现放到一块来讲解

最小原型的实现,所有的代码都耦合在一个类中,这显然是不合理的。接下来,就按照之前讲的面向对象设计的几个步骤,来重新划分、设计类

11.4.1 划分职责进而识别出有哪些类

根据需求描述,先大致识别出下面几个接口或类。这一步不难,完全就是翻译需求

  • MetricsCollector 类负责提供 API,来采集接口请求的原始数据。可以为 MetricsCollector 抽象出一个接口,但这并不是必须的,因为暂时只能想到一个 MetricsCollector 的实现方式
  • MetricsStorage 接口负责原始数据存储,RedisMetricsStorage 类实现 MetricsStorage 接口。这样做是为了今后灵活地扩展新的存储方法,比如用 HBase 来存储
  • Aggregator 类负责根据原始数据计算统计数据
  • ConsoleReporter 类、EmailReporter 类分别负责以一定频率统计并发送统计数据到命令行和邮件。至于 ConsoleReporter 和 EmailReporter 是否可以抽象出可复用的抽象类,或者抽象出一个公共的接口,暂时还不能确定

11.4.2 定义类及类与类之间的关系

接下来就是定义类及属性和方法,定义类与类之间的关系。这两步没法分得很开,所以,将它们合在一起来讲解

大致地识别出几个核心的类之后,可以先在 IDE 中创建好这几个类,然后开始试着定义它们的属性和方法。在设计类、类与类之间交互的时候,不断地用之前学过的设计原则和思想来审视设计是否合理,比如,是否满足单一职责原则、开闭原则、依赖注入、KISS 原则、DRY 原则、迪米特法则,是否符合基于接口而非实现编程思想,代码是否高内聚、低耦合,是否可以抽象出可复用代码等等

MetricsCollector 类的定义非常简单,具体代码如下所示。对比最小原型的代码,MetricsCollector 通过引入 RequestInfo 类来封装原始数据信息,用一个采集函数代替了之前的两个函数

public class MetricsCollector {
    
    
    private MetricsStorage metricsStorage; // 基于接口而非实现编程
    // 依赖注入
    public MetricsCollector(MetricsStorage metricsStorage) {
    
    
        this.metricsStorage = metricsStorage;
    }
    // 用一个函数代替了最小原型中的两个函数
    public void recordRequest(RequestInfo requestInfo) {
    
    
        if (requestInfo == null || StringUtils.isBlank(requestInfo.getApiName())) {
    
    
            return;
        }
        metricsStorage.saveRequestInfo(requestInfo);
    }
}
public class RequestInfo {
    
    
    private String apiName;
    private double responseTime;
    private long timestamp;
    //... 省略 constructor/getter/setter 方法...
}

MetricsStorage 类和 RedisMetricsStorage 类的属性和方法也比较明确。具体的代码实现如下所示。注意,一次性取太长时间区间的数据,可能会导致拉取太多的数据到内存中,有可能会撑爆内存。对于 Java 来说,就有可能会触发 OOM(Out Of Memory)。而且,即便不出现 OOM,内存还够用,但也会因为内存吃紧,导致频繁的 Full GC,进而导致系统接口请求处理变慢,甚至超时

public interface MetricsStorage {
    
    
    void saveRequestInfo(RequestInfo requestInfo);
    List<RequestInfo> getRequestInfos(String apiName, long startTimeInMillis, long endTimeInMillis);
    Map<String, List<RequestInfo>> getRequestInfos(long startTimeInMillis, long endTimeInMillis);
}

public class RedisMetricsStorage implements MetricsStorage {
    
    
    //... 省略属性和构造函数等...
    @Override
    public void saveRequestInfo(RequestInfo requestInfo) {
    
    
        //...
    }
    @Override
    public List<RequestInfo> getRequestInfos(String apiName, long startTimestamp) {
    
    
        //...
    }
    @Override
    public Map<String, List<RequestInfo>> getRequestInfos(long startTimestamp, long endTimeInMillis) {
    
    
        //...
    }
}

MetricsCollector 类和 MetricsStorage 类的设计思路比较简单,不同的人给出的设计结果应该大差不差。但是,统计和显示这两个功能就不一样了,可以有多种设计思路。实际上,如果把统计显示所要完成的功能逻辑细分一下的话,主要包含下面 4 点:

  1. 根据给定的时间区间,从数据库中拉取数据
  2. 根据原始数据,计算得到统计数据
  3. 将统计数据显示到终端(命令行或邮件)
  4. 定时触发以上 3 个过程的执行

実際、一言で言えば、オブジェクト指向の設計と実装で必要なことは、適切なコードを適切なクラスに配置することですしたがって、ここで行う作業は、上記の 4 つの関数をいくつかのクラスに論理的に分割することです。分け方はいろいろありますが、例えば、最初の2つのロジックを1つのクラスに、3番目のロジックを別のクラスに、4番目のロジックを最初の2つのクラスと組み合わせて神クラス(God Class)として、実行のトリガーにすることができます。最初の 3 つのロジック。もちろん、2 番目のロジックを単独でクラスに配置し、1 番目、3 番目、4 番目を別のクラスに配置することもできます。

どの順列・組み合わせ方式を採用するかは、結合度が低い、結束度が高い、責任が一つしかない、拡張に寛容、改変に閉ざされているなど、先に述べたさまざまな設計原則や考え方をコードが満たすかどうかが判断基準となります。この設計は、コードの再利用性、可読性、拡張、および保守の要件を満たしています。

ここでは、1 番目、3 番目、4 番目のロジックを ConsoleReporter または EmailReporter クラスに配置し、2 番目のロジックを Aggregator クラスに配置することを一時的に選択します。その中で、Aggregator クラスを担当するロジックは比較的単純であり、静的メソッドのみを含むツール クラスとして設計されています。具体的なコードの実装は次のとおりです。

public class Aggregator {
    
    
    public static RequestStat aggregate(List<RequestInfo> requestInfos, long duration) {
    
    
        double maxRespTime = Double.MIN_VALUE;
        double minRespTime = Double.MAX_VALUE;
        double avgRespTime = -1;
        double p999RespTime = -1;
        double p99RespTime = -1;
        double sumRespTime = 0;
        long count = 0;

        for (RequestInfo requestInfo : requestInfos) {
    
    
            ++count;
            double respTime = requestInfo.getResponseTime();
            if (maxRespTime < respTime) {
    
    
                maxRespTime = respTime;
            }
            if (minRespTime > respTime) {
    
    
                minRespTime = respTime;
            }
            sumRespTime += respTime;
        }
        if (count != 0) {
    
    
            avgRespTime = sumRespTime / count;
        }
        long tps = (long) (count / durationInMillis * 1000);
        Collections.sort(requestInfos, new Comparator<RequestInfo>() {
    
    
            @Override
            public int compare(RequestInfo o1, RequestInfo o2) {
    
    
                double diff = o1.getResponseTime() - o2.getResponseTime();
                if (diff < 0.0) {
    
    
                    return -1;
                } else if (diff > 0.0) {
    
    
                    return 1;
                } else {
    
    
                    return 0;
                }
            }
        });
        int idx999 = (int) (count * 0.999);
        int idx99 = (int) (count * 0.99);
        if (count != 0) {
    
    
            p999RespTime = requestInfos.get(idx999).getResponseTime();
            p99RespTime = requestInfos.get(idx99).getResponseTime();
        }
        RequestStat requestStat = new RequestStat();
        requestStat.setMaxResponseTime(maxRespTime);
        requestStat.setMinResponseTime(minRespTime);
        requestStat.setAvgResponseTime(avgRespTime);
        requestStat.setP999ResponseTime(p999RespTime);
        requestStat.setP99ResponseTime(p99RespTime);
        requestStat.setCount(count);
        requestStat.setTps(tps);
        return requestStat;
    }
}

public class RequestStat {
    
    
    private double maxResponseTime;
    private double minResponseTime;
    private double avgResponseTime;
    private double p999ResponseTime;
    private double p99ResponseTime;
    private long count;
    private long tps;
    //... 省略 getter/setter 方法...
}

ConsoleReporter クラスは神のクラスに相当し、一定の時間間隔に従って定期的にデータベースからデータをフェッチし、Aggregator クラスを使用して統計作業を完了し、統計結果をコマンド ラインに出力します。具体的なコードの実装は次のとおりです。

public class ConsoleReporter {
    
    
    private MetricsStorage metricsStorage;
    private ScheduledExecutorService executor;

    public ConsoleReporter(MetricsStorage metricsStorage) {
    
    
        this.metricsStorage = metricsStorage;
        this.executor = Executors.newSingleThreadScheduledExecutor();
    }

    // 第 4 个代码逻辑:定时触发第 1、2、3 代码逻辑的执行;
    public void startRepeatedReport(long periodInSeconds, long durationInSeconds) {
    
    
        executor.scheduleAtFixedRate(new Runnable() {
    
    
            @Override
            public void run() {
    
    
                // 第 1 个代码逻辑:根据给定的时间区间,从数据库中拉取数据;
                long durationInMillis = durationInSeconds * 1000;
                long endTimeInMillis = System.currentTimeMillis();
                long startTimeInMillis = endTimeInMillis - durationInMillis;
                Map<String, List<RequestInfo>> requestInfos =
                        metricsStorage.getRequestInfos(startTimeInMillis, endTimeInMillis);
                Map<String, RequestStat> stats = new HashMap<>();
                for (Map.Entry<String, List<RequestInfo>> entry : requestInfos.entrySet) {
    
    
                    String apiName = entry.getKey();
                    List<RequestInfo> requestInfosPerApi = entry.getValue();
                    // 第 2 个代码逻辑:根据原始数据,计算得到统计数据;
                    RequestStat requestStat = Aggregator.aggregate(requestInfosPerApi, durationInMillis);
                    stats.put(apiName, requestStat);
                }
                // 第 3 个代码逻辑:将统计数据显示到终端(命令行或邮件);
                System.out.println("Time Span: ["+startTimeInMillis +", "+endTimeInmillis);
                Gson gson = new Gson();
                System.out.println(gson.toJson(stats));
            }
        },0,periodInSeconds,TimeUnit.SECONDS);
    }
}

public class EmailReporter {
    
    
    private static final Long DAY_HOURS_IN_SECONDS = 86400L;
    private MetricsStorage metricsStorage;
    private EmailSender emailSender;
    private List<String> toAddresses = new ArrayList<>();

    public EmailReporter(MetricsStorage metricsStorage) {
    
    
        this(metricsStorage, new EmailSender(/* 省略参数 */));
    }

    public EmailReporter(MetricsStorage metricsStorage, EmailSender emailSender) {
    
    
        this.metricsStorage = metricsStorage;
        this.emailSender = emailSender;
    }

    public void addToAddress(String address) {
    
    
        toAddresses.add(address);
    }

    public void startDailyReport() {
    
    
        Calendar calendar = Calendar.getInstance();
        calendar.add(Calendar.DATE, 1);
        calendar.set(Calendar.HOUR_OF_DAY, 0);
        calendar.set(Calendar.MINUTE, 0);
        calendar.set(Calendar.SECOND, 0);
        calendar.set(Calendar.MILLISECOND, 0);
        Date firstTime = calendar.getTime();
        Timer timer = new Timer();
        timer.schedule(new TimerTask() {
    
    
            @Override
            public void run() {
    
    
                long durationInMillis = DAY_HOURS_IN_SECONDS * 1000;
                long endTimeInMillis = System.currentTimeMillis();
                long startTimeInMillis = endTimeInMillis - durationInMillis;
                Map<String, List<RequestInfo>> requestInfos =
                        metricsStorage.getRequestInfos(startTimeInMillis, endTimeInMillis);
                Map<String, RequestStat> stats = new HashMap<>();
                for (Map.Entry<String, List<RequestInfo>> entry : requestInfos.entrySet) {
    
    
                    String apiName = entry.getKey();
                    List<RequestInfo> requestInfosPerApi = entry.getValue();
                    RequestStat requestStat = Aggregator.aggregate(requestInfosPerApi, durationInMillis);
                    stats.put(apiName, requestStat);
                }
                // TODO: 格式化为 html 格式,并且发送邮件
            }
        }, firstTime, DAY_HOURS_IN_SECONDS * 1000);
    }
}

11.4.3 クラスの組み立てと実行エントリの提供

このフレームワークはやや特殊なため、2 つの実行入口があります: 1 つは生データを収集するための一連の API を提供する MetricsCollector クラスで、もう 1 つは統計表示をトリガーするために使用される ConsoleReporter クラスと EmailReporter クラスです。フレームワークの具体的な使用法は次のとおりです。

public class Demo {
    
    
    public static void main(String[] args) {
    
    
        MetricsStorage storage = new RedisMetricsStorage();
        ConsoleReporter consoleReporter = new ConsoleReporter(storage);
        consoleReporter.startRepeatedReport(60, 60);

        EmailReporter emailReporter = new EmailReporter(storage);
        emailReporter.addToAddress("[email protected]");
        emailReporter.startDailyReport();

        MetricsCollector collector = new MetricsCollector(storage);
        collector.recordRequest(new RequestInfo("register", 123, 10234));
        collector.recordRequest(new RequestInfo("register", 223, 11234));
        collector.recordRequest(new RequestInfo("register", 323, 12334));
        collector.recordRequest(new RequestInfo("login", 23, 12434));
        collector.recordRequest(new RequestInfo("login", 1223, 14234));
        try {
    
    
            Thread.sleep(100000);
        } catch (InterruptedException e) {
    
    
            e.printStackTrace();
        }
    }
}

11.4.4 設計と実装のレビュー

SOLID、KISS、DRY、YAGNI、LOD などの設計原則について、実装プログラミングではなくインターフェースに基づいていること、多目的な組み合わせと少ない継承、高い凝集性と低い結合性、およびその他の設計アイデアに言及しました。上記のコードの実装がこれらの設計原則とアイデアに準拠しているかどうかを見てみましょう

1、メトリクスコレクター

MetricsCollector はデータの収集と保存を担当し、その責任は比較的単一です。プログラミングの実装ではなくインターフェースに基づいており、依存性注入によって MetricsStorage オブジェクトを渡し、コードを変更せずにさまざまなストレージ メソッドを柔軟に置き換えることができ、オープンとクローズの原則を満たしています。

2、MetricsStorage、RedisMetricsStorage

MetricsStorage と RedisMetricsStorage の設計は比較的単純です。新しいストレージ メソッドを実装する必要がある場合は、MetricsStorage インターフェイスを実装するだけで済みます。MetricsStorage と RedisMetricsStorage が使用されるすべての場所は、アセンブリ クラスの変更 (RedisMetricsStorage から新しいストレージ実装クラスへ) を除いて、同じインターフェイス関数に基づいてプログラムされているため、他のインターフェイス関数呼び出しを変更する必要はなく、原則開閉は満足です。

3、アグリゲーター

Aggregator クラスは、約 50 行のコードを持つ 1 つの静的関数のみを含むツール クラスであり、さまざまな統計データの計算を担当します。新しい統計関数を拡張する必要がある場合はaggregate()関数統計関数を追加すると、この関数のコードサイズは増加し続け、可読性と保守性が低下します。したがって、今の分析から判断すると、このクラスの設計には、責任不足や拡張の困難さなどの問題がある可能性があり、将来のバージョンで構造を最適化する必要があります。

4、ConsoleReporter、EmailReporter

ConsoleReporter と EmailReporter にコード重複の問題があります。これら 2 つのクラスでは、データベースからデータを取得して統計を作成するロジックは同じであり、抽出して再利用できます。そうしないと、DRY 原則に違反します。さらに、クラス全体が多くのことに責任を負っており、責任はあまりにも単一ではありません。特にコードの表示部分はより複雑になる可能性があります (メールの表示方法など)。表示部分のコード ロジックを独立したクラスに分割することをお勧めします。また、コードはスレッド操作を伴い、Aggregator の静的関数を呼び出すため、コードのテスト容易性は良くありません。

ここに示したコードの実装にはまだ多くの問題があり、最終的な最適解を直接与えるよりもはるかに意味のある設計進化プロセス全体を示すために、後で徐々に最適化されます。実際、優れたコードはリファクタリングされ、複雑なコードは徐々に積み上げられます

おすすめ

転載: blog.csdn.net/ACE_U_005A/article/details/127412458