重構代碼以載入文件
許多現代網路伺服器代碼會與傳回 JSON 資料的上游服務通訊,對該 JSON 資料進行少量修改,並使用時髦的單頁應用程式架構將其傳送至豐富的客戶端網頁。與使用此類系統的人交談時,我聽聞許多人對於處理這些 JSON 文件所需付出的工作量感到沮喪。透過封裝載入策略的組合,可以避免許多此類沮喪。
2015 年 12 月 17 日
當我載入文件(無論是 JSON、XML 或任何其他資料階層)時,我必須選擇在程式中如何表示它。我不會撰寫太多載入代碼,有許多函式庫可以為我執行此操作,但我仍需要選擇資料的最終位置。此選擇應根據我如何使用資料。但我經常會遇到將所有內容載入複雜物件結構的程式,最終會產生不必要的代碼,而這些代碼難以持續更新。
初始代碼
為了說明一些不同的方法,我將使用一個範例服務,該服務提供有關音樂專輯分類的資訊。它所使用的資料看起來像這樣。
{ "albums": [ { "title": "Isla", "artist": "Portico Quartet", "tracks": [ {"title": "Paper Scissors Stone", "lengthInSeconds": 327}, {"title": "The Visitor", "lengthInSeconds": 330}, {"title": "Dawn Patrol", "lengthInSeconds": 359}, {"title": "Line", "lengthInSeconds": 449}, {"title": "Life Mask (Interlude)", "lengthInSeconds": 75}, {"title": "Clipper", "lengthInSeconds": 392}, {"title": "Life Mask", "lengthInSeconds": 436}, {"title": "Isla", "lengthInSeconds": 310}, {"title": "Shed Song (Improv No 1)", "lengthInSeconds": 503}, {"title": "Su-Bo's Mental Meltdown", "lengthInSeconds": 347} ] }, { "title": "Horizon", "artist": "Eyot", "tracks": [ {"title": "Far Afield", "lengthInSeconds": 423}, {"title": "Stone upon stone upon stone", "lengthInSeconds": 479}, {"title": "If I could say what I want to", "lengthInSeconds": 167}, {"title": "All I want to say", "lengthInSeconds": 337}, {"title": "Surge", "lengthInSeconds": 620}, {"title": "3 Months later", "lengthInSeconds": 516}, {"title": "Horizon", "lengthInSeconds": 616}, {"title": "Whale song", "lengthInSeconds": 344}, {"title": "It's time to go home", "lengthInSeconds": 539} ] } ] }
此服務以 Java 撰寫,使用 Jackson 函式庫讀取 JSON。執行此操作的通常方式是定義一系列 Java 類別,並使用 Jackson 的精巧資料繫結功能將 JSON 資料對應至類別的欄位。為了處理此資料,我們需要這些類別。
class Assortment…
private List<Album> albums; public List<Album> getAlbums() { return Collections.unmodifiableList(albums); }
class Album…
private String artist; private String title; private List<Track> tracks; public String getArtist() { return artist; } public String getTitle() { return title; } public List<Track> getTracks() { return Collections.unmodifiableList(tracks); }
class Track…
private String title; private int lengthInSeconds; public String getTitle() { return title; } public int getLengthInSeconds() { return lengthInSeconds; }
Jackson 使用反射自動將 JSON 資料對應到 Java 物件。因此,我不必撰寫任何程式碼來載入物件。不過,我必須定義 JSON 載入的物件。
我可以使用公開欄位,或是有公開 getter 的私人欄位。getter 會產生更冗長的程式碼,但我比較喜歡使用它們,因為我喜歡遵循 Uniform Access Principle。IntelliJ 會幫我撰寫 getter,進一步降低麻煩。
以這種方式定義類別後,載入 JSON 資料就變成一個簡單的方法呼叫
類別 Service…
public String tuesdayMusic(String query) { try { Assortment data = Json.mapper().readValue(dataSource.getAlbumList(query), Assortment.class); return Json.mapper().writeValueAsString(data); } catch (Exception e) { log(e); throw new RuntimeException(e); } }
JSON 資料來自於某些資料來源,透過呼叫 dataSource.getAlbum(query)
。這可以是呼叫另一個服務、存取 JSON 導向資料庫、從檔案讀取,或任何其他目前與我無關的來源。
類別 Json…
public static ObjectMapper mapper() { JsonFactory f = new JsonFactory().enable(JsonParser.Feature.ALLOW_COMMENTS); return new ObjectMapper(f); }
雖然我不必撰寫任何程式碼從 JSON 資料載入物件,但我必須撰寫物件定義。在這個案例中並不多,但我曾遇過需要上百個類別來表示 JSON 資料的案例。如果這些物件沒有太多用途,撰寫類別定義就會變成令人討厭的繁瑣工作。
合理的情況是我們使用資料繫結中定義的所有物件和方法。因此,如果我定義了十個類別和 200 個公開方法,但只使用其中五個,這表示有些地方不對勁。為了探討其他選擇,我們需要檢視幾個不同的案例,並考慮我們可以採取的各種替代途徑。
但無論替代方案是什麼,我都會從相同的第一步開始。
封裝分類
每當我進行重構時,我都會設法找出如何透過將大部分變更封裝在某些適當的方法中,來降低變更的可見度。由於我使用的是組合,這表示要將組合轉換為 JSON 的責任移轉到組合本身。

我從在載入組合的程式碼上使用 Extract Method 開始。
類別 Service…
public String tuesdayMusic(String query) { try { Assortment data = loadAssortment(query); return Json.mapper().writeValueAsString(data); } catch (Exception e) { log(e); throw new RuntimeException(e); } } private Assortment loadAssortment(String query) throws java.io.IOException { return Json.mapper().readValue(dataSource.getAlbumList(query), Assortment.class); }
我不喜歡檢查例外,所以我的預設方式是只要它們一出現就將它們包在執行時期例外中。
類別 Service…
private Assortment loadAssortment(String query) { try { return Json.mapper().readValue(dataSource.getAlbumList(query), Assortment.class); } catch (IOException e) { throw new RuntimeException(e); } }
我希望新方法可以取得 JSON 資料字串,所以我調整新方法的引數。
類別 Service…
public String tuesdayMusic(String query) { try { Assortment data = loadAssortment(dataSource.getAlbumList(query)); return Json.mapper().writeValueAsString(data); } catch (Exception e) { log(e); throw new RuntimeException(e); } } private Assortment loadAssortment(String json) { try { return Json.mapper().readValue(json, Assortment.class); } catch (IOException e) { throw new RuntimeException(e); } }
行為已經完美地提取出來,然後我可以將它作為工廠方法移至分類類別。
類別 Service…
public String tuesdayMusic(String query) {
try {
Assortment data = Assortment.fromJson(dataSource.getAlbumList(query));
return Json.mapper().writeValueAsString(data);
} catch (Exception e) {
log(e);
throw new RuntimeException(e);
}
}
class Assortment…
public static Assortment fromJson(String json) {
try {
return Json.mapper().readValue(json, Assortment.class);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
我無法使用 IntelliJ 的移動方法重構來執行此操作,但手動執行很容易。
我使用相同的步驟順序來提取和移動程式碼以發出 JSON。
類別 Service…
public String tuesdayMusic(String query) {
try {
Assortment data = Assortment.fromJson(dataSource.getAlbumList(query));
return data.toJson();
} catch (Exception e) {
log(e);
throw new RuntimeException(e);
}
}
class Assortment…
public String toJson() { try { return Json.mapper().writeValueAsString(this); } catch (JsonProcessingException e) { throw new RuntimeException(e); } }
如果我有其他使用此類分類的服務方法,現在我會調整它們的所有呼叫以使用這些新方法。完成後,我便會擁有封裝其序列化機制的物件,這讓變更它變得容易許多。
封裝的精髓在於將設計決策變成秘密
有些人可能會覺得我使用「封裝其序列化機制」這個詞很奇怪。當人們學習物件導向時,他們通常會被告知資料是封裝的,因此將行為視為應該封裝的東西可能會聽起來很奇怪。然而,封裝的重點在於做出一些決策,可能是行為或資料結構的選擇,並將其變成一個秘密,以便對不需知道封裝界線背後發生什麼事的外部隱藏起來。在這種情況下,我不想讓分類外部的任何程式碼知道分類與 JSON 的關聯性,因此我將執行載入的程式碼封裝在分類類別中。
傳遞 JSON
現在我已封裝了商品的 JSON 處理,我可以開始研究不同的重構方式,這取決於它的使用方式。
最簡單的情況是服務僅需要提供給商品的相同 JSON。在這種情況下,我可以在商品中儲存 JSON 字串本身,而完全不使用 jackson。
class Assortment…
private String json; public static Assortment fromJson(String json) { Assortment result = new Assortment(); result.json = json; return result; } public String toJson() { return json; }
然後,我可以完全移除專輯和曲目類別。
事實上,在這種情況下,我會完全移除商品類別,並讓服務直接傳回資料來源呼叫的結果。
類別 Service…
public String tuesdayMusic(String query) { try { return dataSource.getAlbumList(query); } catch (Exception e) { log(e); throw new RuntimeException(e); } }
內部客戶端
接下來,我將考慮在伺服器程序中有一個客戶端需要從 JSON 資料收集的部分資訊的情況。
類別 SomeClient…
public List<String> doSomething(Assortment anAssortment) { List<String> titles = anAssortment.getAlbums().stream() .map(a -> a.getTitle()) .collect(Collectors.toList()); return somethingCleverWith(titles); }
這種情況需要我的部分 java 程式碼來處理 JSON 資料,所以我需要的不只是一個字串。但是,如果這是唯一的客戶端,那麼我不需要建立我之前展示的整個物件樹。相反,我可以使用看起來像這樣的 Java 類別。
class Assortment…
private List<Album> albums; public List<Album> getAlbums() { return Collections.unmodifiableList(albums); }
class Album…
private String title; public String getTitle() { return title; }
我可以完全刪除曲目類別。

如果我只需要 JSON 資料的一部分,那麼匯入所有資料就沒有意義了。透過資料繫結指定我需要的部分是取得像這樣一組精簡資料的絕佳方式。像這樣使用資料繫結的函式庫通常有一個組態參數,用於指示資料繫結應如何處理 JSON 中沒有目標記錄繫結的欄位。預設情況下,Jackson 會擲回 UnrecognizedPropertyException
,但透過使用類似於
anObjectMapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
透過只使用我需要的屬性宣告類別結構,我避免了不必要的努力 - 宣告屬性的行為與挑選我想要的欄位一樣好。
這是另一個 java 類別想要使用 java API 呼叫取得商品專輯標題的情況。類似的案例是客戶端想要 JSON 資料的子集,常規的 toJson
呼叫現在只會傳回專輯標題。
{"albums":[ {"title":"Isla"}, {"title":"Horizon"} ]}
如果我只使用輸入文件中的資料子集,我通常發現最好安排事情,以便我只繫結到我正在使用的資料。如果我定義一個完整的物件結構來對應,那麼如果供應商新增一個我可以安全忽略的欄位到 JSON,我的程式碼就會中斷。透過只定義我使用的結構,我正在建立一個 容錯讀取器,這使得我的程式對輸入資料的變更更具彈性。
Java API 和完整 JSON
這自然會導致我的第三個案例,如果我們希望服務呼叫傳回完整的 JSON 文件,但同時我們希望透過 Java API 取得專輯標題清單呢?
此案例的解答是結合前兩個案例,同時儲存原始 JSON 字串和 API 所需的任何 Java 物件。因此,在此案例中,我將字串新增到工廠方法。
class Assortment…
private String doc; public static Assortment fromJson(String json) { try { final Assortment result = Json.mapper() .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) .readValue(json, Assortment.class); result.doc = json; return result; } catch (IOException e) { throw new RuntimeException(e); } } public String toJson() { return doc; }
完成此步驟後,我可以從 Java 資料結構中刪除不需要的方法和類別,就像我只使用 Java API 時所做的一樣。

豐富輸出 JSON 文件
到目前為止的場景已將 JSON 文件視為我們所需資訊的完整載體,但有時我們會使用伺服器程序來豐富該資訊。考慮我們想要了解每張專輯長度的案例。使用完整的物件結構,這很容易。
class Album…
public int getLengthInSeconds() { return tracks.stream().collect(Collectors.summingInt(Track::getLengthInSeconds)); }
此方法同時提供資訊給使用 Java 方法呼叫的 Java 用戶端,並在我使用 Jackson 的資料繫結來建立輸出 JSON 時自動將其新增到 JSON 輸出。唯一的問題是這強迫我將整個 JSON 結構宣告為 Java 類別,對於這個小範例來說不是什麼大問題,但當有數十或數百個其他不必要的類別定義時就會出現問題。我可以透過使用和豐富文件模型來避免這個問題。

我將從包含專輯長度方法的完整類別定義原始起點開始重構。我的第一步是,如同前一個範例,在讀取時新增一個額外的 JSON 文件。不過,這次我會將其讀取為 Jackson 樹狀模型,而且我不會修改輸出程式碼。
class Assortment…
private List<Album> albums; private JsonNode doc; public List<Album> getAlbums() { return Collections.unmodifiableList(albums); } public static Assortment fromJson(String json) { try { final Assortment result = Json.mapper().readValue(json, Assortment.class); result.doc = Json.mapper().readTree(json); return result; } catch (IOException e) { throw new RuntimeException(e); } } public String toJson() { try { return Json.mapper().writeValueAsString(this); } catch (JsonProcessingException e) { throw new RuntimeException(e); } }
我的下一步是建立一個方法來輸出即將豐富的 JSON 文件模型。我從一個簡單的存取器開始,並測試它是否只會輸出目前的文檔。
class Assortment…
public String enrichedJson() { return doc.toString(); }
類別測試人員…
@Test public void enrichedJson() throws Exception { JsonNode expected = Json.mapper().readTree(new File("src/test/enrichedJson.json")); JsonNode actual = Json.mapper().readTree(getAssortment().enrichedJson()); assertEquals(expected, actual); }
目前測試只探測輸出文件是否與輸入相同,但我現在有一個掛鉤,可以在其中逐步將豐富步驟新增到輸出。這裡不是什麼大問題,因為我只有這一個,但如果文件有數個豐富,那麼此類測試允許我一次新增一個。
因此,現在是時候設定豐富程式碼了。
class Assortment…
public String enrichedJson() { JsonNode result = doc.deepCopy(); getAlbums().forEach(a -> a.enrichJson(result)); return result.toString(); }
class Album…
public void enrichJson(JsonNode parent) { final ObjectNode albumNode = matchingNode(parent); albumNode.put("lengthInSeconds", getLengthInSeconds()); } private ObjectNode matchingNode(JsonNode parent) { final Stream<JsonNode> albumNodes = StreamSupport.stream(parent.path("albums").spliterator(), false); return (ObjectNode) albumNodes .filter(n -> n.path("title").asText().equals(title)) .findFirst() .get() ; }
我豐富的基本方法是遍歷 Java 記錄,要求每個記錄豐富其對應的樹狀節點。
一般來說,我盡可能使用收集管線,但必須承認整個使用 StreamSupport
和 spliterator
的業務是一個很大的問題。希望隨著時間的推移,Jackson 將直接支援串流,以避免必須執行此操作。
我比較喜歡建立一個新文件,而不是修改嵌入在分類中的文件。一般來說,我比較喜歡保留資料為讀取狀態,並依需求進行更新。如果建立新的 JSON 文件的成本很高,我隨時可以快取結果。
如果我有一堆豐富化,我可以使用這個機制一次新增一個,並在每次變更時測試新資料。然後,一旦完成,我可以將資料繫結的輸出替換為豐富化的文件。
class Assortment…
public String toJson() {
JsonNode result = doc.deepCopy();
getAlbums().forEach(a -> a.enrichJson(result));
return result.toString();
}
一旦完成,我可以愉快地修剪我的類別結構,移除所有現在未使用的資料、方法和類別的枯枝。此範例中並沒有很多,但透過這種方式從程式碼中移除 37 個類別很有趣。
僅使用樹狀模型
我可以透過使用樹狀模型作為豐富化的基礎,從 Java 類別宣告中修剪更多。在此方法中,我純粹透過遍歷文件的樹狀結構來豐富化,完全不涉及網域物件。在此情況下,豐富化程式碼會如下所示。
class Assortment…
public String toJson() { JsonNode result = doc.deepCopy(); enrichDoc(result); return result.toString(); } private void enrichDoc(JsonNode doc) { for(JsonNode n : doc.path("albums")) enrichAlbum((ObjectNode)n); } private void enrichAlbum(ObjectNode albumNode) { int length = 0; for (JsonNode n : albumNode.path("tracks")) length += n.path("lengthInSeconds").asInt(); albumNode.put("lengthInSeconds", length); }
對於習慣於在動態語言中操作 List And Hashs 的人來說,這種程式碼應該很熟悉。然而,整體而言,我不偏好這種 Java 方式。擁有並使用 Java 物件通常是放置行為的最佳位置,特別是在它在體積和複雜度上成長時。
然而,我會使用這種樣式的其中一個例外情況是,如果透過層級的層級向下有許多層次的導覽,透過層級的層級向下有許多層次的導覽,儘管在那裡我會傾向於僅使用較低層級的節點來建立物件。
在文件結構中建立深度物件
如果我們有一個需要許多類別來表示的大文件,其中大多數是不必要的,但在樹狀結構的葉子附近有一些重要的東西,該怎麼辦?透過關於專輯標題清單的先前範例,我可以支援它們並透過資料繫結到 JSON 資料的子集來移除音軌。但如果我只想要較低層級的東西呢?
對於我的範例,讓我們想像一個想要音軌清單的客戶端。由於音軌和分類之間只有一個專輯類別,我會透過資料繫結來處理該案例。但讓我們假裝在途中有十幾個不需要的層級,那該怎麼辦?
在這種情況下,我想避免資料繫結所有那些層級,但我仍然想要適當的物件供客戶端使用。為了說明我如何處理這件事,讓我們假設到目前為止我只有想要整個 JSON 字串的客戶端,所以我遵循了先前的重構路徑,並只將它儲存在我的分類中。
class Assortment…
private String json; public static Assortment fromJson(String json) { Assortment result = new Assortment(); result.json = json; return result; } public String toJson() { return json; }
很好,這是解決問題的極簡解決方案。但隨後我獲得了一個需要透過文件的新 Java API 的新功能。
類別 SomeClient…
public String doSomething(Assortment anAssortment) { final List<Track> tracks = anAssortment.getTracks(); return somethingCleverWith(tracks); }
同樣地,對於此文件,我只需切換到資料繫結,但我們將繼續假裝,而不是在分類和音軌之間只有一個專輯類別,我實際上有十幾件事 - 足以阻止我進行資料繫結。
我想使用文件樹狀結構,所以我的第一步是從字串重構到樹狀結構。
class Assortment…
private JsonNode doc; public static Assortment fromJson(String json) { Assortment result = new Assortment(); try { result.doc = Json.mapper().readTree(json); } catch (IOException e) { throw new RuntimeException(e); } return result; } public String toJson() { return doc.toString(); }
完成這一點準備性重構後,我就能著手建立軌道。我透過選擇軌道節點,然後使用資料繫結,以軌道節點作為來源來執行此動作。
class Assortment…
public List<Track> getTracks() { return StreamSupport.stream(doc.path("albums").spliterator(), false) .map(a -> a.path("tracks")) .flatMap(i -> StreamSupport.stream(i.spliterator(), false)) .map(Track::fromJson) .collect(Collectors.toList()) ; }
class Track…
private String title; private int lengthInSeconds; public String getTitle() { return title; } public int getLengthInSeconds() { return lengthInSeconds; } public static Track fromJson (JsonNode node) { try { return Json.mapper().treeToValue(node, Track.class); } catch (JsonProcessingException e) { throw new RuntimeException(e); } }
我使用單一記錄顯示此動作,但使用小型的子樹來執行此動作一樣容易,這讓我能從較大的文件擷取少量重要資訊。如果我對文件執行較嚴肅的重新建構,這種方法會很方便,我可以使用這些類型的區域 API 作為資料來源,來建立輸出 資料傳輸物件,然後將其序列化為適當的輸出表示。
我也可以從完整的資料繫結模型執行類似的重構。

總結
我們程式設計工作的一大部分在於處理和處理透過階層資料文件而來的資料。請記住,有數種方式可以處理這些資訊,並選擇適當的組合,以維持程式碼庫精簡且彈性。人們常忘記封裝表示隱藏資料結構和處理方法,我們不應期待模組介面與其內部儲存相符。
致謝
我的同事 Carlyle Davis、Chris Birch 和 Peter Hodgson 在我撰寫本文時與我討論這篇文章。
重大修訂
2015 年 12 月 17 日:發布最終初始分期
2015 年 12 月 15 日:發布豐富輸出 json 的章節
2015 年 12 月 14 日:發布第一分期