45. 서버 테스트용 Bot 작성

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

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

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

Note

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

테스트 기능을 이용하기 위해서는 funapi/test/network.h 를 include 하고, 거기서 지원하는 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을 만들 때 유니티나 언리얼에서 UI 없이 실행하는 것이 바람직하며, 클라이언트 플러그인에 이를 위한 테스트 코드가 포함되어있습니다.

45.2.1. C# Mono 이용

유니티 플러그인을 사용한 봇 테스트는 터미널 에서 Mono의 C# 컴파일러를 이용해서 클라이언트를 컴파일하고 실행하는 방법입니다. 샘플 코드는 csharp-samples 폴더에 있습니다.

Important

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

src/client.cs 파일에는 클라이언트 하나에 대한 동작이 들어 있습니다. 각각의 Client 는 TCP 연결을 맺고 테스트용 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
class Client
{
    public Client (int id)
    {
        // 클라이언트 고유 아이디
        id_ = id;
    }

    public void Connect (TransportProtocol protocol, FunEncoding encoding)
    {
        if (session == null)
        {
            SessionOption option = new SessionOption();
            option.sessionReliability = false;
            option.sendSessionIdOnlyOnce = false;

            session = FunapiSession.Create(address, option);
            session.SessionEventCallback += onSessionEvent;
            session.TransportEventCallback += onTransportEvent;
            session.TransportErrorCallback += onTransportError;
            session.ReceivedMessageCallback += onReceivedMessage;
        }

        session.Connect(protocol, encoding, getPort(protocol, encoding));
    }

    ...
}

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
class TesterMain
{
    // 더미 클라이언트 개수
    const int kClientMax = 100;
    // 서버 주소
    const string kServerIp = "127.0.0.1";

    // Main 함수
    public static void Main ()
    {
        Client.address = kServerIp;

        // 테스트 시작
        new TesterMain().Start();
    }

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

        // TCP + Json 타입으로 서버와 연결합니다.
        foreach (Client c in list)
        {
            c.Connect(TransportProtocol.kTcp, FunEncoding.kJson);
            Thread.Sleep(10);
        }

        ...

        // 플러그인 업데이터 종료
        FunapiMono.Stop();

        // 테스트 프로세스 종료
        Process.GetCurrentProcess().Kill();
    }

    ...

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

Note

전체 코드는 csharp-samples/src 폴더에 있습니다. 경로 수정이나 파일 추가가 필요하다면

csharp-samples/makefile 파일을 수정하면 됩니다.

테스트 코드 구현이 끝났다면 이제 빌드하고 실행하기만 하면 됩니다. 터미널에서 makefile 이 있는 폴더로 이동한 후에 make 명령어로 빌드합니다.

$ make
$ mono tester.exe

45.2.2. C# VS 2015 이용

Visual Studio에서 유니티 플러그인을 이용해 봇 테스트를 할 수 있습니다. 테스트 코드는 csharp-samples/vs2015 폴더에 있습니다. 샘플 프로젝트는 VS 2015 Community 버전에서 작성되었습니다.

45.2.2.1. 프로젝트 설정

vs2015 폴더의 funapi-plugin-unity.sln 파일을 실행하면 funapi-plugin-unity 프로젝트가 로드되는데 포함되어 있는 모든 파일은 상위 폴더의 파일을 참조로 링크되어 있습니다. 샘플 코드도 상위 src 폴더 내의 파일을 그대로 사용하므로 샘플 코드의 설명은 위의 C# Mono 이용 설명을 참고해주세요.

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

Note

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

45.2.2.2. 빌드 및 실행

빌드 후 실행하면 콘솔창으로 로그를 확인 할 수 있는데 테스트가 끝나면 창이 자동으로 닫힙니다. 로그를 좀 더 살펴보고 싶을 경우 Ctrl + F5 로 실행하면 테스트가 끝나도 창이 닫히지 않으니 참고해 주세요. 콘솔의 경우 버퍼 크기가 정해져 있어 최근 로그만 남습니다. 버퍼 크기는 콘솔의 속성창에서 조정할 수 있습니다.