Using Redis sets (unique lists) to track relationships between users and their friends, and making friend suggestions
In this blog post I’ll share some code I was tinkering with that uses Redis sets (unique lists) to track relationships between users and their friends. My intention was to create a bunch of users, add a number of friends for each user, and then make a friend suggestion based on a user’s friends.. friends.
I started by creating a Redis module that when included would add its methods to the class and its instances. file: lib/base.rb
require 'redis'
module FriendFinder
module Base
module Methods
def redis
@@redis ||= redis_connection
end
private
def redis_connection
Redis.new
end
end
def self.included(klass)
klass.extend(Methods)
klass.send(:include, Methods)
end
end
end
Here are the guts of the User model module. file: lib/user.rb
module FriendFinder
class User
include Base
include Friend
def initialize(options = {})
@data = options ||= {}
fail 'Options must be a Hash' unless options.is_a?(Hash)
# dynamically add a few methods
# IE: makes user object instance var @data[:id] accessible as user.id
[:id, :first_name, :last_name].each do |meth|
unless self.class.method_defined?(meth)
self.class.send(:define_method, meth) do
@data[meth]
end
end
end
end
# create a new user and store to redis hash
# ex key: FriendFinder::User:1
def create!
options = default_options.merge(@data)
redis.hmset(to_key, options.flatten)
redis.sadd(self.class.id_key, id)
end
def default_options
random_name
end
def random_name
@@names ||= load_random_names
{ first_name: @@names[:first].sample, last_name: @@names[:last].sample }
end
# load random names from a file,
# formatted: FIRST LAST\n
def load_random_names
ret = { first: [], last: [] }
File.readlines('names.txt').each do |line|
first_name, last_name = line.strip.split(/\s/)
ret[:first] << first_name
ret[:last] << last_name
end
ret
end
# define redis key for user
# ex key: FriendFinder::User:1
def to_key
fail 'Id required' if id.nil?
"#{self.class.name}:#{id}"
end
# load a user's data from redis hash
def load!
data = redis.hgetall(to_key)
data = Hash[data.map { |(k, v)| [k.to_sym, v] }]
@data.merge! data
self
end
def puts_output
puts "ID:\t\t#{id}"
puts "first name:\t#{first_name}"
puts "last name:\t#{last_name}"
puts
end
#
# Class methods
#
# define key to track set of redis user ids
def self.id_key
"#{name}:ID"
end
# select random user id
def self.random_user_id
redis.srandmember id_key
end
# create a bunch of users
def self.create_users!(how_many = 1_000)
fail 'How many must be an Integer' unless how_many.respond_to?(:times)
how_many.times do |i|
new(id: i + 1).create!
end
end
# create a bunch of friends for each user
def self.create_friends!(how_many_each = 100)
fail 'How many each must be an Integer' unless how_many_each.respond_to?(:times)
all_ids.each do |id|
user = new(id: id).load!
while user.friends_size < how_many_each
user.friends_create!(random_user_id)
end
end
end
# get a list of all user ids
def self.all_ids
redis.smembers id_key
end
end
end
I created another module for Friend methods, which is included by the User module. file: lib/friend.rb
module FriendFinder
module Friend
def self.included(klass)
klass.extend(ClassMethods)
klass.send(:include, InstanceMethods)
end
module InstanceMethods
def friends_key
fail 'Id required' if id.nil?
"#{self.class.name}:#{id}:Friends"
end
def friends_size
redis.scard friends_key
end
def has_friend?(friend_id)
redis.sismember friends_key, friend_id
end
def friends_create!(friend_id)
return false if has_friend?(friend_id)
redis.sadd friends_key, friend_id
end
def friend_ids
redis.smembers friends_key
end
end
module ClassMethods
def create_friends!(how_many_each = 100)
fail 'How many each must be an Integer' unless how_many_each.respond_to?(:times)
all_ids.each do |id|
user = new(id: id).load!
while user.friends_size < how_many_each
user.friends_create!(random_user_id)
end
end
end
def suggest_friend(user_id = nil)
user_id ||= random_user_id
fail 'User id required' if user_id.nil?
user = new(id: user_id).load!
# output
puts 'Randomly selected user:'
user.puts_output
# get friends of friends
friend_ids = user.friend_ids
friends_of_friends = []
friend_ids.each do |friend_id|
friends_of_friends.push(*new(id: friend_id).load!.friend_ids)
end
fail 'No friends of friends found' if friends_of_friends.empty?
# group/count
friend_counts = friends_of_friends.inject(Hash.new(0)) { |h, i| h[i] += 1; h }
# sort
friend_counts = Hash[friend_counts.sort_by { |_k, v| -v }]
# remove existing
friend_counts.reject! { |k, _v| friend_ids.include?(k) }
# get first friend suggestion
suggested_friend_id, suggested_friend_count = friend_counts.first
suggested_friend = new(id: suggested_friend_id).load!
# output
puts 'Suggested friend:'
suggested_friend.puts_output
puts "Number of friends with #{suggested_friend.first_name}: #{suggested_friend_count}"
end
end
end
end
I created the next two modules to parse command line flags and interact with user objects.
# file: lib/main.rb
module FriendFinder
module Main
extend self
def execute
options = CommandLine.options
case options[:action]
when :create_users
User.create_users!
when :create_friends
User.create_friends!
when :suggest_friend
User.suggest_friend
else
fail 'Action required'
end
end
end
end
# file: lib/command_line.rb
require 'optparse'
module FriendFinder
module CommandLine
extend self
def options
@options ||= parse_options
end
def parse_options
options = {}
OptionParser.new do |opts|
opts.banner = "Usage: #{$PROGRAM_NAME} [options]"
opts.on('--create-users', 'Create users') do |_v|
options[:action] = :create_users
end
opts.on('--create-friends', 'Create friends') do |_v|
options[:action] = :create_friends
end
opts.on('--suggest-friend', 'Suggest friend') do |_v|
options[:action] = :suggest_friend
end
end.parse!
options
end
end
end
I tied all the modules together with a single file in the root of the project. file: main.rb
#!/usr/bin/env jruby
lib_dir = File.dirname(__FILE__) + '/lib'
$LOAD_PATH.unshift(lib_dir) unless $LOAD_PATH.include?(lib_dir)
require 'command_line'
require 'main'
require 'base'
require 'friend'
require 'user'
FriendFinder::Main.execute
Usage:
# monitor redis (separate terminal)
redis-cli monitor
# create users
./main.rb --create-users
# create friends
./main.rb --create-friends
# suggest a friend
./main.rb --suggest-friend
Sample output:
Randomly selected user:
ID: 597
first name: Pearlie
last name: Christiansen
Suggested friend:
ID: 911
first name: Ilana
last name: Kunkel
Number of friends with Ilana: 27