五子棋双人对战项目(3)——匹配模块

目录

一、分析需求

二、约定前后端交互接口

匹配请求:

匹配响应:

三、实现游戏大厅页面(前端代码)

game_hall.html:

common.css:

game_hall.css:

四、实现后端代码

WebSocketConfig

MatchAPI(处理请求、返回响应)

MatchResponse(响应)

[MatchRequest (请求)](#MatchRequest (请求))

OnlineUserManager(用户在线状态)

Matcher(匹配器)

Room(游戏房间)

RoomManager(房间管理器)

五、线程安全问题

[1、HashMap 和 多开](#1、HashMap 和 多开)

2、三个队列

六、忙等问题


一、分析需求

需求 :多个玩家,在游戏大厅进行匹配,系统会把实力相近的玩家匹配到一起。

要想实现上述效果,就需要利用到消息推送机制,即需要使用到 WebSocket 协议。如图:


二、约定前后端交互接口

通过需求分析,确认了要使用 WebSocket 协议,来实现消息推送的效果,因此,约定前后端交互接口也是根据 WebSocket 展开的。

WebSocket 协议,可以传输文本数据,也可以传输二进制数据,这里就采用传输 JSON 格式的文本数据。

匹配请求:

这里并不需要传送用户信息,因为在前面登录的时候,就已经把当前用户信息保存到HttpSession中了,在进行WebSocket连接时,只需要把HttpSession中的Session拿过来就行了,并保存在WebSocket连接中。

匹配响应:

这里会有两个不同的匹配响应。

匹配响应1 :是指玩家点击开始匹配,玩家的这个操作的请求发送成功,后端返回回来的响应(立即返回的响应)。

匹配响应2 :指有两个玩家成功匹配到一起了,服务器主动推送回来的响应(多久返回这个响应?服务器并不知道)。(匹配到的对手信息保存在服务器中)


三、实现游戏大厅页面(前端代码)

game_hall.html:

JSON字符串 和 JS对象的转换:

html 复制代码
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>游戏大厅</title>
    <link rel="stylesheet" href="css/common.css">
    <link rel="stylesheet" href="css/game_hall.css">
</head>

<body>
    <div class="nav">五子棋对战</div>
    <!-- 整个页面的容器元素' -->
    <div class="container">
        <!-- 这个div在container中是处于垂直水平居中这样的位置的 -->
        <div>
            <!-- 展示用户信息 -->
            <div id="screen"></div>
            <!-- 匹配按钮 -->
            <div id="match-button">开始匹配</div>
        </div>
    </div>

    <script src="./js/jquery.min.js"></script>

    <script>
        $.ajax({
            type: 'get',
            url: '/userInfo',
            success: function (body) {
                let screenDiv = document.querySelector("#screen");
                screenDiv.innerHTML = "玩家: " + body.username + "分数: " + body.score
                    + "<br> 比赛场次: " + body.totalCount + "获胜场数: " + body.winCount
            },
            error: function () {
                alert("获取用户信息失败");
            }
        });
        // 此处进行初始化 websocket,并且实现前端的匹配逻辑
        // 此处的路径必须写作 /findMatch
        let websocket = new WebSocket("ws://127.0.0.1:8080/findMatch");
        websocket.onopen = function () {
            console.log("onopen");
        }
        websocket.onclose = function () {
            console.log("onclose");
            // alert("游戏大厅中接收到了失败响应! 请重新登录");
            // location.assign("/login.html");
        }
        websocket.onerror = function () {
            console.log("onerror");
        }
        // 监听页面关闭事件,在页面关闭之前,手动调动这里的 websocket 的 close 方法
        window.onbeforeunload = function () {
            websocket.close();
        }

        //一会重点来实现,要处理服务器返回的响应
        websocket.onmessage = function (e) {
            // 处理服务器返回的响应数据,这个响应就是针对 "开始匹配" / "结束匹配" 来应对的
            //解析得到的响应对象,返回的数据是一个 JSON 字符串,解析成 js 对象
            let resp = JSON.parse(e.data);
            let matchButton = document.querySelector("#match-button");
            if (!resp.ok) {
                console.log("游戏大厅中接收到了失败响应! " + resp.reason);
                alert("游戏大厅中接收到了失败响应! " + resp.reason);
                location.assign("/login.html");
                return;
            }
            if (resp.message == 'startMatch') {
                //开始匹配请求发起成功
                console.log("进入匹配队列成功");
                matchButton.innerHTML = '匹配中...(点击停止)';
            } else if (resp.message == 'stopMatch') {
                //结束匹配请求发起成功
                console.log("离开匹配队列成功");
                matchButton.innerHTML = '开始匹配';
            } else if (resp.message == 'matchSuccess') {
                //已经匹配到对手了
                console.log("匹配到对手! 进入游戏房间");
                location.assign("/game_room.html");
            } else {
                console.log("收到了非法的响应! message=" + resp.message);
            }

        }

        // 给匹配按钮添加一个点击事件
        let matchButton = document.querySelector('#match-button');
        matchButton.onclick = function () {
            //在触发 websocket 请求之前,先确认下 websocket 连接是否好着
            if (websocket.readyState == websocket.OPEN) {
                //如果当前 readyState 处在 OPPEN状态,说明连接是好着的
                //这里发送的数据有两种可能,开始匹配/停止匹配
                if (matchButton.innerHTML == '开始匹配') {
                    console.log("开始匹配");
                    websocket.send(JSON.stringify({
                        message: 'startMatch',
                    }))
                } else if (matchButton.innerHTML == '匹配中...(点击停止)') {
                    console.log("停止匹配");
                    websocket.send(JSON.stringify({
                        message: 'stopMatch',
                    }));
                }
            } else {
                //这是说明当前连接是异常的状态
                alert("当前您的连接已经断开! 请重新登录!");
                location.assign('/login.html');
            }
        }
    </script>

</body>

</html>

common.css:

css 复制代码
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

html, body {
    height: 100%;
    background-image: url(../image/blackboard.jpg);
    background-repeat: no-repeat;
    background-position: center;
    background-size: cover;
}

.nav {
    height: 50px;

    background: rgb(50, 50, 50);
    color: white;

    line-height: 50px;
    padding-left: 20px;
}

.container {
    width: 100%;
    height: calc(100% - 50px);

    display: flex;
    align-items: center;
    justify-content: center;
}

game_hall.css:

css 复制代码
.container {
    width: 100%;
    height: calc(100% - 50px);

    display: flex;
    align-items: center;
    justify-content: center;
}

#screen {
    width: 400px;
    height: 200px;
    font-size: 20px;
    background-color: gray;
    color: white;
    border-radius: 10px;

    text-align: center;
    line-height: 100px;
}

#match-button {
    width: 400px;
    height: 50px;
    font-size: 20px;
    background-color: orange;
    color: white;
    border: none;
    outline: none;
    border-radius: 10px;

    text-align: center;
    line-height: 50px;
    margin-top: 20px;
}

