突破 CRUD - 萬能樹工具類封裝

0、學完本文你或許可以收穫

本文說的樹工具主要是針對前端樹組件初始化時需要的一種簡單樹狀數據結構的生成工具類,而非一般情況下我們所提到的二叉樹、BTree、B+Tree 等的應用或原理技術講解。

特此說明一下。

對於前端樹組件有一定了解和使用過的同學可直接跳躍到第 3 章節開始。

1、樹長什麼樣 ?

前端的樹組件大多數情況下出現在後端的管理系統中,比如我們常見的菜單樹、機構樹、某某分類樹、樹表格等。大致像下方圖片所展示的這樣。

菜單樹

機構樹

樹表格

大致上來說,前端樹的展現形式就是上面 3 張圖所列的幾種形式。而這種前端樹組件的展現構成需要依賴於後端返回的數據格式。

2、數據格式

結合作者自身使用過的前端樹組件來說,大致可以分爲如下兩種。

列表形式

[
    { id:1, pId:0, name:"父節點1"}

    { id:11, pId:1, name:"父節點11"},
    { id:111, pId:11, name:"葉子節點111"},
    { id:112, pId:11, name:"葉子節點112"},
    { id:113, pId:11, name:"葉子節點113"},
    { id:114, pId:11, name:"葉子節點114"},

    { id:12, pId:1, name:"父節點12"},
    { id:121, pId:12, name:"葉子節點121"},
    { id:122, pId:12, name:"葉子節點122"},
    { id:123, pId:12, name:"葉子節點123"},
    { id:124, pId:12, name:"葉子節點124"}
]

樹形結構

[{    name:"父節點1",
    children: [
        { 
            name:"父節點11",
            children: [
                { name:"葉子節點111"},
                { name:"葉子節點112"},
                { name:"葉子節點113"},
                { name:"葉子節點114"}
            ]
        },
        { 
            name:"父節點12",
            children: [
                { name:"葉子節點121"},
                { name:"葉子節點122"},
                { name:"葉子節點123"},
                { name:"葉子節點124"}
            ]
        }
    ]
}]

本文所講的樹工具封裝主要是針對第二種數據格式樹形結構來說,因爲第一種本身不需要特殊處理,也就不存在什麼封裝,就是簡單的列表查詢展示,與一般數據列表數據格式的區別是多了數據 ID 與父 ID 屬性提供給前端進行樹組件的構造。

而第二種是要在列表形式的數據格式上進行轉換,形成如上所示的樹形結構。但是,我們發現裏面沒有數據 ID 與父 ID 屬性,why ?因爲後端完成了數據層面樹結構的構造工作,前端樹組件再無需根據這兩個屬性進行樹結構的判斷構建,直接展示就 OK,當然也不絕對,最終還得看前端的樹組件是否需要。

但一般都會保留這兩個屬性,因爲除過樹組件自身的構造需求,業務處理上往往需要這兩個屬性,而後端樹工具要構造樹結構,那一定是需要數據 ID 與父 ID 的。

如果感覺上面說的麻煩你就記住一點,不管是列表結構還是樹形結構,始終保留數據 ID 與父 ID 兩個屬性就對了。

到這裏又有一個新問題了,上面說了列表形式無需封裝什麼可以直接使用,既然如此那用列表形式的結構就完了唄,爲什麼寫個工具類搞個樹結構出來呢 ?

原因是,前端樹組件的實現方式非常多,不同樹插件或組件需要的數據格式可能不一樣,有的列表、樹形格式都支持,有的僅支持列表或樹形的一種,所以爲了滿足不同前端樹的展示需求,提供樹形結構的構造工具是必要的。

3、話不多說,先實現個初版

從上面的內容我們瞭解了前端樹組件的渲染展現需要後端提供滿足需求的數據格式,那麼實際上也就決定了樹工具類的核心職責就是將一般的數據列表結構轉換爲樹形結構,從而提供給前端使用。

解讀上面所述的核心職責,首先一般列表是什麼列表,此處我們假設爲菜單列表,這就有了第一個類 MenuEntity,緊接着是轉換,誰轉換成誰 ?數據列表轉換樹結構,樹結構本身那應該就是個類,我們暫且叫它 TreeNode,結合我們第一步假設的菜單列表,那實際上就是 List  轉換爲  List < TreeNode > ,如此就得到了第二個類 TreeNode,最後還剩轉換這個動作誰去做 ? 那就是我們今天的主角 TreeUtil 了。

好,至此,通過分析樹工具類的核心職責,我們分析得到了三個類。

OK,有了上面的內容那就來個簡單的實現。

