Web備忘録

雑多に書いています

「Clean Architecture 達人に学ぶソフトウェアの構造と設計」を読んだ

感想

仕事でアプリケーションの設計や、すでに出来上がってるアプリケーションのリプレイス業務などに携わっているので、アーキテクチャの参考にすべく読んだ。

感想としては、率直に面白かった。すべての内容を理解できたわけではないが、今自分が現実で戦っている問題に直接的につながってる所もあり、(SOLID原則あたり)、興味深く読めた。表題の「クリーン」だったり「達人に学ぶ」だったりが若干おそろしくて今まで手をつけられてなかったけれど、内容としては読みやすい部類だと思う(平易ではない)。

アプリケーションの開発が進むにつれ、ある部分の変更点がどこまで影響を及ぼすのか見通しがつかなくなったり、テストしたい部分があまりに巨大になったり、といったことはよくあることだと思うけれど、そういった問題点に対する取り組み方を教えてくれる内容、といった印象だった。

具体的なコードがそこまで多く記述されているわけではないが、コンポーネント間の依存関係の説明のさいは図が多くてありがたかった。

また、フレームワークの選定だったり、使用するRDBMSの選定を遅らせる開発手法について紹介していたけれど、訳者あとがきでも合ったとおり、それはあまり現実的ではなさそうだとは思った。そこは詳細・具体で、原則的に依存は避けるべきものではあるものの、依存しても問題ない箇所ではないかと思う。なぜなら、現実ではそれらが「変更されること」がとても少ないからだ。

Railsで書かれたアプリを、Sinatraに移す、なんてことはしないと思う。現実的には、Railsで苦しんだ開発者は、言語もアーキテクチャーもなにもかも刷新する「フルリプレイス」を選択することが多そうだ。ただ、Railsから少しずつマイクロサービスに移行するというケースはありそうなので、(できれば)それらは考慮した設計の方がよさそうという観点もあるので、簡単に断言はできない。

一つ言えるのは「設計論」は現実の問題を解決するための手法なので、現実の方にフォーカスし続けなければ無意味になるということ(これは本書でも似たようなことが言われていた)、あくまでも武器の一つとして、本書の内容を覚えておきたい。

メモ

印象に残った部分と、その部分に対する感想をメモ。

第1部

2章

ソフトウェア開発者のジレンマは、ビジネスマネージャーがアーキテクチャの重要性を評価できないことである。 ソフトウェア開発チームには、機能の緊急性よりもアーキテクチャの重要性を強く主張する責任が求められる。

ビジネスにおいて一番価値としてわかりやすいのは機能だけれども、ソフトウェア開発者としては、運用まで見込んで、機能追加・変更・拡張について門戸が開いているようなアプリケーションを作る重要性については話すべきだなあ、と上記の文を読んで思った。

たしか、この本のどこかに書いて合った言葉(どこか忘れた)で、「作るのは簡単だ、動かし続けるのが難しい」みたいな言葉があったとは思うが、自分も賛成である。特に動いているように見せるのは簡単だ。しかしアプリケーションの本質的な価値は、まず動かし続けることから生まれると思っている。

第2部

6章

●構造化プログラミングは、直接的な制御の移行に規律を課すものである。 ●オブジェクト指向プログラミングは、間接的な制御の移行に規律を課すものである。 ●関数型プログラミングは、代入に規律を課すものである。 これら3つのパラダイムは、我々から何かを奪っている。 (中略) 過去半世紀にかけて我々が学んだのは、何をすべきではないかである。

VScodeをアップデートする方法

(Mac用)

VScodeを開き、左上のメニューバーより

「Code」→「Check for Updates..(上から2つ目ぐらい)」

で出来ます。

ただし、以下のエラー文により阻まれる場合があります。

Cannot update while running on a read-only volume. 
The application is on a read-only volume. Please move the application and try again. 
If you're on macOS Sierra or later, you'll need to move the application out of the Downloads directory. See https://github.com/Squirrel/Squirrel.Mac/issues/182 for more information.

エラーの内容を一言で言うと「読み取り専用の場所からアプリケーションが起動されているので、アプリケーションの場所を移動してもう一度起動し直せ」ということのようです。

Finderなどのディレクトリ管理ツールを使い、VScode を保存している場所を、別の場所に移しましょう。

自分の場合は、ダウンロードフォルダからアプリケーションフォルダに移せば問題なくアップデートできました。

Vueで画像をリサイズしてプレビュー表示&Base64形式でバックエンドへ送信する(canvasAPI, Base64利用)

あらすじ

fujiten3.hatenablog.com

の続き。

