2024年8月

在数据绑定过程中,我们经常会使用
StringFormat
对要显示的数据进行格式化,以便获得更为直观的展示效果,但在某些情况下格式化操作并未生效,例如
Button

Content
属性以及
ToolTip
属性绑定数据进行
StringFormat
时是无效的。首先回顾一下
StringFormat
的基本用法。

StringFormat
的用法

StringFormat

BindingBase
的属性,指定如果绑定值显示为字符串,应如何设置该绑定的格式。因此,
BindingBase
的三个子类:
Binding

MultiBinding

PriorityBinding
都可以对绑定数据进行格式化。

Binding

Binding
是最常用的绑定方式,使用
StringFormat
遵循
.Net格式字符串标准
即可。例如:

<TextBlock Text="{Binding Price,ElementName=self,StringFormat={}{0:C}}"/>

或者

<TextBlock Text="{Binding TestString,ElementName=self,StringFormat=test:{0}}"/>

其中
{0}
表示第一个数值,如果
StringFormat
属性的值是以花括号开头,前边需要有一对花括号
{}
进行转义,也就是第一个例子中的
{}{0:C}
,否则不需要,如第二个示例一样。
如果设置
Converter

StringFormat
属性,则首先将转换器应用于数据值,然后
StringFormat
应用该值。

MultiBinding

Binding
绑定时,格式化只能指定一个参数,
MultiBinding
绑定时则可指定多个参数。例如:

<TextBlock>
    <TextBlock.Text>
        <MultiBinding StringFormat="{}{0} {1}">
            <Binding Path="FirstName" ElementName="self"/>
            <Binding Path="LastName" ElementName="self"/>
        </MultiBinding>
    </TextBlock.Text>
</TextBlock>

这个例子中
MultiBinding
是由多个子
Binding
组成,
StringFormat
仅在设置
MultiBinding
时适用,子
Binding
中虽然也可以设置
StringFormat
,但是会被忽略。

PriorityBinding

相比于前两种绑定,
PriorityBinding
使用的频率没那么高,它的主要作用是按照一定优先级顺序设置绑定列表, 如果最高优先级绑定在处理时成功返回值,则无需处理列表中的其他绑定。 如果计算优先级最高的绑定需要很长时间,那么将会使用成功返回值的次高优先级,直到优先级较高的绑定成功返回值。
PriorityBinding
和其包含的绑定列表中的子
Binding
也都可以设置
StringFormat
属性。例如:

<TextBlock
    Width="100"
    HorizontalAlignment="Center"
    Background="Honeydew">
    <TextBlock.Text>
        <PriorityBinding FallbackValue="defaultvalue" StringFormat="haha:{0}">
            <Binding IsAsync="True" Path="SlowestDP" StringFormat="hi:{0}"/>
            <Binding IsAsync="True" Path="SlowerDP" />
            <Binding Path="FastDP" />
        </PriorityBinding>
    </TextBlock.Text>
</TextBlock>


MultiBinding
不同的是,
PriorityBinding
的子
Binding
中的
StringFormat
是会生效的,其规则是优先使用子
Binding
设置的格式,其次才使用
PriorityBinding
设置的格式。

Content属性格式化失效的原因

Button

Content
属性可以用字符串赋值并显示在按钮上,但是使用
StringFormat
格式化并不会生效。原本我以为是涉及到类型转换器,在类型转换过程中处理掉了,但这只是猜测,通过源码发现并不是这样的。在
BindingExpressionBase
中有这样一段代码:

internal virtual bool AttachOverride(DependencyObject target, DependencyProperty dp)
{
	_targetElement = new WeakReference(target);
	_targetProperty = dp;
	DataBindEngine currentDataBindEngine = DataBindEngine.CurrentDataBindEngine;
	if (currentDataBindEngine == null || currentDataBindEngine.IsShutDown)
	{
		return false;
	}
	_engine = currentDataBindEngine;
	DetermineEffectiveStringFormat();
	DetermineEffectiveTargetNullValue();
	DetermineEffectiveUpdateBehavior();
	DetermineEffectiveValidatesOnNotifyDataErrors();
	if (dp == TextBox.TextProperty && IsReflective && !IsInBindingExpressionCollection && target is TextBoxBase textBoxBase)
	{
		textBoxBase.PreviewTextInput += OnPreviewTextInput;
	}
	if (TraceData.IsExtendedTraceEnabled(this, TraceDataLevel.Attach))
	{
		TraceData.TraceAndNotifyWithNoParameters(TraceEventType.Warning, TraceData.AttachExpression(TraceData.Identify(this), target.GetType().FullName, dp.Name, AvTrace.GetHashCodeHelper(target)), this);
	}
	return true;
}

其中第11行调用了一个名为
DetermineEffectiveStringFormat
的方法,顾名思义就是检测有效的
StringFormat
。接下来看看里边的逻辑:

internal void DetermineEffectiveStringFormat()
{
	Type type = TargetProperty.PropertyType;
	if (type != typeof(string))
	{
		return;
	}
	string stringFormat = ParentBindingBase.StringFormat;
	for (BindingExpressionBase parentBindingExpressionBase = ParentBindingExpressionBase; parentBindingExpressionBase != null; parentBindingExpressionBase = parentBindingExpressionBase.ParentBindingExpressionBase)
	{
		if (parentBindingExpressionBase is MultiBindingExpression)
		{
			type = typeof(object);
			break;
		}
		if (stringFormat == null && parentBindingExpressionBase is PriorityBindingExpression)
		{
			stringFormat = parentBindingExpressionBase.ParentBindingBase.StringFormat;
		}
	}
	if (type == typeof(string) && !string.IsNullOrEmpty(stringFormat))
	{
		SetValue(Feature.EffectiveStringFormat, Helper.GetEffectiveStringFormat(stringFormat), null);
	}
}

