Akatsuki Hackers Lab | 株式会社アカツキ(Akatsuki Inc.)

Akatsuki Hackers Labは株式会社アカツキが運営しています。

Ruby on Rails 6.1 の水平シャーディング対応 & Octopusからの移行事例

この記事は Akatsuki Advent Calendar 2020 の20日目の記事です。

はじめに

ゲームサーバでは大量のユーザーデータなどを取り扱うため、データベースの負荷分散のために水平シャーディング(水平分割)が行われることがあります。 アカツキでも、これまで Ruby on Rails や Elixir 等でゲームサーバを開発する中で、それぞれの方法で水平分割を行ってきています。

さて、先日リリースされた Ruby on Rails 6.1 では、待望の水平シャーディング機能が標準でサポートされました。

早速使っていきたいところですが、これまで別の方法で水平シャーディングを実現していたアプリケーションを移行するにあたってはいくつか課題があるため、 それをどう解決するかの一例をご紹介したいと思います。

また、その解決の一環で利用した Ruby の BasicObject クラスを用いた Proxy パターンについても説明します。

Ruby on Rails 6.1 の水平シャーディング機能

Ruby on Rails 6.1 の水平シャーディング機能を利用するには以下のようにします。

まず、 database.yml に複数のDB接続を定義します。 *1

development:
  user01:
    <<: *default
    database: user01
  user02:
    <<: *default
    database: user02
  user01_readonly:
    <<: *default
    host: user01-ro....
    database: user01
    replica: true
  ...

レプリカへの接続には replica: true という設定をすることで、マイグレーションや db:create などDB操作の対象外となります。

次に、shard・role (シャード名とwrite/read等の役割)とDB接続のマッピングを、 ApplicationRecord 等モデルの親となるクラスにて connects_to メソッドで設定します。

class ApplicationRecord < ActiveRecord::Base
  self.abstract_class = true

  connects_to shards: {
    user01: { writing: :user01, reading: :user01_readonly },
    user02: { writing: :user02, reading: :user02_readonly },
    ...
  }
end

class User < ApplicationRecord
end

あとは、レコードを取得するときに、 ActiveRecord::Base.connected_to メソッドを使って shard, role を指定します。

ActiveRecord::Base.connected_to(role: :reading, shard: :user01) do
  User.find(user_id)
end

これによって、指定したDB接続に対してクエリが発行されるようになります。

取得したレコードを書き換えて save するときなど、クエリが発行される際には必ずこのブロック内で行う必要があります。そうしないと別のシャードに対してクエリが発行されてしまいかねません。

ActiveRecord::Base.connected_to(role: :writing, shard: :user01) do
  user.save!
end

※ なお上記はあくまでメソッドの使用例であり、トランザクションを全く考慮していません。 多くの場合、API単位などで一貫して connected_to ブロック内で処理することを想定しているものと思われます。

Octopusからの移行

さて、あるアプリケーションでは、これまで Octopus という外部gemを使って水平シャーディングを実現していました。 Octopusでは、上記の User の例だと以下のようにアプリケーションを記述します。 *2

user = User.using(:user01).first
...
user.save!

Octopus では各レコードがどのシャードに属すかを記憶しており、 save! などの各種メソッドを呼び出した時には、自動的にそのシャードに対してクエリを発行してくれるようになっています。

リレーションも同様で、例えば user が has_many リレーション items を持っているとき、単に user.items と書けば自動的に user の属するシャードから items の検索を行ってくれます。

user.items.where.not(expired: true)... といったようにリレーションに対して where 等をメソッドチェインでつなげた場合も、最終的にクエリが発行されるところまでシャード指定が伝わります。 これをシャードトラッキングと呼んでいます。

これにより、間違ったシャードにクエリを発行してしまう心配をしなくてすみますし、特に異なるシャードから取得した複数のレコードをまとめて処理するような時には記述が簡単になるという利点があります。

しかし、 Octopus は Rails 6.1 に対応しておらず、シャードの記述や管理の方法も異なるために、このままでは Rails 6.1 に移行するためにはアプリケーションを大幅に書き直す必要が生じてしまいます。

そこで、Octopus ライクなシャードの指定方法はそのままで、水平シャーディングのDB接続ハンドリングには Rails 6.1 のネイティヴ機能を使用する Octoball という gem を開発しました。

github.com

これを使えば、アプリケーションをほとんど書き換えることなく Rails 6.1 に移行することが可能となっています。

ただし、DB接続の管理は Rails 6.1 に任せるため、接続の定義は Octopus 独自の設定ファイルから上記の database.yml に移す必要があります。 using() の引数に指定するのは connected_to に渡す shard に相当するシンボルになります。

Proxy パターンによるシャードトラッキングの実現方法

Octoball でシャードトラッキングを実現するにあたっては、いわゆる Proxy パターンを利用しています。 *3

user.items.where ... といった記述をした場合、各メソッドは ActiveRecord::Relation の様々な子クラス等を返しており、最終的にこの結果のレコード取得が必要になった段階でクエリが構築・発行されます。

