在推薦系統中,我們常需要根據用戶的標示(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,走起來。
雄關漫道真如鐵,而今漫步從頭越。