Refactoring di un modello Single Rails con metodi di grandi dimensioni e lunghe query di join che cercano di fare tutto

0

Ho un lavoro Ruby on Rails Model che sospetto sia inefficiente , difficile da mantenere e pieno di SQL non necessario unire le query . Voglio ottimizzare e refactor questo modello (Quiz.rb) per rispettare le best practice di Rails, ma non sono sicuro di come dovrei farlo.

L'app Rails è un gioco che ha Missions con molti Stages . User s completa Stage s rispondendo Questions che ha Answers corretto o errato. Quando User tenta di completare uno stage rispondendo alle domande, User ottiene una voce Quiz con molti Attempt s. Ogni Attempt registra un Answer inviato per quel Question all'interno di Stage. Un utente completa uno stage o una missione ottenendo ogni Attempt corretto e il loro progresso viene tracciato aggiungendo una nuova voce a UserMission & UserStage join tabelle.

Tutte queste funzioni funzionano, ma sfortunatamente il Quiz.rb Model è stato trasformato per gestire quasi tutto esclusivamente. I callback sono iniziati con 'Quiz.rb', e poiché non ero sicuro di come lasciare il modello Quiz durante un aggiornamento multi-modello, ho fatto ricorso a Console Rails per avere il @quiz variabile di istanza tramite self.some_method fa tutto il pesante sollevamento a recupera ogni valore di dati per la logica di business del gioco; risultante in ampie query di join estese che "ballano" attorno allo schema del database.

Il modello Quiz.rb che annusa:

class Quiz < ActiveRecord::Base
  belongs_to :user
  has_many :attempts, dependent: :destroy

  before_save :check_answer
  before_save :update_user_mission_and_stage

  accepts_nested_attributes_for :attempts, :reject_if => lambda { |a| a[:answer_id].blank? }, :allow_destroy => true

  #Checks every answer within each quiz, adding +1 for each correct answer 
  #within a stage quiz, and -1 for each incorrect answer

  def check_answer
    stage_score = 0
    self.attempts.each do |attempt|
      if attempt.answer.correct? == true
        stage_score += 1
      elsif attempt.answer.correct == false
        stage_score - 1
      end
    end
    stage_score
  end

  def winner
   return true
  end

  def update_user_mission_and_stage
    #######
    #Step 1: Checks if UserMission exists, finds or creates one.
    #if no UserMission for the current mission exists, creates a new UserMission
      if self.user_has_mission? == false
        @user_mission = UserMission.new(user_id: self.user.id, 
                          mission_id: self.current_stage.mission_id,
                          available: true)
        @user_mission.save
      else
        @user_mission = self.find_user_mission
      end

    #######
    #Step 2: Checks if current UserStage exists, stops if true to prevent duplicate entry
      if self.user_has_stage?
        @user_mission.save
        return true
      else

    #######
    ##Step 3: if step 2 returns false: 
    ##Initiates UserStage creation instructions
      #checks for winner (winner actions need to be defined) if they complete last stage of last mission for a given orientation
        if self.passed? && self.is_last_stage? && self.is_last_mission?
          create_user_stage_and_update_user_mission
          self.winner

      #NOTE: The rest are the same, but specify conditions that are available to add badges or other actions upon those conditions occurring:
      ##if user completes first stage of a mission
        elsif self.passed? && self.is_first_stage? && self.is_first_mission?
          create_user_stage_and_update_user_mission

          #creates user badge for finishing first stage of first mission
          self.user.add_badge(5)
          self.user.activity_logs.create(description: "granted first-stage badge", type_event: "badge", value: "first-stage")

      #If user completes last stage of a given mission, creates a new UserMission
        elsif self.passed? && self.is_last_stage? && self.is_first_mission?
          create_user_stage_and_update_user_mission

          #creates user badge for finishing first mission
          self.user.add_badge(6)
          self.user.activity_logs.create(description: "granted first-mission badge", type_event: "badge", value: "first-mission")

        elsif self.passed?
          create_user_stage_and_update_user_mission

        else self.passed? == false
          return true
        end
      end
  end

  #Creates a new UserStage record in the database for a successful Quiz question passing
  def create_user_stage_and_update_user_mission
    @nu_stage = @user_mission.user_stages.new(user_id: self.user.id, 
                                              stage_id: self.current_stage.id)
    @nu_stage.save
    @user_mission.save

    self.user.add_points(50)
  end

  #Boolean that defines passing a stage as answering every question in that stage correct
  def passed?
    self.check_answer >= self.number_of_questions
  end


  #Returns the number of questions asked for that stage's quiz
  def number_of_questions
    self.attempts.first.answer.question.stage.questions.count
  end

  #Returns the current_stage for the Quiz, routing through 1st attempt in that Quiz
  def current_stage
    self.attempts.first.answer.question.stage
  end

  #Gives back the position of the stage relative to its mission.
  def stage_position
    self.attempts.first.answer.question.stage.position
  end

  #will find the user_mission for the current user and stage if it exists
  def find_user_mission
    self.user.user_missions.find_by_mission_id(self.current_stage.mission_id)
  end

  #Returns true if quiz was for the last stage within that mission
  #helpful for triggering actions related to a user completing a mission
  def is_last_stage?
    self.stage_position == self.current_stage.mission.stages.last.position
  end

  #Returns true if quiz was for the first stage within that mission
  #helpful for triggering actions related to a user completing a mission
  def is_first_stage?
    self.stage_position == self.current_stage.mission.stages_ordered.first.position
  end

  #Returns true if current user has a UserMission for the current stage
  def user_has_mission?
    self.user.missions.ids.include?(self.current_stage.mission.id)
  end

  #Returns true if current user has a UserStage for the current stage
  def user_has_stage?
    self.user.stages.include?(self.current_stage)
  end

  #Returns true if current user is on the last mission based on position within a given orientation
  def is_first_mission?
     self.user.missions.first.orientation.missions.by_position.first.position == self.current_stage.mission.position
  end


  #Returns true if current user is on the first stage & mission of a given orientation
  def is_last_mission?
     self.user.missions.first.orientation.missions.by_position.last.position == self.current_stage.mission.position
  end

