wenmo8 发布的文章

随着公司业务的快速发展,同程旅行的非结构化的数据突破 10 亿,在 2022 年,同程首先完成了对象存储服务的建设。当时,分布式文件系统方面,同程使用的是 CephFS,随着数据量的持续增长,CephFS 的高复杂性和运维难度逐渐成为瓶颈。考虑到可观测性、稳定性和管理效率等维度,同程最终决定转向 JuiceFS。

目前,同程已在 JuiceFS 上构建了一个企业级存储平台,平台规模涵盖了超过 20 个文件系统和 2000 多个客户端挂载点,能够高效管理亿级文件和百 TiB 级别的数据量。值得一提的是,整个存储平台的日常运维工作仅需一人。该平台主要应用于多个场景,包括 AI 应用、容器云环境以及应用级共享存储需求。

01 文件系统选型:从 CephFS 到 JuiceFS

在使用 JuiceFS 之前,同程内部使用的是 Ceph 来提供对象存储和分布式文件存储服务。然而,Ceph 的技术栈复杂度较高,掌握难度大,对使用经验和运维经验都有较高要求。同时,在可运维性和生态建设方面也存在一定不足,对日常稳定性保障构成了较大挑战。

相比之下,JuiceFS 具有诸多优势。JuiceFS 设计上实现了元数据和数据分离,这与我们内部已有的成熟对象存储系统和分布式数据库系统高度契合。凭借已有的技术经验,能够自主进行问题排查和性能分析。此外,JuiceFS 的工具链和生态建设相对成熟,具备良好的 POSIX 兼容性和云原生支持。特别是其 CSI 功能,提供了多种挂载模式,使我们能够灵活选择。中文的用户社区也使得我们在使用过程中的沟通更为顺畅。

选择 JuiceFS 的另一个原因是它能够与我们现有的技术栈良好融合。简要介绍一下我们现有的基础系统:我们自建了一个基于开源 Seaweed 构建的 S3 集群,并搭建了 S3 代理,兼容 Seaweed、Ceph 以及腾讯 COS 等公有云S3服务。S3 集群具备主从机制,可以在代理层实现从主集群切换到从集群。此外,我们的 DCDB 是一个内部使用的分布式数据库系统,语法兼容 MySQL,基于百度的 BaikalDB 构建。

02 JuiceFS 在同程旅行的平台化建设

在平台化建设过程中,系统的可观测性、应用接入与部署以及数据安全性是至关重要的要素。为了确保平台的高效运行,我们在多个维度上进行了精心设计和优化,以实现全面的监控和高效的服务管理。

在可观测方面,我们构建了一系列监控大盘,以全面监控关键指标。同时,我们接入了公司内部的监控告警系统,为容量、接口耗时等重要指标配置了告警规则。为了更高效地接入内部监控系统,我们开发了一个挂载点自动发现程序。该程序从元数据引擎中获取当前的客户端列表,并实时将客户端列表的更改信息推送给我们内部的监控采集系统。

在应用接入与部署方面,我们提供了一系列易用工具,以支持应用的快速接入和部署。这些工具不仅简化了操作流程,还降低了运维难度。此外,我们高度重视数据安全性,对重要的文件系统都实现了全面备份,以确保数据的完整性和可用性。

在监控告警的具体内容方面,我们主要关注服务端的情况,特别是元数据与 S3 服务的平均时延、请求成功率以及异常情况等关键指标。这些指标对于评估系统性能和稳定性至关重要。

高可用 JuiceFS 服务集群:单中心闭环

它解决的一个典型场景是 Kubernets 单中心集群。 Kubernets 本身并非跨中心集群,而是每个中心都部署独立集群,即单中心闭环架构。在这种架构下 Kubernets 的各类资源和服务依赖通常限制在同一数据中心内,以避免跨数据中心通信带来的延迟、带宽消耗和网络复杂性。JuiceFS 在 Kubernets 中,主要解决的是持久化的需求,而不是数据共享的需求。

另外,一些对于性能要求较高的应用,流量也需要保持在同一数据中心内,以避免跨中心传输带来的延迟和带宽消耗。因此,在这些场景中,会采用单中心闭环的方案,将 JuiceFS 相关服务在每个 IDC 部署为独立集群,以确保数据存储和计算任务在同一中心内进行,最大化性能并降低延迟。

这种方式适用于内部 Kubernets 集群等场景,主要解决有状态应用的持久化存储问题。同时,通过将流量闭环在同一 IDC 内,避免了跨机房传输带来的性能瓶颈,从而保证系统的稳定性和性能。

高可用 JuiceFS 服务集群:跨中心闭环

这种部署方式主要应对的是那些本身跨机房部署且存在共享数据需求的应用场景。这类应用不仅需要访问同一个文件系统,而且对高可用性的要求也相对较高。若某个机房的服务出现故障,不可能要求所有应用都切换到其他中心,因此,我们采用了跨中心部署的后端服务集群方案。

在此方案中,对象存储,如 S3 集群以及 DCDB 等关键组件均实现了跨中心部署。具体而言,S3 的 Master 节点会在每个 IDC 都部署一个,以确保服务的全局可达性。同时,数据副本也会存储在多个中心,以提高数据的可靠性和容错性。DCDB 同样采用了跨中心部署策略,其服务在每个中心都有部署,数据副本则通过其内置的复制机制在多个中心间同步。

