乐趣区

导入图片引发出的对图片、视频、文档等上传的思考

导读
我们在开发的过程中,经常会遇到导入和导出:

从哪里导入到哪里?我们在客户端选择上传 Excel 文件,同时调用服务端的某个接口。服务端通过 HttpServletRequest 获取 Excel 的数据流,通过 poi 的相关操作获取单元格的数值,并填充到相应的 javabean 的实例化对象中。再调用事务的保存方法,利用 hibernate 框架或 mybatis 框架,将对象数据保存到数据库中。
从哪里导出到哪里?从服务器上的数据库的数据导出到本地,并以 Excel 文件的方式存储。我们在客户端选择导出 Excel 文件,同时调用服务端的某个接口。服务端通过 HttpServletResponse 响应客户端的请求,同时,调用事务层的查询方法,拿到待导出的数据源,过滤我们想要的数据。调用 poi 的相关操作,将数据填充到 Excel 表中。

导入
这里以导入材料为主,材料中存有图片,如图所示:

你会发现,Excel 表中存储的是图片在服务器上的路径,为什么存储的是图片在服务器的路径,而不是图片的字节码数据?
我们都知道任何文件都可以按照字节码的方式存储,比如视频文件、音乐文件、图片文件、GIF 文件、文本框文件等。但是字节码的存储和读取都占用内存,如果在大批量的导入和导出的情况下,势必会占用 JVM 内存,造成资源阻塞。
因而,我们存储的是图片的路径,这还不是随随便便的路径,而是其所在服务器的路径。为什么选择路径。从上图中的图片路径来看,路径的字符比较短。占用的内存比较少,存储和读取相对来说快。因而,我们读入的图片的路径。比如上图中 ENGINE_PLATFORM1TENANTTHUMBNAILTENANT-LOGO_1_1534415695498_1.jpg 的图片:

其所对应的服务器的图片地址是 http://cw.rosunn.com/upload/i…。因而,我们只要在数据库中存储 /ENGINE_PLATFORM/1/TENANT/THUMBNAIL/TENANT-LOGO_1_1534415695498_1.jpg 这部分路径就可以的。我们的域名前缀 http://cw.rosunn.com/ 是固定的,其所对应的图片的文件夹 upload 是固定的,该文件夹下有很多的图片文件夹。每个图片的文件夹都是不一样的。如图所示:

因为我们是 Windows 服务器,所以服务器是 Windows 界面化操作。其实,一台电脑就是一台服务器,要不然,怎么说本地服务器呢?在该文件夹下,有三个子文件夹。

attach 文件夹,存储与附件相关的文件夹。
image 文件夹,存储图片的文件夹
uEditor 文件夹,前端会使用 uEditor 框架,这是多文本编辑器,可以上传图片、视频等。

以后,可能会有视频文件夹,如果做教学软件的话。不管是存储图片文件和附件文件,还是视频文件,我们在数据库中都只存储该文件路径。当我们从数据库中读出图片到前台页面,我门只要拿到其存储的路径,并在前端做如下配置即可:
http:// 域名 /upload/ 图片在数据 …
明白这一点,我们就好往下进行,当我们点击前端代码的导入按钮时,如图所示:

其首先会进入到拦截器,然后再进入到服务器的三层架构中。

服务器的三层架构
我们常说服务器有三层架构,即 dao 层,service 层,controller 层。实际上这是个通俗的概念,然而,在真正的开发过程中,并非只有三层架构,其中还会有拦截器的概念。如果你用 servlet 开发,会涉及到过滤器。拦截器和过滤器的功能是一样的,只不过用法是不一样的。它俩到底有什么区别,我想网上的博客非常多。这里就不在细说了。也许,你可以参考这篇博客:拦截器(Interceptor)和过滤器(Filter)的执行顺序和区别
一般项目启动后,首先进入的不是 controller 层,而是拦截器,controller 层只是针对接口而言的。
拦截器,听名知其意,主要做数据的过滤和拦截。对于数据库中不时常改变的数据,比如系统变量和数据字典等,我们可以放到拦截器的缓存中,当我们加载数据字典时,不必再从数据库中读取,而是读取缓存的数据字典。这样,减少了与数据库的连接,从而提升了效率。
曾经在实习时,有个老大教我,说影响服务端的效率一般是 db 操作、网络调用操作、线程、JVM 优化等。至于,我们是用 ++i,还是 i ++,哪个效率高一点。当然,是 ++ i 效率高一点。i++ 内部会有一个临时变量,其存储的 i 改变前值,然后再执行 i = i + 1,返回的也是临时变量。++ i 直接执行的是 i = i + 1,并返回改变后的值。但是,不会考虑到这个问题,因为它的影响微乎其微。
同时,我们每打开一个页面,都要经过拦截器,有些页面需要登录才能看,有些页面可以不用登录。这就是拦截器的作用。
我们这个项目使用的是 Apache Shiro 框架。其是一个强大且易用的 Java 安全框架,执行身份验证、授权、密码和会话管理的拦截器框架。
除了,我们登录后台需要身份验证,需要 shiro 的拦截。或者,我们调用第三方支付接口,其要回调我们的接口。但是,我们对每个接口,都要进行拦截,防止其恶意攻击。此时,我们需要忽略第三方回调的我们的接口,也就是说,这个接口不在我们的拦截范围之内,如下代码:
备注,因为涉及到隐私,部分代码省略,或以 ** 代替,望请见谅。
<!– 基于 url+ 角色的身份验证过滤器 –>
<bean id=”urlAuthFilter” class=”com.**.UrlAuthFilter”>
<property name=”ignoreCheckUriList”>
<list>
。。。
<value>/manager/**/lianPayFn</value>
<!– 个人开户回调接口 –>
<value>/manager/**/openAccountFn</value>
<!– 实名认证回调接口 –>
<value>/manager/**/walletFn</value>
<!– 提现回调接口 –>
<value>/manager/**/withdrawFn</value>
<!– 充值回调接口 –>
<value>/manager/**/rechargeFn</value>
。。。
</list>
</property>
</bean>
个人开户回调接口对应的 controller 层的接口为:
/**
* Created By zby on 11:14 2019/3/11
* 钱包管理:
* 0 支付密码修改
* 1 绑定手机号码
* 2 基本信息修改
* 3 绑定银行卡修改
* 4 收支明细查询
* 5 实名认证
*/
@RequestMapping(value = “/wallet”, method = RequestMethod.GET)
public Result wallet(String flagPara) {
CommonUtil.requiredCheck(flagPara);
JSONObject body = new JSONObject();
body.put(“version”, “1.2”);
body.put(“oid_partner”,”1212121212″);
body.put(“timestamp”, DateUtil.ISO_DATETIME_FORMAT_NONE.format(new Date()));
body.put(“sign_type”, “RSA”);
body.put(“userreq_ip”, “127.0.0.1”);
body.put(“user_id”, “test001”);
body.put(“flag_para”, flagPara);
body.put(“url_return”, “http://**/returnPage.html”);
// 仅支持企业的字段:name_unit,notify_url
body.put(“name_unit”, “ceshigongsi”);
body.put(“notify_url”, “http://**/walletFn”);
body.put(“sign”, SignUtil.genRSASign(body));
return ResultUtil.buildSuccess(body);
}

/**
* Created By zby on 11:14 2019/3/11
* 实名认证。绑定银行卡等
*/
@RequestMapping(value = “/walletFn”, method = RequestMethod.POST)
public Result walletFn(HttpServletRequest request) {
String str = CommonUtil.parseInputStream2String(request);
JSONObject json = JSONObject.parseObject(str);
logger.info(json.getString(“user_id”) + “ 钱包管理回调信息:” + json);
return ResultUtil.buildSuccess(json);
}
wallet 签名方法中调用连连的修改银行卡的接口,并向其传递我们的回调接口。如果修改成功,其会回调我们的 /walletFn 接口。这个接口,我们就不需要拦截,而是任其调用的。
当我们的前端请求接口通过了拦截,然后其会进入我们的 controller 层,查找到我们的导入材料的接口。使用该方法 List<JSONObject> jsonObjectList = PoiUtil.importSimpleExcel(request, 1, “P”);,拿到 Excel 表中每行数据,每行数据都是一个 JSONObject 对象。因而,返回的是 JSONObject 集合。这个,会在下面讲到。
/**
* Created By zby on 17:35 2019/2/20
* 导入
*/
@RequestMapping(value = “/import”, method = RequestMethod.POST)
public Result importMaterials(HttpServletRequest request) {
JSONObject body = new JSONObject();
int totalNum = 0, successNum = 0;
synchronized (this) {
try {
List<JSONObject> jsonObjectList = PoiUtil.importSimpleExcel(request, 1, “P”);
if (null != jsonObjectList || jsonObjectList.size() > 0) {
totalNum = jsonObjectList.size();
for (JSONObject json : jsonObjectList) {
Long materialId = CommonUtil.getExcelLongVal(json.getBigDecimal(“A”));
Material dbMaterial = null;
//【1】当项目存在时,更新项目及附属表属性, 否则,就保存新对象
if (isNotNull(materialId) && materialId > 0) {
dbMaterial = materialService.get(materialId).getResultData();
if (null == dbMaterial) {
dbMaterial = new Material();
dbMaterial.setId(materialId);
}
}
if (null == materialId || materialId <= 0) {
dbMaterial = new Material();
}
// 所属品类的 id
Long categoryId = CommonUtil.getExcelLongVal(json.getBigDecimal(“B”));
if (isNotNull(categoryId) && categoryId > 0) {
dbMaterial.setMaterialCategory(materialCategoryService.get(categoryId).getResultData());
}
// 供应商的 id
Long supplierId = CommonUtil.getExcelLongVal(json.getBigDecimal(“C”));
if (isNotNull(supplierId) && supplierId > 0) {
dbMaterial.setSupplier(supplierService.get(supplierId).getResultData());
}
dbMaterial.setMaterialName(json.getString(“D”));
dbMaterial.setVersion(json.getString(“E”));
dbMaterial.setBrand(json.getString(“F”));
dbMaterial.setChroma(json.getString(“G”));
dbMaterial.setSpecifications(json.getString(“H”));
// 单位
String unitValue = json.getString(“I”);
if (isNotNull(unitValue)) {
List<DataDict> units = dataDictService.getDataDictList(“unit”).getResultData();
if (null != units && units.size() > 0) {
for (DataDict unit : units) {
if (unit.getValue().equals(unitValue)) {
dbMaterial.setUnit(unit);
break;
}
}
}
}
dbMaterial.setRetailPrice(json.getBigDecimal(“J”));
dbMaterial.setCostPrice(json.getBigDecimal(“K”));
dbMaterial.setStock(json.getBigDecimal(“L”));
// 状态
String state = json.getString(“M”);
if (StringUtils.isNotBlank(state)) {
for (MaterialStateEnum stateEnum : MaterialStateEnum.class.getEnumConstants()) {
if (stateEnum.getTitle().equals(state)) {
dbMaterial.setStatus(stateEnum);
break;
}
}
}
// 系列
String tags = json.getString(“N”);
if (isNotNull(tags)) {
String[] tagArr = StringUtils.split(tags, “,”);
List<DataDict> tagDicts = new ArrayList<>();
for (String tag : tagArr) {
tagDicts.add(dataDictService.valueToDictObject(“material_tag”, tag));
}
dbMaterial.setTagList(tagDicts);
}
// 图片,输入格式为:/image/system_engine/1/TENANT/ORG/TENANT-LOGO_1_1509329787954_1.jpg
String imgNames = json.getString(“O”);
if (isNotNull(imgNames)) {
String imgUrl = CommonUtil.getTomcatRootPath() + “/upload/”;
String[] imgUrlSuffixes = imgNames.split(“,”);
List<Picture> pictureList = new ArrayList<>();
for (String imgUrlSuffix : imgUrlSuffixes) {
File file = new File(imgUrl + imgUrlSuffix);
if (file.exists()) {
Picture picture = new Picture();
picture.setRemoteRelativeUrl(“/upload/” + imgNames);
picture.setName(imgNames.substring(imgNames.lastIndexOf(“/”), imgNames.length()));
pictureList.add(pictureService.save(picture).getResultData());
}
}
dbMaterial.setPictureList(pictureList);
}
dbMaterial.setNote(json.getString(“P”));
materialService.saveUpdateMaterialAccount(dbMaterial);
successNum++;
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
body.put(“totalNum”, totalNum);
body.put(“successNum”, successNum);
body.put(“errNum”, totalNum – successNum);
return ResultUtil.buildSuccess(body);
}
我们从中调用了保存图片的这段代码:
// 图片,excel 的图片格式为:/image/system_engine/1/TENANT/ORG/TENANT-LOGO_1_1509329787954_1.jpg
String imgNames = json.getString(“O”);
if (isNotNull(imgNames)) {
String imgUrl = CommonUtil.getTomcatRootPath() + “/upload/”;
String[] imgUrlSuffixes = imgNames.split(“,”);
List<Picture> pictureList = new ArrayList<>();
for (String imgUrlSuffix : imgUrlSuffixes) {
File file = new File(imgUrl + imgUrlSuffix);
if (file.exists()) {
Picture picture = new Picture();
picture.setRemoteRelativeUrl(“/upload/” + imgNames);
picture.setName(imgNames.substring(imgNames.lastIndexOf(“/”), imgNames.length()));
pictureList.add(pictureService.save(picture).getResultData());
}
}
dbMaterial.setPictureList(pictureList);
我们首先找到类路径下的字节码文件的的根路径,如代码所示:
/**
* Created By zby on 15:24 2019/3/21
* 通过 spring 自带的方法找到字节码 classes 包的路径,
* 再往上找三级目录,到 Tomcat 的 webApps 下目录,
* 当前目录加上 /upload 才是图片路径
*/
public static String getTomcatRootPath() {
File file = null;
String basePath = null;
try {
file = getFile(CLASSPATH_URL_PREFIX);
basePath = file.toPath().getParent().getParent().getParent().toString();
} catch (FileNotFoundException e) {
e.printStackTrace();
}
return basePath;
}
其返回一个文件对象,通过三次调用 getParent 获取 Tomcat 的根路径,在根路径下加上该路径名“upload”,封装成这样的存储方式 http:// 域名 /upload//image/sys…。如果其在服务器中存在,我们就创建图片的对象,图片的属性 remoteRelativeUrl,存储该路径的后半部分,也就是相对路径,并保存到数据库中,返回一个图片对象。并把图片对象放到集合中,然后保存到材料对象中。材料对象再保存到数据中,这就完成一次导入。但是 jsonObjectList 可能有多个对象,再遍历一次,直到遍历所有的对象。
如下是材料的 java 类:
@Data
@AllArgsConstructor
@NoArgsConstructor
@Entity
@Table(name = “zq_material”)
public class Material extends BaseObj {

/**
* 材料名称
*/
@Column(name = “material_name”)
private String materialName;

/**
* 单位
*/
@ManyToOne
@JoinColumn(name = “unit_code”)
private DataDict unit;

/**
* 零售价
*/
@Column(name = “retail_price”, precision = 12, scale = 2)
private BigDecimal retailPrice;

/**
* 状态
*/
@Enumerated(EnumType.STRING)
private MaterialStateEnum status;

/**
* 浏览量
*/
@Column(name = “page_view”)
private Long pageView;

/**
* 图片
*/
@ManyToMany(fetch = FetchType.EAGER)
@Fetch(FetchMode.SELECT)
@JoinTable(
name = “zq_material_pictures”,
joinColumns = {@JoinColumn(name = “zq_material_id”)},
inverseJoinColumns = @JoinColumn(name = “core_picture_id”)
)
@JSONField(serialize = false)
private List<Picture> pictureList = new ArrayList<>();

/**
* 材料标签
*/
@ManyToMany(fetch = FetchType.EAGER)
@Fetch(FetchMode.SELECT)
@JoinTable(
name = “zq_material_tag”,
joinColumns = {@JoinColumn(name = “zq_material_id”)},
inverseJoinColumns = @JoinColumn(name = “core_data_dict_code”)
)
@JSONField(serialize = false)
private List<DataDict> tagList = new ArrayList<>();

/**
* 备注
*/
@Column(name = “note”, columnDefinition = “longtext”)
private String note;

}

为何向上遍历三次
我们看一下字节码文件在服务器的位置,如图所示:

由图可知。一次次的向上遍历,只为找到根路径,也就是 http:// 域名。这是 Tomcat 的配置。然后再配置 upload 文件夹,即 http:// 域名 /upload

导入的执行效率
以上导入可以分为两种方式。一种是如果导入的数据中,但凡有一条数据不成功,所有的数据都无法导入。这就涉及到了事务一致性的问题。因而,我们需要放在事务层,也就是 service 层。为什么 spring 直到 service 层是事务层,这和我们的框架配置有关,把 service 层定义为事务层。如果某一条数据导入失败,并不影响其他数据的导入,我们可以放在 controller 层。
但是,如果处理的不当,便影响导入的执行效率。为什么这么说?比如,我们现在导入的是材料,材料有单位。单位放置在数据字典中,假设单位有 16 条数据,如图所示:

假如 jsonObjectList 的集合有 1000 条数据。我们每次遍历 jsonObjectList 集合,都要创建一次查询,也就是与数据库创建一次连接,保存之后再关掉连接,势必会减低导入效率:
// 单位
String unitValue = json.getString(“I”);
if (isNotNull(unitValue)) {
List<DataDict> units = dataDictService.getDataDictList(“unit”).getResultData();
if (null != units && units.size() > 0) {
for (DataDict unit : units) {
if (unit.getValue().equals(unitValue)) {
dbMaterial.setUnit(unit);
break;
}
}
}
}
我们从数据查找出当前单位的行数据,封装成我们想要的数据字典的对象。此时与数据库建立连接和释放数据库的连接,最多需要 16000 次,这势必会会增加服务器的资源,降低导入的执行效率。最少也需要 1000 次。
同时,系列也是来源于数据字典,然而,系列是以逗号分割的字符串,也就是说,我们需要将字符串分割成数组,再遍历这个数组,获取数据字典的对象,此时,最少语句数据库的连接数为 16000,最多就不大清楚了。因而,严重降低导入的效率。
我们为什么不采用最少的呢?因而,我们在遍历 jsonObjectList 之前,就从数据库中的加载出所有的单位的数据字典的集合,同时,也加载出系列的集合。放置在 map 的键值对当中,根据 key 值来取 value 值,如代码所示:
根据数据字典的父 code 值加载出所有的子 code 对象。
/**
* Created By zby on 14:12 2019/3/24
* 将 dict 封装成 map
*/
private Map<String, DataDict> dict2Map(String parentCode) {
Map<String, DataDict> dictMap = null;
if (StringUtils.isNotBlank(parentCode)) {
List<DataDict> units = dataDictService.getDataDictList(parentCode).getResultData();
dictMap = new HashMap<>();
if (!CollectionUtils.isEmpty(dictMap)) {
for (DataDict dict : units) {
dictMap.put(dict.getValue(), dict);
}
}
}
return dictMap;
}
对于上面的一串代码,我们省略其他的代码,只加载和数据字典相关的代码,于是乎,得到:
/**
* Created By zby on 17:35 2019/2/20
* 导入
*/
@RequestMapping(value = “/import”, method = RequestMethod.POST)
public Result importMaterials(HttpServletRequest request) {
JSONObject body = new JSONObject();
int totalNum = 0, successNum = 0;
// 单位
Map<String, DataDict> unitDict = dict2Map(“unit”);
// 系列
Map<String, DataDict> tagDict = dict2Map(“material_tag”);

synchronized (this) {
try {
List<JSONObject> jsonObjectList = PoiUtil.importSimpleExcel(request, 1, “P”);
if (null != jsonObjectList || jsonObjectList.size() > 0) {
totalNum = jsonObjectList.size();
for (JSONObject json : jsonObjectList) {
// 单位
String unitValue = json.getString(“I”);
if (StringUtils.isNotBlank(unitValue)) {
dbMaterial.setUnit(unitDict.get(unitValue));
}
// 系列
String tags = json.getString(“N”);
if (isNotNull(tags)) {
String[] tagArr = StringUtils.split(tags, “,”);
List<DataDict> tagDicts = new ArrayList<>();
for (String tag : tagArr) {
if (StringUtils.isNotBlank(tag)) {
tagDicts.add(tagDict.get(tag));
}
}
dbMaterial.setTagList(tagDicts);
}
materialService.saveUpdateMaterialAccount(dbMaterial);
successNum++;
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
body.put(“totalNum”, totalNum);
body.put(“successNum”, successNum);
body.put(“errNum”, totalNum – successNum);
return ResultUtil.buildSuccess(body);
}
这就是数据库导入优化,但是导入图片,和我们上传图片、视频、文档有关系吗?

图片、视频、附件上传
我们在做 java 开发时,势必会涉及到文件操作。我们一般会上传图片、文件、视频等,但它们以什么样的格式存储。正如上面提到的,我们上传图片、视频、附件等,会在服务器上创建一个文件夹,他们存储在该文件夹中。我们只要获取文件夹的相对路径即可,就能将其加载出来,这样比较节省数据库的资源。如图所示:

这一般都是异步上传,先将文件的路径以对象的保存到数据库中,再返回文件被保存后的带有主键 id 的对象。我们拿到持久态的文件对象后,在前端页面展示出来。因而,我们在保存材料时,前端只要向后端传输文件的 id,或者是文件.id 即可,比如 logo.id。spring 会自动创建该文件对象,并将 id 到注入文件对象中。

总结
我们在开发过程中,要知其然,知其所以然。

退出移动版