spring boot 1.x 對(duì) websocket 的支持

spring cloud zuul 如何轉(zhuǎn)發(fā) websocket 請(qǐng)求

????網(wǎng)上關(guān)于spring boot 使用websocket 的文章很多笨农,但是涉及spring cloud zuul如何轉(zhuǎn)發(fā)websocket請(qǐng)求的文章很少就缆,據(jù)網(wǎng)上資料顯示zuul 1.x不支持websocket,2.x支持。在考慮到當(dāng)前項(xiàng)目從spring boot 1.x 更換spring boot 2.x 復(fù)雜度高谒亦,決定根據(jù)當(dāng)前開(kāi)發(fā)版本尋找解決方案(實(shí)在不行竭宰,開(kāi)個(gè)服務(wù)端口就是;方法總比問(wèn)題多)份招。

代碼解析

????文章圍繞github上的一個(gè)解決方案(https://github.com/mthizo247/spring-cloud-netflix-zuul-websocket)展開(kāi)詳細(xì)描述切揭。作者提供的demo(https://github.com/mthizo247/zuul-websocket-support-demo)可以運(yùn)行成功,但基于訂閱topic廣播的樣例明顯不夠脾还,點(diǎn)對(duì)點(diǎn)發(fā)送或?qū)⑾l(fā)送到指定客戶(hù)端的業(yè)務(wù)場(chǎng)景也很常見(jiàn)伴箩,接下來(lái)針對(duì)websocket廣播和點(diǎn)對(duì)點(diǎn)消息方式講解具體的實(shí)現(xiàn)細(xì)節(jié)。
實(shí)現(xiàn)邏輯如下圖:

websockt.png

  1. 網(wǎng)關(guān)添加微服務(wù)的endpoint鄙漏、broken;
  2. 客戶(hù)端向網(wǎng)關(guān)發(fā)送websocket請(qǐng)求,并轉(zhuǎn)發(fā)訂閱微服務(wù)websocket;
    /**
     * 網(wǎng)關(guān)接收到webSocket-client發(fā)送消息,
     * 并向微服務(wù)轉(zhuǎn)發(fā)websocket請(qǐng)求
     */
    @Override
    public void handleMessage(WebSocketSession session, WebSocketMessage<?> message)
            throws Exception {
        super.handleMessage(session, message);
        handleMessageFromClient(session, message);
    }

    private void handleMessageFromClient(WebSocketSession session,
                                         WebSocketMessage<?> message) throws Exception {
        boolean handled = false;
        WebSocketMessageAccessor accessor = WebSocketMessageAccessor.create(message);
        if (StompCommand.SEND.toString().equalsIgnoreCase(accessor.getCommand())) {
            handled = true;
            sendMessageToProxiedTarget(session, accessor);
        }

        if (StompCommand.SUBSCRIBE.toString().equalsIgnoreCase(accessor.getCommand())) {
            handled = true;
            subscribeToProxiedTarget(session, accessor);
        }

        if (StompCommand.UNSUBSCRIBE.toString().equalsIgnoreCase(accessor.getCommand())) {
            handled = true;
            unsubscribeFromProxiedTarget(session, accessor);
        }

        if (StompCommand.CONNECT.toString().equalsIgnoreCase(accessor.getCommand())) {
            handled = true;
            connectToProxiedTarget(session);
        }

        if (!handled) {
            if (logger.isDebugEnabled()) {
                logger.debug("STOMP COMMAND " + accessor.getCommand()
                        + " was not explicitly handled");
            }
        }
    }

    /**
     * 根據(jù)請(qǐng)求獲取微服務(wù)地址
     * 由ProxyWebSocketConnectionManager 代理websocket 連接
     */
    private void connectToProxiedTarget(WebSocketSession session) {
        URI sessionUri = session.getUri();
        ZuulWebSocketProperties.WsBrokerage wsBrokerage = getWebSocketBrokarage(
                sessionUri);

        Assert.notNull(wsBrokerage, "wsBrokerage must not be null");

        String path = getWebSocketServerPath(wsBrokerage, sessionUri);
        Assert.notNull(path, "Web socket uri path must be null");

        URI routeTarget = proxyTargetResolver.resolveTarget(wsBrokerage);

        Assert.notNull(routeTarget, "routeTarget must not be null");
        
        //微服務(wù)配置全局路徑的情況下棺蛛,需要添加微服務(wù)名
        path = "/" + wsBrokerage.getId() + path;

        String uri = ServletUriComponentsBuilder
                .fromUri(routeTarget)
                .path(path)
                .replaceQuery(sessionUri.getQuery())
                .toUriString();

        ProxyWebSocketConnectionManager connectionManager = new ProxyWebSocketConnectionManager(
                messagingTemplate, stompClient, session, headersCallback, uri);
        connectionManager.errorHandler(this.errorHandler);
        managers.put(session, connectionManager);
        connectionManager.start();
    }
  1. 網(wǎng)關(guān)接收到微服務(wù)發(fā)送的消息轉(zhuǎn)發(fā)到客戶(hù)端
    /**
     * 接收到微服務(wù)信息后調(diào)用
     */
    @Override
    public void handleFrame(StompHeaders headers, Object payload) {
        if (headers.getDestination() != null) {
            String destination = headers.getDestination();
            if (logger.isDebugEnabled()) {
                logger.debug("Received " + payload + ", To " + headers.getDestination());
            }

            Principal principal = userAgentSession.getPrincipal();
            String userDestinationPrefix = messagingTemplate.getUserDestinationPrefix();
            if (principal != null && destination.startsWith(userDestinationPrefix)) {
                destination = destination.substring(userDestinationPrefix.length());

                destination = destination.startsWith("/") ? destination
                        : "/" + destination;

                messagingTemplate.convertAndSendToUser(principal.getName(), destination,
                        payload, copyHeaders(headers.toSingleValueMap()));
            } else {
                messagingTemplate.convertAndSend(destination, payload,
                        copyHeaders(headers.toSingleValueMap()));
            }
        }
    }

開(kāi)發(fā)實(shí)例

???? 基于spring boot 1.x (spring mvc)實(shí)現(xiàn)stomp協(xié)議的websocket,并由spring cloud zuul 路由轉(zhuǎn)發(fā)怔蚌。

開(kāi)發(fā)websocket服務(wù)

??1. pom引入如下依賴(lài):

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>1.5.2.RELEASE</version>
    </parent>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>Camden.SR5</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
                <!---創(chuàng)建一個(gè)微服務(wù)工程的基礎(chǔ)依賴(lài)包,網(wǎng)關(guān)可不引用-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
                <!--引入eureka 依賴(lài)包旁赊,將服務(wù)注冊(cè)到注冊(cè)中心-->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-eureka</artifactId>
        </dependency>
                <!--引入websocket 依賴(lài)包-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-websocket</artifactId>
        </dependency>
    </dependencies>

??配置STOMP的服務(wù)端點(diǎn)和請(qǐng)求訂閱前綴

/**
 * 使用 STOMP 協(xié)議
 * @author Golden
 */
@Configuration
@EnableWebSocketMessageBroker
public class StompWebSocketConfig extends AbstractWebSocketMessageBrokerConfigurer {

    /**
     * 注冊(cè)服務(wù)器端點(diǎn)
     */
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        //增加 gs-guide-websocket 端點(diǎn)
        registry.addEndpoint("/gs-guide-websocket")
                //添加握手處理器桦踊,將客戶(hù)端傳入的session_id封裝為Principal對(duì)象,從而讓服務(wù)端能通過(guò)getName()方法找到指定客戶(hù)端
                .setHandshakeHandler(new DefaultHandshakeHandler() {
                    @Override
                    protected Principal determineUser(ServerHttpRequest request, WebSocketHandler wsHandler,
                            Map<String, Object> attributes) {
                         //【關(guān)鍵】
                        final String sessionid = (String) attributes.get("session_id");
                        Principal principal = new Principal() {
                            @Override
                            public String getName() {
                                return sessionid;
                            }
                        };
                        return principal;
                    }
                })
                // 添加socket攔截器终畅,用于從請(qǐng)求中獲取session_id
                .addInterceptors(new CustomHandshakeInterceptor())
                // bypasses spring web security
                .setAllowedOrigins("*").withSockJS();
    }

    /**
     * 定義服務(wù)器端點(diǎn)請(qǐng)求和訂閱前綴
     */
    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        // 客戶(hù)端訂閱請(qǐng)求前綴
        config.enableSimpleBroker("/topic","/queue");
        // 服務(wù)端點(diǎn)請(qǐng)求前綴
        config.setApplicationDestinationPrefixes("/app");
    }
    
}
/**
 * 添加socket攔截器
 * @author Golden
 */
