クックパッドマートにおける宣言的ラベル生成

クックパッドマート流通基盤アプリケーション開発グループのオサ(@s_osa_)です。

少し前にクックパッドマートのラベル生成の仕組みを刷新したので紹介します。

クックパッドマートにおけるラベル

クックパッドマートは「美味しい食材を生産者や市場から直接ユーザーにお届けする」サービスです。

食材をユーザーのもとまで届けるためには流通の仕組みが欠かせません。クックパッドマートでは「1品から送料無料」をはじめとするサービスを実現するために独自の流通網を構築しています。

そんなクックパッドマートですが、流通の現場で実際に商品を運ぶドライバーに対しては、主に2つの手段で情報を提供しています。

ひとつはアプリであり、スマホ向けアプリの画面を通してその日の配送計画を伝えたり配送状況の追跡をおこなったりしています。

そして、もうひとつがラベルです。ラベルは何種類かありますが、たとえば商品に貼り付ける「商品ラベル」は以下のようなものです。

f:id:s_osa:20210817115720j:plain
商品ラベルの様子

流通の現場では多くの物理的な「モノ」を扱う必要がありますが、目の前にあるモノとアプリの画面を見比べながら配送業務をおこなうことは業務効率の観点から現実的ではありません。そこで、目の前のモノについての情報は印刷したラベルシールをモノ自体に貼り付けることによって伝えています。

ラベルとアプリという2種類の情報伝達手段ですが、商品名など目の前のモノに紐付く情報はラベル、ルート情報などの俯瞰的・概念的な情報はアプリ、という適材適所の使い分けをしています。ラベルとアプリの両輪による情報提供があってクックパッドマートの流通オペレーションは成り立っています。

既存のラベル生成が抱えていた問題

そんなラベルですが、重要なだけあってサービスの初期から使われています。一方、サービスを取り巻く環境が当時とは変わってきたこともあり、いくつかつらい点を抱えるようになっていました。

素朴な実装

ラベルプリンタへ送るデータは印字するテキストのほかに制御用のコマンドを含むバイナリなのですが、そのバイナリを素朴で手続き的な文字列操作で生成していました。単純化していますが、イメージとしては以下のようなものです。

binary = ""
binary << "#{item.name}\n"
binary << begin_bold_command
binary << "#{user.name}\n"
binary << end_bold_command

実際には、コードや DB では UTF-8 で扱っている文字列をプリンタが要求する Shift_JIS に変更するなどの処理も必要です。当初は素朴なラベル生成コードでしたが、サービスが成長するにつれて要求が複雑になり、少しずつ見通しの悪いコードになっていきました。

分離されていないデータ生成と印刷

ラベル生成と印刷はバッチや非同期ジョブなどでおこなっていたのですが、そのバッチやジョブの中にラベル生成のロジックがベタ書きされているケースがありました。

ベタ書きされていることによってテストを書きにくいほか、他の箇所で同じラベル生成ロジックを再利用することが困難になっていました。

複数実装の維持

当初はラベルプリンタによるラベル印刷だけをおこなっていましたが、同じ内容を通常のプリンタでも出力したいという要求が出てきました*1

しかし、上記の素朴な実装で生成したバイナリはラベルプリンタ用のデータであって通常のプリンタで印刷できるものではありません。そこで、ラベルと同じ内容を含む HTML を生成してから PDF に変換するという方法を取ることになりましたが、そのための HTML はラベルプリンタ用バイナリとは完全に別の仕組みで生成されることになりました。

結果として、ラベル生成ロジックに変更を加える際には2種類のラベル生成ロジックを不整合なく同時に変更する必要が生まれました。

変更しにくいラベル

流通オペレーションはラベルの存在を前提にして組まれているため、ラベルの生成ができない状況が発生すると流通が止まってしまいます。また、ラベルはその物理的な性質から、一度印刷されて流通に乗ってしまうと修正が(現実的には)不可能になります。

つまり、ラベル生成には不具合が発生した際の影響が大きい上にリカバリが難しいという性質があります。

一方、冒頭でも述べたとおり、クックパッドマートの流通においてラベルによる情報提供は非常に重要です。

これらの性質が複合した結果、ラベルは重要であるにもかかわらず、改善サイクルを回しにくいという状態になってしまっていました。

解決方法

方針

