探索skynet(一):从skynet_sample说起


skynet是云风写的一个轻量级的游戏服务器框架。它以服务为主要的逻辑对象,底层框架支持服务之间的同步/异步调用。由于服务并不是占用独立进程,所以服务之间的调用实现的非常高效。同时,为每个服务启动了一个lua虚拟机(lua_State),实现了服务之间的互不干扰。

但是,skynet是非常轻量的,它并没有实现其他游戏服务器框架中常有的场景管理、用户AOI管理等功能,这些功能,skynet使用者可以按照自己的设计思路使用若干个服务实现。

skynet是开源的,使用c和lua实现,使用者可以只使用lua而不需要有c基础。在GitHub的页面上有非常详细的wiki页,我在阅读源码的过程中就在不断的参考wiki页,非常有帮助。

skynet_sample

start

学习一个框架,最好的做法就是从例子开始。云风自己就有写一个完整的例子skynet_sample(GitHub页面地址)。这个例子的初衷是为了说明sproto(云风自己实现的一个类似google protocol buffer的序列化协议),不过也同时是一个五脏俱全的例程,很适合作为入门例程。

首先我们看一下它的目录结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
drwxrwxr-x  2 yubo yubo 4096 Jan 15 04:51 client
drwxrwxr-x 2 yubo yubo 4096 Jan 15 04:51 cservice
drwxrwxr-x 4 yubo yubo 4096 Jan 15 05:03 lsocket
drwxrwxr-x 2 yubo yubo 4096 Jan 15 04:51 lualib
drwxrwxr-x 2 yubo yubo 4096 Jan 15 04:51 proto
drwxrwxr-x 2 yubo yubo 4096 Jan 15 04:51 run
drwxrwxr-x 2 yubo yubo 4096 Jan 15 04:51 service
drwxrwxr-x 10 yubo yubo 4096 Jan 15 05:08 skynet
drwxrwxr-x 2 yubo yubo 4096 Jan 15 04:51 src
-rw-rw-r-- 1 yubo yubo 238 Jan 15 04:51 Makefile
-rw-rw-r-- 1 yubo yubo 3190 Jan 15 04:51 README.md
-rwxrwxr-x 1 yubo yubo 112 Jan 15 04:51 client.sh
-rw-rw-r-- 1 yubo yubo 515 Jan 15 04:51 config
-rwxrwxr-x 1 yubo yubo 243 Jan 15 04:51 run.sh

run.sh里是skynet_sample的启动脚本,最主要的是这样一行:

1
$ROOT/skynet/skynet $ROOT/config

这是skynet的启动方式:通过读取一个config文件,来启动skynet的各个服务。

config

我们接着来看一下config文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
root = "$ROOT/"
thread = 8
logpath = root .. "run"
harbor = 0
start = "main" -- main script
luaservice = root .. "service/?.lua;" .. root .."skynet/service/?.lua"
lualoader = root .. "skynet/lualib/loader.lua"
lua_path = root .. "lualib/?.lua;" .. root .. "skynet/lualib/?.lua;" .. root .. "skynet/lualib/?/init.lua"
lua_cpath = root .. "skynet/luaclib/?.so"
cpath = root .. "/cservice/?.so;"..root.."/skynet/cservice/?.so"

if $DAEMON then
logger = root .. "run/skynet.log"
daemon = root .. "run/skynet.pid"
end

在config文件中,设置了一些目录信息,以及有关skynet网络的相关信息(harbor=0,所以当前skynet工作于单点模式)。

通过阅读skynet wiki中关于config的章节我们能够知道这些配置项的具体含义。

由于这里没有设置bootstrap,所以采用默认配置:snlua bootstrap。对于这个配置,目前只需要理解到,snlua是一个lua的沙盒服务,我们将bootstrap作为参入传入,就会匹配到bootstrap.lua文件来执行。

bootstrap.lua

bootstrap是skynet框架提供的一个service,文件位于skynet/service/bootstrap.lua。阅读bootstrap.lua的内容,其中关键的一行是:

bootstrap.lua
1
pcall(skynet.newservice,skynet.getenv "start" or "main")

