C言語でTCPソケットを使ってechoサーバーを実装してみた

Sep 3, 2017 18:24 · 3424 words · 7 minutes read #linux

前置き

普段アプリケーションを開発する際に、先人たちが開発したフレームワークのおかげで、 特に通信などを気にかけたことがなく、ぬるま湯エンジニアリングしかしてこなかったことに 最近焦りを覚え始めたのが今回の投稿のきっかけになっています。 1年目から先輩に勧められてたのにずっと先延ばしにしてただけ(スミマセン)

理論ならインターネットに腐るほど転がっているのですが、 実際に手を動かしたほうが良い気がしたので、学習がてらC言語を用いて実装してみました。

アプリケーションがどのように他のアプリケーションと通信を行うのか?

TCP/IP などのプロトコルを使用してコンピュータネットワークが形成されており、 そのコンピュータネットワーク上でアプリケーションが通信を行うために ソケット が用いられます。

ソケットとは?

  • アプリケーションがデータを送受信するための仕組みを抽象化したもの(API)
  • ソケットを介して他のアプリケーションとの通信が可能になる
  • ソケットにはポート番号が割り当てられる(アプリケーションがそれを利用する)
  • ソケットを利用することで具体的な通信手段や手順の詳細を知る必要なく通信が可能になる
  • 同じコンピュータ上で実行中のプロセスとの通信に使うソケットを「UNIXドメインソケット」という
  • 別のコンピュータ上で実行中のプロセスとの通信に使うソケットを「TCPソケット」という
ネットワークやプログラミングの分野では、実行中のプログラム間でデータの送受信を行うための
標準的なプログラミングインターフェース(API)の一つにソケットと呼ばれる仕組みがある。
UNIX系OSの一種であるBSDで最初に用いられたことからBSDソケットとも呼ばれる。
通信を行うプログラムが利用する標準のインターフェースとしてほとんどのOSに実装されている。
ソフトウェア開発者にとっては、ソケットの仕組みに則ってプログラムを記述すれば、
具体的な通信手段や手順の詳細を知らなくてもよく、通信相手の種類や仕様を調べて相手に合わせて通信を行うコードを記述する必要もない。

引用:ソケットとは? - IT用語辞典

echo サーバーとは?

クライアントから文字列を受け取り、受け取った文字列をそのままクライアントへ返す(エコーバックする)サーバーのことを指します。今回は TCPソケット でやりとりを行う echo サーバーを実装していこうと思います。

まず初めにTCPクライアントとTCPサーバーの処理の流れを説明して、実際に動作する様子までを書きなぐっていきます。

実行環境

  • CentOS 7.2
  • GCC 4.8.5
[root@dev /]# cat /etc/redhat-release
CentOS Linux release 7.2.1511 (Core)

[root@dev /]# gcc --version
gcc (GCC) 4.8.5 20150623 (Red Hat 4.8.5-11)
Copyright (C) 2015 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

TCPクライアントの処理の流れ

  1. socket() を実行してTCPソケットを作成する
  2. connect() を実行してTCPサーバーとの接続を確立する
  3. send()recv() を実行して通信を行う
  4. close() を実行してTCPサーバーとの接続をクローズする

※ TCPクライアントの仕事は接続を待っているTCPサーバーとの通信を開始すること

TCPサーバーの処理の流れ

  1. socket() を実行してTCPソケットを作成する
  2. bind() を実行してTCPソケットにポート番号を割り当てる
  3. listen() を実行して割り当てたポート番号への接続を作成できることをシステムに伝える
  4. 以下の手順を繰り返し実行する
    • TCPクライアントからの接続要求を受け accept() を呼び出しTCPソケットを新しく取得する
    • send()recv() を実行して作成したTCPソケットを介してTCPクライアントと通信を行う
    • close() を実行してTCPクライアントとの接続をクローズする

※ TCPサーバーの仕事は通信のためのエンドポイントを用意してクライアントからの接続を待機すること

ソケットで用いる汎用データ型について

ソケットに関連付けられたアドレスの指定に使う sockaddr という構造体ソケットAPIに定義されている

struct sockaddr {
	unsigned short sa_family; // アドレスファミリ
	char sa_data[14]; // アドレス情報(ファミリによって異なる)
}

sockaddr 構造体を TCP/IP のソケットアドレス用に特化したものが sockaddr_in 構造体となっている
※ ソケットの各関数には渡すときには sockaddr にキャストして渡すこと
※ sockaddr_in は sockaddr 構造体のデータを別形式で定義しなおしているだけ

struct sockaddr_in {
	unsigned short sin_family; // TCP/IP(AF_INET)
	unsigned short sin_port; // アドレスポート(16bit)
	struct in_addr sin_addr; // IPアドレス(32bit)
	char sin_zero[8]; // 不使用
}

socket() について

ソケットを作成する関数

int socket(int protocol_family, int type, int protocol)

成功時の戻り値:ソケットを識別する数値(ソケットディスクリプタ) 失敗時の戻り値:-1

  • protocol_family: どのプロトコルファミリのプロトコルを使用するか指定
    • 今回は TCP/IP プロトコルファミリを使うので PF_INET を指定
  • type: ソケットの種類を指定
    • PF_INET だと以下のソケットが存在する
    • SOCK_STREAM(信頼性の高いストリームソケット)
    • SOCK_DGRAM(ベストエフォート型のデータグラムソケット)
  • protocol: 指定したプロトコルファミリのどのプロトコルを使用するか指定

