DBを読み取り専用で使うWebアプリケーションを作るならSinatraがお手軽

以前、まだQiitaを愛していた頃に、こんな記事を書いたことがある。

qiita.com

社内向けWebアプリで、機能が限定的だったとしても、SinatraじゃなくてRailsで書いたほうが総合的にラクよ、という話。この考え自体は今もそんなに変わっていなくて、なんだかんだでRailsラクだ。

しかし、最近になって、SinatraでWebアプリケーションを書いてみて「これはうまく行った」という例を1つ見つけた。

不具合解析のアシスタント的なアプリケーションだ。

  • DBはもともとあるものに接続して、そこにある情報を読むだけ。書き込みは行わない。
  • 入力フォームは最低限でいい
  • テストを真面目に書く必要がない

ようするに、手でSQLを叩くよりはマシ、くらいのものを作りたければ割とお手軽ということ。

## application.rb ##
# frozen_string_literal: true

require 'bundler'
Bundler.require :default, (ENV['RACK_ENV'] || :development).to_sym

require_relative './config/activerecord'
require_relative './config/zeitwerk'
require 'sinatra/base'

class Application < Sinatra::Base
  # いろいろ...
end
## config.ru ##
# frozen_string_literal: true

require_relative './application'

run Application
## Gemfile ##
# frozen_string_literal: true

source 'https://rubygems.org'

gem 'mysql2'
gem 'puma'
gem 'rack'
gem 'sinatra', require: false
gem 'sinatra-activerecord'
gem 'zeitwerk'

group :development do
  gem 'pry-byebug'
  gem 'rubocop'
end
## config/zeitwerk.rb ##
# frozen_string_literal: true

loader = Zeitwerk::Loader.new
loader.push_dir('./models')
loader.setup
## config/activerecord.rb ##
# frozen_string_literal: true

# ref: https://hai3.net/blog/active-record-readonly/
module ActiveRecord
  class Base
    def readonly?
      true
    end

    def self.readonly_attributes
      attribute_names
    end

    # Disable annoying STI
    self.inheritance_column = :_xxxxxx
  end
end

ActiveRecord::Base.logger = Logger.new($stdout)
ActiveRecord.verbose_query_logs = true

このくらいの下地を整えてあげれば、modelsにDB参照用に振り切ったモデルを定義して、config/database.ymlをRailsのものをコピってくれば、あとはviewsを必要な分だけ置けばいい。

MVCしたい?

SinatraアプリケーションではModelとViewはあるが、コントローラなんてものはない。

とはいえ、わざわざコントローラ"層"を定義する必要がどこまであるだろう?erbにして、冒頭で必要なパラメータを取ればそれでよくないだろうか?

class Application < Sinatra::Base
  get '/customer_info' do
    erb :"guide/customer_info.html"
  end
end
## views/guide/customer_info.html.erb
<%
  customer_id = params[:customer_id].presence
  customer_name = params[:customer_name].presence

  if customer_id.present?
    @customer = Customer.find(customer_id)
  elsif customer_name.present?
    @customer = Customer.find_by!(name: customer_name)
  end
%>

<form method="GET">
  <p>どれか1こ入れてね。</p>
  <div class="mb-3">
    <label for="input_customer_id" class="form-label">ID</label>
    <input
      id="input_customer_id"
      type="text"
      class="form-control"
      name="customer_id"
      value="<%= customer_id %>" />
  </div>
  <div class="mb-3">
    <label for="input_customer_name" class="form-label">NAME</label>
    <input
      id="input_customer_name"
      type="text"
      class="form-control"
      name="customer_name"
      value="<%= customer_name %>" />
  </div>
  <button type="submit" class="btn btn-primary">Submit</button>
</form>

