假如网站需要提供客服功能,如果只是简单的聊天咨询可以考虑营销QQ、百度商桥等(目前大部分网站采用此方式,包括一些知名行业电商);如果需要更精细化的管理,比如客服人员安排、各项数据统计汇总,那么需要对接专业的第三方客服平台,比如网易七鱼,当然价格不菲;然而若是如京东本身就是一个平台,需要为每个商家提供各自的客服管理,首先目前第三方提供商并无此类产品(网易七鱼据说已经开发出来了,但是官网上没找到),其次即使有,价格也肯定不便宜,而且数据在别人那里总归不好。所以电商平台的客服系统,一般都是自己开发。当然了,借助优秀的开源项目,自主开发[一套简单能用的]也变得轻松很多。
我采用了openfire+spark+layim,前两者基于java平台,layim是国人开发的一个webim前端组件。
先来看大致效果(左边是浏览器layim-客户提咨询,右边是spark聊天窗口-客服解答)
图示:
本文涉及到的知识点(杂乱,后续会不定期添加内容):
Java基础
Intellij Idea:Java IDE
Mybatis:半ORM
XMPP协议
smack:XMPP协议的Java封装
openfire
fastpath:openfire插件,我们需要依赖它实现客服功能
spark
一秒钟入门Java
Java SE(J2SE):Standard Edition,可认为是基础库,用于开发和部署桌面、服务器以及嵌入设备(J2ME)和实时环境中的Java应用程序。
Java EE(J2EE):基于SE的高级库,提供 Web 服务、组件模型、管理和通信 API,可以用来实现企业级的面向服务体系结构。
可以知道J2EE比J2SE多了Web相关的组件和API,但是本人在使用SpringMVC框架开发Web应用程序时,去官网Java SE页面下载的JDK,也能正常开发。后来查看官网的Java EE的下载页面,发现提供的SDK中主要包含一个叫GlassFish的开源组件和一些示例及文档,而Java EE刚开始是以一种规范提出,GlassFish可以看作是实现了这些规范的JEE容器,而我们开发Web站点时部署到服务器(比如Tomcat),实现了JEE规范其中的Servlet容器部分,所以以JDK开发Web并不会出现问题。
目前流行的IDE有Eclipse和IntelliJ IDEA,前者免费且由于历史关系占有率一直很高,后者也有社区版,据说使用性上目前完胜前者。
final关键词:类似于.NET的readonly
匿名内部类:
定义一个类A(可以为abstract),为方便说明,在A中定义一个[抽象]方法dosth。在调用方法里可以直接new A,并且同时给dosth赋方法体。
public abstract A{ public void dosth() { } } public abstract B{ public void call() { final A a = new A() { public void dosth() { //这里写方法体 } }; } }
看着是实例化了A的一个对象,其实是实例化了A类的匿名子类。
Access restriction:eclipse对某些java包(or 类?)有access rules,比如 sun.awt.shell.ShellFolder。因为这些JAR默认包含了一系列的代码访问规则(Access Rules),如果代码中引用了这些访问规则所禁止引用类,那么就会提示这个错误信息。解决方法:既然存在访问规则,那么修改访问规则即可。打开项目的Build Path Configuration页面,打开引用的[报错]JAR包,选中Access rules条目,选择右侧的编辑按钮,添加一个访问规则即可。
Java NIO
Apache Mina
CopyOnWrite:CopyOnWrite容器即写时复制的容器。通俗的理解是当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁。从JDK1.5开始Java并发包里提供了两个使用CopyOnWrite机制实现的并发容器,它们是CopyOnWriteArrayList和CopyOnWriteArraySet。
Maven:项目管理工具。不像VS,eclipse是更纯粹的编码工具,在维护jar包和项目之间的依赖关系、项目的构建目标等方面的功能比较弱(比如拷贝了一个项目,我们需要手动去Configure Build Path),而Maven就是补足于此。Maven独立于IDE,eclipse有一个插件叫M2E,里面内置了Maven。Maven项目的配置信息保存在pom.xml文件中。
我们在导入Maven项目时,有时会发现不止一个pom.xml,那是因为项目中有子项目(module,module有自己的pom.xml),只要选择最顶层的pom.xml文件即可,会自动加载引用到的子项目。
JavaBean:一般可看作是POJO,可参看 Java Bean 是个什么概念? (不过这个问题里有个答主说Java没有事件的概念,让我大吃一惊,不过转念一想,Java主要用于开发服务端应用,确实不怎么涉及到[自定义]事件。其实Java中是有事件机制的,只是不知变通,就一个半成品的观察者模式,想想C#的委托,其实就一个函数指针的事)
MVC:当.Neter们在被Asp.Net的重量压得踹不过气来的时候,Java已经有MVC的概念了。很多模式,.Net界都是直接copy,.Neter们并没有对其历史的认知,所以接收不能,MVC就是如此。其实在Asp.Net时代已经有MVC的影子,就是一般处理程序.ashx。很早以前,用户提交都是提交到具体的一个页面,于是会经常导致一个页面并不是用于显示,而是用于业务逻辑的处理,于是后来把业务逻辑单独拎出来,这便是Controller,用户请求的是Controller,不再是具体页面,并且Controller里不再使用类似HttpRequest或者HttpResponse获取数据和返回响应,而是使用对象的形式(M),这便是MVC模式。可参看 Java Web开发模式
Java中的注解相当于.NET中的Attribute。
Spring是一个IOC和AOP框架。我们可以通过在xml文件中配置bean,然后在代码中使用@Autowired或@Resource注入bean实例,不过配置的环节稍显繁琐,能不能省略呢?答案是肯定的,Sping2.5开始支持注解注入,具体可看 spring注解注入:<context:component-scan>详解。需要注意的是,@Component及相关的几个注解类,在应用到interface上的时候,可能并不如预期工作,因为interface并不能实例化,而这几个注解类貌似又没有@Inherited修饰,所以就算有实现类或运行时的动态实现类,也不会注册到上下文中;且修饰的类要有公共构造函数。另外注入[被注入方]一般只能在注入方本身是已注册的bean里,若在普通类里想通过@Autowired或@Resource的方式注入bean,则稍微有点绕,可参看 Java普通类获取Spring XML中Bean的方法总结
关于Servlet、Struts、Spring、SpringMVC的关系与区别可参看 Java开发web的几种开发模式 和 SpringMVC与Struts2的对比
SpringMVC竟然URL和参数大小写敏感,虽然有办法配置,但这种预设没有道理吧。。。
Servlet url-pattern /与/*区别:两者的长度不同,根据最长路径匹配的优先级,/*比/更容易被选中,而/的真正含义是,缺省匹配。既所有的URL都无法被选中的时候,就一定会选中/,可见它的优先级是最低的,这就两者的区别。
xml文件也可以打包进jar包,但是访问jar包里的xml文件就不能按文件目录的方式来了,可参看 http://blog.csdn.net/jianxin1009/article/details/18814799
application.getInitParameter:jsp中9个内置对象之一application,它的数据对整个web应用都有效,application有一个重要的用途就是通过getInitParameter()获取web.xm中的配置参数,这样可以提高代码的移植性。
dwr:简化ajax调用,使得调用远程服务器方法看上去像调用本地方法一样。
在java项目中必不可少的是我们要指定一个jdk。在指定jdk的同时,还可以指定jdk的Language level,这个有点像我们工程最低支持版本。比如Language level 设置了5.0 只是就不能出现使用6.0/7.0特性的代码,因为这些特性在5.0的环境下是无法编译的。或者可以理解ide会安装Language level指定的jdk版本来对我们的代码进行编译,以及错误检查。即同样的jdk对应不同的Language Level会采用[可能]不同的编译和优化方式。
Java中也有类似.Net的字符串池的概念,请看 String中intern的方法
Java插件技术: OSGi
貌似在同一package下,protected可见。(和.NET不同)
Java的泛型类型只能是引用类型,而不能是基础类型,但是Java针对每个基础类型有对应的封装类型,比如boolean对应Boolean,后者是引用类型,可以为null,当封装类型不为null时,可以隐式转换,但写代码时null的情况要自己处理,如
private boolean existUser(String username) { Boolean result = null; return result != null && result.booleanValue(); }
Ant:类似于.NET的MSBuild,其构建文件默认为build.xml(可以在其中指定构建基于的Java平台版本),每个构建文件都对应于一个项目,但是大型项目经常包含大量的子项目,每一个子项目都可以有自己的构建文件。
一个.java文件中可以定义多个类,但是public修饰的只能至多有一个,且要与文件名相同,编译后,有几个类就会产生几个对应的.class文件。jar包类似.Net的dll,它将多个.class文件打包一块。大多数 JAR 文件包含一个 META-INF 目录,它用于存储包和扩展的配置数据,如安全性和版本信息。Java 2 平台识别并解释 META-INF 目录中的下述文件和目录,以便配置应用程序、扩展和类装载器。具体可看 MANIFEST.MF 文件内容完全详解。
System.getProperty()获取系统/项目全局变量,比如Java运行时版本,当然我们也可以通过System.setProperty()设置自定义变量。
Java桌面客户端编程:Java Swing 。桌面程序毕竟不是Java的主流领域,因此各IDE貌似也并未作太多努力,相较VS的所见即所得的控件拖拽开发模式,Java GUI编程就吃力很多了。
Java国际化:i18n,注意中文的资源文件,貌似需要先UTF-8转码,大约就是像这样。(可以使用JDK自带的native2ascii.exe)
Intellij Idea
使用Intellij Idea创建spring mvc时(没用maven),run都报 Error during artifact deployment. See server log for details 错误,后来把lib文件夹拷到WEB-INFO文件夹下就没问题了,不知何故。
原因:tomcat默认是去web-info/lib/下找依赖的jar包。手动拷jar包毕竟不是一个好办法,其实我们可以在下图处进行Artifacts设置
运行项目,项目目录下会多出一个out文件夹,生成所有的站点文件,依赖包会自动拷贝到下面的WEB-INF/lib/下,如下图:
IDEA配置artifacts中Web Application:Exploded和Web Application:Archive的区别:前者以文件夹形式(War Exploded)发布项目,后者以war包形式(每次都会重新打包全部的)。Tomcat会自动解压war包并启动站点,缺点是会造成一段时间的站点不可用,而以文件夹形式发布的话,则支持热部署(需进行额外的一些配置)。
当然我们也可以使用Maven进行依赖包的管理。在当前项目右键->Add Framework Support->Maven即可。注意需要在Project Structure-> Project Settings中移除之前非Maven引用的包依赖。此时运行项目,项目目录下会多出一个target文件夹,其下有生成的站点文件。但是运行时发现WEB-INF下的文件除了web.xml外,其它的文件都不会覆盖,貌似用maven管理的web工程,需要将applicationContext.xml等资源文件放在resource目录下,然后以classpath的方式去访问。后来发现jsp页面也无法自动更新到target目录,再后来听说maven有一套约定的目录结构,貌似又可以通过pom.xml进行自定义配置,神烦!目前靠手动覆盖。参考 Maven使用点滴 配置即可(webappDirectory我没设置,就设置了warSourceDirectory,能正常更新了)
Intellij Idea中有个Ant Build Window,默认显示的是主项目下build.xml中的targets,and by default, IDEA only shows the default target and targets that have descriptions。对这个有疑问可参看 How to get Ant Build to list all targets in a hierarchy of build files.
可以在Run/Debug Configurations Window中设置自定义系统变量,如下图(-D不能省):
MyBatis
一个半ORM框架,SQL语句并不是像EF一样由框架解析,而是要预先写在xml中或者写在Java注解(同.Net的Attribute)中,且不支持匿名类型(即select出来的数据要么是基础类型,要么要有对应的Java Bean)。一般情况下,我们使用resultType映射查询结果和对象即可(MyBatis 会在幕后自动创建一个 ResultMap),当只想映射部分字段或者包含复杂类型属性的时候,我们需要自定义ResultMap。
MyBatis不支持方法重载,因为它是通过方法名称(不加参数)去查找执行方法,因此我们设置不同的方法名,或者使用动态sql。
XMPP协议
JID表示一个地址,由三部分组成——node、domain和resource。例如:xiaoming@xiaoming.home/sleeping,xiaoming就是node ,xiaoming.home就是domain,sleeping就是resource。node domain 和resource任何一部分都不能超过1023 字节 ,加上@和 /,一个JID 总共不能超过3071字节。BareJid就是去掉resource,只包含node@domain。
XMPP包含IQ, message and presence 三种packet。
smack
ConnectionConfiguration.Builder的setXmppDomain和setHost的区别?一个是域(服务器集群),一个是其中的一台服务器,应该只要设置其中一个就可以了。
使用XMPPTCPConnectionConfiguration建立连接时报空指针错误,调试发现有个base64encoder未赋值,需要引用smack-java7包,该包会初始化base64encoder,如果是安卓开发,那么就引用smack-android。
openfire
使用idea导入openfire代码,过程可参考将openfire源码部署到IDEA中 或者 IntelliJ IDEA搭建openfire4.1.3开发环境 。使用openfire配置界面只能配置一个数据库,且我也不打算完全依赖它生成的数据库。我需要openfire部分功能使用现有的数据库(比如用户表),而openfire的业务数据仍然使用生成的数据库,因此涉及到多库连接。这只能去修改源码了。
上面说到的配置界面设置的项最终存储在ofproperty表中。在配置界面完成配置后,我们也可以在conf/openfire.xml中重新设置值,重启openfire,配置文件中的值会更新到数据库中。
以AuthFactory为例,其initProvider方法里有 JiveGlobals.migrateProperty("provider.auth.className"); ,XMLProperties根据"provider.auth.className"读取xml文件中的值(getProperty方法)
//按逗号拆分为数组 String[] propName = parsePropertyName(name); // Search for this property by traversing down the XML hierarchy. Element element = document.getRootElement(); for (String aPropName : propName) { element = element.element(aPropName); if (element == null) { return null; } } value = element.getTextTrim();
对应的配置节写法如下(可以看到,propName对应各层级element,而非attribute形式)
<provider> <auth> <className>org.jivesoftware.openfire.auth.JDBCAuthProvider</className> </auth> </provider>
而后覆盖数据库值
public void migrateProperty(String name) { if (getProperty(name) != null) { if (JiveGlobals.getProperty(name) == null) { JiveGlobals.setProperty(name, getProperty(name)); deleteProperty(name); } else if (JiveGlobals.getProperty(name).equals(getProperty(name))) { deleteProperty(name); } else if (!JiveGlobals.getProperty(name).equals(getProperty(name))) { Log.warn("XML Property '"+name+"' differs from what is stored in the database. Please make property changes in the database instead of the configuration file."); } } }
当然,若是我们有数据库权限,直接进入数据库修改也一样。
openfire源码采用JDBC方式操作数据库,而且没有做很好的封装,重复代码较多,如下图所示
相似代码在与数据库交互的地方随处可见。部分逻辑的抽取,莫过于lambda(回调函数)的方式。考虑到Java8已经支持lambda表达式,重构如下:
public <T> T excuteQuery(String queryText, Function<ResultSet, T> func) { T result = null; Connection con = null; PreparedStatement pstmt = null; ResultSet rs = null; try { con = getConnection(); pstmt = con.prepareStatement(queryText); rs = pstmt.executeQuery(); if (rs.next()) { result = func.apply(rs); } } catch (SQLException e) { Log.error(e.getMessage(), e); } finally { DbConnectionManager.closeConnection(rs, pstmt, con); } return result; }
但是在写调用代码的时候提示:
虽然我们在excuteQuery方法中已经catch了这个异常,但是编译器并不买账。而且就算我们在方法定义时已经throws了相关异常,也没用,如下图:
解决方法有两种:可以在lambda体内catch异常后不再throw;或者自定义一个Functional Interface,其中声明一个定义了异常的方法,
@FunctionalInterface public interface CheckedSQLExceptionFunction<T, R> { R apply(T t) throws SQLException; }
然后将Function<Result,T>的地方替换为CheckedSQLExceptionFunction<ResultSet, T>。这两种都显得别扭与不合理,导致这一问题的是,Java Lambda规定如果Lambda中抛出了异常,那么这个异常一定要在Functional Interface中的abstract方法上定义。这是一个让人无法理解的规定。
遇到lambda的另一个坑:
由于username有重新赋值,所以编译报错,是不是很喜感?我不得不用一个临时变量解决。。
官方提供了一种集成外部用户体系的方法(Custom Database Integration Guide),然后并不支持加盐密码,于是我只能自己撸码解决。关键是实现两个接口:AuthProvider 和 UserProvider,只要实现部分方法即可,很简单不赘述。
部署
部署到centos7。首先 rpm -qa | grep openjdk 查看所有已安装的jdk,如果版本不满足则先 rpm -e --nodeps [java-1.7.0-openjdk[-headless]] 卸载掉。然后去官网上下载合适版本的server jre/jre/sdk包(下面会进一步说明),然后解压,设置环境变量,就算安装完毕了(不过这种安装方式通过rpm -qa可是找不到的哦)。具体可看 Centos7 JDK8安装配置。
讲道理,jdk是开发时候用的,部署的话我们只要安装jre就可以了。我刚开始下载的是server jre包,在ant的时候报 package javafx.util does not exist 的错(因为我在代码里用到了Pair<>二元组,属于javafx.util包),然而网上查了下,貌似javaFX是用于客户端GUI方面的组件(不知道是否我这里报错的javafx同个概念)。我懒得探究,马上去官网下了jre包(官网说Covers most end-users needs. Contains everything required to run Java applications on your system.),载下来之后发现果然有jfxrt.jar(包含javafx.util),欢欣鼓舞,但是ant之后报无法找到/lib/tools.jar——因为build.xml里有用到这个jar——之前server jre是有的,也是日了狗了。马上去下jdk,疯狂操作之后终于编译通过。
也可以在windows平台编译打包,然后拷贝到linux系统。
官网上是说./openfire start启动openfire,然而我只找到openfire.bat和openfirectl,先试了./openfirectl start 报错:Could not find Openfire installation under /opt,/usr/share,or /usr/local,查看openfirectl的shell代码,发现当OPENFIRE_HOME未设置时,会去这三个目录下找openfire,于是为其设置真实根目录,然而虽没报错,但还是没有运行起来。试了下openfire.bat,报Permission denied,尼玛,我可是用root登录的。先不管原因,我再去官网下了4.1.6(目前最新版)的tar包,发现bin目录下果然有个openfire文件,拷到服务器上后报同样的Permission denied的错误——网上说root并不默认就有所有文件的最高权限,但是他可以随意给自己增加权限——好吧,设置了权限之后,执行./openfire start 没报错,但是依旧没有运行起来。。。后来发现没有输出错误信息,是因为shell里写了/dev/null 2>&1,去掉之后终于提示——Could not find or load main class com.install4j.runtime.launcher.UnixLauncher——shell代码里该类指向的目录本地编译不存在,最后在官网tar包里发现有一个名为.install4j的隐藏文件夹,拷贝后总算运行起来了。
记得打开相应端口。
webchat
用户一般都是通过浏览器进行咨询,有个webchat示例可以参考(openfire4.2 配置fastpath、webchat、spark实现客服系统),但那是基于很久以前的smack版本,转过来也费了不少劲,特别是QueueUpdate包扩展已经不再内置支持,调试了半天在smack中找到几个关键文件,这些都是内置资源文件,项目运行时会读取这些文件,调用ProviderManager.addExtensionProvider将配置项缓存起来,如果不修改xml的话,那么在外部调用该方法也是可以的。参照着写了一个QueueUpdateProvider,顺便了解了下XmlPullParser的用法。
关于自定义包和扩展,后来才发现官网上有介绍: Provider Architecture: Stanza Extensions and Custom IQ's,也是心累。
再后来,发现部分非内置的扩展的Provider已经在扩展类里[作为内部类]定义好了,比如QueueUpdate.Provider。。。吐血。关于内部类可参看 java中的内部类总结
部署
在CentOS安装tomcat9.0.1。去官网下载tar.gz包,解压,然后去到bin目录,在catalina.sh文件添加内容export CLASSPATH=$JAVA_HOME/lib,然后./startup.sh即可,另外记得开放8080端口。当然我们可以更改端口以及绑定域名,参考 tomcat发布应用并配置域名。关于项目打包成war包,参考 Intellij IDEA社区版打包Maven项目成war包,并部署到tomcat上。
fastpath
增加几个http接口,如新增客服组,添加客服等,示例代码如下:
public class MasonServlet extends HttpServlet { @Override public void init(ServletConfig config) throws ServletException { super.init(config); AuthCheckFilter.addExclude("fastpath/mason/*"); // 公共接口不需身份校验 } @Override public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String action = request.getRequestURI(); action = action.substring(action.indexOf("mason/") + 6); OPResult result = null; if (action.toLowerCase().equals("createworkgroup")) { String wgName = request.getParameter("wgName"); String description = request.getParameter("description"); String agents = request.getParameter("agents"); result = createWorkgroup(wgName, description, agents); } if (result == null) { result = new OPResult(); result.setSuccess(false); result.setMessage("未找到对应方法"); } response.setContentType("application/json; charset=utf-8"); response.setCharacterEncoding("UTF-8"); Genson genson = new Genson(); String json = genson.serialize(result); response.getOutputStream().write(json.getBytes("UTF-8")); } // 新增工作组(会同时建立一个默认客服组,每个工作组可以包含多个客服组) private OPResult createWorkgroup(String wgName, String description, String agents) { OPResult result = new OPResult(); Map errors = WorkgroupUtils.createWorkgroup(wgName, description, agents); if (errors.size() == 0) { Workgroup workgroup = WorkgroupManager.getInstance().getWorkgroup(wgName); result.setData(workgroup.getJID()); result.setSuccess(true); } else result.setSuccess(false); return result; } }
完了我们就可以重新构建该插件了,在intellij中可以在窗口中设置(看了下build.xml,发现plugin任务可以构建单个插件,它接收plugin的参数表明构建的是哪个插件):
由于代码中用到了genson这个第三方jar包,虽然直接编译没问题(项目的其它地方有引用),但用ant构建的时候会报错,提示找不到这个组件,原因官网说了:Any JAR files your plugin needs during compilation should be put into the lib directory,因此我们需要将该jar包复制一份到fastpath/lib目录下。
spark
此spark非彼spark,而是一个开源IM桌面客户端。下载下来2.8.3代码,导入到IntelliJ,运行输出了空指针异常,调试发现找不到资源文件 "META-INF/plugins.xml",查看编译后的jar文件,里面已经包含了resources/META-INF/plugins.xml。再查看Project Structure,发现没有为主模块Spark设置Resource Folders,添加了resources文件夹后编译运行正常,此时再看jar文件,里面并没有resources目录,META-INF直接在根目录体现。
也就是说,将某个目录设置为资源文件夹(Resource Folders),意即将该目录下的子目录一起打包进jar包(不包含该目录本身),而getResource()方法获取特定路径的资源时,是直接去jar包根目录下查找对应文件。
似乎还要设置VM arguments:-Djava.library.path=build/lib/dist/windows64,具体值按照操作系统来。参看 openfire-spark 二次开发-(二)运行环境配置
相关资料:TCP长连接与短连接、心跳机制