LPC 语言基础教程:9.3 socket 接口介绍及游戏开发实战

本文节选自《LPC语言基础教程:从零学习游戏开发》,版权归@mudren,欢迎转载,但必须注明来源(mud.ren)。

Socket 编程面向的是中高级 LPC 开发者,通过socket接口可以实现tcp/udp协议传输数据,利用这个我们可以在MUD中实现所有相关的应用层协议,包括http/ftp/telnet/ssh等,具体游戏中可以轻易的实现 MUD 互联、FTP 文件传输、读取网页等能力,让你在MUD中玩出各种花样。

本节内容相对比较复杂,如果你只是需要MUD中请求HTTP网页API,可直接使用mudcore框架中封装好的HTTP模块:https://bbs.mud.ren/threads/255

Socket 介绍

LPC 中有七种 socket 模式:MUD, STREAM, STREAM_TLS, DATAGRAM, STREAM_BINARY, DATAGRAM_BINARY 和 STREAM_TLS_BINARY。可以从驱动提供的 头文件查看定义:

#ifndef SOCKET_H
#define SOCKET_H

#define MUD                 0   // for sending LPC data types using TCP protocol.
#define STREAM              1   // for sending raw data using TCP protocol.
#define DATAGRAM            2   // for using UDP protocol.
#define STREAM_BINARY       3
#define DATAGRAM_BINARY     4
#define STREAM_TLS          5
#define STREAM_TLS_BINARY   6

#endif          /* SOCKET_H */

注意:STREAM_TLS 和 STREAM_TLS_BINARY 模式为fluffos 2023年12月23日更新的功能,旧版不支持。

MUD 模式

MUD 模式是一种面向连接(TCP)的通信模式,在 MUD 模式下,可以把除对象以外的所有 LPC 数据类型从一个 MUD 发送到另一个 MUD。

STEAM 数据流模式

STEAM 模式也是一种面向连接(TCP)的通信模式,和 MUD 模式不同的是所有数据以字符串的形式收发。STEAM 模式没有 MUD 模式强大,但是比 MUD 模式更快,也更节省资源占用。

DATAGRAM 数据报模式

DATAGRAM 模式是无连接的(UDP)通信模式,所有数据都是以数据报的消息发送给目标MUD。

BINARY 二进制模式

STREAM_BINARY 和 DATAGRAM_BINARY 二种模式下数据以二进制(buffer数据类型)的形式收发,在MUD中如果是不同编码的MUD通信,使用这种模式做编码转换比较方便

Socket通信流程

在 TCP 通信模式下,通信 MUD 双方是以服务器/客户端的模式工作,具体流程如下:

  1. 服务端使用 socket_create() 创建 socket 连接,成功返回 socket 连接描述符 fd,失败返回错误 err
  2. 服务端使用 socket_bind() 绑定 socket 连接 fd 到指定的端口,成功返回 EESUCCESS,失败返回错误 err
  3. 服务端使用 socket_listen() 监听已绑定端口的 socket 连接,成功返回 EESUCCESS,失败返回错误 err
  4. 服务端使用 socket_accept() 接受客户端的连接请求,成功返回和客户端通信的 socket 连接描述符 fd,失败返回错误 err
  5. 客户端使用 socket_create() 创建 socket 连接,成功返回 socket 连接描述符 fd,失败返回错误 err
  6. 客户端使用 socket_connect() 连接服务器,成功返回 EESUCCESS,失败返回错误 err
  7. 连接建立后服务端和客户端都可以在回调函数中使用 socket_write() 传送数据。

在 UDP 通信模式下,没有以上流程的第3、4、6步,客户端直接使用 socket_write() 发送数据到服务端,具体流程如下:

  1. 服务端使用 socket_create() 创建 socket 连接,成功返回 socket 连接描述符 fd,失败返回错误 err
  2. 服务端使用 socket_bind() 绑定 socket 连接 fd 到指定的端口,成功返回 EESUCCESS,失败返回错误 err
  3. 客户端使用 socket_create() 创建 socket 连接,成功返回 socket 连接描述符 fd,失败返回错误 err
  4. 客户端使用 socket_write() 传送数据到服务端。

需要说明的是:

  1. 因为 fd 数量有限,如果服务器绑定端口或监听连接失败,最好使用 socket_close() 释放这个 socket 连接;
  2. 因为TCP模式客户端和服务端建立了连接,可以互相通信,客户端不需要使用 socket_bind() 绑定端口;而UDP模式客户端不需要其他客户端主动连接它,所以也可以不使用 socket_bind() 绑定端口,但是如果需要能接受其它服务端或客户端发送信息,需要使用 socket_bind() 绑定端口(绑定端口后也可以收到服务端的消息,可以socket_bind(udp_fd, 0);让系统自动分配可用端口,避免冲突);
  3. TCP模式建立连接后外网服务端可以和内网的客户端相互通信,而UDP模式外网服务端可以在内网客户端主动发送消息后根据接收消息的地址向内网客户端发送消息;
  4. 可以使用 socket_address() 获取服务端 fd 的远程连接地址;
  5. 可以使用 socket_status() 获取 socket 连接描述符的状态;
  6. 可以使用 socket_error() 获取错误 err 的详细描述;
  7. socket 通信安全校验由 master 对象中的 valid_socket 负责。

