【Slack API】Home Tabの表示
はじめに
こんにちは!大ちゃんの駆け出し技術ブログです。
今回は前回の記事に引き続きEvent Subscriptionsについて書きたいと思います。
前回は「ユーザがメッセージタブでテキトーなメッセージを送信したイベント」を検知し、それをアプリ側にリクエストを送信、そしてアプリ側がレスポンスをメッセージタブに送信するというものでした。
・ユーザがメッセージタブでテキトーなメッセージを送信(イベント) ↓ ・Slackからリクエストを送信 ↓ ・アプリがレスポンス ↓ ・メッセージタブにメッセージが送信される
前回の記事はこちら!
今回の記事では「Home Tabを開いたイベント」を検知し、それをアプリ側にリクエストを送信、そしてアプリ側がレスポンスし、Home Tabにメッセージを表示する方法を紹介します!
・ユーザがHome Tabを開くイベント) ↓ ・Slackからリクエストを送信 ↓ ・アプリがレスポンス ↓ ・Home Tabにメッセージが表示される
対象読者
Home Tabとは
Home Tabとは、Slackのアプリ画面でSlackアプリを開いたときに最初に表示されるアプリの用途や使用説明、機能の一部などを表示したタブのことです。下記の例では「Slack Developer Tools」というSlackアプリのHome Tabです。
補足ですが、このHome Tabは開発する際任意につけられるもので、Home Tabが表示されていないSlack アプリもあります。例えば、有名なアプリ「Google Drive」は表示がされていませんでした。
実装に必要なAPI各種
app_home_opened
まずHome Tabを開いたというイベントを検知するためのEvent Subscriptionsです。Home Tabを開いたときのイベントを検知するためにはapp_home_openedをEvent Subscriptionsに設定します。
views.publish
Home Tabにメッセージを表示させたいときに使用する専用のメソッドとして、views.publishメソッドを使用します。
このメソッドを使うために必要なscopeはありません。
トークン以外の必要なArgumentsとして、user_idとviewが必要になります。
このviewですが、今まで投稿機能の時に使用していたBlock Kitとほとんど変わりません。異なる点として、今まではblocksの中身の配列を指定していましたが、type":"home"
を含めたJSONオブジェクトを指定する必要があります。Home Tabのオブジェクトであると区別するためですかね。
{ "type":"home", "blocks":[ { "type":"section", "text":{ "type":"mrkdwn", "text":"A simple stack of blocks for the simple sample Block Kit Home tab." } }, { "type":"actions", "elements":[ { "type":"button", "text":{ "type":"plain_text", "text":"Action A", "emoji":true } }, { "type":"button", "text":{ "type":"plain_text", "text":"Action B", "emoji":true } } ] } ] }
実装手順
それでは実装していきましょう!
※前回の記事で既にEvent Subscriptionsの設定を行いました。今回は同様のリクエストURLに送信するので、まだ行っていない人はこちらの記事を参照してください。
app_home_openedの設定
まずSlack アプリ開発画面からEvent Subscriptionsを設定します。
Subscribe to bot eventsのタブを開きます。
「Add Bot User Event」のボタンをクリックして、app_home_openedを検索。追加したら設定を保存するために「Save Changes」をクリックしてください。
これでアプリのHome Tabを開いたときにイベントが発生し、リクエストURLにリクエストが送信されます。
views.publish
あとはviews.publishを使用してHome Tabに表示させたいメッセージを表示します。
ただここで少しだけ注意が必要です。Event SubscriptionsのリクエストURLは一つしか設定できません。つまり、前回実装したテキトーなメッセージを送信するイベントのリクエストURLがアプリのURL/slack/events
だったのですが、今回のHome Tabを開いた時のリクエストURLも同様にアプリのURL/slack/events
となります。よって、同一のURLの中で条件分岐をする必要があります。
条件分岐をさせる方法として、Slackから送信されるパラメーターを使用します。今回の実装でHome Tabを開いた場合、下記のようなパラーメーターが送信されます。
{ "type": "app_home_opened", "user": "U061F7AUR", "channel": "D0LAN2Q65", "event_ts": "1515449522000016", "tab": "home", "view": { "id": "VPASKP233", "team_id": "T21312902", "type": "home", "blocks": [ ... ], "private_metadata": "", "callback_id": "", "state":{ ... }, "hash":"1231232323.12321312", "clear_on_close": false, "notify_on_close": false, "root_view_id": "VPASKP233", "app_id": "A21SDS90", "external_id": "", "app_installed_team_id": "T21312902", "bot_id": "BSDKSAO2" } }
"type": "app_home_opened"
とあるように、Home Tabが開いたイベントであることがわかります。
逆に、メッセージを送信したというイベントのパラメーターが下記になります。
{ "token": "one-long-verification-token", "team_id": "T061EG9R6", "api_app_id": "A0PNCHHK2", "event": { "type": "message", "channel": "D024BE91L", "user": "U2147483697", "text": "Hello hello can you hear me?", "ts": "1355517523.000005", "event_ts": "1355517523.000005", "channel_type": "im" }, "type": "event_callback", "authed_teams": [ "T061EG9R6" ], "event_id": "Ev0PV52K21", "event_time": 1355517523 }
"type": "message"
で、"text"
のパラメーターがあれば、それはテキトーに送信したメッセージであることがわかりそうですね。
よって条件分岐をすると下記のようになります。views_publish
メソッドにはもちろんHome Tabを表示させるメソッドを後述します。
def respond if params[:event][:type] == 'app_home_opened' views_publish elsif params[:event][:type] == 'message' && params[:event][:text].present? send_help_msg end end
条件分岐が完了したところで、いよいよAPIメソッドを使用してメッセージを表示させます。
先にコードを見せておきます。
def views_publish team = Team.find_by(workspace_id: params[:team_id]) user = User.find_by(uid: params[:event][:user]) return if team.nil? || user.nil? || team.workspace_id != user.team.workspace_id access_token = set_access_token(user.authentication.access_token) publish_to_home_tab(team, user, access_token) end def publish_to_home_tab(team, user, access_token) encoded_msg = encoded_home_tab_block_msg(team) access_token.post("api/views.publish?user_id=#{user.uid}&view=#{encoded_msg}&pretty=1").parsed end def encoded_home_tab_block_msg(team) channel_id = team.share_channel_id channel_name = team.share_channel_name msg = "{~~~~~~~~~~~~~}" encoded_msg = ERB::Util.url_encode(msg) encoded_msg end
まず、Slackアプリにユーザとチームが存在し、ユーザがチームに属しているはずですので、そうでない場合はreturnで処理を強制終了させます。
team = Team.find_by(workspace_id: params[:team_id]) user = User.find_by(uid: params[:event][:user]) return if team.nil? || user.nil? || team.workspace_id != user.team.workspace_id
その後access_tokenを取り出すメソッドを使用します。
access_token = set_access_token(user.authentication.access_token)
上述しましたviewですが、msgの箇所は長すぎたので割愛します。自分はかなり複雑なviewを設定しましたが、作り方は簡単です。block-kit-builderを使用して、タブにApp Home Previewを指定すると、Home Tabに表示したいJSONオブジェクトを簡単に作れます。
あとはAPIメソッドを使用してHome Tabにメッセージを表示させます。
access_token.post("api/views.publish?user_id=#{user.uid}&view=#{encoded_msg}&pretty=1").parsed
これで実装は終了です。実際にアプリのHome Tabを開いてみましょう。
実際に表示することができました!
【Slack】テキトーなテキストメッセージへの返信方法
はじめに
こんにちは!大ちゃんの駆け出し技術ブログです。
今回もまた!Slack APIです笑
もうどんだけ書くのよという感じですが、今はこれに奮闘しすぎてSlackのネタがたくさん出てきてしまいます笑
今回はテキトーなメッセージを送信した時にヘルプメッセージを送信する方法を紹介します。基本的にはヘルプメッセージはslashコマンドの一覧を返します。
読者対象
メッセージタブでのやりとりのフロー
この記事を深く理解するための事前知識として、「メッセージタブ」の存在に触れておきます。メッセージタブとは、Slack Appのボットとメッセージのやりとりができるスペースのことです。下の図で言うところの「Home」、「Messages」、「About」のMessagesにあたる箇所です。
Slackアプリはこのメッセージタブでイベントを発生させることでユーザとのやりとりを可能にします。メッセージタブでのイベントの種類は下記のような感じです。
- ユーザがメッセージタブを開いた時
- ユーザがメッセージタブにメッセージを送信した時
今回は後者のメッセージタブにメッセージを送信した時にアプリ側にリクエストが送られ、それに対してヘルプメッセージを送信するフローになります。
- ユーザが「hogehoge」とメッセージタブに打ち込む
↓
- Slackが指定のURLにリクエストを送信
↓
- アプリ側でヘルプメッセージを送信する処理
↓
- メッセージタブにヘルプメッセージが送られる
方法がわからなかったのはSlackが指定のURLにリクエストを送信する方法です。「hogehoge」とメッセージタブに送信を行った場合、Slack側ではどのようにそれをアプリ側に送信するのでしょうか。
Event Subscriptions
SlackではEvent SubscriptionsというSlack上でのユーザの動作に対してアプリにリクエストを送ることができる機能があります。
どこで設定するのかというと、Slack App開発画面のFeatures > Event Subscriptionsで設定できます。
ここで「ユーザがメッセージタブにメッセージを送った時にアプリにリクエストを送る」と言う設定を行っていきます。ユーザがメッセージタブにメッセージを送るイベントをアプリ側に送るためには、message.im event
をEvent Subscriptionsで設定します。
Event reference: message.im event
少し大雑把に説明しましたが、ある程度の理解で構いません。今はEvent Subscriptionsの設定でmessage.im event
を使用することでメッセージタブのイベントをリクエストとして送信することができるんだなと思っておいてください。
実装手順
それでは実際に設定していきます。
まず、先ほどの設定画面で右上にあるボタンをONにしましょう。
すると、いろいろな設定項目が出てきます。
まずRequest URLから設定していきます。これはEvent Subscriptionsで有効にしたイベントが発生した時に送信するURLのことです。Railsアプリ側でそのURLを受け取るためのルーティングを設定しましょう。下記の例ですとアプリURL/slack/events
のリクエストを受け取るようにします。
# routes.rb namespace :slack do post 'events', to: 'events#respond' end
よってRequest URLで設定するURLもこれに合わせて設定します。今回はngrokを使用してHTTPS側のURLを設定します。
すると、下記のようにエラーメッセージ が表示されたはずです。
**URLがchallengeパラメータの値で応答しませんでした。**
Event SubscriptionsのURLを受け取るためにはアプリ側で一度challenge
パラメータの値に応答しておく必要があります。これはurl_verification
というイベントが発生していて、Slack側で発行されるchallenge
パラメータをRequest URLのレスポンスでJSONでレンダーすることで、アプリのリクエストURLが本人のものということを認証します。
Event reference: url_verification event
このRequest URLはどのようなHTTPSのURLも設定できてしまいます。例えばYouTubeのURLも設定できます。
https://www.youtube.com/
しかし、当然ですがYouTubeは自分が開発したものではないですよね。何の認証もなしにRequest URLの値に好きなURLを入れられたら、たくさんのリクエストが知らないところから飛んできてしまいます。
そのため、Request URLに最初にchallenge
パラーメーターを送り、同じ値をレンダーすることでそのアプリが本人のものということを認証するのです。よってchallenge
パラメータ をレンダーするようにします。
def respond render json: params[:challenge], status: 200 end
これで同じリクエストURLに認証を行うと、下記画面のように認証が通ったというメッセージが表示されます。
ちなみにですが、このchallenge
パラメーターへの処理は認証が通ればそれ以上は必要ないので削除します。もしこれを残したまま本番環境にデプロイすると、他のslackアプリからのRequest URLにも同じURLが指定された時にchallenge
パラメーターを返却してしまいます。あくまで自分のアプリであることを証明するためのフローです。
それでは次にmessage.im event
を追加します。Subscribe to bot eventsの「Add Bot User Event」ボタンからmessage.im
を検索しましょう。見つけたら追加して右下にある「Save Changes」ボタンをクリックします。
補足すると、im:history
というscopeがアプリ側に自動で追加されているかと思います。これはこのイベントを追加するためにはこのim:history
のscopeが必要になるということです。
これでGUI画面での設定は終わりです。実際にパラメータを受け取れるようになったはずです。binding.pryで実際にパラメータが飛んでいるか確認します。
def respond binding.pry
アプリ側で一度「hogehoge」というメッセージを送ってみましょう。すると処理が止まるはずですのでそこでparamsを確認します。するとすごく長いパラメーターを取得できたと思います。
params => <ActionController::Parameters {"token"=>"xxxxxxx", "team_id"=>"xxxxxx", "api_app_id"=>"xxxxx", "event"=><ActionController::Parameters {"client_msg_id"=>"xxxxx", "type"=>"message", "text"=>"hogehoge", "user"=>"xxxxx",
あとはユーザに対してヘルプメッセージを送信する処理のみです。
自分が作成した処理を貼っておきます。
def respond if params[:event][:type] == 'message' && params[:event][:text].present? send_help_msg end end def send_help_msg team = Team.find_by(workspace_id: params[:team_id]) user = User.find_by(uid: params[:event][:user]) return if team.nil? || user.nil? || team.workspace_id != user.team.workspace_id access_token = set_access_token(user.authentication.access_token) encoded_text = get_encoded_help_text encoded_msg = get_encoded_help_block_msg access_token.post("api/chat.postMessage?channel=#{user.uid}&blocks=#{encoded_msg}&text=#{encoded_text}&pretty=1").parsed end def get_encoded_help_text text = "ヘルプメッセージを送信しました" encoded_text = ERB::Util.url_encode(text) return encoded_text end def get_encoded_help_block_msg msg = "[ { 'type': 'divider' }, { 'type': 'section', 'text': { 'type': 'mrkdwn', 'text': '本アプリでは以下のコマンドがSlack内で利用できるよ:hamster:' } }, { 'type': 'section', 'fields': [ { 'type': 'mrkdwn', 'text': ':information_source: `/prof_help` \nDMでヘルプメッセージを送るよ' }, { 'type': 'mrkdwn', 'text': ':postbox: `/prof_random_block` \n DMでランダムにブロックを1つ送るよ' } ] }, { 'type': 'section', 'fields': [ { 'type': 'mrkdwn', 'text': ':ok_hand: `/prof_activate_share` \n毎日18時の投稿を有効するよ' }, { 'type': 'mrkdwn', 'text': ':raised_back_of_hand: `/prof_inactivate_share` \n 毎日18時の投稿を止めるよ' } ] }, { 'type': 'divider' } ]" encoded_msg = ERB::Util.url_encode(msg) return encoded_msg end
まずパラメータの種類がmessageであること、textが含まれていることでユーザからのDMだということがわかるので条件分岐を張り付けます。
if params[:event][:type] == 'message' && params[:event][:text].present?
DMであることがわかれば、そのユーザからアクセストークンと取り出します。その手前でuserとteamを取り出している処理ですが、これはアプリにユーザが登録しているかどうかを検証するためです。ユーザでなければ基本的にはヘルプメッセージを送信する必要がないので。
team = Team.find_by(workspace_id: params[:team_id]) user = User.find_by(uid: params[:event][:user]) return if team.nil? || user.nil? || team.workspace_id != user.team.workspace_id access_token = set_access_token(user.authentication.access_token)
あとはchat.postMessageメソッドでエンコードしたメッセージを送信しています。ここれへんの説明はSlack APIをよく知っていればわかるはずです。
【Rails】本番環境のツール各種
はじめに
こんにちは!大ちゃんの駆け出し技術ブログです。
ほぼポートフォリオも完成というところまできたので、本番環境に必要な解析ツールやHerokuのAdd-onを導入したのでそれらの導入を記録した記事を残しておきます。各ツールの機能について説明を入れながら導入していきます。
New Relic APM
【概要】
HerokuのAdd-onでアプリのパフォーマンスを監視してくれるサービスです。非常によく使われるAdd-onのようで導入も簡単でした。
【参考】
- 公式
Elements Marketplace: New Relic APM
- 導入記事
Heroku上のRailsアプリにNewRelicを導入する - Qiita
【インストール】
① アプリ画面から「Resources」の欄に移動し、Add-on検索からNew Relic APMを検索し、Freeプランで追加します。
② ダッシュボード画面に遷移します。下記コマンドをターミナルに打ち込めば遷移できます。
$ heroku addons:open newrelic
③ 「Add an app or servise」をクリック
④ サイドバーが出現するのでそこで言語にRubyを選択
⑤ 各項目を入力していきます。
- Give your application a name
⇒ アプリ名を入力
- Are you using Bundler?
⇒ Yesを選択すると、下記gemをインストールするように指示されるのでインストール
# Gemfile gem 'newrelic_rpm' # ターミナル $ bundle install ・ ・ ・ Installing newrelic_rpm 7.1.0
- Download your custom configuration file
ファイルをダウンロードしconfig
配下に配置します。Licenseが書かれているので環境変数に移します。
# config/newrelic.yml # # This file configures the New Relic Agent. New Relic monitors Ruby, Java, # .NET, PHP, Python, Node, and Go applications with deep visibility and low # overhead. For more information, visit www.newrelic.com. # # Generated June 15, 2021 # # This configuration file is custom generated for NewRelic Administration # # For full documentation of agent configuration options, please refer to # https://docs.newrelic.com/docs/agents/ruby-agent/installation-configuration/ruby-agent-configuration common: &default_settings # Required license key associated with your New Relic account. license_key: ENV['LICENSE_KEY'] # Your application name. Renaming here affects where data displays in New # Relic. For more details, see https://docs.newrelic.com/docs/apm/new-relic-apm/maintenance/renaming-applications app_name: prof-chan distributed_tracing: enabled: true # To disable the agent regardless of other settings, uncomment the following: # agent_enabled: false # Logging level for log/newrelic_agent.log log_level: info # Environment-specific settings are in this section. # RAILS_ENV or RACK_ENV (as appropriate) is used to determine the environment. # If your application has other named environments, configure them here. development: <<: *default_settings app_name: prof-chan (Development) test: <<: *default_settings # It doesn't make sense to report to New Relic from automated test runs. monitor_mode: false staging: <<: *default_settings app_name: prof-chan (Staging) production: <<: *default_settings
④ これでローカルでの設定は完了。デプロイします。
⑤ 実際にアクセスしてデータを取得できているか確認します。
Sentry
HerokuのAdd-onでサイトのエラーなどを監視し、ログとして記録するサービスです。他のAdd-onでもログの検知ツールはありますが、どちらがいいかがあまりわからなかったので脳死でSentryにしました。
【参考】
- 公式
Rails Error and Performance Monitoring
- 導入記事
【インストール】
① アプリ画面から「Resources」の欄に移動し、Add-on検索からSentryを検索し、Freeプランで追加します。
② gemをインストール
# Gemfile gem 'sentry-rails' gem 'sentry-ruby' #ターミナル $ bundle install ・ ・ ・ Installing sentry-ruby-core 4.5.1 Fetching sentry-ruby 4.5.1 Fetching sentry-rails 4.5.1 Installing sentry-ruby 4.5.1 Installing sentry-rails 4.5.1
③ config/initializers/sentry.rb
を作成します。
# config/initializers/sentry.rb Sentry.init do |config| config.dsn = 'https://xxxxxxx:xxxxxxx@xxxx.ingest.sentry.io/xxxxxx' config.breadcrumbs_logger = [:active_support_logger] # To activate performance monitoring, set one of these options. # We recommend adjusting the value in production: config.traces_sample_rate = 0.5 # or config.traces_sampler = lambda do |context| true end end
config.dsn
の値はアプリの「Settings」のConfig Varsから「SENTRY_DSN」のキーの値を参照してください。Add-onが追加されているとここに自動的に値が割り振られています。
④Herokuにデプロイしましょう。
⑤動作確認として下記コマンドを実行します。
Sentry.capture_message("test message")
Log Rocket
【概要】
Log Rocketとは自分のサイトでどのようにユーザが動いたのかを動画として記録してくれるツールです。ユーザがどこで迷っているかやどの画面に多くの時間を使っているのかなどがこれをみると一目瞭然。こんなものが今できたんですね。びっくりしました。
【参考】
- 公式
Modern Frontend Monitoring and Product Analytics
- 導入記事
ユーザー行動を動画で記録できる「LogRocket」の使い方・設定方法【JSコード1行で簡単導入】 | Tekito style.me
【インストール】
①トップ画面から「Get Started Free」をクリック
②認証方法を選択しログインします。(Googleログインなど外部認証で問題ないです。)
③プロジェクト名を入力します。基本的にはサイトのドメインなのでいいと思います。また、プロジェクト管理用アカウントとしてメールアドレスを指定して他の人などを招待もできます。自分はサイト用のアドレスを追加しました。
④インストール方法としてnpmを使用して直接インストールする方法とheadタグにscriptを挿入する方法があります。npmより取り外しが簡単なscriptでも問題ないと思います。
- npm
- script
headタグにscriptを追加(slimの場合)
doctype html html head / Log Rocket script src="https://cdn.lr-ingest.io/LogRocket.min.js" crossorigin="anonymous" javascript: window.LogRocket && window.LogRocket.init('9mwyop/f');
以上でLog Rocketの導入は完了です。めちゃ簡単で驚きました。
【Slack API】APIメソッドまとめ
はじめに
こんにちは!大ちゃんの駆け出し技術ブログです。
今回は自分が今まで使用してきたSlack のAPIのメソッドをまとめてみました!メソッドの使用方法と使用するために必要なscopeなどを載せておきます。1から詳しくは説明しませんがメソッドでできることを大まかにお伝えできたらと思います。
※「scopeとはなんぞ?」と思った方は以前自分が書いた記事のscopeの節を読んでいただければと思います。
【Slack API】user scopeとbot scope - 大ちゃんの駆け出し技術ブログ
users.identity
「Sign in with Slack」でトークンを取得した後に、ユーザに関する情報を取得するメソッド。
【概要】
メソッドのURL: https://slack.com/api/users.identity 推奨HTTPメソッド: GET 必要なscope: identity.basic(tokenの種類: user)
取得できる情報
- ユーザの名前
- ユーザのID
- ユーザのいるワークスペースのID
【JSON】
{ "ok": true, "user": { "name": "Sonny Whether", "id": "U0G9QF9C6" }, "team": { "id": "T0G9PQBBK" } }
【公式サイト】
以下の3つのscopeを追加で付与することで取得するユーザ情報を増やすことができます。
identity.email
identity.avatar
identity.team
【identity.email
を追加した場合のJSON】
{ "ok": true, "user": { "name": "Sonny Whether", "id": "U0G9QF9C6", "email": "bobby@example.com" // emailアドレスの追加 }, "team": { "id": "T0G9PQBBK" } }
【identity.avatar
を追加した場合のJSON】
{ "ok": true, "user": { "name": "Sonny Whether", "id": "U0G9QF9C6", // 各サイズごとの画像の追加(数字はサイズを表している) "image_24": "https://cdn.example.com/sonny_24.jpg", "image_32": "https://cdn.example.com/sonny_32.jpg", "image_48": "https://cdn.example.com/sonny_48.jpg", "image_72": "https://cdn.example.com/sonny_72.jpg", "image_192": "https://cdn.example.com/sonny_192.jpg" }, "team": { "id": "T0G9PQBBK" } }
【identity.team
を追加した場合のJSON】
{ "ok": true, "user": { "name": "Sonny Whether", "id": "U0G9QF9C6" }, "team": { "name": "Captain Fabian's Naval Supply", // teamの名前の追加 "id": "T0G9PQBBK" } }
基本的にidentity.basic
のJSONレスポンスだけだと不十分なので、identity.avatar
などを追加で付与することが多いのかなと思っています。
conversations.list
ワークスペースにあるチャンネルをリストとして返してくれるメソッド。
【概要】
メソッドのURL: https://slack.com/api/conversations.list 推奨HTTPメソッド: GET 必要なscope: channels:read, groups:read, im:read, mpim:read(tokenの種類: bot)
取得できる情報
- ワークスペースのチャンネルリスト
- チャンネルごとの名前やID、その他細かい情報
【JSON】
{ "ok": true, "channels": [ { "id": "C012AB3CD", "name": "general", "is_channel": true, "is_group": false, "is_im": false, "created": 1449252889, "creator": "U012A3CDE", "is_archived": false, "is_general": true, "unlinked": 0, "name_normalized": "general", "is_shared": false, "is_ext_shared": false, "is_org_shared": false, "pending_shared": [], "is_pending_ext_shared": false, "is_member": true, "is_private": false, "is_mpim": false, "topic": { "value": "Company-wide announcements and work-based matters", "creator": "", "last_set": 0 }, "purpose": { "value": "This channel is for team-wide communication and announcements. All team members are in this channel.", "creator": "", "last_set": 0 }, "previous_names": [], "num_members": 4 }, { "id": "C061EG9T2", "name": "random", "is_channel": true, "is_group": false, "is_im": false, "created": 1449252889, "creator": "U061F7AUR", "is_archived": false, "is_general": false, "unlinked": 0, "name_normalized": "random", "is_shared": false, "is_ext_shared": false, "is_org_shared": false, "pending_shared": [], "is_pending_ext_shared": false, "is_member": true, "is_private": false, "is_mpim": false, "topic": { "value": "Non-work banter and water cooler conversation", "creator": "", "last_set": 0 }, "purpose": { "value": "A place for non-work-related flimflam, faffing, hodge-podge or jibber-jabber you'd prefer to keep out of more focused work-related channels.", "creator": "", "last_set": 0 }, "previous_names": [], "num_members": 4 } ], "response_metadata": { "next_cursor": "dGVhbTpDMDYxRkE1UEI=" } }
【公式サイト】
少しわかりづらい値としてis_channelなどがあるかと思いますが、それも公式で説明してくれています。
調べてみると同じような情報が乱立しているようです。数が多いので抜粋して説明します。
is_channel
・・・チャンネルがパブリックチャンネルであればtrue、プライベートであればfalseを返すis_group
・・・チャンネルがパブリックチャンネルであればfalse、プライベートであればtrueを返すis_im
・・・DMの会話であるかどうかcreated
・・・unixのタイムスタンプcreator
・・・チャンネルを作成したユーザのID
ちなみに必要なscopeはchannels:read
, groups:read
, im:read
, mpim:read
と書かれていますが、全ての説明が必要と言うわけではありません。users.identity
の時と同じようにscopeによって取得できるチャンネルの種類が変わります。
channels:read
・・・ワークスペース内のパブリックチャンネルgroups:read
・・・アプリボットが参加しているチャンネルim:read
・・・アプリボットがDMを送った会話mpim:read
・・・アプリボットがグループにDMを送った会話
conversations.create
ワークスペース内にチャンネルを作成するメソッド。
【概要】
メソッドのURL: https://slack.com/api/conversations.create 推奨HTTPメソッド: POST 必要なscope: channels:manage, groups:write, im:write, mpim:write(tokenの種類: bot) 必要な値: name(作成するチャンネルの名前)
取得できる情報
- 作成したチャンネル
- チャンネルの名前やID、その他細かい情報
【JSON】
{ "ok": true, "channel": { "id": "C0EAQDV4Z", "name": "endeavor", "is_channel": true, "is_group": false, "is_im": false, "created": 1504554479, "creator": "U0123456", "is_archived": false, "is_general": false, "unlinked": 0, "name_normalized": "endeavor", "is_shared": false, "is_ext_shared": false, "is_org_shared": false, "pending_shared": [], "is_pending_ext_shared": false, "is_member": true, "is_private": false, "is_mpim": false, "last_read": "0000000000.000000", "latest": null, "unread_count": 0, "unread_count_display": 0, "topic": { "value": "", "creator": "", "last_set": 0 }, "purpose": { "value": "", "creator": "", "last_set": 0 }, "previous_names": [], "priority": 0 }
【公式サイト】
こちらのメソッドではURLに作成するチャンネル名を含める必要があります。
def create_channel_in_team(channel_name, access_token) encoded_name = URI.encode_www_form_component(channel_name) access_token.post("api/conversations.create?name=#{encoded_name}&pretty=1").parsed end
こちらのメソッドも複数のscopeを必要としますが、channels:manage
とgroups:write
で使い分ける必要がありそうです。
channels:manage
・・・新規チャンネルの作成権限とアプリボットが参加しているパブリックチャンネルの管理権限(チャンネル名を変えたり、チャンネルのトピックを追加するなどの権利)groups:write
・・・新規チャンネルの作成権限とアプリボットが参加しているプライベートチャンネルの管理権限(チャンネル名を変えたり、チャンネルのトピックを追加するなどの権利)im:write
・・・ダイレクトメッセージの開始mpim:write
・・・複数人とのダイレクトメッセージの開始
パブリックチャンネルを作成する場合channels:manage
を、プライベートチャンネルを作成する場合はgroups:write
を使用しましょう。
conversations.invite
チャンネルにユーザを参加させるメソッド。
【概要】
メソッドのURL: https://slack.com/api/conversations.invite 推奨HTTPメソッド: POST 必要なscope: channels:manage, groups:write, im:write, mpim:write(tokenの種類: bot) 必要な値: channel(招待するチャンネルのID), users(招待するユーザのリスト)
取得できる情報
- 招待したチャンネル
- チャンネルの名前やID、その他細かい情報
【JSON】
{ "ok": true, "channel": { "id": "C012AB3CD", "name": "general", "is_channel": true, "is_group": false, "is_im": false, "created": 1449252889, "creator": "W012A3BCD", "is_archived": false, "is_general": true, "unlinked": 0, "name_normalized": "general", "is_read_only": false, "is_shared": false, "is_ext_shared": false, "is_org_shared": false, "pending_shared": [], "is_pending_ext_shared": false, "is_member": true, "is_private": false, "is_mpim": false, "last_read": "1502126650.228446", "topic": { "value": "For public discussion of generalities", "creator": "W012A3BCD", "last_set": 1449709364 }, "purpose": { "value": "This part of the workspace is for fun. Make fun here.", "creator": "W012A3BCD", "last_set": 1449709364 }, "previous_names": [ "specifics", "abstractions", "etc" ] } }
【公式サイト】
こちらのメソッドではURLに招待するチャンネルのIDと招待するユーザのID群を含める必要があります。以下の例は1人のユーザを招待しています。
def try_invite_user(info, channel, access_token) channel_id = channel.dig("id") user_id = info.dig("user", "id") access_token.post("api/conversations.invite?channel=#{channel_id}&users=#{user_id}&pretty=1").parsed end
conversations.create
でチャンネルを作成し、conversations.invite
でそのチャンネルに招待するという流れになるかと思うので、必要となるscopeについてはconversations.create
と同じかと思います。
chat.postMessage
メッセージを送信するためのメソッド。Slack APIに関する記事の例で最も使われるメソッドです。
【概要】
メソッドのURL: https://slack.com/api/chat.postMessage 推奨HTTPメソッド: POST 必要なscope: chat:write(tokenの種類: bot) 必要な値: channel(送信するチャンネルのID)
取得できる情報
- 送信したしたチャンネルのID
- 送信したメセージの内容
【JSON】
{ "ok": true, "channel": "C1H9RESGL", "ts": "1503435956.000247", "message": { "text": "Here's a message for you", "username": "ecto1", "bot_id": "B19LU7CSY", "attachments": [ { "text": "This is an attachment", "id": 1, "fallback": "This is an attachment's fallback" } ], "type": "message", "subtype": "bot_message", "ts": "1503435956.000247" } }
【公式サイト】
こちらのメソッドではURLに招待するチャンネルのIDを含める必要があります。しかし、チャンネルのIDの代わりにユーザのIDを指定するとユーザにDMを送信することができます。
def post_direct_message_to_user(info, access_token) user_id = info.dig("user", "id") encoded_mgs = get_msg text = "こんにちは" encoded_text = URI.encode_www_form_component(text) access_token.post("api/chat.postMessage?channel=#{user_id}&blocks=#{encoded_mgs}&text=#{encoded_text}&pretty=1").parsed end
また、送信するメッセージとして、text
(テキスト)、blocks
(Block Kit)、もしくはattachments
(添付)のどれかを必ず値としてURLに含める必要があります。そうでないとno_text
というエラーを返します。
必要なscopeは1つのみです。基本的にアプリbotからメッセージに送信をすることはSlack アプリを作成すると必ず必要になると思いますので以下のscopeは覚えておきましょう。
chat:write
・・・アプリbotからメッセージを送信する権限
参考
【Rails】アクセストークンは別テーブルで管理
はじめに
こんにちは!大ちゃんの駆け出し技術ブログです。
以前Slack API を使用する上でアクセストークンを管理する記事を出しました。
【Rails】Slack認証時のアクセストークンの保存 - 大ちゃんの駆け出し技術ブログ
この記事の中で暗号化をするために以下のメソッドをbefore_save
で使用しました。
before_save :encrypt_access_token def encrypt_access_token key_len = ActiveSupport::MessageEncryptor.key_len secret = Rails.application.key_generator.generate_key('salt', key_len) crypt = ActiveSupport::MessageEncryptor.new(secret) self.access_token = crypt.encrypt_and_sign(access_token) end
しかしながら、今回別の機能を実装したことで上記の実装ではできないことに気づきました。その経緯を備忘録として記録します。
ユーザの更新機能
今回追加する機能としては、ユーザごとにアプリからSlackへの投稿機能を1日に一回にするというものです。そのため、enumで投稿を既に行ったかどうかを示すカラムを定義しました。
新規カラムをusersテーブルに追加
t.integer :share_right, null: false, default: 0
user.rb
でカラムをenumとして設定
enum share_right: { not_shared_yet: 0, already_shared: 1 }
あとはユーザが投稿機能を使用したらUserに対してalready_shared!
メソッドを使用することでユーザの更新を行います。
def update_share_right if @user.not_shared_yet? # おそらく必要はないが念のため @user.already_shared! render json: @user, serializer: UserSerializer end end
そして定期実行で全てのユーザのステータスを元のnot_shared_yet
に戻します。
namespace :users do desc "全てのユーザのslack共有の権利を更新" task update_share_right: :environment do User.find_each do |user| user.not_shared_yet! if user.already_shared? end end end
ところがこのユーザの更新が問題でした。
before_saveの理解不足
しばらく気づかなかったのですが、どうもユーザが一度投稿してenumのメソッドを実行した後にAPIメソッドが使用できなくなっていました。理由はbefore_save
にありました
実はbefore_save
は作成時だけでなく、更新時にも実行されます。
バリデーションに成功し、実際にオブジェクトが保存される直前で実行されます。INSERT される場合も、UPDATE される場合も呼び出されます。INSERT もしくは UPDATE の場合だけ実行したい処理があるときは、後述する before_create / before_update を使用します。
Railsのコールバックまとめ | TECHSCORE BLOG
つまり、ユーザの更新を行ってしまうと暗号化されたアクセストークンが再度暗号化されてしまうのです。
更新前の暗号化済みトークン
RHrIxenifGqbqi4vvi7ybE79cg/AhbQDMiLh
暗号化済みトークンを再度暗号化(値が変わっている!!!)
kXUHH9fVb7ZhrqASKKi+dK2c65YHWKlhw6np
よって一度の復号ではなく二度復号処理を行わないと、復号された値を取り出すことができなくなりました。
def set_access_token encrypted_access_token = current_user.access_token key_len = ActiveSupport::MessageEncryptor.key_len secret = Rails.application.key_generator.generate_key('salt', key_len) crypt = ActiveSupport::MessageEncryptor.new(secret) decrypted_access_token = crypt.decrypt_and_verify(encrypted_access_token) decrypted_access_token = crypt.decrypt_and_verify(decrypted_access_token) # 再復号処理 access_token = OmniAuth::Slack.build_access_token(ENV['SLACK_CLIENT_ID'], ENV['SLACK_CLIENT_SECRET'], decrypted_access_token) return access_token end
しかし、定期実行でもユーザーの更新を行うため、その時にも復暗号化済みのトークンが再度暗号化されてしまいます。よって、ユーザごとにアクセストークンが暗号化されている回数が違います。
考えられる解決策
解決策について考えたところ、最初はユーザカラムの状態を変えるのが面倒なので、復号化された値が取り出せるまで複合処理を繰り返し行う実装を考えました。
def set_access_token encrypted_access_token = current_user.authentication.access_token key_len = ActiveSupport::MessageEncryptor.key_len secret = Rails.application.key_generator.generate_key('salt', key_len) crypt = ActiveSupport::MessageEncryptor.new(secret) while encrypted_access_token.kind_of?(String) encrypted_access_token = crypt.decrypt_and_verify(decrypted_access_token) end decrypted_access_token = encrypted_access_token access_token = OmniAuth::Slack.build_access_token(ENV['SLACK_CLIENT_ID'], ENV['SLACK_CLIENT_SECRET'], decrypted_access_token) return access_token end
暗号化されている値はStringクラスなのでencrypted_access_token.kind_of?(String)
でaccess_tokenがStringクラスの間復号処理を行うというものです。正直、これによって暗号化された回数に関わらず全てのアクセストークンが復号できるのでこれでもいいかと思いました。
しかし、少し適当すぎるかなと思い別の解決策を考えたところ、before_create
を使用することを考えました。
before_save の後に実行されます。オブジェクトが登録されるとき (new_record? が true のとき) は before_create が実行されます。
Railsのコールバックまとめ | TECHSCORE BLOG
before_create
であれば更新時には実行されることはないので安心だと思いました。
before_create :encrypt_access_token def encrypt_access_token key_len = ActiveSupport::MessageEncryptor.key_len secret = Rails.application.key_generator.generate_key('salt', key_len) crypt = ActiveSupport::MessageEncryptor.new(secret) self.access_token = crypt.encrypt_and_sign(access_token) end
しかし、userカラムにaccess_tokenを残しておくこと自体が少し危険だと思い始めました。今回のように後々ユーザのカラムに今回のように更新したい値が出てきた時に、毎度before_create
を使用して退避しなければなりません。加えて、誤って気づかないうちにユーザカラムを更新する処理を行っていたら、いつのまにかバグになってしまいそうです。
そのため、少々面倒ですがaccess_tokenを別で管理するテーブルをhas_oneの関係で用意することにしました。
class User < ApplicationRecord has_one :authentication, dependent: :destroy def check_authentication_existence(hash_token) if self.authentication.present? self.authentication.update!(access_token: hash_token) else @authentication = self.build_authentication(access_token: hash_token) @authentication.save! end end end
class Authentication < ApplicationRecord # before before_save :encrypt_access_token belongs_to :user def encrypt_access_token key_len = ActiveSupport::MessageEncryptor.key_len secret = Rails.application.key_generator.generate_key('salt', key_len) crypt = ActiveSupport::MessageEncryptor.new(secret) self.access_token = crypt.encrypt_and_sign(access_token) end end
別テーブルで管理することでユーザの更新を行っても、access_tokenが更新されることはありません。加えて、check_authentication_existence
メソッドをslack認証時に使用することで、アクセストークンの更新も行えるようにしておきます。
これでなんとかaccess_tokenの再暗号化を防ぐことができました!
【Rails】Slack認証時のアクセストークンの保存
はじめに
こんにちは!大ちゃんの駆け出し技術ブログです。
今日はslack認証時に得られるアクセストークンをDBに保存する方法です。(立て続けにslackについての記事を書いておりますが、最近ようやく実装できたことなのでたくさんアウトプットの題材が残っているのでアウトプットしておきます。)アクセストークンを保存することで 、下記メソッドを使用することで認証時以外でもslack APIのメソッドを使用することができます。
access_token = OmniAuth::Slack.build_access_token(ENV['SLACK_CLIENT_ID'], ENV['SLACK_CLIENT_SECRET'], hash_token)
それでは記事内容を見ていきましょう。
JSON型で保存
外部認証時の返却値request.env['omniauth.strategy']
からアクセストークンを生成するメソッドにつなげることができます。
bot_token = request.env['omniauth.strategy'].access_token hash_token = bot_token.to_hash access_token = OmniAuth::Slack.build_access_token(ENV['SLACK_CLIENT_ID'], ENV['SLACK_CLIENT_SECRET'], hash_token)
繰り返しになりますが、保存すべき値はhash_token
の値になります。
このhash_token
の値をDBに保存するのですがhash_tokenはJSONです。
hash_token => {"ok"=>true, "app_id"=>"xxxxxxxxxx", "authed_user"=> {"id"=>"xxxxxxxxxx", "scope"=>"identity.basic,identity.email,identity.avatar,identity.team", "access_token"=>"xoxp-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx", "token_type"=>"user"}, "scope"=>"chat:write,groups:write,im:write,channels:manage,channels:read,groups:read", "token_type"=>"bot", "bot_user_id"=>"xxxxxxxxxx", "team"=>{"id"=>"xxxxxxxxxx", "name"=>"prof"}, "enterprise"=>nil, "is_enterprise_install"=>false, :access_token=>"xoxb-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx", :refresh_token=>nil, :expires_at=>nil}
よってDBに保存する型はJSON型を使用します。(はじめてJSON型というものを使用しました、、、。)基本的にhashは全てJSON型の方が良さそうだと思いました。
ActiveModelのバリデータでJSON型のカラムを検証する
したがってUserカラムに追加するaccess_tokenはjson型で作成しましょう。
class DeviseTokenAuthCreateUsers < ActiveRecord::Migration[6.0] def change create_table(:users) do |t| ## User Info ・ ・ ・ t.json :access_token end end end
あとは保存時にhash_tokenを引数として指定して、保存をしてあげるだけです。
@user = User.from_omniauth(request.env['omniauth.auth'], user_info, hash_token, channel)
# app/models/user.rb class User < ApplicationRecord def self.from_omniauth(auth, user_info, hash_token, channel) user = find_or_initialize_by(provider: auth.provider, uid: auth.uid) user.password = Devise.friendly_token[0, 20] # ランダムなパスワードを作成 user.name = user_info.dig('user', 'name') user.email = user_info.dig('user', 'email') user.access_token = hash_token user.remote_image_url = user_info.dig('user', 'image_192') user.check_team_existence(user_info.dig('team'), channel) user.save! user end end
暗号化
slack APIについては今回生成したアクセストークンの値などが記されたhash_tokenを直接盗み見られてもそこまで問題ではありません。繰り返しアクセストークンを生成するメソッドを例にしますが、slackアプリのIDとなるSLACK_CLIENT_IDとSLACK_CLIENT_SECRETが知られない限り、アクセストークンを知られたとしても基本的に何もできません。
access_token = OmniAuth::Slack.build_access_token(ENV['SLACK_CLIENT_ID'], ENV['SLACK_CLIENT_SECRET'], hash_token)
SLACK_CLIENT_IDとSLACK_CLIENT_SECRETがあることでsecureな状態を保ってくれています。
しかし、アクセストークンを直接保存することはあまり良くないので、暗号化してから保存しましょう。
# app/models/user.rb class User < ApplicationRecord # before before_save :encrypt_access_token, :if => Proc.new {|u| u.access_token.present? } def encrypt_access_token key_len = ActiveSupport::MessageEncryptor.key_len secret = Rails.application.key_generator.generate_key('salt', key_len) crypt = ActiveSupport::MessageEncryptor.new(secret) self.access_token = crypt.encrypt_and_sign(access_token) end def self.from_omniauth(auth, user_info, hash_token, channel) user = find_or_initialize_by(provider: auth.provider, uid: auth.uid) user.password = Devise.friendly_token[0, 20] # ランダムなパスワードを作成 user.name = user_info.dig('user', 'name') user.email = user_info.dig('user', 'email') user.access_token = hash_token user.remote_image_url = user_info.dig('user', 'image_192') user.check_team_existence(user_info.dig('team'), channel) user.save! user end end
before_save :encrypt_access_token
で保存する前にencrypt_access_token
を実行するようにします。そのUserがアクセストークンを持っていない場合は実行しないように:if
オプションをつけています。
before_save :encrypt_access_token, :if => Proc.new {|u| u.access_token.present? }
暗号化方法にはいくつかの方法がありますが、上述したようにアクセストークンを暗号化する重要性がそこまで高くはないため、少し簡易的な方法をとっています。
def encrypt_access_token key_len = ActiveSupport::MessageEncryptor.key_len secret = Rails.application.key_generator.generate_key('salt', key_len) crypt = ActiveSupport::MessageEncryptor.new(secret) self.access_token = crypt.encrypt_and_sign(access_token) end
これを復号するときは以下のようにするば復号できます。
def set_access_token encrypted_access_token = current_user.access_token key_len = ActiveSupport::MessageEncryptor.key_len secret = Rails.application.key_generator.generate_key('salt', key_len) crypt = ActiveSupport::MessageEncryptor.new(secret) decrypted_access_token = crypt.decrypt_and_verify(encrypted_access_token) access_token = OmniAuth::Slack.build_access_token(ENV['SLACK_CLIENT_ID'], ENV['SLACK_CLIENT_SECRET'], decrypted_access_token) return access_token end
Slack認証時に保存するべき値
以上までの説明でhash_tokenを保存することで再度slack用のアクセストークンを作成することができます。しかし、アプリによってはアクセストークン以外にも認証時にカラムに保存しておくべき値があります。例えば、DMをuserに送信するアプリの場合、ユーザのIDがわかっていないと送ることができません。
chat.postMessage Slack API Method
chat.postMessage
メソッドはアクセストークンの値に加えて、channelのIDを必要とします。(DMを送る場合はuserのIDを必要とする)
よって認証時にuserのIDは控えておけば、のちのちDMを認証時以外で送る場合により簡単にDMを送ることができるでしょう。
アプリの用途によって必要なAPIメソッドが変わるのでそれに合わせて必要な値を認証時に保存しておきましょう。
今回は以上です。
【Slack API】user scopeとbot scope
はじめに
こんにちは!大ちゃんの駆け出し技術ブログです。
最近になってようやく「Sign in with Slack」を実装できるようになったのですが、そこまで到達するのに必要なSlack API特有の知識があったのでそれについて紹介していこうかと思います。今回はその第一弾ということで、user scopeとbot scopeについて書いていきます!
今回の記事は基本的にはコードを書くということはありません。基本的には説明オンリーで話を進めていきます。
scopeとは
scopeというのは「範囲」という意味で理解している人が多そうですが、その意味を使うとSlack APIでのscopeは「権限の範囲」という意味になります。APIにどの程度アクセスすることができるかを決定するのがscopeです。
scope一覧から少し抜粋し説明します。
例えば、チャンネルに投稿するというAPIの権限が欲しいのであれば、chat:write
という権限が必要になります。
このscopeがあることで、チャンネルに投稿するためのAPIメソッドが利用可能になります。chat.postMessageメソッドはSlackに投稿するために必要なメソッドです。chat:write
の権限が付与されていることで初めて利用可能になります。
chat.postMessage Slack API Method
ちなみに、scope
による権限は投稿に関わる権限となるため、投稿を更新するメソッドや削除するメソッドも利用可能になります。
chat.updateメソッド
chat.deleteメソッド
このようにscopeがAPIの利用範囲を決定づけています。scopeの理解はSlack APIを利用する上でとても重要ですので、他の権限でもどういったメソッドが利用可能なのか今後紹介していけたらと思います。
User Scope
Slackはこのscopeの権限を付与する対象としてUserとBotを明確に区別しています。Userについての理解は簡単です。Slackを利用する私たちの情報にどれほどアクセスすることができるかという権限です。例えば、identity.basic
というscopeはユーザのIDの情報を得ることができるusers.identityメソッドが利用可能になります。
users.identity Slack API Method
レスポンスとして返却されるJSON
{ "ok": true, "user": { "name": "大ちゃん", "id": "U0G9QF9C6" }, "team": { "id": "T0G9PQBBK" } }
名前とユーザIDが返却されていますね。他にもユーザのアイコン画像へのアクセスやemailアドレスへのアクセスをするためのscopeがあります。
ユーザのscopeはユーザ情報を得るためのもの以外にもchat:write
のような投稿する権限なども一応設定できますが、基本的に利用頻度は少ないです。理由は権限を使わなくてもユーザがチャンネルに参加して投稿すれば言い訳ですので。ですので、基本的にはSlack アプリを作る上では、ユーザのscopeはユーザ情報へのアクセスをどの程度行うことができるかという理解で大丈夫だと思います。
Bot Scope
BotとはSlackアプリのBotのことです。Slack Botと言えば下記のアイコンをイメージする人が多いと思います。
しかし、Botはこれだけでなくインストールしたアプリは全てBotになります。インストールしたアプリは下の画像のようにslack UIの左下に表示されると思います。
このアプリはメッセージを#generarlに投稿したり、他のチャンネルに投稿したりするかと思いますが、それはBotにscopeが付与されているためです。上述したようにchat:write
のような投稿する権限はユーザに必要ないということを説明しましたが、それはユーザが生きている人間で参加したり投稿したりを自由意志で行えるからです。アプリのBotにはそのような自由意志はなく、権限を与えなければ投稿などをさせることができません。このような違いからBotとUserで権限を分けているのです。
ちなみに2つの種類のscopeの見分け方ですがscope一覧の右列のtokenから確認できます。一覧にあるworkspaceとlegacyは昔使われていたらしいですが、今はほとんどは非推奨です。
【Slack API】Block Kitで投稿する方法
はじめに
こんにちは!大ちゃんの駆け出し技術ブログです。
slackのBlock Kitを知っていますか?
Block KitはJSON形式でメッセージの装飾を表したものになります。
slackのメッセージでは他のチャットツールよりもかなり複雑なメッセージを送ることができます。
これをJSONで表すと、、、
{ "blocks": [ { "type": "header", "text": { "type": "plain_text", "text": "New request", "emoji": true } }, { "type": "section", "fields": [ { "type": "mrkdwn", "text": "*Type:*\nPaid Time Off" }, { "type": "mrkdwn", "text": "*Created by:*\n<example.com|Fred Enriquez>" } ] }, { "type": "section", "fields": [ { "type": "mrkdwn", "text": "*When:*\nAug 10 - Aug 13" }, { "type": "mrkdwn", "text": "*Type:*\nPaid time off" } ] }, { "type": "section", "fields": [ { "type": "mrkdwn", "text": "*Hours:*\n16.0 (2 days)" }, { "type": "mrkdwn", "text": "*Remaining balance:*\n32.0 hours (4 days)" } ] }, { "type": "section", "text": { "type": "mrkdwn", "text": "<https://example.com|View request>" } } ] }
このようにかなり複雑な見た目となっています。しかし、自分で装飾するのはとても簡単です!
slackが専用で直感的に作れるようにBlock Kit Builderというものが用意されています。
これを使えば、めちゃくちゃ簡単に装飾できます。しかし、実はこれを自分のslackチャンネルにRailsアプリを使用して投稿するのに少し苦労したのでここに記録しておきます。
読者対象
- omniauthを使用してsign in with slackが実装できているアプリをお持ちの方
以下のgemを使用すればできます。
star数すごく少ないですが、omniauth-slackで1番使用されています。ちなみにTwitterのomniauthのstar数は569でした。悲しい、、、
※ omniauth-slackを使用した認証方法は結構時間かかると思いますので、別記事にて今度紹介したいと思います!
実装方法
①アクセストークンの取得
ではまず認証が通ってリダイレクトされる時に呼び出されるslackメソッドを空にした状態から始めます。
module Users class OmniauthCallbacksController < Devise::OmniauthCallbacksController skip_before_action :authenticate_user!, only: %i[slack failure] def slack end
まず、slack認証が通るとrequest.env['omniauth.strategy']
というものが返ってきます。これを使用してslack用のアクセストークンを作ります。request.env['omniauth.strategy'].access_token
でbot_token
というものを取得できます。
bot_token = request.env['omniauth.strategy'].access_token
bot_token
は簡単に説明すると、slackアプリのBot専用のアクセストークンになります。ユーザー専用のトークンはbot_token.user_token
で取り出すことができます。(bot_token
, user_token
の説明に関してはomniauth-slackを理解していることが前提ですので大幅に省きます。詳しくは今後の記事で。)
user_token = bot_token.user_token
その後、slackアプリを作る上で定番となる「SLACK CLIENT ID」、「SLACK CLIENT SECRET」を使用してBotのアクセストークンを生成することができます。第三引数にはbot_token
をハッシュ化したものを指定します。
access_token = OmniAuth::Slack.build_access_token(ENV['SLACK_CLIENT_ID'], ENV['SLACK_CLIENT_SECRET'], bot_token.to_hash
使用しているメソッド(OmniAuth::Slack.build_access_token)については下記リンクを参照ください。
access_token
を使用することでslackのAPIを叩くことができます。ちなみに、機能するかどうかを確認する方法があります。access_token.get("/api/auth.test")
を使用し、以下のようなレスポンスが返ってこれば正常に動作しています。
{ "ok": true, "url": "https://subarachnoid.slack.com/", "team": "Subarachnoid Workspace", "user": "grace", "team_id": "T12345678", "user_id": "W12345678" }
auth.testメソッドについてはこちら
これまでのコード
module Users class OmniauthCallbacksController < Devise::OmniauthCallbacksController skip_before_action :authenticate_user!, only: %i[slack failure] def slack bot_token = request.env['omniauth.strategy'].access_token user_token = bot_token.user_token access_token = OmniAuth::Slack.build_access_token(ENV['SLACK_CLIENT_ID'], ENV['SLACK_CLIENT_SECRET'], bot_token.to_hash end
②ユーザ情報の取得
現状user_token
を取得できているのでscope
を設定できていれば情報にアクセスすることができます。といっても今回はユーザーのID情報を取得できればいいのでidentity.basic
のscopeだけあれば問題ないです。
ちなみにuser_token.scopes
を使用することでscopeの中身を確認できます。identity.basic
があればokです!
user_token.scopes => {"classic"=>["identity.basic", "identity.email", "identity.avatar", "identity.team"]}
scopeを確認したらusers.identityメソッドを使用してユーザ情報を取得します。
user_info = user_token.get('/api/users.identity').parsed
返却値の例としては下記のような見た目です。
{ "ok": true, "user": { "name": "Sonny Whether", "id": "U0G9QF9C6" }, "team": { "id": "T0G9PQBBK" } }
user_info.dig("user", "id")
でidを取得することができます。
user_id = user_info.dig("user", "id")
users.identityメソッドについてはこちら
users.identity Slack API Method
これまでのコード
module Users class OmniauthCallbacksController < Devise::OmniauthCallbacksController skip_before_action :authenticate_user!, only: %i[slack failure] def slack bot_token = request.env['omniauth.strategy'].access_token user_token = bot_token.user_token access_token = OmniAuth::Slack.build_access_token(ENV['SLACK_CLIENT_ID'], ENV['SLACK_CLIENT_SECRET'], bot_token.to_hash user_info = user_token.get('/api/users.identity').parsed user_id = user_info.dig("user", "id") end
③ブロックメッセージの準備
それでは実際にBolt Kitのメッセージを変数に代入しておきます。代入するメッセージは最初にお見せしたJSONをそのまま使用してみます。
[再掲]
{ "blocks": [ { "type": "header", "text": { "type": "plain_text", "text": "New request", "emoji": true } }, { "type": "section", "fields": [ { "type": "mrkdwn", "text": "*Type:*\nPaid Time Off" }, { "type": "mrkdwn", "text": "*Created by:*\n<example.com|Fred Enriquez>" } ] }, { "type": "section", "fields": [ { "type": "mrkdwn", "text": "*When:*\nAug 10 - Aug 13" }, { "type": "mrkdwn", "text": "*Type:*\nPaid time off" } ] }, { "type": "section", "fields": [ { "type": "mrkdwn", "text": "*Hours:*\n16.0 (2 days)" }, { "type": "mrkdwn", "text": "*Remaining balance:*\n32.0 hours (4 days)" } ] }, { "type": "section", "text": { "type": "mrkdwn", "text": "<https://example.com|View request>" } } ] }
しかし、ここで問題点がありまして、それがBolt KitのJSONが長すぎ問題です。これをコントローラに置いておいたらとんでもなくFat controllerになってしまいます。そのためBolt Kit用のmoduleを作成し、そこにメソッドとしてBolt KitのJSONを返すメソッドを定義しておきます。
SlackApiBlockKitをモジュールとして作成し、それを認証コントローラでincludeします。メッセージを返すメソッドはget_block_kit_msg
としましょう。
module SlackApiBlockKit def get_block_kit_msg message = '[ { "type": "header", "text": { "type": "plain_text", "text": "New request", "emoji": true } }, { "type": "section", "fields": [ { "type": "mrkdwn", "text": "*Type:*\nPaid Time Off" }, { "type": "mrkdwn", "text": "*Created by:*\n<example.com|Fred Enriquez>" } ] }, { "type": "section", "fields": [ { "type": "mrkdwn", "text": "*When:*\nAug 10 - Aug 13" }, { "type": "mrkdwn", "text": "*Type:*\nPaid time off" } ] }, { "type": "section", "fields": [ { "type": "mrkdwn", "text": "*Hours:*\n16.0 (2 days)" }, { "type": "mrkdwn", "text": "*Remaining balance:*\n32.0 hours (4 days)" } ] }, { "type": "section", "text": { "type": "mrkdwn", "text": "<https://example.com|View request>" } } ]' return ERB::Util.url_encode(message) end end
module Users class OmniauthCallbacksController < Devise::OmniauthCallbacksController skip_before_action :authenticate_user!, only: %i[slack failure] include SlackApiBlockKit
それではメソッドの中にメッセージを仕込みます。注意点としてはJSONの"blocks"の中の配列を使用してください。また、その配列を文字列として認識するために、``で囲いましょう。
message = '[ { "type": "header", "text": { "type": "plain_text", "text": "New request", "emoji": true } }, { "type": "section", "fields": [ { "type": "mrkdwn", "text": "*Type:*\nPaid Time Off" }, { "type": "mrkdwn", "text": "*Created by:*\n<example.com|Fred Enriquez>" } ] }, { "type": "section", "fields": [ { "type": "mrkdwn", "text": "*When:*\nAug 10 - Aug 13" }, { "type": "mrkdwn", "text": "*Type:*\nPaid time off" } ] }, { "type": "section", "fields": [ { "type": "mrkdwn", "text": "*Hours:*\n16.0 (2 days)" }, { "type": "mrkdwn", "text": "*Remaining balance:*\n32.0 hours (4 days)" } ] }, { "type": "section", "text": { "type": "mrkdwn", "text": "<https://example.com|View request>" } } ]'
APIを叩くためにencodeする必要があるため、このメッセージをencodeします。slackのencode方法に対応したERB::Util.url_encodeメソッドを選定しました。他のencode方法も試しましたがうまく動かなかったため、Block Kitをencodeする際はERB::Util.url_encodeを使用しましょう。
return ERB::Util.url_encode(message)
よってメッセージを取得するメソッドは以下のようになります。
def get_block_kit_msg message = '[ { "type": "header", "text": { "type": "plain_text", "text": "New request", "emoji": true } }, { "type": "section", "fields": [ { "type": "mrkdwn", "text": "*Type:*\nPaid Time Off" }, { "type": "mrkdwn", "text": "*Created by:*\n<example.com|Fred Enriquez>" } ] }, { "type": "section", "fields": [ { "type": "mrkdwn", "text": "*When:*\nAug 10 - Aug 13" }, { "type": "mrkdwn", "text": "*Type:*\nPaid time off" } ] }, { "type": "section", "fields": [ { "type": "mrkdwn", "text": "*Hours:*\n16.0 (2 days)" }, { "type": "mrkdwn", "text": "*Remaining balance:*\n32.0 hours (4 days)" } ] }, { "type": "section", "text": { "type": "mrkdwn", "text": "<https://example.com|View request>" } } ]' return ERB::Util.url_encode(message) end
これをコントローラ側で取り出します。
encoded_mgs = get_block_kit_msg
これまでのコード
module Users class OmniauthCallbacksController < Devise::OmniauthCallbacksController skip_before_action :authenticate_user!, only: %i[slack failure] include SlackApiBlockKit def slack bot_token = request.env['omniauth.strategy'].access_token user_token = bot_token.user_token access_token = OmniAuth::Slack.build_access_token(ENV['SLACK_CLIENT_ID'], ENV['SLACK_CLIENT_SECRET'], bot_token.to_hash user_info = user_token.get('/api/users.identity').parsed user_id = user_info.dig("user", "id") encoded_mgs = get_block_kit_msg end
module SlackApiBlockKit def get_block_kit_msg message = '[ { "type": "header", "text": { "type": "plain_text", "text": "New request", "emoji": true } }, { "type": "section", "fields": [ { "type": "mrkdwn", "text": "*Type:*\nPaid Time Off" }, { "type": "mrkdwn", "text": "*Created by:*\n<example.com|Fred Enriquez>" } ] }, { "type": "section", "fields": [ { "type": "mrkdwn", "text": "*When:*\nAug 10 - Aug 13" }, { "type": "mrkdwn", "text": "*Type:*\nPaid time off" } ] }, { "type": "section", "fields": [ { "type": "mrkdwn", "text": "*Hours:*\n16.0 (2 days)" }, { "type": "mrkdwn", "text": "*Remaining balance:*\n32.0 hours (4 days)" } ] }, { "type": "section", "text": { "type": "mrkdwn", "text": "<https://example.com|View request>" } } ]' return ERB::Util.url_encode(message) end end
④メッセージを投稿
最後にメッセージを投稿するメソッドとして、chat.postMessageメソッドを使用します。本来であればチャンネルに投稿するためのメソッドですが、userのIDを使用することでDMを送ることができます。
以下公式の内容です。
If you simply want your app's bot user to start a 1:1 conversation with another user in a workspace, provide the user's user ID as the channel value and a direct message conversation will be opened, if it hasn't already. Resultant messages and associated direct message objects will have a direct message ID you can use from that point forward, if you'd rather.
ここではaccess_tokenを使用するため、access_tokenにchat:writeのscopeがあることも確認してください。user_tokenと同様にaccess_token.scopesで確認できます。
access_token.scopes => {"classic"=>["chat:write"]}
それでは実際にメソッドを使用しAPI を叩きます。
access_token.post("api/chat.postMessage?channel=#{user_id}&blocks=#{encoded_mgs}").parsed
するとDMが実際に飛んできたかと思います。
chat.postMessageメソッドについてはこちら
chat.postMessage Slack API Method
最終コード
module Users class OmniauthCallbacksController < Devise::OmniauthCallbacksController skip_before_action :authenticate_user!, only: %i[slack failure] include SlackApiBlockKit def slack bot_token = request.env['omniauth.strategy'].access_token user_token = bot_token.user_token access_token = OmniAuth::Slack.build_access_token(ENV['SLACK_CLIENT_ID'], ENV['SLACK_CLIENT_SECRET'], bot_token.to_hash user_info = user_token.get('/api/users.identity').parsed user_id = user_info.dig("user", "id") encoded_mgs = get_block_kit_msg access_token.post("api/chat.postMessage?channel=#{user_id}&blocks=#{encoded_mgs}").parsed end
module SlackApiBlockKit def get_block_kit_msg message = '[ { "type": "header", "text": { "type": "plain_text", "text": "New request", "emoji": true } }, { "type": "section", "fields": [ { "type": "mrkdwn", "text": "*Type:*\nPaid Time Off" }, { "type": "mrkdwn", "text": "*Created by:*\n<example.com|Fred Enriquez>" } ] }, { "type": "section", "fields": [ { "type": "mrkdwn", "text": "*When:*\nAug 10 - Aug 13" }, { "type": "mrkdwn", "text": "*Type:*\nPaid time off" } ] }, { "type": "section", "fields": [ { "type": "mrkdwn", "text": "*Hours:*\n16.0 (2 days)" }, { "type": "mrkdwn", "text": "*Remaining balance:*\n32.0 hours (4 days)" } ] }, { "type": "section", "text": { "type": "mrkdwn", "text": "<https://example.com|View request>" } } ]' return ERB::Util.url_encode(message) end end
注意点としては、Block Kitのメッセージは必ずERB::Util.url_encodeメソッドを使用してください。ここでだいぶ時間がかかりました。一応公式にもSlackが使用しているencode方法が載っているのですが、実際にRubyでBlock Kitをアクセストークンから飛ばしている記事はなかったので注意が必要です。
【Gem】fog-aws
はじめに
こんにちは!大ちゃんの駆け出し技術ブログです。
ついにPFを昨日アップロードしたのですが、案の定複数の不具合が起きてしまいました。その一つにcarriwaveの画像が読み込まれないというものがありました。これはよくよく調べるとherokuではcarriwaveをそのまま使用できないという記事が複数あったので、自分の調査不足が原因でもあります。
解決策としてはfog-awsを使用して画像をAWS上にアップロードすることだそうですので、こちらで備忘録として残しておきます。まだAWSの登録をしていない方は登録を済ませてください。
AWSでS3の設定
まずはAWSの設定から。AWSはほとんど触ったことがないので疑問点が多いですが、一つ一つ調べていきます。
まずそもそもS3というのはなんなのかと思って調べてみました。S3は「データを保存したり取得できるオブジェクトストレージ」というのが公式の記述で書かれています。データをAWS上に保存するという比較的シンプルでよく使うようなサービスなのかなと思います。
それではAWSの設定を始めていきます。 「S3」のダッシュボード画面に遷移しバケットを作成をクリックしてください。
一般的な設定の画面ではバケット名に任意の名前と、リージョンにアジアパジフィック(東京)を選択します。
※ 画像のバケット名だと保存できません!バケットの命名規則で_
は使えなかったみたいですね、、、。
このバケットのブロックパブリックアクセス設定では以下のように項目を設定します。
これで「バケットを作成」をクリックしてください。これでバケットが作成されたと思いまうs。
次にS3のポリシーを編集していきます。作成したバケットの管理画面に遷移します。アクセス許可のタブを開き下にスクロールすると、バケットポリシーという画面があると思います。
編集ボタンをクリックすると下記画面が表示されます。
ここでポリシーを作成するのですが、このポリシーを作成する上で「ポリシージェネレーター」を使用します。
入力方法としては下記記事を参考にするといいと思います。
AWS のポリシーを楽に作成する方法(ポリシージェネレーター) - Qiita
ポリシーを無事に生成したら生成されたJSONをバケットポリシーにそのまま貼り付け保存します。
これでバケットポリシーの設定は完了です。基本的なAWSの設定は完了となります。
fog-aws
の導入
AWSの設定が長かったですが、いよいよ実際のアプリにS3を反映させていきます。
fog-aws
をインストールします。
※ 意外と依存関係で新しくインストールされるgemがかなりありました。
gem 'fog-aws' bundle install Fetching formatador 0.2.5 Installing formatador 0.2.5 Fetching mime-types-data 3.2021.0225 Installing mime-types-data 3.2021.0225 Fetching mime-types 3.3.1 Installing mime-types 3.3.1 Fetching fog-core 2.2.4 Installing fog-core 2.2.4 Using multi_json 1.15.0 Fetching fog-json 1.2.0 Installing fog-json 1.2.0 Fetching fog-xml 0.1.3 Installing fog-xml 0.1.3 Fetching ipaddress 0.8.3 Installing ipaddress 0.8.3 Fetching fog-aws 3.10.0 Installing fog-aws 3.10.0
carriwaveの導入時に設定したapp/uploaders/image_uploader.rb
を編集します。storage :file
をコメントアウトしstorage :fog
をコメントアウトから外します。
# app/uploaders/image_uploader.rb # Choose what kind of storage to use for this uploader: # storage :file storage :fog
config/initializers/carrierwave.rb
を新規で作成します。ファイルを以下のように記述します。
require 'carrierwave/storage/abstract' require 'carrierwave/storage/file' require 'carrierwave/storage/fog' CarrierWave.configure do |config| config.storage :fog config.fog_provider = 'fog/aws' config.fog_directory = 'prof-chan-carriwave' # AWSで作成したバケット名 config.fog_credentials = { provider: 'AWS', aws_access_key_id: ENV['AWS_ACCESS_KEY_ID'], aws_secret_access_key: ENV['AWS_SECRET_ACCESS_KEY'], region: ENV['AWS_DEFAULT_REGION'], path_style: true } end
環境変数の設定には定番のgemであるdotenvを使用します。導入はすごく簡単ですので自分の下記記事を参照してください。
.envファイルに書き込むものは以下の3つです。これはバケット作成時にダウンロードしたcsvファイルから参照できます。
AWS_ACCESS_KEY_ID='AWSで作成したAccess key ID' AWS_SECRET_ACCESS_KEY='AWSで作成したSecret access key' AWS_DEFAULT_REGION='ap-northeast-1'
この環境変数をherokuでも設定します。
ブラウザから直接入力してもコンソールから直接入力しても大丈夫です。
自分はherokuから設定しました。環境変数の設定ですので画面はお見せできないですが、下記の項目から環境変数を設定できます。
じつはこれで設定は完了しています。実際にcarriwaveに画像を投稿すると作成したバケットの配下に画像が格納されているのがわかるかと思います。
AWSの設定の複雑さに比べるとこちらは断然導入がしやすかったですね。gem最高。
参考記事
ams_lazy_relationships
はじめに
Active Model Serializersというgemを使用していたんですけど、N + 1問題が多く生じるという問題を抱えています。mapメソッドを使用して自分でロジックを組んで解消していくような実装が多く見受けられてたのですが、自分のポートフォリオはモデル数が15を超えているため、あまり自分でロジックを組んで時間をかけたくありませんでした。gemでどうにかしてよしなにやってくれないかと探していたところ、下記のgemを発見しました。
ams_lazy_relationships
star数自体はそこまで多くないですが、自作で新しいロジックを組むことなく各シリアライザーを編集するだけで済むらしく、興味本位で使ってみました。すると一瞬でN + 1問題が解決できて驚き。仕組みは全く分かりませんがありがたく使わせてもらいました。
そこで勉強しようとQiitaで探そうと思ったところ、、、
なんとわずか1記事のみ。しかも、この方の記事はams_lazy_relationshipsについて触れているだけであって、gemについては書いていません。つまり0記事。これは記事を書くチャンスと思い本記事を書くこととしました。
導入が相変わらず長くすみません。記事を書いていきます。構成としては、まずgemのドキュメントを訳しつつ概要を把握し、実際に導入する中で使用方法を明確にさせていきます。
ドキュメントの訳
Eliminates N+1 queries problem in Active Model Serializers gem thanks to batch loading provided by a great BatchLoader gem.
Active Model Serializers gemのN+1クエリの問題を解消するgemということで、Active Model Serializers専用のgemであることがわかります。BatchLoaderは依存gemですね。こちらもN + 1問題を解決するために使われていますが、Active Model Serializerのためのgemというわけではなく、より広義のgemみたいですね。ちなみにstar数は800を超えているのでかなり使われているgemかと思います。
次にams_lazy_relationshipsの特徴についてです。これは公式gemに載っていなかったので、別記事を参照しています。
- バッチローディングを使用して、複雑なオブジェクトトレスをシリアライズする際のN+1クエリを防止する。
- 過剰なデータをロードしない(Railsが不適切に使用された場合に発生するような)
- すべてのリレーションシップがActiveRecordモデルではない場合でも、N+1を取り除くことができる(例えば、一部のレコードはMySQL DBに、他のモデルはCassandraに保存されている場合など
Use lazy relationships to eliminate N+1 queries in your Rails apps
全て詳しく説明すると長文になるため、ここではかなりよしなにN + 1問題を解決してくれるgemという理解だけに留めておいてください。正直このgemはかなりよしなにやってくれるgemなので仕組みはほとんど書かれていなかったので、、
次に具体的な使用方法についてですが、これが恐ろしく簡単に記載されています。下記のようにserializerでアソシエーションをしている場合、N + 1問題が発生します。
class BlogPostSerializer < BaseSerializer has_many :comments end
しかし、上記の記述を以下のように変更するだけでN + 1問題は解決できるとのことです。
class BlogPostSerializer < BaseSerializer lazy_has_many :comments end
よしなにやりすぎてませんか??笑ただ接頭辞にlazyを付け足すだけなんて、、
もう少し詳しく見ていきます。公式のgemでは以下のように複数の使用例が紹介されていましたのでそおれぞれ見ていきます。
class UserSerializer < BaseSerializer # Short version - preloads a specified ActiveRecord relationship by default lazy_has_many :blog_posts # Works same as the previous one, but the loader option is specified explicitly lazy_has_many :blog_posts, serializer: BlogPostSerializer, loader: AmsLazyRelationships::Loaders::Association.new("User", :blog_posts) # The previous one is a shorthand for the following lines: lazy_relationship :blog_posts, loader: AmsLazyRelationships::Loaders::Association.new("User", :blog_posts) has_many :blog_posts, serializer: BlogPostSerializer do |serializer| # non-proc custom finder will work as well, but it can produce redundant sql # queries, please see [Example 2: Modifying the relationship before rendering](#example-2-modifying-the-relationship-before-rendering) -> { serializer.lazy_blog_posts } end lazy_has_one :poro_model, loader: AmsLazyRelationships::Loaders::Direct.new(:poro_model) { |object| PoroModel.new(object) } lazy_belongs_to :account, loader: AmsLazyRelationships::Loaders::SimpleBelongsTo.new("Account") lazy_has_many :comment, loader: AmsLazyRelationships::Loaders::SimpleHasMany.new("Comment", foreign_key: :user_id) end
まず上から1つ目と2つ目ですがどちらも同じ意味だそうです。
# Short version - preloads a specified ActiveRecord relationship by default lazy_has_many :blog_posts # Works same as the previous one, but the loader option is specified explicitly lazy_has_many :blog_posts, serializer: BlogPostSerializer, loader: AmsLazyRelationships::Loaders::Association.new("User", :blog_posts)
上の方は全てよしなにやっている場合で、下はよしなにやってくれるが一応設定を明示していますね。serializer: BlogPostSerializer
でどのserializerを使用するか明示しています。目新しい部分はloaderオプションの箇所であるloader: AmsLazyRelationships::Loaders::Association.new("User", :blog_posts)
です。
複数の使用例で記載したコードを見るにams_lazy_relationshipsには複数のLoadersクラスがあるようです。
AmsLazyRelationships::Loaders::Association
ActiveRecordのアソシエーション(has_one/has_many/has_many-through/belongs_to)をバッチでロードする。特にloaderオプションで明示がない場合、こちらのオプションがデフォルトとして使用される。
AmsLazyRelationships::Loaders::SimpleBelongsTo
シリアル化されたオブジェクトの外部キーを使用して、ActiveRecordモデルを一括してロードする。SimpleBelongsTo
とあるようにbelongs_to用に使われる。
AmsLazyRelationships::Loaders::SimpleHasMany
シリアル化されたオブジェクトの外部キーを使用して、ActiveRecordモデルを一括してロードする。SimpleHasMany
とあるようにhas_many用に使われる。
AmsLazyRelationships::Loaders::Direct
必要な時にだけよしなに実行されるらしい。あまり詳しくは記載なし。
loadersオプションはデフォルトでAssociation
ですが、基本的にこれだけで完結するのかなと。ただ、オプションの明示が必要な時もあります。例えばシリアライザーの名前と実際のモデル名が異なる場合です。下記の例の場合、シリアライザーの名前はPostであるが、実際のモデルはBlogPostモデルですので、loaderオプションで明示してあげます。
class PostSerializer < BaseSerializer lazy_has_many :comments, serializer: CommentSerializer, loader: AmsLazyRelationships::Loaders::Association.new( "BlogPost", :comments ) end
導入手順
公式の流れに沿って、実際に導入していきます。
# Gemfile gem "ams_lazy_relationships"
$ bundle Fetching batch-loader 2.0.1 Installing batch-loader 2.0.1 Fetching ams_lazy_relationships 0.3.2 Installing ams_lazy_relationships 0.3.2
各自シリアライザーのベースとなるシリアライザーにinclude AmsLazyRelationships::Core
を記載します。
class ApplicationSerializer < ActiveModel::Serializer include AmsLazyRelationships::Core end
また、注意点として、上述したようにこのgemはBatchLoaderを多用しています。gemの仕様上HTTPリクエストの間にバッチローダのキャッシュをクリアすることを推奨しています。方法はconfig配下のapplication.rbに以下を記載します。
# application.rb config.middleware.use BatchLoader::Middleware
準備はこれだけです。
では導入前のN + 1問題が起きている状態を見てみます。修正前のシリアライザーの状態です。
# app/serializers/question_block_serializer.rb class QuestionBlockSerializer < ApplicationSerializer attributes :id, :title has_many :question_items, serializer: QuestionItemSerializer has_many :question_block_likes has_many :users, through: :question_block_likes end
質問ブロックみたいなモデルがあってそこに質問項目であるquestion_items
がhas_manyの関係で表示、また、質問ブロックに対して言い値機能を実装しているので、users
をhas_many :users, through
の関係でアソシエーションが結ばれています。
ここで実際にリクエストを送ります。N + 1問題検出用のgemであるbulletを使用していますので、bulletのalertがターミナル上に表示されます。
GET /api/v1/question_blocks USE eager loading detected QuestionBlock => [:question_items] Add to your query: .includes([:question_items]) Call stack GET /api/v1/question_blocks USE eager loading detected QuestionBlock => [:question_block_likes] Add to your query: .includes([:question_block_likes]) Call stack GET /api/v1/question_blocks USE eager loading detected QuestionBlock => [:users] Add to your query: .includes([:users]) Call stack GET /api/v1/question_blocks USE eager loading detected QuestionBlockLike => [:user] Add to your query: .includes([:user]) Call stack
これだけで4つものN + 1問題が検出されました。長すぎるのでここには少ししか載せませんが、ターミナル上にはたくさんのSQL文が飛んでいます。
User Load (1.0ms) SELECT `users`.* FROM `users` INNER JOIN `question_block_likes` ON `users`.`id` = `question_block_likes`.`user_id` WHERE `question_block_likes`.`question_block_id` = 66 ↳ app/controllers/api/v1/question_blocks_controller.rb:12:in `index' QuestionItem Load (0.9ms) SELECT `question_items`.* FROM `question_items` WHERE `question_items`.`question_block_id` = 67 ↳ app/controllers/api/v1/question_blocks_controller.rb:12:in `index' QuestionBlockLike Load (1.5ms) SELECT `question_block_likes`.* FROM `question_block_likes` WHERE `question_block_likes`.`question_block_id` = 67 ↳ app/controllers/api/v1/question_blocks_controller.rb:12:in `index' ・ ・ ・
ここでlazy_has_manyを使用してみます。面倒なので全てにつけてみましょう。
# app/serializers/question_block_serializer.rb class QuestionBlockSerializer < ApplicationSerializer attributes :id, :title, :owing_user lazy_has_many :question_items, serializer: QuestionItemSerializer belongs_to :profile_block, serializer: ProfileBlockSerializer lazy_has_many :question_block_likes lazy_has_many :users, through: :question_block_likes end
実際にQuestionBlockについて発行されたSQLは下記の部分のみでした。
SQL (191.2ms) SELECT question_blocks
.id
AS t0_r0, question_blocks
.title
AS t0_r1, question_blocks
.profile_block_id
AS t0_r2, question_blocks
.created_at
AS t0_r3, question_blocks
.updated_at
AS t0_r4, profile_blocks
.id
AS t1_r0, profile_blocks
.user_id
AS t1_r1, profile_blocks
.created_at
AS t1_r2, profile_blocks
.updated_at
AS t1_r3, users
.id
AS t2_r0, users
.provider
AS t2_r1, users
.uid
AS t2_r2, users
.encrypted_password
AS t2_r3, users
.reset_password_token
AS t2_r4, users
.reset_password_sent_at
AS t2_r5, users
.allow_password_change
AS t2_r6, users
.remember_created_at
AS t2_r7, users
.name
AS t2_r8, users
.image
AS t2_r9, users
.email
AS t2_r10, users
.role
AS t2_r11, users
.team_id
AS t2_r12, users
.created_at
AS t2_r13, users
.updated_at
AS t2_r14, teams
.id
AS t3_r0, teams
.name
AS t3_r1, teams
.workspace_id
AS t3_r2, teams
.image
AS t3_r3, teams
.created_at
AS t3_r4, teams
.updated_at
AS t3_r5 FROM question_blocks
LEFT OUTER JOIN profile_blocks
ON profile_blocks
.id
= question_blocks
.profile_block_id
LEFT OUTER JOIN users
ON users
.id
= profile_blocks
.user_id
LEFT OUTER JOIN teams
ON teams
.id
= users
.team_id
WHERE teams
.workspace_id
= '852021469278993732'
bulletのalertも表示されていません。本当に簡単に導入できてびっくりしました。。
しかし、実際にはこのまま脳死で使用していくのはブラックボックスすぎるのでさらに理解を深められたら後述で本gemについて加筆していきたいと思います。
参考記事
Use lazy relationships to eliminate N+1 queries in your Rails apps