各レコードや ActiveRecord::Relation は多様なメソッドを提供しているので、これらに逐一シャードトラッキング機能を入れていくのは現実的ではありません。

そこで、最初にこれらをラップする Octoball::RelationProxy というクラスのインスタンスを生成して返すようにします。

これに対して何かメソッドが呼ばれたときには、必要に応じてシャードを ActiveRecord::Base.connected_to で切り替えてから、ラップしているオブジェクトのメソッドを呼び出します。 そして、返り値が ActiveRecord::Relation などシャードトラッキングの対象であれば、再びそれをラップして呼び出し元に返します。

これが各メソッドチェインごとに繰り返されることで、最終的にクエリが発行されるところまでシャード指定が伝搬されてきいます。

Ruby には、まさにこうした Proxy パターンを実現するために、 BasicObject というクラスが用意されています。以下ではこれについて少し紹介します。

BasicObject による Proxy パターンの実装

BasicObject クラスは、通常の Object の親である Kernel のさらに親クラスにあたり、ほとんど何もメソッドを持っていないクラスです。 従って、これに対して何かしらのメソッドを呼び出そうとすると、基本的には method_missing が呼ばれることになります。 Proxy パターンでは、 method_missing で何かしらの処理をしてからラップしているオブジェクトのメソッドを呼び出すことで、そのオブジェクトの機能を拡張します。

例えば、ナイーブな Octoball::RelationProxy の実装だと、以下のように method_missing で元のオブジェクトのメソッド呼び出し前にシャードを切り替える動作を入れます:

class Octoball::RelationProxy < BasicObject
  def initialize(rel, shard)
    @rel = rel  # ラップしているオブジェクト
    @current_shard = shard
  end

  def respond_to?(method, include_all = false)
    @rel.respond_to?(method, include_all)
  end

  def method_missing(method, *args, &block)
    ret = nil

    # シャードを切り替えてから @rel に対してメソッドを呼び出す
    ::ActiveRecord::Base.connected_to(
        role: ::Octoball.current_role, shard: @current_shard) do
      ret = @rel.public_send(method, *args, &block)

      # connected_to のブロックから ActiveRecord::Relation を
      # 返すと自動的にその場でクエリを発行してしまうので、
      # nilを返してこれを抑制する
      nil  
    end

    # retがラップする必要のあるオブジェクトでなければそのまま返却
    return ret unless ret.is_a?(::ActiveRecord::Relation) || ret.is_a?...

    # retをRelationProxyでラップして返却
    ::Octoball::RelationProxy.new(ret, @current_shard)
  end

  # Ruby3でのキーワード引数デリゲーション対応
  ruby2_keywords(:method_missing) if respond_to?(:ruby2_keywords, true)
end

BasicObject を使わない場合、例えば ActiveRecord のコード中で

if x.is_a?(ActiveRecord::Relation)
  ...

などとクラスの判別をしている箇所に、 ActiveRecord::Relation をラップしている RelationProxy が渡った場合、 RelationProxy#is_a? が呼ばれることになり、ラップしたことで結果が変わってしまいます。 一方、 BasicObject なら is_a? メソッドすら持っていませんので、 method_missing 経由で @rel.is_a? に転送され、ラップされたオブジェクトのクラスに基づいた動作になってくれるというわけです。

ちなみに最後の、ruby2_keywords の件は、Ruby2.7以前でもRuby3以降でも **kwargs をつけずにキーワード引数のデリゲーションを可能にするために付加してあります。

なお、実際の Octoball では、メソッドに渡されたブロック内でのデフォルトシャードの振る舞いを Octopus に合わせたりするためにもっと複雑なことになっています。 また、 method_missing を毎回呼ぶのは遅そうなので、最初の呼び出し時に同名の Proxy メソッドを動的に定義してしまうという方法でいくらか高速化を狙っていたりもします。

まとめ

本記事では、 Ruby on Rails 6.1 で対応した水平シャーディング機能と、Octopus を使ったアプリケーションをそこに乗せるために開発した Octoball gem について紹介しました。 また、Octoball でシャードトラッキングを実現するのに用いた Proxy パターンと、その実装のための BasicObject についても説明しました。

Octoball 自体は割とニッチな gem だと思いますが、Proxy パターンなどのテクニックが何かの参考になれば幸いです。

*1:複数DB接続の定義自体はrails6から可能になっていた機能です。

*2: この using は ActiveRecord::Base に追加されるメソッドで、 Ruby の Refinement で使われる Module#using とは関係ありません。

*3: 元になった Octopus においても Proxy パターンが用いられています。

1人開発のコードにオニオンアーキテクチャを導入した経験談

はじめに

この記事は Akatsuki Advent Calendar 2020 の 16 日目の記事です。

s-capybara です。普段は Elixir でモバイルゲームサーバーの機能開発をしていることが多いですが、時々 Go でツール開発も行っています。ほぼ1人で開発していてコードベースを自由に変更できるということもあり、このツールにオニオンアーキテクチャを導入してみました。色々あった困りごとが解消できたのですが、実際に導入することで新たに悩みが生まれたこともあり、その経験を共有できればと思います。

