With Open Source: To Build More Than a Robot

With the help of your powerful HomeLab and the entire powerful opensource community, we can build more than we thought.

Your Homelab

特别声明

本文提及的音乐API,仅限交流学习,不得用于任何侵犯版权/违反法律的用途

相关新闻

Part 0. Structure

Structure Graph

Part 1. Lark应用开启机器人能力

Part 1.1 开启机器人能力&事件订阅

Lark Interaction

Lark机器人需要与开放平台进行通信,并接收开放平台推送的事件进行处理;飞书提供了两种通信方式:

  1. 长连接(websocket):对网络要求低,只要能够保持正常的心跳即可
  2. 无连接回调(webhook):对网络要求高,需要有可固定访问的回调地址(通常意味着需要合法的公网ip和域名)

除了上述的主要特征之外,两种连接方式还有一点不同:飞书开放平台不支持通过长连接推送按钮点击事件,所以如果机器人有按钮交互的需求,必须通过webhook实现。https://open.feishu.cn/document/faq/trouble-shooting/how-to-enable-bot-ability与开放平台的wss/webhook交互协议稍显复杂,我们可以通过飞书提供的SDK来简化这一部分的工作。出于笔者习惯,这里使用Go的SDK来构建机器人:https://github.com/larksuite/oapi-sdk-go

// example by lark openplatform
package main
import (
   "context"
   "fmt"
   larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
   larkevent "github.com/larksuite/oapi-sdk-go/v3/event"
   "github.com/larksuite/oapi-sdk-go/v3/event/dispatcher"
   larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
   larkws "github.com/larksuite/oapi-sdk-go/v3/ws"
)
func main() {
   // 注册事件回调
   eventHandler := dispatcher.NewEventDispatcher("", "").
      OnP2MessageReceiveV1(func(ctx context.Context, event *larkim.P2MessageReceiveV1) error {
         fmt.Printf("[ OnP2MessageReceiveV1 access ], data: %s\n", larkcore.Prettify(event))
         return nil
      }).
      OnCustomizedEvent("message", func(ctx context.Context, event *larkevent.EventReq) error {
         fmt.Printf("[ OnCustomizedEvent access ], type: message, data: %s\n", string(event.Body))
         return nil
      })
   // 创建Client
   cli := larkws.NewClient("YOUR_APP_ID", "YOUR_APP_SECRET",
      larkws.WithEventHandler(eventHandler),
      larkws.WithLogLevel(larkcore.LogLevelDebug),
   )
   // 启动客户端
   err := cli.Start(context.Background())
   if err != nil {
      panic(err)
   }
}

SDK将会监听机器人可获取的所有事件,并在运行时判断事件类型调用注册的handler,借此即可对长连接发送的事件进行ACK,并在后台处理后续逻辑。

Lark机器人需要与开放平台进行通信,并接收开放平台推送的事件进行处理;飞书提供了两种通信方式:

  1. 长连接(websocket):对网络要求低,只要能够保持正常的心跳即可
  2. 无连接回调(webhook):对网络要求高,需要有可固定访问的回调地址(通常意味着需要合法的公网ip和域名)

除了上述的主要特征之外,两种连接方式还有一点不同:飞书开放平台不支持通过长连接推送按钮点击事件,所以如果机器人有按钮交互的需求,必须通过webhook实现。

与开放平台的wss/webhook交互协议稍显复杂,我们可以通过飞书提供的SDK来简化这一部分的工作。

出于笔者习惯,这里使用Go的SDK来构建机器人:

GitHub - larksuite/oapi-sdk-go: larksuite oapi sdk by golang
larksuite oapi sdk by golang. Contribute to larksuite/oapi-sdk-go development by creating an account on GitHub.
// example by lark openplatform
package main

import (
   "context"
   "fmt"
   larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
   larkevent "github.com/larksuite/oapi-sdk-go/v3/event"
   "github.com/larksuite/oapi-sdk-go/v3/event/dispatcher"
   larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
   larkws "github.com/larksuite/oapi-sdk-go/v3/ws"
)

func main() {
   // 注册事件回调
   eventHandler := dispatcher.NewEventDispatcher("", "").
      OnP2MessageReceiveV1(func(ctx context.Context, event *larkim.P2MessageReceiveV1) error {
         fmt.Printf("[ OnP2MessageReceiveV1 access ], data: %s\n", larkcore.Prettify(event))
         return nil
      }).
      OnCustomizedEvent("message", func(ctx context.Context, event *larkevent.EventReq) error {
         fmt.Printf("[ OnCustomizedEvent access ], type: message, data: %s\n", string(event.Body))
         return nil
      })
   // 创建Client
   cli := larkws.NewClient("YOUR_APP_ID", "YOUR_APP_SECRET",
      larkws.WithEventHandler(eventHandler),
      larkws.WithLogLevel(larkcore.LogLevelDebug),
   )
   // 启动客户端
   err := cli.Start(context.Background())
   if err != nil {
      panic(err)
   }
}

