2024年7月

本文记录一下我在 Spring 自带的事件监听类添加 @RefreshScope 注解时遇到的坑,原本这两个东西单独使用是各自安好,但当大家将它们组合在一起时,会发现我们的事件监听代码被重复执行。希望大家引以为鉴,避免重复踩坑。耐心看完,你一定会有所收获!

前置描述

最近有一个用户拉新的需求,需要在新用户注册时判断用户是否有对应的邀请关系,如果有则需要给新用户赠送系统资源。

原有的用户注册逻辑里使用了 Spring 自带的事件监听工具,也就是 applicationEventPublisher(事件发布类)以及 ApplicationListener(事件监听类),在用户注册完毕写入用户记录并生成 token 后,会触发 RegisterEvent(注册事件)的发布。伪代码如下,

// 1. 用户注册,写入数据库
RegisterResponseVO registerResponseVO = memberRegisterService.register(new RegisterRequestVO(request);

// 2. 生成token
String token = getToken(memberEntity.getId(), request.getSource());
log.info("login mobile {} login token {}", request.getMobile(), token);

// 3. 发布注册事件,会触发登录日志监听、优惠券赠送监听等
applicationEventPublisher.publishEvent(new RegisterEvent(request, memberEntity, token));

由于之前代码已经使用事件监听逻辑,所以这里我们的
新用户注册判断邀请关系
的逻辑就直接新建一个 NewUserInvitedListener 监听类即可。伪代码如下,

@Slf4j
@RefreshScope
@AllArgsConstructor
@Component
public class NewUserInvitedListener implements ApplicationListener<RegisterEvent> {

    @Async("asyncServiceExecutor")
    @Override
    public void onApplicationEvent(RegisterEvent registerEvent) {
        UserLoginRequestVO requestVO = registerEvent.getRequestVO();
        MemberEntity memberEntity = registerEvent.getMemberEntity();
        log.info("================ NewUserInvitedListener =============== registerEvent is {}", registerEvent);
        // 1. 校验逻辑
        validateUser(memberEntity);
        // 2. 判断用户是否有邀请关系
        // 3. 如果有则赠送系统资源
        ...
    }
}

OK,代码逻辑也不复杂,写完提测交给测试下班(周五下午写完)。

发现问题

周一一来,测试就在群里 @ 后端人员说是新用户赠送的系统资源送了两次,说实话我一开始是不太信的,直到我去查了日志,发现 NewUserInvitedListener 监听类的日志确实被打印了两次,也就是说我们的 NewUserInvitedListener 监听类被触发了两次。

OK,到这里我们的问题就确确实实产生了,接下来就是解决问题。

image

解决思路

问题产生通常都有很多种解决方法,我们如何选择一个最适合我们当前场景的方法才能体现出我们对业务、技术的理解。
在这个监听类重复触发的场景里,就有多种解决方式,我简单列举几个,

  1. 添加幂等处理,防止重复执行
  2. 加锁,防止重复执行
  3. 解决下为什么监听类会重复触发

这三个解决方案各有优劣,通过对监听类的业务逻辑添加幂等逻辑或者加锁逻辑都是可以解决的,但是这不是问题根源,问题根源是在于监听类为什么会被重复触发。
在本文中,我也将带着大家一步一步探索并解决这个问题。

检查下之前的事件监听类是否也有重复触发的问题

因为这个代码是照着之前的逻辑写的,新加的 NewUserInvitedListener 被发现重复触发,那以前的 MemberLoginLogListener 是否也有重复触发的问题。伪代码如下,

@Slf4j
@Component
@AllArgsConstructor
public class MemberLoginLogListener implements ApplicationListener<RegisterEvent> {
    private MemberLoginLogService memberLoginLogService;

    @Async("asyncServiceExecutor")
    @Override
    public void onApplicationEvent(RegisterEvent event) {
        MemberEntity memberEntity = event.getMemberEntity();
        log.info("================ MemberLoginLogListener ===============, mobile is {}", memberEntity.getMobile());
        MemberLoginLogEntity memberLoginLogEntity = MemberLoginLogConvertor.buildLoginLogEntity(event.getRequestVO(),
                event.getMemberEntity());
        memberLoginLogEntity.setToken(event.getToken());
        memberLoginLogService.save(memberLoginLogEntity);
    }
}

查询 MemberLoginLogListener 监听类的日志,发现只有一次打印,说明之前写的 MemberLoginLogListener 监听类没有重复触发的问题,那这里就很奇怪了。对比一下 NewUserInvitedListener 监听类与 MemberLoginLogListener 监听类的差别,很明显我们发现 NewUserInvitedListener 监听类上多了一个 @RefreshScope 注解。

OK,问题有可能就是 @RefreshScope 注解导致,我们去掉 @RefreshScope 注解在看看日志打印。

去掉 @RefreshScope 注解

当我们去掉 @RefreshScope 注解后,神奇的事情发生了,NewUserInvitedListener 监听类的日志打印正常了,只触发了一次!

OK,到这里我们也就发现了问题出在 @RefreshScope 注解上。

如何搜索问题

虽然我们知道了问题出在 @RefreshScope 注解上,但是我们怎么向搜索引擎描述这个问题嘞?

很多人发现了问题,但是不知道如何描述问题,怎么描述问题才能让别人一听就懂,从而能给你提供帮助。你需要把问题的重点描述出来,搜索引擎才能给予精准帮助。

在我们这个
新用户注册判断邀请关系
的场景里,很显然我们的搜索词可以是
“spring 事件监听重复触发 @RefreshScope”
可以看到我的搜索关键词有 3 个,分别是 spring、事件监听重复触发以及 @RefreshScope。让我们来看看搜索结果。
image

前 5 个搜索结果中,只有第五个的标题可能符合我们的搜索内容,我们点进去看一看。

image

很遗憾,跟我们的问题场景并不相符,我们并没有搜索到我们想要的东西。在这里我们的搜索关键词
“spring 事件监听重复触发 @RefreshScope”
并没有给予我们帮助。

回到问题本身

既然我们的问题已经定位到了,在于 @RefreshScope 会导致监听类的重复触发,可是这个关键词并没有相关搜索结果,那么我们只能换个角度。

为什么会重复触发?

在 NewUserInvitedListener 监听类中,我们使用 @Component 注解,默认注册了一个单例 bean,这个 bean 用于接收用户注册事件。既然 bean 是单一的,那就是说 Spring 发送了 2 次 RegisterEvent 事件吗?结合上文提到的 MemberLoginLogListener 监听类只触发一次的日志,很显然,Spring 只会发送了 1 次 RegisterEvent 事件。

难道说问题在于 Spring 里出现了两个 NewUserInvitedListener 类型的 bean?

那么到这里恭喜我们终于定位到了重复触发问题的根源。

如果大家了解 @RefreshScope 的原理相信大家已经猜出来了。

@RefreshScope 原理

Spring 中 @scope 注解的原理就是在创建 Scope=singleton 的 Bean 时,IOC 会保存实例在一个 Map 中,保证这个 Bean 在一个 IOC 上下文有且仅有一个实例。

SpringCloud 新增了一个自定义的作用域:refresh(可以理解为“动态刷新”),同样用了一种独特的方式改变了 Bean 的管理方式,使得其可以通过外部化配置(.properties)的刷新,在应用不需要重启的情况下热加载新的外部化配置的值。

这个 scope 是如何做到热加载的呢?RefreshScope 主要做了以下动作:
单独管理 Bean 生命周期

创建 Bean 的时候如果是 RefreshScope 就缓存在一个专门管理的 ScopeMap 中,这样就可以管理 Scope 是 Refresh 的 Bean 的生命周期了(所以含 RefreshScope 的其实一共创建了两个 bean)。

重新创建 Bean

外部化配置刷新之后,会触发一个动作,这个动作将上面的 ScopeMap 中的 Bean 清空,这样这些 Bean 就会重新被 IOC 容器创建一次,使用最新的外部化配置的值注入类中,达到热加载新值的效果。

看完 @RefreshScope 的原理相信大家已经知道了出现两个 NewUserInvitedListener 类型 bean 的原因是在于 @RefreshScope 导致。这是由于 @RefreshScope 注解的内部实现创建了另外一个相同类型的 NewUserInvitedListener bean,导致我们的新用户监听逻辑被重复执行。

回到搜索关键词

假如我是说假如,假如我们不知道 @RefreshScope 的原理,自然不知道项目中出现了两个 NewUserInvitedListener 类型的 bean 是 @RefreshScope 导致。 那么我们怎么通过搜索关键词来找到这个问题嘞?

到这里也就是本文的重点所在,怎么通过搜索关键词来解决我们的问题。

先定义问题

在这个场景里我们使用的是 Spring 项目,问题本质是 @RefreshScope 在 Spring 自带的事件监听类搭配使用时,会导致 bean 重复进而导致监听类逻辑被重复执行,当我们去掉 @RefreshScope 后,也就没有这种情况。

也就是说这句话我们换个说话:
“@RefreshScope 在 Spring 自带的事件监听类搭配使用时,会生成另外一个相同的 bean 导致监听类被重复触发”

总结关键词

在上面的先定义问题中,我们提炼一下关键词,

  • Spring:这个关键词在 Spring 项目中必带,大家应该没有意见把
  • @RefreshScope:我们的问题根源,搜索也得带上
  • 生成同一个 bean:这是一个描述语句,简要描述一下我们发现的问题

看一看搜索结果,
image

点进第一个结果,

image

OK,大功告成,看到我们框选中的地方了吗,上文的 @RefreshScope 原理解释,就是复制与这里。

贴一下原文地址:
https://blog.csdn.net/m0_71777195/article/details/127223544

一些思考

实话实说,我在测试给我上报问题,到发现这个问题来自于 @RefreshScope 注解只用了 10 分钟,如上文所说,我通过对比以前写的 MemberLoginLogListener 监听类,早早的定位到问题来自于 @RefreshScope 注解。可是到我完整修复这个问题,提交到测试环境,却花了 2 个半小时,原因是因为我在研究这个问题的根源,这也是这篇文章的由来。

假如说这个问题发生在线上,那么我根本不可能花这么多时间来研究,我需要的就是迅速解决这个问题并修复上线,避免影响更多用户。

一样的,大家在遇到这种相似问题时,如果境况紧急出现在生产环境,大家本着对工作负责的态度,应该迅速解决并做故障复盘。如果是出现在测试环境我们可以本着对技术执着可以认真专研下这个问题。

其实我还想说的是在这个问题里,我能 10 分钟定位到问题来自于 @RefreshScope 注解,可能也有运气成分。但是很多情况下当我们照驴子画马写代码,发现出了问题时,这种情况大部分还是我们“画蛇添足”导致。大家可以通过对比以前代码迅速找出问题原因。

找出了问题后是如何解决问题。这篇文章里,我给大家讲了讲我的搜索关键词心得。第一是讲重点、第二是找到问题本质,这样才能从搜索引擎嘴里找出我们想要的答案。

如果觉得这篇文章写的不错的话,可以关注我的公众号【程序员wayn】,我会更新更多技术干货、项目教学、经验分享的文章。

之前我们聊过如何使用LangChain给LLM(大模型)装上记忆,里面提到对话链
ConversationChain

MessagesPlaceholder
,可以简化安装记忆的流程。下文来拆解基于LangChain的大模型记忆方案。

1. 安装记忆的原理

1.1. 核心步骤

给LLM安装记忆的核心步骤就3个:

  1. 在对话之前调取之前的历史消息。
  2. 将历史消息填充到Prompt里。
  3. 对话结束后,继续将历史消息保存到到memory记忆中。

1.2. 常规使用方法的弊端

了解这3个核心步骤后,在开发过程中,就需要手动写代码实现这3步,这也比较麻烦,不仅代码冗余,而且容易遗漏这些模板代码。

为了让开发者聚焦于业务实现,LangChain贴心地封装了这一整套实现。使用方式如下。

2. 记忆的种类

记忆分为 短时记忆 和 长时记忆。

在LangChain中使用
ConversationBufferMemory
作为短时记忆的组件,实际上就是以键值对的方式将消息存在内存中。

如果碰到较长的对话,一般使用
ConversationSummaryMemory
对上下文进行总结,再交给大模型。或者使用
ConversationTokenBufferMemory
基于固定的token数量进行内存刷新。

如果想对记忆进行长时间的存储,则可以使用向量数据库进行存储(比如FAISS、Chroma等),或者存储到Redis、Elasticsearch中。

下面以
ConversationBufferMemory
为例,对如何快速安装记忆做个实践。

3. 给LLM安装记忆 — 非MessagesPlaceholder

3.1. ConversationBufferMemory使用示例

使用
ConversationBufferMemory
进行记住上下文:

memory = ConversationBufferMemory()
memory.save_context(
    {"input": "你好,我的名字是半支烟,我是一个程序员"}, {"output": "你好,半支烟"}
)
memory.load_memory_variables({})

3.2. LLMChain+ConversationBufferMemory使用示例

# prompt模板
template = """
你是一个对话机器人,以下<history>标签中是AI与人类的历史对话记录,请你参考历史上下文,回答用户输入的问题。

历史对话:
<history>
{customize_chat_history}
</history>

人类:{human_input}
机器人:

"""

prompt = PromptTemplate(
    template=template,
    input_variables=["customize_chat_history", "human_input"],
)
memory = ConversationBufferMemory(
    memory_key="customize_chat_history",
)
model = ChatOpenAI(
    model="gpt-3.5-turbo",
)

chain = LLMChain(
    llm=model,
    memory=memory,
    prompt=prompt,
    verbose=True,
)

chain.predict(human_input="你知道我的名字吗?")

# chain.predict(human_input="我叫半支烟,我是一名程序员")

# chain.predict(human_input="你知道我的名字吗?")

此时,已经给LLM安装上记忆了,免去了我们写那3步核心的模板代码。

对于
PromptTemplate
使用以上方式,但
ChatPromptTemplate
因为有多角色,所以需要使用
MessagesPlaceholder
。具体使用方式如下。

4. 给LLM安装记忆 — MessagesPlaceholder

MessagesPlaceholder
主要就是用于
ChatPromptTemplate
场景。
ChatPromptTemplate
模式下,需要有固定的格式。

4.1. PromptTemplate和ChatPromptTemplate区别

ChatPromptTemplate
主要用于聊天场景。
ChatPromptTemplate
有多角色,第一个是System角色,后续的是Human与AI角色。因为需要有记忆,所以之前的历史消息要放在最新问题的上方。

4.2. 使用MessagesPlaceholder安装

最终的ChatPromptTemplate + MessagesPlaceholder代码如下:

chat_prompt = ChatPromptTemplate.from_messages(
    [
        ("system", "你是一个乐于助人的助手。"),
        MessagesPlaceholder(variable_name="customize_chat_history"),
        ("human", "{human_input}"),
    ]
)

memory = ConversationBufferMemory(
    memory_key="customize_chat_history",
    return_messages=True,
)
model = ChatOpenAI(
    model="gpt-3.5-turbo",
)

chain = LLMChain(
    llm=model,
    memory=memory,
    prompt=chat_prompt,
    verbose=True,
)

chain.predict(human_input="你好,我叫半支烟,我是一名程序员。")

至此,我们使用了
ChatPromptTemplate
简化了构建prompt的过程。

5. 使用对话链ConversationChain

如果连
ChatPromptTemplate
都懒得写了,那直接使用对话链
ConversationChain
,让一切变得更简单。实践代码如下:

memory = ConversationBufferMemory(
    memory_key="history",  # 此处的占位符必须是history
    return_messages=True,
)
model = ChatOpenAI(
    model="gpt-3.5-turbo",
)

chain = ConversationChain(
    llm=model,
    memory=memory,
    verbose=True,
)

chain.predict(input="你好,我叫半支烟,我是一名程序员。")  # 此处的变量必须是input

ConversationChain提供了包含AI角色和人类角色的对话摘要格式。ConversationChain实际上是对Memory和LLMChain和ChatPrompt进行了封装,简化了初始化Memory和构建ChatPromptTemplate的步骤。

6. ConversationBufferMemory

6.1. memory_key

ConversationBufferMemory
有一个入参是
memory_key
,表示内存中存储的本轮对话的

,后续可以根据

找到对应的值。

6.2. 使用"chat_history"还是"history"

ConversationBufferMemory

memory_key
,有些资料里是设置是
memory_key="history"
,有些资料里是
"chat_history"

这里有2个规则,如下:

  • 在使用
    MessagesPlaceholder

    ConversationBufferMemory
    时,
    MessagesPlaceholder

    variable_name

    ConversationBufferMemory

    memory_key
    可以自定义,只要相同就可以。比如这样:
chat_prompt = ChatPromptTemplate.from_messages(
    [
        ("system", "你是一个乐于助人的助手。"),
        MessagesPlaceholder(variable_name="customize_chat_history"),
        ("human", "{input}"),
    ]
)

memory = ConversationBufferMemory(
    memory_key="customize_chat_history",  # 此处的占位符可以是自定义
    return_messages=True,
)
model = ChatOpenAI(
    model="gpt-3.5-turbo",
)

chain = ConversationChain(
    llm=model,
    memory=memory,
    prompt=chat_prompt,
    verbose=True,
)

chain.predict(input="你好,我叫半支烟,我是一名程序员。")  # 此处的变量必须是input
  • 如果只是使用
    ConversationChain

    又没有使用
    MessagesPlaceholder
    的场景下,ConversationBufferMemory的memory_key,
    必须用
    history

7. MessagesPlaceholder的使用场景

MessagesPlaceholder
其实就是在与AI对话过程中的
Prompt
的一部分,它代表
Prompt
中的历史消息这部分。它提供了一种结构化和可配置的方式来处理这些消息列表,使得在构建复杂
Prompt
时更加灵活和高效。

说白了它就是个占位符,相当于把从memory读取的历史消息插入到这个占位符里了。

比如这样,就可以表示之前的历史对话消息:

chat_prompt = ChatPromptTemplate.from_messages(
    [
        ("system", "你是一个乐于助人的助手。"),
        MessagesPlaceholder(variable_name="customize_chat_history"),
        ("human", "{human_input}"),
    ]
)

是否需要使用
MessagesPlaceholder
,记住2个原则:

  • PromptTemplate
    类型的模板,无需使用MessagesPlaceholder

  • ChatPromptTemplate
    类型的聊天模板,需要使用MessagesPlaceholder。但是在使用ConversationChain时,可以省去创建ChatPromptTemplate的过程(也可以不省去)。省去和不省去在输出过程中有些区别,如下:

8. 总结

本文主要聊了安装记忆的基本原理、快速给LLM安装记忆、
ConversationBufferMemory

MessagesPlaceholder
的使用、对话链
ConversationChain
的使用和原理。希望对你有帮助!

=====>>>>>>
关于我
<<<<<<=====

本篇完结!欢迎点赞 关注 收藏!!!

原文链接:
https://mp.weixin.qq.com/s/cRavfyu--AjBOO3-1aY0UA

1、缓存和数据库不一致

只要我们使用 Redis 缓存,就必然会面对缓存和数据库间的一致性保证问题,这里的“一致性”包含了两种情况:缓存中有数据且与数据库中的值相同、缓存中没有数据,最新值在数据库中。

对于读写缓存来说,要想保证缓存和数据库中的数据一致,就要采用同步直写策略,在业务应用中使用事务机制,来保证缓存和数据库的更新具有原子性。对数据一致性的要求不高的场景,可以使用异步写回策略。

只读缓存在新增数据时是符合数据一致性第二种情况的,但是在删改数据时,无论是操作缓存还是操作数据库,有一项失败,就会产生数据不一致的问题。具体情况如图:

针对这种情况可以引用重试机制来解决,具体来说,可以把要数据暂存到消息队列中。无论哪项操作失败,都可以从消息队列中重新读取这些值,然后再次进行删除或更新。如果删改成功,就把数据从消息队列中去除,以免重复操作,以此来保证数据一致性。多次重试仍然失败的话就需要业务层面预警来排查解决了。

除了操作失败的原因,实际当有大量并发请求时,应用还是有可能读到不一致的数据。根据删改缓存、数据库的先后顺序分为两种情况:先操作缓存和先操作数据库。通过引用“延迟双删”的操作,来保证先操作缓存后操作数据库的数据一致性问题,代码示意如下:

redis.delKey(X)
db.update(X)
Thread.sleep(N)
redis.delKey(X)

具体情况与应对措施总结如图:

2、雪崩

指大量的应用请求无法在 Redis 缓存中进行处理,到达数据库层面,导致数据库的压力激增。原因有二:
其一,缓存中有大量数据同时过期。
其二,Redis 缓存实例挂了。

原因一可以通过以下两个方法解决:
(1)微调过期时间:对于同一批数据设置不同的过期时间,如通过随机数延迟过期等。
(2)降级处理:非核心数据请求直接返回 空 或 错误 等预置信息,核心数据运行未命中缓存访问数据库。

原因二可以通过以下两个方法解决:
(1)熔断:拒绝缓存客户端的请求,保护数据库,防止整个系统崩溃,直到 Redis 缓存实例恢复正常,但是对业务应用的影响范围大。
(2)限流:允许部分请求到达 Redis 缓存,无法命中再访问数据库,减轻数据库压力,相对于熔断来说影响范围稍微缩小。

无论采取何种措施,雪崩都已经发生了,必定会影响到业务系统,所以要做好预防工作,构建 Redis 缓存高可靠集群,尽量避免事故。

3、击穿

指针对某个热点数据的请求,无法在缓存中处理,大量访问该数据的请求发送到了后端数据库,压力激增影响到其他请求,如下图:
为了避免缓存击穿给数据库带来的激增压力,对于访问特别频繁的热点数据,可以不设置过期时间了,全部在缓存中进行处理。

4、穿透

指要访问的数据既不在 Redis 缓存中,也不在数据库中,导致请求压力还是落到数据库,缓存也就成了“摆设”。如下图:

通常情况是由于业务数据被误删除,或者恶意攻击访问,有三种方案解决缓存穿透的影响:
(1)缓存空值或缺省值:
针对穿透的数据,在 Redis 中缓存一个空值或是和业务层协商确定的缺省值,避免把大量请求发送给数据库。

(2)布隆过滤器
布隆过滤器由一个初值都为 0 的 bit 数组和 N 个哈希函数组成,数据入库则进行标记,过程如下:

  • 使用 N 个哈希函数,分别计算这个数据的哈希值,得到 N 个哈希值。
  • 我们把这 N 个哈希值对 bit 数组的长度取模,得到每个哈希值在数组中的对应位置。
  • 我们把对应位置的 bit 位设置为 1,这就完成了在布隆过滤器中标记数据的操作。

当查询某个数据时,按照哈希函数的计算结果,查看 bit 数组中这 N 个位置上的 bit 值,只要有一个不为 1,这就表明布隆过滤器没有对该数据做过标记。当缓存穿透发生,布隆过滤器的快速检测特性可以帮数据库阻挡大部分的压力。例子如下图:

(3)前端请求过滤:
在请求入口前端进行合法性检测,把恶意的请求(如请求参数不合理、非法值或请求字段不存在)直接过滤掉。

小结

雪崩、击穿、穿透,这三类异常问题从成因来看,前两个主要是因为数据不在缓存中了,而穿透则是因为数据既不在缓存中,也不在数据库中。当雪崩或击穿发生时,一旦数据库中的数据被再次写入到缓存后,应用又可以在缓存中快速访问数据了,数据库的压力也会相应地降低,而穿透发生时,Redis 缓存和数据库会同时持续承受请求压力。

对应的熔断、降级、限流这些方法都是属于“有损”方案,在保证数据库和整体系统稳定的同时,会对业务应用带来负面影响。降级时,有部分数据的请求就只能得到错误返回信息,无法正常处理。熔断时,整个缓存系统暂停,影响的业务范围更大。流机时,整个业务系统的吞吐率会降低,并发处理能力减弱,影响到用户体验。

所以,应当尽量使用预防式方案,总结如下:

  • 雪崩,合理地设置数据过期时间,以及搭建高可靠缓存集群。
  • 击穿,在缓存访问非常频繁的热点数据时,不要设置过期时间。
  • 穿透,提前在入口前端实现恶意请求检测,或者规范数据库的数据删除操作,避免误删除。

以前,我看到一个朋友在对一个系统做初始化的时候,通过一组魔幻般的按键,调出来一个隐藏的系统设置界面,这个界面在常规的菜单或者工具栏是看不到的,因为它是一个后台设置的关键界面,不公开,同时避免常规用户的误操作,它是作为一个超级管理员的入口功能,这个是很不错的思路。其实Winform做这样的处理也是很容易的实现的,本篇随笔来介绍Winform中增加隐藏的按键处理的功能。

1、准备好相关的界面功能

例如,我对于动态表和属性配置的界面,不希望一般用户看到,用户只是可以对业务表的数据进行维护处理即可。那么我们可以把系统动态表和属性配置的界面开发好,但是不直接放在菜单或者工具栏中。也就是我们完成功能的开发,但是不提供常规的调用入口即可。

例如对于下面的定义界面,我们开发好,测试正常后,移除通用的菜单或者工具栏操作入口。

而只是给一个常规的数据录入管理界面,如下所示。

这样可以防止普通用户的误操作,同时也可以把这些特殊的功能给一些特殊的用户使用即可。

2、 在Winform程序中增加隐藏的按键处理

完成常规功能的开发后,我们需要增加隐藏的按键处理。

我们知道,常规的Winform界面,如果需要接受按键的侦听,需要设置窗体属性 KeyPreview 为True。

然后跟踪窗体的按键事件,包括按下去,和弹上来的两个事件进行处理,就可以跟踪到用户按键的组合,从而根据特殊的组合进行过滤处理即可。

//设置可以跟踪按键
form.KeyPreview = true;//按键事件进行跟踪
form.KeyDown += (s, e) =>{};
form.KeyUp
+= (s, e) => {};

有了这个思路,我们在一个辅助类中封装一个方法,用来跟踪两组按键的处理,如下所示。

        /// <summary>
        ///用于记录第一个 Ctrl+Key(指定按键) 是否被按下/// </summary>
        private bool IsFirstKeyPressed { get; set; } = false;/// <summary>
        ///针对特殊的按键跟踪处理, Ctrl+K 被按下,Ctrl+0按下,触发某个特殊事件/// </summary>
        public void InitSpecialKeyPress(Form form, Action action, Keys firstKey = Keys.K, Keys secondKey =Keys.D0)
{
form.KeyPreview
= true;//设置可以跟踪按键 form.KeyDown += (s, e) =>{if (e.Control && e.KeyCode ==firstKey)
{
e.SuppressKeyPress
= true; //禁止默认处理 IsFirstKeyPressed = true; //记录 Ctrl+K 被按下 }else if (IsFirstKeyPressed && e.Control && e.KeyCode ==secondKey)
{
e.SuppressKeyPress
= true; //禁止默认处理 action(); //如果Ctrl + K 然后 Ctrl + 0 被按下!执行操作 IsFirstKeyPressed= false; //重置 Ctrl+K 状态 }
};
form.KeyUp
+= (s, e) =>{//如果松开了 Ctrl 或 K 键,则重置 Ctrl+A 状态 if (e.KeyCode == secondKey || e.KeyCode ==Keys.ControlKey)
{
IsFirstKeyPressed
= false; //重置 Ctrl+K 状态 }
};
}

我们注意到Action action,这个传入一个匿名函数进行处理符合条件按键的操作,因此可以变得通用很多。

因此我们在主窗体MainForm的构造函数中进行按键事件的注册处理即可。

/// <summary>
///程序主界面/// </summary>
public partial classMainForm : RibbonForm
{
publicMainForm()
{
InitializeComponent();

....
//针对特殊的按键跟踪处理 Portal.gc.InitSpecialKeyPress(this, () =>
{
//EAV属性定义操作
ChildWinManagement.LoadMdiForm(this, typeof
(FrmEntityTypeProperties));
}, Keys.K, Keys.D0);

}

这样,我们就可以通过隐藏的组合按键,Ctrl+K, Ctrl+0调出我们特殊隐藏的窗体了。

一般我们可以用于处理一些特殊的操作,如隐藏高级功能、隐藏系统危险初始化操作,一些关键记录的管理等等。

技术背景

在许久之前写的一篇
博客
中,我们介绍过使用twine向pypi上传我们自己的开源包的方法。最近发现这个方法已经不再支持了(报错信息如下所示),现在最新版需要使用API Token进行文件上传,这里大致介绍一下配置的方法。

$ python3 setup.py check
$ python3 setup.py sdist bdist_wheel
$ twine upload --repository-url https://upload.pypi.org/legacy/ dist/*
Uploading distributions to https://upload.pypi.org/legacy/
Enter your username: dechin
Enter your password:
Uploading hadder-3.4-py3-none-any.whl
100%|█████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 22.0k/22.0k [00:01<00:00, 20.9kB/s]
Error during upload. Retry with the --verbose option for more details.
HTTPError: 403 Forbidden from https://upload.pypi.org/legacy/
Username/Password authentication is no longer supported. Migrate to API Tokens or Trusted Publishers instead. See https://pypi.org/help/#apitoken and https://pypi.org/help/#trusted-publishers

Pypi主页登录

因为许久没有登录,所以很多人登录
Pypi主页
的时候可能需要添加一下双因子认证:

点击这个生成覆盖码的按钮,会生成一系列的一次性覆盖码,建议保存到本地:

这个覆盖码,是用来修改账号配置的,跟我们上传whl包没有关系。但是每修改一次账号配置,就会使用一个覆盖码。然后选择继续:

我们选择一个覆盖码输入,用于配置双因子认证:

双因子认证模式有两种,一种是应用认证,一种是设备认证,这里如果使用使用的是Win11操作系统,可以选择设备认证,然后用
Windows Hello
进行认证:

点击设定设备:

这样就完成了Pypi账号登录和双因子认证的安全设定。

API Token获取

在登录Pypi主页之后,在账号设定界面往下拉,找到API tokens选项

选择添加API token,可以给这个token设定一个专门的用途名称,还可以分仓库管理token。不过这里我为了方便,直接统一使用一个token进行上传:

然后就会生成一个token:

配置API Token

其实官方推荐的方法是把Token保存到
~/.pypirc
文件里面去,像这个样子:

但是不知为何,在我的环境下这个方法不奏效。尝试过在
twine upload
的时候把配置文件也加上,但还是不起作用,有知道的高手可以方便评论区告知一下原因。最后我使用的是
keyring
配置的方法(keyring是安装twine的时候一起安装了,不需要自己再去手动安装):

$ keyring set https://upload.pypi.org/legacy/ __token__
Password for '__token__' in 'https://upload.pypi.org/legacy/':

账号名就是
__token__
,密码就是那一串token复制粘贴进来。最后,还是成功的完成了upload:

$ twine upload --repository-url https://upload.pypi.org/legacy/ dist/*
Uploading distributions to https://upload.pypi.org/legacy/
Uploading hadder-3.4-py3-none-any.whl
100%|█████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 22.0k/22.0k [00:02<00:00, 8.98kB/s]
Uploading hadder-3.4.tar.gz
100%|█████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 22.5k/22.5k [00:01<00:00, 14.9kB/s]

View at:
https://pypi.org/project/hadder/3.4/

总结概要

这篇文章介绍了新版Pypi上传Python编译后的whl包的操作流程,主要内容为登录设置双因子认证,以及获取API Token并使用token上传whl包的方法。

版权声明

本文首发链接为:
https://www.cnblogs.com/dechinphy/p/pypi-api.html

作者ID:DechinPhy

更多原著文章:
https://www.cnblogs.com/dechinphy/

请博主喝咖啡:
https://www.cnblogs.com/dechinphy/gallery/image/379634.html

参考链接

  1. https://packaging.python.org/en/latest/specifications/pypirc/