Как стать автором
Обновить

Размышления о реализации социального графа

Время на прочтение8 мин
Количество просмотров1.5K
Здравствуйте!

Мы все привыкли пользоваться социальными сетями. Одной из их основ является установление социально значимых связей между пользователями. Как правило, эти связи — дружба или поклонники (последователи).

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

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



Некоторое время назад мне пришлось несколько раз сталкиваться с реализацией функционала социальных связей в ROR проектах. В первом случае это был проект в котором реализовывалась дружба между участниками, во втором создавались связи типа «последователь». Оба проекта коммерческие — имен не называю. Извините.

Общая суть была в том, что создавалась связь с названием похожим на Friendship в которой было 2 идентификатора пользователей и состояние этой связи. pending — заявка на дружбу подана и ожидает подтверждения, accepted — заявка подтверждена и активна, rejected — заявка отклонена, deleted — связь удалена.

Кроме того, я обратил внимание, что обычно при создании связи от одного человека №1 к человеку №2 (в тех реализациях которые я видел) создается вторая связь-близнец, которая отличается только тем, что id пользователей переставлены местами. Состояние записи-близнеца копируются из оригинала при каждом изменении. Такой подход понятен — выборки связей для конкретного пользователя проводятся одним запросом к БД. Однако, вам необходимо дополнительная запись в БД и обеспечение контроля изменения статуса записи.

Забегая вперед, скажу, что я решил в своем варианте кода не плодить записи и пошел по пути подачи 2 запросов к БД.

Мир сложнее, чем он отображен в социальных сетях



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

Заметили? Часто роли людей во взаимосвязи не равнозначны, это вам не просто — друзья. Все немного сложнее.

Так же, как правило у социальной связи есть контекст (жизнь, работа, армия, школа) — т.е. место, где эта связь была установлена.

Что мне не нравится в социальных сетях сейчас? Так это то, что добавляя в друзья случайных знакомых или людей, которых ты знаешь только в лицо, а потом удаляя их из своего «послужного списка» во время ревизии (плохого настроения) порою приходится объясняться — мол, извини, ты не враг мне, и лично против тебя я ничего не имею — но держать в листе «друзей» тебя больше не хочу — мы не виделись уже несколько лет (и я даже не помню как тебя зовут) — sorry, но не вижу особого смысла.

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

Уделив 45 минут времени Rails 3 я попробовал накидать некий прототип того, что неожиданно взбудоражило мой воспаленный учительский разум.

Модель



Модель (я назову ее Graph) содержит 2 id пользователей (подавшего заявку и получателя заявки), статус заявки, роль отправителя и роль получателя, а так же контекст социальной связи.
rails g model graph context:string sender_id:integer sender_role:string recipient_id:integer recipient_role:string state:string 


Что, дает следующую миграцию:

class CreateGraphs < ActiveRecord::Migration
  def self.up
    create_table :graphs do |t|

      t.string :context
      t.integer :sender_id
      t.string :sender_role
      t.integer :recipient_id
      t.string :recipient_role
      t.string :state

      t.timestamps
    end
  end

  def self.down
    drop_table :graphs
  end
end


Выполним в консоли:
rake db:migrate

Что создаст нам в БД необходимую таблицу с заданными полями.

В самом файле модели Graph я с помощью state machine определил какие состояния может принимать элемент графа, а scope позволит мне дополнить запросы к БД необходимыми условиями.


class Graph < ActiveRecord::Base
  scope :pending, where(:state => :pending)
  scope :accepted, where(:state => :accepted)
  scope :rejected, where(:state => :rejected)
  scope :deleted, where(:state => :deleted)

  #state pending, accepted, rejected, deleted
  state_machine :state, :initial => :pending do
    event :accept do
      transition :pending => :accepted
    end
    event :reject do
      transition :pending => :rejected
    end
    event :delete do
      transition all => :deleted
    end
    event :initial do
      transition all => :pending
    end
  end

end


В модель User (она по-любому есть в каждом Rails App) я для начала добавлю метод: graph_to, который вернет мне элемент графа к данному пользователю (если элемент графа существует) или просто создаст новый элемент.

Элемент графа я строю от текущего пользователя до другого пользователя, в некотором контексте, где я являюсь кем-то и получатель так же, является кем-то (согласно предопределенных ролей).
По-умолчанию контекстом является жизнь, а пользователи имеют роли — друг.


class User < ActiveRecord::Base

  def graph_to(another_user, opts={:context=>:live, :me_as=>:friend, :him_as=>:friend})
    Graph.where(:context=>opts[:context], :sender_id=>self.id, :sender_role=>opts[:me_as], :recipient_id=>another_user, :recipient_role=>[:him_as]).first ||
    graphs.new( :context=>opts[:context], :sender_role=>opts[:me_as], :recipient_id=>another_user.id, :recipient_role=>opts[:him_as])
  end
end


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

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


