概述
导入导出是常见的后台管理应用功能,几乎所有的后台系统都要用到。
借助第三方组件,如POI等,要实现基本的导入导出功能并不难,但是要设计一个高效且通用的导出导入框架,却并不容易。这考验到系统框架人员的设计功力。以笔者在多个公司工作时所见到的现状,未发现一个很好用的框架。导出的功能比较独立还好一点,比较容易做成通用的框架,一般的公司都实现了导出框架,虽然不是尽善尽美,但基本可用。
而涉及到导入方面,基本上没有发现一个十分高效而通用的导入框架。包括不少大公司在内,都没有现成的导入框架,需要开发人员自己一点点的自己写代码完成。而开发人员水平参差不齐,基本上写出来的东西只能说勉强完成了功能,一方面性能方面不太好,更主要的是可扩展性无从谈起。每个开发人员个各搞,经常容易出乱子。
那么为什么一般的公司的平台框架都会提供导出框架而没提供导入框架呢?
这是因为导入比导出要复杂一些,导出一般仅仅是将数据搞出来即可,属于比较独立的技术处理,方便做到系统框架里去。而对于导入功能来讲,并不是纯粹的导入,往往伴随着要尽行数据有效性检查等复杂事情,而数据校验根据具体业务场景可简可繁,实际上是具体的业务规则。这种业务逻辑是不能做到平台框架里的。所以大多数公司的平台框架都不提供导入功能,有的即使提供了也不太好用。还是需要自己写代码处理。
那么导入能不能做成通用的功能做到平台框架里呢?实际上是可以的。
设计思路
设计要点就是,将导入过程切分成几步:
- 第1步——保存原始数据到临时区,
- 第2步——在临时区检查数据,也就是数据校验,得到检查结果信息。
- 第3步——将有效数据导入正式区,将检查出的校验结果信息传出来让用户看到。
其中第1步是纯技术的事情,由框架中台代码实现。第2步,第3步由开发人员在后台数据层实现,系统框架提供一些约定的信息处理规则即可。
中台框架定好后,供前端调用的接口也就确定下来了。
需要注意的是,业务校验功能是不能设计在中台的java代码里的,否则框架结构会非常难处理,校验功能一定要放到数据库层。
了解了这个思路后我们再看下实现代码。
前端代码示例
先说前台:前台代码分2部分,一部分是前端开发人员使用的调用部分示例,在customer.js文件里,一部分是前端框架部分,在app.js文件里。
前端采用ajax异步方式处理上传。
前台第1部分:
前端调用代码示例
function importChooseFile() {
if (isDblClick()) return;
$("#up_btn").click(function() {
importCustomer();
});
$("#myUploadDialog").modal();
}
function importCustomer() {
$("#d2").load("commons.html #myImportResult");
if (isDblClick()) return;
var cols = {
customerid: "Customer Id",//db field: excel header name
firstname: "First Name",
lastname: "LastName",
company: "Company",
address: "Address",
city: "City",
state: "State",
country: "Country",
postalcode: "Zip Code",
phone: "Phone",
fax: "Fax",
email: "Email",
supportrepid: "Support Rep Id"
};
var file1 = $("#myfile")[0].files[0];
var formData = new FormData();
formData.append("file1", file1);
formData.append("method", "sp_imp_customer");
formData.append("verifyMethod", "sp_imp_customer_verify");
formData.append("cols", JSON.stringify(cols));//上传文件时表单变量不支持复杂结构,转为字符串传。
uploadImport(formData);
}
先看调用部分: 前端传送数据用jquery封装到表单里。
fileupload: 要传的文件,这里只以一个文件为例子。
cols 定义Excel文件表头对应的数据字段。这个好理解。注意这里提暴露给前端开发人员的接口规范只需要定义好名称对应关系,并不需要管位置,位置应当由框架去处理。
method:后台导入的SP名称
verifyMethod: 后台校验数据的SP名称
我们看到,前端的调用部分比较简单的。
前端的框架部分是自己提炼出来的,也比较简单,主要添加一些需要传到后台的通用属性,比如token,userCode等,这些由前端的框架app.js处理,这部分数据框架范畴,这部分代码可以重用,一般不用动它。
前端框架部分示例
function uploadImport(formData) {
formData.append("userCode", loginInfo.userCode);
formData.append("roleCode", loginInfo.roleCode);
formData.append("tag", "IMPORT");
formData.append("token", "" + new Date().getTime());//required for import
confirm1('Import data', 'Import selected file into database. Are you sure?', function() {
$("#myUploadDialog").modal('hide'); //close dialog manually
$(document.body).css("cursor", "progress");
$.ajax({
url: 'importserver',
type: 'post',
data: formData,
contentType: false,
processData: false,
success: function(result, status) {
console.log("---ajax-result=" + result);
$(document.body).css("cursor", "default");
proccessResult(JSON.parse(result));
},
error: function(xhr) {
$(document.body).css("cursor", "default");
console.log("---ajax-Error:" + xhr.status + " " + xhr.statusText);
}
});
});
}
中台实现
中台部分由Java实现,当然设计方案确定后实际上也可以用任何其它语言实现。因为笔者对java稍微熟悉点,这里以Java为例说明。
虽然中台部分属于此框架的核心,但是都属于系统框架的内容,可以不用给开发人员开放。这里对中台代码简要说明下。
先看控制器ImportController,
控制器代码示例
@RestController
public class ImportController {
private static Logger log = LoggerFactory.getLogger(ImportController.class);
@Autowired
private ImportDao importDao;
@RequestMapping(value = "/importserver")
public String importPost(@RequestParam("file1") MultipartFile f1,
@RequestParam("method") String method,
@RequestParam("verifyMethod") String verifyMethod,
@RequestParam("tag") String tag,
@RequestParam("token") String token,
@RequestParam("cols") String cols,
@RequestParam("userCode") String userCode,
@RequestParam("roleCode") String roleCode,
HttpServletRequest request) throws IOException {
long t0 = System.currentTimeMillis();
ObjectMapper mapper = new ObjectMapper();
ImportRequest req = new ImportRequest();
req.method = method;
req.verifyMethod = verifyMethod;
req.token = token;
req.tag = tag;
req.cols = mapper.readValue(cols, VOS.class);
req.userCode = userCode;
req.roleCode = roleCode; // optional
ImportResponse rsp = req.copy();
String today = new SimpleDateFormat("yyyyMMdd").format(new Date());
String currDir = request.getServletContext().getRealPath("/");
String fileUrl = "upload/" + today + "/" + U.getTempFileName(f1.getOriginalFilename());
String destFilePath = currDir + fileUrl;
log.debug("upload original file=" + f1.getOriginalFilename());
log.debug("upload destFilePath=" + destFilePath);
File f = new File(destFilePath);
f.getParentFile().mkdirs();
f1.transferTo(f);
req.fullPath = destFilePath;
rsp = importDao.execute(req);
long t1 = System.currentTimeMillis();
rsp.sourceFile = f1.getOriginalFilename();
rsp.fileUrl = fileUrl;
rsp.consumed = t1 - t0;
String str = mapper.writeValueAsString(rsp);
return str;
}
}
主要用途是接住前端传送的内容,将上传的文件保存为临时文件以便下一步导入,其它的参数封装成一个ImportRequest对象,
都是Spring 的一些常规处理,比较好理解。
通用的DAO实现-第1部分
核心部分是DAO层 ImportDao
我们看到,DAO层主要分2步,第1步,将数据保存到临时表,具体的插入动作是调用 前台传出的方法名,req.method
@Component
public class ImportDao extends BaseDao<ImportRequest, ImportResponse> {
private static Logger log = LoggerFactory.getLogger(ImportDao.class);
private static final String PARAM_ROWNUM = "p_rownum";
@Override
public ImportResponse execute(ImportRequest req0) {
ImportRequest req = (ImportRequest) req0;
ImportResponse rsp = req.copy();
try {
init(req.method);
Workbook wb = new XSSFWorkbook(new File(req.fullPath));
Sheet sheet1 = wb.getSheetAt(0);
Row row = sheet1.getRow(0);
initExcelIndex(req.cols, row);
for (ProcedureColumn pc : spCols) {
String dbColName = pc.COLUMN_NAME.substring(2);
if (!req.cols.containsKey(dbColName) && (!"token".equals(dbColName)) && (!"rownum".equals(dbColName))) {
String ss = "Error: SP parameter not defined in front end,SP=" + req.method + ",dbColName= "
+ pc.COLUMN_NAME;
log.error(ss);
throw new Exception(ss);
}
}
int n = 0;
conn.setAutoCommit(false);// Important!
for (int i = 1; i <= sheet1.getLastRowNum() + 1; i++) {
row = sheet1.getRow(i);
if (row == null) {
continue;
}
for (ProcedureColumn pc : spCols) {
Object val = null;
Cell cell = row.getCell(pc.excelIndex);
CellType tp = cell.getCellType();
String key = pc.COLUMN_NAME;
if (key.equals(PARAM_TOKEN)) {
val = req.token;
} else if (key.equals(PARAM_ROWNUM)) {
val = i;
} else if (tp == CellType.NUMERIC) {
val = cell.getNumericCellValue();
} else if (tp == CellType.STRING) {
val = cell.getStringCellValue().trim();
}
stmt.setObject(pc.pos, val);
}
stmt.addBatch();
n++;
if (i % 1000 == 0) {
stmt.executeBatch();
stmt.clearBatch();
conn.commit();
n = 0;
}
}
if (n > 0) {
stmt.executeBatch();
conn.commit();
}
wb.close();
conn.setAutoCommit(true);
executeImportVerify(req, rsp);
} catch (Exception e) {
rsp.result = C.RESULT_FAIL;
rsp.message = e.getMessage();
log.error(e.getMessage(), e);
}
return rsp;
}
中台的作用主要是读取EXCEL文件。注意为了提高效率,我们没有用spring封装的数据访问对象,而是直接用的jdbc底层方法。只用到spring的datasource得到Connection
同时,批量提交十分重要。
需要注意的是,由于要做成通用的框架,中台的导入代码,不能涉及到任何具体的业务字段名。
initExcelIndex(req.cols, row);这句话就是做这个用的,填充入参时也是通用的代码。针对用户给定的数据做循环。
具体的字段名,只在前端/后端涉及到。一般在实际开发过程中由前后端开发人员沟通而定。
通用的DAO实现-第2部分
Dao第2步,检查数据,调用前端传入的方法名,req.verifyMethod, 调用数据层SP检查数据有效性。
并将错误信息写入到保存的临时Excel文件里,后面供用户查看。
private void executeImportVerify(ImportRequest req, ImportResponse rsp) throws Exception {
try {
stmt = conn.prepareCall("{call " + req.verifyMethod + "(?,?)}");// fixed SP name with suffix _verify
stmt.setObject(1, req.token);// fixed parameter
stmt.setObject(2, req.userCode);// fixed parameter
stmt.executeQuery();
rs = stmt.getResultSet();
List<VO> list = U.getDataFromResultSet(rs);
FileInputStream fis = new FileInputStream(req.fullPath);
XSSFWorkbook wb = new XSSFWorkbook(fis);
XSSFSheet sheet1 = wb.getSheetAt(0);
XSSFRow row0 = sheet1.getRow(0);
int n = sheet1.getLastRowNum();
rsp.totalNum = n;
rsp.successNum = n;
CellStyle style = wb.createCellStyle();
Font font1 = wb.createFont();
font1.setFontName("Arial");
font1.setFontHeightInPoints((short) 11);
style.setFont(font1);
style.setFillForegroundColor(IndexedColors.RED1.getIndex());
style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
if (rs != null && list.size() > 0) {
int ncol = row0.getLastCellNum();
for (VO vo : list) {
int rownum = Integer.valueOf(vo.get("rownum").toString());
Row row = sheet1.getRow(rownum);
Cell cell = row.createCell(ncol);
cell.setCellValue(vo.get("error_message").toString());
for (int i = 0; i < ncol + 1; i++) {// need set every cell instead of setRowStyle
row.getCell(i).setCellStyle(style);
}
}
rsp.errorNum = list.size();
rsp.successNum = rsp.totalNum - rsp.errorNum;
}
rsp.message = "Total=" + rsp.totalNum + ", success=" + rsp.successNum + ", error=" + rsp.errorNum;
File file = new File(req.fullPath);
try (OutputStream fileOut = new FileOutputStream(file)) {
wb.write(fileOut);
;
}
fis.close();
wb.close();
rsp.result = C.RESULT_SUCCESS;
} catch (Exception e) {
rsp.result = C.RESULT_FAIL;
rsp.message = e.getMessage();
log.error(e.getMessage(), e);
throw e;
} finally {
close();
}
}
DAO的第2步比较重要,只有在这一步才将数据正式写入,并将结果信息传递给用户查看。
这里要特别注意,中台代码无论是Controller和DAO, 始终贯彻设计理念,并没有任何涉及业务处理相关的代码。
后台代码
这里说得后台代码,说的是数据库层。由具体的后端开发人员实现。包含2个存储过程,一个是导入临时表:
这里是示例SP名为 sp_imp_customer
后台代码示例-插入临时表
CREATE DEFINER=`root`@`localhost` PROCEDURE `test`.`sp_imp_customer`(
IN `p_token` varchar(50),
IN `p_rownum` int,
IN `p_customerid` int,
IN `p_firstname` varchar(40),
IN `p_lastname` varchar(20),
IN `p_company` varchar(80),
IN `p_address` varchar(70),
IN `p_city` varchar(40),
IN `p_state` varchar(40),
IN `p_country` varchar(40),
IN `p_postalcode` varchar(20),
IN `p_phone` varchar(20),
IN `p_fax` varchar(20),
IN `p_email` varchar(60),
IN `p_supportrepid` int
)
COMMENT 'Import customer info into temp table "customer_import", p_token passed from front end, p_rownum(excel row num) passed from middle tier of java framework.'
begin
insert
into
test.customer_import
(token,
rownum,
customerid,
firstname,
lastname,
company,
address,
city,
state,
country,
postalcode,
phone,
email,
supportrepid)
values
(p_token,
p_rownum,
p_customerid,
p_firstname,
p_lastname,
p_company,
p_address,
p_city,
p_state,
p_country,
p_postalcode,
p_phone,
p_email,
p_supportrepid);
END
代码很简单,就是基本的导入,注意临时表需要比原表新增2个字段,token, rownum,error_message
- token是前端传入的唯一导入号,
- rownum是要存入源文件行号,以便后面回写错误信息。
- error_message 错误信息,供第2步校验时用。
只需要写一条SQL语句即可,供中台调用。
后台代码示例-数据校验及处理
第2个是校验SP,这里示例SP名为 sp_imp_customer_verify
CREATE DEFINER=`root`@`localhost` PROCEDURE `test`.`sp_imp_customer_verify`(
IN `p_token` varchar(50),
IN `p_usercode` varchar(50)
)
COMMENT 'Checking imported data in the temp table with specific business rule; record error info; import valid data to formal table "customer", p_token should be passed'
begin
-- 开发人员自己加入校验业务逻辑,记录错误信息
update test.customer_import
set error_message = 'Missing First Name'
where token = p_token
and firstname is null;
update test.customer_import
set error_message = 'Missing Last Name'
where token = p_token
and lastname is null;
delete from test.customer_import
where datediff(now(), dw_create_time) > 30;
insert into test.customer
(firstname,
lastname,
company,
address,
city,
state,
country,
postalcode,
phone,
fax,
email,
supportrepid,
create_by)
select firstname,
lastname,
company,
address,
city,
state,
country,
postalcode,
phone,
fax,
email,
supportrepid,
p_usercode
from test.customer_import
where token = p_token
and error_message is null;
select *
from test.customer_import
where token = p_token
and error_message is not null;
END
这里根据业务规则先批量检查数据
将错误信息写入字段error_message
将有效的数据写入正式表。
注意中台一定要传入参数 p_token
总结
我们看到,导入框架的核心思路就是分层分步骤,将业务部分剥离,核心部分放在中台,但是作为系统框架,对开发人员屏蔽了复杂性,
只需要在两端实现基本的业务功能即可:前端开发人员声明具体用到的字段清单,后台开发人员在数据层编写业务校验规则,以及很简单的写入操作。
该框架经测试,20个字段以内,导出速度大于1万条每秒, 导入速度大于5千条每秒。
源代码说明
全部源代码地址:
https://github.com/chenyh-a/easyweb
源代码结构
示例数据导入
下载源代码后, 主目录下有一个数据文件,将它导入mysql或者mariaDB 数据库:
进入mysql客户端命令窗口
创建数据库 test
然后导入数据文件 source E:\git\easyweb\data.sql (改为你自己的具体路径)
修改配置文件 application.properties 的数据库连接参数
编译与打包
打包 mvn package (假定你已经配置好了maven环境)
生成WAR包 easyweb-0.0.1.war
将生成在target目录下的war包改名为 easyweb.war 放入tomcat 的webapps目录下,启动tomcat即可访问
http://localhost:8080/easyweb/customer_list.html
查看效果:
或者将源代码整体导入IDE 环境,比如eclipse等。在IDE里运行即可看到效果(IDE必须预先配置好了Spring支持环境)
如果对源代码有任何疑问或发现错误疏漏请不吝指正。