为了优化流量路径并减少跨中心传输带来的延迟和成本,我们在正常情况下将客户端请求限制在本地机房,流量通过负载均衡转发到本机房的 S3 服务节点。这不仅保证了性能,还减少了不必要的跨机房流量。由于跨中心集群内部的数据同步和复制需求,集群内部仍然会有跨中心流量。

在出现故障的情况下,例如某个中心的 S3 服务出现问题,我们可以将流量入口切换到其他中心。这一故障切换机制进一步保障了集群的高可用性。

03 落地 JuiceFS 收益

JuiceFS 的架构与我们内部已有的对象存储系统和分布式数据库系统高度兼容,使得集成过程非常顺利,整个项目仅投入了 2 人力,从选型到原理研究,再到最终落地的实施,仅用了半年的时间完成了从 CephFS 到 JuiceFS 的切换。基于 JuiceFS,我们成功构建了一个企业级存储平台,显著提升了存储系统的可观测性、稳定性与管理效率。

从 CephFS 切换到 JuiceFS 后的主要收益:

  • 扩展性和灵活性
    • 可以无缝扩展存储容量,并轻松应对数据量的快速增长,无需停机或影响现有业务。
    • 更好地适配云计算和容器化环境,便于企业在多云或混合云环境中运行。
  • 简化运维
    • 完善的可观测性功能,方便集成到企业内部系统。
    • 运维简单,能更好的支持稳定性保障工作。
  • 数据安全和可靠性
    • 更强的数据容错能力,能够自动进行故障恢复,确保数据的高可用性。
    • 提供强大的备份和灾难恢复能力,保证数据长期安全可靠。

目前,JuiceFS 已在多个场景中提供了强大的存储解决方案,满足了不同应用的需求:

  • 容器云平台:作为基础架构,JuiceFS 有效支持了云中心的持久化存储,尤其是通过容器存储接口(CSI)实现了有状态应用的数据持久化功能,解决了容器化环境中对持久存储的核心需求。
  • 大数据与 AI 平台:在大数据和 AI 应用中,JuiceFS 为海量数据存储提供了高效的支持,特别是在模型训练和数据处理过程中,显著提升了存储性能,解决了对大规模数据存储的需求。
  • 应用共享文件场景:JuiceFS 使多个应用实例能够便捷地共享文件资源,替代了传统的数据传输方案(如 SFTP),优化了应用间的数据交换和资源共享。
  • 数据冷备:尽管对象存储被广泛采用,但一些用户仍然偏好文件系统接口,JuiceFS 正是为这些场景提供了可靠的数据备份解决方案,确保了数据的长期可靠性与可访问性。

04 JuiceFS 使用中的挑战与优化实践

读写性能优化

首要挑战来自于一个关键业务场景——商品库业务。在该场景中,写服务负责数据的全量和增量更新,而读服务需要在更新完成后(通常为 10 分钟内)迅速加载数据。

在此过程中,我们观察到了一系列性能上的挑战。特别是在数据加载阶段,会产生大量的目录列表读取和文件操作,峰值带宽需求高达 20Gbps,同时元数据操作量也达到了 4 万次的高峰。为了应对这些挑战,我们对后端服务进行了针对性的优化,涵盖了资源存储和元数据管理两大方面。

在 S3 存储方面,我们进行了大量的链路对比测试。通过对比经过四层负载均衡(tvs)与直接连接 S3 的性能差异,我们发现了一些隐藏的链路节点存在较大的延时损耗。特别是在高带宽场景下,四层负载均衡的性能瓶颈尤为明显。为了解决这个问题,我们将负载均衡器升级到了采用高性能 DPDK 技术的版本,从而显著提升了链路性能。

元数据方面,我们深入调研了部署方案和元数据引擎的性能边界。尽管我们对 DCDB 进行了多项优化,如事务处理等,但由于其基于 Raft 协议,存在多次 RPC 请求的问题,即使经过极致优化,也只能达到毫秒级响应。对于跨中心集群部署而言,网络消耗和成本也是不可忽视的问题。因此,我们最终确定了将流量闭环在单个中心的方案,并选择了 Redis 作为元数据引擎,以确保元数据操作在 1 毫秒以内完成。

此外,通过将原始文件系统按照顶层目录拆分为多个子文件系统,我们成功地将整体流量分散到各个JuiceFS服务中。在进行了上述优化后,我们已经能够满足业务的性能要求。

我们还深入业务逻辑,协助业务方优化了代码流程,进一步降低了请求量,为系统创造了更多的性能提升空间。

低版本 FUSE 缓存同步 bug

这个bug的现象是当我们在一台机器上删除目录后再创建同名目录时,其他机器上看到的却是之前删除的目录内容。经过深入分析,我们发现这是 Linux 内核 2.6 版本中的一个已知 bug。该 bug 导致在删除目录后,Linux 的目录项缓存和 inode 缓存未能及时更新,从而在其他机器上产生了错误的目录视图。幸运的是,在 Linux 内核 3.10 版本及更高版本中,这个 bug 已经被修复。因此,我们建议大家在使用 JuiceFS 时,尽量使用 3.10 版本及以上的 Linux 内核,以避免类似的性能问题和 bug。

写入阻塞问题的排查与解决方案

在进行写入压测过程中发现偶尔应用写入会卡住,一段时间后应用写入报错 input/output error,查看挂载进程日志是访问 S3 403 鉴权失败

首先,由于问题偶发,我们可以排除密钥错误的可能性。通过直连 S3 测试,我们确认问题与中间链路节点有关,特别是与 Nginx 的交互存在问题。