namespace :db do
  namespace :graphs do

    # rake db:graphs:create
    desc 'create graphs for development'
    task :create => :environment do

      i = 1
      puts 'Test users creating'
      100.times do |i|
        u = User.new(
          :login => "user#{i}",
          :email => "test-user#{i}@ya.ru",
          :name=>"User Number #{i}",
          :password=>'qwerty',
          :password_confirmation=>'qwerty'
        )

        u.save
        puts "test user #{i} created"
        i = i.next
      end#n.times
      puts 'Test users created'

      contexts = [:live, :web, :school, :job, :military, :family]
      roles={
        :live=>[:friend,:friend],
        :web=>[:moderator, :user],
        :school=>[:teacher, :student],
        :job=>[:chief, :worker],
        :military=>[:officer, :soldier],
        :family=>[:child, :parent]
      }

       users = User.where("id > 10 and id < 80") #70 users
       test_count = 4000
       test_count.times do |i|
          sender = users[rand(69)]
          recipient = users[rand(69)]

          context = contexts.rand # :job
          role = roles[context].shuffle # [:worker, :chiеf]
          # trace
          p "test graph #{i}/#{test_count} " + sender.class.to_s+" to "+recipient.class.to_s + " with context: " + context.to_s
          
          graph = sender.graph_to(recipient, :context=>context, :me_as=>role.first, :him_as=>role.last)
          graph.save
          # set graph state
          reaction = [:accept, :reject, :delete, :initial].rand
          graph.send(reaction)
       end# n.times
      
    end# db:graphs:create
  end#:graphs
end#:db


Инвертированные элементы графа


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

Это я сделаю добавив в модель User строки:

  has_many :graphs, :foreign_key=>:sender_id
  has_many :inverted_graphs, :class_name => 'Graph', :foreign_key=>:recipient_id

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

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

def accepted_chiefs_from_job
   chiefs = graphs.accepted.where(:context => :job, :recipient_role=>:chief) # my graphs
   _chiefs = inverted_graphs.accepted.where(:context => :job, :sender_role=>:chief) # foreign graphs
  chiefs | _chiefs
 end

Оператор | является оператором объединения массивов. По мне, так очень красиво.

Немного мета-программирования и ruby магии


У меня очень много контекстов и ролей пользователей во взаимосвязях. Мне нужно много методов, подобных вышеизложенному методу accepted_chiefs_from_job который выбирает всех моих начальников с работы, которых я согласился добавить. Вы же не думаете писать их в ручную?
Мы используем мета-программирование, что бы руби сам создавал нам нужные методы и делал соответствующие выборки. Поможет в этом волшебный метод method_missing(method_name, *args). Этот метод вызывается когда руби не находит какой-то метод. Вот тут то мы ему и поясним, что нужно делать в случае, когда он встретит попытку выборки данных из графа.

Руби будет сам создавать методы подобные этим:

user.accepted_friends_from_live
user.rejected_friends_from_live
user.deleted_friends_from_live

user.deleted_chiefs_from_job
user.accepted_chiefs_from_job
user.rejected_chiefs_from_job

user.accepted_teachers_from_school
user.deleted_teachers_from_school


Добавим в модель User следующее:

  def method_missing(method_name, *args)
    if /^(.*)_(.*)_from_(.*)$/.match(method_name.to_s)
      match = $~
      state = match[1].to_sym
      role = match[2].singularize.to_sym
      context = match[3].to_sym
      graphs.send(state).where(:context => context, :recipient_role=>role) | inverted_graphs.send(state).where(:context => context, :sender_role=>role)
    else
      super
    end
  end


Если method_missing(method_name, *args) не находит какой-то метод, то он попытается его распарсить по регулярке. Если регулярка подходит под название методов нашего графа, то руби сам состовит запрос по полученным из строки данным и вернет результат. Если вызываемый метод не подходит под регулярку, то method_missing(method_name, *args) просто перейдет к своему стандартном у поведению — super, и, вероятно, даст ошибку выполнения кода.

Итоговый код User:

class User < ActiveRecord::Base
  has_many :pages

  has_many :graphs, :foreign_key=>:sender_id
  has_many :inverted_graphs, :class_name => 'Graph', :foreign_key=>:recipient_id

  def method_missing(method_name, *args)
    if /^(.*)_(.*)_from_(.*)$/.match(method_name.to_s)
      match = $~
      state = match[1].to_sym
      role = match[2].singularize.to_sym
      context = match[3].to_sym
      graphs.send(state).where(:context => context, :recipient_role=>role) | inverted_graphs.send(state).where(:context => context, :sender_role=>role)
    else
      super
    end
  end

  def graph_to(another_user, opts={:context=>:live, :me_as=>:friend, :him_as=>:friend})
    Graph.where(:context=>opts[:context], :sender_id=>self.id, :sender_role=>opts[:me_as], :recipient_id=>another_user, :recipient_role=>[:him_as]).first ||
    graphs.new( :context=>opts[:context], :sender_role=>opts[:me_as], :recipient_id=>another_user.id, :recipient_role=>opts[:him_as])
  end
end

Ну вот и всё


Теперь выполняем рейк:
rake db:graphs:create

Запускаем rails консоль
rails c

Пробуем выполнять:

u = User.find(10)
u.graph_to(User.first, :context=>:job, :me_as=>:boss, :him_as=>:staff_member)
u.graph_to(User.last, :context=>:school, :me_as=>:student, :him_as=>:teacher)
u.graph_to(User.find(20), :context=>:school, :me_as=>:student, :him_as=>:school)

u.accepted_friends_from_live
u.rejected_friends_from_live
u.deleted_friends_from_live

u.deleted_chiefs_from_job
u.accepted_chiefs_from_job
u.rejected_chiefs_from_job


PS:
Прикладным программистам уважение и пожелание удачи от школьного учителя!
Теги:
Хабы:
+40
Комментарии34

Публикации

Истории

Работа

Ruby on Rails
10 вакансий

Ближайшие события

Weekend Offer в AliExpress
Дата20 – 21 апреля
Время10:00 – 20:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн