开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

采集 Kubernetes 容器日志最佳实践

发表于 2024-04-26

前言

指标、日志、链路是可观测的三大支柱,日志主要用于记录代码执行的痕迹,方便定位和排查问题。当前主流的应用都是以容器的方式运行在 Kubernetes 集群,由于容器的动态性,容器可能会频繁地创建和销毁。日志的采集和持久化变得尤为重要,以确保在容器生命周期结束后,仍然能够访问到运行时的信息。以下内容介绍如何利用观测云采集 Kubernetes 容器日志,并对采集的日志进行解析、查询、可视化分析和备份的整个流程。

接入方案

部署 DataKit 采集器

采集 Kubernetes 容器日志需要先部署 DataKit。

登录观测云控制台,点击「集成」 -「DataKit」 - 「Kubernetes」,下载 datakit.yaml ,拷贝第 3 步中的 token 。

编辑 datakit.yaml ,把 token 粘贴到 ENV_DATAWAY 环境变量值中“token=”后面,设置环境变量 ENV_CLUSTER_NAME_K8S 的值并增加环境变量 ENV_NAMESPACE,这两个环境变量的值一般和集群名称对应,一个工作空间集群名称要唯一。

1
2
yaml复制代码        - name: ENV_NAMESPACE
value: k8s-prod

把 datakit.yaml 上传到可以连接到 Kubernetes 集群的主机上,执行如下命令。

1
2
arduino复制代码kubectl apply -f datakit.yaml
kubectl get pod -n datakit

当看到状态是 “Running”后表示部署 DataKit 成功。

控制台日志采集

DataKit 默认采集了所有容器输出到控制台的日志(stdout/stderr),这些日志的特点是通过 kubectl logs 可以查看到。登录观测云控制台,点击「日志」 -「查看器」 ,可以看到已经采集到的日志,其中数据源默认展示的是容器的名称,接下来的采集中,会使用自定义数据来源。

DataKit 也提供了自监控功能,实时查看采集情况。DataKit 默认部署在 datakit namespace 下面,执行 kubectl exec 命令进入 DataKit 容器。

1
bash复制代码kubectl exec -it datakit-6rjjp -n datakit bash

再执行 datakit monitor,右下方的 logging/ 开头的行即是采集容器日志的实时监控数据。

默认的采集方式不是太灵活,这里推荐一种最佳的采集方式,把默认采集所有输出到控制台的日志关掉,通过染色的方式,在需要采集日志的 Deployment 部署文件中增加 annotation 方式指定是否需要采集、更改数据源名称以及为日志打 tags。

在 datakit.yaml 中增加下面的环境变量,即不采集任何控制台日志。

1
2
yaml复制代码        - name: ENV_INPUT_CONTAINER_CONTAINER_EXCLUDE_LOG
value: image:*

然后在应用的 Deployment yaml 文件中添加 annotation。

1
2
3
4
5
6
7
8
9
10
11
bash复制代码      annotations:
datakit/logs: |
[
{
"disable" : false,
"source": "log_stdout_demo",
"tags": {
"region": "hangzhou"
}
}
]

字段说明:

  • disable 是否禁用该容器的日志采集,默认是 false。
  • source 日志来源,非必填项。
  • tags key/value 键值对,添加额外的 tags,非必填项。

登录观测云控制台,点击「日志」 -「查看器」 ,可以看到已经采集到的日志。

容器内日志文件采集

对于容器内日志文件的采集,也是通过添加 annotations 的方式来实现采集的。

1
2
3
4
5
6
7
8
9
10
11
12
13
bash复制代码      annotations:
datakit/logs: |
[
{
"disable": false,
"type": "file",
"path":"/data/app/logs/log.log",
"source": "log_file_demo",
"tags": {
"region": "beijing"
}
}
]

字段说明:

  • disable 是否禁用该容器的日志采集,默认是 false。
  • type 默认为空是采集 stdout/stderr,采集文件必须写 file。
  • path 配置文件路径。如果是采集容器内文件,必须填写 volume 的 path,注意不是容器内的文件路径,是容器外能访问到的路径。
  • source 日志来源,非必填项。
  • tags key/value 键值对,添加额外的 tags,非必填项。

注意:需要把日志路径目录挂载到 emptyDir,这里挂的是 /data/app/logs。

1
2
3
4
5
6
7
yaml复制代码        volumeMounts:
- mountPath: /data/app/logs
name: varlog
......
volumes:
- name: varlog
emptyDir: {}

日志路径支持 glob 规则 进行批量指定,比如日志文件是 /tmp/opt/**/*.log ,挂载的目录必须高于通配的目录,比如挂载 /tmp 或 /tmp/opt。

登录观测云控制台,点击「日志」 -「查看器」 ,可以看到已经采集到的日志,当然也可以使用自定义的 tags 进行检索。

日志解析

为了通过日志中特定内容进行快捷筛选、关联分析,就需要使用 Pipeline 对日志进行结构化处理,比如提取trace_id、日志状态等。

下面是一条业务日志和对应的 Pipeline。

1
ini复制代码2024-04-11 11:10:17.921 [http-nio-9201-exec-9] INFO  c.r.s.c.SysRoleController - [list,48] - ry-system-dd 2350624413051873476 1032190468283316 - 查询角色列表开始
1
2
yaml复制代码grok(_, "%{TIMESTAMP_ISO8601:time} %{NOTSPACE:thread_name} %{LOGLEVEL:status}%{SPACE}%{NOTSPACE:class_name} - \[%{NOTSPACE:method_name},%{NUMBER:line}\] - %{DATA:service} %{DATA:trace_id} %{DATA:span_id} - %{GREEDYDATA:msg}")
default_time(time, "Asia/Shanghai")

成功解析出 trace_id、span_id、service 等标签,方便后续的快捷筛选、关联分析。

日志查询

观测云支持通过多种操作对日志数据进行查询和分析。

文本搜索

日志查看器支持关键词查询、通配符查询,* 表示匹配 0 或多个任意字符,? 表示匹配 1 个任意字符;若要将多个术语组合到一个复杂查询中,可以使用布尔运算符(AND/OR/NOT)连接。

术语可以是单词或者短语。比如:

  • 单个单词:guance;
  • 多个单词:guance test;(等同于 guance AND test)
  • 短语:”guance test”; (使用双引号可以将一组单词转换为短语)

搜索查询示例:

JSON 搜索

查看器原生支持对 JSON 格式 message 内容进行精确检索,搜索格式为: @key:value ,若为多层级 JSON 可用 “.” 承接,即 @key1.key2:value ,如图所示:

日志可视化分析

场景图表

观测云内置多种数据监控视图模版,用户可导入模板创建仪表板和查看器,并进行自定义编辑配置;或选择自定义创建方式,通过一系列设置构建数据洞察场景。比如,根据前面解析出来的 status 字段,统计一下 info、error 状态的日志分别有多少,可以通过以下步骤来创建可视化仪表板。

第一步:在场景->新建空白仪表板中,选择自己想要的视图类型。

第二步:选择日志数据源,设置过滤条件和分组,点击创建。

强大的关联能力

1、视图配置跳转链接

观测云提供链接功能,可以平滑跳转仪表板 & 查看器,实现数据联动分析、系统全面可观测。

  • 在视图设置页面,配置链接地址。

  • 再点击视图中的数据,即可跳转到对应的日志查看器,快速实现视图与查看器联动分析。

2、绑定内置视图

观测云还支持将视图保存为内置视图,并绑定到查看器中,方便在查看日志数据的同时,分析其他维度的数据。

查看日志详情时,即可查看上面绑定的内置视图,也可以绑定其他维度的视图,比如主机的指标视图等等。

日志告警

观测云提供开箱即用的监控模板来新建监控器;也支持自定义新建监控器,通过阈值检测、日志检测、突变检测、区间检测等十余种检测规则来设置检测规则和触发条件。开启监控器后,即可接收到由检测规则触发的相关异常事件告警。

其中,日志检测用于监控工作空间内基于日志采集器产生的的全部日志数据。支持基于日志的关键字设置告警,及时发现不符合预估行为的异常模式(如:日志文本数据中存在异常的标签),多适用于 IT 监控场景下的代码异常或任务调度检测等。

第一步:在监控->新建日志检测监控器。

第二步:设置检测规则和触发条件。

这里以日志内容包含”WARN”为例,设置超过100条时就触发告警。

第三步:编辑事件通知内容和告警策略,点击创建即可。

日志备份

观测云提供日志数据转发到观测云的对象存储及转发到外部存储的功能(包含观测云备份日志、AWS S3、华为云 OBS、阿里云 OSS 和 Kafka 消息队列)。用户可以自由选择存储对象,灵活管理日志备份数据。

日志备份

第一步:点击日志->数据转发

第二步:点击转发规则->新建规则

第三步:设置需要备份的数据源,和相关筛选条件,点击确定即可。

注意:该规则下的日志数据最低存储默认为 180 天,可以前往管理 > 设置 > 变更数据存储策略中修改数据转发存储策略。

查看备份数据

第一步:点击日志->数据转发,在下拉框选定规则。

第二步:自定义时间范围查询,可选择多个日期及定义开始时间和结束时间,时间会精确到小时,即可查询到备份数据。

更多日志备份相关操作,也可以阅读官方文档的详细介绍。

总结

通过以上方式,可以快速将部署在 Kubernetes 集群的各个业务系统的日志采集到观测云平台,实现日志采集、日志解析、查询分析、监控告警、归档备份等一整套解决方案。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

云原生✖️ AI 时代的微服务架构最佳实践—— CloudW

发表于 2024-04-26

活动介绍

CloudWeGo 开源两年多以来,社区发展迅速,生态日益丰富,落地企业用户已超过 40 家,涵盖 AI、电商、金融、游戏 、互联网等多个行业。同时,随着云原生技术和 AI 技术的持续蓬勃发展,我们发现企业用户也面临着越来越多性能、成本和稳定性方面的挑战,系统需要支持弹性伸缩和潮汐流量下的稳定性,因而也越发需要一套高性能、易扩展、功能丰富的微服务架构。