Socket 函数

LPC中有大量和 socket 操作相关的外部函数,包括:socket_accept、socket_acquire、socket_address、socket_bind、socket_close、 socket_connect、socket_create、socket_error、socket_listen、socket_release、socket_status、 socket_write。另外还有一个 apply 方法:valid_socket。因为函数比较多,这里不一一列出使用说明,请从 mud.wiki 查看。

先看看以上流程使用到的函数使用语法:

名称

socket_create() - 创建一个 socket

语法

int socket_create( int mode, string read_callback );
int socket_create( int mode, string read_callback, string close_callback );

描述

socket_create() 创建一个 socket。参数 `mode` 决定要创建哪种模式的 socket。目前支持的 socket 模式为:

MUD         使用 TCP 协议传输 LPC 类型数据
STREAM      使用 TCP 协议传输原始数据
DATAGRAM    使用 UDP 协议
------------------------------------------------
#define MUD                 0 // for sending LPC data types using TCP protocol.
#define STREAM              1 // for sending raw data using TCP protocol.
#define DATAGRAM            2 // for using UDP protocol.
#define STREAM_BINARY       3
#define DATAGRAM_BINARY     4
------------------------------------------------

参数 `read_callback` 是 socket 接收数据后驱动程序调用的函数名称,此函数原型应该是以下格式:

void read_callback(int fd, mixed message, void|string addr);

其中参数 `fd` 是接收到数据的 socket 连接;参数 `message` 是接收到的数据,在非二进制模式下返回值是 utf-8 格式的字符串,在二进制模式下返回值是 buffer;在UDP模式下,第三个参数 `addr` 为客户端IP地址。

参数 `close_callback` 是 socket 意外关闭时驱动程序调用的函数名称(如不是通过 socket_close() 关闭)。此函数原型应该是以下格式:

void close_callback(int fd);

其中参数 `fd` 是被关闭的 socket 连接。注意:close_callback() 不能在 DATAGRAM 模式下使用。

返回值

成功时返回一个非负连接描述符,失败时返回以下意义的负值:

错误

EEMODENOTSUPP  不支持的 Socket 模式
EESOCKET       创建 socket 时的问题
EESETSOCKOPT   设置 socket 选项(setsockopt)时的问题
EENONBLOCK     设置非阻塞模式(non-blocking mode)时的问题
EENOSOCKS      没有空余的 efun sockets
EESECURITY     试图违反安全性

以上错误定义在驱动程序源文件的 `socket_err.h` 头文件中,具体文件内容如下:
-----------------------------------------
#ifndef _SOCKET_ERRORS_H
#define _SOCKET_ERRORS_H

#define EESUCCESS 1       /* Call was successful */
#define EESOCKET -1       /* Problem creating socket */
#define EESETSOCKOPT -2   /* Problem with setsockopt */
#define EENONBLOCK -3     /* Problem setting non-blocking mode */
#define EENOSOCKS -4      /* UNUSED */
#define EEFDRANGE -5      /* Descriptor out of range */
#define EEBADF -6         /* Descriptor is invalid */
#define EESECURITY -7     /* Security violation attempted */
#define EEISBOUND -8      /* Socket is already bound */
#define EEADDRINUSE -9    /* Address already in use */
#define EEBIND -10        /* Problem with bind */
#define EEGETSOCKNAME -11 /* Problem with getsockname */
#define EEMODENOTSUPP -12 /* Socket mode not supported */
#define EENOADDR -13      /* Socket not bound to an address */
#define EEISCONN -14      /* Socket is already connected */
#define EELISTEN -15      /* Problem with listen */
#define EENOTLISTN -16    /* Socket not listening */
#define EEWOULDBLOCK -17  /* Operation would block */
#define EEINTR -18        /* Interrupted system call */
#define EEACCEPT -19      /* Problem with accept */
#define EEISLISTEN -20    /* Socket is listening */
#define EEBADADDR -21     /* Problem with address format */
#define EEALREADY -22     /* Operation already in progress */
#define EECONNREFUSED -23 /* Connection refused */
#define EECONNECT -24     /* Problem with connect */
#define EENOTCONN -25     /* Socket not connected */
#define EETYPENOTSUPP -26 /* Object type not supported */
#define EESENDTO -27      /* Problem with sendto */
#define EESEND -28        /* Problem with send */
#define EECALLBACK -29    /* Wait for callback */
#define EESOCKRLSD -30    /* Socket already released */
#define EESOCKNOTRLSD -31 /* Socket not released */
#define EEBADDATA -32     /* sending data with too many nested levels */
#define ERROR_STRINGS 33  /* sizeof (error_strings) */

#endif /* SOCKET_ERRORS_H */
-----------------------------------------

名称

socket_bind() - 绑定 IP 和端口到 socket 连接

语法

int socket_bind( int s, int port );

描述

socket_bind() 为未名称的 socket 指定名称, 当通过 socket_create() 创建一个 socket 连接后,它存在于命名空间中但没有指定名称(译者注:简单的说就是有 IP 没端口),socket_bind() 会请求把端口 `port` 分配给 socket `s`。