这段代码的作用就是检测有效的
StringFormat
,并通过
SetValue
方法保存起来,从第4~7行代码可以看到,一开始就会检测目标属性的类型是不是
String
类型,不是的话直接返回,绑定表达式中的
StringFormat
也就不会保存了。在后续的
BindingExpression
类计算绑定表达式值时获取到
StringFormat

null
,也就不会进行格式化了。
image

Button

Content
属性虽然可以用字符串赋值,但它其实的
Object
类型。因此,在检测有效的
StringFormat
表达式时直接过滤了。
ToolTip
也同样是
Object
类型。
image

解决方法

对于
Content
这种
Object
类型的属性绑定字符串并且需要格式化时,可以采用以下三种方式解决:

  1. 最通用的方法就是自定义
    ValueConverter
    ,在
    ValueConverter
    中对字符串进行格式化;
  2. 绑定到其他可进行
    StringFormat
    的属性上,比如
    TextBlock

    Text
    属性进行格式化,
    ToolTip
    绑定到
    Text
    上;
  3. 既然是
    Object
    类型,那也可把
    TextBlock
    作为
    Content
    的值。
<Button Width="120" Height="30">
    <Button.Content>
        <TextBlock Text="{Binding TestString,ElementName=self,StringFormat=test:{0}}"/>
    </Button.Content>
</Button>

小结

数据绑定时出现StringFormat失效的主要分为两种情况。一是没有遵循绑定时StringFormat使用的约束,二是绑定的目标属性不是
String
类型。

每个人的职业生涯都是一段充满转折和挑战的旅程,当然每一次职业转型都是一次重新定义自己的机会,从2015年开始,当时我刚踏入IT行业,成为一名Java开发者,后来随着时间的推移,我的职业方向逐渐转向了前端开发者,埋头于代码的世界。最终在2018年找到了属于自己的职业定位——产品经理。一路走来,我不断扩展自己的技能边界,从代码的深度探索,到产品的全面把控,这段经历不仅是我职业发展的缩影,也是我对技术与战略结合的深刻体会。

2015年 - Java开发的初体验

2015年,我进入了IT行业,开始了我的Java开发之旅。那时,我的世界充满了代码、算法和数据库。我热衷于编写后台逻辑,解决复杂的技术难题。第一次用Java成功搭建一个系统时,那种成就感至今难忘。这一年让我扎实掌握了Java的核心技能,培养了严谨的逻辑思维和解决问题的能力。然而,随着项目的深入,我开始对仅仅专注于后台开发感到一些局限。

2016年 - 前端开发的跃升

2016年,我决定拓展自己的技术视野,转向前端开发。我想更贴近用户,了解他们如何与产品互动。转型后的日子充满了挑战,从学习HTML、CSS到掌握JavaScript,我一步步走入了前端的世界。最令我兴奋的是,看到自己的代码直接呈现在用户面前,创造出视觉上赏心悦目的网页和应用。前端开发不仅让我更了解用户体验的重要性,还让我逐渐意识到,一个优秀的产品不仅仅依赖于技术,更在于用户需求的精准把握。

2018年 - 从技术到产品的转型

然而,在与产品经理合作的过程中,我逐渐意识到,自己对整个产品的愿景、设计逻辑以及用户需求有着强烈的兴趣。我不再满足于只完成技术任务,而是开始思考:为什么要这样设计?这个功能对用户来说真的有用吗?我渴望跳出代码的框框,去探索更广阔的产品世界。

于是在2018年,我做出了一个重大决定:从技术开发转型为产品经理。这并不是一条平坦的道路,我需要快速掌握市场分析、需求定义、产品设计、项目管理等全新的技能,起初,这些新领域让我感到陌生,但也激发了我无尽的好奇心。我开始参与制定产品策略,协调跨职能团队,并主导项目的从零到一的全过程。在这个过程中,我深刻体会到,产品经理不仅要有技术背景,还需要具备战略眼光和用户思维。

结语

从Java开发到前端开发,再到产品管理,我的职业经历是一段不断探索、不断挑战自我的旅程。这些年的转型让我深刻认识到,技术只是职业发展的起点,而对用户、市场和战略的把握,才是成就一个产品经理的关键。在未来的职业道路上,我将继续融合技术与管理的优势,推动更多创新产品的诞生,实现更大的职业突破。

接下来,我会分享从0到1设计获客系统和支付系统的实战干货,每一步都有亲身经验和独家技巧。为了让你更直观地理解,我将重点探讨以下内容:

获客系统:

1.短视频获客系统的背景与重要性

为什么短视频在当今市场如此关键?我会深入解析其背后的逻辑和发展趋势。

2.市场现状与竞品分析

了解市场动态和竞争对手是设计获客系统的基础。我会分享如何高效进行市场调研和竞品分析。

3.需求分析

确定目标用户的需求是系统设计的核心,我将展示如何精准把握用户需求并转化为系统需求。

4.
商业模式设计

成功的获客系统不仅仅是技术的体现,更需要与商业模式相辅相成。我会剖析如何设计出既能吸引用户又能带来收益的商业模式。

5.
功能规划

系统功能的规划直接影响用户体验和系统的可扩展性,我会分享功能规划的关键策略。

6.
产品设计

最后,我会讲解如何将以上内容融入实际的产品设计中,从概念到实现,全流程详解。

