Muduo网络库实现 [十六] - HttpServer模块

打印 上一主题 下一主题

主题 1866|帖子 1866|积分 5598

目录

设计思路
类的设计
模块的实现
公有接口
私有接口
疑问点


设计思路

本模块就是设计一个HttpServer模块,提供便携的搭建http协议的服务器的方法。那么这个模块需要如何设计呢? 这还需要从Http哀求说起。
起首从http哀求的哀求行开始分析,哀求行内里有个方法。分为静态资源哀求功能性哀求的。
静态资源哀求顾名思义就是用来获取服务器中的某些路径下的实体资源,好比文件的内容等,这一类哀求中,url 中的资源路径必须是服务器中的一个有效的存在的文件路径。好比:


  • HTML/CSS/JavaScript文件
  • 图片(JPG、PNG、GIF等)
  • 视频和音频文件
  • PDF、文档等静态文件
  • 字体文件
而如果提取出来的资源路径并不是一个实体文件的路径,那么他大概率是一个功能性哀求,这时候就有用户来决定如何处理这个哀求了,也就是我们前面说过的 哀求路径 和 处理方法的路由表。
功能性哀求如下


  • 用户登录/注册
  • 商品搜刮效果
  • 个人资料页面
  • 订单处理
  • API接口调用
  • 数据统计和报表生成
但是还有一种特殊的环境就是资源路径是一个目录,好比 / ,这时候有可能是一个访问网站首页的哀求,以是我们需要判断在这个路径背面加上 index.html (也可以是其他的文件名,取决于你的网站的首页的文件名) ,如果加上之后,路径有效且存在实体文件,那么就是一个静态资源哀求,如果照旧无效,那么就是一个功能性哀求。
功能性哀求如何处理呢?这是由利用大概说搭建服务器的人来决定的。 用户未来想要提供某些功能,可以让他和某个虚拟的目录大概说特定的路径绑定起来。 好比提供一个登录功能,那么用户可以规定  /login 这个路径就代表登录的功能,未来如果收到了一个哀求资源路径是 /login ,那么就不是哀求实体资源,而是调用网站搭建者提供的登录的方法举行验证等操作。 一般来说这些虚拟路径不会和实体资源路径冲突。
同时,对于这种功能性哀求对应的路径,他并不是说一个路径只能有一个功能,不同的哀求方法,同一个路径,最终实行的方法也可以是不同的,这具体照旧要看利用者的设定。
以是为了维护这样的功能性路径和需要实行的方法之间的映射关系,我们需要为每一种哀求方法都维护一张路由表,路由表中实在就是生存了路径和所需要实行的方法之间的映射关系。

在我们这里,就只思量常用的五种方法,get,post,delete,head,put,其他的临时就不提供支持了
  1.     //五张路由表
  2.     using Handler = std::function<void(const HttpRequest&,HttpResponse*)>;
  3.     using HandlerTable = std::unordered_map<std::string,Handler>;
  4.     HandlerTable _get_route;
  5.     HandlerTable _post_route;
  6.     HandlerTable _head_route;
  7.     HandlerTable _put_route;
  8.     HandlerTable _delete_route;
复制代码
这是交给用户举行设置的,我们也会提供五个接口给用户用来添加处理方法。
但是,这样的表真的好吗? 
在实际的应用中,好比有以下的功能性哀求的哀求路径 , /login1213 , /login12124 , /login1213626 , /login12152 , /login1295 , /login1275,对于这样的一类路径,他们实在需要实行的是同一个方法,而并不需要为每一个类似的路径设置一个方法,而路径后半部门的数字实在后续可以当成参数来用。
那么综上所述,我们的路由表中作为 key 值的并不是 std::string ,而是只需要满足某一种匹配要求的路径,都可以实行某一方法,那么作为 key 值的实在是正则表达式。
  1.     using HandlerTable = std::unordered_map<std::regex,Handler>;
复制代码
但是如果我们编译一下就会发现,正则表达式是不能作为哈希的 key 值的,大概说不匹配默认的哈希函数。 
我们可以思索一下,我们用正则表达式作为 key 了,那么背面不管利用何种数据结构来存储正则表达式和操作方法的映射关系,我们都是要遍历整个路由表的,需要遍历表中的全部的正则表达式,然后拿着我们的路径来举行正则匹配,匹配上了就说明这是我们要找的方法,如果匹配不上就说明不是,不管怎么样,都是要举行遍历,那么实在我们直接用数组来存储也是一样的。
以是最终我们利用 vector 来存储用户方法。
  1.     using HandlerTable = std::vector<std::pair<std::regex,Handler>>;
