デザインパターンの美しさまとめ(リファクタリング)


title: デザインパターンの美しさまとめ(リファクタリング)
date: 2022-10-27 17:31:42
tags:

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

記事ディレクトリ


最初の 2 つの記事を参照してください。

1。概要

1.1 リファクタリングの目的: なぜリファクタリングするのか (なぜ)?

ソフトウェア設計の第一人者である Martin Fowler は、リファクタリングを次のように定義しています。

実際、リファクタリングに関して多くの本がこの定義を参照しています。この定義には、強調する価値のあるポイントがあります。「リファクタリングは、外部から見える動作を変更しません」。リファクタリングは、機能を変更しないという前提の下で、設計のアイデア、原則、パターン、プログラミング仕様、およびその他の理論を使用して、コードを最適化し、設計の欠陥を修正し、コードの品質を向上させることと理解できます。

コードのリファクタリングを行う理由

1. リファクタリングは、常にコードの品質を確保するための非常に効果的な手段です

プロジェクトは進化しており、コードは常に積み上げられています。コードの品質に誰も責任を負わない場合、コードは常に混乱の方向に進化します。混乱が一定のレベルに達すると、量的な変化が質的な変化につながり、プロジェクトのメンテナンス コストは、新しいコード セットを再開発するコストよりも高くなります。

優れた企業や製品が反復されるのと同じように、優れたコードやアーキテクチャーは最初から完全に設計されているわけではありません。将来のニーズを 100% 予測することはできず、遠い将来に支払うだけの十分なエネルギー、時間、およびリソースがないため、システムが進化するにつれて、コードのリファクタリングは避けられません。

2. リファクタリングは過剰設計を避ける有効な手段

コードを保守する過程で、実際に問題が発生した場合は、コードをリファクタリングします。これにより、初期段階で過剰な設計に多くの時間を費やすことを効果的に回避し、目標を達成できます。

3. リファクタリングは、エンジニア自身の技術の成長にも大きな意味があります

リファクタリングは、実際には、古典的なデザインのアイデア、デザインの原則、デザイン パターン、およびプログラミング仕様を学習するアプリケーションです。実際、リファクタリングは、これらの理論的知識を実践に適用するための非常に優れたシナリオであり、これらの理論的知識を上手に使用する能力を行使できます。

また、リファクタリング能力もエンジニアのコード能力を測る有効な手段です。いわゆる「ジュニア エンジニアはコードを保守し、シニア エンジニアはコードを設計し、シニア エンジニアはコードをリファクタリングします。」この文は、ジュニア エンジニアが既存のコード フレームワークの下でバグを修正し、機能コードを修正および追加することを意味します。コード構造を設計し、コード フレームワークを構築します。シニア エンジニアはコードの品質に責任があり、コードの問題を見つけ、コードをリファクタリングし、コードの品質が常に制御可能な状態にあることを確認する必要があります。もちろん、ここでいう後輩、先輩、先輩はあくまでも相対的な概念であり、明確な順位ではありません)

1.2 リファクタリングの対象:リファクタリングとは?

リファクタリングの規模によって、大規模な高レベルリファクタリング(以下、「大リファクタリング」という)と小規模な低レベルリファクタリング(以下、「小規模リファクタリング」という)に大別できる。

大規模なリファクタリングとは、システム、モジュール、コード構造、およびクラス間の関係などのリファクタリングを含む、最上位のコード設計のリファクタリングを指します。リファクタリングの方法には、階層化、モジュール化、デカップリング、再利用可能な抽象コンポーネントなど。このタイプのリファクタリングのツールは、学習した設計のアイデア、原則、およびパターンです。このタイプのリファクタリングには、より多くのコード変更が含まれ、影響が大きいため、より困難で時間がかかり、バグが発生するリスクが比較的高くなります。

スモール リファクタリングとは、主にクラス、関数、変数などのコード レベルのリファクタリングのための、コードの詳細のリファクタリングを指します。たとえば、標準の命名、標準の注釈、超大規模なクラスまたは関数の削除、重複コードの抽出などです。 . 小さなリファクタリングは、コーディング規約を利用することに関するものです。この種のリファクタリングは、より集中的で、より単純で、より操作しやすく、より時間がかからず、バグを導入するリスクが比較的少ないように修正する必要があります。

1.3 リファクタリングのタイミング: いつリファクタリングするか (いつ)?

なぜリファクタリングするのか、何をリファクタリングするのかを理解したら、いつリファクタリングするのかをもう一度見てみましょう。ある程度コードが腐ってからのリファクタリングですか?もちろん違います。なぜなら、「開発効率が悪い」「開発効率が悪い」ほどコードが悪いと、多くの人が採用され、残業は毎日あるが仕事が少ない、オンラインバグが頻繁に発生する、リーダーが狂う、中間管理職が無力になる、エンジニア文句を言い続け、バグを見つけるのが難しい」、基本的にリファクタリングでは問題を解決できない

個人的には、コードの品質に注意を払わず、悪いコードを積み上げて、保守できない場合はリファクタリングまたは書き直すという行動には反対です。場合によってはプロジェクトコードが多すぎて、リファクタリングを徹底するのが難しく、最終的に「4つの異なる形状を持つモンスター」が作成され、さらに面倒です! したがって、コードがある程度腐敗した後、集中型リファクタリングですべての問題を解決できると期待するのは非現実的であり、持続可能で進化可能な方法を模索する必要があります。

推奨されるリファクタリング戦略は、継続的なリファクタリングです。何もすることがないときは、うまく書かれておらず、プロジェクトで最適化できるコードを見て、率先してリファクタリングすることができます。または、特定の機能コードを変更または追加するときに、コーディング基準を満たさない悪い設計を簡単にリファクタリングできます
つまり、単体テストやコードレビューが開発の一部であるように、継続的なリファクタリングも開発の一部となり、開発の習慣になることができれば、プロジェクトと自分自身にとって非常に有益です。

リファクタリング能力は重要ですが、継続的なリファクタリングの意識はより重要です。コードの品質とリファクタリングの問題を正しく見る必要があります。テクノロジーは更新され、要件は変化し、人は流動し、コードの品質は常に低下し、コードは常に不完全であり、リファクタリングは続きます。開発初期段階での過剰設計やコードメンテナンス時の品質低下を避けるため、継続的なリファクタリングを常に意識する

1.4 リファクタリング方法: どのようにリファクタリングするか (どのように)?

リファクタリングの規模によって、リファクタリングは大規模リファクタリングと小規模リファクタリングに大別できます。これら 2 つの異なるスケールのリファクタリングでは、扱いが異なります。

大規模なリファクタリングは、モジュールやコードが多くなるため、プロジェクトコードの品質が比較的悪く、結合が深刻な場合、全身に影響を与えることが多いです。変更が行われるほど、より多くの変更が混沌とし、1、2 週間以内に行うことはできません。そして、新規事業開発はリファクタリングと衝突するので、途中であきらめて、すべての変更を元に戻し、欲求不満で悪いコードを積み上げて行くことができます

大規模なリファクタリングを行う場合は、事前にリファクタリングの計画を立て、段階的に整然と進めていく必要があります。各段階でコードの小さな部分のリファクタリングを完了し、サブミット、テスト、および実行する. 問題が見つからない場合は、リファクタリングの次の段階に進み、コード ウェアハウス内のコードが常に実行可能であることを確認します。論理的に正しい状態。各段階で、リファクタリングの影響を受けるコードの範囲を制御し、古いコード ロジックとの互換性を保つ方法を検討し、必要に応じて互換性のある移行コードを記述する必要があります。このようにしてのみ、各段階のリファクタリングに時間がかかりすぎず (できれば 1 日で完了することができます)、新しい機能の開発と競合しません。

大規模で高レベルのリファクタリングは、組織化され、計画され、非常に慎重でなければならず、経験とビジネスに精通した上級同僚が主導する必要があります。小規模・低レベルのリファクタリングは、影響範囲が小さく、変更も短時間で済むため、時間に余裕があればいつでもできます。実際、低レベルの品質問題を手動で見つけるだけでなく、多くの成熟した静的コード分析ツール (CheckStyle、FindBugs、PMD など) を使用して、コード内の問題を自動的に検出し、対象を絞ったリファクタリングと最適化を実行することもできます。

リファクタリングに関しては、シニア エンジニアやプロジェクト リーダーが責任を持ち、何もすることがなければコードをリファクタリングし、コードの品質が常に良好な状態であることを確認する必要があります。そうしないと、「壊れたウィンドウ効果」が発生すると、1 人の人が悪いコードを積み上げ、さらに多くの人がさらに悪いコードを積み込むことになります。結局のところ、悪いコードをプロジェクトに積み込むコストは低すぎます。ただし、コードの品質を維持するための最善の方法は、優れた技術的な雰囲気を作り、誰もがコードの品質に積極的に注意を払い、コードのリファクタリングを継続するように促すことです。

2.単体テスト

多くのプログラマーは依然としてリファクタリングの実践に同意しています.プロジェクトの悪いコードに直面して、彼らもリファクタリングしたいと思っていますが、リファクタリング後に問題が発生することを心配しており、彼らの努力は感謝されません. 実際、リファクタリングするコードが他の同僚によって開発されたものであり、そのコードに特に精通していない場合、何の保証もなく、リファクタリング後にバグが発生するリスクは依然として非常に高くなります。

那如何保证重构不出错呢?需要熟练掌握各种设计原则、思想、模式,还需要对所重构的业务和代码有足够的了解。除了这些个人能力因素之外,最可落地执行、最有效的保证重构不出错的手段应该就是**单元测试(Unit Testing)**了。当重构完成之后,如果新的代码仍然能通过单元测试,那就说明代码原有逻辑的正确性未被破坏,原有的外部可见行为未变,符合重构的定义

2.1 什么是单元测试?

单元测试由研发工程师自己来编写,用来测试自己写的代码的正确性。常常将它跟集成测试放到一块来对比。单元测试相对于集成测试(Integration Testing)来说,测试的粒度更小一些。集成测试的测试对象是整个系统或者某个功能模块,比如测试用户注册、登录功能是否正常,是一种端到端(end to end)的测试。而单元测试的测试对象是类或者函数,用来测试一个类和函数是否都按照预期的逻辑执行。这是代码层级的测试。如下例:

public class Text {
    
    
    private String content;
    public Text(String content) {
    
    
        this.content = content;
    }
    /**
     * 将字符串转化成数字,忽略字符串中的首尾空格;
     * 如果字符串中包含除首尾空格之外的非数字字符,则返回 null。
     */
    public Integer toNumber() {
    
    
        if (content == null || content.isEmpty()) {
    
    
            return null;
        }
        //... 省略代码实现...
        return null;
    }
}

要测试 Text 类中的 toNumber() 函数的正确性,应该如何编写单元测试呢?

实际上,写单元测试本身不需要什么高深技术。它更多的是考验程序员思维的缜密程度,看能否设计出覆盖各种正常及异常情况的测试用例,来保证代码在任何预期或非预期的情况下都能正确运行

为了保证测试的全面性,针对 toNumber() 函数,需要设计下面这样几个测试用例:

  • 如果字符串只包含数字:“123”,toNumber() 函数输出对应的整数:123
  • 如果字符串是空或者 null,toNumber() 函数返回:null
  • 如果字符串包含首尾空格:“ 123”,“123 ”,“ 123 ”,toNumber() 返回对应的整数:123
  • 如果字符串包含多个首尾空格:“ 123 ”,toNumber() 返回对应的整数:123
  • 文字列に数字以外の文字が含まれている場合はtoNumber()null を返します: "123a4"、"123 4"

テスト ケースが設計されたら、あとはコードに変換するだけです。コードに変換するプロセスは次のように非常に簡単です: (ここではテスト フレームワークは使用しません)

public class Assert {
    
    
    public static void assertEquals(Integer expectedValue, Integer actualValue) {
    
    
        if (actualValue != expectedValue) {
    
    
            String message = String.format(
                    "Test failed, expected: %d, actual: %d.", expectedValue, actualVa
                    System.out.println(message);
        } else {
    
    
            System.out.println("Test succeeded.");
        }
    }
    public static boolean assertNull(Integer actualValue) {
    
    
        boolean isNull = actualValue == null;
        if (isNull) {
    
    
            System.out.println("Test succeeded.");
        } else {
    
    
            System.out.println("Test failed, the value is not null:" + actualValue);
        }
        return isNull;
    }
}
public class TestCaseRunner {
    
    
    public static void main(String[] args) {
    
    
        System.out.println("Run testToNumber()");
        new TextTest().testToNumber();
        System.out.println("Run testToNumber_nullorEmpty()");
        new TextTest().testToNumber_nullorEmpty();
        System.out.println("Run testToNumber_containsLeadingAndTrailingSpaces()");
        new TextTest().testToNumber_containsLeadingAndTrailingSpaces();
        System.out.println("Run testToNumber_containsMultiLeadingAndTrailingSpaces
        new TextTest().testToNumber_containsMultiLeadingAndTrailingSpaces();
        System.out.println("Run testToNumber_containsInvalidCharaters()");
        new TextTest().testToNumber_containsInvalidCharaters();
    }
}

public class TextTest {
    
    
    public void testToNumber() {
    
    
        Text text = new Text("123");
        Assert.assertEquals(123, text.toNumber());
    }
    public void testToNumber_nullorEmpty() {
    
    
        Text text1 = new Text(null);
        Assert.assertNull(text1.toNumber());
        Text text2 = new Text("");
        Assert.assertNull(text2.toNumber());
    }
    public void testToNumber_containsLeadingAndTrailingSpaces() {
    
    
        Text text1 = new Text(" 123");
        Assert.assertEquals(123, text1.toNumber());
        Text text2 = new Text("123 ");
        Assert.assertEquals(123, text2.toNumber());
        Text text3 = new Text(" 123 ");
        Assert.assertEquals(123, text3.toNumber());
    }
    public void testToNumber_containsMultiLeadingAndTrailingSpaces() {
    
    
        Text text1 = new Text(" 123");
        Assert.assertEquals(123, text1.toNumber());
        Text text2 = new Text("123 ");
        Assert.assertEquals(123, text2.toNumber());
        Text text3 = new Text(" 123 ");
        Assert.assertEquals(123, text3.toNumber());
    }
    public void testToNumber_containsInvalidCharaters() {
    
    
        Text text1 = new Text("123a4");
        Assert.assertNull(text1.toNumber());
        Text text2 = new Text("123 4");
        Assert.assertNull(text2.toNumber());
    }
}

2.2 なぜ単体テストを書くのか?

単体テストは、リファクタリングを効果的にエスコートするだけでなく、コードの品質を保証する 2 つの最も効果的な手段の 1 つでもあります (もう 1 つはコード レビューです)。単体テストの利点は次のとおりです。

  1. ユニットテストはコード内のバグを効果的に発見することができます
    .バグのないコードを書けるかどうかはエンジニアのコーディング能力を判断する重要な基準の一つです.ユニットテストを通じて,コード内の多くの不完全な考慮事項を見つけることができます.
  2. 単体テストを書くと、コード設計の問題を見つけることができます.
    コードのテスト容易性は、コードの品質を判断するための重要な基準です. コードの一部について、単体テストを作成するのが難しい場合、または単体テストを作成するのが非常に難しい場合は、完成させるために単体テスト フレームワークの非常に高度な機能に依存する必要があります。たとえば、依存性注入が使用されていない、静的関数の広範な使用、グローバル変数、高度に結合されたコードなどです。
  3. 単体テストは統合テストを強力に補完するものであり、
    プログラム動作のバグは、除数が空と判断されない、ネットワーク タイムアウトなど、一部の境界条件や異常な状況で発生することがよくあります。異常な状態のほとんどは、テスト環境でシミュレートするのがより困難です。単体テストでは、モック メソッドを使用して、これらの異常な状況でコードのパフォーマンスをテストするためにシミュレートする必要がある例外を返すモック オブジェクトを制御できます. さらに、一部の複雑なシステムでは、統合テストでは包括的にカバーできません
    . 多くの場合、複雑なシステムには多くのモジュールがあります。各モジュールにはさまざまな入力、出力、異常な状況があります. 組み合わせると、システム全体でシミュレートするテストシナリオが無数にあり、設計するテストケースが無数にあります. テストチームがどれほど強力であっても、すべてを網羅することはできません. 単体テストは完全にはできませんが.
    統合テストを置き換える必要がありますが、各クラスと各関数が期待どおりに実行され、根本的なバグが少ないことを確認できれば、組み立てられたシステムで問題が発生する可能性はそれに応じて減少します
  4. 単体テストを作成するプロセスは、それ自体がコードのリファクタリングのプロセスです.
    単体テストを作成することは、実際には、継続的なリファクタリングを実装するための効果的な方法です. コードを設計して実装するとき、すべての問題を考え抜くのは困難です。単体テストを書くことは、コードのセルフコードレビューに相当します. このプロセス中に、いくつかの設計上の問題 (テストできないコード設計など) やコード記述の問題 (いくつかの境界条件の不適切な処理など) が見つかります.対象を絞った再建を行う
  5. 単体テストを読むと、コードにすぐに慣れることができます
    . コードを読む最も効果的な方法は、最初にそのビジネスの背景と設計のアイデアを理解してから、コードを見て、コードをより読みやすくすることです. しかし、プログラマーはドキュメンテーションやコメントを書くことをあまり好まず、ほとんどのプログラマーによって書かれたコードは「自明」であることが困難です。ドキュメントやコメントがない場合は、単体テストが代わりになります。単体テスト ケースは実際にはユーザー ケースであり、コードの機能とその使用方法を反映しています。単体テストでは、コードが実装する機能、考慮する必要がある特別な状況、および対処する必要がある境界条件を知るために、コードを深く読む必要はありません。
  6. 単体テストは、地上で実装できる TDD の改善されたソリューションです.
    テスト駆動開発 (略して TDD) は、よく言及されますが、実装されることはめったにない開発モデルです. その中核となる指針となるイデオロギーは、コードの前にテスト ケースを作成することです。しかし, プログラマーがこの開発モードを完全に受け入れて慣れることはかなり難しい. 結局, 多くのプログラマーはユニットテストを書くのが面倒で, 言うまでもなくコードを書く前にテストケースを書く. ユニットテストはTDDにちょうどいい
    .改善計画、最初にコードを書き、次に単体テストを書き、最後に単体テストのフィードバックに従って問題を見つけ、それから戻ってコードをリファクタリングします。この開発プロセスは受け入れやすく、実装しやすく、TDD の利点を考慮に入れています。

2.3 単体テストの書き方は?

単体テストの作成は、コードのさまざまな入力、例外、および境界条件をカバーするテスト ケースを設計し、これらのテスト ケースをコードに変換するプロセスです。

テスト ケースをコードに変換する場合、単体テスト フレームワークを使用して、テスト コードの記述を簡素化できます。たとえば、Java のよく知られた単体テスト フレームワークには、Junit、TestNG、Spring Test などがあります。これらのフレームワークは、共通の実行プロセス (テスト ケースを実行する TestCaseRunner など) やツール ライブラリ (さまざまな Assert 判定関数など) などを提供します。それらを使用すると、テスト コードを記述するときに、テスト ケース自体の記述にのみ注意を払い、テスト フレームワークを使用して次のことを達成する必要があります。

import org.junit.Assert;
import org.junit.Test;

public class TextTest {
    
    
    @Test
    public void testToNumber() {
    
    
        Text text = new Text("123");
        Assert.assertEquals(new Integer(123), text.toNumber());
    }
    @Test
    public void testToNumber_nullorEmpty() {
    
    
        Text text1 = new Text(null);
        Assert.assertNull(text1.toNumber());
        Text text2 = new Text("");
        Assert.assertNull(text2.toNumber());
    }
    @Test
    public void testToNumber_containsLeadingAndTrailingSpaces() {
    
    
        Text text1 = new Text(" 123");
        Assert.assertEquals(new Integer(123), text1.toNumber());
        Text text2 = new Text("123 ");
        Assert.assertEquals(new Integer(123), text2.toNumber());
        Text text3 = new Text(" 123 ");
        Assert.assertEquals(new Integer(123), text3.toNumber());
    }
    @Test
    public void testToNumber_containsMultiLeadingAndTrailingSpaces() {
    
    
        Text text1 = new Text(" 123");
        Assert.assertEquals(new Integer(123), text1.toNumber());
        Text text2 = new Text("123 ");
        Assert.assertEquals(new Integer(123), text2.toNumber());
        Text text3 = new Text(" 123 ");
        Assert.assertEquals(new Integer(123), text3.toNumber());
    }
    @Test
    public void testToNumber_containsInvalidCharaters() {
    
    
        Text text1 = new Text("123a4");
        Assert.assertNull(text1.toNumber());
        Text text2 = new Text("123 4");
        Assert.assertNull(text2.toNumber());
    }
}

2.4 まとめ

2.4.1 単体テストを書くのは本当に時間がかかりますか?

単体テストコードの量は、テストするコード自体の 1 ~ 2 倍になる場合がありますが、作成プロセスは非常に面倒ですが、それほど時間はかかりません。結局、多くのコード設計の問題を考慮する必要はなく、テスト コードは比較的簡単に実装できます。異なるテストケース間のコードの違いはそれほど大きくないかもしれません。単にコピーして貼り付けて変更してください

2.4.2 単体テストのコード品質に関する要件はありますか?

結局、単体テストは生産ラインでは実行されず、各クラスのテスト コードは比較的独立しており、基本的に相互に依存しません。したがって、テスト対象コードと比較して、単体テストコードの品質が低下する可能性があります。ネーミングが少しイレギュラーで、コードが少し重複していますが問題ありません

2.4.3 高いカバレッジは単体テストに十分ですか?

ユニットテストカバレッジは比較的数値化しやすい指標であり、ユニットテストが適切に書かれているかどうかの判断基準としてよく使われます。JaCoCo、Cobertura、Emma、Clover など、カバレッジ統計専用の市販ツールが多数あります。カバレッジを計算するには多くの方法があります。単純なものはステートメント カバレッジであり、高度なものは条件付きカバレッジ、決定カバレッジ、パス カバレッジです。

カバレッジの計算方法がどれだけ進歩したとしても、ユニットテストの品質を測定するための唯一の基準としてカバレッジを使用することは合理的ではありません。実際には、テスト ケースが考えられるすべてのケース、特にいくつかのコーナー ケースをカバーしているかどうかを確認することがより重要です。例えば:

public double cal(double a, double b) {
    
    
    if (b != 0) {
    
    
        return a / b;
    }
}

上記のコードのように、cal(10.0, 2.0) のように 100% のカバレッジを達成するために必要なテスト ケースは 1 つだけですが、それはテストが十分に包括的であることを意味するものではありません. それも考慮する必要があります. 0 にすると、コードは期待どおりに動作しますか

実際には、単体テストのカバレッジ率に過度に注意を払うと、開発者はカバレッジ率を向上させるために不要なテスト コードを大量に作成することになります.たとえば、get メソッドと set メソッドは非常に単純であり、テストする必要はありません。 . 過去の経験から、単体テストのカバー率が 60 ~ 70% の場合、プロジェクトはオンラインになることができます。プロジェクトのコード品質に対する要件が比較的高い場合は、単体テスト カバレッジの要件を適切に引き上げることができます。

2.4.4 単体テストを書くとき、コードの実装ロジックを理解する必要がありますか?

単体テストは、テストされた関数の特定の実装ロジックに依存せず、テストされた関数が実装する関数のみを気にします。カバレッジを追求するためにコードを 1 行ずつ読んでから、実装ロジックの単体テストを記述しないでください。そうしないと、コードがリファクタリングされると、コードの外部動作が変更されないままコードの実装ロジックが変更され、元の単体テストの実行に失敗し、リファクタリングを保護できなくなります。元のコードにも違反します。単体テストを書く意図

2.4.5 単体テスト フレームワークの選択方法

単体テストを書くこと自体はそれほど複雑な技術を必要とせず、ほとんどの単体テスト フレームワークが要件を満たすことができます。社内では、少なくともチームには統一されたユニット テスト フレームワークが必要です。記述したコードが選択した単体テスト フレームワークでテストできない場合は、コードが適切に記述されておらず、コードのテスト容易性が十分でないことが原因である可能性があります。現時点では、別のより高度な単体テスト フレームワークを探すのではなく、コードをリファクタリングしてテストを容易にする必要があります。

2.4.6 単体テストの実装が難しいのはなぜですか?

単体テストはリファクタリングがうまくいかないことを確認するための有効な手段であると多くの本で言及されていますが、単体テストの重要性を認識している人もたくさんいます。しかし、健全で高品質な単体テストを備えているプロジェクトはいくつあるでしょうか? 非常に少ない

単体テストを書くことは、まさに忍耐の試練です。一般に、単体テスト コードの量は、テストされるコードの量よりも多く、場合によっては数倍になります。多くの人は、単体テストを書くのは面倒で、課題が少ないのでやりたがらないと考えがちです。単体テストの実装を最初に開始したとき、より真剣に取り組み、よりうまく実行できた多くのチームやプロジェクトがあります。しかし、開発タスクがタイトになると、単体テストの要件が低くなり始めます. 壊れたウィンドウ効果が発生すると、誰もが書くのをやめます. これは非常に一般的です.

もう1つの状況は、歴史的な問題により、元のコードは単体テストを記述しておらず、コードはすでに10万行以上積み重なっており、単体テストを1つずつ補足することは不可能です. この場合、まず新しく書くコードに単体テストが必要であることを確認する必要があります.次に、クラスが変更されるたびに、単体テストがない場合は、途中で追加されます.ただし、これはエンジニアに強い当事者意識(オーナーシップ)が求められるため、やはりリーダーの監督だけでは多くのことを実行に移すことは難しい

さらに、テスト チームで単体テストを作成するのは時間の無駄であり、不必要だと考える人もいます。プログラマーの業界はインテリジェンス集約型のはずですが、現在、一部の大規模工場を含め、多くの企業が労働集約型にしています。開発プロセスでは、単体テストもコード レビュー プロセスもありません。あったとしても、それがすることは満足のいくものではありません。コードを書いて直接提出し、ブラックボックステストに投入して厳しいテストを行い、問題が検出された場合は開発チームにフィードバックされて修正されます。問題が検出されない場合は、オンラインのままにしてから修復しました。

このような開発モードでは、チームは単体テストを記述する必要がないと感じることがよくありますが、単体テストが適切に記述され、コード レビューが適切に行われ、コードの品質が強調されている場合、ブラック ボックス テストへの投資は無駄になりません。大幅に削減。

3. コードのテスト容易性

3.1 テストしやすいコードを書くには?

以下のように、Transaction は、電子商取引システムを抽象化および単純化した後のトランザクション クラスであり、各注文トランザクションのステータスを記録するために使用されます。Transaction クラスのexecute()関数は、送金操作を実行し、買い手のウォレットから売り手のウォレットにお金を送金します。実際の転送操作は、WalletRpcService RPC サービスを呼び出すことによって行われます。さらに、コードには分散ロック DistributedLock シングルトン クラスも含まれています。これは、トランザクションの同時実行を回避するために使用され、ユーザーのお金が繰り返し送金される原因となります。

public class Transaction {
    
    
    private String id;
    private Long buyerId;
    private Long sellerId;
    private Long productId;
    private String orderId;
    private Long createTimestamp;
    private Double amount;
    private STATUS status;
    private String walletTransactionId;

