【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件

CsFramework

  • Ⅰ 前言
  • Ⅱ 准备工作
    • A. 包扫描器
    • B. XML 文件解析器
  • Ⅲ 搭建框架
    • A. 通信的建立
      • a. 通信层基类的实现
      • b. 通信协议的实现
      • c. 核心——会话层的建立
    • B. 接口层
      • a. Server
      • b. Client
    • C. 框架主要功能的实现
      • a. 对端异常掉线的处理
      • b. 服务器 & 客户端命令处理
        • ① 客户端上线
        • ② 拒绝客户端上线
        • ③ 消息转发
          • 对消息转发的思考和准备工作
          • 一对一消息传送
          • 一对多消息传送
        • ④ 客户端下线
        • ⑤ 服务器强制宕机
        • ⑥ 清除指定客户端
        • ⑦ 获取在线客户端列表
    • D. 划重点——分发器的实现
      • a. 分发器能做什么
      • b. 分发器的实现
        • ① 主体思路实现
        • ② xml 文件配置
        • ③ 注解配置
    • E. Request & Response 的实现
  • Ⅳ 写在最后

Ⅰ 前言

本文旨在实现一个基于C/S模式的工具型框架 CsFramework,该框架将完成如下几个功能:

  1. 服务器对客户端一对多,实现长连接,传送数据流;
  2. 可配置连接客户端的最大数量;
  3. 识别对端异常掉线;
  4. 拒绝同名登录;
  5. 支持服务器与客户端进行基于此框架的MVC模式二次开发;
  6. 提供APP接口;
  7. 客户端之间可进行一对一,一对多通信;
  8. 提供通讯日志;(可配置)

首先先解释一下何为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文件解析器,和通过包扫描器扫描注解完成的,所以 我在此先给出这两个工具的代码,关于这些工具是如何实现的,有时间我会再写文章发出

如果对这里觉得云里雾里的读者,可以先跳过这一部分,再看到相应功能的实现时再跳转回这里。

A. 包扫描器

大家知道,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();
        }
    }
}

B. XML 文件解析器

然后是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;
        }
    }
}

接下来我们就开始逐步完成这个框架。

Ⅲ 搭建框架

【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第1张图片

A. 通信的建立

a. 通信层基类的实现

要建立一个C/S模式的框架,自然是要完成客户端与服务器之间的通信,不管是客户端还是服务器,对另一方发送消息的代码都是一样的,所以我们可以将其抽象出来,做一个基类。再后面通过服务器和客户端不同的特性,增加不同的操作。

Java中的网络编程是靠Socket类实现的,两端相连需要连接对方的Socket,这里我们选择使用字符串进行传输,所以用DataInputstream建立通信信道。

【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第2张图片
这个通信层的基类需要完成服务器和客户端之间共同需要的通信功能,也就是要向对端发送消息,从对端接收消息,以及建立和关闭通信信道。服务器和客户端这四个功能都是相通的。

CsFramework需要实现的服务器和客户端之间的长连接,所以我们可以使用一个单独的线程,专门来侦听从对端发送的消息,然后进行处理。

这样大致思路就清晰了。

可以让这个通信层的基类Communication直接继承Runnable,在初始化它的时候,就完成和对端通信信道的建立,然后保持侦听对端发送的消息。

所以可以设置一个变量goOn,并且是volatile的,在goOntrue的时候,也就是服务器和客户端还正常连接着,就一直保持侦听,当goOnfalse时,结束侦听。

【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第3张图片
下面一行我使用了一个线程池,因为线程的申请不同于对象的申请,线程是需要调用操作系统内核的API的,然后操作系统会为线程申请一系列资源,这个成本是很高的,所以即使这里我们可能不需要多少线程,也不要建立显示线程,使用线程池就好。

在这个侦听对端信息的线程运行过程中,会一直尝试从对端读取数据,读不到就会阻塞在那里。这里会出现几种情况,就是读到了这个消息这层通信层要如何处理,以及读数据时发生了异常该怎么办。

【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第4张图片
我们先来看发生异常的情况,这时候有两种情况,如果goOn已经是false,说明是自己下线的,正常下线即可。如果goOn是true,说明发生了对端异常掉线,这里需要处理。那我们怎么处理呢?显然这不是我们这层通信层可以处理的,需要更高层来处理这个情况,因此我这里直接定义一个抽象方法,让继承基层通信层的类必须处理,这样就完成了下层对上层功能的逻辑实现。

【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第5张图片
接下来就是正常读到了消息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();
    }
}

b. 通信协议的实现