为了深入了解问题根源,我们在 S3 端增加了日志记录,并发现 S3 在进行签名时使用了“Expect: 100-continue”请求头。这里需要解释一下,HTTP 协议中,“Expect: 100-continue”是一种机制,用于在上传大文件时,先发送 HTTP 请求头给服务器,服务器若接受,则返回 100 状态码,客户端再发送HTTP请求体。然而,在我们的案例中,打印日志发现 403 的请求不带有 Expect 头,但 S3 签名串用到了 “Expect:100-continue” 这引发了我们的进一步调查。

通过排查 JuiceFS 代码及 S3 SDK 发现是 S3 SDK 处理重试时,如果上传请求体大于 2M 时,会默认加上 “Expect:100-Continue”。然而,Nginx 在处理此类请求时,虽然遵循 HTTP 规范返回了 100 状态码,但在后续向 S3 转发请求时,却未保留该头部,S3 在收到请求后校验签名,由于没有收到 Expect 请求头,会使用默认的 “Expect:100-continue”,最终由于 Expect 请求头的值大小写不一致,导致签名校验失败,导致 juicefs mount 进程会重试阻塞。

针对此问题,我们提出了两种修复方案:

  • 一是将 SDK 中的“Expect: 100-Continue”头部修改为规范的小写形式;
  • 二是为 Nginx 添加一个不启用“Expect: 100-continue”的选项,这个选项是很必要的,可以减少一次网络交互。

这两种方案均能有效解决问题,我们向 S3 SDK 社区 JuiceFS 社区提交了 pr。

其他 Tips

文件权限

经常会出现在一批机器中,存在用户名相同但 uid 不相同的情况。针对这种情况,建议如果要做隔离,就在挂载时做好隔离,比如使用挂载子目录的方式来做隔离。同时,文件写入方负责文件权限的正确分配,确保其他文件使用方能有正确的权限。

元数据备份

当元数据数量庞大时,可能会遇到备份失败的情况。如果你的元数据引擎已经具备副本机制或者你已经实施了定时备份策略,那么可以考虑关闭元数据备份功能,以节省资源。

k8s-CSI

在使用 k8s-CSI 时,开可以选择禁用 format 选项。具体操作是,给 k8s-CSI只提供name和metaUrl两个参数即可。这样一来,在 k8s-CSI 运行过程中,就不会实际执行 format过程。这一做法能够带来两大好处:

首先,它能够保护我们的安全信息,如 S3 密钥等敏感数据不被泄露。由于format 过程中可能涉及文件系统的关键信息,禁用该功能能够减少信息暴露的风险。

其次,它允许存储提供方来管理文件系统的配置。在将JuiceFS交付给容器云平台之前,我们已经完成了文件系统的配置工作。这样一来,容器云平台就可以直接使用已经配置好的文件系统,无需再进行额外的配置或调整。

05 未来展望

分布式元数据引擎

我们注意到当前在某些场景中,对元数据性能的要求较高。针对这些场景,我们可能会考虑使用 Redis。然而,目前的 Redis 存在容量瓶颈问题,因为它为了保证事务的一致性,只使用了集群中的一个节点进行数据存储,无论集群中部署了多少个节点,实际上只有一个节点在运行,这导致了容量不能水平扩展。

此外,Redis 在运维方面也存在不便之处。因此,我们部门内部正在开发一个分布式的 KV 存储系统。在系统的调研阶段,我们已经与相关部门进行了多轮的沟通。

分布式缓存

通过引入分布式缓存,我们可以更有效地处理大数据场景下的数据存储和访问需求,进一步提升系统的整体性能和稳定性。

希望这篇内容能够对你有一些帮助,如果有其他疑问欢迎加入
JuiceFS 社区
与大家共同交流。

作为后端开发,进行Web Api 调试,除了使用 Postman, Apifox 等 Web Api 调试工具之外,我想使用Swagger进行调试应该是更方便,更常用的方式了吧。

那么在需要 token 授权的情况下,每次调试都需要调用一下登录API, 然后复制返回的 token, 再粘贴到上图的文本框里,点一下Authorize按钮,是不是很麻烦呢?

我的懒病犯了,我真的不想再复制粘贴token啦~

要是可以只要调用一下登录接口,返回的token就自动添加进去,自动授权,那该多好啊~

Let's go!

第一步:添加 custom.js 文件
  1. 在 webapi 的项目中添加
    wwwroot
    文件夹;
  2. 新建
    custom.js
    文件到
    wwwroot/swagger-ui/
    目录下,目录没有自己建,当然js文件名自己随便取;
  3. 编辑
    custom.js
    文件内容:

console.log("Custom JS has been loaded and executed.");  
 
const originalFetch = window.fetch;  
  
window.fetch = function(...args) {  
    const [resource, config] = args;  
  
    return originalFetch.apply(this, args).then(response => {  
        // 检查请求的 URL        
        const requestUrl = new URL(response.url, window.location.origin).pathname;  
        if (requestUrl.endsWith("/login")) {  
            // 克隆响应以便读取  
            response.clone().json().then(data => {  
                const token = data.result.token;  
                if (token) {  
                    console.log("Token received via fetch override:", token);  
                    authorizeSwagger(token);  
                } else {  
                    console.warn("Token not found in login response.");  
                }            
            }).catch(err => {  
	            console.error("Failed to parse login response:", err);  
            });        
        }        
        return response;  
    });
}
  
// 定义授权函数  
function authorizeSwagger(token) {  
    const bearerToken = 'Bearer ' + token;  
    console.log("Setting Swagger UI Authorization with token:", bearerToken);  
    if (window.ui && window.ui.authActions) {  
        window.ui.authActions.authorize({  
            Bearer: {  
                name: 'Bearer',  
                schema: {  
                    type: 'apiKey',  
                    in: 'header',  
                    name: 'Authorization',  
                    description: '',  
	            },                
	            value: bearerToken,  
	        }        
        });        
        console.log("Authorization set successfully");  
    } else {  
        console.warn("Swagger UI authActions not available yet.");  
    }
}
    
第二步:启用静态文件服务


Program.cs
中,启用静态文件服务;

var app = builder.Build();

// 启用静态文件服务
app.UseStaticFiles();
第三步:SwaggerUI 中间件中注入 js 脚本

使用
InjectJavascript
的方式将上面的
custom.js
注入到SwaggerUI中间件中。

// 启用Swagger  
app.UseSwagger();  
app.UseSwaggerUI(options =>  
{  
    options.InjectJavascript("/swagger-ui/custom.js");  
    options.SwaggerEndpoint("/swagger/v1/swagger.json", "v1");  
});

OK!

大家快来试试吧~

【引言】

本案例将展示如何使用鸿蒙NEXT框架开发一个简单的世界时钟应用程序。该应用程序能够展示多个城市的当前时间,并支持搜索功能,方便用户快速查找所需城市的时间信息。在本文中,我们将详细介绍应用程序的实现思路,包括如何获取时区信息、更新城市时间、以及如何实现搜索高亮功能。

【环境准备】

• 操作系统:Windows 10

• 开发工具:DevEco Studio NEXT Beta1 Build Version: 5.0.3.806

• 目标设备:华为Mate60 Pro

• 开发语言:ArkTS

• 框架:ArkUI

• API版本:API 12

【实现思路】

1. 组件结构设计

我们的应用程序主要由两个核心组件构成:CityTimeInfo类和WorldClockApp组件。CityTimeInfo类用于存储每个城市的名称、当前时间和时区信息。WorldClockApp组件则负责管理城市时间列表、搜索功能以及用户界面。

2. 获取时区信息

在应用程序启动时,我们需要获取可用的时区信息。通过调用i18n.TimeZone.getAvailableIDs()方法,我们可以获取所有可用的时区ID。接着,我们使用这些ID创建CityTimeInfo实例,并将其添加到城市时间列表中。为了确保用户能够看到当前时间,我们还添加了北京的时间信息作为默认城市。

3. 更新时间逻辑

为了实时更新城市的当前时间,我们在updateAllCityTimes方法中实现了时间更新逻辑。通过获取系统的语言环境和相应的日历对象,我们可以根据城市的时区ID获取当前的年、月、日、时、分、秒,并将其格式化为字符串。这个方法会在页面显示时每秒调用一次,确保时间信息的准确性。

4. 搜索功能实现

为了提升用户体验,我们实现了搜索功能,允许用户通过输入关键词来筛选城市。在highlightSearchText方法中,我们对城市名称进行分段处理,将匹配的关键词高亮显示。通过这种方式,用户可以快速找到所需的城市,并且高亮的文本能够提供更好的视觉反馈。

5. 用户界面构建

最后,我们使用build方法构建用户界面。界面包括一个搜索框、城市名称和时间的显示区域。我们使用了Column和Row组件来布局,并通过设置样式属性来美化界面。滚动区域的实现使得用户可以方便地浏览多个城市的信息。

【完整代码】

import { i18n } from '@kit.LocalizationKit' // 导入国际化模块,用于处理多语言
import { inputMethod } from '@kit.IMEKit' // 导入输入法模块

@ObservedV2
  // 观察者装饰器,用于观察状态变化
class CityTimeInfo { // 定义城市时间信息类
  @Trace cityName: string = ""; // 城市名称,初始为空字符串
  @Trace currentTime: string = ""; // 当前时间,初始为空字符串
  timeZone: i18n.TimeZone; // 时区属性

  constructor(cityName: string, timeZone: i18n.TimeZone) { // 构造函数,接收城市名称和时区
    this.cityName = cityName; // 设置城市名称
    this.timeZone = timeZone; // 设置时区
  }

  @Trace isVisible: boolean = true; // 是否可见,初始为true
}

@Entry
  // 入口组件装饰器
@Component
  // 组件装饰器
struct WorldClockApp { // 定义世界时钟应用组件
  @State private searchText: string = ''; // 搜索文本,初始为空字符串
  @State private cityTimeList: CityTimeInfo[] = []; // 城市时间信息列表,初始为空数组
  private lineColor: string = "#e6e6e6"; // 边框颜色
  private titleBackgroundColor: string = "#f8f8f8"; // 标题背景色
  private textColor: string = "#333333"; // 文字颜色
  private basePadding: number = 4; // 内边距
  private lineWidth: number = 2; // 边框宽度
  private rowHeight: number = 50; // 行高
  private ratio: number[] = [1, 1]; // 列宽比例
  private textSize: number = 14; // 基础字体大小
  private updateIntervalId = 0; // 更新间隔ID

  updateAllCityTimes() { // 更新所有城市的时间
    const locale = i18n.System.getSystemLocale(); // 获取系统语言环境
    for (const cityTime of this.cityTimeList) { // 遍历城市时间列表
      const timeZoneId: string = cityTime.timeZone.getID(); // 获取时区ID
      const calendar = i18n.getCalendar(locale); // 获取日历对象
      calendar.setTimeZone(timeZoneId); // 设置日历的时区

      // 获取当前时间的各个部分
      const year = calendar.get("year").toString().padStart(4, '0'); // 年
      const month = calendar.get("month").toString().padStart(2, '0'); // 月
      const day = calendar.get("date").toString().padStart(2, '0'); // 日
      const hour = calendar.get("hour_of_day").toString().padStart(2, '0'); // 小时
      const minute = calendar.get("minute").toString().padStart(2, '0'); // 分钟
      const second = calendar.get("second").toString().padStart(2, '0'); // 秒

      // 更新城市的当前时间字符串
      cityTime.currentTime = `${year}年${month}月${day}日 ${hour}:${minute}:${second}`;
    }
  }

  onPageShow(): void { // 页面显示时的处理
    clearInterval(this.updateIntervalId); // 清除之前的定时器
    this.updateIntervalId = setInterval(() => { // 设置新的定时器
      this.updateAllCityTimes(); // 每秒更新所有城市的时间
    }, 1000);
  }

  onPageHide(): void { // 页面隐藏时的处理
    clearInterval(this.updateIntervalId); // 清除定时器
  }

  private highlightSearchText(cityTime: CityTimeInfo, keyword: string) { // 高亮搜索文本
    let text = cityTime.cityName // 获取城市名称

    if (!keyword) { // 如果没有关键词
      cityTime.isVisible = true // 设置城市可见
      return [text] // 返回城市名称
    }
    let segments: string[] = []; // 存储分段文本
    let lastMatchEnd: number = 0; // 上一个匹配结束的位置
    while (true) { // 循环查找关键词
      const matchIndex = text.indexOf(keyword, lastMatchEnd); // 查找关键词位置
      if (matchIndex === -1) { // 如果没有找到
        segments.push(text.slice(lastMatchEnd)); // 添加剩余文本
        break; // 退出循环
      } else {
        segments.push(text.slice(lastMatchEnd, matchIndex)); // 添加匹配前的文本
        segments.push(text.slice(matchIndex, matchIndex + keyword.length)); // 添加匹配的关键词
        lastMatchEnd = matchIndex + keyword.length; // 更新最后匹配结束位置
      }
    }
    cityTime.isVisible = (segments.indexOf(keyword) != -1) // 设置城市可见性
    return segments; // 返回分段文本
  }

  aboutToAppear() { // 组件即将出现时的处理
    const timeZoneIds: Array<string> = i18n.TimeZone.getAvailableIDs(); // 获取可用时区ID列表

    this.cityTimeList.push(new CityTimeInfo('北京 (中国)', i18n.getTimeZone())); // 添加北京的城市时间信息

    for (const id of timeZoneIds) { // 遍历时区ID
      const cityDisplayName = i18n.TimeZone.getCityDisplayName(id.split('/')[1], "zh-CN"); // 获取城市显示名称
      if (cityDisplayName) { // 如果城市名称存在
        this.cityTimeList.push(new CityTimeInfo(cityDisplayName, i18n.getTimeZone(id))); // 添加城市时间信息
      }
    }

    this.updateAllCityTimes(); // 更新所有城市的时间
  }

  build() { // 构建组件的UI
    Column({ space: 0 }) { // 创建一个垂直列
      Search({ value: $$this.searchText })// 创建搜索框
        .margin(this.basePadding)// 设置边距
        .fontFeature("\"ss01\" on") // 设置字体特性
      Column() { // 创建一个列
        Row() { // 创建一行
          Text('城市')// 显示“城市”文本
            .height('100%')// 高度占满
            .layoutWeight(this.ratio[0])// 设置布局权重
            .textAlign(TextAlign.Center)// 文本居中
            .fontSize(this.textSize)// 设置字体大小
            .fontWeight(600)// 设置字体粗细
            .fontColor(this.textColor) // 设置字体颜色
          Line().height('100%').width(this.lineWidth).backgroundColor(this.lineColor) // 创建分隔线
          Text('时间')// 显示“时间”文本
            .height('100%')// 高度占满
            .layoutWeight(this.ratio[1])// 设置布局权重
            .textAlign(TextAlign.Center)// 文本居中
            .fontSize(this.textSize)// 设置字体大小
            .fontWeight(600)// 设置字体粗细
            .fontColor(this.textColor) // 设置字体颜色
        }.height(this.rowHeight).borderWidth(this.lineWidth).borderColor(this.lineColor) // 设置行高和边框
        .backgroundColor(this.titleBackgroundColor) // 设置背景色
      }.width(`100%`).padding({ left: this.basePadding, right: this.basePadding }) // 设置列宽和内边距

      Scroll() { // 创建可滚动区域
        Column() { // 创建一个列
          ForEach(this.cityTimeList, (item: CityTimeInfo) => { // 遍历城市时间列表
            Row() { // 创建一行
              Text() { // 创建文本
                ForEach(this.highlightSearchText(item, this.searchText), (segment: string, index: number) => { // 高亮搜索文本
                  ContainerSpan() { // 创建容器
                    Span(segment)// 创建文本段
                      .fontColor(segment === this.searchText ? Color.White : Color.Black)// 设置字体颜色
                      .onClick(() => { // 点击事件
                        console.info(`高亮文本被点击:${segment}`); // 输出点击的文本
                        console.info(`点击索引:${index}`); // 输出点击的索引
                      });
                  }.textBackgroundStyle({
                    // 设置文本背景样式
                    color: segment === this.searchText ? Color.Red : Color.Transparent // 根据是否匹配设置背景色
                  });
                });
              }
              .height('100%') // 高度占满
              .layoutWeight(this.ratio[0]) // 设置布局权重
              .textAlign(TextAlign.Center) // 文本居中
              .fontSize(this.textSize) // 设置字体大小
              .fontColor(this.textColor) // 设置字体颜色

              Line().height('100%').width(this.lineWidth).backgroundColor(this.lineColor) // 创建分隔线
              Text(item.currentTime)// 显示当前时间
                .height('100%')// 高度占满
                .layoutWeight(this.ratio[1])// 设置布局权重
                .textAlign(TextAlign.Center)// 文本居中
                .fontSize(this.textSize)// 设置字体大小
                .fontColor(this.textColor) // 设置字体颜色
            }
            .height(this.rowHeight) // 设置行高
            .borderWidth({ left: this.lineWidth, right: this.lineWidth, bottom: this.lineWidth }) // 设置边框宽度
            .borderColor(this.lineColor) // 设置边框颜色
            .visibility(item.isVisible ? Visibility.Visible : Visibility.None) // 根据可见性设置显示状态
          })

        }.width(`100%`).padding({ left: this.basePadding, right: this.basePadding }) // 设置宽度和内边距
      }
      .width('100%') // 设置宽度占满
      .layoutWeight(1) // 设置布局权重
      .align(Alignment.Top) // 对齐方式
      .onScrollStart(() => { // 滚动开始事件
        this.onPageHide() // 页面隐藏处理
      })
      .onScrollStop(() => { // 滚动停止事件
        this.onPageShow() // 页面显示处理
      })
      .onTouch((event) => { // 触摸事件
        if (event.type == TouchType.Down) { // 如果是按下事件
          inputMethod.getController().stopInputSession() // 停止输入会话
        }
      })
    }
  }
}


