気まま研究所ブログ

ITとバイク、思ったことをてきとーに書きます。

Savannah Manager Telnetサーバへ接続をする

f:id:AonaSuzutsuki:20170207121207p:plain こんにちは。
ブログではほとんどSavannah Managerについて触れてきませんでしたが、ちょっとずつ触れていきます。
今回から数回にかけてSavannah Managerの技術的情報を公開していきます。
一応公開する情報はSavannah Manager Libでも実装しているのでそちらを使ってもらっても構いません。(ドキュメントはもうちょっとかかりそう)
後継期待します…!

で、今回は第一弾にTelnet接続から。
実は、Savannah Managerはすべての機能がTelnetに依存しています。
初版もここから実装していきました。


開発環境

  1. Windows 10 Pro x64
  2. .Net Framework 4.6.2
  3. 7Days to Die Alpha 15.1(b16)

1、サンプルコードで.Net Framework 4.5以上が必須になるのでWindows Vista以上が必要です。
2、上述の通り。


Telnetってなんぞ

TelnetとはTCP/IPを用いてリモート上の端末を遠隔操作するためのプロトコルの一つです。
具体的にはテキスト(コマンドなど)を送信し、サーバはそのテキストを受け取り処理します。
ターミナルで行う操作をリモートでもできるようにするという感覚で問題ないです。


7dtdのTelnet

7dtdではTelnetが用いられてはいますが、中身はテキストだけをやり取りします。
通常Telnetは接続時にネゴシエーションにてオプションの設定を行うクライアントやサーバが多いですが、7dtdではそのような処理はありません。
ですので、本当にただテキストを送ることだけを考えれば問題ないです。
また、Windows標準Telnetクライアントではキーボード入力毎に入力情報を送信しますが、文字列の最後に改行コードを入れて送信すれば正しく動作します。


実際に繋いてみよう

では、実際にコードで繋げてみましょう。
具体的にはSystem.Net.Sockets.Socketクラスを用いてTCP/IPプロトコルの接続を行います。
.NetにはTCP/IPプロトコルに特化したTcpClientクラスとNetworkStreamクラスを使用する方法もありますが、何故かうまく動作しないバージョンがありましたのでSocketでやったほうが恐らく安全です。
一応使えなくないことはないんですが、以下ではSocketで統一します。

// ソケットをTCPプロトコルで生成
// AddressFamily.InterNetwork: IPv4で接続(IPv6の場合はInterNetworkV6)
// SocketType.Stream: TCPを使うので双方向のバイトストリームをサポートするStream
// ProtocolType.Tcp: TCPなのでTcp
Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
// ローカルホストの8081ポートのサーバへ接続
socket.Connect("127.0.0.1", 8081);
            
bool endRead = false; // 読み込み用スレッドの停止用

// ログ読み取り用スレッド
Task tasks = Task.Factory.StartNew(() =>
{
    // exitが入力されたら終了
    while (!endRead)
    {
        if (socket.Available > 0)
        {
            // 受信バッファー分の配列を用意
            byte[] recBytes = new byte[socket.ReceiveBufferSize];
            // 受信データの取得
            socket.Receive(recBytes, SocketFlags.None);
            // 受信データを文字列に変換
            string recStr = System.Text.Encoding.UTF8.GetString(recBytes).TrimEnd('\0');
            // 受信文字列データを出力
            if (!string.IsNullOrEmpty(recStr))
            {
                Console.Write(recStr);
                Console.Write("> ");
            }
        }
        // 速すぎるとCPUをバカ食いするので
        System.Threading.Thread.Sleep(100);
    }
});
            