返回值

成功时返回 EESUCCESS,失败(错误)时返回代表以下意义的负值:

错误

EEFDRANGE      连接描述符(Descriptor)超出范围
EEBADF         无效的连接描述符
EESECURITY     试图违反安全性
EEISBOUND      Socket 已经绑定(被命名)
EEADDRINUSE    地址已经被占用
EEBIND         绑定(命名)出问题
EEGETSOCKNAME  获取 socket 名称(getsockname)出问题

注:所有错误名称定义在驱动程序源文件的 `socket_err.h` 头文件中。

名称

socket_listen() - 监听一个 socket 连接

语法

int socket_listen( int s, string listen_callback );

描述

为了接收连接,需要先使用 socket_create() 创建 socket,并使用 socket_listen() 进入监听模式,最后使用 socket_accept() 接收连接。socket_listen() 仅仅在 STEAM 或 MUD 模式有效。

参数 `listen_callback` 是监听中的 socket 收到连接请求时驱动程序调用的函数名称,函数原型格式如下:

void listen_callback(int fd)

其中参数 `fd` 是正在监听中的 socket 连接。

返回值

成功时返回 EESUCCESS,失败(错误)时返回代表以下意义的负值:

错误

EEFDRANGE      连接描述符(Descriptor)超出范围
EEBADF         无效的连接描述符
EESECURITY     试图违反安全性
EEMODENOTSUPP  不支持的 Socket 模式
EENOADDR       Socket 没有绑定地址
EEISCONN       Socket 已连接
EELISTEN       监听有问题

名称

socket_connect() - 启动一个 socket 连接

语法

int socket_connect( int s, string address, string read_callback, string write_callback );

描述

参数 `s` 是一个 socket 连接,`s` 必须是 STREAM 模式 或 MUD 模式. 参数 `address` 是将要连接的 socket 地址,地址格式类似: "127.0.0.1 23"。

参数 `read_callback` 是 socket 获取数据时驱动程序调用的函数名称,函数原型应该是以下格式:

void read_callback(int fd, mixed message)

其中参数 `fd` 是接受数据的 socket 连接,参数 `message` 是接收到的数据。

参数 `write_callback` 是 socket 准备写入数据时驱动程序调用的函数名称,函数原型应该是以下格式:

void write_callback(int fd)

其中参数 `fd` 是将被写入数据的 socket 连接。

返回值

成功时返回 EESUCCESS ,失败(出错)时返回代表以下意义的负值:

错误

EEFDRANGE      连接描述符(Descriptor)超出范围
EEBADF         无效的连接描述符
EESECURITY     试图违反安全性
EEMODENOTSUPP  不支持的 Socket 模式
EEISLISTEN     Socket 正在监听中
EEISCONN       Socket 已连接
EEBADADDR      地址格式有问题
EEINTR         中断的系统调用
EEADDRINUSE    地址已经被占用
EEALREADY      操作已在进行中
EECONNREFUSED  连接被拒绝
EECONNECT      连接有问题

名称

socket_accept() - 在一个 socket 上接受连接

语法

int socket_accept( int s, string read_callback, string write_callback );

描述

参数 `s` 是使用 socket_create() 创建的已调用 socket_bind() 绑定地址并通过 socket_listen() 进入监听状态的 socket。 socket_accept() 从待处理的连接队列中提取第一个连接创建一个和 `s` 有相同属性的新的 socket 并分配一个新的文件描述符,如果队列中不存在待处理的连接,socket_accept() 返回一个下面描述的错误。已接受的 socket 用来从已连接到它的 socket 中读数据或向其中写数据,它不能用来接受别的连接,原始的socket `s` 会保持打开状态以接受新的连接。

参数 `read_callback` 是当新的 socket (不是正在接收中的 socket)收到数据时驱动程序调用的函数名称,这个函数的原型应该是以下格式:

void read_callback(int fd);

其中参数 `fd` 是已准备好接收数据的 socket。

参数 `write_callback` 是当新的 socket (不是正在接收中的 socket) 准备好写入数据时驱动程序调用的函数名称,这个函数的原型应该是以下格式:

void write_callback(int fd);

其中参数 `fd` 是已准备好被写入数据的 socket。

注意:当新的socket 异常关闭时,正在接收中的 socket (不是新的 socket)中的 close_callback 方法会被调用,和调用 socket_close() 的结果不同,close_callback 函数原型应该是以下格式:

void close_callback(int fd)

其中参数 `fd` 是被关闭的 socket 连接。

返回值

socket_accept() 在成功时为接受的 socket 返回一个非负描述符,失败时返回一个错误值,可以使用 socket_error() 外部函数获取错误值的文字描述。

错误

EEFDRANGE      连接描述符(Descriptor)超出范围
EEBADF         无效的连接描述符
EESECURITY     试图违反安全性
EEMODENOTSUPP  不支持的 Socket 模式
EENOTLISTN     Socket 没有开启监听
EEWOULDBLOCK   操作会阻塞
EEINTR         中断的系统调用
EEACCEPT       接收出问题
EENOSOCKS      没有空余的 efun sockets