#match-button:active{
    background-color: gray;
}   

四、实现后端代码

后端要想建立WebSocket连接,需要创建一个专门的类(MatchAPI),来处理 WebSocket 的请求;同时,还要新建一个类(WebSocketConfig),进行WebSocket连接、配置 WebSocket 连接的路径,以及拿到之前 HTTP 连接时的 Session。

WebSocketConfig

java 复制代码
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
    @Autowired
    private MatchAPI matchAPI;

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(matchAPI, "/findMatch")
                .addInterceptors(new HttpSessionHandshakeInterceptor());
    }
}

MatchAPI(处理请求、返回响应)

JSON字符串 和 Java对象的转换:

java 复制代码
//通过这个类来处理匹配功能中的 websocket 请求
@Slf4j
@Component
public class MatchAPI extends TextWebSocketHandler {
    @Autowired
    private OnlineUserManager onlineUserManager;

    @Autowired
    private Matcher matcher;

    private ObjectMapper objectMapper = new ObjectMapper();
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        // 玩家上线,加入到 OnlineUserManager 中

        //1、先获取到当前用户的身份信息(谁在游戏大厅中,建立的连接)
        // 此处的代码,之所以能够getAttributes,全靠了在注册 websocket 的时候,
        // 加上了 .addInterceptors(new HttpsessionHandshakeInterceptor())
        // 这个逻辑就把 HttpSession 中的 Attribute 都给拿到 WebSocketSession 中了
        // 在 Http 登录逻辑中,往 HttpSession 中存了 User 数据:httpSession.setAttribute("user", user)
        // 此时就可以在 WebSocketSession 中把之前 HttpSession 里存的 User 对象给拿到了
        // 注意,此处拿到的 user,可能是为空的
        // 如果之前用户压根就没有通过 HTTP 来进行登录,直接就通过 /game_hall.html 这个URL来进行访问游戏大厅了
        // 此时就会出现 user 为 null 的情况
        try {
            User user = (User) session.getAttributes().get("user");
            //2、拿到了身份信息之后,进行判断当前用户是否已经登录过(在线状态),如果已经是在线,就不该继续进行后续逻辑
            WebSocketSession tmpSession = onlineUserManager.getFromGameHall(user.getUserId());
            if(tmpSession != null) {
                //  说明该用户已经登录了
                //  针对这个情况,要告知客户端,你这里重复登录了
                MatchResponse response = new MatchResponse();
                response.setOk(false);
                response.setReason("当前用户已经登录, 静止多开!");
                session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));
                session.close();
                return;
            }
            onlineUserManager.enterGameHall(user.getUserId(), session);
