44. 서버 테스트용 Bot 작성

이 챕터에서는 게임 클라이언트를 쓰지 않고 Bot 테스트를 할 수 있는 방법들을 소개합니다.

44.1. 방법1: 아이펀 엔진의 Bot 콤포넌트 이용

아이펀엔진은 테스트 목적으로 마치 클라이언트인 것처럼 접속해서 클라이언트-서버 세션을 흉내내는 콤포넌트를 제공합니다. 게임 서버 코드에 이 콤포넌트를 포함시켜 별도의 서버 인스턴스로 띄우게 되면 다수의 bot 처럼 동작시킬 수 있습니다.

Note

암호화, Session Reliability, Sequence Number Validation 은 지원하지 않습니다.

테스트 기능을 이용하기 위해서는 funapi/test/network.h 를 include 하고, 거기서 지원하는 funtest::Network 클래스와 funtest::Session 클래스를 활용합니다. (C# 의 경우 funtest.Network, funtest.Session)

44.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);
}

}

44.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;
}

}

44.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);
  }
}

44.2. 방법2: 클라이언트 플러그인을 이용

테스트 Bot을 만들 때 유니티나 언리얼에서 UI 없이 실행하는 것이 바람직하며, 클라이언트 플러그인에 이를 위한 테스트 코드가 포함되어있습니다.

44.2.1. C# Runtime 이용

C# Runtime 테스트는 터미널 에서 클라이언트를 실행하는 방법입니다. Unity 플러그인을 이용한 C# Runtime 용 테스트 코드는 plugin-test/src 폴더에 있습니다.

Important

Unity 플러그인을 사용해서 만들었지만 C# Runtime 용으로 만든거라 UnityEngine 라이브러리를 사용할 수 없어서 실제 유니티에서 사용하는 코드와는 Update 호출 등 약간 다른 부분이 있습니다. Unity로 만든 클라이언트 코드를 그대로 가져와서는 C# Runtime 테스트 봇을 만들 수 없음을 주의해 주세요.

src/client.cs 파일에는 클라이언트 하나에 대한 동작이 들어 있습니다. 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

44.2.2. C# VS 2015 이용

Visual Studio에서 유니티 플러그인을 이용해 봇 테스트를 할 수 있습니다. 테스트 코드는 vs2015 폴더에 있습니다. 테스트 샘플은 VS 2015에서 작성되었습니다.

44.2.2.1. 프로젝트 설정

vs2015 폴더의 funapi-plugin.sln 파일을 실행하면 2개의 프로젝트가 로드되는데 funapi-plugin 은 유니티의 플러그인 파일을 클래스 라이브러로 만든 프로젝트이고 funapi-plugin-tester 프로젝트는 .NET 프레임워크의 단위테스트 프로젝트입니다.

유니티 플러그인의 코드를 VS에서 실행하기 위해서는 Preprocessor를 하나 선언해야 합니다. funapi-plugin 프로젝트의 빌드 속성에서 조건부 컴파일 기호에 NO_UNITY 를 추가해주세요.

44.2.2.2. 단위 테스트 코드 작성

funapi-plugin-tester 폴더의 Tester.cs 파일에 샘플 테스트 코드가 있습니다. [TestMethod] 가 선언되어 있는 함수가 테스트 함수이며 이 함수의 이름이 테스트 탐색기에 표시되게 됩니다. 이 파일에서 함수를 수정하거나 추가해서 테스트 코드를 작성하거나 새로운 테스트 클래스를 만들어서 사용하셔도 됩니다.

Note

C# Runtime 테스트와 마찬가지로 UnityEngine 라이브러리는 사용할 수 없음에 주의해주세요.

44.2.2.3. 단위 테스트 빌드 및 실행

빌드 메뉴에서 솔루션 빌드를 실행합니다. 빌드에 성공하면 UnitTestExplorer가 나타나는데 이 창이 나타나지 않으면 메뉴에서 테스트 > 창 > 테스트 탐색기 를 선택합니다.

탐색기에 미리 만들어 둔 테스트 메서드들의 목록이 표시 됩니다. 모두 실행을 선택하면 각각의 단위 테스트 함수들을 순차적으로 실행하게 되며 원하는 메서드를 선택해서 하나만 실행할 수도 있습니다.

테스트할 때의 로그는 바로는 확인이 불가능하고 테스트가 완료되면 결과 창에 [출력] 이라는 단어가 표시되는데 이 단어를 클릭하면 별도의 로그 창이 뜨고 전체 로그를 확인할 수 있습니다.