🎯 本文档详细介绍了开发机构用户数据导入功能的必要性及实现方法,如针对教育机构如学校场景下提高用户体验和管理效率的需求。文中首先分析了直接对接学生管理系统与平台对接的优势,包括减少人工审核成本、提高身份验证准确性等。接着介绍了FastExcel作为处理Excel文件的高效工具及其在用户数据导入导出中的应用,并展示了如何利用JavaFaker生成测试数据,以及通过消息队列异步处理Excel数据导入以保证效率和稳定性。最后,提供了SQL批量插入语句示例,用于处理用户数据的高效入库。
需求说明
为什么需要开发机构用户数据导入功能?平台不是本身就存在注册功能吗,为啥要多此一举?
场快订采用SaaS模式开发,支持多租户使用,不同租户对其资源的管理方式可能是不同的。
例如,在部分学校中,其内部场馆的预订服务可能仅对本校的教师、学生及工作人员开放。因此,为了准确识别和区分用户身份,确保只有特定机构内的成员能够访问相应的资源和服务,我们需要引入基于机构ID的身份绑定功能。
尽管平台本身已经具备注册功能,但对于类似学校等机构而言,直接利用其现有的学生管理系统与我们的平台进行对接,将极大地提高效率并减少管理成本。如果要求所有学生首先通过上传学生证等方式来完成注册审核流程,不仅会面临伪造证件的风险(如通过P图方式绕过审核),还会因需要大量人工审核而消耗不必要的资源。此外,这种做法对于学生来说也不够友好,增加了他们使用平台的复杂度。
相比之下,如果能够实现从学校的学生管理系统直接导入数据的功能,不仅可以避免上述问题,还能简化学生的登录过程------他们只需使用学号等已有的信息即可快速登录平台,享受便捷的服务。这种方式既提高了验证学生身份的准确性,也提升了整体用户体验,实现了双赢的局面。因此,开发这一功能具有显著的实际意义和应用价值。
FastExcel简介
POI 是 Apache 软件基金会下的一个项目,提供了用于操作各种文档格式的Java API,包括Microsoft Office中的Word、Excel和PowerPoint等格式。特别是对于Excel文件的操作,Apache POI提供了一套完整的解决方案,允许开发者读取、创建和修改Excel文件。然而,随着数据量的增长以及对处理效率要求的提高,使用POI操作Excel文件时遇到了性能瓶颈,尤其是在处理大文件时,内存消耗过大导致效率低下,甚至导致内存溢出。
在这种背景下,EasyExcel应运而生。它是由阿里巴巴开源的一个基于Java的数据处理库,专门用来简化Excel文件的读写操作。EasyExcel采用了不同于POI的实现方式,通过按行读取Excel文件而不是一次性加载整个文件到内存中,大大降低了内存占用,提高了处理效率。此外,EasyExcel还支持多种便捷的功能,如自定义转换器、注解式编程模型等,使得开发者能够更高效地进行Excel文件的操作。
EasyExcel的强大之处主要体现在以下几个方面:
- 低内存消耗:EasyExcel采用流式读取Excel文件的方式,避免了将整个文件加载到内存中,极大地减少了内存占用。
- 高效率:由于其优化的内部机制,无论是读取还是写入Excel文件,都比传统的POI方式更快。
- 易用性:提供了简洁明了的API接口,并支持通过注解配置Excel与Java对象之间的映射关系,极大地方便了开发者的使用。
- 灵活性:支持自定义转换器,可以根据需要灵活处理不同类型的数据转换问题。
- 扩展性:易于集成到现有的工程中,并且支持进一步的定制化开发,满足不同场景下的需求。
然而,这么优秀的项目却停止维护了
但是问题不大,EasyExcel 的原作者开了新的项目 FastExcel ,原本使用了 EasyExcel 的项目,可以平滑过渡到这个项目中,只需要修改一下 Maven 依赖导入和导包路径,即可接着使用。为啥使用 FastExcel ?
- 原作者通过对底层算法的优化和内存管理的改进,让 FastExcel 的性能更高。
- 相较于 EasyExcel ,FastExcel 还将持续更新,未来将提供更多的功能。
- 此外,FastExcel 免费开源,真心感谢大佬造福人类。
感兴趣的朋友可以去关注大佬的微信公众号
FastExcel项目说明:https://mp.weixin.qq.com/s/XMXLEFaTjaU7QVnSYRFC_A
项目开源仓库地址:https://github.com/CodePhiliaX/fastexcel
用户数据生成
使用 Faker 库虚构数据
Faker 是一个方便的Java库,用于生成各种虚构数据,如姓名、地址、公司信息等,非常适合测试和开发时填充数据库或模拟真实数据场景。通过简单的API调用,它能提供丰富多样的数据类型,并支持多语言和地区设置,帮助开发者高效地创建贴近实际的测试案例,而无需手动编写虚假数据。
依赖
xml
<!-- javafaker 虚构数据 -->
<dependency>
<groupId>com.github.javafaker</groupId>
<artifactId>javafaker</artifactId>
<version>1.0.2</version>
</dependency>
使用
使用方式非常简单,new一个 faker 对象出来之后,直接调用相关方法即可
java
Faker faker = new Faker(Locale.CHINA);
faker.number().digits(10)
FastExcel 写出 excel 表格
依赖
xml
<dependency>
<groupId>cn.idev.excel</groupId>
<artifactId>fastexcel</artifactId>
<version>1.0.0</version>
</dependency>
实现
首先声明一个实体类,来接收 faker 生成的数据,同时这个实体类也是 FastExcel 的输入
java
import cn.idev.excel.annotation.ExcelProperty;
import cn.idev.excel.annotation.write.style.ColumnWidth;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* @Author dam
* @create 2025/1/12 15:14
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ExcelUserData {
// 设置相关列的宽度
@ColumnWidth(30)
// 设置相关列的名称
@ExcelProperty("用户名")
private String userName;
@ExcelProperty("昵称")
private String nickName;
@ColumnWidth(20)
@ExcelProperty("手机号")
private String phoneNumber;
@ColumnWidth(30)
@ExcelProperty("邮箱")
private String email;
@ColumnWidth(10)
@ExcelProperty("性别")
private int gender;
@ColumnWidth(20)
@ExcelProperty("密码")
private String password;
}
生成数据,并导出 excel 表格
java
package com.vrs;
import cn.hutool.core.io.FileUtil;
import cn.idev.excel.EasyExcel;
import cn.idev.excel.util.ListUtils;
import com.github.javafaker.Faker;
import com.vrs.common.entity.ExcelUserData;
import org.junit.Test;
import java.io.File;
import java.nio.file.Paths;
import java.util.List;
import java.util.Locale;
import java.util.Random;
/**
* 模拟用户数据生成
*
* @Author dam
* @create 2025/1/12 15:06
*/
public class UserDataGenerateTest {
/**
* 用户数量
*/
private final int userNum = 10000;
private final Faker faker = new Faker(Locale.CHINA);
/**
* excel地址
*/
private final String excelPath = Paths.get("").toAbsolutePath().getParent().getParent() + File.separator + "tmp";
@Test
public void generate() {
if (!FileUtil.exist(excelPath)) {
FileUtil.mkdir(excelPath);
}
// 数据生成
List<ExcelUserData> list = ListUtils.newArrayList();
for (int i = 0; i < userNum; i++) {
ExcelUserData data = ExcelUserData.builder()
// 随机生成10位数字,并拼接成邮箱
.email(faker.number().digits(10) + "@qq.com")
// 生成一个随机的电话号码
.phoneNumber(faker.phoneNumber().cellPhone())
// 生成一个随机的用户名,使用 faker 库的 regexify 方法生成 3 到 10 个字母组成的字符串
.userName(faker.regexify("[a-zA-Z]{3,10}"))
// 生成一个随机的昵称,使用 faker 库的 name 方法生成一个名字作为昵称
.nickName(faker.name().firstName())
// 示例性别选项
.gender(faker.options().option(0, 1))
// 生成随机密码
.password(faker.internet().password())
.build();
list.add(data);
}
// 输出Excel
String fileName = excelPath + File.separator + "机构用户生成.xlsx";
EasyExcel.write(fileName, ExcelUserData.class)
.sheet("用户表")
.doWrite(list);
}
}
不如得感叹,这也太方便了,一句代码就完成了导出
java
EasyExcel.write(fileName, ExcelUserGenerateData.class)
.sheet("用户表")
.doWrite(list);
导出之后的表格如下
用户数据导入
FastExcel 读入 excel 表格
监听器
监听器需要继承 AnalysisEventListener ,泛型指定为 ExcelUserData 之后,读取的时候会将每一行数据转化为 ExcelUserData 对象
java
import cn.idev.excel.context.AnalysisContext;
import cn.idev.excel.event.AnalysisEventListener;
import com.vrs.common.entity.ExcelUserData;
import lombok.Getter;
/**
* @Author dam
* @create 2025/1/12 17:05
*/
public class UserDataRowListener extends AnalysisEventListener<ExcelUserData> {
/**
* 处理每一行数据
* @param excelUserData
* @param analysisContext
*/
@Override
public void invoke(ExcelUserData excelUserData, AnalysisContext analysisContext) {
}
/**
* 所有数据解析完成之后的操作
* @param analysisContext
*/
@Override
public void doAfterAllAnalysed(AnalysisContext analysisContext) {
}
}
进行读取
java
UserDataRowListener listener = new UserDataRowListener();
EasyExcel.read(importUserExcelMqDTO.getExcelPath(), ExcelUserData.class, listener).sheet().doRead();
Controller
java
/**
* 上传excel表,并解析导入机构用户数据
*/
@Operation(summary = "机构数据excel导入")
@PostMapping("/importUserExcel")
public Result importUserExcel(MultipartFile file) throws Exception {
userService.importUserExcel(file);
return Results.success();
}
Service
注意,为了提高接口的效率,使用消息队列异步解析 excel
java
@Override
public void importUserExcel(MultipartFile file) {
/// 数据校验
// 校验用户是否有绑定机构
Long organizationId = UserContext.getOrganizationId();
if (organizationId == null) {
throw new ClientException(BaseErrorCode.USER_NOT_SET_ORGANIZATION_ERROR);
}
// 校验用户权限,是否为机构管理员
Integer userType = UserContext.getUserType();
UserTypeConstant.validateInstituteManager(userType);
// 文件类型校验
String absPath;
String name = file.getOriginalFilename();
if (!name.contains(".")) {
// --if-- 如果图片没有正常后缀
throw new ClientException(BaseErrorCode.NO_SUFFIX_ERROR);
} else if (name.endsWith(".xlsx")) {
absPath = EXCEL_TEMP_PATH + File.separator + UUID.randomUUID() + ".xlsx";
} else if (name.endsWith(".xls")) {
absPath = EXCEL_TEMP_PATH + File.separator + UUID.randomUUID() + ".xls";
} else {
throw new ClientException(BaseErrorCode.EXCEL_TYPE_ERROR);
}
/// 暂存文件到服务器本地
// 如果不存在目录,创建目录
if (!FileUtil.exist(EXCEL_TEMP_PATH)) {
FileUtil.mkdir(EXCEL_TEMP_PATH);
}
// 将输入流中的数据复制到目标文件中
try (InputStream is = file.getInputStream()) {
File targetFile = new File(absPath);
// 将输入流中的数据复制到目标文件中
java.nio.file.Files.copy(is, targetFile.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
e.printStackTrace();
throw new ServiceException(e.getMessage());
}
/// 发送消息,执行excel数据解析并将用户数据导入数据库
SendResult sendResult = importUserExcelProducer.sendMessage(ImportUserExcelMqDTO.builder()
.organizationId(UserContext.getOrganizationId())
.excelPath(absPath)
.build());
if (!sendResult.getSendStatus().equals(SendStatus.SEND_OK)) {
log.error("消息发送失败: " + sendResult.getSendStatus());
throw new ServiceException(BaseErrorCode.MQ_SEND_ERROR);
}
// todo 记录导入任务到数据库中
}
【excel读取与数据导入】
java
/**
* 正式执行excel数据解析与导入
*
* @param importUserExcelMqDTO
*/
@Override
public void handleImportUserExcel(ImportUserExcelMqDTO importUserExcelMqDTO) {
OrganizationDO organizationDO = organizationService.getById(importUserExcelMqDTO.getOrganizationId());
UserDataRowListener listener = new UserDataRowListener(this, importUserExcelMqDTO.getOrganizationId(), organizationDO.getMark());
EasyExcel.read(importUserExcelMqDTO.getExcelPath(), ExcelUserData.class, listener).sheet().doRead();
// 删除暂存的 excel 文件
FileUtil.del(importUserExcelMqDTO.getExcelPath());
}
【读取行监听器】
注意:在解析导入用户数据的时候,需要保证导入的效率,又要保证数据尽量都插入成功,如果有的数据插入不成功,需要向管理员反馈,哪些数据导入失败。
为什么会出现导入失败?
username
冲突:有可能用户一开始已经将部分用户导入了平台中,后面机构有了新用户,例如学校每年有新学生,此时再导入,会出现 username 重复插入的现象。为了处理这个情况,本文通过使用INSERT IGNORE
语句来处理,即如果想数据库中插入一批数据,对于之前还没有插入过的数据,正常执行插入。对于已经插入过的数据,则跳过插入,因为这些用户可能已经使用了平台一段时间- 部分字段缺失:可能存在部分数据条目,如用户名等关键字段缺失
- 数据类型不匹配:如字符串填入到整型字段中
- 违反非空约束:部分必填的字段为空
- 数据库连接超时
- 数据库性能负载过高
- 网络中断
- 内存、磁盘资源不足
- 数据库服务异常
这段代码的作用是读取 Excel 文件里的用户数据,然后把这些数据存到数据库里。为了提高效率,它会先尝试一批一批地插入数据(比如每次插 1000 条)。如果某批数据插入失败了,它会把这批数据拆成更小的批次(比如每次插 250 条)再试一次。如果拆到最小批次(比如 50 条)还是失败,就改成一条一条地插入。最后,如果还有插不进去的数据,就把这些失败的数据保存到一个新的 Excel 文件里,方便以后查看和处理。整个过程既保证了速度,又确保了数据不会丢失。
java
package com.vrs.service.excel;
import cn.idev.excel.EasyExcel;
import cn.idev.excel.context.AnalysisContext;
import cn.idev.excel.event.AnalysisEventListener;
import com.vrs.common.entity.ExcelUserData;
import com.vrs.domain.entity.UserDO;
import com.vrs.service.UserService;
import com.vrs.utils.SnowflakeIdUtil;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import java.io.File;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.UUID;
/**
* @Author dam
* @create 2025/1/12 17:05
*/
@Slf4j
public class UserDataRowListener extends AnalysisEventListener<ExcelUserData> {
private UserService userService;
private Long organizationId;
private String organizationMark;
public UserDataRowListener(UserService userService, Long organizationId, String organizationMark) {
this.userService = userService;
this.organizationId = organizationId;
this.organizationMark = organizationMark;
}
/**
* excel表中的数据行数
*/
@Getter
private int rowCount = 0;
/**
* 一批的数据量
*/
private final int BATCH_SIZE = 1000;
/**
* 最小批次大小
*/
private final int MIN_BATCH_SIZE = 50;
public static final String EXCEL_TEMP_PATH = System.getProperty("user.dir") + File.separator + "tmp" + File.separator + "excel";
/**
* 数据插入缓冲区,满了就存储到数据库中
*/
private List<UserDO> userDOBuffer = new ArrayList<>();
/**
* 记录插入失败的数据
*/
private List<UserDO> failedData = new ArrayList<>();
/**
* 处理每一行数据
*
* @param excelUserData
* @param analysisContext
*/
@Override
public void invoke(ExcelUserData excelUserData, AnalysisContext analysisContext) {
UserDO userDO = new UserDO();
BeanUtils.copyProperties(excelUserData, userDO);
userDO.setOrganizationId(organizationId);
userDO.setUserName(organizationMark + "_" + userDO.getUserName());
userDO.setId(SnowflakeIdUtil.nextId());
userDO.setCreateTime(new Date());
userDOBuffer.add(userDO);
if (userDOBuffer.size() >= BATCH_SIZE) {
processBatch(userDOBuffer, BATCH_SIZE);
userDOBuffer.clear();
}
}
/**
* 所有数据解析完成之后的操作
*
* @param analysisContext
*/
@Override
public void doAfterAllAnalysed(AnalysisContext analysisContext) {
if (userDOBuffer.size() > 0) {
processBatch(userDOBuffer, BATCH_SIZE);
userDOBuffer.clear();
}
log.info("-------------------------- 机构用户全部导入完毕 ----------------------------");
if (failedData.size() > 0) {
String fileName = EXCEL_TEMP_PATH + File.separator + "fail " + UUID.randomUUID() + ".xlsx";
// todo 如果错误的数据很多的话,之类需要优化,否则占用内存较高
EasyExcel.write(fileName, ExcelUserData.class)
.sheet("导入失败用户")
.doWrite(failedData);
// todo 导出excel之后,将excel请求路径存在到数据库中,供后续被管理员下载
}
// todo 修改数据库中的导入任务状态为成功或部分失败
}
/**
* 处理批次数据
*
* @param batch 当前批次数据
* @param batchSize 当前批次大小
*/
private void processBatch(List<UserDO> batch, int batchSize) {
try {
// 尝试批量插入
userService.saveBatchIgnore(batch);
} catch (Exception e) {
log.error("批量插入失败,当前批次大小:{},尝试拆分批次", batchSize, e);
if (batchSize > MIN_BATCH_SIZE) {
// 拆分批次为更小的批次
int newBatchSize = batchSize / 4;
List<List<UserDO>> smallerBatches = splitBatch(batch, newBatchSize);
for (List<UserDO> smallerBatch : smallerBatches) {
processBatch(smallerBatch, newBatchSize); // 递归处理更小的批次
}
} else {
// 如果批次已经缩小到最小批次,逐条插入
processSingleBatch(batch);
}
}
}
/**
* 将批次拆分为更小的批次
*
* @param batch 原始批次
* @param newBatchSize 新批次大小
* @return 拆分后的批次列表
*/
private List<List<UserDO>> splitBatch(List<UserDO> batch, int newBatchSize) {
List<List<UserDO>> smallerBatches = new ArrayList<>();
for (int i = 0; i < batch.size(); i += newBatchSize) {
int end = Math.min(i + newBatchSize, batch.size());
smallerBatches.add(batch.subList(i, end));
}
return smallerBatches;
}
/**
* 逐条插入数据
*
* @param batch 当前批次数据
*/
private void processSingleBatch(List<UserDO> batch) {
for (UserDO userDO : batch) {
try {
// 逐条插入
userService.save(userDO);
} catch (Exception e) {
log.error("单条插入失败,失败数据:{}", userDO, e);
// 记录失败数据
failedData.add(userDO);
}
}
}
}
MQ
【生产者】
java
import cn.hutool.core.util.StrUtil;
import com.vrs.constant.RocketMqConstant;
import com.vrs.domain.dto.mq.ImportUserExcelMqDTO;
import com.vrs.templateMethod.AbstractCommonSendProduceTemplate;
import com.vrs.templateMethod.BaseSendExtendDTO;
import com.vrs.templateMethod.MessageWrapper;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.common.message.MessageConst;
import org.springframework.messaging.Message;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.stereotype.Component;
import java.util.UUID;
/**
* 机构用户数据导入 生产者
*
* @Author dam
* @create 2024/9/20 16:00
*/
@Slf4j
@Component
public class ImportUserExcelProducer extends AbstractCommonSendProduceTemplate<ImportUserExcelMqDTO> {
@Override
protected BaseSendExtendDTO buildBaseSendExtendParam(ImportUserExcelMqDTO messageSendEvent) {
return BaseSendExtendDTO.builder()
.eventName("机构用户数据导入")
.keys(String.valueOf(messageSendEvent.getOrganizationId()))
.topic(RocketMqConstant.ADMIN_TOPIC)
.tag(RocketMqConstant.IMPORT_USER_EXCEL_TAG)
.sentTimeout(2000L)
.build();
}
@Override
protected Message<?> buildMessage(ImportUserExcelMqDTO messageSendEvent, BaseSendExtendDTO requestParam) {
String keys = StrUtil.isEmpty(requestParam.getKeys()) ? UUID.randomUUID().toString() : requestParam.getKeys();
return MessageBuilder
.withPayload(new MessageWrapper(keys, messageSendEvent))
.setHeader(MessageConst.PROPERTY_KEYS, keys)
.setHeader(MessageConst.PROPERTY_TAGS, requestParam.getTag())
.build();
}
}
【消费者】
java
package com.vrs.rocketMq.listener;
import com.vrs.annotation.Idempotent;
import com.vrs.constant.RocketMqConstant;
import com.vrs.domain.dto.mq.ImportUserExcelMqDTO;
import com.vrs.enums.IdempotentSceneEnum;
import com.vrs.service.UserService;
import com.vrs.templateMethod.MessageWrapper;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.spring.annotation.MessageModel;
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.annotation.SelectorType;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.springframework.stereotype.Component;
/**
* @Author dam
* @create 2024/9/20 21:30
*/
@Slf4j(topic = RocketMqConstant.VENUE_TOPIC)
@Component
@RocketMQMessageListener(topic = RocketMqConstant.ADMIN_TOPIC,
consumerGroup = RocketMqConstant.ADMIN_CONSUMER_GROUP + "-" + RocketMqConstant.IMPORT_USER_EXCEL_TAG,
messageModel = MessageModel.CLUSTERING,
// 监听tag
selectorType = SelectorType.TAG,
selectorExpression = RocketMqConstant.IMPORT_USER_EXCEL_TAG
)
@RequiredArgsConstructor
public class ImportUserExcelListener implements RocketMQListener<MessageWrapper<ImportUserExcelMqDTO>> {
private final UserService userService;
/**
* 消费消息的方法
* 方法报错就会拒收消息
*
* @param messageWrapper 消息内容,类型和上面的泛型一致。如果泛型指定了固定的类型,消息体就是我们的参数
*/
@Idempotent(
uniqueKeyPrefix = "import_user_excel:",
key = "#messageWrapper.getMessage().getOrganizationId()+''",
scene = IdempotentSceneEnum.MQ,
keyTimeout = 3600L
)
@SneakyThrows
@Override
public void onMessage(MessageWrapper<ImportUserExcelMqDTO> messageWrapper) {
// 开头打印日志,平常可 Debug 看任务参数,线上可报平安(比如消息是否消费,重新投递时获取参数等)
log.info("[消费者] 机构用户数据导入,机构ID:{}", messageWrapper.getMessage().getOrganizationId());
userService.handleImportUserExcel(messageWrapper.getMessage());
}
}
SQL
这段SQL是一个用于批量插入数据的语句,具体来说是使用INSERT IGNORE
语法向名为user
的表中插入多条记录。如果遇到主键冲突或者唯一键冲突,它不会插入那条特定的记录,但会继续尝试插入其他记录,而不是直接中断整个操作。
xml
<insert id="saveBatchIgnore">
INSERT IGNORE INTO user (
id, user_name, nick_name, user_type, email, phone_number, gender, avatar,
avatar_type, password, status, login_ip, login_date, point, organization_id,
create_time, update_time
) VALUES
<foreach collection="userDOBatch" item="userDO" separator=",">
(
#{userDO.id}, #{userDO.userName}, #{userDO.nickName}, #{userDO.userType},
#{userDO.email}, #{userDO.phoneNumber}, #{userDO.gender}, #{userDO.avatar},
#{userDO.avatarType}, #{userDO.password}, #{userDO.status}, #{userDO.loginIp},
#{userDO.loginDate}, #{userDO.point}, #{userDO.organizationId},
#{userDO.createTime}, #{userDO.updateTime}
)
</foreach>
</insert>