Go RPC 如何实现服务间通信

在微服务架构中,每个服务实例负责一个单一领域的业务实现,不同服务实例之间需要进行频繁交互来共同实现业务。那它们是如何通信的呢?

服务实例之间主要通过轻量级的远程调用方式来实现,比如RPC.

RPC(RemoteProcedureCall,远程过程调用协议),是一种通过网络从远程计算机程序上请求服务,而不需要了解底层网络技术的协议。RPC只是一套协议,基于这套协议规范来实现的框架都可以称为RPC 框架,比较典型的有 Dubbo、Thrift 和 gRPC。

RPC机制和实现过程

RPC是远程过程调用的方式之一,涉及调用方和被调用方 两个进程的交互。因为RPC提供类似于本地方法调用的形式,所以对于调用方来说,调用RPC方法和调用本地方法并没有明显区别。

下面,我们就来简单介绍一下 RPC机制的诞生和基础概念。

1984 年, Birrell和 Nelson 在 ACM Transactions on Computer Systems 期刊上发表了名为"lmplementing remote procedure calls"的论文,该文对 RPC 的机制做了经典的诠释:

RPC远程过程调用是指计算机A上的进程,调用另外一台计算机B上的进程的方法。其中,A上的调用进程被挂起,而B上的被调用进程开始执行对应方法,并将结果返回给A;计算机A接收到返回值后,调用进程继续执行。

发起RPC的进程通过参数等方式将信息传送给被调用方,然后被调用方处理结束后,再通过返回值将信息传递给调用方。这一过程对于开发人员来说是透明的,开发人员一般也无须知道双方底层是如何进行消息通信和信息传递的,这样可以让业务开发人员更专注于业务开发,而非底层细节。

RPC让程序之间的远程过程调用具有与本地调用类似的形式。比如说,程序需要读取某个文件的数据,开发人员会在代码中执行 read 系统调用来获取数据。

当read 实际是本地调用时,read函数由链接器从依赖库中提取出来,接着链接器会将它链接到该程序中。虽然read 中执行了特殊的系统调用,但它本身依然是通过将参数压入堆栈的常规方式调用的,调用方并不知道read 函数的具体实现和行为。

当read 实际是一个远程过程时(比如调用远程文件服务器提供的方法),调用方程序中需要引入read的接口定义,称为客户端存根(client-stub)。远程过程read 的客户端存根与本地方法的read 函数类似,都执行了本地函数调用。不同的是它底层实现上不是进行操作系统调用读取本地文件来提供数据,而是将参数打包成网络消息,并将此网络消息发送到远程服务器,交由远程服务执行对应的方法,在发送完调用请求后,客户端存根随即阻塞,直到收到服务器发回的响应消息为止。
下图展示了远程方法调用过程中的客户端和服务端各个阶段的操作。

当客户端发送请求的网络消息到达服务器时,服务器上的网络服务将其传递给服务器存根(server-stub)。服务器存根与客户端存根一一对应,是远程方法在服务端的体现,用来将网络请求传递来的数据转换为本地过程调用。服务器存根一般处于阻塞状态,等待消息输入。

当服务器存根收到网络消息后,服务器将方法参数从网络消息中提取出来,然后以常规方式调用服务器上对应的实现过程。从实现过程角度看,就好像是由客户端直接调用一样,参数和返回地址都位于调用堆栈中,一切都很正常。实现过程执行完相应的操作,随后用得到的结果设置到堆栈中的返回值,并根据返回地址执行方法结束操作。以read 为例,实现过程读取本地文件数据后,将其填充到read 函数返回值所指向的缓冲区。

read 过程调用完后,实现过程将控制权转移给服务器存根,它将结果(缓冲区的数据)打包为网络消息,最后通过网络响应将结果返回给客户端。网络响应发送结束后,服务器存根会再次进入阻塞状态,等待下一个输入的请求。

客户端接收到网络消息后,客户操作系统会将该消息转发给对应的客户端存根,随后解除对客户进程的阻塞。客户端存根从阻塞状态恢复过来,将接收到的网络消息转换为调用结果,并将结果复制到客户端调用堆栈的返回结果中。当调用者在远程方法调用read 执行完毕后重新获得控制权时,它唯一知道的是read 返回值已经包含了所需的数据,但并不知道该read 操作到底是在本地操作系统读取的文件数据,还是通过远程过程调用远端服务读取文件数据。