名称

socket_write() - 从 socket 发送信息

语法

int socket_write( int s, mixed message );
int socket_write( int s, mixed message, string address );

描述

socket_write() 在 socket `s` 上发送消息 `message`,如果 socket `s` 是 STREAM 或 MUD 模式,socket 必须已经连接且不能指定参数 `address`,如果 socket `s` 是 DATAGRAM 模式, 地址参数 `address` 必须指定,地址格式类似: "127.0.0.1 23"。

返回值

成功时返回 EESUCCESS,失败(错误)时返回代表以下意义的负值:

错误

EEFDRANGE      连接描述符(Descriptor)超出范围
EEBADF         无效的连接描述符
EESECURITY     试图违反安全性
EENOADDR       Socket 没有绑定地址
EEBADADDR      地址格式有问题
EENOTCONN      Socket 没有连接
EEALREADY      操作已在进行中
EETYPENOTSUPP  不支持的对象类型
EEBADDATA      发送嵌套级别过多的数据
EESENDTO       sendto 有问题
EEMODENOTSUPP  不支持的 Socket 模式
EEWOULDBLOCK   操作会阻塞
EESEND         send 有问题
EECALLBACK     等待回调中

名称

socket_error() - 返回 socket 错误的文字描述

语法

string socket_error( int error );

描述

socket_error() 返回 socket 错误 `error` 的文字描述

返回值

报错时返回错误描述,否则返回 "socket_error: invalid error number"。

名称

socket_close() - 关闭一个 socket

语法

int socket_close( int s );

描述

socket_close() 关闭 socket `s`,这将释放一个 socket 位置以供使用。

返回值

成功返回 EESUCCESS ,失败返回代表以下错误的负值:

错误

EEFDRANGE      连接描述符(Descriptor)超出范围
EEBADF         无效的连接描述符
EESECURITY     试图违反安全性

Socket 基础示例

前面介绍了socket通信的基础知识和相关函数,在实际开发中,我们并不是需要实现所有功能,因为我们要么做为服务端提供服务要么做为客户端请示服务,这种情况下我们只用实现服务端或客户端的功能即可。

这里是功能演示,所以我们服务端和客户端都实现,而且TCP模式和UDP模式也都做演示。

TCP模式

前面介绍的通信流程可知,TCP模式需要建立连接,服务端需要调用以下方法:socket_createsocket_bindsocket_listensocket_accept,而客户端需要调用以下方法:socket_createsocket_connect,双方都是通过socket_write方法发送数据,使用socket_close关闭连接。

Socket服务端

我们先上示例,在 /cmds/test/ 目录新建 socket_tcp_server.c,代码如下:

/**
 * sockets 相关测试,这里是TCP模式服务端
 */

#define TCP 1
#define PORT 6000

nosave int S;
nosave object R;

void close_callback(int fd)
{
    tell_object(R, sprintf("【TCP服务端】close_callback: fd = %d\n", fd));
    socket_close(fd);
}

void write_callback(int fd)
{
    tell_object(R, sprintf("【TCP服务端】write_callback: fd = %d\n", fd));
}

void read_callback(int fd, mixed message)
{
    tell_object(R, sprintf("【TCP服务端】read_callback: fd = %d, message = %s\n", fd, message));
    // 发送消息给客户端
    if (message == "hi")
    {
        socket_write(fd, "你好呀。");
    }
}

void listen_callback(int fd)
{
    int err;
    tell_object(R, sprintf("【TCP服务端】listen_callback: fd = %d\n", fd));
    // 在一个 socket 上接受连接
    S = socket_accept(fd, "read_callback", "write_callback");
    if (S < 0)
    {
        tell_object(R, sprintf("【TCP服务端】socket_accept error: %s\n", socket_error(S)));
    }
    else
    {
        tell_object(R, sprintf("【TCP服务端】socket_accept: fd = %d\n", S));
        err = socket_write(S, "欢迎连接到服务器 mud.ren ^_^");
        if (err < 0)
        {
            tell_object(R, sprintf("【TCP服务端】socket_write error: %s\n", socket_error(err)));
        }
    }
}

private void server_init()
{
    int s, err;

    // 创建一个 efun socket 连接
    s = socket_create(TCP, "read_callback", "close_callback");
    if (s < 0)
    {
        debug("【TCP服务端】socket_create error: " + socket_error(s));
    }
    else
    {
        debug("【TCP服务端】socket_create: fd = " + s);
        // 绑定端口到 socket 连接
        err = socket_bind(s, PORT);
        if (err < 0)
        {
            debug("【TCP服务端】socket_bind error: " + socket_error(err));
            socket_close(s);
        }
        else
        {
            debug("【TCP服务端】socket_bind SUCCESS!");

            // 监听一个 socket 连接
            err = socket_listen(s, "listen_callback");
            if (err < 0)
            {
                debug("【TCP服务端】socket_listen error: " + socket_error(err));
                socket_close(s);
            }
            else
            {
                debug("【TCP服务端】socket_listen ON " + socket_address(s, 1));
            }
        }
    }
}

