微服务 云原生:gRPC 客户端、服务端的通信原理

news2024/11/25 11:59:39

gRPC Hello World

在这里插入图片描述
protoc 是 Protobuf 的核心工具,用于编写 .proto 文件并生成 protobuf 代码。在这里,以 Go 语言代码为例,进行 gRPC 相关代码编写。

  • 下载 protoc 工具:https://github.com/protocolbuffers/protobuf/releases,并添加到环境变量。
  • 下载依赖包:
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.28
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.2
  • 定义目录结构,在 proto 目录下编写 helloworld.proto 文件:
    在这里插入图片描述
syntax="proto3";
package proto;

option go_package = "./;proto";

service Greeter {
    rpc SayHello (HelloRequest) returns (HelloReply) {}
}

message HelloRequest {
    string name = 1;
}

message HelloReply {
    string message = 1;
}

运行 protoc -I . helloworld.proto --go_out=:. --go-grpc_out=require_unimplemented_servers=false:. 命令用于生成与 Protocol Buffers 和 gRPC 服务相关的代码。
在这里插入图片描述

  • 分别在 server 目录和 client 目录编写服务端和客户端代码,启动 sever 和 client,client 端可看到运行结果:Hello: Alex。
// server.go
package main

import (
    "context"
    "net"

    "google.golang.org/grpc"

    "your_moudle/proto"
)

type Server struct {}

func (s *Server) SayHello(ctx context.Context, request *proto.HelloRequest) (*proto.HelloReply, error) {
    return &proto.HelloReply{Message: "Hello " + request.GetName()}, nil
}

func main() {
    lis, err := net.Listen("tcp", "0.0.0.0:8000")
    if err != nil {
        panic("failed to listen: " + err.Error())
    }

    g := grpc.NewServer()
    proto.RegisterGreeterServer(g, &Server{})

    err = g.Serve(lis)
    if err != nil {
        panic("failed to start grpc: " + err.Error())
    }
}

// client.go

package main

import (
    "context"
    "fmt"

    "google.golang.org/grpc"
    "google.golang.org/grpc/credentials/insecure"

    "your_moudle/proto"
)

func main() {
    conn, err := grpc.Dial("127.0.0.1:8000", grpc.WithTransportCredentials(insecure.NewCredentials()))
    if err != nil {
        panic(err)
    }

    defer conn.Close()

    c := proto.NewGreeterClient(conn)
    r, err := c.SayHello(context.Background(), &proto.HelloRequest{Name: "Alex"})
    if err != nil {
        panic(err)
    }

    fmt.Println(r.Message)
}

从上面例子中可以看出,一个完整的客户端与服务端进行通信的流程如下:
在这里插入图片描述

服务端:

  • 服务端实现 proto 文件中定义的接口:
func (s *Server) SayHello(ctx context.Context, request *proto.HelloRequest) (*proto.HelloReply, error) {
    return &proto.HelloReply{Message: "Hello " + request.GetName()}, nil
}
  • 监听 TCP 端口:
lis, _ := net.Listen("tcp", "0.0.0.0:8000")
  • 注册和启动服务:
g := grpc.NewServer()
proto.RegisterGreeterServer(g, &Server{})
_ = g.Serve(lis)

客户端:

  • 建立 TCP 连接:
conn, _ := grpc.Dial("127.0.0.1:8000", grpc.WithTransportCredentials(insecure.NewCredentials()))
  • 创建 Client:
c := proto.NewGreeterClient(conn)
  • 执行 RPC 调用,并接收信息:
r, _ := c.SayHello(context.Background(), &proto.HelloRequest{Name: "Alex"})

gRPC 内部怎样实现方法调用

服务端

通过 NewServer() 方法初始化一个 gRPC server,用于后续注册服务以及接受请求

创建带默认值的 gRPC server 结构体对象,初始化描述协议的各种参数选项,包括发送和接收的消息大小、buffer大小等等各种,类似于 http Headers。

