Quantcast
Channel: クックパッド開発者ブログ
Viewing all 726 articles
Browse latest View live

良い感じにログを収集するライブラリ、Puree-Swiftをリリースしました

$
0
0

こんにちは。技術部モバイル基盤グループの三木(@)です。

クックパッドでは、Pureeと呼ばれるiOS/Android/ReactNative向けのログ収集ライブラリを公開しています。

モバイルアプリのログ収集ライブラリ「Puree」をリリースしました - クックパッド開発者ブログ

ログ収集ライブラリ Puree の iOS 版をリリースしました - クックパッド開発者ブログ

最近、以前開発されていたPureeをpure Swiftで書き換え、OSSとして公開しました。

この記事では、新しくなったPureeをご紹介します。

概要

クックパッドでは全社的にAmazon Redshiftを中心としたデータ活用基盤を構築しています。

クックパッドのデータ活用基盤 - クックパッド開発者ブログ

この仕組みを使い、公開している多くのモバイルアプリからも、1つのログ基盤にさまざまなログを集積させています。

しかし、モバイルアプリからのログ送信には、さまざまな状態を考慮する必要があります。 ログを送りたいタイミングに安定した通信が確保されているとは限らないですし、闇雲に送りすぎてしまうと、ユーザーさんのギガを圧迫してしまうかもしれません。

これらを解決するライブラリがPureeです。 ログをバッファリングし、まとめて送信したり、送信に失敗したログをキャッシュし、復元時にリトライする機能などを有しています。

f:id:gigi-net:20180227165235p:plain

Puree-Swiftの特徴

Puree-Swiftは、以前公開していたObjective-C版と異なり、以下のような特徴があります。

Objective-C版の設計思想を踏襲

Puree-SwiftはObjective-C版のPureeの置き換えを目指しています。そのため、タグシステムやプラグインの設計など、基本的な仕組みを踏襲しています。 詳しく知りたい方は以下の記事をご覧ください。

ログ収集ライブラリ Puree の iOS 版をリリースしました - クックパッド開発者ブログ

よりSwiftらしいインターフェイス

Objective-Cで書かれていた物をSwiftに刷新したため、よりSwiftから利用しやすいインターフェイスとなりました。

大きく変わったのはFilterOutputの実装方法で、以前は抽象クラスとして実装していたのですが、protocolを利用することができるようになり、よりSwiftらしいプロトコル指向な設計に生まれ変わりました。

依存関係の廃止

Objective-C版のPureeでは、未送信のログの永続化のため、YapDatabaseというSQLiteにアクセスするライブラリを利用していました。 しかしこのライブラリは最近メンテナンスが止まっていたり、Swiftで書かれていなかったりと、Pureeのメンテナンスを難しくする原因となっていました。 そのため、Puree-Swiftでは一切の依存関係を廃止して、iOS標準のファイルストレージを使うようにしています。

通常はこの利用方法で問題ありませんが、巨大なデータを扱いたい需要が出たときのために、LogStoreを自分でプラグインとして拡張できる設計になりました。 必要に応じてRealmやCoreDataなど、使いたいバックエンドを採用することができます。

実装例

それではさっそくPureeの実装例を見てみましょう。最終的には、以下のようなインターフェイスで任意の場所にログを送れるようになります。

ここでは、以下のようにPVログを送るまでの実装を考えてみます。

logger.postLog(["recipe_id":42, "user_id":100], tag:"pv.recipe.detail")

Pureeを扱うには以下の3ステップが必要です。

  1. ログを加工するFilterを実装する
  2. 収集されたログを外部に出力するOutputを実装する
  3. タグにより、どのFilterやOutputを利用するかルーティングする

より詳しい使い方はREADMEをご覧ください。

1. ログを加工するFilterを実装する

まず、Filterプロトコルを用いて、Filterを実装します。これは渡ってきた任意のデータをLogEntryに加工する役目を持っています。

ここでは単純に渡ってきたペイロードをJSONとしてエンコードして、LogEntryに格納しています。

import Foundation
import Puree

structPVLogFilter:Filter {
    lettagPattern:TagPatterninit(tagPattern:TagPattern, options:FilterOptions?) {
        self.tagPattern = tagPattern
    }

    funcconvertToLogs(_ payload:[String: Any]?, tag:String, captured:String?, logger:Logger) ->Set<LogEntry> {
        letcurrentDate= logger.currentDate

        letuserData:Data?
        ifletpayload= payload {
            userData = try! JSONSerialization.data(withJSONObject:payload)
        } else {
            userData =nil
        }
        letlog= LogEntry(tag:tag,
                           date:currentDate,
                           userData:userData)
        return [log]
    }
}

このFilter上で、全てのログに共通して付加したいペイロードを載せることもできます。 例えば、ユーザー情報などが考えられます。

2. 収集されたログを外部に出力するOutputを実装する

次に、収集されたログを外部に出力するためにOutputを実装します。

以下は渡ってきたLogEntryのペイロードを標準出力に出力するだけのOutputです。

classConsoleOutput:Output {
    lettagPattern:Stringinit(logStore:LogStore, tagPattern:String, options:OutputOptions?) {
        self.tagPattern = tagPattern
    }

    funcemit(log:Log) {
        ifletuserData= log.userData {
            letjsonObject= try! JSONSerialization.jsonObject(with:log.userData)
            print(jsonObject)
        }
    }
}

BufferedOutput

Outputを用いると、ログが送信され、即座に出力されますが、代わりにBufferedOutputを用いると、一定数のログが溜まるまでバッファリングし、定期的にログを送ることができます。 以下のようにAPIリクエストを伴うようなログ送信に適しています。

classLogServerOutput:BufferedOutput {
    overridefuncwrite(_ chunk:BufferedOutput.Chunk, completion:@escaping (Bool) ->Void) {
        letpayload= chunk.logs.flatMap { log inifletuserData= log.userData {
                return try? JSONSerialization.jsonObject(with:userData, options:[])
            }
            returnnil
        }
        ifletdata= try? JSONSerialization.data(withJSONObject:payload, options:[]) {
            lettask= URLSession.shared.uploadTask(with:request, from:data)
            task.resume()
        }
    }
}

クックパッドでは、最初に紹介したログ基盤を利用するための、APIを提供しており、社内ライブラリとして、そのAPIに送信を行うOutputを提供しています。

このように、自前で用意したあらゆるログ基盤に出力することができますし、Firebase AnalyticsなどのmBaaSに対応することもできるでしょう。

3. タグにより、どのFilterやOutputを利用するかルーティングする

最後に、実装したFilterやOutputをどのログに対して適応するかのルーティングを定義しましょう。 Pureeは、ログに付加されたタグを元に、どのような処理を行うかを決定します。

import Puree

letconfiguration= Logger.Configuration(filterSettings:[                                             FilterSetting(PVLogFilter.self,                                                           tagPattern: TagPattern(string: "pv.**")!),                                         ],
                                         outputSettings:[                                             OutputSetting(ConsoleOutput.self,                                                           tagPattern: TagPattern(string: "activity.**")!),                                             OutputSetting(ConsoleOutput.self,                                                           tagPattern: TagPattern(string: "pv.**")!),                                             OutputSetting(LogServerOutput.self,                                                           tagPattern: TagPattern(string: "pv.**")!),                                         ])
letlogger= try! Logger(configuration:configuration)
logger.postLog(["page_name":"top", "user_id":100], tag:"pv.top")

例えば、上記のような定義ですと、それぞれのタグについて、以下のように処理が行われます。 これにより、ログの種類によって加工方法や出力先を変えることもできます。

tag name -> [ Filter Plugin ] -> [ Output Plugin ]
pv.recipe.list -> [ PVLogFilter ] -> [ ConsoleOutput ], [ LogServerOutput ]
pv.recipe.detail -> [ PVLogFilter ] -> [ ConsoleOutput ], [ LogServerOutput ]
activity.recipe.tap -> ( no filter ) -> [ ConsoleOutput ]
event.special -> ( no filter ) -> ( no output )

まとめ

  • iOSのログ収集ライブラリ、Puree-Swiftをリリースしました
  • すでにクックパッドアプリでは使われており、開発中の他のアプリでも利用される予定です
  • Outputを追加すれば、さまざまなログバックエンドに対応することができます

どうぞご利用ください。

try!Swift

f:id:gigi-net:20180227165258j:plain

ところで、明日3/1から開催のtry! Swift Tokyo 2018にクックパッドもブースを出展いたします。 オリジナルグッズの配布も行いますので、クックパッドでのiOS開発に興味のある方は是非遊びに来てください。

私もスピーカーとして登壇します。当日お会いしましたらよろしくおねがいします。

TOKYO - try! Swift Conference


Nginxへの変更に伴うリバースプロキシのテストの改善

$
0
0

Nginxへの変更に伴うリバースプロキシのテストの改善

SREグループの菅原です。 クックパッドではブラウザ用Webサイトのリバースプロキシ用のWebサーバとして長らくApacheを使っていたのですが、最近、Nginxへと変更しました。

Nginxへの変更に当たって、構成管理の変更やテストの改善を行ったので、それらについて書きたいと思います。

リバースプロキシのリニューアルについて

まず、ブラウザ用Webサイトの基本的なサーバ構成は以下のようになります。

f:id:winebarrel:20180227123255p:plain

リバースプロキシはELB経由でリクエストを受けて、静的ファイルの配信やキャッシュサーバ・Appサーバへの振り分けを行います。

リバースプロキシとして利用されているApacheは、長年の改修により設定が煩雑なものとなっており、設定の追加や変更にコストがかかる状態になっていました。

また、Apacheの設定ファイルはItamaeでは管理されておらず、ItamaeのレシピがあるGitリポジトリとは別に、Apacheの設定ファイルだけを格納したGitリポジトリで管理され、Capistranoで設定を配布する方式になっていました。これは当初、サーバ全体の構成管理(当時はPuppet)の適用タイミングと、プロキシサーバの設定の変更タイミングが異なると考えてのことだったのですが、現状では単に管理が複雑になるだけでメリットがない状態になっていました。

この状況を踏まえ、リバースプロキシのOSなどのリニューアルをするタイミングで、より平易に設定を書くことができるNginxへ変更し、また、Nginxの設定ファイルについてはItamaeの管理下に置くことにしました。

既存のリバースプロキシのテストについて

以前の記事でも取り上げられたのですが、リバースプロキシの設定はInfratasterでテストが行われています。

InfratasterのテストにはDocker Composeを使用しており、以下のようなコンテナの構成になっていました。

f:id:winebarrel:20180227123314p:plain

前述の通り、Apacheの設定はItamaeとは別のGitリポジトリで管理されており、cookpad.comを含む主要なサービスの設定が同じリポジトリに含まれています。

[リバースプロキシリポジトリ]
├── cookpad/
│   ├── conf/
│   │   └── httpd.conf
│   └── conf.d/
│       └── xxx.conf
├── other_service_a/
│   ├── conf/
│   │   └── httpd.conf
│   └── conf.d/
│       └── xxx.conf
├── other_service_b/
│   ├── conf/
│   │   └── httpd.conf
│   └── conf.d/
│       └── xxx.conf
└── spec/
    ├── cookpad_spec.rb
    ├── other_service_a_spec.rb
    ├── other_service_b_spec.rb
    ├── docker/
    │   ├── apache/
    │   │   └── Dockerfile
    │   ├── backend
    │   │   └── Dockerfile
    │   └── taster/
    │       └── Dockerfile
    └── docker-compose.yml

テスト用のdocker-compose.ymlでは、cookpad.comを含むサービス毎のコンテナを、設定ファイルのディレクトリをマウントする形で起動し、InfratasterからRSpecを実行するようになっています。

リバースプロキシからダミーバックエンドにアクセスする場合は、Docker Composeのlink_local_ips設定を使ってダミーバックエンドのコンテナにIPアドレスを割り当て、リバースプロキシの/etc/hostsを書き換えることで、リバースプロキシからバックエンドへの問い合わせを、ダミーバックエンドに差し替えるようにしていました。*1

# docker-compose.ymlbackend:networks:bridge:link_local_ips:- 169.254.100.100
        - 169.254.100.101
        - 169.254.100.102
        ...

 

# /etc/hosts
169.254.100.100 app-server-001
169.254.100.101 cache-server-001
169.254.100.102 ad-server-001
...

コンテナへのItamaeの適用

Itamae管理下に置かれたNginxの設定ファイルをテストするには、既存の方式のようにディレクトリをマウントするのではなく、コンテナに対してItamaeを適用する必要があります。

ItamaeレシピのGitリポジトリは、だいたい以下のようなファイル構成になっています。

[Itamaeリポジトリ]
├── ...
└── itamae/
    ├── function.rb
    ├── cookbooks/
    │   └── nginx/
    │       ├── default.rb
    │       ├── files/
    │       └── templates/
    └── roles/
        └── apne1_vpc-xxx/
            └── rproxy/
                ├── default.rb
                ├── td-agent.rb
                ├── files/
                └── templates/

これをCapistranoを使って、適用対象のサーバ上でitamae local function.rb roles/apne1_vpc-xxx/default.rbというようなコマンドを実行して、レシピを適用します。

function.rbはItamaeのヘルパーが定義されており

moduleRecipeHelperdefinclude_role(name)
    include_role_or_cookbook(name, "role")
  enddefinclude_cookbook(name)
    include_role_or_cookbook(name, "cookbook")
  enddefinclude_role_or_cookbook(name, type)
    Pathname.new(File.dirname(@recipe.path)).ascend do |dir|
      names = name.split("::")
      names << "default"if names.length == 1if type == "cookbook"
        recipe_file = dir.join("cookbooks", *names)
      else
        recipe_file = dir.join(*names)
      endif recipe_file.exist?
        include_recipe(recipe_file.to_s)
        returnendendraise"#{name} is not found."endendItamae::Recipe::EvalContext.send(:include, RecipeHelper)

Itamaeのレシピ内で

include_cookbook 'nginx'
include_recipe 'rproxy::td-agent'

と書くことにより、roles/cookbooks/配下のレシピを読み込めるようにしていました。

これらのItamaeのレシピをコンテナに適用する場合、以下の問題点があります。

  1. Nodeオブジェクトに含まれるサーバのメタ情報(例: node[:ec2])が、コンテナ適用時には含まれない
  2. Nginx以外の不要なレシピ(例: zabbix-agentなど)が適用されてしまう

1の問題については、Nodeクラスを書き換えることによって回避しました。

以下のコードをItamae適用時に読み込ませることで、レシピからEC2のメタ情報などを参照する場合に、未定義のときはnilを返すのではなく、ダミー値(key)が返るようにして、レシピの適用が失敗しないようにしました。

moduleFakeNodemoduleValuedef[](key)
      key.to_s
    endalias:fetch:[]enddef[](key)
    value = superif value.nil? && !self.mash.has_key?(key)
      case key
      when:http_proxynilwhen:rspectrueelse
        key.to_s.tap do |v|
          v.extend(Value)
        endendelse
      value
    endendendItamae::Node.prepend FakeNode

また、2の問題については、SKIP_RECIPESという環境変数を定義して、そこに含まれるレシピはItamaeでは適用しないようにヘルパーを修正しました。

moduleRecipeHelperSKIP_RECIPES = ENV.fetch('SKIP_RECIPES', '').split(',')

  definclude_role_or_cookbook(name, type)
    returnifSKIP_RECIPES.include?(name)

上記の修正などにより、既存のItamaeレシピに大きな修正をすることなく、サーバ同様にコンテナにもItamaeを適用できるようになりました。

DockerfileでのItamaeを適用する箇所は以下のようなコードになります。

ENV SKIP_RECIPES haproxy,td-agent,zabbix-agent

RUN cd /infra2 && \
    itamae local \
      spec/itamae/fake_node.rb \
      itamae/functions.rb \
      itamae/roles/apne_vpc-xxx/rproxy/default.rb

テストの改善

既存のリバースプロキシのテストをItamaeリポジトリに移行するに当たって、以下の点を改善するようにしました。

  1. 複数のサービスを同じdocker-compose.ymlで定義するのをやめて、環境構築の時間を短縮し、テストの相互依存をなくす
  2. link_local_ips/etc/hostsを使った経路の差し替えをやめ、エンドポイントのホスト名そのままでダミーサーバにアクセスできるようにする
  3. 実際のサーバ構成を再現するようにコンテナを構成して、SSL TerminationやIPの偽装などの処理をリバースプロキシのコンテナに持ち込まない

最終的にテストまわりのコンテナ構成・ファイル構成は以下のようになりました。

f:id:winebarrel:20180227123328p:plain

[itamae/spec]
├── backend/
│   ├── Dockerfile│   └── files/
│       ├── backend.rb
│       └── init.sh
├── internal_service_proxy/
│   ├── Dockerfile│   └── files/etc/nginx/conf.d/default.conf.tmpl
├── itamae/
│   └── fake_node.rb
├── rproxy/
│   ├── Dockerfile│   ├── docker-compose.yml
│   ├── files/
│   │   ├── etc/haproxy/haproxy.cfg
│   │   └── init.sh
│   └── spec/
│       ├── cookpad_spec.rb
│       └── spec_helper.rb
├── ssl/
│   ├── cookpad.com.crt
│   ├── cookpad.com.key
│   └── root-ca.crt
└── taster/
    └── Dockerfile

またdocker-compose.ymlは以下のようになりました。

version:'3'services:backend:build:context: ../backend
    networks:- spec-network
  internal-service:environment:SERVERS: cache-server-001:backend
    build:context: ../internal_service_proxy
    depends_on:- backend
    networks:spec-network:aliases:- cache-server-001
  rproxy:build:context: ../..
      dockerfile: spec/rproxy/Dockerfile
    depends_on:- backend
      - internal-service
    networks:- spec-network
  elb:environment:SERVERS: cookpad.com:rproxy
    build:context: ../ssl_termination_proxy
    volumes:- ../ssl:/ssl
    depends_on:- rproxy
    networks:spec-network:aliases:- cookpad.com
  taster:build:context: ../..
      dockerfile: spec/taster/Dockerfile
    volumes:- ./spec:/spec
    working_dir: /spec
    depends_on:- elb
    networks:- spec-network
networks:spec-network:

elbコンテナ(ssl_termination_proxy/

elbコンテナは、SSL Terminationを行うコンテナで、ELBの役割を担うコンテナです。 Docker Composeのaliasesを利用して、実際と同様のホスト名でコンテナにアクセスできるようにしています。

コンテナで動くNginxの設定には、以下のようにテンプレートを用意して*2

{{ range $server_name, $backend := var "SERVERS" | split "," | splitkv ":" }}
server {
  listen 80;
  listen 443 ssl;
  server_name {{ $server_name }};
  ssl_certificate /ssl/{{ $server_name }}.crt;
  ssl_certificate_key /ssl/{{ $server_name }}.key;

  underscores_in_headers on;

  location / {
    # set external network ip address
    set $custom_x_forwarded_for "93.184.216.34";

    if ($http_x_test_client_ip != "") {
      set $custom_x_forwarded_for $http_x_test_client_ip;
    }

    proxy_set_header X-Forwarded-Host $host:$server_port;
    proxy_set_header X-Forwarded-Server $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-For $custom_x_forwarded_for;
    proxy_set_header Host $host;
    proxy_pass http://{{ $backend }};
  }
}
{{ end }}

環境変数SERVERSの値(ホスト名:バックエンド,...)で各サービスのserverproxy_passを定義し、また、ホスト名毎に、あらかじめ用意しておいたSSL証明書を読み込むようにして、httpsでもアクセスできるようにしています。 (ルート証明書はtasterコンテナに含めています)

また、クライアントからのリクエストにX-Test-Client-IPというヘッダをつけることで、任意のIPからリバースプロキシにリクエストが投げられたように見せかけるようにもしています。

この機能のため、ngx_http_realip_moduleを読み込む箇所のItamaeレシピでは、テスト用コンテナへの適用時にset_real_ip_fromのレンジを広げるようにしています。

real_ip_header X-Forwarded-For;
set_real_ip_from xx.xx.xx.xx/xx;
<%- if node[:rspec] -%>
set_real_ip_from 0.0.0.0/0;
<%- end -%>
real_ip_recursive on;

rproxyコンテナ(rproxy/

rproxyコンテナは、テスト対象となるリバースプロキシのコンテナです。前述のItamaeレシピの適用を行ったNginxサーバが動作します。 cookpad.com用のdocker-compose.ymlはこのディレクトリに置いて、cookpad.com関連以外のコンテナの定義が含まれないようにしました。

rproxyコンテナのNginxの設定はItamaeレシピで適用されるため、実際のサーバと同じですが、HAProxyの設定だけはItamaeレシピのものを使わずに、テスト専用に用意しています。

listen www
  bind :8080
  mode http
  balance roundrobin
  http-request set-header X-Via-Haproxy 'localhost:www:8080'
  server app-server-001 backend:80 check inter 5s fall 3

この設定により、localhost:8080へのアクセスはbackendコンテナに投げられるようになります。

internal-serviceコンテナ(internal_service_proxy

internal-serviceコンテナは、HAProxyを経由しないキャッシュサーバへのリクエストを受け付けるコンテナです。基本的にはelbコンテナと同じような構成で、SSL Terminationの機能が除かれています。

Nginxの設定は以下のようなテンプレートで、elbコンテナと同様に環境変数 SERVERSserverproxy_passを定義しています。

{{ range $server_name, $backend := var "SERVERS" | split "," | splitkv ":" }}
server {
  listen 80;
  server_name {{ $server_name }};

  location / {
    set $custom_x_forwarded_for $proxy_add_x_forwarded_for;

    if ($http_x_test_client_ip) {
      set $custom_x_forwarded_for $http_x_test_client_ip;
    }

    proxy_set_header X-Dest {{ $server_name }};
    proxy_pass http://{{ $backend }};
  }
}
{{ end }}

また、aliases設定を使って、プロダクションサーバのホスト名そのままで、internal-serviceコンテナにアクセスできるようにしています

backendコンテナ(backend/

backendコンテナは、各種サーバを偽装するダミーサーバで、Webrickで書いています。

#!/usr/bin/env rubyrequire'webrick'require'json'require'mime/types'URI_ATTRS = %w(  scheme  userinfo  host  port  registry  path  opaque  query  fragment)

server = WEBrick::HTTPServer.new(Port: 80)
trap(:INT){ server.shutdown }

server.mount_proc('/') do |req, res|
  uri = req.request_uri

  res.body = JSON.pretty_generate({
    request_method: req.request_method,
    uri: URI_ATTRS.map {|k| [k, uri.send(k)] }.to_h,
    header: req.header,
    body: req.body,
  })

  res.keep_alive = false

  mime_type = MIME::Types.type_for(uri.path).first
  res.content_type = mime_type.to_s if mime_type

  x_test_set_cookie = req['x-test-set-cookie']
  res['set-cookie'] = x_test_set_cookie if x_test_set_cookie

  x_test_status = req['x-test-status']
  res.status = x_test_status.to_i if x_test_status
end

server.start

基本的にリクエストの情報をJSONで返すだけのサーバですが、X-Test-...というリクエストヘッダが来た場合に、任意のCookieやステータスコードを返せるようにして、異常系などのテストパターンに対応しています。

tasterコンテナ(taster/

tasterコンテナは、Infratasterを実行するコンテナです。 Infratasterと、rproxy/配下のspecを含むようにしています。

RSpec

リバースプロキシをテストするためのspecファイルは以下のようになります。

# spec_helper.rbrequire'infrataster/rspec'%w(  cookpad.com  xxx.cookpad.com).each do |server|
  Infrataster::Server.define(server, server)

  RSpec.shared_examples "#{server} normal response"do
    it 'returns 200'do
      expect(response.status).to eq(200)
    end

    it "accesses #{server}"do
      expect(request_uri.fetch('host')).to eq(server)
    endendend

 

# cookpad_spec.rb
describe server('cookpad.com') do
  let(:body_as_json) { JSON.parse(response.body) }
  let(:request_uri) { body_as_json.fetch('uri') }

  describe 'normal'do
    describe http('https://cookpad.com') do
      it_behaves_like 'cookpad.com normal response'
      it_behaves_like 'https'

      it "doesn't cache"do
        expect(response.headers).not_to have_key('cache-control')
      endendend

  describe 'error pages'do
    describe http('https://cookpad.com/error', headers: {'X-Test-Status' => 500}) do
      it "return front 500 page"do
        expect(response.status).to eq(500)
        expect(response.body.strip).to eq("fw_errors/500.html")
      endendendend

aliases設定を使ったことで、コンテナのIPなどを意識することなくテストを記述することができます。

RSpecはdocker-composeをつかって以下のように実行します。

docker-compose run taster rspec -I. -r spec_helper .

まとめ

今回の作業により、煩雑だった設定ファイルの見通しがよくなり、設定の追加などが大分楽になりました。 また、テストまわりの改善をしたことで、テスト環境の構築に時間がかかったり、原因不明でテストがコケるようなことがなくなり、テストに付随するyak shavingも減らせました。 以前の「設定を変更する→テストがめんどくさい→テストをサボる/設定の追加を諦める」というような負のスパイラルをうまく断ち切れた気がします。

ちょっと実行したテストの結果がFFFFFFFFFFFFFFF...になっていると、精神に大変なダメージを受けるので、未来の自分のメンタルヘルスを保つために、今後も改善を続けていきたいところです。

*1:link_local_ipsはCompose file version 3でサポートされなくなりました

*2:テンプレートを使うためにEntrykitを利用しています

Cookpad Tech Kitchen #14 〜海外で働く〜 開催報告

$
0
0

Cookpad UKに出向中の西山(@yuseinishiyama)です。

去る2月16日、弊社で定期開催しているCookpad Tech Kitchenの一環として、海外事業をテーマとしたイベント『海外で働く』を開催しました。私も一時帰国して登壇しましたので、その内容をここで紹介させてください。

f:id:yuseinishiyama:20180301010500j:plain

f:id:yuseinishiyama:20180301010811j:plain

Introduction

まず最初に、Engineering ManagerのLeonard Chin(@l15n)から海外事業全体の概要説明がありました。

  • そもそもなぜ海外でやるのか
  • なにを目標としているのか
  • 日本のCookpadとはどういう関係性なのか
  • どういう組織体制なのか

などをカバーする内容で、詳細を以降の登壇者が埋めていきます。

Working at Cookpad UK

次に、私のほうからCookpad UKで働くことをテーマとした発表をしました。

海外オフィスがどこにあるか、そこでどんなメンバーが働いているのか、どれくらいの英語力が求められるのか、という点について触れています。

Workflow and development in globally distributed mobile teams

Data AnalysisチームのPaweł Rusin(@RusinPaw)からは海外事業部のワークフローについての説明がありました。

クックパッドの海外事業には様々なメンバーが複数のタイムゾーンからコミットしています。チームの多様性は、サービスの国際化という観点では非常に大きなメリットがありますが、一方で多くのコミュニケーションの問題を引き起こします。この発表では、我々がどのようなマインドセット、ルール、ツールを用いてこうした問題に対処しているかが言及されています。

20言語以上に対応している検索システムが楽しくない訳がない

次に、同じく一時帰国した滝口(@rejasupotaro)から、検索システムの話がありました。

様々な具体例を用いて、複数の言語をサポートし、かつ、個々の地域に合わせた細かなチューニングを行うことの難しさ(と楽しさ)が説明されています。

Architecting for Experiments at Cookpad Global

次に、iOSエンジニアのChristopher Trott(@twocentstudio)からプロトタイピングについての話がありました。

クックパッドの海外事業はプロダクトとしてはまだまだ初期のフェーズですが、一方で既に多くのユーザーが世界中にいて、日本で安定した収益源があるという点では、単なるスタートアップとは異なります。こうしたユニークな状況下で、既存のユーザーに悪影響を与えずに、新しい機能を試すためには多くの課題があります。

おわりに

クックパッドが海外事業をやっていることをなんとなく耳に挟んだことがあっても、その詳細についてご存知の方はほとんどいらっしゃらないのではないでしょうか。このイベントを通じて、皆さんにクックパッドの海外事業に興味を持っていただき、そこでどんなことが行われているのかについて、より具体的なイメージを持っていただけたなら幸いです。

イベントに参加していただいて、もしくは、この記事をご覧になってUKオフィスで働くことに興味を持ってくださった方は、ぜひ以下のリンクからご応募ください。

info.cookpad.com

クックパッドでは今後も様々なテーマで継続的にイベントを開催していく予定です。開催予定のイベントの詳細は以下のリンクからチェックできます。ご興味をお持ちいただけましたら、お気軽にお越しください。

クックパッド株式会社 - connpass

最後に最近ドローンで撮影したUKオフィスでの昼食時の写真を掲載して、この記事を締めたいと思います。

f:id:yuseinishiyama:20180305182218p:plain

Swift.Decodable + Int64 / iOS 10 = 要注意

$
0
0

モバイル基盤グループのヴァンサン(@vincentisambart)です。

Swift 4 で JSON を読み込むための仕組みとして Swift.Decodableが追加されました。

iOS クックパッドアプリでは、 Swift での JSON の読込は以前 Himotokiが使われていましたが、新規コードでは Swift.Decodableが使われています。依存関係を減らすために、 Himotoki を使っているコードが少しずつ Swift.Decodableに移行されています。

ただし、この間、ユーザーの報告で分かったのですが、最近 Himotoki から Swift.Decodableに移行したコード辺りに一部のユーザーにエラーが出ています。 iOS 10 に限りますが。

調査

調べてみた結果、以下のコードでエラーを再現できました。

structMyDecodable:Decodable {
    varid:Int64
}

letstr="{\"id\":1000000000000000070}"letdata= str.data(using: .utf8)!do {
    letdecodable= try JSONDecoder().decode(MyDecodable.self, from:data)
    print("id: \(decodable.id)")
} catch {
    print("error: \(error)")
}

iOS 10 で実行してみると Parsed JSON number <1000000000000000070> does not fit in Int64.というエラーが出ます。 1000000000000000080でも起きますが、 1000000000000000071では起きません。

このエラーって何だろう… Swift がオープンソースなので、コードに grep してみましょう。これっぽい。エラーが発生する条件をもう少し見てみましょう。

guardletnumber= value as? NSNumber, number !== kCFBooleanTrue, number !== kCFBooleanFalse else {
    throw DecodingError._typeMismatch(at:self.codingPath, expectation:type, reality:value)
}

letint64= number.int64Value
guard NSNumber(value:int64) == number else {
    throw DecodingError.dataCorrupted(DecodingError.Context(codingPath:self.codingPath, debugDescription:"Parsed JSON number <\(number)> does not fit in \(type)."))
}

numberNSNumberなのに NSNumber(value: number.int64Value) == numberを満たさない!?

JSON の解読は実は JSONDecoderが Foundation の JSONSerializationを使っているので、 JSONSerializationを直接使ってみましょう。

letstr="{\"id\":1000000000000000070}"letdata= str.data(using: .utf8)!letjsonObject= try! JSONSerialization.jsonObject(with:data) as! [NSString:Any]
letnumber= jsonObject["id"] as! NSNumber
print("number: \(number)")
print("type: \(type(of: number))")
print("comparison: \(NSNumber(value: number.int64Value) == number)")

iOS 10 で実行してみた結果は以下の通りです。

number: 1000000000000000070
type: NSDecimalNumber
comparison: false

iOS 11 では以下のように表示されます。

number: 1000000000000000070
type: __NSCFNumber
comparison: true

結果がかなり違いますね。 iOS 10 でもっと小さい数字を使ってみると、 iOS 11 と同じ結果になります。

number: 10000070
type: __NSCFNumber
comparison: true

__NSCFNumberというクラス名は不思議に見えるかもしれませんが、一番見かける NSNumberのサブクラスです。 type(of: NSNumber(value: 1))__NSCFNumberです。

iOS 11 で JSONSerializationが数字に使っているクラスの条件が変わったようですね。実際 iOS 11 でも、 64-bit に入りきらない大きい数字だと NSDecimalNumberになります。

解決方法

では、原因があの NSDecimalNumberにあるのは分かりましたが、問題はどう解決すればいいのでしょうか。

iOS 10 の JSONSerializationは流石に直せません。

NSDecimalNumberと遊んでみると挙動が分かりにくいところがありますが、上記の大きい数字でも NSDecimalNumber(value: int64) == numberが満たされるので、 Swift 本体は条件を以下のにすれば直りそうです。

letint64= number.int64Value
letrecreatedNumber= number is NSDecimalNumber ? NSDecimalNumber(value:int64) :NSNumber(value:int64)
guard recreatedNumber == number else {
    throw DecodingError.dataCorrupted(DecodingError.Context(codingPath:self.codingPath, debugDescription:"Parsed JSON number <\(number)> does not fit in \(type)."))
}

iOS クックパッドアプリはどうしたかと言いますと、Swift.Decodableを使っていて、サーバーからとても大きい ID が来そうな箇所だけを Himotoki に戻すことにしました。 iOS 10 対応をやめたら、再度 Swift.Decodableに戻す予定です。

まとめ

iOS 10 にまだ対応しているアプリは Swift.Decodableを準拠している classstruct内に Int64を使っている場合、要注意です。一部のとても大きい数字では読込中にエラーが起きる可能性があります。その場合、すぐできる対応は対象の classstructSwift.Decodableを使うのをやめる必要あるかもしれません。

バグを報告したので、修正が行われたら追記します。

firebase.yebisu #2 の開催報告

$
0
0

こんにちは。事業開発部で新規事業に取り組んでいる高田です。

Cookpad の新規事業と Firebaseでもご紹介したとおりクックパッドでは Firebase を活用しはじめています。そのような流れもあり2018年2月20日に Firebase.yebisu #2を開催しましたのでご報告いたします。

クックパッドからは3名が発表し、LT枠として3名社外のかたに発表していただきました。

発表

Firebase Cloud Messaging 入門編 by 三浦

Komerco 事業部の三浦から Firebase Cloud Messaging (以下 FCM) についての発表です。

通知対象を柔軟に指定できる Topic 機能などについてデモを通しての説明がありました。また FCM を使用する際に毎回実装する処理をまとめたライブラリ Tsuchiの紹介がありました。

speakerdeck.com

料理ショートライブアプリ Cookin'の開発 by 森川

投稿開発部の森川からは新規事業で Firebase を検討し採用するまでの経緯や、どのように Firebase を利用してサービス開発をしたかの話などがありました。

新しく Firebase を検討している人には参考になる話だったのではないかと思います。

speakerdeck.com

実践 Cloud Functions for Firebase by 星川

Komerco 事業部の星川からは在庫管理や決済処理で Cloud Functions を利用して得た知見を元に実践的な話がありました。トリガーイベントの多重起動対策やデプロイの話など参考になる話があったのではないかと思います。

speakerdeck.com

発表のなかで紹介のあったライブラリは次の通りです。

Firestore rules tips by 岸本

Komerco 事業部の岸本は当日インフルエンザにより発表できなかったため、発表予定内容を以下で共有させていただきました。 qiita.com

LT枠

LT枠では3人のかたに発表していただきました。

Firebase関連をCIでデプロイするときのTips by yamacraft さん

speakerdeck.com

スマートなcronを考案した by Yatima さん

speakerdeck.com

Firebase Auth with GAE & Cloud Endpoints by take_e10 さん

www.slideshare.net

まとめ

いずれの発表も Firebase 利用者の現場の知見があり参考になったという声を多くお聞きしました。

クックパッドでは引き続きエンジニアを募集していますので、Firebase を利用した開発や新規事業開発に興味あるかたは採用ページを是非ご覧ください。ご連絡をお待ちしております。

Google Play アプリ内定期購入を実装する

$
0
0

技術部モバイル基盤グループの宇津(@)です。

今年3月、クックパッドのAndroidアプリはこれまでサポートしていたクレジットカード、キャリア決済に加えて、Google Playアプリ内定期購入によるプレミアムサービス登録機能を追加しました。

f:id:himeatball:20180313181401p:plain

Google Playのアプリ内定期購入機能は個人的に気に入っているので早速乗り換えました。

良い機会なので、この記事ではIn-app Billing version 3(以下IABv3)以降のアプリ内課金の実装を振り返りつつ、Google Play Billing Libraryの紹介も交えながら開発TIPSを紹介します。

TIPS1. Google Play Billing Libraryの採用

IABv3までのアプリ内課金を実施するためには(ざっくりですが)以下のフローを実装する必要がありました。

  1. ServiceConnectionを生成し、 IInAppBillingServiceにバインドする
  2. 購入可能な商品情報を IInAppBillingServiceから取得する
  3. 購入する商品のパラメータを含む Bundle (以下 BuyIntentBundle )を IInAppBillingService経由で生成する
  4. BuyIntentBundleに含まれる PendingIntentを取得する
  5. PendingIntentを用いて Activity#startIntentSenderForResultを呼び出し、Google Play決済画面を立ち上げる
  6. Google Play決済画面上での精算結果は Activity#onActivityResultにて返却される
  7. 返却された精算結果を元に(レシート検証等も実施しつつ)よしなに処理する

より詳細なフローは公式ドキュメントを参照ください。これに加えて、IabHelper等のサンプル実装クラスが提供されておりこれらを元に各位アプリ内課金を実装していたと思いますが、その内容もそこそこにAndroid開発経験がないと難しい内容となっており、ただでさえ難易度の高い決済周りの実装にさらに多くのドメイン知識を求められるものとなっていました。

さらに、素直にこれを実装した場合、処理フロー5,6 がある以上、既存の画面表示処理にアプリ内課金処理実装が混ざってしまう事になってしまいます。 決済が絡んでくる箇所においてはメンテナンス容易性の側面から避けたい所です。 その対策として、購入用のActivityを1つ用意し、そちらに処理フロー5, 6を移譲する、といった工夫がされてきたと思います。

2017年9月、GoogleからGoogle Play Billing Libraryがリリースされました。

このライブラリは前述の購入処理の分離がなされており、比較的アプリ内課金処理の分離がしやすい状況になりました。 私自身、 IInAppBillingServiceを直接取り扱う事から卒業したい思いがあり、こちらのライブラリを利用する事にしました。

ライブラリ自体にこれといった問題もなく使えているのですが、いくつか懸念点がありました。そちらについてはTIPS2, 3にて紹介します。

TIPS2. Developer Payload非推奨に対する対処

Developer PayloadとはIABv3時代からある、「購入レシートの中に含める事のできる文字列フィールド」を指します。

多くの開発者はこのフィールドに、サーバ上で発行した購入トランザクションに対して一意な識別子を入れる事で、Google Playアプリ内課金に対する問い合わせ対応等に役立てていたかと思います。

しかし、このフィールドは現在非推奨となっており、Google Play Billing LibraryにおいてもDeveloper Payloadフィールドを指定する事が出来ません。

クックパッドのAndroidアプリでは、購入時あるいは復元(ユーザ様のアプリ内定期購入の購入情報を元にプレミアムサービス登録を再開する)際に都度識別子をサーバ上で発行し、レシート検証が必要なタイミングで復元するレシートとセットで送信するようにして代替しています。

こうする事で、Developer Payloadが存在していた時代では購入時に一度だけサーバ上で発行すれば良かった識別子が、無駄に発行されてしまう事が懸念されますが、これは許容する方向に倒しています。

TIPS3. ラッパーライブラリによるドメイン知識のさらなる吸収

アプリ内定期購入に限らずですが、決済処理というのは想定以上に実装が複雑になりがちです。 クックパッドのようにプレミアムサービスという商品を1つだけ取り扱うにしても、自サービス内のアカウント種別に加え、決済手段も複数あり、それらの整合性を取りながら決済処理を実装しなければならないとなると サーバサイドはもちろんの事、アプリ単体でも中々に複雑な実装になる事が予想できます。

その為、Google Play Billing Libraryでも吸収できていない、アプリ内課金処理固有のドメイン知識をもっと吸収して、利用者側のソースコードのメンテナンス容易性を上げたい気持ちがありました。 そこで tsuruhashiという名のラッパーライブラリを(社内向けに)開発し、クックパッドのAndroidアプリではこれを使用しています。

tsuruhashiでは以下の3点をうまく吸収し、関心事を分離しています。

TIPS3-1. BillingClientをいい感じにpoolingして ServiceConnection を意識させない

BillingClientはGoogle Play Billing Libraryに含まれる、 IInAppBillingServiceとのやり取りや決済Activityの起動を行うクラスです。

BillingClientはいつ ServiceConnectionが切断されるか分からないので、利用者側でそのライフサイクルをしっかり管理する前提の実装にするのが無難です。 でも、そもそもこういった事はそもそも意識したくないですね。

tsuruhashiでは BillingClientインスタンスの生成を常に1つに抑えるようpoolingを行い、 ServiceConnectionが切断されたら自動的にpoolから破棄するような機構を設けています。

ついでではありますが、 BillingClientは 内部的に Handlerインスタンスを生成する為にメインスレッドでの生成が必須ですが、これも意識したくないのでライブラリ側で吸収しています。

TIPS3-2. BillingClient の全 interface を Rx friendly にして記述しやすく

クックパッドのAndroidアプリではRxJavaが導入されているので、アプリ内定期購入においてもRxなinterfaceで実装したい需要がありました。 BillingClientそのままの状態でもRx化は可能ですが、1つのメソッドだけ実現方法に悩むかもしれません。

それは BillingClient#launchBillingFlowです。

このメソッドは返却値としては決済Activityの起動等に成功したか否かが返却され、実際の購入結果は BillingClientの生成時に引数として渡した PurchasesUpdatedListenerに渡されます。 なので、メソッドの呼び出し元と購入結果の受け取り口が離れてしまいます。

tsuruhashiでは以下のように対応しました。まず、 PurchasesUpdatedListenerを実装します。

class CompositePurchasesUpdatedListener : PurchasesUpdatedListener {
    privateval listeners: MutableList<PurchasesUpdatedListener> = mutableListOf()
    privateval lock:ReentrantReadWriteLock = ReentrantReadWriteLock()

    fun add(listener: PurchasesUpdatedListener): () ->Unit {
        lock.write {
            if (!listeners.contains(listener)) {
                listeners.add(listener)
            } }
        return { lock.write { listeners.remove(listener) } }
    }

    fun remove(listener: PurchasesUpdatedListener): Boolean = lock.write { listeners.remove(listener) }

    fun clear() = lock.write { listeners.clear() }

    overridefun onPurchasesUpdated(responseCode: Int, purchases: MutableList<Purchase>?) {
        val list = lock.read { listeners.toList() }
        list.forEach { it.onPurchasesUpdated(responseCode, purchases) }
    }
}

これを BillingClient生成時に listener として登録します。

val listener = CompositePurchasesUpdatedListener()
val client = BillingClient
        .newBuilder(context)
        .setListener(listener)
        .build()
val wrapper = BillingClientWrapperImpl(client, listener)

wrapper内での BillingClient#launchBillingflowの呼び出しは以下のように行います。(説明の為、一部省略しています)

var removeListenerFunc:(() ->Unit)? = null
removeListenerFunc = compositeListener.add(PurchasesUpdatedListener { responseCode, purchases ->if (/* 長いので省略 `PurchaseUpdatedListener#onPurchasesUpdated` がこのブロック内で捌ける時にlistenerを破棄 */) {
        removeListenerFunc?.invoke()
    }

    when (responseCode) {
        BillingResponse.OK -> {
            if (purchases.any { it.sku == params.sku }) {
                /* 省略 */
            }
        }
        /* 省略 */
    }
})

val launchResponseCode = client.launchBillingFlow(activity, params)
if (launchResponseCode != BillingResponse.OK) {

    // BillingClient#launchBillingFlowに失敗したらその時点でlistenerを破棄
    removeListenerFunc?.invoke()
    when (launchResponseCode) {
       /* 省略 */
    }
}

Rxな表現でいえばSubject(hot-observable)を内包する形ですね。 このような呼び出し方にする事で、非Rxの世界ではcallback interfaceで、Rxの世界においてもRx friendlyなinterfaceで購入結果を取り扱えるようになっています。

fun launchBillingFlow(activity: Activity, params: BillingFlowParams, listener:LaunchBillingFlowListener) // 非Rxfun launchBillingFlow(activity: Activity, params: BillingFlowParams): Single<BillingResult>              // Rx

TIPS3-3. 汎用的に必要となるinterfaceを追加して利用者側のコードの理解可読性の向上を図る

アプリ内定期購入の実装においては多くの場合、以下の2つのFeatureTypeに対応しているかを確認します。

  • FeatureType.SUBSCRIPTIONS
  • FeatureType.SUBSCRIPTIONS_UPDATE

そうなった際に FeatureTypeをまとめて確認できないと流石に利用者側のコードが冗長になってしまうので、複数の FeatureTypeをまとめて確認できるようなinterfaceを追加しています。

fun verifyFeaturesSupported(features: List<String>, listener: VerifyFeaturesSupportedResponseListener) // 非Rx fun verifyFeaturesSupported(features: List<String>): Completable                                       // Rx

上記のような、汎用的に必要となる機能についてはライブラリ機能として提供し、利用者側のソースコードの冗長化を抑止しています。

TIPS4. レシート情報は多少過剰にでもサーバサイドで保存する

決済系の格言として「まずは保存」というのがあります。

サーバサイド寄りの話になりますが、アプリからレシートを送信するようなエンドポイントを用意する場合、まず第一にアプリから送信されたレシートをDBに保存し、その後レシート検証や定期購入の開始処理といったエンドポイントの本来求められている処理を実施するようにしています。

これは、レシート検証や定期購入の開始処理に万が一不具合がありレシートがDB上に保存されず、その状態でユーザ様から問い合わせがあった場合、DB上にはレシートが存在しないため、どうしても煩わしいやり取りが発生してしまい、ユーザ様に悪い印象を与えてしまう恐れがある為です。

レシートの保存さえ出来てしまえば、Google Play Developer APIを利用する事で、ユーザ様の問い合わせに対して購入のキャンセルや払い戻し対応も可能になるので、この格言に習う事に越した事はありません。

TIPS5. 購入トランザクション上のログを細かく取る

決済系に限らず「まずはログ」という所で、TIPS4に関連して、ユーザ様が商品の購入に失敗した際、どの処理中に購入に失敗してしまったのかを追跡可能な状態にする必要があります。

クックパッドのAndroidアプリでは、よりユーザ様の購入状況が追跡できるよう、購入/復元時に実施する処理のログをサーバログとは別にアプリから送信・集計し、 問い合わせがあったユーザ様に対し適切にサポート対応が実施できるよう環境を整えています。

まとめ

クックパッドのAndroidアプリでは、Google Playアプリ内課金実装に対するドメイン知識をラッパーライブラリ上でいい感じに吸収した甲斐もあって、アプリケーション上の実装はクックパッドのプレミアムサービスのドメイン知識のみで満たされた実装に仕上がっています。[TIPS1, 3]

Developer Payloadは非推奨になってしまいましたが、それでもなんとかうまくやっています。[TIPS2]

ユーザ様に対し適切にサポート対応が実施できるよう、レシートの取扱い方とログ集計を中心に環境を整備しています。[TIPS4, 5]

今後の展望としては、まずはTIPS3で紹介した開発した社内ライブラリをオープンソース化したいという所と、まだまだ運用面を見据えて改善したい箇所が沢山あります。例えば、

  • テスト購入の為のAndroid端末上の設定の自動化
  • 購入/復元処理のE2Eな自動テスト
  • 決済処理実装に対する敷居を下げる為のあれこれ

といった事があげられます。 今後も大きな進歩があればtechlifeで報告していきます。

最後に、クックパッドではより良いサービスを提供し続ける為にエンジニアを募集しています。 もしこの記事を読んで興味を持たれたAndroidエンジニアの方、あるいは決済処理に熱い思いのあるエンジニアの方いらっしゃいましたら、是非遊びに来てください。 ご連絡をお待ちしています!

クックパッド株式会社 採用情報

巨大なWEBアプリケーションに巨大な変更を取り入れるためにやったこと

$
0
0

会員事業部ユーザー基盤チームエンジニアの井口(@iguchi1124)です。

ユーザー基盤チームでは、クックパッドのサービス開発者のあらゆる要望に答え続けられるような『柔軟でいい感じのユーザー基盤』を目指し、サービス開発者およびユーザーさんの課題と向き合いながら日々開発を進めています。

第一弾として、普段の開発の様子や一部のユーザーさんに向けてユーザー登録機能をリリースするまでの話も公開されていますので是非そちらもご覧いただければと思います。

今回は、上述の記事にも触れられているようにクックパッドでユーザーさんのアカウント登録や認証情報として電話番号を利用できるようになりましたので、そのためにやってきたことの一部をご紹介したいと思います。

一口に電話番号を利用出来るようになったと言うと簡単そうに聞こえますが実際にはそうでもありません。

クックパッドではこれまで連絡先情報あるいはアカウントの認証に必要な情報としてメールアドレスを使うという前提で長い期間に渡る開発が積み重なってきました。

その状況から、電話番号をメールアドレスと同等に連絡先情報やアカウントの認証に必要な情報として利用するには多くの技術的負債の返却や機能追加が必要になります。

また、ユーザーさんに与える影響を考えると、ユーザーさんに迷惑をかけないようにリリースする順序を考慮したり、関連するサービスでデプロイするタイミングを合わせることも必要になったりします。

このような巨大な変更を取り入れようとしている最中も、並行してクックパッドのサービス開発は継続的に行われています。様々な施策を止めるわけにはいきません。

サービスを「ユーザーさんが一通り触れる」単位で分割する

ユーザー基盤チームでは、ユーザー登録およびログインに関わるサービス内の一連の動きを垂直に分割した、社内ではShishamo(ししゃも)と呼ばれるマイクロサービスに分離することで開発を加速させています。

サービスを分割することで実際に得られた利点は次のようなものです。

  • チームの外の開発者達にコードの変更による影響を与えたり、受けたりしない
  • 自動テストを高速に実行できる
  • 新しいアーキテクチャを素早く取り入れることができる
    • Dockerを利用したナウいデプロイフローを取り入れる
    • Webpackerを利用してナウいjavascript開発環境を取り入れる
      • React.js を導入して再利用性の高いプレゼンテーショナルコンポーネントを設計してみる
    • 電話番号パーサーを導入してみる

また、サービスを水平に分割し地層を積み上げるのではなく垂直に分割することで、新しい技術要素を取り入れる場面では早い段階で技術スタックを試し、技術的に実現可能であることの裏取りができます。

巨大な開発ブランチを作らない

提供したい機能の内容によっては、それぞれの機能の依存関係から変更を同時にリリースしなければならないことがあります。

そういった場合、開発ブランチを作り水面下で作業をすすめ、変更内容が揃った段階でマージし、リリースするということになるかと思います。

しかし、それではリリースするためには負担が開発者だけに留まらず広がってしまうことが想像できます。

  • 開発者の負担
    • 他の開発者との変更の衝突
    • 他の開発者や自分の変更の影響による予想外のバグの発生
  • コードレビューにかかる負担
    • 「よさそうだけど自信がない」、「自信がないけどLGTM」の発生
  • リリース前の動作確認、リリース後の監視にかかる負担
    • テストする必要があるパターン数の肥大化
    • 動作確認の結果おかしそうな動きを直したら別の挙動がおかしくなったので再修正、再確認、再修正、再確認

ユーザー基盤チームでは、Cookieベースのfeature flagを導入することで、この問題の解消に取り組みました。

これによって、ユーザーさんには機能を提供しないサイレントリリースとスタッフによる動作確認を可能にし、最小単位での変更のマージ、デプロイとテストを繰り返すことができました。

実際に、最後のユーザーさんに届けるステップに入る頃には十分にテストされたサービスのうちの、if分岐を取り除く程度のものにできます。

非常に簡単な仕組みではありますがOSSとして公開しています。

https://github.com/iguchi1124/cookie_flag

以下に電話番号でユーザー登録する機能をリリースするために実際に運用した例を紹介します。

ログイン機能の実装の中で、電話番号によるユーザー登録機能がリリースされているときの動きを実装する場合

classSessionsController< ApplicationControllerdefcreateif feature_available?(:phone_number_registration)
      # 電話番号またはメールアドレスとパスワードを利用したユーザー認証処理else# メールアドレスとパスワードを利用したユーザー認証処理endendend

電話番号によるユーザー登録機能がリリースされると表示されるリンク

<% if feature_available?(:phone_number_registration) %>
  <%= link_to "電話番号でユーザー登録する", new_phone_number_registrations_path %>
<% end %>

「電話番号によるユーザー登録をしようとしたこと」リソースにfeature flagを適用したい場合

classPhoneNumberRegistrationsController< ApplicationController
  feature :phone_number_registrationdefnewenddefcreateendend

動作確認をするときは feature_available?(:phone_number_registration)が真になるように手動でブラウザのクッキーを設定することで一般のユーザーさんが利用する前に社内のスタッフが機能を試せるようになります。

まとめ

この記事では継続的にサービス開発が行われているクックパッドで巨大な変更を入れるためにやったことのうち、以下の2つのことを紹介しました。

  • 垂直分割によるサービス開発の効率化
  • フィーチャーフラグ導入によるリリースにかかる負担の改善

ユーザー基盤チームでは大きなサービスの基盤を再構築するにあたり、イテレティブかつインクリメンタルに価値を届けることを心がけながら周囲の開発者と協力してサービスの改善に取り組んでいます。

まだまだ失敗も多い道半ばですが、今後もユーザーさんや、となりで働く開発者、そして一緒にサービスを運営している全員にとってよいものである基盤づくりをしていけたらと思います。

TLS証明書の発行・デプロイについて

$
0
0

こんにちは、インフラストラクチャー部セキュリティグループの三戸 (@mittyorz) です。 クックパッドでは全てのサービスをHTTPSにて提供しています。 今回はHTTPSの使用にあたって必要となるTLS証明書について、申請や発行、管理やサーバへのデプロイなどの運用について書きたいと思います。

TLS終端

クックパッドでは、サービスとユーザーとの通信経路は全てTLSにより暗号化されていますが、通信内容を暗号化するためのTLS終端処理はELBあるいはCloudFrontで行っています。 ELB、CloudFrontともにAWS Certificate Manager(ACM)を用いて証明書を管理*1することが出来ますが、社内向けで外部に公開していないサービスやステージング環境についてはELB背後のリバースプロキシで終端処理をしているものも多く、これらについては証明書ファイルを直接EC2インスタンスへ配置する必要があります。

なお、クックパッドのHTTPS化については Web サービスの完全 HTTPS 化を御覧ください。

証明書の種類

TLS証明書には、ドメインの所有者について認証局が実在照会を厳格に行ったのちに発行される、Extended Validation(EV)証明書があります。 EV証明書を用いることで、ブラウザのアドレスバーにはそのドメインの所有者の情報が表示され、ユーザーにとって意図したサイトに接続しているかどうかがわかりやすくなります。 クックパッドでは、PCあるいはスマートフォン向けブラウザからユーザーが直接アクセスするページについては、原則EV証明書を設定するようにしています。

なお、EV証明書ではない証明書には、ドメインの所有者であることを確認して発行されるDomain Validation(DV)証明書と、所有者の実在照会まで行うOrganization Validation(OV)証明書が存在します。 OV証明書とEV証明書はいずれも実在照会が行われますが、CA/Browser Forumによって定められたガイドライン*2に従って発行されたものだけがEV証明書となります。

証明書の発行

新規サービスの立ち上げなどで新しいドメインを使用する場合、まずはACMを用いてDV証明書を発行し、APIエンドポイント用のドメインなどを除いて順次EV証明書を配置しています。 以前はドメインごとにEV証明書を一つ一つ購入していたためコストも無視できなかったのですが、後述するマルチドメイン証明書を用いることで年100ドルほどで追加購入できるようになりました。 また、常にEV証明書を設定するというわけでもなく、URLの変更などで使用しなくなりリダイレクトのみ行うドメインについてはEV証明書をやめてACMの証明書に切り替える、ということも行っています。

EV証明書の発行はACMでは行えないため、ACMで用いる場合別途認証局から購入しインポートする必要があります。 またACMから秘密鍵を取り出すことも出来ないため、EC2インスタンスで直接TLS終端している場合も同様に購入しています。

認証局の選定

クックパッドでは、現在はDigiCertから証明書を購入しています。 使いやすいWebコンソールが存在していることや、WebコンソールへのログインがSAMLによるシングルサインオンに対応していることが選定理由ですが、 後述するSANに対応したEV証明書の発行に対応していることやAPIが用意されていることもポイントとして挙げられるかと思います。 また、脆弱性の発生時など特に迅速な対応が必要な場合でも、認証局から直接のサポートが受けられるというのもあります。

証明書のデプロイ

Classic Load Balancer(CLB)の設定にはkelbimを、ECSと組み合わせて用いるApplication Load Balancer(ALB)の設定にはHakoを用いており、 それぞれACMに用意した証明書をARNを使って指定することが出来るようになっています。 CLBは主に社内向けのステージング環境や、Hako化がまだなされていないサービスにおいて使用されています。 最近リリースされたサービスは基本的にHakoを用いてデプロイ出来るようになっているので、以下のようなフローで証明書の設定を行っています。

  1. 証明書の発行の依頼がサービス開発チームからSREチームに来る
  2. EV証明書が必要と判断された場合は認証局へ発行を申請する
  3. ACMへ証明書をインポート、もしくはACMで証明書を発行する
  4. 証明書のARNをサービス開発チームに通知し、Hakoの定義ファイルに記載する
  5. Hakoを用いてデプロイ。ALBに証明書が設定される

Hako自体の説明はここではいたしませんが、定義ファイルでの証明書の指定の仕方はサンプルなどが参考になるでしょう*3

他、設定ファイルや証明書をサーバに直接配置する必要がある場合は、証明書や中間証明書はGitリポジトリに含めておき、itamaeを用いてデプロイしています。秘密鍵はそのままリポジトリに入れるのではなく、変数を用いてデプロイ時に展開されるようになっています。

証明書の有効期限の監視

TLS証明書には有効期限が存在します。 有効期限が切れる前に新しい証明書に更新する必要がありますが、有効期限は1年以上となっていることが多く「忘れた頃に有効期限が来る」ということが起きます。 認証局によっては、例えば30日前などにメールで通知してくれるところがありますし、ACMの場合は2017年の11月からDNSレコードによりドメインの所有者検証を行い自動更新することが出来ます*4。 EC2インスタンスで直接TLS終端している場合、どのインスタンスで証明書が使用されているのか把握しておく必要がありますが、クックパッドではZabbixを用いて監視しています。 また、一部のドメインについてはStatusCakeも併用しています。

社外のインターネット回線からアクセスした場合とオフィスからアクセスした場合とでエンドポイントが違っていて*5、設定されている証明書が異なるため監視漏れで危うく有効期限切れするところだったということもありました。 また、見落としがちなのがオフィスからのみアクセスできるサーバやアプライアンス製品で、特にワイルドカード証明書は思わぬサブドメインで使われていることもあるので、 Route 53からレコードを取得し、登録されているサブドメインも含め全てのドメインに対してチェックするということも行っています。

EV証明書発行のための実在証明

実在証明と書くとなんだか凄そうですが、手順としてはそれほど複雑ではなく、ざっくりと以下のようなことを行いました。

  1. 組織名(Organization)として商号を登録する
    • この部分がサイトにアクセスした際にアドレスバーに表示されます。
    • あわせて、本社所在地などの情報も登録します。
  2. 組織名と所在地が掲載された公的文書を提出する
  3. 担当者の在籍状況について、電話などで確認が行われる

2 について認証局が日本法人であれば登記簿謄本を提出することで証明出来たのですが、DigiCertはアメリカ合衆国の法人なため、アメリカ合衆国において発行されたものが必要となります*6。 今回はアメリカ証券取引委員会に登録された文章を見つけることが出来たため、比較的すんなりと会社の実在証明を行うことが出来ました。

一方 3 については、公的文書には代表電話番号のみ記載されていたためその番号での対応が必要となり、インフラストラクチャー部の直通番号へ入電を期待していたため何度か掛け直してもらうなど混乱もありました。 詳しい手順は前述のガイドラインにも掲載されていますが、受容可能な手順として法的に有効な文書に記載されている住所、電話番号、メールアドレスなどを用いて担当者の確認を行うこととされているため、 担当者直通など任意の電話番号に掛けてもらうにはその番号が記載された公的文書が必要となり注意が必要です。

フィーチャーフォン対応

国内のフィーチャーフォンがターゲットとなっているモバれぴ*7については特段の配慮が必要になりました。

証明書の認証パスにおいて、ルート証明書は本来その名の通り根本に存在し他のどの証明書にも依存せずに信頼される必要があるため、 OSに付属して提供されたり、ブラウザとあわせてインストールされるなど予め信頼されるようになっています。 しかし、フィーチャーフォンでは出荷後のアップデートなどで新しく証明書を追加することが出来ないことが多く、 プリインストールされているルート証明書自体も種類が少ないということがよくあります。 したがって、古いルート証明書しかサポートしていないフィーチャーフォンにおいては、証明書を切り替えてしまうと認証されずエラーとなる可能性があります。

この問題は、サポートされていないルート証明書を別のサポートされているルート証明書で署名する、クロスルート証明書という仕組みで回避することが出来ます。

DigiCertが発行しているルート証明書は多くの環境でサポートされていますが、フィーチャーフォン向けのBaltimoreのルート証明書によって更に署名されており、 この場合具体的には次のような認証パスになります。

  1. CN=Baltimore CyberTrust Root
  2. DigiCert High Assurance EV Root
  3. CN=DigiCert SHA2 Extended Validation Server CA
  4. CN=m.cookpad.com

フィーチャーフォン以外の殆どの環境では2がルート証明書、 3が中間証明書、4がサーバ証明書になりますが、このケースだと2、3が中間証明書であると言えます。 したがって、ACMに証明書を登録する場合は、以下のように登録することになります。

  • Certificate body に、4の証明書
  • Certificate private key に、4の秘密鍵
  • Certificate chainに に、3の証明書へ2の証明書を結合したもの

実際に opensslコマンドを用いて認証パスを表示すると以下のようになります。

$ openssl s_client -connect m.cookpad.com:443 -quiet
depth=3 C = IE, O = Baltimore, OU = CyberTrust, CN = Baltimore CyberTrust Root
verify return:1
depth=2 C = US, O = DigiCert Inc, OU = www.digicert.com, CN = DigiCert High Assurance EV Root CA
verify return:1
depth=1 C = US, O = DigiCert Inc, OU = www.digicert.com, CN = DigiCert SHA2 Extended Validation Server CA
verify return:1
depth=0 businessCategory = Private Organization, jurisdictionC = JP, serialNumber = 0104-01-071872, C = JP, ST = Tokyo, L = Shibuya-ku, O = COOKPAD Inc., OU = Infrastructure Division, CN = m.cookpad.com
verify return:1

マルチドメイン証明書

TLS証明書にはSubject Alternative Names(SAN)という属性をもたせることができ、Common Nameとは別の任意のドメインを追加することが出来ます。 この機能により、一つの証明書で例えば example.comexample.jpのように複数の独立したドメインに対応することが可能になります。 ただし、どんなときでもまとめてしまえば良いという訳でもなく、HTTP/2でサービスを提供している場合はコネクションの再利用に注意する必要があります。 例えば、SANに *.example.comが設定された証明書を用いて配信しているサーバがあったとして、このサーバAは a.example.comのコンテンツは配信しているものの b.example.comのコンテンツは配信しておらず、別のサーバBで配信しているとします。 クライアントが a.example.comのコンテンツを取得するためにサーバAとのコネクションを確立したあと、サーバBに存在する b.example.comの取得についてもサーバAとのコネクションを再利用してしまい、 サーバAからエラーが返されてから*8サーバBに改めてコネクションを確立するため、かえってレイテンシが増えてしまいます。 これはHTTP/1.1でよく見られた、画像やCSS、javascriptファイルを別のドメインから提供することでページ全体のレイテンシを低減している場合*9に起こりやすいと言えるでしょう。

この問題については HTTP/2 のコネクション再利用について確認してみる - ブログのしゅーくりーむに詳しく解説されています。

証明書の発行のためCSRファイルを作成する際、opensslコマンドを用いることが多いと思いますが、マルチドメイン証明書のCSRファイルについてはSANの指定が引数で指定することが出来ません。 設定ファイルのopenssl.cnfに直接記入する必要がありますが、いちいち書き換えるのも面倒なので以下のようなスクリプトで作成しています。

#!/bin/bash

subject="/C=JP/ST=Tokyo/L=Shibuya-ku/O=Cookpad Inc./OU=Infrastructure Division/"


# CN and SAN list
common_name=$1
if [ -z $common_name ]; then
      read -p 'Common name? : ' common_name
fi

sans_file=${common_name}.txt
if [ ! -f "$sans_file" ]; then
    echo $0: "$sans_file" does not exist
    exit 1
fi

# find out where the openssl.cnf is
conf=`openssl version -a | grep OPENSSLDIR | cut -d '"' -f2`
conf=$conf/openssl.cnf

# compose SAN section
sansection=$(cat <(
    echo -n "subjectAltName='DNS:"
    cat $sans_file | perl -pe "chomp if eof" | perl -pe "s/\r?\n$/,DNS:/g"
    echo "'"
))

# display CN and SANs
echo CN: $common_name
echo $sansection

# make csr/key
openssl req -new\
            -newkey rsa:2048\
            -nodes -out ${common_name}-san.csr\
            -keyout ${common_name}-san.key\
            -sha256\
            -subj "${subject}CN=$common_name" \
            -reqexts SAN \
            -config <(cat $conf \
                <(printf "\n[SAN]\n$sansection"))

作成したCSRファイルは例えば以下のようになります。

$ openssl req -text -noout -in cookpad.com-san.csr
Certificate Request:
    Data:
        Version: 0 (0x0)
        Subject: C=JP, ST=Tokyo, L=Shibuya-ku, O=Cookpad Inc., OU=Infrastructure Division, CN=cookpad.com

(中略)

        Requested Extensions:
            X509v3 Subject Alternative Name:
                DNS:info.cookpad.com, DNS:payment.cookpad.com

終わりに

クックパッドでのTLS証明書の運用について紹介しました。 HTTPS化されるインターネットサービスはどんどん増えており、証明書の発行も昔と比べてずっと容易になってきています。 一方で実際に作業してみると、コード管理されていないサーバが見つかって手作業で証明書ファイルを配置したり、認証局と電話でやり取りしたりといったこともありました。 監視対象への追加や、証明書の自動更新などまだ出来ていない部分も多く、これからも改善した点について紹介させていただきたいと思います。

*1:ACMが2016年1月にリリースされるまではIAMを使用していました。

*2:https://cabforum.org/extended-validation/

*3:サンプルでは証明書をIAMで指定していますが、ACMでも同様に指定できます

*4:https://aws.amazon.com/certificate-manager/faqs/#dns_validation

*5:特にステージング環境でよくあるケース

*6:DigiCertの場合 https://www.digicert.com/ssl-certificate-purchase-validation.htmに受容可能な文章について記載されています。

*7:https://m.cookpad.com/

*8:421 Misdirected Request

*9:いわゆるドメインシャーディング


ハッシュ値の使い方について

$
0
0

モバイル基盤グループのヴァンサン(@vincentisambart)です。

先日以下のツイートを拝見しました。

この変更はSwift 4.1にはまだ入りませんが、4.2か5.0に入るはずです。コードレビューでこの変更が問題を起こそうなコードを指摘したことあるので、ハッシュ値のおさらいをする良いタイミングではないでしょうか。

Swiftのことを考えて書いていますが、多くのプログラミング言語にも当てはまります。ハッシュ値はSwiftではhashValueというプロパティが返しますが、多くの言語では単にhashというメソッド・関数が返します。

ハッシュマップ

ハッシュ値はハッシュマップ(別名ハッシュテーブル)に一番使われるのではないでしょうか。SwiftではDictionary、RubyではHash、C++ではunordered_map、RustではHashMapと呼ばれるものです。

ハッシュマップはマップの一種であって、マップというのはキーに値を結びつけるためのものです。1つのキーに1つの値しか結びつけない場合が多いです(値は配列を使えますが)。例えば漫画の連載開始の年のマップを作ると以下のようになります。

キー
ONE PIECE 1997
DRAGON BALL 1984
青の祓魔師 2009
Levius 2012
宇宙兄弟 2007

ハッシュマップは基本的にキーに順がない場合が多いです。キーが挿入された順で列挙されると保証する実装(例えばRubyのHash)もありますが。

ハッシュマップのキーに一番使われるのは文字列ですが、以下の2つの条件を満たせば何でも使えます。

  • 2つのキーが等しいかどうか比較できる(SwiftではEquatableというプロトコルに準拠すること)
  • キーからハッシュ値を計算できる(SwiftではHashableというプロトコルに準拠すること。比較できないとハッシュ値が使い物にならないのでHashableEquatableに準拠している)

ハッシュマップは別のマップの種類に比べてどういうメリットあるかと言いますと、ハッシュ関数(ハッシュ値を計算する関数)が良ければ、キーが多くても値を早く取得できるところです。

ハッシュ値とそれを生成するハッシュ関数

ハッシュマップに使われるハッシュ値は基本的に32-bitか64-bitの整数です。ハッシュ値を元にキーと値がメモリ上どこに置かれるのか決まります。

ハッシュマップで使うには、ハッシュ値が以下の条件を満たす必要があります。

  • プログラムが終了するまで、ハッシュ関数(ハッシュ値を計算する関数)に同じキーを渡すと必ず同じハッシュ値が返るべき
  • ハッシュ値が違っていれば、ハッシュ関数に渡されたキーが異なるべき
  • 違う2つのキーが同じハッシュ値を持っても良い。可変長の文字列から計算されるハッシュ値が固定長数バイトだけに収まるので、すべてのキーが違うハッシュ値を持つはずがありません。

上記の条件を満たす一番シンプルなハッシュ関数が固定値を返すだけです。それだとハッシュマップは一応動きますが、性能がすごく落ちて、ハッシュマップを使うメリットがなくなります。

ハッシュ値を計算するハッシュ関数なんですが、良いハッシュ関数はハッシュ値の計算が速くて、色んなキーを渡すとできるだけ違うハッシュ値を返してくれた方がハッシュマップの性能が出ます。良いハッシュ関数を作るのはすごく大変なので、既存の研究されたものが使われる場合が多いです。

気を付けるべきところ

ハッシュ値が満たすべき条件に「プログラムが終了するまで、ハッシュ関数に同じキーを渡すと必ず同じ値が返る」と書きましたが、「プログラムが終了するまで」が重要です。プログラムをまた実行すると変わる可能性があります。Rubyで試してみると分かりやすいと思います。

$ ruby -e 'p "abcd".hash'
-2478909447338366169
$ ruby -e 'p "abcd".hash'
3988221876519392566
$ ruby -e 'p "abcd".hash'
-771890369285024305

今までSwiftではプログラムを何回実行しても標準のhashValueが毎回同じハッシュ値を返していましたが、この記事の頭にリンクされていた変更でプログラムが実行される度にハッシュ値が変わるようになります。

どうして変わるようになったのかと言いますと、DoS攻撃のリスクを下がるためです。DoS攻撃というのは簡単にいうとマシンがやるべき処理に追いつけなくなることです。

ハッシュマップに同じハッシュ値を持つキーをたくさん入れると、性能がどんどんと落ちていきます。ハッシュ値を事前に予測できると同じハッシュ値を持つキーを大量用意できます。サーバーがハッシュマップのキーにしそうなもの(例えばリクエストの引数名)に用意された大量のキーを使わせてサーバーがやるべき処理に追いつかなくなります。

ハッシュ値がプログラムの実行ごとに変わると、ハッシュ値の予測がかなり困難になるのでリスクを減らせます。

ハッシュマップ

でもどうして同じハッシュ値が多いとハッシュマップの性能が落ちていくのでしょうか。理解するにはハッシュマップの仕組みをもっと細かく見る必要があります。

ハッシュマップのコアな部分が単なる配列です。配列の項目がバケット(bucket)と呼ばれています。

配列のサイズ(バケット数)に満たすべき条件が特にありませんが、基本的に項目が増えるともっと大きい配列が用意されて、以前の項目を新しい配列に移し替えます(新しい配列でバケットが変わる可能性あるので要注意)。

挿入されるキーと値がどこに入るのかはハッシュ値で決まります。バケット数がハッシュ値の数ほど多いわけではないので、モジュロ(剰余演算)を使ってバケット数以下にします。

lethashValue= key.hashValue
// 負のインデックスだと困るので絶対値を取るletbucketIndex= abs(hashValue) % buckets.count
// ハッシュ値は後でまた計算できるけど、再計算を減らすために入れておく
buckets[bucketIndex] = Bucket(hashValue:hashValue, key:key, value:value)
0 1 2 3 4 5 6 7

ハッシュ値が-3272626601332557488のキー"ONE PIECE"を挿入すると、abs(hashValue) % 80なので以下のようになります。

0 1 2 3 4 5 6 7
"ONE PIECE"

1997

ハッシュ値が4799462990991072854のキー"青の祓魔師"を挿入すると、abs(hashValue) % 86なので以下のようになります。

0 1 2 3 4 5 6 7
"ONE PIECE"

1997
"青の祓魔師"

2009

ただし、それだと同じバケットに別のキーが入っていたら代入すると前のキーがなくなります。同じバケットに複数のキーが入るケースを衝突(collision)といいます。

衝突の扱いは様々あります。一番シンプルなのは連結リストや動的配列ですが、例えばキーを次に空いているバケットに入れることもあります。

ハッシュ値が4799462990991072854のキー"宇宙兄弟"を挿入すると、abs(hashValue) % 86なので以下のようになります。

0 1 2 3 4 5 6 7
"ONE PIECE"

1997
"青の祓魔師"

2009
"宇宙兄弟"

2007

シンプルなハッシュマップを実装してみると以下のようになります。

import Foundation

structSimpleDictionary<Key: Hashable, Value> {
    typealiasHashValue= Int
    structBucketElement {
        varhashValue:HashValue// キーから計算できるけど時間掛かるので残しておくvarkey:Keyvarvalue:Value
    }

    // 連結リストがよく使われるが、実装をもっと分かりやすくするため可変長の配列を使うtypealiasBucket= [BucketElement]
    // 実質配列の配列varbuckets:[Bucket]init() {
        // 分かりやすさのためバケット数を固定にしているが、普段キーが増えるとデータをもっと大きい配列に移し替える// 移し替える時、バケット数が変わってキーのバケットが変わる可能性があるのでハッシュ値を元に新しいバケットを再度計算する必要があるletbucketCount=8
        buckets = Array<Bucket>(repeating:[], count:bucketCount)
    }

    subscript(key:Key) ->Value? {
        get {
            // ハッシュ値でバケットが決まるlethashValue= key.hashValue
            letbucketIndex= abs(hashValue) % buckets.count
            for element in buckets[bucketIndex] {
                // ハッシュ値の比較が早いのでまずハッシュ値を比較しておくif element.hashValue == hashValue && element.key == key {
                    return element.value
                }
            }
            returnnil
        }
        set(newValue) {
            lethashValue= key.hashValue
            letbucketIndex= abs(hashValue) % buckets.count

            // キーが既に使わている場合、バケット内どのインデックスに入っているのかletindexInsideBucket= buckets[bucketIndex].index { element in
                element.hashValue == hashValue && element.key == key
            }

            ifletnonNilNewValue= newValue {
                letnewElement= BucketElement(
                    hashValue:hashValue,
                    key:key,
                    value:nonNilNewValue
                )
                ifletnonNilIndexInsideBucket= indexInsideBucket {
                    // キーが既に入っているので置き換え
                    buckets[bucketIndex][nonNilIndexInsideBucket] = newElement
                } else {
                    // キーがまだ入っていなかったので、挿入
                    buckets[bucketIndex].append(newElement)
                }
            } else {
                // newValueがnilなので、削除ifletnonNilIndexInsideBucket= indexInsideBucket {
                    buckets[bucketIndex].remove(at:nonNilIndexInsideBucket)
                }
            }
        }
    }
}

肝心なところは衝突の扱いです。同じバケットにキーが増えると、バケットに入っている項目のリストが少しずつ伸びます。項目が増えると読み込みも書き込みも比較が増えて処理が重くなります。バケットに項目が1つしかなかった場合、アクセスする時は行われるのはハッシュ値の計算1回と、ハッシュ値の比較1回と、キー自体の比較1回です。同じハッシュ値のキーが100個入っていると、全部同じバケットになるので、アクセスすると行われるのはハッシュ値の計算1回と、ハッシュ値の比較100回と、キー自体の比較100回です。100個目なので、前の99回分の挿入ももちろんあります。

逆にすべてのキーが別のバケットに入ると挿入する度に比較は各1回だけです。ですのでできるだけ多くのバケットが使われる方が性能が出ます。

コードレビューで気づいた間違い

この記事の冒頭でハッシュ値に関する間違いを指摘したと言いましたが、具体的にいうと大きい間違いが以下の2つでした。

  • ==の実装が hashValueを比較していただけ
structFoo:Hashable {
    staticfunc== (lhs:Foo, rhs:Foo) ->Bool {
        return lhs.hashValue == rhs.hashValue
    }
}

lhsrhsが違っても、ハッシュ値が同じの可能性があります。ハッシュ値が違っていたらlhsrhsが必ず違いますけど。

  • hashValueUserDefaultsに保存されていた
UserDefaults.standard.integer(forKey:fooHashValueKey)
(...)
UserDefaults.standard.set(foo.hashValue, forKey:fooHashValueKey)

プログラムの次の実行でハッシュ値が変わる可能性があります。元からHashableの公式ドキュメントには明確に書いてありました。

Hash values are not guaranteed to be equal across different executions of your program. Do not save hash values to use in a future execution.

実際冒頭でリンクした変更がSwiftに入ったら、プログラムの次の実行でハッシュ値が変わっていない可能性が極めて低いです。

まとめ

ハッシュ値を扱っている場合、以下の項目を覚えておきましょう。

  • ハッシュ値はプログラムの実行ごとに変わる可能性あるためディスクに保存してはいけない(別の実行でも同じ結果を返すハッシュ関数を意図的に使わない限り)
  • 2つのキーのハッシュ値が違ったら、キーが必ず違う
  • 2つのキーのハッシュ値はが同じだとしても、キーが同じだと限らない

人工知能学会のトップカンファレンス派遣レポータとして NIPS2017 に参加しました

$
0
0

研究開発部の菊田(@yohei_kikuta)です。機械学習を活用した新規サービスの研究開発(主として画像分析系)に取り組んでいます。 最近読んだ論文で面白かったものを3つ挙げろと言われたら以下を挙げます。

人工知能学会の トップカンファレンス派遣レポータという制度で NIPS2017 に参加しました。 学会への参加に加えて、その後の論文読み会や報告会での発表など様々な活動をしましたので、一連の活動を紹介したいと思います。

NIPS2017 の特徴的な写真として invited talk の一コマを貼っておきます。驚くほど人が多い...

20180327114113

また、参加して自分が面白いと思った内容(deep learning のいくつかのトピック)をまとめた資料も最初に紹介しておきます。

経緯

昨年 トップカンファレンス派遣レポータという制度がアナウンスされ、新しい取り組みで面白そうな企画でもあるので応募しました。 学会に参加するだけなら応募せずに会社で申請して参加すればいいのですが、NIPS の内容に興味を持つ人が集まりそうな場での発表の機会が得られるということが主たるモチベーションでした。

レポータとして選ばれるのは3名で応募者の統計情報などは明らかにされていません。 3名の内訳は、大学の先生・大学院生・私、という感じでバランスも考慮されている印象を受けました。

応募自体はA4の資料を一枚程度作成すればよいもので(郵送ですが)、それで必要経費を全て出してくれるというなかなか太っ腹な制度だと思います。 今年も同様の内容で募集する可能性が高そうなので、興味がある方は申し込んでみるのもよいと思います。 昨年は5月上旬に人工知能学会のメーリングリストから応募者を募るメールを受け取りました。

NIPS2017

Neural Information Processing Systems (NIPS) は機械学習の主要な国際会議の一つで、私は2015年にも参加していて二回目の参加となりました。

昨今の機械学習ブームを牽引する学会でもあり、2017年は registration が8000人でそれもかなり早い段階で打ち切られたという状態でした。 投稿論文数も3240件(採択率21%)で2016年から30%程度増加しており、年々その熱量が増しています。 企業のスポンサーは84社にも上り、diamond sponsor に関しては展示会さながらの大々的な展示が繰り広げられていました。

論文採択に関しては面白いデータが紹介されていて、事前に arXivにも submit されていた論文は43%に上り、レビュワーがそれを見た場合の採択率が35%という高い数字であったというものです。 レビュワーが見てない場合も25%と高い水準のため、そもそも質が高めの論文が arXiv に submit されるという傾向はあるかもしれませんが(例えば地力のある研究室がそういう戦略を取っているなど)、arXiv が機械学習分野にも高い影響力を発揮していることが伺えます。 学会では査読されてから publish されるまで時間も掛かるので、論文は arXiv などですぐに共有されて open review などで評価する open science 化が進んでいくかもしれません。

内容に関しては、NIPS において長らく主題の一つである algorithm が最多でありながら、deep learning や meta learning などの勢いが著しく、それ以外にも fairness や interpretability のような分野の台頭が目立つという、様々な側面で盛んに研究が進められているという印象でした。 個々の詳細な内容の説明はここでは省きますが、deep learning 関連のまとめに関しては冒頭の紹介資料にも記載しています。

NIPS2017 では新たな取組として competition track や DeepArt contest が開催されていました。 前者は kaggle のようなコンペを事前に開催して当日に上位入賞者に解説をしてもらうというような形式で、後者は style transfer を使って画像を artistic に変換して投票で入賞者を決めるという形式でした。 学会にこれらの要素が必要なのかということは議論の余地があるかもしれませんが、学会も世の中の動向に合わせて変化を続けていることが伺えるものでした。

その他にはネットでも話題になった苛烈な人材獲得競争のような話題がありますが、参加者としてはそこまで騒ぎ立てるほどではないと感じました。 一部でバブルを感じさせるイベントがあったりしたことは事実ですが、学会の性質を歪めるほどのものではないように思います。 学生としても自分が興味のある企業に直接アプローチする機会が増えて良いのではないでしょうか。

NIPS2017 論文読み会

せっかく参加したので、興味を持った論文をもう少し深く読んで発表しようと思い クックパッドで論文読み会を主催しました。 読み会の様子です。

20180327114104

私は GAN の学習の収束性に関するいくつかの論文を読んで発表をしました。

本来はどこかで開催される読み会に参加して発表だけしようと思っていたのですが、観測範囲内で望ましいイベントが開催されなかったので主催するに至りました(その後いくつか同様のイベントが開催されました)。

イベントの主催は大変なところもあるのですが、機械学習に興味のある方々に参加していただき盛況でした。 こういったイベントを通してクックパッドに興味を持って頂ける場合も少なくないので、主催してみて良かったなと思います。

今後もこのようなイベントを開催していくことになると思いますので、興味のある方は是非ご参加下さい。

NIPS2017 報告会

派遣レポータの仕事として事前に開催が決まっていた 報告会でも発表しました。 20180221に大阪大学中之島センターで、20180228に早稲田大学西早稲田キャンパスで報告会が開催されました。

有料イベントにも関わらず満員御礼状態で、特に企業の方々の参加者が多かったと伺っています。 久しぶりの大学での発表だし、NIPS の報告でもあるので、内容は思いっきり deep learning の理論的な話をしました。 参加者の目的と合致していたかは一抹の不安が残りますが、自分が聴衆として聞くなら悪くない内容だったと思っています(自分が話してるので当然ですね)。

その他

人工知能学会紙に参加報告を載せる予定です。 また、それ以外にも付随して何かやるかもしれません。

まとめ

人工知能学会のトップカンファレンス派遣レポータとして NIPS2017 に参加した話と、それに関連するイベントで何をやったのかという紹介をしました。 NIPS は理論的な色合いが濃い学会ではありますが、次々と新しいものが出てくる機械学習界隈ではこういった内容をキャッチアップしていくのは事業会社の研究開発でも重要だと考えていて、そして何より自分が好きなので、参加して得られた知見を今後の業務に活かしていきたいと思います。

いかがでしたでしょうか。 クックパッドでは、機械学習を用いて新たなサービスを創り出していける方を募集しています。 興味のある方はぜひ話を聞きに遊びに来て下さい。 クックパッド株式会社 研究開発部 採用情報

MR による料理サポートのための取り組み

$
0
0

研究開発部アルバイトの山谷 @kei_bilityです。リモートアルバイトとして IoT やモバイル、VR, MR の領域で新規サービス開発に取り組んでいます。

その中で HoloLens を活用した MR (Mixed Reality) による料理サポートのための取り組みを少しだけ紹介したいと思います。

概要

HoloLens を装着したユーザーが空間上で AirTap と呼ばれるジェスチャーをすると、いま見ている状態をキャプチャしてその画像を API リクエストでサーバへ送信します。

そしてサーバ側で画像認識した結果を受け取りその結果を表示します。

背景

背景としては、Semantic ARのように HoloLens と画像認識モデルを組み合わせ画像認識結果を現実世界に重畳させてユーザーに提供したいというモチベーションがありました。 HoloLens で画像認識するには HoloLens 自体で画像認識モデルを動かすか、API を経由してサーバ側で画像認識した結果を可視化するかの2通りがあります。 前者の方法について TensorFlowSharpなどを使ってモデルを動かせないか検討していましたが、 HoloLens の CPU/GPU では画像認識モデルの推論計算に時間を要する or モデル自体を圧縮する必要があるなど、すぐに試せなさそうなことがわかりました。 ということで、今回は HoloLens から画像を取得しその画像に対して API を通してその結果を可視化することにしました。

社内には Cookpad Computer Vision API (別名 See Food API) があります。ここにはクックパッドの画像認識モデルがAPI化されており、最新の研究成果を利用することができるようになっています。 今回はこの API と HoloLens を連携させてみました。

以下では Unity でのセッティング、HoloLens でのカメラ画像の取得、APIリクエストについて触れます。

Unity での UI セッティング

Unity で作る UI としては、キャプチャした画像を表示するパネルと、画像認識結果を表示するパネルの2つをユーザーの前に配置します。

f:id:kei_bility:20180328184731p:plain

カメラ画像の取得

HoloLens のカメラ画像を Unity で取得するため以下のように PhotoCaptureObjectを使ってカメラパラメータを設定します。

void OnPhotoCaptureCreated(PhotoCapture captureObject)
{
    photoCaptureObject = captureObject;

    Resolution cameraResolution = PhotoCapture.SupportedResolutions.OrderByDescending((res) => res.width * res.height).First();

    CameraParameters c = new CameraParameters();
    c.hologramOpacity = 0.0f;
    c.cameraResolutionWidth = cameraResolution.width;
    c.cameraResolutionHeight = cameraResolution.height;
    c.pixelFormat = CapturePixelFormat.JPEG;

    captureObject.StartPhotoModeAsync(c, false, OnPhotoModeStarted);
}

APIリクエスト

ユーザーがジェスチャーをしたらイベント発火し、API へリクエストを送ります。Unity の WWWクラスを利用し、C# スクリプトとして以下のように実装します。 レスポンスの json データからインスタンスを生成するため、レスポンスデータに対応する [Serializable] なクラスを用意しておき、 Unity 5.3 から利用できる JsonUtility で json からインスタンスを生成します。

WWW www = new WWW(apiEndPoint);
yield return www;
string response = www.text;
var result_json = JsonUtility.FromJson<ImageRes>(response);

こうしてユーザーが AirTap すると目の前の画像に対して認識した結果を表示します。

f:id:kei_bility:20180328001905j:plain

まとめ

Mixed Reality を実現する HoloLens を活用した料理サポートのための取り組みを紹介しました。 今後は料理動画をキッチンの任意の場所に貼り付けて操作できるようになったり、食材を認識して下ごしらえの仕方を教えてくれるなど、 Mixed Reality によって料理の新しい体験やサポートができるようになればいいなと考えています。

いかがでしたでしょうか。 クックパッドでは、IoTやモバイル、VR, MRで新たなサービスを創り出そうとチャレンジしています。 興味のある方はぜひ話を聞きに遊びに来て下さい。

Web アプリケーションを把握するためのコンソール

$
0
0

技術部開発基盤グループの鈴木 (id:eagletmt) です。 クックパッドではほとんどの Web アプリケーションが Amazon ECS 上で動く状態となり、またマイクロサービス化や新規サービスのリリースにより Web アプリケーションの数も増えていきました。 個々のアプリケーションでは Docker イメージを Jenkins でビルドして Amazon ECR にプッシュし、Rundeck から hakoを用いて ECS にデプロイし、またその Web アプリケーションからは Amazon RDS、Amazon ElastiCache 等のマネージドサービスを活用しています。

このように多くの Web アプリケーションが存在し、また各アプリが別のアプリや AWS の様々なマネージドサービスを利用している状況では、どのアプリが何を使っているのかを把握することが困難になっていきます。 具体的には新しくチームに所属したメンバーが、どのアプリがどの GitHub リポジトリに存在するのか、どの Jenkins ジョブを使っているのか、どうデプロイするのかを把握することが難しかったり、また自分のアプリの調子が悪いときにどのデータベースのメトリクスを見ればいいのか、どの ELB のメトリクスを見ればいいのかが難しかったりします。 マイクロサービス化を推し進めて各チームに権限と責任を移譲していく上で、各チームが自分たちのアプリの状態をすばやく把握できる状況は不可欠です。 そういった課題を解決するために hako-console を昨年開発していたので、その話を書こうと思います。 なお今回の話は昨年11月に行われた Rails Developers Meetup #7での発表と一部重複します。

https://speakerdeck.com/eagletmt/web-application-development-in-cookpad-2017

hako-console とは

hako-console はアプリケーション毎にそのアプリケーションに関連したシステムやメトリクス等の情報を閲覧することができる Web アプリケーションです。 たとえば

  • そのアプリの Docker イメージがビルドされている Jenkins ジョブがわかる
  • そのアプリのエラーログが蓄積されている Sentry プロジェクトがわかる
  • そのアプリが利用している RDS インスタンスがわかり、そのメトリクスも閲覧できる
  • そのアプリが stdout/stderr に出力したログを閲覧、検索できる

といった機能を持ちます。

hako-console app pagehako-console ECS metrics pagehako-console ELB metrics page

設計方針

Web アプリケーションの把握を手助けするための手段としてまず最初に挙がるのがドキュメンテーションだと思います。 しかしながら、人間が入力した情報は構成変更等があったときに必ず古くなり誤った記述になります。 README に書かれていたジョブを探したけど見つからなかった、ドキュメントに書かれていない別のデータベースにも実は接続していた、みたいな経験はよくあると思います。 これを解決するには「人間が入力しない」ということが重要だと考えていて、AWS のように API で情報を取得できるインフラを使っていたり、hako の定義ファイルのように機械的に読み込める情報があるので、これらの実際に使われている実態と乖離していない一次情報を自動的に集めることを念頭に置いて設計しました。

  • hako-console 自体は極力マスターとなるデータを持たない
    • 既に別の場所にあるデータやメトリクスを表示するだけにする
    • 人間が何かを入力することも極力しない
  • かわりに他のシステムからデータを取得して、それを機械的に処理する

アプリの状態を知るためのメトリクスは Zabbix、Amazon CloudWatch、Prometheus といったものに既に存在していたので、hako-console の役割は各アプリとメトリクスを繋ぐものにしようと考えました。 当初はサーバやマネージドサービスのメトリクスのようなものだけを考えていましたが、AWS X-Ray を使うようになってからは X-Ray のデータを使って通信があるアプリ間にリンクを表示する機能を追加する等 *1、そのアプリについて知るために役立つ情報を追加していきました。

開発に必要な情報を集める

クックパッドでの Web アプリケーションの主要な開発フローは以下のようになっています。

  1. GitHub Enterprise にリポジトリを作成し、そこで開発を行う
  2. 同リポジトリに Dockerfile を追加し、Jenkins ジョブでテストを実行し、docker build && docker push を実行する
  3. hako_apps という中央リポジトリにアプリケーション定義を追加する
    • たとえば example というアプリであれば example.jsonnet を追加する
  4. hako_apps リポジトリの webhook を通じて hako-console にアプリケーションが自動的に登録される
  5. hako-console から Rundeck にデプロイ・ロールバック用のジョブを作成する
    • デプロイ用のジョブは hako deploy example.jsonnet、ロールバック用のジョブは hako rollback example.jsonnetを実行するように作成される
  6. ruboty deploy exampleと Slack で発言することで chatbot の ruboty を通じて Rundeck のデプロイジョブが起動され、ECS でアプリが動く状態になる

社内では https://gist.github.com/eagletmt/f66150364d2f88daa20da7c1ab84ea13のような hako の script を使っており、example.jsonnet の scripts に jenkins_tag を追加すると hako deploy -t jenkins example.jsonnetで最新の安定ビルドのリビジョンを Docker イメージのタグに指定できるようになっています。 そのため example.jsonnet を読めば example というアプリがどの Jenkins ジョブを使っているかが分かります。 Rundeck ジョブは ruboty から使われるため、hako-console を作成する前から Rundeck ジョブ名はアプリ名にすることが習慣となっていました。そのため、Rundeck のジョブを API で取得しアプリ名で寄せることで、どの Rundeck ジョブを使っているかが分かります。 hako-console ができてからは、この習慣に従って Rundeck ジョブが hako-console によって半自動的に作成されるようになっています。

hako-console はこのような形で情報を集めて、アプリケーション毎にリンクを表示しています。

運用に必要な情報を集める

Web アプリケーションが使っている ELB は、hako が hako-${app_id}という固定の命名規則で ELB を作成するため、それで見つけることができます。 たとえば example.jsonnet というアプリケーション定義であれば、hako-example という名前のロードバランサーやターゲットグループが対応します。

RDS インスタンスや ElastiCache インスタンスはどうでしょうか。 Docker コンテナで動かすアプリの場合、接続先の MySQL や memcached のエンドポイントといった設定値は環境変数で渡すことが多く、環境変数は hako のアプリケーション定義に書かれます。 したがって example.jsonnet の環境変数定義を調べて、その中に RDS のエンドポイントっぽい文字列 (つまり /\b(?<identifier>[^.]+)\.[^.]+\.(?<region>[^.]+)\.rds\.amazonaws\.com/にマッチするような文字列) であったり、ElastiCache のエンドポイントっぽい文字列を探すことで、多くの場合うまくいきます。 こうすることで、各アプリケーションが接続している RDS インスタンスや ElastiCache インスタンスを見つけることができ、また逆に各 RDS インスタンスや ElastiCache インスタンスを使っているアプリケーションを知ることもできます。

hako-console RDS and ElastiCache list pagehako-console RDS page

Sentry のプロジェクトについても同様で、Sentry 用の各種 SDK は SENTRY_DSNという環境変数で DSN を設定できるようになっており、APIを通じてプロジェクトの一覧を取得できるので、 example.jsonnet に書かれた SENTRY_DSNと API の結果を突き合わせることで Sentry プロジェクトを見つけることができます。

ログの閲覧、検索

ECS で動かしている Docker コンテナのログは、log driver として fluentd を指定し fluent-plugin-s3を使って Amazon S3 に送信するようにしています。 S3 に送信されたログはそのままでは閲覧しにくいので、hako-console 上でアプリ毎やタスク毎に閲覧や検索できるようにしています。

hako-console log list pagehako-console log page

検索には Amazon Athena を使っており、そのためのテーブル定義は AWS Glue のデータカタログに作成しています。 fluentd が ${アプリ名}/${コンテナ名}/${日付}/のようなプレフィックスで jsonl を gzip で圧縮したものを保存し、日次のバッチジョブがログの中身にあわせたパーティションを Glue のテーブル定義に追加していくことで、S3 にログが送信されてからすぐに検索対象になるようにしています。

まとめ

内製している hako-console について紹介しました。 Web アプリケーションを把握するためのコンソールを作ること、またそのようなコンソールを作成できるようなツールやインフラにすることは重要だと考えています。 hako-console は社内のインフラ事情と密接に関係しているため OSS ではありませんが、それぞれの環境にあわせてこのようなコンソールを作成することに意味があるんじゃないかなと思います。

*1:ちなみに、この「どのアプリとどのアプリが通信するのか」という情報はトレーシングではなくサービスメッシュによって達成しようと動いています https://speakerdeck.com/taiki45/observability-service-mesh-and-microservices

Ruby の lazy loading の仕組みを利用して未使用の gem を探す

$
0
0

技術部開発基盤グループのシム(@shia)です。 最近は cookpad のメインレポジトリを開発しやすい環境に改善するために様々な試みをしています。 この記事ではその試みの一つとして不要な gem を検出し、削除した方法を紹介したいと思います。

背景

cookpad は10年以上にわたって運用されている巨大なウェブアプリケーションです。 巨大かつ古いアプリケーションには昔は使っていたが、現在は使われてない依存性などが技術負債として溜まっています。 事業的観点から技術的負債を完全返却するのはコストとのバランスが悪いことも多いです。 これは20万行を超えるプロジェクトを幾つも抱えている cookpad のメインレポジトリも例外ではなく、その規模から使ってないと思われる依存性を探しだすのも大変な状況でした。

どうするか

人が頑張るより機械に頑張らせたほうが楽ができるし、何より確実です。 ですので今回は未使用の gem を探すために Ruby の遅延ロード仕組みに乗りました。 遅延ロードのために用意された仕組みにパッチを当て、使用されている gem のリストを出します。 これを利用して依存してる gem のリストから未使用である gem のリストを逆引きします。

InstructionSequence(iseq) とは

InstructionSequence(iseq) とは Ruby のソースコードをコンパイルして得られる命令の集合を指します。 この命令は Ruby VM が理解できるもので、各 iseq はツリー構造で成り立ちます。例えば

classCatdefsleependend

このコードからはCatクラスを表現する iseq が一つ、 sleepメソッドを表現する iseq が一つ作られます*1が、 構造的には Catの iseq に sleepの iseq が含まれている状態です。 これより詳しい説明を見たい方は RubyVM::InstructionSequenceの説明や「Rubyのしくみ」という本がおすすめです。 もしくは弊社で Ruby の内部が分かる Ruby Hack Challenge というイベントが不定期に開催されるので参加してみるのも良いも思います。 参考記事

InstructionSequence lazy loading

Ruby 2.3 では iseq を lazy loading するという仕組みが試験的に導入されました。 この機能は iseq を初めて実行する時まで中身の読み込みを遅延させることで、

  • アプリケーションのローディングが早くなる
  • メモリーの使用量を減らす

ということを狙っています。ですが、今回は「初めて実行する時まで中身の読み込みを遅延させる」ために用意された仕組みに興味があります。 iseq の定義パスや first line number は iseq から簡単に取り出せるので、これらを利用すれば実際に使用された gem のリストを作れます。

どういうパッチを当てるのかを見る前に少しだけ Ruby のコードを見てみましょう。

// https://github.com/ruby/ruby/blob/v2_4_3/vm_core.h#L415-L424staticinlineconst rb_iseq_t *
rb_iseq_check(const rb_iseq_t *iseq)
{
#if USE_LAZY_LOADif (iseq->body == NULL) {
    rb_iseq_complete((rb_iseq_t *)iseq);
    }
#endifreturn iseq;
}

