0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

妖怪ウォッチの「カセット抜き増殖」から学ぶ分散システム(3)今、3DS の交換を作り直すなら

0
Last updated at Posted at 2026-11-06

今、3DS の交換を作り直すなら。ノートPCで実験する470くんと、おあずけ中 No.0721 の札を下げたおばけ

🕶️🚲 470(ヨンナナマル)くん
ヨットの帆なのに、陸で三輪車を漕いでいるこのブログの案内役です。今回は PC を広げて実験します。

この連載も最後です。

第1部では、妖怪ウォッチのカセット抜きで妖怪が増える理由を、妖怪の言葉だけで考えました。第2部では、その話に正式な名前をつけて、AWS や銀行や50年前の通信が同じ問題とどう付き合ってきたかを見ました。

第3部は、学んだことを使ってみる回です。

🤔 もし今、僕が 3DS の交換を作り直すとしたら?

どう作るか。それって本当にうまくいくのか。うまくいかない所はどこか。そして、この考え方はほかに何に使えるのか。

中身 レベル
第1部 妖怪ウォッチの言葉だけで、なんで壊れるのかと、それでも打てる手を考える 噛み砕き
第2部 その話の正式な名前と、AWS や銀行が実際にどうしているか 大学レベル+実務
第3部(この記事) 今、3DS の交換を作り直すならどうするか 応用

この記事でわかること

  • そもそもデータベースって何者で、普通のファイルと何が違うのか
  • 第1部の「おあずけ札」を、本物の通信・本物のセーブ・本物の強制終了で試した結果
  • おあずけ札の弱点(待たされる、時間切れ、本気のズル)
  • 中央サーバー+整理番号で作り直すと、何が解決して、何が残るのか
  • 同じ考え方が、スマホ同士のオフライン送金でどう使われようとしているか

ここで動かすのは、仕組みを理解するための実験です。実際の『妖怪ウォッチ』の作りを再現したものではありません。また、話を簡単にするために、僕のレア妖怪を相手に渡す片方向だけを追っています。


1. 実験の準備:カセットを「データベース」にする

実験の前に、道具の話を少しだけさせてください。今回の実験では、カセットの代わりにデータベースを使います。

データベースって何者?

ひとことで言うと、データをしまっておいて、あとで正しく取り出せるようにしてくれる係です。

妖怪ウォッチで言うと、カセットのセーブデータを管理してくれる係ですね。「手持ちの妖怪は何体?」と聞くと答えてくれるし、「この妖怪を足して」と頼むと書き込んでくれます。

でも、データをしまうだけなら、メモ帳のファイルに「レア妖怪:1体」と書いておくのでもできますよね。じゃあ、わざわざデータベースを使うのはなんでなんでしょうか。

普通のファイルに書く データベースに書く
書いている途中で止まったら 半分だけ書かれた、壊れたファイルが残ることがある 「書く前」か「書き終わった後」のどちらかになる
2つのことを一緒に書きたい 片方だけ書かれることがある 「全部か、何もしないか」にまとめられる
「この取引番号ある?」と探す 自分で全部読んで探す 命令1つで探してくれる

ということは、データベースは**「途中で止まっても、半分だけの状態を残さない」ための道具**でもあるんですよね。カセット抜きで困っていたのは、まさにこの「半分だけ」でした。

ただし、SQLite のように1つのファイルで完結するデータベースが守ってくれるのは、1台の中だけです。2台のカセットをまたいで「両方そろって書く」ことまではしてくれません(第2部の2相コミットのように、何台かをまたぐ仕組みを持つデータベースもありますが、そのぶん「幹事が倒れたら止まる」が付いてきます)。

データベースにもいろいろある

データベースには、大きく分けてこんな種類があります。

SQLite(エスキューライト) PostgreSQL・MySQL Amazon DynamoDB
どこにある? プログラムの中に組み込まれる。データはファイル1つ データベース専用のサーバー AWS のクラウドの中
どうやって使う? 自分のプログラムから直接読み書き ネット越しにサーバーへ頼む ネット越しに AWS へ頼む
妖怪で言うと カセットの中のセーブデータ 学校の職員室にある、みんなの台帳 遠くの大きな倉庫にある台帳

SQLite の公式サイトは、自分のことを「別のサーバーのプロセスを持たない」「データベース全体が1つのファイルに入る」と説明しています。さらに、「サーバー型のデータベースと張り合うものではなく、普通のファイルの読み書き(fopen())と張り合うものだ」とも書いています1。

3DS 1台にカセット1つ、と同じで、プログラム1つにファイル1つ。カセットの代わりにするには、ちょうどいいんですよね。しかも Python に最初から入っています。

3DS が SQLite を使っている、という意味ではありません。 本物のカセットの中身は公開されていません。ここでは「セーブが1回ずつちゃんと書ける」という条件をそろえるための道具として使っています。

1回のセーブ=トランザクション

データベースでいちばん大事な仕組みが、トランザクションです。

「ここからここまでの書き込みを、1回のセーブとして扱って」と頼むと、データベースはそれを全部書くか、何も書かないかのどちらかにしてくれます。SQLite の公式ページには、プログラムが落ちても、OS が落ちても、電源が切れても、1つのトランザクションの変更は「全部起きるか、まったく起きないか」だと書かれています2(この記事の実験で試したのは、プログラムの強制終了だけです)。

データベースへの頼みごとは、SQLという決まった言い方で書きます。おあずけ札を貼るセーブは、こうなります(説明用に書き直したものです。本物のコードでは BEGIN IMMEDIATE と書き、値は ? の所にあとから差し込みます)。

説明用
BEGIN;                                                        -- ここから1回のセーブ
UPDATE yokai SET state='おあずけ中' WHERE name='レア妖怪';    -- 札を貼る
INSERT INTO txlog VALUES ('No.0721', 'おあずけ中');            -- 取引番号をメモする
COMMIT;                                                       -- ここで確定

COMMIT(コミット)の前に止まったら、札も取引番号も、どちらも書かれていません。後に止まったら、どちらも書かれています。「札は貼ったのに取引番号のメモがない」という状態にはなりません。

命令 意味 妖怪で言うと
INSERT 行を足す 手持ちに妖怪を足す
UPDATE 行を書き換える 妖怪に札を貼る
DELETE 行を消す 手持ちから消す
SELECT 行を探して読む 「No.0721 の交換、どうなってる?」と聞く