复制代码
而HttpServer模块中除了五张路由表,还需要一个TcpServer对象,这是毋庸置疑的。 同时还需要生存一个网页根目录,这个根目录是要交给用户设置的,由利用者决定。
  1. //支持Http协议的服务器class HttpServer{private:    TcpServer _server;    std::string _base_path; //网页根目录    //五张路由表    using Handler = std::function<void(const HttpRequest&,HttpResponse*)>;     using HandlerTable = std::vector<std::pair<std::regex,Handler>>;    HandlerTable _get_route;     HandlerTable _post_route;     HandlerTable _head_route;     HandlerTable _put_route;     HandlerTable _delete_route;  public:};
复制代码
类的设计

那么需要哪些接口呢?
起首需要提供给用户的五个设置功能方法的接口,以及设置网页根目录和服务器线程数的接口。
还需要提供给用户是否开启超时开释,以及启动服务器的接口。
提供给用户的接口就这么多,实在都很简朴,难的是私有的一些接口:
起首,未来拿到一个完备哀求之后,我们需要可以或许判断这个哀求是静态资源哀求照旧功能性哀求。


  • 如果是资源性哀求我们需要怎么做?
  • 如果是功能性哀求我们有需要怎么做?
末了还需要将相应构造成一个tcp报文举行复兴。
同时还需要提供未来设置给TcpServer的连接创建和新数据到来的回调方法,这两个方法是必需的,因为在连接创建时我们必须要设置上下文,在新数据到来时必须要有逻辑来决定怎么处理。
  1. class HttpServer {
  2. public:
  3.     //构造函数
  4.     HttpServer();
  5.     // 配置设置类方法
  6.     void SetBaseDir();         // 设置静态资源路径的根目录
  7.     void SetThreadCount();      // 设置线程数量
  8.     void Listen();  //开始监听
  9.    
  10.     // 路由设置方法
  11.     void Get();                 // 设置GET方法
  12.     void Post();                // 设置Post方法
  13.     void Put();                 // 设置Put方法
  14.     void Delete();              // 设置Delete方法
  15.     void Head();                // 设置Head方法
  16.    
  17. private:
  18.     // 连接和消息处理回调
  19.     void OnConnected();         // 设置一个上下文
  20.     void OnMessage();           // 把请求报文从应用层缓冲区提取出来
  21.    
  22.     // 请求处理和路由
  23.     bool IsFileHandler();       // 判断是否是静态资源请求
  24.     void Route();               // 通过请求类型分别进行处理
  25.     void Dispatcher();          // 功能性请求处理
  26.     void FileHandler();         // 静态资源请求处理
  27.     void ErrorHandler();        // 错误信息填充
  28.     void WriteResponse(); //把响应对象转换成响应报文并发送
  29. };
复制代码
模块的实现

下面我将具体的讲解,这些模块都是干嘛的,让各位能有个更清楚的思路
公有接口

起首是设置静态资源路径根目录,这个是为了开发者设定的,因为用户在访问的时候,很大概率是不会加上根目录的,就好比你要在图片网站上找一个苹果的图片,对于网站来说他这个苹果肯定是分类在水果目录下面的,你在输入的时候大概率是直接apple.png,如果不举行设置默认根目录的话,那么肯定是找不到的,当你设置了根目录之后,你再输入apple.png的时候,它就会默认的变成fruit/apple.png了。
而开发者在举行设置的环境下,可能会出现粗心,好比说这个目录名字我少写了一个字母,以是这个函数需要先去探求是否存在这个目录,要是不存在那就是开发者写错了,我要反馈给开发者,要是没写错我就设置传入的dir为根目录
  1. void SetBasedir(const string &dir)
  2. {
  3.     assert(Util::IsDirectory(dir) == true);
  4.     _basedir = dir;
  5. }