//            System.out.println("玩家" + user.getUsername() + " 进入游戏大厅");
            log.info("玩家 {}",user.getUsername() + " 进入游戏大厅");
        } catch (NullPointerException e) {
            e.printStackTrace();
            // 出现空指针异常,说明当前用户的身份信息为空,也就是用户未登录
            // 就把当前用户尚未登录,给返回回去
            MatchResponse response = new MatchResponse();
            response.setOk(false);
            response.setReason("您尚未登录,不能进行后续匹配");
            session.sendMessage(new TextMessage(objectMapper.writeValueAsBytes(response)));
        }
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        //  实现处理开始匹配请求和停止匹配请求
        User user = (User) session.getAttributes().get("user");
        //  拿到客户端发给服务器的数据
        String payload = message.getPayload();
        //  当前传过来的数据是JSON格式的字符串,就需要把它转成 Java 对象:MatchRequest
        MatchRequest request = objectMapper.readValue(payload, MatchRequest.class);

        MatchResponse response = new MatchResponse();
        if(request.getMessage().equals("startMatch")) {
            //  进入匹配队列
            //  把当前用户加入到匹配队列中
            matcher.add(user);
            //  把玩家信息放入匹配队列后,就可以返回一个响应给客户端了
            response.setOk(true);
            response.setMessage("startMatch");
        } else if (request.getMessage().equals("stopMatch")) {
            //  退出匹配队列
            //  在匹配队列中把当前用户给删除了
            matcher.remove(user);
            // 在匹配队列中把当前用户给删除后,就可以返回一个响应给客户端了
            response.setOk(true);
            response.setMessage("stopMatch");
        } else {
            //  非法情况
            response.setOk(false);
            response.setMessage("非法的匹配请求");
        }

        String jsonString = objectMapper.writeValueAsString(response);
        session.sendMessage(new TextMessage(jsonString));
    }

    @Override
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
        // 玩家下线,删除 OnlineUserManager 中的该用户的Session
        try {
            User user = (User) session.getAttributes().get("user");
            WebSocketSession tmpSession = onlineUserManager.getFromGameHall(user.getUserId());
            if(tmpSession == session) {
                onlineUserManager.exitGameHall(user.getUserId());
            }
            // 如果玩家正在匹配中,但WebSocket断开了,就应该把该玩家移除匹配队列
            log.info("Error玩家: {}", user.getUsername() + " 下线");
            matcher.remove(user);
        } catch (NullPointerException e) {
            e.printStackTrace();
            MatchResponse response = new MatchResponse();
            response.setOk(false);
            response.setReason("您尚未登录,不能进行后续匹配");
            session.sendMessage(new TextMessage(objectMapper.writeValueAsBytes(response)));
        }
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        // 玩家下线,删除 OnlineUserManager 中的该用户的Session
        try {
            User user = (User) session.getAttributes().get("user");
            WebSocketSession tmpSession = onlineUserManager.getFromGameHall(user.getUserId());
            if(tmpSession == session) {
                onlineUserManager.exitGameHall(user.getUserId());
            }
            // 如果玩家正在匹配中,但WebSocket断开了,就应该把该玩家移除匹配队列
            log.info("Closed玩家: {}", user.getUsername() + " 下线");
            matcher.remove(user);
        } catch (NullPointerException e) {
            e.printStackTrace();
            MatchResponse response = new MatchResponse();
            response.setOk(false);
            response.setReason("您尚未登录,不能进行后续匹配");
            session.sendMessage(new TextMessage(objectMapper.writeValueAsBytes(response)));
        }
    }
}