【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第6张图片
这里的NetMessage是什么东西呢?大家可以看我的注释,写的这是规范的信息。

我们要做一个C/S的框架,肯定是要完成通信的功能,通信就要传递数据,但是这个数据可以随便传吗?当然不行。这样服务器就完全控制不住客户端了,也不知道该怎么处理这些随便的信息。所以在我们的框架里,需要定义一个简单的协议,在我们的框架中能传递的必须是规范的信息,这样服务器和客户端才能对其进行识别和解码。

比如我们看TCP头,

【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第7张图片

这就是NetMessage这个类的作用,在框架中我们传递的就只是这个格式的消息,定义一个类似于TCP这个头的东西,目的只是为了能让在CsFramework框架中运行的机器有一个可以交流的语言。

那我们的信息头中需要些什么信息呢?这里我先定义三个。

【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第8张图片

首先是action,这个变量是为后面框架实现分发器做准备的,我先搁置不谈。

parameter的作用就是要传递的数据,如果某个客户端想说句hello,那么parameter就是这个hello,如果客户端要登录,那发给服务器的parameter就是客户端的登录信息。

第三个有意思了,ETransferCommand是我定义的一个类,并且它是个枚举。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第9张图片

这个类中会定义所有客户端和服务器的命令,根据这个命令服务器和客户端会做出相应的处理操作,这个到后面还会再说到。

好了,现在一个信息头就被我们定义好了,分为行为action,数据parameter和命令command

大家不知道还记不记得,我们的传输通道用的是DataInputStreamDataOutputStream。所以要要传输的信息就只能是String类型的,我们就需要对NetMessage制定一个编码解码的规则。怎么定义呢?就一切从简,我们将三个成员之间都加个':'

【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第10张图片
解码就更简单啦。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第11张图片
这就是通信协议的制定,我将暂时的代码贴出,后面有需要的地方再继续补充。

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

c. 核心——会话层的建立

在一开始我定义了一个基类Communication,实现的是服务器和客户端通信的共有的功能。现在我们来思考一个问题,服务器和客户端之间的通信结构是怎样的?

在框架中,最高层一定是ServerClient,这两个类最终就是我们要暴露给使用框架的用户的两个类,如果这两个类中又有接收对端消息的,又有处理消息的,又有做分发的,那就太耦合了。通信层的事就让通信层的代码来做,最高层的服务器和客户端一定要尽可能简洁,留下要暴露出去的接口,具体的实现尽可能交给下层来做。

所以为了ServerClient这两个最终的大老板能舒舒服服,我们还需要再完善一下通信层的内容,建立两个会话类。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第12张图片

为了避免线太复杂,我只画出服务器和中间一个客户端之间的结构。其实非常简单,就是客户端和服务器并不直接通信,而是由它们建立的ServerConversationClientConversation之间进行通信。

当客户端连接到服务器的时候,发生了两件事,客户端会建立一个ClientConversation,由它负责和服务器的通信工作,同时服务器为这个客户端建立一个ServerConversationClientConversationServerConversation 通过之前建立的Communication进行交互。

注意这个关系,服务器对ServerConversation是一对多,ServerConversationClientConversation是一对一,客户端对ClientConversation自然也是一对一。

清楚了之后,我们就可以写一下ClientConversationServerConversation 了。

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() {
     

    }
}

这里我将Communicationcore包分开,最终ServerClient是在core包中的,避免了服务器端的代码可能对底层Communication的干扰,大家仔细看会发现我的Communication用的都是protected的权限,这样包外的类就可以通过继承来使用Communication的方法。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第13张图片

先将框架摆在这里,接下来我们慢慢实现。

B. 接口层

框架的核心当然是由客户机和服务器建立起来的,在前面我说了,这两个类也是最终要暴露给使用框架的用户的,接下来我们就从这个两个类的功能出发,逐步完善这个框架。

a. Server

一个客户端,要连接到服务器,需要两个东西,一个是服务器的端口号(port),一个是服务器的IP地址。所以在服务器初始化的时候,一定是需要一个端口号的。我们的框架在用户不设置端口号时会默认设置一个端口号。

再来,服务器启动以后,是不知道客户端会什么时候和它连接的,所以我还是直接在启动服务器时,再启动一个线程,专门用来侦听客户端的连接,而服务器的主线程做其他功能的操作。因此,这里还是设置一个成员变量goOn,来控制这个线程,只有当goOnfalse时,才会结束侦听。

【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第14张图片
在前面的分析中,我写了服务器和客户端连接的时候两件事会发生,