大家好,我是痞子衡,是正经搞技术的痞子。今天痞子衡给大家分享的是
i.MXRT1170上PXP对CM7 TCM进行随机地址短小数据写入操作限制

在 MCU 里能够对片内外映射的存储器进行读写操作的主设备(Master)除了常见的 Core 以及 DMA 外,其实还有一些面向高速数据传输(比如 USB、uSDHC、ENET 接口等)或其他特定功能(比如 GPU、LCD、Crypto 等)的外设,但就用户数据搬移处理而言,一般我们只借助 Core 和 DMA。

在 i.MXRT 四位数上,还有一个叫 PXP 的外设,这本是一个面向像素数据处理的模块,但是它也能够完成一般数据搬移处理任务。当我们借助这个 PXP 来做数据搬移时,发现它在对 CM7 TCM 写入时有一些使用限制。今天我们就来聊聊这个话题:

一、PXP功能简介

先来看一下 PXP 模块功能框图,既然是面向图像数据处理,那常见的图像缩放、色彩空间转换、图像旋转功能支持必不可少(即下图蓝框里的三个独立引擎被整合在 PXP 里),这些操作实际上都涉及到 FrameBuffer 像素数据处理(读改写) 。

再进一步细读 PXP 特性,我们发现除了像素处理之外,它还是个标准的 2D DMA(这里 2D 的意思是为搬移二维图像数据而设计的),这就是我们所要的数据搬移特性。在用 PXP 做数据搬移操作时,当源 FrameBuffer 和目的 FrameBuffer 大小相同,且搬移目标尺寸就是 FrameBuffer 长度时,其就蜕变成了大家所熟悉的普通 DMA。

二、一个RT1170的Errata

在我们实测 PXP 数据搬移功能时,我们先来看一个 RT1160/1170 上独有的 Errata,也正是因为这个 Errata 让痞子衡关注到了 PXP 的 2D DMA 功能。

这个 Errata 提及到 RT1160/1170 里若干个具有存储器读写能力的主设备在对 CM7 TCM 进行 Sparse write(随机地址短小数据写入操作)时可能会导致数据出错,PXP 就是其一,解决方案就是 CM7 TCM 不要作为目的 FrameBuffer。

  • Note:列出来的有限制的主设备大多是 RT1170 里新增的外设(CAAM, ENET_1G, ENET_QOS, GC355, LCDIFv2),除了 PXP 是 RT10xx 上也存在的,但是 RT10xx PXP 并没有这个限制。

三、在PXP下实测数据搬移

要实测 PXP 数据搬移功能可以直接借助 \SDK_2_16_000_MIMXRT1170-EVKB\boards\evkbmimxrt1170\driver_examples\pxp\copy_pic\cm7 例程,其主要函数 APP_CopyPicture() 摘录如下,代码清晰明了。我们要做不同的测试,只需要将 s_inputBuf、s_outputBuf 分别链接在不同存储器空间里,并且设置不同的拷贝块大小与坐标位置即可。

  • Note:仅需调整 COPY_WIDTH、DEST_OFFSET_X 值来测试对一维数据搬移影响(数据长度、起始地址对齐因素)
#include "fsl_pxp.h"
// 源/目标 Buffer 长宽设置(为测试方便,可设置成一样)
#define BUF_WIDTH   64
#define BUF_HEIGHT  64
// 拷贝块长宽及在目标 Buffer 坐标设置(从源 Buffer 坐标固定为 [0,0])
#define COPY_WIDTH        8
#define COPY_HEIGHT       8
#define DEST_OFFSET_X     1
#define DEST_OFFSET_Y     1

uint16_t s_inputBuf[BUF_HEIGHT][BUF_WIDTH];
uint16_t s_outputBuf[BUF_HEIGHT][BUF_WIDTH];

