2024年10月

1.简介

通过前边两篇文章跟随宏哥学习想必到这里已经将环境搭建好了,今天就在Java项目搭建环境中简单地实践一下: 启动两大浏览器。按市场份额来说,全球前三大浏览器是:IE.Firefox.Chrome。但是微软已经在Win10中不维护IE浏览器了,用Edge浏览器代替或者兼容IE模式的浏览器,因此宏哥这里主要介绍一下如何在Windows系统中启动这两大浏览器即可,其他平台系统的浏览器类似的启动方法,照猫画虎就可以了。但是在实践过程中,宏哥发现Java语言的Playwright目前好像支持三款浏览器:Chrome、Firefox和Webkit,索性就全部讲解和介绍了吧。可能有的小伙伴或者童鞋们不相信就仅仅支持三款浏览器,也可能是宏哥理解错了,欢迎纠错哦!下边说一下宏哥的依据。

1.1下载的浏览器

跟随宏哥首先看一下,我们运行代码下载的浏览器,只有三款:Chrome、Firefox和Webkit。如下图所示:

1.2BrowserType(浏览器类型)

再跟随随宏哥,看一下代码中提供的浏览器类型就三个:chromium()、firefox()和webkit(),如下图所示:

2.启动浏览器准备工作

2.1准备工作

创建一个Playwright的Java项目,用来练习,具体步骤如下:

1.打开Eclipse,创建一个名称为bjhg_playwright的Java 项目,如下图所示:

2.选择项目,右键项目,选择Build Path,然后选择Confige Build Path...,弹出框点击Libraries,如下图所示:

3.这里点击Add External JARs...,弹出框,选择当前电脑本地之前下载好的所有与playwright相关的jar包,添加到新建的项目中,如下图所示:

4.点击“OK”,如下图所示:

准备工作就介绍到这里,下边介绍如何启动两大浏览器。

3.启动Chrome浏览器

大致思路:打开Chrome浏览器,访问百度网址,获取网址的title,然后再关闭Chrome浏览器。根据思路进行代码设计。

3.1代码设计

3.2参考代码

packagebjhg_playwright;importcom.microsoft.playwright.Browser;importcom.microsoft.playwright.BrowserType;importcom.microsoft.playwright.Page;importcom.microsoft.playwright.Playwright;/***@author北京-宏哥
*
* @公众号:北京宏哥(微信搜索,关注宏哥,提前解锁更多测试干货)
*
* 《刚刚问世》系列初窥篇-Java+Playwright自动化测试-3-启动浏览器(详细教程)
*
* 2024年7月09日
*/ public classLaunchChrome {public static voidmain(String[] args) {try (Playwright playwright =Playwright.create()) {
Browser browser
= playwright.chromium().launch(new BrowserType.LaunchOptions().setHeadless(false).setSlowMo(50));
Page page
=browser.newPage();
page.navigate(
"https://www.baidu.com/");
System.out.println(page.title());
page.close();
}
}

}

3.3运行代码

1.运行代码,右键Run AS->java Application,就可以看到控制台输出,如下图所示:

2.运行代码后电脑端的浏览器的动作。如下图所示:

4.启动Firefox浏览器

大致思路和启动Chrome浏览器相同:打开Firefox浏览器,访问百度网址,获取网址的title,然后再关闭Firefox浏览器。根据思路进行代码设计。

4.1代码设计

4.2参考代码

packagebjhg_playwright;importcom.microsoft.playwright.Browser;importcom.microsoft.playwright.BrowserType;importcom.microsoft.playwright.Page;importcom.microsoft.playwright.Playwright;/***@author北京-宏哥
*
* @公众号:北京宏哥(微信搜索,关注宏哥,提前解锁更多测试干货)
*
* 《刚刚问世》系列初窥篇-Java+Playwright自动化测试-3-启动浏览器(详细教程)
*
* 2024年7月09日
*/ public classLaunchFirefox {public static voidmain(String[] args) {try (Playwright playwright =Playwright.create()) {
Browser browser
= playwright.firefox().launch(new BrowserType.LaunchOptions().setHeadless(false).setSlowMo(50));
Page page
=browser.newPage();
page.navigate(
"https://www.baidu.com/");
System.out.println(page.title());
page.close();
}
}

}

4.3运行代码

1.运行代码,右键Run AS->java Application,就可以看到控制台输出,如下图所示:

2.运行代码后电脑端的浏览器的动作。如下图所示:

5.启动webkit浏览器

大致思路和启动Chrome浏览器相同:打开webkit浏览器,访问百度网址,获取网址的title,然后再关闭webkit浏览器。根据思路进行代码设计。

5.1代码设计

5.2参考代码

packagebjhg_playwright;importcom.microsoft.playwright.Browser;importcom.microsoft.playwright.BrowserType;importcom.microsoft.playwright.Page;importcom.microsoft.playwright.Playwright;/***@author北京-宏哥
*
* @公众号:北京宏哥(微信搜索,关注宏哥,提前解锁更多测试干货)
*
* 《刚刚问世》系列初窥篇-Java+Playwright自动化测试-3-启动浏览器(详细教程)
*
* 2024年7月09日
*/ public classLaunchWebkit {public static voidmain(String[] args) {try (Playwright playwright =Playwright.create()) {
Browser browser
= playwright.webkit().launch(new BrowserType.LaunchOptions().setHeadless(false).setSlowMo(50));
Page page
=browser.newPage();
page.navigate(
"https://www.baidu.com/");
System.out.println(page.title());
page.close();
}
}

}

5.3运行代码

1.运行代码,右键Run AS->java Application,就可以看到控制台输出,如下图所示:

2.运行代码后电脑端的浏览器的动作。如下图所示:

6.关键代码说明

packagebjhg_playwright;importcom.microsoft.playwright.Browser;importcom.microsoft.playwright.BrowserType;importcom.microsoft.playwright.Page;importcom.microsoft.playwright.Playwright;/***@author北京-宏哥
*
* @公众号:北京宏哥(微信搜索,关注宏哥,提前解锁更多测试干货)
*
* 《刚刚问世》系列初窥篇-Java+Playwright自动化测试-3-启动浏览器(详细教程)
*
* 2024年7月09日
*/ public classLaunchChrome {public static voidmain(String[] args) {try (Playwright playwright =Playwright.create()) {//使用chromium浏览器,# 浏览器配置,设置以GUI模式启动Chrome浏览器(要查看浏览器UI,在启动浏览器时传递 headless=false 标志。您还可以使用 slowMo 来减慢执行速度。 Browser browser = playwright.chromium().launch(new BrowserType.LaunchOptions().setHeadless(false).setSlowMo(50));//创建page Page page =browser.newPage();//浏览器打开百度 page.navigate("https://www.baidu.com/");//打印title System.out.println(page.title());//关闭page page.close();
}
}

}

通过上边在代码中增加的注释,想必小伙伴或者童鞋们就比较好理解了,也知道宏哥在第一篇文章运行代码时,为何没有看到浏览器UI了吧!

7.小结

宏哥因为之前做过python、java语言和selenium,经常碰到头疼的问题就是:出现浏览器版本和驱动版本匹配的问题,新手一定要注意这个问题。但是playwright无论是Java还是python语言,无论是新手还是老鸟就都不需要担心这些问题了,而且今天讲解和分享的非常简单,就是简单换个方法就可以启动不同的浏览器了。好了,今天关于三大浏览器的驱动宏哥就分享到这里,感谢你耐心的阅读。

随着大模型流行,GPU 算力资源正变得日益稀缺,传统的“算力跟着存储跑”的策略需要转变为“存储跟着算力跑”。为了确保数据一致性和管理的便捷性,企业通常在特定地区的公有云上选择对象存储作为所有模型数据的集中存储点。当进行计算任务调度时,往往需要人工介入,手动进行数据拷贝和迁移方法不仅成本高昂,还存在管理和维护的复杂性,包括权限控制等问题都极为棘手。

JuiceFS 企业版的 “镜像文件系统” 功能允许用户从一个地区自动复制元数据到多个地区,形成一对多的复制模式。在多云架构下,该功能在确保数据一致性的同时,大幅降低人工运维的工作量。

最新的 JuiceFS 企业版 5.1 中, 镜像文件系统除了支持读取,还新增了可直接写入的功能。本文将探讨镜像文件系统的读写实现原理。

01 为什么需要镜像文件系统

让我们设想这样一个场景,某用户的文件系统部署在北京,但北京地区的 GPU 资源供给不足,而该用户在上海还有可用的 GPU 资源。这时用户想在上海运行模型训练任务,有两个简单的方案:

  1. 直接在上海挂载北京的文件系统
    。理论上来说,只要北京与上海之间的网络连接顺畅,上海的客户端确实就能访问数据以进行训练。然而实际情况是,文件系统的访问通常涉及到频繁的元数据操作,而由于两地的网络延迟较大,性能结果往往都无法达到预期。
  2. 在上海建立新的文件系统,在训练前拷贝所需数据集到上海
    。这样做的优点是可以保证上海训练任务的性能。但缺点也是很明显的,一方面构建新文件系统需要较高的硬件成本,另一方面每次训练前同步数据也提高了运维的复杂性。

综上所述,这两个简单的方案都无法令人满意。为此 JuiceFS 企业版提供了镜像文件系统功能。
它允许用户为已有文件系统创建一个或多个完整的镜像,这些镜像会自动从源端同步元数据,这样在镜像区域的客户端可以就近访问文件系统,来得到高性能的体验
。由于可以只镜像元数据,并且同步过程是自动的,因此相较于之前提到的方案二而言,镜像文件系统在成本与运维复杂性上都有明显的优势。

02 镜像文件系统原理

JuiceFS 企业版的架构与社区版相似,都包括客户端、对象存储以及元数据引擎。区别在于社区版的元数据引擎通常采用第三方数据库如 Redis、TiKV、MySQL 等,而企业版则配备了自研的高性能元数据服务,其中的元数据引擎由一个或多个 Raft 组组成,其架构图如下:

得益于元数据与数据分离的架构设计,用户在创建镜像文件系统时可以独立选择是否镜像元数据和是否镜像数据。两者皆配置镜像的架构如下:

此时,镜像的元数据服务其实跟源端的元数据服务同属一个 Raft 组,只是它们的角色是 learner。在源端发生元数据更新时,服务会自动推送变更日志到镜像端,并在镜像服务中进行回放。这样,镜像文件系统的存在并不会影响源端文件系统的性能表现,只是镜像的元数据版本会略落后一点点。

数据的镜像也是采用异步复制的方式,由指定配置的节点进行自动同步。不同的是,对镜像区域的客户端而言,它仅访问本区域的元数据,但是可以同时访问两个区域的对象存储。实际读取数据时,客户端优先从本区域读取;如果查找不到所需的对象,再尝试从源端区域读取。

一般而言,数据本身的体量较大,再拷贝一份的成本也比较高,因此另一种更推荐的方式是仅镜像元数据,并且在镜像区域构建一套分布式缓存组来提升读取数据的速度,示意如下:

JuiceFS 镜像文件系统推荐使用方法:两区域共用同一个对象存储,镜像区域搭建分布式缓存组来提升性能

这种使用方式尤其适合模型训练等可以提前准备数据集的场景。用户在执行训练任务前,先通过 juicefs warmup 命令将所需数据对象拉取到镜像区域的缓存组中,接下来的训练就能在镜像区域内完成,且性能与在源端(假设也配置了类似的分布式缓存组)基本一致。

03 实验性新功能:可写镜像文件系统

在之前的版本中,镜像客户端默认为只读模式,因为镜像元数据本身只支持读取,所有的修改操作必须在源端执行。然而,随着用户需求的增加,我们注意到一些新的使用情况,
例如在数据训练过程中产生的临时数据。用户希望避免维护两个不同的文件系统,并期望镜像端也能支持少量写操作

为了满足这些需求,我们在 5.1 版本中引入了 “可写镜像文件系统” 功能。在设计这项功能时,我们主要考虑三个方面:首先是系统的稳定性,这是必须保证的;其次是两端数据的一致性;最后是写入的性能。
最初,我们探索的一种直接方案是允许元数据镜像也能处理写操作。然而,在开发中我们发现,当需要将两端的元数据更新进行合并时,会面临非常复杂的细节处理和一致性问题。因此我们还是维持 “仅源端元数据可写” 的设计。为了处理镜像客户端的写请求,有两个可选的方案:

方案一:客户端将写请求发送至镜像的元数据服务,然后由其转发到源端
。源端接收到请求后开始执行操作,并在完成后将元数据同步回镜像端,并最终返回。这个方法的优点是客户端操作简单,只需发送请求并等待响应。然而,这样会使元数据服务的实现变得复杂,因为需要管理请求的转发和元数据的同步。此外,由于链路较长,任何环节的错误都可能导致请求处理出错。

方案二:客户端不仅连接镜像的元数据服务,还直接连接源端的元数据服务
。客户端内部进行读写分离,读请求仍然发送至镜像端,但将写请求发送至源端。这种方法虽然使客户端的处理逻辑复杂化,但简化了元数据服务的实现,让它们仅需做很小的适配改动即可。对整个系统而言,这样的做法稳定性也更高。

考虑到服务的简洁性和可靠性,我们最终选择了方案二,具体如下图所示。相较于原来的架构而言,这个方案主要多了一条镜像客户端发送写请求到源端元数据服务的流程。

以下将以创建一个新文件(create 请求)为例对此流程进行详细的介绍。假设源端和镜像端的元数据服务分别是 A 和 B,镜像客户端为 C,请求的完成大致分为 5 步:

  1. 客户端发送写请求:C 首先将创建文件的 create 请求发送至 A。
  2. 源端服务响应:A 在处理请求后,发送 create OK 告知 C 文件已成功创建,并在响应中附带 A 的元数据版本号(假设为 v1)。
  3. 变更日志推送:A 在发送回复给客户端的同时,也会立即生成一条变更日志,并将其推送给 B。
  4. 客户端发送等待请求:C 接收到源端的成功回复后,会检查自己的镜像元数据缓存,看其版本是否也达到了 v1。如果没有,客户端会发送一条 wait 消息给 B,并附上版本号 v1。
  5. 镜像端服务响应:B 收到等待消息后,检查自己的元数据版本。如果已经达到 v1,则立即回复 wait OK 给 C;否则的话就将请求放入内部队列,等自己的版本号更新到 v1 以后再发送回复。

C 在第 4 步确认镜像版本已经达到 v1,或者第 5 步收到 wait OK 后返回给上层应用。无论哪种情况,都表示 B 已经包含了本次 create 的修改,因此后续 C 在读取时,就能访问到最新的元数据。另外,由于步骤 2 和 3 几乎是同时发生的,所以大部分情况下 wait 消息都能被立即处理并返回。

镜像客户端的读操作也有类似的检查版本的机制。具体而言,C 在发送读请求前,会先比较其缓存中源端服务和镜像端服务的元数据版本号;如果源端的版本号更新,则会先发送 wait 消息给 B,等到其版本也更新上来后再处理原来的读请求。遗憾的是,C 缓存的源端版本号并不一定是最新的(比如其长时间未发送过写请求的情况),也就是说该机制只是尽可能地让 C 能读到较新的数据,但并不保证其一定是最新的(可能会有小于 1 秒的滞后,与原有的只读镜像相同)。

最后,我们通过一个稍复杂些的读写混合的例子,来简要说明使用 JuiceFS 镜像文件系统给用户带来的直接收益。

需求是客户端 C 希望在
/d1/d2/d3/d4
目录下创建一个新文件
newf
。按照文件系统的设计,C 需要逐级查找路径上的每一个目录和文件,并在确认文件不存在后再发送创建请求。现假设 C 到 A 和 B 的网络延迟分别是 30ms 和 1ms,C 尚未建立元数据缓存,并且忽略 A 和 B 的请求处理时间。

使用镜像文件系统的情况
:C 的读请求都由 B 处理,只有最后的创建文件请求需要发往 A。总耗时大概需要 1 * 2 * 6(mirror lookup) + 30 * 2(source create) + 1 * 2(mirror wait) = 74ms。

没有使用镜像文件系统的情况
:如果直接在镜像区域挂载源文件系统,C 的每个请求都需要跟 A 交互,那么总耗时就需要 30 * 2 * 6(source lookup) + 30 * 2(source create) = 420ms,是前者的 5 倍还多。

04 小结

在 AI 研究中,由于 GPU 资源的成本极高,多云架构已成为众多企业的标配。通过使用 JuiceFS 镜像文件系统,用户可创建一个或多个完整的文件系统镜像,这些镜像会自动从源端同步元数据,使得镜像区域的客户端能够就近访问文件,从而提供高性能并减少运维工作量。

在最新的 JuiceFS 5.1 版本中,我们对镜像文件系统进行了重要的优化,新增了允许写入的功能,使得企业能够在任何数据中心通过统一的命名空间访问数据。同时在保证数据一致性的前提下,享受就近缓存的加速效果。希望通过这篇文章分享的实现思路与尝试,为用户提供一些见解与启发。

本文主要记录个人做存储系统研究时,在 QEMU 环境下编译和安装 Linux 内核的过程

安装虚拟机系统

之前在
利用 RocksDB + ZenFS 测试 ZNS 的环境搭建和使用
给出过借助 VNC 进行图形化安装的步骤,这里再给出仅通过终端进行安装的步骤

# 下载 Ubuntu 镜像
wget https://releases.ubuntu.com/24.04.1/ubuntu-24.04.1-live-server-amd64.iso

# 制作磁盘镜像,大小随意
qemu-img create -f qcow2 u24s.qcow2 80G

# ubuntu 镜像挂在 cdrom 上启动
# -enable-kvm 用于开启 KVM 虚拟化
# -boot once=d 用于只从 cdrom 启动一次
# -nographic 用于关闭图形界面
qemu-system-x86_64 -m 8G -smp 4 -enable-kvm -nographic -hda u24s.qcow2 \
        -cdrom ubuntu-24.04.1-live-server-amd64.iso -boot once=d

按 e 进入编辑模式

然后在 grub menu 按
e
进入编辑模式

新增 console=ttyS0

然后在 vmlinuz 那一行新增
console=ttyS0
,之后
ctrl+x
启动即可

安装完毕后,后续启动命令可以简化

qemu-system-x86_64 -m 8G -smp 4 -enable-kvm -nographic -hda u24s.qcow2

但是此时的启动过程中的 grub menu 不会显示,还需要修改下 grub 配置

sudo vim /etc/default/grub

# 修改下面三个配置项
#GRUB_TIMEOUT_STYLE=hidden
GRUB_TIMEOUT=3
GRUB_TERMINAL=console

sudo update-grub
sudo poweroff

如果想通过 ssh 登陆虚拟机,启动参数可以加一个端口转发

qemu-system-x86_64 -m 8G -smp 4 -enable-kvm -nographic -hda u24s.qcow2 \
        -net nic,model=virtio -net user,hostfwd=tcp::6666-:22

之后就可以在物理机器上通过 ssh 登陆虚拟机了

ssh -p 6666 [user]@localhost

共享目录

为了加速内核编译,可以在物理机器上编译内核,然后将编译好的内核文件借助共享目录传输到虚拟机中

# 在物理机器上创建共享目录
mkdir -p xxx/share

# 启动虚拟机时挂载共享目录
qemu-system-x86_64 -m 8G -smp 4 -enable-kvm -nographic -hda u24s.qcow2 \
        -fsdev local,path=xxx/share,id=share_dir,security_model=none \
        -device virtio-9p-pci,fsdev=share_dir,mount_tag=hostshare \
        -net nic,model=virtio -net user,hostfwd=tcp::6666-:22

如果报错,很有可能是
qemu
不支持
9p
,需要从源码编译
qemu
,在
configure
时加上
--enable-virtfs
选项即可

之后在虚拟机中挂载共享目录

# 虚拟机中挂载共享目录
sudo mkdir -p /mnt/share
sudo mount -t 9p -o trans=virtio hostshare /mnt/share/ -oversion=9p2000.L

如果报错,很有可能是虚拟机的内核不支持
9p
,需要编译内核,是打开以下内核配置选项:

CONFIG_NET_9P=y
CONFIG_NET_9P_VIRTIO=y
CONFIG_NET_9P_DEBUG=y (Optional)
CONFIG_9P_FS=y
CONFIG_9P_FS_POSIX_ACL=y
CONFIG_PCI=y
CONFIG_VIRTIO_PCI=y

CONFIG_PCI=y
CONFIG_VIRTIO_PCI=y
CONFIG_PCI_HOST_GENERIC=y (only needed for the QEMU Arm 'virt' board)

编译内核

在物理机上准备环境

# 编译工具,词法语法分析库
sudo apt install build-essential bison flex
# 如果编译时缺少 openssl 的相关头文件,需要安装相关库
sudo apt install libssl-dev
# 利用 make menuconfig 图形界面配置编译选项需要安装 ncurses 环境:
sudo apt install libncurses5-dev

# 下载 kernel 源码,解压
wget https://cdn.kernel.org/pub/linux/kernel/v5.x/linux-5.4.xxx.tar.xz
tar xvf linux-5.4.xxx.tar.xz
mv linux-5.4.xxx xxx/share/

在虚拟机内获取内核配置

sudo mount -t 9p -o trans=virtio hostshare /mnt/share/ -oversion=9p2000.L

cd /mnt/share/linux-5.4.xxx
sudo make oldconfig

在物理机上编译内核

# 解决 make Error 问题
sudo scripts/config --set-str SYSTEM_TRUSTED_KEYS ""
sudo scripts/config --set-str SYSTEM_REVOCATION_KEYS ""

# 编译内核和模块, -j24 表示使用 24 个线程编译, 可以根据自己的 CPU 核心数和内存大小调整
sudo make -j24

在虚拟机内安装内核

# 去除调试信息,解决 initrd.img 过大的问题
sudo make INSTALL_MOD_STRIP=1 modules_install
sudo make install
sudo poweroff

卸载内核

开发过程中可能会有 bug,需要在虚拟机卸载有问题的内核

# 删除 /lib/modules/ 目录下以内核的版本号为名称的目录
sudo rm -rf /lib/modules/5.4.xxx+/

# (可选)删除 /usr/src/linux/ 目录下不需要的内核源码
# sudo rm -rf /usr/src/linux-headers-5.4.xxx

# 删除 /boot 目录下启动的内核和内核映像文件
sudo rm /boot/*5.4.xxx*

# 更改 grub 的配置文件,删除不需要的内核启动列表 
sudo update-grub2

参考资料

本文作者:
ywang_wnlo
本文链接:
https://ywang-wnlo.github.io/posts/5fce01ae/
版权声明:
本博客所有文章除特别声明外,均采用
BY-NC-SA
许可协议。转载请注明

之前接入微信小游戏本身代码js桥接比较完善,抖音小游戏有缺少但也没缺的这么多,华为这边的API,大残啊!
官方转换插件Github仓库
上一次提交在3月份。(截至现在)API给的很简略,接入js代码那里说
看demo
,但unity的demo里面没jslib,另一个比较完善的demo看起来像是cocos的,比较无奈。

还好用unity导出的webgl产物,和导出rpk这两部是分开的,测试调整jslib可以之间改
xxx.framework.js
来快速尝试,不用浪费大量的时间用来打包。

华为官方的JS API文档
中给出的API全局对象是
qg
, 在转换插件内部的代码
QGSDK-Call-JS.jslib
中可以找到大量的
QG_
开头的函数,没有
qg
字段,但API看起来都使用
ral
作为入口,再看打包插件里面的
ral.js
是调用
qg
的。

测试发现
ral === window.ral
,
qg === window.qg
, 直接通过
Object.keys(x).ForEach()
遍历打印
qg

ral
两个对象,键值对的文本是能对的上的。直接调用又有些内容不同,比如有
qg.showToast
但没有
ral.showToast
,都使用
qg
,测试
qg.showToast
是能用的,不知道会不会有其他问题。

以下为桥接代码的思路展示,一些接口基于实际需求进行了简化。

处理js返回C#的回调:


using LitJson;
using System.Collections.Generic;
using UnityEngine;

namespace Plugins.SDK {
    public delegate void SDKCallback(int code, string msg, string dataStr);

    public static class SDKCode {
        public const int Succeed = 1;
        public const int Failed = -1;
        public const int Cancel = -2;
    }

    // 用于处理回调
    public class CsJsEventHandler : MonoBehaviour
    {
        private static CsJsEventHandler s_Instance = null;
        public static CsJsEventHandler Instance
        {
            get
            {
                if (s_Instance == null)
                {
                    GameObject go = new GameObject("CsJsEventHandler");
                    GameObject.DontDestroyOnLoad(go);
                    s_Instance = go.AddComponent<CsJsEventHandler>();
                }
                return s_Instance;
            }
        }
    }

    private readonly Dictionary<string, SDKCallback> m_Callbacks = new Dictionary<string, SDKCallback>();
    private readonly HashSet<string> m_PersistentCallbacks = new HashSet<string>();

    public void StartUp()
    {
    }

    public string AddCallback(string funcName, SDKCallback callback, bool persistent = false)
    {
        string callbackId = $"{funcName}_{System.DateTime.Now:ddHHmmssfff}";
        m_Callbacks[callbackId] = callback;
        if (persistent)
        {
            m_PersistentCallbacks.Add(callbackId);
        }
        return callbackId;
    }

    public void RemoveCallback(string callbackId)
    {
        m_Callbacks.Remove(callbackId);
        m_PersistentCallbacks.Remove(callbackId);
    }

    // 经常调用又是异步的函数
    public string AddCallbackSingleton(string funcName, SDKCallback callback)
    {
        string callbackId = $"{funcName}_singleton";
        m_Callbacks[callbackId] = callback;
        m_PersistentCallbacks.Add(callbackId);
        return callbackId;
    }

    public string GetCallbackSingleton(string funcName)
    {
        string callbackId = $"{funcName}_singleton";
        return m_PersistentCallbacks.Contains(callbackId) ? callbackId : string.Empty;
    }

    public string RemoveCallbackSingleton(string funcName)
    {
        string callbackId = $"{funcName}_singleton";
        RemoveCallback(callbackId);
    }

    public void ClearCallbacks()
    {
        m_Callbacks.Clear();
        m_PersistentCallbacks.Clear();
    }

    private static string GetJsonString(JsonData obj, string key)
    {
        if (!obj.ContainsKey(key)) return string.Empty;
        return obj[key]?.ToString() ?? string.Empty;        
    }

    private static int GetJsonInt(JsonData obj, string key, int defaultValue = 0)
    {
        string s = GetJsonString(obj, key);
        if (int.TryParse(s, out int v))
        {
            return v;
        }
        return defaultValue;
    }

    public void HandleJsEvent(string jsonStr)
    {
        if (string.IsNullOrEmpty(jsonStr)) return;

        JsonData jsonData = JsonMapper.ToObject(jsonStr);
        string callbackId = GetJsonString(jsonData, "callbackId");
        if (m_Callbacks.TryGetValue(callbackId, out SDKCallback callback))
        {
            int code = GetJsonInt(jsonData, "code", SDKCode.Failed);
            string msg = GetJsonString(jsonData, "msg");
            string dataStr = GetJsonString(jsonData, "data");

            if (!m_PersistentCallbacks.Contains(callbackId))
            {
                m_Callbacks.Remove(callbackId);
            }
            callback.Invoke(code, msg, dataStr);
        }
    }
}

jslib

/*
*   handler return code:
*       SUCCEED: 1
*       FAILED: -1
*       CANCEL: -2
*
*   只展示部分函数,请根据实际需求自行添加
*/

var js_bridge_sdk_api = {

// File System
// 主要是调试\日志用, 能用同步的都用同步了

    JSB_GetUserDataPath: function() {
        return JSBHelper.stringToBuffer(qg.env.USER_DATA_PATH);
    },
    JSB_FSMAccessSync: function(path) {
        var fsm = qg.getFileSystemManager();
        try {
            fsm.accessSync(UTF8ToString(path));
            return JSBHelper.stringToBuffer("access:ok");
        } catch(error) {
            return JSBHelper.stringToBuffer(`error:${error}`);
        }
    },
    JSB_FSMCopyFileSync: function(path) { /* ... */ },
    JSB_FSMMkdirSync: function(dirPath) { /* ... */ },
    JSB_FSMRmdirSync: function(dirPath) { /* ... */ },
    JSB_FSMReaddirSync: function(dirPath) {
        var fsm = qg.getFileSystemManager();
        try {
            var result = fsm.readdirSync(UTF8ToString(dirPath));
            var text = JSON.stringify(result);
            return JSBHelper.stringToBuffer(text);
        } catch(error) {
            return JSBHelper.stringToBuffer("[]");
        }
    },
    JSB_FSMUnlinkSync: function(path) { /* ... */ },
    JSB_FSMReadTxtFile: function(filePath) {
        // readFileSync可以读utf8和binary, 不会写byte[]数组怎么从js传递到C#, 但因为没有需求, 先直接跳过了
        var fsm = qg.getFileSystemManager();
        try {
            var result = fsm.readFileSync(UTF8ToString(dirPath), "utf8");
            return JSBHelper.stringToBuffer(result);
        } catch(error) {
            return JSBHelper.stringToBuffer("");
        }
    },
    JSB_FSMWriteTxtFile: function(filePath, text) {
        // 同上
        var fsm = qg.getFileSystemManager();
        try {
            fsm.readFileSync(UTF8ToString(filePath), UTF8ToString(text), "utf8");
            return JSBHelper.stringToBuffer("ok");
        } catch(error) {
            return JSBHelper.stringToBuffer(`error:${error}`);
        }
    },
    JSB_FSMAppendTxtFile: function(filePath, text) {
        // 同上
        var fsm = qg.getFileSystemManager();
        try {
            fsm.appendFileSync(UTF8ToString(filePath), UTF8ToString(text), "utf8");
            return JSBHelper.stringToBuffer("ok");
        } catch(error) {
            return JSBHelper.stringToBuffer(`error:${error}`);
        }
    },

// UI

    JSB_ShowToast: function(config) {
        // 参数尽量简单, 用一个json字符串传递
        var options = JSON.parse(UTF8ToString(config));
        qg.showToast(options);
    },
    JSB_HideToast: function(config) {
        qg.hideToast({});
    },
    JSB_ShowModal: function(config) {
        // 快应用加载器里面这个modal好像能点穿的, 略坑
        var options = JSON.parse(UTF8ToString(config));
        var callbackId = options.callbackId; // 这里的callbackId是C#传递过来的, 后面展示
        delete options.callbackId;
        options.success = function(res) {
            if (res.confirm) {
                JSBHelper.returnEvent(callbackId, 1, "", "");
            }
            else if (res.cancel) {
                JSBHelper.returnEvent(callbackId, -2, "", "");
            }
        };
        options.fail = function() {
            JSBHelper.returnEvent(callbackId, -1, "", "");
        };
        qg.showModal(options);
    },
    JSB_ShowLoading: function(title) {
        qg.showLoading({
            title: UTF8ToString(title),
            mask: true,
        });
    },
    JSB_HideLoading: function() {
        qg.hideLoading({});
    },

// Misc

    JSB_GetBatteryLevel: function() {
        var info = qg.getBatteryInfoSync();
        return parseInt(info.level);
    },
    JSB_TriggerGC: function() {
        qg.triggerGC();
    },
    JSB_GetNetworkType: function() {
        var callbackId_j = UTF8ToString(callbackId);
        qg.getNetworkType({
            success: function(res) {
                var networkType = res.networkType;
                if (networkType === undefined) networkType = "unknown";
                JSBHelper.returnEvent(callbackId_j, 1, "", networkType);
            },
            fail: function() {
                JSBHelper.returnEvent(callbackId_j, -1, "", "");
            }
        });
    },
    JSB_OnNetworkStatusChange: function(callbackId) {
        // 因为回调一旦挂上就没有移除的需求, 没有把callback存起来
        var callbackId_j = UTF8ToString(callbackId);
        qg.onNetworkStatusChange(function(res) {
            var data = JSON.stringify(res);
            JSBHelper.returnEvent(callbackId_j, 1, "", data);
        });
    },
    JSB_Vibrate: function(mode) {
        if (mode == 0) {
            qg.vibrateShort({});
        }
        else {
            qg.vibrateLong({});
        }
    },
    JSB_PreviewImage: function(url) {
        qg.previeImage({
            urls: [
                UTF8ToString(url)
            ]
        });
    },

// Helper

    $JSBHelper: {
        stringToBuffer: function(valueStr) {
            var bufferSize = lengthBytesUTF8(valueStr) + 1;
            var buffer = _malloc(bufferSize);
            stringToUTF8(valueStr, buffer, bufferSize);
            return buffer;
        },
        returnEvent: function(callbackId, code, msg, dataStr) {
            var obj = {
                callbackId: callbackId,
                code: code,
                msg: msg,
                data: dataStr,
            };
            var text = JSON.stringify(obj);
            qg.unityInstance.Module.SendMessage("CsJsEventHandler", "HandleJsEvent", text);
            // Unity2021 文档给的是"MyGameInstance", 需要到index.html里面自己新建一个var并获取unityInstance
            // 但在这里不行, 测试"SendMessage"和"qg.unityInstance.Module.SendMessage"可用
        }
    }
};

autoAddDeps(js_bridge_sdk_api, "$JSBHelper");
mergeInfo(LibraryManager.library, js_bridge_sdk_api);

C#调用


using LitJson;
using System.Runtime.InteropServices;

namespace Plugins
{
    public static partial class JSBridgeExterns
    {
#if !UNITY_EDITOR
        [DllImport("__Internal")]
        public static extern string JSB_GetUserDataPath();

        // ... 省略, 函数签名对上即可
#else
        // 如果调用的代码不想写#if, 可以再写一遍UNITY_EDITOR的版本
        public static string JSB_GetUserDataPath() => default;

        // ...
#endif
    }

    // 调用展示
    public static class Test
    {
        public static void Test()
        {
            // 基础调用

            // 获取文件目录            
            string userDataPath = JSBridgeExterns.JSB_GetUserDataPath();

            // toast
            JsonData toastOptions = new JsonData();
            toastOptions["title"] = "Hello, world!";
            toastOptions["icon"] = "none";
            toastOptions["mask"] = true;
            toastOptions["duration"] = 2000;
            JSBridgeExterns.JSB_ShowToast(toastOptions.ToJson());

            // 带回调的调用
            SDKCallback callback = (code, msg, dataStr) => {
                if (code == SDKCode.Succeed)
                {
                    // ...
                }
                else if (code == SDKCode.Cancel)
                {
                    // ...
                }
                else if (code == SDKCode.Failed)
                {
                    // ...
                }
            };
            string callbackId = CsJsEventHandler.Instance.AddCallback("showModal", callback);
            JsonData modalOptions = new JsonData();
            modalOptions["title"] = "系统";
            modalOptions["content"] = "您有新短消息, 请注意查收";
            modalOptions["callbackId"] = callbackId;
            JSBridgeExterns.JSB_ShowModal(modalOptions.ToJson());

            // 挂网络状态监听
            string singletonCallbackId = CsJsEventHandler.Instance.AddCallbackSingleton("onNetworkStatusChange", _OnNetworkStatusChange);
            JSBridgeExterns.JSB_OnNetworkStatusChange(singletonCallbackId);
        }

        private static void _OnNetworkStatusChange(int code, string msg, string dataStr)
        {
            string json = dataStr;
            // ...
        }
    }
}

大家好,我是汤师爷~

应用交互是指不同应用结构之间的数据交换和通信方式。

在一个复杂的系统中,各个应用并不是孤立存在的,它们往往需要相互协作,才能完成更复杂的业务流程。

应用交互的设计就是为了确保这些系统和组件能够顺畅地“对话”,实现系统整体目标。

应用交互的形式有多种,包括同步调用、异步消息通信等。每种交互方式都有其特定的应用场景和优缺点。

通过合理的交互设计,系统中的各个部分能够高效协同,减少耦合度,增加系统的灵活性。

同时,良好的交互设计还能显著提升系统的性能和容错能力,即使在大流量访问、业务需求复杂的情况下,也依然保持稳定运行。

应用服务的上下游

应用服务是系统对外提供的核心业务功能。

应用服务也是如此,应用服务可以独立演化和实现,但它们并非完全独立,必须相互交互,才能实现整体系统目标。

如何设计应用服务之间的交互?首先需要了解清楚服务上下游的概念。

1、服务上下游的概念

服务的上下游关系可以通过DDD(领域驱动设计)的建模方法来定义,主要使用限界上下文(bounded context)和上下文映射(context mapping)这两个概念。

上下游表示上下文之间的依赖方向,下游需要了解上游的领域知识来实现业务,而上游不需要了解下游。

换句话说,上游服务不需要关心下游服务的存在,但下游服务的实现却依赖于上游服务提供的能力。

这个概念听起来有些抽象,确实让许多人犯迷糊。让我们通过线上商城的几个应用服务来具体说明:

  • 用户服务:管理用户的账户信息,包括注册、登录、认证、个人资料等。处理用户的权限和角色管理。
  • 商品服务:管理商品的基本信息,包括名称、描述、价格、图片、分类等。提供商品的查询、筛选和浏览功能。
  • 库存服务:管理商品的库存数量。处理库存的预占、扣减和回补操作。
  • 交易服务:处理订单的创建、修改、取消和查询。管理订单的状态和生命周期。
  • 支付服务:处理支付事务,支持多种支付方式。管理支付状态。
  • 履约服务:处理订单的履约,包括拣货、包装、发货等。管理物流信息和配送状态。

如图所示,我们可以看出各个服务的上下游关系。

商品服务和用户服务是上游服务,它们提供基础数据,其他服务依赖于这些数据。

交易服务位于中间位置。对用户服务和商品服务而言,交易服务是下游,因为它依赖于这两个服务的基础数据。

对库存服务来说,交易服务也是下游,因为交易下单过程中,需要库存服务来预占、扣减库存。

对履约服务而言,交易服务是上游,因为它提供订单数据,驱动后续的订单履约流程。

2、为什么要区分上下游?

区分上下游关系的核心目标是为了解耦。

"解耦"这个词相信大家都不陌生,但它的含义往往过于抽象和模糊。在这里,我们探讨一下解耦到底指什么。

耦合是指两个或多个结构之间的相互作用和影响。在软件开发中,这可以理解为不同模块、系统或团队之间的相互依赖和影响。

随着软件需要解决的业务问题越来越复杂,单个系统或团队很难独立实现业务目标。因此,解耦的目的并非完全消除耦合,而是减少不必要的依赖关系。

在上文中我们提到,上游服务不需要关心下游服务的存在,但下游服务的实现却依赖于上游服务提供的能力。

因此,当下游服务的团队迭代新功能时,无需评估是否影响上游服务,因为基于明确的上下游关系,能快速判断出不会影响上游服务。只需评估是否影响自己的下游服务。

例如,交易服务的功能发生变更时,只需通知履约服务的团队,评估是否会影响到他们,上游服务团队则无需知晓。

这种方式能大大减少影响面的评估工作,提高团队协作效率。

相反,如果上下游关系混乱,存在各种循环依赖,那么任何一个服务的改动都难以准确评估影响面。此时就需要召集所有服务的团队,逐一评估是否有影响。

实际场景中,每次项目会议都需要一屋子人才能评估出影响面,这样的协作效率极低。

3、上下游关系的核心使用场景

在软件研发过程中,上下游关系在许多关键场景中发挥着重要作用。

  • 明确服务之间的依赖关系:上下游关系让开发者清晰地了解服务间的依赖。这有助于减少不合理的依赖,确保服务的独立性和模块化设计。同时,它也避免了服务间的循环依赖,降低了一个服务出现故障引发连锁反应的风险。
  • 评估影响面:当上游服务变更时,可以预见其对下游服务的影响,从而制定相应的应对策略。
  • 指导团队协作:上下游关系有助于明确各团队的职责和工作范围。上游团队需要考虑下游团队的需求,提供稳定的接口和服务;下游团队则需要适应上游的变化。

应用服务的交互方式

应用服务的交互方式多种多样,其中最主要的两种是同步调用和异步消息。

1、同步调用

同步调用是一种通信方式,其中调用方(客户端)向被调用方(服务端)发送请求,并等待服务端处理完成后返回结果。在此期间,调用方会阻塞,直到收到服务端的响应。这种方式要求调用方和被调用方同时在线,且调用方在等待响应期间无法执行其他操作。

在微服务架构中,同步调用的典型技术实现协议包括HTTP、REST API、Dubbo、Thrift、gRPC和SOAP等。

同步调用适用于下游服务需要立即获取上游服务的数据或功能的场景。这种通信方式简单直接,但需要处理服务之间的可用性问题。

例如,用户下单时,订单服务需要同步调用商品服务,获取商品的最新价格和库存信息,以确保订单有效。

通常来说,上游服务不应同步调用下游服务。如果上游服务同步调用下游服务,会导致上游需要了解下游的领域知识,违背DDD上下游的设计原则,加深系统耦合,并增加团队协作复杂性。

此外,这种做法还可能引发级联故障,降低系统可靠性。如果上下游直接互相调用,那下游服务发生故障也将直接影响上游服务的可用性,可能导致整个系统都不可用。

2、异步消息

异步消息是另一种通信方式,其中消息的发送者(生产者)和接收者(消费者)通过消息队列或消息中间件进行通信。

发送者无需等待接收者处理完成即可继续其他操作。消息被发送到消息队列后,接收者从队列中异步获取并处理。这种方式将发送者和接收者的时间依赖解除,让两者能够独立运作,提高了系统的灵活性和可扩展性。

在微服务架构中,异步消息通常通过消息中间件实现,如RabbitMQ、Kafka和RocketMQ等。

异步消息适用于上游服务向下游服务发布事件或通知的场景,能有效解耦服务,提高系统的弹性和可靠性。下游服务也可通过异步消息向上游服务反馈信息,实现双向通信。

例如,当用户提交订单后,订单服务调用支付服务发起支付。用户完成支付后,支付服务发布"支付成功"消息,订单服务接收该消息后,更新订单状态并发送通知。

3、其他交互方式

1)共享数据库方式

多个服务访问同一个数据库,直接读取或写入数据。

在微服务架构中,通常不建议采用共享数据库的方式,因为它违反了服务自治原则,增加了服务间的耦合度。

2)文件传输

服务之间通过共享文件系统或FTP等方式交换数据文件。这种交互方式通常是批处理的,实时性较差。

3)服务总线(ESB)

使用统一的通信总线来连接不同的服务和系统。服务之间不直接通信,而是通过总线中介,适用于需要集成多种异构系统和服务的大型企业级系统。

然而,这种方式引入了额外的架构层,增加了系统复杂性。所有服务都耦合到总线上,存在单点故障风险。

本文已收录于,我的技术网站:
tangshiye.cn
里面有,算法Leetcode详解,面试八股文、BAT面试真题、简历模版、架构设计,等经验分享。