どうやって「半分だけ」を防いでるの?
今回は、SQLite の WAL(Write-Ahead Log:先に書く記録) という方式を使いました。セーブデータ本体をいきなり書き換えずに、まず横の「下書きノート」に書き足していきます。書き終わったら、最後に「ここまで有効」という印を書く。これが COMMIT です。途中で抜かれても、印のない書きかけは無かったことになるので、半分だけのセーブは残りません3。

3DS を「2つのプログラム」に、カセット抜きを「強制終了」に

実験の登場人物は、こうしました。

実験の中 何者? 妖怪で言うと
cassette.py(2つ起動) 別々に動く2つのプログラム 僕の 3DS と、相手の 3DS
cart_a.sqlite / cart_b.sqlite それぞれの SQLite のファイル それぞれのカセット
TCP 2つのプログラムをつなぐ通信(第2部の、1974年の論文から続いているもの)。今回は同じ PC の中でつないでいます 3DS 同士の通信
SIGKILL(シグキル) プログラムを有無を言わさず止める合図。後片付けもさせない カセットを抜く

抜き方は2通りです。決めた場所で抜くときは、3DS 役が自分で自分を SIGKILL します。好きな瞬間に抜くときは、実験する係が外から SIGKILL します。電波が途切れたり遅れたりする、という本物の通信らしさは入っていません。

正しい交換なら、いつ数えても「使えるレア妖怪」は1体のはずです。


2. 今作り直すなら①:おあずけ札を、本物で動かす

おさらい:おあずけ札の手順

手順 誰が 何をセーブする?
1 僕の 3DS 送る前に、レア妖怪に**「おあずけ中」の札と取引番号(No.0721)**を貼る
2 相手の 3DS 受け取ったら「No.0721 を受け取った」と一緒にセーブ
3 僕の 3DS 相手から「受け取ったよ」が届いたら、札付きのレア妖怪を消す

僕のレア妖怪の状態だけを図にすると、こうです。点線が、あとで「増える」原因になる危ない道です。

主要なコード①:1回のセーブと、カセット抜き

cassette.py(抜粋。型の注記などは省略)
def save(self, *statements):
    self.db.execute("BEGIN IMMEDIATE")        # ここから1回のセーブ
    for sql, params in statements:
        self.db.execute(sql, params)          # 書き込みを順に実行
    self.db.execute("COMMIT")                 # 確定(ディスクに書かれる)
    self.saves += 1                           # 何回目のセーブかを数える
    if self.saves == self.crash_after:        # 「n 回目のあとで抜いて」と言われていたら
        os.kill(os.getpid(), signal.SIGKILL)  # 自分に SIGKILL = カセットを抜く
    …

いくつかの書き込みを、BEGIN〜COMMIT で1回のセーブにまとめています。IMMEDIATE は「始める時点でカセットを予約しておく」という意味で、ほかの書き込みと同時にならないようにしています。そして、決められた回数目のセーブが終わった直後に、自分で自分を強制終了します。

なんで自分で止めるのかというと、外から止めると、ちょうど「セーブの直後」を狙うのが難しいからです。どこで止めるかを正確に選ぶためにこうしています(外から好きな瞬間に止める実験は、あとでやります)。

主要なコード②:おあずけ札の手順

cassette.py(抜粋・僕の 3DS。本物は「elif proto == "oazuke":」の中)
cart.save(                                                       # 手順1. 1回のセーブで
    ("UPDATE yokai SET state='おあずけ中' WHERE name=?", (RARE,)),   #    札を貼る
    ("INSERT INTO txlog VALUES (?, 'おあずけ中')", (TX,)),          #    取引番号をメモ
)
send(conn, {"type": "GIVE", "yokai": RARE, "tx": TX})            # 送る
ack = recv(f)                                                    # 「受け取ったよ」を待つ(手順2は相手)
if ack.get("tx") == TX:
    cart.save(                                                   # 手順3. 札付きを消す
        ("DELETE FROM yokai WHERE name=? AND state='おあずけ中'", (RARE,)),
        ("UPDATE txlog SET state='完了' WHERE tx=?", (TX,)),
    )
cassette.py(抜粋・相手の 3DS。手順2)
msg = recv(f)                                                    # 「送るよ」を受け取る
done = cart.db.execute("SELECT 1 FROM txlog WHERE tx=?", (msg["tx"],)).fetchone()
if not done:                                                     # この取引番号、まだ受け取ってない?
    cart.save(                                                   # 1回のセーブで
        ("INSERT INTO yokai VALUES (?, '使える')", (msg["yokai"],)),   # 妖怪を足す
        ("INSERT INTO txlog VALUES (?, '受け取った')", (msg["tx"],)),   # 取引番号をメモ
    )
send(conn, {"type": "ACK", "tx": msg["tx"]})                     # 「受け取ったよ」を返す

第1部のおあずけ札を、そのままコードにしただけです。大事なのは2か所です。

  • 僕の 3DS は、送る前に札を貼ります。札と取引番号は同じ1回のセーブなので、片方だけにはなりません。
  • 相手の 3DS は、同じ取引番号を2回は受け取りません。第2部の「番号を見て2回目を捨てる」です。

相手が抜かれると、通信がブツッと切れます。そのとき、こっちの 3DS はそれ以上何もセーブせずにやめるようにしました。カセットを抜くと「1回エラーが出て、ホーム画面に戻る」、あの感じです。

比べるために、第1部のパターン1(先に僕が渡したとセーブ)とパターン2(先に相手が受け取ったとセーブ)も、同じように作りました。

主要なコード③:抜く場所を、全部試す

run_all.py(抜粋)
for k in range(len(steps) + 1):      # k = 0, 1, 2, 3(抜く場所を全部)
    fresh()                          # 2つのカセットを最初の状態に戻す
    a, b = trade(proto, **crash_args(steps, k))   # k 回目のセーブの直後に抜く設定で交換
    finish(a, b)                     # 2台とも止まるまで待つ
    print(row(where(steps, k)) + …)  # 2つのカセットを開いて数え、表の1行を出す

おあずけ札のセーブは全部で3回です(僕→相手→僕)。「何もする前」「1回目の後」「2回目の後」「3回目の後」の4か所で抜くのを、1つずつ全部試します。

結果:決めた場所で抜く

