45. 编写服务器测试用Bot

本章介绍了不使用游戏客户端的情况下,进行Bot测试的方法。

45.1. 方法1:利用iFun引擎的Bot组件

iFun引擎提供了用于进行测试的类似于客户端的组件,访问后会模拟客户端-服务器会话。将该组件包含在游戏服务器代码中,通过单独的服务器实例运行,即可像多数的bot那样运行。

Note

暂不支持加密、Session Reliability、Sequence Number Validation。

为了使用测试功能,可以包含 funapi/test/network.h , 使用其中支持的 funtest::Network 类和 funtest::Session 类。(对于C#,则为funtest.Network, funtest.Session)

45.1.1. funtest的Network类

Network类负责初始化,并支持如下方法。

 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
namespace funtest {

class Network {
 public:
  // session 이 열리고 닫힐 때 호출될 callback 과 io thread 의 수를
  // 설정합니다. 이 함수는 반드시 가장먼저 호출해야 합니다.
  static void Install(const SessionOpenedHandler &opened_handler,
                      const SessionClosedHandler &closed_handler,
                      size_t io_threads_size);

  // 필요할 경우 TCP 연결이 맺어지고, 끊길 때 호출될 callback 을 지정합니다.
  static void RegisterTcpTransportHandler(
      const TcpTransportAttachedHandler &tcp_attached_handler,
      const TcpTransportDetachedHandler &tcp_detached_handler);

  // JSON 타입의 메시지 핸들러를 등록합니다.
  static void Register(const string &message_type,
                       const MessageHandler &message_handler);

  // Protobuf 타입의 메시지 핸들러를 등록합니다.
  static void Register2(const string &message_type,
                        const MessageHandler2 &message_handler);
};

}
 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
namespace funtest

public static class Network
{
  // session 이 열리고 닫힐 때 호출될 callback 과 io thread 의 수를
  // 설정합니다. 이 함수는 반드시 가장먼저 호출해야 합니다.
  public static void Install(
      SessionOpenedHandler session_opened_handler,
      SessionClosedHandler session_closed_handler,
      uint io_threads_size);

  // 필요할 경우 TCP 연결이 맺어지고, 끊길 때 호출될 callback 을 지정합니다.
  public static void RegisterTcpTransportHandler(
      TcpTransportAttachedHandler tcp_attached_handler,
      TcpTransportDetachedHandler tcp_detached_handler);

  // JSON 타입의 메시지 핸들러를 등록합니다.
  public static void RegisterMessageHandler(
      string message_type, JsonMessageHandler message_handler);

  // Protobuf 타입의 메시지 핸들러를 등록합니다.
  public static void RegisterMessageHandler(
      string message_type, ProtobufMessageHandler message_handler);
}

}

45.1.2. funtest的Session类

Session类负责连接一个客户端。

 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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
namespace funtest {

class Session {
 public:
  DECLARE_CLASS_PTR(Session);

  enum State {
    kOpening,
    kOpened,
    kClosed
  };

  // 세션 객체를 생성합니다. 이 함수를 호출한다고 세션이 맺어지는 것은
  // 아닙니다. TCP 연결을 위해서는 아래 ConnectTcp(...) 함수를 이용해야됩니다.
  static Ptr<Session> Create();

  virtual ~Session();

  // TCP 로 서버에 접속합니다. 만약 첫 연결이면 세션이 열리게 됩니다.
  // 그렇지 않은 경우에는 이미 존재하던 세션을 계속 재사용하게 됩니다.
  virtual void ConnectTcp(const string &ip, uint16_t port,
                          EncodingScheme encoding) = 0;
  virtual void ConnectTcp(const boost::asio::ip::tcp::endpoint &endpoint,
                          EncodingScheme encoding) = 0;

  // UDP 로 서버에 접속합니다. 만약 첫 연결이면 세션이 열리게 됩니다.
  // 그렇지 않은 경우에는 이미 존재하던 세션을 계속 재사용하게 됩니다.
  virtual void ConnectUdp(const string &ip, uint16_t port,
                          EncodingScheme encoding) = 0;
  virtual void ConnectUdp(const boost::asio::ip::tcp::endpoint &endpoint,
                          EncodingScheme encoding) = 0;