static void APP_CopyPicture(void)
{
    pxp_pic_copy_config_t pxpCopyConfig;
    // 设置拷贝参数(将s_inputBuf里坐标[0,0]开始的大小为8x8的数据拷贝到s_outputBuf里[1,1]位置处)
    // 源 Buffer 地址与拷贝块坐标设置
    pxpCopyConfig.srcPicBaseAddr  = (uint32_t)s_inputBuf;
    pxpCopyConfig.srcPitchBytes   = sizeof(uint16_t) * BUF_WIDTH;
    pxpCopyConfig.srcOffsetX      = 0;
    pxpCopyConfig.srcOffsetY      = 0;
    // 目的 Buffer 地址与拷贝块坐标设置
    pxpCopyConfig.destPicBaseAddr = (uint32_t)s_outputBuf;
    pxpCopyConfig.destPitchBytes  = sizeof(uint16_t) * BUF_WIDTH;
    pxpCopyConfig.destOffsetX     = DEST_OFFSET_X;
    pxpCopyConfig.destOffsetY     = DEST_OFFSET_Y;
    // 拷贝块大小设置(像素点格式为 RGB565 即 2bytes)
    pxpCopyConfig.width           = COPY_WIDTH;
    pxpCopyConfig.height          = COPY_HEIGHT;
    pxpCopyConfig.pixelFormat     = kPXP_AsPixelFormatRGB565;
    // 启动拷贝(将拷贝块数据从源 Buffer 搬移到目的 Buffer)
    PXP_StartPictureCopy(PXP, &pxpCopyConfig);
    while (!(kPXP_CompleteFlag & PXP_GetStatusFlags(PXP)));
    PXP_ClearStatusFlags(PXP, kPXP_CompleteFlag);
}

经测试当 s_outputBuf 放在 OCRAM 或者外部 RAM 空间里时,搬移结果完全如预期。而当 s_outputBuf 放在 CM7 ITCM 或者 DTCM 时,则会出现异常结果,在 ITCM/DTCM 异常表现是一致的。

设置不同的 COPY_WIDTH、DEST_OFFSET_X 值组合带来的异常结果不尽相同,这里仅放出一个 COPY_WIDTH = 1、DEST_OFFSET_X = 3 的情况供参考,可以看到除了目标地址数据之外,前后还会有一些额外数据被写入,这样的数据搬移操作显然不可靠了。

当然并不是 s_outputBuf 放在 CM7 TCM 就一定能引起异常,只要拷贝的一维数据长度是 16bytes 整数倍,且目的起始地址以 8 对齐时,此时并无出错情况发生。不满足这个条件的写入我们即称之为有风险的 Sparse write(随机地址短小数据写入)。

至此,i.MXRT1170上PXP对CM7 TCM进行随机地址短小数据写入操作限制痞子衡便介绍完毕了,掌声在哪里~~~

欢迎订阅

文章会同时发布到我的
博客园主页

CSDN主页

知乎主页

微信公众号
平台上。

微信搜索"
痞子衡嵌入式
"或者扫描下面二维码,就可以在手机上第一时间看了哦。

Java模拟Oracle函数MONTHS_BETWEEN注意事项

MONTHS_BETWEEN(DATE1, DATE2) 用来计算两个日期的月份差。

最近接到一个迁移需求,把
Oracle SQL
接口迁移到新平台上,但新平台是采用
Java
计算的方式,所以我需求把SQL逻辑转成Java语言。

在遇到
MONTHS_BETWEEN
时,遇到一些奇怪的问题,在此记录一下。

情景在现

一开始,我的大致思路:先计算出两个日期的月份差,再拿开始日期加上月份差再与结束日期计算出日差,如果日差大于0,月份差+1;日差小于0,则月份差-1。

为什么不保留小数?

因为在SQL逻辑中使用到MONTHS_BETWEEN都是用来计算近x个月、未来x个月这类数据,只需要判断是否大于或小于某个整数,所有这里取整是没有问题的(当时是这样想的)。

package com.chen.util;

import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;

import java.text.ParsePosition;
import java.text.SimpleDateFormat;
import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
import java.time.temporal.Temporal;
import java.util.Calendar;
import java.util.Date;
import java.util.Objects;

@Slf4j
public class DateUtil {

    public static final SimpleDateFormat yyyyMMddDateFormat = new SimpleDateFormat("yyyyMMdd");

    public static Date strToDate(String str) {
        if (StringUtils.isBlank(str)) {
            return null;
        }
        return yyyyMMddDateFormat.parse(str, new ParsePosition(0));
    }

    /**
     * 计算两个日期差月份差
     *
     * @param begDate 开始日期
     * @param endDate 结束日期
     * @return 月份差
     */
    public static Integer monthsBetween(Date begDate, Date endDate) {
        try {
            if (Objects.isNull(begDate) || Objects.isNull(endDate)) {
                return null;
            }
            Temporal beg = begDate.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
            Temporal end = endDate.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
            int between = (int) ChronoUnit.MONTHS.between(beg, end);
            Calendar calendar = Calendar.getInstance();
            calendar.setTime(begDate);
            calendar.add(Calendar.MONTH, between);
            Date begDateNew = calendar.getTime();
            Temporal begNew = begDateNew.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
            long dayDiff = ChronoUnit.DAYS.between(begNew, end);
            if (dayDiff > 0) {
                between += 1;
            } else if (dayDiff < 0) {
                between -= 1;
            }
            return between;
        } catch (Exception e) {
            log.warn("DateUtil monthsBetweenWithMon() Occurred Exception.", e);
            return null;
        }
    }