public class CustomHandshakeInterceptor implements HandshakeInterceptor {

    @Override
    public void afterHandshake(ServerHttpRequest arg0, ServerHttpResponse arg1,
            org.springframework.web.socket.WebSocketHandler arg2, Exception arg3) {
    }

    /**
     * handler處理前調(diào)用,attributes屬性最終在WebSocketSession里,可能通過(guò)webSocketSession.getAttributes().get(key值)獲得
     */
    @Override
    public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse arg1,
            org.springframework.web.socket.WebSocketHandler arg2, Map<String, Object> attributes) throws Exception {

        if (request instanceof ServletServerHttpRequest) {
            // 【關(guān)鍵】籍胯,header中的session_id是通過(guò)zuul端創(chuàng)建websocket conenction中傳遞過(guò)來(lái)
            ServletServerHttpRequest servletRequest = (ServletServerHttpRequest) request;
            
            String session_id = servletRequest.getServletRequest().getHeader("session_id");
//          String session_id = servletRequest.getServletRequest().getParameter("session_id");
            
            attributes.put("session_id", session_id);
            
            return true;
        }
        return true;
    }

}

zuul 網(wǎng)關(guān)配置

??spring cloud 使用網(wǎng)關(guān)(zuul)需要使用spring-cloud-netflix-zuul-websocket代碼竟闪,直接引入jar 滿(mǎn)足需要。

