ボクココ

個人開発に関するテックブログ

Rails でシンプルな無限スクロール実装

ども、@kimihom です。

久々に無限スクロールを実装する機会が 詠みラボで発生しました。そこで、振り返りがてら、こんな感じで実装するのが個人的に一番シンプルだったコードをご紹介します。

サーバー側実装

俳句(Haiku) をそれぞれ20句ごとに毎回取得し、スクロール下部についたら新しく20件を取得します。HTMLの生成は Rails サーバー側で実施し、それをフロントエンド側では単に HTML を追加するだけの処理になっています。

# Gemfile
gem "kaminari"

# config/routes.rb
resources :haiku

# app/models/haiku.rb
class Haiku < ApplicationRecord
  paginates_per 20
end

# app/controllers/haiku_controller.rb
class HaikuController < ApplicationController
  before_action :authenticate_user!, only: [:new, :create, :update]

  def index
    @haikus = Haiku
      .page(params[:page])
      .order("created_at DESC")

    # 重要. スクロール時はlayoutを含めない
    return render layout: false if params[:no_layout]
  end

end
<%# app/views/haiku/index.html.erb %>
<div id="haikuCmp">
  <% @haikus.each do |haiku| %>
    <div class="ku">
      <%= haiku.kigo #etc %>
    </div>
  <% end %>
</div>
// app/assets/javascripts/haiku.js
$(function() {

  let isScrollLoading = false;
  let scrollPage = 1;
  $(window).scroll(function() {
    if (!isScrollLoading && $(window).scrollTop() + $(window).height() > $(document).height() - 100) {
      isScrollLoading = true;

      scrollPage += 1;
      $.ajax({url: "/haiku", data: {
        "page": scrollPage, "no_layout": "true"
      }}).done(function(data) {
        let appends = $(data).find(".haikusZone").html();
        if (appends.trim().length == 0) return;
        $("#haikuCmp .haikusZone").append(appends);
        isScrollLoading = false;
      }).fail(function(e) {
        console.error("error", e);
      })
    }
  });

});

ポイント

ほとんどが Rails での基本コードに近いです。唯一 "重要" と記した場所を詳しく解説します。

    # 重要. スクロール時はlayoutを含めない
    return render layout: false if params[:no_layout]

まず、ここには if 文となっています。no_layout のパラメータがない限りここを通らずに 通常の app/views/layouts/application.html.erb が呼ばれ、基本のヘッダで CSS, JavaScript などの読み込みなどのコード全てが含まれるようになっています。

しかし、スクロールした後のHaiku一覧取得では、あくまでHaikuの追加したいリストだけを返すようにします。そのため、layout: false を定義しています。これを定義せずに、スクロールして返すHTMLでも<html><head>... が返ってくると、実装はより面倒になります。以下の3行でシンプルに収まるコードになりませんので、実際どうなるか、気になる方は調べてみてください。

let appends = $(data).find("#haikuCmp").html();
if (appends.trim().length == 0) return;
$("#haikuCmp").append(appends);

個人的なお気に入りは $(data).find("#haikuCmp").html();です。no_layout: true でAjaxで送ったサーバーから 、レスポンスがきたHTMLを、一括で追加 appendするだけです。まさに意図した通りに動きます。

サーバーから#haikuCmp の中身が空白以外の何もないデータが返れば、もうそれ以上データがないとしてスクロールを停止します。

終わりに

「いやいや、まだ jQuery かよ・・」そんな声が聞こえてきそうな記事です。

jQuery で慣れたら秒速でコードを組み立てることができるようになりました。この開発スピード感が楽しく、個人開発を進めるモチベーションに繋がっています。

引き続きこっそり改善を続けていきたいと思います。

www.yomilab.com

Twilio Voice JavaScript 2.0 以降での複数着信の受け取り方

ども、@kimihom です。

f:id:cevid_cpp:20220226191000j:plain

Twilio コミュニティの中で、「1本目の着信が来た後、続けて2本目の着信が来たときに、1本目の着信を出られるようにしたい」というケースで質問をいただいた。まさにそのケースの対応を自分も実装したので、コードを含めて説明していければと思う。

Twilio Voice JavaScript SDK の変更

まず、v1 から v2 への 大枠の変更は以下の記事にまとめてある。

www.bokukoko.info

"Device 実装の変更" がさっと書いてあるが、これが実装する上では最も大きな影響を与えている。

個人的には以下の違いが最も大きいと感じている。

  • v1: 着信が来た順で最新の着信のみを受け取れていた
  • v2: 着信が複数来たときに、そのどの着信を出るかを選択できるようになった

この影響もあり、どうやら執筆時点で Twilio Voice JavaScript SDK のサンプルアプリでは、最もシンプルに着信を表示するだけの実装になっているようだ。

ではどう実装していくか、詳細をコードを含めて説明していく。

初期化コード

まず答えから書くと、"着信を受けているCallをJavaScript内で管理する" ということになる。

具体的なサンプルコードを書いてみる。

let token = getToken(); // access token
let activeCall = null; // 現在アクティブなCall
let activeIncomingCalls = {} // 現在着信を受けているCall

function initTwilioClient(token) {
  const twilio = new Twilio.Device(token);

  twilio.on('registering', device => {
    console.log("Device登録中");
  });
  twilio.on('registered', device => {
    console.log("Device登録完了");
  });
  twilio.on('unregistered', call => {
    console.log("Device登録解除");
  });
  twilio.on('incoming', handleIncomingCall);
  twilio.on('error', handleErrorCall);
  twilio.register();
};

もちろん、各イベントの中で UI の更新などが必要になってくる。ただし、上記コードで今回の "複数着信の受け取り方" を示す上では十分である。

では肝心の handleIncomingCall をどうするか。以下となる。

function handleIncomingCall(call) {
  activeIncomingCalls[call.parameters.CallSid] = call;

  call.on("accept", handleAcceptedCall);
  call.on("disconnect", handleDisconnectedCall);
  call.on("cancel", () => {
    handleCanceledCall(call);
  });
  call.on("reject", () => {
    handleRejectedCall(call);
  });
  call.on('error', handleErrorCall);

  if (activeCall) return;
  activeCall = call;
  showIncoming(); // 着信中のUIをactiveCallを使って表示
}

上記コードで、今着信が来ている activeIncomingCalls に1件目、2件目の着信が連続できてもそれぞれがハッシュとして保存できるようになった。そして、それぞれのイベントを事前に定義している。

この状態で1件目の着信で activeCall 登録される。2件目の着信は、既に activeCall が存在するので、UI を変えないような実装にしている。

※このようにHashで管理しているので、現在着信が来ている通話を一覧表示して、好きな着信を選択して出る UI を実現することも可能!

ただし、これで完成ではない。このままだと、着信通話が相手がそのまま電話を切ったり、こちらで拒否したりする場合に着信表示が出続ける問題が発生してしまう。activeIncomingCalls を適切に管理する必要がある。

function removeActiveIncomingCall(call) {
  delete activeIncomingCalls[call.parameters.CallSid];
}
function handleDisconnectedCall(call) {
  removeActiveIncomingCall(call);
  tryShowIncomingCall();
  // ...
}
function handleCanceledCall(call) {
  removeActiveIncomingCall(call);
  // ...
}
function handleRejectedCall(call) {
  removeActiveIncomingCall(call);
  // ...
}

それぞれの終了イベント時に、ハッシュの中で対象の通話を削除するようにしよう。これで、新しい着信のみが表示されるようになる。

tryShowIncomingCall というメソッドがちょろっとある。これは任意ではあるけど、一件目の着信が来て、通話が終わった後に既に他の着信が来てたときの対応も可能となる。

  function tryShowIncomingCall() {
    const obj = Object.keys(activeIncomingCalls);
    if (obj.length == 0) return; // もし他の着信がなければ終了

    activeCall = activeIncomingCalls[obj[0]]; // Hash最初のを表示
    showIncoming(); // 着信中のUIをactiveCallを使って表示
  }

終わりに

今回は Twilio Voice JavaScript 2.0 以降での複数着信の受け取り方 について、実際のコードを含めて解説した。

いろいろなイベントがあって、それらを一つ一つテストするのは大変だけど、v2 からはより柔軟な着信管理ができるようになっている。

この記事が参考になれば幸いだ。

Rails7 で jQuery をセットアップ

ども、@kimihom です。

Rails 7 がリリースされてから暫く経つが、基本的な部分がまだシェアされてないことが多い。本記事では Rails 7 注目のフロントエンドのセットアップについて記す。