RPC框架的组成

一个完整的 RPC框架包含了服务注册发现、负载、容错、序列化、协议编码和网络传输等组件。不同的RPC 框架包含的组件可能会有所不同,但是一定都包含 RPC 协议相关的组件,RPC 协议包括序列化、协议编解码器和网络传输栈,如下图所示:

RPC 协议一般分为公有协议和私有协议。例如,HTTP、SMPP、WebSerVice 等都是公有协议;如果是某个公司或者组织内部自定义、自己使用的,没有被国际标准化组织接纳和认可的协议,往往划为私有协议,例如Thrift 协议和蚂蚁金服的BoIt 协议。

分布式架构所需要的企业内部通信模块,往往采用私有协议来设计和研发。相较公有协议,私有协议虽然有很多弊端,比如在通用性上、公网传输的能力上,但是高度定制化的私有协议可以最大限度地降低成本,提升性能,提高灵活性与效率。定制私有协议,可以有效地利用协议里的各个字段,灵活满足各种通信功能需求,比如:CRC校验、SerVerFail-Fast 机制和自定义序列化器。
在协议设计上,你还需要考虑以下三个关键问题:

  • 1.协议包括的必要字段与主要业务负载字段。协议里设计的每个字段都应该被使用到,避免无效字段。
  • 2.通信功能特性的支持。比如,CRC 校验、安全校验、数据压缩机制等。
  • 3.协议的升级机制。毕竟是私有协议,没有长期的验证,字段新增或者修改,是有可能发生的,因此升级机制是必须考虑的。

RPC 和 HTTP 概念解析

RPC 和 HTTP 都是微服务间通信较为常用的方案之一,所以 RPC 和 HTTP 这两个概念经常被拿来一起比较,今天我们就来彻底讲清楚这两个概念之间的关系。

其实,RPC 和 HTTP 并不完全是同一个层次的概念。

RPC 是远程过程调用,其调用协议通常包括序列化协议和传输协议。序列化协议有基于纯文本的XML和JSON、二进制编码的 Protobuf 和 Hessian。传输协议是指其底层网络传输所使用的协议,比如TCP、HTTP。

可以看出HTTP是RPC的传输协议的一个可选方案,比如说gRPC的网络传输协议就是HTTP。

如上图所示,HTTP既可以和RPC一样作为服务间通信的解决方案,也可以作为 RPC 中通信层的传输协议(此时与之对比的是TCP协议)。

HTTP 和自定义 TCP 协议都可以作为 RPC 的传输协议,二者的对比和选择也是 RPC 选型的重要考量或优化点。那么,为什么传输层协议会使用自定义的TCP 协议呢?

你可能首先想到HTTP是无状态、无连接的,所以每次进行通信都要建立和断开连接,这会影响通信效率。

但实际上HTTP协议是支持连接池复用的,能建立一定数量的连接并且保持连接不会断开,不用频繁建立和断开连接,因此连接问题并不是优先选择自定义TCP协议的真正原因。

那真正的原因到底是什么呢?

其实真正的原因就在于自定义TCP协议可以灵活地对协议字段进行定制减少非必要字段的传输,减少网络开销;而HTP协议则包含了过多无用的信息,比如头部等信息。

HTTP1.1 协议包含太多废信息,一个响应的格式大致如下:

bash 复制代码
HTTP/1.0 200 0K
Content-Type: text/plain
Content-Length: 137582
Expires: Thu, 05 Dec 2020 16:00:00 GMT
Last-Modified: Wed, 5 August 2020 15:55:28 GMT
Server: Apache/1.3.3.7 (Unix) (Red-Hat/Linux)
<htm1>
    <body>He11o Wor1d</body>
</htm1>

从中可以看出,其返回的数据很少,其他大部分都是元数据。至于HTTP协议和TCP自定义协议的详细对比,如下图所示,可以看出自定义TCP协议传输相同信息时所要传递的数据量更少,所以网络通信更快,所需开销也更少。

常见的PRC框架

目前流行的开源 RPC框架还是比较多的,有阿里巴巴的 Dubbo、Google 的 gRPC、Facebook 的 Thrift和Twitter的Finagle等。

