目录
- 网络基础------2
-
- 1.应用层
-
- 1.1再谈协议
- 1.2小实验------网络计算器
- 1.3HTTP协议
-
- [1.3.1 认识URL](#1.3.1 认识URL)
- 1.3.2urlencode和urldecode
- 1.3.3HTTP协议格式
- [1.3.4 一些网页基础知识](#1.3.4 一些网页基础知识)
- [1.3.4 长链接](#1.3.4 长链接)
- [1.3.5 http周边会话保持](#1.3.5 http周边会话保持)
- [1.3.6 基本工具(postman, fiddler)](#1.3.6 基本工具(postman, fiddler))
- [1.4 https协议](#1.4 https协议)
-
- [1.4.1 中间人攻击](#1.4.1 中间人攻击)
- [1.4.2 数据加密](#1.4.2 数据加密)
- [1.4.3 数据摘要](#1.4.3 数据摘要)
- [1.4.4 证书](#1.4.4 证书)
-
- [1.4.4.1 为什么需要证书](#1.4.4.1 为什么需要证书)
- [1.4.4.2 CA证书](#1.4.4.2 CA证书)
- [1.4.4 https采取的解决方案](#1.4.4 https采取的解决方案)
网络基础------2
1.应用层
程序员写的一个个解决我们实际问题, 满足我们日常需求的网络程序, 都是在应用层。因此下面就来学习一些有关应用层的知识
1.1再谈协议
在socket.api的接口环境下,传输数据的时候,都是以"字符串"(一串数据)的形式去传输数据的,但是如果我们想传输一些结构化的数据要怎么办呢?------其实就是序列化
序列化其实就是将多个字符串数据,变成一个字节流(形成一个报文),由多变一,序列化为一个序列字节流(即一份报文)。
而在序列化之前,要先有结构化的数据,结构化类似于描述成数据结构,因为多个数据可以描述为一个结构体/类,数据就是这些数据结构的成员/属性(而这每个对象都必须有的成员/属性,就是业务协议(应用层逻辑)规定的!比如qq用户发送一个信息,除了发送的信息本身!还会自带头像,发送时间,ip地址等等信息)。然后就往往发送的数据就是一个类的对象。
只要保证, 一端发送时构造的数据, 在另一端能够正确的进行解析, 就是ok的. 这种约定, 就是 应用层协议

既然有序列化的过程,自然就有反序列化的过程。反序列化的过程就是将序列字节流(报文),拆成需要的多个数据。其实就是将结构化的对象,提取需要的数据,然后在上层就使用这些数据。
但是这里有一个问题------**怎么保证上层收到的是一个完整的报文?**比如接受到一份数据,怎么知道这是一份报文还是半个报文,还是多个报文呢?这些问题后面在做小实验的时候,会解决
1.2小实验------网络计算器
1.2.1准备知识
实际上tcp在发送数据,不是发送函数一执行就发送到对面去的。
我们知道,在应用层有应用层的缓冲区,实际上在tcp中也有缓冲区。而执行发送函数/写函数其实本质都是拷贝函数。是将应用层缓冲区的数据拷贝到tcp层的发送缓冲区。由tcp控制什么时候发送到网络上去。
说道这里终于能理解tcp协议的本质了------传输控制协议,这也能解释为什么tcp是传输层的协议

因此在应用层想发送一份数据给对方,实际上是先结构化数据,然后序列化为报文(序列字节流),然后通过发送函数将数据从应用层缓冲区拷贝到tcp的发送缓冲区
然后由tcp控制什么时候发到网络,通过网络发送到目标主机的tcp的接受缓冲区。
然后由对方应用层调用接收函数(如read)将数据拷贝到应用层缓冲区,这也就是为什么当数据没有发给对方的时候,对方调用read会阻塞的原因------因为接受缓冲区没有数据,于是阻塞等待
然后在反序列化,拿到结构化的数据,再进行响应
可以说tcp传输数据的本质------即将数据从一个发送缓冲区,拷贝到另一个接受缓冲区

这里也可以理解为什么tcp有一个特质是全双工了。即一个发送缓冲区在拷贝数据到对方的接受缓冲区的时候,并不影响对方的发送缓冲区拷贝数据到我的接受缓冲区。
绕回到上面提出的问题------如何保证应用层从tcp的接受缓冲区读取到的是一个完整的报文?
由于接受缓冲区中可能存了很多份报文(序列字节流),为了保证能准确的读到一份报文,就要明确报文和报文之间的边界。
如何明确呢?有很多的方法
- 定长------每份报文都是固定的长度,这个在最开始就有了解到
- 特殊符号------每份报文的开头和结尾是特殊符号,当读到某个特殊符号即为开头或者结尾
- 自描述方法------自己设计。
下面的实验中采用的就是自描述方法------在每个报文的前面会有一个大小为4字节的字段,这个字段表示的是后面的有效载荷的大小。然后根据这个大小去读取报文。这样就能完整的读取到一个完整的报文【其实这个方式就是之前一开始了解到的报头+有效载荷的方法】

注意在本次实验中,这个红色圈起来的部分就是序列字节流(对请求和响应序列化的结果),即有效载荷
1.2.2实验代码
注意:本次小实验的代码。重点是关注,数据在发送请求给对面,然后对方接受请求,做出响应,将响应发送回来的过程。体会到:数据--->结构化数据--->序列化--->发送--->读取--->反序列化--->结构化数据--->数据--->进行业务逻辑处理
因此序列化和反序列化的结构,以及协议的定制相关,本次实验都要自己实现。
但是实际上,序列化和反序列化接口在以后是不可能由自己写的,这里自己写只是为了更好的学习和体会这个过程。而写协议的过程是很重要的,这里也要注意一下
calClient.hpp
cpp
#pragma once
#include<iostream>
#include<string>
#include<memory>
#include<string.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include<arpa/inet.h>
#include<unistd.h>
#include"protocol.hpp"
using namespace std;
class tcpClient
{
public:
tcpClient(const string& serverip, const uint16_t& port)
:_sockfd(-1)
,_serverip(serverip)
,_serverport(port)
{}
void InitClient()
{
// 创建socket(套接字)
_sockfd = socket(AF_INET, SOCK_STREAM, 0);
if(_sockfd < 0)
{
// 客户端也是可以弄日志的[类似之前用某些软件崩溃后,会弹出一个是否同意上传日志,]
// 这里我没弄, 只弄了服务器的日志
cerr << "socket create error" << endl;
exit(2);
}
cout << "create socket success\n";
//2.bind ip和port【tcp的客户端bind同样不需要显示bind, 由os来管】
// 并且客户端不需要listen, 所以客户端不需要accept
// 对于客户端,要向服务器端发起链接,让服务端listen到,然后accept
}
void run()
{
// 填充结构体,为后来给服务端传输数据做准备
struct sockaddr_in server;
bzero(&server, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(_serverport);
server.sin_addr.s_addr = inet_addr(_serverip.c_str());
// 准备与服务端通信,但tcp通信之前需要先建立链接,对于客户端来说要向服务端发起新链接
if(connect(_sockfd, (struct sockaddr*)&server, sizeof(server)) != 0)
{
//建立链接失败肯定没发继续通信了, 但是也没必要exit
cerr << "socket connect error" << endl;
}
else
{
// 成功建立连接之后就可以通信了
string message;
string inbuffer;
while(true)
{
cout << "Enter: \n";
getline(cin, message); // 用户输入的1+1或者 1 + 1
// 获取到用户的输入信息之后,客户端也得遵守协议,向服务器端发送请求,然后接受服务器的响应
Request req = ParseLine(message); // 要先变成结构化的数据
// cout << req._x << req._op << req._y << endl;
string content;
req.serialize(&content); //序列化
// cout << "content: " << content << endl;
string send_str;
if(!enLength(send_str, content)) //形成报文
continue; //很难失败的说
// cout << "send_str: "<< send_str << endl;
send(_sockfd, send_str.c_str(), send_str.size(), 0); // 这里发送有问题,先不管
// 接受服务器发回来的响应
// 首先要保证能读到一份完整的报文
string res_str; //存放读取到的完整的报文'
if(!recvPackage(_sockfd, inbuffer, &res_str))
continue;
// cout << res_str << endl;
string res_content; //用于接受有效载荷, 即"exitcode result"
if(!deLength(res_str, &res_content)) // 从报文分离有效载荷
continue;
// cout << res_content << endl;
Response res;
res.deserialize(res_content); //反序列化
// cout << res._exitcode << res._result << endl;
if(res._exitcode == 0)
{
// 只有当exitcode为0时,才是没有出现错误,才有答案
cout << "exitcode: " << res._exitcode << " result: " << res._result << endl;
}
else //走到这里就说明出现了计算错误,比如除0错误
{
cout << "exitcode: " << res._exitcode << " result: " << res._result << endl;
continue;
}
}
}
}
// 将用户输入的数据进行字符串提取
Request ParseLine(const string& line)
{
// 首先line一定是 1+1,或者123+312之类的字符串
int status = 0; // 0表示遇到操作符之前,1表示遇到了,2表示操作符之后
int i = 0;
int size = line.size();
char op;
string left, right; //left放op左边的数字,right放op右边的数字
while(i < size)
{
switch(status)
{
case 0:
if(!isdigit(line[i])) //判断是否为数字
{
// 走进来就说明不是数字,是op
op = line[i];
status = 1;
}
else
{
left.push_back(line[i++]);
}
break;
case 1:
i++;
status = 2;
break;
case 2:
right.push_back(line[i++]);
break;
}
}
// 此时就将切割出来的数据进行结构化处理
// Request req;
// req._x = stoi(left);
// req._y = stoi(right);
// req._op = op;
// return req;
return Request(stoi(left), op, stoi(right)); // 等价上面的几行代码
}
~tcpClient()
{
if(_sockfd >= 0)
close(_sockfd); //这里手动关一下,其实服务器进程一关,这个文件描述符也就关了
}
private:
int _sockfd;
string _serverip;
uint16_t _serverport;
};
calClient.cc
cpp
#include"calClient.hpp"
static void Usage(string proc)
{
cout << "Usage: \n" << proc << " server_ip server_port\n";
}
// 在调用这个客户端进程的时候要传ip和port进来
// ./udpClient ip port
int main(int argc, char* argv[])
{
// 客户端和服务端不一样,服务端可以将接受的ip地址设为0, 采用任意地址绑定
// 客户端要给服务端发消息必须给客户端指定服务端的IP地址和port
if(argc != 3)
{
Usage(argv[0]);
exit(1);
}
string serverip = argv[1];
uint16_t serverport = atoi(argv[2]);
// 走到这里就创建客户端对象
unique_ptr<tcpClient> tc(new tcpClient(serverip, serverport));
tc->InitClient();
tc->run();
return 0;
}
protocol.hpp
cpp
#pragma once
//这个头文件用于制定一些业务协议
#include<iostream>
#include<string>
#include <sys/socket.h>
#include<vector>
using namespace std;
enum
{
DIV_ZERO = 1,
MOD_ZERO,
OP_ERR
};
#define SEP " "
#define SEP_LEN strlen(SEP) //不能用sizeof
#define LINE_SEP "\r\n"
#define LINE_SEP_LEN strlen(LINE_SEP) //不能用sizeof
// 将序列化的数据+上一个报头,形成一个新的报文
// "x op y" --> "content_len\r\n""x op y\r\n"
// "exitcode result" --> "content_len\r\n""exitcode result\r\n"
bool enLength(string& send_string, const string& text)
{
string str = to_string(text.size());
str += LINE_SEP;
str += text;
str += LINE_SEP;
send_string = str;
return true;
}
// 将读取到的数据,拿掉报头,拿到一个准确的有效载荷------------序列化的数据
// "content_len\r\n""x op y\r\n" --> "x op y"
// "content_len\r\n""exitcode result" --> "exitcode result"
bool deLength(const string& package, string* text)
{
auto pos = package.find(LINE_SEP);
if(pos == string::npos)
return false;
string context_len = package.substr(0, pos);
int text_len = stoi(context_len);
*text = package.substr(pos + LINE_SEP_LEN, text_len);
return true;
}
// 由于实现的是计算器,因此就制定协议,比如接收到的必须是 x op y。 比如2 % 4
class Request
{
public:
Request(int x = 0, char op = 0, int y = 0)
:_x(x), _op(op), _y(y)
{}
// 不管是请求还是响应都是要被序列化和反序列化的。因此都需要有序列化接口
// 这个接口可以自己写,也可以用现成的
bool serialize(string* out) // 序列化接口
{
// 序列化其实就是结构化的数据转化成报文(序列字节流)
// 结构化数据 ---------------> x op y
string xstring = to_string(_x);
string ystring = to_string(_y);
*out = ""; //先进行初始化
*out += xstring;
*out += SEP;
*out += _op;
*out += SEP;
*out += ystring;
return true;
}
bool deserialize(const string& in) // 反序列化接口
{
// x op y -> 结构化数据
// 简单来说就是提取出x op y
// 这里自己写可以用很多方法,比如循环,也可以直接用容器接口
int left = in.find(SEP);
int right = in.rfind(SEP);
if(left == right)
return false; //一定是两个分隔符
if(left == string::npos || right == string::npos)
return false;
if((right - left - SEP_LEN) != 1) //有且只有一个操作符
return false;
//走到这里就是找到了两个分隔符,现在就是将x op y提取出来
// 这里用substr实现子串截取,要注意参数, 第一个参数是起始位置,第二个参数是截取长度
string x = in.substr(0, left);
string y = in.substr(right + SEP_LEN, in.size() - right - SEP_LEN);
// char op = in.substr(left + SEP_LEN, right - left - SEP_LEN)[0];
char op = in[left + SEP_LEN]; //等价上面这句代码
// 然后将提出出来的数据进行处理
if(x.empty()) return false;
if(y.empty()) return false;
_x = stoi(x);
_y = stoi(y);
_op = op;
return true;
}
public:
int _x;
char _op;
int _y;
};
class Response
{
public:
Response()
:_exitcode(0), _result(0)
{}
Response(int exitcode, int result)
:_exitcode(exitcode), _result(result)
{}
bool serialize(string& out)
{
// 结构化数据 --> "_exitcode _result"
string str_exitcode = to_string(_exitcode);
string str_result = to_string(_result);
out = "";
out += str_exitcode;
out += SEP;
out += str_result;
return true;
}
bool deserialize(const string& in)
{
// "_exitcode _result" --> 结构化数据
auto mid = in.find(SEP);
if(mid == string::npos)
return false;
// 截取子串
string ex_string = in.substr(0, mid);
string res_string = in.substr(mid + SEP_LEN);
if(ex_string.empty() || res_string.empty())
return false;
_exitcode = stoi(ex_string);
_result = stoi(res_string);
return true;
}
public:
int _exitcode; //定好标准,0表示计算成功,!0表示计算失败,遇到错误。具体是多少,自己定制,比如1是÷0错误
int _result; // 这是计算成功后的结果
};
// text是输出型参数
bool recvPackage(int sockfd, string& inbuffer, string* text)
{
char buffer[1024];
while(1)
{
ssize_t n = recv(sockfd, buffer, sizeof(buffer)-1, 0); //阻塞式读取,0代表读取方式,默认0即可
if(n > 0) //大于0就是读到了n个字节长度
{
buffer[n] = 0;
inbuffer += buffer;
// 此时inbuffer中+上了本次读取的数据,这个时候就要判断里面是否含有一份完整的报文------------ "content_len\r\n""x op y\r\n"
int pos = inbuffer.find(LINE_SEP);
if(pos == string::npos) //如果没找到的话就说明压根就不可能有完整报文
continue;
// 一旦能读到LINE_SEP, 就一定能找到content_len
string content_str = inbuffer.substr(0, pos); //找到content_len之后,就能知道正文的长度
int text_len = stoi(content_str);
int total_len = content_str.size() + 2*LINE_SEP_LEN + text_len;
// 此时拿到了一份完整的报文所应该拥有的长度
if(inbuffer.size() < total_len) //一旦小于,就说明肯定没有一份完整的报文
{
cout << "读取到的内容的格式并不是遵守了协议的规定,因此等待后序的内容,continue\n";
continue;
}
cout << "处理前的inbuffer: " << inbuffer << endl;
// 走到这里就说明,,inbuffer里面肯定有一份完整的报文
*text = inbuffer.substr(0, total_len); //将完整报文给切下来
//【注意这里不考虑发送方发的是乱序的, 序列字节流,只考虑,没发完整,即顺序是对的】
inbuffer.erase(0, total_len); //将已经读取的报文给清除掉
cout << "处理后的inbuffer: \n" << inbuffer << endl;
break;
}
else if(n == 0)
return false;
else
return false;
}
return true;
}
// 下面这个函数表示可以同时接受多个报文,即多个进程调用该函数,每个进程将自己读到的报文都放到out这个vector中
// 但是由于recvRequest是阻塞式读取,这里就不适合
// bool recvRequestAll(int sockfd, vector<string>& out)
// {
// string line;
// while(recvRequest(sockfd, &line)) //不断循环的去读取,读到一个完整报文,就放到
// {
// out.push_back(line);
// }
// }
calServer.hpp
cpp
#pragma once
#include <iostream>
#include <string>
#include <cerrno>
#include <cstring>
#include <cstdlib>
#include <strings.h>
#include <functional>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <unistd.h>
#include <sys/wait.h>
#include <signal.h>
#include <pthread.h>
#include "log.hpp"
#include "protocol.hpp"
using namespace std;
enum
{
USAGE_ERR = 1,
SOCKET_ERR,
BIND_ERR,
LISREN_ERR,
OPEN_ERR,
};
static const uint16_t gport = 10086; // 默认端口号,外部没传就调用这个
static const int gbacklog = 5;
// req是输入型参数,res是输出型参数
typedef function<bool(const Request &req, Response &res)> func_t; // 功能为,req这个请求的响应结果放到了res中
// 正常来说HandlerEnter的解耦方式,是回调函数比较好一些,
// 但是这里为了简单的演示,将函数放到类外来简单实现解耦
void HandlerEnter(int sockfd, func_t func)
{
string inbuffer;
while (true)
{
// 1.读取------------读取到的是序列字节流(报文)
// 这里先要解决一个问题------------这里怎么保证读到的是一份完整的请求呢
string str_text; //用于接收一个完整的请求
if (!recvPackage(sockfd, inbuffer, &str_text)) // 由于是多进程执行,因此recvRequest可以选择阻塞式读取
return;
cout << "带报头的请求:\n" << str_text << endl;
// 接收到一个完整报文之后,就要将有效载荷分离出来
string req_text; //用于接受请求的有效载荷,即"x op y"
if (!deLength(str_text, &req_text))
return;
cout << "去掉报头的正文:\n" << req_text << endl;
// 2.对请求(Request)进行反序列化
// 反序列化后得到的是一个结构化的数据。
Request req;
if (!req.deserialize(req_text)) // 反序列化
return; // 反序列化失败就返回
// cout << req._x << req._op << req._y << endl;
// 3.对结构化数据进行处理 (只有3这里才是真正的业务逻辑)
// 这里的处理是根据规定好的业务协议去处理的。
// 处理后会得到一个结构化的响应(Response)。
Response res;
func(req, res); // 这里有一个设计, req是输入型参数,res是输出型参数,res最终会被修改,即响应的结果
// cout << res._exitcode << res._result << endl;
// 走到这里,res已经填充好处理响应后的数据了
// 4.将响应后的结构化数据进行序列化
// 得到一个序列字节流(即报文)
string res_str;
res.serialize(res_str);
cout << "计算完成,将响应的结构化数据进行序列化: " << res_str << endl;
// 5.发送响应------------将序列字节流(报文)发送给客户端
// 先构建成完整的报文之后才能发出去
// 完成这五步才算是一个完整的业务逻辑处理过程
string send_string;
if(!enLength(send_string, res_str))
return;
cout << "添加报头,构建完整的响应报文: \n" << send_string << endl;
// 接下来就是发送,write可以,send也可以
send(sockfd, send_string.c_str(), send_string.size(), 0); // 最后一个参数是发送方式,默认为0
// 注意了其实这里的send还是有问题的
}
}
class tcpServer
{
public:
tcpServer(const uint16_t &port = gport)
: _listensockfd(-1), _port(port)
{}
void InitServer()
{
// 1.创建tcp套接字(socket)
_listensockfd = socket(AF_INET, SOCK_STREAM, 0); // SOCK_STREAM表示要创建TCP套接字
if (_listensockfd < 0)
{
logMessage(FATAL, "create socket error");
exit(SOCKET_ERR);
}
logMessage(NORMAL, "create socket success: %d", _listensockfd);
// 2.bind 绑定自己的网络信息【ip和port等】
struct sockaddr_in local;
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = INADDR_ANY; // 任意地址bind
if (bind(_listensockfd, (struct sockaddr *)&local, sizeof(local)) < 0)
{
logMessage(FATAL, "bind error");
exit(BIND_ERR);
}
logMessage(NORMAL, "bind success");
// 由于tcp是需要建立连接的,因此这里需要设置一个监听
// 3. 设置socket为监听状态【需要知道客户端是否给我发消息】
if (listen(_listensockfd, gbacklog) < 0)
{
logMessage(FATAL, "listen socket error");
exit(LISREN_ERR);
}
logMessage(NORMAL, "listen socket success");
}
void Start(func_t func) // 在服务器启动的时候,告诉服务器要执行func这个函数(任务)
{
for (;;)
{
// tcp不能直接接受或发送消息,需要先建立链接
// 1.server获取新链接
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int sockfd = accept(_listensockfd, (struct sockaddr *)&peer, &len);
if (sockfd < 0)
{
logMessage(ERROR, "accept error, next"); // 获取新链接失败不会导致程序崩溃,再获取就行了
continue;
}
logMessage(NORMAL, "accept a new link success! new sock: %d", sockfd);
// 由于tcp的通信逻辑在读取数据的时候就是一个死循环,因此当有多个客户端进来的时候,就无法为多个客户端提供服务
// 为了解决这个问题,就需要设计出多个解决方案【多进程,多线程, 线程池,多路转接】
// 有关其他版本的代码这里就不贴了,忘记或者想再看的话去tcp_1的tcpServer.hpp这个文件中去看
// 2.2多进程版本2
// 弄成非阻塞等待,但是要结合信号捕捉,当子进程退出的时候,捕捉其信号再让父进程去捕捉它、【signal(SIGCHLD, SIG_IGN)】
signal(SIGCHLD, SIG_IGN); // SIG_IGN是LinuxOS提供的一个专门用来处理子进程资源释放的函数
pid_t id = fork();
if (id == 0)
{
close(_listensockfd);
// serverIO(sockfd);
// 为了体现应用层的业务中,数据是如何处理发送和接收的,这里来一个不用serverIO这个业务逻辑,用一个简单的计算器
HandlerEnter(sockfd, func);
// 为了让这个tcpServer类更加的纯粹,只负责服务器任务,这里的HandlerEnter不能是类内方法,要进行解耦------------即之前学习过的回调函数来处理
close(sockfd);
exit(1);
}
// 不需要父进程等待子进程了
close(sockfd); // 文件描述符必须关掉,因为不关的话会导致文件描述符泄漏
}
}
~tcpServer() {}
private:
int _listensockfd; // listen套接字【不进行数据通信,只是用来监听是否有链接到来,建立新链接】
uint16_t _port;
string _ip;
};
calServer.cc
cpp
#include"calServer.hpp"
#include<memory>
//走到这里就是外部调用错误,没有传应该传的参数进来, 这里就提示一下
static void Usage(string proc)
{
cout << "Usage: \n" << proc << "local_port\n";
}
// req,输入型参数,一定是一个完整的请求对象
// res,输出型参数,将请求根据业务逻辑处理后,将响应结果放到res, 也是一个对象
// 而cal函数不需要管理任何的序列化啊,反序列化之类的操作
bool cal(const Request& req, Response& res) //截止到该函数,当前应用层已经分为了3层
{
res._exitcode = 0;
res._result = 0; //初始化,但是这里只是规范一下。其实在构造函数的时候就已经初始化完毕了
switch(req._op)
{
case '+':
res._result = req._x + req._y;
break;
case '-':
res._result = req._x - req._y;
break;
case '*':
res._result = req._x * req._y;
break;
case '/':
if(req._y == 0)
res._exitcode = DIV_ZERO; //除0错误
else
res._result = req._x / req._y;
break;
case '%':
if(req._y == 0)
res._exitcode = MOD_ZERO;
else
res._result = req._x % req._y;
break;
default:
res._exitcode = OP_ERR;
break;
}
return true;
}
//和之前udp的server进程调用一样,要穿port
// ./tcpServer port
int main(int argc, char* argv[])
{
if(argc != 2) //必须得是2个元素,进程名,port。【这是命令行参数的知识,忘了就复习】
{
Usage(argv[0]);
exit(USAGE_ERR);
}
// 将argv中char*类型的port记载下来
uint16_t port = atoi(argv[1]); //必须转成int类型,因为argv里面全是char*类型的元素
unique_ptr<tcpServer> ts(new tcpServer(port));
ts->InitServer(); //初始化服务器
ts->Start(cal); //然后再启动服务器, 这里要将任务传给服务器
return 0;
}
客户端执行结果如下:

其实是比较简单的一份代码,重点是为了体验数据--->结构化数据(请求)--->序列化--->调用发送函数将数据拷贝到tcp协议的发送缓冲区--->由tcp协议控制何时发送到对方的接受缓冲区--->调用读取函数将数据拿到--->反序列化--->结构化数据--->拿到数据进行响应(处理)--->再次结构化数据(响应)--->序列化--->。。。。。。
要注意:上述代码中,其实是一个简易的小代码,其可拓展性是非常差的,问题是出在自定义的序列化和反序列化的接口上了,自定义的序列化接口过于简洁,导致能处理的情况很少。实际上,在以后得使用中,这个序列化是不可能由程序员自己实现的,都是调用的库里的,这里使用只是为了加深对于序列化的理解。
这个protubuf用于内网,即服务器组和服务器组之间的通讯,一般外网,简单的就用的是json,下面我们用的也是json
除了序列化是自己定义的,并且序列化在之后是不需要自己实现的,协议也是自己定制的,而协议是可以也是需要自己来定义的,这是很重要的一个认识
1.2.3使用库里的序列化接口
一般来说分三个主流的序列化方案,一个是json,一个是protobuf,xml(java常用)。这个protubuf用于内网,即服务器组和服务器组之间的通讯,一般外网,简单的就用的是json,下面我们用的也是json
这里用json的序列化接口。json创建出来的对象类型是KV结构的,具体可以去网上查找资料,这里不细讲
json有很多库,支持java和支持c++,在c++要使用的话要用jsoncpp这个库。需要下载,可以在网上去找资源下载,gitee和github都可以,但是可以直接用yum来下载
sudo yum install -y jsoncpp-devel
验证是否下载成功的话就像下面这样验证就可以

序列化接口只需要引入头文件\#include<jsoncpp/json/json.h>后,直接用就行了
代码如下:
cpp
// 由于实现的是计算器,因此就制定协议,比如接收到的必须是 x op y。 比如2 % 4
class Request
{
public:
Request(int x = 0, char op = 0, int y = 0)
:_x(x), _op(op), _y(y)
{}
// 不管是请求还是响应都是要被序列化和反序列化的。因此都需要有序列化接口
// 这个接口可以自己写,也可以用现成的
bool serialize(string* out) // 序列化接口
{
#ifdef MYSELF // 条件编译,用自己的方案,就用MYSEL这个宏编译
// 序列化其实就是结构化的数据转化成报文(序列字节流)
// 结构化数据 ---------------> x op y
string xstring = to_string(_x);
string ystring = to_string(_y);
*out = ""; //先进行初始化
*out += xstring;
*out += SEP;
*out += _op;
*out += SEP;
*out += ystring;
#else // 不用自己的序列化方案就用json的
Json::Value root; // Value是json的一个万能对象
root["first"] = _x; // 不用担心类型不一样,Value内会自动转化
root["second"] = _y;
root["oper"] = _op;
// 接下来就是序列化
Json::FastWriter writer; //这个和下面的一样都是用于序列化的,只是两种风格
// Json::StyledWriter writer;
*out = writer.write(root); //将序列化后的结果返回
#endif
return true;
}
bool deserialize(const string& in) // 反序列化接口
{
#ifdef MYSELF
// x op y -> 结构化数据
// 简单来说就是提取出x op y
// 这里自己写可以用很多方法,比如循环,也可以直接用容器接口
int left = in.find(SEP);
int right = in.rfind(SEP);
if(left == right)
return false; //一定是两个分隔符
if(left == string::npos || right == string::npos)
return false;
if((right - left - SEP_LEN) != 1) //有且只有一个操作符
return false;
//走到这里就是找到了两个分隔符,现在就是将x op y提取出来
// 这里用substr实现子串截取,要注意参数, 第一个参数是起始位置,第二个参数是截取长度
string x = in.substr(0, left);
string y = in.substr(right + SEP_LEN, in.size() - right - SEP_LEN);
// char op = in.substr(left + SEP_LEN, right - left - SEP_LEN)[0];
char op = in[left + SEP_LEN]; //等价上面这句代码
// 然后将提出出来的数据进行处理
if(x.empty()) return false;
if(y.empty()) return false;
_x = stoi(x);
_y = stoi(y);
_op = op;
#else
Json::Value root;
Json::Reader reader;
reader.parse(in, root); //将反序列后的结果放在root中
_x = root["first"].asInt();
_y = root["second"].asInt();
_op = root["oper"].asInt(); //_op是char类型,但是char本质也是int,所以这里转int,存到char里面自动会变char
#endif
return true;
}
public:
int _x;
char _op;
int _y;
};
class Response
{
public:
Response()
:_exitcode(0), _result(0)
{}
Response(int exitcode, int result)
:_exitcode(exitcode), _result(result)
{}
bool serialize(string& out)
{
#ifdef MYSELF
// 结构化数据 --> "_exitcode _result"
string str_exitcode = to_string(_exitcode);
string str_result = to_string(_result);
out = "";
out += str_exitcode;
out += SEP;
out += str_result;
#else
Json::Value root;
root["exitcode"] = _exitcode;
root["result"] = _result;
Json::FastWriter writer;
out = writer.write(root);
#endif
return true;
}
bool deserialize(const string& in)
{
#ifdef MYSELF
// "_exitcode _result" --> 结构化数据
auto mid = in.find(SEP);
if(mid == string::npos)
return false;
// 截取子串
string ex_string = in.substr(0, mid);
string res_string = in.substr(mid + SEP_LEN);
if(ex_string.empty() || res_string.empty())
return false;
..
_exitcode = stoi(ex_string);
_result = stoi(res_string);
#else
Json::Value root;
Json::Reader reader;
reader.parse(in, root); //将反序列后的结果放在root中
_exitcode = root["exitcode"].asInt();
_result = root["result"].asInt();
#endif
return true;
}
public:
int _exitcode; //定好标准,0表示计算成功,!0表示计算失败,遇到错误。具体是多少,自己定制,比如1是÷0错误
int _result; // 这是计算成功后的结果
};
如果要切换回自己的版本就定义MYSELF宏就好了

如果,不要自己的版本就带个#,不要定义MYSELF就好了
1.2.4总结
这个实验主要就是为了体验序列化和反序列化的存在和过程,并且要理解协议定制这个具体的操作,协议定制是非常重要的。
其实协议定制说人话就是定制一个结构化的对象,并且可以不止定制一种协议,可以定制很多协议,给每种协议带上编号就可以了。这样子就可以对很多种情况,自由做出选择。
其实写完这份代码,再回头来看OSI七层模型就明确了

第5层的会话层,其工作就是建立链接,和管理,其实就是上面实验代码中的,多进程/多线程建立会话链接。建立会话链接之后,才能开始通信
第6层的表示层,其工作其实就是将数据进行格式的转换,其实就是上面protocol.hpp中的代码,将传输中的数据进行序列化反序列化等操作,将数据从协议的格式中取出
第7层的应用层,就不难理解了,表示层将需要的数据取出来后,应用层拿到数据进行业务逻辑处理。相当于实验代码中的计算器业务逻辑
1.3HTTP协议
虽然我们说, 应用层协议是我们程序猿自己定的.
但实际上, 已经有大佬们定义了一些现成的, 又非常好用的应用层协议, 供我们直接参考使用. HTTP(超文本传输协议)就是其中之一.
1.3.1 认识URL
其实之前再学c++的时候,已经接触过一点URL的知识了。【String-02/test.cpp · WZF-sang/Cpp-Learn,这里在写分离域名的接口的时候了解了一下相关知识】

注意:现在的网址,这里的登录信息已经没有了,之前才有,现在已经没有了
而这个服务器ip地址和端口号是一定要有的,根据前面学的网络知识,**IP地址 + 端口号能够标识网络上的某一台主机的某一个进程,**因此,即便平常看到的网址没有端口号,也会默认的自带。因为http默认端口号是80,https是443
最开始的这个路径分隔符,代表着前面的是一个根目录。但是这个不是Linux下的根目录,这里这个指的是web根目录------一般来说可以是Linux的任意目录
在学习了Linux和些许网络知识之后,现在可以来看看URL本质上是在做什么工作了

如图所示,先分离出url中的域名,将域名转化为ip地址【域名本质是个字符串,和指定ip地址做了一个映射】,标定了一台Linux的网络主机,然后在http协议下,通过后面的文件路径,将文件内的资源打开并获取,返回到客户端。
因此http请求本质------实际上就是通过http协议从服务器拿下来对应的资源(一切在网络上看到的都是资源,都可以看做是从某个资源文件中拿出来的!即都存在于服务器的磁盘上,而要访问磁盘上指定的文件,就需要Linux的文件路径结构!)
注意:之前我写的所有服务器都是内存级别的服务器【即所有服务器资源都是从内存获得的】
即我们上网的目标只有两种,一种是将网上的资源获取到本地,第二种是将本地的资源上传的网络上。而这两种目的,都有资源的传输!而资源无论是在本地还是在对方的服务器上,都是存储在磁盘的某个路径下的,这就需要通过指定的文件路径去访问,而http协议能搞定很多种类型的文件资源传输,因此叫做超文本传输协议
总结:
url会有域名和端口号,域名其实是个字符串,它映射着服务器端或者客户端对应的ip地址,端口号看不到是因为0到1024的端口号都已经被指定了,因此知道了协议就强相关了端口号,通过ip地址和端口号就能找到一台主机上的某个进程,因此就能建立传输通道,然后url后面的一串其实是经过encode转义过后的文件路径,通过文件路径就能找到对应主机上的某个资源,拿到资源之后进行数据传输【比如注册账号要把数据传输到服务器端,加载页面需要把数据传输到本地上】(这个过程中存在协议和序列化和反序列化,以及读取报文的过程),而http不止能传输文本,还能传输视频,音频,图片等多种格式的数据,因此被叫做超文本传输协议
1.3.2urlencode和urldecode
**像 / ? : 等这样的字符, 已经被url当做特殊意义理解了. 因此这些字符不能随意出现,不然就会干扰正常url的解析。**比如, 某个参数中需要带有这些特殊字符, 就必须先对特殊字符进行转义.
转义的规则如下:
将需要转码的字符转为16进制,然后从右到左,取4位(不足4位直接处理),每2位做一位,前面加上%,编码成%XY格式
下面是urlencode的例子:

而urldecode就是urlencode的逆过程,即收到urlencode后如何将其识别为对应的特殊符号。
现在有个需要注意的点:
- 在编写服务器的时候,是否需要自己解决urlencode和urldecode的过程?
如果是从0开始的服务器编写,那肯定是需要的,但是不需要自己手动去写代码解决,因为这样虽然可以但是有点小麻烦。而网络上有相当多关于urldecode的源码,直接粘贴就可以了
1.3.3HTTP协议格式
HTTP是一个以行为单位的协议
- HTTP请求

首行/请求行: 方法 + url + 版本
Header/请求报头: 请求的属性, 冒号分割的键值对;每组属性之间使用\n分隔;遇到空行表示Header部分结束
Body/请求正文(可以没有): 空行后面的内容都是Body. Body允许为空字符串. 如果Body存在, 则在Header中会有一个Content-Length属性来标识Body的长度;
- HTTP响应

首行/状态行: 版本号 + 状态码 + 状态码解释
这里要补充一下状态码的知识:
状态码其实和上面网络计算器的exitcode本质是一样的,当服务器接收到http请求的时候,进行分析处理后,就会反应一个状态码,表示本次响应的状态。
常见的状态码有:404、200、400、302、307、500
状态码描述就是防止你看不懂状态码,而对状态码的一种解释,比如404的描述是------Not Found、200的描述是------OK
Header/响应报头: 请求的属性, 冒号分割的键值对;每组属性之间使用\n分隔;遇到空行表示Header部分结束
Body/响应正文: 空行后面的内容都是Body. Body允许为空字符串. 如果Body存在, 则在Header中会有一个Content-Length属性来标识Body的长度; 如果服务器返回了一个html页面, 那么html页面内容就是在body中.

这里有两个问题:
- 请求和响应怎么保证应用层读取完毕?
由于http有空行的存在,因此可以直接用while循环不断地读取完整的一行,直至遇到空行,此时就把请求行+请求报头读取完毕了,而报头会有正文长度。

- http如何解决序列化和反序列化的问题
这个http没有借助json或者protubuf之类的外部工具,而是自己实现的序列化和反序列化,和上面我自己实现序列化和反序列化本质一样的。http具体怎么做的呢?------即首行+报头,只要按照\r\n将字符串1->n即可!
1.3.4 一些网页基础知识
- get和post提交是主流提交,并且区别为get直接以问号为分隔符,左边是资源地址,右边是客户端提交的信息,直接以url提交,post将客户端提交信息放到正文中提交
- web根目录不是root根目录,这个web根目录是可以指定的根目录
- index默认是首页,页面跳转的原理就是跳进了给定了另一个资源地址,并且资源存在,如果不存在会跳404,404其实也是一个资源地址
- html状态码

- 临时重定向------即客户端访问了服务端,服务端返回了一个新的url然后客户端再去访问这个新的客户端,临时重定向就像淘宝广告和小说广告那种,点击自动跳转到另一个服务端去了
- 永久重定向------服务端的人不想你在访问之前的那个url了,于是在你访问旧的url的时候返回了一个新的url然后就跳转过去了,这个一般出现于网页设计落后之后想要更新一下,于是给所有人发了一个新网页的url。
1.3.4 长链接
实际上我们看到的网页是由多种元素构成的,http超文本传输可以传输多种类型的数据,图片,文本,视频等等,因此在显式一个网页的时候,需要把要显示的资源全部都向服务端发起请求。这就会导致发起很多的请求,而tcp协议下,请求是面向链接的,这就会造成频繁创建链接的问题
因此,在这个时候就有了长链接的概念,就是在这种需要频繁创建链接的情况下,我们选择直接建立好一条链接,通过这同一条链接,完成所有资源的请求和响应,这就是长链接,概念上其实很好理解

1.3.5 http周边会话保持
首先http会话保持是后面才发现需要的,一开始的http协议严格意义上不具备会话保持
会话保持是什么?
首先要知道http协议是无状态的,就是要什么就给什么,但是很多时候用户在登录之后,会不断地跳转新的网页,但是如果网页不能够会话保持,那么就需要用户不断地重新登录,这样就很麻烦。
而保持这个用户始终在线的这个状态就是会话保持
那会话保持是如何实现的呢?
- 老方法(cookie技术)
浏览器会提供一个基本功能,就是保存用户输入的账户和密码等信息,而往后只要访问同一个网站,浏览器会自动保留历史保留信息(cookie技术【分为cookie文件级别和cookie内存级别】,在这里是cookie文件级别),然后在你每次访问的时候再自动推送信息给服务端
而服务端也会配合浏览器,即用户每次对有访问权限的网页进行访问的时候,都会在数据被获取前进行一次身份判断

但是这个方法有问题------即有可能会被病毒攻击窃取cookie文件

- 新方法
其实就是把用户登录时所带的身份信息和浏览痕迹不保存在本地的浏览器cookie文件中,而是保存在服务器端,服务端给其创建一个session文件,并且给这个文件唯一的名称取做session id。而浏览器的cookie文件保存的就是session id ,下次用户打开网页的时候,浏览器就提交的就是session id,由于session id是唯一的,因此就能进行身份鉴定

这个新方案就大大降低了用户信息的泄漏可能性,但是并不是说不法分子拿到session id就访问不到服务器了,其实拿到了session id就可以访问服务器,依旧能拿到身份信息等数据,只是,攻击大厂的服务器获取客户隐私,已经上升到了刑事案件。
因此还需要配合其他的策略来缓解该类问题**【比如ip溯源,识别到异地登录直接将账号下线,提示异常等】,一旦识别到账号异常,就直接让seeion id失效**

1.3.6 基本工具(postman, fiddler)
postman是一个模拟客户端的工具
fiddler是一个抓包工具,是http的抓包工具【只能抓本地的】,专门用于调试的
postman 和 fiddler的原理:
postman代替了浏览器,直接作为客户端向服务端发起请求
fiddler会将浏览器的请求都劫持(代理),代替浏览器做请求,服务器拿到请求返回响应也是返回给fiddler,然后再有fiddler拿到响应,再把响应给到浏览器。

1.4 https协议
其实就是多走了一层加密层,然后在传输的时候走的不再是80端口而是443。这样就可以做到在网络传输的时候是密文的,在双方的本地都是明文的

1.4.1 中间人攻击
因为http的内容是明文传输的,明文数据会经过路由器、wifi热点、通信服务运营商、代理服务器等多个物理节点,如果信息在传输过程中被劫持,传输的内容就完全暴露了。劫持者还可以篡改传输的信息且不被双方察觉,这就是中间人攻击,所以我们才需要对信息进行加密。

如何成为中间人-了解
ARP欺骗:在局域网中,hacker经过收到ARP Request广播包,能够偷听到其它节点的(IP,MAC)地址。例,黑客收到两个主机A,B的地址,告诉B(受害者),自己是A,使得B在发送给A的数据包都被黑客截取
ICMP攻击:由于ICMP协议中有重定向的报文类型,那么我们就可以伪造一个ICMP信息然后发送给局域网中的客户端,并伪装自己是一个更好的路由通路。从而导致目标所有的上网流量都会发送到我们指定的接口上,达到和ARP欺骗同样的效果
假wifi&&假网站等
1.4.2 数据加密
分为对称加密和非对称加密
-
对称加密:一个密钥,速度快
-
非对称密钥:两个密钥,一个公钥,一个私钥,速度慢,安全性高
这里所说的安全性其实是从算力时间成本来说的,非对称密钥要解开需要计算的时间成本高,所以安全性强
1.4.3 数据摘要

这里有个例子就是百度网盘:
网盘给人的存储空间,并不是百度网盘真实给你的存储空间,它只是说你最多能用这么多,等你真的用了,再分配存储空间给你。这是它们节省成本的方法之一,还有一个方法就是数据摘要,比如一个热门电影------流浪地球,有很多人都要上传到网盘,那么就会有非常多的空间用来反复的存储同一份电影,那就很浪费了,因此百度网盘在用户上传数据前,会先对数据进行哈希处理,然后通过生成的字符串来判断是否在云盘中存在这个数据,如果存在就闪存【用户感知不出来 】,不需要上传数据了,直接用别人曾经上传的数据

还有一个数据摘要的应用例子:
就是所有后端数据库中用于存储用户密码登隐私数据的表,其字段必须是加密的,不然一旦泄漏,那么就非常恐怖了。因此会对用户的密码进行数据摘要,然后生成的字符串放在字段里面,因此每次登录的时候是通过提交的密码进行数据摘要,然后根据生成的字符串去数据库里面找相应表中是否存在,来实现密码认证的

1.4.4 证书
1.4.4.1 为什么需要证书
在证书这个概念引入之前,要先了解为什么需要证书
这就需要我们了解一下,我们到底要怎么解决数据在网络传输时的安全性
对称加密其实并不适合在网络传输中作为一个保护措施来实施,因为要保证服务端能够先知道你的密钥,但是第一次的传输密钥就可能被截取,那对密钥进行加密就变成了鸡生蛋,蛋生鸡的问题。
因此在网络传输中要保护请求的安全性,要用非对称密钥。
因此有一个方案是,双方都使用非对称密钥
过程图如下:
这个方案看似无懈可击,但是实际上,仍然存在问题:
慢,非对称加密的解密是很慢的
仍然有安全问题
因此,方案还需要改进,那再来一个方案是对称加密+非对称加密
但是这个方案依旧还是有问题,它虽然解决了慢的问题,但是安全问题仍然没有解决------那就是中间人如果一开始就攻击了,那就不安全了。
为什么?
因为一旦一开始就开始攻击,就可以发生如下过程
- 服务器发送公钥S给客户端,但是中间人劫持了公钥S,将其替换为公钥M
- 客户端收到公钥M,根据公钥M生成对称密钥C,然后用公钥M加密,生成X并发送给服务端
- 中间人劫持加密后的密钥X,用M'私钥解密,拿到对称密钥C,然后重新用公钥S加密形成密钥Y,发给服务端
- 服务端用私钥S'解密Y后,拿到的是对称密钥C,从此用对称密钥C通信,但是C密钥已经被中间人获取,所以安全性也无从谈起了
因此下一个方案就登场了,这个方案要解决上面方案的安全问题。要解决问题,就要知道问题的本质是------Client无法辨别公钥是否合法的能力
但是这个问题并不好解决,要引入一些其他的知识,比如证书
1.4.4.2 CA证书
服务端在使用HTTPS前,需要向CA机构申领一份数字证书,数字证书里含有证书申请者信息、公钥信息等。服务器把证书传输给浏览器,浏览器从证书里获取公钥就行了,证书就如身份证,证明服务端公钥的权威性

当服务端申请CA证书的时候,CA机构会对该服务端进行审核,并专门为该网站形成数字签名,过程如下:
1.CA机构拥有非对称加密的私钥A和公钥A'
2.CA机构对服务端申请的证书明文数据进行hash,形成数据摘要3.然后对数据摘要用CA私钥A'加密,得到数字签名S服务端申请的证书明文和数字签名S共同组成了数字证书,这样一份数字证书就可以颁发给服务端了

而客户端也会对发来的证书里的公钥 做认证

此时中间人就无法攻击修改我们的数据了,因为只认CA的公钥,中间人改不了,而一旦改数据,数据的公钥做一个数据摘要,发现和数据签名的摘要不一样,那就说明数据不安全,丢弃请求
因此最后的方案就是**,对称加密+非对称加密+CA证书**
1.4.4 https采取的解决方案
为了保证数据在传输的安全性,https最后采取的方案是对称加密+非对称加密+CA证书

总结:全过程如下图:

总结
HTTPS工作过程中涉及到的密钥有三组
- 第一组(非对称加密):用于校验证书是否被篡改.服务器持有私钥(私钥在形成CSR文件与申请证书时获得),客户端持有公钥(操作系统包含了可信任的CA认证机构有哪些,同时持有对应的公钥).服务器在客户端请求是,返回携带签名的证书.客户端通过这个公钥进行证书验证,保证证书的合法性,进一步保证证书中携带的服务端公钥权威性。
- 第二组(非对称加密):用于协商生成对称加密的密钥.客户端用收到的CA证书中的公钥(是可被信任的)给随机生成的对称加密的密钥加密,传输给服务器,服务器通过私钥解密获取到对称加密密钥.
- 第三组(对称加密):客户端和服务器后续传输的数据都通过这个对称密钥加密解密.
其实一切的关键都是围绕这个对称加密的密钥.其他的机制都是辅助这个密钥工作的.
第二组非对称加密的密钥是为了让客户端把这个对称密钥传给服务器
第一组非对称加密的密钥是为了让客户端拿到第二组非对称加密的公钥.




