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
Source code on Github .