経 緯
会社の昼休みに、自分のスマートフォンで手軽に友人同士の将棋対局を楽しみたいと思って作りました。スマートフォン同士で手軽に対局できるアプリって以外に無いんです。shogi-serverというありがたいOSSを自宅サーバーで稼働して、httpゲートウェイをPSGIで作り、GUIはJavaScript。これなら自分のスキルの範囲内で実現できると思った次第です。
概 要
shogi-serverは、CSA(コンピューター将棋協会)プロトコルを搭載したマルチクライアント対応のTCP Socket Serverで、クライアント同士の対局全てを管理します。このソフトウェアを利用して、ブラウザ同士で対局ができるサービスを作ったわけです。
サービスとしては下記の2つがあります。誰でも使用できます。使い方は「ここ」
1. DEN将棋(標準モード対局)
2. DEN将棋X(拡張モード対局:中断・チャットができます)
httpゲートウェイdaemonをPSGIで作り、PSGIが固定数のワーカープロセスを起動し、実際にはこのワーカープロセスがshogi-serverと通信します。
ここでは、
1ソフトウェア構成 2shogi-serverの紹介 3PSGIによるゲートウェイdaemon
4ワーカープロセス 5棋譜ファイル
の5つに分けて説明します。ポーリングリクエストとforkによるワーカープロセス管理という伝統的(古い・・)な手法を用いており、最新の技術とはとても言えませんがそれなりに役に立つ情報もあると思っています。また、無駄なコーディングも多くスマートではありませんが、webkoza.comで雑多に掲載した記事を再掲したり、新たに追加したりしてわかりやすく書いたつもりです。長くなりそうなので、1~3をNo1、4~5をNo2として記事にします。実は一番苦労したのは、苦手なGUI部分ですが、これは「んとか将棋」さんのスマートフォン版の一部を流用させていただき力技で作り上げました。デバッグに付き合ってくれた友人のおかげで結構楽しめるレベルのGUIになっていますが、記事にできる気がしませんので除外させていただきます。
3.ソフトウェア構成
下記に連関図を掲載します。対局としてはブラウザ1対ブラウザ2のブラウザ同士対局以外にも、shogi-serverの受付ポートも開放しているので、ブラウザ1対「将棋所(PC用将棋ソフト)」、「将棋所」対「将棋所」といった対局も可能です。将棋所はUSIプロトコルに対応しており、USI対応のAIを動かす事ができますので、例えば自分のスマートフォンのブラウザ画面で「やねうら王」と対局することも可能なはずです(やったことありませんが)。
今回サーバーサイドとして作ったのは、PSGIとTCP Client(ワーカープロセス)の部分です。ブラウザからのリクエストはapacheのproxy_moduleにより、PSGIとして常駐しているdaemonの受付ポートにフォワードされます。PSGIは受信したJSONデータを変換してブラウザセッション番号(=着席番号)に対応した子プロセス(shogi-serverのクライアント)に渡します。情報を受け取った子プロセスはshogi-serverに情報を送信します。shogi-serverから受け取った情報は子プロセスに渡され、子プロセス毎に持っているキューに保存されます。子プロセスのキューに保存されたデータはブラウザからの定期リクエストにより取り出されブラウザに渡ります。
実装した機能は図の中に記載しましたので参照してください。
2.shogi-serverの紹介
私が紹介するのもおこがましいのですが、すばらしいソフトウェアなので私なりに。
shogi-serverは、CSAが規定したTCP Socketプロトコルを搭載したマルチクライアント対応のサーバーソフトウェアで、有名なfloodgateという将棋対局サービスのメインソフトウェアです。OSSなので誰でもダウンロードして使用する事ができます。rubyで書かれているのでruby環境をあらかじめインストールしておく事が必要です。webkoza.comはFedoraなのでyumを使って簡単にインストールすることができました。ただFedora自体古いバージョンなのでインストールされたrubyは「1.9.3p484」でした。推奨は2.1系統と書いてありますが今のところそれなりに動いています。ダウンロードリンクは「ここ」の下の方にあります。
shogi-serverにはCSAモード(標準モード)と拡張モードが有って、それぞれコマンド体系が異なります。拡張モードは標準モードを発展させて、ログインしたまま他の人の対局を観戦したり、対局中断、対局中のチャットといったことができます。
公開されている状態遷移図によると、2つのモードは最初のログインコマンドで分岐し、拡張モードでは対局終了してもログイン状態を維持できるようになっています。但しログイン状態の維持機能はDEN将棋Xでは使用しませんでした。
shogi-serverの主な機能としては、下記です。
- 条件(パスワード・持ち時間情報・希望先手/後手)が有った人どうしのマッチメイク(ログイン管理)
- 対局中の指し手データの有効・無効判定
- 対局中の指し手データを指した人と相手の両方に送信
- 終局判定
これらの機能をコマンド別に説明した仕様は「ここ」に掲載されていますが、クライアントを設計する上では不足なので、実際にはTELNETとWiresharkでレスポンス電文の解析を行いながらコーディングしました。
おそらくfloodgateのような大規模なサービスをターゲットとしているせいか、若干使いにくい所を発見しました。それは、「現在保存されている中断対局リストを参照する機能が無い」ことです。これの解決は主にワーカープロセスの項で紹介します。
3.PSGIによるゲートウェイdaemon
PSGIは、リクエストを受け付ける1つのpsgiファイルと各リクエストに対応したパッケージモジュールファイルから構成されます。
(1)daemon化
PSGIをplackupコマンドで起動してdaemon化しました。下記はDEN将棋Xの.serviceファイルです。
[Unit]
Description=Webkoza DEN Shogi X Application
After=syslog.target
After=network.target
After=webkoza_DENShogi.service
[Service]
Restart=always
SyslogIdentifier=webkoza_DENShogi
ExecStart=/****/****/plackup -p **** /****.psgi
[Install]
WantedBy=multi-user.target
(2)モジュール
下記はDEN将棋Xのpsgiファイルの最初のモジュールロード部分です。
PlackミドルウェアのSessionモジュール(★印の行)を使用してセッション管理を行います。Login(ログインリクエスト受付),SendDa(手データのJSON送信リクエスト受付),GetQue(shogi-serverからのデータ取得リクエスト受付),GetBuoyList(中断棋譜一覧取得リクエスト受付) の4つのモジュールがそれぞれのリクエストを処理するパッケージモジュールです。モジュールロード後グローバル変数を定義しています。(****は伏せ字)
use Plack::Builder;
use Plack::Middleware::Session; #★
use lib qw(/****/****/lib);
use Encode;
use URI::Escape;
use Jcode;
use Plack::Session 0.13; #★
use Plack::Session::Store::File; #★
use Plack::Session::State::Cookie; #★
use ShogiX::Login;
use ShogiX::SendDa;
use ShogiX::GetQue;
use ShogiX::GetBuoyList;
use IPC::Open2;
my(@SeatUsing,@Pid,@MaxEndTime,$MAXWAIT,@MochiTime,@Byoyomi);
my(@INREF,@OUTREF);
#各席番号(=子プロセスNo)を要素とする子プロセスへの入力ハンドル・出力ハンドル
my $SeatNumber = 4;#席数
(3)初期化
次にワーカープロセスの起動と各種変数の初期化を行います。
sub Init{# 子プロセスの生成とグローバル変数初期化
my $i;
for($i=0;$i<$SeatNumber;$i++){
$Pid[$i]=open2($INREF[$i],$OUTREF[$i],'/****/****.pl');
$SeatUsing[$i] = 0;#席使用フラグ
$MaxEndTime[$i] = 0;#席毎の最大終了時刻 epoc time
$MochiTime[$i] = 0;#席毎の持ち時間[秒]
$Byoyomi[$i] = 0;#席毎の秒読み時間[秒]
$MAXWAIT = 30;#getqueリクエストの最大待ち時間[秒]。これを超えたら強制的子プロセス再起動
}
return;
}
&Init;
(4)アプリケーション呼び出し
下記はリクエストに対応したアプリケーション呼び出し部分です。to_appの引数がやたら多くなってしまってきれいではありません。。
my $ShogiLogin = sub {
# 将棋サーバーへのログインアプリケーション
# 対局セッション開始
my $env = shift;
my $request = Plack::Request->new($env);
my $p = ShogiX::Login->new($request);
return $p->to_app(\@Pid,\@SeatUsing,\@INREF,\@OUTREF,\@MaxEndTime,\$MAXWAIT,\@MochiTime,\@Byoyomi);
};
my $ShogiSendDa = sub {
# 対局中クエリーで受け取ったデータを将棋サーバーへ「手」として送信する
my $env = shift;
my $request = Plack::Request->new($env);
my $p = ShogiX::SendDa->new($request);
return $p->to_app(\@Pid,\@INREF,\@OUTREF,\@MaxEndTime,\$MAXWAIT,\@MochiTime,\@Byoyomi);
};
my $ShogiGetQue = sub {
# 将棋サーバーからの受信キューにあるデータをJSONに変換して返信する
# 対局開始後定期的に呼ばれる
my $env = shift;
my $request = Plack::Request->new($env);
my $p = ShogiX::GetQue->new($request);
return $p->to_app(\@Pid,\@SeatUsing,\@INREF,\@OUTREF,\@MaxEndTime,\$MAXWAIT,\@MochiTime,\@Byoyomi);
};
my $ShogiGetBuoyList = sub {
# クエリーで指定されたユーザーが保存したGameNameリストを取得する
my $env = shift;
my $request = Plack::Request->new($env);
my $p = ShogiX::GetBuoyList->new($request);
return $p->to_app(\@Pid,\@INREF,\@OUTREF);
};
(5)builderブロック(マルチアプリケーションフレーム)
Plack::Builder使うとこんな感じで大変すっきりと記述できます。storeはファイルを指定するためにPlack::Session::Store::Fileを使用し、stateはPlack::Session::State::Cookieを使用しています。セッションファイルはマウント済みの脱着可能なUSBメモリを指定しました。sidが着席(ログイン)番号に対応したクッキーデータです。expires指定していないので、ブラウザを閉じるたびにセッションが切れます。つまりブラウザをリロードしたり閉じたりすると中断保存していない場合対局継続不能になります。
ここでマウントされた各URLは、ブラウザ画面上で動作しているJavaScriptが送ってくるJSONリクエストを受け付けるURLです。
builder{
enable 'Session',
store => Plack::Session::Store::File->new(
dir => '/****/****/sessions'
),
state => Plack::Session::State::Cookie->new(
session_key => 'sid'
#expires => 30
#起動時expiresは指定しないことにする
# →Login時クエリーでexpires指定しない場合はブラウザセッション閉じるとセッション断
);
mount "/****_login"=>builder{
enable "StackTrace";
$ShogiLogin;
};
mount "/****_send_da"=>builder{
enable "StackTrace";
$ShogiSendDa;
};
mount "/****_get_que"=>builder{
enable "StackTrace";
$ShogiGetQue;
};
mount "/****_getbuoylist"=>builder{
enable "StackTrace";
$ShogiGetBuoyList;
};
};
(6)ログインアプリケーション
まずshogi-serverにログインするためのアプリケーションLogin.pmです。まず定義部分とnew関数です。ログイン時にセッション開始するので新しくセッションを生成しています。★1はSession Fixation 対策で、古いセッションの破棄とあたらしいセッションの発行をこの記述でできるのだそうです。
→Plack::Middleware::Session で Session Fixation 対策
package ShogiX::Login;
use strict 'vars';
use Plack::Session 0.13;
use IPC::Open2;
our $json = {
Result => "OK",
NgReason => "XX",
SeatNo => "0",
Pass => "XX",
};
our $SeatNumber = 4;#席数
# オブジェクトメソッド
sub new {
my ($class,$request) = @_;
my $session = Plack::Session->new($request->env);
my $cses = $request->session_options->{change_id}++; # ★1
# is equals to $request->env->{'psgix.session.options'}->{change_id}++;
my $my_expires = $request->param('expires');
if(defined($my_expires)){
$request->session_options->{expires} = time + $my_expires; #現在時刻に加算
}
$session->set('verified', 1);
my $self = {# メンバ変数を初期化
Req => $request,
SesID => $request->session_options->{id},# ブラウザが送ってきたsid
};
bless $self,$class;
return $self;
}
下記はアプリケーション本体to_appの抜粋です。newにPlack::Requestによるリクエストオブジェクトを渡して、このオブジェクトをメンバー変数にセットしているので、★2でそれを取り出しています。その後paramメソッドで各クエリー文字列データを変数に格納しています。★3はshogi-serverのIPアドレス(127.0.0.1)、★4,5はユーザー名とパスワード、★6は拡張モードのgamename(name-(持ち時間)-(秒読み時間))+「半角スペース」+「(先後どちらでも)or +(先手希望) or -(後手希望)」
その後、ユーザー名の文字列検査NGならJSONによるNGレスポンスボディーをセットして返します。検査OKならばその後&check_empty_seatで空席番号を探して、空席があれば&connect_srvでshogi-serverに接続コマンド「ConnectRequest:(shogi-serverIPアドレス)\n」を送信します。次に&send_daでワーカーに対して"get_que"を送信しshogi-serverからの接続レスポンス待ちになります。ワーカからログインOKを受信後&send_gnameでshogi-serverの%%GAMEコマンドを送信して、JSONのレスポンスボディーをセットして返します。ブラウザサイドはこれでマッチメーク待ち(条件の合うブラウザが現れるのを待つ)になります。
&send_gnameでは、クエリー文字列でブラウザから送ってきた$mode変数の値によって、新規GAMEか再開GAMEかを判断して%%GAMEコマンドに渡すgname文字列を変えています。再開の場合指定gnameの頭に**buoy_**を付加するのが仕様です。
sub to_app {
my $self = shift;
# ・・・・・・(省 略)・・・・・・
my $request = $self->get_req();# リクエストオブジェクト ★2
my $res = $request->new_response(200);
# ・・・・・・(省 略)・・・・・・
$server = $request->param('server'); # ★3
$un = $request->param('uname'); # ★4
$ps = $request->param('pass'); # ★5
$gname = $request->param('gname');#name-持ち時間-秒読み時間 ★6
$mode = $request->param('mode');#新規:new 再開:cont
my $body1 = '';
if(($un =~ /[^\x01-\x7E]/) or ($ps =~ /[^\x01-\x7E]/)){#全角および半角カナにマッチ
$json->{Result} = "\"NG\"";
$json->{NgReason} = "\"ERROR_STRING\"";
$json->{SeatNo} = "\"\"";
$json->{Pass} = "\"$ps\"";
$body1 = &get_res_json($json);
$res->body($body1);
$ret = $res->finalize;#psgiレスポンスデータを返す
return $ret;
}
my $seatno = &check_empty_seat(\@seatusing,\@maxendtime_ar,$pid_ref,$inrefar_ref,$outrefar_ref);
#$res->content_type('text/html');
$res->content_type('application/json');
my @wt = ();
if(($seatno >= 0) and ($seatno <= 3)){
#@wt = split(/-/,$ps);
@wt = split(/-/,$gname);
$maxendtime_ar[$seatno] = time + $maxwait;#最大終了時刻として現在時刻+最大待ち時間をセット
$mochitime_ar[$seatno] = $wt[1];#持ち時間セット
$byoyomi_ar[$seatno] = [split(/\s/,$wt[2])]->[0];#秒読み時間セット
$seatusing[$seatno] = 1;#使用フラグを立てる
#戻す
$seatusing_ref->[$seatno] = $seatusing[$seatno];
@$maxendtime_ar_ref = @maxendtime_ar;
$$maxwait_ref = $maxwait;
@$mochitime_ar_ref = @mochitime_ar;
&connect_srv($outrefar_ref->[$seatno],$server);#CSAサーバに接続要求
&send_login($outrefar_ref->[$seatno],$un,$ps);
&send_da($outrefar_ref->[$seatno],"getque");
@inrefar = @$inrefar_ref;
while(<$inrefar[$seatno]>){
chomp;
last if(/^%Login/); #ログインOKを受信してからGAMEコマンド送信
}
&send_gname($outrefar_ref->[$seatno],$gname,$mode);# %%GAMEコマンド送信
$json->{Result} = "\"OK\"";
$json->{NgReason} = "\"\"";
$json->{SeatNo} = "\"$seatno\"";
$json->{Pass} = "\"$ps\"";
$body1 = &get_res_json($json);
$res->cookies->{Seat} = $seatno;#着席Noをクッキー送信
$res->body($body1);
}else{#満席
$json->{Result} = "\"NG\"";
$json->{NgReason} = "\"NOSPACE\"";
$json->{SeatNo} = "\"\"";
$json->{Pass} = "\"$ps\"";
$body1 = &get_res_json($json);
$res->body($body1);
}
$ret = $res->finalize;#psgiレスポンスデータを返す
return $ret;
}
#--------
sub connect_srv{
my($outref,$server)=@_;
print $outref "ConnectRequest:$server\n";
}
#--------
sub send_login{
my($outref,$uname,$pass)=@_;
print $outref "LOGIN $uname $pass x1\n";
}
#--------
sub send_gname{
my($outref,$gname,$mode)=@_;
if($mode eq 'new'){#新規対局
print $outref "%%GAME $gname\n";
}else{#再開
print $outref "%%GAME "."buoy_"."$gname\n";
}
}
#--------
sub send_da{
my($outref,$da)=@_;
print $outref "$da\n";
}
(7)shogi-serverからのデータ取り出しアプリケーション
次にshogi-serverからワーカープロセスが受信したデータを取り出すためのアプリケーションGetQue.pmです。定義部分とnew関数はSession Fixation 対策が無いだけでログインアプリケーションと同様なので省略します。
下記はアプリケーション本体to_app関数の抜粋です。DEN将棋Xは、ブラウザを途中で閉じられてしまった場合、ログイン中ブラウザ(=席)に1対1対応しているワーカープロセスが、親プロセス(PSGI)からのコマンド待ちが永遠に継続してしまういわゆる「ゾンビプロセス」と化すことがあります。ここではGetQueアプリケーションがマッチメーク後ブラウザサイドから1秒おきに呼び出されることを利用して、全席番号(0-3)毎に前回リクエスト受信から30秒経過しているかどうかを計測して超えていたら(タイムアップしていたら)対応ワーカープロセスを強制的に再起動することにより、このゾンビプロセス対策としています。
自分の席がタイムアップしていたらNGレスポンスを返し、タイムアップしていなかったら、&get_quedataを呼んでJSONのレスポンスボディーをセットして返します。&get_quedataの中で上記の全席タイムアップチェック&ワーカープロセス再起動を行っています。
sub to_app {
my $self = shift;
my($pidar_ref,$seatusing_ref,$inrefar_ref,$outrefar_ref,$maxendtime_ar_ref,
$maxwait_ref,$mochitime_ar_ref,$byoyomi_ar_ref) = @_;
#一旦デリファレンス
# ・・・・・・(省 略)・・・・・・
my $request = $self->get_req();
my $session = Plack::Session->new($request->env);
my $res = $request->new_response(200);
my $seat = $self->get_seat();
$res->content_type('application/json');
my $body1 = '';
if ($session->get('verified')) {
if($maxendtime_ar[$seat] == 0){#タイムアップ
$json->{Result} = "\"NG\"";
$json->{NgReason} = "\"Time Up\"";
$json->{SeatNo} = "\"$seat\"";
$json->{Que} = "\"". $maxendtime_ar[$seat] . ':' . $$maxwait_ref . "\"";
}else{
$body1 .= &get_quedata(\@pidar,\@seatusing,\@inrefar,\@outrefar,
$seat,\@maxendtime_ar,$maxwait_ref,\@mochitime_ar,\@byoyomi_ar);
#戻す
@$seatusing_ref = @seatusing;
@$maxendtime_ar_ref = @maxendtime_ar;
$json->{Result} = "\"OK\"";
$json->{NgReason} = "\"\"";
$json->{SeatNo} = "\"$seat\"";
$json->{Que} = "\"$body1\"";
}
$body1 = &get_res_json($json);
$res->body($body1);
} else {
$json->{Result} = "\"NG\"";
$json->{NgReason} = "\"NO Session\"";
$json->{SeatNo} = "\"\"";
$json->{Que} = "\"\"";
$body1 = &get_res_json($json);
$res->body($body1);
}
$res->finalize;
}
########################
sub get_quedata{
my($pidar_ref,$seatusing_ref,$inrefar_ref,$outrefar_ref,
$seat,$maxendtime_ar_ref,$maxwait_ref,$mochitime_ar_ref,$byoyomi_ar_ref)=@_;
# ・・・・・・(省 略)・・・・・・
my($ww,@reboot_seat);
for($i=0;$i<$SeatNumber;$i++){
if($i == $seat){#リクエスト元の席だったら
#最大終了時刻再セット
$maxendtime_ar[$seat] = time + $maxwait;
}else{
if(($maxendtime_ar[$i] != 0) and (time > $maxendtime_ar[$i])){
#最大終了時刻を超えていたら子プロセスクローズ→再オープン→対局中でも切断される
kill(15, $pid[$i]);#SIGTERM
do {
$ww = waitpid($pid[$i], WNOHANG);
} while $ww > 0;
$pid[$i] = open2($inref[$i],$outref[$i],'/****/****clientX.pl');
push(@reboot_seat,$i);
$seatusing[$i] = 0;
$maxendtime_ar[$i] = 0;
}
}
&send_da($outref[$seat],"getque");
my $ir = $inref[$seat];
while(<$ir>){
chomp;
last if($_ eq 'FIN');
push(@recda,$_);
if(/(#WIN)|(#LOSE)|(%Logout)/){#勝ち・負け・ログアウトのいずれもソケット切断しているので席を空ける
$seatusing[$seat] = 0;#席使用フラグをクリア
$maxendtime_ar[$seat] = 0;#リセット
}elsif(/%Sengo_/){# 先手後手決定通知
}
}
#戻す
# ・・・・・・(省 略)・・・・・・
my $retd = join('/',@recda);
return $retd;
}
(8)shogi-serverへのデータ送信アプリケーション
shogi-serverへ指し手データを送信するアプリケーションSendDa.pmは、技術的に内容はほとんど重複するので省略します。
(9)中断gnameリスト取得アプリケーション
このアプリケーションは、中断対局リスト取得アプリケーションです。前述の通りshogi-serverは中断対局リストを取得するコマンドが無いので、中断した時点でワーカープロセスが独自ファイルに保存しています。独自ファイルは
登録者名,登録者先手/後手,相手名,相手先手/後手,GameName
を1レコードとするCSVファイルで、ブラウザサイドから送ってきたユーザー名が、登録者名または相手名に一致したレコードのみを返します。下記はto_app関数内で呼ばれるリスト取得関数です。
sub get_buoylist{
my($inrefar_ref,$outrefar_ref,$seat,$uname)=@_;
my($retd,@wline);
#中断中ファイルレコードを/でつなげて返却
my(@fda);
open(BUOYF, "+< /****/****_buoy.txt");
flock(BUOYF, 2);
while(<BUOYF>){
chomp;
@wline = split(/,/,$_);
if($wline[0] eq $uname){
push(@fda,$_);
}elsif($wline[2] eq $uname){
push(@fda,$_);
}
}
close(BUOYF);#クローズ&ロック解除
$retd = join('/',@fda);
return $retd;
}
No2「4ワーカープロセス 5棋譜ファイル」へ続く..