<% if @customer %>
  <%
    data = [
      ['ID', @customer.id],
      ['NAME', @customer.name],
      ['契約開始日', @customer.contract.started_at],
    ]
  %>
  <table class="table">
    <thead>
      <tr>
        <th scope="col">key</th>
        <th scope="col">value</th>
      </tr>
    </thead>
    <tbody>
      <% data.each do |key, value| %>
      <tr>
        <th scope="row"><%= key %></th>
        <td><%= value %></td>
      </tr>
      <% end %>
    </tbody>
  </table>
<% end %>

まとめ

DBをみて解析が必要なシーンだと、Sinatra+ActiveRecordでWebアプリケーション作ると意外とイイ!

github.com

(コードは、このリポジトリを大いに参考にさせてもらった。)

Fukuoka.rb 第256回でLT登壇するまでの話

Fukuoka.rbのLTで久々に登壇をした。

Fukuoka.rb 0x100 回 LT 大会 (#256) - connpass

まじめなasync/awaitの話をするように見せかけて、ポニョの歌を使った並列処理の勉強会ライブコーディングをするというネタを仕込んでの登壇。「まさかそうくるとは」的な期待通りの反応もあり、ちょっと嬉しかった。

さて、反省記事。

きっかけ

なんかTwitterでFukuoka.rbの256回がLT大会だというのが流れているのが見えた。

LTは過去に何度か社内勉強会でやって毎度時間をはみ出してしまって、非常に苦手という自負はあった。ただ、「まぁFukuoka.rbならなんとかなるやろ」(意味不明w)てきなノリで、とりあえず申し込んでみた。

ようするに、登壇理由はほぼない。久しぶりにちょっと喋りたかっただけ、くらいのノリ。

着想

話すネタは、登壇2日前からようやく考え始めた。

ブラウザの自動操作について話そうかと思ったが、とてもじゃないが5分では何も話しきらない。

ぜんぜん別の方面で、socketry/async というライブラリの話をしようかとも思ったが、これもとてもじゃないがLTで話すようなものではない。

そうこうしているとテーマを決めてねとアナウンスがあったのでとりあえず回答。

そうはいっても、この時点では本当にまだ何もできていなかった。

さて、Async gemを説明するとして、「そもそもなぜAsync gemを説明したいか」というのをまず考える。すると、JSのPromiseやasync/awaitをRubyで表現するときの方法が実はあまりQiitaとかZennとかで記事になっていないし、Threadベースのconcurrent-rubyは順序性の担保が大変だということはほとんど知られていない。という課題に気づく。

こうしてLT登壇テーマは決まった

PuppeteerとかPlaywrightのJSコードには、async/awaitが非常に多く使われていて、RubyRailsで使いたいときにポーティングに困る、という自分の過去の実体験にも紐付けることができて、それなりに良いテーマだなぁと自己納得しつつ、資料を一気に作り上げた。これが登壇前日の夜の出来事w

"音"の着想

さて、資料を夜更しして作って、寝て起きてみたら、自分のLT発表が全く面白くないことに気づいた。

concurrent-rubyとAsync gemそれぞれの使い方を軽く説明し、concurrent-rubyはスレッドベースなので順序が前後しやすい弱点を説明して、軽くデモしておしまい、デモの内容は、sleepとputsだけのコンソールプログラム。

順序性の乱れをputsとsleepで表現するのは、エンジニアならわからなくはないが、聴く側としてはおそらくとてもつまらない。

ここでふと、rukawa(ワークフローエンジン)で音を鳴らしている人がいたなーと思い出す。(思い出すといっても、その発表自体は自分は知らなくて、前職の先輩がエンジニアブログの記事の片隅に書いていたなー、というのを思い出した)

並列処理を音で表現すれば、順序性の乱れが音の乱れとなって聞こえてくるのでは?!

さっそくやってみることにした。

Rubyでどうやって音を鳴らせばいいのか

Googleで検索してみても、音をならすというと、BGMを鳴らす系のものが多く出てくる。知りたいのは、MIDIなのか、音声合成なのか、そういう系のものなのだけど、なかなかキーワードがわからず苦戦した。

いろいろ調べてみた結果、どうもSonic Pi というものがあって、そいつにむかってUDPパケットを投げつけると音が鳴るっぽいことはわかった。

/run-code みたいなやつ。日本語記事もふくめいろいろ出てくるんだけど、トレンドが過ぎているせいか割とみんな情報が古くて(UDPポート番号が違ってる記事が多い)、一番役に立った情報はこれ。

in-thread.sonic-pi.net

require 'osc-ruby'

def play(sound)
  client = OSC::Client.new('localhost', 51235)
  client.send(OSC::Message.new('/run-code', 'DEMO', "play #{sound}"))
end

こんな感じでメソッド定義すれば、 play 60 で「ドー♪」とピアノ音が鳴る。(当然、あらかじめSonic Piを起動しておく必要はある)

音の表現を使ってLT発表する

音で非同期処理を表現できることはわかった。時刻はLT当日の午前10時。平日なので、お仕事の時間になったw

資料は修正が間に合わないので、あきらめてそのまま。LT発表は口頭でフォローしつつ、ライブコーディング形式でいかざるをえなくなった。

順序性の乱れは音で表現できなかった

この時点で、1つ問題に気づいていた。concurrent-rubyの順序性の乱れは音で表現できないということだ。

人間の耳というのはとてもよくできていて、数ミリ秒の音の前後はうまく吸収して和音と認識してしまう。つまり、もともと説明しようとしていたことは無理だということだ。

それならもう、「音で並列処理を表現する」単なるconcurrent-rubyやAsyncの使い方の説明だけにふりきってしまおうと、楽しむ方向性でいくことにした。

音で楽しませつつライブコーディングに困らない選曲

幸いにして、自分はもともとピアノ弾きなので、任意の曲を音符に起こすことにはそんなに困らない。

最初に作ったのは「かえるのうた」の輪唱。

def kaeru
  Async do |t|
    # かーえーるーのーうーたーがーー
    play(60)
    t.sleep 0.4
    play(62)
    t.sleep 0.4
    play(64)
    t.sleep 0.4
    play(65)
    t.sleep 0.4
    play(64)
    t.sleep 0.4
    play(62)
    t.sleep 0.4
    play(60)
    t.sleep 0.8

    # きーこーえーてーくーるーよーー
    play(64)
    t.sleep 0.4
    play(65)
    t.sleep 0.4
    play(67)
    t.sleep 0.4
    play(69)
    t.sleep 0.4
    play(67)
    t.sleep 0.4
    play(65)
    t.sleep 0.4
    play(64)
    t.sleep 0.8

    # ぐわーx4
    4.times {
      play(60)
      t.sleep 0.8
    }

    # げげげげげげげげぐわぐわぐわ
    play(60)
    t.sleep 0.2
    play(60)
    t.sleep 0.2
    play(62)
    t.sleep 0.2
    play(62)
    t.sleep 0.2
    play(64)
    t.sleep 0.2
    play(64)
    t.sleep 0.2
    play(65)
    t.sleep 0.2
    play(65)
    t.sleep 0.2
    play(64)
    t.sleep 0.4
    play(62)
    t.sleep 0.4
    play(60)
    t.sleep 0.8
  end
end

1メソッドを実行するだけだと当然に単旋律だが、↓こんな感じで並列処理すれば輪唱になる。

Async {
  bar = Async::Barrier.new
  bar.async { kaeru.wait }
  bar.async { |t| t.sleep 3.2 ; kaeru.wait }
  bar.async { |t| t.sleep 6.4 ; kaeru.wait }
  bar.wait

  kaeru.wait
}

が、このデモには1つ問題がある。長い

かえるのうたを全パート鳴らしきるのに30秒くらいかかるのだ。LT時間は5分しかないので却下。

なんとなくジブリ系の音楽を片っ端から想像して、最終的に採用したのが、ポニョのあれ。「ポーニョポーニョポニョさかなのこ」は2〜3秒くらいだけど、聞いた瞬間からそれとわかる。和音で奏でることもできる。

ベースパートは、Rubyらしくループで

def base_async
  Async do |t|
    8.times do
      play(53)
      t.sleep 0.2
    end
    4.times do
      play(58)
      t.sleep 0.2
    end
    play(53)
  end
end

主旋律と副旋律はてきとうにこんな感じで

def main_async
  Async do |t|
    play(72)
    t.sleep 0.4
    play(69)
    t.sleep 0.2
    play(65)
    t.sleep 0.4
    play(60)
    t.sleep 0.2
    play(60)
    t.sleep 0.2
    play(60)
    t.sleep 0.2
    play(62)
    t.sleep 0.2
    play(65)
    t.sleep 0.2
    play(70)
    t.sleep 0.2
    play(74)
    t.sleep 0.2
    play(72)
    t.sleep 0.4
  end
end

def sub1_async
  Async do |t|
    play(69)
    t.sleep 0.4
    play(65)
    t.sleep 0.2
    play(65)
    t.sleep 0.4
    play(60)
    t.sleep 0.2
    play(57)
    t.sleep 0.2
    play(60)
    t.sleep 0.2
    play(58)
    t.sleep 0.2
    play(62)
    t.sleep 0.2
    play(58)
    t.sleep 0.2
    play(62)
    t.sleep 0.2
    play(65)
  end
end

全部重ねあわせると、いいかんじのポニョになる。

ライブコーディングでいちいちこのsleepとかplayは書いてられないので、切り貼りできるようにgistに上げておいた。

fukuoka.rb 0x100 LT demo · GitHub

ZOOMで音を届けるための準備

LT当日16:50、仕事を早めに切り上げて(というか、本当に体調が悪くて早退して)ZOOMでポニョを鳴らすためのいろいろを調べた。

画面共有の設定でかんたんにできることはわかったのだが、ぶっつけ本番はこわいので自分のスマホとPCでZOOMを実際に通話させて実験。この予行練習は本当にやっておいて正解で、「音がならない場合にはSonic Piをとりあえず再起動すればよい」というのは予行練習で発見したバッドノウハウだ。

ZOOMで音を届けるための設定は↓ここにノウハウをまとめたので、本記事では書かない。 https://zenn.dev/yusukeiwaki/articles/eb13bda3462b0f#%E3%82%AA%E3%83%B3%E3%83%A9%E3%82%A4%E3%83%B3%E9%96%8B%E5%82%AC%E3%81%AE%E5%8B%89%E5%BC%B7%E4%BC%9A%E3%81%A7%E9%9F%B3%E3%82%92%E9%B3%B4%E3%82%89%E3%81%99

まさか音を届けるとは思わないだろう、作戦

登壇タイトルはいかにも真面目そうなasync/awaitの説明に見えるので、LTだしギャップを楽しんでもらいたい。そんなわけで資料はあえて事前公開して、まさか音を鳴らすデモがあるとは発表時までわからないようにしてみた。

当日のTwitterの反応を見るに、そこそこ反響はあったのかなと想像。(自己満足?w)

ふりかえり

  • 資料を作るのが直前だった割にはどうにかなった。"音"は偉大。
  • プライベートPCをリプレイスしたばかりでKeynote入れてなかったのでGoogle Slideで作ったが、LT発表くらいのコンテンツであればちょうど使いやすかった
  • LT苦手と自覚しておきながら、5分でおさめる努力が足りなさすぎた
    • オンライン登壇ならやっぱり事前練習を2〜3回やらないといかんね...

rakudaでWebアプリケーションのHTTPクライアントを"段階的に"作る

まえおき

最近、会社でつかっている勤怠入力システムが刷新されて、イケイケなSPAっぽいものになった。

最近のSPAは、ReactとかVueとかで作っているならだいたい、裏でJSON APIを叩いて結果をレンダリングするみたいな作りをしている。ということは、自分用にWebアプリケーションのふりをして必要な機能だけ詰め込んだコンソールアプリケーションを作ることもできる。

そんなときに、「curlやHTTPieでJSON APIの挙動を調べてGoやDartコマンドラインツールを作る」といったプロセスを経ることが多いが、それって結構非効率じゃないだろうか?

動作確認の際に、いちいちAuthorizationヘッダーを付けるのがめんどくさい!となってrakudaというものを作ってみたわけだが。

yusukeiwaki.hatenablog.com

ただそれでも、デバッグ用にHTTPクライアントを作って、さらにコマンドラインツール本体向けにHTTPクライアントを作って、というのはあまりに非効率じゃないだろうか。

rakudaのデバック用HTTPクライアントでまずは挙動を探って(=scaffolding)、HTTPリクエストすべき内容がわかったところで、その部分だけ足場を解体して(段階的に)本実装をする、みたいなのがなんとなく理想の開発プロセスに思える。そして rakuda をそういうものにしてみた。

rakudaは何がラクじゃなかったのか

rakudaはもともと「dart createして、baseURLとinterceptorさえ実装すれば、あとはコマンドライン引数を渡すだけでイイカンジのデバッグ用HTTPクライアントが作れる」ライブラリであった。

// bin/androidmanagement.dart

Future<Response> auth(PerformRequest performRequest, Request request) async {
  final response = await performRequest(request);
  // 認証ヘッダーを付ける処理など
  return response;
}


Future<void> main(List<String> arguments) async {
    await createJSONClient(
      arguments,
      baseURL: 'https://androidmanagement.googleapis.com/v1',
      interceptors: [auth],
    );
  }
}

