- 投稿日:2021-12-03T21:14:30+09:00
コードの見方・関わり方が少し変わった一年でした、という話
グロービスにバックエンドエンジニアとして参画している @ymstshinichiro です。 僕は今年の5月から本格的にGLOPLA LMSの開発にジョインしました。 本記事では、そこから今日までの7ヶ月を通して自分に起こった変化について書いてみたいと思います。 参画以前は 僕は約10年飲食の業界にいましたが、約4年前にキャリアチェンジで SIer > Web業界(社員) > Web業界(フリーランス) という流れで現在に至っており、新卒できちんと先輩から教わったとか、スクールで先生についてもらったみたいなことがありませんでした。 なので、基本的に独学でプログラミングを学び現場のコードを見ながら/直しながら覚えていくという感じでやってきており、 「こうあるべき」みたいなのは書籍を読んで得たものが基本 とはいえ現場では現場のルールや状況がある 結果、その時々で対応していくので、背骨のある設計/実装、綺麗で汎用性の高いコード みたいなのがあまり身についてこなかった という実感がありました。 一方で、急なトラブル対応やスピードを求められる仕様変更、古代に練り込まれたコードのメンテ、色んな意味ですごいSQLなど、アンチパターンや決して教科書通りには行かない現場でどう対応していくかみたいなことに関しては多少経験が積めたかなと思っています。 今のチームでは GLOPLA LMSは、プロダクトの試作段階から経験豊富なメンバーが携わっており、開発当初から下記のような設計方針が貫かれています。 要件を定義する段階でWHYを突き詰める. 無駄なものをできる限り作らない Railsの持つ柔軟なコード表現を積極的に活用していく gemも活用&常に新しいものを使う仕組みを回す 冗長になってもいいのでテストケースのコンテキストはできるだけ抜け漏れなく実装する その上で、shared_example等を活用し読みやすく再利用性の高いテストコードを実装する 時間が経過しても、実装の意図が正確に伝わる表現にこだわる 端的にまとめると「場当たり的な実装ではなく、長期にメンテナンスしていけるための設計/実装を意識したコード」を書くということになります。 そのような現場に入って、自分の意識や行動がどう変わったかを書いていきたいと思います。 全員が維持できるコードを書く 「多少保守性を犠牲にしてもリリースを優先する」というコードを書くことは許されません。(余程の事情がない限り) わかっている情報の中で可能な限り未来を予想し、メンテナンスのコストも含め最もベターな選択肢はどこか?というwhyとhowを考えた上で実装します。 これはユニットテストに関しても同じ(というかむしろテストの精度を上げていくことが本題)で、なぜこのテストケースが存在すべきか誰が見てもわかる状態を目指すべきです。 この結果、テストのコンテキストを分割する、コミットを分ける、コミットメッセージと実装を矛盾なく一致させるなど、コード(と、PR)がどう見られるか?どうやったらレビューしやすいか?を意識するようになりました。 こう書くと当たり前のようですが、以前の僕は特にコミットの仕方に関しては本当にできていなかったですね。。。所属した現場でのルールや意識に影響されたのと、仕事以外でのOSS活動などをやってこなかったというのが大きいと思っています。 外のリポジトリに興味を持つ 以前に在籍していたいくつかの現場では、様々な理由によりあまり積極的にgemを使うということをしていませんでしたが、GLOPLA LMSではそういったことはありません。それが必要であれば積極的に使っていこうという姿勢です。 となると当然ですが、使おうとするgemが本当に我々の望む機能を有しているのか、今後の保守に耐えうることができそうかということを見極める必要が出てきます。 そのために何をするか。 これも当たり前のことなんですが、公開されているリポジトリのソースを読みに行きます。 この時、自分にある変化が起こっていることに気づきました。 以前の僕はこういう外のソース読みに行っても「んーなんかすごい複雑で読むの大変やな...」というのが先に来ていたんですが、"どう読まれるか"を意識してコードを書くようになると「どこから読んだら分かりやすそうか」の勘が働くようになるので、以前よりも全然スラスラと読めるようになりました。 また、「おっ この書き方は分かりやすくていいな」とか、現場でコードを書くだけでは中々気づけなかったテクニックを発見したりと、自分で作る楽しさだけでなく誰かが作ったものに触れる面白さというのがプログラミングにはあるんだなと、今更ながらに気づくことができました。 ちなみに、GLOPLA LMS(というか、Globis全体)の開発メンバーは日常的にOSSコントリビュートしている方が結構多いです。 これも結構珍しい現場なのでは?と個人的には思っています。 そして自分も作る そんなこんなで開発にも慣れてきた頃、チームで使っているGithub(Zenhub)のissueチケット作成が面倒だねーという話になりました。(具体的には、全員で編集したMarkdownのファイルをベースに、チケットを手動で何枚も作り直すという手動コピペが常態化していた) ちょうどGithubのAPI触ってみたいな〜と思っていたところだったので、Markdownをパース → JSONで固めてGithub APIに投げる、という簡単なgemを作って、初めて公開してみました。 Copyist https://rubygems.org/gems/copyist これまで書いてきたようなメンテナブルなコードになっているか?と言われるとちょっと怪しいんですが笑、「このツールをどこかの誰かが使うかもしれない」と思うと、少なくとも致命的なバグは起こさないようにとか、せめて説明ぐらいはわかるように書かなきゃな、とかコードを書く以外にも色んな手間ひまがあってライブラリは作られているんだなという実感があり、改めて世のOSS開発者の皆さんに感謝せねばならんなという気持ちになりました。 また一方で、作って公開することは決して特別なことではなく少し気合を入れれば自分にもできることなんだから、もっと世に貢献するようなコード・プロダクトを作って、これからも楽しみながらエンジニア生活を送って行きたいな〜と思った2021年の師走でした。 最後に GLOPLA LMSをはじめGlobisでは一緒にプロダクト開発をするエンジニアを募集しています。 先述の通り、OSSコントリビュートを積極的に行っていますし、某有名Rubyエンジニアの方が開発に参画していたり、RubyWorld conferenceのスポンサード等、エンジニアの活動を支援する組織文化があります。 また、リモートワークや開発環境など働き方の面でも非常に過ごしやすいです。 なんといっても「プロダクトを綺麗に作っていきたい」という思いをお持ちの方、ここにはそれを受け入れる土壌があります。 ぜひ一度コンタクトいただけると嬉しいです! 自分語りな記事を最後までお読みいただきありがとうございましたmm 2022年も、皆様にとって実りと学びのある一年になりますように〜
- 投稿日:2021-12-03T20:00:00+09:00
マンダラートを作りたい #1
マンダラートとはアイデア出しの手法の一つ。 こんな漢字で、連想してアイデアを出してく。 最初から、こんな漢字のマス目を用意して、投稿表示するのが意外と難しい。 投稿ページ、投稿一覧表示ページ、投稿編集ページを合体させたいのだが、それが意外と難しい。(大事なことなので2回言った) class MandalartsController < ApplicationController def index @mandalarts = Mandalart.all new end def new @mandalart = Mandalart.new end def create @mandalart = Mandalart.new(mandalart_params) if @mandalart.save redirect_to mandalarts_path else render 'new' end end index.html.erb <table class="mandalarts"> <% 3.times do %> <tr> <% 3.times do %> <td> <% if @mandalarts %> <%= render "mandalarts/form", %> <% end %> <%= render "mandalarts/form"%> </td> <% end %> </tr> <% end %> </table> mandalarts/_form.html.erb <%= form_with model: @mandalart do |f|%> <% if @mandalart %> <%= f.text_area :text ,value: @mandalart.text%> <% else %> <%= f.text_area :text %> <% end %> <%= f.submit%> <% end %> ひとまずこんな感じ。 <%= f.text_area :text ,value: @mandalart.text%> とすることで、 すでにあるオブジェクトのテキストが、フォームの中にある状態にはなったが、submitを押すと、また新しいオブジェクトが生成されて、 フォームの中にあるテキストは、変わっていない。 編集のフォームをindex.html.erbに表示したい! 問題点は、form_withの性質上modelオプションの引数のオブジェクトが空なら、newアクションを呼び出してしまう。 <%if @mandalart == nil %> <%= @mandalart.text = "" %> <% end %> <%= form_with model: @mandalart do |f|%> とかにすれば、editアクションが呼び出されて、 マンダラートができるのではないか。 一旦諦めて、投稿フォームと、編集フォームを別々にすることに mandalarts_controller.rb class MandalartsController < ApplicationController def index # 最初から配列の要素を9つ入れとく # 配列の要素の中身があれば、何もしない # 配列の要素の中身があれば、""の要素を入れとく @mandalarts = Mandalart.all if @mandalarts == [] @mandalarts = [] 9.times do |mandalart| mandalart = Mandalart.create(text: '') @mandalarts << mandalart end end end def new @mandalart = Mandalart.new end def create @mandalart = Mandalart.new(mandalart_params) if @mandalart.save redirect_to mandalarts_path else render 'new' end end def edit @mandalart = Mandalart.find(params[:id]) end def update @mandalart = Mandalart.find(params[:id]) if @mandalart.update(mandalart_params) redirect_to mandalarts_path else render 'edit' end end private def mandalart_params params.require(:mandalart).permit(:text) end end mandalarts/index.html.erb <table class="mandalart-table"> <tr> <% @mandalarts[0..2].each do |mandalart| %> <td> <div class="mandalart-text"> <%= mandalart.text%> </div> <%= link_to "記入", edit_mandalart_path(mandalart.id)%> </td> <% end %> </tr> <tr> <% @mandalarts[3..5].each do |mandalart| %> <td> <div class="mandalart-text"> <%= mandalart.text%> </div> <%= link_to "記入", edit_mandalart_path(mandalart.id)%> </td> <% end %> </tr> <tr> <% @mandalarts[6..8].each do |mandalart| %> <td> <div class="mandalart-text"> <%= mandalart.text%> </div> <%= link_to "記入", edit_mandalart_path(mandalart.id)%> </td> <% end %> </tr> </table> こうすることで3×3のマンダラートが一応完成した。 DRYとはいい難いが、今の自分の技術でマス目状にするにはこうするしかなかった。 def index # 最初から配列の要素を9つ入れとく # 配列の要素の中身があれば、何もしない # 配列の要素の中身があれば、""の要素を入れとく @mandalarts = Mandalart.all if @mandalarts == [] @mandalarts = [] 9.times do |mandalart| mandalart = Mandalart.create(text: '') @mandalarts << mandalart end end end コメントにもあるが、最初に、9個要素がある配列を用意しないと、 viewでエラーが起きるので、9個要素がある配列を用意する処理を書いた。 (この処理は切り出してもいいかもしれない) if @mandalarts == [] とすることで、すでに配列の中身があれば、9.timesとかいう処理は実行されない。 常に3×3のマンダラートを表示させることに成功 次回はこのテストを書いていくことにする。
- 投稿日:2021-12-03T16:51:46+09:00
Railsで日時を取得したいのに、2000年1月1日ばかり保存されてしまう件について
はじめに 現在、Ruby on Railsにて睡眠を記録するアプリを作成しています。form_withを使用して日時を記録する際に、登録したデータの日付が2000年1月1日としか保存されませんでした。非常に単純な原因でしたので、備忘録として投稿します。 開発環境 DBにはMySQLを使用しています。 Mac OS Big Sur 11.6 VSCode Ruby 3.0.2p107 Rails 6.1.4.1 MySQL 8.0.27 for macos11.6 on arm64 (Homebrew) 原因 migrationファイルでDateTime型とすべきところをTime型にしていた。 MySQLについて調べるべきなのに、Rubyのことを調べていた。 背後要因 日付だけでなく時刻も扱える Date のサブクラスです。 DateTime は deprecated とされているため、 Timeを使うことを推奨します。 Ruby 3.0.0 リファレンスマニュアル class DateTime(要約) RubyのTime型について調べたときに、リファレンスを見て「なるほど、Ruby3.0以降はDateTimeは非推奨なのか。ならばmigrationを作るときもTime型を使えばいいのか!」と安易な考えに至ってしまいました。 やりたかったこと やりたかったことは、「フォームを利用して、就寝した時間と起床した時間をDBに保存できるかを確かめるための試験的な機能を作る」ことでした。 登録画面で就寝および起床時間を選択 登録ボタンを押したらデータをDBに保存 正常に保存できたら睡眠データの一覧ページにリダイレクト このような機能を目指して作成していました。 migration すでに生成してあるUserモデルと関連を持たせています。UserとSleepLogは、1対多の関係になります。 ***_create_sleep_logs.rb class CreateSleepLogs < ActiveRecord::Migration[6.1] def change create_table :sleep_logs do |t| t.references :user, foreign_key: true t.time :sleep_at t.time :wake_at t.timestamps end add_index :sleep_logs, %i[user_id created_at] end end なお、ここで問題の原因となったのがsleep_atカラムとwake_atカラムです。本来はここでdatetime型とすべきところを、time型としてしまいました。 routes ルーティングはresourcesを利用してindex以外のアクションを生成しています。 routes.rb resources :sleep_logs, except: :index これでsleep_logs_pathにPOSTすればsleep_logs_controllerのcreateアクションが利用できるようになりました。 $ rails routes | grep sleep_logs sleep_logs POST /sleep_logs(.:format) sleep_logs#create controller データを正常に保存できれば、ユーザーの個別ページで睡眠データ一覧を表示するようにしています。 sleep_logs_controller.rb class SleepLogsController < ApplicationController def new end def create sleep_log = current_user.sleep_logs.build(sleep_log_params) if sleep_log.save flash.now[:success] = "記録が保存されました" redirect_to @current_user else flash[:invalid] = "エラーが発生しました" render 'new' end end (省略) private def sleep_log_params params.require(:sleep_log).permit(:sleep_at, :wake_at) end end view 登録用フォームのviewです。form_withのtime_selelctを利用して、時間を選択できるようにしています。とりあえず試験的なデータを保存できればいいのでdefaultオプションを設定しています。 new.html.erb <%= form_with(url: sleep_logs_path, scope: :sleep_log, local: true) do |f| %> <%= f.label :sleep_at, "就寝時間" %> <%= f.time_select :sleep_at, default: Time.zone.now - 8.hours %> <%= f.label :wake_at, "起床時間" %> <%= f.time_select :wake_at, default: Time.zone.now %> <%= f.submit "記録する" %> <% end %> 特にcssなどは当てていないため、chrome上ではこのように表示されます。 睡眠データ一覧ページのviewについては省略させていただきます。 実際に登録してみる とりあえず準備が整ったので、睡眠データを保存してみます。 登録前 データが1件もないため、メッセージが表示されています。 登録後 登録したデータ自体のid、関連付けしてあるuserのuser_id、日付、就寝時間、起床時間の順に表示されています。 投稿内容を編集している現在(2021/12/03)の日付を表示したいのですが、01/02となってしまっています。 どんな日付が入っているのか? rails consoleを利用して、睡眠データの中身を確認してみます。sleep_atとwake_atは2000年の1月、timestamp(created_atとupdated_at)は正しく時間が表示されています。 [#<SleepLog:0x000000012ef4dc90 id: 1, user_id: 1, sleep_at: Sun, 02 Jan 2000 07:50:00.000000000 JST +09:00, wake_at: Sat, 01 Jan 2000 15:50:00.000000000 JST +09:00, created_at: Fri, 03 Dec 2021 15:50:41.277532000 JST +09:00, updated_at: Fri, 03 Dec 2021 15:50:41.277532000 JST +09:00>] paramsの内容を調べてみる 原因を探るため、まずは「paramsに正しいデータが保存されているか」を調べてみることにしました。controllerにraiseを追加して、わざと例外を発生させてみます。 sleep_logs_controller.rb def create sleep_log = current_user.sleep_logs.build(sleep_log_params) raise if sleep_log.save flash.now[:success] = "記録が保存されました" redirect_to @current_user else flash[:invalid] = "エラーが発生しました" render 'new' end end paramsの中身は...? このようになっていました。どうやらparams[:sleep_log]内には、sleep_atとwake_atそれぞれの年、月、日、時、分が格納されているようです。年には2021、月には12, 日には3が入っています。paramsには問題なさそうです。 問題の解決 いろいろと検索してみた結果、「DBの型がおかしいのかもしれない」と勘付きました。RubyのTime型では日付も扱うことができますが、MySQLのTime型では時間を取り扱うだけで日付は保存できないようですね… migrationの修正 sleep_atをwake_atをdatetime型に変換し、rails db:migrate:resetを実行します。 ***_create_sleep_logs.rb t.datetime :sleep_at t.datetime :wake_at 再度、登録してみる フォームを利用して再度データを登録してみます。 DateTime型に変更した結果、日付も正しく保存されるようになりました!! 最後に 問題が発生したときは、何が原因かを探る前に「何を取り扱っているのか」を考えるべきだと感じました。今回で言えば、MySQLのTime型とDateTime型の違いについて調べればすぐに判明したのに、Rubyのことばかり調べて数時間を無駄にしてしまったからです。 これからも何度も壁にぶつかると思いますが、がんばって乗り越えて行こうと思います。
- 投稿日:2021-12-03T13:51:32+09:00
【技育展2021】本当に"はじめてのアプトプット"をしたら意外にも優秀賞をもらえた
技育展とは? 株式会社サポーターズが開催する、学生エンジニアのアウトプットを展示するイベントです。 アウトプット作品を作る+それをプレゼンすることがイベントの主な内容です。 私は2021年開催の回に参加しました。 チームは高校時代の同級生の友人と2人でした。 何を作ったの? 失敗ドットコムというWebサイトを作成したました。 このサイトは、みんなの失敗談を知識として集積して、他の人の挑戦に役立てようというコンセプトから生まれました。 主な機能は、自分の失敗談を投稿したり、他人の失敗談を閲覧することです。 例えば、こんな投稿があったりします。 審査結果は? 以下のようなルールで入賞者が選出されます。 最優秀賞(1作品) 30万円/テーマ 優秀賞(1作品) 10万円/テーマ 「はじめてのアウトプット」は優秀賞のみ (賞金3万円×12作品)となります 参加賞 5000円(登壇者全員) 私が参加したのは「はじめてのアウトプット」というテーマのため、優秀賞しか存在しませんでした。 たしか、「はじめてのアウトプット」は60チームぐらいあったため、全体の1/5が優秀賞を貰えます。 そして… 私のチームは優秀賞をいただくことができました。(でも、よく考えれば1/5ってだいぶ多い) 本当にはじめてのアウトプットだったのでかなり嬉しかったです。 ここから先ではなぜ優秀賞を貰えたのかを振り返りたいと思います。 優秀賞を取るために工夫したことは? 開発フェーズ 実は何もありません。 なぜなら、エントリー時には既にサービスが完成していたからです。 (エントリー前の開発時にどのような工夫を行ったのかは、後で述べています) ちなみに、技育展の応募資格は下記のようになっており、既成作品を提出することはルール違反ではありません。 ・学生が作成した作品であること(学校種別、学年不問) ・新規作成or既成作品かは問いません ・個人制作orチーム制作いずれでも可 プレゼンフェーズ めちゃくちゃ力を入れました。 なぜなら、自分自身が「はじめてのアウトプット」の中でも技術レベルが相当低い方であることは容易に想像がついていたため、プレゼンで勝負するしかないと思ったからです。 下記のGoogle Slideがプレゼン本番で利用したスライドです。 技術レベルが低いからこそ、サービスの価値という側面で攻めた内容にしました。 (エントリー前に)開発するときに工夫したことは? 開発に着手する前から、失敗談を閲覧・投稿できるWebサイトというコンセプト自体は固まっていました。 そして、開発段階で工夫したことは、このコンセプトを元になるべくそれっぽいものを作るということでした。 「それっぽい」の定義にオリジナリティーがありすぎる気がするため補足すると、 『今の技術力の範囲で、「えwwこれ絶対素人が作ったサイトじゃんwwwぷぷぷwww」となることをなるべく避けられてる』ことを表すと思ってください。 なぜ「それっぽさ」を追求したかというと、素人が作った感が出ている時点で同志の駆け出しエンジニアでもない限り、そのサービスは利用する人はほとんどいないと思っていたからです。(超個人的意見です) 「それっぽさ」を追求して実際に行ったことの例は下記の通りです。 UIにこだわる。特にトップページ。 投稿内容に見出しや太字、イタリックなどを入れられるようにする。 画像を投稿できるようにする。 カテゴリを2階層にする。(ex.学習カテゴリの中の大学受験カテゴリ) 閲覧数をカウントし、人気の記事を表示する。 利用規約・プライバシーポリシー・お問い合わせページを作成する。 審査員からどんな評価をもらった? アプローチがおもしろい。 発想が面白かった。 内容も面白く、サービスとしてのクオリティも高かった。 考え方の視点が今までなくて確かにと納得できる考え方で,とてもサービスを使ってみたいと感じました。 投稿方法もmarkdownではなく普通の人でも使いやすそうでUIもかわいく人気が出そうと感じた。 コンセプトが明確だったため。 課題と解決するプロダクトの説明が明瞭完結。 テーマがおもしろく、そのテーマを実現するためのシステムもしっかりしていた。 アイデアが面白い 発想、動機が面白い。 利用層が安定しそうである。 アイデアが良い これも普及してほしい アイディアが良かった。 「失敗談」への着目が面白いなと思いました。 よく成功談が注目されるが、失敗という経験談に注目した点。 カテゴリ分けや分かりやすいUIがなおよい。 失敗を集めるっていうのがあまり思い浮かばなかった発想で驚きました。使ってみたいです。 失敗談を投稿するという発想がおもしろかったため。全体的な完成度も高く実用的だと思った。 失敗経験から失敗を防ぐための学びを得るという考えに共感し、UIもきれいで見やすかったため。 UIがとてもきれいでした。 制作背景とそこからの発想や視点、裏付けがしっかりしていて、それに沿ったコンテンツを実現させた技術力、全てすごいと感じました! コンセプトがすばらしいと思った。 成功のパターンはたくさんあるけれど、失敗のパターンは限られてくるので、失敗を学ぶことで成功すると以前聞いたことがあります。このサイトで失敗を共有すれば確かにみんなが成功に近づくだろうなと思いました。 成功談ではなく失敗談を集めることで誇張されることはなく、また役に立つという発想が大変面白かったです。投稿周りもしっかりしていて、これから投稿数が増えるのが楽しみです。 コンセプトが面白い サイトも見易く、またミッションにも強く共感したから。 失敗の体験談は話しづらいことでもあるので、こういうアプリがあるといいなと思った。 発想が面白かった。 共感の翁。 失敗談にフォーカスしている点に強く共感を抱いた。体験ベースのアウトプットという点に価値を感じた。 失敗が重要というコンセプトに共感する。沢山の人に使ってほしい。 失敗を見れるサイトは需要があると思う。ユーザーもある程度いて、使いやすいデザインになっていると思う。 作品の着眼点がすごくいいなと思いました 逆転の発想のようなアイデア リリース済みでユーザもいる点 プレゼンテーションでの説得力が高かった。サイトも単なるデモ以上に機能しており、ターゲットに対して明確にソリューションを打ち出せている。今後の機能開発に期待したい。 アイディア・UIに関する評価内容が多く、概ね狙い通りです。 最後に 失敗ドットコムで失敗談を投稿しませんか? おそらく、とても多くの人の参考になります。 投稿できることなんてないよという方は人気の記事だけでもご覧になってください!
- 投稿日:2021-12-03T13:24:30+09:00
【Ruby】trimしたいときはstripしよう
strip Rubyにはtrimメソッドがないので代わりにstripメソッドを使う。 文字列先頭と末尾の空白文字を全て取り除いた文字列を生成して返す。 空白文字の定義は" \t\r\n\f\v" 文字列右側からは"\0"も取り除くが、左側の"\0"は取り除かない。 pry(main)> p " s".strip => "s" # 文字と文字の間の空文字は取り除かない pry(main)> p " s f ".strip => "s f" pry(main)> p " s f\n".strip => "s f" pry(main)> p "\0 s f\0".strip => "\u0000 s f" 参考
- 投稿日:2021-12-03T10:51:28+09:00
【Rails】Twitter APIとLINE APIでジャニーズの私物を特定して、すぐに購入できるサービスを作ってみた!!! PF
はじめに PFでジャニーズの私物を特定して、すぐに購入できるサービスを作成したのでまとめます。 サービスの概要 ジャニーズファンは推しと同じものを日常生活やライブで身につけたいという心理があります。 twitte上にファンの人が私物を特定してツイートをするので、そのツイートをキャッチして、LINEで通知・購入できるサービスになっています。 なぜこのサービスを作成したか 私物の特定のツイートがされるとすぐファンが購入し、売り切れてしまうことがよくあります。 私自身、私物特定のツイートを発見し、サイトを検索してももう売り切れてしまっていたということが何回もあります。 他のファンよりも早く購入できるサービスがあればいいなと思い、このサービスを作成しました。 使用した機能 Twitter API LINE Messaging API Sorcery ER図 モデル Twiiter API 部分について Twiiter APIを使用して、特定のアカウントが私物に関するツイートをした際にツイート内容や画像等を取得します。 1.gemを取得します。 Gemfile # Twitter API用 gem 'twitter' # .env 管理用 gem 'dotenv-rails' ❶twitter gemを取得 gem 'twitter' Twitter APIを使用するので取得します。 ❷dotenv-rails gemを取得します。 gem 'dotenv-rails' 環境変数(公開させたくない情報)を管理することができるgemです。 自身のアプリケーションの直下に.envファイルを作成することで、パスワードなどネット上に公開させたくない情報を扱い、自動で読み込むことが可能です。 これを使用しない場合は、自分でシェルを使用して設定しなければならないです。 設定したアクセストークン等を.envファイルに設定しておく。 .env #Twitter CONSUMER_KEY="" CONSUMER_SECRET="" ACCESS_TOKEN="" ACCESS_SECRET="" git管理しているので、.gitignoreに.envを追加しておく。 gitignoreはGitで使われる特殊なファイル名で、このファイルに書かれたファイルは上から順に処理されて、Gitのトラッキングの対象外になります。 .gitignore .env 2.メソッドを定義 controllerのfat化を防ぐためにメソッドはarticle.rbに作成しました。 まずは、全体を載せます。 article.rb # Twitterclient認証 def twitter_client Twitter::REST::Client.new do |config| config.consumer_key = ENV["CONSUMER_KEY"] config.consumer_secret = ENV["CONSUMER_SECRET"] config.access_token = ENV["ACCESS_TOKEN"] config.access_token_secret = ENV["ACCESS_SECRET"] end end # 指定したidのアカウントのツイート検索 def search(id) @tweets = twitter_client.user_timeline(user_id: id, count: 1, exclude_replies: false, include_rts: false, contributor_details: false, result_type: "recent", locale: "ja", tweet_mode: "extended") end # 検索したツイートが私物関連のものであるか判断 def set_article(tag) @for_article_tweets = [] @tweets.each do |tweet| @for_article_tweets << tweet if tag.any?{|t| tweet.text.include?(t)} end end # 関係するメンバーを判断 def check_member(tweet_content) member_ids = [] a = ["#鈴木太郎"] b = ["#佐藤一郎"] c = ["#斉藤愛"] member_ids << 1 if a.any?{ |a| tweet_content.include?(a) } member_ids << 2 if b.any?{ |b| tweet_content.include?(b) } member_ids << 3 if c.any?{ |c| tweet_content.include?(c) } members << Member.find(member_ids) end # ツイートに含まれる画像を保存 def set_images(medias) image_urls = medias.map{ |h| h.media_url_https } # LINE送信用の画像url作成 imgae_url_for_line = image_urls.first # imgae_url_for_line = image_urls.last @imgae_url_for_line_small = "#{imgae_url_for_line}:small" # 画像をActiveStorageに保存 image_urls.each_with_index do |image_url, i| image_url_small = "#{image_url}:small" io = open(image_url_small) self.images.attach(io: io, filename: "image_#{i}") end end # GoodsFindというアカウントのツイート検索 def make_GoodsFind_article search("100000000000") tag = ["#findgoodsofstan"] set_article(tag) @for_article_tweets.each do |tweet| tweet_content = tweet.text.gsub(/[\r\n]/,"") self.tweet_url = tweet.url self.price = tweet_content.scan(/¥.+?-/).join(',') self.brand = tweet_content.scan(/(?<=\【).+?(?=\】)/).join(',') self.item = tweet_content.scan(/(?<=\】).+?(?=\¥)/).join(',') check_member(tweet_content) set_images(tweet.media) if save send_line(member_ids, tweet_url, @imgae_url_for_line_small) end end end 分解してみていきます。 ❶Twitter APIの認証をします。 article.rb # Twitterclient認証 def twitter_client Twitter::REST::Client.new do |config| config.consumer_key = ENV["CONSUMER_KEY"] config.consumer_secret = ENV["CONSUMER_SECRET"] config.access_token = ENV["ACCESS_TOKEN"] config.access_token_secret = ENV["ACCESS_SECRET"] end end ここの部分で、認証(gemのgithubを参考)させます。 あらかじめ1-❷で.envに設定したアクセストークン等を consumer key (API key) consumer secret (API secret) access token access secretに定義します。 ❷user_timelineメソッドを使用して、指定したidのアカウントのツイートを取得するインスタンスメソッドを作成します。 article.rb # 指定したidのアカウントのツイート検索 def search(id) @tweets = twitter_client.user_timeline(user_id: id, count: 1, exclude_replies: false, include_rts: false, contributor_details: false, locale: "ja", tweet_mode: "extended") end twitter_client → 2-❶で認証した際に設定しました。 user_timeline screen_nameパラメータや user_idパラメータで指定したユーザーが投稿した 最新のツイート集を取得します。 user_id: id → 検索結果を取得するユーザーのID count: 1 → 取得するツイート数を設定します。 exclude_replies: false→ リプライを含まないように設定します。 include_rts: false → falseを設定すると、タイムラインはリツイートを表示しません。 contributor_details: false → ライター機能(複数人による投稿)を利用したツイートの場合、contributorsプロパティにユーザー情報の詳細を含めた内容にします。 locale: "ja" → 日本語のツイートを取得します。 tweet_mode: "extended" → 140文字以上のツイートを取得します。(ツイートの前内容を取得したかったので!) ❸ ❷で取得したツイートが私物関連のものであるのか判断するメソッドを作成します。tagにはツイートに必ず含まれている#から始まるタグを配列にして引数に渡します。 article.rb # 検索したツイートが私物関連のものであるか判断 def set_article(tag) @for_article_tweets = [] @tweets.each do |tweet| @for_article_tweets << tweet if tag.any?{|t| tweet.text.include?(t)} end end ここからまた分解してみていきます (1)私物関連のツイートであった場合、配列の形で次のメソッドで使用したいので空の配列を用意しておく。 @for_article_tweets = [] (2)any?メソッドとeachメソッドと三項演算子を使用して判断していきます。 2-❷で@tweetsの中に取得したツイートを代入しているので使用していきます。 @tweets.each do |tweet| @for_article_tweets << tweet if tag.any?{|t| tweet.text.include?(t)} end any?メソッド → すべての要素が偽である場合に false を返します。真である要素があれば、ただちに true を返します。 このような形で、1つでも真である要素があればtrueを返します。 ターミナル irb(main):095:0> [100, 200, 300].any? { |v| v > 200 } => true irb(main):096:0> ["aaa","bbb","ccc"].any? {|v| v.include?("a")} => true これに三項演算子を使用すると... まず上のターミナルで試したものに三項演算子を加えてみます。 このような形で、trueであればifの前の処理を行ってくれます。 ターミナル irb(main):107:0> puts "ok" if [100, 200, 300].any? { |v| v > 200 } ok => nil irb(main):108:0> puts "YES!" if ["aaa","bbb","ccc"].any? {|v| v.include?("a")} YES! => nil 配列部分をインスタンスに代入して使用してみます。 tweet_contentsにtagが含まれているかどうか... ターミナル #インスタンスに入れてみる irb(main):125:0> tweet_contents = ["aaa","bbb","ccc"] => ["aaa", "bbb", "ccc"] irb(main):126:0> tag = ["ccc"] => ["ccc"] #any?メソッドが正しく使用できるか試してみる irb(main):127:0> tag.any?{ |t| tweet_contents.include?(t)} => true #次は三項演算子も使用してみる irb(main):128:0> puts "ok" if tag.any?{ |t| tweet_contents.include?(t)} ok => nil こんな感じで使用できました。 ですがこれだと、「取得したツイートの配列の中に指定したtagが含まれている。」と言うことしかわからず、「取得したツイートの配列の中のどれに含まれているのかわからないです。 なので、eachメソッドを使用して@tweetsの要素を一つずつ渡し、判断していきます。 ターミナル tweets = ["aaa","bbb","ccc"] tag = ["ccc"] results = [] #tweetsに入っている要素を順番に取得し、変数tweetに格納しブロック内の処理を実行する。変数tweetにtag内の文字が含まれていれば、resultsの中に追加している。 tweets.each do |tweet| results << tweet if tag.any?{ |t| tweet.include?(t)} end puts results =>ccc 最終的に私が作成したメソッドだと... [1]2-❷で定義した@tweets(取得したツイート)をeachメソッドを使用し、@tweetsに入っている要素を順番に取得し、変数(tweet)に格納しブロック内の処理を実行する。 [2]ブロック内では、要素を格納した変数(tweet)に対し引数(tag)が含まれているかどうかをany?メソッドを使用して判断する。 [3]true(含まれている)であれば、@for_article_tweetsに追加していく。 article.rb def set_article(tag) @for_article_tweets = [] @tweets.each do |tweet| @for_article_tweets << tweet if tag.any?{|t| tweet.text.include?(t)} end end このような形になってます。 ❸ツイートに関係しているメンバーを判断していきます。名前は変えてます。 article.rb # 関係するメンバーを判断 def check_member(tweet_content) #引数に、取得したツイートの内容を渡します。 member_ids = [] a = ["#鈴木太郎"] b = ["#佐藤一郎"] c = ["#斉藤愛"] member_ids << 1 if a.any?{ |a| tweet_content.include?(a) } member_ids << 2 if b.any?{ |b| tweet_content.include?(b) } member_ids << 3 if c.any?{ |c| tweet_content.include?(c) } #取得したツイートの内容に、メンバーの名前のタグが含まれていれば、seedファイルで初期に登録したメンバーのid(1,2,3)がそれぞれ追加されるようになっている。 (self.は略)members << Member.find(member_ids) #メンバーのidを格納したmember_idsを使用してMemberを取得し、membersに追加する。 end any?メソッド → 2❸(2)でまとめてあります。 <<メソッド → 関連付けをしたので、追加されたメソッド collection<<メソッドは、1つ以上のオブジェクトをコレクションに追加します。このとき、追加されるオブジェクトの外部キーは、呼び出し側モデルの主キーに設定されます。 ❹ ツイートに含まれる画像を保存します article.rb # ツイートに含まれる画像を保存 def set_images(medias) image_urls = medias.map{ |h| h.media_url_https } # LINE送信用の画像url作成 imgae_url_for_line = image_urls.first @imgae_url_for_line_small = "#{imgae_url_for_line}:small" # 画像をActiveStorageに保存 image_urls.each_with_index do |image_url, i| image_url_small = "#{image_url}:small" io = open(image_url_small) self.images.attach(io: io, filename: "image_#{i}") end end ここが割と詰んだ部分です。 また分解してみていきます。 (0)前提として... 例えば2-❶のように認証した後、 ターミナル @tweets = twitter_client.user_timeline(user_id: "(ユーザーID)", count: 1, 〜2-❷と同様) => [#<Twitter::Tweet id=1461747303736242176>] #@tweetは配列(array)なので、`first`で一つ抜き出して試してみます。 irb(main):019:0> @tweet = @tweets.first => #<Twitter::Tweet id=1461747303736242176> #.textを使用すると、ツイートの内容を取得できます。 irb(main):021:0> puts @tweet.text 2021/11/20 YouTube等 【adidas】 ジャージジャケット ポルトガル代表 ¥不明 ※画像使わせて頂きました https://t.co/xvYNvE45sX => nil ここからが本題です。mediaを使用すると何ができるかをまとめます。@tweetに関しては↑のターミナルと続いているものとします。 取得したツイート@tweetに対して@tweet.mediaと使用するとTwitter::Media::Photoオブジェクトが取得できます。(@tweetに含まれている画像についての情報が取得できます。) Twitter::Media::Photoオブジェクト↓のようにこれも配列(array)です。 ターミナル irb(main):023:0> @tweet.media => [#<Twitter::Media::Photo id=1461747300020420608>, #<Twitter::Media::Photo id=1461747300016222208>] Twitter::Media::Photoオブジェクトのメソッドはattrs, id, media_url, media_url_https, sizesなどがあります。 attrsメソッド → Twitter::Media::Photoの中身を見れます。ハッシュとして扱えます。 ターミナル #配列なのでわかりやすくするためにfirstで最初の画像を取得します。 irb(main):024:0> @tweet.media.first => #<Twitter::Media::Photo id=1461747300020420608> irb(main):025:0> @tweet.media.first.attrs => {:id=>1461747300020420608, :id_str=>"1461747300020420608", :indices=>[141, 164], :media_url=>"http://pbs.twimg.com/media/FEkrbLkagAAFXVH.jpg", :media_url_https=>"https://pbs.twimg.com/media/FEkrbLkagAAFXVH.jpg", :url=>"https://t.co/xvYNvE45sX", :display_url=>"pic.twitter.com/xvYNvE45sX", :expanded_url=>"https://twitter.com/GoodsFind/status/1461747303736242176/photo/1", :type=>"photo", :sizes=>{ :thumb=>{:w=>150, :h=>150, :resize=>"crop"}, :medium=>{:w=>784, :h=>732, :resize=>"fit"}, :large=>{:w=>784, :h=>732, :resize=>"fit"}, :small=>{:w=>680, :h=>635, :resize=>"fit"} } } id, media_url, media_url_https, sizesを使用してみます。 ターミナル #idを取得できる irb(main):026:0> @tweet.media.first.id => 1461747300020420608 #画像のurlを取得できる(httpから連なる) irb(main):027:0> @tweet.media.first.media_url => #<Addressable::URI:0x3fce24c8e664 URI:http://pbs.twimg.com/media/FEkrbLkagAAFXVH.jpg> #画像のurlを取得できる(httpsから連なる) irb(main):028:0> @tweet.media.first.media_url_https => #<Addressable::URI:0x3fce1ffa571c URI:https://pbs.twimg.com/media/FEkrbLkagAAFXVH.jpg> #画像のサイズを取得できる irb(main):030:0> @tweet.media.first.sizes => {:thumb=>#<Twitter::Size h=150 w=150>, :medium=>#<Twitter::Size h=732 w=784>, :large=>#<Twitter::Size h=732 w=784>, :small=>#<Twitter::Size h=635 w=680>} media_urlの返値であるAddressable::URIオブジェクトはURIオブジェクトみたいなもので、to_sでURL文字列になります。 前提が長くなりましたが このインスタンスメソッドの引数の(medias)には、 def set_images(medias) 使用するときに、このように取得したツイートに対して.mediaを使用してTwitter::Media::Photoオブジェクトを渡します。 set_images(tweet.media) 次に行きます。 (1)media_url_httpsメソッド(画像のurlを取得できる(httpsから連なる)) (2)の最後に書いたように、引数のmediasここにはTwitter::Media::Photoオブジェクトが渡されています。 [medias = Twitter::Media::Photoオブジェクト]に対してmapメソッドを使用し各要素に対してブロック{}内の処理を実行させ、結果を全て含む配列を返します。 もっと砕いた言葉にすると →mapメソッドを使用し、mediasに含まれているTwitter::Media::Photoオブジェクト一つ一つに対して、ブロック内のmedia_url_httpsメソッドを使用し、httpsから連なる画像のURLの配列を返し、image_urlsに代入します。 image_urls = medias.map{ |h| h.media_url_https } mapメソッド 各要素に対してブロックを評価した結果を全て含む配列を返します。 (2)LINE Messaging APIで使用する画像を準備 LINE Messaging APIで使用する画像をimgae_url_for_lineに代入します。 今回は最初の画像を使用します。 imgae_url_for_line = image_urls.first (3)LINE Messaging APIで使用する画像のサイズを指定します。 今回は一番小さいサイズを指定したいので、:smallを使用します。 ここは単純で、media_url_httpsメソッドを使用し、httpsから連なる画像のURLの最後に:smallをつければサイズ調整ができます。 ここでは、(2)で定義したimgae_url_for_lineの最後に:smallを結合させています。 @imgae_url_for_line_small = "#{imgae_url_for_line}:small" 2-❹(0)の再記載になりますが、ここに:smallが含まれています。 ターミナル irb(main):025:0> @tweet.media.first.attrs => {:id=>1461747300020420608, :id_str=>"1461747300020420608", :indices=>[141, 164], :media_url=>"http://pbs.twimg.com/media/FEkrbLkagAAFXVH.jpg", :media_url_https=>"https://pbs.twimg.com/media/FEkrbLkagAAFXVH.jpg", :url=>"https://t.co/xvYNvE45sX", :display_url=>"pic.twitter.com/xvYNvE45sX", :expanded_url=>"https://twitter.com/GoodsFind/status/1461747303736242176/photo/1", :type=>"photo", #ここの :sizes=>{ :thumb=>{:w=>150, :h=>150, :resize=>"crop"}, :medium=>{:w=>784, :h=>732, :resize=>"fit"}, :large=>{:w=>784, :h=>732, :resize=>"fit"}, #ここです!!! :small=>{:w=>680, :h=>635, :resize=>"fit"} } } (4)画像をActiveStorageに保存 まず説明を書いて、その後に詳しくまとめます。 image_urls.each_with_index do |image_url, i| #image_urls → httpsから連なる画像のURLの配列(2-❹(1)(2)で設定済み) image_url_small = "#{image_url}:small" #サイズをsmallに指定 io = open(image_url_small) #openメソッドを使用し、取得しているURLをファイルオブジェクトとして返す。 self.images.attach(io: io, filename: "image_#{i}") #attachメソッドを使用してimagesに取得した画像を追加する end (4)-1 image_urls.each_with_index do |image_url, i| #image_urls → httpsから連なる画像のURLの配列(2-❹(1)(2)で設定済み) image_url_small = "#{image_url}:small" #サイズをsmallに指定 each_with_indexメソッド → eachループで回しつつ、それぞれのデータに番号を振りたいのでこのメソッドを使用しました。(ファイル名が同じ名前だと保存されない。) ターミナル ["いちご", "なし" , "ブドウ"].each_with_index do |item, i| puts "#{i}番目のフルーツは、#{item}です" end =>0番目のフルーツは、いちごです =>1番目のフルーツは、なしです =>2番目のフルーツは、ブドウです (4)-2 問題はこの部分ですね...ここは2行をまとめて書いた方がわかりやすいと思うのでまとめます。 io = open(image_url_small) #openメソッドを使用し、取得しているURLをファイルオブジェクトとして返す。 self.images.attach(io: io, filename: "image_#{i}") #attachメソッドを使用してimagesに取得した画像を追加する まず、self.images.attach(io: io, filename: "image_#{i}")は以下を参照に作成しています。 HTTPリクエスト経由では配信されないファイルをアタッチする必要が生じる場合があります。たとえば、ディスク上で生成したファイルやユーザーが送信したURLからダウンロードしたファイルをアタッチしたい場合や、モデルのテストでfixtureファイルをアタッチしたい場合などが考えられます。これを行うには、オープンIOオブジェクトとファイル名を1つ以上含むハッシュを渡します。 @message.image.attach(io: File.open('/path/to/file'), filename: 'file.pdf') 以上から、io = open(image_url_small)では、self.images.attach(io: io, filename: "image_#{i}")のio: ioの部分に渡すものを作成します。 open-uriライブラリを使用します。 http/ftp上のURLを普通のファイルのように開き、開いたファイルオブジェクトは StringIO もしくは Tempfile になります。 io = open(image_url_small) image_url_smallに入ったAddressable::URIオブジェクトに対してopenメソッドを実行します。 openメソッド → name が http:// や https://、ftp:// で始まっている文字列なら URI のリソースを取得した上で StringIO オブジェクトまたは Tempfile オブジェクトとして返します。返されるオブジェクトは OpenURI::Meta モジュールで extend されています。 ターミナルで中身を確認してみると(サイズはsmallにしてない状態です) ターミナル irb> image_url => #<Addressable::URI:0x3fce24c8e664 URI:http://pbs.twimg.com/media/FEkrbLkagAAFXVH.jpg> irb> io = open(image_url) => #<Tempfile:/var/folders/gp/jc5cbzvd0v77std1f228cw080000gn/T/open-uri20211130-72880-1f2t1e> irb> p io #<Tempfile:/var/folders/gp/jc5cbzvd0v77std1f228cw080000gn/T/open-uri20211130-72880-1f2t1e> #Tempfileについて(テンポラリファイル(一時的なファイル)を操作するクラスです。画像などのダウンロードやアップロード処理の中で、一時的にファイルとして扱いたい場合などに利用できます。) 参照 ❺ ❶❷❸❹で作成したメソッドとツイートIDを使用してツイートと画像を取得します。 article.rb # GoodsFindというアカウントのツイート検索 def make_GoodsFind_article search("100000000000") #searchメソッドにアカウントの idを渡して最新のツイートを取得させる。 tag = ["#findgoodsofstan"] set_article(tag) #tagにそのアカウントが私物特定の時に使用するタグを渡し、set_articleメソッドを使用して私物特定のツイートかどうかを確認する。 #以下↓はアカウントごとに仕様が異なるのでメソッドにはしなかった。 #それぞれ正規表現を使用してツイート内容から必要な文字を抜き取る。 @for_article_tweets.each do |tweet| tweet_content = tweet.text.gsub(/[\r\n]/,"") self.tweet_url = tweet.url self.price = tweet_content.scan(/¥.+?-/).join(',') self.brand = tweet_content.scan(/(?<=\【).+?(?=\】)/).join(',') self.item = tweet_content.scan(/(?<=\】).+?(?=\¥)/).join(',') check_member(tweet_content) #どのメンバーの私物か判断させる。 set_images(tweet.media) #tweet.mediaを引数にし、set_imageメソッドでがそうを登録させる。 if save #saveできれば、lineで送信する。 send_line(member_ids, tweet_url, @imgae_url_for_line_small) end end end LINE Messaging API 「LINE BOT」とは、メッセージアプリのLINEを使用して、ユーザーの質問に自動で返答できたり、メッセージを送信できたりするプログラムのことです。 仕組みについてはこちら↓ Messaging APIを使って、ボットサーバーとLINEプラットフォームの間でデータを交換できます。リクエストは、JSON形式でHTTPSを使って送信されます。 1.ユーザーが、LINE公式アカウントにメッセージを送信します。 2.LINEプラットフォームからボットサーバーのWebhook URLに、Webhookイベントが送信されます。 3.Webhookイベントに応じて、ボットサーバーからユーザーにLINEプラットフォームを介して応答します 1.gemを追加する # LINE API用 gem 'line-bot-api' LINE Messaging API SDK for Rubyを使用すると、LINE Messaging APIを使用してボットを簡単に開発でき、数分以内にサンプルボットを作成できます。 2.メソッド 今回はブロードキャストメッセージを使用して送信していきます。 LINE公式アカウントと友だちになっているすべてのユーザーに、任意のタイミングでプッシュメッセージを送信します。 LINE botはメンバー全員用 鈴木太郎さん用 佐藤一郎さん用 斉藤愛さん用を作成しました。 article.rb def send_line(member_ids, tweet_url, tweet_image_url) #引数として、(記事のメンバーのid,ツイートのurl,送信する画像のurl) unless member_ids.empty? #記事のメンバーのidが空でなければ ids = member_ids.map(&:to_s) @line_names = ["ALL"] #全メンバーのチャネルに送信 ids.each do |id| #eachメソッドを使用して、それぞれメンバーのidが含まれていれば、名前の頭文字を追加 @line_names << "ST" if id.include?("1") @line_names << "SI" if id.include?("2") @line_names << "SA" if id.include?("3") end end #.envファイルにLINE_CHANNEL_SECRET_〇〇(ここにALLやSTとかが入る)で、トークンを設定してある。それをここに代入し、認証させる。 @line_names.each do |line_name| client = Line::Bot::Client.new { |config| config.channel_secret = ENV["LINE_CHANNEL_SECRET_#{line_name}"] config.channel_token = ENV["LINE_CHANNEL_TOKEN_#{line_name}"] } #送信するメッセージの中で名前を使用したいので name = "鈴木太郎さん" if ids.include?("1") name = "佐藤一郎さん" if ids.include?("2") name = "斉藤愛さん" if ids.include?("3") #LINEbotで送信するメッセージから検索画面に遷移できるようにする word = "#{self.brand} #{self.price} #{self.item}" #ブランドなどをwordに代入 enc = URI.encode_www_form_component(word) #URI.encode_www_form_componentを使用して、文字列のエンコードを行う url = "https://www.google.co.jp/search?q=" #gogleの検索url search_url = url+enc #gogleの検索urlとエンコードしたencを結合させる #LINE botのメッセージを定義する message = { "type": "flex", "altText": "#{name}の私物が特定されました!(Twitter)", "contents": { "type": "bubble", "hero": { "type": "image", "size": "full", "aspectRatio": "20:28", "aspectMode": "cover", "url": tweet_image_url }, "body": { "type": "box", "layout": "vertical", "spacing": "sm", "contents": [ { "type": "text", "wrap": true, "weight": "bold", "size": "xl", "text": "#{name}着用" }, { "type": "box", "layout": "vertical", "contents": [ { "type": "text", "text": "#{self.brand}", "wrap": true, "weight": "bold", "size": "xl", "flex": 0 }, { "type": "text", "text": "#{self.price}", "wrap": true, "weight": "bold", "size": "sm", "flex": 0 } ] } ] }, "footer": { "type": "box", "layout": "vertical", "spacing": "sm", "contents": [ { "type": "button", "style": "secondary", "action": { "type": "uri", "label": "商品検索", "uri": "#{search_url}", }, "color": "#FCE3E7", "height": "md" }, { "type": "button", "action": { "type": "uri", "label": "To tweet", "uri": "#{tweet_url}" } } ] } } } #broadcastメソッドを使用して、ユーザーにメッセージを送信する。 response = client.broadcast(message) p response end end (1)エンコーディングについて パーセントエンコードを行わないと、ただ"https://www.google.co.jp/search?q="にword(文字列)を繋げたものだと、検索がうまくかかりません。 article.rb word = "#{self.brand} #{self.price} #{self.item}" #ブランドなどをwordに代入 enc = URI.encode_www_form_component(word) #URI.encode_www_form_componentを使用して、文字列のエンコードを行う url = "https://www.google.co.jp/search?q=" #gogleの検索url search_url = url+enc #gogleの検索urlとエンコードしたencを結合させる URI.encode_www_form_component(str) 文字列を URL-encoded form data の1コンポーネントとしてエンコードした文字列を返します。 他に使用しているgemについて 他にも今回導入しているgemがあるのでまとめていきます。 whenever gem Gemfile gem 'whenever', require: false cronの設定を簡単な文法で書けます。 定時実行の処理をwheneverを使うと設定できます。 今回は、Twitterの検索をrakeタスクに作成して、定期的に私物に関するツイートがないか検索をかけました。 デプロイにはherokuを使用しました。herokuはwhenever gemに対応していないため、schedulerを使用して定期実行を行っています。 ❶lib/tasks以下にcronを使用して、定期的に実行する処理のかたまり(rakeタスク)を作成します。 herokuでは10分に1回しか定期実行できない為、3.timesやsleep(150)を使用して、時間の帳尻を合わせています。 lib/tasks/make_goods_find_article.rake namespace :make_goods_find_article do desc '「GoodsFind」のツイートが私物に関するものかを判別し私物に関するものであれば記事を作成する' task account_goods_find: :environment do 3.times{ #3回行っています。 article = Article.new article.make_GoodsFind_article #article.rbで作成したmake_GoodsFind_articleメソッドを実行 sleep(150) #150秒経ったら次の処理へ } end end ❷config/suchedule.rbを作成して、定期実行したいタスク設定(実行の間隔、時間)を書き込んで反映させます。 以下のコマンドをアプリディレクトリ内で実行します。 実行すると、config/suchedule.rbが作成されます。 $ wheneverize . ここに定期実行したいタスク設定(実行の間隔、時間)を書き込んで反映させます。 config/suchedule.rb # Rails.rootを使用するために必要 require File.expand_path(File.dirname(__FILE__) + '/environment') # cronを実行する環境変数 rails_env = ENV['RAILS_ENV'] || :development # cronを実行する環境変数をセット set :environment, rails_env # cronのログの吐き出し場所 set :output, "#{Rails.root}/log/cron.log" # 10分ごとに実行 every 10.minute do rake "make_goods_find_article:account_goods_find" end ❸設定を反映させます。 設定内容にエラーがないか確認 $ bundle exec whenever cronにデータを反映させるコマンド $ bundle exec whenever --update-crontab 参照(開発中はこの記事を参考にして、定期実行を行いました。) herokuのschedulerを使用し定期実行した方法はこちらの記事にまとめてあります。 ransack gem Gemfile # 検索 gem 'ransack' 検索部分をこのgemで作成しました。 このような感じで、ブランド名とメンバー名から検索できるように作成しました。 今回は、article一覧ページのヘッダーに検索フォームを作成したいです。 ❶まずメソッドを確認します。 params[:q] → この後に作成するビューファイルから送られてくるパラメーターです。 ransackメソッド → 送られてきたパラメーターを元にテーブルからデータを検索するメソッドです。 (whereメソッドのransack版というイメージです。) resultメソッド → ransackメソッドで取得したデータをActiveRecord_Relationのオブジェクトに変換するメソッドです。 ❸controllerを確認します。今回はヘッダー部分に検索フォームを作成したいので、application_controller.rbとcontrollers/articles_controller.rbとcontrollers/admins/articles_controller.rbに作成します。 共通で使用するメソッドを作成します。 application_controller.rb class ApplicationController < ActionController::Base def set_item_search @q = Article.ransack(params[:q]) #ransackメソッドを使用しparams[:q]でviewから送られてくるパラメーターを元にテーブルからデータを検索する。 @set_items = @q.result #ransackメソッドで取得したデータをActiveRecord_Relationのオブジェクトに変換する end end controllers/articles_controller.rb def index @articles = @set_items.includes(:members).published.order(created_at: :desc).page(params[:page]).per(6) end controllers/admins/articles_controller.rb def index @articles = @set_items.includes(:members).order(created_at: :desc).page(params[:page]) end ❸検索フォームのviewを確認します。 共通で使用する検索フォームはパーシャルにしました。 views/layouts/_search.html.erb <div class = "search mx-auto py-4"> <%= search_form_for @q do |f| %> <%= f.search_field :brand_cont, class: " search_btn btn-outline-dark", placeholder: t("activerecord.attributes.article.brand") %> <%= f.search_field :members_name_cont, class: " search_btn btn-outline-dark", placeholder: Member.model_name.human %> <%= f.submit 'search', class: "search_btn btn-outline-dark"%> <% end %> </div> ヘッダーでそのときのpathに合わせて表示する形にしました。 views/layouts/_header.html.erb <% if current_page?(articles_path) %> <%= render partial: 'layouts/search', url: articles_path %> <% elsif current_page?(admins_articles_path) %> <%= render partial: 'layouts/search', url: admins_articles_path %> <% end %> sorcery gem Gemfile # ログイン gem 'sorcery' 認証のための最小限のロジックを積んだgemで、ユーザーの必要に応じてメソッドを増やしたり拡張をしていくことを目的としています。 以下の点を考えて今回はsorceryを使用しました。 使いたいのはログイン機能のみです。 deviseに比べて重くない カスタマイズしやすい。 ❶コマンドを実行し必要なファイルを生成します。 $ rails generate sorcery:install ↓のファイルが生成されます。 create app/models/user.rb create db/migrate/XXXXXXXXX_sorcery_core.rb (1)db/migrate/XXXXXXXXX_sorcery_core.rbについて db/migrate/XXXXXXXXX_sorcery_core.rb class SorceryCore < ActiveRecord::Migration[5.2] def change create_table :users do |t| t.string :email, null: false t.string :crypted_password t.string :salt t.integer :role, default: 0 t.timestamps end end end saltについて 標準の暗号化では、「ソルト」を使用して、パスワードハッシュの安全性を高めます。Sorceryでは、パスワードの末尾にランダムな文字列を結合し、その文字列をソルトフィールドに記憶することでこれを行います。 $ rails db:migrateで反映させます。 今回は、ユーザー作成ページは作成しませんでした。 管理者のみ必要なので、一般ユーザーはログインせずに使用できる流れになります。 なので、ログイン用のviewやcontrollerのみ作成しています。 ログイン用のviewやcontroller ログイン・ログアウト機能の実装についてです。sorceryで提供しているメソッドについては、公式のこちらのに記述があります。 loginメソッドは、email、password、remenber_me(デフォルト値はfalse)の3つの引数を取ることができると分かります。 今回はemail、passwordを使用してログインさせます。 controllers/admins/sessions_controller.rb class Admins::SessionsController < ApplicationController skip_before_action :require_login, only: %i[create new] def new;end def create @user = login(params[:email], params[:password]) if @user redirect_back_or_to admins_articles_path, success: t('flash.login') else flash.now[:danger] = t('flash.not_login') render :new end end def destroy logout redirect_back_or_to admins_login_path, success: t('flash.logout') end end ここのrequire_loginについて controllers/admins/sessions_controller.rb skip_before_action :require_login, only: %i[create new] require_loginメソッドはSorceryで提供されているメソッドです。 ログインしていないユーザーをアクション単位で弾き、not_authenticatedメソッドを発火します。 not_authenticatedはSorceryで提供されているメソッドです。 デフォルトではredirect_to root_pathが定義されています。 自分で変更したい際には、application_controller.rbで記述します。 controllers/application_controller.rb class ApplicationController < ActionController::Base add_flash_types :success, :info, :warning, :danger #add_flash_typesメソッドで指定したキーがredirect時のflashで使えるようになる。 #通常はnoticeとalertだけだが、任意のキーを指定できるようになる。 before_action :require_login private def not_authenticated redirect_to admins_login_path, danger: t('flash.before_login') end end それぞれまとめているページはこちらになります。 rails-i18n gem Gemfile # ja.yml gem 'rails-i18n' デフォルトの言語を日本語に設定します。 config/locales/ja.ymlを作成し、日本語に対応させます。 ❶config/application.rbの設定を変更します。 config/application.rb config.i18n.default_locale = :ja #デフォルトの言語を日本語(ja)にします。 config.i18n.load_path += Dir[Rails.root.join('config', 'locales', '**', '*.{rb,yml}').to_s] #i18nの複数ロケールファイルが読み込まれるようpathを通します。 ❷config/locales以下にロケールファイルを配置し、日本語を設定します。 config/locales/activerecord/ja.yml ja: activerecord: models: member: 'Member' report: 'REPORT' attributes: article: brand: 'Brand' member: 'メンバー' twitter_url: 'TwitterへのURL' member: email: 'メールアドレス' password: 'パスワード' enum_help gem Gemfile # enum gem 'enum_help' enumで定義した値をi18n化させることができます。 i18n対応セレクトボックスを作成することも可能です。 ❶enumで定義した値をi18n化はこのように設定し、使用します。 config/locales/activerecord/ja.yml ja: enums: user: role: other: '一般' admin: '管理者' ❷今回はi18n対応セレクトボックスも作成、使用しています。 以下の形で、編集ページで公開か非公開かを選択できるようにしています。 views/admins/articles/edit.html.erb <%= form_with model: @article, url: admins_article_path(@article.id), local: true do |f| %> #省略 <%= f.select :status, Article.statuses_i18n.invert %> #省略 <% end %> ポイントはセレクトボックスの値です。 select(オブジェクト名, メソッド名, 要素(配列 or ハッシュ) [, オプション or HTML属性 or イベント属性]) とすればいいので、以下のようにすれば動きます。 <%= f.select :status,[["公開中", "published"], ["非公開", "draft"]] %> # f.select :プロパティ名,[["表示される文字", "渡される値"], ["表示される文字", "渡される値"]] ですが、enum_helpを使用するともっと簡単に書けますし、enumに変更があったとしてもコードを変更する必要はありません。 ↓このように書くことができます。 <%= f.select :status, Article.statuses_i18n.invert %> 上で見た以下のコードは以下のような構成になっていたので、 f.select :プロパティ名,[["表示される文字", "渡される値"], ["表示される文字", "渡される値"]] enum_helpを使用して[[表示される選択肢, 渡される値], [表示される選択肢, 渡される値]]という形にして渡すには... ターミナル irb(main):012:0> Article.statuses => {"published"=>0, "draft"=>1} irb(main):013:0> Article.statuses_i18n => {"published"=>"公開中", "draft"=>"非公開"} irb(main):014:0> Article.statuses_i18n.map { |k, v| [v, k] } => [["公開中", "published"], ["非公開", "draft"]] irb(main):015:0>Article.statuses_i18n.invert #値からキーへのハッシュを作成 => {"公開中"=>"published", "非公開"=>"draft"} invertメソッド 値からキーへのハッシュを作成して返します。 select(オブジェクト名, メソッド名, 要素(配列 or ハッシュ) [, オプション or HTML属性 or イベント属性]) の通り、配列 or ハッシュを渡せばいいので、以下の形で同じように動きます!もし並列にしたければto_aメソッドを最後に使用すれば配列になりますが、必要ないので使用しません。 Article.statuses_i18n.invert #値からキーへのハッシュを作成 => {"公開中"=>"published", "非公開"=>"draft"} kaminari gem Gemfile # ページネーション gem 'kaminari' 1つのページに表示する記事の数を指定し、複数ページに分けて表示させるようにするgemです。 今回は1ページに8つの記事を表示させています。 8個以上の記事がある場合は以下を表示し、別ページに表示されています。 ❶設定ファイルを作成し、デフォルトの表示数を設定します。 $ rails g kaminari:config config/initializers/kaminari_config.rbが作成されます。 ここで、表示数を指定します。デフォルトは25が設定されています。 今回は8で設定します。 config/initializers/kaminari_config.rb Kaminari.configure do |config| config.default_per_page = 8 #省略 end ❷controllerで表示数などを指定します。 デフォルトで8を指定したので、8こ表示したい場合は記載は必要ないですが、この一覧部分は6個を指定したかったので、.per(6)を使用して数を指定しています。 controllers/articles_controller.rb def index @articles = @set_items.includes(:members).published.order(created_at: :desc).page(params[:page]).per(6) end ❸view部分を指定します。 ❷のコントローラー、アクションでレンダリングされるページを例にすると... 以下を記載するだけで表示されます。 views/articles/index.html.erb <%= paginate @articles %> ❹デザインを変更します。bootstrap4を導入し、自分で後から少し変更しています。 $ rails g kaminari:views bootstrap4 aws-sdk-s3 gem Gemfile # gem "aws-sdk-s3", require: false Heroku には “一時的” ハードドライブがあります。これは、ファイルをディスクに書き込むことはできますが、アプリケーションを再起動するとそれらのファイルは失われてしまいます。 activestorageを使用すると、しばらくは問題ありませんが、添付ファイルは表示動作に支障をきたすようになり、最終的には消えてしまいます。 それが起きないように、アップロードしたファイルをディスクに保存する代わりに、クラウドファイルストレージサービスを使用します。 それに使用するのがこのaws-sdk-s3 gemです
- 投稿日:2021-12-03T04:19:13+09:00
データ分析基盤・DataLakeを作成するツールとしてのEmbulk
この記事はエイチーム引越し侍 / エイチームコネクトの社員による、Ateam Hikkoshi samurai Inc.× Ateam Connect Inc. Advent Calendar 2021 3日目の記事です。 はじめに 今日は、最近のお仕事で触ることになる可能性が高いEmbulkについて書きます。 触ることになるとあるようにEmbulkを使い始めるソフトウェアエンジニアによる記事です。説明に至らない点があったり間違っていたりするかもしれません。その際は編集リクエストなりコメントなりでご指摘いただけたら幸いです。 何に使うかですが、データ分析基盤、DataLake作成におけるETL処理のために使います。ちなみにETLはExtract、Transform、Loadの略でデータを抽出、変換・加工、ロード(Output)する処理のことです。 ETL処理ツールとしては過去Pentaho、Talendを少し触った経験があります。 RubyistなのでFluentdからのEmbulkは知っていたのですが、実際に触るのは初めてです。Embulk、勝手に中身Rubyかと思ってました。(メインがJavaで一部Rubyなんですね) Embulkとは Embulk(エンバルク)はbulk data loader です。はい、bulk load なのでデータを大量に且つ一括で登録するツールです。並列・分散実行も可能となっています。 Embulkの優れた点はプラグイン機構を導入していることによる対応フォーマット、対応データソースの多さです。 MySQLやBigQueryはもちろん、CSVといった物理ファイル、salesforceといったPaaSにも対応しています。 プラグイン一覧はこちら↓ 導入方法 導入方法については上記記載の公式URLにあるので割愛します。 実際に運用する際はFargateなりECSでDockerイメージを使うことになるかと思います。 使い方 公式にembulk exampleというサンプル用コマンドが用意されているのでそれを叩いてみます。 $ embulk example ./try1 2021-12-03 02:39:19.977 +0900: Embulk v0.9.23 Creating ./try1 directory... Creating ./try1/ Creating ./try1/csv/ Creating ./try1/csv/sample_01.csv.gz Creating ./try1/seed.yml Run following subcommands to try embulk: 1. embulk guess ./try1/seed.yml -o config.yml 2. embulk preview config.yml 3. embulk run config.yml exampleコマンドを実行すると上記の通り~/try1というディレクトリが作成されその配下にサンプル用ファイルが格納されます。 ディレクトリ内の構成は以下でした。 $ tree try1/ try1/ ├── csv │ └── sample_01.csv.gz └── seed.yml そして次にguessコマンドを実行するのですが、これがEmbulkの特徴の1つです。まずは実行してみます。 $ embulk guess ./try1/seed.yml -o config.yml 2021-12-03 02:39:36.109 +0900: Embulk v0.9.23 2021-12-03 02:39:37.255 +0900 [WARN] (main): DEPRECATION: JRuby org.jruby.embed.ScriptingContainer is directly injected. 2021-12-03 02:39:40.522 +0900 [INFO] (main): Gem's home and path are set by default: "/Users/h-1390/.embulk/lib/gems" 2021-12-03 02:39:41.612 +0900 [INFO] (main): Started Embulk v0.9.23 2021-12-03 02:39:41.932 +0900 [INFO] (0001:guess): Listing local files at directory '/Users/h-1390/./try1/csv' filtering filename by prefix 'sample_' 2021-12-03 02:39:41.934 +0900 [INFO] (0001:guess): "follow_symlinks" is set false. Note that symbolic links to directories are skipped. 2021-12-03 02:39:41.935 +0900 [INFO] (0001:guess): Loading files [/Users/h-1390/./try1/csv/sample_01.csv.gz] 2021-12-03 02:39:41.946 +0900 [INFO] (0001:guess): Try to read 32,768 bytes from input source 2021-12-03 02:39:42.026 +0900 [INFO] (0001:guess): Loaded plugin embulk (0.9.23) 2021-12-03 02:39:42.063 +0900 [INFO] (0001:guess): Loaded plugin embulk (0.9.23) 2021-12-03 02:39:42.109 +0900 [INFO] (0001:guess): Loaded plugin embulk (0.9.23) 2021-12-03 02:39:42.133 +0900 [INFO] (0001:guess): Loaded plugin embulk (0.9.23) in: type: file path_prefix: /Users/h-1390/./try1/csv/sample_ decoders: - {type: gzip} parser: charset: UTF-8 newline: LF type: csv delimiter: ',' quote: '"' escape: '"' null_string: 'NULL' trim_if_not_quoted: false skip_header_lines: 1 allow_extra_columns: false allow_optional_columns: false columns: - {name: id, type: long} - {name: account, type: long} - {name: time, type: timestamp, format: '%Y-%m-%d %H:%M:%S'} - {name: purchase, type: timestamp, format: '%Y%m%d'} - {name: comment, type: string} out: {type: stdout} Created 'config.yml' file. 上記にあるようにconfig.ymlファイルが出力されました。引数として指定してあるseed.ymlは以下です。 seed.yml in: type: file path_prefix: '/Users/h-1390/./try1/csv/sample_' out: type: stdout in:配下に記載されているのがInputとして使用するデータ定義、out:配下に記載するのがOutputするデータ定義です。 上記の場合はInputは実ファイルで任意のディレクトリにプレフィックス指定された形で任意の数のファイルがある、とわかります。(pathでCSVと推測できますが)ファイル形式は指定されていません。 上記内容を引数として出力guessコマンドを実行して出力されたのが以下です。 config.yml in: type: file path_prefix: /Users/h-1390/./try1/csv/sample_ decoders: - {type: gzip} parser: charset: UTF-8 newline: LF type: csv delimiter: ',' quote: '"' escape: '"' null_string: 'NULL' trim_if_not_quoted: false skip_header_lines: 1 allow_extra_columns: false allow_optional_columns: false columns: - {name: id, type: long} - {name: account, type: long} - {name: time, type: timestamp, format: '%Y-%m-%d %H:%M:%S'} - {name: purchase, type: timestamp, format: '%Y%m%d'} - {name: comment, type: string} out: {type: stdout} 出力されたファイルには、引数として与えた元ファイル内に記載されたCSVの構造・ヘッダー情報が出力されています。 つまり、指定されたファイル(郡)から形式、カラム数カラム名、型を推測し、引数として渡したymlへ追記した形で定義ファイルを作成してくれるのです。 こういう、「形式が決まってて頑張れば自動化できるけど手でやってもそんなにかからない(から結局自動化しないで毎回手でやっちゃうような)作業」を自動化してくれるのは非常にありがたく、感謝しかありません。 どうやって形式や型を推測しているのだろう…と気になり、csvという名称が含まれるディレクトリパス(これは推測材料にしてないと思う)や拡張子(これは判断材料にしているかも)を別名にしてコマンドを叩き直したりしましたが、ちゃんとCSVと推測されてました。おそらくDecorder,Parserを動かして成功するかどうかとかで判断しているんだと思います。 この出力された定義ファイルを引数としてbulk loadを実行します。定義ファイルはYml形式です。Yml形式だと何が嬉しいのか。 はい。Git管理ができますね。ここソフトウェアエンジニアとしては嬉しいポイントです。定義ファイルがバイナリだったりすると管理がなかなか大変だったりするので。 さて、previewコマンドではどのような出力(Output)になるのかを確認できます。 $ embulk preview config.yml 2021-12-03 02:42:49.234 +0900: Embulk v0.9.23 2021-12-03 02:42:50.382 +0900 [WARN] (main): DEPRECATION: JRuby org.jruby.embed.ScriptingContainer is directly injected. 2021-12-03 02:42:53.580 +0900 [INFO] (main): Gem's home and path are set by default: "/Users/h-1390/.embulk/lib/gems" 2021-12-03 02:42:54.539 +0900 [INFO] (main): Started Embulk v0.9.23 2021-12-03 02:42:54.694 +0900 [INFO] (0001:preview): Listing local files at directory '/Users/h-1390/./try1/csv' filtering filename by prefix 'sample_' 2021-12-03 02:42:54.695 +0900 [INFO] (0001:preview): "follow_symlinks" is set false. Note that symbolic links to directories are skipped. 2021-12-03 02:42:54.697 +0900 [INFO] (0001:preview): Loading files [/Users/h-1390/./try1/csv/sample_01.csv.gz] 2021-12-03 02:42:54.704 +0900 [INFO] (0001:preview): Try to read 32,768 bytes from input source +---------+--------------+-------------------------+-------------------------+----------------------------+ | id:long | account:long | time:timestamp | purchase:timestamp | comment:string | +---------+--------------+-------------------------+-------------------------+----------------------------+ | 1 | 32,864 | 2015-01-27 19:23:49 UTC | 2015-01-27 00:00:00 UTC | embulk | | 2 | 14,824 | 2015-01-27 19:01:23 UTC | 2015-01-27 00:00:00 UTC | embulk jruby | | 3 | 27,559 | 2015-01-28 02:20:02 UTC | 2015-01-28 00:00:00 UTC | Embulk "csv" parser plugin | | 4 | 11,270 | 2015-01-29 11:54:36 UTC | 2015-01-29 00:00:00 UTC | | +---------+--------------+-------------------------+-------------------------+----------------------------+ そして実行はrunです。サンプルではout: {type: stdout}なので標準出力にそのまま出力されます。 $ embulk run config.yml 2021-12-03 02:43:48.603 +0900: Embulk v0.9.23 2021-12-03 02:43:49.935 +0900 [WARN] (main): DEPRECATION: JRuby org.jruby.embed.ScriptingContainer is directly injected. 2021-12-03 02:43:53.109 +0900 [INFO] (main): Gem's home and path are set by default: "/Users/h-1390/.embulk/lib/gems" 2021-12-03 02:43:54.129 +0900 [INFO] (main): Started Embulk v0.9.23 2021-12-03 02:43:54.251 +0900 [INFO] (0001:transaction): Listing local files at directory '/Users/h-1390/./try1/csv' filtering filename by prefix 'sample_' 2021-12-03 02:43:54.253 +0900 [INFO] (0001:transaction): "follow_symlinks" is set false. Note that symbolic links to directories are skipped. 2021-12-03 02:43:54.254 +0900 [INFO] (0001:transaction): Loading files [/Users/h-1390/./try1/csv/sample_01.csv.gz] 2021-12-03 02:43:54.306 +0900 [INFO] (0001:transaction): Using local thread executor with max_threads=8 / output tasks 4 = input tasks 1 * 4 2021-12-03 02:43:54.318 +0900 [INFO] (0001:transaction): {done: 0 / 1, running: 0} 1,32864,2015-01-27 19:23:49,20150127,embulk 2,14824,2015-01-27 19:01:23,20150127,embulk jruby 3,27559,2015-01-28 02:20:02,20150128,Embulk "csv" parser plugin 4,11270,2015-01-29 11:54:36,20150129, 2021-12-03 02:43:54.426 +0900 [INFO] (0001:transaction): {done: 1 / 1, running: 0} 2021-12-03 02:43:54.433 +0900 [INFO] (main): Committed. 2021-12-03 02:43:54.433 +0900 [INFO] (main): Next config diff: {"in":{"last_path":"/Users/h-1390/./try1/csv/sample_01.csv.gz"},"out":{}} サンプルではシンプルなデータ出力でしたが、プラグインを導入することでデータの加工(ETL)も可能です。また、MySQL等SQLが使えるデータソースの場合はSQLでin:を記述できるため、SQLでデータセットのJOIN・加工しそれをOutput先に登録も可能です。 実際の運用時には最初に一度今あるデータをすべて移行して、その後は差分移行という形になると思いますが、もちろんそういった差分更新にも対応しています。 SQLで記述できる場合はシンプルにupdate_atの条件指定をしてN日前にしたり前回実行時のタイムスタンプを参照するようにしたり、実現方法は複数あります。 Embulkまとめ bulk data loader プラグイン方式で様々なデータソースに対応している 定義ファイル(Yml)を用いてI/Oを定義する 定義ファイルはInputから推測して生成可能 定義ファイルがYmlなのでバージョン管理(Git)が容易 おまけ: なぜEmbulkなのか データ分析基盤・DataLakeの構築の手段は複数存在し、サービスもいくつかあります。その中でなぜEmbulkなのか、について。 データ分析基盤を構築する上での採用事例が複数存在する点、プラグインによって独自のデータセットがあったとしても柔軟に対応できる、等複数ありますが、社内に有識者が複数いる点というのも強いです。 すでに運用実績がある場合、外部に情報として出てこない深い話や情報があるため、導入の障壁が下がります。 身近に気軽に質問できる有識者がいる(今はリモートメインなのでビデオ越しですが)という点は大きいです。 話はそれますが、逆に社内に導入事例が無くても、誰かにその技術知見やモチベーションがある場合その人が旗振り役となって導入するという流れももちろんあります。 今、我々が実現したいことを最も簡単に実現できる方法は何か。それは保守性があるか・マネージドな状態にできるか等をそれなりに考えた結果の技術選定です。 次回 Ateam Hikkoshi samurai Inc.× Ateam Connect Inc. Advent Calendar 2021 3日目の記事は以上です。 読んでくださった方、ありがとうございます。参考になれば幸いです。 明日は @yhorikawa です。ご期待ください!!
- 投稿日:2021-12-03T01:14:53+09:00
メーカー技術者からエンジニアになってみて
人生充実してますか?? こんにちは! エンジニアになって、過ごしている時間全てが自己投資になっていると感じている、 takaakiといいます。 26歳です。 前職の内容 工学部を卒業後、地元の電子部品メーカーで技術総合職として、ゼネラリストをやっていました。 業務は、こんな感じ。 ・新製品の開発 ・製造工程の設計 ・製品の性能向上活動 ・品質管理 研究設備や工場に行っての業務が多かったため、毎日片道40分かけて出勤していました。 当時不満点があったかと言われると、特にありませんでした。 逆に、上司や先輩方、同期、後輩方にはとても恵まれて、 すごく幸せな環境だったな、と感じています。 エンジニアになろうと思ったきっかけ 3つあります。 ①人生において、時間と場所に縛られたくないと思ったから ②自身の市場価値を上げないと、これからの時代豊かに安定して暮らすのは苦しいと感じたから ③将来的に自身のビジネスを持ちたいと思ったから まず1つ目。 なぜ縛られたくないか? それは生きているうちにできるだけ沢山の経験をしたい。 そして、大切な人たちとできるだけ一緒にいたい。 そう考えるからです。 前職は基本的に毎日出社でした。 業務上、設備がないと仕事にならないので、しょうがありませんでした。 しかし、コロナになって家でゆっくりする時間が増え、 それと同時に自分の人生について考える時間が増えました。 そこで、本やYouTubeで自由な働き方をしている方達がいることを知りました。 初めはお決まりの、「そんなことできる訳ない」と考えていましたが、 心は正直で、そんな人生に惹かれました。 仕事しながら時には世界を回ったり、 時には大切な人たちの身近なところ居てあげたいと思っています。 次に2つ目。 なぜ市場価値が上がらないことに危機感を感じたのか? 一番影響を受けたのは『LIFE SHIFT 100年時代の人生戦略』を読んだことです。 今の仕事を80歳まで続けられるのか? 今の会社がなくなったら、もしくは居られなくなったら、 自分は家族を支えることができるのか? 近年はドックイヤーと言われ、 時代の流れが凄まじく、 大手の大企業でも安泰とは言えない時代となってきました。 経済的に安定するには、 「誰かに依存している状態」でいることはとてもリスクが高いです。 会社を飛び出しても周りから「来てほしい」と思ってもらえるような人間になる。 それこそが、これからの時代を強く生き抜いていく基本戦略だと思っています。 最後に3つ目。 なぜビジネスを持ちたいのか。 素直に、経済力をつけたい、 そして、豊かで生き生きとした社会作りをしていきたい。 こういうことを、パワーを持って実現していきたいと考えているからです。 何をやりたいかは具体的になっていませんが、 多くの人にもっと自分の心に素直な生き方ができる世の中にしていきたいと考えています。 僕自身が周りに言われるがままの人生を、25歳まで続けてきてしまいました。 もっと早く行動できていれば、 もっと豊かな人生にできていたと感じています。 ただ、人生いつからでも遅くはないと思います。 今日が一番若い日だと思って、一歩踏み出しやすい社会にできたらなと思っています。 エンジニアとして働いてみた感想(前職と比べてどう変わったか) 圧倒的に毎日が充実しています。 全ての時間が自身の将来への自己投資になっていると感じます。 事実、スキルがアップし、実務経験期間も積み上がっています。 日々市場価値が上がっていると感じられることで、 漠然とした不安は一切なく、 半年後、一年後、5年後がとても楽しみに日々過ごしています。 ただ、求められる仕事のレベルはとても高いです。 楽な仕事だとは思わない方がいいです。 独学の時点で9割やめていくような分野です。 一方、参入障壁が高いからこそ需要が高いまま推移していくし、 現在の会社でエンジニアを採用したいと思っても、 なかなか来てもらえないという実態から、 エンジニアの需要はさらに高まっているとヒシヒシと感じています。 このことから、確かに独学、エンジニアとしての業務は大変ですが、 自分の時間や体力、お金のリソースと投入する価値は十分にあったと思っています。 今後はどんなエンジニアを目指したいか 社会に対して真の価値があるものを提供できるエンジニアになりたいと思っています。 聞いた話として、「DXの一人歩き」があります。 「流行っているから焦ってDXしたが、むしろ使いにくくて業務の改善になっていない」 こういったことが散見されるようですが、 これでは目的を達成しているとは到底思えません。 しっかりとクライアントや社会の要求を聞き、 時にはこちらから情報を提供して、 本当に価値のあるシステムを構築できるエンジニアになりたいと思っています。 また、それをエンジニアで終わりにせずに、 ビジネスまで持っていきたいと考えています。 最後まで読んでいただき、ありがとうございました! 読者の方の人生に、少しでも良い影響を与えることができたら幸いです。
- 投稿日:2021-12-03T00:02:23+09:00
部分和問題を動的計画法で解く
概要 これは Money Forward Engineering Advent Calendar 2021 3 日目の記事です ? 運用しているとある Rails アプリケーションのある機能でリクエストがタイムアウトしてしまう問題が生じたのが発端です。ある n 個の整数の集合 $\{a_1, a_2, ..., a_n\}$ から、和が与えられた sum に等しくなるように部分集合を選ぶ問題を解く処理があります。この問題を 部分和問題 と呼びます。例えば集合が $\{3, 34, 4, 12, 5, 2\}$ で sum = 20 の場合、和が 20 となる部分集合は $\{3, 12, 5\}$です。 今回 n の数がある程度大きい場合に、Rails アプリケーション内の処理に含まれる部分和問題を数分では解くことができず、結果 HTTP リクエストのタイムアウトが発生してしまいました。そして、このタイムアウトは動的計画法というアルゴリズムを用いることで解決できました。その経緯についてお話します。 なお、動的計画法自体の解説については本記事では省略します。「そもそも動的計画法って何?」という方もいらっしゃると思います。Qiita 上に素晴らしい解説記事がありますので、まずは以下をご参考ください。私も非常にお世話になりました ? 動的計画法超入門! Educational DP Contest の A ~ E 問題の解説と類題集 動的計画法について。 典型的な DP (動的計画法) のパターンを整理 Part 1 ~ ナップサック DP 編 ~ 動的計画法の応用例について。 意外と解説がない!動的計画法で得た最適解を「復元」する一般的な方法 動的計画法では解が存在することは判定できるが、その解の具体的な値を知るには別途復元する方法が必要になる。 内容 部分和問題を解くのに時間がかかる もともとは以下のコードで部分和問題を解いていました。部分集合の大きさを 1 から n まで増やしながら Array#combination を使って部分和が sum になる部分集合を探索するナイーブ (愚直) な方法です。 def resolve_subset_sum_old_version(values:, sum:) # 集合の合計が sum に満たない場合は解は見つからない。 return nil if values.sum < sum # 集合の合計がちょうど sum の場合は集合自体が解になる。 return values if values.sum == sum (1..values.size).each do |i| values.combination(i).each do |sub_values| return sub_values if sub_values.sum == sum end end nil end この方法は n = 10 の場合は瞬時に解くことができます。 require 'benchmark' # n = 10 values = [3590, 1260, 2560, 510, 1780, 2710, 120, 610, 2410, 2620] sum = 10000 resolve_subset_sum_old_version(values: values, sum: sum) #=> [3590, 1260, 120, 2410, 2620] Benchmark.realtime { resolve_subset_sum_old_version(values: values, sum: sum) } #=> 0.00012099999003112316 # 秒 しかし、n = 25 では暗雲が立ち込めてきます ? require 'benchmark' # n = 25 values = [3590, 1260, 2560, 510, 1780, 2710, 120, 610, 2410, 2620, 1250, 1910, 50, 4130, 2760, 190, 720, 1560, 2590, 2400, 2090, 3590, 650, 4320, 4420] # 部分集合の大きさが小さい、つまり探索回数が少なかった場合。 sum = 20000 resolve_subset_sum_old_version(values: values, sum: sum) #=> [3590, 2560, 2710, 4130, 2590, 4420] Benchmark.realtime { resolve_subset_sum_old_version(values: values, sum: sum) } #=> 0.009242000058293343 # 秒 # 部分集合の大きさが大きい、つまり探索回数が多かった場合。 sum = 50000 resolve_subset_sum_old_version(values: values, sum: sum) #=> [3590, 1260, 2560, 510, 1780, 2710, 120, 2410, 2620, 1250, 1910, 50, 4130, 2760, 720, 1560, 2590, 2400, 2090, 3590, 650, 4320, 4420] Benchmark.realtime { resolve_subset_sum_old_version(values: values, sum: sum) } #=> 3.2122450000606477 # 秒 なお、現在は Mac mini (M1, 2020) で動かしていますが、MacBook Pro (15-inch, 2019) では sum = 50000 の場合は処理に 12 秒ほどかかりました。サーバのスペックによっては数分以上かかってしまいそうな予感です。 (1..values.size).each do |i| values.combination(i).each do |sub_values| return sub_values if sub_values.sum == sum end end この探索方法の計算量は $O(2^n\cdot n)$ です。Array#combination の結果、つまり探索対象の部分集合の数が指数関数的に増えていくため、n が大きくなった場合に地獄が発生するのは簡単に想像できますね ? 動的計画法を用いる Wikipedia で 部分和問題 について調べると 部分和問題は、ナップサック問題に含まれるため、動的計画法等の手法で解くことができる。(詳しくは、ナップサック問題の項を参照。) との記載があります。早速動的計画法を用いてみましょう ? 実装時の要件は次の通りです。 引数として n 個の整数の集合を values、合計値 sum を sum として与える。 n + 1 行 sum + 1 列の動的計画法のテーブル dp_table を用意し、初期値 0 を設定する。 それぞれ +1 しているのは初期値を参照する行や列を用意するため。 解を復元するための復元テーブル restoration_table を dp_table と同じ大きさで用意し、初期値 0 を設定する。 dp_table[i][j] は values の i 番目までの値の中で j 以下になるように値を選んだ場合の部分和の最大値を表している。 restoration_table[i][j] は dp_table[i][j] が dp[i - 1][restoration_table[i][j]] によって更新されたことを表している。 動的計画法では以下の漸化式に基づいて処理する。 (スペースの都合のため dp_table を dp、values を vals と表記します。) \rm{dp}[0][j] = 0 (j = 0, 1, \dots, sum) \rm{dp}[i][j] = \left\{ \begin{array}{ll} \rm max({dp}[i-1][j - vals[i-1]] + vals[i-1], \rm{dp}[i - 1][j]) & (j \ge vals[i - 1]) \\ \rm{dp}[i - i][j] & (j < vals[i - 1]) \end{array} \right. # 動的計画法を用いて部分和問題を解くためのクラス。 class ResolveSubsetSumProblem attr_reader :values, :sum def self.call(...) new(...).call end def initialize(values:, sum:) @values = values @sum = sum end def call return nil if values.sum < sum selected_values = find_values_without_dp return selected_values if selected_values dp_table, restoration_table = find_values_with_dp # 動的計画法のテーブルの最下行と最右列の要素が sum ではない場合、解は存在しないと判定する。 return nil unless dp_table[n][sum] == sum restore_selected_values(restoration_table) end private def find_values_without_dp return values if values.sum == sum value = values.find { |value| value == sum } return [value] if value nil end # 動的計画法を使って部分和を解く。 # 同時に解 (部分集合) を復元するための準備をしておく。 def find_values_with_dp # 動的計画法のテーブル。 # dp_table[i][j] は values の i 番目までの値の中で j 以下になる、 # かつ和が最も大きくなるように値を選んだ場合の部分和を表している。 dp_table = Array.new(n + 1) { Array.new(sum + 1, 0) } # 解を復元するためのテーブル。 # value = restoration_table[i][j] が存在する場合、 # value は dp_table[i][j] が dp[i - 1][value] によって更新されたことを表している。 restoration_table = Array.new(n + 1) { Array.new(sum + 1, nil) } (1..n).each do |i| (1..sum).each do |j| k = j - values[i - 1] # values[i - 1] を選ぶ場合。 if j >= values[i - 1] && dp_table[i - 1][j] < dp_table[i - 1][k] + values[i - 1] dp_table[i][j] = dp_table[i - 1][k] + values[i - 1] restoration_table[i][j] = k # values[i - 1] を選ばない場合。 else dp_table[i][j] = dp_table[i - 1][j] end end end [dp_table, restoration_table] end # 復元テーブルから選んだ値を復元する。 def restore_selected_values(restoration_table) selected_values = [] tmp_sum = sum n.downto(1) do |i| next unless restoration_table[i][tmp_sum] # values[i - 1] を選んでいた場合。 selected_values << values[i - 1] tmp_sum = restoration_table[i][tmp_sum] end selected_values.reverse end def n values.size end end def resolve_subset_sum_new_version(values:, sum:) ResolveSubsetSumProblem.call(values: values, sum: sum) end require 'benchmark' sum = 50000 resolve_subset_sum_new_version(values: values, sum: sum) #=> [3590, 1260, 2560, 510, 1780, 2710, 120, 2410, 2620, 1250, 1910, 50, 4130, 2760, 720, 1560, 2590, 2400, 2090, 3590, 650, 4320, 4420] Benchmark.realtime { resolve_subset_sum_new_version(values: values, sum: sum) } #=> 0.3366469999309629 # 秒 非常に早くなりました ? さきほどのナイーブな方法と比較してみます。 values = [3590, 1260, 2560, 510, 1780, 2710, 120, 610, 2410, 2620, 1250, 1910, 50, 4130, 2760, 190, 720, 1560, 2590, 2400, 2090, 3590, 650, 4320, 4420] require 'benchmark' Benchmark.bm(40) do |r| m = 5 r.report 'explore all subsets (sum: 10,000)' do m.times { resolve_subset_sum_old_version(values: values, sum: 10000) } end r.report 'use dynamic programming (sum: 10,000)' do m.times { resolve_subset_sum_new_version(values: values, sum: 10000) } end r.report 'explore all subsets (sum: 30,000)' do m.times { resolve_subset_sum_old_version(values: values, sum: 30000) } end r.report 'use dynamic programming (sum: 30,000)' do m.times { resolve_subset_sum_new_version(values: values, sum: 30000) } end r.report 'explore all subsets (sum: 50,000)' do m.times { resolve_subset_sum_old_version(values: values, sum: 50000) } end r.report 'use dynamic programming (sum: 50,000)' do m.times { resolve_subset_sum_new_version(values: values, sum: 50000) } end end # user system total real # explore all subsets (sum: 10,000) 0.001162 0.000032 0.001194 ( 0.001386) # use dynamic programming (sum: 10,000) 0.301114 0.003848 0.304962 ( 0.305438) # explore all subsets (sum: 30,000) 0.883106 0.007375 0.890481 ( 0.910092) # use dynamic programming (sum: 30,000) 0.877341 0.011383 0.888724 ( 0.890696) # explore all subsets (sum: 50,000) 15.447484 0.156102 15.603586 ( 15.618439) # use dynamic programming (sum: 50,000) 1.526902 0.019747 1.546649 ( 1.548114) sum ナイーブな方法 (愚直な探索) 動的計画法を用いる方法 10,000 0.001386 秒 0.305438 秒 30,000 0.910092 秒 0.890696 秒 50,000 15.618439 秒 1.548114 秒 探索回数が少ない場合 (結果の部分集合が小さい場合) は動的計画法を用いる方法の方が時間がかっていますが、探索回数が多くなるにつれてナイーブな方法では時間が指数関数的に増えているのが分かります。一方、動的計画法を用いる方法ではあくまで比例的に増えているだけです。これはナイーブな方法の計算量が $O(2^n\cdot n)$ だったのに対し、動的計画法を用いた方法の計算量は $O(n\cdot{sum})$ であるためです。 ただし、動的計画法では sum が大きい場合に内部で使用する 2 次元配列のサイズが巨大になるためメモリを消費するというデメリットもあります。 動的計画法のデメリット ただし、動的計画法では sum が大きな場合に内部で使用する 2 次元配列のサイズが巨大になるためメモリを消費するというデメリットもあります。 require 'objspace' # [Ruby] メモリ使用量を計測する # https://qiita.com/QUANON/items/b559548a99395ad0fd0b def measure_memsize_before_and_after # メモリ使用量をなるべく正確に計測するために、事前に GC を実行しておく。 GC.start before = ObjectSpace.memsize_of_all yield after = ObjectSpace.memsize_of_all [before, after] end values = [3590, 1260, 2560, 510, 1780, 2710, 120, 610, 2410, 2620, 1250, 1910, 50, 4130, 2760, 190, 720, 1560, 2590, 2400, 2090, 3590, 650, 4320, 4420] sum = 50000 # ナイーブな方法 (愚直な探索) before_memsize, after_memsize = measure_memsize_before_and_after { resolve_subset_sum_old_version(values: values, sum: sum) } ((after_memsize - before_memsize).to_f / (1024 ** 2)).round(2) #=> 2.89 # MB # 動的計画法を用いる方法 before_memsize, after_memsize = measure_memsize_before_and_after { resolve_subset_sum_new_version(values: values, sum: sum) } ((after_memsize - before_memsize).to_f / (1024 ** 2)).round(2) # MB #=> 19.84 # MB sum が大きな数の場合は処理時間も増大するので、動的計画法のテーブルのサイズを圧縮するような工夫が必要そうです (未調査) ? large_values = values.map { _1 * 100 } large_sum = sum * 100 require 'benchmark' Benchmark.realtime { resolve_subset_sum_new_version(values: large_values, sum: large_sum) } #=> 24.495768000138924 # 秒 before_memsize, after_memsize = measure_memsize_before_and_after { resolve_subset_sum_new_version(values: large_values, sum: large_sum) } ((after_memsize - before_memsize).to_f / (1024 ** 2)).round(2) #=> 1983.65 # MB まとめ 部分和問題はナップサック問題の一部で、動的計画法を用いることで効率的に解くことができる ? 動的計画法で部分和問題を解く際に、動的計画法用のテーブルのみでは解があることしかわからない。しかし、復元テーブルを用いることで具体的な解のひとつを求めることができる ? 動的計画法はテーブルのサイズが大きい場合にメモリを消費するので注意が必要 ? 最後に 引き続き Money Forward Engineering Advent Calendar 2021 をお楽しみください ? 参考 記事 動的計画法超入門! Educational DP Contest の A ~ E 問題の解説と類題集 典型的な DP (動的計画法) のパターンを整理 Part 1 ~ ナップサック DP 編 ~ 意外と解説がない!動的計画法で得た最適解を「復元」する一般的な方法 書籍 なっとく! アルゴリズム ゆるふわテイストなイラストと文章でアルゴリズムをわかりやすく説明してくれる良書です。 動的計画法についても説明が詳しく載っています。 書籍中のコードの言語は Python です ?
- 投稿日:2021-12-03T00:01:29+09:00
【LINE×Rails】Rails初学者も作れるLINE Botアプリケーション
概要 この記事では、先日リリースしたReLINE【"猫さん"】で得た LINE Messaging API の知見を使って、投稿に応じて返信するテキストを変化させるLINE Botアプリケーションを作成します。 記事の内容に沿って実際に手を動かして取り組んでもらうと、今回私が作成したものと同じものができるので、お時間のある方はチャレンジしてもらえると嬉しいです! 【注意点】 この記事では上記のLINE Botアプリケーションを作成する流れを記載していきます。そのため技術的な話はほとんど出てきませんが、身近なLINEアプリを使ってプログラミングへの楽しさや作成する面白さなどを感じてもらえたら幸いです。 【今回挑戦する4ケース】 1."おまじない"(アブラカタブラ、チチンプイプイ、ヒラケゴマ のいずれかテキスト)が投稿された場合 => It is Omajinaiと返す 2.rubyというテキストが投稿された場合 => Is it Programming language? Ore?と返す 3.スタンプが投稿された場合 => Thanks!!と返す 4.上記以外のイベントが発生した場合 => .......と返す 尚、今回はngrokを使用して、Railsは開発環境のままでも楽しめるようにします。 (以下の画像は完成イメージです) 本記事の全体の流れ 1.LINE側の設定 2.Rails側の設定 3.ngrokの立ち上げ 4.その他諸々の設定 1.LINE側の設定 【大まかな流れ】 LINEアカウントを使用してLINE Developersにログイン プロバイダーの作成 Messaging APIを選択から作成まで チェンネルIDとチェンネルシークレットの値を控える チャンネルアクセストークンを発行して値を控える ■ LINEアカウントを使用してLINE Developersにログイン LINE Developersにアクセスして、右上にある「ログイン」からLINEアカウントを使用してログインします。 ■ プロバイダーの作成 新規プロバイダーを作成します。「作成」ボタンをクリックします。新規プロバイダー作成がポップアップで表示されるので、プロバイダー名を入力して「作成」をクリックしてください。 ■ Messaging APIを選択から作成まで 新規プロバイダーを作成できたら、Messaging APIを選択してください。 入力画面に遷移したら必須項目を埋めて、「作成」ボタンをクリックしてください。 【必須項目に関しての補足】 チャンネルの種類:Messaging API プロバイダー:先程作成したプロバイダー名 チャンネル名:(好きなチャンネル名を入力) チェンネル説明:(適宜入力) 大業種:(分類の中から選択) 小業種:(分類の中から選択) メールアドレス:自分のメールアドレス LINE公式アカウント利用規約の内容に同意します:チェックを入れる LINE公式アカウントAPI利用規約の内容に同意します:チェックを入れる 以下の内容でMessaging APIチャンネルを作成しますか?というポップアップが表示されたら「OK」をクリックし、続いて表示される情報利用に関する同意については「同意する」をクリックしてください。 ■ チェンネルIDとチェンネルシークレットの値を控える 下記画像の緑色で隠している2箇所をコピーして控えておいてください(後程Railsの設定のところで使用します)。 チャンネルID チャンネルシークレット ■ チャンネルアクセストークンを発行して値を控える タグを「Messaging API設定」に切り替えて、チャンネルアクセストークン(長期)を発行し、こちらもコピーして控えておきます(後程Railsの設定のところで使用します)。 以上で一旦LINE側の設定は終了になります。 2.Rails側の設定 【大まかな流れ】 開発環境 rails new からサーバー起動確認まで Gem(line-bot-api)を加える ルーティングの設定 credentials.yml.encの編集 コントローラーの設定 development.rbにngrokを使用する設定を追加 ■ 開発環境 ● Ruby:3.0.2 ● Ruby on Rails:6.1.4.1 * credentials.yml を使用するのでRailsのバージョンが5.2以上であれば大丈夫です。Rubyに関してもRailsが動けば問題ないかと思われます。 ■ rails new からサーバー起動確認まで では、早速rails new でアプリケーションを立ち上げて、ちゃんとサーバーが起動するかを確認しましょう。 $ rails new hello_hogefuga $ cd hello_hogefuga $ bin/rails s Yay! You're on Rails! が表示されたらOKです。 ■ Gem(line-bot-api)を加える この開発で必要なgem line-bot-apiをGemfileに加えて、bundle installを実行します。 # Gemfile gem 'line-bot-api' $ bundle install ■ ルーティングの設定 次にルーティングを設定します。 # config/routes.rb Rails.application.routes.draw do post '/callback', to: 'hoges#callback' end ■ credentials.yml.encの編集 今回は開発環境で動かすだけなので必要ではないかもしれませんが、万が一情報が漏れてしまうとマズイので、チャンネルID・チャンネルシークレット・チャンネルアクセストークン(長期)の値をcredentials.ymlに加えましょう。 *vimの使い方は各自で調べてください。 $ EDITOR='vim' bin/rails credentials:edit channel_id: 11111 # 先程控えたチャンネルID channel_secret: 'hogehogehoge' # 先程控えたチャンネルシークレット channel_token: 'fugafugafugafugafugafuga' # 先程控えたチャンネルトークン(長期) ■ コントローラーの設定 次にコントローラーを作成し、config/routes.rbで設定したアクションの中身を記述しましょう。 *今回は体験重視のため、ロジック部分もコントローラーに記載しております。 $ bin/rails g controller hoges (以下のコントローラーの記述は公式GitHUbを参考に少し手を加えております) # app/controllers/hoges_controller.rb class HogesController < ApplicationController protect_from_forgery except: :callback OMAJINAI = /アブラカタブラ|チチンプイプイ|ヒラケゴマ/ def callback client = Line::Bot::Client.new do |config| config.channel_id = Rails.application.credentials.channel_id config.channel_secret = Rails.application.credentials.channel_secret config.channel_token = Rails.application.credentials.channel_token end body = request.body.read signature = request.env['HTTP_X_LINE_SIGNATURE'] return head :bad_request unless client.validate_signature(body, signature) events = client.parse_events_from(body) events.each do |event| message = case event when Line::Bot::Event::Message { type: 'text', text: parse_message_type(event) } else { type: 'text', text: '........' } end client.reply_message(event['replyToken'], message) end head :ok end private def parse_message_type(event) case event.type when Line::Bot::Event::MessageType::Text reaction_text(event) # ユーザーが投稿したものがテキストメッセージだった場合に返す値 else 'Thanks!!' # ユーザーが投稿したものがテキストメッセージ以外だった場合に返す値 end end def reaction_text(event) if event.message['text'].match?(OMAJINAI) 'It is Omajinai' # 定数OMAJINAIに含まれる文字列の内、いずれかに一致した投稿がされた場合に返す値 elsif event.message['text'].match?('ruby') 'Is it Programming language? Ore?' # `ruby`という文字列が投稿された場合に返す値 else event.message['text'] # 上記2つに合致しない投稿だった場合、投稿と同じ文字列を返す end end end *Line::Bot::Clientに用意されているメソッドに関しては、公式ドキュメントや公式GitHubなどを参照してください。 ■ development.rbにngrokを使用する設定を追加 次にngrokを使用できるように、development.rbに以下の記述を加えます。 # config/environments/development.rb require 'active_support/core_ext/integer/time' Rails.application.configure do # ===== 省略 ===== config.hosts << '.ngrok.io' end 以上でRails側の設定は完了です。 3.ngrokの立ち上げ 【大まかな流れ】 ngrokのインストール ngrokの起動とURL(https)を控える Railsサーバーを立ち上げ直す ■ ngrokのインストール HomebrewがインストールされているMacでしたら、以下のコマンドでngrokをインストールできます。 (Homebrewが入っていない方はこちらのページからインストールを行ってください) $ brew install ngrok ■ ngrokの起動とURL(https)を控える インストールが完了したら以下のコマンドを実行してngrokを立ち上げます。 $ ngrok http 3000 立ち上がると下記画像のようになるので、緑色の枠の部分(https.....)をコピーして控えておいてください(後程その他諸々の設定のところで使用します)。 ■ Railsサーバーを立ち上げ直す 2.Rails側の設定の最後でconfig/environments/development.rbを編集しているので、Railsサーバーを立ち上げ直します。 # 別にターミナルを開いてサーバーを立ち上げ直す $ bin/rails s *ngrokは立ち上げる度に付与されるURLが異なりますので、都度控えて後述のWebhook URLの編集部分を変更してください。 以上でngrokの立ち上げは完了です。 4.その他諸々の設定 【大まかな流れ】 Webhookの設定 Webhookの利用を有効にする Webhook URLを編集 検証を行う 応答設定 グループ・複数人チャットへの参加を許可する:無効 応答メッセージ:無効 あいさつメッセージ:無効 ■ Webhookの設定 LINE Developersに戻り、残りの設定を行いましょう。 まずはMessaging API設定のタグをクリックし、Webhookの利用をアクティブにします。 次に、Webhook URL横にある「編集」ボタンをクリックし、表示された入力画面にngrokで控えた「https://.....ngrok.io」の値を貼り付け、その後ろにRails側で設定したルーティングのURLを加えます。 https://.....ngrok.io/callback 入力が整ったら「更新」ボタンをクリックしてください。 ここまでの設定で問題が無ければ「検証」ボタンを押すと成功のポップアップが表示されるので確認してみましょう。 もし成功以外が表示されてしまった際は、rails c を使ってcredentials.ymlで設定した値がちゃんと呼び出せるか、入力している値に差異がないか、その他設定で打ち間違いがないかなどなど、一度確認してみてください。 # credentials.ymlがちゃんと呼び出せるかの確認方法 $ rails c $ Rails.application.credentials.channel_id # チャンネルID $ Rails.application.credentials.channel_secret # チャンネルシークレット $ Rails.application.credentials.channel_token # チャンネルトークン(長期) ■ 応答設定 「検証」で成功が表示されたら、応答メッセージが表示されている行の右側にある「編集」ボタンをクリックしてください。 応答設定のページに遷移したら、基本設定、詳細設定の中身を以下の画像のように修正してください。 【基本設定・詳細設定の補足】 応答モード:Bot あいさつメッセージ:オフ 応答メッセージ:オフ Webhook:オン 修正を終えたら完了です。 LINE DevelopersのMessaging API設定のところで表示されているQRコードを、スマートフォンのLINEアプリで読み込んで友だち追加を行ったら、LINE Botと1対1のトークルーム内でアブラカタブラ・ruby・スタンプを投稿してみましょう。 (この記事の始めに掲載した画像と同じようなものが表示されたのではないでしょうか?) 最後に 作成は以上になります。お疲れ様でした! ちょっとだけ今回の機能部分の話をすると、受け取ったeventsの中身、特にmessage['text']の値をRubyのmatch?メソッドと正規表現を使って、match?メソッドの返り値がtrueであるか否かで条件分岐させています。(割りと単純ですよね!?) 今回は全てテキストメッセージを返す仕様になっていますが、別の値、例えば外部APIを叩いてその結果を返したりすることもできるかと思いますので、意欲のある方はチャレンジしてもらえればと思います?✨ 最後まで読んでいただき、ありがとうございました!? 参考URL ・LINE Developers Messaging API https://developers.line.biz/ja/docs/messaging-api/ ・GitHub line/line-bot-sdk-ruby https://github.com/line/line-bot-sdk-ruby