理解Nodejs中的进程间通信

news2025/1/12 20:45:29

前置知识

文件描述符

在 Linux 系统中,一切都看成文件,当进程打开现有文件时,会返回一个文件描述符。
文件描述符是操作系统为了管理已经被进程打开的文件所创建的索引,用来指向被打开的文件。
当我们的进程启动之后,操作系统会给每一个进程分配一个 PCB 控制块,PCB 中会有一个文件描述符表,存放当前进程所有的文件描述符,即当前进程打开的所有文件。

🤔 进程中的文件描述符是如何和系统文件对应起来的?
在内核中,系统会维护另外两种表

  • 打开文件表(Open file table)
  • i-node 表(i-node table)

文件描述符就是数组的下标,从0开始往上递增,0/1/2 默认是我们的输入/输出/错误流的文件描述符
在 PCB 中维护的文件描述表中,可以根据文件描述符找到对应了文件指针,找到对应的打开文件表
打开文件表中维护了:文件偏移量(读写文件的时候会更新);对于文件的状态标识;指向 i-node 表的指针
想要真正的操作文件,还得靠 i-node 表,能够获取到真实文件的相关信息

他们之间的关系

image

图解

  • 在进程 A 中,文件描述符1/20均指向了同一打开文件表项23,这可能是对同一文件多次调用了 open 函数形成的
  • 进程 A/B 的文件描述符2都指向同一文件,这可能是调用了 fork 创建子进程,A/B 是父子关系进程
  • 进程 A 的文件描述符0和进程 B 的文件描述符指向了不同的打开文件表项,但这些表项指向了同一个文件,这可能是 A/B 进程分别对同一文件发起了 open 调用

总结

  • 同一进程的不同文件描述符可以指向同一个文件
  • 不同进程可以拥有相同的文件描述符
  • 不同进程的同一文件描述符可以指向不同的文件
  • 不同进程的不同文件描述符可以指向同一个文件

文件描述符的重定向

每次读写进程的时候,都是从文件描述符下手,找到对应的打开文件表项,再找到对应的 i-node 表

🤔如何实现文件描述符重定向?
因为在文件描述符表中,能够找到对应的文件指针,如果我们改变了文件指针,是不是后续的两个表内容就发生了改变
例如:文件描述符1指向的显示器,那么将文件描述符1指向 log.txt 文件,那么文件描述符 1 也就和 log.txt 对应起来了

shell 对文件描述符的重定向

是输出重定向符号,< 是输入重定向符号,它们是文件描述符操作符
和 < 通过修改文件描述符改变了文件指针的指向,来能够实现重定向的功能

我们使用cat hello.txt时,默认会将结果输出到显示器上,使用 > 来重定向。cat hello.txt 1 > log.txt 以输出的方式打开文件 log.txt,并绑定到文件描述符1上

image

c函数对文件描述符的重定向

dup

dup 函数是用来打开一个新的文件描述符,指向和 oldfd 同一个文件,共享文件偏移量和文件状态,

int main(int argc, char const *argv[])
{
    int fd = open("log.txt");
    int copyFd = dup(fd);
    //将fd阅读文件置于文件末尾,计算偏移量。
    cout << "fd = " << fd << " 偏移量: " << lseek(fd, 0, SEEK_END) << endl;
    //现在我们计算copyFd的偏移量
    cout << "copyFd = " << copyFd << "偏移量:" << lseek(copyFd, 0, SEEK_CUR) << endl;
    return 0;
}

image

调用 dup(3) 的时候,会打开新的最小描述符,也就是4,这个4指向了3所指向的文件,操作任意一个 fd 都是修改的一个文件

dup2

dup2 函数,把指定的 newfd 也指向 oldfd 指向的文件。执行完dup2之后,newfd 和 oldfd 同时指向同一个文件,共享文件偏移量和文件状态

int main(int argc, char const *argv[])
{
    int oldfd = open("log.txt");
    int newfd = open("log1.txt");
    dup2(oldfd, newfd);
    //将fd阅读文件置于文件末尾,计算偏移量。
    cout << "fd = " << fd << " 偏移量: " << lseek(fd, 0, SEEK_END) << endl;
    //现在我们计算copyFd的偏移量
    cout << "copyFd = " << copyFd << "偏移量:" << lseek(copyFd, 0, SEEK_CUR) << endl;
    return 0;
}

image

Node中通信原理

