以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;
}
現在我們來想想,導出都會有哪些場景:
- 清單頁的分頁查詢,可能是目前頁資料導出,也可能是所有資料導出,這涉及到分頁查詢
- 資料總覽頁的查詢,通常是開發者自定義的複雜連表查詢,不需要分頁
那麼本文針對以上兩種情況來實作第一版的通用導出功能。
清單頁的分頁查詢
清單頁的資料導出分目前頁導出和所有資料導出,
假設查詢流程是這樣的:
- 接口層接收參數:Controller.search(Param param)
- 業務層調用查詢方法:Service.search(param)
- 持久層通路資料庫:Mapper.search(param)
這種情況很簡單,但如果流程是這樣的:
- 接口層接收參數:Controller.search(Param param)
- 業務層調用查詢方法:Service.search(new Condition(param))
- 持久層通路資料庫: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的導出檔案。
導入
導入分兩個步驟:
- 使用者下載下傳導入模闆
- 使用者填内容進導入模闆,然後上傳模闆檔案到系統,實作資料導入操作
下載下傳導入模闆
導入模闆隻需要上面的customHeads參數即可:
"customHeads": [
{
"fieldName": "username",
"fieldNameZh": "賬号"
},
{
"fieldName": "nickname",
"fieldNameZh": "昵稱"
},
{
"fieldName": "phone",
"fieldNameZh": "聯系方式"
},
{
"fieldName": "createTime",
"fieldNameZh": "注冊時間"
}
]
甚至fieldName都可以不要,生成一個隻有表頭的excel檔案。
導入資料
導入資料有兩種場景:
- 單表資料導入,該場景很簡單
- 複雜資料導入,涉及多表,這種情況就稍微複雜點
單表資料導入
單表隻需要考慮對應實體類的屬性即可,我們可以通過反射來擷取實體類的屬性,是以需要的配置項是:
- 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
為了支援這種複雜資料導入,系統内需要提供對應的儲存方法:
- 建立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,并且轉換成指定格式導出
}
]
}