Featured image of post コードナビゲーター【Observer】鳴らない警報と太った結び目〜購読で張り替える補給線〜

コードナビゲーター【Observer】鳴らない警報と太った結び目〜購読で張り替える補給線〜

船外センサーが通知先を直接呼び出す密結合で、新設区画だけ警報が届かない事故に。購読者の名簿へ一斉通知するObserverパターンで、通知先の増減がセンサー無改変になる設計をRubyで解説します。

第1幕 異変——鳴らなかった一区画

月に一度、この船には全区画の警報を鳴らす朝がある。

総員警報演習。船外で何かがあったとき——微小デブリの群れに突っ込むとか、放射線の嵐に巻かれるとか——船外センサーの警報が全区画に届いて、各持ち場が持ち場の対処を取る。その通り道が生きているかどうかを、月に一度、試験信号で確かめる。それだけの行事だ。派手なことは何も起きない。起きないことを確かめるための演習だから、それでいい。

点呼表の機関室の欄には、俺の名前がある。ダグ。この恒星間貨物船〈オリオール号〉で機関を見て、二十年と少しになる。演習の朝の機関士の仕事は単純で、定刻にサイレンが鳴ったら、警報盤の点灯を確認し、主機出力を絞る手順を実際に一通り演じてみせる。それを何十回とやってきた。正直に言えば退屈な行事だ。だが省いていい行事だとは、一度も思ったことがない。警報ってのは、鳴らない日のためにある。鳴るべき日にちゃんと鳴ると分かっているから、鳴らない日を安心して過ごせる。

定刻。船内にサイレンが立ち上がった。

機関室の警報盤が点く。「船外異常・危険度8・訓練」の表示。俺は手順どおり、主機出力を巡航の三割まで絞る操作を確認する。ここまでは、いつもどおり。

演習の応答には二系統ある。ブリッジや医務室みたいに人が詰めている持ち場は、艦内無線で口頭の応答を返す。人のいない区画は、警報を受け取った装置が受達信号を管制盤へ自動で返す。管制盤の一覧が全部明るくなれば、演習は終わりだ。

無線が順に流れる。ブリッジ、応答よし。医務室、応答よし。機関室——俺だ——応答よし。

管制盤の一覧で、一つだけ暗い欄があった。

農産貨物区画。受達信号が、返っていない。

もう一度試験信号が流れた。サイレンは船中で鳴っている。だが管制盤のあの欄だけは、暗いままだった。

言っておくと、農産貨物区画というのは今航海前に増設したばかりの新入りだ。生鮮品輸送の受注が取れて、水耕栽培棚と低温槽を積んだ区画を貨物甲板の一角に組んだ。区画には人が常駐しない。船外で何かあれば、警報を受けて通風弁を閉じ、栽培棚を固定する——そういう対処が決まっている。その警報が、届いていない。

そして、もう一つ言っておかなきゃならないことがある。あの区画の増設工事で、電装と配管を引いたのは、俺だ。

警報線の敷設も、スピーカーの取り付けも、受達信号の返し線も、全部俺の手だ。演習記録に「農産区画・警報不達」と書かれれば、真っ先に検分されるのは俺の配線ということになる。誰かに言われるまでもない。俺が俺を最初に疑った。

演習が終わってすぐ、工具箱を提げて農産区画へ降りた。栽培棚の緑の匂いと、低温槽の乾いた冷気の中で、端子盤を開ける。テスターを当てる。警報線、導通よし。電圧よし。端子の締め付け、緩みなし。スピーカーは単体試験なら鳴る——区画の試験ボタンを押せば、ちゃんと音が出る。受達信号の返し線も生きている。

つまり、こういうことだ。鳴らせば、鳴る。鳴らす側が、鳴らしていない。

配線は容疑者から外れた。残る容疑者は一つしかない。船外センサーの警報を各区画へ配っている、手続きプログラムだ。俺はテスターを工具箱に戻し、蓋を閉めた。ハードの潔白を確かめてから行く。それが機関士の筋の通し方ってもんだ。

星図室へ上がると、うちの航法士は床に演習記録の写しを広げていた。

ミラ。〈オリオール号〉の航路を一手に引いている変わり者だ。床が定位置で、机はほとんど物置になっている。俺が戸口に立つと、顔も上げずに言った。

「農産区画、静かだったね。配線から見るか、プログラムから見るか、賭けてたところ」

「配線は俺がいま見てきた。導通も電圧もスピーカーも生きてる。プログラムだ」

賭けの答え合わせはそれで済んだらしい。ミラは演習記録から顔を上げて、投影機に手を伸ばした。

第2幕 解析——改修の年表と、三度目の開腹

投影に浮かんだのは、船外センサーの警報処理だった。ただし、今朝の演習で動いていた版——つまり、事故を起こした当のコードだ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# 事故当時の警報処理(今朝の演習で動いていた版)
class HullSensor
  def initialize(engine_room, bridge, infirmary)
    @engine_room = engine_room
    @bridge      = bridge
    @infirmary   = infirmary
  end

  def detect(reading)
    return unless reading.dangerous?

    alert = Alert.new(kind: reading.kind, level: reading.level)
    @engine_room.throttle_down(alert)
    @bridge.post_warning(alert)
    @infirmary.open_triage(alert)
  end
end

機関屋でも、コードくらいは読む。detect が警報処理の本体で、センサーの観測値 reading を受け取る。観測値が危険域でなければ何もせずに引き返し、危険なら警報 alert を作って、機関室、ブリッジ、医務室——三つの部署を順に呼び出す。readingalert は、こういう小さな入れ物だ。

 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
# センサーの観測値。事象名と危険度(0〜10)を持つ
class SensorReading
  attr_reader :kind, :level

  def initialize(kind:, level:)
    raise ArgumentError, "kind must not be empty" if kind.nil? || kind.empty?
    raise ArgumentError, "level must be within 0..10: #{level}" unless (0..10).cover?(level)

    @kind = kind
    @level = level
  end

  def dangerous?
    level >= 7
  end
end

# 発報された警報。センサーが観測値から作って各部署へ配る
class Alert
  attr_reader :kind, :level

  def initialize(kind:, level:)
    @kind = kind
    @level = level
  end
end

attr_reader は持ち物を外から読めるようにする書き方、raise ArgumentError はおかしな値をその場で弾く門番、名前付きで渡す引数の書き方も含めて、このへんは前にミラから教わった型のままだ。(0..10).cover?(level) だけ初見だったから訊いたら、0..10 は「0から10まで」という範囲そのものを表す Ruby の値(範囲オブジェクト)で、cover? は「その範囲に収まっているか」の問い合わせだという。危険度は10段階、7以上が危険域。今朝の試験信号は危険度8だから、detect は間違いなく本体まで進んでいる。

で、肝心の農産区画はどこにいるのかというと——どこにもいない。

呼び出しは三つ。機関室、ブリッジ、医務室。四つ目が無い。増設工事のとき、配線は引かれたが、この手続きへの追記が漏れた。それだけの話だった。原因としては、あっけないくらい単純だ。

「探す手間もなかったでしょ」とミラは言った。「でもね、今日の話は『なぜ漏れたか』じゃないの。『なぜ漏れる形なのか』のほう」

ミラは投影の脇に、このコードの改修履歴を呼び出した。そして年表でも読み上げるみたいに、順にさかのぼっていった。

就航時。警報の通知先は機関室とブリッジの、二つだけだった。センサーが二つの部署を直接呼ぶ。それだけの素直な作りで、必要十分だった。

数年前。長距離航路の医療要件が変わって、医務室が通知先に加わった。センサーの手続きに三行目の呼び出しが書き足された。当時としては最小の変更だ。

そして今回。農産貨物区画。四つ目の追記——が、漏れた。

「就航のとき、知らせる先は二つしかなかった。二本の糸なら、直に結ぶのが一番強いの。医務室のときも、同じ流儀で一行足すのが一番小さい直し方だった。三度目の追記で、初めて漏れた。よく二回持った、って読むべきよ」

責める調子は、どこにもなかった。前に別の一件でも思ったが、うちの航法士は昔のコードを悪く言わない。書かれた当時の条件をまず数えて、それから今の条件と見比べる。変わったのはコードじゃなくて、船の運用のほうだ——通知先が「増え続けるもの」になった。