結果の表は、実行ログから転記しています(記録の欄は短くしています)。

パターン2(先に相手が受け取ったとセーブ)

抜いた所 僕のカセット 相手のカセット 使えるレア妖怪 判定
最初のセーブの前 使える なし 1 OK
「相手が受け取った」をセーブした直後 使える 使える 2 増えた!
最後まで終わった なし 使える 1 OK

真ん中で抜くと増えました。 僕がやってたカセット抜きは、たぶんこの行です。パターン1のほうは、同じ真ん中で「使えるのは0体」(消える)になりました。

おあずけ札

抜いた所 僕のカセット 相手のカセット 使えるレア妖怪 判定
最初のセーブの前 使える なし 1 OK
「僕が札と取引番号」をセーブした直後 おあずけ中 なし 0 使えるのは0体
「相手が受け取りと取引番号」をセーブした直後 おあずけ中 使える 1 OK
最後まで終わった なし 使える 1 OK

どこで抜いても、使えるレア妖怪が2体になる行はありませんでした。

そのかわり、2行目は**「使えるのは0体」**です。レア妖怪は僕のカセットにいるけど、札が貼られていて使えない。ここをどうするかが、次の章の話です。

結果:好きな瞬間に、外から抜く

ここまでは「セーブの直後」という、きれいな場所でしか抜いていませんでした。でも、本当のカセット抜きは、いつ抜くか決まっていませんよね。

なので、交換を始めてから 0〜0.08 秒のどこかで、僕か相手のどちらかを外から強制終了する実験も、200 回ずつやりました。セーブ1回に 0.02 秒かかることにしてあるので、抜く瞬間は交換のあちこちに散らばります。COMMIT の真っ最中や、メッセージを送っている途中で抜かれる回も入りえます(どの回がそうだったかまでは記録していません)。

run_all.py(抜粋)
a, b = trade(proto, env=env)          # 普通に交換を始める
victim = rnd.choice([a, b])           # 僕か相手か、どちらかをランダムに選ぶ
time.sleep(rnd.uniform(0, 0.08))      # 0〜0.08秒のどこかまで待って
if victim.poll() is None:             # まだ動いていたら
    victim.send_signal(signal.SIGKILL)   # 外から SIGKILL = カセットを抜く
やり方(各200回) 使えるのは0体 1体(OK) 2体(増えた)
パターン1:先に僕が渡したとセーブ 62 138 0
パターン2:先に相手が受け取ったとセーブ 0 132 68
おあずけ札 64 136 0

パターン2は、だいたい3回に1回増えました。おあずけ札は、200 回で一度も増えませんでした。なお、パターン2の 14 回は、抜こうとしたときにはもう交換が終わっていました(抜けなかった回も 200 回に入っています)。

200 回で一度も増えなかったのは、「絶対に増えない」ことの証明ではありません。また、プログラムの動く速さは毎回少しずつ違うので、回し直すと数は少し変わります(「0体」と「2体」が出るかどうかは変わりませんでした)。

僕の勘違い④:SQLite を使ったから大丈夫?

この結果を見たとき、僕は最初、「交換する前に妖怪の状態を変えておけば、途中で通信が切れても、SQLite がセーブした所に戻してくれるから大丈夫なんだ」と思っていました。

これも、半分違いました。

僕が最初に思ってた 実は
切れたら セーブした所に戻る 戻るというより、札が貼られたまま(0体)で止まる。元に戻るのは、相手と答え合わせしたとき
守ってくれたもの SQLite も一緒に守ってくれている SQLite がしてくれるのは「1回のセーブを半分にしない」まで。増えないのは、札を先に貼る順番のおかげ

同じ SQLite を使っていても、パターン2は増えています。SQLite が守ってくれるのは「1回のセーブが半分にならない」ことまでで、2台をまたいだ順番までは面倒を見てくれません。

データベースは土台。増えないようにしていたのは「順番」のほうだったんです。


3. 止まったあと、どうする?

おあずけ札の「0体」の状態や、札付きが残っている状態は、止まったあとに片付ける必要があります。3通り試しました。

① 同じ相手と再会して、答え合わせする

次に同じ相手とつないだとき、僕の 3DS が「No.0721 ってそっちで受け取ってる?」と聞きます。受け取っていれば札付きを消して完了、知らなければ札をはがして戻します。実験では、2つのプログラムをもう一度本当に通信させて答え合わせをしました。

抜いた所 答え合わせのあと、僕のカセット 相手のカセット 使えるレア妖怪 判定
最初のセーブの前 使える なし 1 OK
「僕が札と取引番号」をセーブした直後 使える(取り消し) なし 1 OK
「相手が受け取りと取引番号」をセーブした直後 なし(完了) 使える 1 OK
最後まで終わった なし(完了) 使える 1 OK

全部1体になりました。……再会できれば、ですけど。

② 相手と二度と会わない

休み時間に一回だけ交換した子と、それっきりだったら。答え合わせができないので、何もできません。結果は上の「決めた場所で抜く」と同じで、2行目は「使えるのは0体」のままです。僕のレア妖怪は、ずっとおあずけです。

③ 会えないので、時間切れで札をはがす

「じゃあ3日たったら、相手に聞かずに札をはがそう」としてみます(実験では、3日たったことにして、すぐはがしています)。

抜いた所 札をはがしたあと、僕のカセット 相手のカセット 使えるレア妖怪 判定
最初のセーブの前 使える なし 1 OK
「僕が札と取引番号」をセーブした直後 使える(時間切れで取り消し) なし 1 OK
「相手が受け取りと取引番号」をセーブした直後 使える(時間切れで取り消し) 使える 2 増えた!
最後まで終わった なし(完了) 使える 1 OK

増えました。 相手がもう受け取っていたのに、僕が勝手に札をはがしたからです。

3通りを並べると

止まったあと 使えるレア妖怪 払うもの
① 再会して答え合わせ いつも1体 再会するまで待たされる
② 二度と会わない 0体のままのことがある ずっとおあずけ
③ 時間切れで札をはがす 2体になることがある 増える危険

ということは、ここで第2部の話がそのまま出てきます。

  • 増えないことを守るなら、相手と答え合わせできるまで待たされる
  • 待たせないために時間で区切ると、増えるかもしれない

サーバーなしの2台だけなら、正しさと待ち時間の綱引きは、どう作り直しても消えない。