  // 현재 버전에선 작동되지 않습니다.
  // HTTP 로 서버에 접속합니다. 만약 첫 연결이면 세션이 열리게 됩니다.
  virtual void ConnectHttp(const string &url, EncodingScheme encoding) = 0;
  virtual void ConnectHttp(const string &ip, uint16_t port,
                           EncodingScheme encoding) = 0;
  virtual void ConnectHttp(const boost::asio::ip::tcp::endpoint &endpoint,
                           EncodingScheme encoding) = 0;

  // 세션 아이디를 반환합니다.
  virtual const SessionId &id() const = 0;
  // 세션 상태를 반환합니다.
  virtual State state() const = 0;

  // Transport 의 연결 상태를 확인합니다.
  virtual bool IsTransportAttached() const = 0;
  virtual bool IsTransportAttached(TransportProtocol protocol) const = 0;

  // 서버로 JSON 메시지를 보냅니다.
  virtual void SendMessage(const string &message_type, const Json &message,
                           TransportProtocol protocol) = 0;

  virtual void SendMessage(const string &message_type,
                           const Ptr<FunMessage> &message,
                           TransportProtocol protocol) = 0;

  // Transport 연결을 닫습니다.
  // 새로운 트랜스포트가 붙어서 세션을 계속 쓸 수 있기 때문에 세션은 계속 유효한 상태로 남습니다.
  virtual void CloseTransport() = 0;
  virtual void CloseTransport(TransportProtocol protocol) = 0;

  // 세션을 닫습니다.
  virtual void Close() = 0;

  // 세션 별 Context 를 다루는 인터페이스입니다. 이 컨텍스트는 세션이 살아있는 동안 유효합니다.
  // 예)
  //   boost::mutex::scoped_lock lock(*session);  // 필요할 경우 lock 으로 보호
  //   if (session->GetContext()["my_state"] == ...) {
  //     ...
  //     session->GetContext()["my_state"] = ...;
  //   }
  virtual void SetContext(const Json &ctxt) = 0;
  virtual Json &GetContext() = 0;
  virtual const Json &GetContext() const = 0;
  virtual boost::mutex &GetContextMutex() const = 0;
  virtual operator boost::mutex &() const = 0;
};

}
 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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
namespace funtest {

public class Session
{
  public enum SessionState
  {
    kOpening = 0,
    kOpened,
    kClosed
  }

  public enum EncodingScheme
  {
    kUnknownEncoding = 0,
    kJsonEncoding,
    kProtobufEncoding
  }

  // 세션 객체를 생성합니다. 이 함수를 호출한다고 세션이 맺어지는 것은
  // 아닙니다. TCP 연결을 위해서는 아래 ConnectTcp(...) 함수를 이용해야됩니다.
  public Session();

  ~Session();

  // TCP 로 서버에 접속합니다. 만약 첫 연결이면 세션이 열리게 됩니다.
  // 그렇지 않은 경우에는 이미 존재하던 세션을 계속 재사용하게 됩니다.
  public void ConnectTcp(string ip, ushort port, EncodingScheme encoding);
  public void ConnectTcp(System.Net.IPEndPoint address,
                         EncodingScheme encoding);

  // UDP 로 서버에 접속합니다. 만약 첫 연결이면 세션이 열리게 됩니다.
  // 그렇지 않은 경우에는 이미 존재하던 세션을 계속 재사용하게 됩니다.
  public void ConnectUdp(string ip, ushort port, EncodingScheme encoding);
  public void ConnectUdp(System.Net.IPEndPoint address,
                         EncodingScheme encoding);

   // 현재 버전에선 작동되지 않습니다.
   // HTTP 로 서버에 접속합니다. 만약 첫 연결이면 세션이 열리게 됩니다.
  public void ConnectHttp(string ip, ushort port, EncodingScheme encoding);
  public void ConnectHttp(System.Net.IPEndPoint address,
                          EncodingScheme encoding);
  public void ConnectHttp(string url, EncodingScheme encoding);

  // 세션 아이디를 반환합니다.
  public System.Guid Id;
  // 세션 상태를 반환합니다.
  public SessionState State;