樹節點類

public class TreeNode {
    // 樹節點ID
    private String id;
    // 樹節點名稱
    private String name;
    // 樹節點編碼
    private String code;
    // 樹節點鏈接
    private String linkUrl;
    // 樹節點圖標
    private String icon;
    // 父節點ID
    private String parentId;
}

菜單類

public class MenuEntity {
    // 菜單ID
    private String id;
    // 上級菜單ID
    private String pid;
    // 菜單名稱
    private String name;
    // 菜單編碼
    private String code;
    // 菜單圖標
    private String icon;
    // 菜單鏈接
    private String url;
}

樹工具類

public class TreeUtil {

    /**
     * 樹構建
     */
    public static List<TreeNode> build(List<TreeNode> treeNodes,Object parentId){
        List<TreeNode> finalTreeNodes = CollectionUtil.newArrayList();
        for(TreeNode treeNode : treeNodes){
            if(parentId.equals(treeNode.getParentId())){
                finalTreeNodes.add(treeNode);
                innerBuild(treeNodes,treeNode);
            }
        }
        return finalTreeNodes;
    }

    private static void innerBuild(List<TreeNode> treeNodes,TreeNode parentNode){
        for(TreeNode childNode : treeNodes){
            if(parentNode.getId().equals(childNode.getParentId())){
                List<TreeNode> children = parentNode.getChildren();
                if(children == null){
                    children = CollectionUtil.newArrayList();
                    parentNode.setChildren(children);
                }
                children.add(childNode);
                childNode.setParentId(parentNode.getId());
                innerBuild(treeNodes,childNode);
            }
        }
    }
}

樹工具類實現的兩個關鍵點,第一,樹構建的開始位置也就是從哪裏開始構建,所以需要一個父 ID 參數來指定構建的起始位置,第二,構建到什麼時候結束,不做限制的的話,我們的樹是可以無限延伸的,所以此處 innerBuild 方法進行遞歸操作。

測試代碼

public static void main(String[] args) {
    // 1、模擬菜單數據
    List<MenuEntity> menuEntityList = CollectionUtil.newArrayList();
    menuEntityList.add(new MenuEntity("1","0","系統管理","sys","/sys"));
    menuEntityList.add(new MenuEntity("11","1","用戶管理","user","/sys/user"));
    menuEntityList.add(new MenuEntity("111","11","用戶添加","userAdd","/sys/user/add"));
    menuEntityList.add(new MenuEntity("2","0","店鋪管理","store","/store"));
    menuEntityList.add(new MenuEntity("21","2","商品管理","shop","/shop"));

    // 2、MenuEntity -> TreeNode
    List<TreeNode> treeNodes = CollectionUtil.newArrayList();
    for(MenuEntity menuEntity : menuEntityList){
        TreeNode treeNode = new TreeNode();
        treeNode.setId(menuEntity.getId());
        treeNode.setParentId(menuEntity.getPid());
        treeNode.setCode(menuEntity.getCode());
        treeNode.setName(menuEntity.getName());
        treeNode.setLinkUrl(menuEntity.getUrl());
        treeNodes.add(treeNode);
    }

    // 3、樹結構構建
    List<TreeNode> treeStructureNodes = TreeUtil.build(treeNodes,"0");
    Console.log(JSONUtil.formatJsonStr(JSONUtil.toJsonStr(treeStructureNodes)));
}

收工,第一版簡單的樹工具就實現了。

4、迭代優化

1.0 這不是我的事

但是,通過測試代碼我們發現這個用起來不是太爽,要將菜單數據轉換爲樹結構竟然需要我先把菜單列表轉換成樹結構的列表才能調用樹工具類的 build 方法,這裏的轉換操作僅僅是屬性的拷貝,並未完成樹狀結構的生成構建,但這是調用者需要關心的嗎 ?很顯然 TreeNode 集合創建生成這個過程應該是樹工具類應該做的事情。所以做了如下調整。

1 調整了 build 方法參數,將原有 treeNodes 調整爲 menuEntityList,意味着將上面說的 treeNodes 構建構成交給 TreeUtil 去做。

2 新增了 Convert 類,幷包含 convert 方法,該方法的職責是完成菜單實體到樹節點屬性的拷貝。

3 再次調整 build 方法參數,新增 Convert 轉換。

調整完成的結果,看下代碼。

樹工具

public class TreeUtil_1_0 {

    // 新增的屬性轉換方法
    public interface Convert<MenuEntity,TreeNode>{
        public void convert(MenuEntity menuEntity, TreeNode treeNode);
    }