第2部で見た AWS が、重複を捨てるのを「10分」「5分」「1時間」と時間で区切っていたのも、この綱引きと同じ形をしているんですよね。時間を過ぎてから来た2回目は防げない、という作りです。

「増えない」の盾と「待たされない」の砂時計のシーソーで、470くんがバランスを取ろうとして困っている


4. 本気のズルには、カセットの中だけでは勝てない

もう1つ、試したことがあります。カセット抜きより本気のズルです。

交換の前に、カセットのファイルを丸ごとコピーして取っておく。交換が正しく終わったあとで、下書きノート(WAL のファイル)ごと消して、取っておいたファイルで上書きする。

run_all.py(抜粋)
shutil.copyfile(CART_A, backup)   # 交換の前に、カセットのファイルをコピーして取っておく
finish(*trade("oazuke"))          # 交換は最後まで正しく終わる
…                                 # 下書きノート(-wal・-shm のファイル)を消す
shutil.copyfile(backup, CART_A)   # 取っておいたファイルで上書きする(=ズル)
手順 僕のカセット 相手のカセット 使えるレア妖怪 判定
交換が正しく終わった直後 なし(完了) 使える 1 OK
僕が交換前のファイルで上書きした 使える(記録なし) 使える 2 増えた!
そのあと再会して答え合わせ 使える(記録なし) 使える 2 増えた!

増えました。しかも、答え合わせをしても直りません。書き戻したファイルには「No.0721 の交換をした」という記録自体が残っていないので、僕の 3DS は答え合わせをする理由がないからです。

データベースは「1回のセーブを半分にしない」ことは守ってくれます。でも、ファイルそのものを昔のものに差し替えられると、何もできません。

第2部で、故障には「止まる」だけじゃなくて「わざと変なことをする」がある、という話をしました。

おあずけ札が守れるのは、止まる故障までです。
ズルをする相手に勝つには、カセットの外に、書き戻しにくい記録が要りそうです(この実験で確かめたのは、カセットの中だけでは負けることまでです)。

書き戻しにくい記録の置き場所 どうなる?
3DS 本体に覚えさせる 3DS には、セーブの書き戻しを見破る仕組み(Secure Value)が実際にあるそうです。ただ、カセットは別の 3DS に挿して遊べるので、本体だけに記録を持つとそこで困りそうです4
サーバーに覚えさせる 交換をサーバーに通して、サーバーが「No.0721 はもう終わった」を覚えておく。次の章で、これを作ってみます

5. 今作り直すなら②:中央サーバー+整理番号

じゃあ、セーブをカセットに持たせない作りにしたらどうなるか。これも作って試しました。

たとえ:妖怪の「交換所」

中身
台帳 妖怪は全部、交換所の台帳1冊(サーバーの SQLite)で管理する
3DS 交換所に「僕のレア妖怪を相手に渡して」と頼むだけ
交換所 台帳を書き換えて、「交換しました」と返事をする

台帳が1冊なら、「僕から消す」と「相手に足す」を1回のセーブで書けます。なので、3DS 同士のときの「僕のほうは書いたのに、相手のほうはまだ」という食い違いは、そもそも起きません。

でも、「返事が届かない」問題は残る

困るのは、ここです。

  1. 交換所は台帳を書き換えた(交換は終わっている)
  2. 「交換しました」と返事をする直前に、交換所が止まった
  3. 3DS には返事が来ないので、「失敗したのかな?」と思ってもう1回頼む

これ、第2部の「返事が消えて送り直したら2回届く」と同じ形ですよね。実験では、交換所のプログラムを台帳を確定した直後、返事を返す前に強制終了させました。交換所はすぐ立ち上がり直して、3DS は第2部の AWS のやり方どおり、間を空けて、少しずつずらして(最大3回)送り直します。

交換所の作りを3つ比べる

server_trade.py(抜粋)
# ① 何も確かめない:僕を -1
db.execute("UPDATE hand SET rare = rare - 1 WHERE owner=?", (req["from"],))

# ② 「まだ1体以上持っているときだけ」-1
cur = db.execute("UPDATE hand SET rare = rare - 1 WHERE owner=? AND rare >= 1", (req["from"],))
moved = cur.rowcount == 1     # 書き換えた行が 0 なら「もう持っていない」

①と②の違いは、AND rare >= 1 があるかどうかだけです。②は、「持っているか確かめる」と「書き換える」を、データベースが一度にやってくれます。

③は、②に整理番号を足したものです。3DS は頼むたびに Idempotency-Key という欄に番号を付けて、交換所はその番号を処理済みノートに書いておきます。

server_trade.py(抜粋)
hit = db.execute("SELECT req_hash, status, body FROM idem WHERE key=?", (key,)).fetchone()
if hit:                                       # この整理番号、もう処理済み?
    …                                         #   (中身が前と違えば 422 で断る)
    return hit[1], json.loads(hit[2]), True   #   → 交換はせず、前の返事をそのまま返す
…                                             # 初めての番号なら、②と同じように交換して
db.execute("INSERT INTO idem VALUES (?, ?, ?, ?)",           # ノートに書く
           (key, req_hash, status, json.dumps(body, ensure_ascii=False)))
maybe_crash("before_commit")                  # (実験用:ここで止まる設定もできる)
db.execute("COMMIT")                          # 交換とノートを「同じ1回のセーブ」で確定
maybe_crash("after_commit")                   # (実験用:確定の直後、返事の前に止まる)

大事なのは COMMIT の行です。交換とノートを、同じ1回のセーブで書いています。 もし別々に書くと、その間で止まったときに「交換したのにノートがない」が起きます。そうすると、送り直しが来ても番号では見分けられません。この実験の作りなら「持っているか」のチェックが止めてくれるので増えはしませんが、②と同じ「失敗に見える」に戻ってしまいます。チェックのない操作なら、2回実行されてしまうはずです(別々に書く形は試していません)。

結果

「台帳を確定した直後、返事の前に交換所が止まる」→ 3DS が送り直す、の結果です(実行ログから転記)。

交換所の作り 2回目の返事 台帳の結果
① 何も確かめない 「交換しました」 僕 -1体 / 相手 2体(増えた)
② 持っているときだけ 「もう持っていません」 僕 0 / 相手 1(増えない。でも 3DS には失敗に見える)
③ ②+整理番号 「交換しました(前の返事)」 僕 0 / 相手 1(増えない。しかも成功と伝わる)

