2024年4月

本文分享自华为云社区《
Java Chassis 3技术解密:与Spring Cloud的互操作
》,作者: liubao68。

Java Chassis 3一个很重要的设计原则:利用架构的韧性设计来解决兼容性问题。

比如通过引入微服务网关,来解决不同语言、不同框架、遗留系统之间的互操作问题。 本文在这个架构原则基础上,讨论一个更加细粒度的互操作问题,并借此解密Java Chassis3在运行时设计依赖上的技术细节。

首先,我们描述一个互操作的场景和假设。

  • Spring Cloud和Java Chassis应用同时注册到注册中心。引入了Spring Cloud Gateway作为网关,网关也注册到注册中心。
  • Spring Cloud微服务和Java Chassis微服务相互调用。它们彼此作为消费者的时候,不需要感知对方是不同的框架,做到透明调用。

从技术原理上梳理下上述互操作需要满足的条件:

  • Spring Cloud和Java Chassis需要有相互认识的注册信息。核心包括:应用名称、服务名称、地址信息和格式等。 需要的共同注册中心越少,越容易对注册中心和客户端进行选型。 在本例子中,我们选择
    Service Center
    或者
    Nacos
    作为注册中心,并选择Spring Cloud Huawei实现Spring Cloud注册。
  • Spring Cloud访问Java Chassis,只需要一个地址信息,依赖较少。 Java Chassis访问Spring Cloud,需要知道Spring Cloud应用提供的契约信息。

Java Chassis区别于Spring Cloud的REST调用的部分,就是契约依赖。 Spring Cloud通过FeignClient来声明客户端契约,客户端都需要在FeignClient中重复书写REST标签;Java Chassis有两种模式发现契约:从注册中心发现和从Provider实例发现。 Java Chassis3默认采用从Provider实例发现, Java Chassis2采用从注册中心发现。 从Provider发现的好处是可以降低对于注册中心元数据管理能力的要求,本例既可以采用
Service Center
作为注册中心,也可以选择
Nacos
作为注册中心。

从Provider发现,要求Provider实现如下接口:

/**
* Java Chassis internal management apis.
*/@Path("/scb/management")public interfaceManagementEndpoint {
String NAME
= "scb-management";/**
* Health of this instance. If the instanceId match this instance, and this service is ready
* to service return true. Otherwise, return false.
*
* This api is for internal instance status usage. Load balancer will call this api to check if
* the target instance is alive.
*/@GET
@Path(
"/health")
boolean health(@QueryParam(
"instanceId") String instanceId, @QueryParam("registryName") String registryName);/**
* Schemas of this instance.
*
* This api is for internal schema loading usage.
*/@POST
@Path(
"/schema/contents")
Map
<String, String>schemaContents();
}

它包含一个健康检查接口和一个查询契约的接口。 当Spring Cloud应用实现上述接口以后,它就具备了Java Chassis微服务需要的基础特征,这样Java Chassis就可以像访问本框架的微服务一样访问Spring Cloud框架开发的微服务应用。 为了简化,在Spring Cloud简单实现了该接口,该实现接口从
export
目录加载契约信息,只需要将Spring Cloud需要对外暴露的 REST 接口的符合 Open API 3.0规范的契约文件放到这个目录下面 。

@RestController
@RequestMapping((
"/scb/management"))public classJavaChassisManagementEndpoint {private static final Logger LOGGER = LoggerFactory.getLogger(JavaChassisManagementEndpoint.class);

@Autowired
privateRegistration registration;

@GetMapping(path
= "/health")public boolean health(@RequestParam("instanceId") String instanceId,
@RequestParam(
"registryName") String registryName) {return "sc-registry".equals(registryName) &&registration.getInstanceId().equals(instanceId);
}

@PostMapping(path
= "/schema/contents")public Map<String, String>schemaContents() {try{
List
<URI> resourceUris = ResourceUtil.findResourcesBySuffix("export", ".yaml");
Map
<String, String> result = new HashMap<>(resourceUris.size());for(URI uri : resourceUris) {
String path
=uri.toURL().getPath();
String[] segments
= path.split("/");if (segments.length < 2 || !"export".equals(segments[segments.length - 2])) {continue;
}
result.put(segments[segments.length
- 1].substring(0, segments[segments.length - 1].indexOf(".yaml")),
IOUtils.toString(uri, StandardCharsets.UTF_8));
}
returnresult;
}
catch (IOException |URISyntaxException e) {
LOGGER.error(
"Load schema ids failed from microservices. {}.", e.getMessage());returnCollections.emptyMap();
}
}
}

Java Chassis与Spring Cloud互操作的例子放到了
ServiceComb Samples
, 这个例子也提供了使用 Nacos 作为注册中心和配置中心的实现, 只需要将 Profile 设置为 Nacos 即可。

客户故事:在架构选型变化的时候,解决功能迁移和兼容性问题是最大的挑战。一些客户将Spring Cloud应用改造为Java Chassis的过程中,发现一些功能不支持,比如SseEmitter、WebSocket等。 如果选择支持这些能力,Java Chassis需要实现很多Servlet能力,这些能力规划会和微服务技术架构存在冲突。 对于这些场景,我们选择通过架构韧性来保留这些功能,比如将提供SseEmitter、WebSocket功能的独立出微服务,采用Spring Boot开发,这些应用可以通过调用Java Chassis微服务的REST接口来实现其特殊功能。通过这种架构韧性的理念,降低了技术持续演进的包袱,为敏捷迭代,持续创新奠定了方向,减少了兼容性问题的争论。

点击关注,第一时间了解华为云新鲜技术~


Redis24篇集合

1 背景

在我们的《
Redis高可用之战:主从架构
》篇章中,介绍了Redis的主从架构模式,可以有效的提升Redis服务的可用性,减少甚至避免Redis服务发生完全宕机的可能。
它主要包含如下能力:
1. 故障隔离和恢复
:无论主节点或者从节点宕机,其他节点依然可以保证服务的正常运行,并可以手动或自动切换主从。

  • 如果Slave库故障,则读写操作全部走到Master库中
  • 如果Master库故障,则将Slave转成Master库,仅丢失Master库来不及同步到Slave的小部分数据

2. 读写隔离
:Master 节点提供写服务,Slave 节点提供读服务,分摊流量压力,均衡流量的负载。
3. 提供高可用保障
:主从模式是高可用的最基础版本,也是 sentinel 哨兵模式和 cluster 集群模式实施的前置条件。

主从架构模式虽然很强大,但依然存在一些的问题,我们知道,在衡量系统可用性这方面有个指标叫做
MTTR
,即平均修复时间。虽然主从模式支持手动切换,但是
我们从接收到服务故障预警到手动切换止损到恢复,这可能是一个比较长的过程。这期间的损失将难以计量,对于超高并发大系统是一个绝对灾难。
所以我们需要系统能自动的感知到Master故障,并选择一个 Slave 切换为 Master,实现故障自动转移的能力,提升
RTO
指数。这时候
哨兵模式
就可以支棱起来了。