    // ...get() methods...
    public Transaction(String preAssignedId, Long buyerId, Long sellerId, Long p) {
    
    
        if (preAssignedId != null && !preAssignedId.isEmpty()) {
    
    
            this.id = preAssignedId;
        } else {
    
    
            this.id = IdGenerator.generateTransactionId();
        }
        if (!this.id.startWith("t_")) {
    
    
            this.id = "t_" + preAssignedId;
        }
        this.buyerId = buyerId;
        this.sellerId = sellerId;
        this.productId = productId;
        this.orderId = orderId;
        this.status = STATUS.TO_BE_EXECUTD;
        this.createTimestamp = System.currentTimestamp();
    }

    public boolean execute() throws InvalidTransactionException {
    
    
        if ((buyerId == null || (sellerId == null || amount < 0.0) {
    
    
            throw new InvalidTransactionException(...);
        }
        if (status == STATUS.EXECUTED) return true;
        boolean isLocked = false;
        try {
    
    
            isLocked = RedisDistributedLock.getSingletonIntance().lockTransction(id)
            if (!isLocked) {
    
    
                return false; // 锁定未成功,返回 false,job 兜底执行
            }
            if (status == STATUS.EXECUTED) return true; // double check
            long executionInvokedTimestamp = System.currentTimestamp();
            if (executionInvokedTimestamp - createdTimestap > 14d ays){
    
    
                this.status = STATUS.EXPIRED;
                return false;
            }
            WalletRpcService walletRpcService = new WalletRpcService();
            String walletTransactionId = walletRpcService.moveMoney(id, buyerId, sell);
            if (walletTransactionId != null) {
    
    
                this.walletTransactionId = walletTransactionId;
                this.status = STATUS.EXECUTED;
                return true;
            } else {
    
    
                this.status = STATUS.FAILED;
                return false;
            }
        } finally {
    
    
            if (isLocked) {
    
    
                RedisDistributedLock.getSingletonIntance().unlockTransction(id);
            }
        }
    }
}

前の Text クラスのコードと比較すると、このコードははるかに複雑です。このコードの単体テストを作成する場合、どのように記述すればよいでしょうか?

Transaction クラスでは、主なロジックはexecute()functionため、テストのキー オブジェクトです。さまざまな正常および異常な状況を可能な限り包括的にカバーするために、次の 6 つのテスト ケースがこの機能用に設計されています。

  1. 正常情况下,交易执行成功,回填用于对账(交易与钱包的交易流水)用的 walletTransactionId,交易状态设置为 EXECUTED,函数返回 true
  2. buyerId、sellerId 为 null、amount 小于 0,返回 InvalidTransactionException
  3. 交易已过期(createTimestamp 超过 14 天),交易状态设置为 EXPIRED,返回 false
  4. 交易已经执行了(status==EXECUTED),不再重复执行转钱逻辑,返回 true
  5. 钱包(WalletRpcService)转钱失败,交易状态设置为 FAILED,函数返回 false
  6. 交易正在执行着,不会被重复执行,函数直接返回 false

测试用例设计完了。现在看起来似乎一切进展顺利。但是,事实是,当将测试用例落实到具体的代码实现时,就会发现有很多行不通的地方。对于上面的测试用例,第 2 个实现起来非常简单,重点来看其中的 1 和 3。测试用例 4、5、6 跟 3类似

测试用例 1 的代码实现。具体如下所示:

public void testExecute() {
    
    
        Long buyerId = 123L;
        Long sellerId = 234L;
        Long productId = 345L;
        Long orderId = 456L;

        Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId);
        boolean executedResult = transaction.execute();
        assertTrue(executedResult);
}

execute() 函数的执行依赖两个外部的服务,一个是 RedisDistributedLock,一个 WalletRpcService。这就导致上面的单元测试代码存在下面几个问题:

  • 如果要让这个单元测试能够运行,需要搭建 Redis 服务和 Wallet RPC 服务。搭建和维护的成本比较高
  • 还需要保证将伪造的 transaction 数据发送给 Wallet RPC 服务之后,能够正确返回期望的结果,然而 Wallet RPC 服务有可能是第三方(另一个团队开发维护的)的服务,并不是可控的。换句话说,并不是想让它返回什么数据就返回什么
  • Transaction 的执行跟 Redis、RPC 服务通信,需要走网络,耗时可能会比较长,对单元测试本身的执行性能也会有影响
  • 网络的中断、超时、Redis、RPC 服务的不可用,都会影响单元测试的执行

単体テストの定義に戻りましょう。単体テストは、主にプログラマー自身が記述したコード ロジックの正確性をテストすることであり、エンド ツー エンドの統合テストではありません. 外部システム (分散ロック、Wallet RPC サービス) の論理的正確性をテストする必要はありません。に依存します。したがって、コードが外部システムまたは制御不能なコンポーネントに依存している場合、たとえば、データベース、ネットワーク通信、ファイル システムなどに依存する必要がある場合、テスト対象のコードは外部システムから独立している必要があります。依存解除方法は「モック」と呼ばれます。いわゆるモックとは、実際のサービスを「偽の」サービスに置き換えることです
モック サービスは完全に私たちの管理下にあり、必要なデータをシミュレートして出力します

では、サービスをモックする方法は? モッキングには、手動モッキングとフレームワーク モッキングの 2 つの主な方法があります。フレームワーク モックの使用は、コードの記述を単純化するためのものであり、各フレームワークのモック メソッドは異なります。ここには手動モックのみが表示されます

このモックはWalletRpcService クラスを継承し、その中のmoveMoney()関数実現されています。具体的なコードの実装は次のとおりです。モックすることで、必要なデータmoveMoney()を返す。これは完全に制御可能であり、実際のネットワーク通信を必要としません。

public class MockWalletRpcServiceOne extends WalletRpcService {
    
    
    public String moveMoney(Long id, Long fromUserId, Long toUserId, Double amoun) {
    
    
        return "123bac";
    }
}

public class MockWalletRpcServiceTwo extends WalletRpcService {
    
    
    public String moveMoney(Long id, Long fromUserId, Long toUserId, Double amoun) {
    
    
        return null;
    }
}

コード内の実際の WalletRpcService を MockWalletRpcServiceOne と MockWalletRpcServiceTwo に置き換える方法をもう一度見てみましょう。

WalletRpcService はexecute()関数、動的に置き換えることはできません。とはいえ、Transaction クラスのexecute()メソッドはテストしにくいので、テストしやすくするためにリファクタリングする必要があります。このコードをリファクタリングするには?

依存性注入は、コードのテスト容易性を達成するための最も効果的な手段です。依存性注入を適用して、WalletRpcService オブジェクトの作成を上位層のロジックに戻し、外部で作成された後に Transaction クラスに注入できます。リファクタリング後の Transaction クラスのコードは次のとおりです。

public class Transaction {
    
    
    //...
    // 添加一个成员变量及其 set 方法
    private WalletRpcService walletRpcService;
    public void setWalletRpcService(WalletRpcService walletRpcService) {
    
    
        this.walletRpcService = walletRpcService;
    }
    // ...
    public boolean execute() {
    
    
       // ...
       // 删除下面这一行代码
       // WalletRpcService walletRpcService = new WalletRpcService();
       // ...
    }
}

これで、単体テストで WalletRpcService を MockWalletRpcServiceOne または WalletRpcServiceTwo に簡単に置き換えることができます。リファクタリングされたコードに対応する単体テストは次のとおりです。

public void testExecute() {
    
    
        Long buyerId = 123L;
        Long sellerId = 234L;
        Long productId = 345L;
        Long orderId = 456L;

        Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId);
        // 使用 mock 对象来替代真正的 RPC 服务
        transaction.setWalletRpcService(new MockWalletRpcServiceOne()):
        boolean executedResult = transaction.execute();
        assertTrue(executedResult);
        assertEquals(STATUS.EXECUTED, transaction.getStatus());
}

WalletRpcService のモックと置換の問題は解決されました。もう一度 RedisDistributedLock を見てみましょう。そのモックと置換は、主に RedisDistributedLock がシングルトン クラスであるため、より複雑です。シングルトンはグローバル変数と同等であり、モックすることも (メソッドを継承およびオーバーライドすることもできません)、依存性注入によって置き換えることもできません。

RedisDistributedLock が独自に維持され、自由に変更およびリファクタリングできる場合、それを非シングルトン モードに変更するか、IDistributedLock などのインターフェイスを定義して、RedisDistributedLock にこのインターフェイスを実装させることができます。このように、RedisDistributedLock は WalletRpcService の置き換え方法と同様に MockRedisDistributedLock に置き換えることができます。しかし、
RedisDistributedLock が私たちによって維持されていない場合、コードのこの部分を変更する権利はありません。現時点で何をすべきでしょうか?

トランザクションをロックするロジックを再パッケージ化できます。具体的なコードの実装は次のとおりです。

public class TransactionLock {
    
    
    public boolean lock(String id) {
    
    
        return RedisDistributedLock.getSingletonIntance().lockTransction(id);
    }
    public void unlock() {
    
    
        RedisDistributedLock.getSingletonIntance().unlockTransction(id);
    }
}

public class Transaction {
    
    
    //...
    private TransactionLock lock;
    public void setTransactionLock(TransactionLock lock) {
    
    
        this.lock = lock;
    }
    public boolean execute() {
    
    
        //...
        try {
    
    
            isLocked = lock.lock();
        //...
        } finally {
    
    
            if (isLocked) {
    
    
                lock.unlock();
            }
        }
        //...
    }
}

リファクタリングされたコードの場合、単体テスト コードは次のように変更されます。このようにして、実際の RedisDistributedLock 分散ロックのロジックを単体テスト コードで分離できます。

public void testExecute() {
    
    
    Long buyerId = 123L;
    Long sellerId = 234L;
    Long productId = 345L;
    Long orderId = 456L;

    TransactionLock mockLock = new TransactionLock() {
    
    
        public boolean lock(String id) {
    
    
            return true;
        }
        public void unlock() {
    
    }
    };
    Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId);
    transaction.setWalletRpcService(new MockWalletRpcServiceOne());
    transaction.setTransactionLock(mockLock);
    boolean executedResult = transaction.execute();
    assertTrue(executedResult);
    assertEquals(STATUS.EXECUTED, transaction.getStatus());
}

この時点で、テスト ケース 1 が作成されます。依存性注入とモックにより、単体テスト コードは制御不能な外部サービスに依存しません。

ここで、テスト ケース 3 をもう一度見てみましょう。トランザクションが期限切れになり (createTimestamp が 14 日以上前)、トランザクション ステータスが EXPIRED に設定され、false が返されます。この単体テスト ケースでは、最初にコードを記述してから分析することをお勧めします。

public void testExecute_with_TransactionIsExpired() {
    
    
        Long buyerId = 123L;
        Long sellerId = 234L;
        Long productId = 345L;
        Long orderId = 456L;

        Transction transaction = new Transaction(null, buyerId, sellerId, productId,orderId);
        transaction.setCreatedTimestamp(System.currentTimestamp() - 14days);
        boolean actualResult = transaction.execute();
        assertFalse(actualResult);
        assertEquals(STATUS.EXPIRED, transaction.getStatus());
}

上記のコードは問題ないようです。トランザクションの createdTimestamp を 14 日前に設定します。つまり、単体テスト コードを実行するとき、トランザクションは期限切れの状態である必要があります。ただし、createdTimestamp メンバー変数を変更するための set メソッドが Transaction クラスで公開されていない (つまり、setCreatedTimestamp()関数

このとき、createTimestamp の set メソッドがなければ、もう 1 つ追加すればよいと言えます。実際、これはクラスのカプセル化プロパティに違反します。Transaction クラスの設計では、createTimestamp は、トランザクションが生成されたとき (つまり、コンストラクター内) に自動的に取得されるシステム時間であり、人為的に簡単に変更されるべきではありません. したがって、createTimestamp を公開する set メソッドは柔軟性をもたらしますが、 、しかし、それは制御不能ももたらします。ユーザーが set メソッドを呼び出して createTimestamp をリセットするかどうかを制御することは不可能であり、createTimestamp のリセットは想定された動作ではないためです。

createTimestamp の set メソッドがない場合、テスト ケース 3 をどのように実装できますか? 実際、これは比較的一般的なタイプの問題です。つまり、コードには「時間」に関連する「保留中の動作」ロジックが含まれています。一般的なアプローチは、この保留中の動作ロジックを再パッケージ化することです。For the Transaction class, you only encapsulate the logic of whether the transaction is expires into isExpired()a function . 具体的なコードの実装は次のとおりです。

public class Transaction {
    
    
    protected boolean isExpired() {
    
    
        long executionInvokedTimestamp = System.currentTimestamp();
        return executionInvokedTimestamp - createdTimestamp > 14days;
    }
    public boolean execute() throws InvalidTransactionException {
    
    
        //...
        if (isExpired()) {
    
    
            this.status = STATUS.EXPIRED;
            return false;
        }
        //...
    }
}

リファクタリングされたコードの場合、テスト ケース 3 のコード実装は次のとおりです。

public void testExecute_with_TransactionIsExpired() {
    
    
        Long buyerId = 123L;
        Long sellerId = 234L;
        Long productId = 345L;
        Long orderId = 456L;
  
        Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId) {
    
    
            protected boolean isExpired() {
    
    
                return true;
            }
        };
        boolean actualResult = transaction.execute();
        assertFalse(actualResult);
        assertEquals(STATUS.EXPIRED, transaction.getStatus());
}

リファクタリングにより、Transaction コードのテスト容易性が向上します。前にリストしたすべてのテスト ケースが正常に実装されました。ただし、次のように、Transaction クラスのコンストラクターの設計はまだ少し不適切です。

public Transaction(String preAssignedId, Long buyerId, Long sellerId, Long p) {
    
    
        if (preAssignedId != null && !preAssignedId.isEmpty()) {
    
    
            this.id = preAssignedId;
        } else {
    
    
            this.id = IdGenerator.generateTransactionId();
        }
        if (!this.id.startWith("t_")) {
    
    
            this.id = "t_" + preAssignedId;
        }
        this.buyerId = buyerId;
        this.sellerId = sellerId;
        this.productId = productId;
        this.orderId = orderId;
        this.status = STATUS.TO_BE_EXECUTD;
        this.createTimestamp = System.currentTimestamp();
}

コンストラクターには、単純な代入操作だけが含まれているわけではありません。トランザクション ID の割り当てロジックはもう少し複雑です。テストして、ロジックのこの部分が正しいことを確認することをお勧めします。テストの便宜上、ID 割り当てのロジックを関数に個別に抽象化できます。具体的なコードの実装は次のとおりです。

public Transaction(String preAssignedId, Long buyerId, Long sellerId, Long p
        //...
        fillTransactionId(preAssignId);
        //...
}

protected void fillTransactionId(String preAssignedId) {
    
    
        if (preAssignedId != null && !preAssignedId.isEmpty()) {
    
    
            this.id = preAssignedId;
        } else {
    
    
            this.id = IdGenerator.generateTransactionId();
        }
        if (!this.id.startWith("t_")) {
    
    
            this.id = "t_" + preAssignedId;
        }
}

これまでのところ、Transaction は段階的にテスト不可能なコードから十分にテスト可能なコードに再構築されてきました。しかし、まだ疑問があるかもしれません。Transaction クラスのisExpired()関数? isExpired()関数の場合、ロジックは非常に単純で、バグがあるかどうかは肉眼で判断できるため、単体テストを記述する必要はありません。

実際、テスト容易性の低いコードは十分に設計されておらず、「実装ではなくインターフェースに基づいたプログラミング」という考え方、依存関係逆転の原則など、前述の設計原則や考え方に従っていないところが多く、等 リファクタリングされたコードは、テスト容易性が向上するだけでなく、コード設計の観点から古典的な設計原則とアイデアに従います。これは、コードのテスト容易性が、コード設計が側面から合理的であるかどうかを反映できることも確認します。また、通常の開発では、このようにコードを書く際に単体テストを書きやすいかどうかについてももっと考えるべきであり、これも良いコードを設計するのに役立ちます。

3.2 その他の一般的なアンチパターン

要約すると、アンチパターンと呼ばれることが多い、テスト容易性の低い典型的で一般的なコードは次のとおりです。

3.2.1 保留中のアクション

いわゆる保留動作ロジックは、コードの出力がランダムまたは不確実であることです。たとえば、時間と乱数に関連するコードです。

3.2.2 グローバル変数

グローバル変数は手続き型のプログラミング スタイルであり、さまざまな欠点があります。実際、グローバル変数を誤用すると、次の例のように単体テストの記述が難しくなります。

RangeLimiter は [-5, 5] の範囲を表し、位置は最初は 0 であり、move()関数は位置の移動を担当します。その中で、位置は静的グローバル変数です。RangeLimiterTest クラスはそのために設計された単体テストですが、大きな問題があります。

public class RangeLimiter {
    
    
    private static AtomicInteger position = new AtomicInteger(0);
    public static final int MAX_LIMIT = 5;
    public static final int MIN_LIMIT = -5;
    public boolean move(int delta) {
    
    
        int currentPos = position.addAndGet(delta);
        boolean betweenRange = (currentPos <= MAX_LIMIT) && (currentPos >= MIN_LIMI
        return betweenRange;
    }
}
public class RangeLimiterTest {
    
    
    public void testMove_betweenRange() {
    
    
        RangeLimiter rangeLimiter = new RangeLimiter();
        assertTrue(rangeLimiter.move(1));
        assertTrue(rangeLimiter.move(3));
        assertTrue(rangeLimiter.move(-5));
    }
    public void testMove_exceedRange() {
    
    
        RangeLimiter rangeLimiter = new RangeLimiter();
        assertFalse(rangeLimiter.move(6));
    }
}

上記の単体テストは実行に失敗する場合があります。単体テスト フレームワークが 2 つのテスト ケースtestMove_betweenRange()testMove_exceedRange()最初のテスト ケースの実行が完了すると、position の値は -1 になり、2 番目のテスト ケースが実行されると、position は 5 になり、関数はmove()true を返し、assertFalse ステートメントは失敗します。したがって、2 番目のテスト ケースは実行に失敗します。

もちろん、RangeLimiter クラスにリセット (リセット) 位置の値を公開する関数がある場合は、ユニット テスト ケースを実行するたびに位置を 0 にリセットできます。これにより、今の問題を解決できます。

ただし、各ユニット テスト フレームワークがユニット テスト ケースを実行する方法は異なる場合があります。順次実行されるものもあれば、同時に実行されるものもあります。同時実行の場合、毎回位置を0にリセットしても動きません。2 つのテスト ケースを同時に実行すると、コード 16、17、18、および 23 の 4 行がクロス実行され、move()関数

3.2.3 静的メソッド

グローバル変数のような静的メソッドも、プロセス指向のプログラミングの考え方です。コード内で静的メソッドを呼び出すと、コードのテストが難しくなることがあります。主な理由は、静的メソッドもモックするのが難しいためです。ただし、これは状況によって異なります。静的メソッドの実行に時間がかかりすぎ、外部リソースに依存し、複雑なロジックがあり、保留中の動作がある場合にのみ、単体テストでこの静的メソッドをモックする必要があります。また、Math.abs()この、モックを必要としないため、コードのテスト容易性には影響しません。

3.2.4 複合継承

構成関係と比較して、継承関係のコード構造はより結合され、柔軟性がなく、拡張と保守が容易ではありません。実際、継承関係もテストがより困難です。これにより、コードのテスト容易性とコード品質の相関関係も確認されます

親クラスが単体テストのために依存オブジェクトをモックする必要がある場合、すべてのサブクラス、サブクラスのサブクラスは、単体テストを記述するときにこの依存オブジェクトをモックする必要があります。階層が深く (継承関係のクラス図では縦の深さで示されています)、複雑な構造 (継承関係のクラス図では横の幅で示されています) を持つ継承関係の場合、下位のサブクラスほどモックするオブジェクトが多くなる可能性があります。基礎となるサブクラスが単体テストを作成するとき、多くの依存オブジェクトを 1 つずつモックする必要があり、親クラスのコードをチェックして、これらの依存オブジェクトをモックする方法を理解する必要があるという事実に注意してください。

クラス間の関係を整理するために継承ではなく構成を使用する場合、クラス間の構造階層は比較的フラットです.単体テストを作成するときは、クラスが依存するオブジェクトをモックするだけで済みます.

3.2.5 高度に結合されたコード

クラスの責任が重く、作業を完了するために 12 を超える外部オブジェクトに依存する必要があり、コードが高度に結合されている場合、単体テストを作成するときに、これらの 12 を超える依存オブジェクトをモックする必要がある場合があります。これは、コード設計の観点からも、単体テストを作成する観点からも不合理です。

4. デカップリング

前述のように、リファクタリングは、大規模で高レベルのリファクタリング (「大リファクタリング」と呼ばれます) と小規模な低レベルのリファクタリング (「小規模リファクタリング」と呼ばれます) に分けられます。大規模なリファクタリングは、システム、モジュール、コード構造、クラス間の関係などの最上位コード設計のリファクタリングです。大規模なリファクタリングの場合、最も効果的な手段の 1 つが「デカップリング」です。デカップリングの目的は、高いコード凝集度と疎結合を実現することです

4.1 「デカップリング」はなぜ重要なのですか?

ソフトウェアの設計と開発の最も重要なタスクの 1 つは、複雑さに対処することです。人間が複雑さに対処する能力には限界があります。過度に複雑なコードは、可読性と保守性の点で不親切であることがよくあります。では、コードの複雑さを制御するにはどうすればよいでしょうか。方法はたくさんありますが、個人的にはデカップリングで疎結合とコードの高い結束を確保することが最も重要だと思います。リファクタリングが、コードの品質が絶望的に​​損なわれないことを保証する効果的な手段である場合、デカップリング手法を使用してコードをリファクタリングすることは、コードが複雑すぎて制御不能にならないようにする効果的な手段です。

「高凝集度と疎結合」は比較的一般的な設計思想であり、細粒度のクラスとクラス間の関係の設計を導くだけでなく、粗粒度のシステム、アーキテクチャ、およびモジュールの設計も導くことができます。コーディング標準と比較して、より高いレベルでコードの可読性と保守性を向上させることができます

不管是阅读代码还是修改代码,“高内聚、松耦合”的特性可以让我们聚焦在某一模块或类中,不需要了解太多其他模块或类的代码,让我们的焦点不至于过于发散,降低了阅读和修改代码的难度。而且,因为依赖关系简单,耦合小,修改代码不至于牵一发而动全身,代码改动比较集中,引入 bug 的风险也就减少了很多。同时,“高内聚、松耦合”的代码可测试性也更加好,容易 mock 或者很少需要 mock 外部依赖的模块或者类

除此之外,代码“高内聚、松耦合”,也就意味着,代码结构清晰、分层和模块化合理、依赖关系简单、模块或类之间的耦合小,那代码整体的质量就不会差。即便某个具体的类或者模块设计得不怎么合理,代码质量不怎么高,影响的范围是非常有限的。可以聚焦于这个模块或者类,做相应的小型重构。而相对于代码结构的调整,这种改动范围比较集中的小型重构的难度就容易多了

4.2 代码是否需要“解耦”?

该怎么判断代码的耦合程度呢?或者说,怎么判断代码是否符合“高内聚、松耦合”呢?再或者说,如何判断系统是否需要解耦重构呢?

间接的衡量标准有很多,比如,看修改代码会不会牵一发而动全身。除此之外,还有一个直接的衡量标准,那就是把模块与模块之间、类与类之间的依赖关系画出来,根据依赖关系图的复杂性来判断是否需要解耦重构

如果依赖关系复杂、混乱,那从代码结构上来讲,可读性和可维护性肯定不是太好,那就需要考虑是否可以通过解耦的方法,让依赖关系变得清晰、简单。当然,这种判断还是有比较强的主观色彩,但是可以作为一种参考和梳理依赖的手段,配合间接的衡量标准一块来使用

4.3 如何给代码“解耦”?

4.3.1 封装与抽象

封装和抽象作为两个非常通用的设计思想,可以应用在很多设计场景中,比如系统、模块、lib、组件、接口、类等等的设计。封装和抽象可以有效地隐藏实现的复杂性,隔离实现的易变性,给依赖的模块提供稳定且易用的抽象接口

比如,Unix 系统提供的 open() 文件操作函数,用起来非常简单,但是底层实现却非常复杂,涉及权限控制、并发控制、物理存储等等。通过将其封装成一个抽象的 open() 函数,能够有效控制代码复杂性的蔓延,将复杂性封装在局部代码中。除此之外,因为 open() 函数基于抽象而非具体的实现来定义,所以在改动 open() 函数的底层实现的时候,并不需要改动依赖它的上层代码,也符合前面提到的“高内聚、松耦合”代码的评判标准

4.3.2 中间层

引入中间层能简化模块或类之间的依赖关系。下面这张图是引入中间层前后的依赖关系对比图。在引入数据存储中间层之前,A、B、C 三个模块都要依赖内存一级缓存、Redis 二级缓存、DB 持久化存储三个模块。在引入中间层之后,三个模块只需要依赖数据存储一个模块即可。从图上可以看出,中间层的引入明显地简化了依赖关系,让代码结构更加清晰

ここに画像の説明を挿入

除此之外,在进行重构的时候,引入中间层可以起到过渡的作用,能够让开发和重构同步进行,不互相干扰。比如,某个接口设计得有问题,需要修改它的定义,同时,所有调用这个接口的代码都要做相应的改动。如果新开发的代码也用到这个接口,那开发就跟重构冲突了。为了让重构能小步快跑,可以分下面四个阶段来完成接口的修改:

  1. 引入一个中间层,包裹老的接口,提供新的接口定义
  2. 新开发的代码依赖中间层提供的新接口
  3. 将依赖老接口的代码改为调用新接口
  4. 确保所有的代码都调用新接口之后,删除掉老的接口

这样,每个阶段的开发工作量都不会很大,都可以在很短的时间内完成。重构跟开发冲突的概率也变小了

4.3.3 模块化

模块化是构建复杂系统常用的手段。不仅在软件行业,在建筑、机械制造等行业,这个手段也非常有用。对于一个大型复杂系统来说,没有人能掌控所有的细节。之所以能搭建出如此复杂的系统,并且能维护得了,最主要的原因就是将系统划分成各个独立的模块,让不同的人负责不同的模块,这样即便在不了解全部细节的情况下,管理者也能协调各个模块,让整个系统有效运转

聚焦到软件开发上面,很多大型软件(比如 Windows)之所以能做到几百、上千人有条不紊地协作开发,也归功于模块化做得好。不同的模块之间通过 API 来进行通信,每个模块之间耦合很小,每个小的团队聚焦于一个独立的高内聚模块来开发,最终像搭积木一样将各个模块组装起来,构建成一个超级复杂的系统

再聚焦到代码层面。合理地划分模块能有效地解耦代码,提高代码的可读性和可维护性。所以,在开发代码的时候,一定要有模块化意识,将每个模块都当作一个独立的 lib 一样来开发,只提供封装了内部实现细节的接口给其他模块使用,这样可以减少不同模块之间的耦合度

实际上,模块化的思想无处不在,像 SOA、微服务、lib库、系统内模块划分,甚至是类、函数的设计,都体现了模块化思想。如果追本溯源,模块化思想更加本质的东西就是分而治之

4.4 其他设计思想和原则

“高内聚、松耦合”是一个非常重要的设计思想,能够有效提高代码的可读性和可维护性,缩小功能改动导致的代码改动范围。实际上,在前面的章节中,已经多次提到过这个设计思想。很多设计原则都以实现代码的“高内聚、松耦合”为目的,如下:

4.4.1 单一职责原则

内聚性和耦合性并非独立的。高内聚会让代码更加松耦合,而实现高内聚的重要指导原则就是单一职责原则。模块或者类的职责设计得单一,而不是大而全,那依赖它的类和它依赖的类就会比较少,代码耦合也就相应的降低了

4.4.2 基于接口而非实现编程

基于接口而非实现编程能通过接口这样一个中间层,隔离变化和具体的实现。这样做的好处是,在有依赖关系的两个模块或类之间,一个模块或者类的改动,不会影响到另一个模块或类。实际上,这就相当于将一种强依赖关系(强耦合)解耦为了弱依赖关系(弱耦合)

4.4.3 依赖注入

跟基于接口而非实现编程思想类似,依赖注入也是将代码之间的强耦合变为弱耦合。尽管依赖注入无法将本应该有依赖关系的两个类,解耦为没有依赖关系,但可以让耦合关系没那么紧密,容易做到插拔替换

4.4.4 多用组合少用继承

继承是一种强依赖关系,父类与子类高度耦合,且这种耦合关系非常脆弱,牵一发而动全身,父类的每一次改动都会影响所有的子类。相反,组合关系是一种弱依赖关系,这种关系更加灵活,所以,对于继承结构比较复杂的代码,利用组合来替换继承,也是一种解耦的有效手段

4.4.5 迪米特法则

迪米特法则讲的是,不该有直接依赖关系的类之间,不要有依赖;有依赖关系的类之间,尽量只依赖必要的接口。从定义上,明显可以看出,这条原则的目的就是为了实现代码的松耦合

除了上面讲到的这些设计思想和原则之外,还有一些设计模式也是为了解耦依赖,比如观察者模式

5. 编程规范

关于编码规范、如何编写可读代码,很多书籍已经讲得很好了。不过,这里总结罗列了 20 条个人觉得最好用
的编码规范。掌握这 20 条编码规范,能最快速地改善代码质量。分为三个部分:命名与注释(Naming andComments)、代码风格(Code Style)和编程技巧(Coding Tips)

5.1 命名

大到项目名、模块名、包名、对外暴露的接口,小到类名、函数名、变量名、参数名,只要是做开发,就逃不过“起名字”这一关。命名的好坏,对于代码的可读性来说非常重要,甚至可以说是起决定性作用的。除此之外,命名能力也体现了一个程序员的基本编程素养

5.1.1 命名多长最合适?

ここでは代表的なものを 2 つ紹介します。1つ目は、私は非常に長いネーミング方法を使用するのが好きです.ネーミングは正確で表現力豊かでなければならないと感じています.長くても問題ないので、プロジェクトではクラス名と関数名が非常に長いです. 2 番目のタイプは、短い命名方法を使用し、できるだけ略語を使用することを好みます。そのため、プロジェクトはさまざまな略語を含む名前でいっぱいです。これら 2 つの命名方法のうち、どちらがより推奨されますか?

長い名前はより多くの情報を含み、意図をより正確かつ直感的に表現できますが、関数と変数の名前が非常に長い場合、それらで構成されるステートメントは非常に長くなります。コード列の長さが制限されている場合、ステートメントが2行に分割されることがよくあり、実際にはコードの可読性に影響します

実際、その意味を表現するのに十分であれば、ネーミングは短いほど良いです。ただし、ほとんどの場合、短い名前は長い名前ほど表現力がありません。したがって、多くの本や記事では、名前を付けるときに略語を使用することを推奨していません。ただし、一部の既定のよく知られた単語については、省略形を使用することをお勧めします。このように、一方では、名前を短縮することができますが、他方では、読解に影響を与えることはありません.たとえば、sec は秒、str は文字列、num は数字、doc はドキュメントを意味します。さらに、スコープが比較的小さい変数については、一部の関数の一時変数など、比較的短い名前を使用できます。逆に、比較的スコープの広いクラス名については、長い命名方法を使用することをお勧めします

つまり、ネーミングの原則の1つは、意味を正確に表現することです。ただし、コード作成者は、コードのロジックについて非常に明確であり、アイデアを表現するために任意の名前を使用できると常に感じています. 実際、コードに精通していない同僚にとっては、そうは思わないかもしれません. . そのため、名前を付けるときは、共感することを学ぶ必要があります.コードに慣れていないことを前提として、コードリーダーの観点からネーミングが十分に直感的であるかどうかを検討してください.

5.1.2 コンテキストを使用して命名を簡素化する

例えば:

public class User {
    
    
    private String userName;
    private String userPassword;
    private String userAvatarUrl;
    //...
}

User クラスのコンテキストでは、メンバー変数の名前付けに「user」などの接頭語を繰り返し追加する必要はなく、名前、パスワード、および avatarUrl に直接名前を付けます。これらの属性を使用すると、オブジェクトのコンテキストを使用でき、意味が十分に明確になります。具体的なコードは次のとおりです。

User user = new User();
user.getName(); // 借助 user 对象这个上下文

クラスに加えて、次の例のように、関数パラメーターも関数コンテキストを使用して名前付けを簡素化できます。

public void uploadUserAvatarImageToAliyun(String userAvatarImageUri);
// 利用上下文简化为:
public void uploadUserAvatarImageToAliyun(String imageUri);

5.1.3 ネーミングは読みやすく、検索可能であるべき

可読と呼ばれるもの。ここでいう「読みやすい」とは、特に珍しくて発音しにくい英単語を名前に使用しないことを意味します。

过去曾参加过两个项目,一个叫 plateaux,另一个叫 eyrie,从项目立项到结束,自始至终都没有几个人能叫对这两个项目的名字。在沟通的时候,每当有人提到这两个项目的名字的时候,都会尴尬地卡顿一下。虽然我们并不排斥一些独特的命名方式,但起码得让大部分人看一眼就能知道怎么读

在 IDE 中编写代码的时候,经常会用“关键词联想”的方法来自动补全和搜索。比如,键入某个对象“.get”,希望 IDE 返回这个对象的所有 get开头的方法。再比如,通过在 IDE 搜索框中输入“Array”,搜索 JDK 中数组相关的类。所以,在命名的时候,最好能符合整个项目的命名习惯。大家都用“selectXXX”表示查询,你就不要用“queryXXX”;大家都用“insertXXX”表示插入一条数据,你就要不用“addXXX”,统一规约是很重要的,能减少很多不必要的麻烦

5.1.4 如何命名接口和抽象类?

对于接口的命名,一般有两种比较常见的方式。一种是加前缀“I”,表示一个 Interface。比如 IUserService,对应的实现类命名为 UserService。另一种是不加前缀,比如 UserService,对应的实现类加后缀“Impl”,比如 UserServiceImpl

对于抽象类的命名,也有两种方式,一种是带上前缀“Abstract”,比如 AbstractConfiguration;另一种是不带前缀“Abstract”。实际上,对于接口和抽象类,选择哪种命名方式都是可以的,只要项目里能够统一就行

5.2 注释

命名很重要,注释跟命名同等重要。很多书籍认为,好的命名完全可以替代注释。如果需要注释,那说明命名不够好,需要在命名上下功夫,而不是添加注释。实际上,我个人觉得,这样的观点有点太过极端。命名再好,毕竟有长度限制,不可能足够详尽,而这个时候,注释就是一个很好的补充

5.2.1 注释到底该写什么?

コメントの目的は、コードを理解しやすくすることです。この条件を満たした内容であれば、コメント欄に記載可能です。要約すると、アノテーションの内容は主に、何をするか、なぜ行うか、どのように行うかという 3 つの側面で構成されます。例えば:

/**
 * (what) Bean factory to create beans.
 *
 * (why) The class likes Spring IOC framework, but is more lightweight.
 *
 * (how) Create objects from different sources sequentially:
 * user specified object > SPI > configuration > default object.
 */
public class BeansFactory {
    
    
    // ...
}

コメントはコードにない追加情報を提供するためのものだと考える人もいますので、「何を、どのように行うか」は書かず、どちらもコードに反映させることができます。「なぜ」を明確に書くだけです、コードの設計意図を示します。それだけです。個人的には、主に次の 3 つの理由から、この観点に特に同意しません。

1. コメントはコードよりも多くの情報を運ぶ

命名の主な目的は、「何をすべきか」を説明することです。たとえば、void increaseWalletAvailableBalance(BigDecimal amount)この関数がウォレットの利用可能な残高を増やすために使用されることを示し、boolean isValidatedPasswordこの変数が正当なパスワードであるかどうかを識別するために使用されることを示します。適切な名前の関数と変数を使用すると、実際にコメントでその機能を説明する必要がなくなります。ただし、クラスには多くの情報が含まれており、単純な名前付けでは十分に包括的ではありません。このとき、コメントに「何をすべきか」を書くのが合理的です

2. メモは決定的な役割とドキュメントを再生します

コードの下に秘密はありません。コードを読むことで、コードの「やり方」、つまりコードを実現する方法が明確にわかるので、コメントに「やり方」を書く必要はありませんか?実際に書くこともできます。コメントでは、特定のコード実装のアイデアに関する特別なケースについて、いくつかの要約の指示と説明を書くことができます。このように、コードを読む人はコメントを通じてコードの実装アイデアを大まかに理解することができ、読みやすくなります。

実際、より複雑なクラスやインターフェースの場合は、コメントに「使用方法」を明確に記述し、簡単なクイック スタートの例をいくつか示して、ユーザーがコードを読まなくても使用方法をすぐに理解できるようにする必要がある場合があります。使用

3. いくつかの要約コメントは、コード構造をより明確にすることができます

複雑なロジックまたは長い関数を含むコードの場合、小さな関数呼び出しに絞り込んだり分割したりすることが容易でない場合は、要約コメントを使用してコード構造をより明確にし、より整理することができます。

public boolean isValidPasword(String password) {
    
    
        // check if password is null or empty
        if (StringUtils.isBlank(password)) {
    
    
            return false;
        }
        // check if the length of password is between 4 and 64
        int length = password.length();
        if (length < 4 || length > 64) {
    
    
            return false;
        }
        // check if password contains only lowercase characters
        if (!StringUtils.isAllLowerCase(password)) {
    
    
            return false;
        }
        // check if password 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;
}

5.2.2 コメントは多い方が良い?

コメントが多すぎても少なすぎても問題があります。多すぎるとコードが読みにくくなり、コメントをたくさん書いて補足する必要があるかもしれません。さらに、コメントが多すぎると、コード自体の読み取りが妨げられます。さらに、後の期間の保守コストは比較的高く、コードが変更されたり、コメントが同期的に変更されるのを忘れたりすることがあり、コードの読者をさらに混乱させます。もちろん、コードにコメント行がない場合は、プログラマーが怠け者であることを示しているだけであり、必要なコメントを追加するよう注意を払うよう適切に監督する必要があります。

経験によると、コメントはクラスと関数に対して記述する必要があり、可能な限り包括的かつ詳細に記述する必要がありますが、関数内のコメントは比較的少なく、一般に、適切な名前付け、洗練された関数、説明変数、および合計コメントに依存して改善されますコードの可読性

5.3 コードスタイル(コードスタイル)

5.3.1 クラスと関数の適切なサイズは?

一般的に言えば、クラスまたは関数のコードの行数は多すぎてはいけませんが、少なすぎてもいけません。クラスや関数のコード行数が多すぎる クラスには数千行、関数には数百行が含まれる ロジックが複雑すぎる コードを読むとき、簡単に裏を読んで表を忘れてしまう. 逆に、クラスや関数のコード行数が少なすぎると、総コード量が同じ場合、その分クラスや関数の分割数が増え、呼び出し関係が複雑になります。特定のコードロジックを読む、n個の複数のカテゴリまたはn個の複数の機能の間を頻繁にジャンプする必要があり、読み取り体験が良くない

クラスまたは関数に最適なコードの行数は?

正確な定量値を与えることは困難です。関数コードの行数の上限については、インターネット上で、ディスプレイ画面の縦方向の高さを超えてはならないということわざがあります。たとえば、私のコンピューターでは、関数のコードを完全に IDE に表示する場合、コードの最大行数は 50 を超えることはできません。この発言はかなり理にかなっていると思います。複数の画面の後、コードを読み取るときに、前後のコードロジックを接続するために、画面を頻繁に上下にスクロールする必要がある場合があるため、読み取りエクスペリエンスが悪いことは言うまでもなく、エラーも発生しやすくなります。

クラスのコード行数の最大制限については、正確な値を与えることはさらに困難です。以前、間接的な判断基準も出しましたが、あるクラスのコードを読んで圧倒されてしまい、ある機能を実装する際にどの機能を使えばいいのかわからない場合は、どの機能を使えばよいかを検索すればよいのです。これ以上、クラス全体を導入するために小さな関数だけを使用すると (クラスには、この関数の実装とは関係のない多くの関数が含まれています)、それは、クラスが多すぎる

5.3.2 コード行の最適な長さは?

Google Java スタイル ガイドのドキュメントでは、コード行は最大 100 文字に制限されています。ただし、プログラミング言語、仕様、プロジェクト チームが異なれば、これに対する制限も異なる場合があります。制限に関係なく、一般的に従うべき原則は次のとおりです。コードの最長行は、IDE によって表示される幅を超えることはできません。行のすべてのコードを表示するには、マウスをスクロールする必要がありますが、これは明らかにコードの読み取りに役立ちません。もちろん、この制限は小さすぎてはいけません。小さすぎると、わずかに長いステートメントが 2 行に折りたたまれ、コードのクリーン度にも影響し、読みにくくなります。

5.3.3 空行をうまく利用して単位ブロックを区切る

比較的長い関数で、論理的にいくつかの独立したコードブロックに分割できる場合、これらの独立したコードブロックを小さな関数に切り出すのが不便な場合は、論理をより明確にするために、要約する方法に加えて、コメント さらに、空白行を使用して個々のコード ブロックを区切ることができます

また、クラスのメンバー変数と関数の間、静的メンバー変数と通常のメンバー変数の間、関数の間、さらにはメンバー変数の間でも、空白行を追加してこれらの違いを作ることができます モジュールのコードの間で、境界がより明確に定義されます. コードを書くことは記事を書くことに似ています.空白行をうまく使うと、コードの全体的な構造がより明確で整理されたように見えます.

5.3.4 4 スペースのインデントか 2 スペースのインデントか?

「PHP は世界で最も優れたプログラミング言語ですか?コード区切りは 4 つまたは 2 つのスペースでインデントする必要がありますか?」これらは、プログラマーの間で最も議論される 2 つのトピックです。私の知る限り、Java 言語はスペース 2 つ、PHP 言語はスペース 4 つにインデントする傾向があります。2スペースインデントにするか4スペースインデントにするかは個人の好みによると思います。プロジェクトが統一できる限り

もちろん、別の選択基準があります。つまり、業界が推奨するスタイルと一致すること、および有名なオープン ソース プロジェクトと一致することです。オープン ソース コードをプロジェクトにコピーする必要がある場合、インポートしたコードのスタイルをプロジェクト自体のコードと一致させておくことができます。

ただし、スペースを節約できる 2 つのスペースのインデントを使用することを個人的にお勧めします。特にコードの入れ子レベルが比較的深い場合、累積インデントが大きいと、ステートメントが 2 行に折りたたまれやすくなり、コードの可読性に影響します。

さらに、2 スペースのインデントまたは 4 スペースのインデントを使用する場合でも、インデントに Tab キーを使用してはならないことを強調する価値があります。異なる IDE では、Tab キーの表示幅が異なるため、スペース 4 個のインデントで表示されるものもあれば、スペース 2 個のインデントで表示されるものもあります。同じプロジェクト内で、異なる同僚が異なるインデント方法 (スペース インデントまたはタブ キー インデント) を使用している場合、一部のコードが 2 スペース インデントで表示され、一部のコードが 4 スペース インデントで表示される場合がありますが、 IDE でインデントされるタブの数を設定する

5.3.5 中括弧は新しい行に配置する必要がありますか?

左中かっこは新しい行で開始する必要がありますか? これも物議を醸しています。私の知る限り、PHP プログラマーは新しい行を開始するのが好きで、Java プログラマーはそれを前のステートメントと組み合わせるのが好きです。具体的なコード例は次のとおりです。

// PHP
class ClassName
{
    
    
    public function foo()
    {
    
    
        // method body
    }
}
// Java
public class ClassName {
    
    
    public void foo() {
    
    
        // method body
    }
}

個人的には、ステートメントと同じ行に括弧を入れることをお勧めします。理由は上記と同様で、コード行を節約できます。しかし、新しい行で中括弧を開く方法にも利点があります。このように、左右の括弧を縦に並べることができ、どのコードがどのコードブロックに属しているかが一目瞭然

ただし、チームが統一され、業界が統一され、オープンソースプロジェクトに沿っている限り、中括弧は前のステートメントと同じ行にある、または新しい行にあることに変わりはありません。善悪の絶対的な区別ではない

5.3.6 クラス内のメンバの並び順

Java クラス ファイルには、まずクラスが属するパッケージ名を記述し、次にインポートによって導入された依存クラスをリストします。Google Coding Standards では、従属クラスは小さいものから大きいものへとアルファベット順に並べられています。

クラスでは、メンバー変数は関数の前に来ます。メンバー変数間または関数間では、「静的 (静的関数または静的メンバー変数)、通常 (非静的関数または非静的メンバー変数)」のように配置されます。また、メンバー変数や関数の間は、スコープの大きいものから小さいものへと並べ、最初に public のメンバー変数や関数を書き、次に protected、最後に private とします。

ただし、異なるプログラミング言語では、クラスの内部メンバーの配置順序がまったく異なる場合があります。たとえば、C++ では、メンバー変数は習慣的に関数の後ろに配置されます。また、関数間の配置順序は、スコープの大きさに合わせて配置されます。実は、呼び出し関係のある関数をまとめて配置するという、別の配置の癖があります。たとえば、パブリック関数が別のプライベート関数を呼び出す場合、2 つをまとめます。

5.4 プログラミングのヒント

5.4.1 コードを小さな単位ブロックに分割する

ほとんどの人はコードを読む習慣があり、まず全体を見てから詳細を見ていきます。したがって、モジュール化された抽象的思考を持ち、複雑なロジックの大きなブロックをクラスまたは関数に洗練するのが得意であり、詳細を隠して、コードを読む人が詳細で迷子にならないようにする必要があります。これにより、可読性が大幅に向上します。コードの。ただし、コード ロジックがより複雑な場合にのみ、実際にクラスまたは関数を抽出することをお勧めします。結局、抽出された関数に 2 ~ 3 行のコードしか含まれていない場合、コードを読み取るときにそれをスキップする必要があり、読み取りコストが増加します。

次の例では、リファクタリング前のinvest()関数、時間処理に関する最初のコードがわかりにくいですか? リファクタリング後、ロジックのこの部分を関数に抽象化し、isLastDayOfMonth という名前を付けます. 名前から、その関数が明確に理解でき、今日が月末かどうかを判断できます
. ここで、複雑なロジックコードを関数化することで、コードの可読性が大幅に向上します。

// 重构前的代码
public void invest(long userId, long financialProductId) {
    
    
        Calendar calendar = Calendar.getInstance();
        calendar.setTime(date);
        calendar.set(Calendar.DATE, (calendar.get(Calendar.DATE) + 1));

        if (calendar.get(Calendar.DAY_OF_MONTH) == 1) {
    
    
            return;
        }
        //...
}

// 重构后的代码:提炼函数之后逻辑更加清晰
public void invest(long userId, long financialProductId) {
    
    
        if (isLastDayOfMonth(new Date())) {
    
    
            return;
        }
        //...
}
public boolean isLastDayOfMonth(Date date) {
    
    
        Calendar calendar = Calendar.getInstance();
        calendar.setTime(date);
        calendar.set(Calendar.DATE, (calendar.get(Calendar.DATE) + 1));

        if (calendar.get(Calendar.DAY_OF_MONTH) == 1) {
    
    
            return true;
        }
        return false;
}

5.4.2 関数パラメータが多すぎないようにする

関数に含まれるパラメータが 3 つまたは 4 つであれば問題ありませんが、5 つ以上になるとパラメータが多すぎると感じられ、コードの可読性に影響を与え、使用するのが不便になります。パラメータが多すぎる場合は、一般に次の 2 つの処理方法があります。

1.関数の責任が単一かどうか、および複数の関数に分割することでパラメーターを削減できるかどうかを検討する

public void getUser(String username, String telephone, String email);
// 拆分成多个函数
public void getUserByUsername(String username);
public void getUserByTelephone(String telephone);
public void getUserByEmail(String email);

2. 関数のパラメーターをオブジェクトにカプセル化する

public void postBlog(String title, String summary, String keywords, String cont
// 将参数封装成对象
public class Blog {
    
    
    private String title;
    private String summary;
    private String keywords;
    private Strint content;
    private String category;
    private long authorId;
}
public void postBlog(Blog blog);

さらに、関数が外部に公開されたリモート インターフェイスである場合、パラメーターをオブジェクトにカプセル化することで、インターフェイスの互換性を向上させることもできます。インターフェイスに新しいパラメーターを追加する場合、古いリモート インターフェイスの呼び出し元は、新しいインターフェイスと互換性があるようにコードを変更する必要がない場合があります。

5.4.3 関数パラメーターを使用してロジックを制御しない

内部ロジックを制御する関数でブール型の識別パラメータを使用しないでください。true の場合はこのロジックを使用し、false の場合は別のロジックを使用します。これは、単一責任の原則とインターフェイス分離の原則に明らかに違反しています。読みやすくするために、2 つの関数に分割することをお勧めします。例えば:

public void buyCourse(long userId, long courseId, boolean isVip);
// 将其拆分成两个函数
public void buyCourse(long userId, long courseId);
public void buyCourseForVip(long userId, long courseId);

ただし、関数が影響範囲が限定されたプライベート関数である場合、または分割後の 2 つの関数が同時に呼び出されることが多い場合は、識別パラメーターを適切に保持することを検討できます。サンプルコードは次のとおりです。

// 拆分成两个函数的调用方式
boolean isVip = false;
//...省略其他逻辑...
if (isVip) {
    
    
    buyCourseForVip(userId, courseId);
} else {
    
    
    buyCourse(userId, courseId);
}
// 保留标识参数的调用方式更加简洁
boolean isVip = false;
//...省略其他逻辑...
buyCourse(userId, courseId, isVip);

除了布尔类型作为标识参数来控制逻辑的情况外,还有一种“根据参数是否为 null”来控制逻辑的情况。针对这种情况,也应该将其拆分成多个函数。拆分之后的函数职责更明确,不容易用错。具体代码示例如下所示:

public List<Transaction> selectTransactions(Long userId, Date startDate, Date endDate) {
    
    
    if (startDate != null && endDate != null) {
    
    
        // 查询两个时间区间的transactions
    }
    if (startDate != null && endDate == null) {
    
    
        // 查询startDate之后的所有transactions
    }
    if (startDate == null && endDate != null) {
    
    
        // 查询endDate之前的所有transactions
    }
    if (startDate == null && endDate == null) {
    
    
        // 查询所有的transactions
    }
}

// 拆分成多个public函数,更加清晰、易用
public List<Transaction> selectTransactionsBetween(Long userId, Date startDate, Date endDate) {
    
    
    return selectTransactions(userId, startDate, endDate);
}
public List<Transaction> selectTransactionsStartWith(Long userId, Date startDate, Date endDate) {
    
    
    return selectTransactions(userId, startDate, null);
}
public List<Transaction> selectTransactionsEndWith(Long userId, Date endDate) {
    
    
    return selectTransactions(userId, null, endDate);
}
public List<Transaction> selectAllTransactions(Long userId) {
    
    
    return selectTransactions(userId, null, null);
}
private List<Transaction> selectTransactions(Long userId, Date startDate, Date endDate) {
    
    
    // ...
}

5.4.4 函数设计要职责单一

前面讲到单一职责原则的时候,针对的是类、模块这样的应用对象。实际上,对于函数的设计来说,更要满足单一职责原则。相对于类和模块,函数的粒度比较小,代码行数少,所以在应用单一职责原则的时候,没有像应用到类或者模块那样模棱两可,能多单一就多单一,如下例:

public boolean checkUserIfExisting(String telephone, String username, String email) {
    
    
        if (!StringUtils.isBlank(telephone)) {
    
    
            User user = userRepo.selectUserByTelephone(telephone);
            return user != null;
        }
        if (!StringUtils.isBlank(username)) {
    
    
            User user = userRepo.selectUserByUsername(username);
            return user != null;
        }
        if (!StringUtils.isBlank(email)) {
    
    
            User user = userRepo.selectUserByEmail(email);
            return user != null;
        }
        return false;
}
// 拆分成三个函数
public boolean checkUserIfExistingByTelephone(String telephone);
public boolean checkUserIfExistingByUsername(String username);
public boolean checkUserIfExistingByEmail(String email);

5.4.5 移除过深的嵌套层次

代码嵌套层次过深往往是因为 if-else、switch-case、for 循环过度嵌套导致的。个人建议,嵌套最好不超过两层,超过两层之后就要思考一下是否可以减少嵌套。过深的嵌套本身理解起来就比较费劲,除此之外,嵌套过深很容易因为代码多次缩进,导致嵌套内部的语句超过一行的长度而折成两行,影响代码的整洁

解决嵌套过深的方法也比较成熟,有下面 4 种常见的思路:

1、去掉多余的 if 或 else 语句

// 示例一
public double caculateTotalAmount(List<Order> orders) {
    
    
        if (orders == null || orders.isEmpty()) {
    
    
            return 0.0;
        } else {
    
     // 此处的else可以去掉
            double amount = 0.0;
            for (Order order : orders) {
    
    
                if (order != null) {
    
    
                amount += (order.getCount() * order.getPrice());
            }
        }
        return amount;
        }
}

// 示例二
public List<String> matchStrings(List<String> strList,String substr) {
    
    
        List<String> matchedStrings = new ArrayList<>();
        if (strList != null && substr != null) {
    
    
            for (String str : strList) {
    
    
                if (str != null) {
    
     // 跟下面的if语句可以合并在一起
                    if (str.contains(substr)) {
    
    
                        matchedStrings.add(str);
                    }
                }
            }
        }
        return matchedStrings;
}

2、使用编程语言提供的 continue、break、return 关键字,提前退出嵌套

// 重构前的代码
public List<String> matchStrings(List<String> strList,String substr) {
    
    
        List<String> matchedStrings = new ArrayList<>();
        if (strList != null && substr != null){
    
    
            for (String str : strList) {
    
    
                if (str != null && str.contains(substr)) {
    
    
                    matchedStrings.add(str);
                    // 此处还有10行代码...
                }
            }
        }
        return matchedStrings;
}

// 重构后的代码:使用continue提前退出
public List<String> matchStrings(List<String> strList,String substr) {
    
    
        List<String> matchedStrings = new ArrayList<>();
        if (strList != null && substr != null){
    
    
            for (String str : strList) {
    
    
                if (str == null || !str.contains(substr)) {
    
    
                    continue;
                }
                matchedStrings.add(str);
                // 此处还有10行代码...
            }
        }
        return matchedStrings;
}

3、调整执行顺序来减少嵌套

// 重构前的代码
public List<String> matchStrings(List<String> strList,String substr) {
    
    
        List<String> matchedStrings = new ArrayList<>();
        if (strList != null && substr != null) {
    
    
            for (String str : strList) {
    
    
                if (str != null) {
    
    
                    if (str.contains(substr)) {
    
    
                        matchedStrings.add(str);
                    }
                }
            }
        }
        return matchedStrings;
}

// 重构后的代码:先执行判空逻辑,再执行正常逻辑
public List<String> matchStrings(List<String> strList,String substr) {
    
    
        if (strList == null || substr == null) {
    
     //先判空
            return Collections.emptyList();
        }
        List<String> matchedStrings = new ArrayList<>();
        for (String str : strList) {
    
    
            if (str != null) {
    
    
                if (str.contains(substr)) {
    
    
                    matchedStrings.add(str);
                }
            }
        }
        return matchedStrings;
}

4、将部分嵌套逻辑封装成函数调用,以此来减少嵌套

// 重构前的代码
public List<String> appendSalts(List<String> passwords) {
    
    
        if (passwords == null || passwords.isEmpty()) {
    
    
            return Collections.emptyList();
        }
        List<String> passwordsWithSalt = new ArrayList<>();
        for (String password : passwords) {
    
    
            if (password == null) {
    
    
                continue;
            }
            if (password.length() < 8) {
    
    
                // ...
            } else {
    
    
                // ...
            }
        }
        return passwordsWithSalt;
}

// 重构后的代码:将部分逻辑抽成函数
public List<String> appendSalts(List<String> passwords) {
    
    
        if (passwords == null || passwords.isEmpty()) {
    
    
            return Collections.emptyList();
        }
        List<String> passwordsWithSalt = new ArrayList<>();
        for (String password : passwords) {
    
    
            if (password == null) {
    
    
                continue;
            }
            passwordsWithSalt.add(appendSalt(password));
        }
        return passwordsWithSalt;
}
private String appendSalt(String password) {
    
    
        String passwordWithSalt = password;
        if (password.length() < 8) {
    
    
            // ...
        } else {
    
    
            // ...
        }
        return passwordWithSalt;
}

除此之外,常用的还有通过使用多态来替代 if-else、switch-case 条件判断的方法。这个思路涉及代码结构的改动

5.4.6 学会使用解释性变量

常用的用解释性变量来提高代码的可读性的情况有下面 2 种:

1、常量取代魔法数字

public double CalculateCircularArea(double radius) {
    
    
    return (3.1415) * radius * radius;
}
// 常量替代魔法数字
public static final Double PI = 3.1415;
public double CalculateCircularArea(double radius) {
    
    
    return PI * radius * radius;
}

2、使用解释性变量来解释复杂表达式

if (date.after(SUMMER_START) && date.before(SUMMER_END)) {
    
    
    // ...
} else {
    
    
    // ...
}

// 引入解释性变量后逻辑更加清晰
boolean isSummer = date.after(SUMMER_START)&&date.before(SUMMER_END);
if (isSummer) {
    
    
    // ...
} else {
    
    
    // ...
}

6. 通过一段 ID 生成器代码,学习如何发现代码质量问题

6.1 需求背景介绍

“ID”中文翻译为“标识(Identifier)”。这个概念在生活、工作中随处可见,比如身份证、商品条形码、二维码、车牌号、驾照号。聚焦到软件开发中,ID 常用来表示一些业务信息的唯一标识,比如订单的单号或者数据库中的唯一主键,比如地址表中的 ID 字段(实际上是没有业务含义的,对用户来说是透明的,不需要关注)

バックエンド業務システムの開発では、リクエストが失敗した場合のトラブルシューティングを容易にするために、コードを記述するときにクリティカル パスにログを出力するとします。リクエストが失敗した後、そのリクエストに対応するすべてのログを検索して、問題の原因を特定できることが望まれます。実際、ログ ファイルでは、さまざまな要求のログが絡み合っています。どのログが同じリクエストに属しているかを特定する手段がなければ、同じリクエストのすべてのログを関連付ける方法はありません。

これは、マイクロサービスのコール チェーン トレースに少し似ています。ただし、マイクロサービスにおけるコール チェーンの追跡はサービス間の追跡であり、今実装する必要があるのはサービス内の追跡です。

マイクロサービス コール チェーン トラッキングの実装アイデアを利用して、一意の ID を各要求に割り当て、要求コンテキスト (Context) に格納できます。たとえば、要求を処理するワーカー スレッドのローカル変数です。Java 言語では、サーブレット スレッドの ThreadLocal に ID を格納するか、Slf4j ログ フレームワークの MDC (Mapped Diagnostic Contexts) を使用して実装できます (実際、基になる原則もスレッドの ThreadLocal に基づいています)。 )。ログ出力の都度、リクエストコンテキストからリクエストIDを取り出し、ログとともに出力します。このように、同じリクエストのすべてのログには同じリクエスト ID 情報が含まれており、同じリクエストのすべてのログはリクエスト ID で検索できます。

6.2 「動く」コードの実装

public class IdGenerator {
    
    
    private static final Logger logger = LoggerFactory.getLogger(IdGenerator.class);