Node 中的 IPC 通道具体实现是由 libuv 提供的。根据系统的不同实现方式不同,window 下采用命名管道实现,*nix 下采用 Domain Socket 实现。在应用层只体现为 message 事件和 send 方法。参考 前端面试题详细解答

image

父进程在实际创建子进程之前,会创建 IPC 通道并监听它,等到创建出真实的子进程后,通过环境变量(NODE_CHANNEL_FD)告诉子进程该 IPC 通道的文件描述符。

子进程在启动的过程中,会根据该文件描述符去连接 IPC 通道,从而完成父子进程的连接。

建立连接之后可以自由的通信了,IPC 通道是使用命名管道或者 Domain Socket 创建的,属于双向通信。并且它是在系统内核中完成的进程通信

image

⚠️ 只有在启动的子进程是 Node 进程时,子进程才会根据环境变量去连接对应的 IPC 通道,对于其他类型的子进程则无法实现进程间通信,除非其他进程也按着该约定去连接这个 IPC 通道。

unix domain socket

是什么

我们知道经典的通信方式是有 Socket,我们平时熟知的 Socket 是基于网络协议的,用于两个不同主机上的两个进程通信,通信需要指定 IP/Host 等。
但如果我们同一台主机上的两个进程想要通信,如果使用 Socket 需要指定 IP/Host,经过网络协议等,会显得过于繁琐。所以 Unix Domain Socket 诞生了。

UDS 的优势:

  • 绑定 socket 文件而不是绑定 IP/Host;不需要经过网络协议,而是数据的拷贝
  • 也支持 SOCK_STREAM(流套接字)和 SOCK_DGRAM(数据包套接字),但由于是在本机通过内核通信,不会丢包也不会出现发送包的次序和接收包的次序不一致的问题

如何实现

流程图

image

Server 端
int main(int argc, char *argv[])
{
    int server_fd ,ret, client_fd;
    struct sockaddr_un serv, client;
    socklen_t len = sizeof(client);
    char buf[1024] = {0};
    int recvlen;

    // 创建 socket
    server_fd = socket(AF_LOCAL, SOCK_STREAM, 0);

    // 初始化 server 信息
    serv.sun_family = AF_LOCAL;
    strcpy(serv.sun_path, "server.sock");

    // 绑定
    ret = bind(server_fd, (struct sockaddr *)&serv, sizeof(serv));

    //设置监听,设置能够同时和服务端连接的客户端数量
    ret = listen(server_fd, 36);

    //等待客户端连接
    client_fd = accept(server_fd, (struct sockaddr *)&client, &len);
    printf("=====client bind file:%s\n", client.sun_path);

    while (1) {
        recvlen = recv(client_fd, buf, sizeof(buf), 0);
        if (recvlen == -1) {
            perror("recv error");
            return -1;
        } else if (recvlen == 0) {
            printf("client disconnet...\n");
            close(client_fd);
            break;
        } else {
            printf("recv buf %s\n", buf);
            send(client_fd, buf, recvlen, 0);
        }
    }

    close(client_fd);
    close(server_fd);
    return 0;
}
Client 端
int main(int argc, char *argv[])
{
    int client_fd ,ret;
    struct sockaddr_un serv, client;
    socklen_t len = sizeof(client);
    char buf[1024] = {0};
    int recvlen;

    //创建socket
    client_fd = socket(AF_LOCAL, SOCK_STREAM, 0);

    //给客户端绑定一个套接字文件
    client.sun_family = AF_LOCAL;
    strcpy(client.sun_path, "client.sock");
    ret = bind(client_fd, (struct sockaddr *)&client, sizeof(client));

    //初始化server信息
    serv.sun_family = AF_LOCAL;
    strcpy(serv.sun_path, "server.sock");
    //连接
    connect(client_fd, (struct sockaddr *)&serv, sizeof(serv));

    while (1) {
        fgets(buf, sizeof(buf), stdin);
        send(client_fd, buf, strlen(buf)+1, 0);

        recv(client_fd, buf, sizeof(buf), 0);
        printf("recv buf %s\n", buf);
    }

    close(client_fd);
    return 0;
}

命名管道(Named Pipe)

是什么

命名管道是可以在同一台计算机的不同进程之间,或者跨越一个网络的不同计算机的不同进程之间的可靠的单向或者双向的数据通信。
创建命名管道的进程被称为管道服务端(Pipe Server),连接到这个管道的进程称为管道客户端(Pipe Client)。

