• 【Linux】——网络基础:http协议


    目录

    前言

    应用层

    认识协议

    协议的概念

    传输结构化数据

    序列化和反序列化

    网络版本计算器

    服务器端Server

     客户端Client

    协议定制

    其它

    运行效果

    HTTP协议

    HTTP的简介

    认识URL

    urlencode和urldecode

    HTTP协议格式

    HTTP请求

    HTTP响应

    HTTP的方法 

    GET和POST方法

    POSTMAN演示

    HTTP的状态码

    HTTP常见header

    Cookie和session


    前言

            本文主要探究理解应用层的作用, 介绍HTTP协议的工作原理,同时介绍一些常用的分析网络问题的工具和方法。在详细了解HTTP协议之后,我们不难发现在网络通信的过程中使用HTTP协议存在安全隐患。因此还会详细介绍基于HTTP协议的HTTPS协议是如何在网络通信的过程中还能够保证数据的安全性。

    应用层

    应用层是计算机网络体系结构中的一个层级,位于网络协议栈的最顶部。它提供一些应用程序与网络之间的接口,使得应用程序能够通过网络进行通信和数据交换。

    在应用层,各种应用程序可以利用不同的协议来实现数据传输和通信。一些常见的应用层协议包括HTTP、FTP、SMTP、DNS等。这些协议定义了数据传输的格式、交互方式和错误处理等规范。

    应用层的功能包括数据编码与格式化、数据压缩、安全认证、数据加密、资源共享等。它为用户提供了各种各样的应用,例如网页浏览器、电子邮件客户端、文件传输工具等。

    总结来说,应用层是网络协议栈中负责为应用程序提供通信接口的层级,它使得不同的应用程序能够通过网络进行数据交换和通信。

    网络应用程序体系结构

    从应用程序研发者的角度看,网络体系结构是固定的,并为应用程序提供了特定的服务集合。在另一方面,应用程序体系结构( application architecture)由应用程序研发者设计,规定了如何在各种端系统上组织该应用程序。在选择应用程序体系结构时,应用程序研发者很可能利用现代网络应用程序中所使用的主流体系结构之一:客户-服务器体系结构

    客户——服务器体系结构

    在客户-服务器体系结构(client- server architecture)中,有一个总是打开的主机称为服务器,它服务于来自许多其他称为客户的主机的请求。值得注意的是利用客户-服务器体系结构,客户相互之间不直接通信。客户-服务器体系结构的另一个特征是该服务器具有固定的、周知的地址,该地址称为IP地址。因为该服务器具有固定的、周知的地址,并且因为该服务器总是打开的,客户总是能够通过向该服务器的IP地址发送分组来与其联系。具有客户-服务器体系结构的非常著名的应用程序包括Web,FTP以及电子邮件。

    在一个客户-服务器应用中,常常会出现一台单独的服务器主机跟不上它所有客户请求的情况。为此,配备大量主机的数据中心(data center)常被用于创建强大的虚拟服务器。一个数据中心能够有数十万台服务器,它们必须要供电和维护。此外,服务提供商必须支付不断出现的互联和带宽费用,以发送和接收到达/来自数据中心的数据。

    应用层协议

    应用层协议定义了运行在不同端系统上的应用程序进程如何相互传递报文。

    其中我们主要介绍HTTP协议:

    http:Web的应用层协议是超文本传输协议(即http,它是web的核心)。HTTP由两个程序实现:一个客户程序和一个服务器程序。客户程序和服务器程序运行在不同的端系统中,通过交换HTTP报文进行会话。


    认识协议

    协议是一种 "约定"。使用过socket网络套接字通信的人应该都知道socket api的接口, 在读写数据时, 都是按 "字符串" 的方式来发送接收的。如果我们要传输一些"结构化的数据" 怎么办呢?比如下面的情况:

            我们想要实现一个网络版本的计算器,计算器想要完成一个计算,最好是直接传给它一个包含着两个操作数和一个操作符的结构体对象。但是使用socket通信的时候,UDP/TCP发送的是数据报或者字节流的数据。所以此时我们如果想要发送一些“结构化的数据”就需要制订协议了。

    1. //网络计算器需要的数据是结构体
    2. typedef struct request{
    3. int x; //左操作数
    4. int y; //右操作数
    5. char op; //操作符
    6. };
    7. //网络通信发送的数据是string
    8. string request = "10+20";

    协议的概念

    协议,网络协议的简称,网络协议是通信计算机双方必须共同遵从的一组约定,比如怎么建立连接、怎么互相识别等。

    为了使数据在网络上能够从源到达目的,网络通信的参与方必须遵循相同的规则,我们将这套规则称为协议(protocol),而协议最终都需要通过计算机语言的方式表示出来。只有通信计算机双方都遵守相同的协议,计算机之间才能互相通信交流。

    传输结构化数据

    通信双方在进行网络通信时:

    • 如果需要传输的数据是一个字符串,那么直接将这一个字符串发送到网络当中,此时对端也能从网络当中获取到这个字符串。
    • 但如果需要传输的是一些结构化的数据,此时就不能将这些数据一个个发送到网络当中。

    比如上面提到的网络版本计算器,如果客户端将这些结构化的数据单独一个个的发送到网络当中,那么服务端从网络当中获取这些数据时也只能一个个获取,此时服务端还需要纠结如何将接收到的数据进行组合。因此客户端最好把这些结构化的数据打包后统一发送到网络当中,此时服务端每次从网络当中获取到的就是一个完整的请求数据,客户端常见的“打包”方式如下。

    • 定制结构体来表示需要交互的信息。
    • 发送数据时将这个结构体按照一个规则转换成网络标准数据格式,接收数据时再按照相同的规则把接收到的数据转化为结构体。
    • 这个过程叫做“序列化”和“反序列化”。

    客户端可以定制一个结构体,将需要交互的信息定义到这个结构体当中。客户端发送数据时先对数据进行序列化,服务端接收到数据后再对其进行反序列化,此时服务端就能得到客户端发送过来的结构体,进而从该结构体当中提取出对应的信息。

    序列化和反序列化

    序列化:struct-->string        反序列化: string-->struct

    • 序列化是将对象的状态信息转换为可以存储或传输的形式(字节序列)的过程。
    • 反序列化是把字节序列恢复为对象的过程。

    序列化和反序列化的目的

    • 在网络传输时,序列化目的是为了方便网络数据的发送和接收,无论是何种类型的数据,经过序列化后都变成了二进制序列,此时底层在进行网络数据传输时看到的统一都是二进制序列。
    • 序列化后的二进制序列只有在网络传输时能够被底层识别,上层应用是无法识别序列化后的二进制序列的,因此需要将从网络中获取到的数据进行反序列化,将二进制序列的数据转换成应用层能够识别的数据格式。

    我们可以认为网络通信和业务处理处于不同的层级,在进行网络通信时底层看到的都是二进制序列的数据,而在进行业务处理时看得到则是可被上层识别的数据。如果数据需要在业务处理和网络通信之间进行转换,则需要对数据进行对应的序列化或反序列化操作。

    网络版本计算器

    下面实现一个网络版的计算器,主要目的是感受一下什么是协议,以及体会序列化和反序列化的过程。

    服务器端Server

    在写服务端之前,我们先对库函数中的socket封装一下,方便我们使用。

    我们自己定义一个sock对象,该sock对象支持bind,listen,accept,connect功能。因此之后在写服务端时只需要调用bind,listen,accept函数。客户端就只用调用connect函数,省去每端初始化套接字的过程。

    1. #include /* See NOTES */
    2. #include
    3. #include
    4. #include
    5. #include "Log.hpp"
    6. #include "Err.hpp"
    7. static const int gbacklog = 32;
    8. static const int defaultfd = -1;
    9. class Sock
    10. {
    11. public:
    12. Sock() : _sock(defaultfd)
    13. {
    14. }
    15. void Socket()
    16. {
    17. _sock = socket(AF_INET, SOCK_STREAM, 0);
    18. if (_sock < 0)
    19. {
    20. logMessage(Fatal, "socket error, code: %d, errstring: %s", errno, strerror(errno));
    21. exit(SOCKET_ERR);
    22. }
    23. }
    24. void Bind(const uint16_t &port)
    25. {
    26. struct sockaddr_in local;
    27. memset(&local, 0, sizeof(local));
    28. local.sin_family = AF_INET;
    29. local.sin_port = htons(port);
    30. local.sin_addr.s_addr = INADDR_ANY;
    31. if (bind(_sock, (struct sockaddr *)&local, sizeof(local)) < 0)
    32. {
    33. logMessage(Fatal, "bind error, code: %d, errstring: %s", errno, strerror(errno));
    34. exit(BIND_ERR);
    35. }
    36. }
    37. void Listen()
    38. {
    39. if(listen(_sock,gbacklog) <0)
    40. {
    41. logMessage(Fatal, "listen error, code: %d, errstring: %s", errno, strerror(errno));
    42. exit(LISTEN_ERR);
    43. }
    44. }
    45. int Accept(std::string *clientip, uint16_t *clientport)
    46. {
    47. struct sockaddr_in temp;
    48. socklen_t len = sizeof(temp);
    49. int sock = accept(_sock,(struct sockaddr *)&temp, &len);
    50. if (sock < 0)
    51. {
    52. logMessage(Warning, "accept error, code: %d, errstring: %s", errno, strerror(errno));
    53. }
    54. else
    55. {
    56. *clientip = inet_ntoa(temp.sin_addr);
    57. *clientport = ntohs(temp.sin_port);
    58. }
    59. return sock;
    60. }
    61. int Connect(const std::string &serverip, const uint16_t &serverport)
    62. {
    63. struct sockaddr_in server;
    64. memset(&server, 0, sizeof(server));
    65. server.sin_family = AF_INET;
    66. server.sin_port = htons(serverport);
    67. server.sin_addr.s_addr = inet_addr(serverip.c_str());
    68. return connect(_sock, (struct sockaddr *)&server, sizeof(server));
    69. }
    70. int Fd()
    71. {
    72. return _sock;
    73. }
    74. void Close()
    75. {
    76. if (_sock != defaultfd)
    77. close(_sock);
    78. }
    79. ~Sock()
    80. {
    81. }
    82. private:
    83. int _sock;
    84. };

    服务器端我们需要首先对服务器进行初始化工作,建立套接字,bind端口号,并将服务器设置为listen状态。初始化工作完成后,我们就可以启动服务器,在这里我们使用多线程,可以让服务器同时接收来自多个客户端的消息。

    1. #pragma once
    2. #include
    3. #include
    4. #include
    5. #include "Sock.hpp"
    6. #include "Protocol.hpp"
    7. #include
    8. namespace tcpserver_ns
    9. {
    10. class TcpServer;
    11. class ThreadData
    12. {
    13. public:
    14. ThreadData(int sock, std::string ip, uint16_t port, TcpServer *p)
    15. : _sock(sock), _ip(ip), _port(port), _tsvrp(p)
    16. {
    17. }
    18. ~ThreadData() {}
    19. public:
    20. int _sock;
    21. std::string _ip;
    22. uint16_t _port;
    23. TcpServer *_tsvrp;
    24. };
    25. using namespace protocol_ns;
    26. using func_t = std::function<Response(const Request &)>;
    27. class TcpServer
    28. {
    29. public:
    30. TcpServer(func_t fuc, uint16_t port) : _func(fuc), _port(port)
    31. {
    32. }
    33. void InitServer()
    34. {
    35. // 1.初始化服务器
    36. _listensock.Socket();
    37. _listensock.Bind(_port);
    38. _listensock.Listen();
    39. // 初始化成功,打印日志
    40. logMessage(Info, "init server done, listensock: %d", _listensock.Fd());
    41. }
    42. void StartServer()
    43. {
    44. // 启动服务器(多线程方式)
    45. for (;;)
    46. {
    47. std::string clientip;
    48. uint16_t clientport;
    49. int sock = _listensock.Accept(&clientip, &clientport);
    50. if (sock < 0)
    51. continue;
    52. logMessage(Debug, "get a new client, client info : [%s:%d]", clientip.c_str(), clientport);
    53. pthread_t tid;
    54. ThreadData *td = new ThreadData(sock, clientip, clientport, this);
    55. pthread_create(&tid, nullptr, ThreadRoutine, td);
    56. }
    57. }
    58. static void *ThreadRoutine(void *args)
    59. {
    60. pthread_detach(pthread_self());
    61. ThreadData *td = static_cast(args);
    62. td->_tsvrp->ServiceIO(td->_sock, td->_ip, td->_port);
    63. logMessage(Debug, "thread quit, client quit ...");
    64. }
    65. void ServiceIO(int sock, const std::string &ip, const uint16_t &port)
    66. {
    67. std::string inbuffer;
    68. while (true)
    69. {
    70. // 0. 你怎么保证你读到了一个完整的字符串报文?"7"\r\n""10 + 20"\r\n
    71. std::string package;
    72. int n = ReadPackage(sock, inbuffer, &package);
    73. if (n == -1)
    74. break;
    75. else if (n == 0)
    76. continue;
    77. else
    78. {
    79. // 一定得到了一个"7"\r\n""10 + 20"\r\n
    80. // 1. 你需要的只是有效载荷"10 + 20",要提取有效载荷
    81. package = RemoveHeader(package, n);
    82. // 2. 已经读到了一个完整的string
    83. Request req;
    84. req.Deserialize(package);// 对读到的request字符串要进行反序列化
    85. // 3. 直接提取用户的请求数据啦
    86. Response resp = _func(req);
    87. // 4. 给用户返回响应 - 序列化
    88. std::string send_string;
    89. resp.Serialize(&send_string);
    90. // 5. 添加报头
    91. send_string = AddHeader(send_string);
    92. // 6. 发送
    93. send(sock, send_string.c_str(), send_string.size(), 0);
    94. }
    95. }
    96. }
    97. ~TcpServer()
    98. {
    99. }
    100. private:
    101. uint16_t _port;
    102. Sock _listensock;
    103. func_t _func;
    104. };
    105. }
    1. #include "TcpServer.hpp"
    2. #include
    3. using namespace tcpserver_ns;
    4. Response calculate(const Request &req)
    5. {
    6. // 走到这里,一定保证req是有具体数据的!
    7. // _result(result), _code(code)
    8. Response resp(0, 0);
    9. switch (req._op)
    10. {
    11. case '+':
    12. resp._result = req._x + req._y;
    13. break;
    14. case '-':
    15. resp._result = req._x - req._y;
    16. break;
    17. case '*':
    18. resp._result = req._x * req._y;
    19. break;
    20. case '/':
    21. if (req._y == 0)
    22. resp._code = 1;
    23. else
    24. resp._result = req._x / req._y;
    25. break;
    26. case '%':
    27. if (req._y == 0)
    28. resp._code = 2;
    29. else
    30. resp._result = req._x % req._y;
    31. break;
    32. default:
    33. resp._code = 3;
    34. break;
    35. }
    36. return resp;
    37. }
    38. int main()
    39. {
    40. uint16_t port = 8888;
    41. std::unique_ptr tsvr(new TcpServer(calculate,port));
    42. tsvr->InitServer();
    43. tsvr->StartServer();
    44. return 0;
    45. }

     客户端Client

    客户端首先也需要进行初始化:调用socket函数,创建套接字。客户端初始化完毕后需要调用connect函数连接服务端,当连接服务端成功后,客户端就可以向服务端发起计算请求了。这里可以让用户输入两个操作数和一个操作符构建一个计算请求,然后将该请求发送给服务端。而当服务端处理完该计算请求后,会对客户端进行响应,因此客户端发送完请求后还需要读取服务端发来的响应数据。客户端在向服务端发送或接收数据时,可以使用write或read函数进行发送或接收,也可以使用send或recv函数对应进行发送或接收。

    1. #include "TcpClient.hpp"
    2. #include "Sock.hpp"
    3. #include "Protocol.hpp"
    4. #include
    5. #include
    6. using namespace protocol_ns;
    7. static void usage(std::string proc)
    8. {
    9. std::cout << "Usage:\n\t" << proc << " serverip serverport\n"
    10. << std::endl;
    11. }
    12. enum
    13. {
    14. LEFT,
    15. OPER,
    16. RIGHT
    17. };
    18. // 将输入的一行数据转化为请求的函数
    19. Request ParseLine(const std::string &line)
    20. {
    21. std::string left, right;
    22. char op;
    23. int status = LEFT;
    24. int i = 0;
    25. while (i < line.size())
    26. {
    27. // if(isdigit(e)) left.push_back;
    28. switch (status)
    29. {
    30. case LEFT:
    31. if (isdigit(line[i]))
    32. left.push_back(line[i++]);
    33. else
    34. status = OPER;
    35. break;
    36. case OPER:
    37. op = line[i++];
    38. status = RIGHT;
    39. break;
    40. case RIGHT:
    41. if (isdigit(line[i]))
    42. right.push_back(line[i++]);
    43. break;
    44. }
    45. }
    46. Request req;
    47. std::cout << "left: " << left << std::endl;
    48. std::cout << "right: " << right << std::endl;
    49. std::cout << "op: " << op << std::endl;
    50. req._x = std::stoi(left);
    51. req._y = std::stoi(right);
    52. req._op = op;
    53. return req;
    54. }
    55. int main(int argc, char *argv[])
    56. {
    57. if (argc != 3)
    58. {
    59. usage(argv[0]);
    60. exit(USAGE_ERR);
    61. }
    62. std::string serverip = argv[1];
    63. uint16_t serverport = atoi(argv[2]);
    64. Sock sock;
    65. sock.Socket();
    66. int n = sock.Connect(serverip, serverport);
    67. if (n != 0)
    68. {
    69. logMessage(Error, "client connect fail...");
    70. }
    71. std::string buffer;
    72. while (true)
    73. {
    74. std::cout << "Enter# ";
    75. std::string line;
    76. getline(std::cin, line);
    77. Request req = ParseLine(line);
    78. std::cout << "test: " << req._x << req._op << req._y << std::endl;
    79. // 1. 序列化
    80. std::string sendString;
    81. req.Serialize(&sendString);
    82. // 2. 添加报头
    83. sendString = AddHeader(sendString);
    84. // 3. send
    85. send(sock.Fd(), sendString.c_str(), sendString.size(), 0);
    86. // 4. 获取响应
    87. std::string package;
    88. int n = 0;
    89. START:
    90. n = ReadPackage(sock.Fd(), buffer, &package);
    91. if (n == 0)
    92. goto START;
    93. else if (n < 0)
    94. break;
    95. else
    96. {
    97. // 5. 去掉报头
    98. package = RemoveHeader(package, n);
    99. // 6. 反序列化
    100. Response resp;
    101. resp.Deserialize(package);
    102. std::cout << "result: " << resp._result << "[code: " << resp._code << "]" << std::endl;
    103. // sleep(100);
    104. }
    105. }
    106. return 0;
    107. }

    协议定制

    要实现一个网络版的计算器,就必须保证通信双方能够遵守某种协议约定,因此我们需要设计一套简单的约定。数据可以分为请求数据和响应数据,因此我们分别需要对请求数据和响应数据进行约定。

    在实现时可以采用C++当中的类来实现,也可以直接采用结构体来实现,这里就使用结构体来实现,此时就需要一个请求结构体和一个响应结构体。

    • 请求结构体中需要包括两个操作数,以及对应需要进行的操作。
    • 响应结构体中需要包括一个计算结果,除此之外,响应结构体中还需要包括一个状态字段,表示本次计算的状态,因为客户端发来的计算请求可能是无意义的。

    规定状态字段对应的含义:

    • 状态字段为0,表示计算成功。
    • 状态字段为1,表示出现除0错误。
    • 状态字段为2,表示出现模0错误。
    • 状态字段为3,表示非法计算。

    此时我们就完成了协议的设计,但需要注意,只有当响应结构体当中的状态字段为0时,计算结果才是有意义的,否则计算结果无意义。

    同时在协议中,我们不仅需要定义好应用层需要的结构化数据,更要提供序列化和反序列化的方法,否则只有结构化数据,没用序列化方法也就没有办法发送数据;没有反序列化方法也就无法对收到的数据进行业务处理。

    1. #pragma once
    2. #include
    3. #include
    4. #include
    5. #include
    6. // #include
    7. #include "Util.hpp"
    8. // 给网络版本计算器制订协议
    9. namespace protocol_ns
    10. {
    11. #define SEP " "
    12. #define SEP_LEN strlen(SEP) // 绝对不能写成sizeof
    13. #define HEADER_SEP "\r\n"
    14. #define HEADER_SEP_LEN strlen("\r\n")
    15. std::string AddHeader(const std::string &str)
    16. {
    17. std::string s = std::to_string(str.size());
    18. s += HEADER_SEP;
    19. s += str;
    20. s += HEADER_SEP;
    21. return s;
    22. }
    23. std::string RemoveHeader(const std::string &str, int len)
    24. {
    25. std::string res = str.substr(str.size() - HEADER_SEP_LEN - len, len);
    26. return res;
    27. }
    28. int ReadPackage(int sock, std::string &inbuffer, std::string *package)
    29. {
    30. // 边读取
    31. char buffer[1024];
    32. ssize_t s = recv(sock, buffer, sizeof(buffer - 1), 0);
    33. if (s <= 0)
    34. return -1;
    35. buffer[s] = 0;
    36. inbuffer += buffer;
    37. // 边分析, "7"\r\n""10 + 20"\r\n
    38. auto pos = inbuffer.find(HEADER_SEP);
    39. //分析读取到的数据流,未找到分隔符,说明没读到完整报文
    40. if (pos == std::string::npos)
    41. return 0; // inbuffer什么都没有动
    42. //找到分隔符
    43. std::string lenStr = inbuffer.substr(0, pos); // 获取了头部字符串
    44. int len = Util::toInt(lenStr); // "123" -> 123 inbuffer什么都没有动
    45. int targetPackageLen = lenStr.size() + len + 2 * HEADER_SEP_LEN; // inbuffer什么都没有动
    46. if (inbuffer.size() < targetPackageLen)
    47. return 0; // inbuffer什么都没有动
    48. *package = inbuffer.substr(0, targetPackageLen); // 提取到了一个完整报文,inbuffer还是什么都没有动
    49. inbuffer.erase(0, targetPackageLen); // 读取到完整的报文后,将其从inbuffer中直接移除
    50. return len;
    51. }
    52. class Request
    53. {
    54. public:
    55. Request() {}
    56. Request(int x, int y, char op)
    57. : _x(x), _y(y), _op(op)
    58. {
    59. }
    60. //序列化: struct->string
    61. bool Serialize(std::string *outStr)
    62. {
    63. *outStr = "";
    64. //手动序列化
    65. std::string x = std::to_string(_x);
    66. std::string y = std::to_string(_y);
    67. *outStr = x + SEP + y + _op + SEP + y;
    68. return true;
    69. }
    70. //反序列化: string->struct
    71. bool Deserialize(const std::string &inStr)
    72. {
    73. //inStr: 10 + 20 ----> [0]='10' [1]='+' [2]='20'
    74. //string->vector
    75. std::vector result;
    76. Util::StringSplit(inStr,SEP,&result);
    77. if (result.size() != 3)
    78. return false;
    79. if (result[1].size() != 1)
    80. return false;
    81. _x = Util::toInt(result[0]);
    82. _y = Util::toInt(result[2]);
    83. _op = result[1][0];
    84. return true;
    85. }
    86. ~Request() {}
    87. public:
    88. int _x;
    89. int _y;
    90. char _op;
    91. };
    92. class Response
    93. {
    94. public:
    95. Response() {}
    96. Response(int result, int code) : _result(result), _code(code)
    97. {
    98. }
    99. // struct->string
    100. bool Serialize(std::string *outStr)
    101. {
    102. *outStr = "";
    103. //_result _code
    104. std::string res_string = std::to_string(_result);
    105. std::string code_string = std::to_string(_code);
    106. *outStr = res_string + SEP + code_string;
    107. std::cout << "Response Serialize:\n"
    108. << *outStr << std::endl;
    109. return true;
    110. }
    111. // string->struct
    112. bool Deserialize(const std::string &inStr)
    113. {
    114. // 10 0, 10 1
    115. std::vector result;
    116. Util::StringSplit(inStr, SEP, &result);
    117. if (result.size() != 2)
    118. return false;
    119. _result = Util::toInt(result[0]);
    120. _code = Util::toInt(result[1]);
    121. return true;
    122. }
    123. ~Response() {}
    124. public:
    125. int _result;
    126. int _code; // 0 success, 1,2,3,4代表不同的错误码
    127. };
    128. }

    其它

    为了代码的规范易读以及调试工作,我在这里还加入了一个日志系统,并将序列化反序列化过程中会用到的一些字符串转化函数统一定义到了一个Util头文件中。

    Util.hpp

    1. #pragma once
    2. #include
    3. #include
    4. #include
    5. #include
    6. using namespace std;
    7. class Util
    8. {
    9. public:
    10. // 输入: const &
    11. // 输出: *
    12. // 输入输出: &
    13. static bool StringSplit(const string &str, const string &sep, vector *result)
    14. {
    15. size_t start = 0;
    16. // + 20
    17. // "abcd efg" -> for(int i = 0; i < 10; i++) != for(int i = 0; i <= 9; i++)
    18. while (start < str.size())
    19. {
    20. auto pos = str.find(sep, start);
    21. if (pos == string::npos) break;
    22. result->push_back(str.substr(start, pos-start));
    23. // 位置的重新reload
    24. start = pos + sep.size();
    25. }
    26. if(start < str.size()) result->push_back(str.substr(start));
    27. return true;
    28. }
    29. static int toInt(const std::string &s)
    30. {
    31. // std::stoi();
    32. return atoi(s.c_str());
    33. }
    34. };

    Log.hpp

    1. #pragma once
    2. #include
    3. #include
    4. #include
    5. #include
    6. #include
    7. #include
    8. #include
    9. #include
    10. // 日志是有日志等级的
    11. const std::string filename = "log/tcpserver.log";
    12. enum
    13. {
    14. Debug = 0,
    15. Info,
    16. Warning,
    17. Error,
    18. Fatal,
    19. Uknown
    20. };
    21. static std::string toLevelString(int level)
    22. {
    23. switch (level)
    24. {
    25. case Debug:
    26. return "Debug";
    27. case Info:
    28. return "Info";
    29. case Warning:
    30. return "Warning";
    31. case Error:
    32. return "Error";
    33. case Fatal:
    34. return "Fatal";
    35. default:
    36. return "Uknown";
    37. }
    38. }
    39. static std::string getTime()
    40. {
    41. time_t curr = time(nullptr);
    42. struct tm *tmp = localtime(&curr);
    43. char buffer[128];
    44. snprintf(buffer, sizeof(buffer), "%d-%d-%d %d:%d:%d", tmp->tm_year + 1900, tmp->tm_mon+1, tmp->tm_mday,
    45. tmp->tm_hour, tmp->tm_min, tmp->tm_sec);
    46. return buffer;
    47. }
    48. // 日志格式: 日志等级 时间 pid 消息体
    49. // logMessage(DEBUG, "hello: %d, %s", 12, s.c_str()); // DEBUG hello:12, world
    50. void logMessage(int level, const char *format, ...)
    51. {
    52. char logLeft[1024];
    53. std::string level_string = toLevelString(level);
    54. std::string curr_time = getTime();
    55. snprintf(logLeft, sizeof(logLeft), "[%s] [%s] [%d] ", level_string.c_str(), curr_time.c_str(), getpid());
    56. char logRight[1024];
    57. va_list p;
    58. va_start(p, format);
    59. vsnprintf(logRight, sizeof(logRight), format, p);
    60. va_end(p);
    61. // 打印
    62. printf("%s%s\n", logLeft, logRight);
    63. // 保存到文件中
    64. // FILE *fp = fopen(filename.c_str(), "a");
    65. // if(fp == nullptr)return;
    66. // fprintf(fp,"%s%s\n", logLeft, logRight);
    67. // fflush(fp); //可写也可以不写
    68. // fclose(fp);
    69. // 预备
    70. // va_list p; // char *
    71. // int a = va_arg(p, int); // 根据类型提取参数
    72. // va_start(p, format); //p指向可变参数部分的起始地址
    73. // va_end(p); // p = NULL;
    74. }

    运行效果


    HTTP协议

    HTTP的简介

    上文中我们自己制订了一个协议,不难发现即使想要完成一个简单的计算器功能都需要不小的工作量,因此实际在网络通信中不需要我们自己制订协议了,而是存在许多已经优秀的工程师制订好的协议,我们只需要学习如何使用即可。

    HTTP(Hyper Text Transfer Protocol)协议又叫做超文本传输协议,是一个简单的请求-响应协议,TCP是字节流传输,HTTP通常运行在TCP之上。

    认识URL

    URL(Uniform Resource Lacator)叫做统一资源定位符,也就是我们通常所说的网址,是因特网的万维网服务程序上用于指定信息位置的表示方法。

    一个URL大致由如下几部分构成: 

    协议方案名

    http://表示的是协议名称,表示请求时需要使用的协议,通常使用的是HTTP协议或安全协议HTTPS。HTTPS是以安全为目标的HTTP通道,在HTTP的基础上通过传输加密和身份认证保证了传输过程的安全性。

    常见的应用层协议:

    • DNS(Domain Name System)协议:域名系统。
    • FTP(File Transfer Protocol)协议:文件传输协议。
    • TELNET(Telnet)协议:远程终端协议。
    • HTTP(Hyper Text Transfer Protocol)协议:超文本传输协议。
    • HTTPS(Hyper Text Transfer Protocol over SecureSocket Layer)协议:安全数据传输协议。
    • SMTP(Simple Mail Transfer Protocol)协议:电子邮件传输协议。
    • POP3(Post Office Protocol - Version 3)协议:邮件读取协议。
    • SNMP(Simple Network Management Protocol)协议:简单网络管理协议。
    • TFTP(Trivial File Transfer Protocol)协议:简单文件传输协议。

    登录信息 

    user:pass表示的是登录认证信息,包括登录用户的用户名和密码。虽然登录认证信息可以在URL中体现出来,但绝大多数URL的这个字段都是被省略的,因为登录信息可以通过其他方案交付给服务器,并且直接将密码显示到url中也是不私密的。

    服务器地址

    www.example.ip表示的是服务器地址,也叫做域名,比如www.baidu.com、mp.csdn.net、www.bilibili.com等等。

    需要注意的是,我们用IP地址标识公网内的一台主机,但IP地址本身并不适合给用户看。比如说我们可以通过ping命令,分别获得www.baidu.comwww.qq.com这两个域名解析后的IP地址。

    服务器端口号

    80表示的是服务器端口号。HTTP协议和套接字编程一样都是位于应用层的,在进行套接字编程时我们需要给服务器绑定对应的IP和端口,而这里的应用层协议也同样需要有明确的端口号。

    当我们使用某种协议时,该协议实际就是在为我们提供服务,现在这些常用的服务与端口号之间的对应关系都是明确的,所以我们在使用某种协议时实际是不需要指明该协议对应的端口号的,因此在URL当中,服务器的端口号一般也是被省略的。

    带层次的文件路径 

    表示的是要访问的资源所在的路径。访问服务器的目的是获取服务器上的某种资源,通过前面的域名和端口已经能够找到对应的服务器进程了,此时要做的就是指明该资源所在的路径。

    当我们发起网页请求时,本质是获得了这样的一张网页信息,然后浏览器对这张网页信息进行解释,最后就呈现出了对应的网页。

    我们可以将这种资源称为网页资源,此外我们还会向服务器请求视频、音频、网页、图片等资源。HTTP之所以叫做超文本传输协议,而不叫做文本传输协议,就是因为有很多资源实际并不是普通的文本资源。

    因此在URL当中就有这样一个字段,用于表示要访问的资源所在的路径。此外我们可以看到,这里的路径分隔符是/,而不是\,这也就证明了实际很多服务都是部署在Linux上的。
     

    查询字符串

    uid=1&2表示的是请求时提供的额外的参数,这些参数是以键值对的形式,中间用&分割

    比如我们在百度中搜索helloworld:

    urlencode和urldecode

    如果在搜索关键字当中出现了像/?:这样的字符,由于这些字符已经被URL当作特殊意义理解了,因此URL在呈现时会对这些特殊字符进行转义。

    转义的规则如下:

    • 将需要转码的字符转为十六进制,然后从右到左,取4位(不足4位直接处理),每两位做一位,前面加上%,编码成%XY格式。

    "+" 被转义成了 "%2B" urldecode就是urlencode的逆过程

    HTTP协议格式

    HTTP请求

    一个真实的HTTP请求:

    HTTP请求协议格式如下:

    HTTP请求由以下四部分组成:

    • 请求行:[请求方法]+[url]+[http版本]
    • 请求报头:请求的属性,这些属性都是以key: value的形式按行陈列的。
    • 空行:遇到空行表示请求报头结束。
    • 请求正文:请求正文允许为空字符串,如果请求正文存在,则在请求报头中会有一个Content-Length属性来标识请求正文的长度。

    其中,前面三部分是一般是HTTP协议自带的,是由HTTP协议自行设置的,而请求正文一般是用户的相关信息或数据,如果用户在请求时没有信息要上传给服务器,此时请求正文就为空字符串。

    获取HTTP请求

    因此下面我们编写一个简单的TCP服务器,这个服务器要做的就是把浏览器发来的HTTP请求进行打印即可。(这里我们也使用了上文网络计算器中封装好的Socket头文件)

    1. #include
    2. #include "Sock.hpp"
    3. static int defaultport = 8888;
    4. class HttpServer;
    5. class ThreadData
    6. {
    7. public:
    8. ThreadData(int sock, const std::string &ip, const uint16_t &port, HttpServer *tsvrp)
    9. : _sock(sock), _ip(ip), _port(port), _tsvrp(tsvrp)
    10. {
    11. }
    12. ~ThreadData() {}
    13. public:
    14. int _sock;
    15. std::string _ip;
    16. uint16_t _port;
    17. HttpServer *_tsvrp;
    18. };
    19. class HttpServer
    20. {
    21. public:
    22. HttpServer(int port=defaultport)
    23. :port_(port)
    24. {
    25. }
    26. ~HttpServer()
    27. {
    28. }
    29. void InitServer()
    30. {
    31. sock_.Socket();
    32. sock_.Bind(port_);
    33. sock_.Listen();
    34. }
    35. void Start()
    36. {
    37. for (;;)
    38. {
    39. std::string clientip;
    40. uint16_t clientport;
    41. int sock = sock_.Accept(&clientip, &clientport);
    42. if (sock < 0)
    43. continue;
    44. else
    45. {
    46. pthread_t tid;
    47. ThreadData *td = new ThreadData(sock, clientip, clientport, this);
    48. pthread_create(&tid, nullptr, threadRoutine, td);
    49. }
    50. }
    51. }
    52. static void *threadRoutine(void *args)
    53. {
    54. pthread_detach(pthread_self());
    55. ThreadData *td = static_cast(args);
    56. char buffer[4096];
    57. std::cout<_sock<
    58. int n = recv(td->_sock,buffer,sizeof(buffer)-1,0);
    59. if(n>0)
    60. {
    61. std::cout<
    62. }
    63. else
    64. {
    65. std::cout<<"recv fail\n";
    66. }
    67. close(td->_sock);
    68. return nullptr;
    69. }
    70. void HandlerHttpRequest(int sock)
    71. {
    72. char buffer[4096];
    73. ssize_t n = recv(sock, buffer, sizeof(buffer) - 1, 0);
    74. std::string request;
    75. if(n>0)
    76. {
    77. request = buffer;
    78. std::cout<
    79. }
    80. else
    81. {
    82. std::cout<<"recv fail..."<
    83. }
    84. }
    85. private:
    86. Sock sock_;
    87. int port_;
    88. };
    89. int main()
    90. {
    91. std::unique_ptr tsvr(new HttpServer(8888));
    92. tsvr->InitServer();
    93. tsvr->Start();
    94. return 0;
    95. }

    运行效果:

    HTTP响应

    HTTP响应协议格式如下:

    HTTP响应由以下四部分组成: 

    • 请求行:[响应方法]+[状态码]+[状态描述]
    • 响应报头:响应的属性,这些属性都是以key: value的形式按行陈列的。
    • 空行:遇到空行表示响应报头结束。
    • 响应正文:响应正文允许为空字符串,如果响应正文存在,则响应报头中会有一个Content-Length属性来标识响应正文的长度。比如服务器返回了一个html页面,那么这个html页面的内容就是在响应正文当中的。

    如何将HTTP响应的报头与有效载荷进行分离?

    对于HTTP响应来讲,这里的状态行和响应报头就是HTTP的报头信息,而这里的响应正文实际就是HTTP的有效载荷。与HTTP请求相同,当应用层收到一个HTTP响应时,也是根据HTTP响应当中的空行来分离报头和有效载荷的。当客户端收到一个HTTP响应后,就可以按行进行读取,如果读取到空行则说明报头已经读取完毕。

    发送HTTP响应给浏览器

    服务器读取到客户端发来的HTTP请求后,需要对这个HTTP请求进行各种数据分析,然后构建成对应的HTTP响应发回给客户端。而我们刚才写的服务器连接到客户端后,实际就只读取了客户端发来的HTTP请求就将连接断开了。

    接下来我们可以构建一个HTTP请求给浏览器,鉴于现在还没有办法分析浏览器发来的HTTP请求,这里我们可以给浏览器返回一个固定的HTTP响应。我们就将当前服务程序所在的路径作为我们的web根目录,我们可以在该目录下创建一个html文件,然后编写一个简单的html作为当前服务器的首页。

    1. "en">
    2. "UTF-8">
    3. Document
    4. hello world

    5. 这是从hrimkn的linux服务器发送来的数据

    服务器代码:

    1. #include
    2. #include
    3. #include
    4. #include
    5. #include "Sock.hpp"
    6. static int defaultport = 8888;
    7. class HttpServer;
    8. class ThreadData
    9. {
    10. public:
    11. ThreadData(int sock, const std::string &ip, const uint16_t &port, HttpServer *tsvrp)
    12. : _sock(sock), _ip(ip), _port(port), _tsvrp(tsvrp)
    13. {
    14. }
    15. ~ThreadData() {}
    16. public:
    17. int _sock;
    18. std::string _ip;
    19. uint16_t _port;
    20. HttpServer *_tsvrp;
    21. };
    22. class HttpServer
    23. {
    24. public:
    25. HttpServer(int port = defaultport)
    26. : port_(port)
    27. {
    28. }
    29. ~HttpServer()
    30. {
    31. }
    32. void InitServer()
    33. {
    34. sock_.Socket();
    35. sock_.Bind(port_);
    36. sock_.Listen();
    37. }
    38. void Start()
    39. {
    40. for (;;)
    41. {
    42. std::string clientip;
    43. uint16_t clientport;
    44. int sock = sock_.Accept(&clientip, &clientport);
    45. if (sock < 0)
    46. continue;
    47. else
    48. {
    49. pthread_t tid;
    50. ThreadData *td = new ThreadData(sock, clientip, clientport, this);
    51. pthread_create(&tid, nullptr, threadRoutine, td);
    52. }
    53. }
    54. }
    55. static void *threadRoutine(void *args)
    56. {
    57. pthread_detach(pthread_self());
    58. ThreadData *td = static_cast(args);
    59. // 接收请求
    60. char buffer[4096];
    61. std::cout << td->_sock << std::endl;
    62. int n = recv(td->_sock, buffer, sizeof(buffer) - 1, 0);
    63. if (n > 0)
    64. {
    65. std::cout << buffer << std::endl;
    66. }
    67. else
    68. {
    69. std::cout << "recv fail\n";
    70. }
    71. // 分析请求,发送响应
    72. // 打开写好的html网页文件
    73. int fd = open("./wwwroot/index.html", O_RDONLY);
    74. char file[4096];
    75. ssize_t len = read(fd, file, sizeof(file));
    76. file[len] = '\0';
    77. // 构建HTTP响应
    78. std::string status_line = "http/1.1 200 OK\n"; // 状态行
    79. std::string response_header = "Content-Length: " + std::to_string(len) + "\n"; // 响应报头
    80. std::string blank = "\n"; // 空行
    81. std::string response_text = file; // 响应正文
    82. std::string response = status_line + response_header + blank + response_text; // 响应报文
    83. // 发送请求
    84. send(td->_sock, response.c_str(), response.size(), 0);
    85. close(td->_sock);
    86. return nullptr;
    87. }
    88. private:
    89. Sock sock_;
    90. int port_;
    91. };
    92. int main()
    93. {
    94. std::unique_ptr tsvr(new HttpServer(8888));
    95. tsvr->InitServer();
    96. tsvr->Start();
    97. return 0;
    98. }

    运行结果:

    说明一下:

    • 实际我们在进行网络请求的时候,如果不指明请求资源的路径,此时默认你想访问的就是目标网站的首页,也就是web根目录下的index.html文件。
    • 由于只是作为示例,我们在构建HTTP响应时,在响应报头当中只添加了一个属性信息Content-Length,表示响应正文的长度,实际HTTP响应报头当中的属性信息还有很多。

    HTTP的方法 

    HTTP的方法如下:

    其中最常用的是GET和POST方法。

    GET和POST方法

    GET方法一般用于获取某种资源信息,而POST方法一般用于将数据上传给服务器。但实际我们上传数据时也有可能使用GET方法,比如百度提交数据时实际使用的就是GET方法。

    GET方法和POST方法都可以带参:

    • GET方法是通过url传参的。
    • POST方法是通过正文传参的。

    从GET方法和POST方法的传参形式可以看出,POST方法能传递更多的参数,因为url的长度是有限制的,POST方法通过正文传参就可以携带更多的数据。

    此外,使用POST方法传参更加私密,因为POST方法不会将你的参数回显到url当中,此时也就不会被别人轻易看到。不能说POST方法比GET方法更安全,因为POST方法和GET方法实际都不安全,要做到安全只能通过加密来完成。

    POSTMAN演示

    Postman是一种网页调试与发送网页http请求的chrome插件。我们可以用来很方便的模拟get或者post或者其他方式的请求来调试接口。在Postman中,请求可以保存,也就类似于文件。而Collection类似文件夹,可以把同一个项目的请求放在一个Collection里方便管理和分享,Collection里面也可以再建文件夹。Postman具有每个API开发人员的功能:请求构建,测试和预请求脚本,变量,环境和请求描述,旨在无缝地一起工作。在这里我们可以借助POSTMAN来看一看http请求方法中GET和POST的区别。

    如果访问我们的服务器时使用的是GET方法,此时应该通过url进行传参,可以在Params下进行参数设置,因为Postman当中的Params就相当于url当中的参数,你在设置参数时可以看到对应的url也在随之变化。

    而如果我们使用的是POST方法,此时就应该通过正文进行传参,可以在Body下进行参数设置,在设置时可以选中Postman当中的raw方式传参,表示原始传参,也就是你输入的参数是什么样的实际传递的参数就是什么样的。

     

    HTTP的状态码

    最常见的状态码,比如:

    • 200(OK)
    • 404(Not Found)
    • 403(Forbidden请求权限不够)
    • 302(Redirect)
    • 504(Bad Gateway)

    Redirection(重定向状态码) 

    重定向就是通过各种方法将各种网络请求重新定个方向转到其它位置,此时这个服务器相当于提供了一个引路的服务。

    重定向又可分为临时重定向永久重定向,其中状态码301表示的就是永久重定向,而状态码302和307表示的是临时重定向。

    临时重定向和永久重定向本质是影响客户端的标签,决定客户端是否需要更新目标地址。如果某个网站是永久重定向,那么第一次访问该网站时由浏览器帮你进行重定向,但后续再访问该网站时就不需要浏览器再进行重定向了,此时你访问的直接就是重定向后的网站。而如果某个网站是临时重定向,那么每次访问该网站时如果需要进行重定向,都需要浏览器来帮我们完成重定向跳转到目标网站。

    临时重定向演示

    进行临时重定向时需要用到Location字段,Location字段是HTTP报头当中的一个属性信息,该字段表明了你所要重定向到的目标网站。

    将http响应加入重定向

    1. //4. 重定向测试
    2. std::string response;
    3. response = "HTTP/1.0 301 Moved Permanently" + SEP;
    4. response += "Location: https://www.bilibili.com/" + SEP;
    5. response += SEP;
    6. // 发送请求
    7. send(td->_sock, response.c_str(), response.size(), 0);

    运行效果:

    HTTP常见header

    • Content-Type: 数据类型(text/html等)
    • Content-Length: Body的长度
    • Host: 客户端告知服务器, 所请求的资源是在哪个主机的哪个端口上;
    • User-Agent: 声明用户的操作系统和浏览器版本信息;
    • referer: 当前页面是从哪个页面跳转过来的;
    • location: 搭配3xx状态码使用, 告诉客户端接下来要去哪里访问;
    • Cookie: 用于在客户端存储少量信息. 通常用于实现会话(session)的功能
       

    Cookie和session

    HTTP实际上是一种无状态协议,HTTP的每次请求/响应之间是没有任何关系的,但你在使用浏览器的时候发现并不是这样的。

    比如当你登录一次bilibil后,就算你把网站关了甚至是重启电脑,当你再次打开该网站时,bilibili并没有要求你再次输入账号和密码,这实际上是通过cookie技术实现的,点击浏览器当中锁的标志就可以看到对应网站的各种cookie数据。

    这些cookie数据实际都是对应的服务器方写的,如果你将对应的某些cookie删除,那么此时可能就需要你重新进行登录认证了,因为你删除的可能正好就是你登录时所设置的cookie信息。

    cookie 

    因为HTTP是一种无状态协议,如果没有cookie的存在,那么每当我们要进行页面请求时都需要重新输入账号和密码进行认证,这样太麻烦了。

    比如你是某个视频网站的VIP,这个网站里面的VIP视频有成百上千个,你每次点击一个视频都要重新进行VIP身份认证。而HTTP不支持记录用户状态,那么我们就需要有一种独立技术来帮我们支持,这种技术目前现在已经内置到HTTP协议当中了,叫做cookie。

    当我们第一次登录某个网站时,需要输入我们的账号和密码进行身份认证,此时如果服务器经过数据比对后判定你是一个合法的用户,那么为了让你后续在进行某些网页请求时不用重新输入账号和密码,此时服务器就会进行Set-Cookie的设置。(Set-Cookie也是HTTP报头当中的一种属性信息)

    当认证通过并在服务端进行Set-Cookie设置后,服务器在对浏览器进行HTTP响应时就会将这个Set-Cookie响应给浏览器。而浏览器收到响应后会自动提取出Set-Cookie的值。当认证通过并在服务端进行Set-Cookie设置后,服务器在对浏览器进行HTTP响应时就会将这个Set-Cookie响应给浏览器。而浏览器收到响应后会自动提取出Set-Cookie的值。

    内存级别&文件级别

    cookie就是在浏览器当中的一个小文件,文件里记录的就是用户的私有信息。cookie文件可以分为两种,一种是内存级别的cookie文件,另一种是文件级别的cookie文件。

    • 将浏览器关掉后再打开,访问之前登录过的网站,如果需要你重新输入账号和密码,说明你之前登录时浏览器当中保存的cookie信息是内存级别的。
    • 将浏览器关掉甚至将电脑重启再打开,访问之前登录过的网站,如果不需要你重新输入账户和密码,说明你之前登录时浏览器当中保存的cookie信息是文件级别的。

    SessionID

    单纯的使用cookie是非常不安全的,因为此时cookie文件当中就保存的是你的私密信息,一旦cookie文件泄漏你的隐私信息也就泄漏。

    如果你浏览器当中保存的cookie信息被非法用户盗取了,那么此时这个非法用户就可以用你的cookie信息,以你的身份去访问你曾经访问过的网站,我们将这种现象称为cookie被盗取了。比如你不小心点了某个链接,这个链接可能就是一个下载程序,当你点击之后它就会通过某种方式把程序下载到你本地,并且自动执行该程序,该程序会扫描你的浏览器当中的cookie目录,把所有的cookie信息通过网络的方式传送给恶意方,当恶意方拿到你的cookie信息后就可以拷贝到它的浏览器对应的cookie目录当中,然后以你的身份访问你曾经访问过的网站。

    所以当前主流的服务器还引入了SessionID这样的概念,当我们第一次登录某个网站输入账号和密码后,服务器认证成功后还会服务端生成一个对应的SessionID,这个SessionID与用户信息是不相关的。系统会将所有登录用户的SessionID值统一维护起来。

    此时当认证通过后服务端在对浏览器进行HTTP响应时,就会将这个生成的SessionID值响应给浏览器。浏览器收到响应后会自动提取出SessionID的值,将其保存在浏览器的cookie文件当中。后续访问该服务器时,对应的HTTP请求当中就会自动携带上这个SessionID。

    引入SessionID后的好处

    • 在引入SessionID之前,用户登录的账号信息都是保存在浏览器内部的,此时的账号信息是由客户端去维护的。
    • 而引入SessionID后,用户登录的账号信息是有服务器去维护的,在浏览器内部保存的只是SessionID。

    此时虽然SessionID可能被非法用户盗取,但服务器也可以使用各种各样的策略来保证用户账号的安全。

    具体演示

    1. //cookie && session实验
    2. std::string response;
    3. response = "HTTP/1.0 200 OK" + SEP;
    4. response += "Set-Cookie: sessionid=1234abcd" + SEP;
    5. response += SEP;
    6. // 发送请求
    7. send(td->_sock, response.c_str(), response.size(), 0);

    运行结果: 


  • 相关阅读:
    LeetCode 1700. 无法吃午餐的学生数量:真假模拟(极简代码) + 奇技淫巧
    网络攻防实战演练
    C++设计模式-适配器(Adapter)
    Redis 实现持久化
    大学生网页制作教程 学生HTML静态动物网页设计作业成品 简单网页制作代码 学生宠物网页作品
    解决IDEA中Tomcat控制台乱码问题(包括sout输出乱码)
    论文阅读11——《Mutual Boost Network for Attributed Graph Clustering》
    JavaScript实现经典消方块游戏
    事务的特性
    Docker入门Dockerfile详解及镜像创建
  • 原文地址:https://blog.csdn.net/weixin_69519040/article/details/132744002