void create()
{
    R = this_player();
    server_init();
}

int release()
{
    if(S)
    {
        int err;
        err = socket_release(S, load_object(__DIR__"socket_server"), "release_callback");
        if (err < 0)
        {
            debug("【TCP服务端】socket_release error: " + socket_error(err));
        }
        else
        {
            debug("【TCP服务端】socket_release SUCCESS!");
        }
    }

    return 1;
}

int main(object me, string arg)
{
    if (arg == "release")
    {
        release();
    }
    else if (arg)
    {
        int err;

        err = socket_write(S, arg);
        if (err < 0)
        {
            debug("【TCP服务端】socket_write error: " + socket_error(err));
        }
        else
        {
            debug("【TCP服务端】消息已发送:" + arg);
        }
    }

    return 1;
}

以上代码为服务端测试代码,我们执行指令socket_tcp_server默认创建一个TCP模式的 socket,绑定端口为 6000,可以接受客户端连接。

Socket客户端

再新建文件 socket_tcp_client.c 代码如下:

/**
 * sockets TCP模式客户端测试
 */

#define TCP 1
#define ADDR "127.0.0.1 6000"

nosave int S;
nosave object R;

void close_callback(int fd)
{
    tell_object(R, sprintf("【TCP客户端】close_callback: fd = %d\n", fd));
    socket_close(fd);
}

void write_callback(int fd)
{
    tell_object(R, sprintf("【TCP客户端】write_callback: fd = %d\n", fd));
}

void read_callback(int fd, mixed message)
{
    tell_object(R, sprintf("【TCP客户端】read_callback: fd = %d, message = %s\n", fd, message));
}

private void client_init()
{
    int err;
    // 创建一个 efun socket 连接
    S = socket_create(TCP, "read_callback", "close_callback");
    if (S < 0)
    {
        debug("【TCP客户端】socket_create error : " + socket_error(S));
    }
    else
    {
        debug("【TCP客户端】socket_create fd = " + S);
        // 启动一个 socket 连接
        err = socket_connect(S, ADDR, "read_callback", "write_callback");
        if (err < 0)
        {
            debug("【TCP客户端】socket_connect error : " + socket_error(err));
            socket_close(S);
        }
        else
        {
            debug("【TCP客户端】socket_connect SUCCESS!");
        }
    }
}

void create()
{
    R = this_player();
    client_init();
}

int main(object me, string arg)
{
    if (arg)
    {
        int err;
        err = socket_write(S, arg);
        if (err < 0)
        {
            debug("【TCP客户端】socket_write error : " + socket_error(err));
        }
        else
        {
            debug("【TCP客户端】消息已发送:" + arg);
        }
    }

    return 1;
}

直接运行 socket_tcp_client 会建立一个 socket 连接并连接到socket_tcp_server服务端,如果输入socket_tcp_client 内容,服务端会正常收到,如:socket_tcp_client hi,服务端收到并会响应"你好呀。",服务端也可以主动发送数据给客户端,如:socket_tcp_server 你好

提示:close_callback是在连接断开时自动调用的,比如在服务端和客户端正常通信后,你摧毁一端,另一端都会自动调用close_callback,所以我们一般在这里调用socket_close自动回收无效连接。

UDP模式

相对TCP模式,UDP模式要简陋很多,通信双方不需要先建立连接,严格上说没有服务端和客户端的区别,两边的代码实现很相似,都需要调用以下方法:socket_create建立socket,然后直接使用socket_write发送数据,只是服务端必须调用socket_bind绑定通信端口,而客户端不用,如果客户端也绑定端口,那就和服务端完全一样了。

Socket服务端

新建文件socket_udp_server.c,代码如下:

/**
 * sockets 相关测试,这里是UDP模式服务端
 */

#define UDP 2
#define PORT 6000

nosave int S;
nosave string Addr;
nosave object R;

// udp模式使用
void read_callback(int fd, mixed message, string addr)
{
    S = fd;
    Addr = addr;
    tell_object(R, sprintf("【UDP服务端】read_callback: fd = %d, from = %s, message = %s\n", fd, addr, message));
    // 发送消息给客户端,需要udp客户端绑定端口
    if (message == "hi")
    {
        socket_write(fd, "你好,客户端!", addr);
    }
}

private void server_init()
{
    int s, err;

    // 创建一个 efun socket 连接
    s = socket_create(UDP, "read_callback");
    if (s < 0)
    {
        debug("【UDP服务端】socket_create error: " + socket_error(s));
    }
    else
    {
        debug("【UDP服务端】socket_create: fd = " + s);
        // 绑定端口到 socket 连接
        err = socket_bind(s, PORT);
        if (err < 0)
        {
            debug("【UDP服务端】socket_bind error: " + socket_error(err));
            socket_close(s);
        }
        else
        {
            debug("【UDP服务端】socket_bind SUCCESS!");
            debug("【UDP服务端】UDP Socket 服务已启动 : " + socket_address(s, 1));
        }
    }
}

void create()
{
    R = this_player();
    server_init();
}