「止まらない」と「確定の直前に止まる」の場合は、3つとも正しく 僕 0 / 相手 1 になりました。

②がちょっと面白くて、増えはしないけど、3DS からは自分の交換が成功したのか分からないんですよね。本当は1回目で交換できているのに、「もう持っていません」と言われてしまう。③の整理番号を足して、やっと「増えない」と「成功したと分かる」が両立しました。

ほかにも、こんなことを試しました。

試したこと 結果
同じ番号で 10本同時に頼む 交換は1回だけ。10本とも「交換しました」(うち9本は前の返事)
同じ番号で中身を変えて頼む 「その番号は別の頼みに使われています」と断られる
新しい番号で同じ交換をもう一度 「もう持っていません」。止めたのは番号ではなく、②の「持っているか」のチェック

整理番号の約束

ということは、冪等性(第2部の「何回やっても1回分」)は、取引ごとに番号を付けて、頼みが届くたびにその番号を確かめることで作れるんですよね。ただ、実験と答え合わせから整理すると、守らないといけない約束がいくつかありました。

約束 なんで? 根拠
番号は人ではなく、取引1回ごとに付ける 送り直しは同じ番号、新しい交換は新しい番号。人ごとの番号だと、2回目の交換まで断ってしまう 推論
番号は送る前に決めて、3DS 側もセーブしておく 3DS が落ちて番号を忘れると、送り直しが別の頼みに見えてしまう 推論(第2部の Stripe の実験とつながる)
交換所は「確かめる → 交換 → ノートに書く」を1回のセーブで 別々だと、その間で止まったときに番号で見分けられなくなる この実験の作り
番号をどのくらい覚えておくかを決める ずっとは覚えられない。過ぎてから届いた送り直しは防げない 公式ドキュメント(AWS は10分・5分、Stripe は24時間)。この実験の交換所には期限がない
番号と「持っているか」のチェックは両方要る 番号が見分けるのは送り直しだけ 実験で確認(新しい番号の2回目を止めたのはチェック)

最後の「番号を送る前に決めて、セーブしておく」って、第1部のおあずけ札で、取引番号をカセットにセーブしていたのと同じ理由なんですよね。

サーバーにすれば、全部解決?

3DS 同士(おあずけ札) 中央サーバー+整理番号
抜かれても増えない ⭕ ⭕
止まったあと待たされる ❌ 再会するまで0体 ⭕ サーバーが動いていれば、送り直してすぐ分かる(待つ相手が、交換相手からサーバーに変わる)
セーブの書き戻し ❌ 負ける ⭕ 3DS 側のファイルを戻しても、サーバーの台帳は変わらない(推論。試していない)
払うもの ― 通信のたびにサーバーが要る。運営のお金もかかる

サーバーにすると、第3章の「待たされる」も、第4章の「書き戻し」も解決します。そのかわり、サーバーがないと交換できないし、サーバーを動かし続けるお金がかかります。第2部の PayPay や Stripe も、お店が付けた番号で送り直しを見分ける、という点では同じ考え方でした(中身の作りまでは公開されていません)。


6. それって、高くつくの?

調査を読んでいたとき、僕が気になっていたのがこれでした。3DS でこれを作るのって、そんなに大変だったのか。

レベルファイブは中身を公開していないので、ここからは全部、僕の推測です。

論点 高い? 理由
札を貼る 安い 札と取引番号を付けて、セーブを1回増やすだけ
札をはがすタイミング 高い 再会しないとはがれない。時間ではがすと増える
ズルまで防ぐ もっと高い カセットの外の記録が要る。本体なら挿し替えで困る、サーバーなら運営費

札を貼るだけなら、安い

やることは、妖怪に札と取引番号を付けて、セーブを1回増やすだけです。実験のコードを見ても、パターン1・2との違いは数行でした。

高いのは、札をはがすタイミング

第3章のとおり、札がちゃんとはがれるのは同じ相手と再会したときだけです。小学生の交換は、その場限りのことも多いと考えられます。レア妖怪がずっとおあずけのままになったら、たぶん「妖怪が使えなくなった」という問い合わせが来ます。かといって時間で札をはがすと、増えます。

ズルまで防ぐなら、もっと高い

第4章・第5章のとおり、本気のズルを防ぐには、カセットの外の記録が要ります。本体に持てばカセットを挿し替えたときに困るし、サーバーに持てば通信のたびにサーバーが要って、運営のお金もかかります。

そもそも、割に合うのか

オフラインのゲームで妖怪が増えても、誰かのお金が消えるわけではありません。実際、確認できた範囲では、『バスターズ』での公式の対応は「高確率でデータが破壊されます」という警告と、更新データでの「セーブ時の処理の改善」「増殖技の発生率を下げました」でした。ゼロにしたとは書いていません。

ということは、完全に防ぐよりも、修正と注意で済ませる、という判断だったのかもしれません。

逆に、第2部の銀行や PayPay は、同じ問題に本気でお金と手間をかけています。人のお金が動くからだと考えられます。
:::note warn
同じ問題でも、どこまで守るかは、守るものの重さで決まるのかもしれません。
:::


7. ほかに使えそうな場面:スマホ同士のオフライン送金

最後に、この考え方が今まさに使われようとしている場所を見てみます。

中央銀行がデジタルのお金を発行するとき、ネットがなくてもスマホ同士を近づけるだけで送金できるようにする研究が進んでいます。たとえば、停電で基地局もネットも止まったような場面でも使えるかもしれません。日本銀行も2020年の資料で、オフラインで払えるようにするための課題を検討しています。

これ、条件だけ見ると、サーバーなしの 3DS 同士の交換とそっくりなんですよね。第5章のサーバー版が使えない場面です。

対策を並べると

カナダの中央銀行(2023年)と、国際決済銀行 BIS のハンドブック(2023年)を見ると、対策はこう整理されていました。

オフライン送金の対策 3DS の交換で言うと
改ざんしにくい専用のチップの中にお金の記録を置く カセットの外の、書き戻しにくい記録(第4章の答え)
持てる金額、1回に送れる金額、オフラインでいられる期間に上限を付ける 「交換できるのは1日1体まで」みたいに、最悪の被害を小さくする
あとでネットにつながったときに記録を突き合わせて、不正を見つける 再会したときの答え合わせ(第3章の①)
相手と同期する前に、その場で確定してしまうと二重使用につながりうる 時間切れで札をはがすと増える(第3章の③)