平均修复时间(Mean time to repair,MTTR),是描述产品由故障状态转为工作状态时修理时间的平均值。
复原时间目标(Recovery Time Objective,RTO):是描述产品从故障到恢复原状的时间,优质架构要求我们尽量在1分钟左右恢复,一线互联网大厂的高并发场景0容忍。

2 什么是哨兵模式

在实际生产环境中,服务器难免会遇到一些突发状况:服务器宕机,停电,硬件损坏等等,一旦发生,后果不堪设想。
哨兵模式的核心还是主从模式的演变,只不过
相对于主从模式,在主节点宕机导致不可写的情况下,多了探活,以及竞选机制
:从所有的从节点竞选出新的主节点,然后自动切换。竞选机制的实现,是依赖于在系统中启动Sentinel进程,对各个服务器进行监控。如下图所示:
image

3 哨兵模式的职责能力

哨兵模式作为Redis高可用的一种运行机制,专注于对 Redis 实例(master、slaves)运行状态进行监控,并能够在主节点发生故障时通过一系列的操作,实现新的master竞选、主从切换、故障转移,确保整个 Redis 服务的可用性。

整体来说它有如下能力:

  • 集群监控
  • 故障监测与通知
  • 自动故障转移(主从切换)

3.1 集群监控

哨兵模式的主要任务之一是监控Redis主从复制集群中的各个节点。它会定期检查主节点和从节点的健康状态,确保它们都在正常运行。

3.1.1 前置知识

1. 主观下线(sdown):

  • sdown(主观不可用)是单个哨兵自己主观上检测到的关于Master的状态,从哨兵的角度来看,如果发送PING心跳后,在一定的时间内没有得到应有的回复,就达到了sdown的条件。
  • 哨兵配置文件
    sentinel.conf

    down-after-milliseconds
    属性设置了判断主观下线的回复时间。

image

# sentinel down-after-milliseconds mymaster 30000  默认30s
sentinel down-after-milliseconds <masterName> <timeout>

这种机制是为了保证多个哨兵实例可以一起综合判断,避免单个哨兵(
因为自身请求超时、网络抖动等问题
)的误判,导致主库被下线。

2. 客观下线 (odown):
上面说了,Master是否下线不是单个Sentinel能够决定的,一般来说需要一定数量的哨兵,多个哨兵达成一致意见才能认为一个Master客观上已经宕机了。
上面的图可以看到,我们一般会有个Sentinel集群 ,这时候这个集群就发挥作用了,通过投票机制,超过指定数量(一般为半数)的Sentinel 都判断了『主观下线』 ,这时候我们就把 Master 标记为『客观下线』,代表它确实不可用了。
投票判定的数量是通过
sentinel.conf
配置的:

image

# sentinel monitor <master-name> <master-host> <master-port> <quorum>
# 举例如下:
sentinel monitor master 127.0.0.1 6379 2

这条配置项用于告知哨兵需要监听的主节点:
1、sentinel monitor:监控标识
2、mymaster:这边可以放上主节点的名称
3、192.168.11.128 6379:代表监控的主节点 ip,port。6379是redis常规端口。
4、2:判定的sentinel数量,果你有3个 Sentinel,并且 quorum 设置为 2,那么至少需要有2个 Sentinel 认定 Master 节点不可用时(sdown),才会触发故障转移,执行 failover 操作。

image

3.1.2 监控和通信逻辑

1. 哨兵(Sentinel)与主节点(Master)之间

  • Sentinel通过定期(1s一次心跳包)向主节点发送PING命令来检查其状态
  • Sentinel启动后根据配置向Master发送
    INFO
    指令,获取并保存所有哨兵(Sentinel)状态,主节点(Master)和从节点(Slave)信息。
  • 主节点(Master)会记录所有从节点(Slave)和与它连接的哨兵(Sentinel)实例的信息。

2. 哨兵(Sentinel)与从节点(Slave)之间

  • 从上面得知,Sentinel向Master发送
    INFO
    命令,并获取所有Slave的信息
  • Sentinel 根据 Master 返回的 Slave 列表,逐个与 Salve 建立连接,同样的定期向从节点发送PING命令来检查它们的状态

3. 集群中的哨兵(sentinel)之间实现通信

使用Redis的pub/sub 订阅能力实现哨兵间通信 和 Slave 发现。

哨兵之间可以相互通信,主要归功于 Redis 的 pub/sub (发布/订阅)机制。Master 有一个
__sentinel__:hello
的专用通信通道,用于哨兵之间发布和订阅消息。哨兵与 Master 建立通信之后,就可以利用 Master 提供发布/订阅机制发布自己的IP、Port等信息,同时订阅其他Sentinel发布的Name、IP、Port消息。

  • Sentinel 建立与 Master 的通信
  • 通过订阅Master的
    __sentinel__:hello
    频道,当自身节点启动或更新其状态时,重新发布自己的当前状态和信息(Name、IP、Port消息)
  • 同时订阅其他哨兵发布的Name、IP、Port消息
  • 互相发现之后建立起了连接,后续的消息通信就可以直接进行交互

★ 有没有觉得套路很熟悉,这个与微服务中的服务注册与发现,以及RPC通信类似的做法。请理解清楚图中1、2、3步骤。
image

4. 标记下线的过程
我们上面说过了,Sentinel进程启动之后,会定期(1s一次心跳包)向主节点发送PING命令来检查其状态,检查看状态是否正常响应。

  • 如果Slave 没有在规定的时间内响应 Sentinel 的 PING 命令 , Sentinel 会认为该实例已经挂了,将它tag为下线状态(offline)。
  • 同理,如果Master 没有在规定时间响应 Sentinel 的 PING 命令,也会被判定为 offline 状态,为后续的主从自动切换做好准备工作。

3.2 主从动态切换(故障转移)

当master出现故障之后,Sentinel 的一个很核心的作用,就是从多个Slave中选举出一个新的Master,以达到故障转移的目的。核心步骤如下:

  1. 哨兵会心跳包定时给主节点发送
    publish sentinel :hello
    ,如果超时不响应则标记 主观下线(sdown)。超时时间配置
    down-after-milliseconds
    前面说过了。
  2. 哨兵标记主节点 sdown 只是单个哨兵行为,需要往Sentinel集群发布消息说明这个主节点挂了,发送的指令
    sentinel is-master-down-by-address-port
  3. 其余的哨兵接收到指令后,也对Master进行探活,如果收不到响应同样标记
    sdown
    ,同时发送指令
    sentinel is-master-down-by-address-port
    到Sentinel内网,这样哨兵内部群会再收到 Master 挂了的消息。
  4. 汇总计票,超过半数(通过
    quorum
    配置)就认为Master节点确实不行了,然后修改其状态为
    odown
    , 既客观下线。注意哨兵总数尽量为单数,避免『脑裂』。
  5. 一旦认为主节点
    odown
    后,哨兵就会进行选举新Master的工作,这很重要。
  6. 选举新的Master,由指定的哨兵进行选举。选举条件:
    • 响应慢的过滤掉,Sentinel会给所有的Redis从节点发送信息,响应速度慢的就会被优先过滤掉,说明健壮性不够。
    • 判断 offset 偏移量,选择数据偏移量差距最小的,即
      slave_repl_offset

      master_repl_offset
      的进度差距,其实就是比较 Slave 与 原 Master 复制进度差距。 假如 slave2 的 offset 为90, slave1 偏移量 为100 那么哨兵就会认为slave2的网络不佳,优先选择slave1为新的主节点。
    • slave runID,在优先级和复制进度都相同的情况下,选用runID最小的,runID越小说明创建时间越早,优先选为Master,先来后到原则。

等这几个条件都评估完,我们就会选择出最合适的Slave,把他推举为新的Master。
image

3.3 信息通知

等推选出最新的Master之后,后续所有的写操作都会进入这个Master中。所以需要尽快广播通知到所有的Slave,让他们重新
replacaof
到 Master上,重新建立
runID

slave_repl_offset
,来保证数据的正常传输和主从一致性。

4 总结

Redis 哨兵机制是实现 Redis 高可用的核心手段,相比之前的《
Redis高可用之战:主从架构
》更具
自动化和时效性。
它的核心功能职责如下:

  • 集群监控:
    哨兵模式的主要任务之一是监控Redis主从复制集群中的各个节点。它会定期检查主节点和从节点的健康状态,确保它们都在正常运行。
  • 故障检测与通知:
    当检测到主节点出现故障或不可用时,哨兵会立即发送报警通知给其他哨兵。这有助于及时发现并处理潜在的问题。
  • 自动故障转移
    :在检测到主节点故障后,哨兵会自动触发故障转移机制。它会选择一个健康的从节点,将其提升为新的主节点,并通知其他从节点更新复制目标。这样,整个系统可以在主节点故障时保持可用性。
  • 配置更新与通知
    :在故障转移完成后,哨兵会更新相关配置,并将新的主节点地址通知给客户端。这确保了客户端可以连接到新的主节点并继续进行操作。

基于OT与CRDT协同算法的文档划词评论能力实现

当我们实现在线文档平台时,划词评论的功能是非常必要的,特别是在重文档管理流程的在线文档产品中,文档反馈是非常重要的一环,这样可以帮助文档维护者提高文档质量。而即使是单纯的将划词评论作为讨论区,也是非常有用的,尤其是在文档并不那么完善的情况下,对接产品系统的时候可以得到文档之外的输入。那么本文将通过引入协同算法来解决冲突,从而实现在线文档的划词评论能力。

我们即将要聊的
OT

CRDT
的实现分别会有相关示例:

如果想了解关于协同的相关内容,也可以参考之前的文章:

描述

实际上实现划词评论在交互上并不是非常困难的事,我们可以先简单设想一下,无非是在文档中选中文本,然后在
onMouseUp
事件唤醒评论的按钮,当用户点击按钮时输入评论的内容,然后将评论的位置和数据传输到持久化存储即可。在这里不禁让我想起来了一个著名的问题,把大象放进冰箱需要几步?答案是三步:把冰箱门打开,把大象放进去,把冰箱门关上。而把长颈鹿放进冰箱需要四步:把冰箱门打开,把大象拿出来,把长颈鹿放进去,把冰箱门关上。

我们的划词评论也很像将大象放进冰箱,那么这个问题难点究竟是什么,很明显我们不容易找到评论的位置,如果此时不是富文本编辑器的话,我们可以考虑一种方案,即将
DOM
的具体层级存储起来,也就是保存一个路径数组,在渲染以及
Resize
的时候将其重新查找并计算即可。当然如果情况允许的话,对于每个文本节点都放置一个
id
,然后持久化的时候存储
id

offset
即可,只不过通常不太容易具备这种条件,入侵性太强且可能需要改造数据
->
渲染的存储结构,也就是说这个
id
是需要幂等地渲染,即多次渲染不会改变
id
,这样对于数据的存储也是额外增加了负担,当然如果对于位置计算比较复杂的话,这种空间换时间的实现也是可取的。

那么对于静态的内容,我们可能有很多办法来解决划词位置的持久化问题,而我们的在线文档是动态的内容,我们需要考虑到文档的变更,而文档内容的变更就有可能影响到划词位置的改变。例如原本划词的位置是
[2, 6]
,而此时在
0
位置上加入了文本或者图片等内容,此时如果还保持着
[2, 6]
的位置,那么划词的位置就不正确了,所以我们需要引入协同算法来解决这个问题,相当于
follow
文档的变更,重新计算划词的位置。请注意,在这里我们讨论的是非协同场景下的划词评论能力,如果此时文档系统已经引入了在线协同编辑的能力,那么基本就不需要考虑位置的计算问题,此时我们可以直接将后端同样作为一个协同编辑的客户端,直接使用协同算法来解决位置变换的问题。

OT

那么首先我们来聊一聊编辑时的评论位置同步,通常划词评论会分为两部分,一部分是在文档中划词的位置展示,另一部分是右侧的评论面板。那么在这里我们主要讨论的是文档中划词的位置展示,也就是如何在编辑的时候保持划词评论位置的正确
follow
,此部分的相关代码都在
https://github.com/WindrunnerMax/QuillBlocks/blob/master/examples/comment-ot.html
中。

我们可以设想一个问题,实际上在文档中的划词部分对于编辑器来说仅仅就是一个样式而已,与加粗等样式没有什么本质上的区别,也就是说我们可以通过在
attributes
上增加类似于
{ comment: id }
的形式将其表达出来。那么这种方式是能够正常跟随编辑区域移动的,本质上是编辑器引擎帮我们实现了这部分能力,对于这种方式我们可以在编辑器中轻松地实现,只需要对选区中的内容做
format
即可。

const onFormatComment = (id: string) => {
  editor.format("comment", id);
}

这种方案是最方便的实现方式,但是这里需要注意的是,此时我们是非协同场景下的划词评论,因为不存在协同编辑的实现,我们通常都是需要使用编辑锁来防止内容覆盖的问题。那么这种划词评论方式的问题是我们需要在内容中写入数据,相当于所有有权限的人都可以在整个流程中写入数据,所以我们通常不能将全量的数据存储到后端,而是应该在后端同样对数据做一次
format
,并且保持好多端的数据同步,否则在多人同时评论的场景下例如审核的流程中,存在内容覆盖的风险。

实际上我们可以发现,上述的方式是通过保证文档实例只存在一份的方式来实现的,也就是说无论是处于草稿状态下的编辑锁,还是审核状态下的评论能力,都是操作了同一份文档。那么如果场景再复杂一些,此时我们的文档平台存在线上态和草稿态,线上状态和草稿状态都分别可以评论,当然这里通常是分开管理的,草稿态是内部对文档的修改标注和评审意见等,线上态的评论主要是用户的反馈和讨论等,那么在编辑态的方案我们上边已经比较清晰地实现了,那么在线上状态的评论就没有这么简单了。试想一个场景,此时我们对文档发布了一个版本
A
,而在后台又将文档编辑了一部分此时内容为
B
版本,用户此时在
A
版本上评论了内容,然而此时我们的文档已经是
B
版本了,如何将用户评论的内容同步到
B
版本,以便于我们发布
C
版本的时候能够正确保持用户的评论位置,这就是我们即将要讨论的问题。

在讨论具体的问题之前,我们不妨先考虑一下这个问题的本质,实质上就是需要我们根据文档的改变来
transform
评论的位置,那么我们不如直接将这部分实现先抽象一下,将这个复杂的问题原子化实现一下,那么首先我们先定义一个选区列表用来存储评论的位置。

const COMMENT_LIST = [
  { index: 12, length: 2 },
  { index: 17, length: 4 },
];

因为先前我们是使用
format
来实现的,也就是将评论的实质性地写入到了
delta
当中,而在这里为了演示实际效果,此处的评论是使用虚拟图层的方式实现的,关于虚拟图层的实现我们在先前的 文档
diff
算法实现与对比视图 中已经抽象出来了通用能力,在这里就不具体展开了。使用虚拟图层实现就是相当于我们的数据表达是完全脱离于
delta
,也就是意味着我们可以将其独立存储起来,这样就可以做到完全的状态分离。

那么接下来就是在视图初始化时将虚拟图层渲染上去,并且为我们先前定义的评论按钮加入事件冒泡和默认行为的阻止,特别是我们不希望在点击评论按钮的时候失去编辑器的焦点,所以需要阻止其默认行为。

const applyComment = document.querySelector(".apply-comment");
applyComment.onmousedown = e => {
  e.stopPropagation();
  e.preventDefault();
};

接下来我们需要关注于点击评论按钮需要实现的功能,实际上也比较简单,主要是将选区的位置存储起来,然后将其渲染到虚拟图层上,最后将选区的位置移动到评论的位置上,也就是将选区折叠起来。

applyComment.onclick = e => {
  const selection = editor.getSelection();
  if (selection) {
    const sel = { ...selection };
    console.log("添加评论:", sel);
    COMMENT_LIST.push(sel);
    editor.renderLayer(COMMENT_LIST);
    editor.setSelection(sel.index + sel.length);
  }
};

最后的重点来了,当我们编辑的时候会触发内容变更的事件,在这里是原子化的
op/delta
,那么我们就可以借助于这个
op
来对评论的位置进行
transform
,也就是说此时评论的位置会根据
op
的变化来重新计算,最后将评论的虚拟图层全部渲染出来。由此可以看到当我们编辑的时候,评论是会正常跟随我们的编辑进行位置变换的。而实际上我们不同版本的文档评论的位置同步也是类似的,只不过是单个
op
还是多个
op
的问题,而本身
op
又是可以进行
compose

ops
的,所以本质上就是同一个问题,那么就可以通过类似的方案解决问题。

editor.on("text-change", delta => {
  for(const item of COMMENT_LIST){
    const { index, length } = item;
    const start = delta.transformPosition(index);
    const end = delta.transformPosition(index + length, true);
    item.index = start;
    item.length = end - start;
  }
  editor.renderLayer(COMMENT_LIST);
});

在这里我们需要再看一下
transformPosition
这个方法,这个方法是根据
delta
变换索引,对于表示光标以及选区相关的操作时很有用,而第二个参数是比较有迷惑性的,我们可以借助
transform
方法来表示这个参数的意义,如果是
true
,那么就表示
this
的行为被认为是
first
也就是首先执行的
delta
,因为我们的
delta
都是从
0
开始索引位置,而对于同样的位置进行操作则会产生冲突,所以需要此标识来决定究竟谁在前,或者说谁是先执行的
delta

const a = new Delta().insert('a');
const b = new Delta().insert('b').retain(5).insert('c');

// Ob' = OT(Oa, Ob)
a.transform(b, true);  // new Delta().retain(1).insert('b').retain(5).insert('c');
a.transform(b, false); // new Delta().insert('b').retain(6).insert('c');

回到我们线上文档也就是消费侧评论的场景,我们不如举一个具体的例子来描述要解决的问题,此时线上文档的状态是
A
内容是
yyy
,在草稿态我们又重新编辑了文档,此时文档的状态是
B
内容是
xxxyyy
,也就是在
yyy
前边加了
3

x
字符,那么此时有个用户在线上
A
版本划词评论了内容
yyy
,此时的标记索引是
[0, 3]
,过后我们将
B
版本发布到了线上,如果此时评论还保持着
[0, 3]
的位置,那么就会出现位置不正确的问题,此时评论标记内容将会是
xxx
,并不符合用户最初划词的内容,所以我们需要将评论的位置根据
A -> B
的变化来重新计算,也就是将
[0, 3]
变换为
[3, 6]

实际上这里有个点需要注意的是,我们并不会将消费侧的评论同步到草稿状态上,如果此时用户正在评论且作者正在写文档的话,这个状态同步将会是比较麻烦的问题,相当于实现了简化的协同编辑,复杂性上升且不容易把控,在这种情况下甚至可以直接考虑接入成熟的协同系统。那么根据我们之前实现的原子化的
transform
评论位置的方法,我们只需要将版本
A
到版本
B
的变更
ops
找出来,并且将评论的位置根据
delta
进行
transform
即可,那么如何找出
A

B
的变更呢,这里就有两个办法:

  • 一种方案是记录版本之间的
    ops
    ,实际上我们的线上状态文档和草稿状态的文档并不是完全不相关的两个文档,草稿状态实际上就是由前一个线上文档版本得到的,那么我们就完全可以将文档变更时的
    ops
    完整记录下来,需要的时候再取得相关的
    ops
    进行
    transform
    即可,这种方式实际上是实现
    OT
    协同的常见操作,并且通过记录
    ops
    的方式可以更方便地实现 细粒度的操作记录回滚、字数变更统计、追溯字粒度的作者 等等。
  • 另一种方案是在发布时对版本内容做
    diff
    ,如果我们的在线文档系统最开始就没有设计
    ops
    的记录以及做协同能力的储备的话,突然想加入相关的能力成本是会比较高的,而我们如果单单为了评论就引入完整的协同能力显然并不是那么必要,所以此时我们直接对两个版本做
    diff
    就可以以更加低成本的方式实现评论的位置同步,关于
    diff
    的性能消耗可以参考之前的 文档
    diff
    算法实现与对比视图 中的相关内容。

那么先前我们实现的方案可以看作是记录了
ops
的方案,接下来我们以上述的例子演示一下基于
diff
的实现算法,首先将线上状态
online

draft
的表达按照之前的例子表示出来,然后标记出评论的位置,在这里需要注意的是评论的位置是我们数据库持久化存储的内容,实际做
transform
时需要将其转换为
delta
表达,之后将线上内容和评论
compose
,这就是实际上要展示给用户带评论划线的内容,然后对线上和草稿状态做
diff

