【個人開発】飲み方やおつまみの相性から直感的にウイスキーを探せるサービスを作った話🥃

![OGP.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1834267/71b87264-5ed3-7ff9-1ef0-41a88850138a.png)

飲み方やおつまみの相性、香りの強さ、口当たりの軽さなど
直感的な要素からウイスキーを探せるサービスをリリースしました！🥃

▼サービスURL
https://malt-mate.jp

▼GitHub
https://github.com/tarakish/whiskey_database

# はじめに🧎🏻
はじめまして！駆け出しへべれけエンジニアの[みや](https://twitter.com/tarakish_23)と申します🫠
ウイスキーについても駆け出しのためネットで情報を探すことが多いのですが、
フレーバー？歴史？タイヤのような香り？カスクストレングス？etc...前提知識が必要だったり、
そもそもの情報量が多すぎて分からなくなることが多いな〜と感じてました。そこで
- 相性のいい飲み方やおつまみなど、**飲む際に実際に考えるような要素**から探せる
- 網羅するのではなく、逆に**情報を絞って表示**する
- 絞った上で**表示する情報は直感的に分かりやすく**する

そんなサービスがあれば、同じような悩みを持つウイスキービギナーのウイスキー選びを助けられるんじゃないか？
あわよくば、沼に引っ張りこんで飲み仲間ができるんじゃないか？
そんな ~~企み~~ 想いから本サービスの開発に思い至りました！🥃

# サービス概要と使い方🥃
### コア機能（ウイスキーの詳細・検索機能）🔎
各ウイスキーに対して、相性のいい飲み方やおつまみを紐付けていることでその要素から検索ができます。

|一覧ページから検索|ウイスキー詳細での表示|
|---|---|
|[![一覧で検索](https://i.gyazo.com/461d3bcf15dfab780cfb50ab17b593ca.gif)](https://gyazo.com/461d3bcf15dfab780cfb50ab17b593ca)|[![詳細でのペアリング](https://i.gyazo.com/16a81695f5edc263e7c2e3413dc35128.gif)](https://gyazo.com/16a81695f5edc263e7c2e3413dc35128)
|一覧からは利用頻度の高い「飲み方」「おつまみ」での絞り込みのみに留め、認知負荷を下げる。|「相性のいい飲み方」「相性のいいおつまみ」をクリックすると各一覧ページの対象箇所に遷移します。|

一覧ページではセレクトボックスによる並び替えも利用できます。これを[Ransack](https://github.com/activerecord-hackery/ransack)で実現するのに少し手間だったので、以下の記事にまとめました。

https://qiita.com/tarakish/items/a50de8e2810cd54c17c0

<br>

その他にも香りの強さ、口当たりの重さ、フレーバー、手に入り易さ、価格などの情報を持たせており、それらの要素からも検索ができます。
|詳細検索画面|ウイスキー詳細での表示|
|---|---|
|[![詳細検索](https://i.gyazo.com/136c991d25545835b1d788f47ae5f4b5.gif)](https://gyazo.com/136c991d25545835b1d788f47ae5f4b5)|[![ウイスキー詳細での表示](https://i.gyazo.com/3717685db8f7ff4166cc2297c3574753.gif)](https://gyazo.com/3717685db8f7ff4166cc2297c3574753)|
|ラベルにチェックボックスを貼り、デザインの統一感と操作性を両立。|一部情報はあえて初期表示しないことで情報量を減らし、認知負荷を軽減。|
<br>

また、各ウイスキーに紐付けた「おすすめの飲み方」「フレーバー」から、**[独自メソッド](https://qiita.com/tarakish/items/cfc3c5a1ae05e2a46cbe#5-%E7%8B%AC%E8%87%AA%E3%83%A1%E3%82%BD%E3%83%83%E3%83%89%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6)を通して類似度の高いウイスキーを『似ているウイスキー』として表示**することで、好みのウイスキーに傾向の近いものを探せます。
|ザ・マッカラン　シェリーオーク12年の場合|
|---|
|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1834267/71681c61-71c3-f4b5-f48d-f4f689b41c85.png" width="400">|

### ユーザー機能👤
ユーザー登録することで「気になる！（いいね）機能」「テイスティングノート（投稿）機能」が利用可能になります。
登録＆ログインは、Twitter・Google・LINEの外部認証から選択できます。
|マイページ|気になる！一覧(マイページ)|
|---|---|
|![マイページ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1834267/534c542b-99b8-1e0f-ef2a-f3b3f58dca85.png)|![気になる一覧](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1834267/28397493-defb-a057-f58e-2e543fba665d.png)|


|テイスティングノート一覧（マイページ）|テイスティングノート投稿・編集（ウイスキー詳細）|
|---|---|
|![テイスティングノート一覧（マイページ）](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1834267/354fd263-6080-25bd-bdf4-44ccb103286c.png)|![テイスティングノート投稿（ウイスキー詳細）](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1834267/9f74892f-cfc3-2fcd-f28f-71e943d01ba7.png)|

# 工夫点🤔
本サービスの開発にあたって意識した点、工夫点について、5点ほどお話させてください。
1. ウイスキーの分かりにくさをどう解消するか
2. UI/UXのFBをいただくためにノーコードでチャットボットを作成・配置
3. エラーページのハンドリング
4. 外部認証のみのログイン機能
5. 独自メソッドについて

### 1. ウイスキーの分かりにくさをどう解消するか？
具体的には[はじめに](https://qiita.com/tarakish/items/cfc3c5a1ae05e2a46cbe#%E3%81%AF%E3%81%98%E3%82%81%E3%81%AB)で書いた通りですが、抽象化すると主に
- UI/UXを最適化すること
- 情報量を絞り認知負荷を下げること([コア機能](https://qiita.com/tarakish/items/cfc3c5a1ae05e2a46cbe#%E3%82%B3%E3%82%A2%E6%A9%9F%E8%83%BD%E3%82%A6%E3%82%A4%E3%82%B9%E3%82%AD%E3%83%BC%E3%81%AE%E8%A9%B3%E7%B4%B0%E6%A4%9C%E7%B4%A2%E6%A9%9F%E8%83%BD)で説明)

の２軸で解消を試みました。
UI/UXの最適化に関しては、以下のようなことを意識しました。

- どういった探し方をするだろうか？を考える
    初心者の場合ウイスキーの知識に乏しいため、身近な要素から探すだろうという仮説を立て、「相性のいい飲み方」「相性のいいおつまみ」を軸にすることでより現実に即して検索できるような導線で実装を行いました。

- FBを参考にする
    「好みのウイスキーに似たウイスキーがわかると嬉しい」とのユーザーの声をいただいたため、[独自メソッド](https://qiita.com/tarakish/items/cfc3c5a1ae05e2a46cbe#5-%E7%8B%AC%E8%87%AA%E3%83%A1%E3%82%BD%E3%83%83%E3%83%89%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6)を作成し、各ウイスキーに似たウイスキーをサジェストする機能を実装しました。

### 2. UI/UXのフィードバックを得るため、ノーコードでチャットボットを作成・配置
上記のような仮説が正しいのか計測をするために、[Collect Chat](https://workspace.google.com/marketplace/app/form_to_chatbot_%E2%9A%A1%EF%B8%8F/349974714221?hl=ja)というノーコードツールでチャットボットを作成・配置しフィードバックを集めました。
これにより仮説ではなく、実際に課題解決できていることが確認できました。

### 3. エラーページのハンドリング
404,500エラーに関しては、サービスで紹介しているウイスキーを表示し、離脱率を下げることを意識しました。
|404エラー|
|---|
|![404](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1834267/7cfcf583-46da-2547-a694-e4931c22f8b8.png)|

### 4. 外部認証のみのログイン機能
本サービスの登録、ログイン機能は、Twitter・Google・LINEの外部認証(`Oauth2.0`)のみの実装です。
理由としては、主に３点です。
1.  登録・ログイン時の手間を減らすため
2.  メアド＆パスワード認証によるセキュリティ管理コストを減らすため
3.  外部認証機能の学習のため

1に関しては、UI／UXの観点から。
２、３に関しては、自分がまだ未経験エンジニアであるため。
また、3のアウトプットとして記事を書きました。（`sorcery` × `Google`・`LINE`認証の記事が見当たらなかったので）

https://qiita.com/tarakish/items/8e61106fa2473a9d97e8

https://qiita.com/tarakish/items/a4c73104375878ad61c5

### 5. 独自メソッドについて
各ウイスキーに似ているウイスキーを算出するために独自でメソッドを定義しました。
冗長かつパフォーマンス上の問題もありますが以下のような形です。(必要な部分のみ記載)
```ruby:whiskey.rb
class Whiskey < ApplicationRecord
  belongs_to :drink_way
  has_many :whiskey_flavors, dependent: :destroy
  has_many :flavors, through: :whiskey_flavors

  # ウイスキーとフレーバーはN:Nだが、一つのウイスキーに紐付けられるフレーバーは3つまで。
  validates :whiskey_flavors,
            length: { maximum: 3,
                      message:
                      I18n.t('activerecord.errors.messages.upto_three') }


  def similar_to_self
    whiskeys_same_drink_way = Whiskey.where(drink_way_id: drink_way).where.not(id: id)
                                     .eager_load(:flavors, :whiskey_flavors)
    return if whiskeys_same_drink_way.blank?

    original_flavors = flavors.map(&:category_before_type_cast)
    whiskeys_with_similarity = []
    whiskeys_same_drink_way.each do |whiskey_same_drink_way|
      similarity = 0
      comparison_flavors = whiskey_same_drink_way.flavors.map(&:category_before_type_cast).tally
      original_flavors.uniq.each do |i|
        similarity += comparison_flavors[i] unless comparison_flavors[i].nil?
      end
      whiskeys_with_similarity.push({ whiskey: whiskey_same_drink_way, sim: similarity })
    end
    max_similarity = whiskeys_with_similarity.max_by { |i| i[:sim] }[:sim]
    return if max_similarity.zero?

    whiskeys_with_similarity.filter { |i| i[:sim] == max_similarity }.sample[:whiskey]
  end
end
```
```ruby:flaovr.rb
class Flavor < ApplicationRecord
  has_many :whiskey_flavors
  has_many :whiskeys, through: :whiskey_flavors

  enum category: { woody: 0, winy: 1, fruity: 2, floral: 3, sereal: 4, smoky: 5 }
end
```
```ruby:whiskeys_controller.rb
class WhiskeysController < ApplicationController
  def show
    @whiskey = Whiskey.find(params[:id])
    # ここで独自メソッドを呼び出し、nilの場合はviewで制御しています。
    @recommended_whiskey = @whiskey.similar_to_self
  end
end
```
##### `similar_to_self`メソッドについて細かく説明します。
```ruby
whiskeys_same_drink_way = Whiskey.where(drink_way_id: drink_way).where.not(id: id)
                                     .eager_load(:flavors, :whiskey_flavors)
return if whiskeys_same_drink_way.blank?
```
まず、おすすめの飲み方が同じウイスキー（以後「子ウイスキー」）のコレクション（自身は除く）を取得。
なかった場合はnilを返します。
```ruby
original_flavors = flavors.map(&:category_before_type_cast)
```
レシーバのウイスキーインスタンス(以後「親ウイスキー」)に紐付けられているフレーバーのカテゴリー(`enum`)を数値で取得。
```ruby
whiskeys_with_similarity = []
whiskeys_same_drink_way.each do |whiskey_same_drink_way|
  similarity = 0
  comparison_flavors = whiskey_same_drink_way.flavors.map(&:category_before_type_cast).tally
```
各子ウイスキーのフレーバーのカテゴリーの値も同様に取得→`tally`でハッシュ化し、`comparison_flavors`に入れる。
```ruby
  original_flavors.uniq.each do |i|
    similarity += comparison_flavors[i] unless comparison_flavors[i].nil?
  end
  whiskeys_with_similarity.push({ whiskey: whiskey_same_drink_way, sim: similarity })
end
```
親ウイスキーのフレーバーのカテゴリーと、子ウイスキーのフレーバーのカテゴリーを比較し、類似度`similarity`に`0~3`の範囲で入れていく。
そして「算出した類似度」と「子ウイスキーのインスタンス」をセットのハッシュとして、`whiskeys_with_similarity`に入れていく。

:::note warn
**【補足】**
子ウイスキーのフレーバーに`tally`と親ウイスキーのフレーバーに`uniq`を使う理由は、
もし使わなかった場合に
1. 親のフレーバーのカテゴリーの配列が`[1, 2, 3]`、子も同様`[1, 2, 3]`だった場合に類似度は３
2. 親が`[1, 2, 2]`、子が`[2, 2, 3]`の場合に類似度は4

2より1の方が類似度が高いはずが、類似度として逆になるため正確ではなくなってしまうため。
※配列同士のままでうまく算出できればそうしたかったのですが、自分の頭では思いつかず…
:::
```ruby
max_similarity = whiskeys_with_similarity.max_by { |i| i[:sim] }[:sim]
return if max_similarity.zero?
```
算出した中から類似度の最大値を取得する。
類似度の最大値が0の場合、類似していないも同義のためnilを返す。
```ruby
whiskeys_with_similarity.filter { |i| i[:sim] == max_similarity }.sample[:whiskey]
```
`whiskeys_with_similarity`から類似度が最大のものをすべて取得し、その中から一つをランダムで返す。

# 主な使用技術💻
- `Ruby 3.0.1`
- `Ruby on Rails 6.1.4`
  - 【認証】sorcery ＆ Oauth 2.0(Google・Twitter・LINE)
- `Heroku`
- `AWS S3`
- `Semantic UI`


# ER図🥞
![erd.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1834267/f775be3b-c4de-b28b-468a-d0cb58626b98.png)

# おわりに🙇🏻‍♂️
長文にも関わらず最後までご覧いただきありがとうございました！！！
ウイスキーが気になっていた方はもちろん、興味なかったけど記事を読んでちょっと興味が出てきたあなたも
~~その一歩がウイスキー沼の入り口とも気付かずに~~ ぜひ一度覗きに来てくださると嬉しいです！🥃

▼ Malt Mate
https://malt-mate.jp

▼ Twitter
[@malt_mate](https://twitter.com/malt_mate)（サービスの公式アカウント）　/　[@tarakish_23](https://twitter.com/tarakish_23)（開発者）

### このサービスを開発する中で執筆した記事

https://qiita.com/tarakish/items/4a6472e7ecb0e07f2e6a

https://qiita.com/tarakish/items/a50de8e2810cd54c17c0

https://qiita.com/tarakish/items/8e61106fa2473a9d97e8

https://qiita.com/tarakish/items/a4c73104375878ad61c5