天天看點

通用導入導出功能思考

作者:Java架構學習指南

以JSON配置的方式去實作通用性和動态調整,當然,這個通用仍然存在一定的局限性,每個項目的代碼風格都不同,想要寫出一個适合所有項目的通用性子產品并不容易,這裡的通用局限于其所在項目,是以該功能代碼如果不适用于自己的項目,希望可以以此為參考,稍作修改。

那麼現在來分析一下,我們會需要哪些JSON配置項。

導出

基礎配置項

先從最簡單的導出開始,被導出資料應該支援通過業務層查出,如:Service.search(param),這是大前提,

然後為了支援顯示導出進度,業務層還需要提供數量查詢方法,如:Service.count(param),否則無法實作導出進度。

最後導出檔案名也可以定制,如:filename

由上可以得出配置項:

  • serviceClazz:業務類路徑,如:com.cc.service.UserService,必填
  • methodName:查詢方法名,如:listByCondition,必填
  • countMethodName:數量查詢方法名,可填,用于支援導出進度
  • filename:導出檔案名
  • searchParams:查詢參數,數組類型,字典元素。用數組是為了支援查詢方法需要傳多參數的情況

至于查詢方法的參數類,不需要填,因為我們可以通過反射去擷取到該方法所需要傳入的參數類型(注意,以下貼出的是關鍵代碼,僅作參考了解):

Class<?> serviceClass = Class.forName(param.getServiceClazz());

// param為請求參數類
Method searchMethod = ReflectUtil.findMethodByName(serviceClass, param.getMethodName());

// 方法所需要傳入的參數清單
Class<?>[] parameterTypes = searchMethod.getParameterTypes();           
/**
 * 通過反射從指定類中擷取方法對象
 */
public static Method findMethodByName(Class<?> clazz, String name) {
    Method[] methods = clazz.getMethods();
    if (StringUtils.isEmpty(name)) {
        return null;
    }
    for (Method method : methods) {
        if (method.getName().equals(name)) {
            return method;
        }
    }
    return null;
}           

現在我們來想想,導出都會有哪些場景:

  1. 清單頁的分頁查詢,可能是目前頁資料導出,也可能是所有資料導出,這涉及到分頁查詢
  2. 資料總覽頁的查詢,通常是開發者自定義的複雜連表查詢,不需要分頁

那麼本文針對以上兩種情況來實作第一版的通用導出功能。

清單頁的分頁查詢

清單頁的資料導出分目前頁導出和所有資料導出,

假設查詢流程是這樣的:

  1. 接口層接收參數:Controller.search(Param param)
  2. 業務層調用查詢方法:Service.search(param)
  3. 持久層通路資料庫:Mapper.search(param)

這種情況很簡單,但如果流程是這樣的:

  1. 接口層接收參數:Controller.search(Param param)
  2. 業務層調用查詢方法:Service.search(new Condition(param))
  3. 持久層通路資料庫:Mapper.search(condition)

上面代碼中,接口請求參數和持久層參數不一緻,在業務層經過了包裝,那麼這種情況也要相容處理。

但是如果請求參數在業務層經過了包中包中包,那麼就算了。

接着是分頁參數,我們用pageNum和pageSize來表示頁碼和數量字段,類似于:

{
    "pageNum": 1,
    "pageSize": 10,
    "name": "老劉" // 此為查詢字段,如查詢名字為老劉的資料
}           

關于目前頁導出和所有資料導出,可以用一個bool來表示:onlyCurrentPage,預設false,即導出時會自動分頁查詢資料,直到所有資料查詢完畢,導出所有資料時分頁查詢很有必要,能提高性能,避免記憶體溢出,當onlyCurrentPage為true時,則隻導出目前頁面資料。

得出需要的配置項為:

  • searchParam:接口分頁請求參數,JSON類型,必填
  • conditionClazz:條件查詢類,也可以認為是包裝類,如:com.cc.codition.UserCondition,可填
  • onlyCurrentPage:僅目前頁導出,預設false,可填

資料總覽頁的查詢

資料總覽資料沒有數量查詢方法,即Service.count(xxx),也沒有分頁查詢參數,類似于目前頁導出,在也隻考慮一層包裝類的情況下,沒有額外的配置項,上面的已經足夠了,要注意的就是代碼裡面得把分頁參數剔除掉。

表頭配置

一級表頭

