【読書記録】ALL for SaaS

ALL for SaaS SaaS立ち上げのすべて | 宮田 善孝 | ビジネス・経済 | Kindleストア | Amazon

読書背景

現在業務でSaaS開発に携わっているのですが、ビジネスサイド等と戦略や案件について議論する際に、自分がこれまでの他領域や他職種時代に得たなんとなくの経験やノウハウでやり過ごしていることにモヤモヤしていました。
年末のタイミングに一度スタンダードなSaaSについての考え方を知ろうと思い、本書を手に取りました。

読んだ感想、おすすめ対象

  • 個人的には勉強になることが多く、良書に感じました
  • SaaSに関して考慮すべき観点を薄く広く網羅してある
    • 一見どれも当たり前のようなことだが、それらが広く全てちゃんと言語化されていることに価値がある印象
    • 全てをちゃんとできている人はかなり限られていると思うので、自プロダクトのチェックシート、振り返りとしても使えそうな印象
  • 以下のような各種トピックについては、書籍内でも注釈があるように別の良書が存在
  • freeeのPdMが著者で、freee時代の経験が基に汎用的、再現性があるナレッジに落とし込まれている
    • freeeの具体的な立ち上がりストーリーやエピソード等はあまり記載されていないので、そういったノンフィクションストーリー的な面白さは無い
  • 読むことをおすすめできる人は以下
    • 開発やマーケ、セールス等で通常業務しており、今後より自サービスへの理解や関わるスコープを広くしたいと考えている人
    • あまり経験が無い状態で、SaaSにPdMとしてに関わる人
    • 自己流で頑張ろうとしている、頑張ってきたSaaSスタートアップに関わる人
  • 逆に読むことをおすすめできない人は以下
    • プロダクト志向でないエンジニアやマーケ、セールスの人
    • PdMとして(特にSaaSで)一定の経験がある人
    • 既にプロダクトとして一定程度安定しており、経験豊富なPdMがリードしているSaaSに関わっている人

個人的に参考になったトピック

直接関係する人は少なそうだが、面白いと感じたトピック

  • プライシング
    • ユーザーへの提供価値視点
      • SaaSを利用した際に得られる価値に見合うコストか否か
    • 提供側のコスト視点
      • 立ち上げ、開発、維持費等のコストに利益を載せる
    • 市場、競合視点
      • 競合のサービスと比較した提供価値等の差異
  • プロダクトサイドにおける人事評価設計
    • プロダクトやタスクフォースでの評価
    • 所属部署等での評価
    • 上記の折衷案
  • リーガル対応
    • 業法上の実現可能性、他社知的財産権の侵害可能性等
    • 利用規約、プライバシーポリシー、特許、商標等

その他

  • たまたまfreeeのエンジニアにカジュアルで話す機会がありましたが、組織としてかなり成熟していて、プロダクト開発していて楽しそうな環境に見えました。
  • freeeの技術ブログにも、本書とかなり似た内容がありました。 developers.freee.co.jp

Active Storage導入しました

背景、経緯、目的

業務でRailsを使ってWebサービスを開発しているのですが、
ユーザーの画像設定やファイル添付機能等をシンプルに実施したく、
またRailsのバージョンアップ等のタイミングもあり、 Active Storageの導入を決めました。
(CarrierWave vs Active Storageみたいな議論は本記事では扱いません)
純粋にRailsからアップロードし、AWS S3に保存するような設計です。

環境

主な使用方法

  • 以下コマンド等でテーブルを作成、migrationを実施
rails active_storage:install
rails db:migrate
  • 各種設定 今回は開発環境ではlocal、本番環境等ではAWS S3に保存するイメージです。
# Gemfile
gem 'aws-sdk-s3'
# config/environments/development.rb
config.active_storage.service = :local
# config/environments/production.rb
config.active_storage.service = :amazon
# config/storage.yml
local:
  service: Disk
  root: <%= Rails.root.join("storage") %>
/storage/*
!/storage/.keep

amazon:
  service: S3
  access_key_id: <%= ENV['AWS_S3_ACCESS_KEY_ID'] %>
  secret_access_key: <%= ENV['AWS_S3_SECRET_ACCESS_KEY'] %>
  region: <%= ENV['AWS_S3_REGION'] %>
  bucket: <%= ENV['AWS_S3_BUCKET'] %>
  • 該当Modelに紐付け
# app/models/book.rb
class AdminUser < ApplicationRecord
  # 1対1の場合
  has_one_attached :image
  # 1対複数の場合
  has_many_attached :images
end
  • フォーム等イメージ
-# app/views/books/edit.html.haml
-# 複数の場合 multiple: trueを指定、accept: 'image/jpeg,image/png'等で受付可能なMIMEタイプ指定可能
= f.file_field :files, accept: 
  • 表示イメージ
-# app/views/books/show.html.haml
%dl
  %dt 表紙イメージ
  %dd= image_tag @book.image

Tips

  • attribute代入タイミング

    有名な話なのですが、Rails5.2〜6.0までは、attributeに代入した時点で、
    アップロードしたファイルがストレージに保存されてしまいます。
    以下のようにすることが良くあるかと思いますが、バリデーションエラー時等に、
    そのままファイルが残ってしまうという問題点があります。

@book = Book.new(title: 'サンプル本', image: params[:image])
if @book.save
  # 省略
  redirect_to dashboard_path
else
  # 省略
  render 'edit'
end

Rails6.0以上ではsave後に保存されるようになったので、気にする必要は無いのですが、 それ以前のバージョンでは、以下等の対応が必要になります。

  • バリデーションエラーの場合、purgeを呼び出す
@book.image.purge
errors.add(:image, '指定のファイル形式ではありません。')
  • バリデーション等実施、save後にattachするようにする(別途save前にimageのバリデーションも必要)
if @book.save
  @book.image = params[:image] if params[:image]
else
end
  • purge_later

    ActiveStorageでは、has_one_attachedhas_many_attachedにおいて、
    デフォルトでdependent: :purge_laterとなっており、該当インスタンスが削除された場合、
    Active Jobを介して、関連付けられているモデルと実際のリソースファイルを非同期で破棄します。
    また、キューは以下のように必要に応じて指定が必要です。 (Rails6.1以降はqueues.analysis, queues.purgeに分割して指定することが推奨されています。)

# config/application.rb
config.active_storage.queue = 'default'
  • nginx設定、413エラー等

    個人的に一番ハマったポイントです。 まずデフォルトではnginxのPOST最大サイズは1MBのようです。 必要に応じて、以下のようにclient_max_body_sizeを指定する必要があります。

# script/nginx/nginx-development.conf
server {
  listen 8080;
  server_name  xxx.xxx.xx.xxx;
  root         /app/public;
  client_max_body_size 10m;
}

ただこのままでは、サイズ超過したファイルをアップロード時に、 413エラー画面がnginxのデフォルトの画面になってしまいます。

f:id:dtanakab:20211012173727p:plain
413エラー画面
本件を解決する方法はいくつかあるかと思いますが、Webアプリ側はGET http(s)://{host}/payload_too_largeでエラーページを表示できるようにし、
413エラーを以下のようにリダイレクトを使い、
POSTをGETとし、オリジナルのエラーページを表示できるようにしています。

location / {
  error_page 413 @413;
}

location @413 {
  return 303 https://$host/payload_too_large;
}

これで全て解決と思ったのですが、 ChromeFirefoxでは実際にサイズ超過したファイルをアップロードすると、 数百MBまでは問題ないのですが1GB等を超過すると、以下エラーが出るようになりました。
Safariではどれだけサイズが大きくても、問題なくリダイレクトされる)

f:id:dtanakab:20211004160903p:plain
ERR_CONNECTION_RESETエラー

ChromeFirefoxでは、nginxが413エラーを返した場合も、
アップロード処理を継続してしまうという仕様のようです...
よって、JS等フロントで予め必要以上のサイズのファイルは、
アップロードできないよう制御しておくのが良さそうです。
以下サンプルではファイルの複数アップロードを想定し、
ファイル数およびファイルサイズで制御をかけています。

// book_file_upload.js
document.addEventListener('DOMContentLoaded', function() {
  const lengthLimit = 3
  const sizeLimit = 1024 * 1024 * 10
  const inputTemplate = document.getElementById('book_files');
  var fileLength = 0

  $('.file-uploads').on('change', function(e){
    var files = e.target.files;
    // 添付ファイルボタンのinputは空に戻す
    e.target.files = new DataTransfer().files;
    fileLength += files.length
    // ファイル数バリデーションエラー表示
    if (fileLength > lengthLimit) {
      alert('1つのBookに添付できるファイル数は最大3つです。');
      fileLength -= files.length
      return;
    }

    var error_message = ''
    for (let i = 0; i < files.length; i++) {
      var file = files[i]
      if (file.size > sizeLimit) {
        error_message = 'Bookに添付できる1ファイルの最大サイズは10MBです。';
        continue;
      }
      // アップロード予定ファイルプレビュー表示
      var file_preview = $('<div>', { class: 'file-preview' });
      file_preview.append(`<i class= "fa fa-file"></i>${file.name}`);
      file_preview.append('<span class="btn-delete">削除</span>');
      // ファイルを個別に削除等できるよう分けてinputへ格納
      var dt = new DataTransfer();
      dt.items.add(file);
      var input = inputTemplate.cloneNode(false);
      input.files = dt.files;
      input.id = `book_files_${file.name}`;
      file_preview.append(input);

      $('.uploaded-files').append(file_preview);
    };
    // ファイルサイズバリデーションエラー表示
    if (error_message != '') alert(error_message);
  })

  // アップロードファイルの削除
  $('.uploaded-files').on('click', '.btn-delete', function(){
    fileLength -= 1
    $(this).parent().remove();
  });
});
  • spec

    上記等のバリデーションテスト等で、サイズ上限のバリデーション等を試したいこともあるかと思います。
    ただその為だけに、大きいサイズのファイルを用意はできないケースが多いと思うので、モック等で対応するのが良いかと思います。

let(:images) { [image] }
let(:image) { Rack::Test::UploadedFile.new(Rails.root.join('app', 'assets', 'images', 'sample.png'), 'image/png') }
before do
  allow(image).to receive(:size).and_return(11.megabytes)
end

感想

  • Rails6.0以降改善されているとのことですが、
    やはりsave前のattribute代入でアップロードされてしまうのはやや面倒
  • シンプルなアップロード機能であれば、特に問題は感じませんでした!

参考記事

Draper導入しました

背景、経緯、目的

業務でRailsを使ってWebサービスを開発しているのですが、
一部Modelにおいて、徐々にメソッドが増え肥大化してきたため、チームでDraperの導入を決めました。
以下のようなメソッドをModelからDecoratorへ移せたらいいなというイメージでした。

  • 表示に関するメソッドだが、オブジェクトに依存しているメソッド
  • 多くのページで使用するメソッド
  • 以下例
# app/models/book.rb
# 本の在庫状況を表示
def display_status
  sold_out? ? '売り切れ' : '在庫有り'
end

主な使用方法

  • gemを入れる
gem 'draper'
  • 以下コマンド等で既存モデルに対して、Decoratorファイルを生成
rails generate decorator Book
  • Decorator側に移すメソッド等を記載
# app/decorators/book_decorator.rb
class BookDecorator < Draper::Decorator
  # 本の在庫状況を表示
  def display_status
    sold_out? ? '売り切れ' : '在庫有り'
  end
end
  • delegete_all呼び出しによって、BookModel内のメソッドを利用可能にします。
    以下例のようにsold_out?メソッドがModel内にあっても、Decorator側で利用できます。
    (上記コマンドで生成した場合、初めからdelegete_allは記載されているかも。)
# app/decorators/book_decorator.rb
class BookDecorator < Draper::Decorator
  delegate_all

  # 本の在庫状況を表示
  def display_status
    sold_out? ? '売り切れ' : '在庫有り'
  end
end
  • Controller側からViewにインスタンス変数を渡す直前に.decorateでデコレート
    View側でDecorateファイルに記載したメソッド等が使えます。
# app/controllers/books_controller.rb
@book = Book.first.decorate
-# app/views/books/show.html.haml
%dl
  %dt 在庫状況
  %dd= @book.display_status
  • ActiveRecord_Relationに対しても、まとめてデコレートし、View側で個別にメソッドを使用可能です。
# app/controllers/books_controller.rb
@books = Book.all.decorate
-# app/views/books/index.html.haml
- @books.each do |book|
  %dl
    %dt 在庫状況
    %dd= book.display_status

その他、Tips

  • association

    associationを記載しておくと、関連するModelも合わせてデコレートできる。

# app/decorators/book_decorator.rb
class BookDecorator < Draper::Decorator
  # 対1
  decorates_association :author
  # 対複数
  decorates_associations :comments
end
  • ActiveAdmin

    ActiveAdminを使用した管理画面で、Decoratorメソッドを使用する場合、
    そのままでは使えず、decorate_withで渡してあげる必要があります。

# app/admin/book.rb
ActiveAdmin.register Book do
  decorate_with BookDecorator
end
  • kaminari

    ページネーションでkaminari等を使用している場合、
    ActiveRecord_Relationにメソッドを追加されているので、
    そのままだと、ActiveRecord_Relationをデコレート後には使用できません。
    該当メソッド類をdelegeteして使用できるようにする必要があります。

# app/decorators/collection_decorator.rb
class CollectionDecorator < Draper::CollectionDecorator
  delegate :current_page, :total_pages, :limit_value, :entry_name, :total_count, :offset_value, :last_page?
end

特定のModel等ではなく、多くのModel等で使用する場合、まとめてしまって良いかも。
(各DecoratorはApplicationDecoratorを引き継ぐようにする)

# app/decorators/application_decorator.rb
class ApplicationDecorator < Draper::Decorator
  def self.collection_decorator_class
    CollectionDecorator
  end
end
# app/decorators/book_decorator.rb
class BookDecorator < ApplicationDecorator
end
  • CollectionDecorator

    基本的に使うケースは少ないかつ、あまり推奨されないかと思いますが、
    デコレートした後のCollectionDecoratorに対して、
    どうしてもビュー側でfind_by等を使いたくなるケースもあります。
    CollectionDecoratorにそういったメソッドは用意されていないので、自分で追加する必要があります。

# app/decorators/collection_decorator.rb
class CollectionDecorator < Draper::CollectionDecorator
  def find_by(attribute = {})
    object.find_by(attribute)&.decorate
  end
end
  • spec

    上記のコマンドで生成した場合、specファイル等も合わせて作成されます。
    特に変わった点はないが、当然オブジェクト生成時にデコレートする必要があります。

# spec/decorators/book_decorator_spec.rb
RSpec.describe BookDecorator do
  describe '#display_status' do
    subject { book.display_status }
    let(:book) { build(:book).decorate }
    context '...' do
    end
  end
end

感想

  • お試し的に、associationが複雑でないModel等から導入しましたが、
    インスタンスメソッドが24個→11個になったModel等もあったので、
    当初のFatModelの回避という点では目的を達成できていそう。
  • 1つのModelにインスタンスメソッドが20個以上ある等が無ければ、
    個人で開発する小規模なサービス等ではメリットを感じる場面が少なさそう。
  • 途中から導入すると移行がそれなりに大変なので、
    規模が大きくなるとあらかじめ分かっているサービスであれば、最初から導入したい
    (現在も関連箇所を触る際等に、適宜移行中)

参考記事

  • Draper公式ドキュメント
    READMEが丁寧に書いてあり、かつそんなに文量も多くないので、
    基本的に公式ドキュメントで事足りるかと思います。
    github.com

  • Decoratorの役割とDraperについて
    そもそものDecoratorの役割等について、簡潔に記載されています。 qiita.com

  • Draperの使い方 まとめ
    上記公式ドキュメントの日本語訳的な位置づけかと思います。
    公式ドキュメントに書かれている使用方法は大体書かれています。 nekorails.hatenablog.com