func NewServer(opt ...ServerOption) *Server {
    // 描述协议的各种参数选项,包括发送和接收的消息大小、buffer大小等等各种,类似于 http Headers
    opts := defaultServerOptions
    for _, o := range globalServerOptions {
        o.apply(&opts)
    }
    for _, o := range opt {
        o.apply(&opts)
    }

    // 创建带默认值的 gRPC server 结构体对象
    s := &Server{
        lis:      make(map[net.Listener]bool),
        opts:     opts,
        conns:    make(map[string]map[transport.ServerTransport]bool),
        services: make(map[string]*serviceInfo),
        quit:     grpcsync.NewEvent(),
        done:     grpcsync.NewEvent(),
        czData:   new(channelzData),
    }

    // 配置拦截器
    chainUnaryServerInterceptors(s)
    chainStreamServerInterceptors(s)
    s.cv = sync.NewCond(&s.mu)

    // 配置简单的链路工具
    if EnableTracing {
        _, file, line, _ := runtime.Caller(1)
        s.events = trace.NewEventLog("grpc.Server", fmt.Sprintf("%s:%d", file, line))
    }

    if s.opts.numServerWorkers > 0 {
        s.initServerWorkers()
    }

    s.channelzID = channelz.RegisterServer(&channelzServer{s}, "")
    channelz.Info(logger, s.channelzID, "Server created")
    return s
}

通过 RegisterService 方法来实现服务注册

使用 proto.RegisterGreeterServer(g, &Server{}) 来注册服务时,其在 xxx_grpc.pb.go 中通过 RegisterService 方法来实现服务注册。

func RegisterGreeterServer(s grpc.ServiceRegistrar, srv GreeterServer) {
    // 第二个参数为我们自定义实现了相应接口的实现类。
    s.RegisterService(&Greeter_ServiceDesc, srv)
}
// Greeter_ServiceDesc 是 greter 服务的 grpc.ServiceDesc,不能被自省或修改(即使作为副本)。
var Greeter_ServiceDesc = grpc.ServiceDesc{
    // 声明了名称、路由、方法及其他元数据属性
    ServiceName: "proto.Greeter",
    HandlerType: (*GreeterServer)(nil),
    Methods: []grpc.MethodDesc{
        {
            MethodName: "SayHello",
            Handler:    _Greeter_SayHello_Handler,
        },
    },
    Streams:  []grpc.StreamDesc{},
    Metadata: "helloworld.proto",
}

RegisterService 方法的具体实现如下:

func (s *Server) RegisterService(sd *ServiceDesc, ss interface{}) {
    // 判断 ServiceServer 是否实现 ServiceDesc 中描述的 HandlerType,如果实现了则调用 s.register 方法注册
    if ss != nil {
        ht := reflect.TypeOf(sd.HandlerType).Elem()
        st := reflect.TypeOf(ss)
        if !st.Implements(ht) {
            logger.Fatalf("grpc: Server.RegisterService found the handler of type %v that does not satisfy %v", st, ht)
        }
    }
    s.register(sd, ss)
}

首先做一些前置判断,接着 register 根据 sd 中的 Method 创建对应的 map,并将名称作为键,方法描述(指针)作为值,添加到相应的 map 中,最后按照服务名为 key,将 serviceInfo 信息注入到 Server 的 services map 中。

func (s *Server) register(sd *ServiceDesc, ss interface{}) {
    s.mu.Lock()
    defer s.mu.Unlock()
    // 一些前置性判断,注册服务必须要在 server() 方法之前调用
    s.printf("RegisterService(%q)", sd.ServiceName)
    if s.serve {
        logger.Fatalf("grpc: Server.RegisterService after Server.Serve for %q", sd.ServiceName)
    }
    if _, ok := s.services[sd.ServiceName]; ok {
        logger.Fatalf("grpc: Server.RegisterService found duplicate service registration for %q", sd.ServiceName)
    }

    // 在 Server 结构体中,services 字段存放的是 {service name -> service info} map
    // serviceInfo 中有两个重要的属性:methods 和 streams
    info := &serviceInfo{
        serviceImpl: ss,
        methods:     make(map[string]*MethodDesc),
        streams:     make(map[string]*StreamDesc),
        mdata:       sd.Metadata,
    }

    // register 根据 sd 中的 Method 创建对应的 map,并将名称作为键,方法描述(指针)作为值,添加到相应的 map 中
    for i := range sd.Methods {
        d := &sd.Methods[i]
        info.methods[d.MethodName] = d
    }
    for i := range sd.Streams {
        d := &sd.Streams[i]
        info.streams[d.StreamName] = d
    }

    // 按照服务名为 key,将 serviceInfo 信息注入到 Server 的 services map 中
    s.services[sd.ServiceName] = info
}

