Статьи

Таблицы лидеров на Rails

Снимок экрана 2015-03-07 14.41.07

Недавно у меня была причина создать приложение Rails, которое было бы простой таблицей лидеров. Мои ограничения были следующими:

  • Примите имя и оценку для новой записи на доске. Если имя уже существует, обновите счет с новым значением.
  • Вернуть JSON-представление для предоставленного имени, включая рейтинг и оценку.
  • Поддержка HTML и JSON представления всех записей в таблице лидеров. Включите возможность указать номер страницы (по умолчанию 0) и смещение (по умолчанию 10).
  • Не разрешайте возвращать более 100 записей для одного запроса.
  • Разрешить удаление указанного имени из списка лидеров.

В общем, требования довольно просты. В основном, позволяют пользователю создавать, обновлять и удалять записи в таблице лидеров. Кроме того, возвращайте текущие записи, упорядоченные по рангу, в формате HTML или JSON.

Быстрый Google или два позже, я знал, что хочу использовать Redis. Эта статья действительно объясняет, как функции Redis поддерживают таблицу лидеров довольно хорошо. А именно, тип данных Redis Sorted Set был создан для списков лидеров. Вот некоторые из команд Sorted Set, которые будут использоваться:

  • ZADD : добавьте одного или нескольких участников в набор с указанными баллами. ZADD myzset 2 "two" 3 "three" добавит 2 участников.
  • ZREVRANGEBYSCORE : вернуть все элементы в таблице лидеров, найденные между предоставленными минимальными и максимальными значениями. Это используется, чтобы показать членов от высшего к низшему.
  • ZSCORE и ZRANK позволяют искать участников по количеству баллов и званию соответственно.

Я не единственный, кто считает, что Redis хорошо работает в этом случае. Пользователь Github agoragames создал гем Ruby, чтобы сделать его еще проще. leaderboard не только упрощает использование Redis для списков лидеров, но и добавляет ТОНН функций. Если бы существовала таблица лидеров для драгоценных камней, leaderboard была бы наверху.

заявка

В этой статье я собираюсь показать вам очень простой, поддерживаемый Rails список лидеров. В то время как некоторые из внешних интерфейсов (т. Е. Разбиение на страницы) зависят от Rails, операции с таблицей лидеров — нет. Вы должны, если хотите, легко иметь возможность извлекать из этого биты из списка лидеров и использовать их в приложениях Sinatra, Volt или Padrino без каких-либо хлопот. Как вы увидите, я поместил весь код, который касается таблицы лидеров, в набор сервисных объектов. Я сделал это из любви.

Сам список лидеров может быть для чего угодно. Я собираюсь использовать камень Faker, чтобы просто заполнить его именами. Это позволяет нам видеть много записей на доске и демонстрировать, что происходит, когда мы добавляем новые или обновляем существующие записи. Я поделюсь сценарием Ruby, который создает записи, когда мы перейдем к этому фрагменту.

Код для этого списка лидеров находится здесь, и демонстрация для приложения здесь .

Я не собираюсь проходить настройку приложения, так как вы можете посмотреть на код. Это довольно ванильное приложение Rails со следующими добавленными драгоценными камнями:

  • leaderboard : жемчужина, о которой мы здесь говорим.
  • faker : драгоценный камень для генерации поддельных / тестовых значений. Это используется здесь, чтобы генерировать имена для наших участников.
  • cells : «Просмотр компонентов для Rails». В основном, просмотр моделей для Rails. Это не обязательно для лидеров, но они мне нравятся.
  • kaminari : хорошо известная жемчужина для Rails. Таблица лидеров будет разбита на страницы.

Я также использую RSpec и PostgreSQL, поэтому необходимые гемы для них также есть в Gemfile. Спецификации не завершены на 100%, но они охватывают счастливый путь. PostgreSQL практически не используется, мне просто лень его удалять. Следующие шаги для этого приложения будут включать аутентификацию, и я буду использовать PostgreSQL и Devise для этого, более чем вероятно.

Маршруты и виды

Маршруты для нашего списка лидеров очень просты:

 Prefix Verb URI Pattern Controller#Action not_found GET /not_found(.:format) errors#not_found entries GET /entries(.:format) entries#index POST /entries(.:format) entries#create new_entry GET /entries/new(.:format) entries#new edit_entry GET /entries/:id/edit(.:format) entries#edit entry GET /entries/:id(.:format) entries#show PATCH /entries/:id(.:format) entries#update PUT /entries/:id(.:format) entries#update DELETE /entries/:id(.:format) entries#destroy root GET / leaderboards#show 

По сути, есть один список лидеров, поэтому он получает метод show . Таблица лидеров состоит из записей, а Entry является полным ресурсом Rails . Записи могут быть созданы с помощью POST для маршрута сбора. Все записи можно получить с помощью GET в коллекцию. Отдельные записи следуют стандартным путям Rails RESTful Я добавил маршрут «Не найдено» только для полноты картины.

Важно отметить, что единственным реальным представлением HTML в приложении является отображение таблицы лидеров. Все остальное будет сделано через AJAX. Я использую Angular для этого ( звук сотен пользователей ReactJS, кричащих ). Наконец, для стилей и разметки я использую Zurb Foundation ( звучит сотня слов от пользователей Bootstrap ).

Обслуживание досок

Как упоминалось ранее, я выделил весь код списка лидеров. Модуль Boards имеет несколько методов уровня модуля для хранения значений по умолчанию:

 module Boards DEFAULT_BOARD = 'ccleaders' def self.default_leaderboard Leaderboard.new( DEFAULT_BOARD, default_options, redis_connection: Redis.current ) end def self.default_options Leaderboard::DEFAULT_OPTIONS.merge( page_size: 10 ) end end 

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

Чтобы соответствовать требованиям приложения, нам необходимо:

  • Получить все записи
  • Получить одну запись
  • Создать / обновить записи
  • Удалить запись

Классы, которые делают работу, объяснены ниже.

GetAllService

Получить записи в таблице лидеров. Могут быть предоставлены опции, такие как page и page_size .

 module Boards class GetAllService < Boards::Base def execute(options = {}) leaderboard.leaders( page(options), page_size: page_size(options) ) end private def page(options) options[:page] || 1 end def page_size(options) options[:page_size] || 10 end end end 

GetAllService вызывает метод GetAllService , передавая параметры страницы. Это вернет записи в таблице лидеров. Метод leaderboard находится в классе Boards::Base и выглядит следующим образом:

 class Base def leaderboard @leaderboard ||= Boards.default_leaderboard end def member_exists?(name) leaderboard.check_member?(name) end end 

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

GetService

Получить одну запись в таблице лидеров на основе имени.

 module Boards class GetService < Boards::Base def execute(options = {}) return unless member_exists?(options[:name]) leaderboard.score_and_rank_for(options[:name]) end end end 

Человек, жемчужина leaderboard делает это так просто. Быстро проверьте, существует ли запись ( member_exists? Находится в классе Boards::Base ), затем верните оценку и рейтинг этой записи.

UpdateService

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

 module Boards class UpdateService < Boards::Base def execute(entry_params) name = entry_params[:name] score = entry_params[:score].to_i leaderboard.rank_member(name, score) member = leaderboard.score_and_rank_for(name) member[:page] = leaderboard.page_for(name, leaderboard.page_size) member end end end 

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

Кроме того, этот класс используется для создания и обновления записей, поэтому здесь нет CreateService .

DeleteService

Удалить запись из списка лидеров на основе предоставленного имени.

 module Boards class DeleteService < Boards::Base def execute(options = {}) return unless member_exists?(options[:name]) leaderboard.remove_member(options[:name]) end end end 

Просто простой вызов remove_member с именем. Очень просто.

Вы можете спросить, сожалею ли я о добавлении Service к имени каждого из этих классов. Я делаю.

Контроллеры будут использовать эти объекты и методы для выполнения действий, основанных на пользовательских запросах.

Leaderboard Controller и просмотр

LeaderboardController имеет единственное действие: show . Это действие получает записи для текущей страницы списка лидеров.

 class LeaderboardsController < ApplicationController before_action :query_options def show @lb = Boards.default_leaderboard @entries = entry_service.execute(query_options) respond_to do |format| format.html do paginate end format.json do render json: @entries end end end private def query_options @limit = [params.fetch(:limit, 10).to_i, 100].min @page = params.fetch(:page, 1).to_i { page: @page, limit: @limit } end def paginate pager = Kaminari.paginate_array( @entries, total_count: @lb.total_members) @page_array = pager.page(@page).per(@limit) end def entry_service Boards::GetAllService.new end end 

Метод show довольно прост. Он захватывает таблицу лидеров по умолчанию и вызывает Boards::GetAllService чтобы получить записи. Если мы визуализируем HTML, метод paginate использует невероятно полезный метод Kaminari.paginate_array . Мы даже получим общее количество страниц, поскольку таблица лидеров знает, что это total_members . Я думал, что нумерация страниц будет скорее ошибкой, чем это, но сообщество Ruby снова поддерживает меня.

Представление show выглядит так:

 <h1><%= t('leaderboard.subtitle') %></h1> <%= render_cell :leaderboard, :new_entry_form %> <table style='width: 100%'> <tr> <th><%= t('entries.rank') %></th> <th><%= t('entries.name') %></th> <th><%= t('entries.score') %></th> <th><%= t('labels.delete') %></th> </tr> <% @entries.each do |entry| %> <%= render_cell :entry, :show, entry: entry %> <% end %> </table> <span style='font-size: small; font-style:italic; float:right'><%= t('labels.edit_help') %></span> <%= paginate @page_array %> 

Я использую метод t (сокращение от I18n.translate ), чтобы получить мои строки из файлов локали. Записи будут отображаться в виде таблицы ( звук сотен хохотливых CSS-педантов ).

Как уже упоминалось ранее, я использую драгоценный камень cells потому что он мне действительно нравится. В этом представлении есть две ячейки: новая форма записи в таблице лидеров и ячейка для отображения отдельной записи в таблице. Новая ячейка формы ввода выглядит так:

 <%= form_tag controller: 'entries' do %> <div class="row"> <div class="large-8 columns"> <div class="row collapse prefix-radius"> <div class="small-3 columns"> <span class="prefix"><%= t('entries.name') %></span> </div> <div class="small-9 columns"> <%= text_field_tag :name, nil, {name: 'entry[name]'} %> </div> </div> </div> <div class="large-4 columns"> <div class="row collapse prefix-radius"> <div class="small-3 columns"> <span class="prefix"><%= t('entries.score') %></span> </div> <div class="small-7 columns"> <%= number_field_tag :score, nil, {name: 'entry[score]'} %> </div> <div class="small-2 columns "> <%= submit_tag 'Add', class: 'button postfix' %> </div> </div> </div> </div> <% end %> 

Это просто простая форма для добавления новой записи. Требуется имя и оценка.

Ячейка ввода немного интереснее:

 <tr> <td style='width: 10%'><%= @rank %></td> <td><%= @name %></td> <td style='width: 10%'> <entry-form entry='<%= @entry.to_json %>'></entry-form> </td> <td style="width: 10%"><%= render_cell :entry, :delete_form, entry: @entry %></td> </tr> 

Ячейка отображает одну строку таблицы, которая содержит имя записи, нечто, называемое entry-form , и ДРУГУЮ ячейку. Мы вернемся к этому странному компоненту entry-form , давайте посмотрим на ячейку delete_form , очень быстро:

 <%= form_tag "/entries/#{@entry[:member]}", controller: 'entries', method: 'DELETE' do %> <%= submit_tag "x", style: 'color: #fff; background: #F00; border:none;cursor: pointer' %> <% end %> 

В форме удаления используется HTTP-УДАЛЕНИЕ для публикации определенной записи. Это одна из причин, по которой мне нравятся клетки. Я получаю сфокусированные кусочки представления, которые можно проверить.