オニオンアーキテクチャとは

オニオンアーキテクチャ は Jeffrey Palermo 氏が提唱したアーキテクチャです。コードをいくつかのレイヤに分解して、依存性の逆転により DB やファイルにアクセスする処理を外に追い出し、アプリケーションの中心であるドメインロジックがこれらに依存しないようにするというものです。ドメインロジックのテストがやりやすくなったり、DB を他の機構に乗り換えやすくなったりといったメリットがあります。

依存性を逆転させてドメインを中心に置くというのが大事なポイントなので、ヘキサゴナルアーキテクチャやクリーンアーキテクチャと本質的には変わりません。個人的には、シンプルでレイヤのネーミングが分かりやすく他に比べて好みです。

作っているもの

今回オニオンアーキテクチャを導入した対象について少し説明します。

モバイルゲーム開発には大量のマスタデータを作成する仕組みが必要です。ゲームにおけるマスタデータというのは、例えば「A という名前のクエストがあって、その中には B という敵がいて、クリアすると C という報酬が貰える」など、ゲームの進行方法を指示するデータのことです。ゲームクライアントやゲームサーバーはこれを読み込んで、動的に処理を決定していきます。

今回取り上げるのはこのマスタデータ作成に使うツールで、Go で書いています。Excel にマスタデータの内容を記述し、事前に YAML で定義しておいたスキーマ(型情報)をもとに、JSON などゲームが利用しやすい形式のファイルに出力します。単に出力するだけでなく、データレコードをリリース日別に出力したり、海外版のために翻訳データを適用したりといった機能もサポートしています。このツールはデータを作成する人のローカル環境で実行するのですが、Go なのでバイナリの作成・配布が簡単で、あるゲームタイトルで使っている大量のデータでも1分ほどで実行が完了するぐらい高速です。

テーブルごとに以下のように YAML でスキーマを定義し、Excel の中身を解釈しています。

properties:
  id:
    description: "ID"
    type: "integer"
  name:
    description: "クエスト名"
    type: "string"
  stamina:
    description: "消費スタミナ"
    type: "integer"

f:id:s-capybara:20201216193234p:plain
サンプルの Excel

オニオンアーキテクチャの導入による改善

導入前に困っていたこと

最初はシンプルな実装だったのですが、開発を進める中で機能の数が増えていき、当初の想像以上に複雑なアプリケーションになっていきました。以下のように困りごとの数も増えていき、開発がやりにくくなってしまいました。

  • テストが入力データのファイル形式に依存している。
    • 複雑なロジックの途中に YAML を読む処理があるため、細かいテストのために逐一 YAML でテストデータを作る必要がある。
    • Excel で日時を表現するデータ形式が特殊なため、自分でテストデータを作ることが難しく、日時に関するテストをする際に Excel でテストデータを作る必要がある。
  • ファイル形式やライブラリの都合がコード全体に漏れ出ている。
    • YAML のデータ構造と実装に便利なデータ構造が微妙に異なるため、YAML にデータ構造を合わせるとそれを利用する側のコードが書きにくくなる。
    • YAML ライブラリの都合上、struct のフィールドを public にする必要がある。(そのため、関数でラップして上の問題を解決するということも難しい)
  • 新しい出力形式のサポートが難しい。
    • ツールを利用するゲームタイトルによって出力形式を変えられるようにしようと思うと、各形式を ON/OFF する分岐処理が煩雑になる。
    • 各形式で細かく設定変更できるようにするのも難しい。

改善後の構造

これらの問題を解決するために、オニオンアーキテクチャを導入しました。 以下のようにリポジトリのパッケージ(ディレクトリ)構成を変更し、依存関係が domain <- application <- infrastructure となるようにしました。

  • domain
    • Excel や YAML といった特定の形式に依存しない、ツールにとって最も重要なロジックを書く。
    • 外側のレイヤが使う interface を定義する。
  • application
    • domain のオブジェクトや、domain の interface を満たす infrastructure のオブジェクトを組み合わせて、ツールのユーザーが所望する単位の処理の流れを形成する。
    • コード量は非常に少ない。
  • infrastructure
    • Excel や YAML, JSON といった特定のファイル形式に依存したコードを書く。
    • domain で定義された interface を実装する。

内側で interface を定義することで依存性を逆転させ、ドメインロジックが特定のファイル形式に依存せずに済むようになりました。

オニオンアーキテクチャではドメインモデルとドメインサービスがはっきり分かれていますが、今回は、区別しつつも同じディレクトリに入れることにしました。ドメイン駆動開発(を今回きっちりやっているわけではないですが)において、これらを別の場所に置くと「ドメインモデル貧血症」を起こしやすくなると ヴァーン・ヴァーノン著『実践ドメイン駆動設計』 に書かれています。また、ドメインサービスが大きくなりすぎないように、かつドメインモデルがドメインサービスに依存しないようにしています(注意しなくても自然とそうなりました)。

改善されたこと