对于 WebSocket 请求、返回的响应,把传送的数据封装成这两个类:

MatchResponse(响应)

java 复制代码
// 这是表示一个 WebSocket响应
@Data
public class MatchResponse {
    private boolean ok;
    private String reason;
    private String message;
}

MatchRequest (请求)

java 复制代码
// 这是表示一个 WebSocket 请求
@Data
public class MatchRequest {
    private String message;
}

OnlineUserManager(用户在线状态)

之所以要维护用户的在线状态,目的是为了能够在代码中比较方便的获取到某个用户当前的WebSocket 会话,从而通过这个会话来对客户端发送消息。

同时,也能感知到用户的 在线/离线 状态~。

此处使用 哈希表 来维护 userId 和 WebSocketSession 的映射关系。

java 复制代码
@Component
public class OnlineUserManager {
    //这个hash表就是用来表示当前用户在游戏大厅的在线状态
    private ConcurrentHashMap<Integer, WebSocketSession> gameHall = new ConcurrentHashMap<>();

    public void enterGameHall(int userId, WebSocketSession webSocketSession) {
        gameHall.put(userId, webSocketSession);
    }

    public void exitGameHall(int userId) {
        gameHall.remove(userId);
    }

    public WebSocketSession getFromGameHall(int userId) {
        return gameHall.get(userId);
    }
}

Matcher(匹配器)

通过这个匹配器,来处理玩家的匹配功能。

java 复制代码
//  这个类表示匹配器,通过这个类来负责整个的匹配功能
@Slf4j
@Component
public class Matcher {
    //  创建三个匹配队列
    private Queue<User> normalQueue = new LinkedList<>();
    private  Queue<User> highQueue = new LinkedList<>();
    private Queue<User> veryHighQueue = new LinkedList<>();

    @Autowired
    private OnlineUserManager onlineUserManager;

    @Autowired
    private RoomManager roomManager;

    private ObjectMapper objectMapper = new ObjectMapper();

    //  操作匹配队列的方法
    //  把玩家放到匹配队列中
    public void add(User user) {
        if(user.getScore() < 2000) {
            synchronized (normalQueue) {
                normalQueue.offer(user);
                normalQueue.notify();
            }
            log.info("把玩家 " + user.getUsername() + " 加入到了 normalQueue 中");
        } else if(user.getScore() >= 2000 && user.getScore() < 3000) {
            synchronized (highQueue) {
                highQueue.offer(user);
                highQueue.notify();
            }
            log.info("把玩家 " + user.getUsername() + " 加入到了 highQueue 中");
        } else {
            synchronized (veryHighQueue) {
                veryHighQueue.offer(user);
                veryHighQueue.notify();
            }
            log.info("把玩家 " + user.getUsername() + " 加入到了 veryHighQueue 中");
        }
    }

    //  当玩家点击停止匹配,就需要把该玩家从匹配队列删除
    public void remove(User user) {
        if(user.getScore() < 2000) {
            synchronized (normalQueue) {
                normalQueue.remove(user);
            }
            log.info("玩家: " + user.getUsername() + " 在 normalQueue 队列被删除");
        } else if(user.getScore() >= 2000 && user.getScore() < 3000) {
            synchronized (highQueue) {
                highQueue.remove(user);
            }
            log.info("把玩家: " + user.getUsername() + " 在 highQueue 队列被删除");
        } else {
            synchronized (veryHighQueue) {
                veryHighQueue.remove(user);
            }
            log.info("把玩家: " + user.getUsername() + " 在 veryHighQueue 队列被删除");
        }
    }

