NettyのBufferChannelSelector用法

news2024/12/26 13:49:48

这一篇介绍Buffer&Channel&Selector的常见API使用案例

1、Buffer

        1.1、从Buffe中读取/写入

        以ByteBuffer为例。Buffer需要和Channel结合使用(在上一篇中提到,通道是数据传输的载体,缓冲区是数据的临时存储区)。

        那么如何获取Channel对象?我们可以通过FileInputStream的.getChannel() 方法获取。

new FileInputStream(new File("D:\\Idea_workspace\\2024\\netty\\src\\data.txt")).getChannel()

        获取ByteBuffer对象需要通过.allocate() 静态方法获取,同时需要指定容量:

 ByteBuffer byteBuffer = ByteBuffer.allocate(10);

        从channel中读取数据,写入ByteBuffer,则用到了channel.read()方法,它的返回值如果为-1则代表读取结束。

int len = channel.read(byteBuffer);

        如果需要读取ByteBuffer中的内容,需要将Buffer从写模式切换到读模式,用到了Buffer的.flip() 方法,然后利用.get()方法进行读取:

byteBuffer.flip();

        完整案例:

@Slf4j
public class TestByteBuffer {
    public static void main(String[] args) {
        try (FileChannel channel = new FileInputStream(new File("D:\\Idea_workspace\\2024\\netty\\src\\data.txt")).getChannel()) {
            //准备缓冲区 指定容量为10
            ByteBuffer byteBuffer = ByteBuffer.allocate(10);
            while (true) {
                //从channel中读取数据,写入ByteBuffer
                int len = channel.read(byteBuffer);
                log.debug("获取到的长度:{}",len);
                //len == -1 说明已读取完成
                if (len == -1){
                    break;
                }
                //读取buffer的内容
                //flip():将Buffer从写模式切换到读模式。
                byteBuffer.flip();
                while (byteBuffer.hasRemaining()){
                    log.debug("获取到的字节:{}",(char)byteBuffer.get());
                }
                //clear():清空Buffer,准备写入。
                byteBuffer.clear();
            }
        } catch (IOException e) {


        }
    }
}

        在前一篇中提到,缓冲区会维护一个类似于数组的结构,其中包含了position(指针)、limit(限制)和capacity(容量)。三个关键属性,下面我们再通过一个案例结合图片分析:

        创建一个容量为10的缓冲区:

 ByteBuffer allocate = ByteBuffer.allocate(10);
+--------+-------------------- all ------------------------+----------------+
position: [0], limit: [10]
         +-------------------------------------------------+
         |  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| 00 00 00 00 00 00 00 00 00 00                   |abc.......      |
+--------+-------------------------------------------------+----------------+

         放入三个元素:

allocate.put(new byte[]{97,98,99});
+--------+-------------------- all ------------------------+----------------+
position: [3], limit: [10]
         +-------------------------------------------------+
         |  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| 61 62 63 00 00 00 00 00 00 00                   |abc.......      |
+--------+-------------------------------------------------+----------------+

        切换成读模式,并且获取一个元素:

 allocate.flip();
 allocate.get();
position: [1], limit: [3]
         +-------------------------------------------------+
         |  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| 61 62 63 00 00 00 00 00 00 00                   |abc.......      |
+--------+-------------------------------------------------+----------------+

        如果不切换成读模式呢?那么指针在position: [3] 读取到的是00

        1.1.1、compact()

        利用compact()切换成写模式。将未读的数据复制到Buffer的开头,然后将位置设到最后一个未读元素的后面。(此时数组中读取了索引为0的元素,调用 compact()方法后,就将1,2索引上的元素复制到0,1上,如果下次写入是从2索引开始)

allocate.compact();
position: [2], limit: [10]
         +-------------------------------------------------+
         |  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| 62 63 63 00 00 00 00 00 00 00                   |bcc.......      |
+--------+-------------------------------------------------+----------------+

         再次放入三个元素,覆盖掉2索引上的63:

allocate.put(new byte[]{100,101,102});
+--------+-------------------- all ------------------------+----------------+
position: [5], limit: [10]
         +-------------------------------------------------+
         |  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| 62 63 64 65 66 00 00 00 00 00                   |bcdef.....      |
+--------+-------------------------------------------------+----------------+

        利用clear()切换到写模式,清空数组中的元素:

allocate.clear();
+--------+-------------------- all ------------------------+----------------+
position: [0], limit: [10]
         +-------------------------------------------------+
         |  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| 62 63 64 65 66 00 00 00 00 00                   |bcdef.....      |
+--------+-------------------------------------------------+----------------+

        切换成读模式,除了常规的filp()方法,还有rewind()、mark() & reset()方法:

        1.1.2、rewind()

        我们再次创建一个容量为10的缓冲区,并且初始化4个元素,使用filp()切换到读模式,再获取两个元素:

ByteBuffer buffer = ByteBuffer.allocate(10);
buffer.put(new byte[]{97,98,99,100});
buffer.flip();
buffer.get();
buffer.get();

        此时的position在2位置。

 position: [2], limit: [4]

         +-------------------------------------------------+
         |  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| 61 62 63 64 00 00 00 00 00 00                   |abcd......      |
+--------+-------------------------------------------------+----------------+
+--------+-------------------- all ------------------------+----------------+

        rewind() 方法的底层会将position重新设置为0:

         调用rewind() 方法:

buffer.rewind();

         position重新回到0的位置

+--------+-------------------- all ------------------------+----------------+
position: [0], limit: [4]
         +-------------------------------------------------+
         |  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| 61 62 63 64 00 00 00 00 00 00                   |abcd......      |
+--------+-------------------------------------------------+----------------+

        1.1.3、mark() & reset()

         mark() & reset() 通常会结合使用。 mark() 方法的作用是标记当前的position,reset() 方法的作用是重置position为 mark() 方法标记的位置。

        接上面的案例,经过了rewind() 方法, position重新回到0的位置。我们进行四次读取,但是在第二次读取结束后使用 mark() 方法:

buffer.get();
buffer.get();
buffer.mark();
buffer.get();
buffer.get();
+--------+-------------------- all ------------------------+----------------+
position: [4], limit: [4]
         +-------------------------------------------------+
         |  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| 61 62 63 64 00 00 00 00 00 00                   |abcd......      |
+--------+-------------------------------------------------+----------------+

        调用reset() 方法:

buffer.reset();
+--------+-------------------- all ------------------------+----------------+
position: [2], limit: [4]
         +-------------------------------------------------+
         |  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| 61 62 63 64 00 00 00 00 00 00                   |abcd......      |
+--------+-------------------------------------------------+----------------+
         1.1.4、get(index)

        get(index)是get()方法的重载。和get()方法的区别在于获取元素不会移动指针。

         1.2、半包、粘包

        半包现象:

        一个完整的应用层数据包在传输过程中被分成了多个TCP包发送和接收。接收方在读取数据时,可能只能读到一个完整包的一部分:

        假设发送端要发送一个大小为1024字节的数据包,但由于网络或缓冲区限制,这个数据包被拆分成两个TCP包,第一个包包含前512字节,第二个包包含后512字节。接收方在读取时可能先读到前512字节,接下来再读到后512字节。

        其原因在于:

  • 在网络传输过程中,数据包可能会被拆分成多个较小的TCP包进行传输。
  • 发送端的缓冲区限制了每次可以发送的数据量,因此较大的数据包可能会被拆分。
  • 接收方读取数据的速度可能赶不上数据到达的速度,导致每次读取时只能获取部分数据。

        粘包现象

        多个应用层数据包在传输过程中被粘合在一起,接收方在读取数据时一次性读取到了多个数据包的内容:

        假设发送端发送了两个大小分别为512字节的数据包,接收方由于读取速度较慢,可能一次性读取到1024字节的数据,这样两个数据包的内容就粘在了一起。

        其原因在于:

  • 发送端以较快的速度发送多个数据包,而接收方读取数据的速度较慢,导致多个数据包积累在接收缓冲区中。
  • TCP是面向字节流的协议,没有明确的消息边界,多个应用层数据包可能在TCP层被拼接在一起。        

        为了处理半包和粘包现象,可以采用以下几种常见的方法:

  1. 定长消息:预先规定每个消息的长度,接收方根据约定的长度读取固定大小的字节数据。
  2. 分隔符:在每个消息之间插入特殊的分隔符,接收方读取数据时根据分隔符进行拆分。
  3. 消息头:在每个消息前添加固定长度的消息头,消息头中包含消息的长度信息,接收方首先读取消息头,然后根据消息头中的长度信息读取相应长度的消息体。