rb_iseq_checkは iseq が実行される前に呼ばれる関数です。 ここで iseq の中身が空なら(まだ実行されたことがない)、中身をロードしてるのがわかります。 先程話したようにこれは実験的な機能であるため USE_LAZY_LOAD がマクロで宣言されてないと使われません。 ですので普段はなにもせず引数として渡された iseq を返すだけの関数です。 ここで iseq の初回実行のみ特定の関数を呼び、そこで必要なロギング作業すれば良さそうです。

パッチ

上記のコードからどういう感じのパッチを書けばよいのか理解できると思うので実際のパッチを見てみましょう。 以下のパッチは 2.4.3 をターゲットとして書かれてるので注意してください。

---
 iseq.c    | 16 ++++++++++++++++
 vm_core.h | 15 +++++++++++++++
 2 files changed, 31 insertions(+)

diff --git a/iseq.c b/iseq.c
index 07d8828e9b..322dfb07dd 100644
--- a/iseq.c+++ b/iseq.c@@ -2482,3 +2482,19 @@ Init_ISeq(void)
     rb_undef_method(CLASS_OF(rb_cISeq), "translate");
     rb_undef_method(CLASS_OF(rb_cISeq), "load_iseq");
 }
++#if USE_EXECUTED_CHECK+void+rb_iseq_executed_check_dump(rb_iseq_t *iseq)+{+    iseq->flags |= ISEQ_FL_EXECUTED;+    char *output_path = getenv("IE_OUTPUT_PATH");+    if (output_path == NULL) { return; }++    char *iseq_path = RSTRING_PTR(rb_iseq_path(iseq));+    FILE *fp = fopen(output_path, "a");+    fprintf(fp, "%s:%d\n", iseq_path, FIX2INT(rb_iseq_first_lineno(iseq)));+    fclose(fp);+}+#endifdiff --git a/vm_core.h b/vm_core.h
index 8e2b93d8e9..96f14445f9 100644
--- a/vm_core.h+++ b/vm_core.h@@ -412,6 +412,16 @@ struct rb_iseq_struct {
 const rb_iseq_t *rb_iseq_complete(const rb_iseq_t *iseq);
 #endif

+#ifndef USE_EXECUTED_CHECK+#define USE_EXECUTED_CHECK 1+#endif++#define ISEQ_FL_EXECUTED IMEMO_FL_USER0++#if USE_EXECUTED_CHECK+void rb_iseq_executed_check_dump(rb_iseq_t *iseq);+#endif+
 static inline const rb_iseq_t *
 rb_iseq_check(const rb_iseq_t *iseq)
 {
@@ -419,6 +429,11 @@ rb_iseq_check(const rb_iseq_t *iseq)
     if (iseq->body == NULL) {
        rb_iseq_complete((rb_iseq_t *)iseq);
     }
+#endif+#if USE_EXECUTED_CHECK+    if ((iseq->flags & ISEQ_FL_EXECUTED) == 0) {+       rb_iseq_executed_check_dump((rb_iseq_t *)iseq);+    }
 #endif
     return iseq;
 }
--
  • iseq が持っている未使用のフラグ一つを iseq が実行されたことがあるかを判断するためのフラグ(ISEQ_FL_EXECUTED)として使えるようにする
  • ISEQ_FL_EXECUTEDフラグが立ってない場合 rb_iseq_checkrb_iseq_executed_check_dumpという関数を呼ふ
  • rb_iseq_executed_check_dumpではその iseq の path, first_lineno を(環境変数 IE_OUTPUT_PATHで指定した)ファイルに書き込む

このように rb_iseq_checkにフックポイントを作ることで TracePoint とは比べるまでもないほどの低コストで実行された iseq を探せます。 もちろんロギングのコストは発生するので注意する必要はありますが、仕組み自体のコストは実質ゼロに近いことがわかっています。

このパッチを当てた Ruby を利用することで実行された iseq のリストを得ることができます。 今回は手作業で確認したい対象を減らすためのものなので、パッチを当てた ruby でテストを完走させ、そのログを利用することにしました。以下のような大量のログが吐かれるのでこれらを処理して実際使われてる gem のリストを作成できます。

.../2.4.3/lib/ruby/gems/2.4.0/gems/rspec-expectations-3.7.0/lib/rspec/matchers/built_in/has.rb:46
.../2.4.3/lib/ruby/gems/2.4.0/gems/rspec-expectations-3.7.0/lib/rspec/matchers/built_in/has.rb:58
.../2.4.3/lib/ruby/gems/2.4.0/gems/rspec-expectations-3.7.0/lib/rspec/matchers/built_in/has.rb:71
.../2.4.3/lib/ruby/gems/2.4.0/gems/rspec-expectations-3.7.0/lib/rspec/matchers/built_in/has.rb:63
.../2.4.3/lib/ruby/gems/2.4.0/gems/rspec-expectations-3.7.0/lib/rspec/matchers/built_in/has.rb:67
.../2.4.3/lib/ruby/gems/2.4.0/gems/capybara-2.13.0/lib/capybara/node/matchers.rb:245
.../2.4.3/lib/ruby/gems/2.4.0/gems/capybara-2.13.0/lib/capybara/node/matchers.rb:3

依存している gem のリストは Bundler::LockfileParserを利用すると簡単に得られます。

# プロジェクト rootrequire"bundler"

lockfile_parser = Bundler::LockfileParser.new(File.read("Gemfile.lock"))
lockfile_parser.specs.map(&:name)

この使用された gem のリストと依存している gem のリストから、後者から前者を引き算することで、 依存しているが使用されてない gem のリストを作れます。

成果

現在、cookpad のメインレポジトリには1つの mountable engine を共有する 5つのプロジェクトがあります。 この5つのプロジェクトを対象に上記のパッチを利用して作り出した未使用 gem のリストを作成し、必要のないものをなくす作業を進めました。

結果としてすべてのプロジェクトから未使用の gem が 41個見つかりました。 これらを削除することで、依存している gem の数を大幅に減らすことができました。 さらに require するファイルの数が大量に減ったため、アプリケーションの読み込み時間が最大1秒程度速くなりました。

まとめ

Ruby の lazy loading という仕組みを利用して未使用の gem を探す方法を紹介しました。 この方法は使用されてないコードを探すのに以下のような利点を持っています。

  • プロジェクト別にコードを書く必要がないのでどのプロジェクトからも簡単に利用することができる
  • 動的に生成されるメソッドもある程度追跡ができる
  • 低コストにコードの使用状況が分かる

特に三番目が重要だと思っていて、本番のサービスから使われてない依存 gem やプロジェクトコードを簡単に追跡できるんじゃないかと期待しているので、次回にご期待ください。

*1:正確には3つが作られますが、ここでは説明のため省略しています

ディープラーニングによるホットドッグ検出器のレシピ

$
0
0

研究開発部の画像解析担当のレシェックです。techlife を書くのは初めてです。よろしくお願いいたします。

最先端の機械学習を使うためには、常に自分のスキルアップが必要です。そのために、毎日論文を読んだり、新しいオープンソースのコードを試してみたり、クックパッドのデータで実験しています。これはちょっと料理の練習と似ています。新しいモデルを学習させるのは料理をオーブンに入れるのと同じ気持ちです。オーブンの温度は学習率と同じで、低すぎだとよく焼けず、高すぎだと焦げてしまいます。しかし、ちゃんと他のリサーチャーの論文やブログの中のレシピを見ながら自分のデータでモデルを学習させると、失敗せずに済むかもしれません。

このエントリでは、そういった機械学習のレシピの一例を紹介します。

f:id:lunardog:20180405185342j:plain

このブログで使っているテスト画像はPixabayから取得した、Creative Commonsのライセンスの写真です。

概要

クックパッドは料理/非料理のモデルを開発しています。ここでは、このモデルのミニチュア版のレシピを紹介します。カテゴリは「料理」と「非料理」の代わりに、「ホットドッグ」と「非ホットドッグ」にします。そして、パッチ化した画像に対する認識モデルを使って、画像の中でホットドッグがどこにあるかを検出します。

調理器具

  • python
  • Keras
  • numpy
  • pillow (PIL)
  • jupyter notebook(お好みでお使い下さい。)

KerasはTensorflow、CNTKやTheano上で動く高水準のライブラリーです。Keras は特に画像データに対して、単なる学習以外にも前処理などでも様々な機能があります。

材料

KaggleからHot Dog - Not Hot Dogのデーターセットをダウンロードしてください。なお、ダウンロードするには Kaggle の登録が必要です。

ダウンロードした後、seefood.zipunzipしてください。

アーカイブの中に、2つのディレクトリtraintestがあります。

seefood/train/not_hot_dog
seefood/train/hot_dog
seefood/test/not_hot_dog
seefood/test/hot_dog

hot_dogディレクトリの中にホットドッグの画像が入っており、not_hot_dogの中にそれ以外の画像が入っています。新しい機械学習のレシピを開発する時はテストデータを分けるべきです。しかし、今回は画像が少ないので、テストデータも学習に使いましょう。

mkdir seefood/all
cp -r seefood/test/* seefood/train/* seefood/all

以降では、seefood/allのディレクトリを使います。

データ拡張

Keras のモバイルネットは(224px・224px)のフィックスサイズの画像しか認識できないので、これから学習や認識用にサイズを変換します。

IMG_SIZE=[224, 224]

テストデータを学習に使っても、このデータセットはまだ小さいので、データ拡張を使いましょう。

KerasのImageDataGeneratorは学習時に画像を一つずつ変換します。

import keras.preprocessing.image

image_generator = keras.preprocessing.image.ImageDataGenerator(
        rescale=1./255,
        shear_range=0.0,
        width_shift_range=0.1,
        height_shift_range=0.1,
        rotation_range=10,
        fill_mode="wrap",
        vertical_flip=True,
        horizontal_flip=True
)

上のimage_generator"seefood/all"のディレクトリで動かします。

train_generator = image_generator.flow_from_directory(
    "seefood/all",
    target_size=IMG_SIZE,
    batch_size=32,
    class_mode="categorical",
    classes=["not_hot_dog", "hot_dog"]
)

モデルの作り方

以下のレシピでは、3 個のモデルを 3 層のスポンジケーキのように積み重ねています。

  1. base_modelMobileNetです。転移学習のために使います。
  2. その上のpatch_modelは画像のパッチごとに分類できます。
  3. さらにその上のclassifierは「ホットドッグ」と「非ホットドッグ」の二値分類器です。

まずkerasimportします:

import keras

ベースとして、Googleで開発されたMobileNetというモデルを使います。

weights="imagenet"は、ILSVRCのコンペティションのデータセットで学習されたパラメタを使って、転移学習することを意味しています。

base_model = keras.applications.mobilenet.MobileNet(
    input_shape=IMG_SIZE + [3], 
    weights="imagenet",
    include_top=False
)

ベースモデルの一番上のフィーチャサイズは1024です。パッチレイヤが学習できるようにちょっと下げましょう。

drop1 = keras.layers.SpatialDropout2D(0.3)(base_model.output)
conv_filter = keras.layers.convolutional.Conv2D(
    4, (1,1),
    activation="relu",
    use_bias=True,
    kernel_regularizer=keras.regularizers.l2(0.001)
)(drop1)

パッチレイヤもConv2Dのタイプのレイヤです。この場合、softmaxを使えば、パッチごとに分類できるようになります。

drop2 = keras.layers.SpatialDropout2D(0.3)(conv_filter)
patch = keras.layers.convolutional.Conv2D(
    2, (3, 3),
    name="patch",
    activation="softmax",
    use_bias=True,
    padding="same",
    kernel_regularizer=keras.regularizers.l2(0.001)
)(drop2)

これでパッチモデルができました。

patch_model = keras.models.Model(
    inputs=base_model.input, 
    outputs=patch
)

パッチモデルをベースにして、最後の出力レイヤを追加して分類モデルを作ります。

pool = keras.layers.GlobalAveragePooling2D()(patch)
logits = keras.layers.Activation("softmax")(pool)


classifier = keras.models.Model(
    inputs=base_model.input, 
    outputs=logits
)

学習

ベースモデルは学習させません。

for layer in base_model.layers:
    layer.trainable = False

そして全体のモデルをcompileします。

classifier.compile(optimizer="rmsprop", loss="categorical_crossentropy", metrics=["accuracy"])

では、学習を始めましょう!

いくつか実験をした結果、以下のようにnot_hot_dogのクラスのclass_weightを高くするほうが良いことが分かりました。

%%time
classifier.fit_generator(
    train_generator, 
    class_weight={0: .75, 1: .25}, 
    epochs=10
)
Epoch 1/10
32/32 [==============================] - 148s 5s/step - loss: 0.3157 - acc: 0.5051
Epoch 2/10
32/32 [==============================] - 121s 4s/step - loss: 0.3017 - acc: 0.5051
Epoch 3/10
32/32 [==============================] - 122s 4s/step - loss: 0.2961 - acc: 0.5010
Epoch 4/10
32/32 [==============================] - 121s 4s/step - loss: 0.2791 - acc: 0.5862
Epoch 5/10
32/32 [==============================] - 122s 4s/step - loss: 0.2681 - acc: 0.6380
Epoch 6/10
32/32 [==============================] - 123s 4s/step - loss: 0.2615 - acc: 0.6876
Epoch 7/10
32/32 [==============================] - 121s 4s/step - loss: 0.2547 - acc: 0.6790
Epoch 8/10
32/32 [==============================] - 122s 4s/step - loss: 0.2522 - acc: 0.7052
Epoch 9/10
32/32 [==============================] - 123s 4s/step - loss: 0.2522 - acc: 0.7045
Epoch 10/10
32/32 [==============================] - 145s 5s/step - loss: 0.2486 - acc: 0.7164
CPU times: user 1h 4min 20s, sys: 2min 35s, total: 1h 6min 56s
Wall time: 21min 8s

このデータセットの場合、10エポックぐらいが良さそうです。パッチベースを使っているので、精度は100%にならないほうがいいです。70%ぐらいがちょうどいいです。

私の MacBook Pro では10エポックで20分ぐらいかかりました。

確認作業

画像とデータの変換のために、PILnumpyを使います。

import numpy as np
from PIL import Image

画像をインファレンスする前に、numpyのデータに変換します。

defpatch_infer(img):
    data = np.array(img.resize(IMG_SIZE))/255.0
    patches = patch_model.predict(data[np.newaxis])
    return patches

そして、元の画像とインファレンス結果をビジュアライズします。

defoverlay(img, patches, threshold=0.99):
    # transposeはパッチをクラスごとに分けます。
    patches = patches[0].transpose(2, 0, 1)
    # hot_dogパッチ - not_hot_dogパッチ
    patches = patches[1] - patches[0]
    # 微妙なパッチをなくして
    patches = np.clip(patches, threshold, 1.0)
    patches = 255.0 * (patches - threshold) / (1.0 - threshold)
    # 数字を画像にして
    patches = Image.fromarray(patches.astype(np.uint8)).resize(img.size, Image.BICUBIC)
    # もとの画像を白黒に
    grayscale = img.convert("L").convert("RGB").point(lambda p: p * 0.5)
    # パッチをマスクに使って、元の画像と白黒の画像をあわせて
    composite = Image.composite(img, grayscale, patches)
    return composite

まとめて、インファレンスとビジュアライズを一つのファンクションにすると、

defprocess_image(path, border=8):
    img = Image.open(path)
    patches = patch_infer(img)
    result = overlay(img, patches)
    # 元の画像と変換された画像をカンバスに並べます
    canvas = Image.new(
        mode="RGB", 
        size=(img.width * 2 + border, img.height), 
        color="white")
    canvas.paste(img, (0,0))
    canvas.paste(result, (img.width + border, 0))
    return canvas

では、結果を見てみましょう!

f:id:lunardog:20180405185418j:plainきれいですね!

f:id:lunardog:20180405185437j:plainホットドッグの色はちょっと隣のコーヒーに移りましたが、ほとんど大丈夫です。

f:id:lunardog:20180405185457j:plainフォーカスが足りないところは認識にならなかったみたいです。なぜでしょう?学習データにフォーカスが当たらないホットドッグがなかったからです。

f:id:lunardog:20180405185342j:plainこちらも、左側のホットドッグはフォーカスが当たっておらず、モデルはホットドッグを認識できませんでした。

ホットドッグではない画像は? f:id:lunardog:20180405185526j:plain

f:id:lunardog:20180405185541j:plain

f:id:lunardog:20180405185558j:plain

f:id:lunardog:20180405185609j:plain

ホットドッグではない画像には、パッチはゼロやゼロに近い値になります。

まとめ

転移学習を使えば、データが少なくても、それなりの識別器が作れますね!

パッチごとの分類を使えば、画像の中の認識したいフィーチャーを可視化できます。

モバイルネット(MobileNet)のおかげで、CPU でもモデルを学習できます。

いかがでしたでしょうか。 クックパッドでは、機械学習を用いて新たなサービスを創り出していける方を募集しています。 興味のある方はぜひ話を聞きに遊びに来て下さい。

クッキングLIVEアプリcookpadTVのコメント配信技術

$
0
0

こんにちは。メディアプロダクト開発部の長田です。

この記事では、クッキングLIVEアプリcookpadTVのLIVE中のコメント配信について工夫したことを紹介したいと思います。

2018/3/28 (水)に開催されたCookpad Tech Kitchen #15資料も合わせてご覧いただけると、分かりやすい部分もあるかと思います。

cookpadTV

cookpadTVでは、料理家や料理上手な有名人による料理のLIVE配信を視聴することができます。iOS/Androidのアプリがリリースされおり、LIVE配信を通して、分かりづらい工程や代替の材料の質問などをコメント機能を使って質問することができます。また、他のLIVE配信アプリのようにハートを送ることでLIVEを盛り上げることができます。

以下では、LIVE中のコメント配信を実装するにあたって私が課題だと感じたものと、それらをどう解決したのかを紹介します。

コメント配信の課題

コメント配信には次のような課題があると感じています。

1つ目は、パフォーマンスの問題です。LIVEの日時に合わせてユーザーが同時に集まるので、人気な配信ほど多くのユーザーがサーバーにリクエストしてきます。また、コメントだけではなくハートを送信する機能を設けており、これは気軽に連打できるようにしてあるので、リクエスト数も多くなることが予想されました。

2つ目は、双方向通信です。cookpadTVでは「料理家や有名人にその場で質問できる」のを価値にしていて、ユーザーのコメントは演者が読み上げて回答してくれたりします。 演者とユーザーのコミュニケーションと、それを見ている他のユーザーの体験を損なわないようにするために、サーバーとアプリの情報をある程度同期させておく必要がありました。

パフォーマンスを出すために

コメントを受けるAPIは別アプリケーションとして構築しました。コメントを受けるAPIはその他のAPIとは特性が違うので、コメントを受けるAPIだけをチューニングしやすくなるからです。 以下では、このコメントを受けるAPIサーバーのことを メッセージサーバーと呼び、その他のAPIサーバーを 通常のAPIサーバーと呼ぶことにします。*1

f:id:osadake212:20180412145148p:plain

まず、実装言語はgolangを採用しました。採用理由は以下が挙げられます。

  • 並列処理が得意な言語なので、同時接続を受け付けやすい
  • 後述のFirebaseを使うためのAdmin SDKが提供されていた
  • golang書きたかった

クックパッドはRubyの会社というイメージがあると思いますが、特性に応じてRuby以外の言語を選択できるよう、hakoを使ったDockerコンテナのデプロイ環境が全社的に整備されており、他のサービスでもRuby以外の言語で実装されているものがあります。*2*3

また、hakoによってDockerコンテナがECSにデプロイされるようになっており、必要に応じてECSのAuto Scalingの設定ができるので、このメッセージサーバーも設定しています。これにより、アクセスが増えてきてサーバーリソースが消費され始めたらスケールアウトして、アクセスが減ってサーバーリソースに余裕がでてきたらスケールインするようになります。 また、Auto Scalingが間に合わないことが予想される場合は、予めコンテナ数を増やしておくようにしています。*4

さらに、WebアプリケーションはDBアクセスがボトルネックになりがちだと思うのですが、メッセージサーバーではDBにアクセスをしない、という選択をしました。一方で、DBにアクセスしないので認証と永続化について工夫する必要がありました。

認証については、メッセージサーバー用の寿命の短い認証情報(トークン)を通常のAPIサーバーで発行しておき、それをキャッシュに乗せておきます。各アプリはそのトークンを乗せてリクエストするので、メッセージサーバーはキャッシュを見に行くことで認証を実現しています。

また、永続化については非同期で行うようにしました。 コメント/ハートは後述のFirebase Realtime Databaseを使って各アプリに配信されており、LIVE配信中に永続化できなくてもよかったので非同期で行う選択をしました。

永続化の流れは、fluentdを使ってコメント/ハートのデータをS3に送ったあと、弊社のデータ基盤を使うことで、Redshiftに継続的に取り込まれるようになっています。*5さらに、Redshiftに入ったデータは、Kuroko2を使ったバッチ処理によりMySQLに取り込む流れになります。*6

f:id:osadake212:20180412145141p:plain

これらの工夫をして、直近の配信ではピーク時 5,100rpm のメッセージを無事捌くことができました。

双方向通信

コメントやハートのやり取りで使用する、iOS/Androidアプリとの双方向通信を行うためにいくつかの手段を検討しました。

などを検討した結果、最終的にFirebase Realtime Databaseを使うことにしました。選択した理由としては、

  • iOS/AndroidのSDKが提供されており、アプリの実装工数が減らせる
  • 社内の他プロジェクトで導入されており、知見があった

というのが挙げられます。

また、Firebase Realtime Databaseに直接アプリが書き込むのではなく、以下の図のように、一度メッセージサーバーがリクエストを受け付けて、その内容をFirebase Realtime Databaseに書き込むようにしました。こうすることで、認証と永続化を実現しています。 つまり、Firebase Realtime Databaseをストレージとしてではなく、イベント通知をするために利用しています。これに関しては、この後のデータ構造の工夫と合わせて詳しく説明します。

f:id:osadake212:20180412145041p:plain

Firebase Realtime Databaseを使うことにしたので、データ構造を工夫する必要がありました。

cookpadTVでは、データ転送量を抑えるために最新のコメントだけを保存するようにしました。 具体的には以下のようなJSON構造にしています。(これはイメージなので実際のものとは異なります。)

{"latest_comment": {"user_id" : 1,
    "text": "こんにちはー"
  }}

このような構造にしておいて、 latest_commentを上書き更新することで、各アプリに配布するデータは最新のコメント分だけになるので、転送量を抑えることができます。過去のコメントはアプリ側で保持しておいて、LIVE中に受け取ったデータは遡れるようになっています。

ただしこのデータ構造には、途中からLIVE配信を見始めたユーザーは過去のコメントを見ることが出来ないという課題が残っています。 この課題に関しては、直近のコメントはいくつか保持しておく、というものと、非同期での永続化のラグを短くした上でAPIでコメントを返せるようにする、という2つのアプローチのあわせ技で解決したいと思っています。

まとめ

この記事では、cookpadTVのLIVE中のコメント配信について工夫したことを紹介しました。 最後になりましたが、この記事がLIVE配信サービスの開発について、少しでもお役に立てれば幸いです。

*1:コメントだけではなく、ハート等、他のメッセージも受けるのでメッセージサーバーと呼んでいます。

*2:hakoの近況は本ブログでも紹介されています。http://techlife.cookpad.com/entry/2018/04/02/140846

*3:2018/02/10に開催されたCookpad TechConf 2018では、「Rubyの会社でRustを書くということ」というタイトルで弊社のkobaによる発表が行われました。 https://techconf.cookpad.com/2018/hidekazu_kobayashi.html

*4:LIVEコンテンツの集客予想に応じて、自動でコンテナ数を増やす仕組みを実装しています。

*5:本ブログの過去のエントリで、クックパッドのデータ基盤について紹介しているものがあるので、詳細はこちらを御覧ください。 http://techlife.cookpad.com/entry/2017/10/06/135527

*6:弊社のオープンソースで、WebUIが用意されているジョブスケジューラーです。


React Nativeで作った新アプリについて(5日間連載)

$
0
0

こんにちは投稿開発部の丸山@h13i32maruです。

今日から5日間、本ブログに投稿開発部メンバーで連載記事を書かせていただきます!

いきなり「投稿開発部で連載記事」と言われても何のことかわからないと思うので、まず投稿開発部について簡単に紹介させてもらいます。

投稿開発部は「クックパッドに投稿されるコンテンツ全般」について責任をもっている部署なのですが、中でもレシピ事業の根幹であるレシピ投稿者向けのサービス改善に力を入れています。レシピ投稿者向けのサービス改善は「どうすれば継続的に投稿したくなるのか?」「どうすれば投稿をはじめてみたくなるのか?」の2点に答えを出すことを目標に日々サービス開発に励んでいます。

そこで、本連載では投稿開発部が今年メインで取り組んでいる「クックパッド MYキッチン」という新しいアプリについて5人のメンバーで紹介させていただきます。

1日目(vol1)では「クックパッド MYキッチン」ができるまでの話をちょっとしたストーリー仕立ての文章で紹介させていただきます。普段の記事と比べると技術的なトピックは少なめなので、肩の力を抜いて気軽にお読みください。

そして、2日目以降は以下のような内容を予定しております。

  • vol2: React Native アプリの開発基盤構築(仮) by @morishin127
  • vol3: ReactNativeプロジェクトのAndroid環境を整備する(仮) by @101kaz
  • vol4: 「クックパッド MYキッチン」のアプリアイコンができるまで(仮) by @sn_taiga
  • vol5: 料理する人の課題を起点に施策を作る試み(仮) by 五味 夏季

「クックパッド MYキッチン」とは

今年、投稿開発部では「クックパッド MYキッチン」というアプリ(以下MYキッチンアプリ)の開発に注力しています。このアプリはレシピ投稿者が使いたいと思えるアプリを目指して、これまでのクックパッドアプリ上での体験をリデザインして作られているものです。

f:id:h13i32maru:20180413135704p:plain:w100f:id:h13i32maru:20180413135755p:plainApp Store / Google Play

ではなぜ既存のアプリ上でレシピ投稿者向けの体験をリデザインしなかったかというと、「開発・検証のスピードを上げるため」というのが大きな理由です。そのために「関わる人を少なくして、意思決定を速く」と「機能の制約を受け入れて、実装を速く」ということを行っています。

特に後者の「機能の制約を受け入れて、実装を速く」について、MYキッチンアプリではReact Nativeを採用してフルスクラッチで作られています。また、CodePushについても試し始めています。

ではここから、いかにしてMYキッチンアプリが出来上がっていったのかを紹介していきます。

Prototype Labs(2017年春)

時は遡り2017年春、当時同じチームだったiOSエンジニアがReact Nativeをアプリのプロトタイピングに使えないか調査していました。当時、彼が書いた社内ブログにはこのように書いてありました。

年末年始でReactNativeの調査をしていました。

目的はReactとcssの知見でネイティブのアプリが作れれば、アプリのプロトタイプできる人口を増やせるのでは?というところ。

xxx(とあるプロジェクト)の初期でいくつかの機能を試していたときに、「アプリに組み込んで手触りを試したい」という欲求があったものの ネイティブがかける人は少ないし、ネイティブでレイアウトを変えるトライアンドエラーはどうしてもコストが高いので 何か別の手段で試せた方が良いのでは・・?と考えたのがきっかけです。

この彼の取り組みを横目でみながら、「React Nativeというものがあって」「アプリのプロトタイピングに使えるかもしれない」という情報を得た僕は、自分でもちょっと試しに触ってみることにしました。

当時、どういうふうに試し始めたのかははっきりとは覚えていないのですが、「アイテムのリスト画面」と「アイテムの詳細画面」という基本の画面を作ったと思います。そして色々触ってみた結果「開発スピードをあげるために、完成度・機能・UI・パフォーマンスなどの制約を受け入れることができる」というものに向いていることがわかりました。そう、まさにプロトタイピングに向いていると思ったのです。*1

さらに、React Nativeを使ったプロトタイピングなら、これまで静的なプロトタイピング(ペーパーモック、InVisionなど)では諦めるしかなかった点もカバーできると思いました。

  • 日常生活で使うことができるプロトタイプ
  • 実際のデータを使ったプロトタイプ
  • データを書き込むことができるプロトタイプ

というわけで、僕の中の「React Nativeでプロトタイピング環境を作りたい」という欲求がむくむくと湧き上がっていきました。なので鉄は熱いうちに打ての精神で、React Nativeを使った社内用のプロトタイピング環境「Prototype Labs」を作りました*2

Prototype Labsの中身はというと、React Nativeを社内のプロトタイピングに特化させるために、薄いラッパーと幾つかの便利機能を追加したものです。具体的には、ファイルの配置ルール、デプロイの仕組み、ドキュメントの構築、認証周りのデフォルト実装、サーバサイドのAPIを簡単に呼び出せる仕組み、よく使うカラー・レイアウトの提供、etcという感じです。

Prototype Labsとは APIリファレンス
f:id:h13i32maru:20180413135849p:plainf:id:h13i32maru:20180413135902p:plain

ドキュメントはESDocというJavaScript向けのドキュメンテーションツールで作りました

特にドキュメント周りは力を入れて整備しました。というのも、社内のデザイナー(HTML, CSS, JSに多少触れたことがある人)にも使ってもらえるようにというのを目標の1つにしていたからです。実際、デザイナーとペアプロ的にPrototype Labsを触ってもらい、簡単な画面を作ってもらったりもしました。

そして、Prototype Labsを使って「料理まとめ(自分が投稿したレシピを自由にまとめられるもの)」という機能のプロトタイピングをデザイナーと一緒に行いました。結果、実際に日常使いをしながら議論をすることができ、主要な要件を決めるのに非常に役立ちました。料理まとめはその後、iOS版のクックパッドアプリに実装され、現在、本番環境で元気に動いています。この時一緒にプロトタイピングをしたデザイナーが当時の様子を「React Nativeで作る 「触れるプロトタイプ」の活用」というタイトルで発表しているので興味がある方は見てみてください。

裏クックパッドアプリ(2017冬)

Prototype Labsを作った後、しばらく業務ではReact Nativeを触ることはありませんでした。

一方で、プライベートでは自分の料理レシピをクックパッドに投稿しはじめました。これまでもレシピはGoogle Docsやブログなどに書き散らかしていたのですが、それらをせっかくなのでクックパッドに集約しようと思い、どんどんレシピを投稿していきました。そうするとレシピ投稿者の視点でクックパッドアプリを見るようになり、「今までレシピ投稿者向けの開発はしたことがなかったけど、来年(2018年)はレシピ投稿者向けの開発をしたいな」と思うようになりました。

で、それなら「レシピ投稿者(自分)が使いたくなるクックパッドアプリ」を作ってしまえば良いんだと思い立ちました。またしても鉄は熱いうちに打ての精神で、React Nativeを使ってオリジナルのクックパッドアプリをまるっと作り変えてしまおうと開発にとりかかりました。これが後にMYキッチンアプリの土台となるもので、社内の一部からは「裏クックパッドアプリ」「RNクックパッドアプリ」などと呼ばれることになります。

上述したとおり、裏クックパッドアプリはレシピ投稿者が使いたくなるというのを目指していたので、コンセプトや体験はオリジナルのクックパッドアプリとは大きく異なります。具体的には・・・という話をしたいのですが、ここに書くのは長くなりそうなのと企業秘密というわけで詳細は伏せておきます。この話を聞いてみたい!という方がいらっしゃれば、TwitterのDMなどから是非とも僕までコンタクトしていただければと思います。

モード切替 キッチンモード さがすモード
f:id:h13i32maru:20180413140026j:plainf:id:h13i32maru:20180413140034j:plainf:id:h13i32maru:20180413140040j:plain

特徴は左下のクックパッドアイコン/ユーザアイコンからモードを切り替えるという点です

その他に気をつけたこととしては、オリジナルのクックパッドアプリにある機能はほぼ全て使えるようにするという点です。何故かと言うと、僕は普段使いのアプリをオリジナルから裏クックパッドアプリに完全に移行したいという考えがあったからです。そうしないと、結局オリジナルのアプリを使ってしまい、裏クックパッドアプリが中途半端なものになりうまく改善できなくなってしまうと危惧したからです。

そんなこんなで、コンセプトや体験の見直しをして、それを実現させる機能を実装し、さらに既存の主要な機能の実装を完了させ、僕は裏クックパッドアプリに完全に移行することができました。開発に取り組み始めてから2ヶ月ほどかかりましたが、実際に使った時間は10日間ほどでした。しかもこの期間でAndroidとiOSの両方を作ることができたのもReact Nativeの強みだと思います。裏クックパッドアプリを社内にリリースした時のブログに同僚が以下のようにコメントしてくれました。

10日間でここまで作り上げられるのはプロトタイピングにすごいインパクトがあることだと思いました。

この速さなら自分が鍵だと思ってるコンセプトを形にして提案することで、細かい調整(人的リソース、仕様共に)に時間を取られずに本質的な議論を始めやすくなるように思います。

プロダクト開発って結構一部を変えようと思っても全体を整えていかないといけない(けど時間がないからスコープを絞って細部の変更に留まってしまう)ということがありがちだと思うのでアプリ全体を素早く作り変えて試せるのは大きな価値だと思います。

クックパッド MYキッチン(2018年春)

そして、自分で毎日裏クックパッドアプリを使ってみて、この新しいアプリに未来を感じました。なので、2018年は裏クックパッドアプリを使ってレシピ投稿者向けの改善に取り組んでいくことを決めました。

そこから裏クックパッドを「クックパッド MYキッチン」に改名し、デプロイの仕組みやコードの整理、足りていない機能の追加やデザインの修正、アイコンの作成などを経て、2018年3月にAndroid/iOSともにプロダクションにリリースすることができました。

というわけで、続く3日間ではプロダクションリリースするために取り組んだ技術的な話、アプリアイコンの話、そして最終日はユーザの課題と解決策をどのように探っているかの話を各メンバーが紹介してくれます。お楽しみに!(ちなみに明日からの記事は今回のようなストーリー仕立てではなく、いつもの雰囲気に戻ると思うのでご安心ください)

自己紹介

最後になりましたが、簡単に自己紹介をさせていただきます。

僕は2014年にクックパッドに入社しWebやAndroid周りの機能実装を担当していました。その後に幾つかの機能のPMを担当して、今年から投稿開発部のマネージャー(部長)という役割を担っています。

プライベートではESDoc(JavaScriptのドキュメンテーションジェネレーター)やJasper(GitHub向けのIssueリーダー)というソフトウェアを開発しています。あと、CodeLunch.fmというポッドキャストをやっていたりもします。

僕個人に関してもっと詳しい話はForkwell Pressのインタビュー記事でお話させてもらっているので、興味のある方はご覧ください。


この連載を通して「仮説を素早くプロトタイプにしていく開発」や「React Nativeを使った開発」などに興味を持たれた方がいらっしゃれば、丸山(TwitterのDM)までお気軽にご連絡ください!もちろん、採用ページから応募していただくのも大歓迎です😊

最後に、この記事を読んだ印象を簡単なアンケートでご回答いただけるとうれしいです!

アンケートリンク

*1:ちなみに、プロダクションに使えるかどうかはどちらかわからないというのが当時に意見でした

*2:社内のエンジニア数名にも手伝ってもらいながら

React Native アプリの開発基盤構築

$
0
0

こんにちは、投稿開発部の @morishin127です。React Native 新アプリシリーズ連載2日目ということで、この記事では React Native アプリの開発基盤の構築について書こうと思います。「クックパッド MYキッチン」というアプリは React Native 製で、iOS/Android 両プラットフォームでリリースされています。元々は一人の手で JavaScript (ES2017+) によって書かれていたアプリケーションでしたが、リリースまでの間に開発メンバーも増え、TypeScript の導入や CI の整備、また高速な検証のためにログ収集の仕組み作りや CodePush の導入などを行いました。それぞれ具体的にどのようなことをしたかを説明します。

セットアップスクリプト

npm-scripts を用いて npm run ios:setup / npm run android:setupでそれぞれのプラットフォームでアプリケーションをビルドするための依存関係をインストールできるようにしています。npm-scripts は package.json に定義していて、それぞれの定義は次のようになっています。

{"scripts": {"ios:setup": "cd ios && bundle install && bundle exec fastlane setup",
        "android:setup": "cd android && bundle install && bundle exec fastlane setup",
        ⋮
    },
    ⋮
}

ネイティブアプリのセットアップやバイナリ生成の処理には Fastlaneを用いており、ここで行うセットアップの処理も ios/fastlane/Fastfile / android/fastlane/Fastfileに次のように定義しています。Fastlane は Ruby 製のタスクランナーで、ビルドやストアへのサブミット、スクリーンショットの撮影など様々なタスクを定義して自動化するためのツールです。

ios/fastlane/Fastfile

react_native_root = File.absolute_path('../../')

desc "Install dependencies"
lane :setupdoDir.chdir react_native_root do
    sh "yarn install && yarn run build"end
  cocoapods(use_bundle_exec: true, try_repo_update_on_error: true)
end

android/fastlane/Fastfile

react_native_root = File.absolute_path('../../')

desc "Install dependencies"
lane :setupdoDir.chdir react_native_root do
    sh "yarn install && yarn run build"endend

また次のような npm-scripts でシミュレータでの実行スクリプトを定義しておくと、開発者はリポジトリをクローンしてから yarn run ios:setupyarn run startyarn run ios:run:debugを実行するだけで iOS アプリをシミュレータ上で実行することができて便利です。(Android も同様)

{"scripts": {"ios:run:debug": "node node_modules/react-native/local-cli/cli.js run-ios --simulator 'iPhone SE'",
        "android:run:debug": "cd android && ./gradlew installStagingDebug",
        ⋮
    },
    ⋮
}

TypeScript 導入

開発当初は JavaScript のみでしたが途中で TypeScriptを導入しました。元々の JavaScript コードと混在することになるため tsconfig.jsonでは "allowJs": trueを指定しています。React Native 周りのライブラリは型定義が充実していたため、TypeScript の恩恵を受けながら開発することができました。既存の React Native プロジェクトに TypeScript を導入した手順は morishin/ReactNativePracticeの README.md にまとめたのでそちらをご参照ください。基本的には公式の Microsoft/TypeScript-React-Native-Starterに倣った手順を踏んでいます。

github.com

参考までに、「クックパッド MYキッチン」アプリの tsconfig.jsonは執筆時点ではこのようになっています。ソースコードは srcディレクトリに、tscによりトランスパイルされたコードが libディレクトリに配置され、アプリは lib以下のソースを読み込みます。

{"compilerOptions": {"target": "es2017",
    "module": "es2015",
    "allowJs": true,
    "jsx": "react-native",
    "sourceMap": true,
    "outDir": "./lib",
    "strict": true,
    "skipLibCheck": true,
    "moduleResolution": "node"
  },
  "include": ["./src/"]}

tscによるトランスパイルを自動化するために npm-scripts に build:watchを定義し、開発中はこれを実行した状態でソースコードを編集しています。

{"scripts": {"build": "tsc",
        "build:watch": "tsc --watch",
        ⋮
    },
    ⋮
}

フォーマッタ導入

複数人で開発する際にはフォーマッタがあると便利なので、Prettierを利用しています。フォーマットルールはほぼデフォルトのままです。エディタのプラグイン等で保存時に自動フォーマットがかかるようにしておくと便利かもしれません。

CodePush 導入

「クックパッド MYキッチン」ではサービスの高速な検証のために、バンドルの配信に Microsoft 製の CodePushという仕組みを利用しています。CodePush を利用すると React Native アプリの JS バンドルのみをユーザーの端末に配信することができ、App Store / Google Play Store でアップデートを配信することなくアプリを更新することができます。この特徴はとにかく高速に仮説を検証したいサービス開発者にとって魅力的で、React Native を採用する大きな理由のひとつになると思っています。

導入手順

CodePush の導入手順については公式の通りなので割愛します。

運用

CodePush は一つのプロジェクトに対して複数のデプロイ環境を作ることができ、「クックパッド MYキッチン」では Production, Production-test, Stagingの3つの環境を用意しています。Productionは App Store / Google Play Store で配信されている本番のアプリが JS バンドルを取得する環境、Production-testは社内のみで配信しているアプリが参照している環境、Stagingはチーム内でデザインや動作を確認をするためのアプリが参照している環境です。Production-testアプリと Stagingアプリの違いとして、前者は API サーバーや DB も本番環境を参照しているに対し、後者はバックエンドが本番環境とは切り離されているため、コンテンツの投稿・公開といった動作テストを行うことができるアプリになっています。それぞれの社内配信の方法とデプロイのタイミングについては CI の項で説明します。

画像の Beta 帯アイコンのものが Production-test環境のアプリ、Staging 帯アイコンのものが Staging環境のアプリ、無印が Production環境のストア版アプリです。

f:id:morishin127:20180416150414p:plain

ちなみにこのアイコンの帯は fastlane-plugin-badgeを用いて社内配信の CI ジョブ内で付加しています。

github.com

iOS/Android アプリから CodePush のデプロイ環境を読み分ける

Production, Production-test, Stagingアプリで参照する CodePush の環境を分けていると述べましたが、iOS アプリでは Build Configuration 毎に CodePush の deployment key を切り替えることで、またAndroid アプリでは Build Type / Product Flavor 毎に deployment key を切り替えることでデプロイ環境を読み分けています。React Native アプリの Xcode プロジェクトに Build Configuration を追加するとビルドが通らなくなって苦戦したのでその解決の記録を貼っておきます。同じ問題に遭われた方の参考になれば幸いです。

qiita.com

CI 環境構築

CI マシンではプルリクエストを出したときに実行されるジョブと、プルリクエストを master にマージしたときに実行するジョブがあります。前者のジョブはアプリケーションのテストコードを実行しています。後者の master にマージしたときに実行されるジョブでは iOS/Android アプリのバイナリ生成と社内配信、CodePush の Production-test環境への JS バンドルのデプロイを行っています。CodePush の Staging環境へのデプロイは開発者が手元でビルドしたバンドルを手動でデプロイします。そうすることで master にマージする前のプルリクエスト段階のコードもチームメンバーの Stagingアプリに配信することができ、デザイナとのコミュニケーションに有用です。実際にユーザーさんが触れる CodePush の Production環境へのデプロイは Production-test環境にデプロイされたバンドルを Production環境へコピーする(promote)という形で行われます。promoteの実行はチャットボットを介して Rundeck上のデプロイジョブを実行することで行っています。アプリバイナリのデプロイと CodePush のデプロイの構造をそれぞれ図1, 図2にしました。

▼図1: アプリバイナリのデプロイ

f:id:morishin127:20180416154243p:plain

▼図2: CodePush のデプロイ

f:id:morishin127:20180416154227p:plain

ログ収集

クックパッドには複数のモバイルアプリで共通して利用しているログ収集基盤があり、「クックパッド MYキッチン」のユーザーのイベントログ等の情報もそこへ送っています。ログ収集基盤というのはクックパッドのデータ活用基盤 - クックパッド開発者ブログで触れられている基盤のことで、モバイルアプリは Logend と呼ばれる社内のログ送信用エンドポイントへログを送り、Logend は fluentd を介して Amazon S3 にログを蓄積しています。S3 に蓄積されたログは社内のデータウェアハウスにロードされ、開発者はそこで分析を行います。データ活用の基盤に関して詳しくは上述の記事をご覧ください。

アプリからログを送信するに当たってバッファリングやリトライの機構が必要になりますが、これまでのクックパッドのモバイルアプリでは Puree というライブラリがその機構を担っていました。Puree には iOS/Android 両方のライブラリがありましたが、React Native から利用できる JavaScript 版は存在しなかったため、「クックパッド MYキッチン」の開発に際して作られました。

github.com

Puree に関して詳しくは過去の記事をご覧ください。

おわりに

いかがでしたでしょうか。これから React Native でやっていこうとしているやっていき手の皆さんの参考になれば幸いです。明日は@101kazさんから React Native プロジェクトの Android 環境整備のお話です、お楽しみに!

クックパッドでは毎日の料理を楽しみにするために、より良い技術を選択し、より速くユーザーさんに価値を届けられるサービス開発エンジニアを募集しています。興味を持っていただけましたら是非気軽にご連絡ください。話をしてみたいけど応募はちょっとという方は@morishin127にDMしていただいても大丈夫です🙆

👋

ReactNativeプロジェクトのAndroid環境を整備する

$
0
0

投稿開発部の吉田です。React Native 新アプリシリーズ連載3日目はAndroid担当の私からReactNative(以下RN)のAndroidプロジェクトを出来る限り健康な状態にするために行ったことを紹介します。

手元の環境

RNは開発が活発なため執筆時点の環境を載せておきます。バージョンが乖離している場合は動かない可能性があります。

  • react-native-cli(2.0.1)
  • npm(5.8.0)
  • node(8.9.3)
  • react-native(0.55.1)
  • Android Studio(3.1)
  • macOS High Sierra(10.13.4)

Androidの開発環境はセットアップ済みとして話を進めます。

設定ファイルの掃除

react-native initで生成されたプロジェクトは丁寧な作りになっていますが、コメントが多すぎたり、設定が古かったりするので整えていきます。 RNはAndroidプラットフォームにそこまで依存性がないので、設定ファイルを大胆に書き換えても問題なく動作します。

AndroidStudioでプロジェクトを開く

AndroidStudioを起動してOpen an existing Android Studio projectを選択します。 ディレクトリは$project_root/androidを指定します。

起動すると"Android Gradle Plugin Update Recommneded"というタイトルのダイアログが出てきます。問題ないので更新しましょう。 内容はAndroidの標準のビルドシステムであるGradleの更新とGradleでAndroidProjectをビルドするためのプラグインの更新です。

Gradle pluginの更新が終わるとgradle syncやAndroidStudioのindexingが動き始めます。それらが終わると恐らく右下に下記の警告が現れます。

Configuration 'compile' is obsolete and has been replaced with 'implementation'.
It will be removed at the end of 2018


The specified Android SDK Build Tools version (23.0.1) is ignored, as it is below the minimum supported version (27.0.3) for Android Gradle Plugin 3.1.0.
Android SDK Build Tools 27.0.3 will be used.
To suppress this warning, remove "buildToolsVersion '23.0.1'" from your build.gradle file, as each version of the Android Gradle Plugin now has a default version of the build tools.

警告は2点あります。

  • ライブラリの依存関係を表す文法が変更され、古いcompileというシンタックスは2018年末を目処に削除されること
  • Android-Gradle-Plugin(3.1.0)はAndroid SDK Build Tools(23.0.1)をサポートしていないこと

上の警告は app/build.gradle以下のこの部分の事を指しているので素直に置き換えましょう

 dependencies {
-    compile fileTree(dir: "libs", include: ["*.jar"])-    compile "com.android.support:appcompat-v7:23.0.1"-    compile "com.facebook.react:react-native:+"  // From node_modules+    implementation fileTree(dir: "libs", include: ["*.jar"])+    implementation "com.android.support:appcompat-v7:23.0.1"+    implementation "com.facebook.react:react-native:+"  // From node_modules
 }

二つ目の警告も解決は簡単です。Android SDK Build Toolsについて詳しく触れませんが、Android-Gradle-Plugin3.0以降では指定しなければ常に最新のものが利用されるので該当箇所を削除します。

 android {
     compileSdkVersion 23
-    buildToolsVersion "23.0.1"

     defaultConfig {
         applicationId "com.sampleproject"

SDK Build Tools Release Notes | Android Studio

上記の作業後画面の右上からSync Nowすると全ての問題は解決したので警告は表示されなくなります。

compileSdkVersionとtargetSdkVersionを最新にする

Android開発にはcompileSdkVersionとtargetSdkVersionとminSDKVersionの3つのバージョン概念があります。それぞれの違いについては公式ブログに譲るとして、compileSdkVersionとtargetSdkVersionを最新にします。 (2018年4月地点では27が最新)

diff --git a/android/app/build.gradle b/android/app/build.gradle
index e37c508..e645022 100644
--- a/android/app/build.gradle+++ b/android/app/build.gradle@@ -94,12 +94,12 @@ def enableSeparateBuildPerCPUArchitecture = false
 def enableProguardInReleaseBuilds = false

 android {
-    compileSdkVersion 23+    compileSdkVersion 27

     defaultConfig {
         applicationId "com.sampleproject"
         minSdkVersion 16
-        targetSdkVersion 22+        targetSdkVersion 27
         versionCode 1
         versionName "1.0"
         ndk {
@@ -137,7 +137,7 @@ android {

 dependencies {
     implementation fileTree(dir: "libs", include: ["*.jar"])
-    implementation "com.android.support:appcompat-v7:23.0.1"+    implementation "com.android.support:appcompat-v7:27.1.1" //majorVersionがcompileSDKVersionと一致する必要がある
     implementation "com.facebook.react:react-native:+"  // From node_modules
 }

diff --git a/android/build.gradle b/android/build.gradle
index 3931303..7456eb1 100644
--- a/android/build.gradle+++ b/android/build.gradle@@ -17,6 +17,7 @@ allprojects {
     repositories {
         mavenLocal()
         jcenter()
+        google() //最新のsupportライブラリを取得するためgoogle-repositoryを追加している
         maven {
             // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
             url "$rootDir/../node_modules/react-native/android"

特にtargetSdkVersionを26以上に上げる作業はリリース前に必ずやっておくことをお薦めします。今年中にtargetSdkVersionの低いアプリのリリースや更新が制限される事が公式のアナウンスで発表されています。

targetSdkVersionを上げるとアプリの一部の振る舞いが変わります。一番わかりやすい例はRuntimePermissionの有効化です。他にも細かな振る舞いの変更があるのでこの辺りで一度アプリが正常に動くのかチェックしましょう。

見落としがちな変更にランチャー上のアプリアイコンの見た目の変化があります。(ランチャーアプリによっては想定より小さく表示される) この問題は、roundIconリソースの追加することで解決します。2018年現在ではついでにAdaptiveIcon対応もすることをお薦めします。AdaptiveIconとはランチャーアプリ側でアイコンの外形を決めることの出来る機能です。どちらにせよ対応は難しくないのでデザイナーと相談してandroid:roundIconを設定しましょう。

不要なパーミッションや宣言を取り除く

ReactNativeでAndroidアプリを作ると不要なパーミッションがデフォルトでついているので適切に削除します。

DevSettingsActivityの宣言をリリース版から削除する

DevSettingsActivityは名前の通りデバック用途のActivityです。リリースビルドには必要ないのでapp/src/debug/AndroidManifest.xmlにデバッグ用のマニフェストを作り移動させます。

--- /dev/null+++ b/android/app/src/debug/AndroidManifest.xml@@ -0,0 +1,6 @@+<manifest xmlns:android="http://schemas.android.com/apk/res/android">++    <application>+        <activity android:name="com.facebook.react.devsupport.DevSettingsActivity" />+    </application>+</manifest>diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index c765926..d4b02f4 100644
--- a/android/app/src/main/AndroidManifest.xml+++ b/android/app/src/main/AndroidManifest.xml@@ -28,7 +28,6 @@<category android:name="android.intent.category.LAUNCHER" />
             </intent-filter>
         </activity>
-        <activity android:name="com.facebook.react.devsupport.DevSettingsActivity" /></application>

 </manifest>

SYSTEM_ALERT_WINDOWをリリースビルドから削除する

SYSTEM_ALERT_WINDOWは他アプリの上への描画を許可する強めのパーミッションです。こちらもデバックビルドで利用されるものですがリリースビルドにも紛れ込むので取り除く設定を追加します。

--- /dev/null+++ b/android/app/src/release/AndroidManifest.xml@@ -0,0 +1,8 @@+<manifest xmlns:android="http://schemas.android.com/apk/res/android"+          xmlns:tools="http://schemas.android.com/tools"+        >+    <uses-permission+            android:name="android.permission.SYSTEM_ALERT_WINDOW"+            tools:node="remove"+            />+</manifest>

READ_PHONE_STATEを削除する

ReactNative for Androidはandroid-jscというライブラリに間接的に依存しています。 このライブラリのtargetSDKlevelが4と異常に低い影響でREAD_PHONE_STATEのパーミッションが勝手に追加されます。多くの場合不要なパーミッションだと思うので削除しておきます。

--- a/android/app/src/main/AndroidManifest.xml+++ b/android/app/src/main/AndroidManifest.xml@@ -1,8 +1,11 @@<manifest xmlns:android="http://schemas.android.com/apk/res/android"
-    package="com.sampleproject">+          xmlns:tools="http://schemas.android.com/tools"+          package="com.sampleproject"+        ><uses-permission android:name="android.permission.INTERNET" />
     <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
+    <uses-permission android:name="android.permission.READ_PHONE_STATE" tools:node="remove"/><application
       android:name=".MainApplication"

残りは好みですが不要な設定やコメントやファイルを削除します。

  • 不要なコメントの削除
  • keyStore以下の削除
  • libs以下のjarの参照削除
  • mavenLocalを参照レポジトリから削除
  • BUCKファイルの削除

Clean up project · kazy1991/techlife-rn-android-sample@8366400 · GitHub

apkサイズの最適化

必ずしもやる必要はないですが、ReactNative製のアプリはデフォルトの設定だと不必要にバイナリサイズが大きくなってしまうのでダイエットする方法を2つ紹介します。 私達の開発しているMYキッチンアプリでは下記の二つを行い10MB程度あったapkを5MBまで減らすことが出来ました。

Multiple APKs化

Androidは多種多様な環境の端末で動く可能性があるため、必要になりそうなものは全部詰め込んでいます。Multiple APKsとは環境毎にバイナリを分けることでサイズを削減する方法です。 ReactNativeの場合Native Developer Kit(NDK)の部分が大きいためCPUアーキテクチャ(ABI)毎にバイナリを分けてあげるとapkのサイズがかなり小さくすることが出来ます。 対応が難しそうですが、実はプロジェクトの雛形の中にdef enableSeparateBuildPerCPUArchitecture = falseというフラグが用意されているためtrueに切り替えるだけでMultiple APKsは完了します。

ちょっとした補足ですが、apkを分割する場合PlayConsoleの都合上versionCodeはそれぞれ異なる必要があります。テンプレートではaapt(Android Asset Packaging Tool)コマンドを使って確認しやすいように、既存のバージョンコードに1MB(1048576)を足し合わせた値を採用しています。これは個人の込みのですが、私たちはversionCodeをログ分析などでも利用する都合上パッと見のわかりやすさを重視して既存のversionCode+末尾1桁をABIの識別という形に変更しています。

diff --git a/android/app/build.gradle b/android/app/build.gradle
index f491bca..d8dc9bc 100644
--- a/android/app/build.gradle+++ b/android/app/build.gradle@@ -69,11 +69,11 @@ android {

     applicationVariants.all { variant ->
         variant.outputs.each { output ->
-            def versionCodes = ["armeabi-v7a":1, "x86":2]+            def versionCodes = ["armeabi-v7a": 1, "x86": 2]
             def abi = output.getFilter(OutputFile.ABI)
             if (abi != null) {
-                output.versionCodeOverride =-                        versionCodes.get(abi) * 1048576 + defaultConfig.versionCode+                // 例) version1.1.1のx86のversionCode: 101012+                output.versionCodeOverride = defaultConfig.versionCode + versionCodes.get(abi)
             }
         }
     }

https://developer.android.com/studio/build/configure-apk-splits.html

Proguardの有効化

Proguardとはプロジェクトを静的解析して参照のないコードを削除したり、難読化を行うツールです。 これも詳細は下記のリンクに譲りますが、こちらもdef enableProguardInReleaseBuilds = falseというフラグが用意されているためtrueに切り替えるだけで有効になります。 ネイティブモジュールを多用しない限り嵌まらないと思いますが、正しい設定がされていないままProguardを有効にするとビルドが通らなくなったり、実行時にアプリが意図せずクラッシュすることもあります。 有効に切り替える際には動作確認することをお薦めします。また可能ならProguardの振る舞いについて正しく理解できていると安心できます。

https://developer.android.com/studio/build/shrink-code.html

Stetho(デバッグツール)の導入

StethoはFacebook社が開発しているAndroid向けデバッグツールです。おおまかに言うとAndroid開発でもChromeのdevToolが使えるようになります。 特にNetwork Inspectionはとても便利なので導入しておくことをお薦めします。

f:id:kazy1991:20180409202337p:plain

一般的な導入方法は公式のドキュメントに譲りますが、一点だけ注意点があります。ReactNative:0.45~0.54を利用されている場合公式ドキュメント通りに導入しても動作しません。ReactNativeのバージョンを上げるか下記のリンクのように強引に差し込む必要があります。

Fix NetworkingModule losing Cookies when multiple CatalystInstances e… · facebook/react-native@0a71f48 · GitHub

バージョニングをsemverぽく変更する

ReactNativeを使った開発ではCodePushを利用することも多いと思います。CodePushと相性を良くするためにAndroid側のバージョニングもsemverっぽく管理すると便利です。 AndroidのバージョニングにはversionCodeversionNameがありますがMYキッチンアプリでは下記のように管理しています。

--- a/android/app/build.gradle+++ b/android/app/build.gradle@@ -7,6 +7,7 @@ project.ext.react = [
 ]

 apply from: "../../node_modules/react-native/react.gradle"
+apply from: "${project.rootDir}/gradle/appversion/appversion.gradle"

 def enableSeparateBuildPerCPUArchitecture = false
 def enableProguardInReleaseBuilds = false
@@ -18,8 +19,8 @@ android {
         applicationId "com.sampleproject"
         minSdkVersion 16
         targetSdkVersion 27
-        versionCode 1-        versionName "1.0"+        versionCode project.ext.getVersionCode()+        versionName project.ext.getVersionName()
         ndk {
             abiFilters "armeabi-v7a", "x86"
         }
diff --git a/android/gradle/appversion/appversion.gradle b/android/gradle/appversion/appversion.gradle
new file mode 100644
index 0000000..e9bdb5c
--- /dev/null+++ b/android/gradle/appversion/appversion.gradle@@ -0,0 +1,49 @@+class AppVersion {++    private int major++    private int minor++    private int patch++    AppVersion(int major, int minor, int patch) {+        throwExceptionIfVersionIsNotValid(minor)+        throwExceptionIfVersionIsNotValid(patch)++        this.major = major+        this.minor = minor+        this.patch = patch+    }++    private static void throwExceptionIfVersionIsNotValid(int version) {+        if (version >= 100) {+            throw new IllegalArgumentException("Can't use version number more than three digit")+        }+    }++    int getCode() {+        return major * 100_00_0 + minor * 100_0 + patch * 10+    }++    String getName() {+        return "${major}.${minor}.${patch}"+    }+}++ext {+    def vProperties = new Properties()+    vProperties.load(rootProject.file('version.properties').newDataInputStream())+    def versionMajor = vProperties.getProperty("version.major").toInteger()+    def versionMinor = vProperties.getProperty("version.minor").toInteger()+    def versionPatch = vProperties.getProperty("version.patch").toInteger()+    def appVersion = new AppVersion(versionMajor, versionMinor, versionPatch)++    getVersionCode = {+        return appVersion.getCode()+    }++    getVersionName = {+        return appVersion.getName()+    }+}+diff --git a/android/version.properties b/android/version.properties
new file mode 100644
index 0000000..9f61962
--- /dev/null+++ b/android/version.properties@@ -0,0 +1,3 @@+version.major=1+version.minor=0+version.patch=0

ReactNativeに限らないTips

紹介した内容以外にもReactNativeプロジェクトに限らない変更をいくつか入れているので簡単に紹介します。

おわり

ReactNaiveを使ったアプリ開発は評判通り高速に開発が可能でとても気に入っています。AndroidエンジニアはRNプロジェクトであまり活躍できない印象がありましたが、より安全に(例えば秘匿値の管理)より快適な体験(例えばSmartLock for passwordを使って自動ログインを実現するなど)を提供するために活躍する場面はたくさんあります。

もし私達の取り組みに少しでも興味を持って頂けたらぜひお声がけください。私個人へのDMでも大丈夫です! 😀

明日は@sn_taigaさんから 「クックパッド MYキッチン」のアプリアイコンができるまでのお話です。お楽しみに!

今回作成したプロジェクトのレポジトリはこちらです。

GitHub - kazy1991/techlife-rn-android-sample

「クックパッド MYキッチン」のアプリアイコンができるまで

$
0
0

こんにちは投稿開発部の佐野大河です。React Native 新アプリシリーズ連載4日目はReact Native のお話はしません。今日はその新アプリ「クックパッド MYキッチン」のアプリアイコンの制作過程について書こうと思います。

f:id:sn_taiga:20180418115300p:plain

連載初日の記事でもありましたように、クックパッド MYキッチン(以下、MYキッチンアプリ)は、レシピ投稿者に使いたいと思ってもらうことを目指したアプリです。ベースの機能はクックパッドとは大きく変わらない派生アプリのようなものですが、レシピ投稿者に好んで使い続けてもらえるように、全く新しいアプリとしてユーザーに届けたいという想いがありました。なので、MYキッチンアプリのアイコンを考えるにあたって、通常のクックパッドアプリのブランドイメージを引き継ぎつつも「MYキッチンアプリらしい」シンボルをいちから考えました。 今回はこのアプリアイコン完成までのプロセスを(迷走したことも含めて)紹介していきます。

🏃 まずは情報収集

まず最初にアイデアを出すための情報収集を行いました。MYキッチンアプリのサービス開発の過程で「アプリケーション定義ステートメントシート」というものを作成し、そこからMYキッチンアプリの要素を抽出しました。

アプリケーション定義ステートメントシート

ここではサービスのコアの価値やターゲット、ユースケース、利用シーンなどを定義しています。このシートの使い方については過去の記事でも紹介されているので興味のある方はご覧ください。

ここからサービス名の「キッチン」や「自分の場所」「記録、蓄積」「育てる」「整っている」といったものをキーワードとして上げました。また、サービスのキャッチコピーを「料理を楽しんでいるあなたに」とチームメンバーで定め、これも要素の一つとして取り上げました。

💭 モチーフを考える

これらの情報をもとにアプリアイコンのモチーフを考え始めました。

アイデアが浮かばない

がしかし、初めは思うようにアイデアが浮かばず、理想の形に全く辿り着けませんでした。

ラフスケッチ

ひとまずキッチン周りの道具で思い浮かんだものを一通り描き出してみたり、キーワードから連想する形を描いてそれらを組み合わせたりしながら色々模索しましたが、MYキッチンアプリのモチーフとしてしっくりくるものが全く出てきませんでした。

キーワードから発想を得ようとしても「自分の場所だから家?」「記録だからペン?メモ帳?」と直接的な発想しかできなかったり「こうしたら料理が楽しい雰囲気出そう?」「そもそも楽しいってどういう状態だ?」と具体的なイメージが定まらずにしばらく迷走しました。

f:id:sn_taiga:20180418143042p:plain

それでも現状思い付くものを具体化してみましたがとても納得できるものではありませんでした。

🔍 サービスを理解する

これ以上ペンを動かしても何も出てくる気がしなかったので、あらためてMYキッチンアプリというサービスについて理解しようと立ち戻りました。先輩デザイナーからのアドバイスも受けながら、まずはサービスを表現するためのキーワードの抽出を行いました。

キーワードを抽出する

初めはサービスのコンセプトやキャッチコピーを眺めてそれをただそのまま引用するようなことをしていましたが、視野を広げて「料理」に少しでも関係のありそうな形容詞を一旦洗い出し、その中から「MYキッチンらしい」と思えるワードを絞り込みました。

料理に関係しそうなキーワード

「MYキッチンらしいものは何か」「MYキッチンを使ったユーザーにどう思ってもらいたいのか」という考えのもと、イメージに近いものをオレンジ色に、逆にこれは違うというものを青色にしてチームメンバーに共有しました。メンバーからのフィードバックも受け、最終的にMYキッチンアプリを表すキーワードを以下のように整理しました。

f:id:sn_taiga:20180418142212p:plain

「楽しい」に「充実感」が加わり、自分の作った料理を見返して達成感を得たり自己満足したりするような、そういう楽しさが大事なんだとイメージが具体化しました。また、新しい料理を作っている人たちの「アイデア」や「創造性」「自由さ」といったものもMYキッチンアプリにとって重要な要素だと気づくことができました。

キーワードを視覚化する

整理したキーワードをもとにアイコンのムードボードを作成しました。ムードボードとは、具体的なアウトプットを出す前にデザインの雰囲気やトーンを視覚化したもので、他者との認識のずれをなくしたり、発想の元にしたり、その後の制作の手助けをしてくれます。

f:id:sn_taiga:20180418142350p:plain

ムードボードを作成することで抽象的だったキーワードのイメージが視覚化されていきます。そうすると全体を通して見えてくるものもあり、例えば「静的じゃなく動的」「動的ではあるがカオスまではいかず秩序のとれた形」「単色ではなく複数色」といったように、MYキッチンらしさを表す上でキーになりそうな要素が色々と見えてきました。(このとき、最初に考えた案がいかにMYキッチンらしさを表現できていなかったかをあらためて実感しました)

サービスの人格を作る

アプリのイメージをより固めるために「MYキッチンアプリというサービスを擬人化したらこんな人」を視覚化しました。

サービスの人格

ベースとなる人物やプロフィール写真はチームメンバーが共通で知っている人物を用いると認識のずれが少なくなって良いです。

f:id:sn_taiga:20180418142422p:plain

また、この人格を憑依させたスマホのホーム画面を作り、アイコン案を並べて見てモチーフやクオリティの良し悪しを判断するのに用いました。

✍️ 形にする

ここまで整理した要素を元にもう一度手を動かし始めました。

ラフスケッチ

初めのときに比べ表現したいキーワードのイメージが固まり、ムードボードを作成したことで形の着想もしやすくなりました。キーワードから連想できる形を描き出し、具体化して色を付けたり要素同士を組み合わせたりしながらモチーフのアイデアを出していきました。

ラフスケッチ

結果、波を描いた鍋またはボウルから具材が広がっていく形が、料理をしている人の楽しさや創造性といったものを表現できそうで良いとモチーフの方向性を定めました。また、配色はブランドカラーのオレンジをメインに、アプリの主要機能である「キッチンモード」のテーマカラーに用いている緑と、色相の近い黄色または茶色を段階的に使い、動きを作りつつ全体をまとめる方向で進めました。

f:id:sn_taiga:20180418142457p:plain

上部のパーツを抽象化させ、配色や形状のパターンを出し、その中からbowlの5番が良いとなり最後の詰めの作業に移りました。

🎨 磨き込む

アプリアイコンとしてのクオリティを上げるための磨き込みを行いました。

視認性を上げる

「ホーム画面で他のアプリと並べたときに埋もれてしまわないか」という点を意識し、アイコンの視認性や存在感を上げる修正を行いました。

f:id:sn_taiga:20180418142542p:plain

まず上部のパーツをよりシンプルにしました。パーツ数が減って動きや広がりの印象が弱まった分、葉の大きさに差を付けて奥行きを出したり、緑の明度を若干下げてくっきりさせたり、パーツの向きやバランスを調整したりしました。

f:id:sn_taiga:20180418142611p:plain

柄の形状もいくつかパターンを試し、ホーム画面で見たとき一番視認性の高かった5番を採用しました。

このように形状や色味の調整を行いましたが、まだ他のアプリアイコンに比べて印象が弱いなと思うところがありました。そこで、元々全体のまとまりを出すために配色を3色に抑え段階的に使っていましたが、オレンジや緑に対してアクセントになる色を取り入れて印象を強めることを考えました。

意味を持たせる

印象を強めるのと同時に、色や要素に意味を持たせることも合わせて行いました。

ここまで漠然とボウルや具材と置いていたけど「この人が作っているものは何だろう」という疑問を抱き、「ボウルと泡立器ならスイーツ系かな」「緑は葉を表しているけど黄色の丸は何?」「赤青系の色ならベリーと連想できそうだ」といったように発想し、モチーフと色との意味付けをしました。

そうと決まれば、赤青系の中からベストな色を選んでいきます。

f:id:sn_taiga:20180418142647p:plain

予定通りアクセントの色を加えたことで全体的に印象が強まりました。赤〜青とその周辺の色を試し、サービス人格と比較したりホーム画面に置いたりしながら評価していきました。結果、赤寄りの色は楽しさが出る反面若々しすぎて人格よりも5~10歳程下だなと思ったり、紫寄りは少しお洒落感が出てちょっと違うなと思ったりして、2番の青が良さそうとなりました。

f:id:sn_taiga:20180418142711p:plain

さらに青の中でも微調整を行い、最終的に2_Aが一番アプリのイメージに一致していてこれがベストだと決定しました。

完成

アプリアイコン

あらためてクックパッドMYキッチンのアイコンは、料理を作っている人たちの「楽しさ」や「充実感」、新しい料理を生み出す「豊富なアイデア」や「創造性」「自由さ」、またそれらが蓄積し広がっていく状態を表しています。

🍵 まとめ

今回の制作過程で強く実感したのは「形が出てこない = サービスの理解が足りていない」ということで、もちろん自身の発想力不足というのもありますが、初めは「良い形を思いつかなければ」とアイデアを捻り出す感覚だったのに対し、サービスの理解や整理を徹底した後では「自然に形ができていた」という感覚に近く、最終的に納得のできる形にもっていくことができました。 紹介したプロセスは一例で他にも良い手法が様々あると思いますが、これからサービスのアイコンやロゴを作ろうとしている方の参考に少しでもなれば幸いです。明日はディレクターの五味夏季さんから「料理する人の課題を起点に施策を作る試み」のお話です、お楽しみに!

また、今回作成したアプリアイコンのコンセプトを、アプリ内でも強く実感できるようなサービス開発をこれからどんどん行なっていきます。興味を持たれた方がいらっしゃれば採用ページを是非ご覧ください。軽く話を聞いてみたいという方は@sn_taigaにDMしていただいても大丈夫です😄

料理をつくる人はどんな課題を抱えているのか? 〜ユーザーの課題を施策につなげるインタビューの取り組み〜

$
0
0

こんにちは。投稿開発部 ディレクターの五味と申します。 初日の記事から5日間に渡ってお届けしてきた「クックパッド MYキッチン」の連載も、いよいよ今回が最終回です!

私たち投稿開発部では、クックパッドユーザーの中でも特に「レシピを投稿するユーザー」にとって最適なアプリを追求するために、「クックパッド MYキッチン」アプリをリリースしました。ではこれからそこで、ユーザーにどのような体験や機能を提供していくべきでしょうか。

今回は、ユーザーの課題を起点に次の施策を発想していくために行なっている、ユーザーインタビューの取り組みについて紹介します。

f:id:natsuki53:20180419194028p:plainApp Store / Google Play

料理をつくる人はどんな課題を抱えているのか

投稿開発部は現在「クックパッドにレシピを投稿するユーザーを増やすこと」を目標とし、その戦略として「レシピ投稿の継続率を改善すること」と「レシピを投稿し始める人を増やすこと」の2点に注力することを決めています。

施策を考える上では、目先の数字を稼ぐのではなく、レシピ投稿を、何らかユーザーの課題を解決する、ユーザーが使いたくて使う手段にすることを重視しています。それを実現する指針を得るためには、料理をつくる人が抱えている課題を知り、できるだけ深く理解をする必要があります。

チームではそのため、アプリ開発と同時並行でユーザー調査を進めてきました。

調査の計画:ユーザーの実際をもっと知りたい

ところで投稿開発部は2018年に新設された部署です。メンバーは部長含め、サービス開発の経験はあれど、レシピ投稿者にフォーカスして取り組むのは初めての面々でした。さらに部内には、ユーザーインタビューを通してユーザーの課題を発見するための設計をしたことがある人もいません。そのため、部の発足当初から、定性的なユーザー調査に積極的に取り組むことは計画していました。

2月に入り「クックパッド MYキッチン」アプリの開発目処が立ってきた段階で、計画の実行に着手しました。投稿開発部のメンバーは7名と多くはないのですが、知見を自分たちのものにするためにも、インタビュー対象者のリクルーティングからすべて自分たちの手で行うことにしました。

まず有効な調査手法を手っ取り早く学ぶため、『ユーザビリティエンジニアリング*1』を参考図書とさせていただきました。書籍を読んで、サービス開発で重用する定性的ユーザー調査は大きく3つに大別されると考えました。

▼私の理解は以下の通りです f:id:natsuki53:20180419192007p:plain

当時ようやく主要機能が動くようになっていた「クックパッド MYキッチン」アプリがある状況で、投稿開発部としてユーザー調査を始めるなら、そのアプリを用いたユーザビリティテストから行うことが順当に思えました。

開発チーム全員が参加するインタビューに

インタビューを始めるにあたり意識していたことが、この取り組みにエンジニア含めたチーム全員が直接関わり、ユーザー理解を一緒に深められるようにすることです。

そのため、すべてのインタビューに中継機材とカメラを用意し、別室に待機するチームメンバーがインタビューの様子を同時中継で見られるようにしました。モニター室では各自メモを取ってもらいながら、ユーザーに聞きたいことがあれば、ファシリテーター役をしている私のPCにチャットで質問を入れられるようになっています。これによって、メンバー全員が当事者としてインタビューに関わることができるようになりました。

インタビュー開始と早々の方針見直し

ユーザーのリクルーティングや調査票・スクリプトの設計、会議室や機器設置に手間はかかったものの、リハーサルなども行いながら、何とか3週間程度でインタビューの開始に漕ぎつけました。しかも実際テストを開始してみると、新しいアプリは思った以上に円滑に操作され、用意したタスクリストは大きな問題なくこなされ、上々に過ぎていきそうに見えます。しかしその反面、ポジティブな反応も希薄なことが徐々に気がかりになってきました。

チームの興味は段々、アイスブレイクとしてヒアリングしていたユーザープロファイルに移っていきました。思えばチームメンバーにとって、「既存/潜在作者」とされる社外のユーザーにきちんと話を聞くのはそれが初めてだったのです。その人物像や生活背景への自分たちの理解の浅さに気がつくまで、時間はかかりませんでした。

結局私たちはインタビュー開始3日目には、開発中のアプリのユーザビリティよりもずっと前の段階に自分たちの問題点があると判断し、テストは早々に切り上げ、ターゲットユーザーの日常生活に潜む課題を見つけるための生成的調査へ転換することにしました。

生成的調査の試行錯誤

既に存在するプロトタイプの調査をするユーザビリティテストに比べ、まだ目に見えないユーザーの課題を対話の中で引き出す生成的調査は、難易度が高いと思います。この種のインタビューは漫然と行うと、得られる学びも漫然のまま終わることは経験してきているため、やるからには確固たる学びを得たいと考えていました。

- 苦慮したポイント1:設問設計

設問設計には当初から苦心しました。先述の『ユーザビリティエンジニアリング』には、ユーザーとの対話の中からキーワードを見つけて根掘り葉掘り質問をしていく手法をお薦めされており、「事前にインタビューガイドを作っても絶対にその通りにインタビューするな」と書かれています。しかし私たちのインタビュー対象者は、普段実際にクックパッドをご利用いただいているユーザーさんでもあるので、会話が途切れたり失礼を働くことが怖くなってしまい、結局、電話営業のトークスクリプト並みにがっちりと設問を並べた台本を用意してしまいました。

- 苦慮したポイント2:インタビュー後の振り返り

インタビュー後の振り返りの仕方にも悩みました。そもそもの課題抽出を目的とする生成調査は、検証調査やユーザビリティテストのように、答えを出す項目が先に存在するわけではありません。事前に想像できる範囲には限りがあるため、結局最初の1〜2回のインタビューを実地演習と割り切っていくつかの手法を試すしかありませんでした。

振り返りは、モニター室でインタビューを見ていたメンバーのふせんメモとホワイトボードを使うのが効率的です。ふせんメモをネガティブ/ポジティブで整理してみたり、対話中に見せたプロトタイプの種類別に整理してみたり、いくつかパターンを試した結果、ユーザーに語られたエピソードからメンバー各自気になったものを出し合い、「事実」と「(ユーザーご自身の)意見」に分けて整理する手法に今は落ち着いています。

「事実」「意見」ごとにKJ法を用いて重要そうなキーワードを見出したら、その周辺のエピソードを洗い出し、背景にある動機を想像して、各対象者が持っている「料理に関する課題」を推察して書き出せたら、その回の振り返りは完了です。

▼振り返り時の板書のイメージ(内容は架空です) f:id:natsuki53:20180419192140p:plain

振り返りの手法が固まると、インタビューの構成も、こちらが用意する質問に広く浅く答えてもらうのではなく、ユーザーの言葉で普段の料理や食事を取り巻く状況を語ってもらうものになっていきました。それに従い、設問もおのずとシンプルになります。当初20問近くあった設問が、最終回のインタビューでは2問まで減ったのは、とても印象的な出来事でした。

f:id:natsuki53:20180419192214p:plain

得た学びを確実にする作業:総括レポート

ユーザーの普段の料理の状況を根掘り葉掘り聞き出し、インタビューごとにチームで振り返りを行って、彼らが料理に関して持っている重要な課題を書き出す手法により、個別のインタビュー対象者ごとの理解は深められるようになりました。対象にしたユーザーごとに書き出した課題をリスト化して、次の施策を考えるネタにもできそうです。

しかし、インタビュー全体としての評価はどうでしょうか?得られた学びが何だったか、明言はできるでしょうか?コストをかけてしっかり行ったインタビューなので、結果はうやむやにせず、チーム全員で得た学びを確実にものにしたいです。

そこで私たちは、普段プロジェクトごとに作成している施策設計・評価用のレポート「report.md*2」を、インタビュー調査でも作成するようにしました。

インタビュー調査は機能開発とは施策設計が異なるため、report.mdの構成も少々見直し、以下の項目で作成しています。

- 目的
- 対象ユーザー
- スクリプト
- 議事録
- 結果
- 次のアクション

インタビュー調査の計画時・完了時にこのレポートをGitHub Enterpriseの自部署のリポジトリにPull Requestで作成し、チームメンバーにレビューしてもらいます。こうして1枚の簡潔なレポートにまとめることで、調査計画も、全回のインタビューを経た考察や結論も明確に提言され、またチームメンバー間での理解の差も埋められ、結果を次の施策に生かしやすくなりました。

「クックパッド MYキッチン」での検証へ

私たちはいま、このインタビュー調査から得たユーザーの課題リストを元に、次に注力するユーザー課題を絞って取り組んでいます。メンバー全員が一連のインタビューの様子や結果を共有しているため、調査以前に比べてターゲットの人物像から具体的に解決策のイメージが湧くようになったことを実感しています。

ここから得た理解やアイデアを元に、次の施策のプロトタイプを鋭意進めています。近日中に「クックパッド MYキッチン」アプリで検証を始める予定でおりますので、リリースを楽しみにお待ちください。

アプリのインストールはこちらから! App Store / Google Play

終わりに

5日間に渡りお届けしてきた「クックパッド MYキッチン」の連載記事、いかがでしたでしょうか。最後に今一度、これまでの連載記事と、執筆した開発メンバーを紹介させていただきます!

クックパッドMYキッチンアプリ、ならびに、投稿開発部にご興味を持ってくださった方、一緒に開発に取り組んでくださるメンバーを募集中です!

採用サイトまで、お気軽にお問い合わせください!!

*1:橋本徹也 著『ユーザビリティエンジニアリング(第2版)―ユーザエクスペリエンスのための調査、設計、評価手法』amazon

*2:クックパッドのサービス開発チームで行なっている、施策レポートの取り組み。詳細は私の昨年の記事で触れています。

Viewing all 726 articles
Browse latest View live