合併多對多關係並輸出成JSON
例如要列出每個使用者有哪些群組身分

情境

  • 例如有"使用者"與"群組",他們是多對多關係,存在一張DB的中間表

    • 一個使用者可以有多個群組身分,一個群組中也能存在多個使用者
  • 今天想查詢以"使用者"出發,要列出每個使用者的群組身分有哪些,返回JSON像這樣

[
{"userId":1,"groups":[1,2,3]},
{"userId":2,"groups":[2,3]},
{"userId":3,"groups":[3]}
]      

模擬資料

// 模擬資料
List<User> list = new ArrayList<>();
list.add(new User(1, 1));
list.add(new User(1, 2));
list.add(new User(1, 3));
list.add(new User(2, 2));
list.add(new User(3, 3));
list.add(new User(2, 3));

方法一

  • 建一個UserVo
public class UserVo {
    Integer userId;
    List<Integer> groups;
}
  • stream().filter操作
List<UserVo> result = new ArrayList<>();
for (User user : list) {
    UserVo userVo = null;
    // 如果Id已經存在
    Optional<UserVo> matched = result.stream().filter(e -> user.getUserId().equals(e.getUserId())).findFirst();
    if (matched.isPresent()) {
        userVo = matched.get();
        List<Integer> groups = new ArrayList<>(userVo.getGroups());
        groups.add(user.getGroup());
        userVo.setGroups(groups);
    } else {
        userVo = UserVo.builder()
                .userId(user.getUserId())
                .groups(Arrays.asList(user.getGroup())).build();
        result.add(userVo);
    }
}
System.out.println("result = " + result);

方法二

  • 用Map
Map<Integer, Set<Integer>> idGroupsMap = new HashMap<>();
for (User user : list) {
    if (idGroupsMap.containsKey(user.getUserId())) {
        // 如果Id已經存在
        idGroupsMap.get(user.getUserId()).add(user.getGroup());
    } else {
        idGroupsMap.put(user.getUserId(), new HashSet<>(Arrays.asList(user.getGroup())));
    }
}
System.out.println(idGroupsMap); // 純數值

// 不借助Vo生成有序的JSON
JSONArray ja = new JSONArray();
idGroupsMap.forEach((k, v) -> {
    JSONObject jo = new JSONObject(new LinkedHashMap<String, Object>());
    jo.putOpt("userId", k);
    jo.putOpt("groups", v);
    ja.add(jo);
});
System.out.println(ja);
System.out.println("JSON=" + JSONUtil.toJsonPrettyStr(ja));

踩坑記錄

  • 我在邊吃過一個虧,就是單純遍歷idGroupsMap,然後判斷k是否存在,若有就把v拿出來,add資料,然後再用同k放回去,問題就出在最後這個put
  • 一直報錯null,我就覺得很奇怪,開debug看每個值都有阿,怎麼到put那步就報null
  • 後來才想明白,我取出的v實際上是取得引用,我add是沒問題,但是在put的時候,由於key已經存在,所以這個put是執行覆蓋,他底層做的事就是把本來的v直接變成null,然後放入新的v
  • 所以我根本是自找麻煩,如果像這種只是附加到尾部的直接add就好
  • 如果v需要多步驟大改可以取出v然後做一個clone,運算完,將clone作為新的v放進去才行
  • 相較之下就很麻煩,不如用computeIfPresent()
idGroupsMap.computeIfPresent(user.getUserId(), (k, v) -> {
    v.add(user.getGroup());
    return v;
});

方法三

  • 流式操作,用Collectors.mapping
  • 最優雅,且如果資料量龐大時可以用parallelStream來並行處理(但是小心順序與線程安全問題)
Map<Integer, Set<Integer>> userMap =
        list.stream().collect(
                Collectors.groupingBy(
                        User::getUserId,
                        Collectors.mapping(
                                User::getGroup,
                                Collectors.toSet()
                        )
                )
        );

List<Map<String, Object>> result =
        userMap.entrySet().stream()
                .map(entry -> {
                    Map<String, Object> map = new HashMap<>(2);
                    map.put("userId", entry.getKey());
                    map.put("groups", entry.getValue());
                    return map;
                })
                .collect(Collectors.toList());

小結

  • 方法二、三更好一點,用Map、Set本身可以防止重複的key,並且不需要額外造一個Vo更省資源
  • 另外我發現HutoolJSONArray就很奇怪,如果純用LinkedHashMap作為JsonNode,那我put的順序最後輸出時又會亂掉,但是用JSONObject(new LinkedHashMap<String, Object>());他就能維持住put的順序
  • 後來覺得jackson-databind可能還是好用一點,直接用LinkedHashMap構成他就會照順序
List<Object> ja = new ArrayList<>();
idGroupsMap.forEach((k, v) -> {
    HashMap<String, Object> jo = new LinkedHashMap<>();
    jo.put("userId", k);
    jo.put("groups", v);
    ja.add(jo);
});
System.out.println("JSON = " + new ObjectMapper().writeValueAsString(ja));
  • 如果有POJO也可以直接註釋順序
@JsonPropertyOrder({"name", "phoneNumber","email", "salary", "id" })
public class Employee {
...
  • 但是要注意用jackson還原泛型時使用TypeReference不要引錯包,我曾經在這邊找問題找超久…
import com.fasterxml.jackson.core.type.TypeReference;

上次修改於 2022-04-10