SDK将会监听机器人可获取的所有事件,并在运行时判断事件类型调用注册的handler,借此即可对长连接发送的事件进行ACK,并在后台处理后续逻辑。

Part 1.2 构建卡片消息

Lark的文本信息很难囊括一首歌的完整信息,通常只能用来展示歌曲的元信息和歌词,也并不美观。同时也无法满足按钮等交互逻辑的需要。

然而,Lark的卡片消息的构建堪称灾难:

对于下面这样一张卡片:

对应着这样一个JSON:

{
    "config": {},
    "i18n_elements": {
        "zh_cn": [
            {
                "tag": "column_set",
                "flex_mode": "none",
                "background_style": "default",
                "horizontal_spacing": "8px",
                "horizontal_align": "left",
                "columns": [
                    {
                        "tag": "column",
                        "width": "weighted",
                        "vertical_align": "top",
                        "vertical_spacing": "8px",
                        "background_style": "default",
                        "elements": [
                            {
                                "tag": "button",
                                "text": {
                                    "tag": "plain_text",
                                    "content": "Play"
                                },
                                "type": "primary_filled",
                                "complex_interaction": true,
                                "width": "default",
                                "size": "small",
                                "value": {
                                    "test": "test"
                                }
                            },
                            {
                                "tag": "markdown",
                                "content": "Lyrics",
                                "text_align": "center",
                                "text_size": "notation"
                            },
                            {
                                "tag": "markdown",
                                "content": "",
                                "text_align": "left",
                                "text_size": "normal"
                            }
                        ],
                        "weight": 1
                    },
                    {
                        "tag": "column",
                        "width": "weighted",
                        "vertical_align": "top",
                        "vertical_spacing": "8px",
                        "background_style": "default",
                        "elements": [
                            {
                                "tag": "img",
                                "img_key": "img_v3_02ae_dd4a1dac-f6fd-4c4d-a701-0b87d8eb36ag",
                                "preview": false,
                                "transparent": false,
                                "scale_type": "fit_horizontal",
                                "alt": {
                                    "tag": "plain_text",
                                    "content": ""
                                },
                                "corner_radius": "70%"
                            }
                        ],
                        "weight": 1
                    }
                ],
                "margin": "16px 0px 0px 0px"
            },
            {
                "tag": "action",
                "layout": "default",
                "actions": [
                    {
                        "tag": "button",
                        "text": {
                            "tag": "plain_text",
                            "content": "按钮 1"
                        },
                        "type": "default",
                        "complex_interaction": true,
                        "width": "default",
                        "size": "medium"
                    },
                    {
                        "tag": "button",
                        "text": {
                            "tag": "plain_text",
                            "content": "按钮 2"
                        },
                        "type": "default",
                        "complex_interaction": true,
                        "width": "default",
                        "size": "medium"
                    }
                ]
            },
            {
                "tag": "column_set",
                "flex_mode": "stretch",
                "background_style": "default",
                "horizontal_spacing": "8px",
                "horizontal_align": "left",
                "columns": [
                    {
                        "tag": "column",
                        "width": "weighted",
                        "vertical_align": "top",
                        "vertical_spacing": "8px",
                        "background_style": "default",
                        "elements": [],
                        "weight": 1
                    }
                ],
                "margin": "16px 0px 0px 0px"
            }
        ]
    },
    "i18n_header": {
        "zh_cn": {
            "title": {
                "tag": "plain_text",
                "content": "示例标题"
            },
            "subtitle": {
                "tag": "plain_text",
                "content": "示例文本"
            },
            "template": "blue"
        }
    }
}
很遗憾的是,Lark SDK(至少在Golang的版本中)并没有对CardMessage明确类型约束和构建方法,合法性验证完全在开放平台服务端进行;在不熟知卡片消息语法的情况下,几乎只能依靠尝试的手段来验证自己的卡片消息是否合法,且并不能很容易的发现问题。相较之下,discord的多个社区SDK均实现了复杂卡片消息的构建和类型约束,或许值得作为参考:https://github.com/Goscord/goscord/blob/main/goscord/discord/builder/message.go

自行实现一套CardBuilder显然是一个短时间内不现实的策略,在避免基于上面这样的JSON生成大量Struct的前提下,最终将所需的几个卡片模板作为硬编码的JSON保存在了代码中,并通过JSONPath来修改、更新需要的值,作为一个「很不优雅」的替代方案:

func GenFullLyricsCard(ctx context.Context, title, artist, leftLyrics, rightLyrics string) string {
        ctx, span := otel.LarkRobotOtelTracer.Start(ctx, utility.GetCurrentFunc())
        defer span.End()
        var jsonData interface{}
        err := sonic.UnmarshalString(FullLyricsCardPattern, &jsonData)
        if err != nil {
                log.Println(err.Error())
        }
        jsonpath.Set(jsonData, "i18n_elements.zh_cn[0].columns[0].elements[0].columns[0].elements[0].content", leftLyrics)
        jsonpath.Set(jsonData, "i18n_elements.zh_cn[0].columns[0].elements[0].columns[1].elements[0].content", rightLyrics)
        jsonpath.Set(jsonData, "i18n_header.zh_cn.title.content", title)
        jsonpath.Set(jsonData, "i18n_header.zh_cn.subtitle.content", artist)
        jsonpath.Set(jsonData, "i18n_elements.zh_cn[1].actions[0].text.content", "Jaeger Tracer - "+span.SpanContext().TraceID().String())
        jsonpath.Set(jsonData, "i18n_elements.zh_cn[1].actions[0].multi_url.url", "https://jaeger.kmhomelab.cn/trace/"+span.SpanContext().TraceID().String())
        var s string
        s, err = sonic.MarshalString(jsonData)
        if err != nil {
                log.Println(err)
        }
        return s
}

Part 1.3 卡片设计

音乐列表

  • 「选择歌曲」的按钮存入value:songID,点击后触发回调调用Webhook,进而通过songID生成歌曲详情的卡片。

音乐详情

  • 检索到的歌词计算大致宽度和高度,限制在与专辑封面接近的高度展示,并提供点击触发完整歌词的选项。
  • 单击Play按钮触发链接跳转,跳转到WebPlayer进行播放

完整歌词

  • 仅在需要的时候触发完整歌词展示

Part 2. 获取音频&封面&歌词...

注:本文中涉及的音乐资源相关API,仅限学习与交流使用,由于版权等法律原因,原作者已删库
GitHub - Binaryify/NeteaseCloudMusicApi: 网易云音乐 Node.js API service
网易云音乐 Node.js API service. Contribute to Binaryify/NeteaseCloudMusicApi development by creating an account on GitHub.

得益于开源工作者的努力,我们可以使用Netease未被公开的部分Api,让这些封存在客户端里的音乐可以为我们所用。

通过简单的镜像部署,我们可以很容易的在支持Docker的环境下启动一个ApiServer的实例,为了便于后续的服务间通信和统一管理,我们使用docker-compose来编写yaml进行部署:

services:
    netease_cloud_music_api:
        image: binaryify/netease_cloud_music_api
        ports:
            - '3000:3000'

通过运行

$ docker compose up -d

服务将会在容器中监听3000端口,并通过docker daemon建立一个到宿主机3000端口的转发;最终Api服务在宿主机的3000端口可用。

Part 2.1 如何保持登录

即使部署了本地的音乐Api,我们仍然需要会员的登录身份来获取一些高音质/版权的歌曲:

Api提供了三种途径来登录账号:

  1. 简单账密登录:极易风控,可以刷新Cookie
  2. 手机验证码登录:风控风险低,可以刷新Cookie
  3. 二维码扫码登录:风控风险低,不能刷新Cookie

简单账密登录非常容易实现,但同时也是最容易触发风控的,通常不推荐使用这个接口;手机验证码登录非常稳定,但想要在运行过程中将验证码提交给程序,需要提供一个接口和一些同步机制来实现;二维码扫码登录可以不需要「写入」这个机制,只需要将二维码信息推送出来,就可以完成登录操作。

所以,最终采用了「二维码推送登录信息」+「简单账密登录兜底」的策略;考虑到方便扫描的实时性,部署了开源的通知服务Gotify(因为它贴心的提供了移动端的App),通过webhook的方式来实现消息推送:

Part 2.2 Gotify架构

Gotify 架构图
Gotify · a simple server for sending and receiving messages
a simple server for sending and receiving messages

于是我们可以在compose文件中再加上一个服务:

services:
    netease_cloud_music_api:
      image: binaryify/netease_cloud_music_api
      container_name: neteaseapi
      ports:
        - '3000:3000'
+    gotify:
+      image: gotify/server
+      container_name: gotify
+      ports:
+        - 8080:80
+      environment:
+        - GOTIFY_DEFAULTUSER_PASS=custom
+      volumes:
+        - "./gotify_data:/app/data"
在8080端口即可访问到Gotify服务,如何使用Gotify的Api来创建通知消息这里不再展开。

但即使我们取得了会员账号,登录态终究不是永久的,我们需要利用一些机制来保持它:

  1. 朴素的想法:登录态丢失后,重新触发登录
  2. 自动化的方式:利用已有的登录态,刷新Cookie
WebUI
Android App
经过观察,二维码登录的机制虽然无法刷新Cookie来达到持续登录的能力,但其Cookie的过期时间非常长;自动化的方式需要使用账密或者验证码登录,容易触发风控、操作复杂,这里遗留下了这一问题。使用了登录态丢失后,重新触发二维码登录的机制。

Part 3. S3 存储

成功取到音乐的相关信息和数据之后,我们还面临着下面几个问题:

  1. 重复的频繁请求可能会被限制
  2. 音频、歌词、图片的信息存储在临时的OSS链接中,会出现过期
  3. 请求的速度受限于网络

为了避免上述的几个问题,我们需要持久化这部分数据。对于常规的小文本信息,我们可以选择存储到传统关系表或者与NoSQL组合起来;但对于音频、图片这类体积偏大的二进制、要求一定的流式传输能力的文件来说,对象存储(OSS)显然更合适。

云服务商提供的对象存储通常按调用次数、流量大小计费,其优势是可以利用云服务商的大带宽、多机房以及CDN加速来满足多地域的高速文件访问;但对应着这些优势,也不可避免的导致产生额外的成本。

于是我们可以继续向开源致敬,自行托管一个S3对象存储的服务Minio(相比直接存储到文件系统中,这明显更易于管理、更稳定):

GitHub - minio/minio: The Object Store for AI Data Infrastructure
The Object Store for AI Data Infrastructure. Contribute to minio/minio development by creating an account on GitHub.

简单起见,我们仍然使用compose来添加这一服务:

services:
  netease_cloud_music_api:
    image: binaryify/netease_cloud_music_api
    container_name: neteaseapi
    ports:
      - "3000:3000"
  gotify:
    image: gotify/server
    container_name: gotify
    ports:
      - 8080:80
    environment:
      - GOTIFY_DEFAULTUSER_PASS=custom
    volumes:
      - "./gotify_data:/app/data"
+  minio:
+    command: 'server /data --console-address ":9001"'
+    image: minio/minio
+    restart: always
+    environment:
+      - MINIO_ROOT_PASSWORD=some_password_here
+      - MINIO_ROOT_USER=some_user
+    volumes:
+      - "./minio/data:/data"
+    container_name: minio
+    ports:
+      - 29001:9001
+      - 29000:9000
+    expose:
+      - 9001
+      - 9000

minio将在29000端口上托管api服务,29001端口上托管WebUI服务:

minio dashboard

minio有一个遵循S3协议的Go SDK,可以方便的支持多种文件类型的上传,并允许io.ReadCloser来实现流式的上传而不必在内存或本地文件中转。

S3协议:
什么是 Amazon S3? - Amazon Simple Storage Service
在云中存储数据,了解 Amazon S3 Web 服务的核心概念存储桶和对象。

在请求过程中,按照约定将静态的资源上传到本地的对象存储中,由于仅需本地环回通信,此时的延迟将远低于重新下载资源,并可以充当前置的缓存。

Part 4. 短链服务

由于部署的minio实例允许从公网访问,在网络安全日益严峻的当下,为了避免被有心之人利用狂刷流量,将存储桶设置为私有,仅允许通过预签名的、带过期时间的URL访问。这也导致单个对象的请求URL变得非常长,同时包含大量的需转义字符。这不仅对于URL拼接时有414的风险,同时还可能带来潜在的兼容问题。

短链服务的实现并不困难,但要通过反向代理管理好url的重写和转发仍然稍显麻烦,所以我再次引入了一个轻量化的开源短链服务lynx:

GitHub - Lynx-Shortener/Lynx: A fullstack application using the MEVN stack to shorten your URLs.
A fullstack application using the MEVN stack to shorten your URLs. - Lynx-Shortener/Lynx

同样,我们继续将其添加到compose中:

services:
  netease_cloud_music_api:
    image: binaryify/netease_cloud_music_api
    container_name: neteaseapi
    ports:
      - "3000:3000"
  gotify:
    image: gotify/server
    container_name: gotify
    ports:
      - 8080:80
    environment:
      - GOTIFY_DEFAULTUSER_PASS=custom
    volumes:
      - "./gotify_data:/app/data"
  minio:
    command: 'server /data --console-address ":9001"'
    image: minio/minio
    restart: always
    environment:
      - MINIO_ROOT_PASSWORD=i_am_a_password
      - MINIO_ROOT_USER=i_am_a_user
      - MINIO_API_CORS_ALLOW_ORIGIN=*
      - MINIO_PROMETHEUS_AUTH_TYPE=public
      - MINIO_PROMETHEUS_URL="http://prom:9090"
      - MINIO_PROMETHEUS_JOB_ID=minio-job
      - MINIO_LOG_LEVEL=debug
    volumes:
      - "/mnt/StorageMirror/Storage/minio/data:/data"
    container_name: minio
    ports:
      - 29001:9001
      - 29000:9000
    expose:
      - 9001
      - 9000
+  db:
+    image: mongo
+    restart: always
+    environment:
+      - MONGO_INITDB_ROOT_USERNAME=
+      - MONGO_INITDB_ROOT_PASSWORD=
+    volumes:
+      - ./db:/data/db
+  lynx:
+    image: jackbailey/lynx:1
+    restart: always
+    ports:
+      - 3000:3000
+    depends_on:
+      - db
+    volumes:
+      - ./backups:/app/backups
+    environment:
+      - NODE_ENV=production
+      - DB_USER=
+      - DB_PASSWORD=
+      - JWT_KEY=
+      - URL_LENGTH=8
+      - URL_SET=standard
+      - URL_ONLY_UNIQUE=false
+      - HOME_REDIRECT=/dash/overview
+      - FORCE_FRONTEND_REDIRECT=false
+      - ENABLE_REGISTRATION=false # First registration will always be allowed
+      - DOMAIN=http://example.com
+      - DEMO=false
+      - USE_HTTPS=true
+      - CORS=*
+      - BACKUP=true
+      - BACKUP_SCHEDULE=* * * * * # Use crontab.guru to create
+      - BACKUP_COUNT=5 # A count of 1 will make a backup file called backup.json
+      - URL_REGEX=https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*) # Don't use quotation marks

+      ## DO NOT CHANGE THESE:
+      - DB_HOST=db
+      - DB_PORT=27017

简单的编写一个随机字符串生成函数,调用其Api即可创建临时的短链服务。

Part 5. Docker Network DNS

在添加lynx进入compose文件之后,我们已经可以发现一些明显的恼人问题:

  1. 不同服务之间已经开始出现默认端口的重叠,我们需要手动修改端口映射来避免宿主机端口冲突
  2. 某些服务仅仅希望被内部访问,绑定端口到宿主机在防火墙放通的情况下,服务将暴露到公网
  3. 服务之间的访问需要依赖NodeIP+端口的方式来建立,ip+端口不能直接代表任何服务的信息,非常难以辨识和管理

为了解决上面三个问题,我们可以利用Docker network的DNS机制来实现一个类似「服务发现」的能力:

Networking overview
Learn how networking works from the container’s point of view
Docker Daemon会为同一个Docker Network下的容器创建一个内部的DNS,通过守护程序的容器DNS查询会将service_namecontainer_name解析为Docker Network中为容器分配的IP.

Part 5.1 CNM(Container Network Model)

Container Network Model

所以,只要确保compose中部署的服务处在同一个network下,就可以不必bind任何端口到宿主机,直接通过container_name:port的方式被网络内的容器访问到。在这一点特性上,Docker和K8s的服务发现机制是以相同的方式工作的。

在docker compose中,我们可以直接指定要使用的network:

services:
  netease_cloud_music_api:
    image: binaryify/netease_cloud_music_api
    container_name: neteaseapi
    ports:
      - "3000:3000"
  gotify:
    image: gotify/server
    container_name: gotify
    ports:
      - 8080:80
    environment:
      - GOTIFY_DEFAULTUSER_PASS=custom
    volumes:
      - "./gotify_data:/app/data"
  minio:
    command: 'server /data --console-address ":9001"'
    image: minio/minio
    restart: always
    environment:
      - MINIO_ROOT_PASSWORD=Heyuheng1.22.3
      - MINIO_ROOT_USER=kevinmatt
      - MINIO_API_CORS_ALLOW_ORIGIN=*
      - MINIO_PROMETHEUS_AUTH_TYPE=public
      - MINIO_PROMETHEUS_URL="http://prom:9090"
      - MINIO_PROMETHEUS_JOB_ID=minio-job
      - MINIO_LOG_LEVEL=debug
    volumes:
      - "/mnt/StorageMirror/Storage/minio/data:/data"
    container_name: minio
    ports:
      - 29001:9001
      - 29000:9000
    expose:
      - 9001
      - 9000
  db:
    image: mongo
    restart: always
    environment:
      - MONGO_INITDB_ROOT_USERNAME=
      - MONGO_INITDB_ROOT_PASSWORD=
    volumes:
      - ./db:/data/db

  lynx:
    image: jackbailey/lynx:1
    restart: always
    ports:
      - 3000:3000
    depends_on:
      - db
    volumes:
      - ./backups:/app/backups
    environment:
      - NODE_ENV=production
      - DB_USER=
      - DB_PASSWORD=
      - JWT_KEY=
      - URL_LENGTH=8
      - URL_SET=standard
      - URL_ONLY_UNIQUE=false
      - HOME_REDIRECT=/dash/overview
      - FORCE_FRONTEND_REDIRECT=false
      - ENABLE_REGISTRATION=false # First registration will always be allowed
      - DOMAIN=http://example.com
      - DEMO=false
      - USE_HTTPS=true
      - CORS=*
      - BACKUP=true
      - BACKUP_SCHEDULE=* * * * * # Use crontab.guru to create
      - BACKUP_COUNT=5 # A count of 1 will make a backup file called backup.json
      - URL_REGEX=https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*) # Don't use quotation marks

      ## DO NOT CHANGE THESE:
      - DB_HOST=db
      - DB_PORT=27017
+networks:
+  default:
+    name: "homelab"

在启动compose时,守护进程将会自动创建一个新的name为homelab的network,在当前的Context中未明确指定networks的service都会使用这一个network,从而允许在同一网桥下的服务发现。这样一来,我们只需要标明每个服务的expose来帮助检查端口号,而不需要实际bind宿主机端口;利用容器名的访问也会更明确的指示实际请求的服务。

Part 6. Web Player

由于Lark并不支持在卡片中嵌入音频元素或播放器,播放的功能只能通过按钮交互跳转来提供;为了尽可能让这个跳转的页面简单,希望实现一个静态的页面,接收query params作为入参,来呈现出音乐的相关信息、滚动歌词和播放的基本能力

由于笔者对前端知识的极度匮乏,为了减少时间的开销,选择了魔改开源页面,经过一番搜寻,最终选择了受害者YesPlaymusic

GitHub - qier222/YesPlayMusic: 高颜值的第三方网易云播放器,支持 Windows / macOS / Linux
高颜值的第三方网易云播放器,支持 Windows / macOS / Linux :electron: - GitHub - qier222/YesPlayMusic: 高颜值的第三方网易云播放器,支持 Windows / macOS / Linux

这是一个基于Vue开发的前端页面,实际上已经基于前文提到的NeteaseCloudmusicApi模拟了音乐客户端所需要的所有必要功能,并拥有良好的用户界面;其播放器/歌词页面元素由大量Vue框架特性生成,作者也没有使用原生的播放器来实现播放,给魔改带来了不小的阻碍。

经过一系列的魔改,最终实现了一个静态的播放器页面;歌曲元信息、歌词、音频、图片完全由query参数指定的url或文本确定:

https://player.kmhomelab.cn/?album=Lose%20Control&artists=%5B%7B%22name%22%3A%22Teddy%20Swims%22%7D%5D&duration=210688&lyrics=https%3A%2F%2Faka.kmhomelab.cn%2F658c6adc&music=https%3A%2F%2Faka.kmhomelab.cn%2F2e09f53e&picture=https%3A%2F%2Faka.kmhomelab.cn%2Ff07f386d&title=Lose%20Control

这样就可以通过按钮的点击事件打开链接,直接跳转到播放器页面。

Part 7. 自动化DevOps

在编写代码的过程中,很难避免的需要不断的修改、更新代码,并构建出镜像来进行部署;公司内有成熟的自动化流水线平台可以解决这个问题;而在公司之外,我们还可以使用Github Action充当CI:
一个构建流水线的示例

通过Action Secrets和Repo Environment,我们可以将一些敏感的信息隐藏起来,允许我们在构建过程中执行一些安全的操作,yaml的编写非常简单,基本只需要使用编写好相应的shell脚本即可:

name: LarkRobot

on:
  push:
    branches: [master]
    paths-ignore:
      - "neteaseapi/netease-api-service/*"
      - "qqmusicapi/qqmusic-api-service/*"
      - "README.md"
  workflow_dispatch:
    inputs:
      name:
        description: "触发用途"
        required: true
        default: "测试"

env:
  IMAGE_NAME: kevinmatt/larkbot
  ROBOT_NAME: LarkRobot
  NETEASE_PHONE: "${{ secrets.NETEASE_PHONE }}"
  NETEASE_PASSWORD: "${{ secrets.NETEASE_PASSWORD }}"
  TENCENT_HEADER: ccr.ccs.tencentyun.com
  REGISTRY: ghcr.io
jobs:
  Build_Push:
    runs-on: ubuntu-latest
    steps:
      - name: Log into registry
        uses: nick-fields/retry@v2
        with:
          timeout_minutes: 10
          max_attempts: 3
          retry_wait_seconds: 15
          command: | 
            echo "${{ secrets.DOCKER_ACCESS_TOKEN }}" | docker login ccr.ccs.tencentyun.com -u 100016072032 --password-stdin
            echo "${{ secrets.DOCKER_ACCESS_TOKEN }}" | docker login -u kevinmatt --password-stdin
      - name: Log into registry ${{ env.REGISTRY }}
        if: github.event_name != 'pull_request'
        uses: docker/login-action@28218f9b04b4f3f62068d7b6ce6ca5b26e35336c
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - name: Checkout
        uses: actions/checkout@v2
      - name: Build image
        uses: nick-fields/retry@v2
        with:
          timeout_minutes: 10
          max_attempts: 3
          retry_wait_seconds: 15
          command:  |
            zip \
            -r \
            -9 \
            -q betago.zip \
            . \
            -x ".git/*" \
            -x "dal/qqmusicapi/qqmusic-api-service/*" \
            -x "dal/neteaseapi/netease-api-service/*"
            DOCKER_BUILDKIT=1 docker build \
             . \
             --file scripts/larkrobot/Dockerfile \
             --tag $IMAGE_NAME
      - name: Push image
        uses: nick-fields/retry@v2
        with:
          timeout_minutes: 10
          max_attempts: 3
          retry_wait_seconds: 15
          command:  |
            current=`date "+%Y-%m-%d %H:%M:%S"`
            timeStamp=`date -d "$current" +%s` 
            #将current转换为时间戳,精确到毫秒  
            currentTimeStamp=$((timeStamp*1000+`date "+%N"`/1000000)) 

            IMAGE_ID=$IMAGE_NAME
            
            # 将所有的大写字母转为小写
            IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]')

            # 从 tag 名字中替换 v 字符
            [[ "${{ github.ref }}" == "refs/tags/"* ]] && VERSION=$(echo $VERSION | sed -e 's/^v//')

            # 设置镜像 id 和版本号
            docker tag $IMAGE_NAME $IMAGE_NAME:latest
            
            # 进行 push
            docker push $IMAGE_NAME:latest
            docker tag $IMAGE_NAME:latest $IMAGE_NAME:latest-$currentTimeStamp
            docker push $IMAGE_NAME:latest-$currentTimeStamp

            # docker tag $IMAGE_NAME:latest $TENCENT_HEADER/$IMAGE_NAME:latest 
            # docker push $TENCENT_HEADER/$IMAGE_NAME:latest
            # docker tag $TENCENT_HEADER/$IMAGE_NAME:latest $TENCENT_HEADER/$IMAGE_NAME:latest-$currentTimeStamp
            # docker push $TENCENT_HEADER/$IMAGE_NAME:latest-$currentTimeStamp
      - name: Gotify Notification
        uses: eikendev/gotify-action@master
        with:
          gotify_api_base: '${{ secrets.GOTIFY_URL }}'
          gotify_app_token: '${{ secrets.GOTIFY_APP_TOKEN }}'
          notification_title: '${{ env.ROBOT_NAME }} Build Complete'
          notification_message: 'Your build was completed.'

yaml示例

示例中的yaml将会在master分支发生变更或者有PR产生时触发,自动编译代码、构建镜像、注入环境变量,最终推送到私有的镜像仓库。

Part 8. 可观测性

在机器人调试的过程中,难免会发现一些性能问题或者bug,但遇到问题可能会因为日志缺失、调用情况复杂等问题导致很难快速定位。因为是一个轻量化的机器人服务,即使拿来实验改造的代价也并不高,于是希望能接入一些辅助观测的框架。

经过一些方案的比选调研,最终发现了又一个来自CNCF云原生基金会的可观测性(Observatory)组件的广泛实践标准:OpenTelemetry

OpenTelemetry
High-quality, ubiquitous, and portable telemetry to enable effective observability

以及链路Tracing的可视化项目: Jaeger

Jaeger: open source, distributed tracing platform
Monitor and troubleshoot workflows in complex distributed systems
Jaeger曾经使用的是自己的Trace协议,但在近两年也开始拥抱OpenTelementry,废弃了旧的标准。

OpenTelementry相对各种百花齐放的开源遥测协议并不具有绝对性的性能或易用性优势,在兼容过去的OpenTracing 和OpenCensus的基础上,它提供了一个跨平台的通用协议,从而避免开发者在不同的协议栈中的切换,将遥测的Metrics、Tracing、Logging三根支柱联系在了一起,包括Prometheus、Jaeger、Loki等等广泛使用的开源遥测中间件均已经实现了对OpenTelementry协议的支持。

jaeger的部署也可以直接通过compose来支持:

services:
  netease_cloud_music_api:
    image: binaryify/netease_cloud_music_api
    container_name: neteaseapi
    ports:
      - "3000:3000"
  gotify:
    image: gotify/server
    container_name: gotify
    ports:
      - 8080:80
    environment:
      - GOTIFY_DEFAULTUSER_PASS=custom
    volumes:
      - "./gotify_data:/app/data"
  minio:
    command: 'server /data --console-address ":9001"'
    image: minio/minio
    restart: always
    environment:
      - MINIO_ROOT_PASSWORD=Heyuheng1.22.3
      - MINIO_ROOT_USER=kevinmatt
      - MINIO_API_CORS_ALLOW_ORIGIN=*
      - MINIO_PROMETHEUS_AUTH_TYPE=public
      - MINIO_PROMETHEUS_URL="http://prom:9090"
      - MINIO_PROMETHEUS_JOB_ID=minio-job
      - MINIO_LOG_LEVEL=debug
    volumes:
      - "/mnt/StorageMirror/Storage/minio/data:/data"
    container_name: minio
    ports:
      - 29001:9001
      - 29000:9000
    expose:
      - 9001
      - 9000
  db:
    image: mongo
    restart: always
    environment:
      - MONGO_INITDB_ROOT_USERNAME=
      - MONGO_INITDB_ROOT_PASSWORD=
    volumes:
      - ./db:/data/db

  lynx:
    image: jackbailey/lynx:1
    restart: always
    ports:
      - 3000:3000
    depends_on:
      - db
    volumes:
      - ./backups:/app/backups
    environment:
      - NODE_ENV=production
      - DB_USER=
      - DB_PASSWORD=
      - JWT_KEY=
      - URL_LENGTH=8
      - URL_SET=standard
      - URL_ONLY_UNIQUE=false
      - HOME_REDIRECT=/dash/overview
      - FORCE_FRONTEND_REDIRECT=false
      - ENABLE_REGISTRATION=false # First registration will always be allowed
      - DOMAIN=http://example.com
      - DEMO=false
      - USE_HTTPS=true
      - CORS=*
      - BACKUP=true
      - BACKUP_SCHEDULE=* * * * * # Use crontab.guru to create
      - BACKUP_COUNT=5 # A count of 1 will make a backup file called backup.json
      - URL_REGEX=https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*) # Don't use quotation marks

      ## DO NOT CHANGE THESE:
      - DB_HOST=db
      - DB_PORT=27017
+  all-in-one:
+    image: "jaegertracing/all-in-one:1.57"
+    container_name: jaeger
+    ports:
+      - "16686:16686"
+    volumes:
+      - "<storage_dir_on_host>:/badger"
+    environment:
+      - BADGER_DIRECTORY_KEY=/badger/key
+      - BADGER_DIRECTORY_VALUE=/badger/data
+      - BADGER_EPHEMERAL=false
+      - SPAN_STORAGE_TYPE=badger
networks:
  default:
    name: "homelab"

在代码中简单的利用一些工具函数注入Tracing和Span:

如果是Stage+Runner类型的逻辑,可以在遍历Stage的过程中注入span:

在jaegerUI中,可以很清晰的观察到,链路上各个函数的耗时:

对于复杂、存在并发场景的耗时:

https://jaeger.kmhomelab.cn/trace/19528c07eb87f4148fa66103fd75107

在Span图中可以很清晰的展示出来函数的调用瓶颈,帮助我们决策如何优化代码。

相关链接

  • 云原生计算基金会(CNCF)
Cloud Native Computing Foundation
CNCF is the vendor-neutral hub of cloud native computing, dedicated to making cloud native ubiquitous.
  • Docker Documents
Home
Docker Documentation is the official Docker library of resources, manuals, and guides to help you containerize applications.
  • Lark Develop Doc
开发文档 - 飞书开放平台
飞书开发文档中包含丰富多样的开发指南、教程和示例,让开发者获得愉悦、高效的应用开发体验。
  • BetaGo Robot 机器人已开源,欢迎参考和建议:
GitHub - BetaGoRobot/BetaGo: BetaGo: A robot of Kook written by Golang
BetaGo: A robot of Kook written by Golang. Contribute to BetaGoRobot/BetaGo development by creating an account on GitHub.