诚挚邀请企业用户和开发者共同参与 CloudWeGo 技术沙龙。活动将于2024年5月25日(周六)在上海举办,邀请广大技术同仁共同探讨在 云原生 xAI 浪潮之下,企业如何构建云原生微服务架构,来支持产品的快速迭代与发展。

  • 时间:2024年5月25日(周六)14:00-17:00
  • 地点:上海 · 漕河泾中心D栋F2

议题简介

本次活动分享议题将聚焦 CloudWeGo 相关技术功能实现,以及如何借力 CloudWeGo 开源项目帮助企业构建微服务等议题,将携手 CSDN 、infoQ、稀土掘金、火山引擎开发者社区、字节跳动技术团队作为合作伙伴同步进行宣传和直播。多位 CloudWeGo 社区 Maintainer 和 Committer 将分享包括微服务框架的对比和落地实践,以及基于 cwgo 代码生成工具的工程化实践等主题。另外我们还邀请了多位 CloudWeGo 的用户代表进行分享他们基于 CloudWeGo 的落地实践经验等精彩话题。最后我们也会围绕微服务相关热点话题进行圆桌讨论,和现场观众进行互动。

image.png

主题演讲:微服务框架对比、测试与迁移

  • 讲师:周启恒,CloudWeGo-Kitex Maintainer;李纪昀,CloudWeGo-Web&Doc Reviewer,CloudWeGo-Hertz Committer
  • 大纲: CloudWeGo 提供了高性能、高可靠的 Go 语言 RPC 框架 Kitex 以及 HTTP 框架 Hertz,助力用户高效搭建完备的企业级微服务架构。本次分享中,我们将从功能和性能多方面对比 CloudWeGo 微服务框架与开源框架 ,展示 Kitex 与 gRPC,Hertz 与 Gin 的差异与优势。此外,我们将分享关于框架迁移的操作实践和迁移的真实收益。

主题演讲:基于 Hertz 的微服务落地实践

  • 讲师:初泽良,字节跳动西瓜视频研发工程师
  • 大纲: Hertz 是一个 Golang 微服务 HTTP 框架,具有高易用性、高性能、高扩展性等特点。在本次演讲中,我们将介绍西瓜视频基于 Hertz 的微服务落地实践。我们将介绍西瓜视频微服务架构设计、Hertz 框架介绍、西瓜视频迁移 Hertz 过程及踩坑经验、落地 Hertz 后的收益。

主题演讲:从0到1基于 Kitex + Istio 的微服务系统建设

  • 讲师:Jason,Construct 服务端总监
  • 大纲: 在本次演讲中,我们将展示如何使用 Kitex 和 Istio 从0到1构建微服务架构,探讨技术栈和架构选择的理由及其实施细节。内容将包括系统兼容性策略、自动化流程、泳道以及通过监控和分布式追踪技术确保微服务可观测性和稳定性。

主题演讲:基于 cwgo 代码生成工具的工程化实践

  • 讲师:王鑫, CloudWeGo-Hertz Committer;鹿瑞超, CloudWeGo-Hertz Reviewer
  • 大纲: cwgo 是 CloudWeGo All in one 代码生成工具,整合了各个组件(hz,kitex)的优势,以提高开发者的体验。在本次演讲中,我们将从代码生成能力和工程化实践两方面介绍cwgo,了解如何通过使用 cwgo 简化代码生成过程和提高开发效率,实现工程化开发体验的提升。

圆桌讨论

  • 主持人:罗广明
  • 圆桌嘉宾:初泽良、Jason、周启恒
  • 大纲:
+ 微服务框架和中间件技术选型关注哪些方面?
+ 浅谈开源框架的易用性和其带来的研发效率在业务团队的价值
+ 浅谈 AI 对微服务框架演进和业务研发带来的影响
+ 现场 Q&A

立刻报名

访问活动页面即可报名注册,参与现场互动还有机会获得社区精美周边礼品。了解更多 CloudWeGo 项目相关信息请访问 www.cloudwego.cn 或 github.com/cloudwego 期待您的参与!


重磅,由字节跳动服务框架团队联合 CloudWeGo 开源社区出品的 《CloudWeGo 技术白皮书: 字节跳动云原生微服务架构原理与开源实践》 现已正式对外发布!本书总结了字节跳动自 2018 年以来的微服务架构演进之路,讲述了字节微服务架构的难点、编程语言的选择和开发框架的演进,以及流量激增后的流量治理模式和服务网格全面落地。白皮书中还详细介绍了电商、AI、金融、游戏相关行业的落地案例,同时探讨了在降本增效压力下微服务的性能提升和成本优化解决方案。下载地址:

  • www.cloudwego.cn/zh/
  • www.cloudwego.io/zh/

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Redis存储方式大揭秘,让你的数据飞上天! Redis存储

发表于 2024-04-26

Redis存储方式大揭秘,让你的数据飞上天!

哟,听说你最近的项目数据存储遇到了困难?那些老掉牙的关系型数据库已经跟不上你的脚步了?别怕,Redis来了!这位数据存储界的”网红”,绝对能让你的数据嗨起来!

Redis这家伙啊,就是一个高性能的key-value内存数据库。它凭什么这么牛?就凭它那恐怖的读写速度和花里胡哨的数据类型!今天,咱们就一起扒一扒Redis存储方式的底裤,看看它是怎么让你的数据又快又好存的!

内存存储,就是这么任性!

Redis的第一大特点,那就是内存存储!没错,你的数据在Redis里,就是直接住在内存里的!这意味着什么?这意味着Redis读写数据快得一批!

想想看,硬盘IO速度才100MB/s左右,而内存IO速度可以达到几十GB/s!这差距,不是一般的大啊!所以,当Redis处理海量数据的读写请求时,那速度,嗖嗖的,眨眼间就完事了!

举个例子,假设你的应用需要频繁地读取用户的基本信息。如果你把这些信息存在MySQL里,每次读取都得走一遍硬盘IO,那性能肯定上不去。但如果你把这些信息缓存在Redis里,读取速度那是杠杠的,啥时延都别想有!

当然,内存存储也不是完美无缺的。内存容量不够大,价格又贵。但Redis早就想到了这茬,它提供了一些机制来解决这些问题,比如数据淘汰策略和数据持久化,咱们一会儿再说。

数据类型,花样百出!

Redis的另一大亮点,就是它那花里胡哨的数据类型支持。别的key-value存储,通常就只支持简单的字符串类型。但Redis可不是吃素的,它的数据类型,那是相当丰富:

  • 字符串(String):这是最基础的类型,就像Memcached一样,一个key对一个value。
  • 哈希(Hash):这个类型有点儿像Java里的Map,一个key对应一个Map。
  • 列表(List):这个类型就像一个双向链表,你可以在列表的头部或尾部添加元素。
  • 集合(Set):这个类型跟Java中的HashSet差不多,它里面的键值对是无序且唯一的。
  • 有序集合(Sorted Set):这个类型跟Set类似,但里面的元素是有序的,每个元素都有一个分数(score)来排序。

有了这一箩筐数据类型,Redis就成了一个”多面手”,啥业务场景都能应对。

比如,你可以用String来缓存一些常用的对象,提高系统的响应速度:

1
2
3
4
5
6
7
8
// 将用户对象缓存到Redis
User user = new User(1, "Tom", 25);
String key = "user::" + user.getId();
redisTemplate.opsForValue().set(key, JSON.toJSONString(user));

// 从Redis获取用户对象
String userJson = redisTemplate.opsForValue().get(key);
User cachedUser = JSON.parseObject(userJson, User.class);

又比如,你可以用Hash来存储一个用户的详细信息:

1
2
3
4
5
6
7
8
9
10
// 存储用户信息到Hash
String userKey = "user::" + userId;
redisTemplate.opsForHash().put(userKey, "name", "Tom");
redisTemplate.opsForHash().put(userKey, "age", 25);
redisTemplate.opsForHash().put(userKey, "city", "New York");

// 获取用户信息从Hash
String name = (String) redisTemplate.opsForHash().get(userKey, "name");
int age = (int) redisTemplate.opsForHash().get(userKey, "age");
String city = (String) redisTemplate.opsForHash().get(userKey, "city");

再比如,你可以用List来实现一个简单的队列:

1
2
3
4
5
6
// 添加元素到队列
redisTemplate.opsForList().leftPush("queue", "task1");
redisTemplate.opsForList().leftPush("queue", "task2");

// 从队列获取元素
String task = redisTemplate.opsForList().rightPop("queue");

或者,你可以用Set来实现一个标签系统:

1
2
3
4
5
6
// 添加标签
redisTemplate.opsForSet().add("tags::user::1", "music", "movie", "travel");
redisTemplate.opsForSet().add("tags::user::2", "music", "sports");

// 获取共同的标签
Set<String> commonTags = redisTemplate.opsForSet().intersect("tags::user::1", "tags::user::2");

还有啊,你可以用Sorted Set来实现一个排行榜功能:

1
2
3
4
5
6
7
// 添加分数
redisTemplate.opsForZSet().add("leaderboard", "player1", 100);
redisTemplate.opsForZSet().add("leaderboard", "player2", 90);
redisTemplate.opsForZSet().add("leaderboard", "player3", 110);

// 获取排名
long rank = redisTemplate.opsForZSet().reverseRank("leaderboard", "player2");

看看,Redis的应用场景,是不是相当丰富?

数据淘汰,优胜劣汰!

咱们刚说了,内存存储的一个问题是容量有限。那Redis咋解决这个问题呢?答案就是:数据淘汰策略!

当Redis的内存使用到了某个程度,它就会自动触发数据淘汰机制。Redis有一堆数据淘汰策略让你挑:

  • volatile-lru:在设置了过期时间的数据中,挑最近最少使用的数据淘汰。
  • volatile-ttl:在设置了过期时间的数据中,挑快要过期的数据淘汰。
  • volatile-random:在设置了过期时间的数据中,随便挑数据淘汰。
  • allkeys-lru:在所有数据中,挑最近最少使用的数据淘汰。
  • allkeys-random:在所有数据中,随便挑数据淘汰。
  • noeviction:不淘汰数据,内存不够时,新写入操作会报错。

你可以根据自己的业务特点,选一个合适的淘汰策略。比如,如果你的数据都能再次获取,那选allkeys-lru就不错;如果你有一些”热点数据”,那就用volatile-lru,优先保证这些数据在内存里常驻。

举个例子,如果你用Redis缓存用户信息,你可能希望经常访问的活跃用户的信息能够常驻内存,不那么活跃的用户的信息就不那么care了。这时候,你就可以这样设置:

1
2
3
4
5
6
# 设置淘汰策略为volatile-lru
redis-cli config set maxmemory-policy volatile-lru

# 设置key的过期时间
redis-cli expire user::1 3600
redis-cli expire user::2 7200

这样,Redis就会在内存不够时,优先淘汰那些不怎么访问的用户信息,保留活跃用户的信息在内存中。是不是很智能?

数据持久化,安全第一!

除了数据淘汰策略,Redis还提供了数据持久化功能,保证数据安全。Redis的持久化有两种方式:RDB和AOF。

RDB(Redis Database)就是每隔一段时间,把内存中的数据快照写到磁盘上。这玩意儿默认就开启了,你可以设置自动备份的时间和文件名。RDB的好处是备份速度快,适合定期备份;坏处是两次备份之间如果出问题,可能会丢点数据。

你可以在redis.conf里设置RDB:

1
2
3
4
5
6
7
8
# 900秒内如果至少有1个key被修改,则执行bgsave (background save)
save 900 1  

# 300秒内如果至少有10个key被修改,则执行bgsave  
save 300 10

# 60秒内如果至少有10000个key被修改,则执行bgsave
save 60 10000

AOF(Append Only File)则是把Redis的操作日志追加写到文件里。每次有写操作,AOF就会同步地把操作写到硬盘上的AOF文件里。AOF的好处是最大限度保证数据不丢,坏处是备份文件可能很大,恢复速度也没RDB快。

你也可以在redis.conf里设置AOF:

1
2
3
4
5
6
7
8
# 开启AOF
appendonly yes

# AOF文件名
appendfilename "appendonly.aof"

# 同步策略,这里是每秒同步一次
appendfsync everysec

实际用的时候,你可以根据自己的业务需求选RDB还是AOF,甚至可以两个一起用,既保证数据安全,又兼顾性能。比如,你可以这样设置:

1
2
3
4
5
6
7
8
9
# 开启RDB
save 900 1
save 300 10
save 60 10000

# 开启AOF
appendonly yes
appendfilename "appendonly.aof"
appendfsync everysec

这样,Redis就会既有RDB的快照,又有AOF的操作日志,双保险,数据更安全!

小结

Redis凭借其飞快的性能和一大票功能,已经成了互联网应用必备的神器。今天咱们主要扒了Redis的几大存储特性:内存存储、数据类型支持、数据淘汰策略和数据持久化。

希望通过这篇文章,你对Redis的存储方式有了更深入的了解。Redis简单易用,但也有很多需要注意的地方,比如数据淘汰策略的选择,持久化方式的权衡等等。

当然,Redis的精彩远不止这些,它还有很多高级特性等着你去发现,比如主从复制、哨兵机制、集群模式等等。这些都是优秀的分布式解决方案,能让你的应用更稳定,更高可用。

让咱们一起潜入Redis的世界,用Redis的力量来武装我们的应用吧!相信有了Redis这个大杀器,你的项目绝对能更上一层楼!还等啥,快去撸代码吧!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

使用 CXYTableViewExt 来简化 UITable

发表于 2024-04-26

CXYTableViewExt

CXYTableViewExt 是用来简化 UITableView - Cell 配置的库。特别是对于拥有多种类型的 Cell,逻辑上将更加清晰,所见即所得。

几乎在你用到 UITableView 的地方,都可以使用 CXYTableViewExt 来简化代码和逻辑。

开始使用

1、简单使用,无需设置 dataSource 和 delegate,也不需要注册 Cell


如需实现上面的UI界面:

1、让 ArrowTextCell 实现 CXYTableItemProtocol 的协议,Cell 可以是纯代码 也可以是 Xib。

1
2
3
4
5
6
7
8
9
objc复制代码@interface ArrowTextCell ()<CXYTableItemProtocol>
@end
@implementation ArrowTextCell

#pragma mark - CXYTableItemProtocol
// 配置你的 Cell data
- (void)configData:(id)data {
self.title.text = data;
}

2、类似 Masonry block 写法, 内部会自动帮你注册Cell,设置默认代理对象和数据源。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
objc复制代码// ViewController.m 
- (void)bindViewData {

[self.tableView.t makeItems:^(CXYTable * _Nonnull make) {

[make addCellClass:ArrowTextCell.class data:@"设置页示例" didSelectBlock:^(id  _Nonnull data, NSIndexPath * _Nonnull indexPath) {
// 处理点击
}];

[make addCellClass:ArrowTextCell.class data:@"资讯列表示例" didSelectBlock:^(id  _Nonnull data, NSIndexPath * _Nonnull indexPath) {

}];

[make addCellClass:ArrowTextCell.class data:@"Section Header & Footer 示例" didSelectBlock:^(id  _Nonnull data, NSIndexPath * _Nonnull indexPath) {

}];
}];

}

2、类设置页 - delegate 方式让 ViewController 响应 Cell 的操作


1、有时我们需要响应 Cell 里的一些动作,我们可以在配置 Cell 时,给它设置一个代理对象(delegate)

1
objc复制代码- (void)addCellClass:(Class)cellClass data:(id)data delegate:(id)delegate

2、在 Cell 中定义一些代理对象( delegate)需要实现的协议

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
objc复制代码@protocol SwitchCellDelegate <NSObject>
- (void)switchCellDelegateSwitchChanged:(id)data;
@end

@interface SwitchCell ()<CXYTableItemProtocol>
@property (nonatomic, weak) id delegate;
@property (nonatomic, strong) SwitchModel *model;
@end

@implementation SwitchCell

- (IBAction)onSwitch:(UISwitch*)sender {
// 开关操作
if ([self.delegate respondsToSelector:@selector(switchCellDelegateSwitchChanged:)]) {
[self.delegate switchCellDelegateSwitchChanged:self.model];
}
}

#pragma mark - CXYTableItemProtocol
- (void)configData:(SwitchModel*)data indexPath:(NSIndexPath *)indexPath delegate:(id)delegate {
self.model = data;
self.delegate = delegate; // 接收代理对象
...
}

@end

3、让这个代理对象遵循你的协议,从而将 Cell 的动作传递给 delegate 响应

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
objc复制代码- (void)bindViewData {

[self.tableView.t makeItems:^(CXYTable * _Nonnull make) {
[make addCellClass:HeadTitleCell.class data:@"通知"];
// delegate: self 实现 cell 里面的交互
[make addCellClass:SwitchCell.class data:[SwitchModel title:@"系统消息通知" isOn:YES] delegate:self];
[make addCellClass:SwitchCell.class data:[SwitchModel title:@"通知显示消息详情" isOn:NO] delegate:self];
[make addCellClass:SwitchCell.class data:[SwitchModel title:@"振动" isOn:YES] delegate:self];

[make addCellClass:HeadTitleCell.class data:@"提示音"];
[make addCellClass:ArrowTextCell.class data:@"消息提示音" didSelectBlock:^(id  _Nonnull data, NSIndexPath * _Nonnull indexPath) {
// 处理点击
}];
[make addCellClass:ArrowTextCell.class data:@"来电铃声" didSelectBlock:^(id  _Nonnull data, NSIndexPath * _Nonnull indexPath) {

}];
}];
}

#pragma mark - SwitchCellDelegate
- (void)switchCellDelegateSwitchChanged:(SwitchModel*)data {
data.isOn = !data.isOn;
NSLog(@"%@ %@",data.title, @(data.isOn));
}

3、列表 - 下拉刷新或上拉加载更多


每次你的数据变化时,直接重新绑定数据就行。如果你觉得性能有影响,那可以使用 CXYTable.h 里的一些插入、添加、更新、删除方法来实现更新。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
objc复制代码- (void)requestPage:(NSInteger)page {
// 模拟请求,假数据
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
NSMutableArray *res = @[].mutableCopy;
for (NSInteger i=0; i<10; i++) {
NewsModel *model = [NewsModel new];
model.title = @"新闻标题";
model.desc = @"新闻摘要";
model.img = @"cover";
[res addObject:model];
}

if (page==1) {
self.list = res;
} else {
self.list = [self.list arrayByAddingObjectsFromArray:res];
}
self.page = page;
[self bindViewData];
});
}

- (void)bindViewData {
[self.tableView.t makeItems:^(CXYTable * _Nonnull make) {
[make addCellClass:NewsCell.class dataList:self.list];
}];
}

4、拥有 Header & Footer


同样,你的 Header 和 Footer 也需要实现 CXYTableItemProtocol 的协议。从配置项来看,几乎是所见即所得的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
objc复制代码- (void)bindViewData {
NSArray *sectionList1 = @[@"1",@"2",@"3",@"4",@"5"];

[self.tableView.t makeItems:^(CXYTable * _Nonnull make) {
[make addHeaderItem:Header.class data:@"我是Header1"];
[make addCellClass:TextCell.class dataList:sectionList1];
[make addFooterItem:Footer.class data:@"我是Footer1"];

[make addHeaderItem:Header.class data:@"我是Header2"];
[make addCellClass:TextCell.class data:@"text-cell"];
[make addCellClass:TextCell.class data:@"text-cell"];
[make addFooterItem:Footer.class data:@"我是Footer1"];
}];
}

更多操作

在 CXYTable.h 文件里面提供了很多操作方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
objc复制代码// 可使用自定义的
- (void)configDataSource:(id<UITableViewDelegate,UITableViewDataSource>)dataSource;

// 默认的数据源和代理
- (void)useDefaultDataSource;
// 使用默认的数据源和代理,cell被点击时调用
- (void)defaultDidSelectCell:(void(^)(UITableView *tableView, NSIndexPath *indexPath))didSelect;

// 配置item
- (void)makeItems:(void(NS_NOESCAPE ^)(CXYTable *make))block;
- (void)updateItems:(void(NS_NOESCAPE ^)(CXYTable *make, UITableView *tableView))block;
- (void)removeItems:(void(NS_NOESCAPE ^)(CXYTable *make, UITableView *tableView))block;

/**
添加item
*/
- (void)addCellClass:(Class)cellClass data:(id _Nullable)data;
...

/**
插入item
*/
- (void)insertCellItem:(Class)cellClass data:(id _Nullable)data indexPath:(NSIndexPath *)indexPath;
...

/**
删除 item
*/
- (void)removeCellItem:(NSIndexPath *)indexPath;
...

/**
获取item 数据
*/
// header item
- (id)headerItemDataInSection:(NSUInteger)section;
...

// footer item
- (id)footerItemDataInSection:(NSUInteger)section;
...

// cell item
- (id)cellItemDataAtIndexPath:(NSIndexPath*)indexPath;
...

Tip: 有些第三方统计SDK可能会 hook UITableView,默认给 tableView.delegate 一个对象。这会影响到 CXYTableViewExt 里内部设置默认数据源判断,这时,你需要手动设置默认的数据源和代理。

1
objc复制代码[self.tableView useDefaultDataSource];

源码:

OC版: CXYTableViewExt-OC

Swift版:CXYTableViewExt-Swift

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Spring Boot项目实战:消息丢失和重复消费问题

发表于 2024-04-26

你好,我是田哥

在我的充电桩项目中,有个用户积分模块,原型图如下:

图片

我的积分

下面来聊聊这个项目中,积分增加场景。

  • 用户充值完成后,赠送积分,比如:充100元,给用户积分新增100个。
  • 用户充电支付完成(非余额支付),赠送积分。
  • 用户邀请新人注册,赠送积分
  • 新用户认证完成,赠送积分
  • ….

关于积分增加策略,基本上都是由运营来定,总之,很多项目中都有这么个功能。

常规系统就是用户上面的行为伴随着用户积分处理完成(在同一个事物里),但,咱们为了提升系统性能和用户体验,我们通常把积分增加这类业务采用异步方式去实现。

比如:线程池、各种消息队列等

在我们这个版本中,采用的是RabbitMQ消息队列来实现的。

问题

既然使用RabbitMQ,那我们就不得不考虑关于消息的问题:

  • 消息丢失问题
  • 重复消费问题

消息丢失问题

这里对用户积分增加,如果把消息搞丢了,用户积分最终并不会得到增加,那用户肯定不干了,为了防止这类问题出现,我们采用下面的解决方案。

1:我们采用confirm模式+持久化+手动ack

2:消息丢失种鸽问题:消息发送失败,我们采用失败消息记录表

3:定时任务轮训失败消息记录,再次发送

这里为了防止多次重试问题,所以设置一个重试上限,并加入警告(比如一条消息最多重发5次,一旦到5次了,就给运维/开发/测试发送邮件警告)。

重复消费问题

这里是对用户积分增加,所以,绝对不能重复消费,不然这样会导致用户积分暴增,数据会出现一致性问题。解决:

1:每个消息有一个唯一的reqId,reqId=业务前缀+UUID+年月日时分秒毫秒的时间戳

2:在对比是否重复消费之前,对用户加上分布式锁,key=固定用户分布式锁前缀+userId

整体流程图

标配版:

图片

标配版

为了更好地监控消息发送失败问题,我们还可以对标配版进行升级。

图片

升级版

其他问题

我们上面说了,为了防止消息丢失,采用confirm模式+持久化+手动ack

但,实现起来并非那么简单,如果没有做过,很多东西是无法体会到的。

在使用confirm模式时,新的问题来了。

图片

问题

我们Spring中,一个Bean默认是单列的,这样的话会造成一个RabbitMQTemplate只能绑定一个confirm,这就不对了,我们需要RabbitMQTemplate不受Spring这个影响,很多人第一印象想到的就是采用原型模式。也就是在bean上添加注解:@Scope(“prototype”) 但,问题来了,比如在一个producer bean里注入RabbitMQTemplate,他最终还是认为你这个RabbitMQTemplate是单列,又和上面原型违背了,网上很多办法是给这个producer也搞成原型模式。

这个确实能解决这个问题。

说白了就是 从请求开始的bean开始到最后发送消息,这个过程的bean要都是原型模式才行。

比如:controller–service–producer

挖了个蛐蛐,问题又来了,项目中定时任务采用的是xxl-job,它的每个job都必须是单列的,上面的办法又不行了。

绝招:用Spring中的ApplicationContext**的getBean方法直接获取对应的Bean就不存在问题。

这里有点绕哈,说白了就是必须使用原型,不能使用单例。

核心代码

定义原型的rabbitTemplate。

1
2
3
4
5
6
7
8
9
10
11
12
less复制代码@Configuration
public class RabbitConfig {
    //省略部分非核心代码
    @Bean
    @Scope("prototype")
    public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) {
        RabbitTemplate rabbitTemplate = new RabbitTemplate();
        rabbitTemplate.setConnectionFactory(connectionFactory); 
        rabbitTemplate.setMandatory(true);
        return rabbitTemplate;
    }
}

confirm模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
scss复制代码@Component
public class UserPointProducer { 
    @Resource
    private RetryMessageMapper chargeUserMapper;

    public void sendMessage(String message) {
        RabbitTemplate rabbitTemplate = ApplicationContextFactory.getBean(RabbitTemplate.class);
        CorrelationData correlationId = new CorrelationData(UUID.randomUUID().toString());
        rabbitTemplate.setConfirmCallback((correlationData, ack, cause) -> {
            if (ack) {
                log.info("UserPointConfirm ConfirmCallback 关联数据:{},投递成功,确认情况:{}", correlationData, ack);
            } else {
                RetryMessage retryMessage = new RetryMessage();
                retryMessage.setContent(message);
                retryMessage.setRetry(5);
                retryMessage.setCreateTime(new Date());
                retryMessage.setStatus(0);
                retryMessage.setRetriedTimes(0);
                retryMessage.setType(RabbitMQConstantEnum.USER_POINT.getType());
                chargeUserMapper.insert(retryMessage);
                log.info("UserPointConfirm ConfirmCallback 关联数据:{},投递失败,确认情况:{},原因:{}", correlationData, ack, cause);
            }
        });
 
        //后面+1 主要是为了掩饰消息发送失败
        rabbitTemplate.convertAndSend(RabbitMQConstantEnum.USER_POINT.getExchange()+"1"
                , RabbitMQConstantEnum.USER_POINT.getRoutingKey(), message, message1 -> {
                    message1.getMessageProperties().setDeliveryMode(MessageDeliveryMode.PERSISTENT); // 设置消息持久化
                    return message1;
                }, correlationId);
    }
}

下面来写个测试发送案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
typescript复制代码/**
 * {@code @description:} 测试案例
 *
 * @author tianwc 公众号:Java后端技术全栈
 * 在线刷题 1200+java面试题和1000+篇技术文章:https://woaijava.cc/
 * {@code @date:} 2024-03-24 9:19
 * {@code @version:} 1.0
 */
@GetMapping("/send")
public void send() {
    UserPointMessage userPointMessage = new UserPointMessage();
    userPointMessage.setType(UserUpdatePointEnum.ADD.getType());
    userPointMessage.setUserId(1L);
    userPointMessage.setPoint(9);
    userPointMessage.setReqId(MessageReqIdPrefixConstant.USER_POINT + UUID.randomUUID()+ DateUtils.formatDefaultDateMs());
    userPointProducer.sendMessage(JSON.toJSONString(userPointMessage));
}

消费者:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
less复制代码/**
 * {@code @description:} 用户积分消息消费者
 *
 * @author tianwc 公众号:Java后端技术全栈
 * 在线刷题 1200+java面试题和1000+篇技术文章:<a href="https://woaijava.cc/">博客地址</a>
 * {@code @date:} 2024-03-24 9:19
 * {@code @version:} 1.0
 */
@RabbitListener(queues = "user.point.queue")
@Component
@Slf4j
public class UserPointConsumer {

    @Resource
    private UserPointService userPointService;

    @RabbitHandler
    public void process(Object data, Channel channel, Message message) throws IOException {
        try {
            log.info("消费者接受到的消息是:{},消息体为:{}", data, message);
            UserPointMessage userPointMessage = JSON.parseObject(new String(message.getBody()), UserPointMessage.class);
            userPointService.updateUserPoint(userPointMessage);
            channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
        } catch (Exception exception) {
            channel.basicNack(message.getMessageProperties().getDeliveryTag(), false, false);
        }
    }
}

具体业务逻辑实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
less复制代码/**
 * {@code @description:} 用户积分扣除消费者服务实现类
 *
 * @author tianwc 公众号:Java后端技术全栈
 * 在线刷题 1200+java面试题和1000+篇技术文章:<a href="https://woaijava.cc/">博客地址</a>
 * {@code @date:} 2024-03-24 9:19
 * {@code @version:} 1.0
 */
@Slf4j
@Service
public class UserPointServiceImpl implements UserPointService {
    @Resource
    private ChargeUserMapper chargeUserMapper;
    @Resource
    private RedissonClient redissonClient;
    @Resource
    private PointsChangeRecordMapper pointsChangeRecordMapper;

    @Transactional(rollbackFor = Exception.class)
    @Override
    public void updateUserPoint(UserPointMessage userPointMessage) {
        RLock lock = redissonClient.getLock(RedisConstantPre.USER_INFO_ID_PRE + userPointMessage.getUserId());
        lock.lock();
        try {
            int count = pointsChangeRecordMapper.selectByReqId(userPointMessage.getReqId());
            if (count > 0) {
                log.info("消息体参数 【重复消息】:{}", userPointMessage);
                return;
            }
            PointsChangeRecord pointsChangeRecord = new PointsChangeRecord();
            pointsChangeRecord.setUserId(userPointMessage.getUserId());
            pointsChangeRecord.setPoint(userPointMessage.getPoint());
            pointsChangeRecord.setType(userPointMessage.getType());
            pointsChangeRecord.setCreateTime(new Date());
            pointsChangeRecord.setReqId(userPointMessage.getReqId());
            //积分变动记录
            pointsChangeRecordMapper.insert(pointsChangeRecord);

            ChargeUser chargeUser = chargeUserMapper.selectByPrimaryKey(userPointMessage.getUserId());
            if (userPointMessage.getType() == UserUpdatePointEnum.ADD.getType()) {
                chargeUser.setPoint(chargeUser.getPoint() + userPointMessage.getPoint());
            } else {
                chargeUser.setPoint(chargeUser.getPoint() - userPointMessage.getPoint());
            }
            //用户积分变动
            chargeUserMapper.updateByPrimaryKey(chargeUser);
        } finally {
            lock.unlock();
        }
    }
}

这里的分布式锁的好处:

  • 保证了这个重复消费部分代码的原子性
  • 保证了此时只有一个线程对用户积分进行修改

其实,正常情况下,不会走失败消息记录表,但是作为程序不得不多考虑点。

定时任务部分代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
java复制代码/**
 * {@code @description:} 用户积分消息发送job
 *
 * @author tianwc 公众号:Java后端技术全栈
 * 在线刷题 1200+java面试题和1000+篇技术文章:https://woaijava.cc/
 * {@code @date:} 2024-03-24 9:19
 * {@code @version:} 1.0
 */
@Slf4j
@Component
public class UserPointRetryMessageJob {

    @Resource
    private RetryMessageMapper retryMessageMapper;
    @Resource
    private UserPointProducer userPointProducer;

    @XxlJob("userPointRetryMessageJob")
    public void process() {
        log.info("开始执行 userPointRetryMessageJob 定时任务");
        XxlJobHelper.log("start userPointRetryMessageJob job");
        int countRetryMessage = retryMessageMapper.countRetryMessage(0, 0);
        if (countRetryMessage == 0) {
            log.info(" 执行结束 userPointRetryMessageJob 没有消息需要重发");
        }
        List<RetryMessage> retryMessages = retryMessageMapper.selectRetryMessage(0, 0);
        for (RetryMessage retryMessage : retryMessages) {
            userPointProducer.sendMessage(retryMessage);
        }
    }
}

这里还可以优化,你能想到吗?

List<RetryMessage> retryMessages = retryMessageMapper.selectRetryMessage(0, 0);

这里是一次性全部查出来了,如果出现大量消息发送失败,一次性放到本地缓存里,很容易出问题,所以,我们可以再优化成分页进行处理,比如:每次处理50条,再根据count来计算需要进行分页。

定时任务中生产者代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
scss复制代码/**
 * {@code @description:} 用户积分消息发送生产者
 *
 * @author tianwc 公众号:Java后端技术全栈
 * 在线刷题 1200+java面试题和1000+篇技术文章:https://woaijava.cc/
 * {@code @date:} 2024-03-24 9:19
 * {@code @version:} 1.0
 */
@Slf4j
@Component
public class UserPointProducer {
    @Resource
    private RetryMessageMapper chargeUserMapper;

    public void sendMessage(RetryMessage retryMessage) {
        log.info("用户积分消息重试补发,{}", retryMessage);
        String message = retryMessage.getContent();
        CorrelationData correlationId = new CorrelationData(UUID.randomUUID().toString());
        RabbitTemplate rabbitTemplate = ApplicationContextFactory.getBean(RabbitTemplate.class);
        rabbitTemplate.setConfirmCallback((correlationData, ack, cause) -> {
            if (ack) {
                retryMessage.setStatus(1);
                chargeUserMapper.updateByPrimaryKey(retryMessage);
                log.info("UserPointConfirm ConfirmCallback 关联数据:{},投递成功,确认情况:{}", correlationData, ack);
            } else {
                retryMessage.setRetriedTimes(retryMessage.getRetriedTimes() + 1);
                chargeUserMapper.updateByPrimaryKey(retryMessage);
                log.info("UserPointConfirm ConfirmCallback 关联数据:{},投递失败,确认情况:{},原因:{}", correlationData, ack, cause);
            }
        });

        rabbitTemplate.convertAndSend(RabbitMQConstantEnum.USER_POINT.getExchange()
                , RabbitMQConstantEnum.USER_POINT.getRoutingKey(), message, message1 -> {
                    message1.getMessageProperties().setDeliveryMode(MessageDeliveryMode.PERSISTENT); // 设置消息持久化
                    return message1;
                }, correlationId);
        log.info("用户积分消息重试补发完成");
    }
}

失败消息表

1
2
3
4
5
6
7
8
9
10
sql复制代码CREATE TABLE `retry_message` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `type` int DEFAULT NULL,
  `content` text,
  `retried_times` int DEFAULT NULL,
  `retry_limit` int NOT NULL DEFAULT '1',
  `create_time` datetime DEFAULT NULL,
  `status` int DEFAULT '0',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 ;

一个数据库里就只要一张表即可,专门用来存储发送失败消息。

tyep:什么业务场景

content:具体消息内容

retried_times:已经重试了几次

retry_limit:重试次数限制

status:状态,是否需要重试

这里的重试次数限制,我们也可以采用配置的方式,这样就可以在分布式配置中心对此进行动态调整,不过,这个好像没什么必要,因为不会频繁地更换这个限制。直接存在表里还可以动态的针对某些业务做特殊处理,比如业务A限制次数2次,业务B次数改成3次….

没有完美的解决方法,但总有相对完美的解决方案即可。

关于积分模块,其实不止有增加积分,还有扣除积分。

比如:用户使用积分兑换优惠券,积分目前设计在用户中心,优惠券又在营销中心,所以,会涉及到分布式事务问题。

我们可以采用Seata、Atomikos、RockSeataetMQ等技术来解决,目前充电桩项目中用到过Atomikos,但是代码量实在是会增加不少,最后使用了Seata来解决分布式事务问题。

最后

希望通过本文学习,下次再遇到面试官问消息队列的两个问题,就不再是背八股文了。

好了,今天就跟大家分享这么多,希望能给你带来点点帮助。

麻烦个三连呗:点赞、转发、再看,谢谢啦!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

分布式事务实现方案:一文详解RocketMQ事务消息 实现原

发表于 2024-04-26

常见的分布式事务实现方案有以下几种:两阶段提交(2PC)、两阶段提交(2PC)、补偿事务(Saga)、MQ事务消息等。今天就讲一下 RocketMQ 的事务消息,是一种非常特殊的分布式事务实现方案,基于半消息(Half Message)机制实现的。

看完这篇想一下,RocketMQ事务消息到底能不能保证分布式系统中数据的强一致性?

实现原理

RocketMQ事务消息执行流程如下:

  1. 生产者将消息发送至RocketMQ服务端。
  2. RocketMQ服务端将消息持久化成功之后,向生产者返回Ack确认消息已经发送成功,此时消息被标记为”暂不能投递”,这种状态下的消息即为半事务消息(Half Message)。
  3. 生产者开始执行本地事务逻辑。
  4. 生产者根据本地事务执行结果向服务端提交二次确认结果(Commit或是Rollback),服务端收到确认结果后处理逻辑如下:
    • 二次确认结果为Commit:服务端将半事务消息标记为可投递,并投递给消费者。
    • 二次确认结果为Rollback:服务端将回滚事务,不会将半事务消息投递给消费者。
  5. 在断网或者是生产者应用重启的特殊情况下,若服务端未收到发送者提交的二次确认结果,或服务端收到的二次确认结果为Unknown未知状态,经过固定时间后,服务端将对消息生产者即生产者集群中任一生产者实例发起消息回查。
  6. 生产者收到消息回查后,需要检查对应消息的本地事务执行的最终结果。
  7. 生产者根据检查到的本地事务的最终状态再次提交二次确认,服务端仍按照步骤4对半事务消息进行处理。

image.png

代码实现

RocketMQ事务消息示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
java复制代码//演示demo,模拟订单表查询服务,用来确认订单事务是否提交成功。
private static boolean checkOrderById(String orderId) {
return true;
}

//演示demo,模拟本地事务的执行结果。
private static boolean doLocalTransaction() {
return true;
}

public static void main(String[] args) throws ClientException {
ClientServiceProvider provider = new ClientServiceProvider();
MessageBuilder messageBuilder = new MessageBuilderImpl();
//构造事务生产者:事务消息需要生产者构建一个事务检查器,用于检查确认异常半事务的中间状态。
Producer producer = provider.newProducerBuilder()
.setTransactionChecker(messageView -> {
/**
* 事务检查器一般是根据业务的ID去检查本地事务是否正确提交还是回滚,此处以订单ID属性为例。
* 在订单表找到了这个订单,说明本地事务插入订单的操作已经正确提交;如果订单表没有订单,说明本地事务已经回滚。
*/
final String orderId = messageView.getProperties().get("OrderId");
if (Strings.isNullOrEmpty(orderId)) {
// 错误的消息,直接返回Rollback。
return TransactionResolution.ROLLBACK;
}
return checkOrderById(orderId) ? TransactionResolution.COMMIT : TransactionResolution.ROLLBACK;
})
.build();
//开启事务
final Transaction transaction;
try {
transaction = producer.beginTransaction();
} catch (ClientException e) {
e.printStackTrace();
//事务开启失败,直接退出。
return;
}
Message message = messageBuilder.setTopic("topic")
//设置消息索引键,可根据关键字精确查找某条消息。
.setKeys("messageKey")
//设置消息Tag,用于消费端根据指定Tag过滤消息。
.setTag("messageTag")
//一般事务消息都会设置一个本地事务关联的唯一ID,用来做本地事务回查的校验。
.addProperty("OrderId", "xxx")
//消息体。
.setBody("messageBody".getBytes())
.build();
//发送半事务消息
final SendReceipt sendReceipt;
try {
sendReceipt = producer.send(message, transaction);
} catch (ClientException e) {
//半事务消息发送失败,事务可以直接退出并回滚。
return;
}
/**
* 执行本地事务,并确定本地事务结果。
* 1. 如果本地事务提交成功,则提交消息事务。
* 2. 如果本地事务提交失败,则回滚消息事务。
* 3. 如果本地事务未知异常,则不处理,等待事务消息回查。
*
*/
boolean localTransactionOk = doLocalTransaction();
if (localTransactionOk) {
try {
transaction.commit();
} catch (ClientException e) {
// 业务可以自身对实时性的要求选择是否重试,如果放弃重试,可以依赖事务消息回查机制进行事务状态的提交。
e.printStackTrace();
}
} else {
try {
transaction.rollback();
} catch (ClientException e) {
// 建议记录异常信息,回滚异常时可以无需重试,依赖事务消息回查机制进行事务状态的提交。
e.printStackTrace();
}
}
}