これまでに述べた問題を解決するために、以下の設計目標を立てました。

  • 見通しが良くメンテナンスしやすいラベル構造の定義
  • ラベル生成と印刷の分離
  • 単一実装によるラベルプリンタ用バイナリと HTML 両方の生成

これらの設計目標を満たすため、ラベル構造を表現する木構造のオブジェクトを組み立てて、そのオブジェクトからバイナリや HTML の表現を生成する方針にしました。

ここから先は実装の話なので、サンプルコードを中心に説明します。ただし、わかりやすさのために多少簡略化しています。

ラベル要素の実装

はじめに、ラベルに含まれる要素を表現するためのクラスを定義します。HTML におけるタグを思い浮べてもらうのがわかりやすいと思います*2

また、必要なクラスを定義していく際、すべてのクラスに to_binaryto_html メソッドを持たせます。

たとえば、ラベル中の文字列を表わす要素 Text だとこんな感じになります。下のサンプルコードにあるエンコーディングの変換や HTML のエスケープ処理のほか、実際には UTF-8 から Shift_JIS に変換できない文字の対処などもこのクラスで実行しています。

class LabelElement::Text << LabelElement::Base
  # @param text [String] UTF-8
  def initialize(text)
    @text = text
  end
  
  # @return [String] Binary
  def to_binary
    @text.encode(Encoding::Shift_JIS)
  end
  
  # @return [String] HTML string in UTF-8
  def to_html
    escaped_text = CGI.escapeHTML(@text)
    %Q|<span class="label-element__text">#{escaped_text}</span>|
  end
end 

また、太字を表わす要素 Bold などは基本的に木構造の内部ノードになるので、太字にする対象の子要素を持てるようにします。

class LabelElement::Bold << LabelElement::Base
  attr_reader :children

  def initialize
    @children = []
  end

  # @return [String] Binary
  def to_binary
    [
      begin_bold_command,
      @children.map(&:to_binary).join,
      end_bold_command,
    ].join
  end

  # @return [String] HTML string in UTF-8
  def to_html
    %Q|<span class="label-element__bold">#{@children.map(&:to_html).join}</span>|
  end

  # @param element [LabelElement::Base]
  def <<(element)
    @children << element
  end
end

これらのクラスのほか、クックパッドマートのラベルで利用している要素をそれぞれクラスとして定義しました。具体的には、改行、フォントサイズ変更、文字寄せ(左・中央・右)、上線、下線、白黒反転、QR コード、ラベルのカット、ラベルシートそのものなどです。

また、上のサンプルコードでは省略していますが、オブジェクト同士の値としての等価性を判定する == メソッドなども適切に定義します。

これらのクラスが準備できると以下のような形式でラベル構造を定義できるようになります。to_binary, to_html それぞれのメソッドが木構造の根ノードから葉ノードまで順に呼び出されていくことによって、最終的にバイナリ・HTML それぞれの表現が得られます。

label = LabelElement::Sheet.new
label.children << LabelElement::Text.new(item.name)
label.children << LabelElement::NewLine.new

bold = LabelElement::Bold.new
bold.children << LabelElement::Text.new("#{user.name}")
bold.children << LabelElement::NewLine.new

label.children << bold

label.to_binary # => ラベルのバイナリ表現
label.to_html # => ラベルの HTML 表現

ラベル構造の定義を簡単にする

上に書いたラベル構造の定義はお世辞にも読み書きしやすいものではないので、もう少し人間にやさしいインターフェイスを定義します。React.createElement() に対する JSX のようなイメージです。

今回の実装は Ruby でおこなっているのでブロックを用いて以下のように定義しました。jbuilder などと似た、Ruby ではわりとよく見る記法になっています。

label = LabelBuilder.new.build do |l|
  l.text_line(item.name)
  l.bold do
    l.text_line("#{user.name}")
  end
end

label.to_binary # => ラベルのバイナリ表現
label.to_html # => ラベルの HTML 表現  

要素を直接作るスタイルと比べて、人間が読み書きしやすいインターフェイスになったと思います。

ドメインに基づいたラベルを定義する

ここまでで汎用的なラベル生成の仕組みができました。

実際のアプリケーションではドメイン内のモデルからラベルを作りたいことがほとんどなので、そのためのテンプレートを作成します。テンプレートの中身は上のブロックを用いたインターフェイスで書きます。

