HttpServer模块
功能
搭建HTTP服务器,其实就是把前面实现的server模块、httprequest模块、httpresponse模块、httpcontext模块整合起来,并且引入业务处理路由表。
设计思想
服务器收到请求将请求解析后,需要对请求URL进行业务处理,而业务处理函数应该是用户提供的,服务器并不知道用户想要怎么处理资源路径;而请求是各种各样的,请求方法不一样,资源路径也不一样,为了更好地管理用户提供的业务处理函数,这里设计了路由表
以POST路由表为例:表中记录了针对哪个资源路径应该调用哪个业务处理的映射关系。当服务器收到请求,根据请求方法找到对应的路由表,在路由表查找有没有对应资源路径的服务器,有则执行函数
也就是说,什么请求怎么处理,由用户决定,用户来实现业务处理函数,然后将请求(请求方法和资源路径)与处理函数的映射关系添加到服务器的路由表;而服务器收到数据、解析完毕后,只需要在路由表查找是否有对应请求的映射关系,执行业务处理函数即可
要素
- GET请求的路由映射表:记录请求资源路径和对应处理函数的映射关系,一般是功能性请求
- POST请求的路由映射表
- PUT请求的路由映射表
- DELETE请求的路由映射表
- 静态资源相对根目录:请求资源可能是静态资源
- TCP服务器:进行连接的IO操作
功能接口
供用户使用的公有接口
- 添加请求-业务处理映射信息
- 设置静态资源根目录
- 设置线程池中线程数量
- 启动服务器
私有接口:服务器自己设计实现