模拟一些資料來加深了解,現有一個接口是查詢系統使用者清單,如:/user/search,傳回結果是這樣的:

{
    "code": 0,
    "msg": "請求成功",
    "data": [
        {
            "id": 1,
            "username": "admin",
            "nickname": "超管",
            "phone": "18818881888",
            "createTime": "2023-06-23 17:16:00"
        },
        {
            "id": 2,
            "username": "cc",
            "nickname": "管理者",
            "phone": "18818881888",
            "createTime": "2023-06-23 17:16:00"
        },
        ...
    ]
}           

現在貼出EasyExcel的代碼:

// 建立excel檔案
try (ExcelWriter excelWriter = EasyExcel.write(path).build()) {
    WriteSheet writeSheet = EasyExcel.writerSheet("sheet索引", "sheet名稱").head(getHeader()).build();
    excelWriter.write(getDataList(), writeSheet);
}           
// 模拟表頭
private static List<List<String>> getHeader() {
    List<List<String>> list = new ArrayList<>();
    list.add(createHead("賬号"));
    list.add(createHead("昵稱"));
    list.add(createHead("聯系方式"));
    list.add(createHead("注冊時間"));

    return list;
}

public static List<String> createHead(String... head) {
    return new ArrayList<>(Arrays.asList(head));
}

// 模拟資料
public static List<List<Object>> getDataList() {
    List<List<Object>> list = new ArrayList<>();
    list.add(createData("admin", "超管", "18818881888", "2023-06-23 17:16:00"));
    list.add(createData("cc", "管理者", "18818881888", "2023-06-23 17:16:00"));
    return list;
}

public static List<Object> createData(String... data) {
    return new ArrayList<>(Arrays.asList(data));
}s           

然後導出效果是這樣的:

通用導入導出功能思考

現在先别在乎效果圖的excel樣式,我們後面都會進行動态配置,比如列寬、表頭背景色、字型居中等。

上面我們雖然是寫死了代碼,但聰明的開發者一定懂得将資料庫查詢來的資料轉換成對應的格式,是以這段就跳過了。

現在我們就可以得出基礎的表頭配置:

"customHeads": [
        {
            "fieldName": "username", 
            "fieldNameZh": "賬号"
        },
        {
            "fieldName": "nickname",
            "fieldNameZh": "昵稱"
        },
        {
            "fieldName": "phone",
            "fieldNameZh": "聯系方式"
        },
        {
            "fieldName": "createTime",
            "fieldNameZh": "注冊時間"
        }
    ]           

也就是:

  • fieldName:屬性名,這樣可以從傳回結果的資料對象裡面通過反射找到該屬性以及值
  • fieldNameZh:屬性名肯定不适合作為表頭名,增加一個中文說明來代替屬性名作為表頭

有了上面的基礎,我們就可以增加更多的項來實作功能的豐富性,比如

{
    "fieldName": "username", 
    "fieldNameZh": "賬号",
    "width": 20, // 列寬
    "backgroundColor": 1, // 表頭背景色
    "fontSize": 20, // 字型大小
    "type": "date(yyyy-MM-dd)" // 字段類型
    ...
}           

注:字段類型可以用作資料格式化,比如該屬性是一個status狀态,1表示正常,2表示異常,那麼導出這個1或2是沒有意義的,是以通過字段類型識别出這個狀态值對應的中文描述,這樣的導出才正常。

一級表頭已經可以滿足我們許多場景了,但是這并不足夠,我的經驗中,經常需要用到兩行表頭甚至是複雜表頭,好在EasyExcel是支援多級表頭的。

多級表頭

先貼出EasyExcel生成二級表頭的示例代碼:

// 模拟表頭
private static List<List<String>> getHeader() {
    List<List<String>> list = new ArrayList<>();
    list.add(createHead("使用者資訊", "賬号"));
    list.add(createHead("使用者資訊", "昵稱"));
    list.add(createHead("使用者資訊", "聯系方式"));
    list.add(createHead("使用者資訊", "注冊時間"));

    list.add(createHead("角色資訊", "超管"));
    list.add(createHead("角色資訊", "管理者"));

    return list;
}

public static List<String> createHead(String... head) {
    return new ArrayList<>(Arrays.asList(head));
}

// 模拟資料
public static List<List<Object>> getDataList() {
    List<List<Object>> list = new ArrayList<>();
    list.add(createData("admin", "超管", "18818881888", "2023-06-23 17:16:00", "是", "是"));
    list.add(createData("cc", "管理者", "18818881888", "2023-06-23 17:16:00", "否", "是"));
    return list;
}

public static List<Object> createData(String... data) {
    return new ArrayList<>(Arrays.asList(data));
}           

效果是這樣的:

通用導入導出功能思考

可以看到,前面4列有一個共同表頭【使用者資訊】,後面兩列有一個共同表頭【角色資訊】,

從上面的示例代碼我們知道,要使表頭合并,資料清單得按順序和相同表頭名,這樣會被EasyExcel識别到然後才有合并效果,這點需要注意。

同理,當我們需要生成複雜表頭的時候,可以這樣:

// 模拟表頭
private static List<List<String>> getHeader() {
    List<List<String>> list = new ArrayList<>();
    list.add(createHead("導出使用者資料", "使用者資訊", "賬号"));
    list.add(createHead("導出使用者資料", "使用者資訊", "昵稱"));
    list.add(createHead("導出使用者資料", "使用者資訊", "聯系方式"));
    list.add(createHead("導出使用者資料", "使用者資訊", "注冊時間"));

    list.add(createHead("導出使用者資料", "角色資訊", "超管"));
    list.add(createHead("導出使用者資料", "角色資訊", "管理者"));

    return list;
}           

效果圖:

通用導入導出功能思考

結論

以上是我對導出功能的思考和實作思路,因為篇幅的關系,我沒有貼出完整的代碼,但是相信以上内容已經足夠大家作為參考,缺少的内容,比如列寬、顔色字型等設定,請查閱EasyExcel官方文檔來實作,主要方式就是根據前端傳過來的JSON配置資訊,來動态配置EasyExcel的導出檔案。

導入

導入分兩個步驟:

  1. 使用者下載下傳導入模闆
  2. 使用者填内容進導入模闆,然後上傳模闆檔案到系統,實作資料導入操作

下載下傳導入模闆

導入模闆隻需要上面的customHeads參數即可:

"customHeads": [
        {
            "fieldName": "username", 
            "fieldNameZh": "賬号"
        },
        {
            "fieldName": "nickname",
            "fieldNameZh": "昵稱"
        },
        {
            "fieldName": "phone",
            "fieldNameZh": "聯系方式"
        },
        {
            "fieldName": "createTime",
            "fieldNameZh": "注冊時間"
        }
    ]           

甚至fieldName都可以不要,生成一個隻有表頭的excel檔案。

導入資料

導入資料有兩種場景:

  1. 單表資料導入,該場景很簡單
  2. 複雜資料導入,涉及多表,這種情況就稍微複雜點

單表資料導入

單表隻需要考慮對應實體類的屬性即可,我們可以通過反射來擷取實體類的屬性,是以需要的配置項是:

  • modelClazz:實體類路徑,如:com.cc.entity.User

配置示例:

{
    "modelClazz": "com.cc.entity.User",
    "customHeads": [
        {
            "fieldName": "username", 
            "fieldNameZh": "賬号"
        },
        {
            "fieldName": "nickname",
            "fieldNameZh": "昵稱"
        },
        {
            "fieldName": "phone",
            "fieldNameZh": "聯系方式"
        },
        {
            "fieldName": "createTime",
            "fieldNameZh": "注冊時間"
        }
    ]
}           

這樣在導入資料,被EasyExcel讀取每一行資料的時候,可以識别到如:username項對應com.cc.entity.User類的username屬性那麼就能做到類似這樣的事情:

User user = new User();
user.setUsername(fieldName列的值)           

由此可以得到一個List<User> userList數組,再通過系統的UserService或UserMapper儲存到資料庫,即可實作資料導入操作。

複雜資料導入

複雜資料比如這種場景:excel檔案中每行的資料是這樣的:

賬号 昵稱 聯系方式 注冊時間 角色名
admin 超管 18818881888 2023-06-23 17:16:00 超級管理者
cc 管理者 18818881888 2023-06-23 17:16:00 管理者

其中是否超管和是否管理者涉及關聯表:

  • 使用者表:tb_user
  • 角色表:tb_role
  • 使用者角色關聯表:tb_user_role_relation

為了支援這種複雜資料導入,系統内需要提供對應的儲存方法:

  1. 建立DTO類:

第一種:

public class UserDto {
private String username;
private String nickname;
private String phone;
private Date createTime;
private Boolean superAdminFlag;
private Boolean adminFlag;
}           

