基于Socket实现的Java聊天小项目


项目说明

编写该项目的目的在于对之前所学的JavaSE部分的知识进行一个基本的综合运用和回顾。本次项目基于Socket实现,IO模型为阻塞IO-BIO(使用非阻塞IO-NIO或者Netty做聊天案例的文章可能后续会写一下),涵盖的知识点有面向对象、集合、泛型、反射、JDBC、线程基础等部分,项目基于Maven构建(导入依赖比较方便,懒得找依赖jar包),UI界面只使用控制台打印的方式(GUI部分的知识没了解过,学习这部分知识意义不大)。JDK版本为1.8,数据库为MySQL,版本为5.7,开发工具为IDEA。


项目功能说明

本次聊天项目要实现的主要功能有:注册、登录、在线用户列表、私聊、群聊、文件发送、服务端推送新闻。

一、项目准备

1.1 创建maven多模块工程

使用IDEA创建一个maven工程项目(关于Maven的知识不再阐述):

mychat模块为父模块,所以可以删掉父模块的src目录:
在这里插入图片描述本次项目子模块主要分三部分:服务端[chatserver模块]、客户端[chatclient模块]、公共依赖模块[common模块],创建完毕后目录结构如下:
在这里插入图片描述

1.2 项目依赖导入

在父模块的pom.xml文件中设置编码、导入依赖等。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.zkt.study</groupId>
    <artifactId>mychat</artifactId>
    <packaging>pom</packaging>
    <version>1.0-SNAPSHOT</version>
    <modules>
        <module>chatserver</module>
        <module>chatclient</module>
        <module>common</module>
    </modules>

    <properties>
        <java.version>1.8</java.version>
        <maven.compiler.source>1.8</maven.compiler.source>
        <maven.compiler.target>1.8</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
    </properties>

    <dependencies>
        <!--MYSQL JDBC驱动-->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>5.1.47</version>
        </dependency>

        <!--Druid连接池-->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid</artifactId>
            <version>1.1.10</version>
        </dependency>


        <!--测试-->
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.12</version>
            <scope>test</scope>
        </dependency>
        
    </dependencies>

</project>

在这里插入图片描述
因为子模块中的chatserver模块、chatclient模块都要依赖公共模块,所在分别在这两个子模块中引入公共模块。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>mychat</artifactId>
        <groupId>com.zkt.study</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>chatserver</artifactId>

    <dependencies>
        <dependency>
            <groupId>com.zkt.study</groupId>
            <artifactId>common</artifactId>
            <version>1.0-SNAPSHOT</version>
        </dependency>
    </dependencies>

</project>

到此,项目基本结构就搭建完成了。

1.3 公共模块设置

该模块主要是设置客户端、服务端共用的一些数据、Java类等,如常量类、工具类、数据库连接等(后续有其他需要继续补充)。
在这里插入图片描述

1.4 测试数据库连接

本次数据库连接池使用了Druid连接池(实际上在这个基础的小项目作用不大,只是作为一个知识点复习而已),配置文件如下:

driverClassName=com.mysql.jdbc.Driver
url=jdbc:mysql://数据库ip地址/数据库?useSSL=false&rewriteBatchedStatements=true
username=用户名
password=密码
# 初始连接数
initialSize=10
# 最多保持5个空闲连接
minIdle=5
# 最大连接数
maxActive=20
# 最大超时等待时间
maxWait=5000

编写数据库连接相关工具类如下:

public class DruidJdbcUtil {
    private DruidJdbcUtil() {}

    private static DataSource ds;

    /**
     * 静态代码块:完成一些初始化工作
     */
    static {
        try {
            //加载配置文件: 从类路径加载
            InputStream is = DruidJdbcUtil.class.getClassLoader().getResourceAsStream(ChatConstant.DB_PROP_NAME);
            Properties prop = new Properties();
            prop.load(is);
            //创建数据库连接池 => 数据源
            ds = DruidDataSourceFactory.createDataSource(prop);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    /**
     * 获取数据库连接
     *
     * @return
     */
    public static Connection getConnection() {
        try {
            return ds.getConnection();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * 重载
     *
     * @param conn
     */
    public static void close(Connection conn) {
        close(null, null, conn);
    }

    /**
     * 资源释放
     *
     * @param rs
     * @param stat
     * @param conn
     */
    public static void close(ResultSet rs, Statement stat, Connection conn) {
        try {
            if (rs != null) {
                rs.close();
            }
            if (stat != null) {
                stat.close();
            }
            if (conn != null) {
                conn.close();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

测试数据库连接是否正常:

public class DbTest {

    @Test
    public void testGetConnection() {
        Connection conn = DruidJdbcUtil.getConnection();
        System.out.println(conn);
    }
}

出现如下结果即表示获取数据库连接正常:
在这里插入图片描述

1.5 Server模块包说明

ChatServer模块的包层级设置如下。
在这里插入图片描述
app:编写服务端相关代码。dao:数据库操作层(持久层)。entity:存放实体类(实际是放在Common模块…)。service:业务层。

1.6 服务端基本代码编写

服务端基于ServerSocket实现,监听的端口为9999。

public class ChatServer {
    private ServerSocket serverSocket;

    /**
     * 服务端初始化
     */
    public ChatServer() {
        try {
            serverSocket = new ServerSocket(ChatConstant.SERVER_PORT);
            System.out.println("服务器在9999端口监听~");
            while (true) {
                //等待客户端连接 => 连接成功后获得交互的socket
                Socket socket = serverSocket.accept();
                //后续相关业务处理
            }
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (null != serverSocket) {
                try {
                    serverSocket.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

服务端启动如下(控制台界面有点单调,那就打印一只小企鹅吧):

public class ChatServerApplication {
    public static void main(String[] args) {
        System.out.println("服务端,启动!");
        bannerPrint();
        new ChatServer();
    }

    /**
     * 打印启动图标
     */
    private static void bannerPrint() {
        String[] linuxPenguin = {
                "    .--.",
                "   |o_o |",
                "   |:_/ |",
                "  //   \\ \\",
                " (|     | )",
                "/'\\_   _/`\\",
                "\\___)=(___/"
        };

        for (String line : linuxPenguin) {
            System.out.println(line);
        }
    }
}

在这里插入图片描述

1.7 客户端基本代码编写

客户端控制台界面分为一级菜单、二级菜单,一级菜单主要是用户注册、登录、退出等功能界面,二级菜单则是对应的聊天、群聊等功能界面。

public class ClientView {
    /**
     * 循环控制
     */
    private boolean loop = true;

    /**
     * 显示客户端主界面
     */
    public void mainMenu() throws Exception {
        while (loop) {
            System.out.println("************** 欢迎来到聊天通信系统 ******************");
            System.out.println("*                                                 *");
            System.out.println("*                                                 *");
            System.out.println("*               1 用户登录                         *");
            System.out.println("*               2 用户注册                         *");
            System.out.println("*               9 退出                            *");
            System.out.println("*                                                *");
            System.out.println("*                                                *");
            System.out.println("***************************************************");
            System.out.print("请输入您的选择:");
            String key = ScannerUtil.readString(1);
            switch (key) {
                case "1":
                    System.out.println("用户登录处理");
                    //登录业务逻辑
                    break;
                case "2":
                    System.out.println("用户注册处理");
                    //注册业务逻辑
                    break;
                case "9":
                    loop = false;
                    break;
                default:
                    break;
            }
        }
    }
}

客户端启动如下:

public class ChatClientApplication {
    public static void main(String[] args) throws Exception {
        new ClientView().mainMenu();
        System.out.println("客户端退出~");
    }
}

在这里插入图片描述

二、功能开发

2.1 注册功能

2.1.1 分析

用户在界面输入用户名、密码后向服务端发送请求,服务端接收该请求并实现用户注册,注册成功后在数据库的用户表插入对应用户数据,再向客户端回复注册成功消息。

2.1.1.1 用户表设计

用户表user创建如下(只设置一些基础字段,实际开发中表结构当然不会这么简单):

drop table if exists user;
create table user (
	id int primary key auto_increment comment ' -- 自增主键',
	userId VARCHAR(50) comment ' -- 用户编号',
	username VARCHAR(20) comment ' -- 用户名',
	password VARCHAR(50) comment ' -- 用户密码',
	registerTime VARCHAR(36) comment ' -- 注册时间'
) comment ' 用户表';
2.1.1.2 实体类创建

为了方便操作数据库数据,一般来说一张数据库表会映射到一个Java实体类,故在entity包下创建User类:

public class User implements Serializable {
    private static final long serialVersionUID = 3524151099125702500L;

    private Integer id;
    private String userId;
    private String username;
    private String password;
    private String registerTime;

    public User() {
    }
    
	public User(String username, String password) {
        this.username = username;
        this.password = password;
    }

    public User(Integer id, String userId, String username, String password, String registerTime) {
        this.id = id;
        this.userId = userId;
        this.username = username;
        this.password = password;
        this.registerTime = registerTime;
    }

    public Integer getId() {
        return id;
    }

    public void setId(Integer id) {
        this.id = id;
    }

    public String getUserId() {
        return userId;
    }

    public void setUserId(String userId) {
        this.userId = userId;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    public String getRegisterTime() {
        return registerTime;
    }

    public void setRegisterTime(String registerTime) {
        this.registerTime = registerTime;
    }
}

Java对象在网络中传输时,需要实现序列化Serializable 接口。

2.1.1.3 Dao层封装

为了方便对数据库进行操作,可以封装一个BasicDao(只涉及一些简单的逻辑):

public class BasicDao<T> {
    private Connection conn;
    private PreparedStatement ps;
    /**
     * 默认开启事务 -> 手动提交
     */
    protected boolean isAutoCommit = false;

    /**
     * 是否开启事务
     *
     * @param isAutoCommit
     */
    protected void setAutoCommit(boolean isAutoCommit) {
        this.isAutoCommit = isAutoCommit;
    }

    /**
     * 封装prepareStatement参数
     */
    private void setParamsForPs(PreparedStatement ps, Object... params) throws SQLException {
        int len = params.length;
        //ps占位下标从1开始
        for (int i = 0, psIndex = 1; i < len; i++, psIndex++) {
            //判断参数类型
            Object param = params[i];
            //字符串
            if (param instanceof String) {
                ps.setString(psIndex, (String) param);
            }
            //整数
            if (param instanceof Integer) {
                ps.setInt(psIndex, (Integer) param);
            }
            //浮点数
            if (param instanceof Double) {
                ps.setDouble(psIndex, (Double) param);
            }
            //----其他类型
        }
    }

    /**
     * DML操作 -> 增删改
     *
     * @param sql
     * @param params
     * @return
     */
    public int doUpdate(String sql, Object... params) {
        int res = 0;
        try {
            //获取数据库连接
            conn = DruidJdbcUtil.getConnection();
            //开启事务
            conn.setAutoCommit(false);
            ps = conn.prepareStatement(sql);
            //ps参数设置
            setParamsForPs(ps, params);
            //执行sql
            res = ps.executeUpdate();
            //提交事务
            conn.commit();
        } catch (Exception e) {
            //回滚
            try {
                conn.rollback();
            } catch (SQLException throwables) {
                throwables.printStackTrace();
            }
            e.printStackTrace();
        } finally {
            //资源释放
            DruidJdbcUtil.close(null, ps, conn);
        }

        return res;
    }

    /**
     * 查询单个
     *
     * @param sql
     * @param clazz
     * @param params
     * @return
     * @throws Exception
     */
    public T doQueryOne(String sql, Class<T> clazz, Object... params) throws Exception {
        T obj = null;
        int count = 0;
        try {
            conn = DruidJdbcUtil.getConnection();
            ps = conn.prepareStatement(sql);
            //参数封装
            setParamsForPs(ps, params);
            //执行查询
            ResultSet rs = ps.executeQuery();
            while (rs.next()) {
                ++count;
                if (count > 1) {
                    throw new RuntimeException("单个查询返回结果异常!");
                }

                //反射创建对象
                obj = clazz.newInstance();
                //获取类属性
                Field[] declaredFields = clazz.getDeclaredFields();
                for (Field declaredField : declaredFields) {
                    //取消访问检查
                    declaredField.setAccessible(true);
                    //属性名
                    String fieldName = declaredField.getName();
                    //属性类型
                    Class<?> fieldType = declaredField.getType();
                    //String
                    if (fieldType == String.class) {
                        //rs字符串取值
                        String fieldVal = rs.getString(fieldName);
                        //属性赋值
                        declaredField.set(obj, fieldVal);
                    }
                    //Integer
                    if (fieldType == Integer.class) {
                        //rs字符串取值
                        Integer fieldVal = rs.getInt(fieldName);
                        //属性赋值
                        declaredField.set(obj, fieldVal);
                    }
                    //Double
                    if (fieldType == Double.class) {
                        //rs字符串取值
                        Double fieldVal = rs.getDouble(fieldName);
                        //属性赋值
                        declaredField.set(obj, fieldVal);
                    }
                    //其他类型
                }
            }
        } finally {
            //资源释放
            DruidJdbcUtil.close(null, ps, conn);
        }

        return obj;
    }

    /**
     * 查询多个
     *
     * @param sql
     * @param clazz
     * @param params
     * @return
     * @throws Exception
     */
    public List<T> doQueryMany(String sql, Class<T> clazz, Object... params) throws Exception {
        List<T> resList = new ArrayList<>();
        try {
            conn = DruidJdbcUtil.getConnection();
            ps = conn.prepareStatement(sql);
            //参数封装
            setParamsForPs(ps, params);
            //执行查询
            ResultSet rs = ps.executeQuery();
            while (rs.next()) {
                //反射创建对象
                T one = clazz.newInstance();
                //获取类属性
                Field[] declaredFields = clazz.getDeclaredFields();
                for (Field declaredField : declaredFields) {
                    //取消访问检查
                    declaredField.setAccessible(true);
                    //属性名
                    String fieldName = declaredField.getName();
                    //属性类型
                    Class<?> fieldType = declaredField.getType();
                    //String
                    if (fieldType == String.class) {
                        //rs字符串取值
                        String fieldVal = rs.getString(fieldName);
                        //属性赋值
                        declaredField.set(one, fieldVal);
                    }
                    //Integer
                    if (fieldType == Integer.class) {
                        //rs字符串取值
                        Integer fieldVal = rs.getInt(fieldName);
                        //属性赋值
                        declaredField.set(one, fieldVal);
                    }
                    //Double
                    if (fieldType == Double.class) {
                        //rs字符串取值
                        Double fieldVal = rs.getDouble(fieldName);
                        //属性赋值
                        declaredField.set(one, fieldVal);
                    }
                }
                //加入集合
                resList.add(one);
            }
        } finally {
            //资源释放
            DruidJdbcUtil.close(null, ps, conn);
        }

        return resList;
    }

}

当需要操作对应数据库用户数据时,创建一个UserDao并继承BasicDao即可:

public class UserDao extends BasicDao<User> {
}
2.1.1.4 消息类型设置

为了让服务端知道本次客户端请求的类型,需要维护一个消息类型MessageType枚举类,因为客户端、服务端都会使用到该枚举类,所以该类放在公共模块里面。

public enum MessageType {
    /**
     * 用户注册
     */
    CHAT_USER_REGISTER("1");

    /**
     * 消息类型
     */
    private String type;

    /**
     * 构造器
     * @param type
     */
    MessageType(String type) {
        this.type = type;
    }
}
2.1.1.5 通讯消息类Message

为了方便服务端和客户端的消息通讯,可以封装一个Message消息类,该类的成员变量包括发送者、接收者、消息类型、消息内容等。

public class Message<T> implements Serializable {
    private static final long serialVersionUID = -8621512324130711419L;
    /**
     * 发送者
     */
    private String sender;
    /**
     * 接收者
     */
    private String getter;
    /**
     * 消息内容
     */
    private T content;
    /**
     * 发送时间
     */
    private String sendTime;
    /**
     * 消息类型
     */
    private String msgType;

    public Message() {
    }

    public Message(String sender, String getter, T content, String sendTime, String msgType) {
        this.sender = sender;
        this.getter = getter;
        this.content = content;
        this.sendTime = sendTime;
        this.msgType = msgType;
    }

    public String getSender() {
        return sender;
    }

    public void setSender(String sender) {
        this.sender = sender;
    }

    public String getGetter() {
        return getter;
    }

    public void setGetter(String getter) {
        this.getter = getter;
    }

    public T getContent() {
        return content;
    }

    public void setContent(T content) {
        this.content = content;
    }

    public String getSendTime() {
        return sendTime;
    }

    public void setSendTime(String sendTime) {
        this.sendTime = sendTime;
    }

    public String getMsgType() {
        return msgType;
    }

    public void setMsgType(String msgType) {
        this.msgType = msgType;
    }
}

2.1.2 服务端代码实现

用户业务处理接口UserService:

public interface UserService {
    /**
     * 根据用户名查询用户信息
     *
     * @param username
     * @return
     */
    User queryByUsername(String username) throws Exception;

    /**
     * 用户注册处理
     * @param user
     * @return
     * @throws Exception
     */
    boolean userRegister(User user) throws Exception;
}

用户业务处理实现类UserServiceImpl:

public class UserServiceImpl implements UserService {
    private final UserDao userDao = new UserDao();

    /**
     * 根据用户名查询用户信息
     *
     * @param username
     * @return
     */
    @Override
    public User queryByUsername(String username) throws Exception {
        String sql = "select * from user where username = ?";
        return userDao.doQueryOne(sql, User.class, username);
    }

    /**
     * 用户注册处理
     * @param user
     * @return
     * @throws Exception
     */
    @Override
    public boolean userRegister(User user) throws Exception {
        String sql = "insert into user(userId,username,password,registerTime) values(?,?,?,?)";
        //为每位注册的用户生成一个唯一标识 => UUID
        String userId = CommonUtil.createUuid();
        String registerDate = CommonUtil.getNowDate();
        int i = userDao.doUpdate(sql, userId, user.getUsername(), user.getPassword(), registerDate);
        return i > 0;
    }
}

服务端代码:

public class ChatServer {
    private ServerSocket serverSocket;
    private final UserService userService = new UserServiceImpl();

    /**
     * 服务端初始化
     */
    public ChatServer() {
        try {
            serverSocket = new ServerSocket(ChatConstant.SERVER_PORT);
            System.out.println("服务器在9999端口监听~");
            while (true) {
                //等待客户端连接 => 连接成功后获得交互的socket
                Socket socket = serverSocket.accept();
                //读取客户端发送的消息
                ObjectInputStream ois = new ObjectInputStream(socket.getInputStream());
                Message<User> msg = (Message<User>) ois.readObject();
                //取出User对象
                User user = msg.getContent();
                //消息类型
                String msgType = msg.getMsgType();
                //服务端回复客户端的消息
                Message<String> replyMsg = new Message<>();

                //用户注册处理
                if (MessageType.CHAT_USER_REGISTER.getType().equals(msgType)) {
                    //用户名是否重复
                    User u = userService.queryByUsername(user.getUsername());
                    if (Objects.isNull(u)) {
                        //可以注册
                        if (Objects.isNull(u)) {
                        //可以注册
                        if (userService.userRegister(user)) {
                            replyMsg.setMsgType(MessageType.USER_REGISTER_SUCCESS.getType());
                            replyMsg.setContent("注册成功!");
                        } else {
                            replyMsg.setMsgType(MessageType.USER_REGISTER_FAIL.getType());
                            replyMsg.setContent("注册异常!");
                        }
                    } else {
                        //当前用户名已存在,不可注册
                        replyMsg.setMsgType(MessageType.USER_REGISTER_FAIL.getType());
                        replyMsg.setContent("当前用户名已被注册!");
                    }
                }

                //回复客户端消息
                ObjectOutputStream oos = new ObjectOutputStream(socket.getOutputStream());
                oos.writeObject(replyMsg);
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            if (null != serverSocket) {
                try {
                    serverSocket.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

2.1.3 客户端代码实现

ClientUserService专门用于处理客户端用户相关的业务,如用户注册、用户登录等。

public class ClientUserService {
    /**
     * 和服务端保持通讯的socket
     */
    private Socket socket;
    /**
     * 用户对象
     */
    private User user;

    /**
     * 发送用户注册请求消息
     * @param username
     * @param password
     */
    public void sendUserRegisterMsg(String username, String password) {
        user = new User(username, password);
        Message<User> registerMsg = new Message<>();
        registerMsg.setMsgType(MessageType.CHAT_USER_REGISTER.getType());
        registerMsg.setContent(user);
        try {
            socket = new Socket(ChatConstant.SERVER_IP, ChatConstant.SERVER_PORT);
            //发送
            ObjectOutputStream oos = new ObjectOutputStream(socket.getOutputStream());
            oos.writeObject(registerMsg);
            //等待服务端回复[阻塞]
            ObjectInputStream ois = new ObjectInputStream(socket.getInputStream());
            Message<String> replyMsg = (Message<String>) ois.readObject();
            System.out.println("********* " + replyMsg.getContent() + " *********");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

客户端注册输入页面:

public class ClientView {
    /**
     * 循环控制
     */
    private boolean loop = true;

    /**
     * 客户端用户相关业务处理
     */
    private final ClientUserService clientUserService = new ClientUserService();

    /**
     * 显示客户端主界面
     */
    public void mainMenu() throws Exception {
        while (loop) {
            System.out.println("************** 欢迎来到聊天通信系统 ******************");
            System.out.println("*                                                 *");
            System.out.println("*                                                 *");
            System.out.println("*               1 用户登录                         *");
            System.out.println("*               2 用户注册                         *");
            System.out.println("*               9 退出                            *");
            System.out.println("*                                                *");
            System.out.println("*                                                *");
            System.out.println("***************************************************");
            //用户输入
            System.out.print("请输入您的选择:");
            String key = ScannerUtil.readString(1);
            switch (key) {
                case "1":
                    System.out.println("用户登录处理");
                    //登录业务逻辑
                    break;
                case "2":
                    System.out.print("请输入用户名: ");
                    String regUsername = ScannerUtil.readString(20);
                    System.out.print("请输入密码: ");
                    String regPassword = ScannerUtil.readString(20);
                    clientUserService.sendUserRegisterMsg(regUsername, regPassword);
                    break;
                case "9":
                    loop = false;
                    break;
                default:
                    break;
            }
        }
    }
}

2.1.4 注册功能测试

分别启动服务端、客户端。在控制台输入用户名、密码进行注册:
在这里插入图片描述
查看数据库的user表:
在这里插入图片描述
密码这种敏感信息一般需要加密处理,不能以明文形式直接显示。本项目中采用MD5进行加密处理,加密方法放在通用工具类中:

public class CommonUtil {
    private CommonUtil() {}

    /**
     * 生成UUID序列号
     * @return
     */
    public static String createUuid() {
        return UUID.randomUUID().toString().replace("-", "");
    }

    /**
     * MD5加密工具
     * @param pwdStr
     * @return
     */
    public static String encrypByMD5(String pwdStr) {
        StringBuilder sb = new StringBuilder();
        try {
            MessageDigest digest = MessageDigest.getInstance("MD5");
            byte[] bytes = digest.digest(pwdStr.getBytes());
            for (byte b :bytes){
                sb.append(Integer.toString((b & 0xff) + 0x100, 16).substring(1));
            }
        } catch (NoSuchAlgorithmException e) {
            e.printStackTrace();
        }

        return sb.toString();
    }

    /**
     * 获取当前日期 -> 格式yyyy-MM-dd HH:mm:ss
     * @return
     */
    public static String getNowDate() {
        return getNowDate("yyyy-MM-dd HH:mm:ss");
    }

    /**
     * 获得当前日期
     * @param datePattern 日期格式
     * @return
     */
    public static String getNowDate(String datePattern) {
        LocalDateTime now = LocalDateTime.now(ZoneId.of("GMT+8"));
        DateTimeFormatter dtf = DateTimeFormatter.ofPattern(datePattern);
        return dtf.format(now);
    }
}

在插入数据库前需要进行加密处理:

/**
     * 用户注册处理
     * @param user
     * @return
     * @throws Exception
     */
    @Override
    public boolean userRegister(User user) throws Exception {
        String sql = "insert into user(userId,username,password,registerTime) values(?,?,?,?)";
        //为每位注册的用户生成一个唯一标识 => UUID
        String userId = CommonUtil.createUuid();
        String registerDate = CommonUtil.getNowDate();
        //密码加密处理
        String md5Password = CommonUtil.encrypByMD5(user.getPassword());
        int i = userDao.doUpdate(sql, userId, user.getUsername(), md5Password, registerDate);
        return i > 0;
    }

在这里插入图片描述

再次测试注册,可以看到密码已经是按加密后的形式存储。
如果再次按用户名ikun进行注册,则注册失败:
在这里插入图片描述
到此注册功能实现完毕。

2.2 用户登录

2.2.1 功能分析

用户向服务端发送用户名和密码信息,服务端进行相应的登录逻辑处理。因为后续需要实现用户之前的私聊以及群聊等功能,所以在用户登录成功以后,应该要和服务端保持通讯,此时线程就登场了。此外,登录的用户有多个,所以服务端和客户端都需要维护一个管理各个用户和服务端保持通讯的线程的集合。如下图所示:
在这里插入图片描述

2.2.2 服务端代码实现

服务端和客户端保持通讯的线程ServerConnClientThread:

public class ServerConnClientThread extends Thread {
    /**
     * 数据交互的socket
     */
    private Socket socket;
    /**
     * 通讯线程唯一标识[username不可重复]
     */
    private String username;

    public ServerConnClientThread(Socket socket, String username) {
        this.socket = socket;
        this.username = username;
    }

    public Socket getSocket() {
        return socket;
    }

    public String getUsername() {
        return username;
    }

    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + " - 服务端保持和客户端[" + this.username + "]通讯的线程启动...");
        while (true) {
            try {
                ObjectInputStream ois = new ObjectInputStream(socket.getInputStream());
                //等待客户端发送消息[阻塞]
                Message clientMsg = (Message) ois.readObject();
            } catch (IOException e) {
                e.printStackTrace();
            } catch (ClassNotFoundException e) {
                e.printStackTrace();
            }
        }
    }
}

服务端管理和客户端保持通讯的线程集合:

public class ServerConnClientThreadManage {
    /**
     * 用户通讯线程集合:key[用户名] -> value[对应通讯线程]
     */
    private static Map<String, ServerConnClientThread> scctMap = new HashMap<>();

    /**
     * 添加
     * @param username
     * @param scct
     */
    public static void addServerConnClientThread(String username, ServerConnClientThread scct) {
        scctMap.put(username, scct);
    }

    /**
     * 移除
     * @param username
     */
    public static void removeServerConnClientThread(String username) {
        scctMap.remove(username);
    }

    /**
     * 获得对应用户的通讯线程
     * @param username
     * @return
     */
    public ServerConnClientThread getServerConnClientThread(String username) {
        return scctMap.get(username);
    }

}

登录逻辑处理(业务层的代码不再展示):

public class ChatServer {
    private ServerSocket serverSocket;
    private final UserService userService = new UserServiceImpl();

    /**
     * 服务端初始化
     */
    public ChatServer() {
        try {
            serverSocket = new ServerSocket(ChatConstant.SERVER_PORT);
            System.out.println("服务器在9999端口监听~");
            while (true) {
                //等待客户端连接 => 连接成功后获得交互的socket
                Socket socket = serverSocket.accept();
                //读取客户端发送的消息
                ObjectInputStream ois = new ObjectInputStream(socket.getInputStream());
                Message<User> msg = (Message<User>) ois.readObject();
                //取出User对象
                User user = msg.getContent();
                //消息类型
                String msgType = msg.getMsgType();
                //服务端回复客户端的消息
                Message<String> replyMsg = new Message<>();

                //用户注册处理
                if (MessageType.CHAT_USER_REGISTER.getType().equals(msgType)) {
                    //用户名是否重复
                    User u = userService.queryByUsername(user.getUsername());
                    if (Objects.isNull(u)) {
                        //可以注册
                        if (userService.userRegister(user)) {
                            replyMsg.setMsgType(MessageType.USER_REGISTER_SUCCESS.getType());
                            replyMsg.setContent("注册成功!");
                        } else {
                            replyMsg.setMsgType(MessageType.USER_REGISTER_FAIL.getType());
                            replyMsg.setContent("注册异常!");
                        }
                    } else {
                        //当前用户名已存在,不可注册
                        replyMsg.setMsgType(MessageType.USER_REGISTER_FAIL.getType());
                        replyMsg.setContent("当前用户名已被注册!");
                    }
                }

                //用户登录处理
                if (MessageType.CHAT_USER_LOGIN.getType().equals(msgType)) {
                    //查询用户是否存在
                    User u = userService.queryByUsernameAndPassword(user.getUsername(), user.getPassword());
                    if (Objects.isNull(u)) {
                        //未查到
                        replyMsg.setMsgType(MessageType.USER_LOGIN_FAIL.getType());
                        replyMsg.setContent("用户名或者密码错误!");
                    } else {
                        //登录成功
                        replyMsg.setMsgType(MessageType.USER_LOGIN_SUCCESS.getType());
                        String username = user.getUsername();
                        //创建服务端和客户端保持通讯的线程
                        ServerConnClientThread scct = new ServerConnClientThread(socket, username);
                        //线程启动
                        scct.start();
                        //加入集合管理
                        ServerConnClientThreadManage.addServerConnClientThread(username, scct);
                    }
                }

                //回复客户端消息
                ObjectOutputStream oos = new ObjectOutputStream(socket.getOutputStream());
                oos.writeObject(replyMsg);
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            if (null != serverSocket) {
                try {
                    serverSocket.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

2.2.3 客户端代码实现

客户端保持和服务端通讯的线程ClientConnServerThread:

public class ClientConnServerThread extends Thread {
    private Socket socket;
    private String username;

    public ClientConnServerThread(Socket socket, String username) {
        this.socket = socket;
        this.username = username;
    }

    public Socket getSocket() {
        return socket;
    }

    public String getUsername() {
        return username;
    }

    @Override
    public void run() {
    System.out.println(Thread.currentThread().getName() + " - 客户端[" + this.username + "]和服务端保持通讯的线程启动...");
        while (true) {
            try {
                ObjectInputStream ois = new ObjectInputStream(socket.getInputStream());
                //等待服务端回复消息[阻塞]
                Message serverMsg = (Message) ois.readObject();
                //根据消息类型做对应处理
                String msgType = serverMsg.getMsgType();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
}

客户端通讯线程集合管理:

public class ClientConnServerThreadManage {
    /**
     * 不同客户端的通讯线程集合
     */
    private static Map<String, ClientConnServerThread> ccstMap = new HashMap<>();

    /**
     * 通讯线程加入集合
     * @param username
     * @param ccst
     */
    public static void add(String username, ClientConnServerThread ccst) {
        ccstMap.put(username, ccst);
    }

    /**
     * 获得对应客户端通讯线程
     * @param username
     * @return
     */
    public static ClientConnServerThread getClientConnServerThread(String username) {
        return ccstMap.get(username);
    }

    /**
     * 移除对应客户端和服务端通讯的线程
     * @param username
     */
    public static void remove(String username) {
        ccstMap.remove(username);
    }

}

ClientUserService新增发送登录请求方法:

/**
     * 发送用户登录请求消息
     * @param username
     * @param password
     */
    public boolean sendUserLoginMsg(String username, String password) {
        boolean isLoginSuccess = false;
        user = new User(username, password);
        Message<User> loginMsg = new Message<>();
        loginMsg.setMsgType(MessageType.CHAT_USER_LOGIN.getType());
        loginMsg.setContent(user);

        try {
            socket = new Socket(ChatConstant.SERVER_IP, ChatConstant.SERVER_PORT);
            //发送
            ObjectOutputStream oos = new ObjectOutputStream(socket.getOutputStream());
            oos.writeObject(loginMsg);
            //等待服务端回复[阻塞]
            ObjectInputStream ois = new ObjectInputStream(socket.getInputStream());
            Message<String> replyMsg = (Message<String>) ois.readObject();
            if (MessageType.USER_LOGIN_SUCCESS.getType().equals(replyMsg.getMsgType())) {
                //登录成功
                isLoginSuccess = true;
                //创建和服务器端保持通讯的线程
                ClientConnServerThread ccst = new ClientConnServerThread(socket, username);
                //启动
                ccst.start();
                //加入集合管理
                ClientConnServerThreadManage.add(username, ccst);
            } else {
                System.out.println("***** " + replyMsg.getContent() + " *****");
            }
        } catch (Exception e) {
            e.printStackTrace();
        }

        return isLoginSuccess;
    }

这时需要返回一个布尔值判断用户登录是否成功,若登录成功则进入二级功能菜单(此阶段还未完成)。
客户端页面新增登录输入(不再粘贴所有代码):

//省略
 case "1":
                    System.out.print("请输入您的用户名: ");
                    String username = ScannerUtil.readString(20);
                    System.out.print("请输入您的密码: ");
                    String password = ScannerUtil.readString(20);
                    boolean isLoginSuccess = clientUserService.sendUserLoginMsg(username, password);
                    if (isLoginSuccess) {
                        System.out.println("登录成功 -> 进入二级菜单");
                    }
                    break;
                    //省略

2.2.4 登录功能测试

启动服务端、客户端,输入用户名、密码查看登录情况:
在这里插入图片描述
在这里插入图片描述
如果输入的是错误的用户名或者密码,则不会登录成功:
在这里插入图片描述

2.3 二级功能菜单初始化

用户登录成功以后即可进入二级系统菜单界面,该界面包括在线用户列表、私聊、群聊、文件发送等功能。二级系统菜单ClientSecondView初始如下:

public class ClientSecondView {
    /**
     * 循环控制
     */
    private boolean loop = true;

    /**
     * 显示客户端二级菜单
     */
    public void mainMenu(String username) throws Exception {
        while (loop) {
            System.out.println("************** 通信系统二级菜单[用户: "+ username +"] ******************");
            System.out.println("*                                                 *");
            System.out.println("*                                                 *");
            System.out.println("*               1 显示在线用户                      *");
            System.out.println("*               2 私聊消息                          *");
            System.out.println("*               3 群发消息                          *");
            System.out.println("*               4 发送文件                          *");
            System.out.println("*               9 退出登录                          *");
            System.out.println("*                                                 *");
            System.out.println("***************************************************");
            //用户输入
            System.out.print("请输入您的选择:");
            String key = ScannerUtil.readString(1);
            switch (key) {
                case "1":
                    System.out.println("显示在线用户");
                    break;
                case "2":
                    System.out.println("私聊消息");
                    break;
                case "3":
                    System.out.println("群发消息");
                    break;
                case "4":
                    System.out.println("发送文件");
                    break;
                case "9":
                    System.out.println("退出登录");
                    loop = false;
                    break;
                default:
                    break;
            }
        }
    }
}

现在即可把一级菜单中登录成功后的逻辑进行替换:

//省略
case "1":
                    System.out.print("请输入您的用户名: ");
                    String username = ScannerUtil.readString(20);
                    System.out.print("请输入您的密码: ");
                    String password = ScannerUtil.readString(20);
                    boolean isLoginSuccess = clientUserService.sendUserLoginMsg(username, password);
                    if (isLoginSuccess) {
                        //进入二级系统菜单
                        new ClientSecondView().mainMenu(username);
                    }
                    break;
                    //省略

2.4 二级功能菜单开发

在本节中主要是对二级功能菜单涉及的功能逐个进行实现。用户登录成功以后进入二级菜单,此时已经启动了和服务端保持通讯的线程,而每个通讯线程中维护了两个成员变量:username(当前登录用户),socket(数据交互),所以此时就可以从通讯线程管理类ClientConnServerManage维护的集合中根据username取出对应通讯线程,再从通讯线程中获取socket即可和服务端进行数据交互。

2.4.1 用户退出登录

2.4.1.1 退出分析

为什么要先处理用户退出功能,因为用户登录成功以后客户端和服务端之间会创建一个保持通讯的线程,如果客户端退出时不做任何处理,那么相当于客户端断开了socket数据通道,但是服务端依旧在通讯线程的循环中保持连接socket数据通道,那么这时就会一直抛出IO异常(忽略此处设计,只要服务端或者客户端断开一边即可看到控制台一直抛出IO异常,不再演示)。退出分析如下图所示:
在这里插入图片描述
客户端退出时,向服务端发送一个退出请求,移除对应通讯线程并关闭socket通道,退出循环。服务端接收到客户端退出请求后,同样需要移除对应通讯线程和关闭socket通道,并退出循环。

2.4.1.2 服务端代码实现

此时客户端、服务端保持通讯的线程已经启动,代码应该写在run()方法中(后续功能代码只粘贴对应if分支处理部分,不再粘贴所有)。

 @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + " - 服务端保持和客户端[" + this.username + "]通讯的线程启动...");
        while (true) {
            try {
                ObjectInputStream ois = new ObjectInputStream(socket.getInputStream());
                //等待客户端发送消息[阻塞]
                Message clientMsg = (Message) ois.readObject();
                //根据消息类型做对应处理
                String msgType = clientMsg.getMsgType();
                //用户退出登录处理
                if (MessageType.CHAT_USER_EXIT.getType().equals(msgType)) {
                    //从集合中移除该通讯线程
                    ServerConnClientThreadManage.removeServerConnClientThread(username);
                    //socket通道关闭
                    socket.close();
                    System.out.println("用户[" + username + "]已退出~");
                    //退出循环
                    break;
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
2.4.1.3 客户端代码实现

ClientUserService新增发送用户退出登录方法:

/**
     * 发送用户退出登录请求消息
     * @param username
     */
    public void sendUserExitMsg(String username) {
        Message<String> exitMsg = new Message<>();
        exitMsg.setMsgType(MessageType.CHAT_USER_EXIT.getType());
        exitMsg.setContent(username);
        //从通讯线程获取socket
        ClientConnServerThread ccst = ClientConnServerThreadManage.getClientConnServerThread(username);
        if (null != ccst) {
            try {
                Socket socket = ccst.getSocket();
                //发送消息
                ObjectOutputStream oos = new ObjectOutputStream(socket.getOutputStream());
                oos.writeObject(exitMsg);
                //服务端回复后的逻辑在对应通讯线程中处理即可
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

ClientConnServerThread新增用户退出登录处理:

@Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + " - 客户端[" + this.username + "]和服务端保持通讯的线程启动...");
        while (true) {
            ObjectInputStream ois = new ObjectInputStream(socket.getInputStream());
                //等待服务端回复消息[阻塞]
                Message serverMsg = (Message) ois.readObject();
                //根据消息类型做对应处理
                String msgType = serverMsg.getMsgType();
                //用户退出登录
                if (MessageType.USER_EXIT_SUCCESS.getType().equals(msgType)) {
                    //移除通讯线程
                    ClientConnServerThreadManage.remove(username);
                    //socket通道关闭
                    socket.close();
                    System.out.println("***** 您已退出登录 *****");
                    //退出循环
                    break;
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

二级菜单swith处理块添加发送退出登录消息处理:

public void mainMenu(String username) throws Exception {
        while (loop) {
            System.out.println("************** 通信系统二级菜单[用户: "+ username +"] ******************");
            System.out.println("*                                                 *");
            System.out.println("*                                                 *");
            System.out.println("*               1 显示在线用户                      *");
            System.out.println("*               2 私聊消息                          *");
            System.out.println("*               3 群发消息                          *");
            System.out.println("*               4 发送文件                          *");
            System.out.println("*               9 退出登录                          *");
            System.out.println("*                                                 *");
            System.out.println("***************************************************");
            //用户输入
            System.out.print("请输入您的选择:");
            String key = ScannerUtil.readString(1);
            switch (key) {
                case "1":
                    System.out.println("显示在线用户");
                    break;
                case "2":
                    System.out.println("私聊消息");
                    break;
                case "3":
                    System.out.println("群发消息");
                    break;
                case "4":
                    System.out.println("发送文件");
                    break;
                case "9":
                    clientUserService.sendUserExitMsg(username);
                    loop = false;
                    break;
                default:
                    break;
            }
        }
    }
2.4.1.4 退出登录功能测试

在这里插入图片描述
可以看到界面打印有点错乱,为了让打印信息看起来合理一点,可以让打印菜单界面的main线程小休眠一下。

 case "9":
                    clientUserService.sendUserExitMsg(username);
                    //休眠500ms,防止打印信息错乱
                    TimeUnit.MILLISECONDS.sleep(500);
                    loop = false;
                    break;

再次测试:
在这里插入图片描述
这样打印信息看起来就合理一点啦。

2.4.2 显示在线用户

2.4.2.1 功能分析

显示在线用户列表即显示当前所有已登录的用户,根据上述分析可知,服务端会维护和每个登录成功的用户保持通讯的线程集合,遍历集合即可获得所有在线用户列表(实现方式很多,仁者见仁智者见智吧)。

2.4.2.2 服务端代码实现

后续的群聊功能中也会涉及在线用户列表的获取,故在ServerConnClientThreadManage类中维护一个获取在线用户列表的方法:

/**
     * 获取在线用户列表
     * @return
     */
    public static List<String> getOnlineUsers() {
        if (scctMap.isEmpty()) {
            return Collections.emptyList();
        }

        List<String> resList = new ArrayList<>(scctMap.size());
        //遍历key集合[username]即可
        Set<String> keySet = scctMap.keySet();
        resList.addAll(keySet);
        return resList;
    }

ServerConnClientThread:

//请求在线用户列表处理
                if (MessageType.ONLINE_USER_LIST.getType().equals(msgType)) {
                    List<String> onlineUsers = ServerConnClientThreadManage.getOnlineUsers();
                    Message<List<String>> replyMsg = new Message<>();
                    replyMsg.setMsgType(MessageType.ONLINE_USER_LIST.getType());
                    replyMsg.setContent(onlineUsers);
                    //回复
                    oos = new ObjectOutputStream(socket.getOutputStream());
                    oos.writeObject(replyMsg);
                }
2.4.2.3 客户端代码实现

ClientUserService:

public void sendGetOnlineUsersMsg(String username) {
        Message onlineMsg = new Message();
        onlineMsg.setMsgType(MessageType.ONLINE_USER_LIST.getType());
        //从通讯线程获取socket
        ClientConnServerThread ccst = ClientConnServerThreadManage.getClientConnServerThread(username);
        if (null != ccst) {
            try {
                socket = ccst.getSocket();
                //发送消息
                ObjectOutputStream oos = new ObjectOutputStream(socket.getOutputStream());
                oos.writeObject(onlineMsg);
                //服务端回复后的逻辑在对应通讯线程中处理即可
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

ClientConnServerThread:

//在线用户列表
                if (MessageType.ONLINE_USER_LIST.getType().equals(msgType)) {
                    List<String> onlineUsers = (List<String>) serverMsg.getContent();
                    //打印在线用户列表
                    StringBuilder sb = new StringBuilder("当前在线用户列表: \n");
                    for (String onlineUser : onlineUsers) {
                        sb.append("\t - ").append(onlineUser).append("\n");
                    }
                    System.out.println(sb.toString());
                }

二级菜单:

 case "1":
                    clientUserService.sendGetOnlineUsersMsg(username);
                    TimeUnit.MILLISECONDS.sleep(500);
                    break;
2.4.2.4 在线用户功能测试

为了方便测试,再增加两个用户数据:
在这里插入图片描述
分别登录用户ikun、xiaoheizi-1、xiaoheizi-2,测试在线用户功能:
在这里插入图片描述
ikun请求在线用户列表:
在这里插入图片描述
xiaoheizi-1退出登录:
在这里插入图片描述
在这里插入图片描述
用户xiaoheizi-2退出登录:
在这里插入图片描述
在这里插入图片描述

2.4.3 用户私聊

2.4.3.1 功能分析

用户A要和用户B私聊,首先向服务端发送私聊消息请求,服务端接收到请求后,根据请求中的接收者信息查询对应用户的通讯线程,若接收消息的用户在线,则转发此条私聊消息(转发消息时使用的socket通道是接收者的socket,不是发送者的socket);若接收消息的用户离线,则可将此条消息存入到数据库表,方便后续进行相关离线消息的处理。

2.4.3.2 服务端代码实现

ServerConnClientThread:

//用户私聊处理
                if (MessageType.CHAT_USER.getType().equals(msgType)) {
                    //接收者
                    String getter = clientMsg.getGetter();
                    //接收者是否在线
                    ServerConnClientThread getterScct = ServerConnClientThreadManage.getServerConnClientThread(getter);
                    if (null == getterScct) {
                        //接收者离线 -> 离线消息处理
                    } else {
                        //接收者在线 -> 消息转发
                        Socket getterSocket = getterScct.getSocket();
                        //聊天内容
                        String chatContent = (String) clientMsg.getContent();
                        //发送时间
                        String sendTime = clientMsg.getSendTime();
                        //消息封装
                        Message<String> chatMsg = new Message<>();
                        chatMsg.setMsgType(MessageType.CHAT_USER.getType());
                        //发送人、聊天消息、发送时间
                        chatMsg.setSender(clientMsg.getSender());
                        chatMsg.setContent(chatContent);
                        chatMsg.setSendTime(sendTime);
                        //转发
                        oos = new ObjectOutputStream(getterSocket.getOutputStream());
                        oos.writeObject(chatMsg);
                    }
2.4.3.3 客户端代码实现

客户端聊天业务的处理封装在ClientChatService中:

public class ClientChatService {
    /**
     * 和服务端保持通讯的socket
     */
    private Socket socket;

    /**
     * 发送用户私聊请求
     * @param sender 发送者
     * @param getter 接收者
     * @param chatContent 聊天消息
     */
    public void sendChatUserMsg(String sender, String getter, String chatContent) {
        Message<String> chatMsg = new Message<>();
        chatMsg.setMsgType(MessageType.CHAT_USER.getType());
        //发送人
        chatMsg.setSender(sender);
        //接收人
        chatMsg.setGetter(getter);
        //聊天内容
        chatMsg.setContent(chatContent);
        //发送时间
        chatMsg.setSendTime(CommonUtil.getNowDate());
        //从通讯线程获取socket
        ClientConnServerThread ccst = ClientConnServerThreadManage.getClientConnServerThread(sender);
        if (null != ccst) {
            try {
                socket = ccst.getSocket();
                //发送消息
                ObjectOutputStream oos = new ObjectOutputStream(socket.getOutputStream());
                oos.writeObject(chatMsg);
                //服务端回复后的逻辑在对应通讯线程中处理即可
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

ClientConnServerThread:

//用户私聊消息
                if (MessageType.CHAT_USER.getType().equals(msgType)) {
                    //发送人
                    String sender = serverMsg.getSender();
                    //聊天内容
                    String chatContent = (String) serverMsg.getContent();
                    //发送时间
                    String sendTime = serverMsg.getSendTime();
                    StringBuilder sb = new StringBuilder();
                    sb.append("\n[").append(sendTime).append("] ")
                            .append(sender).append(" 对你说: ").append(chatContent)
                            .append("\n");
                    System.out.println(sb.toString());
                }

在客户端的私聊菜单界面,需要用户输入私聊的用户名和聊天内容。

 case "2":
                    System.out.print("请输入您要私聊的用户: ");
                    String getterUserName = ScannerUtil.readString(20);
                    System.out.print("请输入您要说的话: ");
                    String chatContent = ScannerUtil.readString(100);
                    clientChatService.sendChatUserMsg(username, getterUserName, chatContent);
                    System.out.println("你对 " + getterUserName + "说: " + chatContent);
                    break;
2.4.3.4 私聊功能测试

以用户ikun和用户xiaoheizi-1演示私聊功能。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

2.4.3.5 离线消息处理说明

本次项目不再编写离线消息处理部分的代码和内容。大致思路就是当用户为离线状态时,将此条聊天消息存放到一张离线消息表,当用户登录后,服务端可以专门启一个线程拉取该用户的离线消息,即从数据库表查询离线消息并转发,转发完成后删除对应的离线消息即可(思路很多,因人而异)。

2.4.4 用户群聊

2.4.4.1 功能分析

用户群聊是指当前登录用户向除自己之外的所有当前在线用户发送聊天消息,即从上述的一对一私聊发消息到一对多发送消息,服务端接收到用户群聊消息请求后,遍历所有在线用户(不包括当前用户)转发聊天消息即可。

2.4.4.2 服务端代码实现

ServerConnClientThread:

//群聊消息处理
                if (MessageType.CHAT_GROUP.getType().equals(msgType)) {
                    //发送人
                    String sender = clientMsg.getSender();
                    String sendTime = clientMsg.getSendTime();
                    //聊天内容
                    String chatContent = (String) clientMsg.getContent();
                    //消息转发 -> 当前在线用户
                    List<String> onlineUsers = ServerConnClientThreadManage.getOnlineUsers();
                    for (String onlineUser : onlineUsers) {
                        //排除自己
                        if (sender.equals(onlineUser)) {
                            continue;
                        }

                        ServerConnClientThread getterScct = ServerConnClientThreadManage.getServerConnClientThread(onlineUser);
                        Socket getterSocket = getterScct.getSocket();
                        //消息封装
                        Message<String> chatMsg = new Message<>();
                        chatMsg.setMsgType(MessageType.CHAT_GROUP.getType());
                        //发送人、聊天消息、发送时间
                        chatMsg.setSender(sender);
                        chatMsg.setContent(chatContent);
                        chatMsg.setSendTime(sendTime);
                        //转发
                        oos = new ObjectOutputStream(getterSocket.getOutputStream());
                        oos.writeObject(chatMsg);
                    }
                }
2.4.4.3 客户端代码实现

ClientChatService:

/**
     * 发送用户群聊请求
     * @param sender 发送者
     * @param chatContent 聊天消息
     */
    public void sendChatGroupMsg(String sender, String chatContent) {
        Message<String> chatMsg = new Message<>();
        chatMsg.setMsgType(MessageType.CHAT_GROUP.getType());
        //发送人
        chatMsg.setSender(sender);
        //聊天内容
        chatMsg.setContent(chatContent);
        //发送时间
        chatMsg.setSendTime(CommonUtil.getNowDate());
        //从通讯线程获取socket
        ClientConnServerThread ccst = ClientConnServerThreadManage.getClientConnServerThread(sender);
        if (null != ccst) {
            try {
                socket = ccst.getSocket();
                //发送消息
                ObjectOutputStream oos = new ObjectOutputStream(socket.getOutputStream());
                oos.writeObject(chatMsg);
                //服务端回复后的逻辑在对应通讯线程中处理即可
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

ClientConnServerThread:

//用户群聊消息
                if (MessageType.CHAT_GROUP.getType().equals(msgType)) {
                    //发送人
                    String sender = serverMsg.getSender();
                    //聊天内容
                    String chatContent = (String) serverMsg.getContent();
                    //发送时间
                    String sendTime = serverMsg.getSendTime();
                    StringBuilder sb = new StringBuilder();
                    sb.append("\n[").append(sendTime).append("] ")
                            .append(sender).append(" 对大伙说: ").append(chatContent)
                            .append("\n");
                    System.out.println(sb.toString());
                }

二级功能菜单:

case "3":
                    System.out.print("请输入您想对大伙说的话: ");
                    String groupChatContent = ScannerUtil.readString(100);
                    clientChatService.sendChatGroupMsg(username, groupChatContent);
                    System.out.println("你对大伙说: " + groupChatContent);
                    break;
2.4.4.4 群聊功能测试

登录三个用户:ikun、xiaoheizi-1、xiaoheizi-2测试群聊功能。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

2.4.5 文件发送

2.4.5.1 功能分析

用户A向用户B发送文件,服务端接收用户A的文件发送请求消息,获得对应的目标文件路径和文件字节数组然后向用户B转发该消息(如果用户B为离线状态,则按离线文件处理,同离线消息处理,文章中不涉及该部分的编码和设计),用户B根据目标文件路径和文件字节数组写入文件。服务端在这个过程中只负责转发,不负责对文件的读写操作!!!(现模拟用户A要将自己"D:\resources\srcDir"目录下的一张图片资源发送到用户B的存储目录"D:\resources\destDir\)。
在这里插入图片描述
在这里插入图片描述

2.4.5.2 服务端代码实现

为了方便处理文件资源的发送、接收等,首先需要对传输消息Message类做一个改造。

public class Message<T> implements Serializable {
    private static final long serialVersionUID = -8621512324130711419L;
    /**
     * 发送者
     */
    private String sender;
    /**
     * 接收者
     */
    private String getter;
    /**
     * 消息内容
     */
    private T content;
    /**
     * 发送时间
     */
    private String sendTime;
    /**
     * 消息类型
     */
    private String msgType;
    /**
     * 存储数据的字节数组
     */
    private byte[] bytes;
    /**
     * 文件源路径
     */
    private String srcPath;
    /**
     * 文件目标路径
     */
    private String destPath;
    //省略

ServerConnClientThread:

//文件转发处理
                if (MessageType.FILE_SEND.getType().equals(msgType)) {
                    //文件源路径
                    String srcPath = clientMsg.getSrcPath();
                    //接收人
                    String getter = clientMsg.getGetter();
                    //接收人是否在线
                    ServerConnClientThread getterScct = ServerConnClientThreadManage.getServerConnClientThread(getter);
                    if (null == getterScct) {
                        //离线
                        System.out.println("离线文件发送处理");
                    } else {
                        //在线 -> 转发
                        Message fileMsg = new Message();
                        fileMsg.setMsgType(MessageType.FILE_SEND.getType());
                        fileMsg.setSender(clientMsg.getSender());
                        fileMsg.setSendTime(clientMsg.getSendTime());
                        fileMsg.setSrcPath(clientMsg.getSrcPath());
                        //文件字节数组
                        fileMsg.setBytes(clientMsg.getBytes());
                        //文件存储路径
                        fileMsg.setDestPath(clientMsg.getDestPath());
                        oos = new ObjectOutputStream(getterScct.socket.getOutputStream());
                        oos.writeObject(fileMsg);
                    }
                }
2.5.4.3 客户端代码实现

文件发送消息请求封装在ClientFileService类:

public class ClientFileService {
    /**
     * 和服务端保持通讯的socket
     */
    private Socket socket;

    /**
     * 发送用户文件消息请求
     *
     * @param sender
     * @param getter
     * @param srcPath
     * @param destPath
     */
    public void sendFileMsg(String sender, String getter, String srcPath, String destPath) {
        Message fileSendMsg = new Message();
        //发送人
        fileSendMsg.setSender(sender);
        //接收人
        fileSendMsg.setGetter(getter);
        //发送时间
        fileSendMsg.setSendTime(CommonUtil.getNowDate());
        //源路径
        fileSendMsg.setSrcPath(srcPath);
        //目标路径
        fileSendMsg.setDestPath(destPath);
        BufferedInputStream bis = null;
        try {
            //10M
            byte[] bytes = new byte[ChatConstant._10M];
            //文件读操作
            bis = new BufferedInputStream(new FileInputStream(new File(srcPath)));
            bis.read(bytes, 0, bytes.length);
            //文件字节数据
            fileSendMsg.setBytes(bytes);
            //从通讯线程获取socket
            ClientConnServerThread ccst = ClientConnServerThreadManage.getClientConnServerThread(sender);
            if (null != ccst) {
                socket = ccst.getSocket();
                //发送消息
                ObjectOutputStream oos = new ObjectOutputStream(socket.getOutputStream());
                oos.writeObject(fileSendMsg);
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            //资源释放
            if (null != bis) {
                try {
                    bis.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

ClientConnServerThread:

//用户文件消息
                if (MessageType.FILE_SEND.getType().equals(msgType)) {
                    String sender = serverMsg.getSender();
                    String sendTime = serverMsg.getSendTime();
                    String srcPath = serverMsg.getSrcPath();
                    //目标路径
                    String destPath = serverMsg.getDestPath();
                    //文件字节数组
                    byte[] fileBytes = serverMsg.getBytes();
                    //文件写操作
                    BufferedOutputStream bos = null;
                    bos = new BufferedOutputStream(new FileOutputStream(new File(destPath)));
                    bos.write(fileBytes, 0, fileBytes.length);
                    //--消息打印
                    StringBuilder sb = new StringBuilder();
                    sb.append("\n[").append(sendTime).append("] ")
                            .append("用户: ").append(sender).append(" 发送文件(")
                            .append(srcPath).append(")").append(" 到我的目录: ")
                            .append(destPath);
                    System.out.println(sb.toString());
                    //资源释放
                    bos.close();
                }

二级菜单文件发送时需要用户输入接收人、文件源路径、文件目标存储路径信息:

case "4":
                    System.out.print("请输入文件接收用户: ");
                    String fileGetter = ScannerUtil.readString(20);
                    System.out.print("请输入要发送的文件路径(格式: d:\\xx\\xx.jpg): ");
                    String srcPath = ScannerUtil.readString(50);
                    System.out.print("请输入接收用户的文件存储路径(格式: d:\\xx\\xx.jpg): ");
                    String destPath = ScannerUtil.readString(50);
                    clientFileService.sendFileMsg(username, fileGetter, srcPath, destPath);
                    System.out.println("您发送了文件: " + srcPath + " 到用户[" + fileGetter +"] 的存储目录: " + destPath);
                    break;
2.5.4.4 文件发送功能测试

登录用户ikun、xiaoheizi-1来测试文件发送,用户ikun将文件d:\resources\srcDir\9527.jpg图片文件发送到用户xiaoheizi-1的存储路径d:\resources\destDir\6666.jpg。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
查看目标存储目录:
在这里插入图片描述

2.4.6 服务端推送新闻

2.4.6.1 功能分析

服务端发布公告消息,推送给所有在线用户(类似群聊中的消息群发,只不过是服务端主动推送消息),可以专门设置一个线程来做推送公告处理。

2.4.6.2 服务端代码实现

服务端公告推送线程ServerNewsThread:

public class ServerNewsThread extends Thread {
    @Override
    public void run() {
    	System.out.println("服务端公告推送线程启动~");
        try {
            while (true) {
                System.out.print("请输入您要推送的公告消息[exit: 退出公告推送]: ");
                String serverNews = ScannerUtil.readString(100);
                if ("exit".equals(serverNews)) {
                    System.out.println("服务端停止公告推送~");
                    break;
                }

                //向在线用户转发
                List<String> onlineUsers = ServerConnClientThreadManage.getOnlineUsers();
                for (String onlineUser : onlineUsers) {
                    Message<String> serverNewsMsg = new Message<>();
                    serverNewsMsg.setMsgType(MessageType.SERVER_NEWS.getType());
                    serverNewsMsg.setContent(serverNews);
                    //获得socket通道
                    ServerConnClientThread scct = ServerConnClientThreadManage.getServerConnClientThread(onlineUser);
                    Socket socket = scct.getSocket();
                    ObjectOutputStream oos = new ObjectOutputStream(socket.getOutputStream());
                    oos.writeObject(serverNewsMsg);
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

在服务端启动的同时启动该公告推送线程:

//省略
 /**
     * 服务端初始化
     */
    public ChatServer() {
        try {
            serverSocket = new ServerSocket(ChatConstant.SERVER_PORT);
            System.out.println("服务器在9999端口监听~");
            //启动公告推送线程
            new ServerNewsThread().start();
            while (true) {
                //等待客户端连接 => 连接成功后获得交互的socket
                Socket socket = serverSocket.accept();
                //省略
2.4.6.3 客户端代码实现

ClientConnServerThread:

//服务端公告消息
                if (MessageType.SERVER_NEWS.getType().equals(msgType)) {
                    //公告消息
                    String news = (String) serverMsg.getContent();
                    String sendTime = serverMsg.getSendTime();
                    StringBuilder sb = new StringBuilder();
                    sb.append("\n").append(sendTime).append("<=========== ")
                            .append("系统公告【全体注意!!!】").append(" ===========>\n")
                            .append(news);
                    System.out.println(sb.toString());
                }
2.4.6.4 公告推送测试

登录用户ikun、xiaoheizi-1、xiaoheizi-2测试公告推送功能。
在这里插入图片描述
在这里插入图片描述
查看在线用户接收公告情况:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
服务端输入"exit"即可停止公告推送:
在这里插入图片描述

源码压缩包


总结

这是博主第一次尝试写博客文章,文章结构和语言组织等可能稍显混乱,加上博主Java方面的知识水平依旧尚浅,可能在功能分析、代码逻辑等诸多地方存在不足,还望各位看官多多包涵。本以为写博客也不会太累,但是实际写起来还是有点累的(┭┮﹏┭┮),不管写的好还是不好,创作的过程也是思维锻炼的一个过程,也算是略有收获吧。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值