    public Matcher() {
        //  创建三个线程,分别针对这三个匹配队列,进行操作
        Thread t1 = new Thread() {
            @Override
            public void run() {
                //  扫描normalQueue
                while(true) {
                    handlerMatch(normalQueue);
                }
            }
        };
        t1.start();

        Thread t2 = new Thread() {
            @Override
            public void run() {
                while (true) {
                    handlerMatch(highQueue);
                }
            }
        };
        t2.start();

        Thread t3 = new Thread() {
            @Override
            public void run() {
                while (true) {
                    handlerMatch(veryHighQueue);
                }
            }
        };
        t3.start();
    }

    public void handlerMatch(Queue<User> matchQueue) {
        synchronized (matchQueue) {
            try {
                //  1、检测队列中元素个数是否达到 2
                //  队列的初始情况可能是 空
                //  如果往队列中添加一个元素,这个时候,仍然是不能进行后续匹配操作的
                //  因此在这里使用 while 循环检查更合理
                while (matchQueue.size() < 2) {
                    matchQueue.wait();
                }
                //  2、尝试从队列中取出两个玩家
                User player1 = matchQueue.poll();
                User player2 = matchQueue.poll();
                log.info("匹配出两个玩家: " + player1.getUsername() + ", " + player2.getUsername());

                //  3、获取到玩家的 WebSocket 会话
                //     获取到会话的目的是为了告诉玩家,你排到了~
                WebSocketSession session1 = onlineUserManager.getFromGameHall(player1.getUserId());
                WebSocketSession session2 = onlineUserManager.getFromGameHall(player2.getUserId());
                //  理论上来说,匹配队列中的玩家一定是在线的状态
                //  因为前面的逻辑进行了处理,当玩家断开连接的时候,就把玩家从匹配队列移除了
                //  但是这里还是进行一次判定,进行双重判定会更稳妥一点
                if(session1 == null) {
                    //  如果玩家1不在线了,就把玩家2放回匹配队列
                    matchQueue.offer(player2);
                    return;
                }
                if(session2 == null) {
                    //  如果玩家1不在线了,就把玩家2放回匹配队列
                    matchQueue.offer(player1);
                    return;
                }

                //  当前能否排到两个玩家是同一个用户的情况吗?一个玩家入队列两次
                //  理论上也不会存在~
                //  1) 如果玩家下线,就会对玩家移除匹配队列
                //  2) 又禁止了玩家多开
                //  但是仍然在这里多进行一次判定,以免前面的逻辑出现 bug 时,带来严重的后果
                if(session1 == session2) {
                    //  把其中的一个玩家放回匹配队列
                    matchQueue.offer(player1);
                    return;
                }

                //  4、把这两个玩家放到同一个游戏房间中
                Room room = new Room();
                roomManager.add(room, player1.getUserId(), player2.getUserId());

                //  5、给玩家反馈信息
                //    通过 WebSocket 返回一个 message 为 "matchSuccess" 这样的响应
                MatchResponse response1 = new MatchResponse();
                response1.setOk(true);
                response1.setMessage("matchSuccess");
                String json1 = objectMapper.writeValueAsString(response1);
                session1.sendMessage(new TextMessage(json1));

                MatchResponse response2 = new MatchResponse();
                response2.setOk(true);
                response2.setMessage("matchSuccess");
                String json2 = objectMapper.writeValueAsString(response2);
                session2.sendMessage(new TextMessage(json2));

            } catch (IOException | InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

Room(游戏房间)

java 复制代码
//  这个类表示一个游戏房间
@Data
public class Room {
    // 使用字符串类型来表示,方便生成唯一值
    private String roomId;

    private User user1;
    private User user2;

    public Room() {
        // 构造 Room 的时候生成一个唯一的字符串表示房间 id
        // 使用 UUID 来作为房间 id
        roomId = UUID.randomUUID().toString();
    }
}

RoomManager(房间管理器)

java 复制代码
// 房间管理器类
// 这个类也希望有唯一实例
@Component
public class RoomManager {
    private ConcurrentHashMap<String, Room> rooms = new ConcurrentHashMap<>();
    private ConcurrentHashMap<Integer, String> userIdToRoomId = new ConcurrentHashMap<>();

    public void add(Room room, int userId1, int userId2) {
        rooms.put(room.getRoomId(), room);
        userIdToRoomId.put(userId1, room.getRoomId());
        userIdToRoomId.put(userId2, room.getRoomId());
    }

    public void remove(String roomId, int userId1, int userId2) {
        rooms.remove(roomId);
        userIdToRoomId.remove(userId1);
        userIdToRoomId.remove(userId2);
    }

    public Room getRoomByRoomId(String roomId) {
        return rooms.get(roomId);
    }

    public Room getRoomByUserId(int userId) {
       String roomId = userIdToRoomId.get(userId);
       if(roomId == null) {
           // userId -> roomId 映射关系不存在,直接返回 null
           return null;
       }
       return rooms.get(roomId);
    }
}

五、线程安全问题

1、HashMap 和 多开

如果多个线程访问同一个HashMap,就容易出现线程安全问题。

如果同时多个用户和服务器 建立/断开 连接,此时服务器就是并发的在针对 HashMap 进行修改。

所以要避免这种情况,解决这个线程安全问题,可以直接使用ConcurrentHashMap。

当多个浏览器,通时对一个用户进行登录,进入游戏大厅,会引发下面这种问题:

所以,我们不仅要解决线程安全问题,也要考虑用户多开的情况,那么用户能进行多开操作吗?显然是不能的,所以上面的代码逻辑也是会处理这种多开的情况,如果当前用户已经登录,禁止其他地方再登录。

2、三个队列

在匹配模块(Matcher类),为了划分玩家水平实力,使用了三个队列表示不同的实力分段;

同时,创建三个线程,当用户进行匹配时,就会不停的扫描这三个队列,看是否能匹配对局成功。

但因为匹配时,就会把玩家加入到对应段位的队列,而停止匹配,也会把玩家从对应的队列删除,又有多个线程并发的去执行,所以,存在线程安全问题。

怎么办?

针对这三个队列对象,分别进行加锁,如图:

这三个线程都是调用同一个方法。如图:

因此,我们针对这一个方法加锁就好了:


六、忙等问题

我们创建了三个线程:

会不停的去扫描这三个队列,元素个数是否达到2,如果达到2,就要吧这两个用户取出来,放在同一个房间中进行对局。

但这里要一直扫描码?显然是不用的,所以可以在这里 wait 一下。

既然 wait了,那就要有 notify,来唤醒它,继续锁竞争。那什么时候唤醒呢?当然是这个队列有新的用户加进来了,那再进行唤醒,再重新判断用户个数是否达到2.

这样,我们就能解决忙等的问题。

相关推荐
程序猿麦小七29 分钟前
基于springboot的景区网页设计与实现
java·spring boot·后端·旅游·景区
蓝田~38 分钟前
SpringBoot-自定义注解,拦截器
java·spring boot·后端
theLuckyLong39 分钟前
SpringBoot后端解决跨域问题
spring boot·后端·python
A陈雷39 分钟前
springboot整合elasticsearch,并使用docker desktop运行elasticsearch镜像容器遇到的问题。
spring boot·elasticsearch·docker
.生产的驴40 分钟前
SpringCloud Gateway网关路由配置 接口统一 登录验证 权限校验 路由属性
java·spring boot·后端·spring·spring cloud·gateway·rabbitmq
小扳44 分钟前
Docker 篇-Docker 详细安装、了解和使用 Docker 核心功能(数据卷、自定义镜像 Dockerfile、网络)
运维·spring boot·后端·mysql·spring cloud·docker·容器
v'sir1 小时前
POI word转pdf乱码问题处理
java·spring boot·后端·pdf·word
李少兄1 小时前
解决Spring Boot整合Redis时的连接问题
spring boot·redis·后端
冰逸.itbignyi2 小时前
SpringBoot之AOP 的使用
java·spring boot
码上一元6 小时前
SpringBoot自动装配原理解析
java·spring boot·后端