소스 코드를 기반으로 RabbitMQ 시뮬레이션 및 구현 - 가상 호스트 + 소비 디자인(7)

목차

1. 가상 호스트 + 디자인 소비

1.1 승인 문제

1.2 구체적인 구현

1.2.1 소비자 구독 메시지 구현 아이디어

1.2.2 소비자는 자신이 작업을 수행하는 방법을 설명합니다.

1.2.3 소비자에게 메시지를 푸시하기 위한 구현 아이디어

1.2.4 메시지 확인


1. 가상 호스트 + 디자인 소비


1.1 승인 문제

가상 호스트의 기능과 전달 규칙 결정의 대부분은 이전에 구현되었으며, 즉 이제 해당 전달 규칙에 따라 변환기를 통해 해당 대기열로 메시지를 보낼 수 있습니다.

그렇다면 다음 해결해야 할 문제는 소비자가 메시지(큐)를 구독하는 방법, 소비자에게 메시지를 푸시하는 방법, 소비자가 작업 수행 방법을 설명하는 방법입니다 ~

1.2 구체적인 구현

1.2.1 소비자 구독 메시지 구현 아이디어

소비자는 대기열 차원을 기반으로 메시지를 구독하며 대기열은 여러 소비자가 구독할 수 있습니다. 일단 대기열에 메시지가 있으면 이 메시지를 누구에게 전달해야 합니까? 여기서는 소비자가 "폴링" 방식으로 소비한다는 점에 동의합니다.

여기서는 소비자를 설명하기 위해 다음과 같이 클래스(ConsumerEnv)를 정의해야 합니다.

public class ConsumerEnv {
    private String consumerTag;
    private String queueName;
    private boolean autoAck;
    //通过这个回调来处理收到的消息
    private Consumer consumer;

    public ConsumerEnv(String consumerTag, String queueName, boolean autoAck, Consumer consumer) {
        this.consumerTag = consumerTag;
        this.queueName = queueName;
        this.autoAck = autoAck;
        this.consumer = consumer;
    }

    public String getConsumerTag() {
        return consumerTag;
    }

    public void setConsumerTag(String consumerTag) {
        this.consumerTag = consumerTag;
    }

    public String getQueueName() {
        return queueName;
    }

    public void setQueueName(String queueName) {
        this.queueName = queueName;
    }

    public boolean isAutoAck() {
        return autoAck;
    }

    public void setAutoAck(boolean autoAck) {
        this.autoAck = autoAck;
    }

    public Consumer getConsumer() {
        return consumer;
    }

    public void setConsumer(Consumer consumer) {
        this.consumer = consumer;
    }
}

 

그런 다음 아래와 같이 위의 여러 소비자(소비자가 현재 큐를 구독함)를 포함하도록 각 큐 객체(MSGQueue 객체)에 List 속성을 추가합니다.

    //当前队列都有哪些消费者订阅了
    private List<ConsumerEnv> consumerEnvList = new ArrayList<>();
    //记录当取到了第几个消费者(AtomicInteger 是线程安全的)
    private AtomicInteger consumerSeq = new AtomicInteger(0);

    /**
     * 添加一个新的订阅者
     * @param consumerEnv
     */
    public void addConsumerEnv(ConsumerEnv consumerEnv) {
        consumerEnvList.add(consumerEnv);
    }

    /**
     * 删除订阅者暂时先不考虑
     */

    /**
     * 挑选一个订阅者,来处理当前的消息(按照轮询的方式)
     * @return
     */
    public ConsumerEnv chooseConsumer() {
        if(consumerEnvList.size() == 0) {
            //该队列暂时没有人订阅
            return null;
        }
        //计算当前要取的下标
        int index = consumerSeq.get() % consumerEnvList.size();
        consumerSeq.getAndIncrement();// 自增
        return consumerEnvList.get(index);
    }

VirtualHost의 구독 메시지 구현

    /**
     * 订阅消息
     * 添加一个队列的订阅者,当队列收到消息之后,就要把消息推送给对应的订阅者
     * @param consumerTag 消费者的身份标识
     * @param queueName
     * @param autoAck 消息被消费之后,应答的方式,true 标识自动应答,false 标识手动应答
     * @param consumer 是一个回调函数,此处设定成函数式接口,这样后续调用 basicConsume 并且传实参的时候,就可以写作 lambda 样子了
     * @return
     */
    public boolean basicConsume(String consumerTag, String queueName, boolean autoAck, Consumer consumer) {
        //构造一个 ConsumerEnv 对象,把这个对应的队列找到,再把 Consumer 对象添加到队列中
        queueName = virtualHostName + queueName;
        try {
            consumerManager.addConsumer(consumerTag, queueName, autoAck, consumer);
            System.out.println("[VirtualHost] basicConsume 成功! queueName=" + queueName);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            System.out.println("[VirtualHost] basicConsume 失败! queueName=" + queueName);
            return false;
        }
    }