下面是一种通过设置消息头进行解决的案例:

发送方

public void send(SocketChannel socketChannel, byte[] data) throws IOException {
    //假设我们data的长度为6
     
    //创建 ByteBuffer 其大小为消息头(4 字节)加上消息体(6 字节)的长度:
    ByteBuffer buffer = ByteBuffer.allocate(4 + data.length);

    //将消息体长度(6)放入缓冲区,作为消息头:
    //写入 4 字节的消息头,内容为 6
    buffer.putInt(data.length);

    // 将消息体放入缓冲区
    buffer.put(data);

    // 切换缓冲区为读模式,准备写入到SocketChannel中
    buffer.flip();

    // 循环写入SocketChannel,确保缓冲区中的数据全部发送出去
    while (buffer.hasRemaining()) {
        socketChannel.write(buffer);
    }
}

接收方

public void receive(SocketChannel socketChannel) throws IOException {
    // 创建一个ByteBuffer用于读取消息头(4字节)
    ByteBuffer headerBuffer = ByteBuffer.allocate(4);

    // 确保消息头全部读入缓冲区
    while (headerBuffer.hasRemaining()) {
        socketChannel.read(headerBuffer);
    }
    // 切换缓冲区为读模式,准备读取消息头中的数据
    headerBuffer.flip();

    // 读取消息头,获取消息体的长度
    // length 的值为 6
    int length = headerBuffer.getInt();

    // 创建一个ByteBuffer用于读取消息体
    // length 的值为 6
    ByteBuffer dataBuffer = ByteBuffer.allocate(length);

    // 确保消息体全部读入缓冲区
    while (dataBuffer.hasRemaining()) {
        socketChannel.read(dataBuffer);
    }
    // 切换缓冲区为读模式,准备读取消息体中的数据
    dataBuffer.flip();

    // 从缓冲区中读取消息体数据
    byte[] data = new byte[length];
    dataBuffer.get(data);

}

2、Channel

        我们重点介绍与网络编程有关的SocketChannel和ServerSocketChannel。

       SocketChannel和ServerSocketChannel,又分为阻塞和非阻塞两种模式:

        2.1、阻塞模式

        首先需要创建服务器:

/**
 * nio的阻塞模式 服务端
 */
@Slf4j
public class Server {
    public static void main(String[] args) throws IOException {
        //缓冲区
        ByteBuffer byteBuffer = ByteBuffer.allocate(16);
        //创建服务器
        ServerSocketChannel ssc = ServerSocketChannel.open();
        //绑定端口
        ssc.bind(new InetSocketAddress(8080));

        //连接集合
        ArrayList<SocketChannel> channels = new ArrayList<>();
        while (true){
            //接受客户端的信息
            log.debug("开始链接...");
            SocketChannel socketChannel = ssc.accept();//没有连接时会阻塞
            log.debug("链接完成...{}",socketChannel);
            channels.add(socketChannel);
            for (SocketChannel channel : channels) {
                //把信息读取到缓冲区
                log.debug("开始读取");
                channel.read(byteBuffer);//读取不到数据时会阻塞
                byteBuffer.flip();
                debugRead(byteBuffer);
                byteBuffer.clear();
                log.debug("读取结束");
            }
        }
    }
}

        创建前端:

/**
 * nio的阻塞模式 前端
 */
public class Client {
    public static void main(String[] args) throws IOException {
        SocketChannel socketChannel = SocketChannel.open();
        socketChannel.connect(new InetSocketAddress("localhost",8080));
        // 发送消息到服务器
        String message = "Hello, Server!";
        ByteBuffer buffer = ByteBuffer.wrap(message.getBytes());
        socketChannel.write(buffer);
    }
}

        这里的阻塞主要体现在两个地方,第一在于,服务器接受客户端连接时,如果一直没有连接,会阻塞。第二在于,连接建立上,但是客户端一直没有发送消息,同样会阻塞。

        同时启动前端和服务端:

        此时前端还没有连接上服务器,服务器一直阻塞在SocketChannel socketChannel = ssc.accept()

        前端尚未发出消息:

        服务器一直阻塞在channel.read(byteBuffer);

        2.2、非阻塞模式

        非阻塞模式和阻塞模式的代码大致相同,在服务器对于SocketChannel和ServerSocketChannel设置configureBlocking属性为false。

        只启动服务器,很显然此时是没有任何一个客户端连接上的,没有像阻塞模式那样在SocketChannel socketChannel = ssc.accept();这一行一直等待。

        前端连接上之后,还没有发送消息:

        服务器没有在int read = channel.read(byteBuffer);阻塞。

3、Selector

        使用Channel的阻塞模式效率很低,而非阻塞模式,如果一直没有客户端连接或者读取不到数据,就会在循环中空转,也是对cpu的一种浪费,实际开发中也不会采用这样的模式。

        为了改进上面的弊端,引入了Selector(选择器),核心思想是一个Selector去管理多个Channel,根据Channel注册的不同事件类型去进行操作

/**
 * NIO selector
 */
@Slf4j
public class ServerSelector {
    public static void main(String[] args) throws IOException {
        //创建selector,管理多个channel
        Selector selector = Selector.open();

        //创建服务器
        ServerSocketChannel ssc = ServerSocketChannel.open();
        //如果要注册到 selector上 必须先设置成非阻塞
        ssc.configureBlocking(false);

        //把ssc注册到selector上,并且设置监听accept事件
        SelectionKey key = ssc.register(selector, 0, null);
        key.interestOps(SelectionKey.OP_ACCEPT);
        log.debug("注册的key:{}", key);


        //绑定端口
        ssc.bind(new InetSocketAddress(8080));

        //连接集合
        ArrayList<SocketChannel> channels = new ArrayList<>();
        while (true) {
            //如果没有事件发生会阻塞
            selector.select();
            Iterator<SelectionKey> it = selector.selectedKeys().iterator();
            while (it.hasNext()){
                SelectionKey selectionKey = it.next();
                log.debug("获取的Key:{}",selectionKey);
                //根据获取到的SelectionKey进行分派不同的事件
                if (selectionKey.isAcceptable()) {
                    ServerSocketChannel channel = ((ServerSocketChannel) selectionKey.channel());
                    SocketChannel sc = channel.accept();
                    log.debug("获取的连接:{}",sc);
                    //将sc同时注册到selector上,监听读事件
                    sc.configureBlocking(false);
                    SelectionKey scKey = sc.register(selector, 0, null);
                    scKey.interestOps(SelectionKey.OP_READ);
                }else if (selectionKey.isReadable()){
                    SocketChannel socketChannel = (SocketChannel) selectionKey.channel();
                    ByteBuffer byteBuffer = ByteBuffer.allocate(16);
                    socketChannel.read(byteBuffer);
                    byteBuffer.flip();
                    debugRead(byteBuffer);
                }
            }
        }
    }
}

        只启动服务端,因为没有连接事件,在selector.select()这一行陷入阻塞。

        启动客户端:

        并且尝试向服务端发送数据:

        服务端没有接收到消息,反而出现了一个空指针异常,这是什么原因?


        Selector是一个抽象类,具体实现是WindowsSelectorImpl 