// 改行コード
byte[] newLine = { 0x0D, 0x0A };
while (true)
{
    // 文字入力を受ける
    string inputStr = Console.ReadLine();
    // exitの時は読み取りスレッド停止とループから抜ける
    if (inputStr.Equals("exit"))
    {
        endRead = true;
        break;
    }

    // 入力文字を送信
    byte[] inputBytes = System.Text.Encoding.UTF8.GetBytes(inputStr);
    socket.Send(inputBytes, inputBytes.Length, SocketFlags.None);
    // 改行コードを送信
    socket.Send(newLine, newLine.Length, SocketFlags.None);
}

// 受信スレッドを待機
tasks.Wait();

// 接続終了処理
socket.Shutdown(SocketShutdown.Both);
socket.Disconnect(false);
socket.Dispose();

ざっとTelnet処理を書いてみました。
これを実行すると入力と読み取りを続けるコンソールアプリケーション型簡易Telnetクライアントが出来上がりです。
ただし、例外処理などは省いているので例外などが発生すると止まります。
全文はgistで公開しています。 -> Simple telnet client code for 7Days to Die. · GitHub

ちょっとややこしいので分解して見ていきましょう。
まず、ソケット生成から

// ソケットをTCPプロトコルで生成
// AddressFamily.InterNetwork: IPv4で接続(IPv6の場合はInterNetworkV6)
// SocketType.Stream: TCPを使うので双方向のバイトストリームをサポートするStream
// ProtocolType.Tcp: TCPなのでTcp
Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);

ここではSocketクラスを用いてIPv4を用いたTCPプロトコルをサポートするインスタンスを生成します。
とりあえずこうすればTCPプロトコルを用いて通信ができるという認識で問題ないです。
ただ、IPv6を使用する場合は第一引数のAddressFamilyをInterNetworkV6に変更してください。

次に接続部分です。

// ローカルホストの8081ポートのサーバへ接続
socket.Connect("127.0.0.1", 8081);

ここはそれほど説明する箇所はありません。
ただ単に127.0.0.1:8081へと接続します。

読み取り部分です。

bool endRead = false; // 読み込み用スレッドの停止用

// ログ読み取り用スレッド
Task tasks = Task.Factory.StartNew(() =>
{
    // exitが入力されたら終了
    while (!endRead)
    {
        // 受信処理

        // 速すぎるとCPUをバカ食いするので
        System.Threading.Thread.Sleep(100);
    }
});

まず、読み取り部分は別のスレッドで動作させます。
後述する入力待ちでメインスレッドを止めてしまうため、常に受信データを処理するには別のスレッドで処理しなければいけません。
で、肝心の読み取りですが、一度だけ走らせても意味が無いので無限ループでずっと受信を待機します。
ただ、ずっと無限にループさせてしまうと終わらないのでどこかで終了を指定しなければいけませんね。
そこで後述する入力の際に特定のコマンドが来れば受信スレッドを止めるように指示します。

さて、肝心の受信処理を見ていきましょう。

if (socket.Available > 0)
{
    // 受信バッファー分の配列を用意
    byte[] recBytes = new byte[socket.ReceiveBufferSize];
    // 受信データの取得
    socket.Receive(recBytes, SocketFlags.None);
    // 受信データを文字列に変換
    string recStr = System.Text.Encoding.UTF8.GetString(recBytes).TrimEnd('\0');
    // 受信文字列データを出力
    if (!string.IsNullOrEmpty(recStr))
    {
        Console.Write(recStr);
        Console.Write("> ");
    }
}

まず、Socket.Availableプロパティにて読み取り可能なデータが有るかどうかを確認します。
あれば受信用バッファーサイズ分だけbyte配列を作成し、そこにReceiveメソッドで受信データを突っ込みます。
あとはbyte配列をUTF-8のGetStringメソッドに通せば文字列が取得できます。
ただ、この時に余分なバイトは0埋めされているのでnull文字として入ってしまいます。
邪魔になるのでTrimEndメソッドで排除してしまいましょう。
あとは出力するなり返すなりするだけです。

次は入力及び、送信部分ですね。