1.2.2 소비자는 자신이 작업을 수행하는 방법을 설명합니다.

메시지를 구독할 때 소비자가 메시지 처리 작업을 구현하도록 하고(메시지의 내용은 매개변수를 통해 전달되며 수행할 작업은 소비자의 비즈니스 기반에 따라 다름) 마지막으로 스레드 풀이 콜백 기능을 실행하도록 합니다.

여기서는 기능적 인터페이스(콜백 함수) 방식(람다 표현식)을 사용하여 소비자가 메시지를 구독할 때 나중에 메시지를 받은 후 메시지를 처리하는 방법을 구현할 수 있습니다.

@FunctionalInterface
public interface Consumer {

    /**
     * Delivery 的意思是 ”投递“,这个方法预期是在服务器收到消息之后来调用
     * 通过这个方法,把消息推送给对应的消费者
     * (注意,这里的方法名和参数,也都是参考 RabbitMQ 来展开的)
     * @param consumerTag
     * @param basicProperties
     * @param body
     */
    void handlerDelivery(String consumerTag, BasicProperties basicProperties, byte[] body);


}

왜 이런 식으로 구현됩니까?

한편으로 이 아이디어는 RabbitMQ에도 적용됩니다.

반면, 클래스 없이는 자바 함수가 존재할 수 없기 때문인데, 이런 람다를 구현하기 위해 자바는 함수형 인터페이스를 도입해 나라를 구했다.

기능적 인터페이스의 경우:

  1. 첫 번째는 인터페이스 유형입니다.
  2. 방법은 오직 하나뿐이다
  3. @FunctionalInterface 주석을 추가합니다.

사실 이는 람다의 기본 구현(본질)이기도 합니다.

1.2.3 소비자에게 메시지를 푸시하기 위한 구현 아이디어

여기에서 검색 스레드를 추가하고 대기열에서 작업을 가져오도록 할 수 있습니다.

스캔 스레드를 사용할 때 스레드 풀을 사용해야 하는 이유는 무엇입니까?

메시지를 얻고 콜백을 실행해야 하는 검색 스레드가 하나만 있는 경우 이 스레드는 너무 바쁠 수 있습니다. 소비자가 제공한 콜백이 무엇을 하는지 모르기 때문입니다.

스캐닝 스레드는 어떤 대기열에 새 메시지가 있는지 어떻게 알 수 있나요?

  1. 간단하고 조악한 방법은 스캐닝 스레드가 모든 대기열을 직접 순환하도록 하고 요소가 발견되면 즉시 처리하는 것입니다.
  2. 또 다른 좀 더 우아한 방법(내가 택한 방법)은 차단 대기열을 사용하는 것입니다 . 대기열의 요소는 메시지를 수신하는 대기열의 이름입니다. 스캐닝 스레드는 이 차단 상대에만 집중하면 됩니다 . 이때, 차단 대기열 전달된 대기열 이름은 "토큰"과 동일합니다.

토큰을 얻을 때마다 군대를 한 번 동원할 수 있습니다. 즉, 해당 대기열에서 메시지를 받을 수 있습니다.

특히 소비자의 위 동작을 관리하려면 ConsumerManager 클래스를 구현하세요.

public class ConsumerManager {
    // 持有上层的 VirtualHost 对象的引用,用来操作数据
    private VirtualHost parent;
    // 指定一个线程池,负责取执行具体的回调任务
    private ExecutorService workerPool = Executors.newFixedThreadPool(4);
    //存放令牌的队列
    private BlockingQueue<String> tokenQueue = new LinkedBlockingQueue<>();
    //扫描线程
    private Thread scannerThread = null;


    /**
     * 初始化
     * @param parent
     */
    public ConsumerManager(VirtualHost parent) {
        this.parent = parent;

        //创建扫描线程,取队列中消费消息
        scannerThread = new Thread(() -> {
            while(true) {
                try {
                    //1.拿到令牌
                    String queueName = tokenQueue.take();
                    //2.根据令牌,找到队列
                    MSGQueue queue = parent.getMemoryDataCenter().getQueue(queueName);
                    if(queue == null) {
                        throw new MqException("[ConsumerManager] 取到令牌后发现,该队列名不存在!queueName=" + queueName);
                    }
                    //3.从这个队列中消费一个消息
                    synchronized (queue) {
                        consumeMessage(queue);
                    }
                } catch (InterruptedException | MqException e) {
                    throw new RuntimeException(e);
                }
            }
        });
        //设置为后台线程
        scannerThread.setDaemon(true);
        scannerThread.start();
    }

    public void notifyConsume(String queueName) throws InterruptedException {
        tokenQueue.put(queueName);
    }