【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第15张图片
包括服务器会建立一个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的日志就会按照用户自己需要的方式记录下来。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第16张图片
对观察者模式这里不再赘述,大家若有兴趣,可以参考极客时间上王争的《设计模式之美》。
观察者模式

那么服务器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);
        }
    }
}


b. Client

客户端最首先的操作就是连接服务器,其实没什么好说的,就是正常操作,这里我们对异常直接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可以就先实现到这里,我们现在来完善框架的功能。

C. 框架主要功能的实现

a. 对端异常掉线的处理

还是先从ServerConversationClientConversation中的抽象方法开始看起。
由于ServerConversationClientConversation都是继承了通信层的基类Communication,而Communication中有两个操作是这层的逻辑无法实现需要传递到下一层的。

【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第17张图片
好,我们先来看处理对端异常掉线的操作,对服务器来说就是一个客户端异常掉线了,对于客户端来说,就是服务器异常掉线了。那会话层可以处理这种事情吗?显然不行,所以我们需要将它再往高层传。再想想,处理这种异常掉线的工作到底是谁应该做的?我认为应该是用我们的框架做开发的人员才能处理的,比如腾讯的大哥要用我的框架开发QQ,我能知道在一个QQ掉线之后应该做什么吗?显然不行呀。所以只能腾讯的大哥来实现。

那我该怎么让腾讯的大哥实现我框架的这个功能呢?答案就是接口。

我们需要定义一个接口,IServerAction,里面需要写一个方法,就是处理客户端异常掉线。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第18张图片
我们知道,接口如果被写在要调用的类中,是必须被实现的。那如果腾讯大哥说,就是个客户端掉线而已,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的适配器,然后调用方法。不过我是不喜欢这样的实现的,太多的GetterSetter会非常破坏OOP的封装特性,所以能不用GetterSetter就不要用,这是我的建议。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第19张图片
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第20张图片

注意哦,这里是包权限的,因为这个方法是ServerConversation用的,不需要暴露给用户。并且这里我们仍然要记录一下日志。

好,那Server中已经实现完了,我们应该怎么在ServerConversation中调用呢?

这里我们直接通过ServerConversation的构造方法,将Server传递进去。也就是说,我们需要在ServerConversation添加一个成员变量Server
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第21张图片
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第22张图片
Server中的初始化就需要改一下了。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第23张图片
实现了ServerConversation中的抽象方法,客户端其实是一模一样的。我们同样需要先建一个IClientAction的接口,然后还有它的适配器,最后在Client中实现它,然后将Client传递给ClientConversation,再由ClientConversation进行调用。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第24张图片
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第25张图片
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第26张图片

b. 服务器 & 客户端命令处理

好了,现在实现了Communication中的一个抽象方法,还剩一个。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第27张图片

这个方法的实现,其实就是我们这个框架大部分功能的实现了。这里一定不要忘记,客户端和服务器之间传递的是NetMessage编码后得到的字符串,这是我们框架的协议。

我们先从第一个最简单的功能开始吧。

① 客户端上线

回顾我们前面做的事情,我们实现了客户端和服务器的连接,然后Server生成了一个ID,ServerConversation再接着对自己的ID进行设置,然后呢?就没有啦。

回顾我们的结构图,这个ID只在了红框中进行了传播。【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第28张图片
那如果客户端要给其他客户端发送消息的话,它还是没法发送的,因为它都不知道其他客户端的ID。

所以在服务器建立起一个ServerConversation并设置了ID以后,还需要将这个ID传递给它连接的客户端,让对端的客户端知道自己的ID。

这时候我们前面的枚举ETransferCommand就派上用场啦。

【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第29张图片
我们用ONLINE命令来实现这个功能。

既然是要向对端发送信息,那肯定就是会话层的事。是服务器要发,所以我们找ServerConversation

【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第30张图片
还记得吗?NetMessage的格式,中间的parameter就是我们要发送的真正的数据,所以可以直接将id当作parameter直接发送给对端。并且由于我们直接覆盖了NetMessagetoString()方法,所以这里传送的时候是自动将它编码的。

那接下来就是要在Server中调用了,我们需要在客户端建立连接成功的时候发送给它它的ID
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第31张图片
好了,现在服务器把消息发给对端的客户端了,我们再来看一眼Communication中的方法。【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第32张图片
这个dealNetMessage()是不是就是从通信信道中读到的信息然后要进行处理的?

好,那我们就直接去ClientConversationdealNetMessage()方法,来解析服务器发送的这条消息。

由于我们收到的是一个NetMessage,这里再提醒一下,解码也是自动完成的。

Communication在接收到字符串以后,会调用NetMessage的单参构造。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第33张图片
而它的构造方法,就是一个解码的过程。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第34张图片