  // Transport 의 연결 상태를 확인합니다.
  public bool IsTransportAttached();
  public bool IsTransportAttached(funapi.Session.Transport transport);

  // 서버로 JSON 메시지를 보냅니다.
  public void SendMessage(string message_type, JObject message,
                          funapi.Session.Transport transport);

  // 서버로 Protocol buffer 메시지를 보냅니다.
  public void SendMessage(string message_type, FunMessage message,
                          funapi.Session.Transport transport);

  // Transport 연결을 닫습니다.
  // 새로운 트랜스포트가 붙어서 세션을 계속 쓸 수 있기 때문에 세션은 계속 유효한 상태로 남습니다.
  public void CloseTransport();
  public void CloseTransport(funapi.Session.Transport transport);

  // 세션을 닫습니다.
  public void Close();

  // 세션 별 Context 를 다루는 인터페이스입니다. 이 컨텍스트는 세션이 살아있는 동안 유효합니다.
  // 예)
  //   lock (session) // 필요할 경우 lock 으로 보호
  //   {
  //     if (session.Context ["my_state"] == ...) {
  //       ...
  //       session.Context ["my_state"] = ...;
  //     }
  //   }
  public JObject Context;
}

}

45.1.3. 示例: 300个Bot各传输5000次echo

 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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
#include <funapi/test/network.h>

static bool Start() {
  funtest::Network::Install(OnSessionOpened, OnSessionClosed, 4);

  funtest::Network::RegisterTcpTransportHandler(OnTcpAttached, OnTcpDetached);

  funtest::Network::Register2("pbuf_echo", OnEcho);

  // Created 300 clients.
  for (size_t i = 0; i < 300; ++i) {
    Ptr<funtest::Session> session = funtest::Session::Create();
    session->GetContext()["count"] = 5000;
    session->ConnectTcp("127.0.0.1", 8013, kProtobufEncoding);
  }

  return true;
}


void OnSessionOpened(const Ptr<funtest::Session> &session) {
  LOG(INFO) << "[test_client] session created: sid=" << session->id();

  string message = RandomGenerator::GenerateAlphanumeric(5, 50);

  session->GetContext()["sent_message"] = message;

  Ptr<FunMessage> msg(new FunMessage);
  PbufEchoMessage *echo_msg = msg->MutableExtension(pbuf_echo);
  echo_msg->set_msg(message);
  session->SendMessage("pbuf_echo", msg, kTcp);
};


void OnSessionClosed(const Ptr<funtest::Session> &session, SessionCloseReason reason) {
  LOG(INFO) << "[test_client] session closed: sid=" << session->id();
};


void OnTcpAttached(const Ptr<funtest::Session> &session, bool connected) {
  if (not connected) {
    LOG(ERROR) << "[test_client] failed to connect to the server";
  }
};


void OnTcpDetached(const Ptr<funtest::Session> &session) {
  LOG(INFO) << "[test_client] tcp transport disconnected: sid=" << session->id();
};


void OnEcho(const Ptr<funtest::Session> &session, const Ptr<FunMessage> &msg) {
  BOOST_ASSERT(msg->HasExtension(pbuf_echo));

  const PbufEchoMessage &echo_msg = msg->GetExtension(pbuf_echo);
  const string &message = echo_msg.msg();

  string sent_message = session->GetContext()["sent_message"].GetString();
  BOOST_ASSERT(sent_message == message);

  Json &count_ctxt = session->GetContext()["count"];
  count_ctxt.SetInteger(count_ctxt.GetInteger() - 1);
  if (count_ctxt.GetInteger() == 0) {
    LOG(INFO) << "[test_client] complete: sid=" << session->id();
    return;
  }

  {
    string message = RandomGenerator::GenerateAlphanumeric(5, 50);
    session->GetContext()["sent_message"] = message;

    Ptr<FunMessage> msg(new FunMessage);
    PbufEchoMessage *echo_msg = msg->MutableExtension(pbuf_echo);
    echo_msg->set_msg(message);
    session->SendMessage("pbuf_echo", msg, kTcp);
  }
};
  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
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
using funapi;

public static void Start()
{
  funapi.funtest.Network.Install (
      new funapi.funtest.Network.SessionOpenedHandler (OnSessionOpened),
      new funapi.funtest.Network.SessionClosedHandler (OnSessionClosed),
      4);

  funapi.funtest.Network.RegisterTcpTransportHandler(OnTcpAttached,
                                                     OnTcpDetached);

  funapi.funtest.Network.RegisterMessageHandler ("pbuf_echo", OnEcho));

  for (int i = 0; i < 300; ++i)
  {
    funapi.funtest.Session session = new funapi.funtest.Session ();
    session.Context["count"] = 5000;
    session.ConnectTcp (
        "127.0.0.1",
        8013,
        funapi.funtest.Session.EncodingScheme.kProtobufEncoding);
  }
}


public static void OnSessionOpened(funapi.funtest.Session session)
{
  Log.Info ("[test client] Session opened: session_id={0}", session.Id);

  string message = RandomGenerator.GenerateAlphanumeric(5, 50);

  session.Context ["sent_message"] = message;

  FunMessage msg = new FunMessage();
  PbufEchoMessage echo_msg = new PbufEchoMessage();
  echo_msg.msg = message;
  msg.AppendExtension_pbuf_echo (echo_msg);

  session.SendMessage("pbuf_echo", msg, funapi.Session.Transport.kTcp);
}


public static void OnSessionClosed(funapi.funtest.Session session,
                                   Session.CloseReason reason)
{
  Log.Info ("[test client] Session closed: session_id={0}, reason={1}",
            session.Id, reason);
}


public static void OnTcpAttached(funapi.funtest.Session session,
                   bool connected)
{
  if (!connected) {
    Log.Error ("[test_client] failed to connect to the server");
  }
}


public static void OnTcpDetached(funapi.funtest.Session session)
{
  Log.Info ("[test_client] tcp transport disconnected: sid={0}",
            session.Id);
}


public static void OnEcho(funapi.funtest.Session session,
                          FunMessage msg) {
  PbufEchoMessage echo;
  if (!msg.TryGetExtension_pbuf_echo (out echo))
  {
    Log.Error ("OnEchoPbuf: Wrong message.");
    return;
  }
  string message = echo.msg;
  Log.Info ("client recv echo.msg. {0}", echo.msg);

  string sent_message = (string) session.Context ["sent_message"];
  Log.Assert (sent_message == message);

  int count  = (int) session.Context ["count"];
  session.Context ["count"] = count - 1;

  if ((int) session.Context ["count"] == 0)
  {
    Log.Info("[test_client] complete: sid={0}",
             session.Id.ToString());
    return;
  }

  {
    string message2 = RandomGenerator.GenerateAlphanumeric(5, 50);
    session.Context ["sent_message"] = message2;

    FunMessage fun_message = new FunMessage ();
    PbufEchoMessage echo2 = new PbufEchoMessage();
    echo_msg.msg = message2;
    fun_message.AppendExtension_pbuf_echo(echo2);
    session.SendMessage("pbuf_echo", fun_message,
                        funapi.Session.Transport.kTcp);
  }
}

45.2. 方法2:利用客户端插件

创建测试Bot时,最好在Unity或Unreal中直接运行,无需UI。客户端插件中已包含相关的测试代码。

45.2.1. 使用C# Runtime

C# Runtime测试是一种在 终端 上运行客户端的方法。利用Unity插件进行C# Runtime 测试的代码位于 plugin-test/src 文件夹中。

Important

虽然是通过Unity创建,但它的创建是为了进行C# Runtime测试,所以无法使用UnityEngine库, 因此它在调用Update等方面,与实际Unity中所使用的代码稍有不同。注意,无法直接导入已通过Unity创建好的 客户端代码来创建C# Runtime测试Bot。

src/client.cs 文件中包含了对一个客户端进行的action。Client对于含有一个 FunapiSession对象的协议,TCP、UDP、HTTP三者均可使用。 Client负责收发用于测试的echo消息。

 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
class Client
{
    public Client (int id, string server_ip)
    {
        // 클라이언트 고유 아이디
        id_ = id;

        // 서버 주소
        server_ip_ = server_ip;
    }

    public void Connect (bool session_reliability)
    {
        // FunapiSession 객체 생성 및 콜백 등록
        session_ = FunapiSession.Create(server_ip_, session_reliability);
        session_.SessionEventCallback += onSessionEvent;
        session_.TransportEventCallback += onTransportEvent;
        session_.TransportErrorCallback += onTransportError;
        session_.ReceivedMessageCallback += onReceivedMessage;

        ...

        // 서버에 연결
        ushort port = getPort(protocols[i], encodings[i]);
        session_.Connect(protocols[i], encodings[i], port, option);
    }

    ...
}

src/main.cs 文件是执行测试的文件。它会创建虚拟客户端,负责连接、结束、 收发消息。

 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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
class TesterMain
{
    // 더미 클라이언트 개수
    const int kClientMax = 3;
    // 서버 주소
    const string kServerIp = "127.0.0.1";

    // 클라이언트 목록
    List<Client> list_ = new List<Client>();

    // Main 함수
    public static void Main ()
    {
        // 테스트 시작
        new TesterMain().Start();
    }

    void start ()
    {
        // 더미 클라이언트를 생성합니다.
        for (int i = 0; i < kClientMax; ++i)
        {
            Client client = new Client(i, kServerIp);
            list_.Add(client);
        }

        // 업데이트를 위한 스레드를 생성합니다.
        Thread t = new Thread(new ThreadStart(onUpdate));
        t.Start();

        // Connect 테스트
        testConnect();

        // 시작/종료 테스트
        testStartStop();

        // 메시지 테스트
        testSendReceive();

        // 스레드 종료
        t.Abort();
    }

    void onUpdate ()
    {
        // 스레드가 종료될 때까지 33 milliseconds 간격으로 업데이트를 수행합니다.
        while (true)
        {
            foreach (Client c in list_)
            {
                c.Update();
            }

            Thread.Sleep(33);
        }
    }

    void connect ()
    {
        // 클라이언트 연결을 시작합니다.
        foreach (Client c in list_)
        {
            c.Connect(reliableSession);
        }

        // 모든 클라이언트의 연결이 완료될 때까지 대기합니다.
        while (waitForConnect())
        {
            Thread.Sleep(100);
        }
    }

    ...
}

Note

所有代码位于 plugin-test/src 文件夹中。如需更改路径或添加文件,修改 plugin-test/makefile 文件即可。

若已实现测试代码,后续只要构建并运行即可。在终端上移动至 makefile 所在文件夹后, 通过 make 命令语句构建。

$ make
$ mono tester.exe

45.2.2. 使用C# VS 2015

可在Visual Studio中运用Unity插件进行Bot测试。测试代码位于 vs2015 文件夹中。测试示例已在VS 2015中编写。

45.2.2.1. 设置项目

运行 vs2015 文件夹下的 funapi-plugin.sln 文件时,会加载2个项目,其中 funapi-plugin 是将Unity的插件文件变成类库的项目, funapi-plugin-tester 是 .NET框架的单元测试项目。

为了在VS中运行Unity插件的代码,须要先声明一个Preprocessor。 在 funapi-plugin 项目的Build属性中,为附条件编译符号添加 NO_UNITY

45.2.2.2. 编写单元测试代码

funapi-plugin-tester 文件夹的 Tester.cs 文件中有示例测试代码。 已声明 [TestMethod] 的函数是测试函数,该函数的名字会显示在测试资源管理器中。 可以通过在该文件中修改或添加函数来编写测试代码,也可以创建新的测试类 来使用。

Note

注意,它和C# Runtime测试一样,也无法使用UnityEngine库。

45.2.2.3. 构建及执行单元测试

在Build菜单中执行solution build。Build成功后,将显示UnitTestExplorer,如该窗口 未显示,可在菜单中选择 测试>窗口>测试资源管理器

此时将显示已在资源管理中创建的测试方法列表。选择全部执行,将依次执行各个单元测试 函数,也可仅选择任意一个方法执行。

无法在测试时直接查看日志,待测试完毕后,结果窗口上会显示 [输出] 字样,点击后将显示单独的日志窗口,从中查看全部日志。