仓库地址
前端搭建
导航栏的创建
首先,一个网页大多会有一个公用的导航栏,因此在bootstrap中复制一个NavBar的example,稍作修改,若处于未登录状态,则右端显示登录,否则显示当前登录用户的用户名。导航栏的Vue代码如下:
<template>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<router-link class="navbar-brand" :to="{name: 'chat_index'}">聊天室</router-link>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarText" aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarText">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<router-link class="nav-link" aria-current="page" :to="{name:'chat_index'}">聊天</router-link>
</li>
</ul>
<ul class="nav-item" v-if="$store.state.user.nickname !== ''">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle navbar-text" href="#" role="button" data-bs-toggle="dropdown">
{{ $store.state.user.nickname }}
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" @click="logout">退出登录</a></li>
</ul>
</li>
</ul>
<span class="navbar-text" v-if="$store.state.user.nickname === null || $store.state.user.nickname === ''">
登录
</span>
</div>
</div>
</nav>
</template>
<script>
import { useStore } from 'vuex';
import $ from 'jquery';
export default {
setup() {
const store = useStore();
const logout = () => {
$.ajax({
url: "http://localhost:8081/logout",
type: "get",
success(resp) {
if(resp.state === "success") {
store.commit("updateUser",{ userId: -1,username: "",nickname: "" });
localStorage.clear();
window.location.href = "http://localhost:8080/login/";
}
}
});
};
return {
logout,
}
}
}
</script>
导航栏样式如下所示(已登录状态):
导航栏样式如下所示(未登录状态):
如果用户已经登录,那么点击右边的用户名之后会弹出一个下拉框,可以退出登录。
要实现这个效果,可以用过vuex中的store实现,当用户登录之后后端会向前端返回已登录的状态存到session和localstorage里面,在vuex中从localstorage中取出这个bool值,当为true时显示用户名,否则显示登录按键。
实现这部分功能的vue代码如下:
<ul class="nav-item" v-if="$store.state.user.nickname !== ''">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle navbar-text" href="#" role="button" data-bs-toggle="dropdown">
{{ $store.state.user.nickname }}
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" @click="logout">退出登录</a></li>
</ul>
</li>
</ul>
<span class="navbar-text" v-if="$store.state.user.nickname === null || $store.state.user.nickname === ''">
登录
</span>
import { createStore } from 'vuex';
export default createStore({
state: {
login: localStorage.getItem("isLogin"),
userId: localStorage.getItem("userId"),
user: {
username: "",
nickname: ""
}
},
getters: {
},
mutations: {
updateLogin(state, login) {
state.login = login;
},
updateUser(state, user) {
state.user.username = user.username;
state.user.nickname = user.nickname;
}
},
actions: {
},
modules: {
}
})
登录界面的创建
接着使用bootstrap创建登录界面,首先创建一个card布局,将前端划分为一个个小的卡片,这样会显得稍微好看一些,因此,外面可以提取出一个公共的组件:ContentField。ContentField的vue代码如下:
<template>
<div class="container content-field">
<div class="card">
<div class="card-body">
<slot></slot>
</div>
</div>
</div>
</template>
<script>
export default {
setup() {
},
}
</script>
<style scoped>
div.content-field {
margin-top: 20px;
}
</style>
创建公共组件之后,可以开始创建登录界面了,首先也是在bootstrap官网上,找到一个关于Form的example接着用我们刚刚创建的组件ContentField将其包裹,也就是说,这个登录界面是一个新的小卡片。vue代码如下:
<template>
<ContentField>
<div class="mb-3">
<label class="form-label">用户名</label>
<input v-model="username" type="text" class="form-control" name="username">
</div>
<div class="mb-3">
<label for="exampleInputPassword1" class="form-label">密码</label>
<input v-model="password" type="password" class="form-control" name="password" id="exampleInputPassword1">
</div>
<span style="color: red;">{{ message }}</span>
<br/>
<button @click="login" class="btn btn-primary">登录</button>
</ContentField>
</template>
可以注意到,我们登录的这个button会绑定上一个名为login的方法,这个方法会向后端发送ajax请求,实现登录,代码如下:
<script>
import ContentField from '@/components/ContentField.vue';
import { ref } from 'vue';
import $ from 'jquery';
export default {
setup() {
// const store = useStore();
let message = ref("");
let username = ref("");
let password = ref("");
const login = () => {
$.ajax({
url: "http://localhost:8081/login",
type: "post",
data:{
username: username.value,
password: password.value
},
success(resp) {
if(resp.state === "fail") {
message.value = "用户名或密码错误";
} else {
localStorage.setItem("isLogin",true);
localStorage.setItem("userId",resp.user.id);
window.location.href = "http://localhost:8080/chat/";
}
}
});
};
return {
message,
username,
password,
login
}
},
components: {
ContentField,
}
}
</script>
可以看到,前端后向后端发送请求,请求的路径为http://localhost:8081/login,后端经过登录校验之后会向前端发送登录状态,将登录状态设置为true,并且将用户的信息返回给前端,将用户的id保存到localstorage,最后跳转至聊天界面。
实现聊天界面
首先在element-plus中寻找合适的example,最后选择了以一个表格的形式作为聊天界面,聊天界面如下:
vue代码如下:
<template >
<el-container class="layout-container-demo" style="height: 500px">
<el-aside width="200px">
<el-scrollbar>
<el-table :data="userList">
<el-table-column prop="nickname" label="用户列表" width="150" />
</el-table>
</el-scrollbar>
</el-aside>
<el-container>
<el-header style="text-align: right; font-size: 12px">
<div style="text-align: center;">
<span style="font-size:30px">聊天室</span>
</div>
</el-header>
<el-main>
<el-scrollbar>
<el-table :data="tableData">
<el-table-column prop="date" label="时间" width="160" />
<el-table-column prop="name" label="发送者 " width="120" />
<el-table-column prop="message" label="消息" />
</el-table>
</el-scrollbar>
</el-main>
<el-footer>
<textarea class="form-control" v-model="textarea" @keydown="sendMsg" aria-label="With textarea"></textarea>
</el-footer>
</el-container>
</el-container>
</template>
<script>
import { onMounted, onUnmounted, ref } from 'vue'
import $ from "jquery";
import { useStore } from 'vuex';
const debounce = (fn, delay) => {
let timer = null;
return function () {
let context = this;
let args = arguments;
clearTimeout(timer);
timer = setTimeout(function () {
fn.apply(context, args);
}, delay);
};
};
// 解决ERROR ResizeObserver loop completed with undelivered notifications.
const _ResizeObserver = window.ResizeObserver;
window.ResizeObserver = class ResizeObserver extends _ResizeObserver {
constructor(callback) {
callback = debounce(callback, 16);
super(callback);
}
};
export default {
setup() {
const store = useStore();
onMounted(() => {
$.ajax({
url: "http://localhost:8081/getinfo",
type: "get",
data: {
id: store.state.userId,
},
success(resp) {
store.commit("updateUser", { username: resp.username, nickname: resp.nickname });
}
});
});
const socketUrl = `ws://localhost:8081/websocket/${store.state.userId}/`;
let socket = new WebSocket(socketUrl);
socket.onopen = () => {
console.log("connect!");
};
onUnmounted(() => {
socket.close();
});
const sendMsg = event => {
if (event.shiftKey && event.keyCode === 13) {
document.execCommand('insertLineBreak'); // 换行
event.preventDefault();
return false;
} else if (event.keyCode === 13) { // 回车键
console.log("回车发送");
socket.send(JSON.stringify({
msg: textarea.value,
}));
textarea.value = "";
event.preventDefault();
return false;
}
};
let user = [];
console.log(user);
const textarea = ref("");
let dataList = [];
const tableData = ref(dataList);
const userList = ref(user);
$.ajax({
url: "http://localhost:8081/getUserList",
type: "get",
success(resp) {
for (let i = 0; i < resp.length; ++i) {
userList.value.push({ "nickname": resp[i].nickname, "username": resp[i].username });
}
}
});
$.ajax({
url: "http://localhost:8081/getHistory",
type: "get",
success(resp) {
for (let i = 0; i < resp.length; ++i) {
tableData.value.push({ "date": resp[i].date, "name": resp[i].name ,"message": resp[i].message});
}
}
});
socket.onmessage = msg => {
console.log("receive message" + msg.data);
const data = JSON.parse(msg.data);
tableData.value.push(data);
};
return {
tableData,
textarea,
userList,
sendMsg,
};
},
}
</script>
<style scoped>
.layout-container-demo .el-header {
position: relative;
background-color: var(--el-color-primary-light-7);
color: var(--el-text-color-primary);
}
.layout-container-demo .el-aside {
color: var(--el-text-color-primary);
background: var(--el-color-primary-light-8);
}
.layout-container-demo .el-menu {
border-right: none;
}
.layout-container-demo .el-main {
padding: 0;
}
.layout-container-demo .toolbar {
display: inline-flex;
align-items: center;
justify-content: center;
height: 100%;
right: 20px;
}
</style>
其中template和style没什么好说的,基本上都是官网的例子然后随便改改,主要是script中的逻辑部分,首先,当该组件被挂载完成后会向后端发送请求,获取当前用户的信息获取用户的昵称,并且获取当前用户列表以及历史记录:
onMounted(() => {
$.ajax({
url: "http://localhost:8081/getinfo",
type: "get",
data: {
id: store.state.userId,
},
success(resp) {
store.commit("updateUser", { username: resp.username, nickname: resp.nickname });
}
});
});
$.ajax({
url: "http://localhost:8081/getUserList",
type: "get",
success(resp) {
for (let i = 0; i < resp.length; ++i) {
userList.value.push({ "nickname": resp[i].nickname, "username": resp[i].username });
}
}
});
$.ajax({
url: "http://localhost:8081/getHistory",
type: "get",
success(resp) {
for (let i = 0; i < resp.length; ++i) {
tableData.value.push({ "date": resp[i].date, "name": resp[i].name ,"message": resp[i].message});
}
}
});
还有最重要的一步是打开websocket连接:
const socketUrl = `ws://localhost:8081/websocket/${store.state.userId}/`;
let socket = new WebSocket(socketUrl);
socket.onopen = () => {
console.log("connect!");
};
建立websocket连接之后,当后端有消息发送过来时,以下方法会异步调用,处理后端接收到的消息。
socket.onmessage = msg => {
console.log("receive message" + msg.data);
const data = JSON.parse(msg.data);
tableData.value.push(data);
};
处理十分简单,后端按照指定的json格式将需要的数据发送给了前端,前端可以直接将数据放到表格中。形成一条新的聊天记录:
当用户在聊天消息框中输入消息后,按下Enter键之后,可以发送消息,如果按下Shift + Enter,可以换行。
const sendMsg = event => {
if (event.shiftKey && event.keyCode === 13) {
document.execCommand('insertLineBreak'); // 换行
event.preventDefault();
return false;
} else if (event.keyCode === 13) { // 回车键
console.log("回车发送");
socket.send(JSON.stringify({
msg: textarea.value,
}));
textarea.value = "";
event.preventDefault();
return false;
}
};
最后,当组件卸载时,会将websocket连接关闭:
onUnmounted(() => {
socket.close();
});
后端搭建
搭建环境
首先在idea中创建maven工程,接着导入依赖,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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.12</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.ljx</groupId>
<artifactId>chat</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>chat-backend</name>
<description>chat-backend</description>
<properties>
<java.version>11</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.3.1</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.30</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-generator</artifactId>
<version>3.5.3</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.2</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.76</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
<repositories>
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
<repository>
<id>spring-snapshots</id>
<name>Spring Snapshots</name>
<url>https://repo.spring.io/snapshot</url>
<releases>
<enabled>false</enabled>
</releases>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</pluginRepository>
<pluginRepository>
<id>spring-snapshots</id>
<name>Spring Snapshots</name>
<url>https://repo.spring.io/snapshot</url>
<releases>
<enabled>false</enabled>
</releases>
</pluginRepository>
</pluginRepositories>
</project>
然后创建WebSocket的配置类
@Configuration
public class WebSocketConfig {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
创建配置文件,写入数据库连接信息和后端端口号:
server.port=8081
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.username=root
spring.datasource.password=root
spring.datasource.url=jdbc:mysql://localhost:3306/chat?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8
实现查询历史记录
创建HistoryController,查询数据库,并且将日期格式化即可。
@CrossOrigin
@RestController
public class HistoryController {
@Autowired
private HistoryMapper historyMapper;
@Autowired
private UserMapper userMapper;
@GetMapping("getHistory")
public List getHistory() {
ArrayList<Dto> list = new ArrayList<>();
List<History> historyList = historyMapper.selectList(null);
for(int i = 0;i < historyList.size();i++) {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
String date = sdf.format(historyList.get(i).getSendTime());
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
queryWrapper.select("nickname").eq("id",historyList.get(i).getUserId());
User user = userMapper.selectOne(queryWrapper);
String name = user.getNickname();
String message = historyList.get(i).getMessage();
Dto dto = new Dto(name,message,date);
list.add(dto);
}
return list;
}
}
实现用户相关操作
在UserController下实现四个接口,分别是登录,登出,根据id获取信息用户信息以及获取用户列表接口。这里功能比较复杂因此放在service中实现。Controller只完成前端发送来的数据的解析操作。
@CrossOrigin
@RestController
public class UserController {
@Autowired
private UserService userService;
@PostMapping("login")
public Map login(HttpServletRequest request, @RequestParam Map<String,String> data) {
User user = new User();
user.setUsername(data.get("username"));
user.setPassword(data.get("password"));
System.out.println(user);
return userService.login(request,user);
}
@GetMapping("logout")
public Map logout(HttpServletRequest request) {
request.getSession().removeAttribute("login");
Map<String,String> result = new HashMap<>();
result.put("state","success");
return result;
}
@GetMapping("getinfo")
public Map getinfo(@RequestParam Integer id) {
return userService.getinfo(id);
}
@GetMapping("getUserList")
public List<User> getUserList() {
return userService.getUserList();
}
}
具体逻辑在service中:
@Service
public class UserServiceImpl implements UserService {
@Autowired
private UserMapper userMapper;
@Override
public Map login(HttpServletRequest request, User user) {
Map<String,Object> result = new HashMap<>();
String username = user.getUsername();
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getUsername, user.getUsername()).eq(User::getPassword, user.getPassword());
User user1 = userMapper.selectOne(wrapper);
if(user1 == null) {
result.put("state","fail");
System.out.println("失败!");
return result;
}
request.getSession().setAttribute("login", true);
result.put("state","success");
user1.setPassword("");
result.put("user",user1);
System.out.println("成功");
return result;
}
@Override
public Map getinfo(Integer id) {
User user = userMapper.selectById(id);
Map result = new HashMap();
result.put("username",user.getUsername());
result.put("nickname",user.getNickname());
return result;
}
@Override
public List<User> getUserList() {
List<User> users = userMapper.selectList(null);
users.forEach(user -> user.setPassword(""));
return users;
}
}
WebSocket实现群聊功能
在该类下,加了@OnOpen注解的方法是有连接建立之后调用的方法,当连接断开调用@OnCloss注解的方法,当后端发送消息,则调用加了@OnMessage注解的方法。逻辑十分清晰,因此不做过多解释,直接阅读代码即可。
@Component
@ServerEndpoint("/websocket/{userId}")
public class WebSocket {
private Session session = null;
private Integer userId;
private static HistoryMapper historyMapper;
@Autowired
public void setHistoryMapper(HistoryMapper historyMapper) {
WebSocket.historyMapper = historyMapper;
}
private static UserMapper userMapper;
@Autowired
public void setUserMapper(UserMapper userMapper) {
WebSocket.userMapper = userMapper;
}
final private static CopyOnWriteArraySet<WebSocket> webSockets = new CopyOnWriteArraySet<>(); // 保存在线列表
@OnOpen
public void onOpen(Session session, @PathParam("userId") String userId) {
// 建立连接
this.session = session;
System.out.println("connect:" + userId);
this.userId = Integer.parseInt(userId);
webSockets.add(this);
}
@OnClose
public void onClose() {
// 关闭链接
System.out.println("disconnected:" + userId);
}
@OnMessage
public void onMessage(String message) { // 处理消息
JSONObject jsonObject = JSONObject.parseObject(message);
// System.out.println(message);
History history = new History();
history.setSendTime(new Date());
history.setUserId(userId);
history.setMessage(jsonObject.get("msg").toString());
System.out.println(history);
// 保存历史记录并且发消息
historyMapper.insert(history);
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
// System.out.println(sdf.format(historyMapper.selectById(4).getSend_time()));
JSONObject resp = new JSONObject();
resp.put("date",sdf.format(history.getSendTime()));
resp.put("name",userMapper.selectById(userId).getNickname());
resp.put("message",jsonObject.get("msg").toString());
sendAllMessage(resp.toJSONString());
}
private void sendAllMessage(String message) { // 群发消息
for(WebSocket webSocket : webSockets) {
try {
if(webSocket.session.isOpen()) {
webSocket.session.getAsyncRemote().sendText(message);
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
@OnError
public void onError(Session session, Throwable error) {
error.printStackTrace();
}
}
结语
这里向为本课程做出贡献的各路豪杰致以崇高敬意,没有他们的例子妄图跟上课程进度,所花费的时间只怕三国都能统一三次了(玩笑话)。但并不能因为能跑通现成的项目就心安理得或者沾沾自喜,以为掌握了这门技术;而应该仔细研读,以求温故知新,创出自己的版本来,这样才能达到这门课该有的效果。想必这也是孟宁老师用心良苦所希望的。
第一次选修这样独具匠心的课尚属首次,这种别开生面的授课方式也给我带来了以往所收获不到的惊喜。摒除了其他课程一言堂式的教学方式,每个同学可以自由而全面的发展,并能够感受不同思想的碰撞,颇有一种百家争鸣的盛世场面。大家各自繁荣,还能各取所长。但各自发展又不是任意的,这个时候就体现出老师引导的重要性:什么时候该做尝试,什么时候该做取舍,对整个项目的进展有着举足轻重的作用。毫无疑问,孟宁老师始终发挥着把控全局的作用:不至于分崩离析,又能够遍地开花;既能保证项目的前进方向,也能兼顾不同版本的依次更新。将艺高人胆大诠释的淋漓尽致。
然而分别终究是要来到了。但是课程的结束并不意味着工程的止步,这一个多月的成果,将推动我们朝着更高的要求前进。希望在以后的工作中,我们能够怀揣着网络程序设计带来的理念和精神,在网络程序设计的领域中乘万里风,破万里浪!