今天我们简单介绍下 Go RPC、gRPC 和 Thrift 这三种常见的框架,在后续的课程中我们还会对其进行详细讲解。

  • GoRPC:Go 语言原生支持的 RPC 远程调用机制,简单便捷,非常适合你了解和学习RPC的入门框架。
  • gRPC:Google 发布的开源 RPC 框架,是基于 HTTP 2.0 协议的,并支持众多常见的编程语言,它提供了强大的流式调用能力,目前已经成为最主流的 RPC 框架之一。
  • Thrift:Facebook 的开源 RPC 框架,主要是一个跨语言的服务开发框架,作为老牌开源 RPC协议,以其高性能和稳定性成为众多开源项目提供数据的方案选项。

在上面,我们讲解了 RPC 的相关概念和常见的 RPC 框架。其中,Go RPC 是指 Go 语言原生支持的RPC框架,它虽然简单但却十分经典,非常适合作为你后续深入了解 RPC框架时的研究对象。

下面,我们将先通过一个字符串服务为案例简单讲解GoRPC 是如何进行通信的,然后再具体剖析GoRPC的底层原理和实现,对以服务端注册服务、接收并处理客户端请求和客户端发起请求等步骤分别进行详细介绍,相信你学习后,一定会对GoRPC 有更加全面的了解和认识。

Go语言RPC过程调用实践

Go语言原生的RPC过程调用实现起来非常简单。服务端只需实现对外提供的远程过程方法和结构体,然后将其注册到RPC服务中,客户端就可以通过其服务名称和方法名称进行RPC方法调用。

下面,我们就使用字符串操作的服务来展示如何使用Go语言原生的RPC 来进行过程调用。

**第一步,定义远程过程调用相关接口传入参数和返回参数的数据结构。**如下代码所示,调用字符串操作的请求包括两个参数:字符串A和字符串B。

Go 复制代码
type stringRequest struct {
    A string
    B string
}

**第二步,定义一个服务对象。**这个服务对象可以很简单,比如类型是 int 或者是interfaceϑ,重要的是它输出的方法。这里我们定义一个字符串服务类型的interface,其名称为 Service,它有一个字符串拼接函数Concat;然后定义一个名为 StringService的结构体,实现 Service 接口,并给出Concat 具体实现。代码如下:

Go 复制代码
type Service interface {
  // Concat a and b
  Concat(req StringRequest, ret *string) error
}
type Stringservice struct {
}
func (s StringService) Concat(req StringRequest, ret *string) error {
  // test for length overflow
  if len(req.A)+len(req.B) > StrMaxSize {
    *ret = ""
    return ErrMaxSize

  }
  *ret = req.A + req.B
  return nil
}

**第三步,实现 RPC服务器。**这里我们生成了一个 StringSevice 结构体,并使用rpc.Register注册这个服务,然后通过net.Listen 监听对应 socket 并对外提供服务。客户端可以访问服务 StringService 以及它的方法Concat,代码如下:

Go 复制代码
func mainO {
  stringService := new(service.StringService)
  rpc.Register(stringService)
  rpc.HandleHTTP()
  l, e := net.Listen("tcp", "127.0.0.1:1234")
  if e != nil {
    log.Fatal("'listen error:", e)
  }
  http.Serve(l, nil)
}

**第四步,建立 HTTP 客户端,**然后通过 Call 方法调用远程 StringService 的对应方法,比如使用同步的方式,代码如下所示。这时客户端就可以进行远程调用了。

Go 复制代码
func main) {
  client, err := rpc.DialHTTP("tcp", "127.0.0.1:1234")
  if err != nil {
    log.Fatal("dialing:", err)
  }
  stringReq := &service.StringRequest{"A", "B"}
  var reply string
  err = client.Call("stringService.Concat", stringReq, &reply)
  if err != nil {
    log.Fatal("Concat error:", err)
  }
  // 异步的调用方式
  cal1 := client.Call("stringservice.Concat", stringReq, &reply)
  _ := <-cal1.Done
}

通过上述代码的编写就可以实现两个Go服务之间的RPC 调用了。那GoRPC 又是如何实现的呢?

GoRPC原理解析

接下来我们将对Go语言的RPC原生实现进行源码分析,细致讲解其具体实现和原理。首先我们会对RPC的服务(SerVer)端代码进行分析,包括注册服务、反射处理和存根保存,然后讲解服务端处理RPC请求的流程,最后讲解客户(Client)端的 RPC请求处理。