コマンドライン引数を渡すと通信結果が勝手にprintされるので、たとえば「ログインAPIは仕様がつかめたので本実装しよう」とする。

// bin/androidmanagement.dart

Future<Response> auth(PerformRequest performRequest, Request request) async {
  final response = await performRequest(request);
  // 認証ヘッダーを付ける処理など
  return response;
}


Future<void> main(List<String> arguments) async {
    if (arguments[0] == 'login') {
      // ログイン処理.
      const  response = await http.post('/login', body: { username: await prompt(), password: await prompt() })
      if (reponse. statusCode == 200) {
        // 成功したら認証トークンの保存
      } 
    }

    await createJSONClient(
      arguments,
      baseURL: 'https://androidmanagement.googleapis.com/v1',
      interceptors: [auth],
    );
  }
}

Dartのhttpやdioなどを使うことになるだろう。そして、そこでもまたhttpやdio向けの認証トークンの保存処理だったり認証アクセス用のinterceptorだったりを書くことになる。

ようするに、rakuda向けに作ったauthインターセプターだったり、その内部で使っている認証トークンの保存処理などが再利用できない

改善してみた

コマンドライン引数を渡すと、あとはいい感じに通信して結果をプリントしてくれる」という特性がよくないことはわかった。

これを「コマンドライン引数を渡すと、いい感じにHTTPリクエストを生成してくれる」部分と「HTTPリクエストを渡すと素直にHTTPレスポンスを返してくれる」部分とに分けることで、再利用できない問題を解決できそうである。

Request(HTTPリクエスト内容を表すもの)とRequestContext(リクエストを実行する環境)とに分けて考えることで、一部のリクエストはデバッグ用途でコマンドライン引数から受け取って実行し、一部のリクエストはクライアントを本実装したものから実行する、interceptorsは共通実装、という構成にできる。

いやぁ、素直になった。うん。(自己満足)


まとめ

「最初は挙動を探って、挙動がつかめたやつから本実装する」というプロセスにマッチするような便利なやつを作った。みんなSPAのフリをするコマンドラインツールみたいなの作りたくなったら、ぜひ使ってみてね。

github.com

ちなみに、同じ改善をいれたJS版 (ライブラリ名は @zatsu/core) もある。

github.com