商品ラベルであれば主に OrderItem というクラスのインスタンスから生成されるので、テンプレートを使って以下のように生成できるようにします。

template = LabelTemplate::OrderItemLabel.new(order_item)
label = template.build_label

label.to_binary # => ラベルのバイナリ表現
label.to_html # => ラベルの HTML 表現  

このテンプレートができたことにより、ラベル生成ロジックが一元化され、アプリケーションの任意の箇所で簡単にラベルデータを生成できるようになります。また、それぞれのラベルの構造を知りたいときはブロック記法で把握できるようになっています。

効果

上記の実装により以下のような効果が得られました。

宣言的に記述されたラベル構造

ブロック記法を用いてラベルのテンプレートを定義したことにより、文字列とコマンドの羅列ではなく、階層化された構造としてラベルを読み書きできるようになりました。

また、ラベル構造を記述する際には「どういう内容をどういう装飾で表示するか」を記述するだけで良くなり、そのデータを「どのように生成するか」は考える必要がなくなりました。

ラベル生成と印刷の完全な分離

ラベル生成のロジックをテンプレートに切り出して宣言したことにより、ラベル生成は印刷から完全に分離され、アプリケーションの任意の箇所で再利用できるようになりました。

単一実装による複数表現の生成

単一のラベル構造から to_binary, to_html という2つのメソッドを用いて2種類の表現を生成できるようになりました。さらに、他の表現形式としてテストや簡易的なログ用途のプレーンテキスト表現が欲しくなったのですが、各ラベル要素に to_plain_text というメソッドを追加することで簡単に実現できています。

また、すべてのラベルに無料で HTML 表現がついてくるようになったため、管理画面上で印刷プレビューを表示したり印刷ログを HTML でも保存・表示したりといったことが簡単にできるようになりました。これは開発時の簡易的なチェックやデバッグに便利なだけでなく、流通オペレーションやカスタマーサポートなどの運用・調査でも参照する機会が多く、非常に役立っています。

f:id:s_osa:20210817170236p:plain:w300
プレビューの様子

変更しやすいラベル

個々のラベルがテンプレートに切り出されたことによりテストがしやすくなったほか、ラベル生成の仕組み自体を具体的なラベルから切り離してテストすることが可能になりました。

さらに、ラベル生成と印刷が完全に分離されたことにより、今後印刷する予定のラベル生成を dry run で走らせることが可能になりました。dry run の実現によって、万一変更内容に問題があった際にも実際に問題が起こって流通が止まる前に対処できるようになり、ラベル変更にともなうリスクが大きく下がったため、改善サイクルを回しやすくなりました。

厳密な比較ではなく参考程度の情報にはなりますが、刷新の前後で pull request の数を調べてみたところ、同じ期間あたりの pull request 数が2倍以上になっています。

おわりに

サービス開発初期のすべてが不確かな状況で書かれたラベル生成コードを現在の状況に合わせて書き直したという話を書いてきました。当初はこのエントリのサンプルコードにあたる部分を OSS として公開しようと考えていたのですが、絶妙に業務ロジックが絡みついていて公開できる形に抽象化できなかったのでサンプルコードでの紹介になりました。

流通という領域ではここまで書いてきたようなコードによる問題解決のほか、実際にモノを動かす現場のオペレーションも非常に重要です。事実、今回のラベル生成刷新も現場オペレーションの改善サイクルを高速化するための刷新です。

現場のオペレーションは、良い仕組みを考えたと思っても実際には実行が困難だったり、そうでなくても人間は間違えたりします。そういったソフトウェアだけに留まらない問題に対して、ソフトウェアを軸に挑んでいくのは困難であるとともに挑戦的で楽しいことだと感じています。

流通という裏側の仕組みはイメージしにくいところも多いと思いますが、少しでも興味が湧いた方がいたらご連絡ください。採用サイトからの正規ルートでももちろん良いですし、@s_osa_ まで雑に DM していただくなどでも大丈夫です。よろしくお願いします。

info.cookpad.com

*1:万一ラベルプリンタが故障しても出荷・流通が止まらないようにしたいというサービス可用性の観点のほか、販売者数拡大のためにラベルプリンタなしでの出店を可能にしたいといった動機が背景です。

*2:厳密には DOM element のほうがメタファーとして適切だと思います。

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/