System.out.println(selector.getClass());

        根据堆栈信息,出现异常在52行,我们在服务端打断点看一下原因:

        重点关注selectedKeys和keys两个成员变量:

        把ServerSocketChannel注册到selector上时,keys将其记录:

        在运行了selector.select()方法后,selectedKeys成员变量中也会记录ServerSocketChannel

        在将SocketChannel注册到selector上后,keys将其记录。

 

        此时第一次循环结束,注意,selectedKeys中的ServerSocketChannel没有被删除,下一次循环依旧会匹配到ServerSocketChannel的accept事件

        再次进入selectionKey.isAcceptable()分支。此时没有新的连接,channel.accept()会返回null。(为什么会返回null?因为设置的是非阻塞模式),从而导致空指针。

        从上面的过程中可以发现,selectedKeys 集合中的元素不会自动移除,需要我们手动删除。(也是为什么要使用迭代器而不是增强for循环的原因,如果使用增强for一边遍历一边增删集合中的元素,会导致并发修改异常。

        改进上面的代码:

SelectionKey selectionKey = it.next();
log.debug("获取的Key:{}",selectionKey);
it.remove();

小结:

       改造服务器的代码,分为以下的步骤:

  1. 创建selector,管理多个channel。
  2. 将ServerSocketChannel或SocketChannel注册到selector上(必须设置成非阻塞模式)。并且设置即将监听的事件。
  3. 调用selector的select方法。
  4. 得到selector中所有key并遍历,根据不同的key进行任务分派。
  5. 移除key
        3.1、Selector的消息边界问题

        Selector也是基于Buffer实现,那么它是如何解决半包,粘包问题的呢?同样有三种方式:

  1. 在消息传输之前固定好Buffer的容量,例如发送了两条消息,第一条消息占了8个字节,第二条消息只有2个字节,但无论消息有多大,都固定容量为10。
  2. 在消息中加入特殊的符号,根据符号进行拆分。
  3. 使用消息头+消息体。消息头固定大小,记录了消息体的大小。

        我们这次使用第二种方式:

        消息拆分方法,假设我们在消息中使用'\n'字符进行拆分:

 /**
     * abc123\nabc...\nab ->
     * abc123
     * abc...
     * ab
     * @param source
     */
    private static void split(ByteBuffer source) {
        //切换到读取模式
        source.flip();
        //找到完整的\n字符
        for (int i = 0; i < source.limit(); i++) {
            if (source.get(i) == '\n'){
                int length = i + 1 - source.position();
                ByteBuffer target = ByteBuffer.allocate(length);
                for (int j = 0; j < length; j++) {
                    target.put(source.get());
                }
                debugAll(target);
            }
        };
        //将剩余部分向前压缩
        source.compact();
    }

        在读取消息时,就不能将ByteBuffer作为一个局部变量了。如果触发了多次循环,局部变量每次获取到的都不是同一个ByteBuffer。我们可以在将SocketChannel或ServerSocketChannel注册到selector时,给其绑定一个专属的ByteBuffer(类似于把队列绑定到交换机上):

ByteBuffer byteBuffer = ByteBuffer.allocate(16);
SelectionKey scKey = sc.register(selector, 0,byteBuffer );

        需要使用的时候再取出:

//取出附件
ByteBuffer scByteBuffer = (ByteBuffer) selectionKey.attachment();

        改造读取消息的代码,加入扩容机制。

split(scByteBuffer);
//加入扩容机制
if (scByteBuffer.position() == scByteBuffer.limit()){
     ByteBuffer newByteBuffer = ByteBuffer.allocate(scByteBuffer.capacity() * 2);
     scByteBuffer.flip();
     newByteBuffer.put(scByteBuffer);
     selectionKey.attach(newByteBuffer);
}

        下面我们通过debug的方式加深一下印象:

        客户端即将发送的消息如下,第一条消息超过了16个字节:

        切换到读取模式前,ByteBuffer已经占满了16个字节:

        切换到读取模式,从0索引开始读:

        第一次循环结束后仍然未找到分隔符的位置,触发扩容:

        将扩容后的ByteBuffer(32长度)重新放回附件中,替换掉原先16长度的:

        下一次循环进入split方法:

        在第18个字节的位置找到了分隔符:

 

        3.2、可写事件

        如果服务器端需要向客户端一次写入较多的数据,可以利用Selector的可写事件分批完成。

        其核心思想在于,如果第一次向客户端没有完全写入,就给socketChannel向Selector注册一个可写事件, 并且将未读完的ByteBuffer放入附件中。下次循环监听到了可写事件,进入分支再次向客户端写入上一次未完成的内容。直到全部写入完毕,再释放附件,并且删除可写事件

        完整案例:

public class ServerWrite {
    public static void main(String[] args) throws IOException {
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        serverSocketChannel.configureBlocking(false);
        serverSocketChannel.bind(new InetSocketAddress(8080));

        Selector selector = Selector.open();
        //将ServerSocketChannel注册到Selector上,默认连接模式
        serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT,null);

        while (true){
            selector.select();
            Set<SelectionKey> selectionKeys = selector.selectedKeys();
            Iterator<SelectionKey> it = selectionKeys.stream().iterator();
            while (it.hasNext()) {
                SelectionKey selectionKey = it.next();
                it.remove();
                if (selectionKey.isAcceptable()) {
                    SocketChannel socketChannel =  handleAccept(selector,selectionKey);
                    //将SocketChannel注册到Selector上,默认读取模式
                    SelectionKey scKey = socketChannel.register(selector, SelectionKey.OP_READ, null);
                    StringBuilder sb = new StringBuilder();
                    for (int i = 0; i < 50000000; i++) {
                        sb.append("a");
                    }
                    ByteBuffer byteBuffer = StandardCharsets.UTF_8.encode(sb.toString());
                    //未处理完
                    if (byteBuffer.hasRemaining()) {
                        //SocketChannel再次注册写模式
                        scKey.interestOps(scKey.interestOps() + SelectionKey.OP_WRITE);
                        //将未处理完的ByteBuffer放入SocketChannel  scKey的附件中
                        scKey.attach(byteBuffer);
                    }
                //监听到读取模式
                }else if (selectionKey.isWritable()){
                    //从附件中取出ByteBuffer
                    ByteBuffer byteBuffer = (ByteBuffer) selectionKey.attachment();
                    //取出SocketChannel
                    SocketChannel socketChannel = (SocketChannel) selectionKey.channel();
                    //再次写入
                    int write = socketChannel.write(byteBuffer);
                    System.out.println(write);
                    //直到写入完成,删除附件和写模式
                    if (!byteBuffer.hasRemaining()){
                        selectionKey.attach(null);
                        selectionKey.interestOps(selectionKey.interestOps() - SelectionKey.OP_WRITE);
                    }
                }
            }
        }
    }

    private static SocketChannel handleAccept(Selector selector,SelectionKey selectionKey) throws IOException {
        ServerSocketChannel ssc = (ServerSocketChannel) selectionKey.channel();
        ssc.configureBlocking(false);
        return ssc.accept();
    }
}

