-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathextra-close-frame.html
More file actions
636 lines (474 loc) · 40.8 KB
/
extra-close-frame.html
File metadata and controls
636 lines (474 loc) · 40.8 KB
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
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>切断フレームの検証 | SOCKET-MANAGER Framework For PHP</title>
<meta name="description" content="WebSocketの切断フレーム(Closing Handshake)の実装方法を詳しく解説。切断パターン別の実装例、RFC仕様との整合性、マインクラフト統合版での注意点など、具体的なコード例とシーケンス図で分かりやすく紹介。" />
<meta content="PHP,ソケット通信,websocket,切断フレーム,サーバー開発,ソケットマネージャー" name="keywords">
<link rel="canonical" href="https://socket-manager.github.io/document/extra-close-frame.html" />
<script async src="https://www.googletagmanager.com/gtag/js?id=G-LF9W695NNW"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-LF9W695NNW');
</script>
<link rel="icon" href="https://socket-manager.github.io/document/favicon.ico" type="image/x-icon" />
<link type="text/css" rel="stylesheet" href="./css/common.css" media="all" />
<script src="./js/jquery-3.7.1.min.js"></script>
<script type="text/javascript" src="./js/common.js"></script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "TechArticle",
"headline": "WebSocketの切断フレーム実装ガイド",
"description": "WebSocketの切断フレーム(Closing Handshake)の実装方法を詳しく解説。切断パターン別の実装例、RFC仕様との整合性、マインクラフト統合版での注意点など、具体的なコード例とシーケンス図で分かりやすく紹介。",
"keywords": "WebSocket, 切断フレーム, Closing Handshake, PHP",
"articleSection": ["Technical Documentation", "Server Development", "PHP Programming"],
"image": "https://socket-manager.github.io/document/img/extra-close-frame/close.gif",
"author": {
"@type": "Person",
"name": "SOCKET-MANAGER開発チーム"
},
"publisher": {
"@type": "Organization",
"name": "SOCKET-MANAGER",
"logo": {
"@type": "ImageObject",
"url": "https://socket-manager.github.io/document/logo.png",
"width": 355,
"height": 50
}
},
"mainEntityOfPage": {
"@type": "WebPage",
"@id": "https://socket-manager.github.io/document/extra-close-frame.html"
},
"url": "https://socket-manager.github.io/document/extra-close-frame.html",
"breadcrumb": {
"@type": "BreadcrumbList",
"itemListElement": [{
"@type": "ListItem",
"position": 1,
"name": "Framework Top",
"item": "https://socket-manager.github.io/document/"
},{
"@type": "ListItem",
"position": 2,
"name": "切断フレームの検証",
"item": "https://socket-manager.github.io/document/extra-close-frame.html"
}]
},
"tutorial": {
"@type": "HowTo",
"name": "WebSocket切断フレームの実装手順",
"step": [
{
"@type": "HowToStep",
"name": "切断パターンの理解",
"text": "4つの切断パターンとシーケンス図の解説"
},
{
"@type": "HowToStep",
"name": "RFC仕様の確認",
"text": "切断フレームの構造とペイロード制限の解説"
},
{
"@type": "HowToStep",
"name": "実装上の注意点",
"text": "マインクラフト統合版での特殊な動作と対策"
}
]
},
"isPartOf": {
"@type": "WebSite",
"name": "フレームワークのご紹介",
"url": "https://socket-manager.github.io/document/"
}
}
</script>
</head>
<body>
<div class="layout">
<div class="menu" role="navigation" aria-label="ページメニュー">
<h2 class="menu-title">SOCKET-MANAGER</h2>
<h4 class="menu-reference menu-page-title-bottom"><a href="./reference/" target="_blank">>> Reference</a></h4>
<h2 class="menu-label">MAIN-MENU</h2>
<div class="menu-text">
<h3 class="menu-page-title-link"><a href="./">▶フレームワークのご紹介</a></h3>
<h3 class="menu-page-title-link"><a href="./event-handler.html">▶イベントハンドラについて</a></h3>
</div>
<h3 class="menu-label-sub">IMPLEMENT</h3>
<div class="menu-text">
<h3 class="menu-page-title-link"><a href="./init-class.html">▶初期化クラス</a></h3>
<h3 class="menu-page-title-link"><a href="./unit-parameter.html">▶UNITパラメータクラス</a></h3>
<h3 class="menu-page-title-link"><a href="./protocol-unit.html">▶プロトコルUNITクラス</a></h3>
<h3 class="menu-page-title-link"><a href="./command-unit.html">▶コマンドUNITクラス</a></h3>
<h3 class="menu-page-title-link"><a href="./main.html">▶メイン処理クラス</a></h3>
<h3 class="menu-page-title-link"><a href="./setting.html">▶設定ファイル</a></h3>
<h3 class="menu-page-title-link"><a href="./message.html">▶メッセージファイル</a></h3>
</div>
<div class="menu-line"></div>
<div class="menu-text">
<h3 class="menu-page-title-link-for-runtime-manager"><a href="./runtime-manager/" target="_blank">>> ランタイムライブラリ</a></h3>
<h3 class="menu-page-title-link-for-runtime-manager"><a href="./simple-socket/" target="_blank">>> シンプルソケット機能</a></h3>
</div>
<h3 class="menu-label-sub">ADVANCED</h3>
<div class="menu-text">
<h3 class="menu-page-title-link"><a href="./architecture.html">▶アーキテクチャ</a></h3>
<h3 class="menu-page-title-link"><a href="./event.html">▶イベント駆動アーキテクチャ</a></h3>
<h3 class="menu-page-title-link"><a href="./ipc.html">▶IPC(プロセス間通信)</a></h3>
<h3 class="menu-page-title-link"><a href="./multi-server.html">▶マルチサーバーの構成</a></h3>
<h3 class="menu-page-title-link"><a href="./tcp-and-udp.html">▶TCP/UDP通信について</a></h3>
<h3 class="menu-page-title-link"><a href="./laravel.html">▶Laravelと連携する</a></h3>
<h3 class="menu-page-title-link"><a href="./system-setting.html">▶システム設定ファイル</a></h3>
<h3 class="menu-page-title-link"><a href="./custom-command.html">▶カスタムコマンド作成機能</a></h3>
</div>
<h3 class="menu-label-sub">OTHER-PROJECT</h3>
<div class="menu-text">
<h3 class="menu-page-title-link"><a href="./new-project.html">▶新規開発環境</a></h3>
<h3 class="menu-page-title-link"><a href="./websocket.html">▶Websocketサーバー開発環境</a></h3>
<h3 class="menu-page-title-link"><a href="./dev-ops.html">▶フレームワークのDevOps環境</a></h3>
</div>
<div class="menu-line"></div>
<div class="menu-text">
<h3 class="menu-page-title-link-for-minecraft"><a href="./minecraft-contents/" target="_blank">>> マインクラフト専用環境</a></h3>
<h3 class="menu-page-title-link-for-launcher"><a href="./launcher/" target="_blank">>> GUI & CLI ランチャー</a></h3>
<h3 class="menu-page-title-link-for-rest-api"><a href="./rest-api/" target="_blank">>> REST-APIサーバー開発環境</a></h3>
</div>
<h2 class="menu-label">EXTRA-MENU</h2>
<div class="menu-text">
<h3 class="menu-page-title-link"><a href="./extra-demo.html">▶デモサーバーの種類</a></h3>
<h3 class="menu-page-title-link"><a href="./extra-demo-command.html">▶デモのコマンド仕様</a></h3>
<h3 class="menu-page-title-link"><a href="./extra-demo-setting.html">▶デモの設定ファイル</a></h3>
<h3 class="menu-page-title-link"><a href="./extra-minecraft.html">▶マインクラフトの通信仕様</a></h3>
<h3 class="menu-page-title">▼切断フレームの検証</h3>
<ul>
<li><a href="./extra-close-frame.html#begin">はじめに</a></li>
</ul>
<ul>
<li><a href="./extra-close-frame.html#pattern">シーケンスパターン</a></li>
</ul>
<ul>
<li><a href="./extra-close-frame.html#test">パターン別検証</a></li>
</ul>
<ul>
<li><a href="./extra-close-frame.html#outline">切断フレームの構造</a></li>
</ul>
<ul>
<li><a href="./extra-close-frame.html#minecraft">マインクラフトの場合</a></li>
</ul>
<ul>
<li><a href="./extra-close-frame.html#last">おわりに</a></li>
</ul>
</div>
<h2 class="menu-label">PHP-TECHNIQUE</h2>
<div class="menu-text">
<h3 class="menu-page-title-link"><a href="./php-pass-by-reference.html">▶参照渡し</a></h3>
<h3 class="menu-page-title-link"><a href="./php-phpdoc.html">▶PHPDocのフォーマット</a></h3>
</div>
<div class="menu-dummy-for-framework"></div>
</div>
<div class="main" role="main">
<h1>【切断フレームの検証】</h1>
<a id="begin"></a>
<h2 class="subtitle">はじめに</h2>
<div class="text-block">
Websocketのプロトコルには切断フレームというものがあります。<br />
RFC文書には以下のように書かれています。<br />
<blockquote role="note" aria-label="RFC仕様からの引用">
状態コード, および オプションの close 事由 をパラメタに WebSocket closing ハンドシェイクを開始する ときは、 端点は § 5.5.1 の記述に従って,状態コードが コード にされ, close 事由が 事由 にされた, Close 制御フレームを送信しなければならない。 端点が Close 制御フレームを送信し, 受信したなら、 その端点は § 7.1.1 の定義に従って,WebSocket 接続を close するべきである。
</blockquote>
どうやら切断フレームを使った切断処理を<code>closingハンドシェイク</code>と呼んでいるようです。<br />
この<code>closingハンドシェイク</code>の正体が何なのかをRFC文書を確認しながらデモのソースを使って検証してみました。<br /><br />
※ブラウザはChromeを使っています。
</div><br />
<a id="run"></a>
<h2 class="subtitle">切断時の動作</h2>
<div class="text-block">
以下は切断動作のデモです。まずは一番左のチャット履歴に注目してください。<br />
周りのユーザーがそれぞれ異なる切断方法で終了しているのに対してチャット履歴の方にもそれに対応したコメントが表示されています。<br /><br />
<div class="img-block">
<a href="./img/extra-close-frame/close.gif" target="_blank"><img class="img-zoomout" src="./img/extra-close-frame/close.gif" fetchpriority="high" loading="eager" alt="WebSocket切断フレームの動作デモ - 4つの切断パターンの実行例" /></a>
</div>
切断時の履歴の種類は以下の通り。<br />
<pre aria-label="ブラウザ:退室ボタンを押した時">
2024/02/25 14:28:28 紅蓮の村人
退室しました
</pre>
<pre aria-label="ブラウザ:×ボタンで閉じた時">
2024/02/25 14:30:26 地獄の村人
切断されました
</pre>
<pre aria-label="マインクラフト:×ボタンで閉じた時">
2024/02/25 14:32:27 マイクラー
切断されました
</pre>
<pre aria-label="サーバーからの強制切断">
----/--/-- --:--:-- -----
切断されました
</pre><br />
これらの区別は切断フレームの内容を判断して表示しています。<br />
切断フレームを正しく判断できれば、このような実装が可能になります。
</div><br />
<a id="pattern"></a>
<h2 class="subtitle">シーケンスパターン</h2>
<div class="text-block">
RFC文書には以下のように書かれています。<br />
<blockquote role="note" aria-label="RFC仕様からの引用">
WebSocket 接続の closing は,どちらの端点からも(同時の可能性も含めて)起動され得る。
</blockquote>
つまりサーバーサイド、クライアントサイドのどちらからでも切断要求を開始できると解釈できます。<br />
また、以下のようにも書かれています。<br />
<blockquote role="note" aria-label="RFC仕様からの引用">
WebSocket 接続は close 済みであり,かつ その端点で他に Close 制御フレームが受信されなかった場合 (下層のトランスポート層の接続が失われた場合などに生じ得る)、 close コードは 1006 であるものと見なされる。
</blockquote>
つまり切断フレームなしの瞬停があり得る事とその場合の<code>切断コード=1006</code>であると解釈できます。<br />
これを踏まえた上で切断シーケンスのパターンを大きく分けると下記4パターンにまとまると思います。<br />
<div class="img-block">
<a href="./img/extra-close-frame/seq-pattern1.png" target="_blank"><img class="img-zoomout" src="./img/extra-close-frame/seq-pattern1.png" loading="lazy" alt="サーバーから切断フレームを送信するパターン" /></a>
</div>
<div class="img-block">
<a href="./img/extra-close-frame/seq-pattern2.png" target="_blank"><img class="img-zoomout" src="./img/extra-close-frame/seq-pattern2.png" loading="lazy" alt="クライアントから切断フレームを送信するパターン" /></a>
</div>
<div class="img-block">
<a href="./img/extra-close-frame/seq-pattern3.png" target="_blank"><img class="img-zoomout" src="./img/extra-close-frame/seq-pattern3.png" loading="lazy" alt="サーバーから緊急切断するパターン" /></a>
</div>
<div class="img-block">
<a href="./img/extra-close-frame/seq-pattern4.png" target="_blank"><img class="img-zoomout" src="./img/extra-close-frame/seq-pattern4.png" loading="lazy" alt="クライアントから緊急切断するパターン" /></a>
</div>
①と②は切断フレームを使ったclosingハンドシェイクのパターンで、③と④はそれぞれ切断フレームを投げずにソケットを直接切断(瞬停)するパターンです。
</div>
<a id="test"></a>
<h2 class="subtitle">パターン別検証</h2>
<div class="text-block">
サーバーサイドの方はスクラッチで組んでいれば特に問題ないと思いますが、ブラウザから切断フレームを送受信する時はどのようにすればいいのでしょうか。<br />
切断シーケンスのパターンに当てはめて順を追ってみていきます。<br /><br />
<h3 class="underline">①サーバーから切断フレームを送信</h3>
今回のデモではサーバーサイドから切断要求を送信するインターフェースを実装していないので、クライアントサイドから<code>exit</code>(退室)コマンド(「退室する」ボタン押下時)を送る事でサーバーサイドから切断フレームを送ってもらうようにしています。<br />
これをシーケンス図にすると次のようになります。<br />
<div class="img-block">
<a href="./img/extra-close-frame/seq-exit.png" target="_blank"><img class="img-zoomout" src="./img/extra-close-frame/seq-exit.png" loading="lazy" alt="サーバーからの切断フレームをクライアント契機で送信するパターン" /></a>
</div>
そして実際のソースがこちら↓<br />
<pre color-change="php" aria-label="app/client/chat.js">
// Websocket接続
websocket = new WebSocket($('input[name="uri"]').val());
.
.
.
// 退出コマンドを送信
let data =
{
'cmd': 'exit'
};
websocket.send(JSON.stringify(data));
</pre><br />
<code>exit</code>というコマンド文字列をJSON形式にしてサーバーへ送信しています。<br />
このJSONデータをサーバーが受け取ると<code>exit</code>コマンドが指示されたと解釈して処理を継続します。<br />
その後サーバーから切断フレームが送信されるので以下のソースで受信できます。<br />
<pre color-change="php" aria-label="app/client/chat.js">
websocket.onclose = function(event)
{
console.log(`Websocket切断情報[code=${event.code} reason=${event.reason}]`);
// 切断フレームを受信して切断検知した場合
if(event.wasClean)
{
let data = JSON.parse(event.reason);
}
// 切断フレームを受信せずに切断検知した場合
else
{
// 切断処理
}
}
</pre><br />
相手側から切断フレームが投げられた上で切断検知した場合に<code>event.wasClean</code>を評価するとフラグが立ちます。<br />
RFC文書の正に以下の部分です。<br />
<blockquote role="note" aria-label="RFC仕様からの引用">
WebSocket closing ハンドシェイクの完了後に TCP 接続が close された場合、 WebSocket 接続は clean に close されたとされる。
</blockquote>
そして以下のように<code>event.code</code>には切断コードが、<code>event.reason</code>にはペイロード部の応用データが格納されている事がわかります。<br /><br />
<img src="./img/extra-close-frame/console_pattern1.png" width="762px" loading="lazy" alt="サーバーから送信された切断フレームの通信データ" /><br /><br />
この例ではサーバーから<code>切断コード=10</code>が返されます。<br />
そして応用データには日時のJSON形式のデータが格納されています。<br />
これは<code>退室しました</code>の表示を出す時に日時を含めるためで、このパターンで切断フレームを送信する時はこの形式で統一しています。<br /><br />
※切断フレームの返信はブラウザが自動で行ってくれるので気にする必要はありません。<br /><br />
下記はサーバー側で受信した切断フレームのログです。切断コード(<code>code</code>キーの値)とペイロード部の応用データ(<code>data</code>キーの値)が取得できているのがわかります。<br />
確かにサーバーから送信した切断フレームがそのままブラウザから返されています。<br />
<pre aria-label="切断フレームのログ">
2024-02-25 16:45:24 debug Array
(
[close code] => 10
[payload] => {"datetime":"2024\/02\/25 16:45:24"}
)
</pre><br />
サーバーサイドではブラウザから返された切断フレームを受け取った直後に切断しています。<br />
これはRFC文書に以下の記載があるからです。<br />
<blockquote role="note" aria-label="RFC仕様からの引用">
下層の TCP 接続は、 ほとんどの通常の事例では,サーバが TIME_WAIT 状態を保持するようにするため、 クライアントではなく,まずサーバから close されるべきである。
</blockquote>
その理由はソケットがTIME_WAIT状態になると、ソケットリソースが解放されるまでにかなりの時間を要するためだと思われます。<br />
つまり切断フレームを送信するのがサーバーからであろうがクライアントからであろうがサーバー側が先に切断するべきという事になります。<br /><br />
冒頭のデモ画面で言えば以下の履歴の部分に該当します。<br />
<pre aria-label="ブラウザ:退室ボタンを押した時">
2024/02/25 14:28:28 紅蓮の村人
退室しました
</pre><br />
<h3 class="underline">②クライアントから切断フレームを送信</h3>
実際のソースがこちら↓<br />
<pre color-change="php" aria-label="app/client/chat.js">
// 切断要求を送信
let param =
{
'cmd': 'close',
'code': 3010,
'datetime': getDatetimeString()
};
websocket.close(3010, JSON.stringify(param));
</pre><br />
クライアントから直接切断要求を投げる場合は<code>close</code>コマンドを切断コードと日時文字列を添えて送信してもらうようにしています。<br />
(<code>getDatetimeString</code>関数は現在日時の文字列を<code>Y/m/d H:i:s</code>形式で取得する処理です。)<br /><br />
このように<code>websocket.close</code>メソッドの第一パラメータに切断コードを、第二パラメータにペイロード部の応用データを指定する事で任意の切断フレームをクライアント側から送信する事が可能です。<br /><br />
サーバー側で受け取った切断フレームがこちら↓<br />
<pre aria-label="サーバーログ">
2024-02-25 16:56:07 debug Array
(
[close code] => 3010
[payload] => {"cmd":"close","code":3010,"datetime":"2024/02/25 16:56:07"}
)
</pre><br />
そして先ほどと同じソースを使ってサーバーから受け取った切断フレームを確認すると以下の内容になっています。<br /><br />
<img src="./img/extra-close-frame/console_pattern2.png" width="762px" loading="lazy" alt="クライアントからの切断フレームをサーバーがレスポンスとして送信した切断フレームの通信データ" /><br /><br />
冒頭のデモ画面で言えば以下の履歴の部分に該当します。<br />
<pre aria-label="ブラウザ:退室ボタンを押した時">
2024/02/25 14:28:28 紅蓮の村人
退室しました
</pre><br />
画面の見た目は①のパターンと同じですが、内部では動作検証のため「退室する」ボタンが押される度に<code>exit</code>コマンドが送信されるパターンと<code>close</code>コマンドが送信されるパターンで切断処理が切り替わるようにしています。<br /><br />
<h3 class="underline">③サーバー側の緊急切断</h3>
今回のデモではサーバーの管理画面を設けているわけではないので、物理サーバーをシャットダウンするか<code>Ctrl+C</code>、あるいは<code>kill</code>コマンドで緊急停止した場合がこれに当たります。<br />
この場合も<code>websocket.onclose</code>を使ってイベントを拾う事ができますので、実行すると下記のログが表示されます。<br /><br />
<img src="./img/extra-close-frame/console_pattern3.png" width="762px" loading="lazy" alt="サーバーから切断された時にクライアントが返す通信データ" /><br /><br />
これを見ると<code>切断コード=1006</code>として返されている事がわかります。<br /><br />
冒頭のデモ画面で言えば以下の履歴の部分に該当します。<br />
<pre aria-label="サーバーからの強制切断">
----/--/-- --:--:-- -----
切断されました
</pre><br />
<h3 class="underline">④クライアント側の緊急切断</h3>
デモのケースで言えばブラウザの×ボタンやマインクラフトの×ボタンで閉じた場合に当てはまります。<br />
実際にブラウザ画面を閉じるとサーバーサイドのログで以下の切断フレームを受け取っている事がわかります。<br />
<pre aria-label="サーバーログ">
2024-02-25 17:00:10 debug Array
(
[close code] => 1001
[payload] =>
)
</pre><br />
これを見ると<code>切断コード=1001</code>として送られている事がわかります。<br /><br />
マインクラフトの場合は何も送られて来ないので、わざわざ切断フレームを送信するような事はしていないようです。<br /><br />
冒頭のデモ画面で言えば以下の履歴の部分に該当します。<br />
<pre aria-label="ブラウザ:×ボタンで閉じた時">
2024/02/25 14:30:26 地獄の村人
切断されました
</pre>
<pre aria-label="マインクラフト:×ボタンで閉じた時">
2024/02/25 14:32:27 マイクラー
切断されました
</pre>
</div><br />
<a id="outline"></a>
<h2 class="subtitle">切断フレームの構造</h2>
<div class="text-block">
これまでの内容をご覧頂いた通り、切断処理には色んなパターンがある事に気付いて頂けたのではないかと思います。<br /><br />
その上でもう一つ理解しておきたいのが切断フレームの構造です。<br />
この部分も正しく理解しておかないと思わぬ不具合を生んでしまう事になります。<br />
<div class="img-block">
<a href="./img/extra-close-frame/outline.png" target="_blank"><img class="img-zoomout" src="./img/extra-close-frame/outline.png" loading="lazy" alt="切断フレームのデータ構造" /></a>
</div>
<h3 class="underline">拡張データ(切断コード)の仕様</h3>
ヘッダ部の値は固定なので特に問題はないと思いますがペイロード部の切断コードの部分には注意が必要です。<br />
RFC文書には以下のように書かれています。<br />
<blockquote role="note" aria-label="RFC仕様からの引用">
本体が在る場合、 本体の最初の 2 バイトは,[ 状態コードを表現する,(ネットワークバイト順序で)2 バイトの無符号整数 ]でなければならない。
</blockquote>
普段はペイロード部を使ってサーバー/クライアント間でアプリケーション部分としてのデータのやり取りを行いますが、切断フレームの場合は拡張データとして切断コードの2バイト分を占有しますので、データを復元する時には最初の2バイト分を取り除かないといけません。<br />
また、切断コードの部分は当然ながらネットワークバイトオーダーに従う必要があり、ビッグエンディアン(数値の桁の大きい方を先頭としたバイト順)として解釈する必要があります。<br /><br />
<h3 class="underline">ペイロード長の仕様</h3>
RFC文書には以下のように書かれています。<br />
<blockquote role="note" aria-label="RFC仕様からの引用">
すべての制御フレームは :ペイロード長さは 125 バイト以下でなければならない/断片化してはならない。
</blockquote>
制御フレーム(切断フレームを含む)のペイロード長は125バイト以下でなければならないという制約があるので実際に使えるのは<code>125-2(切断コード)=123</code>バイトになります。ユーザー名などのフリー入力ができるようなデータを載せてしまうと、あっという間にオーバーするので注意が必要です。<br /><br />
試しにクライアント側から切断コードも含めて126バイトのデータを送ってみます。<br />
<pre class="php" aria-label="切断コードを含めた126バイトのデータで送信">
let data = '0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123';
websocket.close(3010, data);
</pre>
<img src="./img/extra-close-frame/console_123byte_over.png" width="626px" loading="lazy" alt="切断フレームのペイロード長が制限を超えた場合のエラー" /><br />
ご覧のようにエラーになります。
</div><br />
<a id="minecraft"></a>
<h2 class="subtitle">マインクラフトの場合</h2>
<div class="text-block">
マインクラフトからWebsocketサーバーに接続する時には<code>wsserver</code>という専用のコマンドがあるのに対して、切断用のコマンドに関してはコマンドヘルプを見廻してみても見当たりません。<br />
というわけでブラウザ版と同じようにマインクラフトから<code>exit</code>コマンドを投げて切断する事にしました。<br />
つまり上記①のパターンで<code>exit</code>コマンドを受け取ったサーバーサイドから切断フレームを投げる形になります。<br /><br />
ところがサーバーからマインクラフトへ切断フレームを投げてみてもレスポンスが全く返ってきません。<br />
仕方がないので切断フレームを投げた後にソケットを強制切断する事にしました。<br />
これで切断はできたのですが、今度はマインクラフトから再接続する時に問題が発生しました。<br /><br />
マインクラフト側の動きとしては、サーバーから切断されても再接続するのが基本的な動きのようですが、その再接続すら働かない事があります。<br />
しかも再接続が働いたとしても最初にopeningハンドシェイクが走るところまではいいのですが、その後すぐに切断されたり全く反応がなくなったりします。<br />
そこで切断時のマインクラフトのおかしな挙動を調べた上で以下4つに分けてまとめました。<br /><br />
<h3>①再接続後すぐに切断される事がある</h3>
再接続時の受信データを調べているとopeningハンドシェイクが終わった直後にマインクラフトから切断フレームが送られている事がわかりました。<br />
サーバーサイドから切断フレームを送信した時にはレスポンスを返さないのに再接続時に送られても意味がありませんので、これでは切断されてしまうのも当然です。<br />
さらに調べてみると再接続時に送られた切断フレームの内容も間違っている事に気付きました。<br /><br />
RFCには以下のように記載されています。<br />
<blockquote role="note" aria-label="RFC仕様からの引用">
Close フレームを受信した端点は、 それまでに Close フレームを送信していなかったならば,応答として Close フレームを送信しなければならない (概して,応答として Close フレームを送信する際は、 端点は受信した状態コードを返す) — 実用的な限り早く行うべきである。
</blockquote>
つまり切断フレームのレスポンス送信はなるべく早く行わなければならないのと、レスポンスを返す時は受信した切断コードと同じものを返さないといけない事になります。<br /><br />
ところがマインクラフトから受信した切断フレームは、サーバーから送信した応用データが空の状態ではあったものの、切断コードは入っていましたが異なるコードになっていました。<br />
応用データの事はRFCで明記されていないので無視できるとしても切断コードが間違っていると何の要因で切断されたものなのかが判断できなくなるので困ります。<br /><br />
そしてさらに詳しく調べてみると、切断コードをバイト反転してみたらサーバーサイドから送信した切断コードと一致している事がわかりました。<br />
つまりマインクラフトから送られた切断コードはネットワークバイトオーダーに準じていなかった事になります。<br />
RFCにも以下のように書かれています。<br />
<blockquote role="note" aria-label="RFC仕様からの引用">
本体が在る場合、 本体の最初の 2 バイトは,[ 状態°コードを表現する,(ネットワークバイト順序で)2 バイトの無符号整数 ]でなければならない。
</blockquote>
これは通信データを扱う上では致命的な不具合だと思いますが、この事を知ってしまうとWebsocketのヘッダ部もそうなっているのでは?と疑ってしまいたくなるので、この辺は早いうちに修正してもらいたいところです。<br /><br />
<h3>②再接続後反応がなくなる事がある</h3>
この症状が出た時はマインクラフト側から空パケットが延々と送られている事がわかりました。<br />
その状態でサーバーサイドからデータを送信すると即座に切断されていました。<br />
この状況を放置しているとサーバーリソースが圧迫されると同時に他の接続にも影響を及ぼしかねません。<br /><br />
<h3>③再接続動作が発動しない事がある</h3>
この挙動についてはよくわかりませんが、この状態で<code>wsserver</code>コマンドを使って再接続しようとすると上記の①や②と同じ現象が起こっている事がわかりましたので単純に再接続動作に失敗しているのだと思われます。<br /><br />
<h3>④サーバーから切断フレーム送らなければ...</h3>
そう思って試したところ、①②の挙動はなくなりましたが③の動作は再現する事があります。<br />
つまり再接続しようとする時もあれば、しない時もあり、切断したつもりでしばらく放っておくといつの間にか繋がっていたりもします。<br /><br />
<h3 class="underline">今回の対策</h3>
上記の①から③までの事を踏まえて、マインクラフトからの接続時には以下の対応を入れるようにしています。<br /><br />
①マインクラフトからの切断フレームは無視する<br />
②openingハンドシェイク直後にアライブチェックを行う<br />
③ゼロレングスパケットを一定回以上連続で受信したらアライブチェックを行う<br /><br />
はっきり言ってマインクラフトの切断処理はかなり不安定なので、マインクラフト側の対策が入るまでは④の対策をして切断フレームは送らないようにしています。<br />
一応<code>$exit</code>コマンドで切断できるようにはしていますが、これらの対策をとってもいつの間にか再接続されている現象は発生しますので注意が必要です。<br /><br />
当然ながらxボタンで閉じた場合は再接続は走らないので、クライアントから強制切断されたとみなされます。<br />
今のところ確実に切断できるのはこの方法しかないようです。
</div><br />
<a id="last"></a>
<h2 class="subtitle">おわりに</h2>
<div class="text-block">
上記を踏まえてブラウザ版とマインクラフト版を比較してみると以下のようになります。<br />
<div class="img-block">
<a href="./img/extra-close-frame/compare.png" target="_blank"><img class="img-zoomout" src="./img/extra-close-frame/compare.png" loading="lazy" alt="ブラウザ版とマインクラフト版での切断処理の比較" /></a>
</div>
これを見る限りでは、まだマインクラフト側では完全に対応しているわけではないようです。
</div>
</div>
</div>
</body>
</html>