面白いのは、どちらの資料も完全な防止はできないと認めていることです。BIS のハンドブックにははっきり「There are no perfect technologies」と書いてあって、チップが破られる前提で、上限と、あとからの検知を重ねています。

妖怪の交換で考えたことが、ほとんどそのまま国のお金の設計に出てくるんですよね。

左は2台のゲーム機の間をおあずけ中のおばけが行き来する3DSの交換、右は金庫の付いたスマホ同士でコインが移るオフライン送金。2つは似ている


まとめ

第3部のまとめ

  • データベースは「1回のセーブを半分にしない」道具。でも守ってくれるのは1台の中だけ。
  • おあずけ札を本物の通信・セーブ・強制終了で試すと、決めた場所で抜いても、好きな瞬間に200回抜いても、使えるレア妖怪は2体にならなかった。守ったのはデータベースではなく、札を先に貼る順番。
  • そのかわり「使えるのは0体」の時間がある。再会して答え合わせできれば1体に戻り、時間切れで札をはがすと増える。サーバーなしの2台だけなら、正しさと待ち時間の綱引きは消えない。
  • セーブの書き戻しには、カセットの中だけでは勝てない。
  • 中央サーバーにすると食い違いは消えるけど、返事が消えて送り直すと増える。防ぐのは、取引ごとの整理番号を、交換と同じ1回のセーブでノートに書くこと。
  • どこまで守るかは、守るものの重さで決まるのかもしれない。妖怪の交換と国のオフライン送金は、同じ問題を違う重さで扱っている。

僕の学び

妖怪ウォッチの交換で、妖怪が増えたり消えたりするバグ。そういう単純な疑問から「なんで起こるんだろう?」と思って調べてみると、銀行のシステムや決済システムなど、多くのインフラで同じような問題があるんだなぁ、ということがわかりました。

そこから分散システムや、人々がこれをどうやって解決してきたかの歴史を読むことで、3DS の実装の裏に隠されていた経緯なども見えてきて、面白かったです。

実際にどういう仕様になっているかはわからないんですけど、今後もこんなふうに、裏側の仕組みを解いていこうと思っています。よろしくお願いします。

🕶️🚲 テルテール、ピシッと風に乗りました。連載はここでおしまいです。

連載をふりかえって

小学生の時にカセットを抜いて妖怪を増やしていたのが、50年前の通信や、AWS や、銀行や、国のお金の設計とつながっている。

足し算と引き算が1回ずつの交換でも、それが別々の機械で行われるだけで、ここまでつながっていました。第1部から読んでくれた人、ありがとうございました。


連載「妖怪ウォッチの『カセット抜き増殖』から学ぶ分散システム」

実験の動かし方と実行環境
  • 3DS 同士の実験:python3 run_all.py(cassette.py を2つ起動して交換させます)
  • サーバー版の実験:python3 run_server.py(server_trade.py を起動して頼みます)
  • どちらも Python の標準ライブラリだけで動きます。
  • 実行環境:macOS、Python 3.14.10 / SQLite 3.53.1。Python 3.9.6 / SQLite 3.51.0 でも同じ結果でした(好きな瞬間に抜く実験だけは、回すたびに数が少し揺れます)。
3DS 役のコード全体(cassette.py)
cassette.py
"""3DS 1 台分の役。別プロセスとして起動し、相手と TCP で通信し、SQLite(=カセット)にセーブする。

仕組みを理解するための実験で、実際の『妖怪ウォッチ』の実装ではない。
僕(A)のレア妖怪 1 体を相手(B)に渡す片方向だけを追う。

  python cassette.py --role A|B --proto 名前 --db ファイル --port 番号 [--crash-after N]

--crash-after N : 自分の N 回目のセーブを commit した直後に、自分に SIGKILL を送る(=カセット抜き)。
"""

from __future__ import annotations

import argparse
import json
import os
import signal
import socket
import sqlite3
import sys
import time

TX = "No.0721"
RARE = "レア妖怪"

# ---- カセット(SQLite) -------------------------------------------------

def open_cart(path: str) -> sqlite3.Connection:
    db = sqlite3.connect(path, isolation_level=None)  # BEGIN/COMMIT を自分で書く
    db.execute("PRAGMA journal_mode=WAL")    # 書きかけで止まっても、前の状態か後の状態のどちらかに戻る
    db.execute("PRAGMA synchronous=FULL")    # commit のたびにディスクまで書く
    return db

def init_cart(path: str, has_rare: bool) -> None:
    for suffix in ("", "-wal", "-shm"):
        if os.path.exists(path + suffix):
            os.remove(path + suffix)
    db = open_cart(path)
    db.execute("CREATE TABLE yokai (name TEXT PRIMARY KEY, state TEXT NOT NULL)")  # 使える / おあずけ中
    db.execute("CREATE TABLE txlog (tx TEXT PRIMARY KEY, state TEXT NOT NULL)")     # 取引番号の記録
    if has_rare:
        db.execute("INSERT INTO yokai VALUES (?, '使える')", (RARE,))
    db.close()

class Cartridge:
    """セーブ 1 回 = SQLite のトランザクション 1 回。--crash-after 回目の直後に自分を SIGKILL する。"""

    def __init__(self, path: str, crash_after: int | None):
        self.db = open_cart(path)
        self.saves = 0
        self.crash_after = crash_after

    def save(self, *statements: tuple[str, tuple]) -> None:
        self.db.execute("BEGIN IMMEDIATE")
        for sql, params in statements:
            self.db.execute(sql, params)
        self.db.execute("COMMIT")                 # ここでディスクに書かれる
        self.saves += 1
        if self.saves == self.crash_after:
            os.kill(os.getpid(), signal.SIGKILL)  # 後片付けもさせずに即死(カセットを抜いた)
        time.sleep(float(os.environ.get("STEP_SLEEP", "0")))  # ランダム抜き実験用の「セーブにかかる時間」

# ---- 通信(1 行 1 メッセージの JSON) --------------------------------------

def send(conn: socket.socket, msg: dict) -> None:
    conn.sendall((json.dumps(msg, ensure_ascii=False) + "\n").encode())