応急の処置——現場の言い方ならホットフィックスだ——は一分で終わった。ミラは呼び出しを一行と、生成時に受け取る引数を一つ、書き足した。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# 応急修正後の警報処理(掲載時点の現行版)
class HullSensor
  def initialize(engine_room, bridge, infirmary, farm_bay)
    @engine_room = engine_room
    @bridge      = bridge
    @infirmary   = infirmary
    @farm_bay    = farm_bay # ← 今回のホットフィックスで追加
  end

  def detect(reading)
    return unless reading.dangerous?

    alert = Alert.new(kind: reading.kind, level: reading.level)
    @engine_room.throttle_down(alert)
    @bridge.post_warning(alert)
    @infirmary.open_triage(alert)
    @farm_bay.seal_vents(alert) # ← 同上。この1行が漏れていた
  end
end

試験信号を流すと、管制盤の農産区画の欄に、今度は受達の応答灯が点いた。これで演習記録には「復旧」と書ける。

ちなみに呼ばれる側の部署は、それぞれこういうクラスだ。機関室のものを見せてもらった。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# 機関室。警報を受けると主機出力を絞る
class EngineRoom
  attr_reader :journal

  def initialize
    @journal = []
  end

  def throttle_down(alert)
    @journal << "機関室: #{alert.kind}警報。主機出力を巡航30%まで絞り、衝撃に備える"
  end
end

journal は当直記録——どの警報を受けて何をしたかを書き溜めていく記録の束で、あとで挙動を検分するのはこの記録を読む形になる。ブリッジも医務室も農産区画も同じ形のクラスだが、受け口の名前だけが違う。ブリッジは post_warning、医務室は open_triage、農産区画は seal_vents。部署ごとに「警報を受けたときの動詞」で名前がついている。

さて、復旧はした。したのだが、ミラは投影を消さなかった。代わりに床の星図を隅に寄せて、物入れから妙なものを出してきた。木の杭と、糸巻きだ。

床の真ん中に、杭が一本打たれた——打つといっても床は鋼板だから、磁石の台座でくっつける。「これがセンサー」。その周りに、四本。「機関室、ブリッジ、医務室、農産区画」。それから糸巻きの糸を、センサーの杭から各部署の杭へ、一本ずつ張っていく。俺は黙って農産区画の杭を持たされて、位置決めを手伝った。この人の思考は、手を動かしながらでないと出てこないことがある。長い付き合いで知っている。

四本目を張り終えると、床の上に小さな補給網ができていた。センサーの杭を見ると、糸の結び目が四つ、団子になって重なっている。各部署の杭は、それぞれ一本の糸が結ばれているだけで、すっきりしたものだ。

「補給線はね、張った数だけ船を縛るの」とミラは言った。「見て。太るのはいつも、真ん中のこの子。こういう結び方を、密結合っていうの。知らせる側と受け手が直に固く結ばれてて、結び足すにもほどくにも、真ん中の結び目を触るしかない」

言われて、コードを見直す。なるほど、絵のとおりだった。通知先が一つ増えるたびに、センサーの側では三つのことが起きる。生成時の引数が一つ増える。抱える持ち物(インスタンス変数)が一本増える。detect の呼び出しが一行増える。そして呼び出すためには、その部署の受け口の名前——seal_vents だの open_triage だの——をセンサーが覚えなきゃならない。受け手の側は何も変わらない。変わるのはいつも、知らせる側だ。

床の糸模型と投影のコードを、機関士の癖で一枚の系統図に重ねて写し取ってみた。今朝の事故は、この絵の欠けた一本だ。

Beforeの密結合構造図。船外センサーが機関室・ブリッジ・医務室を別名メソッドで直接呼び出し、農産貨物区画への呼び出しだけが欠けて警報不達となり、部署が増えるたびセンサー側の修正が必要になることを示す

「模擬航走で、今朝の沈黙を残しておくね」

模擬航走——実機を使わず、手続きに試験の入力だけ食わせて答えを検分する、いつもの試験航行だ。ミラが書いたのは、「危険な観測値を一発流したら、四つの部署ぜんぶの当直記録に何か書かれているはず」という一括の照合だった。

1
2
3
4
5
6
7
8
# 一斉通知の一括照合: 全部署が警報を受け取ったか
def test_all_departments_notified_in_a_single_sweep
  @sensor.detect(SensorReading.new(kind: "放射線", level: 9))

  [@engine_room, @bridge, @infirmary, @farm_bay].each do |dept|
    refute_empty dept.journal
  end
end

refute_empty は Minitest(Ruby に標準で付いてくるテストの道具。これも前に教わった)の照合の一つで、「空っぽではないはずだ」という答え合わせだ。逆の assert_empty なら「空っぽのはずだ」になる。四部署を配列に並べて、一件ずつ取り出しては記録を検める。