    /**
     * 樹構建
     */
    public static List<TreeNode> build(List<MenuEntity> menuEntityList,Object parentId,Convert<MenuEntity,TreeNode> convert){

        // 原來調用方做的事情
        List<TreeNode> treeNodes = CollectionUtil.newArrayList();
        for(MenuEntity menuEntity: menuEntityList){
            TreeNode treeNode = new TreeNode();
            convert.convert(menuEntity,treeNode);
            treeNodes.add(treeNode);
        }

        List<TreeNode> finalTreeNodes = CollectionUtil.newArrayList();
        for(TreeNode treeNode : treeNodes){
            if(parentId.equals(treeNode.getParentId())){
                finalTreeNodes.add(treeNode);
                innerBuild(treeNodes,treeNode);
            }
        }
        return finalTreeNodes;
    }

    private static void innerBuild(List<TreeNode> treeNodes,TreeNode parentNode){
        for(TreeNode childNode : treeNodes){
            if(parentNode.getId().equals(childNode.getParentId())){
                List<TreeNode> children = parentNode.getChildren();
                if(children == null){
                    children = CollectionUtil.newArrayList();
                    parentNode.setChildren(children);
                }
                children.add(childNode);
                childNode.setParentId(parentNode.getId());
                innerBuild(treeNodes,childNode);
            }
        }
    }
}

測試代碼

public static void main(String[] args) {
    // 1、模擬菜單數據
    List<MenuEntity> menuEntityList = CollectionUtil.newArrayList();
    menuEntityList.add(new MenuEntity("1","0","系統管理","sys","/sys"));
    menuEntityList.add(new MenuEntity("11","1","用戶管理","user","/sys/user"));
    menuEntityList.add(new MenuEntity("111","11","用戶添加","userAdd","/sys/user/add"));
    menuEntityList.add(new MenuEntity("2","0","店鋪管理","store","/store"));
    menuEntityList.add(new MenuEntity("21","2","商品管理","shop","/shop"));

    // 2、樹結構構建
    List<TreeNode> treeStructureNodes = TreeUtil_1_0.build(menuEntityList, "0", new Convert<MenuEntity, TreeNode>() {
        @Override
        public void convert(MenuEntity menuEntity, TreeNode treeNode) {
            treeNode.setId(menuEntity.getId());
            treeNode.setParentId(menuEntity.getPid());
            treeNode.setCode(menuEntity.getCode());
            treeNode.setName(menuEntity.getName());
            treeNode.setLinkUrl(menuEntity.getUrl());
        }
    });
    Console.log(JSONUtil.formatJsonStr(JSONUtil.toJsonStr(treeStructureNodes)));
}

比較 1.0 與初版的測試代碼,發現少了樹節點列表構建的過程,屬性拷貝的工作作爲回調過程在轉換過程中進行處理。

2.0 僅支持造菜單樹哪夠

1.0 優化完後,我們來了新的需求,有個機構樹也需要生成,此時的樹工具僅支持了菜單樹,所以我們進行改造,讓其支持其他任何對象的樹生成。

改造點主要是將的 TreeUtil 中的菜單實體轉換爲泛型,限於篇幅,就貼個核心方法的代碼

public static <T> List<TreeNode> build(List<T> list,Object parentId,Convert<T,TreeNode> convert){
    List<TreeNode> treeNodes = CollectionUtil.newArrayList();
    for(T obj : list){
        TreeNode treeNode = new TreeNode();
        convert.convert(obj,treeNode);
        treeNodes.add(treeNode);
    }

    List<TreeNode> finalTreeNodes = CollectionUtil.newArrayList();
    for(TreeNode treeNode : treeNodes){
        if(parentId.equals(treeNode.getParentId())){
            finalTreeNodes.add(treeNode);
            innerBuild(treeNodes,treeNode);
        }
    }
    return finalTreeNodes;
}

如此一來,我們就可以支持任意類型的樹構造。

3.0 哥們,你返回的屬性不夠用啊

前兩點比較容易想到,也比較容易實現,但這時候前端同學拋來了新的問題,哥們,你返回的樹節點屬性不夠用啊,你看我這界面。需要備註你沒返回來啊。

好吧,這種情況確實沒考慮到。

要滿足上述需求,簡單做法就將 remark 屬性直接添加到 TreeNode 類中,Convert 中賦下值,這不就滿足了,但想想又不對,今天這個前端夥計缺個 remark,明天可能別的夥計又缺個其他屬性,全加到 TreeNode 中,TreeNode 到底是樹節點還是業務實體,所以不能這麼搞。