diff
的顺序是
online -> draft
,下面就是将评论的内容进行
transform
,这里需要注意的是我们是需要在
diff
的基础上做
comment
变换,因为我们的
draft
相当于已经应用了
diff
,所以根据
Ob' = OT(Oa, Ob)
,我们实际想要得到的是
Ob'
,之后新的
comment
表达应用到
draft
上即可得到最终的评论内容。可以看到我们的评论是正确
follow
了原来的位置,此外因为最终还是要把新的评论位置存储到数据库中,所以我们需要将
delta
转换为
index

length
的形式存储,也可以在做
transform
时直接使用
transformPosition
来构造新的位置,然后根据新的位置构造
delta
表达来做应用与存储。

const Delta = Quill.import("delta");
// 线上状态
const online = new Delta().insert("yyy");
// 草稿状态
const draft = new Delta().insert("xxxyyy");
// 评论位置
const comment = { index: 0, length: 3 };
// 评论位置的`delta`表示
const commentDelta = new Delta().retain(comment.index).retain(comment.length, { id: "xxx" });
// 线上版本展示的实际内容
const onlineContent = online.compose(commentDelta);
// [{ "insert": "yyy", "attributes": { "id": "xxx" } }]
// `diff`结果
const diff = online.diff(draft); // [{ "insert": "xxx" }]
// 更新的评论`delta`表示
const nextCommentDelta = diff.transform(commentDelta); 
// [{ "retain": 3 }, { "retain": 3, "attributes": { "id": "xxx" } }]
// 更新之后的线上版本实际内容
const nextOnlineContent = draft.compose(nextCommentDelta);
// [{ "insert": "xxx" }, { "insert": "yyy", "attributes": { "id": "xxx" } }]

此外使用
diff
来实现评论的同步时,还有一个需要关注的点是采用
diff
的方案可能会存在意图不一致的问题,,统一进行
diff
计算而不是完整记录
ops
可能会存在数据精度上的损失,例如此时我们有
N
个连续的
xxx
块,编辑时删除了某个
xxx
块,此块上又恰好携带了消费侧的评论,如果按照我们的实际意图来计算,下次发布新版本时这个评论应该会消失或者被收起来,然而事实上可能并不如此,因为
diff
的时候是根据内容来计算的,究竟删除的是哪个
xxx
块只是算法上的解而非是意图上的解,所以在这种情况下如果我们需要保证完整的意图的话就需要引入额外的标记信息,或者采用第一种方案来记录
ops
,甚至完整引入协同算法,这样才能保证我们的意图是完整的。

在这里聊了这么多关于评论位置的记录与变换操作,别忘了我们还有右侧的评论面板部分,这部分实际上没有涉及到很复杂的操作,通常只需要跟文档编辑器通信来获取评论距离文档顶部的实际
top
来做位置计算即可,可以直接使用
CSS

transform: translateY(Npx);
,当然这里边细节还是很多的,例如 何时更新评论位置、避免多个评论卡片重叠、选择评论时可能需要移动评论卡片 等等,交互上需要的实现比较多。当然实现展示评论的交互还有很多种,例如
Hover
或者点击文档内评论时展示具体的评论内容,这些都是可以根据实际需求来实现的。

当然这里还有个可以关注的点,就是如何获取评论距离文档顶部的位置,通常编辑器内部会提供相关的
API
,例如在
Quill
中可以通过
editor.getBounds(index, 0)
来获取具体选区的
rect
。那么为什么需要关注这里呢,因为这里的实现是比较有趣的,因为我们的选区并不一定是个完整的
DOM
,可能存在只选择了一个文本表达的某
N
个字,我们不能直接取这个
DOM
节点的位置,因为可能这是个长段落发生了很多次折行,高度实际上是发生偏移的,那么在这种情况下我们就需要构造
Range
并且使用
Range.getClientRects
方法来得到选区信息了,当然通常我们是可以直接取选区的首个位置即直接使用
Range.getBoundingClientRect
就可以了,在获取这部分位置之后我们还需要根据编辑器的位置信息作额外计算,在这里就不赘述了。

const node = $0; // <strong>123123</strong>
const text = node.firstChild; // "123123"
const range = new Range();
range.setStart(text, 0);
range.setEnd(text, 1);
const rangeRect = range.getBoundingClientRect();
const editorRect = editor.container.getBoundingClientRect();
const selectionRect = editor.getBounds(editor.getSelection().index, 0);
rangeRect.top - editorRect.top === selectionRect.top; // true
rangeRect.left - editorRect.left === selectionRect.left; // true

CRDT

在上述的实现中我们使用了
OT
的方式来解决评论位置的同步问题,而本质上我们就是通过协同来解决的同步问题,那么同样的我们也可以使用
CRDT
的协同方案来解决这个问题,那么在这里我们使用
yjs
来实现与上述
OT
的功能类似的评论位置同步,此部分的相关代码都在
https://codesandbox.io/p/devbox/comment-crdt-psm548
中。

首先我们需要定义
yjs
的数据结构即
Y.Doc
,然后为了方便我们直接采用
indexeddb
作为存储而不是使用
websocket
来与后端
yjs
通信,由此我们可以直接在本地进行测试,在
yjs
中内置了
getText
的富文本数据结构表达,实际上在使用上是等同于
quill-delta
的数据结构,并且使用
yjs
提供的
y-quill
将数据结构与编辑器绑定。

const ydoc = new Y.Doc();
new IndexeddbPersistence("y-indexeddb", ydoc);
// ...
const ytext = ydoc.getText("quill");
new QuillBinding(ytext, editor);

紧接着我们同样初始化一个评论列表,这就是我们持久化存储的内容,与之前不同的是此时我们存储的是
CRDT
的相对位置,也就是说我们存储的是
yjs

RelativePosition
,这个位置是相对于文档的位置而不是绝对的
index
,这是由协同算法的特性决定的,在这里就不具体展开了,有兴趣的话可以看一下之前的
OT
协同算法、
CRDT
协同算法 文章的相关内容。然后我们需要初始化虚拟图层的实现,在这里我们同样借助虚拟图层来实现评论的位置展示,接下来我们需要在具体渲染之前,将相对位置转换为绝对位置。这里需要注意的是,我们创建相对位置时时使用的
yText
,而通过相对位置创建绝对位置时是使用的
yDoc

const COMMENT_LIST: [string, string][] = [];
const layerDOM = initLayerDOM();
const renderAllCommentWithRelativePosition = () => {
  const ranges: Range[] = [];
  for (const item of COMMENT_LIST) {
    const start = JSON.parse(item[0]);
    const end = JSON.parse(item[1]);
    const stratPosition = Y.createAbsolutePositionFromRelativePosition(
      start,
      ydoc,
    );
    const endPosition = Y.createAbsolutePositionFromRelativePosition(end, ydoc);
    if (stratPosition && endPosition) {
      ranges.push({
        index: stratPosition.index,
        length: endPosition.index - stratPosition.index,
      });
    }
  }
  renderLayer(layerDOM, ranges);
};

同样的,我们依然需要为我们先前定义的评论按钮加入事件冒泡和默认行为的阻止,特别是我们不希望在点击评论按钮的时候失去编辑器的焦点,所以需要阻止其默认行为。

const applyComment = document.querySelector(".apply-comment") as HTMLDivElement;
applyComment.onmousedown = (e) => {
  e.stopPropagation();
  e.preventDefault();
};

接下来我们需要关注于点击评论按钮需要实现的功能,此时我们需要将选区的内容转换为相对位置,通过
createRelativePositionFromTypeIndex
方法可以根据我们的数据类型与索引值取得
client

clock
用以标识全序的相对位置,取得相对位置之后我们将其存储到
COMMENT_LIST
中,然后将其渲染到虚拟图层上,最后同样将选区的位置移动到评论的位置上。

applyComment.onclick = () => {
  const selection = editor.getSelection();
  if (selection) {
    const sel = { ...selection };
    console.log("添加评论:", sel);
    const start = Y.createRelativePositionFromTypeIndex(ytext, sel.index);
    const end = Y.createRelativePositionFromTypeIndex(
      ytext,
      sel.index + sel.length,
    );
    COMMENT_LIST.push([JSON.stringify(start), JSON.stringify(end)]);
    renderAllCommentWithRelativePosition();
    editor.setSelection(sel.index + sel.length);
  }
};

那么最后我们在文本内容发生变动的时候重新渲染即可,因为是标识了相对位置,在这里我们不需要对选区作
transform
,我们只需要重新渲染虚拟图层即可。通过添加评论并且编辑内容之后,发现我们的评论位置也是能够正常
follow
初始选区的,那么由此也可以说明
CRDT
能够根据相对位置实现评论位置的同步,我们不需要为其作
transform
或者
diff
的操作,只需要保持数据结构是完整存储与更新即可,而之后的评论面板部分内容是基本一致的实现,通过
Range
对象的操作来获取评论的位置,然后根据编辑器的位置信息作高度计算即可。

editor.on("text-change", () => {
  renderAllCommentWithRelativePosition();
});

每日一题

https://github.com/WindrunnerMax/EveryDay

参考

https://quilljs.com/docs/delta
https://docs.yjs.dev/api/relative-positions
https://www.npmjs.com/package/quill-delta/v/4.2.2

引言

OpenTenBase 企业级分布式HTAP开源数据库,具备高扩展性、商业数据库语法兼容、分布式HTAP引擎、多级容灾和多维度资源隔离等能力,成功应用在金融、医疗、航天等行业的核心业务系统。

image

这是我第一次参与开源项目,所以感到有些摸不着头脑。我看了一下源代码,发现它是用C语言开发的,这让我更加头疼。不过,不管是哪个开源项目,都会存在各种问题(issue)。选择一些简单或中级的开发任务来练习还是一个不错的主意。毕竟,这是一个锻炼自己的机会。在现实工作中,没有任何公司会容忍一个缺乏基础的开发人员去练习,因为那可能导致生产事故。因此,开源项目是一个快速提升自己能力的途径。

如果你也对此感兴趣,可以参考这个链接:
https://competition.atomgit.com/?utm_source=atomgit

要开始参与项目,首先要从部署和启动开始。如果没有本地的开发环境,是不可能真正加入其中的。

开始踩坑

官方源码地址:
git clone https://github.com/OpenTenBase/OpenTenBase

在这篇文章中,我以Centos 8为例展示了如何进行部署。如果你需要了解基本的安装操作步骤,可以参考这个链接:
https://docs.opentenbase.org/guide/01-quickstart

我就不再一一演示这些基本步骤了,而是想分享一些官方文档中没有提及的各种奇葩问题的解决方法。

uuid-devel匹配不到

上来第一步就发现了问题,当执行环境依赖安装时
yum -y install gcc make readline-devel zlib-devel openssl-devel uuid-devel bison flex git

在Centos 8系统上,可能会遇到一个错误提示:找不到 uuid-devel 软件包。这是因为在Centos 8的默认软件仓库中找不到 uuid-devel 软件包,尽管 uuid-devel 实际上是一个必需的依赖项。此外,安装类似uuid依赖包也无法解决问题,否则在执行configure命令时可能会出现错误提示:
configure: error: library 'ossp-uuid' or 'uuid' is required for OSSP UUID

幸运的是,CentOS的“PowerTools”软件库中包含了 uuid-devel 软件包,但默认情况下未启用。要启用该软件库,可以使用以下命令
dnf config-manager --set-enabled powertools
,如果没有dnf命令,则执行一下:
yum install dnf-plugins-core

configure: error: readline library not found

在执行configure命令时报错:configure: error: readline library not found

如果遇到这个问题,可以尝试执行以下命令来安装必要的依赖包:
yum -y install gcc make readline-devel
即可

确保所有的依赖环境都已安装完毕后,再执行make -sj命令。在执行这一步之前,请确保剩余可用内存大于等于4G,以避免内存溢出问题。尽管官方文档建议最低内存为4G,但我建议你将内存扩大至8G,以确保后续执行init all命令时不会遇到各种奇怪的问题。切记,不要将内存设置得过低,否则可能会导致后续命令的异常行为。

环境及ssh

执行
vim ~/.bashrc
编辑系统环境变量后记得source ~/.bashrc,要不然无法找到命令pgxc_ctl

在集群部署过程中,只有一台服务器需要进行编译操作,其他服务器只需进行环境变量配置、用户及目录设置以及SSH连接的配置。这样设计的原因是因为在执行deploy all命令时,已经编译好的安装包会被发送到其他机器上。

为了实现集群节点机器之间的SSH无密码登录,首先需要在各个节点机器上配置好SSH密钥认证。这样一来,在部署和初始化过程中,可以通过SSH连接到每个节点的机器而无需输入密码。在这个过程中,需要确保已经打通了第二台及其IP的SSH连接,并且也打通了自己机器的SSH连接。

ssh-copy-id -i ~/.ssh/id_rsa.pub destination-user@destination-server

启动和节点排查

在进行集群部署时,接下来的步骤是使用pgxc_ctl进行部署。如果对pgxc_ctl的命令不熟悉,可以通过使用help命令来查看帮助文档。在我的机器上,当我运行monitor all命令时,只能显示一个信息然后程序强制退出,这表明肯定有节点启动失败了。因此,建议单独使用monitor命令来查看各个节点的状态,以便更清楚地了解每个节点的运行情况。

image

如果某一个一直无法正常启动,比如显示
gtm_ctl: another server might be running; trying to start server anyway
,那么可能会是你没有正常关闭,通常需要你手动去删除对应的pid文件,

本次以gtm为例,如果不知道的pid文件位置在哪里,那么可以使用
find / -name '*gtm*.pid'
,找到后删除对应的文件即可。然后再次启动start all。

如果还是无法启动,那么则可以去看下对应日志,还以gtm为例。
cd /data/opentenbase/data/gtm/slave/gtm_log
进入对应日志目录,然后查看日志。我这里显示的最后是

image

建议考虑进行扩容操作。显然这里资源不足。如果你的内存已经达到了8GB,那么可以考虑进一步扩展CPU资源至2核心。我目前的系统只有1核心的CPU,显然已经不够用了,扩容后系统性能应该会恢复正常。

image

总结