int main(object me, string arg)
{
    if (arg)
    {
        int err;

        err = socket_write(S, arg, Addr);
        if (err < 0)
        {
            debug("【UDP服务端】socket_write error: " + socket_error(err));
        }
        else
        {
            debug("【UDP服务端】消息已发送:" + arg);
        }
    }

    return 1;
}

直接运行socket_udp_server启动服务,通信端口还是6000,和前面的tcp服务一至。

Socket客户端

再建一个文件 socket_udp_client.c,代码如下:

/**
 * sockets DATAGRAM 模式客户端测试
 */

#define UDP 2
#define ADDR "127.0.0.1 6000"

nosave int S;
nosave object R;

void read_callback(int fd, mixed message, string addr)
{
    tell_object(R, sprintf("【UDP客户端】read_callback: fd = %d, from = %s, message = %s\n", fd, addr, message));
}

private void client_init()
{
    // 创建一个 efun socket 连接
    S = socket_create(UDP, "read_callback");
    // 如果不绑定端口,将使用随机端口连接服务器,但无法收到服务端返回的消息
    socket_bind(S, 52099);
    if (S < 0)
    {
        debug("【UDP客户端】socket_create error: " + socket_error(S));
    }
    else
    {
        debug("【UDP客户端】socket_create: fd = " + S);
    }
}

void create()
{
    R = this_player();
    client_init();
}

int main(object me, string arg)
{
    if (arg)
    {
        int err;
        // UDP 发送消息到服务器
        err = socket_write(S, arg, ADDR);
        if (err < 0)
        {
            debug("【UDP客户端】socket_write error: " + socket_error(err));
            // socket_close(S);
        }
        else
        {
            debug("【UDP客户端】消息已发送:" + arg);
        }
    }

    return 1;
}

这里我们实现了一个UDP模式的客户端,对比代码可以看到和服务端没什么差别,都很简单,直接发数据,对方收不收的到不管了。可以使用socket_udp_client hisocket_udp_server hi试试。

对比TCP和UDP模式,在socket_createsocket_write的使用上是有差别的:

  1. UDP模式不会建立通信连接,也不需要关闭连接,所以socket_create时不用传第三个参数指定关闭回调
  2. UDP模式没有连接,所以socket_write必须提供第三个参数做为通信地址

在回调方法上也有差别:

  1. read_callbackTCP模式服务端是socket_accept中指定的,客户端是socket_connect中指定的,UDP模式是socket_create中指定的。
  2. socket_create中的read_callback在TCP模式下无效,close_callback在UDP模式下无效。
  3. UDP模式下没有write_callbackclose_callback

下面,我们再使用 socket_status 查看所有 socket 连接信息,新建 socket_status.c,代码如下:

// 显示 socket_status
int main(object me, string arg)
{
#ifdef FLUFFOS
    if (arg)
    {
        print_r(socket_status(atoi(arg)));
    }
    else
    {
        print_r(socket_status());
    }
#endif
    return 1;
}

直接输入 socket_status 看看吧。

Socket连接转移

在以前的学习有我们学到一个函数exec可以转移当前玩家对象到其它对象,在Socket通信中也有类似的功能,可以转移当前服务对象到其它对象。这需要使用二个函数:socket_releasesocket_acquire

我们先补充 2 个 socket 函数的语法:

名称

socket_release() - 释放 socket 所有权给其它对象

语法

int socket_release( int socket, object ob, string release_callback );

描述

socket_release() 用来改变一个 socket 的所有权和控制权到其它对象。这在接受连接设置然后转换已接受的 socket 连接到其它对象以做后续处理的守护进程对象中很有用。

socket 所有权的转移涉及当前所属对象和转移目标对象之间的一次握手,当 socket_release() 调用时启动握手。socket_release() 执行必要的安全性和完整性检查后调用对象 `ob` 中的 `release_callback` 方法,这个方法用来通知准备接收 `socket` 所有权的对象 `ob`,对象 `ob` 有责任在 `release_callback` 方法中调用 socket_acquire() 外部函数。如果 socket_acquire() 被调用,握手完成,`socket` 的所有权成功的移交给对象 `ob`。对象 `ob` 也可以不调用 socket_acquire() 以拒绝接收 `socket` 的所有权,在这种情况下 `socket` 的所有权不会改变,而且 `socket` 的当前所有者必须决定如何响应拒绝。

如果 `socket` 的所有者转移成功,socket_release() 返回 EESUCCESS;如果对象 `ob` 拒绝授受,socket_release() 返回 EESOCKNOTRLSD。其它基于安全检查、错误的 socket 描述符等等错误码也可能返回。

名称

socket_acquire() - 获得 socket 的所有权

语法

int socket_acquire( int socket, string read_callback, string write_callback, string close_callback );

描述

socket_acquire() 在 socket_release() 把 `socket` 的所有权和控制权转移到新对象时被呼叫用来完成握手。 socket_release() 呼叫新拥有者对象中的 `release_callback` 方法用来通知对象它想移交 `socket` 的所有权。新对象有责任决定是否愿意接收 `socket`,如果愿意,就调用 socket_acquire() 外部函数完成转移,如果不愿意回调只是简单的回应而不完成握手。

