Недавно у меня была причина создать приложение 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
Идти дальше и занимать место
Теперь вы вооружены знаниями, чтобы создавать свои собственные таблицы лидеров. С этого момента, я полагаю, вы будете создавать списки лидеров для всего. Дайте мне знать, когда вы получите список лидеров под названием «Статьи о рубине и списках лидеров», чтобы я мог проголосовать за эту статью.