這裏要處理成可擴展,同時滿足開閉原則,所以此處比較妥的處理方式是繼承,TreeNode 屬性滿足不了的情況下,通過繼承擴展具體業務的樹節點來實現。

具體改造點如下

1 新增菜單實體擴展樹節點如下

public class MenuEntityTreeNode extends TreeNode {
    // 擴展備註屬性
    private String remark;
    // 省略set get ...

}

2 改造 TreeUtil.build 方法參數,新增 TreeNode Class 類型參數,如下

/**
 * 樹構建
 */
public static <T,E extends TreeNode> List<E> build(List<T> list,Object parentId,Class<E> treeNodeClass,Convert<T,E> convert){
    List<E> treeNodes = CollectionUtil.newArrayList();
    for(T obj : list){
        E treeNode = (E)ReflectUtil.newInstance(treeNodeClass);
        convert.convert(obj, treeNode);
        treeNodes.add(treeNode);
    }

    List<E> finalTreeNodes = CollectionUtil.newArrayList();
    for(E treeNode : treeNodes){
        if(parentId.equals(treeNode.getParentId())){
            finalTreeNodes.add((E)treeNode);
            innerBuild(treeNodes,treeNode);
        }
    }
    return finalTreeNodes;
}

測試代碼

public static void main(String[] args) {
    // ...此處省略模擬數據創建過程

    // 2、樹結構構建
    List<MenuEntityTreeNode> treeStructureNodes = TreeUtil_3_0.build(menuEntityList, "0",MenuEntityTreeNode.class,new TreeUtil_3_0.Convert<MenuEntity,MenuEntityTreeNode>(){

        @Override
        public void convert(MenuEntity object, MenuEntityTreeNode treeNode) {
            treeNode.setId(object.getId());
            treeNode.setParentId(object.getPid());
            treeNode.setCode(object.getCode());
            treeNode.setName(object.getName());
            treeNode.setLinkUrl(object.getUrl());
            // 新增的業務屬性
            treeNode.setRemark("添加備註屬性");
        }
    });
   Console.log(JSONUtil.formatJsonStr(JSONUtil.toJsonStr(treeStructureNodes)));
}

如此一來,不同業務場景下需要添加不同的屬性時,即可做到可擴展,且對現有代碼不造成任何影響和改動。

4.0 哥們,我的屬性名不叫 code

完成了 3.0 版本,基本上大部分需求就都可以滿足了,但是這時候前端同學又拋來了新的問題,哥們,你返回的樹節點編號屬性是 code,但我這邊的叫 number,對應不上,我這邊調整的話影響比較大,你看後端返回的時候能不能處理下。

code 屬性名肯定是不能調整的,因爲其他模塊樹的節點編號都叫 code。

那怎麼辦 ?其實也簡單,跟 3.0 版本一樣,在擴展的業務樹節點去加個屬性,這樣問題是解決了,但萬一出現所有 treeNode 的屬性名都跟前端需要的不對應這種極端情況,那意味着所有樹屬性都需要自行擴展定義,這種豈不是返回了沒什麼用的父 TreeNode 心中的所有屬性。序列化時倒是可以控制,爲空的不進行序列化,但不是依賴序列化框架了麼。還有沒有其他辦法。

稍微整理下需求,就是樹節點屬性在返回前端時要能夠支持自定義屬性名

類屬性定義好就改不了了,怎麼自定義,除了新增類和改現有的屬性,還有什麼辦法呢 ?這時候我們應該想到 map

具體怎麼做

1 首先,定義新的類 TreeNodeMap,看名字就知道基於 map 實現

public class TreeNodeMap extends HashMap {

    private TreeNodeConfig treeNodeConfig;

    public TreeNodeMap(){
        this.treeNodeConfig = TreeNodeConfig.getDefaultConfig();
    }

    public TreeNodeMap(TreeNodeConfig treeNodeConfig){
        this.treeNodeConfig = treeNodeConfig;
    }

    public <T> T getId() {
        return (T)super.get(treeNodeConfig.getIdKey());
    }

    public void setId(String id) {
        super.put(treeNodeConfig.getIdKey(), id);
    }

    public <T> T getParentId() {
        return (T)super.get(treeNodeConfig.getParentIdKey());
    }

    public void setParentId(String parentId) {
        super.put(treeNodeConfig.getParentIdKey(), parentId);
    }

    public <T> T getName() {
        return (T)super.get(treeNodeConfig.getNameKey());
    }

    public void setName(String name) {
        super.put(treeNodeConfig.getNameKey(), name);
    }

    public <T> T  getCode() {
        return (T)super.get(treeNodeConfig.getCodeKey());
    }