命名管道的命名规范:\server\pipe[\path]\name

  • 其中 server 指定一个服务器的名字,本机适用 . 表示,\192.10.10.1 表示网络上的服务器
  • \pipe 是一个不可变化的字串,用于指定该文件属于 NPFS(Named Pipe File System)
  • [\path]\name 是唯一命名管道名称的标识

怎么实现

流程图

image

Pipe Server
void ServerTest()
{
    HANDLE  serverNamePipe;
    char    pipeName[MAX_PATH] = {0};
    char    szReadBuf[MAX_BUFFER] = {0};
    char    szWriteBuf[MAX_BUFFER] = {0};
    DWORD   dwNumRead = 0;
    DWORD   dwNumWrite = 0;

    strcpy(pipeName, "\\\\.\\pipe\\shuangxuPipeTest");
    // 创建管道实例
    serverNamePipe = CreateNamedPipeA(pipeName,
        PIPE_ACCESS_DUPLEX|FILE_FLAG_WRITE_THROUGH,
        PIPE_TYPE_BYTE|PIPE_READMODE_BYTE|PIPE_WAIT,
        PIPE_UNLIMITED_INSTANCES, 0, 0, 0, NULL);
    WriteLog("创建管道成功...");
    // 等待客户端连接
    BOOL bRt= ConnectNamedPipe(serverNamePipe, NULL );
    WriteLog( "收到客户端的连接成功...");
    // 接收数据
    memset( szReadBuf, 0, MAX_BUFFER );
    bRt = ReadFile(serverNamePipe, szReadBuf, MAX_BUFFER-1, &dwNumRead, NULL );
    // 业务逻辑处理 (只为测试用返回原来的数据)
    WriteLog( "收到客户数据:[%s]", szReadBuf);
    // 发送数据
    if( !WriteFile(serverNamePipe, szWriteBuf, dwNumRead, &dwNumWrite, NULL ) )
    {
        WriteLog("向客户写入数据失败:[%#x]", GetLastError());
        return ;
    }
    WriteLog("写入数据成功...");
}
Pipe Client
void ClientTest()
{
    char    pipeName[MAX_PATH] = {0};
    HANDLE  clientNamePipe;
    DWORD   dwRet;
    char    szReadBuf[MAX_BUFFER] = {0};
    char    szWriteBuf[MAX_BUFFER] = {0};
    DWORD   dwNumRead = 0;
    DWORD   dwNumWrite = 0;

    strcpy(pipeName, "\\\\.\\pipe\\shuangxuPipeTest");
    // 检测管道是否可用
    if(!WaitNamedPipeA(pipeName, 10000)){
        WriteLog("管道[%s]无法打开", pipeName);
        return ;
    }
    // 连接管道
    clientNamePipe = CreateFileA(pipeName,
        GENERIC_READ|GENERIC_WRITE,
        0,
        NULL,
        OPEN_EXISTING,
        FILE_ATTRIBUTE_NORMAL,
        NULL);
    WriteLog("管道连接成功...");
    scanf( "%s", szWritebuf );
    // 发送数据
    if( !WriteFile(clientNamePipe, szWriteBuf, strlen(szWriteBuf), &dwNumWrite, NULL)){
        WriteLog("发送数据失败,GetLastError=[%#x]", GetLastError());
        return ;
    }
    printf("发送数据成功:%s\n", szWritebuf );
    // 接收数据
    if( !ReadFile(clientNamePipe, szReadBuf, MAX_BUFFER-1, &dwNumRead, NULL)){
        WriteLog("接收数据失败,GetLastError=[%#x]", GetLastError() );
        return ;
    }
    WriteLog( "接收到服务器返回:%s", szReadBuf );
    // 关闭管道
    CloseHandle(clientNamePipe);
}

Node 创建子进程的流程

Unix

image

对于创建子进程、创建管道、重定向管道均是在 c++ 层实现的

创建子进程

int main(int argc,char *argv[]){
    pid_t pid = fork();
    if (pid < 0) {
        // 错误
    } else if(pid == 0) {
        // 子进程
    } else {
        // 父进程
    }
}

创建管道

使用 socketpair 创建管道,其创建出来的管道是全双工的,返回的文件描述符中的任何一个都可读和可写

