注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

【干货分享】uniapp做的安卓App如何加固

2023年了,uniapp还有人用吗? 对于这个问题,只能说,2023年了,使用uniapp去开发APP的人越来越多了。 一方面在于全平台兼容确实很香,对于一些小项目或者时间要求比较高的项目来说,可以节省大量的时间与精力,也为公司节约了成本;另一方面,开发速度...
继续阅读 »

2023年了,uniapp还有人用吗?


对于这个问题,只能说,2023年了,使用uniapp去开发APP的人越来越多了。


一方面在于全平台兼容确实很香,对于一些小项目或者时间要求比较高的项目来说,可以节省大量的时间与精力,也为公司节约了成本;另一方面,开发速度非常快。就像前面说的,对于一些小项目来说,几天就可以搞定,而对于一些大项目来说,性能和原生大差不差,而且全平台兼容的特性也可以弥补这点;最后就是社区,里面有很多优质的框架和插件,节约了大量的时间(时间就是发量!!!),更重要的是,社区出人才,总能找到人和你一起吐槽(bushi)睿智的官方......


总而言之,虽然uniapp文档一般好,bug一般多,更新像拆炸弹,但是,对于很多人来说,还是很有意义的。所以用的人还是很多。


但是目前随着各种商城上架政策的严格审查,对于加固等需求也慢慢起来了,所以今天我们来讲讲uniapp开发的安卓APP要如何加固。


加固原理


先来看看一般加固会从哪几个方向进行加固


image.png


而我们如果把uniapp制作的安卓APP在加固上其实大同小异--只要是apk或者aab格式都可以,所以我们就基于这个原理来进行加固。


加固流程


01 代码混淆


按照一般的思路,先给他混淆一下子。使用代码混淆工具来混淆 JavaScript 代码,以使其难以被逆向工程和破解。常用的混淆工具包括 ProGuard 和 DexGuard。在 UniApp 中,你可以在打包安卓应用时配置 ProGuard 来进行代码混淆。示例代码如下所示,在项目根目录下的 uniapp.pro 文件中添加以下配置:


-keep class com.dcloud.** { *; }
-keep public class * extends io.dcloud.* {
*;
}

02 加固资源文件 & 防止调试和反调试


加固资源文件: 将敏感资源文件(如证书、配置文件等)进行加密或混淆,以防止被攻击者获取。可以使用第三方工具对资源文件进行加密,或者自定义加密算法来保护资源文件的安全


防止调试和反调试: 这一步可以使用第三方库或自定义代码来实现这些保护措施。比如说,可以检测应用程序是否在调试模式下运行,并在调试模式下采取相应的措施,例如关闭应用程序或隐藏敏感信息。


import android.os.Debug;

public class DebugUtils {
public static boolean isDebugMode() {
return Debug.isDebuggerConnected();
}
}

就是说,在应用程序中调用 DebugUtils.isDebugMode() 方法,可以根据返回值来判断应用程序是否在调试模式下运行,并采取相应的措施。


03 加密敏感数据


我们直接使用PBEWithMD5AndDES 算法对数据进行加密和解密。使用的时候,你可以调用 EncryptionUtils.encrypt(data) 方法来加密敏感数据,并调用 EncryptionUtils.decrypt(encryptedData) 方法来解密数据。记得将 PASSWORDSALT 替换为你自己的密码和盐值(重要!!!)。


import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.PBEParameterSpec;
import java.security.spec.KeySpec;
import java.util.Base64;

public class EncryptionUtils {
private static final String ALGORITHM = "PBEWithMD5AndDES";
private static final String PASSWORD = "your_secret_password"; // 自定义密码,请更换为自己的密码
private static final byte[] SALT = {
(byte) 0x4b, (byte) 0x6d, (byte) 0x7d, (byte) 0x15,
(byte) 0x78, (byte) 0x56, (byte) 0x34, (byte) 0x22
}; // 自定义盐值,请更换为自己的盐值

public static String encrypt(String data) {
try {
KeySpec keySpec = new PBEKeySpec(PASSWORD.toCharArray(), SALT, 65536);
SecretKey secretKey = SecretKeyFactory.getInstance(ALGORITHM).generateSecret(keySpec);
Cipher cipher = Cipher.getInstance(ALGORITHM);
PBEParameterSpec parameterSpec = new PBEParameterSpec(SALT, 100);
cipher.init(Cipher.ENCRYPT_MODE, secretKey, parameterSpec);
byte[] encryptedBytes = cipher.doFinal(data.getBytes("UTF-8"));
return Base64.getEncoder().encodeToString(encryptedBytes);
} catch (Exception e) {
e.printStackTrace();
}
return null;
}

public static String decrypt(String encryptedData) {
try {
KeySpec keySpec = new PBEKeySpec(PASSWORD.toCharArray(), SALT, 65536);
SecretKey secretKey = SecretKeyFactory.getInstance(ALGORITHM).generateSecret(keySpec);
Cipher cipher = Cipher.getInstance(ALGORITHM);
PBEParameterSpec parameterSpec = new PBEParameterSpec(SALT, 100);
cipher.init(Cipher.DECRYPT_MODE, secretKey, parameterSpec);
byte[] decodedBytes = Base64.getDecoder().decode(encryptedData);
byte[] decryptedBytes = cipher.doFinal(decodedBytes);
return new String(decryptedBytes, "UTF-8");
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
}

04 防止篡改


我们使用 SHA-256 哈希算法计算数据的哈希值。使用的时候,可以调用 IntegrityUtils.calculateHash(data) 方法来计算数据的哈希值,并将其与原始的哈希值进行比较,以验证数据的完整性。例如:


String data = "Hello, world!";
String originalHash = "2ef7bde608ce5404e97d5f042f95f89f1c232871";
String calculatedHash = IntegrityUtils.calculateHash(data);

boolean isIntegrityVerified = IntegrityUtils.verifyIntegrity(data, originalHash);
if (isIntegrityVerified) {
System.out.println("Data integrity verified.");
} else {
System.out.println("Data has been tampered with!");
}

05 签名功能


补充一个Android签名。


1)简介


本工具用于对android加固后的apk进行重新签名。


版本文件备注
Windows版apk签名工具压缩包.exe该版本包含Java运行环境,不需要额外安装。
通用版dx-signer-v1.9r.jar该版本需要Java 8+的运行环境,请依照操作系统进行安装:Adoptium

本工具依照Apache 2.0 协议开源,可以在这里查看源码github.com/dingxiangte…



使用说明



  1. 下载签名工具dx-signer.jar,双击运行。

  2. 选择输入apk、aab文件。

  3. 选择签名的key文件,并输入key密码。

  4. 选择重签后apk、aab的路径,以apk结束。如:D:\sign.apk

  5. 点击“签名”按钮,等待即可签名完成。


ps:如果有alias(证书别名)密钥的或者有多个证书的,请在高级tab中选择alias并输入alias密码


2)多渠道功能简介


多渠道工具兼容友盟和美团walle风格的多渠道包,方便客户把APP发布到不同的应用平台,进行渠道统计。



使用说明



  1. 在app中预留读取渠道信息的入口,具体见5.2.2读取渠道信息

  2. 在5.1.1的签名使用基础上,点击选择渠道清单

  3. 选择清单文件channel.txt。具体文件格式见5.2.3

  4. 点击签名,等待生成多个带签名的渠道app


读取渠道信息


顶象多渠道工具兼容友盟和美团walle风格的多渠道包,下面是两种不同风格的渠道信息读取方法。选其中之一即可


读取渠道信息:UMENG_CHANNEL

输出的Apk中将会包含UMENG_CHANNELmata-data



name="UMENG_CHANNEL"
android:value="XXX" />


可以读取这个字段。


public static String getChannel(Context ctx) {
String channel = "";
try {
ApplicationInfo appInfo = ctx.getPackageManager().getApplicationInfo(ctx.getPackageName(),
PackageManager.GET_META_DATA);
channel = appInfo.metaData.getString("UMENG_CHANNEL");
} catch (PackageManager.NameNotFoundException ignore) {
}
return channel;
}

读取渠道信息:Walle

输出的Apk也包含Walle风格的渠道信息


可以在使用Walle的方式进行读取。


implementation 'com.meituan.android.walle:library:1.1.7'


String channel = WalleChannelReader.getChannel(this.getApplicationContext());

渠道文件格式说明


请准备渠道清单文件channel.txt, 格式为每一行一个渠道, 例如:


0001_my
0003_baidu
0004_huawei
0005_oppo
0006_vivo

结语


以上就是基于uniapp制作的Android APP的加固方式,仅供参考~ 欢迎一起交流学习~




作者:昀和
来源:juejin.cn/post/7256615625882615866
收起阅读 »

集帅(美)们,别再写 :key = "index" 啦!

web
浅聊一下 灵魂拷问:你有没有在v-for里使用过:key = "index",如果有,我希望你马上改正过来并且给我点个赞,如果没有,来都来了,顺手给我点个赞... 假如您也和我一样,在准备春招。欢迎加我微信shunwuyu,这里有几十位一心去大厂的友友可以相...
继续阅读 »

浅聊一下


灵魂拷问:你有没有在v-for里使用过:key = "index",如果有,我希望你马上改正过来并且给我点个赞,如果没有,来都来了,顺手给我点个赞...



假如您也和我一样,在准备春招。欢迎加我微信shunwuyu,这里有几十位一心去大厂的友友可以相互鼓励,分享信息,模拟面试,共读源码,齐刷算法,手撕面经。来吧,友友们!



开始


在向掘友们解释为什么不能使用 :key = "index" 之前,我想我还得向你们铺垫一点东西


虚拟DOM


什么是虚拟DOM呢?虚拟DOM是一个对象,没想到吧...我们来看看Vue是如何将template模板里面的东西交给浏览器来渲染的


image.png


首先通过 compiler 将 template模板变成一个虚拟DOM,再将虚拟DOM转换成HTML,最后再交给浏览器V8引擎渲染,那么虚拟DOM是什么样的呢?


<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
</head>

<body>
<div id="app">
<ul id="item">
<li v-for="item in list" class="item">{{item}}</li>
</ul>
</div>
<script>
const { createApp, ref } = Vue
createApp({
setup() {
const list = ref(['vue','js','html'])
return {
list
}
}
}).mount('#app')
</script>
</body>

</html>

在这里,template模板实际上是


 <ul>
<li v-for="item in list">{{item}}</li>
</ul>

通过v-for循环,渲染出来了3个li


<ul>
<li>vue<li>
<li>js<li>
<li>html<li>
</ul>

我们的compiler会将这个模板转化成虚拟DOM


let oldDom = {
tagName = 'ul',
props:{
//存放id 和 class 等
id:'item'
},
children[
{
tagName = 'li',
props:{
class:'item'
},
children:['vue']
},
{
tagName = 'li',
props:{
class:'item'
},
children:['js']
},
{
tagName = 'li',
props:{
class:'item'
},
children:['html']
},
]
}

diff算法


给前面的例子来点刺激的,加上一个按钮和反转函数,点击按钮,list反转


<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
</head>

<body>
<div id="app">
<ul>
<li v-for="item in list">{{item}}</li>
</ul>
<button @click="change">change</button>
</div>
<script>
const { createApp, ref } = Vue
createApp({
setup() {
const list = ref(['唱','跳','rap','篮球'])
const change = ()=>{
list.value.reverse()
}
const add = ()=>{
list.value.unshift('6')
}
return {
list,
change,
}
}
}).mount('#app')
</script>
</body>

</html>

点击change按钮,此时我们的DOM更改vue又是如何来更新DOM的呢?


image.png


众所周知,回流和重绘会消耗极大的性能,而当DOM发生变更的时候会触发回流重绘(可以去看我的文章(从输入4399.com到页面渲染之间的回流和重绘),那么vue3就有一个diff算法,用来优化性能


image.png


当DOM更改,compiler会生成一个新的虚拟DOM,然后通过diff算法来生成一个补丁包,用来记录旧DOM和新DOM的差异,然后再拿到html里面进行修改,最后再交给浏览器V8进行渲染


简单介绍一下diff算法的比较规则



  1. 同层比较,是不是相同的结点,不相同直接废弃老DOM

  2. 是相同结点,比较结点上的属性,产生一个补丁包

  3. 继续比较下一层的子节点,采用双端对列的方式,尽量复用,产生一个补丁包

  4. 同上


image.png


别再写 :key = "index"


要说别写 :key = "index" ,我们得先明白key是用来干什么的...如果没有key,那么在diff算法对新旧虚拟DOM进行比较的时候就没法比较了,你看这里有两个一样的vue,当反转顺序以后diff算法不知道哪个vue该对应哪个vue了


image.png


如果我们用index来充当key的话来看,当我们在头部再插入一个结点的时候,后面的index其实是改变了的,导致diff算法在比较的时候认为他们与原虚拟DM都不相同,那么diff算法就等于没有用...


image.png


可以用随机数吗?


<li v-for="item in list" :key="Math.random()">

想出这种办法的,也是一个狠人...当然是不行的,因为在template模板更新时,会产生一个新的虚拟DOM,而这个虚拟DOM里面的key也是随机值,和原虚拟DOM里的key99.99999%是不一样的...


结尾


希望你以后再也不会写 :key = "index" 了



假如您也和我一样,在准备春招。欢迎加我微信shunwuyu,这里有几十位一心去大厂的友友可以相互鼓励,分享信息,模拟面试,共读源码,齐刷算法,手撕面经。来吧,友友们!



作者:滚去睡觉
来源:juejin.cn/post/7337513012394115111
收起阅读 »

VSCode无限画布模式(可能会惊艳到你的一个小功能)

❓现存的痛点VSCode是我的主力开发工具,在实际的开发中,我经常会对编辑器进行分栏处理,组件A的tsx、css代码、工具类方法各一个窗口,组件B的tsx、css代码、工具类方法各一个窗口,组件C的......当组件拆的足够多的时候,多个分栏会把本就不大的编辑...
继续阅读 »

❓现存的痛点

image.png

VSCode是我的主力开发工具,在实际的开发中,我经常会对编辑器进行分栏处理,组件A的tsx、css代码、工具类方法各一个窗口组件B的tsx、css代码、工具类方法各一个窗口,组件C的......

small.gif

当组件拆的足够多的时候,多个分栏会把本就不大的编辑器窗口分成N份,每一份的可视区域就小的可怜,切换组件代码时,需要不小的翻找成本,而且经常忘记我之前把文件放在了那个格子里,特别是下面的场景(一个小窗口内开了N个小tab),此时更难找到想要的窗口了...

多个tab.gif

问题汇总

  1. 分栏会导致每个窗口的面积变小,开发体验差(即使可以双击放大,但效果仍不符合预期);
  2. 编辑器窗口容易被新打开的窗口替换掉,常找不到之前打开的窗口;
  3. 窗口的可操作性不强,位置不容易调整

💡解题的思路

1. 自由 & 独立的编辑器窗口

  1. 分栏会导致每个窗口的面积变小,开发体验不好。

那就别变小了!每个编辑器窗口都还是原来的大小,甚至更大!

20240531-220533.gif

2. 无限画布

  1. 编辑器窗口容易被新打开的窗口替换掉,常找不到之前打开的窗口。
  2. 窗口的可操作性不强,位置不容易调整。

那就每个窗口都拥有一个自己的位置好了!拖一下就可以找到了!

scroll.gif

3. 画布体验

好用的画布是可以较大的提升用户体验的,下面重点做了四个方面的优化:

3.1 在编辑器里可以快速缩小 & 移动

因为不可避免的会出现一些事件冲突(比如编辑器里的滚动和画布的滚动、缩放等等),通过提供快捷键的解法,可以在编辑器内快速移动、缩放画布。

command + 鼠标上下滑动 = 缩放
option + 鼠标移动 = 画布移动

注意下图,鼠标还在编辑器窗口中,依然可以拖动画布👇🏻

single-editor.gif

3.2 快速放大和缩小编辑窗口

通过快捷按钮的方式,可以快速的放大和缩小编辑器窗口。

scale.gif

3.3 一键定位到中心点

不小心把所有窗口都拖到了画布视口外找不到了?没事儿,可以通过点击快捷按钮的方式,快速回到中心点。

center.gif

3.4 窗口的合并和分解

可以在窗口下进行编辑器的合并,即可以简单的把一些常用的窗口进行合并、分解。

add-remove.gif

💬 提出的背景

作为一名前端开发同学,避免不了接触UI同学的设计稿,我司使用的就是figma,以figma平台为例,其无限画布模式可以非常方便的平铺N个稿子,并快速的看到所有稿子的全貌、找到自己想要的稿子等等,效果如下:

figma.gif

没错!我就是基于这个思路提出了第一个想法,既然图片可以无限展示,编辑器为什么不能呢?

这个想法其实去年就有了,期间大概断断续续花了半年多左右的时间在调研和阅读VSCode的源码上,年后花了大概3个月的时间进行实现,最终在上个月做到了上面的效果。

经过约一个月的试用(目前我的日常需求均是使用这种模式进行的开发),发现效果超出预期,我经常会在画布中开启约10+个窗口,并频繁的在各个窗口之间来回移动,在这个过程中,我发现以下几点很让我很是欣喜:

  1. 空间感:我个人对“空间和方向”比较敏感,恰好画布模式会给我一种真实的空间感,我仿佛在一间房子里,里面摆满了我的代码,我穿梭在代码中,修一修这个,调一调这个~
  2. 满足感:无限画布的方式,相当于我间接拥有了无限大的屏幕,我只需要动动手指找到我的编辑窗口就好了,它可以随意的放大和缩小,所以我可以在屏幕上展示足够多的代码。
  3. 更方便的看源码:我可以把源码的每个文件单独开一个窗口,然后把每个窗口按顺序铺起来,摆成一条线,这条线就是源码的思路(当然可以用截图的方式看源码 & 缕思路,但是,需要注意一点,这个编辑器是可以交互的!)

⌨️ 后续的计划

后续计划继续增强画布的能力,让它可以更好用:

  1. 小窗口支持命名,在缩小画布时,窗口缩小,但是命名不缩小,可以直观的找到想要的窗口。
  2. 增强看源码的体验:支持在画布上添加其他元素(文案、箭头、连线),试想一下,以后在看源码时,拥有一个无限的画板来展示代码和思路,关键是代码是可以交互的,这该有多么方便!
  3. 类似MacOS的台前调度功能:把有关联的一些窗口分组,画布一侧有分组的入口,点击入口可以切换画布中的组,便于用户快速的进行批量窗口切换,比如A页面的一些JS、CSS等放在一个组,B页面放在另一个组,这样可以快速的切换文件窗口。

📔 其他的补充

调研过程中发现无法使用VSCode的插件功能来实现这个功能,所以只好fork了一份VSCode的开源代码,进行了大量修改,最终需要对源码进行编译打包才能使用(一个新的VSCode),目前只打包了mac的arm64版本来供自己试用。

另外,由于VSCode并不是100%开源(微软的一些服务相关的逻辑是闭源的),所以github上的开源仓库只是它的部分代码,经过编译之后,发现缺失了远程连接相关的功能,其他的功能暂时没有发现缺失。

image.png

🦽 可以试用吗

目前还没有对外提供试用版的打算,想自己继续使用一些时间,持续打磨一下细节,等功能细节更完善了再对外进行推广,至于这次的软文~ 其实是希望可以引起阅读到这里的同学进行讨论,可以聊一下你对该功能的一些看法,以及一些其他的好点子~,thx~

🫡 小小的致敬

  • 致敬VSCode团队,在阅读和改造他们代码的过程中学习到了不少hin有用的代码技能,也正是因为有他们的开源,才能有我的这次折腾👍🏻
  • 致敬锤子科技罗永浩老师,这次实现思路也有借鉴当年发布的“无限屏”功能,本文的头图就是来自当年的发布会截图。

image.png


作者:木头就是我呀
来源:juejin.cn/post/7375586227984220169
收起阅读 »

安卓高版本HTTPS抓包:终极解决方案

虽然市面上有好多抓包工具,但是 Android 高版本都需要安装抓包工具的证书到系统目录,才能抓 https 协议的包。本文就以 Charles这个抓包工具来介绍,如何安装证书到 Android 的系统目录,实现 https 抓包。 修改证书名称 启动 Cha...
继续阅读 »

虽然市面上有好多抓包工具,但是 Android 高版本都需要安装抓包工具的证书到系统目录,才能抓 https 协议的包。本文就以 Charles这个抓包工具来介绍,如何安装证书到 Android 的系统目录,实现 https 抓包。


修改证书名称


启动 Charles,通过菜单栏中的 Help → SSL Proxying → Save Charles Root Certificate… 将 Charles 的证书导出。
使用 OpenSSL 查看证书在 Android 系统中对应的文件名,并重命名证书文件


openssl x509 -subject_hash_old -in charles-ssl-proxying-certificate.pem | head -n 1  #cdfb61bc
mv charles-ssl-proxying-certificate.pem cdfb61bc.0

将证书安装到系统证书目录下


使用 adb push 命令将我们的证书文件放到 SD 卡中


adb push cdfb61bc.0 /sdcard/Download

使用 adb 连接手机并切换到 root 用户


adb shell
su

将证书文件移动到 /system/etc/security/cacerts 目录下,由于 /system 默认是只读的,所以要先重新挂载为其添加写入权限


cat /proc/mounts  #查看挂载信息,这里我的 /system 是直接挂载到 / 的

mount -o rw,remount /
mv /sdcard/Download/cdfb61bc.0 /system/etc/security/cacerts
chmod 644 /system/etc/security/cacerts/cdfb61bc.0 #设置文件权限

如果👆的步骤你都能成功,就不用继续往下看了。


终极解决方案


我用我手上的手机都试了一下,用上面的方式安装正式,发现不能成功,一直提示 Read-only file system,但是HttpToolkit这个软件确可以通过 Android Device Via ADB来抓 https 的包。
它是怎么实现的呢?
这下又开始了漫长的谷歌之旅,最后在他们官网找到一篇文章,详细讲述了 通过有root权限的adb 来写入系统证书的神奇方案。



  1. 通过 ADB 将 HTTP Toolkit CA 证书推送到设备上。

  2. 从 /system/etc/security/cacerts/ 中复制所有系统证书到临时目录。

  3. 在 /system/etc/security/cacerts/ 上面挂载一个 tmpfs 内存文件系统。这实际上将一个可写的全新空文件系统放在了 /system 的一小部分上面。 将复制的系统证书移回到该挂载点。

  4. 将 HTTP Toolkit CA 证书也移动到该挂载点。

  5. 更新临时挂载点中所有文件的权限为 644,并将系统文件的 SELinux 标签设置为 system_file,以使其看起来像是合法的 Android 系统文件。


关键点就是挂载一个 内存文件系统, 太有才了。
具体命令如下


# 创建一个独立的临时目录,用于存储当前的证书
# 如果不这样做,在我们添加挂载后将无法再读取到当前的证书。
mkdir -m 700 /data/local/tmp/htk-ca-copy
# 复制现有的证书到临时目录
cp /system/etc/security/cacerts/* /data/local/tmp/htk-ca-copy/
# 在系统证书文件夹之上创建内存挂载点
mount -t tmpfs tmpfs /system/etc/security/cacerts
# 将之前复制的证书移回内存挂载点中,确保继续信任这些证书
mv /data/local/tmp/htk-ca-copy/* /system/etc/security/cacerts/
# 将新的证书复制进去,以便我们也信任该证书
cp /data
/local/tmp/c88f7ed0.0 /system/etc/security/cacerts/
# 更新权限和SELinux上下文标签,确保一切都和之前一样可读
chown root:root /system/etc/security/cacerts/*
chmod 644 /system/etc/security/cacerts/*
chcon u:object_r:system_file:s0 /system/etc/security/cacerts/*
# 删除临时证书目录
rm -r /data/local/tmp/htk-ca-copy

注意:由于是内存文件系统,所以重启手机后就失效了。可以将以上命令写成 shell 脚本,需要抓包的时候执行下就可以了


作者:平行绳
来源:juejin.cn/post/7360242772303577125
收起阅读 »

接口幂等和防抖还在傻傻分不清楚。。。

最近想重温下接口幂等的相关知识,正所谓温故而知新,太久不CV的东西要是哪天CV起来都生疏了,哈哈哈 先从字面意思来温习下吧,幂等的官方概念来源于数学上幂等概念的衍生,如幂等函数,即使用相同的参数重复执行,可以得到相同的结果的函数,翻译成IT行业专业术语就是一个...
继续阅读 »

最近想重温下接口幂等的相关知识,正所谓温故而知新,太久不CV的东西要是哪天CV起来都生疏了,哈哈哈


先从字面意思来温习下吧,幂等的官方概念来源于数学上幂等概念的衍生,如幂等函数,即使用相同的参数重复执行,可以得到相同的结果的函数,翻译成IT行业专业术语就是一个接口使用相同的入参,无论执行多少次,最后得到的结果且保存的数据和执行一次是完全一样的,所以,基于这个概念,分析我们的CRUD,首先,查询,它可以说是幂等的,但是如果更精细的说,它也可能不是幂等的,基于数据库数据不变的情况下,查询接口是幂等的,如果是变化的话那可能上一秒你查出来的数据,下一秒它就被人修改了也不是不可能,所以,基于这一点它不符合幂等概念


接下来是删除接口,它和查询一样,也是天然幂等的,但是如果你的接口提供范围删除,那么就破坏了幂等性原则,理论上这个删除接口就不应该存在,那如果是产品经理非要那也是可以存在滴,技术永远都是为业务服务的嘛


修改接口也是同样的道理,理论上都必须是幂等的,如果不是,那就要考虑接口幂等性了,比如你的修改积分接口里写修改积分,每次都使用i++这种操作,那么它就破坏了幂等原则,有一个好方法就是基于用户唯一性标识把积分变动通过一张表记录下来,最后统计这张表的积分数值,这里也就涉及到新增接口的知识点,其实到这里,我们会发现,所有的接口理论上都可以是幂等的,但是总是这个那个的原因导致不幂等,所以,总结起来就是,如果你的系统需求需要接口幂等,那么就实现它,现在让我们进入正题吧


刚开始温习幂等知识的时候,我百度了很多别人写的文章,发现另一个概念,叫防抖,防止用户重复点击的意思,有意思的是有些文章竟然认为防抖就是幂等,他们解决接口幂等的思路是每次调用需要实现幂等接口时,前端都需要调用后端的颁布唯一token的接口,后端颁布token后保存在缓存中,然后前端带着这个token的请求去请求我们的幂等接口,验证携带的token来实现接口幂等,按照这个思路每次请求的token都不一样,如何保证幂等中相同参数的条件呢,这显然和幂等南辕北辙了,这显然就是接口防抖或者接口加锁的思路嘛


还有一种是可以实现接口幂等性的思路,这里也可以分享一下,和上面的思路差不多,也是每次请求幂等接口的时候,先调用颁发唯一token的接口,唯一不同的是它颁发的token是基于入参生成的哈希值,后面的业务逻辑就是后端基于这个哈希值去校验,如果缓存中已经存在了,说明这个入参已经请求过了,那么直接拒绝请求并返回成功,这样,就从表面上实现了接口幂等性,因为执行100次我只执行一次,剩余的99次我都拒绝,并直接返回成功,这样,我的接口就从表面上幂等了,但是这个方案有一个很大的问题就是每次调用都需要浪费一部分资源在请求颁发token上,这对需要大量的幂等接口的系统来说就是一个累赘,所以,接下来,我们基于这个思路实现一个不需要二次调用的实现接口幂等的方法。


我的思路是这样的,业务上有些接口是实现防抖功能,有些是实现幂等功能,其实这两个功能在业务上确实是相差不大,所以,我的思路是定义一个注解,包含防抖和幂等的功能,首先基于幂等如果要是把所有入参都哈希化作为唯一标识的话有点费劲,可以基于业务上的一些唯一标识来做,如用户id或者code,还需要一个开关,用于决定是否保存这个唯一标识,还要一个时间,保存多久,还有保存时间的单位,最后,还有一个返回提醒,就是拒绝之后的友好提示,基于这些差不多了,如果你的接口功能只需要实现防抖,那么你可以设置时间段内过期,这样就实现了防抖,如果你的接口没有唯一标识,那么可以基于路由来做防抖,这个不要忘了设置过期时间,不然你的接口就永远是拒绝了,好了,思路有了,接下来就是实操了,话不多数,上代码


@Retention(RetentionPolicy.RUNTIME)
//注解用于方法
@Target({ElementType.TYPE, ElementType.METHOD})
//注解包含在JavaDoc中
@Documented
public @interface Idempotent {

/**
* 幂等操作的唯一标识,使用spring el表达式 用#来引用方法参数
*
* @return Spring-EL expression
*/

String key() default "";

/**
* 有效期 默认:1 有效期要大于程序执行时间,否则请求还是可能会进来
*
* @return expireTime
*/

int expireTime() default 100;

/**
* 时间单位 默认:s
*
* @return TimeUnit
*/

TimeUnit timeUnit() default TimeUnit.SECONDS;

/**
* 提示信息,可自定义
*
* @return String
*/

String info() default "重复请求,请稍后重试";

/**
* 是否在业务完成后删除key true:删除 false:不删除
*
* @return boolean
*/

boolean delKey() default false;


基本和我们上面的思路一样,唯一key,有效期,有效期时间单位,提示信息,是否删除,注解有了,那么我们就要基于注解写我们的逻辑了,这里我们需要用到aop,引用注解应该都知道吧,这里我们直接上代码了


@Aspect
@Slf4j
public class IdempotentAspect {
@Resource
private RedisUtil redisUtil;

private static final SpelExpressionParser PARSER = new SpelExpressionParser();

private static final LocalVariableTableParameterNameDiscoverer DISCOVERER = new LocalVariableTableParameterNameDiscoverer();

/**
* 线程私有map
*/

private static final ThreadLocal<Map<String, Object>> THREAD_CACHE = ThreadLocal.withInitial(HashMap::new);

private static final String KEY = "key";

private static final String DEL_KEY = "delKey";

// 以自定义 @Idempotent 注解为切点
@Pointcut("@annotation(com.liuhui.demo_core.spring.Idempotent)")
public void idempotent() {
}

@Before("idempotent()")
public void before(JoinPoint joinPoint) throws Throwable {
//获取到当前请求的属性,进而得到HttpServletRequest对象,以便后续获取请求URL和参数信息。
ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder
.getRequestAttributes();
HttpServletRequest request = requestAttributes.getRequest();
//从JoinPoint中获取方法签名,并确认该方法是否被@Idempotent注解标记。如果是,则继续执行幂等性检查逻辑;如果不是,则直接返回,不进行幂等处理。。
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
if (!method.isAnnotationPresent(Idempotent.class)) {
return;
}
Idempotent idempotent = method.getAnnotation(Idempotent.class);
String key;
// 若没有配置 幂等 标识编号,则使用 url + 参数列表作为区分;如果提供了key规则,则利用keyResolver根据提供的规则和切点信息生成键
if (!StringUtils.hasLength(idempotent.key())) {
String url = request.getRequestURL().toString();
String argString = Arrays.asList(joinPoint.getArgs()).toString();
key = url + argString;
} else {
// 使用jstl 规则区分
key = resolver(idempotent, joinPoint);
}
//从注解中读取并设置幂等操作的过期时间、描述信息、时间单位以及是否删除键的标志。
long expireTime = idempotent.expireTime();
String info = idempotent.info();
TimeUnit timeUnit = idempotent.timeUnit();
boolean delKey = idempotent.delKey();
String value = LocalDateTime.now().toString().replace("T", " ");
Object valueResult = redisUtil.get(key);
synchronized (this) {
if (null == valueResult) {
redisUtil.set(key, value, expireTime, timeUnit);
} else {
throw new IdempotentException(info);
}
}
Map<String, Object> map = THREAD_CACHE.get();
map.put(KEY, key);
map.put(DEL_KEY, delKey);
}


/**
* 从注解的方法的参数中解析出用于幂等性处理的键值(key)
*
* @param idempotent
* @param point
* @return
*/

private String resolver(Idempotent idempotent, JoinPoint point) {
//获取被拦截方法的所有参数
Object[] arguments = point.getArgs();
//从字节码的局部变量表中解析出参数名称
String[] params = DISCOVERER.getParameterNames(getMethod(point));
//SpEL表达式执行的上下文环境,用于存放变量
StandardEvaluationContext context = new StandardEvaluationContext();
//遍历方法参数名和对应的参数值,将它们一一绑定到StandardEvaluationContext中。
//这样SpEL表达式就可以引用这些参数值
if (params != null && params.length > 0) {
for (int len = 0; len < params.length; len++) {
context.setVariable(params[len], arguments[len]);
}
}
//使用SpelExpressionParser来解析Idempotent注解中的key属性,将其作为SpEL表达式字符串
Expression expression = PARSER.parseExpression(idempotent.key());
//转换结果为String类型返回
return expression.getValue(context, String.class);
}

/**
* 根据切点解析方法信息
*
* @param joinPoint 切点信息
* @return Method 原信息
*/

private Method getMethod(JoinPoint joinPoint) {
//将joinPoint.getSignature()转换为MethodSignature
//Signature是AOP中表示连接点签名的接口,而MethodSignature是它的具体实现,专门用于表示方法的签名。
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
//获取到方法的声明。这将返回代理对象所持有的方法声明。
Method method = signature.getMethod();

//判断获取到的方法是否属于一个接口
//因为在Java中,当通过Spring AOP或其它代理方式调用接口的方法时,实际被执行的对象是一个代理对象,直接获取到的方法可能来自于接口声明而不是实现类。
if (method.getDeclaringClass().isInterface()) {
try {
//通过反射获取目标对象的实际类(joinPoint.getTarget().getClass())中同名且参数类型相同的方法
//这样做是因为代理类可能对方法进行了增强,直接调用实现类的方法可以确保获取到最准确的实现细节
method = joinPoint.getTarget().getClass().getDeclaredMethod(joinPoint.getSignature().getName(),
method.getParameterTypes());
} catch (SecurityException | NoSuchMethodException e) {
throw new RuntimeException(e);
}
}
return method;
}


@After("idempotent()")
public void after() throws Throwable {
Map<String, Object> map = THREAD_CACHE.get();
if (CollectionUtils.isEmpty(map)) {
return;
}

String key = map.get(KEY).toString();
boolean delKey = (boolean) map.get(DEL_KEY);
if (delKey) {
redisUtil.delete(key);
log.info("[idempotent]:has removed key={}", key);
}
//无论是否移除了键,最后都会清空当前线程局部变量THREAD_CACHE中的数据,避免内存泄漏
THREAD_CACHE.remove();
}
}

上面redisUtil是基于RedisTemplate封装的工具类,可以直接替换哈,这里我们定义一个切入点,也就是我们定义的注解,然后在调用接口之前获取到接口的入参以及注解的参数,获取到这些之后,判断是否有唯一标识,没有就用路由,保存到reids当中,然后设置过期时间,最后需要把删除的标识放到线程私有变量THREAD_CACHE中在接口处理完之后判断是否需要删除redis当中保存的key,这里,我们的逻辑就写完了,接下来是使用了,使用这个就很简单,直接在你需要实现防抖和幂等的接口上打上我们的注解


/**
* 测试接口添加幂等校验
*
* @return
*/

@PostMapping("/redis")
@Idempotent(key = "#user.id", expireTime = 10, delKey = true, info = "重复请求,请稍后再试")
public Result<?> getRedis(@RequestBody User user) throws InterruptedException {
return Result.success(true);
}

这里key的定义方式我们使用了SpEL表达式,如果不指定这个表达式的话就会使用路由作为key了


到这里,接口幂等和防抖功能就顺利完成了,以后,别再防抖和幂等傻傻分不清楚了哈哈哈


最后,还是要送上一位名人曾说的一句话:手上没有剑和有剑不用是两回事!


作者:失乐园
来源:juejin.cn/post/7380274613185970195
收起阅读 »

探索副业的路上:赚钱的机会往往在你眼前,请不要视而不见

前言 Hi 你好,我是东东拿铁,一个正在探索个人IP&副业的后端程序员。 对于普通人来说,赚钱的机会往往是那些看不上、瞧不起的信息差。 我们的认知,对于看到的项目,可能天生把他归结于大项目、或者小项目。 但每一个赚钱机会,是从每一个小的机会,叠加了风口...
继续阅读 »

前言


Hi 你好,我是东东拿铁,一个正在探索个人IP&副业的后端程序员。


对于普通人来说,赚钱的机会往往是那些看不上、瞧不起的信息差。


我们的认知,对于看到的项目,可能天生把他归结于大项目、或者小项目。


但每一个赚钱机会,是从每一个小的机会,叠加了风口、资金、团队、产品等杠杆,叠加成了一个大的赚钱机会。


所以说,赚钱的机会往往是那些我们看不上、瞧不起的信息差。


看不起就会错过


比如说 大学期间,我曾经对微商嗤之以鼻。卖假货、朋友圈刷屏,太low了!


几年之后,当年做微商的,做得好的赚到第一桶金,做得不好,也积累了项目经验、私域用户。


工作后,我觉着那些整理面经的,没什么意思,不就是罗列了知识,照着书本上的内容,那可差的太远了,有这时间我看看书不好吗?


但整理面经的,在技术平台持续输出的那些人,不但积累了第一波粉丝,在那个快速发展的时间,很多人靠公众号赚到了第一桶金。


看不上、瞧不起的时候,实际上是封闭了自己。


我现在也在写一些技术文章、面经,恰恰是做了自己曾经看不起的事情了。


其实时至今日,我也会有这样的毛病,互联网从来不缺少赚钱的机会。


身边有通过闲鱼赚了10w+的大佬,也有做AI公众号文章,月入过千的自媒体写手。


即使接触了这些人,我的内心也总会有些怀疑的态度,闲鱼的货源怎么办,时刻需要回复客户信息,消耗经历太大怎么办。AI写文章起号难,输出的内容自己又觉着没价值,没有什么前途怎么办。


怎么做


保持开放


保持头脑开放,让信息流入进来


持有怀疑的心态没问题,凡事有利有弊,再小的项目,也有能赚到大钱的,和赚不到钱的人。


但不能天然的排斥这些信息,很多人对于看到的赚钱信息,内心总会说出一个声音:“赚钱的事情谁会真的分享给你啊,肯定是骗人的”。


你是不也经常会有这样的想法?


其实是的,现在非常流行知识付费,市面上确实存在着通过几个赚钱项目,吸引你来买课学习、割韭菜的课程。


但同样的,一定也有优质的信息,来帮助你打平信息差、快速上手,这时候我们需要提升的是鉴别能力,这个我们有空再聊。


先干起来再说


有了充足的信息、案例,如何去做呢?


找到自己热爱或者擅长的事情。


我这里用了或,而不是且,是我最近刚刚想清楚的一个道理。


每个人都有热爱、擅长的事情,如果有一个赚钱机会,既符合你热爱,也很擅长,那是最好的。但这样的机会可遇不可求。


我到今天都还希望找到一个热爱且擅长的事情,但是我发现好难。


不到半年时间,我写了28篇文章,这是第29篇。我还是蛮喜欢写东西的,既能说说心里话,也能积累写作这个最基础的能力。


但我真的不擅长这件事情,可能在几年内,我都不敢说我自己擅长写作,因为市面上有太多输出能力很强的大佬,有太多写作课程了。


市面上真的缺少我一个内容创作者吗?那你说,我为什么还要去写,还要持续输出呢?


去尝试一下,没什么成本,先干起来再说。


保持专注


俗话说:“师傅领进门,修行在个人。”


保持专注,就是找到一个方向,在这个领域里深耕。没有在一个方向的专注,是很难赚到钱的。


写作方向的大佬,比如粥大,写了上百篇10w+阅读的文章。很多有了自己写作课程的知识型博主,都已经日更公众号几百天、上千天。



每个人的擅长都不是找到的,而是自己努力去做到的。



说在最后


在今天,我也决定列出我今年的目标,完成一百篇原创文章,持续分享我在探索个人IP道路上的思考、经验,欢迎你关注、围观。




作者:东东拿铁
来源:juejin.cn/post/7358639642412122166
收起阅读 »

面试问空窗期,HR到底想听什么?

职场中的空窗期,无论是新人还是老手,都可能会遇到,而且因人而异。因此,离职后,建议空窗期不宜过长,万一真的有空窗期,在面试中该如何回答呢?今天我们来聊一聊。 空窗期产生的原因 职场空窗期产生的原因多种多样,这里总结出主要的 2个因素: 个人因素 比如,由于工作...
继续阅读 »

职场中的空窗期,无论是新人还是老手,都可能会遇到,而且因人而异。因此,离职后,建议空窗期不宜过长,万一真的有空窗期,在面试中该如何回答呢?今天我们来聊一聊。


空窗期产生的原因


职场空窗期产生的原因多种多样,这里总结出主要的 2个因素:


个人因素


比如,由于工作压力大,想给自己一段时间休息和调整或者薪资待遇不满意等多方面因素,导致冲动“裸辞”;


又比如,很多大学生,在校招期间没有匹配到合适的工作,踏上社会后又眼高手低,导致企业和个人都不满意,也经常会出现空窗期的问题;


再比如,因为家庭原因不得不离开职场一段时间;


外在因素


比如,经济不景气、公司倒闭、行业调整、裁员等无法改变的外在因素,然后无法快速地衔接下一份工作。


HR为什么很关注空窗期?


HR关注空档期,主要有以下几个原因:


担忧求职者稳定性:HR担心求职者流动性强,可能不稳定。长期空窗期可能暗示个人适应能力不足或求职者可能频繁离职。 担忧求职者能力匹配度:HR关注求职者是否有能力与岗位匹配,长期空窗期可能暗示了求职者的能力不足,以至于长时间找不到工作。 担忧个人生活因素:HR可能担心求职者个人生活琐事过多,影响工作表现。长期空窗期可能反映个人生活稳定性问题。


如何回答空窗期?


求职者在面对空窗期时,应当诚实面对,避免隐瞒或虚假解释。对于空窗期,可用积极的态度和合理的解释来回答面试官的提问,下面根据空窗期的时间长短给出一些建议:


3个月以内


一般三个月以内的空窗期,问题不是特别大,可以先说明离职原因,比如公司组织架构调整,部门全部被优化,借此机会给自己一段时间休息和调整,现在已经调整好状态,可以更好的投入下一份工作,一般这样回答,HR也不会太纠结。


3~6个月


3~6个月时间还是有点长,这里给出给出 2个比较通用的原因:


在先前的工作中感觉到自己某方面能力的不足,所以这段时间去参加培训、给自己充电,提升一下自己某一方面的技能,为了更好的开展下一份工作;


家人生病了,急需自己的照顾,现在家人康复了,家里的事情也已经安排妥当,另外,在此期间自己也在不断地充电,我可以踏踏实实地上班;


6个月以上


6个月以上,这个时间有点长,因此,最重要的是要说明自己并没有脱离职场,比如做了一些和工作性质相关的兼职工作,空窗期时间长也可以围绕生孩子或者生病去说,因为这种理由似乎让人无法拒绝,毕竟每个家庭总有一些属于自己的小插曲。 另外,如果还可以围绕着自己在和职业相关的领域创业,现在创业失败了,经历过之后,想踏踏实实地回归职场干活。


总结


离开职场容易,回归职场难,因此,如果没有什么特殊原因,不建议离职后的空窗期太长。另外,空窗期长并不代表求职者的能力差,不管是什么原因导致空窗期我们都应该给面试官展示积极的一面,作为面试官,我们也应该从打工人的无奈出发,只要不是太离谱,就不要太计较。




作者:猿java
来源:juejin.cn/post/7380268230797901864
收起阅读 »

Echarts中国地图下钻,支持下钻到县(vue3)

web
引言 Echarts 大家都不陌生吧,时常被用于绘制各种图表,也作为大屏可视化的常驻用户,这里就不多说了,今天主要是讲述一下 Echarts 的地图下钻,支持下钻到县、返回上一级。 准备工作 地图JSON数据 DataV.GeoAtlas地理小工具系列 (al...
继续阅读 »

引言


Echarts 大家都不陌生吧,时常被用于绘制各种图表,也作为大屏可视化的常驻用户,这里就不多说了,今天主要是讲述一下 Echarts 的地图下钻,支持下钻到县、返回上一级。


准备工作


地图JSON数据


DataV.GeoAtlas地理小工具系列 (aliyun.com) 支持在线调用API和下载json资源(我这里是调用的API)



如果地图json API请求报错403,可参考这个解决办法 :地图请求阿里的geojson数据时,返回403Forbidden解决方案



技术栈



  • vue: 3.3.7

  • vue-echarts: 6.6.1 (直接使用 Echarts 也是一样的,这个只是对 Echarts 的组件封装)

  • vite: 4.5.0


地图效果


0mdtt-ameuf.gif


项目预览地址:UnusualAdmin


项目代码地址:UnusualAdmin


实现


template


这里只需要一个 Echarts 节点和一个按钮就行了


<template>
<div :style="`height: ${calcHeight('main')};`" class="wh-full pos-relative">
<v-chart :option="mapOption" :autoresize="true" @click="handleClick" />
<n-button v-show="isShowBack" class="pos-absolute top-10 left-10" @click="goBack">返回n-button>
div>
template>

获取mapJson


// 使用线上API
const getMapJson = async (mapName: string) => {
const url = `https://geo.datav.aliyun.com/areas_v3/bound/${mapName}.json`
const mapJson = await fetch(url).then(res => res.json())
return mapJson
}

// 使用本地资源
const getMapJson = async (mapName: string) => {
const url = `@/assets/mapJson/${mapName}.json`
const mapJson = await import(/* @vite-ignore */ url)
return mapJson
}


第二种方法(使用本地资源)存在问题:这个方法后续发现,vite打包不会把json文件打包到dist,线上会报错,目前没找到可靠的解决办法(如果放到public文件夹下会打包进去),故舍弃。


如果大家有什么解决这个问题的好办法,请在评论区留言,博主会一一去尝试的🙏🙏🙏



更新地图配置options


const setOptions = (mapName: string, mapData: any) => {
return {
// 鼠标悬浮提示
tooltip: {
show: true,
formatter: function (params: any) {
// 根据需要进行数据处理或格式化操作
if (params && params.data) {
const { adcode, name, data } = params.data;
// 返回自定义的tooltip内容
return `adcode: ${adcode}
name: ${name}
data: ${data}`
;
}
},
},
// 左下角的数据颜色条
visualMap: {
show: true,
min: 0,
max: 100,
left: 'left',
top: 'bottom',
text: ['高', '低'], // 文本,默认为数值文本
calculable: true,
seriesIndex: [0],
inRange: {
color: ['#00467F', '#A5CC82'] // 蓝绿
}
},
// geo地图
geo: {
map: mapName,
roam: true,
select: false,
// 图形上的文本标签,可用于说明图形的一些数据信息,比如值,名称等。
selectedMode: 'single',
label: {
show: true
},
emphasis: {
itemStyle: {
areaColor: '#389BB7',
borderColor: '#389BB7',
borderWidth: 0
},
label: {
fontSize: 14,
},
}
},
series: [
// 地图数据
{
type: 'map',
map: mapName,
roam: true,
geoIndex: 0,
select: false,
data: mapData
},
// 散点
{
name: '散点',
type: 'scatter',
coordinateSystem: 'geo',
data: mapData,
itemStyle: {
color: '#05C3F9'
}
},
// 气泡点
{
name: '点',
type: 'scatter',
coordinateSystem: 'geo',
symbol: 'pin', //气泡
symbolSize: function (val: any) {
if (val) {
return val[2] / 4 + 20;
}
},
label: {
show: true,
formatter: function (params: any) {
return params.data.data || 0;
},
color: '#fff',
fontSize: 9,
},
itemStyle: {
color: '#F62157', //标志颜色
},
zlevel: 6,
data: mapData,
},
// 地图标点
{
name: 'Top 5',
type: 'effectScatter',
coordinateSystem: 'geo',
data: mapData.map((item: { data: number }) => {
if (item.data > 60) return item
}),
symbolSize: 15,
showEffectOn: 'render',
rippleEffect: {
brushType: 'stroke'
},
label: {
formatter: '{b}',
position: 'right',
show: true
},
itemStyle: {
color: 'yellow',
shadowBlur: 10,
shadowColor: 'yellow'
},
zlevel: 1
},
]
}
}

渲染地图


const renderMapEcharts = async (mapName: string) => {
const mapJson = await getMapJson(mapName)
registerMap(mapName, mapJson); // 注册地图
// 为地图生成一些随机数据
const mapdata = mapJson.features.map((item: { properties: any }) => {
const data = (Math.random() * 80 + 20).toFixed(0) // 20-80随机数
const tempValue = item.properties.center ? [...item.properties.center, data] : item.properties.center
return {
name: item.properties.name,
value: tempValue, // 中心点经纬度
adcode: item.properties.adcode, // 区域编码
level: item.properties.level, // 层级
data // 模拟数据
}
});
// 更新地图options
mapOption.value = setOptions(mapName, mapdata)
}

实现地图点击下钻


// 点击下砖
const mapList = ref<string[]>([]) // 记录地图
const handleClick = (param: any) => {
// 只有点击地图才触发
if (param.seriesType !== 'map') return
const { adcode, level } = param.data
const mapName = level === 'district' ? adcode : adcode + '_full'
// 防止最后一个层级被重复点击,返回上一级出错
if (mapList.value[mapList.value.length - 1] === mapName) {
return notification.warning({ content: '已经是最下层了', duration: 1000 })
}
// 每次下转都记录下地图的name,在返回的时候使用
mapList.value.push(mapName)
renderMapEcharts(mapName)
}

返回上一级实现


// 点击返回上一级地图
const goBack = () => {
const mapName = mapList.value[mapList.value.length - 2] || '100000_full'
mapList.value.pop()
renderMapEcharts(mapName)
}

全部代码

<template>
<div :style="`height: ${calcHeight('main')};`" class="wh-full pos-relative">
<v-chart :option="mapOption" :autoresize="true" @click="handleClick" />
<n-button v-show="isShowBack" class="pos-absolute top-10 left-10" @click="goBack">返回</n-button>
</div>
</template>

<script setup lang="ts" name="EchartsMap">
import { use, registerMap } from 'echarts/core'
import VChart from 'vue-echarts'
import { CanvasRenderer } from 'echarts/renderers'
import { MapChart, ScatterChart, EffectScatterChart } from 'echarts/charts'
import { TitleComponent, TooltipComponent, LegendComponent, GridComponent, VisualMapComponent } from 'echarts/components'
import { calcHeight } from '@/utils/help';

use([
CanvasRenderer,
TitleComponent,
TooltipComponent,
LegendComponent,
GridComponent,
VisualMapComponent,
MapChart,
ScatterChart,
EffectScatterChart
])

const notification = useNotification()
const mapOption = ref()
const mapList = ref<string[]>([]) // 记录地图
const isShowBack = computed(() => {
return mapList.value.length !== 0
})

const getMapJson = async (mapName: string) => {
const url = `https://geo.datav.aliyun.com/areas_v3/bound/${mapName}.json`
const mapJson = await fetch(url).then(res => res.json())
return mapJson
}

const setOptions = (mapName: string, mapData: any) => {
return {
tooltip: {
show: true,
formatter: function (params: any) {
// 根据需要进行数据处理或格式化操作
if (params && params.data) {
const { adcode, name, data } = params.data;
// 返回自定义的tooltip内容
return `adcode: ${adcode}<br>name: ${name}<br>data: ${data}`;
}
},
},
visualMap: {
show: true,
min: 0,
max: 100,
left: 'left',
top: 'bottom',
text: ['高', '低'], // 文本,默认为数值文本
calculable: true,
seriesIndex: [0],
inRange: {
color: ['#00467F', '#A5CC82'] // 蓝绿
}
},
geo: {
map: mapName,
roam: true,
select: false,
// zoom: 1.6,
// layoutCenter: ['45%', '70%'],
// layoutSize: 750,
// 图形上的文本标签,可用于说明图形的一些数据信息,比如值,名称等。
selectedMode: 'single',
label: {
show: true
},
emphasis: {
itemStyle: {
areaColor: '#389BB7',
borderColor: '#389BB7',
borderWidth: 0
},
label: {
fontSize: 14,
},
}
},
series: [
// 数据
{
type: 'map',
map: mapName,
roam: true,
geoIndex: 0,
select: false,
data: mapData
},
{
name: '散点',
type: 'scatter',
coordinateSystem: 'geo',
data: mapData,
itemStyle: {
color: '#05C3F9'
}
},
{
name: '点',
type: 'scatter',
coordinateSystem: 'geo',
symbol: 'pin', //气泡
symbolSize: function (val: any) {
if (val) {
return val[2] / 4 + 20;
}
},
label: {
show: true,
formatter: function (params: any) {
return params.data.data || 0;
},
color: '#fff',
fontSize: 9,
},
itemStyle: {
color: '#F62157', //标志颜色
},
zlevel: 6,
data: mapData,
},
{
name: 'Top 5',
type: 'effectScatter',
coordinateSystem: 'geo',
data: mapData.map((item: { data: number }) => {
if (item.data > 60) return item
}),
symbolSize: 15,
showEffectOn: 'render',
rippleEffect: {
brushType: 'stroke'
},
label: {
formatter: '{b}',
position: 'right',
show: true
},
itemStyle: {
color: 'yellow',
shadowBlur: 10,
shadowColor: 'yellow'
},
zlevel: 1
},
]
}
}

const renderMapEcharts = async (mapName: string) => {
const mapJson = await getMapJson(mapName)
registerMap(mapName, mapJson);
const mapdata = mapJson.features.map((item: { properties: any }) => {
const data = (Math.random() * 80 + 20).toFixed(0) // 20-80随机数
const tempValue = item.properties.center ? [...item.properties.center, data] : item.properties.center
return {
name: item.properties.name,
value: tempValue, // 中心点经纬度
adcode: item.properties.adcode, // 区域编码
level: item.properties.level, // 层级
data // 模拟数据
}
});
mapOption.value = setOptions(mapName, mapdata)
}

renderMapEcharts('100000_full') // 初始化绘制中国地图

// 点击下砖
const handleClick = (param: any) => {
// 只有点击地图才触发
if (param.seriesType !== 'map') return
const { adcode, level } = param.data
const mapName = level === 'district' ? adcode : adcode + '_full'
// 防止最后一个层级被重复点击,返回上一级出错
if (mapList.value[mapList.value.length - 1] === mapName) {
return notification.warning({ content: '已经是最下层了', duration: 1000 })
}
mapList.value.push(mapName)
renderMapEcharts(mapName)
}

// 点击返回上一级地图
const goBack = () => {
const mapName = mapList.value[mapList.value.length - 2] || '100000_full'
mapList.value.pop()
renderMapEcharts(mapName)
}
</script>



作者:树深遇鹿
来源:juejin.cn/post/7371641968600383540
收起阅读 »

阿里也出手了!Spring CloudAlibaba AI问世了

写在前面 在之前的文章中我们有介绍过SpringAI这个项目。SpringAI 是Spring 官方社区项目,旨在简化 Java AI 应用程序开发, 让 Java 开发者像使用 Spring 开发普通应用一样开发 AI 应用。 而SpringAI 主要面向的...
继续阅读 »

写在前面


在之前的文章中我们有介绍过SpringAI这个项目。SpringAI 是Spring 官方社区项目,旨在简化 Java AI 应用程序开发,


让 Java 开发者像使用 Spring 开发普通应用一样开发 AI 应用。


SpringAI 主要面向的是国外的各种大模型接入,对于国内开发者可能不太友好。


于是乎,Spring Cloud Alibaba AI便问世了,Spring Cloud Alibaba AI以 Spring AI 为基础,并在此基础上提供阿里云通义系列大模型全面适配,


让用户在 5 分钟内开发基于通义大模型的 Java AI 应用。


一、Spring AI 简介


可能有些小伙伴已经忘记了SpringAI 是啥?我们这儿再来简单回顾一下。


Spring AI是一个面向AI工程的应用框架。其目标是将可移植性和模块化设计等设计原则应用于AI领域的Spring生态系统,


并将POJO作为应用程序的构建块推广到AI领域。


转换为人话来说就是:Spring出了一个AI框架,帮助我们快速调用AI,从而实现各种功能场景。


二、Spring Cloud Alibaba AI 简介


Spring Cloud Alibaba AISpring AI 为基础,并在此基础上,基于 Spring AI 0.8.1 版本 API 完成通义系列大模型的接入


实现阿里云通义系列大模型全面适配。


在当前最新版本中,Spring Cloud Alibaba AI 主要完成了几种常见生成式模型的适配,包括对话、文生图、文生语音等,


开发者可以使用 Spring Cloud Alibaba AI 开发基于通义的聊天、图片或语音生成 AI 应用,


框架还提供 OutParserPrompt TemplateStuff 等实用能力。


三、第一个Spring AI应用开发


① 新建maven 项目


注: 在创建项目的时候,jdk版本必须选择17+


新建maven项目


② 添加依赖


<dependency>
   <groupId>com.alibaba.cloud</groupId>
   <artifactId>spring-cloud-alibaba-dependencies</artifactId>
   <version>2023.0.1.0</version>
   <type>pom</type>
   <scope>import</scope>
</dependency>

<dependency>
   <groupId>com.alibaba.cloud</groupId>
   <artifactId>spring-cloud-starter-alibaba-ai</artifactId>
   <version>2023.0.1.0</version>
</dependency>

注: 这里我们需要配置镜像源,否则是没法下载依赖的。会报如下错误



spring-ai: 0.8.1 dependency not found



<repositories>
   <repository>
       <id>spring-milestones</id>
       <name>Spring Milestones</name>
       <url>https://repo.spring.io/milestone</url>
       <snapshots>
           <enabled>false</enabled>
       </snapshots>
   </repository>
</repositories>

③ 在 application.yml 配置文件中添加api-key


spring:
cloud:
  ai:
    tongyi:
      api-key: 你自己申请的api-key

小伙伴如果不知道在哪申请,我把申请连接也放这儿了


dashscope.console.aliyun.com/apiKey


操作步骤:help.aliyun.com/zh/dashscop…


④ 新建TongYiController 类,代码如下


@RestController
@RequestMapping("/ai")
@CrossOrigin
@Slf4j
public class TongYiController {

   @Autowired
   @Qualifier("tongYiSimpleServiceImpl")
   private TongYiService tongYiSimpleService;

   @GetMapping("/example")
   public String completion(@RequestParam(value = "message", defaultValue = "Tell me a joke") String message) {

       return tongYiSimpleService.completion(message);
  }
   
}

⑤ 新建TongYiService 接口,代码如下


public interface TongYiService {
   String completion(String message);

}

⑥ 新建TongYiSimpleServiceImpl 实现类,代码如下


@Service
@Slf4j
public  class TongYiSimpleServiceImpl  implements TongYiService {

   private final ChatClient chatClient;

   @Autowired
   public TongYiSimpleServiceImpl(ChatClient chatClient, StreamingChatClient streamingChatClient) {
       this.chatClient = chatClient;
  }

   @Override
   public String completion(String message) {
       Prompt prompt = new Prompt(new UserMessage(message));

       return chatClient.call(prompt).getResult().getOutput().getContent();
  }


}

到这儿我们一个简单的AI应用已经开发完成了,最终项目结构如下


项目结构


四、运行AI应用


启动服务,我们只需要在浏览器中输入:http://localhost:8080/ai/example 即可与AI交互。


① 不带message参数,则message=Tell me a joke,应用随机返回一个笑话


随机讲一个笑话1


② 我们在浏览器中输入:http://localhost:8080/ai/example?message=对话内容


message带入


五、前端页面对话模式


我们只需要在resources/static 路径下添加一个index.html前端页面,即可拥有根据美观的交互体验。


index.html代码官方github仓库中已给出样例,由于代码比较长,这里就不贴代码了


github.com/alibaba/spr…


添加完静态页面之后,我们浏览器中输入:http://localhost:8080/index.html 就可以得到一个美观的交互界面


美观交互界面


接下来,我们来实际体验一下


UI交互


六、其他模型


上面章节中我们只简单体验了对话模型,阿里还有很多其他模型。由于篇幅原因这里就不一一带大家一起体验了。


应用场景:


应用场景


各个模型概述:


模型概述


七、怎么样快速接入大模型


各种应用场景阿里官方GitHub都给出了接入例子


github.com/alibaba/spr…


官方样例


感兴趣的小伙伴可以自己到上面github 仓库看代码研究


本期内容到这儿就结束了,★,°:.☆( ̄▽ ̄)/$: .°★ 。 希望对您有所帮助


我们下期再见 ヾ(•ω•`)o (●'◡'●)


作者:xiezhr
来源:juejin.cn/post/7380771735681941523
收起阅读 »

threejs渲染高级感可视化风力发电车模型

web
本文使用threejs开发一款风力发电机物联可视化系统,包含着色器效果、动画、补间动画和开发过程中使用模型材质遇到的问题,内含大量gif效果图, 视频讲解及源码见文末 技术栈 three.js 0.165.0 vite 4.3.2 nodej...
继续阅读 »

本文使用threejs开发一款风力发电机物联可视化系统,包含着色器效果、动画、补间动画和开发过程中使用模型材质遇到的问题,内含大量gif效果图,



视频讲解及源码见文末



技术栈



  • three.js 0.165.0

  • vite 4.3.2

  • nodejs v18.19.0


效果图


一镜到底动画


一镜到底 (1).gif


切割动画


切割动画.gif


线稿动画


线稿动画.gif


外壳透明度动画


外壳透明度动画.gif


展开齿轮动画


展开齿轮动画.gif


发光线条动画


发光线条.gif


代码及功能介绍


着色器


文中用到一个着色器,就是给模型增加光感的动态光影


创建顶点着色器 vertexShader:


varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}

创建片元着色器 vertexShader:


varying vec2 vUv;
uniform vec2 u_center; // 添加这一行

void main() {
// 泡泡颜色
vec3 bubbleColor = vec3(0.9, 0.9, 0.9); // 乳白色
// 泡泡中心位置
vec2 center = u_center;
// 计算当前像素到泡泡中心的距离
float distanceToCenter = distance(vUv, center);
// 计算透明度,可以根据实际需要调整
float alpha = smoothstep(0.1, 0.0, distanceToCenter);

gl_FragColor = vec4(bubbleColor, alpha);

创建着色器材质 bubbleMaterial


export const bubbleMaterial = new THREE.ShaderMaterial({
vertexShader: vertexShader,
fragmentShader: fragmentShader,
transparent: true, // 开启透明
depthTest: true, // 开启深度测试
depthWrite: false, // 不写入深度缓冲
uniforms: {
u_center: { value: new THREE.Vector2(0.3, 0.3) } // 添加这一行
},
});


从代码中可以看到 uniform声明了一个变量u_center,目的是为了在render方法中动态修改中心位置,从而实现动态光效的效果,


具体引用 render 方法中


 // 更新中心位置(例如,每一帧都改变)  
let t = performance.now() * 0.001;
bubbleMaterial.uniforms.u_center.value.x = Math.sin(t) * 0.5 + 0.5; // x 位置基于时间变化
bubbleMaterial.uniforms.u_center.value.y = Math.cos(t) * 0.5 + 0.5; // y 位置基于时间变化

官网案例 # Uniform,详细介绍了uniform的使用方法,支持通过变量对着色器材质中的属性进行改变


光影着色器.gif


从模型上可能看不出什么,下面的图是在一个圆球上加的这个效果


光影着色器-球体.gif


着色器中有几个参数可以自定义也可以自己修改, float alpha = smoothstep(0.6, 0.0, distanceToCenter);中的smoothstep 是一个常用的函数,用于在两个值之间进行平滑插值。具体来说,smoothstep(edge0, edge1, x) 函数会计算 x 在 edge0 和 edge1 之间的平滑过渡值。当 x 小于 edge0 时,返回值为 0;当 x 大于 edge1 时,返回值为 1;而当 x 在 edge0 和 edge1 之间时,它返回一个在 0 和 1 之间的平滑过渡值。


切割动画


切割动画使用的是数学库平面THREE.Plane和属性 constant,通过修改constant值即可实现动画,从normal法向量起至constant的距离为可展示内容。



从原点到平面的有符号距离。 默认值为 0.



constant取模型的box3包围盒的min值,至max值做补间动画,以下是代码示意


const wind = windGltf.scene
const boxInfo = wind.userData.box3Info;

const max = boxInfo.worldPosition.z + boxInfo.max.z
const min = boxInfo.worldPosition.z + boxInfo.min.z

let tween = new TWEEN.Tween({ d: min - 0.2 })
.to({ d: max + 0.1 }, 1000 * 2)
.start()
.onUpdate(({ d }) => {
clippingPlane.constant = d
})

详看切割效果图


切割动画.gif


图中添加了切割线的辅助线,可以通过右侧的操作面板显示或隐藏。


模型材质需要注意的问题


由于齿轮在风车的内容部,并且风车模型开启了transparent=true,那么计算透明度深度就会出现问题,首先要设置 depthWrite = true,开启深度缓存区,renderOrder = -1



这个值将使得scene graph(场景图)中默认的的渲染顺序被覆盖, 即使不透明对象和透明对象保持独立顺序。 渲染顺序是由低到高来排序的,默认值为0



threejs的透明材质渲染和不透明材质渲染的时候,会互相影响,而调整renderOrder顺序则可以让透明对象和不透明对象相对独立的渲染。


depthWrite对比


depthwrite对比.jpeg


renderOrder 对比


renderOrder 对比.jpeg


自定义动画贝塞尔曲线


众所周知,贝塞尔曲线通常用于调整关键帧动画,创建平滑的、曲线的运动路径。本文中使用的tweenjs就内置了众多的运动曲线easing(easingFunction?: EasingFunction): this;类型,虽然有很多内置,但是毕竟需求是无限的,接下来介绍的方法就是可以自己设置动画的贝塞尔曲线,来控制动画的执行曲线。


具体使用


// 使用示例
const controlPoints = [ { x: 0 }, { x: 0.5 }, { x: 2 }, { x: 1 }];
const cubicBezier = new CubicBezier(controlPoints[0], controlPoints[1], controlPoints[2], controlPoints[3]);

let tween = new TWEEN.Tween(edgeLineGr0up.scale)
.to(windGltf.scene.scale.clone().set(1, 1, 1), 1000 * 2)
.easing((t) => {
return cubicBezier.get(t).x
})
.start()
.onComplete(() => {
lineOpacityAction(0.3)
res({ tween })
})

在tween的easing的回调中添加一个方法,方法中调用了cubicBezier,下面就介绍一下这个方法


源码


[p0] – 起点  
[p1] – 第一个控制点
[p2] – 第二个控制点
[p3] – 终点

export class CubicBezier {
private p0: { x: number; };
private p1: { x: number; };
private p2: { x: number; };
private p3: { x: number; };

constructor(p0: { x: number; }, p1: { x: number; }, p2: { x: number; }, p3: { x: number; }) {
this.p0 = p0;
this.p1 = p1;
this.p2 = p2;
this.p3 = p3;
}

get(t: number): { x: number; } {
const p0 = this.p0;
const p1 = this.p1;
const p2 = this.p2;
const p3 = this.p3;

const mt = 1 - t;
const mt2 = mt * mt;
const mt3 = mt2 * mt;
const t2 = t * t;
const t3 = t2 * t;

const x = mt3 * p0.x + 3 * mt2 * t * p1.x + 3 * mt * t2 * p2.x + t3 * p3.x;

return { x };
}
}


CubicBezier支持get方法,通过四个关键点位信息,绘制三次贝塞尔曲线,参数t在0到1之间变化,当t从0变化到1时,曲线上的点从p0平滑地过渡到p3


mt = 1 - t;:这是t的补数(1减去t)。
mt2 = mt * mt; 和 mt3 = mt2 * mt;:计算mt的平方和立方。
t2 = t * t; 和 t3 = t2 * t;:计算t的平方和立方。


这是通过取四个点的x坐标的加权和来完成的,其中权重是基于t的幂的。具体来说,p0的权重是(1-t)^3p1的权重是3 * (1-t)^2 * tp2的权重是3 * (1-t) * t^2,而p3的权重是t^3


{ x: 0 },{ x: 0.5 },{ x: 2 },{ x: 1 } 这组数据形成的曲线效果是由start参数到end的两倍参数再到end参数


具体效果如下


贝塞尔曲线.gif


齿轮


齿轮动画


模型中自带动画


齿轮动画数据.jpeg


源码中有一整套的动画播放类方法,HandleAnimation,其中功能包含播放训话动画,切换动画,播放一次动画,绘制骨骼,镜头跟随等功能。


具体使用方法:


   // 齿轮动画
/**
*
* @param model 动画模型
* @param animations 动画合集
*/

motorAnimation = new HandleAnimation(motorGltf.scene, motorGltf.animations)
// 播放动画 take 001 是默认动画名称
motorAnimation.play('Take 001')

在render中调用


motorAnimation && motorAnimation.upDate()

齿轮展开(补间动画)


补间动画在齿轮展开时调用,使用的tweenjs,这里讲一下定位运动后的模型位置,使用# 变换控制器(TransformControls),代码中有封装好的完整的使用方法,在TransformControls.ts中,包含同时存在轨道控制器时与变换控制器对场景操作冲突时的处理。


使用方法:


/**
* @param mesh 受控模型
* @param draggingChangedCallback 操控回调
*/

TransformControls(mesh, ()=>{
console.log(mesh.position)
})

齿轮展开定位.jpeg


齿轮发光


发光效果方法封装在utls/index.ts中的unreal方法,使用的是threejs提供的虚幻发光通道RenderPass,UnrealBloomPass,以及合成器EffectComposer,方法接受参数如下



// params 默认参数
const createParams = {
threshold: 0,
strength: 0.972, // 强度
radius: 0.21,// 半径
exposure: 1.55 // 扩散
};

/**
*
* @param scene 渲染场景
* @param camera 镜头
* @param renderer 渲染器
* @param width 需要发光位置的宽度
* @param height 发光位置的高度
* @param params 发光参数
* @returns
*/


调用方法如下:



const { finalComposer: F,
bloomComposer: B,
renderScene: R, bloomPass: BP } = unreal(scene, camera, renderer, width, height, params)
finalComposer = F
bloomComposer = B
renderScene = R
bloomPass = BP
bloomPass.threshold = 0


除了调用方法还有一些需要调整的地方,比如发光时模型什么材质,又或者不发光时又是什么材质,这里需要单独定义,并在render渲染函数中调用


 if (guiParams.isLight) {
if (bloomComposer) {
scene.traverse(darkenNonBloomed.bind(this));
bloomComposer.render();
}
if (finalComposer) {
scene.traverse(restoreMaterial.bind(this));
finalComposer.render();
}
}

scene.traverse的回调中,检验模型是否为发光体,再进行材质的更换,这里用的标识是 object.userData.isLighttrue时,判定该物体为发光物体。其他物体则不发光


回调方法


function darkenNonBloomed(obj: THREE.Mesh) {
if (bloomLayer) {
if (!obj.userData.isLight && bloomLayer.test(obj.layers) === false) {
materials[obj.uuid] = obj.material;
obj.material = darkMaterial;
}
}

}

function restoreMaterial(obj: THREE.Mesh) {
if (materials[obj.uuid]) {
obj.material = materials[obj.uuid];
// 用于删除没必要的渲染
delete materials[obj.uuid];
}
}


再场景的右上角我们新增了几个参数,用来调整线条的发光效果,下面通过动图看一下,图片有点大,请耐心等待加载


调试发光效果.gif


好啦,本篇文章到此,如看源码有不明白的地方,可私信~


最近正在筹备工具库,以上可视化常用的方法都将涵盖在里面


历史文章


three.js——商场楼宇室内导航系统 内附源码


three.js——可视化高级涡轮效果+警报效果 内附源码


高德地图巡航功能 内附源码


three.js——3d塔防游戏 内附源码


three.js+物理引擎——跨越障碍的汽车 可操作 可演示


百度地图——如何计算地球任意两点之间距离 内附源码


threejs——可视化地球可操作可定位


three.js 专栏


源码及讲解



源码 http://www.aspiringcode.com/content?id=…


体验地址:display.aspiringcode.com:8888/html/171422…


B站讲解地址:【threejs渲染高级感可视化风力发电车模型】 http://www.bilibili.com/video/BV1gT…


作者:孙_华鹏
来源:juejin.cn/post/7379906492038889512
收起阅读 »

前端太卷了,不玩了,写写node.js全栈涨工资,赶紧学起来吧!!!!!

web
如果你感觉到累了,卷不动了,那就来看这篇文章吧,写写全栈,涨涨工资,吹吹牛皮!人生得意须尽欢,不要只想忙搬砖!首先聊下node.js的优缺点和应用场景Node.js的优点和应用场景Node.js作为后端开发的选择具有许多优点,以下是其中一些:高性能: ...
继续阅读 »

如果你感觉到累了,卷不动了,那就来看这篇文章吧,写写全栈,涨涨工资,吹吹牛皮!人生得意须尽欢,不要只想忙搬砖!

首先聊下node.js的优缺点和应用场景

Node.js的优点和应用场景

Node.js作为后端开发的选择具有许多优点,以下是其中一些:

  1. 高性能: Node.js采用了事件驱动、非阻塞I/O模型,使得它能够处理大量并发请求而不会阻塞线程,从而具有出色的性能表现。
  2. 轻量级和高效: Node.js的设计简洁而轻量,启动速度快,内存占用低,适合构建轻量级、高效的应用程序。
  3. JavaScript全栈: 使用Node.js,开发者可以使用同一种语言(JavaScript)进行前后端开发,简化了开发人员的学习成本和代码维护成本。
  4. 丰富的生态系统: Node.js拥有丰富的第三方模块和库,可以轻松集成各种功能和服务,提高开发效率。
  5. 可扩展性: Node.js具有良好的可扩展性,可以通过添加更多的服务器实例来横向扩展应用程序,满足不断增长的用户需求。
  6. 实时应用: 由于Node.js对于事件驱动和非阻塞I/O的支持,它非常适合构建实时应用,如即时通讯、在线游戏、实时分析等。
  7. 微服务架构: Node.js可以作为微服务架构中的一个服务组件,通过轻量级的设计和快速的响应能力,使得微服务之间的通信更加高效。
  8. 数据流处理: Node.js适合处理大量的数据流,例如文件操作、网络流量分析等,可以通过流式处理来有效地管理和处理数据。

应用场景包括但不限于:

  1. Web应用程序: 开发基于Node.js的Web应用程序,如社交网络、电子商务平台、博客、内容管理系统等。
  2. API服务: 使用Node.js构建RESTful API服务,为移动应用、前端应用提供数据接口。
  3. 实时应用: 构建实时应用程序,如聊天应用、在线游戏、实时地图等,利用Node.js的事件驱动和非阻塞I/O模型实现高效的实时通讯。
  4. 数据流处理: 使用Node.js处理大量的数据流,例如日志处理、实时监控、数据分析等。
  5. 微服务架构: 将Node.js作为微服务架构中的一个服务组件,通过轻量级的设计和快速的响应能力,实现服务之间的高效通信。

总的来说,Node.js作为一种高性能、轻量级的后端开发工具,适用于各种类型的应用场景,尤其在需要处理大量并发请求和实时通讯的应用中表现突出。

node.js的局限性

尽管Node.js在许多方面都表现出色,但它也有一些局限性和适用场景的限制。以下是一些Node.js的局限性:

  1. 单线程阻塞: 虽然Node.js采用了非阻塞I/O的模型,但在处理CPU密集型任务时,单线程的特性可能导致性能瓶颈。由于Node.js是单线程的,处理阻塞操作(如大量计算或长时间的同步操作)会影响整个应用程序的响应性。
  2. 回调地狱(Callback Hell): 在复杂的异步操作中,嵌套的回调函数可能导致代码难以理解和维护,这被称为“回调地狱”问题。虽然可以使用Promise、async/await等来缓解这个问题,但在某些情况下仍可能存在。
  3. 相对较小的标准库: Node.js的标准库相对较小,相比于其他后端语言,需要依赖第三方模块来实现一些常见的功能。这可能需要花费额外的时间来选择、学习和整合这些模块。
  4. 不适合密集型计算: 由于Node.js是单线程的,不适合用于处理大量的计算密集型任务。如果应用程序主要依赖于大量的数学计算或复杂的数据处理,其他多线程的语言可能更合适。
  5. Callback错误处理: 在回调模式下,错误处理可能变得繁琐,需要在每个回调中检查错误对象。这使得开发者需要更加小心地处理错误,以确保它们不会被忽略。
  6. 相对较新的技术栈: 相较于一些传统的后端技术栈,Node.js是相对较新的技术,一些企业可能仍然更倾向于使用更成熟的技术。
  7. 不适合长连接: 对于长连接的应用场景,如传统的即时通讯(IM)系统,Node.js的单线程模型可能不是最佳选择,因为它会导致长时间占用一个线程。

尽管有这些局限性,但Node.js在许多应用场景下仍然是一个强大且高效的工具。选择使用Node.js还是其他后端技术应该根据具体项目的需求、团队的技术栈和开发者的经验来做出。

node.js常用的几种主流框架

Node.js是一个非常灵活的JavaScript运行时环境,它可以用于构建各种类型的应用程序,从简单的命令行工具到大型的网络应用程序。以下是一些常用的Node.js框架:

  1. Express.js:Express.js是Node.js最流行的Web应用程序框架之一,它提供了一组强大的功能,使得构建Web应用变得更加简单和快速。Express.js具有路由、中间件、模板引擎等功能,可以满足大多数Web应用的需求。
  2. Koa.js:Koa.js是由Express.js原班人马打造的下一代Node.js Web框架,它使用了ES6的新特性,如async/await,使得编写异步代码更加简洁。Koa.js更加轻量级和灵活,它提供了更强大的中间件功能,可以更方便地实现定制化的功能。
  3. Nest.js:Nest.js是一个用于构建高效、可扩展的服务器端应用程序的渐进式Node.js框架。它基于Express.js,但引入了许多现代化的概念,如依赖注入、模块化、类型检查等,使得构建复杂应用变得更加简单。
  4. Hapi.js:Hapi.js是一个专注于提供配置简单、可测试性强的Web服务器框架。它提供了一系列的插件,可以轻松地扩展其功能,同时具有强大的路由、验证、缓存等功能,适用于构建大型和高可靠性的Web应用程序。
  5. Meteor.js:Meteor.js是一个全栈JavaScript框架,它可以同时构建客户端和服务器端的应用程序。Meteor.js提供了一整套的工具和库,包括数据库访问、实时数据同步、用户认证等功能,使得构建实时Web应用变得更加简单和快速。
  6. Sails.js:Sails.js是一个基于Express.js的MVC框架,它提供了类似于Ruby on Rails的开发体验,使得构建数据驱动的Web应用变得更加简单。Sails.js具有自动生成API、蓝图路由、数据关联等功能,适用于构建RESTful API和实时Web应用。

Express框架:实践与技术探索

1. Express框架简介:

Express是一个轻量级且灵活的Node.js Web应用程序框架,它提供了一组简洁而强大的工具,帮助开发者快速构建Web应用。Express的核心理念是中间件,通过中间件可以处理HTTP请求、响应以及应用程序的逻辑。


2. 基础搭建与路由:

在开始实践之前,首先需要搭建Express应用程序的基础结构。通过使用express-generator工具或手动创建package.jsonapp.js文件,可以快速启动一个Express项目。接下来,我们将学习如何定义路由以及如何处理HTTP请求和响应。

const express = require('express');
const app = express();

app.get('/', (req, res) => {
res.send('Hello World!');
});

app.listen(3000, () => {
console.log('Express app listening on port 3000');
});

3. 中间件:

Express中间件是一个函数,它可以访问请求对象(req)、响应对象(res)以及应用程序的下一个中间件函数(通常命名为next)。中间件函数可以用来执行任何代码,修改请求和响应对象,以及终止请求-响应周期。

app.use((req, res, next) => {
console.log('Time:', Date.now());
next();
});

4. 模板引擎与视图:

Express框架允许使用各种模板引擎来生成动态HTML内容。常用的模板引擎包括EJS、Pug和Handlebars。通过配置模板引擎,可以将动态数据嵌入到静态模板中,以生成最终的HTML页面。

app.set('view engine', 'ejs');

5. 数据库集成与ORM:

在实际应用中,数据库是不可或缺的一部分。Express框架与各种数据库集成良好,可以通过ORM(对象关系映射)工具来简化数据库操作。常用的ORM工具包括Sequelize、Mongoose等,它们可以帮助开发者更轻松地进行数据模型定义、查询和操作。


6. RESTful API设计与实现:

Express框架非常适合构建RESTful API。通过定义不同的HTTP动词和路由,可以实现资源的创建、读取、更新和删除操作。此外,Express还提供了一系列中间件来处理请求体、响应格式等,使得构建API变得更加简单。

app.get('/api/users', (req, res) => {
// 获取所有用户信息
});

app.post('/api/users', (req, res) => {
// 创建新用户
});

7. 实践案例:

为了更好地理解Express框架的实践,我们将以一个简单的博客应用为例。在这个应用中,我们可以拓展一下用户的注册、登录、文章的创建和展示等功能,并且结合数据库和RESTful API设计。在这个示例中,我们将使用MongoDB作为数据库,并使用Mongoose作为MongoDB的对象建模工具。首先,确保您已经安装了Node.js``和MongoDB,并创建了一个名为blogApp的文件夹来存放我们的项目。

  1. 首先,在项目文件夹中初始化npm,并安装Express、Mongoose和body-parser依赖:
npm init -y
npm install express mongoose body-parser
  1. 在项目文件夹中创建app.js文件,并编写以下代码:
// 导入所需的模块
const express = require('express');
const bodyParser = require('body-parser');
const mongoose = require('mongoose');

// 连接MongoDB数据库
mongoose.connect('mongodb://localhost:27017/blog', { useNewUrlParser: true, useUnifiedTopology: true });
const db = mongoose.connection;

// 检测数据库连接状态
db.on('error', console.error.bind(console, 'MongoDB connection error:'));
db.once('open', function() {
console.log('Connected to MongoDB');
});

// 创建Express应用
const app = express();

// 使用body-parser中间件解析请求体
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));

// 定义用户模型
const User = mongoose.model('User', new mongoose.Schema({
username: String,
password: String
}));

// 注册用户
app.post('/api/register', async (req, res) => {
try {
const { username, password } = req.body;
const user = await User.create({ username, password });
res.json({ success: true, message: 'User registered successfully', user });
} catch (error) {
res.status(500).json({ success: false, message: error.message });
}
});

// 用户登录
app.post('/api/login', async (req, res) => {
try {
const { username, password } = req.body;
const user = await User.findOne({ username, password });
if (user) {
res.json({ success: true, message: 'User logged in successfully', user });
} else {
res.status(401).json({ success: false, message: 'Invalid username or password' });
}
} catch (error) {
res.status(500).json({ success: false, message: error.message });
}
});

// 启动Express服务器
const port = 3000;
app.listen(port, () => {
console.log(`Server is running on port ${port}`);
});

以上代码实现了用户注册和登录的功能,使用了MongoDB作为数据库存储用户信息,并提供了RESTful风格的API接口。

您可以通过以下命令启动服务器:

node app.js
  1. 接下来,我们添加文章模型和相关的路由来实现文章的创建和展示功能。在app.js文件中添加以下代码:
// 定义文章模型
const Article = mongoose.model('Article', new mongoose.Schema({
title: String,
content: String,
author: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
}
}));

// 创建文章
app.post('/api/articles', async (req, res) => {
try {
const { title, content, author } = req.body;
const article = await Article.create({ title, content, author });
res.json({ success: true, message: 'Article created successfully', article });
} catch (error) {
res.status(500).json({ success: false, message: error.message });
}
});

// 获取所有文章
app.get('/api/articles', async (req, res) => {
try {
const articles = await Article.find().populate('author', 'username');
res.json({ success: true, articles });
} catch (error) {
res.status(500).json({ success: false, message: error.message });
}
});

以上代码实现了创建文章和获取所有文章的功能,每篇文章都与特定的作者相关联。

现在,您可以使用POST请求来创建新的用户和文章,使用GET请求来获取所有文章。例如:

  • 注册新用户:发送POST请求到/api/register,传递usernamepassword字段。
  • 用户登录:发送POST请求到/api/login,传递usernamepassword字段。
  • 创建新文章:发送POST请求到/api/articles,传递titlecontentauthor字段(注意,author字段应该是已注册用户的ID)。
  • 获取所有文章:发送GET请求到/api/articles

这个示例演示了如何使用Express框架结合MongoDB实现一个简单的博客应用,并提供了RESTful API接口。可以根据需求扩展和定制这个应用,例如添加用户身份验证、文章编辑和删除功能等。

看完后是不是觉得后端(CRUD)很简单,没错!就是这么简单!喜欢的小伙伴给个点赞加收藏,码字不易!


作者:为了WLB努力
来源:juejin.cn/post/7343138637971734569
收起阅读 »

反射为什么慢?

1. 背景 今天刷到一篇文章,标题是反射为什么慢,一下子懵逼了,确实没想过这个问题;盲猜了一下是由于反射实际上是做了一个代理的动作,导致执行的效率是小于直接实体类去调用方法的。 2. 文章给出的解释 文章中给出的理由是因为以下4点: 反射涉及动态解析的内容,...
继续阅读 »

1. 背景


今天刷到一篇文章,标题是反射为什么慢,一下子懵逼了,确实没想过这个问题;盲猜了一下是由于反射实际上是做了一个代理的动作,导致执行的效率是小于直接实体类去调用方法的。


2. 文章给出的解释


文章中给出的理由是因为以下4点:



  1. 反射涉及动态解析的内容,不能执行某些虚拟机优化,例如JIT优化技术

  2. 在反射时,参数需要包装成object[]类型,但是方法真正执行的时候,又使用拆包成真正的类型,这些动作不仅消耗时间,而且过程中会产生很多的对象,这就会导致gc,gc也会导致延时

  3. 反射的方法调用需要从数组中遍历,这个遍历的过程也比较消耗时间

  4. 不仅需要对方法的可见性进行检查,参数也需要做额外的检查


3. 结合实际理解


3.1 第一点分析


首先我们需要知道,java中的反射是一种机制,它可以在代码运行过程中,获取类的内部信息(变量、构造方法、成员方法);操作对象的属性、方法。
然后关于反射的原理,首先我们需要知道一个java项目在启动之后,会将class文件加载到堆中,生成一个class对象,这个class对象中有一个类的所有信息,通过这个class对象获取类相关信息的操作我们称为反射。


其次是JIT优化技术,首先我们需要知道在java虚拟机中有两个角色,解释器和编译器;这两者各有优劣,首先是解释器可以在项目启动的时候直接直接发挥作用,省去编译的时候,立即执行,但是在执行效率上有所欠缺;在项目启动之后,随着时间推移,编译器逐渐将机器码编译成本地代码执行,减少解释器的中间损耗,增加了执行效率。


我们可以知道JIT优化通常依赖于在编译时能够知道的静态信息,而反射的动态性可能会破坏这些假设,使得JIT编译器难以进行有效的优化。


3.2 第二点


关于第二点,我们直接写一段反射调用对象方法的demo:


@Test
public void methodTest() {
Class clazz = MyClass.class;

try {
//获取指定方法
//这个注释的会报错 java.lang.NoSuchMethodException
//Method back = clazz.getMethod("back");
Method back = clazz.getMethod("back", String.class);
Method say = clazz.getDeclaredMethod("say", String.class);
//私有方法需要设置
say.setAccessible(true);
MyClass myClass = new MyClass("abc", 99);
//反射调用方法
System.out.println(back.invoke(myClass, "back"));

say.invoke(myClass, "hello world");
} catch (Exception e) {
e.printStackTrace();
}
}

在上面这段代码中,我们调用了一个invoke 方法,并且传了class对象和参数,进入到invoke方法中,我们可以看到invoke方法的入参都是Object类型的,args更是一个Object 数组,这就第二点,关于反射调用过程中的拆装箱。


@CallerSensitive
public Object invoke(Object obj, Object... args)
throws IllegalAccessException, IllegalArgumentException,
InvocationTargetException
{
if (!override) {
if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) {
Class<?> caller = Reflection.getCallerClass();
checkAccess(caller, clazz, obj, modifiers);
}
}
MethodAccessor ma = methodAccessor; // read volatile
if (ma == null) {
ma = acquireMethodAccessor();
}
return ma.invoke(obj, args);
}

3.3 第三点


关于调用方法需要遍历这点,还是上面那个demo,我们在获取Method 对象的时候是通过调用getMethod、getDeclaredMethod方法,点击进入这个方法的源码,我们可以看到如下代码:


private static Method searchMethods(Method[] methods,
String name,
Class<?>[] parameterTypes)

{
Method res = null;
String internedName = name.intern();
for (int i = 0; i < methods.length; i++) {
Method m = methods[i];
if (m.getName() == internedName
&& arrayContentsEq(parameterTypes, m.getParameterTypes())
&& (res == null
|| res.getReturnType().isAssignableFrom(m.getReturnType())))
res = m;
}

return (res == null ? res : getReflectionFactory().copyMethod(res));
}

我们可以看到,底层实际上也是将class对象的所有method遍历了一遍,最终才拿到我们需要的方法的,这也就是第二点,执行具体方法的时候需要遍历class对象的方法。


3.4 第四点


第4点说需要对方法和参数进行检查,也就是我们在执行具体的某一个方法的时候,我们实际上是需要校验这个方法是否可见的,如果不可见,我们还需要将这个方法设置为可见,否则如果我们直接调用这个方法的话,会报错。


同时还有一个点,在我们调用invoke方法的时候,反射类会对方法和参数进行一个校验,让我们来看一下源码:


@CallerSensitive
public Object invoke(Object obj, Object... args)
throws IllegalAccessException, IllegalArgumentException,
InvocationTargetException
{
if (!override) {
if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) {
Class<?> caller = Reflection.getCallerClass();
checkAccess(caller, clazz, obj, modifiers);
}
}
MethodAccessor ma = methodAccessor; // read volatile
if (ma == null) {
ma = acquireMethodAccessor();
}
return ma.invoke(obj, args);
}

我们可以看到还有quickCheckMemberAccess、checkAccess 等逻辑


4. 总结


平时在反射这块用的比较少,也没针对性的去学习一下。在工作之余,还是得保持一个学习的习惯,这样子才不会出现今天这种被一个问题难倒的情况,而且才能产出更多、更优秀的方案。


作者:喜欢小钱钱
来源:juejin.cn/post/7330115846140051496
收起阅读 »

听我一句劝,业务代码中,别用多线程。

你好呀,我是歪歪。 前几天我在网上冲浪,看到一个哥们在吐槽,说他工作三年多了,没使用过多线程。 虽然八股文背的滚瓜烂熟,但是没有在实际开发过程中写的都是业务代码,没有使用过线程池,心里还是慌得一比。 我只是微微一笑,这不是很正常吗? 业务代码中一般也使不上多线...
继续阅读 »

你好呀,我是歪歪。


前几天我在网上冲浪,看到一个哥们在吐槽,说他工作三年多了,没使用过多线程。


虽然八股文背的滚瓜烂熟,但是没有在实际开发过程中写的都是业务代码,没有使用过线程池,心里还是慌得一比。


我只是微微一笑,这不是很正常吗?


业务代码中一般也使不上多线程,或者说,业务代码中不知不觉你以及在使用线程池了,你再 duang 的一下搞一个出来,反而容易出事。


所以提到线程池的时候,我个人的观点是必须把它吃得透透的,但是在业务代码中少用或者不用多线程。


关于这个观点,我给你盘一下。


Demo


首先我们还是花五分钟搭个 Demo 出来。


我手边刚好有一个之前搭的一个关于 Dubbo 的 Demo,消费者、生产者都有,我就直接拿来用了:



这个 Demo 我也是跟着网上的 quick start 搞的:



cn.dubbo.apache.org/zh-cn/overv…




可以说写的非常详细了,你就跟着官网的步骤一步步的搞就行了。


我这个 Demo 稍微不一样的是我在消费者模块里面搞了一个 Http 接口:



在接口里面发起了 RPC 调用,模拟从前端页面发起请求的场景,更加符合我们的开发习惯。


而官方的示例中,是基于了 SpringBoot 的 CommandLineRunner 去发起调用:



只是发起调用的方式不一样而已,其他没啥大区别。


需要说明的是,我只是手边刚好有一个 Dubbo 的 Demo,随手就拿来用了,但是本文想要表达的观点,和你使不使用 Dubbo 作为 RPC 框架,没有什么关系,道理是通用的。


上面这个 Demo 启动起来之后,通过 Http 接口发起一次调用,看到控制台服务提供方和服务消费方都有对应的日志输出,准备工作就算是齐活儿了:



上菜


在上面的 Demo 中,这是消费者的代码:



这是提供者的代码:



整个调用链路非常的清晰:



来,请你告诉我这里面有线程池吗?


没有!


是的,在日常的开发中,我就是写个接口给别人调用嘛,在我的接口里面并没有线程池相关的代码,只有 CRUD 相关的业务代码。


同时,在日常的开发中,我也经常调用别人提供给我的接口,也是一把梭,撸到底,根本就不会用到线程池。


所以,站在我,一个开发人员的角度,这个里面没有线程池。


合理,非常合理。


但是,当我们换个角度,再看看,它也是可以有的。


比如这样:



反应过来没有?


我们发起一个 Http 调用,是由一个 web 容器来处理这个请求的,你甭管它是 Tomcat,还是 Jetty、Netty、Undertow 这些玩意,反正是个 web 容器在处理。


那你说,这个里面有线程池吗?


在方法入口处打个断点,这个 http-nio-8081-exec-1 不就是 Tomcat 容器线程池里面的一个线程吗:



通过 dump 堆栈信息,过滤关键字可以看到这样的线程,在服务启动起来,啥也没干的情况下,一共有 10 个:



朋友,这不就是线程池吗?


虽然不是你写的,但是你确实用了。


我写出来的这个 test 接口,就是会由 web 容器中的一个线程来进行调用。所以,站在 web 容器的角度,这里是有一个线程池的:



同理,在 RPC 框架中,不管是消费方,还是服务提供方,也都存在着线程池。


比如 Dubbo 的线程池,你可以看一下官方的文档:



cn.dubbo.apache.org/zh-cn/overv…




而对于大多数的框架来说,它绝不可能只有一个线程池,为了做资源隔离,它会启用好几个线程池,达到线程池隔离,互不干扰的效果。


比如参与 Dubbo 一次调用的其实不仅一个线程池,至少还有 IO 线程池和业务线程池,它们各司其职:



我们主要关注这个业务线程池。


反正站在 Dubbo 框架的角度,又可以补充一下这个图片了:



那么问题来了,在当前的这个情况下?


当有人反馈:哎呀,这个服务吞吐量怎么上不去啊?


你怎么办?


你会 duang 的一下在业务逻辑里面加一个线程池吗?



大哥,前面有个 web 容器的线程池,后面有个框架的线程池,两头不调整,你在中间加个线程池,加它有啥用啊?


web 容器,拿 Tomcat 来说,人家给你提供了线程池参数调整的相关配置,这么一大坨配置,你得用起来啊:



tomcat.apache.org/tomcat-9.0-…




再比如 Dubbo 框架,都给你明说了,这些参数属于性能调优的范畴,感觉不对劲了,你先动手调调啊:



你把这些参数调优弄好了,绝对比你直接怼个线程池在业务代码中,效果好的多。


甚至,你在业务代码中加入一个线程池之后,反而会被“反噬”。


比如,你 duang 的一下怼个线程池在这里,我们先只看 web 容器和业务代码对应的部分:



由于你的业务代码中有线程池的存在,所以当接受到一个 web 请求之后,立马就把请求转发到了业务线程池中,由线程池中的线程来处理本次请求,从而释放了 web 请求对应的线程,该线程又可以里面去处理其他请求。


这样来看,你的吞吐量确实上去了。


在前端来看,非常的 nice,请求立马得到了响应。


但是,你考虑过下游吗?


你的吞吐量上涨了,下游同一时间处理的请求就变多了。如果下游跟不上处理,顶不住了,直接就是崩给你看怎么办?



而且下游不只是你一个调用方,由于你调用的太猛,导致其他调用方的请求响应不过来,是会引起连锁反应的。


所以,这种场景下,为了异步怼个线程池放着,我觉得还不如用消息队列来实现异步化,顶天了也就是消息堆积嘛,总比服务崩了好,这样更加稳妥。


或者至少和下游勾兑一下,问问我们这边吞吐量上升,你们扛得住不。


有的小伙伴看到这里可能就会产生一个疑问了:歪师傅,你这个讲得怎么和我背的八股文不一样啊?


巧了,你背过的八股文我也背过,现在我们来温习一下我们背过的八股文。


什么时候使用线程池呢?


比如一个请求要经过若干个服务获取数据,且这些数据没有先后依赖,最终需要把这些数据组合起来,一并返回,这样经典的场景:



用户点商品详情,你要等半天才展示给用户,那用户肯定骂骂咧咧的久走了。


这个时候,八股文上是怎么说的:用线程池来把串行的动作改成并行。



这个场景也是增加了服务 A 的吞吐量,但是用线程池就是非常正确的,没有任何毛病。


但是你想想,我们最开始的这个案例,是这个场景吗?



我们最开始的案例是想要在业务逻辑中增加一个线程池,对着一个下游服务就是一顿猛攻,不是所谓的串行改并行,而是用更多的线程,带来更多的串行。


这已经不是一个概念了。


还有一种场景下,使用线程池也是合理的。


比如你有一个定时任务,要从数据库中捞出状态为初始化的数据,然后去调用另外一个服务的接口查询数据的最终状态。



如果你的业务代码是这样的:


//获取订单状态为初始化的数据(0:初始化 1:处理中 2:成功 3:失败)
//select * from order where order_status=0;
ArrayList initOrderInfoList = queryInitOrderInfoList();
//循环处理这批数据
for(OrderInfo orderInfo : initOrderInfoList){
    //捕获异常以免一条数据错误导致循环结束
    try{
        //发起rpc调用
        String orderStatus = queryOrderStatus(orderInfo.getOrderId);
        //更新订单状态
        updateOrderInfo(orderInfo.getOrderId,orderStatus);  
    } catch (Exception e){
        //打印异常
    }
}

虽然你框架中使用了线程池,但是你就是在一个 for 循环中不停的去调用下游服务查询数据状态,是一条数据一条数据的进行处理,所以其实同一时间,只是使用了框架的线程池中的一个线程。


为了更加快速的处理完这批数据,这个时候,你就可以怼一个线程池放在 for 循环里面了:


//循环处理这批数据
for(OrderInfo orderInfo : initOrderInfoList){
    //使用线程池
    executor.execute(() -> {
        //捕获异常以免一条数据错误导致循环结束
        try {
            //发起rpc调用
            String orderStatus = queryOrderStatus(orderInfo.getOrderId);
            //更新订单状态
            updateOrderInfo(orderInfo.getOrderId, orderStatus);
        } catch (Exception e) {
            //打印异常
        }
    });
}


需要注意的是,这个线程池的参数怎么去合理的设置,是需要考虑的事情。


同时这个线程池的定位,就类似于 web 容器线程池的定位。


或者这样对比起来看更加清晰一点:



定时任务触发的时候,在发起远程接口调用之前,没有线程池,所以我们可以启用一个线程池来加快数据的处理。


而 Http 调用或者 RPC 调用,框架中本来就已经有一个线程池了,而且也给你提供了对应的性能调优参数配置,那么首先考虑的应该是把这个线程池充分利用起来。


如果仅仅是因为异步化之后可以提升服务响应速度,没有达到串行改并行的效果,那么我更加建议使用消息队列。


好了,本文的技术部分就到这里啦。


下面这个环节叫做[荒腔走板],技术文章后面我偶尔会记录、分享点生活相关的事情,和技术毫无关系。我知道看起来很突兀,但是我喜欢,因为这是一个普通博主的生活气息。


荒腔走板



不知道你看完文章之后,有没有产生一个小疑问:最开始部分的 Demo 似乎用处并不大?


是的,我最开始构思的行文结构是是基于 Demo 在源码中找到关于线程池的部分,从而引出其实有一些我们“看不见的线程池”的存在的。


原本周六我是有一整天的时间来写这篇文章,甚至周五晚上还特意把 Demo 搞定,自己调试了一番,该打的断点全部打上,并写完 Demo 那部分之后,我才去睡觉的,想得是第二天早上起来直接就能用。


按照惯例周六睡个懒觉的,早上 11 点才起床,自己慢条斯理的做了一顿午饭,吃完饭已经是下午 1 点多了。


本来想着在沙发上躺一会,结果一躺就是一整个下午。期间也想过起来写一会文章,坐在电脑前又飞快的躺回到沙发上,就是觉得这个事情索然无味,当下的那一刻就想躺着,然后无意识的刷手机,原本是拿来写文章中关于源码的部分的时间就这样浪费了。


像极了高中时的我,周末带大量作业回家,准备来个悬梁刺股,弯道超车,结果变成了一睡一天,捏紧刹车。


高中的时候,时间浪费了是真的可惜。


现在,不一样了。


荒腔走板这张图片,就是我躺在沙发上的时候,别人问我在干什么时随手拍的一张。


我并不为躺了一下午没有干正事而感到惭愧,浪费了的时间,才是属于自己的时间。


很久以前我看到别人在做一些浪费时间的事情的时候,我心里可能会嘀咕几句,劝人惜时。


这两年我不会了,允许自己做自己,允许别人做别人。


作者:why技术
来源:juejin.cn/post/7297980721590272040
收起阅读 »

8年前端,那就聊聊被裁的感悟吧!!

web
前端开发,8年工作经验,一共呆了2家公司,一个是做积分兑换的广告公司。这是一个让我成长并快乐的公司,并不是因为公司多好,而是遇到了一群快乐的朋友,直到现在还依旧联系着。 另一家是做电子签名的独角兽,我人生的至暗时刻就是在这里这里并不是说公司特别不好,而是自己的...
继续阅读 »

前端开发,8年工作经验,一共呆了2家公司,一个是做积分兑换的广告公司。这是一个让我成长并快乐的公司,并不是因为公司多好,而是遇到了一群快乐的朋友,直到现在还依旧联系着。
另一家是做电子签名的独角兽,我人生的至暗时刻就是在这里这里并不是说公司特别不好,而是自己的际遇


我的经历


第一家公司


第一家公司说来也巧,本来是准备入职一家外包的,在杭州和同学吃个饭,接到了面试通知,一看地址就在楼上,上去一共就3轮面试,不到2个小时直接给了offer。有些东西真的就是命中注定


第二家公司


第二家公司我入职以后挖了上家公司诸多墙角,我一共挖了6个前端,2个后端。拯救朋友们于水深火热之中。





我本以为我能开启美好的新生活,结果第二年就传来我父亲重病的噩耗 肺癌晚期,我学习了大量的肺癌知识什么小细胞,非小细胞,基因检测呀等等。。。





可是最后还是没有挽留住他的生命,我记得我俩在最后一次去武汉的时候,睡在一起,他给我说了很多。


他说:治不好就算了,只是没能看到自己的孙子有些可惜罢了。

他说:我这一辈碌碌无为,没给你带来多么优越的条件,结婚、买房、工作都没给到任何帮助,唯一让我感到欣慰的是你那么努力,比我强多了,家里邻居很多都眼馋你呢。
他说:你小孩的名字想好了吗?你媳妇真是个孝顺的孩子,性格也好,心地善良,你要好好对待她。

他说了很多。。。我都快忘了他说了啥了,我不想忘来着,可是可是,想起来就又好难过。


这只是我人生历程的一部分,我把这些讲出来,是为了让大家明白,你现在所经历的困苦其实没有那么严重,人在逆境之中会放大自己的困难,以博得同情。所以现在很多人给我倒苦水的时候,我总有点不屑一顾的感觉,并不是我有多强,我只是觉得都能过去。


在灰暗的时候,工作总是心不在焉,情绪莫名冲动,我和领导吵过架,和ui妹妹撕破脸,导致人家天天投诉我。我leader说我态度极其嚣张,我说你再多说一句,我干死你所以不裁我裁谁


我的人生感悟


我时常以我爸的角度换位思考,我在得知这个消息后我该咋办?是积极面对,还是放弃治疗?可是所有的都是在假设的前提之下,一切不可为真。只有在其中的才最能明白其中的感受。
那一年我看着他积极想活着的毅力,也看到了他身体日渐消瘦的无奈,无奈之余还要应付各种亲戚的嘘寒问暖


我现在很能明白《天道》中那段,丁元英说的如果为了孝顺的名声,让父亲痛苦没有尊严地活着,还不如让父亲走了。 的意思了。在他昏迷不醒的时候,大小便失禁的时候,真不如有尊严的走了。


我其实已经预感到自己要被裁,我原本是挺担心的,可是后来想想父亲的话,我总结成一句话圆滑对事,诚以待人。 这句话看上去前后矛盾,无外乎俩个观点。


圆滑对事的意思是:就是要学会嘴甜,事嘛能少干就少干,能干几分是几分,累的是别人,爽的是自己,在规则中寻求最大的自我利益化。


诚以待人的意思是:圆滑归圆滑,不能对谁都圆滑,你得有把事情办的很好的能力,你需要给真正需要的人创造价值,而不是为了给压榨者提供以自我健康为代价的价值。



用现在最流行的词来说就是「佛系」。


什么叫活明白了,通常被理解为不争不抢,得之淡然、失之泰然、顺其自然的一种心理状态。


活明白的人一般知道自己要什么样的生活,他们不世故、不圆滑,坦荡的、磊落的做自己应该做的事儿。他们与社会上潜规则里的不良之风格格不入,却不相互抵触,甚至受到局中人的青睐与欣赏。


活明白的人看着更为洒脱,得不张扬,失不气馁,心态随和、随遇而安。


不过,还有一种活明白的人,不被多数人所接受。他们玩世不恭、好吃懒做,把所有一切交给命运去背锅。这种人极度自我,没有什么可以超越他自己的利益,无法想象这种活法,简直就是在浪费六道轮回的名额。


总之,有的人活明白了,是调整自己的心态,维护社会的稳定和安宁。有的人活明白了,是以自我为中心,一边依赖着社会救济,一边责备社会龌蹉。


所以,活明白的人也分善与恶,同样是一种积极向善,另一种是消极向恶,二者同出而异名。



我对生活的态度


离职的第一个月,便独自一人去了南京,杭州,长沙,武汉,孝感。我见了很多老朋友,听听他们发发牢骚,然后找一些小众的景点完成探险。


在南京看了看中医,在杭州露营看了看日落,在长沙夜爬了岳麓山,在武汉坐了超级大摆锤,在孝感去了无名矿坑并在一个奶奶家蹭了中午饭。


我的感受极其良好,我体验了前所未有生活态度,我热情待人,嘻嘻笑笑,我站在山顶敞怀吹风,在无尽的树林中悠然自得,治愈我不少的失落情绪。我将继续为生活的不易奔波,也将继续热爱生活,还会心怀感恩对待他人,也会圆滑处事 事事佛系。


背景1.png


图层 1.png


IMG_6214.JPG


IMG_6198.JPG


IMG_6279.JPG


可能能解决你的问题


要不要和家里人说


我屏蔽了家里人,把负面情绪隐藏,避免波及母亲本就脆弱的内心世界,我还骗她说公司今年不挣钱,提前让我们放假,只给基础工资。如果你家境殷实,家庭和睦,我建议大方的说,这样你和父母又多了一个可以聊的话题,不妨和他们多多交流,耐心一些。


裁员,真不是你的问题


请记住,你没有任何问题,你被裁员是公司的损失,你不需要为此担责,你需要做的是让自己更强,不管是心理、身体还是技术,你得让自己变得精彩,别虚度了这如花般的时光。可能你懒,可能也没什么规划,那就想到啥就做啥好了,可能前几次需要鼓足干劲,后面就会发现轻而易举。


如何度过很丧的阶段


沮丧需要一个发泄的出口,可以保持运动习惯,比如日常爬楼梯、跑步等,一场大汗淋漓后,又是一个打满鸡血积极向上的你。

不要总在家待着,要想办法出门,多建立与社会的联系,多和朋友吹吹牛逼,别把脸面看的那么重要,死皮赖脸反而是一种讨人喜欢的性格。



不管环境怎样,希望你始终向前,披荆斩棘

如果你也正在经历这个阶段,希望你放平心态,积极应对

如果你也在人生的至暗时刻,也请不要彷徨,时间总会治愈一切

不妨试试大胆一点,生活给的惊喜也同样不少

我在一个冬天的夜晚写着文字,希望能对你有些帮助


作者:顾昂_
来源:juejin.cn/post/7331657679012380722
收起阅读 »

Electron实现静默打印小票

web
Electron实现静默打印小票 静默打印流程 1.渲染进程通知主进程打印 //渲染进程 data是打印需要的数据 window.electron.ipcRenderer.send('handlePrint', data) 2.主进程接收消息,创建打印页面...
继续阅读 »

Electron实现静默打印小票


静默打印流程


09c00eb5-f171-4090-a178-37e149d1d0f7.png


1.渲染进程通知主进程打印


//渲染进程 data是打印需要的数据
window.electron.ipcRenderer.send('handlePrint', data)

2.主进程接收消息,创建打印页面


//main.ts
/* 打印页面 */
let printWindow: BrowserWindow | undefined
/**
* @Author: yaoyaolei
* @Date: 2024-06-07 09:27:22
* @LastEditors: yaoyaolei
* @description: 创建打印页面
*/

const createPrintWindow = () => {
return new Promise<void>((resolve) => {
printWindow = new BrowserWindow({
...BASE_WINDOW_CONFIG,
title: 'printWindow',
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
nodeIntegration: true,
contextIsolation: false
}
})

printWindow.on('ready-to-show', () => {
//打印页面创建完成后不需要显示,测试时可以调用show查看页面样式(下面有我处理的样式图片)
// printWindow?.show()
resolve()
})

printWindow.webContents.setWindowOpenHandler((details: { url: string }) => {
shell.openExternal(details.url)
return { action: 'deny' }
})

if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
printWindow.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/print.html`)
} else {
printWindow.loadFile(join(__dirname, `../renderer/print.html`))
}
})
}

ipcMain.on('handlePrint', (_, obj) => {
//主进程接受渲染进程消息,向打印页面传递数据
if (printWindow) {
printWindow!.webContents.send('data', obj)
} else {
createPrintWindow().then(() => {
printWindow!.webContents.send('data', obj)
})
}
})

3.打印页面接收消息,拿到数据渲染页面完成后通知主进程开始打印


<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>打印</title>
<style>
</style>
</head>

<body>

</body>
<script>
window.electron.ipcRenderer.on('data', (_, obj) => {
//这里是接受的消息,处理完成后将html片段放在body里面完成后就可以开始打印了
//样式可以写在style里,也可以内联
console.log('event, data: ', obj);
//这里自由发挥
document.body.innerHTML = '处理的数据'
//通知主进程开始打印
window.electron.ipcRenderer.send('startPrint')
})
</script>
</html>



这个是我处理完的数据样式,这个就是print.html
9f17ea7e-3f83-408f-a780-05d50da305de.png
微信图片_20240609102325.jpg



4,5.主进程接收消息开始打印,并且通知渲染进程打印状态


ipcMain.on('startPrint', () => {
printWindow!.webContents.print(
{
silent: true,
margins: { marginType: 'none' }
},
(success) => {
//通知渲染进程打印状态
if (success) {
mainWindow.webContents.send('printStatus', 'success')
} else {
mainWindow.webContents.send('printStatus', 'error')
}
}
)
})

aa.jpg



完毕~



作者:彷徨的耗子
来源:juejin.cn/post/7377645747448365091
收起阅读 »

几行代码,优雅的避免接口重复请求!同事都说好!

web
背景简介 我们日常开发中,经常会遇到点击一个按钮或者进行搜索时,请求接口的需求。 如果我们不做优化,连续点击按钮或者进行搜索,接口会重复请求。 首先,这会导致性能浪费!最重要的,如果接口响应比较慢,此时,我们在做其他操作会有一系列bug! 那么,我们该如...
继续阅读 »

背景简介


我们日常开发中,经常会遇到点击一个按钮或者进行搜索时,请求接口的需求。


如果我们不做优化,连续点击按钮或者进行搜索,接口会重复请求。




首先,这会导致性能浪费!最重要的,如果接口响应比较慢,此时,我们在做其他操作会有一系列bug!



那么,我们该如何规避这种问题呢?


如何避免接口重复请求


防抖节流方式(不推荐)


使用防抖节流方式避免重复操作是前端的老传统了,不多介绍了


防抖实现


<template>
<div>
<button @click="debouncedFetchData">请求</button>
</div>

</template>

<script setup>
import { ref } from 'vue';
import axios from 'axios';

const timeoutId = ref(null);

function debounce(fn, delay) {
return function(...args) {
if (timeoutId.value) clearTimeout(timeoutId.value);
timeoutId.value = setTimeout(() => {
fn(...args);
}, delay);
};
}

function fetchData() {
axios.get('http://api/gcshi) // 使用示例API
.then(response => {
console.log(response.data);
})
}

const debouncedFetchData = debounce(fetchData, 300);
</script>

防抖(Debounce)



  • 在setup函数中,定义了timeoutId用于存储定时器ID。

  • debounce函数创建了一个闭包,清除之前的定时器并设置新的定时器,只有在延迟时间内没有新调用时才执行fetchData。

  • debouncedFetchData是防抖后的函数,在按钮点击时调用。


节流实现


<template>
<div>
<button @click="throttledFetchData">请求</button>
</div>

</template>

<script setup>
import { ref } from 'vue';
import axios from 'axios';

const lastCall = ref(0);

function throttle(fn, delay) {
return function(...args) {
const now = new Date().getTime();
if (now - lastCall.value < delay) return;
lastCall.value = now;
fn(...args);
};
}

function fetchData() {
axios.get('http://api/gcshi') //
.then(response => {
console.log(response.data);
})
}

const throttledFetchData = throttle(fetchData, 1000);
</script>

节流(Throttle)



  • 在setup函数中,定义了lastCall用于存储上次调用的时间戳。

  • throttle函数创建了一个闭包,检查当前时间与上次调用时间的差值,只有大于设定的延迟时间时才执行fetchData。

  • throttledFetchData是节流后的函数,在按钮点击时调用。


节流防抖这种方式感觉用在这里不是很丝滑,代码成本也比较高,因此,很不推荐!


请求锁定(加laoding状态)


请求锁定非常好理解,设置一个laoding状态,如果第一个接口处于laoding中,那么,我们不执行任何逻辑!


<template>
<div>
<button @click="fetchData">请求</button>
</div>

</template>

<script setup>
import { ref } from 'vue';
import axios from 'axios';

const laoding = ref(false);

function fetchData() {
// 接口请求中,直接返回,避免重复请求
if(laoding.value) return
laoding.value = true
axios.get('http://api/gcshi') //
.then(response => {
laoding.value = fasle
})
}

const throttledFetchData = throttle(fetchData, 1000);
</script>


这种方式简单粗暴,十分好用!


但是也有弊端,比如我搜索A后,接口请求中;但我此时突然想搜B,就不会生效了,因为请求A还没响应



因此,请求锁定这种方式无法取消原先的请求,只能等待一个请求执行完才能继续请求。


axios.CancelToken取消重复请求


基本用法


axios其实内置了一个取消重复请求的方法:axios.CancelToken,我们可以利用axios.CancelToken来取消重复的请求,爆好用!


首先,我们要知道,aixos有一个config的配置项,取消请求就是在这里面配置的。


<template>
<div>
<button @click="fetchData">请求</button>
</div>

</template>

<script setup>
import { ref } from 'vue';
import axios from 'axios';

let cancelTokenSource = null;


function fetchData() {
if (cancelTokenSource) {
cancelTokenSource.cancel('取消上次请求');
cancelTokenSource = null;
}
cancelTokenSource = axios.CancelToken.source();

axios.get('http://api/gcshi',{cancelToken: cancelTokenSource.token}) //
.then(response => {
laoding.value = fasle
})
}

</script>


我们测试下,如下图:可以看到,重复的请求会直接被终止掉!



CancelToken官网示例



官网使用方法传送门:http://www.axios-http.cn/docs/cancel…



const CancelToken = axios.CancelToken;
const source = CancelToken.source();

axios.get('/user/12345', {
cancelToken: source.token
}).catch(function (thrown) {
if (axios.isCancel(thrown)) {
console.log('Request canceled', thrown.message);
} else {
// 处理错误
}
});

axios.post('/user/12345', {
name: 'new name'
}, {
cancelToken: source.token
})

// 取消请求(message 参数是可选的)
source.cancel('Operation canceled by the user.');

也可以通过传递一个 executor 函数到 CancelToken 的构造函数来创建一个 cancel token:


const CancelToken = axios.CancelToken;
let cancel;

axios.get('/user/12345', {
cancelToken: new CancelToken(function executor(c) {
// executor 函数接收一个 cancel 函数作为参数
cancel = c;
})
});

// 取消请求
cancel();

注意: 可以使用同一个 cancel token 或 signal 取消多个请求。


在过渡期间,您可以使用这两种取消 API,即使是针对同一个请求:


const controller = new AbortController();

const CancelToken = axios.CancelToken;
const source = CancelToken.source();

axios.get('/user/12345', {
cancelToken: source.token,
signal: controller.signal
}).catch(function (thrown) {
if (axios.isCancel(thrown)) {
console.log('Request canceled', thrown.message);
} else {
// 处理错误
}
});

axios.post('/user/12345', {
name: 'new name'
}, {
cancelToken: source.token
})

// 取消请求 (message 参数是可选的)
source.cancel('Operation canceled by the user.');
// 或
controller.abort(); // 不支持 message 参数

作者:石小石Orz
来源:juejin.cn/post/7380185173689204746
收起阅读 »

Vite 为何短短几年内变成这样?

web
给前端以福利,给编程以复利。大家好,我是大家的林语冰。 00. 观前须知 在 Web 开发领域,Vite 如今已如雷贯耳。 自 2020 年 4 月发布以来,Vite 的人气蒸蒸日上。目前 Vite 在 GitHub 上的收藏数量已超过 64k,每周下载量超过...
继续阅读 »

给前端以福利,给编程以复利。大家好,我是大家的林语冰。


00. 观前须知


在 Web 开发领域,Vite 如今已如雷贯耳。


自 2020 年 4 月发布以来,Vite 的人气蒸蒸日上。目前 Vite 在 GitHub 上的收藏数量已超过 64k,每周下载量超过 1200 万次,现在为 Nuxt、Remix、Astro 等大多数开源框架提供支持。


尽管众口嚣嚣,我们意识到许多开发者可能仍然不熟悉 Vite 是什么鬼物,也不熟悉 Vite 在推动现代 Web 框架和工具的开发中扮演的重要角色。


在本文中,我们将科普 Vite 的知识储备,以及 Vite 如何在短短几年后发展成为现代 Web 的重量级角色。


00-trend.png



免责声明


本文属于是语冰的直男翻译了属于是,略有删改,仅供粉丝参考。英文原味版请传送 What is Vite (and why is it so popular)?



01. Vite 是什么鬼物?


Vite 的发音为 /vit/,在法语中是“快速”或“迅捷”的意思,不得不说 Vite 名副其实。


简而言之,Vite 是一种现代 JS 构建工具,为常见 Web 模式提供开箱即用的支持和构建优化,兼具 rollup 的自由度和成熟度。


Vite 还与 esbuild 和原生 ES 模块强强联手,实现快速无打包开发服务器。


Vite 是由“Vue 之父”尤雨溪(Evan You)构思出来的,旨在通过减少开发者在启动开发服务器和处理文件编辑后重载时遭遇的性能瓶颈,简化打包过程。


02. Vite 的核心特性


00-wall.png


运行 Vite 时,你会注意到的第一个区别在于,开发服务器会即时启动。


这是因为,Vite 采用按需方法将你的应用程序提供给浏览器。Vite 不会首先打包整个源码,而是响应浏览器请求,将你编写的模块即时转换为浏览器可以理解的原生 ESM 模块。


Vite 为 TS、PostCSS、CSS 预处理器等提供开箱即用的支持,且可以通过不断增长的插件生态系统进行扩展,支持所有你喜欢的框架和工具。


每当你在开发期间更改项目中的任意文件时,Vite 都会使用应用程序的模块图,只热重载受影响的模块(HMR)。这允许开发者预览他们的更改,及其对应用程序的影响。


Vite 的 HMR 速度惊人,可以让编辑器自动保存,并获得类似于在浏览器开发工具中修改 CSS 时的反馈循环。


Vite 还执行 依赖预构建(dependency pre-bundling)。在开发过程中,Vite 使用 esbuild 来打包你的依赖并缓存,加快未来服务器的启动速度。


此优化步骤还有助于加快 lodash 等导出许多迷你模块的依赖的加载时间,因为浏览器只加载每个依赖的代码块(chunk)。这还允许 Vite 在依赖中支持 CJS 和 UMD 代码,因为它们被打包到原生 ESM 模块中。


当你准备好部署时,Vite 将使用优化的 rollup 设置来构建你的应用程序。Vite 会执行 CSS 代码分割,添加预加载指令,并优化异步块的加载,无需任何配置。


Vite 提供了一个通用的 rollup 兼容插件 API,适用于开发和生产,使你可以更轻松地扩展和自定义构建过程。


03. Vite 的优势


使用 Vite 有若干主要优势,包括但不限于:


03-1. 开源且独立


Vite 由开源开发者社区“用爱发电”,由来自不同背景的开发者团队领导,Vite 核心仓库最近贡献者数量已突破 900 人。


Vite 得到积极的开发和维护,不断实现新功能并解决错误。


03-2. 本地敏捷开发


开发体验是 Vite 的核心,每次点击保存时,你都能感受到延迟。我们常常认为重载速度是理所当然的。


但随着您的应用程序增长,且重载速度逐渐停止,你将感恩 Vite 几乎能够保持瞬间重载,而无论应用程序大小如何。


03-3. 广泛的生态系统支持


Vite 的方案人气爆棚,大多数框架和工具都默认使用 Vite 或拥有一流的支持。通过选择使用 Vite 作为构建工具,这些项目维护者可以在它们之间共享一个统一基建,且随着时间的推移共同改良 Vite。


因此,它们可以花更多的时间开发用户需要的功能,而减少重新造轮子的时间。


03-4. 易于扩展


Vite 对 rollup 插件 API 的押注得到了回报。插件允许下游项目共享 Vite 核心提供的功能。


我们有很多高质量的插件可供使用,例如 vite-plugin-pwavite-imagetools


03-5. 框架构建难题中的重要角色


Vite 是现代元框架构建的重要组成部分之一,这是一个更大的工具生态系统的一部分。


Volar 提供了在代码编辑器中为 Vue、MDX 和 Astro 等自定义编程语言构建可靠且高性能的编辑体验所需的工具。Volar 允许框架向用户提供悬停信息、诊断和自动补全等功能,并共享 Volar 作为为它们提供支持的通用基建。


另一个很好的例子是 Nitro,它是一个服务器工具包,用于创建功能齐全的 Web 服务器,开箱即用地支持每个主要部署平台。Nitro 是一个与框架无关的库 UnJS 的奇妙集合的一部分。


04. Vite 的未来


evan-vite5.png


在最近的 ViteConf 大会的演讲中,尤雨溪表示,虽然 Vite 取得了巨大进展,但仍面临一些已知的问题和挑战。


Vite 目前使用 rollup 进行生产构建,这比 esbuildBun 等原生打包器慢得多。


Vite 还尽可能减少开发和生产环境之间的不一致性,但考虑到 rollupesbuild 之间的差异,某些不一致性无法避免。


尤雨溪现在领导一个新团队开发 rolldown,这是一个基于 Rust 的 rollup 移植,在 “JS 氧化编译器 OXC”之上构建了最大的兼容性。


这个主意是用 rolldown 替代 Vite 中的 rollupesbuild。Vite 将拥有一个单独基建,兼具 rollup 的自由度和 esbuild 的速度,消除不一致性,使代码库更易于维护,并加快构建时间。


rolldown 目前处于早期阶段,但已经显示出有希望的结果。rolldown 现已开源,rolldown 团队正在寻找贡献者来辅助实现这一愿景。


与此同时,Vite 团队在每个版本中不断改良 Vite。这项工作从上游的为 Vitest 和 Nuxt Dev SSR 提供​​动力的引擎 vite-node 开始,现已发展成为框架作者对 Vite API 的完整修订版。


新版 Environment API 预计在 Vite 6 中发布,这将是自 Vite 2 发布以来 Vite 最大的变化之一。这将允许在任意数量的环境中通过 Vite 插件管道运行代码,解锁对 worker、RSC 等的一流支持。


Vite 正在开辟一条前进的道路,并迅速成为 JS 生态系统事实上的构建工具。


参考文献



粉丝互动


本期话题是:如何评价人气爆棚的 Vite,你最喜欢或期待 Vite 的哪个功能?你可以在本文下方自由言论,文明科普。


欢迎持续关注“前端俱乐部”,给前端以福利,给编程以复利。


坚持阅读的小伙伴可以给自己点赞!谢谢大家的点赞,掰掰~


26-cat.gif


作者:前端俱乐部
来源:juejin.cn/post/7368836713965486119
收起阅读 »

封装WebSocket消息推送,干翻Ajax轮询方式

web
建议可以提前先看下之前两篇文章,深度学习! 仅仅只会Ajax,那就out了!WebSocket实战解锁实时通信新境界! WebSocket: 实时通信的魔法快递,让你的网络生活飞跃升级! 使用AJAX和WebSocket都可以实现消息推送,但它们在实现方式和...
继续阅读 »

建议可以提前先看下之前两篇文章,深度学习!


仅仅只会Ajax,那就out了!WebSocket实战解锁实时通信新境界!



WebSocket: 实时通信的魔法快递,让你的网络生活飞跃升级!


使用AJAX和WebSocket都可以实现消息推送,但它们在实现方式和适用场景上有所不同。下面是使用这两种技术实现消息推送的简要说明。


AJax实现或WebSocket实现对比


AJAX 实现消息推送


AJAX(Asynchronous JavaScript and XML)允许你在不重新加载整个页面的情况下,与服务器进行数据交换。但是,传统的AJAX并不直接支持实时消息推送,因为它基于请求-响应模式。为了模拟消息推送,你可以使用轮询(polling)或长轮询(long-polling)技术。


轮询(Polling)


轮询是定期向服务器发送请求,以检查是否有新的消息。这种方法简单但效率较低,因为即使在没有新消息的情况下,也会频繁地发送请求。


function pollForMessages() {
$.ajax({
url: '/messages', // 假设这是获取消息的API端点
method: 'GET',
success: function(data) {
// 处理接收到的消息
console.log(data);

// 等待一段时间后再次轮询
setTimeout(pollForMessages, 5000); // 每5秒轮询一次
},
error: function() {
// 处理请求失败的情况
setTimeout(pollForMessages, 10000); // 等待更长时间后重试
}
});
}

// 开始轮询
pollForMessages();

长轮询(Long-Polling)


长轮询是轮询的一种改进方式。客户端发起一个请求到服务器,服务器会保持这个连接打开直到有新消息到达或超时,然后返回新消息或超时响应。这种方式比简单轮询减少了无效的请求,但仍然存在一定的延迟和资源浪费。


使用长轮询时,通常需要在服务器端有特殊的支持来保持连接直到有数据可以发送。


WebSocket 实现消息推送


WebSocket 提供了一个全双工的通信通道,允许服务器主动向客户端推送消息。一旦建立了WebSocket连接,服务器和客户端就可以随时向对方发送消息,而不需要像AJAX那样频繁地发起请求。


WebSocket 客户端实现


var socket = new WebSocket('ws://your-server-url');

socket.onopen = function(event) {
// 连接打开后,你可以向服务器发送消息
socket.send('Hello Server!');
};

socket.onmessage = function(event) {
// 当收到服务器发来的消息时,触发此事件
console.log('Received:', event.data);
};

socket.onerror = function(error) {
// 处理错误
console.error('WebSocket Error:', error);
};

socket.onclose = function(event) {
// 连接关闭时触发
console.log('WebSocket is closed now.');
};

WebSocket 服务器端实现


服务器端实现WebSocket通常依赖于特定的服务器软件或框架,如Node.js的ws库、Java的Spring WebSocket等。这些库或框架提供了处理WebSocket连接的API,你可以在这些连接上发送和接收消息。


在WebSocket服务器端,你可以保存与每个客户端的连接,并在需要时向它们发送消息


下面开始做封装WebSocket的介绍


想象


想象一下,你是一位超级快递员,负责把客户的包裹准确无误地送到指定的地址。这些包裹里装的是WebSocket消息,而你的任务是根据每个包裹上的useridurl信息,找到正确的收件人并将包裹送达。


首先,你需要准备一辆超级快递车(也就是WebSocket连接)。这辆车非常智能,它可以记住多个收件人的地址(url),并且同时为他们运送包裹。但是,每个收件人(userid)只能对应一个地址,这样才不会送错。


当有客户找你寄送包裹时,他们会告诉你收件人的userid和地址url。你会把这些信息记在小本本上,然后告诉超级快递车:“嘿,车车,我们要去这个地方送这个包裹给这个人!”


快递车非常听话,它会立即启动并前往指定的地址。一旦到达,它就会静静地等待,直到有包裹需要送出。


当你需要发送消息时,就像把包裹放进快递车里一样简单。你只需告诉快递车:“给这个userid的人送这个包裹!”快递车就会准确无误地将包裹送达给指定的收件人。


如果收件人回复了消息,快递车就像个贴心小助手一样,会第一时间把回信拿给你。你可以轻松地查看并处理这些回信。


这样一来,你就不再需要亲自跑腿送包裹了,超级快递车会帮你搞定一切。你只需要告诉它去哪里、送给谁,然后坐等好消息就行啦!



  1. WebSocketMessenger(快递服务公司)



    • 负责建立和维护WebSocket连接。

    • 采用单例模式,确保同一时间只有一个实例在运行。

    • 存储收件人(recipient)和地址(address)信息。

    • 提供发送消息(send_message)的方法。



  2. 快递员(WebSocket连接实例)



    • WebSocketMessenger创建和管理。

    • 负责实际的消息传递工作。

    • 知道如何与指定的收件人通信(通过地址)。



  3. 客户(发送消息的人)



    • 使用WebSocketMessenger的服务来发送消息。

    • 提供收件人信息和消息内容。



  4. 收件人(接收消息的人)



    • 在WebSocket连接的另一端,接收来自WebSocketMessenger传递的消息。




这些角色通过WebSocket连接进行交互,实现了消息的发送和接收。WebSocketMessenger作为服务提供者,管理着快递员(WebSocket连接实例),而客户和收件人则是服务的使用者。


代码层面


服务node代码可以看上篇文章:
仅仅只会Ajax,那就out了!WebSocket实战解锁实时通信新境界!


// WebSocketMessenger(快递服务公司)
class WebSocketManager {
constructor(url = null, userId = null, receiveMessageCallback = null) {
this.socket = null // WebSocket 对象
this.sendTimeObj = null // 发送信息给服务端的重复调用的时间定时器
this.reconnectTimeObj = null // 尝试链接的宏观定时器
this.reconnectTimeDistance = 5000 // 重连间隔,单位:毫秒
this.maxReconnectAttempts = 10 // 最大重连尝试次数
this.reconnectAttempts = 0 // 当前重连尝试次数
this.id = userId //用户ID(业务逻辑,根据自己业务需求调整)
this.url = url // WebSocket 连接地址
this.receiveMessageCallback = receiveMessageCallback // 接收消息回调函数
}

/**
* 开启WebSocket
*/

async start() {
if (this.url && this.id) {
// 连接WebSocket
this.connectWebSocket()
} else {
console.error('WebSocket erros: 请传入连接地址和用户id')
}
}

/**
* 创建WebSocket连接, 超级快递车
*/

connectWebSocket() {
// 通过id生成唯一值(服务端要求,具体根据自己业务去调整)
let id = `${this.id}-${Math.random()}`
// 创建 WebSocket 对象
this.socket = new WebSocket(this.url, id) // 快递员(WebSocket连接实例

// 处理连接打开事件
this.socket.onopen = (event) => {
// 给服务端发送第一条反馈信息
this.startSendServe()
}

// 处理接收到消息事件
this.socket.onmessage = (event) => {
this.receiveMessage(event)
}

// 处理连接关闭事件
this.socket.onclose = (event) => {
// 清除定时器
clearTimeout(this.sendTimeObj)
clearTimeout(this.reconnectTimeObj)
// 尝试重连
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++
console.log('重试链接次数:'+ this.reconnectAttempts)
this.reconnectTimeObj = setTimeout(() => {
this.connectWebSocket()
}, this.reconnectTimeDistance)
} else {
// 重置重连次数
this.reconnectAttempts = 0
console.error(
'WebSocketManager erros: Max reconnect attempts reached. Unable to reconnect.'
)
}
}

// 处理 WebSocket 错误事件
this.socket.onerror = (event) => {
console.error('WebSocketManager error:', event)
}
}

/**
* 发送给node的第一条信息
*/

startSendServe() {

this.sendMessage('hi I come from client')
}

/**
* 发送消息
* @param {String} message 消息内容
*/

sendMessage(message) {
if (this.socket.readyState === WebSocket.OPEN) {
this.socket.send(message)
} else {
console.error(
'WebSocketManager error: WebSocket connection is not open. Unable to send message.'
)
}
}

/**
* 接收到消息
*/

receiveMessage(event) {
// 根据业务自行处理
console.log('receiveMessage:', event.data)
this.receiveMessageCallback && this.receiveMessageCallback(event.data)
}

/**
* 关闭连接
*/

closeWebSocket() {
this.socket.close()
// 清除定时器 重置重连次数
clearTimeout(this.sendTimeObj)
clearTimeout(this.reconnectTimeObj)
this.reconnectAttempts = 0
}
}

代码解读


该类用于管理和控制WebSocket连接,包括连接建立、消息接收、重连机制等。下面是对代码的详细解读:


构造函数 constructor



  • url: WebSocket的连接地址。

  • userId: 用户的ID,用于业务逻辑处理。

  • receiveMessageCallback: 接收消息时的回调函数。

  • 初始化了一些成员变量,包括socket(WebSocket对象)、定时器对象(sendTimeObjreconnectTimeObj)、重连间隔和尝试次数等。


start 方法



  • 检查urluserId是否存在,若存在则调用connectWebSocket方法建立WebSocket连接。


connectWebSocket 方法



  • 生成一个基于用户ID和随机数的唯一值作为WebSocket的子协议(或协议片段)。

  • 创建新的WebSocket连接。

  • 设置了WebSocket的onopenonmessageoncloseonerror事件处理器。


事件处理器



  • onopen: 当WebSocket连接打开时触发,开始发送消息给服务端(通过startSendServe方法,该方法在代码片段中未给出)。

  • onmessage: 当接收到服务端发送的消息时触发,调用receiveMessage方法处理消息。

  • onclose: 当WebSocket连接关闭时触发,首先清除相关定时器,然后尝试重连。如果重连次数未达到最大限制,则设置定时器在一段时间后重新调用connectWebSocket进行重连;如果达到最大重连次数,则重置重连次数并输出错误信息。

  • onerror: 当WebSocket发生错误时触发,输出错误信息。
    当服务端断开后开始重连


image.png
这里设置重连10次后断开


image.png


receiveMessage 方法



  • 该方法应该是用来处理从服务端接收到的消息,具体实现取决于业务逻辑。根据传入的回调函数receiveMessageCallback,可以对接收到的消息进行相应处理


使用Demo


index.html


<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script src="./webSocketManager.js"></script>
<script>
// const WebSocketManager = require('./webSocketManager.js')
console.log(WebSocketManager)
/**
* 接收消息回调
*/

const receiveMessage = (res)=>{
console.log('接收消息回调:',res)
}
const socketManager = new WebSocketManager('ws://localhost:3000', 'userid292992', receiveMessage)
socketManager.start()

</script>
</head>

导入模块即可使用


总结:


相对完善的WebSocket管理器,能够处理连接建立、消息接收和重连等常见场景。但需要注意的是,具体的业务逻辑和错误处理可能需要根据实际情况进行进一步的完善和优化


作者:梦幻星辰吧
来源:juejin.cn/post/7380222254196326412
收起阅读 »

全局异常统一处理很好,但建议你谨慎使用

思考,输出,沉淀。用通俗的语言陈述技术,让自己和他人都有所收获。 作者:毅航😜 在SpringBoot应用开发中,利用@ControllerAdvice 结合 @ExceptionHandler来实现对后端服务的统一异常管理似乎已经成为了开发者约定俗成的规...
继续阅读 »



思考,输出,沉淀。用通俗的语言陈述技术,让自己和他人都有所收获。

作者:毅航😜





SpringBoot应用开发中,利用@ControllerAdvice 结合 @ExceptionHandler来实现对后端服务的统一异常管理似乎已经成为了开发者约定俗成的规范了,为此网上也有很多文章来阐述如何更加优雅的来实现统一异常管理,但这样做真的好吗?


前言


SpringBoot中的全局统一异常管理通常是指利用@ControllerAdvice 结合 @ExceptionHandler来自定义一个全局的异常处理器,从而避免了在程序中频繁写书写try-catch来对异常进行处理。但结合笔者最近惨痛debug旧有项目的经历来看,笔者不推荐这样去做。


在讨论之前我们不妨先来看看实际开发中都有哪些地方可能出现的异常。


全局异常所带来的困惑


目前,大部分应用在开发时,通常会将代码划分为控制层,服务层,数据访问层三层结构,每个模块负责自己独立的逻辑。简单来看,控制层主要作用在于对外暴露 ur1访问地址,并将前台的处理请求委托给服务层来处理;而对于服务层来说其主要是业务逻辑处理的地方,以登录请求为例,用户名、密码的校验通常会放在服务层来处理;数据访问层则主要用于对数据库进行访问。如下这张图直观的反映了三层架构下各个模块所肩负的责任。


image.png


知晓了软件开发过程中的分层架构模式后。我们再来看每个模块可能产生的异常信息。其中:



  • 控制层主要用于处理实现前后端交互逻辑,其主要负责信息收禁、参数校验等,抛出的异常也多是参数校验、请求服务异常等异常信息。

  • 服务层主要用于处理业务逻辑,以及各种外部服务调用、访问数据作、缓存处理、消息处理等处理操作,这部分可能抛出的逻辑就非常多了。例如,数据库访问异常、服务调用异常等问题。

  • 数据访问层则主要负责数据访问实现,其一般没有业务逻辑。由于目前持久层使用技术通常为Mybatis,所以这一层抛出的异常多是Mybatis框架内部所抛出的异常。


不难发现,其中每一层所抛出的异常类型有着很大的差异。而我们在使用全局异常管理时,通常使用如下
代码逻辑:



@ControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(Exception.class)
public ResponseEntity<String> handleGlobalException(Exception ex) {
// 在这里实现异常处理逻辑
log.error(ex.getMessage());//在控制台打印错误信息
return Result.error(ex.getMessage());
}
}



即我们通过在ExceptionHandler执行捕获异常为Exception来尽可能捕获业务中可能遇到的各种异常,相信网上已经有很多博主都在不断讲授这样的写法。


但这样做真的好吗?在笔者最近接手的旧项目中,当后端无法处理前端请求后会返回如下信息:


{
"message": “服务器异常”,
"code":602
}

后台服务通常会打印出如下信息:


image.png


(注:此处仅为展示,真实环境出现的异常远比此复杂)


不难发现,通过网上所盛传全局异常处理逻辑,我根本不知道问题出在哪里。我只知道当前程序出现异常,且异常信息为/ by zero。除此之外我无法得到任何有用的信息。


此外,通过在@ExceptionHandler配置Exception.class使得程序可以捕获多种异常信息,但这样粗粒度做法所导致的直接问题就是无法精确的定位异常问题发生所在地,极大的提升了问题定位的难度笔者项目同事debug经历来看,当项目无法正常运行时,组内的程序员通常会通过打日志的方法来一行一行定位问题所在。


破解之道


其实造成这样调试困难得本质原因在于全局异常机制的滥用,如下这张图真实的反映了引入全局异常机制后,异常的处理逻辑。


image.png


不难发现,当引入全局异常处理后,所有的异常信息都会交由RestExceptionHandler来进行处理。当程序遇到异常时,会将异常信息抛给RestExceptionHandler来处理,并由其定义错误的处理逻辑。


分析到此处,其实你已经发现了。对于全局异常处理逻辑而言,其更适合做异常的兜底工作。即如果当前层出现异常,并且不断上抛的仍然无法解决的话,不妨通过全局统一的异常管理来进行处理,以对这些未处理的异常进行捕获


此外,异常处理不应该进行像很多博客说的那样,仅是通过e.getMessage打印异常信息就可以了。这对于排查问题没有以一丁点的帮助,可以说是百害而无一利。


对于此,笔者更推荐在打印异常信息时,记录异常以及当前 URL、执行方法等信息以便后期方便问题排查。具体可参考如下代码:



@Slf4j
@RestControllerAdvice
public class GlobExceptionHandler {


@ExceptionHandler(ArithmeticException.class)//ArithmeticException异常类型通过注解拿到
public String exceptionHandler(HttpServletRequest request,ArithmeticException exception){
// 打印详细信息
log(request,exception);
return exception.getMessage();

}


public void log(HttpServletRequest request, Exception exception) {
//换行符
String lineSeparatorStr = System.getProperty("line.separator");

StringBuilder exStr = new StringBuilder();
StackTraceElement[] trace = exception.getStackTrace();
// 获取堆栈信息并输出为打印的形式
for (StackTraceElement s : trace) {
exStr.append("\tat " + s + "\r\n");
}
//打印error级别的堆栈日志
log.error("访问地址:" + request.getRequestURL() + ",请求方法:" + request.getMethod() +
",远程地址:" + request.getRemoteAddr() + lineSeparatorStr +
"错误堆栈信息如下:" + exception.toString() + lineSeparatorStr + exStr);
}
}

当程序发生错误时,其打印的日志信息如下:


image.png


不难发现,其完整的打印出了url、方法信息,错误参数、请求地址等信息,极大的降低了线上Bug的排查难度。


总结


技术本身并没有什么对与错之分,只不过有时我们用的方式和时机不对,进而使得本该提效的工具,反而在不断拖垮我们的效率。就如同本文分析的全局异常处理机制一样,其确实可以帮助我们降低try-catch的使用,但错误且不加考虑的乱用只会使得当系统出现问题时,我们只能两眼一抹黑,然后一行一行打日志来定位问题。


最后,对于代码中异常的捕获处理,笔者认为全局异常应该作为异常处理的都兜底操作,而不应该成为异常处理的灵丹妙药! 此外,全局异常处理过程不应该仅是简单的 e.getMessage()打印异常消息即可,其更应记录更加有助于异常排查的信息例如,方法,请求的url,请求参数等信息。



如果觉文章对你有帮助,不妨点赞+关注,不错过笔者之后的每一次更新!



作者:毅航
来源:juejin.cn/post/7291555600854106147
收起阅读 »

为什么 Android 要采用 Binder 作为 IPC 机制?

Hi 大家好,我是 DHL,大厂程序员,公众号:ByteCode ,在美团、快手、小米工作过。搞过逆向,做过性能优化,研究过系统,擅长鸿蒙、Android、Kotlin、性能优化、职场分享。 微信小程序「猿面试」每日分享一道大厂面试题,涉及 Java、And...
继续阅读 »

网站.jpg



Hi 大家好,我是 DHL,大厂程序员,公众号:ByteCode ,在美团、快手、小米工作过。搞过逆向,做过性能优化,研究过系统,擅长鸿蒙、Android、Kotlin、性能优化、职场分享。



微信小程序「猿面试」每日分享一道大厂面试题,涉及 JavaAndroid鸿蒙和ArkTS设计模式算法和数据结构 等内容。




本篇文章主要以面试为主,因此只要记住这些即可。Android 采用 Binder 作为 IPC (进程间通信) 机制的原因主要包括以下几点,


高效性


Binder 机制通过减少数据拷贝次数来提高 IPC 的效率。在 Binder 机制中,发送方只需要将数据从用户空间拷贝到内核空间一次,接收方可以直接访问内核空间中的数据,避免了额外的数据拷贝。


与其他 IPC 机制相比,Binder 更高效。Binder 数据拷贝只需要一次,而管道、消息队列、Socket 都需要 2 次,但共享内存方式一次内存拷贝都不需要;从性能角度看,Binder 性能仅次于共享内存。


对象级别的通信


与基于消息的通信方式不同,Binder 机制提供了一种面向对象的 IPC 方法。Binder 允许在进程间传递对象引用,开发者可以像调用本地对象一样调用远程对象的方法,而无需关心对象实际所在的进程。这种面向对象的 IPC 方式让编程模型更自然,易于理解和使用。


支持异步通信


除了同步调用外,Binder 还支持异步通信,这对于构建响应式应用尤其重要。通过异步通信,应用可以在等待 Binder 事务完成时继续执行其他任务,提高了应用的响应性和性能。


安全性


Binder 通过使用 UID(用户 ID)和 PID(进程 ID)来验证请求的来源,提供了进程间通信的安全性保障。这意味着每个 Binder 事务都可以精确到发起者,系统可以据此实施安全策略,例如权限检查,从而防止未授权的数据访问或通信。


每个 Binder 通信都有明确的权限控制,可以限制哪些进程可以访问 Binder 服务,从而增强了系统的安全性。


稳定性


与其他 IPC 机制相比,Binder 是基于 C/S 架构的,是指客户端 (Client) 和服务端 (Server) 组成的架构,Client 端有什么需求,直接发送给 Server 端去完成,架构清晰明朗,Server 端与 Client 端相对独立。


而共享内存实现方式复杂,没有客户与服务端之别,需要充分考虑到访问临界资源的并发同步问题,否则可能会出现死锁等问题;


从这稳定性角度看,Binder 架构优越于共享内存


简便性


Binder 为开发者提供了一套易于使用的 API 来进行进程间通信,隐藏了复杂的内部实现。它使得不同应用之间或应用与系统服务之间的数据传递和方法调用变得简单直观。


总之,由于其高效、安全、简便、面向对象等特性,Binder 成为了 Android 系统中进行 IPC 通信的首选机制。这些特性使得 Binder 非常适合移动设备这种资源受限的环境。



作者:程序员DHL
来源:juejin.cn/post/7378321582399602707
收起阅读 »

如何在3天内开发一个鸿蒙app

web
华为鸿蒙操作系统(HarmonyOS)自2.0版本正式上线以来,在短时间内就部署超过了2亿台设备,纵观全球操作系统的发展史,也是十分罕见的。与其他手机操作系统不同,HarmonyOS自诞生之日起,就是一款面向多设备、分布式的万物互联操作系统。“1+8+N”是H...
继续阅读 »

华为鸿蒙操作系统(HarmonyOS)自2.0版本正式上线以来,在短时间内就部署超过了2亿台设备,纵观全球操作系统的发展史,也是十分罕见的。与其他手机操作系统不同,HarmonyOS自诞生之日起,就是一款面向多设备、分布式的万物互联操作系统。“1+8+N”是HarmonyOS打造的全场景战略,其中,“1”是智能手机,“8”是指大屏、音箱、眼镜、手表、车机、耳机、平板电脑和PC“八大行星”。


围绕着关键的八大行星,周边还有合作伙伴开发的N个“卫星”,指的是移动办公、智能家居、运动健康、影音娱乐及智能出行等板块的延伸业务。



一个典型鸿蒙应用的产品设计,必然要包含鸿蒙应用的特色。既然鸿蒙操作系统主张万物互联,那么配合HarmonyOS独立操作系统的推进,咱们开发的鸿蒙App肯定不能像andriod app一样,还是要多联动鸿蒙操作系统上的流量入口,方能对于后续的业务规划起到更好的拓展作用。一些创新的点包括:



  • 多设备支持,即手机、平板、手表甚至是智汇屏都可以支持

  • 分布式数据或文件能力,不同设备中的同一款应用数据应该是实时同步的,且不完全需要后台服务即可实现

  • 支持卡片功能

  • 支持应用流转

  • 支持原子化服务


开发鸿蒙原生App的两种主流方式


1、请鸿蒙原生开发工程师,用鸿蒙ArkTS语言重新写一遍


我们可以看到鸿蒙官方的开发者文档上,有很详细的开发教程及文档,其中划重点的是,其技术语言为ArkTS语言(直接区别于IOS和Andriod的开发语言)。


这个办法是最完美的开发方式,但也是最慢的开发方式。如果按照鸿蒙原生开发的“套路”去一步步开发鸿蒙版App,就好比中国人学外语一般,开发者还得从0开始学习新的技术语言(ArkTS语言),可能时间窗口就错过了...



2、混合App开发思路


混合app开发框架是指能够同时支持原生开发和Web开发的框架,它可以将原生应用和Web应用的优势结合起来,为开发者提供更高效、更便捷的开发体验。


混合app开发框架的概念最早可以追溯到2009年,当时PhoneGap(现为Cordova)框架的发布标志着混合app开发的开始。PhoneGap允许开发者使用HTML、CSS和JavaScript来开发跨平台的移动应用,并通过插件来访问原生设备功能。随后,混合app开发框架得到了快速发展,涌现出了许多流行的框架,如Ionic、React Native、Xamarin等。2016年至今,混合app开发框架趋于成熟,并开始向更细分的方向发展。


有了混合开发框架和技术实践下,让”一端开发,多端部署“的概念执行成为可能。


混合app开发框架通常采用以下两种技术原理:



  • WebView:使用WebView控件将Web页面嵌入到原生应用中,从而实现跨平台开发。

  • JavaScript桥:提供JavaScript与原生代码之间的通信桥梁,使得Web代码可以访问原生设备功能。


特性包括以下四点:



  • 跨平台开发:使用一套代码可以开发Android、iOS等多个平台的应用。

  • 快速开发:提供丰富的UI组件和API,可以快速构建应用原型。

  • 性能优化:通过各种技术手段提升应用性能。

  • 原生功能支持:可以访问原生设备功能,提供更好的用户体验。



兼顾跨操作系统 & 跨智能终端的快速应用开发模式


开发应用要快速的话,还有一个隐藏的前提条件就是:面向业务应用场景可以复用,毕竟现在市场需求日新月异,业务流程线上化基本不会做大的调增,新功能的研发面向市场,也希望能够在短周期内能够在全端(至少是手机端的用户全网发放)。但众所周知,如果用操作系统原生语法开发,就会出现研发团队需要维护三套代码,哪怕修改一个功能,也需要三端共同改造,及其麻烦。


小程序技术或者HTML5技术天然的跨端,以及受益于微信小程序生态近几年来的蓬勃发展,小程序应用场景复用且通过“小程序转换工具”(小程序容器技术,如FinClip;或跨端框架,如Flutter、Weex等),将已有微信小程序一键转换成App,并进行用户活跃和留存,加上社交平台应用作为引流,企业可谓低成本(只需有小程序)的将业务覆盖用户整个生命周期,具了解,凡是小程序容器技术,都有将自己SDK适配鸿蒙操作系统的计划(或者说已经适配了)。


3天内开发一个鸿蒙App?


近期在研究FinClip的免费“小程序转换App”工具,结合他们新推出的鸿蒙SDK,发现还挺好用,大致步骤如下:



  1. 上传小程序代码包:如果已经有微信小程序应用,那么下载一下他们家的FinClip Studio(IDE开发工具)进行简单的转换。

  2. 使用微信登录插件:已经预先调试好的微信登录插件,非常方便,能够在转好的App中,通过一个中间转换的小程序调起微信登录接口,快速跑通业务。

  3. 生成App前必要的配置:如App图标、启动闪屏、权限配置等

  4. 生成App:配置一下对应IOS或Andriod的证书,然后「一键」生成App


实操下来,这个工具还是挺方便的。当然,其他跨端转换框架应该也是操作便捷,感兴趣的同学都可以试试。



将小程序转换为App,如果小程序容器技术支持鸿蒙NEXT版本,那么,使用已有小程序+转换App的功能,便能快速开发出一套适配兼容鸿蒙NEXT操作系统的App。



小程序转鸿蒙原生app的创新开发方式,为开发者提供了快速、便捷的开发途径,助力开发者高效地将小程序业务迁移至鸿蒙生态,同时也为用户提供了更加丰富、流畅的应用体验。展望未来,随着技术的不断发展和完善,相信将会有更多创新的开发模式涌现,为开发者和用户带来更加便利、高效的开发和使用体验。


作者:Speedoooo
来源:juejin.cn/post/7379521155286843404
收起阅读 »

实现 Springboot 程序加密,禁止 jadx 反编译

背景 toB 的本地化 java 应用程序,通常是部署在客户机器上,为了保护知识产权,我们需要将核心代码(例如 Lience,Billing,Pay 等)进行加密或混淆,防止使用 jadx 等工具轻易反编译。同时,为了更深层的保护程序,也要防止三方依赖细节被窥...
继续阅读 »

背景


toB 的本地化 java 应用程序,通常是部署在客户机器上,为了保护知识产权,我们需要将核心代码(例如 Lience,Billing,Pay 等)进行加密或混淆,防止使用 jadx 等工具轻易反编译。同时,为了更深层的保护程序,也要防止三方依赖细节被窥探;


业界方案



  1. ProGuard

    • 简介:开源社区有名的免费混淆工具,相较于字节码加密,对性能基本无影响;

    • 优势:打包阶段混淆字节码,各种变量方法名都变成了abcdefg 等等无意义的符号,字节码可被反编译,但几乎无法阅读,通常被 Android App 用来防止逆向;

    • 不足1:只能混淆部分代码,打包阶段较为耗时,对于三方包混淆,并没有什么好办法。

    • 不足2:混淆后的代码,会影响 arthas 工具的使用,导致排查问题变慢。

    • 不足3:配置比较复杂,曾经在我司 T 项目上用过,令人眼花缭乱。

    • 不足4:无法加密三方依赖所有信息;



  2. jar-protect

    • 简介:一款国人开发的 springboot jar 加密工具;需要配合 javaagent 解密;

    • 优势:打包阶段使用 javassist 重写 class 文件;jadx 反编译后看到的都是空方法。反编译后只能看到类信息和方法签名,无法看到具体内容。

    • 不足1:使用 DES 方案,对于几百个三方 jar 的场景,加密手段过重,且加密后的不够完整;

    • 不足2:类文件放在一个目录(META-INF/.encode/),非常容易类冲突;

    • 不足3:无法加密三方依赖所有信息;



  3. GraalVM

    • 简介:Oracle GraalVM 提前将 Java 应用程序编译为独立的二进制文件。与在 Java 虚拟机 (JVM) 上运行的应用程序相比,这些二进制文件更小,启动速度提高了 100 倍,无需预热即可提供峰值性能,并且使用的内存和 CPU 更少, 并且无法反编译。

    • 不足:无法支持我司业务程序框架。



  4. core-lib/xjar

    • 简介:国人开源的,基于 golang 的加密工具。使用 maven 插件加密,启动时 golang 解密;性能影响未知。

    • 优势:可对所有 class 文件加密。

    • 不足1: 加密后 jar 文件体积翻倍;

    • 不足2:依赖 golang 编译,依赖 golang 启动;

    • 不足3:无法加密三方依赖所有信息;

    • 不足4:开源项目,3年未有新提交。




思考:


我们的需求到底是什么?a:保护知识产权。具体手段为:



  1. 对本司项目代码进行加密,使其无法被 jadx 工具轻易反编译,

  2. 对本司三方依赖进行加密,使其无法窥探我司三方依赖细节;


但上面的几个项目,基本都是围绕着 class 加密(除了GraalVM),这无法实现我们的第二个需求。


我们的方案


设计目标:



  1. 将项目三方依赖 jar 进行加密,使其无法使用 jadx 反编译,但运行时会生成解密后的临时文件。

  2. 将项目本身的 class 进行加密,使其无法使用 jadx 反编译运行时解密后的文件。

  3. 加密策略要灵活,轻量,对启动速度,包体积,内存消耗,接口性能的影响要控制在 5% 以内;


设计方案:



  1. 加密jar时,使用 maven 打包工具,repackage fat jar;将其内部 lib 目录的依赖进行加密;使 jadx 无法反编译;

  2. 加密class时,对于核心业务代码,使用 javassist 工具将其重写,清空方法体,重置属性值;

  3. 解密jar时,将指定目录的 加密包 解密 到指定目录,并将其放入 springboot classloader classpath 里。

  4. 解密class时,agent 配合判断是否是加密 class,如果是,则寻找加密 class 文件,找到后解密,返回解密后的 classBytes。


逻辑如下:



注意点:



  1. javassist 重写方法体时,需要将 lib 里的所有代码都加入 classpool 的 classpath 里。

  2. javassist 加密后的类,需将其放入到当前 lib 的单独目录进行个例,防止类冲突。

  3. agent 解密要轻量,不能影响程序性能;

  4. 三方包的加解密重新打包后,jar 顺序发生变化,较小可能会导致类冲突(比如 log4j)。需要在测试环境验证,如果存在冲突,则需要排包。


End


通过以上方案,我们实现了一个极其轻量的 maven 加密,agent 解密插件。他能够将三方包彻底加密,使 jadx 等工具无法反编译 ,屏蔽我们的三方依赖细节,同时,该插件也可以加密我们的业务 class 代码,使 jadx 无法反编译运行时生成的代码,从而一定程度的保护我们的知识产权;


另外,私有的加密算法,在性能,体积,内存等方便的影响都控制在 5% 以内。


为了防止混淆后的代码影响 arthas 的使用和 bug patch 的应用,我们放弃了混淆方案,只能说是一种权衡与取舍吧。


从软件防破解的角度来理解,通常只能是加大破解的难度,铁了心想要破解的话,就算是 ProGuard 混淆,也无法解决。也许只能用 GraalVM,但不是每一个客户都会用这个。


推荐


Java 扩展点/插件系统,支持热插拔,旨在解决大部分软件的功能定制问题


作者:莫那鲁道
来源:juejin.cn/post/7289661061984469051
收起阅读 »

应用容器化后为什么性能下降这么多?

1. 背景 随着越来越多的公司拥抱云原生,从原先的单体应用演变为微服务,应用的部署方式也从虚机变为容器化,容器编排组件k8s也成为大多数公司的标配。然而在容器化以后,我们发现应用的性能比原先在虚拟机上表现更差,这是为什么呢?。 2. 压测结果 2.1 容器化之...
继续阅读 »

1. 背景


随着越来越多的公司拥抱云原生,从原先的单体应用演变为微服务,应用的部署方式也从虚机变为容器化,容器编排组件k8s也成为大多数公司的标配。然而在容器化以后,我们发现应用的性能比原先在虚拟机上表现更差,这是为什么呢?。


2. 压测结果


2.1 容器化之前的表现


应用部署在虚拟机下,我们使用wrk工具进行压测,压测结果如下:


image.png


从压测结果看,平均RT1.68msqps716/s\color{red}{平均RT为1.68ms,qps为716/s},我们再来看下机器的资源使用情况,cpu基本已经被打满。
image.png


2.2 容器化后的表现


使用wrk工具进行压测,结果如下:
image.png


从压测结果看,平均RT2.11msqps554/s\color{red}{平均RT为2.11ms,qps为554/s},我们再来看下机器的资源使用情况,cpu基本已经被打满。
image.png


2.3 性能对比结果


性能对比虚拟机容器
RT1.68ms2.11ms
QPS716/s554/s


总体性能下降:RT(25%)、QPS(29%)



3. 原因分析


3.1 架构差异


由于应用在容器化后整体架构的不同、访问路径的不同,将可能导致应用容器化后性能的下降,于是我们先来分析下两者架构的区别。我们使用k8s作为容器编排基础设施,网络插件使用calico的ipip模式,整体架构如下所示。


x3.png


这里需要说明,虽然使用calico的ipip模式,由于pod的访问为service的nodePort模式,所以不会走tunl0网卡,而是从eth0经过iptables后,通过路由到calico的calixxx接口,最后到pod。


3.2性能分析


在上面压测结果的图中,我们容器化后,cpu的软中断si使用率明显高于原先虚拟机的si使用率,所以我们使用perf继续分析下热点函数。


image.png
为了进一步验证是否是软中断的影响,我们使用perf进一步统计软中断的次数。


image.png



我们发现容器化后比原先软中断多了14%,到这里,我们能基本得出结论,应用容器化以后,需要更多的软中断的网络通信导致了性能的下降。



3.3 软中断原因


由于容器化后,容器和宿主机在不同的网络namespace,数据需要在容器的namespace和host namespace之间相互通信,使得不同namespace的两个虚拟设备相互通信的一对设备为veth pair,可以使用ip link命令创建,对应上面架构图中红色框内的两个设备,也就是calico创建的calixxx和容器内的eth0。我们再来看下veth设备发送数据的过程


static netdev_tx_t veth_xmit(struct sk_buff *skb, struct net_device *dev)
{
...
if (likely(veth_forward_skb(rcv, skb, rq, rcv_xdp)
...
}

static int veth_forward_skb(struct net_device *dev, struct sk_buff *skb,
struct veth_rq *rq, bool xdp)
{
return __dev_forward_skb(dev, skb) ?: xdp ?
veth_xdp_rx(rq, skb) :
netif_rx(skb);//中断处理
}


/* Called with irq disabled */
static inline void ____napi_schedule(struct softnet_data *sd,
struct napi_struct *napi)
{
list_add_tail(&napi->poll_list, &sd->poll_list);
//发起软中断
__raise_softirq_irqoff(NET_RX_SOFTIRQ);
}

通过虚拟的veth发送数据和真实的物理接口没有区别,都需要完整的走一遍内核协议栈,从代码分析调用链路为veth_xmit -> veth_forward_skb -> netif_rx -> __raise_softirq_irqoff,veth的数据发送接收最后会使用软中断的方式,这也刚好解释了容器化以后为什么会有更多的软中断,也找到了性能下降的原因。


4. 优化策略


原来我们使用calico的ipip模式,它是一种overlay的网络方案,容器和宿主机之间通过veth pair进行通信存在性能损耗,虽然calico可以通过BGP,在三层通过路由的方式实现underlay的网络通信,但还是不能避免veth pari带来的性能损耗,针对性能敏感的应用,那么有没有其他underly的网络方案来保障网络性能呢?那就是macvlan/ipvlan模式,我们以ipvlan为例稍微展开讲讲。


4.1 ipvlan L2 模式


IPvlan和传统Linux网桥隔离的技术方案有些区别,它直接使用linux以太网的接口或子接口相关联,这样使得整个发送路径变短,并且没有软中断的影响,从而性能更优。如下图所示:


ipvlan l2 mode


上图是ipvlan L2模式的通信模型,可以看出container直接使用host eth0发送数据,可以有效减小发送路径,提升发送性能。


4.2 ipvlan L3 模式


ipvlan L3模式,宿主机充当路由器的角色,实现容器跨网段的访问,如下图所示:


ipvlan L3 mode


4.3 Cilium


除了使用macvlan/ipvlan提升网络性能外,我们还可以使用Cilium来提升性能,Cilium为云原生提供了网络、可观测性、网络安全等解决方案,同时它是一个高性能的网络CNI插件,高性能的原因是优化了数据发送的路径,减少了iptables开销,如下图所示:


cilium netwok


虽然calico也支持ebpf,但是通过benchmark的对比,Cilium性能更好,高性能名副其实,接下来我们来看看官网公布的一些benchmark的数据,我们只取其中一部分来分析,如下图:


xxxx2
xxxx3


无论从QPS和CPU使用率上Cilium都拥有更强的性能。


5. 总结


容器化带来了敏捷、效率、资源利用率的提升、环境的一致性等等优点的同时,也使得整体的系统复杂度提升一个等级,特别是网络问题,容器化使得整个数据发送路径变长,排查难度增大。不过现在很多网络插件也提供了很多可观测性的能力,帮助我们定位问题。


我们还是需要从实际业务场景出发,针对容器化后性能、安全、问题排查难度增大等问题,通过优化架构,增强基础设施建设才能让我们在云原生的路上越走越远。


最后,感谢大家观看,也希望和我讨论云原生过程中遇到的问题。


5. 参考资料


docs.docker.com/network/dri…


cilium.io/blog/2021/0…


作者:云之舞者
来源:juejin.cn/post/7268663683881828413
收起阅读 »

SpringBoot获取不到用户真实IP怎么办

今天周六,Binvin来总结一下上周开发过程中遇到的一个小问题,项目部署后发现服务端无法获取到客户端真实的IP地址,这是怎么回事呢?给我都整懵逼了,经过短暂的思考,我发现了问题的真凶,那就是我们使用了Nginx作的请求转发,这才导致了获取不到客户端真实的IP地...
继续阅读 »

今天周六,Binvin来总结一下上周开发过程中遇到的一个小问题,项目部署后发现服务端无法获取到客户端真实的IP地址,这是怎么回事呢?给我都整懵逼了,经过短暂的思考,我发现了问题的真凶,那就是我们使用了Nginx作的请求转发,这才导致了获取不到客户端真实的IP地址,害,看看我是怎么解决的吧!


a00e8833034583c6895e1582c899a2f3.png


问题原因


客户端请求数据时走的是Nginx反向代理,默认情况下客户端的真实IP地址会被其过滤,使得SpringBoot程序无法直接获得真实的客户端IP地址,获取到的都是Nginx的IP地址。


解决方案:


通过更改Nginx配置文件将客户端真实的IP地址加到请求头中,这样就能正常获取到客户端的IP地址了,下面我一步步带你看看如何配置和获取。


修改Nginx配置文件


在需要做请求转发的配置里添加下面的配置


#这个参数设置了HTTP请求头的Host字段,host表示请求的Host头,也就是请求的域名。通过这个设置,Nginx会将请求的Host头信息传递给后端服务。
proxy_set_header Host $host;
#这个参数设置了HTTP请求头的X−Real−IP字段,remote_addr表示客户端的IP地址。通过这个设置,Nginx会将客户端的真实IP地址传递给后端服务
proxy_set_header X-Real-IP $remote_addr;
#这个参数设置了HTTP请求头的 X-Forwarded-For字段,"X-Forwarded-For"是一个标准的HTTP请求头,用于表示HTTP请求经过的代理服务器链路信息,proxy_add_x_forwarded_for表示添加额外的服务器链路信息。
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

修改后我的nginx.conf中的server如下所示


server {
listen 443 ssl;
server_name xxx.com;

ssl_certificate "ssl证书pem文件";
ssl_certificate_key "ssl证书key文件";
ssl_session_timeout 5m;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:HIGH:!aNULL:!MD5:!RC4:!DHE;
ssl_prefer_server_ciphers on;

location / {
root 前端html文件目录;
index index.html index.htm;
}

error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
# 关键在下面这个配置,上面的配置自己根据情况而定就行
location /hello{
proxy_pass http://127.0.0.1:8090;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}

SpringBoot代码实现


第一种方式:在代码中直接通过X-Forwarded-For获取到真实IP地址


@Slf4j
public class CommonUtil {
/**
* <p> 获取当前请求客户端的IP地址 </p>
*
* @param request 请求信息
* @return ip地址
**/

public static String getIp(HttpServletRequest request) {
if (request == null) {
return null;
}
String unknown = "unknown";
// 使用X-Forwarded-For就能获取到客户端真实IP地址
String ip = request.getHeader("X-Forwarded-For");
log.info("X-Forwarded-For:" + ip);
if (ip == null || ip.length() == 0 || unknown.equalsIgnoreCase(ip)) {
ip = request.getHeader("Proxy-Client-IP");
log.info("Proxy-Client-IP:" + ip);
}
if (ip == null || ip.length() == 0 || unknown.equalsIgnoreCase(ip)) {
ip = request.getHeader("WL-Proxy-Client-IP");
log.info("WL-Proxy-Client-IP:" + ip);
}
if (ip == null || ip.length() == 0 || unknown.equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_X_FORWARDED_FOR");
log.info("HTTP_X_FORWARDED_FOR:" + ip);
}
if (ip == null || ip.length() == 0 || unknown.equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_X_FORWARDED");
log.info("HTTP_X_FORWARDED:" + ip);
}
if (ip == null || ip.length() == 0 || unknown.equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_X_CLUSTER_CLIENT_IP");
log.info("HTTP_X_CLUSTER_CLIENT_IP:" + ip);
}
if (ip == null || ip.length() == 0 || unknown.equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_CLIENT_IP");
log.info("HTTP_CLIENT_IP:" + ip);
}
if (ip == null || ip.length() == 0 || unknown.equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_FORWARDED_FOR");
log.info("HTTP_FORWARDED_FOR:" + ip);
}
if (ip == null || ip.length() == 0 || unknown.equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_FORWARDED");
log.info("HTTP_FORWARDED:" + ip);
}
if (ip == null || ip.length() == 0 || unknown.equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_VIA");
log.info("HTTP_VIA:" + ip);
}
if (ip == null || ip.length() == 0 || unknown.equalsIgnoreCase(ip)) {
ip = request.getHeader("REMOTE_ADDR");
log.info("REMOTE_ADDR:" + ip);
}
if (ip == null || ip.length() == 0 || unknown.equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
log.info("getRemoteAddr:" + ip);
}
return ip;
}

第二种方式:在application.yml文件中加以下配置,直接通过request.getRemoteAddr()并可以获取到真实IP


server:
port: 8090
tomcat:
#Nginx转发 获取客户端真实IP配置
remoteip:
remote-ip-header: X-Real-IP
protocol-header: X-Forwarded-Proto

作者:rollswang
来源:juejin.cn/post/7266040474321027124
收起阅读 »

小知识分享:控制层尽量别暴露这样的接口,避免横向越权。

前言 谈不上是多么厉害的知识,但可能确实有人不清楚或没见过。 我还是分享一下,就当一个小知识点。 如果知道的,就随便逛逛,不知道的,Get到了记得顺手点个赞哈。 正文 1、接口别随便暴露 当一个项目的维护周期拉长的时候,不断有新增的需求,如果经手的人也越...
继续阅读 »

前言



谈不上是多么厉害的知识,但可能确实有人不清楚或没见过。


我还是分享一下,就当一个小知识点。


如果知道的,就随便逛逛,不知道的,Get到了记得顺手点个赞哈。



正文


1、接口别随便暴露



当一个项目的维护周期拉长的时候,不断有新增的需求,如果经手的人也越来越多,接口是会肉眼可见增多的。




此时,如果一个团队没有良好的规范和代码审查机制,就会导致许多不安全的接口被暴露出来。




比如下面这种接口:



/**
* 根据ID查询患者信息
*/

@GetMapping("/{id}")
public AjaxResult getById(@PathVariable("id") Long id) {
PersonInfo personInfo = personInfoService.selectPersonInfoById(id);
return AjaxResult.success(personInfo);
}



这种接口是我们部门以前审查出来的其中一个,类似这样的接口还有很多。




这些接口都是不同的同事在紧凑的工作任务中写的,慢慢就积累出了一堆。




还有些是为了方便,直接通过代码生成器生成的,而代码生成器是把常用的CRUD接口都给你生成出来,如果研发人员没有责任心,可能就直接不管了,想着以后哪一天也许会用上呢。




别以为这种想法的人少啊,你整个职业生涯很可能就会遇见。




这就导致了,一堆用不上又不安全的接口出现了。




服务过政务机构、企事业单位、医疗等行业的工程师应该就知道,这些单位对于安全性的要求其实挺高的,尤其是这些年,会找专门的信息安全公司做攻防演练。




最近两年,很多省市甚至会自发组织全市的信息安全攻防演练,在当前大环境下这也是符合国情的。




而攻防演练的目的之一就是找系统安全漏洞,这里面就会有一个我本章要讲的典型漏洞,接口的横向越权。



2、什么是横向越权



广义的解释就是,该越权行为允许用户获取他们正常情况下无权访问的信息或执行操作。




如果纯粹从理论上理解,是很抽象的,所以我才把这个案例捞出来,让你一次就懂。




我们再回过头看看上面我贴出来的那段很正常的代码,就是根据id获取用户信息,你一定曾经在一些项目中见过这种接口,提供给前端直接调用,比如用户详情、订单详情,只要是和详情有关的,很可能前端会需要这么一个接口。




那么,问题在这里,我们的id是不是有规则的呢?比如下面这样:



1.jpg



可以看出来,id是自增的,增量是2。其实很多中小企业现在用MySQL都喜欢这样设置自增id,有些会设置增量,有些干脆就默认。




试想一下,我如果知道了id=865的用户信息,我也知道大部分中小企业喜欢用自增id,是不是就等于知道了1-1000000的用户信息,而用户信息可能包含身-份-证、手机号、详细住址等非常敏感的内容。




这就是典型的横向越权之一,我明明只应该拿到id=865这个用户的信息,但是通过非正常的方式,我暴力获取了其他100万个用户信息。




一旦真的发生这样的事故,不管最终结果如何,这家公司基本上就进黑名单了,从此在行业中消失。



3、权限控制不了吗



一定会有人产生疑惑,SpringBoot接口怎么可能直接放出来,一定都是有权限控制的,没有权限是根本不可能访问到的。




我打个比方,如果是后台管理这种,他是有登录的,登录后会产生token,token中是可以包含角色权限的,那么这种是没有问题的。




但如果没有登录操作呢,比如小程序这种,你打开就直接是首页各种信息,前端调接口很可能传递的只有网关层的token,又该如何呢。




尤其是小程序雨后春笋一般涌现的那几年,我曾经打开过很多小程序,都是没什么权限校验的,就是直接点来点去。




直到近几年,这种现象才慢慢消失,很多小程序打开后,会提示你授权登录,比如微信小程序,你一定遇到过打开小程序后让你授权登录的场景,如果不授权登录,你绝对做不了很多操作,这是很多互联网企业的安全意识都加强的结果。




我所在的公司早年刚进入医疗行业就经历过这种事情,为了占坑拿下了很多项目,但缺乏安全意识和管理规范,程序员也是来来走走,你写两个我写两个,导致不少接口都存在安全隐患。




直到被攻防演练攻破,甲方下发整改通知,还要我们写事故报告、原因、解决方案等等一大堆,我们才慌了。




连夜开会讨论出一套基本的安全整改思路,然后开始加班加点做安全改造。




我印象最深的就是其中这个接口横向越权,只传递了网关层的token,而没有细化到个人的权限控制,导致被信息安全公司通过抓包等一些我不了解的技术把token拿到了,然后直接横向获取到了很多用户敏感信息。




当时这个事情闹得很厉害,考虑到只是攻防演练,同时客户方对公司还保留信任,才只要求我们限期整改,否则就直接替换了。




所以,记得以后写接口的时候别只考虑业务逻辑,安全性也是考量之一。



4、如何防范



防范的方式,我归纳了这么几点:


1、不用的接口尽量删掉,这样也避免了多余接口埋下的安全隐患;


2、团队要有安全规范,比如敏感字段加密,引入代码审查机制,缩小安全隐患出现的范围;


3、带登录的终端,除了网关层校验,要精确控制登录用户的角色及权限;


4、不带登录的终端,除了网关层校验,要根据用户的唯一信息,来做授权登录,授权不成功不允许做其他操作,这也是现在比较流行的方式。


我个人理解,第4点和第3点本质一样,因为不带登录,所以要想办法制造登录,而目前比较友好的方式还是一键授权登录,不管是根据openid、手机号等等,总之要找到一个规则,这样省去了用户手动操作登录的时间。




总之,一定要控制用户只能看到属于自己的内容,避免横向越权。



总结



如果写的不好,还望大家原谅,只是分享了曾经工作中发生过的和安全改造有关的事情。


现在的程序员其实了解和接收的知识技术是挺多的,许多人其实都知道这些。


希望不知道的人,能够因为我的文章得到一点点帮助。


最后,大家其实可以去试一试,打开微信小程序,搜索下你们所在城市的某某中心医院,看看这样的医疗小程序打开后是什么样的,是不是有授权登录,或者其他方式来控制权限,搞不好一部分人能遇到有意思的事情。


作者:程序员济癫
来源:juejin.cn/post/7276467933235642405
收起阅读 »

分分钟带你实现视频消息的在线播放和本地播放|干货教程

发送视频消息是即时通讯应用中很常见的功能,现在的视频播放场景五花八门,眼瞅快下班,接到产品需求前提条件:实现方法:1、调用下载,下载成功后进行播放处理;EMMessage msg = EMClient.getInstance().chatManager().g...
继续阅读 »

有种需求叫"下班前实现"

发送视频消息是即时通讯应用中很常见的功能,现在的视频播放场景五花八门,眼瞅快下班,接到产品需求



如何快速实现这个需求,好准点下班回家抢显卡 ,好提升自己的工作效率,你只要熟读本篇文章,分分钟带你实现!

前提条件:

  • 完成环信IM SDK的初始化
    (没完成的参考文档:
    SDK初始化

  • 实现发送视频消息和接收视频消息
    (没实现的参考文档:
    发送和接收消息

实现方法:

一、本地播放实现方法

1、调用

EMChatManager#downloadAttachment

下载,下载成功后进行播放处理;

EMMessage msg = EMClient.getInstance().chatManager().getMessage(msgId);
EMCallBack callback = new EMCallBack() {
public void onSuccess() {
EMLog.e(TAG, "onSuccess" )
//下载成功,进行播放处理
}

public void onError(final int error, String message) {
EMLog.e(TAG, "offline file transfer error:" + message);
}

public void onProgress(final int progress, String status) {
EMLog.d(TAG, "Progress: " + progress);
}
};
msg.setMessageStatusCallback(callback);
EMClient.getInstance().chatManager().downloadAttachment(msg);

2、如果对本地存储的路径有特殊要求:

1)可以先通过

EMFileMessageBody#setlocalUrl

去修改路径;

2)再调用

EMChatManager#downloadAttachment

下载(下载操作可以参考上面);


二.在线播放实现方法

1、在接收消息监听onMessageReceived里,接收到视频消息;

EMMessageListener msgListener = new EMMessageListener() {
// 收到消息,遍历消息队列,解析和显示。
@Override
public void onMessageReceived(List<EMMessage> messages) {

}
};
// 注册消息监听
EMClient.getInstance().chatManager().addMessageListener(msgListener);
// 解注册消息监听
EMClient.getInstance().chatManager().removeMessageListener(msgListener);

2、拿到

EMVideoMessageBody#getRemoteUrl

拿到消息的远程服务器存储地址

// 从服务器端获取视频文件。
String imgRemoteUrl = ((EMVideoMessageBody) body).getRemoteUrl();

3、用第三方或者是VideoView进行播放视频(例子里使用的VideoView);

vidw = (VideoView) findViewById(R.id.viewview);
vidw.setVideoPath(imgRemoteUrl+"?em-redirect=true");
vidw.setOnErrorListener(new MediaPlayer.OnErrorListener() {
@Override
public boolean onError(MediaPlayer mp, int what, int extra) {
return true;
}
});
vidw.start();

按照上面的步骤测试,发现还是不能在线播放的朋友,就需要看下,你使用的Appkey需要联系环信商务经理免费开通在线播放功能

另需注意:imgRemoteUrl 需要拼接 ?em-redirect=true,否则也会出现播放不了的情况;

大功告成!快把代码推上去,回家抢显卡!

此教程适用于Android端,其他端兄弟们报一丝,下次一定!

相关文档:

收起阅读 »

很容易中招的一种索引失效场景,一定要小心

快过年,我的线上发布出现故障 “五哥,你在上线吗?”,旁边有一个声音传来。 “啊,怎么了?”。真是要命,在上线发布时候,我最讨厌别人叫我的名字 。我慌忙站起来,看向身后,原来是 建哥在问我。我慌忙的问,怎么回事。 “DBA 刚才在群里说,Task数据库 cpu...
继续阅读 »

快过年,我的线上发布出现故障


“五哥,你在上线吗?”,旁边有一个声音传来。


“啊,怎么了?”。真是要命,在上线发布时候,我最讨厌别人叫我的名字 。我慌忙站起来,看向身后,原来是 建哥在问我。我慌忙的问,怎么回事。


“DBA 刚才在群里说,Task数据库 cpu 负载增加!有大量慢查询”,建哥来我身边,跟我说。


慢慢的,我身边聚集着越来越多的人


image.png


“你在上线Task服务吗?改动什么内容了,看看要不要立即回滚?”旁边传来声音。此时,我的心开始怦怦乱跳,手心发痒,紧张不已。


我检查着线上机器的日志,试图证明报警的原因不是出在我这里。


我对着电脑,微微颤抖地回答大家:“我只是升级了基础架构的Jar包,其他内容没有改动啊。”此时我已分不清是谁在跟我说话,只能对着电脑作答……


这时DBA在群里发送了一条SQL,他说这条SQL导致了大量的慢查询。


我突然记起来了,我转过头问林哥:“林哥,你上线了什么内容?”这次林哥有代码的变更,跟我一起上线。我觉得可能是他那边有问题。


果然,林哥看着代码发呆。他嘟囔道:“我添加了索引啊,怎么会有慢查询呢?”原来慢查询的SQL是林哥刚刚添加的,这一刻我心里的石头放下了,问题并不在我,我轻松了许多。


“那我先回滚吧”,幸好我们刚发布了一半,现在回滚还来得及,我尝试回滚机器。此刻我的紧张情绪稍稍平静下来,手也不再发抖。


既然不是我的问题,我可以以吃瓜的心态,暗中观察事态的发展。我心想:真是吓死我了,幸好不是我的错。


然而我也有一些小抱怨:为什么非要和我一起搭车上线,出了事故,还得把我拖进来。


故障发生前的半小时


2年前除夕前的一周,我正准备着过年前的最后一次线上发布,这时候我刚入职两个月,自然而然会被分配一些简单的小活。这次上线的内容是将基础架构的Jar包升级到新版本。一般情况下,这种配套升级工作不会出问题,只需要按部就班上线就行。


“五哥,你是要上线 Task服务吗?”,工位旁的林哥问我,当时我正做着上线前的准备工作。


“对啊,马上要发布,怎么了?”,我转身回复他。


“我这有一个代码变更,跟你搭车一起上线吧,改动内容不太多。已经测试验证过了”,林哥说着,把代码变更内容发给我,简单和我说了下代码变更的内容。我看着改动内容确实不太多,新增了一个SQL查询,于是便答应下来。我重新打包,准备发布上线。


半小时以后,便出现了文章开头的情景。新增加的SQL 导致大量慢查询,数据库险些被打挂。


为什么加了索引,还会出现慢查询呢?


”加了索引,为什么还有慢查询?“,这是大家共同的疑问。


事后分析故障的原因,通过 mysql explain 命令,查看该SQL 确实没有命中索引,从而导致慢查询。


这个SQL 大概长这个样子!我去掉了业务相关的部分。


select * from order_discount_detail where orderId = 1123;


order_discount_detailorderId 这一列上确实加了索引,不应该出现慢查询,乍一看,没有什么问题。我本能的想到了索引失效的几种场景。难道是类型不匹配,导致索引失效?


果不其然, orderId 在数据库中的类型 是 varchar 类型,而传参是按照 long 类型传的。


复习一下: 类型转换导致索引失效


类型转换导致索引失效,是很容易犯的错误


因为在某些特殊场景下要对接外部订单,存在订单Id为字符串的情况,所以 orderId被设计成 varchar 字符串类型。然而出问题的场景比较明确,订单id 就是long类型,不可能是字符串类型。


所以林哥,他在使用Mybatis 时,直接使用 long 类型的 orderId字段传参,并且没有意识到两者数据类型不对。


因为测试环境数据量比较小,即使没有命中索引,也不会有很严重的慢查询,并且测试环境请求量比较低,该慢查询SQL 执行次数较少,所以对数据库压力不大,测试阶段一直没有发现性能问题。


直到代码发布到线上环境————数据量和访问量都非常高的环境,差点把数据库打挂。


mybatis 能避免 “类型转换导致索引失效” 的问题吗?


mybatis能自动识别数据库和Java类型不一致的情况吗?如果发现java类型和数据库类型不一致,自动把java 类型转换为数据库类型,就能避免索引失效的情况!


答案是不能。我没找到 mybatis 有这个能力。


mybatis 使用 #{} 占位符,会自动根据 参数的 Java 类型填充到 SQL中,同时可以避免SQL注入问题。


例如刚才的SQL 在 mybatis中这样写。


select * from order_discount_detail where orderId = #{orderId};


orderId 是 String 类型,SQL就变为


select * from order_discount_detail where orderId = ‘1123’;


mybatis 完全根据 传参的java类型,构建SQL,所以不要认为 mybatis帮你处理好java和数据库的类型差异问题,你需要自己关注这个问题!


再次提醒,"类型转换导致索引失效"的问题,非常容易踩坑。并且很难在测试环境发现性能问题,等到线上再发现问题就晚了,大家一定要小心!小心!


险些背锅


可能有朋友疑问,为什么发布一半时出现慢查询,单机发布阶段不能发现这个问题吗?


之所以没发现这个问题,是因为 新增SQL在 Kafka消费逻辑中,由于单机发布机器启动时没有争抢到 kafka 分片,所以没有走到新代码逻辑。


此外也没有遵循降级上线的代码规范,如果上线默认是降级状态,上线过程中就不会有问题。放量阶段可以通过降级开关快速止损,避免回滚机器过程缓慢而导致的长时间故障。


不是我的问题,为什么我也背了锅


因为我在发布阶段没有遵循规范,按照规定的流程应该在单机发布完成后进行引流压测。引流压测是指修改机器的Rpc权重,将Rpc请求集中到新发布的单机上,这样就能提前发现线上问题。


然而由于我偷懒,跳过了单机引流压测。由于发布的第一台机器没有抢占到Kafka分片,因此无法执行新代码逻辑。即使进行了单机引流压测,也无法提前发现故障。虽然如此,但我确实没有遵循发布规范,错在我。


如果上线时没有出现故障,这种不规范的上线流程可能不会受到责备。但如果出现问题,那只能怪我倒霉。在复盘过程中,我的领导抓住了这件事,给予了重点批评。作为刚入职的新人,被指责确实让我感到不舒服。


快要过年了,就因为搭车上线,自己也要承担别人犯错的后果,让我很难受。但是自己确实也有错,当时我的心情复杂而沉重。


两年前的事了,说出来让大家吃个瓜,乐呵一下。如果这瓜还行,东东发财的小手点个赞


作者:五阳
来源:juejin.cn/post/7305572311812636683
收起阅读 »

什么?你设计接口什么都不考虑?

后端接口设计 如果让你设计一个接口,你会考虑哪些问题? 1.接口参数校验 接口的入参和返回值都需要进行校验。 入参是否不能为空,入参的长度限制是多少,入参的格式限制,如邮箱格式限制 返回值是否为空,如果为空的时候是否返回默认值,这个默认值需要和前端协商 ...
继续阅读 »

后端接口设计


如果让你设计一个接口,你会考虑哪些问题?


image.png


1.接口参数校验


接口的入参和返回值都需要进行校验。



  • 入参是否不能为空,入参的长度限制是多少,入参的格式限制,如邮箱格式限制

  • 返回值是否为空,如果为空的时候是否返回默认值,这个默认值需要和前端协商


2.接口扩展性


举个例子,比如用户在进行某些操作之后,后端需要进行消息推送,那么是直接针对这个业务流程来开发一个专门为这个业务流程服务的消息推送功能呢?还是说将消息推送整合为一个通用的接口,其他流程都可以进行调用,并非针对特定业务。


这个场景可能光靠说不是很能理解,大家想想策略工厂设计模式,是不是可以根据不同的策略,来选择不同的实现方式呢?再结合上面的这个例子,是否对扩展性有了进一步的理解呢?


3.接口幂等设计


什么是幂等呢?幂等是指多次调用接口不会改变业务状态,可以保证重复调用的结果和单次调用的结果一致


举个例子,在购物商场里面你用手机下单,要买某个商品,你需要去支付,然后你点击了支付,但是因为网速问题,始终没有跳转到


支付界面,于是你又连点了几次支付,那在没有做接口幂等的时候,是不是你点击了多少次支付,我们就需要执行多少次支付操作?


所以接口幂等到的是什么?防止用户多次调用同一个接口



  • 对于查询和删除类型的接口,不论调用多少次,都是不会产生错误的业务逻辑和数据的,因此无需幂等处理

  • 对于新增和修改,例如转账等操作,重复提交就会导致多次转账,这是很严重的,影响业务的接口需要做接口幂等的处理,跟前端约定好一个固定的token接口,先通过用户的id获取全局的token,写入到Redis缓存,请求时带上Token,后端做处理


image.png


4.关键接口日志打印


关键的业务代码,是需要打印日志进行监测的,在入参和返回值或者如catch代码块中的位置进行日志打印



  • 方便排查和定位线上问题,划清责任

  • 生产环境是没有办法进行debug的,必须依靠日志查问题,看看到底是出现了什么异常情况


5.核心接口要进行线程池隔离


分类查询啊,首页数据等接口,都有可能使用到线程池,某些普通接口也可能会使用到线程池,如果不做线程池隔离,万一普通接口出现bug把线程池打满了,会导致你的主业务受到影响


image.png


6.第三方接口异常重试


如果有场景出现调用第三方接口,或者分布式远程服务的话,需要考虑的问题



  • 异常处理


    比如你在调用别人提供的接口的时候,如果出现异常了,是要进行重试还是直接就是当做失败


  • 请求超时


    有时候如果对方请求迟迟无响应,难道就一直等着吗?肯定不是这样的,需要设法预估对方接口响应时间,设置一个超时断开的机制,以保护接口,提高接口的可用性,举个例子,你去调用别人对外提供的一个接口,然后你去发http请求,始终响应不回来,此时你又没设置超时机制,最后响应方进程假死,请求一直占着线程不释放,拖垮线程池。


  • 重试机制


    如果调用对外的接口失败了或者超时了,是否需要重新尝试调用呢?还是失败了就直接返回失败的数据?



7.接口是否需要采用异步处理


举个例子,比如你实现一个用户注册的接口。用户注册成功时,发个邮件或者短信去通知用户。这个邮件或者发短信,就更适合异步处理。总不能一个通知类的失败,导致注册失败吧。 那我们如何进行异步操作呢?可以使用消息队列,就是用户注册成功后,生产者产生一个注册成功的消息,消费者拉到注册成功的消息,就发送通知。


image.png


8.接口查询优化,串行优化为并行


假设我们要开发一个网站的首页,我们设计了一个首页数据查询的接口,这个接口需要查用户信息,需要查头部信息,需要查新闻信息


等等之类的,最简单的就是一个一个接口串行调用,那要是想要提高性能,那就采取并行调用的方式,同时查询,而不是阻塞


可以使用CompletableFuture(推荐)或者FutureTask(不推荐)


        Map<Long, List<SubjectLabelBO>> map = new HashMap<>();
      List<CompletableFuture<Map<Long, List<SubjectLabelBO>>>> completableFutureList =
      categoryBOList.stream().map(category ->
              CompletableFuture.supplyAsync(() -> getLabelBOList(category), labelThreadPool)
      ).collect(Collectors.toList());

      completableFutureList.forEach(future -> {
          try {
              Map<Long, List<SubjectLabelBO>> resultMap = future.get(); //这里会阻塞
              map.putAll(resultMap);
          } catch (Exception e) {
              e.printStackTrace();
          }
      });
       
public Map<Long, List<SubjectLabelBO>> getLabelBOList(SubjectCategoryBO category) {...}

9.高频接口注意限流


自定义注解 + AOP


@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimiter {
   int value() default 1;
   int durationInSeconds() default 1;
}

@Aspect
@Component
public class RateLimiterAspect {

   private final ConcurrentHashMap<String, RateLimiter> rateLimiters = new ConcurrentHashMap<>();

   @Pointcut("@annotation(RateLimiter)")
   public void rateLimiterPointcut(RateLimiter rateLimiterAnnotation) {
  }

   @Around("rateLimiterPointcut(rateLimiterAnnotation)")
   public Object around(ProceedingJoinPoint joinPoint, RateLimiter rateLimiterAnnotation) throws Throwable {
       int permits = rateLimiterAnnotation.value();
       int durationInSeconds = rateLimiterAnnotation.durationInSeconds();

       // 使用方法签名作为 RateLimiter 的 key
       String key = joinPoint.getSignature().toLongString();
       com.google.common.util.concurrent.RateLimiter rateLimiter = rateLimiters.computeIfAbsent(key, k -> com.google.common.util.concurrent.RateLimiter.create((double) permits / durationInSeconds));

       // 尝试获取令牌,如果获取到则执行方法,否则抛出异常
       if (rateLimiter.tryAcquire()) {
           return joinPoint.proceed();
      } else {
           throw new RuntimeException("Rate limit exceeded.");
      }
  }
}

@RestController
public class ApiController {

   @GetMapping("/api/limited")
   @RateLimiter(value = 10, durationInSeconds = 60) //限制为每分钟 10 次请求
   public String limitedEndpoint() {
       return "This API has a rate limit of 10 requests per minute.";
  }

   @GetMapping("/api/unlimited")
   public String unlimitedEndpoint() {
       return "This API has no rate limit.";
  }
}

10.保障接口安全


配置黑白名单,用Bloom过滤器实现黑白名单的配置


具体代码不贴出来了,大家可以去看看布隆过滤器的具体使用


11.接口控制锁粒度


在高并发场景下,为了防止超卖等情况,我们会对共享资源进行加锁的操作来保证线程安全的问题,但是如果加锁的粒度过大,是会影响


到接口性能的。那什么是加锁粒度呢?举一个例子,你带了一封情书回家,但是不想被爸妈发现,然后你偷偷回到房间里放到一个可以锁


住的抽屉里面,而不用把房间的门锁给锁上。 无论是使用synchronized加锁还是redis分布式锁,只需要在共享临界资源加锁即可,不涉


及共享资源的,就不必要加锁。



  • 锁粒度过大:


    把方法A和方法B全部进行加锁,但是实际上我只是想要对A加锁,这就是锁粒度过大



void test(){
   synchronized (this) {
      B();
      A();
  }
}


  • 缩小锁粒度


void test(){
      B();
   synchronized (this) {
      A();
  }
}

12.避免长事务问题


长事务期间可能伴随cpu、内存升高、严重时会导致服务端整体响应缓慢,导致在线应用无法使用


产生长事务的原因除了sql本身可能存在问题外,和应用层的事务控制逻辑也有很大的关系。



  • 如何尽可能的避免长事务问题呢?


    1.RPC远程调用不要放到事务里面


    2.一些查询相关的操作如果可用,尽量放到事务外面


    3.并发场景下,尽量避免使用@Transactional注解来操作事务,使用TransactionTemplate的编排式事务来灵活控制事务的范围



在原先使用@Transactional来管理事务的时候是这样的


@Transactional
public int createUser(User user){
   //保存用户信息
   userDao.save(user);
   passCertDao.updateFlag(user.getPassId());
   // 该方法为远程RPC接口
   sendEmailRpc(user.getEmail());
   return user.getUserId();
}

使用TransactionTemplat进行编排式事务


@Resource
private TransactionTemplate transactionTemplate;

public int createUser(User user){
   transactionTemplate.execute(transactionStatus -> {
     try {
        userDao.save(user);
        passCertDao.updateFlag(user.getPassId());
    } catch (Exception e) {
        // 异常手动设置回滚
        transactionStatus.setRollbackOnly();
    }
     return true;
  });
// 该方法为远程RPC接口
   sendEmailRpc(user.getEmail());
   return user.getUserId();
}

作者:radient
来源:juejin.cn/post/7343548913034133523
收起阅读 »

如何给application.yml文件的敏感信息加密?

Hello,大家好,我是云帆。在我们传统的基于SpringBoot开发的项目中,在配置文件里,或多或少的都会有一些敏感信息,这样就会丢失一定的安全性,所以我们就需要,对敏感信息进行加密。我们可以使用jasypt工具进行加密。 好了废话不多少,直接进入正题: 1...
继续阅读 »

Hello,大家好,我是云帆。在我们传统的基于SpringBoot开发的项目中,在配置文件里,或多或少的都会有一些敏感信息,这样就会丢失一定的安全性,所以我们就需要,对敏感信息进行加密。我们可以使用jasypt工具进行加密。


好了废话不多少,直接进入正题:


1. 导入依赖


<dependency>  
<groupId>com.github.ulisesbocchio</groupId>
<artifactId>jasypt-spring-boot-starter</artifactId>
<version>3.0.5</version>
</dependency>

我的Demo里使用的是SpringBoot3.0之后的版本,所以大家如果像我一样都是基于SpringBoot3.0之后的,jasypt一定要使用3.0.5以后的版本。


2. 使用jasypt


我们在配置文件里写几行配置


jasypt:  
encryptor:
password: sdjsdbshdbfuasd
property:
prefix: ENC(
suffix: )


password是加密密码,必须配置这一项,值可以随便输入。
prefixsuffix是默认配置,也可以自定义,默认值就是ENC(),这个是自动解密使用的。



2.1. 加/解密


jasypt 提供了一个工具类接口,StringEncryptor,这个接口提供了加解密方法。下面是他的源码。


public interface StringEncryptor {  

/**
* 加密输入信息
*
* @param 要加密的信息
* @return 加密结果
*/

public String encrypt(String message);


/**
* 解密加密信息
*
* @param 加密信息(encryptedMessage) 要解密的加密信息
* @return 解密结果
*/

public String decrypt(String encryptedMessage);

}

我们在 test 测试类中,将要进行加密的文本使用encrypt方法进行加密


@SpringBootTest  
@Slf4j
class JasryptApplicationTests {

@Autowired
private StringEncryptor stringEncryptor;

@Test
void contextLoads() {
String username = stringEncryptor.encrypt("root");
String password = stringEncryptor.encrypt("root");
log.info("username encrypt is {}", username);
log.info("password encrypt is {}", password);
log.info("username decrypt is {}", stringEncryptor.decrypt(username));
log.info("password decrypt is {}", stringEncryptor.decrypt(password));
}

}

上边代码,加密的内容是,MySQL的用户名密码,同时对它们进行加密和解密,你当然可以对任意配置信息进行加解密操作。看看输出内容:


2023-07-23T18:59:50.621+08:00  INFO 9489 --- [           main] c.e.jasrypt.JasryptApplicationTests      : username encrypt is 61zSoixtNayUruXt5x84kEKO9jGnZObTGCa1+k5Yg9F7qSUiZvp5fG31AMuVqrot
2023-07-23T18:59:50.621+08:00 INFO 9489 --- [ main] c.e.jasrypt.JasryptApplicationTests : password encrypt is a6snCZCkbQFKkQqxN2bS18ags04yZxH+THwIL5RjGocEjG9sLkJvvasPFFVxEBWv
2023-07-23T18:59:50.623+08:00 INFO 9489 --- [ main] c.e.jasrypt.JasryptApplicationTests : username decrypt is root
2023-07-23T18:59:50.630+08:00 INFO 9489 --- [ main] c.e.jasrypt.JasryptApplicationTests : password decrypt is root

加密默认使用的是PBEWITHHMACSHA512ANDAES_256加密
我们将密文,替换到数据源,配置:


spring:  
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/honey?useUnicode=true&zeroDateTimeBehavior=convertToNull&autoReconnect=true&characterEncoding=utf-8
username: ENC(61zSoixtNayUruXt5x84kEKO9jGnZObTGCa1+k5Yg9F7qSUiZvp5fG31AMuVqrot)
password: ENC(a6snCZCkbQFKkQqxN2bS18ags04yZxH+THwIL5RjGocEjG9sLkJvvasPFFVxEBWv)

⚠️注意别忘了加上前缀和后缀,如上边代码。


这个时候就已经完成了,但是官方不建议我们将加密密码放到配置文件中,我们应作为系统属性、命令行参数或环境变量传递,只要其名称是 jasypt.encryptor.password,就能正常工作。


我们可以将项目打为jar包然后使用 java -jar命令


java -jar jasrypt-0.0.1-SNAPSHOT.jar --jasypt.encryptor.password=加密密码

⚠️加密密码必须与之前给属性加密时用的加密密码一致。


3. 结尾


好了,分享到这里就结束了,希望小伙伴们多多点赞,如果有建议,欢迎留言。


此致


作者:寒江雪369
来源:juejin.cn/post/7258850748149203000
收起阅读 »

我使用 GPT-4o 帮我挑西瓜

hi,这里是小榆。在 5 月 15 日,OpenAI 旗下的大模型 GPT-4o 已经发布,那时网络上已经传开, 但很多小伙伴始终没有看到 GPT-4o 的体验选项。 在周五的时候,我组建的 ChatGPT 交流群的伙伴已经发现了 GPT-4o 这个选项了,是...
继续阅读 »

hi,这里是小榆。在 5 月 15 日,OpenAI 旗下的大模型 GPT-4o 已经发布,那时网络上已经传开, 但很多小伙伴始终没有看到 GPT-4o 的体验选项。


在周五的时候,我组建的 ChatGPT 交流群的伙伴已经发现了 GPT-4o 这个选项了,是在没有充值升级 Plus 版的情况下,意味着这个模型已经更新给大众免费使用了。


图片


我看到后,立马放下手中正在编写的代码,开启 GPT 登录后果然有一个  GPT-4o 的选项,然后发现它的功能比 3.5 模型更加全面了,它不仅能够全面覆盖听觉、视觉和语音。


图片


我体验了一把语音对话,非常的丝滑没感觉到延迟,仿佛真的和“女朋友”在聊天。意味着它能够感知我们的呼吸节奏,并用更加丰富的语气实时回应,还会在适当的时候打断对话。


那么,就让我们了解 GPT-4o 这个大模型吧,首先 GPT-4 是比 3.5 版本更强的版本,即为 4.0+,后面还有一个‘o’ ,它的全称是‘Omni’,即‘全能’的意思。


图片


它能够接受文本、音频和图像的任意组合输入,并生成回答。响应速度快至 232 毫秒,平均 320 毫秒,与人类对话的速度可以说是很接近平均了。


并且,随着这次版本的发布,GPTo 与 ChatGPT Plus 会员版的所有功能,包括视觉、联网、记忆、执行代码、GPT Store 等,都会免费开放给大家。新语音模式将在几周内优先向 Plus 用户开放。


图片


在直播现场,OpenAI CTO Murati 谦虚道:“这是将 GPT-4 级别的模型开放给大家。”


同时将这一版本的模型提供 API 服务,价格随之减少一半,速度比之提高一倍,单位时间内调用次数是原来的 5 倍了。


OpenAI 的总裁 Brockman 也给大家在线演示,将两个 ChatGPT 相互对话,对话内容比较丰富了,不知不觉还唱起歌来了,整的还挺有意思。


发现还有伙伴和我一样体验到了不错的应用场景,当我使用手机版的 GPT-4o ,我可以实时拍照询问它,给我一些建议,如何挑西瓜榴莲等,询问给出差异分析,借助 AI 的力量进行挑瓜。


图片


你甚至可以拍摄一批西瓜的照片,上传给 GPT-4o。


你:“这瓜保熟吗?”


AI:“(警觉)...你故意找茬是不是。”


AI:“我一AI,还能给你挑生瓜蛋子不成?!”


图片


图片


我们可以看到上图中的西瓜是根据自己拍摄的西瓜图并且标记了序号,询问 GPT 哪个西瓜很甜,GPT 一通分析,虽然目前只能根据形状和成色来识别西瓜,推荐挑选的 6 号西瓜果然很不错,甚至皮也很薄。


聪明的你,脑洞大开已经熟练使用 AI 了,你或许会有很多问题问他。


你:“这盒牛奶含有什么成分?”


AI:“......”


你(掏出手机,打开摄像头扫描):“这盒牛奶有科技成分吗?卫生是否达标?”


AI:“......”


你(掏出手机,打开摄像头扫码):“请问这个妹妹面相如何?是否旺夫?”


AI:“......”


显然,上面有一部分是我的遐想,但我觉得已经不远了。


如果 AI 没有被一方人污染,升级完全体的情况下,它真的能够为我们参谋很多,洞悉很多潜在的信息,毕竟你能骗我,但是 AI 不会骗我。


好了,大家可以多去体验新产品吧,的确会很有趣。但是发现很多小伙伴 不仅电脑版本的 GPT 无法体验,更别说手机版本的 GPT 了。


目前来说对一些普通用户体验的确很困难,被迫使用某些企业研发的 AI 产品或套壳产品,还被迫收费。但也不是没有办法,别说我还挺想撰写一篇从 0 到 1 给大家完全科普使用。


okay,分享(暗示)到这里,大家如果有感兴趣,可以后台回复 GPT 加入群聊,将会有更多咨询和体验内容分享。


作者:程序员小榆
来源:juejin.cn/post/7370327567763816498
收起阅读 »

坚持与确定性:毒药还是良药?

前段时间跟几个大龄程序员一起吃饭,聊了大家的现状,后来写了篇博客总结了一下《从大龄程序员现状聊聊出路》,本想着给朋友们提供些观点和思路,结果被有些网友批评了。 1. 我的认知达不到赚快钱 有的网友认为我在瞎扯,有的觉得我在灌鸡汤,还有的认为我在指错路。 文中虽...
继续阅读 »

前段时间跟几个大龄程序员一起吃饭,聊了大家的现状,后来写了篇博客总结了一下《从大龄程序员现状聊聊出路》,本想着给朋友们提供些观点和思路,结果被有些网友批评了。


1. 我的认知达不到赚快钱


有的网友认为我在瞎扯,有的觉得我在灌鸡汤,还有的认为我在指错路。


文中虽然总结了一些自认为有价值的观点,本想着让看到的朋友能够少走弯路,尽早建立正确的思维方式。但是依旧没法满足部分网友想轻易赚块钱的思路。我实在是感到惭愧。


对于想赚快钱的网友,你们也可以去参考下刑法里的路子,或者去网上买点教你如何赚钱的课嘛,何必在这浪费口舌呢。


我在那篇文章里提到了坚持、积累、人脉、资源、推广等关键因素,每一个都不是速成的。每一项都需要长期努力,不是一蹴而就的。反正在我的认知里,每一个成功的背后都是:找到了正确的道路后,不停的微调,默默的坚持。


2. 少有的坚持


各位也可以试想下,想想这些年看过的赚钱课程,或者什么创业导师商学社等等,他们除了制造些焦虑,讲一些假大空不好落地的概念之外,其余的好像都是正确的废话吧?


远的不说,就以程序员做副业为例,有各种建议,比如做自媒体、接项目、搞某鱼某宝或卖课。这些导师对这些方法都提供了具体的行动方案,请问你是否开始行动了?是否坚持了?是否取得了成效?


这些路子是不是都是对的呢?无疑都是对的,但是每条路子都不好走,都需要自己去坚持去深钻。你都不愿意去深钻,怎么可能赚的了那份钱呢?


真的是应了那句话:我知道了那么多道理,依然过不好这一生。知道、做了、做到、做得好,是4个完全不同的概念。


许多人回忆自己几十年好像什么都没做,好像又做了很多事。为什么出现这种幻觉?因为没有坚持、没有产品思维,没有拿得出手的作品嘛。好的作品不用多,一个就够!


希望所有的朋友,都能具备产品思维。就是:认真、坚持的打磨好一件事情,把这件事情做到产生价值甚至巨大的价值,做到对他人有利,做到能够盈利,然后再去打磨下一个产品


坚持绝对是创业或者副业路上的一副良药。道阻且长,行则将至!与君共勉!



3. 确定性是一副毒药


后来我又思考了下,为什么有些程序员朋友对于正确的事情却无法坚持执行下去呢?这可能不是他们的问题,可能是我们程序员这个群体的问题。


为什么?因为就算坚持下去,也不一定能拿到想要的结果,这里面不确定的成分太高了,他们不愿意面对这份不确定性。


程序员这个群体是搞技术的,技术本身就是确定性的,非此即彼嘛。


大部分程序员学技术这条路子也是冲着确定性去的,比如我就算一个,当年学计算机就是为了确定性的找到一份稳定的工作,我早年特别害怕面对不确定性。


程序员学会了某个前端技术或者后端技术,对应的就能确定的拿到了一份什么等级的薪资。埋头苦干,确定的就会有一个好的结果。


但是,那些创业和副业都是不确定性的。创业和副业不是说通过学习或者努力就能达到一个确定性的位置。所以说呀,与其说有些网友在喷我,倒不如说是他们不敢面对不确定性。


但是,朋友们,时代在变化,我们每个人都要敢于面对不确定性。


早年我们处在IT红利期,只要努力,到处是机会。确定的程序,确定的路径,确定的繁荣,让我们误以为这个世界可以一直确定下去。这种确定性让我们慢慢上瘾,最终失去了面对不确定性的能力和勇气


想想当年学计算机那么多人是为了图个安稳,结果过了十几年,还是没能逃脱不确定的因素,如果早日接受不确定性,可能眼界会更开阔一些。


没想到十年前的那颗子弹最终还是中了自己的眉心。


以后,随着AI的发展,我觉得未来一定是一个超级个体的时代,我们会面临更多的不确定性。以后社会发展也会伴随着更多的不确定性,只有我们要调整好心态,接受它,才能在以后的道路上越走越宽,越走越顺。


趁现在就改变,千万别让确定性这副慢性毒药继续侵蚀我们的思想。




原文链接: mp.weixin.qq.com/s/9QZHF7ria…


作者:程序员半支烟
来源:juejin.cn/post/7379414160642654260
收起阅读 »

如何组装一台高性价比的电脑

前言最近想配置一台电脑,由于在这方面是小白,就花了一部分时间以及精力在这方面,相信你看完,也能轻轻松松装电脑。目标高性价比、小钱办大事电脑配置基础知识组装一台台式机需要考虑多个组件,以下是一份基本的装机配置清单,包括了主要硬件和一些可选配件我把他们划分为必须和...
继续阅读 »

1718195402741.png

前言

最近想配置一台电脑,由于在这方面是小白,就花了一部分时间以及精力在这方面,相信你看完,也能轻轻松松装电脑。

目标

高性价比、小钱办大事

电脑配置基础知识

组装一台台式机需要考虑多个组件,以下是一份基本的装机配置清单,包括了主要硬件和一些可选配件

我把他们划分为必须和非必须(可理解为表单的必填或者非必填)

有了必须项目的配置就可以组装出一台电脑,非必须项目属于个人需求(可理解为个性化定制)

必须项

  1. 处理器(CPU) :

    • 选择适合需求的处理器,如Intel Core系列或AMD Ryzen系列。
  2. 显卡(GPU) :

    • 集成显卡或独立显卡,如NVIDIA GeForce或AMD Radeon系列。
  3. 主板(Motherboard) :

    • 根据CPU选择相应插槽类型的主板,如ATX、Micro-ATX或Mini-ITX规格。
  4. 内存(RAM) :

    • 至少8GB DDR4内存,更高需求可以选择16GB或32GB。
  5. 固态硬盘(SSD) :

    • 用于安装操作系统和常用软件,256GB或更大容量。
  6. 散热器(Cooler) :

    • CPU散热器,盒装CPU可能附带散热器。
  7. 电源供应器(PSU) :

    • 根据系统需求选择合适的功率,如500W、600W等,并确保有80 PLUS认证。
  8. 机箱(Case) :

    • 根据主板规格和个人喜好选择机箱。

机箱严格来讲属于非必须,因为你用纸箱子也能行,但是对于小白来说还是整个便宜的比较好

  1. 外围设备

    • 显示器、键盘、鼠标。
  2. 音频设备

    • 耳机、扬声器。

非必须项

  1. 机械硬盘(HDD, 可选) :

    • 用于数据存储,1TB或更大容量。
  2. 机箱风扇(可选) :

    • 用于改善机箱内部空气流通。
  3. 光驱(可选) :

    • 如有需要,可以选择DVD或蓝光光驱。
  4. 无线网卡(可选) :

    • 如果主板不支持无线连接,可以添加无线网卡。
  5. 声卡(可选) :

    • 如果需要更好的音频性能,可以添加独立声卡。
  6. 扩展卡(可选) :

    • 如网络卡、声卡、图形卡等。
  7. 机箱装饰(可选) :

    • RGB灯条、风扇等。

处理器(CPU)天梯图

image.png

显卡(GPU)天梯图

image.png

如何选择显示器

选择电脑显示器时,有几个关键因素需要考虑:

  1. 分辨率:分辨率决定了显示器的清晰度。常见的有1080p(全高清)、1440p(2K)、2160p(4K)等。分辨率越高,画面越清晰,但同时对显卡的要求也越高。
  2. 屏幕尺寸:根据你的使用习惯和空间大小来选择。大屏幕可以提供更宽广的视野,但也需要更大的桌面空间。
  3. 刷新率:刷新率表示显示器每秒可以刷新多少次画面。常见的有60Hz、144Hz、240Hz等。高刷新率可以提供更流畅的视觉效果,特别适合游戏玩家。
  4. 响应时间:响应时间指的是像素从一种状态变化到另一种状态所需的时间,通常以毫秒(ms)为单位。响应时间越短,画面变化越快,越适合快速变化的游戏或视频。
  5. 面板类型:主要有TN、IPS、VA三种面板。TN面板响应速度快,但色彩表现一般;IPS面板色彩表现好,视角宽,但响应时间相对较慢;VA面板则介于两者之间。
  6. 连接接口:确保显示器的连接接口与你的电脑兼容,常见的有HDMI、DisplayPort、DVI、VGA等。
  7. 色彩准确性:如果你的工作涉及到图像或视频编辑,那么选择色彩准确性高的显示器非常重要。
  8. 附加功能:一些显示器可能提供额外的功能,如USB接口、扬声器、可调节支架等。
  9. 预算:根据你的预算来决定购买哪种类型的显示器。通常来说,价格越高,显示器的性能和功能也越全面。
  10. 品牌和售后服务:选择知名品牌的显示器通常可以保证较好的质量和售后服务。

根据你的具体需求和预算,综合考虑上述因素,选择最适合你的显示器。 并不是配置越高越好,重点是根据你的显卡和CPU来看,实现极致性价比,需要对应电脑配置能带动的最大区间,比方说 英特尔 i5-12400F(6核心12线程,睿频4.4GHz)+ 华硕DUAL-RTX 4060-8G游戏显卡能带起来的刷星率极限基本就是165Hz左右,那么你配置2K,180Hz的显示器就够用了,当然你有钱也可以整个4K,200+Hz的

关于外围设备(键鼠、耳机、音响等)

这个看个人喜好以及预算来决定吧,比方说有线还是无线,机械还是非机械等等....

不同价位装机性价比清单(根据目前市场上)

装机其实主要花费就在显卡(GPU)和处理器(CPU)上,显卡(GPU)金额占比超过50%比比皆是,处理器(CPU)金额占比一般是在百分之20%~30%,其他配件金额占比20%~30%左右。

如果预算足够建议优先升级显卡

CPU分为盒装和散片,预算充足盒装,预算不足散片也能用

3K推荐

  • CPU: Intel 12代酷睿i5 12400F
  • 显卡: 华硕DUAL RTX 3050 6G
  • 内存: 威刚D4 16GB 3200MHz
  • 硬盘: 英睿达 500GB PCI-E 4.0 M.2
  • 主板: 圣旗H610M
  • 电源: 爱国者额定500W
  • 跑分: 107W±
  • 合计:3.5K左右

4K极致性价比

4K-6K这个价位其实CPU最佳的就是就是在下图区间 同理可自行对比GPU天梯图

image.png

image.png

  • CPU: 英特尔 i5-12400F(6核心12线程,睿频4.4GHz)
  • 显卡: 华硕DUAL-RTX 4060-8G游戏显卡 升级选项: +299元升级至华硕TX-RTX 4060-O8G-GAMING天选白三风扇
  • 散热: 动力火车四铜管白色强劲散热器
  • 主板: 华硕PRIME B760M-FD4(支持AURA神光同步)
  • 内存: 雷克沙DDR4 16GB 3200MHz高频内存
  • 硬盘: 雷克沙500GB NVMe PCIe SSD(读速高达3300MB/S)
  • 电源: 源之力静音大师额定600W安规3C白色
  • 机箱: 动力火车琉璃海景房
  • 合计:4K左右

1718192436562.png

5K极致性价比

1718192483898.png

image.png

image.png

6K极致性价比

image.png

7K极致性价比

image.png

8K极致性价比

image.png

1W极致性价比

1W预算以上可能考虑的不单单是配置了,还有外观之类的,可以DIY定制之类的

image.png

image.png

1.2W极致性价比

1718192287164.png

1.3w极致性价比

image.png

总结下,其实根据CPU、GPU天梯图就可以找到自己的目标区间,其他配置看个人预算来就行,核心就是两大件!如果有装机高手,欢迎留言交流,有不懂的,欢迎提问。后续会根据问题持续补充


作者:凉城a
来源:juejin.cn/post/7379420157670670372
收起阅读 »

汝为傀儡,吾来操纵(🍄Puppeteer🍄)

web
puppeteer是我以前同事使用过的一个工具,用来测试页面的功能,可以模拟用户操作。这几天我也看到了一些相关的文章,也是很感兴趣的,所以准备整理输出一篇Puppeteer的文章,用来学习记忆。后面发现Puppeteer相关的内容比较多,准备分为两部分来讲,这...
继续阅读 »

IMG_20240601_170616_843.jpg

puppeteer是我以前同事使用过的一个工具,用来测试页面的功能,可以模拟用户操作。这几天我也看到了一些相关的文章,也是很感兴趣的,所以准备整理输出一篇Puppeteer的文章,用来学习记忆。

后面发现Puppeteer相关的内容比较多,准备分为两部分来讲,这一部分主要讲理论相关的,也会举些简单的实例。下一章则会主要针对实战来讲解。

介绍

Puppeteer词义解释

  • Puppet:木偶,傀儡
  • Puppeteer:操纵木偶的人

Puppeteer 是一个由 Google 开发的 Node.js 库用于控制 Chrome 或 Chromium 浏览器的高级 API。它可以模拟用户的交互行为,例如点击、填写表单、导航等,同时还可以截取页面内容、生成 PDF、执行自动化测试等功能

官方网站:github.com/GoogleChrom…

Puppeteer 中文文档

官方文档:pptr.dev/

文档地址:zhaoqize.github.io/puppeteer-a…

核心功能

Puppeteer 的核心功能包括以下几个方面:

  1. 控制浏览器:Puppeteer 可以启动一个 Chrome 或 Chromium 浏览器实例,并通过 API 控制浏览器的行为,如打开网页、点击链接、填写表单、执行 JavaScript 等操作。
  2. 页面操作:Puppeteer 可以模拟用户在页面上的操作,包括点击元素、填写表单、滚动页面、截取屏幕截图等,实现对页面的交互操作。
  3. 网页内容抓取:Puppeteer 可以获取页面的 DOM 结构、元素属性、文本内容等信息,从而实现网页内容的抓取和提取。
  4. 页面性能分析:Puppeteer 可以获取页面加载性能数据、网络请求信息、CPU 和内存使用情况等,帮助开发者进行页面性能优化和调试。
  5. 生成 PDF:Puppeteer 可以将网页内容保存为 PDF 文档,支持设置页面大小、方向、页边距等参数,方便生成打印版的网页内容。
  6. 自动化测试:Puppeteer 可以用于编写自动化测试脚本,模拟用户的操作行为,验证页面的功能和交互是否符合预期,实现自动化测试流程。
  7. 爬虫和数据采集:Puppeteer 可以用于编写网络爬虫,自动访问网页、提取数据、填写表单等,实现网页内容的自动采集和处理。

总的来说,Puppeteer 是一个功能强大的浏览器自动化工具,可以实现对浏览器的控制和页面操作,适用于各种场景下的自动化任务,如自动化测试、网页内容抓取、页面性能分析等。

下面介绍一些常用的API。

启动新的浏览器实例

puppeteer.launch()是一个用于启动一个新的浏览器实例的方法。该方法返回一个 Promise,该 Promise 在浏览器实例启动后会被解析为一个 Browser 对象,你可以通过这个对象来操作浏览器。

在 Puppeteer 中,puppeteer.launch() 方法可以接受一个可选的配置对象 options,用于指定启动浏览器实例时的一些参数和选项。下面是一些常用的配置选项:

  1. headless:布尔值,是否以 无头模式 运行浏览器。默认是 true,即以无头模式启动,不会显示浏览器界面。如果设置为 false,则会以有头模式启动,显示浏览器界面。
  2. args:一个字符串数组,传递给浏览器实例的其他参数。 这些参数可以参考 这里
  3. defaultViewport 是一个对象,用于为每个页面设置一个默认视口大小。默认是 800x600。如果为 null 的话就禁用视图口。下面是 defaultViewport 对象中可以设置的属性:

    • width:页面的宽度像素。
    • height:页面的高度像素。
    • deviceScaleFactor:设备的缩放比例,可以认为是设备像素比(device pixel ratio,DPR)。默认值为 1。

      更多

  4. ignoreHTTPSErrors: 布尔值,指定是否忽略 HTTPS 错误。默认是 false
  5. defaultViewport:一个对象,用于指定浏览器的默认视口大小,包括宽度、高度和设备比例因子等。
  6. userDataDir:一个字符串,用于指定用户数据目录的路径,用于存储浏览器的用户数据,比如缓存、Cookies 等。
  7. timeout: 数值,指定启动浏览器的超时时间,单位为毫秒。
  8. slowMo: 数值,指定 Puppeteer 操作的延迟时间,单位为毫秒。可以用来减慢操作的速度,方便调试。

下面是一个简单的示例代码,演示如何使用 puppeteer.launch() 方法来启动一个浏览器实例:

const puppeteer = require('puppeteer');

(async () => {
const browser = await puppeteer.launch({
headless: false, // 显示浏览器界面
executablePath: '/path/to/chrome', // 指定浏览器可执行文件路径
args: ['--no-sandbox', '--disable-setuid-sandbox'], // 额外参数
defaultViewport: { width: 1280, height: 800 }, // 默认视口大小
userDataDir: '/path/to/userDataDir'// 用户数据目录
slowMo: 100, // 延迟 100 毫秒
});

// 在这里可以进行其他操作,比如创建新页面、访问网页等

await browser.close();
})();

在上面的代码中,我们通过 puppeteer.launch() 方法启动了一个浏览器实例,并通过 options 参数配置了一些选项,比如显示浏览器界面、指定浏览器可执行文件路径、传递额外参数、设置默认视口大小和用户数据目录。你可以根据需要自定义 options 对象中的属性来满足你的需求。

需要注意的是,在使用完浏览器实例后,应该调用 browser.close() 方法来关闭浏览器,释放资源。

Browser 类

Browser 类表示一个 Chrome 或 Chromium 浏览器实例。它提供了一组方法来操作整个浏览器,如创建新页面、关闭浏览器、监听事件等。

当 Puppeteer 连接到一个 Chromium 实例的时候会通过 puppeteer.launch 或 puppeteer.connect 创建一个 Browser 对象。

以下是一些 Browser 类常用的方法:

  • newPage(): 创建一个新的页面实例。
  • close(): 关闭浏览器实例。
  • version(): 获取浏览器的版本信息。
  • pages(): 获取所有已打开的页面实例。
  • newContext(): 创建一个新的浏览器上下文。
  • target(): 获取指定目标的实例。

下面是使用 Browser 创建 Page 的例子

const puppeteer = require('puppeteer');

puppeteer.launch().then(async browser => {
const page = await browser.newPage();
await page.goto('https://example.com');
await browser.close();
});

Page 类

Page 类表示一个浏览器页面。它提供了一系列方法,用于操作和控制页面的行为,例如导航至指定 URL、执行 JavaScript 代码、截取页面截图等。

以下是一些 Page 类常用的方法:

  1. goto(url): 导航到指定的 URL。
await page.goto('https://www.example.com');
  1. waitForSelector(selector): 等待页面中指定的选择器出现。
await page.waitForSelector('.my-element');
  1. click(selector): 点击页面中指定的选择器。
await page.click('.my-button');
  1. type(selector, text): 在指定的输入框中输入文本。
await page.type('input[name="username"]', 'myusername');
  1. evaluate(pageFunction): 在页面上下文中执行指定的函数。
const title = await page.evaluate(() => document.title);
  1. screenshot(options): 截取当前页面的屏幕截图。
await page.screenshot({ path: 'screenshot.png' });
  1. close(): 关闭页面实例。
await page.close();

通过使用 Page 类提供的方法,我们可以模拟用户在浏览器中的操作,实现各种自动化任务,如网页截图、表单填写、点击操作等。

下面会更详细地介绍几个常用的方法。

元素获取

这些方法可以帮助我们在 Puppeteer 中获取页面元素:

  1. page.content(): 返回页面完整的 HTML 代码。
const html = await page.content();
  1. page.$(selector): 使用 document.querySelector 寻找指定元素。
const element = await page.$('.my-element');
  1. page.$$(selector): 使用 document.querySelectorAll 寻找指定元素。
const elements = await page.$$('.my-elements');
  1. page.$x(expression): 使用 XPath 寻找指定元素。
const element = await page.$x('//div[@class="my-element"]');
  1. page.$eval(selector, pageFunction, …args?): 在页面中注入方法,执行 document.querySelector 后将结果作为第一个参数传给函数体。
const text = await page.$eval('.my-element', element => element.textContent);
  1. page.$$eval(selector, pageFunction, …args?): 在页面中注入方法,执行 document.querySelectorAll 后将结果作为第一个参数传给函数体。
const texts = await page.$$eval('.my-elements', elements => elements.map(element => element.textContent));

页面操作

点击操作

  • page.click(selector, options?): 点击选择器匹配的元素,有多个元素满足匹配条件仅作用第一个。
await page.click('.my-button');
  • page.tap(selector): 点击选择器匹配的元素,有多个元素满足匹配条件仅作用第一个,主要针对手机端的触摸事件。
await page.tap('.my-button');
  • page.focus(selector): 给选择器匹配的元素获取焦点,有多个元素满足匹配条件仅作用第一个。
await page.focus('.my-input');
  • page.hover(selector): 鼠标悬浮于选择器匹配的元素,有多个元素满足匹配条件仅作用第一个。
await page.hover('.my-element');

输入操作

page.type 是 Puppeteer 中用于在指定元素上输入文本的方法。该方法接受两个参数:选择器和要输入的文本。

以下是 page.type 方法的用法示例:

await page.type('input[type="text"]', 'Hello, Puppeteer!');

在这个示例中,我们使用选择器 input[type="text"] 来定位页面上的一个文本输入框,并在该输入框中输入文本 'Hello, Puppeteer!'。

键盘模拟按键

page.keyboard 对象提供了一系列方法,可以模拟按键的按下、释放、输入等操作。

以下是一些常用的 page.keyboard 方法:

  1. keyboard.press(key[, options]): 模拟按下指定的键。
  2. keyboard.release(key): 模拟释放指定的键。
  3. keyboard.down(key): 模拟按下指定的键,保持按下状态。
  4. keyboard.up(key): 模拟释放指定的键,取消按下状态。
  5. keyboard.type(text[, options]): 模拟输入指定的文本。

下面是一个示例代码,演示如何在输入框中模拟按键操作:

const puppeteer = require('puppeteer');

(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://www.example.com');

// 获取要输入文本的输入框的选择器
const selector = 'input[type="text"]';

// 等待输入框加载完成
await page.waitForSelector(selector);

// 在输入框中模拟按键操作
await page.focus(selector); // 让输入框获得焦点
await page.keyboard.type('Hello, Puppeteer!'); // 输入文本
await page.keyboard.press('Enter'); // 模拟按下 Enter 键

await browser.close();
})();

在这个示例中,我们首先让输入框获得焦点,然后使用 page.keyboard.type 方法输入文本 'Hello, Puppeteer!',最后使用 page.keyboard.press 方法模拟按下 Enter 键。

鼠标模拟

在 Puppeteer 中,可以使用 page.mouse 对象来模拟鼠标操作。page.mouse 对象提供了一系列方法,可以模拟鼠标的移动、点击、滚动等操作。

以下是一些常用的 page.mouse 方法:

  1. mouse.move(x, y[, options]): 将鼠标移动到指定位置。
  2. mouse.click(x, y[, options]): 在指定位置模拟鼠标点击。
  3. mouse.down([options]): 模拟按下鼠标按钮。
  4. mouse.up([options]): 模拟释放鼠标按钮。
  5. mouse.wheel(deltaX, deltaY): 模拟滚动鼠标滚轮。

下面是一个示例代码,演示如何在页面中模拟鼠标操作:

const puppeteer = require('puppeteer');

(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://www.example.com');

// 获取要点击的元素的选择器
const selector = 'button';

// 等待元素加载完成
await page.waitForSelector(selector);

// 获取元素的位置
const element = await page.$(selector);
const boundingBox = await element.boundingBox();

// 在元素位置模拟鼠标点击
await page.mouse.click(boundingBox.x + boundingBox.width / 2, boundingBox.y + boundingBox.height / 2);

await browser.close();
})();

在这个示例中,我们首先等待页面中的按钮元素加载完成,然后获取按钮元素的位置信息,最后使用 page.mouse.click 方法在按钮元素的中心位置模拟鼠标点击操作。

事件监听

这些是 Puppeteer 中常用的页面事件,可以通过监听这些事件来执行相应的操作。以下是每个事件的简要说明:

  • close: 当页面被关闭时触发。
  • console: 当页面中调用 console API 时触发。
  • error: 当页面发生错误时触发。
  • load: 当页面加载完成时触发。
  • request: 当页面收到请求时触发。
  • requestfailed: 当页面的请求失败时触发。
  • requestfinished: 当页面的请求成功时触发。
  • response: 当页面收到响应时触发。
  • workercreated: 当页面创建 webWorker 时触发。
  • workerdestroyed: 当页面销毁 webWorker 时触发。

您可以通过以下示例代码来监听页面加载完成和页面请求成功的事件:

const puppeteer = require('puppeteer');

(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();

// 监听页面加载完成事件
page.
on('load', () => {
console.log('Page loaded successfully');
});

// 监听页面请求成功事件
page.
on('requestfinished', (request) => {
console.log(`Request finished: ${request.url()}`);
});

await page.goto('https://www.example.com');

await browser.close();
})();

在这个示例中,我们使用 page.on 方法来监听页面加载完成和页面请求成功的事件,并在事件发生时打印相应的信息。

等待元素、请求、响应

在 Puppeteer 中,您可以使用以下方法来等待元素、请求和响应:

  1. page.waitForXPath(xpath, options)等待指定的 XPath 对应的元素出现。参数 options 可以包含 timeout 和 visible 选项。返回一个 ElementHandle 实例。
await page.waitForXPath('//div[@]', { visible: true });
  1. page.waitForSelector(selector, options)等待指定的选择器对应的元素出现。参数 options 可以包含 timeout 和 visible 选项。返回一个 ElementHandle 实例。
await page.waitForSelector('.example', { visible: true });
  1. page.waitForResponse(predicate)等待符合条件的响应结束。参数 predicate 是一个函数,用于判断响应是否符合条件。返回一个 Response 实例。
const response = await page.waitForResponse(response => response.url().includes('/api'));
  1. page.waitForRequest(predicate)等待符合条件的请求出现。参数 predicate 是一个函数,用于判断请求是否符合条件。返回一个 Request 实例。
const request = await page.waitForRequest(request => request.url().includes('/api'));

这些方法可以帮助您在 Puppeteer 中更精确地控制等待元素、请求和响应的时间,以便在需要时执行相应的操作。如果您有任何疑问或需要进一步的解释,请随时告诉我。我将很乐意帮助您。

网络拦截操作

page.setRequestInterception() 方法可以拦截页面中发出的网络请求,并对其进行处理。通过拦截请求,你可以修改请求的行为,例如阻止请求、修改请求的头部、修改请求的内容等。

以下是使用 page.setRequestInterception() 方法的一个示例:

const puppeteer = require('puppeteer');

(async () => {
 const browser = await puppeteer.launch();
 const page = await browser.newPage();

 // 启用请求拦截
 await page.setRequestInterception(true);

 // 监听请求事件
 page.on('request', (request) => {
   // 判断请求的 URL 是否符合条件
   if (request.url().includes('/api')) {
     request.continue(); // 继续请求
  } else {
     request.abort(); // 中止请求
  }
});

 // 导航至指定 URL
 await page.goto('https://example.com');
 
   // 等待页面加载完成
 await page.waitForNavigation();
   
  // 获取符合条件的网络响应
 const responses = await page.waitForResponse(response => response.url().includes('/api'));
 // 获取接口数据
 const responseData = await responses.json();
 console.log(responseData);
 
 await browser.close();
})();

需要注意的是,在使用请求拦截功能时,务必要确保在请求被中止或继续之前,要么调用 interceptedRequest.abort() 中止请求,要么调用 interceptedRequest.continue() 继续请求,否则可能会导致页面无法正常加载。

简单示例

截图

在 Puppeteer 中实现截图可以通过 page.screenshot() 方法来实现。

以下是一个简单的示例代码,演示如何在 Puppeteer 中对页面进行截图:

const puppeteer = require('puppeteer');

(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();

await page.goto('https://www.example.com');

// 在当前目录下保存截图
await page.screenshot({ path: 'example.png' });

await browser.close();
})();

在上面的示例中,我们首先启动了一个 Puppeteer 浏览器实例,然后创建了一个新页面并访问了示例网站。接着使用 page.screenshot() 方法对页面进行截图,并将截图保存在当前目录下的 example.png 文件中。最后关闭了浏览器实例。

生成pdf

在 Puppeteer 中生成 PDF 可以通过 page.pdf() 方法来实现。

以下是一个简单的示例代码,演示如何在 Puppeteer 中生成 PDF:

const puppeteer = require('puppeteer');

(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();

await page.goto('https://www.example.com');

// 生成 PDF 并保存在当前目录下的 example.pdf 文件中
await page.pdf({ path: 'example.pdf', format: 'A4' });

await browser.close();
})();

在上面的示例中,我们首先启动了一个 Puppeteer 浏览器实例,然后创建了一个新页面并访问了示例网站。接着使用 page.pdf() 方法生成 PDF,并将其保存在当前目录下的 example.pdf 文件中。您还可以调整生成 PDF 的格式、尺寸、页面边距等参数。

设置cookie

在 Puppeteer 中设置 cookie 可以通过 page.setCookie() 方法来实现。

以下是一个简单的示例代码,演示如何在 Puppeteer 中设置 cookie:

const puppeteer = require('puppeteer');

(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();

// 设置 cookie
await page.setCookie({
name: 'username',
value: 'john_doe',
domain: 'www.example.com'
});

await page.goto('https://www.example.com');

// 在页面中获取 cookie
const cookies = await page.cookies();
console.log(cookies);

await browser.close();
})();

在上面的示例中,我们首先启动了一个 Puppeteer 浏览器实例,然后创建了一个新页面。接着使用 page.setCookie() 方法设置了一个名为 username 的 cookie,然后访问了示例网站。最后使用 page.cookies() 方法获取页面中的所有 cookie,并将其打印出来。

您可以根据需要设置更多的 cookie,以及设置 cookie 的路径、过期时间等属性。


作者:Aplee
来源:juejin.cn/post/7379512671617679412
收起阅读 »

作为前端开发,感受下 nginx 带来的魅力!🔥🔥

引言:纯干货分享,汇总了我在工作中八年遇到的各种 Nginx 使用场景,对这篇文章进行了细致的整理和层次分明的讲解,旨在提供简洁而深入的内容。希望这能为你提供帮助和启发! 对于前端开发人员来说,Node.js 是一种熟悉的技术。虽然 Nginx 和 Node...
继续阅读 »

引言:纯干货分享,汇总了我在工作中八年遇到的各种 Nginx 使用场景,对这篇文章进行了细致的整理和层次分明的讲解,旨在提供简洁而深入的内容。希望这能为你提供帮助和启发!
1802c30a7bb47ccc7cd70314829ac04796140850.jpeg


对于前端开发人员来说,Node.js 是一种熟悉的技术。虽然 Nginx 和 Node.js 在某些理念上有相似之处,比如都支持 HTTP 服务、事件驱动和异步非阻塞操作,但两者并不冲突,各有各自擅长的领域:



  • Nginx:擅长处理底层的服务器端资源,如静态资源处理、反向代理和负载均衡。

  • Node.js:更擅长处理上层的具体业务逻辑。


而两者的结合可以实现更加高效和强大的应用服务架构,下面我们就来看一下。借助文章目录阅读,效率更高。目前您可能还用不到这篇文章,不过可以先收藏起来。希望将来它能为您提供所需的帮助!


Nginx 是什么?


Nginx 是一个高性能的HTTP和反向代理服务器,由俄罗斯程序员Igor Sysoev于 2004 年使用 C 语言开发。它最初设计是为了应对俄罗斯大型门户网站的高流量挑战。


1667274211133.jpg


反向代理是什么?(🔥面试会问)


让我们先从代理说起。Nginx 常被用作反向代理,那么什么是正向代理呢?



  • 正向代理:客户端知道要访问的服务器地址,但服务器只知道请求来自某个代理,而不清楚具体的客户端。正向代理隐藏了真实客户端的信息。例如,当无法直接访问国外网站时,我们通过代理服务器访问特定网址。






  • 反向代理:多个客户端向反向代理服务器发送请求,Nginx 根据一定的规则将请求转发至不同的服务器。客户端不知道具体请求将被转发至哪台服务器,反向代理隐藏了后端服务器的信息。





Nginx 的核心特性


Nginx包含以下七个核心特性,使它成为处理高并发和大数据量请求的理想选择:


1. 事件驱动:Nginx采用高效的异步事件模型,利用 I/O 多路复用技术。这种模型使 Nginx 能在占用最小内存的同时处理大量并发连接。


2. 高度可扩展:Nginx能够支持数千乃至数万个并发连接,非常适合大型网站和高并发应用。例如:为不同的虚拟主机设置不同的 worker 进程数,以增加并发处理能力:


http {
worker_processes auto; # 根据系统CPU核心数自动设置worker进程数
}

3. 轻量级:相较于传统的基于进程的Web服务器(如Apache),Nginx的内存占用更低,得益于其事件驱动模型,每个连接只占用极小的内存空间。


4. 热部署:Nginx支持热部署功能,允许在不重启服务器的情况下更新配置和模块。例如:在修改了 Nginx 配置文件后,可以快速热部署 Nginx 配置:


sudo nginx -s reload

5. 负载均衡:Nginx内置负载均衡功能,通过upstream模块实现客户端请求在多个后端服务器间的分配,从而提高服务的整体处理能力。以下是一个简单的upstream配置,它将请求轮询分配到三个后端服务器:


upstream backend {
server backend1.example.com;
server backend2.example.com;
server backend3.example.com;
}

server {
location / {
proxy_pass http://backend; # 将请求转发到upstream定义的backend组
}
}

6. 高性能:Nginx 在多项 Web 性能测试中表现卓越,能快速处理静态文件、索引文件及代理请求。比如:配置 Nginx 作为反向代理服务器,为大型静态文件下载服务:


location /files/ {
alias /path/to/files/; # 设置实际文件存储路径
expires 30d; # 设置文件过期时间为30天
}

7. 安全性:Nginx支持SSL/TLS协议,能够作为安全的Web服务器或反向代理使用。


server {
listen 443 ssl;
ssl_certificate /path/to/fullchain.pem; # 证书路径
ssl_certificate_key /path/to/privatekey.pem; # 私钥路径
ssl_protocols TLSv1.2 TLSv1.3; # 支持的SSL协议
}

搭建 Nginx 服务


1-48.jpeg


如何安装?


在 Linux 系统中,可以使用包管理器来安装 Nginx。例如,在基于 Debian 的系统上,可以使用 apt


sudo apt update
sudo apt install nginx

在基于 Red Hat 的系统上,可以使用 yum 或 dnf


sudo yum install epel-release
sudo yum install nginx

在安装完成后,通常可以通过以下命令启动 Nginx 服务:


sudo systemctl start nginx

设置开机自启动:


sudo systemctl enable nginx

启动成功后,在浏览器输入服务器 ip 地址或者域名,如果看到 Nginx 的默认欢迎页面,说明 Nginx 运行成功。


常用命令有哪些


在日常的服务器管理和运维中,使用脚本来管理 Nginx 是很常见的。这可以帮助自动化一些常规任务,如启动、停止、重载配置等。以下是一些常用的 Nginx 脚本命令,这些脚本通常用于 Bash 环境下:



  1. 启动 Nginx:nginx

  2. 停止 Nginx:nginx -s stop

  3. 重新加载 Nginx:nginx -s reload

  4. 检查 Nginx 配置文件:nginx -t(检查配置文件的正确性)

  5. 查看 Nginx 版本:nginx -v


其他常用的配合脚本命令:



  1. 查看进程命令:ps -ef | grep nginx

  2. 查看日志,在logs目录下输入指令:more access.log


。。。还有哪些常用命令,评论区一起讨论下!


配置文件构成(🔥核心重点,一定要了解)


Nginx配置文件主要由指令组成,这些指令可以分布在多个上下文中,主要上下文包括:



  1. main: 全局配置,影响其他所有上下文。

  2. events: 配置如何处理连接。

  3. http: 配置HTTP服务器的参数。

    • server: 配置虚拟主机的参数。

      • location: 基于请求的URI来配置特定的参数。






worker_processes auto;   # worker_processes定义Nginx可以启动的worker进程数,auto表示自动检测 

# 定义Nginx如何处理连接
events {
worker_connections 1024; # worker_connections定义每个worker进程可以同时打开的最大连接数
}

# 定义HTTP服务器的参数
http {
include mime.types; # 引入mime.types文件,该文件定义了不同文件类型的MIME类型
default_type application/octet-stream; # 设置默认的文件MIME类型为application/octet-stream
sendfile on; # 开启高效的文件传输模式
keepalive_timeout 65; # 设置长连接超时时间

# 定义一个虚拟主机
server {
listen 80; # 指定监听的端口
server_name localhost; # 设置服务器的主机名,这里设置为localhost

# 对URL路径进行配置
location / {
root /usr/share/nginx/html; # 指定根目录的路径
index index.html index.htm; # 设置默认索引文件的名称,如果请求的是一个目录,则按此顺序查找文件
}

# 错误页面配置,当请求的文件不存在时,返回404错误页面
error_page 404 /404.html;

# 定义/40x.html的位置
location = /40x.html {
# 此处可以配置额外的指令,如代理、重写等,但在此配置中为空
}

# 错误页面配置,当发生500、502、503、504等服务器内部错误时,返回相应的错误页面
error_page 500 502 503 504 /50x.html;

# 定义/50x.html的位置
location = /50x.html {
# 同上,此处可以配置额外的指令
}
}
}

这个配置文件设置了Nginx监听80端口,使用root指令指定网站的根目录,并为404和50x错误页面提供了位置。其中,userworker_processes指令在main上下文中,events块定义了事件处理配置,http块定义了HTTP服务器配置,包含一个server块,该块定义了一个虚拟主机,以及两个location块,分别定义了对于404和50x错误的处理。


进入正题,详细看下如何配置


a0e2c2ce0c28044531b1589f5e3fb83263cb690c.jpeg


打开 Nginx 配置世界大门


下面这段是 Nginx 配置定义了一个服务器块(server block),它指定了如何处理发往特定域名的 HTTP 请求。


server {
listen 80; # 监听80端口,HTTP请求的默认端口
client_max_body_size 100m; # 设置客户端请求体的最大大小为100MB
index index.html; # 设置默认的索引文件为index.html
root /user/project/admin; # 设置Web内容的根目录为/user/project/admin

# 路由配置,处理所有URL路径
location ~ /* {
proxy_pass http://127.0.0.1:3001; # 将请求代理到本机的3001端口
proxy_redirect off; # 关闭代理重定向

# 设置代理请求头,以便后端服务器可以获取客户端的原始信息
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

# 定义代理服务器失败时的行为,如遇到错误、超时等,尝试下一个后端服务器
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504;
proxy_max_temp_file_size 0; # 禁止代理临时文件写入

# 设置代理连接、发送和读取的超时时间
proxy_connect_timeout 90;
proxy_send_timeout 90;
proxy_read_timeout 90;

# 设置代理的缓冲区大小
proxy_buffer_size 4k;
proxy_buffers 4 32k;
proxy_busy_buffers_size 64k;
proxy_temp_file_write_size 64k;
}

# 对图片文件设置缓存过期时间,客户端可以在1天内使用本地缓存
location ~ .*.(gif|jpg|jpeg|png|swf)$ {
expires 1d;
}

# 对JavaScript和CSS文件设置缓存过期时间,客户端可以在24小时内使用本地缓存
location ~ .*.(js|css)?$ {
expires 24h;
}

# 允许访问/.well-known目录下的所有文件,通常用于WebFinger、OAuth等协议
location ~ /.well-known {
allow all;
}

# 禁止访问隐藏文件,即以点开头的文件或目录
location ~ /. {
deny all;
}

# 指定访问日志的路径,日志将记录在/user/logs/admin.log文件中
access_log /user/logs/admin.log;
}

注意:Nginx 支持使用正则表达式来匹配 URI,这极大地增强了配置的灵活性。在 Nginx 配置中,正则表达式通过 ~ 来指定。


例如,location ~ /* 可以匹配所有请求。另一个例子是 location ~ .*.(gif|jpg|jpeg|png|swf)$,这个表达式用于匹配以 gif、jpg、jpeg、png 或 swf 这些图片文件扩展名结尾的请求。


Nginx 配置实战(🔥可以复制,直接拿来使用)


以下是一些常见的 Nginx 配置实战案例:


1、静态资源服务:前端web


server {
listen 80;
server_name example.com;
location / {
root /path/to/your/static/files;
index index.html index.htm;
}
location ~* \.(jpg|png|gif|jpeg)$ {
expires 30d;
add_header Cache-Control "public";
}
}

在这个案例中,Nginx 配置为服务静态文件,如 HTML、CSS、JavaScript 和图片等。通过设置 root 指令,指定了静态文件的根目录。同时,对于图片文件,通过 expires 指令设置了缓存时间为 30 天,减少了服务器的负载和用户等待时间。


2、反向代理


server {
listen 80;
server_name api.example.com;
location / {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}

这个案例展示了如何配置 Nginx 作为反向代理服务器。当客户端请求 api.example.com 时,Nginx 会将请求转发到后端服务器集群。通过设置 proxy_set_header,可以修改客户端请求的头部信息,确保后端服务器能够正确处理请求。


3、负载均衡


http {
upstream backend {
server backend1.example.com;
server backend2.example.com;
server backend3.example.com;
}
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
}

在这个负载均衡的案例中,Nginx 将请求分发给多个后端服务器。通过 upstream 指令定义了一个服务器组,然后在 location 块中使用 proxy_pass 指令将请求代理到这个服务器组。Nginx 支持多种负载均衡策略,如轮询(默认)、IP 哈希等。


4、HTTPS 配置


server {
listen 443 ssl;
server_name example.com;
ssl_certificate /path/to/your/fullchain.pem;
ssl_certificate_key /path/to/your/private.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384';
ssl_prefer_server_ciphers on;
location / {
root /path/to/your/https/static/files;
index index.html index.htm;
}
}

这个案例展示了如何配置 Nginx 以支持 HTTPS。通过指定 SSL 证书和私钥的路径,以及设置 SSL 协议和加密套件,可以确保数据传输的安全。同时,建议使用 HTTP/2 协议以提升性能。


5、安全防护


server {
listen 80;
server_name example.com;
location / {
# 防止 SQL 注入等攻击
rewrite ^/(.*)$ /index.php?param=$1 break;
# 限制请求方法,只允许 GET 和 POST
if ($request_method !~ ^(GET|POST)$ ) {
return 444;
}
# 防止跨站请求伪造
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
add_header X-XSS-Protection "1; mode=block";
}
}

通过 rewrite 指令,可以防止一些常见的 Web 攻击,如 SQL 注入。这种限制请求方法,可以减少服务器被恶意利用的风险。同时,添加了一些 HTTP 头部来增强浏览器安全,如防止点击劫持和跨站脚本攻击(XSS)等。


63d12faf8e9f0972ed2f0d90_1024.jpeg


Nginx 深入学习-负载均衡


在负载均衡的应用场景中,Nginx 通常作为反向代理服务器,接收客户端的请求并将其分发到一组后端服务器。这样做不仅可以分散负载、提升网站的响应速度,更能提高系统的可用性。


健康检查


Nginx 能够监测后端服务器的健康状态。如果服务器无法正常工作,Nginx 将自动将请求重新分配给其他健康的服务器。


http {
upstream myapp1 { # 定义了后端服务器组
server srv1.example.com;
server srv2.example.com;
server srv3.example.com;

# 健康检查配置。
# 每10秒进行一次健康检查,如果连续3次健康检查失败,则认为服务器不健康;
# 如果连续2次健康检查成功,则认为服务器恢复健康。
check interval=10s fails=3 passes=2;
}

server {
listen 80;

location / {
proxy_pass http://myapp1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
}


check interval=10s fails=3 passes=2; 这样的配置语法在开源版本的 NGINX 上是不支持的。这是 ngx_http_upstream_check_module 模块的特有语法,而该模块不包含在 NGINX 的开源版本中,需要自行下载、编译和安装。该模块是开源免费的,具体详情请参见 ngx_http_upstream_check_module 文档



Nginx 会定期向定义的服务器发送健康检查请求。如果服务器响应正常,则认为服务器健康;如果服务器没有响应或者响应异常,则认为服务器不健康。当服务器被标记为不健康时,Nginx 将不再将请求转发到该服务器,直到它恢复健康。


负载均衡算法


Nginx 支持多种负载均衡算法,可以适应不同的应用场景。以下是几种常见的负载均衡算法的详细说明和示例:


1、Weight轮询(默认):权重轮询算法是 Nginx 默认的负载均衡算法。它按顺序将请求逐一分配到不同的服务器上。通过设置服务器权重(weight)来调整不同服务器上请求的分配率。


upstream backend {
server backend1.example.com weight=3; # 设置backend1的权重为3
server backend2.example.com; # backend2的权重为默认值1
server backend3.example.com weight=5; # 设置backend3的权重为5
}

如果某一服务器宕机,Nginx会自动将该服务器从队列中剔除,请求代理会继续分配到其他健康的服务器上。


2、IP Hash 算法: 根据客户端IP地址的哈希值分配请求,确保客户端始终连接到同一台服务器。


upstream backend {
ip_hash; # 启用IP哈希算法
server backend1.example.com;
server backend2.example.com;
}

根据客户端请求的IP地址的哈希值进行匹配,将具有相同IP哈希值的客户端分配到指定的服务器。这样可以确保同一客户端的请求始终被分配到同一台服务器,有助于保持用户的会话状态。


3、fair算法: 根据服务器的响应时间和负载来分配请求。


upstream backend {
fair; # 启用公平调度算法
server backend1.example.com;
server backend2.example.com;
server backend3.example.com;
}

它结合了轮询和IP哈希的优点,但Nginx默认不支持公平调度算法,需要安装额外的模块(upstream_fair)来实现。


4、URL Hash 算法: 根据请求的 URL 的哈希值分配请求,每个请求的URL会被分配到指定的服务器,有助于提高缓存效率。


upstream backend {
hash $request_uri; # 启用URL哈希算法
server backend1.example.com;
server backend2.example.com;
}
# 根据请求的URL哈希值来决定将请求发送到backend1还是backend2。

这种方法需要安装Nginx的hash软件包。


开源模块


Nginx拥有丰富的开源模块,有很多还有待我们探索,除了一些定制化的需求需要自己开发,大部分的功能都有开源。大家可以在 NGINX 社区、GitHub 上搜索 "nginx module" 可以找到。


image (1).png


总结


虽然前端人员可能不经常直接操作 Nginx,但了解其基本概念和简单的配置操作是必要的。这样,在需要自行配置 Nginx 的情况下,前端人员能够知晓如何进行基本的设置和调整。


作者:Sailing
来源:juejin.cn/post/7368433531926052874
收起阅读 »

一个Android App最少有多少个线程?

守护线程 Signal Catcher线程 Signal Catcher是一个守护线程,用于捕获 SIGQUIT, SIGUSR1 信号,并采取相应的行为。 Android系统中,由Zygote孵化而来的子进程,包含system_server进程和各种APP进...
继续阅读 »

守护线程


Signal Catcher线程


Signal Catcher是一个守护线程,用于捕获 SIGQUIT, SIGUSR1 信号,并采取相应的行为。


Android系统中,由Zygote孵化而来的子进程,包含system_server进程和各种APP进程都存在一个Signal Catcher线程,但是Zygote进程本身没有这个线程。


在Process类中有SIGQUIT,SIGUSR1 的定义:


    public static final int SIGNAL_QUIT = 3;
public static final int SIGNAL_KILL = 9;
public static final int SIGNAL_USR1 = 10;

当前进程的Signal Catcher线程接收到信号SIGNAL_QUIT,则挂起进程中的所有线程并DUMP所有线程的状态。


当前进程的Signal Catcher线程接收到信号SIGNAL_USR1,则触发进程强制执行GC操作。


发送信号的方式:


Process.sendSignal(android.os.Process.myPid(), Process.SIGNAL_QUIT);
Process.sendSignal(android.os.Process.myPid(), Process.SIGNAL_USR1);

当我们直接在代码中调用上诉代码会发生什么?


Process.SIGNAL_USR1:


I/Process: Sending signal. PID: 12363 SIG: 10
I/etease.popo.ap: Thread[3,tid=12373,WaitingInMainSignalCatcherLoop,Thread*=0x793d816400,peer=0x13700020,"Signal Catcher"]: reacting to signal 10
I/etease.popo.ap: SIGUSR1 forcing GC (no HPROF) and profile save
I/etease.popo.ap: Explicit concurrent copying GC freed 18773(4MB) AllocSpace objects, 7(140KB) LOS objects, 68% free, 2MB/8MB, paused 130us total 13.633ms

Process.SIGNAL_QUIT


I/Process: Sending signal. PID: 12812 SIG: 3
I/etease.popo.ap: Thread[3,tid=12823,WaitingInMainSignalCatcherLoop,Thread*=0x793d816400,peer=0x148051b0,"Signal Catcher"]: reacting to signal 3
I/etease.popo.ap: Wrote stack traces to '[tombstoned]'

RenderThread 渲染线程


Android 5.0之后新增的一个线程,用来协助UI线程进行图形绘制。所有的GL命令执行都放在这个线程上。渲染线程在RenderNode中存有渲染帧的所有信息,并监听VSync信号,因此可以独立做一些属性动画。


在清单文件APP中添加:android:hardwareAccelerated="false"
启动APP将会不存在渲染线程;硬件加速在Android中试默认开启的。


Render Thread在运行时主要是做以下两件事情:



  • Task Queue的任务,这些Task一般就是由MainThread发送过来的,例如,MainThread通过发送一个DrawFrameTask给RenderThread的TaskQueue中,请求RenderThread渲染窗口的下一帧。

  • PendingRegistrationFrameCallbacks列表的IFrameCallback回调接口。每一个IFrameCallback回调接口代表的是一个动画帧,这些动画帧被同步到Vsync信号到来由RenderThread自动执行。具体来说,就是每当Vsync信号到来时,就将一个类型为DispatchFrameCallbacks的Task添加到RenderThread的TaskQueue去等待调度。一旦该Task被调度,就可以在RenderThread中执行注册在PendingRegistrationFrameCallbacks列表中的IFrameCallback回调接口了。


FinalizerDaemon 析构守护线程


对于重写成员函数finailze的对象,他们被GC决定回收时,并没有马上被回收,而是被放入到一个队列中,等待FinalizerDaemon守护线程去调用他们的成员函数finalize,然后再被回收。


FinalizerWatchdogDaemon 析构监护守护线程。


用来监控FinalizerDaemon线程的执行。一旦检测哪些重写了成员函数finalize的对象在执行成员函数finalize时超出一定的时候,那么就会退出VM。


ReferenceQueueDaemon 引用队列守护线程


我们知道在创建引用对象的时候,可以关联一个队列。当被引用对象引用的对象被GC回收的时候呀,被引用对象就会呗加入到其创建时关联的队列中去,这个加入队列的操作就是由ReferenceQueueDaemon守护线程来完成的。这样应用程序就可以知道哪些被引用对象引用的对象已经被回收了。


HeapTrimmerDaemon 堆裁剪守护线程


用于执行裁剪堆的操作,也就是用来将哪些空闲的堆内存归还给系统。


HeapTaskDaemon 线程


Android每个进程都有一个HeapTaskDaemon线程,在该线程内进行GC操作。
HeapTaskDaemon 继承自 Daemon 对象。Daemon 对象实际是一个Runnale,并且内部会创建一个线程,用于执行当前这个 Daemon unnable,这个内部线程的线程名就叫 HeapTaskDaemon。


private static class HeapTaskDaemon extends Daemon {
private static final HeapTaskDaemon INSTANCE = new HeapTaskDaemon();

HeapTaskDaemon() {
super("HeapTaskDaemon");
}

// Overrides the Daemon.interupt method which is called from Daemons.stop.
public synchronized void interrupt(Thread thread) {
VMRuntime.getRuntime().stopHeapTaskProcessor();
}

@Override public void runInternal() {
synchronized (this) {
if (isRunning()) {
// Needs to be synchronized or else we there is a race condition where we start
// the thread, call stopHeapTaskProcessor before we start the heap task
// processor, resulting in a deadlock since startHeapTaskProcessor restarts it
// while the other thread is waiting in Daemons.stop().
VMRuntime.getRuntime().startHeapTaskProcessor();
}
}
// This runs tasks until we are stopped and there is no more pending task.
VMRuntime.getRuntime().runHeapTasks();
}
}

Binder 线程


每个APP进程在启动之后会创建一个binder线程池,用于相应IPC客户端的请求。


例如APP与AMS等服务之间可以通过IPC双向通信,当APP作为服务端的时候,就需要通过Binder线程相应来自AMS的请求。一个Server进程中有一个最大的Binder线程数限制,默认为16个binder线程。


与系统服务通信或者自行实现多进程Binder通信大致需要注意一下几点:



  • 与系统通信的方法,建议使用try cache进行防护,防止App出现崩溃。

  • 需要注意调用频率,部分API调用频率过快响应会比较慢,从而导致主线程卡顿甚至ANR。


主线程


主线程也较UI线程。


这个线程作为Android 开发都比较熟悉。


三方线程


OkHttp相关


如果你使用OkHttp作为网络请求库,那么工程中会有以下几类线程


OkHttp Dispatcher


用于实际执行HTTP请求


  @get:Synchronized
@get:JvmName("executorService") val executorService: ExecutorService
get() {
if (executorServiceOrNull == null) {
executorServiceOrNull = ThreadPoolExecutor(0, Int.MAX_VALUE, 60, TimeUnit.SECONDS,
SynchronousQueue(), threadFactory("$okHttpName Dispatcher", false))
}
return executorServiceOrNull!!
}

OkHttp TaskRunner


4.x版本引入,用于管理和调度内部任务


  companion object {
@JvmField
val INSTANCE = TaskRunner(RealBackend(threadFactory("$okHttpName TaskRunner", daemon = true)))

val logger: Logger = Logger.getLogger(TaskRunner::class.java.name)
}

OkHttp ConnectionPool


负责清理和回收无用的连接池


  private val cleanupTask = object : Task("$okHttpName ConnectionPool") {
override fun runOnce() = cleanup(System.nanoTime())
}

Okio Watchdog


OkHttp中使用Watchdog来处理超时逻辑


  private class Watchdog internal constructor() : Thread("Okio Watchdog") {
init {
isDaemon = true
}

override fun run() {
while (true) {
try {
var timedOut: AsyncTimeout? = null
synchronized(AsyncTimeout::class.java) {
timedOut = awaitTimeout()

// The queue is completely empty. Let this thread exit and let another watchdog thread
// get created on the next call to scheduleTimeout().
if (timedOut === head) {
head = null
return
}
}

// Close the timed out node, if one was found.
timedOut?.timedOut()
} catch (ignored: InterruptedException) {
}
}
}
}

Glide相关


如果你使用Glide加载图片,那么工程中会有以下几类线程


"glide-source-thread-x"


用于从网络、文件系统或其他数据源中加载原始图像数据


  public static GlideExecutor.Builder newSourceBuilder() {
return new GlideExecutor.Builder(/* preventNetworkOperations= */ false)
.setThreadCount(calculateBestThreadCount())
.setName(DEFAULT_SOURCE_EXECUTOR_NAME);
}

"glide-disk-cache-thread-x"


用于从缓存中读取数据


  public static GlideExecutor.Builder newDiskCacheBuilder() {
return new GlideExecutor.Builder(/* preventNetworkOperations= */ true)
.setThreadCount(DEFAULT_DISK_CACHE_EXECUTOR_THREADS)
.setName(DEFAULT_DISK_CACHE_EXECUTOR_NAME);
}

source-unlimited


  public static GlideExecutor newUnlimitedSourceExecutor() {
return new GlideExecutor(
new ThreadPoolExecutor(
0,
Integer.MAX_VALUE,
KEEP_ALIVE_TIME_MS,
TimeUnit.MILLISECONDS,
new SynchronousQueue<Runnable>(),
new DefaultThreadFactory(
new DefaultPriorityThreadFactory(),
DEFAULT_SOURCE_UNLIMITED_EXECUTOR_NAME,
UncaughtThrowableStrategy.DEFAULT,
false)));
}

animation


用于执行动画


  public static GlideExecutor.Builder newAnimationBuilder() {
int maximumPoolSize = calculateAnimationExecutorThreadCount();
return new GlideExecutor.Builder(/* preventNetworkOperations= */ true)
.setThreadCount(maximumPoolSize)
.setName(DEFAULT_ANIMATION_EXECUTOR_NAME);
}

ARouter相关


如果你使用ARouter作为路由


ARouter task pool No.x , thread No.X


创建位置


    public static DefaultPoolExecutor getInstance() {
if (null == instance) {
synchronized (DefaultPoolExecutor.class) {
if (null == instance) {
instance = new DefaultPoolExecutor(
INIT_THREAD_COUNT,
MAX_THREAD_COUNT,
SURPLUS_THREAD_LIFE,
TimeUnit.SECONDS,
new ArrayBlockingQueue<Runnable>(64),
new DefaultThreadFactory());
}
}
}
return instance;
}

另外ARouter也为开发者提供了使用自己线程池的接口:


    static synchronized void setExecutor(ThreadPoolExecutor tpe) {
executor = tpe;
}

三方库总结


关于三方库中的线程池这里仅列举了几个库。如果你的工程中含有大量的三方库,我详细也会存在大量的工作线程。


一些三方库会提供接口允许开发者将其替换成工程内部统一维护的线程池,这样可以做到工程中线程的收敛。笔者遇到最离谱的应该是腾讯X5浏览器,由另外一个小组的同事引入到项目中,集成后发现其引入了大量的线程(大概几十个)。


其他


我个人不太喜欢发这些纯概念的东西,更喜欢总结一些在工程中遇到的问题、对应的解决方案以及思考。后面考虑尽量少发这类笔记,多输出一些思考。


作者:半山居士
来源:juejin.cn/post/7372445124753883155
收起阅读 »

最全的docx,pptx,xlsx(excel),pdf文件预览方案总结

web
最近遇到了文件预览的需求,但一搜索发现,这还不是一个简单的功能。于是又去查询了很多资料,调研了一些方案,也踩了好多坑。最后总结方案如下花钱解决(使用市面上现有的文件预览服务)微软google阿里云 IMMXDOCOffice Web 365wps开放平台前端方...
继续阅读 »

最近遇到了文件预览的需求,但一搜索发现,这还不是一个简单的功能。于是又去查询了很多资料,调研了一些方案,也踩了好多坑。最后总结方案如下

  1. 花钱解决(使用市面上现有的文件预览服务)
    1. 微软
    2. google
    3. 阿里云 IMM
    4. XDOC
    5. Office Web 365
    6. wps开放平台
  2. 前端方案
    1. pptx的预览方案
    2. pdf的预览方案
    3. docx的预览方案
    4. xlsx(excel)的预览方案
    5. 前端预览方案总结
  3. 服务端方案
    1. openOffice
    2. kkFileView
    3. onlyOffice

如果有其他人也遇到了同样的问题,有了这篇文章,希望能更方便的解决。

基本涵盖了所有解决方案。因此,标题写上 最全 的文件预览方案调研总结,应该不为过吧。

一.市面上现有的文件预览服务

1.微软

docx,pptx,xlsx可以说是office三件套,那自然得看一下 微软官方 提供的文件预览服务。使用方法特别简单,只需要将文件链接,拼接到参数后面即可。

记得encodeURL

https://view.officeapps.live.com/op/view.aspx?src=${encodeURIComponent(url)}

(1).PPTX预览效果:

image.png

  • 优点:还原度很高,功能很丰富,可以选择翻页,甚至支持点击播放动画。
  • 缺点:不知道是不是墙的原因,加载稍慢。

(2).Excel预览效果:

image.png

(3).Doxc预览效果

image.png

(4).PDF预览效果

这个我测试没有成功,返回了一个错误,其他人可以试试。

image.png

(5).总的来说

对于docx,pptx,xlsx都有较好的支持,pdf不行。

还有一个坑点是:这个服务是否稳定,有什么限制,是否收费,都查不到一个定论。在office官方网站上甚至找不到介绍这个东西的地方。

目前只能找到一个Q&A:answers.microsoft.com/en-us/msoff…

微软官方人员回答表示:

image.png

翻译翻译,就是:几乎永久使用,没有收费计划,不会存储预览的文件数据,限制文件10MB,建议用于 查看互联网上公开的文件

但经过某些用户测试发现:

image.png

使用了微软的文件预览服务,然后删除了文件地址,仍然可访问,但过一段时间会失效。

2.Google Drive 查看器

接入简单,同 Office Web Viewer,只需要把 src 改为https://drive.google.com/viewer?url=${encodeURIComponent(url)}即可。

限制25MB,支持以下格式:

image.png

测试效果,支持docx,pptx,xlsx,pdf预览,但pptx预览的效果不如微软,没有动画效果,样式有小部分会错乱。

由于某些众所周知的原因,不可用

3.阿里云 IMM

官方文档如下:help.aliyun.com/document_de…

image.png

付费使用

4.XDOC 文档预览

说了一些大厂的,在介绍一些其他的,需要自行分辨

官网地址:view.xdocin.com/view-xdocin…

image.png

5.Office Web 365

需要注意的是,虽然名字很像office,但我们看网页的Copyright可以发现,其实是一个西安的公司,不是微软

但毕竟也提供了文件预览的服务

官网地址:http://www.officeweb365.com/

image.png

6.WPS开放平台

官方地址:solution.wps.cn/

image.png

付费使用,价格如下:

image.png

二.前端处理方案

1.pptx的预览方案

先查一下有没有现成的轮子,目前pptx的开源预览方案能找到的只有这个:github.com/g21589/PPTX… 。但已经六七年没有更新,也没有维护,笔者使用的时候发现有很多兼容性问题。

简单来说就是,没有。对于这种情况,我们可以自行解析,主要步骤如下:

  1. 查询pptx的国际标准
  2. 解析pptx文件
  3. 渲染成html或者canvas进行展示

我们先去找一下pptx的国际标准,官方地址:officeopenxml

先解释下什么是officeopenxml:

Office OpenXML,也称为OpenXML或OOXML,是一种基于XML的办公·文档格式,包括文字处理文档、电子表格、演示文稿以及图表、图表、形状和其他图形材料。该规范由微软开发,并于2006年被ECMA国际采用为ECMA-376。第二个版本于2008年12月发布,第三个版本于2011年6月发布。该规范已被ISO和IEC采用为ISO/IEC 29500。

虽然Microsoft继续支持较旧的二进制格式(.doc、.xls和.ppt),但OOXML现在是所有Microsoft Office文档(.docx、.xlsx和.pptx)的默认格式。

由此可见,Office OpenXML由微软开发,目前已经是国际标准。接下来我们看一下pptx里面有哪些内容,具体可以看pptx的官方标准:officeopenxml-pptx

PresentationML或.pptx文件是一个zip文件,其中包含许多“部分”(通常是UTF-8或UTF-16编码)或XML文件。该包还可能包含其他媒体文件,例如图像。该结构根据 OOXML 标准 ECMA-376 第 2 部分中概述的开放打包约定进行组织。

image.png

根据国际标准,我们知道,pptx文件本质就是一个zip文件,其中包含许多部分:

部件的数量和类型将根据演示文稿中的内容而有所不同,但始终会有一个 [Content_Types].xml、一个或多个关系 (.rels) 部件和一个演示文稿部件(演示文稿.xml),它位于 ppt 文件夹中,用于Microsoft Powerpoint 文件。通常,还将至少有一个幻灯片部件,以及一张母版幻灯片和一张版式幻灯片,从中形成幻灯片。

那么js如何读取zip呢?

找到一个工具: http://www.npmjs.com/package/jsz…

于是我们可以开始尝试解析pptx了。

import JSZip from 'jszip'
// 加载pptx数据
const zip = await JSZip.loadAsync(pptxData)
  • 解析[Content_Types].xml

每个pptx必然会有一个 [Content_Types].xml。此文件包含包中部件的所有内容类型的列表。每个部件及其类型都必须列在 [Content_Types].xml 中。通过它里面的内容,可以解析其他的文件数据


const filesInfo = await getContentTypes(zip)

async function getContentTypes(zip: JSZip) {
const ContentTypesJson = await readXmlFile(zip, '[Content_Types].xml')
const subObj = ContentTypesJson['Types']['Override']
const slidesLocArray = []
const slideLayoutsLocArray = []
for (let i = 0; i < subObj.length; i++) {
switch (subObj[i]['attrs']['ContentType']) {
case 'application/vnd.openxmlformats-officedocument.presentationml.slide+xml':
slidesLocArray.push(subObj[i]['attrs']['PartName'].substr(1))
break
case 'application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml':
slideLayoutsLocArray.push(subObj[i]['attrs']['PartName'].substr(1))
break
default:
}
}
return {
slides: slidesLocArray,
slideLayouts: slideLayoutsLocArray,
}
}
  • 解析演示文稿

先获取ppt目录下的presentation.xml演示文稿的大小

由于演示文稿是xml格式,要真正的读取内容需要执行 readXmlFile

const slideSize = await getSlideSize(zip)
async function getSlideSize(zip: JSZip) {
const content = await readXmlFile(zip, 'ppt/presentation.xml')
const sldSzAttrs = content['p:presentation']['p:sldSz']['attrs']
return {
width: (parseInt(sldSzAttrs['cx']) * 96) / 914400,
height: (parseInt(sldSzAttrs['cy']) * 96) / 914400,
}
}

  • 加载主题

根据 officeopenxml的标准解释

每个包都包含一个关系部件,用于定义其他部件之间的关系以及与包外部资源的关系。这样可以将关系与内容分开,并且可以轻松地更改关系,而无需更改引用目标的源。

除了包的关系部分之外,作为一个或多个关系源的每个部件都有自己的关系部分。每个这样的关系部件都可以在部件的_rels子文件夹中找到,并通过在部件名称后附加“.rels”来命名。

其中主题的相关信息就在ppt/_rels/presentation.xml.rels


async function loadTheme(zip: JSZip) {
const preResContent = await readXmlFile(
zip,
'ppt/_rels/presentation.xml.rels',
)
const relationshipArray = preResContent['Relationships']['Relationship']
let themeURI
if (relationshipArray.constructor === Array) {
for (let i = 0; i < relationshipArray.length; i++) {
if (
relationshipArray[i]['attrs']['Type'] ===
'http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme'
) {
themeURI = relationshipArray[i]['attrs']['Target']
break
}
}
} else if (
relationshipArray['attrs']['Type'] ===
'http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme'
) {
themeURI = relationshipArray['attrs']['Target']
}

if (themeURI === undefined) {
throw Error("Can't open theme file.")
}

return readXmlFile(zip, 'ppt/' + themeURI)
}

后续ppt里面的其他内容,都可以这么去解析。根据officeopenxml标准,可能包含:

PartDescription
Comments AuthorsContains information about each author who has added a comment to the presentation.
CommentsContains comments for a single slide.
Handout MasterContains the look, position, and size of the slides, notes, header and footer text, date, or page number on the presentation's handout. There can be only one such part.
Notes MasterContains information about the content and formatting of all notes pages. There can be only one such part.
Notes SlideContains the notes for a single slide.
PresentationContains the definition of a slide presentation. There must be one and only one such part. See Presentation.
Presentation PropertiesContains all of the presentation's properties. There must be one and only one such part.
SlideContains the content of a single slide.
Slide LayoutContains the definition for a slide template. It defines the default appearance and positioning of drawing objects on the slide. There must be one or more such parts.
Slide MasterContains the master definition of formatting, text, and objects that appear on each slide in the presentation that is derived from the slide master. There must be one or more such parts.
Slide Synchronization DataContains properties specifying the current state of a slide that is being synchronized with a version of the slide stored on a central server.
User-Defined TagsContains a set of user-defined properties for an object in a presentation. There can be zero or more such parts.
View PropertiesContains display properties for the presentation.

等等内容,我们根据标准一点点解析并渲染就好了。

完整源码:ranui

使用文档:preview组件

2.pdf的预览方案

(1).iframe和embed

pdf比较特别,一般的浏览器默认支持预览pdf。因此,我们可以使用浏览器的能力:

<iframe src="viewFileUrl" />

但这样就完全依赖浏览器,对PDF的展示,交互,是否支持全看浏览器的能力,且不同的浏览器展示和交互往往不同,如果需要统一的话,最好还是尝试其他方案。

embed的解析方式也是一样,这里不举例子了

(2)pdfjs

npm: http://www.npmjs.com/package/pdf…

github地址:github.com/mozilla/pdf…

mozilla出品,就是我们常见的MDN的老大。

而且目前 火狐浏览器 使用的 PDF 预览就是采用这个,我们可以用火狐浏览器打开pdf文件,查看浏览器使用的js就能发现

image.png

需要注意的是,最新版pdf.js限制了node版本,需要大于等于18

github链接:github.com/mozilla/pdf…

image.png

如果你项目node版本小于这个情况,可能会无法使用。

如果遇到这种情况,评论区 @敲敲敲敲暴你脑袋 提出一种解决方案,以前老的版本没有限制,可以用以前版本,详情见评论区。

具体使用情况如下:

import * as pdfjs from 'pdfjs-dist'
import * as pdfjsWorker from 'pdfjs-dist/build/pdf.work.entry'

interface Viewport {
width: number
height: number
viewBox: Array<number>
}

interface RenderContext {
canvasContext: CanvasRenderingContext2D | null
transform: Array<number>
viewport: Viewport
}

interface PDFPageProxy {
pageNumber: number
getViewport: () => Viewport
render: (options: RenderContext) => void
}

interface PDFDocumentProxy {
numPages: number
getPage: (x: number) => Promise<PDFPageProxy>
}

class PdfPreview {
private pdfDoc: PDFDocumentProxy | undefined
pageNumber: number
total: number
dom: HTMLElement
pdf: string | ArrayBuffer
constructor(pdf: string | ArrayBuffer, dom: HTMLElement | undefined) {
this.pageNumber = 1
this.total = 0
this.pdfDoc = undefined
this.pdf = pdf
this.dom = dom ? dom : document.body
}
private getPdfPage = (number: number) => {
return new Promise((resolve, reject) => {
if (this.pdfDoc) {
this.pdfDoc.getPage(number).then((page: PDFPageProxy) => {
const viewport = page.getViewport()
const canvas = document.createElement('canvas')
this.dom.appendChild(canvas)
const context = canvas.getContext('2d')
const [_, __, width, height] = viewport.viewBox
canvas.width = width
canvas.height = height
viewport.width = width
viewport.height = height
canvas.style.width = Math.floor(viewport.width) + 'px'
canvas.style.height = Math.floor(viewport.height) + 'px'
const renderContext = {
canvasContext: context,
viewport: viewport,
transform: [1, 0, 0, -1, 0, viewport.height],
}
page.render(renderContext)
resolve({ success: true, data: page })
})
} else {
reject({ success: false, data: null, message: 'pdfDoc is undefined' })
}
})
}
pdfPreview = () => {
window.pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker
window.pdfjsLib
.getDocument(this.pdf)
.promise.then(async (doc: PDFDocumentProxy) => {
this.pdfDoc = doc
this.total = doc.numPages
for (let i = 1; i <= this.total; i++) {
await this.getPdfPage(i)
}
})
}
prevPage = () => {
if (this.pageNumber > 1) {
this.pageNumber -= 1
} else {
this.pageNumber = 1
}
this.getPdfPage(this.pageNumber)
}
nextPage = () => {
if (this.pageNumber < this.total) {
this.pageNumber += 1
} else {
this.pageNumber = this.total
}
this.getPdfPage(this.pageNumber)
}
}

const createReader = (file: File): Promise<string | ArrayBuffer | null> => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = () => {
resolve(reader.result)
}
reader.onerror = (error) => {
reject(error)
}
reader.onabort = (abort) => {
reject(abort)
}
})
}

export const renderPdf = async (
file: File,
dom?: HTMLElement,
): Promise<void> => {
try {
if (typeof window !== 'undefined') {
const pdf = await createReader(file)
if (pdf) {
const PDF = new PdfPreview(pdf, dom)
PDF.pdfPreview()
}
}
} catch (error) {
console.log('renderPdf', error)
}
}

3.docx的预览方案

我们可以去查看docx的国际标准,去解析文件格式,渲染成htmlcanvas,不过比较好的是,已经有人这么做了,还开源了

npm地址:http://www.npmjs.com/package/doc…

使用方法如下:

import { renderAsync } from 'docx-preview'

interface DocxOptions {
bodyContainer?: HTMLElement | null
styleContainer?: HTMLElement
buffer: Blob
docxOptions?: Partial<Record<string, string | boolean>>
}

export const renderDocx = (options: DocxOptions): Promise<void> | undefined => {
if (typeof window !== 'undefined') {
const { bodyContainer, styleContainer, buffer, docxOptions = {} } = options
const defaultOptions = {
className: 'docx',
ignoreLastRenderedPageBreak: false,
}
const configuration = Object.assign({}, defaultOptions, docxOptions)
if (bodyContainer) {
return renderAsync(buffer, bodyContainer, styleContainer, configuration)
} else {
const contain = document.createElement('div')
document.body.appendChild(contain)
return renderAsync(buffer, contain, styleContainer, configuration)
}
}
}

4.xlsx的预览方案

我们可以使用这个:

npm地址:http://www.npmjs.com/package/@vu…

支持vue2vue3,也有js的版本

对于xlsx的预览方案,这个是找到最好用的了。

5.前端预览方案总结

我们对以上找到的优秀的解决方案,进行改进和总结,并封装成一个web components组件:preview组件

为什么是web components组件?

因为它跟框架无关,可以在任何框架中使用,且使用起来跟原生的div标签一样方便。

并编写使用文档: preview组件文档, 文档支持交互体验。

源码公开,MIT协议。

目前docx,pdf,xlsx预览基本可以了,都是最好的方案。pptx预览效果不太好,因为需要自行解析。不过源码完全公开,需要的可以提issuepr或者干脆自取或修改,源码地址:github.com/chaxus/ran/…

三.服务端预览方案

1.openOffice

由于浏览器不能直接打开docx,pptx,xlsx等格式文件,但可以直接打开pdf和图片.因此,我们可以换一个思路,用服务端去转换下文件的格式,转换成浏览器能识别的格式,然后再让浏览器打开,这不就OK了吗,甚至不需要前端处理了。

我们可以借助openOffice的能力,先介绍一下openOffice:

Apache OpenOffice是领先的开源办公软件套件,用于文字处理,电子表格,演示文稿,图形,数据库等。它有多种语言版本,适用于所有常用计算机。它以国际开放标准格式存储您的所有数据,还可以从其他常见的办公软件包中读取和写入文件。它可以出于任何目的完全免费下载和使用。

官网如下:http://www.openoffice.org/

需要先下载opneOffice,找到bin目录,进行设置

configuration.setOfficeHome("这里的路径一般为C:\\Program Files (x86)\\OpenOffice 4");

测试下转换的文件路径

    public static void main(String[] args) {
convertToPDF("/Users/Desktop/asdf.docx", "/Users/Desktop/adsf.pdf");
}

完整如下:


package org.example;

import org.artofsolving.jodconverter.OfficeDocumentConverter;
import org.artofsolving.jodconverter.office.DefaultOfficeManagerConfiguration;
import org.artofsolving.jodconverter.office.OfficeManager;

import java.io.File;

public class OfficeUtil {

private static OfficeManager officeManager;
private static int port[] = {8100};

/**
* start openOffice service.
*/

public static void startService() {
DefaultOfficeManagerConfiguration configuration = new DefaultOfficeManagerConfiguration();
try {
System.out.println("准备启动office转换服务....");
configuration.setOfficeHome("这里的路径一般为C:\\Program Files (x86)\\OpenOffice 4");
configuration.setPortNumbers(port); // 设置转换端口,默认为8100
configuration.setTaskExecutionTimeout(1000 * 60 * 30L);// 设置任务执行超时为30分钟
configuration.setTaskQueueTimeout(1000 * 60 * 60 * 24L);// 设置任务队列超时为24小时
officeManager = configuration.buildOfficeManager();
officeManager.start(); // 启动服务
System.out.println("office转换服务启动成功!");
} catch (Exception e) {
System.out.println("office转换服务启动失败!详细信息:" + e);
}
}

/**
* stop openOffice service.
*/

public static void stopService() {
System.out.println("准备关闭office转换服务....");
if (officeManager != null) {
officeManager.stop();
}
System.out.println("office转换服务关闭成功!");
}

public static void convertToPDF(String inputFile, String outputFile) {
startService();
System.out.println("进行文档转换转换:" + inputFile + " --> " + outputFile);
OfficeDocumentConverter converter = new OfficeDocumentConverter(officeManager);
converter.convert(new File(inputFile), new File(outputFile));
stopService();
}

public static void main(String[] args) {
convertToPDF("/Users/koolearn/Desktop/asdf.docx", "/Users/koolearn/Desktop/adsf.pdf");
}
}

2.kkFileView

github地址:github.com/kekingcn/kk…

支持的文件预览格式非常丰富 image.png

接下来是 从零到一 的启动步骤,按着步骤来,任何人都能搞定

  1. 安装java:
brew install java
  1. 安装maven,java的包管理工具:
brew install mvn
  1. 检查是否安装成功

执行java --versionmvn -v。我这里遇到mvn找不到java home的报错。解决方式如下:

我用的是zsh,所以需要去.zshrc添加路径:

export JAVA_HOME=$(/usr/libexec/java_home)

添加完后,执行

source .zshrc
  1. 安装下libreoffice:

kkFileView明确要求的额外依赖,否则无法启动

brew install libreoffice 
  1. mvn安装依赖

进入项目,在根目录执行依赖安装,同时清理缓存,跳过单测(遇到了单测报错的问题)

mvn clean install -DskipTests
  1. 启动项目

找到主文件,主函数mian,点击vscode上面的Run即可执行,路径如下图

image.png

  1. 访问页面

启动完成后,点击终端输出的地址

image.png

  1. 最终结果

最终展示如下,可以添加链接进行预览,也可以选择本地文件进行预览

image.png

预览效果非常好

3.onlyOffice

官网地址:http://www.onlyoffice.com/zh

github地址:github.com/ONLYOFFICE

开发者版本和社区版免费,企业版付费:http://www.onlyoffice.com/zh/docs-ent…

预览的文件种类没有kkFileView多,但对office三件套有很好的支持,甚至支持多人编辑。

四.总结

  1. 外部服务,推荐微软的view.officeapps.live.com/op/view.aspx,但只建议预览一些互联网公开的文件,不建议使用在要求保密性和稳定性的文件。
  2. 对保密性和稳定性有要求,且不差钱的,可以试试大厂服务,阿里云解决方案。
  3. 服务端技术比较给力的,使用服务端预览方案。目前最好最全的效果是服务端预览方案。
  4. 不想花钱,没有服务器的,使用前端预览方案,客户端渲染零成本。

五.参考文档:



    作者:然燃
    来源:juejin.cn/post/7268530145208451124
    收起阅读 »

    程序员男盆友给自己做了一款增进感情的小程序

    web
    前言 又是无聊的一天,逛GitHub的时候发现一个给女朋友做了一个互动微信小程序,据说女朋友更爱自己了,所以当晚。。。。给自己做了丰盛的晚餐,我当即点开立马开发粘贴复制起来,想到做的小程序可以和未来的女朋友增进感觉,越加猩粪。。。 回到正题,这个库有1.1k的...
    继续阅读 »

    前言


    又是无聊的一天,逛GitHub的时候发现一个给女朋友做了一个互动微信小程序,据说女朋友更爱自己了,所以当晚。。。。给自己做了丰盛的晚餐,我当即点开立马开发粘贴复制起来,想到做的小程序可以和未来的女朋友增进感觉,越加猩粪。。。


    回到正题,这个库有1.1k的star,推荐新人入坑原生小程序的可以学习


    项目地址:github.com/UxxHans/Rai…


    image.png


    云开发情侣互动小程序(做任务,攒积分,换商品)


    这是使用云开发能力构建的情侣互动小程序,可以跟女朋友互动哦,其中使用了云开发基础能力的使用:



    • 数据库:对文档型数据库进行读写和管理

    • 云函数:在云端运行的代码,开发者只需编写业务逻辑代码


    使用逻辑


    打个比方:



    • 女朋友发布任务->女朋友来做任务->做完后由你来确认完成->女朋友收到积分

    • 你发布商品(洗碗券)->女朋友使用积分购买->商品进入到女朋友的库存->女朋友拿着洗碗券叫你洗碗->你洗碗->女朋友将物品(洗碗券)标记为已使用(不可逆)

    • 这样做的原因是 不想给任何一方能自说自话 增加自己或者对方积分的能力[点击完成任务的人不能是获得积分的人也不能是自己]


    版本新增



    • 将所有非云函数的云逻辑封装为云函数

    • 新增了仓库系统,购买了的商品会存入仓库,然后再被使用

    • 新增了搜索框,可以搜索物品和任务

    • 新增了滑动窗,可以自动播放显示多张图片

    • 新增了商品和任务预设,添加商品或任务可以使用预设,非常迅速

    • 将新增按钮变为可拖拽的页面悬浮按钮

    • 购买,上架,新建任务的时间都会被记录并显示

    • 取消了点击左边圆圈来完成或者购买,统一改为左滑菜单

    • 左滑菜单统一用图标显示,更加精简

    • 使用特效升级了详细信息页面与添加页面的美观度

    • 添加任务或物品界面积分文本框改为滑块

    • 在商城添加了顶栏显示积分,更直观

    • 使用表情符号简单的增加了美感


    效果图与动画


    Animation.gif
    image.png


    部署方式



    image.png



    • 登录之后先在主页完成小程序信息类目

    • 然后可以在管理中的版本管理成员管理中发布小程序体验版并邀请对象使用


    image.png



    • 随后可以在开发中的开发工具里下载微信开发者工具

    • 打开微信开发工具->登录->导入我的文件夹-进入工具

    • 在左上角五个选项中选择云开发->按照提示开通云开发(这里可以选择免费的,不过限量,我开发用的多,6块够用了)


    image.png



    • 进入后点击数据库->在集合名称添加四个集合:MarketListMissionListStorageListUserList

    • 之前使用过上一个版本的,需要清空所有数据,因为字段结构不一样


    image.png



    • UserList中添加两个默认记录, 在两个记录中分别添加两个字段:


    字段 = _openid | 类型 = string | 值 = 先不填
    字段 = credit | 类型 = number | 值 = 0


    • 打开云开发的控制台的概览选项->复制环境ID

    • 打开 miniprogram/envList.js 将内容全部替换成如下,注意替换环境ID


    module.exports = {
    envList: [{
    envId:'上述步骤中你获得的环境ID (保留单引号)'
    }]
    }


    • 右键点击 cloudfunctions 中的每个文件夹并选择云函数云端安装依赖上传 (有点麻烦但是这是一定要做的)


    image.png



    • 如果云开发里面的云函数页面是这样的就是成功了


    image.png



    • 没有安装npm或者NodeJs, 需要先在这里安装: nodejs.org/dist/v16.15…

    • 安装好的,就直接运行cloudfunctions/Install-WX-Server-SDK.bat

    • 不成功的话可以在命令行输入 npm install --save wx-server-sdk@latest

    • 然后创建体验版小程序->通过开发者账号分享到女朋友手机上(要先登录小程序开发者账号)

    • 在两个手机上运行小程序->分别在两个手机上的小程序里新建任务

    • 然后回到云开发控制台的missionlist数据库集合->找自己和女朋友的_openid变量并记录

    • 把这两个记录下来的_openid拷贝到云开发控制台UserList数据集合里刚刚没填的_openid变量中

    • 把这两个记录下来的_openid拷贝到miniprogram/app.js里的_openidA_openidB的值里(A是卡比,B是瓦豆)

    • miniprogram/app.js里把userAuserB改成自己和女朋友的名字

    • 然后再试试看是不是成功了! (别忘了任务和物品左滑可以完成和购买)

    • 消息提醒功能:

    • 参考blog.csdn.net/hell_orld/a…allsobaiduend~default-2-110675777-null-null.142^v87^insert_down28v1,239^v2^insert_chatgpt&utm_term=%E5%BE%AE%E4%BF%A1%E5%B0%8F%E7%A8%8B%E5%BA%8F%E9%80%9A%E7%9F%A5%E4%BA%91%E5%BC%80%E5%8F%91&spm=1018.2226.3001.4187配置自己想要的模板\

    • miniprogram/pages/MainPage/index.jsminiprogram/pages/MissionAdd/index.js里把模板号换成自己想要的模板号

    • cloudfunctions/information/index.js里把UserA和UserB的openid值进行修改就能使用消息提醒功能了


    image.png



    • 别忘了最后点击右上角上传->然后在开发者账号上设置小程序为体验版->不用去发布去审核


    image.png



    旧版效果图


    image.png


    作者:嚣张农民
    来源:juejin.cn/post/7298966889358196788
    收起阅读 »

    两台Android 设备同一个局域网下如何自由通信?

    一、背景 笔者前段时间开发了一款非常有意思的项目,已知在同一个局域网下有两款App分别运行在不同的设备上,业务上两款App分别具有不同的功能,同时两款App需要支持互相通信。后面我们称两款设备上的两款App一个为Server,一个为Client。 基于此我们需...
    继续阅读 »

    一、背景


    笔者前段时间开发了一款非常有意思的项目,已知在同一个局域网下有两款App分别运行在不同的设备上,业务上两款App分别具有不同的功能,同时两款App需要支持互相通信。后面我们称两款设备上的两款App一个为Server,一个为Client。


    基于此我们需要考虑以下几点



    • 如何发现对方

    • 两款App如何通信

    • 通信协议如何选择


    二、发现对方


    在局域网中查找识别我们可以基于DNS-SD协议。


    1、DNS-SD协议介绍


    DNS-SD(Domain Name System - Service Discovery)是一种用于在局域网(Local Area Network,LAN)中发现服务的协议。它使用DNS协议扩展了域名系统(Domain Name System,DNS)的功能,使得客户端能够在局域网中查找特定类型的服务,并获取有关该服务的信息。


    在Android 中也有对应的Api可以使用:NsdManager。
    NsdManager主要实现三个功能:



    • 注册

    • 发现

    • 解析


    其中一台设备作为服务端(即上面的Server App)通过NsdManager注册服务,提供自己的IP地址与端口号,同时可以提供用于标识自己的信息,例如设备ID。当一个局域网中有多台服务时,客户端在发现服务后,可以基于标识信息确认是否为自己需要找到的服务端。


    另一台设备作为客户端(即上面的Client App),通过NsdManager的发现接口去发现服务。发现服务之后,再调用解析接口,获取到对应的信息,如服务端的IP地址、端口号、设备标识信息等。



    详细的官方介绍地址:developer.android.com/reference/a…



    2、注册


    作为服务端的Server App,通过NsdManager注册。


    NsdManager nsdManager = (NsdManager) context.getSystemService(Context.NSD_SERVICE);
    NsdServiceInfo serviceInfo = new NsdServiceInfo();
    serviceInfo.setServiceName(ServiceName);
    serviceInfo.setServiceType(ServiceType);
    int localPort = getLocalPort();
    serviceInfo.setPort(localPort);
    serviceInfo.setAttribute(Attribute_UUID, UuidManager.INSTANCE.getUUID());
    String ipAddress = LocalIpUtils.getIPAddress();
    serviceInfo.setAttribute(Attribute_IP, ipAddress);
    nsdManager.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, new NsdManager.RegistrationListener() {
    @Override
    public void onRegistrationFailed(NsdServiceInfo nsdServiceInfo, int i) {
    //异常上报
    }

    @Override
    public void onUnregistrationFailed(NsdServiceInfo nsdServiceInfo, int i) {}

    @Override
    public void onServiceRegistered(NsdServiceInfo nsdServiceInfo) {
    //注册成功
    }

    @Override
    public void onServiceUnregistered(NsdServiceInfo nsdServiceInfo) {}
    });


    3、发现


    Client App,调用NsdManager#discoverServices()接口去发现服务。


    NsdManager nsdManager = (NsdManager) context.getSystemService(Context.NSD_SERVICE);
    discoveryListener = new NsdManager.DiscoveryListener() {
    @Override
    public void onStartDiscoveryFailed(String s, int i) {}

    @Override
    public void onStopDiscoveryFailed(String s, int i) {}

    @Override
    public void onDiscoveryStarted(String s) {}

    @Override
    public void onDiscoveryStopped(String s) {}

    @Override
    public void onServiceFound(NsdServiceInfo nsdServiceInfo) {}

    @Override
    public void onServiceLost(NsdServiceInfo nsdServiceInfo) {}
    };
    nsdManager.discoverServices(NsdServer.ServiceType, NsdManager.PROTOCOL_DNS_SD, discoveryListener);


    4、解析


    Client App 收到onServcieFound回调之后,就可以调用解析接口解析数据了


    nsdManager.resolveService(nsdServiceInfo, new NsdManager.ResolveListener() {
    @Override
    public void onServiceResolved(NsdServiceInfo nsdServiceInfo) {
    Map<String, byte[]> attributes = nsdServiceInfo.getAttributes();
    }

    @Override
    public void onResolveFailed(NsdServiceInfo nsdServiceInfo, int i) {}
    });


    二、TCP通信


    发现设备之后,我们要考虑两台设备要如何通信。此时我们可以有两套方案,分别是HTTP以及TCP。


    HTTP


    使用HTTP的话,就是在Server App上启动一个HTTP服务,我们可以预定义一些接口用于和Client来通信。
    优点是比较简单,缺点是Server App没有办法主动通知Client App。


    TCP


    直接使用TCP协议的话,我们可以考虑选择一个支持TCP通信的框架,自定义一个简易的通信协议。优点是客户端与服务端可以互相通信,满足我们的需求。缺点是相对复杂一些。


    在我们的项目中,我们有两台设备交互通信的需求,所以选择了TCP协议。


    1、TCP通信库的选择


    在我们的项目中使用了Netty作为TCP通信框架。关于Netty其大名鼎鼎,网络上的分析文章一大把,这里就不详细介绍了。Netty在国内有一位步道的大神名为李林峰(在华为工作),有兴趣的同学可以去看看大神的书。


    2、简易的TCP协议设计


    由于业务比较简单,所以这里我们将TCP通信协议进行简化。整体协议大致如下:[Int][String]。


    其中Int用于指定后面String字符串长度,我们可以基于这个Int来处理拆包、粘包的问题,这个逻辑后面详细介绍。String可以是JSON结构,可以依据业务来定义JSON中字段。


    3、TCP服务端


    使用Netty实现TCP服务端大致代码


    bossGr0up = new NioEventLoopGr0up();
    workerGr0up = new NioEventLoopGr0up();
    //构建引导程序
    mBootstrap = new ServerBootstrap();
    //设置EventGr0up
    mBootstrap.group(bossGr0up, workerGr0up);
    //设置Channel
    mBootstrap.channel(NioServerSocketChannel.class);
    mBootstrap.option(ChannelOption.SO_BACKLOG, 128);
    //设置的好处是禁用Nagle算法。表示不延迟立即发送
    //这个算法试图减少TCP包的数量和结构性开销,将多个较小的包组合较大的包进行发送。
    //这个算法收TCP延迟确认影响,会导致相继两次向链接发送请求包。
    mBootstrap.option(ChannelOption.TCP_NODELAY, false);
    mBootstrap.option(ChannelOption.SO_KEEPALIVE, true);
    mBootstrap.childHandler(new CustomChannelInitializer());
    channelFuture = mBootstrap.bind(inetPort).sync();

    以上TCP服务就启动了。


    4、TCP客户端


    使用Netty实现TCP客户端大致代码:


    初始化


    //构建线程池
    mGr0up = new NioEventLoopGr0up();
    //构建引导程序
    mBootstrap = new Bootstrap();
    //设置EventGr0up
    mBootstrap.group(mGr0up);
    //设置Channel
    mBootstrap.channel(NioSocketChannel.class);
    //设置的好处是禁用Nagle算法。表示不延迟立即发送
    //这个算法试图减少TCP包的数量和结构性开销,将多个较小的包组合较大的包进行发送。
    //这个算法收TCP延迟确认影响,会导致相继两次向链接发送请求包。
    mBootstrap.option(ChannelOption.TCP_NODELAY, false);
    mBootstrap.option(ChannelOption.SO_KEEPALIVE, true);
    mBootstrap.remoteAddress(ip.getIp(), ip.getPort());
    mBootstrap.handler(new CustomChannelInitializer());

    建立连接


    ChannelFuture channelFuture = mBootstrap.connect();
    channelFuture.addListener(new FutureListener() {
    @Override
    public void operationComplete(Future future) {
    final boolean isSuccess = future.isSuccess();
    writeLog("operation complete future.isSuccess: " + isSuccess);
    }
    });


    断开连接


    public void disConnect(boolean onPurpose) {
    try {
    if (mGr0up != null) {
    mGr0up.shutdownGracefully();
    }
    } catch (Exception e) {
    e.printStackTrace();
    }
    try {
    if (mChannel != null) {
    mChannel.close();
    }
    } catch (Exception e) {
    e.printStackTrace();
    }
    try {
    if (mChannelHandlerContext != null) {
    mChannelHandlerContext.close();
    }
    } catch (Exception e) {
    e.printStackTrace();
    }
    }

    我们可以在operationComplete()方法中,确认建立连接成功。


    ChannelInitializer


    为了让更多协议和其他各种方式处理数据,Netty有了Handler组件。Handler就是为了处理Netty里面的置顶事件或一组事件。 ChannelInitializer 的作用就是将Handler 添加到ChannelPipeline中。当你发送或收到消息的时候,这些Handler就决定怎么处理你的数据。


    public class CustomChannelInitializer extends ChannelInitializer<SocketChannel> {

    @Override
    protected void initChannel(SocketChannel socketChannel) {
    ChannelPipeline pipeline = socketChannel.pipeline();
    mCustomDecoder = new CustomDecoder();
    mCustomEncoder = new CustomEncoder();
    mCustomHandler = new CustomHandler();

    pipeline.addLast(mCustomDecoder);
    pipeline.addLast(mCustomEncoder);
    pipeline.addLast(mCustomHandler);
    }

    @Override
    public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
    super.handlerAdded(ctx);
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
    super.exceptionCaught(ctx, cause);
    }
    }


    ChannelPipeline 是一个管道,Handler就是里面一层一层要对数据进行处理的事件。所有的Handler都一个顶层接口ChannelHandler。ChannelHandler有两个子接口:



    • ChannelInboundHandler

    • ChannelOutboundHandler


    Netty中数据流有2个方向:



    • 数据进(应用收到消息)的时候与ChannelInboundHandler有关。

    • 数据出(应用发出数据)的时候与ChannelOutboundHandler有关。


    为了将数据从一端发送到另一端,一般都会有一个或多个ChannelHandler用各种方式对数据进行操作。 决定这些Handler以一种特定的顺序处理数据的是ChannelPipeline。


    ChannelInboundHandler与ChannelOutboundHandler可以混在同一个ChannelPipeline里面。当应用收到数据时,首先从ChannelPipeline的头部进入到一个ChannelInboundHandler 。第一个ChannelInboundHandler处理后传给下一个ChannelInboundHandler。 然后ChannelPipeline中没有其他的ChannelInboundHandler 了数据就会到达ChannelPipeline的尾部,也就是应用对数据的处理已经完成了。


    数据流出的过程是返过来的,首先从ChannelPipeline的尾部开始进入到最后一个ChannelOutboundHandler,最后一个ChannelOutboundHandler处理后,传给前面一个ChannelOutboundHandler。 和进不同的,进是从前到后,而出是从后到前。没有多余的ChannelOutboundHandler的时候,数据进入实际网络中传输,触发一些IO操作。


    一旦一个ChannelHandler被添加到ChannelPipeline中,它就会获得一个ChannelHandlerContext。一般情况下,获得这个对象并持有它是安全的, 不过在数据包协议的时候不一样安全,例如UDP协议。 在Netty中有两种发送数据的方式。你可以写到Channel中或者使用ChannelhandlerContext对象。他们的主要区别是,直接写到Channel,则数据会从ChannelPipeline头部传到尾部。 每一个ChannelHandler都会处理数据,而使用ChannelHandlerContext则是将数据传送到下一个ChannelHandler。


    一个 Channel 包含了一个 ChannelPipeline, 而 ChannelPipeline 中又维护了一个由 ChannelHandlerContext 组成的双向链表, 并且每个 ChannelHandlerContext 中又关联着一个 ChannelHandler。 入站事件和出站事件在一个双向链表中,入站事件会从链表head往后传递到最后一个入站的handler,出站事件会从链表tail往前传递到最前一个出站的handler,两种类型的handler互不干扰。


    我们在 CustomChannelInitializer#initChannel()方法中添加编码、解码器。其中 CustomHandler 继承自SimpleChannelInboundHandler用来接收消息。


    三、编解码


    当你用Netty接收或发送消息,必须将其从一种格式转成另一种格式。比如收消息,你需要从字节转为Java对象。发消息就是将Java对象转成字节发出去。


    Netty中有各种各样的编码器和解码器基类。



    • ByteToMessageDecoder

    • MessageToByteEncoder

    • ProtobufEncoder

    • ProtobufDecoder

    • StringDecoder

    • StringEncode


    这里,编码器都是继承自ChannelInboundHandlerAdapter或者实现了ChannelInboundHandler。当读到数据时,会调用ChannelRead方法。重写此方法,然后就会调用decode 方法进行解码。并且会执行ChannelHandlerContext,fireChannelRead方法,将解码后的消息传给下一个Channelhandler。 当发送消息的时候,也执行类似的过程,编码器将消息转为字节,然后传给下一个ChannelOutboundHandler。


    在我们的项目中,由于自定义了协议所以使用了ByteToMessageDecoder、MessageToByteEncoder。


    1、编码


    编码器比较简单,我们可以自定义类继承 MessageToByteEncoder 来实现解码。
    需要注意的是,按照我们定义的协议顺序向 ByteBuf 中写入数据就好了。示例代码:


    public class CustomEncoder extends MessageToByteEncoder<CustomMessage> {

    @Override
    protected void encode(ChannelHandlerContext channelHandlerContext, CustomMessage message, ByteBuf byteBuf) {
    //byteBuf.writeInt();
    //byteBuf.writeByte();
    }

    }


    2、解码


    解码我们要解决TCP拆包、粘包的问题。


    一般TCP中应对拆包、粘包基本有以下几种方案:



    • 消息定长,这但比较好理解,由于定长了,我们可以直接判断ByteBuffer中数组长度。不过在实践过程中,一般我们不会使用这个方案,因为扩展性太差了。

    • 使用特殊字符作为结尾。

    • 自定义协议。


    前面我们有提到我们的通信协议为[Int][String]实际上就是一个非常简易的自定义协议(由于业务简单,所以这里没有设计的非常复杂)。我们使用Int来标记后面的String长度,这样两端收到消息后,按照这个格式解析实际上就知道消息的长度了。


    在Netty中,我们可以自定义类继承自 ByteToMessageDecoder,来处理解码。Netty中 ByteBuf 来处理数据。具体的步骤是:



    • 先标记已读位置:byteBuf.markReaderIndex();

    • 判断 ByteBuf 可读是否达到4个字节长度,如果不足直接返回,重置已读位置。

    • 读取前4个字节,转为Int。

    • 如果字符长度 > 0,那么接下来继续读 length 长度的字符,转为String。

    • 这样整个协议就解码完成了。


    这里需要注意的是:客户端要与服务端约定好是大端字节序还是小端字节序。


    以上在局域网中,两款App进行TCP通信就已经搭建好了,我们有了协议上层业务就可以基于此来封装业务需要的逻辑了。实际上很多IM 通信SDK,基本上都是自定义通信协议,只是协议会比本篇中举例的协议要复杂的多。


    四、优化点


    项目上线一段时间后,用户反馈偶现存在两台设备无法通信的问题。后面经过调研发现是服务端进程一直存在(即Server App 并没有挂),只是TCP服务挂了。


    我们在客户端有处理断线重连逻辑,但是在服务端没有做任何监控重启逻辑。


    如何优化


    经过调研,我们在服务端Server App中,另外启动一个客户端来与TCP服务端建立连接(相当于是我监控我自己了),如果建立失败,就重启TCP服务。



    • 建立一个TCP客户端,启动轮询逻辑

    • 如果该客户端没有与服务端建立连接,那么尝试建立连接。

    • 如果连续三次都无法建立成功连接,那么认为此时TCP服务存在异常,重启TCP服务。

    • 如果可以正常与TCP服务建立连接,那么开始向TCP服务发送心跳包

    • 如果连续三次TCP服务没有回复心跳回包,那么也认为TCP服务存在异常,重启TCP服务。
      以上,就是我们针对TCP服务端的优化,上面逻辑上线后,无法建立连接的反馈就没有了。


    五、总结


    本篇主要是介绍如何在局域网中,两个APP使用Dns-SD协议来发现对方。自定义协议进行TCP通信,使用Netty作为TCP通信框架。


    作者:半山居士
    来源:juejin.cn/post/7375275474006802443
    收起阅读 »

    别再用后缀判断文件类型了,来认识一下魔数头

    引言 最近公司整改,招了个安全测试,安全测试的同事搞了个危险的文件,悄咪咪加了个.png的后缀,就丢到我们的生产服务器上面去了,这么勇,你敢信? 不管你信不信,事实他就是发生了,就问你怕不怕。 好了,这里也就暴露了一个很大的问题,我们的开发同事判断文件类型,...
    继续阅读 »

    引言



    最近公司整改,招了个安全测试,安全测试的同事搞了个危险的文件,悄咪咪加了个.png的后缀,就丢到我们的生产服务器上面去了,这么勇,你敢信?


    不管你信不信,事实他就是发生了,就问你怕不怕。


    好了,这里也就暴露了一个很大的问题,我们的开发同事判断文件类型,是使用文件后缀来判断的,所以被直接跳过。咱来看看更加科学的识别方式。



    一、认识魔数


    魔数:也被称为签名或文件签名,是一种用于识别文件类型和格式的短序列字节。它们通常位于文件的开头,并被设计为易于识别,以便软件可以快速确定文件是否是它所支持的格式。


    魔数是一种简单的识别机制,它由一系列字节组成,这些字节在特定的文件格式中是唯一的。当一个程序打开一个文件时,它会检查文件的开始处是否包含这些特定的字节。如果找到,程序就认为文件是该格式的,并按照相应的规则进行解析和处理。


    二、文件类型检测的原理



    文件类型检测通常基于文件的“魔数”(magic number),也称为签名或文件签名。魔数是文件开头的字节序列,用于标识文件格式。以下是文件类型检测的原理和步骤:



    2.1 文件头的读取方法


    image.png


    打开文件:首先,需要以二进制模式打开文件,以便能够读取文件的原始字节。


    读取字节:接着,读取文件开头的一定数量的字节(通常是前几个字节)。


    关闭文件:读取完成后,关闭文件以释放资源。


    2.2 如何通过文件头识别文件类型


    image.png


    比较魔数:将读取的字节与已知的文件类型魔数进行比较。


    匹配类型:如果字节序列与某个文件类型的魔数匹配,则可以确定文件类型。


    处理异常:如果字节序列与任何已知魔数都不匹配,可能需要进一步的分析或返回未知文件类型。


    三、Java实现文件类型检测


    当需要通过文件头(魔数头)判断文件类型时,可以按照以下文字描述的流程进行实现:


    graph LR
    F[打开文件] --> B[读取文件头]
    B --> C[判断文件类型]
    C --> D[比较文件头]
    D --> E[输出文件类型]

    style B fill:#FFC0CB,stroke:#FFC0CB,stroke-width:2px
    style C fill:#FFA07A,stroke:#FFA07A,stroke-width:2px
    style D fill:#FFFFE0,stroke:#FFFFE0,stroke-width:2px
    style E fill:#98FB98,stroke:#98FB98,stroke-width:2px
    style F fill:#B2FFFF,stroke:#B2FFFF,stroke-width:2px


    1. 打开文件:使用文件输入流(FileInputStream)打开待判断类型的文件。

    2. 读取文件头:从文件中读取一定长度的字节数据作为文件头。通常,文件头的长度为固定的几个字节,一般是 2-8 个字节。

    3. 判断文件类型:根据不同文件类型的魔数头进行判断。魔数头是文件中特定位置的字节序列,用于标识文件类型。每种文件类型都有不同的魔数头。

    4. 比较文件头:将读取到的文件头与已知文件类型的魔数头进行比较。如果匹配成功,则确定文件类型。

    5. 输出文件类型:根据匹配的文件类型,输出相应的文件类型描述信息。


    3.1 具体实现方法


    3.1.1 定义枚举类


    /**
    * 文件类型魔数枚举
    * 使用场景:用于判断文件类型
    * 使用方法:FileUtils.isFileType(new FileInputStream(file), FileTypeEnum.XLSX)
    *
    * @author bamboo panda
    * @version 1.0
    * @date 2024/5/23 19:37
    */

    public enum FileTypeEnum {
    /**
    * JPEG
    */

    JPEG("JPEG", "FFD8FF"),

    /**
    * PNG
    */

    PNG("PNG", "89504E47"),

    /**
    * GIF
    */

    GIF("GIF", "47494638"),

    /**
    * TIFF
    */

    TIFF("TIFF", "49492A00"),

    /**
    * Windows bitmap
    */

    BMP("BMP", "424D"),

    /**
    * CAD
    */

    DWG("DWG", "41433130"),

    /**
    * Adobe photoshop
    */

    PSD("PSD", "38425053"),

    /**
    * Rich Text Format
    */

    RTF("RTF", "7B5C727466"),

    /**
    * XML
    */

    XML("XML", "3C3F786D6C"),

    /**
    * HTML
    */

    HTML("HTML", "68746D6C3E"),

    /**
    * Outlook Express
    */

    DBX("DBX", "CFAD12FEC5FD746F "),

    /**
    * Outlook
    */

    PST("PST", "2142444E"),

    /**
    * doc;xls;dot;ppt;xla;ppa;pps;pot;msi;sdw;db
    */

    OLE2("OLE2", "0xD0CF11E0A1B11AE1"),

    /**
    * Microsoft Word/Excel
    */

    XLS_DOC("XLS_DOC", "D0CF11E0"),

    /**
    * Microsoft Access
    */

    MDB("MDB", "5374616E64617264204A"),

    /**
    * Word Perfect
    */

    WPB("WPB", "FF575043"),

    /**
    * Postscript
    */

    EPS_PS("EPS_PS", "252150532D41646F6265"),

    /**
    * Adobe Acrobat
    */

    PDF("PDF", "255044462D312E"),

    /**
    * Windows Password
    */

    PWL("PWL", "E3828596"),

    /**
    * ZIP Archive
    */

    ZIP("ZIP", "504B0304"),

    /**
    * ARAR Archive
    */

    RAR("RAR", "52617221"),

    /**
    * WAVE
    */

    WAV("WAV", "57415645"),

    /**
    * AVI
    */

    AVI("AVI", "41564920"),

    /**
    * Real Audio
    */

    RAM("RAM", "2E7261FD"),

    /**
    * Real Media
    */

    RM("RM", "2E524D46"),

    /**
    * Quicktime
    */

    MOV("MOV", "6D6F6F76"),

    /**
    * Windows Media
    */

    ASF("ASF", "3026B2758E66CF11"),

    /**
    * MIDI
    */

    MID("MID", "4D546864"),
    /**
    * xlsx
    */

    XLSX("XLSX", "504B0304"),
    /**
    * xls
    */

    XLS("XLS", "D0CF11E0A1B11AE1");

    private String key;
    private String value;

    FileTypeEnum(String key, String value) {
    this.key = key;
    this.value = value;
    }

    public String getValue() {
    return value;
    }

    public String getKey() {
    return key;
    }
    }

    3.1.2 文件类型判断工具类


    import java.io.IOException;
    import java.io.InputStream;

    /**
    * 文件类型判断工具类
    *
    * @author bamboo panda
    * @version 1.0
    * @date 2024/5/23 19:38
    */

    public class FileTypeUtils {

    /**
    * 获取文件头
    *
    * @param inputStream 输入流
    * @return 16 进制的文件投信息
    * @throws IOException io异常
    */

    private static String getFileHeader(InputStream inputStream) throws IOException {
    byte[] b = new byte[28];
    inputStream.read(b, 0, 28);
    inputStream.close();
    return bytes2hex(b);
    }

    /**
    * 将字节数组转换成16进制字符串
    *
    * @param src 文件字节数组
    * @return 16进制字符串
    */

    private static String bytes2hex(byte[] src) {
    StringBuilder stringBuilder = new StringBuilder("");
    if (src == null || src.length <= 0) {
    return null;
    }
    for (byte b : src) {
    int v = b & 0xFF;
    String hv = Integer.toHexString(v);
    if (hv.length() < 2) {
    stringBuilder.append(0);
    }
    stringBuilder.append(hv);
    }
    return stringBuilder.toString();
    }

    /**
    * 判断指定输入流是否是指定文件格式
    *
    * @param inputStream 输入流
    * @param fileTypeEnum 文件格式枚举
    * @return true 是; false 否
    * @throws IOException io异常
    */

    public static boolean isFileType(InputStream inputStream, FileTypeEnum fileTypeEnum) throws IOException {
    if (null == inputStream) {
    return false;
    }
    String fileHeader = getFileHeader(inputStream);
    return fileHeader.toUpperCase().startsWith(fileTypeEnum.getValue());
    }

    }

    3.1.3 测试方法


    import java.io.File;
    import java.io.FileInputStream;
    import java.io.FileNotFoundException;
    import java.io.IOException;

    /**
    * 测试:判断文件是否是excel
    *
    * @author bamboo panda
    * @version 1.0
    * @date 2024/5/23 19:33
    */

    public class Test {

    public static void main(String[] args) {
    File file = new File("C:\Users\Admin\Desktop\temp\Import file.xlsx");
    try (FileInputStream fileInputStream = new FileInputStream(file)) {
    if (!FileTypeUtils.isFileType(fileInputStream, FileTypeEnum.XLSX) || !FileTypeUtils.isFileType(fileInputStream, FileTypeEnum.XLS)) {
    System.out.println(true);
    } else {
    System.out.println(false);
    }
    } catch (FileNotFoundException e) {
    e.printStackTrace();
    } catch (IOException e) {
    e.printStackTrace();
    }
    }

    }

    四、其它注意事项


    在处理文件类型检测和数据保护时,安全性和隐私是两个非常重要的考虑因素。以下是一些相关的安全性问题和最佳实践:


    4.1 魔数检测的安全性问题


    graph LR
    F(文件类型判断)
    B(魔数检测的安全性问题)
    C(误报和漏报)
    D(恶意文件伪装)
    E(更新和维护)

    F ---> B
    B ---> C
    B ---> D
    B ---> E

    style B fill:#FFC0CB,stroke:#FFC0CB,stroke-width:2px
    style C fill:#FFA07A,stroke:#FFA07A,stroke-width:2px
    style D fill:#FFFFE0,stroke:#FFFFE0,stroke-width:2px
    style E fill:#98FB98,stroke:#98FB98,stroke-width:2px
    style F fill:#B2FFFF,stroke:#B2FFFF,stroke-width:2px


    1. 误报和漏报:魔数检测可能因为文件损坏或不完整而产生误报或漏报。一些恶意软件可能会模仿合法文件的魔数来逃避检测。

    2. 恶意文件伪装:攻击者可能故意在文件中嵌入合法的魔数,使得恶意文件看起来像是合法的文件类型。

    3. 更新和维护:随着新文件类型的出现和旧文件类型的淘汰,魔数列表需要定期更新,否则检测系统可能会变得不准确或过时。


    4.2 数据保护和隐私的最佳实践


    graph LR
    I(文件类型判断)

    A(数据保护和隐私的最佳实践)

    G(最小权限原则)
    B(数据加密)
    C(安全的数据传输)
    D(访问控制)
    E(定期审计和测试)
    F(数据最小化)

    I ---> A
    A ---> B
    A ---> C
    A ---> D
    A ---> E
    A ---> F
    A ---> G

    style B fill:#FFC0CB,stroke:#FFC0CB,stroke-width:2px
    style C fill:#FFA07A,stroke:#FFA07A,stroke-width:2px
    style D fill:#FFFFE0,stroke:#FFFFE0,stroke-width:2px
    style E fill:#98FB98,stroke:#98FB98,stroke-width:2px
    style F fill:#B2FFFF,stroke:#B2FFFF,stroke-width:2px
    style G fill:#ADD8E6,stroke:#ADD8E6,stroke-width:2px
    style A fill:#E6E6FA,stroke:#E6E6FA,stroke-width:2px
    style I fill:#EEDD82,stroke:#EEDD82,stroke-width:2px


    1. 最小权限原则:确保应用程序只请求执行其功能所必需的权限,不要求额外的权限。

    2. 数据加密:对敏感数据进行加密,无论是在传输中还是存储时,都应使用强加密标准。

    3. 安全的数据传输:使用安全的协议(如HTTPS)来保护数据在网络中的传输。

    4. 访问控制:实施严格的访问控制措施,确保只有授权用户才能访问敏感数据。

    5. 定期审计和测试:定期进行安全审计和渗透测试,以发现和修复潜在的安全漏洞。

    6. 数据最小化:只收集完成服务所必需的最少数据量,避免收集不必要的个人信息。


    4.3 魔数的局限性和风险


    魔数判断文件类型是一种常用的方法,但也存在一些局限性和风险,包括以下几点:


    graph LR
    I(文件类型判断)

    A(魔数的局限性和风险)

    B(可伪造性)
    C(文件类型扩展性)
    D(文件损坏或篡改)
    E(多重文件类型)
    F(文件类型模糊性)

    I ---> A
    A ---> B
    A ---> C
    A ---> D
    A ---> E
    A ---> F

    style B fill:#FFC0CB,stroke:#FFC0CB,stroke-width:2px
    style C fill:#FFA07A,stroke:#FFA07A,stroke-width:2px
    style D fill:#FFFFE0,stroke:#FFFFE0,stroke-width:2px
    style E fill:#98FB98,stroke:#98FB98,stroke-width:2px
    style F fill:#B2FFFF,stroke:#B2FFFF,stroke-width:2px
    style A fill:#E6E6FA,stroke:#E6E6FA,stroke-width:2px
    style I fill:#EEDD82,stroke:#EEDD82,stroke-width:2px


    1. 可伪造性:魔数头是文件中的特定字节序列,攻击者可以通过修改文件的魔数头来伪装文件类型。这可能导致误判文件类型或绕过文件类型检测。

    2. 文件类型扩展性:随着新的文件类型的出现,魔数头的定义可能需要不断更新,以适应新的文件类型。如果应用程序不及时更新对新文件类型的判断逻辑,可能无法正确识别新的文件类型。

    3. 文件损坏或篡改:如果文件的魔数头部分被损坏或篡改,可能导致无法正确判断文件类型,或者将文件错误地归类为不正确的类型。

    4. 多重文件类型:某些文件可能具有多重文件类型,即使使用魔数头判断了其中一种类型,也可能存在其他类型。这可能导致文件类型的混淆和判断的不准确性。

    5. 文件类型模糊性:某些文件类型可能具有相似或相同的魔数头,这可能导致在这些类型之间进行区分时出现困难。这可能增加了误判文件类型的风险。


    五、总结


    好了,到这里魔数怎么用的就说明白了。


    魔数的广泛的应用在在文件类型检测中。魔数是文件开头的特定字节序列,帮助软件快速识别文件格式。


    然而,魔数检测存在安全性问题,如误报、恶意伪装等,需定期更新魔数库。此外,应用魔数检测时要考虑文件损坏、多重类型等局限性,结合实际情况采取综合措施,如数据加密、访问控制等,确保安全性和准确性。



    希望本文对您有所帮助。如果有任何错误或建议,请随时指正和提出。


    同时,如果您觉得这篇文章有价值,请考虑点赞和收藏。这将激励我进一步改进和创作更多有用的内容。


    感谢您的支持和理解!



    作者:竹子爱揍功夫熊猫
    来源:juejin.cn/post/7372100124636381194
    收起阅读 »

    我有点想用JDK17了

    大家好呀,我是summo,JDK版本升级的非常快,现在已经到JDK20了。JDK版本虽多,但应用最广泛的还得是JDK8,正所谓“他发任他发,我用Java8”。 其实我也不太想升级JDK版本,感觉投入高,收益小,不过有一次我看到了一些使用JDK17新语法写的代码...
    继续阅读 »

    大家好呀,我是summo,JDK版本升级的非常快,现在已经到JDK20了。JDK版本虽多,但应用最广泛的还得是JDK8,正所谓“他发任他发,我用Java8”。


    其实我也不太想升级JDK版本,感觉投入高,收益小,不过有一次我看到了一些使用JDK17新语法写的代码,让我改变了对升级JDK的看法,因为这些新语法我确实想用!


    废话不多说,上代码!


    一、JDK17语法新特性


    1. 文本块



    这个更新非常实用。在没有这个特性之前,编写长文本非常痛苦。虽然IDEA等集成开发工具可以自动处理,但最终效果仍然丑陋,充满拼接符号。现在,通过字符串块,我们可以轻松编写JSON、HTML、SQL等内容,效果更清爽。这个新特性值得五颗星评价,因为它让我们只需关注字符串本身,而无需关心拼接操作。



    原来的写法


    /**
    * 使用JDK8返回HTML文本
    *
    * @return 返回HTML文本
    */

    public static final String getHtmlJDK8() {
    return "<html>\n" +
    " <body>\n" +
    " <p>Hello, world</p>\n" +
    " </body>\n" +
    "</html>";
    }

    新的写法


    /**
    * 使用JDK17返回HTML文本
    *
    * @return 返回HTML文本
    */

    public static final String getHtmlJDK17() {
    return """
    <html>
    <body>
    <p>Hello, world</p>
    </body>
    </html>
    """
    ;
    }


    推荐指数:⭐️⭐️⭐️⭐️⭐️



    2. NullPointerException增强



    这一功能非常强大且实用,相信每位Java开发者都期待已久。空指针异常(NPE)一直是Java程序员的痛点,因为报错信息无法直观地指出哪个对象为空,只抛出一个NullPointerException和一堆堆栈信息,定位问题耗时且麻烦。尤其在遇到喜欢级联调用的代码时,逐行排查更是令人头疼。如果在测试环境中,可能还需通过远程调试查明空对象,费时费力。为此,阿里的编码规范甚至不允许级联调用,但这并不能彻底解决问题。Java17终于在这方面取得了突破,提供了更详细的空指针异常信息,帮助开发者迅速定位问题源头。



    public static void main(String[] args) {
    try {
    //简单的空指针
    String str = null;
    str.length();
    } catch (Exception e) {
    e.printStackTrace();
    }
    try {
    //复杂一点的空指针
    var arr = List.of(null);
    String str = (String)arr.get(0);
    str.length();
    } catch (Exception e) {
    e.printStackTrace();
    }
    }

    运行结果



    推荐指数:⭐️⭐️⭐️⭐️⭐️



    3. Records



    在Java中,POJO对象(如DO、PO、VO、DTO等)通常包含成员变量及相应的Getter和Setter方法。尽管可以通过工具或IDE生成这些代码,但修改和维护仍然麻烦。Lombok插件为此出现,能够在编译期间自动生成Getter、Setter、hashcode、equals和构造函数等代码,使用起来方便,但对团队有依赖要求。
    为此,Java引入了标准解决方案:Records。它通过简洁的语法定义数据类,大大简化了POJO类的编写,如下所示。虽然hashcode和equals方法仍需手动编写,但IDE能够自动生成。这一特性有效解决了模板代码问题,提升了代码整洁度和可维护性。



    package com.summo.jdk17;

    /**
    * 3星
    *
    * @param stuId 学生ID
    * @param stuName 学生名称
    * @param stuAge 学生年龄
    * @param stuGender 学生性别
    * @param stuEmail 学生邮箱
    */

    public record StudentRecord(Long stuId,
    String stuName,
    int stuAge,
    String stuGender,
    String stuEmail)
    {
    public StudentRecord {
    System.out.println("构造函数");
    }

    public static void main(String[] args) {
    StudentRecord record = new StudentRecord(1L, "张三", 16, "男", "xxx@qq.com");
    System.out.println(record);
    }
    }


    推荐指数:⭐️⭐️⭐️⭐️



    4. 全新的switch表达式



    有人可能问了,Java语言不早已支持switch了嘛,有什么好提的?讲真,这次的提升还真有必要好好地来聊一聊了。在Java12的时候就引入了switch表达式,注意这里是表达式,而不是语句,原来的switch是语句。如果不清楚两者的区别的话,最好先去了解一下。主要的差别就是就是表达式有返回值,而语句则没有。再配合模式匹配,以及yield和“->”符号的加入,全新的switch用起来爽到飞起来。



    package com.summo.jdk17;

    public class SwitchDemo {
    /**
    * 在JDK8中获取switch返回值方式
    *
    * @param week
    * @return
    */

    public int getByJDK8(Week week) {
    int i = 0;
    switch (week) {
    case MONDAY, TUESDAY:
    i = 1;
    break;
    case WEDNESDAY:
    i = 3;
    break;
    case THURSDAY:
    i = 4;
    break;
    case FRIDAY:
    i = 5;
    break;
    case SATURDAY:
    i = 6;
    break;
    case SUNDAY:
    i = 7;
    break;
    default:
    i = 0;
    break;
    }

    return i;
    }

    /**
    * 在JDK17中获取switch返回值
    *
    * @param week
    * @return
    */

    public int getByJDK17(Week week) {
    // 1, 现在的switch变成了表达式,可以返回值了,而且支持yield和->符号来返回值
    // 2, 再也不用担心漏写了break,而导致出问题了
    // 3, case后面支持写多个条件
    return switch (week) {
    case null -> -1;
    case MONDAY -> 1;
    case TUESDAY -> 2;
    case WEDNESDAY -> 3;
    case THURSDAY -> {yield 4;}
    case FRIDAY -> 5;
    case SATURDAY, SUNDAY -> 6;
    default -> 0;
    };
    }

    private enum Week {
    MONDAY,
    TUESDAY,
    WEDNESDAY,
    THURSDAY,
    FRIDAY,
    SATURDAY,
    SUNDAY
    }
    }


    推荐指数:⭐️⭐️⭐️⭐️



    5. 私有接口方法



    从Java8开始,允许在interface里面添加默认方法,其实当时就有些小困惑,如果一个default方法体很大怎么办,拆到另外的类去写吗?实在有些不太合理,所以在Java17里面,如果一个default方法体很大,那么可以通过新增接口私有方法来进行一个合理的拆分了,为这个小改进点个赞。



    public interface PrivateInterfaceMethod {
        /**
         * 接口默认方法
         */

        default void defaultMethod() {
            privateMethod();
        }

        // 接口私有方法,在Java8里面是不被允许的,不信你试试
        private void privateMethod() {
        }
    }


    推荐指数:⭐️⭐️⭐️



    6. 模式匹配



    在JDK 17中,模式匹配主要用于instanceof表达式。模式匹配增强了instanceof的语法和功能,使类型检查和类型转换更加简洁和高效。在传统的Java版本中,我们通常使用instanceof结合类型转换来判断对象类型并进行处理,这往往会导致冗长的代码。



    原来的写法


    /**
    * 旧式写法
    *
    * @param value
    */

    public void matchByJDK8(Object value) {
    if (value instanceof String) {
    String v = (String)value;
    System.out.println("遇到一个String类型" + v.toUpperCase());
    } else if (value instanceof Integer) {
    Integer v = (Integer)value;
    System.out.println("遇到一个整型类型" + v.longValue());
    }
    }

    新的写法


    /**
    * 转换并申请了一个新的变量,极大地方便了代码的编写
    *
    * @param value
    */

    public void matchByJDK17(Object value) {
    if (value instanceof String v) {
    System.out.println("遇到一个String类型" + v.toUpperCase());
    } else if (value instanceof Integer v) {
    System.out.println("遇到一个整型类型" + v.longValue());
    }
    }


    推荐指数:⭐️⭐️⭐️⭐️



    7. 集合类的工厂方法



    在Java8的年代,即便创建一个很小的集合,或者固定元素的集合都是比较麻烦的,为了简洁一些,有时我甚至会引入一些依赖。



    原来的写法


    Set<String> set = new HashSet<>();
    set.add("a");
    set.add("b");
    set.add("c"

    新的写法


    Set<String> set = Set.of("a", "b", "c");


    推荐指数:⭐️⭐️⭐️⭐️⭐️



    二、其他的新特性


    1. 新的String方法



    • repeat:重复生成字符串

    • isBlank:不用在引入第三方库就可以实现字符串判空了

    • strip:去除字符串两边的空格,支持全角和半角,之前的trim只支持半角

    • lines:能根据一段字符串中的终止符提取出行为单位的流

    • indent:给字符串做缩进,接受一个int型的输入

    • transform:接受一个转换函数,实现字符串的转换


    2. Stream API的增强



    增加takeWhile, dropWhile, ofNullable, iterate以及toList的API,越来越像一些函数式语言了。用法举例如下。



    // takeWhile 顺序返回符合条件的值,直到条件不符合时即终止继续判断,
    // 此外toList方法的加入,也大大减少了节省了代码量,免去了调用collect(Collectors::toList)方法了
    List<Integer> list = Stream.of(2,2,3,4,5,6,7,8,9,10)
            .takeWhile(i->(i%2==0)).toList(); // 返回2, 2

    // dropWhile 顺序去掉符合条件的值,直到条件不符合时即终止继续判断
    List<Integer> list1 = Stream.of(2,2,3,4,5,6,7,8,9,10)
            .dropWhile(i->(i%2==0)).toList(); //返回3, 4, 5, 6, 7, 8, 9, 10

    // ofNullable,支持传入空流,若没有这个且传入一个空流,那么将会抛NPE
    var nullStreamCount = Stream.ofNullable(null).count(); //返回0

    // 以下两行都将输出0到9
    Stream.iterate(0, n -> n < 10, n -> n + 1).forEach(x -> System.out.println(x));
    Stream.iterate(0, n -> n + 1).limit(10).forEach(x -> System.out.println(x));

    3. 全新的HttpClient



    这个API首次出现在9之中,不过当时并非是一个稳定版本,在Java11中正式得到发布,所以在Java17里面可以放心地进行使用。原来的JDK自带的Http客户端真的非常难用,这也就给了很多像okhttp、restTemplate、Apache的HttpClient和feign这样的第三方库极大的发挥空间,几乎就没有人愿意去用原生的Http客户端的。但现在不一样了,感觉像是新时代的API了。FluentAPI风格,处处充满了现代风格,用起来也非常地方便,再也不用去依赖第三方的包了,就两个字,清爽。



    // 同步请求
    HttpClient client = HttpClient.newBuilder()
            .version(Version.HTTP_1_1)
            .followRedirects(Redirect.NORMAL)
            .connectTimeout(Duration.ofSeconds(20))
            .proxy(ProxySelector.of(new InetSocketAddress("proxy.example.com", 80)))
            .authenticator(Authenticator.getDefault())
            .build();
       HttpResponse<String> response = client.send(request, BodyHandlers.ofString());
       System.out.println(response.statusCode());
       System.out.println(response.body());
    // 异步请求
    HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create("https://foo.com/"))
            .timeout(Duration.ofMinutes(2))
            .header("Content-Type", "application/json")
            .POST(BodyPublishers.ofFile(Paths.get("file.json")))
            .build();
       client.sendAsync(request, BodyHandlers.ofString())
            .thenApply(HttpResponse::body)
            .thenAccept(System.out::println);
     

    4. jshell



    在新的JDK版本中,支持直接在命令行下执行java程序,类似于python的交互式REPL。简而言之,使用 JShell,你可以输入代码片段并马上看到运行结果,然后就可以根据需要作出调整,这样在验证一些简单的代码的时候,就可以通过jshell得到快速地验证,非常方便。



    5. java命令直接执行java文件



    在现在可以直接通过执行“java xxx.java”,即可运行该java文件,无须先执行javac,然后再执行java,是不是又简单了一步。



    6. ZGC



    在ParallelOldGC、CMS和G1之后,JDK 11引入了全新的ZGC(Z Garbage Collector)。这个名字本身就显得很牛。官方宣称ZGC的垃圾回收停顿时间不超过10ms,能支持高达16TB的堆空间,并且停顿时间不会随着堆的增大而增加。那么,ZGC到底解决了什么问题?Oracle官方介绍它是一个可伸缩的低延迟垃圾回收器,旨在降低停顿时间,尽管这可能会导致吞吐量的降低。不过,通过横向扩展服务器可以解决吞吐量问题。官方已建议ZGC可用于生产环境,这无疑将成为未来的主流垃圾回收器。要了解更多,请参阅官方文档



    三、小结一下


    作为程序员,持续学习和充电非常重要。随着Java8即将停止免费官方支持,越来越多的项目将转向Java17,包括大名鼎鼎的Spring Boot 3.0,它在2022年1月20日发布的第一个里程碑版本(M1)正是基于Java17构建的。该项目依赖的所有组件也将快速升级,未来如果想利用某些新特性,在Java8下将无法通过编译.,到这时候再换就真的晚了... ...


    作者:summo
    来源:juejin.cn/post/7376444924424241162
    收起阅读 »

    Dialog 可不可以传Application

    自定义Dialog继承Dialogclass SourceDialog(context: Context, themeResId: Int) : Dialog(context, themeResId) { constructor(contex...
    继续阅读 »

    自定义Dialog

    1. 继承Dialog
    class SourceDialog(context: Context, themeResId: Int) : Dialog(context, themeResId) {  

    constructor(context: Context) : this(context, R.style.CustomDialogTheme)

    override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.source_layout)
    }
    }
    1. 创建自己的主题样式
    <style name="CustomDialogTheme" parent="@android:style/Theme.Dialog">  
    <item name="android:windowBackground">@android:color/transparentitem> //透明背景
    <item name="android:windowNoTitle">trueitem> //没有标题
    <item name="android:windowFullscreen">trueitem> //是否全屏
    <item name="android:backgroundDimEnabled">trueitem> //背景黑暗
    <item name="android:backgroundDimAmount">0.5item> //背景黑暗透明度
    style>

    可以传入自己创建的主题,也可以不传,Android 会有默认的主题

    Dialog(@UiContext @NonNull Context context, @StyleRes int themeResId,  
    boolean createContextThemeWrapper) {
    if (createContextThemeWrapper) {
    if (themeResId == Resources.ID_NULL) {
    final TypedValue outValue = new TypedValue();
    //这里会指定默认的主题,如果不传主题
    context.getTheme().resolveAttribute(R.attr.dialogTheme, outValue, true);
    themeResId = outValue.resourceId;
    }
    mContext = new ContextThemeWrapper(context, themeResId);
    } else {
    mContext = context;
    }

    mWindowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);

    final Window w = new PhoneWindow(mContext);
    mWindow = w;
    w.setCallback(this);
    w.setOnWindowDismissedCallback(this);
    w.setOnWindowSwipeDismissedCallback(() -> {
    if (mCancelable) {
    cancel();
    }
    });
    w.setWindowManager(mWindowManager, null, null);
    w.setGravity(Gravity.CENTER);
    mListenersHandler = new ListenersHandler(this);
    }
    1. 使用
    val dialog=SourceDialog(context) 
    dialog.show()

    遇到的问题

    1. Dialog 可不可以传Application ?

    背景:这几天接到一个需求,收到动作需要在任何界面上弹出信号源选择器页面(铺满整个屏幕),我一开始是选择了Service+WindowManager 添加View显示的。之前也看了一下公司的CommonUI (展示一下亮度条,音量条之类的全局UI) 用到的是Dialog 弹出界面的。我也跟着写一个,才发现一个一个坑接着来。

    答案是可以的,是要window 传一个 type

    这是我的Dialog

    class SourceDialog(context: Context, themeResId: Int) : Dialog(context, themeResId) {  

    constructor(context: Context) : this(context, R.style.CustomDialogTheme)

    override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.source_layout)
    }
    }

    //传入Application
    val dialog=SourceDialog(MyApplication.CONTEXT)
    dialog.show()

    我就很疑惑,提示Activity需要运行

    屏幕截图 2024-05-24 151913.png

    我后来换成了,正常运行

    //传入activity
    val dialog=SourceDialog(this@MainActivity)
    dialog.show()

    很疑惑,对比同事负责的项目发现我需要给window设置了一些东西

    //Dialog 

    override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.source_layout)
    window?.setType(WindowManager.LayoutParams.TYPE_SYSTEM_ALERT)
    }

    //这样就可以正常展示
    val dialog=SourceDialog(MyApplication.CONTEXT)
    dialog.show()

    1. 设置Dialog 全屏宽高不成功

    我的布局文件 最外层是线性布局

    <LinearLayout android:id="@+id/group_source"  
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:focusable="false"
    android:gravity="center"
    android:orientation="horizontal"
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">


    LinearLayout>

    但是我发现 设置主题样式true 不起作用。 我在这里参考了 三句代码创建全屏Dialog或者DialogFragment

    • 粗暴一点直接设置window 大小 ==需要在setContentView 之后设置window的大小才会生效,如果在setContentView 之前设置,此时window的dectorView为空不会更新布局
    class SourceDialog(context: Context, themeResId: Int) : Dialog(context, themeResId) {  

    constructor(context: Context) : this(context, R.style.CustomDialogTheme)

    override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.source_layout)
    window?.setType(WindowManager.LayoutParams.TYPE_SYSTEM_ALERT)
    window?.setLayout(WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.MATCH_PARENT) //也可以换成具体数值宽高
    }
    }
    • 主题样式增加一样 false 因为很多默认的Dialog 主题这个属性一般为true。
    <style name="CustomDialogTheme" parent="@android:style/Theme.Dialog">  
    <item name="android:windowBackground">@android:color/transparentitem>
    <item name="android:windowNoTitle">trueitem>
    <item name="android:windowFullscreen">trueitem>
    <item name="android:windowIsFloating">falseitem>
    <item name="android:backgroundDimEnabled">trueitem>
    <item name="android:backgroundDimAmount">0.5item>
    style>

    Dialog setContentView 会走到PhoneWindow 的这个方法 走到installDecor -> generateLayout

    屏幕截图 2024-05-24 160441.png

    屏幕截图 2024-05-24 160620.png

    屏幕截图 2024-05-24 160736.png 会发现这个属性为true的话,会根据内容展示。我们给这个属性设置为false这样就不用给Window设置大小了。


    作者:很好881
    来源:juejin.cn/post/7372396174249738278
    收起阅读 »

    对象存储URL被刷怕了,看我这样处理

    个人项目:社交支付项目(小老板) 作者:三哥,j3code.cn 文档系统:admire.j3code.cn/note 社交支付类的项目,怎么能没有图片上传功能呢! 涉及到文件存储我第一时间就想到了 OSS 对象存储服务(腾讯叫 COS),但是接着我又想到了...
    继续阅读 »

    个人项目:社交支付项目(小老板)


    作者:三哥,j3code.cn


    文档系统:admire.j3code.cn/note



    社交支付类的项目,怎么能没有图片上传功能呢!


    涉及到文件存储我第一时间就想到了 OSS 对象存储服务(腾讯叫 COS),但是接着我又想到了”OSS 被刷 150 T 的流量,1.5 W 瞬间就没了?“。


    本来想着是自己搭建一套 MinIO ,但后来一想服务器的开销又要大了,还是作罢了。就在此时,我脑袋突然灵光了一下,既然对象存储的流量是由于资源 url 泄漏导致的外界不停的访问 url 使公网流量剧增从而引起巨额消费,那我能不能不泄露这个 url 呢!


    理论上是可以不直接给用户云存储的 url ,那用户如何访问资源?



    转换,当用户上传图片时,将云存储的 url 保存入库,而返回用户一个本系统的资源访问接口。当用户访问该接口时,系统从库中获取真实 url 进行资源访问,并返回资源给用户,完成一次转换。


    虽然可以解决 url 泄漏问题,但是也是有性能消耗(从直接访问,变为间接访问,而且系统挂了,资源就不可用)。



    方案,虽然曲折了点,但为了 money ,牺牲一点是值得的(后来思考了一下,觉得还是有些问题,文章最后会说)。而且即使有人通过刷系统的接口访问资源,也没事,系统有很强的限流和黑名单处理,不会产生过多的公网流量费用的。


    那下面我们就先开通相关功能,然后再编码实现。


    1、腾讯云对象存储创建


    地址:console.cloud.tencent.com/cos


    开通对象存储的步骤还是非常简单的,具体步骤如下:


    1)开通功能


    Snipaste_2023-07-14_15-27-24.png


    2)配置存储桶


    Snipaste_2023-07-14_15-28-24.png


    下一步


    Snipaste_2023-07-14_15-30-35.png


    下一步


    Snipaste_2023-07-14_15-33-10.png


    3)创建访问的密钥


    腾讯的所有 API 接口都需要这个访问密钥,如果以前创建过就可以直接拿来使用


    Snipaste_2023-07-14_15-34-05.png


    下一步


    Snipaste_2023-07-14_15-35-26.png


    基本的功能我们已经开通了,而且以后我们只需向这个存储桶中上传图片即可。


    2、SpringBoot 对接对象存储


    既然准备工作都已经完成了,那就开始编写上传文件的代码吧!当然,这里我们还是要借助官方文档,便于我们开发,地址如下:



    cloud.tencent.com/document/pr…



    2.1 配置准备


    先来思考一下,对于腾讯 COS 文件上传需要那些配置:



    1. 云 API 的 SecretId 和 SecretKey

    2. 桶名称

    3. 文件上传大小限制

    4. 再加一个 cos 上传后的访问域名


    ok,大致就这些,那咱们就先来写个配置文件:application-cos.yml


    tx.cos:
    # 云 API 的 SecretId
    secret-id: ENC(X7Uu6Y0QD6aCeUmNhyqv1jcr8fSN+fqM/FSP/rqhM+6pkbte2LW5gR3wntsm24n3NAg6sIwBC3pqm1lSNWwElc3iuGe3lE4L/k3zih+EstM=)
    # 云 API 的 SecretKey
    secret-key: ENC(ui3jqYJpyTRtPAizYdtll2Zc1EVzUjK28vjTyD+t3AIydQO6I+JQOVacc5+NJVybsbFptELswKhY55OQLW+BKfujNTOYEM/zb4CMi+AK80w=)
    # 域名访问
    domain: ENC(oRsaRjwRCVLEYfcNB0CjPGyqSMxGM5uzWnSpSifauLF7c5YMt5hZFi7xAthJI4CjmOLVA810Jbgy8lnkKrXUH0g1ee14cr67xSdtPRy1ZaJOXQOMlBgCKNO2wDBg2YW2)
    # 文件上传的桶名称
    bucket-name: ENC(TUsQfDEFx6KSAOpRwG7UYOJbGwnFT0Z9tjS4h+/HeenAE3XbhKsCwn3TTo80n5tUUP9Dzrnu+Ck84FNSYQk5fw==)

    spring:
    servlet:
    multipart:
    # 限制文件上传大小
    max-request-size: 5MB
    max-file-size: 5MB

    注:这里,我的配置值是加密的,所以你们需要配置自己的值


    再根据这个配置文件,写一个对应的配置类:


    地址:cn.j3code.common.config


    @Slf4j
    @Data
    @Configuration
    @ConfigurationProperties(prefix = "tx.cos")
    public class TxCosConfig {
    /**
    * 访问域名
    */

    private String domain;

    /**
    * 桶名称
    */

    private String bucketName;

    /**
    * api密钥中的secretId
    */

    private String secretId;

    /**
    * api密钥中的应用密钥
    */

    private String secretKey;
    }

    2.2 上传文件代码


    这里,我们先实现单个文件的上传,那来思考一下,上传文件应该需要那些步骤:



    1. 校验文件名称

    2. 重新生成一个新文件名称

    3. 腾讯 COS 文件存储路径生成

    4. 文件上传

    5. 拼接文件访问 url


    对应此步骤的流程图,如下:


    Snipaste_2023-07-16_17-31-32.jpg


    1)controller 编写


    位置:cn.j3code.other.api.v1.controller


    @Slf4j
    @AllArgsConstructor
    @ResponseResult
    @RestController
    @RequestMapping(UrlPrefixConstants.WEB_V1 + "/image/upload")
    public class ImageUploadController {

    private final FileService fileService;


    /**
    * 图片上传
    * @param file 文件
    * @return 返回文件 url
    */

    @PostMapping("")
    public String upload(@RequestParam("file") MultipartFile file){
    return fileService.imageUpload(file);
    }
    }

    2)service 编写


    位置:cn.j3code.other.service


    public interface FileService {
    String imageUpload(MultipartFile file);
    }

    @Slf4j
    @AllArgsConstructor
    @Service
    public class FileServiceImpl implements FileService {

    /**
    * 允许上传的图片类型
    */

    public static final Set<String> IMG_TYPE = Set.of("jpg", "jpeg", "png", "gif");

    /**
    * 腾讯 cos 配置
    */

    private final TxCosConfig txCosConfig;
    private final UrlKeyService urlKeyService;

    /**
    * 图片上传
    *
    * @param file
    * @return
    */

    @Override
    public String imageUpload(MultipartFile file) {
    // 文件名称
    String newFileName = getNewFileName(file);

    DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
    String format = formatter.format(LocalDate.now());
    // key = /用户id/年月日/文件
    String key = SecurityUtil.getUserId() + "/" + format + "/" + newFileName;

    String prefix = newFileName.substring(0, newFileName.lastIndexOf(".") - 1);
    String suffix = newFileName.substring(newFileName.lastIndexOf(".") + 1);
    File tempFile = null;
    File rename = null;
    try {
    // 生成临时文件
    tempFile = File.createTempFile(prefix, "." + suffix);
    file.transferTo(tempFile);
    // 重命名文件
    rename = FileUtil.rename(tempFile, newFileName, true, true);
    // 上传
    upload(new FileInputStream(rename), key);
    } catch (Exception e) {
    log.error("imageUpload-error:", e);
    } finally {
    if (Objects.nonNull(tempFile)) {
    FileUtil.del(tempFile);
    }
    if (Objects.nonNull(rename)) {
    FileUtil.del(rename);
    }
    }
    // 返回访问链接
    return initUrl(key);
    }

    /**
    * 初始化图片文件访问 url(本地url和第三方url)
    *
    * @param key 路径
    * @return
    */

    private String initUrl(String key) {
    // 组装第三方 url
    String imageUrl = txCosConfig.getDomain() + "/" + key;

    // 保存 url 到 数据库
    UrlKey urlKey = new UrlKey()
    .setUrl(imageUrl)
    .setKey(RandomUtil.randomString(16) + RandomUtil.randomString(16) + RandomUtil.randomString(16))
    .setUserId(SecurityUtil.getUserId());

    // 保存成功,返回本地中转的 url 出去
    boolean save = Boolean.FALSE;
    try {
    save = urlKeyService.save(urlKey);
    } catch (Exception e) {
    }

    if (save) {
    return CallbackUrlConstants.IMAGE_OPEN_URL + urlKey.getKey();
    }
    // 保存失败,直接把第三方 url 返回给用户
    return imageUrl;
    }

    /**
    * 文件上传到第三方
    *
    * @param fileStream 文件流
    * @param path 路径
    */

    private void upload(InputStream fileStream, String path) {
    PutObjectResult putObjectResult = COSClientUtil.getCosClient(txCosConfig)
    .putObject(new PutObjectRequest(txCosConfig.getBucketName(), path, fileStream, null));
    log.info("upload-result:{}", JSON.toJSONString(putObjectResult));
    }

    /**
    * 生成一个新文件名称
    * 会校验文件名称和类型
    *
    * @param file 文件
    * @return
    */

    private String getNewFileName(MultipartFile file) {
    String originalFilename = file.getOriginalFilename();
    if (StringUtil.isEmpty(originalFilename)) {
    throw new SysException("文件名称获取失败!");
    }
    String suffix = originalFilename.substring(originalFilename.lastIndexOf("."));

    if (!IMG_TYPE.contains(suffix.substring(1))) {
    throw new SysException(String.format("仅允许上传这些类型图片:%s", JSON.toJSONString(IMG_TYPE)));
    }

    return RandomUtil.randomString(8) + SnowFlakeUtil.getId() + suffix;
    }
    }

    代码写的很详细了,应该能看懂,但,有两点我没有提,就是:COSClientUtil 和 UrlKeyService,下面就来结介绍。


    2.2.1 cos 客户端配置提取


    系统中肯定有很多的文件上传,难道是每上传一次,就配置一次 cos 客户端吗?显然不是,这个 cos 客户端肯定是要抽出来的,全局系统中我们只配置一次。也即只有第一次过来是创建 cos 客户端,后续过来的文件上传请求直接返回创建好的 cos 客户端就行。


    COSClientUtil 类就是我抽的公共 cos 客户获取类,具体实现如下:


    位置:cn.j3code.other.util


    public class COSClientUtil {

    /**
    * 统一 cos 上传客户端
    */

    private static COSClient cosClient;

    public static COSClient getCosClient(TxCosConfig txCosConfig) {
    if (Objects.isNull(cosClient)) {
    synchronized (COSClient.class) {
    if (Objects.isNull(cosClient)) {
    // 1 初始化身份
    COSCredentials cred = new BasicCOSCredentials(txCosConfig.getSecretId(), txCosConfig.getSecretKey());
    // 2 创建配置,及设置地域
    ClientConfig clientConfig = new ClientConfig(new Region("ap-guangzhou"));
    // 3 生成 cos 客户端。
    cosClient = new COSClient(cred, clientConfig);
    }
    }
    }
    return cosClient;
    }
    }

    私有构造器,且之对外提供 getCosClient 方法获取 COSClient 对象,保证全局只有一个 cos 客户端配置。


    2.2.2 隐藏云存储 URL 处理


    还记得 FileServiceImpl 类中有个 UrlKeyService 属性嘛,这个类就是做 云存储 URL 隐藏及中转功能的。


    具体做法如图:


    Snipaste_2023-07-16_18-14-59.jpg


    文件上传部分我们已经写好了,不过有点超前的意思了,不过没关系,看整体就行。


    从上面我们要开始抓住一个细节了,就是映射关系,即 key 和 url 的映射。这里我用的是 MySQL 保存,也即用表来存,并没有用 Redis。这里我的考虑是,后续可以把表中的数据定时刷到 Redis 中,接着访问的顺序是从 Redis 中找映射,没有再去 MySQL 中找。


    不过,我们首先还是把数据先存表再说,先来看看映射表结构字段:



    id


    user_id


    key


    url


    create_time


    update_time



    ok,就这些字段,把用户 id 加上是为了好回溯看看是谁上传了图片。


    SQL 如下:


    CREATE TABLE `sb_url_key` (
    `id` bigint(20) NOT NULL AUTO_INCREMENT,
    `key` varchar(64) COLLATE utf8_unicode_ci NOT NULL COMMENT 'key',
    `url` varchar(200) COLLATE utf8_unicode_ci NOT NULL COMMENT '资源url',
    `user_id` bigint(20) DEFAULT NULL COMMENT '上传用户',
    `create_time` datetime DEFAULT NULL COMMENT '创建时间',
    `update_time` datetime DEFAULT NULL COMMENT '修改时间',
    PRIMARY KEY (`id`),
    KEY `key` (`key`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci

    紧接着就是通过 MyBatisX 插件生成对应的实体、service、mapper 代码了,不过多赘述。那,现在就来开发用户访问图片资源,咱们如何去请求第三方,然后返回用户图片 byte[] 资源数组吧!


    1)controller 编写


    位置:cn.j3code.other.api.v1.controller


    @Slf4j
    @AllArgsConstructor
    @RestController
    @RequestMapping(UrlPrefixConstants.OPEN + "/resource/image")
    public class ImageResourceController {

    private final UrlKeyService urlKeyService;

    /**
    * 获取图片 base64
    *
    * @param key
    * @return
    * @throws Exception
    */

    @GetMapping("/base64/{key}")
    public String imageBase64(@PathVariable("key") String key) throws Exception {
    UrlKey urlKey = urlKeyService.oneByKey(key);
    return "data:image/jpg;base64," + Base64Encoder.encode(IoUtil.readBytes(new URL(urlKey.getUrl()).openStream()));
    }


    /**
    * 获取图片 byte 数组
    *
    * @param key
    * @return
    * @throws Exception
    */

    @GetMapping(value = "/io/{key}", produces = MediaType.IMAGE_JPEG_VALUE)
    public byte[] imageIo(@PathVariable("key") String key) throws Exception {
    UrlKey urlKey = urlKeyService.oneByKey(key);

    return IoUtil.readBytes(new URL(urlKey.getUrl()).openStream());
    }
    }

    注意:这里写了两个方法,目的是返回两种不同形式的图片资源:base64 和 byte[]。且,这种资源访问的接口,我们系统的相关拦截器请放行,如:认证,ip 记录等拦截器。


    2)service 编写


    位置:cn.j3code.other.service


    public interface UrlKeyService extends IService<UrlKey> {
    UrlKey oneByKey(String key);
    }
    @Service
    public class UrlKeyServiceImpl extends ServiceImpl<UrlKeyMapper, UrlKey>
    implements UrlKeyService {

    @Override
    public UrlKey oneByKey(String key) {
    UrlKey urlKey = lambdaQuery().eq(UrlKey::getKey, key).one();
    if (Objects.isNull(urlKey)) {
    throw new SysException(SysExceptionEnum.NOT_DATA_ERROR);
    }
    return urlKey;
    }
    }

    ok,这样咱们就处理好了,但是仔细想想这种中转的方法有什么问题。


    2.3 思考


    2.2 节我们已经实现了文件上传和防止 cos 访问 url 泄露的操作,但是我留了个问题,就是思考这种方式有什么问题。


    下面是我的思考:



    1. 用户上传的图片,访问时每次都会经过本系统,造成了本系统的压力

    2. 如果一个页面需要回显的图片过多,那页面响应会不会很慢

    3. 如果系统崩溃了或者服务崩溃了,会导致图片不可访问,但其实第三方 url 是没有问题的


    好吧,其实上面总结就两个问题,即:性能可用性


    这里的解决方法是,如果资金充裕而且 COS 做了黑白名单等之类的防御措施可以直接把 COS 的原始 url 返回出去,没必要把图片资源压力给我我们本系统。如果你不是这种情况,那么就给图片访问接口增加部署资源,即升级服务器增加内存和带款,提高资源访问效率及系统性能。


    以上就是本节内容,如果文章的中转方法有啥不足或者您有什么意见,欢迎一起讨论研究。


    作者:J3code
    来源:juejin.cn/post/7256306281538928701
    收起阅读 »

    我们如何让Android客户端暴瘦了100M

    一、 引言随着Android应用功能的日益丰富,客户端的体积也逐渐膨胀,过大的安装包体积不仅给用户带来了下载和存储的压力,还会影响应用的启动速度和整体性能;传统的图片压缩、冗余资源移除、代码混淆等优化手段可以在一定程度上降低安装包大小,但是在面对大型复杂应用的...
    继续阅读 »

    一、 引言

    随着Android应用功能的日益丰富,客户端的体积也逐渐膨胀,过大的安装包体积不仅给用户带来了下载和存储的压力,还会影响应用的启动速度和整体性能;传统的图片压缩、冗余资源移除、代码混淆等优化手段可以在一定程度上降低安装包大小,但是在面对大型复杂应用的时候,效果往往很有限,本文将详细介绍我们在包大小优化方面的实践经验,并通过一系列技术手段实现了显著的包体积缩减。

    二、  安装包大小分析

    Android的apk通常有以下几部分组成:

    1. 代码:包含应用中Java/Kotlin代码,在包中以dex的形式存在
    2. 资源:包含图片、布局文件等
    3. lib库: 包含了应用的Native代码库,以.so文件的形式存在
    4. assets:包含了应用运行时所需的非代码资源,如音频、视频、字体、配置文件等
    5. 其他:签名文件、资源索引文件等

    通过分析安装包大小的组成,我们发现项目中lib库和assets占比达到70%,代码占比20%,资源和其他占比10%。

    三、基础优化方案

    1. 代码优化:开启代码混淆,混淆可以帮助缩减代码尺寸、移除无用代码,通过分析反编译后的代码,我们发现很多本该混淆的类没有混淆,最终定位到工程中引入的一些三方库的混淆规则keep范围过大,导致混淆效果不理想;通过混淆规则的优化,包大小缩减了6M左右;
    2. 资源优化:解压apk文件,把res目录下的图片按照大小进行排序,我们发现项目中有一些尺寸较大的图片,把图片格式转为webp格式后,尺寸大大降低;同时通过对比资源的md5值,发现一些资源名字虽然不一样,但是内容是一样的,这些重复资源可以移除;通过资源的优化,包大小缩减了15M左右;
    3. assets资源优化:通过分析apk assets目录下的文件,我们发现里面有很多不用的文件,比如arm64位包中存在x86、armeabi-v7a等其他架构的so库,这些assets目录下的so库是三方库引入的,运行时动态加载,由于设置abiFilters无法过滤掉这些so库,导致打包进apk中;我们通过自定义构建流程,在mergeAssetsTask执行结束后移除assets中不用的so库;通过assets资源的优化,包大小缩减了4M左右.
    project.afterEvaluate {
    android.applicationVariants.all { variant ->
    def mergeAssetsTask = project.getTasksByName("merge${variant.name.capitalize()}Assets", false)
    mergeAssetsTask.doLast {
    def assetsDir = mergeAssetsTask.outputDir.get().toString()
    // 移除无用的assets资源
    removeUnusedAssets(assetsDir)
    }
    }
    }

    通过基础的代码和资源等的优化,包大小缩减了25M左右,但是对于一个180M的apk来说,效果非常有限,需要探索其他方案进一步降低包大小。

    三、  进阶优化方案

    上面我们分析过apk中的lib库和assets文件占比达到70%,因此我们重点针对lib库和assets文件尺寸大的问题进行优化,我们可以把这些文件放到云端,在应用启动的时候下载到本地,但是这样做有以下问题:

    1. 一些lib库和assets文件在应用启动的时候就会用到,如果放到云端,会导致应用启动时间变长甚至崩溃;
    2. 应用中加载assets资源是通过系统API AssetManager.open来加载的,但是把assets文件从apk中移除后,需要修改使用AssetManager.open的地方,改为从本地私有目录加载,这样会导致改动的地方很多,而且容易漏改和错改;一些三方库由于没有开源,修改起来会更加困难;
    3. 应用中加载lib库是通过系统API System.loadLibrary来加载的,如果把so库从apk中移除后,需要修改为使用System.load加载私有目录下的so库,同样存在改动地方多,不开源的三方库修改困难的问题;
    4. 把移除的so库和assets文件打包成一个文件下发会存在由于文件尺寸大导致下载时间长,容易下载失败问题,同时会导致当用户使用到相关功能的时候需要长时间的等待,体验差。

    针对以上问题,我们采用了以下优化策略:

    1. 选择性移除:只把一些尺寸大,用户使用频次较低的功能中使用的assets和so库从apk包中移除,在不影响用户体验的同时,降低安装包大小。
    2. 分包下载:需要移除的so库和assets文件按功能模块进行分包,首次使用时再去下载对应的资源包,这样能确保功能模块依赖的云端资源尽可能的小,大幅降低下载时间,提升下载成功率,减少用户等待时间。
    3. 自动化构建:通过编写gradle脚本,自定义构建过程,在构建阶段自动把assets和so库从apk包中移除并打包。
    project.afterEvaluate {
    android.applicationVariants.all { variant ->
    def mergeAssetsTask = project.getTasksByName("merge${variant.name.capitalize()}Assets", false)
    mergeAssetsTask.doLast {
    def assetsDir = mergeAssetsTask.outputDir.get().toString()
    // 把assetsDir中需要移除的assets文件移除,放到模块指定的目录下
    }

    def mergeNativeLibsTask = project.getTasksByName("merge${variant.name.capitalize()}NativeLibs", false)
    mergeNativeLibsTask.doLast {
    def libDir = mergeNativeLibsTask.outputDir.get().toString()
    // 把 libDir中需要移除的so库移除,放到模块指定的目录下
    // 打包压缩模块目录
    }
    }
    }
    1. 字节码插桩:开发gradle插件,使用字节码插桩技术,在编译阶段自动把调用AssetManager.openSystem.loadLibrary的地方替换为我们的自定义加载器,工程中的代码和三方闭源库无需做任何改动。
    public class MyMethodVisitor extends MethodVisitor {
    @Override
    public void visitMethodInsn(int opcode, String owner, String name, string desc, boolean isInterface) {
    // 替换System.loadLibrary为DynamicLoader.loadLibrary
    if ("java/lang/System".equals(owner) && "loadLibrary".equals(name)) {
    return super.visitMethodInsn(opcode, "com/xxx/loader/DynamicLoader", name, desc, isInterface);
    }

    // 替换AssetManager.open为DynamicLoader.openAsset
    if ("android/content/res/AssetManager".equals(owner) && "open".equals(name) && "(Ljava/lang/String;)Ljava/io/InputStream".equals(desc)) {
    return super.visitMethodInsn(Opcodes.INVOKESTATIC, "com/xxx/loader/DynamicLoader", "openAsset", "(Landroid/content/res/AssetManager;Ljava/lang/String;)Ljava/io/InputStream");
    }
    return super.visitMethodInsn(opcode, owner, name, desc, isInterface);
    }
    }
    1. 双重加载机制:在自定义加载器中先尝试加载apk内置的so库和assets文件,如果出现异常,则从动态下发的文件中查找并加载,这样可以保证无论so库是否移除都可以正常加载。
    // 自定义加载器

    public class DynamicLoader {
    public static void loadLibrary(string libname) throw Throwable {
    try {
    // 先加载apk包中的so库
    System.loadLibrary(libname);
    return
    } catch(Throwable e) {
    }

    String soPath = findLibrary(libName);
    // apk包中的so库加载失败时加载动态下发的so库
    return System.load(soPath);
    }

    public static InputStream openAsset(AssetManager am, String fileName) throw IOException {
    try {
    // 先加载apk包中的asset文件
    return am.open(fileName);
    } catch(IOException e) {
    }

    // apk包中的asset文件加载失败时加载动态下发的asset文件
    String assetPath = findAsset(fileName);
    return new FileInputStream(assetPath);
    }
    }

    四、  实施效果

    采用上述包优化方案后,我们的Android客户端安装包大小从180M缩减到78M,实现了显著的包体积缩减。同时,通过监控优化后版本的崩溃率和用户反馈,未出现明显的崩溃率升高和用户体验下降的情况。

    五、  未来展望

    应用的安装包大小优化是一个长期的过程,需要建立一套包大小的监控、预警、原因分析、自动优化等机制,确保安装包大小在合理范围,我们将从以下几个方面进行探索:

    1. 设定安装包大小基准,持续监控安装包大小的变化,当安装包大小偏移基准值过大的时候,触发预警,并自动分析包大小增加原因,找出导致包大小增大的文件;
    2. 优化构建流程,构建阶段自动压缩大图片为webp格式,自动合并重复资源;
    3. 持续优化应用的性能表现和用户体验,并根据实际情况进行进一步的优化调整。


    作者:jack5288
    来源:juejin.cn/post/7379168502455222311
    收起阅读 »

    MyBatis居然也有并发问题

    为了节省dalaos时间先说结论:确实是个问题,issue链接:github.com/mybatis/myb… 下面就是源码分析环节,及处理过程,感兴趣的可以看看。 bug,任何时候都要解决!不解决不行,你们想想,你早上刚到公司,打开电脑,写着需求听着歌,突...
    继续阅读 »

    为了节省dalaos时间先说结论:确实是个问题,issue链接:github.com/mybatis/myb…


    下面就是源码分析环节,及处理过程,感兴趣的可以看看。



    bug,任何时候都要解决!不解决不行,你们想想,你早上刚到公司,打开电脑,写着需求听着歌,突然就被ding了……所以没有bug的日子才是好日子!



    日志


    上了服务器一看,Mybatis报错,接口还是个相当频繁的接口,一想,完了,绩效大概率不保。


    2023-08-08 09:52:05,386|aaaaaaaaa|XXXXXXXXXXXXXX|unknown exception occurred
    org.mybatis.spring.MyBatisSystemException: nested exception is org.apache.ibatis.builder.BuilderException: Error evaluating expression 'projects != null and projects.size() > 0 '. Cause: org.apache.ibatis.ognl.MethodFailedException: Method "size" failed for object [aaa,bbb,ccc] [java.lang.IllegalAccessException: Class org.apache.ibatis.ognl.OgnlRuntime can not access a member of class java.util.Arrays$ArrayList with modifiers "public"]
    at org.mybatis.spring.MyBatisExceptionTranslator.translateExceptionIfPossible(MyBatisExceptionTranslator.java:75) ~[mybatis-spring-1.2.2.jar:1.2.2]
    at org.mybatis.spring.SqlSessionTemplate$SqlSessionInterceptor.invoke(SqlSessionTemplate.java:371) ~[mybatis-spring-1.2.2.jar:1.2.2]
    at com.sun.proxy.$Proxy57.selectList(Unknown Source) ~[na:na]
    at org.mybatis.spring.SqlSessionTemplate.selectList(SqlSessionTemplate.java:198) ~[mybatis-spring-1.2.2.jar:1.2.2]
    at org.apache.ibatis.binding.MapperMethod.executeForMany(MapperMethod.java:119) ~[mybatis-3.2.8.jar:3.2.8]
    at org.apache.ibatis.binding.MapperMethod.execute(MapperMethod.java:63) ~[mybatis-3.2.8.jar:3.2.8]
    at org.apache.ibatis.binding.MapperProxy.invoke(MapperProxy.java:52) ~[mybatis-3.2.8.jar:3.2.8]
    at com.sun.proxy.$Proxy102.queryExperienceCardOrder(Unknown Source) ~[na:na]
    // 业务相关堆栈,保险起见不贴了
    at org.springframework.cglib.proxy.MethodProxy.invoke(MethodProxy.java:204) ~[spring-core-4.3.6.RELEASE.jar:4.3.6.RELEASE]
    at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:652) ~[spring-aop-4.3.6.RELEASE.jar:4.3.6.RELEASE]
    at com.xxxxxxxxxxxxxxxxxxxxxx$$EnhancerBySpringCGLIB$$b85a94bd.queryHasExperienceCardNew(<generated>) ~[zuhao-user-service-1.0.0.jar:na]
    at sun.reflect.GeneratedMethodAccessor564.invoke(Unknown Source) ~[na:na]
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:1.8.0_131]
    at java.lang.reflect.Method.invoke(Method.java:498) ~[na:1.8.0_131]
    at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:333) ~[spring-aop-4.3.6.RELEASE.jar:4.3.6.RELEASE]
    at org.springframework.aop.framework.ReflectiveMethodInvocation.invokeJoinpoint(ReflectiveMethodInvocation.java:190) [spring-aop-4.3.6.RELEASE.jar:4.3.6.RELEASE]
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:157) [spring-aop-4.3.6.RELEASE.jar:4.3.6.RELEASE]
    at xxxxxxxxxxxxx.common.interceptor.ApiInterceptor.invoke(ApiInterceptor.java:79) ~[common-0.0.9-20211228.052440-12.jar:na]
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:179) [spring-aop-4.3.6.RELEASE.jar:4.3.6.RELEASE]
    at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:213) [spring-aop-4.3.6.RELEASE.jar:4.3.6.RELEASE]
    at com.sun.proxy.$Proxy185.queryHasExperienceCardNew(Unknown Source) [na:na]
    at com.alibaba.dubbo.common.bytecode.Wrapper36.invokeMethod(Wrapper36.java) [na:2.5.3]
    at com.alibaba.dubbo.rpc.proxy.javassist.JavassistProxyFactory$1.doInvoke(JavassistProxyFactory.java:46) [dubbo-2.5.3.jar:2.5.3]
    at com.alibaba.dubbo.rpc.proxy.AbstractProxyInvoker.invoke(AbstractProxyInvoker.java:72) [dubbo-2.5.3.jar:2.5.3]
    at com.alibaba.dubbo.rpc.protocol.InvokerWrapper.invoke(InvokerWrapper.java:53) [dubbo-2.5.3.jar:2.5.3]
    at com.alibaba.dubbo.rpc.filter.ExceptionFilter.invoke(ExceptionFilter.java:64) [dubbo-2.5.3.jar:2.5.3]
    at com.alibaba.dubbo.rpc.protocol.ProtocolFilterWrapper$1.invoke(ProtocolFilterWrapper.java:91) [dubbo-2.5.3.jar:2.5.3]
    at com.alibaba.dubbo.monitor.support.MonitorFilter.invoke$original$LFhJaVNd(MonitorFilter.java:65) [dubbo-2.5.3.jar:2.5.3]
    at com.alibaba.dubbo.monitor.support.MonitorFilter.invoke$original$LFhJaVNd$accessor$urPnHrIw(MonitorFilter.java) [dubbo-2.5.3.jar:2.5.3]
    at com.alibaba.dubbo.monitor.support.MonitorFilter$auxiliary$RJHyKBeq.call(Unknown Source) [dubbo-2.5.3.jar:2.5.3]
    at org.apache.skywalking.apm.agent.core.plugin.interceptor.enhance.InstMethodsInter.intercept(InstMethodsInter.java:86) [skywalking-agent.jar:8.16.0]
    at com.alibaba.dubbo.monitor.support.MonitorFilter.invoke(MonitorFilter.java) [dubbo-2.5.3.jar:2.5.3]
    at com.alibaba.dubbo.rpc.protocol.ProtocolFilterWrapper$1.invoke(ProtocolFilterWrapper.java:91) [dubbo-2.5.3.jar:2.5.3]
    at com.alibaba.dubbo.rpc.filter.TimeoutFilter.invoke(TimeoutFilter.java:42) [dubbo-2.5.3.jar:2.5.3]
    at com.alibaba.dubbo.rpc.protocol.ProtocolFilterWrapper$1.invoke(ProtocolFilterWrapper.java:91) [dubbo-2.5.3.jar:2.5.3]
    at com.alibaba.dubbo.rpc.protocol.dubbo.filter.TraceFilter.invoke(TraceFilter.java:78) [dubbo-2.5.3.jar:2.5.3]
    at com.alibaba.dubbo.rpc.protocol.ProtocolFilterWrapper$1.invoke(ProtocolFilterWrapper.java:91) [dubbo-2.5.3.jar:2.5.3]
    at com.alibaba.dubbo.rpc.filter.ContextFilter.invoke(ContextFilter.java:60) [dubbo-2.5.3.jar:2.5.3]
    at com.alibaba.dubbo.rpc.protocol.ProtocolFilterWrapper$1.invoke(ProtocolFilterWrapper.java:91) [dubbo-2.5.3.jar:2.5.3]
    at com.alibaba.dubbo.rpc.filter.GenericFilter.invoke(GenericFilter.java:112) [dubbo-2.5.3.jar:2.5.3]
    at com.alibaba.dubbo.rpc.protocol.ProtocolFilterWrapper$1.invoke(ProtocolFilterWrapper.java:91) [dubbo-2.5.3.jar:2.5.3]
    at com.alibaba.dubbo.rpc.filter.ClassLoaderFilter.invoke(ClassLoaderFilter.java:38) [dubbo-2.5.3.jar:2.5.3]
    at com.alibaba.dubbo.rpc.protocol.ProtocolFilterWrapper$1.invoke(ProtocolFilterWrapper.java:91) [dubbo-2.5.3.jar:2.5.3]
    at com.alibaba.dubbo.rpc.filter.EchoFilter.invoke(EchoFilter.java:38) [dubbo-2.5.3.jar:2.5.3]
    at com.alibaba.dubbo.rpc.protocol.ProtocolFilterWrapper$1.invoke(ProtocolFilterWrapper.java:91) [dubbo-2.5.3.jar:2.5.3]
    at com.alibaba.dubbo.rpc.protocol.dubbo.DubboProtocol$1.reply(DubboProtocol.java:108) [dubbo-2.5.3.jar:2.5.3]
    at com.alibaba.dubbo.remoting.exchange.support.header.HeaderExchangeHandler.handleRequest(HeaderExchangeHandler.java:84) [dubbo-2.5.3.jar:2.5.3]
    at com.alibaba.dubbo.remoting.exchange.support.header.HeaderExchangeHandler.received(HeaderExchangeHandler.java:170) [dubbo-2.5.3.jar:2.5.3]
    at com.alibaba.dubbo.remoting.transport.DecodeHandler.received(DecodeHandler.java:52) [dubbo-2.5.3.jar:2.5.3]
    at com.alibaba.dubbo.remoting.transport.dispatcher.ChannelEventRunnable.run(ChannelEventRunnable.java:82) [dubbo-2.5.3.jar:2.5.3]
    at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142) [na:1.8.0_131]
    at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617) [na:1.8.0_131]
    at java.lang.Thread.run(Thread.java:748) [na:1.8.0_131]
    Caused by: org.apache.ibatis.builder.BuilderException: Error evaluating expression 'projects != null and projects.size() > 0 '. Cause: org.apache.ibatis.ognl.MethodFailedException: Method "size" failed for object [u号租, 租号牛, 租号酷] [java.lang.IllegalAccessException: Class org.apache.ibatis.ognl.OgnlRuntime can not access a member of class java.util.Arrays$ArrayList with modifiers "public"]
    at org.apache.ibatis.scripting.xmltags.OgnlCache.getValue(OgnlCache.java:47) ~[mybatis-3.2.8.jar:3.2.8]
    at org.apache.ibatis.scripting.xmltags.ExpressionEvaluator.evaluateBoolean(ExpressionEvaluator.java:32) ~[mybatis-3.2.8.jar:3.2.8]
    at org.apache.ibatis.scripting.xmltags.IfSqlNode.apply(IfSqlNode.java:33) ~[mybatis-3.2.8.jar:3.2.8]
    at org.apache.ibatis.scripting.xmltags.MixedSqlNode.apply(MixedSqlNode.java:32) ~[mybatis-3.2.8.jar:3.2.8]
    at org.apache.ibatis.scripting.xmltags.TrimSqlNode.apply(TrimSqlNode.java:54) ~[mybatis-3.2.8.jar:3.2.8]
    at org.apache.ibatis.scripting.xmltags.MixedSqlNode.apply(MixedSqlNode.java:32) ~[mybatis-3.2.8.jar:3.2.8]
    at org.apache.ibatis.scripting.xmltags.TrimSqlNode.apply(TrimSqlNode.java:54) ~[mybatis-3.2.8.jar:3.2.8]
    at org.apache.ibatis.scripting.xmltags.MixedSqlNode.apply(MixedSqlNode.java:32) ~[mybatis-3.2.8.jar:3.2.8]
    at org.apache.ibatis.scripting.xmltags.DynamicSqlSource.getBoundSql(DynamicSqlSource.java:40) ~[mybatis-3.2.8.jar:3.2.8]
    at org.apache.ibatis.mapping.MappedStatement.getBoundSql(MappedStatement.java:278) ~[mybatis-3.2.8.jar:3.2.8]
    at com.github.pagehelper.PageInterceptor.intercept(PageInterceptor.java:83) ~[pagehelper-5.1.4.jar:na]
    at org.apache.ibatis.plugin.Plugin.invoke(Plugin.java:60) ~[mybatis-3.2.8.jar:3.2.8]
    at com.sun.proxy.$Proxy234.query(Unknown Source) ~[na:na]
    at org.apache.ibatis.session.defaults.DefaultSqlSession.selectList(DefaultSqlSession.java:108) ~[mybatis-3.2.8.jar:3.2.8]
    at org.apache.ibatis.session.defaults.DefaultSqlSession.selectList(DefaultSqlSession.java:102) ~[mybatis-3.2.8.jar:3.2.8]
    at sun.reflect.GeneratedMethodAccessor347.invoke(Unknown Source) ~[na:na]
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:1.8.0_131]
    at java.lang.reflect.Method.invoke(Method.java:498) ~[na:1.8.0_131]
    at org.mybatis.spring.SqlSessionTemplate$SqlSessionInterceptor.invoke(SqlSessionTemplate.java:358) ~[mybatis-spring-1.2.2.jar:1.2.2]
    ... 54 common frames omitted
    Caused by: org.apache.ibatis.ognl.MethodFailedException: Method "size" failed for object [aaa,bbb,ccc]
    at org.apache.ibatis.ognl.OgnlRuntime.callAppropriateMethod(OgnlRuntime.java:837) ~[mybatis-3.2.8.jar:3.2.8]
    at org.apache.ibatis.ognl.ObjectMethodAccessor.callMethod(ObjectMethodAccessor.java:61) ~[mybatis-3.2.8.jar:3.2.8]
    at org.apache.ibatis.ognl.OgnlRuntime.callMethod(OgnlRuntime.java:860) ~[mybatis-3.2.8.jar:3.2.8]
    at org.apache.ibatis.ognl.ASTMethod.getValueBody(ASTMethod.java:73) ~[mybatis-3.2.8.jar:3.2.8]
    at org.apache.ibatis.ognl.SimpleNode.evaluateGetValueBody(SimpleNode.java:170) ~[mybatis-3.2.8.jar:3.2.8]
    at org.apache.ibatis.ognl.SimpleNode.getValue(SimpleNode.java:210) ~[mybatis-3.2.8.jar:3.2.8]
    at org.apache.ibatis.ognl.ASTChain.getValueBody(ASTChain.java:109) ~[mybatis-3.2.8.jar:3.2.8]
    at org.apache.ibatis.ognl.SimpleNode.evaluateGetValueBody(SimpleNode.java:170) ~[mybatis-3.2.8.jar:3.2.8]
    at org.apache.ibatis.ognl.SimpleNode.getValue(SimpleNode.java:210) ~[mybatis-3.2.8.jar:3.2.8]
    at org.apache.ibatis.ognl.ASTGreater.getValueBody(ASTGreater.java:49) ~[mybatis-3.2.8.jar:3.2.8]
    at org.apache.ibatis.ognl.SimpleNode.evaluateGetValueBody(SimpleNode.java:170) ~[mybatis-3.2.8.jar:3.2.8]
    at org.apache.ibatis.ognl.SimpleNode.getValue(SimpleNode.java:210) ~[mybatis-3.2.8.jar:3.2.8]
    at org.apache.ibatis.ognl.ASTAnd.getValueBody(ASTAnd.java:56) ~[mybatis-3.2.8.jar:3.2.8]
    at org.apache.ibatis.ognl.SimpleNode.evaluateGetValueBody(SimpleNode.java:170) ~[mybatis-3.2.8.jar:3.2.8]
    at org.apache.ibatis.ognl.SimpleNode.getValue(SimpleNode.java:210) ~[mybatis-3.2.8.jar:3.2.8]
    at org.apache.ibatis.ognl.Ognl.getValue(Ognl.java:333) ~[mybatis-3.2.8.jar:3.2.8]
    at org.apache.ibatis.ognl.Ognl.getValue(Ognl.java:310) ~[mybatis-3.2.8.jar:3.2.8]
    at org.apache.ibatis.scripting.xmltags.OgnlCache.getValue(OgnlCache.java:45) ~[mybatis-3.2.8.jar:3.2.8]
    ... 72 common frames omitted


    赶紧查了下这个接口的调用情况,大部分没问题,偶尔冒了这么个错(还好还好)


    根据堆栈反查错误位置,有点想不通,这里会有问题?那就只能翻源码了



    源码分析


    经过排查,ognl表达式中用到的方法,会通过反射,获取method,并缓存至静态变量中,所以,存在多线程状态中,产生并发问题,往下看



    这里是缓存方法的逻辑,org.apache.ibatis.ognl.OgnlRuntime#getMethods(java.lang.Class, boolean)感兴趣的可以自己看


    这里就是bug点,如果调用一旦多,存在A线程修改成true,还没调用方法,B线程就修改成false,此时调用失败,这不是个坑吗


    image.png
    mybatis中一搜果然有这个issue:github.com/mybatis/myb…



    作者给的方案呢是升级mybatis。




    你以为就这样结束了?


    升级是不可能升级的,这辈子都不可能升级的,代码这么稳定,行行都像诗句[狗头]


    开个玩笑,看看如何避免



    原因呢就是Arrays.asList返回的是内部类,是private。所以导致了(!Modifier.isPublic(method.getModifiers()) || !Modifier.isPublic(method.getDeclaringClass().getModifiers()))这个条件为true,进入了设置
    accessible的逻辑,后面又给设置回原样


    总结



    • 问题:如果需要ognl的对象的方法和类不是public,那么会存在并发问题

    • 解决1:针对并发问题,升级Mybatis

    • 解决2:Lists.newArrayList或者其他写法代替,反正看下,内部类是不是private


    有问题希望留言指出哈


    作者:山间小僧
    来源:juejin.cn/post/7264921613551730722
    收起阅读 »

    在Spring Boot中使用Sa-Token实现路径拦截和特定接口放行

    在Spring Boot中使用Sa-Token实现路径拦截和特定接口放行 很喜欢的一段话:别想太多,好好生活,也许日子过着过着就会有答案,努力走着走着就会有温柔的着落。 春在路上,花在枝上,所有的美好都在路上,努力过好自己的生活,偶尔慌乱,偶尔平稳,都各有...
    继续阅读 »

    在Spring Boot中使用Sa-Token实现路径拦截和特定接口放行


    在这里插入图片描述



    很喜欢的一段话:别想太多,好好生活,也许日子过着过着就会有答案,努力走着走着就会有温柔的着落。
    春在路上,花在枝上,所有的美好都在路上,努力过好自己的生活,偶尔慌乱,偶尔平稳,都各有滋味,怀着诚恳,好好努力好好生活,闲事勿虑,别让鸡零狗碎的破事,耗尽你对美好生活的所有向往。



    1. 引入依赖


    首先,在pom.xml文件中引入Sa-Token相关的依赖。Sa-Token是一个轻量级的Java权限认证框架,可以帮助我们轻松实现用户登录状态的管理和权限认证。


    <dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-spring-boot-starter</artifactId>
    <version>1.27.0</version>
    </dependency>

    2. 创建配置类 SecurityProperties


    定义一个配置类SecurityProperties,用于读取和存储从配置文件中加载的排除路径信息。这里使用了Spring Boot的@ConfigurationProperties注解来绑定配置文件中的属性。


    import org.springframework.boot.context.properties.ConfigurationProperties;
    import org.springframework.stereotype.Component;
    import lombok.Data;

    @Data
    @Component
    @ConfigurationProperties(prefix = "security")
    public class SecurityProperties {
    /**
    * 排除路径
    */

    private String[] excludes;
    }


    • @Data:这是Lombok的注解,自动生成getter和setter方法。

    • @Component:将该类注册为Spring的组件。

    • @ConfigurationProperties:指定前缀security,从配置文件中读取以该前缀开头的属性,并将这些属性映射到该类的字段上。


    3. 编写配置文件


    在配置文件application.yml或者application.properties中,配置需要排除的路径。例如:


    application.yml:


    security:
    excludes:
    - "/public/**"
    - "/login"
    - "/register"

    application.properties:


    security.excludes=/public/**,/login,/register


    • /public/**:排除所有以/public/开头的路径。

    • /login:排除/login路径。

    • /register:排除/register路径。


    4. 配置拦截器


    创建一个配置类WebConfig,实现WebMvcConfigurer接口,在其中配置Sa-Token的拦截器,并将排除的路径应用到拦截器中。


    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
    import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
    import cn.dev33.satoken.interceptor.SaInterceptor;
    import cn.dev33.satoken.router.SaRouter;
    import cn.dev33.satoken.stp.StpUtil;

    @Configuration
    public class WebConfig implements WebMvcConfigurer {

    @Autowired
    private SecurityProperties securityProperties;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(new SaInterceptor(handler -> {
    // 获取所有的URL并进行检查
    SaRouter.match("/**").check(() -> {
    // 检查是否登录
    StpUtil.checkLogin();
    });
    }))
    .addPathPatterns("/**") // 拦截所有路径
    .excludePathPatterns(securityProperties.getExcludes()); // 排除指定路径
    }
    }


    • @Configuration:标识这是一个配置类。

    • addInterceptors:重写该方法,向Spring的拦截器注册中心添加自定义的拦截器。

    • SaInterceptor:Sa-Token提供的拦截器,主要用于权限验证。

    • SaRouter.match("/**"):匹配所有路径。

    • StpUtil.checkLogin():Sa-Token提供的登录状态检查方法,用于验证用户是否已登录。

    • excludePathPatterns:从拦截中排除指定的路径,这些路径从SecurityProperties中获取。


    5. 验证拦截效果


    启动Spring Boot应用程序,验证配置是否生效。以下是一些测试步骤:



    1. 访问排除路径



      • 尝试访问配置文件中排除的路径,如/public/**/login/register

      • 这些路径应不会触发登录检查,可以直接访问。



    2. 访问其他路径



      • 尝试访问其他未排除的路径,如/admin/user/profile等。

      • 这些路径应触发Sa-Token的登录验证逻辑,如果用户未登录,将会被拦截,并返回相应的未登录提示。




    代码解析



    • SecurityProperties:通过@ConfigurationProperties注解,Spring Boot会自动将前缀为security的配置属性绑定到该类的excludes字段上,从而实现排除路径的配置。

    • 配置文件:在配置文件中定义需要排除的路径,以便动态加载到SecurityProperties中。

    • WebConfig:实现WebMvcConfigurer接口,通过addInterceptors方法添加Sa-Token的拦截器,并使用excludePathPatterns方法将配置文件中定义的排除路径应用到拦截器中。


    详细解释


    依赖配置


    Sa-Token是一个轻量级的权限认证框架,可以帮助我们轻松实现用户登录状态的管理和权限认证。通过引入sa-token-spring-boot-starter依赖,我们可以很方便地将其集成到Spring Boot项目中。


    配置类 SecurityProperties


    SecurityProperties类的作用是将配置文件中定义的排除路径读取并存储到excludes数组中。通过使用@ConfigurationProperties注解,我们可以将前缀为security的属性绑定到该类的excludes字段上。这样做的好处是,排除路径可以通过配置文件进行动态配置,方便管理和维护。


    配置文件


    在配置文件中,我们定义了需要排除的路径。这些路径将不会被拦截器拦截,可以直接访问。配置文件支持YAML格式和Properties格式,根据项目需要选择合适的格式进行配置。


    拦截器配置


    WebConfig类中,我们实现了WebMvcConfigurer接口,并重写了addInterceptors方法。在该方法中,我们创建了一个Sa-Token的拦截器,并通过SaRouter.match("/**")匹配所有路径。对于匹配到的路径,我们使用StpUtil.checkLogin()方法进行登录状态检查。如果用户未登录,将会被拦截,并返回相应的未登录提示。


    通过excludePathPatterns方法,我们将从SecurityProperties中获取的排除路径应用到拦截器中。这样一来,配置文件中定义的排除路径将不会被拦截器拦截,可以直接访问。


    总结


    通过本文的介绍,我们了解了如何在Spring Boot中使用Sa-Token实现路径拦截和特定接口放行。我们首先引入了Sa-Token的依赖,然后定义了一个配置类SecurityProperties,用于读取和存储排除路径信息。接着,在配置文件中定义了需要排除的路径,并在WebConfig类中配置了Sa-Token的拦截器,将排除路径应用到拦截器中。最后,通过测试和验证,确保配置生效,实现了对特定路径的放行和其他路径的权限验证。


    这种方式可以帮助开发者更灵活地管理Web应用中的访问控制,提升系统的安全性和可维护性。如果你有更多的自定义需求,可以根据Sa-Token的文档进行进一步配置和扩展。


    作者:IT小辉同学
    来源:juejin.cn/post/7379117970797183030
    收起阅读 »

    一文了解 WWDC2024 重要更新

    Ultrawide Mac 显示器在 visionOS 2 中的展现 这就是 visionOS 2 Apple Vision Pro即将进驻这些国家! 有关 iOS 18 新的控制中心 深色模式中的深色图标 iOS 18 中的主屏幕定制 你...
    继续阅读 »

    image.png


    Ultrawide Mac 显示器在 visionOS 2 中的展现


    image.png


    这就是 visionOS 2


    image.png


    Apple Vision Pro即将进驻这些国家!


    image.png


    有关 iOS 18


    image.png


    新的控制中心


    image.png



    • 深色模式中的深色图标
      image.png

    • iOS 18 中的主屏幕定制


    image.png



    • 你现在可以在网格任意位置放置你的应用程序


    image.png



    • 您现在可以在 iOS 18 中更改锁屏上默认的手电筒和相机图标


    image.png



    • 使用面部ID锁定应用程序


    image.png



    • 您现在可以在 iOS 18 #WWDC24中的 iMessage 中使用任意表情符号进行点击回复


    image.png



    • 你现在可以在 iOS 18 中设定信息发送时间


    image.png



    • 在你没有信号时,你现在可以通过 iOS 18 中的卫星发送消息


    image.png



    • iOS 18 简直太疯狂了!


    image.png



    • 你现在可以在 iOS 18 点击付款为苹果现金


    image.png



    • 在 iOS 18 中的照片应用程序重新设计


    image.png


    **iOS 18 中的全新改动!


    ** image.png


    AirPods 和 Apple TV 的新功能


    image.png


    watchOS 11 引入训练负荷功能


    image.png


    watchOS 11 中的全新改动


    image.png


    iPadOS 18中的全新改动


    image.png


    Apple 在 iPadOS 18 中为应用程序引入新的标签栏设计


    image.png


    计算器应用程序终于来到 iPad 中!


    image.png


    iPad 上的计算器应用程序引入了数学笔记!


    image.png


    macOS Sequoia


    image.png


    macOS Sequoia 的全新改动


    image.png



    • 你现在可以把iPhone界面镜像到你的Mac上


    image.png



    • 你现在可以在你的Mac上接收你的iPhone通知


    image.png


    作者:Captaincc
    来源:juejin.cn/post/7378328335036612647
    收起阅读 »

    一个轻量的后台管理模板

    web
    特色: 当前模板将自定义样式配置通过css变量的方式提取了出来,直接通过可视化去配置成你喜欢的样式效果,在下面预览地址中可以体验。 预览地址 可视化配置面版不够好看的话,可以把地址上的vue-admin改为vue-admin-el 项目地址 描述 无UI框...
    继续阅读 »

    特色: 当前模板将自定义样式配置通过css变量的方式提取了出来,直接通过可视化去配置成你喜欢的样式效果,在下面预览地址中可以体验。



    描述


    无UI框架依赖的后台管理模板


    当前项目是基于vue.js去实现的一套后台管理模板,早在2019年就已经在持续迭代,目前已经是较新的vue3.x版本;


    因为在中后台项目中,大多数核心功能只有页面框架样式侧边菜单栏功能,所以除了底层 js 框架vue+vue-router以外,所有样式、功能都采用自行实现方式;之所以不使用第三方UI库的理由是:



    • 不受UI框架的约束,可以使用任何一款自己喜欢的第三方库;

    • 轻量化,因为用到的依赖极少,所以体积非常轻量,同时保证了常用到的大部分功能保留;所有的工程化配置根据自身需求去加入即可,当前模板只做代码减法;

    • 兼容性、拓展性高,模板中每个部分都是可以独立抽离和替换的,并无上手成本;当在引用某一款UI库使用时,直接引入依赖并使用即可,无需修改模板已有功能组件;

    • 别人写的模板代码太多了,都不好改!


    当前模板项目的 package.json 做到了极致的精简


    {
    "dependencies": {
    "nprogress": "0.2.0",
    "vue": "3.4.21",
    "vue-router": "4.3.0"
    },
    "devDependencies": {
    "@types/node": "20.11.28",
    "@types/nprogress": "0.2.0",
    "@vitejs/plugin-vue": "5.0.4",
    "@vitejs/plugin-vue-jsx": "3.1.0",
    "sass": "~1.71.0",
    "typescript": "~5.4.0",
    "vite": "5.2.8",
    "vue-tsc": "~1.8.0"
    }
    }

    功能目录清单



    • vue-router 权限路由功能、路由记录初始进入路径功能

    • layout 部分:可视化配置样式功能、顶部伸缩布局 + 多级侧边菜单栏、路由面包屑、路由历史记录标签栏、整体自适应窗口大小布局、滚动条(类似

    • utils 只保留使用频率极高的:日期格式化、复制、类型判断、网络请求、和一些核心功能函数

    • UI控件 + 通用组件:消息提示条、对话框、高度自适应折叠组件、dialog 组件


    layout 核心布局整体


    大多数情况开发者在选用开源模板时,只是为了侧边菜单栏和顶部的布局不同而选择对应的模板,所以当前项目直接将两种布局写成可以动态切换,并且加入可视化的样式配置操作,这样连css代码都不需要去看了:


    微信截图_20240327154406.png


    侧边菜单栏为什么没有整一个折叠缩略的功能?理由是我觉得这个操作逻辑不是那么的理想,缩小后,我需要鼠标一层一层的放上去找到需要的子菜单,这一点都不方便;而且缩小菜单的目的是为了获得更大内容可视区域,所以缩小后的菜单依然还占用了一部分空间,同时使用功能变得繁琐,那干脆在收起菜单时,将她整个推出屏幕区域,这样就能使可视区域最大化。


    微信截图_20240327152829.png


    路由权限设置


    完全继承了vue-router的数据结构,只在meta对象中加入auth作为路由数组过滤操作去实现权限控制;另外根部对象的name字段则作为路由缓存的唯一值。


    import { RouteRecordRaw } from "vue-router";

    export interface RouteMeta {
    /** 侧边栏菜单名、document.title */
    title: string
    /** 外链地址,优先级会比`path`高 */
    link?: string
    /** `svg`名 */
    icon?: string
    /** 是否在侧边菜单栏不显示该路由 */
    hidden?: boolean
    /**
    * 路由是否需要缓存
    * - 当设置该值为`true`时,路由必须要设置`name`,页面组件中的`name`也是,不然路由缓存不生效
    */

    keepAlive?: boolean
    /**
    * 可以访问该权限的用户类型数组,与`userInfo.type`对应;
    * 传空数组或者不写该字段代表可以全部用户访问
    *
    * | number | 用户类型 |
    * | --- | --- |
    * | 0 | 超级管理员 |
    * | 1 | 普通用户 |
    */

    auth?: Array<number>
    }

    /** 自定义的路由类型-继承`RouteRecordRaw` */
    export type RouteItem = {
    /**
    * 路由名,类似唯一`key`
    * - 路由第一层必须要设置,因为动态路由删除时需要用到,且唯一
    * - 当设置`meta.keepAlive`为`true`时,该值必填,且唯一,另外组件中的`name`也需要对应的同步设置,不然路由缓存不生效
    */

    name?: string
    /** 子级路由 */
    children?: Array<RouteItem>
    /** 标头 */
    meta: RouteMeta
    } & RouteRecordRaw


    代码演示


    状态管理


    Vue3之后不需要Vuex了(虽然我在Vue2中也没用),而是采用另外一种更简单的方式:参考 你不需要vuex


    ts的项目中,因为可以用Readonly去声明状态对象,所以这套程序设计会发挥得最好,具体示例可以在src/store/README.md中查看


    网络请求


    这里我使用的是根据个人习惯用原生写的ajax代码地址


    理由是:



    • 代码少,功能足以覆盖常用的大部分场景

    • ts中可以更友好的声明接口返回类型


    文件:api.ts request中的泛型不是必须的,不传下面 .vue 文件中res.data中的类型则是any


    export interface TableItem {
    id: number
    type: "load" | "update"
    time: string
    }

    /**
    * @param params
    */

    export function getData(params: PageInfo) {
    return request<Api.List<TableItem>>("GET", "/getList", params)
    }

    文件:demo.vue 建议直接在vscode中用鼠标去看提示,那样会更加的直观


    <script lang="ts" steup>
    import { ref } from "vue";
    import { type TableItem, getData } from "@/api.ts";

    const tableData = ref<Array<TableItem>>([]);

    async function getTableData() {
    const res = await getData({
    pageSize: 10,
    currentPage: 1
    })
    if (res.code === 1) {
    tableData.value = res.data.list; // 这里的 .list 就是接口 传入的类型 TableItem
    // do some...
    }
    }
    script>

    强力建议请求函数的封装时,都始终执行 Promise.resolve 去作为正确和错误的响应。接口获取后始终以res.code === 1为判断成功,无需在内部用 try + catch 去包一层


    更多使用示例请在src/api/README.md中查看



    另外可根据自己喜好可以扩展 axios 这类型第三方库。



    SVG 图标组件


    使用方式:到阿里云图标库中下载想要的图标,然后下载svg文件,最后放到src/icons/svg目录下即可


    也是自己写的一个加载器,代码十分简单:


    import { readFileSync, readdirSync } from "fs";

    // svg-sprite-loader 这个貌似在 vite 中用不了
    // 该文件只能作为`vite.config.ts`导入使用
    // 其他地方导入会报错,因为浏览器环境不支持`fs`模块

    /** `id`前缀 */
    let idPerfix = "";

    const svgTitle = /+].*?)>/;

    const clearHeightWidth = /(width|height)="([^>+].*?)"/g;

    const hasViewBox = /(viewBox="[^>+].*?")/g;

    const clearReturn = /(\r)|(\n)/g;

    /**
    * 查找`svg`文件
    * @param dir 文件目录
    */

    function findSvgFile(dir: string): Array<string> {
    const svgRes: Array<string> = []
    const dirents = readdirSync(dir, {
    withFileTypes: true
    });
    dirents.forEach(function(dirent) {
    if (dirent.isDirectory()) {
    svgRes.push(...findSvgFile(dir + dirent.name + "/"));
    } else {
    const svg = readFileSync(dir + dirent.name).toString().replace(clearReturn, "").replace(svgTitle, function(_, group) {
    // console.log(++i)
    // console.log(dirent.name)
    let width = 0;
    let height = 0;
    let content = group.replace(clearHeightWidth, function(val1: string, val2: string, val3: number) {
    if (val2 === "width") {
    width = val3;
    } else if (val2 === "height") {
    height = val3;
    }
    return "";
    });
    if (!hasViewBox.test(group)) {
    content += `viewBox="0 0 ${width} ${height}"`;
    }
    return `${idPerfix}-${dirent.name.replace(".svg", "")}" ${content}>`;
    }).replace("", "");
    svgRes.push(svg);
    }
    });
    return svgRes;
    }

    /**
    * `svg`打包器
    * @param path 资源路径
    * @param perfix 后缀名(标签`id`前缀)
    */

    export function svgBuilder(path: string, perfix = "icon") {
    if (path.trim() === "") return;
    idPerfix = perfix;
    const res = findSvgFile(path);
    // console.log(res.length)
    return {
    name: "svg-transform",
    transformIndexHtml(html: string) {
    return html.replace("",
    `

    ${res.join("")}
    `
    )
    }
    }
    }


    作者:黄景圣
    来源:juejin.cn/post/7350874162011750400
    收起阅读 »

    大部分公司都是草台班子,甚至更水

    我第一份实习是在一家咨询公司,我以为我们能够给我们的客户提供极具商业价值的战略指导,但其实开始干活了之后,发现我们就是PPT和调研报告的搬运工。后来我去了一家互联网大厂,我以为我的身边全都是逻辑超强的技术和产品大佬,直到我们的产品带着一堆的bug上线了.......
    继续阅读 »

    我第一份实习是在一家咨询公司,我以为我们能够给我们的客户提供极具商业价值的战略指导,但其实开始干活了之后,发现我们就是PPT和调研报告的搬运工。后来我去了一家互联网大厂,我以为我的身边全都是逻辑超强的技术和产品大佬,直到我们的产品带着一堆的bug上线了......


    大四秋招的时候,我跟一个应届生一起面试,他简历上写了精通数据分析,还有很多获奖。我当时很羡慕他的能力,直到一起入职之后发现他只会用Excel......


    刚从学生变成打工人的时候,我觉得每一家公司都是一个严丝合缝,非常精密的巨大的仪器,我要达到某一个水平或者有某种资质,我才能够去做一些工作,或者达到一些成就。但后来随着工作久了,我就想明白了一件事情,让我觉得之前的班真是白上了。


    其实一个公司它的运营机制,并不是有很多个有远见的领导把规划都想明白,然后再有很多个能力强的下属把这些规划全部落地,这种太理想化了。电影里都没有这么演的。公司的运营机制就是面多了就加水,水多就加面,所有的公司都是大的草台班子。这里边绝大多数的工作,它的粗糙程度都远超过我们的想象。我们根本不用陷入所谓的入场券陷阱,觉得别人都很厉害,别人都是科班出身的,我得像别人一样厉害,一样有资质了,我才能够去做,这只不过是我给我自己设的一个假想敌。


    想明白这一点之后,我的焦虑和内耗就好多了。既然大家都很水,那在职场这个大的草台班子上,我如果不去争取机会,那就被还不如我的人抢走了。勇敢的人先享受生活,同样勇敢的打工人也会先当上生活中的主演。


    在争取机会的过程中,难免你就会用到一些职场作弊小技巧,就是自我包装。身边就有几个这样的人,敢于勇敢地表现自己,让别人觉得他能够创造很多价值。包装造势在掠夺职场资源的竞争力是非常有效的。


    包装的方式分为职业和爱好。在职场上一定不要沉迷那些琐碎的工作中无法自拔,不要显得自己每天都很忙,加班都很晚,效率低下偷懒的人才要加班,不要不满现状,导致不想思考,不要未经选择直接就开始低效率的行动,所以要适当的停下来,寻找自我包装的发力点。


    就像我们公司今年越来越重视数据分析,所以我就利用下班的时间多学习了数据分析。包装它肯定不只是一句空话,不然用不了多久就露馅了,所以要找到快速高效的学习方法。


    如果你的职业发展方向也是产品运营,市场数据分析类似的岗位,那就要尽早的培养起你的数据分析能力,用好SQL,Python,统计学还有Excel,这些都会帮助你去提取处理和分析数据,再结合上你所在行业的专业知识,技能buff叠加在职场中会非常的加分。


    职场里其实并没有那么多很厉害的人,大家都是在现学现卖,反正都是在草台班上演戏,不妨大胆一点去探索新的东西,去尝试你想尝试的,去找到能够把自己包装好的那个点,然后去大大方方的展示和表现自己。


    作者:程序员Winn
    来源:juejin.cn/post/7304867278566899764
    收起阅读 »

    鸿蒙实现动态增删 View,看这一篇就够了!!

    在 Android 开发的过程中,我们经常使用 addView ,removeView等实现在 java 代码中动态添加和删除 View 的能力 但是在鸿蒙中,组件是相对于静态的结构,而且鸿蒙也没有提供类似于 addView ,removeView的方法,那我...
    继续阅读 »

    在 Android 开发的过程中,我们经常使用 addViewremoveView等实现在 java 代码中动态添加和删除 View 的能力


    但是在鸿蒙中,组件是相对于静态的结构,而且鸿蒙也没有提供类似于 addViewremoveView的方法,那我们怎么来实现动态化增删组件的能力呢


    1. 使用 ForEach 实现动态化增删组件


    1.1. ForEach 的入参


    翻阅了鸿蒙的官方文档,终于看到了一种方法来解决这个问题,那就是使用 ForEach这个组件


    这个组件的官方定义如下


    ForEach(
    arr: Array,
    itemGenerator: (item: any, index: number) => void,
    keyGenerator?: (item: any, index: number) => string
    )


    • arr


      arr 有多少个元素,ForEach 就会渲染多少个组件


    • itemGenerator


      将 arr 中的元素转换为对应的组件样式


      决定了组件长什么样子


    • keyGenerator


      生成唯一的 key



    1.2. ForEach 的渲染


    在ForEach循环渲染过程中,系统会为每个数组元素生成一个唯一且持久的键值,用于标识对应的组件。当这个键值变化时,ArkUI框架将视为该数组元素已被替换或修改,并会基于新的键值创建一个新的组件。


    ForEach 的渲染分为两种:



    • 首次渲染


    在ForEach首次渲染时,会根据前述键值生成规则为数据源的每个数组项生成唯一键值,并创建相应的组件。



    • 非首次渲染:


    在ForEach组件进行非首次渲染时,它会检查新生成的键值是否在上次渲染中已经存在。如果键值不存在,则会创建一个新的组件;如果键值存在,则不会创建新的组件,而是直接渲染该键值所对应的组件。


    2. ForEach 的简单 demo


    @Entry
    @Component
    export struct MyPage {
    @State items: Item[] = [
    { text: "1" },
    { text: "2" },
    { text: "3" },
    ]

    build() {
    Column() {
    ForEach(
    this.items,
    (item: Item, index: number) => {
    Text(item.text)
    .width(40)
    .height(40)
    }
    )

    Button("add item ")
    .onClick(
    ()=>{
    this.items.push(
    { text: "11" },
    )
    }
    )
    }
    .height('100%')
    .width('100%')
    }
    }


    interface Item {
    text: string,
    }

    运行后的状态为这样:每次点击按钮,都会新增一个



    3. ForEach 的注意事项


    3.1. 数组中元素子属性发生变化时的处理


    数组中元素子属性发生变化时,鸿蒙默认是不会触发渲染的


    解决办法是:使用@Observed@ObjectLink


    3.2. 最好自定义 key,key 中不含index


    鸿蒙的默认 key


    鸿蒙 Foreach 的默认key 为


    (item: T, index: number) => {
    //👇 这里带着 index
    return index + '__' + JSON.stringify(item);
    }

    默认的 key 里面带着 index,如果我们在对数组进行操作的过程中,将元素的 index改变了,就会导致 index 改变的元素对应的组件被重绘


    鸿蒙 ForEach 的渲染


    鸿蒙通过 key 来判断组件是新组件还是现有组件


    index 改变,key 就改变了,鸿蒙就会执行两个操作



    1. 删除原来的 key 对应的组件

    2. 重新创建组件


    就会导致原来的组件中的所有状态全部没有了比如说我们正在执行动画,但是 key 被改变了,动画就会中断,重新创建的组件没有动画执行的状态,如果我们想继续执行动画,那么必须保存动画的状态,这样成本太大了,


    而我们只需要自定义 key,让 index 不在 key 里面,就解决这个问题了


    为什么 index 会改变?


    比如说中间的元素被删除了,后面的元素 index 就会改变


    而且我们不可能在执行时,一定保证移除的是最后一个元素,添加的一定在最后面


    而如果我们自定义key 里面没有 index,那么我们可以随意的增删数组中的元素


    自定义 key 的优点



    • 可以让我们任意对数组进行操作,

    • 优化性能


    比如说我们只是删除了数组中间的一个元素,后面的元素没有任何的改变


    如果我们使用鸿蒙默认的 key,后面的元素 index 改变,key 改变,全部会重绘


    如果我们自定义 key,后面的元素就不会重绘了,节省性能


    自定义 key 的注意事项



    1. 一定不要包含 index

    2. key 的组成部分尽量全部为常量,不要变量


    如果是变量,保证组件生命周期内不会变化


    或者如果生命周期发生变化,是自己预期内的



    作者:wddin
    来源:juejin.cn/post/7374984900372709412
    收起阅读 »

    为什么阿里巴巴为什么不推荐使用keySet()进行遍历HashMap?

    引言 HashMap相信所有学Java的都一定不会感到陌生,作为一个非常重用且非常实用的Java提供的容器,它在我们的代码里面随处可见。因此遍历操作也是我们经常会使用到的。HashMap的遍历方式现如今有非常多种: 使用迭代器(Iterator)。 使用 k...
    继续阅读 »

    引言


    HashMap相信所有学Java的都一定不会感到陌生,作为一个非常重用且非常实用的Java提供的容器,它在我们的代码里面随处可见。因此遍历操作也是我们经常会使用到的。HashMap的遍历方式现如今有非常多种:



    1. 使用迭代器(Iterator)。

    2. 使用 keySet() 获取键的集合,然后通过增强的 for 循环遍历键。

    3. 使用 entrySet() 获取键值对的集合,然后通过增强的 for 循环遍历键值对。

    4. 使用 Java 8+ 的 Lambda 表达式和流。


    以上遍历方式的孰优孰劣,在《阿里巴巴开发手册》中写道:


    image.png


    这里推荐使用的是entrySet进行遍历,在Java8中推荐使用Map.forEach()。给出的理由是遍历次数上的不同。



    1. keySet遍历,需要经过两次遍历。

    2. entrySet遍历,只需要一次遍历。



    其中keySet遍历了两次,一次是转为Iterator对象,另一次是从hashMap中取出key所对应的value。



    其中后面一段话很好理解,但是前面这句话却有点绕,为什么转换成了Iterator遍历了一次?


    我查阅了各个平台对HashMap的遍历,其中都没有或者原封不动的照搬上句话。(当然也可能是我没有查阅到靠谱的文章,欢迎指正)


    keySet如何遍历了两次


    我们首先写一段代码,使用keySet遍历Map。


    public class Test {


    public static void main(String[] args) {
    Map<String, String> map = new HashMap<>();
    map.put("k1", "v1");
    map.put("k2", "v2");
    map.put("k3", "v3");
    for (String key : map.keySet()) {
    String value = map.get(key);
    System.out.println(key + ":" + value);
    }
    }

    }

    运行结果显而易见的是


    k1:v1
    k2:v2
    k3:v3

    两次遍历,第一次遍历所描述的是转为Iterator对象我们好像没有从代码中看见,我们看到的后面所描述的遍历,也就是遍历map,keySet()所返回的Set集合中的key,然后去HashMap中拿取value的。


    Iterator对象呢?如何遍历转换为Iterator对象的呢?


    image.png


    首先我们这种遍历方式大家都应该知道是叫:增强for循环,for-each


    这是一种Java的语法糖~。可以看上篇文章了解~


    我们可以通过反编译,或者直接通过Idea在class文件中查看对应的Class文件


    image.png
    public class Test {
    public Test() {
    }

    public static void main(String[] args) {
    Map<String, String> map = new HashMap();
    map.put("k1", "v1");
    map.put("k2", "v2");
    map.put("k3", "v3");
    Iterator var2 = map.keySet().iterator();

    while(var2.hasNext()) {
    String key = (String)var2.next();
    String value = (String)map.get(key);
    System.out.println(key + ":" + value);
    }

    }
    }

    和我们编写的是存在差异的,其中我们可以看到其中通过map.keySet().iterator()获取到了我们所需要看见的Iterator对象。


    那么它又是怎么转换成的呢?为什么需要遍历呢?我们查看iterator()方法


    iterator()


    image.png

    发现是Set定义的一个接口。返回此集合中元素的迭代器


    HashMap.KeySet#iterator()


    我们查看HashMap中keySet类对该方法的实现。


    image.png
    image.png
        final class KeySet extends AbstractSet<K> {
    public final int size() { return size; }
    public final void clear() { HashMap.this.clear(); }
    public final Iterator<K> iterator() { return new KeyIterator(); }
    public final boolean contains(Object o) { return containsKey(o); }
    public final boolean remove(Object key) {
    return removeNode(hash(key), key, null, false, true) != null;
    }
    public final Spliterator<K> spliterator() {
    return new KeySpliterator<>(HashMap.this, 0, -1, 0, 0);
    }
    public final void forEach(Consumer<? super K> action) {
    Node<K,V>[] tab;
    if (action == null)
    throw new NullPointerException();
    if (size > 0 && (tab = table) != null) {
    int mc = modCount;
    for (int i = 0; i < tab.length; ++i) {
    for (Node<K,V> e = tab[i]; e != null; e = e.next)
    action.accept(e.key);
    }
    if (modCount != mc)
    throw new ConcurrentModificationException();
    }
    }
    }

    其中的iterator()方法返回的是一个KeyIterator对象,那么究竟是在哪里进行了遍历呢?我们接着往下看去。


    HashMap.KeyIterator


    image.png
        final class KeyIterator extends HashIterator
    implements Iterator<K> {
    public final K next() { return nextNode().key; }
    }

    这个类也很简单:



    1. 继承了HashIterator类。

    2. 实现了Iterator接口。

    3. 一个next()方法。


    还是没有看见哪里进行了遍历,那么我们继续查看HashIterator


    HashMap.HashIterator


    image.png
        abstract class HashIterator {
    Node<K,V> next; // next entry to return
    Node<K,V> current; // current entry
    int expectedModCount; // for fast-fail
    int index; // current slot

    HashIterator() {
    expectedModCount = modCount;
    Node<K,V>[] t = table;
    current = next = null;
    index = 0;
    if (t != null && size > 0) { // advance to first entry
    do {} while (index < t.length && (next = t[index++]) == null);
    }
    }

    public final boolean hasNext() {
    return next != null;
    }

    final Node<K,V> nextNode() {
    Node<K,V>[] t;
    Node<K,V> e = next;
    if (modCount != expectedModCount)
    throw new ConcurrentModificationException();
    if (e == null)
    throw new NoSuchElementException();
    if ((next = (current = e).next) == null && (t = table) != null) {
    do {} while (index < t.length && (next = t[index++]) == null);
    }
    return e;
    }

    public final void remove() {
    Node<K,V> p = current;
    if (p == null)
    throw new IllegalStateException();
    if (modCount != expectedModCount)
    throw new ConcurrentModificationException();
    current = null;
    K key = p.key;
    removeNode(hash(key), key, null, false, false);
    expectedModCount = modCount;
    }
    }

    我们可以发现这个构造器中存在了一个do-while循环操作,目的是找到一个第一个不为空的entry


            HashIterator() {
    expectedModCount = modCount;
    Node<K,V>[] t = table;
    current = next = null;
    index = 0;
    if (t != null && size > 0) { // advance to first entry
    do {} while (index < t.length && (next = t[index++]) == null);
    }
    }

    KeyIterator是extendHashIterator对象的。这里涉及到了继承的相关概念,大家忘记的可以找相关的文章看看,或者我也可以写一篇~~dog。


    例如两个类


    public class Father {

    public Father(){
    System.out.println("father");
    }
    }

    public class Son extends Father{

    public static void main(String[] args) {
    Son son = new Son();
    }
    }

    创建Son对象的同时,会执行Father构造器。也就会打印出father这句话。


    那么这个循环操作就是我们要找的循环操作了。


    总结



    1. 使用keySet遍历,其实内部是使用了对应的iterator()方法。

    2. iterator()方法是创建了一个KeyIterator对象。

    3. KeyIterator对象extendHashIterator对象。

    4. HashIterator对象的构造方法中,会遍历找到第一个不为空的entry



    keySet->iterator()->KeyIterator->HashIterator



    大家想更清楚了解这个entry是什么?可以看我的HashMap文章~。文章如果存在错误,欢迎大家评论区指正~~


    image.png


    作者:以范特西之名
    来源:juejin.cn/post/7295353579002396726
    收起阅读 »