在前一种情况下,握手完成,新对象成为 `socket` 的拥有者,读、写或关闭回调函数的参数都参照新对象中的函数,在新对象中的这些函数都需要定义以保证驱动程序知道调用哪些函数。后一种情况下 socket_release() 返回 EESOCKNOTRLSD 以保证原所有者可以做适合的处理。

socket_acquire() 应该仅在 `release_callback` 方法中调用并且只使用传递过来的这个 `socket`。

最后,再新建一个示例文件 socket_server.c,代码如下:

/**
 * sockets release 相关测试
 */

nosave int S;
nosave object R;

void create()
{
    R = this_player();
}

int main(object me, string arg)
{
    if (S && arg)
    {
        int err;
        err = socket_write(S, arg);
        if (err < 0)
        {
            debug("【Socket】socket_write error: " + socket_error(err));
        }
        else
        {
            debug("【Socket】消息已发送:" + arg);
        }
    }

    return 1;
}

void release_callback(int fd)
{
    S = fd;
    socket_acquire(fd, "read_callback", "write_callback", "close_callback");
}

void read_callback(int fd, mixed message)
{
    tell_object(R, sprintf("【Socket】read_callback: fd = %d, message = %s\n", fd, message));

    // 发送消息给客户端
    if (message == "hi")
    {
        socket_write(fd, "hello ^_^");
    }
}

void write_callback(int fd)
{
    tell_object(R, sprintf("【Socket】write_callback: fd = %d\n", fd));
}

void close_callback(int fd)
{
    tell_object(R, sprintf("【Socket】close_callback: fd = %d\n", fd));
    socket_close(fd);
}

我们输入 socket_tcp_server release 会把 socket_tcp_server 正在通信的 socket 连接转移到新对象 socket_server 上,输入 socket_tcp_client hi 试试会发现变成 socket_server 正常接收信息和响应请求,输入 socket_server hi 你会看到 socket_tcp_client 也能收到回复。

以上示例服务端和客户端都在一起,如果你还不能直观的感受,可以只实现客户端代码,把通信地址的IP 127.0.0.1 改为线上测试服 test.mud.ren 的IP(39.107.103.166),直接和在线服务器测试通信,在本机运行客户端指令,在服务器上运行服务端指令,然后查看效果。


Socket HTTP通信

在以上教程中我们了解了 Socket TCP/UDP 模式下的工作流程,在游戏开发中,UDP典型的案例是实现各MUD基本信息互联(名称、地址、在线人数),而TCP的基本用法是在游戏中使用FTP服务或HTTP服务。

其中MUD游戏通过socket的TCP模式模拟HTTP请求和网站API通信是非常实用的功能,能让MUD借助网站很容易的实现很多服务,比如AI聊天、短信验证、新闻资讯、数据查询等。

前面讲解了二种模式的工作流程,代码示例也给了服务端和客户端,新手看的可能有点吃力,这里我们要发HTTP请求,服务端就是网站服务器了,我们MUD游戏的定位就是客户端,我们要实现的代码也只是客户端部分,代码实现要简单很多。

HTTP请求

在实现通信前,我们先补充一下HTTP请求的基本知识,看看我们在上网时通过浏览器打开网站经历了哪些过程:

  1. 浏览器根据输入的网址的域名解析IP地址
  2. 浏览器与WEB服务器建立一个TCP连接
  3. 浏览器给WEB服务器发送一个HTTP请求
  4. 服务器端响应HTTP请求,浏览器得到HTML代码
  5. 关闭TCP连接,渲染代码并显示给用户

HTTP协议就是基于socket的tcp通信,只是对HTTP请求和响应有自己的规范,具体请求规范如下:

file

一个HTTP请求报文由请求行(request line)、请求头部(headers)、空行(blank line)和请求数据(request body)4个部分组成。

HTTP响应报文由状态行(status line)、相应头部(headers)、空行(blank line)和响应数据(response body)4个部分组成。

更具体内容建议自己上网搜索了解,因为相关教程非常多,这里不再细讲。

socket代码实现

从上面的内容我们已经清楚要和网站通信的流程了,唯一特别的地方是发HTTP请求的数据格式,具体代码流程如下:

  1. 使用socket_create创建一个socket连接准备和网站服务器通信用
  2. 使用socket_connect连接服务器
  3. socket_connectwrite_callback回调函数中使用socket_write发送HTTP请求
  4. socket_connectread_callback回调函数中接收服务器的响应数据 file http通信伪代码如下:
// 连接服务器
void connect()
{
    // 创建socket连接
    int fd = socket_create(STREAM, "receive_callback", "socket_shutdown");
    // 和网站服务器建立连接
    int ret = socket_connect(fd, addr, "response", "request");
}
// 发起http请求
void request(int fd)
{
    socket_write(fd, http);
}
// 接收http响应
void response(int fd, mixed result)
{
    // result 即为服务器返回的数据
}

用 socket 实现成语词典

这里我们演示使用 Socket 读取网页API实现一个成语词典的功能。

因为网站是服务端,我们要访问网站读数据,只需实现客户端模块即可。这里我们以阿凡达数据API为例,先看看API文档:成语词典API