上图为路由查找模拟图
服务器对新数据到来的处理流程:OnMessage
1、从socket接收数据后,放到用户接收缓冲区
2、调用OnMessage回调函数对接收缓冲区进行处理
(1)首先要对请求进行解析(需要协议上下文),得到一个HttpRequest结构(具有所有的请求要素)
(2)对请求进行路由查找,执行对应处理方法:
a.如果是静态资源请求,比如html、image,将静态资源文件读取出来,填充到HttpResponse响应中
b.不是静态,就是功能性请求,就要根据不同的请求方法,在对应的路由表查找路径与处理函数的映射关系,找到了就执行函数
3、对静态资源或者功能性请求进行处理完毕后,得到了一个填充好的HttpResponse对象,根据http响应的格式组织响应,进行发送。
由上面的处理流程可以引申出其他私有接口:
- OnConnected:给TcpServer设置协议上下文
- OnMessage:处理接收缓冲区数据
- 请求的路由查找
静态资源请求查找和处理
功能性请求查找和处理
- 组织响应进行回复
代码实现
#define DEFALT_TIMEOUT 10
class HttpServer
{
private:
using Handler = std::function<void(const HttpRequest &, HttpResponse *)>; // 业务处理函数
using RouteTable = std::vector<std::pair<std::regex, Handler>>; // 请求路由表
RouteTable _get_route;
RouteTable _put_route;
RouteTable _post_route;
RouteTable _delete_route;
std::string _static_dir; // 静态资源相对根目录
TcpServer _server;
private:
// 请求数据解析并处理
void OnMessage(const PtrConnection &ptr, Buffer *buf)
{
while (buf->ReadAbleSize() > 0)
{
// 1、获取上下文,解析数据,得到httprequest对象
HttpContext *context = ptr->GetContext()->get<HttpContext>();
context->RecvHttpRequest(buf);
HttpRequest &req = context->Request();
HttpResponse rsp(context->ResponStatus());
// 2、解析出错,直接回复错误响应,关闭连接
if (context->RecvStatus() == HTTP_RECV__ERROR)
{
ErrorResponse(req, &rsp); // 填充一个错误显示界面到httpresponse响应
WriteResponse(ptr, req, rsp); // 组织响应,发回给客户端
context->ReSet(); // 防止下一条请求的上下文信息错误
buf->MoveReadOffset(buf->ReadAbleSize()); // 出现错误就把缓冲区数据清空
ptr->Shutdown();
return;
}
// 2.5 不足一条完整请求
if (context->RecvStatus() != HTTP_RECV_OVER)
return;
// 3、解析成功,获得完整的httprequest,进行路由处理
Route(req, &rsp);
// 4、组织httpresponse响应
WriteResponse(ptr, req, rsp);
// 5、重置上下文
context->ReSet();
// 6、根据响应的长短连接判断是否关闭连接还是继续处理
if (rsp.Close() == true)
ptr->Shutdown();
}
return;
}
// 设置协议上下文
void OnConnected(const PtrConnection &ptr)
{
ptr->SetContext(Any(HttpContext()));
DBG_LOG("New Connection: %p", ptr.get());
}
// 路由查找
void Route(HttpRequest &req, HttpResponse *rsp)
{
// 1、判断路径合法
if (Util::VailPath(req._path) == false)
{
rsp->_status = 400; // Bad Request
return;
}
// 2、是否是静态资源并进行处理
if (IsFileHandler(req) == true)
{
FileHandler(req, rsp);
}
// 3、是否是功能性请求,进行路由查找
if (req._method == "GET" || req._method == "HEAD")
{
return Dispatcher(req, rsp, _get_route);
}
else if (req._method == "PUT")
{
return Dispatcher(req, rsp, _put_route);
}
else if (req._method == "POST")
{
return Dispatcher(req, rsp, _post_route);
}
else if (req._method == "DELETE")
{
return Dispatcher(req, rsp, _delete_route);
}
// 4、都不满足,说明请求方法不在支持的列表中
rsp->_status = 405; // Method Not Allowed
return;
}
// 静态资源文件的判断
bool IsFileHandler(const HttpRequest &req)
{
// 1. 必须有静态资源根目录
if (_static_dir.size() == 0)
return false;
// 2. 请求方法必须是GET/HEAD
if (req._method != "GET" && req._method != "HEAD")
return false;
// 3. 请求资源必须存在,并且是一个普通文件
// 如果请求路径是 / , /video/ ,在路径后面加"index.html"
std::string path_tmp = _static_dir + req._path; // 不修改请求路径,记得加上静态资源根目录
if (req._path.back() == '/')
{
path_tmp += "index.html"; // /video/.mp3 /index.html
}
if (Util::IsRegular(path_tmp) == false)
return false;
return true;
}
// 静态资源请求的处理
void FileHandler(const HttpRequest &req, HttpResponse *rsp)
{
std::string path_tmp = _static_dir + req._path; // 不修改请求路径
if (req._path.back() == '/')
{
path_tmp += "index.html"; // /video/.mp3 /index.html
}
// 读取文件内容,填充响应:body,head
if (Util::ReadFile(path_tmp, &(rsp->_body)) == false)
{
rsp->_status = 500; // Internal Server Error
return;
}
std::string mime = Util::ExtMime(path_tmp);
rsp->SetHeader("Content-Type", mime);
return;
}
// 功能性请求的处理
void Dispatcher(HttpRequest &req, HttpResponse *rsp, const RouteTable &table)
{
// 根据请求的路由表table,遍历路由表,将请求路径与表元素的匹配规则(正则表达式)进行匹配,匹配成功就可以执行对应的业务函数
// e:/numbers/(\d+) req._path:/numbers/123
for (auto &relation : table)
{
const std::regex &re = relation.first;
const Handler &func = relation.second;
bool ret = std::regex_match(req._path, req._matches, re);
if (ret == true)
return func(req, rsp); // 传入新的httpresponse
}
rsp->_status = 404; // 请求方法是有效的,但是请求的资源路径在请求路由表匹配不成功,说明请求的资源路径是无效的
}
// 错误界面设置:填充httpresponse
void ErrorResponse(const HttpRequest &req, HttpResponse *rsp)
{
// 1、编写一个简单的错误界面
std::string body;
body += "<html>";
body += "<head>";
body += "<meta http-equiv='Content-Type' content='text/html;charset=utf-8'>";
body += "</head>";
body += "<body>";
body += "<h1>";
body += std::to_string(rsp->_status);
body += " ";
body += Util::StatuDesc(rsp->_status);
body += "</h1>";
body += "<body>";
body += "</html>";
// 2、页面数据当做响应正文,放入rsp
rsp->SetContent(body, "text/html");
}
// 按照http格式组织响应
void WriteResponse(const PtrConnection &ptr, const HttpRequest &req, HttpResponse &rsp)
{
// 0、先完善头部字段:长短连接、正文长度、正文类型、是否重定向
if (req.Close() == true)
{
rsp.SetHeader("Connection", "close");
}
else
{
rsp.SetHeader("Connection", "keep-alive");
}
if (rsp._body.size() > 0 && rsp.HasHeader("Content-Length") == false)
{
rsp.SetHeader("Content-Length", std::to_string(rsp._body.size()));
}
if (rsp._body.size() > 0 && rsp.HasHeader("Content-Type") == false)
{
rsp.SetHeader("Content-Type", "application/octet-stream");
}
if (rsp._is_redirect == true)
{
rsp.SetHeader("Location", rsp._redirect_url);
}
// 1、状态行:协议版本 状态码 状态码描述\r\n
std::string response = req._version + " " + std::to_string(rsp._status) + " " + Util::StatuDesc(rsp._status) + "\r\n";
// 2、响应头部:key: value\r\nkey: value\r\n\r\n
for (auto &e : rsp._headers)
{
std::string key = e.first;
std::string val = e.second;
response += key + ": " + val + "\r\n";
}
response += "\r\n";
// 3、正文
response += rsp._body;
ptr->Send(response.c_str(), response.size());
}
public:
HttpServer(int port, int timeout = DEFALT_TIMEOUT) : _server(port)
{
_server.SetConnectedCallback(std::bind(&HttpServer::OnConnected, this, std::placeholders::_1));
_server.SetMessageCallback(std::bind(&HttpServer::OnMessage, this, std::placeholders::_1, std::placeholders::_2));
_server.EnableInactiveRelease(timeout);
}
// 添加请求路径-业务处理映射
void SetGet(const std::string &path, const Handler &cb)
{
std::regex e(path);
_get_route.push_back(std::make_pair(e, cb));
}
void SetPut(const std::string &path, const Handler &cb)
{
std::regex e(path);
_put_route.push_back(std::make_pair(e, cb));
}
void SetPost(const std::string &path, const Handler &cb)
{
std::regex e(path);
_post_route.push_back(std::make_pair(e, cb));
}
void SetDelete(const std::string &path, const Handler &cb)
{
std::regex e(path);
_delete_route.push_back(std::make_pair(e, cb));
}
// 设置静态资源根目录
void SetDir(const std::string &path)
{
assert(Util::IsDirectory(path));
_static_dir = path;
}
// 设置线程池线程数量
void SetThreadCount(int count)
{
_server.SetThreadCount(count);
}
// 启动服务器
void Start()
{
_server.Start();
}
};
代码分析
// 设置协议上下文
void OnConnected(const PtrConnection &ptr)
{
ptr->SetContext(Any(HttpContext()));
DBG_LOG("New Connection: %p", ptr.get());
}
在收到数据前就设置上下文的必要性:如果在OnMessage里面创建httptext对象,里面是一个循环,看似一直在运行,但是当接收的数据不足以构成一个完整请求,退出OnMessage函数并且该连接是长连接,那么下次接收到数据再次进入OnMessage函数,上一次的上下文信息(即处理进度)就找不到了,所以要在OnMessage外设置上下文对象,并且让connection管理起来,只有连接关闭这个上下文对象才随之释放
void OnMessage(const PtrConnection &ptr, Buffer *buf)
{
while (buf->ReadAbleSize() > 0)
{
//......
HttpRequest &req = context->Request();
HttpResponse rsp(context->ResponStatus());
// 2、解析出错,直接回复错误响应,关闭连接
if (context->RecvStatus() == HTTP_RECV__ERROR)
{
//......
}
这里要对响应的状态码初始化,因为前面context解析过程中如果出错,那么响应状态码等于大于400,这里的响应就不能默认为200
using RouteTable = std::vector<std::pair<std::regex, Handler>>; // 请求路由表
在插入正则表达式和业务处理函数时,将正则表达式先编译成std::regex对象,在后面的Dispatcher进行路由查找时,不需要每次匹配前都把key重新编译-->std::regex re(it->first),而是直接在路由表查找std::regex对象,这样可以节省编译std::regex过程消耗的时间,直接进行匹配
测试
静态资源和功能性请求测试
代码编译通过后,先简单写几个业务处理函数,针对静态资源获取已经四种请求方法进行测试,下面的测试代码对于功能性请求的业务处理都是将请求以一定的格式组织返回
#include "http.hpp"
std::string EchoRequest(const HttpRequest& req)
{
std::stringstream ss;
ss << req._method << " " << req._path << " " << req._version << "\r\n";
for(auto &e : req._query)
{
ss << e.first << "=" << e.second << "&";
}
ss << "\r\n";
for(auto &e : req._headers)
{
ss << e.first << ": " << e.second << "\r\n";
}
ss << "\r\n";
ss << req._body;
return ss.str();
}
void Hello(const HttpRequest& req, HttpResponse *rsp)
{
const std::string &s = EchoRequest(req);
rsp->SetContent(s, "text/plain");
}
void Login(const HttpRequest& req, HttpResponse *rsp)
{
const std::string &s = EchoRequest(req);
rsp->SetContent(s, "text/plain");
}
void PutFile(const HttpRequest& req, HttpResponse *rsp)
{
const std::string &s = EchoRequest(req);
rsp->SetContent(s, "text/plain");
}
void DelteFile(const HttpRequest& req, HttpResponse *rsp)
{
const std::string &s = EchoRequest(req);
rsp->SetContent(s, "text/plain");
}
int main()
{
HttpServer server(8080);
server.SetThreadCount(3);
server.SetDir("./wwwroot/");
server.SetGet("/hello", Hello);
server.SetPost("/login", Login);
server.SetPut("/hjy.png", PutFile);
server.SetDelete("/hjy.png", DelteFile);
server.Start();
return 0;
}
静态资源根目录下也创建了一个index.html页面返回,里面对服务器进行了POST请求
<html>
<head>
<meta charset="utf8">
</head>
<body>
<form action="/login" method="post">
<input type="text" name="username"><br/>
<input type="password" name="password"><br/>
<input type="submit" value="提交" name="submit">
</form>
</body>
</html>
需要注意的是,在使用浏览器进行测试记得开放云服务器端口号,而且单纯通过浏览器的输入框是测试不了PUT和DELETE的,这样可以用 Linux 终端的 curl 命令发送 PUT 和 DELETE 请求,直接验证 “请求是否能到服务器”。
长连接测试
// 长连接测试:客户端在一段时间内持续发送信息,连接不关闭
#include "../source/server.hpp"
int main()
{
Socket client;
client.CreateClient(8080, "127.0.0.1");
std::string request = "GET /hello?username=hjy&password=1234 HTTP/1.1\r\nConnection: keep-alive\r\nContent-Length: 0\r\n\r\n";
while (1)
{
client.Send(request.c_str(), request.size());
char buffer[1024] = {0};
client.Recv(buffer, 1023);
DBG_LOG("%s", buffer);
sleep(3);
}
client.Close();
return 0;
}
超时释放连接测试
//超时连接测试:客户端发送一次信息,后面不发送信息,等待10s,超时,服务器主动关闭连接
#include "../source/server.hpp"
int main()
{
Socket client;
client.CreateClient(8080, "127.0.0.1");
std::string request = "GET /hello?username=hjy&password=1234 HTTP/1.1\r\nConnection: keep-alive\r\nContent-Length: 0\r\n\r\n";
while (1)
{
client.Send(request.c_str(), request.size());
char buffer[1024] = {0};
client.Recv(buffer, 1023);
DBG_LOG("%s", buffer);
sleep(20);
}
client.Close();
return 0;
}
头部正文大小和实际发送正文大小不符
//给服务器发送一条请求,告知发送数据大小为150,但是实际请求正文只发15个字节,RecvHttpRequest不能退出,10s后超时连接释放
//连续给服务器发送几条小的请求,但是实际发送的正文大小小于150,会导致后面接收的请求被当做前一条请求的正文部分,导致解析出错
#include "../source/server.hpp"
int main()
{
Socket client;
client.CreateClient(8080, "127.0.0.1");
std::string request = "GET /hello HTTP/1.1\r\nConnection: keep-alive\r\nContent-Length: 150\r\n\r\n我是客户端";
while (1)
{
client.Send(request.c_str(), request.size());
client.Send(request.c_str(), request.size());
client.Send(request.c_str(), request.size());
client.Send(request.c_str(), request.size());
char buffer[1024] = {0};
client.Recv(buffer, 1023);
DBG_LOG("%s", buffer);
sleep(20);
}
client.Close();
return 0;
}
代码运行分析:很明显服务器在处理第二条请求会把它当成第一条请求的正文,然后正文读取处理完发送响应。
又接着去处理缓冲区剩余数据肯定会解析错误,服务器就得组织错误界面发回给客户端,然后就清空缓冲区,关闭连接。
而这个错误响应的响应头是connection:close,是因为我请求解析错误,然后不能正确提取头部,所以在组织响应时,就默认是close。
容易出错的点:OnMessage函数
void OnMessage(const PtrConnection &ptr, Buffer *buf)
{
while (buf->ReadAbleSize() > 0)
{
// 1、获取上下文,解析数据,得到httprequest对象
HttpContext *context = ptr->GetContext()->get<HttpContext>();
context->RecvHttpRequest(buf);
HttpRequest &req = context->Request();
HttpResponse rsp(context->ResponStatus());
// 2、解析出错,直接回复错误响应,关闭连接
if (context->RecvStatus() == HTTP_RECV__ERROR)
{
ErrorResponse(req, &rsp); // 填充一个错误显示界面到httpresponse响应
WriteResponse(ptr, req, rsp); // 组织响应,发回给客户端
context->ReSet(); // 防止下一条请求的上下文信息错误
buf->MoveReadOffset(buf->ReadAbleSize()); // 出现错误就把缓冲区数据清空
ptr->Shutdown();
return;
}
// 2.5 不足一条完整请求
if (context->RecvStatus() != HTTP_RECV_OVER)
return;
// 3、解析成功,获得完整的httprequest,进行路由处理
Route(req, &rsp);
// 4、组织httpresponse响应
WriteResponse(ptr, req, rsp);
// 5、重置上下文
context->ReSet();
// 6、根据响应的长短连接判断是否关闭连接还是继续处理
if (rsp.Close() == true)
ptr->Shutdown();
}
return;
}
如果没有context->ReSet(); 当解析出错,服务器提供错误界面,组织响应然后发送,下一步要关闭连接,但是在ShutDown函数里会先判断接收缓冲区还有没有数据,假设有数据那么还会继续执行OnMessage函数,而这个时候得到的context是上一次解析出错的context(一个connection对应一个context),也就是说接收状态还是HTTP_RECV__ERROR,那么RecvHttpRequest(buf)根本就不会执行,接收缓冲区的数据就根本读不出来,一直是接收错误状态,一直要服务器提供错误界面,组织响应然后发送,下一步要关闭连接.......程序进入死循环就会崩溃。
所以要在每次处理完错误后,关闭连接前,先把context重置一下,那么下次再进入OnMessage就可以执行RecvHttpRequest,将数据读取出来(至少请求行读取到"\r\n"就可以移动接收缓冲区的读偏移);或者换成buf->MoveReadOffset(buf->ReadAbleSize());既然都出现解析错误了,就把接收缓冲区清空,不让服务器再继续处理数据。
业务处理超时,查看服务器的处理情况
// 业务处理超时,查看服务器的处理情况
// 当描述符1234都就绪,而1的处理花费30s后,如果234都是通信连接,那么服务器会在EventLoop::Start()里依次调用它们的HandleEvent,去刷新活跃度;
// 但是如果定时器描述符是2,并且先进行处理,就可能会导致其他就绪连接因为超时还没来得及刷新活跃度就被提前释放
// 统一所有 ReleaseInLoop 调用都通过 QueueInLoop,将ReleaseInLoop压入队列,最后在Start的RunAllTask执行
#include "../source/server.hpp"
int main()
{
for (int i = 0; i < 10; i++)
{
pid_t pid = fork();
if (pid < 0)
{
DBG_LOG("Fork Failed");
return -1;
}
else if (pid == 0)
{
Socket client;
client.CreateClient(8080, "127.0.0.1");
std::string request = "GET /hello?username=hjy&password=1234 HTTP/1.1\r\nConnection: keep-alive\r\nContent-Length: 0\r\n\r\n";
while (1)
{
client.Send(request.c_str(), request.size());
char buffer[1024] = {0};
client.Recv(buffer, 1023);
DBG_LOG("%s", buffer);
}
client.Close();
}
}
while (1)
{
sleep(1);
}
return 0;
}
源代码可能出现的问题:(下面执行的线程都是从属线程)当描述符1、2、3、4都就绪,而1的处理花费30s后,如果234都是通信连接,那么服务器会在EventLoop::Start()里依次调用它们的HandleEvent,去刷新活跃度;
!!!但是如果定时器描述符是2,并且先进行处理,就可能会导致其他就绪连接因为还没来得及刷新活跃度就被提前释放,处理出错流程:
执行定时器描述符channel的HandleEvent-->TimerWheel::Ontime()的RunTimerTask(),那么就会去执行对应_tick的析构函数,即_task_cb就是定时任务:Connection::ReleaseInLoop(),此时就会把Connection释放,如果这个Connection刚好是3或者4对应的,那么后面再进行3或者4的HandleEvent,就会发现找不到对应的Channel(PtrConnection释放,成员Channel也释放),就会出现内存访问错误。
解决方法:将ReleaseInLoop延后执行:
(1)统一所有 ReleaseInLoop 调用都通过 QueueInLoop,将ReleaseInLoop压入队列,最后在Start的RunAllTask执行。
void Release()
{
_loop->QueueInLoop(std::bind(&Connection::ReleaseInLoop, shared_from_this()));
}
需要用ReleaseInLoop的地方都用Release,包括非活跃连接释放任务的添加,HandleError和HandleClose, ShutDownInLoop。
(2)确保在处理就绪事件时,先处理所有非定时器事件,再处理定时器事件
void EventLoop::Start()
{
while (1)
{
std::vector<Channel *> actives;
_poller.Poll(&actives);
// 分离定时器事件和其他事件
std::vector<Channel*> timer_events;
std::vector<Channel*> other_events;
for (auto &channel : actives)
{
if (channel->Fd() == _timerwheel.Fd())
timer_events.push_back(channel);
else
other_events.push_back(channel);
}
// 先处理所有非定时器事件
for (auto &channel : other_events)
{
channel->HandleEvent();
}
// 再处理定时器事件
for (auto &channel : timer_events)
{
channel->HandleEvent();
}
// 最后执行任务队列
RunAllTask();
}
}
定时器超时任务执行问题
定时器不关注服务器处理任务处理多长时间,反正定时器一定是一秒执行一次定时任务,要是因为其他事件处理时间太长,导致定时器来不及ReadTimeFd,那就要根据超时次数,超时多少就执行多少次定时任务,不能越过前面的超时任务,只执行一次
下面对之前的代码稍作修改
uint64_t ReadTimeFd()
{
uint64_t times;
// read读取到的数据times就是从上一次read之后的超时次数
// 有可能因为其他描述符业务处理时间长,导致后面处理定时器描述符事件的时候,已经超时很多次了,需要执行很多定时任务
// 返回的times相当于要执行多少次超时任务
int n = read(_timerfd, ×, 8);
if (n < 0)
{
ERR_LOG("read errpr");
abort();
}
return times;
}
////这个函数应该每秒钟被执行一次,相当于秒针向后走了一步
void RunTimerTask()
{
_tick = (_tick + 1) % _capacity;
_wheel[_tick].clear(); // 清空指定位置的数组,就会把数组中保存的所有管理 定时器任务对象 的shared_ptr释放掉
}
// 读取定时器内容,执行任务
void OnTime()
{
//实际超时多少次,就执行多少次RunTimerTask
uint64_t times = ReadTimeFd();
for (int i = 0; i < times; i++)
{
RunTimerTask();
}
}
一次性给服务器发送多条正确的请求
//一次性给服务器发送多条请求
#include "../source/server.hpp"
int main()
{
Socket client;
client.CreateClient(8080, "127.0.0.1");
std::string request = "GET /hello HTTP/1.1\r\nConnection: keep-alive\r\nContent-Length: 0\r\n\r\n";
request += "GET /hello HTTP/1.1\r\nConnection: keep-alive\r\nContent-Length: 0\r\n\r\n";
request += "GET /hello HTTP/1.1\r\nConnection: keep-alive\r\nContent-Length: 0\r\n\r\n";
while (1)
{
client.Send(request.c_str(), request.size());
char buffer[1024] = {0};
client.Recv(buffer, 1023);
DBG_LOG("%s", buffer);
sleep(3);
}
client.Close();
return 0;
}
&spm=1001.2101.3001.5002&articleId=157771395&d=1&t=3&u=34423607e76b4f13878769924e4b6ab7)
1116

被折叠的 条评论
为什么被折叠?