注意事项

  • 幂等性: 消费者处理消息时需要确保业务逻辑的幂等性,以应对消息可能的重复消费。
  • 超时和监控: 设置合理的超时时间,并监控事务消息的性能

总结

RocketMQ 事务消息是分布式事务中一种常见的实现方案,只是把发送消息和本地事务放在一个事务中,并且只保证最终一致性,无法保证强一致性。

原因有两点:

  1. 执行完成本地事务后,在commit事务消息之前,这段时间内数据是不一致的,所以只是保证了发送消息和本地事务的最终一致性。
  2. 在commit事务消息之后,然后把消息投递给消费者。至于消费者是否消费消息,什么时候消费?也都是不可控的,所以也只能尽量保证数据最终一致性。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

观察SwiftUI ScrollView的内容偏移量 观察S

发表于 2024-04-26

观察SwiftUI ScrollView的内容偏移量

hudson 译 原文

在构建各种可滚动UI时,通常希望观察当前滚动位置(UIScrollView称之为内容偏移),以触发布局更改,在需要时加载其他数据,或根据用户当前正在查看的内容执行其他类型的操作。

然而,当涉及到SwiftUI的ScrollView时,目前(在撰写本文时)没有内置的方式来执行此类滚动观察。虽然在滚动视图中嵌入ScrollViewReader确实能够在代码中的更改滚动位置,但奇怪的是(特别是考虑到它的名称),它不允许我们以任何方式读取当前(滚动)内容偏移量。

解决这个问题的一种方法是利用UIKit的UIScrollView的丰富功能,由于其代理协议和scrollViewDidScroll方法,它提供了一种在发生任何类型的滚动时获得通知的简单方法。然而,尽管我通常非常喜欢使用UIViewRepresentable和其他 SwiftUI/UIKit互操作性机制,但在这种情况下,我们必须编写相当多的额外代码来弥合两个框架之间的差距。

这主要是因为——至少在iOS上——我们只能将SwiftUI内容嵌入到UIHostingController中,而不是在自我管理的UIView中。因此,如果我们想使用UIScrollView构建一个自定义的、可观察的ScrollView版本,那么我们必须将该实现包装在视图控制器中,然后管理我们的UIHostingController与键盘、滚动视图的内容大小、安全区域嵌入等之间的关系。虽然不是不可能,但仍然有相当多的额外工作和复杂性。

因此,让我们看看是否能找到一种完全SwiftUI原生的方式来执行此类内容偏移观测。

使用GeometryReader解析框架

在开始之前,要意识到的关键一点是,UIScrollView和SwiftUI的ScrollView都通过修改一个容器偏移执行滚动,该容器托管实际可滚动内容。然后,他们将该容器裁剪到边界上,以产生视口移动的错觉。因此,如果能找到观察该容器框架(frame)的方法,那么基本上也会找到观察滚动视图内容偏移的方法。

这就是我们的老朋友GeometryReader发挥作用的地方(没有它,就不会是一个合适的SwiftUI布局解决方法,对吗?)。虽然GeometryReader主要用于访问它所托管的视图的size大小(或者更准确地说,该视图的拟议大小),但它还有另一个巧妙的技巧——可以要求它读取相对于给定坐标系的当前视图的frame框架。

要使用该功能,让我们从创建一个PositionObservingView开始,它允许将CGPoint值绑定到该视图相对于 CoordinateSpace的当前位置,我们也将将其作为参数传递。然后,新视图将嵌入一个GeometryReader作为背景(这将使该GeometryReader具有与视图本身相同的大小),并将使用首选项键将已经解析的框架的origin原点设置为偏移量——像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
swift复制代码struct PositionObservingView<Content: View>: View {
var coordinateSpace: CoordinateSpace
@Binding var position: CGPoint
@ViewBuilder var content: () -> Content

var body: some View {
content()
.background(GeometryReader { geometry in
Color.clear.preference(
key: PreferenceKey.self,
value: geometry.frame(in: coordinateSpace).origin
)
})
.onPreferenceChange(PreferenceKey.self) { position in
self.position = position
}
}
}

要了解如何使用@ViewBuilder属性构建自定义SwiftUI容器视图的更多信息,请查看这篇文章。

上面代码中使用SwiftUI的首选项系统的原因是,GeometryReader将作为视图更新过程的一部分被调用,在此过程中,不允许直接改变视图的状态。因此,通过使用首选项,我们可以以异步方式将CGPoint值传递到视图,然后将这些值分配给position 绑定变量。

现在,需要做的就是实现上面使用的PreferenceKey类型,如下:

1
2
3
4
5
6
7
8
9
swift复制代码private extension PositionObservingView {
struct PreferenceKey: SwiftUI.PreferenceKey {
static var defaultValue: CGPoint { .zero }

static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {
// No-op
}
}
}

实际上不需要实现上述任何类型的reduce算法,因为只有单个视图在任何给定的层次结构中使用该首选项键的传递值(因为上述实现完全包含在PositionObservingView中)。

好吧,现在有了一个能够读取和观察自己在给定坐标系中位置的视图。让我们使用该视图构建一个ScrollView包装器, 实现我们的原始目标——能够在此类滚动视图中读取当前内容偏移量。

从位置到内容偏移

新的ScrollView包装器基本上将有两个职责——一,它需要将内部PositionObservingView的位置转换为当前滚动位置(或内容偏移),二,它还需要定义一个CordinateSpace坐标空间,内部视图可以使用它来解析其位置。除此之外,它只需将其配置参数转发到其底层的ScrollView, 以决定滚动视图在哪些轴上运行,以及是否显示滚动指示器。

好消息是,将内部视图的位置转换为内容偏移就像给CGPoint的x和y分量取负一样简单。这是因为,如前所述,滚动视图的内容偏移量本质上只是容器相对于滚动视图边界移动的距离。

因此,继续实施我们的自定义滚动视图,将其命名为OffsetObservingScrollView(这种情况下拼写出ContentOffset确实有点太冗长了):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
swift复制代码struct OffsetObservingScrollView<Content: View>: View {
var axes: Axis.Set = [.vertical]
var showsIndicators = true
@Binding var offset: CGPoint
@ViewBuilder var content: () -> Content

// The name of our coordinate space doesn’t have to be
// stable between view updates (it just needs to be
// consistent within this view), so we’ll simply use a
// plain UUID for it:
private let coordinateSpaceName = UUID()

var body: some View {
ScrollView(axes, showsIndicators: showsIndicators) {
PositionObservingView(
coordinateSpace: .named(coordinateSpaceName),
position: Binding(
get: { offset },
set: { newOffset in
offset = CGPoint(
x: -newOffset.x,
y: -newOffset.y
)
}
),
content: content
)
}
.coordinateSpace(name: coordinateSpaceName)
}
}

注意我们如何通过使用闭包定义getter和setter,为内部视图的位置参数创建完全自定义的绑定。如上述情况,在将一个值分配给另一个绑定之前需进行转换时,自定义的绑定是一个很好的选择。

就是这样!现在有了一个SwiftUI内置ScrollView的临时替代品,它能够观察当前内容偏移量——可以将其绑定到任何状态属性,例如更改头部视图的布局,向服务器报告分析事件,或执行任何其他类型的基于滚动位置的操作。你可以在这里找到一个使用上述OffsetObservingScrollView的完整示例,以实现可折叠的头部视图。

我希望你发现这篇文章有趣且有用。如果有任何问题、评论或反馈,请随时在Mastodon 上找到我,或通过电子邮件与我联系。

感谢您的阅读!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

SwiftUI视图与修饰符 SwiftUI视图与修饰符

发表于 2024-04-26

SwiftUI视图与修饰符

hudson 译 原文

至少从架构的角度来看,SwiftUI最有趣的方面之一是它本质上如何将视图视为数据。毕竟,SwiftUI视图不是屏幕上渲染的像素的直接表示,而是对给定UI应该如何工作、外观和行为的描述。

在如何构建视图代码时,这种非常数据驱动的方法给了我们很大的灵活性——以至于人们甚至可能会开始质疑将一个UI定义为视图类型与将相同的代码作为修饰符实现之间到底有什么区别。

以下面FeaturedLabel视图为例——它在将星形图片添加到给定文本前面,并应用特定的前景色和字体,使该文本脱颖而出,成为“特色”:

1
2
3
4
5
6
7
8
9
10
11
12
swift复制代码struct FeaturedLabel: View {
var text: String

var body: some View {
HStack {
Image(systemName: ”star“)
Text(text)
}
.foregroundColor(.orange)
.font(.headline)
}
}

虽然上述内容可能看起来像典型的自定义视图,但使用“修改器风格”的View 扩展,也可以轻松实现完全相同的界面效果——像这样:

1
2
3
4
5
6
7
8
9
10
swift复制代码extension View {
func featured() -> some View {
HStack {
Image(systemName: ”star“)
self
}
.foregroundColor(.orange)
.font(.headline)
}
}

当放在示例ContentView中时,这两个不同的解决方案并排看起来如下:

1
2
3
4
5
6
7
8
9
10
11
swift复制代码struct ContentView: View {
var body: some View {
VStack {
// View-based version:
FeaturedLabel(text: ”Hello, world!“)

// Modifier-based version:
Text(”Hello, world!“).featured()
}
}
}

两种解决方案之间的一个关键区别是,后者可以应用于任何视图,而前者只允许我们创建基于String的特色标签。不过,我们可以通过将我们的FeaturedLabel转换为自定义容器视图来解决这个问题,该视图接受任何符合视图的内容,而不仅仅是一个普通的字符串:

1
2
3
4
5
6
7
8
9
10
11
12
swift复制代码struct FeaturedLabel<Content: View>: View {
@ViewBuilder var content: () -> Content

var body: some View {
HStack {
Image(systemName: ”star“)
content()
}
.foregroundColor(.orange)
.font(.headline)
}
}

上面,我们将ViewBuilder属性添加到 content闭包中,便可以利用SwiftUI的视图构建API的全部功能(例如, 可以为每个FeaturedLabel构建内容时使用if和switch语句)。

不过,我们可能仍然希望使用一个字符串轻松初始化 FeaturedLabel 实例,而不是总是传递包含Text视图的闭包。谢天谢地,使用类型约束,可以轻松实现:

1
2
3
4
5
6
7
swift复制代码extension FeaturedLabel where Content == Text {
init(_ text: String) {
self.init {
Text(text)
}
}
}

上图中,我们使用下划线来删除文本的外部参数标签,以模仿SwiftUI自己的内置便利API,如Button和NavigationLink等类型工作的方式。

有了这些改变,两种解决方案现在都具备完全相同的灵活性,并且既可以轻松地用于创建基于文本的标签,也可渲染任何类型的SwiftUI视图内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
swift复制代码struct ContentView: View {
@State private var isToggleOn = false

var body: some View {
VStack {
// Using texts:
Group {
// View-based version:
FeaturedLabel(”Hello, world!“)

// Modifier-based version:
Text(”Hello, world!“).featured()
}

// Using toggles:
Group {
// View-based version:
FeaturedLabel {
Toggle(”Toggle“, isOn: $isToggleOn)
}

// Modifier-based version:
Toggle(”Toggle“, isOn: $isToggleOn).featured()
}
}
}
}

此时我们可能会真正开始问自己——将UI定义为视图与修饰符到底有什么区别? 除了代码风格和结构外,真的有什么实际的差异吗?

嗯,那状态呢? 假设我们想让新的特色标签在首次出现时自动淡入? 这需要我们用@State-来标记opacity属性,然后在onAppear闭包中实现动画效果——例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
swift复制代码struct FeaturedLabel<Content: View>: View {
@ViewBuilder var content: () -> Content
@State private var opacity = 0.0

var body: some View {
HStack {
Image(systemName: ”star“)
content()
}
.foregroundColor(.orange)
.font(.headline)
.opacity(opacity)
.onAppear {
withAnimation {
opacity = 1
}
}
}
}

起初,参与SwiftUI状态管理系统似乎只有适当的视图类型才能做到,但事实证明,修饰符具有完全相同的功能——只要我们将此类修饰符定义为适当的符合 ViewModifier协议的类型,而不仅仅是使用View协议扩展:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
swift复制代码struct FeaturedModifier: ViewModifier {
@State private var opacity = 0.0

func body(content: Content) -> some View {
HStack {
Image(systemName: ”star“)
content
}
.foregroundColor(.orange)
.font(.headline)
.opacity(opacity)
.onAppear {
withAnimation {
opacity = 1
}
}
}
}

有了上述功能,我们现在可以用调用将新的 FeaturedModifier 添加到当前视图中来,取代之前的featured方法实现——两种方法再次产生完全相同的最终结果:

1
2
3
4
5
swift复制代码extension View {
func featured() -> some View {
modifier(FeaturedModifier())
}
}

同样值得注意的是,在ViewModifier类型中包装代码时,代码是延迟加载的,即在需要时计算代码,而不是在首次添加修饰符时就执行的,某些情况下,性能可能会有所不同。

因此,无论我们是想更改视图的样式或结构,还是引入新的状态,SwiftUI视图和修饰符都具有完全相同的功能。这点开始变得明显。但这让我们想到了下一个问题——如果这两种方法之间没有实际差异,我们如何在它们之间做出选择?

至少对我来说,这一切都归结为结果视图层次结构。虽然从技术上讲,在HStack中包装一个特色标签改变了视图层次结构,以添加星形图像,但从概念上讲,这更多的是关于样式,而不是结构。当将featured修饰符应用于视图时,从任何有意义方式说上,其布局或在视图层次结构中的位置并没有真正改变 ——至少从高级的角度来看,它仍然只是具有完全相同整体布局的单一视图。

不过,情况并非总是如此。看看另一个例子,它应该更清楚地说明视图和修饰符之间的潜在结构差异。