源碼修改

??修改ZuulWebSocketConfiguration 類(lèi)中的addStompEndpoint方法杖狼,添加服務(wù)端點(diǎn)的握手處理器炼蛤、攔截器。
??攔截器從websocket的請(qǐng)求鏈接requestURI中獲取到sockjssession的id蝶涩,并用于user理朋;握手處理器,將客戶(hù)端傳入的session_id封裝為Principal對(duì)象绿聘,從而讓服務(wù)端能通過(guò)getName()方法找到指定客戶(hù)端嗽上。代碼如下:

package com.github.mthizo247.cloud.netflix.zuul.web.socket

public class ZuulWebSocketConfiguration extends AbstractWebSocketMessageBrokerConfigurer
        implements ApplicationListener<ContextRefreshedEvent> {

    private SockJsServiceRegistration addStompEndpoint(StompEndpointRegistry registry, String... endpoint) {
        return registry.addEndpoint(endpoint)
                // bypasses spring web security
                .setHandshakeHandler(new DefaultHandshakeHandler() {
                    @Override
                    protected Principal determineUser(ServerHttpRequest request, WebSocketHandler wsHandler,
                            Map<String, Object> attributes) {
                        // 利用client_id用于點(diǎn)對(duì)點(diǎn)發(fā)送
                        final String sessionId = (String) attributes.get("session_id");
                        Principal principal = new Principal() {
                            @Override
                            public String getName() {
                                return sessionId;
                            }
                        };
                        return principal;
                    }
                })
                .addInterceptors(new HandshakeInterceptor() {

                    @Override
                    public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,
                            WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
                        if (request instanceof ServletServerHttpRequest) {
                            // 從websocket的請(qǐng)求鏈接requestURI中獲取到sockjssession的id,并用于user
                            ServletServerHttpRequest servletRequest = (ServletServerHttpRequest) request;
                            String uri = servletRequest.getServletRequest().getRequestURI();
                            System.out.println("----------" + uri);
                            int lastLashIndex = uri.lastIndexOf("/");
                            uri = uri.substring(0, lastLashIndex);
                            uri = uri.substring(uri.lastIndexOf("/") + 1);
                            attributes.put("session_id", uri);
                            return true;
                        }
                        return true;
                    }

                    @Override
                    public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response,
                            WebSocketHandler wsHandler, Exception exception) {

                    }
                })
                .setAllowedOrigins("*").withSockJS();
    }
}

??接著需要將session_id傳遞給微服務(wù)熄攘,修改ProxyWebSocketConnectionManager的buildWebSocketHttpHeaders方法兽愤,將session_id添加到socket connection的WebSocketHttpHeaders中。

    private WebSocketHttpHeaders buildWebSocketHttpHeaders() {
        WebSocketHttpHeaders wsHeaders = new WebSocketHttpHeaders();
        if (httpHeadersCallback != null) {
            httpHeadersCallback.applyHeaders(userAgentSession, wsHeaders);
            List<String> list = new ArrayList<>();
            list.add(userAgentSession.getId());
            wsHeaders.put("session_id", list);
        }
        return wsHeaders;
    }

