Madogiwa Blog

主に技術系の学習メモに使っていきます。

GitHub Actionsで特定のブランチ以外で実行するときの指定の仕方MEMO

特定のブランチ以外でGitHub Actionsを実行する方法をちょっと迷ったのでMEMO📝

結論からいうと下記です!

on:
  push:
    branches:
      - '**'        # matches every branch
      - '!master'   # excludes master

まずはすべてマッチするようにして、その後に!ブランチ名で特定のブランチだけ除外できるんですね🙌

便利✨

参考

t.co

stackoverflow.com

RuboCopに自分で定義した新しいルールを追加する方法のMEMO🤖

RuboCopは、Rubocop::Cop::Copを継承することで独自のルールを追加することできるようで、 レビューでよくコメントもらう内容等、RuboCopで検知できるようになったら便利だなと思い、 ルールを作成してみたので、そのへんの手順等をMEMOしておきます✍

公式の開発用のDocはこちら📝

docs.rubocop.org

今回作ったもの

今回は下記のようなことを検証するルールを作ってみました🤖

SQLの実行回数削減のため、pluckを使っていたらselectを使うことをサジェストする

# bad
Foo.where(id: Bar.pluck(:foo_id))
# good
Foo.where(id: Bar.select(:foo_id))

where内でLIKEを使っていたらsanitized_sql_likeを使うことをサジェストする

# bad
Foo.where('title LIKE ?', "%#{title}%")
# good
Foo.where('title LIKE ?', "%#{sanitized_sql_like(title)}%")

作ったものは下記のリポジトリにアップしています。

github.com

新しいルールを作るのに必要なこと

新しいルール作ってrubocop実行時に静的解析を行うようにするには下記が必要なようです👀

  • RuboCop::Cop::Copを継承したClassを作る
  • RuboCop::Cop::Copを継承したClassのテストを書く
  • 作成したClassを.rubocop.ymlrequireする

RuboCop::Cop::Copを継承したClassを作る

今回、私が実際に作ったClassは下記のような形です。

下記は、SQLの実行回数削減のため、pluckを使っていたらselectを使うことをサジェストするルールです。

require 'rubocop'

module CustomCops
  # @example
  #   # bad
  #   Foo.where(id: Bar.pluck(:foo_id))
  #
  #   # good
  #   Foo.where(id: Bar.select(:foo_id))
  class UseSelectInWhere < RuboCop::Cop::Cop
    MSG = 'Use select instead of pluck in where to use subqueries to reduce SQL executions.'

    def_node_matcher :where_in_pluck_candidate?, <<~PATTERN
      (send _ :where (:hash (:pair (:sym _) (send _ :pluck (:sym _)) ...)))
    PATTERN

    def on_send(node)
      where_in_pluck_candidate?(node) do
        add_offense(node)
      end
    end

    def autocorrect(node)
      # TODO
    end
  end
end

ちょっと解説すると

  • MSGに設定された値が違反時にメッセージとして表示されるようです。
  • def_node_matcherwhere{ sym: 任意のオブジェクト.pluck}のような形で引数に渡しているか解析するmather where_in_pluck_candidate?を定義しています。※このmatherは抜け漏れがあるかも。。。
  • where_in_pluck_candidate?に合致するようなものがあったらadd_offenseで警告を出しています。
  • 今回はTODOにしてしまっていますが、autocorrectに処理を書くとautocorrectの処理を定義出来ます。

RuboCop::Cop::Copを継承したClassのテストを書く

先程作ったClassのテストは下記のような形で行うようにしました。

RuboCop::RSpec::ExpectOffenseをincludeしてexpect_offenseに、コードとメッセージを渡してあげるような形でテストするようです👀

expect_offense内の記載内容をいい感じに取得する方法がわからなかったので、とりあえずテストを実行して失敗したときの値が、それっぽかったらコピペするような形にしたが、もっといい方法がありそう。。。

require 'rubocop'
require 'rubocop/rspec/support'

RSpec.configure do |config|
  config.include(RuboCop::RSpec::ExpectOffense)
end

describe CustomCops::UseSelectInWhere do
  subject(:cop) { described_class.new }

  it 'raised offense when use `pluck` in `where`.' do
    expect_offense(<<-RUBY)
      Foo.where(id: Bar.pluck(:foo_id))
      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Use select instead of pluck in where to use subqueries to reduce SQL executions.
    RUBY
  end
end

作成したClassを.rubocop.ymlrequireする

.rubocop.ymlで先程作ったclassのファイルをrequireすることでルールを追加します。

require: './cops/use_select_in_where'

rubocop実行時に静的解析されるようになりました🙌

$ be rubocop sample/
sample/use_select_in_where.rb:4:23: C: CustomCops/UseSelectInWhere: Use select instead of pluck in where to use subqueries to reduce SQL executions.
  scope :recent, -> { Foo.where(id: Bar.pluck(:foo_id)) }
                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

おわりに

レビューでよくコメントが入る内容や、プロジェクト独自で拡張したクラスを使う必要がある場合等、rubocopの独自ルールとして定義しておくと、レビュー時の抜け漏れ等が防げて良さそうですね✨

追記: RuboCop Version 1.0で作り方が変わるかも

Version 1.0に向けてCop周りの仕様の見直しが結構入っているようなので、これから作るときは下記を参考にしたほうが良さそう👀

rubocop/v1_upgrade_notes.adoc at master · rubocop-hq/rubocop · GitHub

参考

sinsoku.hatenablog.com

qiita.com

koic.hatenablog.com

webpackで管理してるフロントエンドアプリを最低限のPWA対応(ホームに追加して起動)するときのMEMO🕸📦

最近Webpackで管理してるフロントエンドアプリをPWA対応してAndroid環境でホームに追加して起動できるようにしたので、そのへんの対応まわりをメモしておく✍

👇コードだけみたい人はこちら

github.com

PWA(プログレッシブウェブアプリ)とは

Progressive web apps (PWA) は、新しいウェブ API と伝統的なプログレッシブな拡張戦略を使用して、クロスプラットフォームウェブアプリケーションにネイティブアプリと同様の使い勝手をもたらすウェブアプリのことです。 https://developer.mozilla.org/ja/docs/Web/Progressive_web_apps

ウェブアプリケーションをネイティブアプリっぽく使うための拡張でPWAと呼ぶには技術的に下記の要素を持つことが必要なようです。

  • 安全なコンテキスト (HTTPS)

    (HTTPS / TLS を介して) コンテンツが安全に配信され、安全ではないコンテキストとの通信の可能性が限られているという合理的な確信がある Window、または Worker のことです。多くのウェブ API が安全なコンテキストでのみ利用可能です。 https://developer.mozilla.org/ja/docs/Web/Security/Secure_Contexts

    • 今回はgithub pagesを使っており、https化されているので問題なしと判断しました。
  • サービスワーカー

    ネットワークのリクエストに介在してネットワークの使用可否の状況に基づいて適切な対応を取ったり、サーバー上にあるアセットを更新したりします。プッシュ通知やバックグラウンド同期の API 群へのアクセスもできるようになります。 https://developer.mozilla.org/ja/docs/Web/API/Service_Worker_API

    • 今回はworkbox-swworkbox-webpack-pluginを使ってコードを生成しました。
  • マニフェストファイル

    PWA のマニフェストには、その名前、作者、アイコン、バージョン、説明、および (他のものの中で特に) 必要なすべてのリソースのリストが含まれています。 https://developer.mozilla.org/ja/docs/Web/Manifest

    • 今回はwebpack-pwa-manifestを使ってコードを生成しました

詳しくはMDNのドキュメントをご確認ください

developer.mozilla.org

サービスワーカー用のスクリプトファイルを生成してサービスワーカーを登録

サービスワーカー用のスクリプトファイルの生成には下記のライブラリを使用しました。

まずはこれらをinstallしていきます📦

npm install --save-dev workbox-sw workbox-webpack-plugin

そしてwebpack.config.jsを下記のような感じで修正します。 ※今回はdist配下にservice-worker.jsという名前で出力するようにしています。

const workBoxWebpackPlugin = require("workbox-webpack-plugin");
const OUTPUT_PATH = `${__dirname}/dist`;

module.exports = {
  // ... 省略
  plugins: [
    // PWA用のservice-worker.jsを生成
    new workBoxWebpackPlugin.GenerateSW({ swDest: OUTPUT_PATH + "/service-worker.js" }),

この状態でwebpackを実行すると下記のような形でservice-worker.jsと関連ファイルが生成されます✨

$ ls dist/
index-35ccbfc3e6ee670c355d.css                 service-worker.js
index-35ccbfc3e6ee670c355d.js                  service-worker.js.map
index.html                                     workbox-64f1e998.js
workbox-64f1e998.js.map

そしてサービスワーカーを登録するためには生成されたjsを読み込むようにentryのjsで下記を実行するようにします。

if ("serviceWorker" in navigator) {
  window.addEventListener("load", () => {
    navigator.serviceWorker.register("./service-worker.js");
  });
}

これでサービスワーカーの準備が出来ました🙆‍♂️

マニフェストファイルを生成する

マニフェストファイルの生成にはwebpack-pwa-manifestを使用しました。

www.npmjs.com

まずはinstallします📦

npm install --save-dev webpack-pwa-manifest

そしてwebpack.config.jsを下記のような感じで修正します。

const workBoxWebpackPlugin = require("workbox-webpack-plugin");
const WebpackPwaManifest = require("webpack-pwa-manifest");
const OUTPUT_PATH = `${__dirname}/dist`;

module.exports = {
  // ... 省略
  plugins: [
    // PWA用のservice-worker.jsを生成
    new workBoxWebpackPlugin.GenerateSW({ swDest: OUTPUT_PATH + "/service-worker.js" }),
    // PWA用のmenifest.jsonを生成
    new WebpackPwaManifest({
      short_name: "short name", // ホーム画面のラベルに表示される名称
      name: "app full name",    // appの名前
      display: "standalone",    // standaloneにするとブラウザのUI要素が削除されてアプリっぽくなる
      start_url: "index.html",  // 開始時に起動するページ
    }),

👇マニフェストファイルの項目の詳細はこちら

www.npmjs.com

この状態でwebpackを実行すると下記のような形でmanifest.jsonが生成されます✨※最初からdigestが付与されていて便利

$ ls dist/
index-35ccbfc3e6ee670c355d.css                 service-worker.js
index-35ccbfc3e6ee670c355d.js                  service-worker.js.map
index.html                                     workbox-64f1e998.js
manifest.ababbbaae2b52862d5cbfb8aefc3af66.json workbox-64f1e998.js.map

こんな感じのmanifest.jsonが生成されます⚙

{
  "name": "app full name",
  "short_name": "short name",
  "orientation": "portrait",
  "display": "standalone",
  "start_url": "index.html"
}
// "orientation": "portrait"は縦の向きを表しているようです。
// > the "portrait" enum represents the portrait orientation
// > https://www.w3.org/TR/screen-orientation/#screenorientation-interface

これでmanifest.jsonも生成され、PWAを最低限使う準備が出来ました🙌

おまけ:iOSの表示を制御する(Not PWA)

iOSではPWAは未対応なのですが、Safariからホームへ追加した際の挙動を制御することができるようです。

htmlのhead内に下記を追加するとホームに追加したときの挙動を制御できるようです👀

<head>
    <meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0,user-scalable=no">
    <!-- URLバー等を消してアプリのように見せる -->
    <meta name="apple-mobile-web-app-capable" content="yes">
    <!-- ステータスバーのデザイン -->
    <meta name="apple-mobile-web-app-status-bar-style" content="black">
    <!-- ホームに追加したときの名前 -->
    <meta name="apple-mobile-web-app-title" content="App Name">
</head>

おわりに

PWA対応、ネイティブアプリに比べると色々と制約が多そう + まだ対応していないものが(特にios)多いので、 これから感がありますが既存のウェブアプリを意外とさくっとアプリっぽく出来ていいですね👍

参考

qiita.com

qiita.com

employment.en-japan.com

Ruby(with ActiveSupport)で指定日が第何、何曜日かを取得するときのMEMO

第2、第4火曜日だけなんかしらの処理を実行したいとき等、指定日が第何火曜日を知りたいケースの場合に、RubyとかActiveSupportにこういう系のメソッドが無いかなと思ったんですが、 曜日判定は出来ても第何曜日の判定を行うようなメソッドは見当たらず、ちょっと迷って色々考えたのでメモしておきます✍

やりかた(with ActiveSupport)

下記のような方針で考えてみました📅

  • 曜日に関してはDate#wdayを使って曜日を表す数字を取得して、hashで文字列を取得
  • 何番目に関しては対象の日付と月初の日付の週数をDate#cweekを使って取得して差分を取得

実際のコードはこちら

def nth_day_of_week(now: Time.current)
  wdays = { 0 => "", 1 => "", 2 => "", 3 => "", 4 => "", 5 => "", 6 => "" }
  # NOTE: 月初と現在が同じ週だった場合に1としたいので、月の最初の週は0とするためcweekから1を引く
  beginning_of_month_cweek = now.beginning_of_month.to_date.cweek - 1
  nth = now.to_date.cweek - beginning_of_month_cweek
  # NOTE: 年跨ぎの場合、nthが上記方針だとマイナスになるケースがあるので、その場合は前週の結果に+1する方針とする
  # 例) "2019/12/31".in_time_zone.to_date.cweek => 1, "2019/12/1".in_time_zone.to_date.cweek => 48
  nth = now.ago(1.week).to_date.cweek - beginning_of_month_cweek if nth.negative?
  return { nth: nth, wday: wdays[now.wday] }
end

nth_day_of_week
# => {:nth=>2, :wday=>"日"}

nth_day_of_week(now: 1.week.ago)
# => {:nth=>1, :wday=>"日"}

考慮漏れとかもあるかもですが、一旦こんな感じで取れそうでした・・・!(考えてみると意外と難しい・・・🤔💦)

追記: 月初の週数を取得せずに週数を取得する

Twitterで教えてもらった方法、月初を取得しないのでシンプル✨

def nth_day_of_week(now: Time.current)
  wdays = { 0 => "", 1 => "", 2 => "", 3 => "", 4 => "", 5 => "", 6 => "" }
  nth =  now.day / 8 + 1
  return { nth: nth, wday: wdays[now.wday] }
end

この辺の考慮は別途必要・・・!(割と大変)※でもこの辺の年末の考え方は、12月が12月として扱いたいとかありそうなので、要件によって変わるかも?

具体例を以下に示す。年初において以下の曜日に該当する場合、その日は新年第1週の日としてではなく、旧年最終週の日として扱う。
* 1月1日金曜日・1月2日土曜日・1月3日日曜日
* 1月1日土曜日・1月2日日曜日
* 1月1日日曜日
同様に、年末において以下の曜日に該当する場合、その日は旧年最終週の日としてではなく、新年第1週の日として扱う。
* 12月31日月曜日
* 12月30日月曜日・12月31日火曜日
* 12月29日月曜日・12月30日火曜日・12月31日水曜日
https://ja.wikipedia.org/wiki/ISO_8601

それにしても年またぎとかの考慮難しい・・・。

参考

shuzo-kino.hateblo.jp

指定された日付が、その月において何週目にあたるのかを計算する Ruby のメソッド · GitHub

webpack + TypeScript + Vue.jsな環境とYouTubeをTikTok風に見れるViewerを作った

自分でwebpack + TypeScript + Vue.jsの環境を作ったので、勉強がてらYouTube Data APIを使ってTikTokぽく見れるViewerを作ってみました📺

動作はこんな感じです👇(デザインはいまいちですが・・・)

f:id:madogiwa0124:20200607115139g:plain

触ってみたい人はこちらから(YouTube Data APIAPI KEYが必要です)

madogiwa0124.github.io

webpackTypeScriptを触ったのはほぼ初めてだったので、自分で1から環境を作って実装してみて色々ハマりどころも多かったのですが、こういうフレームワークに隠蔽されている部分の車輪の再発明をしとくと仕組みとかを理解出来て良いですね。

今回自分で作ったwebpack + TypeScript + Vue.jsの環境はこちらのリポジトリに公開してます。

github.com

普段書いてるのがRubyなので、型を書くの結構めんどくさいなと思ってたのですが、IDEの補完とかnil周りの考慮漏れとかに気付けるのは結構メリットとして大きいのかなと思いました。

特にAPIとかとのインターフェイス部分とかは書いとくと安心感あるなと思いました。

それでは👋

`dist: trusty`を使用するとTravis CIでchromeのversionが古くなる事象の対処法MEMO

下記のような設定ファイルで動かしていたTravis CI上でheadless chrome使うようなgemのテストが急に落ち始めて、テスト通るところまで直したので色々メモして残しておきます✍

---
dist: trusty
language: ruby
cache: bundler
rvm:
@@ -8,13 +7,6 @@ rvm:
  - 2.5.8
addons:
  chrome: stable
  apt:
    packages:
      - chromium-chromedriver
before_install:
  - export TZ=Asia/Tokyo
  - gem install bundler -v 2.1.4
before_script:
  - "export DISPLAY=:99.0"
  - "sh -e /etc/init.d/xvfb start"
  - sleep 3

修正したPRだけみたい場合はこちら

github.com

起こっていた事象

下記のような形でchromeのversionが古いということで急にテストが落ち始めた。

  1) TravelToJavascript locks time in javascript and restore time outside block.
     Failure/Error: session.visit('/')
     
     Selenium::WebDriver::Error::SessionNotCreatedError:
       session not created exception: Chrome version must be >= 67.0.3396.0
         (Driver info: chromedriver=2.41.578700 (2f1ed5f9343c13f73144538f15c00b370eda6706),platform=Linux 4.4.0-101-generic x86_64)

実際にci上でscriptを走らせてchromeのversionを確認してみたところ確かに古いversionのchromeが動いてしまっているようだった。

$ google-chrome --version
Google Chrome 62.0.3202.94

原因

原因はdist: trustyを使用していることのようだった・・・!

Travis CIのコミュニティで同じような状況の人がいないかなと思って検索したら、同様の事象が発生している人がいたのですが、

travis-ci.community

回答を見ると、下記の通りXenialを使用することが推奨されているようだった。

Google is serving a package that can’t be installed with Trusty. Use Xenial or later.

どうやらGoogleが提供しているパッケージがtrustyではinstall出来なくなってしまったみたいです。(失敗してデフォルトの古いchromeが使われてしまった?)

確かに、jobのログを見てみるとinstall時に失敗しているようだった💦

$ export CHROME_SOURCE_URL=https://dl.google.com/dl/linux/direct/google-chrome-stable_current_amd64.deb
Installing Google Chrome stable
0.56s$ wget --no-verbose -O /tmp/$(basename $CHROME_SOURCE_URL) $CHROME_SOURCE_URL
2020-05-23 06:15:49 URL:https://dl.google.com/dl/linux/direct/google-chrome-stable_current_amd64.deb [68678632/68678632] -> "/tmp/google-chrome-stable_current_amd64.deb" [1]
dpkg-deb: error: archive '/tmp/google-chrome-stable_current_amd64.deb' has premature member 'control.tar.xz' before 'control.tar.gz', giving up
dpkg: error processing archive /tmp/google-chrome-stable_current_amd64.deb (--install):
 subprocess dpkg-deb --control returned error exit status 2
Errors were encountered while processing:
 /tmp/google-chrome-stable_current_amd64.deb

対応策

対策では上記のコミュニティの回答どおり.travis.ymlを下記のように修正してXenialを使うようにしました。

---
language: ruby
cache: bundler
rvm:
  - 2.7.1
  - 2.6.6
  - 2.5.8
addons:
  chrome: stable
before_install:
  - export TZ=Asia/Tokyo
  - gem install bundler -v 2.1.4

※dist未指定だとXenialが使用される。https://docs.travis-ci.com/user/reference/overview/#linux

trustyってubuntuのバージョニングのことだったんですね😅(trustyはサポート終了していてSecurity Fix以外は行われないようでした。。。)

また別件で下記のエラーが発生して落ちるようになってしまったので、

     Selenium::WebDriver::Error::UnknownError:
       unknown error: Chrome failed to start: crashed.
         (unknown error: DevToolsActivePort file doesn't exist)
         (The process started from chrome location /usr/bin/google-chrome is no longer running, so ChromeDriver is assuming that Chrome has crashed.)

Capybaraでのdriverの設定を修正しました。Selenium::WebDriver::Remote::Capabilitiesを使ってoptionを指定していたのが良くなかった(?)🤔

before

Capybara.register_driver :headless_chrome do |app|
  driver = Capybara::Selenium::Driver.new(
  Capybara::Selenium::Driver.new(
    app,
    browser: :chrome,
    desired_capabilities: Selenium::WebDriver::Remote::Capabilities.chrome(
      login_prefs: { browser: 'ALL' },
      chrome_options: {
        args: %w[headless disable-gpu window-size=1900,1200 lang=ja no-sandbox disable-dev-shm-usage],
      }
    )
  )
  driver
end

after

Capybara.register_driver :headless_chrome do |app|
  options = Selenium::WebDriver::Chrome::Options.new
  options.add_argument('headless')
  options.add_argument('--disable-gpu')
  options.add_argument('--no-sandbox')
  options.add_argument('--disable-dev-shm-usage')

  Capybara::Selenium::Driver.new(
    app,
    browser: :chrome,
    options: options
  )
end

これらの対応を行ってCIが通るようになりました🍏

参考

qiita.com

qiita.com

ActiveRecord::QueryMethods#whereで発行されるwhereの部分だけのSQLを取得するMEMO

はじめに

下記のような形でActiveRecordが発行するSQLからWHERE句の部分のSQLだけを取得したいケースがあり、色々やり方とか調べたのでMEMO✍

やりたかったこと

今回やりたかったのは、下記のようなscopeを定義していたときにto_sqlするとSQL全体を取得することが出来ますが、

class Book < ApplicationReacord
  scope :viewable, ->(now = Time.current) { where(published_at: now..) }
end

Book.viewable.to_sql
#=> select * from books where published_at > '2020-05-23 15:24:34'

そうではなくWHERE句のpublished_at > '2020-05-23 15:24:34'部分だけ取得したいというものです。

where部分だけのSQLを取得する方法

下記のような感じで取れるみたいです👀(⚠nodocなので動作保証されてないですが・・・!)

Book.viewable.values[:where].ast.to_sql
#=> published_at > '2020-05-23 15:24:34'

values[:where]ActiveRecord::Relation::WhereClauseのオブジェクトを取得することができて、オブジェクトに対してastメソッドを呼び出すことによってArel::Nodes系のオブジェクトを取得できるようです👀

そして取得したオブジェクトにto_sqlを呼び出すことによってそのNodeの部分だけのSQLを取得できるような形で動いてそうな気がしました・・・!

WHEREも含めて取得したいときは下記のようにするととれるようです👀(⚠こちらもnodocなので動作保証されてないです。)

Book.viewable.arel.where_sql
#=> WHERE published_at > '2020-05-23 15:24:34'

ですが、Arel::Nodes::Node#to_sqlはコメントにも無くなる可能性が明記されてるので、素直にSQLをparseした方が良さそうな気もしました😓

      # FIXME: this method should go away.  I don't like people calling
      # to_sql on non-head nodes.  This forces us to walk the AST until we
      # can find a node that has a "relation" member.
      #
      # Maybe we should just use `Table.engine`?  :'(
      def to_sql(engine = Table.engine)
        collector = Arel::Collectors::SQLString.new
        collector = engine.connection.visitor.accept self, collector
        collector.value
      end

rails/node.rb at b1f6d8c8d8ad3e2e5b96e95b455c70f2c895ce14 · rails/rails · GitHub

参考

stackoverflow.com