我们程序员写的一个个解决我们实际问题, 满足我们日常需求的网络程序, 都是在应用层.
协议是一种 “约定”. socket api的接口, 在读写数据时, 都是按 “字符串”(字符串描述不准确) 的方式来发送接收的. 如果我们要传输一些"结构化的数据" 怎么办呢?
通过序列化与反序列化实现,我们发送数据至网络要进行序列化,将“结构化的数据”合为一个整体,网络发送数据给另一端接收时,要进行反序列化,将整体数据又转化为“结构化数据”。进行序列化的工具有json,xml
例如, 我们需要实现一个服务器版的加法器. 我们需要客户端把要计算的两个加数发过去, 然后由服务器进行计算, 最后再把结果返回给客户端.
约定方案一:
- 客户端发送一个形如"1+1"的字符串;
- 这个字符串中有两个操作数, 都是整形;
- 两个数字之间会有一个字符是运算符, 运算符只能是 + ;
- 数字和运算符之间没有空格;
- …
约定方案二:
- 定义结构体来表示我们需要交互的信息;
- 发送数据时将这个结构体按照一个规则转换成字符串, 接收到数据的时候再按照相同的规则把字符串转化回结构体;
- 这个过程叫做 “序列化” 和 “反序列化”
protocol.hpp
// protocol.hpp 定义通信的结构体
#pragma once
typedef struct request{
int x;
int y;
char op;//操作符
}request_t;
typedef struct response{
//code为0:运算正常
//code为1:/的除数为0
//code为2:%的除数为0
//code为3:操作符不合法
int code;//退出码
int result;//结果
}response_t;
// client.hpp
#pragma once
#include
#include
#include
#include
#include
#include
#include
#include"protocol.hpp"
class client{
private:
std::string _ip;//服务器ip
int _port;//服务器端口号
int _sock;
public:
//构造函数
client(std::string ip = "127.0.0.1", int port = 8080)
:_ip(ip)
,_port(port)
{}
//初始化
void init_client()
{
//创建套接字
_sock = socket(AF_INET, SOCK_STREAM, 0);
if (_sock < 0)
{
std::cerr << "socket error !" << std::endl;
exit(1);
}
//connect
//服务器信息
sockaddr_in svr;
svr.sin_family = AF_INET;
svr.sin_port = htons(_port);
svr.sin_addr.s_addr = inet_addr(_ip.c_str());
if (connect(_sock, (sockaddr*)&svr, sizeof(svr)) < 0)
{
std::cerr << "connect error !" << std::endl;
exit(2);
}
}
void start()
{
request_t rq;
response_t rsp;
std::cout << "请输入第一个操作数: ";
std::cin >> rq.x;
std::cout << "请输入操作符: ";
std::cin >> rq.op;
std::cout << "请输入第二个操作数: ";
std::cin >> rq.y;
//发送给服务器计算
send(_sock, &rq, sizeof(rq), 0);
//接收结果
ssize_t s = recv(_sock, &rsp, sizeof(rsp), 0);
if (s > 0)
{
if (rsp.code != 0)
{
std::cout << "输入错误 !" << std::endl;
std::cout << "code: " << rsp.code << std::endl;
}
else
{
std::cout << rq.x << " " << rq.op << " " << rq.y << " = " << rsp.result << std::endl;
}
}
}
//析构
~client()
{
close(_sock);
}
};
server.hpp
#pragma once
#include
#include
#include
#include
#include
#include
#include"protocol.hpp"
class server{
private:
int _port;//服务器端口号
int _lsock;//监听套接字
public:
//构造函数
server(int port = 8080)
:_port(port)
{}
//初始化
void init_server()
{
//创建套接字
_lsock = socket(AF_INET, SOCK_STREAM, 0);
if (_lsock < 0)
{
std::cerr << "socket error !" << std::endl;
exit(1);
}
//绑定套接字
sockaddr_in local;
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = INADDR_ANY;
if (bind(_lsock, (struct sockaddr*)&local, sizeof(local)) < 0)
{
std::cerr << "bind error !" << std::endl;
exit(2);
}
//监听
if (listen(_lsock, 5) != 0)
{
std::cerr << "listen error !" << std::endl;
exit(3);
}
}
void cal(int sock)
{
//短链接完成服务
request_t rq;
response_t rsp = {0, 0};
//接收计算信息
ssize_t s = recv(sock, &rq, sizeof(rq), 0);
if (s > 0)
{
switch(rq.op)
{
case '+':
rsp.result = rq.x + rq.y;
break;
case '-':
rsp.result = rq.x - rq.y;
break;
case '*':
rsp.result = rq.x * rq.y;
break;
case '/':
if (rq.y == 0)
{
rsp.code = 1;
break;
}
rsp.result = rq.x / rq.y;
break;
case '%':
if (rq.y == 0)
{
rsp.code = 2;
break;
}
rsp.result = rq.x % rq.y;
break;
default:
rsp.code = 3;
}
}
//返回结果
send(sock, &rsp, sizeof(rsp), 0);
//短链接处理,所以这里就closesock
close(sock);
}
void start()
{
//连接客户端
struct sockaddr_in end_point;
socklen_t len = sizeof(&end_point);
while (1)
{
int sock = accept(_lsock, (struct sockaddr*)&end_point, &len);
if (sock < 0)
{
std::cerr << "accept error !" << std::endl;
continue;
}
//创建子进程进行计算
if (fork() == 0)
{
//子进程
close(_lsock);
if (fork() > 0)
{
//还是子进程
exit(0);//退出,防止阻塞子进程
}
else//孙子进程
{
cal(sock);
exit(0);
}
}
close(sock);
waitpid(-1, nullptr, 0);
}
}
//析构
~server()
{
close(_lsock);
}
};
无论我们采用方案一, 还是方案二, 还是其他的方案, 只要保证, 一端发送时构造的数据, 在另一端能够正确的进行解析, 就是ok的,比如输入顺序。这种约定, 就是 应用层协议
我们对上述计算进行抓包:sudo tcpdump -i any -nn tcp port 8080
三次握手,四次挥手
虽然我们说, 应用层协议是我们程序猿自己定的.
但实际上, 已经有大佬们定义了一些现成的, 又非常好用的应用层协议, 供我们直接参考使用. HTTP(超文本传输协议)就是其中之一.
平时我们俗称的 “网址” 其实就是说的 URL
互联网行为
像 / ? : 等这样的字符, 已经被url当做特殊意义理解了. 因此这些字符不能随意出现.
比如, 某个参数中需要带有这些特殊字符, 就必须先对特殊字符进行转义.
转义的规则如下:
将需要转码的字符转为16进制,然后从右到左,取4位(不足4位直接处理),每2位做一位,前面加上%,编码成%XY格式
例如:
“+” 被转义成了 “%2B”
urldecode就是urlencode的逆过程
urlencode工具
无连接。前面我们知道,TCP是要连接的,但TCP建立连接和http无关,http直接向服务器发送http request即可
无状态。我们用一个例子来理解无状态:当我们访问一个网站时,需要登陆,如果我们此次登陆了,下一次再访问时,由于http的无状态特性,我们需要再次输入账号密码才能登陆。这就是http的无状态。而我们实际登陆时,其实是登陆一次后之后再访问就不用输入账号密码了,这是由cookie和session实现的
简单快速。短链接进行文本(html、img、css、js…)传输,这是早期的http/1.0的传输方式
http/1.0支持长连接
HTTP构成:
可以通过Fiddler进行抓包。
原理:正常上网时,我们直接通过网络发送请求给服务器即可。而Fiddler抓包是我们先给Filddler进行代理,Fiddler再通过网路发送请求给服务器,并接收响应
HTTP请求
- 首行: [方法] + [url] + [版本]
- Header: 请求的属性, 冒号分割的键值对;每组属性之间使用\n分隔;遇到空行表示Header部分结束
- Body: 空行后面的内容都是Body. Body允许为空字符串. 如果Body存在, 则在Header中会有一个Content-Length属性来标识Body的长度;
HTTP响应
- 首行: [版本号] + [状态码] + [状态码解释]
- Header: 请求的属性, 冒号分割的键值对;每组属性之间使用\n分隔;遇到空行表示Header部分结束
- Body: 空行后面的内容都是Body. Body允许为空字符串. 如果Body存在, 则在Header中会有一个Content-Length属性来标识Body的长度; 如果服务器返回了一个html页面, 那么html页面内容就是在body中
方法 | 说明 | 支持的HTTP协议版本 |
---|---|---|
GET | 获取资源 | 1.0、1.1 |
POST | 传输实体主体 | 1.0、1.1 |
PUT | 传输文件 | 1.0、1.1 |
HEAD | 获得报文首部 | 1.0、1.1 |
DELETE | 删除文件 | 1.0、1.1 |
OPTIONS | 询问支持的方法 | 1.1 |
TRACE | 追踪路径 | 1.1 |
CONNECT | 要求用隧道协议连接代理 | 1.1 |
LINK | 建立和资源之间的联系 | 1.0 |
UNLINE | 断开连接关系 | 1.0 |
其中最常用的就是GET方法和POST方法.
Head不获取正文信息,只获取前三个信息(请求/响应行、报头、空行)
最常见的状态码, 比如 200(OK), 404(Not Found), 403(Forbidden), 302(Redirect, 重定向), 504(Bad Gateway)
- Content-Type: 数据类型(text/html等)
- Content-Length: Body的长度
- Host: 客户端告知服务器, 所请求的资源是在哪个主机的哪个端口上;
- User-Agent: 声明用户的操作系统和浏览器版本信息;
- referer: 当前页面是从哪个页面跳转过来的;
- location: 搭配3xx状态码使用, 告诉客户端接下来要去哪里访问;
- Cookie: 用于在客户端存储少量信息. 通常用于实现会话(session)的功能;
User-Agent里的历史故事
Cookie的介绍:之前我们了解了http的无状态特性,这样会给用户带来很差的体验,而cookie就是来解决这个问题的。
原理:同样以网站的登录为例来理解,我们在网站上第一次登录时,服务器端就保存了用户名和密码的信息,服务器进行响应时,会在response中包含set-cookie:用户名、密码…的信息,客户端收到后,浏览器就在cookie这个文件中保存了这个信息。下一次进入这个网站,浏览器对网站发送请求时,就会携带这个cookie文件与request一起发送给服务器,服务器进行核对,核对正确后,就不再需要我们手动输入密码了。
cookie的本质是浏览器的一个文件,它分为内存级的和磁盘级的。内存级的cookie只在当前浏览器打开时间内有效,关闭浏览器下次再打开时,我们再次进入网站,还是需要输入账号密码进行登录。磁盘级的cookie则是保存在本地的,这样就不受浏览器打开这一条件限制,可以保存较长的时间。
但实际上,这样也是有风险的,万一我们被黑客入侵,获取到了本地的cookie,他就可以通过这个cookie访问我们同样访问过的信息。
Session就是与Cookie配套使用的。我们第一次登录后,服务器不再将用户名和密码response回来,而是在服务器上用一个session文件保存我们的私密信息,然后生成一个唯一的sid来标识它,之后同样在response里,由set-cookie:sid,此时是将sid返回,本地的cookie中保存sid。下一次访问网站时,浏览器携带cookie发送请求,服务器通过sid找到客户的信息,然后允许客户访问。
cookie和session是相对安全的,但还是有可能会被盗取。此时盗取的只是sid,而不是我们的用户名和密码了。而且他也不知道sid对应的是哪个网站。而要改密码是要原密码的,除非我们密码太简单被别人识破,否则他就不能修改我们的密码然后让我们永远地失去这个账号,除非把后台服务器攻破,拿到密码。
实现一个最简单的HTTP服务器, 只在网页上输出 “hello world”; 只要我们按照HTTP协议的要求构造数据, 就很容易能做到;
#include
#include
#include
#include
#include
#include
#include
#include
#define BAGLOG 5
using namespace std;
class httpserver{
private:
int _port;
int _lsock;
public:
//构造
httpserver(int port)
:_port(port)
,_lsock(-1)
{}
//析构
~httpserver()
{
if (_lsock != -1)
close(_lsock);
}
void initServer()
{
signal(SIGCHLD, SIG_IGN);
//创建套接字
_lsock = socket(AF_INET, SOCK_STREAM, 0);
if (_lsock < 0)
{
cerr << "socket error !" << endl;
exit(1);
}
//绑定
struct sockaddr_in local;
//将local的内容清零
bzero(&local, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = INADDR_ANY;
if (bind(_lsock, (struct sockaddr*)&local, sizeof(local)) < 0)
{
cerr << "bind error !" << endl;
exit(2);
}
//监听
if (listen(_lsock, BAGLOG) != 0)
{
cerr << "listen error !" << endl;
exit(3);
}
}
void EchoHttp(int sock)
{
//短链接执行
//接收数据
char request[1024];
ssize_t s = recv(sock, request, sizeof(request) - 1, 0);
if ( s > 0 )
{
request[s] = 0;
//打印请求
cout << request << endl;
//响应
//响应行
string response = "HTTP/1.0 200 OK\r\n";
//响应报头
response += "Content-type: text/html\r\n";//发送的类型是html,网页类型
//响应空行
response += "\r\n";
//响应正文
response += "\
\
\
ysj \
\
\
Welcome
\
Hello World !
\
\
\r\n";
send(sock, response.c_str(), response.size(), 0);
}
close(sock);
}
void start()
{
while (1)
{
//建立连接
sockaddr_in peer;
socklen_t len = sizeof(peer);
bzero(&peer, len);
int sock = accept(_lsock, (struct sockaddr*)&peer, &len);
if (sock < 0)
{
cerr << "accept error !" << endl;
continue;
}
cout << "get a link..." << endl;
//创建子进程处理任务
pid_t id = fork();
if (id == 0)//子进程
{
//父进程忽略子进程发送的SIGCHLD信号
close(_lsock);
EchoHttp(sock);
exit(0);
}
close(sock);
}
}
};
编译, 启动服务. 在浏览器中输入 http://[ip]:[port], 就能看到显示的结果 “Hello World”
备注:
此处我们使用 8080 端口号启动了HTTP服务器. 虽然HTTP服务器一般使用80端口,但这只是一个通用的习惯. 并不是说HTTP服务器就不能使用其他的端口号.
使用chrome测试我们的服务器时, 可以看到服务器打出的请求中还有一个 GET /favicon.ico HTTP/1.1 这favicon.ico_百度百科 (baidu.com)
如果我们把状态码设置成404会不会显示经典的Not Found页面?
答案是:不会的,页面的显示是要靠html来设置的,不是由状态码决定的
临时重定向和永久重定向
配合状态码3xx使用。浏览器在接收到重定向响应的时候,会采用该响应提供的新的 URL ,并立即进行加载;大多数情况下,除了会有一小部分性能损失之外,重定向操作对于用户来说是不可见的。
将上面的服务器改造,设置成重定向:
响应报头中设置:location: http://baidu.com\r\n
不需要正文。
//响应行
string response = "HTTP/1.0 302 Found\r\n";
//响应报头
response += "Content-type: text/html\r\n";//发送的类型是html,网页类型
response += "location: https://www.baidu.com\r\n";//发送的类型是html,网页类型
//响应空行
response += "\r\n";
输入云服务器公网ip加端口号后,就会跳转到百度了
https是在http与传输层直接再加了一层SSL/TLS(TLS是SSL的标准化版本):对数据进行加密/解密。
大部分情况下都是使用对称加密。
对称加密:只有一个密钥,按同一个密钥进行加密和解密。客户端将数据按照密钥进行加密,服务器按照密钥对数据进行解密。密钥只有客户端和服务器双方知道。即将密钥也发送给服务器,但是这有可能会被黑客劫持,所以单纯的对称加密是不安全的。
于是就有了非对称加密:
非对称加密:通过公钥和私钥的方式来进行加密和解密。通常情况下,公钥用来加密,私钥用来解密。
服务器有很多种公钥私钥的算法,客户端与服务器协商使用哪种算法,协商好后,各自会生成一对公钥私钥。服务器将自己的公钥发送给客户端,私钥留给自己,这样,客户端就可以用服务器的公钥对发送的数据加密,而加密的数据只能用服务器的私钥来解密。然后客户端也将自己的公钥发送给服务器,服务器得到客户端的公钥后,就可以将数据按照公钥加密发送给客户端,同样,只有客户端的私钥才能对其解密。
现在我们虽然可以对数据进行加密,但是我们还不清楚对方是否是我们想要沟通的对象,服务端需要申请SSL证书来证明它的身份,要让SSL证书生效就要向CA(Certificate Authority证书授权中心,大家都信任这个机构颁发的证书)申请,证书表明了域名是属于谁的等等信息。
在客户端和服务器完成三次握手后,双方会协商加密方法,然后服务器会发送证书给客户端以示自己的身份。
虽然我们可以用非对称加密来进行通信,但每次通信都用非对称加密,加密解密的时间就要花费很多时间,影响效率。所以实际上采用的是两者混合的方式进行加密,即先通过非对称加密获取双方的公钥后,服务器再通过非对称加密方式发送对称密钥,这样客户端收到后,两者就可以按照对称加密的方式进行通信了。
为什么要使用对称加密?
非对称加密:效率比较低
对称加密:效率较高