skynet.newservice顾名思义,就是新启动一个skynet服务(我之后也会专门写一篇skynet如何启动一个新服务的文章,敬请期待)。而skynet.getenv,其实可以看作是从config文件中获取参数值。

那让我们再回头看config文件,其中start=”main”,所以这里其实是启动了main.lua作为一个新的服务。

main.lua

main.lua(以及其他用lua写的服务)存放于skynet_sample/service目录下。

其中,主要做了如下几件事:

main.lua
1
2
3
4
5
local proto = skynet.uniqueservice "protoloader"
skynet.call(proto, "lua", "load", {
"proto.c2s",
"proto.s2c",
})

启动protoloader服务(skynet_sample/service/protoloader.lua),并且调用此服务lua类型消息的分发函数load。

main.lua
1
2
local hub = skynet.uniqueservice "hub"
skynet.call(hub, "lua", "open", "0.0.0.0", 5678)

启动hub服务(skynet_sample/service/hub.lua),并且调用此服务lua类型消息的分发函数open。

啊,各种启动服务,是不是对于skynet基于服务的架构有一定的认识了~

我们这里不去详细关心服务之间的调用是如何实现的(之后也会写一篇文章专门讲这个,又挖了一个坑给自己,慢慢填吧),只需要知道通过skynet.call可以实现服务之间的函数调用即可。

protoloader.lua

花开两朵,各表一枝。先看看protoloader.lua中都干了些什么。

protoloader.lua
1
2
3
4
5
6
7
8
local sprotoparser = require "sprotoparser"
local function load(name)
local filename = string.format("proto/%s.sproto", name)
local f = assert(io.open(filename), "Can't open " .. name)
local t = f:read "a"
f:close()
return sprotoparser.parse(t)
end

sprotoparser是skynet中提供的对于sproto的解析库,这里可以当作黑盒。那我们来看看sproto文件长什么样:

proto.c2s.sproto
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
.package {
type 0 : integer
session 1 : integer
ud 2 : string
}

ping 1 {}

signup 2 {
request {
userid 0 : string
}
response {
ok 0 : boolean
}
}

signin 3 {
request {
userid 0 : string
}
response {
ok 0 : boolean
}
}

login 4 {
response {
ok 0 : boolean
}
}

长的和protocol buffer很像吧。所以这里可以把sproto理解为一个类似protocol buffer的黑盒,知道它的功能就好。以后有兴趣,再去详细查看sproto的实现。

hub

接下来我们看main.lua中启动的第二个服务hub。

hub.lua中最后有这么一段代码:

hub.lua
1
2
3
4
5
6
7
8
service.init {
command = hub,
info = data,
require = {
"auth",
"manager",
}
}

这里的service其实是skynet_sample中提供的一个库(skynet_sample/lualib/service.lua),用于统一服务的启动流程:该服务的lua消息由command处理,并且对于require中的值,要依次启动一个uniqueservice。

所以到这里,我们已经启动了四个服务:sprotoloader、hub、auth、manager。

接着看hub。在main.lua中,启动hub之后,就向hub服务发了一条lua类型的消息open。那么hub中对于open消息的处理由hub.open完成:

hub.lua
1
2
3
4
5
6
7
8
function hub.open(ip, port)
log("Listen %s:%d", ip, port)
assert(data.fd == nil, "Already open")
data.fd = socket.listen(ip, port)
data.ip = ip
data.port = port
socket.start(data.fd, new_socket)
end

这段代码的意思也很好懂:hub启动了对一个ip:port的监听,并且对于这个地址的新消息,都交由new_socket处理。

hub.lua
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
local proxy = require "socket_proxy"
function new_socket(fd, addr)
data.socket[fd] = "[AUTH]"
proxy.subscribe(fd)
local ok , userid = pcall(auth_socket, fd)
if ok then
data.socket[fd] = userid
if pcall(assign_agent, fd, userid) then
return -- succ
else
log("Assign failed %s to %s", addr, userid)
end
else
log("Auth faild %s", addr)
end
proxy.close(fd)
data.socket[fd] = nil
end

这里又出现了一个socket_proxy,这不是一个服务,而是一个skynet_sample提供的库,但是它内部会启动一个socket_proxyd单例服务,socket_proxyd对于每一个socket,都会启动一个用c编写的服务package(skynet_sample/src/service_package.c),在这个c服务中具体的进行socket数据的读写(响应上层lua服务的读写消息,按照包长+包体的格式切分数据包/发送数据)。