    public TreeNodeMap setCode(String code) {
        super.put(treeNodeConfig.getCodeKey(), code);
        return this;
    }

    public List<TreeNodeMap> getChildren() {
        return (List<TreeNodeMap>)super.get(treeNodeConfig.getChildrenKey());
    }

    public void setChildren(List<TreeNodeMap> children) {
        super.put(treeNodeConfig.getChildrenKey(),children);
    }

    public void extra(String key,Object value){
        super.put(key,value);
    }
}

2 既然支持屬性名自定義,新增配置類 TreeNodeConfig 來完成這個事情,同時提供默認屬性名

public class TreeNodeConfig {

    // 默認屬性的單例對象
    private static TreeNodeConfig defaultConfig = new TreeNodeConfig();

    // 樹節點默認屬性常量
    static final String TREE_ID = "id";
    static final String TREE_NAME = "name";
    static final String TREE_CODE = "code";
    static final String TREE_CHILDREN = "children";
    static final String TREE_PARENT_ID = "parentId";

    // 屬性
    private String idKey;
    private String codeKey;
    private String nameKey;
    private String childrenKey;
    private String parentIdKey;

    public String getIdKey() {
        return getOrDefault(idKey,TREE_ID);
    }

    public void setIdKey(String idKey) {
        this.idKey = idKey;
    }

    public String getCodeKey() {
        return getOrDefault(codeKey,TREE_CODE);
    }

    public void setCodeKey(String codeKey) {
        this.codeKey = codeKey;
    }

    public String getNameKey() {
        return getOrDefault(nameKey,TREE_NAME);
    }

    public void setNameKey(String nameKey) {
        this.nameKey = nameKey;
    }

    public String getChildrenKey() {
        return getOrDefault(childrenKey,TREE_CHILDREN);
    }

    public void setChildrenKey(String childrenKey) {
        this.childrenKey = childrenKey;
    }

    public String getParentIdKey() {
        return getOrDefault(parentIdKey,TREE_PARENT_ID);
    }

    public void setParentIdKey(String parentIdKey) {
        this.parentIdKey = parentIdKey;
    }

    public String getOrDefault(String key,String defaultKey){
        if(key == null) {
            return defaultKey;
        }
        return key;
    }

    public static TreeNodeConfig getDefaultConfig(){
        return defaultConfig;
    }

}

3 最後,改造 TreeUtil.build 方法,基於 2.0 版本,只需將 TreeNode 替換成 TreeNodeMap 即可。

/**
 * 樹構建
 */
public static <T> List<TreeNodeMap> build(List<T> list,Object parentId,Convert<T,TreeNodeMap> convert){
    List<TreeNodeMap> treeNodes = CollectionUtil.newArrayList();
    for(T obj : list){
        TreeNodeMap treeNode = new TreeNodeMap();
        convert.convert(obj,treeNode);
        treeNodes.add(treeNode);
    }

    List<TreeNodeMap> finalTreeNodes = CollectionUtil.newArrayList();
    for(TreeNodeMap treeNode : treeNodes){
        if(parentId.equals(treeNode.getParentId())){
            finalTreeNodes.add(treeNode);
            innerBuild(treeNodes,treeNode);
        }
    }
    return finalTreeNodes;
}

測試代碼

public static void main(String[] args) {
     // ... 省略菜單模擬數據的創建過程

    TreeNodeConfig treeNodeConfig = new TreeNodeConfig();
    // 自定義屬性名
    treeNodeConfig.setCodeKey("number");
    List<TreeNodeMap> treeNodes = TreeUtil_4_0.build(menuEntityList, "0",treeNodeConfig,new TreeUtil_4_0.Convert<MenuEntity, TreeNodeMap>() {
        @Override
        public void convert(MenuEntity object, TreeNodeMap treeNode) {
            treeNode.setId(object.getId());
            treeNode.setParentId(object.getPid());
            treeNode.setCode(object.getCode());
            treeNode.setName(object.getName());
            // 屬性擴展
            treeNode.extra("extra1","123");
        }
    });

    Console.log(JSONUtil.formatJsonStr(JSONUtil.toJsonStr(treeNodes)));
}

經過上面的改造,我們實現了樹節點屬性的自定義,順便還實現了屬性可擴展,一舉兩得。

3、總結

目前這個程度可能仍有些場景無法滿足,但是對於大部分的問題場景基於 3.0 或 4.0 版本稍加改造應該都可以解決。剩下的就結合場景再酌情優化調整。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/mA6yelLCM7wa4fOJBhyprg