这个API提供了3种功能:1.根据关键词查询相关成语,2. 根据ID查询成语的解释,3. 随机返回一条成语解释。如要要在MUD中读取这些API实现相应功能,该如何实现呢?

我们先上代码,新建 8.3.6.c,代码如下:

#include <ansi.h>

#define STREAM 1
#define EESUCCESS 1

nosave mapping status = ([]);
nosave object receiver;

void write_data(int fd)
{
    socket_write(fd, "GET " + status[fd]["path"] + " HTTP/1.1\nHost: " + status[fd]["host"] + "\n\r\n\r");
}

void receive_data(int fd, mixed result)
{
    string idiom;
    result = result[strsrch(result, "{")..];
    // debug_message(sprintf("%d || %O", strlen(result), result));
    if (strsrch(result, "total") > -1)
    {
        int total;
        result = json_decode(result);
        total = result["total"];
        idiom = HIY "和 " + status[fd]["keyword"] + " 相关的成语共有 " + total + " 条,索引如下:" NOR + "\n";
        result = result["result"];
        idiom += "----------------------------------------\n";
        foreach (mapping list in result)
        {
            idiom += HIC "成语:" + list["name"] + "\n索引:" + list["id"] + NOR + "\n";
        }
        idiom += "----------------------------------------\n";

        if (total > 20)
        {
            idiom += HIR "相关成语超过二十条,推荐使用更精确的关键词查询。" NOR "\n";
        }

        idiom += HIG "可以使用 `8.3.6 索引` 阅读指定成语。" NOR "\n";

        tell_object(receiver, idiom);
    }
    else
    {
        result = json_decode(result);
        if (result["error_code"])
        {
            tell_object(receiver, "没有找到相关索引成语。\n");
        }
        else
        {
            result = result["result"];
            idiom = "----------------------------------------\n";
            idiom += HIY "成语:" + result["name"] + NOR "\n";
            idiom += HIW "读音:" + result["spell"] + NOR + "\n";
            idiom += "----------------------------------------\n";
            idiom += HIC "解释:" + result["content"] + NOR + "\n";
            if (result["derivation"])
            {
                idiom += HIW "出处:" + result["derivation"] + NOR + "\n";
            }
            if (result["samples"])
            {
                idiom += HIW "示例:" + result["samples"] + NOR + "\n";
            }
            idiom += "----------------------------------------\n";
            tell_object(receiver, idiom);
        }
    }
}

void receive_callback(int fd, mixed result, string addr)
{
    // 此方法无效
}

void socket_shutdown(int fd)
{
    socket_close(fd);
}

int main(object me, string arg)
{
    int fd;
    int ret;
    string host = "api.avatardata.cn";
    string addr = "121.42.196.237 80";
    string key = "f47de96f0bd04223817aca17ca150f0e";
    string path;

    receiver = me;
    if (is_chinese(arg))
    {
        path = "/ChengYu/Search?key=" + key + "&keyWord=" + arg;
        msg("info", "$ME开始查找和 " + arg + " 相关的成语。", receiver);
    }
    else if (strlen(arg) == 36)
    {
        path = "/ChengYu/LookUp?key=" + key + "&id=" + arg;
        msg("info", "$ME开始查阅成语的详细解释。", receiver);
    }
    else
    {
        path = "/ChengYu/Random?key=" + key;
        msg("info", "$ME开始学习成语。", receiver);
    }

    fd = socket_create(STREAM, "receive_callback", "socket_shutdown");
    status[fd] = ([]);
    status[fd]["host"] = host;
    status[fd]["path"] = path;
    status[fd]["keyword"] = arg;

    ret = socket_connect(fd, addr, "receive_data", "write_data");
    if (ret != EESUCCESS)
    {
        tell_object(receiver, "服务器连接失败。\n");
        socket_close(fd);
    }

    return 1;
}

注意这里使用了simul_efun json_decode,这个函数在 json.c 中实现。分析以上代码,会发现,最核心的一点是模拟 HTTP request header,关于 HTTP request header 相关知识可以自己百度,这里只用记住怎么使用即可:

socket_write(fd, "GET " + status[fd]["path"] + " HTTP/1.1\nHost: " + status[fd]["host"] + "\n\r\n\r");

运行代码看看效果: file file

用 socket 实现二维码生成

最后,布置一个小作业,使用 socket 在游戏中实现生成二维码的功能,二维码生成可以使用 http://qrenco.de/

具体效果如图: file


附socket游戏功能实例:


以下内容已过期,最新fluffos支持TLS请求了。

目前socket模拟http的困难是不知道怎么模拟https请求,能力有限,仅可实现http和websocket通信,关于https的请求,建议使用中转解决,如nginx反向代理,基础示例:

server {
    listen 80;

    server_name mudren.com;

    location / {
        proxy_ssl_server_name on;
        proxy_pass https://bbs.mud.ren;
    }
}

使用CURL

前面介绍了使用socket实现http通信,相对比较麻烦,其实对http通信还有更推荐的方式,就是在MUD中使用external_start外部函数调用操作系统的curl指令来实现,具体使用参考以下内容:https://bbs.mud.ren/threads/244

京ICP备13031296号-4