这里总结一下,socket_proxy库搭配socket_proxyd服务和service_package服务,完成了socket读写切包的功能。

回到hub,接下来,调用了auth_socket。

hub.lua
1
2
3
local function auth_socket(fd)
return (skynet.call(service.auth, "lua", "shakehand" , fd))
end

auth_socket很简单,直接调用了之前启动的auth单例服务的shakehand。

这里我们先不深入auth,就认为它可以实现认证服务好了,后面会单独有章节阅读auth的代码。直接往下看hub,认证成功后,调用了assign_agent。

hub.lua
1
2
3
local function assign_agent(fd, userid)
skynet.call(service.manager, "lua", "assign", fd, userid)
end

这里出现了之前在service_init中启动的manager单例服务。同样这里是简单的调用了他的lua消息assign。

到这里,如果assign_agent返回成功,hub对于一个新socket的工作就结束了。

总结一下hub对一个新的socket干了什么:

首先,它利用socket_proxy/socket_proxyd/service_package处理好了socket上的数据读写;
第二,它通过auth单例服务,完成了对socket上的用户的认证工作(尚未详细阅读实现);
第三,它将认证后的socket交给了manager单例服务。

auth

在auth.py(skynet_sample/service/auth.py)中,又出现了我们熟悉的service.init。

auth.lua
1
2
3
4
5
6
local client = require "client"
service.init {
command = auth,
info = users,
init = client.init "proto",
}

这里有一个库client,在hub调用的auth.shakehand中,也用到了这个client。那我们就看一下client.py(skynet_sample/lualib/client.lua)的实现。

client.lua
1
2
3
4
5
6
7
8
9
function client.init(name)
return function ()
local protoloader = skynet.uniqueservice "protoloader"
local slot = skynet.call(protoloader, "lua", "index", name .. ".c2s")
host = sprotoloader.load(slot):host "package"
local slot2 = skynet.call(protoloader, "lua", "index", name .. ".s2c")
sender = host:attach(sprotoloader.load(slot2))
end
end

在client.init中,实际返回的是一个函数。这个函数的作用,是加载在main中解析过的序列化协议(一个是client2server,一个是server2client)。host用来解析读取到的数据,sender用来序列化要发送的数据。

在auth.shakehand中,用到了client.dispatch:

auth.lua
1
2
3
4
function auth.shakehand(fd)
local c = client.dispatch { fd = fd }
return c.userid
end

那么,client.dispatch的一个简化版实现如下(去掉了一些错误检查):

client.lua
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
local handler = {}
function client.dispatch( c )
local fd = c.fd
proxy.subscribe(fd)
while true do
local msg, sz = proxy.read(fd)
local type, name, args, response = host:dispatch(msg, sz)
if c.exit then
return c
end
local f = handler[name]
if f then
skynet.fork(function()
local ok, result = pcall(f, c, args)
if ok then
proxy.write(fd, response(result))
else
log("raise error = %s", result)
proxy.write(fd, response(ERROR, result))
end
end)
end
end
end

有了前面对于proxy和host的铺垫,这段代码也不难理解。proxy.read不断通过c服务package从socket上读取切分好包的数据,然后交给host去解析sproto协议。然后根据解析出的name信息,去handler中查找对应的处理函数。如果在循环中,设置了exit标记,则退出处理数据的循环。

但是这里有个问题,这里的handler函数是空的{}。

其实,这里的handler是留给具体的服务自行填充的,例如在auth服务中,这样填充的handler:

auth.lua
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
local cli = client.handler()

function cli:signup(args)
log("signup userid = %s", args.userid)
if users[args.userid] then
return FAIL
else
users[args.userid] = true
return SUCC
end
end

function cli:signin(args)
log("signin userid = %s", args.userid)
if users[args.userid] then
self.userid = args.userid
self.exit = true
return SUCC
else
return FAIL
end
end

function cli:ping()
log("ping")
end

这样,auth就给handler增加了signup、signin、ping三个函数,正好对应proto.c2s.sproto中定义的函数接口。