int main ()
{
    int fd[2];
    int r = socketpair(AF_UNIX, SOCK_STREAM, 0, fd);

    if (fork()){ /* 父进程 */
        int val = 0;
        close(fd[1]);
        while (1){
            sleep(1);
            ++val;
            printf("发送数据: %d\n", val);
            write(fd[0], &val, sizeof(val));
            read(fd[0], &val, sizeof(val));
            printf("接收数据: %d\n", val);
        }
    } else {  /*子进程*/
        int val;
        close(fd[0]);
        while(1){
            read(fd[1], &val, sizeof(val));
            ++val;
            write(fd[1], &val, sizeof(val));
        }
    }
}

当我们使用 socketpair 创建了管道之后,父进程关闭了 fd[1],子进程关闭了 fd[0]。子进程可以通过 fd[1] 读写数据;同理主进程通过 fd[0]读写数据完成通信。

child_process.fork 的详细调用

fork 函数开启一个子进程的流程

image

  • 初始化参数中的 options.stdio,并且调用 spawn 函数

    function spawn(file, args, options) {
      const child = new ChildProcess();
    
      child.spawn(options);
    }
    
  • 创建 ChildProcess 实例,创建子进程也是调用 C++ 层 this._handle.spawn 方法

    function ChildProcess() {
        // C++层定义
        this._handle = new Process();
    }
    
  • 通过 child.spawn 调用到 ChildProcess.prototype.spawn 方法中。其中 getValidStdio 方法会根据 options.stdio 创建和 C++ 交互的 Pipe 对象,并获得对应的文件描述符,将文件描述符写入到环境变量 NODE_CHANNEL_FD 中,调用 C++ 层创建子进程,在调用 setupChannel 方法

    ChildProcess.prototype.spawn = function(options) {
      // 预处理进程间通信的数据结构
        stdio = getValidStdio(stdio, false);
        const ipc = stdio.ipc;
        const ipcFd = stdio.ipcFd;
        //将文件描述符写入环境变量中
        if (ipc !== undefined) {
        ArrayPrototypePush(options.envPairs, `NODE_CHANNEL_FD=${ipcFd}`);
      }
        // 创建进程
        const err = this._handle.spawn(options);
        // 添加send方法和监听IPC中数据
        if (ipc !== undefined) setupChannel(this, ipc, serialization);
    }
    
  • 子进程启动时,会根据环境变量中是否存在 NODE_CHANNEL_FD 判断是否调用 _forkChild 方法,创建一个 Pipe 对象, 同时调用 open 方法打开对应的文件描述符,在调用setupChannel

    function _forkChild(fd, serializationMode) {
      const p = new Pipe(PipeConstants.IPC);
      p.open(fd);
      p.unref();
      const control = setupChannel(process, p, serializationMode);
    }
    

句柄传递

setupChannel
主要是完成了处理接收的消息、发送消息、处理文件描述符传递等

function setipChannel(){
    channel.onread = function(arrayBuffer){
        //...
    }
    target.on('internalMessage', function(message, handle){
        //...
    })
    target.send = function(message, handle, options, callback){
        //...
    }
    target._send = function(message, handle, options, callback){
        //...
    }
    function handleMessage(message, handle, internal){
        //...
    }
}
  • target.send: process.send 方法,这里 target 就是进程对象本身.
  • target._send: 执行具体 send 逻辑的函数, 当参数 handle 不存在时, 表示普通的消息传递;若存在,包装为内部对象,表明是一个 internalMessage 事件触发。调用使用JSON.stringify 序列化对象, 使用channel.writeUtf8String 写入文件描述符中
  • channel.onread: 获取到数据时触发, 跟 channel.writeUtf8String 相对应。通过 JSON.parse 反序列化 message 之后, 调用 handleMessage 进而触发对应事件
  • handleMessage: 用来判断是触发 message 事件还是 internalMessage 事件
  • target.on(‘internalMessage’): 针对内部对象做特殊处理,在调用 message 事件

image

进程间消息传递

  • 父进程通过 child.send 发送消息 和 server/socket 句柄对象

  • 普通消息直接 JSON.stringify 序列化;对于句柄对象来说,需要先包装成为内部对象

    message = {
        cmd: 'NODE_HANDLE',
        type: null,
        msg: message
    };
    

    通过 handleConversion.[message.type].send 的方法取出句柄对象对应的 C++ 层面的 TCP 对象,在采用JSON.stringify 序列化

    const handleConversion = {
        'net.Server': {
        simultaneousAccepts: true,
    
        send(message, server, options) {
          return server._handle;
        },
    
        got(message, handle, emit) {
          const server = new net.Server();
          server.listen(handle, () => {
            emit(server);
          });
        }
      }
    //....
    }
    
  • 最后将序列化后的内部对象和 TCP 对象写入到 IPC 通道中

  • 子进程在接收到消息之后,使用 JSON.parse 反序列化消息,如果为内部对象触发 internalMessage 事件

  • 检查是否带有 TCP 对象,通过 handleConversion.[message.type].got 得到和父进程一样的句柄对象

  • 最后发触发 message 事件传递处理好的消息和句柄对象,子进程通过 process.on 接收

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

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

相关文章

Python学习-8.1.2 标准库(random库的基础与实例)

2.2 random库 使用random库的主要目的是生成随机数 2.2.1 产生随机数 random()函数&#xff1a;生成一个[0.0,1.0)之间的随机小数&#xff0c;左开右闭。 import random print(random.random())#生成一个[0.0,1.0)之间的随机小数 #每次运行random()函数都会产生不同的数据 …

Layer2代币经济学:除了治理 还应该具备什么价值?

为什么我们需要L2&#xff1f; 任何在2021年期间使用以太坊的人都知道&#xff0c;该区块链可能会变得非常拥堵。这是一个典型的问题——需求太多而供应不足。因此&#xff0c;gas费用&#xff08;交易费&#xff09;变得相当昂贵。在牛市高峰期&#xff0c;使用以太坊区块链发…

基于Jenkins的开发测试全流程持续集成实践

今年一直在公司实践CI&#xff0c;本文将近半年来的一些实践总结一下&#xff0c;可能不太完善或优美&#xff0c;但的确初步解决了我目前所在项目组的一些痛点。当然这仅是一家之言也不够完整&#xff0c;后续还会深入实践和引入Kubernetes进行容器编排&#xff0c;以及通过阿…

从js中加载图片和Cannot read property ‘appendChild‘ of null 错误

先写一段代码如下&#xff1b; <!DOCTYPE html> <html> <head> <meta charset"utf-8"> <title> </title><script>var imgnew Image();img.addEventListener("load",loadHandler);img.src"img/1.jpg"…

【JMeter】命令参数说明

jmeter -n -t xxx.jmx -l result.jtl 非GUI运行xxx.jml脚本写入xxx.jtl结果文件 jmeter -n -t xxx.jmx -l result.jtl -j run.log 非GUI运行xxx.jmx脚本写入xxx.jtl结果文件&#xff0c;日志记录到run.log jmeter -n -t xxx.jmx -R ip1:port1,ip2:port2 -l result.jtl 使用远…

详解 Redis 持久化之掌握 RDB ⽂件的格式,学习如何制作数据库镜像

本文带大家了解一下 Redis 数据一种持久化方式 RDB 的实现。包括 Redis 内存快照 RDB ⽂件的创建时机以及⽣成⽅法。可以让你掌握 RDB ⽂件的格式&#xff0c;学习如何制作数据库镜像。 RDB 创建的入口函数 Redis 创建 RDB 文件的函数有三个&#xff0c;分别是 rdbSave, rdbSa…

知识图谱-KGE-语义匹配-双线性模型-2017:ANALOGY

【paper】 Analogical Inference for Multi-relational Embeddings【简介】 本文是卡耐基梅隆大学的中国学者发表在 ICML 2017 上的工作&#xff0c;提出了 ANALOGY 模型&#xff0c;用于建模实体和关系的推理属性。这个模型应当也算是双线性模型中比较经典的一个了&#xff0c…

Erueka基本使用

SpringCloud Erueka基本使用 Erueka是微服务架构中&#xff0c;可以作为注册中心的技术实现&#xff0c;如下图所示 服务提供者&#xff1a;一次业务中&#xff0c;暴露接口给其它微服务调用&#xff0c;被其它微服务调用的服务。&#xff08;提供接口给其它微服务&#xff09…

一个超好看的音乐网站设计与实现(HTML+CSS)

&#x1f389;精彩专栏推荐 &#x1f4ad;文末获取联系 ✍️ 作者简介: 一个热爱把逻辑思维转变为代码的技术博主 &#x1f482; 作者主页: 【主页——&#x1f680;获取更多优质源码】 &#x1f393; web前端期末大作业&#xff1a; 【&#x1f4da;毕设项目精品实战案例 (10…

vue+vite的创建

1、创建vue3项目 yarn create vite效果&#xff1a; yarn create v1.22.19 [1/4] Resolving packages... [2/4] Fetching packages... [3/4] Linking dependencies... [4/4] Building fresh packages...success Installed "create-vite3.2.1" with binaries:- crea…

接口管理测试繁琐复杂?何不试试这个神器

一、前言 作为一名测试从业者&#xff0c;深刻的明白接口测试在项目过程中是多么重要的一个环节。通过页面进行的UI测试会因为界面不稳定而导致用例维护非常困难。另外&#xff0c;在检查系统的安全性、稳定性上面也是尤为重要的环节&#xff0c;这些也是无法通过前端测试的&a…

Redis - Windows下载与安装

1.获取Redis在windows下的安装包 Windows版下载地址&#xff1a;https://github.com/microsoftarchive/redis/releases 选择Redis-x64-*.zip 2.解压zip文件与配置 2.1 选取目录 选取一个目录作为解压目录&#xff0c;这个目录就是你Redis程序所在位置&#xff0c;尽量找一…

【软件测试】师傅给我的测试新手“真理“宝典......

目录&#xff1a;导读前言一、Python编程入门到精通二、接口自动化项目实战三、Web自动化项目实战四、App自动化项目实战五、一线大厂简历六、测试开发DevOps体系七、常用自动化测试工具八、JMeter性能测试九、总结&#xff08;尾部小惊喜&#xff09;前言 熟悉你所测试的软件…

java学习day59(乐友商城)Vue入门

0.前言 前几天我们已经对后端的技术栈有了初步的了解、并且已经搭建了整个后端微服务的平台。接下来要做的事情就是功能开发了。但是没有前端页面&#xff0c;我们肯定无从下手&#xff0c;因此今天我们就要来了解一下前端的一些技术&#xff0c;完成前端页面搭建。 先聊一下…

【Redis】Redis实现分布式锁解析与应用(Redis专栏启动)

&#x1f4eb;作者简介&#xff1a;小明java问道之路&#xff0c;专注于研究 Java/ Liunx内核/ C及汇编/计算机底层原理/源码&#xff0c;就职于大型金融公司后端高级工程师&#xff0c;擅长交易领域的高安全/可用/并发/性能的架构设计与演进、系统优化与稳定性建设。 &#x1…

java版商城+Spring Cloud+SpringBoot+mybatis+uniapp b2b2c o2o 多商家入驻商城 直播带货商城 电子商务

一个好的SpringCloudSpringBoot b2b2c 电子商务平台涉及哪些技术、运营方案&#xff1f;以下是我结合公司的产品做的总结&#xff0c;希望可以帮助到大家&#xff01; 搜索体验小程序&#xff1a;海哇 1. 涉及平台 平台管理、商家端&#xff08;PC端、手机端&#xff09;、买…

助农销售平台毕业设计,农产品销售管理系统设计与实现,毕业设计怎么写论文源码开题报告需求分析怎么做

项目背景和意义 目的&#xff1a;本课题主要目标是设计并能够实现一个基于web网页的多用户商城系统&#xff0c;整个网站项目使用了B/S架构&#xff0c;基于java的springboot框架下开发&#xff1b;用户通过登录网站&#xff0c;查询商品&#xff0c;购买商品&#xff0c;下单&…

ElasticSearch-7.17支持两种客户端连接方式(RestHighLevelClient 和Elasticsearch Java API)

学习es时发现了一个大问题&#xff0c;学习的版本为7.8.0&#xff08;尚硅谷yyds&#xff09;&#xff0c;自己使用的是7.17.8&#xff0c;但是最新的版本已经是8.5X了&#xff08;心累&#xff0c;怎么升级这么快&#xff09;。 因为目前用的还是jdk1.8&#xff0c;所以就按照…

Blazor组件自做十二 : Blazor Pdf Reader PDF阅读器 组件

原文链接 [https://www.cnblogs.com/densen2014/p/16954812.html] Blazor Pdf Reader PDF阅读器 组件 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-IChv1OZ2-1670428567016)(https://img.shields.io/nuget/v/BootstrapBlazor.PdfReader.svg?styl…

【YOLOX 论文+源码解读】YOLOX: Exceeding YOLO Series in 2021

目录前言一、整体网络架构二、改进点1.1、解耦头1.2、Anchor Free1.3、SimOTA三、源码解析3.1、Backbone3.2、Neck3.3、head3.4、预测&#xff1a;decode_outputs3.5、训练&#xff1a;get_losses3.5.1、准备工作&#xff1a;get_output_and_grid3.5.2、get_losses函数&#xf…