    public static void main(String[] args) {
        System.out.printf("%-9s %-9s %-3s\n", "日期1", "日期2", "月份差");
        String date1 = "20240405", date2 = "20240807";
        Integer between = monthsBetween(strToDate(date1), strToDate(date2));
        System.out.printf("%-10s %-10s  %-3s\n", date1, date2, between);
    }
}

结果与Oracle比对

开始日期 结束日期 JAVA ORACLE
20240405 20240807 5 4.06451612903226
20240715 20240102 -7 -6.41935483870968
20231130 20240131 3 2
20240117 20231224 -1 -0.774193548387097
20240229 20240529 -3 -3
20240229 20240530 -4 -3.03225806451613
20240229 20240531 -4 -3
20240731 20240430 -3 3

结果分析

自测与冒烟测试都没发现问题,正式测试时,发现当两个日期均是月末时,就会导致结果不正确(结果中的20231130与20240131)。

并且还发现Orcale的
MONTHS_BETWEEN
在处理月末时更是打破常规思维!比如
20240731
的近3个月应该是从
20240501
开始计算的;还有一种情况是当两个日期中有一个日期是2月末时,与大月比较29号、30号、31号时,29号与31号的月份差居然是相同的。

查了很多资料最后在
ORACLE 日期函数 MONTHS_BETWEEN
文章中找到原因。

MONTHS_BETWEEN函数返回两个日期之间的月份数。如果两个日期月份内天数相同,或者都是某个月的最后一天,返回一个整数,否则,返回数值带小数,以每天1/31月来计算月中剩余天数。如果日期1比日期2小 ,返回值为负数。

问题解决

思路:
日差 = 如果两个日期都是月末,日差为0,否则 (开始日期日 - 结束日期日)
月差 = (开始日期年份 * 12 + 开始日期月份) - (结束日期年份 * 12 + 结束日期月份) + (日差 / 31)

package com.chen.util;

import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.ParsePosition;
import java.text.SimpleDateFormat;
import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
import java.time.temporal.Temporal;
import java.util.Calendar;
import java.util.Date;
import java.util.Objects;

@Slf4j
public class DateUtil {

    public static final SimpleDateFormat yyyyMMddDateFormat = new SimpleDateFormat("yyyyMMdd");

    public static Date strToDate(String str) {
        if (StringUtils.isBlank(str)) {
            return null;
        }
        return yyyyMMddDateFormat.parse(str, new ParsePosition(0));
    }
    
    /**
     * 判断日期是否是月末
     * @param date 日期
     * @return 是否月末
     */
    public static Boolean isEndOfMonth(Calendar date) {
        if (Objects.isNull(date)) {
            return false;
        }
        return date.get(Calendar.DAY_OF_MONTH) == date.getActualMaximum(Calendar.DAY_OF_MONTH);
    }

    /**
     * 适配ORACLE数据库MONTHS_BETWEEN()计算结果
     * MONTHS_BETWEEN(startDate, endDate)
     *
     * @param startDate 开始时间
     * @param endDate   结果时间
     * @return 月份差
     */
    public static BigDecimal oracleMonthsBetween(Date startDate, Date endDate) {
        Calendar startCalendar = Calendar.getInstance();
        startCalendar.setTime(startDate);
        Calendar endCalendar = Calendar.getInstance();
        endCalendar.setTime(endDate);
        
        int startYear = startCalendar.get(Calendar.YEAR);
        int endYear = endCalendar.get(Calendar.YEAR);
        int startMonth = startCalendar.get(Calendar.MONTH);
        int endMonth = endCalendar.get(Calendar.MONTH);
        int startDay = startCalendar.get(Calendar.DATE);
        int endDay = endCalendar.get(Calendar.DATE);
        // 月份差
        double result = (startYear * 12 + startMonth) - (endYear * 12 + endMonth);
        // 小数月份
        double countDay;
        // 如果是两个日期都是月末,就只处理月份;否则使用日差 / 31 算出小数月份
        if (isEndOfMonth(startCalendar) && isEndOfMonth(endCalendar)) {
            countDay = 0;
        } else {
            countDay = (startDay - endDay) / 31d;
        }
        result += countDay;
        // 返回并保留14位小数位
        return BigDecimal.valueOf(result)
                .setScale(14, RoundingMode.HALF_UP)
                .stripTrailingZeros();
    }

    public static void main(String[] args) {
        System.out.printf("%-9s %-9s %-3s\n", "日期1", "日期2", "月份差");
        String date1 = "20240405", date2 = "20240807";
        BigDecimal between = oracleMonthsBetween(strToDate(date1), strToDate(date2));
        System.out.printf("%-10s %-10s  %-3s\n", date1, date2, between.toPlainString());
    }
}

结果与Oracle比对

开始日期 结束日期 JAVA ORACLE
20240405 20240807 -4.06451612903226 -4.06451612903226
20240423 20240614 -1.70967741935484 -1.70967741935484
20240229 20240529 -3 -3
20240229 20240530 -3.03225806451613 -3.03225806451613
20240229 20240531 -3 -3
20230228 20230528 -3 -3
20231130 20240131 -2 -2
20231130 20240201 -2.06451612903226 -2.06451612903226
20240731 20240430 3 3
20240731 20240429 3.06451612903226 3.06451612903226
20240430 20240731 -3 -3
20240114 20231010 3.12903225806452 3.12903225806452