最近一直在鼓捣图片相关的代码,今天抽时间写篇总结。此文没有什么高深的知识点,不汲及第三方的OSS相关点,更不汲及分布式文件存储框架,算是一篇关于WEB项目中图片相关功能的扫盲文; 同时与大家分享码字时的心得。文章中的服务器开发语言使用的是java。文中代码汲及到一个工具子模块(util)在文章最后提供下载连接,放心不需要您有下载积分,防止资源若审核过不去同时提供百度网盘地址。
A. 客户端:
A1. html表单,客户端预览
有同事认为这像输入数字时调出数字键盘一样是高级功能。其实不然,现代浏览器提供多种方案可以实现,代码也不复杂。先看图:
以下代码使用FileReader生成图片的BASE64值:
<div class="form-group row">
<label for="imageAddr" class="col-12 col-sm-3 col-form-label text-sm-right">图标</label>
<div class="col-12 col-sm-8 col-lg-6" id="upload-section">
<label class="custom-control custom-checkbox">
<input type="text" class="form-control" value="" tabindex="2" readonly="readonly" id="inputfile-names"/>
<span class="form-text text-muted">可选项,不填写将使用默认值</span>
</label>
<label class="custom-control custom-checkbox">
<input type="file" name="file" id="inputfile" accept="image/*" multiple="multiple" data-multiple-caption="{count} 文件被选中"/>
<div class="images"><div class="pic">选择图片</div></div>
</label>
</div>
</div>
201491026增补
input type=file的accept属性
可以粗略的划分:accept=“image/*”, 也可用Content-type来细分: accept=“image/gif, image/jpeg”,还可以用扩展名过滤:accept=".gif,.jpeg,.png,.jpg"
需要注意不同浏览器对上面的三种情况的待遇也不同,但无一例外都有选择所有文件的选项,问题来了:怎么删除它或让不可用呢?
function uploadImage() {
var uploader = $('#inputfile');
var images = $('.images');
uploader.on('change', function () {
var reader = new FileReader();
reader.onload = function(event) {
images.prepend('<div class="img" style="background-image: url(\'' + event.target.result + '\');" rel="'+ event.target.result +'"><span>删除</span></div>');
images.find('.pic').hide();
};
reader.readAsDataURL(uploader[0].files[0]);
});
images.on('click', '.img', function () {
$(this).remove();
images.find('.pic').show();
});
}
图片BASE64更多讨论:how-to-convert-image-into-base64-string-using-javascript
A2. 富文本编辑,CKEditor中的图片上传
CKEditor的版本号:4.11.4; 若使用其它富文本编辑器请自行参阅官方文档。以下代码用一个函数来封装了CKEditor的实例
//v:4.11.4
function initCkEditor(config){
var options=$.extend({
textarea: 'content',
plugs: 'uploadimage',
width: 600,
height: 150,
UploadUrl: '/upload/ckeditor'},
config);
CKEDITOR.config.pasteFilter = null;
CKEDITOR.config.height = options.height;
CKEDITOR.config.width = options.width;
CKEDITOR.replace( options.textarea ,{
extraPlugins: options.plugs
});
if(options.plugs.indexOf('uploadimage') != -1){
CKEDITOR.config.filebrowserUploadUrl = options.UploadUrl+'?type=Files';
CKEDITOR.config.filebrowserImageUploadUrl = options.UploadUrl+'?type=Images';
}
};
这里提到CKEditor是因为后面的服务器端上传时需要。因为不同版本的CKEditor的图片上传响应格式不同。此版本的响应格式为:
{
"uploaded": 1,
"fileName": "foo(2).jpg",
"url": "/files/foo(2).jpg",
"error": {
"message": "A file with the same name already exists. The uploaded file was renamed to \"foo(2).jpg\"."
}
}
CKEditor的参考文档: Uploading Dropped or Pasted Files
B. 本地存储(服务器端)
如果项目中的图片不是很多。像一般企业CMS,本地存储也是可以的,具体情况具体对待。为了在需求变动时不改动代码,服务器端对图片的地址进行编码和解码,在数据库中存储的图片地址都是编码后的. 例 :<img src="image://xxx/yy.z"/>
。这种格式没有域名部分,没有具体的目录信息,只有图片的文件名(yy.z)及上一级目录的名称(xxx)。根据具体的配置信息浏览器中html的图片src解码为具体的可访问地址。
B1. 外部化上传需要的参数信息, 以下是资源文件中代码:
img.bucket.domain=http://www.test.com
img.bucket.upload.direct=imagestore
为了在代码中使用方便,而不是到处使用@Value.需要在spring bean工厂中实例一个单例的值对象,例:
<!-- 站内本地存储 -->
<bean id="imageStorageExecutor" class="com.apobates.forum.trident.fileupload.LocalImageStorage">
<constructor-arg name="imageBucketDomain" value="${img.bucket.domain}"/>
<constructor-arg name="uploadImageDirectName" value="${img.bucket.upload.direct}"/>
<constructor-arg name="localRootPath" value="C:\apache-tomcat-8.5.37\webapps\ROOT\"/>
</bean>
localRootPath值等于:servletContext.getRealPath("/");
。为了以后站外存储或第三方存储图片,ImageStorageExecutor设计成一个接口。相关代码如下:
public interface ImageStorageExecutor extends ImageStorage{
/**
* 存储图片
*
* @param file 图片文件
* @return 成功返回图片的访问连接
* @throws IOException
*/
Result<String> store(MultipartFile file) throws IOException;
}
Result是一个类似Optional的一个工具类,同时提供Empty,Success,Fail三种情况; 代码在工具子模块(util)中。 ImageStorage为一获取配置信息的接口:
public interface ImageStorage {
/**
* 图片存储的域名,例:http://x.com
*
* @return
*/
String imageBucketDomain();
/**
* 图片存储的目录名
*
* @return
*/
String uploadImageDirectName();
}
B2. spring mvc获取表单中的图片。并上传图片
formbean中的代码大致如下:
public class BoardForm extends ActionForm{
private String title;
private String imageAddr;
private MultipartFile file;
//ETC
public MultipartFile getFile() {
return file;
}
public void setFile(MultipartFile file) {
this.file = file;
}
public String getEncodeIcoAddr(ImageStorageExecutor executor){
return super.uploadAndEncodeFile(getFile(), executor);
}
}
方法getEncodeIcoAddr使用imageStorageExecutor(注入到控制器类中的)来获得上传后的图片访问地址并对其进行编码。具体上传工作ActionForm.uploadAndEncodeFile方法来作。代码如下:
public abstract class ActionForm implements Serializable{
private String record = "0";
private String token; // (message="错误代码:1051;抽像的操作标识丢失")
private String status = "0";
// 来源地址
// 只允许是本站地址,例:/x
private String refer;
private final static Logger logger = LoggerFactory.getLogger(ActionForm.class);
//ETC
/**
*
* @param file 上传的文件
* @param executor 上传的执行器
* @return
*/
protected String uploadAndEncodeFile(MultipartFile file, ImageStorageExecutor executor)throws FileUploadFailException{
Result<String> data = Result.empty();
try{
data = executor.store(file);
}catch(IOException e){
//只关心上传产生的错误
throw new FileUploadFailException(e.getMessage()); //只关心上传产生的错误
}
if(data.isFailure()){ //上传过程中的错误
throw new FileUploadFailException(data.failureValue().getMessage());
}
//
String defaultValue = "image://defat/ico/default_icon.png";
if(data.isEmpty()){
//新增返回默认值
//编辑返回null
return (isUpdate())?null:defaultValue;
}
//编码
ImagePathCoverter ipc = new ImagePathCoverter(data.successValue());
return ipc.encodeUploadImageFilePath(executor.imageBucketDomain(), executor.uploadImageDirectName()).getOrElse(defaultValue);
}
}
ImagePathCoverter类的构造参数即为图片的可访问地址,接下来上传工作交给ImageStorageExecutor接口的实现类:LocalImageStorage,代码如下:
public class LocalImageStorage implements ImageStorageExecutor{
private final String imageBucketDomain;
private final String uploadImageDirectName;
//servletContext.getRealPath("/")
private final String localRootPath;
/**
*
* @param imageBucketDomain 本站的域名,例:http://x.com
* @param uploadImageDirectName 存储图片的目录名称
* @param localRootPath servletContext.getRealPath("/")的结果
*/
public LocalImageStorage(String imageBucketDomain, String uploadImageDirectName, String localRootPath) {
super();
this.imageBucketDomain = imageBucketDomain;
this.uploadImageDirectName = uploadImageDirectName;
this.localRootPath = localRootPath;
}
@Override
public Result<String> store(MultipartFile file) throws IOException {
//空白
if(file==null || file.isEmpty()){
return Result.failure("arg file is null or empty");
}
//子目录
String childDirect = DateTimeUtils.getYMD();
// 项目路径
final String realPath = localRootPath + File.separator + uploadImageDirectName + File.separator +childDirect;
// 前台访问路径
final String frontVisitPath = imageBucketDomain + "/" + uploadImageDirectName + "/" + childDirect + "/";
CommonInitParamers cip = new CommonInitParamers() {
@Override
public String getFileSaveDir() {
return realPath;
}
@Override
public String getCallbackFunctionName() {
// 没有回调函数
return null;
}
};
// 使用fileupload
SpringFileUploadConnector connector = new SpringFileUploadConnector(new CKEditorHightHandler(frontVisitPath));
try{
return Result.ofNullable(connector.upload(cip, file)); //返回文件的访问地址
}catch(ServletException e){
return Result.failure("upload has exception: "+ e.getMessage());
}
}
@Override
public String imageBucketDomain() {
return imageBucketDomain;
}
@Override
public String uploadImageDirectName() {
return uploadImageDirectName;
}
}
CKEditorHightHandler负责处理CKEditor需要的响应,上面提到过因为版本不同CKEditor需要不同的响应。SpringFileUploadConnector类负责具体的上传工作,代码如下:
public class SpringFileUploadConnector extends ApacheFileUploadConnector{
public SpringFileUploadConnector(UploadHandler handler) {
super(handler);
}
public String upload(CommonInitParamers params, MultipartFile file) throws IOException, ServletException{
if(!(file instanceof CommonsMultipartFile)){
return "500";
}
CommonsMultipartFile cmf=(CommonsMultipartFile)file;
//保存的路径
File uploadDir=new File(params.getFileSaveDir());
if (!uploadDir.exists()) {
uploadDir.mkdirs();
}
String callbackFun=params.getCallbackFunctionName();
return execute(cmf.getFileItem(), uploadDir, callbackFun);
}
}
ApacheFileUploadConnector类的代码在工具子模块(util)中。这里都不继续贴了。
B3. CKEditor的图片上传
上面的代码中有写过CKEditor图片上传的连接:/upload/ckeditor; 此连接是一个单独的控制器,代码如下:
@Controller
@RequestMapping("/upload")
public class UploadController {
@Autowired
private ServletContext servletContext;
@Autowired
private ImageIOInfo imageIOInfo;
private final static Logger logger = LoggerFactory.getLogger(UploadController.class);
// CK4:V4.11.4[LocalStorage|本地存储]
@RequestMapping(value = "/ckeditor", method = RequestMethod.POST, produces = "application/json;charset=UTF-8")
@ResponseBody
public String ckeditorUploadAction(@RequestParam("upload") MultipartFile file, @RequestParam("type") final String fileType) {
String localReal = servletContext.getRealPath("/");
ImageStorageExecutor ise = new LocalImageStorage(imageIOInfo.getImageBucketDomain(), imageIOInfo.getUploadImageDirectName(), localReal);
Result<String> data = Result.empty();
try{
data = ise.store(file);
}catch(Exception e){
data = Result.failure(e.getMessage(), e);
}
if(!data.isSuccess()){
return buildCkeditorResponse(false, "上传失败", null, null);
}
String responsebody = data.successValue();
return buildCkeditorResponse(true, "上传成功", getFileName(responsebody), responsebody);
}
// CK4:V4.11.4的响应格式
private String buildCkeditorResponse(boolean isCompleted, String message, String fileName, String imageUrl) {
Map<String, Object> data = new HashMap<>();
data.put("uploaded", isCompleted ? "1" : "0");
if (fileName != null) {
data.put("fileName", fileName);
}
if (imageUrl != null) {
data.put("url", imageUrl);
}
if (!isCompleted) {
Map<String,String> tmp = new HashMap<>();
tmp.put("message", Commons.optional(message, "未知的网络错误"));
data.put("error", tmp);
}
return new Gson().toJson(data);
}
//获得图片地址的文件名
private String getFileName(String imageURL){
return imageURL.substring(imageURL.lastIndexOf("/") + 1);
}
}
C. 站外存储
本地开发的时候会频繁的改动代码,若图片随项目走,真是太烦人的。
同时也是检验图片灵活存储的一个机会,开始新拉一个小项目:bucket,这个小项目目前只有一项工作都是保存图片。
C1. 接受上传工作, UploadEditorFileServlet
public class UploadEditorFileServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
private String uploadImageDirectName;
private String siteDomain;
private final static Logger logger = LoggerFactory.getLogger(UploadEditorFileServlet.class);
/**
* @see HttpServlet#doPost(HttpServletRequest request, HttpServletResponse
* response)
*/
protected void doPost(HttpServletRequest request, HttpServletResponse response)throws ServletException, IOException {
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
//子目录
String childDirect = DateTimeUtils.getYMD();
// 项目路径
final String realPath = request.getServletContext().getRealPath("/") + File.separator + uploadImageDirectName + File.separator + childDirect;
// 前台访问路径
final String frontVisitPath = siteDomain + uploadImageDirectName + "/"+childDirect+"/";
UploadInitParamers cip = new UploadInitParamers() {
@Override
public String getFileSaveDir() {
return realPath;
}
@Override
public String getCallbackFunctionName() {
// 没有回调函数
return null;
}
@Override
public String getUploadFileInputName() {
return "upload";
}
@Override
public HttpServletRequest getRequest() {
return request;
}
};
try (PrintWriter out = response.getWriter()) {
try {
ServletPartUploadConnector connector = new ServletPartUploadConnector(new CKEditorHightHandler(frontVisitPath));
String responsebody = connector.upload(cip);
out.println(buildCkeditorResponse(true, "上传成功", responsebody.replace(frontVisitPath, ""), responsebody));
} catch (Exception e) {
if (logger.isDebugEnabled()) {
logger.debug(e.getMessage(), e);
}
out.println(buildCkeditorResponse(false, e.getMessage(), null, null));
}
out.flush();
}
}
// CK4:V4.11.4的响应格式
// https://ckeditor.com/docs/ckeditor4/latest/guide/dev_file_upload.html
private String buildCkeditorResponse(boolean isCompleted, String message, String fileName, String imageUrl) {
Map<String, Object> data = new HashMap<>();
data.put("uploaded", isCompleted ? "1" : "0");
if (fileName != null) {
data.put("fileName", fileName);
}
if (imageUrl != null) {
data.put("url", imageUrl);
}
if (!isCompleted) {
Map<String,String> tmp = new HashMap<>();
tmp.put("message", Commons.optional(message, "未知的网络错误"));
data.put("error", tmp);
}
return new Gson().toJson(data);
}
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
resp.setContentType("text/html");
resp.setCharacterEncoding("UTF-8");
try (PrintWriter out = resp.getWriter()) {
out.println("<!DOCTYPE html><html><head><meta charset=\"UTF-8\"><title>Access Denied</title></head><body><p>暂不支持目录的功能</p></body></html>");
out.flush();
}
}
/**
* 获得初始化值
*/
public void init(ServletConfig config) throws ServletException {
super.init(config);
this.siteDomain = config.getInitParameter("siteDomain"); //站点URL, http://xx.com
this.uploadImageDirectName = config.getInitParameter("uploadImageDirectName"); //图片保存的目录
}
}
为了减小依赖这里使用ServletPartUploadConnector来保存图片,代码在工具子模块(util)中。同时也将配置外部化方便更改。web.xml如下:
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://xmlns.jcp.org/xml/ns/javaee"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd"
version="3.1">
<display-name>bucket</display-name>
<welcome-file-list>
<welcome-file>index.html</welcome-file>
</welcome-file-list>
<servlet>
<servlet-name>UploadEditorFileServlet</servlet-name>
<servlet-class>com.apobates.forum.bucket.servlet.UploadEditorFileServlet</servlet-class>
<init-param>
<param-name>siteDomain</param-name>
<param-value>http://pic.test.com/</param-value>
</init-param>
<init-param>
<param-name>uploadImageDirectName</param-name>
<param-value>imagestore</param-value>
</init-param>
<multipart-config>
<max-file-size>10485760</max-file-size>
<max-request-size>20971520</max-request-size>
<file-size-threshold>5242880</file-size-threshold>
</multipart-config>
</servlet>
<servlet-mapping>
<servlet-name>UploadEditorFileServlet</servlet-name>
<url-pattern>/upload/ckeditor</url-pattern>
</servlet-mapping>
</web-app>
这里可以看到访问图片的域名为:pic.test.com, 图片保存在imagestore目录中,上传的地址为: http://pic.test.com/upload/ckeditor。
下面来在tomcat中配置这个域名, <tomcat安装目录>/conf/server.xml
<Host name="www.test.com" appBase="webapps"
unpackWARs="true" autoDeploy="true">
<!-- Access log processes all example.
Documentation at: /docs/config/valve.html
Note: The pattern used is equivalent to using pattern="common" -->
<Valve className="org.apache.catalina.valves.AccessLogValve" directory="logs"
prefix="localhost_access_log" suffix=".txt"
pattern="%h %l %u %t "%r" %s %b" />
</Host>
<Host name="pic.test.com" appBase="bucket" unpackWARs="true" autoDeploy="true">
<Valve className="org.apache.catalina.valves.AccessLogValve" directory="logs"
prefix="bucket_access_log" suffix=".txt"
pattern="%h %l %u %t "%r" %s %b" />
<Context path="" docBase="ROOT" reloadable="true" useHttpOnly="true"/>
</Host>
因为跨域了所以还需要在web.xml配置一个CORS 过滤器。这里都不贴了,请参考: http://tomcat.apache.org/tomcat-8.5-doc/config/filter.html#CORS_Filter
C2. 更改CKEditor编辑图片上传的地址
这个比较容易,找到项目的js文件中的initCkEditor函数,将UploadUrl: '/upload/ckeditor'
变成 : UploadUrl: 'http://pic.test.com/upload/ckeditor'
C3. formbean中的图片上传
C3.1先将资源文件中的配置修改如下:
img.bucket.domain=http://pic.test.com
img.bucket.upload.direct=imagestore
img.bucket.upload.uri=/upload/ckeditor
img.bucket.upload.input=upload
C3.2 将spring配置文件中的 imageStorageExecutor Bean换一个新实现:
<!-- 站外存储图片 -->
<bean id="imageStorageExecutor" class="com.apobates.forum.trident.fileupload.OutsideImageStorage">
<constructor-arg name="imageBucketDomain" value="${img.bucket.domain}"/>
<constructor-arg name="uploadImageDirectName" value="${img.bucket.upload.direct}"/>
<constructor-arg name="imageBucketUploadURL" value="${img.bucket.domain}${img.bucket.upload.uri}"/>
<constructor-arg name="imageBucketUploadInputFileName" value="${img.bucket.upload.input}"/>
</bean>
OutsideImageStorage代码如下:
public class OutsideImageStorage implements ImageStorageExecutor{
private final String imageBucketDomain;
private final String uploadImageDirectName;
private final String imageBucketUploadURL;
private final String imageBucketUploadInputFileName;
private final static Logger logger = LoggerFactory.getLogger(OutsideImageStorage.class);
/**
*
* @param imageBucketDomain 站外的访问域名,例:http://x.com
* @param uploadImageDirectName 站外保存图片的目录
* @param imageBucketUploadURL 站外上传程序的访问地址,例:http://x.com/y
* @param imageBucketUploadInputFileName 站外上传程序接受的type=file输入项的名称
*/
public OutsideImageStorage(String imageBucketDomain, String uploadImageDirectName, String imageBucketUploadURL, String imageBucketUploadInputFileName) {
super();
this.imageBucketDomain = imageBucketDomain;
this.uploadImageDirectName = uploadImageDirectName;
this.imageBucketUploadURL = imageBucketUploadURL;
this.imageBucketUploadInputFileName = imageBucketUploadInputFileName;
}
@Override
public String imageBucketDomain() {
return imageBucketDomain;
}
@Override
public String uploadImageDirectName() {
return uploadImageDirectName;
}
public String getImageBucketUploadURL() {
return imageBucketUploadURL;
}
public String getImageBucketUploadInputFileName() {
return imageBucketUploadInputFileName;
}
@Override
public Result<String> store(MultipartFile file) throws IOException {
if(file==null || file.isEmpty()){
logger.info("[AFU][OU] file is null or empty");
return Result.failure("arg file is null or empty");
}
HttpHeaders parts = new HttpHeaders();
parts.setContentType(MediaType.TEXT_PLAIN);
final ByteArrayResource byteArrayResource = new ByteArrayResource(file.getBytes()) {
@Override
public String getFilename() {
return file.getOriginalFilename();
}
};
final HttpEntity<ByteArrayResource> partsEntity = new HttpEntity<>(byteArrayResource, parts);
//
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add(imageBucketUploadInputFileName, partsEntity);
HttpEntity<MultiValueMap<String, Object>> requestEntity = new HttpEntity<>(body, headers);
RestTemplate restTemplate = new RestTemplate();
String responseBody = restTemplate.postForEntity(imageBucketUploadURL, requestEntity, String.class).getBody();
return parseFileURL(responseBody);
}
private Result<String> parseFileURL(String responseBody){
if(responseBody == null){
logger.info("[AFU][AF]3> upload fail used default value");
return Result.failure("upload fail used default value");
}
Map<String, Object> rbMap = new Gson().fromJson(responseBody, new TypeToken<Map<String, Object>>() {}.getType());
if(rbMap==null || rbMap.isEmpty()){
logger.info("[AFU][AF]4> from json map is not used");
return Result.failure("from json map is not used");
}
if(rbMap.containsKey("error")){
@SuppressWarnings("unchecked")
Map<String,String> tmp = (Map<String,String>)rbMap.get("error");
logger.info("[AFU][AF]5> uploaded response error: "+ tmp.get("message"));
return Result.failure("uploaded response error: "+ tmp.get("message"));
}
//String fileVisitPath=null;
try{
String fileVisitPath = rbMap.get("url").toString();
return Result.success(fileVisitPath);
}catch(NullPointerException | ClassCastException e){
return Result.failure("parse image url for response has exception: "+ e.getMessage());
}
}
}
这里使用Spring的RestTemplate将MultipartFile交给bucket项目的UploadEditorFileServlet,工作结束。
D. 生成缩略图
小可也曾用过img标签上加width和height属性来控制图片,不让它破坏CSS布局; 也有各种hack让图片自适应父div。但这都不治本,因为服务器还是要输出哪么大的图片,试想如果用oss或第三方的解决方案都是按流量收费的。现代app的图片大多采用webp, 因为它有高压缩比,经过优化,可以在网络上实现更快,更小的图像。再者看看你日常去的哪些大网站,哪个对图片原样输出了。
D1 新模块:thumbnail
对外服务的 ThumbBuilderServlet,它接受这几个参数:dir目录名,file文件名,scale支持:auto|widthheight.代码如下:
public class ThumbBuilderServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
private static final Logger logger = LoggerFactory.getLogger(ThumbBuilderServlet.class);
private String originalDir;
private String thumbDir;
private int maxWidth;
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
String scaleParameter = req.getParameter("scale"); //接受:auto|width<x>height
String imageFileName = req.getParameter("file");
String directory = req.getParameter("dir");
//
if (scaleParameter==null || scaleParameter.isEmpty()) {
resp.sendError(400, "scale parameter lost");
return;
}
if (imageFileName.isEmpty() || imageFileName.indexOf(".") == -1) {
resp.sendError(400, "image file is not exist");
return;
}
try {
String originalImageDirect = getServletContext().getRealPath("/") + originalDir + ThumbConstant.FS;
String originalImagePath;
if(!"/".equals(directory)){
originalImagePath = directory + ThumbConstant.FS + imageFileName;
}else{
originalImagePath = imageFileName;
}
//是否裁剪
int imageWidth = getOriginalImageWidth(originalImageDirect+originalImagePath);
if(imageWidth <= maxWidth && "auto".equals(scaleParameter)){
//
RequestDispatcher rd = getServletContext().getRequestDispatcher("/"+ originalDir + ThumbConstant.WEB_FS + originalImagePath);
rd.forward(req, resp);
return;
}
//裁剪开始
ImagePath ip = new ImagePath(scaleParameter, (originalImageDirect+originalImagePath), imageWidth, new File(getServletContext().getRealPath("/") + thumbDir + ThumbConstant.FS));
if (!ip.getImagePhysicalPath().exists()) {
//
ip.makeThumb();
}
//
redirectThumbImage(req, resp, ip);
} catch (IOException e) {
if(logger.isDebugEnabled()){
logger.debug("[Thumb][TBS]image thumb create fail");
}
}
}
/**
* 获得初始化值
*/
public void init(ServletConfig config) throws ServletException {
super.init(config);
this.originalDir = config.getInitParameter("original"); //原目录
this.thumbDir = config.getInitParameter("thumb"); //封面目录
this.maxWidth = 900;
try{
maxWidth = Integer.valueOf(config.getInitParameter("maxWidth"));
}catch(NullPointerException | NumberFormatException e){}
}
/**
* 跳转到缩略图地址
*
* @param req
* @param resp
* @param ip
* @throws ServletException
* @throws IOException
*/
protected void redirectThumbImage(HttpServletRequest req, HttpServletResponse resp, ImagePath ip)throws ServletException, IOException {
RequestDispatcher rd = getServletContext().getRequestDispatcher(ip.getThumbWebHref(thumbDir));
rd.forward(req, resp);
}
/**
* 计算原始图片的宽度
*
* @param originalImagePath 原始图片的物理地址
* @return
*/
private int getOriginalImageWidth(String originalImagePath){
int imageWidth = 0;
try{
BufferedImage bimg = ImageIO.read(new File(originalImagePath));
imageWidth = bimg.getWidth();
}catch(IOException e){}
return imageWidth;
}
}
对于图片宽度小于maxWidth设置的不允以裁剪,原样输出。thumbDir值为裁剪后的目录名。originalDir值为原始图片的存放目录,因此只要将thumbnail模加加入到图片存储项目的依赖中并为servlet配置一个地址即可。ImagePath为裁剪的入口类,代码如下:
public class ImagePath {
//接受:auto|width<x>height
private final String scaleParameter;
//图片的缩放比例(1-100)
private final int imageScale;
//裁剪保存的目录
private final File thumbDirectory;
//被裁剪的图片文件
private final File sourceImageFile;
public ImagePath(String scale, String sourceFilePath, int imageWidthSize, File thumbDir) {
this.scaleParameter = scale;
this.imageScale = getScaleNumber(imageWidthSize);
this.thumbDirectory = thumbDir;
this.sourceImageFile = new File(sourceFilePath);
}
/**
* 裁剪后的WEB访问地址
* @param thumbDir
* @return
*/
public String getThumbWebHref(String thumbDir) {
return ThumbConstant.WEB_FS + thumbDir + ThumbConstant.WEB_FS + getCropDirect() + ThumbConstant.WEB_FS + sourceImageFile.getName();
}
/**
* 裁剪后的物理地址
* @return
*/
public File getImagePhysicalPath() {
File ipp = new File(thumbDirectory, ThumbConstant.FS + getCropDirect() + ThumbConstant.FS);
if (!ipp.exists()) {
//
ipp.mkdirs();
}
return new File(ipp, ThumbConstant.FS + sourceImageFile.getName());
}
/**
* 参数中的宽度和高度信息
* @return
*/
private Map<String, Integer> getImageWidthAndHeight() {
Map<String, Integer> data = new HashMap<String, Integer>();
String[] sas = scaleParameter.split(ThumbConstant.WIDTH_HEIGHT_SPLITER);
if(sas.length != 2){
return Collections.EMPTY_MAP;
}
data.put(ThumbConstant.IMAGE_WIDTH, Integer.valueOf(sas[0]));
data.put(ThumbConstant.IMAGE_HEIGHT, Integer.valueOf(sas[1]));
return data;
}
public void makeThumb() throws IOException {
Map<String, Integer> d = getImageWidthAndHeight();
int cropWidth=0, cropHeight=0;
if(!d.isEmpty()){
cropWidth = d.get(ThumbConstant.IMAGE_WIDTH);
cropHeight = d.get(ThumbConstant.IMAGE_HEIGHT);
}
//
new ThumbnailsScaleHandler(sourceImageFile, imageScale).cropImage(getImagePhysicalPath(), cropWidth, cropHeight);
}
/**
* 根据图片的宽度返回缩放的值
*
* @param originalImageWidth 原始图片的宽度
* @return
*/
private int getScaleNumber(int originalImageWidth){
if(originalImageWidth > 1440){
return 25;
}
if(originalImageWidth > 992){
return 50;
}
return 75;
}
/**
* 返回图片裁剪后的存储目录名
* @return
*/
private String getCropDirect(){
if(scaleParameter.indexOf(ThumbConstant.WIDTH_HEIGHT_SPLITER) == -1){
return imageScale+"";
}
return scaleParameter; //width<x>height
}
}
ThumbnailsScaleHandler类使用了Thumbnails框架来缩放图片。更多关于Thumbnails访问: https://github.com/coobird/thumbnailator.
如果scale=auto,根据图片的宽度来适当的裁剪,getScaleNumber方法来计算得出。ThumbnailsScaleHandler类的代码如下:
public class ThumbnailsScaleHandler extends AbstractCropImageHandler{
//图片的缩放比例(1-100)
private final int scale;
private final static Logger logger = LoggerFactory.getLogger(ThumbnailsScaleHandler.class);
public ThumbnailsScaleHandler(File sourceImagePath, int scale) {
super(sourceImagePath);
this.scale = scale;
}
@Override
public void cropImage(File cropSaveFile, int width, int height) throws IOException{
String fileName = getFileName(cropSaveFile);
String fileExt = getFileExtension(fileName);
File sourceImageFile = getOrginalImageFile();
//("[Thumb][TSH]Thumbnails.cropImage 参数: {w:"+width+", h:"+height+", scale:"+scale+", ext:"+fileExt+", name:"+fileName+"}");
if(width > 0 && height > 0){ //固定大小
if(logger.isDebugEnabled()){
String descrip = "[Thumbnails]" + ThumbConstant.NEWLINE;
descrip += "/*----------------------------------------------------------------------*/" +ThumbConstant.NEWLINE;
descrip += "width: " + width + ", height: " + height + ThumbConstant.NEWLINE;
descrip += "ext: " + fileExt + ThumbConstant.NEWLINE;
descrip += "source: " + sourceImageFile.getAbsolutePath() + ThumbConstant.NEWLINE;
descrip += "thumb: " + cropSaveFile.getAbsolutePath() + ThumbConstant.NEWLINE;
descrip += "/*----------------------------------------------------------------------*/" + ThumbConstant.NEWLINE;
logger.debug(descrip);
}
//
Thumbnails.of(sourceImageFile)
.size(width, height)
.outputFormat(fileExt)
.toFile(cropSaveFile);
}else{
if(logger.isDebugEnabled()){
String descrip = "[Thumbnails]" + ThumbConstant.NEWLINE;
descrip += "/*----------------------------------------------------------------------*/" + ThumbConstant.NEWLINE;
descrip += "scale: " + scale + ThumbConstant.NEWLINE;
descrip += "ext: " + fileExt + ThumbConstant.NEWLINE;
descrip += "source: " + sourceImageFile.getAbsolutePath() + ThumbConstant.NEWLINE;
descrip += "thumb: " + cropSaveFile.getAbsolutePath() + ThumbConstant.NEWLINE;
descrip += "/*----------------------------------------------------------------------*/" + ThumbConstant.NEWLINE;
logger.debug(descrip);
}
Thumbnails.of(sourceImageFile)
.scale(Math.abs(scale) / 100.00D)
.outputFormat(fileExt)
.toFile(cropSaveFile);
}
}
}
这里需要注意一下即使Thumbnails有调用size也不会裁剪成固定的宽和高,它还是缩放到一个宽和高。裁剪出固定的宽和高的图片其实并不难,这里给出一个从中心点开始裁剪的思路
实现类代码如下:
public class FixedCenterCropHandler extends AbstractCropImageHandler{
public FixedCenterCropHandler(File orginalImageFile) {
super(orginalImageFile);
}
@Override
public void cropImage(File cropSaveFile, int width, int height) throws IOException{
BufferedImage originalImage = ImageIO.read(getOrginalImageFile());
int[] xyPointers = getCropXYPointer(originalImage.getWidth(), originalImage.getHeight(), width, height);
if(xyPointers.length == 0){
return;
}
BufferedImage croppedImage = originalImage.getSubimage(xyPointers[0], xyPointers[0], width, height);
String fileExt = getFileExtension(getFileName(cropSaveFile));
ImageIO.write(croppedImage, fileExt, cropSaveFile);
}
/**
* 获取裁剪的起始点坐标
*
* @param originalImageWidth 原始图片的宽度
* @param originalImageHeight 原始图片的高度
* @param width 裁剪的宽度
* @param height 裁剪的高度
* @return
*/
private int[] getCropXYPointer(int originalImageWidth, int originalImageHeight, int width, int height){
if(originalImageWidth <= width && originalImageHeight <= height){
return new int[]{};
}
int startX=0;
if(originalImageWidth > width){
int centerX = originalImageWidth / 2; //宽度中心点X
startX = centerX - width / 2;
}
int startY=0;
if(originalImageHeight > height){
int centerY = originalImageHeight / 2; //高度中心点Y
startY = centerY - height / 2;
}
return new int[]{startX, startY};
}
}
这并不是一个很美的思路,因为不是所有图片中心物都出现在中心点左右,例如:一些风景画往往在右下或右上有动物。
E. 图片URL地址重写
这里介绍使用: urlrewritefilter 框架,官方网站: http://www.tuckey.org/urlrewrite/。
若裁剪的ThumbBuilderServlet配置的地址为: /thumb, 加上参数后可能是这样的: /thumb?dir=20191025&scale=auto&file=xxxx.png
,我希望地址重写后地址更自然,例:http://pic.test.com/thumbs/20191025/auto/xxxx.png
,这样更直观一些。
<dependency>
<groupId>org.tuckey</groupId>
<artifactId>urlrewritefilter</artifactId>
<version>4.0.3</version>
</dependency>
像thumbnail模块的使用一样,它也随是图片存储走,加载入图片绑定的项目中。在绑定的项目的web.xml中加入以下:
<filter>
<filter-name>UrlRewriteFilter</filter-name>
<filter-class>org.tuckey.web.filters.urlrewrite.UrlRewriteFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>UrlRewriteFilter</filter-name>
<url-pattern>/thumbs/*</url-pattern>
<dispatcher>REQUEST</dispatcher>
<dispatcher>FORWARD</dispatcher>
</filter-mapping>
框架还需要在WEB-INF下有一个配置文件:urlrewrite.xml,示例代码如下:
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE urlrewrite PUBLIC "-//tuckey.org//DTD UrlRewrite 4.0//EN"
"http://www.tuckey.org/res/dtds/urlrewrite4.0.dtd">
<urlrewrite>
<rule match-type="regex">
<name>imageServlet</name>
<from>/thumbs/(.*)/(.*)/(.*)\.(png|jpg|jpeg|gif)$</from>
<to type="forward">/thumb?dir=$1&file=$3.$4&scale=$2</to>
</rule>
</urlrewrite>
F. 浏览器中图片的懒加载
这里介绍使用: Lazy Load Js框架,官方网站:https://github.com/tuupola/lazyload
. 先看滚动加载效果图:
注意滚动条的位置,及网络加载的图片。使用方法也及其简单,这里都不贴了。
G 总结
G1. 文章中提到的util模块下载地址
G2. 项目的依赖及版本
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
<project.version>0.0.1-SNAPSHOT</project.version>
<spring-framework.version>5.0.7.RELEASE</spring-framework.version>
<jackson.version>2.9.8</jackson.version>
<junit.version>4.12</junit.version>
<log4j2.version>2.11.2</log4j2.version>
<servlet.version>3.1.0</servlet.version>
<fileupload.version>1.4</fileupload.version>
<slf4j.version>1.7.25</slf4j.version>
</properties>
G3. 20191026补充
修正:
R1: 上传时的文件扩展名检查.
ApacheFileUploadConnector|ServletPartUploadConnector
AbstractHandler(allowFileExtension方法默认实现只允许图片文件)|UploadHandler(接口新增方法:String[] allowFileExtension())
R2: 上抛上传时产生的错误信息
HandleOnUploadException(接口方法增加IO异常声明)|AbstractHandler.forceException
R3: ActionForm在上传失败时上抛错误信息. 不上传时使用默认文件名
com.apobates.forum.trident.controller.form.ActionForm.uploadAndEncodeFile
新增FileUploadFailException,类继承了IllegalStateException
控制器方法若需要知道上传错误可以捕获FileUploadFailException异常
R4: CKEditor的图片类型设置
研究了一天文档没找到配置参数。只能手写代码:
//v:4.x.x
function initCkEditor(config){
//ETC
if(options.plugs.indexOf('uploadimage') != -1){
CKEDITOR.config.filebrowserUploadUrl = BASE+options.UploadUrl+'?type=Files';
CKEDITOR.config.filebrowserImageUploadUrl = BASE+options.UploadUrl+'?type=Images';
//ADD 20191026
CKEDITOR.on('dialogDefinition', function( evt ) {
var dialogName = evt.data.name;
if (dialogName == 'image') {
var uploadTab = evt.data.definition.getContents('Upload'); // get tab of the dialog
var browse = uploadTab.get('upload'); //get browse server button
browse.onClick = function() {
var input = this.getInputElement();
input.$.accept = 'image/*'
};
browse.onChange = function(){
var input = this.getInputElement();
var fn = input.$.value; // 文件路径
var imageReg = /\.(gif|jpg|jpeg|png)$/i;
if(!imageReg.test(fn)){
alert('非法的文件类型');
input.$.value='';
}
};
}
});
}
}
R5: 修复CKEditor的错误响应格式
com.apobates.forum.trident.controller.UploadController.buildCkeditorResponse
com.apobates.forum.bucket.servlet.UploadEditorFileServlet.buildCkeditorResponse
com.apobates.forum.trident.fileupload.OutsideImageStorage.parseFileURL
R6: 上传保存的文件名更改,原来是写死的
CKEditorHandler|CKEditorHightHandler,两者的父类:AbstractHandler默认方法实现输出null
ApacheFileUploadConnector|ServletPartUploadConnector
未写包名的即为util模块中的修改,反之即为本文提及的代码。 修正后的util模块在白渡网盘中更新
H. 有关MaxUploadSizeExceededException
不论是使用Servlet 3 Part还是ASF commons fileupload框架都有可能在设置文件大小(除了不设置,默认是-1不限制)时出来这个异常,这时连接会被重置。
Spring MultipartFile时出现的异常:
nested exception is org.springframework.web.multipart.MaxUploadSizeExceededException: Maximum upload size of 5242880 bytes exceeded;
nested exception is org.apache.commons.fileupload.FileUploadBase$SizeLimitExceededException: the request was rejected because its size (8718571) exceeds the configured maximum (5242880)] with root cause
org.apache.commons.fileupload.FileUploadBase$SizeLimitExceededException: the request was rejected because its size (8718571) exceeds the configured maximum (5242880)
at org.apache.commons.fileupload.FileUploadBase$FileItemIteratorImpl.<init>(FileUploadBase.java:977)
at org.apache.commons.fileupload.FileUploadBase.getItemIterator(FileUploadBase.java:309)
at org.apache.commons.fileupload.FileUploadBase.parseRequest(FileUploadBase.java:333)
at org.apache.commons.fileupload.servlet.ServletFileUpload.parseRequest(ServletFileUpload.java:113)
at org.springframework.web.multipart.commons.CommonsMultipartResolver.parseRequest(CommonsMultipartResolver.java:158)
at org.springframework.web.multipart.commons.CommonsMultipartResolver.resolveMultipart(CommonsMultipartResolver.java:142)
at org.springframework.web.servlet.DispatcherServlet.checkMultipart(DispatcherServlet.java:1128)
at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:960)
at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:925)
at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:974)
at org.springframework.web.servlet.FrameworkServlet.doPost(FrameworkServlet.java:877)
at javax.servlet.http.HttpServlet.service(HttpServlet.java:661)
at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:851)
at javax.servlet.http.HttpServlet.service(HttpServlet.java:742)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:231)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)
at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:52)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)
只使用fileupload时出现的异常:
java.lang.IllegalStateException: org.apache.tomcat.util.http.fileupload.FileUploadBase$SizeLimitExceededException: the request was rejected because its size (12119773) exceeds the configured maximum (1048576)
at org.apache.catalina.connector.Request.parseParts(Request.java:2947)
at org.apache.catalina.connector.Request.parseParameters(Request.java:3242)
at org.apache.catalina.connector.Request.getParameter(Request.java:1136)
at org.apache.catalina.connector.RequestFacade.getParameter(RequestFacade.java:381)
at com.apobates.forum.utils.fileupload.ServletPartUploadConnector.upload(ServletPartUploadConnector.java:81)
at com.apobates.forum.bucket.servlet.UploadEditorFileServlet.doPost(UploadEditorFileServlet.java:71)
at javax.servlet.http.HttpServlet.service(HttpServlet.java:661)
at javax.servlet.http.HttpServlet.service(HttpServlet.java:742)
这时正常的错误提示不会由response输出,网络有一堆如下的解答:
H1. 1兆不行10兆,通过提升允许的上限
个人不建议,这是不是有点妥协的意味呢?
H2. 使用ExceptionHandler
@ExceptionHandler(MultipartException.class)
public String handleError1(MultipartException e, RedirectAttributes redirectAttributes) {
redirectAttributes.addFlashAttribute("message", e.getCause().getMessage());
return "redirect:/uploadStatus";
}
源文地址: How to handle max upload size exceeded exception, 我试了一下电脑里的浏览器只有win10 自带Edge会正常工作, Firefox, Chrome都没效果。
H3. 继承CommonsMultipartResolver 法
public class DropOversizeFilesMultipartResolver extends CommonsMultipartResolver {
/**
* Parse the given servlet request, resolving its multipart elements.
*
* Thanks Alexander Semenov @ http://forum.springsource.org/showthread.php?62586
*
* @param request
* the request to parse
* @return the parsing result
*/
@Override
protected MultipartParsingResult parseRequest(final HttpServletRequest request) {
String encoding = determineEncoding(request);
FileUpload fileUpload = prepareFileUpload(encoding);
List fileItems;
try {
fileItems = ((ServletFileUpload) fileUpload).parseRequest(request);
} catch (FileUploadBase.SizeLimitExceededException ex) {
request.setAttribute(EXCEPTION_KEY, ex);
fileItems = Collections.EMPTY_LIST;
} catch (FileUploadException ex) {
throw new MultipartException("Could not parse multipart servlet request", ex);
}
return parseFileItems(fileItems, encoding);
}
}
源文地址: Using Spring 3 @ExceptionHandler with commons FileUpload and SizeLimitExceededException/MaxUploadSizeExceededException, 我用的Spring5上不好使,所有浏览器都会提示连接重置,我试着在调用parseRequest之前判断上传文件的大小,若超出设置的上限上抛MultipartException,还是提示连接被重置。
H4. 继承OncePerRequestFilter 法
public class MultipartExceptionHandler extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
try {
filterChain.doFilter(request, response);
} catch (MaxUploadSizeExceededException e) {
handle(request, response, e);
} catch (ServletException e) {
if(e.getRootCause() instanceof MaxUploadSizeExceededException) {
handle(request, response, (MaxUploadSizeExceededException) e.getRootCause());
} else {
throw e;
}
}
}
private void handle(HttpServletRequest request,
HttpServletResponse response, MaxUploadSizeExceededException e) throws ServletException, IOException {
String redirect = UrlUtils.buildFullRequestUrl(request) + "?error";
response.sendRedirect(redirect);
}
}
源文地址: How to nicely handle file upload MaxUploadSizeExceededException with Spring Security
H5. 个人方案
即然不论Part,ASF common fileupload或Spring MultipartResolver在超出允许的上限时都会重置连接,说明这可能是上佳的可行方案,出于什么原因这样作不是我辈菜鸟可想到的。但又需要在超出上限时给用户一个提示,操作得以继续进行而不是让用户看浏览器的错误页:
== 这里说明一下:我试着用http watch之类的工具看一看网络情况。发现只要开了代理都可以一遍跑过。错误提示原本不会响应的也出来了。==
即然服务器上无法作文章,又需要给用户一个提示,又不要影响操作继续只能选用客户端异步上传。即使上传的连接被重置,用户当前的操作也不会因此打断,像CKEditor哪样:
这里推荐一下: bootstrap-fileinput, 它可以在客户端对文件大小检测,只不过单位是字节,示例如下
JS代码示例:
$('.bootCoverImage').bind('initEvent', function(){
var self=$(this); var token=new Date().getTime();
var option={
uploadUrl: BASE+"upload/fileinput",
uploadExtraData: {'uploadToken':token, 'id':token},
maxFileCount: 5,
maxFileSize: 1024,
maxFilesNum: 1,
allowedFileTypes: ['image'],
allowedFileExtensions: ['jpg', 'png', 'gif', 'jpeg'],
overwriteInitial: true,
theme: 'fa',
uploadAsync: true,
showPreview: true
};
if(self.attr('data-bind')){
option.initialPreviewAsData=true;
option.initialPreview=[self.attr('data-bind')];
}
if(self.attr('data-element')){
hiddenElement=self.attr('data-element');
}
var coverCmp=self.fileinput(option);
coverCmp.on('fileuploaded', function(event, data, id, index) {
var response=data.response,
currentImage=response.data[0].location;
//currentImage即为上传成功后图片的访问地址;
}).on('fileuploaderror', function(event, data, msg){
console.log('File Upload Error', 'ID: ' + data.fileId + ', Thumb ID: ' + data.previewId);
}).on('filebatchuploadcomplete', function(event, preview, config, tags, extraData) {
console.log('File Batch Uploaded', preview, config, tags, extraData);
});
}).trigger('initEvent');
上传成功后服务器站的响应格式如下:
{
"data":[
{
"id":"101",
"name":"snapshot-20191101000913773.png",
"location":"http://www.test.com/imagestore/20191101/snapshot-20191101000913773.png"
}],
"success":true
}