标签 http 下的文章

使用Go和WebRTC data channel实现端到端实时通信

本文永久链接 – https://tonybai.com/2023/09/23/p2p-rtc-implementation-with-go-and-webrtc-data-channel

关于实时通信(RTC,Real-Time Communication),我和大多数人一样,是用的多(比如网络电话、音视频会议等),但对RTC概念和其底层技术原理了解的却不多。近期,一项目恰用到了RTC技术,我就顺便翻阅了一些资料,并用Go建立了一个端到端数据通信的小demo,这里给大家分享一下。

1. RTC与WebRTC

1.1 实时通信(Real-Time Communication)

实时通信(RTC)是实时发生的任何在线通信。生活中,最常见的采用实时通信方式的例子就是电话:一旦双方接通后,数据便直接从发送方即时发送到接收方,不会存储在前往目的地的途中

而传统的邮件以及互联网电子邮件则并非实时通信,因为在邮件/电邮的场景下,我们发送数据后,对方通常要等待一段时间才能收到数据,同时我们也需要等待一段时间才能收到回复。相信这个反例可以更好地帮助大家理解实时通信的特点。

总结一下,实时通信具有以下特点(想象一下打电话的过程):

  • 存在接通的过程
  • 点对点(通常没有中间存储或处理节点)
  • 传输低延迟

1.2 WebRTC技术的诞生

显然RTC技术是一种能给人们生活带来极大便捷的技术,尤其是在音视频实时传输方面,但很长时间以来,实时通信技术都十分复杂,还有专利门槛,将实时通信技术与业务结合既非常困难,又十分耗时,并且即便大力投入也未必能取得很好的效果,通常只有大厂才有这个能力实现稍完善的RTC方案和产品。

此外,随着Web技术的兴起、移动互联网时代的到来、4G/5G和宽带技术的蓬勃发展,人们都迫切希望将实时通信技术与Web等技术融合在一起,通过浏览器或智能终端即可快速建立音视频的实时数据通信。

于是2009年谷歌出手了!

  • 2009年,谷歌提出了创建WebRTC的概念,作为Adobe Flash以及无法在浏览器中运行的桌面应用程序的替代方案。
  • 2010年,谷歌收购了大量提供RTC技术授权的公司。
  • 2011年,谷歌开源了WebRTC项目
  • 2011年末,W3C发布第一个WebRTC规范草案。
  • 2013年,谷歌和Mozilla展示了基于WebRTC的异构浏览器之间的视频通话。
  • 2017年,WebRTC进入候选推荐标准(Candidate Recommendation,CR)阶段。
  • 2021年初,WebRTC成为W3C正式推荐标准及IETF标准

如今,WebRTC已经广泛用在了在线教育、电商直播、泛娱乐社交等应用领域。

1.3 WebRTC简明介绍

WebRTC(Web Real-Time Communication)是一套开源的点对点实时通信技术,最初为Web打造,旨在让Web应用可以直接在浏览器中进行实时的音视频通信和数据交换,而无需安装第三方插件。WebRTC具体体现为一组开源协议、引擎和API。