追記 jQuery 自体はこれでインポートできたが、jQueryプラグインなどをES6に乗せるのが大変なケースがあった。。 Importmap を使った実装が理想ではあったけども、現状 Rails6 の時と同じように AssetPipeline を使って yarn で管理するで落ち着いている。

importmap

Rails 7 といえば、Node.js を裏で使う必要がなくなった importmap-rails が目玉のリリースだ。 importmap-rails を使うことで、フロントエンドの JavaScript のライブラリを管理してくれるようになる。

github.com

本記事では初歩の初歩として、jQuery をどうやって importmap-rails に載せるかについて記す。

importmap pin でのインストール

さっと手順について記しておく。

$ rails -v
Rails 7.0.2.2

$ rails new sample  --skip-hotwire --javascript=importmap
$ cd sample
$ ./bin/importmap pin jquery

$ vim app/javascript/application.js

import jquery from "jquery"
window.$ = jquery

$(function() {
  console.log("Hello Rails7!");
})

Rails 起動

$ bundle exec rails g scaffold tweets title:string body:text
$ bundle exec rails server
http://localhost:3000/tweets

最初に Rails アプリを初期化するときに importmap を指定することにしている。既にある Rails アプリに importmap を導入するには以下で大丈夫だった。

$ vim Gemfile
gem 'importmap-rails'

$ bundle install
$ bundle exec rails importmap:install

これで初期化した Rails アプリには なんと、package.json, yarn.lock が存在しない! その代わりに、config/importmap.rb にて以下の記載がある。

# Pin npm packages by running ./bin/importmap

pin "application", preload: true
pin "jquery", to: "https://ga.jspm.io/npm:jquery@3.6.0/dist/jquery.js

実際にロードした jQuery を HTML からどう読み込んでいるのか、app/views/layouts/application.html.erb を確認する。

<!DOCTYPE html>
<html>
  <head>
    <title>Sample</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>

    <%= stylesheet_link_tag "application" %>
    <%= javascript_importmap_tags %>
  </head>

  <body>
    <%= yield %>
  </body>
</html>

javascript_importmap_tags が読み込まれている。このシンプルさが素晴らしい。

では実際に HTML でどう展開されるのかを調べよう。

<script type="importmap" data-turbo-track="reload">{
  "imports": {
    "application": "/assets/application-4e9e57d9f5c0b399b79db55caf83b6c720b9f1e5e0bba00203c4011d3b637fad.js",
    "jquery": "https://ga.jspm.io/npm:jquery@3.6.0/dist/jquery.js"
  }
}</script>
<link rel="modulepreload" href="/assets/application-4e9e57d9f5c0b399b79db55caf83b6c720b9f1e5e0bba00203c4011d3b637fad.js">
<script src="/assets/es-module-shims.min-6982885c6ce151b17d1d2841985042ce58e1b94af5dc14ab8268b3d02e7de3d6.js" async="async" data-turbo-track="reload"></script>
<script type="module">import "application"</script>

まず importmap で対象のライブラリを読み込むリストを表示しているようだ。

その後、modulepreload の指定で application.js を優先的にロードする指定をしている。

es-module-shims は、Chrome, Edge 以外のブラウザのサポートをさせるためのライブラリのようである。

最後、module として application.jsimport している。

終わりに

Rails 7 の初歩の初歩を案内した。

実際にガッツリと開発していくと出てくる問題がたくさんあるだろうけど、ひとまずなんとか jQuery を使える状態にまで持っていけた。

引き続き Rails 7 の調査を進めていこう。

Heroku での npm バージョンの違いによるデプロイ失敗メモ

ども、@kimihom です。

f:id:cevid_cpp:20210425144408j:plain

急に Heroku での Node.js アプリのデプロイが失敗したので、その原因と(一次)対応について記しておく。

問題

いつも通り、git push heroku master でデプロイしたところ、以下のようなエラーが発生するようになった。

remote: -----> Installing binaries
remote:        engines.node (package.json):  15.x
remote:        engines.npm (package.json):   unspecified (use default)
remote:
remote:        Resolving node version 15.x...
remote:        Downloading and installing node 15.14.0...
remote:        Using default npm version: 7.7.6
remote:
remote: -----> Restoring cache
remote:        Cached directories were not restored due to a change in version of node, npm, yarn or stack
remote:        Module installation may take longer for this build
remote:
remote: -----> Installing dependencies
remote:        Installing node modules (package.json)
remote:        npm ERR! code ERESOLVE
remote:        npm ERR! ERESOLVE unable to resolve dependency tree
remote:        npm ERR!
remote:        npm ERR! While resolving: undefined@undefined
remote:        npm ERR! Found: bootstrap@3.4.1
remote:        npm ERR! node_modules/bootstrap
remote:        npm ERR!   bootstrap@"^3.3.7" from the root project

根本は bootstrap のバージョン指定? が定義されていない(undefined)ことのようだ。しかし、今まで問題なくデプロイできたのに、急にできなくなったのはなんでだろう?

対応

その答えが、npm のデフォルトバージョンが 7 に上がった ということだった。npm v7 に上がると、どうやら npm の dependency をインストールする際にバージョンチェックなどが走るようである。

取り急ぎ、すぐに7にあげる必要もまだないだろうし、問題なく動いている node 6 で動かすようにすることで解決できた。そのために、package.json に以下の記述をしている。

  "engines": {
    "node": "15.x",
    "npm": "6.x.x"
  }

これで Heroku にデプロイする際に npm 6 のバージョンでデプロイ準備してくれるため、正しく動くようになった。

Heroku キャッシュで動き続けることが

この問題、キャッシュで npm インストールが残っていたりすると、バージョン7でも問題なくデプロイできたみたいな複雑な問題となることがある。キャッシュの消し方については以下ページに記載があった。

How do I clear the build cache? - Heroku Help

テスト環境では動いたのに、本番で動かない!そんなことが起こらないようにしたいところである。

終わりに

もちろん、最新を追い続けるために、今後は npm 7 系でも問題なくインストールできるよう調整する必要はある。 あくまで一時的な対応方法として、本記事が参考になれば幸いである。

面談、ウェビナー用途の Web ビデオシステム wellcast 技術

ども、@kimihom です。

f:id:cevid_cpp:20200524174745p:plain

先日、Web ビデオシステム wellcast をリリースした。wellcast における技術面の工夫に関して、本記事で紹介していく。

www.wellcast.in

ビデオ通話の品質

今では色々なビデオ通話のサービスが登場してきて、読者の皆さんも一度は誰かとビデオ通話をした経験があるかと思う。そんな中、相手の声がまともに聞こえなかったり、ビデオがカクカクになってしまったり、こうしたビデオ通話におけるトラブルは付きものである。そんな中でも、まともに会話のできるビデオ通話のサービスが、安心して使えることは間違いない。

今回の wellcast で一番力を入れているのは、まさにビデオ通話品質の部分だ。

具体的にどんな工夫がされているのかというと、ビデオ通話の中で「どれが一番優先度が高いか」を意識されているという点がある。ビデオ通話の中で最も大切なのは、映像ではなく "音声" だ。ビデオ通話の音声品質が悪いと、相手の言っていることが途切れ途切れになってしまって会話すらできないビデオ通話となってしまう。そのため、wellcast ではネットワークが弱い環境でビデオ通話をする場合、映像の品質を落としてでも音声の品質を保とうとする機能が搭載されている。

それ以外にも、例えばウェビナーでスライドシェアをしている場合、音声の次に大事なのは、スクリーンシェアしている資料映像だろう。もしインターネット接続が不安定である場合、スクリーンシェアをしている映像の品質を保つために、他のカメラの映像などの画質を落として、その画質を落とした分をスクリーンシェアの方にネットワークを費やすといったことが裏で行われている。

ミーティング中では、そもそも途中でネットワークが途切れてしまうことも起こり得る。wellcast ではネットワークのトラブルが起きた時にも、なるべくビデオ通話を続けられるようになっている。ネットワークが途切れた際、数秒間のあいだ再接続を試みる機能が搭載されており、もしネットワーク接続が復活すれば、そのままビデオ通話が再開されるようになっている。

これらのビデオ通話におけるネットワークの工夫により、誰でもできる限り問題の起きづらいビデオ通話を実現している。もし他の ビデオ通話サービスで問題が起きた場合に、wellcast のビデオ通話でどうなるかを試してみて欲しいと思う。

ビジネスの現場におけるビデオ通話の最適化

実際に会うリアルのミーティングでは、記憶を頼りに覚えていたことを情報として書き起こすか、書記担当を用意してひたすらメモを残してもらうなどの対応されていることだろう。これらの対応は、情報が完璧に残らないため、言った/言わないの問題になりかねない。そして振り返りができないのでミーティングの何が良くて何が悪かったのか、こうした改善が難しい。