この記事でやること

以前↑の記事にて、簡易的な画像のプレビュー機能 + 送信機能を実装しました。

その内容を一言でいうと、クライアントサイドで画像をBase64形式で符号化し、文字列として取り扱う送信フォームを実装するといったものでした。

が、このプレビュー機能では、受け取った画像をそのままブラウザで表示するため、たびたびブラウザの画面いっぱいを占有し、「デカすぎんだろ…」状態になってしまいます。最近のスマートフォンの高性能カメラで撮った画像のサイズは4032x3024といったものになることがあるので、特にその傾向が顕著です。

これを避ける方法はいくつかあります。たとえば、Vueを使用している場合は、Vuetifyというライブラリが提供するv-avatarのsizeプロパティを利用するなどすれば、特に深く考えずともリサイズが可能です。

しかし、この記事ではcanvasAPIを利用して、つまり「HTMLElementである<canvas>タグをJavaScriptで操作すること」によって、フロント側の画像をリサイズする処理を書いていきたいと思います。

というのも、WebAPIsとJavaScriptを利用すれば、特定のライブラリに依存しない処理を書けるので、様々なプロジェクトで使え、汎用性が高いからです。

つまり、タイトルでは「Vueでリサイズ」となっておりますが、実際には「JavaScriptでリサイズする」、といった内容になります。まあ、Vue上で表現しているからタイトル詐欺ではないでしょう。

また、この記事は前記事を読んでいることがある程度前提条件となっておりますので、ご容赦下さい。

動くもの

挙動をGIFで置いておきます。もともとの画像サイズがどんなに大きくても、一定以下のサイズに、縦横の比率を保ちながら縮尺されます。

f:id:fujiten3:20200324214611g:plain

jsfiddle.net

コードの全体像は上のJSFiddleをご参考下さい。

大雑把な処理の流れ

Base64形式で符号化された画像用の文字列を、HTMLImageエレメントのsrc属性に代入する

そのImageエレメントをもとに、canvasAPIでリサイズした画像を描画する。

リサイズ後の画像を、canvasAPIがもともと持っている関数によって、再びBase64形式の文字列にする。

プレビューのためのImageに再代入

Vue側

new Vue({
  el: "#app",
  name: 'ImageUploder',
  data () {
    return {

      // avatarはimageのsrc属性にHTML側のコートでバインドしている。imageのsrc属性には、Base64形式でエンコードされたimage、または直接URLを指定できる。
      avatar: '',
      message: '',
      error: '',

      // 本筋とは関係ないが、初期画像がないと寂しかったのでプロパティの追加
      initialImageUrl: ''
    }
  },
  created () {

    // 本筋とは関係ない初期画像(サンプル画像)
    this.initialImageUrl = 'https://image.shutterstock.com/image-vector/sample-stamp-grunge-texture-vector-600w-1389188336.jpg'
    this.avatar = this.initialImageUrl
  },
  methods: {
    setError (error, text) {
      this.error = (error.response && error.response.data && error.response.data.error) || text
    },

    // この関数の挙動は前記事参照
    getBase64 (file) {
      return new Promise((resolve, reject) => {
        const reader = new FileReader()
        reader.readAsDataURL(file)
        reader.onload = () => resolve(reader.result)
        reader.onerror = error => reject(error)
      })
    },
    onImageChange (e) {
      const images = e.target.files || e.dataTransfer.files
      this.getBase64(images[0])
        .then(image => {

          // canvasに渡すためのImageElementを作成
          const originalImg = new Image()

          // src属性に、もともとの画像のBase64形式符号化済み文字列を代入
          originalImg.src = image

          // Imgの画像読み込みが完了したあとに、処理を実行
          originalImg.onload = () => {

            // creatReseizedCanvasElement関数については下記参照
            const resizedCanvas = this.createResizedCanvasElement(originalImg)
            const resizedBase64 = resizedCanvas.toDataURL(images[0].type)
            this.avatar = resizedBase64
          }
        })
        .catch(error => this.setError(error, '画像のアップロードに失敗しました。'))
    },

    // ImageElementを受け取り、それを元に新たな画像をcanvasに描画し直す関数
    createResizedCanvasElement (originalImg) {
      const originalImgWidth = originalImg.width
      const orifinalImgHeight = originalImg.height

      // resizeWidthAndHeight関数については下記参照
      const [resizedWidth, resizedHeight] = this.resizeWidthAndHeight(originalImgWidth, orifinalImgHeight)
      const canvas = document.createElement('canvas')
      const ctx = canvas.getContext('2d')
      canvas.width = resizedWidth
      canvas.height = resizedHeight

      // drawImage関数の仕様はcanvasAPIのドキュメントを参照下さい
      ctx.drawImage(originalImg, 0, 0, resizedWidth, resizedHeight)
      return canvas
    },

    // 縦横の比率を変えず、定めた大きさを超えないWidthとHeightの値を割り出す関数
    resizeWidthAndHeight (width, height) {

      // 今回は400x400のサイズにしましたが、ここはプロジェクトによって柔軟に変更してよいと思います
      const MAX_WIDTH = 400
      const MAX_HEIGHT = 400

      // 縦と横の比率を保つ
      if (width > height) {
        if (width > MAX_WIDTH) {
          height *= MAX_WIDTH / width
          width = MAX_WIDTH
        }
      } else {
        if (height > MAX_HEIGHT) {
          width *= MAX_HEIGHT / height
          height = MAX_HEIGHT
        }
      }
      return [width, height]
    },

    // 前の記事参照。APIを叩く処理を記述することを意図して作られた関数。
    upload () {
        if (this.avatar && this.avatar !== this.initialImageUrl) {
        /* postで画像を送る処理をここに書く */
        this.message = 'アップロードしました' 
        this.avatar = ''
        this.error = ''
      } else if (!this.avatar) {
        this.error = '画像がありません'
      } else {
        this.error = '変更前の画像はアップロードできません'
      }
    }
  }
})

