Timeline介绍
所谓timeline就是当用户打开主页看到的随着时间轴发生的一系列时间的整合,主要包含:
- 关注用户的最新动态
- 热门推荐
- 广告推荐整合等等.
推、拉模式
- 推模式: 当一个用户关注了或者评论了一个问题或用户,触发事件,将会将这一动态广播给该用户所有的粉丝
- 拉模式: 用户由于某种行为触发事件后,不会广播给每一个粉丝,只有当粉丝主动查询该用户最近动态时,才从缓存中读取,组建timeline内容。
- 推拉结合:当一个用户的粉丝数目过多时,推模式会对后台造成很大压力,浪费存贮空间,特别有些粉丝很有可能已经很久未登录;同样的,当一个用户关注的人特别多,或者同时有很多用户同时查询同样的动态时,对后台读取数据压力特别大。于是,在大多数大规模网站采用一种推拉结合的模式,即是对活跃的用户采用推模式,对不活跃用户采用拉模式,这样就缓解了后端的压力,同时满足了大多数用户的需求。
Timeline的存储
在项目中采用timeline的新鲜事(最新动态)统一存储的模式,存储在在mysql中feed表中,记录下来用户新鲜事的核心数据(例如,如果用户关注了一个问题,那么数据就会包含:用户id,用户的头像、问题id,问题的title,问题的url等)。
在推模式下,每一个用户都应该有自己的timeline数据表,当他关注的用户产生新鲜事feed时,需要新鲜事的id存放在自己的timeline数据表中。在本项目中,我们在redis中存储新鲜事的id,键与值对应关系为:key(TIMELINE:用户id) , value(新鲜事在feed表中的id)。
另外,在后端timeline应该秩保存新鲜事的核心数据,不应该保存渲染它们的模板(前端的东西),当数据访问新鲜事列表时,从后端查询得到新鲜事核心数据,到达前端时应该针对不同的数据信息调用不同的模板去整合渲染。
整体路线
下图表现了整合timeline实现路线,从事件触发到事件处理,然后到timeline存储,以及调用不同模板去渲染整合:
实现代码
采用经典的三层结构,即model、service以及controller。
- 首先,是model层,新建java普通对象Feed,并构造对应的setter以及getter,同样的在mysql中创建Feed表,包含与Feed类一致的字段,使用mybatis将数据库与Feed关联。需要注意的是,为了方便保存新鲜事的核心数据,我们将核心数据保存成json串的形式,但是这样在前端读取json会很不方便,因此在本项目中创建一个JSONObject对象,当创建data时,同时也会将json对象化,然后在创建一个get方法,通过key就可以获得保存在json串中对应的核心数据,方便了前端模板读取数据。
public class Feed {private int id;private int type;private int userId;private Date createdDate;private String data; //利用JSON串存储的private JSONObject dataJSON = null; // 直接保存一个json对象,便于直接通过key获取在json串中对应的valuepublic int getId() {return id;}public void setId(int id) {this.id = id;}public int getType() {return type;}public void setType(int type) {this.type = type;}public int getUserId() {return userId;}public void setUserId(int userId) {this.userId = userId;}public Date getCreatedDate() {return createdDate;}public void setCreatedDate(Date createdDate) {this.createdDate = createdDate;}public String getData() {return data;}public void setData(String data) {this.data = data;dataJSON = JSONObject.parseObject(data);}/*** 通过json串中的key获取value,为了方便在以后在前端直接从json读取相应的信息* @param key* @return*/public String get(String key){return dataJSON == null ? null : dataJSON.getString(key);}
}
- 然后,创建对应的DAO层以及Service层。
// DAO 层
@Mapper
public interface FeedDao {String TABLE_NAME = " feed ";String INSERT_FIELDS = " type, user_id, created_date, data ";String SELECT_FIELDS = " id, " + INSERT_FIELDS;/*** 向Feed表插入一条新鲜事* @param feed* @return*/@Insert({"insert into ", TABLE_NAME, " (", INSERT_FIELDS, " ) values (#{type},#{userId},#{createdDate},#{data})"})int addFeed(Feed feed);/*** 通过根据新鲜事的id查询对应的新鲜事* @param id* @return*/@Select({"select ", SELECT_FIELDS, " from ", TABLE_NAME, " where id=#{id}"})Feed getFeedById(int id);/*** 在拉模式下,根据用户的关注用户id列表查找用户的新鲜事* 由于比较复杂,通过在类相同路径的xml文件进行配置* @param maxId 设置最大的id数目* @param userIds 关注用户的id列表* @param count 用于分页显示* @return*/List<Feed> selectUserFeeds(@Param("maxId") int maxId,@Param("userIds") List<Integer> userIds,@Param("count") int count);
}
用于配置selectUserFeeds的xml文件为:
<mapper namespace="cn.dut.wenda.dao.FeedDao"><sql id="table">feed</sql><sql id="selectFields">id, user_id, type, created_date, data</sql><select id="selectUserFeeds" resultType="cn.dut.wenda.model.Feed">SELECT<include refid="selectFields"/>FROM<include refid="table"/>WHERE id < #{maxId}<if test="userIds.size() != 0">AND user_id in<foreach collection="userIds" index="index" item="item" open="(" separator="," close=")">#{item}</foreach></if>ORDER BY id DESCLIMIT #{count}</select>
</mapper>
// Service层
@Service
public class FeedService {@AutowiredFeedDao feedDao;public List<Feed> getUserFeeds(int maxId, List<Integer> userIds, int count){return feedDao.selectUserFeeds(maxId, userIds, count);}public Feed getFeedById(int id){return feedDao.getFeedById(id);}public boolean addFeed(Feed feed){feedDao.addFeed(feed);return feed.getId() > 0;}
}
- 最后,创建Controller层,通过controller层用户选择以推拉哪种模式查看timeline页面。
@RequestMapping(path = "/pushfeeds", method = RequestMethod.GET)public String getPushFeeds(Model model){// 用户为登录时,将默认显示用户为0的timeline,在feedHandler中,每次新的feed都被添加到了用户0的timeline中int localUserId = hostHolder.getUser() != null ? hostHolder.getUser().getId() : 0;List<String> feedIds = jedisAdapter.lrange(RedisKeyUtils.getTimelineKey(localUserId), 0, 10);List<Feed> feeds = new ArrayList<>();for (String feedId : feedIds){Feed feed = feedService.getFeedById(Integer.parseInt(feedId));feeds.add(feed);}model.addAttribute("feeds", feeds);return "feeds";}/*** 拉模式下,用户通过查询feed表,找到自己关注的用户的最新动态,动态组建timeline* 做法是先将用户的关注对象在redis中查询出来,然后得到fellowees的ids,* 然后在feed表中找到这些用户的feed,最后返回timeline到前端显示* @param model* @return*/@RequestMapping(path = "/pullfeeds", method = RequestMethod.GET)public String getPullFeeds(Model model){// 已经在拦截器中添加了本路径,当为登录时发生未登录跳转int localUserId = hostHolder.getUser() != null ? hostHolder.getUser().getId() : 0;List<Integer> followees = new ArrayList<>();if (localUserId != 0){followees = followService.getFollowee(localUserId, EntityType.ENTITY_USER, Integer.MAX_VALUE);}
// System.out.println(Arrays.toString(followees.toArray()));List<Feed> feeds = feedService.getUserFeeds(Integer.MAX_VALUE, followees, 10);model.addAttribute("feeds", feeds);return "feeds";}
- 最重要最关键的一步,就是创建事件处理Handler,当用户触发事件时,需要在处理事件的Handler中进行处理,对应上面的路线图中的两个数据库操作。
/*** 处理事件Handler* @param model*/@Overridepublic void doHandle(EventModel model) {// 事件触发后,会构造一个新的feed对象// 其中保存type是为了在前端调用不同的模板去渲染,比如评论问题和关注问题会根据type的不同来用不同的宏去渲染Feed feed = new Feed();feed.setType(model.getEventType().getValue());feed.setCreatedDate(new Date());feed.setUserId(model.getActorId());feed.setData(buildFeedData(model));if (feed.getData() == null){return;}
// feedService.addFeed(feed);System.out.println(feedService.addFeed(feed));// 采用推模式,将用户所有的粉丝查找出来,然后进行广播// 在redis中针对每一个每一个粉丝,将用户的timeline在feed表中的id存在每一个粉丝对应的在redis存放timeline的队列中List<Integer> followers = followService.getFollower(EntityType.ENTITY_USER, model.getActorId(), Integer.MAX_VALUE);// 将用户0添加进来,为了用户为登录情况下,看到所有用户的timelinefollowers.add(0);for (int follower :followers){String timelineKey = RedisKeyUtils.getTimelineKey(follower);jedisAdapter.lpush(timelineKey, String.valueOf(feed.getId()));System.out.println(timelineKey + " : " + feed.getId());}}/*** 根据事件模型将在前端显示所用到的数据存放在json串中* @param eventModel* @return*/private String buildFeedData(EventModel eventModel){Map<String, String> map = new HashMap<>();User actor = userService.getUser(eventModel.getActorId());if (actor == null){return null;}map.put("userId", String.valueOf(actor.getId()));map.put("userHead", actor.getHeadUrl());map.put("userName", actor.getName());if (eventModel.getEventType() == EventType.COMMENT|| (eventModel.getEventType() == EventType.FOLLOW&& eventModel.getEntityType() == EntityType.ENTITY_QUESTION)){Question question = questionService.selectById(eventModel.getEntityId());if (question == null){return null;}map.put("questionId", String.valueOf(question.getId()));map.put("questionTitle", question.getTitle());return JSONObject.toJSONString(map);}return null;}
实现中出现的错误
- 第一个,就由于采用前端使用freemarker渲染,前面已经说了对不同的核心数据采用不同的模板进行渲染,因此,需要使用freemarker中的macro进行调用选择,出现第一个错误:
freemarker.core.NonHashException: For "." left-hand operand: Expected a hash, but this has evaluated to a string (wrapper: f.t.SimpleScalar):
==> vo [in template "feeds.html" at line 9, column 25]
显示错误地方代码为:
<#list feeds as feedvo><#if feedvo.type == 1><@comment_question vo="${feedvo}"></@comment_question><#elseif feedvo.type == 4><@follow_question vo="${feedvo}"></@follow_question></#if></#list>
原因为feedvo是一个复杂数据类型,而上面的写法,把它变成一个String类型,因此在宏里面不能对它进行复杂数据类型的操作,然后修改为下面代码问题解决。
<#list feeds as feedvo><#if feedvo.type == 1><@comment_question vo=feedvo></@comment_question><#elseif feedvo.type == 4><@follow_question vo=feedvo></@follow_question></#if></#list>
- 第二个,在通过Service层中的getUserFeeds方法,根据关注用户id列表获取Feed表中的数据时,发现得到的Feed对象的id都为0.通过以下步骤找到问题以及解决方法:
1、 检查Feed类里面的getter和setter方法缺少,发现完整
2、检查DAO层selectUserFeeds,检查xml配置文件,发现了错误,但是修改后仍是0,继续检查
3、最后从网上看到,在使用mybatis是要把mybatis配置文件的useGeneratedKeys字段设置为true,否则在利用mybatis中insert一条数据,不会自动将对应表中的id封装到对象的id属性上面,最后解决问题。
总结
对于大型网站的timeline应该采用推拉结合的模式,具体活跃用户的定义可以参照微博等知名网站方式,由于本项目只是身为初学者的我练习使用的,因此采用了推拉两种方式。另外还有许多不足之处,希望您指正,自己也会继续学习,谢谢。
本项目参照牛客网spring boot问答网站项目视频