在这里的signup中,只是记录了userid,在signin的时候也没有做其他的认证机制。而在signin中,设置了userid,并且设置了exit标记,使得dispatch退出循环。这时,auth.shakehand返回signin中设置的userid。

总结一下:

首先,client库中通过proxy服务读取了socket上的数据包,并根据sproto协议进行了解析;
其次,auth服务填充了client库中留出的handler处理函数集合,为client库添加了对于signup、signin、ping消息的处理方式,进行了一个“假的”用户认证,获得了userid,并返回给hub服务交由其处理。

manager

回到hub中,在通过auth服务获得userid后,就将调用manager服务的assign函数。

manager.assign的功能,是根据userid启动一个独立的agent服务负责这个用户的数据处理。

manager.lua
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
  
local function new_agent()
-- todo: use a pool
return skynet.newservice "agent"
end
function manager.assign(fd, userid)
local agent
repeat
agent = users[userid]
if not agent then
agent = new_agent()
if not users[userid] then
-- double check
users[userid] = agent
else
free_agent(agent)
agent = users[userid]
end
end
until skynet.call(agent, "lua", "assign", fd, userid)
log("Assign %d to %s [%s]", fd, userid, agent)
end

在最后,manager又通过调用agent的assign将这个用户的userid和fd交给了agent服务处理。

agent.lua
1
2
3
4
5
6
7
8
9
10
11
12
  
function agent.assign(fd, userid)
if data.exit then
return false
end
if data.userid == nil then
data.userid = userid
end
assert(data.userid == userid)
skynet.fork(new_user, fd)
return true
end

这里实际上对fd的处理,交给了new_user函数。

agent.lua
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
  
local function new_user(fd)
local ok, error = pcall(client.dispatch , { fd = fd })
log("fd=%d is gone. error = %s", fd, error)
client.close(fd)
if data.fd == fd then
data.fd = nil
skynet.sleep(1000) -- exit after 10s
if data.fd == nil then
-- double check
if not data.exit then
data.exit = true -- mark exit
skynet.call(service.manager, "lua", "exit", data.userid) -- report exit
log("user %s afk", data.userid)
skynet.exit()
end
end
end
end

有了之前对auth服务的研究,这里的代码就很容易理解了。同样的,这里用到了client库,通过socket_proxyd服务读取socket上的数据,并按照sproto协议解析,然后交由client.handler进行对应的处理。

在agent中,只针对login和ping消息进行了处理:

agent.lua
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
  
local cli = client.handler()

function cli:ping()
assert(self.login)
log "ping"
end

function cli:login()
assert(not self.login)
if data.fd then
log("login fail %s fd=%d", data.userid, self.fd)
return { ok = false }
end
data.fd = self.fd
self.login = true
log("login succ %s fd=%d", data.userid, self.fd)
client.push(self, "push", { text = "welcome" }) -- push message to client
return { ok = true }
end

注意到,这里agent服务没有主动设置exit标识,所以agent服务是不会主动退出dispatch的。

总结一下:

首先,manager服务为每一个userid新启动一个agent服务,负责处理用户的数据;
其次,在agent服务中,类似auth服务,配合使用client库读取、解析用户发来的数据,交由对应的handler处理。

simpleclient

skynet_sample中还提供了一个默认的client程序,在skynet_sample/client目录下,由3个文件组成。其实它实现了一个简单的client的状态机,在signin、signup、login、ping四个状态之间转化。

总结

扯了这么多,终于把skynet_sample里的lua代码撸了一遍。通过这个过程,可以看到skynet是如何通过服务这一概念来构架服务端系统的,同时也了解了如何在服务之间进行通信。

后面,我会另开文章深入skynet的源码,说说skynet具体是如何启动一个服务的,以及skynet.call/skynet.rawcall/skynet.send这一系列的服务调用接口是怎么实现的~

P.S. skynet_sample的实现中,有一个小bug,会导致服务端无谓的吞掉一个数据包。大家有兴趣可以自己找找看^^(提示,和异步有关)

转载请注明出处: http://blog.guoyb.com/2017/01/19/skynet-1/

欢迎使用微信扫描下方二维码,关注我的微信公众号TechTalking,技术·生活·思考:
后端技术小黑屋

评论