所以在ClientConversationdealNetMessage()中,我们要获得这三个参数,可以直接用NetMessageGetter方法,前面我们还没写,现在可以写了。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第35张图片
由于暂时还用不到action,我们可以先不获取它。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第36张图片
现在我们来根据传统的方法实现对不同命令的处理,补充一下,这里也可以使用设计模式里的策略模式。

【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第37张图片

注意这里我补上了ClientConversation的成员变量id

现在我们再来考虑一下腾讯大哥的需求,比如我一般在PC上登QQ,如果我不设置的话,总会给我弹出来点什么,要么是天气,要么是广告,有时候还是“夜深了,早点休息吧”。所以为了让用我的框架的人也可以弹点什么,我们再设置一个接口,就是用户登陆成功之后服务器做的事情。

于是,还是按照原来的思路,在IClientAction接口里添加一个afterConnectedSuccessfully()方法,并在ClientActionAdapter中实现一个空方法,然后在Client中通过调用IClientAction成员的这个方法,来实现它,最后再由ClientConversation调用,和上面处理异常掉线的思路是一模一样的。

【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第38张图片

【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第39张图片

【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第40张图片
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第41张图片
这就是我们这个框架的套路,在后面的功能中,实现方法基本都是如此,这样一轮调用。

再次强调,使用IClientAction或者IServerAction接口都是为了能让使用我们的框架开发者,可以根据自己的需求灵活实现某些功能,调用接口,其实就是在调用未来才会被写出来的方法。ClientActionAdapter是为了让用户有选择地实现我们需要用户自己去实现的功能,用户不想实现的地方,就默认会实现一个空方法。

好,现在我们已经实现了客户端上线的功能了。

再次回顾这个过程:Server和客户端连接,生成一个ID -> ServerConversationid设置为服务器生成的ID -> ServerConversation将ID传送给对端的客户端 -> 对端的ClientConversation接收了这个ID,将自己的id设置为ID -> 调用Client中的连接成功之后的操作。

② 拒绝客户端上线

和上线成功逻辑完全一样,这里不再赘述,这里走一遍过程。

首先是在ETranseferCommand中写下一个命令。

【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第42张图片
接着在ServerConversation,向对端的客户端发送连接失败的消息:
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第43张图片
接着Server调用ServerConversation中的方法。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第44张图片

然后ClientConversation接收消息。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第45张图片

IClientAction中增加连接失败后的接口。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第46张图片
在适配器ClientActionAdapter中实现空方法【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第47张图片

接着在Client中经过成员变量clientAction实现这个方法
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第48张图片
再次强调,这个成员是由适配器初始化的,所以才可以实现灵活配置。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第49张图片

【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第50张图片

最后,再由ClientConversation调用。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第51张图片

③ 消息转发

对消息转发的思考和准备工作

我们先来明确一下C/S模式下一个客户端给其他客户端是如何发消息的,比如你现在给女朋友发了句“晚安”,这个“晚安”是直接从你的手机发送到你女朋友的手机吗?

并不是的,因为QQ也好微信也好也是C/S模式的,这个模式下一定有个第三者在你和你女朋友中间,就是服务器。你的消息会发送到服务器那里,然后同时发送的还有一条指令,就是你要把这个消息发送到你女朋友的手机上。

这时候服务器就会找到你女朋友的IP地址,再把“晚安”转发给她,所以没想到吧,你不是最晚给你女朋友发晚安的男人。

我们的框架也是基于此,客户端想给别的客户端发消息,服务器需要得到这条消息,要知道是谁发送的,还要知道是发送给谁,这样才能最终在你女朋友的手机里显示出是她的大猪蹄子发了句晚安,是她收到的而不是隔壁李阿姨,她看到是你发的而不是楼上每天刻苦锻炼身体的小王,她收到的是晚安而不是你滚吧,全都要感谢服务器的消息转发功能。

知道了原理,现在我们来想一想要怎么实现。

在前面我定义的消息格式,actioncommand是固定的信息头,用来解析消息的,只有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的命令,代表这是一条一对一消息。

【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第52张图片

【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第53张图片

注意哦,这个发送消息的source是自己,前面我们图中画了,每个ClientConversation是和每个Client一对一的,所以ClientConversationid就代表这个客户端的id,和ServerConversation一样,这里不能搞混了。

然后第二个红框,我圈起来了一个奇怪的类,ArgumentMaker,这个类也是在我的工具包里,它在分发器的实现中会大有作用,我们先按下不表。这个类中有一个GSON常量,也是可以帮助我省去每次都要写一遍Gson的初始化的,直接调用就可以了。