これを、応急修正の前の——今朝の演習で動いていた版に当てる。警報が鳴った。結果盤に赤が一つ。機関室は緑、ブリッジも医務室も緑。農産区画だけが赤。当直記録が空のままだ。

見ていて、嫌な気分になったのは、赤のせいじゃない。赤の出方のせいだ。

Ruby の間違いは航行中にしか鳴らない——動的型付けの言語では、書き間違いは書いた時点では黙っていて、動かした瞬間に初めてエラーになる。それは前に教わって、腹に入れてある。だが今回のはもっとタチが悪い。呼び出しが「無い」ことは、動かしたって鳴らないのだ。書いていないコードは、実行されないだけで、エラーにもならない。三部署への通知は正常に済んで、手続きは何食わぬ顔で終わる。沈黙だけが症状。だから増設の検収も演習前の点検も、素通りした。全数を突き合わせるこの照合みたいな網か、今朝みたいな全数の演習か——どっちかが無ければ、誰にも気づけない。

俺は床の糸模型を見下ろした。太った結び目を見ているうちに、口から出た。

「なあ。次に区画が増えたら、また同じことをやるのか。杭を打って、真ん中の結び目をほどいて、結び直して。受け手が増えるたびに、知らせる側の腹を開けるのか」

言いながら、頭の中にあったのは機関室の配電盤だった。

「俺の配電盤なら、絶対にやらない形だぞ、これは。機関室の母線——太い幹の給電線のことだが——に機器を増設するとき、発電機側の何かを書き換えたりしない。新しい機器のほうがブレーカーを持って、母線に繋ぎに来るんだ。発電機は誰が繋がったかなんて知らん。知らないまま、全員に電気が行く」

ぼやきのつもりだった。うちの区画の増設で、なんで毎回センサーの腹を開けるんだ、という愚痴だ。だが、糸を巻き取ろうとしていたミラの手が、止まった。

「ダグ、いまの、そのまま名前のついた航法術」

ミラは太った結び目を外し始めた。

「知らせる側は、受け手を知らなくていい。受け手の側が、名簿に載りに来ればいい」

第3幕 引き直し——名簿と、張り替えられた補給線

引き直しは二段でやる、とミラは言った。一段目だけ見ると意味が分からないはずだから、途中で分かった気にならないこと——先にそう釘を刺された。

一段目。四つの部署の受け口を、同じ名前に揃える。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# 機関室。受け口を update に統一(対処の中身と記帳の文言はそのまま)
class EngineRoom
  attr_reader :journal

  def initialize
    @journal = []
  end

  def update(alert)
    @journal << "機関室: #{alert.kind}警報。主機出力を巡航30%まで絞り、衝撃に備える"
  end
end

throttle_downupdate になった。中身は一文字も変わっていない。ブリッジの post_warning も、医務室の open_triage も、農産区画の seal_vents も、全部 update に。やることは元のまま、名前だけの統一だ。

俺は素直に引っかかった。

「待て。名前を揃えただけで、何が変わる。センサーはまだ四人の顔を覚えたままだろう。引数も四つ、持ち物も四本。呼び出す名前が四種類から一種類になっただけだ」

「そう。まだ何も変わってない」

あっさり認めた。

「名前を揃えるのは、疎結合——結びつきを緩くすること——じゃないの。ここで満足しちゃう引き直しが、実際よくあるんだけど。揃えたのは、次の一手のため。受け口の名前がバラバラのままだと、名簿にできないのよ。名簿ってものはね、載ってる全員に同じ声で呼びかけられるから、名簿なの」

そして二段目。センサーの腹から、四人の顔が消えた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# 船外センサーの警報処理。センサーは購読者の名簿を一つ持つだけ
class HullSensor
  def initialize
    @subscribers = []
  end

  def subscribe(subscriber)
    @subscribers << subscriber
  end

  def unsubscribe(subscriber)
    @subscribers.delete(subscriber)
  end

  def detect(reading)
    return unless reading.dangerous?

    alert = Alert.new(kind: reading.kind, level: reading.level)
    @subscribers.each { |subscriber| subscriber.update(alert) }
  end
end

生成時の引数が、無くなった。抱えるのは @subscribers——購読者の名簿だ。購読というのは新聞のそれと同じで、「知らせが出たら受け取る」側として名簿に載ること。名簿の実体は、ただの配列が一本だけ。subscribe は名簿の末尾に一件書き足す受付で、unsubscribe は名簿から消す受付だ。delete は配列から指定した値そのものを取り除く Ruby の書き方で、これが「名簿から名前を消す」にそのまま対応する。

detect の中はどうなったかというと、@subscribers.each { |subscriber| subscriber.update(alert) }——名簿を上から順にめくって、載っている全員に update の声をかける。それだけだ。each do 〜 end で一件ずつ取り出す書き方は前の寄港手続きの一件でも出てきたが、今回の { } はその一行版で、中身が短いときはこう畳んで書く。|subscriber| が「いまめくった一件」の呼び名だ。

「で、誰が名簿に載るのか。それを決めるのは——」ミラは投影を切り替えた。「センサーじゃないの。ここ」

1
2
3
4
5
6
7
# 船の起動手順(組み立て)。誰が名簿に載るかは、センサーの外で決まる
# (各部署は engine_room = EngineRoom.new のように、ここまでに生成済みとする)
sensor = HullSensor.new
sensor.subscribe(engine_room)
sensor.subscribe(bridge)
sensor.subscribe(infirmary)
sensor.subscribe(farm_bay)

船の起動手順——各系統のオブジェクトを作って組み合わせる、立ち上げの場所だ。そこで各部署が、順に subscribe しに来る。センサーは受け付けるだけ。誰が来たかを詮索しない。

「見て。センサーのファイル、もうどこにも『機関室』の文字が無いの」

言われて、さっきのセンサーのコードをもう一度上から下まで読んだ。本当に無い。機関室も、ブリッジも、医務室も、農産区画も。engine_room という変数名すら無い。あるのは subscriber——購読者、という顔の見えない呼び名だけだ。検索をかけたって一件も出てこないだろう。結びつきが緩くなったというのは、こういう風に物として確かめられるものなのか、と妙なところで感心した。

床の模型も、張り替えられた。センサーの杭から伸びていた四本の糸が、全部外される。代わりにセンサーの杭には、小さな札が一枚ぶら下がった——連絡名簿、とミラは呼んだ。それから各部署の杭から、部署の側が糸を持ってきて、名簿に名前を書いて結んでいく。機関室が一本。ブリッジが一本。医務室、農産区画。出来上がった網は、糸の本数だけなら前と同じ四本だ。だが結び目の意味が違う。糸を張る作業は全部、受け手の側の杭から始まっている。真ん中の杭は、もう太らない。太るのは名簿の行数だけで、行が増えても、センサーの腹は開かない。

張り替えの済んだ床の網を、配電盤の裏に貼っておく配線図の体裁で起こしておく。糸の始点が、全部ひっくり返っているのが分かるはずだ。

Afterの疎結合構造図。各部署がsubscribeでセンサーの購読者名簿に自分から登録しに来て、センサーは名簿に載った全員へ統一受け口updateで一斉同報するだけとなり、通知先の増減がセンサー無改変で済むことを示す

一つ確認しておいた。この名簿、センサーが持ってていいのか。どこか中立の管制局みたいなところが預かる形も、ありそうなもんだが。

「名簿を預かる局を別に立てる流儀もあるよ。出版・購読——Pub-Sub って呼ばれる、もっと大掛かりな形。でもそれは、船団規模の話。知らせる側と受け手が別々の船にいるような距離なら、間に局を挟む価値がある。船内なら、センサーが自分で名簿を持てば足りるの」

なお、名簿に載る資格の話は、もう聞かなくても分かった。四つの部署は親も型も何も共有していない。ただ全員が update の呼びかけに応えられる——航路計算機の一件で教わった、血筋より規格、というあの流儀だ。名簿は血統書じゃない。呼びかけに応えるなら、載れる。

ミラは名簿の札を軽く叩いて、こちらを向いた。

「この『知らせる側は名簿へ一斉に声をかけるだけ、受け手が自分で名簿に載りに来る』航法術を、Observer っていうの。Observer——発生源の状態変化を、登録された購読者の一覧へ自動で知らせて、発生源と受け手の結びつきを緩くする設計パターン。観測者、って意味ね。受け手のほうが主役の名前なの。知らせる声は、届く先を数えなくていいの」

それから、機関屋として訊いておくべきことを訊いた。引っかかっていたのだ。部品をまるごと差し替える航法術——航路計算機のときの Strategy——と、これは何が違うのか。どっちも「本体が中身を知らない」って話だろう。

「いい線。違いは三つ」ミラは指を折った。「一つ、差し込み口の形。あっちは本体が部品を一つ受け取って、仕事をそれに任せる。挿さるのは常に一枚。こっちは名簿だから、何人でも載る。一対一の交換と、一対多の同報。二つ、決まるタイミング。あっちは出航前の組み立てで一つ選んだら、その航海はそれで行く。こっちは航行中でも名簿の出入りができる。三つ、向き。あっちは本体が部品に『これやって』って頼む。こっちはセンサーが『起きたぞ』って知らせるだけ。受けて何をするかは、各部署が自分の持ち場で決める。機関室は主機を絞る、医務室は処置台を出す——センサーはそれを知らないし、指図もしない」

どっちが上等という話ではなく、揃える相手が違うのだ、と締めた。交換したいなら部品の形を揃える。同報したいなら受け口の名前を揃える。

そこでミラは、張り替えの済んだ糸模型の真ん中に、すとんと座り込んだ。あぐらの膝が機関室の糸を跨いでいる。物入れから乾パンの包みと蜂蜜の瓶が出てきて、乾パンに蜂蜜が、たっぷりかかった。齧る音がする。うちの航法士の主食だ。説明の切れ目にこれが始まるということは、大きな話は済んだということでもある。

俺は名簿の札の横に立ったまま、さっきの配電盤の絵をもう一度、口に出して確かめた。

「発電機は、誰が繋がったか知らない。知らないまま、全員に電気が行く。——で、うちのセンサーも、そうなった」

ミラは蜂蜜を舐めながら頷いた。答え合わせは、それで済んだ。

済んだところへ、内線が鳴った。医務室からだった。用件を聞いて、俺は少し笑ってしまった。あんまり出来のいい教材が飛び込んできたからだ。——受信端末の定期整備で、明朝まで警報回線から外れたい、という定例の届け。

「なあ、これ、前の形だったらどうしてた」

「センサーのコードから医務室の呼び出しを一時的に消して、整備が明けたら書き戻す——なんて改修を、そのたびにやる船はないから」とミラは乾パンを置いた。「実際には医務室側で端末の電源を落として、届く警報を握り潰してた。センサーは知らないまま、外れてる相手に声をかけ続けるわけ」

「新しい形なら」

「一手。sensor.unsubscribe(infirmary)。整備が明けたら sensor.subscribe(infirmary) で戻す。センサーのコードは触らない。再起動も要らない。名簿の行が一晩だけ減って、また増えるだけ」

ミラはその場で、模擬航走にその筋書きを書き足した——引き直した版はまだ模擬の上にしかいない。実機の警報系に載せ替えるのは、模擬航走が全部通ってからだ。試しもしていない航路で船を飛ばさないのは、警報系でも同じである。航路計算機の一件は、出航前にどの部品を選ぶかという話だった。名簿は、航海の途中で書き換えられる。同じ「本体を触らない」でも、効く場面がまるで違う——それが指折りの二つ目、決まるタイミングの差ってやつの、いちばん分かりやすい姿だった。

第4幕 模擬航走——全数の網と、鳴らない平常

模擬航走の束を、ミラは順に流していった。

先頭は、約束の確認だ。同じ観測値の並び——危険度8の隕石、危険度6の微振動、危険度9の放射線——を、応急修正済みの現行版と、引き直した名簿版の両方に食わせる。そして部署ごとに、当直記録が一字一句同じであることを照合する。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def test_engine_room_before_and_after_produce_identical_journal
  # 現行版・名簿版それぞれのセンサーに四部署を組み付けてある。
  # before_dept / after_dept は、どちらもここでは機関室
  @readings.each do |reading|
    before_sensor.detect(reading)
    after_sensor.detect(reading)
  end

  assert_equal before_dept.journal, after_dept.journal
end

現行版と名簿版はどちらも HullSensor という同じ名前だから、試験では別々のファイルに分けて、名前がぶつからない形で読み込んで同居させてある——自分で再現するときは、そこだけ気をつけるところだ。機関室、ブリッジ、医務室、農産区画。四部署とも緑。構造は変わった、挙動は変わっていない。引き直しの約束は、まず数字で示す——このへんの順序の律儀さは、いつもどおりだ。

ここで一つ、設計の側の話を聞いた。この照合、部署ごとに別々の記録を突き合わせている。四部署の記録を一本に混ぜて突き合わせれば一本で済むのに、なぜ分けるのか。