复制代码
接着就是开发者设置下线程的数量了,直接就是传入要设置的count就行
  1. void SetThreadPoolCount(int count)
  2. {
  3.     _server.SetThreadCount(count);
  4. }
复制代码
然后就是开始监听的接口了
  1.     void Listen() // 开始监听
  2.     {
  3.         _server.Start();
  4.     }
复制代码
接下来就是路由表的利用了,对不同的方法的路由表都设置相应的正则表达式模式字符串和对应的回调函数,当用户输入了对应的哀求方法的时候就去对应的路由表里查找。至于如何定义,那就是开发者来自定义了
  1. void Get(const string &pattern, const Handler &handler)
  2. {
  3.     _get_route.push_back({regex(pattern), handler});
  4. }
  5. void Post(const string &pattern, const Handler &handler)
  6. {
  7.     _post_route.push_back({regex(pattern), handler});
  8. }
  9. void Put(const string &pattern, const Handler &handler)
  10. {
  11.     _put_route.push_back({regex(pattern), handler});
  12. }
  13. void Delete(const string &pattern, const Handler &handler)
  14. {
  15.     _delete_route.push_back({regex(pattern), handler});
  16. }
复制代码
末了就是构造函数的实现了,需要传入一个端口号来对我们内部的TcpServer对象举行初始化,然后包括三个内容,启动非活跃连接销毁,设置当连接来到的时候的回调函数用来设置上下文,以及把消息从缓冲区获取的回调函数
  1. HttpServer(int port, int timeout = DEFAULT_TIMEOUT)
  2.     : _server(port)
  3. {
  4.     // 启用非活跃连接释放
  5.     _server.EnableInactiveRelease(timeout);
  6.     // 设置连接回调函数
  7.     _server.SetConnectedCallback(
  8.         bind(&HttpServer::OnConnected, this, placeholders::_1)
  9.     );
  10.     // 设置消息回调函数
  11.     _server.SetMessageCallback(
  12.         bind(&HttpServer::OnMessage, this, placeholders::_1, placeholders::_2)
  13.     );
  14. }
复制代码
私有接口

这些私有接口是由开发者去调用的,来设置一些信息,让利用者去更好的利用
起首是设置给新连接设置一个上下文,用于当连接被切换的时候生存此中的数据,当下次再切换回来的时候,能接着当前的数据继续举行操作,以是需要的参数就是一个新连接的引用,因为连接是会被放在TCP的全连接队列中的
  1.     void OnConnected(const PtrConnection &conn) // 设置一个上下文
  2.     {
  3.         conn->SetContext(HtppContext());
  4.         DBG_LOG("new connection %p", conn.get());
  5.     }
复制代码
接着是对于该新连接数据的提取,因为这些数据从TCP的接收缓冲区是先提取到用户态中的缓冲区的,但是因为TCP是面向字节流的,也就是在用户态的缓冲区存储的数据都是以字节的形式,但是我Http要的是报头,正文,哀求行,这种类型的呀,以是肯定也是需要转换的也就是用到了context的模块,接着就是判断哀求的状态码,如果是状态码大于400了,就说明错误了,此时就需要举行处理了,可能你开发者想这个时候给用户弹出一个错误网页,以是接下来就调用ErrorHandler函数,然后把这个错误相应回去,再重置下HTTP上下文,准备处理新的哀求,然后把缓冲区更新下也就是清空缓冲区,接着就是关闭连接了。如果是正常的状态码就返回了
  1.     void OnMessage(const PtrConnection &conn, Buffer *buf)   // 处理从客户端接收到的HTTP请求消息
  2.     {
  3.         while(buf->ReadAbleSize() > 0)         // 只要缓冲区中还有可读数据就继续处理
  4.         {
  5.             HttpContext *context = conn->GetContext()->get<HttpContext>();  // 从连接上下文中获取HTTP上下文对象
  6.             context->RecvHttpRequest(buf);      // 从缓冲区中解析HTTP请求数据
  7.             HttpRequest &req = context->GetRequest();  // 获取解析后的HTTP请求对象
  8.             HttpResponse rsp;                   // 创建HTTP响应对象
  9.             if(context->StatusCode() >=400)     // 检查HTTP状态码,如果大于等于400表示出错
  10.             {
  11.                 ErrorHandler(req,&rsp);         // 调用错误处理函数生成错误响应
  12.                 WriteResponse(conn,req,rsp);    // 将错误响应写回客户端
  13.                 context->Reset();               // 重置HTTP上下文,准备处理新的请求
  14.                 buf->MoveReadIndex(buf->ReadAbleSize());  // 清空缓冲区中剩余的所有数据
  15.                 conn->ShutDown();               // 关闭连接
  16.             }
  17.             return;
  18.         }
  19.     }