オニオンアーキテクチャのメリットそのものですが、以下のようにメンテナンス性が向上しました。

  • ドメインロジックがファイル形式に依存しなくなり、テストしやすくなった。
  • ファイル形式やライブラリに合わせるためのコードを一箇所に集約できるようになった。
  • 新しい種類のファイル形式に対応しやすくなった。

例として3つ目を少し詳しく紹介します。機能追加をしようとしていて、ツールを利用するゲームタイトルごとにファイルの出力形式を変更できるようにし、またそれぞれの形式について細かいオプションを付けたいという事情がありました。以前は決め打ちで特定の形式を使うようになっていたのですが、この箇所の柔軟性が上がり、設定ファイルを通じて任意の形式を選べるようになりました。また、同じ形式を異なるオプションで複数回利用するということもできるようになりました。

marshalers:
  # キーは出力先のディレクトリ名
  diff_check:
    type: json
    options:
      time_zone: JST
  server:
    type: message_pack
  client:
    type: message_pack
    options:
      hide_secret: true # ゲームクライアントが使わないテーブルやカラムを削除する

導入後に悩んだこと

各レイヤのテスト

どのレイヤにどの程度テストを書けば良いのか、というのは悩んだポイントでした。domain については、もともとの動機でもあるのでユニットテストを細かく書いています。一方で、application についてはとても薄いレイヤになり、分岐があるわけでもないので全くテストしないことにしました。代わりに、Excel や YAML でシンプルなテストデータを少しだけ作って、infrastructure を含めた E2E テストを書くことで application のテストを内包することにしました。手動での動作確認を頻繁に繰り返す手間を省くため、ツール全体が正しく動作することを保証したい事情もありました。

しかし、この対応だけでは不充分で、アップデートしたツールを利用者(マスタデータ作成者)に配布したところ、何度かトラブルが発生してしまいました。domain にバグが含まれるケースはなく、毎度 infrastructure に原因がありました。infrastructure が外部のファイルに依存していることを理由にあまりテストを書いていなかったのですが、冷静に考えてみると、いくつかの工程に分解できることに気づきました。

  1. テキストファイルを読み込む
  2. ライブラリを使ってパースする
  3. domain に合う形に変換する

2 の YAML のキー名を指定する場所で typo が発生しがちで、度々 0 や空文字列などのデフォルト値になってしまっていました。また、3の工程も意外に複雑でした。しかし、細かい機能を追加するたびに E2E テストを書いていては、テストの影響範囲が大きくメンテナンスコストが高すぎると感じました。

そこで、1 とそれ以外を別々の関数にし、2 の結果がライブラリ独自の複雑なデータ構造であり自分で作ることが難しいことも考慮して、テストコードの中にヒアドキュメントとして YAML などを書き、2 と 3 のみまとめてテストすることにしました。ライブラリや domain に強く依存しているので若干脆いテストにはなるのですが、特段メンテナンスしにくいということもなく、バグの発生を抑えられるようになりました。

巨大な domain の整理

オニオンアーキテクチャでは、ドメインモデルやドメインサービスの中身をどう構成するかは特に言及されていません。今回の実装では domain/schema, domain/record といったいくつかのパッケージに分割し、domain/schema <- domain/record などと横向きの依存関係を作りました。複数の domain パッケージから参照されるものもあれば、他の domain パッケージからは参照されないものもあります。今後は domain パッケージの階層を増やす可能性もあります。

domain パッケージの間には interface を挟まず、直接参照するようにしました。最初は interface を逐一定義しようと考えたのですが、ドメインモデルの全てのメソッドを interface 化しようとするとかなりの手間だったので、1人開発では割に合わないと思い、断念しました。

interface を定義しないとなると、スタブを使うことができず他のパッケージ (schema) を参照しているコード (record) のテストが難しくなります。そこで、厳密さよりもテストデータの作りやすさを優先して参照先のコード (schema) を変更しました。具体的には、schema オブジェクト生成時にオブジェクトが正当であることの保証をせず、少しずつ組み立てて、最後にバリデーションする形にしました。

// domain/schema/schema.go
package schema

func NewProperty(name string, propertyType PropertyType) *Property {
    // ...
    return property
}

func (schema *Schema) AddProperty(property *Property) *Schema {
    schema.properties = append(schema.properties, property)
    return schema
}

func (schema *Schema) Validate() error {
    // プロパティ名のバリデーションなど
}
// domain/record/record_test.go
package record

func TestRecord(t *testing.T) {
    sch := schema.NewSchema().
        AddProperty(schema.NewProperty("id", schema.Integer)).
        AddProperty(schema.NewProperty("name", schema.String).SetNullable(true))

    // ...
}

この例では、例えば schema.NewProperty の第1引数であるプロパティ名にはアルファベットや数値以外指定できないのですが、作る際は一旦気にせず、後でバリデーションしています。そのため、バリデーションを実行し忘れると不正なスキーマで処理が続行してしまう危険性があります。interface 定義の手間を減らしてテストをしやすくするためにバグのリスクを取っているということになり、これがベストな方法なのか、今でも迷いがあります。