「混ぜると、順序が写り込むから。いまの名簿は配列だから、実は登録した順に声がかかってる。でもそれは、たまたまそうなってるだけ。名簿の実装を変えれば変わっていい部分なの。順序は約束——契約に入れない。だから試験も、順序が写り込まない形で書く。機関室の記録は機関室の記録とだけ突き合わせる」

順序をあてにした瞬間、名簿の並び替え一つで壊れる設計になる。「医務室より先に機関室へ」みたいな順序が本当に要るなら、それはこの航法術の外で、別に設計する話だそうだ。

次。さっきの一括照合——全部署の記録が空でないこと——を名簿版に当てる。全部緑。今朝、農産区画の欄だけを空にしたあの網は、これからも張りっぱなしにする。網にかかる種類の事故が構造ごと消えたのは確かだが、網を畳む理由にはならない。名簿への載せ忘れという新しい忘れ方は、残っているからだ。

それから境界。危険度6の観測値では、誰の記録も増えない——鳴らないことも仕様のうちだから、鳴らない側もちゃんと検分する。7で発報。全員に届く。

実行時の出入り。医務室を名簿から外して警報を流すと、医務室の記録だけが増えず、他の三部署には届く。戻して流せば、また届く。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def test_unsubscribed_department_does_not_receive_the_next_alert
  @sensor.unsubscribe(@infirmary)

  @sensor.detect(SensorReading.new(kind: "隕石", level: 8))

  assert_empty @infirmary.journal
  refute_empty @engine_room.journal
  refute_empty @bridge.journal
  refute_empty @farm_bay.journal
end

そして、次の増設の予行演習。update に応えられるだけの小さな新区画クラスをその場で書いて、subscribe する。それだけで、次の警報から届く。センサーも、既存の四部署も、一文字も触らない。

「次の増設のときは、杭とブレーカーだけ持ってくればいいわけだ」と俺は言った。「母線側の工事は、もう無い」

門番の検分は軽く流す——事象名が空の観測値、危険度11やマイナスの観測値は、入れ物を作った時点で ArgumentError に弾かれる。いつもの型だ。

結果盤は、全21本、84項目、全灯緑。ミラは全部の緑を確かめてから、警報系の手続きを名簿版に載せ替えた。実機に載るのは、模擬航走を通り切った後。その順序が逆になったことは、この船では一度もない。

締める前に、保証できないことを聞いておいた。うまい話を鵜呑みにしない程度の分別は、二十年で身についている。

「三つある」とミラは言った。「一つ。順序はさっき言ったとおり、契約にしない。順序が要る関係を名簿に持ち込まないこと。二つ。外し忘れ。整備どころか、区画ごと廃止になった部署を unsubscribe し忘れると、名簿に載ったまま残り続けるの。誰も読まない当直記録に、警報が積もり続ける。廃区画に補給便が通い続けるのと同じ。Ruby だと、もう要らないオブジェクトが名簿から参照されてる限り消せない——メモリの上でも生き残り続ける、っていう実害もある。名簿は、載せる手続きと同じくらい、降ろす手続きを運用に組み込むこと」

「三つ目は」

「名簿の途中で、誰かが倒れたら。さっきダグが聞いたでしょ、端末が死んでたらどうなるって。いまの素朴な each はね、名簿の途中の一人が例外——ArgumentError で見た、実行をその場で止めるあのエラーの仲間——を投げると、そこで止まる。後ろに載ってる部署には、警報が届かない。全員への到達をどうしても保証したいなら、一人の失敗を拾って先へ進む手当てを、別に書くことになる。今回の設計は、そこまでは守らない」

守っていないものを守っていると言わないのが、この人の設計の信用の張り方だ。俺としては、三つ目は近いうちに宿題になる気がしたが、今日のところは線の内側で十分だった。

最後に、ミラが道具棚の話を一つくれた。この名簿、実は Ruby が標準で用意しているのだという。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
require "observer"

# 同じセンサーを、標準ライブラリの名簿で書くと
class HullSensor
  include Observable

  def detect(reading)
    return unless reading.dangerous?

    changed
    notify_observers(Alert.new(kind: reading.kind, level: reading.level))
  end
end

