小凯的情感故事(一):用 Netty + Vue 帮他发出第一句“在干嘛“

故事叙述

我们的主人公 小凯。 小凯是一家公司的屌丝程序员,日常与代码为伴。这一天 公司来了个实习生 小雪,小雪年轻漂亮,让小凯心生倾慕,夜深人静时,小凯辗转难眠,终于鼓起勇气,想给小雪发一条消息:“在干嘛?”

那么我们来帮小凯实现这个愿望

一 、先来快速创建一个 vue 前端项目,给小凯提供一个发消息的页面

  1. 创建vue项目小连招
vue create my-project	//创建项目my-project
npm install vue-router //安装路由
npm run serve //启动项目
  1. 我们来用AI快速生成一个Chat.vue页面并设置主题 “可爱粉”,小凯已经着急了
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'

const route = useRoute()
const userId = ref('')
const messageInput = ref('')
const messages = ref([])

onMounted(() => {
  userId.value = route.query.id || ''
  if (!userId.value) {
    alert('请在URL中添加?id=1来标识用户身份')
  }
})

const sendMessage = () => {
  if (messageInput.value.trim()) {
    messages.value.push({
      id: Date.now(),
      text: messageInput.value,
      sender: userId.value,
      timestamp: new Date().toLocaleTimeString()
    })
    messageInput.value = ''
  }
}

const handleEnter = (e) => {
  if (e.key === 'Enter') {
    sendMessage()
  }
}
</script>

<template>
  <div class="chat-container">
    <div class="chat-header">
      <div class="header-info">
        <span class="chat-title">{{ userId }}</span>
      </div>

    </div>

    <div class="messages-area">
      <div v-for="msg in messages" :key="msg.id"
           :class="['message-item', msg.sender === userId ? 'message-self' : 'message-other']">
        <div class="message-bubble">
          <p class="message-text">{{ msg.text }}</p>
          <span class="message-time">{{ msg.timestamp }}</span>
        </div>
      </div>
    </div>



    <div class="input-area">
      <textarea
          v-model="messageInput"
          @keydown="handleEnter"
          placeholder="输入消息..."
          class="message-input"
          rows="3"
      ></textarea>
      <div class="send-section">
        <button class="close-btn">关闭</button>
        <div class="send-dropdown">
          <button class="send-btn" @click="sendMessage">发送</button>
        </div>
      </div>
    </div>
  </div>
</template>