第二種:

public class UserDto {
private User user;
private Role role;
}           

這兩種DTO的情況我們都應該考慮,第一種不用多說,上面的配置就可以應對,主要看第二種,第二種方式要考慮“路徑”這個問題,是以customHeads的寫法就要有所改變:

{
"modelClazz": "com.cc.model.UserDto",
"customHeads": [
{
"fieldName": "user.username",
"fieldNameZh": "賬号"
},
...
]
}           

這樣配置賬号路徑為:user.username,屬性的反射查詢就要有遞歸概念,先去查找UserDto類的user屬性,得到該屬性的類,再去擷取其内的username屬性,指派方式就變成了:

UserDto dto = new UserDto();
User user = new User();
user.setUsername(fieldName列的值);
dto.setUser(user);           

這樣得到一個List<UserDto> dtoList數組。

2.既然有複雜資料導入的業務,那麼在Service業務層中,也應該編寫複雜資料的儲存函數:

public interface UserService {
// 單條插入
void saveUserDto(UserDto dto);

// 批量插入
void saveUserDtoBatch(List<UserDto> dtoList);
}           
@Service
public class UserServiceImpl implements UserService {
@Autowired
private UserMapper userMapper;

@Autowired
private RoleService roleService;

@Autowired
private UserRoleRelationService relationService;

// 事務
@Transactional(rollbackFor = Exception.class)
@Override
public void saveUserDto(UserDto dto) {
// 儲存使用者
User user = userMapper.save(dto.getUser());
// 儲存角色
Role role = roleService.save(dto.getRole);
// 儲存關聯
UserRoleRelation relation = new UserRoleRelation();
relation.setUserId(user.getId());
relation.setRoleId(role.getId());
relationService.save(relation);
}

// 批量插入代碼省略,原理同上
void saveUserDtoBatch(List<UserDto> dtoList);
}           

3.通過EasyExcel讀取到的每一行資料都能轉成UserDto對象,再通過單條或批量來儲存資料,這期間有許多可以優化考慮的點,比如:

  • 批量比單條儲存效率高、性能好,但是批量不容易識别出部分失敗的行
  • 批量儲存的數量不能太多,要考慮系統和資料庫的性能,比如每次讀取500行就執行一次儲存
  • 儲存的進度顯示,先擷取excel總行數,再根據目前讀取行數來計算進度,并傳回給前端
  • 導入時間過長,可以做成背景任務進行,至于前端提醒可以是輪詢也可以是WebSocket

是以需要指定查詢方法,這配置項上面已經給出來了。

配置項總結

最後給出一個總配置項出來參考:

導出資料配置

{
    "filename": "使用者資料導出",
    "serviceClazz": "com.cc.service.UserService",
    "methodName": "listByCondition",
    "countMethodName": "countByCondition",
    "searchParams": [
        {
            "nickname": "cc" // 搜尋昵稱為cc的使用者
        }
    ],
    "customHeads": [
        {
         	"fieldName": "username",
            "fieldNameZh": "賬号",
            "width": 20, // 列寬
            "fontSize": 20 // 字型大小
        },
        {
            "fieldName": "createTime",
            "fieldNameZh": "注冊時間",
            "type": "date(yyyy-MM-dd)" // 屬性類型聲明為date,并且轉換成指定格式導出
        }
    ]
}           

導入模闆配置

{
    "filename": "使用者資料導入",
    "modelClazz": "com.cc.entity.User",
    "customHeads": [
        {
         	"fieldName": "username",
            "fieldNameZh": "賬号",
            "width": 20, // 列寬
            "fontSize": 20 // 字型大小
        },
        {
            "fieldName": "createTime",
            "fieldNameZh": "注冊時間",
            "type": "date(yyyy-MM-dd)" // 屬性類型聲明為date,并且轉換成指定格式導出
        }
    ]
}           

導入資料配置

{
    "modelClazz": "com.cc.entity.User",
    "serviceClazz": "com.cc.service.UserService",
    "methodName": "save",
    "customHeads": [
        {
         	"fieldName": "username",
            "fieldNameZh": "賬号",
        },
        {
            "fieldName": "createTime",
            "fieldNameZh": "注冊時間",
            "type": "date(yyyy-MM-dd)" // 屬性類型聲明為date,并且轉換成指定格式導出
        }
    ]
}           

繼續閱讀