`
野狼杰克
  • 浏览: 14234 次
  • 性别: Icon_minigender_1
  • 来自: 上海
最近访客 更多访客>>
社区版块
存档分类

JForum源码分析笔记

阅读更多

我的开发环境:

JForum2.1.8

tomcat5.X

JDK 1.6X

 

以不能脱俗的套路开始。从web.xml开始

web.xml中包括一个filter,一个listener,和两个servlet,内容不多。

写道
可以看到里边有个监听器ForumSessionListener,*.page的过滤器ClickstreamFilter,还有2个*.page的处理器,其中InstallServlet是安装相关的,JForum则是前端处理器。基本上整个流程就是client request -> ForumSessionListener -> ClickstreamFilter -> JForum -> server response.
 

 

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="plightbo@hotmail.com">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() {}
}

 

写道
ForumSessionListener实现了HttpSessionListener接口,但是只是对session destory做了处理,在这个过程中,保存session的历史记录到DB,并清除用户信息和相关的security信息。

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);
	}
}
 

Servlet:

写道
可以看到 JForum和InstallServlet都继承了JForumBaseServlet这个HttpServlet,而 JForumBaseServlet包括2个重要的方法init和startApplication。众所周知,init是servlet初始化时调用的方法,JForumBaseServlet 里边的init方法的流程是:
调用父类的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中的前端控制器,所有请求都将通过这个类转发。

 

写道
上面简单提到了Jforum处理请求的过程,现在在来看看这个过程,就是service方法,这次采用代码概要的方式展示:
// 初始化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集成,还是比较清晰明了的。
 

 

 

2
0
分享到:
评论

相关推荐

    jforum 源码

    通过分析源码,你可以了解到如何实现数据缓存,减少对数据库的访问。 9. **论坛功能实现** JForum提供了丰富的论坛功能,如发帖、回帖、版块管理等。源码中包含了这些功能的具体实现,如帖子的CRUD操作、版块的...

    jforum 2.1.9源码

    一、JForum 2.1.9源码结构分析 JForum的源码结构清晰,主要分为以下几个核心模块: 1. **Core**:这是JForum的核心模块,包含了论坛的基本功能,如用户管理、论坛板块、帖子处理等。其中,`com.jforum`包下包含了...

    jforum说明文档 源码解析 单点登录 jforum缓存

    在深入理解Jforum的过程中,源码解析是至关重要的一步。 **源码解析** Jforum的源码结构清晰,采用MVC(模型-视图-控制器)设计模式,使得代码维护和扩展变得容易。主要的组成部分包括: 1. **模型层(Model)**:...

    开源BBS--JForum 源码

    本文将深入解析JForum的源码,帮助你了解其架构设计、核心模块以及如何进行二次开发。 首先,JForum采用了MVC(Model-View-Controller)设计模式,这是Web应用程序开发中的经典架构。模型负责处理业务逻辑,视图...

    JForum Source Analysis JForum开源论坛的源码分析

    ### JForum 源码分析 #### 一、引言 JForum是一个强大的开源论坛系统,采用MVC(Model-View-Controller)架构设计。它不仅功能全面且易于管理,适用于任何Servlet容器与EJB服务器环境。对于希望深入了解Java Web...

    JForum 2.1.9 源码包.zip

    JForum 是采用Java开发的功能强大且稳定的论坛系统。它提供了抽象的接口、高效的论坛引擎以及易于使用的管理界面,同时具有完全的权限控制、多语言支持(包括中文)、高性能、可自定义的用户接口、安全、支持多...

    chx 学习jForum笔记十八 jForum与ms sqlserver

    《jForum与MS SQLServer整合学习笔记》 jForum是一款基于Java的开源论坛系统,它以其高度可定制性、灵活性和强大的功能深受开发者喜爱。在本文中,我们将深入探讨如何将jForum与Microsoft SQL Server(简称MS SQL...

    jforum2论坛源码

    标题"jforum2论坛源码"表明了我们关注的是一个名为jforum2的开源论坛系统的源代码。jforum2是一个基于Java技术的讨论板平台,允许用户进行互动交流,提供社区建设和管理功能。源码通常包含程序的所有原始代码,可供...

    JForum v2.1.9 源码版

    JForum 是采用Java开发的功能强大且稳定的论坛系统。它提供了抽象的接口、高效的论坛引擎以及易于使用的管理界面,同时具有完全的权限控制、多语言支持(包括中文)、高性能、可自定义的用户接口、安全、支持多...

    jforum3.0可以运行的源码

    jforum3.0从SVN上导出来的时候缺少jar包,经过不断的测试与添加终于能在myeclipse下面运行啦。可是现在的jforum3.0仍然是beat版本的。有许多BUG。如果不介意的话可以下载回去研究一下哦。我上传的东西没有jar包哦,...

    开源jsp论坛_ jforum 2.1.4源码

    **JSP(JavaServer Pages)技术详解** JSP(JavaServer Pages)是Java平台上的一个标准,用于开发动态网页。...通过分析源码,可以提升对JSP、Servlet、MVC架构等核心概念的理解,并能应用于实际项目开发中。

    jforum-2.1.8-src.zip

    通过分析此文件,我们可以了解JForum如何组织其界面和导航结构。 `ChangeLog.htm` 记录了版本间的变更和改进,对于开发者来说是了解软件历史和演化的重要参考资料。在这里,我们可以看到2.1.8相对于先前版本的改进...

    JForum2.6.2.rar

    1、包含jforum2.6.2的war包、源码包 2、war包可直接放在tomcat的webapps目录下 3、2.6.2版本里面自带汉化功能,在http://localhost:8080/jforum/install.jsp安装时,注意选择中文

    开源jsp论坛jforum-2.1.9源码带mysql数据库文件

    在分析和学习JForum源码时,开发者可以从以下几个方面入手: 1. JSP页面结构:理解各个JSP文件如何通过请求和响应对象交互,以及如何调用后台JavaBean完成业务逻辑。 2. Servlet与JavaBean:研究Servlet如何处理...

    jforum3 JAVA论坛源码

    Jforum3是一款基于JAVA语言开发的开源论坛软件,其源码开放,允许用户进行二次开发和定制,以满足不同需求。这款论坛系统以其稳定性和高效性在Java社区中广受欢迎。本文将深入探讨Jforum3的核心特性、开发环境以及...

    JForum-2.1.4.rar_JForum-2.1.4_java 论坛源码_jforum_论坛源码_论坛网站源码

    **JForum 2.1.4 - Java 开源论坛系统详解** JForum 是一个功能强大的、基于Java技术的开源论坛软件。它以其高效、稳定、易于定制和丰富的特性深受开发者和社区管理员的喜爱。JForum 2.1.4 版本是这个项目的其中一个...

    Jforum二次开发成果

    2.1 环境搭建:首先,需要在本地安装JDK和Eclipse,然后导入Jforum源码到Eclipse项目中。同时,确保数据库环境(如MySQL)已经准备就绪,以便进行数据操作。 2.2 功能分析与设计:分析论坛的需求,确定需要增加的...

    jforum+ckeditor整合案例

    - 打开JForum的源码,定位到编辑器相关部分。通常,这会是在某个`jsp`或`ftl`文件中,比如`post.jsp`或`edit_post.ftl`。 - 替换原有的编辑器代码,引入CKEditor。这通常涉及到在HTML中添加CKEditor的初始化脚本,...

    JForum3 jforum java 开源论坛 论坛

    JForum3是一款基于Java开发的开源论坛系统,其核心设计目标是提供一个高效、稳定且功能丰富的在线讨论平台。...无论是从功能实现、架构设计还是源码分析的角度,JForum3都是值得深入研究的Java项目。

Global site tag (gtag.js) - Google Analytics