def recv(f) -> dict:
    line = f.readline()
    if not line:  # 相手が死ぬと、接続が切れて空が返る
        raise ConnectionError("相手との通信が切れた")
    return json.loads(line)

# ---- 交換のやり方 ---------------------------------------------------------
# どちらも「通信が切れたら、それ以上は何もセーブせずにやめる」(エラーでホーム画面に戻る)。

def a_side(proto: str, cart: Cartridge, conn: socket.socket, f) -> None:
    if proto == "pattern1":                       # 先に僕が「渡した」をセーブ
        cart.save(("DELETE FROM yokai WHERE name=?", (RARE,)))
        send(conn, {"type": "GIVE", "yokai": RARE})
        recv(f)                                   # 受け取ったよ
    elif proto == "pattern2":                     # 先に相手が「受け取った」をセーブ
        send(conn, {"type": "GIVE", "yokai": RARE})
        recv(f)
        cart.save(("DELETE FROM yokai WHERE name=?", (RARE,)))
    elif proto == "oazuke":                       # おあずけ札
        cart.save(                                # 1. 札と取引番号を「同じセーブ」で書く
            ("UPDATE yokai SET state='おあずけ中' WHERE name=?", (RARE,)),
            ("INSERT INTO txlog VALUES (?, 'おあずけ中')", (TX,)),
        )
        send(conn, {"type": "GIVE", "yokai": RARE, "tx": TX})
        ack = recv(f)
        if ack.get("tx") == TX:                   # 3. 受け取ったよが来たら札付きを消す
            cart.save(
                ("DELETE FROM yokai WHERE name=? AND state='おあずけ中'", (RARE,)),
                ("UPDATE txlog SET state='完了' WHERE tx=?", (TX,)),
            )
    else:
        raise ValueError(proto)

def b_side(proto: str, cart: Cartridge, conn: socket.socket, f) -> None:
    msg = recv(f)
    if proto in ("pattern1", "pattern2"):
        cart.save(("INSERT INTO yokai VALUES (?, '使える')", (msg["yokai"],)))
        send(conn, {"type": "ACK"})
    elif proto == "oazuke":
        done = cart.db.execute("SELECT 1 FROM txlog WHERE tx=?", (msg["tx"],)).fetchone()
        if not done:                              # 同じ取引番号は 2 回受け取らない
            cart.save(                            # 2. 受け取りと取引番号を同じセーブで
                ("INSERT INTO yokai VALUES (?, '使える')", (msg["yokai"],)),
                ("INSERT INTO txlog VALUES (?, '受け取った')", (msg["tx"],)),
            )
        send(conn, {"type": "ACK", "tx": msg["tx"]})

# ---- 再会したときの答え合わせ(A が聞き、B が自分の記録で答える) -----------------

def a_reconcile(cart: Cartridge, conn: socket.socket, f) -> str:
    row = cart.db.execute("SELECT state FROM txlog WHERE tx=?", (TX,)).fetchone()
    if not row or row[0] != "おあずけ中":
        send(conn, {"type": "BYE"})
        return "答え合わせ不要"
    send(conn, {"type": "ASK", "tx": TX})
    ans = recv(f)
    if ans["received"]:
        cart.save(("DELETE FROM yokai WHERE name=? AND state='おあずけ中'", (RARE,)),
                  ("UPDATE txlog SET state='完了' WHERE tx=?", (TX,)))
        return "相手は受け取っていた → 札付きを消した"
    cart.save(("UPDATE yokai SET state='使える' WHERE name=?", (RARE,)),
              ("UPDATE txlog SET state='取り消し' WHERE tx=?", (TX,)))
    return "相手は知らない → 札をはがした"

def b_reconcile(cart: Cartridge, conn: socket.socket, f) -> str:
    msg = recv(f)
    if msg["type"] == "BYE":
        return "-"
    row = cart.db.execute("SELECT state FROM txlog WHERE tx=?", (msg["tx"],)).fetchone()
    send(conn, {"type": "ANSWER", "received": bool(row and row[0] == "受け取った")})
    return "自分の記録で答えた"

# ---- 起動 -------------------------------------------------------------------

def main() -> None:
    p = argparse.ArgumentParser()
    p.add_argument("--role", choices=["A", "B"], required=True)
    p.add_argument("--proto", required=True)  # pattern1 / pattern2 / oazuke / reconcile
    p.add_argument("--db", required=True)
    p.add_argument("--port", type=int, default=0)
    p.add_argument("--crash-after", type=int)
    args = p.parse_args()

    cart = Cartridge(args.db, args.crash_after)
    if args.proto == "timeout_unlock":  # 相手に聞かずに、時間切れで札をはがす(悪い例)。通信しない
        n = cart.db.execute("SELECT count(*) FROM txlog WHERE tx=? AND state='おあずけ中'", (TX,)).fetchone()[0]
        if n:
            cart.save(("UPDATE yokai SET state='使える' WHERE name=?", (RARE,)),
                      ("UPDATE txlog SET state='時間切れで取り消し' WHERE tx=?", (TX,)))
        print("時間切れで札をはがした" if n else "はがす札なし", flush=True)
        return
    if args.role == "B":  # 相手側が待ち受ける(--port 0 なら空いている番号を使う)
        srv = socket.create_server(("127.0.0.1", args.port))
        srv.settimeout(float(os.environ.get("NET_TIMEOUT", "5")))
        print("READY", srv.getsockname()[1], flush=True)
        try:
            conn, _ = srv.accept()
        except socket.timeout:
            print("通信エラー:相手が来なかった(ホーム画面に戻る)", flush=True)
            sys.exit(1)
    else:
        conn = socket.create_connection(("127.0.0.1", args.port), timeout=5)
        if args.crash_after == 0:  # 最初のセーブの前に抜く
            os.kill(os.getpid(), signal.SIGKILL)
    conn.settimeout(float(os.environ.get("NET_TIMEOUT", "5")))
    f = conn.makefile("r", encoding="utf-8")
    try:
        if args.proto == "reconcile":
            out = (a_reconcile if args.role == "A" else b_reconcile)(cart, conn, f)
            print(out, flush=True)
        else:
            (a_side if args.role == "A" else b_side)(args.proto, cart, conn, f)
            print("交換完了", flush=True)
    except (ConnectionError, socket.timeout, json.JSONDecodeError) as e:
        print(f"通信エラー:{e}(ホーム画面に戻る)", flush=True)
        sys.exit(1)