<style scoped>
.chat-container {
  display: flex;
  flex-direction: column;
  width: 50vw;
  height: 50vh;
  background: linear-gradient(135deg, #ffe6f0 0%, #fff0f5 100%);
  font-family: 'Microsoft YaHei', sans-serif;
  border-radius: 16px;
  box-shadow: 0 8px 32px rgba(255, 143, 171, 0.25);
  overflow: hidden;
}
.chat-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 12px 20px;
  background: linear-gradient(to right, #ffc8dd, #ffb3c6);
  box-shadow: 0 2px 8px rgba(255, 182, 193, 0.3);
}

.header-info {
  display: flex;
  align-items: center;
  gap: 12px;
}

.chat-title {
  font-size: 18px;
  font-weight: bold;
  color: #8b4513;
}

.messages-area {
  flex: 1;
  overflow-y: auto;
  padding: 20px;
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.message-item {
  display: flex;
  margin-bottom: 8px;
}

.message-self {
  justify-content: flex-end;
}

.message-other {
  justify-content: flex-start;
}

.message-bubble {
  max-width: 60%;
  padding: 12px 16px;
  border-radius: 18px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  position: relative;
}

.message-self .message-bubble {
  background: linear-gradient(135deg, #ffb3c6, #ff8fab);
  color: white;
  border-bottom-right-radius: 4px;
}

.message-other .message-bubble {
  background: white;
  color: #333;
  border-bottom-left-radius: 4px;
}

.message-text {
  margin: 0 0 4px 0;
  font-size: 14px;
  line-height: 1.5;
  word-wrap: break-word;
}

.message-time {
  font-size: 11px;
  opacity: 0.7;
}

.input-area {
  padding: 16px 20px;
  background: rgba(255, 255, 255, 0.9);
  border-top: 2px solid rgba(255, 182, 193, 0.3);
}

.message-input {
  width: 100%;
  border: 2px solid #ffd1dc;
  border-radius: 12px;
  resize: none;
  font-size: 14px;
  font-family: inherit;
  outline: none;
  transition: all 0.3s;
  background: white;
}

.message-input:focus {
  border-color: #ffb3c6;
  box-shadow: 0 0 8px rgba(255, 179, 198, 0.4);
}

.send-section {
  display: flex;
  justify-content: flex-end;
  align-items: center;
  gap: 12px;
  margin-top: 12px;
}

.close-btn {
  padding: 10px 24px;
  background: white;
  border: 2px solid #ddd;
  border-radius: 8px;
  color: #666;
  font-size: 14px;
  cursor: pointer;
  transition: all 0.3s;
}

.close-btn:hover {
  background: #f5f5f5;
  border-color: #ccc;
}

.send-dropdown {
  display: flex;
  align-items: center;
}

.send-btn {
  padding: 10px 28px;
  background: linear-gradient(to right, #ffb3c6, #ff8fab);
  border: none;
  border-radius: 8px 0 0 8px;
  color: white;
  font-size: 14px;
  font-weight: bold;
  cursor: pointer;
  transition: all 0.3s;
}

.send-btn:hover {
  background: linear-gradient(to right, #ff8fab, #ff5d8f);
  transform: translateY(-1px);
  box-shadow: 0 4px 12px rgba(255, 143, 171, 0.4);
}

::-webkit-scrollbar {
  width: 8px;
}

::-webkit-scrollbar-track {
  background: rgba(255, 230, 240, 0.5);
}

::-webkit-scrollbar-thumb {
  background: #ffb3c6;
  border-radius: 4px;
}

::-webkit-scrollbar-thumb:hover {
  background: #ff8fab;
}
</style>

3.修改mian.js 添加路由

import { createApp } from 'vue'
import { createWebHistory } from 'vue-router'
import { createRouter } from 'vue-router'
import App from './App.vue'
import Chat from './components/Chat.vue'

const routes = [
    { path: '/Chat', component: Chat }
]

const router = createRouter({
    history: createWebHistory(),
    routes
})

const app = createApp(App)
app.use(router)
app.mount('#app')
  1. 修改 App.vue,引入路由试图,并调整一下样式
<template>
  <div id="app">
    <router-view />
  </div>
</template>

<script>
export default {
  name: 'App'
}
</script>

<style>
html, body {
  margin: 0;
  padding: 0;
  height: 100%;
}
#app {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 100%;
}
</style>

小凯发消息的页面已经搭好,我们来看看 ,http://localhost:8080/Chat?id=111
在这里插入图片描述

二、快速创建后端项目,让小凯能把消息发送给小雪

  1. 来个最新配置 小凯已经急的不行
    idea-> 新建-> 项目 ->SpringBoot-> maven->JDK21-> Springboot4.1.0
  2. 将来小凯可能要发更多消息给更多人 ,我们来引入通信神器 Netty
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>4.1.0</version>
    <relativePath/> <!-- lookup parent from repository -->
</parent>
<dependencies>
	<dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter</artifactId>
    </dependency>
	<dependency>
	     <groupId>io.netty</groupId>
	     <artifactId>netty-all</artifactId>
	 </dependency>
	<dependency>
	    <groupId>com.fasterxml.jackson.core</groupId>
	    <artifactId>jackson-databind</artifactId>
	    <version>2.15.2</version>
	</dependency>
</dependencies>
  1. Netty三大组件 Channel(通道)、EventLoop(事件循环)和 ChannelFuture(异步结果),来快速构建一个 NettyConfig.java
@Configuration
public class NettyConfig {
    @Value("${netty.port:8090}")
    private int port;

    //Boss 线程组
    private EventLoopGroup bossGroup;
    //Worker 线程组
    private EventLoopGroup workerGroup;
    //通道 Future
    private ChannelFuture channelFuture;

    //@PostConstruct:Spring 容器初始化完成后自动调用此方法
    @PostConstruct
    public void start() {
        new Thread(() -> {
            try {
                // 1个线程,专门接收连接
                bossGroup = new NioEventLoopGroup(1);
                // 默认线程数,处理读写业务 默认 CPU核心数×2
                workerGroup = new NioEventLoopGroup();

                //启动引导类
                ServerBootstrap bootstrap = new ServerBootstrap();
                //设置引导参数
                bootstrap.group(bossGroup, workerGroup) //绑定两个线程组
                        .channel(NioServerSocketChannel.class) // 使用 NIO 的 ServerSocket 通道
                        .option(ChannelOption.SO_BACKLOG, 128)  // 连接等待队列最大长度 128
                        .childOption(ChannelOption.SO_KEEPALIVE, true) // 保持长连接,定期探测
                        .childHandler(new ChannelInitializer<SocketChannel>() {
                            @Override
                            protected void initChannel(SocketChannel ch) {
                                // pipeline 是 Netty 的责任链,数据进来会依次经过 pipeline 中的每个 Handler。
                                // 每个新连接都加上自定义处理器
                                ch.pipeline().addLast(new NettyServerHandler());
                            }
                        });
                // 绑定端口,sync() 等待绑定完成
                channelFuture = bootstrap.bind(port).sync();
                System.out.println("Netty 服务器启动,端口: " + port);
                // 阻塞,直到通道关闭
                channelFuture.channel().closeFuture().sync();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }, "netty-server").start();
    }

    //@PreDestroy:Spring 容器销毁前自动调用。
    @PreDestroy
    public void stop() {
        if (channelFuture != null) {
            // 关闭通道
            channelFuture.channel().close();
        }
        if (bossGroup != null) {
            //优雅关闭 boss 线程组(处理完当前任务再停止)
            bossGroup.shutdownGracefully();
        }
        if (workerGroup != null) {
            //优雅关闭 worker 线程组(处理完当前任务再停止)
            workerGroup.shutdownGracefully();
        }
    }
}

4.创建一个 NettyServerHandler 处理器

public class NettyServerHandler extends SimpleChannelInboundHandler<String> {
    //客户端连接时触发
    @Override
    public void channelActive(ChannelHandlerContext ctx) {
        //打印客户端信息
        System.out.println("客户端连接: " + ctx.channel().remoteAddress());
    }
    //收到消息时触发
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, String msg) {
        System.out.println("收到消息: " + msg);
        //Unpooled 是 Netty 中一个专门用来创建和管理 ByteBuf 的工具类。
        //copiedBuffer 方法会创建一个包含给定数组内容的新的 ByteBuf。
        ByteBuf response = Unpooled.copiedBuffer("服务器已收到: " + msg, StandardCharsets.UTF_8);
        //写入缓冲区 刷出到网络
        ctx.writeAndFlush(response);
    }
    //发生异常时触发
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        cause.printStackTrace();
        ctx.close();
    }
}
  1. 增加配置application.yml
netty:
    port: 8129
  1. 启动项目
    在这里插入图片描述

三、快速实现前后端通信功能

  1. 先来优化一下Ai生成的聊天页面Chat.vue,让它显示名字,而并非id
import { ref, computed,onMounted }
// ... existing code ...
const userMap = {
  '1': '小凯',
  '2': '小雪'
}
const userName = computed(() => userMap[userId.value] || '未知用户')
// ... existing code ...
<span class="chat-title">{{ userName }}</span>
  1. http://localhost:8080/Chat?id=1,http://localhost:8080/Chat?id=2 小凯和小雪名字已经出来了
    在这里插入图片描述
  2. 连接 ws,以及各个ws各个状态操作
//目标用户id
const targetId = computed(() => userId.value === '1' ? '2' : '1')
//目标用户名字
const targetName = computed(() => userMap[targetId.value] || '对方')
const ws = ref(null)
const messagesArea = ref(null)
const connectWebSocket = () => {
  ws.value = new WebSocket('ws://localhost:8129/ws')
  ws.value.onopen = () => {
    console.log('WebSocket 连接成功')
    ws.value.send(JSON.stringify({
      type: 'register',
      userId: userId.value
    }))
  }

  ws.value.onmessage = (event) => {
    const data = JSON.parse(event.data)
    if (data.type === 'chat') {
      messages.value.push({
        id: Date.now(),
        text: data.content,
        sender: data.fromId,
        senderName: data.fromName,
        timestamp: new Date(data.timestamp).toLocaleTimeString()
      })
      scrollToBottom()
    } else if (data.type === 'system') {
      messages.value.push({
        id: Date.now(),
        text: data.content,
        sender: 'system',
        timestamp: new Date().toLocaleTimeString()
      })
      scrollToBottom()
    }
  }
  ws.value.onclose = () => {
    console.log('WebSocket 连接关闭')
  }

  ws.value.onerror = (err) => {
    console.error('WebSocket 错误:', err)
  }
  1. 增加一个滚动到消息列表底部的常用工具函数scrollToBottom,修改发送消息函数sendMessage
import { ref, computed,onMounted,nextTick } from 'vue'
// ... existing code ...
const scrollToBottom = async () => {
  await nextTick()
  if (messagesArea.value) {
    messagesArea.value.scrollTop = messagesArea.value.scrollHeight
  }
}
//发送消息
const sendMessage = () => {
  if (messageInput.value.trim() && ws.value && ws.value.readyState === WebSocket.OPEN) {
    const chatMessage = {
      type: 'chat',
      fromId: userId.value,
      fromName: userName.value,
      toId: targetId.value,
      content: messageInput.value
    }
    ws.value.send(JSON.stringify(chatMessage))

    messages.value.push({
      id: Date.now(),
      text: messageInput.value,
      sender: userId.value,
      timestamp: new Date().toLocaleTimeString()
    })
    messageInput.value = ''
    scrollToBottom()
  }
}

// ... existing code ...
<div class="messages-area" ref="messagesArea"> 
  1. 设置ws连接时机,以及优化 窗口
onMounted(() => {
  userId.value = route.query.id || ''
  if (!userId.value) {
    alert('请在URL中添加?id=1来标识用户身份')
  }else{
    connectWebSocket()
  }
})
// ... existing code ...
<span class="chat-title">{{ userName }}</span>
<span class="chat-subtitle">正在和 {{ targetName }} 聊天</span>
  1. 后端NettyConfig中加入三个处理器,并增加 userChannels 来保存 注册的用户信息
public static final ConcurrentHashMap<String, Channel> userChannels = new ConcurrentHashMap<>();
//... existing code ...
//将HTTP请求的字节流解码成HttpRequest对象,让后端能读到“小凯”发的消息内容。
ch.pipeline().addLast(new HttpServerCodec());
//将 HTTP 请求中的多个零散片段(如请求头和请求体可能被拆分多次到达)聚合成一个完整的 FullHttpRequest 对象,方便后续逻辑一次性处理所有信息。
ch.pipeline().addLast(new HttpObjectAggregator(65536));
// WebSocket 协议升级处理器:处理握手和帧编解码
ch.pipeline().addLast(new WebSocketServerProtocolHandler("/ws"));
  1. 修改 自定义处理器 的收到消息逻辑
protected void channelRead0(ChannelHandlerContext ctx, WebSocketFrame frame) {
        //先用 instanceof 判断是否为文本帧(TextWebSocketFrame)这里只处理文本消息
        if (frame instanceof TextWebSocketFrame textFrame) {
            try {
                //获取消息的纯文本内容(JSON 格式)
                String text = textFrame.text();
                // 使用 Jackson 的 ObjectMapper 将 JSON 字符串解析为 JsonNode 树节点。
                JsonNode jsonNode = objectMapper.readTree(text);
                //从 JSON 中提取 type 字段,用于后续路由分发
                String type = jsonNode.path("type").asText();

                if ("register".equals(type)) {
                    currentUserId = jsonNode.path("userId").asText();
                    NettyConfig.userChannels.put(currentUserId, ctx.channel());
                    System.out.println("用户注册: " + currentUserId);
                    return;
                }

                if ("chat".equals(type)) {
                    String fromId = jsonNode.path("fromId").asText();
                    String toId = jsonNode.path("toId").asText();
                    String content = jsonNode.path("content").asText();
                    String fromName = jsonNode.path("fromName").asText();

                    String message = objectMapper.writeValueAsString(Map.of(
                            "type", "chat",
                            "fromId", fromId,
                            "fromName", fromName,
                            "content", content,
                            "timestamp", System.currentTimeMillis()
                    ));
                    //查找目标用户连接
                    Channel targetChannel = NettyConfig.userChannels.get(toId);
                    //如果目标用户在线,则发送消息
                    if (targetChannel != null && targetChannel.isActive()) {
                        //发送消息给目标用户
                        targetChannel.writeAndFlush(new TextWebSocketFrame(message));
                        System.out.println("消息转发: " + fromId + " -> " + toId + " : " + content);
                    } else {
                        //如果目标用户不在线,则返回系统消息给发送者
                        ctx.channel().writeAndFlush(new TextWebSocketFrame(
                                objectMapper.writeValueAsString(Map.of(
                                        "type", "system",
                                        "content", "对方不在线"
                                ))
                        ));
                    }
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

整体逻辑梳理

  1. 小凯打开 http://localhost:8080/Chat?id=1,前端通过 WebSocket 连接 ws://localhost:8129/ws,发送 register 消息注册 userId=1
  2. 小雪打开 http://localhost:8080/Chat?id=2,同样连接并注册 userId=2
  3. 小凯发消息时,前端发送 JSON {type:“chat”, fromId:“1”, fromName:“小凯”, toId:“2”, content:“在干嘛”}
  4. Netty 服务端收到后,查找 userId=2 对应的 Channel,将消息转发给小雪
  5. 小雪的前端收到消息后显示在聊天窗口,小雪也可以同样方式回复给小凯

好,迫不及待的小凯 终于可以 给小雪 发消息了!!
在这里插入图片描述
在这里插入图片描述

小凯发送消息之后,终于美滋滋的进入了梦乡!!!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

凯凯凯凯神

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值