もはやオニオンアーキテクチャの管轄外の問題ではありますが、ある意味、オニオンアーキテクチャの導入によって問題が明瞭になったと言えるのかもしれません。以前は他の問題が多すぎて、ドメインロジックとそのテストをどう整理していくかということに意識をあまり向けられていませんでした。

infrastructure 都合で domain 実装が変わることもある

「内側のレイヤは外側のレイヤの変更の影響を受けない」と思っていましたが、そうでない場面もありました。パフォーマンスチューニングをするにあたって、例えば DB を使う場面で、複数レコードをまとめて INSERT することで DB との通信回数を減らすなど、ドメインが DB の事情に全く無関心というわけにはいきません。「内側で interface を定義して外側で実装する」というのは「外側の変更の影響を受けない」とはイコールではありませんでした。

今回のツールでは、当初 Excel のシートを読む際に 360EntSecGroup-Skylar/excelizefunc (*File) GetRows という関数を使い文字列の二次元配列 [][]string として利用していましたが、大きいファイルを扱うにはパフォーマンス上不向きだったため、func (*File) Rows というストリーミングで読み込む方式の関数に乗り換えました。これに従ってドメインの interface も変更する必要がありました。

外側の事情で内側を変更することに抵抗を感じましたが、読み込み済みの [][]string データをストリーミング方式の interface で利用することもやろうと思えば可能ではあるので、「より汎用的な interface にブラッシュアップされた」と考えれば、悪いことではなさそうです。

まとめ

オニオンアーキテクチャを使うと、ドメインロジックが特定のファイル形式などに依存しないようになります。導入することで新たに悩むポイントもいくつか発生しましたが、テストがしづらいなどの様々な問題を改善できました。参考になる部分があれば幸いです。

Vim で外部から情報を受ける

この記事は Akatsuki Advent Calendar 2020 の 15 日目の記事です。

thinca です。普段は Vim を使って開発をしています。

Vim は外部ツールとの連携を得意としているソフトウェアで、外部コマンドの実行結果を取り込んだり、Language Server のような外部ツールを起動してデータをやり取りしたり、といったことが行えます。

一方で Vim は、Vim の外部から情報を通知してもらうのが苦手です。

本記事では、まず前半で、Vim が外部から情報を受け取る手段についてまとめます。後半ではこれらを用いて Vim でどのようなことが可能になるかについて検討します。

外部から情報を受ける手法

clientserver

Vim の clientserver 機能は、まさしく外部から情報を受けるための機能です。

clientserver 機能が有効になっている Vim を起動すると、その Vim は外部から情報を受けられるようになります。別の Vim から、別のサーバとして起動している Vim の一覧を取得したり、それぞれの Vim に式を評価させたりすることができます。

この機能には 2 つの問題があります。

  • クライアントも Vim である必要がある
    • これは CLI で vim をワンショットで起動させることで回避が可能です。
  • 環境によっては動作しない
    • 特に Linux 環境においては X サーバとの連携が必要になるため、X の入っていないサーバのようなシステムでは利用できません。

上記の理由から、利用はかなり限定的になります。残念ながら私は、普段はプライベートでは X の入っていないサーバマシンを開発機として使っているため、この機能は利用できません。

Job

Job 機能を使うことで、Vim は外部プロセスを起動してその標準出力を待ち受けることができるようになります。

この機能を使えば外部からの情報の受け取りが現実的に行えます。vim-lsp などはこの機能を使って Language Server から情報を受け取っています。

デメリットとしては、外部コマンドが必要になるため使用するコマンドによっては環境に依存してしまう点です。しかし、最近はシングルバイナリで動作するツールも比較的容易に作れるため、必要であれば専用コマンドを同梱してしまうという手もあります。

ch_listen()

現在はまだ利用できませんが、ch_listen() という関数の追加が提案されています。これがあれば、TCP ポートや UNIX ドメインソケットで入力を受け付けて、処理を実行することができるようになります。Job と違って外部ツールに依存しない点が魅力です。

現在はユースケースが不足していて、必要性についてあと一歩説得力が足りていないようです。もっと実用的なユースケースが提案できれば動きがあるかもしれません。

活用できそうな事例

上記の事情から、私は主に Job を使った方法を利用しています。ここでは、実際にどのような場面で活用できるか、あるいはできそうかを紹介します。

別の Vim でファイルを開く

これは clientserver 機能の主目的とも言えますが、例えばすでに Vim が起動していた際に別の場所で新たに Vim でファイルを開こうとした場合、Vim が複数起動してしまいます。

このとき、新しく Vim を起動する代わりにすでに起動している Vim でファイルを開く、といったことが可能になります。

ファイルの再読み込み

Vim は、開いているファイルが外部で変更されたかどうかは逐一チェックしていません。:checktime Ex コマンドを使うとチェックしてくれますが、必要な際に実行する必要があります。

GUI 版の Vim であれば、例えば Vim のウィンドウがフォーカスを得た際に実行される FocusGained イベントで実行すれば、ほとんどの場合をカバーできそうですが、CUI だと難しそうです。

