使用socket实现一个小型的聊天程序

263 阅读5分钟

最近在学习使用kotlin,然后试了试用socket实现一个聊天通信功能。然后发现其实kotlin用的很多库都是JAVA中已经存在的。所以更好的使用kotlin不仅要明白kotlin相对于java的优秀特性和他的跨平台能力,还需要对java也有深度的理解。

build.gradle.kts配置文件中配置你所需要的插件和依赖。

  • 插件

    plugins {
      ···
      id("com.github.johnrengelman.shadow") version "7.1.0"
      ···
    }
    

    这个shadowJar插件可以编写的帮你打包你的项目所需要的所有依赖。同时你也可以自己编辑那个是主类,以及不包括那些类,和打包后的名字。

    相比于spring自带的bootjar。当你不使用spring时,可能会遇到相关的打包失败问题:如“无法找到主元素清单”等。因此这个插件可以很好的帮助你完成jar包的一键配置

  • 依赖

    dependencies {
      testImplementation(kotlin("test"))
      implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1")
      // https://mvnrepository.com/artifact/cn.hutool/hutool-json
      implementation("cn.hutool:hutool-json:5.8.20")
    }
    

    这里用到了kotlin的协程库和hutool的json序列化工具。
    因为我们的聊天程序是可以多用户一起聊天的,所以需要用到多线程,让每个socket连接都可以单独运作。而json序列化则方便将具体类两边传输后,可以方便的获取其中的元素。

client

  1. 主程序

    fun main(): Unit = runBlocking {
     login()
     checkInit()
     var job = GlobalScope.launch { withContext(Dispatchers.IO) { handleServer() } }
     launch { withContext(Dispatchers.IO) { view(job) } }
    }
    

    其中使用withContext(Dispatchers.IO)方法从线程池里拉起一个新的线程,各线程可以相互独立运行。之间不会相互影响。
    全局定义一个变量,

  2. login()方法
    与socket服务器进行连接,并将输入输出流存储到client单例中。

  3. checkInit()
    用于完成连接后,需要输入一个昵称用作聊天,这个昵称将会被发送到服务器并进行存储,同时也会把这昵称保存在client的单例中。

  4. handleServer()
    循环接受来自服务器的输入流,如果没有读取到的话,就会阻塞,知道接收到消息才开始启动。

  5. view()
    用于显示主体界面,用于输入命令,显示框架。

    server

    fun main(): Unit = runBlocking {
     println("服务器已经开启")
     launch {
         while (true) {
             server.apply {
                 println("等待连接")
                 val client = withContext(Dispatchers.IO) { server.serversocket.accept() }
                 
                 println("有新用户连接")
                 launch {
                     withContext(Dispatchers.IO) { handleClient(client) }
                 }
              }
         }
     }
    }
    

    他拉起了一个协程,并在协程中拉起一个线程,用于时刻等待是否有新的客户端进行,如果有就接受,并且将这个socket拉起一个新线程并且保持运行。

  6. handleClient(client)
    用于接受客户端的命令,并且保持阻塞状态等待命令的发送。

探究细节

  1. 协程与线程的区别
    协程与线程主要区别是它将不再被内核调度,而是交给了程序自己而线程是将自己交给内核调度定义:协程是轻量级线程。 在一个用户线程上可以跑多个协程,这样就提高了单核的利用率。
    优点:协程占用的资源更少,切换协程时,所需要进行更替而交换的上下文也更少,可以提高单核的利用率。
    缺点:协程是处于一个线程中,系统是无感知的,所以需要在该线程中阻塞某个协程的话,就需要手工进行调度。
    就我目前的知识储备,对于会阻塞的程序,我只能通过调用多线程的方法实现
    例如,在程序中,kotlin提供了从线程池中获取一个线程的方法withContext(Dispatchers.IO){执行程序},因为输入流的readLine(),serversocket的accept()方法都是阻塞的,如果使用协程运行他们的话,他们会直接阻塞住所有的程序,所以我在客户端和服务端都选择用withContext(Dispatchers.IO)去拉起一个新线程。

  2. 输入流和输出流的接受和阻塞
    在socket通信中,输入流和输出流都会一致保持开启状态,如果关闭了输入输出流,就会无法重新打开,只能重新启动一个socket连接。而当你进行文字传输时,常用的的方式是使用bufferwriter进行读写操作,kotlin通过扩展函数的方法实现便携的把字节输入输出流转化为缓冲字符输入输出流。

  3. 如果使用bufferreader的readLine方法进行读取操作,那么他需要接受到一个\n以判断读入结束,然后他就会把读入到的内容存到变量中。否则他就会一直读入,处于阻塞状态。这就是为什么我的很多次写入操作都手动加入了"\n"用来表示结束读入符。

  4. 如果你读入文件时,常用的读取方法是

    while (inputStream.read(buffer).also { bytesRead = it } != -1) {
     
    }
    

    可是这样子的判断方法是只适用于读取完之后,直接关闭输入输出流,才会读到-1。所以这里可以使用其他的方法,比如说,提前获取他能读取的最大读入值,available()方法可以用于获取可能读取到的最大字节数。你可以通过这个提前获取最大量,然后按量读取,读取完后就结束。例如我程序中的操作:

    do {
     var len: Int = client.input.read(bytes)
     if (len != -1) {
         it.write(bytes, 0, len)
         i += len
         println("已完成${i * 1.0 / fileSize}")
     }
    } while (len != -1 && i < fileSize)
    

    我这里的操作是类似的,通过先确定文件大小fileSize的方式来读取,当读入完成后就结束。
    但是,有一些问题需要提及
    在 Java 和 Kotlin 中,available() 方法返回的是输入流中当前可读取的字节数量,而不是限制字节数量的大小。
    然而,需要注意的是,available() 方法的返回值并不一定代表整个输入流中的可用字节数量,也不保证一次读取就能读取到该数量的字节。它只是表示当前时刻下可以无阻塞地读取的字节数。
    所以我在程序中,事先把文件大小发过去的。

  5. 我这里的操作是因为我用的是字节数组读入的方法。如果你使用一个字节一个字节的读入。那么你也可以设置一个结束的关键词,就像readLine()一样用来结束输入。

  6. 粘包问题
    如果两次写入socket流时间太接近,他可能会连着一起发送,导致数据无法正确接受。这也就是我在源码中多次使用delay()方法延迟接收的原因。

项目代码在github仓库中,客户端,服务端

本文使用 文章同步助手 同步