在这里插入图片描述
好现在ClientConversation写好了,Client就可以调用它了。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第54张图片
现在消息发送出去了,最后谁会接收到呢?当然就是ServerConversation了。一定不要忘了这个关系。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第55张图片
那现在我们就可以去ServerConversation完成它的dealNetMessage()方法了。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第56张图片
ClientConversation一样,还是要先对传来的消息进行解析,根据命令选择执行什么操作。这里大家想一想,ServerConversation有将消息转发的能力吗?显然是没有的,因为它也只知道自己的ID而已,消息的转发一定建立在知道所有客户端消息的基础上,再回顾上面的结构图,是不是只有Server连接着多个ServerConversation,并且我们已经在Server中设置了一个成员变量ClientPool,存放着所有连接进来的客户端。

所以真正的消息转发工作是Server来做的。我这里就直接在ServerConversation中调用了ServertalkToOne()方法。现在我们来看ServertalkToOne()怎么实现。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第57张图片
之前我们在ClientPool中没有get()方法,先把它加上。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第58张图片
一个客户端要给另一个客户端发消息,存在两种情况,一个是这个客户端在线,发送成功,一个是客户端不在线,发送失败。

所以我们直接从客户端池clientPool中根据target取得要发送的客户端的ServerConversation,如果不是null,说明这个客户端在线,那就把消息转发给它,如果不在线,那就给发送这条消息的客户端说一下,你发送消息失败了,因为对方不在线。这就是targetIsNotExist()这个方法的目的。

再次做一个亲妈式讲解,我们的消息要从服务器发给客户端,需要从Server发给客户端对应的ServerConversation,由这个ServerConversation发给对端的ClientConversation,然后再由ClientConversation调用Client进行相应的处理。

【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第59张图片
好,那么转发是怎么转发呢?我们就需要找到要转发的那个客户端对应的ServerConversation了。所以完整的客户端给另一个客户端发送信息的流程是下面这样的,比如说客户端A要发送信息给客户端B:
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第60张图片

那如果客户端B不存在呢?
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第61张图片
服务器就会传送客户端B不存在的这个消息,再次传回A。

现在流程清楚了,我们再来看一下服务器的转发操作。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第62张图片
无论是把没找到的消息传回去,还是把消息转发给找到的客户端,我们首先要明确这是会话层要做的事情,发送信息不是服务器要实现的。所以我们需要在ServerConversation中实现这两个操作。

首先是找到目标了,要把消息发给它。我们还是在ServerConversation中实现一个talkToOne()方法。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第63张图片

MessagePackage封装的还是客户端发送来的消息,服务器不做任何改变,只负责转发。

那同样的,我们可以在ETransferCommand中再增加一个命令:
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第64张图片
然后ServerConversation中可以实现这个方法。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第65张图片
这个方法不需要再把完整的信息传回去了,因为source就是它自己,而消息message已经没什么意义,我们只需要告诉它targer没有找到即可。

最终这两个方法在服务器转发的逻辑中被调用。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第66张图片
按照前面的套路,服务器发送了消息,客户端自然就要接收消息。所以我们去ClientConversation中处理这两个命令。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第67张图片
这两个方法还是和之前的一样,先在IClientAction中增加这个接口,然后ClientActionAdapter实现空方法,Client再实现一个调用这个方法的方法,最后再这里被ClientConversation调用,这个过程我已经详细过了很多遍了,这里不再赘述。

好了,那么一对一的消息发送我们就处理完了。

一对多消息传送

一对多消息传送我实现的是群发的功能,就相当于游戏大厅里的广播。实现的过程和一对一是一模一样的,差别只有在选择target时可以直接置为null

在服务器进行消息转发的时候,需要通过clientPool得到一个所有在线客户端的列表,这样服务器才可以对所有客户端进行消息的转发。所以我们先在ClientPool中增添一个这样的方法,得到所有客户端。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第68张图片

具体的过程我不再细述,和一对一消息是完全一样的。大家可以对比我的截图做个参考。

Client
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第69张图片
然后ClientConversation
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第70张图片
接着到ServerConversationdealNetMessage()

在这里插入图片描述
经由ServerConversation调用Server
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第71张图片
这里注意不要把客户源发送的消息再给它转发回去,群发是把消息发送给除了它自身以外的所有消息。

接着Server调用ServerConversation
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第72张图片
然后ClientConversation接收这个消息
在这里插入图片描述

dealPublicMessage()实现的过程是这样的:

IClientAction

【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第73张图片
然后ClientActionAdapter实现空方法在这里插入图片描述