// 改行コード
byte[] newLine = { 0x0D, 0x0A };
while (true)
{
    // 文字入力を受ける
    string inputStr = Console.ReadLine();
    // exitの時は読み取りスレッド停止とループから抜ける
    if (inputStr.Equals("exit"))
    {
        endRead = true;
        break;
    }

    // 入力文字を送信
    byte[] inputBytes = System.Text.Encoding.UTF8.GetBytes(inputStr);
    socket.Send(inputBytes, inputBytes.Length, SocketFlags.None);
    // 改行コードを送信
    socket.Send(newLine, newLine.Length, SocketFlags.None);
}

ループや文字取得は基本だと思うので置いといて、if文の中身ですね。
ここではexitと入力された場合に受信スレッドを停止するようにします。
そうしないと永遠に受信スレッドは走り続けて画面はないのに裏で動いているなんてことになりかねません。

で、exit出ない場合はUTF-8でbyte配列に変換します。
それをSocket.Sendメソッドに乗せるだけで送信されます、簡単ですね。
そして、Telnetは改行コードが来た時にコマンドを終えたと認識するので最後に改行コードを送っておきます。
WindowsではCRLF(0x0D 0x0A)を改行コードとするのでこれを送っておけばいいでしょう。

あとは送信した結果がサーバから送られ、受信スレッドで処理されてコンソールに表示されるはずです。


最後に、終了処理について

// 受信スレッドを待機
tasks.Wait();

// 接続終了処理
socket.Shutdown(SocketShutdown.Both);
socket.Disconnect(false);
socket.Dispose();

受信用のスレッドが終わるのを待ってから接続を切ります。
受信スレッドが終わる前に接続を切ってしまうと接続が切れているのに受信しようとしているということで例外が発生する場合があります。

これでとりあえずTelnetでやり取りができるようになりました。
あとはこれをそのまま使うもよし、適当に弄ってライブラリにするもよし、これを見てくださってる方々で好き放題弄ってやってください。


Socket.Connectedについて

実際に活用する時の補足ですが、SocketクラスにはConnectedプロパティがあります。
ドキュメントや名称からすると接続しているかどうかを取得するのに使えそうなんですが、実は落とし穴だったりします。
理由はわからないんですが、これだけだと接続が途切れているのにConnectedプロパティはtrueを返す場合があります。
これでは正しく終了しているかわからないので以下のように判定すると上手くとれます。

public bool Connected
{
    get
    {
        if (socket == null)
        {
            return false;
        }
        else
        {
            bool isPoll = socket.Poll(0, SelectMode.SelectRead);
            bool isAvailable = (socket.Available == 0);
            if (isPoll & isAvailable)
            {
                return false;
            }
            return true;
        }
    }
 }

具体的にはPollメソッドにてSocketが読み込みを行うかどうか、Availableプロパティにて読み取り可能なデータが有るかどうかを取得し、それらをAND演算することで接続状態を取得します。
また、Pollメソッドは接続が途切れた際もtrueを返します。

これらを利用すると

  1. アイドリングのときはisPollがfalse、isAvailableがtrueになり、接続していると見なす。
  2. 読み取りが行われる時はアイドリングの反転となり、これもまた接続していると見なす。
  3. 接続が途切れるとどちらもtrueとなるため、切断されたと見なす。
  4. そもそもこちらから切った時はnullにしておいてそれを取れば一番速い、それでもって切断されていると見なす。

ということができます。
こちらから接続、切断を全て管理する場合は4だけでも事足りるんですが、相手から切断された場合が問題で、1,2,3のステップが必要となります。


公開ライブラリについて

最後に公開中のライブラリについて言い訳しておきます。
このライブラリなんですが、Savannah Managerの後に作ったやつなのでめちゃくちゃクソコードが多いです。
一応ちょっとずつ詰めてはいるんですが、時間的理由でなかなか進んでません。
数日後は多忙からある程度開放されるんでそこで詰めてプルします。