require はライブラリを読み込んで使えるようにする宣言。include Observableinclude は、モジュール——メソッドの詰め合わせ——をクラスに混ぜ込んで、自前で書かずに使えるようにする Ruby の仕組み(ミックスインと呼ぶ)だ。これで add_observerdelete_observer——さっき自前で書いた subscribeunsubscribe に当たる受付——が、書かずに手に入る。notify_observers が一斉の声かけで、その前の changed は「本当に変わったときだけ知らせる」ための変更フラグ。フラグを立ててから知らせる二段構えで、無駄な同報を抑える仕掛けまで付いてくる。裏返すと、changed を立て忘れたまま notify_observers を呼んでも、エラーは出ない。黙って、何も通知されないだけだ。呼んでいないコードが鳴らないのと同じ顔の沈黙が、ここにも棲んでいる——使うなら、そこは覚えておいたほうがいい。

一つだけ注意がある。gem というのは Ruby のライブラリの配布単位のことで、Bundler は、プロジェクトが使う gem を一覧表——Gemfile——で管理する道具だ。Ruby 3.4 から、この observer は bundled gem という扱いに変わった。同梱はされているが、Bundler で依存を管理している船——プロジェクトなら、Gemfile に gem "observer" と一筆書かないと載ってこない。標準といっても、黙ってそこにいてくれる度合いは、少し下がっている。

「で、なんで今日はこれを使わなかったんだ。書かずに済むなら、それが一番だろう」

「名簿の仕組みが手のひらに載る大きさのうちに、一度自分で作っておくと、道具のほうを使うときに何が起きてるか読めるの。配列一本と each 一行——今日の名簿の中身は、それで全部だった。あれを知ってれば、add_observer の裏で何が起きてるかは、もう見当がつくでしょ」

翌朝、臨時の再演習が組まれた。

定刻。サイレンが立ち上がる。機関室の警報盤が点き、俺は主機出力を絞る手順を型どおりに踏む。無線が流れる。ブリッジ、応答よし。医務室、応答よし——整備明けの端末で、名簿に戻った上での応答だ。そして管制盤の、昨日まで暗かった欄。

農産貨物区画。受達、よし。

演習終了の号令で、サイレンが切れた。警報盤が消灯して、機関室にはいつもの機関音だけが戻ってくる。主機の低い唸り。循環器の規則正しい呼吸。俺は計器の前に立ったまま、消えた警報盤をしばらく見ていた。

警報は、鳴らない日のためにある。鳴るべき日に、全区画で鳴る——それが今朝、確かめられた。だからこの静けさは、昨日までの静けさと同じ音をしていて、中身が違う。昨日までのは、確かめていない静けさだった。今日のは、確かめた上の静けさだ。機関士が枕を高くして寝られるのは、後のほうだけである。

星図室には、何も言わなかった。次に名簿の行が増える日まで、この件はもう、俺の当直記録の外だ。


🚀 オリオール号 航海日誌(Navigator’s Log)

  • 航法術(パターン名): Observer(発生源の状態変化を、登録された購読者の一覧へ一斉に知らせ、発生源と受け手を疎結合にする設計)
  • 発生事象(症状): 月例の総員警報演習で、新設の農産貨物区画だけ警報が届かず受達信号が返らない。配線は正常、手続きプログラム側の呼び出し追記漏れ
  • 原因(旧航路の問題): センサーが通知先の顔ぶれと受け口の名前を直接抱え込み、受け手が増えるたびセンサー側の修正を強いる密結合。追記漏れは「呼んでいない」だけなので実行時エラーにもならず、沈黙だけが症状になる
  • 処置(引き直しの要点): 受け口を共通の update に統一した上で、購読者の名簿(配列)をセンサーに一本持たせ、登録はセンサーの外(船の起動手順)へ。受け手の増減は名簿の出入りだけになり、センサーは無改変のまま航行中でも顔ぶれを変えられる
  • 保証外事項: 通知の順序は契約にしない(登録順はたまたま。順序依存の対処は別設計)/購読解除の失念は名簿に残り続ける(不要オブジェクトの延命を含む)/購読者の一人が例外を投げると素朴な each では後続に届かない(全数到達の保証は別の手当て)
  • 模擬航走結果: 全21テスト・84アサーション(照合の項目数)、全灯緑(四部署の挙動一致・一斉通知の一括照合・危険度の境界・実行時の購読の出入り・新購読者の無改変追加・門番)
  • 次の針路: 生鮮品の受注が続けば冷蔵区画の増設案あり。そのときは名簿に一行——センサーの腹は、もう開けない
comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。