    public static String generate() {
    
    
        String id = "";
        try {
    
    
            String hostName = InetAddress.getLocalHost().getHostName();
            String[] tokens = hostName.split("\\.");
            if (tokens.length > 0) {
    
    
                hostName = tokens[tokens.length - 1];
            }
            char[] randomChars = new char[8];
            int count = 0;
            Random random = new Random();
            while (count < 8) {
    
    
                int randomAscii = random.nextInt(122);
                if (randomAscii >= 48 && randomAscii <= 57) {
    
    
                    randomChars[count] = (char)('0' + (randomAscii - 48));
                    count++;
                } else if (randomAscii >= 65 && randomAscii <= 90) {
    
    
                    randomChars[count] = (char)('A' + (randomAscii - 65));
                    count++;
                } else if (randomAscii >= 97 && randomAscii <= 122) {
    
    
                    randomChars[count] = (char)('a' + (randomAscii - 97));
                    count++;
                }
            }
            id = String.format("%s-%d-%s", hostName,
                    System.currentTimeMillis(), new String(randomChars));
        } catch (UnknownHostException e) {
    
    
            logger.warn("Failed to get the host name.", e);
        }
        return id;
    }
}

上記のコードで生成された ID の例を以下に示します。ID 全体は 3 つの部分で構成されます。最初の部分は、ホスト名の最後のフィールドです。2 番目の部分は現在のタイムスタンプで、ミリ秒単位の精度です。3 番目の部分は、大文字と小文字の文字と数字を含む 8 ビットのランダムな文字列です。この方法で生成された ID は完全に一意ではなく、重複する可能性がありますが、実際には重複する可能性は非常に低くなります。ログ追跡の場合、ID が重複する可能性が非常に低いことは完全に許容されます。

103-1577456311467-3nR3Do45
103-1577456311468-0wnuV5yw
103-1577456311468-sdrnkFxN
103-1577456311468-8lwk0BP0

6.3 コード品質の問題を見つける方法は?

全体像を見ると、上記のコード品質評価基準を参照して、このコードが読み取り可能、拡張可能、保守可能、柔軟、簡潔、再利用可能、テスト可能などであるかどうかを確認できます。特定の詳細を実装するために、次の側面からコードを調べることができます。

  • ディレクトリ設定は妥当か、モジュール分割は明確か、コード構造は「高い凝集性と疎結合性」を満たしているか。
  • 古典的なデザイン原則とデザイン アイデア (SOLID、DRY、KISS、YAGNI、LOD など) に従っていますか?
  • デザインパターンは適切に適用されていますか? オーバーエンジニアリングはありますか?
  • コードは簡単に拡張できますか? 新しい機能を追加する場合、実装は簡単ですか?
  • コードを再利用できますか? 既存のプロジェクト コードまたはクラス ライブラリを再利用することは可能ですか? 車輪の再発明はありますか?
  • コードは簡単にテストできますか? 単体テストは、正常なケースと異常なケースを包括的にカバーしていますか?
  • コードは読みやすいですか?コーディング規約に準拠しているか(ネーミングやコメントが適切か、コードスタイルに一貫性があるかなど)?

上記はいくつかの一般的な懸念事項であり、一般的な検査項目として使用でき、あらゆるコード リファクタリングに適用できます。さらに、コードの実装がビジネス自体の固有の機能要件と非機能要件を満たしているかどうかにも注意を払う必要があります。以下は、より一般的な問題の一部のリストです。このリストは十分に包括的ではない可能性があり、残りは特定のビジネスおよび特定のコードについて特に分析する必要があります。

  • コードは期待されるビジネス要件を実装していますか?
  • ロジックは正しいですか?例外は処理されますか?
  • ログは正しく出力されていますか? 問題のデバッグとトラブルシューティングに便利ですか?
  • インターフェイスは使いやすいですか?冪等性、トランザクションなどをサポートしていますか?
  • コードに同時実行の問題はありますか? スレッドセーフですか?
  • SQL やアルゴリズムを最適化できるなど、パフォーマンスを最適化する余地はありますか?
  • セキュリティホールはありますか?たとえば、入力と出力の検証は包括的ですか?

では、上記のチェック項目に従って、6.2コードの問題点を見てみましょう

1. IdGenerator のコードは、クラスが 1 つしかない比較的単純なコードであるため、ディレクトリ設定、モジュール分割、コード構造の問題はなく、SOLID、DRY、KISS、YAGNI、LOD などの基本的な設計原則に違反していません。デザインパターンを適用しないので、無理な使い方やオーバーデザインの問題がない

2. IdGenerator はインターフェイスではなく実装クラスとして設計されており、呼び出し元はインターフェイスではなく実装に直接依存しています。これは、実装ではなくインターフェイスに基づいてプログラミングするという設計思想に違反しています。実際、インターフェイスを定義せずに IdGenerator を実装クラスとして設計することは大きな問題ではありません。ある日 ID 生成アルゴリズムが変更された場合、実装クラスのコードを直接変更するだけで済みます。ただし、2 つの ID 生成アルゴリズムが同時にプロジェクトに存在する必要がある場合、つまり、2 つの IdGenerator 実装クラスが同時に存在する必要があります。たとえば、このフレームワークは、より多くのシステムで使用する必要があります。システムが使用されている場合、必要な生成アルゴリズムを柔軟に選択できます。この時点で、IdGenerator をインターフェースとして定義し、生成アルゴリズムごとに異なる実装クラスを定義する必要があります。

3. IdGenerator のgenerate()関数をこの関数を使用するコードのテスト容易性に影響します。同時に、generate()関数のコード実装は動作環境 (ローカルマシン名)、時間関数、ランダム関数に依存するため、generate()関数リファクタリングが必要です。さらに、リファクタリング時に補足する必要がある単体テスト コードは記述されていません。

4. IdGenerator には 1 つの関数しか含まれておらず、コードの行数も多くありませんが、コードの可読性は良くありません。特にランダムな文字列を生成するコードの部分. 一方で、コードにはコメントがまったくなく、生成アルゴリズムが理解しにくい. 一方で、コードには多くのマジックナンバーがあり、深刻な影響を与えます.コードの可読性。リファクタリングするときは、コードのこの部分の読みやすさを改善することに集中する必要があります

ビジネス自体とは関係のない一般的なコード品質の問題を参照して、コードを評価しただけです。ここで、ビジネス自体の機能要件と非機能要件に対してコードを再検討します。

1. 前述のように、コードによって生成される ID は完全に一意ではありませんが、ログを追跡および印刷する場合、ID の競合の可能性が低く、予想されるビジネス ニーズを満たすことは許容されます。ただし、コードの中でホスト名を取得する部分のロジックに問題があるようで、「ホスト名が空」の場合を扱っていません。また、ローカルマシン名が取得できない場合の例外処理をコードで行っていますが、例外処理は IdGenerator 内で吐き出してからアラームログを出力し、吐き続けないようにしています。この例外処理は適切ですか?

2.コードのログが適切に出力され、ログの説明が問題を正確に反映でき、デバッグに便利で、冗長なログがあまりありません。IdGenerator は、ユーザーが使用できるgenerate()インターフェイス. インターフェイスの定義は単純明快であり、使いにくいという問題はありません。generate()関数コードには共有変数が含まれていないため、コードはスレッドセーフであり、マルチスレッド環境でgenerate()関数を

3. パフォーマンスの面では、ID 生成は外部ストレージに依存せず、メモリ内で生成され、ログの印刷頻度はそれほど高くないため、コードは現在のアプリケーション シナリオに対処するパフォーマンスの面で十分です。ただし、ID を生成するたびにホスト名を取得する必要があり、ホスト名の取得に時間がかかるため、この部分は最適化されていると言えます。また、randomAScii の範囲は 0 から 122 ですが、使用可能な数値には 3 つのサブ間隔 ( 0~9,a~z,A~Z) しか含まれていません。文字列生成アルゴリズムも最適化できます。

共通の特徴がなく、一概に列挙できないコード品質の問題もあり、特定の業務や特定のコードごとに詳細に分析する必要があります。このコードのような特定の問題を見つけることができますか?

generate()functionの while ループでは、3 つの if ステートメント内のコードは非常に似ており、実装が少し複雑すぎます. 実際、3 つの if ステートメントをマージすることで、さらに単純化できます。

6.4 リファクタリング

先ほどシステムの設計と実装について話したとき、私は段階的に進め、小さなステップで実行する必要があると何度も言いました。コードのリファクタリングのプロセスも、この考え方に従う必要があります。毎回少しずつ変更し、改善が行われた後、次の最適化ラウンドが実行され、コードへの各変更が大きくなりすぎず、短時間で完了することが保証されます。したがって、上記のコード品質の問題は、次のように 4 つのリファクタリングに分割して完了します。

  1. コードの可読性を向上させる
  2. コードのテスト容易性を改善する
  3. 良い単体テストを書く
  4. すべてのリファクタリングが完了した後にコメントを追加する

6.4.1 コードの読みやすさを改善する

まず、コードの可読性の最も明白で緊急の領域に対処します。具体的には、以下の点があります。

  • 特に 2 つの使用法が異なる意味を持つ場合は、hostName 変数を再利用しないでください。
  • hostName を取得するコードを抽出し、getLastfieldOfHostName()関数
  • コード内のマジック ナンバーを削除します (例: 57、90、97、122)。
  • 乱数生成のコードを抽出してgenerateRandomAlphameric()関数
  • generate()関数内の 3 つの if ロジックが繰り返され、実装が複雑すぎるため、単純化する必要があります。
  • IdGenerator クラスの名前を変更し、対応するインターフェイスを抽象化します

ここでは、最後の変更に焦点を当てます。実はIDジェネレーターのコードには、以下の3種類の命名方法があります。どちらがより適していますか?

ここに画像の説明を挿入

1. インターフェイス IdGenerator と実装クラス LogTraceIdGenerator に名前を付ける. これは、多くの人が考える最初の名前付け方法かもしれません。名前を付けるときは、2 つのクラスがどのように使用され、将来どのように拡張されるかを考慮する必要があります。使い方や拡張の観点から、そのようなネーミングは理不尽です。

まず、新しいログ ID 生成アルゴリズムを拡張する場合は、別の新しい実装クラスを作成する必要があります。これは、元の実装クラスが既に LogTraceIdGenerator と呼ばれており、名前が一般的すぎて、新しい実装クラスに名前を付けるのが容易ではないためです。 、および名前を付けることはできません LogTraceIdGenerator に類似した名前

第二に、ログ ID の拡張要件はないと仮定して、ユーザー (UserIdGenerator) やオーダー (OrderIdGenerator) など、他のビジネス向けの ID 生成アルゴリズムを拡張することは、最初の命名方法が合理的であると言えますか? 答えはいいえだ。実装プログラミングではなくインターフェースに基づく主な目的は、実装クラスのその後の柔軟な置換を容易にすることです。命名の観点から、LogTraceIdGenerator、UserIdGenerator、および OrderIdGenerator の 3 つのクラスは、まったく異なるサービスを含み、相互の置き換えシナリオはありません
つまり、ログ関連のコードで次の置換を行うことはできません。したがって、これら 3 つのクラスに同じインターフェイスを実装させることは、実際には意味がありません。

IdGenearator idGenerator = new LogTraceIdGenerator();
替换为:
IdGenearator idGenerator = new UserIdGenerator();

2. 2 番目の命名方法は合理的ですか? 答えはいいえだ。その中で、LogTraceIdGenerator インターフェイスのネーミングは合理的ですが、HostNameMillisIdGenerator の実装クラスは実装の詳細を公開しすぎているため、コードを少し変更する限り、実装に合わせてネーミングを変更する必要がある場合があります。

3. 3 番目の命名方法をお勧めします。ID ジェネレーターの現在のコード実装では、生成された ID はランダムな ID であり、インクリメンタルな順序ではありません. したがって、RandomIdGenerator という名前を付ける方が合理的です. 内部生成アルゴリズムが変更されたとしても、生成された ID がはまだランダムです。名前を変更する必要はありません。増分順序の ID 生成アルゴリズムを実装するなど、新しい ID 生成アルゴリズムを拡張する必要がある場合は、SequenceIdGenerator という名前を付けることができます。

実際、より適切な命名方法は、2 つのインターフェイスを抽象化することです。1 つは IdGenerator で、もう 1 つは LogTraceIdGenerator で、LogTraceIdGenerator は IdGenerator を継承します。実装クラスは、RandomIdGenerator、SequenceIdGenerator などの名前のインターフェイス LogTraceIdGenerator を実装します。このように、実装クラスは、前述の user や order など、複数のビジネス モジュールで再利用できます。

上記の最適化戦略に従って、コードに対して最初のリファクタリングが実行されます。リファクタリング後のコードは次のようになります。

public interface IdGenerator {
    
    
    String generate();
}

public interface LogTraceIdGenerator extends IdGenerator {
    
    
}

public class RandomIdGenerator implements IdGenerator {
    
    
    private static final Logger logger = LoggerFactory.getLogger(RandomIdGenerator.class);

    @Override
    public String generate() {
    
    
        String substrOfHostName = getLastfieldOfHostName();
        long currentTimeMillis = System.currentTimeMillis();
        String randomString = generateRandomAlphameric(8);
        String id = String.format("%s-%d-%s",
                substrOfHostName, currentTimeMillis, randomString);
        return id;
    }
    private String getLastfieldOfHostName() {
    
    
        String substrOfHostName = null;
        try {
    
    
            String hostName = InetAddress.getLocalHost().getHostName();
            String[] tokens = hostName.split("\\.");
            substrOfHostName = tokens[tokens.length - 1];
            return substrOfHostName;
        } catch (UnknownHostException e) {
    
    
            logger.warn("Failed to get the host name.", e);
        }
        return substrOfHostName;
    }
    private String generateRandomAlphameric(int length) {
    
    
        char[] randomChars = new char[length];
        int count = 0;
        Random random = new Random();
        while (count < length) {
    
    
            int maxAscii = 'z';
            int randomAscii = random.nextInt(maxAscii);
            boolean isDigit= randomAscii >= '0' && randomAscii <= '9';
            boolean isUppercase= randomAscii >= 'A' && randomAscii <= 'Z';
            boolean isLowercase= randomAscii >= 'a' && randomAscii <= 'z';
            if (isDigit|| isUppercase || isLowercase) {
    
    
                randomChars[count] = (char) (randomAscii);
                ++count;
            }
        }
        return new String(randomChars);
    }
}

// 代码使用举例
LogTraceIdGenerator logTraceIdGenerator = new RandomIdGenerator();

6.4.2 コードのテスト容易性の向上

コードのテスト容易性に関する質問には、主に次の 2 つの側面が含まれます。

  1. generate()関数が静的関数として定義されているため、関数を使用するコードのテスト容易性に影響します。
  2. generate()関数のコード実装は実行環境(ローカル名)、時間関数、ランダム関数に依存するため、generate()関数良くありません

最初の点については、最初のリファクタリングで解決されました。RandomIdGenerator クラスのgenerate()静的通常の関数として再定義します。呼び出し元は、外部で RandomIdGenerator オブジェクトを作成し、依存性注入を通じて自分のコードに注入して、静的関数呼び出しがコードのテスト容易性に影響を与えるという問題を解決できます。

2点目は、1回目のリファクタリングをベースにリファクタリングを行う必要があります。リファクタリングされたコードは次のとおりです。主に次のコード変更が含まれます。

  1. getLastfieldOfHostName()functionから、より複雑なロジックを持つコードの部分が取り除かれ、getLastSubstrSplittedByDot()関数として定義されます。getLastfieldOfHostName()この関数はローカル ホスト名に依存するため、この関数は非常に単純になり、メイン コードが取り除かれるため、テストする必要はありません。テストgetLastSubstrSplittedByDot()機能
  2. generateRandomAlphameric()およびgetLastSubstrSplittedByDot()関数のアクセス許可を保護に設定します。これの目的は、2 つの関数を単体テストのオブジェクトを介して直接呼び出してテストできるようにすることです。
  3. Google Guava のアノテーション @VisibleForTestinggenerateRandomAlphameric()と2 つの関数に追加します。getLastSubstrSplittedByDot()この注釈は実際的な効果はなく、識別としてのみ機能し、これら 2 つの関数にはプライベート アクセス権が必要であることを他のユーザーに伝えます。また、アクセス権が保護されている理由はテストのためだけであり、ユニット テストの中間でのみ使用できます。

public class RandomIdGenerator implements LogTraceIdGenerator {
    
    
  private static final Logger logger = LoggerFactory.getLogger(RandomIdGenerator.class);

  @Override
  public String generate() {
    
    
    String substrOfHostName = getLastfieldOfHostName();
    long currentTimeMillis = System.currentTimeMillis();
    String randomString = generateRandomAlphameric(8);
    String id = String.format("%s-%d-%s",
            substrOfHostName, currentTimeMillis, randomString);
    return id;
  }

  private String getLastfieldOfHostName() {
    
    
    String substrOfHostName = null;
    try {
    
    
      String hostName = InetAddress.getLocalHost().getHostName();
      substrOfHostName = getLastSubstrSplittedByDot(hostName);
    } catch (UnknownHostException e) {
    
    
      logger.warn("Failed to get the host name.", e);
    }
    return substrOfHostName;
  }

  @VisibleForTesting
  protected String getLastSubstrSplittedByDot(String hostName) {
    
    
    String[] tokens = hostName.split("\\.");
    String substrOfHostName = tokens[tokens.length - 1];
    return substrOfHostName;
  }

  @VisibleForTesting
  protected String generateRandomAlphameric(int length) {
    
    
    char[] randomChars = new char[length];
    int count = 0;
    Random random = new Random();
    while (count < length) {
    
    
      int maxAscii = 'z';
      int randomAscii = random.nextInt(maxAscii);
      boolean isDigit= randomAscii >= '0' && randomAscii <= '9';
      boolean isUppercase= randomAscii >= 'A' && randomAscii <= 'Z';
      boolean isLowercase= randomAscii >= 'a' && randomAscii <= 'z';
      if (isDigit|| isUppercase || isLowercase) {
    
    
        randomChars[count] = (char) (randomAscii);
        ++count;
      }
    }
    return new String(randomChars);
  }
}

ログを出力する Logger オブジェクトは static final として定義され、クラス内で作成されますが、これはコードのテスト容易性に影響しますか? Logger オブジェクトを依存性注入によってクラスに注入する必要がありますか?

依存性注入によってコードのテスト容易性が向上する主な理由は、このように依存性の実際のオブジェクトをモック オブジェクトに簡単に置き換えることができるためです。では、なぜこのオブジェクトをモックするのでしょうか? これは、このオブジェクトがロジックの実行に参加している (たとえば、後続の計算のために出力するデータに依存している) が、制御できないためです。Logger オブジェクトの場合、データを書き込むだけで、データを読み取らず、ビジネス ロジックの実行には関与せず、コード ロジックの正確性には影響しないため、Logger オブジェクトをモックする必要はありません。

また、String、Map、UserVo など、データの格納のみに使用される一部の値オブジェクトは、依存性注入によって作成する必要がなく、new を使用してクラス内で直接作成できます。

6.4.3 良い単体テストを書く

经过上面的重构之后,代码存在的比较明显的问题,基本上都已经解决了。现在为代码补全单元测试。RandomIdGenerator 类中有 4 个函数:

public String generate();
private String getLastfieldOfHostName();
@VisibleForTesting
protected String getLastSubstrSplittedByDot(String hostName);
@VisibleForTesting
protected String generateRandomAlphameric(int length);

先来看后两个函数。这两个函数包含的逻辑比较复杂,是测试的重点。而且,在上一步重构中,为了提高代码的可测试性,已经将这两个部分代码跟不可控的组件(本机名、随机函数、时间函数)进行了隔离。所以,只需要设计完备的单元测试用例即可。具体的代码实现如下所示(注意,这里使用了 JUnit 测试框架):

public class RandomIdGeneratorTest {
    
    
  @Test
  public void testGetLastSubstrSplittedByDot() {
    
    
    RandomIdGenerator idGenerator = new RandomIdGenerator();
    String actualSubstr = idGenerator.getLastSubstrSplittedByDot("field1.field2.field3");
    Assert.assertEquals("field3", actualSubstr);

    actualSubstr = idGenerator.getLastSubstrSplittedByDot("field1");
    Assert.assertEquals("field1", actualSubstr);

    actualSubstr = idGenerator.getLastSubstrSplittedByDot("field1#field2#field3");
    Assert.assertEquals("field1#field2#field3", actualSubstr);
  }

  // 此单元测试会失败,因为在代码中没有处理hostName为null或空字符串的情况
  @Test
  public void testGetLastSubstrSplittedByDot_nullOrEmpty() {
    
    
    RandomIdGenerator idGenerator = new RandomIdGenerator();
    String actualSubstr = idGenerator.getLastSubstrSplittedByDot(null);
    Assert.assertNull(actualSubstr);

    actualSubstr = idGenerator.getLastSubstrSplittedByDot("");
    Assert.assertEquals("", actualSubstr);
  }

  @Test
  public void testGenerateRandomAlphameric() {
    
    
    RandomIdGenerator idGenerator = new RandomIdGenerator();
    String actualRandomString = idGenerator.generateRandomAlphameric(6);
    Assert.assertNotNull(actualRandomString);
    Assert.assertEquals(6, actualRandomString.length());
    for (char c : actualRandomString.toCharArray()) {
    
    
         Assert.assertTrue(('0' <= c && c <= '9') || ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z'));
    }
  }

  // 此单元测试会失败,因为在代码中没有处理length<=0的情况
  @Test
  public void testGenerateRandomAlphameric_lengthEqualsOrLessThanZero() {
    
    
    RandomIdGenerator idGenerator = new RandomIdGenerator();
    String actualRandomString = idGenerator.generateRandomAlphameric(0);
    Assert.assertEquals("", actualRandomString);

    actualRandomString = idGenerator.generateRandomAlphameric(-1);
    Assert.assertNull(actualRandomString);
  }
}

再来看 generate() 函数。这个函数也是我们唯一一个暴露给外部使用的 public 函数。虽然逻辑比较简单,最好还是测试一下。但是,它依赖主机名、随机函数、时间函数,我们该如何测试呢?需要 mock 这些函数的实现吗?

实际上,这要分情况来看。前面讲过,写单元测试的时候,测试对象是函数定义的功能,而非具体的实现逻辑。这样才能做到,函数的实现逻辑改变了之后,单元测试用例仍然可以工作。那 generate() 函数实现的功能是什么呢?这完全是由代码编写者自己来定义的

比如,针对同一份 generate() 函数的代码实现,可以有 3 种不同的功能定义,对应 3 种不同的单元测试:

  1. 如果把 generate() 函数的功能定义为:“生成一个随机唯一 ID”,那只要测试多次调用 generate() 函数生成的 ID 是否唯一即可
  2. 如果把 generate() 函数的功能定义为:“生成一个只包含数字、大小写字母和中划线的唯一 ID”,那不仅要测试 ID 的唯一性,还要测试生成的 ID 是否只包含数字、大小写字母和中划线
  3. generate()関数の関数が次のように定義されている場合: "一意の ID を生成します。形式は次のとおりです: {ホスト名のサブストリング}-{タイムスタンプ}-{8 桁の乱数}。ホスト名の取得に失敗した場合は、戻り値: null-{タイムスタンプ}- {8 桁の乱数}」の場合、ID の一意性をテストするだけでなく、生成された ID がフォーマット要件に完全に準拠しているかどうかもテストします

単体テスト ケースの記述方法は、関数の定義方法によって異なりますgenerate()関数の最初の 2 つの定義については、ホスト名を取得する関数、ランダム関数、時間関数などをモックする必要はありませんが、3 つ目の定義については、ホスト名を取得する関数をモックする必要があります。 null を返して、コードが期待どおりに実行されるかどうかをテストします

最後に、getLastfieldOfHostName()関数。実際、この関数は静的関数 ( InetAddress.getLocalHost().getHostName();) を呼び出し、この静的関数はランタイム環境に依存するため、テストは容易ではありません。ただし、この関数の実装は非常に簡単で、肉眼で明らかなバグを基本的に除外できるため、単体テスト コードを記述する必要はありません。結局のところ、単体テストを書く目的はコードのバグを減らすことであり、単体テストを書くために単体テストを書くことではありません。

もちろん、本当にテストしたい場合は方法があります。1 つのアプローチは、より高度なテスト フレームワークを使用することです。たとえば、静的関数をモックできる PowerMock です。もう 1 つの方法は、ホスト名を取得するロジックを新しい関数として再パッケージ化することです。ただし、後者の方法ではコードが断片化しすぎて、コードの可読性にもわずかに影響するため、長所と短所を考慮して選択する必要があります。

6.4.3 コメントの追加

コメントは多すぎても少なすぎてもいけません。主にクラスと関数に追加されます。良いネーミングはコメントの代わりになり、意味を明確に表現できると言う人もいます。これは変数の命名には当てはまりますが、クラスや関数には必ずしも当てはまりません。クラスや関数に含まれるロジックは複雑なことが多く、単に名前を付けるだけではどのような関数が実現されているかを明確に示すことが難しいため、このときコメントで補足する必要があります。たとえば、上記の関数の 3 つの関数定義generate()は名前に反映できず、コメントに追加する必要があります。

コメントの書き方については、何をするか、なぜ、どのように、どのように使用するか、いくつかの境界条件、特別な状況を説明し、関数の入力、出力、および例外を説明することを明確に書くことが主です。

/**
 * Id Generator that is used to generate random IDs.
 *
 * <p>
 * The IDs generated by this class are not absolutely unique,
 * but the probability of duplication is very low.
 */
public class RandomIdGenerator implements LogTraceIdGenerator {
    
    
  private static final Logger logger = LoggerFactory.getLogger(RandomIdGenerator.class);

  /**
   * Generate the random ID. The IDs may be duplicated only in extreme situation.
   *
   * @return an random ID
   */
  @Override
  public String generate() {
    
    
    //...
  }

  /**
   * Get the local hostname and
   * extract the last field of the name string splitted by delimiter '.'.
   *
   * @return the last field of hostname. Returns null if hostname is not obtained.
   */
  private String getLastfieldOfHostName() {
    
    
    //...
  }

  /**
   * Get the last field of {@hostName} splitted by delemiter '.'.
   *
   * @param hostName should not be null
   * @return the last field of {@hostName}. Returns empty string if {@hostName} is empty string.
   */
  @VisibleForTesting
  protected String getLastSubstrSplittedByDot(String hostName) {
    
    
    //...
  }

  /**
   * Generate random string which
   * only contains digits, uppercase letters and lowercase letters.
   *
   * @param length should not be less than 0
   * @return the random string. Returns empty string if {@length} is 0
   */
  @VisibleForTesting
  protected String generateRandomAlphameric(int length) {
    
    
    //...
  }
}

6.5 例外処理

関数の結果は、2 つのカテゴリに分けることができます。1 つのカテゴリは、通常の状況で関数が出力する期待される結果です。1 つのカテゴリは予期しない結果です。つまり、異常な (またはエラーと呼ばれる) 状態で関数によって出力される結果です。たとえば、上記のローカル ホスト名を取得する関数は、通常、この関数はローカル ホスト名を文字列形式で返しますが、異常な状況では、ローカル ホスト名の取得に失敗した場合、この関数は UnknownHostException 例外オブジェクトを返します。

通常の状況では、関数によって返されるデータの型は非常に明確ですが、例外的なケースでは、関数によって返されるデータの型は非常に柔軟であり、多くの選択肢があります。前述の UnknownHostException のような例外オブジェクトに加えて、関数はエラー コード、NULL 値、特別な値 (-1 など)、空のオブジェクト (空の文字列、空のコレクションなど) などを返すこともできます。

各例外戻りデータ型には、独自の特性と適用可能なシナリオがあります。しかし、異常な状況下では、関数が返すデータ型を判断するのがそれほど簡単ではない場合があります。たとえば、ホスト名の取得に失敗した場合、ID ジェネレーターのgenerate()関数は? 異常ですか?ヌル文字?またはNULL値?または他の特別な値 (null-15293834874-fd3A9KBn など、null はホスト名が取得されていないことを意味します)?

関数はコードを記述する上で非常に重要な単位であり、関数を記述するときは常に関数の例外処理を考慮する必要があります。したがって、例外的なケースで関数の戻りデータ型をどのように設計するかは非常に重要です。

以前、非常に単純な ID ジェネレータ コードが「使える」ものから「使いやすい」ものに再構築されました。最終的なコードは完璧に見えますが、よく考えてみると、コード内のエラー処理の方法にはまだ最適化の余地があります。

public class RandomIdGenerator implements IdGenerator {
    
    
    private static final Logger logger = LoggerFactory.getLogger(RandomIdGenerator.class);

    @Override
    public String generate() {
    
    
        String substrOfHostName = getLastFiledOfHostName();
        long currentTimeMillis = System.currentTimeMillis();
        String randomString = generateRandomAlphameric(8);
        String id = String.format("%s-%d-%s",
                substrOfHostName, currentTimeMillis, randomString);
        return id;
    }
    private String getLastFiledOfHostName() {
    
    
        String substrOfHostName = null;
        try {
    
    
            String hostName = InetAddress.getLocalHost().getHostName();
            substrOfHostName = getLastSubstrSplittedByDot(hostName);
        } catch (UnknownHostException e) {
    
    
            logger.warn("Failed to get the host name.", e);
        }
        return substrOfHostName;
    }
    @VisibleForTesting
    protected String getLastSubstrSplittedByDot(String hostName) {
    
    
        String[] tokens = hostName.split("\\.");
        String substrOfHostName = tokens[tokens.length - 1];
        return substrOfHostName;
    }
    @VisibleForTesting
    protected String generateRandomAlphameric(int length) {
    
    
        char[] randomChars = new char[length];
        int count = 0;
        Random random = new Random();
        while (count < length) {
    
    
            int maxAscii = 'z';
            int randomAscii = random.nextInt(maxAscii);
            boolean isDigit= randomAscii >= '0' && randomAscii <= '9';
            boolean isUppercase= randomAscii >= 'A' && randomAscii <= 'Z';
            boolean isLowercase= randomAscii >= 'a' && randomAscii <= 'z';
            if (isDigit|| isUppercase || isLowercase) {
    
    
                randomChars[count] = (char) (randomAscii);
                ++count;
            }
        }
        return new String(randomChars);
    }
}

このコードには 4 つの関数があります。これら 4 つの関数のエラー処理方法については、次のようないくつかの問題があります。

  1. generate()関数について、ホスト名の取得に失敗した場合、関数は何を返しますか? そのような戻り値は妥当ですか?
  2. getLastFiledOfHostName()functionの場合、関数内で UnknownHostException を飲み込む必要がありますか (try-catch してログを出力します)? それとも、引き続き例外をスローする必要がありますか? 上向きにスローされる場合、UnknownHostException をそのままスローするか、新しい例外にカプセル化する必要がありますか?
  3. getLastSubstrSplittedByDot(String hostName)関数の場合、hostName が NULL または空の文字列の場合、関数は何を返す必要がありますか?
  4. generateRandomAlphameric(int length)関数について、長さが 0 未満または 0 に等しい場合、この関数は何を返す必要がありますか?

6.5.1 エラーが発生した場合、関数は何を返す必要がありますか?

関数エラーによって返されるデータ型に関して、エラー コード、NULL 値、空のオブジェクト、例外オブジェクトの 4 つの状況が要約されます。

6.5.1.1 返されるエラー コード

C言語には例外などの文法的な仕組みがないため、エラーコードを返すのが最も一般的なエラー処理方法です。Java や Python などの比較的新しいプログラミング言語では、ほとんどの場合、関数のエラーを処理するために例外が使用され、エラー コードはほとんど使用されません。

C言語では、エラーコードを返す方法として、関数の戻り値を直接占有する方法と、関数の正常実行時の戻り値を出力パラメータに入れる方法と、定義して返す方法の2つがあります。関数内のグローバル変数としてのエラー コード エラーが発生すると、関数の呼び出し元は、このグローバル変数を通じてエラー コードを取得します。これら 2 つの方法の例を次に示します。

// 错误码的返回方式一:pathname/flags/mode为入参;fd为出参,存储打开的文件句柄。
int open(const char *pathname, int flags, mode_t mode, int* fd) {
    
    
    if (/*文件不存在*/) {
    
    
        return EEXIST;
    }
    if (/*没有访问权限*/) {
    
    
        return EACCESS;
    }
    if (/*打开文件成功*/) {
    
    
        return SUCCESS; // C语言中的宏定义:#define SUCCESS 0
    }
    // ...
}
//使用举例
int fd;
int result = open(“c:\test.txt”, O_RDWR, S_IRWXU|S_IRWXG|S_IRWXO, &fd);
if (result == SUCCESS) {
    
    
    // 取出fd使用
} else if (result == EEXIST) {
    
    
    //...
} else if (result == EACESS) {
    
    
    //...
}

// 错误码的返回方式二:函数返回打开的文件句柄,错误码放到errno中。
int errno; // 线程安全的全局变量
int open(const char *pathname, int flags, mode_t mode){
    
    
    if (/*文件不存在*/) {
    
    
        errno = EEXIST;
        return -1;
    }
    if (/*没有访问权限*/) {
    
    
        errno = EACCESS;
        return -1;
    }
    // ...
}
// 使用举例
int hFile = open(“c:\test.txt”, O_RDWR, S_IRWXU|S_IRWXG|S_IRWXO);
if (-1 == hFile) {
    
    
    printf("Failed to open file, error no: %d.\n", errno);
    if (errno == EEXIST ) {
    
    
        // ...
    } else if(errno == EACCESS) {
    
    
        // ...
    }
    // ...
}

実際、使い慣れたプログラミング言語に文法的な例外メカニズムがある場合は、エラー コードを使用しないようにしてください。エラーコードと比較して、例外には多くの利点があります。たとえば、より多くのエラー情報を運ぶことができます (例外にはメッセージ、スタックトレースなどを含めることができます)。

6.5.1.2 NULL 値を返す

ほとんどのプログラミング言語では、NULL は「存在しない」というセマンティクスを表すために使用されます。ただし、インターネット上の多くの人々は、関数が NULL 値を返すことを推奨していません. 彼らは、これは悪い設計思想だと考えています. 主な理由は 2 つあります:

  1. 関数がNULL値を返す可能性がある場合、それを使用する際にNULL値の判定を忘れると、Null Pointer Exception(Null Pointer Exception、略してNPE)がスローされることがあります
  2. 戻り値が NULL になる可能性のある関数をたくさん定義すると、NULL 値の判定ロジックが大量に記述されてしまい、書くのが面倒になる一方で、通常のビジネスロジックと結合されてしまいます。 、コードに影響します。

例えば:

public class UserService {
    
    
    private UserRepo userRepo; // 依赖注入
    public User getUser(String telephone) {
    
    
        // 如果用户不存在,则返回null
        return null;
    }
}

// 使用函数getUser()
User user = userService.getUser("18917718965");
if (user != null) {
    
     // 做NULL值判断,否则有可能会报NPE
    String email = user.getEmail();
    if (email != null) {
    
     // 做NULL值判断,否则有可能会报NPE
        String escapedEmail = email.replaceAll("@", "#");
    }
}

NULL 値を例外に置き換えて、検索ユーザーが存在しない場合に関数が UserNotFoundException をスローするようにすることは可能ですか? 個人的にはNULL値を返すとデメリットが多いのですが、get、find、select、search、queryなどの単語で始まる検索機能については、データがないことは異常事態ではなく正常な動作です。したがって、例外を返すよりもセマンティクスがないことを示す NULL 値を返す方が合理的です。

しかし、そうは言っても、いま述べた理由は特に説得力のあるものではありません。検索データが存在しない場合、関数が NULL 値または例外を使用する必要があるかどうかにかかわらず、重要な参照基準は、プロジェクト全体が統一された規則に従っている限り、プロジェクト内の他の同様の検索関数がどのように定義されているかを確認することです。同意です。プロジェクトがゼロから開発され、参照できる統一された規則とコードがない場合は、2 つのいずれかを選択できます。データが存在しない場合に何が返されるかを呼び出し元が明確に知ることができるように、関数が定義されている場所だけを明確にコメントする必要があります。

もう 1 つポイントを追加します。検索関数については、データ オブジェクトを返すだけでなく、 indexOf()Java の関数のように、文字列内の別の部分文字列を初めて検索するために使用される、添え字現れる。関数の戻り値の型は基本型 int です。現時点では、存在しない状況を表すために NULL 値を使用することはできません。この状況では、2 つの対処方法があります。1 つは NotFoundException を返す方法で、もう 1 つは -1 などの特別な値を返す方法です。ただし、明らかに-1の方が合理的であり、理由も同じで、「見つからない」ことは異常な動作ではなく正常であることを意味します

6.5.1.3 空のオブジェクトを返す

前述のように、NULL 値を返すことにはさまざまな欠点があります。この問題に対処するための古典的な戦略は、Null オブジェクト デザイン パターンを適用することです。ここでは、2 つの比較的単純で特別な空のオブジェクト、つまり、空の文字列と空のコレクションを示します。

関数によって返されるデータが文字列型またはコレクション型の場合、空の文字列または空のコレクションを使用して NULL 値を置き換え、それが存在しないことを示すことができます。このように、関数を使用する場合、NULL 値を判断する必要はありません。サンプルコードは次のとおりです。

// 使用空集合替代NULL
public class UserService {
    
    
    private UserRepo userRepo; // 依赖注入
    public List<User> getUsers(String telephonePrefix) {
    
    
        // 没有查找到数据
        return Collectiosn.emptyList();
    }
}

// getUsers使用示例
List<User> users = userService.getUsers("189");
for (User user : users) {
    
     //这里不需要做NULL值判断
    // ...
}

// 使用空字符串替代NULL
public String retrieveUppercaseLetters(String text) {
    
    
    // 如果text中没有大写字母,返回空字符串,而非NULL值
    return "";
}
// retrieveUppercaseLetters()使用举例
String uppercaseLetters = retrieveUppercaseLetters("wangzheng");
int length = uppercaseLetters.length(); // 不需要做NULL值判断
System.out.println("Contains " + length + " upper case letters.");

6.5.1.4 例外オブジェクトのスロー

多くの関数エラーの戻りデータ型については前述しましたが、最も一般的に使用される関数エラー処理の方法は、例外をスローすることです。例外は、関数呼び出しスタック情報など、より多くのエラー情報を運ぶことができます。また、例外により、通常のロジックと例外ロジックの処理を分離できるため、コードの可読性が向上します。

プログラミング言語が異なれば、例外の構文もわずかに異なります。C++ およびほとんどの動的言語 (Python、Ruby、JavaScript など) は、ランタイム例外 (Runtime Exception) という 1 つの例外タイプのみを定義します。Java と同様に、実行時例外に加えて、コンパイル時例外 (コンパイル例外) という別の例外タイプが定義されています。

For runtime exceptions, when writing code, you don't need to take a Initiative to try-catch. コンパイラがコードをコンパイルするとき、コードがランタイム例外を処理したかどうかはチェックされません。反対に、コンパイル時の例外については、コードを記述するときに、率先して try-catch または関数定義で宣言する必要があります。そうしないと、コンパイルでエラーが報告されます。したがって、実行時例外は未チェックの例外 (Unchecked Exception) とも呼ばれ、コンパイル時例外はチェック済みの例外 (Checked Exception) とも呼ばれます。

使い慣れたプログラミング言語で例外タイプが 1 つだけ定義されている場合は、比較的簡単に使用できます。使い慣れたプログラミング言語 (Java など) で 2 つの例外タイプが定義されている場合、例外が発生したときにスローする例外タイプを選択する必要があります。それはチェックされた例外ですか、それともチェックされていない例外ですか?

コードのバグ (配列の範囲外など) や回復不能な例外 (データベースの接続障害など) は、キャッチされても大したことはできないため、未チェックの例外を使用する傾向があります。回収可能な例外およびビジネス例外 (引き出し金額が残高よりも多い例外など) については、チェック済み例外を使用する傾向があり、それらをキャプチャして処理する必要があることを呼び出し元に明確に通知します。

たとえば、Redis アドレス (パラメーター アドレス) が設定されていない場合は、デフォルトのアドレス (ローカル アドレスやデフォルト ポートなど) を使用します; Redis アドレスの形式が正しくない場合は、プログラムがフェイル ファーストになることを願っています。と言って、この状況を回復不能な例外として扱い、実行時例外を直接スローして、プログラムを終了します

// address格式:"192.131.2.33:7896"
public void parseRedisAddress(String address) {
    
    
    this.host = RedisConfig.DEFAULT_HOST;
    this.port = RedisConfig.DEFAULT_PORT;
    if (StringUtils.isBlank(address)) {
    
    
        return;
    }
    String[] ipAndPort = address.split(":");
    if (ipAndPort.length != 2) {
    
    
        throw new RuntimeException("...");
    }
    this.host = ipAndPort[0];
    // parseInt()解析失败会抛出 NumberFormatException 运行时异常
    this.port = Integer.parseInt(ipAndPort[1]);
}

実際、Java でサポートされているチェック済み例外は批判されており、すべての異常な状況では非チェック例外を使用する必要があると多くの人が主張しています。この見解を支持する主な理由は 3 つあります。

  1. チェック例外は、関数定義で明示的に宣言する必要があります。関数が多くのチェック例外をスローする場合、関数の定義は非常に冗長になり、コードの可読性に影響を与え、使用するのが不便になります。
  2. コンパイラは、すべてのチェック済み例外を明示的にキャッチする必要があるため、コードの実装が煩雑になります。非チェック例外はその逆で、定義で宣言する必要はなく、キャッチする必要があるかどうかは自由に決めることができます。
  3. チェックされた例外の使用は、オープンクローズの原則に違反しています。関数にチェック済み例外を追加する場合、この関数が配置されている関数呼び出しチェーン内のその上にあるすべての関数は、呼び出しチェーン内の関数が新しく追加された例外を削除するまで、対応するコード変更を行う必要があります。try-catch が破棄されるまでの。非チェック例外を追加しても、呼び出しチェーンのコードを変更する必要はありません。SpringのAOPアスペクトで例外処理を集中するなど、特定の機能で処理を集中することを柔軟に選択できます

ただし、非チェック例外には欠点もあり、その利点は実際には欠点です。今の発言から、非チェック例外はより柔軟に使用できることがわかり、それらをどのように処理するかはプログラマーに委ねられています。前述のように、柔軟性が高すぎると制御不能になります. チェックされていない例外は、関数定義で明示的に宣言する必要はありません. 関数を使用する場合は、コードをチェックして、どの例外がスローされるかを知る必要があります. 未チェックの例外はキャッチして処理する必要がないため、プログラマーはキャッチして処理する必要があるいくつかの例外を見逃す可能性があります

チェックされた例外とチェックされていない例外のどちらを使用するかについてオンラインで多くの議論がありますが、一方が他方より優れているという強い理由はありません。したがって、チームの開発習慣に応じて、同じプロジェクト内で統一された例外処理仕様を策定するだけで済みます。

2 種類の例外について説明した後、関数によってスローされた例外を処理する方法について説明しましょう。要約すると、一般的に次の 3 つのアプローチがあります。

1. 直接飲み込む

public void func1() throws Exception1 {
    
    
    // ...
}
public void func2() {
    
    
    //...
    try {
    
    
        func1();
} catch(Exception1 e) {
    
    
    log.warn("...", e); //吐掉:try-catch打印日志
}
    //...
}

2.そのまま再投げ

public void func1() throws Exception1 {
    
    
    // ...
}
public void func2() throws Exception2 {
    
    
    //...
    try {
    
    
        func1();
    } catch(Exception1 e) {
    
    
        throw new Exception2("...", e); // wrap成新的Exception2然后re-throw
}
    //...
}

3. 新しい例外再スローへのパッケージ化

public void func1() throws Exception1 {
    
    
    // ...
}
public void func2() throws Exception2 {
    
    
    //...
    try {
    
    
        func1();
    } catch(Exception1 e) {
    
    
        throw new Exception2("...", e); // wrap成新的Exception2然后re-throw
    }
    //...
}

当面对函数抛出异常的时候,应该选择上面的哪种处理方式呢?这里总结了下面三个参考原则:

  1. 如果 func1() 抛出的异常是可以恢复,且 func2() 的调用方并不关心此异常,完全可以在 func2() 内将 func1() 抛出的异常吞掉
  2. 如果 func1() 抛出的异常对 func2() 的调用方来说,也是可以理解的、关心的 ,并且在业务概念上有一定的相关性,可以选择直接将 func1 抛出的异常 re-throw
  3. 如果 func1() 抛出的异常太底层,对 func2() 的调用方来说,缺乏背景去理解、且业务概念上无关,可以将它重新包装成调用方可以理解的新异常,然后 re-throw

总之,是否往上继续抛出,要看上层代码是否关心这个异常。关心就将它抛出,否则就直接吞掉。是否需要包装成新的异常抛出,看上层代码是否能理解这个异常、是否业务相关。如果能理解、业务相关就可以直接抛出,否则就封装成新的异常抛出

6.5.2 重构 ID 生成器项目中各函数的异常处理代码

平时进行软件设计开发的时候,除了要保证正常情况下的逻辑运行正确之外,还需要编写大量额外的代码,来处理有可能出现的异常情况,以保证代码在任何情况下,都在我们的掌控之内,不会出现非预期的运行结果。程序的 bug 往往都出现在一些边界条件和异常情况下,所以说,异常处理得好坏直接影响了代码的健壮性。全面、合理地处理各种异常能有效减少代码 bug,也是保证代码质量的一个重要手段

1、重构 generate() 函数

首先来看,对于 generate() 函数,如果本机名获取失败,函数返回什么?这样的返回值是否合理?

public String generate() {
    
    
    String substrOfHostName = getLastFiledOfHostName();
    long currentTimeMillis = System.currentTimeMillis();
    String randomString = generateRandomAlphameric(8);
    String id = String.format("%s-%d-%s",
        substrOfHostName, currentTimeMillis, randomString);
    return id;
}

ID は、ローカル名、タイムスタンプ、乱数の 3 つの部分で構成されます。タイムスタンプと乱数の生成関数は間違いませんが、ホスト名の取得に失敗する場合があります。現在のコード実装では、ホスト名の取得に失敗し、substrOfHostName が NULL の場合、generate()関数は「null-16723733647-83Ab3uK6」のようなデータを返します。ホスト名の取得に失敗し、substrOfHostName が空の文字列の場合、generate()関数は"-16723733647-83Ab3uK6" のようなデータを返します。

異常な状況で上記の 2 つの特別な ID データ形式を返すことは合理的ですか? これは、特定のビジネスがどのように設計されているかによって異なります。ただし、呼び出し元に例外を明示的に通知することをお勧めします。したがって、ここで特別な値をスローするのではなく、チェック例外をスローすることをお勧めします。

この設計思想に従って、generate()関数を。リファクタリング後のコードは次のようになります。

public String generate() throws IdGenerationFailureException {
    
    
    String substrOfHostName = getLastFiledOfHostName();
    if (substrOfHostName == null || substrOfHostName.isEmpty()) {
    
    
        throw new IdGenerationFailureException("host name is empty.");
    }
    long currentTimeMillis = System.currentTimeMillis();
    String randomString = generateRandomAlphameric(8);
    String id = String.format("%s-%d-%s",
    substrOfHostName, currentTimeMillis, randomString);
    return id;
}

2. リファクタリングgetLastFiledOfHostName()機能

getLastFiledOfHostName()functionの場合、UnknownHostException を関数内 (try-catch およびログ) に飲み込む必要がありますか、それとも例外をスローし続ける必要がありますか? 上向きにスローされる場合、UnknownHostException をそのままスローするか、新しい例外にカプセル化する必要がありますか?

private String getLastFiledOfHostName() {
    
    
    String substrOfHostName = null;
    try {
    
    
        String hostName = InetAddress.getLocalHost().getHostName();
        substrOfHostName = getLastSubstrSplittedByDot(hostName);
    } catch (UnknownHostException e) {
    
    
        logger.warn("Failed to get the host name.", e);
    }
    return substrOfHostName;
}

現在の処理方法は、ホスト名の取得に失敗した場合、getLastFiledOfHostName()関数は NULL 値を返します。前述のように、NULL 値を返すか異常なオブジェクトを返すかは、データ取得の失敗が正常な動作であるか異常な動作であるかによって異なります。ホスト名の取得に失敗すると、後続のロジックの処理に影響を与えることになるため、予期しない動作になるため、異常な動作です。NULL 値を返すのではなく、ここで例外をスローすることをお勧めします。

至于是直接将 UnknownHostException 抛出,还是重新封装成新的异常抛出,要看函数跟异常是否有业务相关性。getLastFiledOfHostName() 函数用来获取主机名的最后一个字段,UnknownHostException 异常表示主机名获取失败,两者算是业务相关,所以可以直接将 UnknownHostException 抛出,不需要重新包裹成新的异常

按照上面的设计思路,对 getLastFiledOfHostName() 函数进行重构。重构后的代码如下所示:

private String getLastFiledOfHostName() throws UnknownHostException{
    
    
    String substrOfHostName = null;
    String hostName = InetAddress.getLocalHost().getHostName();
    substrOfHostName = getLastSubstrSplittedByDot(hostName);

    return substrOfHostName;
}

getLastFiledOfHostName() 函数修改之后,generate() 函数也要做相应的修改。需要在 generate() 函数中,捕获 getLastFiledOfHostName() 抛出的 UnknownHostException 异常。当捕获到这个异常之后,应该怎么处理呢?

按照之前的分析,ID 生成失败的时候,需要明确地告知调用者。所以,不能在 generate() 函数中,将 UnknownHostException 这个异常吞掉。那应该原封不动地抛出,还是封装成新的异常抛出呢?

这里选择后者。在 generate() 函数中,需要捕获 UnknownHostException 异常,并重新包裹成新的异常 IdGenerationFailureException 往上抛出。之所以这么做,有下面三个原因:

  1. 调用者在使用 generate() 函数的时候,只需要知道它生成的是随机唯一 ID,并不关心 ID 是如何生成的。也就说是,这是依赖抽象而非实现编程。如果 generate() 函数直接抛出 UnknownHostException 异常,实际上是暴露了实现细节
  2. コードのカプセル化の観点から、比較的低レベルの例外である UnknownHostException を上位レベルのコード、つまりgenerate()関数 さらに、呼び出し元が例外を受け取ると、その例外が何を表しているのか理解できず、その処理方法もわかりません。
  3. UnknownHostException の例外は、ビジネス コンセプト上、generate()機能

上記の設計思想に従って、generate()のリファクタリングされます。リファクタリングされたコードは次のようになります。

public String generate() throws IdGenerationFailureException {
    
    
    String substrOfHostName = null;
    try {
    
    
        substrOfHostName = getLastFiledOfHostName();
    } catch (UnknownHostException e) {
    
    
        throw new IdGenerationFailureException("host name is empty.");
    }

    long currentTimeMillis = System.currentTimeMillis();
    String randomString = generateRandomAlphameric(8);
    String id = String.format("%s-%d-%s",
    substrOfHostName, currentTimeMillis, randomString);

    return id;
}

3. リファクタリングgetLastSubstrSplittedByDot()機能

getLastSubstrSplittedByDot(String hostName)関数の場合、hostName が NULL または空の文字列の場合、関数は何を返す必要がありますか?

@VisibleForTesting
protected String getLastSubstrSplittedByDot(String hostName) {
    
    
    String[] tokens = hostName.split("\\.");
    String substrOfHostName = tokens[tokens.length - 1];
    return substrOfHostName;
}

理論的に言えば、パラメーターの受け渡しの正確性はプログラマーによって保証されるべきであり、NULL 値または空の文字列の判断と特別な処理を行う必要はありません。呼び出し元は、NULL 値または空の文字列をgetLastSubstrSplittedByDot()関数。合格した場合、それはコードのバグであり、修正する必要があります。しかし、そうは言っても、プログラマーが NULL 値または空の文字列を渡さないという保証はありません。では、NULL 値か空の文字列かを判断する必要がありますか?

関数がクラスに対してプライベートであり、クラス内でのみ呼び出される場合、それは完全に独自の制御下にあり、このプライベート関数を呼び出すときに NULL 値または空の文字列を渡さないようにすることができます。したがって、private 関数で NULL 値または空文字列を判断する必要はありません。関数が公開されている場合、誰がどのように呼び出すかを制御することはできません (同僚が怠慢で NULL 値を渡す可能性がありますが、これも存在します)。なるべくコードを控えた方が良い public関数でのNULL値か空文字列の判定です

这里可能会说,getLastSubstrSplittedByDot() 是 protected 的,既不是 private 函数,也不是 public 函数,那要不要做 NULL 值或空字符串的判断呢?

之所以将它设置为 protected,是为了方便写单元测试。不过,单元测试可能要测试一些 corner case,比如输入是 NULL 值或者空字符串的情况。所以,这里最好也加上 NULL 值或空字符串的判断逻辑。虽然加上有些冗余,但多加些检验总归不会错的

按照这个设计思路,我们对 getLastSubstrSplittedByDot() 函数进行重构。重构之后的代码如下所示:

@VisibleForTesting
protected String getLastSubstrSplittedByDot(String hostName) {
    
    
    if (hostName == null || hostName.isEmpty()) {
    
    
        throw IllegalArgumentException("..."); //运行时异常
    }
    String[] tokens = hostName.split("\\.");
    String substrOfHostName = tokens[tokens.length - 1];
    return substrOfHostName;
}

按照上面讲的,在使用这个函数的时候,自己也要保证不传递 NULL 值或者空字符串进去。所以,getLastFiledOfHostName() 函数的代码也要作相应的修改。修改之后的代码如下所示:

private String getLastFiledOfHostName() throws UnknownHostException{
    
    
    String substrOfHostName = null;
    String hostName = InetAddress.getLocalHost().getHostName();
    if (hostName == null || hostName.isEmpty()) {
    
     // 此处做判断
        throw new UnknownHostException("...");
    }
    substrOfHostName = getLastSubstrSplittedByDot(hostName);
    return substrOfHostName;
}

4、重构 generateRandomAlphameric() 函数

对于 generateRandomAlphameric(int length) 函数,如果 length < 0 或 length = 0,这个函数应该返回什么?

@VisibleForTesting
protected String generateRandomAlphameric(int length) {
    
    
    char[] randomChars = new char[length];
    int count = 0;
    Random random = new Random();

    while (count < length) {
    
    
        int maxAscii = 'z';
        int randomAscii = random.nextInt(maxAscii);
        boolean isDigit= randomAscii >= '0' && randomAscii <= '9';
        boolean isUppercase= randomAscii >= 'A' && randomAscii <= 'Z';
        boolean isLowercase= randomAscii >= 'a' && randomAscii <= 'z';

        if (isDigit|| isUppercase || isLowercase) {
    
    
            randomChars[count] = (char) (randomAscii);
            ++count;
        }
    }
    return new String(randomChars);
}

先来看 length < 0 的情况。生成一个长度为负值的随机字符串是不符合常规逻辑的,是一种异常行为。所以,当传入的参数 length < 0 的时候,抛出 IllegalArgumentException 异常

再来看 length = 0 的情况。length = 0 是否是异常行为呢?这就看怎么定义了。既可以把它定义为一种异常行为,抛出 IllegalArgumentException 异常,也可以把它定义为一种正常行为,让函数在入参 length = 0 的情况下,直接返回空字符串。不管选择哪种处理方式,最关键的一点是,要在函数注释中,明确告知 length = 0 的情况下,会返回什么样的数据

重构之后的 RandomIdGenerator 代码

public class RandomIdGenerator implements IdGenerator {
    
    
    private static final Logger logger = LoggerFactory.getLogger(RandomIdGenerator.class);

    @Override
    public String generate() throws IdGenerationFailureException {
    
    
        String substrOfHostName = null;
        try {
    
    
            substrOfHostName = getLastFiledOfHostName();
        } catch (UnknownHostException e) {
    
    
            throw new IdGenerationFailureException("...", e);
        }
        long currentTimeMillis = System.currentTimeMillis();
        String randomString = generateRandomAlphameric(8);
        String id = String.format("%s-%d-%s",
                substrOfHostName, currentTimeMillis, randomString);
        return id;
    }
    private String getLastFiledOfHostName() throws UnknownHostException{
    
    
        String substrOfHostName = null;
        String hostName = InetAddress.getLocalHost().getHostName();
        if (hostName == null || hostName.isEmpty()) {
    
    
            throw new UnknownHostException("...");
        }
        substrOfHostName = getLastSubstrSplittedByDot(hostName);
        return substrOfHostName;
    }
    @VisibleForTesting
    protected String getLastSubstrSplittedByDot(String hostName) {
    
    
        if (hostName == null || hostName.isEmpty()) {
    
    
            throw new IllegalArgumentException("...");
        }
        String[] tokens = hostName.split("\\.");
        String substrOfHostName = tokens[tokens.length - 1];
        return substrOfHostName;
    }
    @VisibleForTesting
    protected String generateRandomAlphameric(int length) {
    
    
        if (length <= 0) {
    
    
            throw new IllegalArgumentException("...");
        }
        char[] randomChars = new char[length];
        int count = 0;
        Random random = new Random();
        while (count < length) {
    
    
            int maxAscii = 'z';
            int randomAscii = random.nextInt(maxAscii);
            boolean isDigit= randomAscii >= '0' && randomAscii <= '9';
            boolean isUppercase= randomAscii >= 'A' && randomAscii <= 'Z';
            boolean isLowercase= randomAscii >= 'a' && randomAscii <= 'z';
            if (isDigit|| isUppercase || isLowercase) {
    
    
                randomChars[count] = (char) (randomAscii);
                ++count;
            }
        }
        return new String(randomChars);
    }
}

7. 总结

包括前两篇:

ここに画像の説明を挿入

7.1 代码质量评判标准

7.1.1 如何评价代码质量的高低?

代码质量的评价有很强的主观性,描述代码质量的词汇也有很多,比如可读性、可维护性、灵活、优雅、简洁。这些词汇是从不同的维度去评价代码质量的。它们之间有互相作用,并不是独立的,比如,代码的可读性好、可扩展性好就意味着代码的可维护性好。代码质量高低是一个综合各种因素得到的结论。我们并不能通过单一维度去评价一段代码的好坏

7.1.2 最常用的评价标准有哪几个?

最常用到几个评判代码质量的标准有:可维护性、可读性、可扩展性、灵活性、简洁性、可复用性、可测试性。其中,可维护性、可读性、可扩展性又是提到最多的、最重要的三个评价标准

7.1.3 如何才能写出高质量的代码?

要写出高质量代码,就需要掌握一些更加细化、更加能落地的编程方法论,这就包含面向对象设计思想、设计原则、设计模式、编码规范、重构技巧等

ここに画像の説明を挿入

7.2 面向对象

7.2.1 面向对象概述

现在,主流的编程范式或者编程风格有三种,它们分别是面向过程、面向对象和函数式编程。面向对象这种编程风格又是这其中最主流的。现在比较流行的编程语言大部分都是面向对象编程语言。大部分项目也都是基于面向对象编程风格开发的。面向对象编程因为其具有丰富的特性(封装、抽象、继承、多态),可以实现很多复杂的设计思路,是很多设计原则、设计模式编码实现的基础

7.2.2 面向对象四大特性

封装也叫作信息隐藏或者数据访问保护。类通过暴露有限的访问接口,授权外部仅能通过类提供的方法来访问内部信息或者数据。它需要编程语言提供权限访问控制语法来支持,例如 Java 中的 private、protected、public 关键字。封装特性存在的意义,一方面是保护数据不被随意修改,提高代码的可维护性;另一方面是仅暴露有限的必要接口,提高类的易用性

カプセル化が主に情報を隠してデータを保護する方法に関するものである場合、抽象化はメソッドの特定の実装を隠す方法に関するものであるため、ユーザーはメソッドが提供する機能だけを気にする必要があり、これらの機能がどのように機能するかを知る必要はありません。実装されています。抽象化は、インターフェイス クラスまたは抽象クラスを通じて実装できます。抽象化の存在の重要性は、実装を変更しても定義を変更する必要がない一方で、複雑なシステムを処理する効果的な手段でもあり、不要な注意情報を効果的に除外できます。

継承は、クラス間の is-a 関係を表すために使用され、単一継承と多重継承の 2 つのモードに分けることができます。単一継承とは、サブクラスが 1 つの親クラスからのみ継承することを意味し、多重継承とは、サブクラスが複数の親クラスから継承できることを意味します。この継承の機能を実現するために、プログラミング言語はそれをサポートする特別な構文メカニズムを提供する必要があります。継承は主にコードの再利用の問題を解決するために使用されます

ポリモーフィズムとは、サブクラスが親クラスを置き換えることができることを意味し、実際のコード実行プロセスでは、サブクラスのメソッド実装が呼び出されます。ポリモーフィズムの機能には、継承、インターフェイス クラス、ダックタイピングなどの特別な文法メカニズムを提供するプログラミング言語も必要です。ポリモーフィズムは、コードのスケーラビリティと再利用性を向上させることができ、多くの設計パターン、設計原則、およびプログラミング スキルのコード実装の基礎となります。

7.2.3 オブジェクト指向と手続き指向

プロシージャー指向プログラミングに対するオブジェクト指向プログラミングの主な利点は次の 3 つです。

  1. 大規模で複雑なプログラムを開発する場合、プログラムの処理の流れは一本の幹線ではなく複雑なネットワーク構造になります。このような複雑なタイプのプログラム開発には、手続き型プログラミングよりもオブジェクト指向プログラミングの方が適しています。
  2. プロセス指向プログラミングと比較して、オブジェクト指向プログラミングにはより豊富な機能 (カプセル化、抽象化、継承、ポリモーフィズム) があります。これらの機能を使用して記述されたコードは、拡張、再利用、保守が容易です
  3. プログラミング言語が機械と相互作用する方法の進化法則から、オブジェクト指向プログラミング言語はプロセス指向プログラミング言語よりも人間的で、より高度で、よりインテリジェントであると結論付けることができます

オブジェクト指向プログラミングは、一般にオブジェクト指向プログラミング言語を使用して実行されますが、オブジェクト指向プログラミング言語がなくてもオブジェクト指向プログラミングを実行できます。逆に言えば、オブジェクト指向プログラミング言語を使ったとしても、書かれたコードは必ずしもオブジェクト指向プログラミング スタイルであるとは限らず、プロセス指向プログラミング スタイルで書かれている場合もあります。

オブジェクト指向とプロセス指向の 2 つのプログラミング スタイルは、白黒ではなく、正反対です。オブジェクト指向プログラミング言語で開発されたソフトウェアでは、手続き型のコードは珍しくなく、一部の標準的な開発ライブラリ (JDK、Apache Commons、Google Guava など) でも、手続き型のコードが多数存在します。

プロセス指向またはオブジェクト指向のどちらのスタイルでコードを記述しても、最終的な目標は、保守しやすく、読みやすく、再利用しやすく、拡張しやすい高品質のコードを記述することです。プロセス指向プログラミング スタイルのいくつかの欠点を回避し、その副作用を制御し、制御の範囲内で使用できる限り、プロセス指向スタイルのコードをオブジェクト指向で書くことをためらう必要はありません。プログラミング。

7.2.4 オブジェクト指向の分析、設計、およびプログラミング

オブジェクト指向分析 (OOA)、オブジェクト指向設計 (OOD)、およびオブジェクト指向プログラミング (OOP) は、オブジェクト指向開発の 3 つの主要なリンクです。簡単に言えば、オブジェクト指向分析は何をすべきかを理解することであり、オブジェクト指向設計はそれを行う方法を理解することであり、オブジェクト指向プログラミングは分析と設計の結果をコードに変換するプロセスです。

要件分析のプロセスは、実際には継続的な反復最適化のプロセスです。すぐに完璧な解を与えようとするのではなく、大まかな基本的な解を最初に与え、反復ベースを用意してから、ゆっくりと最適化してください。そのような思考プロセスは、どこから始めればよいかわからないというジレンマから私たちを解放してくれます

オブジェクト指向の設計と実装で行うべきことは、適切なコードを適切なクラスに配置することです。どの分割方法を採用するかは、「疎結合・高結束」「単一責任」「拡張可・改変不可」など、様々な設計原則や考え方をコードが満たすかどうかが判断基準となります。可能な限り再利用可能なコード 使いやすく、読みやすく、拡張しやすく、保守しやすい

オブジェクト指向分析の出力は、詳細な要件記述です。オブジェクト指向設計のアウトプットはクラスです。オブジェクト指向設計のリンクでは、要件記述が特定のクラスの設計に変換されます。このリンクの作業は、次の 4 つの部分に分けることができます。

  1. 役割を分担し、どのクラスにあるかを特定する
    要件記述に従って、それに関与する機能点を1つずつ列挙し、どの機能点が同様の役割を持ち、同じ属性を操作するかを確認し、それらが同じクラスに分類できるかどうかを確認します
  2. クラスとその属性およびメソッドを定義する 要件
    の説明に含まれる動詞をメソッドの候補として特定し、実際のメソッドをさらに除外し、ファンクション ポイントに含まれる名詞を属性の候補として使用し、再度フィルタリングして選別します
  3. クラスとクラス間の相互作用を定義する
    UML 統一モデリング言語は、6 つのクラス間の関係を定義します。それらは、一般化、実装、関連付け、集約、構成、および依存関係です。よりプログラミングに近づけるという観点から、クラス間の関係を調整し、一般化、実装、構成、および依存関係の 4 つの関係を維持しました。
  4. クラスをアセンブルして実行エントリを提供する
    すべてのクラスをまとめてアセンブルし、実行エントリを提供します。このエントリは、main()関数。このエントリを介して、コード全体をトリガーして実行できます

7.2.5 インターフェースと抽象クラス

抽象クラスはインスタンス化できず、継承されるだけです。プロパティとメソッドを含めることができます。メソッドには、コード実装が含まれる場合と含まれない場合があります。コード実装を含まないメソッドは、抽象メソッドと呼ばれます。サブクラスは抽象クラスを継承し、抽象クラスのすべての抽象メソッドを実装する必要があります

インターフェイスにはプロパティを含めることはできません (Java は静的定数を定義できます)。宣言できるのはメソッドのみであり、メソッドにコード実装を含めることはできません (Java8 以降ではデフォルトの実装を持つことができます)。クラスがインターフェイスを実装する場合、インターフェイスで宣言されたすべてのメソッドを実装する必要があります

抽象クラスは、メンバー変数とメソッドを抽象化したものであり、is-a の関係であり、コードの再利用の問題を解決するためのものです。インターフェイスはメソッドの単なる抽象化であり、has-a 関係であり、特定の動作特性セットを持っていることを意味し、デカップリングの問題を解決し、インターフェイスと特定の実装を分離し、スケーラビリティを向上させます。コードの。

どのような場合に抽象クラスを使用する必要がありますか? インターフェイスはいつ使用する必要がありますか? 実は、審査基準はとてもシンプル。is-a 関係を表現し、コードの再利用の問題を解決したい場合は、抽象クラスを使用します; has-a 関係を表現し、コードの再利用ではなく抽象化の問題を解決したい場合は、抽象クラスを使用しますインターフェース

7.2.6 実装ベースではなくインターフェースベースのプログラミング

この原則を適用すると、インターフェイスを実装から分離し、不安定な実装をカプセル化し、安定したインターフェイスを公開できます。上流システムは実装プログラミングではなくインターフェース指向であり、不安定な実装の詳細に依存しないため、実装が変更された場合、上流システムのコードは基本的に変更する必要がなく、結合を減らし、スケーラビリティを向上させます

実際、「実装ではなくインターフェースへのプログラミング」の原則の別の言い方は、「実装ではなく抽象化へのプログラミング」です。後者の表現方法は、実際には、この原則の元の設計意図をよりよく反映しています。ソフトウェア開発における最大の課題の 1 つは、要件が絶え間なく変化することです。これは、コード設計の品質をテストする基準でもあります。

より抽象的で、よりトップレベルで、特定の実装設計から切り離されているほど、コードはより柔軟になり、将来の需要の変化によりよく対応できます。優れたコード設計は、現在のニーズを満たすだけでなく、将来ニーズが変化したときに元のコード設計を破壊することなく柔軟に対応できるものです。抽象化は、コードのスケーラビリティ、柔軟性、保守性を向上させる最も効果的な手段の 1 つです。

7.2.7 構成を増やし、継承を減らす

継承が推奨されないのはなぜですか?

継承は、オブジェクト指向の 4 つの主要な特徴の 1 つで、クラス間の is-a 関係を表すために使用され、コードの再利用の問題を解決できます。継承には多くの機能がありますが、継承レベルが深すぎて複雑すぎると、コードの保守性にも影響します。この場合、控えめに使用するか、継承なしで使用する必要があります

継承に対する構成の利点は何ですか?

継承には、is-a 関係の表現、ポリモーフィック機能のサポート、およびコードの再利用という 3 つの主な機能があります。そして、これらの 3 つの機能は、組み合わせ、インターフェイス、および委託の 3 つの技術的手段によって実現できます。さらに、深く複雑な継承関係がコードの保守性に影響を与えるという問題も、コンポジションを使用することで解決できます。

構成と継承のどちらを使用するかをどのように判断しますか?

より多くの構成を使用し、継承を少なくすることが推奨されていますが、構成は完全ではなく、継承は無用ではありません。実際のプロジェクト開発では、具体的な状況に応じて、継承を使用するか組み合わせを使用するかを選択する必要があります。クラス間の継承構造が安定していて、階層が比較的浅く、関係が複雑でない場合は、大胆に継承を使用できます。逆に、継承の代わりに構成を使用してみてください。さらに、継承または組み合わせを常に使用する設計パターンと特別なアプリケーション シナリオがいくつかあります。

7.2.8 貧血モデル VS うっ血モデル

Web プロジェクトのビジネス開発のほとんどは、伝統的な開発モデルと呼ばれる貧血モデルの MVC 3 層アーキテクチャーに基づいています。「トラディショナル」と呼ばれる理由は、輻輳モデルに基づく新たな DDD 開発モデルに関連しています。貧血モデルに基づく従来の開発モデルは、典型的なプロセス指向プログラミング スタイルです。逆に、輻輳モデルに基づく DDD 開発モデルは、典型的なオブジェクト指向プログラミング スタイルです。

ただし、DDD は特効薬ではありません。ビジネスが複雑でないシステム開発には、貧血モデルに基づく従来の開発モデルはシンプルで十分ですが、充血モデルに基づく DDD 開発モデルは少し過剰であり、役割を果たせません。逆に複雑な業務を伴うシステム開発の場合、充血モデルに基づくDDD開発モデルは、コードの再利用性や保守性を向上させるために初期段階の設計により多くの時間とエネルギーを投資する必要があるため、モデルベースの開発モデルに比べて貧血について 開発モデルにはより多くの利点があります

貧血モデルに基づく従来の開発モデルと比較すると、充血モデルに基づく DDD 開発モデルは、主にサービス層が異なります。充血モデルに基づく開発モードでは、Service クラスの元のビジネス ロジックの一部が充血 Domain ドメイン モデルに移動されるため、Service クラスの実装は Domain クラスに依存します。ただし、Service クラスは完全に削除されたわけではありませんが、Domain クラスに収まらないいくつかの機能を担当しています。たとえば、リポジトリ レイヤーの処理、クロスドメイン モデルのビジネス アグリゲーション機能、べき等トランザクション、およびその他の非機能的な作業を担当します。

貧血モデルに基づく従来の開発モデルと比較すると、コントローラー層とリポジトリー層のコードは基本的に同じです。これは、リポジトリ レイヤーのエンティティ ライフ サイクルが制限されており、コントローラー レイヤーの VO が単なる DTO であるためです。2 つの部分のビジネス ロジックはそれほど複雑ではありません。ビジネス ロジックは、主にサービス層に集中しています。したがって、Repository 層と Controller 層が貧血モデルの設計思想を引き続き使用することは問題ありません

ここに画像の説明を挿入

7.3 設計原則

7.3.1 SOLID 原則: SRP 単一責任原則

クラスは、1 つの責任または機能を完了することのみを担当します。単一責任の原則は、大規模で包括的なクラスを設計したり、無関係な機能を結合したりすることを回避することで、クラスの結束を向上させます。同時に、クラスの責任は単一であり、クラスが依存する他のクラスの数が減少し、コードの結合が減少し、コードの高い凝集性と疎結合が実現します. ただし、分割が細かすぎると、実際には逆効果になり、結束力が低下し、コードの保守性に影響します。

異なるアプリケーション シナリオ、異なる段階での需要背景、および異なるビジネス レベルでは、同じクラスの責任が単一であるかどうかの判断結果が異なる場合があります。実際、いくつかの副次的な判断指標は、より有益で実行可能です. たとえば、次のような状況は、このタイプの設計が単一責任の原則を満たしていないことを示している可能性があります:

  • クラス内のコード、関数、またはプロパティの行数が多すぎる
  • クラスが他のクラスに依存しすぎているか、クラスが他のクラスに依存しすぎている
  • プライベートメソッドが多すぎる
  • クラスに適切な名前を付けるのはより困難です
  • クラス内の多数のメソッドがクラス内の特定の属性に焦点を当てている

7.3.2 SOLID 原則: OCP 開閉原則

「拡張のためのオープン、変更のためのクローズ」をどのように理解するのですか?

新しい機能の追加は、既存のコードを変更する (モジュール、クラス、メソッド、属性などを変更する) のではなく、既存のコードに基づいてコードを拡張する (モジュール、クラス、メソッド、属性などを追加する) ことによって行う必要があります。 . 定義について注意すべき点が 2 つあります。1つ目のポイントは、オープンとクローズの原則は、変更を完全に排除することを意味するのではなく、最小限のコード変更を犠牲にして新しい機能の開発を完了することを意味するということです。2 番目のポイントは、同じコードの変更が、粗いコード粒度では「変更」として識別される可能性があり、細かいコード粒度では「拡張」として識別される可能性があるということです。

「拡張用に開き、変更用に閉じる」を実現するにはどうすればよいですか?

拡張、抽象化、カプセル化を常に意識しなければなりません。コードを作成するときは、このコードの要件が将来変更される可能性があること、コード構造を設計する方法、および拡張ポイントを事前に予約して、将来の要件が変更されたときにコードの全体的な構造が変更される可能性があることについて、より多くの時間を費やす必要があります。最小限のコード変更の場合、新しいコードを柔軟に拡張ポイントに挿入できます。

多くの設計原則、設計アイデア、および設計パターンは、コードのスケーラビリティを向上させることを目的としています。特に、23 の古典的なデザイン パターンのほとんどは、コードのスケーラビリティの問題を解決するためにまとめられており、すべてオープンとクローズの原則に基づいています。コードの拡張性を向上させるために最も一般的に使用される方法は、ポリモーフィズム、依存性注入、実装ではなくインターフェイスに基づくプログラミング、およびほとんどの設計パターン (装飾、戦略、テンプレート、責任の連鎖、状態など) です。

7.3.3 SOLID 原則: LSP Li スタイル置換原則

サブクラス オブジェクト (サブタイプのオブジェクト/派生クラスのオブジェクト) は、プログラム (プログラム) 内の親クラスのオブジェクト (基本/親クラスのオブジェクト) が表示される場所を置き換えることができ、元のプログラムの論理的な動作 (動作) が変更されないままであることを保証します。正しさは崩れない

Li スタイルの置換原則は、継承関係でサブクラスを設計する方法を導くために使用される原則です。李式交換原理を理解する核心は、「契約による設計、合意による設計」という言葉を理解することです。親クラスは関数の「契約」(またはプロトコル) を定義し、サブクラスは関数の内部実装ロジックを変更できますが、関数の元の「契約」を変更することはできません。ここでの「合意」には、関数宣言によって実装される関数、入力、出力、および例外に関する合意、コメントに記載されている特別な命令も含まれます。

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

7.3.4 SOLID の原則: ISP インターフェイス分離の原則

インターフェイス分離の原則の説明は次のとおりです。クライアントは、必要のないインターフェイスに依存することを余儀なくされるべきではありません。「クライアント」は、インターフェースの呼び出し元またはユーザーとして理解できます。「界面分離原理」を理解する鍵は、「界面」という言葉を理解することです。ここには 3 つの異なる解釈があります。

  1. 「インターフェース」を一連のインターフェースとして理解すれば、それはマイクロサービスのインターフェースまたはクラス ライブラリのインターフェースである可能性があります。一部のインターフェイスが一部の呼び出し元によってのみ使用されている場合は、インターフェイスのこの部分を分離し、使用されないインターフェイスのこの部分に他の呼び出し元が依存することを強制せずに、呼び出し元のこの部分だけに使用する必要があります。
  2. 「インターフェース」が単一の API インターフェースまたは関数として理解され、一部の呼び出し元が関数内の関数の一部のみを必要とする場合、呼び出し元が詳細のみに依存するように、関数をより細かい粒度で複数の関数に分割する必要があります。グラニュラ関数
  3. 「インターフェース」は、OOP におけるインターフェースとして理解される場合、オブジェクト指向プログラミング言語におけるインターフェース構文として理解することもできます。インターフェイスの設計はできるだけ単純にする必要があり、インターフェイスの実装クラスと呼び出し元は不要なインターフェイス関数に依存しないようにする必要があります。

单一职责原则针对的是模块、类、接口的设计。接口隔离原则相对于单一职责原则,一方面更侧重于接口的设计,另一方面它的思考的角度也是不同的。接口隔离原则提供了一种判断接口的职责是否单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一

7.3.5 SOLID 原则:DIP 依赖倒置原则

控制反转: 实际上,控制反转是一个比较笼统的设计思想,并不是一种具体的实现方法,一般用来指导框架层面的设计。这里所说的“控制”指的是对程序执行流程的控制,而“反转”指的是在没有使用框架之前,程序员自己控制整个程序的执行。在使用框架之后,整个程序的执行流程通过框架来控制。流程的控制权从程序员“反转”给了框架

依赖注入: 依赖注入和控制反转恰恰相反,它是一种具体的编码技巧。不通过 new 的方式在类内部创建依赖类的对象,而是将依赖的类对象在外部创建好之后,通过构造函数、函数参数等方式传递(或“注入”)给类来使用

依赖注入框架: 通过依赖注入框架提供的扩展点,简单配置一下所有需要的类及其类与类之间的依赖关系,就可以实现由框架来自动创建对象、管理对象的生命周期、依赖注入等原本需要程序员来做的事情

依赖反转原则: 依赖反转原则也叫作依赖倒置原则。这条原则跟控制反转有点类似,主要用来指导框架层面的设计。高层模块不依赖低层模块,它们共同依赖同一个抽象。抽象不需要依赖具体实现细节,具体实现细节依赖抽象

7.3.6 KISS、YAGNI 原则

KISS の原則に関する中国語の説明は次のとおりです。KISS の原則は、コードを読みやすく保守しやすくするための重要な手段です。KISS の原則における「シンプル」は、コードの行数では測定されません。コードの行数が少ないほどコードが単純になるわけではありませんが、論理的な複雑さ、実装の難しさ、コードの読みやすさなども考慮してください。さらに、複雑な問題を複雑な方法で解決することは、KISS の原則に違反しません。さらに、特定のビジネス シナリオで KISS 原則を満たす同じコードが、別のアプリケーション シナリオでは満たされない場合があります。

KISS の原則を満たすコードの書き方については、次の指針がまとめられています。

  • 同僚が理解できない可能性のある手法を使用してコードを実装しないでください
  • 一からやり直すのではなく、既存のツール ライブラリを上手に使用する
  • 最適化しすぎない

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

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

7.3.7 DRY 原則

DRY 原則の中国語の説明は次のとおりです。「自分自身を繰り返さないで、プログラミングに適用してください。次のように理解できます。繰り返しコードを記述しないでください。コードの繰り返しの 3 つの状況があります。実装ロジックの繰り返し、機能的な意味の繰り返し、コードです。」実行の繰り返し

  • 論理的な繰り返しを実現するが、機能的なセマンティクスを繰り返さないコードは、DRY 原則に違反しません。
  • ロジックを繰り返さず、機能的なセマンティクスを繰り返すコードも、DRY 原則に違反しています。
  • コード実行の複製も DRY 原則に違反しています

さらに、コードの再利用性を向上させるいくつかの方法についても言及されています。これには、コード結合の削減、単一責任の原則を満たす、モジュール化、ビジネス ロジックと非ビジネス ロジックの分離、一般的なコード シンキング、継承、ポリモーフィズム、抽象化、カプセル化、アプリケーションが含まれます。テンプレートやその他のデザイン パターン。再利用の意識も非常に重要です。各モジュール、クラス、関数を設計するときは、外部 API を設計するのと同じように再利用性を考慮してください。

在第一次写代码的时候,如果当下没有复用的需求,而未来的复用需求也不是特别明确,并且开发可复用代码的成本比较高,那就不需要考虑代码的复用性。在之后开发新的功能的时候,发现可以复用之前写的这段代码,那就重构这段代码,让其变得更加可复用

相比于代码的可复用性,DRY 原则适用性更强些。可以不写可复用的代码,但一定不能写重复的代码

7.3.8 LOD 原则

如何理解“高内聚、松耦合”?

“高内聚、松耦合”是一个非常重要的设计思想,能够有效提高代码的可读性和可维护性,缩小功能改动导致的代码改动范围。“高内聚”用来指导类本身的设计,“松耦合”用来指导类与类之间依赖关系的设计。所谓高内聚,就是指相近的功能应该放到同一个类中,不相近的功能不要放到同一类中。相近的功能往往会被同时修改,放到同一个类中,修改会比较集中。所谓“松耦合”指的是,在代码中,类与类之间的依赖关系简单清晰。即使两个类有依赖关系,一个类的代码改动也不会或者很少导致依赖类的代码改动

如何理解“迪米特法则”?

迪米特法则的描述为:不该有直接依赖关系的类之间,不要有依赖;有依赖关系的类之间,尽量只依赖必要的接口。迪米特法则是希望减少类之间的耦合,让类越独立越好。每个类都应该少了解系统的其他部分。一旦发生变化,需要了解这一变化的类就会比较少

ここに画像の説明を挿入

7.4 规范与重构

7.4.1 重构概述

重构的目的:为什么重构(why)?

对于项目来言,重构可以保持代码质量持续处于一个可控状态,不至于腐化到无可救药的地步。对于个人而言,重构非常锻炼一个人的代码能力,并且是一件非常有成就感的事情。它是我们学习的经典设计思想、原则、模式、编程规范等理论知识的练兵场

重构的对象:重构什么(what)?

リファクタリングの規模によって、リファクタリングは大規模な高レベルのリファクタリングと小規模な低レベルのリファクタリングに大別できます。大規模で高度なリファクタリングには、コードの階層化、モジュール化、デカップリング、クラス間の相互作用の整理、コンポーネントの抽象化と再利用などが含まれます。作業のこの部分では、より抽象的でトップレベルのデザインのアイデア、原則、およびパターンを使用します。小規模および低レベルのリファクタリングには、主にクラスおよび関数レベルでのリファクタリングのために、標準的な命名、注釈、多すぎる関数パラメーターの修正、超大規模クラスの排除、反復コードおよびその他のプログラミングの詳細の抽出が含まれます。小規模で低レベルのリファクタリングは、コーディング標準の理論的な知識を使用することに関するものです

リファクタリングのタイミング: いつリファクタリングするか (いつ)?

コードに大きな問題が発生するまで待ってから大幅にリファクタリングするのではなく、継続的なリファクタリングの認識を確立し、リファクタリングを開発の不可欠な部分として開発に統合してください。

リファクタリング方法: リファクタリングの方法 (どのように)?

大規模で高レベルのリファクタリングは比較的難しく、組織的かつ計画的な方法で実行し、段階的に小さなステップを踏んで、コードを常に実行可能な状態に保つ必要があります。そして、小規模で低レベルのリファクタリングは、影響範囲が小さいため、変更は短時間で済みます。そのため、意欲と時間さえあれば、いつでもどこでも行うことができます。

7.4.2 単体テスト

単体テストとは

単体テストは、「自己」によって記述されたコードの論理的な正確性をテストするために使用されるコード レベルのテストです。名前が示すように、単体テストは「単体」をテストすることです。これは通常、モジュールやシステムではなく、クラスまたは関数です。

単体テストを作成する理由

単体テストは、コードのバグやコード設計の問題を効果的に見つけることができます。単体テストを作成するプロセスは、それ自体がコード リファクタリングのプロセスです。単体テストは、統合テストを強力に補完するものであり、コードにすばやく慣れるのに役立ちます. これは、現場で実装できる TDD の妥協案です.

単体テストの書き方

単体テストの作成は、さまざまな入力、例外、およびコードの境界条件をカバーするテスト ケースを設計し、それらをコードに変換するプロセスです。一部のテスト フレームワークは、テスト コードの記述を簡素化するために利用できます。単体テストでは、次の正しい認識を確立する必要があります。

  1. 単元テストの作成は、退屈ではありますが、それほど時間はかかりません。
  2. 単体テストの品質要件をわずかに下げることができます
  3. 単体テストの品質を測定するための唯一の基準としてカバレッジを使用するのは不合理です
  4. 通常、単体テストを作成する場合、コードの実装ロジックを理解する必要はありません。
  5. 単体テスト フレームワークのテストの失敗は、ほとんどの場合、コードのテスト容易性が低いことが原因です。

単体テストの実装が難しいのはなぜですか?

一方面,写单元测试本身比较繁琐,技术挑战不大,很多程序员不愿意去写。另一方面,国内研发比较偏向“快糙猛”,容易因为开发进度紧,导致单元测试的执行虎头蛇尾,最后,没有建立对单元测试的正确认识,觉得可有可无,单靠督促很难执行得很好

7.4.3 代码的可测试性

什么是代码的可测试性?

粗略地讲,所谓代码的可测试性,就是针对代码编写单元测试的难易程度。对于一段代码,如果很难为其编写单元测试,或者单元测试写起来很费劲,需要依靠单元测试框架很高级的特性,那往往就意味着代码设计得不够合理,代码的可测试性不好

编写可测试性代码的最有效手段

依赖注入是编写可测试性代码的最有效手段。通过依赖注入,在编写单元测试代码的时候,可以通过 mock 的方法将不可控的依赖变得可控,这也是在编写单元测试的过程中最有技术挑战的地方。除了 mock 方式,还可以利用二次封装来解决某些代码行为不可控的情况

常见的 Anti-Patterns

典型的、常见的测试不友好的代码有下面这 5 种:

  1. 代码中包含未决行为逻辑
  2. 滥用可变全局变量
  3. 滥用静态方法
  4. 使用复杂的继承关系
  5. 高度耦合的代码

7.4.4 大型重构:解耦

“解耦”为何如此重要?

过于复杂的代码往往在可读性、可维护性上都不友好。解耦,保证代码松耦合、高内聚,是控制代码复杂度的有效手段。如果代码高内聚、松耦合,也就是意味着,代码结构清晰、分层、模块化合理、依赖关系简单、模块或类之间的耦合小,那代码整体的质量就不会差

代码是否需要“解耦”?

间接的衡量标准有很多,比如:改动一个模块或类的代码受影响的模块或类是否有很多、改动一个模块或者类的代码依赖的模块或者类是否需要改动、代码的可测试性是否好等等。直接的衡量标准是把模块与模块之间及其类与类之间的依赖关系画出来,根据依赖关系图的复杂性来判断是否需要解耦重构

如何给代码“解耦”?

コード分​​離の方法には、カプセル化と抽象化、中間層、モジュール化、およびその他の設計アイデアと原則が含まれます。たとえば、単一責任の原則、実装ではなくインターフェイスに基づくプログラミング、依存性注入、多目的の組み合わせ、および継承、ディ ミッターの法則。もちろん、オブザーバー パターンなど、いくつかのデザイン パターンがあります。

7.4.5 小さなリファクタリング: コーディング規約

命名と注意事項

  1. ネーミングのポイントは、その意味を正確に表現できるかどうかです。さまざまなスコープの名前付けには、さまざまな長さを適切に選択できます.一時変数などの小さなスコープでの名前付けには、短い名前付け方法を適切に選択できます. さらに、ネーミングにはおなじみの略語を使用することもできます。
  2. クラス情報を使用して属性と関数の命名を簡素化し、関数情報を使用して関数パラメータの命名を簡素化します。
  3. 名前は読みやすく、検索可能である必要があります。珍しい、発音しにくい英語の単語を名前に使用しないでください。さらに、ネーミングはプロジェクトの統一仕様に準拠する必要があり、直感に反するネーミングは使用しないでください。
  4. インターフェイスは、2 つの方法で名前を付けることができます。1 つはインターフェイスで「I」というプレフィックスが付けられ、もう 1 つはインターフェイスの実装クラスで「Impl」というサフィックスが付けられます。どちらの命名方法でもかまいませんが、キーはプロジェクト内で統一することです。抽象クラスの命名には、接頭辞「Abstract」が推奨されます
  5. コメントの目的は、コードを理解しやすくすることであり、この要件を満たす限り、記述できます。要約すると、コメントには主に 3 つの側面が含まれます。何を行うか、なぜ行うか、どのように行うかです。一部の複雑なクラスとインターフェースについては、「使用方法」を記述する必要がある場合もあります。
  6. アノテーション自体には一定のメンテナンス コストがかかるため、多ければ多いほどよいのです。クラスと関数はコメントを書く必要があり、可能な限り包括的かつ詳細に記述する必要がありますが、関数内のコメントは比較的少なくなります.一般的に、関数、説明変数、および要約コメントの適切な名前付けと抽出を使用して、コードを簡単に実現できます読む

プログラミングスキル

  1. 複雑なロジックを関数とクラスに抽出する
  2. パラメータを複数の関数に分割して、多すぎるパラメータを処理する
  3. あまりにも多くの引数をオブジェクトとしてカプセル化して処理する
  4. 関数内でパラメータを使用してコード実行ロジックを制御しないでください
  5. 深すぎるネスト レベルを削除します。メソッドには、冗長な if または else ステートメントを削除する、continue、break、return キーワードを使用してネストを早期に終了する、実行順序を調整してネストを減らす、ネスト ロジックの一部を関数に抽象化するなどがあります。
  6. マジック ナンバーをリテラル定数に置き換える
  7. 説明変数を使用して複雑な式を説明する

ここに画像の説明を挿入

おすすめ

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