图片服务器(图床)的设计与实现
文章目录
一.项目介绍
图片服务器的核心功能:显示图片,上传图片,删除等。
应用场景:将图片上传到另外的服务器上,并提供链接以在其他网站分享图片、论坛、博客引用。现有的图片服务器比如阿里云OSS、腾讯云、TG图床 (IMG.TG)、聚合图床等。
二、核心内容:
基础的Web服务器设计开发(Servlet),Servlet是Tomcat这个HTTP服务器所提供的一组编程接口
使用数据库(MySQL)的JDBC操作MySql
数据库表设计(根据实际场景设计)
前后端交互接口设计(基于HTTP协议)
使用Java中的Gson库操作JSON数据
测试HTTP服务器(Postman)
使用HTML CSS JavaScript技术构建一个简单的网页
三.服务器设计
3.1数据库设计
MySql本质上是一个服务器程序,在安装时,既安装了本地服务器,也安装了客户端。在MySql数据库中存储的是图片的属性(元信息)即图片正文,以文件的形式直接存在磁盘上的,数据库中每次就记录一个path,path就对应到磁盘上的一个文件。
md5:图片的md5校验和(通过一个更短的字符串,来验证整体数据是否正确,短的字符串是根据原串内容通过一定的规则来计算出来的)。
图片表的设计:
实体类设计(将数据库的表转为类,字段转为成员属性一一对应):
package dao;
public class Image {
private int imageId ;
private String imageName;
private int size;
private String uploadTime;
private String contentType;
private String path;
private String md5;
public int getImageId() {
return imageId;
}
public void setImageId(int imageId) {
this.imageId = imageId;
}
public String getImageName() {
return imageName;
}
public void setImageName(String imageName) {
this.imageName = imageName;
}
public int getSize() {
return size;
}
public void setSize(int size) {
this.size = size;
}
public String getUploadTime() {
return uploadTime;
}
public void setUploadTime(String uploadTime) {
this.uploadTime = uploadTime;
}
public String getContentType() {
return contentType;
}
public void setContentType(String contentType) {
this.contentType = contentType;
}
public String getPath() {
return path;
}
public void setPath(String path) {
this.path = path;
}
public String getMd5() {
return md5;
}
public void setMd5(String md5) {
this.md5 = md5;
}
@Override
public String toString() {
return "Image{" +
"imageId=" + imageId +
", imageName='" + imageName + '\'' +
", size=" + size +
", uploadTime='" + uploadTime + '\'' +
", contentType='" + contentType + '\'' +
", path='" + path + '\'' +
", md5='" + md5 + '\'' +
'}';
}
}
3.2.服务器API设计
3.2.1.JSON
一种数据组织的格式,格式键值对的结构。JSON只是一种数据格式,和编程语言无关。使用JSON完成数据的序列化方便完成网络传输。
3.2.2.Gson:一个开源的JSON解析库
3.2.3.使用form表单在HTML中完成文件上传操作
3.3.前后端交互设计
3.3.1.新增图片请求:使用POST,POST到/image这个路径上(包含图片自身的属性)
3.3.2.查看所有图片信息
3.3.3.查看指定图片属性
3.3.4.删除指定图片
3.3.5.查看指定图片内容
四、项目后端的实现
4.1数据库操作
4.1.1封装数据库连接池
使用DBUtil封装获取数据库链接,Image对应每一个图片的属性。
代码如下:
代码如下(示例):
package dao;//数据访问层
import com.mysql.cj.jdbc.MysqlDataSource;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
public class DBUtil {
private static final String URL = "jdbc:mysql://127.0.0.1:3306/java_image_sever?characterEncoding=utf8&useSSL=true";
private static final String USERNAME = "root";
private static final String PASSWORD = "";
private static volatile DataSource dataSource = null;
public static DataSource getDataSource() {
// 通过这个方法创建 DataSource 示例
if (dataSource == null){
synchronized (DBUtil.class) {
if (dataSource == null) {
dataSource = new MysqlDataSource();
MysqlDataSource tmpDataSource = (MysqlDataSource) dataSource;
tmpDataSource.setURL("jdbc:mysql://localhost:63342/java_image_server");
tmpDataSource.setUser("root");
tmpDataSource.setPassword("123456");
}
}
}
return dataSource;
}
public static Connection getConnection() {
try {
return getDataSource().getConnection();
} catch (SQLException e) {
e.printStackTrace();
}
return null;
}
public static void close(Connection connection, PreparedStatement preparedStatement, ResultSet resultSet) {
// 顺序,先创建的先关闭
try {
if (resultSet != null) {
resultSet.close();
}
if (preparedStatement != null) {
preparedStatement.close();
}
if (connection != null) {
connection.close();
}
}
catch (SQLException e) {
e.printStackTrace();
}
}
}
4.1.2数据访问层,实现数据的操作,ImageDao类完成Image对象的增删改查操作
代码如下:
package dao;
import com.sun.javafx.scene.input.InputEventUtils;
import common.JavaImageServerException;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
public class ImageDao {
/**
* 把image 对象插入到数据库中
* @param image
*/
public void insert(Image image){
// 1.获取数据库链接
Connection connection = DBUtil.getConnection();
// 2.创建并且拼接 SQL语句
String sql = " insert into image_table values(null,?,?,?,?,?,?)";
PreparedStatement preparedStatement = null;
try {
preparedStatement = connection.prepareStatement(sql);
preparedStatement.setString(1, image.getImageName());
preparedStatement.setInt(2,image.getSize());
preparedStatement.setString(3,image.getUploadTime());
preparedStatement.setString(4,image.getContentType());
preparedStatement.setString(5, image.getPath());
preparedStatement.setString(6,image.getMd5());
// 3.执行 SQL语句
int ret = preparedStatement.executeUpdate();
if (ret != 1){
// 如果程序出现问题了,就抛出异常
throw new JavaImageServerException("插入数据错误");
}
/*
DBUtil.close(connection,preparedStatement,null);
*/
} catch (SQLException | JavaImageServerException e) {
e.printStackTrace();
}finally {
// 4.关闭连接和释放 statement对象 ,close代码有概率执行不到(超出异常)
DBUtil.close(connection,preparedStatement,null);
}
}
/**
* 查找数据库中的所有图片的信息
* @return
*/
public List<Image> selectAll(){
List<Image> images = new ArrayList<>();
//1.获取数据库连接
Connection connection = DBUtil.getConnection();
//2.构造 SQL语句
String sql = "select * from image_table";
PreparedStatement statement = null;
ResultSet resultSet = null;
//3.执行 SQL语句
try {
statement = connection.prepareStatement(sql);
resultSet = statement.executeQuery();
//4.处理结果集
while (resultSet.next()){
Image image = new Image();
image.setImageId(resultSet.getInt("imageId"));
image.setImageName(resultSet.getString("imageName"));
image.setSize(resultSet.getInt("size"));
image.setUploadTime(resultSet.getString("uploadTime"));
image.setContentType(resultSet.getString("contentType"));
image.setPath(resultSet.getString("path"));
image.setMd5(resultSet.getString("md5"));
images.add(image);
}
return images;
} catch (SQLException e) {
e.printStackTrace();
}finally {
//5.关闭连接
DBUtil.close(connection,statement,resultSet);
}
return null;
}
/**
* 根据 imageId 查找指定图片的操作
* @param imageId
* @return
*/
public Image selectOne(int imageId){
//1.获取数据库连接
Connection connection = DBUtil.getConnection();
//2,构造SQL语句
String sql = " select * from image_table where imageId = ?";
PreparedStatement statement = null;
ResultSet resultSet = null;
try {
//3.执行 SQL语句
statement = connection.prepareStatement(sql);
statement.setInt(1,imageId);
resultSet = statement.executeQuery();
//4.处理结果集
if (resultSet.next()){
Image image = new Image();
image.setImageId(resultSet.getInt("imageId"));
image.setImageName(resultSet.getString("imageName"));
image.setSize(resultSet.getInt("size"));
image.setUploadTime(resultSet.getString("uploadTime"));
image.setContentType(resultSet.getString("contentType"));
image.setPath(resultSet.getString("path"));
image.setMd5(resultSet.getString("md5"));
return image;
}
} catch (SQLException e) {
e.printStackTrace();
}finally {
//5.关闭连接
DBUtil.close(connection,statement,resultSet);
}
return null;
}
/**
* 根据 imageId 删除指定的图片
* @param imageId
*/
public void delete(int imageId){
//1.获取数据库连接
Connection connection = DBUtil.getConnection();
//2.拼装 SQl语句
String sql = "";
PreparedStatement statement = null;
//3.执行SQL语句
try {
statement = connection.prepareStatement(sql);
statement.setInt(1,imageId);
int ret = statement.executeUpdate();
if (ret != 1){
throw new JavaImageServerException("删除数据库操作失败");
}
} catch (SQLException | JavaImageServerException e) {
e.printStackTrace();
} finally {
//4.关闭连接
DBUtil.close(connection,statement,null);
}
}
// 数据库不在本地时,程序无法访问(打一个jar包拷贝到云服务器上再执行
public static void main1(String[] args) {
// 用于简单的测试
// 1.测试插入数据
Image image = new Image();
image.setImageName("1.jpg");
image.setSize(100);
image.setUploadTime("20");
image.setContentType("image/png");
image.setPath("./data/1.png");
image.setMd5("11223344");
ImageDao imageDao = new ImageDao();
imageDao.insert(image);
}
//2.测试查找所有图片
public static void main2(String[] args) {
ImageDao imageDao = new ImageDao();
List<Image> images = imageDao.selectAll();
System.out.println(images);
}
//3.测试查找指定图片
public static void main3(String[] args) {
ImageDao imageDao = new ImageDao();
Image image = imageDao.selectOne(1);
System.out.println(image);
}
public static void main(String[] args) {
//4.测试删除图片
ImageDao imageDao = new ImageDao();
imageDao.delete(1);
}
}
4.2.服务器设计
4.2.1 安装Servlet,基于Servlet来搭建服务器
4.2.2 创建继承HttpServlet类的子类
重写父类中的一些重要方法,如doGet方法、doPost方法、doDelete方法等。
ImageServlet:
package api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import dao.Image;
import dao.ImageDao;
//import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.fileupload.FileItem;
import org.apache.commons.fileupload.FileItemFactory;
import org.apache.commons.fileupload.FileUploadException;
import org.apache.commons.fileupload.disk.DiskFileItemFactory;
import org.apache.commons.fileupload.servlet.ServletFileUpload;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
public class ImageServlet extends HttpServlet {
/**
* 查看图片属性: 既能查看所有, 也能查看指定
* @param req
* @param resp
* @throws ServletException
* @throws IOException
*/
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
// 考虑到查看所有图片属性和查看指定图片属性
// 通过是否 URL 中带有 imageId 参数来进行区分.
// 存在 imageId 查看指定图片属性, 否则就查看所有图片属性
// 例如: URL /image?imageId=100
// imageId 的值就是 "100"
// 如果 URL 中不存在 imageId 那么返回 null
String imageId = req.getParameter("imageId");
if (imageId == null || imageId.equals("")) {
// 查看所有图片属性
selectAll(req, resp);
} else {
// 查看指定图片
selectOne(imageId, resp);
}
}
private void selectAll(HttpServletRequest req, HttpServletResponse resp) throws IOException {
resp.setContentType("application/json; charset=utf-8");
// 1. 创建一个 ImageDao 对象, 并查找数据库
ImageDao imageDao = new ImageDao();
List<Image> images = imageDao.selectAll();
// 2. 把查找到的结果转成 JSON 格式的字符串, 并且写回给 resp 对象
Gson gson = new GsonBuilder().create();
// jsonData 就是一个 json 格式的字符串了, 就和之前约定的格式是一样的了.
// 重点体会下面这行代码, 这个方法的核心, gson 帮我们自动完成了大量的格式转换工作
// 只要把之前的相关的字段都约定成统一的命名, 下面的操作就可以一步到位的完成整个转换
String jsonData = gson.toJson(images);
resp.getWriter().write(jsonData);
}
private void selectOne(String imageId, HttpServletResponse resp) throws IOException {
resp.setContentType("application/json; charset=utf-8");
// 1. 创建 ImageDao 对象
ImageDao imageDao = new ImageDao();
Image image = imageDao.selectOne(Integer.parseInt(imageId));
// 2. 使用 gson 把查到的数据转成 json 格式, 并写回给响应对象
Gson gson = new GsonBuilder().create();
String jsonData = gson.toJson(image);
resp.getWriter().write(jsonData);
}
/**
* 上传图片
* @param req
* @param resp
* @throws ServletException
* @throws IOException
*/
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
// 1. 获取图片的属性信息, 并且存入数据库
// a) 需要创建一个 factory 对象 和 upload 对象, 这是为了获取到图片属性做的准备工作
// 固定的逻辑
FileItemFactory factory = new DiskFileItemFactory();
ServletFileUpload upload = new ServletFileUpload(factory);
// b) 通过 upload 对象进一步解析请求(解析HTTP请求中奇怪的 body 中的内容)
// FileItem 就代表一个上传的文件对象.
// 理论上来说, HTTP 支持一个请求中同时上传多个文件
List<FileItem> items = null;
try {
items = upload.parseRequest(req);
} catch (FileUploadException e) {
// 出现异常说明解析出错!
e.printStackTrace();
// 告诉客户端出现的具体的错误是啥
resp.setContentType("application/json; charset=utf-8");
resp.getWriter().write("{ \"ok\": false, \"reason\": \"请求解析失败\" }");
return;
}
// c) 把 FileItem 中的属性提取出来, 转换成 Image 对象, 才能存到数据库中
// 当前只考虑一张图片的情况
FileItem fileItem = items.get(0);
Image image = new Image();
image.setImageName(fileItem.getName());
image.setSize((int)fileItem.getSize());
// 手动获取一下当前日期, 并转成格式化日期, yyMMdd => 20200218
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyyMMdd");
image.setUploadTime(simpleDateFormat.format(new Date()));
image.setContentType(fileItem.getContentType());
// MD5 暂时先不去计算
image.setMd5(DigestUtils.md5Hex(fileItem.get()));
// 自己构造一个路径来保存, 引入时间戳是为了让文件路径能够唯一
image.setPath("./image/" + image.getMd5());
// 存到数据库中
ImageDao imageDao = new ImageDao();
// 看看数据库中是否存在相同的 MD5 值的图片, 不存在, 返回 null
// Image existImage = imageDao.selectByMd5(image.getMd5());
imageDao.insert(image);
// 2. 获取图片的内容信息, 并且写入磁盘文件
if (existImage == null) {
File file = new File(image.getPath());
try {
fileItem.write(file);
} catch (Exception e) {
e.printStackTrace();
resp.setContentType("application/json; charset=utf-8");
resp.getWriter().write("{ \"ok\": false, \"reason\": \"写磁盘失败\" }");
return;
}
}
// 3. 给客户端返回一个结果数据
// resp.setContentType("application/json; charset=utf-8");
// resp.getWriter().write("{ \"ok\": true }");
resp.sendRedirect("index.html");
}
/**
* 删除指定图片
* @param req
* @param resp
* @throws ServletException
* @throws IOException
*/
@Override
protected void doDelete(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
resp.setContentType("application/json; charset=utf-8");
// 1. 先获取到请求中的 imageId
String imageId = req.getParameter("imageId");
if (imageId == null || imageId.equals("")) {
resp.setStatus(200);
resp.getWriter().write("{ \"ok\": false, \"reason\": \"解析请求失败\" }");
return;
}
// 2. 创建 ImageDao 对象, 查看到该图片对象对应的相关属性(这是为了知道这个图片对应的文件路径)
ImageDao imageDao = new ImageDao();
Image image = imageDao.selectOne(Integer.parseInt(imageId));
if (image == null) {
// 此时请求中传入的 id 在数据库中不存在.
resp.setStatus(200);
resp.getWriter().write("{ \"ok\": false, \"reason\": \"imageId 在数据库中不存在\" }");
return;
}
// 3. 删除数据库中的记录
imageDao.delete(Integer.parseInt(imageId));
// 4. 删除本地磁盘文件
File file = new File(image.getPath());
file.delete();
resp.setStatus(200);
resp.getWriter().write("{ \"ok\": true }");
}
}
ImageShowServlet
package api;
import dao.Image;
import dao.ImageDao;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.util.HashSet;
public class ImageShowServlet extends HttpServlet {
static private HashSet<String> whiteList = new HashSet<>();
static {
whiteList.add("http://47.98.116.42:8080/java_image_server/index.html");
}
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
String referer = req.getHeader("Referer");
if (!whiteList.contains(referer)) {
resp.setContentType("application/json; charset: utf-8");
resp.getWriter().write("{ \"ok\": false, \"reason\": \"未授权的访问\" }");
return;
}
// 1. 解析出 imageId
String imageId = req.getParameter("imageId");
if (imageId == null || imageId.equals("")) {
resp.setContentType("application/json; charset: utf-8");
resp.getWriter().write("{ \"ok\": false, \"reason\": \"imageId 解析失败\" }");
return;
}
// 2. 根据 imageId 查找数据库, 得到对应的图片属性信息(需要知道图片存储的路径)
ImageDao imageDao = new ImageDao();
Image image = imageDao.selectOne(Integer.parseInt(imageId));
// 3. 根据路径打开文件, 读取其中的内容, 写入到响应对象中
resp.setContentType(image.getContentType());
File file = new File(image.getPath());
// 由于图片是二进制文件, 应该使用字节流的方式读取文件
OutputStream outputStream = resp.getOutputStream();
FileInputStream fileInputStream = new FileInputStream(file);
byte[] buffer = new byte[1024];
while (true) {
int len = fileInputStream.read(buffer);
if (len == -1) {
// 文件读取结束
break;
}
// 此时已经读到一部分数据, 放到 buffer 里, 把 buffer 中的内容写到响应对象中
outputStream.write(buffer);
}
fileInputStream.close();
outputStream.close();
}
}
五、前端页面的实现
5.1主要应用的技术:
网页的骨架 --> HTML ;
描述网页上组件的样式 —> CSS ;
前端页面上的和用户具体交互的行为 —> JavaScript:
5.2主流的前端框架
5.2.1 有React,Auglar,Vue等。其中Vue.js更容易上手,格式如下:
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
var app = new Vue({
el:'#app',
data: {
images: [
{
imageId: 1,
imageName: "1.png",
contentType: "image/png",
md5: "aabbccdd",
},
{
imageId: 2,
imageName: "2.png",
contentType: "image/png",
md5: "aabbccdd",
}
}
},
methods: {
},
});
5.2.2 在Vue对象中构造一组数据来渲染页面
<div class="am-g am-g-fixed blog-fixed blog-content">
<figure data-am-widget="figure" class="am am-figure am-figure-default " data-am-figure="
{ pureview: 'true' }">
<div id="container">
<div v-for="image in images">
<img v-bind:src=" 'imageShow?imageId=' + image.imageId "
style="height:200px;width:200px">
<h3>{{image.imageName}}</h3>
</div>
</div>
</figure>
</div>
处理函数:
remove(image_id) {
$.ajax({
url:"image?image_id=" + image_id,
type:"delete",
context: this,
success: function(data, status) {
this.getImages();
alert("删除成功");
}
})
}
5.3拓展:
5.3.1图片防盗链机制
限制其他人来使用图片,判定当前请求的referer字段(表示当前请求的上一个页面的地址),代码中用一个hashSet存一下允许访问的Referer,展示图片的时候判断是否在hashSet中存在,如果发起的页面在代码指定的白名单(一个数组/列表)中,就可以访问。
5.3.2使用MD5优化磁盘空间
MD5的特点:不管原串多长,得到的MD5值都是一个固定长度,哪怕变动一点点,MD5值就会变动很大。MD5值的计算很简单,但是通过无法通过MD5值推测出原字符。
实现思路:
通过MD5判断两个图片内容是否一样:图片文件是二进制数据,本质上也是字符串,使用MD5计算图片内容,如果两个图片内容相同,得到的MD5是相同的,如果MD5近似相同,原图内容也是相同,这是MD5自身在算法设计上引起的特性,即使是有可能存在两个图片的内容不同,MD5相同,但实际上出现概率极低。这样知道了两个图片内容完全一样,在磁盘上存一份即可。在上传图片的时候,判定新图片的MD5在数据库中是否存在,如果存在就不再把图片内容写到磁盘上,不存则就写到磁盘上。
六、测试用例
6.1功能测试
6.1.1上传功能:
测试是否支持多种图片格式(如JPEG、PNG、GIF等)的上传。
测试上传文件的大小限制,确保超过限制的图片无法上传,并显示合适的错误提示。
测试上传过程中是否支持中断重传,以及上传进度的显示是否正确。 验证上传后的图片能否正常显示,且与原图一致。
6.1.2查看功能:
检查用户是否能够正常查看已上传的图片,包括缩略图和原图。
测试图片的放大、缩小、旋转等基本操作是否顺畅。
验证图片的加载速度,确保在合理时间内完成加载。
6.1.3删除功能:
测试删除操作是否成功,且删除后的图片不再显示。
验证删除操作是否会影响其他图片的正常显示。
检查删除操作后,数据库中的相关记录是否也被正确删除。
6.2性能测试
6.2.1负载测试:
在高并发情况下,测试服务器的响应时间、吞吐量和错误率。
验证服务器在大量图片上传、查看和删除操作下的性能表现。
6.2.2压力测试:
模拟大量用户同时访问服务器,测试其稳定性和可靠性。
检查在极端情况下,服务器是否会出现崩溃或性能下降的情况。
6.3界面测试
6.3.1布局测试:
检查界面布局是否合理,元素排列是否整齐。
验证图片显示区域的尺寸是否适应不同分辨率的屏幕。
6.3.2交互测试:
测试用户与界面的交互是否顺畅,如按钮点击、鼠标悬停等。
验证图片加载失败时,界面是否显示合适的错误提示
6.4兼容性测试
6.4.1浏览器兼容性:
在不同浏览器(如Chrome、Firefox、Safari等)中测试图片服务器的功能是否正常。
检查在不同浏览器版本中,界面的显示效果是否一致。
6.2.2操作系统兼容性:
在不同操作系统(如Windows、macOS、Linux等)上测试图片服务器的性能表现。
验证在不同操作系统中,图片的加载和显示是否正常。
6.5易用性测试
6.5.1操作流程:
检查用户上传、查看和删除图片的操作流程是否简洁明了。
验证用户在使用过程中是否容易上手,无需查阅帮助文档。
6.5.2错误提示:
测试在操作过程中遇到错误时,系统是否提供明确的错误提示和解决方案。
验证错误提示的语言是否友好、易于理解。
6.6安全测试
6.6.1数据安全:
检查上传的图片是否经过必要的安全性处理(如防篡改、防病毒等)。
验证存储的图片数据是否加密存储,以防止数据泄露。
6.6.2权限控制:
测试不同用户角色的权限设置是否合理,如管理员和普通用户的权限差异。
验证用户在未经授权的情况下,无法执行敏感操作(如删除其他用户的图片)。
6.6.1防御攻击:
检查服务器是否具备防御常见网络攻击的能力(如SQL注入、跨站脚本攻击等)。
验证服务器在面对恶意请求时,是否能够正常处理并保护用户数据安全。