こんなときに、外部から変更を通知したり、あるいは CUI の Vim がフォーカスを得たことを通知できれば自動でのファイル再読み込みが捗りそうです。

hubot-vimexec

拙作の、チャット上で Vim のコマンドの実行結果を見れるようにする bot です。

thinca/hubot-vimexec

よくある言語処理系の実行結果を出すものと違い、裏で Vim が常駐し、状態を保持しています。なので以下のようなことができます。

f:id:thinca:20201214203600p:plain
状態を保持した bot

これを実現するためには、実行中の Vim に対して外部からスクリプトを与える必要があります。

一番簡単な方法は、Vim 側で新しいスクリプトがないか逐一ポーリングすることです。実際、最初はこの方法で実装したのですが、さすがに 1 秒に何度も readdir() するのはディスクへの負担も気になってきます。

これを Job で置き換えたいのですが、この bot は環境を閉じ込めるために Vim の実行にネットワークを無効にした Docker を使っています。ポーリングの際は、バインドマウントを行ってそこに実行したいファイルを置き、終わったら結果のファイルを置いてもらっていました。これを、Job を使って Vim の内部でプロセスを実行し、外からこのプロセスに対して何らかのアプローチをする必要があります。

そこで、named pipe を使いました。これであればバインドマウントだけでやりとりができます。

まず、マウントした領域にリクエストを受け付けるための named pipe を作成し、Vim はこのファイルを cat コマンドで読み取る Job を生成して待ち受けます。

bot にリクエストが来たら、まずはそのリクエストに対するレスポンスを受け付けるその場限りの named pipe をマウントした領域に生成します。そしてスクリプトの内容と、レスポンスすべきファイル名をリクエスト用のファイルに書き込み、あとはレスポンス用のファイルを、Node.js の fs.readFile() で読み、読み込めるのを待ちます。

Vim 側はリクエストを受けると cat のプロセスがファイルの内容を出力して終了するので、スクリプトを実行し、結果をレスポンス用のファイルに writefile() で書き込みます。その後、次のリクエストのために再び cat の Job を実行します。

bot はレスポンスを受けとると、専用の named pipe を削除し、結果をチャットに伝えます。ちなみにレスポンス用に毎回別のファイルを用意するのは、複数のリクエストが同時に来た場合のためです。

この手法は Windows だと難しいため利用範囲は限られますが、必要なコマンドが catmkfifo だけであるため、Windows 以外であれば比較的容易に応用が効きそうです。

ch_listen() の模倣

ch_listen() はまだ使えませんが、要は、port を LISTEN し、接続があれば入力を標準出力に流し、標準入力から受け取った内容をクライアントに返すようなコマンドがあれば、Job を使って ch_listen() を再現できそうです。

そんな便利なコマンドがあればよいのですが…そんなスイスアーミーナイフのようなコマンドが…アーミー…?あっ!

というわけで netcat を使って ch_listen() のようなことをしてみましょう。

簡易ですが、Vim を起動して、以下のスクリプトを実行します(:source server.vim)。

function s:handle(req) abort
  if a:req.method ==? 'POST'
    return {
    \   'status': 200,
    \   'status_text': 'OK',
    \   'body': execute(a:req.body),
    \ }
  endif
  return {
  \   'status': 404,
  \   'status_text': 'Not Found',
  \   'body': json_encode(a:req),
  \ }
endfunction

function s:parse_request(msg) abort
  let req = {}
  let matched = matchlist(a:msg, '^\v(.{-})\r\n\r\n(.*)')[1 : 2]
  let [header_block, body] = matched[1 : 2]
  let start_line = split(header_block, "\r\n")[0]
  let req.method = split(start_line, '\s\+')[0]
  let req.body = body
  return req
endfunction

function s:out_cb(cxt, ch, msg) abort
  try
    let req = s:parse_request(a:msg)
    let res = s:handle(req)
  catch
    let res = {
    \   'status': 400,
    \   'status_text': 'Bad Request',
    \   'body': v:exception,
    \ }
  endtry

  let response = join([
  \   printf('HTTP/1.1 %d %s', res.status, res.status_text),
  \   'Content-Length: ' .. len(res.body),
  \   '',
  \   res.body,
  \ ], "\r\n")
  call ch_sendraw(a:cxt.in, response)
endfunction

function s:start(port) abort
  let cxt = {}
  let job = job_start(['nc', '-lkp', a:port], {
  \   'in_mode': 'raw',
  \   'out_mode': 'raw',
  \   'out_cb': funcref('s:out_cb', [cxt]),
  \ })
  let cxt.in = job_getchannel(job)
endfunction

call s:start(11111)

サンプルなので処理をかなり省略していますが、どこからどう見ても HTTP サーバです。このサーバは 11111 番ポートで待ち受け、テキストを POST メソッドで投げると Vim script として実行し、結果の出力を返します。

というわけで、curl でアクセスしてみましょう。

$ curl -d 'echo "Hello, Vim server!"' 'localhost:11111'