if __name__ == "__main__":
    main()
交換所のコード全体(server_trade.py)
server_trade.py
"""サーバー版の交換:セーブはサーバーの SQLite だけに置き、3DS 役は HTTP で「交換して」と頼むだけ。

  python3 server_trade.py --db server.sqlite --mode naive|conditional|idempotent [--crash before_commit|after_commit]

mode
  naive        : 僕の手持ちを -1、相手を +1 するだけ
  conditional  : 「僕がまだ持っているときだけ」-1 する(DynamoDB の条件付き書き込みと同じ考え方)
  idempotent   : conditional + Idempotency-Key(同じ番号の 2 回目は、前の結果を返すだけ)

--crash after_commit : commit した直後、返事を返す前に自分を SIGKILL(=返事だけ消える)
--crash before_commit: commit の直前に自分を SIGKILL(=何も起きなかった)
"""

from __future__ import annotations

import argparse
import hashlib
import json
import os
import signal
import sqlite3
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

ARGS = None

def db_open(path: str) -> sqlite3.Connection:
    db = sqlite3.connect(path, isolation_level=None, timeout=10)
    db.execute("PRAGMA journal_mode=WAL")
    db.execute("PRAGMA synchronous=FULL")
    return db

def init_db(path: str) -> None:
    for s in ("", "-wal", "-shm"):
        if os.path.exists(path + s):
            os.remove(path + s)
    db = db_open(path)
    db.execute("CREATE TABLE hand (owner TEXT PRIMARY KEY, rare INTEGER NOT NULL)")  # 手持ちのレア妖怪の数
    db.execute("CREATE TABLE idem (key TEXT PRIMARY KEY, req_hash TEXT NOT NULL,"    # 処理済みノート
               " status INTEGER NOT NULL, body TEXT NOT NULL)")
    db.executemany("INSERT INTO hand VALUES (?, ?)", [("A", 1), ("B", 0)])
    db.close()

def maybe_crash(point: str) -> None:
    if ARGS.crash == point:
        os.kill(os.getpid(), signal.SIGKILL)

def trade(db: sqlite3.Connection, req: dict, key: str | None) -> tuple[int, dict, bool]:
    """1 回の交換を 1 つのトランザクションで行う。戻り値:(HTTP status, 返す本文, 前の結果の再送か)"""
    req_hash = hashlib.sha256(json.dumps(req, sort_keys=True).encode()).hexdigest()
    db.execute("BEGIN IMMEDIATE")  # ほかの依頼とは 1 つずつ順番に処理される
    try:
        if ARGS.mode == "idempotent":
            if not key:
                db.execute("ROLLBACK")
                return 400, {"error": "Idempotency-Key ヘッダーが要ります"}, False
            hit = db.execute("SELECT req_hash, status, body FROM idem WHERE key=?", (key,)).fetchone()
            if hit:
                db.execute("ROLLBACK")
                if hit[0] != req_hash:  # 同じ番号なのに中身が違う
                    return 422, {"error": "この Idempotency-Key は別の依頼に使われています"}, False
                return hit[1], json.loads(hit[2]), True  # 2 回目:何もせず、前の結果を返す

        if ARGS.mode == "naive":
            db.execute("UPDATE hand SET rare = rare - 1 WHERE owner=?", (req["from"],))
            moved = True
        else:  # conditional / idempotent
            cur = db.execute("UPDATE hand SET rare = rare - 1 WHERE owner=? AND rare >= 1", (req["from"],))
            moved = cur.rowcount == 1           # 0 行なら「もう持っていない」
        if moved:
            db.execute("UPDATE hand SET rare = rare + 1 WHERE owner=?", (req["to"],))
            status, body = 200, {"result": "交換しました"}
        else:
            status, body = 409, {"error": "そのレア妖怪はもう持っていません"}

        if ARGS.mode == "idempotent":            # 結果も同じトランザクションで覚える
            db.execute("INSERT INTO idem VALUES (?, ?, ?, ?)",
                       (key, req_hash, status, json.dumps(body, ensure_ascii=False)))
        maybe_crash("before_commit")
        db.execute("COMMIT")
        maybe_crash("after_commit")              # 交換は確定したのに、返事が届かない
        return status, body, False
    except BaseException:
        if db.in_transaction:
            db.execute("ROLLBACK")
        raise

class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        if self.path != "/trade":
            self.send_error(404)
            return
        req = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
        db = db_open(ARGS.db)
        try:
            status, body, replayed = trade(db, req, self.headers.get("Idempotency-Key"))
        finally:
            db.close()
        data = json.dumps(body, ensure_ascii=False).encode()
        self.send_response(status)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(data)))
        if replayed:
            self.send_header("Idempotent-Replayed", "true")  # Stripe と同じ名前の印
        self.end_headers()
        self.wfile.write(data)

    def log_message(self, *a):
        pass

def main() -> None:
    global ARGS
    p = argparse.ArgumentParser()
    p.add_argument("--db", required=True)
    p.add_argument("--mode", choices=["naive", "conditional", "idempotent"], required=True)
    p.add_argument("--crash", choices=["before_commit", "after_commit"])
    ARGS = p.parse_args()
    srv = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
    print("READY", srv.server_address[1], flush=True)
    srv.serve_forever()

if __name__ == "__main__":
    main()
参考にした資料(クリックで開く)
  1. SQLite 公式「About SQLite」"SQLite does not have a separate server process." "A complete SQL database … is contained in a single disk file."、「Appropriate Uses For SQLite」"SQLite does not compete with client/server databases. SQLite competes with fopen()." ↩

  2. SQLite 公式「Atomic Commit / Transactional」"All changes within a single transaction in SQLite either occur completely or not at all"(プログラムのクラッシュ、OS のクラッシュ、電源断で書き込みが中断されても)。 ↩

  3. SQLite 公式「Write-Ahead Logging」"the changes are appended into a separate WAL file." "A COMMIT occurs when a special record indicating a commit is appended to the WAL." 実験では synchronous=FULL(コミットのたびに WAL をディスクへ同期)も指定しています。 ↩

  4. 開発者の議論では「カセットには意味をなさない」という書き込みがありました。ただ、別の資料には逆のことが書かれていて、資料の間で食い違っています(僕は一次資料で確認できていません)。 ↩

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?