接着Client实现调用这个方法
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第74张图片

最终ClientClientConversation调用。
在这里插入图片描述

这样消息转发我们就实现好了。

④ 客户端下线

现在再来看一下客户端下线操作。我的思路还是尽可能多为要基于我们的工具做开发的朋友考虑一点,客户端执行下线命令之前,我们先用一个方法来确认它是否真的要下线,并在下线前和下线后都添加几个接口,这样用户可以做的选择就会很多。这样,三个接口方法呼之欲出。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第75张图片
还是一样,适配器需要对应实现空方法,然后在客户端发送下线命令的前后执行这些方法。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第76张图片

同样需要通过ClientConversation来实现向服务器发送要下线的消息。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第77张图片

参数是客户端的id,因为我们需要告诉服务器是哪个客户端下线了。

close()方法直接调用Communication的就好。

【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第78张图片
现在客户端发送了消息,我们该到ServerConversation处理dealNetMessage()了。

【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第79张图片
ServerConversation中,调用ServerclientOffline()方法,因为我们需要把这个操作抛到更高层来处理。

所以还是和Client一样,我们现在IServerAction中,增加一个接口。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第80张图片
然后通过它的适配器ServerActionAdapter实现一个空方法,在Server中完成对这个方法的调用,最终Server在被ServerConversation调用。

【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第81张图片

注意在Server中,我们需要先将这个客户端从池子中删除,然后记录一下日志,最后再调用用户的下线操作。

⑤ 服务器强制宕机

有时候,服务器可能需要紧急维护,要进行强制宕机,这样就需要通知连接到它的所有客户端,将它们强制下线,这样服务器才可以安全宕机。

现在我们就来做一下服务器的强制宕机。

这个显然是一个服务器的命令,我们就从服务器开始吧。

首先先在Server设置一个公共的方法forceDown,意为强制宕机。这个方法会先判断客户端池子是不是空的,如果不是,就把所有在线的客户端都取出来,然后传送强制宕机的消息,使得它们断开连接然后做客户端的处理。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第82张图片
显然这里还是要调用ServerConversation中的方法进行通信,所以我们要在ServerConversation中写一个serverForceDown的方法。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第83张图片

非常简单,参数都不需要,只需要传送服务器的这一个命令就好了。

现在到ClientConversation中去接收。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第84张图片

ClientConversation需要将这个操作抛给更高层,但是显然Client也不好处理,所以我们还是需要用一个接口方法,在IClientAction中增添一个dealServerExecuteForceDown的方法,然后完成调用,和前面一样。

IClientAction
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第85张图片

ClientActionAdapter
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第86张图片
Client
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第87张图片
ClientConversation
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第88张图片

⑥ 清除指定客户端

如果你发了什么骚话或者小黄图,现在被服务器发现了,服务器要怎么把你踢出去呢?

其实和服务器强制宕机的逻辑一样,都很简单,只不过服务器强制宕机可以不需要理由,但是把你踢出去还是要找个理由的,所以在服务器强制宕机的基础上,我们再补充一个reason,服务器强制踢你的理由。

还是从Server开始。

【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第89张图片

我们还是先从clientPool中找,要被强制下线的客户端是否存在,如果不存在在记录日志客户端不存在,若存在则通过ServerConversation传送将其清除的命令。

所以需要在ServerConversation中完成一个传送这个命令的方法。

【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第90张图片
发送的parameter就是服务器清除它的reason

接着在ClientConversation中接收,然后在IClientAction中增添相应的方法,这一列操作我就不再赘述了。

【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第91张图片
这样我们就完成了这个功能。

⑦ 获取在线客户端列表

现在我们再来完成一个简单的小功能,就是获取当前在线的所有客户端列表。

这里终于不需要再走前面的一大圈了,直接在Server中实现就好。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第92张图片
好了,现在我们框架的基本功能就完成了。到这里就结束了吗?那还早呐。使得我们的CsFramework从普通到有点意思的一步,就是下面要实现的分发器,有了这个分发器,我们可以完成对客户端Request的处理以及服务器的Response。

D. 划重点——分发器的实现

a. 分发器能做什么

先来解释一下分发器是什么以及为什么我们需要分发器。

服务器与客户端之间有两个重要的操作,一个是客户端的请求(Request),一个是服务器的响应(Response)

比如如果有人用我们的框架要开发一个聊天室,他是不是要完成起码的注册和登录?我们的框架怎么去完成他要做的登录和注册等功能呢?或者有人要开发一套学生管理系统,要完成不同的人有不同的权限,可以查询不同的内容。

