1. 项目背景
现在很多网页都可以见到图片上传功能,我们上传一张本地图片后,网页就会显示我们所上传的图片,比如博客、个人信息提交页面等等。那么这背后的原理是什么呢?
其实当我们浏览网页的时候,本质上是从对端服务器获取文档,浏览器在获得这些文档之后进行解析和渲染,呈现在我们用户面前的就是绚丽多彩的页面了。一般来讲这些文档主要有三种格式:HTML、CSS、JS。HTML相当于网页的骨架,CSS相当于网页的衣服,用来规定网页的样式,比如字体大小以及排版等等,而Javascript主要负责一些动态的逻辑,比如在网页上按下一个按键后会显示什么等等。
这里我们以百度主页为例,看一下百度的主页Logo图片在其主页文档中是怎样存储的。右键百度的Logo图片在新标签页打开图片:
我们可以看到,打开一张图片其实是打开了一个新的网址,这个网址中存在这样一张图片。我们再检查一下百度页面的源:
可见百度页面源中嵌入了之前我们搜索的Logo图片的网址,这里的网址更专业的说法叫做同一资源定位符——URL。到这儿,我们就知道了一张图片能在网页上显示的原因是网页的HTML文档中嵌入了这张图片的URL,更进一步的,我们可以知道是有一个专门的服务器存储了这张图片,向外提供了一个URL链接让其他端口来访问这张图片。这个专门存储图片的服务器叫做图片服务器也叫做图床。
本项目就是实现一个简单的HTTP图片服务器,用这个服务器来存储图片,针对每张图片提供一个唯一的URL,有了这个URL之后其他网页就可以借助它把图片展示出来。
2.项目模块划分
本项目的结构分为两部分,数据存储模块和服务器模块,使用MySQL存储图片的描述信息,将图片内容保存到本地磁盘,服务器向外提供诸如上传图片、获取图片的属性、根据图片的URL访问图片(获取图片内容)和删除图片等API接口。
2.1数据存储模块
本项目使用MySQL存储图片的属性,数据库设计中只用到了一张表,如下:
create table image_table(
image_id int,
image_name varchar(256),
size int,
upload_time varchar(50),
type varchar(50),
path varchar(1024),
md5 varchar(50)//其中md5这个字段用来进行校验图片内容正确性
)
针对图片内容,可以直接存磁盘上。
为了让数据库与HTTP图片服务器更加方便的交互,根据MySQL官方提供的一系列API实现一个自主客户端。
这里贴出我设计的MySQL客户端:“db.hpp”
#pragma once
#include <cstdio>
#include <cstdlib>
#include <mysql/mysql.h>
#include <jsoncpp/json/json.h>
namespace image_system{
static MYSQL* MySQLInit(){
//1.先创建一个mysql的句柄
MYSQL* mysql = mysql_init(NULL);
//2.用句柄和数据库建立连接
if(mysql_real_connect(mysql,"127.0.0.1","root","1","image_system",3306,NULL,0) == NULL){
//数据库连接失败
printf("连接失败! %s\n",mysql_error(mysql));
return NULL;
}
//3.设置编码格式
mysql_set_character_set(mysql,"utf8");
return mysql;
}
static void MySQLRelease(MYSQL* mysql){
mysql_close(mysql);
}
//操作数据库中的 image_table 这个表.
//此处 Insert 等操作,函数依赖的输入信息很多
//为了防止参数太多,可以使用JSON或者类来封装参数 JSON改动更为灵活
class ImageTable{
public:
ImageTable(MYSQL* mysql) : _mysql(mysql){}
//image形如以下形式
//{
// image_name: "test.png",
// size: 200,
// upload_time: "2010/08/28",
// md5: "aaaaaaa",
// yupe: "png",
// path: "data/test.png"
//}
//使用JSON原因:1.扩展更为方便 2.和服务器接收到的数据更方便打通
bool Insert(const Json::Value& image){
char sql[4096] = {0};
sprintf(sql,"insert into image_table values(null,'%s',%d,'%s','%s','%s','%s')",
image["image_name"].asCString(),
image["size"].asInt(),
image["upload_time"].asCString(),
image["md5"].asCString(),
image["type"].asCString(),
image["path"].asCString());
printf("[Insert sql] %s\n",sql);
int ret = mysql_query(_mysql,sql);
if(ret != 0){
printf("Insert 执行SQL失败! %s\n",mysql_error(_mysql));
return false;
}
return true;
}
//函数参数设计:
//1.输入型参数,const&
//2.输出型参数,使用*
//3.输入输出型参数,使用&
bool SelectAll(Json::Value* images){
char sql[4096] = {0};
sprintf(sql,"select * from image_table");//实际中建议查啥字段输入啥字段,这里为了方便使用*
int ret = mysql_query(_mysql,sql);
if(ret != 0){
printf("SelectAll 执行SQL失败! %s\n",mysql_error(_mysql));
return false;
}
//遍历结果集合,并把结果集写到images参数之中
MYSQL_RES* result = mysql_store_result(_mysql);
int rows = mysql_num_rows(result);
int i;
for(i = 0;i < rows;++i){
MYSQL_ROW row = mysql_fetch_row(result);
//数据库查询的每条记录都相当于是一个图片的信息
//需要把这个信息转成JSON格式
Json::Value image;
image["image_id"] = atoi(row[0]);
image["image_name"] = row[1];
image["size"] = atoi(row[2]);
image["upload_time"] = row[3];
image["md5"] = row[4];
image["type"] = row[5];
image["path"] = row[6];
images->append(image);
}
mysql_free_result(result);//释放结果集合,忘了会导致内存泄漏
return true;
}
bool SelectOne(int image_id,Json::Value* image_ptr){
char sql[4096] = {0};
sprintf(sql,"select * from image_table where image_id = %d",image_id);
int ret = mysql_query(_mysql,sql);
if(ret != 0){
printf("SelectOne 执行SQL失败! %s\n",mysql_error(_mysql));
return false;
}
//遍历结果集合
MYSQL_RES* result = mysql_store_result(_mysql);
int rows = mysql_num_rows(result);
if(rows != 1){
printf("SelectOne 查询结果不是1条记录!实际查到 %d 条!\n",rows);
return false;
}
MYSQL_ROW row = mysql_fetch_row(result);
Json::Value image;
image["image_id"] = atoi(row[0]);
image["image_name"] = row[1];
image["size"] = atoi(row[2]);
image["upload_time"] = row[3];
image["md5"] = row[4];
image["type"] = row[5];
image["path"] = row[6];
*image_ptr = image;
mysql_free_result(result);
return true;
}
bool Delete(int image_id){
char sql[4096] = {0};
sprintf(sql,"delete from image_table where image_id = %d",image_id);
int ret = mysql_query(_mysql,sql);
if(ret != 0){
printf("Delete 执行SQL失败! %s\n",mysql_error(_mysql));
return false;
}
return true;
}
private:
MYSQL* _mysql;
};
}//end image_system
注意:
Linux下直接yum install mysql++ -devel.x86_64,安装MySQL的API。
其中<mysql.h>是 MySQl API的入口头文件。
一些设计细节如下:
- 数据库的连接参考MySQL官方文档mysql_real_connect,其中host是主机名或IP地址,这里我使用LOOPBACK本地环回接口,db是数据库名称,连接我设计的image_system这个库,端口port使用MySQL默认3306端口,unix_socket填NULL使用默认连接类型,client_flag的值通常为0。
- MySQL的默认编码格式并不是utf-8,这里使用mysql_set_character_set为当前连接设置默认的字符集,避免数据出现乱码。
- 在ImageTable类中封装sql语句,向外提供image_table表的Insert(插入)、SelectAll(查询所有图片信息)、SelectOne(查询指定图片信息)、Delete(删除指定图片)接口。接口参数我采用Json格式来组织数据传入,这样传入参数方便,便于扩展我所需要的信息,且我的服务器的接收和响应数据也是用json格式来组织的,这样就打通数据库和服务器之间的数据传输。
2.2服务器API
首先需要设计一个HTTP服务器,这里使用了GitHub上的cpp-httplib这个开源库,不需要自己造轮子。https://github.com/yhirose/cpp-httplib
从README中可知这是一个基于C++的只需包含单个头文件便可跨平台使用的HTTP/HTTPS库,并且非常易上手使用。基于这个库来设计了这个图片服务器项目。
一个HTTP服务器的作用是接收到http请求,并根据请求返回相应的http响应。此处需要约定不同的请求来表示不同的操作方式,例如有些请求表示上传图片,有些请求表示查看图片,有些表示删除图片等等。
在HTTP协议中,用户自定制携带一些信息上传给服务器有多种方法,一种比较传统的方法是借助URL中query_string查询字符串来携带一些信息。而本项目采用目前更为流行的Restful风格设计。
Restful风格设计具体指:
- 使用http 中method(方法)来表示操作的动词,比如GET表示查,POST表示增,PUT表示改,DELETE表示删;
- 使用http path表示操作的对象;
- 补充信息一般使用body来传递。通常情况下body中使用json格式的数据来组织;
- 响应数据通常也是用json格式组织。
具体如何创建这样一个http服务器,模仿cpp-httplib这个项目的README中的示例来做。 先依据cpp-httplib生成一个最简单的服务器。
#include "httplib.h"
//回调函数,一个函数,调用时机由代码框架和操作系统来决定
void Hello(const httplib::Request& req,httplib::Response& resp){
resp.set_content("<h1>hello</h1>","text/html");//“text/html”是HTTP Content-Type
}
int main(){
using namespace httplib;//引用命名空间,在函数内部生效,避免名称冲突
Server server;
//客户端请求 / 路径的时候,执行一个特定的函数
//指定不同的路径对应到不同的函数上,这个过程称为”设置路由“
//第一个参数是路径,第二个参数是函数指针,当客户端请求path
//为/的请求时,就会执行函数指针所指向的函数,相当于把函数注册到了代码框架中,什么时候
//调用不确定,由代码框架和操作系统来决定,此为回调函数
server.Get("/",Hello);
server.listen("0.0.0.0",9094);//此表示了服务器启动的全部过程
return 0;
}
编译后启动服务器验证一下:
在浏览器中输入ip地址和端口打开成功
在cpp-httplib的README中,Get的第二个参数没有使用函数指针而是用了C++11中的lambda表达式,即匿名函数,[]表示这是一个lambda表达式,()中是lambda表达式的参数,{}中是函数体。本项目中也采取了这种写法。
下面是本项目中服务器的一些API接口
- 图片上传接口
为了让服务器在网页上提供一个最简单的图片上传功能,需要一个html文件,代码框架如下:
<html>
<head></head>
<body>
<form id="upload-form" action="http://192.168.43.71:9094/image" method="post" enctype="multipart/form-data" >
<input type="file" id="upload" name="upload" /> <br />
<input type="submit" value="Upload" />
</form>
</body>
</html>
form标签表示这是一个form表单,form表单是一种传统的浏览器、网页和服务器交互的方式,其功能就是提供一些选项框,借助这些选项框将数据提交给服务器。注意需要修改form表单中的action,action表示将表单提交给指定服务器。
让此html文件保存在一个静态资源目录,且设置服务器的默认静态资源目录为该html所属的目录。
启动服务器打开浏览器看下效果:
从网页中可见,现在有了选择一张图片并可以点击Upload上传给服务器的接口,接下来使用Wireshark抓包观察一下使用这个form表单发送的http请求是什么样的。
从上图可知有两点需要注意,一是http请求中Content-Type是一种特殊的类型——multipart/form-data,二是body中既包含图片属性信息又包含图片内容信息。根据这些信息并结合cpp-httplib开源库的文档设计服务器接收图片的API如下:
server.Post("/image",[&image_table](const Request& req,Response& resp){
Json::FastWriter writer;
Json::Value resp_json;
printf("上传图片!\n");
//1.对参数进行校验
auto ret = req.has_file("upload");
if(!ret){
printf("文件上传出错!\n");
resp.status = 404;
//可以使用json格式组织一个返回结果
resp_json["ok"] = false;
resp_json["reason"] = "上传文件出错,没有需要的upload字段";
resp.set_content(writer.write(resp_json),"application/json");
return;
}
//2.根据文件名获取到文件数据 file 对象
const auto& file = req.get_file_value("upload");
// file.filename;
// file.content_type;
//3.把图片的属性信息插入到数据库中
Json::Value image;
image["image_name"] = file.filename;
image["size"] = (int)file.length;
time_t tt;
time(&tt);
tt = tt + (8*3600);//transform the time zone
tm* t = gmtime(&tt);
char res[1024] = {0};
sprintf(res,"%d-%02d-%02d %02d:%02d:%02d",t->tm_year+1900,t->tm_mon+1,t->tm_mday,t->tm_hour,t->tm_min,t->tm_sec);
image["upload_time"] = res;
std::string md5value;
auto body = req.body.substr(file.offset, file.length);
md5(body,md5value);
image["md5"] = md5value;
image["type"] = file.content_type;
image["path"] = "./data/" + file.filename;
ret = image_table.Insert(image);
if(!ret){
printf("image_table Insert failed!\n");
resp_json["ok"] = false;
resp_json["reason"] = "数据库插入失败!";
resp.status = 500;
resp.set_content(writer.write(resp_json),"application/json");
return;
}
//4.把图片保存到指定的磁盘目录中
//body 图片内容
FileUtil::Write(image["path"].asString(),body);
//5.构造一个响应数据通知客户端上传成功
resp_json["ok"] = true;
resp.status = 200;
resp.set_content(writer.write(resp_json),"application/json");
return;
});
其中 [&image_table]是 lambda 的重要特性,捕获变量,本来 lambda 内部是不能直接访问 image_table 的,捕捉之后就可以访问了,其中 & 的含义相当于按引用捕获,另外upload_time的设置需要包含<time.h>头文件,md5函数需要安装openssl库,添加<openssl/md5.h>头文件,链接libcrypto动态库。
测试一下上传图片功能:
- 客户端显示成功:
- 服务器显示成功:
- 检查数据库,图片存在:
- 获取所有图片信息接口
server.Get("/image",[&image_table](const Request& req,Response& resp){
(void)req;//没有任何实际的效果,确认不需要使用req
printf("获取所有图片信息\n");
Json::Value resp_json;
Json::FastWriter writer;
//1.调用数据库接口来获取数据
bool ret = image_table.SelectAll(&resp_json);
if(!ret){
printf("查询数据库失败!\n");
resp_json["ok"] = false;
resp_json["reason"] = "查询数据库失败!";
resp.status = 500;
resp.set_content(writer.write(resp_json),"application/json");
return;
}
//2.构造响应结果返回给客户端
resp.status = 200;
resp.set_content(writer.write(resp_json),"application/json");
});
这里使用Postman来检测一下,Postman相当于一个http客户端,可以很方便的构造http请求并进行测试。并且可以存储请求,便于下次测试。
- Postman成功获取所有图片信息
- 服务器正确响应
- 获取指定图片信息接口
server.Get(R"(/image/(\d+))",[&image_table](const Request& req,Response& resp){
Json::FastWriter writer;
Json::Value resp_json;
//1.先获取图片id
//std::string matches1 = req.matches[1];
//int image_id = atoi(matches1.c_str());
//matches返回的是一个字符串,atoi是将c风格字符串转成数字,stoi是C++11中,将一个std::string转成数字
int image_id = std::stoi(req.matches[1]);
printf("获取id为 %d 的图片信息!\n",image_id);
//2.再根据图片id查询数据库
bool ret = image_table.SelectOne(image_id,&resp_json);
if(!ret){
printf("数据库查询出错!\n");
resp_json["ok"] = false;
resp_json["reason"] = "数据库查询出错!";
resp.status = 404;
resp.set_content(writer.write(resp_json),"application/json");
return;
}
//3.把查询结果返回给客户端
resp_json["ok"] = true;
resp.set_content(writer.write(resp_json),"application/json");
return;
});
- Postman成功获取指定图片信息
- 服务器正确响应
注意:其中 R"(/image/(\d+))" 是正则表达式,R"()"中表示原始字符串,即不包含转义字符的作用。即匹配/image/后出现数字含1次以上的消息。
另外需要注意g++ 4.8版本不支持正则表达式,用g++ 4.8 编译运行后会崩溃。
- 获取图片内容接口
server.Get(R"(/show/(\d+))",[&image_table](const Request& req,Response& resp){
Json::FastWriter writer;
Json::Value resp_json;
//1.根据图片id去数据库查到对应的目录
int image_id = std::stoi(req.matches[1]);
printf("获取id为 %d 的图片内容!\n",image_id);
Json::Value image;
bool ret = image_table.SelectOne(image_id,&image);
if(!ret){
printf("读取数据库失败!\n");
resp_json["ok"] = false;
resp_json["reason"] = "数据库查询出错!";
resp.status = 404;
resp.set_content(writer.write(resp_json),"application/json");
return;
}
//2.根据目录找到文件内容,读取文件内容
std::string image_body;
ret = FileUtil::Read(image["path"].asString(),&image_body);
if(!ret) {
printf("读取图片内容失败!\n");
resp_json["ok"] = false;
resp_json["reason"] = "读取图片内容失败!";
resp.status = 500;
resp.set_content(writer.write(resp_json),"application/json");
return;
}
//3.把文件内容构造成一个响应
resp.status = 200;
//不同的图片设置的content-type是不一样的。
resp.set_content(image_body,image["type"].asCString());
});
- Postman正确显示图片内容
- 服务器正确响应
- 删除图片接口
server.Delete(R"(/image/(\d+))",[&image_table](const Request& req,Response& resp){
Json::FastWriter writer;
Json::Value resp_json;
//1.根据图片id到数据库中查到对应的目录
int image_id = std::stoi(req.matches[1]);
printf("删除id为 %d 的图片!\n",image_id);
//2.查找到对应文件的路径
Json::Value image;
bool ret = image_table.SelectOne(image_id,&image);
if(!ret){
printf("删除图片文件失败!\n");
resp_json["ok"] = false;
resp_json["reason"] = "删除图片文件失败!";
resp.status = 404;
resp.set_content(writer.write(resp_json),"application/json");
return;
}
//3.调用数据库操作进行删除
ret = image_table.Delete(image_id);
if(!ret){
printf("删除图片文件失败!\n");
resp_json["ok"] = false;
resp_json["reason"] = "删除图片文件失败!";
resp.status = 404;
resp.set_content(writer.write(resp_json),"application/json");
return;
}
//4.删除磁盘上的文件
//C++17之前标准库中,没有删除文件的方法
//此处只能使用操作系统提供的函数
unlink(image["path"].asCString());
//5.构造响应
resp_json["ok"] = true;
resp.status = 200;
resp.set_content(writer.write(resp_json),"application/json");
});
- Postman显示成功
- 服务器显示成功
- 数据库成功删除指定图片
3. 总结
本项目借助cpp-httplib这个库后很容易的实现了一个图片服务器,服务器向外提供了上传图片、获取图片信息、获取图片内容、删除图片等几个接口,获取到的图片信息存储在MySQL数据库中,图片内容保存在磁盘中。服务器和数据库采用Json来组织数据传输。在验证封装的数据库功能是否正常时采用了半手工的单元测试方法,在验证服务器功能时采用了Postman软件。
不过本项目还存在诸多问题,比如直接拼装SQL的方式有一个严重的缺陷即受到SQL注入攻击后会对数据库造成十分严重的危害。此外,本项目还有很多可以提高的地方一是添加防盗链功能,可以降低未经允许的链接而导致服务器带宽速度降低;二是可以添加存储时合并文件功能,当上传的图片是大量比较小的图片时,会造成磁盘碎片的情况,导致空间利用率低,所以可以考虑把这些逻辑上比较小的文件合并成一个比较大的物理文件。但因为本人时间和精力有限只能留待日后研究,总的来说本次项目让我受益良多,当真正动手做一个项目的时候才知道自己知识面的狭隘,需要学习的东西还有很多很多,希望这个九月我能如愿进入一家IT公司,能够有一个更好的平台去锻炼自己。
4. 扩展
- 防盗链
- 存储时合并文件