register 方法可以看出,其实对于不同的 RPC 请求,根据 services 中不同的 serviceName 去 services map 中取出不同的 handler 进行处理.

通过 Serve() 启动服务

通过死循环的方式在某一个端口实现监听,然后 client 对这个端口发起连接请求,握手成功后建立连接,然后 server 处理 client 发送过来的请求数据,根据请求参数,调用不同的 handler 进行处理,回写响应数据。

func (s *Server) Serve(lis net.Listener) error {
    // ...
    for {
        rawConn, err := lis.Accept()
        // ...
        s.serveWG.Add(1)
        go func() {
            // gRPC 是基于 HTTP2.0 实现
            // handleRawConn 实现了 http 的 handshake
            s.handleRawConn(lis.Addr().String(), rawConn)
            s.serveWG.Done()
        }()
    }
    //...
}
func (s *Server) handleRawConn(lisAddr string, rawConn net.Conn) {
    // 。。。
    // Finish handshaking (HTTP2)
    st := s.newHTTP2Transport(rawConn)
    rawConn.SetDeadline(time.Time{})
    // 。。。
    go func() {
        // 继续调用 serveStreams 方法
        s.serveStreams(st)
        s.removeConn(lisAddr, st)
    }()
}

func (s *Server) serveStreams(st transport.ServerTransport) {
    // 。。。
    st.HandleStreams(func(stream *transport.Stream) {
        // 。。。
        go func() {
            defer wg.Done()
            // 根据 serviceName 取 server 中的 services map
            s.handleStream(st, stream, s.traceInfo(st, stream))
        }()
    }, func(ctx context.Context, method string) context.Context {
        // 。。。
    })
    wg.Wait()
}

func (s *Server) handleStream(t transport.ServerTransport, stream *transport.Stream, trInfo *traceInfo) {
    // ...
    service := sm[:pos]
    method := sm[pos+1:]
    // ...
    // 取出 handler 进行处理
    srv, knownService := s.services[service]
    if knownService {
        if md, ok := srv.methods[method]; ok {
            // 在该方法中实现 handler 对 rpc 的处理,以及处理后的 response 
            s.processUnaryRPC(t, stream, srv, md, trInfo)
            return
        }
        if sd, ok := srv.streams[method]; ok {
            s.processStreamingRPC(t, stream, srv, sd, trInfo)
            return
        }
    }
    // ...
}

客户端

通过拨号 Dial() 建立连接

func Dial(target string, opts ...DialOption) (*ClientConn, error) {
    return DialContext(context.Background(), target, opts...)
}

调用 DialContext() 方法主要返回一个初始化的 ClientConn{} 结构体对象:

// 压缩解压缩、是否需要认证、超时时间、是否重试等信息
cc := &ClientConn{
    target: target,
    // 连接的状态管理器,每个连接具有 "IDLE"、"CONNECTING"、"READY"、"TRANSIENT_FAILURE"、"SHUTDOW N"、"Invalid-State" 这几种状态
    csMgr:  &connectivityStateManager{},
    conns:  make(map[*addrConn]struct{}),
    dopts:  defaultDialOptions(),
    // 监测 server 和 channel 的状态
    czData: new(channelzData),
    // 。。。
}

通过 NewGreeterClient 创建客户端

通过 proto.NewGreeterClient(conn) 创建 Client 对象,其在 xxx_grpc.pb.go 中通过 NewGreeterClient 创建客户端

func NewGreeterClient(cc grpc.ClientConnInterface) GreeterClient {
    return &greeterClient{cc}
}

调用 Invoke 发起 RPC 调用

执行 c.SayHello(context.Background(), &proto.HelloRequest{Name: "Alex"}) 调用,并接收信息,SayHello 方法通过调用 Invoke 发起 RPC 调用

func (c *greeterClient) SayHello(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (*HelloReply, error) {
    out := new(HelloReply)

    // Invoke 中调用 invoke
    err := c.cc.Invoke(ctx, "/proto.Greeter/SayHello", in, out, opts...)
    if err != nil {
        return nil, err
    }
    return out, nil
}

func (cc *ClientConn) Invoke(ctx context.Context, method string, args, reply interface{}, opts ...CallOption) error {
    // 。。。
    return invoke(ctx, method, args, reply, cc, opts...)
}

func invoke(ctx context.Context, method string, req, reply interface{}, cc *ClientConn, opts ...CallOption) error {
    cs, err := newClientStream(ctx, unaryStreamDesc, cc, method, opts...)
    // 。。。

    if err := cs.SendMsg(req); err != nil {
        return err
    }
    return cs.RecvMsg(reply)
}

cs.SendMsg(req) 方法中,首先准备数据:

hdr, payload, data, err := prepareMsg(m, cs.codec, cs.cp, cs.comp)

接着调用 csAttempt 这个结构体中的 sendMsg 方法:

op := func(a *csAttempt) error {
    // 这个 sendMsg 方法中通过 a.t.Write(a.s, hdr, payld, &transport.Options{Last: !cs.desc.ClientStreams}) 发出的数据写操作
    return a.sendMsg(m, hdr, payload, data)
}

cs.RecvMsg(reply) 方法中调用 csAttemptrecvMsg 方法:

func (cs *clientStream) RecvMsg(m interface{}) error {
    // 。。。
    err := cs.withRetry(func(a *csAttempt) error {
        return a.recvMsg(m, recvInfo)
    }, cs.commitAttemptLocked)
    // 。。。
}

通过层层调用,最终是通过一个 io.Reader 类型的 p.r 来读取数据。

func (a *csAttempt) recvMsg(m interface{}, payInfo *payloadInfo) (err error) {
    // ...
    err = recv(a.p, cs.codec, a.s, a.dc, m, *cs.callInfo.maxReceiveMessageSize, payInfo, a.decomp)
    // ...
}

func recv(p *parser, c baseCodec, s *transport.Stream, dc Decompressor, m interface{}, maxReceiveMessageSize int, payInfo *payloadInfo, compressor encoding.Compressor) error {
    // 通过 recvAndDecompress 方法,接收数据并解压缩
    d, err := recvAndDecompress(p, s, dc, maxReceiveMessageSize, payInfo, compressor)
    // 。。。
}

func recvAndDecompress(p *parser, s *transport.Stream, dc Decompressor, maxReceiveMessageSize int, payInfo *payloadInfo, compressor encoding.Compressor) ([]byte, error) {
    pf, d, err := p.recvMsg(maxReceiveMessageSize)
    // 。。。
}

func (p *parser) recvMsg(maxReceiveMessageSize int) (pf payloadFormat, msg []byte, err error) {
    if _, err := p.r.Read(p.header[:]); err != nil {
        return 0, nil, err
    }
    // 。。。
}

代理模式

定义:提供一个代理对象,并由代理对象控制对原对象的引用。

代理模式下存在三类角色:

  • 抽象角色 Subject:通过接口或抽象类声明业务方法,需要代理角色和真实角色来实现。
  • 代理角色 Proxy:暴露给客户端,是真实角色的代理,通过真实角色的业务逻辑方法来实现抽象方法,并可以附加额外方法。
  • 真实角色 RealSubject:实现真实角色的业务逻辑,供代理角色调用。
    在这里插入图片描述

如上图所示,Client 客户端通过 toRequest 发起请求。抽象角色 Subject 作为一个接口,其中有一个Request方法。下面还有两个实现类,RealSubject 是实际的实现类,Proxy 是一个代理类,它内部是直接调用 RealSubject 的 request方法。当然,在 Proxy 中,它可以增加自己的 preRequest 和 afterRequest 处理逻辑。

不用代理模式的情况下,Client 就会直接请求 RealSubject。而使用代理模式,Client 直接调用的 Proxy,由 Proxy 先处理一遍,再由 Proxy调用 RealSubject,最后再返回给 Client。整个过程,Client 看到的只有 Proxy,对它来说,Proxy 就是真实的 Subject 实现类。而RealSubject 原来要服务很多的Client,但是现在只需要暴露给 Proxy,它的风险就小很多了,因为只需要信任 Proxy 就够了。

综上,代理模式起到中介隔离效果,避免直接暴露 RealSubject,同时这还符合开闭原则,对扩展开放,对修改关闭。Proxy 就是一个扩展,有新的需求可以在 Proxy 中实现,从而减少对 RealSubject 的修改。RealSubject 从而可以更加聚焦自己的核心能力,把一些边缘性的经常变化的扩展性需求放在 Proxy 中来实现。

作为补充,常见的 gRPC Proxy 原理:

  • 启动一个 gRPC 代理服务端;
  • 拦截 gRPC 的服务,转发到代理的一个函数中执行;
  • 接收客户端的请求,完成相关处理后转发给服务端;
  • 接收服务端的响应,完成相关处理后转发给客户端;

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

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

相关文章

饭堂人群密度检测之Pythton

完整资料进入【数字空间】查看——baidu搜索"writebug" 一、饭堂人群密度检测 二、选题背景 在这个人工智能快速发展的时代,智能交通、智能机器人等人工智能化产品不断出现。作为人工智能的重要分支,计算机视觉起到了重要作用。它通过一系列的…

面试题更新之-使用 base64 编码的优缺点

文章目录 base64 编码是什么?使用 base64 编码的优缺点 base64 编码是什么? Base64编码是一种将二进制数据转换为ASCII字符的编码方式。它将三个字节的二进制数据分割成四组,每组6个比特,然后将这些6个比特转换为可打印的ASCII字…

前端学习——Web API (Day5)

BOM操作 Window对象 BOM 定时器-延时函数 案例 <!DOCTYPE html> <html lang"zh-CN"><head><meta charset"UTF-8"><meta http-equiv"X-UA-Compatible" content"IEedge"><meta name"viewport&q…

XSS 攻击的检测和修复方法

XSS 攻击的检测和修复方法 XSS&#xff08;Cross-Site Scripting&#xff09;攻击是一种最为常见和危险的 Web 攻击&#xff0c;即攻击者通过在 Web 页面中注入恶意代码&#xff0c;使得用户在访问该页面时&#xff0c;恶意代码被执行&#xff0c;从而导致用户信息泄露、账户被…

Docker 部署 Jenkins (一)

Docker 部署 Jenkins (一) 一. 安装 jenkins $ mkdir -p /home/tester/data/docker/jenkins $ vim jenkins:lts-jdk11.sh./jenkins:lts-jdk11.sh 内容 #! /bin/bash mkdir -p /home/tester/data/docker/jenkins/jenkins_homesudo chown -R 1000:1000 /home/tester/data/dock…

解决Spring Data JPA查询存在缓存问题及解决方案

&#x1f337;&#x1f341; 博主 libin9iOak带您 Go to New World.✨&#x1f341; &#x1f984; 个人主页——libin9iOak的博客&#x1f390; &#x1f433; 《面试题大全》 文章图文并茂&#x1f995;生动形象&#x1f996;简单易学&#xff01;欢迎大家来踩踩~&#x1f33…

mysql5.7下载安装配置详细步骤(超详细)【软件下载+环境配置】

1 下载 官方下载地址&#xff1a;MySQL :: Download MySQL Installer 2 安装 双击下载的安装包 等待安装器加载 有些小伙伴在加载过程中可能会出现无法验证其身份或者提示你升级安装器 点击继续运行&#xff0c;不要升级 加载完成后出现这个界面 选择 custom——》next …

中国移动光猫设置桥接

网上教程五花八门&#xff0c;有些坑有些行&#xff0c;我试成功了&#xff0c;记录一下方法。 一、流程简述 1. 使用超级管理员账号登录中国移动光猫&#xff0c;设置桥接&#xff0c;并重启 2. 用网线连接路由器和光猫&#xff0c;登录路由器&#xff0c;设置宽带拨号&…

初识muysql之常见函数

目录 一、日期时间函数 1. 常见的日期时间函数 2. current_date() 3. current_time() 4. current_timestamp() 5. now() 6. date(datetime) 7. date_add(date, interval d_value_type) 8. date_sub(date, d_value_type) 9. datediff(date1, date2) 10. 题目示例 10…

从零开始 Spring Boot 69:JPA 条件查询

从零开始 Spring Boot 69&#xff1a;JPA 条件查询 图源&#xff1a;简书 (jianshu.com) 在之前的文章中我们学习过条件查询&#xff08;Criterial Query&#xff09;&#xff0c;构建条件查询的一般步骤是&#xff1a; 获取HibernateCriteriaBuilder利用HibernateCriteriaBu…

easy rule 学习记录

总体&#xff1a; 使用方面除了官网的wiki外&#xff0c;推荐阅读 作者&#xff1a;夜尽天明_ 链接&#xff1a;https://juejin.cn/post/7048917724126248967 来源&#xff1a;稀土掘金 非annotation 方式&#xff0c;执行不是jdk proxy模式annotation 方式&#xff0c;和ru…

【Linux操作系统】线程控制

文章目录 线程创建线程等待终止线程利用多线程求和(单进程多线程)获取线程ID取消线程线程分离共享&#xff1f; 线程创建 创建线程需要用的函数是pthread_create。函数原型如下&#xff1a; int pthread_create(pthread_t *thread, const pthread_attr_t *attr,void *(*start…

left join 和except方法区别和联系

目录 相同点&#xff1a; left join except 不同点 假设有两个表&#xff1a;A客户表 和 B客户表&#xff0c;客户uid是唯一主键 相同点&#xff1a; 查询在A中的客户 但不在B中&#xff0c;也就是图中的阴影部分&#xff0c;left join 和except方法都可以实现 left join …

Harnessing the Power of LLMs in Practice: A Survey on ChatGPT and Beyond

LLM的系列文章&#xff0c;针对《Harnessing the Power of LLMs in Practice: A Survey on ChatGPT and Beyond》的翻译。 在实践中驾驭LLM的力量——ChatGPT及其后的研究综述 摘要1 引言2 模型实用指南2.1 BERT风格的语言模型&#xff1a;编码器-解码器或仅编码器2.2 GPT风格…

python接口自动化(三十五)-封装与调用--流程类接口关联(详解)

简介 流程相关的接口&#xff0c;主要用 session 关联&#xff0c;如果写成函数&#xff08;如上篇&#xff09;&#xff0c;s 参数每个函数都要带&#xff0c;每个函数多个参数&#xff0c;这时候封装成类会更方便。在这里我们还是以博客园为例&#xff0c;带着小伙伴们实践一…

spring复习:(24)ApplicationContext中的BeanPostProcess是在哪里注册到容器的?

在ApplicationContext实现类的构造方法里。 public ClassPathXmlApplicationContext(String configLocation) throws BeansException {this(new String[] {configLocation}, true, null);}上边的构造方法调用如下构造方法 public ClassPathXmlApplicationContext(String[] conf…

ubuntu使用WHEELTE N100并用rviz显示

写在最开头&#xff0c;如果wheeltec n100被自己改动过参数导致无法读取数据&#xff0c;建议在window的上位机中恢复出厂设置并重新上电&#xff0c;在转入ubuntu。因为我就是这个问题&#xff0c;客服远程操控才帮我解决的。 所有官方资料共享&#xff0c;侵删&#xff1a; …

Flink+StarRocks 实时数据分析新范式

摘要&#xff1a;本文整理自 StarRocks 社区技术布道师谢寅&#xff0c;在 Flink Forward Asia 2022 实时湖仓的分享。本篇内容主要分为五个部分&#xff1a; 极速数据分析 实时数据更新 StarRocks Connector For Apache Flink 客户实践案例 未来规划 点击查看原文视频 &a…

一篇文章让你看懂C语言字符函数和内存函数

目录 一、字符函数 1.strlen函数 1.1strlen函数的介绍 1.2strle函数的使用 1.3模拟实现strlen 1.3.1指针移动法 1.3.2指针减去指针法 1.3.3函数递归法 2.strcpy函数 ​编辑 2.1strcpy函数的介绍 2.2strcpy函数的使用 2.3模拟实现strcpy 3.strcat函数 3.1strcat函数的介…

LiveGBS流媒体平台GB/T28181功能-支持海康大华GB28181语音对讲需要的设备及服务准备

LiveGBS支持海康大华GB28181语音对讲需要的设备及服务准备 1、背景2、准备2.1、服务端必备条件&#xff08;注意&#xff09;2.2、准备语音对讲设备2.2.1、 大华摄像机2.2.1.1、 配置接入示例2.2.1.2、 配置音频通道编号 2.2.2、 海康摄像机2.2.2.1、 配置接入示例 3、开启音频…