背景
http协议被广泛使用 从移动端 pc端浏览器 http协议无疑是打开互联网应用窗口的重要协议
http在网络应用层中的地位不可撼动 是能准确区分前后台的重要协议
虽然说现在最常用的是https协议 但是我们学会了http协议之后对于https的学习也能更加轻松
目标
对http协议的理论学习 从零开始完成web服务器开发 坐拥下三层协议 从技术到应用 让网络难点无处遁形
描述
采用C/S模型 编写支持中小型应用的http 并结合mysql 理解常见互联网应用行为 做完该项目 我们可以从技术上完全理解从你上网开始 到关闭浏览器的所有操作中的技术细节
技术特点
关于此次WEB服务器项目 我们主要用到的技术有
- 网络编程 (TCP/IP协议, socket流式套接字,http协议)
- 多线程技术
- cgi技术
- shell脚本
- 线程池
项目定位
开发环境
此次项目使用的开发环境是 LinuxCentos7
使用的工具有 gcc/g++/gdb + C/C++
WWW介绍
WWW是环球信息网的缩写 (亦作“Web”、“WWW”、“‘W3’”,英文全称为“World Wide Web”) 中文名字为“万维网”,"环球网"等,常简称为Web。
它分为web服务器和web客户端
WWW可以让Web客户端(常用浏览器)访问浏览Web服务器上的页面 是一个由许多互相链接的超文本组成的系统 通过互联网访问
在这个大系统中 我们将每一个有用的事务都称为一个资源 并且由一个全局 统一资源标识符 (URI)标识 这些资源通过超文本传输协议 (HTTP协议) 传送给用户 而后者通过点击连接来获取资源
网络协议栈介绍
网络协议栈整体
从之前网络部分的学习我们知道 TCP/IP协议下的网络模型可以分为四层
- 应用层
- 传输层
- 网络层
- 数据链路层
具体见下图
网络协议栈细节
从作用上分类 我们可以将网络四层模型分为两部分
- 应用层 主要负责对于数据的应用
- 数据传输 主要负责对于数据的传输
从细节上看
发送端自上而下会经过 应用层 传输层 网络层 数据链路层 其中经过每一层都会进行添加报头的操作来保证数据正确的送达对面
接收端自下而上的会对于这些数据进行解包 所以说接收端和发送端 我们可以认为他们同层之间看到的数据是相同的即同层之间可以看作是能够直接通信
与http相关的重要协议
- tcp
- ip
- dns
tcp和ip协议我们在网络部分已经深入了解学习过了 那么什么是dns协议呢?
我们在ping www.baidu.com的时候 可以发现下方自动给我们转化为了一个ip地址
事实上这个ip地址就是百度服务器的公网ip 而dns的作用就是将域名转化为ip地址
为什么要有dns协议的存在呢?
因为我们人类更擅长记住一些有意义的字符串而不是数字 域名和dns本质是为了优化用户的使用体验的
HTTP背景知识补充
目前主流的服务器使用的是http/1.1版本 而我们此次项目使用http/1.0来进行讲解 同时我们还会对比1.0和1.1版本的各种区别
此外我们此次项目只会写服务器 客户端使用浏览器代替
特点
HTTP协议有个特点就是C/S模式 (客户端服务器模式)
客户端通过一些方法(get post等)向服务器发送请求 之后服务器接收到请求之后发送响应
http协议有以下四个特点
- 简单快速 http服务器的规模很小(比如说我们今天要写的服务器代码只有1000行左右) 所以说通信速度很快
- 灵活 http协议可以传输任意类型的数据 正在传输的类型由Content-Type (我们后面会详细讲解)加以标记
- 无连接 每次连接只处理一个请求 服务器处理完客户的请求 收到客户的应答后 即断开连接 采用这种方式可以节省传输时间
- 无状态
http协议的无连接体现在哪里
http协议的无连接是对比tcp协议的连接而言的
http协议它本身对于连接没有概念 它只知道将自己要发送的数据交给下层协议 然后下层协议就会将数据发送到对端
http协议的无状态体现在哪里
http协议的无状态体现在它并不会记得自己发送或者接受过任何的数据
但是同学们读到这里可能会产生一个疑问 那么为什么我在浏览器上登录一个网站之后这个网站就记得我了呢?
这实际上是由浏览器的cookie和session机制实现的 具体的原理可以参考这篇博客
cookie和session
uri & url & urn
- URI 是uniform resource identifier 统一资源标识符 用来唯一的标识一个资源
- URL 是uniform resource locator 统一资源定位符 它是一种具体的URI 即URL可以用来标识一个资源 而且还指明了如何定位这个资源
- URN uniform resource name 统一资源命名 是通过名字来标识资源 比如说: mailto:javanet@java.sun.com
URI是一种抽象的 更高层次的一种统一资源标识符 而URL和URN则是一种具体的标识符
URL和URN是URI的子集 不过我们使用URN并不多
简单来说 URI和URL的主要区别是
- URI强调唯一标识一个资源
- URL强调唯一定位一个资源
- URL是URI的子集 因为如果能唯一定位一个资源就一定能唯一标识一个资源
网址url
URL(Uniform Resource Lacator)叫做统一资源定位符也就是我们通常所说的网址
其中服务器地址就是域名对应着我们的IP地址 带层次的文件路径实际上就是我们Linux中的路径
接下来我们来较为全面的认识下上面URL
一、协议方案名
http://
表示的是协议名称 表示请求时需要使用的协议 通常使用的是HTTP协议或安全协议HTTPS
HTTPS是以安全为目标的HTTP通道 在HTTP的基础上通过传输加密和身份认证保证了传输过程的安全性
二、登录信息
usr:pass
表示的是登录认证信息 包括登录用户的用户名和密码
虽然登录认证信息可以在URL中体现出来 但绝大多数URL的这个字段都是被省略的 因为登录信息可以通过其他方案交付给服务器
三、服务器地址
www.example.jp表示的是服务器地址 也叫做域名
HTTP请求和响应
HTTP请求协议格式
HTTP的请求协议格式如下:
我们可以看到HTTP请求由四部分组成
- 请求行:[请求方法]+[url]+[http版本]
- 请求报头:请求的属性 这些属性都是以key: value的形式按行陈列的
- 空行:遇到空行表示请求报头结束
- 请求正文:请求正文允许为空字符串 如果请求正文存在 则在请求报头中会有一个Content-Length属性来标识请求正文的长度
其中前面三部分是由HTTP协议自带的 而请求正文则是用户的相关信息和数据 如果说用户没有信息要上传给服务器 此时正文则为空
如何将HTTP请求的报头与有效载荷进行分离?
首先我们要明白哪里是报头哪里是有效载荷
请求报头:请求行+请求报头
有效载荷:请求正文
细心的同学就可以发现了 事实上报头和有效载荷之间隔着一个空行
如果我们将整个http协议想象成一个线性的结构 每一行都是使用\n
来进行分隔的 那么如果我们连续读取到两个\n
的话就说明报头读取完毕了开始读取有效载荷
获取浏览器的HTTP请求
在网络协议栈中 应用层的下一层叫做传输层 而HTTP协议底层通常使用的传输层协议是TCP协议
因此我们可以用套接字编写一个TCP服务器 然后启动浏览器访问我们的这个服务器
由于我们的服务器是直接用TCP套接字读取浏览器发来的HTTP请求 此时在服务端没有应用层对这个HTTP请求进行过任何解析
因此我们可以直接将浏览器发来的HTTP请求进行打印输出 此时就能看到HTTP请求的基本构成
HTTP响应格式
HTTP响应协议格式如下:
HTTP响应由以下四部分组成:
- 状态行:[http版本]+[状态码]+[状态码描述]
- 响应报头:响应的属性 这些属性都是以key: value的形式按行陈列的
- 空行:遇到空行表示响应报头结束
- 响应正文:响应正文允许为空字符串 如果响应正文存在 则响应报头中会有一个Content-Length属性来标识响应正文的长度 比如服务器返回了一个html页面 那么这个html页面的内容就是在响应正文当中的
如何将HTTP响应的报头与有效载荷进行分离?
对于HTTP响应来讲 这里的状态行和响应报头就是HTTP的报头信息 而这里的响应正文实际就是HTTP的有效载荷
而报头信息和响应正文之间我们使用换行符来进行分隔
当客户端收到一个HTTP响应后 就可以按行进行读取 如果读取到空行则说明报头已经读取完毕
再介绍其他的格式细节之前我们先来写上一部分的代码
项目编写
TcpSever
我们首先再Linux服务器上创建一个项目目录 之后在项目目录里面创建一个hpp文件
这里有同学可能会有疑惑 为什么要写的是一个HTTP的服务器 这里确要先写一个TcpSever呢
这是因为Http是基于Tcp的 我们首先要保证信息的传输之后再设计http
为什么文件格式要是hpp
hpp格式的C++文件通常可以将类的声明和实现放在一起 比较适合存在于开源项目中
我们设计一个TcpSever类只需要提供一个端口就可以了 因为操作系统会给我们自动分配一个ip 需要注意的是因为我们这里使用的是云服务器 最好不要使用云服务器的公网ip和私有ip 否则有可能会出现对端主机连接不上的情况(ip不是真正的公网ip)
下面是TcpServer的代码
#pragma once #include <iostream> #include <cstdlib> #include <cstring> #include <sys/types.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include <pthread.h> #define PORT 8081 #define BackLog 5 class TcpServer{ private: int _port; int _listen_sock; static TcpServer* svr; private: TcpServer() :_port(PORT), _listen_sock(-1) {} W> TcpServer(const TcpServer &s) {} TcpServer(int port) :_port(port), _listen_sock(-1) {} public: static TcpServer* getinstance(int port) { static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; if (nullptr == svr) { pthread_mutex_lock(&lock); if(nullptr == svr) { svr = new TcpServer(port); svr->InitSever(); } pthread_mutex_unlock(&lock); } return svr; } void InitSever() { Socket(); Bind(); Listen(); } void Socket() { _listen_sock = socket(AF_INET,SOCK_STREAM,0); if(_listen_sock < 0) { exit(1); } // 地址复用 防止突然断开连接 不能立刻复用端口 int opt = 1; setsockopt(_listen_sock,SOL_SOCKET,SO_REUSEADDR,&opt , sizeof(opt)); } void Bind() { struct sockaddr_in local; memset(&local , 0 ,sizeof(local)); local.sin_family = AF_INET; local.sin_port = htons(_port); local.sin_addr.s_addr = INADDR_ANY; // 不能直接绑定公网IP if(bind(_listen_sock , (struct sockaddr*)&local , sizeof(local))<0){ exit(2); } } void Listen() { if(listen(_listen_sock,BackLog) < 0) { exit(3); } } int Sock() { return _listen_sock; } ~TcpServer() { } }; TcpServer* TcpServer::svr = nullptr;
上面是我们使用单例模式设计出来的一个TcpSever 创建Listen套接字 绑定 监听都是网络部分很常见的套路 封装即可
这部分代码其实在网络部分就写过很多遍了 这里就不过多赘述
main.cc
#include <string> #include <iostream> #include <memory> #include "HttpSever.hpp" static void Usage(std::string proc) { std::cout << "Usage:\n\t" << proc << " port" << std::endl; } int main(int argc , char* argv[]) { if (argc != 2) { Usage(argv[0]); exit(4); } int port = atoi(argv[1]); std::shared_ptr<HttpSever> http_server(new HttpSever(port)); http_server->InitSever(); http_server->Loop(); for(;;) { ; } return 0; }
makefile
bin=httpsecer cc=g++ LD_FLAGS=-std=c++11 -lpthread src=main.cc $(bin):$(src) $(cc) -o $@ $^ $(LD_FLAGS) .PHONY:clean clean: rm $(bin)
HttpServer.hpp
#pragma once #include <istream> #include <pthread.h> #include "TcpServer.hpp" #include "Prorocol.hpp" class HttpSever{ private: bool _stop; // 是否在运行 int _port; TcpServer* _tcp_server; public: HttpSever(int port = 8081) W> :_port(port), _stop(false), _tcp_server(nullptr) {} void InitSever() { _tcp_server = TcpServer::getinstance(_port); } void Loop() { int listen_sock = _tcp_server->Sock(); while(!_stop) { struct sockaddr_in peer; socklen_t len = sizeof(peer); int sock = accept(listen_sock ,(struct sockaddr*)&peer , &len); if (sock < 0) { continue; // 获取失败了不暂停 } int* msock = new int(sock); pthread_t tid; pthread_create(&tid , nullptr , Entrance::HandlerRequest , msock); pthread_detach(tid); } } ~HttpSever() {} };
Prorocol
#pragma once #include <unistd.h> #include <iostream> class Entrance { public: static void* HandlerRequest(void* _sock) { int sock =*(int*)_sock; delete (int*)_sock; std::cout << "get a new link ..." << sock << std::endl; close(sock); return nullptr; } };
我们写完上面的代码之后编译运行 之后使用浏览器来访问我们服务器的8081端口
我们可以发现这样的现象
服务器一直在打开4和5套接字 这是为什么呢?
因为浏览器不知道自己的请求有没有被服务器接收到 所以说浏览器一直重新发送请求 这也就导致了服务器一直打开新的套接字
为什么文件描述符是从4开始的呢?
因为C++文件默认会打开 0 1 2 三个文件描述符 此外监听套接字也会占用一个文件描述符
接收报头
此时我们的服务器就已经可以跑起来了 接下来我们就重点完成Prorocol里面的内容 接收到请求之后应该怎么做
我们首先让协议打印出我们接收的请求 该段代码如下
#ifndef DEBUG #define DEBUG char buffer[4096]; recv(sock , buffer , sizeof(buffer) , 0); std::cout << "--------------begin-------------" << std::endl; std::cout << buffer << std::endl; std::cout << "-------------- end -------------" << std::endl; #endif
之后我们重新编译运行
此时我们就可以发现 我们的服务器可以打印浏览器发送过来的一些请求报头
接下来我们逐段分析下这些请求报头
我们可以看到在请求报头的第一行GET方法后面有个 \ 标志 这个标志和我们的Linux服务器根目录的标志十分相似 那么它到底是不是Linux服务器的根目录呢?
我们的回答是 不一定
这个路径通常不是我们Linux服务器的根目录 而是由http服务器设置的一个WEB根目录
这个WEB根目录就是Linux下一个特定的路径
读取请求
其实我们HTTP处理请求的过程无非就是三步
- 读取请求
- 分析请求
- 构建响应并返回
我们目前仍然出于处理请求的阶段 有同学可能会有疑问 我们这里不是将数据全部都读取完毕了嘛?
其实我们这里读取的数据是不准确的
char buffer[4096]; recv(sock , buffer , sizeof(buffer) , 0); std::cout << "--------------begin-------------" << std::endl; std::cout << buffer << std::endl; std::cout << "-------------- end -------------" << std::endl;
我们读取的代码是这样子 它不管客户端每次发送过来多少的请求 我们直接将它放在一个4096字节的数组里面
但是有没有一种可能 客户端一次性会发来多个请求呢? 那么这样子我们还是一次性读取4096个字节 是不是就会发生一个粘包的问题啊 那么这个时候我们就要想办法解决这个问题
我们从它的请求格式来分析
我们观察上图可以发现 实际上HTTP协议的报头是按照行来分隔的 并且在报头和正文中间有一个空行作为间隔 所以说我们就能很简单的将报头和正文分隔开
但是这里就会出现一个问题 那就是每个平台或是浏览器他们分隔一行的方式可能不同 具体有下面三种
- xxxxxxx /r/n
- xxxxxxx /r
- xxxxxxx /n
所以说我们还要自己主动实现一个类来实现分隔行的问题
代码如下
#pragma once #include <sys/types.h> #include <sys/socket.h> #include <iostream> #include <string> // ¹¤¾ßÀà class Util { public: static int ReadLine(int sock ,std::string &out) { char ch = 'X'; while(ch != '\n') { ssize_t s = recv(sock , &ch , 1 ,0); if (s > 0) { if (ch == '\r') { // ת»¯Îª /n recv(sock , &ch , 1 , MSG_PEEK); if (ch == '\n') { recv(sock , &ch , 1 , 0); } else { ch = '\n'; } } // 1. ÆÕͨ×Ö·û // 2 \n out.push_back(ch); } else if (s == 0) { return 0; } else { return -1; } } return out.size(); } };
简单解释下上面的代码 它的作用是将每一行的结束标识符全部统一为\n
因为我们行结束的标志只有三种 所以我们可以做以下区分
- 如果读到了\n 则说明一行读取完毕
- 如果读到了\r 我们不确定后面一个是不是\n 所以说我们需要窥探下一个字符是什么
- 我们使用recv(sock , &ch , 1 , MSG_PEEK)来窥探下一个字符 其中MSG_PEEK选项的含义就是下一个字符
- 如果下一个字符是\n 则读取下一个字符将当前的\r覆盖
- 如果不是\n则直接将当前字符修改为\n
在能够读取单行之后我们可以开始获取一个完整的Http请求了
我们将实现获取完整Http请求这个步骤放在Prorocol这个文件里面方便管理
具体的实现思想是
- 我们分别设置一个请求类和响应类来保存请求和响应
- 之后我们再设计一个终端类 在这个终端类当中获取到完整的Http请求并且构建起响应
具体代码如下
#pragma once #include <unistd.h> #include <iostream> #include <sys/types.h> #include <sys/socket.h> #include <string> #include <vector> #include "Util.hpp" class HttpRequest{ public: std::string _request_line; std::vector<std::string> _request_header; std::string _blank; std::string _request_body; }; class HttpResponse{ public: std::string _response_line; std::vector<std::string> _response_header; std::string _blank; std::string _response_body; }; class EndPoint{ private: int _sock; HttpRequest http_request; HttpResponse http_response; private: void RecvRequestLine() { Util::ReadLine(_sock ,http_request._request_line); } public: EndPoint(int sock) :_sock(sock) {} void RcvRequest() {} void ParseRequest() {} void SendResponse() {} void BuildResponse() {} ~EndPoint() { close(_sock); } }; class Entrance { public: static void* HandlerRequest(void* _sock) { int sock =*(int*)_sock; delete (int*)_sock; std::cout << "get a new link ..." << sock << std::endl; #ifdef DEBUG char buffer[4096]; recv(sock , buffer , sizeof(buffer) , 0); std::cout << "--------------begin-------------" << std::endl; std::cout << buffer << std::endl; std::cout << "-------------- end -------------" << std::endl; #else EndPoint* ep = new EndPoint(sock); ep->RcvRequest(); ep->ParseRequest(); ep->BuildResponse(); ep->SendResponse(); delete ep; #endif return nullptr; } };
日志
为什么要写日志
这个问题也可以是为什么要有日志
日志可以帮助我们了解错误发生的原因和程序运行的状态 从而可以帮助我们更好的去排除错误或者是优化我们的程序
我们要写的日志格式是什么样的
格式如下
分别介绍下上面各个信息
日志级别
日志也是分级别的 这些级别让我们更好的辨别要如何处理这条日志
级别一般分为下面几种
- INFO 正常的打印信息
- WARNING 警告 可能会有错误
- ERROR 有错误 但是不影响程序运行
- FATAL 致命的错误 程序运行不了了
时间戳
我们可以直接使用c语言函数来获取时间戳
c语言中的time函数能够返回给我们一个时间戳
函数原型如下
time(nullptr)
使用时我们只需要传入参数nullptr即可 (如果不是C++11以后的版本传入NULL)
日志信息
告诉我们错误和风险提示等信息
错误文件名称和行数
在c语言中预先定义了__FILE__ 以及 __LINE__
我们直接使用它们来获取错误文件名称和行数即可
代码如下
#pragma once #include <iostream> #include <string> #include <ctime> #define INFO 1 #define WARNING 2 #define ERROR 3 #define FATAL 4 #define LOG(level , message) Log(#level , message , __FILE__ , __LINE__) void Log(std::string level, std::string message , std::string file_name , int line) { std::cout << level << " " << time(nullptr) << " " << message << " " << file_name << " " << line << std::endl; }
之后我们只需要将LOG宏使用起来即可