Re-Wheel 시리즈 1부 - Rails의 find_each는 어떻게 작동하나요?

8884 단어 sqlrubyrailsstreams
수년에 걸쳐 나는 무언가를 배우기 위해서는 그것을 이해해야 한다는 것을 깨달았습니다. 소프트웨어 개념의 경우 이것은 때때로 이미 작성된 코드를 작성한다는 것을 의미하며 아마도 훨씬 더 나을 것입니다. 이것은 일반적으로 "바퀴 재발명"이라고 하며 전문적으로 코드를 작성할 때 권장하지 않는 것이지만 매우 좋은 학습 연습이므로 몇 가지를 해부하려고 하는 일련의 게시물을 만들기로 결정했습니다. 이해하고 학습하는 데 도움이 되는 흥미롭고 일반적으로 사용되는 기술입니다.

내 Re-Wheel 시리즈의 첫 번째 게시물에서는 ActiveRecordfind_each에 대해 이야기하고 싶었습니다. 이 방법을 사용하면 데이터베이스 레코드를 배치로 처리할 수 있으므로 앱의 메모리 소비가 줄어들고 수행하는 쿼리의 양이 증가합니다(각 배치당 하나). 모든 프로젝트(Rails 프로젝트뿐만 아니라)에서 메모리 소비에 대해 생각해야 하므로 이 아이디어를 사용 중인 언어나 프레임워크에 포팅할 수 있습니다.

백만 명의 사용자가 있고 모든 이메일을 화면에 인쇄하고 싶다고 가정해 보겠습니다. User.all.each { |user| puts user.email }와 같은 작업을 수행할 수 있지만 이렇게 하면 메모리의 모든 레코드가 로드됩니다(좋지 않음). 동등하고 메모리 효율적인 방법은 User.all.find_each { |user| puts user.email } 입니다.

그러나 이것이 어떻게 구현됩니까? 음, pagy , kaminari 또는 will_paginate 과 같은 보석을 사용하여 색인 방법에서 페이지 매김을 사용하면 여기에서도 동일한 아이디어가 발생한다는 것을 알 수 있습니다. 그들은 SQL's LIMIT and OFFSET의 힘을 사용하여 매번 데이터. 따라서 100만 명의 사용자 예에서 find_each는 레코드를 놓치지 않도록 오프셋을 적절하게 변경하면서 1000개의 제한으로 1000,000개의 쿼리를 수행합니다.
find_each 메서드에는 또 다른 멋진 점이 있습니다. 보시다시피 강력한 Rubyenumerable 모듈을 사용하여 각 행에서 실행되는 블록을 받습니다.

이 방법에는 약간의 제한이 있으며 pluck 방법에서는 작동하지 않습니다. 메모리 사용량을 줄이기 위해 이 작업을 수행하고 있으므로 테이블에서 필요한 열만 선택하는 것이 합리적이므로 구현에 해당 기능을 추가하겠습니다.

내가 작성한 코드를 보여주고 각 부분을 설명하겠습니다.

class DatabaseStream
  include Enumerable

  DEFAULT_BATCH_SIZE = 1000

  def initialize(sql, options = {})
    @sql = sql
    @pluck = options[:pluck]
    @batch_size = options[:batch_size] || DEFAULT_BATCH_SIZE
    reset_cursors!
  end

  def each
    if block_given?
      while (row = read_from_buffer_or_fetch)
        yield row
      end
      reset_cursors!
    else
      to_enum(:each)
    end
  end

  private

  def reset_cursors!
    @gobal_cursor = 0
    @buffer_cursor = @batch_size
    @buffer = []
  end

  def read_from_buffer_or_fetch
    fetch_data if @buffer_cursor == @batch_size

    row = @buffer[@buffer_cursor]
    return nil unless row

    @gobal_cursor += 1
    @buffer_cursor += 1
    row
  end

  def fetch_data
    @buffer = @sql.limit(@batch_size).offset(@gobal_cursor)
    @buffer = @buffer.pluck(@pluck) if @pluck.present?
    @buffer_cursor = 0
  end
end


이 코드를 테스트하려면 다음을 실행할 수 있습니다.

query = User.all
stream = DatabaseStream.new(query)
stream.each { |user| puts user.email }

stream = DatabaseStream.new(query, batch_size: 100, pluck: 'id, email') # pluck: %i[id, email] also works
stream.each { |user| puts user[1] } # pluck returns an array


주요 아이디어는 2개의 포인터를 갖는 것입니다. 하나는 배치의 현재 요소에 대한 것이고 다른 하나는 전체 위치(오프셋에 사용됨)에 대한 것입니다. fetch_data 메서드는 제한 및 오프셋을 설정하고 pluck 매개 변수가 제공된 경우 열을 선택하는 쿼리를 수행합니다. 그런 다음 결과는 메모리의 버퍼에 저장되고 커서가 첫 번째 요소를 가리키도록 설정합니다.

인스턴스를 만들 때 @buffer_cursor = @batch_size를 설정한 다음 read_from_buffer_or_fetch 메서드에서 쿼리를 만들기 전에 이 조건을 확인합니다. 이를 통해 개체를 지연 초기화하여 실제로 필요할 때만 쿼리를 수행할 수 있습니다. 게으름을 달성하는 다른 방법이 있지만 이것이 코드를 깔끔하게 보이게 한다고 생각합니다.

보시다시피 Enumerable 모듈을 포함했기 때문에 모든 요소를 ​​반복하는 each 메서드를 구현해야 했습니다. 여기에서 몇 가지 (중요하지 않은) 트릭을 만들었습니다.
  • 먼저 블록이 주어졌는지 확인합니다. 그렇다면 단순히 yield 행을 블록으로 이동합니다. 그러나 블록이 제공되지 않고 여기에 트릭이 있는 경우 내 메서드each에서 새 열거자를 생성하는 to_enum 메서드를 호출합니다. 이 마지막 부분은 이제 내 열거자를 다른 사람과 연결하고 다음과 같은 작업을 수행할 수 있기 때문에 중요합니다. stream.each.with_index { |row, idx| puts idx } .
  • 모음을 여러 번 반복할 수 있도록 버퍼와 커서를 재설정합니다. 이것이 없으면 요소를 다시 반복하려면 새 개체를 만들어야 합니다.

  • 여기까지가 이 Re-Wheel 부분의 전부입니다. 여기에서 한두 가지 요령을 익힐 수 있기를 바랍니다!

    좋은 웹페이지 즐겨찾기