下面是W3C出品的WebRTC的技术栈的架构图(来自https://webrtc.github.io/webrtc-org/architecture/):

我们看到WebRTC还是蛮复杂的,涉及到多类API、会话/信令管理、音频编解码算法引擎、视频编解码算法引擎、包含多种协议的传输层以及底层音视频捕捉和渲染等。全面掌握WebRTC全技术栈是很困难的,好在上面的架构图将不同领域的开发者的关注点做了标记,大多数开发者关注WebRTC API和Web API即可。并且,随着WebRTC自身的演进,目前WebRTC已经不局限于浏览器,可以应用于其他各种应用程序。在Go社区,最知名的WebRTC类项目莫过于pion了,它提供了纯Go的WebRTC API实现,任何Go应用都可以使用pion的WebRTC API开发点对点实时通信应用。

1.4 WebRTC相关的协议

WebRTC并没有全部另立炉灶从头建立很多新协议,而是复用了很多成熟的网络协议和应用协议,尤其是涉及数据传输的协议。下图是WebRTC中使用的一些重要协议分布图:


图改自《WebRTC技术详解》一书

很多协议大家都非常熟悉,比如HTTP、WebSocket、TLS、TCP、UDP等,但也有些协议是大家比较陌生的,如RTP/SRTP、SCTP等,针对这些陌生协议,我们下面简要介绍一下:

1.4.1 RTP(Real-time Transport Protocol,实时传输协议)和SRTP(Secure RTP)

RTP协议支持通过IP网络实时传输音频和视频。RTP常用于流媒体服务的通信系统,例如网络电话、视频电话会议等系统。RTP也是WebRTC使用的最重要的协议之一,在WebRTC中,RTP用于在WebRTC客户端(比如浏览器)之间传输音频和视频媒体(media)数据包。

RTP是专为流媒体的端到端实时传输设计的,更关注信息的实时性,可以避免出现因网络传输丢失数据造成通话质量下降的情况。并且,如上图所示,RTP都是基于UDP构建的,并额外提供抖动补偿、包丢失检测和无序传递检测的功能。

此外,RTP在传递媒体流时会为每个媒体流建立一个会话,即音频和视频流各自使用单独的RTP会话,这样接收端就能有选择性地接收媒体流(音频、视频或音视频)。

基础的RTP没有内置任何安全机制,因此不能保证传输数据的安全性,这样端与端之间通信传输未加密的数据时,都有可能被第三方拦截并窃取。为此,WebRTC规范明确禁止使用未加密的RTP,而是使用安全增强后的SRTP(Secure RTP)。SRTP可以为单播和多播应用程序中的RTP数据提供加密、消息身份验证和完整性以及重放攻击保护等安全功能。

注:对于非音频或视频数据,WebRTC不使用RTP,而是在通信的两端建立一个data channel用于交换任意格式的数据。

1.4.2 SCTP(Stream Control Transmission Protocol,SCTP)

WebRTC的端与端建立连接后,音视频数据的交互由RTP/SRTP协议完成,但非音视频数据,则由两端之间建立的数据通道(data channel)完成。数据通道支持传输字符串、文件、图片等数据。

数据通道API的使用方式与WebSocket非常相似,但是WebSocket运行于TCP之上,而WebRTC数据通道的底层传输使用了DTLS/UDP,具有较高的安全性,上层则是使用SCTP,默认使用可靠且有序的方式进行数据传输。

SCTP是在2000年由IETF的SIGTRAN工作组定义的一个传输层协议。它是面向连接、端到端、全双工、带有流量和拥塞控制的可靠传输协议,本来与TCP和UDP处于同一级别,可以直接运行在IP之上。只是在WebRTC中,它被用在了应用层。

WebRTC充分利用了SCTP的面向消息(非tcp那样的面向流)的、带有拥塞控制算法的可靠传输机制,同时SCTP支持在一个传输通道中关联多个流的特性,这样每个流可以单独处理,甚至可以具有不同的可靠性属性。流与流之间不存在线头阻塞问题。流由流编号标识,可以在一定程度上提供多路复用功能,而无需开多个SCTP连接。

1.4.3 SDP(Session Description Protocol, 会话描述协议)

SDP是一种文本形式的会话描述协议,用于描述多媒体会话的参数。

SDP是WebRTC端与端建立连接过程中必须要使用的协议。WebRTC使用SDP来描述对等连接的两端的媒体特征,包括会话属性、会话活动的时间、会话包含的媒体信息、媒体编/解码器、媒体地址和端口信息以及网络带宽的信息等。

下面是SDP协议内容的一个典型例子(来自https://developer.mozilla.org/en-US/docs/Glossary/SDP):

v=0
o=alice 2890844526 2890844526 IN IP4 host.anywhere.com
s=
c=IN IP4 host.anywhere.com
t=0 0
m=audio 49170 RTP/AVP 0
a=rtpmap:0 PCMU/8000
m=video 51372 RTP/AVP 31
a=rtpmap:31 H261/90000
m=video 53000 RTP/AVP 32
a=rtpmap:32 MPV/90000

WebRTC的两个端在使用RTP/SRTP传输音视频数据或使用SCTP传输data channel数据之前,需要先建立连接。建立连接的过程类似于传统电话从拨号、呼叫等待、到接通的过程。这个过程通常会有一个叫信令服务器(signaling server)的中间角色(好比文首配图的人工电话交换机)参与。而SDP在建连过程中起着重要作用,信令服务器会将两端的SDP转发给另一方,直到两端都拥有了自己和对方的会话描述信息(SDP承载),并在媒体交换格式方面达成了一致,这是两端连接成功的前提。

注:SDP不是WebRTC专属的,SDP在很多领域有广泛应用,最常见的就是即时通信(IM)领域。

1.4.4 STUN、TURN和ICE

使用WebRTC进行实时通信的两端通常都位于防火墙或NAT之后的“内网”,只有很少部分主机能够拥有独立的公网IP而直接接入Internet。也就是说,尝试建立连接的双方由于位于NAT网络之中,不能直接使用内网IP地址建立网络连接。WebRTC于是使用“NAT穿透技术(俗称打洞)”来帮助两端建立连接。

STUN就是一种最常见的NAT穿透协议,其全称是“Simple Traversal of UDP Through NATs”,即简单的用UDP穿透NAT。STUN本质上是一种公网地址及端口的发现协议,客户端向STUN服务器发送请求,STUN服务器返回客户端的公网地址及NAT网络信息。这些信息用于构建在ICE打洞时的候选地址,并由信令服务器转发给另一端。

不过STUN无法应对所有NAT网路情形,在对称NAT(映射的外网地址端口号不固定,会随着目的地址的变化而变化)情况下,WebRTC用户无法使用STUN协议建立P2P连接,这种情况就需要借助TURN协议提供的服务进行流量中转。

TURN(Traversal Using Relays around NAT)是一种通过数据转发的方式穿透NAT的,解决了防火墙和对称NAT的问题。TURN支持UDP和TCP协议。

注:使用STUN建立的是P2P的网络模型,网络连接直接建立在通信两端,没有中间服务器介入;而使用TURN建立的是流量中继的网络模型,用户两端都与TURN服务建立连接,用户的网络数据包通过TURN服务进行转发 — 《WebRTC技术详解》

我们看到,TURN与STUN的共同点都是通过修改应用层中的私网地址达到NAT穿透的效果,不同点是TURN是通过两方通讯的“中间人”方式实现穿透。但TURN与其他中继控制协议也有不同,它能够允许一个客户端使用一个中继地址与多个对端连接。

ICE(Interactive Connectivity Establishment, 交互式连接建立)跟STUN和TURN不一样,ICE不是一种协议,而是一个框架(Framework),它整合了STUN和TURN,并利用STUN和TURN服务器来帮助两端建立起连接。

WebRTC的一端通过ICE获得的每个网络信息都会被包装成一个ICE候选者(candidate)。ICE候选者描述了用于建立网络连接的网络信息,包含网络协议、IP地址、端口等。如果设备上有多个IP地址,那么每个IP地址都会对应一个候选。例如设备A上有内网IP地址IP-1,还有公网IP地址IP-2,A通过IP-1可以直接与B进行通信,但是WebRTC不会判断优先使用哪个IP地址,而是同样从两个IP地址收集候选,并将候选信息通过信令服务器转发给另一端。

ICE候选者有多种类型(以基于UDP传输为例),包括host(本机候选)、srflx(映射候选)、relay(中继候选)和prflx(来自对称NAT的映射候选)。类型有优先级次序,其中host优先级最高,relay优先级最低。比如WebRTC收集到了两个候选者,一个是host类型,另一个是srflx类型,那么WebRTC一定会先尝试与host类型的Candidate建立连接,如果不成功,才会使用srflx类型的Candidate。

当两端都得到自己和对方的ICE候选信息后,就会进行ICE候选配对,并最终选出一个用于建立端与端连接的ICE候选者对(pair),最终两端将基于这个候选者对中的网络信息建立了P2P的连接。

有了上面协议这层铺垫后,接下来我们再来看WebRTC建立连接的流程就容易多了。

1.5 WebRTC的建连流程

下面是WebRTC的典型建连流程图:

如图所示,WebRTC端到端建立连接的第一步是与信令服务器建立连接并交换SDP信息。

信令服务器通常位于两端都能访问到的公网。当WebRTC一端启动后,它可能不知道要与谁通信,或者仅知道对方的极少的信息(比如一个ID),信令服务器可以帮助参与通信的两端解决这个问题。就像前面说的,你可以将信令服务器看作是电话人工交换机及其操作员,它可以帮助参与通信的两端找到彼此。WebRTC并未将信令服务器以及信令协议标准化,因为信令服务器是“业务相关”的,究竟是建立一对一连接,还是建立群聊,这些由信令服务器的业务来决定。承载信令的协议可以是普通的HTTP,也可以是WebSocket,亦可是像XMPP那样的专用信令协议。

在WebRTC中,主动发起连接的一方会创建offer,并通过信令服务器将offer转发给另一方;另一方收到offer后会创建answer,并同样通过信令服务器转发给发起方。无论是offer,还是answer,都包含了各自的SDP信息。

第二步,当交换SDP后,两端各自发起ICE过程,向STUN/TURN服务器发起请求,获取各自NAT后的公网信息,并形成ICE候选者。

第三步,双方通过信令服务器交换ICE候选者信息

当ICE候选者配对成功后,就来到了第四步,WebRTC两端直接建立连接。连接建立成功后,便可以进行数据传输交换了。

2. WebRTC data channel

上面提到过,WebRTC除了提供了音视频媒体实时通信能力外,还支持可以传输非媒体流数据的数据通道(data channel)

和音视频数据一样,经由WebRTC数据通道进行的数据交换不经过服务器,不受服务器性能及带宽瓶颈的限制,同时减少了数据被拦截的概率。数据通道底层传输使用了DTLS,具有较高的安全性。上层使用SCTP,默认使用可靠且有序的方式进行数据传输。此外,data channel的建连过程与音视频的建连过程也是一致的。

下面我们就来用一个实际的例子展示一下如何使用Go建立基于WebRTC data channel的端到端实时通信。

3. 基于Go和Pion的WebRTC data channel应用示例

通过前面的介绍,我们知道了WebRTC技术栈十分复杂,日常WebRTC应用开发时,我们一般会基于开源的实现进行开发。Go语言在WebRTC开发领域也有比较成熟的开源项目,如Pion。Pion提供了纯Go实现的WebRTC API实现以及WebRTC相关组件实现,使用Pion可以帮助我们快速高效开发WebRTC服务器和客户端应用。

3.1 pion: 纯Go的WebRTC实现

根据pion之父的说法,pion的诞生源于用WebRTC构建东西的挫败感,这种挫败感来源于Google开源的首个webrtc实现libwebrtc,因为将libwebrtc构建和运行起来似乎十分困难。

pion就是根据libwebrtc的教训而设计的,pion给开发者的第一印象就是它十分容易构建和运行起来。这一定程度要归功于pion是用Go编写的,更模块化,也更透明,并且pion之父最初便考虑了将其用在Chromium之外的应用中。

pion是一个纯粹的WebRTC软件的Go集合, 涵盖了WebRTC项目中需要的所有主要元素:

同时,pion项目还为WebRTC开发者贡献了一本非常好的WebRTC资料《WebRTC For The Curious》,很值得一读。另外,pion项目的examples也十分丰富,非常利于初学者快速掌握WebRTC以及如何使用pion开发WebRTC应用。

下面我们就基于pion的webrtc实现项目开发一个基于data channel的端到端实时通信示例。

根据之前对WebRTC建立过程的说明,我们首先需要设计一下这个示例的信令服务器以及信令协议。

3.2 信令服务与协议设计

信令服务器在WebRTC通信中扮演协调者的角色。它传递客户端的媒体参数和连接候选信息。

我们的业务模型是,信令服务器维护一个被动连接的peer集合,这个集合中的peer是在这些peer在启动时通过register信令注册到信令服务器中的,每个peer有一个唯一的ID,我称这个集合为answer peer集合吧。主动连接方(这里称为offer peer)则通过ID去连接answer peer。一旦建立与某个peer的连接后,它们便可以通过建立的data channel全双工的实时通信了。下面是信令服务与offer peer和answer peer的信令交互图:

参照前面提到的WebRTC建连过程,你可以很容易的看懂这个协议设计。

这里我设计了一个Message抽象来表示信令服务可以收发的消息:

//webrtc-data-channel/signaling/proto/proto.go

type Message struct {
    Cmd     int    `json:"command"`
    Payload []byte `json:"payload"` // carry all kinds of request and response
}

其中的Cmd字段标识Message类型,可选值如下:

//webrtc-data-channel/signaling/proto/proto.go

const (
    // originated from answer peer
    CmdInit = iota + 1
    CmdAnswer

    // originated from answer peer
    CmdOffer

    // from both peer
    CmdCandidate
)

const (
    CmdInitResp = iota + 101 // CmdInit + 100
    CmdAnswerResp
    CmdOfferResp
    CmdCandidateResp
)

Message既可以承载Request,亦可以承载Response。Message的Payload字段中存放的是Request或Response序列化后的结果。Request和Response结构如下:

//webrtc-data-channel/signaling/proto/proto.go

// Request is one kind of payload for Message
type Request struct {
    SourceID string `json:"source"`
    TargetID string `json:"target"`
    Body     []byte `json:"body"` // carry register, offer, answer, candidate
}

// Request is another payload for Message
type Response struct {
    Code int    `json:"code"`
    Msg  string `json:"msg"`
}

Request类型的Body中存放的是WebRTC Offer/Answer的SDP以及ICE Candidate序列化后的结果。

此外,在这个示例中,我们使用WebSocket来作为信令协议的载体,便于信令服务器与offer peer/answer peer进行双向通信。

3.3 信令服务器的实现

按照上述设计,我们的信令服务器就是一个websocket的server:

//webrtc-data-channel/signaling/main.go

func main() {
    flag.Parse()
    log.SetFlags(log.Ldate | log.Ltime | log.Lmicroseconds)
    http.HandleFunc("/register", register) // for peerAnswer
    http.HandleFunc("/offer", offer)       // for peerOffer
    log.Fatal(http.ListenAndServe(*addr, nil))
}

在这个server中我们提供了两个endpoint,一个是/register,供answer peer建立连接使用;另外一个是/offer,供offer peer与信令服务器建连并通信的。

两个endpoint对应的Handler的处理模式也相对一致,都是进入一个event loop中。

//webrtc-data-channel/signaling/main.go

func register(w http.ResponseWriter, r *http.Request) {
    c, err := upgrader.Upgrade(w, r, nil) // *websocket.Conn
    if err != nil {
        log.Print("signaling: websocket upgrade error:", err)
        return
    }
    defer c.Close()

    err = answerPeerEventLoop(c, w)
    if err != nil {
        log.Println("signaling: answerPeerEventLoop error:", err)
        return
    }
    log.Println("signaling: answerPeerEventLoop exit")
}

func offer(w http.ResponseWriter, r *http.Request) {
    c, err := upgrader.Upgrade(w, r, nil) // *websocket.Conn
    if err != nil {
        log.Print("signaling: websocket upgrade error:", err)
        return
    }
    defer c.Close()

    err = offerPeerEventLoop(c, w)
    if err != nil {
        log.Println("signaling: offerPeerEventLoop error:", err)
        return
    }
    log.Println("signaling: offerPeerEventLoop exit")
}

注:offer和register这两个Handler都会在单独的goroutine中执行。

offerPeerEventLoop和answerPeerEventLoop的代码都比较长,这里就不贴出来了。这两个函数的代码也都比较模式化,基本处理流程就是读取一个Message,判断Message的Cmd类型,然后根据Cmd类型分别处理,处理的逻辑参见上面信令服务器的信令处理流程:基本上就是转发、转发、转发。

3.4. answer peer的实现

answer peer启动后会建立RTCPeerConnection类型实例,并设置RTCPeerConnection实例的事件处理函数:

  • OnICECandidate

本地收集到ICE候选者信息,处理动作是将这些ICE候选者信息通过信令服务转发到对端。

  • OnConnectionStateChange

当与对端的连接状态发生变化时触发,比如连接建立、连接断开时。处理动作仅为输出相应的日志。

  • OnDataChannel

当与对端的Data Channel创建成功时,处理逻辑是注册DataChannel.OnOpen和DataChannel.OnMessage两个事件处理函数。

完成这些后,answer peer会向上面设计的那样,与信令服务器建立连接,并发送请求到信令服务的/register端点,然后进入event loop。在event loop中负责处理信令服务器转发过来的Offer、Candidate等信息,以及各种信令服务器返回的Response。

当收到Offer时,answer peer会创建Answer并发给信令服务器;当收到Candidate时,会调用AddICECandidate将Candidate信息添加到peerConnection中,供后续配对使用。后续WebRTC连接自动建立后,便可以通过data channel收发数据了。

answer peer的代码较长,大家可以自行到https://github.com/bigwhite/experiments/tree/master/webrtc-data-channel/answer阅读。

注:answer peer的代码改编自pion/webrtc项目pion-to-pion/answer示例

3.5. offer peer的实现

offer peer的实现与answer相似。

offer peer启动后会建立RTCPeerConnection类型实例,并设置RTCPeerConnection实例的事件处理函数:

  • OnICECandidate
  • OnConnectionStateChange
  • DataChannel的OnOpen
  • DataChannel的OnMessage

offer peer会主动创建DataChannel,然后与信令服务器建立连接,并发送请求到信令服务的/offer端点并主动向信令服务器发送Offer,最后进入event loop。在event loop中负责处理信令服务器转发过来的Answer、Candidate等信息,以及各种信令服务器返回的Response。

当收到Answer时,offer peer会将Answer中携带的SDP传给SetRemoteDescription,同时调用SetLocalDescription开启ICE候选者的收集过程;当收到Candidate时,会调用AddICECandidate将Candidate信息添加到peerConnection中,供后续配对使用。后续WebRTC连接自动建立后,便可以通过data channel收发数据了。

offer peer的代码较长,大家可以自行到https://github.com/bigwhite/experiments/tree/master/webrtc-data-channel/offer阅读。

注:offer peer的代码改编自pion/webrtc项目pion-to-pion/offer示例

3.6 运行示例

下面我们来运行一下这个示例。

先来启动信令服务器:

$cd webrtc-data-channel/signaling
$go run main.go

启动answer peer:

$cd webrtc-data-channel/answer
$go run main.go
2023/09/23 21:24:45.201213 answer: NewPeerConnection ok
2023/09/23 21:24:45.201256 answer: connecting to ws://localhost:18080/register
2023/09/23 21:24:45.203993 answer: recv resp[101]: proto.Response{Code:0, Msg:"ok"}

这时我们会从信令服务器的输出日志中看到:

2023/09/23 21:24:45.203702 signaling: add answer peer: answer-peer-1

我们看到,answer peer成功注册到信令服务器中了,其ID为answer-peer-1。

下面我们来启动offer peer,其要连接的target为answer-peer-1:

$cd webrtc-data-channel/offer
$go run main.go -target answer-peer-1
2023/09/23 21:25:26.462845 offer: new peerConnection ok
2023/09/23 21:25:26.462880 offer: create new channel
2023/09/23 21:25:26.462890 offer: connecting to ws://localhost:18080/offer
2023/09/23 21:25:26.464863 offer: create offer
2023/09/23 21:25:26.465131 offer: recv resp[103]: proto.Response{Code:0, Msg:"ok"}
2023/09/23 21:25:26.465957 offer: recv answer(sdp) message from answer-peer-1
2023/09/23 21:25:26.466064 offer: set local desc
2023/09/23 21:25:26.466099 offer: set remote desc
2023/09/23 21:25:26.466201 offer: Peer Connection State has changed: connecting
2023/09/23 21:25:26.466297 offer: recv candidate message from answer-peer-1
2023/09/23 21:25:26.466344 offer: invoke peerConnection.OnICECandidate: webrtc.ICECandidate{statsID:"candidate:KsXlIk2JNeiDqK3l+znsoB3sDwuh1/2x", Foundation:"4104056053", Priority:0x7effffff, Address:"192.168.1.105", Protocol:1, Port:0xc2b1, Typ:1, Component:0x1, RelatedAddress:"", RelatedPort:0x0, TCPType:""}
2023/09/23 21:25:26.466506 offer: recv resp[104]: proto.Response{Code:0, Msg:"ok"}
2023/09/23 21:25:26.468342 offer: Peer Connection State has changed: connected
2023/09/23 21:25:26.469105 offer: Data channel 'data'-'824634439080' open. Random messages will now be sent to any connected DataChannels every 5 seconds
2023/09/23 21:25:26.859774 offer: recv candidate message from answer-peer-1
2023/09/23 21:25:31.469811 offer: Sending 'offer-1013426535'
2023/09/23 21:25:31.470846 offer: Message from DataChannel 'data': 'answer-695102175'
2023/09/23 21:25:36.469653 offer: Sending 'offer-2065047193'
2023/09/23 21:25:36.470495 offer: Message from DataChannel 'data': 'answer-750781464'
2023/09/23 21:25:41.469603 offer: Sending 'offer-153497802'
2023/09/23 21:25:41.469938 offer: Message from DataChannel 'data': 'answer-2102723687'
2023/09/23 21:25:46.469504 offer: Sending 'offer-1287609150'
2023/09/23 21:25:46.470097 offer: Message from DataChannel 'data': 'answer-645051512'
2023/09/23 21:25:51.470078 offer: Sending 'offer-1486812657'
2023/09/23 21:25:51.470572 offer: Message from DataChannel 'data': 'answer-1325372035'

offer peer的启动引发了“连锁反应”,在信令服务器的帮助下,offer peer与answer peer成功建立了连接,并在打开的Data Channel进行着“定时”的双工实时通信。

信令服务器的输出日志如下:

2023/09/23 21:25:26.465049 signaling: recv request[3] from offer peer
2023/09/23 21:25:26.465070 signaling: send offer resp ok
2023/09/23 21:25:26.465073 signaling: add offer peer:  offer-peer-1
2023/09/23 21:25:26.465085 signaling: forward request[3] to answer peer ok
2023/09/23 21:25:26.465247 signaling: recv offer response from answer peer
2023/09/23 21:25:26.465868 signaling: recv request[2] from answer peer
2023/09/23 21:25:26.465896 signaling: forward request[2] to offer peer[offer-peer-1] ok
2023/09/23 21:25:26.466003 signaling: recv answer response from offer peer
2023/09/23 21:25:26.466218 signaling: recv request[4] from answer peer
2023/09/23 21:25:26.466245 signaling: forward request[4] to offer peer[offer-peer-1] ok
2023/09/23 21:25:26.466363 signaling: recv candidate response from offer peer
2023/09/23 21:25:26.466415 signaling: recv request[4] from offer peer
2023/09/23 21:25:26.466429 signaling: send offer resp ok
2023/09/23 21:25:26.466435 signaling: add offer peer:  offer-peer-1
2023/09/23 21:25:26.466445 signaling: forward request[4] to answer peer ok
2023/09/23 21:25:26.466526 signaling: recv candidate response from answer peer
2023/09/23 21:25:26.859520 signaling: recv request[4] from answer peer
2023/09/23 21:25:26.859609 signaling: forward request[4] to offer peer[offer-peer-1] ok
2023/09/23 21:25:26.859951 signaling: recv candidate response from offer peer

answer peer的输出日志如下:

2023/09/23 21:25:26.465182 answer: recv offer message from offer-peer-1
2023/09/23 21:25:26.465823 answer: send sdp answer
2023/09/23 21:25:26.465834 answer: Peer Connection State has changed: connecting
2023/09/23 21:25:26.465925 answer: set local desc
2023/09/23 21:25:26.465928 answer: recv resp[102]: proto.Response{Code:0, Msg:"ok"}
2023/09/23 21:25:26.466108 answer: invoke peerConnection.OnICECandidate: 192.168.1.105
2023/09/23 21:25:26.466285 answer: recv resp[104]: proto.Response{Code:0, Msg:"ok"}
2023/09/23 21:25:26.466481 answer: recv candidate message from offer-peer-1
2023/09/23 21:25:26.468475 answer: Peer Connection State has changed: connected
2023/09/23 21:25:26.469002 answer: New DataChannel data 824634440046
2023/09/23 21:25:26.469049 answer: Data channel 'data'-'824634440046' open. Random messages will now be sent to any connected DataChannels every 5 seconds
2023/09/23 21:25:26.859199 answer: invoke peerConnection.OnICECandidate: 175.160.224.151
2023/09/23 21:25:26.859770 answer: recv resp[104]: proto.Response{Code:0, Msg:"ok"}
2023/09/23 21:25:31.470331 answer: Sending 'answer-695102175'
2023/09/23 21:25:31.470366 answer: message from DataChannel 'data': 'offer-1013426535'
2023/09/23 21:25:36.470028 answer: Sending 'answer-750781464'
2023/09/23 21:25:36.470123 answer: message from DataChannel 'data': 'offer-2065047193'
2023/09/23 21:25:41.469624 answer: Sending 'answer-2102723687'
2023/09/23 21:25:41.469978 answer: message from DataChannel 'data': 'offer-153497802'
2023/09/23 21:25:46.469606 answer: Sending 'answer-645051512'
2023/09/23 21:25:46.469883 answer: message from DataChannel 'data': 'offer-1287609150'
2023/09/23 21:25:51.470303 answer: Sending 'answer-1325372035'
2023/09/23 21:25:51.470421 answer: message from DataChannel 'data': 'offer-1486812657'

这次运行是在本地同一主机下运行的。你也可以将信令服务器搭建在公网主机上,然后将answer peer和offer peer分别放到不同的公有云虚机上,你看看是否依然可以连通!我在阿里云上的测试结果是ok的(信令服务器放在美国)。

注:示例中使用的stun server:74.125.137.127:19302实际上就是stun.l.google.com:19302。

4. 小结

通过本文的讲解和示例,我们看到:基于WebRTC数据通道可以实现低延迟的P2P实时通信。Go语言通过Pion等项目库提供了对开发WebRTC的支持。通过信令服务器协调Offer/Answer模型,可以建立起端到端的数据通道。未来WebRTC数据通道可用于更多像实时协同、远程控制等应用场景。

本文代码示例可在这里下载。

注:本文示例仅是用作展示如何使用Go进行WebRTC应用的开发,对异常处理等方面并未做太多考虑,不要将示例代码用作生产环境。另外gorilla的websocket.Conn并非始终是goroutine safe的,示例中代码对websocket.Conn的保护并不那么充分。

5. 参考资料


“Gopher部落”知识星球旨在打造一个精品Go学习和进阶社群!高品质首发Go技术文章,“三天”首发阅读权,每年两期Go语言发展现状分析,每天提前1小时阅读到新鲜的Gopher日报,网课、技术专栏、图书内容前瞻,六小时内必答保证等满足你关于Go语言生态的所有需求!2023年,Gopher部落将进一步聚焦于如何编写雅、地道、可读、可测试的Go代码,关注代码质量并深入理解Go核心技术,并继续加强与星友的互动。欢迎大家加入!

img{512x368}
img{512x368}

img{512x368}
img{512x368}

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻) – https://gopherdaily.tonybai.com

我的联系方式:

  • 微博(暂不可用):https://weibo.com/bigwhite20xx
  • 微博2:https://weibo.com/u/6484441286
  • 博客:tonybai.com
  • github: https://github.com/bigwhite
  • Gopher Daily归档 – https://github.com/bigwhite/gopherdaily

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

Gopher Daily改版了

本文永久链接 – https://tonybai.com/2023/08/06/gopherdaily-revamped

已经记不得GopherDaily是何时创建的了,翻了一下GopherDaily项目的commit history,才发现我的这个个人项目是2019年9月创建的,最初内容组织很粗糙,但我的编辑制作的热情很高,基本能坚持每日一发,甚至节假日也不停刊

该项目的初衷就是为广大Gopher带来新鲜度较高的Go语言技术资料。项目创建以来得到了很多Gopher的支持,甚至经常收到催刊邮件/私信以及主动report订阅列表问题的情况。

不过近一年多,订阅GopherDaily的Gopher可能会发现:GopherDaily已经做不到“Daily”了!究其原因还是个人精力有限,每刊编辑都要花费很多时间。但个人又不想暂停该项目,怎么办呢?近段时间我就在着手思考提升GopherDaily制作效率的问题

一个可行的方案就是“半自动化”!在这次从“纯人工”到“半自动化”的过程中,顺便对GopherDaily做了一次“改版”。

在这篇文章中,我就来说说结合大语言模型和Go技术栈实现GopherDaily制作的“半自动化”以及GopherDaily“改版”的历程。

1. “半自动化”的制作流程

当前的GopherDaily每刊的制作过程十分费时费力,下面是图示的制作过程:

这里面所有步骤都是人工处理,且收集资料、阅读摘要以及选优最为耗时。

那么这些环节中哪些可以自动化呢?收集、摘要、翻译、生成与发布都可以自动化,只有“选优”需要人工干预,下面是改进后的“半自动化”流程:

我们看到整个过程分为三个阶段:

  • 第一阶段(stage1):自动化的收集资料,并生成第二阶段的输入issue-20230805-stage1.json(以2023年8月5日为例)。
  • 第二阶段(stage2):对输入的issue-20230805-stage1.json中的资料进行选优,删掉不适合或质量不高的资料,当然也可以手工加入一些自动化收集阶段未找到的优秀资料;然后基于选优后的内容生成issue-20230805-stage2.json,作为第三阶段的输入。
  • 第三阶段(stage3):这一阶段也都是自动化的,程序基于第二阶段的输出issue-20230805-stage2.json中内容,逐条生成摘要,并将文章标题和摘要翻译为中文,最后生成两个文件:issue-20230805.html和issue-20230805.md,前者将被发布到邮件列表gopherdaily github page上,而后者则会被上传到传统的GopherDaily归档项目中。

我个人的目标是将改进后的整个“半自动化”过程缩短在半小时以内,从试运行效果来看,基本达成!

下面我就来简要聊聊各个自动化步骤是如何实现的。

2. Go技术资料自动收集

GopherDaily制作效率提升的一个大前提就是可以将最耗时的“资料收集”环节自动化了!而要做到这一点,下面两方面不可或缺:

  • 资料源集合
  • 针对资料源的最新文章的感知和拉取

2.1 资料源的来源

资料源从哪里来呢?答案是以往的GopherDaily issues中!四年来积累了数千篇文章的URL,从这些issue中提取URL并按URL中域名/域名+一级路径的出现次数做个排序,得到GopherDaily改版后的初始资料源集合。虽然这个方案并不完美,但至少可以满足改版后的初始需求,后续还可以对资料源做渐进的手工优化。

提取文本中URL的方法有很多种,常用的一种方法是使用正则表达式,下面是一个从markdown或txt文件中提取url并输出的例子:

// extract-url/main.go

package main

import (
    "bufio"
    "fmt"
    "os"
    "path/filepath"
    "regexp"
)

func main() {
    var allURLs []string

    err := filepath.Walk("/Users/tonybai/blog/gitee.com/gopherdaily", func(path string, info os.FileInfo, err error) error {
        if err != nil {
            return err
        }

        if info.IsDir() {
            return nil
        }

        if filepath.Ext(path) != ".txt" && filepath.Ext(path) != ".md" {
            return nil
        }

        file, err := os.Open(path)
        if err != nil {
            return err
        }
        defer file.Close()

        scanner := bufio.NewScanner(file)
        urlRegex := regexp.MustCompile(`https?://[^\s]+`)

        for scanner.Scan() {
            urls := urlRegex.FindAllString(scanner.Text(), -1)
            allURLs = append(allURLs, urls...)
        }

        return scanner.Err()
    })

    if err != nil {
        fmt.Println(err)
        return
    }

    for _, url := range allURLs {
        fmt.Printf("%s\n", url)
    }
    fmt.Println(len(allURLs))
}

我将提取并分析后得到的URL放入一个临时文件中,因为仅提取URL还不够,要做为资料源,我们需要的是对应站点的feed地址。那么如何提取出站点的feed地址呢?我们看下面这个例子:

// extract_rss/main.go

package main

import (
    "fmt"
    "io/ioutil"
    "net/http"
    "regexp"
)

var (
    rss  = regexp.MustCompile(`<link[^>]*type="application/rss\+xml"[^>]*href="([^"]+)"`)
    atom = regexp.MustCompile(`<link[^>]*type="application/atom\+xml"[^>]*href="([^"]+)"`)
)

func main() {
    var sites = []string{
        "http://research.swtch.com",
        "https://tonybai.com",
        "https://benhoyt.com/writings",
    }

    for _, url := range sites {
        resp, err := http.Get(url)
        if err != nil {
            fmt.Println("Error fetching URL:", err)
            continue
        }
        defer resp.Body.Close()

        body, err := ioutil.ReadAll(resp.Body)
        if err != nil {
            fmt.Println("Error reading response body:", err)
            continue
        }

        matches := rss.FindAllStringSubmatch(string(body), -1)
        if len(matches) == 0 {
            matches = atom.FindAllStringSubmatch(string(body), -1)
            if len(matches) == 0 {
                continue
            }
        }

        fmt.Printf("\"%s\" -> rss: \"%s\"\n", url, matches[0][1])
    }
}

执行上述程序,我们得到如下结果:

"http://research.swtch.com" -> rss: "http://research.swtch.com/feed.atom"
"https://tonybai.com" -> rss: "https://tonybai.com/feed/"
"https://benhoyt.com/writings" -> rss: "/writings/rss.xml"

我们看到不同站点的rss地址值着实不同,有些是完整的url地址,有些则是相对于主站点url的路径,这个还需要进一步判断与处理,但这里就不赘述了。

我们将提取和处理后的feed地址放入feeds.toml中作为资料源集合。每天开始制作Gopher Daily时,就从读取这个文件中的资料源开始。

2.2 感知和拉取资料源的更新

有了资料源集合后,我们接下来要做的就是定期感知和拉取资料源的最新更新(暂定24小时以内的),再说白点就是拉取资料源的feed数据,解析内容,得到资料源的最新文章信息。针对feed拉取与解析,Go社区有现成的工具,比如gofeed就是其中功能较为齐全且表现稳定的一个。

下面是使用Gofeed抓取feed地址并获取文章信息的例子:

// gofeed/main.go

package main

import (
    "fmt"

    "github.com/mmcdole/gofeed"
)

func main() {

    var feeds = []string{
        "https://research.swtch.com/feed.atom",
        "https://tonybai.com/feed/",
        "https://benhoyt.com/writings/rss.xml",
    }

    fp := gofeed.NewParser()
    for _, feed := range feeds {
        feedInfo, err := fp.ParseURL(feed)
        if err != nil {
            fmt.Printf("parse feed [%s] error: %s\n", feed, err.Error())
            continue
        }
        fmt.Printf("The info of feed url: %s\n", feed)
        for _, item := range feedInfo.Items {
            fmt.Printf("\t title: %s\n", item.Title)
            fmt.Printf("\t link: %s\n", item.Link)
            fmt.Printf("\t published: %s\n", item.Published)
        }
        fmt.Println("")
    }
}

该程序分别解析三个feed地址,并分别输出得到的文章信息,包括标题、url和发布时间。运行上述程序我们将得到如下结果:

$go run main.go
The info of feed url: https://research.swtch.com/feed.atom
     title: Coroutines for Go
     link: http://research.swtch.com/coro
     published: 2023-07-17T14:00:00-04:00
     title: Storing Data in Control Flow
     link: http://research.swtch.com/pcdata
     published: 2023-07-11T14:00:00-04:00
     title: Opting In to Transparent Telemetry
     link: http://research.swtch.com/telemetry-opt-in
     published: 2023-02-24T08:59:00-05:00
     title: Use Cases for Transparent Telemetry
     link: http://research.swtch.com/telemetry-uses
     published: 2023-02-08T08:00:03-05:00
     title: The Design of Transparent Telemetry
     link: http://research.swtch.com/telemetry-design
     published: 2023-02-08T08:00:02-05:00
     title: Transparent Telemetry for Open-Source Projects
     link: http://research.swtch.com/telemetry-intro
     published: 2023-02-08T08:00:01-05:00
     title: Transparent Telemetry
     link: http://research.swtch.com/telemetry
     published: 2023-02-08T08:00:00-05:00
     title: The Magic of Sampling, and its Limitations
     link: http://research.swtch.com/sample
     published: 2023-02-04T12:00:00-05:00
     title: Go’s Version Control History
     link: http://research.swtch.com/govcs
     published: 2022-02-14T10:00:00-05:00
     title: What NPM Should Do Today To Stop A New Colors Attack Tomorrow
     link: http://research.swtch.com/npm-colors
     published: 2022-01-10T11:45:00-05:00
     title: Our Software Dependency Problem
     link: http://research.swtch.com/deps
     published: 2019-01-23T11:00:00-05:00
     title: What is Software Engineering?
     link: http://research.swtch.com/vgo-eng
     published: 2018-05-30T10:00:00-04:00
     title: Go and Dogma
     link: http://research.swtch.com/dogma
     published: 2017-01-09T09:00:00-05:00
     title: A Tour of Acme
     link: http://research.swtch.com/acme
     published: 2012-09-17T11:00:00-04:00
     title: Minimal Boolean Formulas
     link: http://research.swtch.com/boolean
     published: 2011-05-18T00:00:00-04:00
     title: Zip Files All The Way Down
     link: http://research.swtch.com/zip
     published: 2010-03-18T00:00:00-04:00
     title: UTF-8: Bits, Bytes, and Benefits
     link: http://research.swtch.com/utf8
     published: 2010-03-05T00:00:00-05:00
     title: Computing History at Bell Labs
     link: http://research.swtch.com/bell-labs
     published: 2008-04-09T00:00:00-04:00
     title: Using Uninitialized Memory for Fun and Profit
     link: http://research.swtch.com/sparse
     published: 2008-03-14T00:00:00-04:00
     title: Play Tic-Tac-Toe with Knuth
     link: http://research.swtch.com/tictactoe
     published: 2008-01-25T00:00:00-05:00
     title: Crabs, the bitmap terror!
     link: http://research.swtch.com/crabs
     published: 2008-01-09T00:00:00-05:00

The info of feed url: https://tonybai.com/feed/
     title: Go语言开发者的Apache Arrow使用指南:读写Parquet文件
     link: https://tonybai.com/2023/07/31/a-guide-of-using-apache-arrow-for-gopher-part6/
     published: Mon, 31 Jul 2023 13:07:28 +0000
     title: Go语言开发者的Apache Arrow使用指南:扩展compute包
     link: https://tonybai.com/2023/07/22/a-guide-of-using-apache-arrow-for-gopher-part5/
     published: Sat, 22 Jul 2023 13:58:57 +0000
     title: 使用testify包辅助Go测试指南
     link: https://tonybai.com/2023/07/16/the-guide-of-go-testing-with-testify-package/
     published: Sun, 16 Jul 2023 07:09:56 +0000
     title: Go语言开发者的Apache Arrow使用指南:数据操作
     link: https://tonybai.com/2023/07/13/a-guide-of-using-apache-arrow-for-gopher-part4/
     published: Thu, 13 Jul 2023 14:41:25 +0000
     title: Go语言开发者的Apache Arrow使用指南:高级数据结构
     link: https://tonybai.com/2023/07/08/a-guide-of-using-apache-arrow-for-gopher-part3/
     published: Sat, 08 Jul 2023 15:27:54 +0000
     title: Apache Arrow:驱动列式分析性能和连接性的提升[译]
     link: https://tonybai.com/2023/07/01/arrow-columnar-analytics/
     published: Sat, 01 Jul 2023 14:42:29 +0000
     title: Go语言开发者的Apache Arrow使用指南:内存管理
     link: https://tonybai.com/2023/06/30/a-guide-of-using-apache-arrow-for-gopher-part2/
     published: Fri, 30 Jun 2023 14:00:59 +0000
     title: Go语言开发者的Apache Arrow使用指南:数据类型
     link: https://tonybai.com/2023/06/25/a-guide-of-using-apache-arrow-for-gopher-part1/
     published: Sat, 24 Jun 2023 20:43:38 +0000
     title: Go语言包设计指南
     link: https://tonybai.com/2023/06/18/go-package-design-guide/
     published: Sun, 18 Jun 2023 15:03:41 +0000
     title: Go GC:了解便利背后的开销
     link: https://tonybai.com/2023/06/13/understand-go-gc-overhead-behind-the-convenience/
     published: Tue, 13 Jun 2023 14:00:16 +0000

The info of feed url: https://benhoyt.com/writings/rss.xml
     title: The proposal to enhance Go's HTTP router
     link: https://benhoyt.com/writings/go-servemux-enhancements/
     published: Mon, 31 Jul 2023 08:00:00 +1200
     title: Scripting with Go: a 400-line Git client that can create a repo and push itself to GitHub
     link: https://benhoyt.com/writings/gogit/
     published: Sat, 29 Jul 2023 16:30:00 +1200
     title: Names should be as short as possible while still being clear
     link: https://benhoyt.com/writings/short-names/
     published: Mon, 03 Jul 2023 21:00:00 +1200
     title: Lookup Tables (Forth Dimensions XIX.3)
     link: https://benhoyt.com/writings/forth-lookup-tables/
     published: Sat, 01 Jul 2023 22:10:00 +1200
     title: For Python packages, file structure != API
     link: https://benhoyt.com/writings/python-api-file-structure/
     published: Fri, 30 Jun 2023 22:50:00 +1200
     title: Designing Pythonic library APIs
     link: https://benhoyt.com/writings/python-api-design/
     published: Sun, 18 Jun 2023 21:00:00 +1200
     title: From Go on EC2 to Fly.io: +fun, −$9/mo
     link: https://benhoyt.com/writings/flyio/
     published: Mon, 27 Feb 2023 10:00:00 +1300
     title: Code coverage for your AWK programs
     link: https://benhoyt.com/writings/goawk-coverage/
     published: Sat, 10 Dec 2022 13:41:00 +1300
     title: I/O is no longer the bottleneck
     link: https://benhoyt.com/writings/io-is-no-longer-the-bottleneck/
     published: Sat, 26 Nov 2022 22:20:00 +1300
     title: microPledge: our startup that (we wish) competed with Kickstarter
     link: https://benhoyt.com/writings/micropledge/
     published: Mon, 14 Nov 2022 20:00:00 +1200
     title: Rob Pike's simple C regex matcher in Go
     link: https://benhoyt.com/writings/rob-pike-regex/
     published: Fri, 12 Aug 2022 14:00:00 +1200
     title: Tools I use to build my website
     link: https://benhoyt.com/writings/tools-i-use-to-build-my-website/
     published: Tue, 02 Aug 2022 19:00:00 +1200
     title: Modernizing AWK, a 45-year old language, by adding CSV support
     link: https://benhoyt.com/writings/goawk-csv/
     published: Tue, 10 May 2022 09:30:00 +1200
     title: Prig: like AWK, but uses Go for "scripting"
     link: https://benhoyt.com/writings/prig/
     published: Sun, 27 Feb 2022 18:20:00 +0100
     title: Go performance from version 1.2 to 1.18
     link: https://benhoyt.com/writings/go-version-performance/
     published: Fri, 4 Feb 2022 09:30:00 +1300
     title: Optimizing GoAWK with a bytecode compiler and virtual machine
     link: https://benhoyt.com/writings/goawk-compiler-vm/
     published: Thu, 3 Feb 2022 22:25:00 +1300
     title: AWKGo, an AWK-to-Go compiler
     link: https://benhoyt.com/writings/awkgo/
     published: Mon, 22 Nov 2021 00:10:00 +1300
     title: Improving the code from the official Go RESTful API tutorial
     link: https://benhoyt.com/writings/web-service-stdlib/
     published: Wed, 17 Nov 2021 07:00:00 +1300
     title: Simple Lists: a tiny to-do list app written the old-school way (server-side Go, no JS)
     link: https://benhoyt.com/writings/simple-lists/
     published: Mon, 4 Oct 2021 07:30:00 +1300
     title: Structural pattern matching in Python 3.10
     link: https://benhoyt.com/writings/python-pattern-matching/
     published: Mon, 20 Sep 2021 19:30:00 +1200
     title: Mugo, a toy compiler for a subset of Go that can compile itself
     link: https://benhoyt.com/writings/mugo/
     published: Mon, 12 Apr 2021 20:30:00 +1300
     title: How to implement a hash table (in C)
     link: https://benhoyt.com/writings/hash-table-in-c/
     published: Fri, 26 Mar 2021 20:30:00 +1300
     title: Performance comparison: counting words in Python, Go, C++, C, AWK, Forth, and Rust
     link: https://benhoyt.com/writings/count-words/
     published: Mon, 15 Mar 2021 20:30:00 +1300
     title: The small web is beautiful
     link: https://benhoyt.com/writings/the-small-web-is-beautiful/
     published: Tue, 2 Mar 2021 06:50:00 +1300
     title: Coming in Go 1.16: ReadDir and DirEntry
     link: https://benhoyt.com/writings/go-readdir/
     published: Fri, 29 Jan 2021 10:00:00 +1300
     title: Fuzzing in Go
     link: https://lwn.net/Articles/829242/
     published: Tue, 25 Aug 2020 08:00:00 +1200
     title: Searching code with Sourcegraph
     link: https://lwn.net/Articles/828748/
     published: Mon, 17 Aug 2020 08:00:00 +1200
     title: Different approaches to HTTP routing in Go
     link: https://benhoyt.com/writings/go-routing/
     published: Fri, 31 Jul 2020 08:00:00 +1200
     title: Go filesystems and file embedding
     link: https://lwn.net/Articles/827215/
     published: Fri, 31 Jul 2020 00:00:00 +1200
     title: The sad, slow-motion death of Do Not Track
     link: https://lwn.net/Articles/826575/
     published: Wed, 22 Jul 2020 11:00:00 +1200
     title: What's new in Lua 5.4
     link: https://lwn.net/Articles/826134/
     published: Wed, 15 Jul 2020 11:00:00 +1200
     title: Hugo: a static-site generator
     link: https://lwn.net/Articles/825507/
     published: Wed, 8 Jul 2020 11:00:00 +1200
     title: Generics for Go
     link: https://lwn.net/Articles/824716/
     published: Wed, 1 Jul 2020 11:00:00 +1200
     title: More alternatives to Google Analytics
     link: https://lwn.net/Articles/824294/
     published: Wed, 24 Jun 2020 11:00:00 +1200
     title: Lightweight Google Analytics alternatives
     link: https://lwn.net/Articles/822568/
     published: Wed, 17 Jun 2020 11:00:00 +1200
     title: An intro to Go for non-Go developers
     link: https://benhoyt.com/writings/go-intro/
     published: Wed, 10 Jun 2020 23:38:00 +1200
     title: ZZT in Go (using a Pascal-to-Go converter)
     link: https://benhoyt.com/writings/zzt-in-go/
     published: Fri, 29 May 2020 17:25:00 +1200
     title: Testing in Go: philosophy and tools
     link: https://lwn.net/Articles/821358/
     published: Wed, 27 May 2020 12:00:00 +1200
     title: The state of the AWK
     link: https://lwn.net/Articles/820829/
     published: Wed, 20 May 2020 12:00:00 +1200
     title: What's coming in Go 1.15
     link: https://lwn.net/Articles/820217/
     published: Wed, 13 May 2020 12:00:00 +1200
     title: Don't try to sanitize input. Escape output.
     link: https://benhoyt.com/writings/dont-sanitize-do-escape/
     published: Thu, 27 Feb 2020 19:27:00 +1200
     title: SEO for Software Engineers
     link: https://benhoyt.com/writings/seo-for-software-engineers/
     published: Thu, 20 Feb 2020 12:00:00 +1200

注:gofeed抓取的item.Description是文章的摘要。但这个摘要不一定可以真实反映文章内容的概要,很多就是文章内容的前N个字而已。

Gopher Daily半自动化改造的另外一个技术课题是对拉取的文章做自动摘要与标题摘要的翻译,下面我们继续来看一下这个课题如何攻破。

注:目前微信公众号的优质文章尚未实现自动拉取,还需手工选优。

3. 自动摘要与翻译

对一段文本提取摘要和翻译均属于自然语言处理(NLP)范畴,说实话,Go在这个范畴中并不活跃,很难找到像样的开源算法实现或工具可直接使用。我的解决方案是借助云平台供应商的NLP API来做,这里我用的是微软Azure的相关API。

在使用现成的API之前,我们需要抓取特定url上的html页面并提取出要进行摘要的文本。

3.1 提取html中的原始文本

我们通过http.Get可以获取到一个文章URL上的html页面的所有内容,但如何提取出主要文本以供后续提取摘要使用呢?每个站点上的html内容都包含了很多额外内容,比如header、footer、分栏、边栏、导航栏等,这些内容对摘要的生成具有一定影响。我们最好能将这些额外内容剔除掉。但html的解析还是十分复杂的,我的解决方案是将html转换为markdown后再提交给摘要API。

html-to-markdown是一款不错的转换工具,它最吸引我的是可以删除原HTML中的一些tag,并自定义一些rule。下面的例子就是用html-to-markdown获取文章原始本文的例子:

// get-original-text/main.go

package main

import (
    "fmt"
    "io/ioutil"
    "net/http"

    md "github.com/JohannesKaufmann/html-to-markdown"
)

func main() {
    s, err := getOriginText("http://research.swtch.com/coro")
    if err != nil {
        panic(err)
    }
    fmt.Println(s)
}

func getOriginText(url string) (string, error) {
    resp, err := http.Get(url)
    if err != nil {
        return "", err
    }
    defer resp.Body.Close()

    body, _ := ioutil.ReadAll(resp.Body)

    converter := md.NewConverter("", true, nil).Remove("header",
        "footer", "aside", "table", "nav") //"table" is used to store code

    markdown, err := converter.ConvertString(string(body))
    if err != nil {
        return "", err
    }
    return markdown, nil
}

在这个例子中,我们删除了header、footer、边栏、导航栏等,尽可能的保留主要文本。针对这个例子我就不执行了,大家可以自行执行并查看执行结果。

3.2 提取摘要

我们通过微软Azure提供的摘要提取API进行摘要提取。微软Azure的这个API提供的免费额度,足够我这边制作Gopher Daily使用了。

注:要使用微软Azure提供的各类免费API,需要先注册Azure的账户。目前摘要提取API仅在North Europe, East US, UK South三个region提供,创建API服务时别选错Region了。我这里用的是East US。

注:Azure控制台较为难用,大家要有心理准备:)。

