從零開始學重構——重構的流程及基礎重構手法
重構的流程
重構手法
正如上一次所講的那樣,重構有兩個基本條件,一是要保持程式碼在重構前後的行為基本不變,二是整個過程是受控且儘可能少地產生錯誤。尤其是對於第二點,產生了一系列的重構手法,每種重構手法都是一系列簡單而機械的操作步驟,通過遵循這一系列的操作來實現程式碼的結構性調整。因此,重構的整個過程就是不斷運用不同重構手法的過程,是一個相對有章可循的流程。
重構手法有大有小,大的重構手法一般由若干小的基礎重構組成,進而聚沙成塔實現對程式碼結構大幅度的調整。完整的重構列表請參見《重構,改善既有程式碼的設計》一書。
例如,replace conditional with polymorphism這項複雜重構手法,就至少需要使用self encapsulate, extract method, move method, pull down method這四種基礎重構手法。因此在學習類級別的複雜重構手法前,需要先掌握行級別和方法級別的基礎重構手法。
重構步驟
重構的巨集觀步驟一般有如下兩種:自上而下式和自下而上式。
自上而下的重構在重構前,心中已經大致知道重構後的程式碼將會是什麼形態,然後至上而下地將步驟分解出來,並使用相應的重構步驟一一實現,最終達到重構後的形態。其流程為:
1. 識別程式碼中的壞味道
2. 運用設計原則,構思出修改後的目標狀態
3. 將目標狀態分解為一或多項重構步驟
4. 運用重構步驟
自下而上的重構則對重構後的程式碼沒有一個完整而清晰的認識。一般而言,每種重構手法都有助於我們解決某種型別的程式碼壞味,而自下而上的重構則針對每個發現的程式碼壞味直接運用對應的重構手法,直到沒有明顯的壞味,此時的程式碼即能自動滿足某種設計模式。是一種迭代的思路,也是所謂重構到模式
1. 識別程式碼中的壞味道
2. 運用一項或多項重構步驟,消除壞味
3. 重複1-2,直到沒有明顯壞味
在一般的情況下,這兩種重構流程並不是互斥的,經常交錯進行或互相包含。如先運用自上而下的方法識別出程式碼中的壞味,然後根據設計原則重構到某個實現,再運用自下而上的方法重新尋找新的壞味,迭代重構。
基礎重構手法
由於基礎重構手法比較多,而且相對比較簡單。因此先列出常用的基礎重構手法和簡單介紹,並在最後的實踐案例中結合基礎重構手法來重構程式碼。
rename(重新命名變數/方法/類)
- 壞味:含義不清的命名
- 說明:變數名應當體現出變數的作用和含義、方法名應當表現出方法的效果、類名也應提示類的職責和在繼承體系中的位置。
- 操作方法:IntelliJ Shift+F6
reorder(調整語句順序)
- 壞味:變數的申請和使用分離太遠
- 說明:變數的使用應當儘可能離使用近一些,否則會擴大變數的作用域,在重構時也會產生困難。
- 操作方法:IntelliJ Alt+Shift+↑↓ 針對無副作用的語句,直接調整語句位置。
split for/block(拆分for迴圈/程式碼塊)
- 壞味:一個迴圈或程式碼塊中同時操作了多個變數或執行了多個職責
- 說明:一個迴圈中若有太多變數要計算,不利於將此迴圈提取為單獨方法。
操作方法:
- 將迴圈複製一次
- 每個迴圈中只保留一個變數的計算
- 將迴圈提取為獨立方法
- 將所有迴圈的出現替換為方法的呼叫
guard clauses(衛語句)
- 壞味:過深的條件巢狀
- 說明:先判斷跳出/過濾的條件,並直接return或continue,可除去多餘的else巢狀深度。
操作方法:
- IntelliJ 在if語句上Alt+Enter,選擇invert if,可倒轉if和else語句
- IntelliJ 在else語句上Alt+Enter,選擇remove redundant else
extract variable(提取變數)
- 壞味:單條語句過長,含義不清
- 說明:將部分語句提取出變數,併為變數起一個能夠解釋變數含義的名稱來替代註釋
- 操作說明:IntelliJ Ctrl+Alt+V
extract method(提取方法)
- 壞味:單個方法過長,含義不清
- 說明:將做同一件事的程式碼提取出方法(一般為計算某個變數,或進行單個複雜操作),併為方法起一個能夠解釋”這件事”的名稱來替代註釋
- 操作說明:IntelliJ Ctrl+Alt+M,需要考慮返回和引數的列表,返回不能超過1個變數
inline method(內聯方法)
- 壞味:方法只有一行程式碼,且內容本身已經很明確(多行也可以,但若原方法有返回值,則會比較複雜,不推薦)
- 說明:方法的作用是聚合操作並提供註釋資訊,若方法內容已經明確,則方法本身就起不到作用,反而增加複雜度
- 操作說明:將方法內容複製後,替換方法呼叫的部分。再刪除方法本身
add parameter(方法增加引數)
- 壞味:方法主體只有部分變數不同
- 說明:可以提取變化的部分成為引數,從而合併兩個相似的方法
操作說明:
- 將變數的部分提取為變數,並提到方法的最開始處
- 將方法剩餘的內容提取為一個新的方法,新方法會含有新的引數
- 將原來的老方法內聯
案例實踐
重構前
private Set<String> channelColumns;
public String generateSql() {
String channelColumnClauseTemp = StringUtil.flat(channelColumns, ",", "", "");
String channelColumnClause;
Set<String> columns = new TreeSet<>();
for (String str : channelColumns) {
if (ChannelId.CLT_BUS_EML_ADR.toString().equals(str)) {
columns.add(ChannelId.CLT_EML_ADR.toString());
} else {
columns.add(str);
}
}
channelColumnClause = StringUtil.flat(columns, ",", "", "");
String channelColumnsReviewTemp = "";
String channelColumnsReview = "";
if (!channelColumns.isEmpty()) {
channelColumnsReviewTemp = channelColumnClauseTemp +
(channelColumns.contains(idTypeColumn) ? "" : ("," + idTypeColumn)) + ",batch_id";
channelColumnsReview = channelColumnClause +
(channelColumns.contains(idTypeColumn) ? "" : ("," + idTypeColumn)) + ",batch_id";
} else {
channelColumnsReviewTemp = idTypeColumn + ",batch_id";
channelColumnsReview = idTypeColumn + ",batch_id";
}
StringBuffer vsql = new StringBuffer();
vsql.append("insert into ").append(Constant.DB_SCHEMA).append(".").append(tableName)
.append(" (").append(channelColumnsReview).append(")")
.append(" select distinct ").append(channelColumnsReviewTemp.replace(Constant.CITYNAME_COLUMN, "isnull(" + Constant.CITYNAME_COLUMN + ",'未知城市') as " + Constant.CITYNAME_COLUMN))
.append(" from ").append(Constant.DB_SCHEMA).append(".").append(sourceTableName).append(";\n");
return reviewTempTableSql + vsql.toString();
}
程式碼中的壞味有:1. 過長的方法,超過了20行或一屏,2. 變數的命名含義不清,讀者無法理解channelColumnClauseTemp, channelColumnClause以及它們之間的關係,3. if-else中存在重複程式碼。
下來我們就來使用一系列基礎重構手法來整理這段程式碼。
調整變數申明位置
仔細觀察,發現channelColumnClauseTemp變數只在if語句中使用,因此將channelColumnClauseTemp變數的申請放到if中去:
if (!channelColumns.isEmpty()) {
String channelColumnClauseTemp = StringUtil.flat(channelColumns, ",", "", "");
channelColumnsReviewTemp = channelColumnClauseTemp +
(channelColumns.contains(idTypeColumn) ? "" : ("," + idTypeColumn)) + ",batch_id";
channelColumnsReview = channelColumnClause +
(channelColumns.contains(idTypeColumn) ? "" : ("," + idTypeColumn)) + ",batch_id";
}
同樣,channelColumnClause也只在if塊中使用,但其中還涉及了columns及for迴圈部分,也一併移動到if塊中:
if (!channelColumns.isEmpty()) {
String channelColumnClauseTemp = StringUtil.flat(channelColumns, ",", "", "");
channelColumnsReviewTemp = channelColumnClauseTemp +
(channelColumns.contains(idTypeColumn) ? "" : ("," + idTypeColumn)) + ",batch_id";
String channelColumnClause;
Set<String> columns = new TreeSet<>();
for (String str : channelColumns) {
if (ChannelId.CLT_BUS_EML_ADR.toString().equals(str)) {
columns.add(ChannelId.CLT_EML_ADR.toString());
} else {
columns.add(str);
}
}
channelColumnClause = StringUtil.flat(columns, ",", "", "");
channelColumnsReview = channelColumnClause +
(channelColumns.contains(idTypeColumn) ? "" : ("," + idTypeColumn)) + ",batch_id";
}
重新命名變數
仔細觀察channelColumnsReviewTemp和channelColumnsReview兩個變數的使用場景,發現它們是所拼接的sql語句的select xxx和insert (yyy)這兩個部分,因此實際上是源表的列名和目標表的列名。從而將channelColumnsReviewTemp命名為sourceColumnsStr,將channelColumnsReview命名為targetColumnsStr。
同樣,觀察channelColumnClauseTemp和channelColumnClause,它們分別用於計算channelColumnsReviewTemp和channelColumnsReview,因此對應的命名為sourceColumnsWithoutBatchId和targetColumnsWithoutBatchId:
String sourceColumnsStr = "";
String targetColumnsStr = "";
if (!channelColumns.isEmpty()) {
String sourceColumnsWithoutBatchId = StringUtil.flat(channelColumns, ",", "", "");
sourceColumnsStr = sourceColumnsWithoutBatchId +
(channelColumns.contains(idTypeColumn) ? "" : ("," + idTypeColumn)) + ",batch_id";
String targetColumnsWithoutBatchId;
Set<String> columns = new TreeSet<>();
for (String str : channelColumns) {
if (ChannelId.CLT_BUS_EML_ADR.toString().equals(str)) {
columns.add(ChannelId.CLT_EML_ADR.toString());
} else {
columns.add(str);
}
}
targetColumnsWithoutBatchId = StringUtil.flat(columns, ",", "", "");
targetColumnsStr = targetColumnsWithoutBatchId +
(channelColumns.contains(idTypeColumn) ? "" : ("," + idTypeColumn)) + ",batch_id";
} else {
sourceColumnsStr = idTypeColumn + ",batch_id";
targetColumnsStr = idTypeColumn + ",batch_id";
}
拆分程式碼塊
再觀察,發現if-else程式碼塊中同時操作了sourceColumnsStr和targetColumnsStr兩個變數,不利於後續運用提取方法的重構手法。因此需要運用split block手法,將這兩個變數的計算拆分到兩個程式碼塊中。先完整拷貝一份if-else程式碼,並在第一份中保留對sourceColumnsStr的計算,在第二份中保留對targetColumnsStr的計算,並且調整一下這兩個變數申明的位置,到if-else計算邏輯的前面:
String sourceColumnsStr = "";
if (!channelColumns.isEmpty()) {
String sourceColumnsWithoutBatchId = StringUtil.flat(channelColumns, ",", "", "");
sourceColumnsStr = sourceColumnsWithoutBatchId +
(channelColumns.contains(idTypeColumn) ? "" : ("," + idTypeColumn)) + ",batch_id";
} else {
sourceColumnsStr = idTypeColumn + ",batch_id";
}
String targetColumnsStr = "";
if (!channelColumns.isEmpty()) {
String targetColumnsWithoutBatchId;
Set<String> columns = new TreeSet<>();
for (String str : channelColumns) {
if (ChannelId.CLT_BUS_EML_ADR.toString().equals(str)) {
columns.add(ChannelId.CLT_EML_ADR.toString());
} else {
columns.add(str);
}
}
targetColumnsWithoutBatchId = StringUtil.flat(columns, ",", "", "");
targetColumnsStr = targetColumnsWithoutBatchId +
(channelColumns.contains(idTypeColumn) ? "" : ("," + idTypeColumn)) + ",batch_id";
} else {
targetColumnsStr = idTypeColumn + ",batch_id";
}
提取方法
至此,可以提取兩個方法:computeSourceColumnsStr()以及computeTargetColumnsStr():
private String computeTargetColumnsStr() {
String targetColumnsStr = "";
if (!channelColumns.isEmpty()) {
String targetColumnsWithoutBatchId;
Set<String> columns = new TreeSet<>();
for (String str : channelColumns) {
if (ChannelId.CLT_BUS_EML_ADR.toString().equals(str)) {
columns.add(ChannelId.CLT_EML_ADR.toString());
} else {
columns.add(str);
}
}
targetColumnsWithoutBatchId = StringUtil.flat(columns, ",", "", "");
targetColumnsStr = targetColumnsWithoutBatchId +
(channelColumns.contains(idTypeColumn) ? "" : ("," + idTypeColumn)) + ",batch_id";
} else {
targetColumnsStr = idTypeColumn + ",batch_id";
}
return targetColumnsStr;
}
private String computeSourceColumnsStr() {
String sourceColumnsStr = "";
if (!channelColumns.isEmpty()) {
String sourceColumnsWithoutBatchId = StringUtil.flat(channelColumns, ",", "", "");
sourceColumnsStr = sourceColumnsWithoutBatchId +
(channelColumns.contains(idTypeColumn) ? "" : ("," + idTypeColumn)) + ",batch_id";
} else {
sourceColumnsStr = idTypeColumn + ",batch_id";
}
return sourceColumnsStr;
}
觀察一下提取出來的兩個方法,發現他們的不同之處在於computeTargetColumnsStr()中多了一個對columns集合的計算邏輯,於是將columns的計算邏輯再封裝一下:
private String computeTargetColumnsStr() {
String targetColumnsStr = "";
if (!channelColumns.isEmpty()) {
Set<String> columns = getTargetColumns();
String targetColumnsWithoutBatchId;
targetColumnsWithoutBatchId = StringUtil.flat(columns, ",", "", "");
targetColumnsStr = targetColumnsWithoutBatchId +
(channelColumns.contains(idTypeColumn) ? "" : ("," + idTypeColumn)) + ",batch_id";
} else {
targetColumnsStr = idTypeColumn + ",batch_id";
}
return targetColumnsStr;
}
private Set<String> getTargetColumns() {
Set<String> columns = new TreeSet<>();
for (String str : channelColumns) {
if (ChannelId.CLT_BUS_EML_ADR.toString().equals(str)) {
columns.add(ChannelId.CLT_EML_ADR.toString());
} else {
columns.add(str);
}
}
return columns;
}
經過對比,還有
String targetColumnsWithoutBatchId;
targetColumnsWithoutBatchId = StringUtil.flat(columns, ",", "", "");
這兩行與String sourceColumnsWithoutBatchId = StringUtil.flat(channelColumns, ",", "", "");
存在不一致。但可以使用變數的內聯重構成一樣的形式。
經過整理後代碼的形式如下:
public String generateSql() {
String sourceColumnsStr = computeSourceColumnsStr();
String targetColumnsStr = computeTargetColumnsStr();
StringBuffer vsql = new StringBuffer();
vsql.append("insert into ").append(Constant.DB_SCHEMA).append(".").append(tableName)
.append(" (").append(targetColumnsStr).append(")")
.append(" select distinct ").append(sourceColumnsStr.replace(Constant.CITYNAME_COLUMN, "isnull(" + Constant.CITYNAME_COLUMN + ",'未知城市') as " + Constant.CITYNAME_COLUMN))
.append(" from ").append(Constant.DB_SCHEMA).append(".").append(sourceTableName).append(";\n");
return reviewTempTableSql + vsql.toString();
}
private String computeTargetColumnsStr() {
String targetColumnsStr = "";
if (!channelColumns.isEmpty()) {
Set<String> columns = getTargetColumns();
String targetColumnsWithoutBatchId = StringUtil.flat(columns, ",", "", "");
targetColumnsStr = targetColumnsWithoutBatchId +
(channelColumns.contains(idTypeColumn) ? "" : ("," + idTypeColumn)) + ",batch_id";
} else {
targetColumnsStr = idTypeColumn + ",batch_id";
}
return targetColumnsStr;
}
private String computeSourceColumnsStr() {
String sourceColumnsStr = "";
if (!channelColumns.isEmpty()) {
String sourceColumnsWithoutBatchId = StringUtil.flat(channelColumns, ",", "", "");
sourceColumnsStr = sourceColumnsWithoutBatchId +
(channelColumns.contains(idTypeColumn) ? "" : ("," + idTypeColumn)) + ",batch_id";
} else {
sourceColumnsStr = idTypeColumn + ",batch_id";
}
return sourceColumnsStr;
}
private Set<String> getTargetColumns() {
Set<String> columns = new TreeSet<>();
for (String str : channelColumns) {
if (ChannelId.CLT_BUS_EML_ADR.toString().equals(str)) {
columns.add(ChannelId.CLT_EML_ADR.toString());
} else {
columns.add(str);
}
}
return columns;
}
提煉引數
觀察兩個方法,發現只有columns和channleColumns不同,其它均相同。因此彩提煉引數的方法,為其增加引數,從而合併為一個方法。做法是先將channleColumns再重新提取一個名為columns的變數,並提到方法最開始處。再把方法中的區域性變數重新命名一下,就變成了:
private String computeSourceColumnsStr() {
Set<String> columns = this.channelColumns;
String columnsStr = "";
if (!channelColumns.isEmpty()) {
String columnsStrWithoutBatchId = StringUtil.flat(columns, ",", "", "");
columnsStr = columnsStrWithoutBatchId +
(this.channelColumns.contains(idTypeColumn) ? "" : ("," + idTypeColumn)) + ",batch_id";
} else {
columnsStr = idTypeColumn + ",batch_id";
}
return columnsStr;
}
同樣,將computeTargetColumnsStr()方法也處理一下:
private String computeTargetColumnsStr() {
Set<String> columns = getTargetColumns();
String columnsStr = "";
if (!channelColumns.isEmpty()) {
String columnsStrWithoutBatchId = StringUtil.flat(columns, ",", "", "");
columnsStr = columnsStrWithoutBatchId +
(channelColumns.contains(idTypeColumn) ? "" : ("," + idTypeColumn)) + ",batch_id";
} else {
columnsStr = idTypeColumn + ",batch_id";
}
return columnsStr;
}
再將兩個方法的剩下內容提取為一個新的方法,新方法含有columns作為引數:
private String computeTargetColumnsStr() {
Set<String> columns = getTargetColumns();
return transformToString(columns);
}
private String computeSourceColumnsStr() {
Set<String> columns = this.channelColumns;
return transformToString(columns);
}
private String transformToString(Set<String> columns) {
String columnsStr = "";
if (!channelColumns.isEmpty()) {
String columnsStrWithoutBatchId = StringUtil.flat(columns, ",", "", "");
columnsStr = columnsStrWithoutBatchId +
(this.channelColumns.contains(idTypeColumn) ? "" : ("," + idTypeColumn)) + ",batch_id";
} else {
columnsStr = idTypeColumn + ",batch_id";
}
return columnsStr;
}
內聯方法,並整理
原先提取出來的兩個方法就只剩下2行內容了,其中一行中變數的申明,可以將變數內聯:
private String computeTargetColumnsStr() {
return transformToString(getTargetColumns());
}
private String computeSourceColumnsStr() {
return transformToString(this.channelColumns);
}
再將這兩個方法內聯,最終形成如下的形式:
public String generateSql() {
String sourceColumnsStr = transformToString(this.channelColumns);
String targetColumnsStr = transformToString(getTargetColumns());
StringBuffer vsql = new StringBuffer();
vsql.append("insert into ").append(Constant.DB_SCHEMA).append(".").append(tableName)
.append(" (").append(targetColumnsStr).append(")")
.append(" select distinct ").append(sourceColumnsStr.replace(Constant.CITYNAME_COLUMN, "isnull(" + Constant.CITYNAME_COLUMN + ",'未知城市') as " + Constant.CITYNAME_COLUMN))
.append(" from ").append(Constant.DB_SCHEMA).append(".").append(sourceTableName).append(";\n");
return reviewTempTableSql + vsql.toString();
}
private String transformToString(Set<String> columns) {
String columnsStr = "";
if (!channelColumns.isEmpty()) {
String columnsStrWithoutBatchId = StringUtil.flat(columns, ",", "", "");
columnsStr = columnsStrWithoutBatchId +
(this.channelColumns.contains(idTypeColumn) ? "" : ("," + idTypeColumn)) + ",batch_id";
} else {
columnsStr = idTypeColumn + ",batch_id";
}
return columnsStr;
}
private Set<String> getTargetColumns() {
Set<String> columns = new TreeSet<>();
for (String str : channelColumns) {
if (ChannelId.CLT_BUS_EML_ADR.toString().equals(str)) {
columns.add(ChannelId.CLT_EML_ADR.toString());
} else {
columns.add(str);
}
}
return columns;
}
再一次提煉方法
至此,整個程式碼中沒有重複程式碼,每個方法長度得到控制,命名也比較恰當,重構可以至此結束。但對於高要求的風格而言,應該要求方法中的每個子方法都在同一抽象粒度上。然而transformToString()方法與後續的sql拼接並不在一個抽象粒度上,因此可以將sql拼接再提取到一個新方法中,從而增加可讀性。
做法是先將vsql.toString()提取為變數:
StringBuffer vsql = new StringBuffer();
vsql.append("insert into ").append(Constant.DB_SCHEMA).append(".").append(tableName)
.append(" (").append(targetColumnsStr).append(")")
.append(" select distinct ").append(sourceColumnsStr.replace(Constant.CITYNAME_COLUMN, "isnull(" + Constant.CITYNAME_COLUMN + ",'未知城市') as " + Constant.CITYNAME_COLUMN))
.append(" from ").append(Constant.DB_SCHEMA).append(".").append(sourceTableName).append(";\n");
String insertSql = vsql.toString();
return reviewTempTableSql + insertSql;
再將return之前的部分提取到新方法generateInsertSql()中:
public String generateSql() {
String sourceColumnsStr = transformToString(this.channelColumns);
String targetColumnsStr = transformToString(getTargetColumns());
String insertSql = generateInsertSql(sourceColumnsStr, targetColumnsStr);
return reviewTempTableSql + insertSql;
}
總結
重構過程到此結束。
整個重構過程中,使用了reorder, rename, extract variable, extract method, inline method, split for/code block, add parameter等手法。觀察一下每個步驟都是可控的,如果重構在每個步驟後停止,程式碼依然可以執行。更重要的是,每個步驟都能被證明保持了原有程式碼的行為。這也是重構最重要的兩個條件。
重構的案例程式碼:case1