注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

勇敢的人先拿到结果

上周许久未见的大学学长叫我出去喝酒,他这次来贵阳是为开分店的事情而来的,他比我高一个年级,在我毕业的时候,他就自己开始做生意了,短短两三年,到现在他已经开了七八个分店了,还在不断发展,并且加盟的人也不少,平均下来,现在每个月的收入也是很可观的。 对于我们这种末...
继续阅读 »

上周许久未见的大学学长叫我出去喝酒,他这次来贵阳是为开分店的事情而来的,他比我高一个年级,在我毕业的时候,他就自己开始做生意了,短短两三年,到现在他已经开了七八个分店了,还在不断发展,并且加盟的人也不少,平均下来,现在每个月的收入也是很可观的。


对于我们这种末流二本院校毕业的学生,特别还是在贵州这个经济相对比较落后的地区,拿到这个成绩还是挺厉害的,并且这个收入并不是固定的,还是不断增长。


学长是学市场营销的,这也算是个天坑专业,所以那会他就知道自己将来肯定是从事不了这个行业的,所以自己就在宿舍开了一个小卖部,每天下课后就骑着电瓶车去送货,虽然每个月赚不了多少钱,但是对于做生意这一块,他的思维肯定是得到了锻炼。


因为我们是在广西读书,所以螺蛳粉就比较多,在毕业后,他就去柳州考察做螺蛳粉,联系好各种渠道后,回到贵州就直接开干。


因为那会贵州的各个市里面卖螺蛳粉的还很少,并且没有特色和品牌效应,所以自己就先设计名称,logo,最后先开了一个店铺,自己亲自下厨,因为比较有特色,一个月直接干到了全市螺蛳粉餐饮销量的第二名。


随后又开了第二家,第三家......别人在看到他赚了钱后,其它市区的人也纷纷向他学习,他自己就收加盟费用,现在他要做的事情就是玩,还有考察门店,然后扩展。


从他的事迹中,我说两个点。


勇于放弃


对于很多人而言,读书的目的就是为了找一份稳定的工作,最好是体制内。


如果你读完大学后出去做销售,做生意,那么对于你身边的很多人而言,他们会觉得你这个大学白读了,因为在他们眼中,只有坐在办公室里面才是最体面了。


你和他说做生意,创业这些东西,他会给你说:这些不稳,以后没有退休工资。


但是如果你真听他们的,那么后面后悔的一定是你。


就像学长,如果他也和别人一样毕业后回到自己那地方加入考编大军,那么他现在肯定和别人一样,也在背书,焦虑,但是他选择了其它的路。


这时候有些人就会抬杠:考上了就能吃一辈子,而你做生意如果运气不好那么就直接亏光,到时候你就知道编制的香了。


这也是很多人的通病。


我觉得如果一件事情你看不到希望,就别过于去迷恋它,舍不得它,不然会被它束缚,比如学历,经验等等。


敢想敢干


可能你会觉得他家里应该有底子的,不然毕业后怎么就能开店。


但是我们问一下自己,就算你家里有底子,毕业后就给你十万块让你开店,你觉得你行吗?恐怕大部分人都不知道自己该做什么吧。


首先躬身入局本身就是一件很难的事情,我们多数人能够拼命上班,但是如果让你脱离平台去自己干一件事就比登天还难。


因为你在公司有别人给你安排好,你去做就行了,换句话来说,你就是个干苦力的,真让你去谈判,去闯市场,大多数人是没这个能力的。


这也是一种损失厌恶心态,因为你怕自己花时间去做,到后面不仅亏了钱,还把自己弄得很累,而安安稳稳打工不一样,它是“稳赚不赔”的。


但是这个世界上很难有稳赚不赔的东西,就说安安稳稳打工拿工资,但是工资不高,那一定是在亏着走的,除非你觉得自己的时间毫无价值,那么就是赚的。


作者:苏格拉的底牌
来源:juejin.cn/post/7340898858556178432
收起阅读 »

实战(简单):20分钟页面不操作,页面失效

web
如果没有时间想直接解决问题,看最下面的最终代码即可 场景需求 总结: 20分钟内如果不操作,页面就是提示失效并且回到列表页面,如果操作了,计时就会清零。 如果 A 在编辑,B 点击编辑会提示正在编辑。A 在编辑期间,每分钟会向后端发送续租(即正在编辑...
继续阅读 »

如果没有时间想直接解决问题,看最下面的最终代码即可



场景需求


image.png

总结:




  1. 20分钟内如果不操作,页面就是提示失效并且回到列表页面,如果操作了,计时就会清零。

  2. 如果 A 在编辑,B 点击编辑会提示正在编辑。A 在编辑期间,每分钟会向后端发送续租(即正在编辑)的请求,后端收到请求后,会在服务端帮你保留这一分钟的编辑状态,别人就无法在编辑了。并且别人编辑时,后端会返回相应的信息。



前言


乐了,产品提出了需求,然后我去找导师问问团队中有没有现成的解决方案。。。没有,然后导师提出了 web worker 的思路,让我自己思考解决方案。好吧,那就开始吧。


一开始,我想着能否能用 setInterval 来进行定时的,结果后端发来消息


image.png

emm......后端大佬,惹不起~


如图上所说,如果切换了页面,setInterval 会停止计时的(咱就说不信的可以试试),也就是说这个线程被停止了。


那么就需要新建一个线程,也就是 web worker 了,用它单纯来进行计时,不用管其他逻辑,切换页面也不会终止。


正文思路


基本demo


首先,百度了下 web worker 的基本实现案例,一文彻底学会使用web worker


需要该需求的页面


// editEmail.vue(主线程)
<template>
<div>编辑页面哦</div>
</template>

<script>
const myWorker = new Worker('/worker.js'); // 创建worker,函数里的值为 webWorker 文件名

// 向 worker.js 线程发送消息,对应 worker.js 线程中的 e.data
myWorker.postMessage('Greeting from Main.js');

myWorker.addEventListener('message', e => { // 从 worker.js 那接收消息
console.log(e.data); // Greeting from Worker.js,worker线程发送的消息
});

</script>


放入 public 文件下 worker.js


// worker.js(worker线程)
self.addEventListener('message', e => { // 接收到消息
console.log(e.data); // Greeting from Main.js,主线程发送的消息
self.postMessage('Greeting from Worker.js'); // 向主线程发送消息
});

其中,worker.js 的存放路径和 new Worker()里的值有关,比如此时我是在本地资源的根路径创建的 /worker.js ,那么就是放在public下的。


而如果是 ./worker.js,或者 ../worker.js,这是无法找到的,因为此时的 worker.js 已经被打包编译成了 app.js。



注意,public 文件的变动需要重启项目,和 vue.config.js一样



image.png
worker.js 和 主线程通信走通后,开始分析需求了。


1. 每分钟续租一次 =》 1秒钟续租一次


什么叫续租,每分钟你向服务端发送一个续租请求,后端就会帮你保持正在编辑的状态(假设为 edit: true),而且后端其实也在计时一分钟。在这一分钟内,由于 edit 为 true,如果别人想要编辑,就会拒绝别人的编辑。如果你一分钟后没发送这个续租请求,后端会把 true 改成 false,这时别人想要编辑,后端就会接受别人的编辑了。


因此,前端就需要每隔一分钟发送一次续租请求,来维持此时的编辑状态。


当然,由于产品要求的更复杂,你发送续租请求的时候请求头往往会携带用户信息,来反馈谁在进行编辑以提高用户体验感。


下述代码为了更好的测试,把每分钟续租变为了每秒续租一次


2. 20分钟期间不操作就会提示页面失效 =》 10秒钟一到就会触发提示事件


当然,就算 setInterval 不能作为解决方案,但还是需要用它来做定时器的,这还是挺香的。


// worker.js(worker线程)
self.addEventListener('message', e => { // 接收到消息
console.log(e.data); // Greeting from Main.js,主线程发送的消息
setInterval(() => {
self.postMessage('Greeting from Worker.js'); // 向主线程发送消息
}, 1 * 1000)
});

如上代码,Greeting from Worker.js 这条消息每隔 1 秒钟就会向 editEmail.vue 页面发送,这时就算你切换浏览器标签页也仍然会发送。


好,简单的定时器做完了,那就开始进行计时了。


// worker.js(worker线程)
self.addEventListener('message', e => { // 接收到消息
console.log(e.data); // Greeting from Main.js,主线程发送的消息

let sum = 0;
let msg;

setInterval(() => {
sum += 1;
msg = {
text: 'editing',
sum
}
self.postMessage(msg); // 向主线程发送消息 msg 对象
}, 1 * 1000)
});

每过一秒,worker.js 都会发送一次信息,用来持续触发续租事件,而 sum 则是用来进行计时过了多少秒。


image.png


// editEmail.vue(主线程)
<template>
<div>编辑页面哦</div>
</template>

<script>
const myWorker = new Worker('/worker.js'); // 创建worker,函数里的值为 webWorker 文件名

// 向 worker.js 线程发送消息,对应 worker.js 线程中的 e.data
myWorker.postMessage('start');

myWorker.addEventListener('message', e => { // 从 worker.js 那接收消息,每隔一秒都会接收到
console.log(e.data); // {text: 'editing', sum: sum},worker线程发送的消息
campaignListLock(); // 发起续租请求
if (e.data.sum >= 10) {
message.error("页面失效");
PageGoBack(); // 返回上一页,看产品要求
}
});

</script>


image.png

OK,这样,基本的需求就完成了,10 秒一到就会提示页面失效,并且在这 10 秒内谁都无法进入编辑页面(在进入编辑页面前得先向后端请求看看是否有人在编辑)。


但是,10 秒后呢,这个计时器仍然在进行中,所以我需要在 10 秒过后清除这个计时器了。也就是在 e.data.sum >= 10 这个条件内对 worker 进程进行通信,触发清除事件。


// editEmail.vue(主线程)
<template>
<div>编辑页面哦</div>
<input @change="onChange" />
</template>

<script>
const myWorker = new Worker('/worker.js'); // 创建worker,函数里的值为 webWorker 文件名

// 向 worker.js 线程发送消息,对应 worker.js 线程中的 e.data
myWorker.postMessage('start');

myWorker.addEventListener('message', e => { // 从 worker.js 那接收消息,每隔一秒都会接收到
console.log(e.data); // {text: 'editing', sum: sum},worker线程发送的消息
campaignListLock(); // 发起续租请求
if (e.data.sum >= 10) {
message.error("页面失效");
PageGoBack(); // 返回上一页,看产品要求
myWorker.postMessage('end');
}
});

</script>


在这里我们分别向 worker 进程发送了 startend 两个信息,worker 进程拿到信息后进行判断,如果是 start,那么就开始每秒续租,如果为 end,那么就清除定时器来终止续租(即停止每秒向主线程进行通信来触发续租请求)。


// worker.js(worker线程)
let timer;
self.addEventListener('message', e => { // 接收到消息
console.log(e.data); // Greeting from Main.js,主线程发送的消息

let sum = 0;
let msg;

if (e.data === "start") {
timer = setInterval(() => {
sum += 1;
msg = {
text: '编辑中',
sum
}
self.postMessage(msg); // 向主线程发送消息 msg 对象
}, 1 * 1000)
} else {
clearInterval(timer);
}
});

如上代码,定义一个全局变量 timer 用来存储定时器,以便能够随时清除。


image.png

定时重置



Stop,别冲太猛,这里我们需要总结一下了



开启定时


myWorker.postMessage('start');

就会重新 worker.js 中的 self.addEventListener('message',()=>{}) 函数,sum 重置为 0,计时重新开始计算。


停止定时


myWorker.postMessage('end');

就会触发 worker 中的 clearInterval(timer) 来清除定时器


重置定时


myWorker.postMessage('end');
myWorker.postMessage('start');

先清除定时器停止定时,然后再重新开启定时


最后


// 开启定时
const onTimeStart = () => {
myWorker.postMessage('start');
}
// 停止定时
const onTimeEnd = () => {
myWorker.postMessage('end');
}
// 重置定时
const onTime = () => {
onTimeEnd();
onTimeStart();
}

3. 10 秒内如果进行了表单操作则重置计时


const onChange = () => {
onTime();
}

优化代码


// editEmail.vue(主线程)
<template>
<div>编辑页面哦</div>
<input @change="onChange" />
</template>

<script>
const myWorker = new Worker('/worker.js'); // 创建worker,函数里的值为 webWorker 文件名

// 向 worker.js 线程发送消息,对应 worker.js 线程中的 e.data
myWorker.postMessage('start');

// 开启定时
const onTimeStart = () => {
myWorker.postMessage('start');
}
// 停止定时
const onTimeEnd = () => {
myWorker.postMessage('end');
}
// 重置定时
const onTime = () => {
onTimeEnd();
onTimeStart();
}

myWorker.addEventListener('message', e => { // 从 worker.js 那接收消息,每隔一秒都会接收到
console.log(e.data); // {text: 'editing', sum: sum},worker线程发送的消息
campaignListLock(); // 发起续租请求
if (e.data.sum >= 10) {
message.error("页面失效");
PageGoBack(); // 返回上一页,看产品要求
onTimeEnd(); // 停止计时,终止续租
}
});

const onChange = () => {
onTime();
}

</script>


// worker.js(worker线程)
let timer;
self.addEventListener('message', e => { // 接收到消息
console.log(e.data); // Greeting from Main.js,主线程发送的消息

let sum = 0;
let msg;

if (e.data === "start") {
timer = setInterval(() => {
sum += 1;
msg = {
text: 'editing',
sum
}
self.postMessage(msg); // 向主线程发送消息 msg 对象
}, 1 * 1000)
} else {
clearInterval(timer);
}
});

而到这里,只是实现了单纯的停留在页面,但切换浏览器标签页时,没有做相应的监听事件。虽然有着另一个 worker 线程在运行着,但当你切换页面后过 10s 再返回原页面,提示虽然会有,但是一闪即逝,基本看不到提示信息。


4. 切换浏览器标签页


而监听浏览器标签页的切换事件是 visibilitychangedocument.visibilityStat 属性


document.addEventListener("visibilitychange", function () {
if (document.visibilityState == "visible") {
message.error("页面已失效");
} else if (document.visibilityState == "hidden") {
message.error("页面已隐藏");
}
});

其中的隐藏我们并不需要用到,而且过了 10s 后如果反复的切换标签,“页面已失效”的提示会反复的弹出,因为我们并没有进行控制。


此时我们也需要区分过了 10s 后用户是停留在当前页面还是离开了页面又返回了。


如果是停留,那么页面属性为 visible。如果是返回,那么就需要监听 visibilitychange 事件并且页面属性为 visible


let timeCount = 0; // 全局中定义变量,用以控制切换标签页后的提示次数。

myWorker.addEventListener("message", (e) => {
if (e.data.notime >= 10) {
onTimingEnd();
if (document.visibilityState === "visible") {
message.error("页面已失效");
pageGoBack();
timeCount = 1; // 触发了提示就禁止它后续再触发
}
document.addEventListener("visibilitychange", function () {
if (document.visibilityState == "visible" && timeCount == 0) {
message.error("页面已失效");
pageGoBack();
timeCount = 1; // 触发了提示就禁止它后续再触发
}
});
}
})

最终代码


替换成20分钟了


// editEmail.vue(主线程)
<template>
<div>编辑页面哦</div>
<input @change="onChange" />
</template>

<script>
const myWorker = new Worker('/worker.js'); // 创建worker,函数里的值为 webWorker 文件名

// 向 worker.js 线程发送消息,对应 worker.js 线程中的 e.data
myWorker.postMessage('start');

// 开启定时
const onTimeStart = () => {
myWorker.postMessage('start');
}
// 停止定时
const onTimeEnd = () => {
myWorker.postMessage('end');
}
// 重置定时
const onTime = () => {
onTimeEnd();
onTimeStart();
}

myWorker.addEventListener('message', e => { // 从 worker.js 那接收消息,每隔一秒都会接收到
console.log(e.data); // {text: 'editing', sum: sum},worker线程发送的消息
campaignListLock(); // 发起续租请求
if (e.data.sum >= 20) { // 超过 20 分钟,终止续租并提示页面失效
onTimingEnd();
if (document.visibilityState === "visible") {
message.error("页面已失效");
pageGoBack();
timeCount = 1; // 触发了提示就禁止它后续再触发
}
document.addEventListener("visibilitychange", function () {
if (document.visibilityState == "visible" && timeCount == 0) {
message.error("页面已失效");
pageGoBack();
timeCount = 1; // 触发了提示就禁止它后续再触发
}
});
}
});

const onChange = () => {
onTime();
}

</script>


// worker.js(worker线程)
let timer;
self.addEventListener('message', e => { // 接收到消息
console.log(e.data); // Greeting from Main.js,主线程发送的消息

let sum = 0;
let msg;

if (e.data === "start") {
timer = setInterval(() => {
sum += 1;
msg = {
text: 'editing',
sum
}
self.postMessage(msg); // 向主线程发送消息 msg 对象
}, 60 * 1000) // 每分钟 sum 加 1 标识积累了 1 分钟
} else {
clearInterval(timer);
}
});

作者:吃腻的奶油
来源:juejin.cn/post/7340636105765535796
收起阅读 »

通过ip查询归属地 要小心了

背景 最近公司做了一些营销活动,投入资金进行了流量推广,pv、UV都做了统计。老板说,我要看下用户的区域分布的数据。 以前的文章我讲过,pv、UV如何统计?我们是基于ip进行统计的。用的ip能获取到,那通过ip查询归属地就ok了。 思维扩展下,ip 查询归属地...
继续阅读 »

背景


最近公司做了一些营销活动,投入资金进行了流量推广,pv、UV都做了统计。老板说,我要看下用户的区域分布的数据。


以前的文章我讲过,pv、UV如何统计?我们是基于ip进行统计的。用的ip能获取到,那通过ip查询归属地就ok了。


思维扩展下,ip 查询归属地的的场景还蛮多的,我列举一些:


场景



  1. 网络安全调查:当发生网络攻击或恶意行为时,通过查询IP地址的归属地可以帮助调查人员追踪攻击者的位置和身份,进而采取相应的应对措施。

  2. 电商网站反欺诈:电商平台可以通过查询IP的归属地来检测是否有异常行为,如异地登录或使用虚假身份信息下单,从而防止欺诈行为发生。

  3. 广告定向投放:在在线广告市场中,根据用户所在地区进行IP归属地查询可以帮助广告主精准定位目标受众,提高广告投放效果和ROI。

  4. 地理位置服务:地图应用、天气预报和周边生活服务等可以利用IP归属地查询来确定用户的大概地理位置,提供个性化的地理服务和信息。

  5. 网站流量分析:网站管理员可以利用IP归属地查询来分析网站访问的地域分布情况,评估市场覆盖范围,制定针对性的营销策略和内容优化计划。


这些具体的使用场景说明了IP归属地查询在网络安全、营销推广、个性化服务等方面的重要作用,能够帮助用户更好地理解用户行为和优化业务流程。


谷歌搜索了下,第三方提供的ip查询归属地服务,挺多的,但是收费、收费、收费!!!免费也有些,但是怕不稳定。


无意间找到了ip2region这个项目,一直持续维护更新,试用后,效果杠杆的。那我们怎么用的,继续往下看


ip2region


Ip2region 是什么


ip2region - 是一个离线IP地址定位库和IP定位数据管理框架,10微秒级别的查询效率,提供了众多主流编程语言的 xdb 数据生成和查询客户端实现。


Ip2region 特性


1、IP 数据管理框架


xdb 支持亿级别的 IP 数据段行数,默认的 region 信息都固定了格式:国家|区域|省份|城市|ISP,缺省的地域信息默认是0。 region 信息支持完全自定义,例如:你可以在 region 中追加特定业务需求的数据,例如:GPS信息/国际统一地域信息编码/邮编等。也就是你完全可以使用 ip2region 来管理你自己的 IP 定位数据。


2、数据去重和压缩


xdb 格式生成程序会自动去重和压缩部分数据,默认的全部 IP 数据,生成的 ip2region.xdb 数据库是 11MiB,随着数据的详细度增加数据库的大小也慢慢增大。


3、极速查询响应


即使是完全基于 xdb 文件的查询,单次查询响应时间在十微秒级别,可通过如下两种方式开启内存加速查询:



  1. vIndex 索引缓存 :使用固定的 512KiB 的内存空间缓存 vector index 数据,减少一次 IO 磁盘操作,保持平均查询效率稳定在10-20微秒之间。

  2. xdb 整个文件缓存:将整个 xdb 文件全部加载到内存,内存占用等同于 xdb 文件大小,无磁盘 IO 操作,保持微秒级别的查询效率。


Ip2region 支持那些语言


Ip2region大部分主流语言都支持,支持的语言如下:



Ip2region怎么用


在这里,我以golang语言作为演示,其他语言,可以看下官方文档


例子:我需要查询ip为:218.63.140.248 的归属地


下载ip2region.xdb包


访问ip2region 项目,ip的库文件在data目录下,点击下载即可



package 获取


go get github.com/lionsoul2014/ip2region/binding/golang

完全基于文件的查询


package main

import (
"fmt"
"github.com/lionsoul2014/ip2region/binding/golang/xdb"
"time"
)

func main() {
//dbPath写入你下载的ip2region.xdb文件的路径,我这里放在了当前目录下
var dbPath = "ip2region.xdb"
searcher, err := xdb.NewWithFileOnly(dbPath)
if err != nil {
fmt.Printf("failed to create searcher: %s\n", err.Error())
return
}
defer searcher.Close()
// 查询218.63.140.248对应的地址
var ip = "218.63.140.248"
var tStart = time.Now()
region, err := searcher.SearchByStr(ip)
if err != nil {
fmt.Printf("failed to SearchIP(%s): %s\n", ip, err)
return
}
fmt.Printf("{region: %s, took: %s}\n", region, time.Since(tStart))
// 备注:并发使用,每个 goroutine 需要创建一个独立的 searcher 对象。
}

查询结果


此ip的归属地为: 中国云南省昆明市电信



缓存整个 xdb 数据


可以预先加载整个 ip2region.xdb 到内存,完全基于内存查询,类似于之前的 memory search 查询。


package main

import (
"fmt"
"github.com/lionsoul2014/ip2region/binding/golang/xdb"
"time"
)

func main() {
//dbPath写入你下载的ip2region.xdb文件的路径,我这里放在了当前目录下
var dbPath = "ip2region.xdb"
// 1、从 dbPath 加载整个 xdb 到内存
cBuff, err := xdb.LoadContentFromFile(dbPath)
if err != nil {
fmt.Printf("failed to load content from `%s`: %s\n", dbPath, err)
return
}

// 2、用全局的 cBuff 创建完全基于内存的查询对象。
searcher, err := xdb.NewWithBuffer(cBuff)
if err != nil {
fmt.Printf("failed to create searcher with vector index: %s\n", err)
return
}
defer searcher.Close()
// 查询218.63.140.248对应的地址
var ip = "218.63.140.248"
var tStart = time.Now()
region, err := searcher.SearchByStr(ip)
if err != nil {
fmt.Printf("failed to SearchIP(%s): %s\n", ip, err)
return
}
fmt.Printf("{region: %s, took: %s}\n", region, time.Since(tStart))
// 备注:并发使用,每个 goroutine 需要创建一个独立的 searcher 对象。
}

查询结果:



方案比对



  • 基于文件的查询,响应时间:38us

  • 基于缓存的查询,响应时间:10.29µs


生成环境使用建议使用方式为:基于缓存的查询


生产如何使用


以上的演示,只是个demo,如果要放在线上如何使用呢?



  1. 以sdk的形式嵌入到项目,使用基于缓存的查询方式。

  2. ip查询的场景很多,可以单独构建一个ip查询的公共服务,提高给各个业务线使用


sdk接入的方式,用到的业务线都需要对接一次,ip2region.xdb如果有更新,所有用到的项目都要自己去更新升级db文件,维护成本太高。如果你的项目比较单一,sdk接入也是不错的


我们的方案:因为我业务线相对太多,如果各个业务线自己接,维护的成本太高。我们决定构建IP查询归属地公共服务,往外提供查询的能力。后续服务的升级、维护等,统一在公共服务里面来做。


作者:柯柏技术笔记
来源:juejin.cn/post/7340950101534982179
收起阅读 »

一个小公司的技术开发心酸事

背景 长话短说,就是在2022年6月的时候加入了一家很小创业公司。老板不太懂技术,也不太懂管理,靠着一腔热血加上对实体运输行业的了解,加上盲目的自信,贸然开始创业,后期经营困难,最终散伙。 自己当时也是不察,贸然加入,后边公司经营困难,连最后几个月的工资都没给...
继续阅读 »

背景


长话短说,就是在2022年6月的时候加入了一家很小创业公司。老板不太懂技术,也不太懂管理,靠着一腔热血加上对实体运输行业的了解,加上盲目的自信,贸然开始创业,后期经营困难,最终散伙。


自己当时也是不察,贸然加入,后边公司经营困难,连最后几个月的工资都没给发。


当时老板的要求就是尽力降低人力成本,尽快的开发出来App(Android+IOS),老板需要尽快的运营起来。


初期的技术选型


当时就自己加上一个刚毕业的纯前端开发以及一个前面招聘的ui,连个人事、测试都没有。


结合公司的需求与自己的技术经验(主要是前端和nodejs的经验),选择使用如下的方案:



  1. 使用uni-app进行App的开发,兼容多端,也可以为以后开发小程序什么的做方案预留,主要考虑到的点是比较快,先要解决有和无的问题;

  2. 使用egg.js + MySQL来开发后端,开发速度会快一点,行业比较小众,不太可能会遇到一些较大的性能问题,暂时看也是够用了的,后期过渡到midway.js也方便;

  3. 使用antd-vue开发运营后台,主要考虑到与uni-app技术栈的统一,节省转换成本;


也就是初期选择使用egg.js + MySQL + uni-app + antd-vue,来开发两个App和一个运营后台,快速解决0到1的问题。


关于App开发技术方案的选择


App的开发方案有很多,比如纯原生、flutter、uniapp、react-native/taro等,这里就当是的情况做一下选择。



  1. IOS与Android纯原生开发方案,需要新招人,两端同时开发,两端分别测试,这个资金及时间成本老板是不能接受的;

  2. flutter,这个要么自己从头开始学习,要么招人,相对于纯原生的方案好一点,但是也不是最好的选择;

  3. react-native/taro与uni-app是比较类似的选择,不过考虑到熟练程度、难易程度以及开发效率,最终还是选择了uni-app。


为什么选择egg.js做后端


很多时候方案的选择并不能只从技术方面考虑,当是只能选择成本最低的,当时的情况是egg.js完全能满足。



  1. 使用一些成熟的后端开发方案,如Java、、php、go之类的应该是比较好的技术方案,但对于老板来说不是好的经济方案;

  2. egg.js开发比较简单、快捷,个人也比较熟悉,对于新成员的学习成本也很低,对于JS有一定水平的也能很快掌握egg.js后端的开发


中间的各种折腾


前期开发还算顺利,在规定的时间内,完成了开发、测试、上线。但是,老板并没有如前面说的,很快运营,很快就盈利,运营的开展非常缓慢。中间还经历了各种折腾的事情。



  1. 老板运营遇到困难,就到处找一些专家(基本跟我们这事情没半毛钱关系的专家),不断的提一些业务和ui上的意见,不断的修改;

  2. 期间新来的产品还要全部推翻原有设计,重新开发;

  3. 还有个兼职的领导非要说要招聘原生开发和Java开发重新进行开发,问为什么,也说不出什么所以然,也是道听途说。


反正就是不断提出要修改产品、设计、和代码。中间经过不断的讨论,摆出自己的意见,好在最终技术方案没修改,前期的工作成果还在。后边加了一些新的需求:系统升级1.1、ui升级2.0、开发小程序版本、开发新的配套系统(小程序版本)以及开发相关的后台、添加即时通信服务、以及各种小的功能开发与升级;


中间老板要加快进度了就让招人,然后又无缘无故的要开人,就让人很无奈。最大的运营问题,始终没什么进展,明显的问题并不在产品这块,但是在这里不断的折腾这群开发,也真是难受。


明明你已经很努力的协调各种事情、站在公司的角度考虑、努力写代码,却仍然无济于事。


后期技术方案的调整



  1. 后期调整了App的打包方案;

  2. 在新的配套系统中,使用midway.js来开发新的业务,这都是基于前面的egg.js的团队掌握程度,为了后续的开发规范,做此升级;

  3. 内网管理公用npm包,开发业务组件库;

  4. 规范代码、规范开发流程;


人员招聘,团队的管理


人员招聘


如下是对于当时的人员招聘的一些感受:



  1. 小公司的人员招聘是相对比较难的,特别是还给不了多少钱的;

  2. 好在我们选择的技术方案,只要对于JS掌握的比较好就可以了,前后端都要开发一点,也方便人员工作调整,避免开发资源的浪费。


团队管理


对于小团队的管理的一些个人理解:



  1. 小公司刚起步,就应该实事求是,以业务为导向;

  2. 小公司最好采取全栈的开发方式,避免任务的不协调,造成开发资源的浪费;

  3. 设置推荐的代码规范,参照大家日常的代码习惯来制定,目标就是让大家的代码相对规范;

  4. 要求按照规范的流程设计与开发、避免一些流程的问题造成管理的混乱和公司的损失;

    1. 如按照常规的业务开发流程,产品评估 => 任务分配 => 技术评估 => 开发 => 测试 => cr => 上线 => 线上问题跟踪处理;



  5. 行之有效可量化的考核规范,如开发任务的截止日期完成、核心流程开发文档的书写、是否有线上bug、严谨手动修改数据库等;

  6. 鼓励分享,相互学习,一段工作经历总要有所提升,有所收获才是有意义的;

  7. 及时沟通反馈、团队成员的个人想法、掌握开发进度、工作难点等;


最后总结及选择创业公司避坑建议!important



  1. 选择创业公司,一定要确认老板是一个靠谱的人,别是一个总是画饼的油腻老司机,或者一个优柔寡断,没有主见的人,这样的情况下,大概率事情是干不成的;

    1. 老板靠谱,即使当前的项目搞不成,也可能未来在别的地方做出一番事情;



  2. 初了上边这个,最核心的就是,怎么样赚钱,现在这种融资环境,如果自己不能赚钱,大概率是活不下去的@自己;

  3. 抓住核心矛盾,解决主要问题,业务永远是最重要的。至于说选择的开发技术、代码规范等等这些都可以往后放;

  4. 对上要及时反馈自己的工作进度,保持好沟通,老板总是站在更高一层考虑问题,肯定会有一些不一样的想法,别总自以为什么什么的;

  5. 每段经历最好都能有所收获,人生的每一步都有意义。

作者:qiuwww
来源:juejin.cn/post/7257085326471512119
收起阅读 »

丈母娘,你这是来真的啊?

今年春节在家里呆的时间比往年都要久一些,就算离家不远也会这样感觉,随着年龄的增加,越发思念故土以及父母。 以往回家都比较晚,待着待着就想着回工作的城市。因为回家总会伴随着和二老的争论,而且我那时也很戾气,合不到一块就气,或者进房间睡觉。 要说啥事能值得争论,我...
继续阅读 »

今年春节在家里呆的时间比往年都要久一些,就算离家不远也会这样感觉,随着年龄的增加,越发思念故土以及父母。


以往回家都比较晚,待着待着就想着回工作的城市。因为回家总会伴随着和二老的争论,而且我那时也很戾气,合不到一块就气,或者进房间睡觉。


要说啥事能值得争论,我的确忘了,大概是思想观念以及家里生意的问题产生的。以至于身处异乡也要离开家里不愿意喋喋不休。


随着时间流逝,心态已经发生变化。我内心深处逐渐向着故土,和家里二老的关系也潜移默化,彼此心照不宣的拉近了很多。


二老也不过多问责一些,也不再以长者的心态和我对质。


事实也觉得普通家的孩子成年后就该放权干他自己的事,父母少些干预,有些做法和观念已经帮不上忙。父母与孩子之间往往都会产生很大分歧,尤其是思想观念的矛盾。


每一代都有可能推翻上一代的思想意识。父母与子女和谐,一般父母的思想观念足够前卫能够传承给子女,或者子女的思想观念没有很大差异,认可父母的思想体系。


我们会一起坐下来交谈家里的事情,想好对策处理外事,规划老家祠堂以及屋子的安排;参与布置家里,代替老爹出席各项习俗礼节活动。


从读大学开始到现在就没再给它们制造任何的麻烦和搞砸事情。只见他坐在店里抽着烟,沉默着望向我,迟迟不再发话,只在最后说了一句婚姻的问题。


这个话题别说他了,老家众多的亲戚都会抛出这个尖锐的问题,我说我回家只想休息,逢年过节和老朋友叙旧,去亲戚家串串门,一起乐呵乐呵聊聊家常,聊聊八卦。


95‘ 后教师彭老师


小学同学彭老师,现在是一名县城重点中学的高中老师。她是一名 90 后老师,我在她身上看到了很多老师的缩影。她毕业后第一份工作就是任高一班主任兼任课老师。


图片


▲图/ 学生:???


彭老师很是焦虑很迷茫,害怕自己不能胜任;害怕自己教不好;害怕自己耽误学生的前途。


每一个担心都很沉重,责任肩负在身上,没日没夜的备课,改作业,演练怎么让学生听懂。


图片


▲图/ 在最无能为力的学科遇到了最不想辜负的老师


小城市义务教育向来重视成绩不太注重心理建设,导致问题学生很多。彭老师经常 solo 学生和家长,试图说服他们把重心放在当前阶段要做的事。


尽管生病,喉咙发炎也坚持做着这些事情。


图片


▲图/ 除夕夜,彭老师回学校,顺道提零食带红包给她


图片


▲图/ 她赠了一罐饮料,握着只觉比当时天气还冷


图片
▲图/ 彭老师收到开工红包请吃饭,还给了红包,呜呜呜感动


有时煎熬的对着同事和朋友说,看着自己的希望学生开始堕落真的很难受,说也说不通;面对自己的学生读着读着因家里或者各种的问题而辍学时更无语。


图片


▲图/ 00 后学生在 95 后老师教案涂鸦


忍不住会直接联系家长 solo,开导家长有时候如同开山凿石一样,只有坚持不懈才使得那位学生继续读下去,尽管读大专,也改变了不少走向。


有一天她苦笑的跟我们说,她自己都没怎么谈过恋爱却还要开导失恋的学生,甚至被失恋学生反问:“老师,你谈过恋爱吗?”


???


彭老师所带的班级在 23 年第一批结业。上本率超额完成上面给的任务,这一路上面临学生堕落、早恋、辍学、逃课、网瘾...


彭老师有时候很想放弃摆烂,像那些老道的老师一样,风清云淡,看开一点,上课就上课,喝茶就喝茶,晒太阳就晒太阳。


后来她骑着小电瓶,向我重申了她那坚定的信念,绝不可能摆烂,我要对我的学生负责,要为他们的前途着想,就这么决定了。


我看着她感觉头上出现了一顶为人师表的光环。


图片


实话说,老师的行为无论是怎样的,都会被学生刻在记忆深处,尽管有时不会联系,也会在某一刻回想起,念其良莠。


图片


▲图/ 念大学的学生探望彭老师


她性格一直都很逗比很乐观,走路也喜欢蹦蹦跳跳,还被她的老师和学生嘲笑她走路蹦蹦跳跳,和她玩王者鲁班的走姿如出一辙。


在她学生毕业之后,彭老师回归万年鲁班,但技术依然停留在四五年前大学生时期,经常遭截杀一路“啊”,开疾跑徒走回水晶,也经常被我们和她的学生给护着。


现在见她时还是很逗比,嘴硬心软。脸上刻印出一副班主任的形象,坚定严肃而又亲和。


她说她带完一届之后不当班主任了,太辛苦太累了。后面她又被安排带复读班班主任。


阿姨,你来真的啊?


老家的天气很不错,逢年过节我们经常互串亲戚的门,晒晒太阳,欺负欺负小朋友,欺负过头了说送给他们一份《三年高考五年模拟卷》礼物,他们哭的更厉害。


图片


▲图/帮人带孩子真好玩


春节假期充电的时间正在倒计时,最后赶着串堂弟的门,也就是大叔家,那时他们家里很忙,家族里的会做饭打点的都来帮忙了。


原来是堂弟未来丈母娘查家环节,家里上上下下忙活。


我等闲杂人在巷子里晒着太阳,准备迎接他们。堂弟我和同岁,月份比我小,在温州工作,这几年他老爹操够了心,费了不少钱。龙年又长了一岁,他父母更是着急。


图片


▲图/ 回家路途中天色很震撼,大家都不想说话


闲聊之际,只见一列车齐刷刷地开到门口,不知道的还以为是迎亲来了。


二叔见状连忙赶上去打招呼并指挥停车泊位。只见那丈母娘下车后整理着装,望着周边的房屋装饰,一脸严肃对着二叔说位置有些偏远,绕来绕去的,二叔连忙解释可能走的道不一样,走国道会很顺。


亲友团齐刷刷招呼张罗着进去喝茶,握手,递烟,倒茶。摆了三桌才能坐下,我们自己人在旁边站着观望或帮忙。两方互相寒暄之后,不到十分钟对方便开始切入主题,商量儿女婚姻问题。


丈母娘吐露堂弟家位置偏僻,路道不好走,绕了很多弯才抵达,彩礼需要增加 3w 到 5w,作为她女儿的嫁衣钱。


阿姨,你这是来真的啊?


大叔一边忙着圆场有不同的道路可以走,镇与镇之间来往有很多路可走也方便,一边递烟倒茶使其思量再三。


丈母娘依然坚定不移,重申了一遍,对方亲戚应声附和。


大叔脸色像是喝酒上了头一样,随后陷入沉思,见其态度坚硬,且事已至此,作出退步可以增加 1w - 2w,给到对方女儿身上。


前些天,大叔带着堂弟相亲到女方家,据说对方开价 35.8w 彩礼,回礼是购买房之后支援 10w+,不知其是否商量后的价格。


只见那天大叔来我家喝茶水时带着儿子和未来儿媳去了县城买了“五金”, 4w 左右。


见此事既成,并不买二叔的帐,坚持需要增加 3w,并声称给女儿做嫁衣。


大叔又陷入了思考,心里计算着账面,上个月女儿刚嫁出去,彩礼还没捂热,就要付之东流,是为不甘且又无奈。


场面陷入了安静,对方只管握着茶杯吃着果子等待结果,势必做好了撤离的打算。


姑舅们遂即递烟倒茶聊家常。


堂弟陪同坐着喝茶望着对象低下了头,儿媳妇安静得陪在身边,挽着堂弟的手。


掂量之后大叔同意了对方的要求,双方态度方能缓和很多,继续喝茶,商量事宜,聊家常,聊孩子幸福。


饭后,互相道了别,每人随了红包礼。只觉得对方结婚习俗没有讲明白有点遗憾,但愿不会阻碍他两组建一个幸福的小家庭。


我和表弟坐在沙滩上,对着河扔石子打水漂,谁都不想再提,心里比谁都清楚。却和群里的伙伴嘲笑着自己家的那位是否也要几十个 w。


作者:程序员小榆
来源:juejin.cn/post/7336822951273824282
收起阅读 »

架构: 自由表单设计界面布局

web
简介 设计表单设置界面是为了给用户提供一个直观、吸引人的操作界面,方便用户对表单进行个性化的设置。就像我们日常生活中玩游戏一样,游戏中的设置界面可以让我们调整游戏音量、画面效果等,以获得更好的游戏体验。 设计表单设置界面可以让用户自定义表单的外观和功能。就像换...
继续阅读 »

简介


设计表单设置界面是为了给用户提供一个直观、吸引人的操作界面,方便用户对表单进行个性化的设置。就像我们日常生活中玩游戏一样,游戏中的设置界面可以让我们调整游戏音量、画面效果等,以获得更好的游戏体验。


设计表单设置界面可以让用户自定义表单的外观和功能。就像换装一样,通过设置界面,用户可以选择不同的颜色、字体、图标等,将表单变得更漂亮、更符合自己的喜好。不仅如此,用户还可以设置表单的输入验证规则、选项布局等,使表单更加智能、便捷,确保数据的准确性和一致性。


此外,设计吸引人的表单设置界面还可以提升用户的参与感和满足感。当用户看到一个漂亮、有趣的设置界面时,不仅能够激发他们的兴趣和好奇心,还能给他们一种参与到表单设计的快乐感。通过提供丰富的个性化设置选项和交互效果,用户可以将自己的想法和创意融入到表单中,让表单更有个性和趣味性。


设计表单设置界面还可以提供简洁明了的操作流程和指导。一个好的设置界面应该具备清晰的布局和明确的操作步骤,用户在使用时能够快速找到需要的设置选项,并且清楚地知道如何进行设置。同时,设置界面还应该提供一些提示和建议,帮助用户更好地理解设置选项的作用和影响,减少用户的困惑和错误操作。


如何设计表单操作界面?


模块划分


为了更好地讲解该部分内容,我们可以直接上手设计一个通用的表单操作界面。如下图所示,可以看到它差不多涵盖了我们常见的大部分组件……XXXX



从图中我们可以看到,主要包括了如下三个大的模块:



  • 表单设计器模块:这是自由表单设计的核心模块,用户通过该模块可以创建、编辑和配置表单的各个元素,如输入字段、选项、验证规则等。表单设计器通常提供简单直观的界面,让用户可以轻松地拖拽和调整表单元素的位置、大小和样式。

  • 表单属性设置模块:这个模块用于设置表单的基本属性,如表单名称、描述、提交按钮文本等。用户可以在该模块中对表单的基本信息进行编辑和修改。

  • 字段属性设置模块:用户可以选择一个字段或元素,然后在该模块中设置该字段的属性,如标签文本、默认值、是否必填、验证规则等。通过字段属性设置模块,用户可以灵活地对表单中的各个字段进行个性化的配置和定制。


当然,如果加上表单预览实际效果,那就是锦上添花。因此,我们还可以加上个“表单预览模块”用于显示用户设计的表单的实时预览效果,使用户可以随时查看表单的外观和布局。在该模块中,用户可以模拟填写表单,测试表单的交互和功能。具体显示效果如下:




当然,除了以上四个模块,还有其他的模块点,具体可以按照我们个人或者公司的需求,进行合理扩展。


步骤


设置低代码自由表单的操作界面,可以按照以下步骤进行。



  1. 确定操作界面的目标和功能:明确你希望操作界面能够实现什么功能,满足哪些需求。

  2. 设计主要操作区域:确定主要的操作区域,通常会包括表单编辑、保存、提交等功能按钮。

  3. 定义表单字段的布局和样式:根据表单的字段分组情况,确定各字段在操作界面的布局方式,如垂直排列、水平排列等。为字段选择合适的输入控件和标签样式,并考虑必要的验证信息。

  4. 设计其他操作元素:除了表单字段,还可以考虑添加其他操作元素,如工具栏、菜单、侧边栏等,用于辅助用户进行操作和导航。

  5. 考虑交互设计:确定用户与操作界面进行交互的方式,包括按钮点击、字段编辑、菜单选项等。确保用户能够直观地理解和使用操作界面。

  6. 设计响应式布局:如果需要在不同设备上使用操作界面,确保界面能够根据不同屏幕尺寸进行自适应布局,保证在各种设备上都能显示良好的用户体验。

  7. 进行布局调整和优化:根据实际效果进行布局调整和优化,确保界面的整体美观和易用性。


实战规划


经过上面大致的设计介绍,我想你应该有了初步的认识,也大概有了个设计思路。那么,接下来我们开始规划和设计实战项目中的表单设计页面的布局了。


布局规划


先来看下这张规划图:



共分四个模块:



  1. 表单元素;

  2. 元素布局;

  3. 属性设置(包括字段属性、布局属性及其他,而这里只针对字段属性来实践);

  4. 预览。



其中预览模块可以另起一个页面,更为直观。用户可以随时查看表单的外观和布局。还可以提供一些交互功能,例如表单提交的模拟按钮,让用户可以体验表单实际的交互效果。


设计的界面,并不需要你有多花里胡哨,相反,界面的简洁性和易用性,直观的操作方式和清晰的视觉指引,才可帮助用户快速熟悉并使用该低代码自由表单工具。同时,界面设计也应该考虑不同设备和屏幕尺寸的适配(这里针对 PC 和 h5 做一个示范),以确保在不同的平台上都能够良好地展示和操作。


流程模版开发规划-扩展


当我们封装了表单设计这一块功能后,就可以进一步与流程结合


如下例子:


假设我们封装了一个低代码自由表单设计的功能,我们可以将表单设计与流程管理结合使用,实现一个请假流程的应用。



  1. 表单设计:用户使用表单设计功能创建一个请假表单,包括请假类型、请假时间、请假事由等字段。可以设置字段的属性,例如是否必填、格式校验等。

  2. 流程设计:用户使用流程管理功能创建一个请假流程,包括审批节点、流程图设计、流程条件等。可以设置不同节点的审批人、流程条件,以及流程的流转路径。

  3. 表单与流程关联:在表单设计界面中,用户可以将请假表单与请假流程进行关联。可以选择使用该表单作为流程的申请表单,并将表单的字段与流程的变量进行映射。

  4. 表单数据与流程集成:当用户填写请假表单并提交后,表单的数据将被保存,并触发请假流程的启动。流程将根据流程设计中的条件和审批人设定,自动进行流转和审批。

  5. 审批和处理:在流程进行中,审批人可以通过流程管理界面进行审批操作。审批人可以查看已提交的请假表单数据,根据情况进行批准或拒绝,并可以填写审批意见等备注信息。

  6. 流程状态和统计:用户可以通过流程管理界面查看每个请假流程的状态、进度和统计信息。可以了解每个流程当前所处的节点,各个节点的审批状态,以及整个流程的总体情况。


通过将表单设计和流程管理功能结合,我们可以实现一个完整的请假申请流程,简化了传统的请假流程管理流程,同时减少了开发的工作量,提高了工作效率


作者:糖墨夕
来源:juejin.cn/post/7302965547087527977
收起阅读 »

在开源项目中看到一个改良版的雪花算法,现在它是你的了。

你好呀,我是歪歪。 在 Seata 的官网上看到一篇叫做“关于新版雪花算法的答疑”的文章。 seata.io/zh-cn/blog/… 看明白之后,我觉得还是有点意思的,结合自己的理解和代码,加上画几张图,给你拆解一下 Seata 里面的“改良版雪花算法...
继续阅读 »

你好呀,我是歪歪。


在 Seata 的官网上看到一篇叫做“关于新版雪花算法的答疑”的文章。



seata.io/zh-cn/blog/…




看明白之后,我觉得还是有点意思的,结合自己的理解和代码,加上画几张图,给你拆解一下 Seata 里面的“改良版雪花算法”。


虽然是在 Seata 里面看到的,但是本篇文章的内容和 Seata 框架没有太多关系,反而和数据库的基础知识有关。


所以,即使你不了解 Seata 框架,也不影响你阅读。


当你理解了这个类的工作原理之后,你完全可以把这个只有 100 多行的类搬运到你的项目里面,然后就变成你的了。


你懂我意思吧。



先说问题


如果你的项目中涉及到需要一个全局唯一的流水号,比如订单号、流水号之类的,又或者在分库分表的情况下,需要一个全局唯一的主键 ID 的时候,就需要一个算法能生成出这样“全局唯一”的数据。


一般来说,我们除了“全局唯一”这个基本属性之外,还会要求生成出来的 ID 具有“递增趋势”,这样的好处是能减少 MySQL 数据页分裂的情况,从而减少数据库的 IO 压力,提升服务的性能。


此外,在当前的市场环境下,不管你是啥服务,张口就是高并发,我们也会要求这个算法必须得是“高性能”的。


雪花算法,就是一个能生产全局唯一的、递增趋势的、高性能的分布式 ID 生成算法。


关于雪花算法的解析,网上相关的文章比雪花还多,我这里就不展开了,这个玩意,应该是“面试八股文”中重点考察模块,分布式领域中的高频考题之一,如果是你的盲区的话,赶紧去了解一下。


比如一个经典的面试题就是:雪花算法最大的缺点是什么?



背过题的小伙伴应该能立马答出来:时钟敏感。


因为在雪花算法中,由于要生成单调递增的 ID,因此它利用了时间的单调递增性,所以是强依赖于系统时间的。


如果系统时间出现了回拨,那么生成的 ID 就可能会重复。


而“时间回拨”这个现象,是有可能出现的,不管是人为的还是非人为的。


当你回答出这个问题之后,面试官一般会问一句:那如果真的出现了这种情况,应该怎么办呢?


很简单,正常来说只要不是不是有人手贱或者出于泄愤的目的进行干扰,系统的时间漂移是一个在毫秒级别的极短的时间。


所以可以在获取 ID 的时候,记录一下当前的时间戳。然后在下一次过来获取的时候,对比一下当前时间戳和上次记录的时间戳,如果发现当前时间戳小于上次记录的时间戳,所以出现了时钟回拨现象,对外抛出异常,本次 ID 获取失败。


理论上当前时间戳会很快的追赶上上次记录的时间戳。


但是,你可能也注意到了,“对外抛出异常,本次 ID 获取失败”,意味着这段时间内你的服务对外是不可使用的。


比如,你的订单号中的某个部分是由这个 ID 组成的,此时由于 ID 生成不了,你的订单号就生成不了,从而导致下单失败。


再比如,在 Seata 里面,如果是使用数据库作为 TC 集群的存储工具,那么这段时间内该 TC 就是处于不可用状态。


你可以简单的理解为:基础组件的错误导致服务不可用。


再看代码


基于前面说的问题,Seata 才提出了“改良版雪花算法”。



seata.io/zh-cn/blog/…




在介绍改良版之前,我们先把 Seata 的源码拉下来,瞅一眼。


在源码中,有一个叫做 IdWorker 的类:



io.seata.common.util.IdWorker



我带你看一下它的提交记录:



2020 年 5 月 4 日第一次提交,从提交时的信息可以看出来,这是把分布式 ID 的生成策略修改为 snowflake,即雪花算法。


同时我们也能在代码中找到前面提到的“对外抛出异常,本次 ID 获取失败”相关代码,即 nextId 方法,它的比较方式就是用当前时间戳和上次获取到的时间戳做对比:



io.seata.common.util.IdWorker#nextId




这个类的最后一次提交是 2020 年 12 月 15 日:



这一次提交对于 IdWorker 这个类进行了大刀阔斧的改进,可以看到变化的部分非常的多:



我们重点关注刚刚提到的 nextId 方法:



整个方法从代码行数上来看都可以直观的看到变化,至少没有看到抛出异常了。


这段代码到底是怎么起作用的呢?


首先,我们得理解 Seata 的改良思路,搞明白思路了,再说代码就好理解一点。


在前面提到的文章中 Seata 也说明了它的核心思路,我带着你一起过一下:



原版的雪花算法 64 位 ID 是分配这样的:



可以看到,时间戳是在最前面的,因为雪花算法利用了时间的单调递增的特性。


所以,如果前面的时间戳一旦出现“回退”的情况,即打破了“时间的单调递增”这个前提条件,也就打破了它的底层设计。


它能怎么办?


它只能给你抛出异常,开始摆烂了。


然后我主要给你解释一下里面的节点 ID 这个玩意。


节点 ID 可以理解为分布式应用中的一个服务,一个服务的节点 ID 是固定的。


可以看到节点 ID 长度为二进制的 10 位,也就是说最多可以服务于 1024 台机器,所以你看 Seata 最开始提交的版本里面,有一个在 1024 里面随机的动作。


因为算法规定了,节点 ID 最多就是 2 的 10 次方,所以这里的 1024 这个值就是这样来的:



包括后面有大佬觉得用这个随机算法一点都不优雅,就把这部分改成了基于 IP 去获取:



看起来有点复杂,但是我们仔细去分析最后一行:



return ((ipAddressByteArray[ipAddressByteArray.length - 2] & 0B11) << Byte.SIZE) + (ipAddressByteArray[ipAddressByteArray.length - 1] & 0xFF);



变量 & 0B11 运算之后的最大值就是 0B11 即 3。


Byte.SIZE = 8。


所以,3 << 8,对应二进制 1100000000,对应十进制 768。


变量 & 0xFF 运算之后的最大值就是 0xFF 即 255。


768+255=1023,取值范围都还是在 [0,1023] 之间。


然后你再看现在最新的算法里面,官方的老哥们认为获取 IP 的方式不够好:



所以又把这个地方从使用 IP 地址改成了获取 Mac 地址。



最后一行是这样的:



return ((mac[4] & 0B11) << 8) | (mac[5] & 0xFF);



还是我刚刚说的 0B11 << 8 和 0xFF。


那么理论上的最大值就是 768 | 255 ,算出来还是 1023。


所以不管你怎么玩出花儿来,这个地方搞出来的数的取值范围就只能是 [0,1023] 之间。


别问,问就是规定里面说了,算法里面只给节点 ID 留了 10 位长度。


最后,就是这个 12 位长度的序列号了:



这个玩意没啥说的,就是一个单纯的、递增的序列号而已。


既然 Seata 号称是改良版,那么具体体现在什么地方呢?


简单到你无法想象:



是的,仅仅是把时间戳和节点 ID 换个位置就搞定了。


然后每个节点的时间戳是在 IdWorker 初始化的时候就设置完成了,具体体现到代码上是这样的:



io.seata.common.util.IdWorker#initTimestampAndSequence




主要看第一行:



long timestamp = getNewestTimestamp();



可以看到在 getNewestTimestamp 方法里面获取了一次当前时间,然后减去了一个 twepoch 变量。


twepoch 是什么玩意?



是 2020-05-03 的时间戳。


至于为什么是这个时间,我想作者应该是在 2020 年 5 月 3 日写下的关于 IdWorker 的第一行代码,所以这个日期是 IdWorker 的生日。


作者原本完全可以按照一般程序员的习惯,写 2020 年 1 月 1 日的,但是说真的,这个日期到底是 2020-01-01 还是 2020-05-03 对于框架来说完全不重要,所以还不如给它赋予一个特殊的日期。


他真的,我哭死...


那么为什么要用当前时间戳减去 twepoch 时间戳呢?


你想,如果仅仅用 41 位来表示时间戳,那么时间戳的最大值就是 2 的 41 次方,转化为十进制是这么多 ms:



然后再转化为时间:



也就是说,在雪花算法里面,41 位时间戳最大可以表示的时间是 2039-09-07 23:47:35。


算起来也没几年了。


但是,当我们减去 2020-05-03 的时间戳之后,计算的起点不一样了,这一下,咔咔的,就能多用好多年。


twepoch 就是这么个用途。


然后,我们回到这一行代码:



前一行,我们把 41 位的时间戳算好了,按照 Seata 的设计,时间戳之后就是 12 位的序列号了呀:



所以这里就是把时间戳左移 12 位,好把序列号的位置给腾出来。


最后,算出来的值,就是当前这个节点的初始值,即 timestampAndSequence。


所以,你看这个 AtomicLong 类型的变量的名字取的,叫做 timestampAndSequence。


timestamp 和 Sequence,一个字段代表了两个含义,多贴切。


Long 类型转化为二进制一共 64 位,前 11 位不使用,中间的 41 位代表时间戳,最后的 12 位代表序列号,一个字段,两个含义。


程序里面使用的时候也是在一起使用,用 Long 来存储,在内存里面也是放在一块的:



优雅,实在优雅。


上一次看到这么优雅的代码,还是线程池里面的 ctl 变量:



现在 timestampWithSequence 已经就位了,那么获取下一 ID 的时候是怎么搞的呢?


看一下 nextId 方法:




io.seata.common.util.IdWorker#nextId





标号为 ① 的地方是基于 timestampWithSequence 进行递增,即 +1 操作。


标号为 ② 的地方是截取低 53 位,也就是 41 位的时间戳和 12 位的序列号。


标号为 ③ 的地方就是把高 11 位替换为前面说过的值在 [0,1023] 之间的 workerId。


好,现在你再仔细的想想,在前面描述的获取 ID 的过程中,是不是只有在初始化的时候获取过一次系统时间,之后和它就再也没有关系了?


所以,Seata 的分布式 ID 生成器,不再依赖于时间。


然后,你再想想另外一个问题:


由于序列号只有 12 位,它的取值范围就是 [0,4095]。


如果我们序列号就是生成到了 4096 导致溢出了,怎么办呢?


很简单,序列号重新归 0,溢出的这一位加到时间戳上,让时间戳 +1。


那你再进一步想想,如果让时间戳 +1 了,那么岂不是会导致一种“超前消费”的情况出现,导致时间戳和系统时间不一致了?


朋友,慌啥啊,不一致就不一致呗,反正我们现在也不依赖于系统时间了。


然后,你想想,如果出现“超前消费”,意味着什么?


意味着在当前这个毫秒下,4096 个序列号不够用了。


4096/ms,约 400w/s。


你啥场景啊,怎么牛偪?


(哦,原来是面试场景啊,那懂了~)


另外,官网还抛出了另外一个问题:这样持续不断的"超前消费"会不会使得生成器内的时间戳大大超前于系统的时间戳,从而在重启时造成 ID 重复?


你想想,理论上确实是有可能的。假设我时间戳都“超前消费”到一个月以后了。


那么在这期间,你服务发生重启时我会重新获取一次系统时间戳,导致出现“时间回溯”的情况。


理论上确实有可能。


但是实际上...


看看官方的回复:



别问,问就是不可能,就算出现了,最先崩的也不是我这个地方。


好,到这里,我终于算是铺垫完成了,前面的东西就算从你脑中穿脑而过了,你啥都记不住的话,你就抓住这个图,就完事了:



现在,你再仔细的看这个图,我问你一个问题:



改良版的算法是单调递增的吗?



在单节点里面,它肯定是单调递增的,但是如果是多个节点呢?


在多个节点的情况下,单独看某个节点的 ID 是单调递增的,但是多个节点下并不是全局单调递增。


因为节点 ID 在时间戳之前,所以节点 ID 大的,生成的 ID 一定大于节点 ID 小的,不管时间上谁先谁后。


这一点我们也可以通过代码验证一下,代码的意思是三个节点,每个节点各自生成 5 个 ID:



从输出来看,一眼望去,生成的 ID 似乎是乱序的,至少在全局的角度下,肯定不是单调递增的:


但是我们把输出按照节点 ID 进行排序,就变成了这样,单节点内严格按照单调递增,没毛病:



而在原版的雪花算法中,时间戳在高位,并且始终以系统时钟为准,每次生成的时候都会严格和系统时间进行对比,确保没有发生时间回溯,这样可以保证早生成的 ID 一定小于晚生成的 ID ,只有当 2 个节点恰好在同一时间戳生成 ID 时,2 个 ID 的大小才由节点 ID 决定。


这样看来,Seata 的改进算法是不是错的?


好,我再说一次,前面的所有的内容都是铺垫,就是为了引出这个问题,现在问题抛出来了,你得读懂并理解这个问题,然后再继续往下看。



分析一波


分析之前,先抛出官方的回答:



我先来一个八股文热身:请问为什么不建议使用 UUID 作为数据库的主键 ID ?


就是为了避免触发 MySQL 的页分裂从而影响服务性能嘛。


比如当前主键索引的情况是这样的:



如果来了一个 433,那么直接追加在当前最后一个记录 432 之后即可。



但是如果我们要插入一个 20 怎么办呢?


那么数据页 10 里面已经放满了数据,所以会触发页分裂,变成这样:



进而导致上层数据页的分裂,最终变成这样的一个东西:



上面的我们可以看出页分裂伴随着数据移动,所以我们应该尽量避免。


理想的情况下,应该是把一页数据塞满之后,再新建另外一个数据页,这样 B+ tree 的最底层的双向链表永远是尾部增长,不会出现上面画图的那种情况:在中间的某个节点发生分裂。


那么 Seata 的改良版的雪花算法在不具备“全局的单调递增性”的情况下,是怎么达到减少数据库的页分裂的目的的呢?


我们还是假设有三个节点,我用 A,B,C 代替,在数值上 A < B < C,采用的是改良版的雪花算法,在初始化的情况下是这样的。



假设此时,A 节点申请了一个流水号,那么基于前面的分析,它一定是排在 A-seq1 之后,B-seq1 之前的。


但是这个时候数据页里面的数据满了,怎么办?


分裂呗:



又来了 A-seq3 怎么办?


问题不大,还放的下:



好,这个时候数据页 7 满了,但是又来了 A-seq4,怎么办?


只有继续分裂了:



看到这个分裂的时候,你有没有嗦出一丝味道,是不是有点意思了?


因为在节点 A 上生成的任何 ID 都一定小于在节点 B 上生成的任何 ID,节点 B 和节点 C 同理。


在这个范围内,所有的 ID 都是单调递增的:



而这样的范围最多有多少个?


是不是有多少个节点,就有多少个?


那么最多有多少个节点?



2 的 10 次方,1024 个节点。


所以官方的文章中有这样的一句话:



新版算法从全局角度来看,ID 是无序的,但对于每一个 workerId,它生成的 ID 都是严格单调递增的,又因为 workerId 是有限的,所以最多可划分出 1024 个子序列,每个子序列都是单调递增的。



经过前面的分析,每个子序列总是单调递增的,所以每个子序列在有限次的分裂之后,最终都会达到稳态。


或者用一个数学上的说法:该算法是收敛的。


再或者,我给你画个图:



我画的时候尽力了,至于你看懂看不懂的,就看天意了。


如果看不懂的话,自信一点,不要怀疑自己,就是我画的不好。大胆的说出来:什么玩意?这画的都是些啥,看求不懂。呸,垃圾作者。



页分裂


前面写的所有内容,你都能在官网上我前面提到的两个文章中找到对应的部分。


但是关于页分裂部分,官方并没有进行详细说明。本来也是这样的,人家只是给你说自己的算法,没有必要延伸的太远。


既然都说到页分裂了,那我来补充一个我在学习的时候看到的一个有意思的地方。


也就是这个链接,这一节的内容就是来源于这个链接中:



mysql.taobao.org/monthly/202…



还是先搞个图:



问,在上面的这个 B+ tree 中,如果我要插入 9,应该怎么办?


因为数据页中已经没有位置了,所以肯定要触发页分裂。


会变成这样:



这种页分裂方式叫做插入点(insert point)分裂。


其实在 InnoDB 中最常用的是另外一种分裂方式,中间点(mid point)分裂。


如果采用中间点(mid point)分裂,上面的图就会变成这样:



即把将原数据页面中的 50% 数据移动到新页面,这种才是普遍的分裂方法。


这种分裂方法使两个数据页的空闲率都是 50%,如果之后的数据在这两个数据页上的插入是随机的话,那么就可以很好地利用空闲空间。


但是,如果后续数据插入不是随机,而是递增的呢?


比如我插入 10 和 11。


插入 10 之后是这样的:



插入 11 的时候又要分页了,采用中间点(mid point)分裂就变成了这样:



你看,如果是在递增的情况下,采用中间点(mid point)分裂,数据页 8 和 20 的空间利用率只有 50%。


因为数据的填充和分裂的永远是右侧页面,左侧页面的利用率只有 50%。


所以,插入点(insert point)分裂是为了优化中间点(mid point)分裂的问题而产生的。


InnoDB 在每个数据页上专门有一个叫做 PAGE_LAST_INSERT 的字段,记录了上次插入位置,用来判断当前插入是是否是递增或者是递减的。


如果是递减的,数据页则会向左分裂,然后移动上一页的部分数据过去。


如果判定为递增插入,就在当前点进行插入点分裂。


比如还是这个图:



上次插入的是记录 8,本次插入 9,判断为递增插入,所以采用插入点分裂,所以才有了上面这个图片。


好,那么问题就来了,请听题:


假设出现了这种情况,阁下又该如何应对?



在上面这个图的情况下,我要插入 10 和 9:


当插入 10 的时候,按 InnoDB 遍历 B+ tree 的方法会定位到记录 8,此时这个页面的 PAGE_LAST_INSERT 还是 8。所以会被判断为递增插入,在插入点分裂:



同理插入 9 也是这样的:



最终导致 9、10、11 都独自占据一个 page,空间利用率极低。


问题的根本原因在于每次都定位到记录 8(end of page),并且都判定为递增模式。


哦豁,你说这怎么办?


答案就藏在这一节开始的时候我提到的链接中:



前面我画的所有的图都是在没有并发的情况下展开的。


但是在这个部分里面,牵扯到了更为复杂的并发操作,同时也侧面解释了为什么 InnoDB 在同一时刻只能有有一个结构调整(SMO)进行。


这里面学问就大了去了,有兴趣的可以去了解一下 InnoDB 在 B+ tree 并发控制上的限制,然后再看看 Polar index 的破局之道。


反正我是学不动了。


哦,对了。前面说了这么多,还只是聊了页分裂的情况。


有分裂,就肯定有合并。


那么什么时候会触发页合并呢?


页合并会对我们前面探讨的 Seata 的改良版雪花算法带来什么影响呢?


别问了,别问了,学不动了,学不动了。



自己看一下吧:



最后,如果本文对你有一点点帮助的话,点个免费的赞,求个关注,不过分吧?



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

web端屏幕截屏,生成自定义海报!

web
在一些社群网站,经常会碰到问题、活动、商品的信息分享,这种分享通常是以海报的形式发送给好友或保存到本地。在这种场景下,海报肯定是动态变化的,所以我们要动态的渲染内容并生成图片,海报其实就是图片。 官网:html2canvas 海报示例: 介绍 了解 htm...
继续阅读 »

在一些社群网站,经常会碰到问题、活动、商品的信息分享,这种分享通常是以海报的形式发送给好友或保存到本地。在这种场景下,海报肯定是动态变化的,所以我们要动态的渲染内容并生成图片,海报其实就是图片。

官网:html2canvas

海报示例:

在这里插入图片描述




介绍


了解 html2canvas,它是如何工作的以及它的一些局限性。

在你开始使用这个脚本以前,这里有些帮助你更好的了解脚本的好处及其的一些局限性。


关于


html2canvas 是一个 HTML 渲染器。该脚本允许你直接在用户浏览器截取页面或部分网页的“屏幕截屏”,屏幕截图是基于 DOM,因此生成的图片并不一定 100% 一致,因为它没有制作实际的屏幕截图,而是根据页面上可用的信息构建屏幕截图。


它是如何工作的


该脚本通过读取 DOM 以及应用于元素的不同样式,将当前页面呈现为 canvas 图像。

它不需要来自服务器的任何渲染,因为整个图像是在客户端上创建的。但是,由于它太依赖于浏览器,因此该库不适合在 nodejs 中使用。它也不会神奇地规避任何浏览器内容策略限制,因此呈现跨域内容将需要代理来将内容提供给相同的源。




开始


准备工作


安装依赖


npm install html2canvas

在需要的页面引入依赖


import html2canvas from 'html2canvas'

然后就可以使用html2canvas相关API了。


定义海报结构


在使用之前我们要先定义好页面,我们先在页面上写好海报的html


class="html2canvas">
<view class="poster_title">
海报标题
view>

<view class="img_box">
<img class="img_case" src="http://image.gwmph.com/weican/2024/02/27/695aa1d4c2394be48925a6858dd68e9d.jpg" alt="" />
view>

<view class="poster_title" @click="getPoster()">
确定分享
view>



	.html2canvas{
padding: 20rpx;
.poster_title{
text-align: center;
}
.img_box{
display: flex;
justify-content: space-around;
margin: 10rpx 0;
.img_case{
width: 300rpx;
height: 300rpx;
}
}
}

image.png


script部分


在这里我们要区分两种script类型,一种正常的,一种是renderjs

在一个页面中script可以有多个,它也可以写在任意位置,如果我们做正常的逻辑操作,可以在普通的script中编码;如果我们要对页面进行交互,请使用renderjs



renderjs是一个运行在视图层的js。它比WXS更加强大。它只支持app-vueweb
renderjs的主要作用有2个:



  1. 大幅降低逻辑层和视图层的通讯损耗,提供高性能视图交互能力

  2. 在视图层操作dom,运行 for web 的 js库





点击确定分享,我们则会调用getPoter来生成图片,canvas.toDataURL生成的图片为base64格式,下面是生成后的内容:

在这里插入图片描述


然后我们通过a标签图片进行下载,下面是生成海报并下载的完整逻辑。




下面就是下载下来的图片

在这里插入图片描述

在这里插入图片描述




注意事项


1、多个script




<script module="html2canvas" lang="renderjs">
import html2canvas from 'html2canvas';
export default {
methods: {
}
}
script>


uniapp中,我们如果想要提供逻辑层和视图层的通讯效率,可能会使用renderjs,你可能会在页面中看到多个script,这是正常的,我们可能会将生成海报的功能封装成组件,通过组件传参的方式在多个页面复用,这种结构页面就可能有两个script,一个是正常的vuescrpit,用于处理正常逻辑以及接收传参和事件等,一个是用于视图层通讯的renderjs


2、html2canvas不要用image标签


我们在生成图片的时候,可能会调整清晰度和分辨率,让画面更高清,html2canvas应该使用img标签,而不是image标签,image标签不会对html2canvasscaledpi生效。


3、html2canvas对于现在的css高级属性的支持


html2canvas可能不会支持css高级属性,例如:

● background-blend-mode

● border-image

● box-decoration-break

● box-shadow

● filter

● font-variant-ligatures

● mix-blend-mode

● object-fit

● repeating-linear-gradient()

● writing-mode

● zoom

● ......

对于渐变文字裁切之类的高阶属性可能不支持,如果海报生成的时候没有生效,那就是不支持,需要思考替代方案。




最后


1、html2canvas是基于html的渲染器,只要定义好海报结构即可生成,可以看成html2canvas就是将页面结构转换成图片。

2、不要使用image标签,应该使用img标签。

3、不支持部分css高阶属性。




作者:DCodes
来源:juejin.cn/post/7340208335982903322
收起阅读 »

Flutter 用什么架构方式才合理?

前言 刚入门 Flutter 编程时,差点被 Flutter 的嵌套地狱吓走,不过当我看到 Flutter 支持 Windows 稳定后,于是下定决心尝试接受 Flutter,因为 Flutter 真的给的太多了:跨平台、静态编译、热加载界面。 Flutter...
继续阅读 »

前言


刚入门 Flutter 编程时,差点被 Flutter 的嵌套地狱吓走,不过当我看到 Flutter 支持 Windows 稳定后,于是下定决心尝试接受 Flutter,因为 Flutter 真的给的太多了:跨平台、静态编译、热加载界面。


Flutter 代码是写到文件夹中的,通过文件夹来管理代码,像是 c++ 语言那样,一个文件,即可以写类,也可以直接写方法😠。


不像 java 那样,全部都是类,整齐划一,通过包名来管理,但也支持类似的“导包”😆。


那么怎样才能像 Java 那样,有个框架优化代码,让项目看起来更整洁好维护呢?


我目前的答案是 MVC 🐷,合适自己的架构才是最好的架构,用这个架构,我感觉找到了家,大家先看看我的代码,然后再做评价。


使用部分


结合GetX, 使用方式如下:


import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:wenznote/commons/mvc/controller.dart';
import 'package:wenznote/commons/mvc/view.dart';

class CustomController extends MvcController {
var count = 0.obs;

void addCount() {
count.value++;
}
}

class CustomView extends MvcView<CustomController> {
const CustomView({super.key, required super.controller});

@override
Widget build(BuildContext context) {
return Center(
child: Column(
children: [
Obx(() => Text("点击次数:${controller.count.value}")),
TextButton(
onPressed: () {
controller.addCount();
},
child: Text("点我"),
),
],
),
);
}
}

简单粗暴,直接在 CustomView 中设计 UI, 在 CustomController 中编写业务逻辑代码,比如登录注册之类的操作。


至于 MVC 中的 Model 去哪里了?你猜猜😘。


代码封装部分


代码封装也很简洁,封装的 controller 代码如下


import 'package:flutter/material.dart';

class MvcController with ChangeNotifier {
late BuildContext context;

@mustCallSuper
void onInitState(BuildContext context) {
this.context = context;
}

@mustCallSuper
void onDidUpdateWidget(BuildContext context, MvcController oldController) {
this.context = context;
}

void onDispose() {}
}

封装的 view 代码如下


import 'package:flutter/material.dart';
import 'controller.dart';

typedef MvcBuilder<T> = Widget Function(T controller);

class MvcView<T extends MvcController> extends StatefulWidget {
final T controller;
final MvcBuilder<T>? builder;

const MvcView({
super.key,
required this.controller,
this.builder,
});

Widget build(BuildContext context) {
return builder?.call(controller) ?? Container();
}

@override
State<MvcView> createState() => _MvcViewState();
}

class _MvcViewState extends State<MvcView> with AutomaticKeepAliveClientMixin{
@override
bool get wantKeepAlive => true;

@override
void initState() {
super.initState();
widget.controller.onInitState(context);
widget.controller.addListener(onChanged);
}

void onChanged() {
if (context.mounted) {
setState(() {});
}
}

@override
Widget build(BuildContext context) {
super.build(context);
widget.controller.context = context;
return widget.build(context);
}

@override
void didUpdateWidget(covariant MvcView<MvcController> oldWidget) {
super.didUpdateWidget(oldWidget);
widget.controller.onDidUpdateWidget(context, oldWidget.controller);
}


@override
void dispose() {
widget.controller.removeListener(onChanged);
widget.controller.onDispose();
super.dispose();
}
}

结语


MVC 可以很简单快速的将业务代码和 UI 代码隔离开,改逻辑的时候就去找 Controller 就行,改 UI 的话就去找 View 就行,和后端开发一样的思路,完成作品就行。


附上的作品文件结构截图,亲喷哈~


04ab4670-d62d-11ee-b1e9-af9546993c52.png


感谢大家的关注与支持,后续继续更新更多 flutter 跨平台开发知识,例如:MVC 架构中的 Controller 应该在哪里创建?Controller 中的 Service 应该在哪里创建?


作品地址:github.com/lyming99/we…


作者:果冻橙橙君
来源:juejin.cn/post/7340472228927914024
收起阅读 »

刚入职因为粗心大意,把事情办砸了,十分后悔

刚入职,就踩大坑,相信有很多朋友有我类似的经历。5年前,我入职一家在线教育公司,新的公司福利非常好,各种零食随便吃,据说还能正点下班,一切都超出我的期望,“可算让我找着神仙公司了”,我的心里一阵窃喜。在熟悉环境之后,我趁着上厕所的时候,顺便去旁边的零食摊挑了点...
继续阅读 »

刚入职,就踩大坑,相信有很多朋友有我类似的经历。

5年前,我入职一家在线教育公司,新的公司福利非常好,各种零食随便吃,据说还能正点下班,一切都超出我的期望,“可算让我找着神仙公司了”,我的心里一阵窃喜。

在熟悉环境之后,我趁着上厕所的时候,顺便去旁边的零食摊挑了点零食。接下来的一天里,我专注地配置开发环境、阅读新人文档,当然我也不忘兼顾手边的零食。

初出茅庐,功败垂成

"好景不长",第三天上午,刚到公司,屁股还没坐热。新组长立刻给我安排了任务。他决定让我将配置端的课程搜索,从使用现有的Lucene搜索切换到ElasticSearch搜索。这个任务并不算复杂,然而我却办砸了。

先说为什么不复杂?

  1. ElasticSearch的搜索功能 基于Lucene工具库实现的,两者在搜索请求构造方式上几乎一致,在客户端使用上差异很小。

image.png

  1. 切换方案无需顾虑太多稳定性问题。由于是配置端课程搜索,并非是用户端搜索,所以平稳切换的压力较小、性能压力也比较小。

总的来说,领导认为这个事情并不紧急,重要性也不算高,而且业务逻辑相对简单,难度能够把握,因此安排我去探索一下。可是,我却犯了两个错误,把入职的第一件事办砸了。现在回过头来看,十分遗憾!

image.png

难以解决的bug让我陷入困境

将搜索方式从Lucene切换为ElasticSearch后,如何评估切换后搜索结果的准确度呢?

除了通过不断地回归测试,还有一个更好的方案。

我的方案是,在调用搜索时同时并发调用Lucene搜索和ElasticSearch搜索。在汇总搜索结果时,比对两者的搜索结果是否完全一致。如果在切换搜索引擎的过程中,两个方案的搜索结果不一致,就打印异常搜索条件和搜索结果,并进行人工排查原因。

image.png

在实际切换过程中,我经常遇到搜索数据不一致的情况,这让我感到十分苦恼。我花了一周的时间编写代码,然后又用了两周多的时间来排查问题,这超出了预估的时间。在这个过程中,我感到非常焦虑和沮丧。作为一个新来的员工,我希望能够表现出色,给领导留下好印象。然而事与愿违,难以解决的bug让我陷入困境。

经过无数次的怀疑和尝试,我终于找到了问题的根源。原来,我忘记了添加排序方式。

因为存在很多课程数据,所以配置端搜索需要分页搜索。在之前的Lucene搜索方式中,我们使用课程Id来进行排序。然而在切换到新的ElasticSearch方案中时,我忘记了添加排序方式。这个错误的后果是,虽然整体上结果是一致的,但由于新方案没有排序方式,每一页的搜索结果是随机的,无法预测,所以与原方案的结果不一致。

image.png 新方案加上课程Id排序方式以后,搜索结果和原方案一致。

为此,我总结了分页查询的设计要点!希望大家不要重复踩坑!# 四选一,如何选择适合你的分页方案?

千万不要粗心大意

实际上,在解决以上分页搜索没有添加排序方式的问题之后,还存在着许多小问题。而这些小问题都反映了我的另一个不足:粗心大意。

正是这些小问题,导致线上环境总会出现个别搜索结果不一致的情况,导致这项工作被拖延很久。

课程模型是在线教育公司非常核心的数据模型,业务逻辑非常复杂,当然字段也非常多。在我入职时,该模型已经有120 个字段,并且有近 50 个字段可以进行检索。

在切换搜索方式时,我需要重新定义各种DO、DTO、Request等类型,还需新增多个类,并重新定义这些字段。在这个过程中,我必须确保不遗漏任何字段,也不能多加字段。当字段数量在20个以内时,这项工作出错的可能性非常低。然而,班课模型却有多达120个字段,因此出错的风险极大。当时我需要大量搬运这些字段,然而我只把这项工作看作是枯燥乏味的任务,未能深刻意识到出错的可能性极大,所以工作起来散漫随意,没有特别仔细校验重构前后代码的准确性。

image.png 墨菲定律:一件事可能出错时就一定会出错

墨菲定律是一种普遍被接受的观念,指出如果某件事情可能出错,那么它将以最不利的方式出错。这个定律起源于美国航天局的项目工程师爱德华·墨菲,在1950年代发现了这一规律。

墨菲定律还强调了人类的倾向,即将事情弄糟或让事情朝着最坏的方向发展。它提醒人们在计划和决策时要考虑可能出错的因素,并准备应对不利的情况。

墨菲定律实在是太准了,当你感觉某个事情可能会出错的时候,那它真的就会出错。而且我犯错不止一次,因为有120个字段,很多字段的命名非常相似,最终我遗漏了2个字段,拼写错误了一个字段,总共有三个字段出了问题。

不巧的是,这三个字段也参与检索。当用户在课程搜索页面选择这三个字段来进行检索时,因为字段的拼写错误和遗漏,这三个字段没有被包含在检索条件中,导致搜索结果出错……

导致这个问题的原因有很多,其中包括字段数量太多,我的工作不够细致,做事粗心大意,而且没有进行充分的测试……

为什么没有测试

小公司的测试人员相对较少,尤其是在面对课程管理后台的技术重构需求时,更加无法获取所需的测试资源!

组长对我说:“ 要人没有,要测试更没有!”

image.png

事情办砸了,十分遗憾

首先,从各个方面来看,切换搜索引擎这件事的复杂度和难度是可控的,而且目标也非常明确。作为入职后第一项任务,我应该准确快速地完成它,以留下一个良好印象。当然,领导也期望我能够做到这一点,然而事实与期望相去甚远。

虽然在线上环境没有出现问题,但在上线后,问题排查的时间却远远超出了预期,让领导对结果不太满意。

总的来说,从这件事中,我获得的最重要教训就是:对于可能出错的事情,要保持警惕。时刻用墨菲定律提醒自己,要仔细关注那些可能发生小概率错误的细节问题。

对于一些具有挑战性的工作,我们通常都非常重视,且在工作中也非常认真谨慎,往往不会出错。

然而,像大量搬运代码、搬运大量字段等这类乏味又枯燥的工作确实容易使人麻痹大意,因此我们必须提高警惕。要么我们远离这些乏味的工作,要么就要认真仔细地对待它们。

否则,如果对这些乏味工作粗心大意,墨菲定律一定会找上你,让你在线上翻车!


作者:他是程序员
来源:juejin.cn/post/7295576148364787751
收起阅读 »

2024年令人眼前一亮的Web框架

web
本文翻译自 dev.to/wasp/web-fr… 感谢您的阅读! 介绍 2024年正向我们走来,我们怀着满腔热情为新的一年制定计划,探索未来一年可以学习或实现的目标。此时此刻,正是探寻来年值得学习的框架、理解其功能和特色的最佳时刻。我们以2023年JS 新...
继续阅读 »

本文翻译自 dev.to/wasp/web-fr…

感谢您的阅读!



介绍


2024年正向我们走来,我们怀着满腔热情为新的一年制定计划,探索未来一年可以学习或实现的目标。此时此刻,正是探寻来年值得学习的框架、理解其功能和特色的最佳时刻。我们以2023年JS 新星名单为指引,力求保持客观公正的态度。对于每一个特色框架,我们都将突出其最大的优势,使您能够全面理解它们的优点,从而选择适合自己的框架进行尝试!


HTMX - 返璞归真🚲


htmx-演示


为谁而设:



  • 你希望减少JavaScript的编写量

  • 你希望代码更简单,以超媒体为中心


HTMX在2023年迅速走红,过去一年间在GitHub上赢得了大量星标。HTMX并非普通的JS框架。如果你使用HTMX,你将大部分时间都花在超媒体的世界中,以与我们通常对现代Web开发的JS密集型视角完全不同的视角看待Web开发。HTMX利用HATEOAS(Hypermedia作为应用程序状态的引擎)的概念,使开发人员能够直接从HTML访问浏览器功能,而不是使用Javascript。


此外,它还证明了通过发布令人惊叹的表情符号并以口碑作为主要营销手段,你可以获得人气和认可。不仅如此,你还可能成为HTMX的CEO!它吸引了许多开发人员尝试这种构建网站的方法,并重新思考他们当前的实践。所有这些都使2024年对于这个库的未来发展充满了激动人心的可能性。


Wasp - 全栈,开箱即用🚀


开放SaaS


为谁而设:



  • 你希望快速构建全栈应用

  • 你希望在一个出色的一体化解决方案中继续使用React和Node.js,而无需手动挑选堆栈的每一部分

  • 你希望获得一个为React和Node.js预配置的免费SaaS模板—— Open SaaS


对于希望简单轻松地全面控制其堆栈的工具的用户,无需再寻找!Wasp是一个有主见的全栈框架,利用其编译器以快速简便的方式为你的应用创建数据库、后端和前端。它使用React、Node.js和Prisma,这些都是全栈Web开发人员正在使用的一些最著名的工具。


Wasp的核心是main.wasp文件,它作为你大部分需求的一站式服务。在其中,你可以定义:



  • 全栈身份验证

  • 数据库架构

  • 异步作业,无需额外的基础设施

  • 简单且灵活的部署

  • 全栈类型安全

  • 发送电子邮件(Sendgrid、MailGun、SMTP服务器等)

  • 等等……


最酷的事情是?经过编译器步骤后,你的Wasp应用程序的输出是一个标准的React + Vite前端、Node.js后端和PostgreSQL数据库。从那里,你可以使用单个命令轻松将一切部署到Fly.io等平台。


尽管有些人可能会认为Wasp的有主见立场是负面的,但它却是Wasp众多全栈功能的驱动力。使用Wasp,单个开发人员或小型团队启动全栈项目变得更加容易,尤其是如果你使用预制的模板或OpenSaaS作为你的SaaS起点。由于项目的核心是定义明确的,因此开始一个项目并可能在几天内创建自己的全栈SaaS变得非常容易!


此外,还有一点很酷的是,大多数Web开发人员对大多数现有技术的预先存在的知识仍然在这里适用,因为Wasp使用的技术已经成熟。


Solid.js - 一流的reactivity库 ↔️


扎实的例子


适合人群:



  • 如果你希望代码具有高响应性

  • 现有的React开发人员,希望尝试一种对他们来说学习曲线较低的高性能工具


Solid.js是一个性能很高的Web框架,与React有一些相似之处。例如,两者都使用JSX,采用基于函数的组件方法,但Solid.js不使用虚拟DOM,而是将你的代码转换为纯JavaScript。然而,Solid.js因其利用信号、备忘录和效果实现细粒度响应性的方法而更加出名。信号是Solid.js中最简单、最知名的基本元素。它们包含值及其获取和设置函数,使框架能够观察并在DOM中的确切位置按需更新更改,这与React重新渲染整个组件的方式不同。


Solid.js不仅使用JSX,还对其进行了增强。它提供了一些很酷的新功能,例如Show组件,它可以启用JSX元素的条件渲染,以及For组件,它使在JSX中更轻松地遍历集合变得更容易。另一个重要的是,它还有一个名为Solid Start的元框架(目前处于测试版),它使用户能够根据自己的喜好,使用基于文件的路由、操作、API路由和中间件等功能,以不同的方式渲染应用程序。


Astro - 静态网站之王👑


天文示例


适合人群:



  • 如果您需要一款优秀的博客、CMS重型网站工具

  • 需要一个能够集成其他库和框架的框架


如果您在2023年构建了一个内容驱动的网站,那么很有可能您选择了Astro作为首选框架来实现这一目标!Astro是另一个使用不同架构概念来脱颖而出的框架。对于Astro来说,这是岛屿架构。在Astro的上下文中,岛屿是页面上的任何交互式UI组件,与静态内容的大海形成鲜明对比。由于这些岛屿彼此独立运行,因此页面可以有任意数量的岛屿,但它们也可以共享状态并相互通信,这非常有用。


关于Astro的另一个有趣的事情是,他们的方法使用户能够使用不同的前端框架,如React、Vue、Solid来构建他们的网站。因此,开发人员可以轻松地在其当前知识的基础上构建网站,并利用可以集成到Astro网站中的现有组件。


Svelte - 简单而有效🎯


精简演示


适合人群:



  • 您希望学习一个简单易上手的框架

  • 追求简洁且代码执行速度快的开发体验


Svelte是另一个尝试通过尽可能直接和初学者友好的方式来简化和加速Web开发的框架。它是一个很容易学习的框架,因为要使一个属性具有响应性,您只需声明它并在HTML模板中使用它。 每当在JavaScript中程序化地更新值时(例如,通过触发onClick事件按钮),它将在UI上反映出来,反之亦然。


Svelte的下一步将是引入runes。runes将是Svelte处理响应性的方式,使处理大型应用程序变得更加容易。类似于Solid.js的信号,符文通过使用类似函数的语句提供了一种直接访问应用程序响应性状态的方式。与Svelte当前的工作方式相比,它们将允许用户精确定义整个脚本中哪些部分是响应性的,从而使组件更加高效。类似于Solid和Solid Start,Svelte也有其自己的框架,称为SvelteKit。SvelteKit为用户提供了一种快速启动其由Vite驱动的Svelte应用程序的方式。它提供了路由器、构建优化、不同的渲染和预渲染方式、图像优化等功能。


Qwik - 非常快🚤


qwik演示


适合人群:



  • 如果您想要一个高性能的Web应用

  • 现有的React开发人员,希望尝试一种高性能且学习曲线平缓的框架


最后一个但同样重要的框架是Qwik。Qwik是另一个利用JSX和函数组件的框架,类似于Solid.js,为基于React的开发人员提供了一个熟悉的环境,以便尽快上手。正如其名字所表达的,Qwik的主要目标是实现您应用程序的最高性能和最快执行速度。


Qwik通过利用可恢复性(resumability)的概念来实现其速度。简而言之,可恢复性基于在服务器上暂停执行并在客户端上恢复执行而无需重新播放和下载全部应用程序逻辑的想法。这是通过延迟JavaScript代码的执行和下载来实现的,除非有必要处理用户交互,这是一件非常棒的事情。它使整体速度提高,并将带宽降低到绝对最小值,从而实现近乎瞬间的加载。


结论


在我们所提及的所有框架和库中,最大的共同点是它们的熟悉度。每个框架和库都试图以构建在当前知识基础上的方式吸引潜在的新开发者,而不是做一些全新的事情,这是一个非常棒的理念。


当然,还有许多我们未在整篇文章中提及但值得一提的库和框架。例如,Angular 除了新的标志和文档外,还包括信号和新的控制流。还有 Remix,它增加了对 Vite、React Server Components 和新的 Remix SPA 模式的支持。最后,我们不能忘记 Next.js,它在过去几年中已成为 React 开发者的默认选择,为新的 React 功能铺平了道路。


作者:腾讯TNTWeb前端团队
来源:juejin.cn/post/7339830464000213027
收起阅读 »

新手入门——快速跑通环信IM iOS Demo,十分钟即可搞定

准备工作1、获取环信Appkey登录环信控制台,获取自己的Appkey2、创建一个新用户用户管理-创建IM用户,填入用户ID和密码3、下载Demo 应用环信IM iOS端Demo下载:https://www.easemob.com/download/demo扫...
继续阅读 »

准备工作

1、获取环信Appkey

登录环信控制台,获取自己的Appkey

2、创建一个新用户

用户管理-创建IM用户,填入用户ID和密码

3、下载Demo 应用

环信IM iOS端Demo下载:https://www.easemob.com/download/demo


扫码完成会跳转 TestFlight (保证已经下载了这个app) 点击接受

打开app会进入手机号验证码登录页面,这个时候需要连续点击版本号(V4.4.0)直到进入开发者页面

点击确定

进入服务器配置,将自己的Appkey填入,其他默认 保存配置并重新启动app

在Demo登录页面输入前面控制台创建用的id和密码,即可登录

4、下载Demo源码

从官网下载Demo源码,下载之后需要 先pod install

打开项目 打开 EMDefines 文件 更改你自己的Appkey 然后运行项目

出现此界面,进入开发者模式 填入控制台创建的id和密码 登录即可

登录之后进 我->开发者服务

可以看见自定义Appkey一栏 显示为替换的Appkey

项目跑通!大功告成!

参考文档:

环信官方Demo下载:https://www.easemob.com/download/demo

IMGeek社区支持:https://www.imgeek.net/

收起阅读 »

自适应iframe高度

web
使用iframe嵌入页面很方便,但必须在父页面指定iframe的高度。如果iframe页面内容的高度超过了指定高度,会出现滚动条,很难看。如何让iframe自适应自身高度,让整个页面看起来像一个整体?在HTML5之前,有很多使用JavaScript的Hack技...
继续阅读 »

使用iframe嵌入页面很方便,但必须在父页面指定iframe的高度。如果iframe页面内容的高度超过了指定高度,会出现滚动条,很难看。

如何让iframe自适应自身高度,让整个页面看起来像一个整体?

在HTML5之前,有很多使用JavaScript的Hack技巧,代码量大,而且很难通用。随着现代浏览器引入了新的ResizeObserver API,解决iframe高度问题就变得简单了。

我们假设父页面是index.html,要嵌入到iframe的子页面是target.html,在父页面中,先向页面添加一个iframe

const iframe1 = document.createElement('iframe');
iframe1.src = 'target.html';
iframe1.onload = autoResize;
document.getElementById('sameDomain').appendChild(iframe1);


iframe载入完成后,触发onload事件,然后自动调用autoResize()函数:

function autoResize(event) {
// 获取iframe元素:
const iframeEle = event.target;
// 创建一个ResizeObserver:
const resizeRo = new ResizeObserver((entries) => {
let entry = entries[0];
let height = entry.contentRect.height;
iframeEle.style.height = height + 'px';
});
// 开始监控iframe的body元素:
resizeRo.observe(iframeEle.contentWindow.document.body);
}


通过创建ResizeObserver,我们就可以在iframebody元素大小更改时获得回调,在回调函数中对iframe设置一个新的高度,就完成了iframe的自适应高度。

跨域问题

ResizeObserver很好地解决了iframe的监控,但是,当我们引入跨域的iframe时,上述代码就失效了,原因是浏览器阻止了跨域获取iframebody元素。

要解决跨域的iframe自适应高度问题,我们需要使用postMessage机制,让iframe页面向父页面主动报告自身高度。

假定父页面仍然是index.html,要嵌入到iframe的子页面是http://xyz/cross.html,在父页面中,先向页面添加一个跨域的iframe

const iframe2 = document.createElement('iframe');
iframe2.src = 'http://xyz/cross.html';
iframe2.onload = autoResize;
document.getElementById('crossDomain').appendChild(iframe2);


cross.html页面中,如何获取自身高度?

我们需要现代浏览器引入的一个新的MutationObserver API,它允许监控任意DOM树的修改。

cross.html页面中,使用以下代码监控body元素的修改(包括子元素):

// 创建MutationObserver:
const domMo = new MutationObserver(() => {
// 获取body的高度:
let currentHeight = body.scrollHeight;
// 向父页面发消息:
parent.postMessage({
type: 'resize',
height: currentHeight
}, '*');
});
// 开始监控body元素的修改:
domMo.observe(body, {
attributes: true,
childList: true,
subtree: true
});


iframe页面的body有变化时,回调函数通过postMessage向父页面发送消息,消息内容是自定义的。在父页面中,我们给window添加一个message事件监听器,即可收取来自iframe页面的消息,然后自动更新iframe高度:

window.addEventListener('message', function (event) {
let eventData = event.data;
if (eventData && eventData.type === 'resize') {
iframeEle.style.height = eventData.height + 'px';
}
}, false);


使用现代浏览器提供的ResizeObserverMutationObserver API,我们就能轻松实现iframe的自适应高度。

点击阅读原文查看演示页面:

作者:廖雪峰
来源:mp.weixin.qq.com/s/8NmYRzPlTTihJVUybkqqOQ

收起阅读 »

vue项目部署自动检测更新

web
前言 当我们重新部署前端项目的时候,如果用户一直停留在页面上并未刷新使用,会存在功能使用差异性的问题,因此,当前端部署项目后,需要提醒用户有去重新加载页面。 在以往解决方案中,不少人会使用websocket去通知客户端更新,但是为了这么个小功能加入websoc...
继续阅读 »

前言


当我们重新部署前端项目的时候,如果用户一直停留在页面上并未刷新使用,会存在功能使用差异性的问题,因此,当前端部署项目后,需要提醒用户有去重新加载页面。


在以往解决方案中,不少人会使用websocket去通知客户端更新,但是为了这么个小功能加入websocket是十分不明智的,新方案的思路是去轮询请求index.html文件,从中解析里面的js文件,由于vue打包后每个js文件都有指纹标识,因此可以对比每次打包后的指纹,分析文件是否存在变动,如果有变动即可提示用户更新


原理


自动更新.png


封装函数 auto-update.js


let lastSrcs; //上一次获取到的script地址
let needTip = true; // 默认开启提示

const scriptReg = /<script.*src=["'](?<src>[^"']+)/gm;

async function extractNewScripts() {
const html = await fetch('/?_timestamp=' + Date.now()).then((resp) => resp.text());
scriptReg.lastIndex = 0;
let result = [];
let match;
while ((match = scriptReg.exec(html))) {
result.push(match.groups.src)
}
return result;
}

async function needUpdate() {
const newScripts = await extractNewScripts();
if (!lastSrcs) {
lastSrcs = newScripts;
return false;
}
let result = false;
if (lastSrcs.length !== newScripts.length) {
result = true;
}
for (let i = 0; i < lastSrcs.length; i++) {
if (lastSrcs[i] !== newScripts[i]) {
result = true;
break
}
}
lastSrcs = newScripts;
return result;
}
const DURATION = 5000;

function autoRefresh() {
setTimeout(async () => {
const willUpdate = await needUpdate();
if (willUpdate) {
const result = confirm("页面有更新,请刷新查看");
if (result) {
location.reload();
}
needTip = false; // 关闭更新提示,防止重复提醒
}
if(needTip){
autoRefresh();
}
}, DURATION)
}
autoRefresh();

引入


在main.js中引入


// 引入自动更新提醒
import "@/utils/auto-update.js"

使用element-ui的notify提示更新


修改auto-update.js



let lastSrcs; //上一次获取到的script地址
let needTip = true; // 默认开启提示

const scriptReg = /<script.*src=["'](?<src>[^"']+)/gm;

async function extractNewScripts() {
const html = await fetch('/?_timestamp=' + Date.now()).then((resp) => resp.text());
scriptReg.lastIndex = 0;
let result = [];
let match;
while ((match = scriptReg.exec(html))) {
result.push(match.groups.src)
}
return result;
}

async function needUpdate() {
const newScripts = await extractNewScripts();
if (!lastSrcs) {
lastSrcs = newScripts;
return false;
}
let result = false;
if (lastSrcs.length !== newScripts.length) {
result = true;
}
for (let i = 0; i < lastSrcs.length; i++) {
if (lastSrcs[i] !== newScripts[i]) {
result = true;
break
}
}
lastSrcs = newScripts;
return result;
}
const DURATION = 5000;

function autoRefresh() {
setTimeout(async () => {
const willUpdate = await needUpdate();
if (willUpdate) {
// 延时更新,防止部署未完成用户就刷新空白
setTimeout(()=>{
window.dispatchEvent(
new CustomEvent("onmessageUpdate", {
detail: {
msg: "页面有更新,是否刷新?",
},
})
);
},30000);
needTip = false; // 关闭更新提示,防止重复提醒
}
if(needTip){
autoRefresh();
}
}, DURATION)
}
autoRefresh();

编写模板


CnNotify.vue文件


<template>
<div class="cn_notify">
<div class="content">
<i class="el-icon-message-solid"></i>
{{ msg }}
</div>
<el-row :gutter="20">
<el-col :span="7" :offset="10">
<el-button type="primary" @click="onSubmit">确定</el-button>
</el-col>
<el-col :span="7">
<el-button @click="cancle">取消</el-button>
</el-col>
</el-row>
</div>
</template>
<script>
export default {
props: {
msg: {
type: String,
default: "",
},
},
data() {
return {};
},
created() {},
methods: {
// 点击确定更新
onSubmit() {
location.reload();
},
// 关闭
cancle() {
this.$parent.close();
},
},
};
</script>
<style lang='less' scoped>
.cn_notify {
.content {
padding: 20px 0;
}
.footer {
display: flex;
flex-direction: row-reverse;
}
}
</style>

使用


App.vue


// 引入
import CnNotify from "@/components/CnNotify.vue";
components: {
CnNotify,
},
mounted() {
this.watchUpdate();
},
methods: {
watchUpdate() {
window.addEventListener("onmessageUpdate", (res) => {
let msg = res.detail.msg;
this.$notify({
title: "提示",
duration: 0,
position: "bottom-right",
dangerouslyUseHTMLString: true,
message: this.$createElement("CnNotify", {
// 使用自定义组件
ref: "CnNotify",
props: {
msg: msg,
},
}),
});
});
},
},

作者:howcode
来源:juejin.cn/post/7246997498572619831
收起阅读 »

半小时到秒级,京东零售定时任务优化怎么做的?

导言:京东零售技术团队通过真实线上案例总结了针对海量数据批处理任务的一些通用优化方法,除了供大家借鉴参考之外,也更希望通过这篇文章呼吁大家在平时开发程序时能够更加注意程序的性能和所消耗的资源,避免在流量突增时给系统带来不必要的压力。 业务背景: 站外广告投放平...
继续阅读 »

导言:京东零售技术团队通过真实线上案例总结了针对海量数据批处理任务的一些通用优化方法,除了供大家借鉴参考之外,也更希望通过这篇文章呼吁大家在平时开发程序时能够更加注意程序的性能和所消耗的资源,避免在流量突增时给系统带来不必要的压力。


业务背景:


站外广告投放平台在做推广管理状态优化重构的时候,引入了四个定时任务。分别是单元时间段更新更新任务,计划时间段更新任务,单元预算撞线恢复任务,计划预算撞线恢复任务。


时间段更新更新任务:


由于单元上可以设置分时段投放,最小粒度是半个小时,每天没半个小时都已可以被广告主设置为可投放或者不可投放,当个广告主修改了,这个时间段,我们可以通过binlog来异步更新这个状态,但是,随着时间的流逝,单元有可能在上半个小时处于可投放状态,来到下半个小时就处于不可投放状态。此时我们的程序是无法感知的,只能通过定时任务,计算每个单元在当前时间段是否需要被更新子状态。计划时间段更新任务类似,也需要半个小时跑一次。


单元预算恢复任务:


当单元的当天日预算被消耗完之后,我们接收到计费的信号后会把该单元的状态更新为预算已用完子状态。但是到第二天凌晨,随着时间的到来,需要把昨天带有预算已用完子状态的单元全部查出来,然后计算当前是否处于撞线状态进行状态更新,此时大部分预算已用完的单元都处于可播放状态,所以这个定时任务只需要一天跑一次,计划类似。


本次以单元和计划的时间段更新为例,因为时间段每半个小时需要跑一次,且数据量多。


数据库:


我们的数据库64分片,一主三从,分片键user_id(用户id)。


定时任务数据源:


我们选取只有站外广告在用的表dsp_show_status作为数据源,这个表总共8500万(85625338)条记录。包含三层物料层级分别是计划,单元,创意通过type字段区分,包含四大媒体(字节,腾讯,百度,快手)和京东播放的物料,可以通过campaignType字段区分。


机器配置和垃圾回收器:


单台机器用的8C16G


-Xms8192m -Xmx8192m -XX:MaxMetaspaceSize=1024m -XX:MetaspaceSize=1024m -XX:MaxDirectMemorySize=1966m -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:ParallelGCThreads=8


定时任务处理逻辑


对于单元,


第一步:先查出来出来dsp_show_status 最大主键区间MaxAutoPk和最小区间MinAutoPk。


第二步:根据Ducc里设置的步长,和条件,去查询dsp_show_status表得出数据。其中条件包含层级单元,腾讯渠道(只有腾讯渠道的单元上有分时段投放),不包含投放已过期的数据(已过期的单元肯定不在投放时间段)


伪代码:


startAutoPk=minAutoPk;
while (startAutoPk <= maxAutoPk) {
//每次循环的开始区间
startAutoPkFinal = startAutoPk;
//每次循环的结束区间
endAutoPkFinal = Math.min(startAutoPk + 步长, maxAutoPk);
List showSatusVoList =
showStatusConsumer.betweenListByParam(
startAutoPkL, endAutoPkL,
条件(type=2单元层级,不包含已过期的数据,腾讯渠道))
startAutoPk = endAutoPkFinal + 1;
}

第三步:遍历第二步查询出来showSatusVoList,得到集合单元ids,然后根据集合ids去批量查询单元扩展表,取出单元扩展表里每个单元对应的start_time,end_time,time_range_price_coef字段。进行子状态计算。


计算逻辑伪代码:


1、当前时间

2、end_time <当前时间 ,子状态为 单元投放已结束


3、start_time<当前时间

4、其他,移除单元未开始投放,单元投放已结束,单元不在投放时间段 三个子状态


然后对这批单元按上面的四种情况进行分组,总共分为四组。如果查询来的dsp_show_status表的子状态和算出来的子状态一样则不加入分组,如果不一样则加入相应分组。


最后对这批单元对应的dsp_show_status表里的记录进行四次批量更新。


计划时间段任务处理逻辑类似,但是查询出来的数据源不包含腾讯渠道的,因为腾讯的渠道的时间段在单元上,计划上没有。


任务执行现象:


(一阶段)任务执行时间长且CPU利用率高


按某个pin调试任务,逻辑上落数据没有问题,但是任务时长在五分钟左右。当时是说产品可以接受这个时间子状态更新延迟。


但当不按pin调试进行计划时间段任务更新时,相对好点,十分钟左右,cpu不到50%。


进行单元时间段任务更新时,机器的cpu是这样的:



cpu80%,且执行了半个小时才执行完成。 如果这样,按业务需求,这个批次执行完成就要继续执行下一次了,肯定是不满需求的。


那怎么缩短CPU利用率,缩短任务执行时间呢?听我慢慢讲解。


(二阶段)分析数据源,调大步长缩短任务运行时间


上面这个情况肯定满足不了业务需求的。


第一感觉优化的方向应该往着数据分布上想,于是去分析dsp_show_status表里的数据,发现表里数据稀疏主要是因为两个点。


(1)程序问题 这个表里不仅存在站外的数据,还因为某些程序问题无意落了站内的数据。我们查询数据的时候卡了计划类型,不会处理站内的数据。但是表里存在会增大主键区间。导致我们每个批次出来的数据比较稀疏。


(2)业务背景 由于百度量小,字节则最近进行了升级,历史物料不多,快手之前完全处于停投。所以去除出腾讯渠道,计划需要处理的数据量比较少18万(182934)。但是腾讯侧一直没有进行升级,而且量大,所以需要处理的单元比较多130万左右(1309692 )。


于是我们为了避免每个批次查出来要处理数据比较少,导致空跑,调大了步长。


再次执行任务


果然有效,计划时间段任务计,cpu虽然上去了,但是任务5分钟就执行完了。


执行执行单元时间段更新的时候,时间缩短到十几分钟,但是cpu却是这样的,顶着100%cpu跑任务。



道路且长,那我们怎么解决这个cpu问题呢,请看下一阶段。


(三阶段)减少临时对象大小和无效日志,避免多次ygc


这个cpu确实令人悲伤。当时我们


第一想法是,为了尽快满足产品需求,先用我们的组件事件总线进行负载(底层是用的mq)到多台机器。这样不但解决了cpu利用率高的问题,还能解决任务执行时间长的问题。这个想法确实能解决问题,但是还是耗用机器资源。


第二想法是,由于时间段在表里是个json存储,在执行查询的时候不好进行条件查询。于是想着单独在建一张表,拉平时间段,在进行查询的时候直接查新建的表,不再查询存储json时间段的表。但是这张表相当于异构了数据源,不但要新建表还要考虑这张表的维护。


于是我们继续分析cpu高用在哪里,理论上这个定时任务是IO型任务,cpu利用率应该比较低。在执行任务的时候,我们仔细观察了机器的监控,发现在执行单元时段更新任务时,机器每分钟不断地进行多次ygc。之前刚和组内同学分享过gc相关知识。这里说一下,虽然我们的机器用的是G1垃圾回收器,没有进行full gc,但是G1在ygc的时候会比jdk1.8默认的垃圾回收器要更耗资源,因为G1还要mixgc兼顾回收老年代的垃圾。G1用于响应优先,默认的垃圾回收器吞吐量优先。这样的批量任务其实更适合用默认垃圾回收器。


不断进行ygc肯定是因为我们在执行任务的时候产生大量的临时对象导致的。


这里我们采取了两条有效措施:


(1)去掉无效日志 由于调试时加了大量日志,java进行序列化的时候会产生比原来的对象占用更多内存的临时变量。于是我们去掉了所有的无效日志。


(2)减少临时对象占用的内存 代码对象的个数肯定不能减少,于是我们我们减少对象的的大小。之前是我们用的proxy工程现成接口,把表里的每个字段都查出来了,但是表里那么多字段,实际我们每张表也就用2-3个字段。于是我们为这个定时任务写了专用的查询接口,每个接口只查我们需要的字段。


结果果然有效,单元时间段更新任务从原来的顶着100%cpu跑了十几分钟,瞬间降到了cpu不到60%,五分钟执行完成。ycg次数也有明显的下降。


刷数任务: 这两个措施到底多有效呢,说另一个栗子也与这个需求相关。在没有减少临时变量大小(把单元表和单元扩展表中的所有字段都查出来)把单元表的启停状态和单元扩展表的审核状态刷到dsp_show_status时,涉及1400百万数据,刷了两个小时也没刷完,最后怕影响物料传输工程查询数据库给停了。之后减少临时变量后,九分钟就刷完了。


经过上述的优化看似皆大欢喜,但还存在很大的问题。给大家看一个监控图。



看完这个监控图,我们慌了,计划和单元更新时间段任务每半个小时运行一次,都给数据库带来了200万qpm的增长,这无疑给我们的数据库带来了巨大隐患。


此时总结下来存在两个问题有待解决。


(1)怎么减少与数据库的交互次数 ,消除给数据库带来的安全隐患。


(2)怎么降低任务的执行的时间, 五分钟的子状态更新延迟是不可以接受的。对广告主来说更是严重的bug。


这两个问题让我们觉得这个任务还有很大的优化空间,于是我们继续分析优化。下一阶段的措施很好的解决了这两个问题。


(四阶段)基于游标查询数据源,基于数据库分片批量更新,降低数据库交互次数,避免空跑缩短任务运行时间。


对于上面的问题,我们分析这么大的调用量主要用在了哪里。


发现由于站内数据的存在和历史数据的删除以及dsp_show_status和其他表公用一个主键id生成序列,导致dsp_show_status表的MaxAutoPk到达90多亿。


也就是所及时我们步长达到2万,光查询数据调用次数就达到了45万次,在加上每次都有可能产生小于四次的更新操作。也就是一个定时任务都会产生高大100万的qpm,两个任务产生200万也就符合预期了。于是我们把步长调整为4万,qpm降到了130万左右,但还是很高。



于是我们继续分析,就单元时间段更新任务而言,其实我们需要查出来的数据也就是上面提到的腾讯的130万左右(1309692 )。但是我们查询了45万次且步长是2万。也就是说我们每次查出来的数据还是很稀疏且个数不确定,如果忙盲目的调大步长,很可能由于某个区间数据量特别多导致负载不均衡,还有可能rpc超时。


那怎么才能做到每次查出来数据个数就是我们的设置的步长呢,我们想到了mysql里面的游标查询。但是jed弹性数据库并不支持,于是我们就要手动实现游标的逻辑。此时我们考虑dsp_show_status是否有唯一主键能标识唯一记录。假如主键不唯一,就有可能出现漏查和重复查询的情况。幸运的是我们的jed数据库所有的表里都有唯一主键。于是我们手写了一个游标查询。


(1)游标查询


伪代码如下


//上层业务代码
Long maxId = null;
do {
showStatuses = showStatusConsumer.betweenListByParam(
startAutoPkL, endAutoPkL, maxId,每次批次要查出来的数据,
其他条件(type=2单元层级,不包含已过期的数据,腾讯渠道)
)

if (CollectionsJ.isEmpty(showStatuses)) {
//如果为空的,直接推出,代表已经查到最后了。
break;
}
//循环变量值叠加,查出来的数据最后一行的id,数据库进行了升序,也就是这批记录的最大id
maxId = showStatuses.get(showStatuses.size() - 1).getId();

//处理查出来的数据
processShowStatuses( showStatuses);

} while (CollectionsJ.isNotEmpty(showStatuses));


//下层sql

SELECT
id,cga_id,status_bitmap1,user_id
FROM dsp_show_status
<where>
id BETWEEN #{startAutoPk,jdbcType=BIGINT} AND #{endAutoPk,jdbcType=BIGINT}
//param.maxId 上一批次查出数据的最大maxId
<if test="param.maxId != null">
AND id >#{param.maxId,jdbcType=BIGINT}

<----!其他条件------>

order by id
<if test="param.batchSize != null">
//上层传过来的每个批次要查询的出来的数据量
limit #{param.batchSize}



这里可以思考一下基于游标的查询方式在什么场景下有效? 如果有效需要满足一下两个条件


1.jed表里有唯一键,且基于唯一键查询排序


2.区间满足查询条件的记录越稀疏越有效


这里要一定注意排序的顺序,是升序不是降序。如果你无意间按降序排序,那么每次查询的都是最后的满足条件的batch大小的数据。


(2)深度分页引起慢sql


此时组内同学提出了一个疑问,深度分页引起慢sql问题。这里解释一下到底会不会产生慢sql。


当进行分页的时候一般sql会这样写


select *
from dsp_show_status
where 其他查询条件
limit 50000000 , 10;

当limit 的初始位置非常靠后时,即使压中查询条件里的二级索引,也需从二级索引得到的主键索引去加载所有的磁盘记录,然后扫描50000000行记录取50000000到-50000010条返回,这里涉及到记录的扫描,和多次磁盘到内存的IO,所以比较耗时。


但是我们的sql


select *
from dsp_show_status
where 其他查询条件
and id >maxId
oder by id
limit 100

当maxId非常大时,比如50000000 时,mysql压中查询条件的里的二级索引,得到主键索引。然后MySQL会直接过滤掉 id<50000000 的主键id,然后从主键50000000开始查询数据库得到满足条件的100条记录。所以他会非常快,并不是产生慢sql。实际sql执行只需要37毫秒。



(3) 按数据库分片进行批量更新


但是又遇到了另一个数据库长事务问题,由于使用了基于游标的方式,查出来的数据都是需要进行计算的数据,且任务运行时间缩短到到30秒。那在进行数据更新时,每次批量更新都比之前(不使用游标的方式)更新的数据量要多,且并发度高。其次由于批量更新的时候更新多个单元id,这些id不一定属于某一个user_id,所以在执行更新的时候没有带分片键,此时数据库jed网关又出现了问题。


当时业务日志的报错的信息是这样的,出现了执行时间超过了30秒的sql,被kill掉:


{"error":true,"exception":{"@type":"org.springframework.jdbc.UncategorizedSQLException","cause":{"@type":"com.mysql.cj.jdbc.exceptions.MySQLQueryInterruptedException","errorCode":1317,"localizedMessage":"transaction rolled back to reverse changes of partial DML execution: target: dsp_ads.c4-c8.primary: vttablet: (errno 2013) due to context deadline exceeded, elapsed time: 30.000434219s, killing query ID 3511786 (CallerID: )","message":"transaction rolled back to reverse changes of partial DML execution: target: dsp_ads.c4-c8.primary: vttablet: (errno 2013) due to context deadline exceeded, elapsed time: 30.000434219s, killing query ID 3511786 (CallerID: )","sQLState":"70100","stackTrace":[{"className":"com.mysql.cj.jdbc.exceptions.SQLError","fileName":"SQLError.java","lineNumber":126,"methodName":"createSQLException","nativeMethod":false},{"className":"com.mysql.cj.jdbc.exceptions.SQLError","fileName":"SQLError.java","lineNumber":97,"methodName":"createSQLException","nativeMethod":false},


数据库的监控也发现了异常,任务执行的时候出现了大量的MySQL rollbakc:



当时联系dba suport ,dba排查后告诉我们,我们的批量更新sql在数据库执行非常快,但是我们用了长事务超过30秒没有提交,所以被kill掉了。但是我们检查了我们的代码,发现并没有使用事务,且我们的事务是单库跨rpc事务,从发起事务到提交事务对于数据库来说执行时间非常快,并不会出现长事务。我们百思不得其解,经过思考我们觉得可能是jed网关出现了问题,jed网关的同学给的答复是。由于没有带分片键导致jed网关会把sql分发到64分片,如果某个分片上没有符合条件的记录,就会产生间隙锁,其他sql更新的时候一直锁更待从而导致事务一直没有提交出现长事务。


对于网关同学给我们的答复,我们仍然持有怀疑态度。本来我们想改下数据库的隔离级别验证一下这个回复,但是jed并不支持数据库隔离级别的更改。


但是无论如何我们知道了是因为我们批量更新时不带分片键导致的,但是如果按userId进行更新,将会导致原来只需要一次进行更新,现在需要多次更新。于是我们想到循环64分片数据库进行批量更新。但是jed并不支持执行sql时指定分片, 于是我们给他们提了需求。


后来我们想到了折中的方式,我们按数据库分片对要执行的单元id进行分组,保证每个分组对应的单元id落到数据库的一个分片上,并且执行更新的时候加上userId集合。这个方案要求jed网关在执行带有多个分片键sql时能进行路由。这边jed的同事验证了一下是可以的。



于是我们在进行更新的时候对这些ids按数据库分片进行了分组。


伪代码如下:


//按数据库分片进行分组
adgroups.stream().collect(Collectors.groupingBy(Adgroup::shardKey));
// 按计算每个userId对象的数据库分片,BinaryHashUtil是jed网关的jar包
public String shardKey() {
try {
return BinaryHashUtil.getShardByVindex(ShardEnum.SIXTY_FOUR_SHARDS, this.userId);
} catch (SQLException ex) {

throw new ApplicationException(ex);
}
}

在上述的刷数任务中能够执行那么快,并且更新数据没有报错,一方面也得益于这个按数据库分片进行分组更新数据


(4)优化效果


经过基于游标查询的方式进行任务优化,就单元时间段更新时。从原来的五分钟,瞬间降为30秒完成。cpu不到65% 。由于计划记录更稀疏,所以更快。



对数据库的查询更新操作,也从原来的也从原来的200万qpm降为2万多(早上高峰的时候),低峰的时候甚至不到两万。当我们把batchSize设置为100时,通过计算单元的130多万/100 +计划的18万/100=1.4万次qpm 也是符合预期的。


查询db监控:



更新db的监控,也符合预期



虽然引入基于游标的方式进行查询非常有效,把原来的200万qpm数据库交互降到了2万,把任务运行时间从5分钟降到了30秒。但是仔细分析你还会发现,还存在如下问题。


1、单台机器cpu高, 仍然在60%,对于健康的程序来说,这个数值仍然不被接受。


2、查询和更新数据量严重不符, 每次定时任务更新只更新了上万行记录,但是我们却查出来了上百万(130万)行记录进行子状态,这无疑还在浪费CPU和磁盘IO资源。


监控如下


每次查询出来的记录数:



每次需要更新的记录数:



经过上面的不断优化,我们更加相信,资源不能被浪费,作为程序员应该追求极致。于是我们还继续优化。解决上面两个问题


(五阶段)异构要更新状态的数据源,降低数据库交互次数,降低查询出来的数据量,降低机器cpu利用率。


为了减少无效数据查询和计算,我们还是决定冗余数据,但是不是像前面提到的新建一张表,而是在dsp_show_status 表里冗余一个nextTime字段,来存储这个物料下一次需要被定时任务拉起更改状态的时间戳,(也就是物料在投放时间段子状态和不在投放时间段子状态转变的时间戳),举个栗子,广告主设置某个单元早上8点开始投放,晚上8点结束投放,其他时间不投放。那早8点的时候,这个单元就会被我们的定时任务扫描到,然后计算更新这个单元从不投放变为投放,同时计算比较投放时间段,下一个状态变更的时间段,经过计算得知,广告主在晚上8点需要状态变更,也就是从投放变为不投放,那nextTime字段就落晚上8点的时间戳。这个字段的维护逻辑分为两部分,一部分是广告主主动更改了时间段需要更新计算这个nextTime,另一部分是定时任务拉起这个物料更改完子状态后,再次计算下一次需要被拉起的nextTime。


这样我们定时任务在查询数据源的时候只需新增一个查询条件(因为是存的是时间戳,所以需要卡个范围)就可以查出我们需要真正要更新的数据了。


当维护投放时间段这个异构数据,就要考虑异构数据和源数据的一致性问题。假如某次定时任务执行失败了,就会导致nextTime 和投放时间段数据不一致,此时我们的解决办法时,关闭基于nextTime的优化查询,进行上一阶段(第四阶段)基于游标的全量更新。


sql查询增加条件:
next_time_change between ADDTIME(#{param.nextTimeChange}, '-2:0:0')
and ADDTIME(#{param.nextTimeChange}, '0:30:0')

优化之后我们每次查询出来的记录从130万降到了1万左右。


11点的时候计划和单元总共查出来6000个,监控如下:



11点的时候计划和单元总共更新5000个,由于查询数据源的时候卡了时间戳范围,所以符合预期,查出来的个数基本就是要更新的记录。监控如下:



查询次数也从原来的1万次降到了200次。监控如下:



机器的监控如下cpu只用了28%,且只ygc了1次,任务执行时间30秒内完成。



这个增加next_time 这个字段进行查询的思路,和之前做监控审核中的创意定时任务类似。创意表20亿行数据,怎么从20亿行记录表里实时找出哪些创意正在审核中。当时的想法也是维护一个异构的redis数据源,送审的时候把数据写入redis,审核消息过来后再移除。但是当我们分析数据源的时候,幸运的发现审核中的创意在20亿数据中只占几万,大部分创意都是在审核通过和审核驳回,之前大家都了解到建立索引要考虑索引的区分度,但是在这种数据分布严重不均匀的场景,我们建立yn_status联合索引,在取数据源的时候,直接压数据库索引取出数据,sql执行的非常快,20毫秒左右就能执行完成,避免走了很多弯路。


你以为优化结束了? 不,合格的程序员怎么允许系统中存在cpu不稳定的场景存在,即使只增加28%


(六阶段)负载均衡,消除所有风险,让系统程序稳定运行。


消除单台机器cpu不稳定的最有效办法就是,把大任务拆分为小任务,然后分发到不同的机器上进行执行。我们的定时任务本来就是按批次进行查询计算的,所以本身就是小任务。剩下的就是分发任务,很多人想到的就是利用mq的负载进行分发,但是mq不可控,不可控制失败重试时间。如果一个小任务失败了,下次什么时候被拉起重试就不得而知了,或许半个小时以后?这里用到了我们非常牛逼的一个组件,可重试总线进行负载,支持自定义重试频率,支持自动识别无效重试,防止重试叠加。


负载后的机器cpu是这样的



优化效果数据汇总:


这里列一下任务从写出来到被优化后的数据对比。


优化前,cpu增加80%,任务运行半个小时,查询数据库次数百万次,查询出来130万行记录。


优化后,cpu增加1%,任务30秒以内,查询数据库200次,查询出来1万行记录。


写到最后:


通过本次优化让我收获许多,最大的收获是让我深刻明白了,对于编码人员,要时刻考虑资源的消耗。举个不太恰当的栗子,假如每个人在工程里都顺手打印一行无效日志,随着时间的积累整个工程都会到处打印在无效日志。毫不夸张的讲,或许只是因为你多打印了一行log.info日志,在请求量猛增达到一定程度时都会导致机器和应用的不良连锁反应。建议大家在开发的时候在关键点加上关键日志,并且合理利用Debugger,结合ducc进行动态日志调整排查问题。


作者:京东零售广告研发 董舒展
来源:juejin.cn/post/7339742783236702271
收起阅读 »

前端接口多参数请求时如何优雅封装

web
接口多参数优雅封装 开发中经常会遇到有一个接口需要的query参数比较多, 参数的数据类型也不全是 string | number ,还存在数组或者其他类型的情况。 我以前的做法 因为是query参数,我以前的写法就是在接口地址上面进行字符串拼接。 asyn...
继续阅读 »

接口多参数优雅封装


开发中经常会遇到有一个接口需要的query参数比较多,
参数的数据类型也不全是 string | number ,还存在数组或者其他类型的情况。


我以前的做法


因为是query参数,我以前的写法就是在接口地址上面进行字符串拼接。


 async function getJobList(
clusterId: string,
page: number,
pageSize: number,
JobName : string,
JobStatus : string,
Username : string,
Queue : string,
StartTime: string,
EndTime: string
) {
if (JobStatus == undefined) JobStatus = '';
const res = await apiRequest.get(
`/api/Cluster/${clusterId}/Job?page=${page}&pageSize=${pageSize}&JobName=${JobName}&JobStatus=${jobStatus}&Username=${Username}&Queue=${Queue}&StartTime=${
StartTime ? StartTime : ''}
&EndTime=${EndTime ? EndTime : ''}`

);
return res;
}

以前觉得这样做没什么问题,就是写起来很不优雅,代码的可读性也特别差。


那能不能不安代码的格式化自己整理一下表呢?为方阅读这样去写


 const res = await apiRequest.get(
`/api/Cluster/${clusterId}/Job?${page ? 'page=' + page : ''}
${pageSize ? '&pageSize=' + pageSize : ''}
${JobName ? '&JobName=' + JobName : ''}
${JobStatus ? JobStatus?.map((x) => '&JobStatus=' + x).join('') : ''}
${Username ? '&Username=' + Username : ''}
${Queue ? '&Queue=' + Queue : ''}
${StartTime ? '&StartTime=' + StartTime : '' }
${EndTime ? '&EndTime=' + EndTime : '' }`

);

这种写法看起来更易读了,但是 ` 中的换行和空格会保留在里面,参数就会莫名的加上几个空格,查询参数就不对了(好心干坏事了)。


再说,如果参数更多呢,那岂不是要再去一个一个拼接,时间成本也太高了,有没有更优雅的写法呢?


解决办法


在MDN上看到了这个URLSearchParams接口


URLSearchParams 接口定义了一些实用的方法来处理 URL 的查询字符串。


通过URLSearchParams.append(name, value)方法可以不断往里面添加参数


下面是更改后的代码


 async function getJobList(
clusterId: string,
page: number,
pageSize: number,
JobName?: string,
JobStatus?: Array<string>,
Username?: string,
Queue?: string,
StartTime?: string,
EndTime?: string
) {
if (JobStatus?.length == 0) JobStatus = null;
const add_params = {
page: page,
pageSize: pageSize,
JobName: JobName,
JobStatus: JobStatus,
Username: Username,
Queue: Queue,
StartTime: StartTime,
EndTime: EndTime,
};
const searchParams = new URLSearchParams();
Object.keys(add_params).forEach((key) => {
if ( add_params[key] !== undefined && add_params[key] !== null ) {
const value = add_params[key];
if (Array.isArray(value)) {
value.forEach((item) => searchParams.append(key, item));
} else {
searchParams.append(key, value);
}
}
});
const res = await apiRequest.get(
`/api/Cluster/${clusterId}/Job?${searchParams.toString()}`
);
return res;
}


这样去写代码就整洁优雅了太多了


作者:张星宇
来源:juejin.cn/post/7338360121624182820
收起阅读 »

买房后,害怕失业,更不敢裸辞,心情不好就提前还房贷,缓解焦虑

自从买房后,心态有很大变化。虽然住自己的房子,心情和体验都很好,但是一把掏空钱包,很焦虑。买房后现金流一直吃紧,再加上每年16万的房贷,我很焦虑会失业。之前我喜欢裸辞,现在不敢想裸辞这个话题。尤其是在行业下行期,找工作很艰难,背着房贷裸辞,简直是头孢就酒,嫌命...
继续阅读 »

自从买房后,心态有很大变化。虽然住自己的房子,心情和体验都很好,但是一把掏空钱包,很焦虑。买房后现金流一直吃紧,再加上每年16万的房贷,我很焦虑会失业。之前我喜欢裸辞,现在不敢想裸辞这个话题。尤其是在行业下行期,找工作很艰难,背着房贷裸辞,简直是头孢就酒,嫌命太久。


焦虑的根源是背负房贷,金额巨大,而且担心40岁以后失业,还不上房贷。


一次偶然的沟通


"你的贷款利率调整了吗",同事问我。


同事比我早两年在北京买房,他在顺义买的,我在昌平买的,我俩一直有沟通房贷的问题。但我没听说利率有调整,银行好像也没通知我,于是我问道:”我不知道啊,你调整了?调到多少了?“。


”调的挺多的,已经降到了 4.3%“。同事兴高采烈的回复我。


”这么牛逼,之前我记得一直是4.85%,我去看看我的利率“,我听说房贷利率下降那么多,很是兴奋。


然而我的房贷利率没有调整,我尝试给银行打电话,沟通的过程很坎坷。工商银行客服说了很多,大概意思是:利率会自动调整,无需申请,但是要等到利率调整日才会调整”。我开始很不理解,很生气,利率都调整了,别人也都调整了,凭什么不给我调整呢?


我想到同事有尝试提前还贷,生气的时候,我就萌发了提前还贷的想法。


开始尝试提前还贷,真香


我在22年初贷款买房,其中商业贷款 174 万,贷款25年,等额本息,每个月要还 1 万的房贷。公积金贷款每个月大概需要还 2500。每个月一万二的房贷还是很有压力的,尤其是刚买房的这一两年,兜里比脸都干净,没存款,不敢失业,更不敢裸辞。


即便兜里存款不多,也要提前还贷,因为实在太香了。


我在工行App上,申请 提前还贷,选择缩短 18个月的房贷,只需要 6万2,而我每个月房贷才1万,相当于是用 6 万 顶 18 万的房贷。还有比这更划算的事情吗?


image.png
预约提前还款后,银行会安排一个时间,在这个时间前,把钱存进去。到时候银行就会扣除,如果扣除金额不足,那么提前还款计划则自动终止,需要重新预约!


工行的预约还款时间大概是1个月以后,我是10-15号申请提前还款,银行给的预约日期是 11-14号,大概是1个月。


提前还款,比理财强多了


这次还贷以后,我又申请了提前还款, 提前还 24 期,只需要 9 万,也就是 9 万顶 24 万;提前还 60 期,只需要 24 万,相当于 24 万顶 60 万。


image.png


image.png


还有比提前还贷收益更高,风险更低的理财方式吗?没有! 除了存款外,任何理财都是有风险的。债券和基金收益和风险挂钩,想找到收益5%的债券基金,要承担亏本风险。你惦记人家的利息,人家惦记你的本金!


股票的风险更不必说,我买白酒股票已经被套的死死,只能躺平装死。(劝大家不要入 A 股)


提前还贷划算吗?


我目前的贷款利息是 4.85%,而存到银行的利息不会超过 3% ,很多货币基金只有 2%了。两者利息差高达 3%,肯定是提前还贷款更加合适。


要明白,一年两年短期的利息差还好,但是房贷可是高达 25 年。25年 170 万贷款 3% 的利息差,这个金额太大了。提前还了,省下来的钱还是很多的。例如刚才截图里展示的 提前还 24 万顶了 60 万的房贷。


网上很多砖家说,“要考虑通货膨胀因素,4.85% 的贷款利率和实际通货膨胀比起来不高,提前还款不划算。”


砖家说话都是昧良心的。提前还贷款是否划算,只需要和存款利率比就行了,不需要和通货膨胀比。因为把钱存在银行也会因为通货膨胀贬值。只有把钱 全都消费,全部花光才不会受通货膨胀的困扰,建议砖家,多消费,把家底败光,这样最划算!


砖家们一定是害怕太多人提前还贷,影响了银行的放贷生意。今年上半年,提前还贷已经成潮流,有些银行坐不住,甚至关闭了提前还贷的入口…… 所以要抓紧,没准哪天就提高了还贷门槛,或者直接禁止。


程序员群体收入高,手里闲钱多,可以考虑提前还贷款,比存银行划算多了,别再给银行打工了!


作者:五阳神功
来源:juejin.cn/post/7301530293378727971
收起阅读 »

记一次页面截图需求

web
需求背景 上图是我所负责的监控产品,页面上有大量的图表,用户的述求是能对页面截屏从而直接分享给别人。 那么就有小伙伴要发问了,为什么不直接把页面链接分享给别人呢? 首先,页面可能有权限校验,被分享的人可能没有该页面的访问权限,而图片不会有这个问题;其次,实践...
继续阅读 »

需求背景



上图是我所负责的监控产品,页面上有大量的图表,用户的述求是能对页面截屏从而直接分享给别人。


那么就有小伙伴要发问了,为什么不直接把页面链接分享给别人呢?


首先,页面可能有权限校验,被分享的人可能没有该页面的访问权限,而图片不会有这个问题;其次,实践表明,如果分享的是链接,用户的点击意愿很低,如果不是直接相关的人往往不会点开链接查看,而如果是图片的话,非常直观,往往第一眼就传递了很多信息给被分享的人。


那么又有小伙伴要发问了,既然如此,为何不让用户自己装一个截屏软件自己截算了?


考虑两个点,第一是不一定所有用户都有一个好用的截屏的软件(特别是在Mac上,大伙应该深有体会),并且页面如果需要滚动截屏,用户的操作就会比较麻烦,因此页面上能提供一个一键截屏的按钮就十分便利了;第二是如果由页面提供截图能力,可以很好地定制最终图片上所呈现的页面,比如可以调整一下布局,修改某些元素。


不过需要注意的是,我们要实现的截屏并不是一个真正的截屏,而是相当于dom的快照,针对传入的dom生成图片。


方案调研


那么咱就来研究研究,市面上都有哪些截屏的方案。


后端方案


一种比较常见的方案,是在服务端使用puppeteer(或者playwright啥的)起一个无头浏览器,渲染完页面后截图返回给前端,比如金山文档就是这么做的。


但是吧,这种方案的缺陷很明显。首先毋庸置疑的是,服务端的压力会变大,成本会变高;其次,最终生成的图片往往与用户所看到的页面有些出入,比如金山文档的截屏,如果源文档是些奇奇怪怪的字体,最终生成的图片里的字体就会是默认字体,另外布局什么的也可能会不一致;


源文档:



生成的图片:



那么后端方案优点也就与缺点一一对应,首先是对用户设备的消耗较小,性能较差的设备也能使用;其次是对于同一页面,后端方案生成图片能够完全一致,不会因为用户的机型不同导致页面布局发生变化,而且更重要的一点是,生成图片基本上都依赖于canvas,而canvas这东西有个坑,它对宽、高、面积有一定的限制,并且不同浏览器、不同设备的限制还不太一样,并且同一设备同一浏览器也会因为用户的设备可用资源受到影响,在生成canvas之前也不能拿到这个限制,这个限制在IOS设备上最为严重(有意思的是canvas是苹果提出的标准),参考javascript - Maximum size of a element - Stack Overflow,因此采用后端方案能够保证结果的一致性。



前端方案


有的小伙伴会说了,浏览器自带截屏功能的,直接用多好呀。是的,浏览器有一个截屏功能,但是我们在JS代码里并没法直接调用,并且浏览器自带的截屏,也无法实现上述所说的修改页面元素的能力。


浏览器自带截屏:



那么比较靠谱的前端截屏方案其实就两种,一种自己实现渲染,将dom一一渲染到canvas上后生成图片,比如html2canvas;另一种是借助foreignObject,将svg绘制到canvas上再生成图片,代表作为dom-to-image


html2canvas


html2canvas可以说是最古老的前端截屏实现方案了,也称得上是独一档的实现。它的原理简单来说就是克隆传入的dom,遍历克隆树,通过getBoundingClientRect获取元素的位置、大小,getComputedStyle获取元素样式,然后使用canvas的底层API,一点一点画出来的。


可想而知,这个过程是多么复杂,相当于自己实现了一套渲染引擎,并且css越来越复杂,想要完全绘制到canvas,够呛,所以html2canvas现在有一个很大的缺点就是对css的支持不够好。


另外,由于它自建了一套渲染,需要处理的情况非常多,所以包体积相当大,官网标注的gzip压缩后也有45kB。



除了上述原因外,真正让我放弃这个库的原因是,它太老了,它真的太老了,作为一个十几年前的库,它现在已经年久失修,上次更新都是两年前,而且看着只是文档修改。



并且已经堆积了800+ issue没有处理,基本上是不维护状态了。



更有意思的是,即使这个库已经存在了十几年,并且有大量页面将其应用到了生产环境,其中不乏一些大公司产品,比如腾讯文档(别问我怎么知道的,问就是我写的),但是它的作者仍在Readme里边写到:



dom-to-image


dom-to-image的基本原理十分简单,不需要做什么复杂的渲染,利用到了svg元素的foreignObject:



只需要把dom丢到foreignObject里边,就会在svg里边渲染出来,因为是浏览器的标准,也不用担心对css的支持不够友好:




其实,到这一步,你会发现已经达到将dom转成图片的目的了,svg本来就是图片。但是你可能会需要其他格式的图片,并且这样生成的svg体积实在是大了点,包含了大量冗余的信息。所以这里还是用到canvas,通过drawImage把svg画到canvas上,再通过canvas的toDataUrl生成图片链接。


从体积上看,不到10kB,是完全可以接受的:


看看它的代码仓库,可以看到已经七八年不更新了,并且有200+ issue没有处理,也基本上处于不维护状态了。:




如果能够满足需求,也不是不能用,遗憾的是,不太能满足我的需求。


首先是资源跨域问题,其实资源本身是支持跨域的,但是原始html中的标签没有加上crossorigin属性,导致生成图片时会报跨域错误,像页面里的图片、外链css啥的得做点特殊处理才能用。另外还有些奇奇怪怪的问题,可以看看issue,反正是不太能用。


dom-to-image-more


dom-to-image-more听名字也能听出来是fork的dom-to-image,解决了dom-to-image的部分bug,增加了一些能力。最重要的能力应该是解决了上述提到的跨域问题,它把link标签做了一下拦截,使用fetch去请求对应的src,加上了跨域配置,然后再对返回结果进行处理。另外还有一个有意思的点,在dom-to-image中,获取元素的样式是通过document.getComputedStyle拿到每个dom节点的样式,然后通过行内样式插入到对应的标签上,会导致最后生成的图片上包含了大量的行内样式,体积自然就比较大;而dom-to-image-more做了一个优化,利用沙盒获取到了元素的默认样式,再和getComputedStyle作比较,只插入不同于默认样式的属性,从而极大地减小了图片的体积,自然而然,这个复杂度高了点,生成图片的耗时稍微长点。


体积很理想,不到6kB:



之前看最新的更新在两年前,但是近期好像又有更新,说明还是有人在维护的:



但是最终还是没有用它,因为有个痛点,在我的场景下用了很多icon,而这些icon都是svg格式的,它们通过defs - SVG定义了一次,然后使用时都是通过 - SVG引用的;但是这个库没有处理这种情况,导致生成图片时只复制了use元素,而没有将其对应的defs元素复制过去,从而导致最终生成的图片上丢失了这些icon。


html-to-image


html-to-image也是fork的dom-to-image,修了部分bug,增加了一些能力。这个库相较于dom-to-image,特点是优化了文件结构,增加typescript支持,对比上述的dom-to-image-more,处理好了svg use和svg defs的情况,在有use的情况下会去找到对应的defs元素并添加进来。但是,它没有解决跨域问题。


另外还有个痛点,之前提到的icon,它们的样式吧,上面我们提到了,是通过getComputedStyle获取到,然后插入到行内样式实现的;对于普通的dom元素而言,这样做没有问题,因为这些dom使用的地方就是它们定义的地方;但是对于svg defs和svg use这样的元素而言,在定义时它的样式就已经被行内样式写死了,使用的时候就没办法覆盖定义时的样式,导致我的彩色icon全变成黑色了:


原图:



生成的图片:



看了下源代码,确实没有针对这点进行处理,所以还是放弃了,另外可想而知的是,像webcomponent这样定义和使用分离的情况,估计也存在样式不能覆盖的问题。


modern-screenshot


modern-screenshot也是基于dom-to-image,但它不是直接fork的dom-to-image,而是上面提到的html-to-image,所以相当于是dom-to-image的孙子辈了。


这个库既然是fork的html-to-image,自然也就继承了html-to-image良好的文件结构以及优秀的ts支持;并且这个库有意思的是,它还整合了dom-to-image-more的优化,不会产生跨域的问题了;对于svg use和svg defs,它更进一步,复用已有的defs,减小了生成图片的体积;另外还有个点,它用到了webworker并行地发起网络请求。


东抄抄西补补,modern-screenshot是目前我看到的效果最理想的前端截屏方案,并且这个库的作者仍在维护:


最近的更新发生在三周前,包体积gzip压缩后不到10kB,完全可以接受。


美中不足的是,这个库依然没有解决上述提到的svg use样式不能覆盖问题。其实想想也明白,通过getComputedStyle再写入行内样式的方式,这个问题是避免不了的。不过,考虑到svg defs元素一般都是icon在使用,而这些icon一般来说不会被外界样式所影响,所以针对svg defs和svg use标签,我们不通过getComputedStyle获取其样式,而是直接使用dom.cloneNode获取的样式,这样就不会写死行内样式,从而解决了这个问题。于是给该项目提了一个PR,也顺利合入:



当然这种解法并不严谨,但是绝大部分情况下应该够用,至少在我的场景下已经足够满足需求,因此最终我也是选择了使用modern-screenshot来实现截屏的需求。


modern-screenshot使用


modern-screenshot用起来也很简单,安装完成之后,只需少量代码即可使用:


// html
<div class="container">
<tw-el-tooltip content="生成图片" placement="top" hide-after="0">
<div class="config-button" @click="generateImage">
<el-icon
:config="common_system_picture"
color="#898A8C"
>
</el-icon>
</div>
</tw-el-tooltip>

</div>

// js
import { domToJpeg } from 'modern-screenshot';

const generateImage = async () => {
// 获取要生成图片的dom节点
const node = document.getElementById('service-analyzer-main');
if (!node) {
return;
}
try {
const dataUrl = await domToJpeg(node, {
// 传入配置
scale: 2,
});

// 通过a标签自动下载图片
const a = document.createElement('a');
a.href = dataUrl;
a.download = route.path.split('/').at(-1) + '.jpg';
a.click();
a.remove();
ElMessage.success('图片生成成功,请耐心等待下载');
} catch (error) {
ElMessage.error('图片生成失败');
}
};

原图(使用截屏软件截的长图):



通过modern-screenshot生成的图片:



当然,这是最基本的使用。如果涉及到一些复杂的操作,比如需要在截图时,对某些元素进行修改,比如把图片转成url展示,就需要在截图前遍历dom树进行一些转换再生成图片了。如果这都还不能满足需求,可能就需要专门实现一个预览模式,通过iframe打开预览模式的页面再生成图片了,腾讯文档就是这么做的。


还有些坑


苹果两倍屏


用Mac的小伙伴应该知道,Mac的屏幕是所谓的Retina屏,它的像素密度是普通屏幕的两倍,因此如果直接生成图片的话,在Mac上看起来会是比较模糊的,因此在生成图片时需要将其放大两倍。


截图元素有滚动条导致图片截断


如果截图元素有滚动条,会导致最终生成的图片只包含当前滚动条区域内的内容。如果想要截取完整内容,可以通过将临时截图元素的宽高设置为fit-content,让其展示完整,截图完成后再修改为原始宽高即可。不可避免的,这种方式会对原始元素产生一些影响,滚动条会“跳一下”,不过问题不大,实在介意的话可以加个遮罩层,在生成图片时盖住就好。


缩放后canvas元素模糊


如果在生成图片时设置scale选项,将其放大,可以看到最终生成的图片上canvas元素虽然放大了,但是并不够清晰。这个是没办法避免的,毕竟canvas是像素级别的画布,做不到无损放大,同理可以推断像素图比如jpg、png这些经过放大后也会模糊。


元素内部的滚动元素


另外还有一个坑点,如果截图元素内部的元素有滚动条,生成的图片只能包含可视区域内的部分。其实这是合理的,当然不能全都包含进来,问题是,我有一个页面,截图元素内部存在滚动元素,但是这个滚动元素的默认滚动位置是居中的,而生成图片时这个元素的滚动位置只能是左上角,因此最终生成的图片就没有我想要的内容:


原图:



生成的图片:



而且好像并没有办法指定滚动条的位置。


这让我想到之前看到别人分享的一个有意思的bug,说是他的服务接入了第三方地图服务,但是不管他如何调试,找遍了官方文档,他的页面始终是蓝色的。后续排查了很久,终于发现,原来这个地图默认定位是在经纬度(0, 0),而这里是一片大海。。。



内容过长时生成空白图片


这个其实就是我一开始提到的canvas存在限制的问题:javascript - Maximum size of a element - Stack Overflow


小结


那么事情就是这么个事情啦,主要是记录一下当时我做截图需求过程中调研的一些方案,以及对应的优缺点,并记录了一些坑。其中也包含了一些我在选择三方库时的考量,比如npm下载量、最近更新情况、仓库维护情况、包体积大小、项目功能完善程度、仓库质量、ts支持情况等。


看到这里,如果对你有所帮助的话,可以给我一些鼓励哦。


作者:超级无敌大怪兽
来源:juejin.cn/post/7339671825646338057
收起阅读 »

同学们说我染上面试了

web
目前大三,最近刚分手,想着正好沉下心来去搞就业,寒假期间抱着摸自己底的心态去试试面试,海投了许多厂,小厂,中厂,大厂都有,给我的感觉就是大部分内容都是八股,怎么跟别人的面试不一样,本期就来记录下我面试中遇到的那些面试题,希望对大家春招有所帮助 一、聊聊盒子模型...
继续阅读 »

目前大三,最近刚分手,想着正好沉下心来去搞就业,寒假期间抱着摸自己底的心态去试试面试,海投了许多厂,小厂,中厂,大厂都有,给我的感觉就是大部分内容都是八股,怎么跟别人的面试不一样,本期就来记录下我面试中遇到的那些面试题,希望对大家春招有所帮助


一、聊聊盒子模型


盒模型是css描述布局用的概念,盒子模型有两种,默认情况下为标准盒模型,还有一种为IE或者怪异盒模型,这个是曾经IE使用的盒模型,如今使用需要打上box-sizing: border-box;


对于标准盒模型,一个盒子最终的宽度为width + padding + border + margin。而对于IE盒模型,一个盒子最终的宽度为width + margin,也就是说,IE盒模型的宽度其实就是标准盒模型的width + padding + border,最终自身的真实宽度会被边框和内边距挤掉一部分,当你为了防止盒子被撑大的时候你可以使用IE盒模型


二、vue3的响应式如何实现


vue3的响应式主要是靠es6的代理proxy来实现的,proxy可以拦截对象,进行读值操作,能够捕获到数据的修改


reactive能够将引用类型变成响应式,ref通常用于将原始数据类型变成响应式,但是ref也可以将引用类型变成响应式,另外还有个副作用函数effect,当响应式数据发生变更,副作用函数就会重新执行


三、computed和watch是什么,有什么应用场景


computed是计算属性,依赖响应式数据,只要发生变更就会重新计算,另外computed有缓存机制,多次使用计算属性的逻辑时只会执行一次。所以当数据是根据其他响应式数据计算而来的时候,可使用computed


watch是监听器,监听一个数据的变化,当数据变化时,就会执行一个回调,可以用于处理异步,另外watch在刚进页面的时候就会执行一次


四、说说你对BFC的理解


BFC全称Block Formatting Context也就是块级格式化上下文,是css描述块级盒子的一种方式


BFC可以让浮动元素的高度也计算在内,因此常用于清除浮动,另外BFC还可以阻止子元素的margin-top和父容器重叠。


像是overflow: auto | hidden | scroll 以及左右浮动,还有相对,固定定位display: flex | grid | inline-block | table开头的属性都可以触发BFC


五、浏览器的事件循环


js事件循环机制是浏览器用于处理js代码执行顺序的机制


因为js设计之初仅仅是个脚本语言,所以为了性能,将其定义为单线程,代码一定会有耗时和不耗时代码,也就是异步和同步代码,并且设计师为了更精细地控制异步代码地执行顺序,又将异步分为宏任务,微任务,因此事件循环机制就是描述同步,宏任务和微任务的执行优先顺序


在浏览器的一个事件循环中,先是执行同步,再是执行微任务,最后才是宏任务,之后宏任务又是下一轮事件循环的开启,像是js全局的打印就是一个同步代码,常见的宏任务有三个定时器,和scriptI/O页面渲染。常见的微任务有promise.thenMutationObserverProcess.nextTick


六、浏览器输入url之后发生了什么


先进行url解析,浏览器解析输入的url,提取出协议,主机名,路径等信息


再是dns解析,浏览器将主机名解析成相应的ip地址


然后建立tcp连接,这个过程就是三次握手,确保客户端和服务器之间的连接正常建立


再是发送http请求,浏览器向服务器发送http请求,请求中包括了方法,路径,请求头等信息


然后服务器会处理请求,根据请求的内容进行处理,查询数据库,读取文件


然后服务器会返回响应,将生成的响应数据通过tcp连接发送回浏览器


浏览器接收响应并进行渲染页面,这个过程包括了解析html,css代码生成相应的dom树和cssom树,然后两树结合形成Render Tree,回流,重绘


断开连接,也就是tcp四次挥手


七、flex:1代表什么


flex: 1通常用于做分配父容器的宽度,比如做三栏布局时,两边固定写死宽度,中间给个flex: 1,那么他会继承到中间剩余的所有宽度


不过其实flex是有三个参数的,flex: 1其实是flex: 1 1 auto的缩写,第一个参数是flex-grow也就是放大比例,1代表会放大,也是默认值,第二个参数flex-shrink是收缩,默认值1代表空间不足时等比例收缩,第三个参数flex-basis是定义弹性元素的初始大小


八、讲讲diff算法


diff算法就是用在比较两颗虚拟dom树的差异,最小化地对真实dom进行修改,就是找不同。


这个比较过程先是对两颗dom树进行深度优先遍历,然后同级节点比较,比较相同位置地节点,类型属性是否不同,不同则替换,相同继续下去


两个相同类型的节点有不同的属性diff算法也会更新到真实dom,如果两个节点有不同的子节点,diff算法也会递归找到子节点地差异


当比较列表节点地时候,diff算法会尽可能地复用已有地节点,而不是删除再重新创建。另外当diff算法发现某个节点已经不同于之前的虚拟dom树,它会立即停止对改节点地进一步比较,避免性能浪费


九、js数据类型判断


typeof可以判断除了null之外的原始数据类型,还可以判断函数


instanceof只能判断引用数据类型,它是通过原型链来查找的


Array.isArray只能判断是否为数组


Object.prototype.toString.call可以判断所有的数据类型,返回一个字符串,比较起来会麻烦点


十、手写防抖节流


防抖就是在规定的时间内没有第二次操作,才执行,规定的时间内一直触发,就不会执行。


防抖如下


function debounce(fn, delay){
let timer
return function(){
let args = arguments
if(timer) clearTimeout(timer);
timer = setTimeout(() => {
fn.call(this,...args)
},delay)
}
}

防抖就是接收一个函数和一个时间,然后在函数中创建一个定时器,如果定时器不存在,那么就会创建一个定时器,并在时间过期之后执行传入的函数,如果定时器存在那么久掐灭这个定时器


节流就是规定的时间内只执行一次,假设手速很快,但是触发事件地速度是恒速


节流如下


function throttle(fn, delay){
let prevTime = Date.now();
return function(){
if(Date.now() - prevTime > delay){
fn.apply(this,arguments)
prevTime = Date.now()
}
}
}

节流同样,接受一个函数,一个时间,只要两次时间差大于传入的时间才会执行函数,并且只有满足了这个条件,prevTime才会更新为当前时间


十一、vite为什么比webpack快


vite使用了ES Module作为开发基础,在开发过程中能够以原生的ES Module直接加载和解析模块


vite可以在你修改代码后,只重新加载和应用发生改变的部分,而非整个页面,并且当你启动项目时,vite只会加载你正在编辑得文件,而非整个项目,vite只在需要时加载代码,而不是一次性加载所有的内容,并且vite可以缓存已经构建过的代码,下次启动时直接使用缓存


十二、如何解决闭包导致的内存泄露


可以使用WeakMap或者WeakSet来存储外部变量,因为二者是弱引用,时刻会被垃圾回收机制回收


如果闭包处于事件处理函数中,可以借助事件委托的机制,将事件处理函数放到父元素上,而非每个子元素上,事件委托就是借助了冒泡的机制


十三、聊聊cookie


cookie是个小型的文本文件,由服务器端发送到用户的浏览器,并存储在用户的计算机上。cookie主要是来跟踪用户的会话信息,存储用户的偏好设置


cookie通常用于身份认证来保证网站资源的安全性,而非大量数据的本地存储,当用户访问一个网站时,服务器会创建一个唯一的会话标识符,存在cookie中,这样用户在同一网站就可以保持登录状态,不用重复登录


十四、聊聊vue的生命周期


vue的生命周期有如下几个阶段,每个阶段都有两个钩子,除了最后一个销毁,时间一到自动触发钩子


创建阶段:创建之前,初始化选项式api,创建之后


挂载阶段:挂载之前,初始渲染dom节点,挂载之后


更新阶段:更新前,更新后,这里的更新是根据数据源的变化


销毁阶段:销毁前,销毁后,移除所有的监听器


错误捕获:子组件报错时触发


十五、vue中父子组件如何通讯


父传子,需要父组件v-bind绑定属性用于传值,子组件用props接收


子传父,需要子组件通过$emit发布该事件,且携带参数


最后


不知道为什么,面了这么多,很少碰到让我手写,大部分都是聊八股和项目。


作者:Dolphin_海豚
来源:juejin.cn/post/7340479361219002405
收起阅读 »

js如何控制一次只加载一张图片,加载完成后再加载下一张

web
今天看到一个面试题,是关于img图片加载方面的,有必要记录一下。其实关于这个问题,只要知道图片什么时候加载完成就能解决了。通过onload事件判断Img标签加载完成实现逻辑:新建一个Image对象实例,为实例对象设置src属性等,在onload事件中添加此实例...
继续阅读 »

今天看到一个面试题,是关于img图片加载方面的,有必要记录一下。其实关于这个问题,只要知道图片什么时候加载完成就能解决了。

通过onload事件判断Img标签加载完成

实现逻辑:新建一个Image对象实例,为实例对象设置src属性等,在onload事件中添加此实例对象到父元素中,然后将图片地址数组中的第一个元素剔除,继续调用此方法直到存储图片地址的数组为空。

代码

const imgArrs = [...]; // 图片地址
const content = document.getElementById('content');
const loadImg = () => {
if (!imgArrs.length) return;
const img = new Image(); // 新建一个Image对象
img.src = imgArrs[0];
img.setAttribute('class', 'img-item');
img.onload = () => { // 监听onload事件
// setTimeout(() => { // 使用setTimeout可以更清晰的看清实现效果
content.appendChild(img);
imgArrs.shift();
loadImg();
// }, 1000);
}
img.onerror = () => {
// do something here
}
}
loadImg();


实现效果

lp_img_load.gif

加上setTimeout后,看到的效果更加明显,我这里加了500毫秒的延迟(录屏软件只支持录制8秒的时间...)

setTimeout_load_img.gif

其实我在网上还看到了一种答案,通过onreadystatechange事件实现监听,于是在我本地调试了一下,发现并不能实现,img实例对象上并没有这个属性方法。查了查MDN,发现目前仅有XmlHttpRequest对象和Document对象中存在onreadystatechange属性,而对于其它元素onreadystatechange此属性并不存在。

因此对于其它元素需要慎用onreadystatechange事件

不过我电脑上目前只有ChormeSafari两种浏览器,对于onreadystatechange测试的覆盖面不全,所以我上面的结论可能还需要进一步验证才行,感兴趣的掘友可以调试一下~。

扩展知识

img标签是什么时候发送图片资源请求的?

  1. HTML文档渲染解析,如果解析到img标签的src时,浏览器就会立刻开启一个线程去请求图片资源。
  2. 动态创建img标签,设置src属性时,即使这个img标签没有添加到dom元素中,也会立即发送一个请求。
// 例1:
const img = new Image();
img.src = 'http://xxxx.com/x/y/z/ccc.png';

上面的代码如果运行起来后,就会发送请求。 如图:

image.png

再看一个例子:创建了一个div元素,然后将存放img标签元素的变量添加到div元素内,而div元素此时并不在dom文档中,页面不会展示该div元素,那么浏览器会发送请求吗?

// 例2:
const img = ``;
const dom = document.createElement('div');
dom.innerHtml = img;

答案:会请求。如图:

image.png

通过设置css属性能否做到禁止发送图片请求资源?

  1. img标签设置样式display:none或者visibility: hidden,隐藏img标签,无法做到禁止发送请求。
"http://xxx.com/x/sdf.png" style="display: none;">
或者
"http://xxx.com/x/sdf.png" style="visibility: hidden;">
  1. 将图片设置为元素的背景图片,但此元素不存在,可以做到禁止发送请求。
DOCTYPE html>
<html lang='en'>
<head>
<meta charset='UTF-8'>
<title>title>
<style>
.test {
height: 200px;
background-image: url('http://eb118-file.cdn.bcebos.com/upload/39148b2a545b48bf9b4ee95fd1b7f1eb_1515564089.png?');
}
style>
head>
<body>
<div>div>
body>
html>

dom文档中不存在test元素时,即使设置了背景图片,也不会发送请求,只有test元素存在时才会发送请求。

另外这个例子其实有点不太贴切,img标签background-image二者有着本质的区别。一个属于HTML标签,另一个属于css样式,加载机制和解析顺序也不同。

一个完整的页面是由jshtmlcss组成的,按照解析机制,html元素会优先解析,尽管css样式是放在head标签内的,但也不意味着它会优先加载,它只有等到html文档加载完成后才会执行。而img标签属于网页内容,所以img标签会随着网页解析渲染优先于css样式表加载出来。

文章中若有描述不正确的地方,欢迎掘友们纠正~。

参考文章


作者:娜个小部呀
来源:juejin.cn/post/7340167256267391012
收起阅读 »

小小导出,我大前端足矣!

web
如果你觉得你现在做的事情很累,很难,那一定是你方法用错了。 一、问题剖析 那是一个倾盆大雨的早上,花瓣随风雨落在我的肩膀上,是五颜六色的花朵。 我轻轻抚摸着他,随后拨开第一朵花瓣,她不爱我。 拨开第二朵,她爱我。 正当我沉迷于甜蜜的幻想中,后端小白🙋喊道:...
继续阅读 »



如果你觉得你现在做的事情很累,很难,那一定是你方法用错了。



2.jpg


一、问题剖析


那是一个倾盆大雨的早上,花瓣随风雨落在我的肩膀上,是五颜六色的花朵。


我轻轻抚摸着他,随后拨开第一朵花瓣,她不爱我。


拨开第二朵,她爱我。


正当我沉迷于甜蜜的幻想中,后端小白🙋喊道:这个导出你前端应该就能做的吧!


🙋🏻‍♂️那是自然,有什么功能是我大前端做不了的,必须得让你们大开眼界。


二、为什么导出要前端做?


前端导出的场景:



  1. 轻量级数据:如果要导出的表格数据相对较小,可以直接在前端生成和导出,避免服务器端的处理和通信开销。

  2. 数据已存在于前端:如果表格数据已经以 JSON 或其他形式存在于前端,可以直接利用前端技术将其导出为 Excel、CSV 或其他格式。

  3. 实时生成/计算:如果导出的表格需要根据用户输入或动态生成,可以使用前端技术基于用户操作实时生成表格,并提供导出功能。

  4. 快速响应:前端导出表格可以提供更快的响应速度,避免等待服务器端的处理和下载时间。


后端导出的场景:



  1. 大量数据:如果要导出的表格数据量很大,超过了前端处理能力或网络传输限制,那么在服务器端进行导出会更高效。

  2. 安全性和数据保护:敏感数据不适合在前端暴露,因此在服务器端进行导出可以更好地控制和保护数据的安全。

  3. 复杂的业务逻辑:如果导出涉及复杂的业务逻辑、数据处理或数据查询,使用服务器端的计算能力和数据库访问更合适。

  4. 跨平台支持:如果需要支持多个前端平台(如 Web、移动应用等),将导出功能放在服务器端可以提供一致的导出体验。


三、讲解一下在前端做的导出


xlsx、xlsx-style


如果是只做表格导出:http://www.npmjs.com/package/xls…


如果导出要包含样式:http://www.npmjs.com/package/xls…


import XLSX from "xlsx";

exportData() {
let tableName = '表格'

if(!getVal(this.dataList, 'length')){
this.$message.info("暂时数据");
return
}


// 处理头部
let headers = {
"B2": "字段-B2",
"E2": "字段-E2",
}
const props = [ "B2", "E2" ]
let tmp_dataListFilter = [
{
"B2": "字段-B2",
"E2": "字段-E2",
},
{
"E2": "2",
"B2": "2",
}
]

tmp_dataListFilter.unshift(headers) // 将表头放入数据源前面
let wb = XLSX.utils.book_new();
let contentWs = XLSX.utils.json_to_sheet(tmp_dataListFilter, {
skipHeader: true, // 是否忽略表头,默认为false
origin: "A2" // 设置插入位置
});
// /单独设置某个单元格内容
contentWs["A1"]={
t:"s",
v:tableName,
};
// /设置单元格合并!merges为一个对象数组,每个对象设定了单元格合并的规侧,
// /{s:{r:0,c:},e:{r:0,c:2}为一个规则,s:起始位置,e:结束位置,r:行,c:列
contentWs["!merges"]=[{ s:{r:0,c:0 },e:{r:0,c:props.length - 1 }}]

// 设置单元格的宽度
contentWs["!cols"] = []
props.forEach(p => contentWs["!cols"].push({wch: 35}))
XLSX.utils.book_append_sheet(wb,contentWs,tableName) // 表格内的下面的tab
XLSX.writeFile(wb,tableName + ".xlsx"); // 导出文件名字
},

package.json


"xlsx": "^0.15.5",
"xlsx-style": "^0.8.13"

大概效果如下:


3.png


感觉前端导出也很容易。


哦哦,那你别高兴太早。


四、需求升级:单元格要居中和加粗。


xlsx


尝试使用xlsx-style设样式。


官方文档:github.com/rockboom/Sh…


文档说给单元格设置s为对象


4.png


let contentWs = XLSX.utils.json_to_sheet(tmp_dataListFilter, {
skipHeader: true, // 是否忽略表头,默认为false
origin: "A2", // 设置插入位置
});
// /单独设置某个单元格内容
contentWs["A1"] = {
t: "s",
v: tableName,
s:{ // 这个是关键s
font: { bold: true },
alignment: { horizontal: 'center' }
}
};

发现设置无效。


有人说要改xlsx、xlsx-style源码:


大概的意思是:修改xlsx.extendscript.js、xlsx.full.min.js更改文件变量。


发现仍然无效。


使用binary方式保存



  1. 首先保存的时候 type要改成 binary方式

  2. 保存的时候需要使用 xlsx-style模块


var writingOpt = { 
bookType: 'xlsx',
bookSST: true,
type: 'binary' // <--- 1.改这里
}


/*
2. type:'array'改为'binary' 后因为下面代码会报错, 打不开excel
new Blob([wbout], { type: 'application/octet-stream' }
要文本转换成数组缓存后再生成二进制对象
*/


// 添加String To ArrayBuffer
function s2ab(s) {
var buf = new ArrayBuffer(s.length);
var view = new Uint8Array(buf);
for (var i = 0; i < s.length; i++) {
view[i] = s.charCodeAt(i) & 0xFF;
}
return buf;
}

let blob = new Blob([s2ab(wbout)], { type: 'application/octet-stream' })

FileSaver.saveAs(blob, exportName)

可以下载了。但依然样式没起作用。


使用 xlsx-style 模块生成文件


首先安装模块


npm install xlsx-style 

在项目里安装报好多错误直接强制安装,不检查依赖。


npm install xlsx-style -force

安装完成后 找不到cptable模块会报错

报错内容如下:


./node_modules/xlsx-style/dist/cpexcel.js Module not found: Error: Can't resolve './cptable' in 

这个问题在vue.config.js里配置一下就可以解决。

其他框架自己找找方法,反正只要不让他报错能启动就行。


module.exports = {
// ...其他配置省略
configureWebpack: {
// ...其他配置省略
externals:{
'./cptable':'var cptable'
},
},

安装完xlsx-style后改代码


import XLSX2 from "xlsx-style";    // 1. 引入模块

// 2. 使用`xlsx-style` 生成。 XLSX.write => XLSX2.write
var wbout = XLSX2.write(wb, writingOpt)

仍然无效。


总结xlsx


大概的意思是说:默认不支持改变样式,想要支持改变样式,需要使用它的收费版本。


本着勤俭节约的原则,很多人使用了另一个第三方库:xlsx-style[4] ,但是使用起来极其复杂,还需要改 node_modules 源码,这个库最后更新时间也定格在了 6年前。还有一些其他的第三方样式拓展库,质量参差不齐。


使用成本和后期的维护成本很高,不得不放弃。


ExcelJS


ExcelJS终于可以了


ExcelJS[5] 周下载量 450k,github star 9k,并且拥有中文文档,对国内开发者很友好。虽然文档是以README 的形式,可读性不太好,但重在内容,常用的功能基本都有覆盖。


最近更新时间是6个月内,试用了一下,集成很简单,再加之文档丰富,就选它了。


npm install exceljs
npm install file-saver // 下载到本地还需要另一个库:file-saver

基本操作


//导入ExcelJS
import ExcelJS from "exceljs";

//下载文件
download_file(buffer, fileName) {
console.log("导出");
let fileURL = window.URL.createObjectURL(new Blob([buffer]));
let fileLink = document.createElement("a");
fileLink.href = fileURL;
fileLink.setAttribute("download", fileName);
document.body.appendChild(fileLink);
fileLink.click();
}

导出xlsx表格的代码


//下面是导出的函数
async export() {
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet("Sheet1");
//这里是数据列表
const data = [
{ id: 1, name: "艾伦", age: 20, sex: "男", achievement: 90 },
{ id: 2, name: "柏然", age: 25, sex: "男", achievement: 86 },
];
// 设置列,这里的width就是列宽
worksheet.columns = [
{ header: "序号", key: "id", width: 10},
{ header: "姓名", key: "name", width: 10 },
];

// 批量插入数据
data.forEach(item => worksheet.addRow(item));

// 写入文件
const buffer = await workbook.xlsx.writeBuffer();
//下载文件
this.download_file(buffer, "填报汇总.xlsx");
}

设置行高和列宽


列宽上面已经有了,这里说明一下行高怎么设置

worksheet.getRow(2).height = 30;


合并单元格


worksheet.mergeCells("B1:C1");


自定义表格样式


//设置样式表格样式,font里面设置字体大小,颜色(这里是argb要注意),加粗
//alignment 设置单元格的水平和垂直居中
const B1 = worksheet.getCell('B1')
B1.font = { size: 20, color:{ argb: 'FF8B008B' }, bold: true }
B1.alignment = { horizontal: 'center', vertical: 'middle' }

ExcelJS实战


import ExcelJS from "exceljs";

//下载文件
download_file(buffer, fileName) {
console.log("导出");
let fileURL = window.URL.createObjectURL(new Blob([buffer]));
let fileLink = document.createElement("a");
fileLink.href = fileURL;
fileLink.setAttribute("download", fileName);
document.body.appendChild(fileLink);
fileLink.click();
},
async exportClick() {
const loading = this.$loading({
lock: true,
text: "数据导出中,请耐心等待!",
spinner: "el-icon-loading",
background: "rgba(0, 0, 0, 0.7)",
});

this.tableData = [
{ a: 1, b:2 }
]
const enterpriseVisitsColumns = [
{
prop: "a",
label: "银行",
},
{
prop: "b",
label: "企业数",
}
]

// 表格数据:this.tableData
if (!(this.tableData && this.tableData.length)) {
this.$message.info("暂无数据");
loading.close();
return;
}

let tableName = this.tableName; // 表格名
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet(tableName);
const props = enterpriseVisitsColumns();
//这里是数据列表
const data = this.tableData;
// 设置列,这里的width就是列宽
let arr = [];
props.forEach((p) => {
arr.push({
header: p.label,
key: p.prop,
width: 25,
});
});
worksheet.columns = arr;

// 插入一行到指定位置,现在我往表格最前面加一行,值为表名
const rowIndex = 1; // 要插入的行位置
const newRow = worksheet.insertRow(rowIndex);
// 设置新行的单元格值
newRow.getCell(1).value = tableName; // 值为表名

// 批量插入数据,上面插一条,这里就是从第二行开始加
data.forEach((item) => worksheet.addRow(item));

//设置样式表格样式,font里面设置字体大小,颜色(这里是argb要注意),加粗
//alignment 设置单元格的水平和垂直居中
// const B1 = worksheet.getCell("B1");
// B1.font = { size: 20, color: { argb: "FF8B008B" }, bold: true };
// B1.alignment = { horizontal: "center", vertical: "middle" };

// 合并单元格,就是把A1开始到J1的单元格合并
worksheet.mergeCells("A1:J1");

// 批量设置所有表格数据的样式
worksheet.eachRow((row, rowNumber) => {
let size = rowNumber == 1 ? 16 : rowNumber == 2 ? 12 : "";
//设置表头样式
row.eachCell((cell) => {
cell.font = {
size,
// color:{ argb: 'FF8B008B' },
bold: true,
};
cell.alignment = { horizontal: "center", vertical: "middle" };
});

//设置所有行高
row.height = 30;
});

// 写入文件
const buffer = await workbook.xlsx.writeBuffer();
//下载文件
this.download_file(buffer, tableName + ".xlsx");

loading.close();
},

后记


导出功能并不是说都是前端或者后端实现,要具体情况,具体分析,我相信哪方都可以做,但谁适合做,这个才是我们需要去思考的。


就如同我们项目中,该例子后面也是前端实现的,大数据分页当然还是得后端同学来实现较好。


如果有其他更好的方法也欢迎评论区见,这里提供的只是诸多方法之一。


最后,祝君能拿下满意的offer。




作者:Dignity_呱
来源:juejin.cn/post/7339814359886348328
收起阅读 »

MyBatis-Plus 效能提升秘籍:掌握这些注解,事半功倍!

MyBatis-Plus是一个功能强大的MyBatis扩展插件,它提供了许多便捷的注解,让我们在开发过程中能够更加高效地完成数据库操作,本文将带你一一了解这些注解,并通过实例来展示它们的魅力。一、@Tablename注解这个注解用于指定实体类对应的数据库表名。...
继续阅读 »

MyBatis-Plus是一个功能强大的MyBatis扩展插件,它提供了许多便捷的注解,让我们在开发过程中能够更加高效地完成数据库操作,本文将带你一一了解这些注解,并通过实例来展示它们的魅力。

一、@Tablename注解

这个注解用于指定实体类对应的数据库表名。如果你的表名和实体类名不一致,就需要用到它:

@TableName("user_info")
public class UserInfo {
// 类的属性和方法
}

在上述代码中,即使实体类名为UserInfo,但通过@TableName注解,我们知道它对应数据库中的"user_info"表。

二、@Tableld注解

每个数据库表都有主键,@TableId注解用于标识实体类中的主键属性。通常与@TableName配合使用,确保主键映射正确。

AUTO(0),
NONE(1),
INPUT(2),
ASSIGN_ID(3),
ASSIGN_UUID(4),
/** @deprecated */
@Deprecated
ID_WORKER(3),
/** @deprecated */
@Deprecated
ID_WORKER_STR(3),
/** @deprecated */
@Deprecated
UUID(4);

Description

  • INPUT 如果开发者没有手动赋值,则数据库通过自增的方式给主键赋值,如果开发者手动赋值,则存入该值。

  • AUTO 默认就是数据库自增,开发者无需赋值。

  • ASSIGN_ID MP 自动赋值,雪花算法。

  • ASSIGN_UUID 主键的数据类型必须是 String,自动生成 UUID 进行赋值。

// 自己赋值
//@TableId(type = IdType.INPUT)
// 默认使用的雪花算法,长度比较长,所以使用Long类型,不用自己赋值
@TableId
private Long id;

测试

@Test
void save(){
// 由于id加的有注解,这里就不用赋值了
Student student = new Student();
student.setName("天明");
student.setAge(18);
mapper.insert(student);
}

Description

雪花算法

雪花算法是由Twitter公布的分布式主键生成算法,它能够保证不同表的主键的不重复性,以及相同表的主键的有序性。

核心思想:

  • 长度共64bit(一个long型)。

  • 首先是一个符号位,1bit标识,由于long基本类型在Java中是带符号的,最高位是符号位,正数是0,负数是1,所以id一般是正数,最高位是0。

  • 41bit时间截(毫秒级),存储的是时间截的差值(当前时间截 - 开始时间截),结果约等于69.73年。

  • 10bit作为机器的ID(5个bit是数据中心,5个bit的机器ID,可以部署在1024个节点)。

  • 12bit作为毫秒内的流水号(意味着每个节点在每毫秒可以产生 4096 个 ID)。

Description

优点: 整体上按照时间自增排序,并且整个分布式系统内不会产生ID碰撞,并且效率较高。

三、@TableField注解

当你的实体类属性名与数据库字段名不一致时,@TableField注解可以帮助你建立二者之间的映射关系。

  • 映射非主键字段,value 映射字段名;

  • exist 表示是否为数据库字段 false,如果实体类中的成员变量在数据库中没有对应的字段,则可以使用 exist,VO、DTO;

  • select 表示是否查询该字段;

  • fill 表示是否自动填充,将对象存入数据库的时候,由 MyBatis Plus 自动给某些字段赋值,create_time、update_time。

Description

自动填充

1)给表添加 create_time、update_time 字段。

Description

2)实体类中添加成员变量。

package com.md.entity;

import com.baomidou.mybatisplus.annotation.*;
import com.md.enums.StatusEnum;
import lombok.Data;
import java.util.Date;

@Data
@TableName(value = "student")
public class Student {
@TableId
private Long id;

// 当该字段名称与数据库名字不一致
@TableField(value = "name")
private String name;

// 不查询该字段
@TableField(select = false)
private Integer age;

// 当数据库中没有该字段,就忽略
@TableField(exist = false)
private String gender;

// 第一次添加填充
@TableField(fill = FieldFill.INSERT)
private Date createTime;

// 第一次添加的时候填充,但之后每次更新也会进行填充
@TableField(fill = FieldFill.INSERT_UPDATE)
private Date updateTime;

}

3)创建自动填充处理器。

注意:不要忘记添加 @Component 注解。

package com.md.handler;

import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;

import java.util.Date;

/**
* @author md
* @Desc 对实体类中使用的自动填充注解进行编写
* @date 2020/10/26 17:29
*/
// 加入注解才能生效
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {

@Override
public void insertFill(MetaObject metaObject) {
this.setFieldValByName("createTime", new Date(), metaObject);
this.setFieldValByName("updateTime", new Date(), metaObject);
}

@Override
public void updateFill(MetaObject metaObject) {
this.setFieldValByName("updateTime", new Date(), metaObject);
}
}

4)测试

@Test
void save(){
// 由于id加的有注解,这里就不用赋值了
Student student = new Student();
student.setName("韩立");
student.setAge(11);
// 时间自动填充
mapper.insert(student);
}

Description

5)更新

当该字段发生变化的时候时间会自动更新。

@Test
void update(){
Student student = mapper.selectById(1001);
student.setName("韩信");
mapper.updateById(student);
}

Description

四、@TableLogic注解

在很多应用中,数据并不是真的被删除,而是标记为已删除状态。@TableLogic注解用于标识逻辑删除字段,通常配合逻辑删除功能使用。

1、逻辑删除

物理删除: 真实删除,将对应数据从数据库中删除,之后查询不到此条被删除的数据。

逻辑删除: 假删除,将对应数据中代表是否被删除字段的状态修改为“被删除状态”,之后在数据库中仍旧能看到此条数据记录。

使用场景: 可以进行数据恢复。

2、实现逻辑删除

step1: 数据库中创建逻辑删除状态列。
Description

step2: 实体类中添加逻辑删除属性。

@TableLogic
@TableField(value = "is_deleted")
private Integer deleted;

3、测试

测试删除: 删除功能被转变为更新功能。

-- 实际执行的SQL
update user set is_deleted=1 where id = 1 and is_deleted=0

测试查询: 被逻辑删除的数据默认不会被查询。

-- 实际执行的SQL
select id,name,is_deleted from user where is_deleted=0

你还在苦恼找不到真正免费的编程学习平台吗?可以试试【云端源想】!课程视频、知识库、微实战、云实验室、一对一咨询……你想要的全部学习资源这里都有,重点是现在还是免费的!点这里即可查看!

五、@Version注解

乐观锁是一种并发控制策略,@Version注解用于标识版本号字段,确保数据的一致性。

乐观锁

Description
标记乐观锁,通过 version 字段来保证数据的安全性,当修改数据的时候,会以 version 作为条件,当条件成立的时候才会修改成功。

version = 2

  • 线程1:update … set version = 2 where version = 1
  • 线程2:update … set version = 2 where version = 1

1.数据库表添加 version 字段,默认值为 1。

2.实体类添加 version 成员变量,并且添加 @Version。

package com.md.entity;

import com.baomidou.mybatisplus.annotation.*;
import com.md.enums.StatusEnum;
import lombok.Data;
import java.util.Date;

@Data
@TableName(value = "student")
public class Student {
@TableId
private Long id;
@TableField(value = "name")
private String name;
@TableField(select = false)
private Integer age;
@TableField(exist = false)
private String gender;
@TableField(fill = FieldFill.INSERT)
private Date createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private Date updateTime;

@Version
private Integer version; //版本号

}

3.注册配置类

在 MybatisPlusConfig 中注册 Bean。

package com.md.config;

import com.baomidou.mybatisplus.extension.plugins.OptimisticLockerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.PaginationInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
* @author md
* @Desc
* @date 2020/10/26 20:42
*/
@Configuration
public class MyBatisPlusConfig {
/**
* 乐观锁
*/
@Bean
public OptimisticLockerInterceptor optimisticLockerInterceptor(){
return new OptimisticLockerInterceptor();
}
}

六、@EnumValue注解

mp框架对枚举进行处理的一个注解。

使用场景: 创建枚举类,在需要存储数据库的属性上添加@EnumValue注解。

public enum SexEnum {

MAN(1, "男"),
WOMAN(2, "女");

@EnumValue
private Integer key;
}

MyBatis-Plus的注解是开发者的好帮手,它们简化了映射配置,提高了开发效率。希望以上的介绍能帮助新手朋友们快速理解和运用这些常用注解,让你们在MyBatis-Plus的世界里游刃有余!记得实践是最好的学习方式,快去动手试试吧!

收起阅读 »

技术人的绩效评审发年终奖那些事儿

前言 这几天陆续开工了,收益不好的公司,没有年会,没有年终奖,没有开工红包,没有团建,也没有聚餐,唯一有的可能是降薪裁员... 收益好的公司开了年会,年终奖加倍... 接下来就来聊聊关于技术人的绩效评审以及年终奖那些事儿 以下基于个人经历展开讨论和思考,如果...
继续阅读 »


前言


这几天陆续开工了,收益不好的公司,没有年会,没有年终奖,没有开工红包,没有团建,也没有聚餐,唯一有的可能是降薪裁员...


收益好的公司开了年会,年终奖加倍...


接下来就来聊聊关于技术人的绩效评审以及年终奖那些事儿



以下基于个人经历展开讨论和思考,如果有不同的观点,欢迎交流探讨



关于年终奖


一般来讲,只要公司收益好,一般是多少都会发点年终奖的,例如13、4、5、6,7薪,过节费,项目奖,年终奖,xx绩效奖,部门xx奖等等



公司要是效益不好的话,可能就是这些各种发钱的项目就没有了,可能会有一点过节费,如果效益差到工资社保都拖欠的话,可能就是考虑能不能年底也发点工资的情况了


总的来说年终奖主要和公司收益挂钩,公司收益好,多少有点,收益不好,无


如果发年终奖的话,在相关制度下总量可能就那么多,发到个人这边一般就是和绩效挂钩了,绩效评级高,同类型岗位情况下发的多点,绩效低,发的少,那么公司是怎么对技术人进行绩效评审呢



微小型公司可能不需要绩效,发钱基本老板一个人就决定了


特殊情况的公司或者部门各种特殊的情况也有



这里分享一下我司今年出的年终奖方案



以下半年部门净收入目标完成率作为基础指标,实现目标销售净额的,按3%计提奖金池,未完成目标的,按3%×目标完成率计提奖金池,超过目标的,超过部分按10%增加奖金池。由分管领导与部门沟通后参照员工年终评估结果出具分配方案(年终评估为D或E员工无奖金)



负责人,其他前台部门等年终奖的计算方式是另外的方式,有兴趣的可以留言讨论,这里先不展开了


员工评估 D, E 是有硬指标的,具体占比百分之几咱也不知道,每年的评审结果都是只有领导知道


由于公司业务效益不行,净收入为负,亏损状态,所以我们这个部门的员工年终奖——无



关于绩效评审


先来看看我司对技术人员工的评估方式


第一步:直系领导直接打分,提交对应的表到人力部门


第二步:人力部门根据任务系统中的个人任务情况,以及考勤情况打分


第三步:人力总监最终决定给xxx员工涨薪,发奖金



日常任务由直系领导安排发放,包括任务工时评估,注意了,这里任务工时不是开发者评估的,大部分都是负责人直接评好写到周任务表上去的,有的任务用时会和开发者咨询协商


人力部门看的那个任务系统中的任务,一般是业务线负责人不忙的时候根据腾讯文档周任务表中的任务后期补上去的


日常的很多临时工作在周任务表上体现不出来的,然后很多周任务表中的任务没有写到任务系统中,任务系统中的任务只是创建,开始,完成状态,没有工时的体现


由于任务系统是人力部门单独推动的,最终的情况就是实际工作内容和任务系统记录其实是脱节状态,为了建任务而建任务


不同业务线的直系领导角色也不一样,有的是纯管理,有的是半后端开发半管理,有的是产品



总结一下就是直系领导决定主要的打分情况,人力部门根据基于一线负责人的打分结合考勤和任务情况,决定要不要涨薪,发多少奖金


以上就是我司绩效评审的一些情况,这也是相当一部分比例公司(部门/团队)的常规操作


从客观数据角度来看,基本没有体现岗位产出方面客观可量化的指标,唯一能量化的指标就是一个考勤了,两三个人的主观评价就决定了一个技术人的升职、加薪、辞退、奖金发放的问题


万物都有存在的道理,毕竟,大部分公司都是草台班子,甚至更水


从客观数据角度思考对于技术人的工作评审


先来看看技术人的实际工作都有哪些?


我们拿技术人中的开发人员来举例,从一个任务安排到工作完成提交都有哪些常见步骤:


参加需求评审会,了解需求,设计实现方案,代码编写,提交代码,线上测试,修复bug,输出文档,技术分享等等


开发人员工作产出一般通俗点讲就是开发了多少需求,功能,解决了多少bug等等


在技术圈内一般讨论一个开发人员是否大佬,是否能力出众的标准一般是:



  1. 解决问题的快慢程度(不局限于技术问题)

  2. 掌握技术栈的深度和广度

  3. 是否能和其他不同的工种、部门良好协作

  4. 技术方案执行落地能够良好取舍

  5. 定期更新技术栈,使用相对合适的技术解决业务问题

  6. 提交功能的bug数量

  7. 代码可读性是否良好

  8. 代码是否足够简洁优雅

  9. 开发的功能是否健壮

  10. 其他人接手可维护性是否容易

  11. 输出的文档是否专业,格式简洁明了

  12. ...


相对来讲,开发人员工作产出基本都是可以量化的,关于工作量化的问题,鲁迅说: 任何岗位的工作都是可以量化的


为了评审量化产出,也不能为了量化而量化,那样没任何意义了,浪费团队大把时间扯皮,还影响工作


关于量化产出激励团队方面,我个人比较喜欢敏捷开发那种模式,相对公正,公开


例如:在敏捷开发里面是日常工作是按照评估的人天,人时等方式


任务评估为了保证相对公平性是有这么一个前提的,团队岗位不是一个人评估


例如前端开发岗位,至少俩个人,一个人评估1人天,一个人评估10人天,这显然是有明显差距的,这种情况下一般 master 角色和团队其他成员参与讨论决定,master 也就是项目经理或者项目负责人的角色进行一定的把控


任务是个人根据需求池中的任务自己拉到个人任务表里,而不是单纯的分配,这种机制有个很不错的方式,就是优秀的人在一起会激发更强大的创造力



这里推荐看下美剧 《硅谷》 中 Dinesh 和 Gilfoyle 敏捷开发时的竞争桥段



还有就是不同的开发任务难易程度不一样,举个例子,有两个小任务,同样都是2人天,第一个任务需要大量的尝试新方案,进行相应的测试,并编写一定数据量的代码;第二个任务是使用现有方案,直接写一定量的代码,如常见的业务需求


这种情况的任务如果在项目工期相对紧张的情况,任务是自我选择的话绝大多数都会选择第二个任务,虽然量大,没有风险,不会出现研究过程某个地方卡壳导致任务延期(延期也有对应的处理机制,这里先不讨论)


但是这样很可能会出现难度高的任务会放到最后,没人做了,所以要有人为把控优先级,并且进行合理安排进行一定的调整


同样都是2天的任务,接受度是不一样的,这种任务一般会增加一个难度系数,也有的是进行难度分类,例如:难,一般,容易


最终任务产出统计时,工时还需要乘以难度系数


理想情况下以上的操作基本能实现大部分开发工作产出的量化,对于年终的工作评审结果相对公平客观



注意!!!


这里有个点,敏捷的机制是激励那些那些高效率高产出的人,是一种公开透明的激励机制


良好落地敏捷也是需要一定条件的,如团队需要有一定规模,并且要进行培训和认同这种机制,而且岗位对应人员至少是一个人,级别不能相差太大等等



每一种机制都是基于特定人群设计的,如果用错了人群可能机制流程就变成了纯形式化了


基于客观数据的评审方式


简单分析了一下后,基于客观数据相对公正的评审方式可看如下例子


日常任务得分 * 权重系数 + 直系领导打分 * 权重系数 + 部门领导/CXO 打分 * 权重系统 + 人事考勤得分 * 权重系数 + 其他得分


日常任务得分权重 > 领导打分权重 > ...


单纯衡量技术人的工作的话,应该主要以产出为主,其他环节占比相对小一点


相对来讲这种方式各方都有参与环节,基于数据数据说话是比较客观公正的


以上都是理想状态,一个公司的做事风格和方式和创始人有着直接关系,最终落地后的是个什么东西还得看公司老板和高管是如何做的


写在最后


这个世界没有绝对的公平合理



欢迎大家讨论交流,如果喜欢本文章或感觉文章有用,动动你那发财的小手点赞、收藏、关注再走呗 ^_^ 


微信公众号:草帽Lufei




作者:草帽lufei
来源:juejin.cn/post/7337630368907198502
收起阅读 »

麻了,一个操作把MySQL主从复制整崩了

前言 最近公司某项目上反馈mysql主从复制失败,被运维部门记了一次大过,影响到了项目的验收推进,那么究竟是什么原因导致的呢?而主从复制的原理又是什么呢?本文就对排查分析的过程做一个记录。 主从复制原理 我们先来简单了解下MySQL主从复制的原理。 主库m...
继续阅读 »

前言


最近公司某项目上反馈mysql主从复制失败,被运维部门记了一次大过,影响到了项目的验收推进,那么究竟是什么原因导致的呢?而主从复制的原理又是什么呢?本文就对排查分析的过程做一个记录。


主从复制原理


我们先来简单了解下MySQL主从复制的原理。




  1. 主库master 服务器会将 SQL 记录通过 dump 线程写入到 二进制日志binary log 中;

  2. 从库slave 服务器开启一个 io thread 线程向服务器发送请求,向 主库master 请求 binary log。主库master 服务器在接收到请求之后,根据偏移量将新的 binary log 发送给 slave 服务器。

  3. 从库slave 服务器收到新的 binary log 之后,写入到自身的 relay log 中,这就是所谓的中继日志。

  4. 从库slave 服务器,单独开启一个 sql thread 读取 relay log 之后,写入到自身数据中,从而保证主从的数据一致。


以上是MySQL主从复制的简要原理,更多细节不展开讨论了,根据运维反馈,主从复制失败主要在IO线程获取二进制日志bin log超时,一看主数据库的binlog日志竟达到了4个G,正常情况下根据配置应该是不超过300M。



binlog写入机制


想要了解binlog为什么达到4个G,我们来看下binlog的写入机制。


binlog的写入时机也非常简单,事务执行过程中,先把日志写到 binlog cache ,事务提交的时候,再把binlog cache写到binlog文件中。因为一个事务的binlog不能被拆开,无论这个事务多大,也要确保一次性写入,所以系统会给每个线程分配一个块内存作为binlog cache




  1. 上图的write,是指把日志写入到文件系统的page cache,并没有把数据持久化到磁盘,所以速度比较快

  2. 上图的fsync,才是将数据持久化到磁盘的操作, 生成binlog日志中


生产上MySQL中binlog中的配置max_binlog_size为250M, 而max_binlog_size是用来控制单个二进制日志大小,当前日志文件大小超过此变量时,执行切换动作。,该设置并不能严格控制Binlog的大小,尤其是binlog比较靠近最大值而又遇到一个比较大事务时,为了保证事务的完整性,可能不做切换日志的动作,只能将该事务的所有$QL都记录进当前日志,直到事务结束。一般情况下可采取默认值。


所以说怀疑是不是遇到了大事务,因而我们需要看看binlog中的内容具体是哪个事务导致的。


查看binlog日志


我们可以使用mysqlbinlog这个工具来查看下binlog中的内容,具体用法参考官网:https://dev.mysql.com/doc/refman/8.0/en/mysqlbinlog.html



  1. 查看binlog日志


./mysqlbinlog --no-defaults --base64-output=decode-rows -vv /mysqldata/mysql/binlog/mysql-bin.004816|more


  1. 以事务为单位统计binlog日志文件中占用的字节大小


./mysqlbinlog --no-defaults --base64-output=decode-rows -vv /mysqldata/mysql/binlog/mysql-bin.004816|grep GTID -B1|grep '^# at' | awk '{print $3}' | awk 'NR==1 {tmp=$1} NR>1 {print ($1-tmp, tmp, $1); tmp=$1}'|sort -n -r|more


生产中某个事务竟然占用4个G。



  1. 通过start-positionstop-position统计这个事务各个SQL占用字节大小


./mysqlbinlog --no-defaults --base64-output=decode-rows --start-position='xxxx' --stop-position='xxxxx' -vv /mysqldata/mysql/binlog/mysql-bin.004816 |grep '^# at'| awk '{print $3}' | awk 'NR==1 {tmp=$1} NR>1 {print ($1-tmp, tmp, $1); tmp=$1}'|sort -n -r|more


发现最大的一个SQL竟然占用了32M的大小,那超过10M的大概有多少个呢?



  1. 通过超过10M大小的数量


./mysqlbinlog --no-defaults --base64-output=decode-rows --start-position='xxxx' --stop-position='xxxxx' -vv /mysqldata/mysql/binlog/mysql-bin.004816|grep '^# at' | awk '{print $3}' | awk 'NR==1 {tmp=$1} NR>1 {print ($1-tmp, tmp, $1); tmp=$1}'|awk '$1>10000000 {print $0}'|wc -l


统计结果显示竟然有200多个,毛估一下,也有近4个G了



  1. 根据pos, 我们看下究竟是什么SQL导致的


./mysqlbinlog --no-defaults --base64-output=decode-rows --start-position='xxxx' --stop-position='xxxxx' -vv /mysqldata/mysql/binlog/mysql-bin.004816|grep '^# atxxxx' -C5| grep -v '###' | more


根据sql,分析了下,这个表正好有个blob字段,统计了下blob字段总合大概有3个G大小,然后我们业务上有个导入操作,这是一个非常大的事务,会频繁更新这表中记录的更新时间,导致生成binlog非常大。


问题: 明明只是简单的修改更新时间的语句,压根没有动blob字段,为什么生产的binlog这么大?因为生产的binlog采用的是row模式。


binlog的模式


binlog日志记录存在3种模式,而生产使用的是row模式,它最大的特点,是很精确,你更新表中某行的任何一个字段,会记录下整行的内容,这也就是为什么blob字段都被记录到binlog中,导致binlog非常大。此外,binlog还有statementmixed两种模式。



  1. STATEMENT模式 ,基于SQL语句的复制



  • 优点: 不需要记录每一行数据的变化,减少binlog日志量,节约IO,提高性能。

  • 缺点: 由于只记录语句,所以,在statement level下 已经发现了有不少情况会造成MySQL的复制出现问题,主要是修改数据的时候使用了某些定的函数或者功能的时候会出现。



  1. ROW模式,基于行的复制


5.1.5版本的MySQL才开始支持,不记录每条sql语句的上下文信息,仅记录哪条数据被修改了,修改成什么样了。



  • 优点: binlog中可以不记录执行的sql语句的上下文相关的信息,仅仅只需要记录那一条被修改。所以rowlevel的日志内容会非常清楚的记录下每一行数据修改的细节。不会出现某些特定的情况下的存储过程或function,以及trigger的调用和触发无法被正确复制的问题

  • 缺点: 所有的执行的语句当记录到日志中的时候,都将以每行记录的修改来记录,会产生大量的日志内容。



  1. MIXED模式


从5.1.8版本开始,MySQL提供了Mixed格式,实际上就是StatementRow的结合。


Mixed模式下,一般的语句修改使用statment格式保存binlog。如一些函数,statement无法完成主从复制的操作,则采用row格式保存binlog


总结


最终分析下来,我们定位到原来是由于大事务+blob字段大致binlog非常大,最终我们采用了修改业务代码,将blob字段单独拆到一张表中解决。所以,在设计开发过程中,要尽量避免大事务,同时在数据库建模的时候特别考虑将blob字段独立成表。


作者:JAVA旭阳
来源:juejin.cn/post/7231473194339532861
收起阅读 »

使用java自己简单搭建内网穿透

思路 内网穿透是一种网络技术,适用于需要远程访问本地部署服务的场景,比如你在家里搭建了一个网站或者想远程访问家里的电脑。由于本地部署的设备使用私有IP地址,无法直接被外部访问,因此需要通过公网IP实现访问。通常可以通过购买云服务器获取一个公网IP来实现这一目的...
继续阅读 »

思路


内网穿透是一种网络技术,适用于需要远程访问本地部署服务的场景,比如你在家里搭建了一个网站或者想远程访问家里的电脑。由于本地部署的设备使用私有IP地址,无法直接被外部访问,因此需要通过公网IP实现访问。通常可以通过购买云服务器获取一个公网IP来实现这一目的。


实际上,内网穿透的原理是将位于公司或其他工作地点的私有IP数据发送到云服务器(公网IP),再从云服务器发送到家里的设备(私有IP)。从私有IP到公网IP的连接是相对简单的,但是从公网IP到私有IP就比较麻烦,因为公网IP无法直接找到私有IP。


为了解决这个问题,我们可以让私有IP主动连接公网IP。这样,一旦私有IP连接到了公网IP,公网IP就知道了私有IP的存在,它们之间建立了连接关系。当公网IP收到访问请求时,就会通知私有IP有访问请求,并要求私有IP连接到公网IP。这样一来,公网IP就建立了两个连接,一个是用于访问的连接,另一个是与私有IP之间的连接。最后,通过这两个连接之间的数据交换,实现了远程访问本地部署服务的目的。


代码操作


打开IDEA创建一个mave项目,删除掉src,创建两个模块clientservice,一个是在本地的运行,一个是在云服务器上运行的,这边socket(tcp)连接,我使用的是AIO,AIO的函数回调看起来好复杂。


先编写service服务端,创建两个ServerSocket服务,一个是监听16000的,用来外来连接的,另一是监听16088是用来client访问的,也就是给serviceclient之间交互用的。先讲一个extListener他是监听16000,当有外部请求来时,也就是在公司访问时,先判断registerChannel是不是有clientservice,没有就关闭连接。有的话就下发指令告诉client有访问了赶快给我连接,连接会存在channelQueue队列里,拿到连接后,两个连接交换数据就行。


private static final int extPort = 16000;
private static final int clintPort = 16088;


private static AsynchronousSocketChannel registerChannel;

static BlockingQueue<AsynchronousSocketChannel> channelQueue = new LinkedBlockingQueue<>();

public static void main(String[] args) throws IOException {

final AsynchronousServerSocketChannel listener =
AsynchronousServerSocketChannel.open().bind(new InetSocketAddress("192.168.1.10", clintPort));

listener.accept(null, new CompletionHandler<AsynchronousSocketChannel, Void>() {
public void completed(AsynchronousSocketChannel ch, Void att) {

// 接受连接,准备接收下一个连接
listener.accept(null, this);

// 处理连接
clintHandle(ch);
}

public void failed(Throwable exc, Void att) {
exc.printStackTrace();
}
});


final AsynchronousServerSocketChannel extListener =
AsynchronousServerSocketChannel.open().bind(new InetSocketAddress("localhost", extPort));

extListener.accept(null, new CompletionHandler<AsynchronousSocketChannel, Void>() {

private Future<Integer> writeFuture;

public void completed(AsynchronousSocketChannel ch, Void att) {
// 接受连接,准备接收下一个连接
extListener.accept(null, this);

try {
//判断是否有注册连接
if(registerChannel==null || !registerChannel.isOpen()){
try {
ch.close();
} catch (IOException e) {
e.printStackTrace();
}
return;
}
//下发指令告诉需要连接
ByteBuffer bf = ByteBuffer.wrap(new byte[]{1});
if(writeFuture != null){
writeFuture.get();
}
writeFuture = registerChannel.write(bf);

AsynchronousSocketChannel take = channelQueue.take();

//clint连接失败的
if(take == null){
ch.close();
return;
}

//交换数据
exchangeDataHandle(ch,take);

} catch (Exception e) {
e.printStackTrace();
}

}

public void failed(Throwable exc, Void att) {
exc.printStackTrace();
}
});

Scanner in = new Scanner(System.in);
in.nextLine();


}

看看clintHandle方法是怎么存进channelQueue里的,很简单client发送0,就认为他是注册的连接,也就交互的连接直接覆盖registerChannel,发送1的话就是用来交换数据的,扔到channelQueue,发送2就异常的连接。


private static void clintHandle(AsynchronousSocketChannel ch) {

final ByteBuffer buffer = ByteBuffer.allocate(1);
ch.read(buffer, null, new CompletionHandler<Integer, Void>() {
public void completed(Integer result, Void attachment) {
buffer.flip();
byte b = buffer.get();
if (b == 0) {
registerChannel = ch;
} else if(b == 1){
channelQueue.offer(ch);
}else{
//clint连接不到
channelQueue.add(null);
}

}

public void failed(Throwable exc, Void attachment) {
exc.printStackTrace();
}
});
}

再编写client客户端,dstHostdstPort是用来连接service的ip和端口,看起来好长,实际上就是client连接service,第一个连接成功后向service发送了个0告诉他是注册的连接,用来交换数据。当这个连接收到service发送的1时,就会创建新的连接去连接service


private static final String dstHost = "192.168.1.10";
private static final int dstPort = 16088;

private static final String srcHost = "localhost";
private static final int srcPort = 3389;


public static void main(String[] args) throws IOException {

System.out.println("dst:"+dstHost+":"+dstPort);
System.out.println("src:"+srcHost+":"+srcPort);

//使用aio
final AsynchronousSocketChannel client = AsynchronousSocketChannel.open();

client.connect(new InetSocketAddress(dstHost, dstPort), null, new CompletionHandler<Void, Void>() {
public void completed(Void result, Void attachment) {
//连接成功
byte[] bt = new byte[]{0};
final ByteBuffer buffer = ByteBuffer.wrap(bt);
client.write(buffer, null, new CompletionHandler<Integer, Void>() {
public void completed(Integer result, Void attachment) {

//读取数据
final ByteBuffer buffer = ByteBuffer.allocate(1);
client.read(buffer, null, new CompletionHandler<Integer, Void>() {
public void completed(Integer result, Void attachment) {
buffer.flip();

if (buffer.get() == 1) {
//发起新的连
try {
createNewClient();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
buffer.clear();
// 这里再次调用读取操作,实现循环读取
client.read(buffer, null, this);
}

public void failed(Throwable exc, Void attachment) {
exc.printStackTrace();
}
});


}

public void failed(Throwable exc, Void attachment) {
exc.printStackTrace();
}
});


}

public void failed(Throwable exc, Void attachment) {
exc.printStackTrace();
}
});
Scanner in = new Scanner(System.in);
in.nextLine();

}

createNewClient方法,尝试连接本地服务,如果失败就发送2,成功就发送1,这个会走 serviceclintHandle方法,成功的话就会让两个连接交换数据。


private static void createNewClient() throws IOException {

final AsynchronousSocketChannel dstClient = AsynchronousSocketChannel.open();
dstClient.connect(new InetSocketAddress(dstHost, dstPort), null, new CompletionHandler<Void, Void>() {
public void completed(Void result, Void attachment) {

//尝试连接本地服务
final AsynchronousSocketChannel srcClient;
try {
srcClient = AsynchronousSocketChannel.open();
srcClient.connect(new InetSocketAddress(srcHost, srcPort), null, new CompletionHandler<Void, Void>() {
public void completed(Void result, Void attachment) {

byte[] bt = new byte[]{1};
final ByteBuffer buffer = ByteBuffer.wrap(bt);
Future<Integer> write = dstClient.write(buffer);
try {
write.get();
//交换数据
exchangeData(srcClient, dstClient);
exchangeData(dstClient, srcClient);
} catch (Exception e) {
closeChannels(srcClient, dstClient);
}


}

public void failed(Throwable exc, Void attachment) {
exc.printStackTrace();
//失败
byte[] bt = new byte[]{2};
final ByteBuffer buffer = ByteBuffer.wrap(bt);
dstClient.write(buffer);
}
});

} catch (IOException e) {
e.printStackTrace();
//失败
byte[] bt = new byte[]{2};
final ByteBuffer buffer = ByteBuffer.wrap(bt);
dstClient.write(buffer);
}

}

public void failed(Throwable exc, Void attachment) {
exc.printStackTrace();
}
});
}

下面是exchangeData交换数据方法,看起好麻烦,效果就类似IOUtils.copy(InputStream,OutputStream),一个流写入另一个流。


private static void exchangeData(AsynchronousSocketChannel ch1, AsynchronousSocketChannel ch2) {
try {
final ByteBuffer buffer = ByteBuffer.allocate(1024);

ch1.read(buffer, null, new CompletionHandler<Integer, CompletableFuture<Integer>>() {

public void completed(Integer result, CompletableFuture<Integer> readAtt) {

CompletableFuture<Integer> future = new CompletableFuture<>();

if (result == -1 || buffer.position() == 0) {
// 处理连接关闭的情况或者没有数据可读的情况

try {
readAtt.get(3,TimeUnit.SECONDS);
} catch (Exception e) {
e.printStackTrace();
}

closeChannels(ch1, ch2);
return;
}

buffer.flip();

CompletionHandler readHandler = this;

ch2.write(buffer, future, new CompletionHandler<Integer, CompletableFuture<Integer>>() {
@Override
public void completed(Integer result, CompletableFuture<Integer> writeAtt) {

if (buffer.hasRemaining()) {
// 如果未完全写入,则继续写入
ch2.write(buffer, writeAtt, this);

} else {
writeAtt.complete(1);
// 清空buffer并继续读取
buffer.clear();
if(ch1.isOpen()){
ch1.read(buffer, writeAtt, readHandler);
}
}

}

@Override
public void failed(Throwable exc, CompletableFuture<Integer> attachment) {
if(!(exc instanceof AsynchronousCloseException)){
exc.printStackTrace();
}
closeChannels(ch1, ch2);
}
});

}

public void failed(Throwable exc, CompletableFuture<Integer> attachment) {
if(!(exc instanceof AsynchronousCloseException)){
exc.printStackTrace();
}
closeChannels(ch1, ch2);
}
});

} catch (Exception ex) {
ex.printStackTrace();
closeChannels(ch1, ch2);
}

}

private static void closeChannels(AsynchronousSocketChannel ch1, AsynchronousSocketChannel ch2) {
if (ch1 != null && ch1.isOpen()) {
try {
ch1.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (ch2 != null && ch2.isOpen()) {
try {
ch2.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}

测试


我这边就用虚拟机来测试,用云服务器就比较麻烦,得登录账号,增加开放端口规则,上传代码。我这边用Hyper-V快速创建了虚拟机,创建一个windows 10 MSIX系统,安装JDK8,下载地址:http://www.azul.com/downloads/?… 。怎样把本地编译好的class放到虚拟机呢,虚拟机是可以访问主机ip的,我们可以弄一个web的文件目录下载给虚拟机访问,人生苦短我用pyhton,下面python简单代码


if __name__ == '__main__':
# 定义服务器的端口
PORT = 8000

# 创建请求处理程序
Handler = http.server.SimpleHTTPRequestHandler

# 设置工作目录
os.chdir("C:\netTunnlDemo\client\target")

# 创建服务器
with socketserver.TCPServer(("", PORT), Handler) as httpd:
print(f"服务启动在端口 {PORT}")
httpd.serve_forever()

到class的目录下运行cmd,执行java -cp . org.example.Main,windows 默认远程端口3389。


最后效果


QQ截图20240225075018.png


总结


使用AIO导致代码长,逻辑并不复杂,完整代码,供个人学习:断续/netTunnlDemo (gitee.com)


作者:cloudy491
来源:juejin.cn/post/7338973258895802431
收起阅读 »

小程序使用有赞 UI 库

web
引入有赞 UI 库 1、初始化 npm 在小程序 package.json 所在的目录(代码根目录)中执行下面命令,进行初始化: npm init ps. 这里一路按 enter 键就可以了,命令窗口可以在目录下通过 shift + 鼠标右键选择 Powe...
继续阅读 »

引入有赞 UI 库


1、初始化 npm

在小程序 package.json 所在的目录(代码根目录)中执行下面命令,进行初始化:


npm init 

ps. 这里一路按 enter 键就可以了,命令窗口可以在目录下通过 shift + 鼠标右键选择 PowerShell 打开 。


2、安装 Vant 包

在上面的基础上,输入下面命令,安装有赞 UI 库:


npm i vant-weapp -S --production

如果这里报 rollbackFailedOptional 错误,可以试试修改 npm 的资源镜像链接,输入下面命令:


npm config set registry http://registry.npm.taobao.org

然后再执行上面安装命令,应该就可以了。


在这里插入图片描述


3、使用 npm 模块

点击微信开发者工具右上角详情,选择本地设置,勾选上下面的 “使用 npm 模块”


在这里插入图片描述


4、构建 npm

点击开发者工具中的菜单栏:工具 --> 构建 npm


在这里插入图片描述


5、修改 app.json

将 app.json 中的 "style": "v2" 去除


在这里插入图片描述


6、修改 project.config.json

在根目录下的 project.config.json 文件中,通过 ctrl + f 搜索 packNpmManually ,修改配置,使开发者工具可以正确索引到 npm 依赖的位置。


在这里插入图片描述


改成如下图


在这里插入图片描述
代码如下:


        "packNpmManually": true,
"packNpmRelationList": [
{
"packageJsonPath": "./package.json",
"miniprogramNpmDistDir": "./"
}
],



到这应该就完成安装了,下面看看使用。


使用有赞 UI 库


1、引入控件

在 app.json (或 Page 的 json)中引入控件


"usingComponents": {
"van-button": "@vant/weapp/button/index"
}

2、使用控件

引入组件后,可以在 wxml 中直接使用组件


<van-button type="primary">按钮</van-button>

示例


这里拿一个 Dialog 弹出框作为示例,因为官方文档有问题,在 Page 中引入错了,真的是把我坑到了。


1、引入 Dialog 控件

app.jsonindex.json中引入组件


"usingComponents": {
"van-dialog": "@vant/weapp/dialog/index"
}

2、在 WXML 中设置 Dialog

这里有两种用法,一种是把 Dialog 当布局组件使用,一种是像 wx.showModel 一样弹出对话框,无论哪种都要在 WXML 中写 van-dialog。


这里以后一种用法为例,在 WXML 中随便找个地方,填入下面代码:


<van-dialog id="van-dialog" />

3、在 Page 中使用

先引入组件,就是这里把我坑了,主要就是没有 dist 这个目录了,有赞也不提示。


//import Dialog from 'path/to/@vant/weapp/dist/dialog/dialog';
import Dialog from '../../../miniprogram_npm/@vant/weapp/dialog/dialog';

这里用的相对路径,后面路径是对的,需要直接改下!


在需要的时候像下面一样使用就可以,不过我是觉得还不如微信自带的好看哦!


Dialog.confirm({
title: '标题',
message: '弹窗内容',
})
.then(() => {
// on confirm
})
.catch(() => {
// on cancel
});

4、在 Dialog 中有原生控件

这里提一下,如果 Dialog 中有原生控件,消失的时候原生控件回后消失,很奇怪。例如对话框里面放了一个 canvas 来显示二维码,关闭对话框时,对话框消失了,二维码延迟一会才消失,这时候可以通过变量,先隐藏二维码,再隐藏对话框,有这个思路,就能解决了。


结语


如果不想自己设计各种控件的话,用有赞的 UI 库还是很方便的,但是如果给了设计图,要改这些控件还是有点麻烦。


作者:方大可
来源:juejin.cn/post/7222897518500708407
收起阅读 »

新项目跑不起来,人和项目总得有一个能跑

web
前言 有人说面试造火箭,进去拧螺丝;其实个人觉得问的问题是项目中涉及的点 || 热门的技术栈都是很好的面试体验,不要是旁门左道冷门的知识,实际上并不会用到的。 进入新公司或者接手新项目,都会遇到如何将项目跑起,跑不起的话,可能就得人跑了。 每个人的花期不...
继续阅读 »

前言


  • 有人说面试造火箭,进去拧螺丝;其实个人觉得问的问题是项目中涉及的点 || 热门的技术栈都是很好的面试体验,不要是旁门左道冷门的知识,实际上并不会用到的。

  • 进入新公司或者接手新项目,都会遇到如何将项目跑起,跑不起的话,可能就得人跑了。





每个人的花期不同。


就像万特特在《这世界很好,你也不差》中讲的,每个人花期不同,不必焦虑有人比你提前拥有。



1.jpg


一、问题剖析


那是一个风和日丽的早上,我想要去看漫天霞光。


要和心上人手挽手走在街上,


清晨的花香,傍晚的夕阳。


正当我沉迷于甜蜜的幻想中,前同事发来:我遇到一个问题,帮我看看呗!


真是的,慌慌张张的,说吧~


问题是这样子的:


现在有一个新项目,B项目从A项目复制过去,在原有的功能上扩展。


我又多嘴的问了一句,为啥要复制一份过去啊!


他回道:因为现在要做国际化的需求,之前的项目没有考虑到,改起来很麻烦,打算先拷贝一份出来,通过java程序执行一下,先把大部分的先转化一下,这样子不会影响原本项目的开发。


我又对嘴了一下,开新分支不可以嘛!


他说:因为是给两个地区用的,可能在A页面,两个区的需求不同,改起来也麻烦,需求不同,就没必要硬写在一起了。


懂了!


二、gitlab仓库中有两种方式拉取代码仓库:ssh和https


2.png


ssh


首先,确保你已经在 GitLab 上配置了 SSH 密钥。如果没有,你需要生成 SSH 密钥并将公钥添加到 GitLab 的个人设置中。


https


使用 HTTPS 协议拉取代码需要输入你的 GitLab 用户名和密码,或者是访问令牌(Access Token)。


两者



  • 使用 SSH 协议可以避免频繁输入用户名和密码,但需要提前配置好 SSH 密钥。

  • 使用 HTTPS 协议在拉取代码时需要提供用户名和密码或者访问令牌,相对来说更加方便快捷。


一般来说,都是用https拉取仓库,但我怎么拉都拉不下来。


提示:


SSL certificate problem: self signed certificate

翻译:SSL证书问题:自签名证书

于是,我尝试运行ssh的方式拉取。


我们首先需要先生成秘钥


ssh-keygen -t rsa -C “your_email@youremail.com”  
// 命令中的email,就是gitlab中的账号,需要保持一致

直接三个Enter就行,然后会提示输入密码(可输可不输)


3.png


在~/.ssh/下会生成两个文件,id_rsa和id_rsa.pub


4.png


id_rsa是私钥

id_rsa.pub是公钥

在我们c盘的用户里面有个.ssh文件夹


C:\Users\Lenovo\.ssh

gitlab添加秘钥


5.png


这样子就配置好了,可以通过ssh方式拉取了。


git clone 项目远程仓库ssh地址

项目已拉取。


此时要推送的时候,一直让我输入密码,但我输入完之后,仍然要求输入。


我使用的是tortoisegit


连接gitlab,总是弹出git@xxx.com’s password 对话框


然后打开TortoiseGit设置,如下图进入相应页面,选择相对路径的ssh.exe文件


6.png


查找git在哪里,只需要输入 where git


7.png


到这里项目已经拉取,也能推送了。


接下来运行项目。


三、运行的时候,先安装依赖,发现npm安装失败。


发现npm i


8.png


于是,我尝试cnpm i安装,安装是可以安装,但npm run serve的时候还是报错了。


四、于是,我找同事要来了他本地的依赖,想着在我本地看看能不能跑起来。


但因我和同事的环境(npm、node)环境不一样。


于是,我和他保持一致的版本node:12


但又因为还是有些不同,npm run serve的时候,报了:


error  in ./src/styles/element-variables.scss  
Syntax Error: Error: Missing binary. See message above.

看起来是,sass的问题,于是想着重新安装一下


一般来说sass、node-sass问题经常会出现。


npm install --save-dev sass-loader node-sass

仍然不行,那切换一下node的版本,从node10-node14都轮流切换尝试都不行。


我使用的是nvm管理node版本。


9.png


但点进去看只有10.14.1版本有node_modules文件夹依赖


10.png


其他的版本貌似没有,本来想着和同事保持12版本的node,然后把他的依赖复制给我,但我本地的node12没有node_modules文件夹


关于上面的scss文件引起的:Syntax Error: Error: Missing binary. See message above.


下载fibers


运行的时候报错:而且会弹出框说:中止/忽略(其实是fibers缺少二进制文件执行)


Try running this to fix the issue: D:\Program Files\nodejs\node.exe E:\vue-project\node_modules\fibers/build
Error: Cannot find module 'E:\vue-project\node_modules\fibers\bin\win32-x64-83\fibers'
Require stack:

我们发现了fibers引起的错误


有回答说:


项目node_moudules/fibers/bin文件夹中没有win32-x64-83模块,缺少win32-x64-83文件夹下的fibers.node。


11.png


1.在github下载对应系统版本的node文件


win32-x64-83_binding.node 文件下载地址:github-releases


2.下载后的win32-x64-83_binding.node文件改名为fibers.node。


3.保存fibers.node在项目node_moudules/fibers/bin新建的win32-x64-83文件夹中。


4.然后重新执行run serve就可以


rebuild node-sass

执行


npm rebuild node-sass

如果提示 stack Error: EACCES: permission denied, mkdir 错误,则执行命令:


npm rebuild node-sass --unsafe-perm

失败告终!



但我简单粗暴直接把fibers文件夹给删除重新跑就可以了。(√)



五、后面想了一下,项目的背景,是从A项目复制过来的,那我把A项目的依赖复制过来运行下看看。


复制过来后,可以运行了,但因为B新项目装了vue-i18n国际化依赖,于是我安装一下,运行,终于可以了。


但项目打开页面的时候,还是报错了。


12.webp


看起来,vue-router版本和i18n版本冲突了


我的解决办法是把i18n的版本 改为了 8.26.7 ,再启动项目就可以了


npm install vue-i18n@8.26.7 -S

虽然B项目的package.json写着是版本9的,但这不影响我运行。


只不过在页面用到这个依赖的时候可能会有一些不同。


完成搞定。


后记


那晚,我们一起找了家烧烤店,他说:今日之事,都在酒里了,一口闷。


道不清,理还乱,别是一般滋味在心头~


刚开始接手一个项目的时候,依赖啊,版本啊,什么的都很头疼,但一步一步来,见招拆招,无非就是node版本、npm版本、cnpm他们的故事。


如果有其他更好的方法也欢迎评论区见,这里提供的只是诸多方法之一。



作者:Dignity_呱
来源:juejin.cn/post/7339376488028307456
收起阅读 »

MyBatis实现多行合并(collection标签使用)

一、举个栗子 现有如下表结构,用户表、角色表、用户角色关联表。 一个用户有多个角色,一个角色有可以给多个用户,也即常见的多对多场景。 现有这样一个需求,分页查询用户数据,除了用户ID和用户名称字段,还要查出这个用户的所有角色。 从上面的表格我们可以看出,用...
继续阅读 »

一、举个栗子


现有如下表结构,用户表、角色表、用户角色关联表。
一个用户有多个角色,一个角色有可以给多个用户,也即常见的多对多场景
在这里插入图片描述


现有这样一个需求,分页查询用户数据,除了用户ID和用户名称字段,还要查出这个用户的所有角色
在这里插入图片描述
从上面的表格我们可以看出,用户有三个,但每个人的角色不止一个,而且有重复的角色,这里角色的数据从多行合并到了1行


二、难点分析


SQL存在的问题:



想使用SQL实现上面的效果不是不可以,但是很复杂且效率低下,尤其这个地方还需要分页,所以为了保证查询效率,我们需要把逻辑放到服务端来写;



服务端存在的问题:



服务端可以把需要的数据都查询出来,然后自己判断整合,首先十分复杂不说,而且这里有个问题:如果角色也是一个查询条件如何处理呢?



三、解决方案


核心方案就是使用Mybatis的collection标签自动实现多行合并。


下面是collection标签的一些介绍
在这里插入图片描述


常见写法


<resultMap id="ExtraBaseResultMap" type="com.example.mybatistest.entity.UserInfoDO">
<!--
WARNING - @mbg.generated
-->

<result column="user_id" jdbcType="INTEGER" property="userId"/>
<result column="user_name" jdbcType="INTEGER" property="userName"/>
<collection javaType="java.util.ArrayList" ofType="com.example.mybatistest.entity.MyRole"
property="roleList">

<result column="role_id" jdbcType="INTEGER" property="roleId"/>
<result column="role_name" jdbcType="VARCHAR" property="roleName"/>
</collection>
</resultMap>

四、尝试一下


1. 准备材料


(1)数据库脚本


/*
Navicat Premium Data Transfer

Source Server : 本机
Source Server Type : MySQL
Source Server Version : 80021
Source Host : localhost:3306
Source Schema : mybatis-test

Target Server Type : MySQL
Target Server Version : 80021
File Encoding : 65001

Date: 23/06/2022 19:16:34
*/


SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for my_role
-- ----------------------------
DROP TABLE IF EXISTS `my_role`;
CREATE TABLE `my_role` (
`role_id` int NOT NULL COMMENT '角色主键',
`role_code` varchar(32) DEFAULT NULL COMMENT '角色code',
`role_name` varchar(32) DEFAULT NULL COMMENT '角色名称',
PRIMARY KEY (`role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

-- ----------------------------
-- Records of my_role
-- ----------------------------
BEGIN;
INSERT INTO `my_role` VALUES (1, 'admin', '超级管理员');
INSERT INTO `my_role` VALUES (2, 'visitor', '游客');
COMMIT;

-- ----------------------------
-- Table structure for my_user
-- ----------------------------
DROP TABLE IF EXISTS `my_user`;
CREATE TABLE `my_user` (
`user_id` int NOT NULL COMMENT '用户主键',
`user_name` varchar(32) DEFAULT NULL COMMENT '用户名称',
`user_gender` tinyint DEFAULT NULL COMMENT '用户性别,1:男/2:女/3:未知',
PRIMARY KEY (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

-- ----------------------------
-- Records of my_user
-- ----------------------------
BEGIN;
INSERT INTO `my_user` VALUES (1, '用户1', 1);
COMMIT;

-- ----------------------------
-- Table structure for my_user_role_rel
-- ----------------------------
DROP TABLE IF EXISTS `my_user_role_rel`;
CREATE TABLE `my_user_role_rel` (
`rel_id` int NOT NULL COMMENT '角色主键',
`role_id` int DEFAULT NULL COMMENT '角色ID',
`user_id` int DEFAULT NULL COMMENT '用户ID',
PRIMARY KEY (`rel_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

-- ----------------------------
-- Records of my_user_role_rel
-- ----------------------------
BEGIN;
INSERT INTO `my_user_role_rel` VALUES (1, 1, 1);
INSERT INTO `my_user_role_rel` VALUES (2, 2, 1);
COMMIT;

SET FOREIGN_KEY_CHECKS = 1;


(2)pom.xml


<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">

<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.0</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>mybatis-test</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>mybatis-test</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.16.22</version>
</dependency>
<!-- 我这里使用的是mybatis-plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.3.2</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-extension</artifactId>
<version>3.3.2</version>
</dependency>
<dependency>
<groupId>javax.persistence</groupId>
<artifactId>javax.persistence-api</artifactId>
</dependency>

<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>

</project>


(3)application.properties


# 数据库配置
spring.datasource.type=com.zaxxer.hikari.HikariDataSource
spring.datasource.url=jdbc:mysql://localhost:3306/mybatis-test?characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true
spring.datasource.username=root
spring.datasource.password=xxx
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

# mybatis
mybatis.configuration.auto-mapping-behavior=full
mybatis.configuration.map-underscore-to-camel-case=true
mybatis-plus.mapper-locations=classpath*:/mybatis/mapper/*.xml

2. 项目代码


(1)目录结构


在这里插入图片描述


(2)各类代码


MybatisTestApplication.java


import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
@MapperScan({"com.example.mybatistest.mapper"})
public class MybatisTestApplication {

public static void main(String[] args) {
SpringApplication.run(MybatisTestApplication.class, args);
}

}


QueryController.java


import com.example.mybatistest.entity.UserInfoDO;
import com.example.mybatistest.service.UserRoleRelService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
@RequestMapping("/mybatis")
public class QueryController {

@Autowired
private UserRoleRelService userRoleRelService;

@GetMapping("/queryList")
public List<UserInfoDO> queryList() {
return userRoleRelService.queryList();
}
}

UserRoleRelService.java


import com.example.mybatistest.entity.UserInfoDO;

import java.util.List;

public interface UserRoleRelService {
List<UserInfoDO> queryList();
}

UserRoleRelServiceImpl.java


import com.example.mybatistest.entity.UserInfoDO;
import com.example.mybatistest.repository.MyUserRoleRelRepository;
import com.example.mybatistest.service.UserRoleRelService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class UserRoleRelServiceImpl implements UserRoleRelService {

@Autowired
private MyUserRoleRelRepository myUserRoleRelRepository;

@Override
public List<UserInfoDO> queryList() {
return myUserRoleRelRepository.queryList();
}
}

MyUserRoleRelRepository.java


import com.example.mybatistest.entity.UserInfoDO;

import java.util.List;

public interface MyUserRoleRelRepository {
List<UserInfoDO> queryList();
}

MyUserRoleRelRepositoryImpl.java


import com.example.mybatistest.entity.UserInfoDO;
import com.example.mybatistest.mapper.MyUserRoleRelMapper;
import com.example.mybatistest.repository.MyUserRoleRelRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public class MyUserRoleRelRepositoryImpl implements MyUserRoleRelRepository {
@Autowired
public MyUserRoleRelMapper myUserRoleRelMapper;

@Override
public List<UserInfoDO> queryList() {
return myUserRoleRelMapper.queryList();
}
}

MyUserRoleRelMapper.java


import com.example.mybatistest.entity.UserInfoDO;
import org.apache.ibatis.annotations.Mapper;

import java.util.List;

@Mapper
public interface MyUserRoleRelMapper {
List<UserInfoDO> queryList();
}

MyRole.java


import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.Column;


@Builder
@Data
@TableName("my_role")
@NoArgsConstructor
@AllArgsConstructor
public class MyRole {

@Column(name = "role_id")
private Integer roleId;

@Column(name = "role_name")
private String roleName;
}

MyUserRoleRel.java


import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.Column;

@Builder
@Data
@TableName("my_user_role_rel")
@NoArgsConstructor
@AllArgsConstructor
public class MyUserRoleRel {

@Column(name = "rel_id")
private Integer relId;

@Column(name = "user_id")
private Integer userId;

@Column(name = "role_id")
private Integer roleId;
}

UserInfoDO.java


import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.List;

@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserInfoDO {
private Integer userId;

private String userName;

private List<MyRole> roleList;
}

MyUserRoleRelMapper.xml


<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.mybatistest.mapper.MyUserRoleRelMapper">
<resultMap id="BaseResultMap" type="com.example.mybatistest.entity.MyUserRoleRel">
<!--
WARNING - @mbg.generated
-->

<result column="rel_id" jdbcType="INTEGER" property="relId"/>
<result column="role_id" jdbcType="INTEGER" property="roleId"/>
<result column="user_id" jdbcType="INTEGER" property="userId"/>
</resultMap>
<resultMap id="ExtraBaseResultMap" type="com.example.mybatistest.entity.UserInfoDO">
<!--
WARNING - @mbg.generated
-->

<result column="user_id" jdbcType="INTEGER" property="userId"/>
<result column="user_name" jdbcType="INTEGER" property="userName"/>
<collection javaType="java.util.ArrayList" ofType="com.example.mybatistest.entity.MyRole"
property="roleList">

<result column="role_id" jdbcType="INTEGER" property="roleId"/>
<result column="role_name" jdbcType="VARCHAR" property="roleName"/>
</collection>
</resultMap>
<select id="queryList" resultMap="ExtraBaseResultMap">
SELECT
t3.user_id,
t3.user_name,
t2.role_id,
t2.role_name
FROM
my_user_role_rel t1
LEFT JOIN my_role t2 ON t1.role_id = t2.role_id
LEFT JOIN my_user t3 ON t1.user_id = t3.user_id
</select>
</mapper>

3. 实现效果


在这里插入图片描述


这里可以看到roleList里面有两条数据,说明mybatis已经自动聚合完成了。


4. 一些缺点


缺点1、查询条件不能支持很多


虽然Mybatis可以帮我们实现多行合并的功能,但并不是没有问题的。
当使用角色当做查询条件时,由于角色已经指定了,那么roleList里面必定只有这一个角色,不再会有聚合效果,也就看不到这个用户所有的角色了。
我只能说具体看产品要求吧,大部分时候上面那种问题产品都是可以接受的。


缺点2、不支持分页


这个缺点也看业务场景吧,产品可以接受就用,不能接受就别用,我这里只是介绍有这么一个办法。


作者:summo
来源:juejin.cn/post/7337849561479708735
收起阅读 »

微服务下,如何实现多设备同时登录或强制下线?

分享技术,用心生活 前言:你有没有遇到过这样的需求,产品要求实现同一个用户根据后台设置允许同时登录,或者不准同时登录时,需要强制踢下线前一个的场景。本文将带领大家实现一个简单的这种场景需求。 先来看一下简单的时序图,方便后续理解。 sequenceDi...
继续阅读 »

分享技术,用心生活





前言:你有没有遇到过这样的需求,产品要求实现同一个用户根据后台设置允许同时登录,或者不准同时登录时,需要强制踢下线前一个的场景。本文将带领大家实现一个简单的这种场景需求。





先来看一下简单的时序图,方便后续理解。


sequenceDiagram
用户->>过滤器: 请求登录
过滤器->>业务系统: 是否允许同时登录
业务系统-->>过滤器: 返回是/否
过滤器-->>用户: 登录成功(踢下线)

首先我们需要有一个后台设置开关来控制允不允许用户多设备同时登录的功能(没有也无妨,假定允许),其次在登录后,需要保存用户的userId-token的关系缓存。再回头看上面的时序图,是不是已经能理解实现的原理了。


如果你的架构是微服务,那么可以使用redis来存登录关系缓存,单体架构可以直接存session即可。本文是微服务架构,所以采用的是redis。


本文的前提都是基于同一个用户的情况下,下文不再赘述。


1 构造登录缓存关系


如果要实现同一用户多设备同时登录,那必然需要在session(微服务中可以用redis做session共享)中能找到用户的每一个登录状态,如果只是简单的缓存用户信息是实现不了的,登录时那就必须要有一个唯一值token,这样每次登录token不一样,但是指向的用户是同一个。


user


usertoken中维护的是前缀:用户id,这里不需要维护多个,因为用的reids的hash数据类型,多个登录时,添加新行即可;user部分,这里维护的是多个,即登录一次就有一条记录;因为根据业务需要,后续需要从缓存中获取用户其他信息。



  • 允许多设备同时登录:usertoken只有1条,user可能会有多条

  • 不允许多设备同时登录(有则强制下线):usertoken只有1条,user只有1条


    /**
* 登录成功后缓存用户信息
*
* @param req
* @return
*/

public void cacheUserInfo(CacheUserInfoReqDTO req) {
// 1、缓存用户信息
cacheUser(req);
cacheAuth(req.getUid(), req.getRoles(), req.getPermissions());

// 2、更新token与userId关系
String userTokenRelationKey = RedisKeyHelper.getUserTokenRelationKey(req.getEntId() + SymbolConstant.COLON + req.getUid());
redisAdapter.set(userTokenRelationKey, req.getToken(), RedisTtl.USER_LOGIN_SUCCESS);
}

2 过滤器配置


登录鉴权部分和用户登录状态上下文不在本文范围内,此处忽略


登录成功后,每一个请求到达过滤器时,通过请求header中的token来获取登录信息;因为我们存的缓存key前缀都包含userId,所以要想得到用户信息,需要使用到redis的scan命令来获取。(scan最好配置count来限制,保证性能


@Override
protected Mono<Void> innerFilter(ServerWebExchange exchange, WebFilterChain chain) {
String token = filterContext.getToken();
if (StringUtils.isBlank(token)) {
throw new DataValidateException(GatewayReturnCodes.TOKEN_MISSING);
}

// scan获取user的key
String userKey = "";
Set<String> scan = redisAdapter.scan(GatewayRedisKeyPrefix.USER_KEY.getKey() + "*" + token);
if (scan.isEmpty()) {
throw new DataValidateException(GatewayReturnCodes.TOKEN_EXPIRED_LOGIN_SUCCESS);
}
userKey = scan.iterator().next();

MyUser myUser = (MyUser) redisAdapter.get(userKey);
if (myUser == null) {
throw new BusinessException(GatewayReturnCodes.TOKEN_EXPIRED_LOGIN_SUCCESS);
}

// 将用户信息塞入http header
// do something...
return chain.filter(exchange.mutate().request(newServerHttpRequest).build());
}

这样保证即使有多设备同时登录,也能获取到登录信息和上下文。


3 如何做强制下线呢?


其实也很简单,在登录前可以通过AOP方式做校验,如果已登录了,那么这里就清除session或用户缓存,再继续进行正常登录即可。再简单一点可以直接在登录service中添加校验


核心逻辑


 String userTokenRelationKey = RedisKeyHelper.getUserTokenRelationKey(req.getEntId() + SymbolConstant.COLON + userEntList.get(0).getUserId());
String redisToken = (String) redisAdapter.get(userTokenRelationKey);
if (StringUtils.isNotEmpty(redisToken) && !redisToken.equals(token)) {
throw new BusinessException(UserReturnCodes.MULTI_DEVICE_LOGIN);
}

这里用于判断是否已有登录,并返回给前端提示。用于前端其他业务处理
如果不需要给前端提示,不用返回前端,直接进行清除session或用户缓存逻辑。


 String userTokenRelationKey = RedisKeyHelper.getUserTokenRelationKey(req.getEntId() + SymbolConstant.COLON + userEntity.getId());
// 获取当前已用户的登录token
String redisToken = (String) redisAdapter.get(userTokenRelationKey);
// 踢下线之前全部登录
Response<Void> exitLoginResponse = gatewayRpc.allExit(ExitLoginReqDTO.builder().token(redisToken).userId(userEntity.getId()).build());

4 演示



  • 演示强制下线


这里我用用户id为4做演示


先正常第一次登录,提示成功,并且redis中有1条user记录
redis_succ
redis_succ


再次登录,我这里是返回给前端处理了,所以会有提示信息。


login


前端效果


message


最后,扩展一下,如果要实现登录后强制修改默认密码、登录时间段限制等场景,你会怎么实现呢?


作者:临时工
来源:juejin.cn/post/7258155447831920700
收起阅读 »

前端更新部署后通知用户刷新

web
前言 周五晚上组里说前端有bug,正在吃宵夜的我眉头一紧,立即打开了钉钉(手贱...),看了一下这不是前几天刚解决的吗,果然,使用刷新大法就解决,原因不过是用户一直停留在页面上,新的版本发布后,没有刷新拿不到新的资源。 现在大部分的前端系统都是SPA,用户在使...
继续阅读 »

前言


周五晚上组里说前端有bug,正在吃宵夜的我眉头一紧,立即打开了钉钉(手贱...),看了一下这不是前几天刚解决的吗,果然,使用刷新大法就解决,原因不过是用户一直停留在页面上,新的版本发布后,没有刷新拿不到新的资源。


现在大部分的前端系统都是SPA,用户在使用中对系统更新无感知,切换菜单等并不能获取最新资源,如果前端是覆盖性部署,切换菜单请求旧资源,这个旧资源已经被覆盖(hash打包的文件),还会出现一直无响应的情况。


那么,当前端部署更新后,提示一直停留在系统中的用户刷新系统很有必要。


解决方案



  1. 在public文件夹下加入manifest.json文件,记录版本信息

  2. 前端打包的时候向manifest.json写入当前时间戳信息

  3. 在入口JS引入检查更新的逻辑,有更新则提示更新

    • 路由守卫router.beforeResolve(Vue-Router为例),检查更新,对比manifest.json文件的响应头Etag判断是否有更新

    • 通过Worker轮询,检查更新,对比manifest.json文件的响应头Etag判断是否有更新。当然你如果不在乎这点点开销,可不使用Worker另开一个线程




Public下的加入manifest.json文件


{
"timestamp":1706518420707,
"msg":"更新内容如下:\n--1.添加系统更新提示机制"
}

这里如果是不向用户提示更新内容,可不填,前段开发者也无需维护manifest.json的msg内容,这里主要考虑到如果用户在填长表单的时候,填了一大半,你这时候给用户弹个更新提示,用户无法判断是否影响当前表单填写提交,如果将更新信息展示出来,用户感知更新内容,可判断是否需要立即刷新,还是提交完表单再刷新。


webpack向manifest.json写入当前时间戳信息


	// 版本号文件
const filePath = path.resolve(`./public`, 'manifest.json')
// 读取文件内容
readFile(filePath, 'utf8', (err, data) => {
if (err) {
console.error('读取文件时出错:', err)
return
}
// 将文件内容转换JSON
const dataObj = JSON.parse(data)
dataObj.timestamp = new Date().getTime()
// 将修改后的内容写回文件
writeFile(filePath, JSON.stringify(dataObj), 'utf8', err => {
if (err) {
console.error('写入文件时出错:', err)
return
}
})
})

如果你无需维护更新内容的话,可直接写入timestamp


// 生成版本号文件
const filePath = path.resolve(`./public`, 'manifest.json')
writeFileSync(filePath, `${JSON.stringify({ timestamp: new Date().getTime() })}`)


检查更新的逻辑


入口文件main.js处引入


我这里检查更新的文件是放在utils/checkUpdate


// 检查版本更新
import '@/utils/checkUpdate'

checkUpdate文件内容如下


import router from '@/router'
import { Modal } from 'ant-design-vue'
if (process.env.NODE_ENV === 'production') {
let lastEtag = ''
let hasUpdate = false
let worker = null

async function checkUpdate() {
try {
// 检测前端资源是否有更新
let response = await fetch(`/manifest.json?v=${Date.now()}`, {
method: 'head'
})
// 获取最新的etag
let etag = response.headers.get('etag')
hasUpdate = lastEtag && etag !== lastEtag
lastEtag = etag
} catch (e) {
return Promise.reject(e)
}
}

async function confirmReload(msg = '', lastEtag) {
worker &&
worker.postMessage({
type: 'pause'
})
try {
Modal.confirm({
title: '温馨提示',
content: '系统后台有更新,请点击“立即刷新”刷新页面\n' + msg,
okText: '立即刷新',
cancelText: '5分钟后提示我',
onOk() {
worker.postMessage({
type: 'destroy'
})
location.reload()
},
onCancel() {
worker &&
worker.postMessage({
type: 'recheck',
lastEtag: lastEtag
})
}
})
} catch (e) {}
}

// 路由拦截
router.beforeResolve(async (to, from, next) => {
next()
try {
await checkUpdate()
if (hasUpdate) {
worker.postMessage({
type: 'destroy'
})
location.reload()
}
} catch (e) {}
})

// 利用worker轮询
worker = new Worker(
/* webpackChunkName: "checkUpdate.worker" */ new URL('../worker/checkUpdate.worker.js', import.meta.url)
)

worker.postMessage({
type: 'check'
})
worker.onmessage = ({ data }) => {
if (data.type === 'hasUpdate') {
hasUpdate = true
confirmReload(data.msg, data.lastEtag)
}
}
}


这里因为缺换路由本来就要刷新页面,用户可无需感知系统更新信息,直接通过请求头的Etag即可,这里的Fetch方法就用head获取相应头就好了。


checkUpdate.worker.js文件如下


let lastEtag
let hasUpdate = false
let intervalId = ''
async function checkUpdate() {
try {
// 检测前端资源是否有更新
let response = await fetch(`/manifest.json?v=${Date.now()}`, {
method: 'get'
})
// 获取最新的etag和data
let etag = response.headers.get('etag')
let data = await response.json()
hasUpdate = lastEtag !== undefined && etag !== lastEtag
if (hasUpdate) {
postMessage({
type: 'hasUpdate',
msg: data.msg,
lastEtag: lastEtag,
etag: etag
})
}
lastEtag = etag
} catch (e) {
return Promise.reject(e)
}
}

// 监听主线程发送过来的数据
addEventListener('message', ({ data }) => {
if (data.type === 'check') {
// 每5分钟执行一次
// 立即执行一次,获取最新的etag,避免在setInterval等待中系统更新,第一次获取的etag是新的,但是lastEtag还是undefined,不满足条件,错失刷新时机
checkUpdate()
intervalId = setInterval(checkUpdate,5 * 60 * 1000)
}
if (data.type === 'recheck') {
// 每5分钟执行一次
hasUpdate = false
lastEtag = data.lastEtag
intervalId = setInterval(checkUpdate, 5 * 60 * 1000)
}
if (data.type === 'pause') {
clearInterval(intervalId)
}
if (data.type === 'destroy') {
clearInterval(intervalId)
close()
}
})


如果不使用worker直接讲轮询逻辑放在checkUpdate即可


Worker引入


从 webpack 5 开始,你可以使用 Web Workers 代替 worker-loader


new Worker(new URL('./worker.js', import.meta.url));

以下版本的就只能用worker-loader


也可以逻辑写成字符串,然后通过ToURL给new Worker,如下:


function createWorker(f) {
const blob = new Blob(['(' + f.toString() +')()'], {type: "application/javascript"});
const blobUrl = window.URL.createObjectURL(blob);
const worker = new Worker(blobUrl);
return worker;
}

createWorker(function () {
self.addEventListener('message', function (event) {
// 消费信息
self.postMessage('send message')
}, false);
})


worker数据通信



// 主线程
var uInt8Array = new Uint8Array(new ArrayBuffer(10));
for (var i = 0; i < uInt8Array.length; ++i) {
 uInt8Array[i] = i * 2; // [0, 2, 4, 6, 8,...]
}
worker.postMessage(uInt8Array);
// Worker 线程
self.onmessage = function (e) {
 var uInt8Array = e.data;
 postMessage('Inside worker.js: uInt8Array.toString() = ' + uInt8Array.toString());
 postMessage('Inside worker.js: uInt8Array.byteLength = ' + uInt8Array.byteLength);
};


但是,拷贝方式发送二进制数据,会造成性能问题。比如,主线程向 Worker 发送一个 500MB 文件,默认情况下浏览器会生成一个原文件的拷贝。为了解决这个问题,JavaScript 允许主线程把二进制数据直接转移给子线程,但是一旦转移,主线程就无法再使用这些二进制数据了,这是为了防止出现多个线程同时修改数据的麻烦局面。这种转移数据的方法,叫做Transferable Objects。这使得主线程可以快速把数据交给 Worker,对于影像处理、声音处理、3D 运算等就非常方便了,不会产生性能负担。


如果要直接转移数据的控制权,就要使用下面的写法。


// Transferable Objects 格式
worker.postMessage(arrayBuffer, [arrayBuffer]);

// 例子
var ab = new ArrayBuffer(1);
worker.postMessage(ab, [ab]);


然而,并不是所有的对象都可以被转移。只有那些被设计为可转移的对象(用[ Transferable ] IDL 扩展属性修饰),比如ArrayBuffer、MessagePort,ImageBitmap,OffscreenCanvas,才能通过这种方式来传递。转移操作是不可逆的,一旦对象被转移,原始上下文中的引用将不再有效。转移对象可以显著减少复制数据所需的时间和内存。


作者:Zayn
来源:juejin.cn/post/7329280514628534313
收起阅读 »

决定了,转产品经理啦。

今天来聊一下产品经理这个话题,掘金上有一个读者私信问,对代码不是特别有天赋,想转行产品经理可行吗?前景如何? 那刚好之前有另外一个读者转了产品经理,女生,关键是她对当前的薪资还非常满意,“撩”天起来也特别丝滑(😂)。 那希望小姐姐的分享,能给大家带来一些启发...
继续阅读 »

今天来聊一下产品经理这个话题,掘金上有一个读者私信问,对代码不是特别有天赋,想转行产品经理可行吗?前景如何?


那刚好之前有另外一个读者转了产品经理,女生,关键是她对当前的薪资还非常满意,“撩”起来也特别丝滑(😂)。



那希望小姐姐的分享,能给大家带来一些启发和帮助🤔,尤其是 25 届 26 届,以及打算转行产品的小伙伴,注意啦。


一、你觉得找工作最关键的一环是什么?


首先首先,要有一份看的过去的简历,这里有个区别就是,如果你是 985 那种好学校没有实习经历投什么公司都可以。


但是如果你是双非一本二本之类的学校,没有实习经历就别去投大公司了,先把简历写好投个 500 人以内的小公司实习着。



7-10 月份之间挑两个月实习着,然后是多看网上的(牛客网,b 站,知乎产品面经非常多,b 站模拟面试视频也挺多的)一些面经,熟悉面试官的套路,懂得面试中常问的问题及思路。


深刻总结自己大学三年经历(实习,成绩,获奖经历),在反思自己的各个经历时,一定要有条理的、详尽的把自己的经历描述清楚,简历上写概要,但是当面试官问到你其中经历时,一定要能条理清晰地表述出来;


之后是在面试过程中根据自己的面试经验,总结面试官们对自己简历的关注重点,多次改版


Boss 直聘投递简历,大量投递简历(boss 记录我总共沟通过的 hr 是 982 位,目总共投递了 196 份简历,视频面试 23 家),说真的产品面试多面几次你就知道哪些问题会经常问了,面试期间录音,面试结束后立刻记录并分析面试过程中的问题,并不断反思自己的不足。


二、学生阶段对找工作最有用的是什么?


大一大二大三在学校都没怎么学习,基本上都是班级倒数前十,我记得上 Java 课那个老师每次都叫全班倒数前十坐第一排,所以我的学习是真的很差。


但是我这个人性格活泼开朗有社交牛杂证,喜欢玩各种 app,喜欢上网,性格内向的女生做产品不太推荐,产品面试有的时候还是要跟面试官画大饼的,要敢说敢想敢画饼。


三、自学了哪些课程?


纯纯靠在 b 站搜索产品经理学习,看视频自己总结复盘,做笔记。


四、大学四年过得怎么样?


大学前三年都在谈恋爱吧,反正没学习,不过也没挂过科,就是普通女生,后来失恋了才知道要好好考虑未来了。其实我也就是大三暑假才开始学习的,计算机专业应届生想面试产品实习好好学一个月都能行!



五、为什么走上了这条路?


因为对于我来说,计算机本身不喜爱,软件学习不好,不能硬去做软件开发,这样坐下来自己就很痛苦,软件技术就把自己的发展方向卡的死死的,转行产品经理是计算机专业学生一个更好的方向。



六、有去实习吗?


我是 9 月份才实习的,说实话有点晚了,你们最好别像我一样这么晚,那个时候找的是一个 300 多人的小公司实习,公司做的是 B 端的产品,我和带我那个导师玩的很好。


我当时跟她做一个她全程负责已经上线的项目,项目资料啥的都给我了,但是她把项目从头到尾细节都给我讲了十几遍,所以我能把这个项目完全梳理清楚。


还有产品经理的特质要求你必须要善于思考,所以虽然你做的事可能是基层的工作,但是你在整个过程中不断思考,这点是面试官比较看重的。


七、为什么选择了产品岗做了哪些准备?


本科双非软件工程专业女生,从大一开始就知道自己并不喜欢技术岗,也做不来,真正明确目标是在大三暑假,在 b 站刷到有关计算机专业做产品的视频,后来仔细了解了产品经理的相关工作,就想尝试入行。



八、推荐 B 站学习资料/或者其他资料?


B 站学习资料其实一搜产品经理学习会有一大堆,我觉得每个 up 主的视频都可以看看,另外可以关注一下人人都是产品经理这个网站,这个也可以下载 app,还有关注一些写的比较好的产品公众号,经常浏览一下培养产品思维。


九、有什么好的学习建议吗?


我觉得要多加一些产品交流学习群,因为一个人可以走得很快,但是一群人可以走得更远,一个人自学的同时也要看看别人的情况,多和未来要做产品以及现在已经做产品的人做朋友,在面试这块还能一起模拟一下。



十、工作后和学校时最大的区别?


工作后和在学校时最大的差别就是,工作后你是赚钱,而在学校你是花钱。


说真的我领第一个月实习工资的时候虽然只有 4000,但是我很开心,第二家公司实习 7000 的时候我存款都一万多了,感觉工作赚钱真好。


十一、工作后的心得体会?


其实工作后和大学时还是有一点比较相似的。


那就是“没人真正关心你的个人发展”。


你就是你,除了你自己和你的父母外,没有人会真正在意你的未来发展,你是出人头地还是泯然众人。每个人都很忙的,所以你要自己好好在意自己,自己去时常反省,自己去寻找资源学习提升,自己好好整自己的简历。



十二、对未来的展望?


因为我第一段实习和目前这家公司都是做 B 端产品的,自己本人也喜欢 B 端产品,我未来产品发展方向肯定是往 B 端走的,希望能在毕业后 3-5 年成长为高级产品经理。


十三、计算机专业做产品的优势?


很多产品招聘要求都是计算机相关专业优先,这一点在产品面试里面可以突出重点。懂一点技术这样可以和技术人员更好的沟通交流,解决 bug。



十四、有什么祝福语吗?


提前祝大家龙年暴富,也祝二哥掘金上早日百万粉。


希望每个和我一样同是计算机专业但是不想做开发的兄弟姐妹都不要放弃!可以来找我一起学习交流产品面试经验哦。


作者:沉默王二
来源:juejin.cn/post/7330933094159908916
收起阅读 »

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

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

你好呀,我是歪歪。


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


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


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


业务代码中一般也使不上多线程,或者说,业务代码中不知不觉你以及在使用线程池了,你再 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
收起阅读 »

解决前端跨团队统一的隐性拦路虎

前言 春节刚归来,我们不搞那么烧脑,先来一篇浅显易懂的文章,期望给大家带来一些新的解题思路。 背景 过去多年无论是一款插件推广,还是组件库统一,无论是一次机制流程制定,还是前端工程化体系建设,相信很多同学与我一样,在跨团队方案推广统一过程中,前期无论做好多详实...
继续阅读 »

默认标题__2024-02-06+15_50_28.png


前言


春节刚归来,我们不搞那么烧脑,先来一篇浅显易懂的文章,期望给大家带来一些新的解题思路。


背景


过去多年无论是一款插件推广,还是组件库统一,无论是一次机制流程制定,还是前端工程化体系建设,相信很多同学与我一样,在跨团队方案推广统一过程中,前期无论做好多详实的准备,最终都会有一种未竟全功的感觉。


推广过程中,总会有人摆出历史包袱过重这一拦路虎“说服”我们,比如”我这项目不维护了,无需升级“,”我这项目框架太老旧了,无法升级“,或两者兼有之,到底改哪些项目多取决于双方自行判断,说穿了其实是双方“非不能也,乃不欲也”。


危害


一方面前端项目下线充满不确定性,业务不维护不代表页面无访问,旧有项目中总有一些页面残留,需要长期持续跟进。


另一方面过去多年前端技术生态快速向前发展,造成了不同部门、时期,从jquery、vue2、vue3、react、angular到webpack3、4、5、gulp、vite等前端基建五花八门的场景,仅23年我们团队就先后接入过webpack5、vite、pinia、rspack等前端架构局部优化,跨团队统一需要做大量兼容工作,全量统一困难。


前端项目的业务和技术特点,造成了前端项目数量基本越垒越多,每个项目总有几个有流量的页面时不时跳出来恶心人。造成了前端基建越来越庞杂,兼容成本偏高,总是不能全量升级。形成了前端项目独特的长尾化问题,项目长尾化,基建长尾化,团队意识长尾化。随着时间延续会带来升级维护困难和难以言表的线上偶发惊吓。


根源和解决思路


基于我司经验,问题产生根源一是前端团队资源有限,并不能覆盖全部项目;二是没有统一标准,项目缺乏统一标准管理,各团队自我决策改动范围;三是缺乏强制机制,并不能保证完成效果和时间。


资源有限是个基本无法解决的问题,我们只能从标准和强制机制两个角度去解决,基于此我们针对性的制定了转转自己的项目动态分级标准和强制倒逼机制。


项目动态分级


分级指的是用客观统一的数据标准反映项目重要性,规避主观评判。动态指的是随着时间延续,项目走过新建、迭代、维护、下线的生命周期,客观数据也随生命周期波谷、波峰、波谷往复更替。项目动态分级的最大好处是将有限资源聚焦在重点项目上。


以过去我司推进项目代码规范为例,我们设计时采用项目月活跃分支数、月代码提交行数、项目用户日访问量几个指标确定项目级别;


044ab65d-0883-49c5-ac41-07a588af6c2b.png


比如只有同时满足日访问量UV高于10000、日PV高于100000、月活跃分支数多于4个、月代码提交行数多于200的项目才确定为移动端重点项目,其它项目为非重点项目,每日或每周可以跑定时任务更新项目分级数据,具体数据可以通过拉取git api和公司前端埋点数据获得。


针对重点项目,可以制定2-4个月的改造时间节点和达标标准,非重点项目可以不做改造或制定其它策略。


我司前端项目数不到千,仅将项目分成重要和不重要两个级别已经够用,如场景有必要,也可将项目进一步拆分为更多级别。


指标边界值


大家可能对上面UV > 10000或月活跃分支数 > 4 等指标边界值是怎么确定的感兴趣?


说一下我们的思路,相信每个前端团队过去都手动收集过团队中的重点项目有哪些,这些手动数据可以作为我们的衡量基础,可以不断尝试调整我们的指标边界值,计算出的重点项目一般达到基本覆盖我们手动收集的95%以上重点项目即可,以此来确定边界值指标,一般结果肯定会大于我们的手动集,大家拿到结果可以人工再去分析下具体项目,基本能够发现多出来的项目大部分都是真实的重点项目,大部分都是因为我们谈到的历史包袱问题,不提罢了。


注意如果动态项目池,每日或每周都有较大变化,可能是你的指标不太合理,可以考虑扩大或缩小边界值来解决。另外注意突然进入重点动态项目池的项目,一般下个周期有可能就波动出去了,此类项目可以不做处理,也可推动解决,留好一定改动时间即可。


多指标好处


相比单指标确定边界值,多指标有哪些好处呢?


多指标好处一个是覆盖全部场景,比如我司移动端项目框架为Vue,有大量用户访问,中后台项目技术栈为React,没有大量用户访问。如果仅使用用户访问量指标,中后台项目全部会被排除在重点项目外,如果仅使用项目活跃度,移动端最看重的用户访问量不被纳入,项目区分度不够,但是两者相结合,恰好能覆盖移动端和PC端的全量场景。


另一个是多指标得出的结果稳定性更好,不会因单指标剧烈变化造成结果波动太大的情况,比如去年618和双11期间,尽管我司用户访问量大增,但重点项目集并没有什么变化。


强制倒逼机制


另外怎么确保deadline无延期呢?


我司采用的解决办法是与上线环节绑定,通过强拦截和审批拦截的方式,确保各团队能在最终时间节点之前,完成全部项目的改造。除此之外,强制机制能够倒逼各团队提升生产率,将很多重复性工作自动化,比如上文说到的代码规范,总会有同学梳理出一套本地自动化方案,帮我们完成配套建设。当我们标准达成、配套健全后,就可以考虑下扩大范围或者提高标准的事情了。


另外大家需要注意,跨组推进工作中很重要的一点是前置沟通,给同学们留下足够的时间,比如2-6个月,达成时间共识很重要,不要自觉良好一刀切,想想过去经历的很多跨组项目,半年能彻底搞完就很不错了。


252bfa83-7993-42bc-9cea-799da0876e5d.png


思路&基建复用


本文谈到的整套分级和强制思路,后续逐步复用在我司代码重复度、复杂度、线上异常治理、性能指标等多项跨团队公共事项落地中,有些指标直接复用同一套项目分级标准即可,有些指标需要一定的改动,比如性能指标,就从重点项目,变为重点页面即可,大部分基础能力和解决问题的思路仍可复用。


总结


保持公司各团队基建、标准、机制统一,长期看是非常有必要的事,但过去受制于历史包袱问题这一隐性拦路虎,前端很难做到跨团队统一方案的效用最大化,总会留下一些尾巴,本文通过转转过去的实践,给出了项目分级和强制机制的解体思路,非常适合前端资源并不那么充足的公司,至少能够保证公司重要的项目长期标准统一。


另外整个思路的落地,除标准制定外,还涉及到数据抽取、各指标检测、CICD集成、报警机制、系统开发等多方面的具体工作,每一部分都能作为一个单独的模块去做分享,比如代码规范涉及到的项目代码增量存量检测、代码复杂度、重复度检测,线上异常治理涉及到的实时报警策略、错误分级策略等,因篇幅关系,本文不再赘述,期待后面其他转转er的文章吧!!!


作者:转转技术团队
来源:juejin.cn/post/7337589994464854016
收起阅读 »

用 Puppeteer 把繁琐工作给自动化了,太爽啦!

web
最近在鱼皮的编程导航星球做嘉宾,需要输出一些内容。 而很多内容我之前写过,所以想复制过来。 这时候我就遇到了一个令人头疼的问题: 知识星球的编辑器也太难用了! 比如我在掘金编辑器里这样的 markdown 内容: 复制到星球编辑器是这样的: markdow...
继续阅读 »

最近在鱼皮的编程导航星球做嘉宾,需要输出一些内容。


而很多内容我之前写过,所以想复制过来。


这时候我就遇到了一个令人头疼的问题:


知识星球的编辑器也太难用了!


比如我在掘金编辑器里这样的 markdown 内容:



复制到星球编辑器是这样的:



markdown 语法是识别了,但图片没有自动上传。


如果用富文本格式,格式又不对:



而且 gif 没有识别出来,还是需要手动传一次。


这意味着如果文中有几十张图片,那我需要单独把这几十张图片保存到本地,然后光标定位到对应位置,点击上传图片,把图片插进去。


也就是这样:




把每个图片下载下来,保存为不同的后缀名(png、jpg、gif),然后再定位到对应位置,删除原来的链接,插入图片。


然后这样重复十几次,每篇文章都这样来一遍。


是不是想想都觉得很痛苦。。。


那有什么好的办法解决这个问题呢?


于是我想到了 puppeteer。



它是一个网页自动化的 Node.js 工具,基本所有你手动在浏览器里做的事情,都可以用它来自动化完成。


比如点击、移动光标、输入等等。


那前面那个繁琐的问题自然也可以用 puppeteer 自动化来做,解放我们的生产力。


我们来分析下整个流程:


首先打开星球编辑器页面,如果没登录会跳到登录页:



这一步要扫码,没法自动化。


登录之后进入编辑器页面,输入内容:



这时候我们要把其中的图片链接分析出来,自动下载到本地的目录中。


然后记录每个链接所在的行数,把光标移动到对应的行数,点击上传按钮:



上传这一步也要手动来做,选择之前自动下载的图片就行。


然后光标会自动移动到下一个位置,再点击上传按钮,直到所有图片上传完。


文件浏览器这一步是操作系统的功能,没法自动化。


我们把下载图片、在对应位置插入图片的过程给自动化了。只有登录、选择文件这两步还要还要手动做。


但这样已经方便太多了。


流程理清了,我们就来写下代码吧:


import puppeteer from 'puppeteer';

const browser = await puppeteer.launch({
headless: false,
defaultViewport: {
width: 0,
height: 0
}
});

const page = await browser.newPage();

await page.goto('http://www.baidu.com');

await page.focus('#kw');

await page.keyboard.type('hello', {
delay: 200
});

await page.click('#su');

引入 puppeteer,跑一个 chrome 浏览器,创建一个页面,导航到 baidu,输入 hello,点击搜索。


puppeteer 的 api 还是很容易懂的。


其中 defaultViewport 设置宽高为 0 是让网页充满整个窗口。


然后我们把它跑起来,因为用到了 es module、顶层 await,需要在 package.json 声明 type 为 module:



声明 type 为 module 就是所有的模块都是 es module 的意思。


然后把它跑起来:



可以看到脚本正确执行了。


然后我们让它打开星球编辑器的网址:


import puppeteer from 'puppeteer';

const browser = await puppeteer.launch({
headless: false,
defaultViewport: {
width: 0,
height: 0
}
});

const page = await browser.newPage();

await page.goto('https://wx.zsxq.com/dweb2/article?groupId=51122858222824');


确实跳到登录了:



扫码登录之后进入星球页面,就可以写文章了。



但是,下次跑脚本还是要再登录。


我们不是登录过了么?为啥还需要登录?


因为 chrome 默认的数据保存在一个目录中,叫 userDataDir,而这个目录默认是临时生成的,所以每次保存数据的目录都不一样。


这就导致了每次都需要登陆。


所以我们指定一个固定的 userDataDir 就好了。


import puppeteer from 'puppeteer';
import os from 'os';
import path from 'path';

const browser = await puppeteer.launch({
headless: false,
defaultViewport: {
width: 0,
height: 0
},
userDataDir: path.join(os.homedir(), '.puppeteer-data')
});

const page = await browser.newPage();

await page.goto('https://wx.zsxq.com/dweb2/article?groupId=51122858222824');

通过 os.homedir() 拿到 home 目录,再下面新建一个 .puppeteer-data 的目录来保存用户数据。


这样登录一次之后,下次就不再需要登录了:



这时候可以看到 userDataDir 下是保存了用户数据的:



接下来就是编辑部分的自动化了。


我们要做的事情有这么两件:



  • 提取文本中的所有链接,自动下载。

  • 光标定位到每个链接的位置,自动点击上传按钮。


执行这俩自动化脚本的过程最好让用户控制,比如输入 download-img 就自动下载图片,输入 upload-next 光标就自动定位到下个位置,点击上传。


所以我们引入 readline 这个内置模块接收用户输入。


import readline from 'readline';

const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});

rl.on('line', async (command) => {
switch(command) {
case 'upload-next':
await uploadNext();
break;
case 'download-img':
await downloadImg();
break;
default:
break;
}
});

async function uploadNext() {
console.log('------');
}
async function downloadImg() {
console.log('+++++++');
}

调用 creatInterface api,指定 input、output 为标准输入输出。


然后当收到一行的输入的时候,根据内容决定执行什么方法:



我们先实现 download-img 的部分:



可以看到,编辑器部分的内容就是 .ql-editor 下的一个个 p 标签。


那我们只要取出所有的 p 标签,选出 ![]() 格式的内容就好了。


这需要一个正则,我们先把这个正则写出来:


整体格式是这样的:


![]()

但[] 和 () 需要转义:


!\[\]\(\)

中间部分是除了 [] 和 () 的任意字符出现任意次,也就是这样:


[^\[\]\(\)]*

并且 () 里的内容需要提取,需要用小括号包裹。


完整正则就是这样的:


!\[[^\[\]\(\)]*\]\(([^\[\]\(\)]*)\)

我们测试下:



可以看到 () 中的内容被正确提取出来了。


然后在网页里取出所有的 p 标签,根据内容过滤,把链接和行数记录下来:


const links = await page.evaluate(() => {
let links = [];
const lines = document.querySelectorAll('.ql-editor p');
for(let i = 0; i < lines.length; i ++) {
const matchRes =lines[i].textContent.trim().match(/!\[[^\[\]\(\)]*\]\(([^\[\]\(\)]*)\)/)
if (matchRes) {
links.push({
index: i,
link: matchRes && matchRes[1],
});
}
}
return links;
})

用 page.evaluate 方法在网页里远程执行一段 js,拿到它的返回结果。


这里拿到的就是所有的图片链接:



其实严格来说这不叫行数,而是第几个 p 标签,想要定位到对应的 p 标签,只要点击它就好了。



我们记录的下标是从 0 开始,而 nth-child 从 1 开始,所以要加 1。


可以看到,光标定位到了正确的位置:



不过先不着急定位光标,我们先把图片下载给搞定。


下载部分的代码如下:


import https from 'https';
import fs from 'fs';

function downloadFile(url, destinationPath, progressCallback) {
let resolve , reject;
const promise = new Promise((x, y) => { resolve = x; reject = y; });

const request = https.get(url, response => {
if (response.statusCode !== 200) {
const error = new Error(`Download failed: server returned code ${response.statusCode}. URL: ${url}`);
response.resume();

reject(error);
return;
}
const file = fs.createWriteStream(destinationPath);

file.on('finish', () => resolve ());
file.on('error', error => reject(error));

response.pipe(file);

const totalBytes = parseInt(response.headers['content-length'], 10);
if (progressCallback)
response.on('data', onData.bind(null, totalBytes));
});
request.on('error', error => reject(error));
return promise;

function onData(totalBytes, chunk) {
progressCallback(totalBytes, chunk.length);
}
}

用 https 模块的 get 方法请求 url,然后把 response 用流的方式写入文件,并且通过 content-length 的响应头拿到总长度。


这样,在每次 data 方法里就能根据总长度,当前 chunk 的长度,算出下载进度。


我们测试下:


const url = 'https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/66399947ea6b45289c8d77b6d4568cc5~tplv-k3u1fbpfcp-watermark.image'

let currentTotal = 0;
downloadFile(url, './1.gif', (totalBytes, chunkBytes) => {
const percent = (currentTotal/totalBytes * 100).toFixed(1);
console.log('总长度:' + totalBytes + 'B', '当前已下载:' + currentTotal + 'B','进度' + percent + '%');
currentTotal += chunkBytes;
})


可以看到,图片下载成功了!


但是,我们现在是知道这是个 gif 才给它加上 .gif 后缀,要是任意一个链接,怎么知道它的格式呢?


这个可以用 image-size 这个包:


import sizeOf from 'image-size';
import fs from 'fs';

const buffer = fs.readFileSync('./1.image');
const dimensions = sizeOf(buffer);

console.log(dimensions);

它能拿到图片的类型和宽高信息:



这样我们在下载完改下名就可以了。


const url = 'https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/66399947ea6b45289c8d77b6d4568cc5~tplv-k3u1fbpfcp-watermark.image'

let currentTotal = 0;
let filePath = './1.image';
downloadFile(url, filePath, (totalBytes, chunkBytes) => {
const percent = (currentTotal/totalBytes * 100).toFixed(1);
console.log('总长度:' + totalBytes + 'B', '当前已下载:' + currentTotal + 'B','进度' + percent + '%');
currentTotal += chunkBytes;

if(currentTotal >= totalBytes) {
const {type} = sizeOf(fs.readFileSync(filePath));
fs.renameSync(filePath, filePath + '.' + type)
}
})

当下载完之后,拿到图片信息,重命名一下,把后缀名改成新的。


注意下图中文件名字的变化:



这样,下载图片就搞定了。


我们把它集成到自动化流程中。


先指定下文件保存位置和文件名:


我们在 home 目录下创建一个 .img 目录吧,然后文件名是 1.image、2.image 的形式。


const imgPath = path.join(os.homedir(), '.img');

fs.rmSync(imgPath, {
recursive: true
});
fs.mkdirSync(imgPath);

for(let i = 0; i< links.length; i++) {
const filePath = path.join(imgPath, (i+1) + '.image');
fs.writeFileSync(filePath, 'aaaa')
}


每次先清空 .img 目录,再创建。


执行之后,确实在 .img 目录下创建了对应的图片文件:



然后把下载图片和重命名的逻辑集成进来:


fs.rmSync(imgPath, {
recursive: true
});
fs.mkdirSync(imgPath);

for(let i = 0; i< links.length; i++) {
const filePath = path.join(imgPath, (i+1) + '.image');
let currentTotal = 0;
downloadFile(links[i].link, filePath, (totalBytes, chunkBytes) => {
currentTotal += chunkBytes;

if(currentTotal >= totalBytes) {
setTimeout(() => {
const {type} = sizeOf(fs.readFileSync(filePath));
fs.renameSync(filePath, filePath + '.' + type)
console.log(`${filePath} 下载完成,重命名为 ${filePath + '.' + type}`);
}, 1000);
}
})
}

这里加了一个 setTimeout,1s 之后执行重命名的逻辑,保证在文件下载完之后再重命名。


效果是这样的:



在 .img 下可以看到所有的图片都下载并重命名成功了:



有 png 也有 gif



下一步只要在不同的位置插入就好了。


我们再来做光标定位的部分。


这部分前面演示过,就是触发对应 p 标签的 click 就好了。


let cursor = 0;
async function uploadNext() {
if(cursor >= links.length) {
return;
}
await page.click(`.ql-editor p:nth-child(${links[cursor].index + 1})`);
await page.evaluate((index) => {
const p = document.querySelector(`.ql-editor p:nth-child(${index + 1})`);
p.textContent = '';
}, links[cursor].index);
await page.click('.ql-image');
cursor ++;
}

我们定义一个游标,从 0 开始,先点击第一个 link 的 p 标签,把它的内容清空,插入下载的图片。


然后再次执行就是插入下一个。


这样依次插入。


我们来试试:


首先,打开编辑器页面,自己登录和输入 markdown 内容:



然后输入 download-img 来下载图片:



之后执行 upload-next 插入第一张图片:



再执行 upload-next 插入第二张图片:



插入的位置非常正确!



依次 upload-next 就能把所有图片插入完成。


对比下之前的体验:


一张张下载图片,根据不同的格式来重命名,然后一张张找到对应的位置,删除原来的链接,插入图片。


现在的体验:


输入 download-img 自动下载图片,不断执行 upload-next 选择图片,自动插入到正确的位置。


这体验差距很明显吧!


这就是用 puppeteer 自动化以后的工作流。


全部代码如下:


import puppeteer from 'puppeteer';
import os from 'os';
import path from 'path';
import fs from 'fs';
import readline from 'readline';
import sizeOf from 'image-size';
import downloadFile from './download.js';

const browser = await puppeteer.launch({
headless: false,
defaultViewport: {
width: 0,
height: 0
},
userDataDir: path.join(os.homedir(), '.puppeteer-data')
});

const page = await browser.newPage();

await page.goto('https://wx.zsxq.com/dweb2/article?groupId=51122858222824');

const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});

rl.on('line', async (command) => {
switch(command) {
case 'upload-next':
await uploadNext();
break;
case 'download-img':
await downloadImg();
break;
default:
break;
}
});



let links = [];
async function downloadImg() {
links = await page.evaluate(() => {
let links = [];
const lines = document.querySelectorAll('.ql-editor p');
for(let i = 0; i < lines.length; i ++) {
const matchRes =lines[i].textContent.trim().match(/!\[[^\[\]\(\)]*\]\(([^\[\]\(\)]*)\)/)
if (matchRes) {
links.push({
index: i,
link: matchRes && matchRes[1],
});
}
}
return links;
})

const imgPath = path.join(os.homedir(), '.img');

fs.rmSync(imgPath, {
recursive: true
});
fs.mkdirSync(imgPath);


for(let i = 0; i< links.length; i++) {
const filePath = path.join(imgPath, (i+1) + '.image');
let currentTotal = 0;
downloadFile(links[i].link, filePath, (totalBytes, chunkBytes) => {
currentTotal += chunkBytes;

if(currentTotal >= totalBytes) {
setTimeout(() => {
const {type} = sizeOf(fs.readFileSync(filePath));
fs.renameSync(filePath, filePath + '.' + type)
console.log(`${filePath} 下载完成,重命名为 ${filePath + '.' + type}`);
}, 1000);
}
})
}

console.log(links);
}

let cursor = 0;
async function uploadNext() {
if(cursor >= links.length) {
return;
}
await page.click(`.ql-editor p:nth-child(${links[cursor].index + 1})`);
await page.evaluate((index) => {
const p = document.querySelector(`.ql-editor p:nth-child(${index + 1})`);
p.textContent = '';
}, links[cursor].index);
await page.click('.ql-image');
cursor ++;
}

import https from 'https';
import fs from 'fs';

export default function downloadFile(url, destinationPath, progressCallback) {
let resolve , reject;
const promise = new Promise((x, y) => { resolve = x; reject = y; });

const request = https.get(url, response => {
if (response.statusCode !== 200) {
const error = new Error(`Download failed: server returned code ${response.statusCode}. URL: ${url}`);
response.resume();

reject(error);
return;
}
const file = fs.createWriteStream(destinationPath);

file.on('finish', () => resolve ());
file.on('error', error => reject(error));

response.pipe(file);

const totalBytes = parseInt(response.headers['content-length'], 10);
if (progressCallback)
response.on('data', onData.bind(null, totalBytes));
});
request.on('error', error => reject(error));
return promise;

function onData(totalBytes, chunk) {
progressCallback(totalBytes, chunk.length);
}
}

总结


星球编辑器不好用,每次都要把图片手动下载下来然后插入对应位置,我们通过 puppeteer 把这个流程自动化了。


puppeteer 是一个自动化测试工具,基本所有浏览器手动的操作都能自动化。


我们用 readline 模块读取用户输入,当输入 download-img 的时候,拿到所有的 p 标签,过滤出链接的内容,把信息记录下来。


自动下载图片并用 image-size 读取图片类型来重命名。


然后输入 upload-next,会通过点击对应 p 标签实现光标定位,然后点击上传按钮来选择图片。


自动化以后的工作流程简单太多了,繁琐的工作都给自动化了,体验爽翻了!


作者:zxg_神说要有光
来源:juejin.cn/post/7230757380819812407
收起阅读 »

js如何实现当文本内容过长时,中间显示省略号...,两端正常展示

web
前一阵做需求时,有个小功能实现起来废了点脑细胞,觉得可以记录一下。 产品的具体诉求是:用户点击按钮进入详情页面,详情页内的卡片标题内容过长时,标题的前后两端正常展示,中间用省略号...表示,并且鼠标悬浮后,展示全部内容。 关于鼠标悬浮展示全部内容的代码就不放在...
继续阅读 »

前一阵做需求时,有个小功能实现起来废了点脑细胞,觉得可以记录一下。


产品的具体诉求是:用户点击按钮进入详情页面,详情页内的卡片标题内容过长时,标题的前后两端正常展示,中间用省略号...表示,并且鼠标悬浮后,展示全部内容。


关于鼠标悬浮展示全部内容的代码就不放在这里了,本文主要写关于实现中间省略号...的代码。


实现思路



  1. 获取标题盒子的真实宽度, 我这里用的是clientWidth;

  2. 获取文本内容所占的实际宽度;

  3. 根据文字的大小计算出每个文字所占的宽度;

  4. 判断文本内容的实际宽度是否超出了标题盒子的宽度;

  5. 通过文字所占的宽度累加之和与标题盒子的宽度做对比,计算出要截取位置的索引;

  6. 同理,文本尾部的内容需要翻转一下,然后计算索引,截取完之后再翻转回来;


代码


html代码


<div class="title" id="test">近日,银行纷纷下调大额存单利率,但银行定期存款仍被疯抢。银行理财经理表示:有意向购买定期存款要尽快,不确定利率是否会再降。</div>

css代码: 设置文本不换行,同时设置overflow:hidden让文本溢出盒子隐藏


.title {
width: 640px;
height: 40px;
line-height: 40px;
font-size: 14px;
color: #00b388;
border: 1px solid #ddd;
overflow: hidden;
/* text-overflow: ellipsis; */
white-space: nowrap;
/* box-sizing: border-box; */
padding: 0 10px;
}

javascript代码:


获取标题盒子的宽度时要注意,如果在css样式代码中设置了padding, 就需要获取标题盒子的左右padding值。 通过getComputedStyle属性获取到所有的css样式属性对应的值, 由于获取的padding值都是带具体像素单位的,比如: px,可以用parseInt特殊处理一下。


获取盒子的宽度的代码,我当时开发时是用canvas计算的,但计算的效果不太理想,后来逛社区,发现了嘉琪coder大佬分享的文章,我这里就直接把代码搬过来用吧, 想了解的掘友可以直接滑到文章末尾查看。


判断文本内容是否超出标题盒子


 // 标题盒子dom
const dom = document.getElementById('test');

// 获取dom元素的padding值
function getPadding(el) {
const domCss = window.getComputedStyle(el, null);
const pl = Number.parseInt(domCss.paddingLeft, 10) || 0;
const pr = Number.parseInt(domCss.paddingRight, 10) || 0;
console.log('padding-left:', pl, 'padding-right:', pr);
return {
left: pl,
right: pr
}
}
// 检测dom元素的宽度,
function checkLength(dom) {
// 创建一个 Range 对象
const range = document.createRange();

// 设置选中文本的起始和结束位置
range.setStart(dom, 0),
range.setEnd(dom, dom.childNodes.length);

// 获取元素在文档中的位置和大小信息,这里直接获取的元素的宽度
let rangeWidth = range.getBoundingClientRect().width;

// 获取的宽度一般都会有多位小数点,判断如果小于0.001的就直接舍掉
const offsetWidth = rangeWidth - Math.floor(rangeWidth);
if (offsetWidth < 0.001) {
rangeWidth = Math.floor(rangeWidth);
}

// 获取元素padding值
const { left, right } = getPadding(dom);
const paddingWidth = left + right;

// status:文本内容是否超出标题盒子;
// width: 标题盒子真实能够容纳文本内容的宽度
return {
status: paddingWidth + rangeWidth > dom.clientWidth,
width: dom.clientWidth - paddingWidth
};
}

通过charCodeAt返回指定位置的字符的Unicode编码, 返回的值对应ASCII码表对应的值,0-127包含了常用的英文、数字、符号等,这些都是占一个字节长度的字符,而大于127的为占两个字节长度的字符。


截取和计算文本长度


// 计算文本长度,当长度之和大于等于dom元素的宽度后,返回当前文字所在的索引,截取时会用到。
function calcTextLength(text, width) {
let realLength = 0;
let index = 0;
for (let i = 0; i < text.length; i++) {
charCode = text.charCodeAt(i);
if (charCode >= 0 && charCode <= 128) {
realLength += 1;
} else {
realLength += 2 * 14; // 14是字体大小
}
// 判断长度,为true时终止循环,记录索引并返回
if (realLength >= width) {
index = i;
break;
}
}
return index;
}

// 设置文本内容
function setTextContent(text) {
const { status, width } = checkLength(dom);
let str = '';
if (status) {
// 翻转文本
let reverseStr = text.split('').reverse().join('');

// 计算左右两边文本要截取的字符索引
const leftTextIndex = calcTextLength(text, width);
const rightTextIndex = calcTextLength(reverseStr, width);

// 将右侧字符先截取,后翻转
reverseStr = reverseStr.substring(0, rightTextIndex);
reverseStr = reverseStr.split('').reverse().join('');

// 字符拼接
str = `${text.substring(0, leftTextIndex)}...${reverseStr}`;
} else {
str = text;
}
dom.innerHTML = str;
}

最终实现的效果如下:


image.png


上面就是此功能的所有代码了,如果想要在本地试验的话,可以在本地新建一个html文件,复制上面代码就可以了。


下面记录下从社区内学到的相关知识:



  1. js判断文字被溢出隐藏的几种方法;

  2. JS获取字符串长度的几种常用方法,汉字算两个字节;


1、 js判断文字被溢出隐藏的几种方法


1. Element-plus这个UI框架中的表格组件实现的方案。


通过document.createRangedocument.getBoundingClientRect()这两个方法实现的。也就是我上面代码中实现的checkLength方法。


2. 创建一个隐藏的div模拟实际宽度


通过创建一个不会在页面显示出来的dom元素,然后把文本内容设置进去,真实的文本长度与标题盒子比较宽度,判断是否被溢出隐藏了。


function getDomDivWidth(dom) {
const elementWidth = dom.clientWidth;
const tempElement = document.createElement('div');
const style = window.getComputedStyle(dom, null)
const { left, right } = getPadding(dom); // 这里我写的有点重复了,可以优化
tempElement.style.cssText = `
position: absolute;
top: -9999px;
left: -9999px;
white-space: nowrap;
padding-left:${style.paddingLeft};
padding-right:${style.paddingRight};
font-size: ${style.fontSize};
font-family: ${style.fontFamily};
font-weight: ${style.fontWeight};
letter-spacing: ${style.letterSpacing};
`
;
tempElement.textContent = dom.textContent;
document.body.appendChild(tempElement);
const obj = {
status: tempElement.clientWidth + right + left > elementWidth,
width: elementWidth - left - right
}
document.body.removeChild(tempElement);
return obj;
}

3. 创建一个block元素来包裹inline元素


这种方法是在UI框架acro design vue中实现的。外层套一个块级(block)元素,内部是一个行内(inline)元素。给外层元素设置溢出隐藏的样式属性,不对内层元素做处理,这样内层元素的宽度是不变的。因此,通过获取内层元素的宽度和外层元素的宽度作比较,就可以判断出文本是否被溢出隐藏了。


// html代码
<div class="title" id="test">
<span class="content">近日,银行纷纷下调大额存单利率,但银行定期存款仍被疯抢。银行理财经理表示:有意向购买定期存款要尽快,不确定利率是否会再降。</span>
</div>

// 创建一个block元素来包裹inline元素
const content = document.querySelector('.content');
function getBlockDomWidth(dom) {
const { left, right } = getPadding(dom);
console.log(dom.clientWidth, content.clientWidth)
const obj = {
status: dom.clientWidth < content.clientWidth + left + right,
width: dom.clientWidth - left - right
}
return obj;
}

4. 使用canvas中的measureText方法和TextMetrics对象来获取元素的宽度


通过Canvas 2D渲染上下文(context)可以调用measureText方法,此方法会返回TextMetrics对象,该对象的width属性值就是字符占据的宽度,由此也能获取到文本的真实宽度,此方法有弊端,比如说兼容性,精确度等等。


// 获取文本长度
function getTextWidth(text, font = 14) {
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d")
context.font = font
const metrics = context.measureText(text);
return metrics.width
}

2、JS获取字符串长度的几种常用方法


1. 通过charCodeAt判断字符编码


通过charCodeAt获取指定位置字符的Unicode编码,返回的值对应ASCII码表对应的值,0-127包含了常用的英文、数字、符号等,这些都是占一个字节长度的字符,而大于127的为占两个字节长度的字符。


function calcTextLength(text) {
let realLength = 0;
for (let i = 0; i < text.length; i++) {
charCode = text.charCodeAt(i);
if (charCode >= 0 && charCode <= 128) {
realLength += 1;
} else {
realLength += 2;
}
}
return realLength;
}

2. 采取将双字节字符替换成"aa"的做法,取长度


function getTextWidth(text) {
return text.replace(/[^\x00-\xff]/g,"aa").length;
};

参考文章


1. JS如何判断文字被ellipsis了?


2. Canvas API 中文网


3. JS获取字符串长度的常用方法,汉字算两个字节


4. canvas绘制字体偏上不居中问题、文字垂直居中后偏上问题、measureText方法和TextMetrics对象


作者:娜个小部呀
来源:juejin.cn/post/7329967013923962895
收起阅读 »

uniapp踩坑合集

web
1、onPullDownRefresh下拉刷新不生效 pages.json对应的style中enablePullDownRefresh设置为true,开启下拉刷新 { "path" : "pages/list/list", "style" : ...
继续阅读 »

1、onPullDownRefresh下拉刷新不生效


pages.json对应的style中enablePullDownRefresh设置为true,开启下拉刷新
{
"path" : "pages/list/list",
"style" :
{
"navigationBarTitleText": "页面标题名称",
"enablePullDownRefresh": true
}
}

2、onReachBottom上拉加载不生效


page中css样式设置了height:100%;
修改为height:auto;即可

3、onPageScroll生命周期不触发


最外层css样式设置了以下样式
height: 100%;
overflow: scroll;

4、onBackPress监听页面返回生命周期


使用场景:APP手机左滑返回时控制执行某些操作,不直接返回上一页(例如:弹框打开时关闭弹框)


注意事项


1、onBackPress上不可使用async,会导致无法阻止默认返回


2、支付宝小程序只有真机可以监听到非navigateBack引发的返回事件(使用小程序开发工具时不会触发onBackPress),不可以阻止默认返回行为


3、只有在该函数中返回值为 true 时,才表示不执行默认的返回,自行处理此时的业务逻辑


4、当不阻止页面返回却直接调用页面路由相关接口(如:uni.switchTab)时,可能会导致页面显示异常,可以通过延迟调用路由相关接口解决


5、H5 平台,顶部导航栏返回按钮支持 onBackPress(),浏览器默认返回按键及Android手机实体返回键不支持 onBackPress()


6、暂不支持直接在自定义组件中配置该函数,目前只能是在页面中来处理。


//场景1:弹框打开时,返回执行关闭弹框
//html
"searchPop" type="right" @change="popupChange">
<view class="popup-con">1111view>


//js
export default {
data() {
return {
boxShow: false
}
},
onBackPress(options) {
if( this.boxShow ){
this.$refs.searchPop.close();
return true
}
//其他情况执行默认返回
},
methods: {
popupChange(e) {
this.boxShow = e.show;
},
}
}

//场景2,多级返回时
export default {
data() {
return {
boxShow: false
}
},
onBackPress(options) {
if( this.boxShow ){
this.$refs.searchPop.close();
return true
}else{
if (options.from === 'navigateBack') {
return false;
}
uni.navigateBack({
delta: 2
});
}
},
methods: {
popupChange(e) {
this.boxShow = e.show;
},
}
}


5、遮罩层不能遮底部导航栏


应用场景:APP升级弹框提示


uni文档api界面——交互反馈中uni.showModal可以遮罩底部导航栏;
uni.showToast(OBJECT)、uni.showLoading(OBJECT)都无法遮罩底部导航栏;


目前可以采用两种方式解决:自定义底部导航栏、打开时隐藏底部导航栏


方法一:自定义底部导航栏


1、在app.vue页面的onLaunch生命周期中隐藏原生底部  
onLaunchfunction() {
console.log('App Launch')
uni.hideTabBar();
}

2、自己封装tab组件
<template>
<view class="foot-bar">
<view v-if="hasBorder" class="foot-barBorder">view>
<view class="foot-con">
<view class="foot-list" v-for="(item,index) in tabList" :key="index" @tap="tabJump(index,item.pagePath)">
<img v-if="index!=selectedIndex" class="foot-icon" :src="'/'+item.iconPath" mode="heightFix" />
<img v-else class="foot-icon" :src="'/'+item.selectedIconPath" mode="heightFix" />
<text v-if="index!=selectedIndex" :style="textStyle">{{item.text}}text>
<text v-else :style="textSelectStyle">{{item.text}}text>
view>
view>
view>
template>

<script>
export default {
name: "tabBar",
props: {
hasBorder: {
type: Boolean,
default: false
},
selectedIndex:{
type:[String,Number],
default:0
},
textStyle: {
type: Object,
default () {
return {
color:'#999'
}
}
},
textSelectStyle:{
type: Object,
default () {
return {
color: 'rgb(0, 122, 255)'
}
}
}
},
data() {
return {
tabList: [{
"pagePath": "pages/tabBar/component/component",
"iconPath": "static/component.png",
"selectedIconPath": "static/componentHL.png",
"text": "内置组件"
},
{
"pagePath": "pages/tabBar/API/API",
"iconPath": "static/api.png",
"selectedIconPath": "static/apiHL.png",
"text": "接口"
}, {
"pagePath": "pages/tabBar/extUI/extUI",
"iconPath": "static/extui.png",
"selectedIconPath": "static/extuiHL.png",
"text": "扩展组件"
}, {
"pagePath": "pages/tabBar/template/template",
"iconPath": "static/template.png",
"selectedIconPath": "static/templateHL.png",
"text": "模板"
}
]
};
},
methods:{
tabJump(index,url){
if( index == this.selectedIndex ){
return
}
uni.switchTab({
url: '/' + url
})
}
}
}
script>

<style lang="scss" scoped>
.foot-bar {
position: fixed;
left: 0px;
right: 0px;
bottom: 0px;
z-index: 998;
width: 100vw;
.foot-barBorder {
position: absolute;
left: 0px;
right: 0px;
top: -1px;
width: 100vw;
height: 1px;
background-color: #eee;
}
.foot-con {
background-color: #fff;
width: 100vw;
height: 50px;
display: flex;
flex-direction: row;
align-items: center;
.foot-list {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
.foot-icon{
width: auto;
height:30px;
}
text{
font-size: 12px;
}
}
}
}
style>


3、需要底部导航的页面引入组件,当前页面是导航栏第几个,selectedIndex就等于几,从0开始
<template>
<view>
<TabBar :selectedIndex="0">TabBar>
view>
template>
<script>
import TabBar from "@/components/tabBar/tabBar";
export default {
components:{
TabBar
},
data() {
return {}
}
}
script>


方法二:打开时隐藏底部导航栏,关闭时打开导航栏


uni.hideTabBar();uni.showTabBar(); 官方文档


image.png


image.png


6、条件编译的正确写法


语法:以 #ifdef 或 #ifndef 加 %PLATFORM% 开头,以 #endif 结尾



  • #ifdef:if defined 仅在某平台存在

  • #ifndef:if not defined 除了某平台均存在

  • %PLATFORM%:平台名称


//仅出现在 App 平台下的代码  
#ifdef APP-PLUS
需条件编译的代码
#endif

//除了 H5 平台,其它平台均存在的代码  
#ifndef H5
需条件编译的代码
#endif

在 H5 平台或微信小程序平台存在的代码  
#ifdef H5 || MP-WEIXIN
需条件编译的代码
#endif

//css样式中  
page{
padding-top:24rpx;
/* #ifdef  H5 */
padding-top:34rpx;
/* #endif */
}

//.vue页面中  
<template>

<view>NFC扫码view>

template>

//page.json页面中  
//json文件中
//API 的条件编译
//生命周期中
//methods方法中
mounted(){
// #ifdef APP-PLUS
//APP更新
this.checkUpdate();
//#endif
}

7、限制input输入类型,replace不生效


//不生效代码
"Code" type="number" placeholder="请输入号码" clearable trim="all" :inputBorder="false" @input="gunChange" maxlength="11">

methods:{
gunChange(e){
this.addForm.oilGunCode = e.replace(/[^\d]/g, '');
},
}

使用v-model绑定值时,replace回显不生效;将v-model修改为:value即可生效;


//生效代码
all" :inputBorder="false" @input="gunChange" maxlength="11">

限制只能输入数字:/[^\d]/g/\D/g (但无法限制0开头)
限制只能输入大小写字母、数字、下划线:/[^\w_]/g
限制只能输入小写字母、数字、下划线:/[^a-z0-9_]/g
限制只能输入数字和点:/[^\d.]/g
限制只能输入中文:/[^\u4e00-\u9fa5]/g
限制只能输入英文(大小写均可):/[^a-zA-Z]/g
去除空格:/\s+/g

8、常见的登录验证


"name" placeholder="请输入用户名" clearable trim="all" maxlength="11">
<uni-easyinput v-model="tell" placeholder="请输入手机号" clearable trim="all" maxlength="11">uni-easyinput>

methods:{
submitHandle(){
//姓名 2-5为的汉字
var reg0 = /^[\u4e00-\u9fa5]{2,5}$/,
//用户名正则,4到16位(字母,数字,下划线,减号)
var reg = /^[a-zA-Z0-9_-]{4,16}$/;
//密码强度正则,最少6位,包括至少1个大写字母,1个小写字母,1个数字,1个特殊字符
var reg2 = /^.*(?=.{6,})(?=.*\d)(?=.*[A-Z])(?=.*[a-z])(?=.*[!@#$%^&*? ]).*$/;
//Email正则
var reg3 = /^([A-Za-z0-9_\-\.])+\@([A-Za-z0-9_\-\.])+\.([A-Za-z]{2,4})$/;
//手机号正则
var reg4 = /^((13[0-9])|(14[5|7])|(15([0-3]|[5-9]))|(18[0,5-9]))\d{8}$/;
//身-份-证号(18位)正则
var reg5 = /^[1-9]\d{5}(18|19|([23]\d))\d{2}((0[1-9])|(10|11|12))(([0-2][1-9])|10|20|30|31)\d{3}[0-9Xx]$/;
//车牌号正则
var reg6 = /^[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领A-Z]{1}[A-Z]{1}[A-Z0-9]{4}[A-Z0-9挂学警港澳]{1}$/;

if( !reg.test(this.name) ){
uni.showToast(
title:'用户名格式不正确!'
)
}
},
}

作者:CRMEB技术团队
来源:juejin.cn/post/7272185503822086203
收起阅读 »

React Server Components引发的分歧与机遇

web
介绍 React Server Components 在以前,当用户访问一个 React 应用时,服务端会返回一个空的 HMTL 文件,里面包含一个或多个 JavaScript 文件,浏览器解析 HTML,然后下载 JavaScript 文件,并在客户端呈现网...
继续阅读 »

介绍 React Server Components


在以前,当用户访问一个 React 应用时,服务端会返回一个空的 HMTL 文件,里面包含一个或多个 JavaScript 文件,浏览器解析 HTML,然后下载 JavaScript 文件,并在客户端呈现网页。


React Server Components(RSC)的出现拓展了 React 的范围。顾名思义,React Server Components 就是 React 的服务端组件,它们只在服务端运行,可以调用服务端的方法、访问数据库等。RSC 每次预渲染后把 HTML 发送到客户端,由客户端进行水合(hydrate)并正式渲染。这种做法的好处是,一部分原本要打包在客户端 JavaScript 文件里的代码,现在可以放在服务端运行了,从而减轻客户端的负担,提升应用的整体性能和响应速度。


「充分利用服务器资源」是发布 RSC 的最大动机,换句话说就是:一切不需要交互的内容都应当放到服务端。React 官方举了一个非常典型的例子——渲染 markdown 内容,


// 客户端组件渲染

import marked from 'marked'; // 35.9K (11.2K gzipped)
import sanitizeHtml from 'sanitize-html'; // 206K (63.3K gzipped)

function NoteWithMarkdown({text}) {
const html = sanitizeHtml(marked(text));
return (/* 渲染 */);
}

这个例子中,如果用客户端组件渲染,客户端至少要下载 200 多k的文件才能渲染出内容,但这里的 markdown 内容其实不需要交互,也不会因为用户的操作产生更新信息的需求,非常符合使用 RSC 的理念。如果使用 RSC,


// 服务器组件渲染

import marked from 'marked'; // 零打包大小
import sanitizeHtml from 'sanitize-html'; // 零打包大小

function NoteWithMarkdown({text}) {
// 与之前相同
}

依赖包放在服务端,服务端只返回用户需要看到的内容,客户端包一下子就小了 200 多k。


直到这里,社区主流观点都是积极的,直到 Next.js 基于 RSC 的特性野蛮狂奔,分歧出现了。


社区分歧


出现分歧的最根本原因是 React 引入了服务端的概念,服务端组件和客户端组件有着明显差异:



  • 服务器组件不能使用像 useState 和 useEffect 这样的 React hook;客户端则可以;

  • 服务器组件无权访问浏览器 API;客户端有完整的浏览器 API 权限;

  • 服务端有权限直接访问服务端程序和 API;而客户端组件只能通过请求访问部分程序。


随着 Next.js v13 和 v14 版本发布,React 仍然是金丝雀版本的 RSC 被 Next.js 搬到生产环境,‘use client’‘use server’ 被越来越多人讨论,开发者们说现在有「两个 React」,社区开始争吵 React 这些年在进步还是在退步?


WechatIMG4559.jpeg


社区里反对的声音


首先是知名软件工程师 Cassidy Williams,她指出 React 这两年的发展问题:



  • 「两个 React」带来的新概念对大多数人来说并不是清晰易懂的知识,这种分裂可能导致了额外的混淆和学习障碍。

  • 自 2022 年 6 月以来 React 不仅没有新的发布,还鼓励开发者使用上层框架,而这些上层框架不等 RSC 升级成稳定版,就发布了基于 RSC 的特性(就差点名 Next.js 了)。

  • React 近些年有成员加入其他上层框架的团队,不仅疏于更新版本,还疏于更新文档。


React Query 的开发者 Tanner Linsley 也对 React 的发展表达了担忧和不满:



  • 自从 React 引入 hooks 和 suspense API 以来,React 过分专注于少数几个概念,这些新概念虽然在技术上推动了单线程 UI API 的极限和边界,但对他日常为用户提供价值的工作影响甚微。

  • 从 RSC 发布看出来,React 团队对客户端性能已经没有那么强烈的追求了。


地图技术和可视化技术专家 Tom MacWright 对 React 生态系统的分裂进行了批评:



  • 当前 React 更新缓慢,反而说两个上层框架Remix(由 Shopify 资助)和 Next.js(由 Vercel 资助)在激烈竞争。

  • React 团队和 Next.js 团队交集过多,让 Vercel 获得了领先优势,那些不属于 Vercel 和 Facebook 生态系统的其他框架,如 Remix,它们会受到 React 中已修复但未发布错误的影响。


社区里积极的态度


面对社区里越来越多的反对声音,React 主要贡献者 Dan Abramov 也多次发表里自己看法,他对技术的变革持开放态度:



  • Next.js 的 App Router 有着雄心壮志,但是现在还是发展初期,未来会迭代得更优秀。

  • 客户端组件的工作是 UI = f(state),服务端组件的工作是 UI = f(data),React 希望组合二者的优势,实现 UI = f(data, state),他号召社区共同推动实现这一目标。

  • 对于 Next.js 把 RSC 发布到生产版本,Dan 认为“生产就绪”是一个主观的判断,虽然 RSC 还是金丝雀版本,但是 Facebook 也已经大量使用了。他认为在实践中验证才能更快完善技术,最终达到成熟和稳定。

  • 新技术的发展是一个渐进的过程,涉及到不断的测试、反馈和迭代,社区的力量非常重要。


总的来说,Dan 是希望大家放下偏见,共同在实践中摸索出 React 下一阶段的变革。


我的观点


在 RSC 的讨论中,我比较认同 Dan 提出的开放和包容性的观点。我认为,面对技术的发展,要抛弃个人偏见,可以实践验证,也可以持续观察它们的发展。只有心态上拥抱变革,开发者才能在变革中找到机遇。


RSC 在提升现代 Web 应用开发绝对是有积极意义的,最显而易见的优势是它可以提高大型应用的性能、减少客户端负载、优化数据获取流程等,通过 RSC 完成这些工作会比以往的 SSR 方案要更加方便。


随着 Node v20 的发布和 RSC 的应用,前端和服务端的距离进一步缩小,我们有机会见证前端工作“后端化”——前端工程师会处理更多传统上属于后端的工作,如数据查询优化、服务器资源管理等。这实际上为前端工程师打开了一扇门,让我们有机会更全面地掌握整个 web 应用的开发流程,也就是我们常说的“全栈开发”。这样的转变势必会提高前端的职业天花板和扩大前端工作的广度。


作者:BigYe程普
来源:juejin.cn/post/7330602636934774823
收起阅读 »

React 19 发布在即,抢先学习一下新特性

web
React 上一次发布版本还要追溯到2022年6月14日,版本号是18.2.0。在大前端领域,一项热门技术更新如此缓慢属实罕见。这也引起社区里一些大佬的不满,在我的上一篇文章里有提到,感兴趣的朋友可以点击查看:React 社区里的分歧。 在社区不满的声音越来越...
继续阅读 »

React 上一次发布版本还要追溯到2022年6月14日,版本号是18.2.0。在大前端领域,一项热门技术更新如此缓慢属实罕见。这也引起社区里一些大佬的不满,在我的上一篇文章里有提到,感兴趣的朋友可以点击查看:React 社区里的分歧


在社区不满的声音越来越大的背景下,React 新版本的消息终于来了。


React 团队也回应了迟迟未发布新的正式版本的质疑:此前发布到 Canary 版本的多项特性,因为这些特性是相互关联的,所以 React 团队需要投入大量时间确保它们能够协同工作,然后才能逐步发布到 Stable 版本。


事实也确实如此,虽然在这将近两年的时间里 React 没有发布正式版本,但是 Canary 却有一些重磅更新,例如:useuseOptimistic hook,use clientuse server 指令。这些更新客观上丰富了 React 生态系统,特别是推动了 Next.js 和 Remix 等全栈框架的高速发展。


React 团队已经确定,下一个版本将是大版本号,即版本号会是 19.0.0。


v19 新特性预测


现在,让我们根据 React 团队最新发布的消息,来抢先学习一下 v19 版本可能正式发布的新特性。


自动记忆化


你是否还记得 React Conf 2021 上黄玄介绍的 React Forget?




现在,它来了。


它是一个编译器,目前已经在 instagram 的生产环境中应用,React 团队计划在 Meta 的更多平台中应用,并且未来会进行开源发布。


在使用新编译器以前,我们使用 useMemouseCallbackmemo 来手动缓存状态,以减少不必要的重新渲染,这种实现方式虽然可行,但 React 团队认为这并不是他们认为理想的方式,他们一直寻找让 React 在状态变化时自动且只重新渲染必要部分的方案。经过多年的攻坚,现在新的编译器成功落地了。


新的 React 编译器会是一个开箱即用的特性,对开发者来说是又一次开发范式的改变,这也是 v19 最让人期待的功能。


好玩的是,React 团队在介绍新编译器时完全没有提到“React Forget”,这也让好事的网友爆梗了:They forget React Forget & forget to mentioned Forget in the Forget section.🤣


Actions


React Actions 是 React 团队在探索客户端向服务器发送数据的解决方案过程中发展出来的,这个功能允许开发者向 DOM 元素(如 <form/>)传递一个函数:

<form action={search}>
<input name="query" />
<button type="submit">Search</button>
</form>

action 函数可以同步或异步操作。使用 action 时,React 将为开发者管理数据提交的生命周期,我们可以通过 useFormStatususeFormState 这两个 hook 来访问表单操作的当前状态和响应。


action 可以在执行数据库变更(如增加、删除、更新数据)和实现表单(如登录表单、注册表单)等客户端到服务器交互的场景中使用。


action 不仅可以与 useFormStatususeFormState 结合使用,还可以用与 useOptimisticuse server 结合使用。详细展开篇幅就会很长了,你可以关注我,很快我会单独写一篇文章介绍 action 的详细用法。


指令:use client 与 use server


use clientuse server 两个指令在 Canary 版本发布已久,终于也要在 v19 版本里加入 Stable 版本了。


此前社区频频有人因为 Next.js 在生产环境使用这两个指令而指责 Next.js 在破坏 React 生态、批评 React 团队纵容 Next.js 超前使用非稳定特性。其实大可不必,因为这两个指令就是为 Next.js 和 Remix 这样的全栈框架设计的,短期内普通开发者使用 React 开发应用几乎不会用到它们。


如果你是使用 React,而不是使用全栈框架,你只需要了解这两个指令的作用即可:use clientuse server 标记了前端和服务端两个环境的“分割点”,use client 指示打包工具生成一个 <script> 标签,而 use server 告诉打包工具生成一个POST端点。这两个指令能够让开发者在一份文件里同时写客户端代码和服务端代码。



💡 如果你对这两个指令感兴趣,可以来看我的另一篇文章:「🌍NextJS v13服务端组件和客户端组件及最佳实践



useOptimistic 乐观更新



💡 乐观更新:是一种在前端开发中常用的处理异步操作反馈的策略。它基于一种“乐观”的假设:即假设无论我们向服务器发送什么请求,这些操作都将成功执行,因此在得到服务器响应之前,我们就提前在用户界面上渲染这些改变。

使用场景:点赞、评论、任务添加编辑等。



useOptimistic 是一个新的 hook,很可能在 v19 版本中被标记为稳定版。useOptimistic 允许你在异步操作(如网络请求)进行时,乐观地更新 UI。它通过接受当前状态和一个更新函数作为参数,返回一个在异步操作期间可能会有所不同的状态副本。你需要提供一个函数,这个函数接收当前状态和操作的输入,并返回在操作等待期间使用的乐观状态。


它的用法定义如下:

const [optimisticState, addOptimistic] = useOptimistic(state, updateFn);

// or

const [optimisticState, addOptimistic] = useOptimistic(
state,
// updateFn
(currentState, optimisticValue) => {
// merge and return new state with optimistic value
}
);

参数



  • state: 初始状态值,以及在没有操作进行时返回的值。

  • updateFn(currentState, optimisticValue) : 一个函数,接收当前状态和传递给 addOptimistic 的乐观值,返回结果乐观状态。updateFn 接收两个参数:currentStateoptimisticValue。返回值将是 currentStateoptimisticValue 的合并值。


返回值



  • optimisticState: 产生的乐观状态。当有操作正在进行,它等于 updateFn 返回的值,没有操作正在进行,它等于 state

  • addOptimistic: 这是在进行乐观更新时调用的调度函数。它接受一个参数 optimisticValue(任意类型),并调用带有 stateoptimisticValueupdateFn


更详细的用法如下:

import { useOptimistic } from 'react';

function AppContainer() {
const [state, setState] = useState(initialState); // 假设有一个初始状态
const [optimisticState, addOptimistic] = useOptimistic(
state,
// updateFn
(currentState, optimisticValue) => {
// 合并返回:新状态、乐观值
return { ...currentState, ...optimisticValue };
}
);

// 假设有一个异步操作,如提交表单
function handleSubmit(data) {
// 在实际数据提交前,使用乐观更新
addOptimistic({ data: 'optimistic data' });

// 然后执行异步操作
fetch('/api/submit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
})
.then(response => response.json())
.then(realData => {
// 使用实际数据更新状态
setState(prevState => ({ ...prevState, data: realData }));
});
}

return (
// 使用 optimisticState 来渲染 UI
<div>{optimisticState.data}</div>
);
}



useOptimistic 会在异步操作进行时先渲染预期的结果,等到异步操作完成,状态更新后,再渲染真实的返回结果(无论成功和失败)。


其它更新


除此之外,React 团队成员 Andrew Clark 还透露2024年还会有以下变化:



  • forwardRef → ref is a prop:简化对子组件内部元素或组件的引用方式,使 ref 作为一个普通的prop传递

  • React.lazy → RSC, promise-as-child:增强了代码分割和懒加载能力

  • useContext → use(Context):提供一种新的方式来访问 Context

  • throw promise → use(promise):改进异步数据加载的处理方式

  • <Context.Provider> → <Context>:简化了上下文提供者的使用


但目前 React 官网没有对以上潜在更新提供详细的信息。


总结


React 的愿景很大,他们希望打破前端和后端的边界,在维持自身客户端能力优势的基础上,同时为社区的全栈框架提供基建。我非常认可他们的做法,因为打破了端的边界,才能帮助前端工程师打破职业天花板。


React 19 会是引入 hooks 之后又一次里程碑式版本,Andrew Clark 说新版本将在 3 月或 4 月发布,让我们拭目以待!


作者:BigYe程普
来源:juejin.cn/post/7339221543992426559
收起阅读 »

MyBatis-Plus快速入门指南:零基础学习也能轻松上手

在Java开发的世界里,持久层框架的选择对于项目的成功至关重要。今天,我们要聊的主角是MyBatis-Plus——一个增强版的MyBatis,它以其强大的功能、简洁的代码和高效的性能,正在成为越来越多开发者的新宠。那么,MyBatis-Plus到底是什么?又该...
继续阅读 »

在Java开发的世界里,持久层框架的选择对于项目的成功至关重要。今天,我们要聊的主角是MyBatis-Plus——一个增强版的MyBatis,它以其强大的功能、简洁的代码和高效的性能,正在成为越来越多开发者的新宠。

那么,MyBatis-Plus到底是什么?又该如何快速入门呢?让我们一起探索这个强大的工具。

一、MyBatis-Plus简介

1、简介

MyBatis-Plus (opens new window)(简称 MP)是一个 MyBatis (opens new window)的增强工具,在 MyBatis 的基础上只做增强不做改变,为简化开发、提高效率而生。

Description

2、特性

无侵入: 只做增强不做改变,引入它不会对现有工程产生影响,如丝般顺滑。

损耗小: 启动即会自动注入基本 CURD,性能基本无损耗,直接面向对象操作,BaseMapper。

强大的 CRUD 操作: 内置通用 Mapper、通用 Service,仅仅通过少量配置即可实现单表大部分 CRUD 操作,更有强大的条件构造器,满足各类使用需求,简单的CRUD操作不用自己编写。

支持 Lambda 形式调用: 通过 Lambda 表达式,方便的编写各类查询条件,无需再担心字段写错。

支持主键自动生成: 支持多达 4 种主键策略(内含分布式唯一 ID 生成器 - Sequence),可自由配置,完美解决主键问题。

支持 ActiveRecord 模式: 支持 ActiveRecord 形式调用,实体类只需继承 Model 类即可进行强大的 CRUD 操作。

支持自定义全局通用操作: 支持全局通用方法注入( Write once, use anywhere )。

内置代码生成器: 采用代码或者 Maven 插件可快速生成 Mapper 、 Model 、 Service 、 Controller 层代码,支持模板引擎,更有超多自定义配置等您来使用(自动生成代码)。

内置分页插件: 基于 MyBatis 物理分页,开发者无需关心具体操作,配置好插件之后,写分页等同于普通 List 查询。

分页插件支持多种数据库: 支持 MySQL、MariaDB、Oracle、DB2、H2、HSQL、SQLite、Postgre、SQLServer 等多种数据库。

内置性能分析插件: 可输出 SQL 语句以及其执行时间,建议开发测试时启用该功能,能快速揪出慢查询。

内置全局拦截插件: 提供全表 delete 、 update 操作智能分析阻断,也可自定义拦截规则,预防误操作。

3、框架结构

Description

二、快速入门

1.开发环境

2.创建数据库和表

1)创建表单

CREATE DATABASE `mp_study` /*!40100 DEFAULT CHARACTER SET utf8mb4 */;
use `mp_study`;
CREATE TABLE `user` (
`id` bigint(20) NOT NULL COMMENT '主键ID',
`name` varchar(30) DEFAULT NULL COMMENT '姓名',
`age` int(11) DEFAULT NULL COMMENT '年龄',
`email` varchar(50) DEFAULT NULL COMMENT '邮箱',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

2)添加数据

INSERT INTO user (id, name, age, email) VALUES
(1, 'Jone', 18, 'test1@baomidou.com'),
(2, 'Jack', 20, 'test2@baomidou.com'),
(3, 'Tom', 28, 'test3@baomidou.com'),
(4, 'Sandy', 21, 'test4@baomidou.com'),
(5, 'Billie', 24, 'test5@baomidou.com');

3. 创建SpringBoot工程

1)初始化工程

2)导入依赖

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.1</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>

4. 编写代码

1)配置application.yml

# DataSource Config
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/mybatis_plus?useSSL=false&useUnicode=true&characterEncoding=utf-8&serverTimezone=GMT%2B8
username: root
password: 1234

2)启动类

在Spring Boot启动类中添加@MapperScan注解,扫描mapper包

@MapperScan("cn.frozenpenguin.mapper")
@SpringBootApplication
public class MybatisPlusStudyApplication {

public static void main(String[] args) {
SpringApplication.run(MybatisPlusStudyApplication.class, args);
}
}

3)添加实体类

@Data//lombok注解
public class User {
private Long id;
private String name;
private Integer age;
private String email;
}

4)添加mapper
BaseMapper是MyBatis-Plus提供的模板mapper,其中包含了基本的CRUD方法,泛型为操作的实体类型

public interface UserMapper extends BaseMapper<User> {
}

5)测试

@Autowired
private UserMapper userMapper;

@Test
void test01(){
List<User> users = userMapper.selectList(null);
for (User user : users) {
System.out.println(user);
}
}

结果
Description
注意:

IDEA在 userMapper 处报错,因为找不到注入的对象,因为类是动态创建的,但是程序可以正确执行。为了避免报错,可以在mapper接口上添加 @Repository注解。

6)添加日志

我们所有的sql现在是不可见的,我们希望知道它是怎么执行的,所以我们必须要看日志!

在application.yml中配置日志输出

# 配置日志
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
mapper-locations:

三、基本CRUD

1.插入

 @Test
void insert()
User user = new User(null, "lisi", 2, "aaa@qq.com");
int insert = userMapper.insert(user);
System.out.println("受影响行数"+insert);
//1511332162436071425
System.out.println(user.getId());
}

id设置为null,却插入了1511332162436071425,这是因为MyBatis-Plus在实现插入数据时,会默认基于雪花算法的策略生成id。

2.删除

1)通过id删除记录

@Test
void testDeleteById(){
//DELETE FROM user WHERE id=?
int result = userMapper.deleteById(1);
System.out.println("受影响行数:"+result);
}
  1. 通过id批量删除记录
@Test
void testDeleteBatchIds(){
//DELETE FROM user WHERE id IN ( ? , ? , ? )
int result = userMapper.deleteBatchIds(ids);
System.out.println("受影响行数:"+result);
}
  1. 通过map条件删除记录
@Test
void testDeleteByMap(){
//DELETE FROM user WHERE name = ? AND age = ?
Map<String,Object> map=new HashMap<>();
map.put("age",12);
map.put("name","lisi");
int result = userMapper.deleteByMap(map);
System.out.println("受影响行数:"+result);
}

3. 修改

@Test
void testUpdateById(){
//SELECT id,name,age,email FROM user WHERE id=?
User user = new User(10L, "hello", 12, null);
int result = userMapper.updateById(user);
//注意:updateById参数是一个对象
System.out.println("受影响行数:"+result);
}

4.自动填充

  • 创建时间、修改时间!这些个操作都是自动化完成的,我们不希望手动更新!

  • 阿里巴巴开发手册:所有的数据库表:gmt_create、gmr_modified、几乎所有的表都要配置上!而且需要自动化!

方式一:数据库级别(工作中不允许修改数据库)

1)在表中新增字段 create_time, update_time;

Description

2)再次测试插入方法,我们需要先把实体类同步!

3)再次更新查看结果即可。

Description

方式二:代码级别

  • 删除数据库的默认值,更新操作

  • 实体类的字段属性上需要加注解

@TableField(fill = FieldFill.INSERT)
private Date createTime;

@TableField(fill = FieldFill.INSERT_UPDATE)
private Date updateTime;
  • 编写处理器处理注解
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
// 起始版本 3.3.0(推荐使用)
this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now());
}


@Override
public void updateFill(MetaObject metaObject) {
// 起始版本 3.3.0(推荐)
this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
}
}
  • 测试插入

  • 测试更新、观察时间即可

在这里给大家分享一下【云端源想】学习平台,无论你是初学者还是有经验的开发者,这里都有你需要的一切。包含课程视频、知识库、微实战、云实验室、一对一咨询等等,现在功能全部是免费的,

点击这里,立即开始你的学习之旅!

5.查询

  • 与查询基本一致;

  • 根据id查询用户信息;

  • 根据多个id查询多个用户信息;

  • 通过map条件查询用户信息;

  • 查询所有数据;

@Test
void test01(){
List<User> users = userMapper.selectList(null);
for (User user : users) {
System.out.println(user);
}
}

通过观察BaseMapper中的方法,大多方法中都有Wrapper类型的形参,此为条件构造器,可针 对于SQL语句设置不同的条件,若没有条件,则可以为该形参赋值null,即查询(删除/修改)所有数据。

6.通用Service

说明:

  • 通用 Service CRUD 封装IService接口,进一步封装 CRUD;

  • 采用 get 查询单行;

  • remove 删除;

  • list 查询集合;

  • page 分页;

  • 前缀命名方式区分 Mapper 层避免混淆;

  • 泛型 T 为任意实体对象;

  • 建议如果存在自定义通用 Service 方法的可能,请创建自己的 IBaseService 继承 Mybatis-Plus 提供的基类;

  • 官网地址:https://baomidou.com/pages/49cc81/#service-crud-%E6%8E%A5%E5%8F%A3。

1)IService

MyBatis-Plus中有一个接口 IService和其实现类 ServiceImpl,封装了常见的业务层逻辑 详情查看源码IService和ServiceImpl。

2)创建Service接口和实现

/**
* UserService继承IService模板提供的基础功能
*/
public interface UserService extends IService<User> {
}
/**
* ServiceImpl实现了IService,提供了IService中基础功能的实现
* 若ServiceImpl无法满足业务需求,则可以使用自定的UserService定义方法,并在实现类中实现
*/
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
}

3)测试查询记录数

@Test
void testGetCount(){
long count = userService.count();
System.out.println("总记录数:" + count);
}

4)测试批量插入

@Test
void testSaveBatch(){
// SQL长度有限制,海量数据插入单条SQL无法实行,
// 因此MP将批量插入放在了通用Service中实现,而不是通用Mapper
ArrayList<User> users = new ArrayList<>();
for (int i = 0; i < 5; i++) {
User user = new User();
user.setName("lyl"+i);
user.setAge(20+i);
users.add(user);
}
//SQL:INSERT INTO t_user ( username, age ) VALUES ( ?, ? )
userService.saveBatch(users);
}

原理:先把user对象存到list(存在内存中),然后直接save集合list中的所有user。

MyBatis-Plus作为MyBatis的增强版,不仅继承了MyBatis的所有优点,还在此基础上做了大量的改进和扩展。它的出现,无疑为Java开发者提供了一个更为强大、便捷的数据操作工具。

技术的世界总是在不断进步,而我们作为开发者,也需要不断学习新的工具和技术。MyBatis-Plus正是这样一把钥匙,它能打开高效数据操作的大门。希望本文能帮助您快速入门MyBatis-Plus!

收起阅读 »

【HTML】交友软件上照片的遮罩是如何做的

web
笑谈 我不知道大家有没有在夜深人静的时候感受到孤苦难耐,🐶。于是就去下了一些交友软件来排遣寂寞。可惜的是,有些交友软件真不够意思,连一些漂亮小姐姐的图片都要进行遮罩,完全不考虑兄弟们的感受,😠。所以今天,我们就一起来看看这些软件的遮罩是如何做的,🐶。 调研...
继续阅读 »

笑谈


我不知道大家有没有在夜深人静的时候感受到孤苦难耐,🐶。于是就去下了一些交友软件来排遣寂寞。可惜的是,有些交友软件真不够意思,连一些漂亮小姐姐的图片都要进行遮罩,完全不考虑兄弟们的感受,😠。所以今天,我们就一起来看看这些软件的遮罩是如何做的,🐶。


调研


市场上这些交友软件比较多,就拿一个我朋友他经常玩的一个软件来研究,叫做《XX之恋》,重申一下,我这里没有任何打广告的嫌疑,毕竟是我朋友玩的,🐶。我们接下来看这软件中遮罩的图片。



注:我实在没有在网上找到该软件这些有遮罩的图片,所以只好从自己的主页上截取了下,如果有当事人认为这是自己的话,请速与我联系,我会及时删除的。




正如上面所见,该软件的遮罩效果还是非常不错的,为什么说非常不错呢?个人认为有两个亮点,🐶保命。



  1. 这个遮罩效果让我们知道对面是女生。

  2. 这个遮罩效果也仅仅只能让我们知道对面是女生。


言归正传,这种效果在我们悠久的前端历史上,有一种专业名词 --> 毛玻璃效果



碎碎念:我看了蛮多毛玻璃的技术文章,这个技术大家说都是为了让能人阅读的时候更赏心悦目,能用来遮小姐姐也算是很不错的创新了。🐶



实现


现在我们只需要两样东西,一个是小姐姐的图片,一个是前端的小知识。我都准备好啦。首先我们先介绍知识点。


backdrop-filter属性


我们看MDN的介绍文档


可以让你为一个元素后面区域添加图形效果(如模糊或颜色偏移)。因为它适用于元素背后的所有元素,为了看到效果,必须使元素或其背景至少部分透明。


backdrop-filter有如下常用属性(带了数值,方便理解)



  • 🌟blur(2px): 对元素背后的背景应用2像素的模糊效果。

  • brightness(60%): 将元素背景的亮度调整为原始亮度的60%。

  • contrast(40%): 将元素背景的对比度调整为原始对比度的40%。

  • ...............等


我们主要使用的便是blur属性,对背景图片模糊,达到类似的效果。


我采用的小姐姐图片如下




思路:使用两个Class。第一个Class的背景图片是上面的小姐姐,第二个Class完全覆盖第一个Class,设置blur10px即可。如下是代码。

效果图如下:





最后,希望大家多多点赞支持,兄弟们的点赞支持是我继续写文章的动力!



作者:鑫宝Code
来源:juejin.cn/post/7333986476030935050
收起阅读 »

软件License授权原理

软件License授权原理 你知道License是如何防止别人破解的吗?本文将介绍License的生成原理,理解了License的授权原理你不但可以防止别人破解你的License,你甚至可以研究别人的License找到它们的漏洞。喜欢本文的朋友建议收藏+关注,...
继续阅读 »

软件License授权原理


你知道License是如何防止别人破解的吗?本文将介绍License的生成原理,理解了License的授权原理你不但可以防止别人破解你的License,你甚至可以研究别人的License找到它们的漏洞。喜欢本文的朋友建议收藏+关注,方便以后复习查阅。


什么是License?


在我们向客户销售商业软件的时候,常常需要对所发布的软件实行一系列管控措施,诸如验证使用者身份、软件是否到期,以及保存版权信息和开发商详情等。考虑到诸多应用场景可能处于离线环境,无法依赖网络进行实时认证,所以还需要考虑单机认证时的防破解问题。总之,License许可证利用HTTPS网站的证书和签名技术,一方面证明当前使用者是申请License的本人,另一方面要防止恶意破解,并伪造篡改License达到白嫖的目的。


image.png


为什么使用License授权?


License的作用是什么呢?收费软件的License其目的肯定是防止用户白嫖啦,所以License还应该具有以下一些功能:



  • 授权使用


明确用户需要满足的使用条件,如单用户、多用户、企业内部使用、全球使用等,并且通常会限定可安装和激活的设备数量。



  • 限制功能


根据不同等级的License,软件可以提供不同等级的功能,例如基础版、专业版、企业版等,License可以解锁相应版本的功能。


image(1).png



  • 期限控制


规定软件的使用期限,可能是永久授权,也可能是订阅式授权,到期后用户需要续费才能继续使用。



  • 版权保护


重申软件的知识产权归属,禁止未经授权的复制、分发、反编译、篡改或逆向工程等侵犯版权的行为。



  • 法律保障


License作为法律合同,确立了软件提供商和用户之间的法律关系,明确了双方的权利和责任,如果发生违反协议的情况,软件提供商有权采取法律手段追究责任。


image(2).png



  • 技术支持和升级服务


部分License中会规定用户是否有权享有免费的技术支持、软件更新和维护服务,以及这些服务的有效期限。



  • 合规性要求


对于特殊行业或特定地区,License可能还涉及到满足特定法规、标准或认证的要求。


归纳起来,我们可以总结出License的作用是:



  • 控制软件使用者的使用权限

  • 申明软件所有者的版权

  • 规定软件的使用规范


最后两点主要是法律相关的,第一点才是本文的重点,即如何生成License,以及如何通过License对软件用户进行限制。


License分类


依据用途的不同,License可分为两大类别:商用License非商用License


非商用License主要服务于诸如展览展示活动、各类研发活动等多种非直接盈利性的应用场景;


商用License,则通常适用于那些开展商业运营活动的企业场所。


image(3).png


基于使用的期限,License可以划分为固定期限License和永久License两类。


固定期限License在激活后的指定时间内有效,过了预设的使用期限,用户必须更新许可期限并通过重新激活才能继续使用;


而永久License则是在激活后赋予用户无时间限制的使用权,一旦激活,无需担忧许可失效的问题,可以无限期地持续使用软件。


如何实现License授权?


要想生成一个安全性高的License,必须让其满足以下几个特征:



  • 保密性

  • 防篡改

  • 时效性

  • 可找回


保密性是指License里携带的data信息具有一定的隐蔽性,这样可以防止想要破解License的人寻找到生成License的规律,进而伪造自己的License。


防篡改是指防止License里携带的重要信息被篡改,例如License有效时间如果被篡改,那么License就起不到限制用户使用期限的作用了。


时效性是指License会记录软件可以使用的有效期,并在验证License的时候判断其是否过期。


可找回是指用户申请的License一旦丢失或者要续期,基于第一次申请License时创建的源文件,再一次生成新的License,新的License会携带用户当初申请时的信息。


由于License必须满足以上特性,所以在介绍License实现原理之前,我们先来学习一下非对称加密和签名&验签。


非对称加密


有非对称加密必然就会有对称加密,对称加密就是我们一般意义上的加密算法,这种算法在加密和解密时都使用同一个密钥,所以对称加密算法的密钥又叫做共享密钥。对称加密算法一般使用AES(Advanced Encryption Standard)加密算法。


image(4).png


非对称加密有两个密钥,一个公钥一个私钥。公钥是公开的,供多个人使用;私钥是非公开的,仅一个人或者少数群体使用。当非对称加密算法用作加解密时,公钥用来对明文加密,私钥用来给密文解密,这个顺序不能颠倒。你可以这样理解,密文是私密的东西,只有少数人才能解密,所以少数人手里的私钥用来解密,多数人手里的公钥不能解密只能加密。


image(5).png


为什么要区分公钥和私钥呢?直接使用一个共享密钥不行吗?可以,但是前提是你能够安全的将共享密钥传递给对方。共享密钥如何在线上安全的同步给对方是一个问题,毕竟在网络上传输信息很容易暴露。如果使用非对称密钥就可以将公钥同步给消息发送者,而消息接收者则保留私钥用来解密消息,这样即使公钥被中间人盗取,他也只能用来做加密操作而不能解密密文。


签名&验签


虽然非对称加密可以解决“密钥分配问题”,但是它不能防止伪造消息的问题。既然公钥可以公之于众,大家都知道你的消息要怎么加密,假如A想给B发送消息,那么中间人X可不可以将A发送的消息拦截,并将自己的消息加密以后发送给B呢?当然可以!


这就好比你买了一张周杰伦的演唱会的门票,我看到了之后自己伪造了一个一模一样的,如此一来我也可以去看周杰伦的演唱会。这时官方组织者发现了这个漏洞以后,规定周杰伦的演唱会门票需要带上官方印章才能进场,此时我就算把门票画的再惟妙惟肖,少了官方印章,我的这张假门票依然是张废纸。


如何解决这个问题呢?答案就是给你的消息“盖章”,即签名,签名就是认证你的身份。这里还是使用非对称密钥算法,只不过使用的顺序和加密消息时恰好相反。签名时是私钥用来加密,公钥用来解密。


image(6).png


你可以这样理解,给消息签名就好比给文件盖章,你会随随便便把你自己的印章交给别人来使用吗?当然不行!所以公钥不适合用来签名,私钥用作签名更加合理。需要注意的是签名所使用的密钥对由消息发送者生成并提供给消息接收者,这和给消息加密时正好相反,这样说来消息加密和消息签名这两个使用场景就需要生成两套密钥对。


1111_waifu2x_photo_noise1_scale.png


出于性能方面的考虑,大多数情况下给消息加密还是使用的对称加密算法,为了解决“密钥分配问题”,只会在第一次发送共享密钥的时候才会使用非对称加密,一旦消息接收者得到了共享密钥,通信双方就能够通过共享密钥进行通信了。


此外,使用非对称密钥对消息签名也可以防止消息被人篡改,由于性能原因一般不会对消息原文进行签名,而是先通过哈希算法形成消息摘要,再对消息摘要签名。消息接收者验签时会将消息的明文进行哈希,再将消息签名解密,两者比对如果一致则证明消息没有被篡改过。


License结构


前面铺垫了一些生成License所必备的基础知识,我们学习了生成的License如果需要防止被人破解,那就需要具有保密性、防篡改和防伪造等特点。接下来要考虑的是License需要携带什么信息就能让其既安全又能限制用户的使用权限。


License文件理论上来说至少需要以下一些信息:



  • 软件所有者信息

  • 申请授权时间

  • 授权截止时间

  • 软件使用者信息


下图是License文件流的结构图,主要字段有:



  • 魔数值

  • 分隔符

  • 申请时间

  • 到期时间

  • 公钥的长度 & 公钥

  • 携带信息的长度 & 携带信息


安全算法总结-导出(4).png



  • 魔数值:和Java Class文件头的魔数CAFEBABE类似,License文件头的魔数也是起到了快速识别的作用,也有格式验证的作用。

  • 分隔符:用来区分各个字段,将字段之间用分隔符隔开便于结构化管理。

  • 申请时间:用户申请License的日期。

  • 到期时间:License的有效截止日期。

  • 公钥的长度 & 公钥:公钥长度用来记录公钥是多少字节,依据公钥长度就可以读取相应长度的公钥数据了。

  • 携带信息的长度 & 携带信息:携带信息长度用来记录携带信息是多少字节,依据携带信息长度就可以读取相应长度的携带信息了。携带信息里通常会包含软件所有者、软件使用者、License唯一ID以及设备MAC地址等信息。


想好了License文件的结构,我们就可以开始生成License啦。


生成License


申请License的总体流程如下图所示。客户在软件服务商处申请License,软件服务商生成License之后会返回给客户License文件,自己保留一份License源文件,源文件用作以后找回License。客户拿到License文件,在安装、启动软件之后激活License。


license_apply.png


生成License主要做了这样几个事情:



  • 对需要携带的信息加密成密文

  • 对密文签名

  • 保存申请日期、有效截止日期和公钥

  • 生成源文件


安全算法总结-导出.png


私钥1加密的作用是对License的安全加固。因为License实际上可以通过Base64解码得到里面的数据,包括公钥信息,这样客户就能够通过公钥将携带的信息解析出来,倘若携带有敏感信息就会造成安全问题。所以这里对携带的信息做了先加密后签名的处理。


另外需要强调的是,申请日期和有效截止日期也需要签名但不需要加密。因为如果不签名的话,客户可以将日期解析出来之后篡改成自己想要的任何日期。


加载License


smart-license.png


客户申请到License之后,就可以去软件上面激活啦。激活License首先判断License是否合法,检查文件头魔数和分隔符是否正确,检查License是否过期等。然后就是提取License的授权信息进行验签比对。如果有必要,还可以检查授权信息里携带的MAC地址是否与安装设备的MAC地址匹配。如果一切正常就可以通过验证。


安全算法总结-导出(1).png


找回License


license_forget.png


防破解


首先需要明确的一点就是,没有万无一失的防破解方案,所谓魔高一尺道高一丈,漏洞堵的再严实依然能找到破解的方法,唯一的区别就是破解的成本高不高而已


例如,具备一定逆向工程经验的程序员都知道,应用程序不仅能够被调试,也能被修改。理论上讲,只要深入探究程序的代码,定位并替换其中嵌入的原始公钥信息,改为自己的公钥。随后,使用个人持有的私钥去创建一个新的授权文件,这样一来,就实现了对软件授权机制的破解。


更简单的方法是直接反编译验证逻辑的代码,当验证的时候直接返回true,即可通过验证。


即使不能做到百分之百的安全性,我们还是应该知道一些防破解的方法,增加用户破解的难度。防破解主要有以下几个方面的问题需要重点限制。



  • 如何解决java代码反编译之后,修改验证License的逻辑?



答:混淆代码,增加反编译的难度。




  • 如何防止客户修改服务器时间以避免License过期?



答:分为离线和在线两种情况。


在线情况下加载License信息时,可以将License里保存的过期时间和线上标准时间做比较


离线情况下,需要满足条件:申请时间 <= 系统时间 <= 截止时间


具体实现方案是,第一次加载License成功之后,将申请时间存到A处;


定时更新A处的时间,更新前比较当前系统时间,如果系统时间 < 申请时间,说明系统时间被篡改过。否则,更新A处时间为当前系统时间;


保存的时间是经过加密的,但是有个问题是如果用户备份了一开始的时间,过了一段时间之后用这个备份文件恢复,再修改系统时间就可以永不过期,如何解决?


可以将A处的时间信息保存到数据库里,数据库权限设置为只有开发人员可以修改,此外数据库安装的机器不能与软件安装的机器相同,否则用户可以将二者统一安装到某一个虚拟机里,快到期的时候再统一恢复到初始时间。


A处除了保存时间以外,还需要License的唯一id、使用License的机器mac地址,这些字段是为了保证License不被重复使用。




  • 如何防止客户在多台服务器上使用同一个License?



答:将服务器的ip或者mac地址与License做绑定关系。



image.png



  • 如何防止用户得到了源文件并获取了私钥,就可以自己伪造License?



答:避免将生成License的代码安装在用户的机器环境下,最好在自己的机器环境下生成License。因为生成License之后得到的源文件一般会保存在代码路径下,如果用户反编译生成License的代码,就能够得到源文件信息。



最后整理了一张泳道图,可以从整体观察一下不限制、防止篡改系统时间和防止多设备共享License等问题的解决方案。


安全算法总结-导出(3).png


作者:IT果果日记
来源:juejin.cn/post/7338723726837465107
收起阅读 »

如果失业了,我们又将何去何从?

经历 先说说自己的经历吧,小编在21年之前在南京一家国企外包工作过;主要做的是国网的项目,那时候工资不高但是福利待遇不错,什么季度奖、项目奖、年终奖没断过,可能日子太过安逸了吧,自己又想挑战一下高薪,于是就跳槽去了一家做法院业务的公司。跳槽的时候是20年,当时...
继续阅读 »

经历


先说说自己的经历吧,小编在21年之前在南京一家国企外包工作过;主要做的是国网的项目,那时候工资不高但是福利待遇不错,什么季度奖、项目奖、年终奖没断过,可能日子太过安逸了吧,自己又想挑战一下高薪,于是就跳槽去了一家做法院业务的公司。跳槽的时候是20年,当时疫情才刚开始,对经济的影响也还好,所以感觉当时找工作也没那么难。又过了1年,由于小编家人在18年的时候就给我在无锡贷款买了房,所以想早点回去发展,也过够了那种挤在出租屋里的感觉,所以我又辞了来了无锡去了一家做云计算的公司,也还算稳定,待了大概有2年多,从去年开始就开始走下坡路了,23年中旬开始大规模裁员,小编不幸中招。

从那家做云计算的公司走了之后我抑郁了半个月,因为半个 月内都找不到工作,当时真的很着急,因为还有房贷要、信用卡、车贷等等这些要还;从第三周开始我就下定决心要好好好背复习,不只是八股文,还有数据结构、算法 、设计模式等等,功夫不负有心人在第三周终于收获2个offer,此时悬着的心终于落了下来,阴差阳错去了一家做医疗的公司做运维监控平台。

后来不知怎么的裁员风波不断,从不少朋友那边收到消息很多公司都在裁员,很快这波风也刮到了我们公司;有一天我邮件收到了三个月试用期转正的邮件,当时还高兴了一下,但是第二天就通知我转正临时取消,没有任何理由 的取消,随后就有人事来通知我去签合同;我纳闷还要签啥合同啊,不过我大概也猜到点了,因为前些天就有小道消息说要延长试用期,我当时还以为是假的。到了人事那边一看是新的劳动合同,一看试用期变成了6个月,人事和我说应公司领导要求试用期需要延长,剩下3个月的公司还是按打八折发,但是转正后一并给我;我当时惊呆了,心想还能有这种操作,不过没办法看着大家都签了也只能签。这个事情开了头之后,后面又小道消息不断,听人说要降薪20%,很多同事听了都不愿意,于是都被一起约了谈了一次话,有些脾气比较爆的直接怼了领导,最后也就不欢而散。年前董事长又召集了我们研发部聊这个降薪的事情,开头先一堆铺垫,说什么今年怎么怎么难,外面都在裁员什么的,最后又说不降20%了,但是还是会扣5%,这个5%看公司运营情况来发放,大家听完虽然还是不情愿,但也没有再多说什么。


现状


这段时间公司一直没有活,领导也没安排,每天来了就往那一坐就没有方向,再也找不到以前工作的那种感觉了;有些人可能觉得没活干还给你发工资不是挺好嘛,但其实不然你仔细想想,没活干代表公司接不到单子,没有单子就代表没有钱,没钱怎么发工资,所以这种状态持续不了多久。不过这种状态迄今为止已经持续1个多月了,年前我也找过几家单位,面试我觉得面得挺好的,但都没有后续了,有的你问人家,人家却说再等等给你答复还有好多人没面呢,我心想这下完了,今年物联网行情太差了,以前随随便便手里拿好几个offer。越是没活干我就越是焦虑,最近这个班上的真的感觉像坐牢一样,不知道有没有和我一样经历的小伙伴,有兴趣的可以私信和我聊一下。


裁员


23年各大互联网公司裁员情况如下:



  • 知乎:裁员约300人

  • 去哪儿:裁员约400人

  • 搜狐:裁员约800人

  • 美团:裁员约900人

  • 途家:裁员约1400人

  • 京东:裁员约1500人

  • 网易:裁员约2000人

  • 58同城:裁员约3000人

  • 阿里:裁员约4000人

  • 百度:裁员约5000人

  • 滴滴:裁员约5000人


寻找方向


小编是一位Java程序员,现在只会Java写写增删改查根本找不到工作(除非学历很优秀),可以说绝大部分的程序员都能满足传统企业的需求;下面是我整理的可以试试去发展的方向,个人理解不喜勿喷,有兴趣的朋友可以一起去探讨。


研发方向



  • 大数据:也比较卷

  • 云计算:容器、容器编排(K8s)、云原生

  • AI大模型:今年的热门,但是不知道怎么去做


其他方向


image.png


如果不做程序员了我们还能做什么


当前的大环境下确实很艰难,说实话如果不做程序员了,我很难立马想到一个能挣到与之相当工资的工作。虽说360行,行行出状元,可哪一行不都是需要经过千锤百炼才能成功的,隔行如隔山,转行又岂是一朝一夕的事情。

现在的市场Java开发已经趋于饱和,和Boss打招呼基本都是已读不回的状态,甚至面试机会都没有,有的就算面得再好也没用,因为还有一堆人没面跟你竞争,有的期望薪资低,有的学历比你好,有的到岗时间比你早,对此我真的很无奈,快卷不动了。


跑滴滴


由于我经常跑扬州,走高速成本太高了,于是我就注册了个顺风车,想着能回一点成本,基本上去一趟拉一单有100多,来回基本上和成本抵消了。后来我就想着通过顺风车偶尔做做兼职能赚点钱,于是下了班就跑了几回,发现在市区跑订单金额很低,后来跑了几回也就放弃了;后来想注册专车司机,于是就找朋友了解了一下 ,发现注册了滴滴只会让你跑一段时间,然后就要办那个营运证,而且也不适合兼职做,目前还有工作,也许真的哪天失业了我会考虑全职做这个吧。从我朋友那边了解到,滴滴也很卷,每天起早贪黑的,给你派了单子只能被动接受,不像顺风车可以主动选择,不过真到没工作那一步也只能选择尝试一下。


开店


小编比较幸运,在老家小镇还有一个小店面,不过大家也可以租一个,只要做起来了都是一样的,关键是要能把生意搞起来。我其实早就萌生了这个想法,但是我现在才临近30岁,还想在外面再闯一下。


食品小超市


为什么想开这个呢,主要我发现了一个路子,哈哈!我老家有个很有名的食品超市,也是从小店一点一点做起来的,而且我还知道他是去无锡金桥食品批发市场批发的,国产的、进口的食品全都有。今年过年的时候去他们家买东西人都爆满,老板在收银台堆满了车厘子,2J、3J、4J的车厘子卖的好的不行,我在那边没一会就卖了一幢(车厘子堆起来的,堆了一幢),后来听别人说这个老板过个年净利润有四五十万。在我老家那边过个年能赚个四五十万万真的可以吓死人,于是乎开食品超市的想法在我心中萌芽。不过还好我们家那个店铺和他们家靠的不是很近,再开一家也不是不行,再不济也可以考虑和他加盟。

大家可以观察一下老家有没有那种食品超市的店,店里卖的都是比较中高档的吃的,周围人群比较密集的地方可以考虑开一个,只要找到货源能持续供货,这个店应该很容易就能开起来。


电脑店


我们家那个店面我也想过开个电脑店,卖卖电脑、装装系统、装装摄像头什么的,我感觉也不错,要说修电脑硬件我还是没那个本事的,不过可以带到城里面去修。


摆美食小摊


我想着等我年纪再大一点,可以搞个小美食摊,可以做做煎饼、鸡蛋饼、臭豆腐、烤香肠这种,不过不能在城里搞,只能去乡下镇上,城里面城管管得严,不是很好搞。


做短视频


这个想法我也考虑了很久,至今还没开始实践,因为我老是怕我做不好,作为程序员,视频剪辑这种我应该是一学就会。其实我主要就是觉得没有赶上风口,现在做短视频的搞直播的一大堆,没有什么吸引流量的创意真的很难博人眼球。后面我打算尝试尝试记录生活,生活琐事都拍一下,比如出去露营、钓鱼、旅行,在家做饭、健身什么的都可以拍一下,不过我听朋友说要专注拍一类才能拍好,也不知道是不是真的。


总结


给大家总结一下就是今年能不跳槽就尽量不要跳槽,绝对不能裸辞,在今年这个风口浪尖上各大企业都在人员优化、降本增效。欢迎大家多多留言,提一些建议,最后也祝大家龙年大运,在新的一年里找到自己满意的工作。


作者:MrDong先生
来源:juejin.cn/post/7338307026245845044
收起阅读 »

请立即停止编写 Dockerfiles 并使用 docker init

本文翻译自 medium 论坛,原文链接:medium.com/@akhilesh-m… , 原文作者: Akhilesh Mishra 您是那种觉得编写 Dockerfile 和 docker-compose.yml 文件很痛苦的人之一吗? 我承认,我就是...
继续阅读 »

本文翻译自 medium 论坛,原文链接:medium.com/@akhilesh-m… , 原文作者:

Akhilesh Mishra


您是那种觉得编写 Dockerfile 和 docker-compose.yml 文件很痛苦的人之一吗?



我承认,我就是其中之一。



我总是想知道我是否遵循了 Dockerfile、 docker-compose 文件的最佳编写实践,我害怕在不知不觉中引入了安全漏洞。


但是现在,我不必再担心这个问题了,感谢 Docker 的优秀开发人员,他们结合了生成式人工智能,创建了一个 CLI 实用工具 — docker init。


介绍 docker init


微信截图_20240224145630.png


几天前,Docker 推出了 docker init 的通用版本。我已经尝试过,发现它非常有用,迫不及待地想在日常生活中使用它。


什么是 docker init?


docker init 是一个命令行应用程序,可帮助初始化项目中的 Docker 资源。它根据项目的要求创建 Dockerfiles、docker-compose 文件和 .dockerignore 文件。


这简化了为项目配置 Docker 的过程,节省时间并降低复杂性。



最新版本的 docker init 支持 Go、Python、Node.js、Rust、ASP.NET、PHP 和 Java。目前它只能于 Docker Desktop 一起使用,也就是说大家目前在 Linux 系统中是无法使用 docker init 的。



如何使用 docker init?


使用 docker init 很简单,只需几个简单的步骤。首先,转到您要在其中设置 Docker 资源的项目目录。


举个例子,我来创建一个基本的 Flask 应用程序。


一、创建 app.py 以及 requirements.txt


touch app.py requirements.txt

将以下代码复制到相应文件中


# app.py
from flask import Flask

app = Flask(__name__)

@app.route('/')
def hello_docker():
return '<h1> hello world </h1'

if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0')

# requirements.txt
Flask

二、使用 docker init 初始化


docker init 将扫描您的项目并要求您确认并选择最适合您的应用程序的模板。选择模板后,docker init 会要求您提供一些特定于项目的信息,自动为您的项目生成必要的 Docker 资源。


现在让我们来执行 docker init。


docker init

出现如下结果,



接下来要做的就是选择应用程序平台,在我们的示例中,我们使用 python。它将建议您的项目的推荐值,例如 Python 版本、端口、入口点命令。



您可以选择默认值或提供所需的值,它将创建您的 docker 配置文件以及动态运行应用程序的说明。



让我们来看看这个自动生成的配置是什么样子。


三、生成 Dockerfile 文件


# syntax=docker/dockerfile:1

# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Dockerfile reference guide at
# https://docs.docker.com/engine/reference/builder/

ARG PYTHON_VERSION=3.11.7
FROM python:${PYTHON_VERSION}-slim as base

# Prevents Python from writing pyc files.
ENV PYTHONDONTWRITEBYTECODE=1

# Keeps Python from buffering stdout and stderr to avoid situations where
# the application crashes without emitting any logs due to buffering.
ENV PYTHONUNBUFFERED=1

WORKDIR /app

# Create a non-privileged user that the app will run under.
# See https://docs.docker.com/go/dockerfile-user-best-practices/
ARG UID=10001
RUN adduser \
--disabled-password \
--gecos "" \
--home "/nonexistent" \
--shell "/sbin/nologin" \
--no-create-home \
--uid "${UID}" \
appuser

# Download dependencies as a separate step to take advantage of Docker's caching.
# Leverage a cache mount to /root/.cache/pip to speed up subsequent builds.
# Leverage a bind mount to requirements.txt to avoid having to copy them int0
# int0 this layer.
RUN --mount=type=cache,target=/root/.cache/pip \
--mount=type=bind,source=requirements.txt,target=requirements.txt \
python -m pip install -r requirements.txt

# Switch to the non-privileged user to run the application.
USER appuser

# Copy the source code int0 the container.
COPY . .

# Expose the port that the application listens on.
EXPOSE 5000

# Run the application.
CMD gunicorn 'app:app' --bind=0.0.0.0:5000

看看它,它写了一个比我更好的 Dockerfile。



它遵循人们在所有 Linkedin 和 Medium 帖子中不断告诉我们的所有性能和安全最佳实践。



docker-compose.yml



它编写了 docker-compose 配置来运行应用程序。由于我们的应用程序不包含与数据库的任何连接,因此它注释掉了数据库容器可能需要的代码。


如果您想在 Flask 应用程序中使用数据库,请从 docker-compose 文件中取消注释 db 服务配置,创建一个包含机密的本地文件,然后运行该应用程序。它还为我们生成了 .dockerignore 文件。


为什么使用 docker init?


docker init 使 Docker 化变得轻而易举,特别是对于 Docker 新手来说。它消除了编写 Dockerfile 和其他配置文件的手动任务,从而节省时间并最大限度地减少错误。


它使用模板根据您的应用程序类型自定义 Docker 设置,同时遵循行业最佳实践。


总结一下


总而言之,docker init 完成了上面这一切。



  • 它可以编写比这里 90% 的孩子更好的 Docker 配置。

  • 像书呆子一样遵循最佳实践。

  • 当安全人员的工具生成包含数百个您从未想过存在的漏洞的报告时,可以节省时间、精力和来自安全人员的讽刺评论。


最后需要说明的是,就像任何其他基于人工智能的工具一样,这个工具也不完美。不要盲目相信它生成的配置。我建议您在使用配置之前再次检查下配置。



如果觉得这篇文章翻译不错的话,不妨点赞加关注,我会更新更多技术干货、项目教学、经验分享的文章。



作者:程序员wayn
来源:juejin.cn/post/7338717224435531826
收起阅读 »

面试官:实现一个吸附在键盘上的输入框

web
实现效果 话不多说,先上效果和 demo 地址: demo 地址:codesandbox.io/p/devbox/ke… 体验地址:7fsqr8-5173.csb.app 实现原理 要实现一个吸附在键盘上的 input,可以分为以下步骤: 监听键盘高度...
继续阅读 »

实现效果


话不多说,先上效果和 demo 地址:



demo 地址:codesandbox.io/p/devbox/ke…

体验地址:7fsqr8-5173.csb.app



666.gif


实现原理


要实现一个吸附在键盘上的 input,可以分为以下步骤:



  1. 监听键盘高度的变化

  2. 获取「键盘顶部距离视口顶部的高度」

  3. 设置 input 的位置


第一步:监听监听键盘键盘高度的变化


要监听键盘高度的变化,我们得先看看在键盘展开或收起的时候,分别会触发哪些浏览器事件:



  • iOS 和部分 Android 浏览器


    展开:键盘展示时会依次触发 visualViewport resize -> focusin -> visualViewport scroll,部分情况下手动调用 input.focus 不触发 focusin


    收起:键盘收起时会依次触发 visualViewport resize -> focusout -> visualViewport scroll


  • 其他 Android 浏览器


    展开:键盘展示的时候会触发一段连续的 window resize,约过 200 毫秒稳定


    收起:键盘收起的时候会触发一段连续的 window resize,约过 200 毫秒稳定,但是部分手机上有些异常的 case:键盘收起时 viewport 会先变小,然后变大,最后再变小



总结两者来看,我们要监听键盘高度的变化,可以添加以下监听事件:


if (window.visualViewport) {
 window.visualViewport?.addEventListener("resize", listener);
 window.visualViewport?.addEventListener("scroll", listener);
} else {
 window.addEventListener("resize", listener);
}

window.addEventListener("focusin", listener);
window.addEventListener("focusout", listener);

===========================


📚 题外话: 获取键盘展开和收起状态


===========================


在实际业务中,获取键盘展开和收起的状态,同样很常见,要完成状态的判断,我们可以设定以下规则:


判断键盘展开:当 visualViewport resize/window.reszie、visualViewport scroll、focusin 任意一个事件触发时,如果高度减少,并且屏幕减少的高度(键盘高度)大于 200px 时,判断键盘为展开状态(由于 focusin 部分情况下不触发,所以还需要监听其他事件辅助判断键盘是否为展开状态)


判断键盘收起:当 visualViewport resize/window.reszie、visualViewport scroll、focusout 任意一个事件触发时,如果高度增加,并且屏幕减少的高度(键盘高度)小于 200px,判断键盘为收起状态


// 获取当前视口高度
const height = window.visualViewport
? window.visualViewport.height
: window.innerHeight;

// 获取视口增量:视口高度 - 上次获取的视口高度
const diffHeight = height - lastWinHeight;

// 获取键盘高度:默认屏幕高度 - 当前视口高度
const keyboardHeight = DEFAULT_HEIGHT - height;

// 如果高度减少,且键盘高度大于 200,则视为键盘弹起
if (diffHeight < 0 && keyboardHeight > 200) {
   onKeyboardShow();
} else if (diff > 0) {
   onKeyboardHide();
}

同时,为了避免 “收起时 viewport 会先变小,然后变大,最后再变小” 这种情况,我们需要在展开收起状态发生变化的时候加一个200毫秒的防抖,避免键盘状态频繁改变执行“收起 -> 展开 -> 收起”的逻辑


let canChangeStatus = true;

function onKeyboardShow({ height, top }) {
   if (canChangeStatus) {
     canChangeStatus = false;
     setTimeout(() => {
callback();
        canChangeStatus = true;
    }, 200);
  }
}

第二步:获取键盘顶部距离视口顶部的高度


在 safari 浏览器或者部分安卓手机的浏览器中,在点击输入框的时候,可以看到页面会滚动到输入框所在位置(这是想让被软键盘遮挡的部分展示出来),这个时候,其实是触发了虚拟视口 visualViewport 的 scroll 事件,让页面整体往上顶,即使是 fixed 定位也不例外,因此要获取「键盘顶部距离视口顶部的高度」,我们需要进行如下计算:


键盘顶部距离视口顶部的高度 = 视口当前的高度 + 视口滚动上去高度


// 获取当前视口高度
const height = window.visualViewport ? window.visualViewport.height : window.innerHeight;
// 获取视口滚动高度
const viewportScrollTop = window.visualViewport?.pageTop || 0;
// 获取键盘顶部距离视口顶部的距离,这里是关键
const keyboardTop = height + viewportScrollTop;

第三步:设置 input 的位置


我们先设置 input 的 css 样式


input {
position: absolute;
top: 0;
left: 0;
width: 100vw;
height: 50px;
transition: all .3s;
}

然后再动态调整 input 的 translateY,让 input 可以配合键盘移动,为了保证 input 能够露出,还需要用上一步计算好的「键盘距离页面顶部高度」再减去「元素高度」,从而获得「当前元素的位移」:


当前元素的位移 = 键盘距离页面顶部高度 - 元素高度


// input 的 position 为 absolute、top 为 0
keyboardObserver.on(KeyboardEvent.PositionChange, ({ top }) => {
 input.style.tranform = `translateY(${top - input.clientHeight}px)`;
});

实现原理是不是很简单?不如来看看完整代码吧~


完整代码


import EventEmitter from "eventemitter3";

// 默认屏幕高度
const DEFAULT_HEIGHT = window.innerHeight;
const MIN_KEYBOARD_HEIGHT = 200;

// 键盘事件
export enum KeyboardEvent {
 Show = "Show",
 Hide = "Hide",
 PositionChange = "PositionChange",
}

interface KeyboardInfo {
 height: number;
 top: number;
}

class KeyboardObserver extends EventEmitter {
 inited = false;
 lastWinHeight = DEFAULT_HEIGHT;
 canChangeStatus = true;

 _unbind = () => {};

 // 键盘初始化
 init() {
   if (this.inited) {
     return;
  }
   
   const listener = () => this.adjustPos();

   if (window.visualViewport) {
     window.visualViewport?.addEventListener("resize", listener);
     window.visualViewport?.addEventListener("scroll", listener);
  } else {
     window.addEventListener("resize", listener);
  }

   window.addEventListener("focusin", listener);
   window.addEventListener("focusout", listener);

   this._unbind = () => {
     if (window.visualViewport) {
       window.visualViewport?.removeEventListener("resize", listener);
       window.visualViewport?.removeEventListener("scroll", listener);
    } else {
       window.removeEventListener("resize", listener);
    }

     window.removeEventListener("focusin", listener);
     window.removeEventListener("focusout", listener);
  };
   
   this.inited = true;
}

// 解绑事件
 unbind() {
   this._unbind();
this.inited = false;
}

 // 调整键盘位置
 adjustPos() {
   // 获取当前视口高度
   const height = window.visualViewport
     ? window.visualViewport.height
    : window.innerHeight;

   // 获取键盘高度
   const keyboardHeight = DEFAULT_HEIGHT - height;
   
   // 获取键盘顶部距离视口顶部的距离
   const top = height + (window.visualViewport?.pageTop || 0);

   this.emit(KeyboardEvent.PositionChange, { top });

   // 与上一次计算的屏幕高度的差值
   const diffHeight = height - this.lastWinHeight;

   this.lastWinHeight = height;

   // 如果高度减少,且减少高度大于 200,则视为键盘弹起
   if (diffHeight < 0 && keyboardHeight > MIN_KEYBOARD_HEIGHT) {
     this.onKeyboardShow({ height: keyboardHeight, top });
  } else if (diffHeight > 0) {
     this.onKeyboardHide({ height: keyboardHeight, top });
  }
}

 onKeyboardShow({ height, top }: KeyboardInfo) {
   if (this.canChangeStatus) {
     this.emit(KeyboardEvent.Show, { height, top });
     this.canChangeStatus = false;
     this.setStatus();
  }
}

 onKeyboardHide({ height, top }: KeyboardInfo) {
   if (this.canChangeStatus) {
     this.emit(KeyboardEvent.Hide, { height, top });
     this.canChangeStatus = false;
     this.setStatus();
  }
}

 setStatus() {
   const timer = setTimeout(() => {
     clearTimeout(timer);
     this.canChangeStatus = true;
  }, 300);
}
}

const keyboardObserver = new KeyboardObserver();

export default keyboardObserver;


使用:


keyboardObserver.on(KeyboardEvent.PositionChange, ({ top }) => {
 input.style.tranform = `translateY(${top - input.clientHeight}px)`;
});

作者:DAHUIAAAAAA
来源:juejin.cn/post/7338335869709385780
收起阅读 »

最新 GitHub 骗局!千万别中招!

今天一早,焚香沐浴更衣,打开全球最大同性交友网站,准备好好摸鱼;突然方向通知多了一条: 正疑惑是触发是 GitHub 的什么隐藏关卡呢,点进去一看: 一个 21 年的创建 issue?但是有个新的评论: 这个评论的大意是: 喂!x毛! GitHub 瞎...
继续阅读 »

今天一早,焚香沐浴更衣,打开全球最大同性交友网站,准备好好摸鱼;突然方向通知多了一条:


image.png


正疑惑是触发是 GitHub 的什么隐藏关卡呢,点进去一看:


image.png


一个 21 年的创建 issue?但是有个新的评论:


image.png


这个评论的大意是:



喂!x毛!
GitHub 瞎了眼相中你了,有个很适合你的职位,年薪高达 18w 刀乐!
赶紧来申请啊,各种福利各种巴适!
但是有记得在 24 小时内点击这个链接来申请哦!过时不候!
后面芭啦芭啦@了一大堆人,其中我的用户名赫然在列!



他真的!我哭死!原来天上真的会掉馅阱 😢...


但是仔细一看,评论的这个人,是默认头像。不对劲!非常不对劲!遂点进其主页一看:


image.png


啥也没有...


这时候事情就很明显了,然后我就去 GitHub 社区找了一下相关的反馈,果不其然,两天前开始有人在反馈相关问题:


image.png



原讨论传送门:github.com/orgs/commun…



于是笔者在隐私模式下打开@我的那个评论附上的链接:


image.png


这个页面会请求你使用 GitHub 授权登录,并且要求你授权各种高级权限;而一旦你授权了,大概率会发生的第一件事,就是你的帐号会在各种 issue 中发布上面那条“GitHub 求职骗局”的评论,以导致更多的人受骗...



截至笔者写下这篇水文时,该钓鱼网站链接已经无法打开。



而在早上笔者在 github.com/orgs/commun… 中留下评论后,陆陆续续又有上百个全球各地的开发者进行了反馈。甚至有领先一步的好哥们已经直接出手向域名注册商、域名托管服务商进行了举报,并收到了反馈:


image.png


而这,仅仅是在笔者写下这篇水文前的 23 分钟(一切发生得太快...


不仅如此,在笔者截完本文第三张图后,提及我的那条评论已经删除了 🤪 (一切发生得实在太快...


不不仅如此,在笔者敲完上一句话后打算再次确认一下发出评论那个用户(第四张截图),发现他已经被封禁了...


image.png


好家伙,这发生得也太快了吧!赶上直播了???


估计这一波,有不少帐号也受到波及,最好确认一下自己的帐号是否有被影响(吓得笔者又刷新了一下页面确认自己有没有被封禁)。


这也让笔者想起最近 GitHub、NPM 等各种平台都在极力地推动用户启用双因素身份验证(2FA),以提高用户帐号的安全;这样看来,确实是一个明智之举。


最后还是提醒一下各位:


不清楚来源的链接不要点!不清楚来源的链接不要点!不清楚来源的链接不要点!


就这样。


作者:Nauxscript
来源:juejin.cn/post/7337666469903122472
收起阅读 »

看完zustand源码后,我的TypeScript水平突飞猛进。

web
前言 过年期间在家里没事,把zustand的源码看了一遍,看完后我最大的收获就是ts水平突飞猛进,再去刷那些类型体操题目就变得简单了,下面和大家分享一下zustand库是怎么定义ts类型的。 ts类型推断 个人认为ts最大的作用有两个,一个是类型约束,另外一个...
继续阅读 »

前言


过年期间在家里没事,把zustand的源码看了一遍,看完后我最大的收获就是ts水平突飞猛进,再去刷那些类型体操题目就变得简单了,下面和大家分享一下zustand库是怎么定义ts类型的。


ts类型推断


个人认为ts最大的作用有两个,一个是类型约束,另外一个是类型推断。



  • 类型约束也叫类型安全,在编译阶段就能发现语法错误,可以有效减少低级错误。

  • 类型推断,当你没有标明变量的类型时,编译器会根据一些简单的规则来推断你定义的变量的类型


这一篇主要和大家分享类型推断,类型推断主要有以下几种情况。


根据变量的值自动推导类型


image.png


image.png


函数返回值自动推断


image.png


函数中如果有条件分支,推导出来的返回值类型是所有分支的返回值类型的联合类型


image.png


ts的类型推导方式是懒推导,也就是说不会实际执行代码。


image.png


上图中如果实际执行了,c的类型是能确认为null的。


使用范型推导


image.png


可以看到按照上面写法,对象合并推导不出来,如果能推导出来u3应该等于 {name: string, age: number}


这时候我们可以借助范型来推导


image.png


可以给上面代码简写为这样,编辑器也能推导出来


image.png


实战


实现pick方法


从一个对象中,返回指定的属性名称。


image.png


上面代码中定义了两个范型T和U,T表示对象,U被限定为T的属性名(U extends keyof T),返回值的类型为{[K in U]: T[K]},in的作用就是遍历U这个数组。


image.png


可以看到数组元素被限制了只能是user对象里的key


image.png


image.png


也正确的推导出来了


实现useRequest


先看一个例子


import { useEffect, useState } from 'react';

// 模拟请求接口,返回用户列表
function getUsers(): Promise<{ name: string }[]> {
return new Promise(resolve => {
setTimeout(() => {
resolve([
{
name: 'tom',
},
{
name: 'jack',
},
]);
}, 1000);
})
}

const App = () => {

const [loading, setLoading] = useState(true);
const [users, setUsers] = useState<Awaited<ReturnType<typeof getUsers>>>([]);
const [error, setError] = useState(false);

useEffect(() => {
setLoading(true);
getUsers().then((res) => {
setUsers(res);
}).catch(() => {
setError(true);
}).finally(() => {
setLoading(false);
})
}, []);

if (loading) {
return (
<div>loading...</div>
)
}

if (error) {
return (
<div>error</div>
)
}

return (
<div
>

{users.map(u => (
<div key={u.name}>{u.name}</div>
))}
</div>

);
};

export default App;

上面这个例子实现了从后端请求用户列表,然后渲染出来。为了提高用户体验,在加载数据时,加了一个loading,当请求出错时,告诉用户请求失败。


代码比较简单我就不一一讲解了,有行代码需要注意一下。


 const [users, setUsers] = useState<Awaited<ReturnType<typeof getUsers>>>([]);


  • typeof getUsers 获取getUsers函数类型

  • ReturnType 获取某个函数的返回值

  • Awaited 如果函数返回值为Promise,这个可以获取到最终的值类型。


image.png


image.png


可以看到,正确的获取到了getUsers函数的返回值类型。


然而一个很简单的功能需要写那么多代码,肯定是不合理的,那么我们给简化一下。目前市面上已经有不少库来解决这个问题了,比如react-query或ahooks库里的useRequest,都可以解决这个问题,我这里分享的不是具体代码实现,而是怎么写ts。


封装useRequest


import { useEffect, useState } from 'react';

export function useRequest<T extends () => Promise<unknown>>(
fn: T,
): {
loading: boolean;
error: boolean;
data: Awaited<ReturnType<T>>;
} {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const [data, setData] = useState<any>();

useEffect(() => {
setLoading(true);
fn().then(res => {
setData(res);
}).catch(() => {
setError(true);
}).finally(() => {
setLoading(false);
});
}, [fn])

return {
loading,
error,
data,
};
}

改造app.tsx文件,使用useRequest


import { useRequest } from './useRequest';

// 模拟请求接口,返回用户列表
function getUsers(): Promise<{ name: string }[]> {
return new Promise(resolve => {
setTimeout(() => {
resolve([
{
name: 'tom',
},
{
name: 'jack',
},
]);
}, 1000);
})
}

const App = () => {

const { loading, data: users, error } = useRequest(getUsers);

if (loading) {
return (
<div>loading...</div>
)
}

if (error) {
return (
<div>error</div>
)
}

return (
<div
>

{users.map(u => (
<div
key={u.name}
>

{u.name}
</div>
))}
</div>

);
};

export default App;

对比最开始的代码,是不是简单了很多。


useRequest.tsx代码也很简单,首先使用了范型限制fn只能是一个函数,返回值还必须是Promise。这个hooks返回值loading和error就不说了,主要是data,这个data要求和传进来的方法返回值一致,前面说过,可以使用Awaited<ReturnType<T>>获取函数的返回类型。


image.png


但是上面代码可能会导致bug,看下面代码,如果请求失败,users应该是空的,直接这样使用就会报错了。改造一下,当error为false的时候data为正常类型,error为true的时候data为null,这里可以使用联合类型。


image.png


image.png


image.png


image.png


加了一个判断后,下面就不会报错了。ts在某些时候,真的可以避免一些低级错误,我相信如果没有这个限制,肯定有人在写代码的时候不加判断直接用users。


如果请求接口的函数需要参数怎么办,下面来实现一下。


image.png


使用Parameters获取传入函数的参数类型


image.png


image.png


多个参数也是支持的


image.png


zustand


zustand是一个react状态管理库,使用起来比较简单没啥心智负担,所以我一直在用。


上面带着大家入门了ts的类型推断,下面给大家分享一下zustand的ts定义。我看完zustand源码后,发现这个库的ts定义比功能实现还复杂,这里我只给大家分享ts,具体实现掘金已经有很多大佬写过了,我就不分享了。


先从一个最简单的例子开始


import { create } from 'zustand';

interface State {
count: number;
}

interface Action {
inc: () => void;
}

export const useStore = create<State & Action>((set) => ({
count: 1,
inc: () => set((state) => ({count: state.count + 1})),
}));

image.png


create方法的定义


type Create = {
<T, Mos extends [StoreMutatorIdentifier, unknown][] = []>(
initializer: StateCreator<T, [], Mos>,
): UseBoundStore<Mutate<StoreApi<T>, Mos>>
<T>(): <Mos extends [StoreMutatorIdentifier, unknown][] = []>(
initializer: StateCreator<T, [], Mos>,
) =>
UseBoundStore<Mutate<StoreApi<T>, Mos>>
/**
* @deprecated Use `useStore` hook to bind store
*/

<S extends StoreApi<unknown>>(store: S): UseBoundStore<S>
}

可以看到create有三个重载方法,最后一个废弃不用了,上面例子使用的是第一个方法,第二个重载方法可以这样使用。


image.png


这样做的意义和中间件有关系,这个后面再说。


 <T, Mos extends [StoreMutatorIdentifier, unknown][] = []>(
initializer: StateCreator<T, [], Mos>,
): UseBoundStore<Mutate<StoreApi<T>, Mos>>

我们先看第一个方法,定义了两个范型,T表示返回值类型,对应上面例子中create<State & Action>,Mos是给中间件用的,这个等会再说。


create方法的参数initializer定义


initializer: StateCreator<T, [], Mos>

参数initializer对应的类型是StateCreator


export type StateCreator<
T,
Mis extends [StoreMutatorIdentifier, unknown][] = [],
Mos extends [StoreMutatorIdentifier, unknown][] = [],
U = T,
> = ((
setState: Get<Mutate<StoreApi<T>, Mis>, 'setState', never>,
getState: Get<Mutate<StoreApi<T>, Mis>, 'getState', never>,
store: Mutate<StoreApi<T>, Mis>,
) =>
U) & { $$storeMutators?: Mos }

StateCreator定义了4个范型,T还是表示返回值类型,其余三个暂时用不到。


((
setState: Get<Mutate<StoreApi<T>, Mis>, 'setState', never>,
getState: Get<Mutate<StoreApi<T>, Mis>, 'getState', never>,
store: Mutate<StoreApi<T>, Mis>,
) =>
U) & { $$storeMutators?: Mos }

这段ts表明,initializer是一个函数,并且有三个参数,& { $$storeMutators?: Mos }表示交叉类型,也就是说这个函数可能会有$$storeMutators属性。


举个例子:


image.png


因为函数上没有$$name属性,所以报错了,下面给函数加上属性就可以了


image.png


setState: Get<Mutate<StoreApi<T>, Mis>, 'setState', never>

type Get<T, K, F> = K extends keyof T ? T[K] : F

定义了一个Get类型,表示K如果在T对象的可以中,则返回K属性对应的值类型,如果不在返回F。


看个例子


image.png


因为T对象中没有count属性,所以返回never,never表示不存在的类型。


image.png


因为T对象中有name属性,所以返回name字段对应的类型string。


Mutate<StoreApi<T>, Mis>

Mutate这个类型很复杂,是为了解决中间件类型提示出现的,后面再说,没有使用中间件的情况下可以把这段代码简化为StoreApi<T>


export interface StoreApi<T> {
setState: SetStateInternal<T>
getState: () => T
getInitialState: () => T
subscribe: (listener: (state: T, prevState: T) => void) => () => void
/**
* @deprecated Use `unsubscribe` returned by `subscribe`
*/

destroy: () => void
}

type SetStateInternal<T> = {
_(
partial: T | Partial<T> | { _(state: T): T | Partial<T> }['_'],
replace?: boolean | undefined,
): void
}['_']

到这里我们就看到前面例子中set的定义了,set方法有两个参数,第一个参数可以是前面范型定义的一个对象,可以是对象中的一些属性,也可以是一个函数。第二个属性表示是否覆盖整个对象。


这里的["_"]让我有点迷惑,不知道有啥作用,也可以写成下面这样。


type SetStateInternal<T> = (
partial: T | Partial<T> | { _(state: T): T | Partial<T> }['_'],
replace?: boolean | undefined,
) =>
void

set竟然可以直接设置值,看完源码后,我才知道可以这样用,一般我都是用函数,然后使用函数返回值更新值。


image.png


create方法的返回值类型定义


UseBoundStore<Mutate<StoreApi<T>, Mos>>

上面说了没有中间件的情况下,可以简化为:UseBoundStore<StoreApi<T>>


export type UseBoundStore<S extends WithReact<ReadonlyStoreApi<unknown>>> = {
(): ExtractState<S>
<U>(selector: (state: ExtractState<S>) => U): U
/**
* @deprecated Use `createWithEqualityFn` from 'zustand/traditional'
*/

<U>(
selector: (state: ExtractState<S>) => U,
equalityFn: (a: U, b: U) => boolean,
): U
} & S

type ExtractState<S> = S extends { getState: () => infer T } ? T : never

create返回值是一个函数,这个函数有三个重载方法,并且方法上还有一些属性,(& S)表示这些属性。


第一个重载方法表示没有参数时直接返回ExtractState<S>,ExtractState其实就是获取S对象中getState的返回值类型。


image.png


第二个重载方法有一个参数,可以返回自定义属性。


image.png


第三个重载方法废弃了,就不说了。


image.png


上图中useStore之所以有setState和getState等属性,就是上面& S的作用。


create第二个重载方法的作用


zustand支持使用中间件和编写中间件,看完官方持久化persist中间件的ts定义后,直接把我CPU干烧了,太复杂。


先看一下前面说过的,为啥create方法加了一个重载方法。


<T>(): <Mos extends [StoreMutatorIdentifier, unknown][] = []>(
initializer: StateCreator<T, [], Mos>,
) =>
Mutate<StoreApi<T>, Mos>

这个重载方法主要是给使用了中间件的情况下使用的,看一个例子。


image.png


image.png


上面例子中使用了官方提供的持久化中间件,如果使用第一种重载方法会报错,使用第二种就会报错,下面我们来分析一下为啥会这样。


先给上面代码简化一下


function a() {
console.log('hello');
}
type Fn = {
<T, U extends any[] = []>(name: U): T;
<T>(): <U extends any[] = []>(name: U) => T;
};

const b = a as Fn;

b(['hello'])

image.png


这时候我们调用第一个重载方法没有报错,加了范型后就报错了。


image.png


这是因为不使用范型的时候,编辑器会自动推导类型,如果传了一个范型,那么 U extends any[] = []会强制使用默认值[],所以传['hello']会报错。传[]就不会报错了。第二个重载方法的意义就是给两个范型拆开,这样设置了T不会应用U。


image.png


回到上面问题再看一下create方法的参数类型


image.png


因为传了一个范型约束,所以第二个参数使用默认值[]了


image.png


然而persist中间件返回值类型Mos不为[],所以报错了


image.png


针对这个问题,有两个解决方案


第一个方案是把范型去掉,把范型写在persist上。


image.png


第二个方案是用第二个重载方法


image.png


中间件返回值的类型定义


前面有个东西没说,create返回值里的Mutate<StoreApi<T>, Mos>是干嘛用的,先看下代码


export type Mutate<S, Ms> = number extends Ms['length' & keyof Ms]
? S
: Ms extends []
? S
: Ms extends [[infer Mi, infer Ma], ...infer Mrs]
? Mutate<StoreMutators<S, Ma>[Mi & StoreMutatorIdentifier], Mrs>
: never

第一次看这个的时候,直接给我看懵了,这是啥,怎么还有递归,然后恶补了一下ts类型体操知识,顺便把github上类型体操题目刷了一下,然后再回来看这个类型体操就很简单了。


先写一个简单的例子让大家入门一下类型体操,合并数组中的对象类型。



// 写一个类型给a转换为{name: string, age: number}

type a = [{ name: string }, { age: number }];

// infer 可以理解为定义一个变量,
// infer F 表示取出数组中第一个元素,
// ...infer R表示把数组中剩余的元素放到R中,
// S & F 表示把S和F合并,
// C<R, S & F>递归剩余元素也合并S中
// 最后返回S

type C<T extends any[] = [], S = {}> = T extends [infer F, ...infer R] ? C<R, S & F> : S


image.png


理解了这个,那上面Mutate也就好理解了。


number extends Ms['length' & keyof Ms] ? S : : Ms extends [] ? S : ...这段表示如果Ms的类型为any[]则返回S,如果Ms为[]也返回S。


正常我们没有使用中间件的时候,Ms是[],所以直接返回S也就是StoreApi<State & Action>


当使用中间件的时候,我们先看下persist返回值类型。


image.png


persist中间件源码中的类型定义


image.png


根据create方法initializer参数定义Mos被自动推导成了[["zustand/persist", State & Action]],Mos对应Mutate里的Ms。


image.png


Ms extends [[infer Mi, infer Ma], ...infer Mrs]

对比上面的Ms类型,Mi为"zustand/persist",Ma为State & Action,Mrs为[]。


Mutate<StoreMutators<S, Ma>[Mi & StoreMutatorIdentifier], Mrs>

接下来开始递归了,StoreMutators<S, Ma>[Mi & StoreMutatorIdentifier],把Mi替换成"zustand/persist",变成StoreMutators<S, Ma>["zustand/persist" & StoreMutatorIdentifier]


image.png


最开始这段代码让很迷惑,因为StoreMutators在项目里定义的是空对象,上面这种写法取不到任何东西。然后我去persist中间件源码里看了一下,原来在persist里给StoreMutators扩展了。


image.png


这几个类型定义可以简单理解为是给Mutate里S添加了persist属性。而persist属性有下面这些方法。


image.png


type Write<T, U> = Omit<T, keyof U> & U

Write表示合并两个类型,如果有重复的key,用后面的覆盖前面。


image.png


可以看到两个对象合并了key,并且name被覆盖成了number类型。


所以当使用persist中间件时,Mutate<StoreApi<T>, Mos>最终类型为StoreApi<T> & { persist: { ... } },所以我们能create返回的值里调用persist里的方法。


image.png


自定义中间件


模拟per中间件,自己也写一个,没有写具体实现,只写了类型定义。


import { StateCreator, StoreMutatorIdentifier } from 'zustand';

type Test = <
T,
Mps extends [StoreMutatorIdentifier, unknown][] = [],
Mcs extends [StoreMutatorIdentifier, unknown][] = [],
U = T
>(
initializer: StateCreator<T, [...Mps, ['test', unknown]], Mcs>
) =>
StateCreator<T, Mps, [['test', U], ...Mcs]>;

type Write<T, U> = Omit<T, keyof U> & U;

declare module 'zustand' {
interface StoreMutators<S, A> {
test: Write<S, {test: {log: () => void}}>;
}
}

function a() {
console.log(444);
}

export const test = a as unknown as Test;

image.png


在中间件中也可以重写setState方法


image.png


image.png


总结


到此终于结束了,最复杂的create方法讲完了,其他都是简单的,就不分享了。说实话ts类型定义比代码实现难理解多了,也有可能是我开始的水平不够,所以看起来比较费劲。为了看懂这些ts,我把ts体操类型刷了一遍,现在我感觉自己ts提升了很多。找个时间看一下zod的源码,学习一下它的ts定义。


我看一些ts教程的文章下面,很多人吐槽说TypeScript没有用,个人觉得公司里的业务代码或者个人小项目确实可以不用,但是如果你要开发一个开源框架或组件库,我觉得ts或jsdoc还是有必要的,类型推断和准确的代码提示可以方便用户使用。


作者:前端小付
来源:juejin.cn/post/7339364757386264612
收起阅读 »

很多人 30 岁了,对人情世故的认知水平还停留在上学

工作和事业 当领导夸你“工作完成的不错”时。 一般的回答:谢谢领导夸奖 高情商回答: 在今后的工作中,我会继续加油,认真负责的跟着您干,如果没做好的地方,希望您指正。 工作认真负责是我的本分,主要是您指挥的好,不然我也不会成长得这么快 都是因为您给了我充分的...
继续阅读 »

工作和事业


当领导夸你“工作完成的不错”时。


一般的回答:谢谢领导夸奖


高情商回答:



  1. 在今后的工作中,我会继续加油,认真负责的跟着您干,如果没做好的地方,希望您指正。

  2. 工作认真负责是我的本分,主要是您指挥的好,不然我也不会成长得这么快

  3. 都是因为您给了我充分的信任和支持,所以我才能这么顺利地推进。

  4. 最开始我信心不足,您对我的信任给了我做事的信心和动力。能做成这件事,全靠您的帮助,非常感谢您。以后我会加倍努力。


当领导给你安排 完成不了的工作时


一般的回答:领导我干不了。


高情商回答:



  1. 好的,领导,不过我缺乏经验,有些事情还需要您的指导的帮助

  2. 好的领导,您先给我一个小时,我先整体评估一下难度,初步预估一下事情的周期。

  3. 领导,我现在手头有三个项目,你看如果我做现在这个项目的话,怕是会影响其他项目的进度和质量,同事 XXX 在某些方面比较专业,您看是否可以让他负责。


炫耀


对利益相关的人


要展示你的实力和智力


对你利益不相关的人


就展示你的礼貌就好


工资一万,对家里说 7000,对外人说 4000。程序员不要炫富,你那点工资差远了。


规则和事实


当事实对你有利,就强调事实


当规则对你有利,就强调规则


当事实和规则都对你不利


就敲桌子把事情搅混


求人帮忙



  • 求人办事,关系再好,也要让对方得到利益。 铁哥们也不例外,至少请一顿饭,当面感谢。

  • 找人办事,别一上来就说等办完事,再给什么好处,办事之前就要给到。

  • 不要轻易得罪别人,虽然他不能帮你成事,但是可能坏你的事。尤其是领导身边重要的心腹。


想要强大,必须出丑。出丑越多,脸皮越厚,成长越快


生活


管住嘴



  • 少跟妈妈说难过的事,她帮不上忙,也会睡不着觉

  • 别人一对你好,你就推心置腹的毛病一定要改

  • 交浅言深,是人际交往的大忌讳

  • 亲朋好友的孩子再不对,也不要去教育,因为教育别人家的孩子就是在打别人的脸


帮忙


别人不开口请你帮忙,尽量不要主动帮忙!


别人求你帮忙,你先探探对方的口风,看他的态度和想法,看看对方是在寻求你的意见,也许对方只是想寻求你认可他的想法。


见人说人话


遇到女人一律说对方瘦了


遇到穷人,一律说钱不重要,快乐就好


遇到美女夸有内涵,身材好、气质好


遇到带孩子,夸小孩聪明伶俐,夸小孩带的好,


遇到帅哥,夸有才华,有风度。


遇到病人夸气色好,很快就会痊愈


遇到企业家,夸有情怀


遇到小职员,夸有格局


遇到富人,夸他有眼光,有品位


饭局



  • 饭桌上一直玩手机的聚会,就这一次没有下次,没有意义的社交无需留念

  • 去别人家吃饭,饭后不要帮忙刷碗

  • 除非是铁哥们,否则不要临时通知别人去聚餐,别人会认为自己是凑数的!

  • 聚餐时,一定不要夹盘子里的最后一口菜

  • 坐同事的车,只坐副驾驶,不坐在后排。

  • 车子收拾的很干净的人,大多数不好客


作者:五阳
来源:juejin.cn/post/7338723726838218771
收起阅读 »

停止使用 localStorage !

web
medium 优秀文章翻译,也增加自己的一些使用体验。 非标题党!本文标题很明确的想表达对 localStorage 的不推荐。 localStorage 的弊病 2009年 localStorage 诞生,简单来说就是 5MB 的字符串格式的存储。让我拆...
继续阅读 »

medium 优秀文章翻译,也增加自己的一些使用体验。




非标题党!本文标题很明确的想表达对 localStorage 的不推荐。


localStorage 的弊病


2009年 localStorage 诞生,简单来说就是 5MB 的字符串格式的存储。让我拆解下定义中的关键点。



  • 字符串集合:它只能存储字符串。如果你想要存储或者检索其他格式数据,你必须进行序列化和反序列化。假如你忘记了这一点,你将会遇到各种各样的网络Bug。例如当你存储 true 和 false 时,你还要注意处理 null、undefined、空字符串等潜在返回值。

  • 非结构化数据:JavaScript 结构化克隆算法用于复制复杂的 JavaScript 对象的算法。它通过递归输入对象来构建克隆,同时保持先前访问过的引用的映射,以避免无限遍历循环。postMessage, WebWorkers, IndexedDB, Caches API, BroadcastChannel, Channel Messaging API, MessagePortHistory API 都是采用的结构化数据! 采用该结构化数据就是为了解决序列化和反序列化 JSON 带来的问题。很遗憾的是 localStorage 并没有更新该特性,并且未来也没有推进的计划。

  • 安全妥协:你永远不会在任何持久化存储中保存敏感数据,但是开发者依旧会在 localStorage 中保存 Session IDs,JWTs、API keys 等敏感信息。这是个常见的安全隐患,你可以在 window.localStorage 中随意查阅。




  • 性能:localStorage 的性能相对于之前已经有了很好的优化,但是对于超量事务的并发应用程序来说,其性能瓶颈一样要重点考虑。

  • 大小限制:localStorage 有 5MB 的限制,而且可能被浏览器删除。对于现代应用来说,5MB 是很小的容量了,几乎很难存储任何媒体数据。它并不是一成不变的,在一些场景下,浏览器也会主动删除部分持久化存储中的数据,这是个通病,这也是何为常规日志上报会有数据丢失的原因之一。因此有甚至需要主动去管理这部分的数据的生命周期,尽管没人告诉你要做这个。还有个点就是存储剩余容量是无法查询的,因此你无法确定操作是否会以为容量达到上限而无法完整写入。

  • WebWorker 无法访问:localStorage 并不是面向未来的API,也不是适用于并放进程中。

  • 非原子化:localStorage 不保证并行操作中的原子性,也没有任何锁能够保证正在写入的数据不会被覆盖。

  • 无数据隔离:localStorage 仅仅是个字符串的对象,应用下所有数据都被混淆在一起,无法进行数据隔离。

  • 无事务:常规数据库都会支持事务操作,也没办法进行分组。所有操作都是同步的、非独立的、无锁定的。

  • 同步阻塞操作:localStorage 不是异步的,它会阻塞主进程。频繁的读取甚至会影响动画的流畅性,在移动端设备最为明显


WebSQL 何去何从?



WebSQL 目标是为 Web 提供一个简单的 SQL 数据库接口,但是浏览器支持程度确实不好。


你可能好奇它为啥会被抛弃?



  • 单一浏览器厂商实现:WebSQL 主要是 Chrome 和 Safari 实现的,由于 Mozilla 和 Microsoft 不支持,业内开发者几乎不采用它。

  • 非 W3C 标准:这个是至关重要的,W3C 在 2010 年将它从标准中移除了。

  • 与 IndexedDB 的竞争:IndexedDB 主要获得更多的关注,且被设计成标准的跨浏览器解决方案了。

  • 安全问题:一些开发人员和安全专家对WebSQL的安全性表示担忧。他们在很多方面都持怀疑态度,包括缺乏权限控制和SOL风格的漏洞。


最终 IndexedDB 成为浏览器存储的标准,被评价为强壮的、跨浏览器友好。但是大多数经验丰富的开发者都视其为瘟疫,那这种推荐又有什么意义呢?


Cookies 又如何呢?


cookie是1994年由网景公司的网络浏览器程序员卢·蒙图利(Lou Montulli)创建的。


本篇文章的标题实际应该是“停止使用 localStorage 和 Cookie”,但是又不全对,我们应该使用安全的 cookies。



  • 4KB体积限制

  • 默认会被请求传输:非跨域 HTTP 请求会携带 cookie 数据,假如数据不需要被每个请求传输,就会带来带宽开销,导致网络加载速度变慢。

  • 安全隐患:cookie更容易受到XSS的攻击。由于cookie会自动包含在对域的每个请求中,因此它们可能成为恶意脚本的目标。

  • 过期:cookie被设计为在给定日期过期。


IndexedDB 呢?



  • 更好的性能:IndexedDB 操作是异步的,不和阻塞主进程。API 被设计为了事件驱动的。

  • 充足的存储配额:与localStorage的5MB上限相比,IndexedDB提供了更大的存储配额(取决于浏览器、操作系统和可用存储。

  • 可靠且结构化数据:Indexed 减少了强制类型转换,并且采用结构化克隆算法,保证数据的完整。



但是你大概并不想直接使用 IndexedDB。


IndexedDB 大概是避免过多依赖的例外。将 IndexedDB 视为后台数据库,你需要的是 ORM 或者 数据库处理程序来进行查询的管理。由于 IndexedDB 糟糕的 API 设计,你更想要一个 IndexedDB 库。



  • 基于 Promise

  • 更好使用

  • 减少样板代码

  • 关注于更关键的部分


本文比较推荐 dexie.js 和 idb 两个针对 indexedDB 的封装库,其中 idb 的体积是最小的,仅仅 1.19 KB,并不会给程序带来负担。


总结


本文的口号虽然是“停止使用 localStorage”,但是在这个时代实际是难以实现的,但是我们确实应该朝着这个目标出发。


未来开发者应该从 Promise()、async/await 和结构化数据中或者更加清晰且有意义的知识,而不应该关注为何数字“0”在条件语句中会成为“true”,而不应该愤怒与客户获得 null 的返回值。


由于 IndexedDB 的性能优势,你存储各种类型的数据,甚至可以使用游标来遍历所有对象。基于这种技术,你甚至可以构建客户端的搜索引擎,而不会像 localStorage 那样影响动画渲染。



IndexedDB is commonly described as “low-level” . There’s absolutely nothing low-level about IndexedDB, it’s just an API with an old-style and unfriendly syntax. But that doesn't negate it’s underlying capabilities, hence common library usage.



你并不需要直接使用 API,一个体积很小的封装库可以帮助你规避这些。


作者:三省法师
来源:juejin.cn/post/7338422591518457871
收起阅读 »

不可不知的Redis秘籍:事务命令全攻略!

在数据处理的世界里,事务(Transaction)是一个不可或缺的概念。它们确保了在一系列操作中,要么所有的操作都成功执行,要么都不执行。这就像是一个“全有或全无”的规则,保证了数据的一致性和完整性。今天,我们就来聊聊Redis事务的使用,看看如何通过它来提升...
继续阅读 »

在数据处理的世界里,事务(Transaction)是一个不可或缺的概念。它们确保了在一系列操作中,要么所有的操作都成功执行,要么都不执行。这就像是一个“全有或全无”的规则,保证了数据的一致性和完整性。

今天,我们就来聊聊Redis事务的使用,看看如何通过它来提升我们的数据操作效率和安全性。

一、Redis事务的概念

Redis 事务的本质是一组命令的集合。事务支持一次执行多个命令,一个事务中所有命令都会被序列化。在事务执行过程,会按照顺序串行化执行队列中的命令,其他客户端提交的命令请求不会插入到事务执行命令序列中。

Description

总结来说: redis事务就是一次性、顺序性、排他性的执行一个队列中的一系列命令。

Redis事务没有隔离级别的概念

批量操作在发送 EXEC 命令前被放入队列缓存,并不会被实际执行,也就不存在事务内的查询要看到事务里的更新,事务外查询不能看到。

Redis不保证原子性

Redis中,单条命令是原子性执行的,但事务不保证原子性,且没有回滚。事务中任意命令执行失败,其余的命令仍会被执行。

redis事务的执行阶段

  • 开始事务(multi)。
  • 命令入队。
  • 执行事务(exec)

Description

二、Redis事务优缺点

对于Redis事务的概念我们已经有了基本的了解,下面我们再来看看它都有哪些优缺点。

优点:

  • 一次性按顺序执行多个Redis命令,不受其他客户端命令请求影响;

  • 事务中的命令要么都执行(命令间执行失败互相不影响),要么都不执行(比如中间有命令语法错误);

缺点:

  • 事务执行时,不能保证原子性;

  • 命令入队每次都需要和服务器进行交互,增加带宽;

注意:

  • 当事务中命令语法使用错误时,最终会导致事务执行不成功,即事务内所有命令都不执行;

  • 当事务中命令知识逻辑错误,就比如给字符串做加减乘除操作时,只能在执行过程中发现错误,这种事务执行中失败的命令不影响其他命令的执行。

三、Redis事务相关命令

Redis事务可以通过一系列命令来执行多个操作,并确保这些操作可以原子性地执行。以下是Redis事务的相关命令及其作用:

MULTI: 开启一个事务。在调用此命令后,Redis 会将后续的命令逐个放入队列中,直到接收到 EXEC 命令为止。

EXEC: 执行事务中的所有操作命令。一旦调用 EXEC 命令,Redis 会原子性地执行队列中的所有命令。

DISCARD: 取消事务,放弃执行事务块中的所有命令。如果不想继续执行事务中的操作,可以使用 DISCARD 命令来清除当前事务队列。

WATCH: 监视一个或多个键,如果在事务执行之前这些键被其他命令所改动,那么事务将会被打断。

UNWATCH: 取消所有由 WATCH 命令监视的键。如果不想继续监视某些键,可以使用 UNWATCH 命令来取消监视。

需要注意的是,在事务执行过程中,其他客户端提交的命令请求不会插入到事务执行命令序列中,这保证了事务的隔离性。同时,Redis 事务提供了批量操作缓存的功能,即在发送 EXEC 命令前,所有操作都会被放入队列缓存。

在这里给大家分享一下【云端源想】学习平台,无论你是初学者还是有经验的开发者,这里都有你需要的一切。包含课程视频、知识库、微实战、云实验室、一对一咨询等等,现在功能全部是免费的,点击这里,立即开始你的学习之旅!

四、Redis事务的使用

使用Redis事务的步骤如下:

  • 使用MULTI命令开启一个事务。

  • 在事务中执行需要的命令,如SET、GET等。

  • 使用EXEC命令提交事务,将事务中的命令一次性发送给Redis服务器执行。

  • 如果需要取消事务,可以使用DISCARD命令。

Description

下面通过一些示例来讲解一下这些命令的使用方法:

1、正常执行

192.168.xxx.21:6379> multi
OK
192.168.xxx.21:6379> set aa AA
QUEUED
192.168.xxx.21:6379> set bb BB
QUEUED
192.168.xxx.21:6379> set cc CC
QUEUED
192.168.xxx.21:6379> set dd DD
QUEUED
192.168.xxx.21:6379> exec
1) OK
2) OK
3) OK
4) OK
192.168.xxx.21:6379> get aa
"AA"

首先,通过执行multi命令开始一个事务块。然后,依次执行了四个set命令,将键"aa"、“bb”、“cc"和"dd"分别设置为对应的值"AA”、“BB”、“CC"和"DD”。

每个set命令执行后返回的结果为"QUEUED",表示该命令已被加入到事务队列中等待执行。

接下来,通过执行exec命令来提交事务,一次性执行事务队列中的所有命令。执行结果为每个命令的返回值,即"OK"。最后,通过执行get aa命令获取键"aa"的值,返回结果为"AA"。

2、取消事务

192.168.xxx.21:6379> multi
OK
192.168.xxx.21:6379> set aa 11
QUEUED
192.168.xxx.21:6379> set ee EE
QUEUED
192.168.xxx.21:6379> discard
OK
192.168.xxx.21:6379> get aa
"AA"
192.168.xxx.21:6379> get ee
(nil)
192.168.xxx.21:6379>

示例代码中,首先,通过执行multi命令开始一个事务块。然后,依次执行了两个set命令,将键"aa"设置为值"11",将键"ee"设置为值"EE"。每个set命令执行后返回的结果为"QUEUED",表示该命令已被加入到事务队列中等待执行。

接下来,通过执行discard命令来取消事务,放弃执行事务块内的所有命令。执行结果为"OK"。

最后,通过执行get aa命令获取键"aa"的值,返回结果为"AA"。而执行get ee命令获取键"ee"的值时,由于之前已经取消了事务,所以返回结果为"(nil)",表示该键不存在。

3、事务队列中存在命令错误

如果在事务队列中存在命令性错误(类似于java编译性错误),则执行EXEC命令时,所有命令都不会执行

192.168.xxx.21:6379> multi
OK
192.168.xxx.21:6379> set aa 22
QUEUED
192.168.xxx.21:6379> set bb 33
QUEUED
192.168.xxx.21:6379> setq cc 44
(error) ERR unknown command 'setq'
192.168.xxx.21:6379> set ff FF
QUEUED
192.168.xxx.21:6379> exec
(error) EXECABORT Transaction discarded because of previous errors.
192.168.xxx.21:6379> get ff
(nil)
192.168.xxx.21:6379> get bb
"BB"
192.168.xxx.21:6379>

首先,通过执行multi命令开始一个事务块。然后,依次执行了三个set命令,将键"aa"设置为值"22",将键"bb"设置为值"33",将键"cc"设置为值"44"。每个set命令执行后返回的结果为"QUEUED",表示该命令已被加入到事务队列中等待执行。

然而,在执行第三个set命令时,出现了错误。因为Redis中并没有名为"setq"的命令,所以返回结果为"(error) ERR unknown command ‘setq’"。

接下来,通过执行exec命令来提交事务,一次性执行事务队列中的所有命令。由于之前已经出现了错误,导致事务被中断,所以执行结果为"(error) EXECABORT Transaction discarded because of previous errors."。

最后,通过执行get ff命令获取键"ff"的值时,由于事务被中断,所以返回结果为"(nil)“,表示该键不存在。而执行get bb命令获取键"bb"的值时,由于事务被中断,所以返回结果为"BB”。

4、事务队列中存在语法错误

如果在事务队列中存在语法性错误(类似于java的1/0的运行时异常),则执行EXEC命令时,其他正确命令会被执行,错误命令抛出异常。

192.168.xxx.21:6379> multi
OK
192.168.xxx.21:6379> incr aa
QUEUED
192.168.xxx.21:6379> set ff FF
QUEUED
192.168.xxx.21:6379> set bb 22
QUEUED
192.168.xxx.21:6379> exec
1) (error) ERR value is not an integer or out of range
2) OK
3) OK
192.168.xxx.21:6379> get bb
"22"
192.168.xxx.21:6379> get ff
"FF"
192.168.xxx.21:6379>

错误原因:字符串不能累加1

5、watch监控

watch 命令可以监控一个或多个键,一旦有其中一个键被修改(被删除),后面的事务就不会执行了。监控一直持续到 EXEC 命令(事务中的命令是在exec之后才执行的,所以在multi命令后可以修改watch监控的键值)

假设我们通过watch命令在事务执行之前监控了多个Keys,倘若在watch之后有任何Key的值发生了变化,exec命令执行的事务都将被放弃,同时返回Null multi-bulk应答以通知调用者事务执行失败。

(1)、执行watch,不执行multi、exec

192.168.xxx.21:6379> get aa
"AA"
192.168.xxx.21:6379> watch aa
OK
192.168.xxx.21:6379> set aa 11
OK
192.168.xxx.21:6379> get aa
"11"
192.168.xxx.21:6379>

(2)、执行 watch 命令,通知执行 MULTI、exec

192.168.xxx.21:6379> set aa Aa
OK
192.168.xxx.21:6379> get aa
"Aa"
192.168.xxx.21:6379> multi
OK
192.168.xxx.21:6379> set aa 11
QUEUED
192.168.xxx.21:6379> exec
(nil)
192.168.xxx.21:6379> get aa
"Aa"
192.168.xxx.21:6379>

(3)、exec 执行之后,会自动执行 UNWatch 命令,撤销监听操作

192.168.xxx.21:6379> set aa Aa
OK
192.168.xxx.21:6379> get aa
"Aa"
192.168.xxx.21:6379> multi
OK
192.168.xxx.21:6379> set aa 11
QUEUED
192.168.xxx.21:6379> exec
(nil)
192.168.xxx.21:6379> get aa
"Aa"
192.168.xxx.21:6379> set aa 11
OK
192.168.xxx.21:6379> get aa
"11"
192.168.xxx.21:6379>

(4) 、unwatch撤销监听

192.168.xxx.21:6379> get bb
"BBB"
192.168.xxx.21:6379> watch bb
OK
192.168.xxx.21:6379> multi
OK
192.168.xxx.21:6379> unwatch
QUEUED
192.168.xxx.21:6379> set bb 222
QUEUED
192.168.xxx.21:6379> exec
1) OK
2) OK
192.168.xxx.21:6379> get bb
"222"
192.168.xxx.21:6379>

以上就是Redis事务的概念及相关命令的使用,Redis事务是一个非常强大的工具,它可以帮助我们在处理数据的时候保持数据的一致性和完整性。通过使用Redis事务,可以让我们的数据操作更高效、更安全。

希望这篇文章能够帮助你更好地理解和使用Redis事务!

收起阅读 »

在高德地图上使用threejs,tweenjs,引入外部模型,实现动画效果

web
init展示地图展示地图-入门教程-地图 JS API 2.0|高德地图API (amap.com) 在vue3项目中使用新版高德地图_vue3使用高德地图-CSDN博客踩坑:KEY异常,错误信息:USERKEY_PLAT_NOMATCH ...
继续阅读 »

init

展示地图

展示地图-入门教程-地图 JS API 2.0|高德地图API (amap.com) 在vue3项目中使用新版高德地图_vue3使用高德地图-CSDN博客

踩坑:KEY异常,错误信息:USERKEY_PLAT_NOMATCH 原因:申请的key和使用的服务不匹配,展示地图使用JS-API的key,地理信息解析是web服务

  1. npm包安装
npm i @amap/amap-jsapi-loader --save
  1. 引入
import AMapLoader from '@amap/amap-jsapi-loader';
  1. 初始化
var AMap, map
window._AMapSecurityConfig = {
securityJsCode: "d0543d6e1c9f40e8272aa30af54e8ded",
};
AMapLoader.load({
key: "d0543d6e1c9f40e8272aa30af54e8ded", //申请好的Web端开发者key,调用 load 时必填
version: "2.0", //指定要加载的 JS API 的版本,缺省时默认为 1.4.15
})
.then((res) => {
//JS API 加载完成后获取AMap对象
AMap = res
initMap()
})
.catch((e) => {
console.error(e); //加载错误提示
});
function initMap () {
map = new AMap.Map("map", {
viewMode: '2D', //默认使用 2D 模式
zoom: 11, //地图级别
center: [116.397428, 39.90923], //地图中心点,背景天安门为例
});
}

此时,一个平平无奇的高德地图跃然纸上

Pasted image 20240221095238.png

结合THREE

自定义图层-GLCustomLayer 结合 THREE-自有数据图层-示例中心-JS API 2.0 示例 | 高德地图API (amap.com)

环境搭建:

  1. 高德地图环境搭建看上一章
  2. three环境搭建 :一定要下载对应版本的three,在官网示例中可以查看其引入的three版本
npm i three@0.142 # 24/2/1日数据

入门小案例-引入外部模型

照搬官网案例就行

我这里做了些许改动

  1. 引入外部模型猴头
  2. 创建mesh,参考官网案例
  3. 添加移动功能,将猴头移动入mesh中

效果如图

Pasted image 20240222161502.png 代码如下:

Pasted image 20240222161509.png

  1. 引入
import AMapLoader from '@amap/amap-jsapi-loader'
import * as THREE from 'three';
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader";
import {reactive } from 'vue'
  1. 地图准备
// step map 
var AMap, map

window._AMapSecurityConfig = {
securityJsCode: "d0543d6e1c9f40e8272aa30af54e8ded",
};
AMapLoader.load({
key: "d0543d6e1c9f40e8272aa30af54e8ded", //申请好的Web端开发者key,调用 load 时必填
version: "2.0", //指定要加载的 JS API 的版本,缺省时默认为 1.4.15
})
.then((res) => {
//JS API 加载完成后获取AMap对象
AMap = res
createMap() // 创建地图
createThree() // 创建three
})
.catch((e) => {
console.error(e); //加载错误提示
});
function createMap () {
map = new AMap.Map("map", {
center: [116.54, 39.79],
zooms: [2, 20],
zoom: 14,
viewMode: '3D',
pitch: 50,
});
}

  1. three 准备 核心内容是创建GL图层,里面的 render 基本没什么变化
// step three init
var camera, renderer, scene
var model, monkey, mesh
// 数据转换工具
var customCoords
// 测试用数据
var data
function createThree () {
customCoords = map.customCoords;
data = customCoords.lngLatsToCoords([
[116.52, 39.79],
[116.54, 39.79],
[116.56, 39.79],
])
// 创建 GL 图层
var gllayer = new AMap.GLCustomLayer({
// 图层的层级
zIndex: 10,
// 初始化的操作,创建图层过程中执行一次。
init: (gl) => {
initThree(gl)
},
render: () => {
// 这里必须执行!!重新设置 three 的 gl 上下文状态。
renderer.resetState();
// 重新设置图层的渲染中心点,将模型等物体的渲染中心点重置
// 否则和 LOCA 可视化等多个图层能力使用的时候会出现物体位置偏移的问题
customCoords.setCenter([116.52, 39.79]);
var { near, far, fov, up, lookAt, position } =
customCoords.getCameraParams();

// 这里的顺序不能颠倒,否则可能会出现绘制卡顿的效果。
camera.near = near;
camera.far = far;
camera.fov = fov;
camera.position.set(...position);
camera.up.set(...up);
camera.lookAt(...lookAt);
camera.updateProjectionMatrix();

renderer.render(scene, camera);

// 这里必须执行!!重新设置 three 的 gl 上下文状态。
renderer.resetState();
},
});
map.add(gllayer)
window.addEventListener('resize', onWindowResize);
}
function onWindowResize () {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}

然后我们来看initThree

function initThree (gl) {
// 这里我们的地图模式是 3D,所以创建一个透视相机,相机的参数初始化可以随意设置,因为在 render 函数中,每一帧都需要同步相机参数,因此这里变得不那么重要。
// 如果你需要 2D 地图(viewMode: '2D'),那么你需要创建一个正交相机
camera = new THREE.PerspectiveCamera(
60,
window.innerWidth / window.innerHeight,
100,
1 << 30
);

renderer = new THREE.WebGLRenderer({
context: gl, // 地图的 gl 上下文
// alpha: true,
// antialias: true,
// canvas: gl.canvas,
});

// 自动清空画布这里必须设置为 false,否则地图底图将无法显示
renderer.autoClear = false;
scene = new THREE.Scene();

// 环境光照和平行光
var aLight = new THREE.AmbientLight(0xffffff, 3);
var dLight = new THREE.DirectionalLight(0xffffff, 10);
dLight.position.set(1000, -100, 900);
scene.add(dLight);
scene.add(aLight);
// 加载模型、mesh
addModel()
addMesh()
}

以上内容也基本不变,但最后加载模型、mesh按照你需要加载的物体变化。

加载外部模型

function addModel () {
const glftLoader = new GLTFLoader()
glftLoader.load("/public/models/monkeyAndCube.glb", function (gltf) {
model = gltf.scene
model.traverse((child) => {
child.scale.set(500, 500, 500); // 放大模型
child.rotation.x = 0.5 * Math.PI;
child.position.z = 0.8;
console.log(child.name)
if (child.name === "monkey") { monkey = child }
})
monkey.position.set(data[0][0], data[0][1], 500); // 设置位置
scene.add(monkey)
})
}

加载mesh

function addMesh () {
// 这里可以使用 three 的各种材质
var mat = new THREE.MeshLambertMaterial({
side: THREE.DoubleSide,
color: 0x1e2f97,
transparent: true,
opacity: .4,
depthWrite: false
})
var geo = new THREE.BoxBufferGeometry(1200, 1200, 1200);
const d = data[2];
mesh = new THREE.Mesh(geo, mat);
mesh.position.set(d[0], d[1], 500);
scene.add(mesh);
animate()
}
// 动画
function animate () {
mesh.rotateZ((1 / 180) * Math.PI);
map.render();
requestAnimationFrame(animate);
}

移动猴头! 记得自己给这个函数加个按钮

function moveMonkey (checked) {
console.log(checked, 'moveMonkey')
if (checked) {
monkey.position.set(data[2][0], data[2][1], 500);
} else {
monkey.position.set(data[0][0], data[0][1], 500);
}
}

[!TIP] 这里面地图和three好像是一起渲染的。如果你只加载了猴头,没加载mesh,此时是没有动画效果的,所以移动猴头的话,这个效果有延迟,缩放平移一下地图就好了。但是如果添加了动画,由于一直调用 map.render() 函数,因此不会出现此问题

大功告成!

结合tween.js

是不是觉得猴头的移动还不够顺滑,加个tween的动画试试

  1. 引入 npm install @tweenjs/tween.js
import * as TWEEN from '@tweenjs/tween.js'
  1. 更改 moveMonkey函数
function moveMonkey (checked) {
console.log(checked, 'moveMonkey')
if (checked) {
// monkey.position.set(data[2][0], data[2][1], 500);
const tween = new TWEEN.Tween(monkey.position)
.to({ x: data[2][0], y: data[2][1], z: 500 }, 2000)
.start()
} else {
// monkey.position.set(data[0][0], data[0][1], 500);
const tween = new TWEEN.Tween(monkey.position)
.to({ x: data[0][0], y: data[0][1], z: 500 }, 2000)
.start()
}
}
  1. 在 render 函数中加入 ( 这里指Three 函数中的 创建GL图层的 render 函数)
 TWEEN.update()

大功告成!Tween的其他功能也可以使用


作者:写bug的小杜
来源:juejin.cn/post/7338240698703314985

收起阅读 »