Poziom: 0 | Kategoria: Komputerowo-internetowo, Ruby, Ruby on Rails, Techblog.
Ponieważ jestem w trakcie pisania małego serwisu społecznościowego w ramach pracy dyplomowej, nadszedł czas na wymodelowanie sieci znajomych. Przeglądałem trochę książek, szukałem trochę po przeróżnych blogach. W końcu stworzyłem hybrydę z rzeczy, które znalazłem.
Piszę w Railsach 2.0.2, chciałem więc w pełni wykorzystać REST. Pierwsze pytania pojawiły się już na poziomie projektowania bazy danych: czy lepiej podwajać krotki i trzymać w nich jakieś dodatkowe atrybuty? Czy może lepiej oszczędzić miejsce redukując rozmiar tabeli do jednej krotki na znajomość kosztem bardziej złożonego zapytania? Stanęło na tym, że każda "znajomość" ma dwie krotki w tabeli pośredniej, a znajomych przeglądamy przeglądając atrubuty "friend_id" krotek wybranych po atrybucie "user_id". Zatem dostaliśmy relację wiele-wiele pomiędzy tabelą Users a nią samą. Nasz model usera zaczął wyglądać więc mniej więcej tak:
# app/models/user.rb class User < ActiveRecord::Base has_many :friendships, :dependent => :destroy end
Model samej relacji "być znajomym" nabrał takiego kształtu:
# app/models/friendship.rb class Friendship < ActiveRecord::Base belongs_to :user belongs_to :friend, :class_name => 'User', :foreign_key => 'friend_id' end
Przyjęliśmy, że każde "zaproszenie" może mieć jeden z trzech stanów:
Stan zaproszenia przechowywany więc jest w tabeli pośredniej relacji wiele-wiele. Chcemy również w łatwy sposób docierać do naszych znajomych z poziomu usera. Z pomocą przychodzi nam metoda has_many :through, którą wykorzystujemy w modelu User (dla uproszczenia stany zaproszeń wyrażane są za pomocą stringów, innym sposobem, w moim mniemaniu lepszym, jest użycie, zamiast napisów, np. jakiś stałych liczbowych):
# app/models/user.rb
class User < ActiveRecord::Base
# (...)
has_many :friendships, :dependent => :destroy
has_many :friends,
:through => :friendships,
:conditions => ["status = ?", 'zaakceptowane']
has_many :requested_friends,
:through => :friendships,
:source => :friend,
:conditions => ["status = ?", 'wyslane']
has_many :pending_friends,
:through => :friendships,
:source => :friend,
:conditions => ["status = ?", 'oczekujace']
has_many :any_friends,
:through => :friendships,
:source => :friend
# (...)
end
Teraz dobrze jest zapewnić jakieś metody do zarządzania znajomościami. W tym celu utworzymy metody klasy Friendship, które będą wysyłały, akceptowały i odrzucały zaproszenia do znajomości:
# app/models/friendship.rb
class Friendship < ActiveRecord::Base
belongs_to :user
belongs_to :friend, :class_name => 'User', :foreign_key => 'friend_id'
validates_presence_of :user_id, :friend_id
def self.exists?(user, friend)
not find_by_user_id_and_friend_id(user, friend).nil?
end
def self.request(user, friend)
unless user == friend or Friendship.exists?(user, friend)
transaction do
create(:user => user, :friend => friend, :status => 'wyslane')
create(:user => friend, :friend => user, :status => 'oczekujace')
end
end
end
def self.accept(user, friend)
transaction do
accepted_at = Time.now
accept_one_side(user, friend, accepted_at)
accept_one_side(friend, user, accepted_at)
end
end
def self.breakup(user, friend)
transaction do
destroy(find_by_user_id_and_friend_id(user, friend))
destroy(find_by_user_id_and_friend_id(friend, user))
end
end
private
def self.accept_one_side(user, friend, accepted_at)
request = find_by_user_id_and_friend_id(user, friend)
request.status = 'zaakceptowane'
request.accepted_at = accepted_at
request.save!
end
end
Jak widać kluczowe akcje modelu wywoływane są w bloku metody transaction. To zapewnia nam transakcyjne wykonanie kodu, co zabda o spójność danych i nie dopuści do powstania sytuacji, w której podczas wysyłania zaproszenia od użytkownika A do użytkownika B w bazie pojawi się tylko krotka (A,B,'wyslane').
Można powiedzieć, że model jest już gotowy, pora zatem na kontroler, który będzie oferował podzbiór standardowych akcji: create, index, show oraz destroy. Akcja index zajmie się wyświetlaniem listy znajomych danego użytkownika, show przekieruje nas na profil użytkownika, create będzie odpowiedzialna za wysłanie lub zaakceptowanie zaproszenia, a destroy pozwoli na usunięcie kogoś z listy znajomych, jak również anulowanie wysłanego zaproszenia czy odrzucenie zaproszenia od niechcianej osoby. Całość umieściłem za filtrem :login_required (korzystam z pluginu restful_authentication) aby zarządzanie znajomymi było dostępne tylko po zalogowaniu.
Poszczególne akcje kontrolera nie są jakoś bardzo wyszukane, dla przykładu wkleję akcję destroy:
# app/controllers/friendships_controller.rb
class FriendshipsController < ApplicationController
# (...)
def destroy
# current_user zwraca aktualnie zalogowanego uzytkownika
@user = current_user # dbamy o to, aby nikt nie usunal za nas naszych znajomych
@friend = User.find(params[:id]) rescue nil
if @friend
if @user.any_friends.include?(@friend)
Friendship.breakup(@user,@friend)
flash[:notice] = 'Zaproszenie zostało usunięte'
else
flash[:notice] = 'Nie ma takiego zaproszenia'
end
else
flash[:notice] = 'Nie ma takiego użytkownika'
end
redirect_to user_friendships_url(@user)
end
# (...)
Zasób Friendships zagnieżdzony został przeze mnie w zasobie Users, co zapewnia taki fragment routes.rb:
# config/routes.rb
# (...)
map.resources :users do |u|
u.resources :friendships
end
# (...)
Wydaje mi się, że takie rozwiązanie jest jednym z najprostszych, a mimo to zapewnia nam podstawowe rzeczy, które mogą okazać się przydatne, np. to, kto był stroną wysyłającą zaproszenie, czy kiedy zostało ono zaakceptowane.
Jedyną rzeczą, którą bym zmienił to trzymanie statusów znajomości w jakimś hashu: STATUSES { :pending => „”, :accepted => „”, :sent => „” }. Wtedy wszędzie tam gdzie np. potrzebujesz zaakceptowanych znajomych dasz User::STATUSES[:accepted].
05 kwietnia 2008 o 18:00:39Seban: napisalem, ze statusy jako stringi to uproszczenie :-) W projekcie mam stale, chociaz nie w hashu – dzieki za przypomnienie o jego istnieniu, rzeczywiście w hashu będzie to ładniej wyglądać
05 kwietnia 2008 o 18:03:09W jednym projekcie nad którym pracuję używamy tabalic slownikowych. Ma to swoje plusy i minusy, ale takie stałe w klasie według mnie są lepsze.
05 kwietnia 2008 o 23:22:36Dla takich modeli ze statusem, idealny jest plugin http://agilewebdevelopment.com/plugins/acts_as_state_machine , jestem w trakcie konczenia wpisu o nim.
09 kwietnia 2008 o 22:47:12Ghandal: fajnie to wszystko wygląda. Po prostu piękny kod, czytam i wszystko wiem co Twoje relacje oznaczają. Na tym polega piękno i potęga Rubiego/Railsów :).
11 kwietnia 2008 o 19:40:26Jak wyciągnąć np 30 ostatnich znajomości z serwisu? Problem tkwi w tym że 2 rekordy mówią o tej samej znajomości.
Można by zrobić DISTINCT na kolumnie accepted_at ale może zdarzyć się tak że zostanie nawiązanych kilka znajomości dokładnie w tym samym czasie.
Pozdrawiam
12 sierpnia 2008 o 12:26:43Mack: najprostszym rozwiazaniem, ktore mi w tej chwili przychodzi do glowy, to zrobienie joina z tabela friendship (user_id z friend_id) i wyciagniecie ostatnich 30 rekordow posortowanych po created/accepted_at
12 sierpnia 2008 o 12:29:18Ok, mam zapytanie i działa:
SELECT * FROM friendships as fr1
JOIN friendships as fr2 ON fr2.user_id = fr1.friend_id AND fr2.status = ‘zaakceptowane’
WHERE fr1.user_id < fr1.friend_id ADN fr1.status = ‘zaakceptowane’
Jak to teraz zapisać ładnie w railsach?
12 sierpnia 2008 o 14:10:32Od razu mowie, ze pisze z glowy, ale powinno byc ok. W kazdym razie idea powinna byc oczywista ;-)
latest = Friendship.find( :all, :joins => "JOIN friendships AS fr ON friendships.user_id=fr.friend_id", :select => "friendships.*", :conditions => ["friendships.id != fr.id AND status=?","zaakceptowane"])12 sierpnia 2008 o 14:11:21w powyższym kodzie jest pomyłka w conditions (nie taki warunek na id), ale mysle, ze wszystko jest jasne
12 sierpnia 2008 o 14:19:55Kod wygląda żywcem wzięty z książki „RailsSpace: Building a Social Networking Website with Ruby on Rails”
19 sierpnia 2008 o 10:38:46Napisałem przecież, że stworzyłem hybrydę z rzeczy, które znalazłem i które wymyśliłem. Poza tym w RailsSpace nie było jeszcze REST-owych kontrolerów, bo to w ogóle książka nie pierwszej świeżości ;-)
19 sierpnia 2008 o 10:47:58