通过本次经历,我了解了OpenTenBase开源项目的基本情况以及参与该项目的一些经验和技巧。作为一个初次参与开源项目的成员,建议选择简单或中级的开发任务来练习,因此,通过参与开源项目,我们不仅可以提升自己的技术能力,还可以学习到解决问题的方法,为未来的工作和学习打下坚实的基础。

目录:

  1. OpenID 与 OAuth2 基础知识
  2. Blazor wasm Google 登录
  3. Blazor wasm Gitee 码云登录
  4. Blazor OIDC 单点登录授权实例1-建立和配置IDS身份验证服务
  5. Blazor OIDC 单点登录授权实例2-登录信息组件wasm
  6. Blazor OIDC 单点登录授权实例3-服务端管理组件
  7. Blazor OIDC 单点登录授权实例4 - 部署服务端/独立WASM端授权
  8. Blazor OIDC 单点登录授权实例5 - 独立SSR App (net8 webapp)端授权
  9. Blazor OIDC 单点登录授权实例6 - Winform 端授权
  10. Blazor OIDC 单点登录授权实例7 - Blazor hybird app 端授权

(目录暂时不更新,跟随合集标题往下走)

源码

BlazorOIDC.WinForms

建立 BlazorOIDC.WinForms 工程

自行安装 Vijay Anand E G 模板,快速建立 Blazor WinForms 工程, 命名为
BlazorOIDC.WinForms

引用以下库

    <ItemGroup>
        <PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="8.0.4" />
        <PackageReference Include="Microsoft.AspNetCore.Components.WebView.WindowsForms" Version="8.*" />
        <PackageReference Include="Microsoft.Extensions.Hosting" Version="8.*" />
        <FrameworkReference Include="Microsoft.AspNetCore.App"></FrameworkReference>
        <PackageReference Include="IdentityModel.OidcClient" Version="5.2.1" />
    </ItemGroup>

_Imports.razor 加入引用

@using Microsoft.AspNetCore.Components.Authorization

Main.razor 加入授权

完整代码

<CascadingAuthenticationState>
    <Router AppAssembly="@GetType().Assembly">
        <Found Context="routeData">
            <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
        </Found>
        <NotFound>
            <LayoutView Layout="@typeof(MainLayout)">
                <p>Sorry, there's nothing at this address.</p>
            </LayoutView>
        </NotFound>
    </Router>
</CascadingAuthenticationState>

添加Oidc授权配置

新建文件
ExternalAuthStateProvider.cs

完整代码

using IdentityModel.OidcClient;
using Microsoft.AspNetCore.Components.Authorization;
using System.Security.Claims;

namespace BlazorOIDC.WinForms;

public class ExternalAuthStateProvider : AuthenticationStateProvider
{
    private readonly Task<AuthenticationState> authenticationState;

    public ExternalAuthStateProvider(AuthenticatedUser user) =>
        authenticationState = Task.FromResult(new AuthenticationState(user.Principal));

    private ClaimsPrincipal currentUser = new ClaimsPrincipal(new ClaimsIdentity());

    public override Task<AuthenticationState> GetAuthenticationStateAsync() =>
        Task.FromResult(new AuthenticationState(currentUser));

    public Task<AuthenticationState> LogInAsync()
    {
        var loginTask = LogInAsyncCore();
        NotifyAuthenticationStateChanged(loginTask);

        return loginTask;

        async Task<AuthenticationState> LogInAsyncCore()
        {
            var user = await LoginWithExternalProviderAsync();
            currentUser = user;

            return new AuthenticationState(currentUser);
        }
    }

    private async Task<ClaimsPrincipal> LoginWithExternalProviderAsync()
    {
        /*
            提供 Open ID/MSAL 代码以对用户进行身份验证。查看您的身份
            提供商的文档以获取详细信息。

            根据新的声明身份返回新的声明主体。
        */

        string authority = "https://localhost:5001/";
        //string authority = "https://ids2.app1.es/"; //真实环境
        string api = $"{authority}WeatherForecast";
        string clientId = "Blazor5002";

        OidcClient? _oidcClient;
        HttpClient _apiClient = new HttpClient { BaseAddress = new Uri(api) };

        var browser = new SystemBrowser(5002);
        var redirectUri = string.Format($"http://localhost:{browser.Port}/authentication/login-callback");
        var redirectLogoutUri = string.Format($"http://localhost:{browser.Port}/authentication/logout-callback");

        var options = new OidcClientOptions
        {
            Authority = authority,
            ClientId = clientId,
            RedirectUri = redirectUri,
            PostLogoutRedirectUri = redirectLogoutUri,
            Scope = "BlazorWasmIdentity.ServerAPI openid profile",
            //Scope = "Blazor7.ServerAPI openid profile",
            Browser = browser,
            Policy = new Policy { RequireIdentityTokenSignature = false }

        };

        _oidcClient = new OidcClient(options);
        var result = await _oidcClient.LoginAsync(new LoginRequest());
        ShowResult(result);

        var authenticatedUser = result.User;

        return authenticatedUser;
    }

    private static void ShowResult(LoginResult result, bool showToken = false)
    {
        if (result.IsError)
        {
            Console.WriteLine("\n\nError:\n{0}", result.Error);
            return;
        }

        Console.WriteLine("\n\nClaims:");
        foreach (var claim in result.User.Claims)
        {
            Console.WriteLine("{0}: {1}", claim.Type, claim.Value);
        }

        if (showToken)
        {
            Console.WriteLine($"\nidentity token: {result.IdentityToken}");
            Console.WriteLine($"access token:   {result.AccessToken}");
            Console.WriteLine($"refresh token:  {result?.RefreshToken ?? "none"}");
        }
    }

    public Task Logout()
    {
        currentUser = new ClaimsPrincipal(new ClaimsIdentity());
        NotifyAuthenticationStateChanged(
            Task.FromResult(new AuthenticationState(currentUser)));
        return Task.CompletedTask;
    }
}

public class AuthenticatedUser
{
    public ClaimsPrincipal Principal { get; set; } = new();
}

添加Oidc浏览器授权方法

新建文件
SystemBrowser.cs

完整代码

using IdentityModel.OidcClient.Browser;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using System.Runtime.InteropServices;
using System.Text;
#nullable disable

namespace BlazorOIDC.WinForms;

public class SystemBrowser : IBrowser
{
    public int Port { get; }
    private readonly string _path;

    public SystemBrowser(int? port = null, string path = null)
    {
        _path = path;

        if (!port.HasValue)
        {
            Port = GetRandomUnusedPort();
        }
        else
        {
            Port = port.Value;
        }
    }

    private int GetRandomUnusedPort()
    {
        var listener = new TcpListener(IPAddress.Loopback, 0);
        listener.Start();
        var port = ((IPEndPoint)listener.LocalEndpoint).Port;
        listener.Stop();
        return port;
    }

