株式会社ZOZOのブログ - TECH PLAY

TECH PLAY

株式会社ZOZO

株式会社ZOZO の技術ブログ

1025

こんにちは。 データチームの後藤です。 A/Bテストはサービス改善のための施策の効果測定に欠かせないツールですが、最近のVASILYでは、運用するサービスが増えてきたことに伴いA/Bテストの内容も多様化してきました。今回はそのA/Bテストにベイズ推論を用いた具体的な例を紹介します。 問題設定 あるサービスのコンバージョン率を上げるため、コンバージョンの前提となる行動Xを増やすための修正を実施しました。ここで、「コンバージョン」は商品の購入などの成約を、「コンバージョン率」は利用数に対して成約に結びついた割合を、「行動X」は買い物カゴに商品を入れるなどコンバージョンの前提となる行動のことを指すことにします。修正がコンバージョン率の上昇に寄与したのかをデータから判断する必要があります。 修正後のページ(パターンA)を表示したグループと、修正前のページ(パターンB)を表示したグループの行動ログを集計し、以下の3つの仮説を検証します。各パターンでは、表示したページ以外の条件は同じであると仮定します。 仮説 パターンAはパターンBより行動Xを取りやすい パターンAはパターンBよりコンバージョン率が高い パターンAはパターンBよりコンバージョンに至るまでの時間が短い これらの仮説を検証する際、通常は平均値の差の検定や母比率の差の検定などの仮説検定が用いられます。今回は、ベイズ推論のフレームワークを利用しビジネスサイドが状況を判断しやすい結論を出していきます。 ベイズ推論を使うメリット ベイズ推論を利用した場合、従来の仮説検定と比べて以下のメリットが考えられます。 前提知識や常識を事前分布として反映できる 推定値の分布に正規性を仮定していないので、より柔軟な区間推定ができる データの生成過程をモデリングすることにより、得られた結果からストーリーを語ることができる PyMC3とは PyMC3はPythonでベイズ推論を実行できるフレームワークです。 http://docs.pymc.io/index.html 公式ドキュメントのExampleをみるとわかるように、様々な機能が実装されています。今回のようなA/Bテストに用いるだけでなく、GLMやMixture Modelの推論など様々な用途に利用できます。今回はPyMC3の生成モデルの定義とMCMCによるサンプリングの機能を利用します。 仮説1: パターンAはパターンBより行動Xを取りやすい 現実的には、ユーザーはサービスの利用開始から様々な過程を通じて行動Xに至る(あるいは、至らない)と考えられます。問題を単純にするために、それらを一旦無視して、利用開始から24時間以内に行動Xを1回でも取ったかどうかを焦点とします。 行動Xを取ったユーザーを1、行動Xを取らなかったユーザーを0と数値化することにより、これらのサンプルが確率 のベルヌーイ分布から生成されると考えます。また に関しての情報はないので、事前分布として一様分布を採用します。 データの集計結果は以下のようになりました。 - グループA - サンプル数: 1602 - 行動Xを取った数: 740 - 平均: 0.462 - グループB - サンプル数: 2121 - 行動Xを取った数: 933 - 平均: 0.440 グループAのほうが行動Xを取った割合が高いので、修正の効果が出ているかもしれません。 PyMC3のフレームワークを使えば、今回のモデルを以下のように記述できます。行動Xを取る確率が増えたかどうかも知りたいので、 という と の差を新たな生成量として定義します。 モデル import pymc3 as pm with pm.Model() as model: # 事前分布として一様分布を採用 p_A = pm.Uniform( 'p_A' , 0 , 1.0 ) p_B= pm.Uniform( 'p_B' , 0 , 1.0 ) # 各行動はベルヌーイ分布から生成される obs_A = pm.Bernoulli( "obs_A" , p_A, observed=sample_A) obs_B = pm.Bernoulli( "obs_B" , p_B, observed=sample_B) # 新たな生成量として行動を起こす確率の差を定義 delta_prob = pm.Deterministic( 'delta_prob' , p_B - p_A) 推論 with model: start = pm.find_MAP(fmin=optimize.fmin_powell) step = pm.Slice() trace = pm.sample( 20000 , step=step, start=start) _ = pm.traceplot(trace) _ = pm.plot_posterior(trace) 、 とその差 の標本は均衡分布に収束していることが確認できます。各変数の分布も左右対称のベル型をとっていることもわかります。 pm.summary(trace) mean sd mc_error hpd_2.5 hpd_97.5 n_eff Rhat p_A 0.461861 0.012475 0.000059 0.437458 0.485777 39737.0 0.999980 p_B 0.439937 0.010784 0.000055 0.418653 0.460909 37254.0 1.000117 delta_prob 0.021924 0.016421 0.000081 -0.009180 0.055029 38586.0 1.000014 解釈 実際に となるサンプルの数を数えて、Aのほうが行動Xを取りやすいと言える確率を算出します。サンプリングした 、 を用いて、 が成り立つ場合に1を、成り立たない場合に0をとる変数 を定義し平均を取ります。 (trace[ 'p_A' ] - trace[ 'p_B' ] > 0 ).mean() 0.90927500000000006 グループAのほうがグループBに比べて行動Xを取りやすいと言える確率は約91%で、高い確率で修正の効果があったと言えそうです。 と の平均値は約2%ほど差があるので、修正の結果行動Xをとるユーザーを2%ほど増やせた可能性が高いです。 仮説2: パターンAはパターンBよりコンバージョン率が高い 仮説1ではコンバージョンの前提となる行動Xをとるユーザーの割合について扱いましたが、コンバージョン率についても同様の問題設定で対応することができます。 モデル with pm.Model() as model: # 事前分布として一様分布を採用 p_A = pm.Uniform( 'p_A' , 0 , 1.0 ) p_B= pm.Uniform( 'p_B' , 0 , 1.0 ) # 各行動はベルヌーイ分布から生成される obs_A = pm.Bernoulli( "obs_A" , p_A, observed=sample_A) obs_B = pm.Bernoulli( "obs_B" , p_B, observed=sample_B) # 新たな生成量として行動を起こす確率の差を定義 delta_prob = pm.Deterministic( 'delta_prob' , p_A-p_B) 推論 with model: start = pm.find_MAP(fmin=optimize.fmin_powell) step = pm.Slice() trace = pm.sample( 20000 , step=step, start=start) _ = pm.traceplot(trace) _ = pm.plot_posterior(trace) pm.summary(trace) mean sd mc_error hpd_2.5 hpd_97.5 n_eff Rhat p_A 0.114902 0.004827 0.000025 0.105367 0.124348 39673.0 0.999975 p_B 0.106540 0.007789 0.000036 0.091163 0.121687 38234.0 0.999989 delta_prob 0.008362 0.009185 0.000043 -0.009795 0.026113 38091.0 0.999987 (trace[ 'p_A' ] - trace[ 'p_B' ] > 0 ).mean() 0.82094999999999996 解釈 パターンAがパターンBより優れている確率の推定値は約82%となりました。パターンAが優れていると完全に言い切るのは難しい結果になりました。一方で、パターンAはパターンBと比べて極端に劣る可能性は低いと考えられます。仮説1での主張と合わせることで、今後はパターンAを採用するという判断ができるかもしれません。 別解 サンプル数を 、コンバージョン数を とすると、Xは二項分布に従います。事前分布に二項分布の共役事前分布であるベータ分布を採用すると、事後分布が解析的に求まるため、MCMCを使うことなくサンプルすることができます。事前分布に 、 のベータ分布(一様分布)を用いると、事後分布は以下の式に従います。 from scipy.stats import beta visitors_to_A = 4297 conversion_from_A = 397 visitors_to_B = 1584 conversion_from_B = 156 alpha_prior = 1 beta_prior = 1 posterior_A = beta(alpha_prior + conversion_from_A, beta_prior + visitors_to_A - conversion_from_A) posterior_B = beta(alpha_prior + conversion_from_B, beta_prior + visitors_to_B - conversion_from_B) samples = 20000 samples_posterior_A = posterior_A.rvs(samples) samples_posterior_B = posterior_B.rvs(samples) prob = (samples_posterior_A < samples_posterior_B).mean() 仮説3: パターンAはパターンBよりコンバージョンに至るまでの時間が短い 長期間コンバージョンに至らないユーザーをコンバージョンするまで追い続けるのは現実的でないです。そのため、データを用意する際に何らかの工夫が必要になります。ここでは、M日以内にコンバージョンしない場合はデータセットから除外するという処理を行いました。この制限を設けたことにより、データの分布はM日目より先が打ち切られた若干歪な分布を持つことになります。 今回はM=9としてデータセットを作成しました。頻度の低い現象が発生するまでにかかる時間間隔の分布を取り扱っているため、指数分布に従っていると考えられます。まずは分布を確認します。 コンバージョンに至るまでの時間の分布(グループA) コンバージョンに至るまでの時間の分布(グループB) sample_A_size: 146 sample_B_size: 391 mean_A: 1.87 mean_B: 1.78 まずはこのデータが指数分布から生成されたと仮定して、ベイズ推論を行います。指数分布のパラメータ の事前分布は一様分布を採用しています。 モデル with pm.Model() as model: # Priors for unknown model parameters lambda_A = pm.Uniform( 'lambda_A' , 0 , 5.0 ) lambda_B= pm.Uniform( 'lambda_B' , 0 , 5.0 ) obs_A = pm.Exponential( "obs_A" , lambda_A, observed=sample_A) obs_B = pm.Exponential( "obs_B" , lambda_B, observed=sample_B) ave_A = pm.Deterministic( 'ave_A' , 1 /lambda_A) ave_B = pm.Deterministic( 'ave_B' , 1 /lambda_B) delta = pm.Deterministic( 'delta' , ave_A - ave_B) 推論 with model: start = pm.find_MAP(fmin=optimize.fmin_powell) step = pm.Slice() trace = pm.sample( 20000 , step=step, start=start) _ = pm.traceplot(trace) 結果 mean sd mc_error hpd_2.5 hpd_97.5 n_eff Rhat lambda_A 0.536024 0.043165 0.000222 0.450473 0.619218 40000.0 0.999990 lambda_B 0.583912 0.041367 0.000202 0.504196 0.666190 38614.0 0.999978 ave_A 1.877783 0.152392 0.000807 1.589442 2.181460 39591.0 0.999995 ave_B 1.721235 0.122699 0.000598 1.483570 1.961930 38377.0 0.999986 delta 0.156548 0.196036 0.001019 -0.213939 0.555821 39552.0 1.000006 ave_Aとave_Bのmeanと実際のデータの平均が一致していており一見良い推定結果が得られたように見えますが、モデルにデータの打ち切りの過程を含められていないため、推定されるパラメータにバイアスがかかっていると考えられます。 そこで推定する分布関数そのものをデータに合わせて定義し直してみます。具体的には指数分布の9日以降の部分を切ったTruncatedExponentialを定義します。 TruncatedExponential class from pymc3.distributions.dist_math import bound from pymc3.distributions import draw_values, generate_samples import theano.tensor as tt import scipy.stats.distributions class TruncatedExponential (pm.Continuous): def __init__ (self, lambda_, *args, **kwargs): super ().__init__(*args, **kwargs) self.mode = tt.minimum(tt.floor(lambda_).astype( 'float32' ), 1 ) self.lambda_ = lambda_ = tt.as_tensor_variable(lambda_) def te_cdf (self, lambda_, size= None ): lambda_ = np.asarray(lambda_) dist = scipy.stats.distributions.expon() lower_cdf = dist.cdf( 0 ) upper_cdf = 1 nrm = upper_cdf - lower_cdf sample = np.random.random(size=size) * nrm + lower_cdf return dist.ppf(sample) def random (self, point= None , size= None ): lambda_ = draw_values([self.lambda_], point=point) return generate_samples(self.te_cdf, lambda_, dist_shape=self.shape, size=size) def logp (self, value): lambda_ = self.lambda_ p = pm.math.log(lambda_) - lambda_ * value - pm.math.log( 1 - pm.math.exp(- 9 *lambda_)) log_prob = bound( p, lambda_ >= 0 , value >= 0 ) # Return zero when mu and value are both zero return tt.switch( 1 * tt.eq(lambda_, 0 ) * tt.eq(value, 0 ), 0 , log_prob) 推論 with pm.Model() as model: # Priors for unknown model parameters lambda_A = pm.Uniform( 'lambda_A' , 0.0 , 5.0 ) lambda_B= pm.Uniform( 'lambda_B' , 0.0 , 5.0 ) TE( 'obs_A' , lambda_=lambda_A, observed=sample_A) TE( 'obs_B' , lambda_=lambda_B, observed=sample_B) ave_A = pm.Deterministic( 'ave_A' , 1 /lambda_A) ave_B = pm.Deterministic( 'ave_B' , 1 /lambda_B) delta = pm.Deterministic( 'delta' , ava_A - ave_B) start = pm.find_MAP(fmin=optimize.fmin_powell) step = pm.Slice() trace = pm.sample( 20000 , step=step, start=start) 結果 mean sd mc_error hpd_2.5 hpd_97.5 n_eff Rhat lambda_A 0.314618 0.060387 0.000309 0.194953 0.431916 37317.0 1.000036 lambda_B 0.364973 0.038134 0.000196 0.288430 0.438147 40000.0 1.000004 ave_A 3.310105 0.724105 0.003919 2.142754 4.715704 30976.0 1.000038 ave_B 2.770713 0.298075 0.001547 2.207913 3.351493 37732.0 1.000012 delta 0.539392 0.782097 0.004153 -0.799306 2.125230 31161.0 1.000069 指数分布を仮定した場合に比べて、推定された平均値が高くなっていることがわかります。データ取得の事情を反映したTruncatedExponentialを使って指数分布のパラメータ を推定したことによる効果だと考えられます。 と の分布は、右側に伸びた左右非対称なものとなっています。9日以降のデータが無いために平均値の高い側が定まりにくいという事情を反映していると考えられます。 コンバージョンまでにかかる平均時間のグループ間の差は0を中心に分布していることがわかります。この結果から、コンバージョンに至るまでの時間短縮への寄与があるとは言えないと判断できます。 まとめ PyMC3を使って、ベイズ推論によるA/Bテストを行いました。今回のデータセットを分析した結果、修正により行動Xは増加しており、コンバージョン率は上がる可能性が高いが、コンバージョンに至るまでの時間の短縮には繋がっているとは言えない、と判断できました。 データの生成過程をより単純なものに置き換えたり、分布関数を定義し直すなどの工夫を取り入れ、各命題に直感的な解釈を与えることができたかと思います。 最後に 弊社では、画像データや行動ログなど多様なデータを扱い、ビジネスサイドの意思決定を支える分析を行います。 多様なデータの分布を捉え、収益へ貢献できるデータサイエンティストを募集しています。 https://www.wantedly.com/projects/109351 www.wantedly.com
こんにちは、VASILYでエンジニアインターンをしている劉です。 僕は昨年の四月、大学一年生になりました。その後、自分でプログラミングを学ぼうとしましたが行き詰まり、六月に思い切ってVASILYのインターンシップに応募しました。それから半年以上働いてみて、あの時思い切って応募してよかったと思うことが多くありました。今日はそのいくつかを紹介したいと思います。 エンジニアインターンシップのいいところ 1. コードが綺麗になる 一人でプログラミングの勉強をしていると、とりあえずプログラムが動けばいい、という考えに陥りがちです。その結果、そのコードをどのように書くか、という部分まで気が回らないことも多々あります。 しかしプログラムというのは面白いもので、同じ動作をするものでもコードの書き方は一通りになりません。人の数だけコードがあると言っていいでしょう。そしてコードの書き方をちょっと変えるだけで、ちょっと見たときに意味がわかりやすくなったり、後から変更を加えやすくなったりします。こういう書き方を積み重ねていくと、プログラムを作るスピードはどんどん上がっていき、作る作業もどんどん楽しくなっていきます。 エンジニアインターンでは、コードを書くたびにメンターとなった社員の方がレビューをしてくれます。ちゃんと動くかだけでなく、この書き方で本当によいのかまでしっかり見て指導を行ってくれます。インターンを続けていくことで、自分でもわかるほどはっきりとコードが綺麗になっていくのを感じるはずです。 VASILYに来る前の僕は、 典型的な「動けばいいや」の考え方の人でした。口で説明するよりも、その時期に僕が書いたコードの一部を見ていただいた方がよくわかると思います。 class WifiSpot < ApplicationRecord reverse_geocoded_by :latitude , :longitude def self . return_jsons (validator) if validator.range.present? @range = validator.range.to_f/ 1000 else @range = 0.5 end if validator.refining.present? @refining = validator.refining.to_i else @refining = 5 end if validator.address.present? @origin = validator.address else @origin = [validator.latitude,validator.longitude] end @language = validator.language @wifi_spots = self .near( @origin , @range ).limit( @refining ) jarray = [] for i in 0 .. @wifi_spots .length- 1 do jarray.push( @wifi_spots [i].response_format( @origin , @language )) end return jarray end def response_format (origin,language) @distance = ( self .distance_from(origin)* 1000 ).to_i if language.eql?( " english " ) @address = self .english_address @name = self .english_name else @address = self .japanese_address @name = self .japanese_name end wifi_spot = { " name " : @name , " distance " : @distance , " address " : @address } return wifi_spot end end 謎の空行がたくさん入っていたり、コードを書き始める位置がグラグラしていたりしてしまっています。あまりプログラミングをよく知らない人でも、なんとなくこのコードが「汚い」ことはおわかりいただけると思います。ちなみに、このコードが入ったファイルはこんな調子で595行も続いていました。今考えると恐ろしいです。 そんな僕が最近個人的に書いたコードがこちらです。 class OrderedItem < ApplicationRecord belongs_to :item belongs_to :order validates :number , numericality : { greater_than : 0 } def self . make_models_from_hashes (hashes) return [] if hashes.blank? hashes.map do | hash | new_hash = {} item = Item .find(hash[ ' item_id ' ]) new_hash[ :item ] = item new_hash[ :shop ] = item.shop new_hash[ :number ] = hash[ ' number ' ] new_hash end end def self . calculate_sum (model_hashes) model_hashes.sum {| hash | hash[ :item ].price * hash[ :number ]} end def session_hash { ' number ' => number, ' item_id ' => item_id} end def valid_cart? temp_order = Order .new self .order = temp_order valid? end def cancel update( canceled : true ) if order.ordered_items.all?{| item | item.canceled} order.update( deliveryman_done : true , user_done : true ) end end def cancellable? (user) return false unless order.deliveryman == user return false if canceled return false if order.he_done?(user) true end end 行なっている処理の違いもありますが、前のコードよりもなんとなくスッキリした印象が感じられるかと思います。自分で言うのも何ですが、改善した点をいくつかあげておきます。 ブロックを作るときにしっかりインデントをするようになった インデントの幅が統一された メソッドごとに空行を入れたことでコードがみやすくなった 手続き的にだらだら続く処理を別メソッドに切り出した 意味もなく他のコードに書いてある記法を続けなくなった(ここではインスタンス変数を作る"@") Rubyの組み込み関数を使って処理を行うようになった これらは、多くの人にとっては当たり前のことかもしれません。しかし、その当たり前のことを当たり前にできるようになったのは、インターンのおかげに他なりません。少しでも自分のコードが「綺麗なコード」に近づけたことを、とても嬉しく思います。 2. 何を勉強すればいいかがわかる 現在の世の中には情報が溢れています。それはプログラミングの世界でも同様で、初心者向けか上級者向けかに関わらず、世界中の人が新しい技術を学ぶための情報を日々発信し続けています。 そんな世界で勉強を進めようとすると、自分の現在のレベルに適した情報を見つけられないことが多々あります。新しいことを学ぼうとPCを開いたのに、自分には全く理解できない情報かわかりきった情報しか見つからずそっとPCを閉じる、そんな経験をしたことがある人も多いはずです。 エンジニアインターンをしていればそんなことはありません。一緒に働く社員の皆さんは確かなスキルを持った一流のエンジニアですし、一緒に働く中でインターン生のスキルも正確に把握してくれます。今足りないスキルから、それを得るための良い本やWebページまで、自分で学んでいくための十分なアドバイスがもらえるはずです。 冒頭にお話しした通り、インターンに来る以前の僕は完全にプログラミングの学習に行き詰まっていました。当時利用していたWebサービスで一通りプログラミングの入門コースを終わらせて、いざ自分で新しいことを学ぼうとすると、見つかる情報は初心者の僕には訳のわからないものばかりでした。何かを学びたいのに、適切な難易度のものが見つからなくてできない、それを歯がゆく思う日々が続きました。 そこでインターンシップに応募、合格すると、即座に綺麗なコードを書くための指針となる本を紹介されました。その後も、プログラムの設計についての本や絶対に知っておきたい基礎技術を解説した本やWebページなど、様々なものを紹介していただきました。それらを読み吸収した結果、最近はインターンに来る前とは比べ物にならないほど自分に知識がついたのを実感できます。それを実感するたびに、インターンに来てよかったと僕は思うのです。 3. 個人では触れることのない技術に触れられる より良いプログラムを作るために、今でも世界中で様々な新技術が生み出されています。中には仕事でプログラムを作るのにはほとんど必須と言っていいものも多々あります。しかしそのほとんどは、個人で勉強していると触れることのないものです。 例えば有償のサービスには手が出しづらいでしょうし、複数人で開発をしないと恩恵を受けにくい技術もあります。使うための初期設定を面倒に感じ使うのをやめてしまうこともあるでしょう。しかし、そういった理由で世の中で広く使われている技術に一切触れないのは非常にもったいないことです。 エンジニアインターンで働く場所は、実際に世の中でサービスを動かしている企業です。当然様々な技術が現場で使われていて、インターン生もそれに触れることができます。もちろんそれらにお金を払うのは企業なので、金銭的な心配はいりません。複数人開発のための技術の恩恵も最大限に享受することができますし、面倒な初期設定を自分でする必要もありません。様々な技術に触れるのに理想的な環境だと言えるでしょう。 僕は初めてインターンにやって来たとき、いまや全エンジニアに必須とも言えるバージョン管理システムであるGitすら満足に扱うことができませんでした。しかしインターン生として働く中で、Gitはもちろん、その他にもAWSやGCPの各サービスや種々のCIツールなど様々な技術に触れて来ました。おそらくあのまま個人で勉強していたら、これらの技術には触れることがなかったでしょう。貴重な経験ができていることに感謝しています。 4. 目標を高く保てる 一人でプログラムを書いていると、現在自分にどの程度の能力があるかを感じることはほとんどありません。特に、周囲の友人に同じようなことをしている人がいないとき、その傾向は顕著になります。こうした状況にあると、ちょっと動くプログラムを作れただけで、そんな自分に満足してしまいます。しかし、現状に満足してしまえば、その人はほとんど成長しなくなります。 エンジニアインターンでは、日々世の中に送り出すサービスを作り続けるプロの社員の方々と働くことになります。綺麗なコードを非常に素早く書ける上、様々な分野に深い知識を持つ彼らに、毎日驚かされることになると思います。 また、インターンを募集している会社には何人ものインターン生がやって来ます。自分から進んでインターンに応募する彼らはやはり意欲、能力共に高いことが多く、同世代であっても見習うべきところが多くあります。現状に満足しきっていた人でも、こうした目標とすべき人々に囲まれた環境では、気を引き締め直すことができるでしょう。 僕の周囲には、プログラムを書くような友人はほとんどいませんでした。そのせいか、「プログラミングできるんだ。すごいね」と言われることも多々あり、自分は本当にすごいんじゃないかと思ってしまうこともありました。 そんな中インターンにやって来て、僕は社員の方々と自分のレベルの差に愕然としました。緩みかけていた気持ちを引き締め直して、頑張って勉強していこうという気持ちになりました。 また、半年以上の間に多くのインターン生たちと知り合いました。彼らは例外なく皆優秀で、話すたびに自分の至らなさを痛感させられます。現在一緒に働いている人の中にも、自分よりもはるかにできる高校生がいたりして、刺激を受ける日々が続いています。 自分はまだまだ半人前ですが、少しでもレベルアップできるようにこれからも頑張りたい、そう思えるのはこの環境に身を置いたからだと思います。 5. お金がもらえる 友達とご飯に行ったり、服を買ったり、旅行に行ったりと、学生生活には何かとお金がいります。なんとかして自力でお金を工面しなければなりません。 しかし、お金を得るために働くと、それだけ技術的な勉強ができる時間も減ってしまいます。勉強はしたいけどお金も欲しい、どちらも叶える道はないものかと考えている人もいるでしょう。 エンジニアインターンではそれが叶います。インターンでは給料がもらえるので、インターン生として様々なことを学んでいると、いつのまにか口座に十分なお金が溜まっているのです。こんなに嬉しいことはありません。 僕は大学に入学した頃、ほとんど働いていませんでした。そうすると、新歓期のドタバタで入学祝いに貰ったお小遣いもすぐに消えて行き、昼食に五百円払うのすら躊躇する状態が続きました。仕方なくPCに触れる時間を減らして始めたアルバイトは単純作業の繰り返しで、そのつまらなさが苦痛で全く続きませんでした。 インターンでできる仕事は楽しく、前のアルバイトで感じたような苦痛は一切ありません。その調子で続けているとお金はどんどん溜まっていき、今ではサッと昼食で五百円以上を出せるようになりました。これは充実した生活を送るには非常に重要なことだと思います。 最後に ここまで、僕が思うエンジニアインターンのいいところを解説して来ました。繰り返しになりますが、僕はあの時インターンに応募してよかったと今でも思っています。エンジニアインターンに興味はあるけど躊躇して踏みとどまっている人も、ぜひ思い切って門を叩いてみてください。きっとかけがえのない経験が得られると思います。 VASILYでは、現在もエンジニアインターンを募集中です。少しでも気になった人は、是非下のリンクから気軽に応募してください。お待ちしてます!
こんにちは! 最近は熱燗にはまってます。 バックエンドエンジニアのりほやんです。 本記事では、最近2段階認証などにもよく使われているSMS認証のサーバーサイド実装についてご紹介します。 FacebookAccountKitを用いて実装しました。 SMS認証とは SMS認証とは、SMS(ショートメッセージ)を利用した認証方法です。 ユーザーが入力した電話番号に対してショートメッセージを送り、ショートメッセージに記載された認証番号を入力することでユーザーを認証する方法です。 FacebookAccountKitとは FacebookAccountKitはFacebookがSMS認証用に提供しているサービスです。 電話番号やメールアドレスを用いて、ユーザー認証を行うことができます。 Web, iOS, Androidの3種類のSDKが提供されています。 利用価格についてですが、2018年8月までは無料で使えるそうです。 2018年8月までSMSの課金は行いません。2018年9月以降、1か月あたり10万件を超えるSMSの確認メッセージには通常のSMS料金が課金される可能性があります。 https://developers.facebook.com/docs/accountkit/faq 認証の流れ 大まかな流れは下記のようになります。 参考: https://developers.facebook.com/docs/accountkit/overview アプリ内でユーザーが電話番号を入力 Account Kitから、入力された電話番号にSMSが届く SMSに記載された確認番号をアプリに入力し認証 認証が完了すると、Account Kitからaccess_codeが発行される 発行されたaccess_codeをサーバーに送信 アプリから送られたaccess_codeを元にaccess_tokenを発行 access_tokenを用い、AccountKit Graph APIからユーザー情報を取得 この記事では、サーバーサイドの認証である、6と7の処理について説明します。 サーバーサイドの実装方法 以下の手順でSMS認証を行います。 FacebookAccountKitの設定 access_codeからaccess_tokenを取得 access_tokenを用いてAccountKit Graph APIからユーザーの情報を取得 1. FacebookAccountKitの設定 初めにFacebookAccountKitの設定をします。 AccountKitを導入するためにはFacebookアプリケーションを作成している必要があります。 Facebookアプリケーション一覧 こちらからAccountKitを使いたいFacebookアプリケーションの画面に行き、 プロダクトの『製品を追加』をクリックし、AccountKitの設定をクリックします。 AccountKitの設定から、『サーバー認証をオンにしますか』を有効にし、スタートボタンを押します。 サーバー認証をオンにすると、設定画面において『App Secretをオンにする』が有効になります。 以上で、FacebookAccountKit側の設定は完了です。 2. access_codeからaccess_tokenを取得 サーバーでは、アプリからaccess_codeを受け取りますが、access_codeではユーザーの情報を取得することはできません。 access_codeを用いて、ユーザーの情報を取得できるaccess_tokenを取得する必要があります。 Facebook AccountKit Graph APIは2018年2月8日現在、バージョン1.3で下記のURLを用います。 https://graph.accountkit.com/v1.3/ accesss_tokenを取得するために、 /access_token というエントリポイントに対して下記パラメーターで、GETリクエストを送りaccess_tokenを取得します。 bodyパラメーター { grant_type: 'authorization_code', code: アプリから送られてきたaccess_code, access_token: アプリアクセストークン } access_tokenについて access_tokenは2種類存在します。 ユーザーアクセストークンとアプリアクセストークンです。 詳しくは、 ドキュメント を参考にしてください。 ユーザーの情報を取得するために必要なのは、ユーザーアクセストークンです。 ユーザーアクセストークンを取得するために、ここではアプリアクセストークンをパラメーターに指定する必要があります。 アプリアクセストークンは、アプリIDとAccountKitAppSecretを連結したもので、下記のように記述します。 AA|アプリID|AccountKitAppSecret アプリIDとAccountKitAppSecretは、FacebookアプリケーションのAccountKitのダッシュボードで確認できます。 レスポンス リクエストが成功すると、以下のようなJSONが返ってきます。 JSON内のaccess_tokenを、ユーザーの情報を取得する際に使用します。 { "id" : account_kitのユーザーID, "access_token" : ユーザーアクセストークン, "token_refresh_interval_sec" : トークンの利用期限 } 3. graph apiからユーザー情報を検証 取得したaccess_tokenを用いて、graph apiを叩きます。 /me というエントリポイントに対して下記のパラメーターでGETリクエストを送り、ユーザーの情報を取得します。 bodyパラメーター { access_token: アプリアクセストークン, appsecret_proof: appsecret_token, } appsecret_proofについて AccounKitのダッシュボードにて『App Secretをオンにする』設定が有効にした場合、access_tokenを使用するリクエストにはappsecret_proofが必須になります。 appsecret_prooftとは、リクエストが自身のサーバーから行われたものであることを証明するためのパラメーターです。 appsecret_proofは、access_tokenのSHA-256ハッシュで、キーにAccountKitAppSecretを用います。 AccountKitの公式ドキュメント にはPHPでの記述例が下記のように紹介されています。 $appsecret_proof = hash_hmac('sha256', $access_token, $app_secret); Ruby on Railsではappsecret_proofを求める場合はopensslを用い、以下のように書くことができます。 OpenSSL::HMAC.hexdigest('sha256', AccountKitAppSecret, ユーザーアクセストークン) 算出したappsecret_proofを、パラメーターとして使用します。 レスポンス リクエストが成功すると、以下のようなJSONが返ってきます。 { id: account_kitのユーザーID, phone: { number: 電話番号 country_prefix: 国番号, national_number: 国番号を除いた電話番号 }, application: { id: アプリID } レスポンスに含まれるアプリIDを用い、正しいアプリIDと比較して不正なレスポンスではないことを確認します。 得られたユーザーの情報から、DBと照合しユーザーログインを行います。 注意点 access_codeの使用期限 access_codeは、1度しか使えません。 一度使用したaccess_codeを再度使用した場合400エラーが発生します。 Account_kitアカウントIDについて Account_kitアカウントIDについて公式ドキュメントには、下記のように記述されています。 これらのアカウントIDは、アプリ固有のものです。アプリにFacebookログインも使用する場合、Facebookのapp-scoped IDと競合することはありません。 https://developers.facebook.com/docs/accountkit/overview?locale=ja_JP しかし実際どのような場合にAccount_kitアカウントIDが変わるのかが詳しく記述されていないため使用する際は注意が必要です。 電話番号に対して固有の値なのか、デバイスなどが変われば変わる値なのかは実際に検証する必要があります。 まとめ 本記事では、FacebookAccountKitを用いたSMS認証についてご紹介しました。 FacebookAccountKitはとてもシンプルなため、簡単にSMS認証が導入できます。 特にFacebookログインを実装した経験があると、GraphAPIの叩き方などが共通しているためスムーズに取り入れることができると思います。 ぜひこの記事がSMS認証導入のきっかけになれば幸いです。 VASILYでは、新しいことに挑戦できるエンジニアを募集しています。 興味ある方は以下のリンクからご応募ください。 参考 Facebook Account Kit 公式ドキュメント
iOSアプリチームの @hiragram です。 最近、ファーストリリース時からあった画面の大規模なリファクタリングを担当しました。 コードは遅かれ早かれ賞味期限が切れて少しずつ腐っていくものですが、その賞味期限を少しでも伸ばすために、普段コードを書く時にSwiftのOptionalについて意識していることを記事にします。 「とりあえずOptional」をやめる SwiftのOptionalは便利ですが、「Optionalを使えば、nilを安全に扱えて良い」と捉えてしまうと、気づくとモデルのプロパティがOptionalだらけになっていて使う側で毎回アンラップをしなくてはいけないような状況に必ずなります。 そうではなく、「Optionalの存在のおかげで、非Optionalなところにnilが絶対入ってこないことが保証されて良い」と捉えるべきだと思っています。 nilに口なしといいますが、Optionalなプロパティにnilが入っている時、そのnilは初期値がセットされる前の未初期化値なのか、データを提供しないことをユーザーが明示的に選択した空値なのか、はたまたエラーによって本来の値が得られなかったnilなのかを判断することはできません。 データ構造の中にnilが生まれそうになった時、それは何か設計が間違っているかもしれません。 nilは無くせるなら無い方がいいのです。 例えば、ViewControllerのあるプロパティは viewDidLoad で値がセットされるとすると、ViewControllerのイニシャライズ時点では値が無いため、安易にOptionalにすることができます。 class ViewController : UIViewController { var hogehoge : String ? override func viewDidLoad () { super .viewDidLoad() hogehoge = createHogehoge() } override func viewDidAppear (animated : Bool ) { super .viewDidAppear(animated : animated ) if let hogehoge = self .hogehoge { // アンラップしないといけない } else { // 値が取れなかったらどうする?????????? } } } しかし、「初期値を与えるのを遅延させたい」というのを実現するために、それ以降の読み出しでいちいち guard や if let を使ってアンラップする必要がでてきます。 「初期値が与えられる前にその値を参照することはそもそも間違っていて、かつ初期値が与えられて以降はnilに戻ることはない」というデータ構造なのであれば、以下のようなテクニックが使えます。 // 初期値が与えられる前に参照されたらアプリをクラッシュさせる func uninitialized <T> () -> T { fatalError() } class ViewController : UIViewController { lazy var hogehoge : String = uninitialized() override func viewDidLoad () { super .viewDidLoad() hogehoge = createHogehoge() } override func viewDidAppear (animated : Bool ) { super .viewDidAppear(animated : animated ) hogehoge // ここでアンラップする必要がない } } こうすることにより、プロパティがOptionalだった時に考えなければいけなかった「値が取れなかった時にどうするか」を考える必要がなくなります。 エラーやデータ不整合を握りつぶさない、回復不能ならアプリを落とすことも検討 上述のように、Optionalを多用してしまうと「型はOptionalだけど、実装上ここがnilになることは無いはずだから、アンラップできなかった時はreturnでいいや」みたいな判断をしがちです。 しかし、その後の改修や仕様変更でnilになるシーンが出てきた時に、以下のような問題を生む可能性があります。 アンラップで失敗を握りつぶされてしまった結果、データの不整合に気がつかない 動いてほしいメソッドがguardで落ちて実際の処理をしてくれない 私は「実装上ここがnilになることは無いはずなら、もし将来nilが入ってきた時に必ず対応する必要がある」と考え、必ずアンラップできるはずのOptional変数がnilだった場合、 fatalError() や preconditionFailure() で明示的にアプリをクラッシュさせることを検討します( ! でアンラップするのと変わらなくなってしまいますが、それは治安が悪いのでやりたくありませんよね)。 「アプリがクラッシュしない」というのは大事なことですが、「アプリ内のデータや状態が不整合を起こしていてもなんとなく動き続けてしまう」よりは潔くクラッシュしたほうが健全だと思います。 クラッシュすればクラッシュログが溜まって気づけますが、本番環境で壊れながらのらりくらり動き続けるアプリはそもそも問題として気づかれないことが多く、ついに崩壊してユーザーの目に明らかな壊れ方をした時には、すでにその状態から遡ってバグの原因を突き止めるのは非常に困難なはずです。 まとめ 以上2点は、Swiftを書く上で意識したいOptionalの本質であると私は考えます。 「クラッシュしないこと」がいちばん大事なのではなく、「正しく動き続けること」が大事なのです。 Optionalにすることでクラッシュしないアプリを手軽に作ることができるようになりましたが、それは実は本質的でなく、将来の耐改修性を損ねている可能性があるということを常に頭にいれて、上手にOptionalと付き合っていきたいものです。 VASILYではiOSエンジニアを募集しています。 この記事に共感してもらえるような、Swiftの機能の本質を考えて最良の設計ができるエンジニアと一緒に働きたいです。 ご応募お待ちしております。 https://www.wantedly.com/projects/88978 www.wantedly.com
こんにちは。インフラエンジニアの内山(@k4ri474)です。 弊社ではCloudFormationとOpsWorksを活用してインフラ構築をしています。 この両サービスではハマりどころが多く、中でもOpsWorksでインスタンスが複数のレイヤに所属する構成を構築した際にとても苦戦しました。 そこで今回は、私が上記構成でインフラ構築をした際に悩んだ点の解決法をTipsとして公開します。 目次 目次 サービスの概要 CloudFormation OpsWorks [Theme 1]インスタンス名の決定 前提条件 命名法則 順序が明確でない場合の挙動 複数レイヤを同じ変更セットで作成した場合 複数インスタンスを同じ変更セットで作成した場合 順序のコントロール [Theme 2]ダミーセキュリティグループの利用 導入ケース 実装方法 [Theme 3]レシピのマージ 注意点 問題発見のアプローチ まとめ サービスの概要 CloudFormation CloudFormationは、AWSリソースをJSON/YAMLで表現したテンプレートを元に、その構成を自動で構築してくれるサービスです。 本エントリでは、後述するOpsWorksのリソース宣言や設定項目のセットをCloudFormationのテンプレートを用いて紹介しています。 OpsWorks OpsWorksは管理下のインスタンスに対してChefを実行することで構成管理を行うサービスです。 スタック、レイヤ、インスタンスという3要素でインスタンスを管理しており、以下のような条件で構成されます。 インスタンスは1つ以上のレイヤに所属する レイヤはスタックに割り当てる このため、上記3要素は Stack > Layer > Instance という階層のように表現されます。 なお、1つのスタックに複数のレイヤを割り当てることができ、インスタンスは複数のレイヤに所属することができます。 CloudFormationとOpsWorksについて詳しく知りたい方はこちらのエントリをご覧ください。 tech.vasily.jp [Theme 1]インスタンス名の決定 前提条件 スタックのAdvanced optionsにある Hostname theme が、デフォルトの Layer Dependent であることを前提条件とします。 命名法則 OpsWorksで作成したインスタンスの名前(Nameキーの値)は、以下3つの要素から構成されます。 所属するスタックのNameプロパティの値 インスタンスが所属するレイヤの中で最も先に作成されたレイヤのShortNameプロパティの値 2の値が同じインスタンスの中で、何番目に作成されたインスタンスなのかを表す数値 これら3要素を使い <stack_name> - <layer_shortname><number> と命名されます。 ここで命名に利用されるレイヤは1つだけという制約があるのですが、1つのインスタンスは複数のレイヤに所属することが可能です。 この場合どのレイヤが優先されるかは、リソースとしてどのレイヤが先に作成されたかで決定されます。 例えば、以下のようなリソースがCloudFormationのテンプレートに記述されていたとします。 TestStack : Type : "AWS::OpsWorks::Stack" Properties : Name : "TestStack" CommonLayer : Type : "AWS::OpsWorks::Layer" Properties : Shortname : "common" StackId : !Ref TestStack WebLayer : Type : "AWS::OpsWorks::Layer" Properties : Shortname : "web" StackId : !Ref TestStack WebFirst : Type : "AWS::OpsWorks::Instance" Properties : LayerIds : - !Ref CommonLayer - !Ref WebLayer StackId : !Ref TestStack (※説明簡略化のため必須プロパティを省略しています) 上記4つのコードブロックでは、以下の状態を表しています。 TestStackというスタックにCommonLayer・WebLayerという2つのレイヤがある WebFirstというインスタンスが両レイヤに所属している 上の例の場合、WebLayerがCommonLayerより先に作成されていた際のWebFirstの名前は、 TestStack - web1 となります。 逆にCommonLayerの方が先に作成されていた場合は、WebFirstの名前は TestStack - common1 となります。 この時、CloudFormationのテンプレートで指定した WebFirst は論理IDというリソースとして扱われ、各サービスでのインスタンス名表記には利用されないことに注意します。 順序が明確でない場合の挙動 複数レイヤを同じ変更セットで作成した場合 先述の通り、レイヤの作成順序によってインスタンス名が左右されます。 作成順序は適用順で決まるので、より先の変更セットに含まれているリソースが"先に作成された"と見なされます。 また、同じ変更セットで作成された複数のレイヤにインスタンスを所属させた場合、どちらのレイヤのShortNameが使われるかは未定です。 上の例の場合、同じ変更セットでCommonLayerとWebLayerを作成すると TestStack - web1 になるのか TestStack - common1 になるのかが把握できないということになります。 複数インスタンスを同じ変更セットで作成した場合 CloudFormationは可能な限り並列にAWSリソースを作成・更新しようとします。 そのため、同じ変更セットで同レイヤに所属するインスタンスを複数作成しようとする場合は、"何番目のインスタンスか"を表す数値が同一になります。 上の例の場合、インスタンスを2つ作成すると2つとも TestStack - web1 となってしまいます。 論理IDをWebFirst・WebSecondのように設定していても上図のようになるので、戸惑いがちなポイントかと思います。 このように、レイヤ・インスタンスの作成順序が明確でないと運用上困ることがあるので、後述するDependsOn属性をリソースに付与してコントロールします。 順序のコントロール DependsOn属性 を付与することで、テンプレート上で明確に作成順序を分けることができます。この場合、変更セットを分ける必要はありません。 レイヤの作成順序を制御したコードは以下のようになります。 CommonLayer : Type : "AWS::OpsWorks::Layer" DependsOn : - WebLayer Properties : Shortname : common StackId : !Ref TestStack WebLayer : Type : "AWS::OpsWorks::Layer" Properties : Shortname : web StackId : !Ref TestStack WebFirst : Type : "AWS::OpsWorks::Instance" Properties : LayerIds : - !Ref CommonLayer - !Ref WebLayer StackId : !Ref TestStack WebSecond : Type : "AWS::OpsWorks::Instance" DependsOn : - WebFirst Properties : LayerIds : - !Ref CommonLayer - !Ref WebLayer StackId : !Ref TestStack CommonLayerにDependsOn属性を記述してWebLayerが先に作成されるように明示することで、この両レイヤの作成順序は常にWebLayerが先になります。 そのため、WebFirst・WebSecondインスタンスのNameキーの値には web が利用されるようになります。 インスタンスにおいてもルールは同様です。 WebSecondにてDependsOn属性を記述してWebFirstに依存するように設定すると、同じ変更セットでインスタンスを作成しても以下のように命名されます。 WebFirst : TestStack - web1 WebSecond : TestStack - web2 このようにDependsOn属性を活用することで、コード上で順序関係を把握することができ、1つの変更セットで意図した通りに複数リソースを作成できます。 [Theme 2]ダミーセキュリティグループの利用 導入ケース OpsWorksでは、全てのレイヤにセキュリティグループを割り当てることが必須となっています。 この制約がネックとなる場面を例示するため、弊社のテンプレートの設計を紹介します。 弊社では以下3種類のレイヤを作成しており、インスタンスは最大で3レイヤに所属しています。 サービスにおける役割(ロールと呼ぶ)ごとの設定を記述したレイヤ 複数のロールにおける共通の設定を記述したレイヤ 全インスタンスにおける共通の設定を記述したレイヤ この時、2と3のレイヤにおいては複数のロールに跨っていることを考慮してセキュリティグループのルールを考えねばなりません。 2のレイヤでアプリケーションサーバ用のレイヤとwebサーバ用のレイヤをくくり出していた場合、共通のインバウンド・アウトバウンドルールとして挙げられる選択肢はそう多くないと思います。 こういった場合に、ルールを持たないセキュリティグループが役に立ちます。 実装方法 セキュリティグループの作成時にインバウンド・アウトバウンドのルールは必須項目ではないため、ルールを持たないセキュリティグループを作成する事ができます。 CloudFormationのテンプレートでは以下のように記述します。 DummySecurityGroup : Type : "AWS::EC2::SecurityGroup" Properties : GroupDescription : "dummy-SG" Tags : - Key : "Name" Value : "dummy-SG" VpcId : !Ref MyVPC このようなルールを持たないセキュリティグループをレイヤに割り当てることで、CloudFormationの制約を守りながらサービスに影響を与えることなく構築ができます。 [Theme 3]レシピのマージ 注意点 マルチレイヤ構成をとる場合、CustomJsonに記述したレシピについて以下の注意点があります。 インスタンスが複数レイヤに所属する場合、レイヤ間のCustomJsonはマージされる 同名の要素があった場合はどちらの要素が使われるか不定 そのため、以下のコードブロックの例だとCommonLayer・WebLayerの両レイヤに所属するインスタンスでは、Rubyのversionが2.5.0か2.2.5のどちらかとなります。 CommonLayer : Type : "AWS::OpsWorks::Layer" DependsOn : - WebLayer Properties : CustomRecipes : Setup : - 'ruby' CustomJson : | { "ruby" : { "version" : "2.5.0" } } WebLayer : Type : "AWS::OpsWorks::Layer" Properties : CustomRecipes : Setup : - 'ruby' CustomJson : | { "ruby" : { "version" : "2.2.5" } } スタック全体のレイヤ数が増え、インスタンスが3レイヤ以上に所属するような状態だと、意図せず発生してしまう問題です。 問題発見のアプローチ OpsWorksによって作成された全てのインスタンスには、 AWS OpsWorks スタック エージェント がインストールされます。 OpsWorksスタックエージェントのCLIで用意されたコマンドの1つである get_json を用いると、上のコードブロックのようにChefで実行されたJSONを確認することができます。 $ sudo opsworks-agent-cli get_json { " ruby " : { " version " : " 2.2.5 " } , " run_list " : [ ] } $ CloudFormationを利用するとテンプレート上で全てのリソースを把握できますが、実際のインスタンスでの適用状況を確認することも重要となります。 まとめ CloudFormationとOpsWorksを使って複数レイヤ構成を構築する際のTipsを紹介しました。実現したいインフラの構成が違えば直面する問題も異なってくるかと思いますが、この記事が参考になりましたら幸いです。 VASILYではモダンな手法を取り入れながら柔軟にインフラ構築をしていきたいエンジニアを募集しています。興味のある方は以下のリンクからご応募ください。 https://www.wantedly.com/projects/108005 www.wantedly.com
こんにちは、バックエンドエンジニアの塩崎です。 最近のTECH BLOGではMatzさんのインタビュー記事を書いたり、RubyKaigiの発表まとめを書いたりして、他人の褌で相撲を取っていました。 今回は心を入れ替えて(?)、自分自身が取り組んだ内容について書きます。 VASILYでは検索用のミドルウェアとしてApache Solr(以下、Solr)を使用しています。 全文検索や、ファセット機能などはMySQLだけでは不十分なために、Solrを併用しています。 Solrのサーバー構成例にはいくつかのパターンがありますが、今回はその中でも最も可用性の高いSolrCloudをサービスインしたので、それについて紹介を行います。 Solrの構成例を幾つか紹介 Solrの構成例は大きく以下の3つに分けられます。 まずは、それぞれについて詳しく説明していきます。 スタンドアローン構成 master slave構成 SolrCloud構成 スタンドアローン構成 スタンドアローン構成は3つの構成例のなかで最もシンプルな構成です。 Solrサーバーは1台のみで、その1台がclientからのすべてのread/writeリクエストを受け付けます。 この構成ではSolrサーバーが死んだ場合にはすべてのread/writeリクエストができない状態になってしまいます。 master slave構成 更新系のリクエストを担当するmaster nodeと参照系のリクエストを担当するslave nodeからなる構成です。 検索インデックスの更新はmaster nodeのみで行い、構築済みのインデックスをslave nodeにコピーします。 インデックスのコピーにはSolrのreplication機能を使います。 参考: Index Replication この構成ではslave nodeの台数を1台以上にすることで参照系の可用性を上げることができます。 すべてのslave nodeが死なない限り、参照系のリクエストを処理することができます。 他方で書き込み系は冗長構成をとっていないため、唯一のmaster nodeが死んだ場合にはすべての書き込みリクエストが失敗します。 なお、場合によってはSolrクライアントとSolr slaveの間にあるロードバランサーを省略することもできます。 Solrクライアント自身が適当なslave nodeへの通信の振り分けと、各slave nodeの死活監視をすれば、ロードバランサーは不要になります。 SolrCloud構成 SolrCloud構成は基本的にはmaster slave構成と同じですが、より可用性が上がった構成です。 更新系のリクエストを処理するleader nodeと参照系のリクエストを処理するfollower nodeがあります。 そして、それらの間のインデックスの同期はreplication機能によってなされます。 master slave構成との大きな違いはleader node(master node)に障害が発生したときの挙動です。 SolrCloud構成ではleader nodeに障害が発生した場合には、適当なfollower nodeが新しいleader nodeになります。 そのため、クラスタ内のどの1台が死んでも、クラスタとしては正常に動作することができます。 これらの機能を実現するためには、現在のクラスタ情報を保持したり、新たなleader nodeを選出するための仕組みが必要です。 SolrCloudではその機能をzookeeperに担わせています。 そのため、Solrクライアントはzookeeperに対して、現在のクラスタ情報を問い合せて、適切なノードに対して通信を振り分ける必要があります。 また、nodeの追加や削除でクラスタ情報が更新された時にはクライアントもそれに追従する必要があります。 さらに、SolrCloud構成ではシャーディングによる水平分割もサポートしているため、leader nodeの負荷分散を行うこともできます。 それぞれの特徴まとめ 各構成の特徴を「必要な台数」と「可用性」という観点からまとめてみました。 スタンドアローン master slave SolrCloud 必要な台数 1 1 + slave台数 可用性の要求次第で何台でも + zookeeper 参照系が死ぬ条件 1台しかないnodeが死んだとき 全slave nodeが死んだとき 全nodeが死んだとき 更新系が死ぬ条件 1台しかないnodeが死んだとき 1台しかないmaster nodeが死んだとき 全nodeが死んだとき 注意が必要な点としては、SolrCloud構成ではzookeeperの構築も必要な点が挙げられます。 zookeeperはSolrCloudの中で非常に重要な役割を果たすため、zookeeper自体の冗長構成も必要です。 zookeeperを冗長構成にするためには最低3台のnodeが必要なので、その考慮が必要です。 上の表を見ると、SolrCloud構成のみが参照系・更新系ともに単一障害点(SPOF)をなくすことのできる構成なことがわかります。 そのかわりに、SolrCloud構成では必要とされるサーバーが多くなりがちです。 SPOFをゼロにしようとした場合は、Solrサーバー 2台とzookeeperサーバー3台の合計5台が最低限必要です。 そのため、個人サービスやまだ小規模なサービスであれば、スタンドアローン構成やmaster slave構成を採用するのが良いのではないかと思います。 SolrCloudの検証 構築してみる さて、ここからはSolrCloudの検証について話していきます。 対象読者はSolrCloudの構築をしたことはないけど、スタンドアローン構成やmaster slave構成のSolrの構築をしたことがある人です。 Solrの構築を一度もしたことがない人は以下の書籍やオンライン資料などを参考にシンプルな構成を試してみてから読んで見てください。 改訂第3版 Apache Solr入門 Apache Solr Reference Guide 環境 今回の検証は以下の環境で行いました。 $ java -version java version "1.8.0_73" Java(TM) SE Runtime Environment (build 1.8.0_73-b02) Java HotSpot(TM) 64-Bit Server VM (build 25.73-b02, mixed mode) $ system_profiler SPHardwareDataType Hardware: Hardware Overview: Model Name: MacBook Pro Model Identifier: MacBookPro11,3 Processor Name: Intel Core i7 Processor Speed: 2.5 GHz Number of Processors: 1 Total Number of Cores: 4 L2 Cache (per Core): 256 KB L3 Cache: 6 MB Memory: 16 GB Boot ROM Version: MBP112.0142.B00 SMC Version (system): 2.19f12 Serial Number (system): ************ Hardware UUID: ********-****-****-****-************ すべてのSolrプロセスは同一のMacの中で起動させています。 検証に使用したSolrのバージョンは2018/01/19時点で最新バージョンである7.2.1です。 構築手順 Solrにはサンプルの環境を簡単に立ち上げることのできるモードがあります。 このモードでは対話的にreplica数やshard数を打ち込むと、それに応じてイイカンジにSolrCloudが立ち上がります。 ですが、あまりにも簡単に立ち上がってしまうので、今回はstep by stepで進めて行きたいと思います。 まずはzookeeperを立ち上げます。 本番環境で運用するときには冗長構成が必要ですが、とりあえずは1台のみの構成にします。 $ wget http://ftp.jaist.ac.jp/pub/apache/zookeeper/zookeeper-3.4.11/zookeeper-3.4.11.tar.gz $ tar xvf zookeeper-3.4.11.tar.gz $ cd zookeeper-3.4.11 conf/zoo.cfgに以下の設定ファイルを配置します。 tickTime=2000 dataDir=/tmp/zookeeper clientPort=2181 以下のコマンドでzookeeperを起動します。 bin/zkServer.sh start 次にSolrCloudのnodeを立ち上げます。 今回はreplica数とshard数の両方を2で構築してみます。 node数は合計で2 * 2 = 4必要です。 SolrCloudでは大抵の設定ファイルはzookeeperによって管理されます。 ですが、zookeeperと接続するための設定だけは各nodeに配置する必要があります。 この部分は共通なので、以下のsolr.xmlを用意します。 <? xml version = "1.0" encoding = "UTF-8" ?> <solr> <solrcloud> <str name = "host" > ${host:} </str> <int name = "hostPort" > ${jetty.port:8983} </int> <str name = "hostContext" > ${hostContext:solr} </str> <bool name = "genericCoreNodeNames" > ${genericCoreNodeNames:true} </bool> <int name = "zkClientTimeout" > ${zkClientTimeout:30000} </int> <int name = "distribUpdateSoTimeout" > ${distribUpdateSoTimeout:600000} </int> <int name = "distribUpdateConnTimeout" > ${distribUpdateConnTimeout:60000} </int> <str name = "zkCredentialsProvider" > ${zkCredentialsProvider:org.apache.solr.common.cloud.DefaultZkCredentialsProvider} </str> <str name = "zkACLProvider" > ${zkACLProvider:org.apache.solr.common.cloud.DefaultZkACLProvider} </str> </solrcloud> <shardHandlerFactory name = "shardHandlerFactory" class = "HttpShardHandlerFactory" > <int name = "socketTimeout" > ${socketTimeout:600000} </int> <int name = "connTimeout" > ${connTimeout:60000} </int> </shardHandlerFactory> </solr> そして、4node分のsolr_homeディレクトリを作り、solr.xmlをコピーします。 mkdir -p solr_home/node1/solr mkdir -p solr_home/node2/solr mkdir -p solr_home/node3/solr mkdir -p solr_home/node4/solr cp solr.xml solr_home/node1/solr cp solr.xml solr_home/node2/solr cp solr.xml solr_home/node3/solr cp solr.xml solr_home/node4/solr 最後に、以下のコマンドを実行することで各nodeが立ち上がります。 bin/solr start -cloud -s solr_home/node1/solr -p 8001 -z 127.0.0.1:2181 -h 127.0.0.1 bin/solr start -cloud -s solr_home/node2/solr -p 8002 -z 127.0.0.1:2181 -h 127.0.0.1 bin/solr start -cloud -s solr_home/node3/solr -p 8003 -z 127.0.0.1:2181 -h 127.0.0.1 bin/solr start -cloud -s solr_home/node4/solr -p 8004 -z 127.0.0.1:2181 -h 127.0.0.1 この状態で、 127.0.0.1:8001 にアクセスするとSolrのwebコンソールが見えます。 左側のメニューのCloudという項目がSolrCloud特有の項目で、ここからzookeeper内のデータを見たり、クラスタの状態を確認したりすることができます。 まだこの時点ではcollectionを作っていないため、Cloud画面にはほとんど情報がありません。 次にcollectionを作成します。 ここではポート8001で起動しているnodeに対してcreate_collectionを行っていますが、他のnodeに対してcreate_collectionをしても結果は変わりません。 bin/solr create_collection -c collection1 -d server/solr/configsets/_default -p 8001 -shards 2 -replicationFactor 2 collectionの作成に成功すると、自動的に各nodeからleader選出が行われ、それぞれのnodeがどれかのshardに属します。 どのnodeがleaderになるか、どのnodeがどのshardに属するのかということはその時々で変わります。 次にcollectionに対してfieldを追加していきます。 fieldの追加はweb uiから行う方法とSchema APIから行う方法の2つがあります。 今回はSchema APIを経由して行います。 Schema API 以下のコマンドでpint型のfield1を追加します。 curl -X POST -H 'Content-type:application/json' --data-binary '{ "add-field":{ "name":"field1", "type":"pint", "stored":true, "indexed": true } }' http://localhost:8001/solr/collection1/schema 同様にして、field2、field3も追加します。 Schema APIによるfieldの追加によってzookeeperで管理されているmanaged-schemaファイルの更新が行われます。 そして、その変更を各nodeが取り込むことによって、全nodeでfield追加が反映されます。 ドキュメントの投入を行います。 サンプルとして各fieldに対して0〜100までの乱数を設定したドキュメントを10000件投入します。 require ' rsolr ' solr_host = ' 127.0.0.1 ' solr_port = 8001 solr_collection = ' collection1 ' solr_url = " http:// #{ solr_host } : #{ solr_port } /solr/ #{ solr_collection }" rsolr = :: RSolr .connect( url : solr_url, retry_503 : 5 , retry_after_limit : 1 ) ( 1 .. 10000 ).each do | i | doc = {} doc[ :id ] = i ( 1 .. 3 ).each do | j | doc[ " field #{ j }" .to_sym] = :: Random .rand( 100 ) end rsolr.add(doc) rsolr.commit end ドキュメントの検索は通常のsolrと同じです。 以下のコマンドで1つめのnodeに対して検索を行います。 curl http://localhost:8001/solr/collection1/select?indent=on&q=*:*&wt=json port番号の部分を変更することで、他のnodeに対して検索リクエストを投げることもできますが、返される結果は同じです。 これは検索リクエストを受け取ったnodeが他のshardのデータを取得し、その結果をマージしているためです。 クライアントから使ってみる RubyからSolrCloudを使う方法を説明します。 RubyからSolrを使うためにはrsolrというgemを使用することが多いです。 SolrCloudを使うためにはzookeeperへの問い合わせなどが必要なため、rsolrの機能だけでは不十分です。 そのため、enigmoさんが公開しているgemであるrsolr-cloudを併用します。 rsolr-cloudはrsolrのアダプターとして機能し、zookeeperへのクラスタ情報の問い合わせや、リクエストの種類に応じて適切なnodeに通信を振り分ける機能などを提供します。 rsolr-cloud 注意点はrsolr-cloudを使うときにはrsolrのメジャーバージョンを1にする必要があることです。 そのため、以下にGemfileでバージョンを指定する必要があります。 gem 'rsolr', '~>1.1.2' # rsolrのv2以上はrsolr-cloud未対応のため gem 'rsolr-cloud' あとは、以下のようにすることで、SolrCloudに対するクエリを投げることができるようになります。 require ' zk ' require ' rsolr/cloud ' zoopeepers = [ ' localhost:2181 ' , ' localhost:2182 ' , ' localhost:2183 ' , ] zk = ZK .new(zookeepers.join( ' , ' )) cloud_connection = RSolr :: Cloud :: Connection .new(zk) solr_client = RSolr :: Client .new(cloud_connection) # 必ずcollectionを指定する必要あり response = solr_client.get( ' select ' , collection : ' collection1 ' , params : { q : ' *:* ' }) 可用性の検証 SolrCloudの可用性を検証してみます。 上の環境で作ったSolrCloudの環境で何台かのnodeを落としてみて、その時の挙動を確認してみます。 サーバーの突然死を再現するためには、以下のkillコマンドを使いました。 kill -9 <PID> 注)この検証では、zoopeekerが死ぬことについては想定していません。 無事なケース leaderが突然死ぬ時 masterが突然死する場合は、書き込みできない期間が数秒間発生してしまいます。 これはSolrCloudクラスターが次のleaderを選出するための期間です。 一方でmaster slave構成の場合には人間が手動でmasterへの昇格を行う必要があり、数分〜数十分かかってしまいがちなことを考えると、SolrCloud構成の可用性の高さがうかがい知れます。 なお、この挙動が嫌な場合には、書き込み処理をsidekiqなどの非同期処理を用いて実装するのが良いと思います。 followerが突然死ぬ時 followerが突然死する場合は一切のリクエストが失敗することなく、SolrCloudは正常に動作を続けます。 これはzookeeperが各nodeの死活監視を行い、Solrクライアントはzookeeper内のドキュメントの変更を検知したときに接続先情報の変更をしているためです。 ダメなケース 上でSolrCloudの可用性が高いことを紹介しましたが、流石のSolrCloudでも復帰できないケースもあります。 シャード内の全nodeが死ぬ時 ある特定のシャードに属するすべてのnodeが死んだ場合、SolrCloudクラスタは正常に機能を提供できなくなります。 そのため、シャード内のレプリカの数は単にreadの負荷分散という観点だけからではなく、可用性という観点からも決める必要があります。 まとめ SolrCloud構成の利点や構成方法について説明をしました。 いくつかあるSolrの代表的な構成例の中でも一番サーバー台数が必要な構成ですが、可用性も一番高い構成です。 検索サーバーが落ちたことによる緊急メンテナンスが辛かった経験のある方は是非検討してみてください。 VASILYでは現状のサーバー構成に満足せず、ユーザーにとってより良い体験を提供するために常に新しい仕組みを導入していきたい人を募集しています。 興味のある方は以下のリンクからご応募ください。
あけましておめでとうございます、CTOの今村( @kyuns )です。 このテックブログを購読してくださっている読者の皆さん、いつもありがとうございます。 VASILYテックブログも記事を投稿し始めてから約6年半が経ちました。 今回はテックブログを長年続けてきた振り返りと、長く続けるコツについて紹介したいと思います。今年はテックブログを始めてみたい、という方々の参考になれば幸いです。 振り返り 初めてVASILYテックブログに記事が投稿されたのは 2011年5月9日 、この時から現在までに約6年半の月日が経ちました。余談ですがこの時に紹介した3種の神器は今でも現役です。(QC3はQC30になりましたが) それでは6年半の歴史を軽く振り返っていきましょう。 2011年〜2013年 とりあえず始めてみたフェーズ 2011年から2013年まではエンジニアもまだ5,6名しかおらず、気が向いたら更新する、という運用しかしていませんでした。ブログを書いたことのある人も少なく、皆アウトプットに慣れていなかったのもあり、1つの記事を公開するのも非常に時間がかかっていました。また、通常の開発が忙しい中で、どんどん書いてよと言い出しにくかったということもあり、結果として公開された記事の本数は非常に少ない結果となりました。 2014年〜2015年 手探りフェーズ 2014年になりエンジニアが10名を超えたあたりで、採用強化のためにも本格的にブログを更新していこうと思い様々な試みを行いました。例えばバズりそうな内容の記事を書いてみる、キャッチーなタイトルを付けてみる、記事の公開時間を工夫してみるなど、一般的なブログ運用でも用いられているアプローチを取り入れてみました。 やってみた結果、バズったりホッテントリに掲載されたりする記事もでてきました。ただ、知名度の向上や採用にはいまいちつながっている実感はあまりありませんでした。 原点回帰 時間をかけて書いた記事がバズったりしているにも関わらず、採用にはあまり響きませんでした。 なぜなのでしょうか? 答えはシンプルで、 なぜやるのか? というところが抜け落ちていたのが原因でした。 そもそもエンジニア採用が目的なのであれば、技術力の高さや業務内容の魅力を、記事を通して伝える必要があります。その観点が抜け落ちていたことに気づきました。 結局ただバズるだけの記事を書いても、そこにはVASILYという存在が見えることはなく、ただコンテンツが消費されて終わるだけでした。 上記の反省を踏まえて、記事は 「業務で行った事に関連する内容」 に限定することにしました。 2016〜2017年 原点回帰、運用フェーズ 記事の内容の一新に続けて、ブログの運用も仕組み化するようにしました。 たとえば、 毎週1記事は必ず公開する ことや、 公開前の内容をエンジニア達でお互いに校正する 、などです。毎週1記事は、エンジニア15名程度だと3か月で全員が丁度1回まわるぐらいの頻度です。 また、ブログの効果をお互いにフィードバックしたり、評価に取り入れたり、様々な仕組み化を行うことによって定常的に記事が公開されるようになりました。定常的に記事が公開されることで、次第にテックブログの効果も実感できるようになってきました。 振り返りを経て実感できたメリット 数年間のブログ運用を振り返ってみて、テックブログを続けることは確実に 個人と会社にメリットをもたらす ということが分かってきました。 実際に運用してきた結果を踏まえた上で感じたそれぞれのメリットを紹介したいと思います。 個人としてのメリット 1.アウトプットの能力が身につく 会社のブログに記事を書いてアウトプットするとなると、それなりにアウトプットする内容についてしっかりと調べなければなりませんし、うまく文章にまとめる必要があります。これは記事の公開を繰り返すことによって確実に上達します。 2.着実に実績になる 記事をコンスタントに公開していると、色んな人から「あの記事みました!」とか「うちでも同じような実装をしているので相談したい」という声を聴くようになります。どんな細かい内容だとしても、必ず何らかのフィードバックを世間から得ることができます。 細かいアウトプットが積み重なることで、勉強会などでも「あの記事を書いた○○さん」という形で認識してもらえることもあり、個人としてのPRや実績に繋がります。また記事を見た方々から登壇依頼があったりすることが多数あります。 これは弊社の新卒エンジニアを見ていても全員同じような経験をしているので、突出したエンジニアだけが効果があるというよりは、誰でもアウトプットすることにより確実にメリットが生まれるということを証明していると思います。 例えば現在 「XPath」 「Swagger」 「Standard SQL」 などの単語で検索した時、最上位に出てくる記事はどれも弊社新卒エンジニアが書いた記事ですし、今もなおアクセスが伸び続けています。 会社としてのメリット 1.知名度向上 面接をしていると、テックブログの記事を通してVASILYという会社を知ってくれた方に多くお会いします。VASILYはクローラーや機械学習のことをやっている、という認識は多くの初対面のエンジニアの方々が我々に持っている印象です。 これは弊社の記事だとクローラーや機械学習系があるのですが、この会社は沢山のECサイトをクロールしてるんだとか、ファッションのデータをつかって新しい取り組みを色々行っているんだ、ということを伝えることができている結果だと思います。 クックパッドやメルカリなど誰もが知っているサービスならまだしも、僕らが運営しているサービスは女性向けであり、TVCMをやっていた時期でさえも男性エンジニアの目に触れることはほぼありませんでした。 サービス自体を知らなくてもその中で使われている技術に興味を持って、そこからサービスや会社に興味を持ってくれるという方々がほとんどでした。これはまだ名前の知られていないベンチャーやスタートアップだからこそ発生する問題だと思いますが、だからこそ小さい会社がテックブログで発信する意味があると思います。 会社に興味を持ってもらえる軸はたくさんあっていいと思いますし、記事を更新することで、会社の知名度向上には少なからず役立っていると感じます。 2.採用力強化 テックブログを更新し続けることは採用につながる、と恐らくどの会社も思っていることでしょう。もちろん、僕もそう思っていますが、記事を書いたからと言って直接採用に繋がるものではありません。直接的な採用効果というよりは、間接的に役立っている印象です。 例えば、エンジニアとして選考をうけてくれる方々はみなさんテックブログを読んで来てくれますし、会社全体としての技術力の評価や会話のきっかけになるケースが多くあります。 検索してて記事を読みました、とか仕事で役立ちました、とかそこから興味を持ってくれる人が多くいます。もちろん、クローラーや機械学習以外にもiOS,Androidなど様々なジャンルでいくつも記事を投稿していますし、地道な積み重ねが確実に採用につながっていると言えます。 3.営業力強化 昨年は弊社テックブログの記事を見て、同じことをやりたい、ぜひ一緒に開発してくれないか?という問い合わせが非常に増えました。例えば画像解析やディープラーニング系はGIGAZINEさんで紹介されたこともあり、非常に多くの問い合わせをいただきました。そこから実際にビジネスに繋がった案件もありますし、ダイレクトに会社の技術力をアピールする場所としては最適だと思います。 以上のように、個人や会社にとって非常にメリットがあることが続けていくうちに分かってきました。 次はテックブログを続けてきた中で、長く続けるコツがいくつかあるので紹介したいと思います。 長く続けるコツ テックブログを長く続けていくコツは一体なんでしょうか? 最も必要なことは 「文化の醸成」 です。 文化を作るためには、記事を更新し続けなければなりません。 更新し続けないと、書くメリットも実感できません。 書くメリットを実感し、アウトプットを繰り返すうちにそこからチームとしての 「文化」 が生まれます。文化が生まれるほど組織運営において強力なことはありません。 ではどのようにすれば記事を更新し続けれるような 「文化」 をつくることができるのでしょうか? 我々は以下のような仕組みをつくることによって、アウトプットを出しやすい環境を作るようにしています。 まさかりを恐れない雰囲気を作る ブログを公開するのをためらう心理的障壁の1つとして有名なのが通称「まさかり」と揶揄される行為です。人気になった記事であればあるほど色んな種類のコメントがつきますし、時には自分の知識不足により罵倒されることもあるでしょう。そして100個の賞賛のコメントよりも1個の罵倒コメントのほうが記憶に残るものです。アウトプットに慣れていない人は、コメントを真摯に受け止めすぎて1人で抱え込んでいると辛い思いをすることもあるでしょう。そこで弊社では毎週行われるTECHMTGでこういうコメントついてたよね〜などみんなで話しあったり時には笑い飛ばしたりします。そういうフォローしたりする場があることで、助け合う空気を作り、心理的ハードルを下げることに貢献しています。 しっかりと評価する テックブログを書くという行為に対して、評価に取り入れてる会社や全く評価とは関係ないという会社など、色々評価方針はあるでしょう。しかしながら弊社ではテックブログやQiitaへの投稿、OSSへの貢献など全てのアウトプットは評価に取り入れています。 VASILYには「エンジニアリングマニフェスト」というものがあり、この最後に「インターネットに貢献する」という項目があります。 インターネットに貢献するという項目は、普段我々が利用しているOSSや参考にしている情報は必ず誰かがアウトプットしたものであり、我々もお世話になっている分、同じことをしてインターネットのエコシステムに貢献すべきだという観点から作られた項目です。 この観点からも、テックブログでの発信も評価につながるようになっていますし、もちろん業務時間を用いて記事を書いています。 校正の品質を上げる 記事の校正には非常に時間がかかりますし、自分で読むと意味が伝わると思っていても、違う人が読むと意味がわかりにくかったりする部分があることがよくあります。 弊社では校正の品質を上げるために 自動 と 手動 の2つの校正手法を用いています。 まず、自動校正には textlint を用いており、メンバーがQiita:Teamにテックブログの下書きを投稿すると自動的にLinterが走るようになっており、コメント欄に校正結果が反映されるようになります。このように誰が見てもわかる間違いはbotが指摘してくれるようになっています。 詳しくは Qiita:Team + Hubot + textlintで文章校正を自動で実行する でも解説しています。 また、botでは見つけれないような伝わりにくい表現やわかりにくい場所などは他のメンバーが積極的に指摘してコメントしてくれます。 メンバーの力を借りる さきほどの校正でももちろんチームの力を借りていますが、他の場所でももちろんチームの力を借りている部分があります。 たとえば、弊社ブログは当番制にしているため、自分の番が来週だ、という時に丁度たまたまリリースが重なってしまい、時間の確保が難しくなることもあるでしょう。そのような時はTECHMTGで変わってくれる人を募集したりして、うまく交代することによって記事の公開を続けることができています。 まとめ 以上、テックブログを振り返ってみて気づいたメリットや長く続けるコツを紹介してみました。 テックブログ運用はチームの協力がないと成り立ちませんし、続けていくことは非常に難しいことだと思います。 日頃VASILYテックブログを更新し続けているメンバーには本当に感謝してもしきれません。 運用するのは大変ですが、今年も色んな分野で新しい会社やサービスが誕生すると思いますし、世の中にもっとテックブログが増えていくといいなと思います。 様々なサービスの裏側の技術を覗けることはエンジニアとしても非常に楽しみです。 2018年、VASILYは新しい節目を迎えます。 世の中がもっと便利になるようなサービスをみなさんにお届けしたいと思いますし、その裏側をこのテックブログでも随時紹介していく予定です。 今年もVASILYテックブログをぜひ楽しみにしておいてくださいね。 よければRSSの登録や、はてなブログの購読ボタンをぜひ押してみてください。 VASILYでは一緒にテックブログを書いてくれるメンバーを募集しています、我こそはという方はぜひ以下からご応募ください。 https://www.wantedly.com/projects/61388 www.wantedly.com
この服装に合う靴を選んでコーディネートを完成させたいと思います。皆さんはどの靴を選びますか? データサイエンティストの中村です。今回、このようなタスクを解くためのシステムを開発しました。本記事ではシステムと裏側の要素技術について紹介したいと思います。 概要 ファッションにおいて、コーディネートは何より大事な要素です。安物の服でもコーディネートが整っていればおしゃれに着ることができますし、逆にハイブランドで固めたとしてもダサく見えてしまうことは充分に考えられます。 コーディネートはアイテムの組み合わせであり、コーディネートをよく見せるには一定の規則 1 に基づく組み合わせの選択が重要です。ところが、この規則は複雑で敷居が高いので、組み合わせに関する表現を直接データから獲得してしまおうというのが今回のトライの内容です。 本記事で紹介するシステムは、コーディネートを学習することで以下のようなタスクを解くことが可能になります。 (1) コーディネートの出来栄えを採点する (2) コーディネートに欠けているアイテムを選択する (3) 任意のアイテムを使ったコーディネートを生成する なお、本記事はHan2017を大いに参考にしています。 入力データ IQONではユーザーがアイテムを組み合わせてコーディネートを作成・公開する機能を提供しています。今回はこの機能で得られたデータを入力として用います。 このように、ひとつのコーディネートは複数のアイテム画像で構成されています。 コーディネートに使うアイテムの数には制約がありません。その為、入力を画像のシーケンスとみなし、可変長の入力を前提としたアプローチを採用しました。 簡単のため、入力するアイテムは主要なカテゴリ 2 に限定し、系列長が4-8となるサンプルのみを対象としています。その他処理を施した結果、学習用のコーディネート数は70,997、アイテム総数は174,653となりました。 ファッションアイテムの相性を求めるタスクでは、Amazon co-purchase data(He2016)やPolyvore(Han2017)が使われますが、IQONデータセットは以下の点で扱いやすいデータセットになっていると思います。 IQONのコーディネートにはAmazonのようなレコメンドによるバイアスが乗らない 人種・民族のばらつきが非常に小さく馴染みがあるので評価しやすい 生活に根ざした日常的なコーディネートが多く、かつおしゃれである モデル CNNとRNNのジョイントモデルを使います。 CNN CNNはアイテム画像 からの特徴抽出を担当します。アーキテクチャはInceptionV3を採用し、ImageNetの学習で獲得したパラメータをそのまま用いました。 RNN Bidirectional LSTM(BiLSTM)を使います。BiLSTMは、入力された系列に対し、前向きと後向き両方向から同時に学習するRNNです。 アイテムの特徴量系列 を逐次入力した時、内部状態を更新しながら次に入力されるアイテムを予測します。次のアイテムを予測できたか否かでモデルを評価するので、コスト関数は以下のようにsoftmaxで定義します。 前向きLSTM 後向きLSTM ここで、 はそれぞれ前向きLSTM、後向きLSTMのパラメータです。 は候補となるアイテム画像の集合です。データセットのすべての画像を含めたいところですが、現実的ではないので、ミニバッチの中から取得します。前向きLSTMのときはミニバッチ内の の集合、後向きLSTMのときは の集合とします。 誤差関数 最終的なコスト関数は以下のようになります。 CNNの重みは固定したので学習の対象はRNNのパラメータ です。 実験 本手法を応用したアプリケーションをいくつか紹介します。 (1) コーディネートの出来栄えを採点する コーディネートを画像の系列として入力した時、以下を計算することでコーディネートの評価を得ることができます。 この式を使えばコーディネートの完成度を定量化して比較することが可能です。 いくつか例を載せます。矢印の左が入力した系列、右側は系列のスコア(大きいほど良い)です。 一番上のコーディネートは自然なのでスコアが大きくなっています。スニーカーをサンダルに入れ替えると不自然さが増し、スコアが減少しました。入力画像がnoisyだったりコーディネートとして成立していない場合もスコアが減少します。 (2) コーディネートに欠けているアイテムを選択する コーディネートをより魅力的に見せるために必要なアイテムを選択することができます。 を候補のアイテム群としたとき、以下の目的関数は の中から最良のアイテムを返してくれます。 結果は以下のとおりです。矢印の左が入力した系列、右側は組み合わせるアイテムの選択肢とそれぞれのスコアを表しています。 1行目は冒頭の問題の解答です。機械はシルバーのサンダルもしくは白のスニーカーが良いと判断しました。 シルバーのサンダルは実際のコーディネートに使われたアイテム、すなわちGround truthです。ただ、この組み合わせなら白いスニーカーでもOKなので、機械の判断は逡巡も含めて正しいと言えます。 (3) 任意のアイテムを使ったコーディネートを生成する アイテム単体を与えた時、そのアイテムを使ったコーディネートを提案することができます。このタスクは前述した2つのタスクを応用することで実現できます。具体的な手順は以下のとおりです。 1. クエリとなるアイテムの前後のアイテムを(2)で予測する 2. 前のステップで得られたアイテムを結合した系列を入力として系列の前後のアイテムを(2)で予測する 3. 2.を末端まで繰り返す 実際は(2)でスコア上位K個のアイテムを取ってきて、K本の系列を生成するようにします。それぞれの系列は(1)により評価可能なので、ビームサーチによる最適化が実行できるようになります。 実際に以下のコーディネートが生成されました。矢印の左側がクエリとなるアイテム、右側が生成されたコーディネートです。 季節感やアイテムの個性を反映した組み合わせになっていると思います。弊社の社員にアンケートしたところ、かなり完成度の高いコーディネートであるとのコメントを貰いました。 スタイルの獲得に向けて 本記事で紹介した手法は、自然なコーディネートを生成可能であることを確認できました。ここで、さらなる改善を考えてみたいと思います。 ファッションにはスタイル 3 という概念が存在します。スタイルはコーディネートの方向性を与えます。同じアイテムがクエリであっても、スタイルが異なれば組み合わせるアイテムは当然変化します。 コーディネートを生成するモデルであれば、スタイルで条件付けて出力をコントロールする機能は備えていたいところです。 出力をコントロールする方法 Han2017やZhao2017はアイテム画像とそれに紐づく属性のマルチモーダル学習を提案しています。画像とテキストを同じ空間に埋め込むことで、アイテムの印象を直感的に操作可能になります。弊社でも過去に生成モデルを使ってトライしました。 Hsiao2017ではトピックモデルを利用してスタイルの認識に取り組んでいます。コーディネートを文書、コーディネートを構成するアイテムの属性を単語と捉え、文書のトピック分布をスタイルと定義しています。 Li2016はユーザーの個性ベクトルを追加して応答文の系列を一貫して変化させることに成功しました。 拡張のアイデアは様々ありますが、今回は以前からずっと気になっていた手法を適用してみます。 LDA2VEC LDA2VEC(Moody2016)はStitchfixが開発し、彼らのユーザーのコメント解析に利用している手法です。単語分散表現に文書分散表現を上乗せし、表現能力を向上させています。元々はlocal(単語)とglobal(文書)の表現を同時に用いることで予測の性能向上を狙ったものと思います。彼らのnoveltyは文書ベクトルを混合モデルとして定義したことで、混合の基底となるベクトルはトピックモデルにおけるトピックと似たような働きをするようです。 詳細に関しては彼らの素晴らしいとしか言いようのない テックブログ をご覧ください。 アイテム画像もベクトルに変換してしまえば単語ベクトルと同じように扱えます。LDA2VECのように、globalな表現であるところのコーデベクトルとlocalな表現であるアイテムベクトルを同時に学習させることで、生成されるコーディネートに変化をつけることができるかもしれません。 LDA2VECは教師なし学習なので簡単に試せますし、「機械が自らスタイルの概念を獲得できるのか」というテーマに興味が唆られたので、実際に実装して実験してみました。 ちなみに、出力を思い通りに制御したいのであれば、条件付きモデルとして拡張するほうが確実だと思います。 モデルのアップデート LDA2VECの文書ベクトルを作成するモジュールを追加します。このモジュールの出力とアイテムベクトルの和がRNNの入力となります。 一見複雑に見えますが、Outfit vector ModuleはLookup tableとLinear layerだけで定義できるので実装は簡単です。 まず、コーディネート毎のトピック分布とトピックベクトルを定義します。 コーディネート のトピック分布 は、コーディネートのトピック混合比を表します。混合比なので を満たします。 スタイルベクトル はスタイル の性格を反映したベクトルで、アイテムベクトル と同じ次元数です。ここで、 はスタイル数です。 両者の内積計算をコーディネート 特有のスタイルとみなします。 これとアイテムベクトルの和をコンテキストと定義し、RNNの入力とします。 Moody2016では混合比 を疎に保つ為、ディリクレ分布を事前分布として利用しています。 上式はディリクレ分布の対数尤度で、 はハイパーパラメータです。 の値が1より大きいと は密になり、1より小さいと疎になるよう作用します。 本手法でも同様に、 を小さく設定したディリクレ分布の負の対数尤度をコスト関数に加えます。 追加実験 (3)のコーディネート生成を、トピックを変化させながら実行しました。トピック毎の特徴は発見できず、スタイルの獲得とはいきませんでしたが、出力をある程度変化させることに成功しました。 所感として、汎用的なアイテム(白いトップスなど)は生成されるバリエーションが多く、主張の激しいアイテム(かごバッグなど)はほとんど変化しませんでした。この現象は直感と一致します。 使ってみての感想ですが、Outfit vector Moduleはなかなか癖がありました。ハイパーパラメータの設定次第では改善余地は充分に考えられます。 まとめ CNNとRNNのジョイントモデルを用いて以下の3つのタスクに挑戦しました。 (1) コーディネートの出来栄えを採点する (2) コーディネートに欠けているアイテムを選択する (3) 任意のアイテムを使ったコーディネートを生成する また、教師無しでのコーデベクトルの学習を試みました。生成されるコーディネートの制御やスタイルの獲得に向けて更に改良と実験を重ねる必要があります。 最後に VASILYでは、最新の研究にアンテナを張りながら、同時にユーザーの課題解決を積極的に行うメンバーを募集しています。 興味のある方はこちらからご応募ください。 https://www.wantedly.com/projects/109351 www.wantedly.com 参考 Han, X., Wu, Z., Jiang, Y., Davis, L. Learning Fashion Compatibility with Bidirectional LSTMs. In ACM Multimedia, 2017. He, R., Packer, C., McAuley, J. Learning compatibility across categories for heterogeneous item recommendation. In ICDM, 2016. Hsiao, W., Grauman, K. Learning the Latent “Look”: Unsupervised Discovery of a Style-Coherent Embedding from Fashion Images. In ICCV, 2017. Li, J., Galley, M., Brockett, C., Spithourakis, G., Gao, J., Dolan, B. A Persona-Based Neural Conversation Model. In ACL, 2016. Moody, C. Mixing Dirichlet Topic Models and Word Embeddings to Make lda2vec. arXiv, 2016. Zhao, B., Feng, J., Wu, X., Yan, S. Memory-Augmented Attribute Manipulation Networks for Interactive Fashion Search. In CVPR, 2017. 例えば、コーデの基本は3色、目立つ素材や柄物は1つに抑える、などです ↩ アウター・トップス・ボトムス・シューズ・バッグ・ハット・アクセサリー ↩ フェミニン、コンサバ、ガーリー、モード、マニッシュ、エスニックなどなど ↩
こんにちは。バックエンドエンジニアインターンの田島です。弊社ではIQONの運用を7年間続けています。長年の運用から技術的負債が溜まってきていました。その中の1つに、IQONの本番DBと開発DBの状態が乖離しているという問題があります。この問題をどのように解決したかについて紹介します。 IQONについて IQONはRuby on Railsで運用されており、以下のような環境で動作しています。 Ruby 2.2 Rails 4 MySQL 5.6 IQONのデータベースについて IQONではRDBとしてMySQLを利用しています。DBは本番DB、開発DB、テストDBの3種類に分かれています。スキーマ変更の作業はRailsのマイグレーション機能を利用せず、SQLを直接利用して行っています。これは、サービスの大規模化に伴い、マイグレーション機能だけでは要件を担保できなくなったためです。手順は以下のようになっています。 SQLファイルを作成 1で作成したSQLをdevelopmentDBに反映 db:schema:dump コマンドを利用しschema.rbに変更を反映 一通り確認 本番DBに1で作成したSQLをproductionDBに反映 3の手順を含める理由は、CIでのテストDBを作成時にschema.rbからDBを生成しているためです。 抱えていた問題 IQONのデータベースが抱えていた問題として以下が挙げられます。 本番DBと開発DBのVARIABLESに差異がある 本番DBと開発DBのストレージエンジンに差異がある 本番DBと開発DBのCollationに差異のあるテーブルが存在する 本番DBと開発DBのCollationに差異のあるカラムが存在する 本番DBと開発DBの型に差異のあるカラムが存在する 本番DBにしか存在しないテーブルが存在する 開発DBにしか存在しないテーブルが存在する 開発DBにしか存在しないカラムが存在する 本番DBと開発DBで多くの差異が発覚しました。この状態では、開発環境では見つからないバグが本番環境に入り込んでしまう恐れがあります。 ゴール 開発DBを以下ような状態にすることを課題解決のゴールに設定しました。 本番DBと同様のテーブル構造になっている 現在の開発DBと同様のデータを持つ 解決手順 解決の大雑把な流れを説明します。最初に開発DBからdumpデータを作成します。本番DBと同じ設定の新開発DBを用意します。最後に、作成したdumpデータ新開発DBに適用するという流れです。 詳細を時系列順に説明していきます。 空のDB(新開発DB)を用意しVARIABLESを本番DBに合わせる 本番DBでは存在せず開発DBにのみ存在するカラムを削除する 本番DBと型に差異のあるテーブルに対して型をキャストし本番に合わせたテーブルを作成 開発DBのダンプデータ(データのみ)を作成 RailsプロジェクトにActiveRecord::Mysql::Awesomeを適用 本番環境のDBからschema.rbを作成 新開発DBに対してschema.rbを適用 作成しておいたダンプデータを新開発DBに適用 確認 1.空のDB(新開発DB)を用意しVARIABLESを本番DBに合わせる 空の新開発DBを作成し、VARIABLESを本番DBに合わせます。VARIABLESはMySQLのシステム変数です。文字コードやCollationのデフォルト値などはVARIABLESで定義されているものが使われます。VARIABLESを合わせることによりMySQLの全体的な状態を本番DBと新開発DBで同一にします。 ここでは、問題1が解決されます。 2.本番DBでは存在せず開発DBにのみ存在するカラムを削除する データの移行に際してMySQLのdumpデータを利用します。開発DBにのみ存在するカラムがあると、新開発DBにdumpデータを適用する時、存在しないカラムへのinsertが発生しエラーとなってしまいます。そのため、dumpデータ作成の前に開発DBにのみ存在するカラムをすべて削除する必要があります。幸いこのようなカラムは少なかったので以下のようなSQLをカラムごとに発行しました。 ALTER TABLE テーブル名 DROP 削除するカラム; ここでは、問題8が解決されます。 3.本番DBと型に差異のあるテーブルに対して型をキャストし本番に合わせたテーブルを作成 開発DBと新開発DBのカラムの型が違うことにより、新開発DBへのdumpデータ適用時にエラーが発生してしまいます。そこで、開発DBのカラムを本番DBに合わせた型でキャストしたテーブルを作成します。そのテーブルに対しdumpデータを作ることで、型を本番DBに合わせた状態でdumpデータを作成することができます。以下のように型をキャストしたテーブルを作成します。こちらも数が少なかったので、手動で行いました。 CREATE TABLE tmp_テーブル名 SELECT column1, column2, CAST(column5 AS キャストする型) AS column5, column4 FROM テーブル名; ここでは、問題5が解決されます。 4.開発DBのダンプデータ(データのみ)を作成 やっとダンプデータ作成の段階に入ります。新開発DBではCREATE TABLEの処理を単独で行いたいので、データのみのdumpデータを作成します。dump時に -c オプションを付けることでデータのみdumpファイルを作成できます。また、新開発DBへのdumpデータ適用時に不要なテーブルの除外をしたいです。そこで、テーブルごとにdumpデータを作成します。テーブルごとに1つずつdumpデータを作るのは大変なので、以下のようなスクリプトを利用しました。 #!/bin/bash MYSQL_USER = '' MYSQL_HOST = '' MYSQL_PASS = '' MYSQL_DBNAME = '' DIR =dump__ ${MYSQL_DBNAME} if [ ! -d ${DIR} ] ; then mkdir ${DIR} fi for TABLE in `mysql -u ${MYSQL_USER} -p ${MYSQL_PASS} -h ${MYSQL_HOST} -N -s -e " show tables in ${MYSQL_DBNAME} ; " ` ; do echo $TABLE mysqldump -c -u ${MYSQL_USER} -p ${MYSQL_PASS} -h ${MYSQL_HOST} -t ${MYSQL_DBNAME} $TABLE > dump__ ${MYSQL_DBNAME} / $TABLE.sql done ; これによりテーブルごとに テーブル名.sql という名前でdumpファイルが作られます。 5.RailsプロジェクトにActiveRecord::Mysql::Awesomeを適用 IQONではRails 4を利用しています。Railsではschema.rbでDBのスキーマ情報を保持することができます。しかし、Rails 4ではCollationやストレージエンジンなどの情報をschema.rbに含めることができません。そこで ActiveRecord::Mysql::Awesome を適用することでこの問題を解決しました。 6.本番環境のDBからschema.rbを作成 本番DBのテーブル状態を新開発DBに合わせるためschema.rbを利用しました。本番DBのテーブル情報をRailsの db:schema:dump を利用しschema.rbに反映します。ActiveRecord::Mysql::Awesomeを適用しているのでCollation情報なども正しくschema.rbに反映されます。 7.新開発DBに対してschema.rbを適用 新開発DBに対し db:schema:load を利用することでschema.rbの情報からテーブルを作成します。schema.rbは本番DBのテーブル情報を適用しているので、本番と同じテーブル・カラムのみが生成されます。これにより、本番DBと新開発DBのテーブル状態が同一になります。 ここまでで、問題2,3,4,6が解決されます。 8.作成しておいたダンプデータを新開発DBに適用 新開発DBの状態が整ったので元の開発DBのデータを新開発DBに再現します。作成してあったdumpデータをinsertすることでデータを再現します。作成したdumpデータから新開発DBにのみ存在するテーブルのみをinsertします。ここでも、テーブルごとに1つずつdumpデータを適用するのは大変なので以下のようなスクリプトを利用しました。 #!/bin/bash MYSQL_USER = '' MYSQL_HOST = '' MYSQL_PASS = '' MYSQL_DBNAME = '' TABLES = ( 本番DBに存在するテーブルのリスト ) for TABLE in ${TABLES[ @ ]} ; do echo $TABLE mysql -u ${MYSQL_USER} -p ${MYSQL_PASS} -h ${MYSQL_HOST} -t ${MYSQL_DBNAME} < dump__ ${MYSQL_DBNAME} / $TABLE.sql done ; ここでは、問題7が解決されます。 9.確認 最後にデータが適切に移行されたか確認をします。確認はそれぞれのテーブルのデータ数を比較することで確認しました。ここでも、テーブルごとに1つずつ確認するのは大変なので以下のようなスクリプトを利用しました。 #!/bin/bash MYSQL_USER = '' MYSQL_HOST = '' MYSQL_PASS = '' MYSQL_DBNAME = '' TABLES = ( 新開発DBに存在するテーブルのリスト ) for TABLE in ${TABLES[ @ ]} ; do echo $TABLE mysql -u ${MYSQL_USER} -p ${MYSQL_PASS} -h ${MYSQL_HOST} -t ${MYSQL_DBNAME} -e" select count(*) from ${TABLE} " done ; まとめ 今回のメンテナンスで本番DBと開発DBの差異をなくすことに成功しました。今後の課題として、再びDB同士の差異が出ないようにしなければなりません。そのために、デプロイ前にDB同士での差異がないかの確認の自動化等を検討しています。また、データベースだけでなく改善すべきところはたくさんあります。今後も技術的負債を精算し、より安定しスピード感あるアプリケーション開発めざしていきます。 終わりに VASILYではアプリケーションの側から開発の安定化、効率化を図れるエンジニアを募集しています。興味がありましたら、以下のリンクからご応募ください。 https://www.wantedly.com/projects/61389 www.wantedly.com
こんにちは。 使うSQLが200行を超えるのが当たり前になってきたデータチームの後藤です。 本記事では、VASILYデータチームで利用しているBigQueryによるデータの前処理のTipsを紹介します。 VASILYではサービスのマスタデータやログデータをGoogle BigQueryに集約して分析に活用しています。機械学習やデータ分析のための前処理を行う際、軽量なデータであれば抽出結果をPythonに渡して処理させることもできます。しかし、分析環境のメモリに載り切らないほど大きなデータを扱う場合、BigQuery内で前処理を済ませてしまうと時間と計算資源の節約になることが多いです。 今回はBigQueryからアクセスできるパブリックデータの1つ、hacker newsのデータを集計しながらTipsを紹介したいと思います。 欠落した日付を埋める 通常のGROUP BY句の場合 SQL Results 指定した日付列を生成する SQL Results 相対的な日付列を生成する SQL Results クロス集計の欠落を埋める フィールドの生成とCROSS JOINを利用する SQL Results 誕生日から年齢を算出する SQL Results 曜日の情報を付与する SQL Results リテンションレートを計算する 基準日に登録したユーザーの継続率を追う SQL Results 大きなデータを取得する LIMIT OFFSETを利用する SQL LIMIT OFFSETの注意点 SQL 画像URLの内容を確認する SQL Results まとめ 最後に 以下に登場するSQLはStandard SQLと呼ばれる仕様にもとづいています。 Standard SQL自体については、弊社の過去の記事が参考になります。 tech.vasily.jp 欠落した日付を埋める GROUP BY句で日毎のレコード数をカウントする際、データに含まれない日付は欠落してしまいます。 通常のGROUP BY句の場合 以下のSQLは、hacker newsに投稿された日毎の記事の数を集計します。 SQL #standardSQL SELECT DATE (time_ts) AS publish_date , COUNT(*) AS article_cnt FROM `bigquery- public -data.hacker_news.stories` WHERE time_ts IS NOT NULL GROUP BY publish_date ORDER BY publish_date LIMIT 10 2006年10月16日や18日〜20日のデータが存在しないため、対応する日付が欠落しています。 Results Row publish_date article_cnt 1 2006-10-09 18 2 2006-10-10 12 3 2006-10-11 5 4 2006-10-12 6 5 2006-10-13 2 6 2006-10-14 2 7 2006-10-15 1 8 2006-10-17 1 9 2006-10-21 1 10 2006-10-22 1 このままでは扱いづらいので、GENERATE_DATE_ARRAY関数を用いて、日付の列を生成することで対処します。生成した日付列にGROUP BY句で集計した結果をLEFT JOINすると日付の欠損がないデータを作成することができます。 指定した日付列を生成する GENERATE_DATE_ARRAY関数に、明示的に日付を渡して日付列を生成します。以下の例では、2006年10月9日〜2006年10月22日までの日付列を事前に生成し、そこに集計結果をLEFT JOINしています。 SQL #standardSQL WITH -- 日付列の生成 date_series AS ( SELECT publish_date FROM UNNEST(GENERATE_DATE_ARRAY( DATE ( '2006-10-09' ), DATE ( '2006-10-22' ) )) AS publish_date) SELECT a.publish_date AS publish_date -- nullを0に置換 , IFNULL(article_num, 0 ) AS article_num FROM date_series AS a LEFT JOIN ( SELECT DATE (time_ts) AS publish_date , COUNT( 1 ) AS article_num FROM `bigquery- public -data.hacker_news.stories` WHERE time_ts IS NOT NULL GROUP BY publish_date) AS b ON a.publish_date = b.publish_date LIMIT 14 上記のSQLを実行すると、以下のように出現しなかった日付に対して0が対応付けられたデータを得ることができます。 Results Row publish_date article_num 1 2006-10-09 18 2 2006-10-10 12 3 2006-10-11 5 4 2006-10-12 6 5 2006-10-13 2 6 2006-10-14 2 7 2006-10-15 1 8 2006-10-16 0 9 2006-10-17 1 10 2006-10-18 0 11 2006-10-19 0 12 2006-10-20 0 13 2006-10-21 1 14 2006-10-22 1 相対的な日付列を生成する CURRENT_DATE()、DATE_ADD()を組み合わせることで「昨日から7日前まで」といった相対的な日付列を生成することもできます。以下の例では、CURRENT_DATE関数に'Asia/Tokyo'を渡して、東京の時刻で処理しています。 SQL #standardSQL WITH date_series AS ( SELECT create_date FROM UNNEST(GENERATE_DATE_ARRAY( DATE_ADD(CURRENT_DATE( 'Asia/Tokyo' ), INTERVAL -7 DAY), DATE_ADD(CURRENT_DATE( 'Asia/Tokyo' ), INTERVAL -1 day) )) AS create_date) SELECT create_date FROM date_series Results Row create_date 1 2017-11-22 2 2017-11-23 3 2017-11-24 4 2017-11-25 5 2017-11-26 6 2017-11-27 7 2017-11-28 クロス集計の欠落を埋める 上記の例と同じ方法で、日付×属性といったクロス集計をした際の欠落も埋めることができます。 WITH句で日付と属性をCROSS JOINした結果を用意し、そこに集計結果をLEFT JOINすることで欠落のないデータを作成することができます。 フィールドの生成とCROSS JOINを利用する 以下の例では、記事の投稿が多いTop10のAuthorが2015年1月1〜2月1日の各日に投稿した記事の数を集計しています。 SQL #standardSQL WITH -- 記事の投稿数が多いTop10ユーザーの集計 top10_users AS ( SELECT author , COUNT( 1 ) AS article_cnt FROM `bigquery- public -data.hacker_news.stories` WHERE author IS NOT NULL GROUP BY author ORDER BY article_cnt DESC LIMIT 10 ), -- 日付列の生成 date_series AS ( SELECT publish_date FROM UNNEST(GENERATE_DATE_ARRAY( DATE ( '2015-01-01' ), DATE ( '2015-02-01' ) )) AS publish_date), -- 欠落の無いフィールド author_cross_date AS ( SELECT author , publish_date FROM -- 暗黙的カンマ CROSS JOIN top10_users , date_series) SELECT a.author , a.publish_date , IFNULL(b.article_cnt, 0 ) AS article_cnt FROM author_cross_date AS a LEFT JOIN ( SELECT author , DATE (time_ts) AS publish_date , COUNT( 1 ) AS article_cnt FROM `bigquery- public -data.hacker_news.stories` GROUP BY author , publish_date) AS b ON a.author = b.author AND a.publish_date = b.publish_date ORDER BY author, publish_date Results Row author publish_date article_cnt 1 ColinWright 2015-01-01 0 2 ColinWright 2015-01-02 1 3 ColinWright 2015-01-03 3 4 ColinWright 2015-01-04 2 5 ColinWright 2015-01-05 0 6 ColinWright 2015-01-06 2 <中略> 315 tokenadult 2015-01-27 0 316 tokenadult 2015-01-28 1 317 tokenadult 2015-01-29 0 318 tokenadult 2015-01-30 1 319 tokenadult 2015-01-31 2 320 tokenadult 2015-02-01 1 以下は、得られた結果を2次元の表としてみたものです。各Authorの投稿がない日には0が入っていることがわかります。 誕生日から年齢を算出する 年齢は日毎に変化するデータなので、誕生日から算出します。日付を'YYYYMMDD'のフォーマットに変換して、 を計算することで算出できます。 例えば、 1988年6月24日生まれの人は2017年12月4日時点で、 となり29歳であることがわかります。 この考え方を使って、hacker newsの各記事が投稿されてからの経過年数を算出してみます。 SQL #standardSQL SELECT id , DATE (time_ts) AS publish_date -- FORMAT_DATE関数を用いて、日付を8桁の整数に変換する , CAST((CAST(FORMAT_DATE( '%Y%m%d' , CURRENT_DATE()) AS INT64) - CAST(FORMAT_DATE( '%Y%m%d' , DATE (time_ts) ) AS INT64)) / 10000 AS INT64) AS age FROM `bigquery- public -data.hacker_news.stories` LIMIT 10 Results Row id publish_date age 1 7330177 2014-03-02 3 2 3671730 2014-05-31 3 3 6059920 2014-05-31 3 4 6528376 2014-05-31 3 5 4697562 2014-05-31 3 6 2249839 2014-05-31 3 7 1578400 2014-05-31 3 8 3563175 2014-05-31 3 9 6969930 2013-12-27 4 10 6990072 2013-12-31 4 曜日の情報を付与する 曜日の情報を付与する場合、dayofweekを利用します。1〜7の整数が振られ、それぞれ日曜日〜土曜日に対応します。 以下のクエリでは曜日ごとの記事の数を集計してみます。 SQL #standardSQL SELECT day_of_week , COUNT(*) AS article_cnt FROM ( SELECT id , DATE (time_ts) AS publish_date , EXTRACT(dayofweek FROM DATE (time_ts)) AS day_of_week FROM `bigquery- public -data.hacker_news.stories` WHERE time_ts IS NOT NULL ) GROUP BY day_of_week ORDER BY day_of_week 以下の結果から、土日の投稿数が平日の投稿数の半数程度であることがわかります。 Results Row day_of_week article_cnt 1 1 160002 2 2 310330 3 3 339530 4 4 333913 5 5 326648 6 6 294343 7 7 169322 リテンションレートを計算する 基準日に登録したユーザーの継続率を追う N日継続率(Retention Rate)とは、ある日にサービスを使い始めたユーザー全体のうち、そのN日後に再度サービスを利用したユーザーの割合のことを指します。毎日利用されることを目指しているサービスでは、この指標を高めることがサービス改善の指針になります。 以下のクエリは基準日に登録したユーザーのN日継続率を集計します。 SQL #standardSQL WITH first_publish_authors AS ( -- 基準日に初めて投稿したユーザーのみ抽出 SELECT author FROM ( SELECT author , MIN( DATE (time_ts)) AS first_publish_date FROM `bigquery- public -data.hacker_news.stories` WHERE author IS NOT NULL AND time_ts IS NOT NULL GROUP BY author ORDER BY first_publish_date) WHERE -- 基準日を指定 first_publish_date = DATE ( '2015-01-01' )) SELECT publish_date -- 基準日に投稿したユーザーの各日のリテンションレート(%)を計算 , COUNT( 1 ) / ( SELECT COUNT( 1 ) FROM first_publish_authors ) * 100 AS retention_rate FROM ( SELECT    -- 基準日に投稿したユーザーがその後に投稿したレコードを集計 author , DATE (time_ts) AS publish_date FROM `bigquery- public -data.hacker_news.stories` WHERE author IN ( SELECT author FROM first_publish_authors) AND time_ts IS NOT NULL GROUP BY author , publish_date) GROUP BY publish_date ORDER BY publish_date LIMIT 5 2015年1月1日に登録されたhacker newsの投稿者の場合、約10%のユーザーがその翌日にも投稿したことがわかります。 Results Row publish_date retention_rate 1 2015-01-01 100.0 2 2015-01-02 9.803921568627452 3 2015-01-03 7.8431372549019605 4 2015-01-04 3.9215686274509802 5 2015-01-05 1.9607843137254901 6 2015-01-06 3.9215686274509802 7 2015-01-07 3.9215686274509802 8 2015-01-08 5.88235294117647 9 2015-01-09 3.9215686274509802 <省略> 大きなデータを取得する サイズの大きな抽出結果は一括で取得できないことがあります。そんな状況ではORDER BY句を使いデータの並びを一意に固定してから、LIMITとOFFSETを利用して少しずつデータを取得します。 LIMIT OFFSETを利用する 以下のクエリではid順にデータをソートした後、OFFSETで指定した最初の1000レコードを飛ばして、1001番目から1500番目までの500レコードを抽出しています。毎回ソートしてから取得するので効率は悪いですが、OFFSETを増やしていくことで最終的にすべてのデータを取得することができます。 SQL SELECT id , title FROM `bigquery- public -data.hacker_news.stories` ORDER BY id LIMIT 500 OFFSET 1000 LIMIT OFFSETの注意点 抽出するデータのサイズが巨大な場合、OFFSETが大きくなるに従ってメモリ使用量を圧迫します。その結果、上記のクエリでは一定のOFFSETを超えると、以下のようなエラーを吐いて落ちることがあります。 Query Failed Error: Resources exceeded during query execution: The query could not be executed in the allotted memory. ORDER BY operator used too much memory.. このようなエラーを防ぐには、利用するメモリの量を減らす必要があります。取得するidを先に抽出し、そのあとtitleをLEFT JOINすることでサイズの大きなデータを取得できるようになります。 (このSQLは非常に効率が悪いと思っています。より良い表現があればご教授いただきたいです) SQL #standardSQL SELECT a.id AS id , b.title AS title FROM ( SELECT id FROM `bigquery- public -data.hacker_news.stories` WHERE title IS NOT NULL AND author IS NOT NULL ORDER BY id LIMIT 500 OFFSET 1000 ) AS a LEFT JOIN ( SELECT id , title FROM `bigquery- public -data.hacker_news.stories`) AS b ON a.id = b.id 画像URLの内容を確認する 画像URLが入ったフィールドを抽出した際、どんな画像が入っているかを把握したい場合に手軽に確認できるテクニックです。 以下のクエリでは、hacker_newsのコメントデータからJPEG画像のURLを抽出します。 SQL SELECT id , URL FROM ( SELECT id , REGEXP_EXTRACT_ALL(text, r '(?i:(?:(?:(?:ftp|https?):\/\/)(?:www\.)?|www\.)(?:[\da-z-_\.]+)(?:[a-z\.]{2,7})(?:[\/\w\.-_\?\&]*).jpg\/?)' ) AS URL FROM `bigquery- public -data.hacker_news.comments` WHERE -- '.jpg'が含まれているレコードだけを対象にする text LIKE '%.jpg%' ORDER BY id), UNNEST(URL) AS URL GROUP BY id , URL LIMIT 100 クエリの抽出結果をSend to Gogle Sheetsボタンを押してスプレッドシートに保存します。 URLが含まれるセルをimage関数に渡します。image関数は画像のURLを渡すと、URL先の画像を表示する関数です。 Results 以下のように、URLの画像の内容が把握できました。 まとめ 本記事では、VASILYデータチームが活用しているBigQueryのTipsを紹介しました。データによって必要となる処理は様々だとは思いますが、ここに記載したSQLの一部でも参考になったなら幸いです。 より体系的に分析のためのSQLを学びたい方には、以下の書籍がおすすめです。高度なデータ分析のためのSQLの例がPostgreSQL、Hive、Redshift、BigQuery、SparkSQLの5つの仕様に対応して上手く書き分けられている良書です。 最後に 弊社では、ファッションに関するデータに強い関心がありデータ分析や機械学習の腕に覚えのある方を募集しています。
こんにちは。インフラエンジニアの光野です。 AWS re:Invent 2017で次々と新発表があり、ワクワクがとまりません。また最近はACMがDNS検証で証明書を発行できるようになったり、SpotFleetがELB Auto Attachできるようになったり、個人的に嬉しいアップデートが続いています。来年もますますAWSのファンになりそうです。 さて、そんなキラキラ(?)した話題は一旦おいて、本記事では泥臭くAmazon S3の権限管理について考えたいと思います。 S3と権限管理 ご存知の通り、S3は99.999999999%の耐久性と実質無限の容量を持つオブジェクトストレージです。弊社では主にファッションアイテムの画像とコーデ画像の保存先として利用しています。 保存は俺に任せろと言わんばかりのS3ですが、一方でオブジェクトの権限管理は利用者に委ねられています。 「誰が・どのオブジェクトに対して・何ができるか」を適切に把握し管理しない場合、重要なデータがインターネットから閲覧可能になっていた。といったインシデントが発生しかねません。 本記事では、実際に運用することをイメージしつつ権限を設定してみたいと思います。 権限管理 本記事では以下の4要素を使ってS3の権限を操作します。 S3 Bucketポリシー VPCエンドポイントポリシー インスタンスプロファイル(IAMロール) IAMユーザ 状況設定 さて、このような構成を考えます。 要件は次の通りです。 インスタンスはS3へのPut権限を持つ 画像の参照はCloudFront経由に限定する 画像の更新はVPCエンドポイント経由に限定する VPC内からアクセスできるS3 Bucketを限定する IAMユーザは、運用作業でデータを削除することがある データは暗号化する 1つずつ設定していきたいと思います。 インスタンスはS3へのPut権限を持つ 手始めに、インスタンスに対してPutObjectの権限を与えます。 設定箇所:インスタンスプロファイル { " Version ": " 2012-10-17 ", " Statement ": [ { " Effect ": " Allow ", " Action ": [ " s3:PutObject " ] , " Resource ": [ " arn:aws:s3:::a-service-static/* " ] } ] } データを保存するだけであれば PutObject 1つで可能です。 " Resource ": [ " arn:aws:s3:::a-service-static/* " ] Bucket以下の任意の要素に対して権限を付与してやります。 画像の参照はCloudFront経由に限定する 次に保存された画像を参照することを考えます。 S3においた画像を公開する場合、オブジェクトのACLをPublicとすることもできますが、ここではCloudFrontを経由して配信します。 OAI(Origin Access Identity)を使って、オブジェクトに対してCloudFront経由でのアクセスを許可します。 設定箇所:S3 Bucketポリシー { " Version ": " 2012-10-17 ", " Statement ": [ { " Effect ": " Allow ", " Principal ": { " AWS ": " arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ABCDEFGHIJKLMN " } , " Action ": " s3:GetObject ", " Resource ": " arn:aws:s3:::a-service-static/* " } ] } OAIはCloudFrontで事前に作成しておく必要があります。 ABCDEFGHIJKLMN の部分がOAI毎のユニークなIDです。 OAIという要素(Principal)に対して権限を付与するので、オブジェクトのACLにPublicを設定する必要はありません。むしろ、Publicを指定してしまうとS3のURL( https://a-service-static.s3.amazonaws.com/... )でもアクセス可能になってしまいます。不要なアクセス経路が増えるのは望ましくありません。 ここで更にCloudFront以外からのアクセスを明示的に拒否してみます。 設定箇所:S3 Bucketポリシー { " Version ": " 2012-10-17 ", " Statement ": [ { " Sid ": " DenyGet ", " Effect ": " Deny ", " NotPrincipal ": { " AWS ": " arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ABCDEFGHIJKLMN " } , " Action ": [ " s3:Get* ", " s3:List* " ] , " Resource ": [ " arn:aws:s3:::a-service-static ", " arn:aws:s3:::a-service-static/* " ] } , { " Effect ": " Allow ", " Principal ": { " AWS ": " arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ABCDEFGHIJKLMN " } , " Action ": " s3:GetObject ", " Resource ": " arn:aws:s3:::a-service-static/* " } ] } NotPricipal を使って「OAI以外の要素からの参照」を全て却下します。実際の所、このままだとWebコンソールなどでBucketの中を覗くこともできなくなるため、後で少し変更します。 画像の更新はVPCエンドポイント経由に限定する 次に画像の更新をVPCエンドポイント経由に限定してみます。更新をVPCエンドポイント経由に限定することで、不正な更新・削除を防ぐ効果を期待します。 設定箇所:S3 Bucketポリシー { " Version ": " 2012-10-17 ", " Statement ": [ { " Sid ": " DenyUpdate ", " Effect ": " Deny ", " Principal ": " * ", " NotAction ": [ " s3:Get* ", " s3:List* " ] , " Resource ": [ " arn:aws:s3:::a-service-static ", " arn:aws:s3:::a-service-static/* " ] , " Condition ": { " StringNotEquals ": { " aws:sourceVpce ": " vpce-12345678 " } } } ] } (参照をOAI経由に限定する部分は省略しています) まず Principal です。 " Principal ": " * ", 任意の要素を対象とします。 次に NotAction です。 " NotAction ": [ " s3:Get* ", " s3:List* " ] , 参照系の否定で更新系を表現します。 最後に Condition です。VPCエンドポイントを使わない場合はNATなどのgIPを指定しますが、VPCエンドポイントを通す場合はVPCエンドポイントIDで管理します。 " Condition ": { " StringNotEquals ": { " aws:sourceVpce ": " vpce-12345678 " } } つまり、任意の要素からの更新系についてアクセス元が特定のVPCエンドポイント以外であれば全て拒否するというポリシーになります。 VPC内からアクセスできるS3 Bucketを限定する ここまでで、a-service-staticに対するアクセスは「参照:CloudFront経由」「更新:VPCエンドポイント経由」に限定できました。 しかし、これだけではVPC内から a-service-static 以外のBucketに対して書き込み(持ち出し)されてしまうかもしれません。 今度はこれを防止します。VPCエンドポイントを使う場合、VPCエンドポイントポリシーを使ってアクセス先Bucketを限定することが可能です。 デフォルトのVPCエンドポイントポリシーは以下のように任意のアクセスを許可する形になっています。 { " Statement ": [ { " Action ": " * ", " Effect ": " Allow ", " Resource ": " * ", " Principal ": " * " } ] } 明示的にアクセス先を限定します。 { " Version ": " 2008-10-17 ", " Statement ": [ { " Effect ": " Deny ", " Principal ": " * ", " NotAction ": [ " s3:Get* ", " s3:List* " ] , " NotResource ": [ " arn:aws:s3:::a-service-static ", " arn:aws:s3:::a-service-static/* " ] } , { " Effect ": " Deny ", " Principal ": " * ", " Action ": [ " s3:Get* ", " s3:List* " ] , " NotResource ": [ " arn:aws:s3:::apt.mackerel.io ", " arn:aws:s3:::apt.mackerel.io/* " ] } , { " Effect ": " Allow ", " Principal ": " * ", " Action ": " s3:PutObject ", " Resource ": [ " arn:aws:s3:::a-service-static/* " ] } , { " Effect ": " Allow ", " Principal ": " * ", " Action ": " s3:GetObject ", " Resource ": [ " arn:aws:s3:::apt.mackerel.io/* " ] } ] } 突然、 apt.mackerel.io という要素が登場していますが、順を追って説明します。 まず、大まかな流れは以下のとおりです。 特定のBucket以外に対する更新系を禁止 特定のBucket以外に対する参照系を禁止 特定のBucketに対する更新系を明示的に許可 特定のBucketに対する参照系を明示的に許可 VPCエンドポイントポリシーには注意すべき点が2つ存在します。 1つ目は「明示的な許可」です。アクセスを許可する要素を明示的に指定しない場合、VPCの内側に存在する要素(例えばEC2インスタンス)が対象へのアクセス権限を持っていたとしてもアクセスが却下されてしまいます。 2つ目は「自己所有以外のS3 Bucketに対する考慮」です。弊社で採用している 監視ツール:Mackerel を例にします。 MackerelのDebianパッケージは apt.mackerel.io というドメインからダウンロードされますが、これの実体はS3 Bucketです。ポリシーで明示しておかないと、インストールに失敗します。 このように、VPCエンドポイントポリシーを設定する場合は自己所有以外のS3 Bucketへのアクセスも洗い出しておかないと、構成管理などで問題となります。これを踏まえて読み解きます。 a-service-static以外への更新系を全て禁止 事前に定められたBucket以外への書き込みを禁止 apt.mackerel.io以外への参照系を全て禁止 運用上必要なファイル以外の読み込みを禁止 a-service-staticへの更新系を明示的に許可 apt.mackerel.ioへの参照系を明示的に許可 とはいえ、あくまでもBucketへのアクセスだけなので、どこかの知らないサーバに対してWebAPIで持ち出し。といった不正な通信を防ぐ効果はありません。 IAMユーザは、運用作業でデータを削除することがある 最後はユーザ操作に対するフォローです。 ここまでの設定で、アプリケーションに必要な権限は明示的に指定できました。しかし、実運用において、これだけではあまりにも窮屈すぎます。 WebコンソールからBucketの中を確認し、時には人の手で更新する運用が発生するかもしれません。そのため、管理者が触れる用にポリシーを変更します。 設定箇所:S3 Bucketポリシー { " Version ": " 2012-10-17 ", " Statement ": [ { " Sid ": " DenyUpdate ", " Effect ": " Deny ", " NotPrincipal ": { " AWS ": [ " arn:aws:iam::123456789098:user/ops-user1 ", " arn:aws:iam::123456789098:user/ops-user2 " ] } , " NotAction ": [ " s3:Get* ", " s3:List* " ] , " Resource ": [ " arn:aws:s3:::a-service-static ", " arn:aws:s3:::a-service-static/* " ] , " Condition ": { " StringNotEquals ": { " aws:sourceVpce ": " vpce-12345678 " } } } , { " Sid ": " DenyGet ", " Effect ": " Deny ", " NotPrincipal ": { " AWS ": [ " arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ABCDEFGHIJKLMN ", " arn:aws:iam::123456789098:user/ops-user1 ", " arn:aws:iam::123456789098:user/ops-user2 " ] } , " Action ": [ " s3:Get* ", " s3:List* " ] , " Resource ": [ " arn:aws:s3:::a-service-static ", " arn:aws:s3:::a-service-static/* " ] } ] } わかりづらいですが、NotPrincipalにIAMユーザが追加されています。 " Sid ": " DenyUpdate ", " Effect ": " Deny ", " NotPrincipal ": { " AWS ": [ " arn:aws:iam::123456789098:user/ops-user1 ", " arn:aws:iam::123456789098:user/ops-user2 " ] } , " Sid ": " DenyGet ", " Effect ": " Deny ", " NotPrincipal ": { " AWS ": [ " arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ABCDEFGHIJKLMN ", " arn:aws:iam::123456789098:user/ops-user1 ", " arn:aws:iam::123456789098:user/ops-user2 " ] } , あとはこのユーザがS3に対する適切な権限を持っていれば、Webコンソールから参照・更新が可能です。 設定箇所:IAMユーザ { " Version ": " 2012-10-17 ", " Statement ": [ { " Action ": [ " s3:GetObject* ", " s3:PutObject* ", " s3:DeleteObject* ", " s3:List* " ] , " Resource ": [ " arn:aws:s3:::a-service-static/* ", " arn:aws:s3:::a-service-static " , ] , " Effect ": " Allow " }, { " Action ": [ " s3:ListAllMyBuckets " ] , " Resource ": [ " arn:aws:s3:::* " ] , " Effect ": " Allow " } ] } AssumeRoleを使うことで、ユーザ一人ひとりを列挙しない方法もありますが、本記事の範囲を外れるため割愛します 折角なのでもうひと工夫します。IAMユーザに対して権限を付与する場合、awscliによるアクセスについても検討する必要があります。 ユーザによって発行されたアクセストークンがあれば実際が誰であってもアクセスできるためです。トークンの発行を禁止するというのも1つの手段ですが、ここではIP制限をかけることでリスク低減を図ります。 設定箇所:S3 Bucketポリシー { " Version ": " 2012-10-17 ", " Statement ": [ { " Sid ": " DenyGet ", " Effect ": " Deny ", " NotPrincipal ": { " AWS ": [ " arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ABCDEFGHIJKLMN ", " arn:aws:iam::123456789098:user/ops-user1 ", " arn:aws:iam::123456789098:user/ops-user2 " ] } , " Action ": [ " s3:Get* ", " s3:List* " ] , " Resource ": [ " arn:aws:s3:::a-service-static ", " arn:aws:s3:::a-service-static/* " ] } , { " Effect ": " Deny ", " Principal ": { " AWS ": [ " arn:aws:iam::123456789098:user/ops-user1 ", " arn:aws:iam::123456789098:user/ops-user2 " ] } , " Action ": " * ", " Resource ": [ " arn:aws:s3:::a-service-static ", " arn:aws:s3:::a-service-static/* " ] , " Condition ": { " NotIpAddress ": { " aws:SourceIp ": " xx.xx.xx.xx/32 " } } } ] } Denyポリシーを1つ増やしました。ユーザに対して、特定のIP以外からのアクセスを禁止するというものです。これをオフィスのgIPとすることで、執務エリア、もしくはVPNを貼った状態でなければアクセスできないという状態を作ることが可能です。 データは暗号化する おまけで、データの暗号化についてもポリシーで強制してしまいましょう。 S3はオブジェクトを保存する際に様々な手法でそれを暗号化することができます。幾つかの暗号化方式に対しては、ポリシーでそれの利用を強制することが可能です。ここでは、SSE-AES256を強制してます。 設定箇所:S3 Bucketポリシー { " Version ": " 2012-10-17 ", " Statement ": [ { " Effect ": " Deny ", " Principal ": " * ", " Action ": " s3:PutObject ", " Resource ": " arn:aws:s3:::a-service-static/* ", " Condition ": { " StringNotEquals ": { " s3:x-amz-server-side-encryption ": " AES256 " } } } ] } 暗号化用のヘッダがない場合、オブジェクトの保存が失敗するようになります。 設定内容まとめ 設定内容をまとめます。 設定箇所:S3 Bucketポリシー { " Version ": " 2012-10-17 ", " Statement ": [ { " Effect ": " Deny ", " Principal ": " * ", " Action ": " s3:PutObject ", " Resource ": " arn:aws:s3:::a-service-static/* ", " Condition ": { " StringNotEquals ": { " s3:x-amz-server-side-encryption ": " AES256 " } } } , { " Sid ": " DenyUpdate ", " Effect ": " Deny ", " NotPrincipal ": { " AWS ": [ " arn:aws:iam::123456789098:user/ops-user1 ", " arn:aws:iam::123456789098:user/ops-user2 " ] } , " NotAction ": [ " s3:Get* ", " s3:List* " ] , " Resource ": [ " arn:aws:s3:::a-service-static ", " arn:aws:s3:::a-service-static/* " ] , " Condition ": { " StringNotEquals ": { " aws:sourceVpce ": " vpce-12345678 " } } } , { " Sid ": " DenyGet ", " Effect ": " Deny ", " NotPrincipal ": { " AWS ": [ " arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ABCDEFGHIJKLMN ", " arn:aws:iam::123456789098:user/ops-user1 ", " arn:aws:iam::123456789098:user/ops-user2 " ] } , " Action ": [ " s3:Get* ", " s3:List* " ] , " Resource ": [ " arn:aws:s3:::a-service-static ", " arn:aws:s3:::a-service-static/* " ] } , { " Effect ": " Allow ", " Principal ": { " AWS ": " arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ABCDEFGHIJKLMN " } , " Action ": " s3:GetObject ", " Resource ": " arn:aws:s3:::a-service-static/* " } , { " Effect ": " Deny ", " Principal ": { " AWS ": [ " arn:aws:iam::123456789098:user/ops-user1 ", " arn:aws:iam::123456789098:user/ops-user2 " ] } , " Action ": " * ", " Resource ": [ " arn:aws:s3:::a-service-static ", " arn:aws:s3:::a-service-static/* " ] , " Condition ": { " NotIpAddress ": { " aws:SourceIp ": " xx.xx.xx.xx/32 " } } } ] } 設定箇所:VPCエンドポイントポリシー { " Version ": " 2008-10-17 ", " Statement ": [ { " Effect ": " Deny ", " Principal ": " * ", " NotAction ": [ " s3:Get* ", " s3:List* " ] , " NotResource ": [ " arn:aws:s3:::a-service-static ", " arn:aws:s3:::a-service-static/* " ] } , { " Effect ": " Deny ", " Principal ": " * ", " Action ": [ " s3:Get* ", " s3:List* " ] , " NotResource ": [ " arn:aws:s3:::apt.mackerel.io ", " arn:aws:s3:::apt.mackerel.io/* " ] } , { " Effect ": " Allow ", " Principal ": " * ", " Action ": " s3:PutObject ", " Resource ": [ " arn:aws:s3:::a-service-static/* " ] } , { " Effect ": " Allow ", " Principal ": " * ", " Action ": " s3:GetObject ", " Resource ": [ " arn:aws:s3:::apt.mackerel.io/* " ] } ] } 設定箇所:インスタンスプロファイル { " Version ": " 2012-10-17 ", " Statement ": [ { " Effect ": " Allow ", " Action ": [ " s3:PutObject " ] , " Resource ": [ " arn:aws:s3:::a-service-static/* " ] } , 設定箇所:IAMユーザ { " Version ": " 2012-10-17 ", " Statement ": [ { " Action ": [ " s3:GetObject* ", " s3:PutObject* ", " s3:DeleteObject* ", " s3:List* " ] , " Resource ": [ " arn:aws:s3:::a-service-static/* ", " arn:aws:s3:::a-service-static " , ] , " Effect ": " Allow " }, { " Action ": [ " s3:ListAllMyBuckets " ] , " Resource ": [ " arn:aws:s3:::* " ] , " Effect ": " Allow " } ] } まとめ 本記事ではS3に対して、可能な限り明示的に権限管理を行いました。 権限管理は案件毎に要件が全く異なるため、本記事の内容がそのまま使えることはないと思いますが、記述の参考にでもなれば幸いです。 権限管理は、正直複雑ですし窮屈になりがちです。とはいえノーガードというわけにもいきません。 これからもより良いバランスを探していきたいと思います。 終わりに VASILYでは安全かつ自由なインフラ管理に興味があるエンジニアを募集しています。 興味ある方は以下のリンクからご応募ください。
こんにちは! 食欲の秋でいつもお腹が空いているバックエンドエンジニアのりほやんです。 VASILYでは11月の13,14日に第1回開発合宿を開催しました! とても楽しい合宿になったため、本記事では開発合宿の様子をレポートします! 出発 今日はエンジニアとデザイナーで開発合宿です!湯河原いくよー! #VASILY開発合宿 — キュン/今村雅幸 (@kyuns) 2017年11月13日 品川駅に全員集合して、踊り子号で湯河原に向かいます。 行きの電車の中からすでに開発を始めている人が…! 開発始まってる #VASILY開発合宿 pic.twitter.com/AIf4OkGY0H — キュン/今村雅幸 (@kyuns) 2017年11月13日 到着 1時間ほどで湯河原に到着です! 到着後は、お昼ごはん! みんな昼ごはん同じ場所〜〜 #vasily開発合宿 pic.twitter.com/Jg6Bu3roG7 — りほ (@rllllho) 2017年11月13日 #vasily開発合宿 pic.twitter.com/Bbzv35XaD6 — ホリエ (@Horie1024) 2017年11月13日 お蕎麦美味しそう。 ねこかわいい。 普段、あまり喋る機会がないメンバーとも交流することができました! 宿到着 昼ごはんを食べた後は、駅からタクシーで宿に向かいます。 10分ほどで今回宿泊するお宿、おんやど恵さんに到着です! 中に入るとロビーに、昔ながらの野球盤があったり今流行りのフォトプロップスがありひと盛り上がり。 開発開始 CTO今村から開会宣言を行ってもらい開発スタートです! 今回は『普段できない開発を行う』をテーマに、各自事前に開発テーマを考えてきてもらいました。 2日目に、1人5分程度のLTで、開発合宿で行ったことを各自発表してもらいます。 なんと今回は、CTO今村から特別に今村賞を用意していただきました! 今村賞めざしてみんな頑張ります! もくもく! #vasily開発合宿 pic.twitter.com/E75sdGqOpI — りほ (@rllllho) 2017年11月13日 夜ご飯 一通り開発したところで夜ご飯です! おいしそうなご飯が並びます。 かんぱーい! みなさん思い思いに楽しんでいます。 日本酒を楽しむ人たちもいます。 みなさん自由に夜ご飯を楽しんだようです! 開発再開? 夜ご飯後は自由時間です。 お酒飲んだり、開発したりみなさん思い思いに楽しんでいます。 獺祭解禁。 お酒飲みながら開発も楽しそうです! また、おんやど恵さんには足湯がありwifiもつながるため、足湯コーディングしている人もいました。 足湯コーディングが最高すぎる #vasily開発合宿 — ホリエ (@Horie1024) 2017年11月13日 足湯ビール開発♨️🍺👨‍💻 最高としか。 #VASILY開発合宿 https://t.co/xas74gWytq — WorldDownTown (@WorldDownTown) 2017年11月13日 足湯大好評です! 朝ごはん 徹夜した人もいたようですが、私は朝5時に起きて朝風呂に入っていました。朝風呂最高でした。 ぐっすり寝た後は、朝ごはん! 朝ごはんもおいしかったです! LT発表 最後は、開発合宿の成果発表LTです! みなさん、今村賞目当てにやる気満々です。 発表順は、CTOがその場でシャッフルして決めます。 水着の着せ替えができるGANの開発を行った人もいたり、 VASILY社員のためのシャッフルランチアプリをデザインする人もいました! みなさん限られた時間で開発をやりきり、デモを含めた発表までやりきっていました! 表彰式 最後にCTO今村からの表彰です。 特別賞:アナログ計算機はニューラルネットワークの夢を見るか(塩崎) バックエンドエンジニアの塩崎は、アナログ計算機の手法を用いて階層型ニューラルネットワークの実装を行いました。ニューラルネットワークの構成要素をOPアンプで実装し、動作確認を回路シミュレータLTSpide上で行いました。 「内容はよくわからないけど評価しておかないともったいない気がする」という理由で特別賞を受賞しました! 第3位:コーポレートサイトのNuxt.js化(権守) フロントエンドエンジニアの権守は、弊社コーポレートサイトをNuxt.jsによる実装に置き換える試みをしました。 Nuxt.jsはVue.jsアプリケーションを構築するためのフレームワークです。 Vue.js単体で構築されていたコーポレートサイトをNuxt.jsに乗せることでSSR(サーバーサイドレンダリング)を簡単に実現できました。 「完成度が高く、実際にすぐに導入できるレベル」という点が特に評価され、3位に選ばれました。 第2位 :実験経過を監視するsomething(中村) データサイエンスチームの中村は、実験経過を監視するextensionを開発しました。 ネットワークの学習中にフレームワークが吐き出すメトリクスをslackにpostして学習のステータスを把握できるようにしました。 「現状抱える課題をうまく解決しており、すでに稼働している」という点が評価され、2位に選ばれました。 第1位 :Rustクライアントサイドフレームワーク龍(茨木) フロントエンドチームの茨木は、Rustをもちいてフレームワーク『Rju』を開発しました。 rjuはRust+ WebAssemblyで動くクライアントサイドフレームワークです。 いわばReact.jsやVue.jsのRust版で、Rustのコード中にHTMLを書くだけでそれが自動でレンダリングされます。 型安全なのに加え、Rustのプラグインマクロ機能によりビルドツールが不要なことが特徴です。 「限られた時間のなかで新しい言語に取り組み、かつ開発物の利点をうまく伝えられたこと、デモの完成度が高かったこと」が評価され見事1位に選ばれました。 以上4名が今村賞を受賞しました! さすがです! 最後に今村から、 「本当にみんなに賞あげたいぐらい良い発表だった」というお言葉が。 みなさん本当におもしろい発表が多かったです。 開発合宿の成果をVASILYアドベントカレンダーに書く人もいるそうなので、ぜひこちらもぜひチェックしてください! qiita.com 帰宅 最後に記念写真をパチリ。 宿を後にして、湯河原駅の周辺でお土産を買います。 個人的に、シュークリームと温泉まんじゅうが本当に美味しかったのでぜひ食べて帰ってもらいたいです。 お土産を買い終えたら、踊り子に乗って帰宅です! 帰りの車内でも、乾杯している人たちがいました。 品川に到着したら解散です! お疲れ様でした! 感想 開発合宿の参加者に、アンケート取ってみた結果です。 開発合宿の満足度を教えてください みなさん大満足なようです。よかったー! 良かったこと 宿が近い&快適で良かった! お宿が綺麗だった 業務のタスク以外の研究開発にまとまった時間を充てられたのが良かった 開発部屋を深夜も使えた。 今村賞があったこと デザイナー組も一緒だったこと 開発内容のバリエーションが多く、LTを聞くことでもインプットが出来た 足湯でwifiが使える 集中できる環境だったこと。息抜きの温泉もとても良かった 次回改善したいこと 2泊にして、飲み会は開発と発表が終わってからにしたい 開発時間をもっと多く取りたい LT後に宴会があれば、開発も宴会も捗りそう 平日ランチで閉まってる店が多い 開発時間がもうちょっと欲しかったという意見が多かったようです。 次回開催時に活かしたいと思います! 気をつけること 実際行ってみていくつか困ったことがあったのでご紹介します。 昼ごはんやっている店が少なかった 駅や宿から徒歩圏内で行けるごはん処が少なかったのと、定休日が重なり昼ごはんの場所探しに苦労しました。 事前にもっと調べていけばよかったなと思ったのでぜひ皆さんは調べてから行ってください! コンビニが遠い(徒歩12分ほど) おんやど恵さんから一番近いコンビニ(セブンイレブン)までは徒歩12分程かかります。 宿内に売店があり、コアラのマーチやチョコレート、ハーゲンダッツなどが売っているのでそちらを活用するのも良いと思います。 まとめ 第1回VASILY開発合宿のレポートでした! 美味しいご飯食べて温泉に入って、普段あまり喋ったことないメンバーとも交流することができ、とても楽しかったです。 また、普段と違う環境で、普段できない開発ができ良い開発合宿になりました。 みなさんもぜひ開発合宿を開催してみてください! 最後に VASILYでは一緒に働くエンジニアを募集しています! ご興味のある方は下のリンクからご応募ください!
課金とPush通知攻略に邁進中のじょーです。 今回は、ひとつのアプリに自動更新購読型と消耗型を共存させたときのサーバーサイドで行うレシート検証のTipsを紹介します。 自動更新購読型課金のサーバーサイド実装について 自動更新購読型課金単体で実装する場合はこちらの記事が参考になります。 (昔書いた記事で古い情報がある場合があります) 下記の記事では月額課金と呼んでいますが、自動更新購読と同義です。 tech.vasily.jp 消耗型課金のサーバーサイド実装について 消耗型課金単体で実装する場合はこちらの記事が参考になります。 tech.vasily.jp 自動更新購読型課金と消耗型課金を共存させるときのレシート検証 「レシート検証って何?」という疑問については、上記にリンクを載せた記事にすでに書いてあるので、この記事では触れないことにします。 自動更新購読型課金と消耗型課金を同じアプリに共存させようとした場合、気になるのがレシート検証の仕方です。 AppStoreのサーバーから返ってくるレシートの形式によって、レシート検証の仕方がそれぞれ単独で存在していたときとは異なってきます。 例えば、レシートはそれぞれ発行されるのか?ひとつのレシートにどちらの情報も返ってくるのか?その場合新しいJSONキーが増えるのか?等が気になる事項です。 さっそく二種類の購入型を共存させたときに実際に返ってくるレシートを見てみましょう。 共存させたときに返るレシートの内容 { " status "=> 0 , " environment "=>" Sandbox ", " receipt "=> { "receipt_type"=>" ProductionSandbox ", "adam_id"=> 0 , "app_item_id"=> 0 , "bundle_id"=>" your bundle id ", "application_version"=>" 36 ", "download_id"=> 0 , "version_external_identifier"=> 0 , "receipt_creation_date"=>" 2017-10-23 12:15:47 Etc/GMT ", "receipt_creation_date_ms"=>" 1508760947000 ", "receipt_creation_date_pst"=>" 2017-10-23 05:15:47 America/Los_Angeles ", "request_date"=>" 2017-10-25 07:03:22 Etc/GMT ", "request_date_ms"=>" 1508915002492 ", "request_date_pst"=>" 2017-10-25 00:03:22 America/Los_Angeles ", "original_purchase_date"=>" 2013-08-01 07:00:00 Etc/GMT ", "original_purchase_date_ms"=>" 1375340400000 ", "original_purchase_date_pst"=>" 2013-08-01 00:00:00 America/Los_Angeles ", "original_application_version"=>" 1.0 ", "in_app"=> [{ " quantity "=>" 1 ", "product_id"=>" your product id ", "transaction_id"=>" 10000003161787 ", "original_transaction_id"=>" 10000003161787 ", "purchase_date"=>" 2017-07-18 03:20:05 Etc/GMT ", "purchase_date_ms"=>" 1500348005000 ", "purchase_date_pst"=>" 2017-07-17 20:20:05 America/Los_Angeles ", "original_purchase_date"=>" 2017-07-18 03:20:05 Etc/GMT ", "original_purchase_date_ms"=>" 1500348005000 ", "original_purchase_date_pst"=>" 2017-07-17 20:20:05 America/Los_Angeles ", "is_trial_period"=>" false " } , { " quantity "=>" 1 ", "product_id"=>" your product id ", "transaction_id"=>" 10000003457411 ", "original_transaction_id"=>" 10000003457411 ", "purchase_date"=>" 2017-10-23 12:15:43 Etc/GMT ", "purchase_date_ms"=>" 1508760943000 ", "purchase_date_pst"=>" 2017-10-23 05:15:43 America/Los_Angeles ", "original_purchase_date"=>" 2017-10-23 12:15:46 Etc/GMT ", "original_purchase_date_ms"=>" 1508760946000 ", "original_purchase_date_pst"=>" 2017-10-23 05:15:46 America/Los_Angeles ", "expires_date"=>" 2017-10-23 12:20:43 Etc/GMT ", "expires_date_ms"=>" 1508761243000 ", "expires_date_pst"=>" 2017-10-23 05:20:43 America/Los_Angeles ", "web_order_line_item_id"=>" 1000000036650295 ", "is_trial_period"=>" false " }] , "original_json_response"=> { ... }} , "latest_receipt_info"=> [{ " quantity "=>" 1 ", "product_id"=>" your product id ", "transaction_id"=>" 10000003161780 ", "original_transaction_id"=>" 10000003161780 ", "purchase_date"=>" 2017-07-18 03:20:05 Etc/GMT ", "purchase_date_ms"=>" 1500348005000 ", "purchase_date_pst"=>" 2017-07-17 20:20:05 America/Los_Angeles ", "original_purchase_date"=>" 2017-07-18 03:20:05 Etc/GMT ", "original_purchase_date_ms"=>" 1500348005000 ", "original_purchase_date_pst"=>" 2017-07-17 20:20:05 America/Los_Angeles ", "is_trial_period"=>" false " } , { " quantity "=>" 1 ", "product_id"=>" your product id ", "transaction_id"=>" 10000003457411 ", "original_transaction_id"=>" 10000003457411 ", "purchase_date"=>" 2017-10-23 12:15:43 Etc/GMT ", "purchase_date_ms"=>" 1508760943000 ", "purchase_date_pst"=>" 2017-10-23 05:15:43 America/Los_Angeles ", "original_purchase_date"=>" 2017-10-23 12:15:46 Etc/GMT ", "original_purchase_date_ms"=>" 1508760946000 ", "original_purchase_date_pst"=>" 2017-10-23 05:15:46 America/Los_Angeles ", "expires_date"=>" 2017-10-23 12:20:43 Etc/GMT ", "expires_date_ms"=>" 1508761243000 ", "expires_date_pst"=>" 2017-10-23 05:20:43 America/Los_Angeles ", "web_order_line_item_id"=>" 1000000036650295 ", "is_trial_period"=>" false " } , . . . { " quantity "=>" 1 ", "product_id"=>" your product id ", "transaction_id"=>" 10000003464111 ", "original_transaction_id"=>" 10000003457411 ", "purchase_date"=>" 2017-10-25 05:54:36 Etc/GMT ", "purchase_date_ms"=>" 1508910876000 ", "purchase_date_pst"=>" 2017-10-24 22:54:36 America/Los_Angeles ", "original_purchase_date"=>" 2017-10-23 12:15:46 Etc/GMT ", "original_purchase_date_ms"=>" 1508760946000 ", "original_purchase_date_pst"=>" 2017-10-23 05:15:46 America/Los_Angeles ", "expires_date"=>" 2017-10-25 06:54:36 Etc/GMT ", "expires_date_ms"=>" 1508914476000 ", "expires_date_pst"=>" 2017-10-24 23:54:36 America/Los_Angeles ", "web_order_line_item_id"=>" 1000000036672623 ", "is_trial_period"=>" false " } , { " quantity "=>" 1 ", "product_id"=>" your product id ", "transaction_id"=>" 10000003464307 ", "original_transaction_id"=>" 10000003457411 ", "purchase_date"=>" 2017-10-25 06:54:36 Etc/GMT ", "purchase_date_ms"=>" 1508914476000 ", "purchase_date_pst"=>" 2017-10-24 23:54:36 America/Los_Angeles ", "original_purchase_date"=>" 2017-10-23 12:15:46 Etc/GMT ", "original_purchase_date_ms"=>" 1508760946000 ", "original_purchase_date_pst"=>" 2017-10-23 05:15:46 America/Los_Angeles ", "expires_date"=>" 2017-10-25 07:09:36 Etc/GMT ", "expires_date_ms"=>" 1508915376000 ", "expires_date_pst"=>" 2017-10-25 00:09:36 America/Los_Angeles ", "web_order_line_item_id"=>" 1000000036672630 ", "is_trial_period"=>" false " }] , "pending_renewal_info"=> [{ "auto_renew_product_id"=>" your product id ", "original_transaction_id"=>" 1000000345741172 ", "product_id"=>" your product id ", "auto_renew_status"=>" 1 " }]} レシートが一緒になって返ってくる! 返ってきたレシートをよーーーく見ると、どちらの情報も1つのレシートに混ぜこぜになって返ってきています。自動更新購読型と消耗型それぞれ別のレシートが発行されるというわけでも、新しいJSONのキーが増えるわけでもありません。 特に、 in_app と latest_receipt_info の購入履歴を保持する配列に各購入情報が混ざって入ってきますので、注意が必要です。 自動更新購読型と消耗型の情報の見分け方 一緒に返ってくることがわかったところで、購入型の違いによってどのような情報の違いがあるかを見ていきます。 下記のJSONが、それぞれの購読型で返ってくるトランザクションごとの情報です。( in_app や latest_receipt_info の配列の中身) 消耗型課金で返る値 { " quantity "=>" 1 ", "product_id"=>" card_10 ", "transaction_id"=>" 10000003161787 ", "original_transaction_id"=>" 10000003161787 ", "purchase_date"=>" 2017-07-18 03:20:05 Etc/GMT ", "purchase_date_ms"=>" 1500348005000 ", "purchase_date_pst"=>" 2017-07-17 20:20:05 America/Los_Angeles ", "original_purchase_date"=>" 2017-07-18 03:20:05 Etc/GMT ", "original_purchase_date_ms"=>" 1500348005000 ", "original_purchase_date_pst"=>" 2017-07-17 20:20:05 America/Los_Angeles ", "is_trial_period"=>" false " } 自動更新購読型で返る値 { " quantity "=>" 1 ", "product_id"=>" monthly_paid_1 ", "transaction_id"=>" 10000003457411 ", "original_transaction_id"=>" 10000003457411 ", "purchase_date"=>" 2017-10-23 12:15:43 Etc/GMT ", "purchase_date_ms"=>" 1508760943000 ", "purchase_date_pst"=>" 2017-10-23 05:15:43 America/Los_Angeles ", "original_purchase_date"=>" 2017-10-23 12:15:46 Etc/GMT ", "original_purchase_date_ms"=>" 1508760946000 ", "original_purchase_date_pst"=>" 2017-10-23 05:15:46 America/Los_Angeles ", "expires_date"=>" 2017-10-23 12:20:43 Etc/GMT ", "expires_date_ms"=>" 1508761243000 ", "expires_date_pst"=>" 2017-10-23 05:20:43 America/Los_Angeles ", "web_order_line_item_id"=>" 1000000036650295 ", "is_trial_period"=>" false " } 上記で確認できる通り、自動更新購読型と消耗型の見分けがつくkeyは expires_date の有無と、 product_id です。 なので、このどちらかで自動更新購読型か消耗型かを見分けてそれぞれの処理をする必要があります。 各購読型で見るべき項目 in_app と latest_receipt_info はそれぞれの購入型によって配列の中に購入情報が残る条件が違います。 in_app latest_receipt_info 自動更新購読型 購入情報の一部が無期限に残る 課金情報の更新の履歴がすべて残る 消耗型 トランザクションが完了していない情報のみ残る ? Appleの公式ドキュメントには、消耗型は in_app を参照し、自動更新購読型に関しては latest_receipt_info で自動更新された最新のレシートを取得してくださいという説明があります。 消耗型だけのときは、レシートに latest_receipt_info は返ってきません。なので、消耗型は in_app しか見ない、自動更新購読型は latest_receipt_info しか見ないという実装でいいと思います。 ただ、それぞれのkeyの中にはそれぞれの購読型の購入情報が混ざって入ってきてしまうので(なぜ latest_receipt_info の配列の中に消耗型の情報が返ってくるかはわかりません。。)、例えば expires_date というkeyが必ずあるというていでコードを書いてしまうと事故になります。 AppStoreのレシート問い合わせ AppStoreのサーバーへレシートを問い合わせる際のとても大きな注意点が1つあります。これは知らないと大事故になると思うのでぜひ事前に把握しておきたいポイントです。 AppStoreのサーバーへのレシート問い合わせは、HTTPのPOSTリクエストを送ることで問い合わせることができます。 その際に、自動購読型と消耗型それぞれが単独で存在している場合のレシートを問い合わせに必要なリクエストbodyには、下記の違いがあります。 消耗型 key 値 サンプル receipt-data Base64エンコードしたレシート情報 MIIjwgYJKoZIhvcNAQcCoIIjszCCI… 自動購読型 key 値 サンプル receipt-data Base64エンコードしたレシート情報 MIIjwgYJKoZIhvcNAQcCoIIjszCCI… passward アプリケーションの共有鍵 fea2ebde5... 表を見ると分かる通り、消耗型のみのときには共有鍵が不要なのに対し、自動購読型では共有鍵をbodyに指定する必要があります。 では、両購入型を共存させた場合、どのようにリクエストする必要があるでしょうか? レシートがひとつしかないとなると、消耗型を購入した際のレシートでAppStoreに問い合わせる際にも共有鍵が必要になるかが気になるポイントです。 この問題を、下記の手順でテストしてみました。 課金履歴のないAppleのアカウントを用意 消耗型アイテムを購入 その後自動購読型アイテムを購入 消耗型アイテムを購入 結果は下記のようになりました。 状態 共有鍵の必要性 2 必要なし 3 必要 4 必要 このように、自動購入型を購入する前と後で、消耗型アイテムを購入した際のレシート問い合わせ時の共有鍵の必要性が変わってきます。 なので、自動購入型を導入した時点でレシートを問い合わせる際は共有鍵を必ず送るようにしておく必要があります。 実際の課金構造 購入型が混ざっている場合、ネイティブからの課金リクエストは3種類あります。 消耗型のアイテムを購入したとき 自動更新購読型のアイテムを購入したとき StoreKitで発火する、未処理のレシートが存在するとき です。 1, 2に関しては今まで通りそれぞれの購入型のレシートを処理すれば問題ありません。 しかし、3に関しては注意が必要です。なぜなら、StoreKit経由でのリクエストは消耗型の未処理トランザクションによって発火したものか、自動更新購読型の更新によって発火しているリクエストかがわからないためです。 なので、3の場合は下記の図のように、自動更新購読型と消耗型の両方の購入情報をチェックする必要があります。 図1 StoreKitで発火する処理 まとめ 自動購読型と消耗型を共存させたとき、レシートはひとつに統合される 自動購読型の存在の有無によってAppStoreのサーバーにレシートを問い合せる際のbodyの内容が変わる StoreKitで発火する末トランザクションがある場合のリクエストは自動更新購読型と消耗型のどちらの情報もチェックする必要がある これらを把握して、事故のない課金ライフを送りましょう! 終わりに VASILYエンジニアはお金周りに興味があるエンジニアを募集しています。 興味ある方は以下のリンクからご応募ください。
こんにちは、データチームの後藤です。 VASILYデータチームは2017年11月8日〜11日にかけて、東京大学の本郷キャンパスで行われた第20回情報論的学習理論ワークショップ(以下、IBIS2017)に参加しました。本記事では、発表の様子や参加した感想をお伝えしたいと思います。 IBIS2017について IBIS2017 IBISは機械学習に関する国内最大規模の学会です。機械学習や統計学、情報理論などの理論研究や、機械学習の応用的な研究が対象となります。参加登録数は去年の約2倍の1036人となっており、その規模は加速的に大きくなっています。 初日の懇親会では「IBIS年代記」と題して、20年の歴史を振り返るトークも行われました。絶滅の危機に瀕しているトキ(IBIS)は絶滅寸前のニッチな研究者集団という意味を表している?そうですが、今となってはビッグデータや深層学習のブームと重なり、絶滅寸前の影を微塵も感じさせません。 IBIS年代記 以下は、IBIS2017のプログラムです。 日付 内容 11月8日(水) 招待講演1:Nathan Srebro 招待講演2:Edward Albert Feigenbaum 企画:国際会議採択論文 懇親会 11月9日(木) 企画:自然言語処理への機械学習の応用 ポスターセッション1 企画:実社会への機械学習の応用 11月10日(金) 企画:画像処理への機械学習の応用 ポスターセッション2 招待講演:渡辺澄夫 11月11日(土) チュートリアル 我々は昨年のIBIS2016で 「VAEとGANを活用したファッションアイテムの特徴抽出と検索システムへの応用」 というタイトルで発表し、多くの方々に成果を伝えることができました。その後もサービスの研究・開発を進め、今年はその中から2つの成果を発表することにしました。 発表 D1-50: 学習可能なマスクを用いた柔軟な類似度計算手法 ディスカッショントラック1日目では、インターン生の上月が「学習可能なマスクを用いた柔軟な類似度計算手法」というタイトルで発表しました。 概要 畳み込みニューラルネットワーク(CNN)に画像と属性の組を学習させ、類似度を計算しやすい特徴量を得ることを目指します。その際、特徴量を出力する層に属性の内容に応じたマスクをかけ、属性の内容と特徴量の次元に対応関係を持たせます。例えば、靴のヒールの高さと靴の性別は性質の異なる属性なので、マスクを切り替えて特徴量を抽出します。この工夫により、属性の内容ごとにモデルを作成する手間が省け、一つのモデルで複数の属性を扱うことができるため、サービスへの実装の観点からメンテナンス性の高いモデルを得ることが期待できます。 一方で、靴のヒールの高さと性別は完全に独立な性質のものではなく、互いに共有している特徴も含みます。そこで今回は独自の工夫として、属性固有の独立した特徴と各属性に共有される特徴を明示的に分けて学習するようマスクを設計し、より解釈性の高い表現を得ることを試みました。 実験のデータセットにはUT Zappos50Kという靴の画像と属性(靴の種類、靴の閉め方、性別、ヒールの高さ)を利用しています。以下の図はわかりやすさのために、CNNから得られた特徴量の空間を二次元で表現したものです。サンダル、スニーカー、ブーツといった靴の種類に関する属性は、それぞれ距離を置いて位置づけられています。この技術は属性を変化させながら画像検索する機能の実装などに利用できます。 発表ポスター PDF版はこちら D2-23: ブランドコンセプトを反映したファッションアイテム類似検索 ディスカッショントラック2日目に、中村が「ブランドコンセプトを反映したファッションアイテム類似検索」というタイトルで発表しました。 概要 ファッションに関する商品データには、ブランド毎にサンプル数の偏りがあるため、ブランド判別問題において十分な学習サンプルが得られないブランドが存在します。 この問題を解決するために、IQONユーザーのブランドLIKEのデータをword2vecに学習させて得られる、ブランドの分散表現を活用します。ブランドの分散表現をクラスタリングしてメンバーの内容を解釈すると、データの生成元であるユーザーのペルソナが浮かび上がってきます。 Deep Visual Semantic Embedding Model (DeViSE)を使って、CNNで抽出する画像特徴量がブランドの分散表現に寄るようにモデルの学習を進めます。 このようにして学習したCNNはブランドの意味表現を反映した画像特徴量を抽出するようになることが期待されます。 以下の図は、クラスタのサンプルサイズとAccuracy(左)、Precision@5(右)の関係を表しています。赤線上が等しいスコアを意味し、サンプルサイズを色で表しています。図の赤線の上側に位置する点はDeViSEが勝っているクラスタになります。Accuracyの観点では、ほとんどすべての場合でDeViSEよりもSoftmaxのほうが勝っています。一方、Precisionの観点では、主にサイズの小さいクラスタ(青点)に対するPrecisionに大きな向上がみられることがわかるかと思います。Presicionが重要になるタスクにおいて、サンプルサイズの小さいブランドを分散表現のような補助情報を用いて学習することで上手く活用できることがわかりました。 発表ポスター PDF版はこちら その他の研究 [招待講演1] Supervised Learning without Discrimination 講演者:Nathan Srebro先生 Toyota Technological Institute at Chicago(TTIC) の研究者、Srebro先生による講演です。機械学習システムが差別的判断をしないためにどう取り組めばよいかというフレームワークのお話でした。信用評価や広告の出し分けなどのタスクにおいて、意図せず人種や性別の差別をしている機械学習システムが存在します。差別につながる変数Aを利用しないという方法も考えられますが、予測値YはAに依存する可能性があります。このような問題設定の場合に、講演中で紹介されたdemographic parityやequalized oddsなどの考え方が参考になります。 以下が参考文献です。 "Equality of Opportunity in Supervised Learning" https://arxiv.org/abs/1610.02413 "Learning Non-Discriminatory Predictors" https://arxiv.org/abs/1702.06081 [招待講演2] Advice to Young and New AI Scientists 講演者:Edward Albert Feigenbaum先生 「エキスパートシステムの父」こと、Feigenbaum先生から、若手研究者へのメッセージです。講演では、若手研究者に対して、大勢の人が取り組む積み上げ型のサイエンスではなく、分野にブレイクスルーをもたらすような研究を行うよう奨励していました。その際、Deep LearningのようなPerceptual AI(問題に対して反射的に回答するAI)の分野ではなく、課題の多いCognitive AI (深い思考をするAI、判断の説明ができるAI)の分野をやるべきだとのことです。ほかには、ブレイクスルーを起こすには、頭で考えるだけでなくあらゆる可能性を実験で確かめることが大切だというメッセージもありました。 Learning from Complementary Labels (NIPS 2017) 講演者:石田隆さん "Learning from Complementary Labels" https://arxiv.org/abs/1705.07541 データとラベルが正しいかどうかの二値のデータは、多クラスデータの正確なラベル付けよりも簡単に得られます。このようなComplementary Labelを持つデータセットの多クラス判別を学習する枠組みを提案しています。クラウドソーシングなどを利用したデータアノテーションへの応用が利きそうなお話でした。 D1-41: IILasso:相関情報を罰則項に導入したスパースモデリング 発表者:髙田正彬さん Lassoは変数間の相関が強い場合に、互いに相関の強い変数を選択しやすいため、モデルの解釈性や汎化誤差が悪化します。提案手法(Independent Lasso)では、相関情報を罰則項に取り入れて学習します。実験では汎化誤差が改善しているようです。Lassoの一般化にあたり、とても汎用性の高い手法であると思いました。 D1-27: 教育ログデータからの解釈性を重視した潜在スキル推定 発表者:玉野浩嗣さん 学習者が問題に取り組んだ正誤ログから、学習者が持っている潜在的なスキルの時系列変化と、実際に問題を解くのに必要な潜在スキルを同時に推定する手法を提案しています。確率モデルを使った問題の定式化が納得感のあるものとなっており、得られた結果の解釈性も高い研究でした。問題設定に適したオープンデータが少ないために検証に苦労しているようです。 D1-32: Maximum mean discrepancyに基づく分布マッチングを用いた教師なしドメイン適応 発表者:熊谷充敏さん 教師なしでドメイン適応をする研究です。Maximum mean discrepancyに基いて、教師ありで学習した元ドメインと教師なしの目標ドメインの特徴分布がおなじになるように変換則を学習します。目標ドメインのラベルが無くても適応可能である点と、分布を圧縮しないので情報のロスが少ない点が良さそうです。 T2-09: カウントデータに関する多次元ヒストグラムのビン幅最適化 発表者:武藤健介さん カウントデータのヒストグラムの最適なビン幅を推定する研究です。MISEからビン幅に依存する部分を抽出すると、ビン幅は真の確率密度に依存しないということがわかり、最適なビン幅が求めらます。多次元ヒストグラムでも同様の性質が成り立ち、最適なビン幅が得られるそうです。最適なビン幅と実験機器の分解能を関係付けると必要なカウント数が求められ、実験者が観測時間を設定できるのが良い点です。 T2-16: Generative Adversarial Networksを用いた確率的識別モデルから訓練データ生成分布の推定 発表者:草野光亮さん 公開された予測モデルから訓練データの生成分布を推定するという研究です。学習に利用されたデータが手元にないより厳しい場合でも、ドメインの異なる補助データを利用することで、訓練データ生成分布を推定できることを示しています。例えば、個人情報を学習データに使った場合に、悪意のある攻撃者からデータの分布を推測され悪用されるという危険性を示しています。 D2-12: クラウドソーシングを用いた半教師あり学習のための深層生成モデル 発表者:新 恭兵さん クラウドソーシングによるデータアノテーションはワーカー毎の作業品質にばらつきがあるという欠点があります。ワーカーが付与したラベルの生成プロセスをモデル化し学習することで、精度向上のみならず、ワーカーの特徴をパラメトライズしたり、ワーカーを分類したりすることができるようになります。論文はちょうど投稿したばかりとのことで、まだ読めないようです。 感想 得るものが多い三日間でした。普段は追えていない車の自動運転やロボット分野の研究を学ぶことができてとても刺激的でしたし、レベルの高い研究に感化されてチームとして研究に対する熱意が高まっている状態です。また、VASILYの研究内容やデータに興味を持っている学生たちとお話することができ、今後につながっていく出会いがあったのもよかったです。 業務への応用という意味では、データの少ない・ラベルが無い・ラベルにノイズが多いなど、様々なデータ事情に対する課題に取り組まれている研究が多く、参考になりました。Webから得られるデータを扱うと、このような場面に出くわすことが多々あるからです。また、クラウドソーシングによるデータのアノテーションを想定した研究もあり、効率的に価値の高いデータを生み出す方法には今後も注目していきたいと思いました。 最後に VASILYでは、最新の研究にアンテナを張りながら、同時にユーザーの課題解決を積極的に行うメンバーを募集しています。本記事のように、業務の一環として研究発表や学会に参加することを認められる環境です。 興味のある方はこちらからご応募ください。
こんにちは、フロントエンド開発部の荒井です。 先日VASILYでは開発合宿が行われました。本記事では私が合宿で使用したHeadless Chrome + Puppeteerを紹介したいと思います。 開発合宿のテーマ決め 合宿での開発内容は個人に委ねられており、普段出来ない開発を自由に行うことが出来ました。各々興味深いテーマを持ち寄っており、非常に面白い開発合宿でした。私も何をテーマにするか非常に悩みましたが、今後の業務のことも考え、久しく触れていなかったヘッドレスブラウザを使用した開発を行うことにしました。 ヘッドレスブラウザ GUIを持たないブラウザで、フロントエンドの自動テストやSPA(Single Page Application)のスクレイピングにも用いられます。ヘッドレスブラウザとしてはPhantomJSが有名だと思いますが、メインメンテナーが終了を宣言したため、今回はHeadless Chromeを採用しました。ヘッドレス環境でChromeを動作させるHeadless ChromeはChrome 59から導入されています。 Headless Chromeを使用する Headless Chromeを使用するのは非常に簡単です。 Chromeがインストールされていれば、以下の手順で使用することが出来ます。 1. Chromeがインストールされているパスを指定 alias chrome =" /Applications/Google \ Chrome.app/Contents/MacOS/Google \ Chrome " 2. --headlessオプションを付けてChromeを実行 chrome --headless --disable-gpu --screenshot https://www.google.com 上記サンプルでは--screenshotオプションを使用してwww.google.comのスクリーンショットを撮っています。実行すると実行時のディレクトリにscreenshot.pngというスクリーンショットがあるはずです。このように簡単な機能であれば コマンドラインフラグ を使用することで達成出来ます。 Puppeteer コマンドライン機能により、簡単にHeadless Chromeを使用することが出来ました。しかし、実際に自動テストやスクレイピングを行うにはプログラムの記述が必要になってきます。そこで今回使用したのがNode.jsからHeadless Chromeを簡単に扱える Puppeteer です。 PuppeteerにはDev Tools ProtocolでChromiumを制御するAPIが提供されています。Node.jsからHeadless Chromeを扱うためのライブラリはいくつか存在しますが、PuppeteerはそのFAQにもある通り、Chrome DevToolsチームがメンテナンスを行なっています。今回採用した一番の決め手となりました。 動作例 それではPuppeteerをインストールしてsample.jsを作成してみます。 最初のサンプルはGoogleにてVASILY, Incと検索するコードです。 インストール yarn add puppeteer sample.js const puppeteer = require( 'puppeteer' ); puppeteer.launch( { headless: false , // フルバージョンのChromeを使用 slowMo: 300 // 何が起こっているかを分かりやすくするため遅延 } ).then(async browser => { const page = await browser.newPage(); await page.setViewport( { width: 1200, height: 800 } ); // view portの指定 await page. goto ( 'https://www.google.co.jp/' ); await page.type( '#lst-ib' , 'VASILY, Inc' ); await page.click( '.lsb' ); await page.waitFor(3000); // デモのための遅延 browser.close(); } ); 実行 node sample.js デモのためヘッドレスモードをオフにしています。サンプルコードを見て頂いても分かる通り、基本的なAPIが用意されており、Headless Chromeを簡単に扱えます。Puppeteer APIは こちら をご参照ください。 SPAサイトの操作 次にVASILYの コーポレートサイト にアクセスしてみます。 VASILYのコーポレートサイトはVue.jsによるSPAであり、ソースコードを確認するとbody内はdivが1行とscrpitタグが存在するだけとなっています 1 。 < body > < div id = "app" > </ div > < script src = "/dist/build.js" ></ script > </ body > このようなSPAのサイトを扱ってみます。ServerSideRenderingをしていないサイトもよく見かけるので スクレイピングなどの参考にしてください。 下記サンプルではメニューのRECRUITをクリックしています。 const puppeteer = require( 'puppeteer' ); puppeteer.launch( { headless: false , slowMo: 300 } ).then(async browser => { const page = await browser.newPage(); await page.setViewport( { width: 1200, height: 800 } ); await page. goto ( 'https://vasily.jp/' ); const recruit = await page.$( '.contents > ul > li:nth-child(4) > a' ); await recruit.click(); await page.waitFor(3000); browser.close(); } ); まとめ Puppeteerを使用したHeadless Chromeの操作は非常に分かりやすく、動作させるまでスムーズに行えました。クライアントでDOMを生成しているサイトのスクレイピングをしたい方、自動テストに興味がある方は是非一度お試しになってください。 また、VASILY開発合宿の様子は後日記事が上がりますので、そちらもご覧ください。 私はHeadless Chrome + Puppeteerを使用して勤怠サイトを操作をしていました(悪巧みはしていません) 最後に VASILYではエンジニアを募集しています。興味ある方はWantedlyからご応募ください! 2017年11月16日現在の情報です。開発合宿でコーポレートサイトへのNuxt.jsの採用が発表されたため、SSRされる可能性があります。 ↩
こんにちは、フロントエンドエンジニアの権守です。Androidアプリ開発を始めてから2か月が経ちましたが、まだまだ実装に苦戦することも多いです。本記事では特に苦戦した実装の1つである角丸の帯グラフについて実装方法を3パターン紹介します。 満たすべき仕様 今回の実装では API level 16以上 を対象とし、実装する帯グラフは以下のデザイン要件を満たす必要がありました。 両端が角丸である グラフを構成するデータは二種類 並び順は大きい順ではなく固定 色は過半数かどうかで決まる (実際に使う場合は割合を表すラベルも併記することになると思いますが、本記事では省略します) 実装方法 本記事で紹介する実装は github に上げてありますので、必要に応じて参照してください。 1. ShapeDrawableを使った実装 Androidアプリで角丸を実装することを考えると、まず最初に思いつくのは ShapeDrawable でcornersを指定したrectangleを用いる方法でしょう。しかし、この方法では割合が極端な場合に角丸部分をうまく表示できず潰れてしまいます。 そこで、ShapeDrawableのrectangleではなくringを用います。ringは真ん中が透過された円形を描画できるので、それをうまく使い角の部分を背景色で塗りつぶします。 具体的には次のようなDrawableを用意します。 <? xml version = "1.0" encoding = "utf-8" ?> <shape xmlns : android = "http://schemas.android.com/apk/res/android" android : innerRadius = "15dp" android : shape = "ring" android : thickness = "15dp" android : useLevel = "false" > <size android : width = "30dp" android : height = "30dp" /> <solid android : color = "#FAFAFA" /> </shape> (Drawableのプレビュー、黒い部分は透過を表します) このDrawableを ClipDrawable を用いて半分に切ることで角の部分だけ塗りつぶすことができます。 この実装方法の問題点は、Drawable内に大きさや背景色を予め指定する必要がある点です。 2. Canvasによる描画を使った実装 Drawableを使うことを諦め、もっと原始的な実装方法を考えると、Canvasを用いてグラフを表す図形を描画する方法もあります。 しかし、Canvasを使った描画も割合が極端な例(2色から構成される角丸の場合)を考慮すると1の実装と同じくロジックは複雑になります。その場合、drawCircleとdrawRectangleを組み合わせるだけでなく、drawArcもうまく組み合わせて使う必要があります。 具体的には、次のようなステップで描画を行います。 両端に過半数の色で円を描画 右の長方形を描画 右の円弧を描画 左の長方形を描画 左の円弧を描画 右の要素が占める割合毎のグラフの描画ステップは以下になります。 98% 60% 2% 実際のコードは次の通りです。 class RoundedBandGraph3 @JvmOverloads constructor (context: Context, attrs: AttributeSet? = null , defStyleAttr: Int = 0 ) : View(context, attrs, defStyleAttr) { private val paint = Paint().apply { isAntiAlias = true } private val leftRect = Rect() private val rightRect = Rect() private val leftArcArea = RectF() private val rightArcArea = RectF() var positivePercentage: Int by Delegates.notNull() override fun onDraw(canvas: Canvas?) { super .onDraw(canvas) val radius = height / 2 val positiveRectangleWidth = width * positivePercentage / 100 val negativeRectangleWidth = width - positiveRectangleWidth // ステップ1 paint.color = ContextCompat.getColor(context, R.color.major) canvas?.drawCircle(radius.toFloat(), radius.toFloat(), radius.toFloat(), paint) canvas?.drawCircle((width - radius).toFloat(), radius.toFloat(), radius.toFloat(), paint) // ステップ2 if (positivePercentage >= 50 ) { paint.color = ContextCompat.getColor(context, R.color.major) } else { paint.color = ContextCompat.getColor(context, R.color.minor) } if (positiveRectangleWidth > radius) { rightRect. set (Math.max(width - positiveRectangleWidth, radius), 0 , width - radius, height) canvas?.drawRect(rightRect, paint) } // ステップ3 var x = Math.max( 0 , radius - positiveRectangleWidth) var angle = Math.toDegrees(Math.acos(x.toDouble() / radius.toDouble())).toFloat() rightArcArea. set ((width - height).toFloat(), 0f , width.toFloat(), height.toFloat()) canvas?.drawArc(rightArcArea, 0f - angle, 2f * angle, false , paint) // ステップ4 if (positivePercentage >= 50 ) { paint.color = ContextCompat.getColor(context, R.color.minor) } else { paint.color = ContextCompat.getColor(context, R.color.major) } if (negativeRectangleWidth > radius) { leftRect. set (radius, 0 , Math.min(negativeRectangleWidth, width - radius), height) canvas?.drawRect(leftRect, paint) } // ステップ5 x = Math.max( 0 , radius - negativeRectangleWidth) angle = Math.toDegrees(Math.acos(x.toDouble() / radius.toDouble())).toFloat() leftArcArea. set ( 0f , 0f , height.toFloat(), height.toFloat()) canvas?.drawArc(leftArcArea, 180f - angle, 2f * angle, false , paint) } } このコードの注意すべき点としては、長方形の描画は円弧の領域を除くように大きさを決める点と、円弧の大きさを求める点が挙げられます。 円弧の描画には孤の始点の角度と孤の大きさ(角度)を指定する必要があります。両端の円の半径 r から円弧が占める横幅を引いた大きさを x とし、 arccos(x/r) を求めることで、始点の角度を求めることができます。弧の大きさ(角度)は求めた角度の2倍になります。 3. CanvasのClipPathを使った実装 Canvasの ClipPath を使うと、指定した領域のみ描画するということができます。 しかし、ClipPathはAPI level 18以降でしか動きません。これは、Hardware AccelerationでClipPathをサポートしているのがAPI level 18以降だからです。コード上でバージョンを判定し、明示的にHardware Accelerationを無効にすることで、API level 16でも意図した動作を行えます。 class RoundedBandGraph4 @JvmOverloads constructor (context: Context, attrs: AttributeSet? = null , defStyleAttr: Int = 0 ) : View(context, attrs, defStyleAttr) { private val paint = Paint().apply { isAntiAlias = true } private val leftRect = Rect() private val rightRect = Rect() private val path = Path() var positivePercentage: Int by Delegates.notNull() init { // Hardware accelerated drawing modelでClipPath()がサポートされていのはAPI Level 18以降 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR2 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { setLayerType(LAYER_TYPE_SOFTWARE, null ); } } override fun onDraw(canvas: Canvas?) { super .onDraw(canvas) val radius = height / 2 val positiveRectangleWidth = width * positivePercentage / 100 val negativeRectangleWidth = width - positiveRectangleWidth path.addCircle(radius.toFloat(), radius.toFloat(), radius.toFloat(), Path.Direction.CCW) path.addCircle(width - radius.toFloat(), radius.toFloat(), radius.toFloat(), Path.Direction.CCW) path.addRect(radius.toFloat(), 0f , width - radius.toFloat(), height.toFloat(), Path.Direction.CCW) canvas?.clipPath(path) if (positivePercentage >= 50 ) { paint.color = ContextCompat.getColor(context, R.color.major) } else { paint.color = ContextCompat.getColor(context, R.color.minor) } rightRect. set (width - positiveRectangleWidth, 0 , width, height) canvas?.drawRect(rightRect, paint) if (positivePercentage >= 50 ) { paint.color = ContextCompat.getColor(context, R.color.minor) } else { paint.color = ContextCompat.getColor(context, R.color.major) } leftRect. set ( 0 , 0 , negativeRectangleWidth, height) canvas?.drawRect(leftRect, paint) } } pathに両端の円と真ん中の長方形を追加することで描画すべき領域を作成し、その後、単純に左右それぞれ割合に応じた大きさの長方形を描画すれば、意図したグラフが得られます。 まとめ 帯グラフは一見簡単に思える図形ですが、角丸にすることで意外と複雑な実装が必要になります。今回紹介した実装そのものは、一般の帯グラフとは異なる動作をするものではありますが、一般の帯グラフなど様々な図形を実装する際の参考になれば幸いです。 最後に VASILYでは新しいことに挑戦できるエンジニアを募集しています。 興味のある方は以下のリンクからぜひご応募ください。
こんにちは。 季節の中では秋が好き、バックエンドエンジニアのりほやんです。 近年、Facebookログインを使うサービスがとても増えています。 VASILYでもFacebookログインとFacebook Graph APIを使用した機能を実装しました。 本記事では、Facebook Graph APIを用いてユーザー情報を取得する方法と注意点について紹介します。 これからFacebook Graph APIを使用する方の参考になれば幸いです。 注意 この情報は2017年11月2日現在のものです。 Graph APIのバージョンはv2.10です。 Graph APIとは Graph APIの公式サイトには、Graph APIについて下記のように説明されています。 Graph API グラフAPIは、Facebookのソーシャルグラフにデータを取り込んだり、データを取り出したりするための主な方法です。データのクエリ、新しい記事の投稿、写真のアップロード、アプリで行う必要があるその他のさまざまなタスクに利用できるローレベルのHTTPベースのAPIです。 Graph APIを用いることで、ユーザーの情報を取得・投稿することができます。 通信の流れ Graph APIとの通信の流れはざっくりと以下のようになります。 ユーザーがFacebookにログイン、ユーザーデータへのアクセス許可を行う FacebookがAccess Tokenを発行する Access Tokenをサーバーサイドに渡す Access Tokenの不正チェックを行う ユーザーの情報を取得する 今回はサーバサイド側に注目し、4,5について説明します。 Access Tokenの不正チェックを行う 1〜3の過程でFacebookから発行されたAccess Tokenを用いて、Facebookとの接続を行います。 その際、トークンハイジャックを防ぐためにクライアントから送られたAccess Tokenが、自分のFacebookアプリケーションに対して発行されたAccess Tokenかどうかをチェックする必要があります。 Access Tokenが不正なものではないかどうかをチェックする際には、 /debug_token エンドポイントを使用します。 エンドポイント /debug_token?input_token={ユーザーのAccess Token}&access_token={app_id}|{app_secret} /debug_token エンドポイントのリクエストパラメーターにはinput_tokenとaccess_tokenが必要です。 ユーザーのAccess Tokenが正しいことをチェックする際には、ユーザーのAccess Tokenは、acccess_tokenではなくinput_tokenを記述する という点に注意してください。 access_tokenには、Facebookアプリケーションの情報を記述します。 具体的には、 {Facebookアプリケーションのapp_id}|{Facebookアプリケーションのapp_secret} を記述します。 例えば、 ユーザーのAccess Tokenがuser、Facebookのapp_idが123、app_secretがsecretとした場合、下記のようなリクエストになります。 /debug_token?input_token=user&access_token=123|secret レスポンス 正しいinput_token, access_tokenを入力した場合は、下記のようなレスポンスが返ります。 { "data" : { "app_id" : app_id, "type" : "APP" , "application" : アプリケーション名, "is_valid" : true , "scopes" : [ ] } } 不正なAccess Tokenを入力した場合は下記のようなレスポンスが返ります。 { "error" : { "message" : "Invalid OAuth access token." , "type" : "OAuthException" , "code" : 190, "fbtrace_id" : "" } } また、違うアプリケーションのAccess Tokenを入力した場合は下記のようなレスポンスが返ります。 { "error" : { "message" : "(#100) The App_id in the input_token did not match the Viewing App" , "type" : "OAuthException" , "code" : 100, "fbtrace_id" : "" } } エラーの種類が複数あることに注意してください。 エラーが発生していない場合、Access Tokenが有効なものであると判断できます。 参考: アクセストークン:デバッグとエラー処理 Access Tokenの確認後、ユーザーの情報を取得します。 ユーザー情報の取得 ユーザーの基本情報(名前・年齢など) ユーザーの基本情報を取得したい場合、 /{user_id} というエンドポイントを使用します。 また、自分の情報に限り /me というエンドポイントで情報を取得できます。 今回は /me を使用します。 /me エンドポイントのパラメーターに、取得したいフィールドを記述します。 例えば、ユーザーのID、名前、プロフィール画像を取得したい場合は /me?fields=id,name,picture と記述します。 実際に /me?fields=id,name,picture エンドポイントを叩いた際は、下記のレスポンスが返ってきます。 エンドポイント /me?fields=id,name,picture レスポンス { "id" : ユーザーID, "name" : 名前, "picture" : { "data" : { "is_silhouette" : 初期ユーザー画像かどうか(プロフィール画像を設定してない場合trueになる), "url" : 画像のURL } } } filedsに指定できるフィールドは他にも多くあります。 参考: Graph API Reference User 友人・友人数を取得したい場合 ユーザーの友人数や、同じアプリを使用している友人を取得したい場合は、 /me のfilelsにfriendsを指定します。 このエンドポイントを叩く際には このエンドポイントで取得できる友人は、ユーザーのすべての友人ではなく同じFacebookアプリケーションを使用している友人のみが取得できる という点に注意してください。 エンドポイント /me?fields=friends レスポンス { "data" : [ { "name" : 友人の名前, "id" : ユーザーID } , ... ] , "summary" : { "total_count" : ユーザーの友人数 } } summary の中の total_count がユーザーの総友人数です。 また友人一覧は、 data に配列として返ってきます。 data内の配列には、友人それぞれの名前とユーザーIDが記載されています。 プロフィール画像のサイズを指定したい場合 プロフィール画像を取得したい場合はfieldsにpictureを指定して /me?fields=picture となります。 プロフィール画像など、取得する画像のサイズを変更したい場合は、 width , hight というパラメータをつけることによって画像のサイズを指定することができます。 エンドポイント /me?fields=picture.width(720).hight(720) レスポンス { "picture" : { "data" : { "height" : 720, "is_silhouette" : false , "url" : 指定したサイズの画像のURL, "width" : 720 } } , "id" : ユーザーID } アクセス許可の確認したい場合 初回認証時にユーザー情報への読み取りが許可されている場合でも、ユーザー自身が アプリ設定画面 から自由に、アプリのアクセス許可を変更することができます。 ユーザーが下記のようにチェックを外し、アクセス許可を変更することで、今まで取得できていたデータが取得できなくなります。 そのため、ユーザー情報の取得する前にAccess Tokenのアクセス許可を確認する必要があります。 アクセス許可を確認する場合は /me エンドポイントのfieldsにpermissionsを指定します。 エンドポイント /me?fields=permissions レスポンス { "permissions" : { "data" : [ { "permission" : "public_profile" , "status" : "granted" } , { "permission" : "publish_actions" , "status" : "declined" } , { "permission" : "email" , "status" : "declined" } , ] } , "id" : ユーザーID } アプリに付与されているアクセス許可それぞれの名前とステータスが返ってきます。 statusについての説明は下記の様になります。 status 説明 granted アクセス許可されている declined アクセス許可されていない statusが declined のアクセス許可があった場合は、ユーザーに再度アクセス許可を求める必要があります。 参考: User permissions Tips テストアプリケーション Facebookアプリケーションをリリースまで公開したくない場合、開発環境用のアプリケーションを作成することができます。 アプリ一覧画面 から、アプリを選択し『Create Test App』ボタンから開発環境用のテストアプリケーションをつくることができます。 Graph API Explorer APIのリクエストパラメーターやレスポンスを試したいときは、 Graph API Explorer が便利です。 1. アプリケーションを選択 まず確認を行いたいアプリケーションを選択します。 APIのレスポンスを確認したいだけのときはGraph API Explorerのままで良いと思います。 2. アクセス許可を行いAccess Tokenを取得 『Get Token』→『Get User Access Token』で、取得したい情報のアクセス許可をチェックしAccess Tokenを取得します。 3. エンドポイントとパラメーターを記述し実行 確認したいエンドポイントとパラメータを記述し『Submit』ボタンを押すとレスポンスの確認ができます。 Access Tokenのアクセス許可を確認したいとき Access Tokenのアクセス許可を確認したい場合には、 Access Token Debugger が便利です。 Access Tokenを入力すると、下記のようにユーザー名、許可しているアクセス許可一覧(Scopes)などが確認できます。 まとめ 本記事では、Facebook Graph APIの利用方法と注意点について紹介しました。 Access Tokenの不正チェックの際はAccess Tokenをinput_tokenに記述すること、友人は同じアプリケーションを使用している友人しか取得できないという点は特に注意してください。 参考資料 Graph API ドキュメント Facebookログインについて Wantedly VASILYでは、ウェブエンジニアを募集しています! 興味のある方は以下のリンクからぜひご応募ください。 Icon made by Freepik from http://www.flaticon.com/
フロントエンドチームの茨木です。 前回ブログを執筆したときにはiOSアプリを開発していましたが、先月からAndroidアプリを開発しています。 本記事では、Androidで美しいバウンドのアニメーションを手軽に導入できるSpring Animationをご紹介します。 Spring Animationとは Spring AnimationはGoogleが公式にサポートしているアニメーションのライブラリで、名前の通りばねの動きを模しています。今年のGoogle I/Oで紹介されました。 Spring Animationを使うとわずか数行のコードで美しいバウンドのアニメーションを実装できます。しかし、実際の物理現象を模しているため調整にはちょっとコツが要ります。 とりあえず使ってみる Spring Animationを使うにはサポートライブラリが必要なので、build.gradleに一行追記しましょう。 dependencies { . . . implementation "com.android.support:support-dynamic-animation:27.0.0" } これによりAPI 16以上でSpring Animationが使えるようになります。Spring Animationを読み込めるようになったら、早速簡単なアニメーションを実装してみましょう。ここでは四角形がバウンドするようなアニメーションを作ってみたいと思います。まず、レイアウトに四角形のビューを追加してみましょう。 <View android : id = "@+id/square" android : layout_width = "50dp" android : layout_height = "50dp" android : layout_gravity = "center|top" android : layout_marginTop = "20dp" android : background = "#f00" /> 次に、以下のコードをアクティビティの任意の場所に追加します。 val square: View = findViewById(R.id.square) val animation = SpringAnimation(square, DynamicAnimation.TRANSLATION_Y, 500f ) animation.spring.apply { dampingRatio = SpringForce.DAMPING_RATIO_HIGH_BOUNCY stiffness = SpringForce.STIFFNESS_VERY_LOW } animation.start() あとはビルドするだけです。上手く行けば次のようなアニメーションが表示されるはずです。 以上でSpring Animationの導入は終わりです。ここでstiffnessとdamping ratioという2つのパラメータが出てきました。実際にアニメーションの動きを調整する場合にはこれらのパラメータを変えていくことになります。どちらもあまり聞き慣れない言葉だと思いますが、ばねの運動を司る重要な物理パラメータです。次節ではばねの運動について触れていきます。 ばねの運動について ばねは引っ張って手を離すと振動を始め、だんだん減衰していきます。このように、ばねの運動は振動と減衰という2つの要素を持ちます。先程触れたstiffnessとdamping ratioはこれらに対応する物理パラメータです。 stiffness(ばね定数) stiffnessという単語は日本語に訳すと剛性です。いわゆる高校物理で出てくるばね定数に当たります。stiffnessはばねの固さを表しており、stiffnessが大きいほどばねが固くなって振動が細かくなります。振動の細かさを表す固有振動数とstiffnessには以下の関係があります。 \[ \omega=\sqrt{\frac{k}{m}}\\ \begin{align} \omega &: \mbox{固有振動数}\\ k &: \mbox{stiffness}\\ m &: \mbox{質量}\\ \end{align} \] 上の関係から、振動の細かさを2倍にしたい場合はstiffnessを2 x 2 = 4倍、3倍にしたい場合はstiffnessを3 x 3 = 9倍…とすれば良いことがわかります。 damping ratio(減衰比) damping ratioはばねの減衰の大きさを表しており、大きいほど減衰が早くなります。これにより振動の持続時間を調整できます。ばねの振動はdamping ratioの値により3つのモードに分類できます。 \(\mbox{damping ratio = 0}\) : 単振動 減衰せずに一定の振幅でずっと振動が続くモードです。高校で物理を選択していた方なら一度習ったことがあるはずです。 \(0 < \mbox{damping ratio} < 1\) : 減衰振動 名前の通り減衰しながら振動するモードです。 \(1 \leq \mbox{damping ratio}\) : 臨界減衰or過減衰 振動せずに減衰していくモードです。\(\mbox{damping ratio = 1}\)の臨界減衰が最も早く減衰します。 これらをグラフに書いたのが下の図です。 どのモードを使うかはお好みですが、実際の開発では減衰振動を使うケースが多いと思います。 Spring Animationの動きを変えてみる ここまででばねの運動を簡単に説明しました。ここから実際にSpring Animationの動きを調整していきましょう。 適用するプロパティ・初期値・最終値を設定する まずはSpringAnimationのプロパティ・初期値・最終値を設定してみましょう。 val animation = SpringAnimation( square, // 適用するビュー DynamicAnimation.TRANSLATION_Y, // 適用するプロパティ 500f // 最終値 ) animation.setStartValue( 200f ) /*初期値(ここで指定しなかった場合はビューに設定された値が初期値になります)*/ アニメーションを適用するプロパティはDynamicAnimationの定数で指定できます。 ビューのプロパティ DynamicAnimationの定数 alpha DynamicAnimation.ALPHA rotation DynamicAnimation.ROTATION rotationX DynamicAnimation.ROTATION_X rotationY DynamicAnimation.ROTATION_Y scaleX DynamicAnimation.SCALE_X scaleY DynamicAnimation.SCALE_Y scrollX DynamicAnimation.SCROLL_X scrollY DynamicAnimation.SCROLL_Y translationX DynamicAnimation.TRANSLATION_X translationY DynamicAnimation.TRANSLATION_Y translationZ DynamicAnimation.TRANSLATION_Z x DynamicAnimation.X y DynamicAnimation.Y z DynamicAnimation.Z SpringAnimationは1つのインスタンスで1つのプロパティしか設定できません。複数のプロパティにアニメーションを適用する場合には、それぞれにSpringAnimationのインスタンスを用意する必要があります。 適用するパラメータを指定したら初期値と最終値を指定しましょう。Spring Animationは初期値から開始し、最終値を中心に振動してから収束します。拡大するようなアニメーションの場合には最終値を超える瞬間があるので、レイアウトを組む際に注意が必要です。 物理パラメータを変えてみる 物理パラメータは定数が幾つか用意されていますが、ここでは前節を踏まえてオリジナルの値を設定してみましょう。stiffnessは100ぐらいを基準に調節していくのがおすすめです。 s: stiffness, dr: damping ratio s = 100.0 dr = 0.2 s = 25.0 dr = 0.2 s = 100.0 dr = 0.0 s = 100.0 dr = 0.5 s = 100.0 dr = 1.0 まとめ 物理パラメータで調整するのは一見すると難しく思えますが、意味を知れば大分扱いやすくなると思います。Spring Animationは数行のコードで美しいアニメーションを実現できるので、皆様もバウンドのアニメーションを実装する際にぜひ使ってみて下さい。 最後に VASILYではプラットフォーム問わず高品質なアプリを開発したい人を大募集しています。 興味ある方はWantedlyからご応募ください!
VASILYのiOSエンジニアにこらすです。 今回のテックブログではiOS・macOS・watchOS・tvOSのUserDefaultsにユーザー設定などを保存するのに便利なラッパーライブラリ Default を作ったので紹介します。 github.com Defaultとは? Defaultは、Codableに準拠するカスタムオブジェクトを保存するための拡張機能を提供するライブラリです。プロトコル DefaultStorable を介して、UserDefaultsに以下で説明する新しいインタフェースを提供することで、UserDefaultsを拡張します。 Codableサポート拡張機能とDefaultStorableプロトコル拡張機能いずれかを使うこともできますし、両方つかうこともできます。 Defaultを使うメリットは? UserDefaultsには、保存したキーをtypoしたり、読み書きしている場所を探すのが難しかったりといった難点があります。 ですが、このDefaultを使って保存されるオブジェクト型を定義すると、 DefaultStorable に準拠する型をプロジェクト内で検索することで保存されるデータを追うのが簡単になります。 UserDefaults に格納する専用のオブジェクトを定義すれば、特定のデータを論理的にグループ化することができます。 NSCoding 時代の実装 Swiftでカスタムオブジェクトを宣言した後は、 NSCoding に準拠し、 NSObject から継承し、適切なEncode / Decodeメソッドを実装すれば、 UserDefaults に直接保存することができるようになります。 これを自前で対応しようとすると、以下のような煩雑なコードが必要になります。 クラスを定義し、 NSCoding に準拠し、必要なDecode / Encodeメソッドを実装する class VolumeSetting : NSObject , NSCoding { let sourceName : String let value : Double init (sourceName : String , value : Double ) { self .sourceName = sourceName self .value = value } required init (coder decoder : NSCoder ) { self .sourceName = decoder.decodeObject(forKey : "sourceName" ) as ? String ?? "" self .value = decoder.decodeDouble(forKey : "value" ) } func encode (with coder : NSCoder ) { coder.encode(sourceName, forKey : "sourceName" ) coder.encode(value, forKey : "value" ) } } オブジェクトを作成し、 NSKeyedArchiver を使用してインスタンスを Data にアーカイブして保存する let setting = VolumeSetting(sourceName : "Super Expensive Headphone Amp" , value : 0.4 ) let encodedData = NSKeyedArchiver.archivedData(withRootObject : setting ) UserDefaults.standard. set (encodedData, forKey : "volume" ) 読み出すときは NSKeyedUnarchiver を使う if let data = UserDefaults.standard.data(forKey : "volume" ), let volumeSetting = NSKeyedUnarchiver.unarchiveObject(with : data ) as ? VolumeSetting { // do something } Defaultを使った実装 一方、 Default を使えば、以下のようなシンプルなコードで実現できます。 保存対象のオブジェクトを定義する DefaultStorable プロトコルに準拠したstructを定義します。 struct VisualSettings : Codable , DefaultStorable { let themeName : String let backgroundImageURL : URL ? } 保存処理 let settings = VisualSettings(themeName : "bright" , backgroundImageURL : URL (string : "https://..." )) settings.storeToDefaults() 読み込み if let settings = VisualSettings.fetchFromDefaults() { // Do something } もう一つのメリット このアプローチのもう一つの利点は、すべてのオブジェクトを一つのファイルに定義することで、 UserDefaults に格納されるものを非常に簡単に見ることができることです。 Defaultの設計について UserDefaults にカスタムオブジェクトを保存するためには、そのオブジェクトは NSCoding に準拠する必要があります。 NSCoding に準拠するにはDecoding / Encodingメソッドを実装する必要があり、少し手間がかかります。 一方、嬉しいことにSwiftの Data 型が NSCoding に準拠しています。オブジェクトを Data に変換する方法を見つけることができれば、それを UserDefaults に格納することができます。 Swift 4から追加された Codable プロトコルは簡単に Data に変換する事ができます。 Swift 4以降のプロジェクトであれば、 Codable に準拠したモデルオブジェクトを作ることが多くなると思います。このライブラリは、そういう Codable プロトコルに準拠したオブジェクトを UserDeafaults に読み書きすることができるようになっています。 結構シンプルですね! まとめ Default は、非常に軽くてシンプルなカスタムオブジェクトを扱う UserDefaults のラッパーです。 GitHubに公開してあるので、 Default をインストールして遊んでみたい場合は、Carthage か CocoaPods を使って試してみてください。 VASILYでは、OSSなどSwift 4での開発に興味があるエンジニアを募集しています。ぜひオフィスに遊びに来てください。 ー にこらす 👍
アプリエンジニアの堀江( @Horie1024 )です。 先日、1つのコードベースからアプリ名やアプリアイコン、アプリの挙動を変更した複数のバージョンのAPKをビルドする必要があり、その際どのように対応したかをご紹介しようと思います。 サンプルコード 本記事内で使用するコードは以下になります。 github.com Androidアプリのビルド Androidビルドシステムは、アプリのリソースとソースコードをコンパイルしAPKにパッケージ化します。Android Studioを使用したAndroidアプリ開発では、 Gradle と Android Plugin for Gradle (以下Android Plugin)が連携することでAndroidアプリのビルドが行われます。また、GradleとAndroid Pluginは、Android Studioから独立していますのでコマンドラインから容易にビルド可能です。 カスタムビルド GradleとAndroid Pluginで構成されるAndroidのビルドシステムは柔軟で、アプリの主要なソースファイルを変更せずにカスタムビルドを設定可能です。カスタムビルドを設定することで、1つのコードベースに対してビルド設定、コード、およびリソースといったソースセットを置き換えるることができます。それによって、アプリ名やアプリアイコン、アプリの挙動を変更したAPKをビルドすることが可能になります。この際重要になる概念が ビルドバリアント(Build Variant) です。 ビルドバリアント ビルドバリアントは以下の2つ要素の組み合わせから構成されます。 ビルドタイプ (Build Type) プロダクトフレーバー (Product Flavor) 例えば、ビルドタイプが debug と release 、プロダクトフレーバーが app1 と app2 である場合以下のような組み合わせになり、ビルドバリアントは app1Debug 、 app2Debug 、 app1Release 、 app2Release の4種類となります。 各ビルドバリアントは、ビルド可能なバージョンのアプリを表しており、特定の組み合わせのビルドを簡単に実行できます。例えば app2Debug の組み合わせでビルドしたい場合、以下のコマンドでビルド可能です。 $ ./gradlew assembleApp2Debug ビルド設定ファイル 先程例として上げたビルドバリアントを構成するビルドタイプとプロダクトフレーバーを作成するには、ビルド設定ファイル( build.gradle )に変更を加え、カスタムビルド作成する必要があります。ビルド設定ファイルにはトップレベルとモジュールレベルがあり、Android Studioでプロジェクトを新規作成すると自動で作成されます。プロジェクトルートにある build.gradle がトップレベルのビルド設定ファイル、 app/ 以下にあるのがモジュールレベルのビルド設定ファイルです。 ビルドタイプ ビルドタイプはモジュールレベルの build.gradle ファイルの android {} ブロック内で作成します。Android Studioでプロジェクトを新規作成するとデバッグおよびリリースビルドタイプが自動的に追加されます。 android {} ブロック内に明示的に指定されているのはリリースビルドタイプのみですが、デバッグビルドタイプも有効になっています。 android { defaultConfig {} buildTypes { release {} } } プロダクトフレーバー プロダクトフレーバーの設定はビルドタイプと同様で、 productFlavors {} ブロックにプロダクトフレーバーを追加して設定します。プロダクトフレーバーでは、 defaultConfig と同様のプロパティがサポートされ、 applicationId や versionCode 、 versionName といった要素も各プロダクトフレーバーで定義できます。 Android Plugin for Gradle 3.0.0以降を使用する場合 flavorDimensions の設定が必須になります。 flavorDimensions については こちら をご覧ください。以下の例では、tierフレーバーディメンションを作成し各プロダクトフレーバーに設定しています。 android { defaultConfig {} buildTypes { debug {} release {} } flavorDimensions "tier" productFlavors { app1 { dimension "tier" } app2 { dimension "tier" } } } 複数バージョンのAPKのビルド 各ビルドタイプとプロダクトフレーバー毎にビルド設定、コード、およびリソースといったソースセットを持つことができます。特定のビルドバリアントでビルドする場合、以下の優先順位に従ってGradleがビルド時に使用するソースセットが判別されます。 ビルドバリアントのソースセット ビルドタイプのソースセット プロダクトフレーバーのソースセット メインソースセット したがって、ビルドバリアント、ビルドタイプ、プロダクトフレーバー毎にソースセットを用意することで複数のバージョンのAPKをビルドすることが可能です。 ビルド設定、コード、リソース、マニフェストについてビルドバリアントを使用した際にどのようにビルドされるのかを見ていきます。 ビルド設定 プロダクトフレーバーでは defaultConfig と同様のDSLを使用できますので、 app1 、 app2 で applicationId を定義することで applicationId を変更可能です。 android { defaultConfig {} buildTypes { debug {} release {} } flavorDimensions "tier" productFlavors { app1 { dimension "tier" applicationId "com.horie1024.app1" } app2 { dimension "tier" applicationId "com.horie1024.app2" } } } この場合、プロダクトフレーバー app1 、 app2 を含むビルドバリアントでビルドすると、それぞれで別アプリとしてビルドされます。メインソースセットよりプロダクトフレーバーのソースセットの優先度が高いためです。Gradleはビルド時により優先度の高いソースセットを利用します。また、プロダクトフレーバー毎に別々の署名設定を追加することも可能です。 コード ソースコードについても各ビルドタイプとプロダクトフレーバー毎に用意することで、ビルドするビルドバリアントに応じて挙動を変化させることができます。 以下のコードは、メインソースセットで定義している MainActivity で、 Greeting クラスの greet メソッドを実行した結果を画面に表示します。 class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super .onCreate(savedInstanceState) setContentView(R.layout.activity_main) val tv: TextView = findViewById(R.id.hello_text) tv.text = "Hello ${ Greeting().greet() } " } } Greeting クラスの実装をプロダクトフレーバー app1 、 app2 のソースセットのディレクトリ src/app1/kotlin/com.horie1024.buildvariantssample/ 、 src/app2/kotlin/com.horie1024.buildvariantssample/ 以下に置きます。 実装は以下の通りです。 greet メソッドを実行した結果が異なります。 class Greeting { fun greet() = "World!" } class Greeting { fun greet() = "Universe!" } ビルドバリアント app1Debug 、 app2Debug でビルドした結果は以下の通りです。表示されるテキストの内容が変化しています。 リソース メインソースセットの res ディレクトリ以下のリソースについても、各ソースセットディレクトリ以下に res ディレクトリを作成することでビルドバリアントに応じて利用するリソースを切り替えることができます。 strings.xml で定義した app_name リソースを例にどうビルドされるか見てみましょう。ここでは app1 、 app2 のソースセットディレクトリに加えて、ビルドバリアント app2Debug に対応するディレクトリを作成します。 各 strings.xml は以下のように定義しています。 src/main/ <resources> <string name="app_name">BuildVariantsSample</string> </resources> src/app1/ <resources> <string name="app_name">app1</string> </resources> src/app2/ <resources> <string name="app_name">app2</string> </resources> src/app2Debug/ <resources> <string name="app_name">app2Debug</string> </resources> app_name リソースはmainソースセットに加えて、 app1 、 app2 プロダクトフレーバー、ビルドバリアント app2Debug の各ソースセットでも同名で定義されています。ビルド時にGradleは優先度に応じてこれらの中からどのソースセットを使うかを判断します。 ビルドバリアント app1Debug でビルドした場合プロダクトフレーバー app1 の src/app1/ 以下に置かれたリソースが使われ、ビルドバリアント app2Debug でビルドした場合 src/app2Debug/ 以下に置かれたリソースが使われます。以下のようにアプリ名としてそれぞれのリソースで定義した app_name の値が使われています。 マニフェスト マニフェストについても同様です。マニフェストの場合、優先順位が低いものから高いものへマニフェストがマージされ最終的に1つの統合済みのマニフェストとなりAPKにパッケージ化されます。 以下の画像は 複数のマニフェスト ファイルの統合 から引用したものになります。 ビルドバリアントでのマニフェストの優先度は以下のようになっており、ソースセットの優先度と同一です。 ビルドバリアントマニフェスト(src/app1Debug/ など) ビルドタイプマニフェスト(src/debug/ など) プロダクトフレーバーマニフェスト(src/app1/ など) フレーバーディメンションを設定している場合、 flavorDimensions での定義順に優先度が付きます。 マニフェストのマージは統合のポリシーは こちら にしたがって行われますが、複数のマニフェストで要素の競合が発生した場合、明示的に統合ルールマーカーを指定して解決します。統合ルールマーカーの詳細は こちら をご覧ください。 ビルドバリアントによるマニフェストの統合を確認するため app1 、 app2 プロダクトフレーバーに対応したソースセットディレクトリに android:theme で指定するstyleを変更した AndroidManifest.xml を用意しました。統合ルールマーカー tools:replace="android:theme" を指定して競合を解決しています。 メインソースセットのAndroidManifest.xml <?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.horie1024.buildvariantssample"> <application android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/AppTheme"> <activity android:name=".MainActivity"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> </application> </manifest> app1プロダクトフレーバーソースセットのAndroidManifest.xml <?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" package="com.horie1024.buildvariantssample"> <application android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/App1Theme" <!-- AppThemeの代わりにApp1Themeを指定 --> tools:replace="android:theme">  <activity android:name=".MainActivity"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> </application> </manifest> app2プロダクトフレーバーソースセットのAndroidManifest.xml <?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" package="com.horie1024.buildvariantssample"> <application android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/App2Theme" <!-- AppThemeの代わりにApp2Themeを指定 --> tools:replace="android:theme"> <activity android:name=".MainActivity"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> </application> </manifest> ビルドバリアント app1Debug 、 app2Debug でそれぞれビルドした結果は以下の通りです。ビルドバリアントによって AndroidManifest.xml で適応されるstyleが異なっています。 コード内でのビルドタイプ、プロダクトフレーバーの参照 以下のように BuildConfig.java が自動生成されるのでビルドタイプ、プロダクトフレーバーの種類で処理を分岐可能です。 public final class BuildConfig { public static final boolean DEBUG = Boolean.parseBoolean("true"); public static final String APPLICATION_ID = "com.horie1024.app1"; public static final String BUILD_TYPE = "debug"; public static final String FLAVOR = "app1"; public static final int VERSION_CODE = 2; public static final String VERSION_NAME = "2.0"; } プロダクトフレーバーが app1 の場合のみ実行したい処理がある場合以下のように書けます。 if (BuildConfig.FLAVOR == "app1" ) // do something Android Studio上でのビルドバリアントの切り替え Android Studioでは、ビルドバリアントを切り替えるUIが用意されています。特定のビルドバリアントに切り替えて挙動を確認したい場合簡単に切り替えられます。 「app」の「Build Variant」をクリックするとドロップダウンでビルドバリアントを選択できます。 まとめ Androidのビルドシステムは、Gradleを採用したことで非常に柔軟にカスタムビルドを作成できるようになっています。ビルド設定ファイルを編集し、ビルドバリアントを利用可能にすれば、主要なコードベースを変更せずともアプリのデザイン・挙動を変化させることができます。 実際に業務で applicationId 、 versionCode 、 versionName 、アプリアイコン、アプリ名の異なる4種類のアプリをビルドする必要があったのですが、ビルドバリアントを利用することでコードを変更することなく対応できました。 さいごに VASILYではアプリエンジニアを大募集しています。是非Wantedlyからご応募ください。