【MQ】java 從零開始實現訊息佇列 mq-02-如何實現生產者呼叫消費者?

老馬嘯西風發表於2022-04-29

前景回顧

上一節我們學習瞭如何實現基於 netty 客服端和服務端的啟動。

【mq】從零開始實現 mq-01-生產者、消費者啟動

【mq】java 從零開始實現訊息佇列 mq-02-如何實現生產者呼叫消費者?

那麼客戶端如何呼叫服務端呢?

我們本節就來一起實現一下。

02.png

消費者實現

啟動類的調整

ServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap.group(workerGroup, bossGroup)
        .channel(NioServerSocketChannel.class)
        .childHandler(new ChannelInitializer<Channel>() {
            @Override
            protected void initChannel(Channel ch) throws Exception {
                ch.pipeline()
                        .addLast(new DelimiterBasedFrameDecoder(DelimiterUtil.LENGTH, delimiterBuf))
                        .addLast(new MqConsumerHandler(invokeService));
            }
        })
        // 這個引數影響的是還沒有被accept 取出的連線
        .option(ChannelOption.SO_BACKLOG, 128)
        // 這個引數只是過一段時間內客戶端沒有響應,服務端會傳送一個 ack 包,以判斷客戶端是否還活著。
        .childOption(ChannelOption.SO_KEEPALIVE, true);

這裡我們通過指定分隔符解決 netty 粘包問題。

解決 netty 粘包問題

MqConsumerHandler 處理類

MqConsumerHandler 的實現如下,新增對應的業務處理邏輯。

package com.github.houbb.mq.consumer.handler;

/**
 * @author binbin.hou
 * @since 1.0.0
 */
public class MqConsumerHandler extends SimpleChannelInboundHandler {

    private static final Log log = LogFactory.getLog(MqConsumerHandler.class);

    /**
     * 呼叫管理類
     * @since 1.0.0
     */
    private final IInvokeService invokeService;

    public MqConsumerHandler(IInvokeService invokeService) {
        this.invokeService = invokeService;
    }

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, Object msg) throws Exception {
        ByteBuf byteBuf = (ByteBuf) msg;
        byte[] bytes = new byte[byteBuf.readableBytes()];
        byteBuf.readBytes(bytes);

        RpcMessageDto rpcMessageDto = null;
        try {
            rpcMessageDto = JSON.parseObject(bytes, RpcMessageDto.class);
        } catch (Exception exception) {
            log.error("RpcMessageDto json 格式轉換異常 {}", new String(bytes));
            return;
        }

        if (rpcMessageDto.isRequest()) {
            MqCommonResp commonResp = this.dispatch(rpcMessageDto, ctx);

            if(commonResp == null) {
                log.debug("當前訊息為 null,忽略處理。");
                return;
            }

            writeResponse(rpcMessageDto, commonResp, ctx);
        } else {
            final String traceId = rpcMessageDto.getTraceId();

            // 丟棄掉 traceId 為空的資訊
            if(StringUtil.isBlank(traceId)) {
                log.debug("[Server Response] response traceId 為空,直接丟棄", JSON.toJSON(rpcMessageDto));
                return;
            }

            // 新增訊息
            invokeService.addResponse(traceId, rpcMessageDto);
        }
    }
}

rpc 訊息體定義

為了統一標準,我們的 rpc 訊息體 RpcMessageDto 定義如下:

package com.github.houbb.mq.common.rpc;

/**
 * @author binbin.hou
 * @since 1.0.0
 */
public class RpcMessageDto implements Serializable {

    /**
     * 請求時間
     */
    private long requestTime;

    /**
     * 請求標識
     */
    private String traceId;

    /**
     * 方法型別
     */
    private String methodType;

    /**
     * 是否為請求訊息
     */
    private boolean isRequest;

    private String respCode;

    private String respMsg;

    private String json;

    //getter&setter

}

訊息分發

對於接收到的訊息體 RpcMessageDto,分發邏輯如下:

/**
 * 訊息的分發
 *
 * @param rpcMessageDto 入參
 * @param ctx 上下文
 * @return 結果
 */
private MqCommonResp dispatch(RpcMessageDto rpcMessageDto, ChannelHandlerContext ctx) {
    final String methodType = rpcMessageDto.getMethodType();
    final String json = rpcMessageDto.getJson();
    String channelId = ChannelUtil.getChannelId(ctx);
    log.debug("channelId: {} 接收到 method: {} 內容:{}", channelId,
            methodType, json);

    // 訊息傳送
    if(MethodType.P_SEND_MESSAGE.equals(methodType)) {
        // 日誌輸出
        log.info("收到服務端訊息: {}", json);
        // 如果是 broker,應該進行處理化等操作。
        MqCommonResp resp = new MqCommonResp();
        resp.setRespCode(MqCommonRespCode.SUCCESS.getCode());
        resp.setRespMessage(MqCommonRespCode.SUCCESS.getMsg());
        return resp;
    }
    throw new UnsupportedOperationException("暫不支援的方法型別");
}

這裡對於接收到的訊息,只做一個簡單的日誌輸出,後續將新增對應的業務邏輯處理。

結果回寫

收到請求以後,我們需要返回對應的響應。

基於 channel 的回寫實現如下:

/**
 * 結果寫回
 *
 * @param req  請求
 * @param resp 響應
 * @param ctx  上下文
 */
private void writeResponse(RpcMessageDto req,
                           Object resp,
                           ChannelHandlerContext ctx) {
    final String id = ctx.channel().id().asLongText();
    RpcMessageDto rpcMessageDto = new RpcMessageDto();
    // 響應類訊息
    rpcMessageDto.setRequest(false);
    rpcMessageDto.setTraceId(req.getTraceId());
    rpcMessageDto.setMethodType(req.getMethodType());
    rpcMessageDto.setRequestTime(System.currentTimeMillis());
    String json = JSON.toJSONString(resp);
    rpcMessageDto.setJson(json);
    // 回寫到 client 端
    ByteBuf byteBuf = DelimiterUtil.getMessageDelimiterBuffer(rpcMessageDto);
    ctx.writeAndFlush(byteBuf);
    log.debug("[Server] channel {} response {}", id, JSON.toJSON(rpcMessageDto));
}

呼叫管理類

為了方便管理非同步返回的請求結果,我們統一定義了 IInvokeService 類,用於管理請求與響應。

介面

package com.github.houbb.mq.common.support.invoke;

import com.github.houbb.mq.common.rpc.RpcMessageDto;

/**
 * 呼叫服務介面
 * @author binbin.hou
 * @since 1.0.0
 */
public interface IInvokeService {

    /**
     * 新增請求資訊
     * @param seqId 序列號
     * @param timeoutMills 超時時間
     * @return this
     * @since 1.0.0
     */
    IInvokeService addRequest(final String seqId,
                              final long timeoutMills);

    /**
     * 放入結果
     * @param seqId 唯一標識
     * @param rpcResponse 響應結果
     * @return this
     * @since 1.0.0
     */
    IInvokeService addResponse(final String seqId, final RpcMessageDto rpcResponse);

    /**
     * 獲取標誌資訊對應的結果
     * @param seqId 序列號
     * @return 結果
     * @since 1.0.0
     */
    RpcMessageDto getResponse(final String seqId);

}

實現

實現本身也不難。

package com.github.houbb.mq.common.support.invoke.impl;

/**
 * 呼叫服務介面
 * @author binbin.hou
 * @since 1.0.0
 */
public class InvokeService implements IInvokeService {

    private static final Log logger = LogFactory.getLog(InvokeService.class);

    /**
     * 請求序列號 map
     * (1)這裡後期如果要新增超時檢測,可以新增對應的超時時間。
     * 可以把這裡調整為 map
     *
     * key: seqId 唯一標識一個請求
     * value: 存入該請求最長的有效時間。用於定時刪除和超時判斷。
     * @since 0.0.2
     */
    private final ConcurrentHashMap<String, Long> requestMap;

    /**
     * 響應結果
     * @since 1.0.0
     */
    private final ConcurrentHashMap<String, RpcMessageDto> responseMap;

    public InvokeService() {
        requestMap = new ConcurrentHashMap<>();
        responseMap = new ConcurrentHashMap<>();

        final Runnable timeoutThread = new TimeoutCheckThread(requestMap, responseMap);
        Executors.newScheduledThreadPool(1)
                .scheduleAtFixedRate(timeoutThread,60, 60, TimeUnit.SECONDS);
    }

    @Override
    public IInvokeService addRequest(String seqId, long timeoutMills) {
        logger.debug("[Invoke] start add request for seqId: {}, timeoutMills: {}", seqId,
                timeoutMills);

        final long expireTime = System.currentTimeMillis()+timeoutMills;
        requestMap.putIfAbsent(seqId, expireTime);

        return this;
    }

    @Override
    public IInvokeService addResponse(String seqId, RpcMessageDto rpcResponse) {
        // 1. 判斷是否有效
        Long expireTime = this.requestMap.get(seqId);
        // 如果為空,可能是這個結果已經超時了,被定時 job 移除之後,響應結果才過來。直接忽略
        if(ObjectUtil.isNull(expireTime)) {
            return this;
        }

        //2. 判斷是否超時
        if(System.currentTimeMillis() > expireTime) {
            logger.debug("[Invoke] seqId:{} 資訊已超時,直接返回超時結果。", seqId);
            rpcResponse = RpcMessageDto.timeout();
        }

        // 這裡放入之前,可以新增判斷。
        // 如果 seqId 必須處理請求集合中,才允許放入。或者直接忽略丟棄。
        // 通知所有等待方
        responseMap.putIfAbsent(seqId, rpcResponse);
        logger.debug("[Invoke] 獲取結果資訊,seqId: {}, rpcResponse: {}", seqId, JSON.toJSON(rpcResponse));
        logger.debug("[Invoke] seqId:{} 資訊已經放入,通知所有等待方", seqId);

        // 移除對應的 requestMap
        requestMap.remove(seqId);
        logger.debug("[Invoke] seqId:{} remove from request map", seqId);

        // 同步鎖
        synchronized (this) {
            this.notifyAll();
            logger.debug("[Invoke] {} notifyAll()", seqId);
        }


        return this;
    }

    @Override
    public RpcMessageDto getResponse(String seqId) {
        try {
            RpcMessageDto rpcResponse = this.responseMap.get(seqId);
            if(ObjectUtil.isNotNull(rpcResponse)) {
                logger.debug("[Invoke] seq {} 對應結果已經獲取: {}", seqId, rpcResponse);
                return rpcResponse;
            }

            // 進入等待
            while (rpcResponse == null) {
                logger.debug("[Invoke] seq {} 對應結果為空,進入等待", seqId);

                // 同步等待鎖
                synchronized (this) {
                    this.wait();
                }

                logger.debug("[Invoke] {} wait has notified!", seqId);

                rpcResponse = this.responseMap.get(seqId);
                logger.debug("[Invoke] seq {} 對應結果已經獲取: {}", seqId, rpcResponse);
            }

            return rpcResponse;
        } catch (InterruptedException e) {
            logger.error("獲取響應異常", e);
            throw new MqException(MqCommonRespCode.RPC_GET_RESP_FAILED);
        }
    }

}

這裡 getResponse 獲取不到會進入等待,直到 addResponse 喚醒。

但是這也有一個問題,如果一個請求的響應丟失了怎麼辦?

總不能一直等待吧。

TimeoutCheckThread 超時檢測執行緒

超時檢測執行緒就可以幫我們處理一些超時未返回的結果。

package com.github.houbb.mq.common.support.invoke.impl;

import com.github.houbb.heaven.util.common.ArgUtil;
import com.github.houbb.mq.common.rpc.RpcMessageDto;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * 超時檢測執行緒
 * @author binbin.hou
 * @since 0.0.2
 */
public class TimeoutCheckThread implements Runnable {

    /**
     * 請求資訊
     * @since 0.0.2
     */
    private final ConcurrentHashMap<String, Long> requestMap;

    /**
     * 請求資訊
     * @since 0.0.2
     */
    private final ConcurrentHashMap<String, RpcMessageDto> responseMap;

    /**
     * 新建
     * @param requestMap  請求 Map
     * @param responseMap 結果 map
     * @since 0.0.2
     */
    public TimeoutCheckThread(ConcurrentHashMap<String, Long> requestMap,
                              ConcurrentHashMap<String, RpcMessageDto> responseMap) {
        ArgUtil.notNull(requestMap, "requestMap");
        this.requestMap = requestMap;
        this.responseMap = responseMap;
    }

    @Override
    public void run() {
        for(Map.Entry<String, Long> entry : requestMap.entrySet()) {
            long expireTime = entry.getValue();
            long currentTime = System.currentTimeMillis();

            if(currentTime > expireTime) {
                final String key = entry.getKey();
                // 結果設定為超時,從請求 map 中移除
                responseMap.putIfAbsent(key, RpcMessageDto.timeout());
                requestMap.remove(key);
            }
        }
    }

}

處理邏輯就是定時檢測,如果超時了,就預設設定結果為超時,並且從請求集合中移除。

訊息生產者實現

啟動核心類

public class MqProducer extends Thread implements IMqProducer {

    private static final Log log = LogFactory.getLog(MqProducer.class);

    /**
     * 分組名稱
     */
    private final String groupName;

    /**
     * 埠號
     */
    private final int port;

    /**
     * 中間人地址
     */
    private String brokerAddress  = "";

    /**
     * channel 資訊
     * @since 0.0.2
     */
    private ChannelFuture channelFuture;

    /**
     * 客戶端處理 handler
     * @since 0.0.2
     */
    private ChannelHandler channelHandler;

    /**
     * 呼叫管理服務
     * @since 0.0.2
     */
    private final IInvokeService invokeService = new InvokeService();

    /**
     * 獲取響應超時時間
     * @since 0.0.2
     */
    private long respTimeoutMills = 5000;

    /**
     * 可用標識
     * @since 0.0.2
     */
    private volatile boolean enableFlag = false;

    /**
     * 粘包處理分隔符
     * @since 1.0.0
     */
    private String delimiter = DelimiterUtil.DELIMITER;

    //set 方法

    

    @Override
    public synchronized void run() {
        // 啟動服務端
        log.info("MQ 生產者開始啟動客戶端 GROUP: {}, PORT: {}, brokerAddress: {}",
                groupName, port, brokerAddress);

        EventLoopGroup workerGroup = new NioEventLoopGroup();

        try {
            // channel handler
            this.initChannelHandler();

            // 省略,同以前

            // 標識為可用
            enableFlag = true;
        } catch (Exception e) {
            log.error("MQ 生產者啟動遇到異常", e);
            throw new MqException(ProducerRespCode.RPC_INIT_FAILED);
        }
    }

}

其中初始化 handler 的實現如下:

private void initChannelHandler() {
    final ByteBuf delimiterBuf = DelimiterUtil.getByteBuf(delimiter);

    final MqProducerHandler mqProducerHandler = new MqProducerHandler();
    mqProducerHandler.setInvokeService(invokeService);

    // handler 實際上會被多次呼叫,如果不是 @Shareable,應該每次都重新建立。
    ChannelHandler handler = new ChannelInitializer<Channel>() {
        @Override
        protected void initChannel(Channel ch) throws Exception {
            ch.pipeline()
                    .addLast(new DelimiterBasedFrameDecoder(DelimiterUtil.LENGTH, delimiterBuf))
                    .addLast(mqProducerHandler);
        }
    };
    this.channelHandler = handler;
}

MqProducerHandler 生產者處理邏輯

和消費者處理邏輯類似。

這裡最核心的就是新增響應結果:invokeService.addResponse(rpcMessageDto.getTraceId(), rpcMessageDto);

package com.github.houbb.mq.producer.handler;

/**
 * @author binbin.hou
 * @since 1.0.0
 */
public class MqProducerHandler extends SimpleChannelInboundHandler {

    private static final Log log = LogFactory.getLog(MqProducerHandler.class);

    /**
     * 呼叫管理類
     */
    private IInvokeService invokeService;

    public void setInvokeService(IInvokeService invokeService) {
        this.invokeService = invokeService;
    }

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, Object msg) throws Exception {
        ByteBuf byteBuf = (ByteBuf)msg;
        byte[] bytes = new byte[byteBuf.readableBytes()];
        byteBuf.readBytes(bytes);

        String text = new String(bytes);
        log.debug("[Client] channelId {} 接收到訊息 {}", ChannelUtil.getChannelId(ctx), text);

        RpcMessageDto rpcMessageDto = null;
        try {
            rpcMessageDto = JSON.parseObject(bytes, RpcMessageDto.class);
        } catch (Exception exception) {
            log.error("RpcMessageDto json 格式轉換異常 {}", JSON.parse(bytes));
            return;
        }

        if(rpcMessageDto.isRequest()) {
            // 請求類
            final String methodType = rpcMessageDto.getMethodType();
            final String json = rpcMessageDto.getJson();
        } else {
            // 丟棄掉 traceId 為空的資訊
            if(StringUtil.isBlank(rpcMessageDto.getTraceId())) {
                log.debug("[Client] response traceId 為空,直接丟棄", JSON.toJSON(rpcMessageDto));
                return;
            }

            invokeService.addResponse(rpcMessageDto.getTraceId(), rpcMessageDto);
            log.debug("[Client] response is :{}", JSON.toJSON(rpcMessageDto));
        }
    }
}

訊息的傳送

關心請求結果的:

public SendResult send(MqMessage mqMessage) {
    String messageId = IdHelper.uuid32();
    mqMessage.setTraceId(messageId);
    mqMessage.setMethodType(MethodType.P_SEND_MESSAGE);
    MqCommonResp resp = callServer(mqMessage, MqCommonResp.class);
    if(MqCommonRespCode.SUCCESS.getCode().equals(resp.getRespCode())) {
        return SendResult.of(messageId, SendStatus.SUCCESS);
    }
    return SendResult.of(messageId, SendStatus.FAILED);
}

不關心請求結果的傳送:

public SendResult sendOneWay(MqMessage mqMessage) {
    String messageId = IdHelper.uuid32();
    mqMessage.setTraceId(messageId);
    mqMessage.setMethodType(MethodType.P_SEND_MESSAGE);
    this.callServer(mqMessage, null);
    return SendResult.of(messageId, SendStatus.SUCCESS);
}

其中 callServer 實現如下:

/**
 * 呼叫服務端
 * @param commonReq 通用請求
 * @param respClass 類
 * @param <T> 泛型
 * @param <R> 結果
 * @return 結果
 * @since 1.0.0
 */
public <T extends MqCommonReq, R extends MqCommonResp> R callServer(T commonReq, Class<R> respClass) {
    final String traceId = commonReq.getTraceId();
    final long requestTime = System.currentTimeMillis();
    RpcMessageDto rpcMessageDto = new RpcMessageDto();
    rpcMessageDto.setTraceId(traceId);
    rpcMessageDto.setRequestTime(requestTime);
    rpcMessageDto.setJson(JSON.toJSONString(commonReq));
    rpcMessageDto.setMethodType(commonReq.getMethodType());
    rpcMessageDto.setRequest(true);
    // 新增呼叫服務
    invokeService.addRequest(traceId, respTimeoutMills);

    // 遍歷 channel
    // 關閉當前執行緒,以獲取對應的資訊
    // 使用序列化的方式
    ByteBuf byteBuf = DelimiterUtil.getMessageDelimiterBuffer(rpcMessageDto);
    //負載均衡獲取 channel
    Channel channel = channelFuture.channel();
    channel.writeAndFlush(byteBuf);
    String channelId = ChannelUtil.getChannelId(channel);

    log.debug("[Client] channelId {} 傳送訊息 {}", channelId, JSON.toJSON(rpcMessageDto));
    if (respClass == null) {
        log.debug("[Client] 當前訊息為 one-way 訊息,忽略響應");
        return null;
    } else {
        //channelHandler 中獲取對應的響應
        RpcMessageDto messageDto = invokeService.getResponse(traceId);
        if (MqCommonRespCode.TIMEOUT.getCode().equals(messageDto.getRespCode())) {
            throw new MqException(MqCommonRespCode.TIMEOUT);
        }
        String respJson = messageDto.getJson();
        return JSON.parseObject(respJson, respClass);
    }
}

測試程式碼

啟動消費者

MqConsumerPush mqConsumerPush = new MqConsumerPush();
mqConsumerPush.start();

啟動日誌如下:

[DEBUG] [2022-04-21 19:55:26.346] [main] [c.g.h.l.i.c.LogFactory.setImplementation] - Logging initialized using 'class com.github.houbb.log.integration.adaptors.stdout.StdOutExImpl' adapter.
[INFO] [2022-04-21 19:55:26.369] [Thread-0] [c.g.h.m.c.c.MqConsumerPush.run] - MQ 消費者開始啟動服務端 groupName: C_DEFAULT_GROUP_NAME, port: 9527, brokerAddress: 
[INFO] [2022-04-21 19:55:27.845] [Thread-0] [c.g.h.m.c.c.MqConsumerPush.run] - MQ 消費者啟動完成,監聽【9527】埠

啟動生產者

MqProducer mqProducer = new MqProducer();
mqProducer.start();

//等待啟動完成
while (!mqProducer.isEnableFlag()) {
    System.out.println("等待初始化完成...");
    DateUtil.sleep(100);
}

String message = "HELLO MQ!";
MqMessage mqMessage = new MqMessage();
mqMessage.setTopic("TOPIC");
mqMessage.setTags(Arrays.asList("TAGA", "TAGB"));
mqMessage.setPayload(message.getBytes(StandardCharsets.UTF_8));

SendResult sendResult = mqProducer.send(mqMessage);
System.out.println(JSON.toJSON(sendResult));

生產者日誌:

[INFO] [2022-04-21 19:56:39.609] [Thread-0] [c.g.h.m.p.c.MqProducer.run] - MQ 生產者啟動客戶端完成,監聽埠:9527
...
[DEBUG] [2022-04-21 19:56:39.895] [main] [c.g.h.m.c.s.i.i.InvokeService.addRequest] - [Invoke] start add request for seqId: a70ea2c4325641d6a5b198323228dc24, timeoutMills: 5000
...
[DEBUG] [2022-04-21 19:56:40.282] [main] [c.g.h.m.c.s.i.i.InvokeService.getResponse] - [Invoke] seq a70ea2c4325641d6a5b198323228dc24 對應結果已經獲取: com.github.houbb.mq.common.rpc.RpcMessageDto@a8f0b4
...
{"messageId":"a70ea2c4325641d6a5b198323228dc24","status":"SUCCESS"}

消費者日誌:

[DEBUG] [2022-04-21 19:56:40.179] [nioEventLoopGroup-2-1] [c.g.h.m.c.h.MqConsumerHandler.dispatch] - channelId: 502b73fffec4485c-00003954-00000001-384d194f6233433e-c8246542 接收到 method: P_SEND_MESSAGE 內容:{"methodType":"P_SEND_MESSAGE","payload":"SEVMTE8gTVEh","tags":["TAGA","TAGB"],"topic":"TOPIC","traceId":"a70ea2c4325641d6a5b198323228dc24"}

[INFO] [2022-04-21 19:56:40.180] [nioEventLoopGroup-2-1] [c.g.h.m.c.h.MqConsumerHandler.dispatch] - 收到服務端訊息: {"methodType":"P_SEND_MESSAGE","payload":"SEVMTE8gTVEh","tags":["TAGA","TAGB"],"topic":"TOPIC","traceId":"a70ea2c4325641d6a5b198323228dc24"}

[DEBUG] [2022-04-21 19:56:40.234] [nioEventLoopGroup-2-1] [c.g.h.m.c.h.MqConsumerHandler.writeResponse] - [Server] channel 502b73fffec4485c-00003954-00000001-384d194f6233433e-c8246542 response {"requestTime":1650542200182,"traceId":"a70ea2c4325641d6a5b198323228dc24","request":false,"methodType":"P_SEND_MESSAGE","json":"{\"respCode\":\"0000\",\"respMessage\":\"成功\"}"}

可以看到消費者成功的獲取到了生產者的訊息。

小結

到這裡,我們就實現了一個訊息生產者呼叫消費者的實現。

但是你可能會問,這不就是 rpc 嗎?

沒有解耦。

是的,為了解決耦合問題,我們將在下一節引入 broker 訊息的中間人。

希望本文對你有所幫助,如果喜歡,歡迎點贊收藏轉發一波。

我是老馬,期待與你的下次重逢。

開源地址

The message queue in java.(java 簡易版本 mq 實現) https://github.com/houbb/mq

擴充閱讀

rpc-從零開始實現 rpc https://github.com/houbb/rpc

相關文章