    public async Task<BrowserResult> InvokeAsync(BrowserOptions options, CancellationToken cancellationToken = default)
    {
        using (var listener = new LoopbackHttpListener(Port, _path))
        {
            OpenBrowser(options.StartUrl);

            try
            {
                var result = await listener.WaitForCallbackAsync();
                if (string.IsNullOrWhiteSpace(result))
                {
                    return new BrowserResult { ResultType = BrowserResultType.UnknownError, Error = "Empty response." };
                }

                return new BrowserResult { Response = result, ResultType = BrowserResultType.Success };
            }
            catch (TaskCanceledException ex)
            {
                return new BrowserResult { ResultType = BrowserResultType.Timeout, Error = ex.Message };
            }
            catch (Exception ex)
            {
                return new BrowserResult { ResultType = BrowserResultType.UnknownError, Error = ex.Message };
            }
        }
    }

    public static void OpenBrowser(string url)
    {
        try
        {
            Process.Start(url);
        }
        catch
        {
            // hack because of this: https://github.com/dotnet/corefx/issues/10361
            if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
            {
                url = url.Replace("&", "^&");
                Process.Start(new ProcessStartInfo("cmd", $"/c start {url}") { CreateNoWindow = true });
            }
            else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
            {
                Process.Start("xdg-open", url);
            }
            else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
            {
                Process.Start("open", url);
            }
            else
            {
                throw;
            }
        }
    }
}

public class LoopbackHttpListener : IDisposable
{
    const int DefaultTimeout = 60 * 5; // 5 mins (in seconds)

    IWebHost _host;
    TaskCompletionSource<string> _source = new TaskCompletionSource<string>();

    public string Url { get; }

    public LoopbackHttpListener(int port, string path = null)
    {
        path = path ?? string.Empty;
        if (path.StartsWith("/")) path = path.Substring(1);

        Url = $"http://localhost:{port}/{path}";

        _host = new WebHostBuilder()
            .UseKestrel()
            .UseUrls(Url)
            .Configure(Configure)
            .Build();
        _host.Start();
    }

    public void Dispose()
    {
        Task.Run(async () =>
        {
            await Task.Delay(500);
            _host.Dispose();
        });
    }

    void Configure(IApplicationBuilder app)
    {
        app.Run(async ctx =>
        {
            if (ctx.Request.Method == "GET")
            {
                await SetResultAsync(ctx.Request.QueryString.Value, ctx);
            }
            else if (ctx.Request.Method == "POST")
            {
                if (!ctx.Request.ContentType.Equals("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase))
                {
                    ctx.Response.StatusCode = 415;
                }
                else
                {
                    using (var sr = new StreamReader(ctx.Request.Body, Encoding.UTF8))
                    {
                        var body = await sr.ReadToEndAsync();
                        await SetResultAsync(body, ctx);
                    }
                }
            }
            else
            {
                ctx.Response.StatusCode = 405;
            }
        });
    }

    private async Task SetResultAsync(string value, HttpContext ctx)
    {
        try
        {
            ctx.Response.StatusCode = 200;
            ctx.Response.ContentType = "text/html; charset=utf-8";
            await ctx.Response.WriteAsync("<h1>您现在可以返回应用程序.</h1>");
            await ctx.Response.Body.FlushAsync();

            _source.TrySetResult(value);
        }
        catch(Exception ex)
        {
            Console.WriteLine(ex.ToString());

            ctx.Response.StatusCode = 400;
            ctx.Response.ContentType = "text/html; charset=utf-8";
            await ctx.Response.WriteAsync("<h1>无效的请求.</h1>");
            await ctx.Response.Body.FlushAsync();
        }
    }

    public Task<string> WaitForCallbackAsync(int timeoutInSeconds = DefaultTimeout)
    {
        Task.Run(async () =>
        {
            await Task.Delay(timeoutInSeconds * 1000);
            _source.TrySetCanceled();
        });

        return _source.Task;
    }
}

Shared 文件夹新建登录/注销页面组件

LoginComponent.razor

完整代码

@inject AuthenticationStateProvider AuthenticationStateProvider
@page "/Login"
@using System.Security.Claims

<button @onclick="Login">Log in</button>

<p>@Msg</p>
 

<AuthorizeView>
    <Authorized>

        你好, @context.User.Identity?.Name
 
        <br /><br /><br />
        <h5>以下是用户的声明</h5><br />

        @foreach (var claim in context.User.Claims)
        {
            <p>@claim.Type: @claim.Value</p>
        } 
 

    </Authorized> 

</AuthorizeView>


<p>以下是基于角色或基于策略的授权,未登录不显示 </p>

<AuthorizeView Roles="Admin, Superuser">
    <p>只有管理员或超级用户才能看到.</p>
</AuthorizeView>

@code
{
    [Inject]
    private AuthenticatedUser? authenticatedUser { get; set; }

    /// <summary>
    /// 级联参数获取身份验证状态数据
    /// </summary>
    [CascadingParameter]
    private Task<AuthenticationState>? authenticationStateTask { get; set; }

    private string? Msg { get; set; }

    private ClaimsPrincipal? User { get; set; }

    public async Task Login()
    {
        var authenticationState = await ((ExternalAuthStateProvider)AuthenticationStateProvider).LogInAsync();

        User = authenticationState?.User;

        if (User != null)
        {
            if (User.Identity != null && User.Identity.IsAuthenticated)
            {
                Msg += "已登录." + Environment.NewLine;
            }
        }
    }
}

LogoutComponent.razor

完整代码

@inject AuthenticationStateProvider AuthenticationStateProvider
@page "/Logout"

<button @onclick="Logout">Log out</button>

@code
{
    public async Task Logout()
    {
        await ((ExternalAuthStateProvider)AuthenticationStateProvider).Logout();
    }
}
		<div class="nav-item px-3">
            <NavLink class="nav-link" href="Login">
                <span class="oi oi-plus" aria-hidden="true"></span> Login
            </NavLink>
		</div>
		<div class="nav-item px-3">
            <NavLink class="nav-link" href="Logout">
                <span class="oi oi-plus" aria-hidden="true"></span> Logout
            </NavLink>
		</div>

Form1.cs 修改首页


        var blazor = new BlazorWebView()
        {
            Dock = DockStyle.Fill,
            HostPage = "wwwroot/index.html",
            Services = Startup.Services!,
            StartPath = "/Login"
        };
        blazor.RootComponents.Add<Main>("#app");
        Controls.Add(blazor);

Startup.cs 注册服务

完整代码

using BlazorOIDC.WinForms.Data;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;

namespace BlazorOIDC.WinForms;
public static class Startup
{
    public static IServiceProvider? Services { get; private set; }

    public static void Init()
    {
        var host = Host.CreateDefaultBuilder()
                       .ConfigureServices(WireupServices)
                       .Build();
        Services = host.Services;
    }

    private static void WireupServices(IServiceCollection services)
    {
        services.AddWindowsFormsBlazorWebView();
        services.AddSingleton<WeatherForecastService>();

        services.AddAuthorizationCore();
        services.TryAddScoped<AuthenticationStateProvider, ExternalAuthStateProvider>();
        services.AddSingleton<AuthenticatedUser>();
 
  

#if DEBUG
        services.AddBlazorWebViewDeveloperTools();
#endif
    }
}

运行