6-发起网络监听
新入门skynet系列视频b站网址 https://www.bilibili.com/video/BV19d4y1678X
lua应用层是怎么发起监听的
在具体讨论前,我们简单的讨论一下skynet的网络部分。
skynet网络线程大体上是处理两部分内容。
- 处理系统的网络事件,比如发现新连接,最终会push消息到监听服务。又或者是发现某个连接上又网络数据达到。
- 处理上层的请求命令。这个请求主要是我们lua层发起的。lua层把请求写入管道的一端,然后网络线程从管道的另一头把这些请求读出来,然后进行处理。比如我们主动发起请求监听的命令。最终也可能会push消息到某个服务
ok,开始讨论监听。
监听的主要目的是发现外部网络发起的请求连接。比如一个玩家发起连接请求。我们底层大致是这样发现的:
- 在底层创建一个监听socket,进行监听的各种相关设置。然后加入epoll对象中。
- 最终我们网络线程,会不断的查询这个监听socket上面是否有事件发生,如果有,就表示有新的连接进来了。
下面就是讨论lua层和底层怎么协调把这个监听建立起来的。
最终水管里面的水 其实都是一个个新连接。也即是说有新连接来了,一定会先告诉监听服务。
local listenid = socket.listen("127.0.0.1", 8001)
socket.start(listenid , function(id, addr)--id 是新连接的id, addr是新连接的发起者ip
print("connect from " .. addr .. " " .. id)
end)
上面是我们实际代码中监听网络的基本套路。过程主要分为两步
-
socket.listen
-
socket.start
下面是分析。注意代码有删减。
1- socket.listen
//lua-socket.c
LUAMOD_API int
luaopen_skynet_socketdriver(lua_State *L) {
luaL_checkversion(L);
luaL_Reg l[] = {
{ "buffer", lnewbuffer },
{ NULL, NULL },
};
luaL_newlib(L,l);
luaL_Reg l2[] = {
{ "listen", llisten },//这里是监听函数
{ NULL, NULL },
};
lua_getfield(L, LUA_REGISTRYINDEX, "skynet_context");
struct skynet_context *ctx = lua_touserdata(L,-1);
if (ctx == NULL) {
return luaL_error(L, "Init skynet context first");
}
luaL_setfuncs(L,l2,1);
return 1;
}
-- socket.lua
local skynet = require "skynet"
local driver = require "skynet.socket" --这里会调用 luaopen_skynet_socketdriver ,最后返回一个库
function socket.listen(host, port, backlog)--next
if port == nil then
host, port = string.match(host, "([^:]+):(.+)$")
port = tonumber(port)
end
return driver.listen(host, port, backlog)--开始调用c层代码 next
end
注意 行4 打开了一个c库。实际上你可以认为 行4 调用 lua-socket.c里面的 luaopen_skynet_socketdriver
函数。
接下来我们从 行6 开始分析。最终调用 行11的 driver.listen
。driver.listen
其实主要是把 监听加入epoll,但是没有立即开启可读监听事件。具体做的事情是
- 向skynet申请槽位,调用操作系统api listen。
- 把监听请求写入管道
- 网络线程从管道读取请求,并把槽位对应的socket结构体填充,并把监听加入到epoll
下面跟踪代码
static int
llisten(lua_State *L) {//next
const char * host = luaL_checkstring(L,1);
int port = luaL_checkinteger(L,2);
int backlog = luaL_optinteger(L,3,BACKLOG);
struct skynet_context * ctx = lua_touserdata(L, lua_upvalueindex(1));//ctx代表发起监听的服务
int id = skynet_socket_listen(ctx, host,port,backlog);//next
if (id < 0) {
return luaL_error(L, "Listen error");
}
lua_pushinteger(L,id);
return 1;
}
int
skynet_socket_listen(struct skynet_context *ctx, const char *host, int port, int backlog) {//next
uint32_t source = skynet_context_handle(ctx);//获取发起监听服务的地址标识
return socket_server_listen(SOCKET_SERVER, source, host, port, backlog);//next
}
上面最终调用了 socket_server_listen,继续看下面 行31
static int
reserve_id(struct socket_server *ss) {//预定一个32位id, 相当于向ss申请预留一个槽位.
int i;
for (i=0;i<MAX_SOCKET;i++) {//每次查找最大次数是 MAX_SOCKET=pow(2,16)
int id = ATOM_FINC(&(ss->alloc_id))+1;
if (id < 0) {//32位int的最大正整数是0x7fffffff ,之后再加1 就变负数了
id = ATOM_FAND(&(ss->alloc_id), 0x7fffffff) & 0x7fffffff;//置零
}
struct socket *s = &ss->slot[HASH_ID(id)];
int type_invalid = ATOM_LOAD(&s->type);
if (type_invalid == SOCKET_TYPE_INVALID) {//是空闲的
if (ATOM_CAS(&s->type, type_invalid, SOCKET_TYPE_RESERVE)) {//设置为预留
s->id = id;
s->protocol = PROTOCOL_UNKNOWN;
// socket_server_udp_connect may inc s->udpconncting directly (from other thread, before new_fd),
// so reset it to 0 here rather than in new_fd.
ATOM_INIT(&s->udpconnecting, 0);
s->fd = -1;
return id;
} else {
// retry
--i;
}
}
}
return -1;
}
int //next
socket_server_listen(struct socket_server *ss, uintptr_t opaque, const char * addr, int port, int backlog) {
int fd = do_listen(addr, port, backlog);//调用操作系统提供的socket api,这里返回的fd是操作系统监听socket
if (fd < 0) {
return -1;
}
struct request_package request;
int id = reserve_id(ss);//预定一个id,这个id是skynet分配的,不同于系统分配的socket fd
if (id < 0) {
close(fd);
return id;
}
request.u.listen.opaque = opaque;//发起监听服务的地址
request.u.listen.id = id;//
request.u.listen.fd = fd;//操作系统监听socket
send_request(ss, &request, 'L', sizeof(request.u.listen));//把请求写入管道 next
return id;
}
这个函数主要做的事情是,
- 调用操作系统网络监听api完成监听。
- 向skynet申请一个槽位 。 这个槽位是skynet管理的。每创建一个socket都会有一个对应的槽位,这个槽位对应一个skynet自定义的一个socket结构。
- 往管道里写入一个类型为 L 的请求 。
下面我们主要看看写管道的过程。send_request
这个函数主要是在当前线程里把请求写入管道,网络线程会适时从管道中读取出来处理。看 下面 行13
struct request_package {
uint8_t header[8]; // 6 bytes dummy
union {
struct request_close close;
struct request_listen listen;
} u;
uint8_t dummy[256];
};
static void
send_request(struct socket_server *ss, struct request_package *request, char type, int len) {//next
request->header[6] = (uint8_t)type;
request->header[7] = (uint8_t)len;
const char * req = (const char *)request + offsetof(struct request_package, header[6]);//header是结构体request_package的成员变量
for (;;) {//发送的数据在内存中排序依次是 [一字节]type [一字节]len [sizeof(request.u.listen]字节request.u.listen
ssize_t n = write(ss->sendctrl_fd, req, len+2);
if (n<0) {
if (errno != EINTR) {
skynet_error(NULL, "socket-server : send ctrl command error %s.", strerror(errno));
}
continue;
}
assert(n == len+2);
return;
}
}
上面 行16 req变量表示的地址 是request的起始地址再偏移6个字节。也就是说request的前面六个字节暂时是没有用到的,可能是将来作为扩展用。
上面 行18 最终调用系统api write把请求数据写入了管道.以上代码就是lua层调用监听时,在当前线程里面的做的事情。
我们看到最后是把请求监听请求写入管道了。而网络线程会在合适的时机在管道的接收端读取请求。
下面是网络线程处理管道请求的分析。 socket_server_poll
是处理管道消息的地方。
上图中 方框的部分就是具体处理管道消息的地方。先看 has_cmd
.
static int
has_cmd(struct socket_server *ss) {
struct timeval tv = {0,0};//设置为非阻塞立即返回检查模式
int retval;
FD_SET(ss->recvctrl_fd, &ss->rfds);//监听可读事件
retval = select(ss->recvctrl_fd+1, &ss->rfds, NULL, NULL, &tv);//判断管道里面是否有数据
if (retval == 1) {//说明有可读事件发生
return 1;
}
return 0;
}
has_cmd
是通过 系统api select函数 判断管道里面是否有数据.如果管道里面有数据,则调用 ctrl_cmd
处理。
// return type
static int
ctrl_cmd(struct socket_server *ss, struct socket_message *result) {//next
int fd = ss->recvctrl_fd;
// the length of message is one byte, so 256+8 buffer size is enough.
uint8_t buffer[256];
uint8_t header[2];
block_readpipe(fd, header, sizeof(header));//读取管道数据
int type = header[0];
int len = header[1];//消息的长度用一个字节保存 可以表示的最大值是256
block_readpipe(fd, buffer, len);//读取管道数据
// ctrl command only exist in local fd, so don't worry about endian.
switch (type) {
case 'L':
return listen_socket(ss,(struct request_listen *)buffer, result);//处理监听请求 next
return -1;
}
ctrl_cmd
把管道数据读取出来,然后根据请求类型,转交给 listen_socket
处理。看下面 行35.
static struct socket *
new_fd(struct socket_server *ss, int id, int fd, int protocol, uintptr_t opaque, bool reading) {
struct socket * s = &ss->slot[HASH_ID(id)];
assert(ATOM_LOAD(&s->type) == SOCKET_TYPE_RESERVE);
if (sp_add(ss->event_fd, fd, s)) {//加入epoll 注意跟s做了一个关联.
ATOM_STORE(&s->type, SOCKET_TYPE_INVALID);
return NULL;
}
s->id = id;//可以认为是skynet分配的槽位id. 实际上 HASH_ID(id) 才是真正的槽位索引
s->fd = fd;//系统fd
s->reading = true;//监听读事件开关
s->writing = false;//监听写事件开关
s->closing = false;
ATOM_INIT(&s->sending , ID_TAG16(id) << 16 | 0);//ID_TAG16是取出高16位
s->protocol = protocol;
s->p.size = MIN_READ_BUFFER;//初始设置可读缓存size
s->opaque = opaque;//关联的服务地址
s->wb_size = 0;
s->warn_size = 0;
check_wb_list(&s->high);
check_wb_list(&s->low);
s->dw_buffer = NULL;
s->dw_size = 0;
memset(&s->stat, 0, sizeof(s->stat));
if (enable_read(ss, s, reading)) {//设置可读监听开关 此时reading是false 所以并没有开启可读监听
ATOM_STORE(&s->type , SOCKET_TYPE_INVALID);
return NULL;
}
return s;
}
static int//next
listen_socket(struct socket_server *ss, struct request_listen * request, struct socket_message *result) {
int id = request->id;//可以认为是预分配的槽位
int listen_fd = request->fd;
struct socket *s = new_fd(ss, id, listen_fd, PROTOCOL_TCP, request->opaque, false);//注意最后一个参数false表示不监听可读事件
ATOM_STORE(&s->type , SOCKET_TYPE_PLISTEN);//注意这里类型是 SOCKET_TYPE_PLISTEN
return -1;//返回-1 表示此次命令处理结束 可以开始处理下一个命令了
}
行38 填充了槽位关联socket结构体。并把监听socket加入到epoll。到此soket.listen监听请求在网络线程的处理就是结束了。也就是说 在lua层调用 socket.listen只返回了一个id,c层也没有其他通知lua层的消息。但注意 此时c层分配的监听socket的类型是 SOCKET_TYPE_PLISTEN
而不是 SOCKET_TYPE_LISTEN
。即当前是 预监听状态,而不是监听状态。
2- socket.start
回顾最开始lua层的代码。
local listenid = socket.listen("127.0.0.1", 8001)
socket.start(listenid , function(id, addr)--id 是新连接的id, addr是新连接的发起者ip
print("connect from " .. addr .. " " .. id)
end)
上面的 行1 已经完成了。下面看 socket.start(id, func)
。其实socket.start主要做的事情就是告诉底层监听将在哪个服务安家,底层知道后,会回应lua层,这样监听才算正式开启。有人要说,调用 socket.listen 的时候,底层不是已经知道lua层的监听服务是在哪里了吗,怎么还要调用socket.start再次告诉底层监听所在的服务?这里我们调用socket.listen和socket.start是在同一个服务,实际上,socket.start才是决定了监听最终所在的位置。
function socket.start(id, func) --注意这里的id是监听id
driver.start(id)//next
return connect(id, func)
end
我们先关注driver.start(id)
.后面再回来看lua层的connect函数
driver.start(id)
static int
lstart(lua_State *L) {
struct skynet_context * ctx = lua_touserdata(L, lua_upvalueindex(1));
int id = luaL_checkinteger(L, 1);
skynet_socket_start(ctx,id);//next
return 0;
}
void
skynet_socket_start(struct skynet_context *ctx, int id) {
uint32_t source = skynet_context_handle(ctx);//调用socket.start的服务地址
socket_server_start(SOCKET_SERVER, source, id);//next
}
void
socket_server_start(struct socket_server *ss, uintptr_t opaque, int id) {
struct request_package request;
request.u.resumepause.id = id;//监听id
request.u.resumepause.opaque = opaque;//调用socket.start的服务地址
send_request(ss, &request, 'R', sizeof(request.u.resumepause));//next
}
上面又再次使用了send_request
把请求写入管道。我们可以根据请求类型 ‘R’ 找到在网络线程处理的代码。如下 行7
// return type
static int
ctrl_cmd(struct socket_server *ss, struct socket_message *result) {
switch (type) {
case 'R':
return resume_socket(ss,(struct request_resumepause *)buffer, result);//next
return -1;
}
继续看 resume_socket
static int
resume_socket(struct socket_server *ss, struct request_resumepause *request, struct socket_message *result) {//next
int id = request->id;
result->id = id;//监听id
result->opaque = request->opaque;//调用socket.start的服务地址
result->ud = 0;
result->data = NULL;
struct socket *s = &ss->slot[HASH_ID(id)];//通过监听id槽位找到对应的skynet定义的socket
if (halfclose_read(s)) {
// The closing socket may be in transit, so raise an error. See https://github.com/cloudwu/skynet/issues/1374
result->data = "socket closed";
return SOCKET_ERR;
}
struct socket_lock l;
socket_lock_init(s, &l);
if (enable_read(ss, s, true)) {//开启可读事件监听 之前是没有开启的
result->data = "enable read failed";
return SOCKET_ERR;
}
uint8_t type = ATOM_LOAD(&s->type);//此时type是 SOCKET_TYPE_PLISTEN
if (type == SOCKET_TYPE_PACCEPT || type == SOCKET_TYPE_PLISTEN) {
ATOM_STORE(&s->type , (type == SOCKET_TYPE_PACCEPT) ? SOCKET_TYPE_CONNECTED : SOCKET_TYPE_LISTEN);
s->opaque = request->opaque;
result->data = "start";
return SOCKET_OPEN;//注意这里的返回值,表示需要s->opaque 代表的服务push一个网络消息
}
// if s->type == SOCKET_TYPE_HALFCLOSE_WRITE , SOCKET_CLOSE message will send later
return -1;
}
之前分析socket.listen
时我们说过,监听id并没有开启可读事件监听。此时 行17 开启了可读事件监听。而且当时socket的类型是预监听,行23 现在才把监听类型设置为 所谓的监听状态。注意 行26 这里最后有一个返回值 SOCKET_OPEN。
我们重新来看网络线程的处理流程。网络线程会一直循环调用skynet_socket_poll
。里面又会调用 socket_server_poll
。看如下代码
int
skynet_socket_poll() {
int type = socket_server_poll(ss, &result, &more);//上面分析了,这次返回值是 SOCKET_OPEN
switch (type) {
case SOCKET_OPEN://监听正式启动
forward_message(SKYNET_SOCKET_TYPE_CONNECT, true, &result);
break;
}
}
注意 result是socket_message类型,也就是网络消息首先是保存在这个结构里的。
下面是 socket_server_poll
处理管道消息的地方。
所以最终会调用 forward_message(SKYNET_SOCKET_TYPE_CONNECT, true, &result);
这个函数会往调用socket.start
的服务发送消息 ,看下面 行10
static void
forward_message(int type, bool padding, struct socket_message * result) {//next
struct skynet_socket_message *sm;
size_t sz = sizeof(*sm);
if (padding) {
if (result->data) {//当前的data是 "start"
size_t msg_sz = strlen(result->data);
if (msg_sz > 128) {
msg_sz = 128;
}
sz += msg_sz;
}
}
sm = (struct skynet_socket_message *)skynet_malloc(sz);//分配内存 这个sz已经包括result->data占用的字节了
sm->type = type;//SKYNET_SOCKET_TYPE_CONNECT
sm->id = result->id;//监听id
sm->ud = result->ud;//0
if (padding) {
sm->buffer = NULL;
memcpy(sm+1, result->data, sz - sizeof(*sm));//把result->data的数据放置到sm尾部。
}
struct skynet_message message;
message.source = 0;
message.session = 0;
message.data = sm;
message.sz = sz | ((size_t)PTYPE_SOCKET << MESSAGE_TYPE_SHIFT);//PTYPE_SOCKET放置在sz的高八位
if (skynet_context_push((uint32_t)result->opaque, &message)) {//push消息
// todo: report somewhere to close socket
// don't call skynet_socket_close here (It will block mainloop)
skynet_free(sm->buffer);
skynet_free(sm);
}
}
forward_message函数主要是把 socket_message 的内容转化为 skynet_socket_message。之后把 skynet_socket_message 的内容转化成skynet_message。看看这几个结构体
struct socket_message {
int id;
uintptr_t opaque;
int ud; // for accept, ud is new connection id ; for data, ud is size of data
char * data;
};
#define SKYNET_SOCKET_TYPE_DATA 1
#define SKYNET_SOCKET_TYPE_CONNECT 2
#define SKYNET_SOCKET_TYPE_CLOSE 3
#define SKYNET_SOCKET_TYPE_ACCEPT 4
struct skynet_socket_message {
int type;//这里的type主要就是上面几个常量
int id;
int ud;
char * buffer;
};
struct skynet_message {
uint32_t source;
int session;
void * data;
size_t sz;
};
同时注意此时的数据如下所示
以上就是整个driver.start的分析。现在我们回到lua层。分析connect函数。
connect(id,func)
function socket.start(id, func)
driver.start(id)
return connect(id, func)--这里func是有新连接时的回调函数 --next
end
这个connect函数主要是进行一些初始化工作,然后挂起自己,等待唤醒。
这里socket.start(id, func)
的时序是 driver.start
向管道写请求,connect
挂起当前线程,网络线程读取管道,处理请求,然后发送 SKYNET_SOCKET_TYPE_CONNECT
给当前服务,最后当前协程被唤醒,connect
函数继续执行。
local function connect(id, func)--fun是新连接的accept回调函数
local newbuffer
if func == nil then
newbuffer = driver.buffer()
end
local s = {
id = id,--监听id
buffer = newbuffer,--这里newbuffer是nil
pool = newbuffer and {},
connected = false,
connecting = true,
read_required = false,
co = false,
callback = func, --如果func不为nil 说明当前是监听socket
protocol = "TCP",
}
assert(not socket_onclose[id], "socket has onclose callback")
assert(not socket_pool[id], "socket is not closed")
socket_pool[id] = s
suspend(s)--next
local err = s.connecting
s.connecting = nil
if s.connected then
return id
else
socket_pool[id] = nil
return nil, err
end
end
每个socket对象都保存在 socket_pool中。监听socket也不例外。注意行20 挂起了当前协程。suspend(s)
里面主要做到事情就是 调用 skynet.wait(s.co)
。看下面代码 行11
function skynet.wait(token)--next
local session = c.genid() --分配一个session
token = token or coroutine.running()
suspend_sleep(session, token)--挂起协程 next
sleep_session[token] = nil
session_id_coroutine[session] = nil
end
local function suspend(s)--next
assert(not s.co)
s.co = coroutine.running()--保存当前协程
skynet.wait(s.co) --next
-- wakeup closing corouting every time suspend,
-- because socket.close() will wait last socket buffer operation before clear the buffer.
if s.closing then
skynet.wakeup(s.closing)
end
end
上面代码在 行15 挂起了协程。继续看下面的代码 suspend_sleep。发现最后调用 coroutine_yield "SUSPEND"
--sleep的原因分为两种 一种是skynet.sleep 一种是skynet.wait
local function suspend_sleep(session, token)--next
session_id_coroutine[session] = running_thread
assert(sleep_session[token] == nil, "token duplicative")
sleep_session[token] = session --标记协程为睡眠. 特别的是以协程作为key. 将来时机成熟时,通过 skynet.wakeup(token) 唤醒协程 其实token不一定是协程 只要是可以表示唯一的值即可 因为最终是token->session->co
return coroutine_yield "SUSPEND" --让出当前协程
end
通过上面的分析。当前协程已经挂起了。等待被唤醒。实际上整个监听的时序如下
继续看。当收到 网络消息 SKYNET_SOCKET_TYPE_CONNECT
后,lua层具体是怎么处理的?
其实socket.lua加载的时候就通过 skynet.register_protocol
注册了 网络消息类型 的回调函数 dispatch。还记得吗,当lua层处理消息,会调用 skynet.dipath_message
,然后获取一个协程并设置任务函数f,这个f 就是下面 行5 设置的dispatch
skynet.register_protocol {
name = "socket",
id = skynet.PTYPE_SOCKET, -- PTYPE_SOCKET = 6
unpack = driver.unpack,--把struct skynet_socket_message内的数据提取出来 即 type id ud buffer 注意type不同 后面参数的意义也是不同的
dispatch = function (_, _, t, ...) --参数分别是: session source SKYNET_SOCKET_TYPE_CONNECT ...
socket_message[t](...) --...表示的参数是 监听id 0 "start"
end
}
注意上面 driver.unpack
是把 c层的 skynet_socket_message
的内容提取出来给dispatch当作参数用 ,看下面的提取代码
static int
lunpack(lua_State *L) {
struct skynet_socket_message *message = lua_touserdata(L,1);
int size = luaL_checkinteger(L,2);
lua_pushinteger(L, message->type);//SKYNET_SOCKET_TYPE_CONNECT
lua_pushinteger(L, message->id);//监听id
lua_pushinteger(L, message->ud);//0
if (message->buffer == NULL) {//此时buffer为NULL
lua_pushlstring(L, (char *)(message+1),size - sizeof(*message));//这里当前是 "start"
}
return 4;//总共四个返回值: SKYNET_SOCKET_TYPE_CONNECT 监听id 0 "start"
}
也就是说lua层此时的基本处理流程是 skynet.dispatch_message--->raw_dispatch_message--->dispatch
。下面是注册的收到 SKYNET_SOCKET_TYPE_CONNECT 网络消息类型 时调用的函数
-- SKYNET_SOCKET_TYPE_CONNECT = 2
socket_message[2] = function(id, _ , addr)
local s = socket_pool[id] --通过监听id找到对应的s对象
if s == nil then
return
end
-- log remote addr
if not s.connected then -- resume may also post connect message
s.connected = true --设置为连接成功 实际上是完成了监听
wakeup(s)--next
end
end
上面是 addr 参数其实是"start" 根本不是地址
local function wakeup(s)--next
local co = s.co
if co then
s.co = nil
skynet.wakeup(co)--把之前等待的协程加入唤醒队列 next
end
end
function skynet.wakeup(token)--next
if sleep_session[token] then --已经处于sleep状态中
tinsert(wakeup_queue, token)--加入唤醒队列
return true
end
end
注意 标记睡眠状态是以协程为key ,唤醒时以协程为key查找对应协程是否睡眠
这里收到网络消息 SKYNET_SOCKET_TYPE_CONNECT 后,co_create了一个协程y,然后在协程中执行上面的dispatch代码。dispatch会把之前 因调用connect函数挂起的协程x 加入了唤醒队列。等当前协程y让出后,之前挂起的那个协程x就有机会继续执行了。也即是继续执行connect函数
注意最终监听底层对应的socket的type是SOCKET_TYPE_LISTEN.虽然最后连接成功发送给监听服务的网络消息是 SKYNET_SOCKET_TYPE_CONNECT
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本