以上です。

見づらいですね、ただ複雑な処理はしていないので、一つ一つ処理を追っていけば、難しくはないと思います。

「大雑把な処理の流れ」の項目でも記述しましたが、canvasAPIのdrawImage関数(リサイズのための関数)に対して渡す「ImageElement」を作るための処理を書いている、と思って頂ければ問題ありません。

RailsAPIでBase64エンコードされた画像ファイルを受け取り、S3(AWS)に保存する

自分が昔に書いた記事の続編です。

fujiten3.hatenablog.com

クライアント層から送られてきたBase64エンコードされた画像ファイルをRailsで扱う方法の紹介するのがこの記事の目的です。

以前書いた記事にコメントを頂いたので、筆を取りました。(コメントありがとうございます!)

実装の時間を短縮したいなら……

Rubyの有名なアップロード用Gem「CarrierWave」と、そのさいのBase64エンコード・デコードをサポートする「Carrierwave-base64」を利用するのが手っ取り早いと思われます。

自分も利用したことがありますが、ドキュメントに詳細に使い方が書いているので、迷うところはないでしょう、たぶん。

それらのGemを利用し、AWSのS3に画像をアップロードする処理自体は、↓の記事が参考になりそうでした。(自分が書いたわけではありません。)

https://qiita.com/junara/items/1899f23c091bcee3b058#carrierwave%E3%81%AE%E8%A8%AD%E5%AE%9A

しかし、この記事では別のライブラリを使っています

ただ、この記事ではShrineという添付ファイルを操るための軽量なGemと、自前で実装したBase64デコード・エンコードモジュールを使った実装を紹介しています。

その理由は、この記事は、過去の記事の続編で、そして過去の自分がこのGemを使って実装していたためです!

このGemを使った理由は、CarrierWaveを使ったものはよく見かけたので、別のものに挑戦してみたい、みたいなモチベーションだった気がします。(半年以上前なので記憶が曖昧)

多少オレオレ実装の気があります。ただ、Carrierwave-base64といったGemが行っていることの一部は、この記事を読めば理解の助けになるのではないかと思います。

まずは受け取ろう

Avatarクラスというのを使って自分は受け取る処理を書きました。

class Avatar < ApplicationRecord
  # Shirne用のメソッド
  include ImageUploader::Attachment.new(:image)

  # 自前実装のモジュール
  include ImageEncodable

  belongs_to :user

end
module Api
  module V1
    class AvatarsController < ApplicationController

      # ...

      def create
        # @avatarという、ユーザーの画像を司るクラスのインスタンスを作る
        @avatar = current_user.avatar

        # Base64で送られてきた画像をデコードする(モジュールの説明はのちにします)
        image_file = ImageEncodable.decode_to_imagefile(avatar_params[:image])

        # イメージを格納して、あとはShrineに任せる
        @avatar.image = image_file
        @avatar.save!
        render json: @avatar
      end

     def show
        # ImageEncodableモジュールのおかげで、Avatarクラスのインスタンスが、自分をBase64でエンコードすることが出来る。
        @avatar = @user.avatar.encode(:icon)
        render json: @avatar
     end


      private

        def avatar_params
          params.require(:avatar).permit(:image)
        end
  
end

Shrineが担保するImageUploader(詳細は公式ドキュメントへGo)

require "image_processing/mini_magick"

class ImageUploader < Shrine
  plugin :processing # allows hooking into promoting
  plugin :versions   # enable Shrine to handle a hash of files
  plugin :delete_raw # delete processed files after uploading
  plugin :validation_helpers

  process(:store) do |io, context|
    versions = { original: io } # retain original


    io.download do |original|
      pipeline = ImageProcessing::MiniMagick.source(original)
      versions[:icon]  = pipeline.resize_to_limit!(200, 200)
      versions[:medium] = pipeline.resize_to_limit!(800, 800)
    end

    Attacher.validate do
      validate_max_size 5 * 1024 * 1024, message: "5MBを超える画像はアップロードできません。"
    end

    versions # return the hash of processed files
  end
end

自前実装のモジュール

module ImageEncodable

  # S3に格納している画像データをエンコードし、クライアント層に返すためのメソッド
  def encode(image_size_symbol)
    encoded_image = Base64.strict_encode64(open(avatar_url(image_size_symbol)).read)
    prefix = "data:image/png;base64,"
    prefix + encoded_image
  end


  # Base64の画像をデコードする
  def self.decode_to_imagefile(prefix_encoded_image)

    # Base64の形式を判断し、画像であればそれに合わせた処理
    meta_data = prefix_encoded_image.match(/data:(image|application)\/(.{3,});base64,(.*)/)
    content_type = meta_data[2]
    encoded_image = meta_data[3]
    if content_type == "jpeg" || content_type == "png"

      # Rubyが担保するデコードを行う。StringIOクラスを利用し、ファイルとして扱えるように。
      decoded_image = Base64.strict_decode64(encoded_image)
      image_file = StringIO.new(decoded_image)
    else
      # raise error
    end
  end

  private

    def avatar_url(image_size_symbol)
      if image.try(:[], image_size_symbol)
        if Rails.env.development?
          url = "public" + url 
        else
          url = image[image_size_symbol].url
        end
        url
      else
        "public/default.png"
      end
    end
    
end

以上です。

昔に書いたコードの、不必要そうな処理を除外してベタ貼りしております。

基本的な処理の流れは、クライアント層から送られてきたBase64データをデコードしてファイルにしてS3に保存、クライアントに返すときはエンコードして返答、という流れです。

しかしですね、今思えば、どう考えても、わざわざ画像をS3に保存するときにデコードする必要がないな、と思います。

そのまま文字列で放り込んどいた方が扱いやすいですよね。

まあ、あくまでこんな実装方法もあるよ、という参考にしていただければ幸いです。

Ruby on Railsで分間100万リクエストを捌くコードの書き方

Rails効率化に関する記事を読んだので、ポイントだけまとめておこうと思います。

engineering.shopify.com

ActiveRecordのパフォーマンス改善

SQLが実行されるタイミングを知る

たとえば下のようなコードは、すぐにクエリが発行されてしまう。

post.comments << user.comments.build

#    (0.1ms)  begin transaction
#  Comment Create (0.4ms)
#  INSERT INTO "comments" ("created_at", "updated_at", "post_id", "user_id")
#  VALUES (?, ?, ?, ?)  [["created_at", "2019-07-11 22:16:43.818858"], ["updated_at", "2019-07-11 22:16:43.818858"], ["post_id", 1], ["user_id", 2]]

予期せぬクエリ発行はパフォーマンス低下につながるので、クエリ発行のタイミングにはいつも注意しよう。

いつ発行されるか知るためには、経験を積んで、あとドキュメントとかいっぱい読もう、とのこと。

出来るだけ少なく選択する

Blog.select(:id)
#   Blog Load (0.2ms)  SELECT "blogs"."id" FROM "blogs" LIMIT
# => #<ActiveRecord::Relation [#<Blog id: 1>, #<Blog id: 2>, #<Blog id: 3>, #<Blog id: 4>, ...]>

Blog.pluck(:id)
#   (1.2ms)  SELECT "blogs"."id" FROM "blogs"
# => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]

このようにidだけほしいならselectよりpluck。ActiveRecordオブジェクトを作るコストが減る。

クエリキャッシュを忘れる

Railsは一つのリクエストで同じクエリを発行するとき、その内容を自動的にキャッシュする機能がある。

Blog Load (0.2ms)  SELECT "blogs".* FROM "blogs" WHERE "blogs"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
CACHE Blog Load (0.0ms)  SELECT "blogs".* FROM "blogs" WHERE "blogs"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
CACHE Blog Load (0.0ms)  SELECT "blogs".* FROM "blogs" WHERE "blogs"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]

でも、これは一つのリクエストの話。リクエストをまたがってクエリを出したとしたら、この機能はべつに効率的に働かないので頼りすぎないこと。

インデックスのないカラムにSQLを打つことを避ける

インデックスなしのカラムを検索しようとすると、「フルテーブルスキャン(前表スキャン)」になって遅い。

だからよく検索で使うカラムにはインデックスをつけるのが大事。ただ、「ALTER TABLE」で後からインデックスを付けようとすると、何百万レコードとあるテーブルだった場合、半端ではない遅さになるそう。あとDBがロックされてその間書き込みできないらしい(このへんは自分のDBへの知識不足で、詳細不明)

なので俺達のチームでは自作Gemを使ってるぜ! とのことです。

https://github.com/shopify/lhm

Railsのパフォーマンス改善

全てをキャッシュする

Rals.cache.fetchメソッドを使うことで、「キーとバリューの組み合わせ」を、キャッシュとして保存できる(たぶんRedisとかに)。

もう一度fetchすれば、クエリを発行せずにキャッシュからデータを引っ張ってこれる。

expires_inで、有効期限の設定も可能。

Rails.cache.fetch("plan_names") do
  Subscription::Plan.all.map(&:name)
end

Rails.cache.fetch("blog_#{blog.id}_posts_#{posts.max(:updated_at)}") do
  blog.posts.as_json
end

Rails.cache.fetch("comment_count", expires_in: 5.minutes) do
  Comment.approved.count
end

これ、うまく使えばめちゃくちゃ効率的だなあ、と思った。クエリ発行しないから、「データの厳密性が必要なとき」は、使うべきじゃないけど、それを差し引いても有用に見えるからどんどん使ってみたい。

ボトルネックを調整する

キャッシュできないものに対する対策。

rack-attackというGemを使うことで、Railsに辿り着く前に、特定のIPによる連続リクエストを拒否する。

Rack::Attack.throttle("limit login by IP", limit: 10, period: 15.minutes) do |request|
  if request.path == "/admin/sign_in" && request.post?
    request.ip
  end
end

Railsに辿り着く前に拒否できるから、パフォーマンスよし、とのこと。

(Jobを使って)後で実行する

重い処理はJobで切り出して、他のプロセスに処理を委譲しようぜ! とのこと。

依存関係をダイエットする

Gemを入れすぎると、依存関係がやばくなるから注意。Dependabot入れると、依存パッケージがバージョンアップすると、プルリクでお知らせしてくれるから便利だよ、とのこと。

Rubyのパフォーマンス改善

メタプログラミングは少しだけ使う

メタプロ(動的なメソッド定義など)は遅いから自分でやるときは、本当に必要なときだけにしようぜとのこと。

O(1)とO(n)の差を知る

serializers = [UserSerializer]
serializers.find do |serializer|
  serializer.accepts?(object.class)
end

serializers = { User => UserSerializer }
serializers[object.class]

上の例のように、O(1)にしたほうが効率いいから、そう設計していこうぜとのこと。

割りあて(Allocation)は少なく

Rubyではよく「!」がついた類の破壊的メソッドは危険だから、あんまり使うなって言われるけど、使い方しだいでは、効率化につながる。

arr = %i(a a b c)

#これより
new_arr = arr.uniq

#こっちのが効率いい
arr.uniq!

既存のオブジェクトを破壊して、新しいオブジェクトを作るほうが、割当てが減って効率がいい、とのこと。もちろん、破壊的変更は使い方を間違えると危ないので注意とのこと。

なるほどねえ。。使っていきたい。

間接化は最小限に

No code is faster than no code.

(ノーコードに優るコードはない)

間接的に複雑な処理を行えば行うほど、パフォーマンスは落ちてしまう。

即座に最適化には結びつかないけど、リファクタリングに大事な考えだから、覚えておくほうがいいとのこと。

最後に

ソフトウェア開発にはトレードオフがいっぱい。速さがいつも「開発で一番大事にしないといけないこと」ではない。

Shopifyでは「速さは機能の一つ」としか考えてなくて、それは「ユーザー体験の向上」と、「サーバー請求額の減額」にはつながるけど、開発者の幸福より優先されて取り組むことではないと思っている。

楽しんで早くしていこうぜ!

感想

Railsのキャッシュとかマジで全く知らなかったから、チャンスがあれば使ってみたいなあ、と思った。(コナミ

あとは、Rubyでの破壊的変更の利用とかも、意識すればすぐに実務で使っていけそうだから、これも覚えておきたい。