我的開發環境:
JForum2.1.8
tomcat5.X
JDK 1.6X
以不能脫俗的套路開始。從web.xml開始
web.xml中包括一個filter,一個listener,和兩個servlet,內容不多。
filter:net.jforum.util.legacy.clickstream.ClickstreamFilter.java
內部功能:大致爲過濾每一個客戶端請求判斷是否是機器人或者蜘蛛。
代碼如下:
- package net.jforum.util.legacy.clickstream;
- //導包部分省略
- /**
- * The filter that keeps track of a new entry in the clickstream for <b>every request</b>.
- *
- * @author <a href="[email protected]">Patrick Lightbody</a>
- * @author Rafael Steil (little hacks for JForum)
- * @version $Id: ClickstreamFilter.java,v 1.1 2010/02/02 11:20:04 cvsr Exp $
- */
- public class ClickstreamFilter implements Filter
- {
- //日誌記錄
- private static final Logger log = Logger.getLogger(ClickstreamFilter.class);
- /**
- * Attribute name indicating the filter has been applied to a given request.
- * 參數暗示一個被經過過濾器檢查的請求
- */
- private final static String FILTER_APPLIED = "_clickstream_filter_applied";
- /**
- * Processes the given request and/or response.
- * 處理給出的請求 和/或者相應
- *
- * @param request The request
- * @param response The response
- * @param chain The processing chain
- * @throws IOException If an error occurs
- * @throws ServletException If an error occurs
- */
- public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException,
- ServletException
- {
- //確保過濾器在一個請求上只應用一次
- // Ensure that filter is only applied once per request.
- if (request.getAttribute(FILTER_APPLIED) == null) {
- request.setAttribute(FILTER_APPLIED, Boolean.TRUE);
- //調用同級目錄下的BotChecker類的isBot方法檢測請求是否爲一個機器人
- String bot = BotChecker.isBot((HttpServletRequest)request);
- //如果當前請求是機器人,並且日誌開關處於打開狀態時,記錄
- if (bot != null && log.isDebugEnabled()) {
- System.out.println("Found a bot: " + bot);
- log.debug("Found a bot: " + bot);
- }
- //設置clickstream.is.bot是否爲真
- request.setAttribute(ConfigKeys.IS_BOT, Boolean.valueOf(bot != null));
- }
- //繼續傳遞請求
- // Pass the request on
- chain.doFilter(request, response);
- }
- /**
- * Initializes this filter.
- *
- * @param filterConfig The filter configuration
- * @throws ServletException If an error occurs
- */
- public void init(FilterConfig filterConfig) throws ServletException {}
- /**
- * Destroys this filter.
- */
- public void destroy() {}
- }
package net.jforum.util.legacy.clickstream;
//導包部分省略
/**
* The filter that keeps track of a new entry in the clickstream for <b>every request</b>.
*
* @author <a href="[email protected]">Patrick Lightbody</a>
* @author Rafael Steil (little hacks for JForum)
* @version $Id: ClickstreamFilter.java,v 1.1 2010/02/02 11:20:04 cvsr Exp $
*/
public class ClickstreamFilter implements Filter
{
//日誌記錄
private static final Logger log = Logger.getLogger(ClickstreamFilter.class);
/**
* Attribute name indicating the filter has been applied to a given request.
* 參數暗示一個被經過過濾器檢查的請求
*/
private final static String FILTER_APPLIED = "_clickstream_filter_applied";
/**
* Processes the given request and/or response.
* 處理給出的請求 和/或者相應
*
* @param request The request
* @param response The response
* @param chain The processing chain
* @throws IOException If an error occurs
* @throws ServletException If an error occurs
*/
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException,
ServletException
{
//確保過濾器在一個請求上只應用一次
// Ensure that filter is only applied once per request.
if (request.getAttribute(FILTER_APPLIED) == null) {
request.setAttribute(FILTER_APPLIED, Boolean.TRUE);
//調用同級目錄下的BotChecker類的isBot方法檢測請求是否爲一個機器人
String bot = BotChecker.isBot((HttpServletRequest)request);
//如果當前請求是機器人,並且日誌開關處於打開狀態時,記錄
if (bot != null && log.isDebugEnabled()) {
System.out.println("Found a bot: " + bot);
log.debug("Found a bot: " + bot);
}
//設置clickstream.is.bot是否爲真
request.setAttribute(ConfigKeys.IS_BOT, Boolean.valueOf(bot != null));
}
//繼續傳遞請求
// Pass the request on
chain.doFilter(request, response);
}
/**
* Initializes this filter.
*
* @param filterConfig The filter configuration
* @throws ServletException If an error occurs
*/
public void init(FilterConfig filterConfig) throws ServletException {}
/**
* Destroys this filter.
*/
public void destroy() {}
}
ClickstreamFilter實現了Filter接口,主要的任務就交給BotChecker了,是用來檢測client是不是一個 robot來的。
主要的工作還是在JForum上面,不過先來看看jforum是怎麼檢測robot的?
BotChecker只有一個靜態工具方法isBot,首先是檢測是否請求robot.txt(這是標準的robot協議文件),接下去判斷 User-Agent頭部,最後是判斷remotehost。而已知的robot都是寫在文件clickstream-jforum.xml裏邊的(包括 agent和host),並通過ConfigLoader加載進來的(SAX方式)。
Listener:
net.jforum.ForumSessionListener.java
實現了javax.servlet.http.HttpSessionListener接口。其主要功能就是重寫了sessionDestroyed方法,對當前session進行了判斷,如果存在session,存儲session信息到DB,之後移除此session信息。
重寫了HttpSessionListener的sessionCreated和sessionDestroyed方法。
sessionCreated方法體爲空。
代碼如下:
- /**
- * @author Rafael Steil
- * @version $Id: ForumSessionListener.java,v 1.1 2010/02/02 11:20:04 cvsr Exp $
- */
- public class ForumSessionListener implements HttpSessionListener
- {
- //實例化日記記錄
- private static final Logger logger = Logger.getLogger(ForumSessionListener.class);
- /**
- * @see javax.servlet.http.HttpSessionListener#sessionCreated(javax.servlet.http.HttpSessionEvent)
- * 空方法
- */
- public void sessionCreated(HttpSessionEvent event) {}
- /**
- * @see javax.servlet.http.HttpSessionListener#sessionDestroyed(javax.servlet.http.HttpSessionEvent)
- */
- public void sessionDestroyed(HttpSessionEvent event)
- {
- HttpSession session = event.getSession();
- if (session == null) {
- return;
- }
- String sessionId = session.getId();
- try {
- //持久化當前session中信息
- SessionFacade.storeSessionData(sessionId);
- }
- catch (Exception e) {
- logger.warn(e);
- }
- //移除此session信息
- SessionFacade.remove(sessionId);
- }
- }
/**
* @author Rafael Steil
* @version $Id: ForumSessionListener.java,v 1.1 2010/02/02 11:20:04 cvsr Exp $
*/
public class ForumSessionListener implements HttpSessionListener
{
//實例化日記記錄
private static final Logger logger = Logger.getLogger(ForumSessionListener.class);
/**
* @see javax.servlet.http.HttpSessionListener#sessionCreated(javax.servlet.http.HttpSessionEvent)
* 空方法
*/
public void sessionCreated(HttpSessionEvent event) {}
/**
* @see javax.servlet.http.HttpSessionListener#sessionDestroyed(javax.servlet.http.HttpSessionEvent)
*/
public void sessionDestroyed(HttpSessionEvent event)
{
HttpSession session = event.getSession();
if (session == null) {
return;
}
String sessionId = session.getId();
try {
//持久化當前session中信息
SessionFacade.storeSessionData(sessionId);
}
catch (Exception e) {
logger.warn(e);
}
//移除此session信息
SessionFacade.remove(sessionId);
}
}
Servlet:
調用父類的init(正常情況這是必須調用的) -> 配置log4j -> startSystemglobals(加載全局參數配置SystemGlobals.properties -> 加載數據庫配置database.driver.config(如mysql就是WEB-INF/config/database/mysql /mysql.properties) -> 加載自定義配置(默認的是jforum-custom.conf)) -> 配置緩存引擎 -> 配置freemarker模板引擎 -> 加載模塊配置modulesMapping.properties -> 加載url映射配置urlPattern.properties -> 加載I18n配置(languages/*) -> 加載頁面映射配置(templatesMapping.properties) -> 加載BBcode配置bb_config.xml -> 結束
net.jforum.JForum.java它繼承了JForumBaseServlet類
這個servlet相當於Struts中的前端控制器,所有請求都將通過這個類轉發。
// 初始化JForumExecutionContext
JForumExecutionContext ex = JForumExecutionContext.get();
// 包裝request和response
request = new WebRequestContext(req);
response = new WebResponseContext(res);
// 檢查數據庫狀態
this.checkDatabaseStatus();
// 創建JForumContext並設置到JForumExecutionContext中去
.......
JForumExecutionContext.set(ex);
// 刷新session
utils.refreshSession();
// 加載用戶權限
SecurityRepository.load(SessionFacade.getUserSession().getUserId());
// 預加載模板需要的上下文
utils.prepareTemplateContext(context, forumContext);
// 從request中解析module name
String module = request.getModule();
// module name -> module class
String moduleClass = module != null ? ModulesRepository.getModuleClass(module) : null;
// 判斷是否在ban list裏邊
......
boolean shouldBan = this.shouldBan(request.getRemoteAddr());
// 主角出場
out = this.processCommand(out, request, response, encoding, context, moduleClass);
// 掃尾工作,例如db的rollback
this.handleFinally(out, forumContext, response);
processCommand會調用Command的process方法:
// 獲取一個module實例(繼承了Command)
Command c = this.retrieveCommand(moduleClass);
// 進入process
Template template = c.process(request, response, context);
// 這裏開始是process方法
//獲取action
String action = this.request.getAction();
//如果不是ignore的,就調用這個action
if (!this.ignoreAction) {this.getClass().getMethod(action, NO_ARGS_CLASS).invoke(this, NO_ARGS_OBJECT);}
//如果是轉發的,就把TemplateName清空
if (JForumExecutionContext.getRedirectTo() != null) {this.setTemplateName(TemplateKeys.EMPTY);}
//不是轉發且attribute裏邊存在template,則設置爲templateName
else if (request.getAttribute("template") != null) {this.setTemplateName((String)request.getAttribute("template"));}
//是否coustomContent?例如下載,驗證碼子類的不需要頁面的操作
if (JForumExecutionContext.isCustomContent()) {return null;}
//返回一個template
return JForumExecutionContext.templateConfig().getTemplate(
new StringBuffer(SystemGlobals.getValue(ConfigKeys.TEMPLATE_DIR)).
append('/').append(this.templateName).toString());
}
// 從process出來,回到processCommand
// 設置content type
response.setContentType(contentType);
//生成頁面並flush
if (!JForumExecutionContext.isCustomContent()) {
out = new BufferedWriter(new OutputStreamWriter(response.getOutputStream(), encoding));
template.process(JForumExecutionContext.getTemplateContext(), out);
out.flush();
}
}
這是一般的流程,就像上面提到的customContent,就是要自己處理了,可以參考CaptchaAction.generate().
這樣的話,如果我們要增加一些action進行二次開發的話,大體的流程就是,增加一個繼承了Command的類,例如叫ExampleAction,定義一個方法,例如叫test(),在urlPattern.properties中定義一個映射,例如爲example.test.1 = forum_id,再在modulesMapping.properties中定義module class的映射,如example = ExampleAction,最後我們在templatesMapping.properties定義個模板的映射,如:example.test = example_test.htm。現在假設我們的請求url是/example/test/1,再來看看test裏邊的一些方法:
this.request.getIntParameter("forum_id")) //獲取參數,得到1
this.context.put("obj", obj); //把結果寫入context,這樣可以在template中獲取到
this.setTemplateName("example.test");//設置template的名字
這樣的簡單流程應該還比較好理解吧?
另外,還可以看出,jforum使用了自己的一套映射機制,這是通過urlPattern.properties來定義的(參考上面 JForumBaseServlet的init流程),這是在JForumBaseServlet的loadConfigStuff 方法的第一行實現的,並加載到UrlPatternCollection中去,如下所示:
Properties p = new Properties();
fis = new FileInputStream(SystemGlobals.getValue(ConfigKeys.CONFIG_DIR) + "/urlPattern.properties");
p.load(fis);
for (Iterator iter = p.entrySet().iterator(); iter.hasNext(); ) {
Map.Entry entry = (Map.Entry) iter.next();
UrlPatternCollection.addPattern((String)entry.getKey(), (String)entry.getValue());
}
可以知道這裏的key和value都是String來的
UrlPatternCollection.patternsMap.put(name, new UrlPattern(name, value));
但在addPattern方法裏邊其實是生成一個UrlPattern作爲value,如何構造一個UrlPattern可以看看代碼,舉例來說把,對於 example.hello.2=a,b,這樣會生成一個UrlPattern,裏邊的內容是name爲example.hello.2,value爲 a,b.而size和vars是用a,b解析出來的,用來表示一共有多少個參數,參數名組成的數組。所以UrlPattern存儲的就是一個url格式的定義,而放在UrlPatternCollection裏邊的一系列的url映射格式是在請求的url解析的時候用到的。
現在再分析一下jforum怎麼使用這個UrlPatternCollection的?按照我們不嚴格的思路,應該是service中處理url,獲取.page前面的一部分,如/example/hello/2/1,用/做一下split,獲取module name,action name,把最後的作爲參數,用module,action,參數個數組成一個key(example.hello.2),通過 UrlPatternCollection找到對應的UrlPattern,通過裏邊的格式對應(vars裏邊的參數名和url的參數值)就可以把參數添加到request的parameters裏邊去。實際的情況也差不多就這個樣。在說到jforum中的service方法的時候,簡單提到過 request和response是經過包裝的:
request = new WebRequestContext(req);
response = new WebResponseContext(res);
WebResponseContext只是簡單的delegate給HttpServletResponse(這樣做的好處是全部方法都限制在 ResponseContext中),而WebRequestContext是繼承了HttpServletRequestWrapper並實現了 RequestContext接口。所以WebRequestContext是一個HttpRequest,但是通過RequestContext接口實現了一些特定的方法就是了,例如getModule/getAction,而這個解析url的過程是在構建WebRequestContext對象的過程中實現的。可以看看WebResponseContext的構造方法,這裏就不詳細說了。注意的是,所有的parameters最後都保存到 query(一個私有的map)裏邊去的。還有就是上面說到的jforum的特定url映射機制,這是通過WebRequestContext的parseFriendlyURL方法實現的,原理就和上面提到的那樣,也不詳說了。
到這裏,基本上整個處理流程就差不多了。現在來說說jforum裏邊的文件修改監聽器(JForumBaseServer的startApplication流程),如果你在使用jforum的過程中,修改了某些文件如*.sql,jforum就會重新加載修改後的配置。我原來以爲是用quartz框架來實現的,後來才知道是用jdk的TimerTask類來實現的。請看ConfigLoader的listenForChanges方法:
FileMonitor.getInstance().addFileChangeListener(new QueriesFileListener(),
SystemGlobals.getValue(ConfigKeys.SQL_QUERIES_GENERIC), fileChangesDelay);
這裏給各個部分分一下責任,FileMonitor是大管家,負責管理所有的文件監聽器;FileChangeListener是一個監聽器接口,只有一個方法,就是fileChanged(String filename),意思就是對某個filename的修改作出怎樣的反應。使用的方法也很簡單,就是實現一個FileChangeListener,並和監控的文件名,檢查間隔作爲參數傳入就可以生效了。FileMonitor裏邊的實現原理就是,通過一個map(timerEntries)來保存(文件名/timertask),每次加入一個監聽器的時候,會根據文件名先移出原來的文件監聽器(缺點是隻能能對一個文件添加一個監聽器),然後構建一個 TimerTask並加入到timerEntries中去。關於TimerTask的具體用法,可以參考api。
作爲一個論壇,應用層緩存這樣的東西似乎必不可少,jforum也提供了緩存配置(上面也提到一些)。jforum提供了數種緩存實現(JForumBaseServlet的init流程),分別是 DefaultCacheEngine(簡單的內存實現),JBossCacheEngine,EhCacheEngine。,請看 ConfigLoader的startCacheEngine方法,流程大概就是得到cacheEngine的實現配置 (SystemGlobals.properties中配置cache.engine.implementation),然後產生CacheEngine 的實例,調用它的init方法進行初始化,然後找到所有的可緩存類(實現了Cacheable接口,並在 SystemGlobals.properties中配置cacheable.objects),最後把cacheEngine注入進去獲得cache的能力。雖然jforum自己實現了許多這樣的注入(除了cacheEngine,還有db,dao等等),雖然達到了一定的的目的,可是怎麼說還是到處充滿了Singleton的實現(參考spring2.5文檔3.9. 粘合代碼和可怕的singleton),爲了尋求更好的組織方式(例如使用ioc來管理對象,使用成熟的orm來隔離數據庫)和獲得更多的用戶羣(選擇更廣泛使用的框架幫助),大概纔會萌發jforum3的想法吧。
順便提一下jforum的Dao 實現方式(參考JForumBaseServlet的startApplication流程),參考ConfigLoader的 loadDaoImplementation方法,原理就是通過配置dao.driver(在特定的數據庫配置裏邊如mysql.properties) 獲取到DataAccessDriver的實現 -> 初始化DataAccessDriver -> 獲取到所有的Dao實現。可以這麼理解,實現一個DataAccessDriver就獲得一整套Dao的實現方式,對於dao裏邊的實現方法,給個範例:
//例行公事
PreparedStatement p = null;
ResultSet rs = null;
//獲得connect,並執行named sql
p = JForumExecutionContext.getConnection().prepareStatement(SystemGlobals.getSql("GroupModel.selectById"));
p.setInt(1, groupId);
rs = p.executeQuery();
Group g = new Group();
//循環resultset進行處理
if (rs.next()) {g = this.getGroup(rs);}
整個實現很直白,就是一個jdbc實現方式來的。對於如何獲取connection,查看JForumExecutionContext的 getConnection(),可以注意到這麼一句:
c = DBConnection.getImplementation().getConnection();
也是比較清晰的,另外可以知道的是,在每次請求的過程中,connection只會獲取一次,並在第一次獲取到以後放到ThreadLocal裏邊去,這樣在每個線程中保留一份數據(正確理解TheradLocal ),在請求請求結束以後才釋放connection(service流程中的handleFinally方法)。
JForumExecutionContext,如字面意,就是請求執行的上下文,例如上面提到的數據庫連接,還有ForumContext(放着和 request,response相關的信息),context(freemarker的上下文變量),redirectTo(轉發地址),contentType(響應內容格式),isCustomContent(不使用默認渲染,上面有提到),enableRollback(db是否會滾)。
jforum是可以配置權限的,可控制的權限類型放在SecurityConstants裏邊,對應的配置界面是根據permissions.xml生成的(參考GroupAction 的permissions)。而每個用戶的權限(PermissionControl)是通過SecurityRepository來管理的,最用形成的權限系統是role(權限)-group(用戶組,可以多級)-用戶這樣的結構圖。
如何判斷權限?
對於一個用戶來說,爲了獲取用戶的權限(PermissionControl),流程是這樣的(詳細看SecurityRepository的load方法):獲取用戶信息 -> 獲取用戶的所有groupid並組成一個用逗號隔開的字符串groupids -> 根據groupids獲取所有的name/role_value -> 組裝成RoleValueCollection -> 生成RoleCollection -> 最後生成PermissionControl
判斷權限是使用SecurityRepository的canAccess(int userId, String roleName, String value)方法:
根據userid獲取PermissionControl-> 如果value參數爲空的話,就判斷是否擁有該roleName(通過內部的RoleCollection對象的keys),就是是否含有該權限 -> 如果value參數不爲空的話,除了需要含有該權限,還要擁有相應的rolevalue(通過內部的RoleCollection對象的values)。參數中的value指數可以爲論壇分類id,論壇id之類,隨業務而定。
總體上jforum還算清晰,大部分的業務代碼沒有細看(那些Command類),有興趣可以對照着寫,大體分爲三個包(admin是管理,jforum 是公共頁面,install是安裝頁面)。
既然說到驗證,就順便要說說jforum的sso驗證機制
官方文檔:
http://www.jforum.net/doc/SSO
http://www.jforum.net/doc/ImplementSSO
http://www.jforum.net/doc/SSOcookies
http://www.jforum.net/doc/SSOremote
有上面這些文檔基本可以自己實現一個,主要就是實現net.jforum.sso接口就是了。
在Jforum的service方法裏邊有段(service流程中的刷新session):
ControllerUtils utils = new ControllerUtils()
utils.refreshSession();//重點
裏邊提到,在沒有usersession的情況下,如果配置的驗證類型是sso(authentication.type),就調用 checkSSO(UserSession userSession)的方法
-> 生成SSO實例(使用sso.implementation來配置) -> 調用authenticateUser(RequestContext request)返回username
-> 假如取不到的username,就設爲匿名 -> 否則,如果不存在該用戶(utils.userExists(username)則註冊一個(utils.register(password, email)) -> 假如已經存在,則讓用戶登錄(configureUserSession(userSession, utils.getUser()))
當已經存在usersession的時候,並且驗證方式是sso的時候,就是驗證是否有效 (sso.isSessionValid(userSession, request))。
所以,整個過程和官方文檔提到的流程是一樣的,如果要實現自己的sso,這是實現SSO接口,使用authenticateUser 來驗證不存在usersession的情況,並返回username or null,而使用isSessionValid來判斷一個已經存在的usersession是否有效。參考上面幾個連接文檔,實現和已有系統的sso集成,還是比較清晰明瞭的。