附录:

NIO Selector四大事件触发的时机:

  • OP_ACCEPT:当有客户端尝试连接到服务器时,ServerSocketChannel会触发OP_ACCEPT事件。
  • OP_CONNECT:当客户端发起连接请求后,连接操作完成时会触发OP_CONNECT事件。
  • OP_READ:当通道中有数据可读时,会触发OP_READ事件。这意味着客户端或服务器端的通道有数据可以读取。
  • OP_WRITE:当通道准备好写数据时,会触发OP_WRITE事件。这意味着可以向通道写入数据而不会阻塞。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1809572.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

44【Aseprite 作图】樱花丸子——拆解

1 枝干 2 花朵&#xff1a;其实只要形状差不多都行&#xff0c;有三个颜色&#xff0c;中间花蕊颜色深一点&#xff0c;中间花蕊外的颜色偏白&#xff1b;不透明度也可以改一下&#xff0c;就变成不同颜色 3 丸子 最外层的颜色最深&#xff0c;中间稍浅&#xff0c;加一些高光…

解决福昕风腾PDF套装无法打印在线电子签章的方法

使用福昕风腾PDF套装打印在线电子签章文件时发现&#xff0c;在线盖的电子印章和签名却打印不出来&#xff0c;后现发现&#xff0c;按图中选项选择“文档”&#xff0c;即可完整打印文件内容及电子签章。留印。