bind() について

ソケットにポート番号を割り当てる関数

int bind(int socket, struct sockaddr *localAddress, unsigned int addressLength)

成功時の戻り値:0 失敗時の戻り値:-1

  • socket: ソケットディスクリプタを指定
  • localAddress: sockaddr(サーバーのIPとPORTの情報を保持するアドレス構造体)へのポインタを指定
  • addressLength: アドレス構造体にサイズを指定

listen() について

ソケットが割り当てられたポートへの接続を待ち受ける関数
※ リッスン中のソケットはクライアントからの接続要求の度に新たにソケットを取得するのに使用される
※ クライアントからの接続要求は accept されるまではキューに格納される

int listen(int socket, int queueLimit)

成功時の戻り値:0 失敗時の戻り値:-1

  • socket: ソケットディスクリプタを指定
  • queueLimit: 接続要求を同時にいくつまで許容するかを指定

connect() について

ソケットとの接続を確立する関数

int connetct(int socket, struct sockaddr *foreignAddress, unsigned int addressLength)

成功時の戻り値:0 失敗時の戻り値:-1

  • socket: ソケットディスクリプタを指定
  • foreignAddress: sockaddr(サーバーのIPとPORTの情報を保持するアドレス構造体)へのポインタを指定
  • addressLength: アドレス構造体にサイズを指定

accept() について

クライアントから要求を受ける度にソケットを取得する関数

int accept(int socket, struct sockaddr *clientAddress, unsigned int addressLength)

成功時の戻り値:クライアントを接続させた新しいソケットのディスクリプタ 失敗時の戻り値:-1

  • socket: ソケットディスクリプタを指定
  • clientAddress: sockaddr(サーバーのIPとPORTの情報を保持するアドレス構造体)へのポインタを指定
  • addressLength: アドレス構造体にサイズを指定

send() について

ソケットにメッセージを送信する関数

int send(int socket, const void *msg, unsigned int msgLength, int flags)

成功時の戻り値:送信したバイト数 失敗時の戻り値:-1

  • socket: ソケットディスクリプタを指定
  • msg: 送信するメッセージへのポインタを指定
  • msgLength: メッセージサイズを指定(バイト数)
  • flags: ソケットが呼び出されたときの振る舞いを指定(今回は使用しないため 0 を指定)

recv() について

ソケットからメッセージを受信する関数

int recv(int socket, void *rcvBuffer, unsigned int bufferLength, int flags)

成功時の戻り値:受信したバイト数 失敗時の戻り値:-1

  • socket: ソケットディスクリプタを指定
  • rcvBuffer: 受信データを格納しているバッファへのポインタを指定
  • bufferLength: バッファサイズを指定(バイト数)
  • flags: ソケットが呼び出されたときの振る舞いを指定(今回は使用しないため 0 を指定)

close() について

ソケットとの接続を終了する関数

int close(int socket)

成功時の戻り値:0 失敗時の戻り値:-1

  • socket: クローズするソケットディスクリプタを指定

echo サーバー完成

これまで説明した処理の流れを実装すると以下の動作をする echo サーバーが完成します。

# TCPサーバーを5000番ポートで起動する
[root@dev c]# ./TCPEchoServer 5000

# ファイルディスクリプタを確認するとソケットが作成されていることがわかる
[root@dev c]# lsof -i:5000
COMMAND   PID USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
TCPEchoSe 126 root    3u  IPv4  21681      0t0  TCP *:commplex-main (LISTEN)
[root@dev c]# ll /proc/126/fd
total 0
lrwx------ 1 root root 64 Sep  3 09:48 0 -> /dev/pts/0
lrwx------ 1 root root 64 Sep  3 09:48 1 -> /dev/pts/0
lrwx------ 1 root root 64 Sep  3 09:44 2 -> /dev/pts/0
lrwx------ 1 root root 64 Sep  3 09:48 3 -> socket:[21681] <--- ソケットが作成されている

# ソケットが5000番ポートに bind() されて listen() していることがわかる
[root@dev c]# netstat -an | grep -i listen
tcp        0      0 0.0.0.0:5000            0.0.0.0:*               LISTEN

# TCPクライアントから文字列を送るとTCPサーバーから文字列が返される
[root@dev c]# ./TCPEchoClient 127.0.0.1 "test" 5000
Recieved: test

# TCPサーバー側でも文字列を受け取ったことがわかる
[root@dev c]# ./TCPEchoServer 5000
Handling client 127.0.0.1

まとめ

TCPクライアントサーバー間でこれまで説明した関数を利用しながら、以下のようにデータ送受信を行っています。 ※ read() は recv(), write() は send() という対応で考えて下さい

tcp-sockets.png 引用:CS 50 Software Design and Implementation

さいごに

実際に実装してみたことで、ソケット同士が内部でどのような手続きを踏みながら通信をおこなっているのかを理解することが出来ました。 ソースコードは GitHub に挙げておいたのでご興味ある方はご覧になってみてください!

https://github.com/ryysud/socket-programming