リアルのミーティングでは困難な、オンライン面談における録画機能は、ビジネスの現場で役に立つことだろう。また、相手のビジネスメールアドレスを含めて管理ができるため、ミーティングを終わった後のサポート・フォローもより正確になる。これらがバラバラで管理されていると、単にリアルで会うのではなくオンラインで会うってだけの違いとなってしまう。

ビジネスの現場において、単に会話をするという目的だけで、BtoC のビデオ通話サービスを使うという選択肢をするケースもあるかもしれない。しかし、それでは誰が、いつ、どんな会話をしたのか、完全にブラックボックス化されてしまい、Web オンラインで通話しているメリットをほとんど活かせてないことになる。

wellcast ではミーティング録画機能の標準搭載だけでなく、ミーティングが終わった後に、自動で Slack 通知がされたり、CRM へ相手の情報が自動で書き出される "連携" 機能がある。ミーティングのログを残すことを wellcast に任せることで、他に集中しなければならない仕事に時間をさくことができるようになる。

"ビジネス" における最適なビデオ通話。wellcast はそこを目指してリリースされている。

ウェビナーの申し込みから視聴までの最適化

ウェビナーは、開催する際にいつ、どんな内容のウェビナーをやるのかを事前にお知らせして、希望者に申し込んでもらうような仕組みが必要であることが多い。しかし、BtoC のビデオサービスではこれらの申し込み機能が存在しないことも多く、単に時間になったら SNS などで URL を共有して始めるだけといったものもある。

ビジネス用途に最適化された wellcast では、ウェビナーで事前に専用の申し込みページを作れるようになっている。その申し込みページは、 Rails6 の "ActionText" によって、マークダウン記法のようなフォーマットを覚える必要すらなくなる。申し込みページには、ツールバーとして文字を強調したり、項目としてまとめたり、ファイルをアップロードできたりする機能が搭載されていて、まるで Microsoft ワードの文書作成のような手軽さを実現している。

f:id:cevid_cpp:20200524180410p:plain

さらに、申し込みフォームには名前、メールアドレス だけでなく カスタム項目 を追加することが可能になっている。ここで、ウェビナー参加者に事前に聞いておきたい内容を聞いておくことが可能だ。例えば電話番号、会員ID、簡単なアンケートなどが考えられるだろう。この柔軟な参加申し込みフォームは、BtoC 向けのウェビナー配信サービスではなかなか提供されていない機能である。

そして wellcast のウェビナーには、事前申し込みページの PV 数、実際に申し込んでいただいた予約者の数、当日実際に見ていただいた視聴者の数がまとめられているため、今後ウェビナーの視聴を増やすためにはどこを改善すればいいのかを簡単に確認することが可能だ。

最後に、ウェビナー後にアンケートを取る機能が標準で提供されている。アンケートによって実際のウェビナーの内容自体も、どこを改善すべきかが把握できるようになっている。

終わりに

オンライン面談、そしてウェビナーの時代へようこそ!ぜひ wellcast を使ってオンライン上でのビジネスを飛躍させていっていただければと思う。

30日間、無料で試すことができるので、是非この機会に新しい wellcast を試してみて欲しい。

ActionText を使う場合の ライブラリロード

ども、@kimihom です。

f:id:cevid_cpp:20200413222001j:plain

ActionText の実装をしていて、実際の利用でハマった点をまとめておく。今まで ActionText の記事として2つ書いてあるので、興味があれば読んでみていただけたらと思う。

www.bokukoko.info

www.bokukoko.info

Webpacker の利用が前提

ActionText を利用する上で完全にハマったポイントだ。Rails 6 に上げた段階で、デフォルトとなった Webpacker については下記記事にあるように調査はした。

www.bokukoko.info

今までの AssetPipeline で作り上げてきた Rails 5 の JavaScript コードを、いきなり Webpacker の importrequire でクラスで分けるのは手間がかかる割に、挙動は何も変わらなくて無意味だなと感じてしまった。てことで Webpacker を使わない選択をした。そうすれば、Rails ディレクトリ内に無駄にファイルがたくさんできずに、平穏な Rails プロジェクトを保ち続けられると思ったからだ。Webpacker を入れたら、以下のようなファイルがバンバン追加されていくのである。

  • config/webpack/environment.js
  • config/webpacker.yml
  • app/javascript/packs/application.js
  • babel.config.js
  • postcss.config.js
  • etc...

てことで AssetPipeline で JavaScript も管理するようにして平和を取り戻したと思ったのだが、ここで表題にあるように ActionText の利用は Webpacker の利用が前提となっているということに ActionText を本格的に使うようになって初めて気づいた。

ActionText を使うときに、app/javascript/packs/application.js に以下の記述をする前提となっている。

require("trix")
require("@rails/actiontext")

この @rails/actiontext を AssetPipeline で同様に記述することが不可能だった。

// NG!
//= require @rails/actiontext
//= require_tree .

// => Uncaught SyntaxError: Cannot use import statement outside a module

ActionText のソースを見ると、"@rails/activestorage" から import する処理が書かれており、actiontext 自体で独立した JavaScript ファイルが存在せず、AssetPipeline では実現できないようだった。 rails/attachment_upload.js at master · rails/rails · GitHub

なお、ActionText さえ使わなければ、他の rails-ujsturbolinks は 独立したライブラリのため、 AssetPipeline でも問題なく利用できる。

てことで最終的には ライブラリ系のロードは Webpacker を使い、それ以外の今まで利用していた JavaScript は AssetPipeline を使うという二重の利用方法で落ち着いている。もちろん、これが最適解だとは決して思っていないが、現状はこれがベストだと考えている。つまりヘッダは以下の通りである。

<%= stylesheet_link_tag 'application', media: 'all' %>
<%= javascript_pack_tag 'application' %>
<%= javascript_include_tag 'application' %>

終わりに

今回は ActionText と AssetPipeline の相性の悪さについて書いた。私が調査した中ではの話なので、もし ActionText を AssetPipeline で使う方法があれば教えていただけたら有難い。とはいえ今後の Rails の風潮としては Webpack 側に寄っていくような感じもあるので、今の JavaScript コードを Webpacker の方で正しく動くようにコードを整備する方が正しい流れなのかもしれない。 もし今後 CSS や 画像なども 基本 Webpacker で入れるのが Rails のデフォルト動作になれば、そのように従っていく予定だ。

最高のユーザー体験を提供するために、ActionText の理想的な利用をこれからも追い続けよう。

Audio 関連の API を用いた音声文字化の自動再生

ども、@kimihom です。

f:id:cevid_cpp:20200321172319j:plain

Amazon Transcribe や Google Cloud Speech-to-Text などを使って、音声を文字化することがもはや当たり前になってきている。そこで本記事では、文字化したテキストと、再生する audio のタイミングを合わせて、より効果的に音声と文字を確認できる実装をしてみよう。

ここでとりわけ重要になってくるのが、Audio API の利用だ。そこで本記事では Audio API に関して紹介する。

再生に合わせて自動でイベントを発生

まず、今回の API に関しては以下のページによくまとまっている。

www.htmq.com

Audio, Video 要素だけの特別なイベントが多く提供されていることがわかる。

とりわけ今回の実装で大切なイベントは、timeupdate だ。再生をしている時に定期的にイベントが発火され、その時の時間を取得することができる。頻度としては1秒に3回くらいか。

$(".audio-wrap audio").on("timeupdate", function(){
  var currentSec = parseInt($(this)[0].currentTime);
  // currentSec に合う文字化時間を見つけて、印付け
});

この currentTime はm秒単位で細かく取得できる。録音再生の秒数をより厳密に秒数管理することが可能だ。秒数さえとってこれれば、あとはそこに合う文字化の秒数タイミングに対して印付をすればいいだけだ。ここは 各 JavaScript の書き方で異なってくるので省略する。

文字をクリックに応じて再生を途中再開

文字をクリックしたら、その秒数から再生を再開する実装もしている。これは、特定の場所のクリックイベントで、その時間から再生を再開させるように実装すれば OK だ。以下のサンプルでは 04:30 といった文字をクリックした場合の秒数への変換で少し複雑になってしまっているが audio.currentTime に秒数の時間を入れて、audio.play() するだけで実現できる。

  $(document).on("click", ".audio-wrap .con", function() {
    var audio = $(this).find("audio")[0];
    var times = $(this).find(".time").text().split(":");
    audio.currentTime = parseInt(times[0] * 60) + parseInt(times[1]);
    audio.play();
  });

あとは先ほどの timeupdate イベントを実装すれば、勝手に自動スクロールが途中から再開される。他の細かな UI の調整で、play, pause, endedあたりは使う可能性があるだろう。

終わりに

とてもシンプルに実装できることがおわかりいただけたかと思う。何より一番難しい音声の文字化を AWS や Google の API でさっとできるようになったおかげである。まだ精度に関しては「ん〜惜しい」というくらいだが、今後制度はさらに良くなってほぼ完璧に文字化される日も近いだろう。

貯めて行った録音がそれぞれの資産となれるよう、より効果的な音声データ解析を研究し続けていく。

Action Text での Amazon S3 アップロード

ども、@kimihom です。

前回に引き続き Action Text に関して調査を続けている。今回は Amazon S3 へアップロードしたものをテキストエリアの中に表示させてみよう。

Active Storage の設定

Action Text のファイルアップロードは、Active Storage の設定に完全に依存している。てことで、これから設定するのは全て Active Storage の設定となる。 まず、config/storage.yml を編集してみよう。デフォルトでは local の向き先はディスクに保存するようになっているが、S3 に直接あげるように変えてみる。

amazon:
  service: S3
  access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %
  secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_acces
  region: ap-northeast-1
  bucket: mybucket

Rails.application.credentials.dig は、bundle exec rails credentials:edit で暗号化されたパスワード関連をセット、閲覧することができる。この場所に AWS の キーを保存してより安全に管理しようという形だ。Heroku を使っていれば 環境変数に保存することが多いかとは思うが、こちらは環境共通で、重要性の高いものを保存するという使い方で良さそう。

そして、config/environments/development.rb での Active Storage の向き先を :amazon に変えておく。

  # Store uploaded files on the local file system (see config/storage.yml for options).
  config.active_storage.service = :amazon

Gemfile に AWS SDK を取り込むことも忘れずに。

gem "aws-sdk-s3", require: false

S3 へのダイレクトアップロードになるため、S3 側の CORS の設定もしておく必要がある。

<?xml version="1.0" encoding="UTF-8"?>
<CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<CORSRule>
    <AllowedOrigin>your-domain.com</AllowedOrigin>
    <AllowedMethod>PUT</AllowedMethod>
    <AllowedHeader>Origin</AllowedHeader>
    <AllowedHeader>Content-Type</AllowedHeader>
    <AllowedHeader>Content-MD5</AllowedHeader>
    <AllowedHeader>Content-Disposition</AllowedHeader>
    <MaxAgeSeconds>3600</MaxAgeSeconds>
</CORSRule>
</CORSConfiguration>

改めて app/javascript/packs/application.js で正しく JavaScript ライブラリがロードされているかを確認しておこう。require("@rails/activestorage").start() を呼び出すことで、S3 へのダイレクトアップロードを実現することが可能だ。

require("@rails/activestorage").start()
require("trix")
require("@rails/actiontext")

じゃあ、require("@rails/actiontext") では何をやっているの? という疑問が湧いたので調べてみると、意外とシンプルだった。trix-attachment-add でテキストエリアへのファイルドラッグ&ドロップを検知したら、Active Storage の Direct Upload を使って直接 S3 へアップロードし、結果を Action Text 内に入れるという処理である。

Action Text で作られるテーブルを改めてチェック

Action Text を使うと、3つのテーブルが自動で作られる。これらの違いを改めて確認しておく。

action_text_rich_texts

1つの記事に作成される action_text_rich_texts は 1つのみだ。1つの記事に画像が複数あっても、action_text_rich_texts は一つのレコードとしてまとめられる。record_id に対象の記事ID が保存される。Action Text は一つのテーブルで、他の複数の name(Article#body や Profile#introduction など) を保存できるように設計されている様子。

CREATE TABLE IF NOT EXISTS "action_text_rich_texts" (
    "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
    "name" varchar NOT NULL,
    "body" text,
    "record_type" varchar NOT NULL,
    "record_id" integer NOT NULL,
    "created_at" datetime(6) NOT NULL,
    "updated_at" datetime(6) NOT NULL);

active_storage_blobs

記事を書いている途中でアップロードした時点で保存される。ファイルをアップロードしたけど、後で記事を保存しなかった、というケースでも1レコードとして保存される。1つの記事に複数のactive_storage_blobsが存在することはもちろんある。

CREATE TABLE  IF NOT EXISTS "active_storage_blobs" (
        "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, 
    "key" varchar NOT NULL, 
    "filename" varchar NOT NULL, 
    "content_type" varchar, 
    "metadata" text, 
    "byte_size" bigint NOT NULL, 
    "checksum" varchar NOT NULL, 
    "created_at" datetime NOT NULL);

active_storage_attachments

記事が保存された時点で、画像と記事の紐付けが行われる。こちらも1つの記事に複数の active_storage_attachments が存在することはもちろんある。record_id に記事ID 、blob_id にactive_storage_blobs のレコード ID が保存される。

CREATE TABLE IF NOT EXISTS "active_storage_attachments" (
    "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, 
    "name" varchar NOT NULL,
    "record_type" varchar NOT NULL, 
    "record_id" integer NOT NULL, 
    "blob_id" integer NOT NULL, 
    "created_at" datetime NOT NULL)"

Trix による ContentEditable 制御

Action Text は、Active Storage と Trix をうまく繋げるためのものだと考えるのが良いだろう。Trix は、ContentEditable を華麗に制御する JavaScript ライブラリだ。シンプルな JavaScript で、テキストエリアを操作できる。 地味に ローカルストレージの保存は大事だ。デフォルトの Action Text だと編集中にリロードとか戻るとか押されると、今までの編集が消えてしまう。よくあるブログ執筆ツールのように、「編集時の状態に戻す」ボタンで書いてきたものを取ってこれるようにしておこう。

var element = document.querySelector("trix-editor")
element.editor

// 範囲選択
element.editor.setSelectedRange([0, 1])

// カーソル移動
element.editor.moveCursorInDirection("backward")
element.editor.expandSelectionInDirection("forward")

// テキスト挿入
element.editor.insertString("Hello")
element.editor.insertHTML("<strong>Hello</strong>")
element.editor.insertLineBreak()
element.editor.deleteInDirection("backward")

// undo / redo
element.editor.undo()
element.editor.redo()

// ローカルストレージへ保存
// Save editor state to local storage
localStorage["editorState"] = JSON.stringify(element.editor)
// Restore editor state from local storage
element.editor.loadJSON(JSON.parse(localStorage["editorState"]))

終わりに

今回までの調査で、Action Text 内にドラッグ&ドロップしたファイルを S3 へアップロードすることが可能となった。 実際に S3 に保存されていることを確認しよう。なお今回の S3 意外にも、Active Storage は Microsoft Azure Storage Service, Google Cloud Storage Service もサポートしている。

引き続き Trix の調査を続けていこうと思う。Action Text の根幹技術は Trix だ。また ContentEditable の世界へ足を踏み入れると思うとワクワクする!

Rails 6 における Webpacker デフォルト動作

ども、@kimihom です。

f:id:cevid_cpp:20200205165243j:plain

Rails 6 が公開されてしばらく経つ。私としては Rails 6 の中でもとりわけ Action Text でクールなテキストエリアを実現したいと思っていので、Rails 6 をゼロから学び始めている。

でも今回は Action Text とは全然関係なく、Rails 6 でのアセット管理について調べたことを残しておく。

アセットの管理

今までの Rails のアセットの管理といえば、そう Asset Pipeline だ。これで JavaScript, CSS, Font など全てを一元管理してくれていた。Rails 6 からは、デフォルトで Webpacker が Gemfile に入るようになった。Gemfile に以下が追記されている。

# Transpile app-like JavaScript. Read more: https://github.com/rails/webpacker
gem 'webpacker', '~> 4.0'

ここにあるコメントが大事だ。Transpile app-like JavaScript。現時点ではデフォルトの Rails 6 で作ったアプリでは JavaScript の管理 をするために Webpacker が搭載されている。つまり、CSS や Font などは引き続き Asset Pipeline を使い続けるのが、Rails 6 の利用想定である。app/views/layout/application.html.erbstylesheet_link_tag を読んでいることが Asset Pipeline を使っている証拠である。

    <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
    <%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>

Webpacker の Github にある Readme を始めとした Rails 6 でのフロントエンド の各記事を読むと、Webpacker 側で CSS や Font なども全て統括して管理する方法が掲載されていることが多い。Rails 6 の View に定義されている stylesheet_link_tag を、stylesheet_pack_tag に書き換える方法である。そして、CSS も app/javascript フォルダの中に CSS や画像も全て 突っ込むというなんとも謎なディレクトリ構造が掲載されている。

そもそも、 Rails 側でアセット管理を全部統一するってなら、最初から stylesheet_pack_tag になっているはずだ。そうじゃないのは、必ず何か理由がある。そう思って調べてみた。

Asset Pipeline の基盤となる Gem は、sprockets だ。Gemfile.lock を見てみると・・・

    sassc-rails (2.1.2)
      railties (>= 4.0.0)
      sassc (>= 2.0)
      sprockets (> 3.0)
      sprockets-rails
      tilt

これだ。sassc-rails が sprockets を必要としている。てことで Webpacker で全てのアセットを管理するのではなく、sassc-rails でスタイルシートを管理することが、Rails 6 の標準となっている可能性が高い。

Webpacker で JavaScript を書く

さて、JavaScript の書き方は Webpacker によってだいぶ変わってくる。初歩的な内容にはなるけど、Asset Pipeline と比較した Webpacker の書き方を記してみる。

まず、JavaScript ファイルの全体を管理するのが app/javascript/packs/application.js だ。

require("@rails/ujs").start()
require("turbolinks").start()
require("@rails/activestorage").start()
require("channels")

ここで Rails-ujs、 Turbolinks、 ActiveStorage、 Action Cable の各 JavaScript ファイルが読み込まれている。では自分で新しく作る JavaScript はどう書いていけばいいのだろう?Rails 6 で rails generate controller home を実行しても、JavaScript ファイルは自動で生成されなくなっている。

そもそも、この require はなにものかを理解する必要がある。これは JavaScript の requireimport であり、 Rails 側の何かのメソッドとかではない。外部のファイルを読み込むための記述だと思っておけばいいだろう。違いについて下記コードとして記す。

// app/javascript/my-import.js
export default  class MyImport { 
}

//  app/javascript/my-require.js
class MyRequire {
}
module.exports = MyRequire

// main.js
import MyImport from "my-import"
const MyRequire = require('my-require')

さて、これらのファイルを app/javascripts 側に書き、 app/javascript/packs/application.js に追記すれば、晴れて自分の書いた JavaScript を動かすことができる。

require("@rails/ujs").start()
require("turbolinks").start()
require("@rails/activestorage").start()
require("channels")
require('my-require')

終わりに

今回は Rails 6 における Webpacker と、 JavaScript の書き方について簡単ではあるが記した。今回の内容はあくまで Rails のレールの話なので、Rails のレールから外れて Webpacker で CSS を含めて全部 管理したり、そもそも使わないという選択ももちろん可能だ。

引き続きRails 6 の扱い方について調査を続けていく。

Rails における Chrome80 の SameSite 対応

ども、@kimihom です。

f:id:cevid_cpp:20200203182935j:plain

明日、2020/02/04 に Chrome 80 のリリースが予定されている。そこで巷で話題になっている SameSite の部分で対応が必要になるケースがでてくる。 ウェブ上で調べても全然その対応方法が出てなかったので、記録として残しておこう。

問題となり得るケース

Rails アプリでログイン機能を実装していて、iframe で そのログインセッションを使い続けている場合、Chrome80 から問題が起こる可能性がある。その他のケースもあるので、自分の作っている Web アプリが SameSite による影響が出るのかを確認しておこう。詳しくは既にたくさんのページで紹介されているので読んでおいて欲しい。

現状の設定の確認

EditThisCookie という Chrome 拡張で確認できる。

chrome.google.com

サイトにアクセスして、EditThisCookie アイコンをクリックすると、それぞれの Cookie の Key/Value を表示してくれる。そこにある SameSite の項目が今回一番大事になってくる項目となる。 何も対応していないと、おそらく何も指定された状態になっていないことだろう。この状態だと、Chrome 80 からは勝手に Lax が指定された状態となる。問題となり得るケースの Web アプリ の場合、これを Chrome 80 が出る前までに No Restriction に設定されていないと、その時点で残念なことが起こり得る。

Rails における対応

ではどうやって対応するか?まずは Devise の Cookie セッションが一番大事だろう。

まず rack のバージョンを上げる必要がある。2.1 以上にあげないと、そもそも SameSite の None 設定ができない。

-    rack (2.0.7)
+    rack (2.1.2)

そして、config/initializers/session_store.rb にて same_site の設定をする。"same_site: :none" をここで指定できる。すると、ログイン時の Cookie "_my_session" に same_site が none で設定されることが 先ほどの Chrome 拡張 EditThisCookie で確認できる。

Rails.application.config.session_store :cache_store, key: "_my_session", same_site: :none

さて、Devise を使っていると remember_me も Cookie で保存しているケースは多いことだろう。これは Devise 側で設定する必要がある。config/initializers/devise.rb だ。

config.rememberable_options = {:secure => true, :same_site => :none}

もちろん、これ以外でも Cookie を使っている場合には対応が必要だ。例えば、JavaScript 側で Cookie を管理している場合、js-cookie を使っている場合があることだろう。その場合は以下のようなコードを実装する必要がある。

Cookies.set("hoge", "value", { expires: 365 * 3, secure: true, samesite: 'none'});

EditThisCookie で必要な項目が一通り same_site 設定ができていることが確認できれば、安心して(?) Chrome80 を迎え入れることができるだろう。

終わりに

ググって調べても Rails 側の SameSite の設定方法が全然出てこなかったので、仕方なくソースコード読んで特定して設定した。 ほとんど情報が出回ってないということは、ほとんどの人は Rails の セッション管理で same_site をまともに設定してないということだろうから、明日からの Chrome 80 アップデートでどうなるか、様子を見てみることにしよう。

このブログを読んだ方は、まだ時間が残されているので対応してみて欲しい。

SaaS におけるカスタムダッシュボードの実装

ども、@kimihom です。

f:id:cevid_cpp:20191201182224j:plain

SaaS を開発していると、指標管理をしたいニーズが多く出てくるかと思う。そんな中で、どんな指標を掲示するかって部分は、企業の文化や目標によって違うため、企業ごとに最適な指標を掲示できるようにしたいという流れになることが多くあるだろう。

今回はその課題を解決するためのカスタムレポート(ダッシュボード)機能に関して技術的な部分にフォーカスを充てて解説してみる。


CallConnect カスタムレポート(ダッシュボード) デモ

表示形態の保存

まず、好きなように項目を表示できるように、設定データを設計しよう。全てのレポートで共通の項目と、レポートごとに違う項目を分けて考える必要がある、ここでは以下のようなデータ構造サンプルを挙げておく。

  • order (順番)
  • width (横幅)
  • height (高さ)
  • title (タイトル)
  • period (集計期間)
  • extra (レポート個別の設定項目、 JSON 形式)

個別の項目は JSON として切り出すことにした。そうしないと無駄に NULL の項目が大量にできてしまうのと、今後レポート項目を追加するときに柔軟に対応ができなくなるためである。最近はリレーショナルデータベースでも簡単に JSON 保存と管理ができるようになって、便利なものだ。

上記 動画デモでは、シンプルな横幅、高さの設計となっている。レポートの各高さは共通で、横幅は 1/4 か 1/2 かのどちらかしかない。必要に応じてそれぞれの width, height も DB に保存しておく必要があることだろう。

extra のデータ保存を、それぞれのフォームに落とし込むのが若干難しい。フォームで保存方法を選ぶと、グラフや表などの選択に応じて、選択項目を動的に変えていく必要がある。それは今回の話とは外れるので頑張って実装しようとだけ書いておく。動画にあるように、フォームで選択項目を変えるたびに、右側のプレビューを動的に変えるようにしてみよう。そうすれば都度都度作って確認して編集して・・みたいな面倒な手間を省くことができる。

順番の並び替え

直感的に各レポートの順番を移動できるように、ドラッグ&ドロップの実装をしたいところだ。このような実装を、ライブラリを使わずに自分の力だけで実装できるか。そこがフロントエンド 開発の実力の見せ所だ!今回はあくまで実装の大枠だけ、解説してみる。

まず、それぞれのレポートの場所を JSON で計算して保存しておくようにする。この情報をベースに、ドラッグ&ドロップしたときにどこに灰色プレビューの置くポジションを表示させるかを決定させる。

[
  { id: 1, left: 0, top: 0, width: 120, height: 120 },
  { id: 2, left: 140, top: 0, width: 120, height: 120 },
  { id: 4, left: 0, top: 140, width: 120, height: 120 },
]

ドラッグ時に、上記 JSON のポジションを満たす条件が出た場合に、各レポートの並び替えをして、改めて位置情報 JSON を計算し直す。実際はドラッグしている間に移動先候補の灰色プレビューをどう表示させるのがユーザーの操作に違和感を与えないか、工夫が必要になる。

`mousemove` 時のイベント
  ドラッグしているレポートを移動させる
  それぞれのレポート位置に対して {
    もし対象のレポート枠内に入っていたら {
      移動先候補の灰色プレビューを移動させる
      レポートの場所を再度計算
    }
  }

そして、ドロップした時点で、移動を確定させる。

`mouseup` 時のイベント
  移動候補先のところに、ドラッグしていたレポートを移動
  移動先候補の灰色プレビューを隠す
  各レポートの位置を API を通じて保存

なぜ独自実装が必要か?

「こういうのはどっかのライブラリ使えば良いっしょ」って考える方は多いかと思う。しかし、このドラッグ&ドロップによるカスタムレポート(ダッシュボード) は、サービスに応じて柔軟に実装を最適化する必要があるため、ライブラリでなんとかならない部分が必ず出てくる。従ってライブラリでなんとかならない部分だけ、ライブラリを拡張した実装をするという判断になることだろう。その判断をする前に気づいて欲しい。自前でゼロから実装した方が速く、そして無駄なコードを読んだり管理したりする必要がなくなるのである。

パッと簡単に実装できるような JavaScript コードでないことは、間違いない。でも上記デモ動画のケースでは200行ほどの JavaScript で実装できる。試行錯誤の果てに200行だけ管理すれば良いコンパクトなソースコードができあがる。しかも、自社 SaaS に最適な形式でね。

終わりに

今回は SaaS の開発で必要になるケースが多いであろう、カスタムダッシュボード(レポート) の実装例について紹介した。こうした動的なHTML要素のカスタマイズってのは JavaScript 実装の腕の見せ所だ。ぜひそれぞれの SaaS に最適な形式で、ダッシュボード(レポート) が作成できるようになって欲しいなと思う。

私はこうして自社の SaaS をより良いものに改善し続けている。

Cache API を利用したフロントエンドキャッシュ

ども、@kimihom です。

f:id:cevid_cpp:20190904153717j:plain

今回は Cache API を使う機会があったので、調査結果と利用のユースケースと共に紹介しよう。サーバーサイドをどんなに高速化するよりも、フロントエンドで そもそもリクエストをさせない 仕組みにすれば、それが最も速いというのを改めて感じた。

Service Worker と Cache API

「Service Worker を使えば、オフラインでもアクセスできるようになる」っていうのがよく聞く話だろう。これを実現するには、Cache API と Service Worker の2つを組み合わせることで実現できる。Service Worker を使うと、ページリクエスト時のイベントを横取りすることができるから、それが実現できる。詳しくは以下の Qiita が参考になる。

ServiceWorkerとCache APIを使ってオフラインでも動くWebアプリを作る - Qiita

もちろん Service Worker にはそんな利点もあるけども、普通に PC 向けサービスだったら別にそれが重要になるようなケースってのは少ない。

Web API の一つとして存在する Cache API。今までの私のイメージだと、「Service Worker とペアで利用するもの」だった。しかし、それは下記ページの 1節目 でものの見事に否定されていた。

developer.mozilla.org

このAPIはService Worker スペックで定義されていますが、service workerに結びつけて使う必要はありません。

純粋に JavaScript 上でキャッシュを保存したり取得、削除ができるというわけである。これは、とりわけ SPA のような JavaScrpt 中心のページ構成である場合には特に有益に利用できる。今回 Cache API を利用して実装した点について次章で紹介する。

無限スクロールでのスクロール位置の保存

InfiniteScroll を利用して、データを無限スクロールで表示させるケースはよくあることだろう。Twitter や Facebook などで誰もが当たり前のように無限スクロールを使いこなしている。そして、無限スクロール内でリンクを出すケースはよくある。

そこでいきなり問題が発生する。リンクをクリックして別ページに移り、そして「戻る」ボタンを押すと前のスクロール位置に戻らずにページトップに戻ってしまう。だから続きを読みたいって場合にはまたスクロールして前の位置まで戻る必要がある。

大抵の場合この問題解決の「逃げ」として、リンクを新しいタブで開くって方法がある。なるほど、こうしてブラウザには大量のタブができていくわけだ。タブを閉じてスクロールするページに戻れば、続きから読み進めることができる。この問題を最も簡単に解決できる方法だ。

だけど、このスクロール内のリンクが外部リンクではなく内部リンクで、複数ページが開かれると都合が悪いケースがある。その時は無限スクロール内をクリックされたら、普通にページ遷移して「戻る」でスクロール位置を復帰させる必要が出てくる。

スクロール位置の保存

ということで Cache API を利用して実装してみる。

Cache への保存

キャッシュに保存するのは「無限スクロール内のリンクをクリックした時だけ」となる。そのタイミングで、無限スクロール内に表示されたコンテンツを丸々キャッシュとして保存する。

caches.open(CACHE_VERSON).then(function(cache) {
  var response = new Response(
    $("#contents").html(),
    {headers: new Headers({
      "scroll": $(window).scrollTop()
    })}
  );
  cache.put('/records', response)
});

/records ページ全体として、スクロールした結果をキャッシュに保存するのが大事な点だ。page=3 などのパラメータは含めない。そして、Response を自前で作って、そこに headers に スクロール位置を保存する。

ちなみに、ちゃんと保存されたかどうかは Chrome Developer Console で確認できる。滅多に使わないであろう Applicationタブに表示される。

f:id:cevid_cpp:20190904152834p:plain

キャッシュの読み込み

では「戻る」を押した時にそのキャッシュを読み込めるようにしよう。戻るを押した時に普通のページ遷移では下記は動作しないので気をつけてほしい。これは、History API を使っている前提の話である。もし History API を使わずにって話になると、Service Worker の出番である。

caches.open(CACHE_VERSON).then(function(cache) {
  cache.match('/records').then(function(res) {
    if (res) {
      res.text().then(function(txt) {
        $("#contents").html(txt);
        $(window).scrollTop(parseInt(res.headers.get("scroll")));
      });
    } else {
      callback(); // http request
    }
  });
});

cache.match() でもしキャッシュがあれば、その内容を読み込んで HTML 描画する。そしてヘッダに保存したスクロール位置まで持っていく。もしキャッシュがなければ、通常の Ajax でコンテンツを読み込むようにする。

キャッシュの削除

このままだとページ更新したり、そのページへのリンクをクリックするたびにそのスクロール位置となってしまう。それでは都合が悪いことだろう。てことで「戻る」を押した時以外はキャッシュをクリアしておく必要がある。もちろん、「戻る」以外でもキャッシュ表示させたい場合にはキャッシュを消さないようにすれば OK だ。

caches.open(CACHE_VERSON).then(function(cache) {
  cache.delete('/records');
});

キャッシュがなければ、普通に Ajax で取ってくるようになって、めでたし、めでたし。

終わりに

今回は Web API の中でも Cache API に焦点を当てて、実際の利用ケースを含めて紹介した。

私は ServiceWorker, Local Storage, Session Storage, Cache API など、これらすべての API を駆使してフロントエンドアプリケーションを構築している。一見複雑そうに見えるけど、実際に使ってみるとシンプルで使いやすい API だ。

フレームワークではなく純粋な JavaScript だけを見ても、Web API フロントエンドは奥が深い。ぜひこれらのバックグラウンド処理、ストレージを活用してより快適な Web アプリケーションを構築していこう。

その先にあるのは、私たちの理想郷「快適なウェブアプリケーション」である!

React チュートリアルで学んだことと解答例

ども、@kimihom です。

f:id:cevid_cpp:20190812163412j:plain

いよいよ React を学ぶ機会が出てきたので、その経緯と Hello React のログを残しておく。

利用のきっかけ

まず、自前の Web アプリケーションを React で書くってのは今でも too much だと思っている。この点に関しては以前の記事に記している。

www.bokukoko.info

今までは React よりも他に自分にとって関係の深い技術を積極的に学んだきたわけだけども、いよいよ React が必要になるケースが出てきた。それが、 Twilio Flex の出現である。Twilio Flex は、カスタマイズ可能なコンタクトセンタープラットフォームで、今 Twilio が一番力を入れて開発しているプロダクトである。

twilio.kddi-web.com

「カスタマイズ可能」ってことなんだけど、この Twilio Flex をカスタマイズするには、React の技術習得が必要不可欠になる。なるほど、Twilio Flex のような複雑なシステムに機能を効率良く追加・編集するのに、React を選択したってのは理解できる。てことで、Twilio Flex を拡張するために必要な技術ってことで React の勉強を始めたわけである。

React チュートリアルでの学習

React のページに行くと、しっかりとチュートリアルが用意されている。これがとてもわかりやすくて良かった。

ja.reactjs.org

一通りこのページを読み終えると、以下の記述があった。

時間がある場合や、今回身につけた新しいスキルを練習してみたい場合に、あなたが挑戦できる改良のアイデアを以下にリストアップしています。後ろの方ほど難易度が上がります:

  1. 履歴内のそれぞれの着手の位置を (col, row) というフォーマットで表示する。
  2. 着手履歴のリスト中で現在選択されているアイテムをボールドにする。
  3. Board でマス目を並べる部分を、ハードコーディングではなく 2 つのループを使用するように書き換える。
  4. 着手履歴のリストを昇順・降順いずれでも並べかえられるよう、トグルボタンを追加する。
  5. どちらかが勝利した際に、勝利につながった 3 つのマス目をハイライトする。
  6. どちらも勝利しなかった場合、結果が引き分けになったというメッセージを表示する。

これは自分の理解をチェックするためにもやるしかないなってことで、5と6だけやった。本記事の最後にコードを掲載している。

React を初めて学んでみて、特徴的だと感じたことを以下メモとして記す。

リフトアップの概念

チュートリアルを読むと、最初は Square(9つ四角の一つ一つ)でそれぞれデータを管理するプログラムだが、各 Square の状態を管理するために Square の状態管理を Board の状態管理へ移し、そのあと履歴を管理するために Game の状態管理 へと移っていっている。 State のリフトアップ というらしく、これが React において重要な概念であると感じた。

複数の子要素からデータを集めたい、または 2 つの子コンポーネントに互いにやりとりさせたいと思った場合は、代わりに親コンポーネント内で共有の state を宣言する必要があります。親コンポーネントは props を使うことで子に情報を返すことができます。こうすることで、子コンポーネントが兄弟同士、あるいは親との間で常に同期されるようになります。

イミュータブル の意識

今までの JavaScript コーディングであまり気にしてこなかったんだけど、イミュータブル(新しいデータのコピーで古いデータを置き換える) ってのが大事になるようだ。以下の記述がある。

ミュータブル (mutable) なオブジェクトは中身が直接書き換えられるため、変更があったかどうかの検出が困難です。ミュータブルなオブジェクト変更の検出のためには、以前のコピーと比較してオブジェクトツリーの全体を走査する必要があります。 イミュータブルなオブジェクトでの変更の検出はとても簡単です。参照しているイミュータブルなオブジェクトが前と別のものであれば、変更があったということです。

なるほど、今回のチュートリアルの履歴の管理は、まさにイミュータブルに管理したからこそ実現できた機能であることがわかる。打ったマスの状態を配列で管理することで、戻ったり進んだりすることが可能になっている。 データを直接編集するメソッドではなく、戻り値で変わった値が返ってくるメソッドを使う意識を持とう。

リストの key 指定

リストを React で表示するときに、key の指定が大事になるらしい。パフォーマンスの視点からとのことなので、忘れないようにしよう。

        <li key={move}>
          <button onClick={() => this.jumpTo(move)}>{desc}</button>
        </li>

改良のアイデア の補足

本記事の最後に掲載したコードには、 5 と 6 の改良のアイディアが実装されているので、ちょっとだけ解説する。

  1. どちらかが勝利した際に、勝利につながった 3 つのマス目をハイライトする。
  2. どちらも勝利しなかった場合、結果が引き分けになったというメッセージを表示する。

まずハイライトってことで、最終的に Square の CSS の背景色を yellow にすることにした。その時点で、Square に勝ちの Square か、通常の Square かを分ける必要がある。てことで、props に isWin を追加した。

  renderSquare(i) {
    return <Square
      value={this.props.squares[i]}
      isWin={this.props.winner && this.props.winner.line.includes(i)}
      onClick={() => this.props.onClick(i)}
    />;
  }

winner には勝った プレイヤー X or O がデフォルトで入っていたんだけど、これだと どの Square が勝ちのマスだかわからないので、winner をハッシュ形式に変えた。

before: winner = "X"
after: winner = {winner: "X", line: [0,1,2]}

Winner: ${winner.winner} ってコードになっちゃって微妙だけど、ひとまず これで勝った時の line だけスタイルを変えることができるようになった。より詳細はコードを読んでいただければと思う。

引き分け表示が一番最後の問題で一番難しいってことなんだけど、そうでもなかった。単に winner がまだいなくて かつ history が最後だったら表示ってだけで実装できた。

    if (winner) {
      status = `Winner: ${winner.winner}`;
    } else if (history.length === current.squares.length + 1) {
      status = "Draw";
    }

終わりに

Hello React!

ひとまず入門ってことで概要を理解することができた。こうやってブログに書くことで、自分の理解をより強固なものにできる。もし自分の変えたコードを「こうしたらもっとよくできる」みたいなアドバイスがあれば、ぜひ教えていただけたら幸いだ。

引き続き React 学習を進めていくので、新しいことを理解したらブログに記そう。

チュートリアル の ソースコード

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';

// 関数コンポーネント
// render メソッドだけを有して自分の state を持たない。
// Sqare は Board に制御されたコンポーネント
// この場合よりシンプルに実装できる。引数に props が出てくる
function Square(props) {
  return (
    <button
      className="square"
      onClick={props.onClick}
      style={{backgroundColor: props.isWin ? 'yellow' : 'white'}}
    >
      {props.value}
    </button>
  );
}

// props, state の2つは予約語
// props は react dom に記された attribute. 読み込み専用!
// state は constructor で定義されたインスタンス変数. 
// state の更新
//  - setState を呼び出す. 
//  - 値はマージされる
//
// setState で現在の state, props 値を使う場合は関数化する
// NG
// this.setState({
//  counter: this.state.counter + this.props.increment,
// });
// OK
// this.setState((state, props) => ({
//  counter: state.counter + props.increment
// }));

class Board extends React.Component {
  renderSquare(i) {
    return <Square 
      value={this.props.squares[i]}
      isWin={this.props.winner && this.props.winner.line.includes(i)}
      onClick={(e) => {this.handleClick(i, e)}}
      //onClick={this.handleClick.bind(this, i)}
    />;
  }

  handleClick(i, e) {
    e.preventDefault(); // NG: return false;
    this.props.onClick(i);
  }

  render() {
    return (
      <div>
        <div className="board-row">
          {this.renderSquare(0)}
          {this.renderSquare(1)}
          {this.renderSquare(2)}
        </div>
        <div className="board-row">
          {this.renderSquare(3)}
          {this.renderSquare(4)}
          {this.renderSquare(5)}
        </div>
        <div className="board-row">
          {this.renderSquare(6)}
          {this.renderSquare(7)}
          {this.renderSquare(8)}
        </div>
      </div>
    );
  }
}

class Game extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      history: [{ squares: Array(9).fill(null), }],
      stepNumber: 0,
      xIsNext: true,
    };
  }

  handleClick(i) {
    // state.squares のコピーを `slice` で作成
    // イミュータブルにすることで 履歴の操作、変更検出の利点
    const history = this.state.history.slice(0, this.state.stepNumber + 1);
    const current = history[history.length - 1];
    const squares = current.squares.slice();
    // 決着がついている or マスが埋まっている
    if (calculateWinner(squares) || squares[i]) return;
    squares[i] = this.state.xIsNext ? 'O' : 'X';
    this.setState({
      // push ではなく concat. イミュータブル
      history: history.concat([{ squares: squares }]),
      stepNumber: history.length,
      xIsNext: !this.state.xIsNext,
    });
  }

  jumpTo(step) {
    this.setState({
      stepNumber: step,
      xIsNext: (step % 2 ) === 0,
    });
  }

  render() {
    const history = this.state.history;
    const current = history[this.state.stepNumber];
    const winner = calculateWinner(current.squares);
    let status = `Next player ${this.state.xIsNext ? 'X' : 'O'}`;
    if (winner) {
      status = `Winner: ${winner.winner}`;
    } else if (history.length === current.squares.length + 1) {
      status = "Draw";
    }

    const moves = history.map((step, move) => {
      const desc = move ? `Go to move #${move}` : 'Go to game start';
      return (
        <li key={move}>
          <button onClick={() => this.jumpTo(move)}>{desc}</button>
        </li>
      );
    });

    return (
      <div className="game">
        <div className="game-board">
          <Board 
            squares={current.squares}
            winner={winner}
            onClick={(i) => this.handleClick(i)}
          />
        </div>
        <div className="game-info">
          <div>{status}</div>
          <ol>{moves}</ol>
        </div>
      </div>
    );
  }
}

function calculateWinner(squares) {
  const lines = [
    [0, 1, 2],
    [3, 4, 5],
    [6, 7, 8],
    [0, 3, 6],
    [1, 4, 7],
    [2, 5, 8],
    [0, 4, 8],
    [2, 4, 6],
  ];
  for (let i = 0; i < lines.length; i++) {
    const [a, b, c] = lines[i];
    if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
      return {winner: squares[a], line: lines[i]};
    }
  }
  return null;
}

// ========================================
ReactDOM.render(
  <Game />,
  document.getElementById('root')
);

HTML で半円や扇形のゲージを描画する方法

ども、@kimihom です。

f:id:cevid_cpp:20190511205736j:plain

今回は扇形のゲージを実装したので、その記録を記す。

完成イメージ

先日、応答率シミュレーターというのを公開した。人数や通話時間から、どのくらいの電話対応ができるのかを計算してくれるツールだ。

www.callconnect.jp

この計算結果にある % の表示が今回のテーマだ。以下のようなゲージが表示される。

f:id:cevid_cpp:20190511203126p:plain

CSS でなんとかする

最初、CSS でできるだろうということで設計をしてみた。そして、扇形の表記は CSS で実装することができた。

まず、円を描くには border-radius: 50%; を指定することで実現可能だ。そして widthheight でその円の大きさをよしなに調整することができる。最終的にborder: 1px solid black などと指定すれば、円の線を描くことができるようになる。

続いて、扇形を表示するために、一部のエリアを重ねて非表示にすることで対応した。そのために、円を描いた div を position: absolute にして、同じエリアに <div class="hidezone"> を用意した。

f:id:cevid_cpp:20190511203804p:plain

<div class="result-wrapper">
  <div class="border-wrapper"></div>
  <div class="hidezone"></div>
</div>
.result-wrapper {
  width: 160px;
  position: relative;
  .border-wrapper {
    width: 160px;
    height: 160px;
    position: absolute;
    top: 0;
    border: 4px solid #CCCCCC;
    border-radius: 50%;
  }
  .hidezone {
    position: absolute;
    width: 100%;
    background-color: white;
    top: 108px;
    height: 52px;
  }
}

よしよし、順調に扇形を描くところまではできた。これはいけるな・・・誰もがそう思った次の瞬間。

扇形ゲージ表示

今回の実装では、途中までの線を描く必要がある。50% なら扇形の半分のところまで線を引くという具合だ。この実装を DIV と CSS だけで実現するのは不可能だと判断した。何かしらの描画ツールを利用して、途中までの半円を描く実装をしなければならなくなった。

そこで、今回はこの実装のためにわざわざ canvas を利用して、弧を描くことにした。

<div class="result-wrapper">
  <div class="border-wrapper"></div>
  <canvas class="border-inner"></canvas>
  <div class="hidezone"></div>
</div>
  .border-inner {
    position: absolute;
    top: 0;
  }
    var canvas = $(".border-inner")[0];
    var ctx = canvas.getContext('2d');
    ctx.clearRect(0, 0, 200, 200);  // 以前描画したcanvasがあればクリア。

    ctx.beginPath();
    ctx.lineWidth = 4;
    ctx.strokeStyle = "red";

    // min -200, max 20
    var result = 60;   // 60%の表記
    var startPoint = -200 + 220 * result / 100;
    ctx.arc(80, 80, 78, startPoint * Math.PI / 180, 160 * Math.PI / 180, true);
    ctx.stroke();

とりわけ重要なのが、Canvas の arc メソッドだ。第1,2 引数でまず中心のポイントを指定する。今回は width. height を 160px に固定したので、真ん中はそれぞれ 80 だ。 第3引数は、開始地点となる。0 の場合、これは座標のように中心から真右の方向になる。真左から円の描画を開始したい場合には-180となる。 ここでstartPoint で -200 とかあるのは、今回の円が半円でもなく、ものすごい中途半端な円を描画しているための値となっている。もはやここら辺の数字は楕円なのか、半円なのかとかによって変わってくるので色々変えていただければ幸いだ。

最終的に、canvas を利用することで見事扇型のゲージを表示することができるようになった。めでたしめでたし。

終わりに

実際、これの実装は途中で無理やと思って諦めかけたけど、ちょっと挑戦してみるかってところで canvas 使ったら割とすぐに実現ができた。こういう普段やらない描画の実装ができた時には一つ達成感を感じることができるね。

つまり、 私が描けないスタイルはない。ということである。

Heroku ログと Webhook を使った応用例

ども、@kimihom です。

f:id:cevid_cpp:20180906210024j:plain

先日の Heroku Meetup で、Heroku ログ処理について語ってきた。今回はそれについて語らせてもらう。

speakerdeck.com

Heroku ログ処理の便利さ

実際に自前でサーバー運用していると、当然複数のサーバーがあるわけで、ログはそれら全部のサーバーに吐かれているログを一括管理できる形にしないといけない。ログ収集専用のソフトウェアを各サーバーにインストールしたり、それぞれ定期的に自動で取ってきてどっかに保存するような処理を書いている方もいることだろう。それのインストールや学習、セットアップにどれだけ時間を使わなければならないのだろうか。最終的にやりたいことは、ログを一括で管理して、対象のログが出た時に通知したり、特定のコードを実行させたいといったゴールのはずである。

Heroku の Addon である Papertrail 等を使えば、これらの処理をインストール(heroku addons:add)し、Web 上のインターフェスで簡単に設定するだけで実現できる。手間の排除と、管理が不要になることのメリットは Heroku を利用することの魅力の一つである。

せっかく Heroku を利用するのなら、こうした既に実現された便利なテクノロジーを有効活用すべきだ。自前でサーバー構築して自由自在にできるようになるのと比べて、すぐに使ってすぐに活用できる Heroku のメリットを存分に享受したいところである。

AWS Lambda 上で処理するコード

Heroku じゃなくて Node.js のお話になるけど、補足として以下に記しておく。

本資料に書かれている Node.js のソースコードは、以下のような形となっている。

exports.handler = (event, context, callback) => {
  let body = event.body;
  let log = JSON.parse(require('querystring').parse(body)["payload"]);
  
  log.events.forEach(evt) => {
    // Papertrail 側での文字対応
    let json = JSON.parse(evt.message.replace(/^[^{]*|¥+$/g, ""));

    // Enjoy!
  }
}

ログにマッチして Papertrail から送られてくる Webhook のデータは1行のみとは限らない。例えば、1分間にマッチするログが5件くることもある。そのため、Papertrail からくる Webhook データは配列形式で送られてくることになる。

このサンプルでは、シンプルに forEach を使って回している。このサンプルコードの forEach 内で非同期処理を実装してしまった時点で、AWS Lambda の終了条件である callback を呼ぶことは不可能になる点に注意してほしい。あくまで上記サンプルは、forEach 内で同期処理だけをした場合のサンプルコード である。でもログ処理において、Node.js で非同期じゃない処理だけで終わるなんてケースはほとんどないよね。

てことでもし実践的にやるなら、Promiseasync/awaitAsync などを使う必要がある。Node.js 経験者なら当たり前の話なんだけど、フロントエンドでくらいしか JavaScript を触ったことのない方は確実にはまるので気をつけよう。

例えば、以下のような記事が今回のケースに似ている。 async/awaitを、Array.prototype.forEachで使う際の注意点、という話 - Qiita

上記の記事を読めばわかるとは思うが、forEach での非同期処理ってだけでも Promise や Asnyc/Await だと複雑で読みづらい感じになってしまう。んで、私自身 AWS Lambda x Node.js を使った経験から、ぶっちゃけ今でも Async ライブラリは便利だと思っている。Async ライブラリを使えば、waterfall ってのをつかって配列の関数を定義するだけで実現できてしまう。実際には慣れの問題かもしれないが、コードの読みやすさ的に私は Async が気に入っている。

async.waterfall([
  (next) => {
     // some codes..
     // 第一引数はエラーオブジェクト。以降は次の関数に渡す任意の引数
     next(null, arg1, arg2); 
  }, (arg1, arg2, next) {
    // some codes..
    next();
  }
], (err) => {
  console.log("done!");
  callback(err);
})

終わりに

今回の Meetup では、Heroku を使った新しい活用方法の話だったり、そもそも Heroku の何がいいのかについての話もあった。引き続き、Heroku Meetup は Heroku を使っている方やこれから使おうとする方にとって有益な情報共有の場にしていきたいと思っている。

それでは、また次の Heroku Meetup で。 ciao !