Система достижений RoR - Полиморфная ассоциация и проблемы дизайна - PullRequest
6 голосов
/ 21 августа 2010

Я пытаюсь создать систему достижений в Ruby on Rails и столкнулся с проблемой с моим дизайном / кодом.

Попытка использовать полиморфные ассоциации:

class Achievement < ActiveRecord::Base
  belongs_to :achievable, :polymorphic => true
end

class WeightAchievement < ActiveRecord::Base
  has_one :achievement, :as => :achievable
end

Миграции:

class CreateAchievements < ActiveRecord::Migration
... #code
    create_table :achievements do |t|
      t.string :name
      t.text :description
      t.references :achievable, :polymorphic => true

      t.timestamps
    end

     create_table :weight_achievements do |t|
      t.integer :weight_required
      t.references :exercises, :null => false

      t.timestamps
    end
 ... #code
end

Затем, когда я пробую этот следующий одноразовый юнит-тест, он не пройдёт, потому что он говорит, что достижение равно нулю.

test "parent achievement exists" do
   weightAchievement = WeightAchievement.find(1)
   achievement = weightAchievement.achievement 

    assert_not_nil achievement
    assert_equal 500, weightAchievement.weight_required
    assert_equal achievement.name, "Brick House Baby!"
    assert_equal achievement.description, "Squat 500 lbs"
  end

И мои фиксации: достижений .yml...

BrickHouse:
 id: 1
 name: Brick House
 description: Squat 500 lbs
 achievable: BrickHouseCriteria (WeightAchievement)

weight_achievements.ym ...

 BrickHouseCriteria:
     id: 1
     weight_required: 500
     exercises_id: 1

Несмотря на то, что я не могу заставить это работать, возможно, по большому счету, это плоховопрос дизайна.Я пытаюсь создать единую таблицу со всеми достижениями и их базовой информацией (название и описание).Используя эту таблицу и полиморфные ассоциации, я хочу связать с другими таблицами, которые будут содержать критерии для завершения этого достижения, например, таблица WeightAchievement будет иметь требуемый вес и идентификатор упражнения.Затем прогресс пользователя будет сохранен в модели UserProgress, где он связан с фактическим достижением (в отличие от WeightAchievement).

Причина, по которой мне нужны критерии в отдельных таблицах, заключается в том, что критерии будут сильно различаться междуразличные типы достижений и будут добавляться динамически впоследствии, поэтому я не создаю отдельную модель для каждого достижения.

Имеет ли это смысл?Должен ли я просто объединить таблицу достижений с определенным типом достижений, таким как WeightAchievement (таким образом, таблица называется name, description, weight_required, упражнение_id), тогда когда пользователь запрашивает достижения, в моем коде я просто ищу все достижения?(например, WeightAchievement, EnduranceAchievement, RepAchievement и т. д.)

1 Ответ

13 голосов
/ 21 августа 2010

Способ, которым системы достижений обычно работают, заключается в том, что существует большое количество различных достижений, которые могут быть инициированы, и есть набор триггеров, которые можно использовать для проверки, нужно ли запускать достижение или нет,

Использование полиморфной ассоциации, вероятно, является плохой идеей, потому что загрузка всех достижений и их проверка могут оказаться сложным упражнением.Есть также тот факт, что вам придется выяснить, как выразить условия успеха или неудачи в какой-то таблице, но во многих случаях вы можете получить определение, которое не отображается так аккуратно.Вы можете получить шестьдесят разных таблиц, представляющих все виды триггеров, и это похоже на кошмарный сон.

Альтернативным подходом может быть определение ваших достижений с точки зрения имени, ценности и так далее,и иметь таблицу констант, которая действует как хранилище ключей / значений.

Вот пример миграции:

create_table :achievements do |t|
  t.string :name
  t.integer :points
  t.text :proc
end

create_table :trigger_constants do |t|
  t.string :key
  t.integer :val
end

create_table :user_achievements do |t|
  t.integer :user_id
  t.integer :achievement_id
end

Столбец achievements.proc содержит код Ruby, который вы оцениваете, чтобы определить, является ли достижениедолжен срабатывать или нет.Обычно это загружается, упаковывается и заканчивается как служебный метод, который вы можете вызвать:

class Achievement < ActiveRecord::Base
  def proc
    @proc ||= eval("Proc.new { |user| #{read_attribute(:proc)} }")
  rescue
    nil # You might want to raise here, rescue in ApplicationController
  end

  def triggered_for_user?(user)
    # Double-negation returns true/false only, not nil
    proc and !!proc.call(user)
  rescue
    nil # You might want to raise here, rescue in ApplicationController
  end
end

Класс TriggerConstant определяет различные параметры, которые вы можете настроить:

class TriggerConstant < ActiveRecord::Base
  def self.[](key)
    # Make a direct SQL call here to avoid the overhead of a model
    # that will be immediately discarded anyway. You can use
    # ActiveSupport::Memoizable.memoize to cache this if desired.
    connection.select_value(sanitize_sql(["SELECT val FROM `#{table_name}` WHERE key=?", key.to_s ]))
  end
end

ИмеяНеобработанный код Ruby в вашей БД означает, что легче настроить правила на лету без необходимости повторного развертывания приложения, но это может усложнить тестирование.

Пример proc может выглядеть следующим образом:

user.max_weight_lifted > TriggerConstant[:brickhouse_weight_required]

Если вы хотите упростить свои правила, вы можете создать что-то, что автоматически расширит $brickhouse_weight_required в TriggerConstant[:brickhouse_weight_required].Это сделало бы его более читабельным для нетехнических людей.

Чтобы не помещать код в вашу БД, что может показаться кому-то плохим, вам придется самостоятельно определять эти процедуры в какой-то массовой процедуре.файл и передать различные параметры настройки по какому-то определению.Этот подход будет выглядеть следующим образом:

module TriggerConditions
  def max_weight_lifted(user, options)
    user.max_weight_lifted > options[:weight_required]
  end
end

Настройте таблицу Achievement таким образом, чтобы она сохраняла информацию о том, какие параметры следует передавать:

create_table :achievements do |t|
  t.string :name
  t.integer :points
  t.string :trigger_type
  t.text :trigger_options
end

В этом случае trigger_options являетсятаблица отображения, которая хранится сериализовано.Примером может быть:

{ :weight_required => :brickhouse_weight_required }

Комбинируя это, вы получаете несколько упрощенный, менее eval счастливый результат:

class Achievement < ActiveRecord::Base
  serialize :trigger_options

  # Import the conditions which are defined in a separate module
  # to avoid cluttering up this file.
  include TriggerConditions

  def triggered_for_user?(user)
    # Convert the options into actual values by converting
    # the values into the equivalent values from `TriggerConstant`
    options = trigger_options.inject({ }) do |h, (k, v)|
      h[k] = TriggerConstant[v]
      h
    end

    # Return the result of the evaluation with these options
    !!send(trigger_type, user, options)
  rescue
    nil # You might want to raise here, rescue in ApplicationController
  end
end

Вам часто приходится пробираться через целую кучуAchievement записей, чтобы увидеть, были ли они достигнуты, если у вас нет таблицы сопоставления, которая может определить, в упрощенном виде, какие записи тестируют триггеры.Более надежная реализация этой системы позволит вам определить конкретные классы для наблюдения за каждым достижением, но этот базовый подход должен, по крайней мере, служить основой.

...