• cpp-httplib 源码剖析



    前言

    之前实现自己的http库的时候感觉有一些设计的不是很好,这几天对cpp-httplib 源码进行剖析,对如何设计http库有了更深入的认识。(主要是对server类进行拆解分析)

    一、cpp-httplib 是什么?

    cpp-httplib是一个c++封装的http开源库,仅包含一个头文件,不过代码行数达到8000多行。
    cpp-httplib 服务端采用select IO多路复用模型,工作线程池的处理方式,主要包含的类Server、Client、Request、Response。

    二、Server类整体架构

    server类的工作流程基本如下:
    1、 搭建tcp服务,注册资源路径与处理方法,并开启监听。
    2、 创建工作线程,select循环监听,当收到客户端消息,放入jobs中,通知工作线程处理。
    3、工作线程从jobs中取出待处理请求。
    4、 处理请求,包含校验、协议解析、根据请求方式分发,最终调用用户注册方法处理上层业务。
    在这里插入图片描述

    三、绑定和监听

    绑定和监听是由Servert中两个成员函数bind_internal和listen_internal的实现的。

    bind_internal

    bind internal函数的作用是绑定服务器的地址和端口。函数首先检查服务器套接字是否有效,如果无效则返回-1。然后调用create_server_socket函数创建服务器套接字,并将其赋值给成员变量svr_sock.。如果创建套接字失败,则返回-1。接下来,根据传入的ort参数判断是否为0。如果为0,则通过调用getsockname函数获取绑定的地址和端口,并返回解析得到的端口号。否则,直接返回传入的port参数。

    
    inline int Server::bind_internal(const std::string &host, int port,
                                     int socket_flags) {
      if (!is_valid()) { return -1; }
    
      svr_sock_ = create_server_socket(host, port, socket_flags, socket_options_);
      if (svr_sock_ == INVALID_SOCKET) { return -1; }
    
      if (port == 0) {
        struct sockaddr_storage addr;
        socklen_t addr_len = sizeof(addr);
        if (getsockname(svr_sock_, reinterpret_cast<struct sockaddr *>(&addr),
                        &addr_len) == -1) {
          return -1;
        }
        if (addr.ss_family == AF_INET) {
          return ntohs(reinterpret_cast<struct sockaddr_in *>(&addr)->sin_port);
        } else if (addr.ss_family == AF_INET6) {
          return ntohs(reinterpret_cast<struct sockaddr_in6 *>(&addr)->sin6_port);
        } else {
          return -1;
        }
      } else {
        return port;
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26

    listen_internal

    首先是对Select的封装:
    创建了一个 fd_set 对象并将要监听的套接字加入其中,然后设置超时时间,最后调用 select 函数进行阻塞等待可读事件或超时。通过这种方式可以同时监听多个套接字,并在有可读事件发生时进行处理。

    inline ssize_t select_read(socket_t sock, time_t sec, time_t usec) {
      fd_set fds;
      FD_ZERO(&fds);
      FD_SET(sock, &fds);
    
      timeval tv;
      tv.tv_sec = static_cast<long>(sec);
      tv.tv_usec = static_cast<decltype(tv.tv_usec)>(usec);
    
      return handle_EINTR([&]() {
        return select(static_cast<int>(sock + 1), &fds, nullptr, nullptr, &tv);
      });
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    Server::listen_internal() 函数,它用于监听连接并处理客户端请求。

    auto ret = true;
    is_running_ = true;
    auto se = detail::scope_exit([&]() { is_running_ = false; });
    
    • 1
    • 2
    • 3

    首先将返回值 ret 设为 true,表示函数执行成功。将服务器运行状态 is_running_ 设为 true,并创建 detail::scope_exit 对象,在函数退出时将 is_running_ 设置为 false。

    std::unique_ptr<TaskQueue> task_queue(new_task_queue());
    
    • 1

    创建一个 TaskQueue 的智能指针 task_queue,用于保存待处理的任务。

    while (svr_sock_ != INVALID_SOCKET) {
    #ifndef _WIN32
      if (idle_interval_sec_ > 0 || idle_interval_usec_ > 0) {
    #endif
        auto val = detail::select_read(svr_sock_, idle_interval_sec_,
                                       idle_interval_usec_);
        if (val == 0) { // Timeout
          task_queue->on_idle();
          continue;
        }
    #ifndef _WIN32
      }
    #endif
      socket_t sock = accept(svr_sock_, nullptr, nullptr);
    
      // ...
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    进入主循环,只要服务器 socket svr_sock_ 是有效的,就继续监听连接。在循环中,先调用 detail::select_read() 函数等待读事件,如果超时则调用 task_queue->on_idle() 处理空闲状态,并继续等待下一个连接。如果有新的连接到达,调用 accept() 函数接受客户端连接,并返回一个新的客户端 socket sock

    if (sock == INVALID_SOCKET) {
      if (errno == EMFILE) {
        // The per-process limit of open file descriptors has been reached.
        // Try to accept new connections after a short sleep.
        std::this_thread::sleep_for(std::chrono::milliseconds(1));
        continue;
      } else if (errno == EINTR || errno == EAGAIN) {
        continue;
      }
      if (svr_sock_ != INVALID_SOCKET) {
        detail::close_socket(svr_sock_);
        ret = false;
      } else {
        ; // The server socket was closed by user.
      }
      break;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    如果 accept() 失败,则根据错误码进行相应处理。如果错误码是 EMFILE,表示打开文件描述符的数量达到了系统设置的最大值,此时暂停一段时间后继续尝试。如果错误码是 EINTREAGAIN,表示该操作被中断或者资源暂时不可用,需要继续重试。如果发生其他错误,则关闭服务器 socket,并将返回值设为 false,表示函数执行失败。此时跳出循环,结束监听。

    {
    #ifdef _WIN32
      auto timeout = static_cast<uint32_t>(read_timeout_sec_ * 1000 +
                                           read_timeout_usec_ / 1000);
      setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, (char *)&timeout,
                 sizeof(timeout));
    #else
      timeval tv;
      tv.tv_sec = static_cast<long>(read_timeout_sec_);
      tv.tv_usec = static_cast<decltype(tv.tv_usec)>(read_timeout_usec_);
      setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, (char *)&tv, sizeof(tv));
    #endif
    }
    {
    #ifdef _WIN32
      auto timeout = static_cast<uint32_t>(write_timeout_sec_ * 1000 +
                                           write_timeout_usec_ / 1000);
      setsockopt(sock, SOL_SOCKET, SO_SNDTIMEO, (char *)&timeout,
                 sizeof(timeout));
    #else
      timeval tv;
      tv.tv_sec = static_cast<long>(write_timeout_sec_);
      tv.tv_usec = static_cast<decltype(tv.tv_usec)>(write_timeout_usec_);
      setsockopt(sock, SOL_SOCKET, SO_SNDTIMEO, (char *)&tv, sizeof(tv));
    #endif
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26

    为新的客户端 socket sock 设置读超时和写超时。

    task_queue->enqueue([this, sock]() { process_and_close_socket(sock); });
    
    • 1

    sock 加入 task_queue,并用 process_and_close_socket() 函数来处理这个连接。

    task_queue->shutdown();
    
    • 1

    循环结束后,调用 task_queue->shutdown() 关闭任务队列。

    最后返回变量 ret,表示函数执行结果。如果 ret 是 true,则表示函数执行成功,否则表示失败。

    四、路由

    添加路由

    使用正则表达式来指定URL的模式,并注册不同类型请求的处理函数。
    比如说Get函数用于注册GET请求的处理器。将匹配的URL模式(pattern)和处理函数(handler)作为键值对添加到get_handlers_列表中。

    
    inline Server &Server::Get(const std::string &pattern, Handler handler) {
      get_handlers_.push_back(
          std::make_pair(std::regex(pattern), std::move(handler)));
      return *this;
    }
    
    inline Server &Server::Post(const std::string &pattern, Handler handler) {
      post_handlers_.push_back(
          std::make_pair(std::regex(pattern), std::move(handler)));
      return *this;
    }
    
    inline Server &Server::Post(const std::string &pattern,
                                HandlerWithContentReader handler) {
      post_handlers_for_content_reader_.push_back(
          std::make_pair(std::regex(pattern), std::move(handler)));
      return *this;
    }
    
    inline Server &Server::Put(const std::string &pattern, Handler handler) {
      put_handlers_.push_back(
          std::make_pair(std::regex(pattern), std::move(handler)));
      return *this;
    }
    
    inline Server &Server::Put(const std::string &pattern,
                               HandlerWithContentReader handler) {
      put_handlers_for_content_reader_.push_back(
          std::make_pair(std::regex(pattern), std::move(handler)));
      return *this;
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33

    路由

    主要函数有routing以及dispatch_request。
    dispatch_request方法用于根据请求的路径(path)匹配相应的处理器(handler),并执行该处理器来处理请求。

    inline bool Server::dispatch_request(Request &req, Response &res,
                                         const Handlers &handlers) {
      for (const auto &x : handlers) {
        const auto &pattern = x.first;
        const auto &handler = x.second;
        if (std::regex_match(req.path, req.matches, pattern)) {
          handler(req, res);
          return true;
        }
      }
      return false;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    routing根据请求的方法和内容,将请求分发给相应的处理函数进行处理,并生成对应的响应

    主要功能如下:

    1. 首先,如果存在预处理器(pre_routing_handler_)且该预处理器处理了请求(req)并返回HandlerResponse::Handled,则直接返回true,表示请求已被处理完成。
    2. 接着,判断请求的方法(method)是否为"GET"或"HEAD",如果是,则调用handle_file_request函数处理文件请求,并返回true,表示请求已被处理完成。
    3. 如果请求中包含内容,则进入内容读取器(ContentReader)的处理流程。首先,创建ContentReader对象,该对象根据请求的方法选择相应的处理逻辑。具体处理逻辑包括:
      • 对于"POST"方法,调用dispatch_request_for_content_reader函数分发请求到post_handlers_for_content_reader_列表中的处理器。
      • 对于"PUT"方法,调用dispatch_request_for_content_reader函数分发请求到put_handlers_for_content_reader_列表中的处理器。
      • 对于"PATCH"方法,调用dispatch_request_for_content_reader函数分发请求到patch_handlers_for_content_reader_列表中的处理器。
      • 对于"DELETE"方法,调用dispatch_request_for_content_reader函数分发请求到delete_handlers_for_content_reader_列表中的处理器。
    4. 如果请求中包含内容,在上述步骤完成后,继续读取请求的内容,并将内容存储到req.body中。
    5. 最后,根据请求的方法分发请求到相应的处理器列表中去处理请求,并返回处理结果。如果请求方法未被识别,或者没有找到匹配的处理器,则设置响应的状态码为400,并返回false。

    通过这个路由方法,服务器可以根据请求的方法和内容,将请求分发给相应的处理函数进行处理,并生成对应的响应。

    inline bool Server::routing(Request &req, Response &res, Stream &strm) {
      if (pre_routing_handler_ &&
          pre_routing_handler_(req, res) == HandlerResponse::Handled) {
        return true;
      }
    
      // File handler
      bool is_head_request = req.method == "HEAD";
      if ((req.method == "GET" || is_head_request) &&
          handle_file_request(req, res, is_head_request)) {
        return true;
      }
    
      if (detail::expect_content(req)) {
        // Content reader handler
        {
          ContentReader reader(
              [&](ContentReceiver receiver) {
                return read_content_with_content_receiver(
                    strm, req, res, std::move(receiver), nullptr, nullptr);
              },
              [&](MultipartContentHeader header, ContentReceiver receiver) {
                return read_content_with_content_receiver(strm, req, res, nullptr,
                                                          std::move(header),
                                                          std::move(receiver));
              });
    
          if (req.method == "POST") {
            if (dispatch_request_for_content_reader(
                    req, res, std::move(reader),
                    post_handlers_for_content_reader_)) {
              return true;
            }
          } else if (req.method == "PUT") {
            if (dispatch_request_for_content_reader(
                    req, res, std::move(reader),
                    put_handlers_for_content_reader_)) {
              return true;
            }
          } else if (req.method == "PATCH") {
            if (dispatch_request_for_content_reader(
                    req, res, std::move(reader),
                    patch_handlers_for_content_reader_)) {
              return true;
            }
          } else if (req.method == "DELETE") {
            if (dispatch_request_for_content_reader(
                    req, res, std::move(reader),
                    delete_handlers_for_content_reader_)) {
              return true;
            }
          }
        }
    
        // Read content into `req.body`
        if (!read_content(strm, req, res)) { return false; }
      }
    
      // Regular handler
      if (req.method == "GET" || req.method == "HEAD") {
        return dispatch_request(req, res, get_handlers_);
      } else if (req.method == "POST") {
        return dispatch_request(req, res, post_handlers_);
      } else if (req.method == "PUT") {
        return dispatch_request(req, res, put_handlers_);
      } else if (req.method == "DELETE") {
        return dispatch_request(req, res, delete_handlers_);
      } else if (req.method == "OPTIONS") {
        return dispatch_request(req, res, options_handlers_);
      } else if (req.method == "PATCH") {
        return dispatch_request(req, res, patch_handlers_);
      }
    
      res.status = 400;
      return false;
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77

    五、处理接受请求

    process_and_close_socket() 函数处理连接,其中最核心的是process_server_socket_core函数以及process_request函数。

    process_server_socket_core

    process_server_socket_core函数用于在服务器套接字上处理连接,并根据保持活动的最大次数和超时时间执行回调函数。在每次循环中,会根据剩余的保持活动次数判断是否需要关闭连接,并将回调函数的执行结果保存在变量ret中。函数最终返回回调函数的执行结果。

    template <typename T>
    inline bool
    process_server_socket_core(const std::atomic<socket_t> &svr_sock, socket_t sock,
                               size_t keep_alive_max_count,
                               time_t keep_alive_timeout_sec, T callback) {
      assert(keep_alive_max_count > 0);
      auto ret = false;
      auto count = keep_alive_max_count;
      while (svr_sock != INVALID_SOCKET && count > 0 &&
             keep_alive(sock, keep_alive_timeout_sec)) {
        auto close_connection = count == 1;
        auto connection_closed = false;
        ret = callback(close_connection, connection_closed);
        if (!ret || connection_closed) { break; }
        count--;
      }
      return ret;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    process_request

    回调函数是process_request处理请求。
    请求处理步骤如下:

    1. 设置默认的HTTP响应版本号为"HTTP/1.1"。
    2. 将默认的HTTP响应头加入响应对象中,如果响应对象中没有相同的头字段。
    3. 检查请求的URI长度是否超过最大限制,如果超过则返回状态码414(请求URI过长)。
    4. 解析请求行和请求头,如果解析失败则返回状态码400(错误的请求)。
    5. 根据请求的Connection头判断是否需要关闭连接。
    6. 获取客户端和服务器端的IP地址和端口,并添加到请求头中。
    7. 如果请求头包含Range字段,解析Range字段的值,并存储在请求对象的ranges成员中。
    8. 调用用户提供的请求处理回调函数,对请求做进一步处理。
    9. 如果请求头包含Expect字段并且值为"100-continue",则发送100 Continue响应。
    10. 根据路由规则处理请求,并设置相应的状态码。
    11. 如果成功匹配到路由规则,根据请求中的ranges判断是否返回部分内容或全部内容。
    12. 如果无法匹配到路由规则,则返回404 Not Found状态码。
    13. 最后根据routing路由处理结果,将响应发送回客户端。
    
    inline bool
    Server::process_request(Stream &strm, bool close_connection,
                            bool &connection_closed,
                            const std::function<void(Request &)> &setup_request) {
      std::array<char, 2048> buf{};
    
      detail::stream_line_reader line_reader(strm, buf.data(), buf.size());
    
      // Connection has been closed on client
      if (!line_reader.getline()) { return false; }
    
      Request req;
      Response res;
    
      res.version = "HTTP/1.1";
    
      for (const auto &header : default_headers_) {
        if (res.headers.find(header.first) == res.headers.end()) {
          res.headers.insert(header);
        }
      }
    
    #ifdef _WIN32
      // TODO: Increase FD_SETSIZE statically (libzmq), dynamically (MySQL).
    #else
    #ifndef CPPHTTPLIB_USE_POLL
      // Socket file descriptor exceeded FD_SETSIZE...
      if (strm.socket() >= FD_SETSIZE) {
        Headers dummy;
        detail::read_headers(strm, dummy);
        res.status = 500;
        return write_response(strm, close_connection, req, res);
      }
    #endif
    #endif
    
      // Check if the request URI doesn't exceed the limit
      if (line_reader.size() > CPPHTTPLIB_REQUEST_URI_MAX_LENGTH) {
        Headers dummy;
        detail::read_headers(strm, dummy);
        res.status = 414;
        return write_response(strm, close_connection, req, res);
      }
    
      // Request line and headers
      if (!parse_request_line(line_reader.ptr(), req) ||
          !detail::read_headers(strm, req.headers)) {
        res.status = 400;
        return write_response(strm, close_connection, req, res);
      }
    
      if (req.get_header_value("Connection") == "close") {
        connection_closed = true;
      }
    
      if (req.version == "HTTP/1.0" &&
          req.get_header_value("Connection") != "Keep-Alive") {
        connection_closed = true;
      }
    
      strm.get_remote_ip_and_port(req.remote_addr, req.remote_port);
      req.set_header("REMOTE_ADDR", req.remote_addr);
      req.set_header("REMOTE_PORT", std::to_string(req.remote_port));
    
      strm.get_local_ip_and_port(req.local_addr, req.local_port);
      req.set_header("LOCAL_ADDR", req.local_addr);
      req.set_header("LOCAL_PORT", std::to_string(req.local_port));
    
      if (req.has_header("Range")) {
        const auto &range_header_value = req.get_header_value("Range");
        if (!detail::parse_range_header(range_header_value, req.ranges)) {
          res.status = 416;
          return write_response(strm, close_connection, req, res);
        }
      }
    
      if (setup_request) { setup_request(req); }
    
      if (req.get_header_value("Expect") == "100-continue") {
        auto status = 100;
        if (expect_100_continue_handler_) {
          status = expect_100_continue_handler_(req, res);
        }
        switch (status) {
        case 100:
        case 417:
          strm.write_format("HTTP/1.1 %d %s\r\n\r\n", status,
                            detail::status_message(status));
          break;
        default: return write_response(strm, close_connection, req, res);
        }
      }
    
      // Rounting
      bool routed = false;
    #ifdef CPPHTTPLIB_NO_EXCEPTIONS
      routed = routing(req, res, strm);
    #else
      try {
        routed = routing(req, res, strm);
      } catch (std::exception &e) {
        if (exception_handler_) {
          auto ep = std::current_exception();
          exception_handler_(req, res, ep);
          routed = true;
        } else {
          res.status = 500;
          std::string val;
          auto s = e.what();
          for (size_t i = 0; s[i]; i++) {
            switch (s[i]) {
            case '\r': val += "\\r"; break;
            case '\n': val += "\\n"; break;
            default: val += s[i]; break;
            }
          }
          res.set_header("EXCEPTION_WHAT", val);
        }
      } catch (...) {
        if (exception_handler_) {
          auto ep = std::current_exception();
          exception_handler_(req, res, ep);
          routed = true;
        } else {
          res.status = 500;
          res.set_header("EXCEPTION_WHAT", "UNKNOWN");
        }
      }
    #endif
    
      if (routed) {
        if (res.status == -1) { res.status = req.ranges.empty() ? 200 : 206; }
        return write_response_with_content(strm, close_connection, req, res);
      } else {
        if (res.status == -1) { res.status = 404; }
        return write_response(strm, close_connection, req, res);
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114
    • 115
    • 116
    • 117
    • 118
    • 119
    • 120
    • 121
    • 122
    • 123
    • 124
    • 125
    • 126
    • 127
    • 128
    • 129
    • 130
    • 131
    • 132
    • 133
    • 134
    • 135
    • 136
    • 137
    • 138
    • 139
  • 相关阅读:
    驱动开发:通过ReadFile与内核层通信
    electronjs入门-编辑器应用程序
    中间件 Redis 服务集群的部署方案
    美创科技勒索病毒“零信任”防护和数据安全治理体系的探索实践
    搭建HTTPS服务器
    【学习笔记十九】EWM Yard Management概述及后台配置
    高情商的正确表达方式都在这,建议收藏备用
    URL和URI
    大连交通大学计算机考研资料汇总
    【STM32】【HAL库】【实用制作】数控收音机(软件设计)
  • 原文地址:https://blog.csdn.net/weixin_44545838/article/details/132800974