微软这个摘要API十分复杂,下面给出一个用curl调用API的示例。

摘要提取API的使用分为两步。第一步是请求对原始文本进行摘要处理,比如:

$curl -i -X POST https://gopherdaily-summarization.cognitiveservices.azure.com/language/analyze-text/jobs?api-version=2022-10-01-preview \
-H "Content-Type: application/json" \
-H "Ocp-Apim-Subscription-Key: your_api_key" \
-d \
'
{
  "displayName": "Document Abstractive Summarization Task Example",
  "analysisInput": {
    "documents": [
      {
        "id": "1",
        "language": "en",
        "text": "At Microsoft, we have been on a quest to advance AI beyond existing techniques, by taking a more holistic, human-centric approach to learning and understanding. As Chief Technology Officer of Azure AI services, I have been working with a team of amazing scientists and engineers to turn this quest into a reality. In my role, I enjoy a unique perspective in viewing the relationship among three attributes of human cognition: monolingual text (X), audio or visual sensory signals, (Y) and multilingual (Z). At the intersection of all three, there’s magic—what we call XYZ-code as illustrated in Figure 1—a joint representation to create more powerful AI that can speak, hear, see, and understand humans better. We believe XYZ-code will enable us to fulfill our long-term vision: cross-domain transfer learning, spanning modalities and languages. The goal is to have pre-trained models that can jointly learn representations to support a broad range of downstream AI tasks, much in the way humans do today. Over the past five years, we have achieved human performance on benchmarks in conversational speech recognition, machine translation, conversational question answering, machine reading comprehension, and image captioning. These five breakthroughs provided us with strong signals toward our more ambitious aspiration to produce a leap in AI capabilities, achieving multi-sensory and multilingual learning that is closer in line with how humans learn and understand. I believe the joint XYZ-code is a foundational component of this aspiration, if grounded with external knowledge sources in the downstream AI tasks."
      }
    ]
  },
  "tasks": [
    {
      "kind": "AbstractiveSummarization",
      "taskName": "Document Abstractive Summarization Task 1",
      "parameters": {
        "sentenceCount": 1
      }
    }
  ]
}
'