【2023】LitCTF

LitCTF2023&#xff08;复现&#xff09; Web&#xff1a; 1、我Flag呢&#xff1f; ​ ctrlu 读取源码&#xff0c;在最后发现了flag&#xff1a; <!--flag is here flagNSSCTF{3d5218b9-4e24-4d61-9c15-68f8789e8c48} -->2、PHP是世界上最好的语言&#xff01;&…

Spring AOP 基于注解实现用户权限校验

主要注解 interface&#xff1a;继承了 Annotation 接口的自定义注解&#xff0c;定义注释类型。 Target&#xff1a;表示这个注解可以应用的地方&#xff0c;此处做权限校验是用在方法上的&#xff0c;所以此处的值为 Target(ElementType.METHOD) …

Qt设置进程环境变量

目的 最近遇上了设置环境变量的问题,看似是小问题,想解决好,实在是一件不容易的事。 看看当时,我遇到这些问题的无奈: 首先说,是在windows进行环境变量的设置,如果在Linux那肯定是简单了。 一般来说,首先是设置系统的环境变量,这条路,是一条复杂的路,首先得写一个…

接口测试时, 数据Mock为何如此重要?

一、为什么要mock 工作中遇到以下问题&#xff0c;我们可以使用mock解决&#xff1a; 1、无法控制第三方系统某接口的返回&#xff0c;返回的数据不满足要求 2、某依赖系统还未开发完成&#xff0c;就需要对被测系统进行测试 3、有些系统不支持重复请求&#xff0c;或有访问…

使用命令给电脑添加虚拟网卡和IP

目录 1、添加网卡 1-1、windows系统添加网卡 1-2、Linux系统中添加网卡 2、添加IP和DNS 2-1、添加IP 2-2、 设置DNS 3、删除网卡 3-1、Windows: 3-2、Linux 3-3、macOS 4、示例&#xff1a; 首先以管理员方式进入CMD命令行&#xff1b; 点击“开始”->“管理员…

力扣74. 搜索二维矩阵

给你一个满足下述两条属性的 m x n 整数矩阵&#xff1a;每行中的整数从左到右按非严格递增顺序排列。每行的第一个整数大于前一行的最后一个整数。给你一个整数 target &#xff0c;如果 target 在矩阵中&#xff0c;返回 true &#xff1b;否则&#xff0c;返回 false 。 示…

猫头虎分享已解决Bug || Uncaught TypeError: Cannot set property ‘innerHTML‘ of null**

猫头虎分享已解决Bug || Uncaught TypeError: Cannot set property ‘innerHTML’ of null** 原创作者&#xff1a; 猫头虎 作者微信号&#xff1a; Libin9iOak 作者公众号&#xff1a; 猫头虎技术团队 更新日期&#xff1a; 2024年6月6日 博主猫头虎的技术世界 &#x…

让GNSSRTK不再难【第二天-第3部分】

第11讲 定位方程构建以及最小二乘 11.1 定位方程重构 历史讲中我们已经初步构建了单点定位的先验残差&#xff1a; p i s P i s − ( X s − X 0 ) 2 ( Y s − Y 0 ) 2 ( Z s − Z 0 ) 2 c δ t r − I i s − T i s − ϵ P i s p_i^s P_i^s - \sqrt{(X^s - X_0)^2 (Y…

