本文旨在实现一个基于C/S模式的工具型框架 CsFramework,该框架将完成如下几个功能:
首先先解释一下何为C/S模式。
C/S 即 Client-Server(服务器-客户机)结构,服务器负责数据的管理,客户机负责完成与用户的交互任务。
我举一个比较常用的例子,就是聊天室。客户端想要给另一个或同时很多客户端发送一条信息,这个请求和需要发送的消息会先到达服务器,然后服务器获取这个请求,将消息转发给其他客户端。
再解释一下长连接,简单来说就是客户端和服务器一直保持着连接,在此期间可以随时发送消息或者请求,不需要进行再次连接,除非某一方发生异常掉线或者主动下线。
C/S模式是非常常见的一种架构,我们的框架将实现基础的服务器消息转发功能,以及基于注解和xml文件解析的分发器。也就是说基于我们框架开发的人员可以通过写注解或者xml文件配置的方式,实现客户端登录、注册等等额外的功能,底层服务逻辑都由CsFramework来完成。
基于此框架还可以开发斗地主、三国杀等等只要是C/S结构的APP。
这个框架是我大二的暑假完成的,当时一直没有写博文,现在我大三了,再做新的框架,需要用到CsFramework,所以再次再进行一遍梳理,并改善一下之前的代码结构。现在我在完成服务发现和多文件云传输,用到了RMI,NIO等技术,我都自己实现了一个可用的框架,在之后的文章中也会分享出来。
CsFramework的源码大家可以从我的github上直接查看
CsFramework 源码
在正式开始框架的实现之前,我先来介绍一下我的工具包。为了便于我的开发工作,我实现了一个常用的工具包,里面包含了 Java 和 MySQL 的 ORM工具,包扫描,XML解析,Properties文件解析,观察者模式模板等等在开发中我需要经常用到的东西,我的CsFramework中实现的分发器,就是基于XML文件解析器,和通过包扫描器扫描注解完成的,所以 我在此先给出这两个工具的代码,关于这些工具是如何实现的,有时间我会再写文章发出。
如果对这里觉得云里雾里的读者,可以先跳过这一部分,再看到相应功能的实现时再跳转回这里。
大家知道,Java中的命名空间是通过包来手动实现的,往往一个工程下我们会建立许多的包,在包中建立许多的类,包扫描器就是实现了一个这样的功能,它会扫描用户给出的包名,在这个包中遍历所有的类文件,通常包扫描器会配合注解一起使用,这样我们就能找到被我们要求的注解过的类,从而进行反射或者其他操作。
package com.tyz.util;
import java.io.File;
import java.io.IOException;
import java.net.JarURLConnection;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.Enumeration;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
/**
* 包扫描
*
* @author tyz
*/
public abstract class PackageScanner {
public PackageScanner() {
}
/**
* 处理扫描到的类
* @param klass 包扫描器扫描到的类
*/
public abstract void dealClass(Class<?> klass);
/**
* 扫描jar包
* @param url jar包的路径
*/
private void scanJar(URL url) {
try {
JarURLConnection connection = (JarURLConnection) url.openConnection();
JarFile jarFile = connection.getJarFile();
Enumeration<JarEntry> entryList = jarFile.entries();
while (entryList.hasMoreElements()) {
JarEntry jarEntry = entryList.nextElement();
if (jarEntry.isDirectory() || !jarEntry.getName().endsWith(".class")) {
continue;
}
String className = jarEntry.getName();
className = className.replace(".class", "");
className = className.replace("/", ".");
Class<?> klass = Class.forName(className);
dealClass(klass);
}
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();
}
}
/**
* 扫描文件夹
* @param curFile 当前扫描到的文件夹名
* @param packageName 包名
*/
private void scanDirectory(File curFile, String packageName) {
File[] files = curFile.listFiles();
for (File file : files) {
if (file.isDirectory()) {
scanDirectory(file, packageName + "." + file.getName());
} else if (file.isFile()) {
String fileName = file.getName();
if (fileName.endsWith(".class")) {
fileName = fileName.replace(".class", "");
String className = packageName + "." + fileName;
try {
Class<?> klass = Class.forName(className);
dealClass(klass);
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
}
}
}
/**
* 暴露给public包扫描方法
* @param packageName 需要扫描的包名
*/
public void packageScanner(String packageName) {
ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
String pathName = packageName.replace(".", "/");
try {
Enumeration<URL> urls = classLoader.getResources(pathName);
while (urls.hasMoreElements()) {
URL url = urls.nextElement();
if ("jar".equals(url.getProtocol())) {
scanJar(url);
} else {
File curFile = new File(url.toURI());
scanDirectory(curFile, packageName);
}
}
} catch (IOException | URISyntaxException e) {
e.printStackTrace();
}
}
}
然后是XML文件解析器,Java的XML解析流程还是比较麻烦,这个工具旨在避免重复写类似的代码,所以我将固定的代码做成了一个模板。要解析xml文件直接用这个工具就好,不需要再去写制式的代码。
package com.tyz.util;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import java.io.IOException;
import java.io.InputStream;
/**
* XML文件解析器
*
* @author tyz
*/
public abstract class XmlParse {
private static DocumentBuilder db;
static {
try {
db = DocumentBuilderFactory.newInstance().newDocumentBuilder();
} catch (ParserConfigurationException e) {
e.printStackTrace();
}
}
public XmlParse() {
}
/**
* 处理获取到的xml文件中的元素
* @param element 获取到的元素
* @param index 下标
* @return 是否成功处理
*/
public abstract boolean dealElement(Element element, int index);
public void getElement(Document doc, String tag) {
if (doc == null) {
return;
}
NodeList nodeList = doc.getElementsByTagName(tag);
for (int i = 0; i < nodeList.getLength(); i++) {
Element element = (Element) nodeList.item(i);
if (!dealElement(element, i)) {
break;
}
}
}
public void getElement(Element parent, String tag) {
if (parent == null) {
return;
}
NodeList nodeList = parent.getElementsByTagName(tag);
for (int i = 0; i < nodeList.getLength(); i++) {
Element element = (Element) nodeList.item(i);
if (!dealElement(element, i)) {
break;
}
}
}
public static Document getDocument(String path) {
InputStream is = Class.class.getResourceAsStream(path);
try {
return db.parse(is);
} catch (SAXException | IOException e) {
e.printStackTrace();
return null;
}
}
}
接下来我们就开始逐步完成这个框架。
要建立一个C/S模式的框架,自然是要完成客户端与服务器之间的通信,不管是客户端还是服务器,对另一方发送消息的代码都是一样的,所以我们可以将其抽象出来,做一个基类。再后面通过服务器和客户端不同的特性,增加不同的操作。
Java中的网络编程是靠Socket
类实现的,两端相连需要连接对方的Socket
,这里我们选择使用字符串进行传输,所以用DataInputstream
建立通信信道。
这个通信层的基类需要完成服务器和客户端之间共同需要的通信功能,也就是要向对端发送消息,从对端接收消息,以及建立和关闭通信信道。服务器和客户端这四个功能都是相通的。
CsFramework需要实现的服务器和客户端之间的长连接,所以我们可以使用一个单独的线程,专门来侦听从对端发送的消息,然后进行处理。
这样大致思路就清晰了。
可以让这个通信层的基类Communication
直接继承Runnable
,在初始化它的时候,就完成和对端通信信道的建立,然后保持侦听对端发送的消息。
所以可以设置一个变量goOn
,并且是volatile
的,在goOn
为true
的时候,也就是服务器和客户端还正常连接着,就一直保持侦听,当goOn
为false
时,结束侦听。
下面一行我使用了一个线程池,因为线程的申请不同于对象的申请,线程是需要调用操作系统内核的API的,然后操作系统会为线程申请一系列资源,这个成本是很高的,所以即使这里我们可能不需要多少线程,也不要建立显示线程,使用线程池就好。
在这个侦听对端信息的线程运行过程中,会一直尝试从对端读取数据,读不到就会阻塞在那里。这里会出现几种情况,就是读到了这个消息这层通信层要如何处理,以及读数据时发生了异常该怎么办。
我们先来看发生异常的情况,这时候有两种情况,如果goOn已经是false,说明是自己下线的,正常下线即可。如果goOn是true,说明发生了对端异常掉线,这里需要处理。那我们怎么处理呢?显然这不是我们这层通信层可以处理的,需要更高层来处理这个情况,因此我这里直接定义一个抽象方法,让继承基层通信层的类必须处理,这样就完成了下层对上层功能的逻辑实现。
接下来就是正常读到了消息message
。
这里还是一样的思路,下层是无法得知上层要干嘛的,所以用一个抽象方法传导上去,用未来的技术解救现在的问题。
所以基类的代码如下:
package com.tyz.csframework.communication;
import com.tyz.csframework.protocol.NetMessage;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.Socket;
import java.util.concurrent.LinkedBlockingDeque;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
/**
* @author tyz
*/
public abstract class Communication implements Runnable {
private Socket socket;
private DataInputStream dis;
private DataOutputStream dos;
private volatile boolean goOn;
private ThreadPoolExecutor threadPool;
/**
* 处理接收到的消息
*
* @param netMessage 规范的信息
*/
public abstract void dealNetMessage(NetMessage netMessage);
/**
* 处理对端异常掉线
*/
public abstract void dealOppositeEndAbnormalDrop();
protected Communication(Socket socket) {
this.socket = socket;
this.goOn = true;
try {
this.dis = new DataInputStream(this.socket.getInputStream());
this.dos = new DataOutputStream(this.socket.getOutputStream());
this.threadPool = new ThreadPoolExecutor(1, 10, 3000L,
TimeUnit.MILLISECONDS,
new LinkedBlockingDeque<>(10),
r -> new Thread(this.getClass().getSimpleName() + "-thread"));
this.threadPool.execute(this);
} catch (IOException e) {
e.printStackTrace();
}
}
@Override
public void run() {
while (this.goOn) {
try {
String message = this.dis.readUTF();
dealNetMessage(new NetMessage(message));
} catch (IOException e) {
//读数据异常说明对端掉线,如果goOn已经是false,
//说明是自己下线的,正常下线即可。如果goOn是true,
//说明发生了对端异常掉线,这里需要处理。
if (this.goOn) {
this.goOn = false;
dealOppositeEndAbnormalDrop();
}
}
}
}
/**
* 向对端发送框架规范的信息
*
* @param netMessage 需要传送的信息
* @throws IOException 发送数据异常
*/
protected void send(NetMessage netMessage) {
try {
this.dos.writeUTF(netMessage.toString());
} catch (IOException e) {
//发送数据失败说明是对端异常掉线
close();
dealOppositeEndAbnormalDrop();
}
}
/**
* 关闭通信信道和线程池
*/
protected void close() {
this.goOn = false;
if (this.socket != null && this.socket.isClosed()) {
try {
this.socket.close();
} catch (IOException ignored) {
} finally {
this.socket = null;
}
}
if (this.dis != null) {
try {
this.dis.close();
} catch (IOException ignored) {
} finally {
this.dis = null;
}
}
if (this.dos != null) {
try {
this.dos.close();
} catch (IOException ignored) {
} finally {
this.dos = null;
}
}
this.threadPool.shutdown();
}
}
这里的NetMessage
是什么东西呢?大家可以看我的注释,写的这是规范的信息。
我们要做一个C/S的框架,肯定是要完成通信的功能,通信就要传递数据,但是这个数据可以随便传吗?当然不行。这样服务器就完全控制不住客户端了,也不知道该怎么处理这些随便的信息。所以在我们的框架里,需要定义一个简单的协议,在我们的框架中能传递的必须是规范的信息,这样服务器和客户端才能对其进行识别和解码。
比如我们看TCP头,
这就是NetMessage
这个类的作用,在框架中我们传递的就只是这个格式的消息,定义一个类似于TCP这个头的东西,目的只是为了能让在CsFramework框架中运行的机器有一个可以交流的语言。
那我们的信息头中需要些什么信息呢?这里我先定义三个。
首先是action
,这个变量是为后面框架实现分发器做准备的,我先搁置不谈。
parameter
的作用就是要传递的数据,如果某个客户端想说句hello
,那么parameter
就是这个hello
,如果客户端要登录,那发给服务器的parameter
就是客户端的登录信息。
第三个有意思了,ETransferCommand
是我定义的一个类,并且它是个枚举。
这个类中会定义所有客户端和服务器的命令,根据这个命令服务器和客户端会做出相应的处理操作,这个到后面还会再说到。
好了,现在一个信息头就被我们定义好了,分为行为action
,数据parameter
和命令command
。
大家不知道还记不记得,我们的传输通道用的是DataInputStream
和DataOutputStream
。所以要要传输的信息就只能是String
类型的,我们就需要对NetMessage
制定一个编码解码的规则。怎么定义呢?就一切从简,我们将三个成员之间都加个':'
。
解码就更简单啦。
这就是通信协议的制定,我将暂时的代码贴出,后面有需要的地方再继续补充。
ETransferCommand
package com.tyz.csframework.protocol;
/**
* 服务器和客户端的传输命令
*
* @author tyz
*/
public enum ETransferCommand {
}
NetMessage
package com.tyz.csframework.protocol;
/**
* @author tyz
*/
public class NetMessage {
/** 分发器的行为 */
private String action;
/** 数据 */
private String parameter;
/** 执行的命令 */
private ETransferCommand command;
public NetMessage(String action, String parameter, ETransferCommand command) {
this.action = action;
this.parameter = parameter;
this.command = command;
}
/**
* 对接收到的字符串进行解码,转换成{@link NetMessage}
*
* @param message 接收到的字符串
*/
public NetMessage(String message) {
String[] words = message.split(":");
this.action = words[0];
this.parameter = words[1];
this.command = ETransferCommand.valueOf(words[2]);
}
/**
* 将协议的信息进行编码
*
* @return 编码好的 {@link NetMessage}
*/
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append(this.action == null ? "" : this.action).append(':')
.append(this.parameter == null ? "" : this.parameter).append(':')
.append(this.command == null ? "" : this.command.name());
return sb.toString();
}
}
在一开始我定义了一个基类Communication
,实现的是服务器和客户端通信的共有的功能。现在我们来思考一个问题,服务器和客户端之间的通信结构是怎样的?
在框架中,最高层一定是Server
和Client
,这两个类最终就是我们要暴露给使用框架的用户的两个类,如果这两个类中又有接收对端消息的,又有处理消息的,又有做分发的,那就太耦合了。通信层的事就让通信层的代码来做,最高层的服务器和客户端一定要尽可能简洁,留下要暴露出去的接口,具体的实现尽可能交给下层来做。
所以为了Server
和Client
这两个最终的大老板能舒舒服服,我们还需要再完善一下通信层的内容,建立两个会话类。
为了避免线太复杂,我只画出服务器和中间一个客户端之间的结构。其实非常简单,就是客户端和服务器并不直接通信,而是由它们建立的ServerConversation
和ClientConversation
之间进行通信。
当客户端连接到服务器的时候,发生了两件事,客户端会建立一个ClientConversation
,由它负责和服务器的通信工作,同时服务器为这个客户端建立一个ServerConversation
。ClientConversation
和ServerConversation
通过之前建立的Communication
进行交互。
注意这个关系,服务器对ServerConversation
是一对多,ServerConversation
对 ClientConversation
是一对一,客户端对ClientConversation
自然也是一对一。
清楚了之后,我们就可以写一下ClientConversation
和ServerConversation
了。
ServerConversation
package com.tyz.csframework.core;
import com.tyz.csframework.communication.Communication;
import com.tyz.csframework.protocol.NetMessage;
import java.net.Socket;
/**
* 服务器建立的与客户端通信的会话层,实现对消息的处理,
* 以及对客户端的响应。
*
* @author tyz
*/
public class ServerConversation extends Communication {
protected ServerConversation(Socket socket) {
super(socket);
}
@Override
public void dealNetMessage(NetMessage netMessage) {
}
@Override
public void dealOppositeEndAbnormalDrop() {
}
}
ClientConversation
package com.tyz.csframework.core;
import com.tyz.csframework.communication.Communication;
import com.tyz.csframework.protocol.NetMessage;
import java.net.Socket;
/**
* 客户端建立的与服务器通信的会话层,实现消息的发送与处理,以及
* 对服务器的请求
*
* @author tyz
*/
public class ClientConversation extends Communication {
protected ClientConversation(Socket socket) {
super(socket);
}
@Override
public void dealNetMessage(NetMessage netMessage) {
}
@Override
public void dealOppositeEndAbnormalDrop() {
}
}
这里我将Communication
和core
包分开,最终Server
和Client
是在core
包中的,避免了服务器端的代码可能对底层Communication
的干扰,大家仔细看会发现我的Communication
用的都是protected
的权限,这样包外的类就可以通过继承来使用Communication
的方法。
先将框架摆在这里,接下来我们慢慢实现。
框架的核心当然是由客户机和服务器建立起来的,在前面我说了,这两个类也是最终要暴露给使用框架的用户的,接下来我们就从这个两个类的功能出发,逐步完善这个框架。
一个客户端,要连接到服务器,需要两个东西,一个是服务器的端口号(port),一个是服务器的IP地址。所以在服务器初始化的时候,一定是需要一个端口号的。我们的框架在用户不设置端口号时会默认设置一个端口号。
再来,服务器启动以后,是不知道客户端会什么时候和它连接的,所以我还是直接在启动服务器时,再启动一个线程,专门用来侦听客户端的连接,而服务器的主线程做其他功能的操作。因此,这里还是设置一个成员变量goOn
,来控制这个线程,只有当goOn
为false
时,才会结束侦听。
在前面的分析中,我写了服务器和客户端连接的时候两件事会发生,
包括服务器会建立一个ServerConversation
,与客户端保持一对一的通信。那么,这个ServerConversation
是不是就代表了一个客户端?所以我们可以根据这个ServerConversation
建立一个客户端池,将和服务器连接的客户端都放进去,便于之后的管理。
那我们怎么识别每个客户端呢?所以在连接成功之后,服务器要为客户端创建一个ID,用来识别不同的客户端,并且这个ID要同步给客户端。
所以我们需要实现一个客户端池,并且这个池子要能限制客户端的数量,也可以更改池子的最大容量,并且这个更改的接口要经由服务器暴露给开发者,这样就实现了连接客户端的数量控制。
package com.tyz.csframework.core;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 收集服务器连接的 {@link ServerConversation},这就
* 代表了一个客户端。此类是对客户端的一个管理和记录,使用
* 一个客户端池子,以客户端的ID为键,客户端为值,生成一个
* 散列表。
*
* @author tyz
*/
public class ClientPool {
/** 默认可承载的客户端数量 */
public static final int DEFAULT_CAPACITY = 1 << 16;
private final Map<String, ServerConversation> clientPool = new ConcurrentHashMap<>();;
private int capacity;
ClientPool() {
this.capacity = DEFAULT_CAPACITY;
}
/**
* 将一个客户端的会话信息加入进客户端池
*
* @param id 客户端ID
* @param client 客户端
*/
boolean addClient(String id, ServerConversation client) {
if (this.clientPool.size() < this.capacity) {
this.clientPool.put(id, client);
return true;
}
return false;
}
/**
* 删除一个客户端
*
* @param id 需要清除的客户端id
*/
void removeClient(String id) {
this.clientPool.remove(id);
}
/**
* 设置客户端池子的容量
* @param capacity 容量
*/
void setClientPoolCapacity(int capacity) {
this.capacity = capacity;
}
/**
* @return 返回客户端池是否为空
*/
boolean isEmpty() {
return this.clientPool.isEmpty();
}
}
这个池子的键就是客户端的ID,值自然就是ServerConversation
,它代表一个客户端。
现在的问题是,客户端的ID应该怎么生成。为了防止一个客户端重复登录,我们可以在每个客户端第一次连接的时候记录下时间,精确到毫秒,除非他手速快到离谱,否则还是会被阻止的。
这样梳理了一下之后,首先我们需要在ServerConversation
中设置一个成员id
,并提供setter
方法,以便服务器在验证通过后设置它的id。并且需要在ServerConversation
覆盖一下Communication
中的close()
方法,以便服务器进行操作。
package com.tyz.csframework.core;
import com.tyz.csframework.communication.Communication;
import com.tyz.csframework.protocol.NetMessage;
import java.net.Socket;
/**
* 服务器建立的与客户端通信的会话层,实现对消息的处理,
* 以及对客户端的响应。
*
* @author tyz
*/
public class ServerConversation extends Communication {
private String id;
protected ServerConversation(Socket socket) {
super(socket);
}
@Override
public void dealNetMessage(NetMessage netMessage) {
}
@Override
public void dealOppositeEndAbnormalDrop() {
}
/**
* 调用 {@link Communication}的方法,关闭通信信道和socket
*/
@Override
protected void close() {
super.close();
}
/**
* 设置 {@code id}
* @param id 服务器生成的id
*/
void setId(String id) {
this.id = id;
}
}
现在服务器端的侦听线程代码如下:
/**
* 侦听客户端的连接,若客户端的数量超过了{@code clientPool}
* 的大小,则关闭与客户端建立的连接。如果客户端的数量还小于客户
* 池的容量,则会把客户端加到客户池中,并为客户端 {@code client}
* 设置服务器生成的{@code id}
*/
@Override
public void run() {
speakOut("Start to listening...");
while (this.goOn) {
try {
Socket socket = this.serverSocket.accept();
ServerConversation client = new ServerConversation(socket);
String id = socket.getLocalAddress().getHostAddress()
+ "-" + System.currentTimeMillis();
if (this.clientPool.addClient(id, client)) {
client.setId(id);
speakOut("Client [" + id + "] connected with server successfully.");
} else {
client.close();
}
} catch (IOException e) {
this.goOn = false;
}
}
}
这里要说一下speakOut()
方法,它实现了日志的工作。因为我们实现的是底层的框架,底层是不知道上层要做什么的,但是我们必须有一个日志功能,来提示用户现在框架的状态是什么,以便用户进行调整。如果你直接把这些内容System.out.println()
,可能用户开发的时候都没有控制台,这些信息是输不出来的,或者用户并不想从控制台得知这些日志,我们做框架就必须要满足他潜在的需求。
在我的工具包中,有两个接口,ISubscriber 和 IPublisher,大家可能看名字就猜到了,这就是实现了一个订阅者-发布者模式,或者说观察者模式。
package com.tyz.util;
/**
* 发布者
*
* @author tyz
*/
public interface IPublisher {
/**
* 处理订阅者的消息
* @param message 订阅者的消息
*/
void dealMessage(String message);
}
package com.tyz.util;
/**
* 订阅者
*
* @author tyz
*/
public interface ISubscriber {
/**
* 增加一个发布者
* @param publisher 发布者
*/
void addPublisher(IPublisher publisher);
/**
* 删除一个发布者
* @param publisher 发布者
*/
void removePublisher(IPublisher publisher);
/**
* 订阅发布者要处理的消息
* @param message 发布者要处理的消息
*/
void speakOut(String message);
}
在Server中,我们通过实现一个ISubscriber,将Server变成了一个订阅者,这样要选择处理这些日志的开发者,可以直接实现一个IPublisher,就可以根据自己的需求选择处理消息的方式,而Server的日志就会按照用户自己需要的方式记录下来。
对观察者模式这里不再赘述,大家若有兴趣,可以参考极客时间上王争的《设计模式之美》。
观察者模式
那么服务器Server
的代码就先实现到这里。
package com.tyz.csframework.core;
import com.tyz.util.IPublisher;
import com.tyz.util.ISubscriber;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.LinkedBlockingDeque;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
/**
* 服务器的实现
*
* @author tyz
*/
public class Server implements Runnable, ISubscriber {
/** 默认端口号 */
public static final int DEFAULT_PORT = 18322;
private int port;
private volatile boolean goOn;
private ServerSocket serverSocket;
private Set<IPublisher> publisherSet;
private ThreadPoolExecutor threadPool;
private ClientPool clientPool;
public Server() {
this(DEFAULT_PORT);
}
public Server(int port) {
this.port = port;
this.publisherSet = new HashSet<>();
this.clientPool = new ClientPool();
}
/**
* 启动服务器
*/
public void startUp() throws IOException {
if (isRunning()) {
speakOut("Server had started yet, can't start again.");
return;
}
this.serverSocket = new ServerSocket(this.port);
this.goOn = true;
speakOut("Server starts successfully.");
//初始化线程池,将侦听客户端连接的线程启动
this.threadPool = new ThreadPoolExecutor(1, 10, 3000L,
TimeUnit.MILLISECONDS,
new LinkedBlockingDeque<>(10),
r -> new Thread(this.getClass().getSimpleName() + "-thread"));
this.threadPool.execute(this);
}
/**
* 侦听客户端的连接,若客户端的数量超过了{@code clientPool}
* 的大小,则关闭与客户端建立的连接。如果客户端的数量还小于客户
* 池的容量,则会把客户端加到客户池中,并为客户端 {@code client}
* 设置服务器生成的{@code id}
*/
@Override
public void run() {
speakOut("Start to listening...");
while (this.goOn) {
try {
Socket socket = this.serverSocket.accept();
ServerConversation client = new ServerConversation(socket);
String id = socket.getLocalAddress().getHostAddress()
+ "-" + System.currentTimeMillis();
if (this.clientPool.addClient(id, client)) {
client.setId(id);
speakOut("Client [" + id + "] connected with server successfully.");
} else {
client.close();
}
} catch (IOException e) {
this.goOn = false;
}
}
}
/**
* @return 服务器是否在运行中
*/
public boolean isRunning() {
return this.goOn;
}
/**
* 关闭服务器
*/
public void shutDown() {
if (!isRunning()) {
speakOut("Server had closed yet, can't close again.");
return;
}
if (!this.clientPool.isEmpty()) {
speakOut("Still are some clients on, can't shut down.");
return;
}
this.goOn = false;
close();
speakOut("Server has shut down successfully.");
}
/**
* 设置可连接的客户端最大数量,如果不设置,默认为 (1 << 16)
*
* @param size 服务器最多可承载客户端的数量
*/
public void setClientMaxCount(int size) {
this.clientPool.setClientPoolCapacity(size);
}
/**
* 关闭socket和线程池,这里对异常无需做处理,直接使得指向它
* 的指针为null就可以了。
*/
private void close() {
if (this.serverSocket != null && this.serverSocket.isClosed()) {
try {
this.serverSocket.close();
} catch (IOException ignored) {
} finally {
this.serverSocket = null;
}
}
this.threadPool.shutdown();
}
@Override
public void addPublisher(IPublisher iPublisher) {
this.publisherSet.add(iPublisher);
}
@Override
public void removePublisher(IPublisher iPublisher) {
this.publisherSet.remove(iPublisher);
}
@Override
public void speakOut(String s) {
for (IPublisher publisher : publisherSet) {
publisher.dealMessage(s);
}
}
}
客户端最首先的操作就是连接服务器,其实没什么好说的,就是正常操作,这里我们对异常直接catch,如果发生了仅仅是连接失败,没必要终止程序,我们可以直接return false
。
package com.tyz.csframework.core;
import java.io.IOException;
import java.net.Socket;
/**
* 客户端的实现
*
* @author tyz
*/
public class Client {
private int port;
private String ip;
private Socket socket;
private ClientConversation clientConversation;
public Client(int port, String ip) {
this.port = port;
this.ip = ip;
}
/**
* 连接服务器,若出现异常或失败,返回false,连接成功返回true
*
* @return 是否成功连接服务器
*/
public boolean connectToServer() {
try {
this.socket = new Socket(this.ip, this.port);
this.clientConversation = new ClientConversation(socket);
return true;
} catch (IOException e) {
return false;
}
}
}
Client可以就先实现到这里,我们现在来完善框架的功能。
还是先从ServerConversation
和 ClientConversation
中的抽象方法开始看起。
由于ServerConversation
和 ClientConversation
都是继承了通信层的基类Communication
,而Communication
中有两个操作是这层的逻辑无法实现需要传递到下一层的。
好,我们先来看处理对端异常掉线的操作,对服务器来说就是一个客户端异常掉线了,对于客户端来说,就是服务器异常掉线了。那会话层可以处理这种事情吗?显然不行,所以我们需要将它再往高层传。再想想,处理这种异常掉线的工作到底是谁应该做的?我认为应该是用我们的框架做开发的人员才能处理的,比如腾讯的大哥要用我的框架开发QQ,我能知道在一个QQ掉线之后应该做什么吗?显然不行呀。所以只能腾讯的大哥来实现。
那我该怎么让腾讯的大哥实现我框架的这个功能呢?答案就是接口。
我们需要定义一个接口,IServerAction
,里面需要写一个方法,就是处理客户端异常掉线。
我们知道,接口如果被写在要调用的类中,是必须被实现的。那如果腾讯大哥说,就是个客户端掉线而已,P大点事还用我来实现?
好,为了让大哥少点麻烦,我们再多给他一个灵活的选择。大家如果用过Swing技术的话,可能会经常使用一个东西叫适配器。
简单来说,就是我们自己对这个接口做一个实现,不过是个空实现。在框架中要调用这个方法的地方,直接调用适配器的空方法,就默认不对它做处理,大哥如果想做,就来覆盖我适配器的方法实现就好了。
package com.tyz.csframework.useraction;
import com.tyz.csframework.core.ServerConversation;
/**
* 本类是 {@link IServerAction} 接口的适配器,用户可选择性覆盖本类
* 中的方法,配置所需的功能。
*
* @author tyz
*/
public class ServerActionAdapter implements IServerAction {
@Override
public void dealClientAbnormalDisconnected(ServerConversation client) {
}
}
好,现在这个方法具体的实现是完成了,那么应该传递给哪层呢?
上面说了嘛,这个是给大哥覆盖的,大哥能用的是哪层?当然就是最终的Server
层嘛,这个类和Client
是最终暴露给用户的。
所以,我们就需要在Server
中增加一个IServerAction
的成员。然后在Server
中实现这个功能,ServerConversation
中可以直接调用Server
中的这个方法。
其实这里也可以选择让Server
提供一个IServerAction
成员的方法,然后ServerConversation
通过get
方法,得到Server
的适配器,然后调用方法。不过我是不喜欢这样的实现的,太多的Getter
和Setter
会非常破坏OOP的封装特性,所以能不用Getter
和Setter
就不要用,这是我的建议。
注意哦,这里是包权限的,因为这个方法是ServerConversation
用的,不需要暴露给用户。并且这里我们仍然要记录一下日志。
好,那Server
中已经实现完了,我们应该怎么在ServerConversation
中调用呢?
这里我们直接通过ServerConversation
的构造方法,将Server
传递进去。也就是说,我们需要在ServerConversation
添加一个成员变量Server
,
那Server
中的初始化就需要改一下了。
实现了ServerConversation
中的抽象方法,客户端其实是一模一样的。我们同样需要先建一个IClientAction
的接口,然后还有它的适配器,最后在Client
中实现它,然后将Client
传递给ClientConversation
,再由ClientConversation
进行调用。
好了,现在实现了Communication
中的一个抽象方法,还剩一个。
这个方法的实现,其实就是我们这个框架大部分功能的实现了。这里一定不要忘记,客户端和服务器之间传递的是NetMessage
编码后得到的字符串,这是我们框架的协议。
我们先从第一个最简单的功能开始吧。
回顾我们前面做的事情,我们实现了客户端和服务器的连接,然后Server
生成了一个ID,ServerConversation
再接着对自己的ID进行设置,然后呢?就没有啦。
回顾我们的结构图,这个ID只在了红框中进行了传播。
那如果客户端要给其他客户端发送消息的话,它还是没法发送的,因为它都不知道其他客户端的ID。
所以在服务器建立起一个ServerConversation
并设置了ID以后,还需要将这个ID传递给它连接的客户端,让对端的客户端知道自己的ID。
这时候我们前面的枚举ETransferCommand
就派上用场啦。
既然是要向对端发送信息,那肯定就是会话层的事。是服务器要发,所以我们找ServerConversation
。
还记得吗?NetMessage的格式,中间的parameter
就是我们要发送的真正的数据,所以可以直接将id
当作parameter
直接发送给对端。并且由于我们直接覆盖了NetMessage
的toString()
方法,所以这里传送的时候是自动将它编码的。
那接下来就是要在Server
中调用了,我们需要在客户端建立连接成功的时候发送给它它的ID
。
好了,现在服务器把消息发给对端的客户端了,我们再来看一眼Communication
中的方法。
这个dealNetMessage()
是不是就是从通信信道中读到的信息然后要进行处理的?
好,那我们就直接去ClientConversation
的dealNetMessage()
方法,来解析服务器发送的这条消息。
由于我们收到的是一个NetMessage
,这里再提醒一下,解码也是自动完成的。
Communication
在接收到字符串以后,会调用NetMessage
的单参构造。
而它的构造方法,就是一个解码的过程。
所以在ClientConversation
的dealNetMessage()
中,我们要获得这三个参数,可以直接用NetMessage
的Getter
方法,前面我们还没写,现在可以写了。
由于暂时还用不到action
,我们可以先不获取它。
现在我们来根据传统的方法实现对不同命令的处理,补充一下,这里也可以使用设计模式里的策略模式。
注意这里我补上了ClientConversation
的成员变量id
。
现在我们再来考虑一下腾讯大哥的需求,比如我一般在PC上登QQ,如果我不设置的话,总会给我弹出来点什么,要么是天气,要么是广告,有时候还是“夜深了,早点休息吧”。所以为了让用我的框架的人也可以弹点什么,我们再设置一个接口,就是用户登陆成功之后服务器做的事情。
于是,还是按照原来的思路,在IClientAction
接口里添加一个afterConnectedSuccessfully()
方法,并在ClientActionAdapter
中实现一个空方法,然后在Client
中通过调用IClientAction
成员的这个方法,来实现它,最后再由ClientConversation
调用,和上面处理异常掉线的思路是一模一样的。
这就是我们这个框架的套路,在后面的功能中,实现方法基本都是如此,这样一轮调用。
再次强调,使用IClientAction
或者IServerAction
接口都是为了能让使用我们的框架开发者,可以根据自己的需求灵活实现某些功能,调用接口,其实就是在调用未来才会被写出来的方法。 而ClientActionAdapter
是为了让用户有选择地实现我们需要用户自己去实现的功能,用户不想实现的地方,就默认会实现一个空方法。
好,现在我们已经实现了客户端上线的功能了。
再次回顾这个过程:Server
和客户端连接,生成一个ID -> ServerConversation
将id
设置为服务器生成的ID -> ServerConversation
将ID传送给对端的客户端 -> 对端的ClientConversation
接收了这个ID,将自己的id
设置为ID -> 调用Client
中的连接成功之后的操作。
和上线成功逻辑完全一样,这里不再赘述,这里走一遍过程。
首先是在ETranseferCommand
中写下一个命令。
接着在ServerConversation
,向对端的客户端发送连接失败的消息:
接着Server
调用ServerConversation
中的方法。
在IClientAction
中增加连接失败后的接口。
在适配器ClientActionAdapter
中实现空方法
接着在Client
中经过成员变量clientAction
实现这个方法
再次强调,这个成员是由适配器初始化的,所以才可以实现灵活配置。
我们先来明确一下C/S模式下一个客户端给其他客户端是如何发消息的,比如你现在给女朋友发了句“晚安”,这个“晚安”是直接从你的手机发送到你女朋友的手机吗?
并不是的,因为QQ也好微信也好也是C/S模式的,这个模式下一定有个第三者在你和你女朋友中间,就是服务器。你的消息会发送到服务器那里,然后同时发送的还有一条指令,就是你要把这个消息发送到你女朋友的手机上。
这时候服务器就会找到你女朋友的IP地址,再把“晚安”转发给她,所以没想到吧,你不是最晚给你女朋友发晚安的男人。
我们的框架也是基于此,客户端想给别的客户端发消息,服务器需要得到这条消息,要知道是谁发送的,还要知道是发送给谁,这样才能最终在你女朋友的手机里显示出是她的大猪蹄子发了句晚安,是她收到的而不是隔壁李阿姨,她看到是你发的而不是楼上每天刻苦锻炼身体的小王,她收到的是晚安而不是你滚吧,全都要感谢服务器的消息转发功能。
知道了原理,现在我们来想一想要怎么实现。
在前面我定义的消息格式,action
和command
是固定的信息头,用来解析消息的,只有parameter
是放数据的,上面我说了,发送一条消息需要知道三个参数,消息来源(source),目标(target),消息(message)。
现在要把这三个参数都放在一个parameter
中,怎么做?首先想到的还是和NetMessage
一样,我们自己编码一下就好了呀,服务器转发的时候可以再解码,是一个思路。不过这里我准备用一个工具,谷歌的Gson
。我们直接把 消息来源(source),目标(target),消息(message)封装成一个类,然后通过Gson
将其转换成json
对象,消息接收者直接通过Gson
再把这个json
对象还原,省去了我们自己编码,还更加高效一点。
于是定义一个类如下:
package com.tyz.csframework.protocol;
/**
* 封装一条消息,根据此类可以得到发送消息的源ip,
* 要送达的目标ip以及消息本身
*
* @author tyz
*/
public class MessagePackage {
/** 消息源ip */
private String source;
/** 目标ip */
private String target;
/** 消息 */
private String message;
public MessagePackage(String source, String target, String message) {
this.source = source;
this.target = target;
this.message = message;
}
public String getSource() {
return source;
}
public String getTarget() {
return target;
}
public String getMessage() {
return message;
}
}
我们先来实现客户端一对一的消息发送。
不同于上一个模块的实现顺序,之前写得命令都是服务器发出的命令,而一对一通信是由客户端发起,所以我们从客户端出发,这里可能会有一点点绕。
首先是Client
,因为这个里面是要暴露接口给使用框架的用户的,所以肯定要一对一发消息的这个命令要从Client
出发。
那客户端要自己发消息吗?当然不行,我们已经建立了会话层ClientConversation
,就是要完成和对端的服务器进行通信的。所以这个实际的消息发送应该让ClientConversation
完成。
好,那我们先在ClientConversation
中写一个向对端服务器发送一对一消息的命令,还是在ETransferCommand
中要写一个TALK_TO_ONE
的命令,代表这是一条一对一消息。
注意哦,这个发送消息的source
是自己,前面我们图中画了,每个ClientConversation
是和每个Client
一对一的,所以ClientConversation
的id
就代表这个客户端的id
,和ServerConversation
一样,这里不能搞混了。
然后第二个红框,我圈起来了一个奇怪的类,ArgumentMaker
,这个类也是在我的工具包里,它在分发器的实现中会大有作用,我们先按下不表。这个类中有一个GSON
常量,也是可以帮助我省去每次都要写一遍Gson
的初始化的,直接调用就可以了。
好现在ClientConversation
写好了,Client
就可以调用它了。
现在消息发送出去了,最后谁会接收到呢?当然就是ServerConversation
了。一定不要忘了这个关系。
那现在我们就可以去ServerConversation
完成它的dealNetMessage()
方法了。
和ClientConversation
一样,还是要先对传来的消息进行解析,根据命令选择执行什么操作。这里大家想一想,ServerConversation
有将消息转发的能力吗?显然是没有的,因为它也只知道自己的ID而已,消息的转发一定建立在知道所有客户端消息的基础上,再回顾上面的结构图,是不是只有Server
连接着多个ServerConversation
,并且我们已经在Server
中设置了一个成员变量ClientPool
,存放着所有连接进来的客户端。
所以真正的消息转发工作是Server
来做的。我这里就直接在ServerConversation
中调用了Server
的talkToOne()
方法。现在我们来看Server
的talkToOne()
怎么实现。
之前我们在ClientPool
中没有get()
方法,先把它加上。
一个客户端要给另一个客户端发消息,存在两种情况,一个是这个客户端在线,发送成功,一个是客户端不在线,发送失败。
所以我们直接从客户端池clientPool
中根据target
取得要发送的客户端的ServerConversation
,如果不是null
,说明这个客户端在线,那就把消息转发给它,如果不在线,那就给发送这条消息的客户端说一下,你发送消息失败了,因为对方不在线。这就是targetIsNotExist()
这个方法的目的。
再次做一个亲妈式讲解,我们的消息要从服务器发给客户端,需要从Server
发给客户端对应的ServerConversation
,由这个ServerConversation
发给对端的ClientConversation
,然后再由ClientConversation
调用Client
进行相应的处理。
好,那么转发是怎么转发呢?我们就需要找到要转发的那个客户端对应的ServerConversation
了。所以完整的客户端给另一个客户端发送信息的流程是下面这样的,比如说客户端A要发送信息给客户端B:
那如果客户端B不存在呢?
服务器就会传送客户端B不存在的这个消息,再次传回A。
现在流程清楚了,我们再来看一下服务器的转发操作。
无论是把没找到的消息传回去,还是把消息转发给找到的客户端,我们首先要明确这是会话层要做的事情,发送信息不是服务器要实现的。所以我们需要在ServerConversation
中实现这两个操作。
首先是找到目标了,要把消息发给它。我们还是在ServerConversation
中实现一个talkToOne()
方法。
MessagePackage
封装的还是客户端发送来的消息,服务器不做任何改变,只负责转发。
那同样的,我们可以在ETransferCommand
中再增加一个命令:
然后ServerConversation
中可以实现这个方法。
这个方法不需要再把完整的信息传回去了,因为source
就是它自己,而消息message
已经没什么意义,我们只需要告诉它targer
没有找到即可。
最终这两个方法在服务器转发的逻辑中被调用。
按照前面的套路,服务器发送了消息,客户端自然就要接收消息。所以我们去ClientConversation
中处理这两个命令。
这两个方法还是和之前的一样,先在IClientAction
中增加这个接口,然后ClientActionAdapter
实现空方法,Client
再实现一个调用这个方法的方法,最后再这里被ClientConversation
调用,这个过程我已经详细过了很多遍了,这里不再赘述。
好了,那么一对一的消息发送我们就处理完了。
一对多消息传送我实现的是群发的功能,就相当于游戏大厅里的广播。实现的过程和一对一是一模一样的,差别只有在选择target
时可以直接置为null
。
在服务器进行消息转发的时候,需要通过clientPool
得到一个所有在线客户端的列表,这样服务器才可以对所有客户端进行消息的转发。所以我们先在ClientPool
中增添一个这样的方法,得到所有客户端。
具体的过程我不再细述,和一对一消息是完全一样的。大家可以对比我的截图做个参考。
先Client
然后ClientConversation
接着到ServerConversation
的dealNetMessage()
经由ServerConversation
调用Server
这里注意不要把客户源发送的消息再给它转发回去,群发是把消息发送给除了它自身以外的所有消息。
接着Server
调用ServerConversation
然后ClientConversation
接收这个消息
dealPublicMessage()
实现的过程是这样的:
先IClientAction
最终Client
被ClientConversation
调用。
这样消息转发我们就实现好了。
现在再来看一下客户端下线操作。我的思路还是尽可能多为要基于我们的工具做开发的朋友考虑一点,客户端执行下线命令之前,我们先用一个方法来确认它是否真的要下线,并在下线前和下线后都添加几个接口,这样用户可以做的选择就会很多。这样,三个接口方法呼之欲出。
还是一样,适配器需要对应实现空方法,然后在客户端发送下线命令的前后执行这些方法。
同样需要通过ClientConversation
来实现向服务器发送要下线的消息。
参数是客户端的id
,因为我们需要告诉服务器是哪个客户端下线了。
close()
方法直接调用Communication
的就好。
现在客户端发送了消息,我们该到ServerConversation
处理dealNetMessage()
了。
在ServerConversation
中,调用Server
的clientOffline()
方法,因为我们需要把这个操作抛到更高层来处理。
所以还是和Client
一样,我们现在IServerAction
中,增加一个接口。
然后通过它的适配器ServerActionAdapter
实现一个空方法,在Server
中完成对这个方法的调用,最终Server
在被ServerConversation
调用。
注意在Server
中,我们需要先将这个客户端从池子中删除,然后记录一下日志,最后再调用用户的下线操作。
有时候,服务器可能需要紧急维护,要进行强制宕机,这样就需要通知连接到它的所有客户端,将它们强制下线,这样服务器才可以安全宕机。
现在我们就来做一下服务器的强制宕机。
这个显然是一个服务器的命令,我们就从服务器开始吧。
首先先在Server
设置一个公共的方法forceDown
,意为强制宕机。这个方法会先判断客户端池子是不是空的,如果不是,就把所有在线的客户端都取出来,然后传送强制宕机的消息,使得它们断开连接然后做客户端的处理。
显然这里还是要调用ServerConversation
中的方法进行通信,所以我们要在ServerConversation
中写一个serverForceDown
的方法。
非常简单,参数都不需要,只需要传送服务器的这一个命令就好了。
ClientConversation
需要将这个操作抛给更高层,但是显然Client
也不好处理,所以我们还是需要用一个接口方法,在IClientAction
中增添一个dealServerExecuteForceDown
的方法,然后完成调用,和前面一样。
ClientActionAdapter
Client
ClientConversation
如果你发了什么骚话或者小黄图,现在被服务器发现了,服务器要怎么把你踢出去呢?
其实和服务器强制宕机的逻辑一样,都很简单,只不过服务器强制宕机可以不需要理由,但是把你踢出去还是要找个理由的,所以在服务器强制宕机的基础上,我们再补充一个reason
,服务器强制踢你的理由。
还是从Server
开始。
我们还是先从clientPool
中找,要被强制下线的客户端是否存在,如果不存在在记录日志客户端不存在,若存在则通过ServerConversation
传送将其清除的命令。
所以需要在ServerConversation
中完成一个传送这个命令的方法。
接着在ClientConversation
中接收,然后在IClientAction
中增添相应的方法,这一列操作我就不再赘述了。
现在我们再来完成一个简单的小功能,就是获取当前在线的所有客户端列表。
这里终于不需要再走前面的一大圈了,直接在Server
中实现就好。
好了,现在我们框架的基本功能就完成了。到这里就结束了吗?那还早呐。使得我们的CsFramework从普通到有点意思的一步,就是下面要实现的分发器,有了这个分发器,我们可以完成对客户端Request的处理以及服务器的Response。
先来解释一下分发器是什么以及为什么我们需要分发器。
服务器与客户端之间有两个重要的操作,一个是客户端的请求(Request),一个是服务器的响应(Response)。
比如如果有人用我们的框架要开发一个聊天室,他是不是要完成起码的注册和登录?我们的框架怎么去完成他要做的登录和注册等功能呢?或者有人要开发一套学生管理系统,要完成不同的人有不同的权限,可以查询不同的内容。
以上等等等等,登录也好,查询信息也好,都是不同的app的客户端需要有的功能,这些app的底层逻辑就是 客户端发出了一个请求(比如请求登录),服务器给出响应(确认账号密码无误后准许登录)。
所以我们的框架要完成的是什么?就是要把这个底层的关系抽出来,我不用管你客户端具体要做什么,要怎么实现,无论你要做什么功能,都是在向服务器发送请求,服务器无论做什么操作,是账号的确权也好,将用户信息写到数据库里完成注册也好,都是服务器对客户端的响应。
那我们要如何实现对未来用户才会写下的代码进行使用呢?你可能会说接口呀,我们的框架中确实使用了不少接口,不过这些接口是要实现确定的功能的。比如我们这里需要客户端处理一下服务器异常掉线的情况,那就直接在这里写一个接口方法dealServerAbnormalDisconnected()
,这里的方法职责是非常明确的。但是大家想想用户的请求和响应一样吗?
有的app可能要把用户数据放在内存里,有的要存到数据库中,有的要放到云中,即使是同一个登陆操作,有的app登录只需要手机号,有的需要邮箱,有的账号、ID、密码都要输入,我们怎么写这个接口?要写几个参数够?更何况,我们不会知道用户要用我们的框架去开发什么,要做什么功能。
所以,这里需要用Java中一个非常凶猛的东西,叫反射机制。
我们的框架不会去写方法,直接用反射调用用户自己写的方法就好了。我不管你的登陆方法怎么写,这不归我管,我直接用就是了。
服务器和客户端对于我们的框架而已没多大区别,面对app的服务器端我们也是毫无办法的,一样的道理,是把用户的注册信息写到哪里,要根据什么来判断用户的账号是否合法,这些也都是未来的程序员要开发的,我们的框架只调用他未来写的方法,不做实现。
好,现在调用的问题解决了,那我们的框架怎么知道要调用什么呢?
比如客户端吨吨吨写了三个功能,注册,登录,注销。这三个就属于客户端的请求吧?那现在服务器要怎么响应呢,服务器是不是首先也要有这三个方法的处理策略?不然用户请求个登录,服务器端没有写登录的内容,那还登什么呀,就没法响应了。
在我们的描述中,服务器和客户端这三个方法都有自己的名字,叫登录叫注册,那肯定客户端的登录对应着服务器的登录,什么功能配什么功能嘛。
这么想想是很简单,问题就是计算机里一切都是二进制,客户端的一个请求发过来了,服务器怎么知道客户端发送的是什么请求,自己又应该用哪个方法来响应它。
如果你看到这里,相信你已经理解了我们现在面对的困局。解决这个困局的一个方法就是,分发器。
分发器的实质就是解决此类的问题的,客户端的请求如何和服务器的响应所匹配,从而服务器可以根据客户端的请求来做出正确的响应。
上面我们说了,分发器本质就是要解决请求和响应之间的映射关系。如何根据一个找到另一个。解决了怎么找到,我们再来看怎么做真正的Request
和Response
。
要解决映射问题,我提供两个方案。和Spring一样,我的框架也使用xml
文件配置和通过注解进行配置两种方法。
在最开始的时候我定义框架中传递的信息体,用了一个成员变量action
,不过在前面的所有功能中传递它的时候都是null
,现在终于到它派上用场的时候了。
比如客户端请求登录,服务器响应登录,登录就是这一组的action
,假设这组登录的action
就叫logIn
。那么,在客户端中,我们必须能根据logIn
找到客户端请求登录的方法;同样在服务器端中,我们必须能根据logIn
找到服务器响应登录的方法。我们的框架只需要传输这个action
就足够了。
所以整体的思路就是,我们需要建立一个 Method
类型,只是一个指代,method一定是一个可以被直接执行的方法类型,因为我们根据action
找到对应的方法只是第一步,下一步就是要执行这个方法,这就是服务器和客户端所做的响应/请求。
现在想一想,一个方法可以被反射执行需要几个参数?一共有三个:Object (对象),Method(方法),以及 Object[] (参数)。
所以我们可以根据此写一个类,就叫ActionBeanDefinition
,意为这是一个关于action
的bean的定义 。最后将所有的bean都放进BeanFactory
中,用的时候根据action
就可以把对应的bean取出,然后执行它的方法。
所以ActionBean
的定义可以先写出来。
package com.tyz.distributor.actionbean;
import com.tyz.util.ArgumentMaker;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
/**
* 构造一个关于action的Bean,这是要要执行用户定义的action
* 所映射的方法,需要的参数描述。
*
* @author tyz
*/
public class ActionBeanDefinition {
/** 方法被反射执行所需要的对象 */
private Object object;
/** 方法被反射执行所需要的方法 */
private Method method;
/** 方法被反射执行所需要的参数列表 */
private List<ParameterDefinition> parameterList;
ActionBeanDefinition(Object object) {
this(object, null);
}
ActionBeanDefinition(Object object, Method method) {
this.object = object;
this.method = method;
this.parameterList = new ArrayList<>();
}
/**
* 在参数列表 {@code parameterList} 中加入一个新的参数
*
* @param pd 注入好的一个参数对象 {@link ParameterDefinition}
*/
void addParameter(ParameterDefinition pd) {
this.parameterList.add(pd);
}
/**
* @return 返回bean中的对象
*/
Object getObject() {
return object;
}
/**
* @return 返回bean中的方法
*/
Method getMethod() {
return method;
}
/**
* 设置bean中的方法
*
* @param method 要设置的方法
*/
void setMethod(Method method) {
this.method = method;
}
}
由于每个方法执行的参数个数都不一定,所以我们可以用一个List来存放方法执行所需要的参数。并且提供一个接口,使得可以在bean中添加参数。
注意这里,List的泛型我使用的是ParameterDefinition
,这是我定义的一个类。因为我们在反射机制中,不同方法的识别是根据参数的类型及个数来的,所以我们要记录下参数的类型。
这里为什么要有参数名呢?因为在反射机制的调用过程中,参数的名称是被抹去的。
我举个例子来描述一下现在的困境。
比如用户的客户端定义了一个登录方法 :
void logIn(String id, String password) {
...}
但是写服务器端程序的哥们和他不是一个人,这个服务器端的哥们就很有个性,写了响应的登陆方法是:
void logIn(String password, String id) {
...}
只是换了顺序而已。但是这样下来的结果就会出问题,因为反射机制中不知道名字,看的就是你的参数顺序。而我们执行要精确,就一定要记录下参数的名字是什么。至于如何解决对反射机制的抵抗,记住每个参数的名字,并且根据参数名要得到参数的值,这个我们后面会说。大家到这里知道ParameterDefinition
必须要有个name
成员就好。
所以ParameterDefinition
的定义是这样的。
package com.tyz.distributor.actionbean;
import java.lang.reflect.Type;
/**
* 描述一个方法执行所需要的参数
*
* @author tyz
*/
public class ParameterDefinition {
/** 参数的名称 */
private String name;
/** 参数的类型 */
private Type type;
ParameterDefinition(String name, Type type) {
this.name = name;
this.type = type;
}
}
好了,现在Bean的构造我们就做好了,现在的问题是,我们怎么得到一个BeanFactory
,让用户可以根据action
得到一个bean
,从而可以执行对应的方法。
注意哦,这里最终要生成的是两个BeanFactory
,客户端的开发者会有一个,放置客户端所有请求的方法,同时服务器端有一个BeanFactory
,在服务器接收到客户端发送的请求之后,根据客户端发的action
,在自己的BeanFactory
中找到同样的action
,把它对应的方法取出来。
要存映射,当然用散列表比较好,所以我们的Factory中使用一个HashMap
来存bean。
现在我们就来根据上面说的两种方案,xml配置文件和注解,来完成BeanFactory
的生成工作。
先来说说非常麻烦的xml文件配置的方案要怎么做。
xml文件的方案就是让用户自行写一个xml文件,里面按我们的框架规定好的格式,补全信息。最后我们可以扫描这个xml文件,客户端写一个客户端的映射关系,服务器端写一个服务器端的映射。
这里我定义一下我们框架中xml文件的格式:
<xml>
<actions>
<action name="logIn" class="com.tyz.server.action.UserAction"
method="userLogIn">
<parameter name="id" type="String"/>
<parameter name="password" type="String"/>
action>
<action name="registry" class="com.tyz.server.action.UserAction"
method="userRegistry">
<parameter name="id" type="String"/>
<parameter name="password" type="String"/>
<parameter name="nickName" type="String"/>
action>
actions>
xml>
用action
标签表示一个方法,必须要写入name
,也就是前面所说的action
,我们需要根据name
来映射服务器和客户端的方法。class
代表这个方法属于哪个类,最后Class.forName()
会用它来生成一个Class。
然后就是method
方法名,还有parameter
配置方法的参数名和参数类型。还是我们前面说的,需要根据类型来找方法,因为方法有可能是重载过的,需要根据参数名找值,因为服务器和客户端的方法参数顺序可能不同。
好了,经过这么麻烦的配置之后,我们来看看如何把这个映射关系转换到BeanFactory
的散列表中。
在一开头的准备工作里,我写了一个我的工具包中的类,就是xml文件解析器,使得我不用再去写制式的xml文件解析的代码,现在我们直接用这个工具来完成xml文件的扫描。
package com.tyz.distributor.actionbean;
import com.tyz.distributor.annotation.Action;
import com.tyz.distributor.annotation.ActionMapping;
import com.tyz.distributor.annotation.ActionParameter;
import com.tyz.distributor.annotation.LostAnnotationException;
import com.tyz.util.PackageScanner;
import com.tyz.util.TypeParser;
import com.tyz.util.XmlParse;
import org.w3c.dom.Element;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.lang.reflect.Type;
import java.util.HashMap;
import java.util.Map;
/**
* 扫描指定的包里的类文件,找到注解有
* {@link com.tyz.distributor.annotation.Action}的类,
* 在这个类中找到注解有
* {@link com.tyz.distributor.annotation.ActionMapping}
* 的方法,将这个方法注册到 {@link ActionBeanFactory}
* ,以方法的 {@code ActionMapping} 注解的值action
* 为键,和方法的封装类 {@link ActionBeanDefinition}
* 映射。
*
* @author tyz
*/
public class ActionBeanFactory {
private static final Map<String, ActionBeanDefinition> ACTION_BEAN_FACTORY;
static {
ACTION_BEAN_FACTORY = new HashMap<>();
}
public ActionBeanFactory() {
}
/**
* 扫描用户提供的xml文件,找到所有将action与可执行方法的映射并将其加到
* {@code ACTION_BEAN_FACTORY} 中
*
* @param xmlFilePath xml配置文件的相对路径
*/
public static void scanActionFromXmlFile(String xmlFilePath) {
new XmlParse() {
@Override
public boolean dealElement(Element element, int i) {
String actionName = element.getAttribute("name");
if (ACTION_BEAN_FACTORY.containsKey(actionName)) {
return false;
}
//获取方法名和类名
String methodName = element.getAttribute("method");
String className = element.getAttribute("class");
try {
//得到方法名对应的类并生成对象
Class<?> clazz = Class.forName(className);
Object object = clazz.newInstance();
ActionBeanDefinition bean = new ActionBeanDefinition(object);
//获取参数,并将参数注入进bean
new XmlParse() {
@Override
public boolean dealElement(Element element, int i) {
return addParameters(element, bean);
}
}.getElement(element, "parameter");
//根据注入好的参数和方法名,将方法注入进bean中
injectMethodIntoBean(methodName, bean);
//将注入好的bean注册进factory中
ACTION_BEAN_FACTORY.put(actionName, bean);
} catch (ClassNotFoundException | IllegalAccessException | InstantiationException | NoSuchMethodException e) {
e.printStackTrace();
return false;
}
return true;
}
}.getElement(XmlParse.getDocument(xmlFilePath), "action");
}
/**
* 将xml文件中配置的参数添加到 {@code bean} 中
*
* @param element 当前遍历到的xml元素
* @param bean 需要被注入参数的bean
* @return 是否添加成功
*/
private static boolean addParameters(Element element, ActionBeanDefinition bean) {
String name = element.getAttribute("name");
String type = element.getAttribute("type");
ParameterDefinition pd = new ParameterDefinition(name, TypeParser.strToType(type));
bean.addParameter(pd);
return true;
}
/**
* 根据 {@code bean} 中的参数类型,方法名 {@code methodName},以及
* {@code bean} 中的 {@code object} 获取对应的方法,将其注入进 {@code bean} 中
*
* @param methodName 需要得到的方法的方法名
* @param bean 被注入好参数的bean
* @throws NoSuchMethodException 未找到对应方法
*/
private static void injectMethodIntoBean(String methodName, ActionBeanDefinition bean) throws NoSuchMethodException {
Class<?>[] types = bean.getParameterTypes();
Class<?> clazz = bean.getObject().getClass();
Method method = clazz.getMethod(methodName, types);
bean.setMethod(method);
}
/**
* 根据用户提供的行为 {@code action},在注册到 {@code ACTION_BEAN_FACTORY}
* 的方法中寻找映射的方法。
*
* @param action 行为
* @return action映射的方法
*/
static ActionBeanDefinition getActionBeanDefinition(String action) {
return ACTION_BEAN_FACTORY.get(action);
}
}
从xml文件中配置bean的实现过程我直接贴在上面,由于用xml文件并不是我们框架的首选,所以不多赘述。其实就是个根据所填的信息解析然后生成一个ActionBeanDefinition
的bean,最后再将这个bean放到ACTION_BEAN_FACTORY
中去。
这里我提醒一点,这里我用了一个静态方法:
这也是我自己的工具包实现的一个类,因为xml文件写进去的类型比如int
,它并不是就是int
了,因为从xml文件解析出来的都是字符串,所以我写了一个工具来做一个转换,将字符串写的什么类型转化成它应该有的类型。
现在就到我们推荐的一个方便的配置映射的方法了,就是使用注解。
首先我们先来定义三个注解。
注意,最后一个ActionParameter
注解,就是要解决不知道参数名称的问题的,用它来注解方法中的参数,是必须写一个name()
属性,这就可以使得远端调用的我们的框架可以得到这个参数的名称了。
好,有了这三个注解,我们就不需要写冗长的xml配置文件了,直接在想要映射的方法存在的类上加一个Action
注解,然后在方法上加一个ActionMapping
注解,同时在形参前注解ActionParameter
,并写下这个参数的名字就好。
那如何扫描到用户的注解,并得到相关的信息,生成BeanFactory
呢?这就需要用到我在准备工作中所说的,我自己写的一个工具类,包扫描器。通过它就可以直接扫描一个包中所有的类,我们只要根据扫描到的类有没有Action
注解就可以知道需要添加映射的方法在哪里了。
扫描注解的整体逻辑就是这样
在生成了一个对象之后,要通过反射机制调用一个方法,我们就还差方法和参数,所以调用processMethod()
方法。
这个方法会找到带有ActionMapping
注解的所有方法,然后根据注解得到action,这样就差参数了,所以我们再去调用processParameter
去将参数也注入进bean中。
这个方法会在给定的方法中遍历所有的参数,如果有参数没有注解ActionParameter
,那么就会报出异常,提示第几个参数没有写注解,因为没有参数名称的话,我们最后是无法得到参数值的,这样框架就无法处理了。
最后我们再提供一个向ACTION_BEAN_FACTORY
中添加bean的方法。这样可以不用包扫描,也能注册bean。
当一个类中有需要映射的方法,可以直接将自己的对象传进来,也可以实现注册bean的目的。
这里直接调用processMethod()
方法就可以了。和包扫描时调用是一样的。
经过上面漫长的征程,现在我们终于可以处理客户端的请求和服务器的响应了,有了上一步经过包扫描得到的BeanFactory,我们可以直接根据客户端的请求的action
,取出相应的方法,进行反射执行就好了。
还是按照之前的绕一大圈的逻辑,不过我们这次先实现接口以及默认接口的实现方法,因为消息的传输是和之前的逻辑完全一样的。
我们的框架处理服务器的Response,处理客户端的Request都是一个路子,就是我第一段说的,先得到方法再进行反射调用。所以我们可以直接把处理Request & Response 的接口方法放到一个接口里。然后再实现一个默认方法,就是执行我门的反射逻辑,用户如果有别的需求可以再自行处理请求和响应的实现。
package com.tyz.csframework.actionbean;
import com.tyz.util.ArgumentMaker;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.lang.reflect.Type;
import java.util.List;
/**
* 在用户没有对 {@link ISessionProcessor} 的响应以及请求方法进行覆盖的
* 条件下,默认调用此类实现好的响应和请求的方法,通过扫描用户注册
* 的 {@code action} 和 {@code abd} 映射,根据 {@code action}
* 得到方法的封装类 {@link ActionBeanDefinition},从而执行映射
* 的方法。
*
* @author tyz
*/
public class DefaultSessionImpl implements ISessionProcessor {
/**
* 处理客户端的请求,并将响应的结果转换成json对象返回
*
* @param action 请求的行为
* @param parameter 参数
* @return json格式的服务器响应的结果
* @throws BeanNotExistException {@code action} 映射的bean不存在
* @throws InvocationTargetException 反射调用方法失败
*/
@Override
public String dealRequest(String action, String parameter) throws BeanNotExistException, InvocationTargetException, IllegalAccessException {
ActionBeanDefinition bean = ActionBeanFactory.getActionBeanDefinition(action);
if (bean == null) {
throw new BeanNotExistException("Action [" +
action + "] didn't have method to invoke.");
}
Object object = bean.getObject();
Method method = bean.getMethod();
Object[] args = getParameters(parameter, method, bean);
Object result = method.invoke(object, args);
return ArgumentMaker.GSON.toJson(result);
}
/**
* 客户端处理服务器的响应
*
* @param action 客户端请求的行为
* @param parameter 参数
* @throws Exception 参数不合法或者 {@code 无映射}
*/
@Override
public void dealResponse(String action, String parameter) throws Exception {
ActionBeanDefinition bean = ActionBeanFactory.getActionBeanDefinition(action);
if (bean == null) {
throw new BeanNotExistException("Action [" +
action + "] didn't have method to invoke.");
}
Object object = bean.getObject();
Method method = bean.getMethod();
Parameter[] parameters = method.getParameters();
// 服务器处理客户端请求后的响应返回的是一个参数
if (parameter.length() != 1) {
throw new Exception("Parameter's length is not valid.");
}
Object[] value = new Object[1];
value[0] = ArgumentMaker.GSON.fromJson(parameter, parameters[0].getParameterizedType());
method.invoke(object, value);
}
/**
* 根据 {@code parameter} 解析出所有参数的值
*
* @param parameter 经 {@link ArgumentMaker} 编码的参数列表
* @param method 需要执行的方法
* @param bean action映射的bean
* @return {@code method} 的所有参数的值
*/
private Object[] getParameters(String parameter, Method method, ActionBeanDefinition bean) {
ArgumentMaker argumentMaker = new ArgumentMaker(parameter);
Parameter[] parameters = method.getParameters();
if (parameters.length <= 0) {
return new Object[] {
};
}
Object[] result = new Object[parameters.length];
List<ParameterDefinition> parameterList = bean.getParameterList();
for (int index = 0; index < parameters.length; index++) {
Type type = parameters[index].getParameterizedType();
String name = parameterList.get(index).getName();
result[index] = argumentMaker.getArgument(name, type);
}
return result;
}
}
前面我们说了要根据参数的名称取得参数的值,由于反射过程中参数名称是一定会被抹去的,所以我们才通过ActionParameter
注解得到参数的名称,取值实现也是依赖于我的工具包中的一个类,就是ArgumentMaker
。
在反射的过程中,除了会把参数名抹去之外,还会将泛型擦除,那被抹去的泛型要怎么得到呢?
这个也是一个比较死的代码,就是要有一个这样的TypeToken
,才能得到被擦去泛型的类型,所以ArgumentMaker
的逻辑就是,建立一个HashMap
,键为参数名称,值为参数的值的json
对象,我们知道HashMap
也是一个泛型,所以我们就可以使用TypeToken
来保存这个HashMap
,这样即使参数被变成json
对象以后,我们仍旧可以解码得到这个HashMap
,只要有它,就可以根据参数名称来取得参数值了。
我将ArgumentMaker
的代码贴出。
package com.tyz.util;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Type;
import java.util.HashMap;
import java.util.Map;
/**
* 在对象传输中记录参数
*
* @author tyz
*/
public class ArgumentMaker {
private static final Type TYPE = new TypeToken<Map<String, String>>() {
}.getType();
public static final Gson GSON = new GsonBuilder().create();
private Map<String, String> argMap;
public ArgumentMaker() {
this.argMap = new HashMap<String, String>();
}
public ArgumentMaker(String parameter) {
this.argMap = GSON.fromJson(parameter, TYPE);
}
@SuppressWarnings("unchecked")
public <T> T getArgument(String name, Class<?> type) {
String str = this.argMap.get(name);
if (str == null) {
return null;
}
return (T) GSON.fromJson(str, type);
}
@SuppressWarnings("unchecked")
public <T> T getArgument(String name, Type type) {
String str = this.argMap.get(name);
if (str == null) {
return null;
}
return (T) GSON.fromJson(str, type);
}
public ArgumentMaker addArg(String name, Object value) {
argMap.put(name, GSON.toJson(value));
return this;
}
@Override
public String toString() {
return GSON.toJson(this.argMap);
}
}
所以用户在发送请求的时候,传递的参数是要先放到ArgumentMaker
中的,然后才能进行网络传输,这样对端就可以根据ArgumentMaker
再解析出来参数值,反射调用才可以实现。
我贴上一个我原来基于框架做开发时写的一个请求,大家可以参考这个用法。
现在最主要的部分已经完成了,我们再来根据结构走一遍传输流程。
必然是客户端先发送请求,所以从Client
开始。
它通过ClientConversation
将请求发出去。
然后在ServerConversation
中接收。
ServerConversation
会调用Server
,由它来最终处理。
Server
首先增加了我们前面写的处理请求和响应的接口成员,并初始化成默认实现。通过调用这个接口的方法完成对客户端请求的处理。
这个处理的结果会由ServerConversation
得知,并发送给客户端,命令变成了Response
。
这个响应发送给客户端,由ClientConversation
接收处理。
ClientConversation
会调用Client
,由它来最终处理服务器的响应,所以Client
也是一样的,增加接口成员。
然后调用接口方法,实现对服务器响应的处理。最终由ClientConversation
调用。
这篇文章真的是我写的最长的一篇,写了整整两天,好多次小崩溃,想放弃,很难受,好在还是坚持下来了。最后一共写了四万多字。
这遍对框架的梳理让我受益良多,对这个框架的理解更深了,也希望有人能因此而获益,那我非常荣幸。
这只是我做的几个中间件项目中的第一个,后续我还会再把其他更难更有意思的项目分享出来。
CsFramework的源码大家可以从我的github上直接查看
CsFramework 源码