Upgrade to Pro — share decks privately, control downloads, hide ads and more …

実践!HanamiでCleanArchitecutre

kawada
June 15, 2021

 実践!HanamiでCleanArchitecutre

Fusic Tech Live vol2 「Rubyistの集い」での発表資料です。

kawada

June 15, 2021
Tweet

More Decks by kawada

Other Decks in Programming

Transcript

  1. ⾃⼰紹介 藤澤 亮平 - ID - Github︓fujisawaryohei - Twitter︓@potaku_dev -

    Work at - 株式会社 Fusic (フュージック) 第⼆技術部⾨ 所属 - ソフトウェアエンジニア - Skill - Rails/Vue.js/Nuxt.js/TypeScript/ AWS SAA - SNS - Github︓fujisawaryohei - Twitter︓@potaku_dev - Qiita︓fujisawaryohei 2
  2. Clean Architecure The main purpose of this architecture is to

    enforce a separation of concerns between the core of our product and the delivery mechanisms. The first is expressed by the set of use cases that our product implements, while the latter are interfaces to make these features available to the outside world. このHanamiのアーキテクチャの目的は、製品のコアロジック の関心とその製品を配信するメカニズムの関心の分離を強化 することです。前者は実装する一連のユースケースによって 表現され、後者はこれらの機能を外部の世界で利用できるよ うにするためのインターフェースです。
  3. # Hanamiのプロジェクト作成 ▶ bundle exec hanami new project_name # hanamiのモデル作成

    ▶ bundle exec hanami g model model_name # controlleとaction⽣成 ▶ bundle exec hanami g action web controller_name#action_name # マイグレーション (HANAMI_ENV=testを先頭につける事でテスト⽤DBを実⾏できます) ▶ bundle exec hanami hanami db prepare # テストコード実⾏ ▶ bundle exec hanami exec rake Hanami コマンド⼀覧
  4. Entity # /lib/bookshelf/entities/book.rb # Table name: books # # id

    :bigint not null, primary key # author :string not null # title :string not null # created_at :datetime not null # updated_at :datetime not null class Book < Hanami::Entity def total_price self.unit_price * 1.08 end end
  5. Repository # /lib/bookshelf/repositories/book_repository.rb class BookRepository < Hanami::Repository # create(data) –

    Create a record for the given data (or entity) # update(id, data) – Update the record corresponding to the given id by setting the given data (or entity) # delete(id) – Delete the record corresponding to the given id # all - Fetch all the entities from the relation # find - Fetch an entity from the relation by primary key # first - Fetch the first entity from the relation # last - Fetch the last entity from the relation # clear - Delete all the records from the relation end
  6. Repository 実装例 # ORMやSQLの使用が可能 class ArticleRepository < Hanami::Repository def most_recent_by_author(author,

    limit: 8) articles.where(author_id: author.id) .order(:published_at) .limit(limit) end end article_repository = ArticleRepository.new article_repository.most_recent_by_author
  7. Repositoryアンチパターン # ダメなケース(例: interactor層でRepositoryを下記のように呼び出す場合) class MostRecentByAuthorGetter attr_reader :repository def call(params)

    article_repository = ArticleRepository.new article_repository.where(author_id: params[:author][:id]) .order(:published_at) .limit(8) end end
  8. Repositoryの呼び出し⽅ # 詳細の実装はRepositoryに隠蔽する事で呼び出し元のInteractorに # DIして使用できるようになる。 class MostRecentByAuthorGetter attr_reader :repository def

    initialize(repository) @repository = repository end def call(params) repository.most_recent_by_author(params[:id]) end End article_repository = ArticleRepository.new MostRecentByAuthorGetter.new(article_repository).call(params[:id])
  9. Interactor # /lib/bookshelf/interactors/book_interactors/add_book.rb require 'hanami/interactor' module BookInteractor class AddBook include

    Hanami::Interactor attr_reader :params attr_reader :repository expose :book def initialize(repository = BookRepository.new) @repository = repository end def call(params) @book = repository.create(Book.new(params)) end private # 参考: https://www.rubydoc.info/gems/hanami-utils/Hanami/Interactor/LegacyInterface # valid?はHanami::Interactorをincludeするとcall呼び出し前に実行するフックメソッド # valid? を実行する # valid? の戻り値が真値であれば、クラスに定義された #call を実行する # valid? の戻り地が偽値であれば、何もしない # Hanami::Interactor::ResultクラスはExposeで指定したキーをインスタンス変数とGetterとして持つ # callでHanami::Interactor::Result のインスタンスを返す def valid?(params) validate_result = Validations::Create.new(params).validate if validate_result.failure? error(validate_result.messages) end validate_result.success? end end end
  10. Interactor # /lib/bookshelf/interactors/book_interactors/add_book.rb require 'hanami/interactor' module BookInteractor class AddBook include

    Hanami::Interactor attr_reader :params attr_reader :repository expose :book def initialize(repository = BookRepository.new) @repository = repository end def call(params) @book = repository.create(Book.new(params)) end private # 参考: https://www.rubydoc.info/gems/hanami-utils/Hanami/Interactor/LegacyInterface # valid?はHanami::Interactorをincludeするとcall呼び出し前に実行するフックメソッド # valid? を実行する # valid? の戻り値が真値であれば、クラスに定義された #call を実行する # valid? の戻り地が偽値であれば、何もしない # Hanami::Interactor::ResultクラスはExposeで指定したキーをインスタンス変数とGetterとして持つ # callでHanami::Interactor::Result のインスタンスを返す def valid?(params) validate_result = Validations::Create.new(params).validate if validate_result.failure? error(validate_result.messages) end validate_result.success? end end end
  11. Interactor バリデーション成功時 # /lib/bookshelf/interactors/book_interactors/add_book.rb require 'hanami/interactor' module BookInteractor class AddBook

    include Hanami::Interactor attr_reader :params attr_reader :repository expose :book def initialize(repository = BookRepository.new) @repository = repository end def call(params) @book = repository.create(Book.new(params)) end private # 参考: https://www.rubydoc.info/gems/hanami-utils/Hanami/Interactor/LegacyInterface # valid?はHanami::Interactorをincludeするとcall呼び出し前に実行するフックメソッド # valid? を実行する # valid? の戻り値が真値であれば、クラスに定義された #call を実行する # valid? の戻り地が偽値であれば、何もしない # Hanami::Interactor::ResultクラスはExposeで指定したキーをインスタンス変数とGetterとして持つ # callでHanami::Interactor::Result のインスタンスを返す def valid?(params) validate_result = Validations::Create.new(params).validate if validate_result.failure? error(validate_result.messages) end validate_result.success? end end end
  12. Interactor バリデーション成功時 # /lib/bookshelf/interactors/book_interactors/add_book.rb require 'hanami/interactor' module BookInteractor class AddBook

    include Hanami::Interactor attr_reader :params attr_reader :repository expose :book def initialize(repository = BookRepository.new) @repository = repository end def call(params) @book = repository.create(Book.new(params)) end private # 参考: https://www.rubydoc.info/gems/hanami-utils/Hanami/Interactor/LegacyInterface # valid?はHanami::Interactorをincludeするとcall呼び出し前に実行するフックメソッド # valid? を実行する # valid? の戻り値が真値であれば、クラスに定義された #call を実行する # valid? の戻り地が偽値であれば、何もしない # Hanami::Interactor::ResultクラスはExposeで指定したキーをインスタンス変数とGetterとして持つ # callでHanami::Interactor::Result のインスタンスを返す def valid?(params) validate_result = Validations::Create.new(params).validate if validate_result.failure? error(validate_result.messages) end validate_result.success? end end end
  13. Interactor バリデーション失敗時 # /lib/bookshelf/interactors/book_interactors/add_book.rb require 'hanami/interactor' module BookInteractor class AddBook

    include Hanami::Interactor attr_reader :params attr_reader :repository expose :book def initialize(repository = BookRepository.new) @repository = repository end def call(params) @book = repository.create(Book.new(params)) end private # 参考: https://www.rubydoc.info/gems/hanami-utils/Hanami/Interactor/LegacyInterface # valid?はHanami::Interactorをincludeするとcall呼び出し前に実行するフックメソッド # valid? を実行する # valid? の戻り値が真値であれば、クラスに定義された #call を実行する # valid? の戻り地が偽値であれば、何もしない # Hanami::Interactor::ResultクラスはExposeで指定したキーをインスタンス変数とGetterとして持つ # callでHanami::Interactor::Result のインスタンスを返す def valid?(params) validate_result = Validations::Create.new(params).validate if validate_result.failure? error(validate_result.messages) end validate_result.success? end end end
  14. Validation # /lib/bookshelf/interactors/book_interactor/validation/create.rb require 'hanami/validations' module BookInteractor::Validations class Create include

    Hanami::Validations validations do required(:title).filled(:str?) required(:author).filled(:str?) required(:unit_price).filled(:int?) end end end
  15. Interactorのテスト # /lib/bookshelf/interactors/book_interactors/add_book.rb RSpec.describe BookInteractor::AddBook, type: :interactor do let(:book) {

    Book.new(params) } let(:repository) { double('BookRepository', create: book)} let(:interactor) { described_class.new(repository) } context 'with valid params' do let(:params) { Hash[ { title: 'TDD', author: 'Kent Beck', unit_price: 3500 } ]} it 'create a book' do interactor_result = interactor.call(params) expect(interactor_result.book.title).to eq params[:title] expect(interactor_result.book.author).to eq params[:author] expect(interactor_result.book.unit_price).to eq params[:unit_price] end # 永続化しているかどうかのテストはBookRepositoryが # createメソッドを呼んでいるかどうかをテストする context 'persistence' do # instance_double は 未定義のインスタンスメソッドをスタブした際にエラーを吐いてくれる let(:repository) { instance_double('BookRepository') } it 'persist a book' do expect(repository).to receive(:create) BookInteractor::AddBook.new(repository).call(params) end end end context 'with invalid params' do let(:params) { Hash[title: '', author: '', unit_price: -1]} it 'create a book' do interactor_result = interactor.call(params) expect(interactor_result.errors.first).not_to be_nil expect(interactor_result.errors.first[:title]).to eq ["must be filled"] expect(interactor_result.errors.first[:author]).to eq ["must be filled"] end end end
  16. Controller # /apps/web/controllers/books/create.rb module Web::Controllers::Books class Create include Web::Action attr_reader

    :interactor before { |params| cast_unit_price(params) } expose :book, :errors def initialize(interactor = BookInteractor::AddBook.new) @interactor = interactor end def call(params) interactor_result = interactor.call(params[:book]) if interactor_result.success? @book = interactor_result.book redirect_to '/books’ else @errors = interactor_result.error_messages self.status = 422 end end private def cast_unit_price(params) return unless params[:book].key?(:unit_price) unit_price = params[:book][:unit_price] params[:book][:unit_price] = unit_price.to_i unless unit_price.empty? end end end
  17. Controller # /apps/web/controllers/books/create.rb module Web::Controllers::Books class Create include Web::Action attr_reader

    :interactor before { |params| cast_unit_price(params) } expose :book, :errors def initialize(interactor = BookInteractor::AddBook.new) @interactor = interactor end def call(params) interactor_result = interactor.call(params[:book]) if interactor_result.success? @book = interactor_result.book redirect_to '/books’ else @errors = interactor_result.error_messages self.status = 422 end end private def cast_unit_price(params) return unless params[:book].key?(:unit_price) unit_price = params[:book][:unit_price] params[:book][:unit_price] = unit_price.to_i unless unit_price.empty? end end end
  18. Controller # /apps/web/controllers/books/create.rb module Web::Controllers::Books class Create include Web::Action attr_reader

    :interactor before { |params| cast_unit_price(params) } expose :book, :errors def initialize(interactor = BookInteractor::AddBook.new) @interactor = interactor end def call(params) interactor_result = interactor.call(params[:book]) if interactor_result.success? @book = interactor_result.book redirect_to '/books’ else @errors = interactor_result.error_messages self.status = 422 end end private def cast_unit_price(params) return unless params[:book].key?(:unit_price) unit_price = params[:book][:unit_price] params[:book][:unit_price] = unit_price.to_i unless unit_price.empty? end end end
  19. Controller # /apps/web/controllers/books/create.rb module Web::Controllers::Books class Create include Web::Action attr_reader

    :interactor before { |params| cast_unit_price(params) } expose :book, :errors def initialize(interactor = BookInteractor::AddBook.new) @interactor = interactor end def call(params) interactor_result = interactor.call(params[:book]) if interactor_result.success? @book = interactor_result.book redirect_to '/books’ else @errors = interactor_result.error_messages self.status = 422 end end private def cast_unit_price(params) return unless params[:book].key?(:unit_price) unit_price = params[:book][:unit_price] params[:book][:unit_price] = unit_price.to_i unless unit_price.empty? end end end
  20. Controller # /apps/web/controllers/books/create.rb module Web::Controllers::Books class Create include Web::Action attr_reader

    :interactor before { |params| cast_unit_price(params) } expose :book, :errors def initialize(interactor = BookInteractor::AddBook.new) @interactor = interactor end def call(params) interactor_result = interactor.call(params[:book]) if interactor_result.success? @book = interactor_result.book redirect_to '/books’ else @errors = interactor_result.error_messages self.status = 422 end end private def cast_unit_price(params) return unless params[:book].key?(:unit_price) unit_price = params[:book][:unit_price] params[:book][:unit_price] = unit_price.to_i unless unit_price.empty? end end end
  21. Controllerのテスト # /spec/web/controllers/books/crete_spec.rb RSpec.describe Web::Controllers::Books::Create, type: :action do let(:book) {

    Book.new(title: 'Confident Ruby', author: 'Avdi Grimm', unit_price: '3500') } let(:repository) { double('BookRepository', create: book) } let(:interactor) { BookInteractor::AddBook.new(repository) } # スタブしたInteractorをControllerにDI let(:action) { described_class.new(interactor) } context 'with valid params' do let(:params) { Hash[book: { title: 'Confident Ruby', author: 'Avdi Grimm', unit_price: '3500' }] } it 'create a book' do response = action.call(params) expect(action.book.title).to eq params[:book][:title] expect(action.book.author).to eq params[:book][:author] expect(action.book.unit_price).to eq params[:book][:unit_price].to_i expect(action.exposures[:book]).to eq Book.new(params[:book]) end it 'redirect the user to the books listing' do response = action.call(params) expect(response[0]).to eq 302 expect(response[1]['Location']).to eq('/books’) end end context 'with invalid params' do let(:params){ Hash[book:{ hoge: 'fuga' }] } it 'returns HTTP Client error' do response = action.call(params) expect(response[0]).to eq(422) expect(action.errors).not_to be_nil end end end
  22. Controllerのテスト # /spec/web/controllers/books/crete_spec.rb RSpec.describe Web::Controllers::Books::Create, type: :action do let(:book) {

    Book.new(title: 'Confident Ruby', author: 'Avdi Grimm', unit_price: '3500') } let(:repository) { double('BookRepository', create: book) } let(:interactor) { BookInteractor::AddBook.new(repository) } let(:action) { described_class.new(interactor) } context 'with valid params' do let(:params) { Hash[book: { title: 'Confident Ruby', author: 'Avdi Grimm', unit_price: '3500' }] } it 'create a book' do response = action.call(params) expect(action.book.title).to eq params[:book][:title] expect(action.book.author).to eq params[:book][:author] expect(action.book.unit_price).to eq params[:book][:unit_price].to_i expect(action.exposures[:book]).to eq Book.new(params[:book]) end it 'redirect the user to the books listing' do response = action.call(params) expect(response[0]).to eq 302 expect(response[1]['Location']).to eq('/books’) end end context 'with invalid params' do let(:params){ Hash[book:{ hoge: 'fuga' }] } it 'returns HTTP Client error' do response = action.call(params) expect(response[0]).to eq(422) expect(action.errors).not_to be_nil end end end
  23. ViewとTemplate require 'hanami/view' module Articles class Index include Hanami::View end

    class AtomIndex < Index format :atom end end Hanami::View.configure do root 'app/templates' end Hanami::View.load! articles = ArticleRepository.new.all Articles::Index.render(format: :html, articles: articles) # => This will use Articles::Index # and "articles/index.html.erb" Articles::Index.render(format: :atom, articles: articles) # => This will use Articles::AtomIndex # and "articles/index.atom.erb" Articles::Index.render(format: :xml, articles: articles) # => This will raise a Hanami::View::MissingTemplateError
  24. ViewとTemplate(登録失敗時: 登録フォーム画⾯) # apps/web/template/books/new.html.erb <h2>Add book</h2> <% unless error_messages.empty? %>

    <div class="errors"> <h3>There was a problem with your submission</h3> <ul> <% error_messages.each do |message| %> <li><%= message %></li> <% end %> </ul> </div> <% end %> <%= form_for :book, '/books' do div class: 'input' do label :title text_field :title end div class: 'input' do label :author text_field :author end div class: 'input' do label :unit_price number_field :unit_price end div class: 'controls' do submit 'Create Book’ end end %> # apps/web/views/books/create.rb module Web::Views::Books class Create include Web::View template 'books/new’ def error_messages error_messages = [] errors.each do |error| error_messages = error.map{|k, v| error_format(k, v) } end error_messages end private def error_format(key, values) "#{key} #{values.join(",")}" end end end
  25. ViewとTemplate(登録失敗時: 登録フォーム画⾯) # apps/web/template/books/new.html.erb <h2>Add book</h2> <% unless error_messages.empty? %>

    <div class="errors"> <h3>There was a problem with your submission</h3> <ul> <% error_messages.each do |message| %> <li><%= message %></li> <% end %> </ul> </div> <% end %> <%= form_for :book, '/books' do div class: 'input' do label :title text_field :title end div class: 'input' do label :author text_field :author end div class: 'input' do label :unit_price number_field :unit_price end div class: 'controls' do submit 'Create Book’ end end %> # apps/web/views/books/create.rb module Web::Views::Books class Create include Web::View template 'books/new’ def error_messages error_messages = [] errors.each do |error| error_messages = error.map{|k, v| error_format(k, v) } end error_messages end private def error_format(key, values) "#{key} #{values.join(",")}" end end end
  26. ViewとTemplate(登録失敗時: 登録フォーム画⾯) # apps/web/template/books/new.html.erb <h2>Add book</h2> <% unless error_messages.empty? %>

    <div class="errors"> <h3>There was a problem with your submission</h3> <ul> <% error_messages.each do |message| %> <li><%= message %></li> <% end %> </ul> </div> <% end %> <%= form_for :book, '/books' do div class: 'input' do label :title text_field :title end div class: 'input' do label :author text_field :author end div class: 'input' do label :unit_price number_field :unit_price end div class: 'controls' do submit 'Create Book’ end end %> # apps/web/views/books/create.rb module Web::Views::Books class Create include Web::View template 'books/new’ def error_messages error_messages = [] errors.each do |error| error_messages = error.map{|k, v| error_format(k, v) } end error_messages end private def error_format(key, values) "#{key} #{values.join(",")}" end end end
  27. ViewとTemplate(登録成功時: 本⼀覧画⾯) # apps/web/template/books/index.html.erb <h2>All books</h2> <% if books.any? %>

    <div id="books"> <% books.each do |book_presenter| %> <div class="book"> <h2><%= book_presenter.title %></h2> <p><%= book_presenter.author %></p> <p><%= book_presenter.total_price %></p> </div> <% end %> </div> <% else %> <p class="placeholder">There are no books yet.</p> <% end %> <a href="/books/new">New book</a> # apps/web/views/books/index.rb module Web::Views::Books class Index include Web::View # SystemStackError (stack level too deep). # def books # books.map{|book| BookPresenter.new(book) } # end def books locals[:books].map{|book| BookPresenter.new(book) } end end end
  28. ViewとTemplate(登録成功時: 本⼀覧画⾯) # apps/web/template/books/index.html.erb <h2>All books</h2> <% if books.any? %>

    <div id="books"> <% books.each do |book_presenter| %> <div class="book"> <h2><%= book_presenter.title %></h2> <p><%= book_presenter.author %></p> <p><%= book_presenter.total_price %></p> </div> <% end %> </div> <% else %> <p class="placeholder">There are no books yet.</p> <% end %> <a href="/books/new">New book</a> # lib/bookshelf/presenters /book_presenter.rb require 'hanami/view’ class BookPresenter include Hanami::Presenter attr_reader :book def initialize(book) @book = book end def title book.title end def author book.author end def total_price "税込価格: #{book.total_price.round}円" end end