复制代码
  可能会有同学问了,这里的context->Reset();   buf->MoveReadIndex(buf->ReadAbleSize()); 的是不是可以抛弃了,因为背面直接就是shutdown了
  可以是可以但是这样是不规范的,开释链接是开释链接,在开释链接之前,我们正常去处理错误逻辑,将各个环节该置空的置空,该清算的清算,这是我们必须在代码中举行表现的,这才是一个好的代码习惯。而且来说,conn->shutdow还不是实际发送的逻辑,在后续还会举行一些判断的,以是上层该做的工作照旧要做的

这个操作之后,我们也就剖析了二进制的数据了,然后判断哀求行的方法是什么类型的,是不是静态资源的哀求。需要四步:起首需要判断有没有设置资源根目录。静态资源哀求的方法必须是 GET 大概 HEAD ,因为其他的方法不是用来获取资源的。然后静态资源哀求的路径必须是一个合法的路径。末了就需要判断哀求的路径的资源是否存在。但是我们需要思量路径是目录的时候,给它加上一个 index.html。末了就是判断文件是否存在
  1. bool IsFileHandler(const HttpRequest &req)
  2. {
  3.    // 1.查看设置了静态资源根目录
  4.    if(_basedir.empty())
  5.        return false;
  6.    // 2.请求方法必须是GET或HEAD
  7.    if(req._method != "GET" && req._method != "HEAD")
  8.        return false;
  9.    // 3.请求资源路径必须合法
  10.    if(Util::ValidPath(req._path) == false)
  11.        return false;
  12.    // 4.请求资源必须存在,且是一个普通文件
  13.    // 为防止修改路径,先复制一份
  14.    string req_path = _basedir + req._path;
  15.    // 先处理特殊情况:如果请求的是目录,就换成请求首页
  16.    if(req._path.back() == '/')
  17.        req_path += "index.html";
  18.    // 如果不是普通文件就错误
  19.    if(Util::IsRegular(req_path) == false)
  20.        return false;
  21.    return true;
  22. }
复制代码
接下来我们举二个例子来更好的帮我们捋一捋思路 
例子1: 正常的图片哀求


  • 用户访问 http://example.com/images/logo.png
  • 欣赏器发送 GET 哀求,路径为 /images/logo.png
  • 函数检查:

    • 静态根目录已设置为 /var/www/html/
    • 哀求方法是 GET ✓
    • 路径 /images/logo.png 是合法的 ✓
    • 文件 /var/www/html/images/logo.png 存在且是普通文件 ✓

  • 函数返回 true,服务器会提供这个图片文件
例子2: 哀求目录


  • 用户访问 http://example.com/blog/
  • 欣赏器发送 GET 哀求,路径为 /blog/
  • 函数检查:

    • 静态根目录已设置 ✓
    • 哀求方法是 GET ✓
    • 路径 /blog/ 是合法的 ✓
    • 检测到路径以 / 结尾,主动添加 index.html
    • 检查 /var/www/html/blog/index.html 是否存在
    • 如果存在,返回 true;如果不存在,返回 false

接下来就是开发者对于路由表的规则的设置了,起首就是判断是什么类型的哀求,如果是静态资源哀求就调用静态资源的处理方法,如果是功能性的哀求就匹配路由表中的方法,而且对不同方法设置不同的哀求,如果两种哀求都不是就说明是错的了
  1. // 通过请求类型分别处理
  2. void Route(HttpRequest &req, HttpResponse *rsp)
  3. {
  4.    // 是静态资源请求就静态资源处理
  5.    if(IsFileHandler(req) == true)
  6.        return FileHandler(req, rsp);
  7.    // 动态性请求就动能性请求处理
  8.    if(req._method == "GET" || req._method == "HEAD")
  9.        return Dispatcher(req, rsp, _get_route);
  10.    else if(req._method == "POST")
  11.        return Dispatcher(req, rsp, _post_route);
  12.    else if(req._method == "PUT")
  13.        return Dispatcher(req, rsp, _put_route);
  14.    else if(req._method == "DELETE")
  15.        return Dispatcher(req, rsp, _delete_route);
  16.    // 两个都不是就返回405 METHOD NOT ALLOWED
  17.    rsp->_status = 405;
  18.    return;
  19. }