end

La mia domanda

Attualmente il mio server Rails impiega circa 500ms a 1 secondo per elaborare un'azione @quiz.save singola. Sono fiducioso che la lentezza qui sia dovuta al codice sciatto, non alla cattiva progettazione del database ERD.

Che aspetto ha ? E in particolare:

  1. Devo usare le unire query per recuperare valori come ho fatto qui, o è meglio istanziare nuovi oggetti all'interno del modello? O mi manca una soluzione migliore?

  2. In che modo update_user_mission_and_stage può essere refactored per seguire le best practice?

Codice rilevante per riferimento:

quizzes_controller.rb w / Controller Route Avvio callback:

  class QuizzesController < ApplicationController
    before_action :find_stage_and_mission
    before_action :find_orientation
    before_action :find_question


  def show
  end

  def create
    @user = current_user
    @quiz = current_user.quizzes.new(quiz_params)
    if @quiz.save
      if @quiz.passed?
         if @mission.next_mission.nil? && @stage.next_stage.nil?
           redirect_to root_path, notice: "Congratulations, you have finished the last mission!"
         elsif @stage.next_stage.nil?
           redirect_to [@mission.next_mission, @mission.first_stage], notice: "Correct! Time for Mission #{@mission.next_mission.position}", info: "Starting next mission"
         else
           redirect_to [@mission, @stage.next_stage], notice: "Answer Correct! You passed the stage!"
         end
      else
       redirect_to [@mission, @stage], alert: "You didn't get every question right, please try again."
      end

    else
      redirect_to [@mission, @stage], alert: "Sorry.  We were unable to save your answer.  Please contact the admministrator."
    end
    @questions = @stage.questions.all

  end

  private


  def find_stage_and_mission
    @stage = Stage.find(params[:stage_id])
    @mission = @stage.mission
  end

  def find_question
    @question = @stage.questions.find_by_id params[:id]
  end

  def quiz_params
    params.require(:quiz).permit(:user_id, :attempt_id, {attempts_attributes: [:id, :quiz_id, :answer_id]}) 

  end

  def find_orientation
  @orientation = @mission.orientation
  @missions = @orientation.missions.by_position
  end

end

Panoramica delle relazioni del database ERD pertinente:

Missione - > Stage - > Domanda - > Risposta - > Tentativo < - Quiz < - Utente

Missione - > UserMission < - User

Stage - > UserStage < - User

Altri modelli:

Mission .RB

class Mission < ActiveRecord::Base
  belongs_to :orientation


  has_many :stages

  has_many :user_missions, dependent: :destroy
  has_many :users, through: :user_missions

  #SCOPES
  scope :by_position, -> {order(position: :asc)}

  def stages_ordered
    stages.order(:position)
  end


  def next_mission
    self.orientation.missions.find_by_position(self.position.next)
  end

  def first_stage
    next_mission.stages_ordered.first
  end