??修改完成后再調(diào)試的過(guò)程中發(fā)現(xiàn)點(diǎn)對(duì)點(diǎn)發(fā)送依然無(wú)法接收到消息挪圾,網(wǎng)關(guān)出現(xiàn)消息轉(zhuǎn)換的異常烹看。通過(guò)調(diào)試發(fā)現(xiàn)訂閱topic和點(diǎn)對(duì)點(diǎn)兩種模式返回的數(shù)據(jù)類(lèi)型不一致。

  1. 訂閱topic 返回?cái)?shù)據(jù)類(lèi)型
    contentType=application/json;charset=UTF-8
  2. 點(diǎn)對(duì)點(diǎn)發(fā)送返回?cái)?shù)據(jù)類(lèi)型
    contentType=text/plain;charset=UTF-8

??緊接著修改ProxyWebSocketConnectionManager中的 getPayloadType方法洛史,添加類(lèi)型判斷惯殊,如下:

    @Override
    public Type getPayloadType(StompHeaders headers) {
        String type = headers.getContentType().getType();
        //content-type=[text/plain;charset=UTF-8]
        if("text".equals(type)) {
            return String.class;
        }
        //content-type=[application/json;charset=UTF-8]
        return Object.class;
    }

????代碼改造完成.

配置zuul

??引入的pom依賴(lài)如下:

        <!--spring boot 1.5.2 -->
        <!--使用spring cloud Camden.SR5-->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-eureka</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-zuul</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-websocket</artifactId>
        </dependency>
        <!-- 從github 上下載源碼碼到本地修改,自行打包 -->
        <dependency>
            <groupId>com.github.mthizo247</groupId>
            <artifactId>spring-cloud-netflix-zuul-websocket</artifactId>
            <version>1.0.7.RELEASE</version>
        </dependency>

??yml 指定websocket 端點(diǎn)也殖、訂閱路徑前綴土思、服務(wù)端點(diǎn)請(qǐng)求前綴