Hello, Vim server!

結果が返ってきました!やりましたね。

注意点として、netcat には様々な派生バージョンがあるため、実際に使えるかどうかは環境に入っている netcat の機能について知る必要があります。

まとめ

Vim で外部から情報を受け取る手法についてまとめました。また、それらの活用方法についても検討しました。

これらの応用により、Vim をもっと便利にできる可能性があるのではないかと考えています。この記事がその一助になれば幸いです。

ボトルネックマネージャーを抜け出す方法

こんにちは、ゆのん(id:yunon_phys)です。この記事は Akatsuki Advent Calendar 2020 の12日目の記事です。

アカツキは10周年を迎え、それと同時に経営体制が一新され、経営チームExecutive Leadership Team(ELT)が結成されました。 経営戦略の基本方針も変更となり、社内の構造的にも社内の方針としても、アカツキにとって大きな変化のある1年となりました。

私もELTのメンバーとして現在主力であるゲーム事業の職能組織を束ねる立場(Chief of Staff, Games / CoSG)となり、影響の範囲も、責任の範囲も大きく拡大しました。 これまでは、私はVP of Engineering(VPoE)としてエンジニア組織を束ねる立場にあり、一つの職能のことだけを考えておけば良かったです。 しかし今では、エンジニアだけでなく、デザイナーや企画職・QA・マーケターなど、様々な職能を理解し、文字通り全ての人が輝けるように組織を作る必要が出てきました。 昨年の私の個人的なブログで、エンジニア組織だけでなく全体的に良くしたいという記事を書きましたが、1年後にまさかそれが出来る立場になるなんて、未来のことはわからないものですね。

この新しい役割になって、どんな組織を作っていくのか、ご興味のある方は以下のインタビュー記事を読んでみてください。 voice.aktsk.jp

・・・さて、前置きが長くなりました。 CoSGになったという話をしてきましたが、実はVPoEを今も兼任しています。 兼任と言うと聞こえは良いですが、同時に業務をやれるわけではないので、どうしても責任範囲の大きいCoSGの業務が優先されてしまいがちです。 しかし、VPoEとしての業務が無くなるわけではないので、自分がボトルネックになってしまっているのが次第に浮き彫りになってきました。

今回の記事は、そんなボトルネックになってしまったマネージャー業をどうやって抜け出そうとしているのか*1、この1年の取り組みを書いていきます。

Engineering Manager(EM)が同じ情報を持てるようにする

以前はプロジェクトに直接入ったり、1on1する頻度も高く設定していたので、様々なエンジニアに関する情報を自分に集約して様々な意思決定できていました。 しかし、CoSG業が多くなり、エンジニア以外の情報が増える一方で、エンジニアの情報が減っていってしまいました。 特に困ったのが異動・配属で、発生する度に情報をかき集めて、そこに所属しているEMや他のリーダーと交渉して、といったことに追われるようになってしまいました。

この状態を脱却しようと、一度立ち止まって何が問題なのか考えたところ、私に情報を集めようとしている行動そのものが良くないことだと気が付きました。 例えば、異動・配属については、プロジェクトの短期的な課題解決として実行する場合もあれば、中長期的な会社戦略を見据えて実行する場合もあります。 これらを広い視点で実行するためには情報を正しく持っていなければいけない、と思っていました。 しかし、そもそも情報を正しく持っているのが自分だけの状態になっているのが問題でした。

私の信念としては、「同じ情報を持っていれば、全員が同じ意思決定にたどり着く」、です。 裏を返すと、全員が同じ意思決定にたどり着くためには、同じ情報を持っていなければならないということです。

そこで、私に集まってくるエンジニアに関する情報や会社の方針情報などを、可能な限りEMに共有するようにしました。 具体的には、月1回全EMが集まる場を設け、そこで共有しながら、EMの意見を出し合うようにしました。 また、全EMにはそれぞれの抱えているプロジェクトの情報や課題などを共有してもらうようにしました。 全EMが同じ情報を持っている状態を一応は出来たことになります。

これでうまくいくと思ったのですが、残念ながら、私の満足いく結果にはなりませんでした。 それはEMに与えられている権限・責任が適切でなかったからです。 権限・責任が適切でなかったために、EMが自発的にやれる領域を狭めてしまっていました。 そこで、権限・責任を適切に委譲する作業に取り掛かりました。

適切な権限委譲をする

私は適切な権限委譲こそ組織を次の段階に成長させるために、重要な要素だと考えています。 オレンジ組織からグリーン組織のブレイクスルーの一つは権限委譲である、と以下の書籍でも書かれています。

過去には私とCTOとの間でManagement3.0のプラクティスの一つであるDelegation Boardを作りました。*2

hackerslab.aktsk.jp

今回も同じ方法で私と全EMが集まり、Delegation Boardを1日がかりで作成しました。 私はDelegation Boardの結果も大事だと感じてますが、それ以上にそれを決めていく過程こそ意味があると思っています。 実際にこれを作っていく過程でDelegation Levelが一致せず、お互いの想定している権限やそれに伴う責任を丁寧に言語化していきながら、時間をかけて一つ一つ対話していきました。 最終的に出来たDelegation Boardは全員が満足いく結果になりました。

