前言:
实时计算与离线计算应用于推荐系统上最大的不同在于实时计算推荐结果应该反映最近一段时间用户近期的偏好,而离线计算推荐结果则是根据用户从第一次评分起的所有评分记录来计算用户总体的偏好。
用户对物品的偏好随着时间的推移总是会改变的。比如一个用户u 在某时刻对商品p 给予了极高的评分,那么在近期一段时候,u 极有可能很喜欢与商品p 类似的其他商品;而如果用户u 在某时刻对商品q 给予了极低的评分,那么在近期一段时候,u 极有可能不喜欢与商品q 类似的其他商品。所以对于实时推荐,当用户对一个商品进行了评价后,用户会希望推荐结果基于最近这几次评分进行一定的更新,使得推荐结果匹配用户近期的偏好,满足用户近期的口味。
如果实时推荐继续采用离线推荐中的ALS 算法,由于算法运行时间巨大,不具有实时得到新的推荐结果的能力;并且由于算法本身的使用的是评分表,用户本次评分后只更新了总评分表中的一项,使得算法运行后的推荐结果与用户本次评分之前的推荐结果基本没有多少差别,从而给用户一种推荐结果一直没变化的感觉,很影响用户体验。
另外,在实时推荐中由于时间性能上要满足实时或者准实时的要求,所以算法的计算量不能太大,避免复杂、过多的计算造成用户体验的下降。鉴于此,推荐精度往往不会很高。实时推荐系统更关心推荐结果的动态变化能力,只要更新推荐结果的理由合理即可,至于推荐的精度要求则可以适当放宽。
所以对于实时推荐算法,主要有两点需求:
(1)用户本次评分后、或最近几个评分后系统可以明显的更新推荐结果;
(2)计算量不大,满足响应时间上的实时或者准实时要求;
(1)实时推荐模型算法设计
当用户u 对商品p 进行了评分,将触发一次对u 的推荐结果的更新。由于用户u 对商品p 评分,对于用户u 来说,他与p 最相似的商品们之间的推荐强度将发生变化,所以选取与商品p 最相似的K 个商品作为候选商品。
每个候选商品按照“推荐优先级”这一权重作为衡量这个商品被推荐给用户u 的优先级。
这些商品将根据用户u 最近的若干评分计算出各自对用户u 的推荐优先级,然后与上次对用户u 的实时推荐结果的进行基于推荐优先级的合并、替换得到更新后的推荐结果。
具体来说:
首先,获取用户u 按时间顺序最近的K 个评分,记为RK;获取商品p 的最相似的K 个商品集合,记为S;
然后,对于每个商品q S ,计算其推荐优先级 ,计算公式如下:
其中:
表示用户u 对商品r 的评分;
sim(q,r)表示商品q 与商品r 的相似度,设定最小相似度为0.6,当商品q和商品r 相似度低于0.6 的阈值,则视为两者不相关并忽略;
sim_sum 表示q 与RK 中商品相似度大于最小阈值的个数;
incount 表示RK 中与商品q 相似的、且本身评分较高(>=3)的商品个数;
recount 表示RK 中与商品q 相似的、且本身评分较低(<3)的商品个数;
公式的意义如下:
首先对于每个候选商品q,从u 最近的K 个评分中,找出与q 相似度较高(>=0.6)的u 已评分商品们,对于这些商品们中的每个商品r,将r 与q 的相似度乘以用户u 对r 的评分,将这些乘积计算平均数,作为用户u 对商品q 的评分预测即:
然后,将u 最近的K 个评分中与商品q 相似的、且本身评分较高(>=3)的商品个数记为 incount,计算lgmax{incount,1}作为商品 q 的“增强因子”,意义在于商品q 与u 的最近K 个评分中的n 个高评分(>=3)商品相似,则商品q 的优先级被增加lgmax{incount,1}。如果商品 q 与 u 的最近 K 个评分中相似的高评分商品越多,也就是说n 越大,则商品q 更应该被推荐,所以推荐优先级被增强的幅度较大;如果商品q 与u 的最近K 个评分中相似的高评分商品越少,也就是n 越小,则推荐优先级被增强的幅度较小;
而后,将u 最近的K 个评分中与商品q 相似的、且本身评分较低(<3)的商品个数记为 recount,计算lgmax{recount,1}作为商品 q 的“削弱因子”,意义在于商品q 与u 的最近K 个评分中的n 个低评分(<3)商品相似,则商品q 的优先级被削减lgmax{incount,1}。如果商品 q 与 u 的最近 K 个评分中相似的低评分商品越多,也就是说n 越大,则商品q 更不应该被推荐,所以推荐优先级被减弱的幅度较大;如果商品q 与u 的最近K 个评分中相似的低评分商品越少,也就是n 越小,则推荐优先级被减弱的幅度较小;
最后,将增强因子增加到上述的预测评分中,并减去削弱因子,得到最终的q 商品对于u 的推荐优先级。在计算完每个候选商品q 的 后,将生成一组<商品q 的ID, q 的推荐优先级>的列表updatedList:
而在本次为用户u 实时推荐之前的上一次实时推荐结果Rec 也是一组<商品m,m 的推荐优先级>的列表,其大小也为K:
接下来,将updated_S 与本次为u 实时推荐之前的上一次实时推荐结果Rec进行基于合并、替换形成新的推荐结果NewRec:
其中,i表示updated_S 与Rec 的商品集合中的每个商品,topK 是一个函数,表示从 Rec updated _ S中选择出最大的 K 个商品,cmp = 表示topK 函数将推荐优先级 值最大的K 个商品选出来。最终,NewRec 即为经过用户u 对商品p 评分后触发的实时推荐得到的最新推荐结果。
总之,实时推荐算法流程流程基本如下:
(1)用户u 对商品p 进行了评分,触发了实时推荐的一次计算;
(2)选出商品p 最相似的K 个商品作为集合S;
(3)获取用户u 最近时间内的K 条评分,包含本次评分,作为集合RK;
(4)计算商品的推荐优先级,产生
将updated_S 与上次对用户u 的推荐结果Rec 利用公式(4-4)进行合并,产生新的推荐结果NewRec;作为最终输出。
package com.lzl.streaming
import com.mongodb.casbah.{MongoClient, MongoClientURI}
import com.mongodb.casbah.commons.MongoDBObject
import org.apache.kafka.common.serialization.StringDeserializer
import org.apache.spark.SparkConf
import org.apache.spark.sql.SparkSession
import org.apache.spark.streaming.kafka010.{ConsumerStrategies, KafkaUtils, LocationStrategies}
import org.apache.spark.streaming.{Seconds, StreamingContext}
import redis.clients.jedis.Jedis
// 定义一个连接助手对象,建立到redis和mongodb的连接
object ConnHelper extends Serializable{
// 懒变量定义,使用的时候才初始化
lazy val jedis = new Jedis("localhost")
lazy val mongoClient = MongoClient(MongoClientURI("mongodb://localhost:27017/recommender"))
}
/**
* 实时推荐模块
* @param uri
* @param db
*/
//创建mongodb连接类
case class MongoConfig(uri:String,db:String)
// 定义标准推荐对象
case class Recommendation(productId:Int,score:Double)
//定义用户的推荐标准
case class UserRecs(userId:Int, recs:Seq[Recommendation])
//定义商品的相似度列表
case class ProductRecs(productId:Int, recs:Seq[Recommendation])
object StreamingRecommender {
// 定义常量和表名
val MONGODB_RATING_COLLECTION = "Rating"
val STREAM_RECS = "StreamRecs"
val PRODUCT_RECS = "ProductRecs"
val MAX_USER_RATING_NUM=20
val MAX_SIM_PRODUCTS_NUM=20
//入口方法
def main(args: Array[String]): Unit = {
val config= Map(
"spark.cores"->"local[*]",
"mongo.uri"->"mongodb://localhost:27017/recommender",
"mongo.db"->"recommender",
"kafka.topic"->"recommender"
)
// 创建spark conf
val sparkConf=new SparkConf().setMaster(config("spark.cores")).setAppName("StreamingRecommender")
val spark = SparkSession.builder().config(sparkConf).getOrCreate()
val sc=spark.sparkContext
val ssc=new StreamingContext(sc,Seconds(2))
import spark.implicits._
implicit val mongoConfig=MongoConfig(config("mongo.uri"),config("mongo.db"))
// 加载数据,相似度矩阵,广播出去
val simProductsMatrix = spark.read
.option("uri", mongoConfig.uri)
.option("collection", PRODUCT_RECS)
.format("com.mongodb.spark.sql")
.load()
.as[ProductRecs]
.rdd
// 为了后续查询相似度方便,把数据转换成map形式
.map{ item =>
(item.productId,item.recs.map(x=>(x.productId,x.score)).toMap)
}.collectAsMap()
// 定义广播变量
val simProcutsMatrixBC = sc.broadcast(simProductsMatrix)
// 创建kafka配置参数
val kafkaParam= Map(
"bootstrap.servers"->"hadoop101:9092",
"key.deserializer"->classOf[StringDeserializer],
"value.deserializer"->classOf[StringDeserializer],
"group.id"->"recommender",
"auto.offset.reset" -> "latest"
)
// 创建一个DStream
val kafkaStream = KafkaUtils.createDirectStream[String, String]( ssc,
LocationStrategies.PreferConsistent,
ConsumerStrategies.Subscribe[String, String]( Array(config("kafka.topic")), kafkaParam )
)
// 对kafkaStream进行处理,产生评分流,userId|productId|score|timestamp
val ratingStream = kafkaStream.map{msg=>
var attr = msg.value().split("\\|")
( attr(0).toInt, attr(1).toInt, attr(2).toDouble, attr(3).toInt )
}
// 核心算法部分,定义评分流的处理流程
ratingStream.foreachRDD{
rdds => rdds.foreach{
case ( userId, productId, score, timestamp ) =>
println("rating data coming!>>>>>>>>>>>>>>>>>>")
// TODO: 核心算法流程
// 1. 从redis里取出当前用户的最近评分,保存成一个数组Array[(productId, score)]
val userRecentlyRatings = getUserRecentlyRatings( MAX_USER_RATING_NUM, userId, ConnHelper.jedis )
// 2. 从相似度矩阵中获取当前商品最相似的商品列表,作为备选列表,保存成一个数组Array[productId]
val candidateProducts = getTopSimProducts( MAX_SIM_PRODUCTS_NUM, productId, userId, simProcutsMatrixBC.value )
// 3. 计算每个备选商品的推荐优先级,得到当前用户的实时推荐列表,保存成 Array[(productId, score)]
val streamRecs = computeProductScore( candidateProducts, userRecentlyRatings, simProcutsMatrixBC.value )
// 4. 把推荐列表保存到mongodb
saveDataToMongoDB( userId, streamRecs )
}
}
// 启动streaming
ssc.start()
println("streaming started!")
ssc.awaitTermination()
}
/**
* 从redis里获取最近num次评分
*/
import scala.collection.JavaConversions._
def getUserRecentlyRatings(num: Int, userId: Int, jedis: Jedis): Array[(Int, Double)] = {
// 从redis中用户的评分队列里获取评分数据,list键名为uid:USERID,值格式是 PRODUCTID:SCORE
jedis.lrange( "userId:" + userId.toString, 0, num )
.map{ item =>
val attr = item.split("\\:")
( attr(0).trim.toInt, attr(1).trim.toDouble )
}
.toArray
}
// 获取当前商品的相似列表,并过滤掉用户已经评分过的,作为备选列表
def getTopSimProducts(num: Int,
productId: Int,
userId: Int,
simProducts: scala.collection.Map[Int, scala.collection.immutable.Map[Int, Double]])
(implicit mongoConfig: MongoConfig): Array[Int] ={
// 从广播变量相似度矩阵中拿到当前商品的相似度列表
val allSimProducts = simProducts(productId).toArray
// 获得用户已经评分过的商品,过滤掉,排序输出
val ratingCollection = ConnHelper.mongoClient( mongoConfig.db )( MONGODB_RATING_COLLECTION )
val ratingExist = ratingCollection.find( MongoDBObject("userId"->userId) )
.toArray
.map{item=> // 只需要productId
item.get("productId").toString.toInt
}
// 从所有的相似商品中进行过滤
allSimProducts.filter( x => ! ratingExist.contains(x._1) )
.sortWith(_._2 > _._2)
.take(num)
.map(x=>x._1)
}
// 计算每个备选商品的推荐得分
def computeProductScore(candidateProducts: Array[Int],
userRecentlyRatings: Array[(Int, Double)],
simProducts: scala.collection.Map[Int, scala.collection.immutable.Map[Int, Double]])
: Array[(Int, Double)] ={
// 定义一个长度可变数组ArrayBuffer,用于保存每一个备选商品的基础得分,(productId, score)
val scores = scala.collection.mutable.ArrayBuffer[(Int, Double)]()
// 定义两个map,用于保存每个商品的高分和低分的计数器,productId -> count
val increMap = scala.collection.mutable.HashMap[Int, Int]()
val decreMap = scala.collection.mutable.HashMap[Int, Int]()
// 遍历每个备选商品,计算和已评分商品的相似度
for( candidateProduct <- candidateProducts; userRecentlyRating <- userRecentlyRatings ){
// 从相似度矩阵中获取当前备选商品和当前已评分商品间的相似度
val simScore = getProductsSimScore( candidateProduct, userRecentlyRating._1, simProducts )
if( simScore > 0.4 ){
// 按照公式进行加权计算,得到基础评分
scores += ( (candidateProduct, simScore * userRecentlyRating._2) )
if( userRecentlyRating._2 > 3 ){
increMap(candidateProduct) = increMap.getOrDefault(candidateProduct, 0) + 1
} else {
decreMap(candidateProduct) = decreMap.getOrDefault(candidateProduct, 0) + 1
}
}
}
// 根据公式计算所有的推荐优先级,首先以productId做groupby
scores.groupBy(_._1).map{
case (productId, scoreList) =>
( productId, scoreList.map(_._2).sum/scoreList.length + log(increMap.getOrDefault(productId, 1)) - log(decreMap.getOrDefault(productId, 1)) )
}
// 返回推荐列表,按照得分排序
.toArray
.sortWith(_._2>_._2)
}
def getProductsSimScore(product1: Int, product2: Int,
simProducts: scala.collection.Map[Int, scala.collection.immutable.Map[Int, Double]]): Double ={
simProducts.get(product1) match {
case Some(sims) => sims.get(product2) match {
case Some(score) => score
case None => 0.0
}
case None => 0.0
}
}
// 自定义log函数,以N为底
def log(m: Int): Double = {
val N = 10
math.log(m)/math.log(N)
}
// 写入mongodb
def saveDataToMongoDB(userId: Int, streamRecs: Array[(Int, Double)])(implicit mongoConfig: MongoConfig): Unit ={
val streamRecsCollection = ConnHelper.mongoClient(mongoConfig.db)(STREAM_RECS)
// 按照userId查询并更新
streamRecsCollection.findAndRemove( MongoDBObject( "userId" -> userId ) )
streamRecsCollection.insert( MongoDBObject( "userId" -> userId,
"recs" -> streamRecs.map(x=>MongoDBObject("productId"->x._1, "score"->x._2)) ) )
}
}
(1)启动mongodb,查看里面的数据:
> show dbs
admin 0.000GB
config 0.000GB
local 0.000GB
recommender 0.005GB
> use recommender
switched to db recommender
> show tables
AverageProducts
Product
ProductRecs
RateMoreProducts
RateMoreRecentlyProducts
Rating
UserRecs
//查看Rating表的数量
> db.Rating.find().count()
44852
//升序排序
> db.Rating.find({userId:4867})
{ "_id" : ObjectId("60abdbe01f34d8059447a53c"), "userId" : 4867, "productId" : 457976, "score" : 5, "timestamp" : 1395676800 }
{ "_id" : ObjectId("60abdbe11f34d8059447b617"), "userId" : 4867, "productId" : 231449, "score" : 3, "timestamp" : 1369238400 }
{ "_id" : ObjectId("60abdbe11f34d8059447d549"), "userId" : 4867, "productId" : 425715, "score" : 5, "timestamp" : 1395676800 }
{ "_id" : ObjectId("60abdbe11f34d8059447eca4"), "userId" : 4867, "productId" : 250451, "score" : 3, "timestamp" : 1381939200 }
{ "_id" : ObjectId("60abdbe11f34d8059448455a"), "userId" : 4867, "productId" : 294209, "score" : 1, "timestamp" : 1383235200 }
//降序排序
> db.Rating.find({userId:4867}).sort({timestamp:1})
{ "_id" : ObjectId("60abdbe11f34d8059447b617"), "userId" : 4867, "productId" : 231449, "score" : 3, "timestamp" : 1369238400 }
{ "_id" : ObjectId("60abdbe11f34d8059447eca4"), "userId" : 4867, "productId" : 250451, "score" : 3, "timestamp" : 1381939200 }
{ "_id" : ObjectId("60abdbe11f34d8059448455a"), "userId" : 4867, "productId" : 294209, "score" : 1, "timestamp" : 1383235200 }
{ "_id" : ObjectId("60abdbe01f34d8059447a53c"), "userId" : 4867, "productId" : 457976, "score" : 5, "timestamp" : 1395676800 }
{ "_id" : ObjectId("60abdbe11f34d8059447d549"), "userId" : 4867, "productId" : 425715, "score" : 5, "timestamp" : 1395676800 }
>
(2)启动redis,向其写入数据:
//启动redis,查看redis当前库(没有数据)
127.0.0.1:6379> keys *
(empty list or set)
//写入操作(lpush userId:4867 231449:3.0)
127.0.0.1:6379> lpush userId:4867 231449:3.0
(integer) 1
//查看数据写入(插入)的信息
127.0.0.1:6379> keys *
1) "userId:4867"
//写入操作(5个数据)
127.0.0.1:6379> lpush userId:4867 250451:3.0 294209:1.0 457976:5.0 425715:5.0
(integer) 5
//查看数据写入(插入)的信息
127.0.0.1:6379> lrange userId:4867 0 -1
1) "425715:5.0"
2) "457976:5.0"
3) "294209:1.0"
4) "250451:3.0"
5) "231449:3.0"
127.0.0.1:6379>
(3)启动kafka(Linux环境)
//先启动zookeeper
[lzl@hadoop101 ~]$ cd /opt/module/zookeeper-3.4.10/
[lzl@hadoop101 zookeeper-3.4.10]$ bin/zkServer.sh start
ZooKeeper JMX enabled by default
Using config: /opt/module/zookeeper-3.4.10/bin/../conf/zoo.cfg
Starting zookeeper ... STARTED //zookeeper起来了
//再启动kafka
[lzl@hadoop101 zookeeper-3.4.10]$ cd /opt/module/kafka/
[lzl@hadoop101 kafka]$ bin/kafka-server-start.sh -daemon config/server.properties
//查看软件启动情况
[lzl@hadoop101 kafka]$ jps
4804 Jps
4763 Kafka
3420 QuorumPeerMain
//查看现有的主题
[lzl@hadoop101 kafka]$ bin/kafka-topics.sh --zookeeper hadoop101:2181 --list
__consumer_offsets
//创建recommender主题
[lzl@hadoop101 kafka]$ bin/kafka-topics.sh --zookeeper hadoop101:2181 --create --replication-factor 1 --partitions 1 --topic recommender
Created topic "recommender".
//查看当前时间作为发送
[lzl@hadoop101 kafka]$date +%s
1622263680
//开启消费服务
[lzl@hadoop101 kafka]$ bin/kafka-console-producer.sh --broker-list hadoop101:9092 --topic recommender
>4867|8195|4.0|1622263680
(4)启动idea程序,StreamingRecommender类(记住redis这里不要设置密码,因为代码中没写redis的密码,是无代码登录的)
当看到以下的字段时,表示数据已经成功地传进来了。
(5)本地查询mongodb数据
//发现多了StreamRecs表
> show tables
AverageProducts
Product
ProductRecs
RateMoreProducts
RateMoreRecentlyProducts
Rating
StreamRecs
UserRecs
//查看StreamRecs表信息
>db.StreamRecs.find().pretty()
我们的系统实时推荐的数据流向是:业务系统 -> 日志 -> flume 日志采集 -> kafka streaming数据清洗和预处理 -> spark streaming 流式计算。在我们完成实时推荐服务的代码后,应该与其它工具进行联调测试,确保系统正常运行。
(1)基本组件:启动实时推荐系统StreamingRecommender以及mongodb、redis
(2)构建Kafka Streaming程序:
1)创建Application.java:
package com.lzl.kafkastreaming;
import org.apache.kafka.streams.KafkaStreams;
import org.apache.kafka.streams.StreamsConfig;
import org.apache.kafka.streams.processor.TopologyBuilder;
import java.util.Properties;
public class Application {
public static void main(String[] args) {
String brokers = "hadoop101:9092";
String zookeepers = "hadoop101:2181";
// 定义输入和输出的topic
//这个程序会将topic为“log”的信息流获取来做处理,并以“recommender”为新的topic转发出去。
String from = "log";
String to = "recommender";
// 定义kafka stream 配置参数
Properties settings = new Properties();
settings.put(StreamsConfig.APPLICATION_ID_CONFIG, "logFilter");
settings.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, brokers);
settings.put(StreamsConfig.ZOOKEEPER_CONNECT_CONFIG, zookeepers);
// 创建kafka stream 配置对象
StreamsConfig config = new StreamsConfig(settings);
// 定义拓扑构建器
TopologyBuilder builder = new TopologyBuilder();
builder.addSource("SOURCE", from)
.addProcessor("PROCESSOR", ()->new LogProcessor(), "SOURCE")
.addSink("SINK", to, "PROCESSOR");
// 创建kafka stream
KafkaStreams streams = new KafkaStreams( builder, config );
streams.start();
System.out.println("kafka stream started!");
}
}
2)创建LogProcessor.java
package com.lzl.kafkastreaming;
import org.apache.kafka.streams.processor.Processor;
import org.apache.kafka.streams.processor.ProcessorContext;
public class LogProcessor implements Processor {
private ProcessorContext context;
@Override
public void init(ProcessorContext processorContext) {
this.context = processorContext;
}
@Override
public void process(byte[] dummy, byte[] line) {
// 核心处理流程
String input = new String(line);
// 提取数据,以固定前缀过滤日志信息
if( input.contains("PRODUCT_RATING_PREFIX:") ){
System.out.println("product rating data coming! " + input);
input = input.split("PRODUCT_RATING_PREFIX:")[1].trim();
context.forward("logProcessor".getBytes(), input.getBytes());
}
}
@Override
public void punctuate(long l) {
}
@Override
public void close() {
}
}
3)启动zookeeper和kafka
4)启动Application,会看到如下提示:
5)配置并启动flume
在flume的conf目录下新建log-kafka.properties,对flume连接kafka做配置:
agent.sources = exectail
agent.channels = memoryChannel
agent.sinks = kafkasink
# For each one of the sources, the type is defined
agent.sources.exectail.type = exec
# 下面这个路径是需要收集日志的绝对路径,改为自己的日志目录
agent.sources.exectail.command = tail –f /mnt/e/Java/ECommerceRecommenderSystem/businessServer/src/main/log/agent.log
agent.sources.exectail.interceptors=i1
agent.sources.exectail.interceptors.i1.type=regex_filter
# 定义日志过滤前缀的正则
agent.sources.exectail.interceptors.i1.regex=.+PRODUCT_RATING_PREFIX.+
# The channel can be defined as follows.
agent.sources.exectail.channels = memoryChannel
# Each sink's type must be defined
agent.sinks.kafkasink.type = org.apache.flume.sink.kafka.KafkaSink
agent.sinks.kafkasink.kafka.topic = log
agent.sinks.kafkasink.kafka.bootstrap.servers = hadoop101:9092
agent.sinks.kafkasink.kafka.producer.acks = 1
agent.sinks.kafkasink.kafka.flumeBatchSize = 20
#Specify the channel the sink should use
agent.sinks.kafkasink.channel = memoryChannel
# Each channel's type is defined.
agent.channels.memoryChannel.type = memory
# Other config values specific to each type of channel(sink or source)
# can be defined as well
# In this case, it specifies the capacity of the memory channel
agent.channels.memoryChannel.capacity = 10000
配置好后,启动flume
6)在ECommerceRecommendSystem下创建一个子模块“businessServer”结果工程目录如下(可以直接下载现有的,将里面的包名修改成自己的即可):
7)启动StreamingRecommender,另外需要在businessServer启动tomcat7:run(双击)
最终会出现以下信息:
8)谷歌浏览器访问地址:http://localhost:8088/login
9)注册登录
给《苏菲的世界》评分4星半:
会在agent.log里面看到新的一条数据:
10)我们在mogodb查看数据(发现多了一个User表)
> show tables
AverageProducts
Product
ProductRecs
RateMoreProducts
RateMoreRecentlyProducts
Rating
StreamRecs
User
UserRecs
//查看user信息
> db.User.find().pretty()
{
"_id" : ObjectId("60bc8e05c9e519403838b452"),
"userId" : -1241893782,
"username" : "[email protected]",
"password" : "123",
"first" : true,
"timestamp" : NumberLong("1622969861823"),
"prefGenres" : [ ]
11)再启动OfflineRecommender程序
再次刷新,离线商品被推荐出来了:
查看mongodb数据信息