复制代码
接下来就是对静态资源哀求的处理,起首构造一个完备的文件路径,然后如果结尾是/就说明是访问的首页,然后把路径读相应到正文中,如果文件读取失败就说明错误了。然后用ExtMime把路径中的末了一部门给分离出来,然后填入到相应报头的Content-type中
  1. // 静态资源请求处理
  2. void FileHandler(const HttpRequest &req, HttpResponse *rsp)  // 处理静态文件请求的函数
  3. {
  4.    // 读取静态文件资源,放到rsp的body,并设置mime
  5.    // 判断里面没有修改资源路径,所以在这里要修改
  6.    string req_path = _basedir + req._path;  // 构造完整的文件路径
  7.    if(req._path.back() == '/')  // 如果请求路径以'/'结尾(请求的是目录)
  8.        req_path += "index.html";  // 自动添加index.html作为默认页面
  9.    bool ret = Util::ReadFile(req_path, &rsp->_body);  // 读取文件内容到响应体
  10.    if(ret == false)  // 如果文件读取失败
  11.        return;  // 直接返回,不设置任何响应内容
  12.    string mime = Util::ExtMime(req_path);  // 根据文件扩展名获取MIME类型
  13.    rsp->SetHeader("Content-type", mime);  // 设置Content-type响应头
  14.    return;  // 处理完成,返回
  15. }
复制代码
接下来几个例子,资助大家明白
假设用户通过欣赏器访问你的网站,哀求了以下几个不同的资源:


  • HTML页面哀求:

    • 用户访问 http://yourwebsite.com/about.html
    • 服务器找到 about.html 文件
    • ExtMime 函数确定这是HTML文件,返回 text/html
    • 服务器在相应头中设置 Content-type: text/html
    • 欣赏器收到相应后,看到这个MIME类型,知道应该将内容剖析为HTML并渲染网页

  • 图片哀求:

    • 当HTML页面中引用了图片 <img src="logo.png">
    • 欣赏器哀求 http://yourwebsite.com/logo.png
    • 服务器找到 logo.png 文件
    • ExtMime 函数确定这是PNG图片,返回 image/png
    • 服务器在相应头中设置 Content-type: image/png
    • 欣赏器看到这个MIME类型,知道应该将内容剖析为PNG图片并显示

接着就是对动态哀求的处理。先循环遍历路由表表内里正则表达式是否匹配哀求对象的资源路径,以找到对应的处理函数添补相应对象,如果找不到就是404
  1. // 功能性请求处理
  2. void Dispatcher(HttpRequest &req, HttpResponse *rsp, Handlers &handlers)  // 处理动态请求的分发器函数
  3. {
  4.    // 循环遍历路由表表里面正则表达式是否匹配请求对象的资源路径,以找到对应的处理函数填充响应对象
  5.    for(auto &handler :handlers)  // 遍历所有注册的处理器
  6.    {
  7.        const regex &e = handler.first;  // 获取当前处理器的正则表达式
  8.        const Handler &func = handler.second;  // 获取当前处理器的处理函数
  9.        bool ret = regex_match(req._path, req._matches, e);  // 尝试匹配请求路径与正则表达式
  10.        if(ret == false)  // 如果不匹配
  11.            continue;  // 继续检查下一个处理器
  12.        return func(req, rsp);  // 找到匹配的处理器,调用对应的处理函数并返回
  13.    }
  14.    // 找不到就是404
  15.    rsp->_status = 404;  // 设置HTTP状态码为404(未找到)
  16. }
复制代码
接着就是把相应对象转换成相应报文并发送。思路就是先把报头完善一下,也就是举行判断是短链接就把字段的Connection设置为close,否则就设置成keep-alive,然后判断Content-Length有没有填,ContentType应该填什么。要是重定向的话,就更新Location字段为重定向的url,这些都是必要的条件。当完成了这些之后,就可以构建相应报文了完善相应行,相应报头,添加空行,添加相应正文,末了就是发送相应报文
  1. // 把响应对象转化成响应报文并发送
  2. void WriteResponse(const PtrConnection &conn, const HttpRequest &req, HttpResponse &rsp)  // 将HTTP响应对象序列化为HTTP报文并发送
  3. {
  4.    // 1.完善报头
  5.    if(req.Close() == true)  // 如果请求要求关闭连接
  6.        rsp.SetHeader("Connection", "close");  // 设置Connection头为close
  7.    else
  8.        rsp.SetHeader("Connection", "keep-alive");  // 否则设置为keep-alive保持连接
  9.    if(rsp._body.empty() == false && rsp.HasHeader("Content-Length") == false)  // 如果响应体不为空且没有设置Content-Length头
  10.        rsp.SetHeader("Content-Length", to_string(rsp._body.size()));  // 设置Content-Length头为响应体大小
  11.    if(rsp._body.empty() == false && rsp.HasHeader("Content-Type") == false)  // 如果响应体不为空且没有设置Content-Type头
  12.        rsp.SetHeader("Content-Type", "application/octet-stream");  // 设置默认的Content-Type为二进制流
  13.    if(rsp._redirect_flag = true)  // 如果需要重定向
  14.        rsp.SetHeader("Location", rsp._redirect_url);  // 设置Location头指向重定向URL
  15.    // 2.组织响应报文
  16.    stringstream rsp_str;  // 创建字符串流用于构建HTTP响应
  17.    // 响应行
  18.    rsp_str << req._version << " " << to_string(rsp._status) << " " << Util::StatusDesc(rsp._status) << "\r\n";  // 构建状态行
  19.    // 响应报头
  20.    for(auto &head : rsp._headers)  // 遍历所有响应头
  21.    {
  22.        rsp_str << head.first << ": " << head.second << "\r\n";  // 添加每个响应头到响应中
  23.    }
  24.    // 一个空行
  25.    rsp_str << "\r\n";  // 添加空行分隔响应头和响应体
  26.    // 响应正文
  27.    rsp_str << rsp._body;  // 添加响应体
  28.    // 3.发送响应报文
  29.    conn->Send(rsp_str.str().c_str(), rsp_str.str().size());  // 发送完整的HTTP响应
  30. }
复制代码
末了就是设置错误信息,如果这个路径是错误的,应该给用户返回什么
  1. void ErrorHandler(const HttpRequest &req, HttpResponse *rsp)  // 处理HTTP错误的函数
  2. {
  3.    string body;  // 创建一个字符串用于构建HTML错误页面
  4.    body += "<html>";  // HTML文档开始标签
  5.    body += "<head>";  // 头部开始标签
  6.    body += "<meta http-equiv='Content-Type' content='text/html;charset=utf-8'>";  // 设置页面元数据,指定内容类型和字符集
  7.    body += "</head>";  // 头部结束标签
  8.    body += "<body>";  // 正文开始标签
  9.    body += "<h1>";  // 一级标题开始标签
  10.    body += std::to_string(rsp->_status);  // 添加HTTP状态码
  11.    body += " ";  // 添加空格
  12.    body += Util::StatusDesc(rsp->_status);  // 添加HTTP状态码的描述文本
  13.    body += "</h1>";  // 一级标题结束标签
  14.    body += "</body>";  // 正文结束标签
  15.    body += "</html>";  // HTML文档结束标签
  16.    // 响应正文类型是html
  17.    rsp->SetContent(body, "text/html");  // 设置响应内容为HTML并指定MIME类型
  18. }
复制代码
疑问点

   std::regex确实不能直接用作std::unordered_map的键
  

   http不是管理的是协议的哀求和相应吗 这个 void OnConnect(const PtrConnection& conn) 连接不是应该由tcp去处理吗?
  

   “哀求可能分多个TCP包到达,需要有地方存储部门剖析的数据,这个地方就是上下文” 那这个数据不是存储在用户的缓冲区当中的吗?
  

   HttpContext *context = conn->GetContext()->get<HttpContext>(); 这个代码不懂
  


免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

杀鸡焉用牛刀

论坛元老
这个人很懒什么都没写!
快速回复 返回顶部 返回列表