end

Stage .RB:

class Stage < ActiveRecord::Base
  belongs_to :mission

  has_many :questions, dependent: :destroy

  has_many :user_stages, dependent: :destroy
  has_many :users, through: :user_stages


  accepts_nested_attributes_for :questions, reject_if: :all_blank, allow_destroy: true

  def next_stage
    self.mission.stages.find_by_position(self.position.next)
  end
end

Question .RB

    class Question < ActiveRecord::Base
      belongs_to :stage
      has_many :answers, dependent: :destroy

      accepts_nested_attributes_for :answers, :reject_if => lambda { |a| a[:body].blank? }, :allow_destroy => true
    end

Answer .RB:

class Answer < ActiveRecord::Base
  belongs_to :question

  has_many :attempts, dependent: :destroy
end

Attempt .RB :

class Attempt < ActiveRecord::Base
  belongs_to :answer
  belongs_to :quiz
end

User .RB:

class User < ActiveRecord::Base


  belongs_to :school
  has_many :activity_logs

  has_many :user_missions, dependent: :destroy
  has_many :missions, through: :user_missions

  has_many :user_stages, dependent: :destroy
  has_many :stages, through: :user_stages

  has_many :orientations, through: :school

  has_many :quizzes, dependent: :destroy
  has_many :attempts, through: :quizzes



  def latest_stage_position
    self.user_missions.last.user_stages.last.stage.position
  end

end

UserMission .RB

class UserMission < ActiveRecord::Base

  belongs_to :user
  belongs_to :mission

  has_many :user_stages, dependent: :destroy

end

UserStage .RB

class UserStage < ActiveRecord::Base

  belongs_to :user
  belongs_to :stage
  belongs_to :user_mission
end
    
posta Kelseydh 12.07.2014 - 18:30
fonte

1 risposta

1

Consiglierei di guardare il tuo log SQL per vedere cosa viene eseguito.
Prova anche il link gratuito "newrelic" per dare una spiegazione dettagliata del rendimento.

Il problema sarà che è difficile cambiare quando / come vengono eseguite varie query quando chiamate le associazioni ActiveRecord così liberamente come ora.

Inoltre, non stai effettivamente utilizzando i join, ma il semplice caricamento "SELECT" basato sull'associazione.

Separa il recupero e la logica dei dati Una buona strategia per aiutare con questo è cercare di recuperare tutti i dati necessari per prima, quindi eseguire la logica su di essa con classi ruby semplici (non ereditate dal record attivo). Ciò richiederà un po 'di riflessione sui percorsi, e probabilmente dovrai recuperare i dati di nuovo in tutto il flusso, ma almeno ogni punto è esplicitato e può essere ottimizzato.

Quando i dati richiesti attraversano più di un ActiveRecord o sono sufficientemente complicati, eseguire il recupero dei dati in una classe separata, una per ogni caso. Mi piace chiamare questi oggetti "query".

Struttura dati

Il codice non dovrebbe duplicare le conoscenze sulla struttura dei dati, ma provare a isolare ogni accesso della struttura in un unico posto.

Ad esempio, chiami "self.attempts.first.answer.question.stage" che significa che il codice nel quiz conosce i tentativi, che il primo è speciale (penso che sia attuale, giusto?), che i tentativi abbiano risposte, le risposte hanno domande e la domanda ha una fase.

Non solo è molto da comprendere quando lo leggi, significa che se qualcosa intorno alla struttura cambia, avrai molti punti da cambiare.

Inoltre fa molto SQL a ogni ".", e non puoi ottimizzarlo senza dover cambiare ogni riga. Ad esempio, possiamo probabilmente ottimizzare utilizzando un numero di join tramite la combinazione di "ambiti".

Altri punti

In update_user_mission_and_stage

  • Prova a ridurre la ramificazione se possibile. Non fare se / else dentro se / else.

Un modo per risolvere il problema è con una macchina 'state'.

  • Evita i richiami come la piaga - è così facile perderne traccia e non rendersi conto di quanto codice stia funzionando in modo imprevisto. Limitarli a ricerche forse semplici per il modello principale nel controller.

Hai "check_answer" chiamato in "before_save" del quiz, ma non salva nulla, quindi sembra non avere alcun effetto.

  • Estrai l'intero metodo a una classe 'servizio' separata. È solo una semplice classe ruby per fare la logica.

Leggi questo per ulteriori dettagli / link / idee: link

    
risposta data 19.08.2014 - 06:04
fonte

Leggi altre domande sui tag