zuul:
  routes:
    web-ui:  # websocket 服務(wù)名
      path: /**
      #url: http://localhost:8080 在連接eureka的情況下不需要
      service-id:  web-ui
      customSensitiveHeaders: true
  ws:
    brokerages:
      web-ui:   # websocket 服務(wù)名
        end-points: /gs-guide-websocket
        brokers:  /topic,/queue
        destination-prefixes: /app

??啟動(dòng)類(lèi)添加源碼的注解

@SpringBootApplication
@EnableZuulProxy
@EnableAsync
@EnableEurekaClient
@EnableZuulWebSocket
@EnableWebSocketMessageBroker
public class ZuulApplication 
{
    public static void main( String[] args )
    { 
        SpringApplication.run(ZuulApplication.class, args);
    }
    
    @Bean
    @LoadBalanced
    RestTemplate restTemplate() {
        return new RestTemplate();
    }

??前端訂閱代碼如下:

function connect() {
    var socket = new SockJS('/gs-guide-websocket');
    stompClient = Stomp.over(socket);
    stompClient.connect({}, function (frame) {
        setConnected(true);
        console.log('Connected: ' + frame);
        stompClient.subscribe('/topic/greetings', function (greeting) {
            showGreeting(JSON.parse(greeting.body).content);
        });
        //訂閱時(shí)間
        stompClient.subscribe('/topic/time', function (greeting) {
            showTime(JSON.parse(greeting.body).content);
        });
        //訂閱用戶(hù)通知消息,/user/ 需要添加
        stompClient.subscribe('/user/queue/customer',function(message){
            console.log("/queue/customer: " + message.body);
            showUserListening(message.body);
        });
    });
}

演示效果:


image.png

最后感謝參考的以下幾篇博客
https://blog.csdn.net/weixin_34389926/article/details/86262894
http://www.reibang.com/p/32fae52c61f6
https://my.oschina.net/u/3706162/blog/1935071

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末忆嗜,一起剝皮案震驚了整個(gè)濱河市己儒,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌捆毫,老刑警劉巖闪湾,帶你破解...
    沈念sama閱讀 218,386評(píng)論 6 506
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異绩卤,居然都是意外死亡途样,警方通過(guò)查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,142評(píng)論 3 394
  • 文/潘曉璐 我一進(jìn)店門(mén)濒憋,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)何暇,“玉大人,你說(shuō)我怎么就攤上這事凛驮●烧荆” “怎么了?”我有些...
    開(kāi)封第一講書(shū)人閱讀 164,704評(píng)論 0 353
  • 文/不壞的土叔 我叫張陵,是天一觀(guān)的道長(zhǎng)宏胯。 經(jīng)常有香客問(wèn)我羽嫡,道長(zhǎng),這世上最難降的妖魔是什么肩袍? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 58,702評(píng)論 1 294
  • 正文 為了忘掉前任杭棵,我火速辦了婚禮,結(jié)果婚禮上了牛,老公的妹妹穿的比我還像新娘颜屠。我一直安慰自己,他們只是感情好鹰祸,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,716評(píng)論 6 392
  • 文/花漫 我一把揭開(kāi)白布甫窟。 她就那樣靜靜地躺著蚁袭,像睡著了一般偿渡。 火紅的嫁衣襯著肌膚如雪旬痹。 梳的紋絲不亂的頭發(fā)上瓮钥,一...
    開(kāi)封第一講書(shū)人閱讀 51,573評(píng)論 1 305
  • 那天,我揣著相機(jī)與錄音划提,去河邊找鬼挠锥。 笑死十兢,一個(gè)胖子當(dāng)著我的面吹牛餐济,可吹牛的內(nèi)容都是我干的耘擂。 我是一名探鬼主播,決...
    沈念sama閱讀 40,314評(píng)論 3 418
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼絮姆,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼醉冤!你這毒婦竟也來(lái)了?” 一聲冷哼從身側(cè)響起篙悯,我...
    開(kāi)封第一講書(shū)人閱讀 39,230評(píng)論 0 276
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤蚁阳,失蹤者是張志新(化名)和其女友劉穎,沒(méi)想到半個(gè)月后鸽照,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體螺捐,經(jīng)...
    沈念sama閱讀 45,680評(píng)論 1 314
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,873評(píng)論 3 336
  • 正文 我和宋清朗相戀三年矮燎,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了定血。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 39,991評(píng)論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡漏峰,死狀恐怖糠悼,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情浅乔,我是刑警寧澤,帶...
    沈念sama閱讀 35,706評(píng)論 5 346
  • 正文 年R本政府宣布,位于F島的核電站靖苇,受9級(jí)特大地震影響席噩,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜贤壁,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,329評(píng)論 3 330
  • 文/蒙蒙 一悼枢、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧脾拆,春花似錦馒索、人聲如沸。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 31,910評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至渠驼,卻和暖如春蜈块,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背迷扇。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 33,038評(píng)論 1 270
  • 我被黑心中介騙來(lái)泰國(guó)打工百揭, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人蜓席。 一個(gè)月前我還...
    沈念sama閱讀 48,158評(píng)論 3 370
  • 正文 我出身青樓器一,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親厨内。 傳聞我的和親對(duì)象是個(gè)殘疾皇子祈秕,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,941評(píng)論 2 355

推薦閱讀更多精彩內(nèi)容

  • 微服務(wù)架構(gòu)模式的核心在于如何識(shí)別服務(wù)的邊界,設(shè)計(jì)出合理的微服務(wù)隘庄。但如果要將微服務(wù)架構(gòu)運(yùn)用到生產(chǎn)項(xiàng)目上踢步,并且能夠發(fā)揮...
    java菜閱讀 2,949評(píng)論 0 6
  • Spring Cloud Zuul ?通過(guò)前幾章的介紹,我們對(duì)于Spring Cloud Netflix下的核心組...
    Chandler_玨瑜閱讀 4,663評(píng)論 2 28
  • 為什么要使用微服務(wù)網(wǎng)關(guān) 不同的微服務(wù)一般會(huì)有不同的網(wǎng)絡(luò)地址丑掺,而客戶(hù)端可能需要調(diào)用多個(gè)服務(wù)接口才能完成一個(gè)業(yè)務(wù)需求 ...
    聰明的奇瑞閱讀 11,858評(píng)論 0 19
  • Spring Boot高級(jí) 內(nèi)容概要 一获印、Spring Boot與緩存 二、Spring Boot與消息 三街州、Sp...
    順毛閱讀 371評(píng)論 0 2
  • 第一次在題目里面用了“吧”艳丛。如此不敢確定不能確定又害怕引發(fā)誤導(dǎo)的詞匯匣掸。 但還是堅(jiān)持在標(biāo)題欄里打下了“吧”。 是一種...
    Cheer莫小貝閱讀 2,317評(píng)論 0 0