Delegation Boardを作ってから、明らかにEMの動きが変わりました。 何を自分たちがやるべきなのか、どんな責任を持っているかが明確になることが、ここまで強力なのかと改めて理解できました。

EMのサポートを手厚くする

ここまででは、私の負担が下がり、EMへの負担が上がる一方です。 ある意味望んだ結果ではあるのですが、それはあまりに不公平な感じがします。 もし私が逆にEMの立場だったら、今までやったことの無い仕事が増えてしまうので、戸惑ってしまうだろうなと思いました。

そこで、EMの業務をよりサポート出来るように、 @noto さんに各EMの1on1を依頼しました。 @noto さんは私とは違う観点で人と向き合ってくださる方で、とても信頼のおいている方です。 EMの悩みに適切な距離感でアドバイスしたり、聞き役に徹してくださっているようで、早い段階でEMの悩みに解決に向けて動けていってると感じてます。

@noto さんのように第三者的なEMのサポートは、僕のように全てをケアしきれないときに、とても心強い存在です。

何が変わったか

これらの活動により変わったことは、主には以下の通りです。

  • 全エンジニアの状況の見える化シートが自発的に作られ、EM全員でメンテするようになった
  • エンジニアの異動・配置をほぼ私なしで進められるようになった
  • ダイレクト・リクルーティングを私なしで進められるようになった

これまで、自分が記録ではなく記憶にいかに頼りすぎていたのだろう、と感じさせられてしまいました。

これらの業務が自分の手元から離れていくことで、より事業の戦略・戦術の策定に落とし込む時間が増えてきました。 まだまだ自分がボトルネックになっていることはたくさんあるとは思いますが、少しずつでも解消していけるよう工夫していきます。

*1:兼任は続いているので、ボトルネックになっている事実は払拭できていない

*2:Delegation Boardは強力で、これを作る過程とそれによる情報の透明さからすっかり虜になってしまいました。それ以来、社内で何かと権限委譲について語る場面が増えて、Delegation Board作成のファシリテーションを何度もしたことがあります。

新メンバー受け入れ時に各セクションの夕会に参加して回るツアーを実施している話

この記事は Akatsuki Advent Calendar 2020 9日目の記事です。

はじめに

アカツキでサーバサイドエンジニアをしている jyll です。

50人を超える大規模チームのオンボーディングとして、チーム内夕会ツアーを実施してみたら新規メンバーにも既存メンバーにも好評だったので、その際の知見を共有してみます。

チームの構造とリモートワーク下で感じていた課題

私の所属しているプロジェクトは全体で50人以上の大規模チームで、企画職・サーバサイドエンジニア・クライアントエンジニアなどのセクションにおおよそ10人ずつ分かれています。現在はほぼフルリモートワークになっています。

リモート開始前からのメンバーとその後から入ってきたメンバーで情報格差が発生していて、 リモートワーク導入以前から見知っていたメンバーはコミュニケーションに不便をほとんど感じないが、そのあとに入ってきたメンバーは

  • 誰に何を相談したらいいのかわからない
  • チーム全体としてどういった流れで開発が進んでいるのかわからない

などといった不安を抱えてしまうという課題が挙がっていました。

夕会ツアー

そこで、新規メンバーのチーム全体での自己紹介は名前を紹介する程度にしておき、その後各セクションの朝会や夕会に一度ずつ参加して回るツアーを実施してみました。

各セクションの業務になるべく業務に負荷をかけない形で、チーム全体としてはどんな人たちがいて、普段どういう仕事をしているか、誰とコミュニケーションを取っているのかに触れる機会を作ることを目的としています。

各セクションで毎日集まっているミーティングの日程を洗い出して、それぞれ1日ずつ都合のいい日をピックアップしてもらって引率者と新メンバーをそこに混ぜてもらいます。 そこで普段通りの報告を行ってもらったあと、新メンバーとの相互の自己紹介タイムを10分ほど取ってもらう、という流れです。

f:id:jyll:20201209110656p:plain
こんな感じで呼びかけて、その日からさっそく色々なところにお邪魔して回りました。

実際やってみてどうだったか

全体自己紹介後にすぐ担当セクションに引き渡してしまうよりも、丁寧なオンボーディングになりました。 チームとして感じたメリットですと、以下2点が大きかった印象です。

  • 役割の紹介という形ではなく実際に回している業務の話が聞けるので、行なっている業務のイメージがつきやすい
  • 少人数かつ普段一緒に業務をしている人たち中心での自己紹介になるので、より具体的な趣味の話が出てきやすい

おわりに

この試みは実はアカツキの他のプロジェクトで先に導入されていて、そこでうまくやれているとの報告を受けて導入してきたのですが、期待通りの結果が得られました。

大規模なプロジェクトのつながりを作るきっかけとして、簡単に導入できるオンボーディング施策かと思います。おすすめです。