如果你也渴望快速提升自己的产品设计能力,欢迎加入我们的学习群或者我个人微信,在这里我们可以一起交流、共同成长!机会难得,千万别错过哦!

Clobotics 是一家将计算机视觉和机器学习技术应用于风电以及零售行业的企业。在风电行业,Clobotics 利用无人机对风力发电机叶片进行检查,显著降低了对人工作业的依赖。在零售领域,公司通过分析捕获的包装商品图像来提供基于实时数据的洞察,以增加销售额并减少运营成本。

存储方面,Clobotics 原本直接使用云 SDK,而部分系统则使用了内部的封装器,没有形成统一的存储层,同时还面临多云架构、海量小文件、兼容性方面的挑战。改造存储层的过程中, Clobotics 对 Ceph、SeaweedFS 和 JuiceFS 等文件系统方案进行了比较,最终选择使用 JuiceFS。

JuiceFS 支持接入几乎所有主要公有云平台,并能有效处理大量小文件的存储问题。其完全的 POSIX 兼容性允许我们在 JuiceFS 上实现整个数据流程,显著降低技术工程的工作量和成本。

目前,在 Clobotics 内部,风电和零售两个业务场景都已经接入 JuiceFS,涉及到业务访问,数据标注和模型训练场景,后续仍在扩展新的场景接入。

01 Clobotics 业务架构以及存储需求

Clobotics 有两大业务模块,风电与零售。 下图是我们的技术架构图,在基础设施层面,我们采用了标准化的服务组件,包括配置中心(如 Apollo)、服务注册中心(如 Nacos)、以及监控、日志与告警系统等,这些系统主要依赖于业界广泛认可的开源组件,如利用 Elasticsearch 与 Grafana 进 行日志与监控数据的可视化展示,以及 Prometheus 作为监控指标的收集工具。

进一步向上,是通用服务层,其核心在于对各类资产数据的集中管理,涵盖了多领域,如风电行业的风机、零售行业的门店与超市,以及我们自有资产如无人机、零售用冷柜等。此外,IAM(身份认证与访问管理)系统负责用户权限的分配与管理,确保系统安全。

针对数据处理过程中不可避免的实时、准实时及批处理需求,我们设计并实施了统一的工作流与调度中心。此中心融合了 Apache Airflow 等开源组件,以应对批处理场景;同时,针对 Airflow 无法完全覆盖的特定需求,我们自行开发了定制化的服务以增强调度能力。在公共服务层面,我们特别抽离了 AI 模型服务,旨在实现 AI 能力的共享与复用。

计算机视觉场景的数据特点

我们的核心数据类型包括各类采集的图片,这些图片在规格、像素清晰度上差异显著。每月新增约 5000 万张原始采集图片,涵盖风电与零售两大领域,数据特点如下:

海量小文件: 风电场景下的图片原始文件可达十余兆,即便经过压缩处理,其体积依然不容忽视。此类图片在标注过程中需逐一细致查看,考虑到网络传输效率与标注工作的流畅性,我们采取了“Tile Image”技术,即将大图切分为类似地图瓦片的小图,以提高加载速度与查看效率。然而,这一方法也导致了文件数量的激增,特别是最底层的小图,其体积虽小但数量庞大。零售场景,我们每月需处理约 200 至 300 万个切图命令,高峰时可达 500 个以上。

二十多种类型模型训练:涵盖通用与垂直领域,迭代周期各异(周、月、季度),确保模型能够适应不同场景的需求。

元数据性能要求高:如 CSV 和 JS 文件,它们是 AI 模型训练过程中不可或缺的数据输入格式。此外,模型文件作为线上服务的关键组成部分,需频繁更新与迭代,且体积较大,对存储性能提出了更高要求。

针对新增数据的管理:随着新站点的不断加入,需定期更新或刷新这些数据,这一过程中会产生额外的 I/O 操作。同时,报告生成后需临时存储在特定位置,以便用户后续下载或分享。

版本管理:这是是我们不可忽视的一环,特别是针对原数据和图片数据集。在零售场景中,客户需求的快速变化要求我们对数据集进行精细化的版本控制。而在风电场景中,为实现对不同叶片叶形的精细化管理,数据集的切分与版本管理亦需更加细致

多云架构对存储层建设的挑战

我们采用了多云存储解决方案,包括 Azure Blob Storage、阿里云 OSS、Google Cloud Storage(GCS)、Amazon S3,以及单机版或小集群模式的 MinIO。主要是源于不同客户环境的适应性需求。

由于不同客户所选择的云服务商各异,我们需要不断进行适配工作,以支持不同技术栈(如.NET、Go、Python、C++、Java等)下的数据访问需求,这无疑增加了架构的复杂性与运维的挑战。除此以外,由于风电与零售等业务平台在功能与场景上的差异性,我们不得不面对一定程度的重复开发工作,这对初创企业的研发资源构成了不小的压力。

再者,跨云架构,在进行数据标注、模型训练等操作时,需从多个云存储服务中拉取数据,这不仅增加了数据迁移的复杂性,还可能因频繁的数据读取而产生不必要的成本。因此,如何在保证数据一致性与安全性的同时,优化跨云数据存储与访问策略,成为我们亟待解决的问题。

02 文件存储选型:POSIX、云原生、低运维

鉴于我们场景的数据特点和以及多云架构给数据存储带来的挑战,我们重新审视并思考如何构建一个更为轻量、灵活的存储层架构。
这一架构需要灵活应对不同业务场景下的数据存储需求,同时确保在引入新的云存储服务时,能够以极低的成本甚至无成本的方式实现快速接入

在最初的选型过程中,我们充分考量了市场上主流及开源的存储解决方案。经过深入调研,我们首先将 HDFS 排除在外,尽管它在国内众多公司中被广泛应用。但针对我们的需求,其设计初衷更偏向于处理大数据量高吞吐的场景,而非我们面临的文件数量众多且需定期清理数据的情况。
HDFS 的 NameNode 在文件数量激增时会承受巨大压力,且数据删除操作成本较高,加之其 POSIX 兼容性不足,因此不符合我们的要求

Name POSIX-compatible CSI Driver Scalability Operation Cost Document
HDFS No No Good High Good
Ceph Yes Yes Medium High Good
SeaweedFS Basic Yes Medium High Medium
GlusterFS Yes Not mature Medium Medium Medium
JuiceFS Yes Yes Good Low Good

随后,鉴于当前多数公司倾向于在 Kubernetes 上进行服务部署与运维,CSI Driver 成为了我们评估存储解决方案时的必要考量因素
。我们当前的数据量仅为 700TB,并以较低的增长率增长,因此可扩展性并非我们的首要关注点。运维成本却是我们必须严格控制的,作为一家创业公司,我们希望在基础设施上尽量减少人力投入,以便集中资源于核心业务。

在评估 Ceph 时,我们发现其安装与部署相对简便,但运维成本较高,特别是在容量规划与扩容方面存在挑战,Ceph 的文档虽然丰富,但组织有一些杂乱,增加了上手难度。

SeaweedFS 作为一个表现不俗的开源项目,因同事有相关的运维经验而进入我们的视野,但最终还是因其运维成本较高及文档完善度不足而被放弃;GlusterFS 则因其轻量级的运维与扩容特性获得了一定关注,尽管自建存储层会带来一定的运维成本上升,但总体仍在可接受范围内。

最终,我们选择了 JuiceFS。JuiceFS 以其完全的 POSIX 兼容性和对云原生环境的支持吸引了我们的注意。在运维成本方面,JuiceFS 主要依赖于轻量级的元数据引擎,如 MySQL 或 Redis,这些均是我们现有技术栈中的组成部分,无需额外引入新组件,从而大大降低了运维复杂度。此外, JuiceFS 的文档清晰易懂,对于新手而言也能快速上手。

03 JuiceFS 应用实践

在选定 JuiceFS 作为我们的存储层解决方案后,我们的整体存储架构已逐步构建并优化至当前形态。在此我仅聚焦于我们实际应用的几个关键环节进行详细说明。

首先,在模型训练环节, FUSE 模块发挥着核心作用
。模型训练需处理大量数据,且这些数据通常存储于云端,我们利用高性能的实体机搭配充足显卡资源,以满足模型训练的计算与调度需求。而所有模型训练任务均集中于单一高性能机器上执行。因此,我们采用 Fuse 挂载的方式,将云端不同存储源的数据同步至本地目录,形成本地可访问的存储空间。这一过程中,我们处理的最大单个训练数据集达到百万级别,数据稳定性高,主要用于零售场景下的快速识别模型训练。

其次,在资源管理与访问控制环节, CSI Driver 的应用则主要体现在以 Mount Pod方式进行
。此方式简化了部署流程与Pod的组织结构,同时,通过内部调度器的精细控制,有效避免了不同Pod 间资源访问的冲突与并发读写问题。初期虽偶有死锁现象,但通过优化数据集管理与访问调度策略,现已实现高效的并发控制。

至于 S3 Gateway,其意外地成为我们选型 JuiceFS 后的又一重要收获
。原本我们计划构建独立的文件服务以共享内部文件,但需面对复杂的权限与时效性问题。而 S3 Gateway 不仅提供了基于角色的权限控制,满足了我们的基本需求,还通过 Security Token 机制实现了对共享链接时效性的精细管理,有效防止了数据被恶意抓取的风险。

使用 JuiceFS 后所获得的收益如下:

  1. 统一存储层
    :首先,JuiceFS 实现了我们最初选型的核心目标,即提供了一个统一的存储层。这一层不仅简化了数据存储的管理,还提升了整体的数据访问效率。
  2. 云存储接入的灵活性
    :随着业务发展,我们能够更轻松地接入新的云存储服务或类型,无需对现有架构进行大规模调整,增强了系统的可扩展性和适应性。
  3. 简化权限管理
    :通过 JuiceFS 内置的 ACL 机制,我们能够满足大多数场景下的权限管理需求。虽然对于特别庞大或复杂的业务环境,这一功能可能需要额外扩展,但对于我们而言,已经足够满足日常需求。
  4. 跨云存储版本管理
    :JuiceFS 允许我们有效管理不同云存储服务上的数据版本,确保数据的一致性和可追溯性,为业务决策提供了坚实的数据支持。
  5. 性能监控与优化
    :借助 JuiceFS,我们能够收集并分析存储层的性能指标,从而更准确地评估和优化系统性能。这一能力在裸用云存储时难以实现,因为云存储的原数据管理对普通用户而言通常是不透明的。
  6. 元数据管理透明化
    :JuiceFS 使我们能够更容易地获取和管理文件的原数据,如写入时间和更新时间等,这对于数据修复、分层存储等高级操作至关重要。
  7. POSIX 兼容性
    :JuiceFS 的 POSIX 兼容性意味着无论使用何种编程语言或技术栈,开发人员都可以利用标准的文件 API 进行操作,无需额外学习成本,提高了开发效率和系统兼容性。
  8. 简化运维
    :JuiceFS 的运维相对简单,主要关注 Redis 或 MySQL 等元数据服务的健康状态即可。这一特点降低了运维难度,减少了因系统维护不当导致的停机风险。
  9. 成本节约
    :最为意外的收获是,通过 JuiceFS 的有效数据集管理,我们显著减少了重复数据的上传和存储。这一改进不仅降低了存储成本,还通过减少不必要的数据拷贝节省了运营成本。此外,对重复数据的清理也进一步提高了存储效率。

在使用 JuiceFS 时,我们采取了以下几点策略以优化数据存储和管理:

  1. 独立实例架构便于数据隔离与合并
    :我们优先采用独立的实例架构,使用不同的元数据引擎来精确管理各种数据存储需求。这种方法比构建统一的大型存储集群更能减少复杂性和管理难题。考虑到不同客户数据间的隔离需求和不同通用场景下的数据合并挑战,我们将数据根据特性和用途分配到各自独立的实例中。这不仅便于针对特定领域如实验数据进行快速访问,也降低了数据恢复的难度和成本。在模型训练中,增加冗余节点和重试机制帮助快速恢复训练,减少对训练周期的影响。

  2. 数据集版本管理与隔离
    :我们通过多层目录结构和特定命名规范来管理数据版本,以应对零售等场景中商品包装频繁更新的挑战;并通过统一编码的前缀管理系统,确保在模型训练或数据读取时能够迅速定位到所需数据集的特定版本;同时,采用多层目录下的子节点排列组合,实现对不同数据集版本的高效管理与快速组合,提高了数据处理的灵活性和效率。

04 未来规划

优化数据预热流程
:目前,我们采取的方法是将 JuiceFS 挂载至本地,并通过拷贝方式将数据转移至模型训练的本地目录中,这一方式在初期实施时即被识别为效率较低。鉴于 JuiceFS 已提供诸如 caching 和 prefetch 等高级功能,我们计划深入调研并充分利用这些内置功能,以实现数据的智能缓存,从而更有效地管理数据集,提升数据访问速度。

跨地域数据访问的优化
:在特定场景下,我们需访问位于欧洲的数据,而由于数据保护政策限制,这些数据不得被传输至欧洲以外地区。然而,临时访问是被允许的。当前,我们采用内部部署的 CDN 解决方案来应对这一需求,以控制成本并避免使用可能不够经济的原厂 CDN 服务。展望未来,我们期望能够利用 JuiceFS 的缓存机制,实现短时数据的共享与高效访问,以进一步优化跨地域数据处理的流程。

部署多个 JuiceFS 实例
:我们将进行深入的调优与优化工作。通过精细调整配置参数、优化资源分配以及监控性能表现,我们旨在进一步提升系统的整体效能与稳定性,确保 JuiceFS 能够持续高效地支持我们的业务需求。


背景需求:

系统需要对接到XXX官方的API,但因此官方对接以及管理都十分严格。而本人部门的系统中包含诸多子系统,系统间为了稳定,程序间多数固定Token+特殊验证进行调用,且后期还要提供给其他兄弟部门系统共同调用。

原则上:每套系统都必须单独接入到官方,但官方的接入复杂,还要官方指定机构认证的证书等各种条件,此做法成本较大。

so:

为了解决对接的XXX官方API问题,我们搭建了一套中继系统,顾名思义:就是一套用于请求中转的中继系统。在系统搭建的时,Leader提出要做多套鉴权方案,必须做到
动静结合
身份鉴权。

动静结合:
就是动态Token 和 静态固定Token。

动态Token:用于兄弟部门系统或对外访问到此中继系统申请的Token,供后期调用对应API。

固定Token:用于当前部门中的诸多子系统,提供一个超级Token,此Token长期有效,且不会随意更换。

入坑:


因为刚来第一周我就接手了这个项目。项目处于申请账号阶段,即将进入开发。对接的是全英文文档(申请/对接流程/开发API....),文档复杂。当时我的感觉:OMG,这不得跑路?整个项目可谓难度之大。然后因为对内部业务也不熟悉,上手就看了微服务等相关系统代码,注:每套系统之间文档少的可怜,可以说
系统无文档状态

项目移交的时候,Leader之说让我熟悉并逐渐进入开发,让我请教同事。好嘛,请教了同事。同事也是接了前任离职的文档而已,大家都不是很熟悉。于是同事让我启新的项目也是直接对接微服务形式开发,一顿操作猛如虎。

项目开发第二周,已经打出框架模型并对接了部分API。此时,Leader开会问进度,结果来一句:此项目使用独立API方式运行,部署到Docker,不接入公司的微服务架构。好嘛,几天功夫白费了,真是取其糟粕去其精华~,恢复成WebAPI。

技术实现:


因为之前对身份认证鉴权这一块没有做太多的深入了解,Leader工期也在屁股追,就一句话:怎么快怎么来,先上后迭代。好嘛,为了项目方便,同时为了符合
动静结合的身份认证鉴权 。
于是,我用了
JWT+自定义身份认证
实现了需求。

方案一:多身份认证+中间件模式实现

添加服务:Services.AddAuthentication 默认使用JWT

 //多重身份认证
//默认使用JWT,如果Controller使用 AuthenticationSchemes 则采用指定的身份认证
Services.AddAuthentication(options =>
{
    options.AddScheme<CustomAuthenticationHandler>(CustomAuthenticationHandler.AuthenticationSchemeName, CustomAuthenticationHandler.AuthenticationSchemeName);
    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
    options.RequireHttpsMetadata = false;//设置元数据地址或权限是否需要HTTPs
    options.SaveToken = true;
    options.TokenValidationParameters = new TokenValidationParameters
    {
        ValidateIssuer = true,
        ValidateAudience = true,
        ValidateLifetime = true,
        ValidateIssuerSigningKey = true,
        ValidIssuer = builder.Configuration["Jwt:Issuer"],
        ValidAudience = builder.Configuration["Jwt:Audience"],
        IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["Jwt:SecretKey"]!))
    };
    options.Events = new CustomJwtBearerEvents();
});

自定义身份认证 CustomAuthenticationHandler.cs代码

    public class CustomAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
    {
        public const string AuthenticationSchemeName = "CustomAuthenticationHandler";
        private readonly IConfiguration _configuration;
        public CustomAuthenticationHandler(
            IOptionsMonitor<AuthenticationSchemeOptions> options,
            ILoggerFactory logger,
            UrlEncoder encoder,
            ISystemClock clock,
            IConfiguration configuration)
            : base(options, logger, encoder, clock)
        {
            _configuration = configuration;
        }
        /// <summary>
        /// 固定Token认证
        /// </summary>
        /// <returns></returns>
        protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
        {
            string isAnonymous = Request.Headers["IsAnonymous"].ToString();
            if (!string.IsNullOrEmpty(isAnonymous))
            {
                bool isAuthenticated = Convert.ToBoolean(isAnonymous);
                if (isAuthenticated)
                    return AuthenticateResult.NoResult();
            }

            string authorization = Request.Headers["Authorization"].ToString();
            // "Bearer " --> Bearer后面跟一个空格
            string token = authorization.StartsWith("Bearer ") ? authorization.Remove(0, "Bearer ".Length) : authorization;
            if (string.IsNullOrEmpty(token))
                return AuthenticateResult.Fail("请求头Authorization不允许为空。");

            //通过密钥,进行加密、解密对比认证
            if (!VerifyAuthorization(token))
                return AuthenticateResult.Fail("传入的Authorization身份验证失败。");


            return AuthenticateResult.Success(GetTicket());
        }
        private AuthenticationTicket GetTicket()
        {
            // 验证成功,创建身份验证票据
            var claims = new[]
            {
                new Claim(ClaimTypes.Role, "Admin"),
                new Claim(ClaimTypes.Role, "Public"),
            };
            var identity = new ClaimsIdentity(claims, Scheme.Name);
            var principal = new ClaimsPrincipal(identity);
            var ticket = new AuthenticationTicket(principal, new AuthenticationProperties(), this.Scheme.Name);
            return ticket;
        }
        private bool VerifyAuthorization(string token)
        {
            //token: [0]随机生成64位字符串,[1]载荷数据,[2]采用Hash对[0]+[1]的签名
            var tokenArr = token.Split('.');
            if (tokenArr.Length != 3)
            {
                return false;
            }
            try
            {
                //1、先比对签名串是否一致
                string signature = tokenArr[1].Hmacsha256HashEncrypt().ToLower();
                if (!signature.Equals(tokenArr[2].ToLower()))
                {
                    return false;
                }

                //解密
                var aecStr = tokenArr[1].Base64ToString();
                var clientId = aecStr.DecryptAES();
                //2、再验证载荷数据的有效性
                var clientList = _configuration.GetSection("FixedClient").Get<List<FixedClientSet>>();
                var clientData = clientList.SingleOrDefault(it => it.ClientID.Equals(clientId));
                if (clientData == null)
                {
                    return false;
                }
            }
            catch (Exception)
            {
                throw;
            }

            return true;
        }
    }

使用中间件:UseMiddleware

app.UseAuthentication();
//中间件模式:自定义认证中间件:双重认证选其一
//如果使用 策略,需要注释掉 中间件
app.UseMiddleware<FallbackAuthenticationMiddleware>(); //使用中间件实现
app.UseAuthorization();

中间件FallbackAuthenticationMiddleware.cs代码实现

   public class FallbackAuthenticationMiddleware
  {
      private readonly RequestDelegate _next;
      private readonly IAuthenticationSchemeProvider _schemeProvider;

      public FallbackAuthenticationMiddleware(RequestDelegate next, IAuthenticationSchemeProvider schemeProvider)
      {
          _next = next;
          _schemeProvider = schemeProvider;
      }
      /// <summary>
      /// 身份认证方案
      /// 默认JWT。JWT失败,执行自定义认证
      /// </summary>
      /// <param name="context"></param>
      /// <returns></returns>
      public async Task InvokeAsync(HttpContext context)
      {
          var endpoints = context.GetEndpoint();
          if (endpoints == null || !endpoints.Metadata.OfType<IAuthorizeData>().Any() || endpoints.Metadata.OfType<IAllowAnonymous>().Any())
          {
              await _next(context);
              return;
          }

          //默认JWT。JWT失败,执行自定义认证
          var result = await Authenticate_JwtAsync(context);
          if (!result.Succeeded)
              result = await Authenticate_CustomTokenAsync(context);

          // 设置认证票据到HttpContext中 
          if (result.Succeeded)
              context.User = result.Principal;

          await _next(context);
      }
      /// <summary>
      /// JWT的认证
      /// </summary>
      /// <param name="context"></param>
      /// <returns></returns>
      private async Task<dynamic> Authenticate_JwtAsync(HttpContext context)
      {
          var verify = context.User?.Identity?.IsAuthenticated ?? false;
          string authenticationType = context.User.Identity.AuthenticationType;
          if (verify && authenticationType != null)
          {
              return new { Succeeded = verify, Principal = context.User, Message = "" };
          }

          await Task.CompletedTask;

          // 找不到JWT身份验证方案,或者无法获取处理程序。
          return new { Succeeded = false, Principal = new ClaimsPrincipal { }, Message = "JWT authentication scheme not found or handler could not be obtained." };
      }

      /// <summary>
      /// 自定义认证
      /// </summary>
      /// <param name="context"></param>
      /// <returns></returns>
      private async Task<dynamic> Authenticate_CustomTokenAsync(HttpContext context)
      {
          // 自定义认证方案的名称
          var customScheme = "CustomAuthenticationHandler";

          var fixedTokenHandler = await context.RequestServices.GetRequiredService<IAuthenticationHandlerProvider>().GetHandlerAsync(context, customScheme);
          if (fixedTokenHandler != null)
          {
              var Res = await fixedTokenHandler.AuthenticateAsync();
              return new { Res.Succeeded, Res.Principal, Res.Failure?.Message };
          }

          //找不到CustomAuthenticationHandler身份验证方案,或者无法获取处理程序。
          return new { Succeeded = false, Principal = new ClaimsPrincipal { }, Message = "CustomAuthenticationHandler authentication scheme not found or handler could not be obtained." };

      }
  }

方案二:通过[Authorize]标签的AuthenticationSchemes
因为中间件还要多维护一段中间件的代码,显得略微复杂,于是通过[Authorize(AuthenticationSchemes = "")]方式。

     //使用特定身份认证    
    //[Authorize(AuthenticationSchemes = CustomAuthenticationHandler.AuthenticationSchemeName)]
    //任一身份认证
    [Authorize(AuthenticationSchemes = $"{CustomAuthenticationHandler.AuthenticationSchemeName},{JwtBearerDefaults.AuthenticationScheme}")]
    public class DataProcessingController : ControllerBase
    {
    }

方案二:通过[Authorize]标签的policy

如果还有其他身份认证,那不断增加AuthenticationSchemes拼接在Controller的头顶,显得不太好看,且要是多个Controller使用,也会导致维护麻烦,于是改用策略方式。

在Program.cs添加服务AddAuthorization。使用策略的好处是增加易维护性。

 //授权策略
//Controller使用 policy 则采用指定的策略配置进行身份认证
builder.Services.AddAuthorization(option =>
{
    option.AddPolicy(CustomPolicy.Policy_A, policy => policy
            .RequireAuthenticatedUser()
            .AddAuthenticationSchemes(CustomAuthenticationHandler.AuthenticationSchemeName, JwtBearerDefaults.AuthenticationScheme)
            );

    option.AddPolicy(CustomPolicy.Policy_B, policy => policy
            .RequireAuthenticatedUser()
            .AddAuthenticationSchemes(CustomAuthenticationHandler.AuthenticationSchemeName)
            );

    option.AddPolicy(CustomPolicy.Policy_C, policy => policy
            .RequireAuthenticatedUser()
            .AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme)
            );
});
     //使用特定策略身份认证
    [Authorize(policy:CustomPolicy.Policy_B)]
    public class DataProcessingController : ControllerBase
    {
    }
     /// <summary>
    /// 策略类
    /// </summary>
    public static class CustomPolicy
    {
        public const string Policy_A= "Policy_A";

        public const string Policy_B = "Policy_B";

        public const string Policy_C = "Policy_C";
    }

最后附上截图:

添加服务:

使用中间件:

控制器:

这样,整套中继系统就能完美的满足Leader的需求,且达到预期效果。

源码Demo:
https://gitee.com/LaoPaoE/project-demo.git

最后附上:

AuthorizeAttribute 同时使用
Policy
和 AuthenticationSchemes
和 Roles
时是怎么鉴权的流程:

  1. AuthenticationSchemes鉴权:
    • AuthenticationSchemes
      属性指定了用于验证用户身份的认证方案(如Cookies、Bearer Tokens等)。
    • ASP.NET Core会根据这些认证方案对用户进行身份验证。如果用户未通过身份验证(即未登录或未提供有效的认证信息),则请求会被拒绝,并可能重定向到登录页面。
  2. Roles鉴权(如果指定了Roles):
    • 如果AuthorizeAttribute中还指定了
      Roles
      属性,那么除了通过身份验证外,用户还必须属于这些角色之一。
    • ASP.NET Core会检查用户的角色信息,以确定用户是否属于
      Roles

      属性中指定的一个或多个角色。
  3. Policy鉴权(如果指定了Policy):
    • Policy
      属性指定了一个或多个授权策略,这些策略定义了用户必须满足的额外条件才能访问资源。
    • ASP.NET Core会调用相应的 IAuthorizationHandler 来评估用户是否满足该策略中的所有要求。这些要求可以基于角色、声明(Claims)、资源等定义。
    • 如果用户不满足策略中的任何要求,则授权失败,并返回一个HTTP 403 Forbidden响应。

鉴权顺序和组合

  • 通常,AuthenticationSchemes的验证会首先进行,因为这是访问任何受保护资源的前提。
  • 如果AuthenticationSchemes验证通过,接下来会根据是否指定了Roles和Policy来进一步进行鉴权。
  • Roles和Policy的鉴权顺序可能因ASP.NET Core的具体版本和配置而异,但一般来说,它们会作为独立的条件进行评估。
  • 用户必须同时满足AuthenticationSchemes、Roles(如果指定)和Policy(如果指定)中的所有条件,才能成功访问受保护的资源。

注意事项

  • 在某些情况下,即使AuthenticationSchemes和Roles验证都通过,但如果Policy中的要求未得到满足,用户仍然无法访问资源。
  • 可以通过自定义 IAuthorizationRequirement 和 IAuthorizationHandler
    来实现复杂的授权逻辑,以满足特定的业务需求。
  • 确保在应用程序的身份验证和授权配置中正确设置了AuthenticationSchemes、Roles和Policy,以便它们能够协同工作,提供有效的访问控制。

小程序录制视频;10-30秒;需要拍摄人脸,大声朗读数字(123456)这种。

1.camera组件

camera页面内嵌的区域相机组件。注意这不是点击后全屏打开的相机

camera只支持小程序使用;
官网链接

1.2 效果图

1.3 页面布局

camera 设置宽100%,高度通过uni.getSystemInfo获取,全屏展示。在通过定位把提示文字等信息放上去;

录制完毕,遮罩提示,完成录制,确认返回;


<template>
    <viewclass="camera-position">
        <cameradevice-position="front"flash="auto"@error="onCameraError":style="'width: 100%; height: '+ screenHeight +'px;'">
            <!--人脸轮廓-图片-->
            <imagesrc="../../static/face/face-avater.png"style="width: 100%; height: 55vh; margin:22vh 0 0 0;"v-if="!achieveShow"></image>
        </camera>

        <!--顶部提示信息-->
        <viewclass="camera-top text-center"v-show="!achieveShow">
            <viewclass="text-lg text-red">请面向屏幕</view>
            <viewclass="text-xl text-white margin-tb-xs">
                <textclass="text-lg">用普通话大声读</text>
                <textclass="text-red text-bold margin-left-xs">123456</text>
            </view>
            <viewclass="text-xxl text-red">
                <textclass="text-df text-white">倒计时</text>
                <textclass="text-red text-bold margin-lr-xs">{{totalSeconds}}</text>
                <textclass="text-df text-white">S</text>
            </view>
        </view>

        <!--完成拍摄-->
        <viewclass="achieve-shade":style="'width: 100%; height: '+ screenHeight +'px;'"v-if="achieveShow">
            <viewclass=""style="font-size: 120rpx;color: #1977FF;">
                <textclass="cuIcon-roundcheck"></text>
            </view>
            <viewclass="text-xl text-white margin-tb-sm">已完成人脸识别</view>
            <buttonclass="cu-btn line-blue round lg"@click="confirmBut">确定</button>
        </view>
    </view>
</template>

View

注:行内css
text-xl text-white margin-tb-xs
使用了
ColorUI-UniApp
插件内容

css样式


<style lang="scss" scoped>
.camera-position
{position:relative;.camera-top {
position
:absolute;left:0;top:50rpx;width:100%; }.camera-bottom{position:absolute;left:0;bottom:0;width:100%; }.achieve-shade{position:absolute;left:0;top:0;background-color:rgba(222, 222, 222, 0.9);display:flex;flex-direction:column;align-items:center;justify-content:center;button {
width
:300rpx; }}
}
</style>

css

js代码


<script>exportdefault{
data() {
return{
cameraContext:
null,//计时器 timer: null,//录制时长 totalSeconds: 10,//屏幕高度 screenHeight: "",//是否显示-完成遮罩 achieveShow: false}
},
onLoad() {
let that
= thisuni.getSystemInfo({
success: (res)
=>{
console.log(
'屏幕宽度,单位为px:', res.windowWidth);
console.log(
'屏幕高度,单位为px:', res.windowHeight);
that.screenHeight
=res.windowHeight;
},
});

setTimeout(()
=>{this.startShoot()
},
500)
},
onReady() {
//创建 camera 上下文 CameraContext 对象 this.cameraContext =uni.createCameraContext()
},
methods: {
//开始拍摄 startShoot() {this.cameraContext.startRecord({
timeoutCallback: ()
=>{
console.error(
'超出限制时长', this.totalSecond);
},
timeout:
this.totalSeconds,
success: (res)
=>{//开启计时器 this.timer = setInterval(() =>{this.totalSeconds--},1000)
console.log(res,
'开始拍摄');
},
fail: (err)
=>{this.errToast('摄像头启动失败,请重新打开')
}
})
},
//结束拍摄 stopShoot() {//接触 计时器 if (this.timer) clearInterval(this.timer)this.cameraContext.stopRecord({
compressed:
true,
success: (res)
=>{//显示遮罩 this.achieveShow = true //TODO 获取数据帧 console.log(res, '结束拍摄');
},
fail: (err)
=>{this.errToast('视频保存失败,请重新录制')
},
})
},
//摄像头错误 onCameraError(error) {
console.error(
'摄像头错误: ', error.detail);
},
//摄像头-失败操作 errToast(e) {this.$operate.toast({
title: e
})
setTimeout(()
=>{this.confirmBut()
},
500)
},
//确定-返回上一页 confirmBut() {
uni.navigateBack()
},
},
watch: {
//监听倒计时 totalSeconds: {
handler(newVal) {
//console.log(newVal, '倒计时'); //倒计时 = 1 的时候结束拍摄 if (newVal == 1) {//结束拍摄 this.stopShoot()
}
}
}
}
}
</script>

js

注:第一次进入页面,有时候摄像头会启动失败,需要重新点击打开;

2.微信官方api

微信小程序
中需要使用手机
拍摄照片以及视频
;使用
wx.chooseMedia
API来实现;

该API用于拍摄或从手机相册中选择图片或视频,官网链接为:
wx.chooseMedia-微信开放文档

wx.chooseMedia({
	//数量 1-9
	count: 1,
	//时长
	maxDuration: '10',
	// 文件类型  image 图片  video视频   mix同时选择图片和视频
	mediaType: ['video'],
	// 图片和视频选择的来源: album 相册  camera相机拍摄
	sourceType: ['camera'],
	//摄像头: back 前置  front 后置摄像头 
	camera: 'back',
	success(res) {
		console.log(res)
		console.log(res.tempFiles[0].tempFilePath)
	},
	fail(err) {
		console.log(err)
	}
})