    /**
     * 添加消费者
     * 找到对应队列的 List 列表, 把消费者添加进去,最后判断,如果有消息,就立刻消费
     * @param consumerTag 消费者身份标识
     * @param queueName
     * @param autoAck 消息被消费之后,应答的方式,true 标识自动应答,false 标识手动应答
     * @param consumer 是一个回调函数,此处设定成函数式接口,这样后续调用 basicConsume 并且传实参的时候,就可以写作 lambda 样子了
     * @throws MqException
     */
    public void addConsumer(String consumerTag, String queueName, boolean autoAck, Consumer consumer) throws MqException {
        //找到对应的队列
        MSGQueue queue = parent.getMemoryDataCenter().getQueue(queueName);
        if(queue == null) {
            throw new MqException("[ConsumerManager] 队列不存在! queueName=" + queueName);
        }
        ConsumerEnv consumerEnv = new ConsumerEnv(consumerTag, queueName, autoAck, consumer);
        synchronized (queue) {
            queue.addConsumerEnv(consumerEnv);
            //如果当前队列中已经有一些消息了,需要立即消费掉
            int n = parent.getMemoryDataCenter().getMessageCount(queueName);
            for(int i = 0; i < n; i++) {
                //这个方法调用一次就消费一条消息
                consumeMessage(queue);
            }
        }
    }

    /**
     * 扫描线程:找到对应的队列后,消费者从队列中拿出消息并消费
     * @param queue
     */
    private void consumeMessage(MSGQueue queue) {
        //1.按照轮询的方式,找个消费者出来
        ConsumerEnv luckDog = queue.chooseConsumer();
        if(luckDog == null) {
            //当前队列中没有消费者,暂时不用消费,等后面有消费者了再说
            return;
        }
        //2.从队列中取出一个消息
        Message message = parent.getMemoryDataCenter().pollMessage(queue.getName());
        if(message == null) {
            //当前队列中还没有消息,也不需要消费
            return;
        }
        //3.把消息带入到消费者的回调方法中,丢给线程池执行
        workerPool.submit(() -> {
            try {
                //1.把消息放到待确认的集合当中,这个操作一定要在执行回调之前(防止执行回调过程中出现异常,导致消息丢失)
                parent.getMemoryDataCenter().addMessageWaitAck(luckDog.getQueueName(), message);
                //2.真正执行回调操作
                luckDog.getConsumer().handlerDelivery(luckDog.getConsumerTag(), message.getBasicProperties(),
                        message.getBody());
                //3.如果当前是 ”自动应答“ ,就可以直接把消息删除了
                //  如果当前是 ”手动应答“ ,则先不处理,交给后续消费者调用 basicAck 方法来处理
                if(luckDog.isAutoAck()) {
                    //1) 删除硬盘上的消息
                    if(message.getDeliverMode() == 2) {
                        parent.getDiskDataCenter().deleteMessage(queue, message);
                    }
                    //2) 删除上面的待确认集合中的消息
                    parent.getMemoryDataCenter().removeMessageWaitAck(queue.getName(), message.getMessageId());
                    //3) 删除内存上的消息中心的消息
                    parent.getMemoryDataCenter().removeMessage(message.getMessageId());
                    System.out.println("[ConsumerManager] 消息被成功消费!queueName=" + queue.getName());
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        });
    }
}

1.2.4 메시지 확인

메시지 확인은 메시지가 올바르게 소비되는지 확인하는 것입니다~~ 

올바른 소비는 소비자의 콜백 메서드가 성공적으로 실행되고(예외가 발생하지 않음) 이 메시지의 임무가 완료되었으며 이때 삭제할 수 있음을 의미합니다.

메시지를 잃지 않는 효과를 얻기 위한 구체적인 단계는 다음과 같습니다.

  1. 콜백을 실제로 실행하기 전에 메시지를 "보류 중인 컬렉션"에 넣어 콜백 실패로 인한 메시지 손실을 방지하세요.
  2. 콜백 실행
  3. 소비자가 autoAck=true를 채택하면 예외가 발생하지 않고 콜백이 실행되는 것으로 간주되며, 소비에 성공하더라도 메시지가 삭제될 수 있다.
    1. 하드 디스크
    2. 메모리 내 메시지 센터
    3. 확인할 메시지 수집
  4. 현재 소비자가 autoAck=false를 채택하고 수동으로 응답하는 경우 소비자는 자체 콜백 메서드에서 basicAck 코어 API를 명시적으로 호출하여 응답을 표현해야 합니다.

 basicAck는 활성 응답을 완료합니다.

    /**
     * 确认消息
     * 各个维度删除消息即可
     * @param queueName
     * @param messageId
     * @return
     */
    public boolean basicAck(String queueName, String messageId) {
        queueName = virtualHostName + queueName;
        try {
            //1.获取消息和队列
            MSGQueue queue = memoryDataCenter.getQueue(queueName);
            if(queue == null) {
                throw new MqException("[VirtualHost] 要确认的队列不存在!queueName=" + queueName);
            }
            Message message = memoryDataCenter.getMessage(messageId);
            if(message == null) {
                throw new MqException("[VirtualHost] 要确认的消息不存在!messageId=" + messageId);
            }
            //2.各个维度删除消息
            if(message.getDeliverMode() == 2) {
                diskDataCenter.deleteMessage(queue, message);
            }
            memoryDataCenter.removeMessage(messageId);
            memoryDataCenter.removeMessageWaitAck(queueName, messageId);
            System.out.println("[VirtualHost] basicAck 成功,消息确认成功!queueName=" + queueName +
                    ", messageId=" + messageId);
            return true;
        } catch (Exception e) {
            System.out.println("[VirtualHost] basicAck 失败,消息确认失败!queueName=" + queueName +
                    ", messageId=" + messageId);
            e.printStackTrace();
            return false;
        }
    }

스캔 스레드가 자동 응답을 완료합니다.

    /**
     * 扫描线程:找到对应的队列后,消费者从队列中拿出消息并消费
     * @param queue
     */
    private void consumeMessage(MSGQueue queue) {
        //1.按照轮询的方式,找个消费者出来
        ConsumerEnv luckDog = queue.chooseConsumer();
        if(luckDog == null) {
            //当前队列中没有消费者,暂时不用消费,等后面有消费者了再说
            return;
        }
        //2.从队列中取出一个消息
        Message message = parent.getMemoryDataCenter().pollMessage(queue.getName());
        if(message == null) {
            //当前队列中还没有消息,也不需要消费
            return;
        }
        //3.把消息带入到消费者的回调方法中,丢给线程池执行
        workerPool.submit(() -> {
            try {
                //1.把消息放到待确认的集合当中,这个操作一定要在执行回调之前(防止执行回调过程中出现异常,导致消息丢失)
                parent.getMemoryDataCenter().addMessageWaitAck(luckDog.getQueueName(), message);
                //2.真正执行回调操作
                luckDog.getConsumer().handlerDelivery(luckDog.getConsumerTag(), message.getBasicProperties(),
                        message.getBody());
                //3.如果当前是 ”自动应答“ ,就可以直接把消息删除了
                //  如果当前是 ”手动应答“ ,则先不处理,交给后续消费者调用 basicAck 方法来处理
                if(luckDog.isAutoAck()) {
                    //1) 删除硬盘上的消息
                    if(message.getDeliverMode() == 2) {
                        parent.getDiskDataCenter().deleteMessage(queue, message);
                    }
                    //2) 删除上面的待确认集合中的消息
                    parent.getMemoryDataCenter().removeMessageWaitAck(queue.getName(), message.getMessageId());
                    //3) 删除内存上的消息中心的消息
                    parent.getMemoryDataCenter().removeMessage(message.getMessageId());
                    System.out.println("[ConsumerManager] 消息被成功消费!queueName=" + queue.getName());
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        });
    }

콜백 메서드에서 예외가 발생하면 어떻게 되나요?

콜백 메소드에서 예외가 발생하면 후속 로직을 실행할 수 없으며 메시지는 항상 확인을 위해 컬렉션에 유지됩니다. RabbitMQ의 접근 방식은 또 다른 스캐닝 스레드를 생성하는 것입니다(실제로 RabbitMQ에서는 스레드라고 부르지 않습니다. 프로세스라고 부르지만 이 프로세스는 운영 체제의 프로세스가 아니라 Erlang의 개념입니다.) 각 메시지가 이 확인 대상 컬렉션에 얼마나 오랫동안 있었는지 주의를 기울이는 역할을 담당합니다. 특정 시간 범위를 초과하면 메시지는 A 특정 대기열인 "배달 못한 편지 대기열"에 배치됩니다(여기서는 표시하지 않습니다. 필요한 경우 개인적으로 메시지를 보낼 수 있습니다).

콜백 실행 중에 브로커 서버가 충돌하여 모든 메모리 데이터가 손실되면 어떻게 되나요?

이때 하드 디스크의 데이터는 여전히 남아 있습니다. 브로커 서버가 다시 시작된 후 메시지는 소비된 적이 없는 것처럼 다시 메모리에 로드됩니다. 소비자는 메시지를 다시 얻을 수 있는 기회를 갖게 됩니다. 다시 소비(반복 소비의 문제), 소비자의 비즈니스 코드로 보장되며 브로커 서버에서는 이를 통제할 수 없습니다.

 

추천

출처blog.csdn.net/CYK_byte/article/details/132424991