Java入门基础11:面向对象高级一(static、extends继承)

Java面向对象高级特性有以下几个&#xff1a;继承&#xff0c;多态&#xff0c;封装&#xff0c;抽象&#xff0c;接口&#xff0c;匿名内部类&#xff0c;静态成员&#xff0c;final关键字&#xff0c;异常处理等。 我会将它分为三章详细讲&#xff0c;本章主要讲静态和继承 …

Java——方法详细介绍

一、方法调用机制 1、方法调用机制详细介绍 下面对方法调用在内存中的情况进行分析&#xff0c;以下面的代码为例&#xff1a; public class Test {public static void main(String[] args) {Person person new Person();person.name "张三";person.age 18;int…

GUI编程02-布局管理器

流式布局 FlowLayout 东西南北中 BorderLayout 表格布局 GridLayout 流式布局 package YMP.GUI; ​ import java.awt.*; import java.awt.event.WindowAdapter; import java.awt.event.WindowEvent; ​ public class TestFlowLayout {public static void main(String[] args…

oracle 12.1 rac to rac adg(maa)搭建保姆级教程

目录 资源配置 一、主库集群操作 1.主库增加standbylog 2.主库开启force logging及归档 3.主库配置参数 4.生成参数文件并将参数文件、密码文件拷贝至备库 4.1参数文件处理 4.2密码文件处理 二、备库操作 1.备库修改参数文件 1.1创建adump目录并在参数文件修改&#…

Mysql基础 - 事务

Mysql基础 - 事务 文章目录 Mysql基础 - 事务1 事务简介2 事务操作2.1 控制事务一2.2 控制事务二 3 事务四大特性4 并发事务问题5 事务隔离级别 1 事务简介 事务是一组操作的集合&#xff0c;他是一个不可分割的工作单位&#xff0c;事务会把所有操作作为一个整体一起向系统提…

Word中插入Mathtype右编号,调整公式与编号的位置

当你已经将mathtype内置于word后&#xff0c;可以使用右编号快速插入公式 但是往往会出现公式和编号出现的位置或之间的距离不合适 比如我在双栏下插入公式&#xff0c;会发现插入的公式与编号是适用于单栏的 解决办法&#xff1a; 开始->样式->MTDisplayLquation -&g…

力扣刷题--728. 自除数【简单】

题目描述 自除数 是指可以被它包含的每一位数整除的数。 例如&#xff0c;128 是一个 自除数 &#xff0c;因为 128 % 1 0&#xff0c;128 % 2 0&#xff0c;128 % 8 0。 自除数 不允许包含 0 。 给定两个整数 left 和 right &#xff0c;返回一个列表&#xff0c;列表的…

MySQL之查询性能优化(七)

查询性能优化 排序优化 无论如何排序都是一个成本很高的操作&#xff0c;所以从性能角度考虑&#xff0c;应尽可能避免排序或者尽可能避免对大量数据进行排序。前面已经提到了&#xff0c;当不能使用索引生成排序结果的时候&#xff0c;MySQL需要自己进行排序&#xff0c;如果…

02-JAVA面向对象编程

一、面向对象编程 1、面向过程编程思想&#xff08;Process Oritented Programming&#xff09; 将实现一个功能的一组指令组合在一起&#xff0c;成为一个函数。这个函数就能实现这一个功能&#xff0c;是对功能实现的一种抽象。通过这种抽象方式&#xff0c;将代码实现复用。…

Jmeter分布式、测试报告、并发数计算、插件添加方式、常用图表

Jmeter分布式 应用场景 当单个测试机无法模拟用户要求的业务场景时&#xff0c;可以使用多台测试机进行模拟&#xff0c;就是Jmeter的分布 式测试。 Jmeter分布式执行原理 Jmeter分布测试时&#xff0c;选择其中一台作为控制机&#xff08;Controller&#xff09;&#xff0c…