1.Go RPC服务端原理

服务端的RPC代码主要分为两个部分:①服务方法注册,包括调用注册接口,通过反射处理将方法取出,并存到map中;②处理网络调用,主要是监听端口、读取数据包、解码请求和调用反射处理后的方法,将返回值编码,返回给客户端。

在上面的示例代码中,我们使用 rpc.Register 对 StringService 进行了注册,Register是进行 RPC 服务注册的入口方法,其参数interfacef 类型的rcvr 就是要注册的 RPC服务类型,该注册过程的流程如下图所示。

Register 方法中通过反射获取接口类型和值,并通过 suitableMethods 函数判断注册的 RPC 是否符合规范,最后调用 serviceMap 的 LoadOrStore(sname, s) 方法将对应 RPC 存根存放于 map 中,供之后查找。

Go 复制代码
func (server *server) register(rcvr interface{}, name string, useName bool) error {
  // 如果服务为空,默认注册一个
  if server.serviceMap == nil {
    server. serviceMap = make (map [string]*service)
  }
  // 获取注册服务的反射信息s := new(service)
  s.typ = reflect.Typeof(rcvr)
  s.rcvr = reflect.Valueof(rcvr)
  // 可以使用自定义名称
  sname := reflect.Indirect(s . rcvr) . TypeC .Name()
  if useName {
    sname = name
  }
  // 方法必须是暴露的,既服务名首字符大写;不允许重复注册。代码有省略
  if !isExported(sname) && !useName {
  }
  if _, present := server.serviceMap[sname]; present {
  }

s.name = sname
// 开始注册 rpc struct 内部的方法存根
s.method = suitableMethods(s.typ, true)
if len(s.method) == 0 {
  //如果struct内部一个方法也没,那么直接报错,打印详细的错误信息
}
  //保存在server的serviceMap中
server.serviceMap[s.name] = S

return nil
}

接下来,我们来看一下服务端处理 RPC 请求的实现。如下图就展示了服务端 RPC程序处理请求的过程,它会一直循环处理接收到的客户端 RPC 请求,将其交由 ReadRequestHandler 处理,然后从之前Register 方法保存的 map 中获取到要调用的对应方法;接着从请求中解码出对应的参数,使用反射调用其方法,获取到结果后将结果编码成响应消息返回给客户端。

下面,我们来看一下服务端接收并处理 RPC 请求的具体代码实现。

(1)接收请求

Server 的 Accept 函数会无限循环地调用 net.Listener 的 Accept 函数来获取客户端建立连接的请求,获取到连接请求后,再使用协程来处理请求。代码如下:

Go 复制代码
func (server *Server) Accept(lis net.Listener) {
  for {
    conn, err := lis.Accept()
    if err != nil {
      log.Fatal("rpc.Serve: accept:", err.Error())
    }
    // accept连接以后,打开一个goroutine处理请求
    go server.ServeConn(conn)
  }
}

(2)读取并解析请求

ServeConn 函数会从建立的连接中读取数据,然后创建一个gobSerVerCodec,并将其交由 SerVer 的ServeCodec函数处理,如下所示:

Go 复制代码
func (server *server) ServeConn(conn io.ReadwriteCloser) {
  buf := bufio.NewWriter(conn)
  srv:= &gobserverCodec{
    rwc: conn,
    dec: gob. NewDecoder(conn) ,
    enc: gob.NewEncoder(buf),
    encBuf: buf,
  }
  // 根据指定的codec进行协议解析
  server.ServeCodec(srv)
}

ServeCodec 函数会循环地调用 readRequest 函数,读取网络连接上的字节流,解析出请求,然后开启协程执行 Server 的 call 函数,处理对应的 RPC 调用。

Go 复制代码
func (server *server) ServeCodec(codec ServerCodec) {
  sending := new(sync.Mutex)
  for {
    // 解析请求
    service, mtype, req, argv, replyv, keepReading, err := server.readRequest(codec)
    if err != nil {
      if debugLog && err != io.EoF {
        log.Println("rpc:", err)
      }
      if !keepReading {
        break
      }
      // send a response if we actually managed to read a header.
      // 如果当前请求错误了,我们应该返回信息,然后继续处理
      if req != nil {
        server.sendResponse(sending, req, invalidRequest, codec, err.Error())
        server .freeRequest(req)
      }
      continue
    }
    // 因为需要继续处理后续请求,所以开一个gorutine处理rpc方法
    go service.call(server, sending, mtype, req, argv, replyv, codec)
  }
    // 如果连接关闭了需要释放资源
    codec.Close()
}

(3)执行远程方法并返回响应

Server 的 call函数就是通过 Func.Call 反射调用对应 RPC 过程的方法,它还会调用 send Response将返回值发送给RPC 客户端,代码如下:

Go 复制代码
func (s *service) call(server *server, sending *sync.Mutex, mtype "methodType, req*Request, argv, replyv reflect.Value, codec ServerCodec) {
  function := mtype.method.Func
  // 这里是真正调用rpc方法的地方
  returnValues := function.Call([]reflect.Value{s.rcvr, argv, replyv})
  errInter := returnValues [o].Interface()
  errmsg := ""
  // 处理返回请求了
  server.sendResponse(sending, req, replyv.InterfaceO, codec, errmsg)
  server .freeRequest(req)
}

2.客户端发送RPC请求原理

无论是同步调用还是异步调用,每次 RPC 请求都会生成一个Call 对象,并使用 seq 作为 key 保存在map中,服务端返回响应值时再根据响应值中的 seq 从 map 中取出 Call,进行相应处理。客户端发起RPC调用的过程大致如下图所示。

下面我们将依次讲解同步调用和异步调用、请求参数编码和接收服务器响应三个部分的具体实现。

(1)同步调用和异步调用

本文的案例展示了Go原生 RPC的客户端支持同步和异步两种调用,下面我们来介绍一下这两种调用的函数以及调用的数据结构。

Go 复制代码
func (client *Client) Call(serviceMethod string, args interface{}, reply
interface{}) error {
  cal1 := <-client.Go(serviceMethod, args, reply, make(chan *Call, 1)).Done
  return cal1.Error
}

Cal方法直接调用了Go方法,而Go方法则是先创建并初始化了CalI对象,记录下此次调用的方法、参数和返回值,并生成 DoneChannel;然后调用Client的 send 方法进行真正的请求发送处理,代码如下:

Go 复制代码
//异步调用实现
func (client *Client) Go(serviceMethod string, args interface{}, reply interface{},done chan *call) *call {
  // 初始化 call1
  call := new(Call)
  call.ServiceMethod = serviceMethod
  call.Args = args
  call.Reply = reply
  if done == ni1 {
    done = make(chan *Cal1, 10) // buffered.
  } else {
    if cap(done) == 0 {
      log.Panic("rpc: done channel is unbuffered")
    }
  }
  call.Done = done
  // 调用Client的send方法
  client.send(call)
  return call
}
type Call struct {
  ServiceMethod string  //  服务名及方法名格式:服务.方法
  Args    interface{}   //   函数的请求参数(*struct).
  Reply    interface{}  //   函数的响应参数(*struct).
  Error    error        // 方法完成后error的状态.
  Done     chan *call  //
}

(2)请求参数编码

Client 的 send 函数首先会判断客户端实例的状态,如果处于关闭状态,则直接返回结果;否则会生成唯一的 seq 值,将Call 保存到客户端的哈希表 pending 中,然后调用客户端编码器的WriteRequest 来编码请求并发送,代码如下:

Go 复制代码
func (client *Client) send(cal1 *Call) {
  // ....
  // 生成seg,每次调用均生成唯一的seg,在服务端返回结果后会通过该值进行匹配
  seq := client.seq
  client.seq++
  client.pending[seq] = call
  client.mutex.Unlock()

  //请求并发送请求
  client.request.Seq = seq
  client.request.ServiceMethod = call.ServiceMethod
  err :=client.codec.WriteRequest(&client.request, call.Args)
  if err != nil {
    // 发送请求错误时,将map中call对象删除
    client.mutex.Lock()
    call = client.pending[seq]
    delete(client.pending, seq)
    client.mutex.Unlock()
  }
}

(3)接收返回值

接下来我们来看一下客户端是如何接收并处理服务端返回值的。客户端的input 函数接收服务端返回的响应值,它进行无限 for 循环,不断调用 codec 也就是 gobClientCodecd 的 ReadResponseHeader函数,然后根据其返回数据中的 seα 来判断是否是本客户端发出请求的响应值。如果是,则获取对应的Call对象,并将其从 pending 哈希表中删除,继续调用 codec 的 ReadReponseBody 方法获取返回值Reply 对象,并调用 Call 对象的 done 方法,代码如下:

Go 复制代码
func (client *Client) input() {
  var err error
  var response Response
  for err nil {
    response = Response{}
    // 通过response中的Seg获取call对象
    seq := response.Seq
    client.mutex.Lock()
    call := client.pending[seq]
    delete(client.pending, seq)
    client.mutex.Unlock()

<span class="h1js-keyword">switch</span> {
<span class="h1js-keyword">case</span> cal1 == <span class="h1js-literal">nil</span>:
<span class="hljs-keyword">case</span> response.Error != <span class="hljs-string">""</span>:
  <span class="h1js-comment">//上述两个case,一个处理cal1为ni1,另外处理服务端返回的错误,直接将错误返回</span>
<span class="hljs-keyword"'>default</span>:
  <spanclass="hljs-comment">//通过编码器,将Resonse的body部分解码成reply对象.</span>
  err = client.codec.ReadResponseBody(call.Reply)
  <span class="h1js-keyword">if</span> err != <span class="h1js-1iteral">ni1</span> {
    call.Error = errors.New(<span class="hljs-string">"reading body "</span> +
err.Error())
  }
  cal1.done)
}

上述代码中,gobClientCodecd 的 ReadResponseHeader、ReadReponseBody方法和上文中的WriteRequest 类似,这里不做赘述。Call 对象的 done 方法则通过Call 的 DoneChannel,将获得返回值的结果通知到调用层,代码如下:

Go 复制代码
func (call *Call) done() {
  select {
  case call.Done <- call:
    // ok
  default:
    if debugLog {
      log.Println("rpc: discarding Call reply due to insufficient Done chan
capacity")
    }
  }
}

客户端接收到RPC 请求的响应后会进行其他业务逻辑操作,RPC 框架则会对进行 RPC 请求所需要的资源进行回收,下次进行 RPC 请求时则需要再次建立相应的结构体并获取对应的资源。

小结

本文我们首先详细介绍了微服务之间的远程方法调用过程,以及这过程中客户端和服务端的行为;然后讲解了RPC框架的组成,以及与HTTP两个概念的对比解析;最后我们还简单介绍了目前主流的RPC框架。

Go语言原生 RPC 算是个基础版本的 RPC 框架,代码精简,可扩展性高,但是只实现了RPC 最基本的网络通信,而超时熔断、链接管理(保活与重连)、服务注册发现等功能还是欠缺的。因此还是达不到生产环境"开箱即用"的水准,不过 GitHub 就有一个基于 RPC 的功能增强版本一一rpcX,支持了大部分主流RPC的特性。

虽然目前官方已经宣布不再添加新功能,并推荐使用gRPC,但是作为Go标准库中的RPC框架,还是有很多地方值得我们借鉴和学习,比如注册服务时如何保存反射信息等。本文我们从源码角度分析了Go 语言原生 RPC 框架,希望能给你带来对RPC 框架的整体认知。

相关推荐
同聘云2 小时前
阿里云国际站服务器cdn网络故障的解决方法是什么?
服务器·开发语言·阿里云·php
计算机安禾2 小时前
【数据结构与算法】第8篇:线性表(四):双向链表与循环链表
c语言·开发语言·数据结构·c++·算法·链表·visual studio
wangchunting2 小时前
数据结构-线性数据结构
java·开发语言·数据结构
小陈工4 小时前
Python安全编程实践:常见漏洞与防护措施
运维·开发语言·人工智能·python·安全·django·开源
是娇娇公主~10 小时前
C++ 中 std::deque 的原理?它内部是如何实现的?
开发语言·c++·stl
SuperEugene10 小时前
Axios 接口请求规范实战:请求参数 / 响应处理 / 异常兜底,避坑中后台 API 调用混乱|API 与异步请求规范篇
开发语言·前端·javascript·vue.js·前端框架·axios
lars_lhuan11 小时前
Go WaitGroup 源码解析
golang
xuxie9911 小时前
N11 ARM-irq
java·开发语言
wefly201712 小时前
从使用到原理,深度解析m3u8live.cn—— 基于 HLS.js 的 M3U8 在线播放器实现
java·开发语言·前端·javascript·ecmascript·php·m3u8