以上等等等等,登录也好,查询信息也好,都是不同的app的客户端需要有的功能,这些app的底层逻辑就是 客户端发出了一个请求(比如请求登录),服务器给出响应(确认账号密码无误后准许登录)。

所以我们的框架要完成的是什么?就是要把这个底层的关系抽出来,我不用管你客户端具体要做什么,要怎么实现,无论你要做什么功能,都是在向服务器发送请求,服务器无论做什么操作,是账号的确权也好,将用户信息写到数据库里完成注册也好,都是服务器对客户端的响应。

那我们要如何实现对未来用户才会写下的代码进行使用呢?你可能会说接口呀,我们的框架中确实使用了不少接口,不过这些接口是要实现确定的功能的。比如我们这里需要客户端处理一下服务器异常掉线的情况,那就直接在这里写一个接口方法dealServerAbnormalDisconnected(),这里的方法职责是非常明确的。但是大家想想用户的请求和响应一样吗?

有的app可能要把用户数据放在内存里,有的要存到数据库中,有的要放到云中,即使是同一个登陆操作,有的app登录只需要手机号,有的需要邮箱,有的账号、ID、密码都要输入,我们怎么写这个接口?要写几个参数够?更何况,我们不会知道用户要用我们的框架去开发什么,要做什么功能。

所以,这里需要用Java中一个非常凶猛的东西,叫反射机制

我们的框架不会去写方法,直接用反射调用用户自己写的方法就好了。我不管你的登陆方法怎么写,这不归我管,我直接用就是了。

服务器和客户端对于我们的框架而已没多大区别,面对app的服务器端我们也是毫无办法的,一样的道理,是把用户的注册信息写到哪里,要根据什么来判断用户的账号是否合法,这些也都是未来的程序员要开发的,我们的框架只调用他未来写的方法,不做实现。

好,现在调用的问题解决了,那我们的框架怎么知道要调用什么呢?

比如客户端吨吨吨写了三个功能,注册,登录,注销。这三个就属于客户端的请求吧?那现在服务器要怎么响应呢,服务器是不是首先也要有这三个方法的处理策略?不然用户请求个登录,服务器端没有写登录的内容,那还登什么呀,就没法响应了。

在我们的描述中,服务器和客户端这三个方法都有自己的名字,叫登录叫注册,那肯定客户端的登录对应着服务器的登录,什么功能配什么功能嘛。

这么想想是很简单,问题就是计算机里一切都是二进制,客户端的一个请求发过来了,服务器怎么知道客户端发送的是什么请求,自己又应该用哪个方法来响应它。

如果你看到这里,相信你已经理解了我们现在面对的困局。解决这个困局的一个方法就是,分发器

分发器的实质就是解决此类的问题的,客户端的请求如何和服务器的响应所匹配,从而服务器可以根据客户端的请求来做出正确的响应。

b. 分发器的实现

① 主体思路实现

上面我们说了,分发器本质就是要解决请求和响应之间的映射关系。如何根据一个找到另一个。解决了怎么找到,我们再来看怎么做真正的RequestResponse

要解决映射问题,我提供两个方案。和Spring一样,我的框架也使用xml文件配置和通过注解进行配置两种方法。

在最开始的时候我定义框架中传递的信息体,用了一个成员变量action,不过在前面的所有功能中传递它的时候都是null,现在终于到它派上用场的时候了。

比如客户端请求登录,服务器响应登录,登录就是这一组的action,假设这组登录的action就叫logIn。那么,在客户端中,我们必须能根据logIn找到客户端请求登录的方法;同样在服务器端中,我们必须能根据logIn找到服务器响应登录的方法。我们的框架只需要传输这个action就足够了。

所以整体的思路就是,我们需要建立一个 的键值对,根据action,就能找到对应的 method。注意这里的method并不是Java的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文件的格式:



<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>

【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第93张图片
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中去。

这里我提醒一点,这里我用了一个静态方法:
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第94张图片
这也是我自己的工具包实现的一个类,因为xml文件写进去的类型比如int,它并不是就是int了,因为从xml文件解析出来的都是字符串,所以我写了一个工具来做一个转换,将字符串写的什么类型转化成它应该有的类型。

③ 注解配置

现在就到我们推荐的一个方便的配置映射的方法了,就是使用注解。

首先我们先来定义三个注解。

  1. Action 注解,表明这个类需要被扫描

【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第95张图片

  1. ActionMapping 注解,表名这个方法需要被注入bean中,action参数表明这个方法需要映射的action。
    【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第96张图片
  2. ActionParameter 注解,标明参数名称
    【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第97张图片

注意,最后一个ActionParameter注解,就是要解决不知道参数名称的问题的,用它来注解方法中的参数,是必须写一个name()属性,这就可以使得远端调用的我们的框架可以得到这个参数的名称了。

好,有了这三个注解,我们就不需要写冗长的xml配置文件了,直接在想要映射的方法存在的类上加一个Action注解,然后在方法上加一个ActionMapping注解,同时在形参前注解ActionParameter,并写下这个参数的名字就好。

那如何扫描到用户的注解,并得到相关的信息,生成BeanFactory呢?这就需要用到我在准备工作中所说的,我自己写的一个工具类,包扫描器。通过它就可以直接扫描一个包中所有的类,我们只要根据扫描到的类有没有Action注解就可以知道需要添加映射的方法在哪里了。

扫描注解的整体逻辑就是这样

【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第98张图片
在生成了一个对象之后,要通过反射机制调用一个方法,我们就还差方法和参数,所以调用processMethod() 方法。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第99张图片
这个方法会找到带有ActionMapping注解的所有方法,然后根据注解得到action,这样就差参数了,所以我们再去调用processParameter去将参数也注入进bean中。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第100张图片
这个方法会在给定的方法中遍历所有的参数,如果有参数没有注解ActionParameter,那么就会报出异常,提示第几个参数没有写注解,因为没有参数名称的话,我们最后是无法得到参数值的,这样框架就无法处理了。

最后我们再提供一个向ACTION_BEAN_FACTORY中添加bean的方法。这样可以不用包扫描,也能注册bean。

当一个类中有需要映射的方法,可以直接将自己的对象传进来,也可以实现注册bean的目的。

【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第101张图片
这里直接调用processMethod()方法就可以了。和包扫描时调用是一样的。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第102张图片

E. Request & Response 的实现

经过上面漫长的征程,现在我们终于可以处理客户端的请求和服务器的响应了,有了上一步经过包扫描得到的BeanFactory,我们可以直接根据客户端的请求的action,取出相应的方法,进行反射执行就好了。

还是按照之前的绕一大圈的逻辑,不过我们这次先实现接口以及默认接口的实现方法,因为消息的传输是和之前的逻辑完全一样的。

我们的框架处理服务器的Response,处理客户端的Request都是一个路子,就是我第一段说的,先得到方法再进行反射调用。所以我们可以直接把处理Request & Response 的接口方法放到一个接口里。然后再实现一个默认方法,就是执行我门的反射逻辑,用户如果有别的需求可以再自行处理请求和响应的实现。

【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第103张图片
现在我们可以通过反射默认实现一下请求和响应。

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

【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第104张图片
在反射的过程中,除了会把参数名抹去之外,还会将泛型擦除,那被抹去的泛型要怎么得到呢?
在这里插入图片描述
这个也是一个比较死的代码,就是要有一个这样的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再解析出来参数值,反射调用才可以实现。

我贴上一个我原来基于框架做开发时写的一个请求,大家可以参考这个用法。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第105张图片
现在最主要的部分已经完成了,我们再来根据结构走一遍传输流程。

必然是客户端先发送请求,所以从Client开始。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第106张图片
它通过ClientConversation将请求发出去。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第107张图片
然后在ServerConversation中接收。

【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第108张图片
ServerConversation会调用Server,由它来最终处理。

【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第109张图片
Server首先增加了我们前面写的处理请求和响应的接口成员,并初始化成默认实现。通过调用这个接口的方法完成对客户端请求的处理。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第110张图片
这个处理的结果会由ServerConversation得知,并发送给客户端,命令变成了Response
在这里插入图片描述
这个响应发送给客户端,由ClientConversation接收处理。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第111张图片
ClientConversation会调用Client,由它来最终处理服务器的响应,所以Client也是一样的,增加接口成员。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第112张图片
然后调用接口方法,实现对服务器响应的处理。最终由ClientConversation调用。
【Java项目整理】-> CsFramework -> 从零开始手动搭建一个C/S模式中间件_第113张图片

Ⅳ 写在最后

这篇文章真的是我写的最长的一篇,写了整整两天,好多次小崩溃,想放弃,很难受,好在还是坚持下来了。最后一共写了四万多字。

这遍对框架的梳理让我受益良多,对这个框架的理解更深了,也希望有人能因此而获益,那我非常荣幸。

这只是我做的几个中间件项目中的第一个,后续我还会再把其他更难更有意思的项目分享出来。

CsFramework的源码大家可以从我的github上直接查看

CsFramework 源码

你可能感兴趣的:(Java中间件,java,网络通信,C/S,中间件)