在这里,我们编写了一个SplitView容器,它采用两个视图——一个是前导视图,一个是尾随视图——然后将它们并排渲染,中间是一个分隔符控件,同时最大化其frame,最终它们将平均分隔可用空间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
swift复制代码struct SplitView<Leading: View, Trailing: View>: View {
@ViewBuilder var leading: () -> Leading
@ViewBuilder var trailing: () -> Trailing

var body: some View {
HStack {
prepareSubview(leading())
Divider()
prepareSubview(trailing())
}
}

private func prepareSubview(_ view: some View) -> some View {
view.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}

就像以前一样,绝对可以使用基于修饰符的方法实现完全相同的结果——这可能看起来像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
swift复制代码extension View {
func split(with trailingView: some View) -> some View {
HStack {
maximize()
Divider()
trailingView.maximize()
}
}

func maximize() -> some View {
frame(maxWidth: .infinity, maxHeight: .infinity)
}
}

然而,如果再次将两个解决方案放在同一个示例ContentView中,那么可以看到,这次这两种方法在结构和清晰度方面看起来确实大不相同:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
swift复制代码struct ContentView: View {
var body: some View {
VStack {
// View-based version:
SplitView(leading: {
Text(”Leading“)
}, trailing: {
Text(”Trailing“)
})

// Modifier-based version:
Text(”Leading“).split(with: Text(”Trailing“))
}
}
}

看看上面的基于视图的调用方法,很明显,两个文本被包装在一个容器中,也很容易判断哪个是前导视图,哪个是尾随视图。

不过这次,不能对基于修饰符的版本说同样的事情,我们确实需要知道,应用修饰符的视图 是前导视图。此外,也无法判断这两个文本最终会被包裹在什么样的容器中。看起来更像是使用尾随标签以某种方式为前导标签增加某种风格 ,但事实并非如此。

虽然可以尝试用更详细的API命名来解决清晰度问题,但核心问题仍然存在——在这种情况下,基于修饰符的版本没有正确显示结果的视图层次结构。因此,在上述情况下,当我们在父容器中包装多个兄弟姐妹时,选择基于视图的解决方案通常会给我们一个更清晰的最终结果。

另一方面,如果我们所做的只是将一组样式应用于单个视图,那么将其实现为“修改器类似”的扩展,或使用正确的ViewModifier类型,将是最常要走的路。对于介于两者之间的一切——例如我们早期的“特色标签”示例——这一切都归结为代码风格和个人偏好,即哪种解决方案最适合每个给定项目。

看看SwiftUI的内置API是如何设计的——容器(如HStack和VStack)是视图,而样式API(如填充和前景颜色)是作为修饰符实现的。因此,如果在项目中尽可能多地遵循相同的方法,那么最终UI代码可能会感觉一致,且与SwiftUI本身一致。

我希望你发现这篇文章有趣且有用。如果有任何问题、评论或反馈,请随时在Mastodon 上找到我,或通过电子邮件与我联系。

感谢您的阅读!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

代码报错不用愁,CodeGeeX一键完成代码修复、错误解释的

发表于 2024-04-26

作为一名开发者,你一定遇到过在编写代码时出现的各种错误。这些错误可能是语法错误、运行时错误或者逻辑错误。处理这些错误通常需要花费大量的时间和精力,特别是当你对错误的原因一无所知时。

CodeGeeX的v2.7.4版本最新上线的代码修复和错误解释功能,让你在解决代码错误的问题上,变得更加简单和高效。下面我们详细介绍这个功能的用法和适用场景,快去更新插件体验起来吧!

一、直接在编辑器中修复代码错误

当你在VSCode、JetBrains全家桶的IDE代码编辑器中编写代码时,如果出现了错误,编辑器通常会用红线来标注出错的代码行。

使用CodeGeeX,你不再需要手动查找错误的原因或者翻阅文档来寻找解决方案。如果你使用的VSCode上的CodeGeeX插件,只需要在出现错误的代码行上点击鼠标右键,选择“使用CodeGeeX修复”的选项,CodeGeeX将自动分析错误,并提供修复建议。

如果你是使用了IDEA,在出现错误的红线代码处,先点击more actions,然后选择fix by codegeex,如下图所示:

视频图1.png

视频图2.png

二、灯泡图标中的CodeGeeX修复功能

除了右键菜单,CodeGeeX还增强了编辑器中的灯泡图标功能。当你点击灯泡图标时,除了编辑器自带的功能选项外,还会看到“使用CodeGeeX修复”的选项。这意味着你可以在不离开当前编辑环境的情况下,直接利用CodeGeeX来修复代码错误。

视频图3.png

三、终端运行时报错的智能解释

在终端运行代码时,经常会遇到各种报错信息。这些信息有时可能非常复杂,难以理解。现在,当你在终端遇到错误时,只需通过点击右键菜单命令“使用CodeGeeX解释”(Windows需要Shift+右键)。

IDEA只需点击终端报错行旁边的按钮,就可以让CodeGeeX来解释这些错误。CodeGeeX能够理解错误信息,并提供清晰、易于理解的解释,甚至还能给出修复步骤。这使得即使是新手开发者也能快速理解并解决问题。

高效的开发不仅仅是写代码,更包括如何快速、优雅地解决问题。

四、更多交互优化,提升开发效率

JetBrains全家桶v2.7.4版本后,还有哪些值得关注的功能更新,下面以IDEA为例,一图看懂在IDEA的侧边栏,还哪些便捷的智能操作。

图1.png

侧边栏顶部的“更多”下增加”设置“菜单入口。类似于VSCode,CodeGeeX在JetBrains IDEs平台上新增了顶部的设置菜单入口,用户可以更轻松的打开设置,自定义插件的行为更符合自己的使用习惯。

图2.png

如上图所示,在CodeGeeX侧边栏上方,点击”…”,弹出下拉菜单,点击“设置”进入页面。

图3.png

侧边栏智能问答提供更多直观操作代码的方式。在智能问答内容生成框上方的“更多”中,提供了将生成代码插入到新文件、终端中运行、与当前文件对比、与剪贴板对比、折行显示等多项操作的功能。

图4.png

智能问答支持一键复制。在侧边栏使用Ask CodeGeeX智能问答时,需要同时复制生成的代码和文本内容。CodeGeeX提供一键复制所有文本内容的按钮,并且使用Markdown格式整理来方便使用。

图5.png

预测后续问题。在智能问答Ask CodeGeeX中,当用户提出一个问题获得回复后,会继续生成接下来的后续问题。

图6.png

这些推荐给用户的后续问题,是和用户提出的问题相关性很强或者更进一步的问题预测。

图7.png

通过检索算法的优化,新版本中的@repo效果明显提升。同时,根据用户反馈,新增更多开源代码仓库的支持。(点击‘@repo’图标后,输入仓库名即可找到)

图8.png

图9.png

CodeGeeX收录的流行开源仓库已经超过100+个,在输入代码仓库名称的同时,展示出的开源仓库列表会根据检索收录结果发生变化。

Diff视图下新增代码审查功能和自动生成Commit Message的功能

图10.png

如果你对CodeGeeX的这一波新功能感兴趣,一定要去IDE的插件市场更新最新版的CodeGeeX插件来使用。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

「KMM」、「KMP」?他们到底是什么?

发表于 2024-04-26

Kotlin Multiplatform is a technology that allows you to create applications for various platforms and efficiently reuse code across them while retaining the benefits of native programming. Your multiplatform applications will work on different operating systems, such as iOS, Android, macOS, Windows, Linux, and others. —— kotlin官网

以上是Kotlin Multiplatform官网上对于KMP的简要概括,翻译一下,就是

“Kotlin Multiplatform 是一种跨平台技术,它允许您在不同平台上高效的重用代码的同时保留不同平台的代码优势。使用KMP编写的程序可以在不同的操作系统上运行,例如iOS、Android、macOS、Windows、Linux等”。

image.png

KMM?KMP?

KMM(Kotlin Multiplatform Mobile)和KMP(Kotlin Multiplatform)实际上是指同一个概念在不同范围的应用。Kotlin Multiplatform是一个更广泛的术语,而Kotlin Multiplatform Mobile专注于移动开发。

Kotlin Multiplatform (KMP)

Kotlin Multiplatform是一种编程技术,允许开发者使用Kotlin语言编写跨平台代码。这意味着你可以用同一套代码逻辑同时运行在多个平台上(如JVM、JavaScript、iOS、Android、Linux、Windows、MacOS等),而不需要为每个平台重写逻辑。

KMP的目标是实现业务逻辑或非UI相关逻辑的共享,以减少重复代码和提高开发效率。对于UI部分,通常还是需要使用各个平台的原生技术或其他跨平台框架。

KMP的应用范围非常广泛,包括但不限于移动应用开发。它也可以用于Web开发、服务器端开发、桌面应用开发等。

Kotlin Multiplatform Mobile (KMM)

KMM是Kotlin Multiplatform在移动开发领域的应用。它专注于使用Kotlin编写可以在Android和iOS平台上共享的代码。

与KMP的差异主要体现在使用范围上,KMM专门针对移动平台,即iOS和Android(也许,maybe未来也包括鸿蒙?)

Kotlin Multiplatform(无论是KMM还是更广泛的KMP)提供了一种高效的方式来共享跨平台代码,同时允许开发者为每个平台提供最佳的用户体验,如果你的项目只涉及到iOS和Android应用的开发,KMM是一个很好的选择,因为它专为这个目的设计。如果你希望在更广泛的平台上共享Kotlin代码(如Web、桌面或服务器端),那么KMP会是更适合的选择。

受命于天,既寿永昌:谁才是跨端正统?

打盘古开天辟地以来,人类对统一一直有一种莫名的执念:非我族类,其心必异。所以历史上各路英雄疯了似的都要统一天下。
前端开发亦是如此:明明都是UI切图仔,为何还要区分出Android切图仔、iOS切图仔、web切图仔…为了达成一统天下的目的,前端历史上也是英雄辈出:React Native、Weex、Flutter以及我们此次要讲的KMM(KMP)。

一个国家,三个政府,这难道不是分裂,不是对孙先生的背叛吗?—— 《建军大业》

下面的表格列举出了几个主流的跨端方案的差异性:

特征/技术 Weex React Native Flutter Kotlin Multiplatform (KMP)
开发者/维护者 阿里巴巴 Meta (前Facebook) Google JetBrains
技术栈 Vue.js, JavaScript JavaScript, React Dart Kotlin
UI渲染 原生组件 原生组件 自绘UI(通过Skia) 依赖于平台
性能 接近原生 接近原生 高(性能更优) 取决于平台,逻辑部分高效
跨平台范围 iOS, Android iOS, Android, Web (部分支持) iOS, Android, Web, Desktop, Embedded iOS, Android, Web, Desktop
学习曲线 适中,需要了解Vue.js 适中,需要了解React 较陡峭,需要学习Dart语言 适中,对Kotlin开发者较为友好
社区和支持 适中 强大 强大 增长中
用例 适合阿里生态圈内的项目 广泛的应用场景,尤其是需要快速迭代的项目 适合高性能和高度定制化的UI设计的应用 适合逻辑重用,跨多平台共享业务逻辑

其中React Native、Flutter成名已久,也算是跨端方案两种实现思路的代表:

  • 以React Native为代表的使用平台的原生组件来构建UI。这就意味着它需要讲JavaScript代码桥接到原生代码,使用原生控件来渲染界面。这种方法可以提供接近原生的性能和外观,但可能会受限于框架对原生组件的支持。与之类似的还有Weex。
  • 以Flutter为代表的使用自己的渲染引擎绘制界面、构建UI。这种方案由于避免了桥接开销,通常可以提供更好的性能,使得动画和过渡更加平滑,响应更快。与之类似的还有Compose Multiplatform。

React Native虽然号称可以实现接近原生的性能,但由于其天然存在的桥接机制(JavaScript与原生代码之间的通信),对于一些要求高性能的应用或复杂动画,React Native可能无法与原生应用相匹敌。
同时,尽管React Native旨在支持跨平台开发,但在实际开发中,仍然可能需要编写平台特定的代码来处理不同操作系统之间的差异。这可能减少代码复用率,增加开发和维护成本。

至于Flutter呢,性能上倒是和原生没有太大差异,对于那些不熟悉Dart语言的开发者来说,Flutter可能有一定的学习曲线。虽然Dart设计为易于学习的语言,但它并不是什么大众性的语言。开发者往往需要额外的时间和资源来学习Dart语言及Flutter框架。

同时,虽然Flutter努力提供丰富的插件来支持各种平台特定功能,但新的平台功能或较少使用的功能可能没有现成的插件支持,这些也需要开发者自行编写原生代码并创建桥接逻辑,无疑增加了开发的复杂性和时间成本。

什么?你问我「Weex」?

大人,时代变了,大清亡了啊!Weex的Github最近的一次提交是在23年8月份[/哭]

「KMP」新王当立?

更细粒度、渐进式的跨平台

image.png

与传统的Flutter or React Native方案不同,KMP对于跨平台代码的粒度更加精细,允许开发者 渐进式 的完成跨平台,按代码的重用程度不同分为3种:

  • 共享部分逻辑层代码

在保障app的稳定性的前提下,将部分关键且独立的逻辑层代码进行共享,使其能在不同的平台上重用这部分的代码。这种方式适合现有项目向KMP的迁移,最大程度的保证app的稳定性。

  • 逻辑层代码完全跨平台,UI层代码保持平台独立性

逻辑层代码完全实现跨平台,但是UI部分的代码保持平台独立性。这种模式比较适合新项目,没有历史债务,从新开始。

  • 逻辑层和UI层完全的跨平台,即代码100%重用

跨平台项目的完全体,UI部分使用Compose Multiplatform来完成跨平台达到重用代码的目的。
使用Compose Multiplatform编写的跨平台项目

性能有保障的跨平台

通过KMP跨平台而共享的代码,最终会被编译成对应平台的二进制文件。

什么意思呢?我们使用Kotlin写的跨平台的代码,最终编译生成的,不一定是.class文件了,可能是js文件、.exe、.framwork…等等都有可能 —— 这取决于目标平台。

这个特性天然就决定了KMP的性能相对于React Native和Flutter更好。理论上KMP生成的代码和原生代码在性能上应该别无二致。

image.png

说的天花乱坠,都谁在用KMP?

官网上列举了很多案例:

基本上以国外公司为主,不过据我所知,国内的很多公司也已经在生产环境上尝试KMP,像小破站、莉莉丝、美团等。

Google也一直在加大对KMP的支持力度,早在2022年10月份就宣布了Jetpack开始支持KMP了:

Announcing an Experimental Preview of Jetpack Multiplatform Libraries

Jetpack开始支持KMP

所以对于KMP的社区生态或者说是开发体验上,我是保持着相对乐观的态度:背靠Google以及最会做IDE的Jetbrains,我相信KMP的生态不会差,时间问题罢了。

该不该使用KMP?

开源的世界百家争鸣,但是落到实处往往也是成王败寇。作为开发者,做技术选型就更需要慎重再慎重了。
如果你有跨平台的诉求,我认为KMP当前值得推荐。

作为一个Android开发者、Kotlin语言的使用者,Kotlin、Compose本身就很熟悉,使用KMP几乎没什么学习成本和门槛,跨平台的能力简直算是白送的。即便不熟悉Compose,我们也可以使用KMM写一些UI无关的、逻辑层的代码,共用逻辑层,达到跨平台的能力。比如使用KMP做埋点数据上报。

至于跨平台是不是伪需求,我觉得应该高瞻远瞩一下。现有主流的移动端平台只有iOS和Android,加上未来的「鸿蒙」呢?之前一套逻辑iOS和Android平台上写两次,现在加上鸿蒙写三遍么?即使你能忍,公司能不能忍呢?KMP对人效的提升和逻辑的统一性我觉得是无需质疑的。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…101112…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%