Итак, что это за entry-form ввода из ячейки? Это, друзья мои, является угловой директивой или компонентом. Угловые директивы — это многократно используемые фрагменты поведения javascript. Эта директива позволяет пользователю дважды щелкнуть по партитуре, чтобы отредактировать ее. Вот оно во всей красе:

 var App = angular.module('App', []) .directive('entryForm', function (){ return { restrict: 'E', scope: { entry: '=entry' }, templateUrl: "<%=asset_path 'templates/entry_form.html'%>", link: function(scope, elem, attrs) { var input = elem.find('input'), form = elem.find('form'); scope.editing = false; elem.bind('dblclick', function(){ scope.editing = true; input.select(); scope.$apply(); }); input.bind('keydown', function(e) { switch(e.which) { case 13: //Enter form.submit(); case 27: //Esc scope.editing = false; } scope.$apply(); }); } }; }) 

Если вы знаете что-нибудь о директивах Angular (или даже, если не знаете), то это довольно просто. Я запускаю файл javascript через ERb (то есть это файл .js.erb), чтобы получить asset_path , что является сомнительной практикой. Шаблон содержит HTML, который заменит компонент entry_form на странице:

 <span ng-hide='editing'>{{entry.score}}</span> <form method='post' action='/entries' ng-show='editing' csrf> <input style='display:none' type='text' name='entry[name]' value='{{entry.member}}'/> <input style='width:100%' ng-show='editing' name='entry[score]' value='{{entry.score}}' /> </form> 

Директива привязывается к паре событий, одно на элементе dblclick ( dblclick ) и одно на входе ( keyDown ). scope.editing включает и выключает ввод.

Более проницательные разработчики Rails среди вас могут спросить: «Как эта форма обходит защиту CSRF? Я не вижу authenticity_token ! »Хороший вопрос. Вы видели атрибут csrf на form в шаблоне entry_form ? Угадай, что? Это еще одна директива! Wheeee! Вот как это выглядит:

 ....entry-form directive... .directive('csrf', function(){ return { restrict: 'A', compile: function(tElement, tAttrs, transclude) { var input = $("<input/>", {type: 'hidden', name:'authenticity_token', value: $("meta[name='csrf-token']").attr("content") }); tElement.append(input); } }; }); 

Помещение csrf в этот тег form приводит к добавлению в форму скрытого input с csrf-token . Острота.

Это все для отдельной HTML-страницы, стиля и поведения. О, вот как это выглядит:

фунт

EntriesController

EntriesController — это то, где бизнес делается:

 class EntriesController < ApplicationController def show entry = retrieve_service.execute(name: params[:id]) return not_found unless entry respond_to do |format| format.html do end format.json do render json: entry, status: :ok end end end def create result = create_service.execute(entry_params) respond_to do |format| format.html do redirect_to root_path(page: result[:page]) end format.json do render json: { status: :ok } end end end def index @entries = retrieve_service.execute(query_options) return not_found if @entries.blank? respond_to do |format| format.html do end format.json do render json: @entries end end end def destroy result = delete_service.execute(name: params[:id]) return not_found unless result respond_to do |format| format.html do redirect_to root_path end format.json do render json: { status: 'ok' }, status: 200 end end end private def create_service Boards::UpdateService.new end def retrieve_service Boards::GetService.new end def delete_service Boards::DeleteService.new end def entry_params (params[:entry] || {}).slice(:name, :score) end end 

Я не собираюсь проходить здесь каждый метод, так как все они следуют одному и тому же шаблону: вызовите правильный сервисный объект, передав соответствующие параметры, затем выполните рендеринг на основе формата. Здесь есть пара рефакторингов, которые я дам вам в качестве домашней работы. Метод destroy перенаправляет на корневой путь, который является грубым. Ваша задача сделать все это AJAXy и все такое.

Но подождите, есть больше

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

Данные участника

Драгоценный камень позволяет вам предоставить дополнительные данные участника для записи. Вот пример из репо:

 require "json" lb.rank_member('Scout', 9001, {level: "over 9000"}.to_json) 

В этот момент звоним:

 JSON.parse(lb.member_data_for("Scout")) => {level: 'over 9000'} 

Это может быть безумно полезно, если ваши записи в таблице лидеров содержат дополнительные данные.

Вокруг меня

Получить записи вокруг записи тривиально:

 lb.around_me('Scout') => [...a bunch of entries...{member: "Vegeta", score: 8999, rank: 9000}, {member:"Scout", score: 9001, rank: 9001},...a bunch of entries...] 

Другие типы лидеров

Существует 3 вида TieRankingLeaderboard лидеров (кто знал?): Default, TieRankingLeaderboard и CompetitionRankingLeadeboard . Разница в том, как обрабатываются записи с одинаковым счетом.

  • По умолчанию ранжирует их лексикографически.
  • TieRankingLeaderboard : равные оценки получают равный ранг, а последующие оценки принимают следующее значение. Пример: 1,1,2,3,4,4,5
  • CompetitionRankingLeadeboard : равные оценки получают равный ранг, а последующие оценки пропускают значение для каждого равного члена. Пример: 1,1,3,4,4,4,7

Условно ранговые записи

Можно определить лямбду, которая определяет, будет ли запись ранжироваться или нет. Вот пример со страницы Github:

 highscore_check = lambda do |member, current_score, score, member_data, leaderboard_options| return true if current_score.nil? return true if score > current_score false end highscore_lb.rank_member_if(highscore_check, 'david', 1337) highscore_lb.score_for('david') => 1337.0 highscore_lb.rank_member_if(highscore_check, 'david', 1336) highscore_lb.score_for('david') => 1337.0 highscore_lb.rank_member_if(highscore_check, 'david', 1338) highscore_lb.score_for('david') => 1338.0 

Здорово.

Скрипт для создания записей

Как и было обещано, вот скрипт, который я использовал для генерации некоторых записей в таблице лидеров:

 100.times do |i| params = {name: Faker::Name.name, score: Random.rand(100)} Boards::UpdateService.new.execute(params) end 

Идти дальше и занимать место

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