在推荐系统中,我们常需要根据用户的标示(UID)和访问行为给用户生成推荐。但某些情况下,我们需要替换用户请求中的UID为特殊的UID,以测试推荐结果。本周基于这个需求,实现了UID替换器。
功能
首先我们需要做一个管理后台,对我们要替换的UID或者其他ID进行管理,有如下需求:
1 增加,修改,删除UID替换规则
2 UID规则启用/停用
3 UID替换规则在线上生效
以上第三个需求相对难以实现一些。UID替换规则在线上生效,其潜台词是每次修改了替换规则之后,需要其立即在线上生效。对于UID替换规则对象,我们可以用关系数据库,如MySQL存储。
一般的想法是在线上请求过来的时候,依据请求中的UID查询数据库,看是否有相应的规则,如果有则取出替换UID进行替换,如果没有,则略过。这个想法在请求量不大的时候没有问题,但如果是每秒成千上万次请求,MySQL数据库难以抗住,就会出现问题。
这时候我们想到的是把规则数据加载到内存中,线上请求直接从内存中查找,就不需要每个请求都要查找数据库。在管理系统中,如果对规则修改了之后,“UID替换规则在线上生效”就是把修改后的数据重新加载到内存。
这就是“UID替换规则在线上生效”这个需求的真实含义。
其次,我们的线上系统要提供两方面的功能:
1 提供加载MySQL数据到线上内存的接口,方便管理系统调用
2 在请求过来的时候,根据请求中的UID和内存中的规则,实现UID替换
第一个功能,由线上系统暴露一个API,管理系统需要使线上生效时,就调用该API,重新由MySQL对应的规则表加载数据到内存对象中。线上API可以直接访问MySQL数据,也可以由管理系统暴露一个服务,然后线上API调用该服务获取生效规则数据。
第二个功能的实现要求从MySQL中加载出来的数据保存在一个全局的对象中。当然,也可以直接存储在Tair/Redis/Memcache这样的内存数据库中,甚至可以做成一个服务,供其他服务调用。不过此处为了简略,我们实现的版本就是直接放在一个全局的Java的HashMap对象中。然后对线上的Servlet做一个Filter,在Filter中使用全局对象提供的规则数据实现替换逻辑。
在本处我们使用了HashMap作为规则存储对象,不用担心线程安全的问题。HashMap并发读没有问题,并发写会出现问题。但在我们的场景中,写数据场景有二:线上服务启动时,要加载数据到HashMap;我们刷新时,要重新加载数据到HashMap。这两个场景都是单线程去写,所以HashMap堪堪够用,如果实在不放心,可以改用concurrentHashMap。
设计
设计主要有两个方面,一个是数据库表的设计,另一个是HashMap的设计。前者可以这么做:
---- table idmapper
id int 规则ID 自增
name varchar 规则名
source_id varchar 源ID
direct_id varchar 目的ID
enable int 是否启用 0:不启用,1:启用
除了常见的对字段的增删改查功能之外,还需要向外暴露已经启用的规则,查询语句如下:
select * from idmapper where enable=1
可在Web框架中,如Spring MVC,把这个查询的结果以API的方式暴露,访问API可以获得对应的Json数据。
第二个是HashMap的设计,因为在文章里面描述的场景相对简单,而实际上我们有更复杂的需求,如不止替换UID这样的参数,还可以替换别的参数,还有一些场景过滤,如只在特定场景下并且有对应规则才实现替换。这些功能使得HashMap的设计相对复杂,此处只需要将sourceID为key,directID为value。
第三个是要设计一个单例。单例中包含一个静态的HashMap对象,以及加载数据到HashMap对象的函数和根据UID查找替换UID的函数。实际上我们的系统相对复杂很多,提供有专门的数据服务接口,需要按照该接口进行开发。此处为了方便,我们简单就设计一个单例就好。伪代码大概如下:
public class MapRuler {
private static HashMap ruler = new HashMap();
// 私有构造函数
private MapRuler() {}
// 此处使用饿汉式即可
private static MapRuler mapRuler = new MapRuler();
public void loadEnableRuler() {
ruler = ... // 调用管理系统提供的API,将Json数据解析成HashMap
}
public String getDirectId(String sourceId) {
if(ruler.containsKey(sourceId)) {
return ruler.get(sourceId);
}
// 没有匹配的规则返回null
return null;
}
public static MapRuler getInstance() {
return mapRuler;
}
}
单例使用饿汉式就可以,因为规则数据在线上应用启动时就要加载到内存对象中,这时候就要存在MapRuler
对象。这个场景并不需要延迟加载
这种功能。
实现
在实现过程中,我遇到不少问题,踩了不少坑。一方面是我很久没写前端了,目前的管理系统是之前留下来的,数据都用ajax
请求后端API,然后前端写js把数据组装呈现。整个前端代码中JS/CSS/HTML混杂在一起,让人感觉很不好。如果要让我开发这个管理系统,我肯定不会这么做。
- css/js/html要尽可能做到分离开来,css放在专用的文件中,js的函数尽可能做到复用,不能每个页面都写一套js函数(这个项目几乎是的)。
- 现有的一些前端框架如vue/anglar/react都能帮助省很多功夫。这个系统中只使用了angular的路由功能,其他是jQuery和原生js拼接。
- 不会考虑只用cookie标记用户身份,居然还是明文的用户名,我也是醉了。其实内部伪造一下这个cookie,管理系统的权限如同虚设。像这种后端只负责提供API,并由前端呈现的应用,有专门的权限方案,例如授权机制,或者token令牌等。
- 代码里面不使用eval函数执行js函数。eval本身就不够安全,很多语言中都不怎么建议使用这个函数,js也是一样。
这种前后端分离的做法优点很多,其一后端减轻了重担,只需要提供API接口。而前端相应的任务就重了些,不过功能实现会更加灵活。我们的后端是用Spring MVC写的,实现对规则的增删改查的API也很简单。但是在前端实现上踩了不少坑。
例如用jQuery取id的数据时,如果前面忘了加#
,然后就取不到数据。另外,原有的管理系统的代码有一个比较坑爹的BUG。在实现修改功能的时候,onclick
对应的方法传进了很多参数,那些参数是通过js用字符串拼接的,然后用eval执行。这造成某个参数是json字符串,或者里面含有单引号的时候,该修改按钮点击无效。我尝试了好几个办法,都没有效果。但后来想到其实可以把带有单引号的字符串先用Base64
加密,去掉单引号,然后作为参数传入onclick
对应的方法,在onclick
对应的方法里面用Base64
解密,这样问题才解决。其实一个比较好的办法是,在onclick
对应的函数参数中,只传入id
,函数实现中用ajax
从后端获取数据。
有一个比较好的工具可能帮助省了很多前端问题。chrome的开发者工具,真是神器,用好了这个东西,前端问题基本搞定了一半。不会的,不确定的代码都可以拿到控制台运行一下,看看结果,再写到生产环境的代码中。
另一方面是我对线上API还不够熟悉,也跟不熟悉aone发布系统有关,毕竟这个东西也是刚用起来。推荐API中会对请求用一个Filter进行包装,包装成我们所需要的用于推荐的Request。我最开始走了弯路,把UID替换放在了该Filter之前,那时候我们只能通过getParameter()
取得UID参数,但修改了后是没法用setParameter()
函数改回去的,因为没有这个函数。正确的做法是把替换的Filter放在包装Filter之后,根据我们自己定义的Request对象替换掉里面的UID。还值得一说的是,替换Filter的配置要在包装Filter之后,这样获得的Request对象才正常而不是一个null
,毕竟只有先把原request转换成推荐Request,我们才能用上推荐Request是不是?
总结
说起来这并不是一个很难实现的东西,但借助这次的实现,重新写了一把js,学了点Spring MVC,以及了解了线上API的东西。当我在预发机器上测试并打印出替换成功的结果时,感觉还是很开心的。但是我也知道,要学的东西还有很多。接下来可能找时间评估一下把后台管理系统重写的任务量,前端部分考虑用vue或者其他框架,优化一些使用功能。
虽然目标是一个数据工程师,但怎么也得会Web应用开发吧。So,走起来。
雄关漫道真如铁,而今漫步从头越。