请求成功后,我们将得到一段应答,应答中包含类似operation-location的一段地址:

Operation-Location:[https://gopherdaily-summarization.cognitiveservices.azure.com/language/analyze-text/jobs/66e7e3a1-697c-4fad-864c-d84c647682b4?api-version=2022-10-01-preview]

这段地址就是第二步的请求地址,第二步是从这个地址获取摘要后的本文:

$curl -X GET https://gopherdaily-summarization.cognitiveservices.azure.com/language/analyze-text/jobs/66e7e3a1-697c-4fad-864c-d84c647682b4\?api-version\=2022-10-01-preview \
-H "Content-Type: application/json" \
-H "Ocp-Apim-Subscription-Key: your_api_key"
{"jobId":"66e7e3a1-697c-4fad-864c-d84c647682b4","lastUpdatedDateTime":"2023-07-27T11:09:45Z","createdDateTime":"2023-07-27T11:09:44Z","expirationDateTime":"2023-07-28T11:09:44Z","status":"succeeded","errors":[],"displayName":"Document Abstractive Summarization Task Example","tasks":{"completed":1,"failed":0,"inProgress":0,"total":1,"items":[{"kind":"AbstractiveSummarizationLROResults","taskName":"Document Abstractive Summarization Task 1","lastUpdateDateTime":"2023-07-27T11:09:45.8892126Z","status":"succeeded","results":{"documents":[{"summaries":[{"text":"Microsoft has been working to advance AI beyond existing techniques by taking a more holistic, human-centric approach to learning and understanding, and the Chief Technology Officer of Azure AI services, who enjoys a unique perspective in viewing the relationship among three attributes of human cognition: monolingual text, audio or visual sensory signals, and multilingual, has created XYZ-code, a joint representation to create more powerful AI that can speak, hear, see, and understand humans better.","contexts":[{"offset":0,"length":1619}]}],"id":"1","warnings":[]}],"errors":[],"modelVersion":"latest"}}]}}%

大家可以根据请求和应答的JSON结构,结合一些json-to-struct工具自行实现Azure摘要API的Go代码。

3.3 翻译

Azure的翻译API相对于摘要API要简单的多。

下面是使用curl演示翻译API的示例:

$curl -X POST "https://api.cognitive.microsofttranslator.com/translate?api-version=3.0&to=zh" \
     -H "Ocp-Apim-Subscription-Key:your_api_key" \
     -H "Ocp-Apim-Subscription-Region:westcentralus" \
     -H "Content-Type: application/json" \
     -d "[{'Text':'Hello, what is your name?'}]"

[{"detectedLanguage":{"language":"en","score":1.0},"translations":[{"text":"你好,你叫什么名字?","to":"zh-Hans"}]}]%

大家可以根据请求和应答的JSON结构,结合一些json-to-struct工具自行实现Azure翻译API的Go代码。

对于源文章是中文的,我们可以无需调用该API进行翻译,下面是一个判断字符串是否为中文的函数:

func isChinese(s string) bool {
    for _, r := range s {
        if unicode.Is(unicode.Scripts["Han"], r) {
            return true
        }
    }
    return false
}

4. 页面样式设计与html生成

这次Gopher Daily改版,我为Gopher Daily提供了Web版邮件列表版,但页面设计是我最不擅长的。好在,和四年前相比,IT技术又有了进一步的发展,以ChatGPT为代表的大语言模型如雨后春笋般层出不穷,我可以借助大模型的帮助来为我设计和实现一个简单的html页面了。下图就是这次改版后的第一版页面:

整个页面分为四大部分:Go、云原生(与Go关系紧密,程序员相关,架构相关的内容也放在这部分)、AI(当今流行)以及热门工具与项目(目前主要是github trending中每天Go项目的top列表中的内容)。

每一部分每个条目都包含文章标题、文章链接和文章的摘要,摘要的增加可以帮助大家更好的预览文章内容。

html和markdown的生成都是基于Go的template技术,template也是借助claude.ai设计与实现的,这里就不赘述了。

5. 服务器选型

以前的Gopher Daily仅是在github上的一个开源项目,大家通过watch来订阅。此外,Basten Gao维护着一个第三方的邮件列表,在此也对Basten Gao对Gopher Daily的长期支持表示感谢。

如今改版后,我原生提供了Gopher Daily的Web版,我需要为Gopher Daily选择服务器。

简单起见,我选用了github page来承载Gopher Daily的Web版。

至于邮件列表的订阅、取消订阅,我则是开发了一个小小的服务,跑在Digital Ocean的VPS上。

在选择反向代理web服务器时,我放弃了nginx,选择了同样Go技术栈实现的Caddy。Caddy最大好处就是易上手,且默认自动支持HTTPS,我无需自行用工具向免费证书机构(如 Let’s Encrypt或ZeroSSL)去申请和维护证书。

6 小结

这次改版后的Gopher Daily应得上那句话:“麻雀虽小,五脏俱全”:我为此开发了三个工具,一个服务。

当然Gopher Daily还在持续优化,后续也会根据Gopher们的反馈作适当调整。

摘要和翻译目前使用Azure API,后续可能会改造为使用类ChatGPT的API。

此外,知识星球Gopher部落的星友们依然拥有“先睹为快”的权益。

本文示例代码可以在这里下载。


“Gopher部落”知识星球旨在打造一个精品Go学习和进阶社群!高品质首发Go技术文章,“三天”首发阅读权,每年两期Go语言发展现状分析,每天提前1小时阅读到新鲜的Gopher日报,网课、技术专栏、图书内容前瞻,六小时内必答保证等满足你关于Go语言生态的所有需求!2023年,Gopher部落将进一步聚焦于如何编写雅、地道、可读、可测试的Go代码,关注代码质量并深入理解Go核心技术,并继续加强与星友的互动。欢迎大家加入!

img{512x368}
img{512x368}

img{512x368}
img{512x368}

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻) – https://gopherdaily.tonybai.com

我的联系方式:

  • 微博(暂不可用):https://weibo.com/bigwhite20xx
  • 微博2:https://weibo.com/u/6484441286
  • 博客:tonybai.com
  • github: https://github.com/bigwhite
  • Gopher Daily归档 – https://github.com/bigwhite/gopherdaily

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 Go语言编程指南
商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats