注册
环信即时通讯云

环信即时通讯云

单聊、群聊、聊天室...
环信开发文档

环信开发文档

Demo体验

Demo体验

场景Demo,开箱即用
RTE开发者社区

RTE开发者社区

汇聚音视频领域技术干货,分享行业资讯
技术讨论区

技术讨论区

技术交流、答疑
资源下载

资源下载

收集了海量宝藏开发资源
iOS Library

iOS Library

不需要辛辛苦苦的去找轮子, 这里都有
Android Library

Android Library

不需要辛辛苦苦的去找轮子, 这里都有

Spring Boot定时任务详解与案例代码

概述 Spring Boot是一个流行的Java开发框架,它提供了许多便捷的特性来简化开发过程。其中之一就是定时任务的支持,让开发人员可以轻松地在应用程序中执行定时任务。本文将详细介绍如何在Spring Boot中使用定时任务,并提供相关的代码示例。 实际案例...
继续阅读 »

image.png





概述


Spring Boot是一个流行的Java开发框架,它提供了许多便捷的特性来简化开发过程。其中之一就是定时任务的支持,让开发人员可以轻松地在应用程序中执行定时任务。本文将详细介绍如何在Spring Boot中使用定时任务,并提供相关的代码示例。


实际案例


在Spring Boot中,使用定时任务非常简单。首先,需要在应用程序的入口类上添加@EnableScheduling注解,以启用定时任务的支持。该注解将告诉Spring Boot自动配置并创建一个线程池来执行定时任务。


import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}

一旦启用了定时任务支持,就可以在任何Spring管理的Bean中创建定时任务。可以通过在方法上添加@Scheduled注解来指定定时任务的执行规则。下面是一个简单的示例,演示了每隔一分钟执行一次的定时任务:


import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Component
public class MyScheduledTask {

@Scheduled(cron = "0 * * * * *") // 每分钟执行一次
public void executeTask() {
// 在这里编写定时任务的逻辑
System.out.println("定时任务执行中...");
}
}

在上面的示例中,我们创建了一个名为MyScheduledTask的组件,并在其中定义了一个名为executeTask的方法。通过使用@Scheduled(cron = "0 * * * * *")注解,我们指定了该方法应该每分钟执行一次。当定时任务触发时,executeTask方法中的逻辑将被执行。


需要注意的是,@Scheduled注解支持不同的任务触发方式,如基于固定延迟时间、固定间隔时间或cron表达式等。可以根据实际需求选择适合的方式。


以上就是使用Spring Boot进行定时任务的基本示例。通过简单的注解配置,您可以轻松地在应用程序中添加和管理定时任务。希望本文能对您理解和使用Spring Boot定时任务提供帮助。


总结


Spring Boot提供了便捷的方式来实现定时任务。通过添加@EnableScheduling注解来启用定时任务支持,并使用@Scheduled注解来指定任务的执行规则。可以根据需求选择不同的触发方式。


除了上述基本示例外,Spring Boot还提供了更多高级功能和配置选项,以满足更复杂的定时任务需求。



  1. 方法参数和返回值:您可以在定时任务方法中添加参数和返回值,Spring Boot会自动注入合适的值。例如,可以将java.util.Date类型的参数添加到方法中,以获取当前时间。返回值可以是voidjava.util.concurrent.Futurejava.util.concurrent.CompletableFuture等类型。

  2. 并发执行和线程池配置:默认情况下,Spring Boot的定时任务是串行执行的,即每个任务完成后再执行下一个任务。如果需要并发执行任务,可以通过配置线程池来实现。可以在application.propertiesapplication.yml文件中设置相关的线程池属性,如核心线程数、最大线程数和队列容量等。

  3. 异常处理:定时任务可能会抛出异常,因此需要适当处理异常情况。您可以使用@Scheduled注解的exceptionHandler属性来指定异常处理方法,以便在任务执行过程中捕获和处理异常。

  4. 动态调度:有时需要根据运行时的条件来动态调整定时任务的触发时间。Spring Boot提供了TaskScheduler接口和CronTrigger类,您可以使用它们来在运行时动态设置定时任务的执行规则。

  5. 集群环境下的定时任务:如果应用程序部署在多个节点的集群环境中,可能会遇到定时任务重复执行的问题。为了避免这种情况,可以使用分布式锁机制,如Redis锁或数据库锁,来确保只有一个节点执行定时
    作者:百思不得小赵
    来源:juejin.cn/post/7244089396567638072
    任务。

收起阅读 »

区块链中的平行链

随着区块链技术的不断发展,人们对于区块链的应用也越来越广泛。在这个过程中,平行链技术应运而生。那么,什么是平行链呢? 1. 平行链的定义和概念 平行链是一种特定的应用程序数据结构,它是全局一致的,由 Polkadot 中继链的验证节点进行验证] 。它允许交易...
继续阅读 »

随着区块链技术的不断发展,人们对于区块链的应用也越来越广泛。在这个过程中,平行链技术应运而生。那么,什么是平行链呢?


1. 平行链的定义和概念


平行链是一种特定的应用程序数据结构,它是全局一致的,由 Polkadot 中继链的验证节点进行验证] 。它允许交易在专用的 Layer1 区块链生态系统中并行分布和处理,从而显著提高了吞吐量和可扩展性


平行链技术来自于波卡项目。Polkadot 是一个可伸缩的异构多链系统,其本身不提供任何内在的功能应用,主要是连接各个区块链协议,并维护协议通讯有效安全,并保存这些通讯信息。平行链可以作为公共或私有网络运行,可以用于企业或组织运行,可以作为平台运行,让其他人在他们的基础上构建应用程序或作为公共利益平行链,来造福整个 Polkadot 生态系统,以及各种各样的其他模型 。


简单来说,平行链是一种依赖于波卡中继链提供安全性并与其他中继链通信的自主运行的原生区块链。它是一种简单、易扩展的区块链,它的安全性由“主链”提供。


2. 平行链与主链的关系


平行链是一种独立的区块链,它与主链(mainchain)通过双向桥接相连。它允许代币或其他数字资产在主链和平行链之间进行转移。每个平行链都是一个独立的区块链网络,拥有自己的代币、协议、共识机制和安全性


双向桥(two-way bridge)是一种连接两个区块链的技术,它允许资产在两个区块链之间进行转移。有单向(unidirectional)桥和双向(bidirectional)桥两种类型。单向桥意味着用户只能将资产桥接到一个目标区块链,但不能返回其原生区块链。双向桥允许资产在两个方向上进行桥接


平行链和主链保持既独立又连结的关系,在主链之下,它可以拥有自己的超级节点、状态机和原始交易数据。主链可以给平行链做跨链操作,从而形成链条生态系统。


3. 平行链的优势


平行链不仅可以利用系统的共识保证安全,还可以共享原有的生态。它们执行的计算本质上是独立,但又连结在一起。平行链之间有明确的隔离分界线,可以立即执行所有交易,而不用担心和其他链产生冲突。


使用平行链的原因是它能够显著提高吞吐量和可扩展性,并且能够支持多种不同的应用场景。


平行链可以用来运行区块链应用程序,如去中心化应用程序(dapps),并将计算负载从主链上移除,从而帮助扩展区块链。它们还可以与其他扩展解决方案结合使用。尽管平行链看起来是一个有前途的解决方案,但它们增加了区块链设计的复杂性,并且需要大量的努力和投资进行初始设置。由于平行链是独立的区块链,它们的安全性可能会受到损害,因为它们不受主链的保护。另一方面,如果一个平行链被攻击,它不会影响主链,因此它们可以用来试验新的协议和对主链的改进


4. 平行 链的关键特征


平行 链具有许多关键特征。例如,它们可以多条并行处理交易,效率可提升十倍。此外,由于只需要下载平行 链相关的数据,因此相对效率更高,速度更快。这些平行 链开枝散叶,可以打造自己独有的生态系。


5. 平行链的应用实例


目前市场上已经出现了许多基于平行 链技术开发 的项目。例如 Crust Network 是一个激励去中心化云服务 的应用型公 链。其通过对平行 链技术 的应用 ,既完整地保存了项目 的自主发展空间 ,也可更好地拓展项目功能 ,为用户提供更好地使用体验。


6. 平行链的经济政策


针对目前公 链技术自主权较弱 的问题 ,Polkadot 平 行 链 上 的社 区根据自己 的意志治理他们 的网络 ,不受波卡网络管理 的限制 ,拥有绝对 的自主权 。通过分片协议连接 的区块 链网络 ,可较好地实现拓展定制 。而基于 Polkadot 技术应用开发 的 Crust Network 是一个激励去中心化云服务 的应用型公 链。其通过对平行 链技术 的应用 ,既完整地保存了项目 的自主发展空间 ,也可更好地拓展项目功能 ,为用户提供更好地使用体验。from刘金,转载请

作者:Pomelo_刘金
来源:juejin.cn/post/7244174365172187193
注明原文链接。感谢!

收起阅读 »

你以为搞个流水线每天跑,团队就在使用CI/CD实践了?

在实践中,很多团队对于DevOps 流水线没有很透彻的理解,要不就创建一大堆流水线,要不就一个流水线通吃。实际上,流水线的设计和写代码一样,需要基于“业务场景”进行一定的设计编排,特别是很多通过“开源工具”搭建的流水线,更需要如此(商业的一体化平台大部分已经把...
继续阅读 »

在实践中,很多团队对于DevOps 流水线没有很透彻的理解,要不就创建一大堆流水线,要不就一个流水线通吃。实际上,流水线的设计和写代码一样,需要基于“业务场景”进行一定的设计编排,特别是很多通过“开源工具”搭建的流水线,更需要如此(商业的一体化平台大部分已经把设计思想融入自己产品里了)。



  • 流水线的设计与分支策略有关

  • 流水线的设计与研发活动有关


清晰的代码结构,标准的环境配置,原子化的流水线任务编排,再加上团队的协作纪律,和持续优化的动作,才是真正的践行CI/CD实践


流水线设计原则


1. 确定好变量



  • 哪些是构建/部署需要变化的,比如构建参数,代码地址,分支名称,安装版本,部署机器IP等,控制变化的,保证任务的可复制性,不要写很多hardcode进去


2. 流水线变量/命名的规范化



  • 标准化的命名,有助于快速复制;有意义的流水线命名,有助于团队新成员快速了解


3. 一次构建,多次部署



  • 一次构建,多次部署(多套环境配置+多套构建版本标签);杜绝相同代码重复打包

  • 相似技术栈/产品形态具备共性,通过以上原则可以抽取复用脚本,良好的设计有助于后续的可维护性!


4. 步骤标准化/原子化



  • 比如docker build/push, helm build/deploy, Maven构建等动作标准化,避免重复性写各种脚本逻辑

  • 根据业务场景组装,例如. 提测场景,每日构建场景,回归测试场景


image.png
5. 快速失败



  • 尽可能把不稳定的,耗时短的步骤 放在流水线的最前面,如果把一个稳定的步骤放在前面,并且耗时几十分钟,后面的某个步骤挂了,反馈周期就会变长


从零开始设计流水线


流水线分步骤实施, 从 “点” 到 “线” 结合业务需要串起来,适合自己团队协作开发节奏的流水线才是最好的。



  1. 价值流进行建模并创建简单的可工作流程

  2. 将 构建 和 部署 流程自动化

  3. 将 单元测试和 代码分析 自动化

  4. 将 验收测试 自动化

  5. 将 发布 自动化


image.png


流水线的分层


由于产品本身的形态不同,负责研发的团队人员组成不同,代码的版本管理分支策略不同,使用的部署流水线形式也会各不相同,所以基于实际业务场景设计流水线是团队工程实践成熟的重要标志



1. 提交构建流水线(个人级)


适用场景:每名研发工程师都创建了自己专属的流水线(一般对应个人的开发分支),用于个人在未推送代码到团队仓库之前的快速质量反馈。
注意:个人流水线并不会部署到 团队共同拥有的环境中,而是仅覆盖个人开发环节。如图所示,虚线步骤非必选

image.png


2. 集成验收流水线(团队级)


适用场景:每个团队都根据代码仓库(master/release/trunk)分支,创建产品专属的流水线,部署到 团队共同拥有的环境中e.g. dev)。
注意:如图所示,虚线步骤非必选,根据情况可通过 启动参数true/flase 跳过执行,自动化测试仅限于保证基本功能的用例。

image.png


3. 部署测试流水线(团队级)


适用场景:每个团队的测试工程师都需要专门针对提测版本的自动化部署/测试流水线,部署到团队共同拥有的环境中(e.g. test).
注意:如图所示,该条流水线的起点不是代码,而是提测的特定版本安装包;虚线步骤非必选,根据情况可通过 启动参数true/flase 跳过执行 或 裁剪。

image.png


4. 多组件集成流水线


适用场景:如果一个产品由多个组件构建而成,每个组件均有独自的代码仓库,并且每个组件由一个单独的团队负责开发与维护,那么,整个产品 的部署流水线的设计通常如下图所示。 集成部署流水线的集成打包阶段将自动从企业软件包库中获取每个组件最近成功的软件包,对其进行产品集成打包
image.png


5. 单功能流水线


适用场景:适用于和代码变更无关的场景,不存在上面步骤复杂的编排 (也可通过上述流水线的 启动参数进行条件控制,跳过一些步骤)



  • 针对某个环境的漏洞扫描

  • 针对某个已部署环境的自动化测试

  • 定时清理任务

  • ...


6. 全功能(持续交付)流水线


适用场景:需求、代码构建、测试、部署环境内嵌自动化能力,每次提交都触发完整流水线,中间通过人工审批层次卡点,从dev环境,test环境,stage环境一直到 prod环境。 常适用于快速发布的 PASS/SASS服务,对团队各项能力和流程制度要求较高,支持快速发布(策略)和快速回滚(策略)
image.png


流水线运转全景图


团队研发工程师每人每天都会提交一次。因此,流水线每天都会启动多次。当然并不是每次提交的变更都会走到最后的“上传发布” 。 也不是每次提交都会走到UAT 部署,因为开发人员并不是完成一个功能需求后才提交代码,而是只要做完一个开发任务,就可以提交。每个功能可能由 多个开发任务组成,研发工程师需要确保即使提交了功能尚未开发完成的代码,也不会影响已开发完成的那些功能。
制品经过一个个质量卡点,经历各种门禁验证,最终交付给客户 可以工作的软件
pipeline-status.jpg

收起阅读 »

接口耗时2000多秒!我人麻了!

接口耗时2000多秒!我人麻了! 前几天早上,有个push服务不断报警,报了很多次,每次都得运维同学重启服务来维持,因为这个是我负责的,所以我顿时紧张了起来,匆忙来到公司,早饭也不吃了,赶紧排查! 1、 现象与排查步骤: 下面是下午时候几次告警的截图: ...
继续阅读 »

接口耗时2000多秒!我人麻了!



  • 前几天早上,有个push服务不断报警,报了很多次,每次都得运维同学重启服务来维持,因为这个是我负责的,所以我顿时紧张了起来,匆忙来到公司,早饭也不吃了,赶紧排查!


1、 现象与排查步骤:


下面是下午时候几次告警的截图:



  • 来看下图。。。。接口超时 2000多秒。。。。我的心碎了!!!人也麻了!!!脑瓜子嗡嗡的。。。



  • image.png



  • 另外还总是报pod不健康、不可用 这些比较严重的警告!



  • image.png


我的第一反应是调用方有群发操作,然后看了下接口的qps 貌似也不高呀!也就 9req/s,
之后我去 grafana 监控平台 观察jvm信息发现,线程数量一直往上涨,而且线程状态是 WAITING 的也是一直涨。


如下是某一个pod的监控:


image.png
image.png


为了观察到底是哪个线程状态一直在涨,我们点进去看下详情:


image.png


上图可以看到 该pod的线程状态图 6种线程状态全列出来了, 分别用不同颜色的线代表。而最高那个同时也是14点以后不断递增那个是蓝线 代表的是 WAITING 状态下的线程数量。


通过上图现象,我大概知道了,肯定有线程是一直在wait无限wait下去,后来我找运维同学 dump了线程文件,分析一波,来看看到底是哪个地方使线程进入了wait !!!


如下 是dump下来的线程文件,可以看到搜索出427个WAITING这也基本和 grafana 监控中状态是WAITTING的线程数量一致


image.png


重点来了(这个 WAITING 状态的堆栈信息,还都是从 IOSPushStrategy#pushMsgWithIOS 这个方法的某个地方出来的(151行出来的)),于是我们找到代码来看看,是哪个小鬼在作怪?



image.png
而类 PushNotificationFuture 继承了 CompletableFuture,他自己又没有get方法,所以本质上 就是调用的 CompletableFuture的 get 方法。
image.png
ps:提一嘴,我们这里的场景是 等待ios push服务器的结果,不知道啥情况,今天(指发生故障的那天)ios push服务器(域名: api.push.apple.com )一直没返回,所以就导致一直等待下去了。。



看到这, 我 豁然开朗 && 真相大白 了,原来是在使用 CompletableFutureget时候,没设置超时时间,这样的话会导致一直在等结果。。。(但代码不是我写的,因为我知道 CompletableFuture 的get不设置参数会一直等下去 ,我只是维护,后期也没怎么修改这块的逻辑,哎 ,说多了都是泪呀!)


好一个 CompletableFuture#get();


(真是 死等啊。。。一点不含糊的等待下去,等的天荒地老海枯石烂也要等下去~~~ )


到此,问题的原因找到了。


2、 修复问题


解决办法很简单,给CompletableFuture的get操作 加上超时时间即可,如下代码即可修复:
image.png


在修复后,截止到今天(6月8号)没有这种报警情况了,而且线程数和WAITING线程都比较稳定,都在正常范围内,如下截图(一共4个pod):
image.png


至此问题解决了~~~ 终于可以睡个好觉啦!


3、 复盘总结


3.1、 代码浅析


既然此次的罪魁祸首是 CompletableFuture的get方法 那么我们就浅析一下 :



  1. 首先看下 get(); 方法
    image.png
    image.png


上边可以看到
不带参数的get方法: if(deadLine==0) 成立 ,也就是最终调用了LockSupport的park(this);方法,而这个方法最终调了unsafe的这个方法-> unsafe.park(false, 0L); 其中第二个参数就是等待多长时间后,unpark即唤醒被挂起的线程,而0 则代表无限期等待。



  1. 再来看下 get(long timeOut,TimeUnit unit);方法
    image.png
    我们可以看到
    带参数的get方法: if(deadLine==0) 不成立,走的else逻辑 也就是最终调用了LockSupportparkNanos(this,nanos);方法,而这个方法最终调了unsafe的这个方法-> unsafe.park(false, nanos); 其中第二个参数就是你调用get时,传入的tiimeOut参数(只不过底层转成纳秒了)


    我们跑了下程序,发现超过指定时间后,get(long timeOut,TimeUnit unit); 方法抛出 TimeoutException异常,而至于超时后我们开发者怎么处理,就在于具体情况了。
    Exception in thread "main" java.util.concurrent.TimeoutException
    at java.base/java.util.concurrent.CompletableFuture.timedGet(CompletableFuture.java:1886)
    at java.base/java.util.concurrent.CompletableFuture.get(CompletableFuture.java:2021)



而在 我的另一篇文章 万字长文分析synchroized 这篇文章中,我们其实深入过openjdk的源码,见识过parkunpark操作,我们截个图回忆一下:


image.png


3.2、最后总结:


1.在调用外部接口时。一定要注意设置超时时间,防止三方服务出问题后影响自身服务。


2.以后无论看到什么类型的Future,都要谨慎,因为这玩意说的是异步,但是调用get方法时,他本质上是同步等待,所以必须给他设置个超时时间,否则他啥时候能返回结果,就得看天意了!


3.凡是和第三方对接的东西,都要做最坏的打算,快速失败的方式很有必要。


4.遇到天大的问题,都尽可能保持冷静,不要乱了阵脚!
作者:蝎子莱莱爱打怪
来源:juejin.cn/post/7242237897993814075
strong>

收起阅读 »

flutter 刷新、加载与占位图一站式服务(基于easy_refresh扩展)

前文 今天聊到的是滚动视图的 刷新与加载,对于这个老生常谈的问题解决方案就太多了,优秀的插件也屡见不鲜,譬如 pull_to_refresh、 easy_refresh、还有笔者前段时间使用过的 infinite_scroll_pagination 这些都是...
继续阅读 »

前文


今天聊到的是滚动视图刷新与加载,对于这个老生常谈的问题解决方案就太多了,优秀的插件也屡见不鲜,譬如 pull_to_refresheasy_refresh、还有笔者前段时间使用过的 infinite_scroll_pagination 这些都是极具代表性的优秀插件。
那你在使用时有没有类似情况:



  • 为了重复的样版式代码感到厌倦?

  • 为了Ctrl V+C感到无聊?

  • 看到通篇类似的代码想大刀阔斧的整改?

  • 更有甚者有没有本来只想自定义列表样式,却反而浪费更多的时间来完成基础配置?


现在我们来解决这类问题,欢迎来到走近科学探索发现 - Approaching Scientific Exploration and Discovery。(可能片场走错了:) )


注意



  • 本文以 easy_refresh 作为刷新框架举例说明(其他框架的类似)

  • 本文案例demo以 getx 作为项目状态管理框架(与刷新无关仅作为项目基础框架构成,不喜勿喷)


正文


现在请出我们重磅成员mixin,对于这个相信大家已经非常熟悉了。我们要做的就是利用easy_refresh提供的refresh controller对视图逻辑进行拆分,从而精简我们的样板式代码。


首页拆离我们刷新和加载方法:


import 'dart:async';
import 'package:easy_refresh/easy_refresh.dart';
import 'state.dart';

mixin PagingMixin<T> {
/// 刷新控制器
final EasyRefreshController _pagingController = EasyRefreshController(
controlFinishLoad: true, controlFinishRefresh: true);
EasyRefreshController get pagingController => _pagingController;

/// 初始页码 <---- 单独提出这个的原因是有时候我们请求的起始页码不固定,有可能是0,也有可能是1
int _initPage = 0;

/// 当前页码
int _page = 0;
int get page => _page;

/// 列表数据
List<T> get items => _state.items;
int get itemCount => items.length;

/// 错误信息
dynamic get error => _state.error;

/// 关联刷新状态管理的控制器 <---- 自定义状态类型,后文会有阐述主要包含列表数据、初始加载是否为空、错误信息
PagingMixinController get state => _state;
final PagingMixinController<T> _state = PagingMixinController(
PagingMixinData(items: []),
);

/// 是否加载更多 <---- 可以在控制器中初始化传入,控制是否可以进行加载
bool _isLoadMore = true;
bool get isLoadMore => _isLoadMore;

/// 控制刷新结束回调(异步处理) <---- 手动结束异步操作,并返回结果
Completer? _refreshComplater;

/// 挂载分页器
/// `controller` 关联刷新状态管理的控制器
/// `initPage` 初始页码值(分页起始页)
/// `isLoadMore` 是否加载更多
void initPaging({
int initPage = 0,
isLoadMore = true,
}) {
_isLoadMore = isLoadMore;
_initPage = initPage;
_page = initPage;
}

/// 获取数据
FutureOr fetchData(int page);

/// 刷新数据
Future onRefresh() async {
_refreshComplater = Completer();
_page = _initPage;
fetchData(_page);
return _refreshComplater!.future;
}

/// 加载更多数据
Future onLoad() async {
_refreshComplater = Completer();
_page++;
fetchData(_page);
return _refreshComplater!.future;
}

/// 获取数据后调用
/// `items` 列表数据
/// `maxCount` 数据总数,如果为0则默认通过 `items` 有无数据判断是否可以分页加载, null为非分页请求
/// `error` 错误信息
/// `limit` 单页显示数量限制,如果items.length < limit 则没有更多数据
void endLoad(
List<T>? list, {
int? maxCount,
// int limit = 5,
dynamic error,
}) {
if (_page == _initPage) {
_refreshComplater?.complete();
_refreshComplater = null;
}

final dataList = List.of(_state.value.items);
if (list != null) {
if (_page == _initPage) {
dataList.clear();
// 更新数据
_pagingController.finishRefresh();
_pagingController.resetFooter();
}
dataList.addAll(list);
// 更新列表
_state.value = _state.value.copyWith(
items: dataList,
isStartEmpty: page == _initPage && list.isEmpty,
);

// 默认没有总数量 `maxCount`,用获取当前数据列表是否有值判断
// 默认有总数量 `maxCount`, 则判断当前请求数据list+历史数据items是否小于总数
// bool hasNoMore = !((items.length + list.length) < maxCount);
bool isNoMore = true;
if (maxCount != null) {
isNoMore = page > 1; // itemCount >= maxCount;
}
var state = IndicatorResult.success;
if (isNoMore) {
state = IndicatorResult.noMore;
}
_pagingController.finishLoad(state);
} else {
_state.value = _state.value.copyWith(items: [], error: error ?? '数据请求错误');
}
}

}


创建PagingMixin<T>混入类型,泛型<T>属于列表子项的数据类型


void initPaging(...):初始化的时候可以写入基本设置(可以不调用)


Future onRefresh() Future onLoad():供外部调用的刷新加载方法


FutureOr fetchData(int page):由子类集成重写,主要是完成数据获取方法,在获取到数据后,需要调用方法void endLoad(...)来结束整个请求操作,通知视图刷新


PagingMixinController继承自ValueNotifier,是对数据相关状态的缓存,便于独立逻辑操作与数据状态:


class PagingMixinController<T> extends ValueNotifier<PagingMixinData<T>> {
PagingMixinController(super.value);

dynamic get error => value.error;
List<T> get items => value.items;
int get itemCount => items.length;
}
// flutter 关于easy_refresh更便利的打开方式
class PagingMixinData<T> {
// 列表数据
final List<T> items;

/// 错误信息
final dynamic error;

/// 首次加载是否为空
bool isStartEmpty;

PagingMixinData({
required this.items,
this.error,
this.isStartEmpty = false,
});

....

}

完成这两个类的编写,我们对于逻辑部分的拆离已经完成了。


下面是对easy_refresh的使用,封装:


class PullRefreshControl extends StatelessWidget {
const PullRefreshControl({
super.key,
required this.pagingMixin,
required this.childBuilder,
this.header,
this.footer,
this.locatorMode = false,
this.refreshOnStart = true,
this.startRefreshHeader,
});

final Header? header;
final Footer? footer;

final bool refreshOnStart;
final Header? startRefreshHeader;

/// 列表视图
final ERChildBuilder childBuilder;

/// 分页控制器
final PagingMixin pagingMixin;

/// 是否固定刷新偏移
final bool locatorMode;

@override
Widget build(BuildContext context) {
final firstRefreshHeader = startRefreshHeader ??
BuilderHeader(
triggerOffset: 70,
clamping: true,
position: IndicatorPosition.above,
processedDuration: Duration.zero,
builder: (ctx, state) {
if (state.mode == IndicatorMode.inactive ||
state.mode == IndicatorMode.done) {
return const SizedBox();
}
return Container(
padding: const EdgeInsets.only(bottom: 100),
width: double.infinity,
height: state.viewportDimension,
alignment: Alignment.center,
child: SpinKitFadingCube(
size: 25,
color: Theme.of(context).primaryColor,
),
);
},
);

return EasyRefresh.builder(
controller: pagingMixin.pagingController,
header: header ??
RefreshHeader(
clamping: locatorMode,
position: locatorMode
? IndicatorPosition.locator
: IndicatorPosition.above,
),
footer: footer ?? const ClassicFooter(),
refreshOnStart: refreshOnStart,
refreshOnStartHeader: firstRefreshHeader,
onRefresh: pagingMixin.onRefresh,
onLoad: pagingMixin.isLoadMore ? pagingMixin.onLoad : null,
childBuilder: (context, physics) {
return ValueListenableBuilder(
valueListenable: pagingMixin.state,
builder: (context, value, child) {
if (value.isStartEmpty) {
return _PagingStateView(
isEmpty: value.isStartEmpty,
onLoading: pagingMixin.onRefresh,
);
}
return childBuilder.call(context, physics);
},
);
},
);
}
}

创建PullRefreshControl类型,设置必须属性pagingMixinchildBuilder,前者是我们创建的PagingMixin对象(可以是任何类型,只要支持混入就可以了),后者是对我们滚动列表的实现。 其他的都是对 easy_refresh的属性配置,参考相关文档就行了。


到这里我们减配版的封装就完成了,使用方式如下:


截图


截图


但是我们并没有完成我们前文所说的简化操作,还是需要一遍又一遍创建重复的滚动列表,所以我们继续:


/// 快速构建 `ListView` 形式的分页列表
/// 其他详细参数查看 [ListView]
class SpeedyPagedList<T> extends StatelessWidget {
const SpeedyPagedList({
super.key,
required this.controller,
required this.itemBuilder,
this.scrollController,
this.padding,
this.header,
this.footer,
this.locatorMode = false,
this.refreshOnStart = true,
this.startRefreshHeader,
double? itemExtent,
}) : _separatorBuilder = null,
_itemExtent = itemExtent;

const SpeedyPagedList.separator({
super.key,
required this.controller,
required this.itemBuilder,
required IndexedWidgetBuilder separatorBuilder,
this.scrollController,
this.padding,
this.header,
this.footer,
this.locatorMode = false,
this.refreshOnStart = true,
this.startRefreshHeader,
}) : _separatorBuilder = separatorBuilder,
_itemExtent = null;

final PagingMixin<T> controller;

final Widget Function(BuildContext context, int index, T item) itemBuilder;

final Header? header;
final Footer? footer;

final bool refreshOnStart;
final Header? startRefreshHeader;
final bool locatorMode;

/// 参照 [ScrollView.controller].
final ScrollController? scrollController;

/// 参照 [ListView.itemExtent].
final EdgeInsetsGeometry? padding;

/// 参照 [ListView.separator].
final IndexedWidgetBuilder? _separatorBuilder;

/// 参照 [ListView.itemExtent].
final double? _itemExtent;

@override
Widget build(BuildContext context) {
return PullRefreshControl(
pagingMixin: controller,
header: header,
footer: footer,
refreshOnStart: refreshOnStart,
startRefreshHeader: startRefreshHeader,
locatorMode: locatorMode,
childBuilder: (context, physics) {
return _separatorBuilder != null
? ListView.separated(
physics: physics,
padding: padding,
controller: scrollController,
itemCount: controller.itemCount,
itemBuilder: (context, index) {
final item = controller.items[index];
return itemBuilder.call(context, index, item);
},
separatorBuilder: _separatorBuilder!,
)
: ListView.builder(
physics: physics,
padding: padding,
controller: scrollController,
itemExtent: _itemExtent,
itemCount: controller.itemCount,
itemBuilder: (context, index) {
final item = controller.items[index];
return itemBuilder.call(context, index, item);
},
);
},
);
}
}

...


归纳我们所需要的使用方式(我这里只写了ListView/GridView),构创建快速初始化加载列表的方法,将我们仅需要的Widget Function(BuildContext context, int index, T item) itemBuilder单个元素的创建(因为对于大多列表来说我们仅关心单个元素样式)暴露出来,简化PullRefreshControl的使用。


截图


对比前面的使用方式,现在更加简洁了,总计代码也就十几行吧。


到这里就结束啦,文章也仅算是对繁杂重复使用的东西进行一些归纳总结,没有特别推崇的意思,更优秀的方案也比比皆是,所以仁者见仁了各位。


附GIF展示:


GIF 2023-6-9 14-23-45.gif


附Demo地址: boomcx/templat

e_getx

收起阅读 »

mybatis拦截器实现数据权限

前端的菜单和按钮权限都可以通过配置来实现,但很多时候,后台查询数据库数据的权限需要通过手动添加SQL来实现。 比如员工打卡记录表,有id,name,dpt_id,company_id等字段,后两个表示部门ID和分公司ID。 查看员工打卡记录SQL为:selec...
继续阅读 »

前端的菜单和按钮权限都可以通过配置来实现,但很多时候,后台查询数据库数据的权限需要通过手动添加SQL来实现。

比如员工打卡记录表,有id,name,dpt_id,company_id等字段,后两个表示部门ID和分公司ID。

查看员工打卡记录SQL为:select id,name,dpt_id,company_id from t_record


当一个总部账号可以查看全部数据此时,sql无需改变。因为他可以看到全部数据。

当一个部门管理员权限员工查看全部数据时,sql需要在末属添加 where dpt_id = #{dpt_id}


如果每个功能模块都需要手动写代码去拿到当前登陆用户的所属部门,然后手动添加where条件,就显得非常的繁琐。

因此,可以通过mybatis的拦截器拿到查询sql语句,再自动改写sql。


mybatis 拦截器


MyBatis 允许你在映射语句执行过程中的某一点进行拦截调用。默认情况下,MyBatis 允许使用插件来拦截的方法调用包括:



  • Executor (update, query, flushStatements, commit, rollback, getTransaction, close, isClosed)

  • ParameterHandler (getParameterObject, setParameters)

  • ResultSetHandler (handleResultSets, handleOutputParameters)

  • StatementHandler (prepare, parameterize, batch, update, query)


这些类中方法的细节可以通过查看每个方法的签名来发现,或者直接查看 MyBatis 发行包中的源代码。 如果你想做的不仅仅是监控方法的调用,那么你最好相当了解要重写的方法的行为。 因为在试图修改或重写已有方法的行为时,很可能会破坏 MyBatis 的核心模块。 这些都是更底层的类和方法,所以使用插件的时候要特别当心。


通过 MyBatis 提供的强大机制,使用插件是非常简单的,只需实现 Interceptor 接口,并指定想要拦截的方法签名即可。


分页插件pagehelper就是一个典型的通过拦截器去改写SQL的。



可以看到它通过注解 @Intercepts 和签名 @Signature 来实现,拦截Executor执行器,拦截所有的query查询类方法。

我们可以据此也实现自己的拦截器。



import com.skycomm.common.util.user.Cpip2UserDeptVo;
import com.skycomm.common.util.user.Cpip2UserDeptVoUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.ibatis.cache.CacheKey;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.SqlSource;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.plugin.Intercepts;
import org.apache.ibatis.plugin.Invocation;
import org.apache.ibatis.plugin.Signature;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.RowBounds;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;

@Component
@Intercepts({
@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class}),
})
@Slf4j
public class MySqlInterceptor implements Interceptor {

@Override
public Object intercept(Invocation invocation) throws Throwable {
MappedStatement statement = (MappedStatement) invocation.getArgs()[0];
Object parameter = invocation.getArgs()[1];
BoundSql boundSql = statement.getBoundSql(parameter);
String originalSql = boundSql.getSql();
Object parameterObject = boundSql.getParameterObject();

SqlLimit sqlLimit = isLimit(statement);
if (sqlLimit == null) {
return invocation.proceed();
}

RequestAttributes req = RequestContextHolder.getRequestAttributes();
if (req == null) {
return invocation.proceed();
}

//处理request
HttpServletRequest request = ((ServletRequestAttributes) req).getRequest();
Cpip2UserDeptVo userVo = Cpip2UserDeptVoUtil.getUserDeptInfo(request);
String depId = userVo.getDeptId();

String sql = addTenantCondition(originalSql, depId, sqlLimit.alis());
log.info("原SQL:{}, 数据权限替换后的SQL:{}", originalSql, sql);
BoundSql newBoundSql = new BoundSql(statement.getConfiguration(), sql, boundSql.getParameterMappings(), parameterObject);
MappedStatement newStatement = copyFromMappedStatement(statement, new BoundSqlSqlSource(newBoundSql));
invocation.getArgs()[0] = newStatement;
return invocation.proceed();
}

/**
* 重新拼接SQL
*/
private String addTenantCondition(String originalSql, String depId, String alias) {
String field = "dpt_id";
if(StringUtils.isBlank(alias)){
field = alias + "." + field;
}

StringBuilder sb = new StringBuilder(originalSql);
int index = sb.indexOf("where");
if (index < 0) {
sb.append(" where ") .append(field).append(" = ").append(depId);
} else {
sb.insert(index + 5, "
" + field +" = " + depId + " and ");
}
return sb.toString();
}

private MappedStatement copyFromMappedStatement(MappedStatement ms, SqlSource newSqlSource) {
MappedStatement.Builder builder = new MappedStatement.Builder(ms.getConfiguration(), ms.getId(), newSqlSource, ms.getSqlCommandType());
builder.resource(ms.getResource());
builder.fetchSize(ms.getFetchSize());
builder.statementType(ms.getStatementType());
builder.keyGenerator(ms.getKeyGenerator());
builder.timeout(ms.getTimeout());
builder.parameterMap(ms.getParameterMap());
builder.resultMaps(ms.getResultMaps());
builder.cache(ms.getCache());
builder.useCache(ms.isUseCache());
return builder.build();
}


/**
* 通过注解判断是否需要限制数据
* @return
*/
private SqlLimit isLimit(MappedStatement mappedStatement) {
SqlLimit sqlLimit = null;
try {
String id = mappedStatement.getId();
String className = id.substring(0, id.lastIndexOf("
."));
String methodName = id.substring(id.lastIndexOf("
.") + 1, id.length());
final Class<?> cls = Class.forName(className);
final Method[] method = cls.getMethods();
for (Method me : method) {
if (me.getName().equals(methodName) && me.isAnnotationPresent(SqlLimit.class)) {
sqlLimit = me.getAnnotation(SqlLimit.class);
}
}
} catch (Exception e) {
e.printStackTrace();
}
return sqlLimit;
}


public static class BoundSqlSqlSource implements SqlSource {

private final BoundSql boundSql;

public BoundSqlSqlSource(BoundSql boundSql) {
this.boundSql = boundSql;
}

@Override
public BoundSql getBoundSql(Object parameterObject) {
return boundSql;
}
}
}


顺便加了个注解 @SqlLimit,在mapper方法上加了此注解才进行数据权限过滤。

同时注解有两个属性,


@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface SqlLimit {
/**
* sql表别名
* @return
*/

String alis() default "";

/**
* 通过此列名进行限制
* @return
*/

String columnName() default "";
}

columnName表示通过此列名进行限制,一般来说一个系统,各表当中的此列是统一的,可以忽略。


alis用于标注sql表别名,如 针对sql select * from tablea as a left join tableb as b on a.id = b.id 进行改写,如果不知道表别名,会直接在后面拼接 where dpt_id = #{dptId},

那此SQL就会错误的,通过别名 @SqlLimit(alis = "a") 就可以知道需要拼接的是 where a.dpt_id = #{dptId}


执行结果


原SQL:select * from person, 数据权限替换后的SQL:select * from person where dpt_id = 234

原SQL:select * from person where id > 1, 数据权限替换后的SQL:select * from person where dpt_id = 234 and id > 1


但是在使用PageHelper进行分页的时候还是有问题。



可以看到先执行了_COUNT方法也就是PageHelper,再执行了自定义的拦截器。


在我们的业务方法中注入SqlSessionFactory


@Autowired
@Lazy
private List<SqlSessionFactory> sqlSessionFactoryList;


PageInterceptor为1,自定义拦截器为0,跟order相反,PageInterceptor优先级更高,所以越先执行。




mybatis拦截器优先级




@Order




通过@Order控制PageInterceptor和MySqlInterceptor可行吗?



将MySqlInterceptor的加载优先级调到最高,但测试证明依然不行。


定义3个类


@Component
@Order(2)
public class OrderTest1 {

@PostConstruct
public void init(){
System.out.println(" 00000 init");
}
}

@Component
@Order(1)
public class OrderTest2 {

@PostConstruct
public void init(){
System.out.println(" 00001 init");
}
}

@Component
@Order(0)
public class OrderTest3 {

@PostConstruct
public void init(){
System.out.println(" 00002 init");
}
}

OrderTest1,OrderTest2,OrderTest3的优先级从低到高。

顺序预期的执行顺序应该是相反的:


00002 init
00001 init
00000 init

但事实上执行的顺序是


00000 init
00001 init
00002 init

@Order 不控制实例化顺序,只控制执行顺序。
@Order 只跟特定一些注解生效 如:@Compent @Service @Aspect … 不生效的如: @WebFilter


所以这里达不到预期效果。


@Priority 类似,同样不行。




@DependsOn




使用此注解将当前类将在依赖类实例化之后再执行实例化。


在MySqlInterceptor上标记@DependsOn("queryInterceptor")



启动报错,

这个时候queryInterceptor还没有实例化对象。




@PostConstruct




@PostConstruct修饰的方法会在服务器加载Servlet的时候运行,并且只会被服务器执行一次。

在同一个类里,执行顺序为顺序如下:Constructor > @Autowired > @PostConstruct。


但它也不能保证不同类的执行顺序。


PageHelper的springboot start也是通过这个来初始化拦截器的。





ApplicationRunner




在当前springboot容器加载完成后执行,那么这个时候pagehelper的拦截器已经加入,在这个时候加入自定义拦截器,就能达到我们想要的效果。

仿照PageHelper来写


@Component
public class InterceptRunner implements ApplicationRunner {

@Autowired
private List<SqlSessionFactory> sqlSessionFactoryList;

@Override
public void run(ApplicationArguments args) throws Exception {
MySqlInterceptor mybatisInterceptor = new MySqlInterceptor();
for (SqlSessionFactory sqlSessionFactory : sqlSessionFactoryList) {
org.apache.ibatis.session.Configuration configuration = sqlSessionFactory.getConfiguration();
configuration.addInterceptor(mybatisInterceptor);
}
}
}

再执行,可以看到自定义拦截器在拦截器链当中下标变为了1(优先级与order刚好相反)



后台打印结果,达到了预期效果。


收起阅读 »

单例模式

单例设计模式 单例设计模式是一种对象创建模式,用于产生一个对象的具体实例,它可以确保系统中一个类只产生一个实例。 好处: 对于频繁使用的对象,可以省略创建对象所花费的时间,这对于那些重量级对象而言,是非常可观的一笔系统开销。 由于new操作的次数减少,因而对...
继续阅读 »

单例设计模式


单例设计模式是一种对象创建模式,用于产生一个对象的具体实例,它可以确保系统中一个类只产生一个实例。


好处:



  • 对于频繁使用的对象,可以省略创建对象所花费的时间,这对于那些重量级对象而言,是非常可观的一笔系统开销。

  • 由于new操作的次数减少,因而对系统内存的使用频率也会降低,这将减轻GC压力,缩短GC停顿时间。


单例模式的六种写法:


一、饿汉单例设计模式


步骤:



  1. 私有化构造函数。

  2. 声明本类的引用类型变量,并且使用该变量指向本类对象。

  3. 提供一个公共静态的方法获取本类的对象。


//饿汉单例设计模式 ----> 保证Single在在内存中只有一个对象。
public class HungrySingleton {
//声明本类的引用类型变量,并且使用该变量指向本类对象
private static final HungrySingleton instance = new HungrySingleton();
//私有化构造函数
private HungrySingleton(){
System.out.println("instance is created");
}
//提供一个公共静态的方法获取本类的对象
public static HungrySingleton getInstance(){
return instance;
}
}


不足:无法对instance实例做延迟加载


优化:懒汉


二、懒汉单例设计模式



  1. 私有化构造函数。

  2. 声明本类的引用类型变量,但是不要创建对象。

  3. 提供公共静态的方法获取本类的对象,获取之前先判断是否已经创建了本类对象,如果已经创建了,那么直接返回对象即可,如果还没有创建,那么先创建本类的对象,然后再返回。


//懒汉单例设计模式 ----> 保证Single在在内存中只有一个对象。
public class LazySingleton {
//声明本类的引用类型变量,不创建本类的对象
private static LazySingleton instance;
//私有化构造函数
private LazySingleton(){

}
public static LazySingleton getInstance(){
//第一次调用的时候会被初始化
if(instance == null){
instance = new LazySingleton();
}
return instance;
}
}


不足:在多线程的情况下,无法保证内存中只有一个实例


public class MyThread extends Thread{

@Override
public void run() {
System.out.println(LazySingleton.getInstance().hashCode());
}

public static void main(String[] args) {
MyThread[] myThread = new MyThread[10];
for(int i=0;i<myThread.length;i++){
myThread[i] = new MyThread();
}
for(int j=0;j<myThread.length;j++){
myThread[j].start();
}
}
}


打印结果:


257688302
1983483740
1983483740
1983483740
1983483740
1983483740
1983483740
1388138972
1983483740
257688302

在多线程并发下这样的实现无法保证实例是唯一的。


优化:懒汉线程安全


三、懒汉线程安全


通过使用同步函数或者同步代码块保证


public class LazySafetySingleton {

private static LazySafetySingleton instance;
private LazySafetySingleton(){

}
//方法中声明synchronized关键字
public static synchronized LazySafetySingleton getInstance(){
if(instance == null){
instance = new LazySafetySingleton();
}
return instance;
}

//同步代码块实现
public static LazySafetySingleton getInstance1(){
synchronized (LazySafetySingleton.class) {
if(instance == null){
instance = new LazySafetySingleton();
}
}
return instance;
}
}

不足:使用synchronized导致性能缺陷


优化:DCL


四、DCL


DCL:double check lock (双重检查锁机制)


public class DclSingleton {

private static DclSingleton instance = null;

private DclSingleton(){

}
public static DclSingleton getInstance(){
//避免不必要的同步
if(instance == null){
//同步
synchronized (DclSingleton.class) {
//在第一次调用时初始化
if(instance == null){
instance = new DclSingleton();
}
}
}
return instance;
}

}


不足:在if判断中执行的instance = new DclSingleton(),该操作不是一个原子操作,JVM首先会按照逻辑,第一步给instance分配内存;第二部,调用DclSingleton()构造方法初始化变量;第三步将instance对象指向JVM分配的内存空间;JVM的缺点:在即时编译器中,存在指令重排序的优化,即以上三步不一定会按照顺序执行,就会造成线程不安全。


优化:给instance的声明加上volatile关键字,volatile能保证线程在本地不会存有instance的副本,而是每次都到内存中读取。即禁止JVM的指令重排序优化。即按照原本的步骤。把instance声明为volatile之后,对它的写操作就会有一个内存屏障,这样,在它的赋值完成之前,就不会调用读操作。



注意:volatile阻止的不是instance = new DclSingleton();这句话内部的指令排序,而是保证了在一个写操作完成之前,不会调用读操作(if(instance == null))



public class DclSingleton {

private static volatile DclSingleton instance = null;

private DclSingleton(){

}
public static DclSingleton getInstance(){
//避免不必要的同步
if(instance == null){
//同步
synchronized (DclSingleton.class) {
//在第一次调用时初始化
if(instance == null){
instance = new DclSingleton();
}
}
}
return instance;
}

}

五、静态内部类


JVM提供了同步控制功能:static final,利用JVM进行类加载的时候保证数据同步。


在内部类中创建对象实例,只要应用中不使用内部类,JVM就不会去加载该类,就不会创建我们要创建的单例对象,


public class StaticInnerSingleton {

private StaticInnerSingleton(){

}
/**
* 在第一次加载StaticInnerSingleton时不会初始化instance,
* 只在第一次调用了getInstance方法时,JVM才会加载StaticInnerSingleton并初始化instance
* @return
*/

public static StaticInnerSingleton getInstance(){
return SingletonHolder.instance;
}
//静态内部类
private static class SingletonHolder{
private static final StaticInnerSingleton instance = new StaticInnerSingleton();
}

}


优点:JVM本身机制保证了线程安全,没有性能缺陷。


六、枚举


public enum EnumSingleton {
//定义一个枚举的元素,它就是Singleton的一个实例
INSTANCE;

public void doSomething(){

}
}

优点:写法简单,线程安全



注意:如果在枚举类中有其他实例方法或实例变量,必须确保是线程安全的。


作者:我可能是个假开发
来源:juejin.cn/post/7242614671001862199

收起阅读 »

来自智能垃圾桶的诱惑,嵌入式开发初探

背景原因 最近裸辞离职在老家,正在收拾老家的房子准备住进去,但迫于经济压力,只能先装非常有必要的东西,例如床、马桶、浴室柜等。 但是垃圾桶是生活必备品,我发现大家现在都在用智能垃圾桶,那种可以感应开盖的,还有那种自动封袋的。我极其的羡慕。但是迫于经济压力,...
继续阅读 »

背景原因



最近裸辞离职在老家,正在收拾老家的房子准备住进去,但迫于经济压力,只能先装非常有必要的东西,例如床、马桶、浴室柜等。



但是垃圾桶是生活必备品,我发现大家现在都在用智能垃圾桶,那种可以感应开盖的,还有那种自动封袋的。我极其的羡慕。但是迫于经济压力,我没有足够的资金购买智能垃圾桶!



作为一个学美术的程序员,我在想我要不要去少儿美术培训教小孩,或者去街头卖艺,甚至去给人画遗照赚点钱呢?我想了想算了,给别人留点活路吧,我这么专业的美术人才去了,那些老师、街头艺人、画师不得喝西北风去。我想了想,我除了是学美术的,我还是个程序员啊!


虽然我没有学过嵌入式、硬件、IOT等等技术,但是入门应该不太困难吧!已经好多年没有从0开始学习一门技术了,非常怀念当时学习Web技术的那种感觉,可以没日没夜的去探索新知识,学习东西。之前上班的时候根本没有精力去学习或者搞一些有趣的东西,下班只想躺着看动漫,听爽文。既然现在赋闲在家,那就搞一搞吧!


项目启动


俗话说得好,找到一个好师傅就是成功的一半!在这方面我有着得天独厚的优势,我的前公司,北京犬安科技就是一个做车联网安全的公司,这家公司的Slogan是:从硅到云,守护网联汽车安全。可以看出,犬安的硬件软件安全能力都非常的强。像我,天天和测试组的同事去山西面馆吃鱼香肉丝盖饭,喝青岛雪花,他们组里面都是硬件大佬,甚至有人造过火箭。此时不向他们学习,更待何时?


他们告诉我,我需要一个开发板、一个超声波传感器、一个舵机。


我还在研究什么是stm32/esp32的时候,他们告诉我用Arduino,特别简单。我就去研究Arduino,在B站上搜了 Arduino,有一个播放量比较高的系列教程看了一下。我顿时就悟了!


我本来以为我需要买烙铁,焊电路板的,后来我发现,只需要买以上提到的三个东西,外加一个面包版,一些杜邦线就可以,甚至可以不需要面包版。



当然为了学习,我在淘宝上买了一些乱七八糟的东西,比如按钮、各种电阻、各种颜色led灯、面包版。买各种颜色的LED灯的时候,购买欲泛滥了,就想每个颜色都买一些,什么白发红,白发黄,白发翠绿,后来我买回来发现,不亮的情况下都是白色的,我根本分不清颜色。原来那个白发黄的意思就是不亮的时候是白色,亮了以后发黄色的光~我还买了接受RGB三色的LED灯,不知道为啥不是RGBA,难道不能让灯透明吗?



我还发现不同欧姆的电阻他上面的“杠杠”是不一样的。



主要是这玩意太便宜了,两块钱就买好多,但是作为新手玩家来说,根本用不了。我只买了公对公的杜邦线,然后我又单独下单了其他杜邦线,果然两块多就能包邮。



我在不同的店铺里面选了很多配件,包括我使用的主要配件:Arduino uno 开发板、SG90舵机、HC-SR04 超声波传感器。


开搞


设备买回来以后,废了9牛2虎之力才成功的能把我写的程序上传到开发板里。


一开始一直报下面这个错误,网上众说纷纭,始终没有找到解决方案。


avrdude: stk500_getsync() attempt 1 of 10: not in sync: resp=0x30

后来我去淘宝详情页仔细研究了一番才搞明白,我本以为我买的是Arduino的开发板,其实是esp8266,只是兼容Arduino。我从淘宝详情页找到了正确姿势,成功的刷入了程序。


看了 Arduino 的视频,我主要悟出来了什么?


开发板、舵机、传感器上有很多小尖尖或者小洞洞,他叫引脚,我们需要把舵机、传感器的引脚,通过一种名为杜邦线的线(通常有公对公/母对母/公对母,代表线两头是小尖尖还是小洞洞),连接到开发板的引脚上。然后使用程序控制某个引脚的电平向舵机发送信号,或者接受传感器的信号。


第一个项目


我举一个最简单的LED灯闪烁的例子: 线是这么接的:



面包版上面,竖着的五个小洞洞里面其实是连在一起的。我的电路是从一个引脚出来,到了一个电阻上,然后连接一个LED灯,然后接地。 接地的概念应该和负极的概念很像,但是不是,不过我暂且不关心它。 为什么不直接从引脚出来接LED,再接地呢,因为视频上说,LED几乎是没有电阻的,如果直接从串口出来接到LED,直接接地的话,开发板就废了,所以加了个电阻。


接下来我让ChatGPT帮我写一个小LED灯闪烁的代码:



我们可以看到在loop里面,调用了digitalWrite,把那个引脚进行了高低电平的切换,这样就实现了小LED灯闪烁的效果,高电平就相当于给小LED灯供电了。


我们只需要把他写的代码中的引脚的编号改成我们的编号就好了。


每种开发板的引脚编号都不一样!我在淘宝上买的这个板,他没有给我资料,我干了一件特别愚蠢的事情,分别给不同的数字编号供电,插线看哪个亮,就记录对应开发板上的引脚号和数字。


第二天我在网上稍微搜了一下资料就找到了。后来我发现,竟然还有一个编号对应两个引脚的,具体为什么我也不知道。


不过具体怎么给舵机传信号,或者接受传感器的信号呢?如果在 Arduino IDE 里面的话就是简单的调用API就好了。这一块我没写代码,都是ChatGPT帮我写的。



实现目标的主要部分


我使用同样的方法,制作了垃圾桶的功能的电路和代码。 使用超声波传感器的时候,我发现这玩意都好神奇啊,仿佛是魔法一样。 比如超声波传感器,要先发送一个超声波信号出去,然后等待回波信息,拿到发送和接收的时间差,通过计算得到距离。



我在调试超声波传感器的时候,我发现我能听到超声波传感器发出的声音,只要耳朵对着它不用靠太近就可以听得到。我朋友说我是超级耳。


有了LED灯的经验以后,超声波识别距离,调用舵机旋转很快就实现了。



最开始我在考虑这个舵机的力道到底能不能支撑起来垃圾盖子,不过现在感觉力道还是挺大的。不过现在没有办法试,因为我还没有做出来合适的垃圾桶。


接下来


接下来我要去找到一个合适的垃圾桶,我也不确定需要用什么方式去打开垃圾桶的盖子,是不是需要其他的比如初中学过的滑轮、齿轮、杠杆原理什么的?这块还没有开始涉猎,等我接下来研究研究来写第二部分。



作者:明非
来源:juejin.cn/post/7215217068803145785

感谢大家阅读~

收起阅读 »

转转商品到手价

1 背景介绍 1.1 问题 搜索结果落地页,按照价格筛选及排序,结果不太准确; 用户按照价格筛选后的商品与实际存在的商品不符,可能会缺失部分商品,影响到用户购物体验。 1.2 到手价模块在促销架构中所处的位置 在整体架构中,商品的到手价涉及红包,...
继续阅读 »

1 背景介绍


1.1 问题




  • 搜索结果落地页,按照价格筛选及排序,结果不太准确;




  • 用户按照价格筛选后的商品与实际存在的商品不符,可能会缺失部分商品,影响到用户购物体验。




image


1.2 到手价模块在促销架构中所处的位置


在整体架构中,商品的到手价涉及红包,活动等模块的协同工作。通过将商品售价、红包、活动等因素纳入综合考虑,计算出最终的到手价,为顾客提供良好的购物体验。


image


2 设计目标



  • 体验:用户及时看到商品的最新到手价,提升用户购物体验;

  • 实时性:由原来的半小时看到商品的最新到手价,提升到3分钟内。


3 技术方案核心点


3.1 影响因素


image


影响商品到手价的主要因素:




  1. 商品,发布或改价;




  2. 红包,新增/删除或过期;




  3. 活动/会馆,加入或踢出。




3.2 计算公式


image


如图所示,商品详情页到手价的优惠项明细可用公式总结如下:


商品的到手价 = 商品原价 - 活动促销金额 - 红包最大优惠金额


4 落地过程及效果


image


随着业务需求的变化,系统也需要不断地增加新功能或者对现有功能进行改进,通过版本演进,可以逐步引入新的功能模块或优化现有模块,以满足业务的需求。


商品的到手价设计也是遵循这样规则,从开始的v1.0快速开发上线,响应业务; 到v2.0,v3.0进行性能优化,升级改造,使用户体验更佳,更及时。


4.1 v1.0流程


image


v1.0流程一共分为两步:




  1. 定时任务拉取拉取特定业务线的全量商品,将这批商品全量推送给各个接入方;




  2. 促销系统提供回查接口,根据商品id等参数,查询商品的到手价;




4.2 v1.0任务及接口


image




  1. v1.0任务执行时间长,偶尔还会出现执行失败的情况;而在正常情况下用户大概需要半小时左右才能感知到最新的商品到手价;




  2. 需要提供额外的单商品及批量商品接口查询到手价,无疑会对系统产生额外的查询压力,随着接入方的增加,接口qps会成比例增加;




4.3 v2.0设计


针对v1.0版本长达半个小时更新一次到手价问题,v2.0解决方案如下:



  • 实时处理部分


商品上架/商品改价;


商品加入/踢出活动;


商品加入/踢出会馆;


这部分数据的特点是,上述这些操作是能实时拿到商品的infoId,基于这些商品的infoId,可以立即计算这些商品的到手价并更新出去。


image


商品发布/改价,加入活动/会馆,踢出活动/会馆;接收这些情况下触发的mq消息,携带商品infoId,直接计算到手价。



  • 3min任务,计算特定业务线的全量商品到手价


红包: 新增/更新/删除/过期;


这部分数据的特点是,一是不能很容易能圈出受到影响的这批商品infoIds,二是有些红包的领取和使用范围可能会涉及绝大部分的商品,甚至有些时候大型促销会配置一些全平台红包,影响范围就是全量商品。


综上,结合这两种情况,以及现有的接口及能力实现v2.0;


image


推送商品的到手价,消息体格式如下,包括商品id,平台类型,到手价:


[
{"infoId":"16606xxxxxxx174465"
"ptType":"10""
realPrice"
:"638000"}
]

image


首先在Redis维护全量商品,根据商品上架/下架消息,新增或删除队列中的商品;其次将全量商品保存在10000队列中,每个队列只存一部分商品:


queue1=[infoId...]

queue2=[infoId...]

...

queue9999=[infoId...]

右图显示的是每个队列存储的商品数,队列商品数使其能保持在同一个量级。


image


多线程并发计算,每个线程只计算自己队列的商品到手价即可;同时增加监控及告警,查看每个线程的计算耗时,右图可以看到大概在120s内各线程计算完成。


注意事项:




  1. 避免无意义的计算: 将每次变化的红包维护在一个队列中,任务执行时判断是否有红包更新;




  2. 并发问题: 当任务正在执行中时,此时恰巧商品有变化(改价,加入活动等),将此次商品放入补偿队列中,延迟执行;




综上,结合这两种场景可以做到:




  1. 某些场景影响商品的到手价(如改价等),携带了商品infoId,能做到实时计算并立即推送最新的到手价;




  2. 拆分多个商品队列,并发计算; 各司其职,每个线程只计算自己队列商品的到手价;




  3. 降低促销系统压力,接入方只需要监听到手价消息即可。




4.4 v3.0设计


image


可以看到随着商品数的增加,计算耗时也成比例增加。


image


解决办法:




  1. 使用分片,v2.0是将一个大任务,由jvm多线程并发执行各自队列的商品;
    v3.0则是将这个大任务,由多个分片去执行各自队列中的商品,使其分布式执行来提高任务的执行效率和可靠性;




  2. 扩展性及稳定性强,随着商品的增多,可以适当增加分片数,降低计算耗时。




5 总结




  • 系统扩展性 数据量日渐增大,系统要能做升级扩展;




  • 系统稳定性 业务迭代,架构升级,保持系统稳定;




  • 完备的监控告警 及时的监控告警,快速发现问题,解决问题;




  • 演进原则 早期不过度设计,不同时期采用不同架构,持续迭代。







关于作者



熊先泽,转转交易营销技术部研发工程师。代码创造未来,勇于挑战,不断学习,不断成长。



转转研发中心及业界小伙伴们的技术学习交流平台,定期分享一线的实战经验及业界前沿的技术话题。
关注公众号「转转技术」(综合性)、「大转转FE」(专注于FE)、「转转QA」(专注于QA),更多干货实践,欢迎交流分享~


作者:转转技术团队
来源:juejin.cn/post/7240006787947135034

收起阅读 »

分享近期研究的 6 款开源API网关

随着API越来越广泛和规范化,对标准化、安全协议和可扩展性的需求呈指数级增长。随着对微服务的兴趣激增,这一点尤其如此,微服务依赖于API进行通信。API网关通过一个相对容易实现的解决方案来满足这些需求。 也许最重要的是,API网关充当用户和数据之间的中介。AP...
继续阅读 »

随着API越来越广泛和规范化,对标准化、安全协议和可扩展性的需求呈指数级增长。随着对微服务的兴趣激增,这一点尤其如此,微服务依赖于API进行通信。API网关通过一个相对容易实现的解决方案来满足这些需求。


也许最重要的是,API网关充当用户和数据之间的中介。API网关是针对不正确暴露的端点的基本故障保护,而这些端点是黑客最喜欢的目标。考虑到一个被破坏的API在某些情况下可能会产生惊人的灾难性后果,仅此一点就使得API网关值得探索。网关还添加了一个有用的抽象层,这有助于将来验证您的API,防止由于版本控制或后端更改而导致的中断和服务中断。


不幸的是,许多API网关都是专有的,而且价格不便宜!值得庆幸的是,已经有几个开源API网关来满足这一需求。我们已经回顾了六个著名的开源API网关,您可以自行测试,而无需向供应商作出大量承诺。


Kong Gateway (Open Source)


Kong Gateway(OSS)是一个受欢迎的开源API网关,因为它界面流畅、社区活跃、云原生架构和广泛的功能。它速度极快,重量轻。Kong还为许多流行的基于容器和云的环境提供了现成的部署,从Docker到Kubernetes再到AWS。这使您可以轻松地将Kong集成到现有的工作流程中,从而使学习曲线不那么陡峭。


Kong支持日志记录、身份验证、速率限制、故障检测等。更好的是,它有自己的CLI,因此您可以直接从命令行管理Kong并与之交互。您可以在各种发行版上安装开源社区Kong Gateway。基本上,Kong拥有API网关所需的一切。


Tyk Open-Source API Gateway


Tyk被称为“行业最佳API网关”。与我们列表中的其他API网关不同,Tyk确实是开源的,而不仅仅是开放核心或免费增值。它为开源解决方案提供了一系列令人印象深刻的特性和功能。和Kong一样,Tyk也是云原生的,有很多插件可用。Tyk甚至可以用于以REST和GraphQL格式发布自己的API。


Tyk对许多功能都有本机支持,包括各种形式的身份验证、配额、速率限制和版本控制。它甚至可以生成API文档。最令人印象深刻的是,Tyk提供了一个API开发者门户,允许您发布托管API,因此第三方可以注册您的API,甚至管理他们的API密钥。Tyk通过其开源API网关提供了如此多的功能,实在令人难以置信。


KrakenD Open-Source API Gateway


KrakenD的开源API网关是在Go中编写的,它有几个显著的特点,尤其是对微服务的优化。它的可移植性和无状态性是其他强大的卖点,因为它可以在任何地方运行,不需要数据库。由于KrakenDesigner,它比我们列表中的其他一些API网关更灵活、更易于接近,这是一个GUI,它可以让您直观地设计或管理API。您还可以通过简单地编辑JSON文件轻松地编辑API。


KrakenD包括速率限制、过滤、缓存和授权等基本功能,并且提供了比我们提到的其他API网关更多的功能。在不修改源代码的情况下,可以使用许多插件和中间件。它的效率也很高-据维护人员称,KrakenD的吞吐量优于Tyk和Kong的其他API网关。它甚至具有本地GraphQL支持。所有这些,KrakenD的网关非常值得一看。


Gravitee OpenSource API Management


Gravite.io是另一个API网关,它具有一系列令人印象深刻的功能,这次是用Java编写的。Gravitee有三个模块用于发布、监控和记录API:




  • API管理(APIM):APIM是一个开源模块,可以让您完全控制谁访问您的API以及何时何地。




  • 访问管理(AM):Gravite为身份和访问管理提供了一个本地开源授权解决方案。它基于OAuth 2.0/OpenID协议,具有集中的身份验证和授权服务。




  • 警报引擎(AE):警报引擎是一个用于监视API的模块,允许您自定义多渠道通知,以提醒您可疑活动。




Gravitee还具有API设计器Cockpit和命令行界面graviteio-cli。所有这些都使Gravitee成为最广泛的开源API网关之一。您可以在GitHub上查看Gravite.io OpenSource API管理,或直接下载AWS、Docker、Kubernetes、Red Hat,或在此处作为Zip文件。


Apinto Microservice Gateway


显然,Go是编写API网关的流行语言。Apinto API网关用Go编写,旨在管理微服务,并提供API管理所需的所有工具。它支持身份验证、API安全以及流控制。


Apinto支持HTTP转发、多租户管理、访问控制和API访问管理,非常适合微服务或具有多种类型用户的任何开发项目。Apinto还可以通过多功能的用户定义插件系统轻松地为特定用户定制。它还具有API健康检查和仪表板等独特功能。


Apinto Microservice针对性能进行了优化,具有动态路由和负载平衡功能。根据维护人员的说法,Apinto比Nginx或Kong快50%。


Apache APISIX API Gateway


我们将使用世界上最大的开源组织之一Apache软件基金会的一个开源API网关来完善我们的开源API网关列表。Apache APISIX API网关是另一个云原生API网关,具有您目前已经认识到的所有功能——负载平衡、身份验证、速率限制和API安全。然而,有一些特殊的功能,包括多协议支持和Kubernetes入口控制。


关于开源API网关的最后思考


不受限制、不受限制的API访问时代已经结束。随着API使用的广泛普及,有无数理由实现API网关以实现安全性,因为不正确暴露的API端点可能会造成严重损害。API网关可以帮助围绕API设置速率限制,以确保安全使用。而且,如果您向第三方供应商支付高昂的价格,开源选项可能会减少您的每月IT预算。


总之,API网关为您的API环境添加了一个重要的抽象层,这可能是它们最有用的功能。这样的抽象层是防止API端点和用户数据不当暴露的一些最佳方法。然而,几乎同样重要的是它为您的API增加了灵活性。


如果没有抽象层,即使对后端的微小更改也可能导致下游的严重破坏。添加API网关可以使敏捷框架受益,并有助于

作者:CV_coder
来源:juejin.cn/post/7241778027876401213
简化CI/CD管道。

收起阅读 »

图像识别,不必造轮子

闲来无事研究了百度图像识别 API,发现该功能还算强大,在此将其使用方法总结成教程,提供大家学习参考 首先预览下效果 从以上预览图中可看出,每张图片识别出5条数据,每条数据根据识别度从高往下排,每条数据包含物品名称、识别度、所属类目 准备工作 1、注册百度账...
继续阅读 »

闲来无事研究了百度图像识别 API,发现该功能还算强大,在此将其使用方法总结成教程,提供大家学习参考


首先预览下效果


图片


从以上预览图中可看出,每张图片识别出5条数据,每条数据根据识别度从高往下排,每条数据包含物品名称识别度所属类目


准备工作


1、注册百度账号


2、登录百度智能云控制台


3、在产品列表中找到 人工智能->图像识别


4、点击创建应用,如下图:


图片


图片


图片


已创建好的应用列表


代码部分


1、获取access_token值


注意:使用图像识别需用到access_token值,因此需先获取到,以便下面代码的使用


access_token获取的方法有多种,这里使用PHP获取,更多有关access_token获取的方法以及说明可查看官方文档:


ai.baidu.com/docs#/Auth/…


创建一个get_token.php文件,用来获取access_token值


PHP获取access_token代码示例:


<?php

//请求获取access_token值函数
function request_post($url = '', $param = '') {

if (empty($url) || empty($param)) {
return false;
}

$postUrl = $url;
$curlPost = $param;
$curl = curl_init();//初始化curl
curl_setopt($curl, CURLOPT_URL,$postUrl);//抓取指定网页
curl_setopt($curl, CURLOPT_HEADER, 0);//设置header
curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);//要求结果为字符串且输出到屏幕上
curl_setopt($curl, CURLOPT_POST, 1);//post提交方式
curl_setopt($curl, CURLOPT_POSTFIELDS, $curlPost);
curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, false);
$data = curl_exec($curl);//运行curl
curl_close($curl);

return $data;
}

$url = 'https://aip.baidubce.com/oauth/2.0/token'; //固定地址
$post_data['grant_type'] = 'client_credentials'; //固定参数
$post_data['client_id'] = '你的 Api Key'; //创建应用的API Key;
$post_data['client_secret'] = '你的 Secret Key'; //创建应用的Secret Key;
$o = "";
foreach ( $post_data as $k => $v )
{
$o.= "$k=" . urlencode( $v ). "&" ;
}
$post_data = substr($o,0,-1);

$res = request_post($url, $post_data);//调用获取access_token值函数

var_dump($res);

?>

返回的数据如下,红框内的就是我们所要的access_token值


图片


2、图片上传及识别


2.1、在项目的根目录下创建一个upload文件夹,用于存放上传的图片


2.2、创建一个index.html文件,用于上传图片及数据渲染


代码如下:


<!DOCTYPE html>  
<html>
<head>
<meta charset="utf-8"> 
<title>使用百度 API 实现图像识别</title> 
<style type="text/css">
  .spanstyle{
    display:inline-block;
    width:500px;
    height:500px;
    position: relative;
  }
</style>
<script src="https://cdn.bootcss.com/jquery/1.10.2/jquery.min.js"></script>

<script>

  function imageUpload(imgFile) {

    var uploadfile= imgFile.files[0]  //获取图片文件流

    var formData = new FormData();    //创建一个FormData对象

    formData.append('file',uploadfile);
    //将图片放入FormData对象对象中(由于图片属于文件格式,不能直接将文件流直接通过ajax传递到后台,需要放入FormData对象中。在传递)

    $("#loading").css("opacity",1);


     $.ajax({
          type: "POST",       //POST请求
          url: "upload.php",  //接收图片的地址(同目录下的php文件)
          data:formData,      //传递的数据
          dataType:"json",    //声明成功使用json数据类型回调

          //如果传递的是FormData数据类型,那么下来的三个参数是必须的,否则会报错
          cache:false,  //默认是true,但是一般不做缓存
          processData:false, //用于对data参数进行序列化处理,这里必须false;如果是true,就会将FormData转换为String类型
          contentType:false,  //一些文件上传http协议的关系,自行百度,如果上传的有文件,那么只能设置为false

         success: function(msg){  //请求成功后的回调函数


              console.log(msg.result)

              //预览上传的图片
              var filereader = new FileReader();
              filereader.onload = function (event) {
                  var srcpath = event.target.result;
                  $("#loading").css("opacity",0);
                  $("#PreviewImg").attr("src",srcpath);
                };
              filereader.readAsDataURL(uploadfile);


                //将后台返回的数据进行进一步处理
                var data=  '<li style="margin:2% 0"><span>物品名称:'+msg.result[0].keyword+';</span> <span style="padding: 0 2%">识别度:'+msg.result[0].score*100+'%'+';</span><span>所属类目:'+msg.result[0].root+';</span></li>'

                data=data+  '<li style="margin:2% 0"><span>物品名称:'+msg.result[1].keyword+';</span> <span style="padding: 0 2%">识别度:'+msg.result[1].score*100+'%'+';</span><span>所属类目:'+msg.result[1].root+';</span></li>'

                data=data+  '<li style="margin:2% 0"><span>物品名称:'+msg.result[2].keyword+';</span> <span style="padding: 0 2%">识别度:'+msg.result[2].score*100+'%'+';</span><span>所属类目:'+msg.result[2].root+';</span></li>'

                data=data+  '<li style="margin:2% 0"><span>物品名称:'+msg.result[3].keyword+';</span> <span style="padding: 0 2%">识别度:'+msg.result[3].score*100+'%'+';</span><span>所属类目:'+msg.result[3].root+';</span></li>'


                data=data+  '<li style="margin:2% 0"><span>物品名称:'+msg.result[4].keyword+';</span> <span style="padding: 0 2%">识别度:'+msg.result[4].score*100+'%'+';</span><span>所属类目:'+msg.result[4].root+';</span></li>'



                //将识别的数据在页面渲染出来
               $("#content").html(data);


        }
  });


   }



</script>
</head>
<body>

  <fieldset>
     <input type="file"  onchange="imageUpload(this)" >
     <legend>图片上传</legend>
  </fieldset>



<div style="margin-top:2%">
    <span class="spanstyle">
      <img id="PreviewImg" src="default.jpg" style="width:100%;max-height:100%"  >
      <img id="loading" style="width:100px;height:100px;top: 36%;left: 39%;position: absolute;opacity: 0;" src="loading.gif" >
    </span>


    <span class="spanstyle" style="vertical-align: top;border: 1px dashed #ccc;background-color: #4ea8ef;color: white;">
        <h4 style="padding-left:2%">识别结果:</h4>
        <ol style="padding-right: 20px;" id="content">

        </ol>
    </span>

</div>



</body>
</html>

2.3、创建一个upload.php文件,用于接收图片及调用图像识别API


备注:百度图像识别API接口有多种,这里使用的是【通用物体和场景识别高级版】 ;该接口支持识别10万个常见物体及场景,接口返回大类及细分类的名称结果,且支持获取图片识别结果对应的百科信息


该接口调用的方法也有多种,这里使用PHP来调用接口,更多有关通用物体和场景识别高级版调用的方法以及说明可查看官方文档:


ai.baidu.com/docs#/Image…


PHP请求代码示例:


<?php  

        //图像识别请求函数    
        function request_post($url ''$param ''){

            if (empty($url) || empty($param)) {
                return false;
            }

            $postUrl $url;
            $curlPost $param;
            // 初始化curl
            $curl curl_init();
            curl_setopt($curl, CURLOPT_URL, $postUrl);
            curl_setopt($curl, CURLOPT_HEADER, 0);
            // 要求结果为字符串且输出到屏幕上
            curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
            curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, false);
            // post提交方式
            curl_setopt($curl, CURLOPT_POST, 1);
            curl_setopt($curl, CURLOPT_POSTFIELDS, $curlPost);
            // 运行curl
            $data curl_exec($curl);
            curl_close($curl);

            return $data;
        }

        $temp explode("."$_FILES["file"]["name"]);
        $extension end($temp);     // 获取图片文件后缀名


        $_FILES["file"]["name"]=time().'.'.$extension;//图片重命名(以时间戳来命名)

        //将图片文件存在项目根目录下的upload文件夹下
        move_uploaded_file($_FILES["file"]["tmp_name"], "upload/" . $_FILES["file"]["name"]);


        $token '调用鉴权接口获取的token';//将获取的access_token值放进去
        $url 'https://aip.baidubce.com/rest/2.0/image-classify/v2/advanced_general?access_token=' . $token;
        $img file_get_contents("upload/" . $_FILES["file"]["name"]);//本地文件路径(存入后的图片文件路径)
        $img base64_encode($img);//文件进行base64编码加密

        //请求所需要的参数
        $bodys array(
            'image' => $img,//Base64编码字符串
            'baike_num'=>5  //返回百科信息的结果数 5条
        );
        $res request_post($url$bodys);//调用请求函数

        echo $res;  //将识别的数据输出到前端


?>

结语补充


在实际开发过程中,获取access_token值并不是单独写成一个页面文件,而是写在项目系统的配置中;由于access_token值有效期为30天,可通过判断是否失效,来重新请求acc

作者:程序员Winn
来源:juejin.cn/post/7241874482574770235
ess_token值

收起阅读 »

什么是优雅的代码设计

今天我来解释一下什么样的代码才是优雅的代码设计。当然我们的代码根据实际的应用场景也分了很多维度,有偏向于底层系统的,有偏向于中间件的,也有偏向上层业务的,还有偏向于前端展示的。今天我主要来跟大家分析一下我对于业务代码的理解以及什么样才是我认为的优雅的业务代码设...
继续阅读 »

今天我来解释一下什么样的代码才是优雅的代码设计。当然我们的代码根据实际的应用场景也分了很多维度,有偏向于底层系统的,有偏向于中间件的,也有偏向上层业务的,还有偏向于前端展示的。今天我主要来跟大家分析一下我对于业务代码的理解以及什么样才是我认为的优雅的业务代码设计。


大家吐槽非常多的是,我们这边的业务代码会存在着大量的不断地持续的变化,导致我们的程序员对于业务代码设计得就比较随意。往往为了快速上线随意堆叠,不加深入思考,或者是怕影响到原来的流程,而不断在原来的代码上增加分支流程。


这种思想进一步使得代码腐化,使得大量的程序员更失去了“好代码”的标准。


那么如果代码优雅,那么要有哪些特征呢?或者说我们做哪些事情才会使得代码变得更加优雅呢?


结构化


结构化定义是指对某一概念或事物进行系统化、规范化的分析和定义,包括定义的范围、对象的属性、关系等方面,旨在准确地描述和定义所要表达的概念或事物。



我觉得首要的是代码,要一个骨架。就跟我们所说的思维结构是一样,我们对一个事物的判断,一般都是综合、立体和全面的,否则就会成为了盲人摸象,只见一斑。因此对于一个事物的判断,要综合、结构和全面。对于一段代码来说也是一样的标准,首先就是结构化。结构化是对一段代码最基本的要求,一个有良好结构的代码才可能称得上是好代码,如果只是想到哪里就写到哪里,一定成不了最优质的代码。


代码的结构化,能够让维护的人一眼就能看出主次结构、看出分层结构,能够快速掌握一段代码或者一段模块要完成的核心事情。


精简



代码跟我们抽象现实的物体一样,也要非常地精简。其实精简我觉得不仅在代码,在所有艺术品里面都是一样的,包括电影。电影虽然可能长达一个小时,两个小时,但你会发现优雅的电影它没有一帧是多余的,每出现的一个画面、一个细节,都是电影里要表达的某个情绪有关联。我们所说的文章也是一样,没有任何一个伏笔是多余的。代码也是一样,严格来说代码没有一个字符、函数、变量是多余的,每个代码都有它应该有的用处。就跟“奥卡姆剃刀”原理一样,每块代码都有它存在的价值包括注释。


但正如我们的创作一样,要完成一个功能,我们把代码写得复杂是简单的,但我们把它写得简单是非常难的。代码是思维结构的一种体现,而往往抽象能力是最为关键的,也是最难的。合适的抽象以及合理的抽象才能够让代码浓缩到最少的代码函数。


大部分情况来说,代码行数越少,则运行效率会越高。当然也不要成为极端的反面例子,不要一味追求极度少量的代码。代码的优雅一定是精要的,该有的有,不该有的一定是没有的。所以在完成一个业务逻辑的时候,一定要多问自己这个代码是不是必须有的,能不能以一种简要的方式来表达。


善用最佳实践


俗话说太阳底下没有新鲜事儿,一般来说,没有一个业务场景所需要用到的编码方式是需要你独创发明的。你所写的代码功能大概率都有人遇到过,因此对于大部分常用的编码模式,也都大家被抽象出来了一些最佳实践。那么最经典的就是23种设计模式,基本上可以涵盖90%以上的业务场景了。


以下是23种设计模式的部分简单介绍:

  1. 单例模式(Singleton Pattern):确保类只有一个实例,并提供全局访问点。
  2. 工厂模式(Factory Pattern):定义一个用于创建对象的接口,并让子类决定实例化哪个对象。
  3. 模板方法模式(Template Method Pattern):提供一种动态的创建对象的方法,通过使用不同的模板来创建对象。
  4. 装饰器模式(Decorator Pattern):将对象包装成另一个对象,从而改变原有对象的行为。
  5. 适配器模式(Adapter Pattern):将一个类的接口转换成客户希望的另一个接口,以使其能够与不同的对象交互。
  6. 外观模式(Facade Pattern):将对象的不同方面组合成一个单一的接口,从而使客户端只需访问该接口即可使用整个对象。

我们所说的设计模式就是一种对常用代码结构的一种抽象或者说套路。并不是说我们一定要用设计模式来实现功能,而是说我们要有一种最高效,最通常的方式去实现。这种方式带来了好处就是高效,而且别人理解起来也相对来说比较容易。


我们也不大推荐对于一些常见功能用一些花里胡哨的方式来实现,这样往往可能导致过度设计,但实际用处可能反而会带来其他问题。我觉得要用一些新型的代码,新型的思维方式应该是在一些比较新的场景里面去使用,去验证,而不应该在我们已有最佳实践的方式上去造额外的轮子。


这个就比如我们如果要设计一辆汽车,我们应该采用当前最新最成熟的发动机方案,而不应该从零开始自己再造一套新的发动机。但是如果这个发动机是在土星使用,要面对极端的环境,可能就需要基于当前的方案研制一套全新的发动机系统,但是大部分人是没有机会碰到土星这种业务环境的。所以通常情况下,还是不要在不需要创新的地方去创新。


除了善用最佳实践模式之快,我们还应该采用更高层的一些最佳实践框架的解决方案。比如我们在面对非常抽象,非常灵活变动的一些规则的管理上,我们可以使用大量的规则引擎工具。比如针对于流程式的业务模型上面,我们可以引入一些工作流的引擎。在需要RPC框架的时候,我们可以根据业务情况去调研使用HTTP还是DUBBO,可以集百家之所长。


持续重构



好代码往往不是一蹴而就的,而是需要我们持续打磨。有很多时候由于业务的变化以及我们思维的局限性,我们没有办法一次性就能够设计出最优的代码质量,往往需要我们后续持续的优化。所以除了初始化的设计以外,我们还应该在业务持续的发展过程中动态地去对代码进行重构。


但是往往程序员由于业务繁忙或者自身的懒惰,在业务代码上线正常运行后,就打死不愿意再动原来的代码。第一个是觉得跑得没有问题了何必去改,第二个就是改动了反而可能引起故障。这就是一种完全错误的思维,一来是给自己写不好的线上代码的一个借口,二来是没有让自己持续进步的机会。


代码重构的原则有很多,这里我就不再细讲。但是始终我觉得对线上第一个要敬畏,第二个也要花时间持续续治理。往往我们在很多时候初始化的架构是比较优雅的,是经过充分设计的,但是也是由于业务发展的迭代的原因,我们持续在存量代码上添加新功能。


有时候有一些不同的同学水平不一样,能力也不一样,所以导致后面写上的代码会非常地随意,导致整个系统就会变得越来越累赘,到了最后就不敢有新同学上去改,或者是稍微一改可能就引起未知的故障。


所以在这种情况下,如果还在追求优质的代码,就需要持续不断地重构。重构需要持续改善,并且最好每次借业务变更时,做小幅度的修改以降低风险。长此以往,整体的代码结构就得以大幅度的修改,真正达到集腋成裘的目的。下面是一些常见的重构原则:

  1. 单一职责原则:每个类或模块应该只负责一个单一的任务。这有助于降低代码的复杂度和维护成本。
  2. 开闭原则:软件实体(类、模块等)应该对扩展开放,对修改关闭。这样可以保证代码的灵活性和可维护性。
  3. 里氏替换原则:任何基类都可以被其子类替换。这可以减少代码的耦合度,提高代码的可扩展性。
  4. 接口隔离原则:不同的接口应该是相互独立的,它们只依赖于自己需要的实现,而不是其他接口。
  5. 依赖倒置原则:高层模块不应该依赖低层模块,而是依赖应用程序的功能。这可以降低代码的复杂度和耦合度。
  6. 高内聚低耦合原则:尽可能使模块内部的耦合度低,而模块之间的耦合度高。这可以提高代码的可维护性和可扩展性。
  7. 抽象工厂原则:使用抽象工厂来创建对象,这样可以减少代码的复杂度和耦合度。
  8. 单一视图原则:每个页面只应该有一个视图,这可以提高代码的可读性和可维护性。
  9. 依赖追踪原则:对代码中的所有依赖关系进行跟踪,并在必要时进行修复或重构。
  10. 测试驱动开发原则:在编写代码之前编写测试用例,并在开发过程中持续编写和运行测试用例,以确保代码的质量和稳定性。

综合


综上所述,代码要有结构化、可扩展、用最佳实践和持续重构。追求卓越的优质代码应该是每一位工程师的基本追求和基本要求,只有这样,才能不断地使得自己成为一名卓越的工程师。



作者:ali老蒋
来源:juejin.cn/post/7241115614102863928

收起阅读 »

初学后端,如何做好表结构设计?

前言 最近有不少前端和测试转Go的朋友在私信我:如何做好表结构设计? 大家关心的问题阳哥必须整理出来,希望对大家有帮助。 先说结论 这篇文章介绍了设计数据库表结构应该考虑的4个方面,还有优雅设计的6个原则,举了一个例子分享了我的设计思路,为了提高性能我们也要从...
继续阅读 »

前言


最近有不少前端和测试转Go的朋友在私信我:如何做好表结构设计?


大家关心的问题阳哥必须整理出来,希望对大家有帮助。


先说结论


这篇文章介绍了设计数据库表结构应该考虑的4个方面,还有优雅设计的6个原则,举了一个例子分享了我的设计思路,为了提高性能我们也要从多方面考虑缓存问题。


收获最大的还是和大家的交流讨论,总结一下:

  1. 首先,一定要先搞清楚业务需求。比如我的例子中,如果不需要灵活设置,完全可以写到配置文件中,并不需要单独设计外键。主表中直接保存各种筛选标签名称(注意维护的问题,要考虑到数据一致性)
  2. 数据库表结构设计一定考虑数据量和并发量,我的例子中如果数据量小,可以适当做冗余设计,降低业务复杂度。

4个方面


设计数据库表结构需要考虑到以下4个方面:

  1. 数据库范式:通常情况下,我们希望表的数据符合某种范式,这可以保证数据的完整性和一致性。例如,第一范式要求表的每个属性都是原子性的,第二范式要求每个非主键属性完全依赖于主键,第三范式要求每个非主键属性不依赖于其他非主键属性。
  2. 实体关系模型(ER模型):我们需要先根据实际情况画出实体关系模型,然后再将其转化为数据库表结构。实体关系模型通常包括实体、属性、关系等要素,我们需要将它们转化为表的形式。
  3. 数据库性能:我们需要考虑到数据库的性能问题,包括表的大小、索引的使用、查询语句的优化等。
  4. 数据库安全:我们需要考虑到数据库的安全问题,包括表的权限、用户角色的设置等。

设计原则


在设计数据库表结构时,可以参考以下几个优雅的设计原则:

  1. 简单明了:表结构应该简单明了,避免过度复杂化。
  2. 一致性:表结构应该保持一致性,例如命名规范、数据类型等。
  3. 规范化:尽可能将表规范化,避免数据冗余和不一致性。
  4. 性能:表结构应该考虑到性能问题,例如使用适当的索引、避免全表扫描等。
  5. 安全:表结构应该考虑到安全问题,例如合理设置权限、避免SQL注入等。
  6. 扩展性:表结构应该具有一定的扩展性,例如预留字段、可扩展的关系等。

最后,需要提醒的是,优雅的数据库表结构需要在实践中不断迭代和优化,不断满足实际需求和新的挑战。



下面举个示例让大家更好的理解如何设计表结构,如何引入内存,有哪些优化思路:



问题描述



如上图所示,红框中的视频筛选标签,应该怎么设计数据库表结构?除了前台筛选,还想支持在管理后台灵活配置这些筛选标签。


这是一个很好的应用场景,大家可以先自己想一下。不要着急看我的方案。


需求分析

  1. 可以根据红框的标签筛选视频
  2. 其中综合标签比较特殊,和类型、地区、年份、演员等不一样
  • 综合是根据业务逻辑取值,并不需要入库
  • 类型、地区、年份、演员等需要入库

3.设计表结构时要考虑到:

  • 方便获取标签信息,方便把标签信息缓存处理
  • 方便根据标签筛选视频,方便我们写后续的业务逻辑

设计思路

  1. 综合标签可以写到配置文件中(或者写在前端),这些信息不需要灵活配置,所以不需要保存到数据库中
  2. 类型、地区、年份、演员都设计单独的表
  3. 视频表中设计标签表的外键,方便视频列表筛选取值
  4. 标签信息写入缓存,提高接口响应速度
  5. 类型、地区、年份、演员表也要支持对数据排序,方便后期管理维护

表结构设计


视频表


字段注释
id视频主键id
type_id类型表外键id
area_id地区表外键id
year_id年份外键id
actor_id演员外键id

其他和视频直接相关的字段(比如名称)我就省略不写了


类型表


字段注释
id类型主键id
name类型名称
sort排序字段

地区表


字段注释
id类型主键id
name类型名称
sort排序字段

年份表


字段注释
id类型主键id
name类型名称
sort排序字段

原以为年份字段不需要排序,要么是年份正序排列,要么是年份倒序排列,所以不需要sort字段。


仔细看了看需求,还有“10年代”还是需要灵活配置的呀~


演员表


字段注释
id类型主键id
name类型名称
sort排序字段

表结构设计完了,别忘了缓存


缓存策略


首先这些不会频繁更新的筛选条件建议使用缓存:


  1. 比较常用的就是redis缓存
  2. 再进阶一点,如果你使用docker,可以把这些配置信息写入docker容器所在物理机的内存中,而不用请求其他节点的redis,进一步降低网络传输带来的耗时损耗
  3. 筛选条件这类配置信息,客户端和服务端可以约定一个更新缓存的机制,客户端直接缓存配置信息,进一步提高性能

列表数据自动缓存


目前很多框架都是支持自动缓存处理的,比如goframe和go-zero


goframe


可以使用ORM链式操作-查询缓存


示例代码:


package main

import (
"time"

"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gctx"
)

func main() {
var (
db = g.DB()
ctx = gctx.New()
)

// 开启调试模式,以便于记录所有执行的SQL
db.SetDebug(true)

// 写入测试数据
_, err := g.Model("user").Ctx(ctx).Data(g.Map{
"name": "xxx",
"site": "https://xxx.org",
}).Insert()

// 执行2次查询并将查询结果缓存1小时,并可执行缓存名称(可选)
for i := 0; i < 2; i++ {
r, _ := g.Model("user").Ctx(ctx).Cache(gdb.CacheOption{
Duration: time.Hour,
Name: "vip-user",
Force: false,
}).Where("uid", 1).One()
g.Log().Debug(ctx, r.Map())
}

// 执行更新操作,并清理指定名称的查询缓存
_, err = g.Model("user").Ctx(ctx).Cache(gdb.CacheOption{
Duration: -1,
Name: "vip-user",
Force: false,
}).Data(gdb.Map{"name": "smith"}).Where("uid", 1).Update()
if err != nil {
g.Log().Fatal(ctx, err)
}

// 再次执行查询,启用查询缓存特性
r, _ := g.Model("user").Ctx(ctx).Cache(gdb.CacheOption{
Duration: time.Hour,
Name: "vip-user",
Force: false,
}).Where("uid", 1).One()
g.Log().Debug(ctx, r.Map())
}

go-zero


官方都做了详细的介绍,不作为本文的重点。


讨论


我的方案也在我的技术交流群里引起了大家的讨论,也和大家分享一下:


Q1 冗余设计和一致性问题



提问: 一个表里做了这么多外键,如果我要查各自的名称,势必要关联4张表,对于这种存在多外键关联的这种表,要不要做冗余呢(直接在主表里冗余各自的名称字段)?要是保证一致性的话,就势必会影响性能,如果做冗余的话,又无法保证一致性



回答:


你看文章的上下文应该知道,文章想解决的是视频列表筛选问题。


你提到的这个场景是在视频详情信息中,如果要展示这些外键的名称怎么设计更好。


我的建议是这样的:

  1. 根据需求可以做适当冗余,比如你的主表信息量不大,配置信息修改后同步修改冗余字段的成本并不高。
  2. 或者像我文章中写的不做冗余设计,但是会把外键信息缓存,业务查询从缓存中取值。
  3. 或者将视频详情的查询结果整体进行缓存

还是看具体需求,如果这些筛选信息不变化或者不需要手工管理,甚至不需要设计表,直接写死在代码的配置文件中也可以。进一步降低DB压力,提高性能。


Q2 why设计外键?



提问:为什么要设计外键关联?直接写到视频表中不就行了?这么设计的意义在哪里?



回答:

  1. 关键问题是想解决管理后台灵活配置
  2. 如果没有这个需求,我们可以直接把筛选条件以配置文件的方式写死在程序中,降低复杂度。
  3. 站在我的角度:这个功能的筛选条件变化并不会很大,所以很懂你的意思。也建议像我2.中的方案去做,去和产品经理拉扯喽~

总结


这篇文章介绍了设计数据库表结构应该考虑的4个方面,还有优雅设计的6个原则,举了一个例子分享了我的设计思路,为了提高性能我们也要从多方面考虑缓存问题。


收获最大的还是和大家的交流讨论,总结一下:

  1. 首先,一定要先搞清楚业务需求。比如我的例子中,如果不需要灵活设置,完全可以写到配置文件中,并不需要单独设计外键。主表中直接保存各种筛选标签名称(注意维护的问题,要考虑到数据一致性)
  2. 数据库表结构设计一定考虑数据量和并发量,我的例子中如果数据量小,可以适当做冗余设计,降低业务复杂度

作者:王中阳Go
来源:juejin.cn/post/7212828749128876092


收起阅读 »

Springboot如何优雅的进行数据校验

基于 Spring Boot ,如何“优雅”的进行数据校验呢? 引入依赖 首先只需要给项目添加上 spring-boot-starter-web 依赖就够了,它的子依赖包含了我们所需要的东西。 注意: Spring Boot 2.3 1 之后,spring-...
继续阅读 »

基于 Spring Boot ,如何“优雅”的进行数据校验呢?


引入依赖


首先只需要给项目添加上 spring-boot-starter-web 依赖就够了,它的子依赖包含了我们所需要的东西。


image.png


注意:
Spring Boot 2.3 1 之后,spring-boot-starter-validation 已经不包括在了 spring-boot-starter-web 中,需要我们手动加上!


<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>

验证 Controller 的输入


一定一定不要忘记在类上加上 @ Validated 注解了,这个参数可以告诉 Spring 去校验方法参数。


验证请求体


验证请求体即使验证被 @RequestBody 注解标记的方法参数。


PersonController


我们在需要验证的参数上加上了@Valid注解,如果验证失败,它将抛出MethodArgumentNotValidException。默认情况下,Spring 会将此异常转换为 HTTP Status 400(错误请求)。


@RestController
@RequestMapping("/api/person")
@Validated
public class PersonController {

@PostMapping
public ResponseEntity<PersonRequest> save(@RequestBody @Valid PersonRequest personRequest) {
return ResponseEntity.ok().body(personRequest);
}
}

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class PersonRequest {

@NotNull(message = "classId 不能为空")
private String classId;

@Size(max = 33)
@NotNull(message = "name 不能为空")
private String name;

@Pattern(regexp = "(^Man$|^Woman$|^UGM$)", message = "sex 值不在可选范围")
@NotNull(message = "sex 不能为空")
private String sex;

}

使用 Postman 验证


image.png


验证请求参数


验证请求参数(Path Variables 和 Request Parameters)即是验证被 @PathVariable 以及 @RequestParam 标记的方法参数。


PersonController


@RestController
@RequestMapping("/api/persons")
@Validated
public class PersonController {

@GetMapping("/{id}")
public ResponseEntity<Integer> getPersonByID(@Valid @PathVariable("id") @Max(value = 5, message = "超过 id 的范围了") Integer id) {
return ResponseEntity.ok().body(id);
}

@PutMapping
public ResponseEntity<String> getPersonByName(@Valid @RequestParam("name") @Size(max = 6, message = "超过 name 的范围了") String name) {
return ResponseEntity.ok().body(name);
}
}

使用 Postman 验证


image.png


image.png


嵌套校验


在一个校验A对象里另一个B对象里的参数


需要在B对象上加上@Valid注解


image.png


image.png


常用校验注解总结


JSR303 定义了 Bean Validation(校验)的标准 validation-api,并没有提供实现。Hibernate Validation是对这个规范/规范的实现 hibernate-validator,并且增加了 @Email、@Length、@Range 等注解。Spring Validation 底层依赖的就是Hibernate Validation。


JSR 提供的校验注解:



  • @Null 被注释的元素必须为 null

  • @NotNull 被注释的元素必须不为 null

  • @AssertTrue 被注释的元素必须为 true

  • @AssertFalse 被注释的元素必须为 false

  • @Min(value) 被注释的元素必须是一个数字,其值必须大于等于指定的最小值

  • @Max(value) 被注释的元素必须是一个数字,其值必须小于等于指定的最大值

  • @DecimalMin(value) 被注释的元素必须是一个数字,其值必须大于等于指定的最小值

  • @DecimalMax(value) 被注释的元素必须是一个数字,其值必须小于等于指定的最大值

  • @Size(max=, min=) 被注释的元素的大小必须在指定的范围内

  • @Digits (integer, fraction) 被注释的元素必须是一个数字,其值必须在可接受的范围内

  • @Past 被注释的元素必须是一个过去的日期

  • @Future 被注释的元素必须是一个将来的日期

  • @Pattern(regex=,flag=) 被注释的元素必须符合指定的正则表达式


Hibernate Validator 提供的校验注解



  • @NotBlank(message =) 验证字符串非 null,且长度必须大于 0

  • @Email 被注释的元素必须是电子邮箱地址

  • @Length(min=,max=) 被注释的字符串的大小必须在指定的范围内

  • @NotEmpty 被注释的字符串的必须非空

  • @Range(min=,max=,message=) 被注释的元素必须在合适的范围内


image.png


@JsonFormat与@DateTimeFormat注解的使用


@JsonFormat用于后端传给前端的时间格式转换,@DateTimeFormat用于前端传给后端的时间格式转换


JsonFormat


1、使用maven引入@JsonFormat所需要的jar包


        <dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
<version>2.8.8</version>
</dependency>

<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.8.8</version>
</dependency>

<dependency>
<groupId>org.codehaus.jackson</groupId>
<artifactId>jackson-mapper-asl</artifactId>
<version>1.9.13</version>
</dependency>

2、在需要查询时间的数据库字段对应的实体类的属性上添加@JsonFormat


   @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateDate;

注: timezone:是时间设置为东八区,避免时间在转换中有误差,pattern:是时间转换格式


DataTimeFormat


1、添加依赖


       <dependency>
<groupId>joda-time</groupId>
<artifactId>joda-time</artifactId>
<version>2.3</version>
</dependency>

2、我们在对应的接收前台数据的对象的属性上加@DateTimeFormat


@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime acquireDate;

3.这样我们就可以将前端获取的时间转换为一个符合自定义格式的时间格式存储到数据库了
全局异常统一处理:拦截并处理校验出错的返回数据
写一个全局异常处理类


@ControllerAdvice

public class GlobalExceptionHandler{
/**
* 处理参数校验异常
*/

@ExceptionHandler({MethodArgumentNotValidException.class})
@ResponseBody
public ErrorResponseData validateException(MethodArgumentNotValidException e) {
log.error("参数异常"+e.getBindingResult().getFieldError().getDefaultMessage(),e);
return new ErrorResponseData(10001,e.getBindingResult().getFieldError().getDefaultMessage());
}

/**
* 处理json转换异常(比如 @DateTimeFormat注解转换日期格式时)
*/

@ExceptionHandler({HttpMessageNotReadableException.class})
@ResponseBody
public ErrorResponseData jsonParseException(HttpMessageNotReadableException e) {
log.error("参数异常"+e.getLocalizedMessage(),e);
return new ErrorResponseData(10001,e.getCause().getMessage());
}

}

作者:Hypnosis
来源:juejin.cn/post/7241114001324228663
收起阅读 »

慢慢的喜欢上泛型 之前确实冷落了

前言 下图 CSDN 水印 为自身博客 什么泛型 通俗意义上来说泛型将接口的概念进一步延伸,”泛型”字面意思就是广泛的类型,类、接口和方法代码可以应用于非常广泛的类型,代码与它们能够操作的数据类型不再绑定在一起,同一套代码,可以用于多种数据类型,这样,不仅可...
继续阅读 »

前言


下图 CSDN 水印 为自身博客


什么泛型



通俗意义上来说泛型将接口的概念进一步延伸,”泛型”字面意思就是广泛的类型,类、接口和方法代码可以应用于非常广泛的类型,代码与它们能够操作的数据类型不再绑定在一起,同一套代码,可以用于多种数据类型,这样,不仅可以复用代码,降低耦合,同时,还可以提高代码的可读性和安全性。



泛型带来的好处



在没有泛型的情况的下,通过对类型 Object 的引用来实现参数的“任意化”,“任意化”带来的缺点是要做显式的强制类型转换,而这种转换是要求开发者对实际参数类型可以预知的情况下进行的。对于强制类型转换错误的情况,编译器可能不提示错误,在运行的时候才出现异常,这是本身就是一个安全隐患。
那么泛型的好处就是在编译的时候能够检查类型安全,并且所有的强制转换都是自动和隐式的



public class GlmapperGeneric<T> {
private T t;
public void set(T t) { this.t = t; }
public T get() { return t; }

public static void main(String[] args) {
// do nothing
}

/**
* 不指定类型
*/

public void noSpecifyType(){
GlmapperGeneric glmapperGeneric = new GlmapperGeneric();
glmapperGeneric.set("test");
// 需要强制类型转换
String test = (String) glmapperGeneric.get();
System.out.println(test);
}

/**
* 指定类型
*/

public void specifyType(){
GlmapperGeneric<String> glmapperGeneric = new GlmapperGeneric();
glmapperGeneric.set("test");
// 不需要强制类型转换
String test = glmapperGeneric.get();
System.out.println(test);
}
}



上面这段代码中的 specifyType 方法中 省去了强制转换,可以在编译时候检查类型安全,可以用在类,方法,接口上。



泛型中通配符



我们在定义泛型类,泛型方法,泛型接口的时候经常会碰见很多不同的通配符,比如 T,E,K,V 等等,这些通配符又都是什么意思呢?



常用的 T,E,K,V,?



本质上这些个都是通配符,没啥区别,只不过是编码时的一种约定俗成的东西。比如上述代码中的 T ,我们可以换成 A-Z 之间的任何一个 字母都可以,并不会影响程序的正常运行,但是如果换成其他的字母代替 T ,在可读性上可能会弱一些。通常情况下,T,E,K,V,?是这样约定的:




  • ?表示不确定的 java 类型

  • T (type) 表示具体的一个java类型

  • K V (key value) 分别代表java键值中的Key Value

  • E (element) 代表Element

  • < T > 等同于 < T extends Object>

  • < ? > 等同于 < ? extends Object>


?无界通配符



先从一个小例子看起:



// 范围较广
static int countLegs (List<? extends Animal > animals ) {
int retVal = 0;
for ( Animal animal : animals )
{
retVal += animal.countLegs();
}
return retVal;
}
// 范围定死
static int countLegs1 (List< Animal > animals ){
int retVal = 0;
for ( Animal animal : animals )
{
retVal += animal.countLegs();
}
return retVal;
}

public static void main(String[] args) {
List<Dog> dogs = new ArrayList<>();
// 不会报错
countLegs( dogs );
// 报错
countLegs1(dogs);
}



对于不确定或者不关心实际要操作的类型,可以使用无限制通配符(尖括号里一个问号,即 <?> ),表示可以持有任何类型。像 countLegs 方法中,限定了上届,但是不关心具体类型是什么,所以对于传入的 Animal 的所有子类都可以支持,并且不会报错。而 countLegs1 就不行。



上界通配符 < ? extends E>



上届:用 extends 关键字声明,表示参数化的类型可能是所指定的类型,或者是此类型的子类。
在类型参数中使用 extends 表示这个泛型中的参数必须是 E 或者 E 的子类,这样有两个好处:



1.如果传入的类型不是 E 或者 E 的子类,编译不成功
2. 泛型中可以使用 E 的方法,要不然还得强转成 E 才能使用


List<? extends Number> eList = null;
eList = new ArrayList<Integer>();
//语句1取出Number(或者Number子类)对象直接赋值给Number类型的变量是符合java规范的。
Number numObject = eList.get(0); //语句1,正确

//语句2取出Number(或者Number子类)对象直接赋值给Integer类型(Number子类)的变量是不符合java规范的。
Integer intObject = eList.get(0); //语句2,错误

//List<? extends Number>eList不能够确定实例化对象的具体类型,因此无法add具体对象至列表中,可能的实例化对象如下。
eList.add(new Integer(1)); //语句3,错误

下界通配符 < ? super E>



下界: 用 super 进行声明,表示参数化的类型可能是所指定的类型,或者是此类型的父类型,直至 Object



在类型参数中使用 super 表示这个泛型中的参数必须是 E 或者 E 的父类。


List<? super Integer> sList = null;
sList = new ArrayList<Number>();

//List<? super Integer> 无法确定sList中存放的对象的具体类型,因此sList.get获取的值存在不确定性
//,子类对象的引用无法赋值给兄弟类的引用,父类对象的引用无法赋值给子类的引用,因此语句错误
Number numObj = sList.get(0); //语句1,错误

//Type mismatch: cannot convert from capture#6-of ? super Integer to Integer
Integer intObj = sList.get(0); //语句2,错误
//子类对象的引用可以赋值给父类对象的引用,因此语句正确。
sList.add(new Integer(1)); //语句3,正确

1. 限定通配符总是包括自己
2. 上界类型通配符:add方法受限
3. 下界类型通配符:get方法受限
4. 如果你想从一个数据类型里获取数据,使用 ? extends 通配符
5. 如果你想把对象写入一个数据结构里,
6. 使用 ? super 通配符 如果你既想存,又想取,那就别用通配符
7. 不能同时声明泛型通配符上界和下界


?和 T 的区别


// 指定集合元素只能是T类型
List<T> list = new ArrayList<T>();
// 集合元素可以是任意类型的,这种是 没有意义的 一般是方法中只是为了说明用法
List<?> list = new Arraylist<?>();


?和 T 都表示不确定的类型,区别在于我们可以对 T 进行操作,但是对 ?不行,比如如下这种 :



// 可以
T t = operate();

// 不可以
?car = operate();


T 是一个 确定的 类型,通常用于泛型类和泛型方法的定义,?是一个 不确定 的类型,通常用于泛型方法的调用代码和形参,不能用于定义类和泛型方法。



区别1 通过 T 来 确保 泛型参数的一致性


   public <T extends Number> void test1(List<T> dest, List<T> src) {
System.out.println();
}

public static void main(String[] args) {
test test = new test();
// integer 是number 的子类 所以是正确的
List<Integer> list = new ArrayList<Integer>();
List<Integer> list1 = new ArrayList<Integer>();
test.test1(list,list1);
}

在这里插入图片描述



通配符是 不确定的,所以这个方法不能保证两个 List 具有相同的元素类型



public void
test(List<? extends Number> dest, List<? extends Number> src)

GlmapperGeneric<String> glmapperGeneric = new GlmapperGeneric<>();
List<String> dest = new ArrayList<>();
List<Number> src = new ArrayList<>();
glmapperGeneric.testNon(dest,src);
//上面的代码在编译器并不会报错,但是当进入到 testNon 方法内部操作时(比如赋值),对于 dest 和 src 而言,就还是需要进行类型转换

区别2:类型参数可以多重限定而通配符不行



使用 & 符号设定多重边界(Multi Bounds),指定泛型类型 T 必须是 MultiLimitInterfaceA 和 MultiLimitInterfaceB 的共有子类型,此时变量 t 就具有了所有限定的方法和属性。对于通配符来说,因为它不是一个确定的类型,所以不能进行多重限定



区别3:通配符可以使用超类限定而类型参数不行



类型参数 T 只具有 一种 类型限定方式



T extends A



但是通配符 ? 可以进行 两种限定



? extends A
? super A

Class和 Class<?>区别


Class<"T"> (默认没有双引号 系统会自动把T给我换成特殊字符才加的引号) 在实例化的时候,T 要替换成具体类。Class<?>它是个通配泛型,? 可以代表任何类型,所以主要用于声明时的限制情况


作者:进阶的派大星
来源:juejin.cn/post/7140472064577634341
收起阅读 »

都JDK17了,你还在用JDK8

Spring Boot 3.1.0-M1 已经发布一段时间了,不知道各位小伙伴是否关注了。随着Spring 6.0以及SpringBoot 3.0的发布,整个开发界也逐步进入到jdk17的时代。大有当年从jdk6 到jdk8升级过程,痛苦并快乐着。 为了不被时...
继续阅读 »

Spring Boot 3.1.0-M1 已经发布一段时间了,不知道各位小伙伴是否关注了。随着Spring 6.0以及SpringBoot 3.0的发布,整个开发界也逐步进入到jdk17的时代。大有当年从jdk6 到jdk8升级过程,痛苦并快乐着。


为了不被时代抛弃,开发者应追逐新的技术发展,拥抱变化,不要固步自封。


0x01 纵观发展




  • Pre-alpha(Dev)指在软件项目进行正式测试之前执行的所有活动




  • LTS(Long-Term Support)版本指的是长期支持版本




  • Alpha 软件发布生命周期的alpha阶段是软件测试的第一阶段




  • Beta阶段是紧随alpha阶段之后的软件开发阶段,以希腊字母第二个字母命名




  • Release candidate 发行候选版(RC),也被称为“银色版本”,是具备成为稳定产品的潜力的 beta 版本,除非出现重大错误,否则准备好发布




  • Stable release 稳定版又称为生产版本,是通过所有验证和测试阶段的最后一个发行候选版(RC)




  • Release 一旦发布,软件通常被称为“稳定版”




下面我们来看下JDK9~JDK17的发展:


版本发布时间版本类型支持时间新特性
JDK 92017年9月长期支持版(LTS)5年- 模块化系统(Jigsaw)
- JShell
- 接口的私有方法
- 改进的 try-with-resources
- 集合工厂方法
- 改进的 Stream API
JDK 102018年3月短期支持版(non-LTS)6个月- 局部变量类型推断
- G1 垃圾回收器并行全阶段
- 应用级别的 Java 类数据共享
JDK 112018年9月长期支持版(LTS)8年- HTTP 客户端 API
- ZGC 垃圾回收器
- 移除 Java EE 和 CORBA 模块
JDK 122019年3月短期支持版(non-LTS)6个月- switch 表达式
- JVM 原生 HTTP 客户端
- 微基准测试套件
JDK 132019年9月短期支持版(non-LTS)6个月- switch 表达式增强
- 文本块
- ZGC 垃圾回收器增强
JDK 142020年3月短期支持版(non-LTS)6个月- switch 表达式增强
- 记录类型
- Pattern Matching for instanceof
JDK 152020年9月短期支持版(non-LTS)6个月- 移除 Nashorn JavaScript 引擎
- ZGC 垃圾回收器增强
- 隐藏类和动态类文件
JDK 162021年3月短期支持版(non-LTS)6个月- 位操作符增强
- Records 类型的完整性
- Vector API
JDK 172021年9月长期支持版(LTS)8年- 垃圾回收器改进
- Sealed 类和接口
- kafka客户端更新
- 全新的安全存储机制

需要注意的是,LTS 版本的支持时间可能会受到 Oracle 官方政策变化的影响,因此表格中的支持时间仅供参考。


0x02 详细解读


JDK9 新特性


JDK 9 是 Java 平台的一个重大版本,于2017年9月发布。它引入了多项新特性,其中最重要的是模块化系统。以下是 JDK 9 新增内容的详细解释:



  1. 模块化系统(Jigsaw):


Jigsaw 是 JDK 9 引入的一个模块化系统,它将 JDK 拆分为约 90 个模块。这些模块相互独立,可以更好地管理依赖关系和可见性,从而提高了代码的可维护性和可重用性。模块化系统还提供了一些新的工具和命令,如 jmod 命令和 jlink 命令,用于构建和组装模块化应用程序。



  1. JShell:


JShell 是一个交互式的 Java 命令行工具,可以在命令行中执行 Java 代码片段。它可以非常方便地进行代码测试和调试,并且可以快速地查看和修改代码。JShell 还提供了一些有用的功能,如自动补全、实时反馈和历史记录等。



  1. 接口的私有方法:


JDK 9 允许在接口中定义 private 和 private static 方法。这些方法可以被接口中的其他方法调用,但不能被实现该接口的类使用。这样可以避免在接口中重复编写相同的代码,提高了代码的重用性和可读性。



  1. 改进的 try-with-resources:


在 JDK 9 中,可以在 try-with-resources 语句块中使用 final 或 effectively final 的变量。这样可以避免在 finally 语句块中手动关闭资源,提高了代码的可读性和可维护性。



  1. 集合工厂方法:


JDK 9 提供了一系列工厂方法,用于创建 List、Set 和 Map 等集合对象。这些方法可以使代码更加简洁和易读,而且还可以为集合对象指定初始容量和类型参数。



  1. 改进的 Stream API:


JDK 9 引入了一些新的 Stream API 方法,如 takeWhile、dropWhile 和 ofNullable 等。takeWhile 和 dropWhile 方法可以根据指定的条件从流中选择元素,而 ofNullable 方法可以创建一个包含一个非空元素或空元素的 Stream 对象。


除了以上几个新特性,JDK 9 还引入了一些其他的改进和优化,如改进的 Stack-Walking API、改进的 CompletableFuture API、Java 应用程序启动时优化(Application Class-Data Sharing)等等。这些新特性和改进都为 Java 应用程序的开发和运行提供了更好的支持。


JDK10 新特性


JDK10是JDK的一个短期支持版本,于2018年3月发布。它的主要特性如下:




  1. 局部变量类型推断:Java 10中引入了一种新的语法——var关键字,可以用于推断局部变量的类型,使代码更加简洁。例如,可以这样定义一个字符串类型的局部变量:var str = "hello"




  2. G1 垃圾回收器并行全阶段:JDK10中对G1垃圾回收器进行了改进,使其可以在并行全阶段进行垃圾回收,从而提高了GC效率。




  3. 应用级别的 Java 类数据共享:Java 10中引入了一项新的特性,即应用级别的 Java 类数据共享(AppCDS),可以在多个JVM进程之间共享Java类元数据,从而加速JVM的启动时间。




  4. 线程局部握手协议:Java 10中引入了线程局部握手协议(Thread-Local Handshakes),可以在不影响整个JVM性能的情况下,暂停所有线程执行特定的操作。




  5. 其他改进:Java 10还包含一些其他的改进,例如对Unicode 10.0的支持,对时间API的改进,以及对容器API的改进等等。




总的来说,JDK10主要关注于提高Java应用程序的性能和可维护性,通过引入一些新的特性和改进对JDK进行优化。


JDK11 新特性


JDK11是JDK的一个长期支持版本,于2018年9月发布。它的主要特性如下:




  1. HTTP 客户端 API:Java 11中引入了一个全新的HTTP客户端API,可以用于发送HTTP请求和接收HTTP响应,而不需要依赖第三方库。




  2. ZGC 垃圾回收器:Java 11中引入了ZGC垃圾回收器(Z Garbage Collector),它是一种可伸缩且低延迟的垃圾回收器,可以在数百GB的堆上运行,且最大停顿时间不超过10ms。




  3. 移除Java EE和CORBA模块:Java 11中移除了Java EE和CORBA模块,这些模块在Java 9中已被标记为“过时”,并在Java 11中被完全移除。




  4. Epsilon垃圾回收器:Java 11中引入了一种新的垃圾回收器,称为Epsilon垃圾回收器,它是一种无操作的垃圾回收器,可以在不进行垃圾回收的情况下运行应用程序,适用于测试和基准测试等场景。




  5. 其他改进:Java 11还包含一些其他的改进,例如对Lambda参数的本地变量类型推断,对字符串API的改进,以及对嵌套的访问控制的改进等等。




总的来说,JDK11主要关注于提高Java应用程序的性能和安全性,通过引入一些新的特性和改进对JDK进行优化。其中,HTTP客户端API和ZGC垃圾回收器是最值得关注的特性之一。


JDK12 新特性


JDK12是JDK的一个短期支持版本,于2019年3月发布。它的主要特性如下:




  1. Switch 表达式:Java 12中引入了一种新的Switch表达式,可以使用Lambda表达式风格来简化代码。此外,Switch表达式也支持返回值,从而可以更方便地进行流程控制。




  2. Microbenchmark Suite:Java 12中引入了一个Microbenchmark Suite,可以用于进行微基准测试,从而更好地评估Java程序的性能。




  3. JVM Constants API:Java 12中引入了JVM Constants API,可以用于在运行时获取常量池中的常量,从而更好地支持动态语言和元编程。




  4. Shenandoah 垃圾回收器:Java 12中引入了Shenandoah垃圾回收器,它是一种低暂停时间的垃圾回收器,可以在非常大的堆上运行,且最大暂停时间不超过几毫秒。




  5. 其他改进:Java 12还包含一些其他的改进,例如对Unicode 11.0的支持,对预览功能的改进,以及对集合API的改进等等。




总的来说,JDK12主要关注于提高Java应用程序的性能和可维护性,通过引入一些新的特性和改进对JDK进行优化。其中,Switch表达式和Shenandoah垃圾回收器是最值得关注的特性之一。


JDK13 新特性


JDK13是JDK的一个短期支持版本,于2019年9月发布。它的主要特性如下:




  1. Text Blocks:Java 13中引入了一种新的语法,称为Text Blocks,可以用于在代码中编写多行字符串,从而简化代码编写的复杂度。




  2. Switch 表达式增强:Java 13中对Switch表达式进行了增强,支持多个表达式和Lambda表达式。




  3. ZGC 并行处理引用操作:Java 13中对ZGC垃圾回收器进行了改进,支持并行处理引用操作,从而提高了GC效率。




  4. Reimplement the Legacy Socket API:Java 13中重新实现了Legacy Socket API,从而提高了网络编程的性能和可维护性。




  5. 其他改进:Java 13还包含一些其他的改进,例如对预览功能的改进,对嵌套访问控制的改进,以及对集合API的改进等等。




总的来说,JDK13主要关注于提高Java应用程序的性能和可维护性,通过引入一些新的特性和改进对JDK进行优化。其中,Text Blocks和Switch表达式增强是最值得关注的特性之一。


JDK14 新特性


JDK14是JDK的一个短期支持版本,于2020年3月发布。它的主要特性如下:




  1. Records:Java 14中引入了一种新的语法,称为Records,可以用于定义不可变的数据类,从而简化代码编写的复杂度。




  2. Switch 表达式增强:Java 14中对Switch表达式进行了增强,支持使用关键字 yield 返回值,从而可以更方便地进行流程控制。




  3. Text Blocks增强:Java 14中对Text Blocks进行了增强,支持在Text Blocks中嵌入表达式,从而可以更方便地生成动态字符串。




  4. Pattern Matching for instanceof:Java 14中引入了一种新的语法,称为Pattern Matching for instanceof,可以用于在判断对象类型时,同时对对象进行转换和赋值。




  5. 其他改进:Java 14还包含一些其他的改进,例如对垃圾回收器和JVM的改进,对预览功能的改进,以及对JFR的改进等等。




总的来说,JDK14主要关注于提高Java应用程序的可维护性和易用性,通过引入一些新的特性和改进对JDK进行优化。其中,Records和Pattern Matching for instanceof是最值得关注的特性之一。


JDK15 新特性


JDK15是JDK的一个短期支持版本,于2020年9月发布。它的主要特性如下:




  1. Sealed Classes:Java 15中引入了一种新的语法,称为Sealed Classes,可以用于限制某个类的子类的数量,从而提高代码的可维护性。




  2. Text Blocks增强:Java 15中对Text Blocks进行了增强,支持在Text Blocks中使用反斜杠和$符号来表示特殊字符,从而可以更方便地生成动态字符串。




  3. Hidden Classes:Java 15中引入了一种新的类,称为Hidden Classes,可以在运行时动态创建和卸载类,从而提高代码的灵活性和安全性。




  4. ZGC并发垃圾回收器增强:Java 15中对ZGC垃圾回收器进行了增强,支持在启动时指定内存大小,从而提高了GC效率。




  5. 其他改进:Java 15还包含一些其他的改进,例如对预览功能的改进,对JVM和垃圾回收器的改进,以及对集合API和I/O API的改进等等。




总的来说,JDK15主要关注于提高Java应用程序的可维护性和性能,通过引入一些新的特性和改进对JDK进行优化。其中,Sealed Classes和Hidden Classes是最值得关注的特性之一。


JDK16 新特性


JDK16是JDK的一个短期支持版本,于2021年3月发布。它的主要特性如下:




  1. Records增强:Java 16中对Records进行了增强,支持在Records中定义静态方法和构造方法,从而可以更方便地进行对象的创建和操作。




  2. Pattern Matching for instanceof增强:Java 16中对Pattern Matching for instanceof进行了增强,支持在判断对象类型时,同时对对象进行转换和赋值,并支持对switch语句进行模式匹配。




  3. Vector API:Java 16中引入了一种新的API,称为Vector API,可以用于进行SIMD(Single Instruction Multiple Data)向量计算,从而提高计算效率。




  4. JEP 388:Java 16中引入了一个新的JEP(JDK Enhancement Proposal),称为JEP 388,可以用于提高Java应用程序的性能和可维护性。




  5. 其他改进:Java 16还包含一些其他的改进,例如对垃圾回收器、JVM和JFR的改进,对预览功能的改进,以及对集合API和I/O API的改进等等。




总的来说,JDK16主要关注于提高Java应用程序的性能和可维护性,通过引入一些新的特性和改进对JDK进行优化。其中,Records增强和Pattern Matching for instanceof增强是最值得关注的特性之一。


JDK17 新特性


JDK17是JDK的一个长期支持版本,于2021年9月发布。它的主要特性如下:




  1. Sealed Classes增强:Java 17中对Sealed Classes进行了增强,支持在Sealed Classes中定义接口和枚举类型,从而提高代码的灵活性。




  2. Pattern Matching for switch增强:Java 17中对Pattern Matching for switch进行了增强,支持在switch语句中使用嵌套模式和or运算符,从而提高代码的可读性和灵活性。




  3. Foreign Function and Memory API:Java 17中引入了一种新的API,称为Foreign Function and Memory API,可以用于在Java中调用C和C++的函数和库,从而提高代码的可扩展性和互操作性。




  4. JEP 391:Java 17中引入了一个新的JEP(JDK Enhancement Proposal),称为JEP 391,可以用于提高Java应用程序的性能和可维护性。




  5. 其他改进:Java 17还包含一些其他的改进,例如对垃圾回收器、JVM和JFR的改进,对预览功能的改进,以及对集合API和I/O API的改进等等。




总的来说,JDK17主要关注于提高Java应用程序的灵活性、可扩展性和性能,通过引入一些新的特性和改进对JDK进行优化。其中,Sealed Classes增强和Foreign Function and Memory API是最值得关注的特性之一。


总结




  • JDK9:引入了模块化系统、JShell、私有接口方法、多版本兼容性等新特性




  • JDK10:引入了局部变量类型推断、垃圾回收器接口、并行全垃圾回收器等新特性




  • JDK11:引入了ZGC垃圾回收器、HTTP客户端API、VarHandles API等新特性




  • JDK12:引入了Switch表达式、新的字符串方法、HTTP/2客户端API等新特性




  • JDK13:引入了Text Blocks、Switch表达式增强、改进的ZGC性能等新特性




  • JDK14:引入了Records、Switch表达式增强、Pattern Matching for instanceof等新特性




  • JDK15:引入了Sealed Classes、Text Blocks增强、Hidden Classes等新特性




  • JDK16:引入了Records增强、Pattern Matching for instanceof增强、Vector API等新特性




  • JDK17:引入了Sealed Classes增强、Pattern Matching for switch增强、Foreign Function and Memory API等新特性




总的来说,JDK9到JDK17的更新涵盖了Java应用程序开发的各个方面,包括模块化、垃圾回收、性能优化、API增强等等,为Java开发者提供了更多的选择和工具,以提高代码的质量和效率


小记


Java作为一门长盛不衰的编程语言,未来的发展仍然有许多潜力和机会。




  • 云计算和大数据:随着云计算和大数据的发展,Java在这些领域的应用也越来越广泛。Java已经成为了许多云计算平台和大数据处理框架的首选语言之一。




  • 移动端和IoT:Java也逐渐开始在移动端和物联网领域崭露头角。Java的跨平台特性和安全性,使得它成为了许多移动应用和物联网设备的首选开发语言。




  • 前沿技术的应用:Java社区一直在积极探索和应用前沿技术,例如人工智能、机器学习、区块链等。Java在这些领域的应用和发展也将会是未来的趋势。




  • 开源社区的发展:Java开源社区的发展也将会对Java的未来产生重要影响。Java社区的开源项目和社区贡献者数量不断增加,将会为Java的发展提供更多的动力和资源。




  • 新的Java版本:Oracle已经宣布将在未来两年内发布两个新的Java版本,其中一个是短期支持版本,另一个是长期支持版本。这将会为Java开发者提供更多的新特性和改进,以满足不断变化的需求。




总的来说,Java作为一门优秀的编程语言,具有广泛的应用和发展前景。随着技术的不断创新和社区的不断发展,Java的未来将会更加光明。


更多内容:


image.png


作者:QIANGLU
来源:juejin.cn/post/7238795712569802809
收起阅读 »

对接了个三方支付,给俺气的呀

故事是这样的: 我们的商城需要在日本上线去赚小日子过得不错的日本人的钱,所以支付是首要的。就找了一家做日本本地支付的公司做对接,公司名字当然不能说,打我也不说。 第一天,很愉快,签了协议,给了开发文档。俺就准备开始撸代码了。 API文档 这开发文档,打开两秒钟...
继续阅读 »

故事是这样的:


我们的商城需要在日本上线去赚小日子过得不错的日本人的钱,所以支付是首要的。就找了一家做日本本地支付的公司做对接,公司名字当然不能说,打我也不说。


第一天,很愉快,签了协议,给了开发文档。俺就准备开始撸代码了。


API文档


这开发文档,打开两秒钟就自动挂掉了,我只能一次又一次的点击Reload


image.png


后来实在受不了了,我趁着那两三秒钟显示的时间,截图对照着看。结果就是所有的字段不能复制粘贴了,只能一个一个敲。


还是这个API文档,必输字段非必输字段乱的一塌糊涂,哪些是必输纯靠试,有的必输字段调试的时候有问题,对面直接甩过来一句,要么不传了吧。听得我懵懵的。


加密,验签


然后到了加密,验签。竟然能只给了node的demo,咱对接的大部分是后端程序员吧,没事,咱自己写。比较坑的是MD5加密完是大写的字母,这三方公司转小写了,也没有提示,折腾了一会,测了一会也就通了,还好还好。


场景支持


在之后就是支付的一个场景了,日本是没有微信支付宝这样的便捷支付的,要么信用卡,要么便利店支付



稍微说下便利店支付,就是说,客户下完单之后,会给到一个回执,是一串数字,我们且称之为支付码,他们便利店有一个类似于ATM机的柜员机,去这个机子上凭借这串支付码打印出来一个凭条,然后拿着这个凭条去找便利店的店员,现金支付



就是这个场景,就是这个数字,三方它就给客户显示一次,就一次,点击支付的时候显示一次。要是客户不截图,那么不好意思,您就重新下单吧。而且这个支付码我们拿不到,这个跳转了他们的页面,也不发个邮件告知。这明显没法用啊,我们的订单过期时间三天,客户这三天啥时候想去便利店支付都行,可是这只显示一次太扯了。

同样的请求再发一次显示的是支付进行中。这怎么玩,好说歹说他们排期开发了两周,把这个订单号重入解决了,就是说同一笔订单再次进入是可以看到那串支付码的。


测试环境不能测


最后,写完了,调通了,测一下支付,结果他们测试环境不支持日本的这个便利店支付测试,what? 测试环境不能测试?那我这么久在干什么,让我们上线上测,我的代码在测试环境还没搞完,让我上生产,最后上了,没测通,对方的问题,当天下午就把代码给回滚了。等着对方调试完继续测。


业务不完整


还有,不支持退款,作为一个支付公司,不支持退款,我们客户退款只能线下转账,闹呢。


以前对接三方的时候,遇到问题地想到的是我们是不是哪里做错了,再看看文档。对接这个公司,就跟他们公司的测试一样,找bug呢。


建议


这里是本文的重点,咱就是给点建议,作为一家提供服务的公司,不管是啥服务。



  1. 对外API文档应当可以正常显示,必输非必输定义正确,字段类型标注准确。

  2. 若是有验签的步骤,介绍步骤详细并配上各个语言的demo,并强调格式以及大小写。

  3. 牵扯到业务的,需要站在客户的角度思考,这个是否合情合理。

  4. 业务的完整性,有可能是尚未开发完全,但是得有备选的方案。


作者:奔跑的毛球
来源:juejin.cn/post/7127691522010529799
收起阅读 »

Go 开发短网址服务笔记

这篇文章是基于课程 Go 开发短地址服务 做的笔记 项目地址:github.com/astak16/sho… 错误处理 在处理业务逻辑时,如果出错误了,需要统一处理错误响应的格式,这样可以方便前端处理错误信息 所以需要定义一个 Error 接口,它包含了 er...
继续阅读 »

这篇文章是基于课程 Go 开发短地址服务 做的笔记


项目地址:github.com/astak16/sho…


错误处理


在处理业务逻辑时,如果出错误了,需要统一处理错误响应的格式,这样可以方便前端处理错误信息


所以需要定义一个 Error 接口,它包含了 error 接口,以及一个 Status() 方法,用来返回错误的状态码


type Error interface {
error
Status() int
}

这个接口用来判断错误类型,在 go 中可以通过 e.(type) 判断错误的类型


func respondWithError(w http.RespondWrite, err error) {
switch e.(type) {
case Error:
respondWithJSON(w, e.Status(), e.Error())
default:
respondWithJSON(w, http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
}
}

go 中 实现 Error 接口,只需要实现 Error()Status() 方法即可


func () Error() string {
return ""
}
func () Status() int {
return 0
}

这样定义的方法,只能返回固定的文本和状态码,如果想要返回动态内容,可以定义一个结构体


然后 ErrorStatus 方法接受 StatusError 类型


这样只要满足 StatusError 类型的结构体,就可以返回动态内容


所以上面的代码可以修改为:


type StatusError struct {
Code int
Err error
}
func (se StatusError) Error() string {
return se.Err.Error()
}
func (se StatusError) Status() int {
return se.Code
}

middlerware


RecoverHandler


中间件 RecoverHandler 作用是通过 defer 来捕获 panic,然后返回 500 状态码


func RecoverHandler(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
defer func() {
if r := recover(); r != nil {
log.Println("Recover from panic %+v", r)
http.Error(w, http.StatusText(500), 500)
}
}()
next.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}

LoggingHandler


LoggingHandler 作用是记录请求耗时


func (m Middleware) LoggingHandler(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
end := time.Now()
log.Printf("[%s] %q %v", r.Method, r.URL.Path, end.Sub(start))
}
return http.HandlerFunc(fn)
}

中间件使用


alicego 中的一个中间件库,可以通过 alice.New() 来添加中间件,具体使用如下:


m := alice.New(middleware.LoggingHandler, middleware.RecoverHandler)
mux.Router.HandleFunc("/api/v1/user", m.ThenFunc(controller)).Methods("POST")

生成短链接


redis 连接


func NewRedisCli(addr string, passwd string, db int) *RedisCli {
c := redis.NewClient(&redis.Options{
Addr: addr,
Password: passwd,
DB: db,
})

if _, err := c.Ping().Result(); err != nil {
panic(err)
}
return &RedisCli{Cli: c}
}

生成唯一 ID


redis 可以基于一个键名生成一个唯一的自增 ID,这个键名可以是任意的,这个方法是 Incr


代码如下:


err = r.Cli.Incr(URLIDKEY).Err()
if err != nil {
return "", err
}

id, err := r.Cli.Get(URLIDKEY).Int64()
if err != nil {
return "", err
}

fmt.Println(id) // 每次调用都会自增

存储和解析短链接


一个 ID 对应一个 url,也就是说当外面传入 id 时需要返回对应的 url


func Shorten() {
err := r.Cli.Set(fmt.Sprintf(ShortlinkKey, eid), url, time.Minute*time.Duration(exp)).Err()
if err != nil {
return "", err
}
}
func UnShorten() {
url, err := r.Cli.Get(fmt.Sprintf(ShortlinkKey, eid)).Result()
}

redis 注意事项


redis 返回的 error 有两种情况:



  1. redis.Nil 表示没有找到对应的值

  2. 其他错误,表示 redis 服务出错了


所以在使用 redis 时,需要判断返回的错误类型


if err == redis.Nil {
// 没有找到对应的值
} else if err != nil {
// redis 服务出错了
} else {
// 正确响应
}

测试


在测试用例中,如何发起一个请求,然后获取响应的数据呢?



  1. 构造请求


var jsonStr = []byte(`{"url":"https://www.baidu.com","expiration_in_minutes":60}`)
req, err := http.NewRequest("POST", "/api/shorten", bytes.NewBuffer(jsonStr))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")


  1. 捕获 http 响应


rw := httptest.NewRecorder()


  1. 模拟请求被处理


app.Router.ServeHTTP(rw, req)


  1. 解析响应


if rw.Code != http.ok {
t.Fatalf("Excepted status created, got %d", rw.Code)
}

resp := struct {
Shortlink string `json:"shortlink"`
}{}
if err := json.NewDecoder(rw.Body).Decode(&resp); err != nil {
t.Fatalf("should decode the response", err)
}

最终完整代码:


var jsonStr = []byte(`{"url":"https://www.baidu.com","expiration_in_minutes":60}`)
req, err := http.NewRequest("POST", "/api/shorten", bytes.NewBuffer(jsonStr))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")

rw := httptest.NewRecorder()
app.Router.ServeHTTP(rw, req)

if rw.Code != http.ok {
t.Fatalf("Excepted status created, got %d", rw.Code)
}
resp := struct {
Shortlink string `json:"shortlink"`
}{}

if err := json.NewDecoder(rw.Body).Decode(&resp); err != nil {
t.Fatalf("should decode the response")
}

代码


log.SetFlags(log.LstdFlags | log.Lshortfile)


作用是设置日志输出的标志


它们都是标志常量,用竖线 | 连接,这是位操作符,将他们合并为一个整数值,作为 log.SetFlags() 的参数



  • log.LstdFlags 是标准时间格式:2022-01-23 01:23:23

  • log.Lshortfile 是文件名和行号:main.go:23


当我们使用 log.Println 输出日志时,会自动带上时间、文件名、行号信息


recover 函数使用


recover 函数类似于其他语言的 try...catch,用来捕获 panic,做一些处理


使用方法:


func MyFunc() {
defer func() {
if r := recover(); r != nil {
// 处理 panic 情况
}
}
}

需要注意的是:



  1. recover 函数只能在 defer 中使用,如果在 defer 之外使用,会直接返回 nil

  2. recover 函数只有在 panic 之后调用才会生效,如果在 panic 之前调用,也会直接返回 nil

  3. recover 函数只能捕获当前 goroutinepanic,不能捕获其他 goroutinepanic


next.ServerHttp(w, r)


next.ServeHTTP(w, r),用于将 http 请求传递给下一个 handler


HandleFunc 和 Handle 区别


HandleFunc 接受一个普通类型的函数:


func myHandle(w http.ResponseWriter, r *http.Request) {}
http.HandleFunc("xxxx", myHandle)

Handle 接收一个实现 Handler 接口的函数:


func myHandler(w http.ResponseWriter, r *http.Request) {}
http.Handle("xxxx", http.HandlerFunc(myHandler))

他们的区别是:使用 Handle 需要自己进行包装,使用 HandleFunc 不需要


defer res.Body.Close()


为什么没有 res.Header.Close() 方法?


因为 header 不是资源,而 body 是资源,在 go 中,一般操作资源后,要及时关闭资源,所以 gobody 提供了 Close() 方法


res.Bodyio.ReadCloser 类型的接口,表示可以读取响应数据并关闭响应体的对象


w.Write()


代码在执行了 w.Writer(res) 后,还会继续往下执行,除非有显示的 returepanic 终止函数执行


func controller(w http.ResponseWriter, r *http.Request) {
if res, err := xxx; err != nil {
respondWithJSON(w, http.StatusOK, err)
}
// 这里如果有代码,会继续执行
}
func respondWithJSON(w http.ResponseWriter, code int, payload interface{}) {
res, _ json.Marshal(payload)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
w.Write(res)
}

需要注意的是,尽管执行了 w.Writer() 后,还会继续往下执行,但不会再对响应进行修改或写入任何内容了,因为 w.Write() 已经将响应写入到 http.ResponseWriter 中了


获取请求参数


路由 /api/info?shortlink=2


a.Router.Handle("/api/info", m.ThenFunc(a.getShortlinkInfo)).Methods("GET")

func getShortlinkInfo(w http.ResponseWriter, r *http.Request) {
vals := r.URL.Query()
s := vals.Get("shortlink")

fmt.Println(s) // 2
}

路由 /2


a.Router.Handle("/{shortlink:[a-zA-Z0-9]{1,11}}", m.ThenFunc(a.redirect)).Methods("GET")

func redirect(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
shortlink := vars["shortlink"]

fmt.Println(shortlink) // 2
}

获取请求体


json.NewDecoder(r.Body) 作用是将 http 请求的 body 内容解析为 json 格式


r.body 是一个 io.Reader 类型,它代表请求的原始数据


如果关联成功可以用 Decode() 方法来解析 json 数据


type User struct {
Name string `json:"name"`
Age int `json:"age"`
}

func controller(w http.ResponseWriter, r *http.Request){
var user User
if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
fmt.Println(err)
}
fmt.Println(user)
}

new


用于创建一个新的零值对象,并返回该对象的指针


它接受一个类型作为参数,并返回一个指向该类型的指针


适用于任何可分配的类型,如基本类型、结构体、数组、切片、映射和接口等


// 创建一个新的 int 类型的零值对象,并返回指向它的指针
ptr := new(int) // 0

需要注意的是:new 只分配了内存,并初始化为零值,并不会对对象进行任何进一步的初始化。如果需要对对象进行自定义的初始化操作,可以使用结构体字面量或构造函数等方式


往期文章



  1. Go 项目ORM、测试、api文档搭建

  2. Go 实现统一加载资源的入口<
    作者:uccs
    来源:juejin.cn/post/7237702874880393274
    /a>

收起阅读 »

原型模式与享元模式

原型模式与享元模式 原型模式和享元模式,前者是在创建多个实例时,对创建过程的性能进行调优;后者是用减少创建实例的方式,来调优系统性能。这么看,你会不会觉得两个模式有点相互矛盾呢? 其实不然,它们的使用是分场景的。在有些场景下,我们需要重复创建多个实例,例如在循...
继续阅读 »

原型模式与享元模式


原型模式和享元模式,前者是在创建多个实例时,对创建过程的性能进行调优;后者是用减少创建实例的方式,来调优系统性能。这么看,你会不会觉得两个模式有点相互矛盾呢?


其实不然,它们的使用是分场景的。在有些场景下,我们需要重复创建多个实例,例如在循环体中赋值一个对象,此时我们就可以采用原型模式来优化对象的创建过程;而在有些场景下,我们则可以避免重复创建多个实例,在内存中共享对象就好了。


今天我们就来看看这两种模式的适用场景,看看如何使用它们来提升系统性能。


原型模式


原型模式是通过给出一个原型对象来指明所创建的对象的类型,然后使用自身实现的克隆接口来复制这个原型对象,该模式就是用这种方式来创建出更多同类型的对象。


使用这种方式创建新的对象的话,就无需再通过new实例化来创建对象了。这是因为Object类的clone方法是一个本地方法,它可以直接操作内存中的二进制流,所以性能相对new实例化来说,更佳。


实现原型模式


我们现在通过一个简单的例子来实现一个原型模式:


   //实现Cloneable 接口的原型抽象类Prototype
class Prototype implements Cloneable {
//重写clone方法
public Prototype clone(){
Prototype prototype = null;
try{
prototype = (Prototype)super.clone();
}catch(CloneNotSupportedException e){
e.printStackTrace();
}
return prototype;
}
}
//实现原型类
class ConcretePrototype extends Prototype{
public void show(){
System.out.println("原型模式实现类");
}
}

public class Client {
public static void main(String[] args){
ConcretePrototype cp = new ConcretePrototype();
for(int i=0; i< 10; i++){
ConcretePrototype clonecp = (ConcretePrototype)cp.clone();
clonecp.show();
}
}
}


要实现一个原型类,需要具备三个条件:



  • 实现Cloneable接口:Cloneable接口与序列化接口的作用类似,它只是告诉虚拟机可以安全地在实现了这个接口的类上使用clone方法。在JVM中,只有实现了Cloneable接口的类才可以被拷贝,否则会抛出CloneNotSupportedException异常。

  • 重写Object类中的clone方法:在Java中,所有类的父类都是Object类,而Object类中有一个clone方法,作用是返回对象的一个拷贝。

  • 在重写的clone方法中调用super.clone():默认情况下,类不具备复制对象的能力,需要调用super.clone()来实现。


从上面我们可以看出,原型模式的主要特征就是使用clone方法复制一个对象。通常,有些人会误以为 Object a=new Object();Object b=a; 这种形式就是一种对象复制的过程,然而这种复制只是对象引用的复制,也就是a和b对象指向了同一个内存地址,如果b修改了,a的值也就跟着被修改了。


我们可以通过一个简单的例子来看看普通的对象复制问题:


class Student {
private String name;

public String getName() {
return name;
}

public void setName(String name) {
this.name= name;
}

}
public class Test {

public static void main(String args[]) {
Student stu1 = new Student();
stu1.setName("test1");

Student stu2 = stu1;
stu2.setName("test2");

System.out.println("学生1:" + stu1.getName());
System.out.println("学生2:" + stu2.getName());
}
}


如果是复制对象,此时打印的日志应该为:


学生1:test1
学生2:test2


然而,实际上是:


学生1:test2
学生2:test2


通过clone方法复制的对象才是真正的对象复制,clone方法赋值的对象完全是一个独立的对象。刚刚讲过了,Object类的clone方法是一个本地方法,它直接操作内存中的二进制流,特别是复制大对象时,性能的差别非常明显。我们可以用 clone 方法再实现一遍以上例子。


//学生类实现Cloneable接口
class Student implements Cloneable{
private String name; //姓名

public String getName() {
return name;
}

public void setName(String name) {
this.name= name;
}
//重写clone方法
public Student clone() {
Student student = null;
try {
student = (Student) super.clone();
} catch (CloneNotSupportedException e) {
e.printStackTrace();
}
return student;
}

}
public class Test {

public static void main(String args[]) {
Student stu1 = new Student(); //创建学生1
stu1.setName("test1");

Student stu2 = stu1.clone(); //通过克隆创建学生2
stu2.setName("test2");

System.out.println("学生1:" + stu1.getName());
System.out.println("学生2:" + stu2.getName());
}
}


运行结果:


学生1:test1
学生2:test2


深拷贝和浅拷贝


在调用super.clone()方法之后,首先会检查当前对象所属的类是否支持clone,也就是看该类是否实现了Cloneable接口。


如果支持,则创建当前对象所属类的一个新对象,并对该对象进行初始化,使得新对象的成员变量的值与当前对象的成员变量的值一模一样,但对于其它对象的引用以及List等类型的成员属性,则只能复制这些对象的引用了。所以简单调用super.clone()这种克隆对象方式,就是一种浅拷贝。


所以,当我们在使用clone()方法实现对象的克隆时,就需要注意浅拷贝带来的问题。我们再通过一个例子来看看浅拷贝。


//定义学生类
class Student implements Cloneable{
private String name; //学生姓名
private Teacher teacher; //定义老师类

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public Teacher getTeacher() {
return teacher;
}

public void setTeacher(Teacher teacher) {
this.teacher = teacher;
}
//重写克隆方法
public Student clone() {
Student student = null;
try {
student = (Student) super.clone();
} catch (CloneNotSupportedException e) {
e.printStackTrace();
}
return student;
}

}

//定义老师类
class Teacher implements Cloneable{
private String name; //老师姓名

public String getName() {
return name;
}

public void setName(String name) {
this.name= name;
}

//重写克隆方法,对老师类进行克隆
public Teacher clone() {
Teacher teacher= null;
try {
teacher= (Teacher) super.clone();
} catch (CloneNotSupportedException e) {
e.printStackTrace();
}
return student;
}

}
public class Test {

public static void main(String args[]) {
Teacher teacher = new Teacher (); //定义老师1
teacher.setName("刘老师");
Student stu1 = new Student(); //定义学生1
stu1.setName("test1");
stu1.setTeacher(teacher);

Student stu2 = stu1.clone(); //定义学生2
stu2.setName("test2");
stu2.getTeacher().setName("王老师");//修改老师
System.out.println("学生" + stu1.getName + "的老师是:" + stu1.getTeacher().getName);
System.out.println("学生" + stu1.getName + "的老师是:" + stu2.getTeacher().getName);
}
}


运行结果:


学生test1的老师是:王老师
学生test2的老师是:王老师


观察以上运行结果,我们可以发现:在我们给学生2修改老师的时候,学生1的老师也跟着被修改了。这就是浅拷贝带来的问题。


我们可以通过深拷贝来解决这种问题,其实深拷贝就是基于浅拷贝来递归实现具体的每个对象,代码如下:


   public Student clone() {
Student student = null;
try {
student = (Student) super.clone();
Teacher teacher = this.teacher.clone();//克隆teacher对象
student.setTeacher(teacher);
} catch (CloneNotSupportedException e) {
e.printStackTrace();
}
return student;
}


适用场景


前面我详述了原型模式的实现原理,那到底什么时候我们要用它呢?


在一些重复创建对象的场景下,我们就可以使用原型模式来提高对象的创建性能。例如,我在开头提到的,循环体内创建对象时,我们就可以考虑用clone的方式来实现。


例如:


for(int i=0; i<list.size(); i++){
Student stu = new Student();
...
}


我们可以优化为:


Student stu = new Student();
for(int i=0; i<list.size(); i++){
Student stu1 = (Student)stu.clone();
...
}


除此之外,原型模式在开源框架中的应用也非常广泛。例如Spring中,@Service默认都是单例的。用了私有全局变量,若不想影响下次注入或每次上下文获取bean,就需要用到原型模式,我们可以通过以下注解来实现,@Scope(“prototype”)。


享元模式


享元模式是运用共享技术有效地最大限度地复用细粒度对象的一种模式。该模式中,以对象的信息状态划分,可以分为内部数据和外部数据。内部数据是对象可以共享出来的信息,这些信息不会随着系统的运行而改变;外部数据则是在不同运行时被标记了不同的值。


享元模式一般可以分为三个角色,分别为 Flyweight(抽象享元类)、ConcreteFlyweight(具体享元类)和 FlyweightFactory(享元工厂类)。抽象享元类通常是一个接口或抽象类,向外界提供享元对象的内部数据或外部数据;具体享元类是指具体实现内部数据共享的类;享元工厂类则是主要用于创建和管理享元对象的工厂类。


实现享元模式


我们还是通过一个简单的例子来实现一个享元模式:


//抽象享元类
interface Flyweight {
//对外状态对象
void operation(String name);
//对内对象
String getType();
}


//具体享元类
class ConcreteFlyweight implements Flyweight {
private String type;

public ConcreteFlyweight(String type) {
this.type = type;
}

@Override
public void operation(String name) {
System.out.printf("[类型(内在状态)] - [%s] - [名字(外在状态)] - [%s]\n", type, name);
}

@Override
public String getType() {
return type;
}
}


//享元工厂类
class FlyweightFactory {
private static final Map<String, Flyweight> FLYWEIGHT_MAP = new HashMap<>();//享元池,用来存储享元对象

public static Flyweight getFlyweight(String type) {
if (FLYWEIGHT_MAP.containsKey(type)) {//如果在享元池中存在对象,则直接获取
return FLYWEIGHT_MAP.get(type);
} else {//在响应池不存在,则新创建对象,并放入到享元池
ConcreteFlyweight flyweight = new ConcreteFlyweight(type);
FLYWEIGHT_MAP.put(type, flyweight);
return flyweight;
}
}
}


public class Client {

public static void main(String[] args) {
Flyweight fw0 = FlyweightFactory.getFlyweight("a");
Flyweight fw1 = FlyweightFactory.getFlyweight("b");
Flyweight fw2 = FlyweightFactory.getFlyweight("a");
Flyweight fw3 = FlyweightFactory.getFlyweight("b");
fw1.operation("abc");
System.out.printf("[结果(对象对比)] - [%s]\n", fw0 == fw2);
System.out.printf("[结果(内在状态)] - [%s]\n", fw1.getType());
}
}


输出结果:


[类型(内在状态)] - [b] - [名字(外在状态)] - [abc]
[结果(对象对比)] - [true]
[结果(内在状态)] - [b]


观察以上代码运行结果,我们可以发现:如果对象已经存在于享元池中,则不会再创建该对象了,而是共用享元池中内部数据一致的对象。这样就减少了对象的创建,同时也节省了同样内部数据的对象所占用的内存空间。


适用场景


享元模式在实际开发中的应用也非常广泛。例如Java的String字符串,在一些字符串常量中,会共享常量池中字符串对象,从而减少重复创建相同值对象,占用内存空间。代码如下:


 String s1 = "hello";
String s2 = "hello";
System.out.println(s1==s2);//true


还有,在日常开发中的应用。例如,池化技术中的线程池就是享元模式的一种实现;将商品存储在应用服务的缓存中,那么每当用户获取商品信息时,则不需要每次都从redis缓存或者数据库中获取商品信息,并在内存中重复创建商品信息了。


总结


原型模式和享元模式,在开源框架,和实际开发中,应用都十分广泛。


在不得已需要重复创建大量同一对象时,我们可以使用原型模式,通过clone方法复制对象,这种方式比用new和序列化创建对象的效率要高;在创建对象时,如果我们可以共用对象的内部数据,那么通过享元模式共享相同的内部数据的对象,就可以减少对象的创建,实现系统调优。


本文由mdnic

e多平台发布

收起阅读 »

Java字符串常量池和intern方法解析

Java字符串常量池和intern方法解析 这篇文章,来讨论一下Java中的字符串常量池以及Intern方法.这里我们主要讨论的是jdk1.7,jdk1.8版本的实现. 字符串常量池 在日常开发中,我们使用字符串非常的频繁,我们经常会写下类似如下的代码: S...
继续阅读 »

Java字符串常量池和intern方法解析


这篇文章,来讨论一下Java中的字符串常量池以及Intern方法.这里我们主要讨论的是jdk1.7,jdk1.8版本的实现.


字符串常量池


在日常开发中,我们使用字符串非常的频繁,我们经常会写下类似如下的代码:



  • String s = "abc";

  • String str = s + "def";


通常,我们一般不会这么写:String s = new String("jkl"),但其实这么写和上面的写法还是有很多区别的.


先思考一个问题,为什么要有字符串常量池这种概念?原因是字符串常量既然是不变的,那么完全就可以复用它,而不用再重新去浪费空间存储一个完全相同的字符串.字符串常量池是用于存放字符串常量的地方,在Java7和Java8中字符串常量池是堆的一部分.


假如我们有如下代码:


String s = "abc";
String s1 = s + "def";    
String s2 = new String("abc");
String s3 = new String("def");

那么从内存分配的角度上看,最终会有哪些字符串生成呢,首先我先给出一张图来代表最终的结论,然后再分析一下具体的原因:


image.png


现在来依次分析上面的代码的执行流程:




  1. 执行String s = "abc",此时遇到"abc"这个字符串常量,会在字符串常量池中完成分配,并且会将引用赋值给s,因此这条语句会在字符串常量池中分配一个"abc".(这里其实没有空格,是因为生成文章时出现了空格,下文中如果出现同样情况,请忽略空格)




  2. 执行String s1 = s + "def",其实这个语句看似简单,实则另有玄机,它其实最终编译而成的代码是这样的:String s1 = new StringBuilder("abc").append("def").toString().首先在这个语句中有两个字符串常量:"abc"和"def",所以在字符串常量池中应该放置"abc"和"def",但是上个步骤已经有"abc"了,所以只会放置"def".另外,new StringBuilder("abc")这个语句相当于在堆上分配了一个对象,如果是new出来的,是在堆上分配字符串,是无法共享字符串常量池里面的字符串的,也就是说分配到堆上的字符串都会有新的内存空间. 最后toString()也是在堆中分配对象(可以从源码中看到这个动作),最终相当于执行了new String("abcdef");所以总结起来,这条语句分析起来还是挺麻烦的,它分配了以下对象:



    • 在字符串常量池分配"abc",但本来就有一个"abc"了,所以不需要分配

    • 在字符串常量池中分配“def"

    • 在堆中分配了"abc"

    • 在堆中分配了"abcdef"




  3. 执行String s2 = new String("abc").首先有个字符串常量"abc",需要分配到字符串常量池,但是字符串常量池中已经有"abc"了,所以无需分配.因此new String("abc")最终在堆上分配了一个"abc".所以总结起来就是,在堆中分配了一个"abc"




  4. 执行String s3 = new String("def");.首先有个字符串常量"def",需要分配到字符串常量池,但是字符串常量池中已经有"def"了,所以无需分配.因此new String("def")最终在堆上分配了一个"def".所以总结起来就是,在堆中分配了一个"def"。




总结起来,全部语句执行后分配的对象如下:



  • 在堆中分配了两个"abc",一个"abcdef",一个"def"

  • 在字符串常量池中分配了一个"abc",一个"def"


也就是图中所表示的这些对象,如果明白了对象是如何分配的,我们就可以分析以下代码的结果:


String s = "abc";
String s1 = s + "def";    
String s2 = new String("abc");
String s3 = new String("def");
String s4 = "abcdef";
String s5 = "abc";
System.out.println(s == s2); //false 前者引用的对象在字符串常量池 后者在堆上
System.out.println(s == s5);; //true 都引用了字符串常量池中的"abc"
System.out.println(s1 == s4); //false 前者引用的对象在字符串常量池,后者在堆上

intern方法


在字符串对象中,有一个intern方法.在jdk1.7,jdk1.8中,它的定义是如果调用这个方法时,在字符串常量池中有对应的字符串,那么返回字符串常量池中的引用,否则返回调用时相应对象的引用,也就是说intern方法在jdk1.7,jdk1.8中只会复用某个字符串的引用,这个引用可以是对堆内存中字符串中的引用,也可能是对字符串常量池中字符串的引用.这里通过一个例子来说明,假如我们有下面这段代码:


String str = new String("abc");
String str2 = str.intern();
String str3 = new StringBuilder("abc").append("def").toString();
String str4 = str3.intern();
System.out.println(str == str2);
System.out.println(str3 == str4);  

那么str2和str以及str3和str4是否相等呢?如果理解了上面对字符串常量池的分析,那么我们可以明白在这段代码中,字符串在内存中是这么分配的:



  • 在堆中分配两个"abc",一个“abcdef"

  • 在字符串常量池中分配一个"def",一个"abc"



  1. 当执行String str2 = str.intern();时,会先从字符串常量池中寻找是否有对应的字符串,此时在字符串常量池中有一个"abc",那么str2就指向字符串常量池中的"abc",而str是new出来的,指向的是堆中的"abc",所以str不等于str2;



  1. 当执行String str4 = str3.intern();会先从字符串常量池中寻找"abcdef",此时字符串常量池中并没有"abcdef",因此str4会指向堆中的"abcdef",因此str3等于str4,我们会发现一个有意思的地方:如果将第三句改成String str3 = new StringBuilder("abcdef").toString();,也就是把append后面的字符串和前面的字符串做一个拼接,那么结果就会变成str3不等于str4.所以这两种写法的区别还是挺大的.


要注意的是,在jdk1.6中intern的定义是如果字符串常量池中没有对应的字符串,那么就在字符串常量池中创建一个字符串,然后返回字符串常量池中的引用,也就是说在jdk1.6中,intern方法返回的对象始终都是指向字符串常量池的.如果上面的代码在jdk1.6中运行,那么就会得到两个false,原因如下:



  1. 当执行String str2 = str.intern();时,会先从字符串常量池中寻找是否有对应的字符串,此时在字符串常量池中有一个"abc",那么str2就指向字符串常量池中的"abc",而str是new出来的,指向的是堆中的"abc",所以str不等于str2;



  1. 当执行String str4 = str3.intern();会先从字符串常量池中寻找"abcdef",此时字符串常量池中并没有"abcdef",因此执行intern方法会在字符串常量池中分配"abcdef",然后str4最终等于这个字符串的引用,因此str3不等于str4,因为上面的str3指向堆,而str4指向字符串常量池,所以两者一定不会相等.


深入理解JVM虚拟机一书中,就有类似的代码:


String str1 = new StringBuilder("计算机").append("软件").toString();
System.out.println(str1 == str1.intern());
String str2 = new StringBuilder("ja").append("va").toString();
System.out.println(str2 == str2.intern());

在jdk1.6中,两个判断都为false.因为str1和str2都指向堆,而intern方法得出来的引用都指向字符串常量池,所以不会相等,和上面叙述的结论是一样的.在jdk1.7中,第一个是true,第二个是false.道理其实也和上述所讲的是一样的,对于第一个语句,最终会在堆上创建一个"计算机软件"的字符串,执行str1.intern()方法时,先在字符串常量池中寻找字符串,但没有找到,所以会直接引用堆上的这个"计算机软件",因此第一个语句会返回true,因为最终都是指向堆.而对于第三个语句,因为和第一个语句差不多,按理说最终比较也应该返回true.但实际上,str2.intern方法执行的时候,在字符串常量池中是可以找到"java"这个字符串的,这是因为在Java初始化环境去加载类的时候(执行main方法之前),已经有一个叫做"java"的字符串进入了字符串常量池,因此str2.intern方法返回的引用是指向字符串常量池的,所以最终判断的结果是false,因为一个指向堆,一个指向字符串常量池.


总结


从上面的分析看来,字符串常量池并不像是那种很简单的概念,要深刻理解字符串常量池,至少需要理解以下几点:



  • 理解字符串会在哪个内存区域存放

  • 理解遇到字符串常量会发生什么

  • 理解new String或者是new StringBuilder产生的对象会在哪里存放

  • 理解字符串拼接操作+最终编译出来的语句是什么样子的

  • 理解toString方法会发生什么


这几点都在本文章中覆盖了,相信理解了这几点之后一定对字符串常量池有一个更深刻的理解.其实这篇文章的编写原因是因为阅读深入理解JVM虚拟机这本书的例子时突然发现作者所说的和我所想的是不一样的,但是书上可能对这方面没有展开叙述,所以我去查了点资料,然后写了一些代码来验证,最终决定写一篇文章来记录一下自己的理解,在编写代码过程中,还发现了一个分析对象内存地址的类库,我放在参考资料中了.


参考资料


http://www.baeldung.com/java-object… 查看java对象内存地址


作者:JL8
来源:juejin.cn/post/7237827233351073849
收起阅读 »

iOS加固保护新思路

之前有写过【如何给iOS APP加固】,但是经过一段时间的思考,我找到了更具有实践性的代码,具体可以看下面。 技术简介 iOS加固保护是基于虚机源码保护技术,针对iOS平台推出的下一代加固产品。可以对iOS APP中的可执行文件进行深度混淆、加固,并使用独创的...
继续阅读 »

之前有写过【如何给iOS APP加固】,但是经过一段时间的思考,我找到了更具有实践性的代码,具体可以看下面。


技术简介


iOS加固保护是基于虚机源码保护技术,针对iOS平台推出的下一代加固产品。可以对iOS APP中的可执行文件进行深度混淆、加固,并使用独创的虚拟机技术对代码进行加密保护,使用任何工具都无法直接进行逆向、破解。对APP进行完整性保护,防止应用程序中的代码及资源文件被恶意篡改。


技术功能


目前iOS加固主要包含逻辑混淆、字符串加密、代码虚拟化、防调试、防篡改以及完整性保护这三大类功能。通过对下面的代码片段进行保护来展示各个功能的效果:


- (void) test {
if (_flag) {
test_string(@"Hello, World!",@"你好,世界!","Hello, World!");
} else {
dispatch_async(dispatch_get_mian_queue(), ^{
do_something( );
});
}
int i=0;
while (i++ < 100) {
sleep(1);
do_something( );
}
}

将代码编译后拖入IDA Pro中进行分析,可以得到这样的控制流图,只有6个代码块,且跳转逻辑简单,可以很容易地判断出if-else以及while的特征:


1.png


将其反编译为伪代码,代码逻辑及源代码中使用的字符串均清晰可见,与源代码结构基本一致,效果如下


void __cdecl -[ViewController test](ViewController *self, SEL a2)
{
signed int v2; // w19
__int64 v3; //x0

if ( self->_flag )
sub_100006534 ( CFSTR("Hello, World!"),CFSTR("你好,世界!"), "Hello, World!" );
else
dispatch_async ( &_dispatch_main_q, &off_10000C308 );
v2 = 100;
do
{
v3 = sleep( 1u );
sub_100006584( v3 );
--v2;
}
while ( v2 );
}

1 代码逻辑混淆


通过将原始代码的控制流进行切分、打乱、隐藏,或在函数中插入花指令来实现对代码的混淆,使代码逻辑复杂化但不影响原始代码逻辑。


对代码进行逻辑混淆保护后,该函数的控制流图会变得十分复杂,且函数中穿插了大量不会被执行到的无用代码块,以及相互间的逻辑跳转,逆向分析的难度大大增强:


2.png
若开启防反编译功能,则控制流图会被完全隐藏,只剩下一个代码块,且无法反编译出有效代码(如下图所示),这对于对抗逆向分析工具来说非常有效,包括但不限于(IDA Pro, Hopper Disassembler, Binary Ninja, GHIDRA等)


3.png


void __cdecl -[ViewController test](ViewController *self , SEL a2)
{
JUMPOUT (__CS__, sub_100005A94(6LL, a2));
}

2 字符串加密


把所有静态常量字符串(支持C/C++/OC/Swift字符串)进行加密,运行时解密,防止攻击者通过字符串进行静态分析,猜测代码逻辑。


对代码中的字符串进行加密之后,所有的字符串都被替换为加密的引用,任何反编译手段均无法看到明文的字符串。你好,世界!,Hello, World!等字符串原本可以被轻易的反编译出来,但保护之后已经看不到了:


void __cdecl -[ViewController test](ViewController *self, SEL a2)
{
__int64 v2; //x0
__int64 v3; //x0
signed int v4; // w19
__int64 v5; //x0

if ( self->_flag )
{
v2 = sub_100008288();
v3 = sub_10000082E8(v2);
sub_100008228(v3);
sub_100007FB0( &stru_100010368, &stru_10001038, &unk_100011344);
}
else
dispatch_async ( &_dispatch_main_q, &off_100010308 );
}
v4 = 100;
do
{
v5 = sleep( 1u );
sub_100008004( v5 );
--v4;
}
while ( v4 );
}

3 代码虚拟化


将原始代码编译为动态的DX-VM虚拟机指令,运行在DX虚拟机之上,无法被反编译回可读的源代码,任何工具均无法直接反编译虚拟机指令。


采用代码虚拟化保护后,对函数进行反编译将无法看到任何与原代码相似的内容,函数体中只有对虚拟机子系统的调用:


void __cdecl -[ViewController test](ViewController *self, SEL a2)
{
SEL v2; // x19
__int64 v3; // x21

v2 = a2;
v3 = sub_10000C1EC;
(( void (* )(void))sub_10000C1D8)();
sub_10000C1D8( v3, v2);
sub_10000C180( v3, 17LL);
}

4 防调试


防止通过调试手段分析应用逻辑,开启防调试功能后,App进程可以有效地阻止各类调试器的调试行为:


4.png


5 防篡改,完整性保护


防止应用程序中的代码及资源文件被恶意篡改,杜绝盗版或植入广告等二次打包行为。


结语


以上内容是不限制ios版本的。不过,对于App类型,仅支持.xcarchive格式,不支持.ipa格式。并且,有以下注意事项:



  • Build Setting 中 Enable Bitcode 设置为 YES

  • 使用 Archive 模式编译以确保Bitcode成功启用,否则编译出的文件将只包含bitcode-marker

  • 若无法开启Bitcode,可使用辅助工具进行处理,详见五、iOS加固辅助工具 -> 5.2 启用 bitcode

  • 压缩后体积在 2048M 以内


以上是根据顶象的加固产品操作指南出具的流程,如需要更详细的说明,可以自行前往用户中心~


作者:昀和
来源:juejin.cn/post/7236634496765509692
收起阅读 »

夯实基础:彻底搞懂零拷贝

零拷贝 零拷贝我相信大家都听说过,Netty 也用到了零拷贝来大幅提升网络吞吐量,但是大多数人对零拷贝中的原理和过程却很难讲清楚,接下来我会给大家详细讲解这方面的内容。 首先,我们看看,没有零拷贝的时候,应用程序是如何从服务器的磁盘读数据并通过网卡发送到网络的...
继续阅读 »

零拷贝


零拷贝我相信大家都听说过,Netty 也用到了零拷贝来大幅提升网络吞吐量,但是大多数人对零拷贝中的原理和过程却很难讲清楚,接下来我会给大家详细讲解这方面的内容。
首先,我们看看,没有零拷贝的时候,应用程序是如何从服务器的磁盘读数据并通过网卡发送到网络的。


无零拷贝时,数据的发送流程


非DMA非零拷贝 (1).png


大家可以通过上图看到,应用程序把磁盘数据发送到网络的过程中会发生4次用户态和内核态之间的切换,同时会有4次数据拷贝。过程如下:



  1. 应用进程向系统申请读磁盘的数据,这时候程序从用户态切换成内核态。

  2. 系统也就是 linux 系统得知要读数据会通知 DMA 模块要读数据,这时 DMA 从磁盘拉取数据写到系统内存中。

  3. 系统收到 DMA 拷贝的数据后把数据拷贝到应用内存中,同时把程序从内核态变为用户态。

  4. 应用内存拿到从应用内存拿到数据后,会把数据拷贝到系统的 Socket 缓存,然后程序从用户态切换为内核态。

  5. 系统再次调用 DMA 模块,DMA 模块把 Socket 缓存的数据拷贝到网卡,从而完成数据的发送,最后程序从内核态切换为用户态。


如何提升文件传输的效率?


我们程序的目的是把磁盘数据发送到网络中,所以数据在用户内存和系统内存直接的拷贝根本没有意义,同时与数据拷贝同时进行的用户态和内核态之间的切换也没有意义。而上述常规方法出现了4次用户态和内核态之间的切换,以及4次数据拷贝。我们优化的方向无非就是减少用户态和内核态之间的切换次数,以及减少数据拷贝的次数



为什么要在用户态和内核态之间做切换?


因为用户态的进程没有访问磁盘上数据的权限,也没有把数据从网卡发送到网络的权限。只有内核态也就是操作系统才有操作硬件的权限,所以需要系统向用户进程提供相应的接口函数来实现数据的读写。
这里涉及了两个系统接口调用分别是:


read(file, tmp_buf, len);


write(socket, tmp_buf, len);



于是,零拷贝技术应运而生,系统为我们上层应用提供的零拷贝方法方法有下列两种:



  • mmap + write

  • sendfile


MMAP + write


这个方法主要是用 MMAP 替换了 read。
对应的系统方法为:
buf = mmap(file,length)
write(socket,buf,length)
所谓的 MMAP, 其实就是系统内存某段空间和用户内存某段空间保持一致,也就是说应用程序能通过访问用户内存访问系统内存。所以,读取数据的时候,不用通过把系统内存的数据拷贝到用户内存中再读取,而是直接从用户内存读出,这样就减少了一次拷贝。
我们还是先看图:


零拷贝之mmap+write.png
给大家简述一下步骤:



  1. 应用进程通过接口调用系统接口 MMAP,并且进程从用户态切换为内核态。

  2. 系统收到 MMAP 的调用后用 DMA 把数据从磁盘拷贝到系统内存,这时是第1次数据拷贝。由于这段数据在系统内存和应用内存是共享的,数据自然就到了应用内存中,这时程序从内核态切换为用户态。

  3. 程序从应用内存得到数据后,会调用 write 系统接口,这时第2次拷贝开始,具体是把数据拷贝到 Socket 缓存,而且用户态切换为内核态。

  4. 系统通过 DMA 把数据从 Socket 缓存拷贝到网卡。

  5. 最后,进程从内核态切换为用户态。


这样做到收益是减少了一次拷贝但是用户态和内核态仍然是4次切换


sendfile


这个系统方法可以实现系统内部不同设备之间的拷贝。具体逻辑我们还是先上图:


零拷贝之sendfile.png
大家可以看到,使用 sendfile 主要的收益是避免了数据在应用内存和系统内存或socket缓存直接的拷贝,同时这样会避免用户态和内核态之间的切换。
基本原理分为下面几步:



  1. 应用进程调用系统接口 sendfile,进程从用户态切换完内核态。

  2. 系统接收到 sendfile 指令后,通过 DMA 从磁盘把数据拷贝到系统内存。

  3. 数据到了系统内存后,CPU 会把数据从系统内存拷贝到 socket 缓存中。

  4. 通过 DMA 拷贝到网卡中。

  5. 最后,进程从内核态切换为用户态。


但是,这还不是零拷贝,所谓的零拷贝不会在内存层面去拷贝数据,也就是系统内存拷贝到 socket 缓存,下面给大家介绍一下真正的零拷贝。


真正的零拷贝


真正的零拷贝是基于 sendfile,当网卡支持 SG-DMA 时,系统内存的数据可以直接拷贝到网卡。如果这样实现的话,执行流程就会更简单,如下图所示:


真正的零拷贝.png


基本原理分为下面几步:



  1. 应用进程调用系统接口 sendfile,进程从用户态切换完内核态。

  2. 系统接收到 sendfile 指令后,通过 DMA 从磁盘把数据拷贝到系统内存。

  3. 数据到了系统内存后,CPU 会把文件描述符和数据长度返回到 socket 缓存中(注意这里没有拷贝数据)。

  4. 通过 SG-DMA 把数据从系统内存拷贝到网卡中。

  5. 最后,进程从内核态切换为用户态。


零拷贝在用户态和内核态之间的切换是2次,拷贝是2次,大大减少了切换次数和拷贝次数,而且全程没有 CPU 参与数据的拷贝。


零拷贝的重要帮手 PageCache


大家知道,从缓存中读取数据的速度一定要比从磁盘中读取数据的速度要快的多。那么,有没有一种技术能让我们从内存中读取磁盘的数据呢?
PageCache 的目的就是让磁盘的数据缓存化到系统内存。那么,PageCache会选取哪些磁盘数据作为缓存呢?具体步骤是这样的:



  • 首先,当用户进程要求方法哪些磁盘数据时,DMA会把这部分磁盘数据缓存到系统内存里,那么问题又来了,磁盘空间明显比内存空间大的多,不可能不限制的把磁盘的数据拷贝到系统内存中,所以必须要有一个淘汰机制来把访问概率低的内存数据删除掉。

  • 那么,用什么方法淘汰内存的数据呢?答案是 LRU (Least Recently Used),最近最少使用。原理的认为最近很少使用的数据,以后使用到的概率也会很低。


那么,这样就够了吗?我们设想一个场景,我们在操作数据的时候有没有顺序读写的场景?比如说消息队列的顺序读取,我们消费了 id 为1的 message 后,也会消费 id 为2的 message。而消息队列的文件一般是顺序存储的,如果我们事先把 id 为2的 message 读出到系统内存中,那么就会大大加快用户进程读取数据的速度。


这样的功能在现代操作系统中叫预读,也就是说如果你读某个文件的了32 KB 的字节,虽然你这次读取的仅仅是 0 ~ 32 KB 的字节,但系统内核会自动把其后面的 32~64 KB 也读取到 PageCache,如果在 32~64 KB 淘汰出 PageCache 前,用户进程读取到它了,那么就会大大加快数据的读取速度,因为我们是直接在缓存上读出的数据,而不是从磁盘中读取。


作者:肖恩Sean
来源:juejin.cn/post/7236988255073370167
收起阅读 »

聊聊「短信」渠道的设计与实现

有多久,没有发过短信了? 一、背景简介 在常规的分布式架构下,「消息中心」的服务里通常会集成「短信」的渠道,作为信息触达的重要手段,其他常用的手段还包括:「某微」、「某钉」、「邮件」等方式; 对于《消息中心》的设计和实现来说,在前面已经详细的总结过,本文重点...
继续阅读 »

有多久,没有发过短信了?



一、背景简介


在常规的分布式架构下,「消息中心」的服务里通常会集成「短信」的渠道,作为信息触达的重要手段,其他常用的手段还包括:「某微」、「某钉」、「邮件」等方式;


对于《消息中心》的设计和实现来说,在前面已经详细的总结过,本文重点来聊聊消息中心的短信渠道的方式;



短信在实现的逻辑上,也遵循消息中心的基础设计,即消息生产之后,通过消息中心进行投递和消费,属于典型的生产消费模型;


二、渠道方对接


在大部分的系统中,短信功能的实现都依赖第三方的短信推送,之前总结过《三方对接》的经验,这里不再赘述;


但是与常规第三方对接不同的是,短信的渠道通常会对接多个,从而应对各种消息投递的场景,比如常见的「验证码」场景,「通知提醒」场景,「营销推广」场景;



这里需要考虑的核心因素有好几个,比如成本问题,短信平台的稳定性,时效性,触达率,并发能力,需要进行不同场景的综合考量;


验证码:该场景通常是用户和产品的关键交互环节,十分依赖短信的时效性和稳定性,如果出问题直接影响用户体验;


通知提醒:该场景同样与业务联系密切,但是相对来说对短信触达的时效性依赖并不高,只要在一定的时间范围内最终触达用户即可;


营销推广:该场景的数据量比较大,并且从实际效果来看,具有很大的不确定性,会对短信渠道的成本和并发能力重点考量;


三、短信渠道


1、流程设计


从整体上来看短信的实现流程,可以分为三段:「1」短信需求的业务场景,「2」消息中心的短信集成能力,「3」对接的第三方短信渠道;



需求场景:在产品体系中,需要用到短信的场景很多,不过最主要的还是对用户方的信息触达,比如身份验证,通知,营销等,其次则是对内的重要消息通知;


消息中心:提供消息发送的统一接口方法,不同业务场景下的消息提交到消息中心,进行统一维护管理,并根据消息的来源和去向,适配相应的推送逻辑,短信只是作为其中的一种方式;


渠道对接:根据具体的需求场景来定,如果只有验证码的对接需求,可以只集成一个渠道,或者从成本方面统筹考虑,对接多个第三方短信渠道,建议设计时考虑一定的可扩展;


2、核心逻辑


单从短信这种方式的管理来看,逻辑复杂度并不算很高,但是很依赖细节的处理,很多不注意的细微点都可能导致推送失败的情况;



实际在整个逻辑中,除了「验证码」功能有时效性依赖之外,其他场景的短信触达都可以选择「MQ队列」进行解耦,在消息中心的设计上,也具备很高的流程复用性,图中只是重点描述短信场景;


3、使用场景


3.1 验证码


对于「短信」功能中的「验证码」场景来说,个人感觉在常规的应用中是最复杂的,这可能会涉及到「账户」和相关「业务」的集成问题;


验证码获取


这个流程相对来说路径还比较简短,只要完成手机号的校验后,按照短信推送逻辑正常执行即可;



这里需要说明的是,为了确保系统的安全性,通常会设定验证码的时效性,并且只能使用一次,但是偶尔可能因为延时问题,引起用户多次申请验证码,基于缓存可以很好的管理这种场景的数据结构;


验证码消费


验证码的使用是非常简单的,现在很多产品在设计上,都弱化了登录和注册的概念,只要通过验证码机制,会默认的新建帐户和执行相关业务流程;



无论是何种业务场景下的「验证码」依赖,在处理流程时都要先校验其「验证码」的正确与否,才能判断流程是否向下执行,在部分敏感的场景中,还会限制验证码的错误次数,防止出现账户安全问题;


3.2 短信触达


无论是「通知提醒」还是「营销推广」,其本质上是追求信息的最终触达即可,大部分短信运营商都可以提供这种能力,只是系统内部的处理方式有很大差异;



在部分业务流程中,需要向用户投递短信消息,在营销推广的需求中,更多的是批量发送短信,部分需求其内部逻辑上,还可能存在一个转化率统计的问题,需要监控相关短信的交互状态;


四、模型设计


由于短信是集成在消息中心的服务中,其相关的数据结构模型都是复用消息管理的,具体细节描述,参考《消息中心》的内容即可,此处不赘述;



从技术角度来看的话,涉及经典的生产消费模型,第三方平台对接,任务和状态机管理等,消息中心作为分布式架构的基础服务,在设计上还要考虑一定的复用性。


五、参考源码


编程文档:
https://gitee.com/cicadasmile/butte-java-note

应用仓库:
https:/
/gitee.com/cicadasmile/butte-flyer-parent

作者:知了一笑
来源:juejin.cn/post/7237082256480649271
收起阅读 »

iOS H5页面秒加载预研

背景 原生架构+H5页面的组合是很常见的项目开发模式了,H5的优势是跨平台、开发快、迭代快、热更新,很多大厂的App大部分业务代码都是H5来实现的,众所周知H5页面的体验是比原生差的,特别是网络环境差的时候,如果首屏页面是H5的话,那酸爽遇见过的都懂 白屏警告...
继续阅读 »

背景


原生架构+H5页面的组合是很常见的项目开发模式了,H5的优势是跨平台、开发快、迭代快、热更新,很多大厂的App大部分业务代码都是H5来实现的,众所周知H5页面的体验是比原生差的,特别是网络环境差的时候,如果首屏页面是H5的话,那酸爽遇见过的都懂


白屏警告




白屏主要就是下载页面资源失败或者慢导致的,下面可以看下H5加载过程


H5加载过程


这部分内容其实网上已经很多了,如下


初始化 webview -> 请求页面 -> 下载数据 -> 解析HTML -> 请求 js/css 资源 -> dom 渲染 -> 解析 JS 执行 -> JS 请求数据 -> 解析渲染 -> 下载渲染图片


主要要优化的就是下载到渲染这段时间,最简单的方案就是把整个web包放在App本地,通过本地路径去加载,做到这一步,再配合上H5页面做加载中占位图,基本上就不会有白屏的情况了,而且速度也有了很明显的提升


可参考以下测试数据(网络地址加载和本地路径加载)


WiFi网络打开H5页面100次耗时:(代表网络优秀时)


本地加载平均执行耗时:0.28秒


网络加载平均执行耗时:0.58秒


4G/5G手机网络打开H5页面100次耗时:(代表网络良好时)


本地加载平均执行耗时:0.43秒


网络加载平均执行耗时:2.09秒


3G手机网络打开H5页面100次耗时:(代表网络一般或者较差时)


本地加载平均执行耗时:1.48秒


网络加载平均执行耗时:19.09秒


ok,恭喜你,H5离线秒加载功能优化完毕,这么简单?


IMG_4941.JPG


进入正题


通过上述实现本地加载后速度虽然是快了不少,但H5页面的数据显示还是需要走接口请求的,如果网络环境不好的话,也是会很慢的,这个问题怎么解决呢?


下面开始上绝活,这里引入一个概念 WKURLSchemeHandler
在iOS11及以上系统中,可以通过WKURLSchemeHandler自定义拦截请求,有什么用?简单说就是可以利用原生的数据缓存去加载H5页面,可以无视网络环境的影响,在拦截到H5页面的网络请求后先判断本地是否有缓存,有缓存的话可以直接拼接一个成功的返回,没有的话直接放开继续走网络请求,成功后再缓存数据,对H5来说也是无侵入无感知的。


首先自定义一个SchemeHandler类,遵守WKURLSchemeHandler协议,实现协议方法


@protocol WKURLSchemeHandler <NSObject>

/*! @abstract Notifies your app to start loading the data for a particular resource
represented by the URL scheme handler task.
@param webView The web view invoking the method.
@param urlSchemeTask The task that your app should start loading data for.
*/

- (void)webView:(WKWebView *)webView startURLSchemeTask:(id <WKURLSchemeTask>)urlSchemeTask;

/*! @abstract Notifies your app to stop handling a URL scheme handler task.
@param webView The web view invoking the method.
@param urlSchemeTask The task that your app should stop handling.
@discussion After your app is told to stop loading data for a URL scheme handler task
it must not perform any callbacks for that task.
An exception will be thrown if any callbacks are made on the URL scheme handler task
after your app has been told to stop loading for it.
*/

- (void)webView:(WKWebView *)webView stopURLSchemeTask:(id <WKURLSchemeTask>)urlSchemeTask;

@end

直接上代码


#import <Foundation/Foundation.h>
#import <WebKit/WebKit.h>

NS_ASSUME_NONNULL_BEGIN

@interface YXWKURLSchemeHandler : NSObject<WKURLSchemeHandler>

@end

NS_ASSUME_NONNULL_END

#import "CMWKURLSchemeHandler.h"
#import "YXNetworkManager.h"
#import <SDWebImage/SDWebImageManager.h>
#import <SDWebImage/SDImageCache.h>

@implementation YXWKURLSchemeHandler

- (void) webView:(WKWebView *)webView startURLSchemeTask:(id<WKURLSchemeTask>)urlSchemeTask {
NSURLRequest * request = urlSchemeTask.request;
NSString * urlStr = request.URL.absoluteString;
NSString * method = urlSchemeTask.request.HTTPMethod;
NSData * bodyData = urlSchemeTask.request.HTTPBody;
NSDictionary * bodyDict = nil;

if (bodyData) {
bodyDict = [NSJSONSerialization JSONObjectWithData:bodyData options:kNilOptions error:nil];
}

NSLog(@"拦截urlStr=%@", urlStr);
NSLog(@"拦截method=%@", method);
NSLog(@"拦截bodyData=%@", bodyData);

// 图片加载
if ([urlStr hasSuffix:@".jpg"] || [urlStr hasSuffix:@".png"] || [urlStr hasSuffix:@".gif"]) {
SDImageCache * imageCache = [SDImageCache sharedImageCache];
NSString * cacheKey = [[SDWebImageManager sharedManager] cacheKeyForURL:request.URL];
BOOL isExist = [imageCache diskImageDataExistsWithKey:cacheKey];
if (isExist) {
NSData * imgData = [[SDImageCache sharedImageCache] diskImageDataForKey:cacheKey];
[urlSchemeTask didReceiveResponse:[[NSURLResponse alloc] initWithURL:request.URL MIMEType:[self createMIMETypeForExtension:[urlStr pathExtension]] expectedContentLength:-1 textEncodingName:nil]];
[urlSchemeTask didReceiveData:imgData];
[urlSchemeTask didFinish];
return;
}
[[SDWebImageManager sharedManager] loadImageWithURL:request.URL options:SDWebImageRetryFailed progress:nil completed:^(UIImage * _Nullable image, NSData * _Nullable data, NSError * _Nullable error, SDImageCacheType cacheType, BOOL finished, NSURL * _Nullable imageURL) {}];
}

// 网络请求
NSData * cachedData = (NSData *)[YXNetworkCache httpCacheForURL:urlStr parameters:bodyDict];
if (cachedData) {
NSHTTPURLResponse * response = [self createHTTPURLResponseForRequest:urlSchemeTask.request];
[urlSchemeTask didReceiveResponse:response];
[urlSchemeTask didReceiveData:cachedData];
[urlSchemeTask didFinish];
} else {
NSURLSessionDataTask * dataTask = [[NSURLSession sharedSession] dataTaskWithRequest:request completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
if (error) {
[urlSchemeTask didFailWithError:error];
} else {
[YXNetworkCache setHttpCache:data URL:urlStr parameters:bodyDict];
[urlSchemeTask didReceiveResponse:response];
[urlSchemeTask didReceiveData:data];
[urlSchemeTask didFinish];
}
}];
[dataTask resume];
}
} /* webView */

- (NSHTTPURLResponse *) createHTTPURLResponseForRequest:(NSURLRequest *)request {
// Determine the content type based on the request
NSString * contentType;

if ([request.URL.pathExtension isEqualToString:@"css"]) {
contentType = @"text/css";
} else if ([[request valueForHTTPHeaderField:@"Accept"] isEqualToString:@"application/javascript"]) {
contentType = @"application/javascript;charset=UTF-8";
} else {
contentType = @"text/html;charset=UTF-8"; // default content type
}

// Create the HTTP URL response with the dynamic content type
NSHTTPURLResponse * response = [[NSHTTPURLResponse alloc] initWithURL:request.URL statusCode:200 HTTPVersion:@"HTTP/1.1" headerFields:@{ @"Content-Type": contentType }];

return response;
}

- (NSString *) createMIMETypeForExtension:(NSString *)extension {
if (!extension || extension.length == 0) {
return @"";
}

NSDictionary * MIMEDict = @{
@"txt" : @"text/plain",
@"html" : @"text/html",
@"htm" : @"text/html",
@"css" : @"text/css",
@"js" : @"application/javascript",
@"json" : @"application/json",
@"xml" : @"application/xml",
@"swf" : @"application/x-shockwave-flash",
@"flv" : @"video/x-flv",
@"png" : @"image/png",
@"jpg" : @"image/jpeg",
@"jpeg" : @"image/jpeg",
@"gif" : @"image/gif",
@"bmp" : @"image/bmp",
@"ico" : @"image/vnd.microsoft.icon",
@"woff" : @"application/x-font-woff",
@"woff2": @"application/x-font-woff",
@"ttf" : @"application/x-font-ttf",
@"otf" : @"application/x-font-opentype"
};

NSString * MIMEType = MIMEDict[extension.lowercaseString];
if (!MIMEType) {
return @"";
}

return MIMEType;
} /* MIMETypeForExtension */

- (void) webView:(WKWebView *)webView stopURLSchemeTask:(id<WKURLSchemeTask>)urlSchemeTask {
NSLog(@"stop = %@", urlSchemeTask);
}

@end

重点来了,需要hook WKWebview 的 handlesURLScheme 方法来支持 http 和 https 请求的代理


直接上代码


#import "WKWebView+SchemeHandle.h"
#import <objc/runtime.h>

@implementation WKWebView (SchemeHandle)

+ (void) load {
static dispatch_once_t onceToken;

dispatch_once(&onceToken, ^{
Method originalMethod1 = class_getClassMethod(self, @selector(handlesURLScheme:));
Method swizzledMethod1 = class_getClassMethod(self, @selector(cmHandlesURLScheme:));
method_exchangeImplementations(originalMethod1, swizzledMethod1);
});
}

+ (BOOL) cmHandlesURLScheme:(NSString *)urlScheme {
if ([urlScheme isEqualToString:@"http"] || [urlScheme isEqualToString:@"https"]) {
return NO;
} else {
return [self handlesURLScheme:urlScheme];
}
}

@end

这里会有一个疑问了?为什么要这么用呢,这里主要是为了满足对H5端无侵入、无感知的要求
如果不hook http和https的话,就需要在H5端修改代码了,把scheme修改成自定义的customScheme,全部都要改,而且对安卓还不适用,所以别这么搞,信我!!!


老老实实hook,一步到位


如何使用


首先是WKWebView的初始化,直接上代码


- (WKWebView *) wkWebView {
if (!_wkWebView) {
WKWebViewConfiguration * config = [[WKWebViewConfiguration alloc] init];
// 允许跨域访问
[config setValue:@(true) forKey:@"allowUniversalAccessFromFileURLs"];

// 自定义HTTPS请求拦截
YXWKURLSchemeHandler * handler = [YXWKURLSchemeHandler new];
[config setURLSchemeHandler:handler forURLScheme:@"https"];

_wkWebView = [[WKWebView alloc] initWithFrame:CGRectMake(0, 0, kScreenWidth, kScreenHeight) configuration:config];
_wkWebView.navigationDelegate = self;
}
return _wkWebView;
} /* wkWebView */

NSString * htmlPath = [[NSBundle mainBundle] pathForResource:@"index" ofType:@"html" inDirectory:@"H5"];
NSURL * fileURL = [NSURL fileURLWithPath:htmlPath];
NSURLRequest * request = [NSURLRequest requestWithURL:url];
[self.wkWebView loadRequest:request];

ok,到了这一步基本上就能看到效果了,H5页面的接口请求和图片加载都会拦截到,直接使用原生的缓存数据,忽略网络环境的影响实现秒加载了


可参考以下测试数据(自定义WKURLSchemeHandler拦截和不拦截)


3G手机网络打开H5页面100次耗时:(代表网络一般或者较差时)

Figure_1.png


拦截后加载平均执行耗时:0.24秒


不拦截加载平均执行耗时:1.09秒


可以发现通过自定义WKURLSchemeHandler拦截后,加载速度非常平稳根本不受网络的影响,不拦截的话虽然整体加载速度并不算太慢,但是随网络波动比较明显。


不足


这套方案也还是有一些缺点的,比如



  1. App打包的时候需要预嵌入web模块的数据,会导致App包的大小增加(需要尽量缩小web模块的包大小,特别是资源文件的优化)

  2. App需要设计一套web模块包的更新机制,同时需要设计一个web包上传发布平台,后续版本管理更新比之前直接上传服务器替换相对麻烦一些

  3. 需要更新时下载全量web模块包会略大,也可以考虑用BSDiff差分算法来做增量更新解决,但是会增加程序复杂度


总结


综上就是本次H5页面秒加载全部的预研过程了,总的来说,基本上可以满足秒加载的需求。没有完美的解决方案,只有合适的方案,有舍有得,根据公司项目情况来。


作者:风轻云淡的搬砖
来源:juejin.cn/post/7236281103221096505
收起阅读 »

详解越权漏洞

1.1. 漏洞原理 越权漏洞是指应用程序未对当前用户操作的身份权限进行严格校验,导致用户可以操作超出自己管理权限范围的功能,从而操作一些非该用户可以操作的行为。简单来说,就是攻击者可以做一些本来不该他们做的事情(增删改查)。 1.2. 漏洞分类 主要分为 水...
继续阅读 »

1.1. 漏洞原理


越权漏洞是指应用程序未对当前用户操作的身份权限进行严格校验,导致用户可以操作超出自己管理权限范围的功能,从而操作一些非该用户可以操作的行为。简单来说,就是攻击者可以做一些本来不该他们做的事情(增删改查)


IDOR


1.2. 漏洞分类


主要分为 水平越权垂直越权 两大类


1.2.1. 水平越权


发生在具有相同权限级别的用户之间。攻击者通过利用这些漏洞,访问其他用户拥有的资源或执行与其权限级别不符的操作。


1.2.2. 垂直越权


发生在具有多个权限级别的系统中。攻击者通过利用这些漏洞,从一个低权限级别跳转到一个更高的权限级别。例如,攻击者从普通用户身份成功跃迁为管理员。


1.3. 漏洞举例


1.3.1. 水平越权


假设一个在线论坛应用程序,每个用户都有一个唯一的用户ID,并且用户可以通过URL访问他们自己的帖子。应用程序的某个页面的URL结构如下:


https://example.com/forum/posts?userId=<用户ID>

应用程序使用userId参数来标识要显示的用户的帖子。假设Alice的用户ID为1,Bob的用户ID为2。


Alice可以通过以下URL访问她自己的帖子:


https://example.com/forum/posts?userId=1

现在,如果Bob意识到URL参数是可变的,他可能尝试修改URL参数来访问Alice的帖子。他将尝试将URL参数修改为Alice的用户ID(1):


https://example.com/forum/posts?userId=1

如果应用程序没有正确实施访问控制机制,没有验证用户的身份和权限,那么Bob将成功地通过URL参数访问到Alice的帖子。


1.3.2. 垂直越权


假设一个电子商务网站,有两种用户角色:普通用户和管理员。普通用户有限的权限,只能查看和购买商品,而管理员则拥有更高的权限,可以添加、编辑和删除商品。


在正常情况下,只有管理员可以访问和执行与商品管理相关的操作。然而,如果应用程序没有正确实施访问控制和权限验证,那么普通用户可能尝试利用垂直越权漏洞提升为管理员角色,并执行未经授权的操作。


例如,普通用户Alice可能意识到应用程序的URL结构如下:


https://example.com/admin/manage-products

她可能尝试手动修改URL,将自己的用户角色从普通用户更改为管理员,如下所示:


https://example.com/admin/manage-products?role=admin

如果应用程序没有进行足够的验证和授权检查,就会错误地将Alice的角色更改为管理员,从而使她能够访问和执行与商品管理相关的操作。


1.4. 漏洞危害


具体以实际越权的功能为主,大多危害如下:



  1. 数据泄露:攻击者可以通过越权访问敏感数据,如个人信息、财务数据或其他敏感业务数据。这可能导致违反隐私法规、信用卡信息泄露或个人身份盗用等问题。

  2. 权限提升:攻击者可能利用越权漏洞提升其权限级别,获得系统管理员或其他高权限用户的特权。这可能导致对整个系统的完全控制,并进行更广泛的恶意活动。


1.5. 修复建议



  1. 实施严格的访问控制:确保在应用程序的各个层面上实施适当的访问控制机制,包括身份验证、会话管理和授权策略。对用户进行适当的身份验证和授权,仅允许其执行其所需的操作。

  2. 验证用户输入:应该对所有用户输入进行严格的验证和过滤,以防止攻击者通过构造恶意输入来利用越权漏洞。特别是对于涉及访问控制的操作,必须仔细验证用户请求的合法性。

  3. 最小权限原则:在分配用户权限时,采用最小权限原则,即给予用户所需的最低权限级别,以限制潜在的越权行为。用户只应具备完成其任务所需的最小权限。

  4. 安全审计和监控:建立安全审计和监控机制,对系统中的访问活动进行监视和记录。这可以帮助检测和响应越权行为,并提供对事件的审计跟踪。


作者:初始安全
来源:juejin.cn/post/7235801811525664825
收起阅读 »

Java常用JVM参数实战

在Java应用程序的部署和调优过程中,合理配置JVM参数是提升性能和稳定性的关键之一。本文将介绍一些常用的JVM参数,并给出具体的使用例子和作用的分析。 内存管理相关参数 -Xmx和-Xms -Xmx参数用于设置JVM的最大堆内存大小,而-Xms参数用于设置J...
继续阅读 »

在Java应用程序的部署和调优过程中,合理配置JVM参数是提升性能和稳定性的关键之一。本文将介绍一些常用的JVM参数,并给出具体的使用例子和作用的分析。


内存管理相关参数


-Xmx和-Xms


-Xmx参数用于设置JVM的最大堆内存大小,而-Xms参数用于设置JVM的初始堆内存大小。这两个参数可以在启动时通过命令行进行配置,例如:


java -Xmx2g -Xms512m MyApp

上述示例将JVM的最大堆内存设置为2GB,初始堆内存设置为512MB。


作用分析:



  • 较大的最大堆内存可以增加应用程序的可用内存空间,提高性能。但也需要考虑服务器硬件资源的限制。

  • 合理设置初始堆内存大小可以减少JVM的自动扩容和收缩开销。


-XX:NewRatio和-XX:SurvivorRatio


-XX:NewRatio参数用于设置新生代与老年代的比例,默认值为2。而-XX:SurvivorRatio参数用于设置Eden区与Survivor区的比例,默认值为8。


例如,我们可以使用以下参数配置:


java -XX:NewRatio=3 -XX:SurvivorRatio=4 MyApp

作用分析:



  • 调整新生代与老年代的比例可以根据应用程序的特点来优化内存分配。

  • 调整Eden区与Survivor区的比例可以控制对象在新生代中的存活时间。


-XX:MaxMetaspaceSize


在Java 8及之后的版本中,-XX:MaxMetaspaceSize参数用于设置元空间(Metaspace)的最大大小。例如:


java -XX:MaxMetaspaceSize=512m MyApp

作用分析:



  • 元空间用于存储类的元数据信息,包括类的结构、方法、字段等。

  • 调整元空间的最大大小可以避免元空间溢出的问题,提高应用程序的稳定性。


-Xmn


-Xmn参数用于设置新生代的大小。以下是一个例子:


java -Xmn256m MyApp


  • -Xmn256m将新生代的大小设置为256MB。


作用分析:



  • 新生代主要存放新创建的对象,设置合适的大小可以提高垃圾回收的效率。


垃圾回收相关参数


-XX:+UseG1GC


-XX:+UseG1GC参数用于启用G1垃圾回收器。例如:


java -XX:+UseG1GC MyApp

作用分析:



  • G1垃圾回收器是Java 9及之后版本的默认垃圾回收器,具有更好的垃圾回收性能和可预测的暂停时间。

  • 使用G1垃圾回收器可以减少垃圾回收的停顿时间,提高应用程序的吞吐量。


-XX:ParallelGCThreads和-XX:ConcGCThreads


-XX:ParallelGCThreads参数用于设置并行垃圾回收器的线程数量,而-XX:ConcGCThreads参数用于设置并发垃圾回收器的线程数量。例如:


java -XX:ParallelGCThreads=4 -XX:ConcGCThreads=2 MyApp

作用分析:



  • 并行垃圾回收器通过使用多个线程来并行执行垃圾回收操作,提高回收效率。

  • 并发垃圾回收器在应用程序运行的同时执行垃圾回收操作,减少停顿时间。


-XX:+ExplicitGCInvokesConcurrent


-XX:+ExplicitGCInvokesConcurrent参数用于允许主动触发并发垃圾回收。例如:


java -XX:+ExplicitGCInvokesConcurrent MyApp

作用分析:



  • 默认情况下,当调用System.gc()方法时,JVM会使用串行垃圾回收器执行垃圾回收操作。使用该参数可以改为使用并发垃圾回收器执行垃圾回收操作,减少停顿时间。


性能监控和调优参数


-XX:+PrintGCDetails和-XX:+PrintGCDateStamps


-XX:+PrintGCDetails参数用于打印详细的垃圾回收信息,-XX:+PrintGCDateStamps参数用于打印垃圾回收发生的时间戳。例如:


java -XX:+PrintGCDetails -XX:+PrintGCDateStamps MyApp

作用分析:



  • 打印垃圾回收的详细信息可以帮助我们了解垃圾回收器的工作情况,检测潜在的性能问题。

  • 打印垃圾回收发生的时间戳可以帮助我们分析应用程序的垃圾回收模式和频率。


-XX:+HeapDumpOnOutOfMemoryError和-XX:HeapDumpPath


-XX:+HeapDumpOnOutOfMemoryError参数用于在发生内存溢出错误时生成堆转储文件,-XX:HeapDumpPath参数用于指定堆转储文件的路径。例如:


java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/to/dump/file MyApp

作用分析:



  • 在发生内存溢出错误时生成堆转储文件可以帮助我们分析应用程序的内存使用情况,定位内存泄漏和性能瓶颈。


-XX:ThreadStackSize


-XX:ThreadStackSize参数用于设置线程栈的大小。以下是一个例子:


java -XX:ThreadStackSize=256k MyApp

作用分析:



  • 线程栈用于存储线程执行时的方法调用和局部变量等信息。

  • 通过调整线程栈的大小,可以控制应用程序中线程的数量和资源消耗。


-XX:MaxDirectMemorySize


-XX:MaxDirectMemorySize参数用于设置直接内存的最大大小。以下是一个例子:


java -XX:MaxDirectMemorySize=1g MyApp

作用分析:



  • 直接内存是Java堆外的内存,由ByteBuffer等类使用。

  • 合理设置直接内存的最大大小可以避免直接内存溢出的问题,提高应用程序的稳定性。


其他参数


除了上述介绍的常用JVM参数,还有一些其他参数可以根据具体需求进行配置,如:



  • -XX:+DisableExplicitGC:禁止主动调用System.gc()方法。

  • -XX:+UseCompressedOops:启用指针压缩以减小对象引用的内存占用。

  • -XX:OnOutOfMemoryError:在发生OutOfMemoryError时执行特定的命令或脚本。


这些参数可以根据应用程序的特点和需求进行调优和配置,以提升应用程序的性能和稳定性。


总结


本文介绍了一些常用的JVM参数,并给出了具体的使用例子和作用分析。合理配置这些参数可以优化内存管理、垃圾回收、性能监控等方面,提升Java应用程序的性能和稳定性。


在实际应用中,建议根据应用程序的需求和性能特点,综合考虑不同参数的使用。同时,使用工具进行性能监控和分析,以找出潜在的问题和瓶颈,并根据实际情况进行调优。



我是蚂蚁背大象,文章对你有帮助给项目点个❤关注我GitHub:mxsm,文章有不正确的地方请您斧正,创建ISSUE提交PR~谢谢!


作者:蚂蚁背大象
来源:juejin.cn/post/7235435351049781304

收起阅读 »

从前后端的角度分析options预检请求

本文分享自华为云社区《从前后端的角度分析options预检请求——打破前后端联调的理解障碍》,作者: 砖业洋__ 。 options预检请求是干嘛的?options请求一定会在post请求之前发送吗?前端或者后端开发需要手动干预这个预检请求吗?不用文档定义堆砌...
继续阅读 »

本文分享自华为云社区《从前后端的角度分析options预检请求——打破前后端联调的理解障碍》,作者: 砖业洋__ 。


options预检请求是干嘛的?options请求一定会在post请求之前发送吗?前端或者后端开发需要手动干预这个预检请求吗?不用文档定义堆砌名词,从前后端角度单独分析,大白话带你了解!


从前端的角度看options——post请求之前一定会有options请求?信口雌黄!


你是否经常看到这种跨域请求错误?


image.png


这是因为服务器不允许跨域请求,这里会深入讲一讲OPTIONS请求。


只有在满足一定条件的跨域请求中,浏览器才会发送OPTIONS请求(预检请求)。这些请求被称为“非简单请求”。反之,如果一个跨域请求被认为是“简单请求”,那么浏览器将不会发送OPTIONS请求。


简单请求需要满足以下条件:



  1. 只使用以下HTTP方法之一:GETHEADPOST

  2. 只使用以下HTTP头部:AcceptAccept-LanguageContent-LanguageContent-Type

  3. Content-Type的值仅限于:application/x-www-form-urlencodedmultipart/form-datatext/plain


如果一个跨域请求不满足以上所有条件,那么它被认为是非简单请求。对于非简单请求,浏览器会在实际请求(例如PUTDELETEPATCH或具有自定义头部和其他Content-TypePOST请求)之前发送OPTIONS请求(预检请求)。


举个例子吧,口嗨半天是看不懂的,让我们看看 POST请求在什么情况下不发送OPTIONS请求


提示:当一个跨域POST请求满足简单请求条件时,浏览器不会发送OPTIONS请求(预检请求)。以下是一个满足简单请求条件的POST请求示例:


// 使用Fetch API发送跨域POST请求
fetch("https://example.com/api/data", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
body: "key1=value1&key2=value2"
})
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error("Error:", error));

在这个示例中,我们使用Fetch API发送了一个跨域POST请求。请求满足以下简单请求条件:



  1. 使用POST方法。

  2. 使用的HTTP头部仅包括Content-Type

  3. Content-Type的值为"application/x-www-form-urlencoded",属于允许的三种类型之一(application/x-www-form-urlencoded、multipart/form-data或text/plain)。


因为这个请求满足了简单请求条件,所以浏览器不会发送OPTIONS请求(预检请求)。


我们再看看什么情况下POST请求之前会发送OPTIONS请求,同样用代码说明,进行对比


提示:在跨域请求中,如果POST请求不满足简单请求条件,浏览器会在实际POST请求之前发送OPTIONS请求(预检请求)。


// 使用Fetch API发送跨域POST请求
fetch("https://example.com/api/data", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Custom-Header": "custom-value"
},
body: JSON.stringify({
key1: "value1",
key2: "value2"
})
})
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error("Error:", error));

在这个示例中,我们使用Fetch API发送了一个跨域POST请求。请求不满足简单请求条件,因为:



  1. 使用了非允许范围内的Content-Type值("application/json" 不属于 application/x-www-form-urlencodedmultipart/form-datatext/plain)。

  2. 使用了一个自定义HTTP头部 “X-Custom-Header”,这不在允许的头部列表中。


因为这个请求不满足简单请求条件,所以在实际POST请求之前,浏览器会发送OPTIONS请求(预检请求)。


你可以按F12直接在Console输入查看Network,尽管这个网址不存在,但是不影响观察OPTIONS请求,对比一下我这两个例子。


总结:当进行非简单跨域POST请求时,浏览器会在实际POST请求之前发送OPTIONS预检请求,询问服务器是否允许跨域POST请求。如果服务器不允许跨域请求,浏览器控制台会显示跨域错误提示。如果服务器允许跨域请求,那么浏览器会继续发送实际的POST请求。而对于满足简单请求条件的跨域POST请求,浏览器不会发送OPTIONS预检请求。


后端可以通过设置Access-Control-Max-Age来控制OPTIONS请求的发送频率。OPTIONS请求没有响应数据(response data),这是因为OPTIONS请求的目的是为了获取服务器对于跨域请求的配置信息(如允许的请求方法、允许的请求头部等),而不是为了获取实际的业务数据,OPTIONS请求不会命中后端某个接口。因此,当服务器返回OPTIONS响应时,响应中主要包含跨域配置信息,而不会包含实际的业务数据


本地调试一下,前端发送POST请求,后端在POST方法里面打断点调试时,也不会阻碍OPTIONS请求的返回


image.png


2.从后端的角度看options——post请求之前一定会有options请求?胡说八道!


在配置跨域时,服务器需要处理OPTIONS请求,以便在响应头中返回跨域配置信息。这个过程通常是由服务器的跨域中间件(Node.jsExpress框架的cors中间件、PythonFlask框架的flask_cors扩展)或过滤器(JavaSpringBoot框架的跨域过滤器)自动完成的,而无需开发人员手动处理。


以下是使用Spring Boot的一个跨域过滤器,供参考


import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;

@Configuration
public class CorsConfig {

public CorsConfig() {
}

@Bean
public CorsFilter corsFilter()
{
// 1. 添加cors配置信息
CorsConfiguration config = new CorsConfiguration();
// Response Headers里面的Access-Control-Allow-Origin: http://localhost:8080
config.addAllowedOrigin("http://localhost:8080");
// 其实不建议使用*,允许所有跨域
config.addAllowedOrigin("*");

// 设置是否发送cookie信息,在前端也可以设置axios.defaults.withCredentials = true;表示发送Cookie,
// 跨域请求要想带上cookie,必须要请求属性withCredentials=true,这是浏览器的同源策略导致的问题:不允许JS访问跨域的Cookie
/**
* withCredentials前后端都要设置,后端是setAllowCredentials来设置
* 如果后端设置为false而前端设置为true,前端带cookie就会报错
* 如果后端为true,前端为false,那么后端拿不到前端的cookie,cookie数组为null
* 前后端都设置withCredentials为true,表示允许前端传递cookie到后端。
* 前后端都为false,前端不会传递cookie到服务端,后端也不接受cookie
*/

// Response Headers里面的Access-Control-Allow-Credentials: true
config.setAllowCredentials(true);

// 设置允许请求的方式,比如get、post、put、delete,*表示全部
// Response Headers里面的Access-Control-Allow-Methods属性
config.addAllowedMethod("*");

// 设置允许的header
// Response Headers里面的Access-Control-Allow-Headers属性,这里是Access-Control-Allow-Headers: content-type, headeruserid, headerusertoken
config.addAllowedHeader("*");
// Response Headers里面的Access-Control-Max-Age:3600
// 表示下回同一个接口post请求,在3600s之内不会发送options请求,不管post请求成功还是失败,3600s之内不会再发送options请求
// 如果不设置这个,那么每次post请求之前必定有options请求
config.setMaxAge(3600L);
// 2. 为url添加映射路径
UrlBasedCorsConfigurationSource corsSource = new UrlBasedCorsConfigurationSource();
// /**表示该config适用于所有路由
corsSource.registerCorsConfiguration("/**", config);

// 3. 返回重新定义好的corsSource
return new CorsFilter(corsSource);
}
}


这里setMaxAge方法来设置预检请求(OPTIONS请求)的有效期,当浏览器第一次发送非简单的跨域POST请求时,它会先发送一个OPTIONS请求。如果服务器允许跨域,并且设置了Access-Control-Max-Age头(设置了setMaxAge方法),那么浏览器会缓存这个预检请求的结果。在Access-Control-Max-Age头指定的时间范围内,浏览器不会再次发送OPTIONS请求,而是直接发送实际的POST请求,不管POST请求成功还是失败,在设置的时间范围内,同一个接口请求是绝对不会再次发送OPTIONS请求的。


后端需要注意的是,我这里设置允许请求的方法是config.addAllowedMethod("*")*表示允许所有HTTP请求方法。如果未设置,则默认只允许“GET”和“HEAD”。你可以设置的HTTPMethodGET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, TRACE


经过我的测试,OPTIONS无需手动设置,因为单纯只设置OPTIONS也无效。如果你设置了允许POST,代码为config.addAllowedMethod(HttpMethod.POST); 那么其实已经默认允许了OPTIONS,如果你只允许了GET,尝试发送POST请求就会报错。


举个例子,这里只允许了GET请求,当我们尝试发送一个POST非简单请求,预检请求返回403,服务器拒绝了OPTIONS类型的请求,因为你只允许了GET,未配置允许OPTIONS请求,那么浏览器将收到一个403 Forbidden响应,表示服务器拒绝了该OPTIONS请求,POST请求的状态显示CORS error



Spring Boot中,配置允许某个请求方法(如POSTPUTDELETE)时,OPTIONS请求通常会被自动允许。这意味着在大多数情况下,后端开发人员不需要特意考虑OPTIONS请求。这种自动允许OPTIONS请求的行为取决于使用的跨域处理库或配置,最好还是显式地允许OPTIONS请求。


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


作者:华为云开发者联盟
来源:juejin.cn/post/7233587643724234811
收起阅读 »

学了设计模式,我重构了原来写的垃圾代码

前言 最近笔者学习了一些设计模式,都记录在我的专栏 前端要掌握的设计模式 中,感兴趣的掘友可以移步看看。本着 学东西不能停留在用眼睛看,要动手实践 的理念,笔者今天带来的是一篇关于代码逻辑重构的文章,将学到的东西充分运用到实际的项目中。 重构代码的背景 要重构...
继续阅读 »

前言


最近笔者学习了一些设计模式,都记录在我的专栏 前端要掌握的设计模式 中,感兴趣的掘友可以移步看看。本着 学东西不能停留在用眼睛看,要动手实践 的理念,笔者今天带来的是一篇关于代码逻辑重构的文章,将学到的东西充分运用到实际的项目中。


重构代码的背景


要重构的代码是之前笔者的一篇文章——我是怎么开发一个Babel插件来实现项目需求的?,大概的逻辑就是实现 JS 代码的一些转换需求:



  1. 去掉箭头函数的第一个参数(如果是ctx(ctx, argu1) => {}转换为(argu1) => {}

  2. 函数调用加上this.: sss(ctx) 转换为 this.sss()

  3. ctx.get('one').$xxx() 转换为 this.$xxxOne()

  4. const crud = ctx.get('two'); crud.$xxx();转换为this.$xxxTwo()


  5. /**
    * 处理批量的按钮显示隐藏
    * ctx.setVisible('code1,code2,code3', true)
    * 转化为
    * this.$refs.code1.setVisible(true)
    * this.$refs.code2.setVisible(true)
    * this.$refs.code3.setVisible(true)
    */


  6. 函数调用把部分 API 第一参数为ctx的变为arguments

  7. 函数调用去掉第一个参数(如果是ctx

  8. 函数声明去掉第一个参数(如果是ctx

  9. 普通函数 转为 () => {}

  10. 标识符ctx 转为 this

  11. ctx.data 转为 this

  12. xp.message(options) 转换为 this.$message(options)

  13. const obj = { get() {} } 转化为 const obj = { get: () => {} }


具体的实现可参考之前的文章,本文主要分享一下重构的实现。


重构前


所有的逻辑全写在一个 JS 文件中:
image.png
还有一段逻辑很长:
image.png


为啥要重构?


虽然主体部分被我折叠起来了,依然可以看到上面截图的代码存在很多问题,而且主体内容只会比这更糟:



  1. 难以维护,虽然写了注释,但是让别人来改根本看不明白,改不动,甚至不想看

  2. 如果新增了转换需求,不得不来改上面的代码,违反开放封闭原则。因为你无法保证你改动的代码不会造成原有逻辑的 bug。

  3. 代码没有章法,乱的一批,里面还有一堆 if/elsetry/catch

  4. 如果某个转换逻辑,我不想启用,按照现有的只能把对应的代码注释,依然是具有破坏性的


基于以上的这些问题,我决定重构一下。


重构后


先来看下重构后的样子:
image.png
统一将代码放到一个新的文件夹code-transform下:



  • transfuncs文件夹用来放具体的转换逻辑

  • util.js中有几个工具函数

  • trans_config.js用于配置transfuncs中转换逻辑是否生效

  • index.js 导出访问者对象 visitor(可以理解为我们根据配置动态组装一个 visitor 出来)


transfuncs下面的文件格式


如下图所示,该文件夹下的每个 JS 文件都默认导出一个函数,是真正的转换逻辑。
image.png
文件名命名规则:js ast树中节点的type_执行转换的大概内容


其余三个文件内容概览


image.png
其中笔者主要说明一下index.js


import config from './trans_config'

const visitor = {}
/**
* 导出获取访问者对象的函数
*/

export function generateVisitorByConfig() {
if (Object.keys(visitor).length !== 0) {
return visitor
}
// 过滤掉 trans_config.js 中不启用的转换规则
const transKeys = Object.keys(config).filter(key => config[key])
// 导入 ./transfuncs 下的转换规则
const context = require.context('./transfuncs', false, /\.js$/)
const types = new Set()
// 统计我们定义的转换函数,是哪些 ast 节点执行转换逻辑
// 别忘了文件名命名规则:js ast树中节点的type_执行转换的大概内容
// 注意去重,因为我们可能在同一种节点类型,会执行多种转换规则。
// 比如 transfuncs 下有多个 CallExpression_ 开头的文件。
context.keys().forEach(path => {
const fileName = path.substring(path.lastIndexOf('/') + 1).replace('.js', '')
const type = fileName.split('_')[0]
types.add(type)
})

const arrTypes = [...types]
// 到此 arrTypes 可能是这样的:
// ['CallExpression', 'FunctionDeclaration', 'MemberExpression', ...]
// 接着遍历每种节点 type

arrTypes.forEach(type => {
const typeFuncs = context.keys()
// 在 transfuncs 文件夹下找出以 对应 type 开头
// 并且 trans_config 中启用了的的文件
.filter(path => path.includes(type) && transKeys.find(key => path.includes(key)))
// 得到文件导出的 function
.map(path => context(path).default)
// 如果 typeFuncs.length > 0,就给 visitor 设置该节点执行的转换逻辑
typeFuncs.length > 0 && (visitor[type] = path => {
typeFuncs.forEach(func => func(path, attribute))
})
})
// 导出 visitor
return visitor
}

最后调用:


import { generateVisitorByConfig } from '../code-transform'

const transed = babel.transform(code, {
presets: ['es2016'],
sourceType: 'module',
plugins: [
{
visitor: generateVisitorByConfig()
}
]
}).code

有些掘友可能对babel的代码转换能力、babel插件不是很了解, 看完可能还处于懵的状态,对此建议各位先去我的上一篇我是怎么开发一个Babel插件来实现项目需求的? 大致看下逻辑,或者阅读一下Babel插件手册,看完之后自然就通了。


总结


到此呢,该部分的代码重构就完成了,能够明显看出:



  1. 文件变多了,但是每个文件做的事情更专一了

  2. 可以很轻松启用、禁用转换规则了,trans_config中配置一下即可,再也不用注释代码了

  3. 可以很轻松的新增转换逻辑,你只需要关注你在哪个节点处理你的逻辑,注意下文件名即可,你甚至不需要关心引入文件,因为会自动引入。

  4. 更容易维护了,就算离职了你的同事也能改的动你的代码,不会骂人了

  5. 逻辑更清晰了

  6. 对个人来说,代码组织能力提升了😃


👊🏼感谢观看!如果对你有帮助,别忘了 点赞 ➕ 评论 + 收藏 哦!


作者:Lvzl
来源:juejin.cn/post/7224205585125556284
收起阅读 »

如何避免旧代码成包袱?5步教你接手别人的系统

👉腾小云导读 老系统的代码,是每一个程序员都不想去触碰的领域,秉着能跑就行的原则,任由其自生自灭。本期就给大家讲讲,接手一套故障频发的复杂老系统需要从哪些地方着手。内容包括:代码串讲、监控建设和告警治理、代码缺陷修复、研发流程建设。在细节上,结合腾讯研发生态,...
继续阅读 »

👉腾小云导读


老系统的代码,是每一个程序员都不想去触碰的领域,秉着能跑就行的原则,任由其自生自灭。本期就给大家讲讲,接手一套故障频发的复杂老系统需要从哪些地方着手。内容包括:代码串讲、监控建设和告警治理、代码缺陷修复、研发流程建设。在细节上,结合腾讯研发生态,介绍有哪些工具可以使用,也介绍一些告警治理、代码 bug 修复的经验、研发流程建设等。欢迎阅读。


👉看目录,点收藏


1 项目背景


2 服务监控


2.1 平台自带监控


2.2 业务定制监控


3 串讲文档


3.1 串讲文档是什么


3.2 为什么需要串讲文档


3.3 怎么输出串讲文档


4 代码质量


4.1 业务逻辑 bug


4.2 防御编程


4.3 Go-python 内存泄露问题


4.4 正确使用外部库


4.5 避免无限重试导致雪崩


4.6 真实初始化


4.7 资源隔离


4.8 数据库压力优化


4.9 互斥资源管理


5 警告治理


5.1 全链路超时时间合理设置


5.2 基于业务分 set 进行资源隔离


5.3 高耗时计算使用线程池


6 研发流程


7 优化效果总结


7.1 健全的 CICD


7.2 更完备的可观测性


7.3 代码 bug 修复


7.4 服务被调成功率优化


7.5 外部存储使用优化


7.6 CPU 用量下降


7.7 代码质量提升


7.8 其他优化效果


01、项目背景


内容架构为 QB 搜索提供内容接入、计算、分发的全套服务。经过多年的快速迭代,内容架构包括 93 个服务,光接入主链路就涉及 7 个服务,支持多种接口类型。其分支定制策略多且散,数据流向混杂,且有众多 bug。


项目组接手这套架构的早期,每天收到大量的业务故障反馈以及服务自身告警。即便投入小组一半的人力做运维支持,依旧忙得焦头烂额。无法根治系统稳定性缺陷的同时,项目组还需要继续承接新业务,而新业务又继续暴露系统缺陷,陷入不断恶化的负循环,漏洞百出。没有人有信心去承诺系统稳定性,团队口碑、开发者的信心都处于崩溃的边缘。


在此严峻的形势下,项目组全员投入启动稳定性治理专项,让团队进入系统稳定的正循环


02、服务监控


监控可以帮助我们掌握服务运行时状态,发现、感知服务异常情况。通过看到问题 - 定位问题 - 修复问题来更快的熟悉模块架构和代码实现细节。下面分两部分介绍,如何利用监控达成稳定性优化。


2.1 平台自带监控


若服务的部署、发布、运行托管于公共平台,则这些平台可能会提供容器资源使用情况的监控,包括:CPU 利用率监控、内存使用率监控、磁盘使用率监控等服务通常应该关注的核心指标。我们的服务部署在123 平台(司内平台),有如下常用的监控。


平台自带监控:


监控类型解析
服务运行监控123 平台的 tRPC 自定义监控:event_alarm 可监控服务异常退出。
节点服务资源使用监控内存使用率监控:检查是否存在内存泄露,会不会不定期 OOM。
磁盘使用率监控:检查日志是否打印的过多,日志滚动配置是否合理。
CPU 使用率监控
如存在定期/不定期的 CPU 毛刺,则检查对应时段请求/定期任务是否实现不合理。如CPU 配额高而使用率低,则检查是配置不合理导致资源浪费,还是服务实现不佳导致 CPU 打不上去。

外部资源平台监控:


数据库连接数监控:检查服务使用 DB 是否全是长连接,使用完没有及时 disconnect 。
数据库慢查询监控:SQL 命令是否不合理,DB 表是否索引设置不合理。数据库 CPU 监控:检查服务是否全部连的 DB 主机,对于只读的场景可选择用只读账号优先读备机,降低 DB 压力。其他诸如腾讯云 Redis 等外部资源也有相关的慢查询监控,亦是对应检查。

2.2 业务定制监控


平台自带的监控让我们掌控服务基本运行状态。我们还需在业务代码中增加业务自定义监控,以掌控业务层面的运转情况。


下面介绍常见的监控完善方法,以让各位对于业务运行状态尽在掌握之中。


2.2.1 在主/被调监控中增加业务错误码


一般来说,后台服务如果无法正常完成业务逻辑,会将错误码和错误详情写入到业务层的回包结构体中,然后在框架层返回成功。


这种实现导致我们无法从平台自带的主/被调监控中直观看出有多少请求是没有正常结果的。一个服务可能看似运行平稳,基于框架层判断的被调成功率 100%,但其中却有大量没有正常返回结果的请求,在我们的监控之外。


此时如果我们想要对这些业务错误做监控,需要上报这些维度:请求来源(主调服务、主调容器、主调 IP、主调 SET)、被调容器、错误码、错误数,这和被调监控有极大重合,存在资源浪费,并且主、被调服务都有开发成本。


若服务框架支持,我们应该尽可能使用框架层状态包来返回业务自定义的错误码。以tRPC框架为例服务的<被调监控 - 返回码>中,会上报框架级错误码和业务错误码:框架错误码是纯数字,业务错误码是<业务协议_业务码>。如此一来,就可以在主/被调服务的平台自带监控中看到业务维度的返回码监控。


2.2.2 在主/被调监控中注入业务标识


有时候一个接口会承担多个功能,用请求参数区分执行逻辑A / 逻辑B。在监控中,我们看到这个接口失败率高,需要进一步下钻是 A 失败高还是 B 失败高、A/B 分别占了多少请求量等等。


对这种诉求,我们可以在业务层上报一个带有各种主调属性的多维监控以及接口参数维度用于下钻分析。但这种方式会造成自定义监控和被调监控字段的重叠浪费。


更好的做法是:将业务维度注入到框架的被调监控中,复用被调监控中的主调服务、节点、SET、被调接口、IP、容器等信息。


2.2.3 单维属性上报升级成多维属性上报


单维属性监控指的是上报单个字符串(维度)、一个浮点数值以及这个浮点数值对应的统计策略。多维监控指的是上报多个字符串(维度)、多个浮点数值以及每个浮点数值对应的统计策略。


下面以错误信息上报场景为例,说明单维监控的缺点以及如何切换为多维上报。


作为后台服务,处理逻辑环节中我们需要监控上报各类关键错误,以便及时感知错误、介入处理。内容架构负责多个不同业务的接入处理,每个业务具有全局唯一的资源标识,下面称为 ResID。当错误出现时,做异常上报时,我们需要上报 “哪个业务”,出现了“什么错误”。


使用单维上报时,我们只能将二者拼接在一起,上报 string (ResID + "." + ErrorMsg)。对不同的 ResID 和不同的 ErrorMsg,监控图是铺平展开的,只能逐个查看,无法对 ResID 下钻查看每类错误分别出现了多少次或者对 ErrorMsg 下钻,查看此错误分别在哪些 ID 上出现。


除了查看上的不便,多维属性监控配置告警也比单维监控便利。对于单维监控,我们需要对每一个可能出现的组合配置条件:维度值 = XXX,错误数 > N 则告警。一旦出现了新的组合,比如新接入了一个业务,我们就需要再去加告警配置,十分麻烦且难以维护。


而对于多维监控表,我们只需设置一条告警配置:对 ResID && 错误维度 下钻,错误数 > N 则告警。新增 ResID 或新增错误类型时,均会自动覆盖在此条告警配置内,维护成本低,配置简单。


03、串讲文档


监控可以帮助我们了解服务运行的表现,想要“深度清理”服务潜在问题,我们还需要对项目做代码级接手。在稳定性治理专项中,我们要求每个核心模块都产出一份串讲文档,而后交叉学习,使得开发者不单熟悉自己负责的模块,也对完整系统链路各模块功能有大概的理解,避免窥豹一斑。


3.1 串讲文档是什么


代码串讲指的是接手同学在阅读并理解模块代码后,系统的向他人介绍对该模块的掌握情况。代码串讲文档则是贯穿串讲过程中的分享材料,需要对架构设计、代码实现、部署、监控、理想化思考等方面进行详细介绍,既帮助其他同学更好的理解整个模块,也便于评估接手同学对项目的理解程度。


代码串讲文档通常包括以下内容:模块主要功能,上下游关系、整体架构、子模块的详细介绍、模块研发和上线流程、模块的关键指标等等。在写串讲文档的时候也通常需要思考很多问题:这个功能为什么需要有?设计思路是这样的?技术上如何实现?最后是怎么应用的?


3.2 为什么需要串讲文档


原因
解析
确保代码走读的质量串讲文档涵盖了模块最重要的几个部分,要求开发人员编写串讲文档的这些章节,可保障他在走读此模块时高度关注到这些部分,并总结输出成文档,确保了代码学习的深度和质量。
强化理解编写串讲文档的过程,也是对模块各方面的提炼总结。在编写的过程中,开发人员可能会发现走读中未想到的问题。通过编写代码串讲文档,开发人员可以更好地理解整个系统的结构和实现,加强对代码和系统的理解,为后续接手、维护该系统提供了帮助。
团队知识沉淀和积累串讲文档是团队内部进行知识分享和沟通的载体,也是团队知识不断沉淀积累的过程,可以作为后续新人加入团队时了解系统的第一手材料,也可以作为其他同学后续多次翻阅、了解该系统的材料。

3.3 怎么输出串讲文档


增代码串讲文档的时候,需要从2个方面进行考虑——读者角度和作者角度。


作者角度: 需要阐述作者对系统和代码的理解和把握,同时也需要思考各项细节:这个功能为什么需要有、设计思路是怎样的、技术上如何实现、最后是怎么应用的等等。


读者角度: 需要考虑目标受众是哪些,尽可能地把读者当成技术小白,思考读者需要了解什么信息,如何才能更好地理解代码的实现和作用。


通常,代码串讲文档可以包含以下几个部分:


文档构成
信息
模块主要功能首先需要明确模块的主要功能,并在后续的串讲中更加准确地介绍代码的实现。
上下游关系在介绍每个模块时,需要明确它与其他模块之间的上下游关系,即模块之间的调用关系,这有助于了解模块之间的依赖关系,从而更好地理解整个系统的结构和实现。
名词解释对一些关键词、专业术语和缩写词等专业术语进行解释,不同团队使用的术语和缩写可能不同,名词解释可以减少听众的阅读难度,提高沟通效率。
整体架构介绍整个系统的设计思路、实现方案和架构选择的原因以及优缺点。
子模块的详细解读在介绍每个子模块时,需要对其进行详细的解读,包括该模块的具体功能、实现方式、代码实现细节等方面。
模块研发和上线流程介绍模块的研发和上线流程,包括需求分析、设计、开发、测试、上线等环节。这有助于了解模块的开发过程和上线流程,以及研发团队的工作流程。
模块的关键指标介绍模块的关键指标,包括不限于业务指标、服务监控、成本指标等。
模块当前存在问题及可能的解决思路主要用于分析该模块目前存在的问题,并提出可能的解决思路和优化方案。
理想化的思考对该模块或整个系统的未来发展和优化方向的思考和展望,可以对模块的长期目标、技术选型、创新探索、稳定性建设等方面进行思考。
中长线工作安排结合系统现状和理想化思考,做出可行的中长线工作计划。
串讲中的问题这部分用于记录串讲中的问题及解答,通常是Q&A的形式,串讲中的问题也可能是系统设计相关的问题,后续将作为todo项加入到工作安排中。

04、代码质量


代码质量很大程度上决定服务的稳定性。在对代码中业务逻辑 bug 进行修复的同时,我们也对服务的启动、数据库压力及互斥资源管理做优化。


4.1 业务逻辑bug


4.1.1 内存泄漏


如下图代码所示,使用 malloc 分配内存,但没有 free,导致内存泄露。该代码为 C 语言风格,现代 C++ 使用智能指针可以避免该问题。


图片


4.1.2 空指针访问成员变量


如下图所示的代码,如果一个非虚成员函数没有使用成员变量,因编译期的静态绑定,空指针也可以成功调用该成员函数。但如果该成员函数使用了成员变量,那么空指针调用该函数时则会 core。该类问题在接入系统仓库中比较普遍,建议所有指针都要进行合理的初始化。


图片


4.2 防御编程


4.2.1 输入防御


如下图所示,如果发生了错误且没有提前返回,request 将引发 panic。针对输入,在没有约定的情况下,建议加上常见的空指针判断及异常判断。


图片


4.2.2 数组长度防御-1


如下图所示,当 url 长度超过 512 时,将会被截断,导致产出错误的url。建议针对字符串数组的长度进行合理的初始化,亦或者使用string来替代字符数组。


图片


4.2.3 数组长度防御-2


如下图所示,老代码不判断数组长度,直接取值。当出现异常数据时,该段代码则会core。建议在每次取值时,基于上下文做防御判断。


图片


4.2.4 野指针问题


下图中的ts指针指向内容和 create_time 一致。当 create_time 被 free 之后,ts 指针就变成了野指针。


该代码为 C 语言风格代码,很容易出现内存方面的问题。建议修改为现代 C++风格。


图片


下图中,临时变量存储的是 queue 中的值的引用。当 queue pop 后,此值会被析构;而变量引用的存储空间也随之释放,访问此临时变量可能出现未定义的行为。


图片


4.2.5 全局资源写防护


同时读写全局共有资源,尤其生成唯一 id,要考虑并发的安全性。


这里直接通过查询 DB 获取最大的 res_id,转成 int 后加一,作为新增资源的唯一 id。如果并发度超过 1,很可能会出现 id 重复,影响后续操作逻辑。


图片


4.2.6 lua 添加 json 解析防御


如下图所示的 lua 脚本中,使用 cjson 将字符串转换 json_object。当 data_obj 不是合法的 json 格式字符串时,decode 接口会返回 nil。修复前的脚本未防御返回值为空的情况,一旦传入非法字符串,lua 脚本就会引发 coredump。


图片


4.3 Go-python 内存泄露问题


如下图 85-86 行代码所示,使用 Go-python 调用 python 脚本,将 Go 的 string 转为PyString,此时 kv 为 PyObject。部分 PyObject 需要在函数结束时调用 DecRef,减少引用计数,以确保资源释放,否则会造成内存泄露。


判定依据是直接看 python sdk 的源码注释,如果有 New Reference , 那么必须在使用完毕时释放内存,Borrowed Reference 不需要手动释放。


图片


4.4 正确使用外部库


4.4.1 Kafka Message 结束字符


当生产者为 Go 服务时,写入 kafka 的消息字符串不会带有结束字符 '\0'。当生产者为 C++ 服务时,写入 kafka 的消息字符串会带有结束字符 '\0'。


如下图 481 行代码所示,C++中使用 librdkafka 获取消费数据时,需传入消息长度,而不是依赖程序自行寻找 '\0' 结束符。


图片


4.5 避免无限重试导致雪崩


如下图所示代码所示,失败之后立马重试。当出现问题时,不断立即重试,会导致雪崩。给重试加上 sleep,减缓下游雪崩的速度,留下缓冲的时间。


图片


4.6 真实初始化


如果每次服务启动都存在一定的成功率抖动,需要检查服务注册前的初始化流程,看看是不是存在异步初始化,导致未完成初始化即提供对外服务。如下图 48 行所示注释,原代码中初始化代码为异步函数,服务对外提供服务时可能还没有完成初始化。


图片


4.7 资源隔离


时延/成功率要求不同的服务接口,建议使用不同的处理线程。如下图中几个 service,之前均共用同一个处理线程池。其中 secure_review_service 处理耗时长,流量不稳定,其他接口流量大且时延敏感,线上出现过 secure_review_service 瞬时流量波峰,将处理线程全部占住且队列积压,导致其他 service 超时失败。


图片


4.8 数据库压力优化


4.8.1 分批拉取


当某个表数据很多或单次传输量很大,拉取节点也很多的时候,数据库的压力就会很大。这个时候,可以使用分批读取。下图 308-343 行代码,修改了 sql 语句,从一批拉取改为分批拉取。


图片


4.8.2 读备机


如果业务场景为只读不写数据,且对一致性要求不高,可以将读配置修改为从备机读取。mysql 集群一般只会有单个主机、多个备机,备机压力较小。如下图 44 行代码所示,使用readuser,主机压力得到改善。


图片


4.8.3 控制长链接个数


需要使用 mysql 长链接的业务,需要合理配置长链接个数,尤其是服务节点数很多的情况下。连接数过多会导致 mysql 实例内存使用量大,甚至 OOM;此外 mysql 的连接数是刚性限制,超过阈值后,客户端无法正常建立 mysql 连接,程序逻辑可能无法正常运转。


4.8.4 建好索引


使用 mysql 做大表检索时,应该建立与查询条件对应的索引。本次优化中,我们根据 DB 慢查询统计,找到有大表未建查询适用的索引,导致 db 负载高,查询速度慢。


4.8.5 实例拆分


非分布式数据库 (如 mariaDB) 存储空间是有物理上限的,需要预估好数据量,数据量过多及时进行合理的拆库。


4.8.6 分布式数据库负载均衡


分布式数据库一般应用在海量数据场景,使用中需要留意节点间负载均衡,否则可能出现单机瓶颈,拖垮整个集群性能。如下图是内容架构使用到的 hbase,图中倒数两列分别为请求数和region 数,从截图可看出集群的 region 分布较均衡,但部分节点请求量是其他节点几倍。造成图中请求不均衡的原因是集群中有一张表,有废弃数据占用大量 region,导致使用中的 region 在节点间分布不均,由此导致请求不均。解决方法是清理废弃数据,合并空数据 region。


图片


4.9 互斥资源管理


4.9.1 避免连接占用


接入系统服务的 mysql 连接全部使用了连接池管理。每一个连接在使用完之后,需要及时释放,否则该连接就会被占住,最终连接池无资源可用。下图所示的 117 行连接释放为无效代码,因为提前 return。有趣的是,这个 bug 和另外一个 bug 组合起来,解决了没有连接可用的问题:当没有连接可用时,获取的连接则会为空。该服务不会对连接判空,导致服务 core 重启,连接池重新初始化,又有可用的连接了。针对互斥的资源,要进行及时释放。


图片


4.9.2 使用 RAII 释放资源


下图所示的 225 行代码,该任务为互斥资源,只能由一个节点获得该任务并执行该任务。GetAllValueText 执行完该任务之后,应该释放该任务。然而在 240 行提前 return 时,没有及时释放该资源。


优化后,我们使用 ScopedDeferred 确保函数执行完成,退出之前一定会执行资源释放。


图片


05、告警治理


告警轰炸是接手服务初期常见的问题。除了前述的代码质量优化,我们还解决下述几类告警:


全链路超时配置不合理。下游服务的超时时间,大于上游调用它的超时时间,导致多个服务超时告警轰炸、中间链路服务无效等待等。
业务未隔离。某个业务流量突增引起全链路队列阻塞,影响到其他业务。请求阻塞。请求线程中的处理耗时过长,导致请求队列拥堵,请求消息得不到及时处理。

5.1 全链路超时时间合理设置


未经治理的长链路服务,因为超时设置不合理导致的异常现象:




  • 超时告警轰炸: A 调用 B,B 调用 C,当 C 异常时,C 有超时告警,B 也有超时告警。在稳定性治理分析过程中,C 是错误根源,因而 B 的超时告警没有价值,当链路较长时,会因某一个底层服务的错误,导致海量的告警轰炸。




  • 服务无效等待: A 调用 B,B 调用 C,当 A->B 超时的时候,B 还在等 C 的回包,此时 B 的等待是无价值的。




这两种现象是因为超时时间配置不合理导致的,对此我们制定了“超时不扩散原则”,某个服务的超时不应该通过框架扩散传递到它的间接调用者,此原则要求某个服务调用下游的超时必须小于上游调用它的超时时间。


5.2 基于业务分 set 进行资源隔离


针对某个业务的流量突增影响其他业务的问题,我们可将重点业务基于 set 做隔离部署。确保特殊业务只运行于独立节点组上,当他流量暴涨时,不干扰其他业务平稳运行,降低损失范围。


5.3 高耗时计算使用线程池


如下图红色部分 372 行所示,在请求响应线程中进行长耗时的处理,占住请求响应线程,导致请求队列阻塞,后续请求得不到及时处理。如下图绿色部分 368 行所示,我们将耗时处理逻辑转到线程池中异步处理,从而不占住请求响应线程。


图片


06、研发流程


在研发流程上,我们沿用司内其他技术产品积累的 CICD 建设经验,包括以下措施:


研发方式具体流程
建设统一镜像统一开发镜像,任意服务都可以在统一镜像下开发编译
配置工蜂仓库保护 master 分支,代码经过评审才可合入
规范分支和 tag 命名,便于后续信息追溯
规范化 commit 信息,确保信息可读,有条理,同样便于后续信息追溯
建设蓝盾流水线MR 流水线提交 MR 时执行的流水线,涵盖代码静态检查、单元测试、接口测试,确保合入 master 的代码质量达到基本水平
提交构建流水线:MR 合入后执行的流水线,同样涵盖代码检查,单元测试,接口测试,确保 master 代码随时可发布
XAC 发布流水线:发布上线的流水线,执行固化的灰度->全量流程,避免人工误操作
落实代码评审机制确保代码合入时,经过了至少一位同事的检查和评审,保障代码质量、安全

07、优化效果总结


7.1 健全的CICD


7.1.1 代码合入


在稳定性专项优化前,内容架构的服务没有合理的代码合入机制,导致主干代码出现违背编码规范、安全漏洞、圈复杂度高、bug等代码问题。


优化后,我们使用统一的蓝盾流水线模板给所有服务加上流水线,以保证上述问题能被自动化工具、人工代码评审拦截。


7.1.2 服务发布


在稳定性优化前,内容架构服务发布均为人工操作,没有 checklist 机制、审批机制、自动回滚机制,有很大安全隐患。


优化后,我们使用统一的蓝盾流水线模板给所有服务加上 XAC 流水线,实现了提示发布人在发布前自检查、double_check 后审批通过、线上出问题时一键回滚。


7.2 更完备的可观测性


7.2.1 多维度监控


在稳定性优化前,内容架构服务的监控覆盖不全,自定义监控均使用一维的属性监控,这导致多维拼接的监控项泛滥、无法自由组合多个维度,给监控查看带来不便。


优化后,我们用更少的监控项覆盖更多的监控场景。


7.2.2 业务监控和负责人制度


在稳定性优化前,内容架构服务几乎没有业务维度监控。优化后,我们加上了重要模块的多个业务维度监控,譬如:数据断流、消息组件消息挤压等,同时还建立值班 owner、服务 owner 制度,确保有告警、有跟进。


7.2.3 trace 完善与断流排查文档建设


在稳定性优化前,虽然已有上报鹰眼 trace 日志,但上报不完整、上报有误、缺乏排查手册等问题,导致对数据处理全流程的跟踪调查非常困难。


优化后,修正并补全了 trace 日志,建设配套排查文档,case 处理从不可调查变成可高效率调查。


7.3代码bug修复


7.3.1 内存泄露修复


在稳定性优化前,我们观察到有3个服务存在内存泄露,例如代码质量章节中描述内存泄露问题。


图片


7.3.2 coredump 修复 & 功能 bug 修复


在稳定性优化前,历史代码中存在诸多 bug 与可能导致 coredump 的隐患代码。


我们在稳定性优化时解决了如下 coredump 与 bug:


  • JSON 解析前未严格检查,导致 coredump 。
  • 服务还未初始化完成即接流,导致服务重启时被调成功率猛跌。
  • 服务初始化时没有同步加载配置,导致服务启动后缺失配置而调用失败。
  • Kafka 消费完立刻 Commit,导致服务重启时,消息未实际处理完,消息可能丢失/
  • 日志参数类型错误,导致启动日志疯狂报错写满磁盘。

7.4 服务被调成功率优化


在稳定性优化前,部分内容架构服务的被调成功率不及 99.5% ,且个别服务存在严重的毛刺问题。优化后,我们确保了服务运行稳定,调用成功率保持在 99.9%以上。


7.5 外部存储使用优化


7.5.1 MDB 性能优化


在稳定性优化前,内容架构各服务对MDB的使用存在以下问题:低效/全表SQL查询、所有服务都读主库、数据库连接未释放等问题。造成MDB主库的CPU负载过高、慢查询过多等问题。优化后,主库CPU使用率、慢查询数都有大幅下降。


图片


7.5.2 HBase 性能优化


在稳定性优化前,内容架构服务使用的 HBase 存在单节点拖垮集群性能的问题。


优化后,对废弃数据进行了清理,合并了空数据 region,使得 HBase 调用的 P99 耗时有所下降。


图片


7.6 CPU 用量下降


在稳定性优化前,内容架构服务使用的线程模型是老旧的 spp 协程。spp 协程在在高吞吐低延迟场景下性能有所不足,且未来 trpc 会废弃 spp。


我们将重要服务的线程模型升级成了 Fiber 协程,带来更高性能和稳定性的同时,服务CPU利用率下降了 15%。


7.7 代码质量提升


在稳定性优化前,内容架构服务存在很多不规范代码。例如不规范命名、魔术数字和常量、超长函数等。每个服务都存在几十个代码扫描问题,最大圈复杂度逼近 100。


优化后,我们对不规范代码进行了清扫,修复规范问题,优化代码命名,拆分超长函数。并在统一清理后,通过 MR 流水线拦截,保护后续合入不会引入新的规范类问题。


7.8 其他优化效果


经过本轮治理,我们的服务告警量大幅度下降。以系统中的核心处理服务为例,告警数量从159条/天降低到了0条/天。业务 case 数从 22 年 12 月接手以来的 18 个/月下降到 4 个/月。值班投入从最初的 4+ 人力,降低到 0.8 人力。


我们项目组在完成稳定性接手之后,下一步将对全系统做理想化重构,进一步提升迭代效率、运维效率。希望这些经验也对你接管/优化旧系统有帮助。如果觉得内容有用,欢迎分享。

作者:腾讯云开发者
来源:juejin.cn/post/7233564835044524092
收起阅读 »

你管这破玩意叫缓存穿透?还是缓存击穿?

大家好,我是哪吒。 一、缓存预热 Redis缓存预热是指在服务器启动或应用程序启动之前,将一些数据先存储到Redis中,以提高Redis的性能和数据一致性。这可以减少服务器在启动或应用程序启动时的数据传输量和延迟,从而提高应用程序的性能和可靠性。 1、缓存预热...
继续阅读 »

大家好,我是哪吒。


一、缓存预热


Redis缓存预热是指在服务器启动或应用程序启动之前,将一些数据先存储到Redis中,以提高Redis的性能和数据一致性。这可以减少服务器在启动或应用程序启动时的数据传输量和延迟,从而提高应用程序的性能和可靠性。


1、缓存预热常见步骤


(1)数据准备


在应用程序启动或服务器启动之前,准备一些数据,这些数据可以是静态数据、缓存数据或其他需要预热的数据。


(2)数据存储


将数据存储到Redis中,可以使用Redis的列表(List)数据类型或集合(Set)数据类型。


(3)数据预热


在服务器启动或应用程序启动之前,将数据存储到Redis中。可以使用Redis的客户端工具或命令行工具来执行此操作。


(4)数据清洗


在服务器启动或应用程序启动之后,可能会对存储在Redis中的数据进行清洗和处理。例如,可以删除过期的数据、修改错误的数据等。


需要注意的是,Redis缓存预热可能会增加服务器的开销,因此应该在必要时进行。同时,为了减少预热的次数,可以考虑使用Redis的其他数据类型,如哈希表(Hash)或有序集合(Sorted Set)。此外,为了提高数据一致性和性能,可以使用Redis的持久化功能,将数据存储到Redis中,并在服务器重启后自动恢复数据。


2、代码实现


@Component
@Slf4j
public class BloomFilterInit
{
@Resource
private RedisTemplate redisTemplate;

//初始化白名单数据
@PostConstruct
public void init() {
//1 白名单客户加载到布隆过滤器
String key = "customer:1";
//2 计算hashValue,由于存在计算出来负数的可能,我们取绝对值
int hashValue = Math.abs(key.hashCode());
//3 通过hashValue和2的32次方后取余,获得对应的下标坑位
long index = (long)(hashValue % Math.pow(2,32));
log.info(key+" 对应的坑位index:{}",index);
//4 设置redis里面的bitmap对应类型白名单:whitelistCustomer的坑位,将该值设置为1
redisTemplate.opsForValue().setBit("whitelistCustomer",index,true);

}
}

二、缓存雪崩


Redis缓存雪崩是指在缓存系统中,由于某些原因,缓存的数据突然大量地被删除或修改,导致缓存系统的性能下降,甚至无法正常工作。


1、什么情况会发生缓存雪崩?


(1)误删除


由于误操作或故障,缓存系统可能会误删除一些正常的数据。这种情况通常会在数据库中发生。


(2)误修改


由于误操作或故障,缓存系统可能会误修改一些正常的数据。这种情况通常会在数据库中发生。


(3)负载波动


缓存系统通常会承受一定的负载波动,例如,在高峰期间,数据量可能会大幅增加,从而导致缓存系统的性能下降。


(4)数据变化频繁


如果缓存系统中的数据变化频繁,例如,每秒钟都会有大量的数据插入或删除,那么缓存系统可能会因为响应过慢而导致雪崩。


2、Redis缓存集群实现高可用


(1)主从 + 哨兵


(2)Redis集群


(3)开启Redis持久化机制aof/rdb,尽快恢复缓存集群。


3、如何避免Redis缓存雪崩?


(1)数据备份


定期备份数据,以防止误删除或误修改。


(2)数据同步


定期同步数据,以防止数据不一致。


(3)负载均衡


使用负载均衡器将请求分配到多个Redis实例上,以减轻单个实例的负载。


(4)数据优化


优化数据库结构,减少数据变化频繁的情况。


(5)监控与告警


监控Redis实例的性能指标,及时发现缓存系统的异常,并发出告警。


三、缓存穿透


Redis缓存穿透是指在Redis缓存系统中,由于某些原因,缓存的数据无法被正常访问或处理,导致缓存失去了它的作用。


1、什么情况会发生缓存穿透?


(1)数据量过大


当缓存中存储的数据量过大时,缓存的数据量可能会超过Redis的数据存储限制,从而导致缓存失去了它的作用。


(2)数据更新频繁


当缓存中存储的数据更新频繁时,缓存的数据可能会出现异步的变化,导致缓存无法被正常访问。


(3)数据过期


当缓存中存储的数据过期时,缓存的数据可能会失去它的作用,因为Redis会在一定时间后自动将过期的数据删除。


(4)数据权限限制


当缓存中存储的数据受到权限限制时,只有拥有足够权限的用户才能访问和处理这些数据,从而导致缓存失去了它的作用。


(5)Redis性能瓶颈


当Redis服务器的性能达到极限时,Redis缓存可能会因为响应过慢而导致穿透。


2、如何避免Redis缓存穿透?


(1)设置合理的缓存大小


根据实际需求设置合理的缓存大小,以避免缓存穿透。


(2)优化数据结构


根据实际需求优化数据结构,以减少数据的大小和更新频率。


(3)设置合理的过期时间


设置合理的过期时间,以避免缓存失去它的作用。


(4)增加Redis的并发处理能力


通过增加Redis的并发处理能力,以提高缓存的处理能力和响应速度。


(5)优化Redis服务器的硬件和软件配置


通过优化Redis服务器的硬件和软件配置,以提高Redis的性能和处理能力。


Redis缓存穿透


四、通过空对象缓存解决缓存穿透


如果发生了缓存穿透,可以针对要查询的数据,在Redis中插入一条数据,添加一个约定好的默认值,比如defaultNull。


比如你想通过某个id查询某某订单,Redis中没有,MySQL中也没有,此时,就可以在Redis中插入一条,存为defaultNull,下次再查询就有了,因为是提前约定好的,前端也明白是啥意思,一切OK,岁月静好。


这种方式只能解决key相同的情况,如果key都不同,则完蛋。


五、Google布隆过滤器Guava解决缓存穿透



1、引入pom


<!--guava Google 开源的 Guava 中自带的布隆过滤器-->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>23.0</version>
</dependency>

2、创建布隆过滤器


BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(), 100);

(3)布隆过滤器中添加元素


bloomFilter.mightContain(1)

(4)判断布隆过滤器中是否存在


bloomFilter.mightContain(1)

3、fpp误判率


@Service
@Slf4j
public class GuavaBloomFilterService {
public static final int SIZE = 1000000;

//误判率
public static double fpp = 0.01;

//创建guava布隆过滤器
private static BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(), SIZE, fpp);

public void guavaBloomFilter() {
for (int i = 1; i <= SIZE; i++) {
bloomFilter.put(i);
}
ArrayList<Integer> list = new ArrayList<>(10000);

for (int i = SIZE + 1; i <= SIZE + (10000); i++) {
if (bloomFilter.mightContain(i)) {
log.info("被误判了:{}", i);
list.add(i);
}
}
log.info("误判总数量:{}", list.size());
}
}

六、Redis缓存击穿


Redis缓存击穿是指在Redis缓存系统中,由于某些原因,缓存的数据无法被正常访问或处理,导致缓存失去了它的作用。


1、什么情况会发生缓存击穿?


根本原因:热点Key失效


(1)数据量过大


当缓存中存储的数据量过大时,缓存的数据量可能会超过Redis的数据存储限制,从而导致缓存失去了它的作用。


(2)数据更新频繁


当缓存中存储的数据更新频繁时,缓存的数据可能会出现异步的变化,导致缓存无法被正常访问。


(3)数据过期


当缓存中存储的数据过期时,缓存的数据可能会失去它的作用,因为Redis会在一定时间后自动将过期的数据删除。


(4)数据权限限制


当缓存中存储的数据受到权限限制时,只有拥有足够权限的用户才能访问和处理这些数据,从而导致缓存失去了它的作用。


(5)Redis性能瓶颈


当Redis服务器的性能达到极限时,Redis缓存可能会因为响应过慢而导致击穿。


2、如何避免Redis缓存击穿?


(1)设置合理的缓存大小


根据实际需求设置合理的缓存大小,以避免缓存穿透。


(2)优化数据结构


根据实际需求优化数据结构,以减少数据的大小和更新频率。


(3)设置合理的过期时间


设置合理的过期时间,以避免缓存失去它的作用。


(4)增加Redis的并发处理能力


通过增加Redis的并发处理能力,以提高缓存的处理能力和响应速度。


(5)优化Redis服务器的硬件和软件配置


通过优化Redis服务器的硬件和软件配置,以提高Redis的性能和处理能力。


七、Redis缓存击穿解决方案


1、互斥更新


通过双检加锁机制。


2、差异失效时间



先更新从缓存B,再更新主缓存A,而且让从缓存B的缓存失效时间长于A,保证A失效时,B还在。


作者:哪吒编程
来源:juejin.cn/post/7233052510553636901
收起阅读 »

一次查找分子级Bug的经历,过程太酸爽了

作者:李亚飞 Debugging is like trying to find a needle in a haystack, except the needle is also made of hay. Debug调试就像是在大片的干草堆中找针一样,只不...
继续阅读 »

作者:李亚飞





Debugging is like trying to find a needle in a haystack, except the needle is also made of hay.


Debug调试就像是在大片的干草堆中找针一样,只不过针也是由干草制成的。



在软件开发的世界里,偶尔会出现一些非常隐蔽的 Bug,这时候工程师们像探险家一样,需要深入代码的丛林,寻找隐藏在其中的“幽灵宝藏”。前段时间,我和我的团队也踏上了这样一段刺激、有趣的探险之旅。


最近繁忙的工作告一段落,我总算轻松下来了,想趁这个机会,跟大家分享我们的这次“旅途”。



01 引子


我是 ShowMeBug 的 CEO 李亚飞,是一个古老的 Ruby 工程师。由于 2019 年招聘工程师的噩梦经历,我立志打造一个真实模拟工作场景的 IDE,用来终结八股文、算法横行的技术招聘时代。


这个云上的 IDE 引擎,我称之为轻协同 IDE 引擎——因为它不是为了繁杂重度的工作场景准备的,而是适应于大部分人的习惯、能快速上手熟悉、加载速度快、能协同(面试用)、低延迟感,让用户感受非常友好


图片


多环境启动与切换


为了达成秒级启动环境的性能要求,我们设计了一套精巧的分布式文件系统架构,其核心是一个可以瞬间复制大量小文件的写时复制 (COW) 技术。IO 吞吐能达到几万人同时在线,性能绝对是它的一大优势。


我们对此信心满满,然而没想到,很快就翻车了。


02 探险启程


2023 年 1 月,北方已经白雪皑皑,而深圳却仍难以感受到冬天的寒意。


我和我的团队在几次打开文件树的某个文件时,会显得有点慢——当时没有人在意,按照常规思路,“网速”背了这个锅。事后我们复盘才发现,这个看似微不足道的小问题,其实正是我们开始这次探险之旅的起点。


1 月底,南方的寒意缓缓侵入。这时候我们的轻协同 IDE 引擎已经开始陆续支持了 Vue2、Vue3、React、Django、Rails 等框架环境,一开始表现都很棒,加载和启动速度都很快。但是,跑了一段时间,我们开始察觉,线上环境就出现个别环境(Rails 环境)启动要 20-30s 才能完成


虽然其他环境仍然保持了极快的加载和启动速度,但敏锐的第六感告诉我,不行,这一定有什么猫腻,如果不立即行动,势必会对用户体验带来很不好的影响。于是,我开始安排团队排查眼前这个不起眼的问题,我们的探险之旅正式开始。


03 初露希望


湿冷的冬季,夜已深,我和我们的团队依旧坐在电脑前苦苦探索,瑟瑟发抖。


探险之旅的第一站,就是老大难的问题:定位Bug。目前只有某一个环境启动很慢,其他的环境都表现不错。大家想了很多办法都没有想明白为什么,甚至怀疑这个环境的模板是不是有问题——但把代码放在本地启动,最多就2秒。


哎,太诡异了。我们在这里卡了至少一周时间,不断追踪代码,分析日志文件,尝试各种方案,都没有弄清楚一个正常的程序启动为什么会慢。我们一度陷入了疲惫和焦虑的情绪中。



Debug 是种信仰,只有坚信自己能找到 Bug,才有可能找到 Bug。



软件开发界一直有一个低级 Bug 定律:所有诡异的问题都来自一个低级原因。在这“山重水复疑无路”之际,我们决定重新审视我们的探险路径:为什么只有 Rails 更慢,其他并不慢?会不会只是一个非常微小的原因而导致?


这时候,恰好有一个架构师朋友来访,向我们建议,可以用 perf 火焰图分析看看 Rails 的启动过程。


图片


perf火焰图实例


当我们用 perf 来分析时,惊讶地发现:原来 Rails 的启动要加载更多的文件! 紧接着,我们又重新用了一个文件读写监控的工具:fatrace,通过它,我们看到 Rails 每次启动需要读写至少 5000 个文件,但其他框架并不需要。


这才让我们突然意识到,会不会是文件系统读写速度不及预期,导致了启动变慢。


04 Bug现身


为了搞清楚是不是文件系统读写速度的问题,我急需一个测试 IO 抖动的脚本。我们初步估算一下,写好这个脚本需要好几个小时的时间。


夜已深,研发同学都陆续下班了。时间紧迫!我想起了火爆全球的 ChatGPT,心想,不如让它写一个试试。


图片


测试 IO 抖动的脚本


Cool,几乎不需要改动就能用,把代码扔在服务器开跑,一测,果然发现问题:每一次文件读写都需要 10-20ms 才能完成 。实际上,一个优秀的磁盘 IO 读写时延应该在亚毫级,但这里至少慢了 50 倍。

Bingo,如同“幽灵宝藏”一般的分子级 Bug 逐渐显现,问题的根因已经确认:过慢的磁盘 IO 读写引发了一系列操作变慢,进而导致启动时间变得非常慢


更庆幸的是,它还让我们发现了偶尔打开文件树变慢的根本原因,这也是整个系统并发能力下降的罪魁祸首


05 迷雾追因


看到这里,大家可能会问,这套分布式文件系统莫非一直这么慢,你们为什么在之前没有发现?


非也,早在项目开始的时候,这里的时延是比较良好的,大家没有特别注意这个 IOPS 性能指标,直到我们后面才留意到,系统运行超过一个月时,IO 读写时延很容易就进入到卡顿的状态,表现就是文件系统所在主机 CPU 忽高忽低,重启就会临时恢复。


此时,探险之旅还没结束。毕竟,这个“幽灵宝藏”周围依旧笼罩着一层迷雾。


我们继续用 fatrace(监控谁在读写哪个 IO)监控线上各个候选人答题目录的 IO读写情况,好家伙,我们发现了一个意外的情况:几乎每一秒都有一次全量的文件 stats 操作 (这是一个检测文件是否有属性变化的 IO 操作)!


也就是说,比如有 1000 个候选人正在各自的 IDE 中编码,每个候选人平均有 300 个文件,就会出现每秒 30 万的 IO 操作数!


我们赶紧去查资料,根据研究数据显示,一个普通的 SSD 盘的 IOPS 最高也就到 2-3 万 。于是,我们重新测试了自己分布式文件系统的 IOPS 能力,结果发现也是 2-3 万 。


那这肯定远远达不到我们理想中的能力级别。


这时,问题更加明确:某种未知的原因导致了大量的 IOPS 的需求,引发了 IO 读写时延变长,慢了大约几十倍


06 接近尾声


我和我的团队继续深究下去,问题已经变得非常明确了:


原来,早在去年 12 月,我们上线一个监听文件增删的变化来通知各端刷新的功能。


最开始我们采用事件监听 (fswatch event),因为跨了主机,所以存在 1-2s 的延迟。研发同学将其改为轮询实现的方案,进而引发了每秒扫描目录的 stats 行为。


当在百人以下访问时,IOPS 没有破万,还足够应对。但一旦访问量上千,便会引发 IO 变慢,进而导致系统出现各种异常:间歇导致某些关键接口 QPS 变低,进而引发系统抖动


随着“幽灵宝藏”显露真身,这次分子级 Bug 的探险之旅也已经接近尾声。团队大
呼:这过程实在太酸爽了!


07 技术无止境


每一个程序员在成长路上,都需要与 Bug 作充足的对抗,要么你勇于探索,深入代码的丛林,快速定位,挖到越来越丰富的“宝藏”,然后尽情汲取到顶级的知识,最终成为高手;或者被它打趴下, 花费大量时间都找不到问题的根源,成为芸芸众生中的一人。


当然,程序员的世界中,不单单是 Debug。


当我毕业 5 年之后,开始意识到技术的真正价值是解决真正的社会问题。前文中我提到,由于我发现技术招聘真是一个极其痛苦的事:特别花面试官的时间,却又无法有效分析出候选人的技术能力,所以创立 ShowMeBug 来解决这个问题:用模拟实战的编程环境,解决科学评估人才的难度


这个轻协同 IDE 技术从零开发,支持协同文件树、完全自定义的文件编辑器、协同的控制台 (Console) 与终端 (Shell),甚至直接支持 Ctrl+P 的文件树搜索,不仅易于使用,又强大有力。


但是这还不够。要知道,追求技术精进是我们技术人的毕生追求。对于这个轻协同IDE,我们追求三个零:零配置、零启动、零延迟。其中,零启动就是本文所追求的极限:以最快的速度启动环境和切换环境


因此,探险之旅结束后,我们进一步改进了此文件系统,设定 raid 的多磁盘冗余,采用高性能 SSD,同时重新制定了新磁盘架构参数,优化相关代码,最终大幅提升了分布式文件系统的稳定性与并发能力。


截止本文结尾,我们启动环境的平均速度为 1.3 秒,切换环境速度进入到亚秒级,仅需要 780ms。目前在全球范围的技术能力评估赛道 (TSA) 中,具备 1-2 年的领先性


08 后记


正当我打算结束本文时,我们内部的产品吐槽群信息闪烁,点开一看:嚯,我们又发现了新 Bug。


立夏已至,我们的探险之旅又即将开始。


作者:ShowMeBug技术团队
来源:juejin.cn/post/7231429790615240764
收起阅读 »

编程中最难的就是命名?这几招教你快速上手

作者:陈立(勤仁) 你可不能像给狗狗取名字那样给类、方法、变量命名。仅仅因为它很可爱或者听上去不错。 在写代码的时候,你要经常想着,那个最终维护你代码的人可能将是一个有暴力倾向的疯子,并且他还知道你住在哪里。 01 为什么命名很重要? 在项目中,从项目的创建到...
继续阅读 »

作者:陈立(勤仁)


你可不能像给狗狗取名字那样给类、方法、变量命名。仅仅因为它很可爱或者听上去不错。


在写代码的时候,你要经常想着,那个最终维护你代码的人可能将是一个有暴力倾向的疯子,并且他还知道你住在哪里。


01 为什么命名很重要?


在项目中,从项目的创建到方法的实现,每一步都以命名为起点,我们需要给变量、方法、参数、类命名,这些名字出现在代码的每个角落,随处可见,混乱或错误的命名不仅让我们对代码难以理解,更糟糕的是,会误导我们的思维,导致对代码的理解完全错误。如果整个项目始终贯穿着好的命名,就能给阅读者一个神清气爽的开始,也能给阅读者一个好的指引。


要知道,代码的阅读次数远远多于编写的次数。请确保你所取的名字更侧重于阅读方便而不是编写方便。


02 为什么很难正确命名?


有人称编程中最难的事情就是命名。我同样深以为然,中国有句古话叫做万事开头难。抛开环境搭建,真正到了编码阶段第一件事就是命名,而最常见的一种情况,就是毫无目的、仅凭个人的喜好的去决定了一个名字。但因为没有想清楚目标和具体实施步骤,所以进行过程中往往会面临无数次的小重构甚至是推倒重来。


1、缺乏意愿


害怕在选择名字上花时间,对做好命名的意愿不足,随心所欲,甚至无视团队对命名的基本规范,觉得编译器能编译通过,代码能正常运行就成。


其实对发现的命名问题进行重构和推倒重来并不可怕,最可怕的是当下程序员不具备发现问题后肯回过头来纠偏的意愿。这终将演变成为一场灾难。


2、缺乏思考


没想清楚被命名的事物是什么,事物应该承担什么职责,是否会对其他人造成误解。


新手程序员总会花很多时间学习一门编程语言、代码语法、技术和工具。他们觉得如果掌握了这些东西,就能成为一个好程序员。然而事实并不是这样,事实上,编程不仅仅关乎掌握技能和工具,更重要的是在特定范畴内解决问题的能力,还有和其他程序员合作的能力。因此,能在代码中准确的表达自己的想法就变得异常重要,代码中最直观的表达方式是命名,其次是注释。


3、缺乏技巧


选一个好的名字真很难,你可能得有较高的描述能力和共同的文化背景。并且知晓一些常见且应该避免的命名问题。


如果最终还是没法找到合适的名字,还请添加准确的注释辅助他人理解,等想到合适的名字后再进行替换,不过往往能够通过注释(母语)描述清楚的事物,命名应该问题不大,问题大的是连注释都无法准确表达,那说明可能当前类、函数、变量承担的职责太多太杂。


03 如何正确的命名?


这里不讨论具体语言的命名规则,原因是不同编程语言命名规则各不相同,甚至不同团队间相同语言的命名规则也有出入。这里主要从提高可读性出发,结合我所在的客户端团队日常开发情况,以Java作为演示语言,给一些关于命名的建议。


1、名副其实


无论是变量、方法、或者类,在看到他名称的时候应该以及答复了所有的大问题,它应该告诉你,它为什么会存在,他做什么事,应该怎么做。如果在看到名称时,还需要去查找注释来确认自己的理解,那就不算名副其实。而且在发现有更好的命名时,记得果断替换。


Case1:到底怎样算End?

代码示例:


public interface OnRequestListener {
/**
* 请求结束 只有成功点才认为是真正的结束
* @param ...
*/

void onRequestEnd(....);
/**
* 请求开始
* @param ...
*/

void onRequestStart(...);
}

大脑活动:


onRequestEnd是请求的什么阶段?请求成功和失败任一情况都算 “end”吗?喔,原来注释有写:“只有成功点才认为是真正的结束”。


修改建议:


// 省略注释
public interface OnRequestListener {
void onStart(....);
void onSuccess(....);
void onFailure(...);
}

2、避免误导


在每种语言中都有内置的标识符,他们都有特定的含义,如果和他们没有关联就不要在命名中加上他们。


2.1 避免使用令人误解的名字


Case1:命错名的集合

代码示例:


private List<SectionModel> dataSet;

大脑活动:


“dataSet” 在最初一定是为了元素去重选择了Set类型,肯定后来某一个历史时刻发现有bug被偷偷改成了List类型,但是变量名没变。


代码跟读:


跟踪提交记录,呃,在18年被刚定义时就是 List<***> dataSet;


修改建议:


private List<SectionModel> dataList;
或者
private List<SectionModel> sections;

Case2:不是View的View类

代码示例:


/** 作者+日期 */
public class RItemOverlayView {
}

/** 作者+日期 */
public class NRItemOverlayView {
}

大脑活动:


“N”是啥意思?类名只有一个N的字母差别,难道是新旧的差别,新的和旧的有什么区别呢?


类名以View结尾,嗯,应该是一个视图,可是,视图为啥不用继承视图基类的?


代码跟读:


喔,N确实代表“New”的意思,NRItemOverlayView被首页推荐使用,RItemOverlayView被购后推荐使用。


这个类主要核心工作是构建浮层视图(职责并不单一),而类本身并不是一个真正的视图;


修改建议:


// 放在首页推荐场景的包下
public class ItemOverlayViewCreator {
}

// 放在购后推荐场景的包下
public class ItemOverlayViewCreator {
}

Case3:整形变量为啥要用is开头

代码示例:


private int isFirstEnter = 0;

大脑活动:


为什么“is”开头的变量却声明成整形?到底是要计数还是判断真假呢?


代码跟读:


isFirstEnter < 1 做第一次进入的逻辑


修改建议:


private boolean isFirstEnter = true;

Case4:开关作用反掉啦

代码示例:


....
if (InfoFlowOrangeConfig.getBooleanValue(POST_DELAYED_HIDE_COVER_VIEW_ENABLE, true)) {
hideCoverImageView();
} else {
delayedHideCoverImageView();
}

大脑活动:


为什么开关名为“delay....”为“true”的时候,走的不是delay逻辑,那开关要怎么发?容我多看几遍,是不是最近没休息好所以看岔了。


代码跟读:


反复看了几遍,确实是开关命名和实际操作完全相反,开关名意为“延迟隐藏封面视图”,执行的却是“立即隐藏封面视图”。


修改建议:


....
if (InfoFlowOrangeConfig.getBooleanValue(IMMEDIATELY_HIDE_COVER_VIEW_ENABLE, true)) {

hideCoverImageView();
} else {
delayedHideCoverImageView();
}

3、做有意义的区分


如果单纯只是为了区分两个名称不能一样,就使用就使用诸如数字,字母来做区分的话,那似乎是毫无意义的区分。


3.1 避免在名字中使用数字


case1: 来自包名的暴击

问题示例:


以下是首页客户端的工程目录节选,数字化的包名:recommend、recommend2、recommend3、recommend4


image.png


大脑活动:


2、3、4难道是因为首页历史包袱太沉重,推荐迭代的版本实在太多导致Old、New单词不够用所以用数字来代替新旧4个历史阶段的版本吗?


代码跟读:



  • recommend:推荐的公共工具和模块;

  • recommend2:收藏夹场景的推荐实现;

  • recommend3:首页场景的推荐实现;

  • recommend4:购后场景的推荐实现;


修改建议:


这里暂时只讨论如何把数字替换成有意义的命名


image.png


3.2 避免使用具有相似含义的名字


case1:同一个类下的“刷新7剑客”

代码示例:


image.png


大脑活动:


为什么一个Adapter类对外有七个刷新数据的接口?


"refreshData()" 和 “speedRefreshData()” 是什么区别?“mainRefreshData()” + "refreshDeltaData()" =“mainRefreshDeltaData()” ?


是一个拆分组合的关系吗?我应该在何总场景下如何正确的使用refresh,我在哪,我在做什么?


代码跟读:


大部分refresh代码线上并不会被调用。阅读和调试下来,实际还在生效的方法只有一个:“gatewayRefreshData()”。


修改建议:实际上这已经不是一个单纯优化命名可以解决的问题,无论叫的多具体,面对7个刷新接口都会懵圈。期望在方法声明期间,作者多体量后来的阅读者和维护者,及时的调整代码。


后来者可以从实际出发去假存真,做减法干掉其它无用的6个刷新方法保留一个刷新接口。


case2:4个数据源定义,该用谁呢

代码示例:


声明1:


public interface IR4UDataSource { 
....
}

声明2:


public interface RecommendIDataSource {
....
}

声明3:


public interface IRecommendDataResource {
....
}

声明4:


public class RecmdDataSource {
....
}

大脑活动:


4个推荐数据源,其中有3个是接口声明,为什么接口定义了不能多态,不能复用接口的声明?这三代的抽象好像有一丢丢失败。


代码跟读:


homepage 包下的 IR4UDataSource,和非常古老的首页曾经爱过,线上实际不会使用;


Recommend2 包下的“RecommendIDataSource” 属于收藏夹,但也属于古老版本,收藏夹不在使用;


Recommend3 包下的“IRecommendDataResource” 确实是首页场景推荐使用,但也是曾经的旧爱;


原来当今的真命天子是Recommend3包下的“RecmdDataSource”,一个使用俏皮缩写未继承接口的实体类,看来是已经放弃伪装。


修改建议:


......


3.3 避免使用具有不同含义但却有相似名字的变量


case1 : 大家都是view,到底谁是谁

代码示例:


public void showOverlay(@NonNull View view ...) {
...
View rootView = getRootView(view);
DxOverlayViewWidget dView = createDxOverlayViewWidget();
dView.showOverLayer(view.getContext(), (ViewGroup)rootView, cardData, itemData);


...
}

代码跟读:


代码中存在3个以view结尾的局部变量,rootView、view 、 dView,其中 view 和 dView 之间只有一个字母的差异,方法如果长一点,view 和 dView 使用频率在高一点,掺杂着rootView会让人抓狂。另外dView也并不是一个view,实际是个DXViewWidget。


修改建议:


public void showOverlay(@NonNull View hostView ...) {
...
ViewGroup parentView = getParentView(hostView);
DxOverlayViewWidget dxOverlayViewWidget = createDxOverlayViewWidget();
dxOverlayViewWidget.showOverLayer(hostView.getContext(), parentView, ...);
...
}

4.使用读的出来的名称


使用读的出来的名称,而不是自造词,这会给你无论是记忆,还是讨论需要说明是哪个方法时,都能带来便利。可以使用达成共识的缩写,避免造成阅读障碍。


4.1 避免使用令人费解的缩写


Case1:接口定义中的俏皮缩写

代码示例:


/**
* Created by *** on 16/8/6.
*/
public interface IR4UDataSource {
....
}

大脑活动:


R4U是什么?R4和Recommend4这个目录有什么关系,难道是购后推荐的数据源定义吗?那U又代表什么?


代码跟读:


原来R4U是Recommend For You的俏皮写法


修改建议:


public interface IRecommendForYouDataSource {
....
}

Case2:成员变量命名的缩写

代码示例:


....
// 标题指示器(indicators)
private LinearLayout mTabLL;
private TabLayout mTabLayout;
....

大脑活动:


“mTabLL”是什么呢?有注释!难道mTabLL是指示器视图?“LL“”也不像是indicators的缩写,喔,LL是LinearLayout的首字母缩写。嗯,使用LinearLayout自定义做成指示器有点厉害!诶,不对,好像TabLayout更像是个选项卡式指示器的样子。


代码跟读:


原来“mTabLL” 下面声明的 “mTabLayout”才是指示器视图,“mTabLL”只是指示器视图的父视图。还好“mTabLayout”没有缩写成“mTabL”,导致和“mTabLL”傻傻分不清,作者已然是手下留情了。


修改建议:


....
private LinearLayout mTabLayoutParentView;
private TabLayout mTabLayout;
....

Case3:局部变量命名的缩写

代码示例:


....
for (PageParams.GroupBuckets ss:params.groupBucketIds.values()) {

if (ss != null) {
bucketIds.removeAll(ss.bucketIdsAll);
Collections.addAll(bucketIds, ss.currentBucketIds);
}
}
....

大脑活动:


"ss"是什么鬼,是不是写错了,GroupBuckets首字母缩写是“gb”,PageParams和GroupBuckets 的首字母缩写是“pg”


这难道是,PageParams 和 GroupBuckets 的尾字母缩写,在一个圈复杂度为18的方法中看到尾字母缩写“ss”?啊!好难受。


修改建议:


for (PageParams.GroupBuckets groupBuckets :params.groupBucketIds.values()) {
if (groupBuckets != null) {
....
}
}

5、使用可搜索的名称


若变量或常量可能在代码中多处使用,则应赋其以便于搜索的名称。


5.1 给魔法值赐名


Case1:数字魔法值没法搜索也看不懂

代码示例:


public static void updateImmersiveStatusBar(Context context) {
....
if (TextUtils.equals(isFestivalOn, "1")) {
if (TextUtils.equals(navStyle, "0") || TextUtils.equals(navStyle, "1")) {
....
} else if (TextUtils.equals(navStyle, "2")) {
....
}
}
....
}

大脑活动:


对于TextUtils.equals(isFestivalOn, "1") ,我还能猜测一下这里的“1” 代表开关为开的意思。


那TextUtils.equals(navStyle, "0"/"1"/"2") 中的“0”,“1”,“2” 我该如何知道代表什么意思?


老板,请不要再问我为什么需求吞吐率不高,做需求慢了,可能是因为我的想象力不够。


修改建议:


实际上,协议约定时就不应该以 “0”,“1”,“2” 这类无意义的数字做区分声明。


public static final String FESTIVAL_ON = "1";
public static final String NAV_STYLE_FESTIVAL = "0";
public static final String NAV_STYLE_SKIN = "1";
public static final String NAV_STYLE_DARK = "2";

public static void updateImmersiveStatusBar(Context context) {
....
if (TextUtils.equals(isFestivalOn, FESTIVAL_ON)) {
if (TextUtils.equals(navStyle, NAV_STYLE_FESTIVAL)
|| TextUtils.equals(navStyle, NAV_STYLE_SKIN)) {
....
} else if (TextUtils.equals(navStyle, NAV_STYLE_DARK)) {
....
}
}
....
}

5.2 避免在名字中拼错单词


Case1:接口拼错单词,实现类也被迫保持队形

代码示例:


public interface xxx {
....
void destory();
}

image.png


修改建议:


public interface xxx {
....
void destroy();
}

6、类的命名


应该总是名词在最后面,名词决定了这个类代表什么,前面的部分都是用于修饰这个名词;比如,假如现在你有一个服务,然后又是一 个关于订单的服务,那就可以命名为OrderService,这样命名就是告诉我们这是一个服务,然后是一个订单服务;再比如 CancelOrderCommand,看到这个我们就知道这是一个Command,即命令,然后是什么命令呢?就是一个取消订单的命令,CancelOrder表示取消订单。


类的命名可以参考前面讲述过的规则。实际上往往了解一个类更多需要通过查看类的方法定义,而仅仅通过类名无法知晓类是如何工作的。关于类的更多内容,会在后续章节详细展开。


7、方法的命名


可以用一个较强的动词带目标的形式。一个方法往往是对某一目标进行操作,名字应该反映出这个操作过程是干什么的,而对某一目标进行操作则意味着我们应该使用动宾词组。比如:addOrder()。当方法有返回值的时候,方法应该用它所返回的值命名,比如currentPenColor()。


《代码大全》:变量名称的最佳长度是 9 到 15 个字母,方法往往比变量要复杂,因而其名字也要长些。有学者认为恰当的长度是 20 到 35 个字母。但是,一般来说 15 到 20 个字母可能更现实一些,不过有些名称可能有时要比它长。


7.1 避免对方法使用无意义或者模棱两可的动词


避免无意义或者模棱两可的动词 。有些动词很灵活,可以有任何意义,比如 HandleCalculation(),processInput()等方法并没有告诉你它是作什么的。这些名字最多告诉你,它们正在进行一些与计算或输入等有关的处理。


所用的动词意义模糊是由于方法本身要做的工作太模糊。方法存在着功能不清的缺陷,其名字模糊只不过是个标志而已。如果是这种情况,最好的解决办法是重新构造这个方法,弄清它们的功能,从而使它们有一个清楚的、精确描述其功能的名字。


Case1: 名不副实的process

代码示例:


/**
* 处理主图的数据
*
* @return 如果有浮层数据就返回true,没有就返回false
*/

private boolean processMainPic() {
....
boolean hasMainPicFloat = false;
....
return hasMainPicFloat;
}

// 调用处
boolean hasMainPicFloat = processMainPic();

大脑活动:


1、方法名的字面意思是处理主图(暂不纠结缩写Pic了),但是是如何处理主图的呢?


2、返回值是bool类型,是表示处理成功或失败吗?


3、查看注释解释,当前方法是在处理主图的数据,返回为是否存在浮层数据,为什么一个处理主图数据的方法检查的是浮层数据呢?


看完发现,这个方法原来是拿主图数据检查其中是否存在浮层数据,名不副实呀。


修改建议:


额外说明:既然工程默认“Float”是浮层,这里不做额外修改,但实际上不合理,毕竟Float在Java中表示浮点型数据类型,会引起误解。


/**
* 是否有浮层数据
*
* @return 如果有浮层数据就返回true,没有就返回false
*/

private boolean hasFloatData($MainPictureData) {
....
boolean hasFloatData = false;
....
return hasFloatData;
}

// 调用处
boolean hasFloatData = hasFloatData(mainPictureData);

Case2: 我该如何正确使用这个方法

代码示例:


// 10多处调用
... = GatewayUtils.processTime(System.currentTimeMillis());

public class GatewayUtils {
....
// 这个方法没有注释
public static long processTime(long time) {
return time + (SDKUtils.getTimeOffset() * 1000L);
}
....
}

大脑活动:


好多地方调用工具类的processTime,processTime到底是在处理些什么呢?


如果入参传入的不是 System.currentTimeMillis() 而是 SystemClock.uptimeMillis() 或者随意传入一个long值,方法的返回值会是什么呢?


修改建议:


public static long currentNetworkTime() {
return System.currentTimeMillis() + (SDKUtils.getTimeOffset() * 1000L);
}

7.2 避免返回和方法名定义不一致的类型


Case1: 私有方法就可以乱定义吗?

码示例:


// 唯一调用处
final IPageProvider pageProvider = checkActivityAvaliable();
if (pageProvider == null) {
....
return;
}

// 函数声明
private IPageProvider checkActivityAvaliable() {
IPageProvider pageProvider = pageProviderWeakReference.get();
if (pageProvider == null) {
PopFactory.destroyPopCenter(pageName);
return null;
}
return pageProvider;
}

大脑活动:


check方法如果有返回值的话不应该是bool类型吗?


“Avaliable”拼错了诶,正确的单词拼写是:“Available”。


“IPageProvider” 和 “ActivityAvaliable” 是什么关系,为什么校验可用的Activity返回的是“IPageProvider”。


代码跟读:


原来方法里面偷偷做了一个销毁“PopCenter”的动作。把获取“PageProvider”和销毁“PopCenter”两件事情放在了一起。确实没看懂方法名和方法所做任何一件事情有什么关系。


修改建议:


干掉checkActivityAvaliable()方法。(这里不展开讨论高质量的函数相关内容)


final IPageProvider pageProvider = pageProviderWeakReference.get();
if (pageProvider == null) {
PopFactory.destroyPopCenter(pageName);
....
return;
}

04 养成良好的命名习惯一些建议


1.对自己严格自律,自己写代码时要有一种希望把每个名称都命名好的强烈意识和严格的自律意识;


2.要努力分析和思考当前被你命名的事物或逻辑的本质;这点非常关键,思考不深入,就会导致最后对这个事物的命名错误,因为你还没想清楚被你命名的事物是个什么东西;


3.你的任何一个属性的名字都要和其实际所代表的含义一致;你的任何一个方法所做的事情都要和该方法的名字的含义一致;


4.要让你的程序的每个相似的地方的命名风格总是一致的。不要一会儿大写,一会儿小写;一会儿全称一会儿简写;一会儿帕斯卡(Pascal)命名法,一会儿骆驼(Camel)命名法或匈牙利命名法;


作者:阿里云云原生
来源:juejin.cn/post/7225524569506005053
收起阅读 »

使用双token实现无感刷新,前后端详细代码

前言 近期写的一个项目使用双token实现无感刷新。最后做了一些总结,本文详细介绍了实现流程,前后端详细代码。前端使用了Vue3+Vite,主要是axios封装,服务端使用了koa2做了一个简单的服务器模拟。 一、token 登录鉴权 jwt:JSON Web...
继续阅读 »

微信图片_2022090618343531.jpg


前言


近期写的一个项目使用双token实现无感刷新。最后做了一些总结,本文详细介绍了实现流程,前后端详细代码。前端使用了Vue3+Vite,主要是axios封装,服务端使用了koa2做了一个简单的服务器模拟。


一、token 登录鉴权


jwt:JSON Web Token。是一种认证协议,一般用来校验请求的身份信息和身份权限。
由三部分组成:Header、Hayload、Signature


header:也就是头部信息,是描述这个 token 的基本信息,json 格式


{
"alg": "HS256", // 表示签名的算法,默认是 HMAC SHA256(写成 HS256)
"type": "JWT" // 表示Token的类型,JWT 令牌统一写为JWT
}

payload:载荷,也是一个 JSON 对象,用来存放实际需要传递的数据。不建议存放敏感信息,比如密码。


{
"iss": "a.com", // 签发人
"exp": "1d", // expiration time 过期时间
"sub": "test", // 主题
"aud": "", // 受众
"nbf": "", // Not Before 生效时间
"iat": "", // Issued At 签发时间
"jti": "", // JWT ID 编号
// 可以定义私有字段
"name": "",
"admin": ""
}

Signature 签名 是对前两部分的签名,防止数据被篡改。
需要指定一个密钥。这个密钥只有服务器才知道,不能泄露。使用 Header 里面指定的签名算法,按照公式产生签名。


算出签名后,把 Header、Payload、Signature 三个部分拼成的一个字符串,每个部分之间用 . 分隔。这样就生成了一个 token


二、何为双 token



  • accessToken:用户获取数据权限

  • refreshToken:用来获取新的accessToken


双 token 验证机制,其中 accessToken 过期时间较短,refreshToken 过期时间较长。当 accessToken 过期后,使用 refreshToken 去请求新的 token。


双 token 验证流程



  1. 用户登录向服务端发送账号密码,登录失败返回客户端重新登录。登录成功服务端生成 accessToken 和 refreshToken,返回生成的 token 给客户端。

  2. 在请求拦截器中,请求头中携带 accessToken 请求数据,服务端验证 accessToken 是否过期。token 有效继续请求数据,token 失效返回失效信息到客户端。

  3. 客户端收到服务端发送的请求信息,在二次封装的 axios 的响应拦截器中判断是否有 accessToken 失效的信息,没有返回响应的数据。有失效的信息,就携带 refreshToken 请求新的 accessToken。

  4. 服务端验证 refreshToken 是否有效。有效,重新生成 token, 返回新的 token 和提示信息到客户端,无效,返回无效信息给客户端。

  5. 客户端响应拦截器判断响应信息是否有 refreshToken 有效无效。无效,退出当前登录。有效,重新存储新的 token,继续请求上一次请求的数据。


注意事项



  1. 短token失效,服务端拒绝请求,返回token失效信息,前端请求到新的短token如何再次请求数据,达到无感刷新的效果。

  2. 服务端白名单,成功登录前是还没有请求到token的,那么如果服务端拦截请求,就无法登录。定制白名单,让登录无需进行token验证。


三、服务端代码


1. 搭建koa2服务器


全局安装koa脚手架


npm install koa-generator -g

创建服务端 直接koa2+项目名


koa2 server

cd server 进入到项目安装jwt


npm i jsonwebtoken

为了方便直接在服务端使用koa-cors 跨域


npm i koa-cors

在app.js中引入应用cors


const cors=require('koa-cors')
...
app.use(cors())

2. 双token


新建utils/token.js


const jwt=require('jsonwebtoken')

const secret='2023F_Ycb/wp_sd' // 密钥
/*
expiresIn:5 过期时间,时间单位是秒
也可以这么写 expiresIn:1d 代表一天
1h 代表一小时
*/

// 本次是为了测试,所以设置时间 短token5秒 长token15秒
const accessTokenTime=5
const refreshTokenTime=15

// 生成accessToken
const accessToken=(payload={})=>{ // payload 携带用户信息
return jwt.sign(payload,secret,{expireIn:accessTokenTime})
}
//生成refreshToken
const refreshToken=(payload={})=>{
return jwt.sign(payload,secret,{expireIn:refreshTokenTime})
}

module.exports={
secret,
setAccessToken,
setRefreshToken
}

3. 路由


直接使用脚手架创建的项目已经在app.js使用了路由中间件
在router/index.js 创建接口


const router = require('koa-router')()
const jwt = require('jsonwebtoken')
const { accesstoken, refreshtoken, secret }=require('../utils/token')

/*登录接口*/
router.get('/login',()=>{
let code,msg,data=null
code=2000
msg='登录成功,获取到token'
data={
accessToken:accessToken(),
refreshToken:refreshToken()
}
ctx.body={
code,
msg,
data
}
})

/*用于测试的获取数据接口*/
router.get('/getTestData',(ctx)=>{
let code,msg,data=null
code=2000
msg='获取数据成功'
ctx.body={
code,
msg,
data
}
})

/*验证长token是否有效,刷新短token
这里要注意,在刷新短token的时候回也返回新的长token,延续长token,
这样活跃用户在持续操作过程中不会被迫退出登录。长时间无操作的非活
跃用户长token过期重新登录
*/

router.get('/refresh',(ctx)=>{
let code,msg,data=null
//获取请求头中携带的长token
let r_tk=ctx.request.headers['pass']
//解析token 参数 token 密钥 回调函数返回信息
jwt.verify(r_tk,secret,(error)=>{
if(error){
code=4006,
msg='长token无效,请重新登录'
} else{
code=2000,
msg='长token有效,返回新的token'
data={
accessToken:accessToken(),
refreshToken:refreshToken()
}
}
})
})


4. 应用中间件


utils/auth.js


const { secret } = require('./token')
const jwt = require('jsonwebtoken')

/*白名单,登录、刷新短token不受限制,也就不用token验证*/
const whiteList=['/login','/refresh']
const isWhiteList=(url,whiteList)=>{
return whiteList.find(item => item === url) ? true : false
}

/*中间件
验证短token是否有效
*/

const cuth = async (ctx,next)=>{
let code, msg, data = null
let url = ctx.path
if(isWhiteList(url,whiteList)){
// 执行下一步
return await next()
} else {
// 获取请求头携带的短token
const a_tk=ctx.request.headers['authorization']
if(!a_tk){
code=4003
msg='accessToken无效,无权限'
ctx.body={
code,
msg,
data
}
} else{
// 解析token
await jwt.verify(a_tk,secret.(error)=>{
if(error)=>{
code=4003
msg='accessToken无效,无权限'
ctx.body={
code,
msg,
datta
}
} else {
// token有效
return await next()
}
})
}
}
}
module.exports=auth

在app.js中引入应用中间件


const auth=requier(./utils/auth)
···
app.use(auth)

其实如果只是做一个简单的双token验证,很多中间件是没必要的,比如解析静态资源。不过为了节省时间,方便就直接使用了koa2脚手架。


最终目录结构:


双token服务端目录结构.png


四、前端代码


1. Vue3+Vite框架


前端使用了Vue3+Vite的框架,看个人使用习惯。


npm init vite@latest client_side

安装axios


npm i axios

2. 定义使用到的常量


config/constants.js


export const ACCESS_TOKEN = 'a_tk' // 短token字段
export const REFRESH_TOKEN = 'r_tk' // 短token字段
export const AUTH = 'Authorization' // header头部 携带短token
export const PASS = 'pass' // header头部 携带长token

3. 存储、调用过期请求


关键点:把携带过期token的请求,利用Promise存在数组中,保持pending状态,也就是不调用resolve()。当获取到新的token,再重新请求。
utils/refresh.js


export {REFRESH_TOKEN,PASS} from '../config/constants.js'
import { getRefreshToken, removeRefreshToken, setAccessToken, setRefreshToken} from '../config/storage'

let subsequent=[]
let flag=false // 设置开关,保证一次只能请求一次短token,防止客户多此操作,多次请求

/*把过期请求添加在数组中*/
export const addRequest = (request) => {
subscribes.push(request)
}

/*调用过期请求*/
export const retryRequest = () => {
console.log('重新请求上次中断的数据');
subscribes.forEach(request => request())
subscribes = []
}

/*短token过期,携带token去重新请求token*/
export const refreshToken=()=>{
if(!flag){
flag = true;
let r_tk = getRefershToken() // 获取长token
if(r_tk){
server.get('/refresh',Object.assign({},{
headers:{[PASS]=r_tk}
})).then((res)=>{
//长token失效,退出登录
if(res.code===4006){
flag = false
removeRefershToken(REFRESH_TOKEN)
} else if(res.code===2000){
// 存储新的token
setAccessToken(res.data.accessToken)
setRefreshToken(res.data.refreshToken)
flag = false
// 重新请求数据
retryRequest()
}
})
}
}
}

4. 封装axios


utlis/server.js


import axios from "axios";
import * as storage from "../config/storage"
import * as constants from '../config/constants'
import { addRequest, refreshToken } from "./refresh";

const server = axios.create({
baseURL: 'http://localhost:3004', // 你的服务器
timeout: 1000 * 10,
headers: {
"Content-type": "application/json"
}
})

/*请求拦截器*/
server.interceptors.request.use(config => {
// 获取短token,携带到请求头,服务端校验
let aToken = storage.getAccessToken(constants.ACCESS_TOKEN)
config.headers[constants.AUTH] = aToken
return config
})

/*响应拦截器*/
server.interceptors.response.use(
async response => {
// 获取到配置和后端响应的数据
let { config, data } = response
console.log('响应提示信息:', data.msg);
return new Promise((resolve, reject) => {
// 短token失效
if (data.code === 4003) {
// 移除失效的短token
storage.removeAccessToken(constants.ACCESS_TOKEN)
// 把过期请求存储起来,用于请求到新的短token,再次请求,达到无感刷新
addRequest(() => resolve(server(config)))
// 携带长token去请求新的token
refreshToken()
} else {
// 有效返回相应的数据
resolve(data)
}

})

},
error => {
return Promise.reject(error)
}
)

5. 复用封装


import * as constants from "./constants"

// 存储短token
export const setAccessToken = (token) => localStorage.setItem(constanst.ACCESS_TOKEN, token)
// 存储长token
export const setRefershToken = (token) => localStorage.setItem(constants.REFRESH_TOKEN, token)
// 获取短token
export const getAccessToken = () => localStorage.getItem(constants.ACCESS_TOKEN)
// 获取长token
export const getRefershToken = () => localStorage.getItem(constants.REFRESH_TOKEN)
// 删除短token
export const removeAccessToken = () => localStorage.removeItem(constants.ACCESS_TOKEN)
// 删除长token
export const removeRefershToken = () => localStorage.removeItem(constants.REFRESH_TOKEN)

6. 接口封装


apis/index.js


import server from "../utils/server";
/*登录*/
export const login = () => {
return server({
url: '/login',
method: 'get'
})
}
/*请求数据*/
export const getData = () => {
return server({
url: '/getList',
method: 'get'
})
}

项目运行


双token前端.png


最后的最后,运行项目,查看效果
后端设置的短token5秒,长token10秒。登录请求到token后,请求数据可以正常请求,五秒后再次请求,短token失效,这时长token有效,请求到新的token,refresh接口只调用了一次。长token也过期后,就需要重新登录啦。
效果.png


写在最后


这就是一整套的前后端使用双token机制实现无感刷新。token能做到的还有很多,比如权限管理、同一账号异地登录。本文只是浅显的应用了一下。


下次空余时间写写大文件切片上传,写文不易,大家多多点赞。感谢各位看官老爷。


作者:夜琛白
来源:juejin.cn/post/7224764099187736634
收起阅读 »

如何按百分比将功能灰度放量

当我们发布新功能时,需要尽可能降低因新功能发布所导致的线上风险,通常会采取灰度放量的方式将新功能逐步发布给用户。在具体实施灰度放量时,我们可以根据业务需求选择相应的放量规则,常见如按白名单放量(如仅 QA 可见)、按特定人群属性放量(如仅某个城市的用户可见)亦...
继续阅读 »

当我们发布新功能时,需要尽可能降低因新功能发布所导致的线上风险,通常会采取灰度放量的方式将新功能逐步发布给用户。在具体实施灰度放量时,我们可以根据业务需求选择相应的放量规则,常见如按白名单放量(如仅 QA 可见)、按特定人群属性放量(如仅某个城市的用户可见)亦或是按用户百分比放量。


当我们选择将功能以用户百分比放量时,如下图所示,会先将功发布给10% 内部用户,此时即便出现问题影响也相对可控,如观察没有问题后逐步扩大需要放量的用户百分比,实现从少量到全量平滑过渡的上线。



那么在 FeatureProbe 上要如何实现百分比放量?


下面将通过一个实际的例子介绍如何通过 FeatureProbe 实现按百分比放量发布一个新功能。


步骤一:创建一个特性开关



接着,配置开关百分比信息。以收藏功能百分比发布为例,设置 10%  的用户可用收藏功能,而另外 90% 的用户无法使用收藏功能。



步骤二:将 SDK 接入应用程序


接下来,将 FeatureProbe SDK 接入应用程序。FeatureProbe 提供完整清晰的接入引导,只需按照步骤即可快速完成 SDK 接入。


1、选择所使用的 SDK



2、按步骤设置应用程序



3、测试应用程序 SDK接入情况



步骤三:按百分比放量发布开关


开关信息配置和 SDK 接入都完成后,点击发布按钮并确认发布。这将会将收藏功能发布给用户,但只有10%的用户可以使用收藏功能。



如果希望逐步扩大灰度范围,可以在开关规则中配置百分比比例。



大部分情况下,我们希望在一个功能的灰度放量过程中,某个特定用户一旦进入了灰度放量组,在灰度比例不减少的情况下,总是进入灰度组。不希望用户因为刷新页面、重新打开APP、请求被分配到另一个服务端实例等原因,一会看到新功能,一会看不到新功能,从而感到迷惑。要达到用户稳定进入灰度组,只需要在上述代码第三步创建 User 时指定stableRollout 即可,具体使用详情见:docs.featureprobe.io/zh-CN/tutor…


总结


灰度按百分比放量是一种软件开发中常用的功能发布方法,它可以帮助提高软件可靠性,提高用户体验,在实施时也需要注意几个方面:


1、确定放量目标:首先需要确定放量的目标,例如增加多少百分比的数据量。这个目标需要根据实际情况进行制定,例如需要考虑数据量的大小、计算资源的限制等因素。


2、确定放量规则:你需要确定在放量过程中,哪些功能会被启用,哪些功能会被禁用。你可以根据开发进度、测试结果和市场需求等因素来确定放量规则。


3、监控放量过程:在实施放量操作时,需要监控放量过程,以确保放量结果的稳定性和可靠性。如果出现异常情况,需要及时采取措施进行调整。


若要了解有关FeatureProbe 灰度发布的更多信息,请查看其官方文档中的教程。该教程可以提供关于如何进行灰度发布的详细说明。文档中还包括其他相关主题的信息,例如如何进行服务降级和指标分析等。请访问以下链接以查看该文档:docs.featureprobe.io/zh-CN/tutor…


关于我们


FeatureProbe 是国内首家功能开关管理开源平台,它包含了灰度放量、AB实验、实时配置变更等针对『功能粒度』的一系列管理操作,完全开源,可以放心直接使用。


当前 FeatureProbe 作为一个功能开关管理平台已经使用 Apache 2.0 License 协议完全开源,你可以在 GitHub 和 Gitee 上访问源码,你也可以在上面给提 issue 和 feature 等。


GitHub: github.com/FeatureProb…


Gitee: gitee.com/featureprob…


体验环境: featureprobe.io/


作者:FeatureProbe
来源:juejin.cn/post/7224045063424049208
收起阅读 »

ElasticSearch数据存储与搜索基本原理

1.缘起: 为啥想学习es,主要是在工作中会用到,但是因为不了解原理,所以用起来畏手畏脚的,就想了解下es是怎么存储数据,以及es是怎么搜索数据的,我们平时应该如 何使用es,以及使用时候需要注意的方面。 es:github.com/elastic/ela… ...
继续阅读 »

1.缘起:


为啥想学习es,主要是在工作中会用到,但是因为不了解原理,所以用起来畏手畏脚的,就想了解下es是怎么存储数据,以及es是怎么搜索数据的,我们平时应该如 何使用es,以及使用时候需要注意的方面。
es:github.com/elastic/ela…
lucene:github.com/apache/luce…


2.es的一些基础概念


es是一个基于lucence的分布式的搜索引擎,它使用java编写,并提供了一套RESTful api,是一款流行的企业级搜索引擎


2.1 es的特点



  1. 横向可扩展性: 作为大型分布式集群, 很容易就能扩展新的服务器到ES集群中; 也可运行在单机上作为轻量级搜索引擎使用.

  2. 更丰富的功能: 与传统关系型数据库相比, ES提供了全文检索、同义词处理、相关度排名、复杂数据分析、海量数据的近实时处理等功能.

  3. 分片机制提供更好地分布性: 同一个索引被分为多个分片(Shard), 利用分而治之的思想提升处理效率.

  4. 高可用: 提供副本(Replica)机制, 一个分片可以设置多个副本, 即使在某些服务器宕机后, 集群仍能正常工作.

  5. 开箱即用: 提供简单易用的API, 服务的搭建、部署和使用都很容易操作.


2.2 es的重要概念


1.cluster(集群)
可以通过为多个节点配置同一个集群名来创建集群,通过elasticsearch.yml文件配置。
2.node(节点)
运行了单个es实例的主机被成为节点,一个集群里会包含一个或者多个节点。可以用来存储数据,搜索数据,操作数据。有三个主节点,三个数据节点。


3.shard(分片)
一个索引会分成多个分片,并存储在不同的节点中。每个shard都是一个最小工作单元,承载部分数据,对应一个lucene实例,具有完整的建立索引和处理请求的能力。shard分为primary shard和replica shard,其中replica shard 负责容错,以及承担读请求负载。一个document只会存在一个primary shard及其replica shard中,而不会存在于多个primary shard中。shards:5*2,表示有五个primary shard 以及五个replica shard。一旦创建完成,其primary shard的数量将不可更改。


4.index(索引)
一堆类型数据结构相同的document的集合。类似于数据库中的表
5.document( 文档)
es中的最小数据单元。比如一条纠纷单的数据,存在es中就是一个document。但是存储格式为json。类似于数据库中的一行数据。
6.type
类型,一个索引会存在一个或者多个 type,一个type下的document有相同的field。7.x后废弃
7.field
一个field就是一个数据字段
8.term
field的内容在经过analyze后,会被分词为term,是数据中最小的存储单位
9.数据库和es概念类比Elasticsearch 关系型数据库


3.es是如何存储数据的


3.1 es写入数据过程


在这里插入图片描述



  1. 用户发送的请求会随机打到某一node,此时这个node为coordinate node

  2. coordinate node通过路由策略找到对应的主分片 shard = hash(routing) % number_of_primary_shards,其中routing为docId,如果docId不存在,es会生成一个id来实现路由。主分片也会把请求转发到副本分片,实现数据备份。索引的primary shards在索引创建后不可更改也是因为路由策略,举个例子,对于同一个docId,原本存在primary shard 0 中,但是primary shard num修改后,就被路由到primary shard 3,这样就出现数据查不到的情况了。

  3. 主分片&副本分片会构建索引以及将索引落盘


3.2 es近实时特性的原理



  1. 写请求将数据写入buffer中,此时数据是不能被搜索到的,此时会同时将写操作记录在translog中,translog的落盘如果配置成同步,此时就会落盘,如果配置成异步,会在配置间隔时间进行落盘。

  2. 默认1s一次的refresh操作,es会将buffer里的数据存入os cache中,并把buffer中的数据转化成segment,此时document便可以被搜索到了。每次refresh都会生成一个segment,es会定期进行segment合并。refresh数据到os cache后,buffer会被清空。

  3. 每隔30min或者segment达到512M 后,会把os cache中的segment写入到磁盘中,这个过程叫做flush。此时会生成一个commit point文件,用来唯一标识该segment。执行flush操作时,会把buffer和os cache里的数据清空,此时translog也会被落盘。原有的translog会被删除,会在内存中创建一个新的translog。


3.3 segment的数据结构以及索引的原理


segment是lucene的概念,也是实现搜索的关键。名称 扩展名 简要描述
Term Dictionary .tim term词典,存储term信息
Term Index .tip 指向term词典的索引
Frequencies .doc 包含了有term的频率的文档列表
Positions .pos 存储了每个出现在索引中的term的位置信息
Payloads .pay 存储了额外的每一个位置的元数据信息如一个字符偏移和用户的负载
Field Index .fdx 包含field data的指针
Field Data .fdt 存储docs里的field表1 segment文件


其中和倒排索引相关的是.tim、.tip、.doc、.pos、.pay文件,而和正向索引相关的是.fdx、.fdt文件。下面先讲下倒排索引


3.3.1 lucene的倒排索引结构


倒排索引,其实从字面意义上很容易理解错,但是看英文就会好理解一些,inverted index,反向的索引。为什么称之为反向的索引,那应该有正向索引,正向索引指的是文档id和文档内容的映射关系,mysql的主键索引就是一个正向索引,而倒排索引,就是把这种对应关系颠倒,指的是,索引词(关键词)和文档之间的对应关系,即通过一个关键词,可以得到包含这个关键词的所有文档的文档id。
在这里插入图片描述



  1. 通过对查询语句的解析,得到需要查找的term,找到该term对应的.tip文件和.tim文件。lucene会默认为每一个term都创建对应的索引

  2. term index主要由FSTIndex和indexStartFP组成,FST(Finite State Transducer)有限状态转移机。我觉得可以理解为一个词语前缀索引。通过对前缀索引的搜索,就可以缩小搜索的范围,提高搜索的效率。

  3. indexStartFPn里存的是FSTIndexn的地址, 为啥要存indexStartFPn,是因为每个FSTIndexn的大小不一样,为了节省存储空间,密集存储FSTIndexn,但是这样就没办法快速查找FSTIndexn。因此使用空间换时间,存下每个FSTIndexn的起始地址,而indexStartFP的大小都一样,这样就可以通过indexStartFPn进行二分查找了。

  4. 通过term的前缀匹配定位到该term可能存在的block,此时就需要到.tim文件里去查找。可以看到.tim文件我们比较关注的是三部分。一是suffix;二是TermStats;三是TermsMetaData。其中suffix里存放的就是该term的后缀长度和suffix的内容。TermStats里包含的是该Term在文档中的频率以及所有 Term的频率,这部分是为了计算相关性。

  5. 第三部分TermsMetaData里存放的是该Term在.doc、.pos、.pay中的地址。.doc文件中存放的是docId信息,包括这个term所在的docId、频率等信息。.pos文件里包含该term在每个文档里的位置。通过.tim文件里存放的这些地址,就可以去对应的文件里得出该term所在的文档id、位置、频率这些重要的信息了。


3.3.2 lucene的正向索引结构


通过倒排索引拿到的docId后,如何去拿到文档的其他字段信息,这时候就需要用到正向索引了
在这里插入图片描述


● fdt文件里每个chunk里包含压缩后的doc信息。fdx文件里存放的是每组chunk的起始地址
● fdx文件较小,直接加载到内存
● 通过fdx文件拿到对应的docid所在chunk的地址,再加载doc的数据信息。
● 通过正向索引拿到doc的数据类似于mysql里的回表操作


3.3.3 和mysql索引的对比


在这里插入图片描述


● 默认设置情况下 lucene会为doc里的每个term都创建对应的倒排、正向索引,而mysql只会为你指定的列创建索引,因此对于复杂查询场景,使用es来查询更合适,mysql无法构建索引覆盖所有的查询情况
● mysql的索引是存放在磁盘里的,检索时需要分页加载到内存再检索;lucene的.tip文件很小,可以直接放入内存,在检索时候,通过term index来快速定位到term可能存在的block。相当于给索引(词典表.tim文件)又建立了一层索引,查询效率更高


4.es是如何搜索数据的


在这里插入图片描述



  1. 用户发送的搜索请求会随机达到任意一个node上,该node即为coordinate node。

  2. coordinate node会将请求转发到该索引对应的所有的primary shard或者replica shard中

  3. 每个shard处理query请求,通过lucene的搜索能力,将搜索结果返回给coordinate node

  4. coordinate node将所有的结果处理后,根据排序要求取topk,再返回给client。


每一个分片中发生的搜索过程
在这里插入图片描述



  1. 对用户的请求语句进行词法、语法分析,生成查询语法树,把文本请求转换为Lucene理解的请求对象

  2. 按照查询语法树,搜索索引获取最终匹配的文档id集合

  3. 对查询结果进行相关性排序,获取Top N的文档id集合,获取文档原始数据后返回用户
    5.一些关于es的使用思考

  4. 对于复杂的查询场景,es查询优于mysql,对于实时性要求高,或需要实现事务操作的场景,需要使用mysql。

  5. 由于es是通过查询所有分片,合并后再给出最终查询结果,所以也和mysql一样,需要注意深分页的问题,不过这块es已经做了限制,默认只返回前1w条的查询结果

  6. 索引过大也会导致查询慢,可以从上面讲的索引结构看出,虽然term index是可以加载到内存的,但是最终的term dict也是存在磁盘里的,对于具体term的查询需要花费很多时间。此时可以考虑重建索引,使用更多的分片来存储/查询数据。

  7. 尽可能地使用filter来代替query,query需要对查询结果进行相关性排序,而filter则不需要。


作者:chenyuxi
来源:juejin.cn/post/7222440107214274597
收起阅读 »

我调用第三方接口遇到的13个坑

前言 在实际工作中,我们经常需要在项目中调用第三方API接口,获取数据,或者上报数据,进行数据交换和通信。 那么,调用第三方API接口会遇到哪些问题?如何解决这些问题呢? 这篇文章就跟大家一起聊聊第三方API接口的话题,希望对你会有所帮助。 1 域名访问不到...
继续阅读 »

前言


在实际工作中,我们经常需要在项目中调用第三方API接口,获取数据,或者上报数据,进行数据交换和通信。


那么,调用第三方API接口会遇到哪些问题?如何解决这些问题呢?


这篇文章就跟大家一起聊聊第三方API接口的话题,希望对你会有所帮助。


图片


1 域名访问不到


一般我们在第一次对接第三方平台的API接口时,可能会先通过浏览器或者postman调用一下,该接口是否可以访问。


有些人可能觉得多次一举。


其实不然。


有可能你调用第三方平台的API接口时,他们的接口真的挂了,他们还不知道。


还有一种最重要的情况,就是你的工作网络,是否可以访问这个外网的接口。


有些公司为了安全考虑,对内网的开发环境,是设置了防火墙的,或者有一些其他的限制,有些ip白名单,只能访问一些指定的外网接口。


如果你发现你访问的域名,在开发环境访问不通,就要到运维同学给你添加ip白名单了。


2 签名错误


很多第三方API接口为了防止别人篡改数据,通常会增加数字签名(sign)的验证。


sign = md5(多个参数拼接 + 密钥)


在刚开始对接第三方平台接口时,会遇到参数错误,签名错误等问题。


其中参数错误比较好解决,重点是签名错误这个问题。


签名是由一些算法生成的。


比如:将参数名和参数值用冒号拼接,如果有多个参数,则按首字母排序,然后再将多个参数一起拼接。然后加盐(即:密钥),再通过md5,生成一个签名。


如果有多个参数,你是按首字母倒序的,则最后生成的签名会出问题。


如果你开发环境的密钥,用的生产环境的,也可能会导致生产的签名出现问题。


如果第三方平台要求最后3次md5生成签名,而你只用了1次,也可能会导致生产的签名出现问题。


因此,接口签名在接口联调时是比较麻烦的事情。


如果第三方平台有提供sdk生成签名是最好的,如果没有,就只能根据他们文档手写签名算法了。


3 签名过期


通过上面一步,我们将签名调通了,可以正常访问第三方平台获取数据了。


但你可能会发现,同一个请求,15分钟之后,再获取数据,却返回失败了。


第三方平台在设计接口时,在签名中增加了时间戳校验,同一个请求在15分钟之内,允许返回数据。如果超过了15分钟,则直接返回失败。


这种设计是为了安全考虑。


防止有人利用工具进行暴力破解,不停伪造签名,不停调用接口校验,如果一直穷举下去的话,总有一天可以校验通过的。


sign = md5(多个参数拼接 + 密钥 + 时间戳)


因此,有必要增加时间戳的校验。


如果出现这种情况,不要慌,重新发起一次新的请求即可。


4 接口突然没返回数据


如果你调用第三方平台的某个API接口查询数据,刚开始一直都有数据返回。


但突然某一天没返回数据了。


但是该API接口能够正常响应。


不要感到意外,有可能是第三方平台将数据删除了。


我对接完第三方平台的API接口后,部署到了测试环境,发现他们接口竟然没有返回数据,原因是他们有一天将测试环境的数据删完了。


因此,在部署测试环境之前,要先跟对方沟通,要用哪些数据测试,不能删除。


5 token失效


有些平台的API接口在请求之前,先要调用另外一个API接口获取token,然后再header中携带该token信息才能访问其他的业务API接口。


在获取token的API接口中,我们需要传入账号、密码和密钥等信息。每个接口对接方,这些信息都不一样。


我们在请求其他的API接口之前,每次都实时调用一次获取token的接口获取token?还是请求一次token,将其缓存到redis中,后面直接从redis获取数据呢?


很显然我们更倾向于后者,因为如果每次请求其他的API接口之前,都实时调用一次获取token的接口获取token,这样每次都会请求两次接口,性能上会有一些影响。


如果将请求的token,保存到redis,又会出现另外一个问题:token失效的问题。


我们调用第三方平台获取token的接口获取到的token,一般都有个有效期,比如:1天,1个月等。


在有效期内,该API接口能够正常访问。如果超过了token的有效期,则该API接口不允许访问。


好办,我们把redis的失效时间设置成跟token的有效期一样不就OK了?


想法是不错,但是有问题。


你咋保证,你们系统的服务器时间,跟第三方平台的服务器时间一模一样?


我之前遇到过某大厂,提供了获取token接口,在30天内发起请求,每次都返回相同的token值。如果超过了30天,则返回一个新的。


有可能出现这种情况,你们系统的服务器时间要快一些,第三方平台的时间要慢一些。结果到了30天,你们系统调用第三方平台的获取token接口获取到了token还是老的token,更新到redis中了。


过一段时间,token失效了,你们系统还是用老的token访问第三方平台的其他API接口,一直都返回失败。但获取新的token却要等30天,这个时间太漫长了。


为了解决这个问题,需要捕获token失效的异常。如果在调用其他的API接口是发现token失效了,马上请求一次获取token接口,将新的token立刻更新到redis中。


这样基本可以解决token失效问题,也能尽可能保证访问其他接口的稳定性和性能。


6 接口超时


系统上线之后,调用第三方API接口,最容易出现的问题,应该是接口超时问题了。


系统到外部系统之间,有一条很复杂的链路,中间有很多环节出现问题,都可能影响API接口的相应时间。


作为API接口的调用方,面对第三方API接口超时问题,除了给他们反馈问题,优化接口性能之外,我们更有效的方式,可能是增加接口调用的失败重试机制


例如:


int retryCount=0;
do {
   try {
      doPost();
      break;
   } catch(Exception e) {
     log.warn("接口调用失败")
     retryCount++;
   }
where (retryCount <= 3)

如果接口调用失败,则程序会立刻自动重试3次


如果重试之后成功了,则该API接口调用成功


如果重试3次之后还是失败,则该API接口调用失败


7 接口返回500


调用第三方API接口,偶尔因为参数的不同,可能会出现500的问题。


比如:有些API接口对于参数校验不到位,少部分必填字段,没有校验不能为空。


刚好系统的有些请求,通过某个参数去调用该API接口时,没有传入那个参数,对方可能会出现NPE问题。而该接口的返回code,很可能是500。


还有一种情况,就是该API接口的内部bug,传入不同的参数,走了不同的条件分支逻辑,在走某个分支时,接口逻辑出现异常,可能会导致接口返回500。


这种情况做接口重试也没用,只能联系第三方API接口提供者,反馈相关问题,让他们排查具体原因。


他们可能会通过修复bug,或者修复数据,来解决这个问题。


8 接口返回404


如果你在系统日志中发现调用的第三方API接口返回了404,这就非常坑了。


如果第三方的API接口没有上线,很可能是他们把接口名改了,没有及时通知你。


这种情况,可以锤他们了。


还有一种情况是,如果第三方的API接口已经上线了,刚开始接口是能正常调用的。


第三方也没有改过接口地址。


后来,突然有一天发现调用第三方的API接口还是出现了404问题。


这种情况很可能是他们网关出问题了,最新的配置没有生效,或者改了网关配置导致的问题。


总之一个字:坑。


9 接口返回少数据了


之前我调过一个第三方的API接口分页查询数据,接入非常顺利,但后来上线之后,发现他们的接口少数据了。


一查原因发现是该分页查询接口,返回的总页数不对,比实际情况少了。


有些小伙伴可能会好奇,这么诡异的问题我是怎么发现?


之前调用第三方API接口分页查询分类数据,保存到我们的第三方分类表中。


突然有一天,产品反馈说,第三方有个分类在分类树中找不到。


我确认之后,发现竟然是真的没有。


从调用第三方API接口的响应日志中,也没有查到该分类的数据。


这个API接口是分页查询接口,目前已经分了十几页查询数据,但还是没有查到我们想要的分类。


之前的做法是先调用一次API接口查询第一页的数据,同时查出总页数。然后再根据总页数循环调用,查询其他页的数据。


我当时猜测,可能是他们接口返回的总页数有问题。


于是,可以将接口调用逻辑改成这样的:



  • 从第一页开始,后面每调用一次API接口查数据,页数就加1。然后判断接口返回的数据是否小于pageSize,

  • 如果不小于,则进行下一次调用。

  • 如果小于,则说明已经是最后一页了,可以停止后续调用了。


验证之后发现这样果然可以获取那个分类的数据,只能说明第三方的分页查询接口返回的总页数比实际情况小了。


10 偷偷改参数了


我之前调用过某平台的API接口获取指标的状态,之前根据双方约定的状态有:正常禁用 两种。


然后将状态更新到我们的指标表中。


后来,双方系统上线运行了好几个月。


突然有一天,用户反馈说某一条数据明明删除了,为什么在页面上还是可以查到。


此时,我查我们这边的指标表,发现状态是正常的。


然后查看调用该平台的API接口日志,发现返回的该指标的状态是:下架


what?


这是什么状态?


跟该平台的开发人员沟通后,发现他们改了状态的枚举,增加了:上架、下架等多个值,而且没有通知我们。


这就坑了。


我们这边的代码中判断,如果状态非禁用状态,都认为是正常状态。


而下架状态,自动被判断为正常状态。


经过跟对方沟通后,他们确认下架状态,是非正常状态,不应该显示指标。他们改了数据,临时解决了该指标的问题。


后来,他们按接口文档又改回了之前的状态枚举值。


11 接口时好时坏


不知道你在调用第三方接口时,有没有遇到过接口时好时坏的情况。


5分钟前,该接口还能正常返回数据。


5分钟后,该接口返回503不可用。


又过了几分钟,该接口又能正常返回数据了。


这种情况大概率是第三方平台在重启服务,在重启的过程中,可能会出现服务暂时不可用的情况。


还有另外一种情况:第三方接口部署了多个服务节点,有一部分服务节点挂了。也会导致请求第三方接口时,返回值时好时坏的情况。


此外还有一种情况:网关的配置没有及时更新,没有把已经下线的服务剔除掉。


这样用户请求经过网关时,网关转发到了已经下线的服务,导致服务不可用。网关转发请求到正常的服务,该服务能够正常返回。


如果遇到该问题,要尽快将问题反馈给第三方平台,然后增加接口失败重试机制。


12 文档和接口逻辑不一致


之前还遇到一个第三方平台提供的API查询接口,接口文档中明确写明了有个dr字段表示删除状态


有了这个字段,我们在同步第三方平台的分类数据时,就能够知道有哪些数据是被删除的,后面可以及时调整我们这边的数据,将相关的数据也做删除处理。


后来发现有些分类,他们那边已经删除了,但是我们这边却没删除。


这是啥情况呢?


代码逻辑很简单,我review了一下代码,也没有bug,为什么会出现这种情况呢?


追查日志之后发现,调用第三方平台获取分类接口时,对方并没有把已删除的分类数据返回给我们。


也就是说接口文档中的那个dr字段没有什么用,接口文档和接口逻辑不一致。


这个问题估计好多小伙伴都遇到过。


如果要解决这个问题,主要的方案有两种:



  1. 第三方平台按文档修改接口逻辑,返回删除状态。

  2. 我们系统在调用分类查询接口之后,根据分类code判断,如果数据库中有些分类的code不在接口返回值中,则删除这些分类。


13 欠费了


我们调用过百度的票据识别接口,可以自动识别发票信息,获取发票编号和金额等信息。


之前是另外一个同事对接的接口,后来他离职了。


发票识别功能上线,使用了很长一段时间,一直都没有出问题。


后来,某一天,生产环境用户反馈发票识别不了了。


我查询了相关服务的日志,没有发现异常,这就奇怪了。


打开代码仔细看了一下,发现那位同事的代码中调用第三方的API接口,接收响应数据时,直接转换成了对象,没有打印当时返回的字符串。


莫非,接口返回值有问题?


后来,我增加了日志,打印出了该接口真正的返回内容值。


原因一下查到了,原来是欠费了。


如果出现该了异常,百度的API接口返回的数据结构,用之前那位同事的实体有些参数没法获取到。


这是一个不小的坑。


我们在接收第三方API接口返回数据时,尽可能先用字符串接收返回值,然后将字符串转换成相应实体类,一定要将该返回值在日志中打印出来,方便后面定位问题。


不要直接用实体对象接收返回值,有些API接口,如果出现不同的异常,返回的数据结构差异比较大。


有些异常结果可能是他们网关系统直接返回的,有些异常是他们业务系统返回的。


其实,我们之前还遇到过其他坑,比如:调用分类树查询接口,但第三方返回的数据有重复的id,我们这边该如何处理这种异常数据呢?


我们在job中循环调用第三方API接口获取数据,如果其中某一次调用失败了,是try/catch捕获异常呢?继续执行后面的调用,还是直接终止当前的程序?如果try/catch如何保证数据一致性?终止当前程序,该如何处理后续的流程?


最后说一句(求关注,别白嫖我)


如果这篇文章对您有所帮助,或者有所启发的话,帮忙扫描下发二维码关注一下,您的支持是我坚持写作最大的动力。


作者:苏三说技术
来源:juejin.cn/post/7222577873793368123
收起阅读 »

我遇到的一个难题,早在1966年就已经有解决方案了...

1. 起因 这一切还得从前段时间接到的一个需求讲起。 业务方需要离线计算一批A附近5公里内的B,并统计聚合B的相关指标,进行展示。 嗯,听起来很合理。🤔 2. 问题 虽然在进行前期评估时,就已经预料到计算量会很大(当时的计算方案十分简陋)。 但在实际运行过程中...
继续阅读 »

1. 起因


这一切还得从前段时间接到的一个需求讲起。

业务方需要离线计算一批A附近5公里内的B,并统计聚合B的相关指标,进行展示。


嗯,听起来很合理。🤔


2. 问题


虽然在进行前期评估时,就已经预料到计算量会很大(当时的计算方案十分简陋)。

但在实际运行过程中,还是发现附近5km的逻辑,计算效率过于低下(按照城市编码将数据拆分为3个任务进行并行计算,但平均耗时还是会达到7-10个小时,无法接受)😦


3. 一些尝试


3.1 第一版:


最开始的计算逻辑很粗暴:把每个城市内的A和B进行full join,然后根据经纬度逐个计算距离,排除掉超出距离限制的集合。肉眼可见的低效。。。


c27f53441c4b15687e02c45821dbd306.gif


3.2 第二版:


由于全量计算十分耗时,并且大部分B的坐标也不会经常变更,因此开始考虑使用增量计算进行优化,减少重复计算。

但在实际任务运行过程中发现,大量耗时用在了历史数据和新增数据的合并写入,并没有有效的效率提升。


322fe630-a978-43d8-9284-78b0865067d3.jpg


3.3 第三版:


这个时候,已经没有什么优化的头绪了。只是一次偶然的搜索,让我发现了一个全新的实现逻辑。(没错,面向google编程)


ad5f2626-5584-4ca1-8809-567474172f11.jpg


一个周五的晚上,脑袋里思索着通过经纬度计算距离的逻辑,突然一个想法出现:既然经纬度可以进行距离计算,是否意味着经纬度的数字也是和距离有着一定的转换关系,比如经度小数点后1位,代表距离xx公里?


带着这个疑问,我发现了这两篇文章。


image.png


其中 高效的多维空间点索引算法 — Geohash 和 Google S2介绍的案例,与我的需求十分相似。(大神的文章值得好好阅读)


里面提到的geohash算法,则是1966年就有人提出的一种将经纬度坐标点进行一维化编码的解决思路。而后续的google的s2、uber的h3,均是在此设计理念的基础上优化而来的。


这种算法的本质就是对地球的每一块区域都进行编码(精度可调),也就是一个编码代表着一段经纬度范围内的区域。


那么接下来问题就简单了,找到合适的编码方案以及精度参数,测试验证即可。


具体的方案选择就不重复了。可以参考这个帖子:geohash、google s2、uber h3三种格网系统比较


我这边最终选择的是h3(h3-pyspark)。


4. 最终解决


第一步:将A的经纬度按照需要的精度进行编码,再获取该编码附近x公里的区域编码集合。
image.png


第二步:将B的经纬度按照同样的精度进行编码。


第三步:将两个数据集inner join,即可获得符合要求的集合。


是的,就是这么简单。(摊手)


5ae26b5d-d753-4900-9c61-213e400f87cd.png


5. 总结


通过这次的问题解决,学习到了这类场景的通用解决方案,受益匪浅。


6. 参考文章


高效的多维空间点索引算法 — Geohash 和 Google S2

彩云天气地理查询优化(2): 行政区划查询

geohash算法

geohash、google s2、uber h3三种格网系统比较

h3-pyspark

Uber H3使用


作者:一匹二维马
来源:juejin.cn/post/7213209438714527800
收起阅读 »

为什么说过早优化是万恶之源?

  Donald Knuth(高德纳)是一位计算机科学界的著名学者和计算机程序设计的先驱之一。他被誉为计算机科学的“圣经”《计算机程序设计艺术》的作者,提出了著名的“大O符号”来描述算法的时间复杂度和空间复杂度,开发了TeX系统用于排版科技文献,获得过图灵奖、...
继续阅读 »

  Donald Knuth(高德纳)是一位计算机科学界的著名学者和计算机程序设计的先驱之一。他被誉为计算机科学的“圣经”《计算机程序设计艺术》的作者,提出了著名的“大O符号”来描述算法的时间复杂度和空间复杂度,开发了TeX系统用于排版科技文献,获得过图灵奖、冯·诺伊曼奖、美国国家科学奖章等多项荣誉。今天要说的就是他所提出的一条软件设计重要原则 Premature optimization is the root of all evil 过早优化是万恶之源
在这里插入图片描述

为什么说“过早优化是万恶之源”? 我认为过早优化代码会让人陷入到错误的目标中去,从而忽视掉了最重要的目标。举个很简单的例子,你需要快速构建一个产品来抢占用户,你当下最重要的目标是让这个产品快速上线,而不是把这个产品打造的好用(在中国互联网下,这样的事数不胜数),如果你只关注到后者体验、性能问题而忽视了速度,在当下高度竞争的市场之下,你根本毫无机会。


  当然上面这个例子是从感性的层面说的,对很多程序猿来说也可能涉及不到产品层面的内容。我们从软件设计的层面,理性的来说,过早优化可能会导致以下的一些问题:




  1. 增加代码的复杂性:过度优化可能会导致代码的复杂性增加,从而降低代码的可读性和可维护性。如果代码过于复杂,可能会导致开发人员难以理解和维护代码,从而增加开发成本和时间。




  2. 耗费开发时间和资源:过度优化可能会导致开发人员花费大量时间和资源在代码的性能优化上,而忽略了其他重要的开发任务。这可能会导致项目进度延误和开发成本增加。




  3. 降低代码的可移植性:过度优化可能会导致代码的可移植性降低。如果代码过于依赖于特定的硬件或操作系统,可能会导致代码无法在其他环境中运行。




  4. 降低代码的可扩展性:过度优化可能会降低代码的可扩展性。如果代码过于依赖于特定的算法或数据结构,可能会导致代码无法适应未来的需求变化。




过早优化的典型案例


  在软件工程史上由于过度关注软件性能导致项目最终失败的案例比比皆是,比如我下面要说的一些项目,在软件工程史上都是非常知名的项目(当然可能有些新生代程序员已经不知道了)。




  1. IBM OS/360操作系统:在20世纪60年代,IBM公司开发了OS/360操作系统,这是当时最大的软件工程项目之一。在开发过程中,IBM公司过于关注代码的性能问题,导致代码的复杂性增加,开发时间延误,最终导致项目的失败。我知晓这个项目还是在我最近在阅读的一本软件工程经典书籍《人月神话》中,也推荐大家阅读下,这个项目虽然最终失败了,但也给整个软件工程领域留下了宝贵的经验。




  2. Netscape Navigator浏览器:在20世纪90年代,Netscape公司开发了Navigator浏览器,这是当时最流行的浏览器之一。在开发过程中,Netscape公司过于关注代码的性能问题,导致代码的复杂性增加,开发时间延误,最终导致浏览器市场份额严重下降。




  3. Windows Vista操作系统:在21世纪初,微软公司开发了Windows Vista操作系统,这是当时最大的软件工程项目之一。在开发过程中,微软公司过于关注代码的性能问题,导致代码的复杂性增加,开发时间延误,最终导致操作系统的用户体验不佳,市场反响不佳。话说这个操作系统我还用过呢,用户界面还是很漂亮的,很多UI设计也被沿用到了Window7中。




如何识别过早优化


  在软件开发过程中,如何判断是否过早优化呢?这里有一些概括性的判断标准,可以简单参考下:




  1. 是否存在性能问题:如果代码还没有性能问题,那么过早优化就是不必要的。因此,在进行优化之前,应该先测试代码的性能,确定是否存在性能问题。




  2. 是否优化了未来可能发生的问题:如果优化的是未来可能发生的问题,而不是当前存在的问题,那么就可能是过早优化。在进行优化之前,应该优先考虑当前存在的问题,而不是未来可能发生的问题。




  3. 是否牺牲了代码的可读性和可维护性:如果优化代码会导致代码的复杂性增加,降低代码的可读性和可维护性,那么就可能是过早优化。在进行优化之前,应该优先考虑代码的可读性、可维护性和可扩展性。




  4. 是否浪费了大量的开发时间和资源:如果优化代码会浪费大量的开发时间和资源,而不是提高代码的性能和效率,那么就可能是过早优化。在进行优化之前,应该评估优化的成本和收益,确定是否值得进行优化。




  判断是否过早优化需要根据具体情况进行评估。在进行优化之前,应该先测试代码的性能,确定是否存在性能问题。同时,也应该优先考虑代码的可读性、可维护性和可扩展性,避免过度优化。


总结


  作为一名在IT领域摸爬滚打多年的工程师,我深有体会地认识到过早优化是软件开发中的一大陷阱。在软件开发的初期,我们可能会过于关注代码的性能问题,而忽略了代码的可读性、可维护性和可扩展性。这种做法可能会导致代码的复杂性增加,降低代码的可读性和可维护性,甚至可能会浪费大量的开发时间和资源。


  在软件开发过程中,我们应该避免过早优化,而是优先考虑代码的可读性、可维护性和可扩展性。当需要进行性能优化时,应该在代码的基础上进行优化,通过分析性能瓶颈、优化算法和数据结构等方法来提高代码的性能和效率。同时,我们也应该意识到,性能优化并不是软件开发的唯一目标,我们还应该注重代码的可读性、可维护性和可扩展性,以便保证代码的质量和可靠性


作者:xindoo
来源:juejin.cn/post/7217305951552634935
收起阅读 »

SpringBoot 项目使用 Sa-Token 完成登录认证

一、设计思路 对于一些登录之后才能访问的接口(例如:查询我的账号资料),我们通常的做法是增加一层接口校验: 如果校验通过,则:正常返回数据。 如果校验未通过,则:抛出异常,告知其需要先进行登录。 那么,判断会话是否登录的依据是什么?我们先来简单分析一下登录...
继续阅读 »

一、设计思路


对于一些登录之后才能访问的接口(例如:查询我的账号资料),我们通常的做法是增加一层接口校验:



  • 如果校验通过,则:正常返回数据。

  • 如果校验未通过,则:抛出异常,告知其需要先进行登录。


那么,判断会话是否登录的依据是什么?我们先来简单分析一下登录访问流程:



  1. 用户提交 name + password 参数,调用登录接口。

  2. 登录成功,返回这个用户的 Token 会话凭证。

  3. 用户后续的每次请求,都携带上这个 Token。

  4. 服务器根据 Token 判断此会话是否登录成功。


所谓登录认证,指的就是服务器校验账号密码,为用户颁发 Token 会话凭证的过程,这个 Token 也是我们后续判断会话是否登录的关键所在。


动态图演示:


登录认证


接下来,我们将介绍在 SpringBoot 中如何使用 Sa-Token 完成登录认证操作。



Sa-Token 是一个 java 权限认证框架,主要解决登录认证、权限认证、单点登录、OAuth2、微服务网关鉴权 等一系列权限相关问题。
Gitee 开源地址:gitee.com/dromara/sa-…



首先在项目中引入 Sa-Token 依赖:


<!-- Sa-Token 权限认证 -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot-starter</artifactId>
<version>1.34.0</version>
</dependency>

注:如果你使用的是 SpringBoot 3.x,只需要将 sa-token-spring-boot-starter 修改为 sa-token-spring-boot3-starter 即可。


二、登录与注销


根据以上思路,我们需要一个会话登录的函数:


// 会话登录:参数填写要登录的账号id,建议的数据类型:long | int | String, 不可以传入复杂类型,如:User、Admin 等等
StpUtil.login(Object id);

只此一句代码,便可以使会话登录成功,实际上,Sa-Token 在背后做了大量的工作,包括但不限于:



  1. 检查此账号是否之前已有登录

  2. 为账号生成 Token 凭证与 Session 会话

  3. 通知全局侦听器,xx 账号登录成功

  4. Token 注入到请求上下文

  5. 等等其它工作……


你暂时不需要完整的了解整个登录过程,你只需要记住关键一点:Sa-Token 为这个账号创建了一个Token凭证,且通过 Cookie 上下文返回给了前端


所以一般情况下,我们的登录接口代码,会大致类似如下:


// 会话登录接口 
@RequestMapping("doLogin")
public SaResult doLogin(String name, String pwd) {
// 第一步:比对前端提交的账号名称、密码
if("zhang".equals(name) && "123456".equals(pwd)) {
// 第二步:根据账号id,进行登录
StpUtil.login(10001);
return SaResult.ok("登录成功");
}
return SaResult.error("登录失败");
}

如果你对以上代码阅读没有压力,你可能会注意到略显奇怪的一点:此处仅仅做了会话登录,但并没有主动向前端返回 Token 信息。
是因为不需要吗?严格来讲是需要的,只不过 StpUtil.login(id) 方法利用了 Cookie 自动注入的特性,省略了你手写返回 Token 的代码。


如果你对 Cookie 功能还不太了解,也不用担心,我们会在之后的 [ 前后端分离 ] 章节中详细的阐述 Cookie 功能,现在你只需要了解最基本的两点:



  • Cookie 可以从后端控制往浏览器中写入 Token 值。

  • Cookie 会在前端每次发起请求时自动提交 Token 值。


因此,在 Cookie 功能的加持下,我们可以仅靠 StpUtil.login(id) 一句代码就完成登录认证。


除了登录方法,我们还需要:


// 当前会话注销登录
StpUtil.logout();

// 获取当前会话是否已经登录,返回true=已登录,false=未登录
StpUtil.isLogin();

// 检验当前会话是否已经登录, 如果未登录,则抛出异常:`NotLoginException`
StpUtil.checkLogin();

异常 NotLoginException 代表当前会话暂未登录,可能的原因有很多:
前端没有提交 Token、前端提交的 Token 是无效的、前端提交的 Token 已经过期 …… 等等。


Sa-Token 未登录场景值参照表:


场景值对应常量含义说明
-1NotLoginException.NOT_TOKEN未能从请求中读取到 Token
-2NotLoginException.INVALID_TOKEN已读取到 Token,但是 Token无效
-3NotLoginException.TOKEN_TIMEOUT已读取到 Token,但是 Token已经过期
-4NotLoginException.BE_REPLACED已读取到 Token,但是 Token 已被顶下线
-5NotLoginException.KICK_OUT已读取到 Token,但是 Token 已被踢下线

那么,如何获取场景值呢?废话少说直接上代码:


// 全局异常拦截(拦截项目中的NotLoginException异常)
@ExceptionHandler(NotLoginException.class)
public SaResult handlerNotLoginException(NotLoginException nle)
throws Exception {

// 打印堆栈,以供调试
nle.printStackTrace();

// 判断场景值,定制化异常信息
String message = "";
if(nle.getType().equals(NotLoginException.NOT_TOKEN)) {
message = "未提供token";
}
else if(nle.getType().equals(NotLoginException.INVALID_TOKEN)) {
message = "token无效";
}
else if(nle.getType().equals(NotLoginException.TOKEN_TIMEOUT)) {
message = "token已过期";
}
else if(nle.getType().equals(NotLoginException.BE_REPLACED)) {
message = "token已被顶下线";
}
else if(nle.getType().equals(NotLoginException.KICK_OUT)) {
message = "token已被踢下线";
}
else {
message = "当前会话未登录";
}

// 返回给前端
return SaResult.error(message);
}



注意:以上代码并非处理逻辑的最佳方式,只为以最简单的代码演示出场景值的获取与应用,大家可以根据自己的项目需求来定制化处理

三、会话查询


// 获取当前会话账号id, 如果未登录,则抛出异常:`NotLoginException`
StpUtil.getLoginId();

// 类似查询API还有:
StpUtil.getLoginIdAsString(); // 获取当前会话账号id, 并转化为`String`类型
StpUtil.getLoginIdAsInt(); // 获取当前会话账号id, 并转化为`int`类型
StpUtil.getLoginIdAsLong(); // 获取当前会话账号id, 并转化为`long`类型

// ---------- 指定未登录情形下返回的默认值 ----------

// 获取当前会话账号id, 如果未登录,则返回null
StpUtil.getLoginIdDefaultNull();

// 获取当前会话账号id, 如果未登录,则返回默认值 (`defaultValue`可以为任意类型)
StpUtil.getLoginId(T defaultValue);

四、Token 查询


// 获取当前会话的token值
StpUtil.getTokenValue();

// 获取当前`StpLogic`的token名称
StpUtil.getTokenName();

// 获取指定token对应的账号id,如果未登录,则返回 null
StpUtil.getLoginIdByToken(String tokenValue);

// 获取当前会话剩余有效期(单位:s,返回-1代表永久有效)
StpUtil.getTokenTimeout();

// 获取当前会话的token信息参数
StpUtil.getTokenInfo();

TokenInfo 是 Token 信息 Model,用来描述一个 Token 的常用参数:


{
"tokenName": "satoken", // token名称
"tokenValue": "e67b99f1-3d7a-4a8d-bb2f-e888a0805633", // token值
"isLogin": true, // 此token是否已经登录
"loginId": "10001", // 此token对应的LoginId,未登录时为null
"loginType": "login", // 账号类型标识
"tokenTimeout": 2591977, // token剩余有效期 (单位: 秒)
"sessionTimeout": 2591977, // User-Session剩余有效时间 (单位: 秒)
"tokenSessionTimeout": -2, // Token-Session剩余有效时间 (单位: 秒) (-2表示系统中不存在这个缓存)
"tokenActivityTimeout": -1, // token剩余无操作有效时间 (单位: 秒)
"loginDevice": "default-device" // 登录设备类型
}

五、来个小测试,加深一下理解


新建 LoginAuthController,复制以下代码


package com.pj.cases.use;

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import cn.dev33.satoken.stp.SaTokenInfo;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.util.SaResult;

/**
* Sa-Token 登录认证示例
*
* @author kong
* @since 2022-10-13
*/

@RestController
@RequestMapping("/acc/")
public class LoginAuthController {

// 会话登录接口 ---- http://localhost:8081/acc/doLogin?name=zhang&pwd=123456
@RequestMapping("doLogin")
public SaResult doLogin(String name, String pwd) {

// 第一步:比对前端提交的 账号名称 & 密码 是否正确,比对成功后开始登录
// 此处仅作模拟示例,真实项目需要从数据库中查询数据进行比对
if("zhang".equals(name) && "123456".equals(pwd)) {

// 第二步:根据账号id,进行登录
// 此处填入的参数应该保持用户表唯一,比如用户id,不可以直接填入整个 User 对象
StpUtil.login(10001);

// SaResult 是 Sa-Token 中对返回结果的简单封装,下面的示例将不再赘述
return SaResult.ok("登录成功");
}

return SaResult.error("登录失败");
}

// 查询当前登录状态 ---- http://localhost:8081/acc/isLogin
@RequestMapping("isLogin")
public SaResult isLogin() {
// StpUtil.isLogin() 查询当前客户端是否登录,返回 true 或 false
boolean isLogin = StpUtil.isLogin();
return SaResult.ok("当前客户端是否登录:" + isLogin);
}

// 校验当前登录状态 ---- http://localhost:8081/acc/checkLogin
@RequestMapping("checkLogin")
public SaResult checkLogin() {
// 检验当前会话是否已经登录, 如果未登录,则抛出异常:`NotLoginException`
StpUtil.checkLogin();

// 抛出异常后,代码将走入全局异常处理(GlobalException.java),如果没有抛出异常,则代表通过了登录校验,返回下面信息
return SaResult.ok("校验登录成功,这行字符串是只有登录后才会返回的信息");
}

// 获取当前登录的账号是谁 ---- http://localhost:8081/acc/getLoginId
@RequestMapping("getLoginId")
public SaResult getLoginId() {
// 需要注意的是,StpUtil.getLoginId() 自带登录校验效果
// 也就是说如果在未登录的情况下调用这句代码,框架就会抛出 `NotLoginException` 异常,效果和 StpUtil.checkLogin() 是一样的
Object userId = StpUtil.getLoginId();
System.out.println("当前登录的账号id是:" + userId);

// 如果不希望 StpUtil.getLoginId() 触发登录校验效果,可以填入一个默认值
// 如果会话未登录,则返回这个默认值,如果会话已登录,将正常返回登录的账号id
Object userId2 = StpUtil.getLoginId(0);
System.out.println("当前登录的账号id是:" + userId2);

// 或者使其在未登录的时候返回 null
Object userId3 = StpUtil.getLoginIdDefaultNull();
System.out.println("当前登录的账号id是:" + userId3);

// 类型转换:
// StpUtil.getLoginId() 返回的是 Object 类型,你可以使用以下方法指定其返回的类型
int userId4 = StpUtil.getLoginIdAsInt(); // 将返回值转换为 int 类型
long userId5 = StpUtil.getLoginIdAsLong(); // 将返回值转换为 long 类型
String userId6 = StpUtil.getLoginIdAsString(); // 将返回值转换为 String 类型

// 疑问:数据基本类型不是有八个吗,为什么只封装以上三种类型的转换?
// 因为大多数项目都是拿 int、long 或 String 声明 UserId 的类型的,实在没见过哪个项目用 double、float、boolean 之类来声明 UserId
System.out.println("当前登录的账号id是:" + userId4 + " --- " + userId5 + " --- " + userId6);

// 返回给前端
return SaResult.ok("当前客户端登录的账号id是:" + userId);
}

// 查询 Token 信息 ---- http://localhost:8081/acc/tokenInfo
@RequestMapping("tokenInfo")
public SaResult tokenInfo() {
// TokenName 是 Token 名称的意思,此值也决定了前端提交 Token 时应该使用的参数名称
String tokenName = StpUtil.getTokenName();
System.out.println("前端提交 Token 时应该使用的参数名称:" + tokenName);

// 使用 StpUtil.getTokenValue() 获取前端提交的 Token 值
// 框架默认前端可以从以下三个途径中提交 Token:
// Cookie (浏览器自动提交)
// Header头 (代码手动提交)
// Query 参数 (代码手动提交) 例如: /user/getInfo?satoken=xxxx-xxxx-xxxx-xxxx
// 读取顺序为: Query 参数 --> Header头 -- > Cookie
// 以上三个地方都读取不到 Token 信息的话,则视为前端没有提交 Token
String tokenValue = StpUtil.getTokenValue();
System.out.println("前端提交的Token值为:" + tokenValue);

// TokenInfo 包含了此 Token 的大多数信息
SaTokenInfo info = StpUtil.getTokenInfo();
System.out.println("Token 名称:" + info.getTokenName());
System.out.println("Token 值:" + info.getTokenValue());
System.out.println("当前是否登录:" + info.getIsLogin());
System.out.println("当前登录的账号id:" + info.getLoginId());
System.out.println("当前登录账号的类型:" + info.getLoginType());
System.out.println("当前登录客户端的设备类型:" + info.getLoginDevice());
System.out.println("当前 Token 的剩余有效期:" + info.getTokenTimeout()); // 单位:秒,-1代表永久有效,-2代表值不存在
System.out.println("当前 Token 的剩余临时有效期:" + info.getTokenActivityTimeout()); // 单位:秒,-1代表永久有效,-2代表值不存在
System.out.println("当前 User-Session 的剩余有效期" + info.getSessionTimeout()); // 单位:秒,-1代表永久有效,-2代表值不存在
System.out.println("当前 Token-Session 的剩余有效期" + info.getTokenSessionTimeout()); // 单位:秒,-1代表永久有效,-2代表值不存在

// 返回给前端
return SaResult.data(StpUtil.getTokenInfo());
}

// 会话注销 ---- http://localhost:8081/acc/logout
@RequestMapping("logout")
public SaResult logout() {
// 退出登录会清除三个地方的数据:
// 1、Redis中保存的 Token 信息
// 2、当前请求上下文中保存的 Token 信息
// 3、Cookie 中保存的 Token 信息(如果未使用Cookie模式则不会清除)
StpUtil.logout();

// StpUtil.logout() 在未登录时也是可以调用成功的,
// 也就是说,无论客户端有没有登录,执行完 StpUtil.logout() 后,都会处于未登录状态
System.out.println("当前是否处于登录状态:" + StpUtil.isLogin());

// 返回给前端
return SaResult.ok("退出登录成功");
}

}

代码注释已针对每一步操作做出详细解释,大家可根据可参照注释中的访问链接进行逐步测试。


本示例代码已上传至 Gitee,可参考:
Sa-Token 登录认证示例




参考资料



作者:省长
来源:juejin.cn/post/7215971680349569061
收起阅读 »

往往排查很久的问题,最后发现都非常简单。。。

之前线上发生了一个很诡异的异常,网上各种搜索、排查,都没有找到问题,给大家分享一下。 大概在 2 月份的时候,我们的某个应用整合了中间件的 kafka 客户端,发布到灰度和蓝节点进行观察,然后就发现线上某个 Topic 发生了大量的RetriableCommi...
继续阅读 »

之前线上发生了一个很诡异的异常,网上各种搜索、排查,都没有找到问题,给大家分享一下。


大概在 2 月份的时候,我们的某个应用整合了中间件的 kafka 客户端,发布到灰度和蓝节点进行观察,然后就发现线上某个 Topic 发生了大量的RetriableCommitException,并且集中在灰度机器上。


E20:21:59.770 RuntimeException  org.apache.kafka.clients.consumer.RetriableCommitFailedException  ERROR [Consumer clientId=xx-xx.4-0, groupId=xx-xx-consumer_[gray]] Offset commit with offsets {xx-xx-xx-callback-1=OffsetAndMetadata{offset=181894918, leaderEpoch=4, metadata=''}, xx-xx-xx-callback-0=OffsetAndMetadata{offset=181909228, leaderEpoch=5, metadata=''}} failed org.apache.kafka.clients.consumer.RetriableCommitFailedException: Offset commit failed with a retriable exception. You should retry committing the latest consumed offsets.
Caused by: org.apache.kafka.common.errors.TimeoutException: Failed to send request after 30000 ms.


排查


检查了这个 Topic 的流量流入、流出情况,发现并不是很高,至少和 QA 环境的压测流量对比,连零头都没有达到。


但是从发生异常的这个 Topic 的历史流量来看的话,发生问题的那几个时间点的流量又确实比平时高出了很多。



同时我们检查 Broker 集群的负载情况,发现那几个时间点的 CPU 负载也比平时也高出很多(也只是比平时高,整体并不算高)。



对Broker集群的日志排查,也没发现什么特殊的地方。


然后我们对这个应用在QA上进行了模拟,尝试复现,遗憾的是,尽管我们在QA上把生产流量放大到很多倍并尝试了多次,问题还是没能出现。


此时,我们把问题归于当时的网络环境,这个结论在当时其实是站不住脚的,如果那个时刻网络环境发生了抖动的话,其它应用为什么没有这类异常?


可能其它的服务实例网络情况是好的,只是发生问题的这个灰实例网络发生了问题。


那问题又来了,为什么这个实例的其它 Topic 没有报出异常,偏偏问题只出现在这个 Topic 呢?。。。。。。。。。


至此,陷入了僵局,无从下手的感觉。


从这个客户端的开发、测试到压测,如果有 bug 的话,不可能躲过前面那么多环节,偏偏爆发在了生产环境。


没办法了,我们再次进行了一次灰度发布,如果过了一夜没有事情发生,我们就把问题划分到环境问题,如果再次出现问题的话,那就只能把问题划分到我们实现的 Kafka 客户端的问题了。


果不其然,发布后的第二天凌晨1点多,又出现了大量的 RetriableCommitFailedException,只是这次换了个 Topic,并且异常的原因又多出了其它Caused by 。


org.apache.kafka.clients.consumer.RetriableCommitFailedException: Offset commit failed with a retriable exception. You should retry committing the latest consumed offsets.
Caused by: org.apache.kafka.common.errors.DisconnectException
...
...
E16:23:31.640 RuntimeException  org.apache.kafka.clients.consumer.RetriableCommitFailedException  ERROR 
...
...
org.apache.kafka.clients.consumer.RetriableCommitFailedException: Offset commit failed with a retriable exception. You should retry committing the latest consumed offsets.
Caused by: org.apache.kafka.common.errors.TimeoutException: The request timed out.

分析


这次出现的异常与之前异常的不同之处在于:



  1. 1. Topic 变了

  2. 2. 异常Cause变了


而与之前异常又有相同之处:



  1. 1. 只发生在灰度消费者组

  2. 2. 都是RetriableCommitFailedException


RetriableCommitFailedException 意思很明确了,可以重试提交的异常,网上搜了一圈后仅发现StackOverFlow上有一问题描述和我们的现象相似度很高,遗憾的是没人回复这个问题:StackOverFlow。


我们看下 RetriableCommitFailedException 这个异常和产生这个异常的调用层级关系。



除了产生异常的具体 Cause 不同,剩下的都是让我们再 retry,You should retry Commiting the lastest consumed offsets。



从调用层级上来看,我们可以得到几个关键的信息,commit 、 async。


再结合异常发生的实例,我们可以得到有用关键信息: 灰度、commit 、async。


在灰度消息的实现上,我们确实存在着管理位移和手动提交的实现。



看代码的第 62 行,如果当前批次消息经过 filter 的过滤后一条消息都不符合当前实例消费,那么我们就把当前批次进行手动异步提交位移。结合我们在生产的实际情况,在灰度实例上我们确实会把所有的消息都过滤掉,并异步提交位移。


为什么我们封装的客户端提交就会报大量的报错,而使用 spring-kafka 的没有呢?


我们看下Spring对提交位移这块的核心实现逻辑。



可以同步,也可以异步提交,具体那种提交方式就要看 this.containerProperties.isSyncCommits() 这个属性的配置了,然而我们一般也不会去配置这个东西,大部分都是在使用默认配置。



人家默认使用的是同步提交方式,而我们使用的是异步方式。


同步提交和异步提交有什么区别么?


先看下同步提交的实现:



只要遇到了不是不可恢复的异常外,在 timer 参数过期时间范围内重试到成功(这个方法的描述感觉不是很严谨的样子)。



我们在看下异步提交方式的核心实现:



我们不要被第 645 行的 RequestFuture future = sendOffsetCommitRequest(offsets) 所迷惑,它其实并不是发送位移提交的请求,它内部只是把当前请求包装好,放到 private final UnsentRequests unsent = new UnsentRequests(); 这个属性中,同时唤醒真正的发送线程来发送的。



这里不是重点,重点是如果我们的异步提交发生了异常,它只是简单的使用 RetriableCommitFailedException 给我们包装了一层。


重试呢?为什么异步发送产生了可重试异常它不给我们自动重试?


如果我们对多个异步提交进行重试的话,很大可能会导致位移覆盖,从而引发重复消费的问题。


正好,我们遇到的所有异常都是 RetriableCommitException 类型的,也就是说,我们把灰度位移提交的方式修改成同步可重试的提交方式,就可以解决我们遇到的问题了。


作者:艾小仙
来源:juejin.cn/post/7214398563023274021
收起阅读 »

多端登录如何实现踢人下线

1:项目背景 一个项目往往会有小程序,APP,PC等多端访问,比如淘宝,京东等。这时候就会有一些踢人下线的需求,比如你在一台电脑登录了PC端,这时候你再另外一台电脑也登录PC端,这时候之前在另外一台电脑上就会被强制下线。 或者你登录了PC端,这时候你登陆了AP...
继续阅读 »

1:项目背景


一个项目往往会有小程序,APP,PC等多端访问,比如淘宝,京东等。这时候就会有一些踢人下线的需求,比如你在一台电脑登录了PC端,这时候你再另外一台电脑也登录PC端,这时候之前在另外一台电脑上就会被强制下线。


或者你登录了PC端,这时候你登陆了APP或者小程序,这时候PC端的账号也会被强制下线


2:项目只有PC端


假设我们现在的项目只有PC端,没有小程序或者APP,那么这时候就是很简单了,用户的sessin(也就是所谓的Token)一般都是存储在redis中,session中包括用户ID等一些信息,当然还有一个最重要的就是登录的ip地址。


image.png


1:用户在登录的时候,从redis中获取用户session,如果没有就可以直接登录了


2:用户在另外一台电脑登录,从redis中获取到用户session,这时候用户session是有的,说明用户之前已经登录过了


3:这时候从用户session中获取IP,判断二者的ip是不是相同,如果不同,这时候就要发送一个通知给客户端,让另外一台设备登录的账号强制下线即可


3:项目有PC端和APP端和小程序端


当你的应用有PC端和APP端的时候,我们用户的session如果还是只存一个ip地址,那明显就是不够的,因为很多情况下,我们PC端和APP端是可以同时登录的,比如淘宝,京东等都是,也就是所谓的双端登录


这时候就会有多种情况


单端登录:PC端,APP端,小程序只能有一端登录
双端登录:允许其中二个端登录
三端登录:三个端都可以同时登录

对于三端可以同时登录就很简单,但是现在有个限制,就是app端只能登录一次,不能同时登录,也就是我一个手机登录了APP,另外一个手机登录的话,之前登录的APP端就要强制下线


所以我们的用户session存储的格式如下


{
userId:用户的id
clientType:PC端,小程序端,APP端
imei:就是设备的唯一编号(对于PC端这个值就是ip地址,其余的就是手机设备的一个唯一编号)
}


单端登录


首先我们要知道,用户登录不同的设备那么用户session是不一样的。对于单端登录,那么我们可以拿到用户的所有的session,然后根据clientType和imei号来强制将其它端的用户session删除掉,然后通知客户端强制下线


双端登录


同样拿到所有用户的session,然后根据自己的业务需求来判定哪一端需要强制下线,比如我们现在已经登录了PC端和APP端,这时候登录小程序,现在要让APP端的强制下线。


这时候登录之后获取用户所有的session,这时候会有二个用户session,首先拿到clientType = APP的session,然后来通知客户端这个端需要强制下线。


如果这时候我登录了PC端和一个APP端,这时候我用另外一台手机登录APP端,那么之前那台手机上登录的APP端就要被强制下线,这个时候仅通过clientType是不行的,因为我二个手机登录的clientType都是APP端。所以这时候就要根据imei号来判断了。因为不同的手机imei号是不一样的。


这时候我拿到用户所有的session



PC端的session
sessionA{
userId: 1,
clientType: PC,
imei: "123"
}

APP端的session
sessionA{
userId: 1,
clientType: APP,
imei: "12345"
}

这时候我从另外一台手机登录的时候,生成的session应该是这样的


 APP端的session
sessionA{
userId: 1,
clientType: APP,
imei: "1234567"
}

我发现同一个clientType的session已经有了,这时候我要判断imei号是否一样,imei一样说明是同一台设备,不同说明不是同一台设备,我们只需要把对应设备的账号强制下线即可了


总结


不管是单端登录,双端登录还是多端登录,我们都是根据用户session来判断。只要根据clientType和imei号来就可以满足我们大部分的踢人下线需求了。


作者:我是小趴菜
来源:juejin.cn/post/7213598216884486204
收起阅读 »

用了这两款插件,同事再也不说我代码写的烂了

大家好,我是风筝同事:你的代码写的不行啊,不够规范啊。我:我写的代码怎么可能不规范,不要胡说。于是同事打开我的 IDEA ,安装了一个插件,然后执行了一下,规范不规范,看报告吧。这可怎么是好,这玩意竟然给我挑出来这么多问题,到底靠谱不。同事潇洒的走掉了,只留下...
继续阅读 »

大家好,我是风筝

同事:你的代码写的不行啊,不够规范啊。

我:我写的代码怎么可能不规范,不要胡说。

于是同事打开我的 IDEA ,安装了一个插件,然后执行了一下,规范不规范,看报告吧。

这可怎么是好,这玩意竟然给我挑出来这么多问题,到底靠谱不。

同事潇洒的走掉了,只留下我在座位上盯着屏幕惊慌失措。我仔细的查看了这个报告的每一项,越看越觉得这插件指出的问题有道理,果然是我大意了,竟然还给我挑出一个 bug 来。

这是什么插件,review 代码无敌了。

这个插件就是 SonarLint,官网的 Slogan 是 clean code begins in your IDE with {SonarLint}

作为一个程序员,我们当然希望自己写的代码无懈可击了,但是由于种种原因,有一些问题甚至bug都无法避免,尤其是刚接触开发不久的同学,也有很多有着多年开发经验的程序员同样会有一些不好的代码习惯。

代码质量和代码规范首先肯定是靠程序员自身的水平和素养决定的,但是提高水平的是需要方法的,方法就有很多了,比如参考大厂的规范和代码、比如有大佬带着,剩下的就靠平时的一点点积累了,而一些好用的插件能够时时刻刻提醒我们什么是好的代码规范,什么是好的代码。

SonarLint 就是这样一款好用的插件,它可以实时帮我们 review代码,甚至可以发现代码中潜在的问题并提供解决方案。

SonarLint 使用静态代码分析技术来检测代码中的常见错误和漏洞。例如,它可以检测空指针引用、类型转换错误、重复代码和逻辑错误等。这些都是常见的问题,但是有时候很难发现。使用 SonarLint 插件,可以在编写代码的同时发现这些问题,并及时纠正它们,这有助于避免这些问题影响应用程序的稳定性。

比如下面这段代码没有结束循环的条件设置,SonarLint 就给出提示了,有强迫症的能受的了这红下划线在这儿?

SonarLint 插件可以帮助我提高代码的可读性。代码应该易于阅读和理解,这有助于其他开发人员更轻松地维护和修改代码。 SonarLint 插件可以检测代码中的代码坏味道,例如不必要的注释、过长的函数和变量名不具有描述性等等。通过使用 SonarLint 插件,可以更好地了解如何编写清晰、简洁和易于理解的代码。

例如下面这个名称为 hello_world的静态 final变量,SonarLint 给出了两项建议。

  1. 因为变量没有被使用过,建议移除;
  2. 静态不可变变量名称不符合规范;

SonarLint 插件可以帮助我遵循最佳实践和标准。编写符合标准和最佳实践的代码可以确保应用程序的质量和可靠性。 SonarLint 插件可以检测代码中的违反规则的地方,例如不安全的类型转换、未使用的变量和方法、不正确的异常处理等等。通过使用 SonarLint 插件,可以学习如何编写符合最佳实践和标准的代码,并使代码更加健壮和可靠。

例如下面的异常抛出方式,直接抛出了 Exception,然后 SonarLint 建议不要使用 Exception,而是自定义一个异常,自定义的异常可能让人直观的看出这个异常是干什么的,而不是 Exception基本类型导出传递。

安装 SonarLint

可以直接打开 IDEA 设置 -> Plugins,在 MarketPlace中搜索SonarLint,直接安装就可以。

还可以直接在官网下载,打开页面https://www.sonarsource.com/products/sonarlint/,在页面中可以看到多种语言、多种开发工具的下载图标,点击下方的 EXPLORE即可到下载页面去下载了。虽然我们只是在 IDEA 中使用,但是它不管支持 Java 、不只支持 IDEA ,还支持 Python、PHP等众多语言,以及 Visual Studio 、VS Code 等众多 IDE。

在 IDEA 中使用

SonarLint 插件安装好之后,默认就开启了实时分析的功能,就跟智能提示的功能一样,随着你噼里啪啦的敲键盘,SonarLint插件就默默的进行分析,一旦发现问题就会以红框、红波浪线、黄波浪线的方式提示。

当然你也可以在某一文件中点击右键,也可在项目根目录点击右键,在弹出菜单中点击Analyze with SonarLint,对当前文件或整个项目进行分析。

分析结束后,会生成分析报告。

左侧是对各个文件的分析结果,右侧是对这个问题的建议和修改示例。

SonarLint 对问题分成了三种类型

类型说明
Bug代码中的 bug,影响程序运行
Vulnerability漏洞,可能被作为攻击入口
Code smell代码意味,可能影响代码可维护性

问题按照严重程度分为5类

严重性说明
BLOCKER已经影响程序正常运行了,不改不行
CRITICAL可能会影响程序运行,可能威胁程序安全,一般也是不改不行
MAJOR代码质量问题,但是比较严重
MINOR同样是代码质量问题,但是严重程度较低
INFO一些友好的建议

SonarQube

SonarLint 是在 IDE 层面进行分析的插件,另外还可以使用 SonarQube功能,它以一个 web 的形式展现,可以为整个开发团队的项目提供一个web可视化的效果。并且可以和 CI\CD 等部署工具集成,在发版前提供代码分析。

SonarQube是一个 Java 项目,你可以在官网下载项目本地启动,也可以以 docker 的方式启动。之后可以在 IDEA 中配置全局 SonarQube配置。

也可以在 SonarQube web 中单独配置一个项目,创建好项目后,直接将 mvn 命令在待分析的项目中执行,即可生成对应项目的分析报告,然后在 SonarQube web 中查看。

5

对于绝大多数开发者和开发团队来说,SonarQube 其实是没有必要的,只要我们每个人都解决了 IDE 中 SonarLint 给出的建议,当然最终的代码质量就是符合标准的。

阿里 Java 规约插件

每一个开发团队都有团队内部的代码规范,比如变量命名、注释格式、以及各种类库的使用方式等等。阿里一直在更新 Java 版的阿里巴巴开发者手册,有什么泰山版、终极版,想必各位都听过吧,里面的规约如果开发者都能遵守,那别人恐怕再没办法 diss 你的代码不规范了。

对应这个开发手册的语言层面的规范,阿里也出了一款 IDEA 插件,叫做 Alibaba Java Coding Guidelines,可以在插件商店直接下载。

比如前面说的那个 hello_world变量名,插件直接提示「修正为以下划线分隔的大写模式」。

再比如一些注释上的提示,不建议使用行尾注释。

image-20230314165107639

还有,比如对线程池的使用,有根据规范建议的内容,建议自己定义核心线程数和最大线程数等参数,不建议使用 Excutors工具类。

有了这俩插件,看谁还能说我代码写的不规范了。

作者:古时的风筝
来源:juejin.cn/post/7211151196328804408
收起阅读 »

保姆级JAVA对接ChatGPT教程,实现自己的AI对话助手

1.前言 大家好,我是王老狮,近期OpenAI开放了chatGPT的最新gpt-3.5-turbo模型,据介绍该模型是和当前官网使用的相同的模型,如果你还没体验过ChatGPT,那么今天就教大家如何打破网络壁垒,打造一个属于自己的智能助手把。本文包括API K...
继续阅读 »

1.前言


大家好,我是王老狮,近期OpenAI开放了chatGPT的最新gpt-3.5-turbo模型,据介绍该模型是和当前官网使用的相同的模型,如果你还没体验过ChatGPT,那么今天就教大家如何打破网络壁垒,打造一个属于自己的智能助手把。本文包括API Key的申请以及网络代理的搭建,那么事不宜迟,我们现在开始。


2.对接流程


2.1.API-Key的获取


首先第一步要获取OpenAI接口的API Key,该Key是你用来调用接口的token,主要用于接口鉴权。获取该key首先要注册OpenAi的账号,具体可以见我的另外一篇文章,ChatGPT保姆级注册教程



  1. 打开platform.openai.com/网站,点击view API Key,


image.png



  1. 点击创建key


image.png



  1. 弹窗显示生成的key,记得把key复制,不然等会就找不到这个key了,只能重新创建。


image.png


将API Key保存好以备用


2.2.API用量的查看


这里可以查看API的使用情况,新账号注册默认有5美元的试用额度,之前都是18美元,API成本降了之后试用额度也狠狠地砍了一刀啊,哈哈。


image.png


2.3.核心代码实现


2.3.1.pom依赖


<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">

<modelVersion>4.0.0modelVersion>
<groupId>com.webtapgroupId>
<artifactId>webtapartifactId>
<version>0.0.1version>
<packaging>jarpackaging>

<parent>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-parentartifactId>
<version>2.1.2.RELEASEversion>
parent>

<dependencies>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-thymeleafartifactId>
dependency>
<dependency>
<groupId>nz.net.ultraq.thymeleafgroupId>
<artifactId>thymeleaf-layout-dialectartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-data-jpaartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-devtoolsartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-testartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-mailartifactId>
dependency>

<dependency>
<groupId>mysqlgroupId>
<artifactId>mysql-connector-javaartifactId>
dependency>
<dependency>
<groupId>org.apache.commonsgroupId>
<artifactId>commons-lang3artifactId>
<version>3.4version>
dependency>
<dependency>
<groupId>commons-codecgroupId>
<artifactId>commons-codecartifactId>
dependency>
<dependency>
<groupId>org.jsoupgroupId>
<artifactId>jsoupartifactId>
<version>1.9.2version>
dependency>

<dependency>
<groupId>com.alibabagroupId>
<artifactId>fastjsonartifactId>
<version>1.2.56version>
dependency>
<dependency>
<groupId>net.sourceforge.nekohtmlgroupId>
<artifactId>nekohtmlartifactId>
<version>1.9.22version>
dependency>
<dependency>
<groupId>com.github.pagehelpergroupId>
<artifactId>pagehelper-spring-boot-starterartifactId>
<version>1.4.1version>
dependency>
<dependency>
<groupId>org.projectlombokgroupId>
<artifactId>lombokartifactId>
dependency>
<dependency>
<groupId>org.apache.httpcomponentsgroupId>
<artifactId>httpasyncclientartifactId>
<version>4.0.2version>
dependency>
<dependency>
<groupId>org.apache.httpcomponentsgroupId>
<artifactId>httpcore-nioartifactId>
<version>4.3.2version>
dependency>

<dependency>
<groupId>org.apache.httpcomponentsgroupId>
<artifactId>httpclientartifactId>
<version>4.3.5version>
<exclusions>
<exclusion>
<artifactId>commons-codecartifactId>
<groupId>commons-codecgroupId>
exclusion>
exclusions>
dependency>
<dependency>
<groupId>commons-httpclientgroupId>
<artifactId>commons-httpclientartifactId>
<version>3.1version>
<exclusions>
<exclusion>
<artifactId>commons-codecartifactId>
<groupId>commons-codecgroupId>
exclusion>
exclusions>
dependency>
<dependency>
<groupId>org.mybatis.spring.bootgroupId>
<artifactId>mybatis-spring-boot-starterartifactId>
<version>1.3.1version>
dependency>
<dependency>
<groupId>com.github.ulisesbocchiogroupId>
<artifactId>jasypt-spring-boot-starterartifactId>
<version>2.0.0version>
dependency>

dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-maven-pluginartifactId>
plugin>
plugins>
build>

project>

2.3.2.实体类ChatMessage.java


用于存放发送的消息信息,注解使用了lombok,如果没有使用lombok可以自动生成构造方法以及get和set方法


@Data
@NoArgsConstructor
@AllArgsConstructor
public class ChatMessage {
//消息角色
String role;
//消息内容
String content;
}

2.3.3.实体类ChatCompletionRequest.java


用于发送的请求的参数实体类,参数释义如下:


model:选择使用的模型,如gpt-3.5-turbo


messages :发送的消息列表


temperature :温度,参数从0-2,越低表示越精准,越高表示越广发,回答的内容重复率越低


n :回复条数,一次对话回复的条数


stream :是否流式处理,就像ChatGPT一样的处理方式,会增量的发送信息。


max_tokens :生成的答案允许的最大token数


user :对话用户


@Data
@Builder
public class ChatCompletionRequest {

String model;

List<ChatMessage> messages;

Double temperature;

Integer n;

Boolean stream;

List<String> stop;

Integer max_tokens;

String user;
}

2.3.4.实体类ExecuteRet .java


用于接收请求返回的信息以及执行结果



/**
* 调用返回
*/

public class ExecuteRet {

/**
* 操作是否成功
*/

private final boolean success;

/**
* 返回的内容
*/

private final String respStr;

/**
* 请求的地址
*/

private final HttpMethod method;

/**
* statusCode
*/

private final int statusCode;

public ExecuteRet(booleansuccess, StringrespStr, HttpMethodmethod, intstatusCode) {
this.success =success;
this.respStr =respStr;
this.method =method;
this.statusCode =statusCode;
}

@Override
public String toString()
{
return String.format("[success:%s,respStr:%s,statusCode:%s]", success, respStr, statusCode);
}

/**
*@returnthe isSuccess
*/

public boolean isSuccess() {
return success;
}

/**
*@returnthe !isSuccess
*/

public boolean isNotSuccess() {
return !success;
}

/**
*@returnthe respStr
*/

public String getRespStr() {
return respStr;
}

/**
*@returnthe statusCode
*/

public int getStatusCode() {
return statusCode;
}

/**
*@returnthe method
*/

public HttpMethod getMethod() {
return method;
}
}

2.3.5.实体类ChatCompletionChoice .java


用于接收ChatGPT返回的数据


@Data
public class ChatCompletionChoice {

Integer index;

ChatMessage message;

String finishReason;
}

2.3.6.接口调用核心类OpenAiApi .java


使用httpclient用于进行api接口的调用,支持post和get方法请求。


url为配置文件open.ai.url的值,表示调用api的地址:https://api.openai.com/ ,token为获取的api-key。
执行post或者get方法时增加头部信息headers.put("Authorization", "Bearer " + token); 用于通过接口鉴权。



@Slf4j
@Component
public class OpenAiApi {

@Value("${open.ai.url}")
private String url;
@Value("${open.ai.token}")
private String token;

private static final MultiThreadedHttpConnectionManagerCONNECTION_MANAGER= new MultiThreadedHttpConnectionManager();

static {
// 默认单个host最大链接数
CONNECTION_MANAGER.getParams().setDefaultMaxConnectionsPerHost(
Integer.valueOf(20));
// 最大总连接数,默认20
CONNECTION_MANAGER.getParams()
.setMaxTotalConnections(20);
// 连接超时时间
CONNECTION_MANAGER.getParams()
.setConnectionTimeout(60000);
// 读取超时时间
CONNECTION_MANAGER.getParams().setSoTimeout(60000);
}

public ExecuteRet get(Stringpath, Map headers) {
GetMethod method = new GetMethod(url +path);
if (headers== null) {
headers = new HashMap<>();
}
headers.put("Authorization", "Bearer " + token);
for (Map.Entry h : headers.entrySet()) {
method.setRequestHeader(h.getKey(), h.getValue());
}
return execute(method);
}

public ExecuteRet post(Stringpath, Stringjson, Map headers) {
try {
PostMethod method = new PostMethod(url +path);
//log.info("POST Url is {} ", url + path);
// 输出传入参数
log.info(String.format("POST JSON HttpMethod's Params = %s",json));
StringRequestEntity entity = new StringRequestEntity(json, "application/json", "UTF-8");
method.setRequestEntity(entity);
if (headers== null) {
headers = new HashMap<>();
}
headers.put("Authorization", "Bearer " + token);
for (Map.Entry h : headers.entrySet()) {
method.setRequestHeader(h.getKey(), h.getValue());
}
return execute(method);
} catch (UnsupportedEncodingExceptionex) {
log.error(ex.getMessage(),ex);
}
return new ExecuteRet(false, "", null, -1);
}

public ExecuteRet execute(HttpMethodmethod) {
HttpClient client = new HttpClient(CONNECTION_MANAGER);
int statusCode = -1;
String respStr = null;
boolean isSuccess = false;
try {
client.getParams().setParameter(HttpMethodParams.HTTP_CONTENT_CHARSET, "UTF8");
statusCode = client.executeMethod(method);
method.getRequestHeaders();

// log.info("执行结果statusCode = " + statusCode);
InputStreamReader inputStreamReader = new InputStreamReader(method.getResponseBodyAsStream(), "UTF-8");
BufferedReader reader = new BufferedReader(inputStreamReader);
StringBuilder stringBuffer = new StringBuilder(100);
String str;
while ((str = reader.readLine()) != null) {
log.debug("逐行读取String = " + str);
stringBuffer.append(str.trim());
}
respStr = stringBuffer.toString();
if (respStr != null) {
log.info(String.format("执行结果String = %s, Length = %d", respStr, respStr.length()));
}
inputStreamReader.close();
reader.close();
// 返回200,接口调用成功
isSuccess = (statusCode == HttpStatus.SC_OK);
} catch (IOExceptionex) {
} finally {
method.releaseConnection();
}
return new ExecuteRet(isSuccess, respStr,method, statusCode);
}

}

2.3.7.定义接口常量类PathConstant.class


用于维护支持的api接口列表


public class PathConstant {
public static class MODEL {
//获取模型列表
public static String MODEL_LIST = "/v1/models";
}

public static class COMPLETIONS {
public static String CREATE_COMPLETION = "/v1/completions";
//创建对话
public static String CREATE_CHAT_COMPLETION = "/v1/chat/completions";

}
}

2.3.8.接口调用调试单元测试类OpenAiApplicationTests.class


核心代码都已经准备完毕,接下来写个单元测试测试下接口调用情况。



@SpringBootTest
@RunWith(SpringRunner.class)
public class OpenAiApplicationTests {

@Autowired
private OpenAiApi openAiApi;
@Test
public void createChatCompletion2() {
Scanner in = new Scanner(System.in);
String input = in.next();
ChatMessage systemMessage = new ChatMessage('user', input);
messages.add(systemMessage);
ChatCompletionRequest chatCompletionRequest = ChatCompletionRequest.builder()
.model("gpt-3.5-turbo-0301")
.messages(messages)
.user("testing")
.max_tokens(500)
.temperature(1.0)
.build();
ExecuteRet executeRet = openAiApi.post(PathConstant.COMPLETIONS.CREATE_CHAT_COMPLETION, JSONObject.toJSONString(chatCompletionRequest),
null);
JSONObject result = JSONObject.parseObject(executeRet.getRespStr());
List choices = result.getJSONArray("choices").toJavaList(ChatCompletionChoice.class);
System.out.println(choices.get(0).getMessage().getContent());
ChatMessage context = new ChatMessage(choices.get(0).getMessage().getRole(), choices.get(0).getMessage().getContent());
System.out.println(context.getContent());
}

}


  • 使用Scanner 用于控制台输入信息,如果单元测试时控制台不能输入,那么进入IDEA的安装目录,修改以下文件。增加最后一行增加-Deditable.java.test.console=true即可。


image.png
image.png




  • 创建ChatMessage对象,用于存放参数,role有user,system,assistant,一般接口返回的响应为assistant角色,我们一般使用user就好。




  • 定义请求参数ChatCompletionRequest,这里我们使用3.1日发布的最新模型gpt-3.5-turbo-0301。具体都有哪些模型大家可以调用v1/model接口查看支持的模型。




  • 之后调用openAiApi.post进行接口的请求,并将请求结果转为JSON对象。取其中的choices字段转为ChatCompletionChoice对象,该对象是存放api返回的具体信息。


    接口返回信息格式如下:


    {
    "id": "chatcmpl-6rNPw1hqm5xMVMsyf6PXClRHtNQAI",
    "object": "chat.completion",
    "created": 1678179420,
    "model": "gpt-3.5-turbo-0301",
    "usage": {
    "prompt_tokens": 16,
    "completion_tokens": 339,
    "total_tokens": 355
    },
    "choices": [{
    "message": {
    "role": "assistant",
    "content": "\n\nI. 介绍数字孪生的概念和背景\n A. 数字孪生的定义和意义\n B. 数字孪生的发展历程\n C. 数字孪生在现代工业的应用\n\nII. 数字孪生的构建方法\n A. 数字孪生的数据采集和处理\n B. 数字孪生的建模和仿真\n C. 数字孪生的验证和测试\n\nIII. 数字孪生的应用领域和案例分析\n A. 制造业领域中的数字孪生应用\n B. 建筑和城市领域中的数字孪生应用\n C. 医疗和健康领域中的数字孪生应用\n\nIV. 数字孪生的挑战和发展趋势\n A. 数字孪生的技术挑战\n B. 数字孪生的实践难点\n C. 数字孪生的未来发展趋势\n\nV. 结论和展望\n A. 总结数字孪生的意义和价值\n B. 展望数字孪生的未来发展趋势和研究方向"
    },
    "finish_reason": "stop",
    "index": 0
    }]
    }



  • 输出对应的信息。




2.3.9.结果演示


image.png


2.4.连续对话实现


2.4.1连续对话的功能实现


基本接口调通之后,发现一次会话之后,没有返回完,输入继续又重新发起了新的会话。那么那么我们该如何实现联系上下文呢?其实只要做一些简单地改动,将每次对话的信息都保存到一个消息列表中,这样问答就支持上下文了,代码如下:


List messages = new ArrayList<>();
@Test
public void createChatCompletion() {
Scanner in = new Scanner(System.in);
String input = in.next();
while (!"exit".equals(input)) {
ChatMessage systemMessage = new ChatMessage(ChatMessageRole.USER.value(), input);
messages.add(systemMessage);
ChatCompletionRequest chatCompletionRequest = ChatCompletionRequest.builder()
.model("gpt-3.5-turbo-0301")
.messages(messages)
.user("testing")
.max_tokens(500)
.temperature(1.0)
.build();
ExecuteRet executeRet = openAiApi.post(PathConstant.COMPLETIONS.CREATE_CHAT_COMPLETION, JSONObject.toJSONString(chatCompletionRequest),
null);
JSONObject result = JSONObject.parseObject(executeRet.getRespStr());
List choices = result.getJSONArray("choices").toJavaList(ChatCompletionChoice.class);
System.out.println(choices.get(0).getMessage().getContent());
ChatMessage context = new ChatMessage(choices.get(0).getMessage().getRole(), choices.get(0).getMessage().getContent());
messages.add(context);
in = new Scanner(System.in);
input = in.next();
}
}

因为OpenAi的/v1/chat/completions接口消息参数是个list,这个是用来保存我们的上下文的,因此我们只要将每次对话的内容用list进行保存即可。


2.4.2结果如下:


image.png


image.png


4.常见问题


4.1.OpenAi接口调用不通


因为https://api.openai.com/地址也被限制了,但是接口没有对地区做校验,因此可以自己搭建一个香港代理,也可以走科学上网。


我采用的是香港代理的模式,一劳永逸,具体代理配置流程如下:



  1. 购买一台香港的虚拟机,反正以后都会用得到,作为开发者建议搞一个。搞活动的时候新人很便宜,基本3年的才200块钱。

  2. 访问nginx.org/download/ng… 下载最新版nginx

  3. 部署nginx并修改/nginx/config/nginx.conf文件,配置接口代理路径如下


server {
listen 19999;
server_name ai;

ssl_certificate /usr/local/nginx/ssl/server.crt;
ssl_certificate_key /usr/local/nginx/ssl/server.key;

ssl_session_cache shared:SSL:1m;
ssl_session_timeout 5m;

ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;

#charset koi8-r;

location /v1/ {
proxy_pass ;
}
}


  1. 启动nginx

  2. 将接口访问地址改为nginx的机器出口IP+端口即可


如果代理配置大家还不了解,可以留下评论我单独出一期教程。


4.2.接口返回401


检查请求方法是否增加token字段以及key是否正确


5.总结


至此JAVA对OpenAI对接就已经完成了,并且也支持连续对话,大家可以在此基础上不断地完善和桥接到web服务,定制自己的ChatGPT助手了。我自己也搭建了个平台,不断地在完善中,具体可见下图,后续会开源出来,想要体验的可以私信我获取地址和账号哈


image.png



作者:王老狮
来源:juejin.cn/post/7208907027841171512
收起阅读 »

删库跑路后的现场还原

数据库是公司重要资产,在此类重要资产平台上,尤其是重要操作,应该保持敬畏心。 数据库被删了?可怎么证明是某某某删了数据库?或者根本都不知道谁删除了数据库,又没抓现行,该怎么办? 正文 第一步 证据先行,有录屏有真相 删库动作的录制回放 录制回放让团队能清楚...
继续阅读 »

数据库是公司重要资产,在此类重要资产平台上,尤其是重要操作,应该保持敬畏心。



数据库被删了?可怎么证明是某某某删了数据库?或者根本都不知道谁删除了数据库,又没抓现行,该怎么办?



正文


第一步 证据先行,有录屏有真相


删库动作的录制回放


录制回放让团队能清楚了解和学习用户路径和行为,其中对于关键页面诸如删除等高价值的动作,可以开启录制回放功能,比如下图,就是某一用户某一行为的屏幕录制情况。
tutieshi_640x360_15s.gif


删库成功的页面截图


针对录制回放的内容,可以看到用户点击删除按钮这一高风险行为。


image.png


第二步 录屏背后是详细的用户访问数据


rum中查看用户会话


在用户使用产品的那一刻,用户体验就开始了。用户体验数据洞见很多,加购物车、下单、视频播放等高价值按钮背后的性能等相关数据和业务息息相关:比如下图展示了成功删除数据库的提示弹窗。


image.png


发现用户登录并浏览数据库平台的详细信息


每一次用户会话中,记录着用户的来源、访问时长,以及用户行为,这里面就包含对页面的加载(切换)和按钮点击。下图便是一个用户登录数据库管理平台后,0-20分钟以内的用户旅程
image.png


发现用户点击删除库的按钮的详细信息


链接或者按钮背后隐藏着逻辑和用户动机,充分利用能转化良好化学反应。反之,在用户旅程中,也能看到用户点击删除数据库的按钮的行为,如下图所示:
image.png


点击按钮成功触发删除数据库的接口请求


为了明白请求或行为在系统中的'前世今生',链路追踪已经成了必备,在下图中,用户行为触发的请求的完整上下文就被“追踪”到了:
image.png


后台处理接口请求


在产品使用流畅度中,丝滑不一定是卖点,但“慢”肯定是用户卡点,通过全链路链路追踪综合分析,可以得到请求耗时占比,进一步定位卡在哪里(前端、后端、网络),详情见下图:
image.png


第三步 成功删库的链路详情


前后端加上数据库形成可视化闭环,构成的业务链路,能够高效定位业务情况,下图能完整看出一次删库的效率:
image.png


第四步 自动关联删库日志


全链路追踪能锦上添花的要数自动关联日志的功能了,下图能清晰看到链路所产生的日志:
image.png


以上我们便通过用户删库的录屏用户行为链路信息、操作日志等,还原了删库现场。当然,其中涉及了很多技术内容,下面整理了其中一些常见问题


相关技术点的FAQ :


1. 如何针对关键步骤开启录制回放功能


删除按钮 为例 ,用户点击删除按钮后 可以开启 录制回放功能


  function deleteDB(){
showConfirm(deleteDB).then((yes,no)=>{
if(yes)=>[ datafluxRum.startSessionReplayRecording();]
})

}

2. 录制回放是否涉及密码等用户私密信息


出于数据安全考虑,任何情况下,以下元素都会被屏蔽:



  • password、email 和 tel 类型的输入

  • 具有 autocomplete 属性的元素,例如信用卡号、到期日期和安全代码


3 . 如何将 用户行为后端 进行关联


前后端关联通过http请求头的traceID进行关联,开启rumapm简单设置即可实现关联。
rum中仅仅需要在启动时注明后端地址。以本文的后台管理系统为例,需要在启动rum时开启allowTracingOrigin这个字段,配置见下图


image.png


可以参照如下代码


 window.DATAFLUX_RUM &&
window.DATAFLUX_RUM.init({
applicationId: "node_mongo_admin_express",
datakitOrigin: "http://mongodb_admin:9529", // 协议(包括://),域名(或IP地址)[和端口号]
env: "production",
service:"node_mongo_admin_express",
version: "1.0.0",
trackInteractions: true,
allowedTracingOrigins: ["http://mongodb_admin:1234"], // 非必填,允许注入trace采集器所需header头部的所有请求列表。可以是请求的origin,也可以是是正则
sessionSampleRate: 100,
sessionReplaySampleRate: 100,
defaultPrivacyLevel: 'allow',
});
window.DATAFLUX_RUM && window.DATAFLUX_RUM.startSessionReplayRecording()

4. 如何自动将采集的日志链路信息进行关联


需要将traceID注入日志,进行切分,就可以实现链路日志的关联。本文仅用一行进行了关联,代码见下图。


image.png


5. 如何从后端下钻到数据库


仅需要接入追踪工具即可实现下图全链路追踪,本文后端使用node的express框架,链路追踪展示图如下:


image.png


其中服务调用拓扑关系如下,也就是web端访问后端(node技术栈)的,后端调用数据库(mongo


image.png


6. 后端支持java吗?


支持javapythongo以及.net等,接入的学习成本是有的,整体对于开发而言,接入配置问题不大。


7. 前端的技术架构或技术栈有兼容性吗?


目前不论是mpa还是spa,不论是ssr、还是csr,亦或是vuereactjQuery等,都支持,但针对不同架构,需要选择接入的场景。


8. 还支持哪些场景?


支持的场景很多,比如:



  • 线上告警的故障定位

  • 开发、测试环境的bug调试

  • 用户行为的追踪与回放

  • 性能瓶颈的查找与性能提升


9.有关请求耗时占比,能更详细的举个例子吗?


我们以后端为例,看到db_create这个接口:


image.png


这些数据是如何统计得出的呢?感兴趣的同学可以查看下图:
image.png


其中每个部分的计算原理如下:


Queueing(队列)耗时 = Duration - First Byte - Download  
First Byte(首包)耗时 = responseStart - domainLookupStart
Download(下载)耗时 = responseEnd - responseStart


更深入的技术内容,我将在今后的文章继续为大家整理。


综上所述


可观测性切入点很多,聪明的团队会观测;可观测性是研发质量的试金石,是企业城墙的基石,用好可观测性,能更多的了解系统,扩宽业务。



本文由观测云高级产品技术专家刘刚和交付工程师 苏桐桐共同撰写,其中所有截图及数据,均来自模拟数据,此外也欢迎一起探讨技术和业务。



参考词汇



  • adminMongo:mongo数据库管理平台

  • rum: 真实用户体验

  • apm: 应用性能管理

  • metrics:指标

  • logs:日志

  • trace:链路


作者:Yestodorrow
来源:juejin.cn/post/7207787191622893624
收起阅读 »

Spring Boot+微信小程序_保存微信登录者的个人信息

1. 前言 微信小程序开发平台,提供有一类 API,可以让开发者获取到微信登录用户的个人数据。这类 API 统称为开放接口。 Tip:微信小程序开发平台,会把微信登录用户的个人信息分为明文数据和敏感数据。 明文数据也称为公开数据,开发者可以直接获取到,如登录...
继续阅读 »

1. 前言


微信小程序开发平台,提供有一类 API,可以让开发者获取到微信登录用户的个人数据。这类 API 统称为开放接口



Tip:微信小程序开发平台,会把微信登录用户的个人信息分为明文数据敏感数据


明文数据也称为公开数据,开发者可以直接获取到,如登录者的昵称、头像……


敏感数据如电话号码、唯一标识符……等数据,只有高级认证开发者和经过登录者授权后才能解密获取到。



这一类 API较多,且 API之间功能有重叠之处,相互之间的区别较微小。有的适用于低版本,有的适用于高版本。


为了避免在使用时出现选择混乱,本文将通过具体应用案例介绍几个常用 API的使用。


2. 开放接口


开放接口是对一类 API的统称,开发者可以通过调用这类接口得到微信登录用户的授权或获取登录者的个人数据
开放接口又分成几个子类 API



  • 登录接口: 包括 wx.pluginLogin(Object args)wx.login(Object object)wx.checkSession(Object object) 几 个 API

  • 账号信息: 包括Object wx.getAccountInfoSync()此接口用来获取开发者的账号信息。

  • 用户信息: 包括 wx.getUserProfile(Object object)wx.getUserInfo(Object object)UserInfo。使用频率非常高的接口,常用于小程序中获取登录者个人公开数据。

  • 授权接口:wx.authorizeForMiniProgram(Object object)wx.authorize(Object object)


除上述列出的子类接口,还有收货地址、生物认证……等诸多子类 API,有兴趣者可以自行了解。


2.1 登录接口


登录接口中有 3API,对于开发者来说,使用频率较高的是 login接口,此环节将重点介绍此接口。



非本文特别关注的接口,会简略带过。



wx.pluginLogin(Object args):此接口只能在插件中可以调用,调用此接口获得插件用户的标志凭证code,插件可使用此凭证换取用于识别用户的唯一标识 OpenpId


用户不同、宿主小程序不同或插件不同的情况下,该标识均不相同,即当且仅当同一个用户在同一个宿主小程序中使用同一个插件时,OpenpId 才会相同。


对于一般开发者,此 接口用的不是很多,具体使用细节在此处也不做过多复述。



什么是 OpenId?


当微信用户登录公众号或小程序时,微信平台为每一个微信登录者分配的一个唯一标识符号。



2.1.1 wx.login(Object object)


功能描述:




  • 开发者使用此接口可以获取到微信登录者登录凭证(code)



    登录凭证具有临时性,也就是每次调用时都会不一样,所以code 只能使用一次。





  • 开发者可以通过临时code,再向微信接口服务器索取登录者的唯一标识符 OpenId、微信开发平台账号的唯一标识 UnionID(需要当前小程序已绑定到微信开放平台帐号)、以及会话密钥 session_key




那么,获取到的openIdsession_key对于开发者而言,有什么实质性的意义?




  • 根据 OpenId的唯一性特点,可以在微信用户第一次登录时,把OpenID保存在数据库或缓存中,在后续登录时,只需要检查用户的 OpenId是否存在于数据库或缓存中,便能实现自动登录功能。




  • session_key 也称会话密钥,用来解密微信登录者的敏感数据。



    后文将详细介绍。





如何获取OpenId


现通过一个简单案例,实现微信小程序端与开发者服务器之间的数据交互。以此了解开发者服务器如何通过微信小程序传递过来的用户临时 code换取到登录者的更多信息。


实现之前,先通过一个简易演示图了解其过程。


wx01.png


简单描述整个请求过程:



  • 微信用户打开微信小程序后,开发者在微信小程序中通过调用wx.login接口获取到临时登录凭证 code

  • 在微信小程序中调用 wx.request 接口向开发者服务器发送 http 请求,需要把登录凭证 code一并发送过去。

  • 开发者服务器使用发送过来的 code 以及开发者凭证信息向微信接口服务器索取微信登录者的 openIdsession_key


简而言之,就是 3 者(微信小程序、开发者服务器、微信接口服务器)之间的一个击鼓传花游戏。


开发流程:


第一步:项目结构分析


完整的系统由 2 个部分组成:




  • 微信小程序端 APP



    如对微信小程序开发不是很了解,请先阅读官方提供的相关文档。





  • 服务器端应用程序。



    本文的服务器端应用程序基于 Spring Boot开发平台。





本项目结构是标准的前后端分离模式,微信小程序是前端应用,服务器端应用程序为后台应用。


第二步:新建微信小程序(前端应用)


打开微信开发工具,新建一个名为 guokeai 的小程序项目 ,项目会初始化一个index 页面。在 index.js中编写如下代码。


//index.js
const app = getApp()
const httpRequest = require("../../utils/request.js")

Page({
data: {
isHasUserInfo: null,
userInfo: null
},
//启动时
onLoad: function () {
let this_ = this
/***
* 检查微信用户是否已经登录到后台服务器
* 已经登录的标志,数据库中存在 OPENID
*/

let code = null
//调用 login 接口
wx.login({
success: (res) => {
//得到登录用户的临时 code
code = res.code
//向开发者服务器发送请求
let api = "wx/getLoginCertificate"
let config = {
url: api,
method: "GET",
data: {
code: code
}
}
let promise = httpRequest.wxRequest(config)
promise.then(res => {
let isHas = null
// 有没有完整的微信登录者信息
isHas = res.data == 0 ? false : true
app.globalData.isHasUserInfo = isHas
this_.setData({
isHasUserInfo: isHas
})
}).catch(res => {
console.log("fail", res)
});
}
})
}
})

代码解释:



  • 一般会在微信小程序启动时,也就是在页面onload 函数中调用 wx.login接口,检查用户是否登录过。

  • http://127.0.0.1:8080/wx/getLoginCertificate开发者服务器提供的对外处理微信用户信息的接口。

  • 最后只是简单地输出开发者服务器端返回的数据。

  • httpRequest.wxRequest(config)是自定义的封装wx.request接口的请求组件。


function wxRequest(config) {
//返回的数据类型
let dataType = config.dataType == null ? "json" : config.dataType;
let responseType = config.responseType == null ? "text" : config.responseType;
//服务器基地址
let serverUrl = "http://127.0.0.1:8080/"
//超时
let timeout = config.timeout == null ? 50000 : config.timeout;
//目标地址,基地址+接口
let url = serverUrl + config.url;
//数据提交方式
let method = config.method == null ? "GET" : config.method;
//提交数据
let data = config.data == null ? null : config.data
//头信息
let header = {
// 默认值
'content-type': 'application/json',
'x-requested-with': 'XMLHttpRequest'
}
let sessionId = wx.getStorageSync('sessionId')
if (sessionId) {
header["cookie"] = sessionId
}
return new Promise(function (resolve, reject) {
wx.request({
url: url,
data: data,
//返回的数据类型(json)
dataType: dataType,
enableCache: false,
enableHttp2: false,
enableQuic: false,
method: method,
header: header,
responseType: responseType,
timeout: timeout,
success: (res) => {
console.log("requestData", res)
if (res.cookies != null && res.cookies.length != 0)
wx.setStorageSync('sessionId', res.cookies[0])
resolve(res)
},
fail: (res) => {
console.log("requestException", res)
reject(res)
}
})
})
}

第三步:创建开发者服务器程序(后台应用)


本文使用 spring boot快速搭建后台应用程序。在项目的 pom.xml文件中除了必要的依赖包外,还需要添加以下 的依赖包。


<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.73</version>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.13</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.1</version>
</dependency>



  • fastjson阿里云提供的开源 JSON解析框架。



    微信小程序开发者服务器构建的项目结构,是标准的前后端分离模式。


    请求与响应时,数据交互常使用JSON格式。这时使用 fastjson 作为json解析器,当然,也可以选择其它的类似解析器。





  • httpclient 是一个http请求组件。




  • mysql-connector-java 本文案例使用 MySQL数据库,需要加载相应的驱动包。




  • mybatis-plus-boot-startermybatis-plus 依赖包。




在后台应用中编写处理器(响应)组件:


@RestController
@RequestMapping("/wx")
public class WxAction {
@Autowired
private IWxService wxService;
/***
* 获取到微信用户的 OPENID
*/

@GetMapping("/getLoginCertificate")
public String getLoginCertificate(@RequestParam("code") String code) throws Exception {
WxUserInfo wxInfo = this.wxService.getLoginCertificate(code);
//用户不存在,或者用户的信息不全
return wxInfo==null || wxInfo.getNickName()==null?"0":"1";
}

代码解释:



  • IWxService是处理器依赖的业务组件,提供有 getLoginCertificate()方法用来实现通过code微信接口服务器换取微信登录者的 openIdsession_key


编写业务组件:


@Service
public class WxService implements IWxService {
@Override
public WxUserInfo getLoginCertificate(String code) throws Exception {
//请求地址
String requestUrl = WxUtil.getWxServerUrl(code);
// 发送请求
String response = HttpClientUtils.getRequest(requestUrl);
//格式化JSON数据
WxUserInfo wxUserInfo = JSONObject.parseObject(response, WxUserInfo.class);
//检查数据库中是否存在 OPENID
WxUserInfo wxUserInfo_ = this.wxUserMapper.selectById(wxUserInfo.getOpenId());
if (wxUserInfo_ == null) {
//数据库中没有用户的 OPENID,添加到数据库中
this.wxUserMapper.insert(wxUserInfo);
} else {
if (!wxUserInfo.getSessionKey().equals(wxUserInfo_.getSessionKey())) {
//如果数据库保存的session_key和最新的session_key 不相同,则更新
wxUserInfo_.setSessionKey(wxUserInfo.getSessionKey());
this.wxUserMapper.updateById(wxUserInfo_);
}
}
return wxUserInfo_;
}
}

代码解释:




  • WxUtil 是自定义的一个工具组件,用来构建请求微信接口服务器url


    https://api.weixin.qq.com/sns/jscode2session微信接口服务器对外提供的接口,请求此接口时,需要提供 4 个请求数据。


    appid:小程序 appId。


    secret:小程序 appSecret。


    js_code:获取到的微信登录者的临时 code


    grant_type:授权类型,此处只需填写 authorization_code




public class WxUtil {
private final static String APP_ID = "微信小程序开发者申请的 appid";
private final static String APP_SECRET = "微信小程序开发者申请的 APP_SECRET";
//
private final static String WX_LOGIN_SERVER_URL = "https://api.weixin.qq.com/sns/jscode2session?appid={0}&secret={1}&js_code={2}&grant_type=authorization_code";
public static String getWxServerUrl(String code) throws IOException {
String url = MessageFormat.format(WX_LOGIN_SERVER_URL, new String[]{APP_ID, APP_SECRET, code});
return url;
}
}


  • HttpClientUtils也是一个自定义组件,用来向指定的服务器发送 http请求。


public class HttpClientUtils {
/**
* GET请求
*/

public static String getRequest(String url) throws Exception {
//HttpClient对象
CloseableHttpClient httpClient = HttpClients.createDefault();
CloseableHttpResponse response = null;
try {
HttpGet httpGet = new HttpGet(url);
response = httpClient.execute(httpGet);
//响应体
HttpEntity entity = response.getEntity();
if (entity != null) {
//格式化响应体
return EntityUtils.toString(entity);
}
} catch (ClientProtocolException e) {
throw e;
} catch (IOException e) {
throw e;
} finally {
response.close();
httpClient.close();
}
return null;
}
}


  • WxUserInfo 是自定义的数据封装类。微信接口服务器返回的数据是以JSON格式组装的,这里需要格式成对象数据,便于在 java中处理。本文使用 MyBatisPlus操作数据库,此类也对应数据库中的gk_wx_user表。


@Data
@AllArgsConstructor
@NoArgsConstructor
@TableName("gk_wx_user")
public class WxUserInfo {
//OPEN_id
@TableId(type = IdType.ASSIGN_ID, value = "open_id")
private String openId;
//会话密钥
@TableField(value = "session_key")
private String sessionKey;
//头像路径
@TableField("avatar_url")
private String avatarUrl;
//城市
private String city;
//国家
private String country;
//性别
private String gender;
//语言
private String language;
//昵称
@TableField("nick_name")
private String nickName;
//备注名或真实名
@TableField("real_name")
private String realName;
//省份
private String province;
//学生ID
@TableField("stu_id")
private Integer stuId;
}

MyBatis 数据库映射组件:


@Repository
public interface WxUserMapper extends BaseMapper<WxUserInfo> {

}

第四步:测试。


先启动后台应用程序,再启动微信小程序,可以在数据库表中查看到如下信息。


数据库.png


微信用户的openidsession_key已经保存到后台的数据库表中。


2.1.2 wx.checkSession(Object object)


官方文档中,有一段对 session_key的生命周期的描述。



  • session_key的生命周期有不确定性,可以使用 wx.login接口刷新 session_key。为了避免频繁调用 wx.login 接口,可以通过调用 wx.checkSession(Object object)接口判断session_key是否已经过期。

  • 当开发者在实现自定义登录态时,可以考虑以 session_key 有效期作为自身登录态有效期,也可以实现自定义的时效性策略。


wx.checkSession 的功能,可以使用此接口判断session_key是否过期。



  • 调用成功说明当前 session_key 未过期。

  • 调用失败说明 session_key 已过期。


2.2 用户信息接口


wx.login接口仅能获取到微信登录者的有限数据,如果想要获取到登录者的更多个人信息,可以使用用户信息接口中的相关API



  • wx.getUserProfile(Object object)。获取用户信息,页面产生点击事件(例如 buttonbindtap 的回调中)后才可调用,每次请求都会弹出授权窗口,用户同意后返回 userInfo

  • wx.getUserInfo(Object object) 。和 wx.getUserProfile的功能一样,在基础库 2.10 的后续版本中,其功能已经被削弱。

  • UserInfo是用户信息封装类。


getUserProfile是从 基础库2.10.4版本开始支持的接口,该接口用来替换 wx.getUserInfo,意味着官方不建议再使用getUserInfo接口获取用户的个人信息。


下图是官方提供的 2 个接口的功能对比图。


接口调整.png


为了避免频繁弹窗,可以在第一次获取到用户信息后保存在数据库中以备以后所用。为了获取到用户的敏感数据,在后台要通过getUserProfile接口所获取的数据进行解密操作。


2.2.2 wx.getUserProfile


下面通过具体代码讲解如何保存微信登录者的个人数据。先了解一下整个数据获取的流程,这里直接截取官方提供的一张流程图。


解密码.jpg


获取微信登录者的个人信息,需要经过 2 个步骤。


签名效验:



  • 通过调用wx.getUserProfile接口获取数据时,接口会同时返回 rawDatasignature,其中 signature = sha1( rawData + session_key )

  • 开发者将 signaturerawData 发送到开发者服务器进行校验。服务器利用用户对应的 session_key 使用相同的算法计算出签名 signature2 ,比对signaturesignature2 即可校验数据的完整性。


解密加密数据:



  • 对称解密使用的算法为 AES-128-CBC,数据采用PKCS#7填充。

  • 对称解密的目标密文为 Base64_Decode(encryptedData)

  • 对称解密秘钥 aeskey = Base64_Decode(session_key), aeskey16字节。

  • 对称解密算法初始向量 为Base64_Decode(iv),其中iv由数据接口返回。


具体编写实现。


**第一步:**在微信小程序端编码。


index.wxml页面中添加一个按钮,并注册bindtap事件。


<view>
<button bindtap="getUserProfile">获取用户数据</button>
</view>

index.js中添加一个名为getUserProfile的事件回调函数。为了避免不必要的弹窗,只有当后台没有获取到个人数据时,才调用wx.getUserProfile接口。


getUserProfile: function (e) {
let this_ = this
if (!this.data.isHasUserInfo) {
//如果服务器端没有保存完整的微信登录者信息
wx.getUserProfile({
desc: '需要完善您的资料!',
success: (res) => {
this_.setData({
//小程序中用来显示个人信息
userInfo: res.userInfo,
isHasUserInfo: true
})
//再次登录,因为 session_key 有生命中周期
wx.login({
success(res_) {
//保存到服务器端
let config = {
url: "wx/wxLogin",
method: "GET",
data: {
code: res_.code,
//明文数据
rawData: res.rawData,
//加密数据
encryptedData: res.encryptedData,
iv: res.iv,
//数字签名
signature: res.signature
}
}
let promise = httpRequest.wxRequest(config)
promise.then(res => {
//返回
console.log("wxLogin", res)
}).catch(res => {
console.log("fail", res)
});
}
})
}
})
}
}

服务器端代码:


pom.xml文件中添加如下依赖包,用来解密数据。


<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk16</artifactId>
<version>1.46</version>
</dependency>
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.15</version>
</dependency>

在处理器类WxAction中添加wxLogin响应方法。


@RestController
@RequestMapping("/wx")
public class WxAction {
@Autowired
private IWxService wxService;
/***
*
* @param code
* @param rawData
* @param encryptedData
* @param iv
* @param signature
* @return
* @throws Exception
*/

@GetMapping("/wxLogin")
public WxUserInfo wxLogin(@RequestParam("code") String code, @RequestParam("rawData") String rawData,
@RequestParam("encryptedData") String encryptedData, @RequestParam("iv") String iv,
@RequestParam("signature") String signature)
throws Exception {
WxUserInfo wxInfo = this.wxService.getWxUserInfo(code, rawData, encryptedData, iv, signature);
return wxInfo;
}
}

业务代码:


小程序中传递过来的数据是经过base64编码以及加密的数据,需要使用 Base64解码字符串,再使用解密算法解密数据。先提供一个解密方法。


public String decrypt(String session_key, String iv, String encryptData) {

String decryptString = "";
//解码经过 base64 编码的字符串
byte[] sessionKeyByte = Base64.getDecoder().decode(session_key);
byte[] ivByte = Base64.getDecoder().decode(iv);
byte[] encryptDataByte = Base64.getDecoder().decode(encryptData);

try {
Security.addProvider(new BouncyCastleProvider());
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS7Padding");
//得到密钥
Key key = new SecretKeySpec(sessionKeyByte, "AES");
//AES 加密算法
AlgorithmParameters algorithmParameters = AlgorithmParameters.getInstance("AES");
algorithmParameters.init(new IvParameterSpec(ivByte));
cipher.init(Cipher.DECRYPT_MODE, key, algorithmParameters);
byte[] bytes = cipher.doFinal(encryptDataByte);
decryptString = new String(bytes);
} catch (Exception e) {
e.printStackTrace();
}
return decryptString;
}

具体获取数据的业务实现:


@Override
public WxUserInfo getWxUserInfo(@NotNull String code, @NotNull String rawData, @NotNull String encryptedData, @NotNull String iv, @NotNull String signature) throws Exception {
//会话密钥
WxUserInfo wxUserInfo = this.getLoginCertificate(code);
String signature2 = DigestUtils.sha1Hex(rawData + wxUserInfo.getSessionKey());
if (!signature.equals(signature2)) {
throw new Exception("数字签名验证失败");
}
//数字签名验证成功,解密
String infos = this.decrypt(wxUserInfo.getSessionKey(), iv, encryptedData);
//反序列化 JSON 数据
WxUserInfo wxUserInfo_ = JSONObject.parseObject(infos, WxUserInfo.class);
wxUserInfo_.setSessionKey(wxUserInfo.getSessionKey());
wxUserInfo_.setOpenId(wxUserInfo.getOpenId());
//更新数据库
this.wxUserMapper.updateById(wxUserInfo_);
return wxUserInfo_;
}

测试,启动微信小程序和后台应用,在小程序中触发按钮事件。


wx03.png


在弹出的对话框中,选择允许


wx04.png


查看后台数据库表中的数据。


wx05.png


能够获取到的微信登录者个人信息都保存到了数据库表中。至于怎么使用这些数据,可以根据自己的业务需要定制。


3.总结


微信开发平台,提供有诸多接口,可以帮助开发者获取到有用的数据。本文主要介绍 wx.loginwx.getProfile接口,因篇幅所限,不能对其它接口做详细介绍 ,有兴趣者可以查阅官方文档。


官方文档只会对接口功能做些介绍 ,如要灵活运用这些接口,还需要结合实际需要演练一下,如此方能有切身体会。


作者:一枚大果壳
来源:juejin.cn/post/7098216504302403591
收起阅读 »

序列化和反序列化

序列化隐秘的吭,你踩过了没? 序列化和反序列化 Java序列化的目的主要有2个: 网络传输 对象持久化 当2个相对独立的进程,需要进行跨进程服务调用时,就需要把被传输的Java对象编码为字节数组或者ByteBuffer对象。 接收方只需要把这些字节数...
继续阅读 »

序列化隐秘的吭,你踩过了没?


序列化和反序列化



Java序列化的目的主要有2个:




  • 网络传输

  • 对象持久化


image-20230301144505527


当2个相对独立的进程,需要进行跨进程服务调用时,就需要把被传输的Java对象编码为字节数组或者ByteBuffer对象


接收方只需要把这些字节数组或者Bytebuf对象重新解码成内存对象即可实现通信、调用的作用。


image-20230301145117301


那么在我们使用序列化的时候有哪些需要注意的,避免的坑呢?


成员变量不能以is开头



阿里的《Java开发手册》明文规定了:成员变量禁止使用类似 isXxxx 的命名方式,也不要有isXxx命名的方法



image-20230301150018030


image-20230301145401694


大概的意思就是:不要加is前缀,因为部分框架序列化的时候,会以为对应的字段名是isXxxx后面的Xxxx



  • 比如:isSucceed序列化成Succeed,前端读取isSucceed的时候就会发现没有这个字段,然后出错了。


u=4214115302,3196714167&fm=253&fmt=auto&app=120&f=JPEG


这里面的序列化框架其实就是fastjson,我们可以直接去看他的源码


fastjson源码分析:computeGetters



去找get前缀的方法,然后进行字符串切割找到get后面的



image-20230301161434898



去找is前缀的方法,然后进行字符串切割



image-20230301161413220



  • 这里还进行了驼峰命名的判断:ixXxx,第三个字符是否是大写等判断


所以isSucceed字段会被fastjson框架认为Succeed字段。


image.png


默认值



成员变量的默认值同样会带来坑



同样是阿里的《Java开发手册》里面也是规定了:POJO类、RPC方法必须使用包装类型


image.png


关于包装类型和基本类型的区别,如果还有不清楚的,赶紧去看,这是最基础的面试知识点..


POJO类必须使用包装类型



尽量让错误暴露在编译期,不要拖到运行期



基本类型具有初始值,比如:



  • Int:0

  • float:0.0f

  • boolean:false


一个统计点赞的接口里面的返回值包含一个表示点赞数变化的字段,当发生错误的时候,这个字段没有进行赋初始值,就会出现以下情况:



  • 基本类型:读默认值,0,表达的意思就是没有点赞数变化,程序上并不知道是服务器那边出了错。

  • 包装类型:读到了个null,程序上是知道服务器那边出错了,可以进行对应的显示,比如显示 - ,表示读取不到等操作。


u=3180711090,4079282331&fm=253&fmt=auto&app=138&f=JPEG


总的来说就是:如果字段设置为基础类型并且基础类型的默认值具有业务意义,那么就会出错,并且无法感知错误


RPC方法的返回值和参数必须使用包装类型



RPC调用常常具有超时导致调用失败的情况



如果用包装类型,那么在接收方,就能感知到,这次RPC调用是成功,还是失败。


包装数据类型的null值具有表示额外的信息功能



彦祖来都来了,点个赞👍再走吧,这对我来说真的非常重要



作者:Ashleejy
来源:juejin.cn/post/7205478140914843709
收起阅读 »

扯什么 try-catch 性能问题?

“yes,你看着这鬼代码,竟然在 for 循环里面搞了个 try-catch,不知道try-catch有性能损耗吗?”老陈煞有其事地指着屏幕里的代码: for (int i = 0; i < 5000; i++) { try { ...
继续阅读 »

“yes,你看着这鬼代码,竟然在 for 循环里面搞了个 try-catch,不知道try-catch有性能损耗吗?”老陈煞有其事地指着屏幕里的代码:


 for (int i = 0; i < 5000; i++) {
try {
dosth
} catch (Exception e) {
e.printStackTrace();
}
}

我探过头去看了眼代码,“那老陈你觉得该怎么改?”


“当然是把 try-catch 提到外面啊!”老陈脑子都不转一下,脱口而出。


“你是不是傻?且不说性能,这代码的目的明显是让循环内部单次调用出错不影响循环的运行,你其到外面业务逻辑不就变了吗!”


老陈挠了挠他的地中海,“好像也是啊!”



“回过头来,catch 整个 for 循环和在循环内部 catch,在不出错的情况下,其实性能差不多。” 我喝一口咖啡不经意地提到,准备在老陈前面秀一下。


“啥意思?”老陈有点懵地看着我,“try-catch是有性能损耗的,我可是看过网上资料的!”


果然,老陈上钩了,我二话不说直接打开 idea,一顿操作敲了以下代码:


public class TryCatchTest {

@Benchmark
public void tryfor(Blackhole blackhole) {
try {
for (int i = 0; i < 5000; i++) {
blackhole.consume(i);
}
} catch (Exception e) {
e.printStackTrace();
}
}

@Benchmark
public void fortry(Blackhole blackhole) {
for (int i = 0; i < 5000; i++) {
try {
blackhole.consume(i);
} catch (Exception e) {
e.printStackTrace();
}
}
}

}

“BB 不如 show code,看到没,老陈,我把 try-catch 从 for 循环里面提出来跟在for循环里面做个对比跑一下,你猜猜两个差多少?”


“切,肯定 tryfor 性能好,想都不用想,不是的话我倒立洗头!”老陈信誓旦旦道。


我懒得跟他BB,直接开始了 benchmark,跑的结果如下:



可以看到,两者的性能(数字越大越好)其实差不多:



  • fortry: 86,261(100359-14098) ~ 114,457(100359+14098)

  • tryfor: 95,961(103216-7255) ~ 110,471(103216+7255)


我再调小(一般业务场景 for 循环次数都不会很多)下 for 循环的次数为 1000 ,结果也是差不多:



老陈一看傻了:“说好的性能影响呢?怎么没了?”


我直接一个javap,让老陈看看,其实两个实现在字节码层面没啥区别:



tryfor 的字节码



异常表记录的是 0 - 20 行,如果这些行里面的代码出现问题,直接跳到 23 行处理




fortry 的字节码



差别也就是异常表的范围小点,包的是 9-14 行,其它跟 tryfor 都差不多。



所以从字节码层面来看,没抛错两者的执行效率其实没啥差别。


“那为什么网上流传着try-catch会有性能问题的说法啊?”老陈觉得非常奇怪。


这个说法确实有,在《Effective Java》这本书里就提到了 try-catch 性能问题:



并且还有下面一段话:



正所谓听话不能听一半,以前读书时候最怕的就是一知半解,因为完全理解选择题能选对,完全不懂蒙可能蒙对,一知半解必定选到错误的选项!


《Effective Java》书中说的其实是不要用 try-catch 来代替正常的代码,书中的举例了正常的 for 循环肯定这样实现:



但有个卧龙偏偏不这样实现,要通过 try-catch 拐着弯来实现循环:



这操作我只能说有点逆天,这两个实现的对比就有性能损耗了


我们直接再跑下有try-catch 的代码和没 try-catch的 for 循环区别,代码如下:



结果如下:



+-差不多,直接看前面的分数对比,没有 ry-catch 的性能确实好些,这也和书中说的 try-catch 会影响 JVM 一些特定的优化说法吻合,但是具体没有说影响哪些优化,我猜测可能是指令重排之类的。


好了,我再总结下有关 try-catch 性能问题说法:



  1. try-catch 相比较没 try-catch,确实有一定的性能影响,但是旨在不推荐我们用 try-catch 来代替正常能不用 try-catch 的实现,而不是不让用 try-catch

  2. for循环内用 try-catch 和用 try-catch 包裹整个 for 循环性能差不多,但是其实两者本质上是业务处理方式的不同,跟性能扯不上关系,关键看你的业务流程处理。

  3. 虽然知道try-catch会有性能影响,但是业务上不需要避讳其使用,业务实现优先(只要不是书中举例的那种逆天代码就行),非特殊情况下性能都是其次,有意识地避免大范围的try-catch,只 catch 需要的部分即可(没把握全 catch 也行,代码安全执行第一)。


“好了,老陈你懂了没?”


“行啊yes,BB是一套一套的,走请你喝燕麦拿铁!” 老陈一把拉起我,我直接一个挣脱,“少来,我刚喝过咖啡,你那个倒立洗头,赶紧的!”我立马意识到老陈想岔开话题。


“洗洗洗,我们先喝个咖啡,晚上回去给你洗!”


晚上22点,老陈发来一张图片:



你别说,这头发至少比三毛多。


我是yes,我们下篇见~


作者:yes的练级攻略
来源:juejin.cn/post/7204121228016091197
收起阅读 »

Java 中为什么要设计 throws 关键词,是故意的还是不小心

我们平时在写代码的时候经常会遇到这样的一种情况 提示说没有处理xxx异常 然后解决办法可以在外面加上try-catch,就像这样 所以我之前经常这样处理 //重新抛出 RuntimeException public class ThrowsDemo { ...
继续阅读 »

我们平时在写代码的时候经常会遇到这样的一种情况


throws.png


提示说没有处理xxx异常


然后解决办法可以在外面加上try-catch,就像这样


trycatch.png


所以我之前经常这样处理


//重新抛出 RuntimeException
public class ThrowsDemo {

public void demo4throws() {
try {
new ThrowsSample().sample4throws();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}

//打印日志
@Slf4j
public class ThrowsDemo {

public void demo4throws() {
try {
new ThrowsSample().sample4throws();
} catch (IOException e) {
log.error("sample4throws", e);
}
}
}

//继续往外抛,但是需要每个方法都添加 throws
public class ThrowsDemo {

public void demo4throws() throws IOException {
new ThrowsSample().sample4throws();
}
}

但是我一直不明白


这个方法为什么不直接帮我做


反而要让我很多余的加上一步


我处理和它处理有什么区别吗?


而且变的好不美观


本来缩进就多,现在加个try-catch更是火上浇油


public class ThrowsDemo {

public void demo4throws() {
try {
if (xxx) {
try {
if (yyy) {

} else {

}
} catch (Throwable e) {
}
} else {

}
} catch (IOException e) {

}
}
}

上面的代码,就算里面没有业务,看起来也已经比较乱了,分不清哪个括号和哪个括号是一对


还有就是对Lambda很不友好


lambda.png


没有办法直接用::来优化代码,所以就变成了下面这样


lambdatry.png


本来看起来很简单很舒服的Lambda,现在又变得又臭又长


为什么会强制 try-catch


为什么我们平时写的方法不需要强制try-catch,而很多jdk中的方法却要呢


那是因为那些方法在方法的定义上添加了throws关键字,并且后面跟的异常不是RuntimeException


一旦你显式的添加了这个关键字在方法上,同时后面跟的异常不是RuntimeException,那么使用这个方法的时候就必须要显示的处理


比如使用try-catch或者是给调用这个方法的方法也添加throws以及对应的异常


throws 是用来干什么的


那么为什么要给方法添加throws关键字呢?


给方法添加throws关键字是为了表明这个方法可能会抛出哪些异常


就像一个风险告知


这样你在看到这个方法的定义的时候就一目了然了:这个方法可能会出现什么异常


为什么 RuntimeException 不强制 try-catch


那为什么RuntimeException不强制try-catch呢?


因为很多的RuntimeException都是因为程序的BUG而产生的


比如我们调用Integer.parseInt("A")会抛出NumberFormatException


当我们的代码中出现了这个异常,那么我们就需要修复这个异常


当我们修复了这个异常之后,就不会再抛出这个异常了,所以try-catch就没有必要了


当然像下面这种代码除外


public boolean isInteger(String s) {
try {
Integer.parseInt(s);
return true;
} catch (NumberFormatException e) {
return false;
}
}

这是我们利用这个异常来达成我们的需求,是有意为之的


而另外一些异常是属于没办法用代码解决的异常,比如IOException


我们在进行网络请求的时候就有可能抛出这类异常


因为网络可能会出现不稳定的情况,而我们对这个情况是无法干预的


所以我们需要提前考虑各种突发情况


强制try-catch相当于间接的保证了程序的健壮性


毕竟我们平时写代码,如果IDE没有提示异常处理,我们完全不会认为这个方法会抛出异常


我的代码怎么可能有问题.gif


我的代码怎么可能有问题!


不可能绝对不可能.gif


看来Java之父完全预判到了程序员的脑回路


throws 和 throw 的区别


java中还有一个关键词throw,和throws只有一个s的差别


throw是用来主动抛出一个异常


public class ThrowsDemo {

public void demo4throws() throws RuntimeException {
throw new RuntimeException();
}
}

两者完全是不同的功能,大家不要弄错了


什么场景用 throws


我们可以发现我们平时写代码的时候其实很少使用throws


因为当我们在开发业务的时候,所有的分支都已经确定了


比如网络请求出现异常的时候,我们常用的方式可能是打印日志,或是进行重试,把异常往外抛等等


所以我们没有那么有必要去使用throws这个关键字来说明异常信息


但是当我们没有办法确定异常要怎么处理的时候呢?


比如我在GitHub上维护了一个功能库,本身没有什么业务属性,主要就是对于一些复杂的功能做了相应的封装,提供给自己或别人使用(如果有兴趣可以看看我的库,顺便给Star,嘿嘿


对我来说,当我的方法中出现异常时,我是不清楚调用这个方法的人是想要怎么处理的


可能有的想要重试,有的想要打印日志,那么我干脆就往外抛,让调用方法的人自己去考虑,自己去处理


所以简单来说,如果方法主要是给别人用的最好用throws把异常往外抛,反之就是可加可不加


结束


很多时候你的不理解只是因为你还不够了解


作者:不够优雅
来源:juejin.cn/post/7204594495996100664
收起阅读 »

Flutter动态化调研实践

一,前言 1,什么是动态化? 目前移动端应用的版本更新, 最常见的方式是定期发版,无论是安卓还是iOS,都需要提交新的安装包到应用市场进行审核。审核通过后,用户在应用市场进行App的下载更新。 而动态化, 就是不依赖更新程序安装包, 就能动态实时更新页面的技术...
继续阅读 »

一,前言


1,什么是动态化?


目前移动端应用的版本更新, 最常见的方式是定期发版,无论是安卓还是iOS,都需要提交新的安装包到应用市场进行审核。审核通过后,用户在应用市场进行App的下载更新。


而动态化, 就是不依赖更新程序安装包, 就能动态实时更新页面的技术。


2,动态化的必要性


为什么需要动态化技术呢? 因为上述定期发版更新应用的方式存在一些问题,比如:



  1. 审核周期长, 且可能审核不通过。 周期长导致发版本不够灵活, 紧急的业务需求不能及时上线。

  2. 线上出现急需修复的bug时,需要较长修复周期,影响用户体验。

  3. 安装包过大, 动辄几十兆几百兆的应用升级可能会让用户比较抗拒。

  4. 即使上线了,也无法达到全部用户升级, 服务端存在兼容多版本App的问题。


面对这些问题,如果能实现app增量、无感知更新,实现功能同步。无论是对公司还是用户都是非常重要的需求,能实现app动态化更新就显得非常重要,能很好的解决以上问题:



  1. 随时实现功能升级,不存在应用市场长时间审核和拒绝上线问题,达到业务需求快速上线的目的。

  2. 线上bug可以实时修复,提高用户体验。

  3. 可以减小发版功能包体积,只需要替换新增功能即可。

  4. 功能保持一致,类似网页一样,发版后用户同步更新,不存在旧版本兼容问题。


接下来,我们就来分析一下,目前业内主要的Flutter动态化更新方式。


二,动态化方案调研


在Flutter实践层面,简单来说分为三个流派:




  • 方案一:JavaScript是最好的语言(🤣碰瓷PHP)
    主要思路:利用Flutter做渲染,开发使用js,逻辑层通过v8/jscore解释运行。代表框架是腾讯的MXFlutter。这个框架是开源的,大写的👍。




  • 方案三:布局,逻辑,一把梭


    主要思路:与方案一最主要的区别是,逻辑层也是使用dart,增加了一层语法解析和运行时。有一个代表,美团的MTFlutter,然而没有开源动向,无从考察更多。




  • 方案二:DSL + JS


    主要思路:基于模板实现动态化,主要布局层采用Dart转DSL的方式,逻辑层使用JS。代表框架是58同城开源的Fair




MXFlutter


项目简介



MXFlutter 是一套基于 JavaScript 的 Flutter 框架,可以用极其类似 Dart 的开发方式,通过编写 JavaScript 代码,来开发 Flutter 应用,或者使用 mxjsbuilder 编译器,把现有Flutter 工程编译为JS,运行在 mxflutter 之上。



核心思想



核心思路是把 Flutter 的渲染逻辑中的三棵树中的第一棵,放到 JavaScript 中生成。用 JavaScript 完整实现了 Flutter 控件层封装,可以使用 JavaScript,用极其类似 Dart 的开发方式,开发Flutter应用,利用JavaScript版的轻量级Flutter Runtime,生成UI描述,传递给Dart层的UI引擎,UI引擎把UI描述生产真正的 Flutter 控件。



MxFlutter 目前已经停止维护,具体请看MXFlutter
MxFlutter通过JavaScript编写Dart,加载线上js文件,通过引擎在运行时转化并显示,从而达到动态化效果。 官方在0.7.0版本开始接入TypeScript,引入npm生态,优化了js开发的成本,向前端生态进一步靠拢。
很遗憾,在对比各大厂的方案时,发现MxFlutter的性价比极低,学习成本也高,而且又抛弃Dart生态。开发及维护成本都很高。


MTFlutter


项目简介



美团的MTFlutter团队flap项目采用的静态生产DSL方案,通过对Dart语言注解,保证平台一致性。实现了动态下发与解释的逻辑页面一体化的 Flutter 动态化方案。Flap 的出现让 Flutter 动态化和包大小这两个短板得到了一定程度的弥补,促进了 Flutter 生态的发展。



核心思想



通过静态生产 DSL+Runtime 解释运行的思路,实现了动态下发与解释的逻辑页面一体化的 Flutter 动态化方案,建设了一套 Flap 生态体系,涵盖了开发、发布、测试、运维各阶段。



布局和逻辑层都使用Dart, 增加了一层语法解析和运行时。然而没有开源动向,无从考察更多。


Fair


项目简介



Fair是为Flutter设计的动态化框架,通过Fair Compiler工具对原生Dart源文件的自动转化,使项目获得动态更新Widget Tree和State的能力。


创建Fair的目标是支持不发版(Android、iOS、Web)的情况下,通过业务bundle和JS下发实现更新,方式类似于React Native。与Flutter Fair集成后,您可以快速发布新的页面,而无需等待应用的下一个发布日期。Fair提供了标准的Widget,它可以被用作一个新的动态页面或作为现有Flutter页面的一部分,诸如运营位的排版/样式修改,整页面替换,局部替换等都可以使用。



核心思想



Fair是58自研的的动态化框架,通过Fair Compiler工具对原生Dart源文件的自动转化,使项目获得动态更新Widget Tree和State的能力。



pic_d3WbXUd1d1V9d1WcXU37U7U75aXdd17b


三,方案对比


经过上述三个方案的调研,我们来大概对比一下上述框架


方案开源方核心思想优点缺点
MXFlutter腾讯用js编写Dart,动态拉取js脚本目前相对最完整的Flutter使用JS开发方案采用js方式编写Dart,维护困难
MTFlutter美团布局,逻辑都使用Dart,增加语法解析和运行时支持布局动态化和逻辑动态化未开源
Fair58通过bundle和js实现热更新支持布局动态化和逻辑动态化开源社区活跃, 开发工具丰富部分语法不支持

可以看到, MXFlutter需要使用js写Dart, 官方已经停止更新,而这种方式我们不能接受, MTFlutter目前未开源,无从继续研究。 接下来着重看一下 Fair


四,Fair接入过程


1,添加依赖


推荐使用pub形式引入


# add Fair dependency
dependencies:
fair: 2.7.0

# add compiler dependency
dev_dependencies:
build_runner: ^2.0.0
fair_compiler: ^1.2.0

# switch "fair_version" according to the local Flutter SDK version
dependency_overrides:
fair_version: 3.0.0

Flutter版本切换


通过切换 flutter_version 版本进行版本兼容。例如,将本机切换为 flutter 2.0.6 后,Fair 需要同步切换


# switch to another stable flutter version
dependency_overrides:
fair_version: 2.0.6

2,使用 Fair


在App中接入Fair步骤如下:


将 FairApp 添加为需要动态化部分的顶级节点

常见做法是作为 App 的根节点,如果不是全局采用也可以作为子页面的根节点


void main() {
WidgetsFlutterBinding.ensureInitialized();

FairApp.runApplication(
_getApp(),
plugins: {
},
);
}

dynamic _getApp() => FairApp(
modules: {
},
delegate: {
},
child: MaterialApp(
home: FairWidget(
name: 'DynamicWidget',
path: 'assets/bundle/lib_src_page_dynamic_widget.fair.json',
data: {"fairProps": json.encode({})}),
),
);

添加动态组件

每一个动态组件由一个FairWidget表示。


FairWidget(
name: 'DynamicWidget',
path: 'assets/bundle/lib_src_page_dynamic_widget.fair.json',
data: {"fairProps": json.encode({})}),

根据不同场景诉求,FairWidget可以混合和使用



  1. 可以作为不同组件混合使用

  2. 一般作为一个全屏页面

  3. 支持嵌套使用,即可以局部嵌套在普通Widget下,也可以嵌套在另一个FairWidget下


五,Fair接入体验


1,fork,下载工程


将官方Github工程fork到自己仓库后, 下载工程。使用官方提供的 test_case/best_ui_templates工程体验fair的体验。


2, 执行 pub get


在 best_ui_templates工程中,执行 pub get命令获取依赖。


3,开发业务


接下来正式开始开发流程。 把一个页面改写为 用Fair 编写:



  1. 创建需要动态化的 componnet, 并添加 @FairPatch() 注解。添加上注解后,在Fair生成产物时,会把此Component build生成 FairWidget加载的产物。


image-20221116173528472


2, 执行 Fair工具链插件的命令生成产物, 如图:


<u>image-20221116173837910</u>


3, 最终生成的产物,拷贝到 assets/bundle目录下(配置config.json后,会自动拷贝)


<u><u>image-20221116182132104</u></u>


4, 看效果, 下图为使用 Fair 改造后的页面:



Screenshot_2022_1116_172859
Screenshot_2022_1116_192940

六,Fair优势


1,社区活跃度高


官方对Fair维护力度大,版本更新较快,问题修复及时,活跃的开发者社区氛围。


使得开发者在开发Fair过程中遇到的问题, 能够及时反馈给官方, 并能得到快速的帮助和解决。


2, 一份代码,灵活使用


Fair的区别于MTFlutter和MXFlutter这2种动态化方案,Fair能让同一份代码在Flutter原生和动态之间随意切换。在开发跟版本需求时,使用原生代码发布,以此持续保持Flutter的性能优势;而在热更新场景可以通过下发动态文件来达到动态更新Widget的目的。使用方式更加灵活。


3,配套开发工具丰富


Faircli配套工具链

官方为了让开发者快速上手,降低接入门槛, 解决在接入过程中的痛点。 Fair团队开发了Faircli配套工具链,主要包含三个部分:



  • 工程创建:快速搭建Fair载体工程及动态化工程。

  • 模板代码:提供页面及组件模板。

  • 本地热更新:线下开发使用,实现开发阶段快速预览Fair动态化功能。


在安装了工具链提供的dart命令行工具及AS插件后, 通过创建模板, 构建产物, 本地启服务,体验热更新功能,开发者可以轻松接入并体验Fair。


Fair语法检测插件

官方为了让开发者在Fair开发过程中,出现不正确或者不支持的语法问题。 开发了配套插件去提示用户使用Fair语法糖。


查看以下示例:


1,build方法下if的代码检测,及提示引导信息


44b58320-e608-420f-854f-799b5bf03cf5image


2,点击more action 或者 AS代码提示快捷键


41094a86-2aea-43e6-b7f0-69aef1c653c0image


3,根据提示点击替换


image.png


通过插件,在编写fair过程中,可以快速识别并解决不支持的语法问题。 提高开发Fair效率。


Fair Web代码编辑器

Fair其中一个方向是在线动态化平台,即在网页中编辑dart代码,在线预览Flutter效果和Fair动态化效果,并且发布Fair动态化产物。


通过在Fair Web代码编辑器,开发者可以在没有复杂的IDE配置的情况下,在网页端开发Fair并预览。 这无疑是降低了接入成本, 为开发者可以快速体验Fair提供了非常便捷的方式。


七,总结


通过近期对各大互联网公司在Flutter动态化方向上的探究方案。 发现这些方案都还没有达到成熟阶段,想在实际业务上落地, 还得看各团队后期的维护力度和开发投入程度。


MXFlutter使用js编写Dart的方式, 抛弃了原本Flutter的开发模式, 导致开发成本大,以及后续维护成本也大,官方已停止维护。


MTFlutter采用布局,逻辑都是使用Dart, 通过静态生产 DSL+Runtime 解释运行的思路,解决布局和逻辑的动态化,然而并没有开源计划,无从深入研究。


Fair通过Fair Compiler工具对原生Dart源文件的自动转化,使项目获得动态更新Widget Tree和State的能力。目前官方维护力度较大, 社区活跃,并且有比较全面的Fair生态工具。 期待 Fair 团队可以解决在开发Fair过程中一些体验问题,如语法支持不全等, 让Fair成为真正能够让开发者可以快速接入,能够达到和正常开发Flutter接近的体验。 为广大Flutter开发人员解决动态化的痛点。


支持我们


欢迎大家使用 Fair,也欢迎大家为我们点亮star

Github地址:github.com/wuba/fair

Fair官网:fair.58.com/


欢迎贡献


通过Issue提交问题,贡献代码请提交Pull Request,管理员将对代码进行审核。


作者:王猛猛
来源:juejin.cn/post/7174978087879671865
收起阅读 »

全网最优雅安卓控件可见性检测

引子 view.setOnClickListener { // 当控件被点击时触发的逻辑 } 正是因为 View 对控件点击采用了策略模式,才使得监听任何控件的点击事件变得易如反掌。 我有一个愿望。。。 如果 View 能有一个可见性监听该多好啊! view...
继续阅读 »

引子


view.setOnClickListener { // 当控件被点击时触发的逻辑 }

正是因为 View 对控件点击采用了策略模式,才使得监听任何控件的点击事件变得易如反掌。


我有一个愿望。。。


如果 View 能有一个可见性监听该多好啊!


view.setOnVisibilityChangeListener { isVisible: Boolean ->   }

系统并未提供这个方法。。。


但业务上有可见性监听的需要,比如曝光埋点。当某控件可见时,上报XXX。


数据分析同学经常抱怨曝光数据不准确,有的场景曝光多报了,有的场景曝光少报了。。。


开发同学看到曝光埋点也很头痛,不同场景的曝光检测有不同的方法,缺乏统一的可见性检测入口,存在一定重复开发。


本文就试图为单个控件以及列表项的可见性提供统一的检测入口。


控件的可见性受到诸多因素的影响,下面是影响控件可见性的十大因素:



  1. 手机电源开关

  2. Home 键

  3. 动态替换的 Fragment 遮挡了原有控件

  4. ScrollView, NestedScrollView 的滚动

  5. ViewPager, ViewPager2 的滚动

  6. RecyclerView 的滚动

  7. 被 Dialog 遮挡

  8. Activity 切换

  9. 同一 Activity 中 Fragment 的切换

  10. 手动调用 View.setVisibility(View.GONE)

  11. 被输入法遮盖


能否把这所有的情况都通过一个回调方法表达?目标是通过一个 View 的扩展方法完成上述所有情况的检测,并将可见性回调给上层,形如:


fun View.onVisibilityChange(block: (view: View, isVisible: Boolean) -> Unit) {}

若能实现就极大化简了上层可见性检测的复杂度,只需要如下代码就能实现任意控件的曝光上报埋点:


view.onVisibilityChange { view, isVisible ->
if(isVisible) { // 曝光埋点 }
else {}
}

控件全局可见性检测


可见性检测分为两步:



  1. 捕获时机:调用检测算法检测控件可见性的时机。

  2. 检测算法:描述如何检测控件是否对用户可见。


拿“手动调用 View.setVisibility(View.GONE)”举例,得先捕获 View Visibility 发生变化的时机,并在此刻检测控件的可见性。


下面是View.setVisibility()的源码:


// android.view.View.java
public void setVisibility(@Visibility int visibility) {
setFlags(visibility, VISIBILITY_MASK);
}

系统并未在该方法中提供类似于回调的接口,即一个 View 的实例无法通过回调的方式捕获到 visibility 变化的时机。


难道通过自定义 View,然后重写 setVisibility() 方法?


这个做法接入成本太高且不具备通用性。


除了“手动调用 View.setVisibility(View.GONE)”,剩下的影响可见性的因素大多都可找到对应回调。难道得在fun View.onVisibilityChange()中对每个因素逐个添加回调吗?


这样实现太过复杂了,而且也不具备通用性,假设有例外情况,fun View.onVisibilityChange()的实现就得修改。


上面列出的十种影响控件可见性的因素都是现象,不同的现象背后可能对应相同的本质。


经过深挖,上述现象的本质可被收敛为下面四个:



  1. 控件全局重绘

  2. 控件全局滚动

  3. 控件全局焦点变化

  4. 容器控件新增子控件


下面就针对这四个本质编程。


捕获全局重绘时机


系统提供了ViewTreeObserver


public final class ViewTreeObserver {
public void addOnGlobalLayoutListener(OnGlobalLayoutListener listener) {
checkIsAlive();
if (mOnGlobalLayoutListeners == null) {
mOnGlobalLayoutListeners = new CopyOnWriteArray();
}
mOnGlobalLayoutListeners.add(listener);
}
}

ViewTreeObserver 是一个全局的 View 树变更观察者,它提供了一系列全局的监听器,全局重绘即是其中OnGlobalLayoutListener


public interface OnGlobalLayoutListener {
public void onGlobalLayout();
}

当 View 树发生变化需要重绘的时候,就会触发该回调。


调用 View.setVisibility(View.GONE) 之所以能将控件隐藏,正是因为整个 View 树触发了一次重绘。(任何一次微小的重绘都是从 View 树的树根自顶向下的遍历并触发每一个控件的重绘,不需要重绘的控件会跳过,关于 Adroid 绘制机制的分析可以点击Android自定义控件 | View绘制原理(画多大?)


在可见性检测扩展方法中捕获第一个时机:


fun View.onVisibilityChange(block: (view: View, isVisible: Boolean) -> Unit) {
viewTreeObserver.addOnGlobalLayoutListener {}
}

其中viewTreeObserver是 View 的方法:


// android.view.View.java
public ViewTreeObserver getViewTreeObserver() {
if (mAttachInfo != null) {
return mAttachInfo.mTreeObserver;
}
if (mFloatingTreeObserver == null) {
mFloatingTreeObserver = new ViewTreeObserver(mContext);
}
return mFloatingTreeObserver;
}

getViewTreeObserver() 用于返回当前 View 所在 View 树的观察者。


全局重绘其实覆盖了上述的两个场景:



  1. 同一 Activity 中 Fragment 的切换

  2. 手动调用 View.setVisibility(View.GONE)

  3. 被输入法覆盖


这两个场景都会发生 View 树的重绘。


捕获全局滚动时机



  1. ScrollView, NestedScrollView 的滚动

  2. ViewPager, ViewPager2 的滚动

  3. RecyclerView 的滚动


上述三个时机的共同特点是“发生了滚动”。


每个可滚动的容器控件都提供了各自滚动的监听


// android.view.ScrollView.java
public interface OnScrollChangeListener {
void onScrollChange(View v, int scrollX, int scrollY, int oldScrollX, int oldScrollY);
}

// androidx.viewpager2.widget.ViewPager2.java
public abstract static class OnPageChangeCallback {
public void onPageScrolled(int position, float positionOffset, @Px int positionOffsetPixels) {}
public void onPageSelected(int position) {}
public void onPageScrollStateChanged(@ScrollState int state) {}
}

// androidx.recyclerview.widget.RecyclerView.java
public abstract static class OnScrollListener {
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {}
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {}
}

难道要针对不同的滚动控件设置不同的滚动监听器?


这样可见性检测就和控件耦合了,不具有通用性,也愧对View.onVisibilityChange()这个名字。


还好又在ViewTreeObserver中找到了全局的滚动监听:


public final class ViewTreeObserver {
public void addOnScrollChangedListener(OnScrollChangedListener listener) {
checkIsAlive();

if (mOnScrollChangedListeners == null) {
mOnScrollChangedListeners = new CopyOnWriteArray();
}

mOnScrollChangedListeners.add(listener);
}
}

public interface OnScrollChangedListener {
public void onScrollChanged();
}

在可见性检测扩展方法中捕获第二个时机:


fun View.onVisibilityChange(block: (view: View, isVisible: Boolean) -> Unit) {
viewTreeObserver.addOnGlobalLayoutListener {}
viewTreeObserver.addOnScrollChangedListener {}
}

捕获全局焦点变化时机


下面这些 case 都是焦点发生了变化:



  1. 手机电源开关

  2. Home 键

  3. 被 Dialog 遮挡

  4. Activity 切换


同样借助于 ViewTreeObserver 可以捕获到焦点变化的时机。


到目前为止,全局可见性扩展方法中已经监听了三种时机,分别是全局重绘、全局滚动、全局焦点变化:


fun View.onVisibilityChange(block: (view: View, isVisible: Boolean) -> Unit) {
viewTreeObserver.addOnGlobalLayoutListener {}
viewTreeObserver.addOnScrollChangedListener {}
viewTreeObserver.addOnWindowFocusChangeListener {}
}

捕获新增子控件时机


最后一个 case 是最复杂的:动态替换的 Fragment 遮挡了原有控件。


该场景如下图所示:
1668323952343.gif


界面中有一个底边栏,其中包含各种 tab 标签,点击其中的标签会以 Fragment 的形式从底部弹出。此时,底边栏各 tab 从可见变为不可见,当点击返回时,又从不可见变为可见。


一开始的思路是“从被遮挡的 View 本身出发”,看看某个 View 被遮挡后,其本身的属性是否会发生变化?


View 内部以is开头的方法如下所示:


微信截图_20221113152433.png


我把其中名字看上去可能和被遮挡有关联的方法值全都打印出来了,然后触发 gif 中的场景,观察这些值在触发前后是否会发生变化。


几十个属性,一一比对,在看花眼之前,log 告诉我,被遮挡之后,这些都没有发生任何变化。。。。


绝望。。。但还不死心,继续寻找其他方法:


微信截图_20221113152922.png


我又找了 View 内部所有has开头的方法,也把其中看上去和被遮挡有关的方法全打印出来了。。。你猜结果怎么着?依然是徒劳。。。。


我开始质疑出发点是否正确。。。此时一声雷鸣劈醒了我。


视图只可能了解其自身以及其下层视图的情况,它无法得知它的平级甚至是父亲的绘制情况。而 gif 中的场景,即是在底边栏的同级有一个 Fragment 的容器。而且当视图被其他层级的控件遮挡时,整个绘制体系也不必通知那个被遮挡的视图,否则多低效啊(我yy的,若有大佬知道内情,欢迎留言指点一二。)


经过这层思考之后,我跳出了被遮挡的那个视图,转而去 Fragment 的容器哪里寻求解决方案。


Fragment 要被添加到 Activity 必须提供一个容器控件,容器控件提供了一个回调用于监听子控件被添加:


// android.view.ViewGroup.java
public void setOnHierarchyChangeListener(OnHierarchyChangeListener listener) {
mOnHierarchyChangeListener = listener;
}

public interface OnHierarchyChangeListener {
void onChildViewAdded(View parent, View child);
void onChildViewRemoved(View parent, View child);
}

为了监听 Fragment 被添加的这个瞬间,得为可见性检测扩展方法添加一个参数:


fun View.onVisibilityChange(
viewGroup:
ViewGroup? = null, // 容器
block: (
view: View, isVisible: Boolean) -> Unit
)
{ }

其中 viewGroup 表示 Fragment 的容器控件。


既然 Fragment 的添加也是往 View 树中插入子控件,那 View 树必定会重绘,可以在全局重绘回调中进行分类讨论,下面是伪代码:


fun View.onVisibilityChange(
viewGroup:
ViewGroup? = null,
block: (
view: View, isVisible: Boolean) -> Unit
)
{
var viewAdded = false
// View 树重绘时机
viewTreeObserver.addOnGlobalLayoutListener {
if(viewAdded){
// 检测新插入控件是否遮挡当前控件
}
else {
// 检测当前控件是否出现在屏幕中
}
}
// 监听子控件插入
viewGroup?.setOnHierarchyChangeListener(object : OnHierarchyChangeListener {
override fun onChildViewAdded(parent: View?, child: View?) {
viewAdded = true
}
}
override fun onChildViewRemoved(parent: View?, child: View?) {
viewAdded = false
}
})
}

子控件的插入回调总是先于 View 树重绘回调。所以先在插入时置标志位viewAdded = true,以便在重绘回调中做分类讨论。(因为检测子控件遮挡和是否出现在屏幕中是两种不同的检测方案)


可见性检测算法


检测控件的可见性的算法是:“判断控件的矩形区域是否和屏幕有交集”


为此新增扩展属性:


val View.isInScreen: Boolean
get() = ViewCompat.isAttachedToWindow(this) && visibility == View.VISIBLE && getLocalVisibleRect(Rect())

val 类名.属性名: 属性类型这样的语法用于为类的实例添加一个扩展属性,它并不是真地给类新增了一个成员变量,而是在类的外部新增属性值的获取方法。


当前新增的属性是 val 类型的,即常量,所以只需要为其定义个 get() 方法来表达如何获取它的值。


View 是否在屏幕中由三个表达式共同决定。



  1. 先通过 ViewCompat.isAttachedToWindow(this) 判断控件是否依附于窗口。

  2. 再通过 visibility == View.VISIBLE 判断视图是否可见。

  3. 最后调用getLocalVisibleRect()判断它的矩形相对于屏幕是否可见:


// android.view.View.java
public final boolean getLocalVisibleRect(Rect r) {
final Point offset = mAttachInfo != null ? mAttachInfo.mPoint : new Point();
if (getGlobalVisibleRect(r, offset)) {
r.offset(-offset.x, -offset.y);
return true;
}
return false;
}

该方法会先获取控件相对于屏幕的矩形区域并存放在传入的 Rect 参数中,然后再将其偏移到控件坐标系。如果矩形区域为空,则返回 false 表示不在屏幕中,否则为 true。


刚才捕获的那一系列时机,有可能会被多次触发。为了只将可见性发生变化的事件回调给上层,得做一次过滤:


val KEY_VISIBILITY = "KEY_VISIBILITY".hashCode()

val checkVisibility = {
// 获取上一次可见性
val lastVisibility = getTag(KEY_VISIBILITY) as? Boolean
// 获取当前可见性
val isInScreen = this.isInScreen() && visibility == View.VISIBLE
// 无上一次可见性,表示第一次检测
if (lastVisibility == null) {
if (isInScreen) {
// 回调可见性回调给上层
block(this, true)
// 更新可见性
setTag(KEY_VISIBILITY, true)
}
}
// 当前可见性和上次不同
else if (lastVisibility != isInScreen) {
// 回调可见性给上层
block(this, isInScreen)
// 更新可见性
setTag(KEY_VISIBILITY, isInScreen)
}
}

过滤重复事件的方案是记录上一次可见性(记录在 View 的 tag 中),如果这一次可见性检测结果和上一次相同则不回调给上层。


将可见性检测定义为一个 lambda,这样就可以在捕获不同时机时复用。


以下是完整的可见性检测代码:


fun View.onVisibilityChange(
viewGroups:
List<ViewGroup> = emptyList()
, // 会被插入 Fragment 的容器集合
needScrollListener: Boolean = true,
block: (view: View, isVisible: Boolean) -> Unit
) {
val KEY_VISIBILITY = "KEY_VISIBILITY".hashCode()
val KEY_HAS_LISTENER = "KEY_HAS_LISTENER".hashCode()
// 若当前控件已监听可见性,则返回
if (getTag(KEY_HAS_LISTENER) == true) return

// 检测可见性
val checkVisibility = {
// 获取上一次可见性
val lastVisibility = getTag(KEY_VISIBILITY) as? Boolean
// 判断控件是否出现在屏幕中
val isInScreen = this.isInScreen
// 首次可见性变更
if (lastVisibility == null) {
if (isInScreen) {
block(this, true)
setTag(KEY_VISIBILITY, true)
}
}
// 非首次可见性变更
else if (lastVisibility != isInScreen) {
block(this, isInScreen)
setTag(KEY_VISIBILITY, isInScreen)
}
}

// 全局重绘监听器
class LayoutListener : ViewTreeObserver.OnGlobalLayoutListener {
// 标记位用于区别是否是遮挡case
var addedView: View? = null
override fun onGlobalLayout() {
// 遮挡 case
if (addedView != null) {
// 插入视图矩形区域
val addedRect = Rect().also { addedView?.getGlobalVisibleRect(it) }
// 当前视图矩形区域
val rect = Rect().also { this@onVisibilityChange.getGlobalVisibleRect(it) }
// 如果插入视图矩形区域包含当前视图矩形区域,则视为当前控件不可见
if (addedRect.contains(rect)) {
block(this@onVisibilityChange, false)
setTag(KEY_VISIBILITY, false)
} else {
block(this@onVisibilityChange, true)
setTag(KEY_VISIBILITY, true)
}
}
// 非遮挡 case
else {
checkVisibility()
}
}
}

val layoutListener = LayoutListener()
// 编辑容器监听其插入视图时机
viewGroups.forEachIndexed { index, viewGroup ->
viewGroup.setOnHierarchyChangeListener(object : ViewGroup.OnHierarchyChangeListener {
override fun onChildViewAdded(parent: View?, child: View?) {
// 当控件插入,则置标记位
layoutListener.addedView = child
}

override fun onChildViewRemoved(parent: View?, child: View?) {
// 当控件移除,则置标记位
layoutListener.addedView = null
}
})
}
viewTreeObserver.addOnGlobalLayoutListener(layoutListener)
// 全局滚动监听器
var scrollListener:ViewTreeObserver.OnScrollChangedListener? = null
if (needScrollListener) {
scrollListener = ViewTreeObserver.OnScrollChangedListener { checkVisibility() }
viewTreeObserver.addOnScrollChangedListener(scrollListener)
}
// 全局焦点变化监听器
val focusChangeListener = ViewTreeObserver.OnWindowFocusChangeListener { hasFocus ->
val lastVisibility = getTag(KEY_VISIBILITY) as? Boolean
val isInScreen = this.isInScreen
if (hasFocus) {
if (lastVisibility != isInScreen) {
block(this, isInScreen)
setTag(KEY_VISIBILITY, isInScreen)
}
} else {
if (lastVisibility == true) {
block(this, false)
setTag(KEY_VISIBILITY, false)
}
}
}
viewTreeObserver.addOnWindowFocusChangeListener(focusChangeListener)
// 为避免内存泄漏,当视图被移出的同时反注册监听器
addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View?) {
}

override fun onViewDetachedFromWindow(v: View?) {
v ?: return
// 有时候 View detach 后,还会执行全局重绘,为此退后反注册
post {
try {
v.viewTreeObserver.removeOnGlobalLayoutListener(layoutListener)
} catch (_: java.lang.Exception) {
v.viewTreeObserver.removeGlobalOnLayoutListener(layoutListener)
}
v.viewTreeObserver.removeOnWindowFocusChangeListener(focusChangeListener)
if(scrollListener !=null) v.viewTreeObserver.removeOnScrollChangedListener(scrollListener)
viewGroups.forEach { it.setOnHierarchyChangeListener(null) }
}
removeOnAttachStateChangeListener(this)
}
})
// 标记已设置监听器
setTag(KEY_HAS_LISTENER, true)
}

该控件可见性检测方法,最大的用处在于检测 Fragment 的可见性。详细讲解可以点击 页面曝光难点分析及应对方案


Talk is cheap,show me the code


上述源码可以在这里找到。


推荐阅读


业务代码参数透传满天飞?(一)


业务代码参数透传满天飞?(二)


全网最优雅安卓控件可见性检测


全网最优雅安卓列表项可见性检测


页面曝光难点分析及应对方案


你的代码太啰嗦了 | 这么多对象名?


你的代码太啰嗦了 | 这么多方法调用?


作者:唐子玄
来源:juejin.cn/post/7165427955902971918
收起阅读 »