Pull to refresh

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

Reading time 8 min
Views 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:
Прикладным программистам уважение и пожелание удачи от школьного учителя!
Tags:
Hubs:
+40
Comments 34
Comments Comments 34

Articles