微信小程序:轻松实现时间轴组件
效果图
引言
老板: “我们公司是做基金的,用户都在买买买,可他们的钱去了哪里?没有时间轴,用户会不会觉得自己的钱瞬移了?”
你: “哈哈,确实!时间轴就像用户的投资地图,不然他们可能觉得钱被外星人劫走了。”
老板: “没错!我们得在时间轴上标清‘资金到账’、‘收益结算’这些节点,这样用户就不会担心他们的钱去买彩-票了。”
你: “放心吧,老板,我马上设计一个时间轴,让用户一看就明白他们的钱在干什么,还能时不时地笑一笑!”
老板: “好,赶紧行动,不然用户要开始给我们寄失踪报告了!”
废话不多说,我们直接开始吧!!!
组件定义
以下代码为时间轴组件的实现,详细注释在代码中。如果有任何疑问,欢迎在评论区留言讨论,或者联系我获取完整案例。
组件的 .js
文件:
/*可视化地呈现时间流信息*/
Component({
options: {
multipleSlots: true // 在组件定义时的选项中启用多slot支持
},
properties: {
activities: { // 时间轴列表
type: Array,
value: []
},
shape: { // 时间轴形状
type: String,
value: 'circle' // circle | square
},
ordinal: { // 是否显示序号
type: Boolean,
value: true
},
reverse: { // 是否倒序排列
type: Boolean,
value: false
}
},
lifetimes: {
attached() {
// 是否倒序排列操作数据
const {reverse, activities} = this.data
if (!reverse) return
this.setData({
activities: activities.reverse()
})
}
}
})
组件的.wxml
文件:
<view class="container">
<view class="item" wx:for="{{activities}}" wx:key="item">
<view class="item-tail"></view>
<view class="item-node {{shape}} {{item.status}}">
<block wx:if="{{ordinal}}">{{index + 1}}</block>
</view>
<view class="item-wrapper">
<view class="item-news">
<view class="item-timestamp">{{item.date}}</view>
<view class="item-mark">收益结算</view>
</view>
<view class="item-content">
<view>{{item.content}}</view>
<!--动态slot的实现方式-->
<slot name="operate{{index}}"></slot>
</view>
</view>
</view>
</view>
组件使用
要使用该组件,首先需要在 app.json
或 index.json
中引用组件:
"usingComponents": {
"eod-timeline": "/components/Timeline/Timeline"
}
然后你可以通过以下方式进行基本使用:
<eod-timeline activities="{{dataList}}" ordinal="{{true}}"></eod-timeline>
如果需要结合插槽动态显示操作记录,可以这样实现:
<eod-timeline activities="{{dataList}}" ordinal="{{true}}">
<!--动态slot的实现方式-->
<view wx:for="{{dataList}}" wx:for-index="idx" wx:key="idx" slot="operate{{idx}}">
<view class="row-operate">
<view>操作记录</view>
<view>收益记录</view>
<view>动账记录</view>
</view>
</view>
</eod-timeline>
数据结构与属性说明
dataList
数据结构示例如下:
dataList:[
{date: '2023-05-26 12:04:14', status: 'info', content: '内容一'},
{date: '2023-05-25 12:04:14', status: 'success', content: '内容二'},
{date: '2023-05-24 12:04:14', status: 'success', content: '内容三'},
{date: '2023-05-23 12:04:14', status: 'error', content: '内容四'},
{date: '2023-05-22 12:04:14', status: 'warning', content: '内容五'}
]
组件的属性配置如下表所示:
参数 | 说明 | 可选值 | 类型 | 默认值 |
---|---|---|---|---|
activities | 显示的数据 | — | array | — |
shape | 时间轴点形状 | circle / square | string | circle |
ordinal | 是否显示序号 | — | boolean | true |
reverse | 是否倒序排列 | — | boolean | false |
总结
这个时间轴组件提供了一个简单易用的方式来展示事件的时间顺序。组件支持定制形状、序号显示以及正序或倒序排列,同时允许通过插槽自定义内容,增强了组件的灵活性。代码中有详细注释,方便理解和修改。如果需要更详细的案例或有任何疑问,请在评论区留言。希望这篇文章对你有所帮助!
拓展阅读
关于动态 Slot
实现:
由于动态 slot
目前仅可用于 glass-easel
组件框架,而该框架仅可用于 Skyline
渲染引擎,因此这些特性也同样受此限制。如果需要在非 glass-easel
组件框架中实现动态 slot
,请参考上文标记了 <!--动态slot的实现方式-->
的代码段。
如需了解更多关于 glass-easel
组件框架的信息,请参阅微信小程序官方开发指南。
来源:juejin.cn/post/7399983901812604980
这些天,我们前端组一起处理的网站换肤功能
前言
大家好,我是沐浴在曙光下的贰货道士,好久不见,别来无恙!本文主要根据UI
设计需求,讲述一套基于scss
封装方法的网页响应式布局,以及不同于传统引入element UI
主题配色文件的换肤思路。大家仔细看完文章,相信一定会有所收获。倘若本文提供的响应式布局思路对您有所帮助,烦请大家一键三连哦。同时如果您有其他响应式布局解决方案或网站换肤思路,欢迎您不吝赐教,在评论区留言分享。感谢大家!
大家好,我是沐浴在曙光下的贰货道士,好久不见,别来无恙!本文主要根据UI
设计需求,讲述一套基于scss
封装方法的网页响应式布局,以及不同于传统引入element UI
主题配色文件的换肤思路。大家仔细看完文章,相信一定会有所收获。倘若本文提供的响应式布局思路对您有所帮助,烦请大家一键三连哦。同时如果您有其他响应式布局解决方案或网站换肤思路,欢迎您不吝赐教,在评论区留言分享。感谢大家!
需求分析
- 早期我们前端项目组开发了一个国外业务网站。这周为了迎合其他国家的喜好,需要在国外业务项目的基础上,新建多个项目,对之前的主题配色和部分布局进行修改,并需要适配不同分辨率下的屏幕。
UI
提供了包括主题配色和页面布局修改在内的一系列项目稿件,这些稿件基于1920px
分辨率的屏幕进行处理。前端需要根据UI提供的主题色,修改项目中的颜色变量。接口暂时使用国外业务的那一套接口,后期需要对接这些项目的接口,而我们目前的主要任务就是处理这些项目的静态页面改版。 - 主题色修改:
- 首先,我们前端团队需要根据
UI
提供的主题色,更新项目中的颜色变量,确保页面上的所有元素都符合新的配色方案。 页面布局
提供的修改稿件,如果有不在主题色内的颜色,需要和UI
确认是否需要更换为其他颜色相近的主题配色或者双方都新增主题配色- 检查项目中包括
CSS
和HTML
在内的所有带#
颜色值的信息。与UI
确认后,将其更换为其他颜色接近的主题配色,或者双方共同新增主题配色,以确保配色方案的一致性和协调性。
- 响应式布局:
- 前端需要根据
UI
提供的稿件和意见,适配项目在不同屏幕下的样式。对于页面上的不同元素,在小于等于1920px
的屏幕上进行缩放时,需要保持横纵比,并根据页面大小进行等比例缩放,包括容器宽高、间距等在内的页面布局是否合适都需要与UI
确认;在高于1920px
屏幕的设备上,需要保持和1920px
屏幕的布局风格,即元素的宽高不变。 - 然而,字体元素在页面缩放时,需要保持一定的风格。比如:
16px
的文字最小不能低于14px
,18px
、20px
以及24px
的文字最小不能低于16px
,32px
的文字最小不能低于18px
,36px
的文字最小不能低于20px
,44px
的文字最小不能低于28px
,48px
的文字最小不能低于32px
。 - 在移动设备上,需要保持和800px网页相同的布局。
- 早期我们前端项目组开发了一个国外业务网站。这周为了迎合其他国家的喜好,需要在国外业务项目的基础上,新建多个项目,对之前的主题配色和部分布局进行修改,并需要适配不同分辨率下的屏幕。
UI
提供了包括主题配色和页面布局修改在内的一系列项目稿件,这些稿件基于1920px
分辨率的屏幕进行处理。前端需要根据UI提供的主题色,修改项目中的颜色变量。接口暂时使用国外业务的那一套接口,后期需要对接这些项目的接口,而我们目前的主要任务就是处理这些项目的静态页面改版。 - 主题色修改:
- 首先,我们前端团队需要根据
UI
提供的主题色,更新项目中的颜色变量,确保页面上的所有元素都符合新的配色方案。 页面布局
提供的修改稿件,如果有不在主题色内的颜色,需要和UI
确认是否需要更换为其他颜色相近的主题配色或者双方都新增主题配色- 检查项目中包括
CSS
和HTML
在内的所有带#
颜色值的信息。与UI
确认后,将其更换为其他颜色接近的主题配色,或者双方共同新增主题配色,以确保配色方案的一致性和协调性。
- 首先,我们前端团队需要根据
- 响应式布局:
- 前端需要根据
UI
提供的稿件和意见,适配项目在不同屏幕下的样式。对于页面上的不同元素,在小于等于1920px
的屏幕上进行缩放时,需要保持横纵比,并根据页面大小进行等比例缩放,包括容器宽高、间距等在内的页面布局是否合适都需要与UI
确认;在高于1920px
屏幕的设备上,需要保持和1920px
屏幕的布局风格,即元素的宽高不变。 - 然而,字体元素在页面缩放时,需要保持一定的风格。比如:
16px
的文字最小不能低于14px
,18px
、20px
以及24px
的文字最小不能低于16px
,32px
的文字最小不能低于18px
,36px
的文字最小不能低于20px
,44px
的文字最小不能低于28px
,48px
的文字最小不能低于32px
。 - 在移动设备上,需要保持和800px网页相同的布局。
- 前端需要根据
项目现状
- 主题色: 早期在与UI团队合作时,我们为国外业务系统确定了一套配色方案,并将其定义在项目的颜色变量中。然而,后续设计稿中出现了一些不在这套配色方案中的色值。 由于种种原因,我们在开发时没有与UI确认这些颜色是否需要更换,也没有将新增的颜色定义到颜色变量中,而是直接在代码中使用了这些颜色值。这导致在此次换肤过程中,仅通过修改颜色变量无法实现统一换肤的效果。我们需要逐一检查代码中硬编码的颜色值,并将其替换为新的颜色变量,以确保换肤的统一性和一致性。
- 布局: 以前我们使用
flex
、百分比、最小最大宽度/高度以及element UI
的栅格布局做了一些简单的适配,但这些方法不够灵活。为了更好地适应不同分辨率的屏幕,我们需要采用更为灵活和动态的布局方案,以确保在各种设备上的显示效果都能达到预期。
- 主题色: 早期在与UI团队合作时,我们为国外业务系统确定了一套配色方案,并将其定义在项目的颜色变量中。然而,后续设计稿中出现了一些不在这套配色方案中的色值。 由于种种原因,我们在开发时没有与UI确认这些颜色是否需要更换,也没有将新增的颜色定义到颜色变量中,而是直接在代码中使用了这些颜色值。这导致在此次换肤过程中,仅通过修改颜色变量无法实现统一换肤的效果。我们需要逐一检查代码中硬编码的颜色值,并将其替换为新的颜色变量,以确保换肤的统一性和一致性。
- 布局: 以前我们使用
flex
、百分比、最小最大宽度/高度以及element UI
的栅格布局做了一些简单的适配,但这些方法不够灵活。为了更好地适应不同分辨率的屏幕,我们需要采用更为灵活和动态的布局方案,以确保在各种设备上的显示效果都能达到预期。
思路分析
主题色
传统的解决方案
- 以前在官网上,我们可以直接编辑并修改一套主题色。点击下载后,会生成一个
css
文件。
- 将下载后的
css
文件引入到我们项目中,可以看到编译后的css
文件
- 最后在项目中的入口文件,引入我们下载的
css
文件(这种方式会增加app.css
的体积)。
`main.js`
import '@/styles/theme/index.css'
- 后续处理的优化
`将编译后的element样式,从main.js指向到index.html中,减小了main.css体积`
`main.js中的css文件,最终还是会link到index.html中。那为什么还要把它拆开呢?`
`这涉及到css的拆分:浏览器会并行请求加载多个css文件,比单独请求并加载一个css文件要快`
`这样处理的目的是:将main.js中的css文件,抽出一部分放到index.html中`
<link rel="stylesheet" href="<%= BASE_URL %>theme/index.css">
- webpack小知识:
loader
webpack
只识别js
文件:当遇到其他非js
文件时,因为不识别js
文件,所以需要使用loader
插件(或引入第三方插件,或自己编写一个loader
方法),将其他文件转换为webpack
能够识别的js
文件。- 因此,
loader
的作用相当于对传入的非js
文件做处理,将它转换为 webpack
可识别的js
字符串。
在字体商用不侵权的前提下,
严格遵循设计稿的字体样式
`如果用户电脑不存在设计稿上提供的字体样式,则会展示用户电脑的默认字体样式。`
`为此,我们需要下载并引入字体,将字体集成到网站中,确保用户电脑呈现效果与我们开发一致`
`(1) 引入: 在public文件夹下新建fonts文件夹,在fonts文件夹下引入我们下载好的字体样式`
`(2) 在index.html中, 为document增加字体`
`(3) 引入并挂载字体后,我们就可以使用下载的字体了,也可以在body上全局挂载字体`
`类似element字体的引入和挂载`
`FontFace: https://developer.mozilla.org/zh-CN/docs/Web/API/CSS_Font_Loading_API`
const font1 = new FontFace(
'iconfont',
'url(/iconfont/iconfont.woff2?t=1688345853791),
url(/iconfont/iconfont.woff?t=1688345853791),
url(/iconfont/iconfont.ttf?t=1688345853791)')
const font2 = new FontFace(
'element-icons',
'url(/theme/fonts/element-icons.woff),
url(/theme/fonts/element-icons.ttf)')
font1.load().then(function() {
document.fonts.add(font1)
})
font2.load().then(function() {
document.fonts.add(font2)
})
- 以前在官网上,我们可以直接编辑并修改一套主题色。点击下载后,会生成一个
css
文件。 - 将下载后的
css
文件引入到我们项目中,可以看到编译后的css
文件 - 最后在项目中的入口文件,引入我们下载的
css
文件(这种方式会增加app.css
的体积)。
`main.js`
import '@/styles/theme/index.css'
- 后续处理的优化
`将编译后的element样式,从main.js指向到index.html中,减小了main.css体积`
`main.js中的css文件,最终还是会link到index.html中。那为什么还要把它拆开呢?`
`这涉及到css的拆分:浏览器会并行请求加载多个css文件,比单独请求并加载一个css文件要快`
`这样处理的目的是:将main.js中的css文件,抽出一部分放到index.html中`
<link rel="stylesheet" href="<%= BASE_URL %>theme/index.css">
loader
webpack
只识别js
文件:当遇到其他非js
文件时,因为不识别js
文件,所以需要使用loader
插件(或引入第三方插件,或自己编写一个loader
方法),将其他文件转换为webpack
能够识别的js
文件。loader
的作用相当于对传入的非js
文件做处理,将它转换为 webpack
可识别的js
字符串。在字体商用不侵权的前提下,
严格遵循设计稿的字体样式`如果用户电脑不存在设计稿上提供的字体样式,则会展示用户电脑的默认字体样式。`
`为此,我们需要下载并引入字体,将字体集成到网站中,确保用户电脑呈现效果与我们开发一致`
`(1) 引入: 在public文件夹下新建fonts文件夹,在fonts文件夹下引入我们下载好的字体样式`
`(2) 在index.html中, 为document增加字体`
`(3) 引入并挂载字体后,我们就可以使用下载的字体了,也可以在body上全局挂载字体`
`类似element字体的引入和挂载`
`FontFace: https://developer.mozilla.org/zh-CN/docs/Web/API/CSS_Font_Loading_API`
const font1 = new FontFace(
'iconfont',
'url(/iconfont/iconfont.woff2?t=1688345853791),
url(/iconfont/iconfont.woff?t=1688345853791),
url(/iconfont/iconfont.ttf?t=1688345853791)')
const font2 = new FontFace(
'element-icons',
'url(/theme/fonts/element-icons.woff),
url(/theme/fonts/element-icons.ttf)')
font1.load().then(function() {
document.fonts.add(font1)
})
font2.load().then(function() {
document.fonts.add(font2)
})
现在的解决方案
由于element UI
官方已不再维护传统的主题配色下载,我们项目采取官方提供的第二种方式:
- 原理: 我们项目使用
scss
编写css
,element UI
的theme-chalk
又恰好使用scss
进行编写。在官方定义的scss
变量中,使用了!default
语法,用于提供默认值。这也就意味着,我们不用考虑css
的加载顺序,直接新建scss
文件,覆盖定义在theme-chalk
文件且在我们系统中常用的scss
变量,达到在css
编译阶段自定义主题scss
变量的效果。

- 引入变量: 新建
element-variable.scss
文件,在这个文件中引入theme-chalk
定义的主题scss
变量,同时需要改变icon
字体路径变量(使用传统方法不需要改变路径变量,是因为我们直接引入了编译后的css
文件,里面已经帮我们做过处理了;而使用现在的解决方案,如果不改变字体路径变量,项目会提示找不到icon
字体路径,所以这个配置必填)。此时,将这个文件引入到我们的入口文件,那么系统中已经存在theme-chalk
定义好的scss
变量了

- 修改变量: 新建
element.scss
文件,在里面覆盖我们需要修改的主题变量,最后在vue.config.js
中sass
配置下的additionalData
里全局引入到项目中的每个vue
文件中(因为是挂载到每个vue
文件中,所以这个配置下的scss文件不宜过多),方便在vue
文件中直接使用变量。


优势
1. 定制化和灵活性
- 更改主题色和变量: 轻松改变
Element UI
的主题色、字体、间距等变量,而无需过多地覆盖现有的element CSS
样式。 - 精细控制: 原先的配置方式只能配置主题色,无法控制更细粒度的配置,比如边框颜色之类。
2. 避免样式冲突
- 避免样式覆盖的冲突: 通过直接修改
SCSS
变量来定制样式,可以避免在使用编译后的 CSS 文件时可能出现的样式覆盖冲突问题。这样可以保证样式的独立性和一致性。
3. 便于维护
- 集中管理: 所有的样式修改都集中在一个地方(变量文件),这使得维护样式变得更加方便和清晰。只需要修改文件中定义的变量,就可以影响整个项目中的样式,无需逐一查找以及修改每个组件的样式。
缺陷
- 在
sass loader
的additionalData
中配置了过多的全局css
变量,添加到每个vue
文件中 - 相比之前的处理方式,在
main.js
中引入element
自定义的主题scss
变量,首页加载的css
文件更多,
sass loader
的additionalData
中配置了过多的全局css
变量,添加到每个vue
文件中main.js
中引入element
自定义的主题scss
变量,首页加载的css
文件更多,响应式布局
思路分析
UI
提供的稿件是1920px
,前端需要对UI
提供的稿件进行一比一还原;- 网页在小屏缩放时,需要保持元素的横纵比。针对这个问题,我们可以用百分比作为布局单位。 以设计稿宽度
1920px
为基准,建立px
和vw
之间的关系。如果把1920px
视为100vw
,那么1vw = 19.2px
。 如果设计稿上某个元素的宽度为192px
, 那么将它换算得到的结果将会是192px / 19.2px * 1vw = 10vw
。因此我们在布局时,需要严格遵循UI
提供的设计稿件,并借助下文封装的方法,将设计稿元素的像素作为第一个形参,传递到下文封装的方法中; 实现思路:为等比例缩放网页元素,先去掉传入的像素单位。最后使用前文提到的换算公式,不论宽高,都将其转换为
vw单位,等比缩放
。 - 字体页面元素在放大时,需要限制字体元素展现的最大阈值。 那么我们封装的方法,第二个形参需要控制字体元素的最大阈值;
实现思路:借助
scss中的
max方法实现。
- 字体页面元素在缩小时,需要限制字体元素展现的最小阈值。 那么我们封装的方法,第三个形参需要控制字体元素的最小阈值;
实现思路:借助
scss中的
min方法实现。
- 在高于
1920px
屏幕的设备上,需要保持和1920px
屏幕的布局风格,即元素的宽高不变。 针对这个问题,我们只需要保证方法中的max
形参和1920px
下的像素值一致,即保证方法中的第一个形参和第二个形参相同。 - 在移动设备上,需要使用
800px
的网页布局。针对这个问题,我们可以使用meta
标签进行适配:
- 不同屏幕下的元素显示势必不会那么完美。我们可以通过媒体查询,在不同分辨率的屏幕下,按照
UI
给定的反馈意见,对网页进行适配,这样就可以解决问题。但是在项目中大量使用媒体查询语法,会导致整个项目看上去很乱。为此,我们可以基于scss
语法,对媒体查询语法进行二次封装。 - 如何测试我们编写的
scss
代码? 移步sass在线调试

UI
提供的稿件是1920px
,前端需要对UI
提供的稿件进行一比一还原;- 网页在小屏缩放时,需要保持元素的横纵比。针对这个问题,我们可以用百分比作为布局单位。 以设计稿宽度
1920px
为基准,建立px
和vw
之间的关系。如果把1920px
视为100vw
,那么1vw = 19.2px
。 如果设计稿上某个元素的宽度为192px
, 那么将它换算得到的结果将会是192px / 19.2px * 1vw = 10vw
。因此我们在布局时,需要严格遵循UI
提供的设计稿件,并借助下文封装的方法,将设计稿元素的像素作为第一个形参,传递到下文封装的方法中;实现思路:为等比例缩放网页元素,先去掉传入的像素单位。最后使用前文提到的换算公式,不论宽高,都将其转换为
vw单位,等比缩放
。 - 字体页面元素在放大时,需要限制字体元素展现的最大阈值。 那么我们封装的方法,第二个形参需要控制字体元素的最大阈值;
实现思路:借助
scss中的
max方法实现。
- 字体页面元素在缩小时,需要限制字体元素展现的最小阈值。 那么我们封装的方法,第三个形参需要控制字体元素的最小阈值;
实现思路:借助
scss中的
min方法实现。
- 在高于
1920px
屏幕的设备上,需要保持和1920px
屏幕的布局风格,即元素的宽高不变。 针对这个问题,我们只需要保证方法中的max
形参和1920px
下的像素值一致,即保证方法中的第一个形参和第二个形参相同。 - 在移动设备上,需要使用
800px
的网页布局。针对这个问题,我们可以使用meta
标签进行适配: - 不同屏幕下的元素显示势必不会那么完美。我们可以通过媒体查询,在不同分辨率的屏幕下,按照
UI
给定的反馈意见,对网页进行适配,这样就可以解决问题。但是在项目中大量使用媒体查询语法,会导致整个项目看上去很乱。为此,我们可以基于scss
语法,对媒体查询语法进行二次封装。 - 如何测试我们编写的
scss
代码? 移步sass在线调试
自适应scss
方法封装
// 自定义scss函数, 作用是去掉传入变量的单位
// 之所以要去掉单位,是为了将传入的px转换为vw单位,自适应布局`
@function stripUnits($value) {
// 对带有单位的变量进行特殊处理,返回去掉单位后的结果`
// 对于scss来说, 90px和90都是number`
// 在scss中,unitless是一个术语,指的是没有单位的数值,not unitless就是变量带单位`
@if type-of($value) == 'number' and not unitless($value) {
// 90px / 1 得到的结果是90px, 90px / 1px得到的结果是90
// 这也是这里为什么要用($value * 0 + 1),而不是直接写1的原因`
@return $value / ($value * 0 + 1);
}
@return $value;
}
/*
自定义scss函数,提供三个参数:
第一个参数是设计稿提供的元素大小,传入会自动转换为vw单位,达到自适应的效果
第二个参数是用来约束这个元素的大小最大不能超过第一个参数和第二个参数的最大值, 必须带单位
第三个参数是用来约束这个元素的大小最小不能小于第一个参数和第三个参数的最小值,必须带单位
如果不传入第二个和第三个参数,则表示元素完全随屏幕响应式缩放
应用场景:
1. 1920px下标题的字体是48px,当屏幕分辨率为960px时,标题字号缩放为24px,起不到突出的作用。
于是我们可以给它设置一个最小阈值,比如最小不能小于32px;
2. 同理,当屏幕分辨率为3840px时,标题字号放大为96px,我们不希望字号这么大。
于是可以给它设置一个最大阈值,比如最大不能超过60px。
*/
@function auto($raw, $max:null, $min:null) {
$raw: stripUnits($raw);
$str: #{$raw / $proportion}vw;
@if $max {
$str: min(#{$str}, #{$max});
}
@if $min {
$str: max(#{$str}, #{$min});
}
@return $str;
}
/*
自定义scss函数,auto方法的二次封装, 提供两个参数
第一个参数用于设置1920px下的元素大小
第二个参数用于设置这个元素的最小值
应用场景:
1. 1920px下标题的字体是48px,当屏幕分辨率为3840px时,标题字号放大为96px,我们希望它保持48px大小,
于是我们可以给它设置一个最大阈值48px。同时,我们可以传入一个最小阈值,让它最小不能小于这个参数。
*/
@function autoMax($raw, $min:null) {
@return auto($raw, $raw, $min)
}
// 和上面相反
@function autoMin($raw, $max:null) {
@return auto($raw, $max, $raw)
}
//1vw = 1920 / 100 ;
$proportion: 19.2;
// 根据UI需求,对不同字体大小进行封装
$wb-font-size-mini: 16px; // $text-mini-1
$wb-font-size-extra-small: 18px; // $text-small-1
$wb-font-size-small: 20px; //$text-sm-md-1
$wb-font-size-base: 24px; //$text-medium-1
$wb-font-size-lesser-medium: 32px;
$wb-font-size-medium: 36px; //$text-large-1
$wb-font-size-extra-medium: 44px;
$wb-font-size-large: 48px; //$text-title-1
// 根据UI需求,在屏幕分辨率缩小时,字体响应式变化,并设定最小阈值
// 并在1920px以上的屏幕,保持和1920px一样的字体大小
$wb-auto-font-size-mini: autoMax($wb-font-size-mini, 14px);
$wb-auto-font-size-extra-small: autoMax($wb-font-size-extra-small, 16px);
$wb-auto-font-size-small: autoMax($wb-font-size-small, 16px);
$wb-auto-font-size-base: autoMax($wb-font-size-base, 16px);
$wb-auto-font-size-lesser-medium: autoMax($wb-font-size-lesser-medium, 18px);
$wb-auto-font-size-medium: autoMax($wb-font-size-medium, 20px);
$wb-auto-font-size-extra-medium: autoMax($wb-font-size-extra-medium, 28px);
$wb-auto-font-size-large: autoMax($wb-font-size-large, 32px);
// 严格按照UI稿件提供的元素大小、间距编写代码,以下是示例代码
.title {
padding: 0 autoMax(180px);
font-size: $wb-auto-font-size-large;
font-weight: 600;
text-align: center;
}

// 自定义scss函数, 作用是去掉传入变量的单位
// 之所以要去掉单位,是为了将传入的px转换为vw单位,自适应布局`
@function stripUnits($value) {
// 对带有单位的变量进行特殊处理,返回去掉单位后的结果`
// 对于scss来说, 90px和90都是number`
// 在scss中,unitless是一个术语,指的是没有单位的数值,not unitless就是变量带单位`
@if type-of($value) == 'number' and not unitless($value) {
// 90px / 1 得到的结果是90px, 90px / 1px得到的结果是90
// 这也是这里为什么要用($value * 0 + 1),而不是直接写1的原因`
@return $value / ($value * 0 + 1);
}
@return $value;
}
/*
自定义scss函数,提供三个参数:
第一个参数是设计稿提供的元素大小,传入会自动转换为vw单位,达到自适应的效果
第二个参数是用来约束这个元素的大小最大不能超过第一个参数和第二个参数的最大值, 必须带单位
第三个参数是用来约束这个元素的大小最小不能小于第一个参数和第三个参数的最小值,必须带单位
如果不传入第二个和第三个参数,则表示元素完全随屏幕响应式缩放
应用场景:
1. 1920px下标题的字体是48px,当屏幕分辨率为960px时,标题字号缩放为24px,起不到突出的作用。
于是我们可以给它设置一个最小阈值,比如最小不能小于32px;
2. 同理,当屏幕分辨率为3840px时,标题字号放大为96px,我们不希望字号这么大。
于是可以给它设置一个最大阈值,比如最大不能超过60px。
*/
@function auto($raw, $max:null, $min:null) {
$raw: stripUnits($raw);
$str: #{$raw / $proportion}vw;
@if $max {
$str: min(#{$str}, #{$max});
}
@if $min {
$str: max(#{$str}, #{$min});
}
@return $str;
}
/*
自定义scss函数,auto方法的二次封装, 提供两个参数
第一个参数用于设置1920px下的元素大小
第二个参数用于设置这个元素的最小值
应用场景:
1. 1920px下标题的字体是48px,当屏幕分辨率为3840px时,标题字号放大为96px,我们希望它保持48px大小,
于是我们可以给它设置一个最大阈值48px。同时,我们可以传入一个最小阈值,让它最小不能小于这个参数。
*/
@function autoMax($raw, $min:null) {
@return auto($raw, $raw, $min)
}
// 和上面相反
@function autoMin($raw, $max:null) {
@return auto($raw, $max, $raw)
}
//1vw = 1920 / 100 ;
$proportion: 19.2;
// 根据UI需求,对不同字体大小进行封装
$wb-font-size-mini: 16px; // $text-mini-1
$wb-font-size-extra-small: 18px; // $text-small-1
$wb-font-size-small: 20px; //$text-sm-md-1
$wb-font-size-base: 24px; //$text-medium-1
$wb-font-size-lesser-medium: 32px;
$wb-font-size-medium: 36px; //$text-large-1
$wb-font-size-extra-medium: 44px;
$wb-font-size-large: 48px; //$text-title-1
// 根据UI需求,在屏幕分辨率缩小时,字体响应式变化,并设定最小阈值
// 并在1920px以上的屏幕,保持和1920px一样的字体大小
$wb-auto-font-size-mini: autoMax($wb-font-size-mini, 14px);
$wb-auto-font-size-extra-small: autoMax($wb-font-size-extra-small, 16px);
$wb-auto-font-size-small: autoMax($wb-font-size-small, 16px);
$wb-auto-font-size-base: autoMax($wb-font-size-base, 16px);
$wb-auto-font-size-lesser-medium: autoMax($wb-font-size-lesser-medium, 18px);
$wb-auto-font-size-medium: autoMax($wb-font-size-medium, 20px);
$wb-auto-font-size-extra-medium: autoMax($wb-font-size-extra-medium, 28px);
$wb-auto-font-size-large: autoMax($wb-font-size-large, 32px);
// 严格按照UI稿件提供的元素大小、间距编写代码,以下是示例代码
.title {
padding: 0 autoMax(180px);
font-size: $wb-auto-font-size-large;
font-weight: 600;
text-align: center;
}
媒体查询语法封装及使用规范
// 导入scss的list和map模块,用于处理相关操作。
@use 'sass:list';
@use "sass:map";
/*
媒体查询映射表,定义各种设备类型的媒体查询范围
key为定义的媒体类型,value为对应的分辨率范围
*/
$media-list: (
mobile-begin: (0, null),
mobile: (0, 800),
mobile-end:(null, 800),
tablet-begin: (801, null),
tablet: (801, 1023),
tablet-end:(null, 1023),
mini-desktop-begin: (1024, null),
mini-desktop: (1024, 1279),
mini-desktop-end: (null, 1279),
small-desktop-begin: (1280, null),
small-desktop: (1280, 1439),
small-desktop-end: (null, 1439),
medium-desktop-begin: (1440, 1919),
medium-desktop: (1440, 1919),
medium-desktop-end: (null, 1919),
large-desktop-begin: (1920, null),
large-desktop: (1920, 2559),
large-desktop-end: (null, 2559),
super-desktop-begin: (2560, null),
super-desktop: (2560, null),
super-desktop-end: (2560, null)
);
/*
创建响应式媒体查询的函数,传参是媒体查询映射表中的媒体类型
从$media-list中获取对应的最小和最大宽度,并返回相应的媒体查询字符串。
*/
@function createResponsive($media) {
$size-list: map.get($media-list, $media);
$min-size: list.nth($size-list, 1);
$max-size: list.nth($size-list, 2);
@if ($min-size and $max-size) {
@return "screen and (min-width:#{$min-size}px) and (max-width: #{$max-size}px)";
} @else if ($max-size) {
@return "screen and (max-width: #{$max-size}px)";
} @else {
@return "screen and (min-width:#{$min-size}px)";
}
}
/*
这个混入接受一个或多个媒体类型参数,调用createResponsive函数生成媒体查询
@content是Scss中的一个占位符,用于在混入中定义块级内容。
它允许你在调用混入时,将实际的样式代码插入到混入定义的样式规则中。
*/
@mixin responsive-to($media...) {
@each $item in $media {
$media-content: createResponsive($item);
@media #{$media-content} {
@content;
}
}
}
// 以下是针对各种媒体类型定义的混入:
@mixin mobile() {
@include responsive-to(mobile) {
@content;
}
}
@mixin tablet() {
@include responsive-to(tablet) {
@content;
}
}
@mixin mini-desktop() {
@include responsive-to(mini-desktop) {
@content;
}
}
@mixin small-desktop() {
@include responsive-to(small-desktop) {
@content;
}
}
@mixin medium-desktop() {
@include responsive-to(medium-desktop) {
@content;
}
}
@mixin large-desktop() {
@include responsive-to(large-desktop) {
@content;
}
}
@mixin super-desktop() {
@include responsive-to(super-desktop) {
@content;
}
}
@mixin mobile-begin() {
@include responsive-to(mobile-begin) {
@content;
}
}
@mixin tablet-begin() {
@include responsive-to(tablet-begin) {
@content;
}
}
@mixin mini-desktop-begin() {
@include responsive-to(mini-desktop-begin) {
@content;
}
}
@mixin small-desktop-begin() {
@include responsive-to(small-desktop-begin) {
@content;
}
}
@mixin medium-desktop-begin() {
@include responsive-to(medium-desktop-begin) {
@content;
}
}
@mixin large-desktop-begin() {
@include responsive-to(large-desktop-begin) {
@content;
}
}
@mixin super-desktop-begin() {
@include responsive-to(super-desktop-begin) {
@content;
}
}
@mixin mobile-end() {
@include responsive-to(mobile-end) {
@content;
}
}
@mixin tablet-end() {
@include responsive-to(tablet-end) {
@content;
}
}
@mixin mini-desktop-end() {
@include responsive-to(mini-desktop-end) {
@content;
}
}
@mixin small-desktop-end() {
@include responsive-to(small-desktop-end) {
@content;
}
}
@mixin medium-desktop-end() {
@include responsive-to(medium-desktop-end) {
@content;
}
}
@mixin large-desktop-end() {
@include responsive-to(large-desktop-end) {
@content;
}
}
@mixin super-desktop-end() {
@include responsive-to(super-desktop-begin) {
@content;
}
}

// 导入scss的list和map模块,用于处理相关操作。
@use 'sass:list';
@use "sass:map";
/*
媒体查询映射表,定义各种设备类型的媒体查询范围
key为定义的媒体类型,value为对应的分辨率范围
*/
$media-list: (
mobile-begin: (0, null),
mobile: (0, 800),
mobile-end:(null, 800),
tablet-begin: (801, null),
tablet: (801, 1023),
tablet-end:(null, 1023),
mini-desktop-begin: (1024, null),
mini-desktop: (1024, 1279),
mini-desktop-end: (null, 1279),
small-desktop-begin: (1280, null),
small-desktop: (1280, 1439),
small-desktop-end: (null, 1439),
medium-desktop-begin: (1440, 1919),
medium-desktop: (1440, 1919),
medium-desktop-end: (null, 1919),
large-desktop-begin: (1920, null),
large-desktop: (1920, 2559),
large-desktop-end: (null, 2559),
super-desktop-begin: (2560, null),
super-desktop: (2560, null),
super-desktop-end: (2560, null)
);
/*
创建响应式媒体查询的函数,传参是媒体查询映射表中的媒体类型
从$media-list中获取对应的最小和最大宽度,并返回相应的媒体查询字符串。
*/
@function createResponsive($media) {
$size-list: map.get($media-list, $media);
$min-size: list.nth($size-list, 1);
$max-size: list.nth($size-list, 2);
@if ($min-size and $max-size) {
@return "screen and (min-width:#{$min-size}px) and (max-width: #{$max-size}px)";
} @else if ($max-size) {
@return "screen and (max-width: #{$max-size}px)";
} @else {
@return "screen and (min-width:#{$min-size}px)";
}
}
/*
这个混入接受一个或多个媒体类型参数,调用createResponsive函数生成媒体查询
@content是Scss中的一个占位符,用于在混入中定义块级内容。
它允许你在调用混入时,将实际的样式代码插入到混入定义的样式规则中。
*/
@mixin responsive-to($media...) {
@each $item in $media {
$media-content: createResponsive($item);
@media #{$media-content} {
@content;
}
}
}
// 以下是针对各种媒体类型定义的混入:
@mixin mobile() {
@include responsive-to(mobile) {
@content;
}
}
@mixin tablet() {
@include responsive-to(tablet) {
@content;
}
}
@mixin mini-desktop() {
@include responsive-to(mini-desktop) {
@content;
}
}
@mixin small-desktop() {
@include responsive-to(small-desktop) {
@content;
}
}
@mixin medium-desktop() {
@include responsive-to(medium-desktop) {
@content;
}
}
@mixin large-desktop() {
@include responsive-to(large-desktop) {
@content;
}
}
@mixin super-desktop() {
@include responsive-to(super-desktop) {
@content;
}
}
@mixin mobile-begin() {
@include responsive-to(mobile-begin) {
@content;
}
}
@mixin tablet-begin() {
@include responsive-to(tablet-begin) {
@content;
}
}
@mixin mini-desktop-begin() {
@include responsive-to(mini-desktop-begin) {
@content;
}
}
@mixin small-desktop-begin() {
@include responsive-to(small-desktop-begin) {
@content;
}
}
@mixin medium-desktop-begin() {
@include responsive-to(medium-desktop-begin) {
@content;
}
}
@mixin large-desktop-begin() {
@include responsive-to(large-desktop-begin) {
@content;
}
}
@mixin super-desktop-begin() {
@include responsive-to(super-desktop-begin) {
@content;
}
}
@mixin mobile-end() {
@include responsive-to(mobile-end) {
@content;
}
}
@mixin tablet-end() {
@include responsive-to(tablet-end) {
@content;
}
}
@mixin mini-desktop-end() {
@include responsive-to(mini-desktop-end) {
@content;
}
}
@mixin small-desktop-end() {
@include responsive-to(small-desktop-end) {
@content;
}
}
@mixin medium-desktop-end() {
@include responsive-to(medium-desktop-end) {
@content;
}
}
@mixin large-desktop-end() {
@include responsive-to(large-desktop-end) {
@content;
}
}
@mixin super-desktop-end() {
@include responsive-to(super-desktop-begin) {
@content;
}
}
需求解决思路:
- 根据提供的设计稿,使用
autoMax
系列方法,对页面做初步的响应式布局适配 - 针对不同屏幕下部分元素布局需要调整的问题,使用封装的媒体查询方法进行处理
书写规范:
为避免项目中的scss
文件过多,搞得整个项目看上去很臃肿,现提供一套书写规范:
- 在每个路由下的主
index.vue
文件中,引入同级文件夹scss
下的media.scss
文件
// 小屏状态下,覆盖前面定义的css样式
media.css
文件
写法:以vue
文件最外层的类进行包裹,使用deep
穿透,以屏幕分辨率大小作为排序依据,从大到小书写媒体查询样式
.about-wrapper::v-deep {
@include small-desktop {
.a {
.b {
}
}
}
@include mini-desktop {
.a {
.b {
}
}
}
@include tablet-end {
.a {
.b {
}
}
}
}
结语
感谢掘友们耐心看到文末,希望你们不是一路跳转至评论区,我们江湖再见!
来源:juejin.cn/post/7388753413309775887
淘宝、京东复制好友链接弹出商品详情是如何实现的
前言: 最近接到了一个需求很有意思,类似于我们经常在逛购物平台中,选择一个物品分享给好友,然后好友复制这段文本打开相对应的平台以后,就可以弹出链接上的物品。实现过程也比较有意思,特来分享一下实现思路🎁。
一. 效果预览
当我在别的界面复制了内容以后,回到主应用,要求可以检测到当前剪切板是什么内容。
二. 监听页面跳转动作
- 要完成这个需求,整体思路并不复杂。首先我们要解决的就是如何检测到用户从别的应用切回到我们自己的应用。
- 这个听起来很复杂,但其实浏览器已经提供了相对应的
api
来帮我们检测用户这个操作----document.visibilitychange
。
- 那么我们就可以写下如下代码
document.addEventListener("visibilitychange", () => {
console.log("用户切换了");
});
相对应的效果如下图所示,你可能会好奇,我明明只切换了一次,但是为什么控制台却执行了两次打印?
这也不难理解,首先你要理解这个change
这个动作,你从 tab1 切换到 Tab2 的时候,触发了当前 Tab1 从可见 变为=> 不可见 。
而当你从 tab2 切回 tab1 的时候,触发了当前 Tab1 从不可见变为了可见。完整动作引起了状态两次变化,所以才有了两次打印。 - 而我们的场景只是希望 app 从不可见转变为可见的时候才触发。那么我们就需要用到��外一个变量来配合使用-------
document.visibilityState
。
这个值是一个document
对象上的一个只读属性,它有三个string
类型的值visible
、hidden
、prerender
。从它的使用说明中不难看出,我们要使用的值是visible
。
tips:hidden 可以用来配合做一些流量控制优化,当用户切换网页到后台的时候,我们可以停止一些不必要的轮询任务,待用户切回后再开启。
- 那么我们现在的代码应该是这样的:
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
console.log("页面变得可见了!");
}
});
可以看到,现在控制台正确的只执行了一次。
三. 完成读取剪切板内容
- 要完成读取剪切板内容需要用到浏览器提供的另外一个
api
-----navigator.clipboard
。这里穿插一个英语记忆的小技巧,我们要把这个单词分成两部分记忆:clip 和 board。clip 本身就有修剪的意思,board 常作为木板相近的含义和别的单词组合,如:黑板 blackboard、棋盘 chessboard。所以这两个单词组合起来的含义就是剪切板。
- 这里需要注意一句话,这个功能只能用在安全上下文中。这个概念很抽象,如果想深入了解的话,还需自行查阅资料。这里指简单说明这句话的限制:要想使用这个
api
你只能在localhost、127.0.0.1
这样的本地回环地址或者使用https
协议的网站中使用。
- 要快速检测当前浏览器或者网站是否是安全上下文 ,可以使用
Window:isSecureContext
属性来判断。 - 你可以动手访问一个 http 的网站,然后在控制台打印一下该属性,你大概率会看到一个
false
,则说明该环境不是一个安全上下文,所以 clipboard 在这个环境下大概率不会生效。因为本文章代码都为本地开发(localhost),所以自然为安全上下文。
- 经过上面的知识,那么我们就可以写出下面的兼容性代码。
- 前置步骤都已经完成,接下来就是具体读取剪切板内容了。关于读取操作,
clipboard
提供了两个api
-----read
和readText
。这里由于我们的需求很明确,我读取的链接本身就是一个字符串类型的数据,所以我们就直接选用readText
方法即可。稍后在第四章节我会介绍read
方法。 clipboard
所有操作都是异步会返回一个Promise
类型的数据的,所以这里我们的代码应该是这样的:
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
if (window.isSecureContext && navigator.clipboard) {
const clipboardAPI = navigator.clipboard; //获取 clipboard 对象
setTimeout(() => {
clipboardAPI.readText().then((text) => {
console.log("text", text);
});
}, 1000);
} else {
console.log("不支持 clipboard");
}
}
});
注意⚠️:这里你会看到我使用了
setTimeout
来解决演示的问题,如果你正在跟着练习但是不明白原因,请查看下面链接:
关于 DOM exception: document is not focused 请查阅stackoverflow 文档未聚焦的解决方案
相应的效果如下图所示,可以看到我们已经可以正确读取刚刚剪切板的内容了。
- 此时,当拿到用户剪切板的内容以后,我们就可以根据某些特点来判断弹窗了。这里我随便使用了一个弹出组件来展示效果:
- 什么?到这里你还是没看懂和网购平台链接之间有什么关系?ok,让我们仔细看一下我分别从两家平台随手复制的两个链接,看出区别了吗?开头的字符串可以很明显看出是各家的名字。
- 那么我只需判断用户剪切板上的字符串是否符合站内的某项规则不就行了吗?让我来举个更具体的栗子,下面链接是我掘金的个人首页,假如用户此时复制了这段文本,然后跳转回我们自己的应用后,刚刚的代码就可以加一个逻辑判断,检测用户剪切板上的链接是否是以
juejin.cn
开头的,如果是则跳转首页;如果不是,那么什么事情也不做。
对应的代码如下:
- 那么相对应的效果如下,这也就是为什么复制某宝的链接到某东后没任何反应的原因。某东并不是没读取,而是读取后发现不是自家的就不处理罢了。
:
四*. 思维拓展:粘贴图片自动转链接的实现
- 用过相关写作平台的小伙伴大概对在编辑器中直接接粘贴图片的功能不陌生。如掘金的编辑器,当我复制一个图片以后,直接在编辑器中粘贴即可。掘金会自动将图片转换为一个链接,这样会极大的提高创作者的写作体验。
- 那么现在让我们继续发散思维来思考这个需求如何实现,这里我们先随便创建一个富文本框。
- 既然是粘贴图片,那么最起码我得知道用户什么时候进行粘贴操作吧?这还不简单,直接监听
paste
事件即可。
document.addEventListener("paste",()=>{
console.log("用户粘贴了")
})
实现的效果如下:
- 把之前的
clipboard.readText
替换为clipboard.read
以后你的代码应该是下面这样的:
document.addEventListener("paste", () => {
if (window.isSecureContext && navigator.clipboard) {
const clipboardAPI = navigator.clipboard; //获取 clipboard 对象
setTimeout(() => {
clipboardAPI.read().then((result) => {
console.log("result", result);
});
}, 1000);
} else {
console.log("不支持 clipboard");
}
});
让我们复制一张图片到富文本区域执行粘贴操作后,控制台会打印以下信息:
clipboardItem
是一个数组,里面有很多子clipboardItem
,是数组的原因是因为你可以一下子复制多张图片,不过在这里我们只考虑一张图片的场景。- 这里我们取第一项,然后调用
clipboardItem.getType
方法,这个方法需要传递一个文件类型的参数,这里我们传入粘贴内容对应的类型即可,这里传入image/png
。
在控制台这里可以看到一下输出,就表示我们已经正确拿到图片的blob
的格式数据了。
- 此时我们就只需要把相对应的图片数据传递给后端或者
CDN
服务器,让它们返回一个与之对应的链接即可。在掘金的编辑器中,对应的请求就是get-image-url
这个请求。
- 然后调用
textarea.value + link
把链接补充到文章最后位置即可。
五. 源码
<script lang="ts" setup>
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
if (window.isSecureContext && navigator.clipboard) {
const clipboardAPI = navigator.clipboard; //获取 clipboard 对象
setTimeout(() => {
clipboardAPI.read().then((result) => {});
}, 1000);
} else {
console.log("不支持 clipboard");
}
}
});
document.addEventListener("paste", () => {
if (window.isSecureContext && navigator.clipboard) {
const clipboardAPI = navigator.clipboard; //获取 clipboard 对象
setTimeout(() => {
clipboardAPI.read().then((result) => {
result[0].getType("image/png").then((blob) => {
console.log("blob", blob);
const reader = new FileReader();
reader.readAsDataURL(blob);
reader.onload = (e) => {
const img = document.createElement("img");
img.src = e.target?.result;
const wrapper = document.getElementById("han");
wrapper.appendChild(img);
};
});
});
}, 1000);
} else {
console.log("不支持 clipboard");
}
});
</script>
<template>
<div id="han" class="w-full h-full bg-blue">
<textarea class="w-300px h-300px"></textarea>
</div>
</template>
六. 思考 writeText 的用法
有了上面的经验,我相信你已经可以自己理解 clipboard
剩下的两个方法 write
和 writeText
了。你可以思考下面的问题:
为什么在掘金复制文章内容后,剪切板会自动加版权信息呢?。
如果你实现了,不妨在评论区写下你的思路~🌹
来源:juejin.cn/post/7385776238789181449
Dart中令人惊艳的8个用法(深入探索)
Dart是谷歌开发的现代化编程语言,凭借其简洁的语法和强大的功能,在开发者当中赢得了极高的声誉,尤其是在Flutter框架中发挥了巨大的作用。本文将介绍Dart中的8个令人惊艳的用法,这些用法不仅技术深度足够,充满启发性,而且能够让您的Dart编程效率飞速提升。
1. 泛型类型别名的高级应用
类型别名可以让你用简单的名称定义更复杂的类型,尤其是在处理大量嵌套的泛型时特别有用。
typedef ComplexList<T> = List<Map<T, T>>;
void main() {
// 适用于需要设置特定键值对类型的列表
ComplexList<String> complexList = [
{'key1': 'value1'},
{'key2': 'value2'},
];
// 复杂集合的操作
complexList.add({'key3': 'value3'});
print(complexList);
}
泛型类型别名可以更好地组织代码,增强代码的可读性。
2. Stream的高级处理技巧
利用Stream提供的各种操作符和转换器,能够更好地处理事件流和异步数据。
Stream<int> timedCounter(Duration interval, int maxCount) async* {
int count = 0;
while (count < maxCount) {
await Future.delayed(interval);
yield ++count;
}
}
void main() async {
// 监听Stream,执行特定逻辑
await for (final count in timedCounter(Duration(seconds: 1), 5)) {
print(count);
}
}
通过async*
和yield
,你可以构建出能够发射数据序列的Stream,为异步编程提供强大支持。
3. Isolate的轻量级并行计算
Isolate可以在不同的执行线程中运是执行并发操作的强大工具。
import 'dart:isolate';
Future<void> computeOnIsolate() async {
final receivePort = ReceivePort();
Isolate.spawn(_heavyComputation, receivePort.sendPort);
final message = await receivePort.first as String;
print(message);
}
void _heavyComputation(SendPort sendPort) {
// 很重的计算
// 假设这是一个令CPU满负荷的操作
sendPort.send('计算完成');
}
void main() {
computeOnIsolate();
}
通过Isolate,你可以在Flutter应用中执行耗时操作而不影响应用的响应性。
4. 使用枚举的高级技巧
枚举类型不仅仅可以代表一组命名常量,通过扩展方法,可以大幅提升它们的功能。
enum ConnectionState {
none,
waiting,
active,
done,
}
extension ConnectionStateX on ConnectionState {
bool get isTerminal => this == ConnectionState.done;
}
void main() {
final state = ConnectionState.active;
print('Is the connection terminal? ${state.isTerminal}');
}
枚举类型的扩展性提供了类似面向对象的模式,从而可以在保证类型安全的前提下,增加额外的功能。
5. 使用高级const构造函数
const构造函数允许在编译时创建不可变实例,有利于性能优化。
class ImmutableWidget {
final int id;
final String name;
const ImmutableWidget({this.id, this.name});
@override
String toString() => 'ImmutableWidget(id: $id, name: $name)';
}
void main() {
const widget1 = ImmutableWidget(id: 1, name: 'Widget 1');
const widget2 = ImmutableWidget(id: 1, name: 'Widget 1');
// 标识符相同,它们是同一个实例
print(identical(widget1, widget2)); // 输出: true
}
使用const构造函数创建的实例,由于它们是不可变的,可以被Dart VM在多个地方重用。
6. 元数据注解与反射
虽然dart:mirrors
库在Flutter中不可用,但理解元数据的使用可以为你提供设计灵感。
import 'dart:mirrors'; // 注意在非Web平台上不可用
class Route {
final String path;
const Route(this.path);
}
@Route('/login')
class LoginPage {}
void main() {
final mirror = reflectClass(LoginPage);
for (final instanceMirror in mirror.metadata) {
final annotation = instanceMirror.reflectee;
if (annotation is Route) {
print('LoginPage的路由是: ${annotation.path}');
}
}
}
通过注解,你可以给代码添加可读的元数据,并通过反射在运行时获取它们,为动态功能提供支持,虽然在Flutter中可能会借助其他方式如代码生成来实现。
7. 匿名mixin
创建匿名mixin能够在不暴露mixin到全局作用域的情况下复用代码。
class Bird {
void fly() {
print('飞翔');
}
}
class Swimmer {
void swim() {
print('游泳');
}
}
class Duck extends Bird with Swimmer {}
void main() {
final duck = Duck();
duck.fly();
duck.swim();
}
利用匿名mixin可以在不同的类中混入相同的功能而不需要创建明显的类层次结构,实现了代码的复用。
8. 高级异步编程技巧
在异步编程中,Dart提供了Future、Stream、async和await等强大的工具。
Future<String> fetchUserData() {
// 假设这是一个网络请求
return Future.delayed(Duration(seconds: 2), () => '用户数据');
}
Future<void> logInUser(String userId) async {
print('尝试登录用户...');
try {
final data = await fetchUserData();
print('登录成功: $data');
} catch (e) {
print('登录失败: $e');
}
}
void main() {
logInUser('123');
}
通过使用async
和await
,可以编写出看起来像同步代码的异步操作,使得异步代码更加简洁和易于理解。
来源:juejin.cn/post/7321526403434315811
用了这么多年的字体,你知道它是怎么解析的吗?
大家好呀。
因为之前有过字体解析的相关代码开发,一直想把这个过程记录下来,总觉得这个并不是很难,自认为了解得不算全面,就一拖再拖了。今天仅做简单的抛砖引玉,通过本篇文章,可以知道Opentype.js是如何解析字体的。在遇到对应问题的时候,可以有其它的思路去解决。比如:.ttc的解析。又或者好奇我们开发软件过程中字体是如何解析的。
Opentype.js 使用
看官方readme也可以,这里直接将github代码下载,使用自动化测试目录里的字体文件。
需要注意的是load方法已经被废弃。
function load() {
console.error('DEPRECATED! migrate to: opentype.parse(buffer, opt) See: https://github.com/opentypejs/opentype.js/issues/675');
}
将package.json
设置为type: module
,然后就可以直接使用import
了。
import { parse } from './src/opentype.mjs';
import fs from 'fs';
// test/fonts/AbrilFatface-Regular.otf
const buffer = fs.promises.readFile('./test/fonts/AbrilFatface-Regular.otf');
// if not running in async context:
buffer.then(data => {
const font = parse(data);
console.log(font.tables);
})
这样就能得到解析的结果了。
Opentype源码阅读
parseBuffer:解析的入口
通过简单的调用入口,我们可以反查源码。传入文件的ArrayBuffer并返回Font结构的对象,在不清楚会有什么结构的时候,可以通过Font查看,当然了,直接console.log查看更方便。
// Public API ///////////////////////////////////////////////////////////
/**
* Parse the OpenType file data (as an ArrayBuffer) and return a Font object.
* Throws an error if the font could not be parsed.
* @param {ArrayBuffer}
* @param {Object} opt - options for parsing
* @return {opentype.Font}
*/
function parseBuffer(buffer, opt={}) {
// ...
// should be an empty font that we'll fill with our own data.
const font = new Font({empty: true});
}
export {
// ...
parseBuffer as parse,
// ...
};
字体类型判断
接着往下阅读。
根据signature
的值,去确认字体类型。粗略看来,这里仅支持了TrueType
(.ttf)、CFF
(.otf)、WOFF
、WOFF2
。
const signature = parse.getTag(data, 0);
if (signature === String.fromCharCode(0, 1, 0, 0) || signature === 'true' || signature === 'typ1') {
} else if (signature === 'OTTO') {
} else if (signature === 'wOFF') {
} else if (signature === 'wOF2') {
} else {
throw new Error('Unsupported OpenType signature ' + signature);
}
还需要注意的是,signature
的值是的获取(后续基本都是这样婶儿获取的信息)。从指定偏移位置开始,读取4个字节的数据,并将每个字节转换为字符,最终返回一个4字符的字符串标签。
// Retrieve a 4-character tag from the DataView.
// Tags are used to identify tables.
function getTag(dataView, offset) {
let tag = '';
for (let i = offset; i < offset + 4; i += 1) {
tag += String.fromCharCode(dataView.getInt8(i));
}
return tag;
}
表入口信息获取
再看TrueType
和CFF
字体的处理,除了对font.outlinesFormat
属性的设置之外。剩余的处理方式都是:获取表的个数numTables
,再获取表的入口偏移信息。
numTables = parse.getUShort(data, 4);
tableEntries = parseOpenTypeTableEntries(data, numTables);
// Table Directory Entries //////////////////////////////////////////////
/**
* Parses OpenType table entries.
* @param {DataView}
* @param {Number}
* @return {Object[]}
*/
function parseOpenTypeTableEntries(data, numTables) {
const tableEntries = [];
let p = 12;
for (let i = 0; i < numTables; i += 1) {
const tag = parse.getTag(data, p);
const checksum = parse.getULong(data, p + 4);
const offset = parse.getULong(data, p + 8);
const length = parse.getULong(data, p + 12);
tableEntries.push({tag: tag, checksum: checksum, offset: offset, length: length, compression: false});
p += 16;
}
return tableEntries;
}
function getUShort(dataView, offset) {
return dataView.getUint16(offset, false);
}
// Retrieve an unsigned 32-bit long from the DataView.
// The value is stored in big endian.
function getULong(dataView, offset) {
return dataView.getUint32(offset, false);
}
留意到tableEntries
获取的offset是从12开始的,而获取numTables
是从4开始的,也仅仅是getUnit16
,也就是说4-12中间还会有别的信息。
表信息标准描述
这时候只能通过查看微软排版文档描述,Microsoft Typography documentation: Organization of an OpenType Font。
按照8bit计算,这些信息之后,刚好是在12个字节开始。
后续的描述就是parseOpenTypeTableEntries
的结构信息了。
表入口数据
以选择的AbrilFatface-Regular.otf 为例。我们可以打断点看看,这两步骤得到的结果,这里Opentype提供了网址,就直接在上面断点了。
这里有11个表,在入口分别有对应的名称、偏移量、长度、校验和。
表数据解析
有了表入口信息,就可以通过tableEntries
获取表的数据了。接下来的代码就是通过对应的tag(name)
去选择对应的解析方式。有些表的信息需要依赖于别的表,则先暂时存起来。比如: name表需要依赖language表。
case 'ltag':
table = uncompressTable(data, tableEntry);
ltagTable = ltag.parse(table.data, table.offset);
break;
// ...
case 'name':
nameTableEntry = tableEntry;
break;
// ...
const nameTable = uncompressTable(data, nameTableEntry);
font.tables.name = _name.parse(nameTable.data, nameTable.offset, ltagTable);
font.names = font.tables.name;
这里就简单看下ltag
表的解析,table = uncompressTable(data, tableEntry);
判断是否有压缩,比如WOFF压缩字体,这里没有entry数据就还是原来的。
ltag表的解析
function parseLtagTable(data, start) {
const p = new parse.Parser(data, start);
const tableVersion = p.parseULong();
check.argument(tableVersion === 1, 'Unsupported ltag table version.');
// The 'ltag' specification does not define any flags; skip the field.
p.skip('uLong', 1);
const numTags = p.parseULong();
const tags = [];
for (let i = 0; i < numTags; i++) {
let tag = '';
const offset = start + p.parseUShort();
const length = p.parseUShort();
for (let j = offset; j < offset + length; ++j) {
tag += String.fromCharCode(data.getInt8(j));
}
tags.push(tag);
}
return tags;
}
创了p
这个Parser
实例,包含各种长度parseShort
、parseULong
等。自动移动offset,避免每次手动传入位置。获取了table的version信息,而后就是循环的获取表内容了。找了好些个字体,都没有ltag表🤦🏻♀️
解析小结
这里我们可以初步的了解到整个字体的解析过程,就是按照约定的顺序,有个线头般一点儿一点儿的找到所需,只储存了数据。
如需获取最终字形信息,可能需要经过多个表联合查询,比如loca获取字形数据的偏移量,glyf获取字形数据,又或者camp获取字符代码对应的字形索引。
TTC字体集合的解析
回到前面提出的,ttc字体集合,应该怎么解析呢?参照文档对字体集合的处理 Font Collections,相信大家已经有办法解析了。
注意:这里截图给出的是1.0的结构,更多的查看文档。
最后
这次的分享就到这里了,对一些有按需解析,自定义解析的场景下,希望对大家有帮助。
来源:juejin.cn/post/7400072326199640100
前端身份验证终极指南:Session、JWT、SSO 和 OAuth 2.0
Hello,大家好,我是 Sunday
在前端项目开发中,验证用户身份主要有 4 种方式:Session、JWT、SSO 和 OAuth 2.0
。
那么这四种方式各有什么优缺点呢?今天,咱们就来对比下!
01:基于 Session 的经典身份验证方案
什么是基于Session的身份验证?
基于 Session 的身份验证是一种在前端和后端系统中常用的用户认证方法。
它主要依赖于服务器端创建和管理用户会话。
Session 运行的基本原理
Session 的运行流程分为 6 步:
- 用户登录:用户在登录页面输入凭据(如用户名和密码)。这些凭据通过前端发送到后端服务器进行验证。
- 创建会话:后端服务器验证凭据后,创建一个会话(session)。这个会话通常包括一个唯一的会话 ID,该 ID 被存储在服务器端的会话存储中。
- 返回会话 ID:服务器将会话 ID 返回给前端,通常是通过设置一个 cookie。这个 cookie 被发送到用户的浏览器,并在后续的请求中自动发送回服务器。
- 保存会话 ID:浏览器保存这个 cookie,并在用户每次向服务器发起请求时都会自动包含这个 cookie。这样,服务器就能识别出该用户的会话,从而实现身份验证。
- 会话验证:服务器根据会话 ID 查找和验证该用户的会话信息,并确定用户的身份。服务器可以使用会话信息来确定用户的权限和访问控制。
- 会话过期与管理:服务器可以设置会话过期时间,定期清除过期的会话。用户注销或会话超时后,服务器会删除或使会话失效。
通过以上流程,我们可以发现:基于 Session 的身份验证,前端是不需要主动参与的。核心是 浏览器 和 服务器 进行处理
优缺点
优点:
- 简单易用:对开发者而言,管理会话和验证用户身份相对简单。
- 兼容性好:大多数浏览器支持 cookie,能够自动发送和接收 cookie。
缺点:
- 扩展性差:在分布式系统中,多个服务器可能需要共享会话存储,这可能会增加复杂性。
- 必须配合 HTTPS:如果 cookie 被窃取,可能会导致会话劫持。因此需要使用 HTTPS 来保护传输过程中的安全性,并实施其他安全措施(如设置 cookie 的
HttpOnly
和Secure
属性)。
示例代码
接下来,我们通过 Express 实现一个基本的 Session 验证示例
const express = require('express');
const session = require('express-session');
const app = express();
// 配置和使用 express-session 中间件
app.use(session({
secret: 'your-secret-key', // 用于签名 Session ID cookie 的密钥,确保会话的安全
resave: false, // 是否每次请求都重新保存 Session,即使 Session 没有被修改
saveUninitialized: true, // 是否保存未初始化的 Session
cookie: {
secure: true, // 是否只通过 HTTPS 发送 cookie,设置为 true 需要 HTTPS 支持
maxAge: 24 * 60 * 60 * 1000 // 设置 cookie 的有效期,这里设置为 24 小时
}
}));
// 登录路由处理
app.post('/login', (req, res) => {
// 进行用户身份验证(这里假设用户已经通过验证)
// 用户 ID 应该从数据库或其他存储中获取
const user = { id: 123 }; // 示例用户 ID
req.session.userId = user.id; // 将用户 ID 存储到 Session 中
res.send('登录成功');
});
app.get('/dashboard', (req, res) => {
if (req.session.userId) {
// 如果 Session 中存在用户 ID,说明用户已登录
res.send('返回内容...');
} else {
// 如果 Session 中没有用户 ID,说明用户未登录
res.send('请登录...'); // 提示用户登录
}
});
app.listen(3000, () => {
console.log('服务器正在监听 3000 端口...');
});
02:基于 JWT(JSON Web Token) 的身份验证方案
什么是基于 JWT 的身份验证?
这应该是我们目前 最常用 的身份验证方式。
服务端返回 Token
表示用户身份令牌。在请求中,把 token
添加到请求头中,以验证用户信息。
因为 HTTP
请求本身是无状态的,所以这种方式也被成为是 无状态身份验证方案
JWT 运行的基本原理
- 用户登录:用户在登录页面输入凭据(如用户名和密码),这些凭据通过前端发送到后端服务器进行验证。
- 生成 JWT:后端服务器验证用户凭据后,生成一个 JWT。这个 JWT 通常包含用户的基本信息(如用户 ID)和一些元数据(如过期时间)。
- 返回 JWT:服务器将生成的 JWT 发送回前端,通常通过响应的 JSON 数据返回。
- 存储 JWT:前端将 JWT 存储在客户端(Token),通常是 localStorage 。极少数的情况下会保存在 cookie 中(但是需要注意安全风险,如:跨站脚本攻击(XSS)和跨站请求伪造(CSRF))
- 使用 JWT 进行请求:在用户进行 API 调用时,前端将 JWT(Token) 附加到请求的 Authorization 头部(格式为
Bearer
)发送到服务器。 - 验证 JWT:服务器接收到请求后,提取 JWT(Token) 并验证其有效性。验证过程包括检查签名、过期时间等。如果 JWT 合法,服务器会处理请求并返回相应的资源或数据。
- 响应请求:服务器处理请求并返回结果,前端根据需要展示或处理这些结果。
优缺点
优点
- 无状态:JWT 是自包含的,不需要在服务器端存储会话信息,简化了扩展性和负载均衡。
- 跨域支持:JWT 可以在跨域请求中使用(例如,API 与前端分离的场景)。
缺点
- 安全性:JWT 的安全性取决于密钥的保护和有效期的管理。JWT 一旦被盗用,可能会带来安全风险。
示例代码
接下来,我们通过 Express 实现一个基本的 JWT 验证示例
const express = require('express');
const jwt = require('jsonwebtoken');
const bodyParser = require('body-parser');
const app = express();
app.use(bodyParser.json());
const secretKey = 'your-secret-key'; // JWT 的密钥,用于签名和验证
// 登录路由,生成 JWT
app.post('/login', (req, res) => {
const { username, password } = req.body;
// 用户身份验证(假设验证通过)
const user = { id: 1, username: 'user' }; // 示例用户信息
const token = jwt.sign(user, secretKey, { expiresIn: '24h' }); // 生成 JWT
res.json({ token }); // 返回 JWT
});
// 受保护的路由
app.get('/dashboard', (req, res) => {
const token = req.headers['authorization']?.split(' ')[1];
if (!token) {
return res.status(401).send('没有提供令牌');
}
jwt.verify(token, secretKey, (err, decoded) => {
if (err) {
return res.status(401).send('无效的令牌');
}
res.send('返回仪表板内容');
});
});
app.listen(3000, () => {
console.log('服务器正在监听 3000 端口...');
});
03:基于 SSO 的身份验证方案
什么是基于 SSO(Single Sign-On,单点登录) 的身份验证?
SSO 身份验证多用在 “成套” 的应用程序中,通过 登录中心 的方式,可以实现 一次登录,在多个应用中均可以获取身份
SSO 运行的基本原理
- 用户访问应用:用户访问一个需要登录的应用(称为服务提供者或 SP)。
- 重定向到身份提供者:由于用户尚未登录,应用会将用户重定向到 SSO 身份提供者(Identity Provider,简称 IdP)(一般称为 登录中心)。登录中心 是负责处理用户登录和身份验证的系统。
- 用户登录:用户在 登录中心 输入凭据进行登录。如果用户已经在 IdP 处登录过(例如,已登录到公司内部的 SSO 系统),则可能直接跳过登录步骤。
- 生成 SSO 令牌:SSO 身份提供者验证用户身份后,生成一个 SSO 令牌(如 OAuth 令牌或 SAML 断言),并将用户重定向回原应用,同时附带令牌。
- 令牌验证:原应用(服务提供者)接收到令牌后,会将其发送到 SSO 身份提供者进行验证。SSO 身份提供者返回用户的身份信息。
- 用户访问应用:一旦身份验证成功,原应用会根据用户的身份信息提供访问权限。用户现在可以访问应用中的受保护资源,而无需再次登录。
- 访问其他应用:如果用户访问其他应用,这些应用会重定向用户到相同的 登录中心 进行身份验证。由于用户已经登录,登录中心 会自动验证并将用户重定向回目标应用,从而实现无缝登录。
优缺点
优点
- 简化用户体验:用户只需登录一次,即可访问多个应用或系统,减少了重复登录的麻烦。
- 集中管理:管理员可以集中管理用户的身份和访问权限,提高了管理效率和安全性。
- 提高安全性:减少了密码泄露的风险,因为用户只需记住一个密码,并且可以使用更强的认证机制(如多因素认证)。
缺点
- 单点故障:如果 登录中心 出现问题,可能会影响所有依赖该 SSO 服务的应用。
- 复杂性:SSO 解决方案的部署和维护可能较为复杂,需要确保安全配置和互操作性。
常见的 SSO 实现技术
- SAML(Security Assertion Markup Language):
- 一个 XML-based 标准,用于在身份提供者和服务提供者之间传递认证和授权数据。
- 常用于企业环境中的 SSO 实现。
- OAuth 2.0 和 OpenID Connect:
- OAuth 2.0 是一种授权框架,用于授权第三方访问用户资源。
- OpenID Connect 是建立在 OAuth 2.0 之上的身份层,提供用户身份认证功能。
- 常用于 Web 和移动应用中的 SSO 实现。
- CAS(Central Authentication Service):
- 一个用于 Web 应用的开源 SSO 解决方案,允许用户通过一次登录访问多个 Web 应用。
04:基于 OAuth 2.0 的身份验证方案
什么是基于 OAuth 2.0 的身份验证?
基于 OAuth 2.0 的身份验证是一种用于授权第三方应用访问用户资源的标准协议。常见的有:微信登录、QQ 登录、APP 扫码登录等
OAuth 2.0 主要用于授权,而不是身份验证,但通常与身份验证结合使用来实现用户登录功能。
OAuth 2.0 运行的基本原理
OAuth 2.0 比较复杂,在了解它的原理之前,我们需要先明确一些基本概念。
OAuth 2.0 的基本概念
- 资源拥有者(Resource Owner):通常是用户,拥有需要保护的资源(如个人信息、文件等)。
- 资源服务器(Resource Server):提供资源的服务器,需要保护这些资源免受未经授权的访问。
- 客户端(Client):需要访问资源的应用程序或服务。客户端需要获得资源拥有者的授权才能访问资源。
- 授权服务器(Authorization Server):责认证资源拥有者并授权客户端访问资源。它颁发访问令牌(Access Token)给客户端,允许客户端访问资源服务器上的受保护资源。
运行原理
- 用户授权:用户使用客户端应用进行操作时,客户端会请求授权访问用户的资源。用户会被重定向到授权服务器进行授权。
- 获取授权码(Authorization Code):如果用户同意授权,授权服务器会生成一个授权码,并将其发送回客户端(通过重定向 URL)。
- 获取访问令牌(Access Token):客户端使用授权码向授权服务器请求访问令牌。授权服务器验证授权码,并返回访问令牌。
- 访问资源:客户端使用访问令牌向资源服务器请求访问受保护的资源。资源服务器验证访问令牌,并返回请求的资源。
常见的授权流程
- 授权码流程(Authorization Code Flow):最常用的授权流程,适用于需要与用户交互的客户端(如 Web 应用)。用户在授权服务器上登录并授权,客户端获取授权码后再交换访问令牌。
- 隐式流程(Implicit Flow):适用于公共客户端(如单页应用)。用户直接获得访问令牌,适用于不需要安全存储的情况,但不推荐用于高度安全的应用。
- 资源所有者密码凭据流程(Resource Owner Password Credentials Flow):适用于信任客户端的情况。用户直接将用户名和密码提供给客户端,客户端直接获得访问令牌。这种流程不推荐用于公开的客户端。
- 客户端凭据流程(Client Credentials Flow):适用于机器对机器的情况。客户端直接向授权服务器请求访问令牌,用于访问与客户端本身相关的资源。
优缺点
优点
- 灵活性:OAuth 2.0 支持多种授权流程,适应不同类型的客户端和应用场景。
- 安全性:通过分离授权和认证,增强了系统的安全性。使用令牌而不是用户名密码来访问资源。
缺点
- 复杂性:OAuth 2.0 的实现和配置可能较复杂,需要正确管理访问令牌和刷新令牌。
- 安全风险:如果令牌泄露,可能会导致安全风险。因此需要采取适当的安全措施(如使用 HTTPS 和适当的令牌管理策略)。
示例代码
接下来,我们通过 Express 实现一个基本的 OAuth 2.0 验证示例
const express = require('express');
const axios = require('axios');
const app = express();
// OAuth 2.0 配置
const clientId = 'your-client-id';
const clientSecret = 'your-client-secret';
const redirectUri = 'http://localhost:3000/callback';
const authorizationServerUrl = 'https://authorization-server.com';
const resourceServerUrl = 'https://resource-server.com';
// 登录路由,重定向到授权服务器
app.get('/login', (req, res) => {
const authUrl = `${authorizationServerUrl}/authorize?response_type=code&client_id=${clientId}&redirect_uri=${redirectUri}&scope=read`;
res.redirect(authUrl);
});
// 授权回调路由,处理授权码
app.get('/callback', async (req, res) => {
const { code } = req.query;
if (!code) {
return res.status(400).send('Authorization code is missing');
}
try {
// 请求访问令牌
const response = await axios.post(`${authorizationServerUrl}/token`, {
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
client_id: clientId,
client_secret: clientSecret
});
const { access_token } = response.data;
// 使用访问令牌访问资源
const resourceResponse = await axios.get(`${resourceServerUrl}/user-info`, {
headers: { Authorization: `Bearer ${access_token}` }
});
res.json(resourceResponse.data);
} catch (error) {
res.status(500).send('Error during token exchange or resource access');
}
});
app.listen(3000, () => {
console.log('服务器正在监听 3000 端口...');
});
总结一下
目前这四种验证方案均有对应的 优缺点、应用场景:
- Session:非常适合简单的服务器呈现的应用程序
- JWT:适用于现代无状态架构和移动应用
- SSO:非常适合具有多种相关服务的企业环境
- OAuth 2.0:第三方集成和 API 访问的首选
来源:juejin.cn/post/7399986979736322063
京东企业业务前端监控实践
作者:零售企业业务苏子刚
监控的背景和意义
在现代前端开发中,接入监控系统是一个很重要的环节,它可以帮助开发者、产品、运营了解应用的性能表现,用户的实际体验以及潜在的错误和问题,从而进一步优化用户体验,帮助产品升级迭代。
背景
•应用复杂性增加:随着单页应用(SPA)和渐进式网页应用(PWA)的流行,前端应用变得越来越复杂。
•网页加载性能要求提高:用户对网页加载速度和交互响应的要求越来越高,性能成为影响用户体验的关键因素。
•多样化的设备和网络环境:用户通过各种设备和网络环境访问应用,这些因素都可能影响应用的性能和用户体验。
•敏捷开发和持续部署:敏捷开发和 CI/CD 的实践要求开发团队能够快速的响应问题,并持续改进产品。
意义
•对应用性能监控,提升用户体验:监控页面的加载时间,交互时间等性能指标,帮助团队优化代码,提升用户体验。
•错误追踪,快速定位并解决问题:捕获前端错误和异常,快速定位问题源头,缩短故障的修复时间。
•用户行为分析,指导产品快速迭代:了解用户如何与应用互动,哪些功能用户喜欢,哪些路径导致转化,从而指导产品迭代升级。
•业务指标监控,保证主流程的稳定性:监控关键业务流程,如:购物车、商详、结算页等黄流,确保业务流程的稳定性。
•告警系统,异常情况能够快速响应:通过定时任务自动化巡检/实时监控,当出现性能异常时,能够快速响应。
监控的类别
从上面可看出,通过监控能够促使我们的系统更加健壮、稳定,体验更好。那我们团队也从去年开始逐步将各个应用接入了监控。到目前为止,我们的监控分为两个部分:
•实时监控:成功集成了 SGM 集团内部监控平台,并引入了相应的 SDK 来对前端应用进行实时监控。目前,我们所涉及的 100+ 个应用程序已经完全接入该系统。通过配置有效的告警机制,我们能够及时捕捉并上报线上环境中的错误和异常,确保业务的稳定运行。
•定时任务巡检:实现了定时任务的自动化设置,用于定期执行自动巡检,并自动上报检测结果,还能激活告警系统。通过这种方式,我们能够确保持续监控系统健康状况,并在发现潜在问题时能够迅速响应。
◦使用 Chrome 插件快速创建 UI 测试脚本,随后在 UI啄木鸟平台 上配置定时执行这些脚本进行系统巡检。这一流程实现了自动化的界面测试,确保应用的用户界面按预期工作,同时及时发现并解决潜在的 UI 问题;
◦自主研发一套脚本,这些脚本通过启动一个 Node.js 服务来进行系统巡检。这种方法使我们能够灵活地监控系统性能和功能,及时发现并处理潜在的问题,从而保障系统的稳定运行;
监控整体架构
监控建设实践
实时监控
通过整合 SGM 监控平台和告警系统,我们实现了对所有接入应用的实时监控。一旦检测到异常,系统会立即通过多种方式(如咚咚、邮件、电话等)通知相关团队成员,确保问题能够被迅速发现和解决。为了提高告警的有效性,我们精心设计了告警策略,确保只有真正的异常情况才会触发告警。以下是我们在优化告警设置过程中积累的一些关键经验和建议:
1.精确度与敏感度的平衡:过于敏感的告警会导致频繁的误报,而设置过于宽松则可能错过关键的异常。找到合适的平衡点至关重要。
2.分级告警机制:根据问题的严重程度设置不同级别的告警,以便采取相应的响应措施。紧急问题可以通过电话直接通知,而较为轻微的问题可以通过邮件或即时消息通知。
3.持续优化告警规则:定期回顾和分析告警的准确性和响应情况,根据实际情况调整告警规则,以提高告警的准确性和有效性。
4.明确责任分配:确保每个告警都有明确的责任人,这样一旦发生异常,能够迅速有人响应。
5.培训和意识提升:对团队成员进行定期的培训,提高他们对告警系统的理解和重视,确保每个人都能正确响应告警。
这些经验和建议是我们在实践中不断摸索和尝试的结果,希望能够帮助你们更有效地管理和响应系统告警,确保应用的稳定运行。
WEB端
用户体验
用户体验是指用户在使用网站过程中的整体感受、情绪和态度,它包括与产品交互的全程。对于开发者而言,打造出色的用户体验意味着关注网站的加载速度、视觉稳定性、交互响应时间、渲染性能等关键因素。Google 提出了一系列 Web 性能标准指标,这些指标旨在量化用户体验的不同层面。SGM 性能监控平台紧跟这些标准,对几项关键指标进行监控并提供反馈,以确保用户能够获得流畅且愉悦的网站使用体验。
•LCP:页面加载过程中最大的内容元素(图片、视频等)渲染完成的时间点。也可近似看作是首屏加载时间。Google 标准是 小于等于 2500ms。
最初,我们遵循了 Google 提出的标准来精确设定我们的告警系统。
配置告警后,我们注意到告警频率较高,主要是因为多数系统的实际网页最大内容绘制(LCP)值普遍超过了预期的 2.5s 标准。然而,这些告警并未实质性影响用户的正常使用体验。为了优化告警机制,我们经过仔细评估后,决定将部分系统的 LCP 阈值暂时调整至 5s,并相应调整了告警级别,以更合理地反映系统性能对用户体验的实际影响。
显然,当前调整的 LCP 阈值 5s 并非我们的最终目标。这一调整是基于应用当前的性能状况所做的临时措施,旨在优化告警的频率和质量。我们计划随着对各个应用性能的持续改进,最终将 LCP 阈值恢复至标准的 2.5s。
•CLS:从页面开始加载到生命周期状态更改为隐藏期间发生的所有意外布局偏移的累计得分。Web 给出的性能指标是 < 0.1
•FCP:从网页开始加载到有内容渲染的耗时。标准性能指标 1.8s
•FID:用户首次发出交互指令到页面可以响应为止的时间差。标准性能指标 100ms
•TTFB:客户端接收到服务器返回的第一个字节响应信息的耗时。标准性能指标 1000ms
从最初启用告警到目前决定关闭大部分告警,我们的决策基于以下考虑:
1.用户体验未受显著影响:即使某些性能指标超出了标准值,我们观察到这并未对用户操作网站造成实质性影响。
2.避免告警疲劳:频繁的告警可能导致开发团队对通知产生麻木感,从而忽视那些真正关键的、影响网站健康的告警。
3.指标作为优化参考:这些性能指标更多地被视为优化网站时的参考点,而对用户的直觉体验影响甚微。
4.有针对性的优化:在网站优化过程中,我们会将这些指标作为健康检查的一部分,进行针对性的改进,而无需依赖告警来频繁提醒。
基于这些理由,我们目前选择只对 LCP 指标保持告警启用,以确保关注到可能影响用户加载体验的关键性能问题。
健康度指标 | 告警开启 | 标准值 | 优化后值 | 告警阀值 | 告警级别 |
---|---|---|---|---|---|
LCP | 开启 | <=2500ms | 针对部分应用 <=5000ms | 1min 内 耗时>=5000ms(持续优化并更新阀值到2500ms) 调用次数:50 连续次数:1次 | 通知 |
FCP | 关闭 | <=1800ms | - | | - |
CLS | 关闭 | <=0.1 | - | | - |
FID | 关闭 | <=100ms | - | | - |
TTFB | 关闭 | <=1000ms | - | | - |
页面性能
网页性能涉及从加载开始到完全可交互的整个过程,包括页面内容的下载、解析和渲染等多个阶段。在 SGM 监控平台上,我们能够追踪到一系列与页面加载性能相关的指标,如页面加载总时长、DNS 解析时长、DOM 加载完成时间、用户的浏览器和地理分布、首次渲染(白屏)时间、用户行为追踪、性能重现和热力图分析等。这些丰富的数据为我们优化页面性能提供了宝贵的参考。特别是首次渲染时间,或称为白屏时间,是我们监控中特别关注的一个关键指标,因为它直接影响用户的首次印象和整体体验。
•白屏时间:这一指标能够向开发者展示哪些页面出现了白屏现象。在利用此指标之前,我们需要为每个应用单独配置白屏时间的监控参数,以确保准确地捕捉到首次内容呈现的时刻。这有助于我们识别并优化那些影响用户首次加载体验的关键页面。
为了监控白屏时间,我们必须在应用的全局配置中的白屏监控项下,指定每个页面的 URL 及其关键元素。同时,我们还需设定监控的起始时间点和超时阈值。值得注意的是,URL 配置支持正则表达式,这为我们提供了灵活性,以匹配和监控一系列相似的页面路径。
•白屏检测的机制:白屏检测机制的核心在于验证页面上的关键元素是否已经被渲染。所谓关键元素,指的是在配置过程中指定的用于检测的 DOM 元素。这些元素的渲染情况是判断页面是否白屏的依据。
•告警设置:白屏告警功能已经启用,但目前还处于初期阶段,一些功能尚待完善。例如,当前的 URL 配置尚未支持正则表达式匹配。
面对当前的局限性,我们采取了双策略应对。一方面,我们利用白屏告警功能直接监控页面的白屏情况;另一方面,我们通过分析页面加载性能指标中的白屏时间来间接监测潜在的白屏问题。在设置平均耗时时间和告警级别时,我们综合考虑了多个因素,包括用户的网络环境、告警的发生频率以及告警的实际适用性,以确保监控方案的有效性和合理性。
网页性能 | 告警开启 | 优化前值 | 优化后值 | 告警阀值 | 告警级别 |
---|---|---|---|---|---|
首包时间 | 关闭 | - | - | - | - |
页面完全加载时间 | 关闭 | - | - | - | - |
白屏时间 | 开启 | <=5000ms | <=10000ms | 1min 内 耗时>=10000ms,后期采用白屏告警替代 调用次数:30 连续次数:5 次 | 紧急 |
•此外,我们的页面追踪功能包括用户行为回溯、页面性能重现以及行为轨迹热力图,这些工具允许我们从多个维度和场景对用户行为进行深入分析,极大地便利了问题的诊断和排查。
JSError监控
我们通过配置错误关键词来匹配控制台的报错信息。报错阈值的设定可以参考各个项目的 QPS,因为在项目实施了恰当的降级策略后,即便控制台出现报错,页面通常仍能正常访问,不会对用户体验造成影响。因此,这个阈值可以适当设置得较高。
这里需要特别注意 “Script error” 的错误,这种错误给不到任何对我们有用的信息。所以需要采用一定的手段避免出现类似的报错:
•这种错误也称之为跨域错误,所以首先,我们需要开启跨域资源共享功能(CORS)。
<script src="http://xxxdomain.com/home.js" crossorigin></script>
•针对 Vue 项目,由于 Vue 重写了 window.onerror 事件,所以我们需要在 Vue 项目中增加 错误处理:
Vue.config.errorHandler = (err, vm, info)=> {
if (err) {
try {
console.error(err);
window.__sgm__.error(err)
} catch (e) {}
}
};
•在某些情况下,“Script error” 可能是无关紧要的,我们可以选择忽略这类特定的错误。为此,可以关闭这些特定错误的监控,具体的错误可以通过它们的 hash 在错误日志中进行识别和过滤。
关键指标 | 关键字(支持正则匹配) | 触发次数 | 告警级别 |
---|---|---|---|
js错误 | null、undefined、error、map、filter、style、length... | 周期:1min ,错误次数:50/100/200(可参考 QPS 值设置),连续次数:1次 | 严重 |
API请求监控
这里我们的告警设置主要关注 HTTP 状态码和业务错误码。这两项指标的异常表明我们的应用可能遇到了问题,需要我们迅速进行检查和处理以确保系统的正常运行。
首先,我们必须在应用的监控配置中设定数据采集参数:
关键指标 | 错误码 | 业务域名 | 触发次数 | 告警级别 |
---|---|---|---|---|
http错误 | !200(Http响应非200报警) | xx1.jd.com xx2.jd.com | 周期:1min 错误次数:1 总调用次数:50 连续次数:1 | 严重 |
业务失败码 | errCode(根据实际业务线设置 -1,-2等) |
针对业务失败码:
1.由于现有应用跨不同业务条线存在异常码的差异,我们需要针对每个业务线收集并配置其特定的异常码。
应用来源 | 标准响应 | 针对性告警 |
---|---|---|
慧采PC 企业购 | { "code":null, "success":true, "msg":"操作成功", "result":{} } | 业务异常码 code ·-1,-2 ·... |
锦礼 | { "code": 000, "data": {}, "msg": "操作成功" } | 业务异常码 code ·! (1000 && 3001等 ) |
color | | ·-1 echo ·1 echo |
其他 | ... | .... |
2、对于新增应用,我们实施了后端服务异常码的标准化。因此,在监控方面,我们只需要配置一套统一的标准来进行监控。
{
"50000X": "程序异常,内部",
"500001": "程序异常,上游",
"500002": "程序异常,xx",
"500003": "程序异常,xx",
...
}
资源错误
这里通常指的是 css、js、图片等资源的加载错误
关键指标 | 告警开启 | 告警阀值 | 告警级别 |
---|---|---|---|
资源错误 | 开启 | 周期:1min 错误次数:200(也可参照QPS进行设置) 连续次数:1 | 严重 |
对于图片加载错误,只需在项目中实施适当的降级方案。在应用的监控配置中,我们可以设置为不收集图片错误相关的数据。
再来举个例子:
在企业业务的封闭场景中,例如慧采平台,我们集成了埋点 JavaScript 脚本。然而,由于某些客户的网络环境,导致我们的埋点相关静态资源延迟加载。
治理方案:
自定义上报
每个业务流程的关键节点或核心功能实施了专门的监控措施,以便对任何异常状况进行跟踪、监控并及时上报。
目前,几条业务线已经实施了自定义上报机制,其主要目的包括:
•利用自定义上报来捕获接口异常的详细信息,如入参和出参,以便在线上出现异常时,能够依据上报的数据快速进行问题的诊断和定位。
•在复杂的环境下,准确追踪用户行为导致的错误,并利用上报的信息进行有效的问题排查和定位。
锦礼酷兜: 用户在选择地址后,系统未能根据所选地址提供正确的信息,导致页面加载出现异常。
由于这个 H5 页面被嵌入到用户的 App 内,开发者难以直接复现用户遇到的问题。因此,我们利用监控平台的自定义上报功能来收集相关信息,以辅助进行问题排查。
•上报地址组件返回值
•上报接口入参
•然后根据自定义上报日志查看具体信息
E卡:外部引用资源异常上报(设备指纹,eid 等)
在结算页面提交订单时,系统需要获取设备ID。为此,我们实施了降级方案,并通过自定义上报机制对此过程进行监控,以确保流程的顺利执行。
降级方案:如果获取不到,后端会生成 uuid 给到前端。
企业购注销pc: 我们需要集成科技 SDK,以便在页面上完成用户注销后自动跳转到指定的 URL。上线后,收到客户反馈指出在完成注销流程后页面未能正确跳转。
接口异常: 在接口异常中,添加自定义监控,查看入参和出参信息。
尽管我们已经在应用中引入了自定义监控以便更好地观察和定位问题,但我们仍需进一步细化和规范化这些监控措施。目前,我们正积极对各业务线的功能点进行梳理,以实现更深入的细化监控。我们的目标是为每个业务线、每个应用的关键链路和功能点定制针对各种异常情况的精细化自定义监控(例如,某页面按钮的显示或点击异常)。
自定义告警 | 告警开启 | 告警阀值 | 告警级别 |
---|---|---|---|
自定义编码 | 开启 | 1min内 调用次数:50 连续次数:1次 | 警告 |
小程序端
与 Web 端相比,小程序的监控存在一些差异,主要是缺少了如 LCP(最大内容绘制)等特定性能指标。然而,性能问题、JavaScript错误、资源加载错误等其他监控指标仍然可以被捕获。此外,小程序官方和开发者工具都提供了性能检测工具,这些工具便于开发者查看和优化小程序应用的性能。本文将不深入介绍 Web 端的监控指标,而是专注于介绍小程序中独有的监控和分析工具。
小程序官方后台
可以分析接口数据、js 分析等。
利用 SGM 的监控功能结合小程序官方的分析工具,对我们的小程序进行综合优化,是一个有效的策略。
原生应用
基础监控:mPaas、烛龙、SGM
mPaaS:崩溃监控。应用崩溃对用户体验有显著影响,是移动端监控中的一个关键指标。通过不断的监控和优化,京东慧采移动端的崩溃率已经降至较低水平,目前的平均用户崩溃率大约为0.03122%。
烛龙:启动耗时、首屏耗时、启动且首焦耗时、卡顿。为了改善首屏加载时间,京东慧采采用了烛龙监控平台并实施了相应的优化措施。优化后,应用的整体性能显著提升,其中 Android 平台的tp95耗时大约为 2764ms,iOS 平台的tp95耗时大约为1791ms,均达到了较低的水平。
SGM:网络、WebView、原生页面等指标。
业务监控
京东慧采在多个业务模块中,其中登录、商详、订单详情 3 个模块接入了业务监控。
登录:登录接入 SGM 监控平台,自定义了整个流程错误码,并配置了告警规则
(1)600:登录正常流程(必要是可白名单开启)
(2)601:登录防刷验证流程异常监控
(3)602:登录魔方验证流程异常监控
(4)603:登录流程异常监控
商详、订单详情:在商品详情和订单详情页面,我们集成了业务监控 SDK。通过移动配置平台,我们下发了监控规则,以便在接口返回的数据不符合预期时,能够上报错误信息。目前,这些监控信息被上报到崩溃分析平台的自定义异常模块中,以便进行进一步的分析和优化。
(1)接口请求是否成功。
(2)banner 楼层:是否为空、楼层类型是否正常(1 原生)、数据/大小图地址是否为空。
(3)商品信息楼层:是否为空、楼层类型是否正常(2 动态)、数据/商品名称/价格是否为空。
(4)服务楼层:是否为空、楼层类型是否正常(1 原生)、数据/服务信息是否为空。
(5)spu 和物流楼层:是否为空、楼层类型是否正常(1 原生)、数据/sup信息是否为空。
(6)其他:其他/按钮数据/按钮名称是否为空、按钮类型是否正常(排除1/2/3/4/5/6/20000)。
订单详情监控信息:
(1)接口请求是否成功。
(2)基础信息楼层:是否为空、楼层类型是否正常(2 动态)、数据/地址信息是否为空。
(3)商品信息楼层:是否为空、楼层类型是否正常(2 动态)、数据/商品列表是否为空。
(4)支付信息楼层:是否为空、楼层类型是否正常(2 动态)、数据是否为空。
(5)价格楼层:是否为空、楼层类型是否正常(2 动态)、数据是否为空。
定时巡检
定时巡检可以通过两种方法实现:
•利用 UI 啄木鸟平台配置定时执行的任务。
•使用团队开发的自定义脚本,并通过自动启动的服务来执行这些检测任务。
UI 啄木鸟
定时巡检的主要目的是确保每个项目的核心流程在每次迭代中保持稳定。通过配置定时任务,我们能够及时发现并解决线上问题,从而维护系统的稳定性。
什么是 UI 啄木鸟
UI啄木鸟平台,由京东集团-京东科技开发,是一个自动化巡检工具,其主要功能包括:
•Chrome插件:用于录制项目的用户交互步骤。
•定时任务平台:用于在服务器端定期执行已录制的脚本进行巡检。
在使用Chrome插件过程中,我们遇到了一些问题。与京东科技团队沟通后,我们获得了共同开发和升级插件的机会。目前,我们已经添加了新功能并修复了一些已知问题。
1.打开录制和停止录制按钮,分别开启监听和关闭拦截页面事件功能;
2.新增点击录制后保留当前录制步骤功能;
3.在执行事件过程中,禁止再次点击执行,避免执行顺序错乱;
4.点击事件可切换操作类型为 focus 事件,便于监听滚动条滑动;
5.在步骤复现情况下,调整判断元素选择器和屏幕位置的顺序,避免位置出入点击位置错位;
6....
使用chrome扩展程序进行安装即可。
怎么使用
•新建录制脚本
•点击详情,开启录制
•监听步骤
•关联到啄木鸟平台
•啄木鸟平台(调试、配置 Cookie,开启定时任务)
自启动巡检工具
自动化巡检工具能够检测页面上的多种元素和链接,包括 a 标签的外链、接口返回的链接、鼠标悬停元素、点击元素,以及跳转后 URL 的有效性。该工具尤其适合用于频道页,这些页面通常通过投放广告和配置通天塔链接来生成。在大型促销活动期间,我们可以运行脚本来验证广告和通天塔链接的有效性。目前,工具的功能还相对有限,并且对于广告组等特定接口的支持不够通用,这也是我们计划逐步改进和优化的方向。
功能检测
•检测所有 a 标签和所有接口响应数据中包含所有通天塔活动外链是否有效。
{
"cookieThor":"", // 是否依赖cookie等登录态,无需请传空
"urlPattern": "pro\.jd\.com",// 匹配的链接,这里只匹配通天塔
"urls": ["https://b.jd.com/s?entry=newuser"] // 将要检测的url,可填多个
}
运行结果:
•检测鼠标 hover 事件,收集用户交互后的接口响应数据,检测所有活动外联是否有效。
{
"cookieThor": "",
"urlPattern": "///pro.jd.com/",
"urls": [
{
"url": "https://b.jd.com/s?entry=newuser",
"hoverElements": [
{
"item": "#focus-category-id .focus-category-item", // css样式选择器
"target": ".focus-category-item-subtitle"
}
]
}
]
}
•检查 click 事件,收集用户交互后的接口响应数据,检测所有通天塔活动外联是否有效。
{
"cookieThor": "",
"urlPattern": "///pro.jd.com/",
"urls": [
{
"url": "https://b.jd.com/s?entry=newuser",
"clickElements": [
{
"item": "#recommendation-floor .drip-tabs-tab"
}
]
}
]
}
•检测点击后,跳转后的链接有效性。
{
"cookieThor": "",
"urlPattern": "///pro.jd.com/",
"urls": [
{
"url": "https://b.jd.com/s?entry=newuser",
"clickElements": [
{
"item": ".recommendation-product-wrapper .jdb-sku-wrapper"
}
]
}
]
}
检测原理
监控后暴露的问题
•慧采 jsError:
用于埋点数据上报的API,但在一些封闭环境中,由于网络环境,客户可能无法及时访问埋点或指纹识别等 SDK,导致频繁的报错。为了解决这个问题,我们可以通过引入 try...catch 语句来捕获异常,并结合使用队列机制,以确保埋点数据能够在网络条件允许时正常上报。这样既避免了错误的频繁发生,也保障了数据上报的完整性和准确性。
public exposure(exposureId: ExposureId, jsonParam = {}, eventParam = '') {
this.execute(() => {
try {
const exposure = new MPing.inputs.Exposure(exposureId);
exposure.eventParam = eventParam; // 设置click事件参数
exposure.jsonParam = serialize(jsonParam); // 设置json格式事件参数,必须是合法的json字符串
console.log('上报 曝光 info >> ', exposure);
new MPing().send(exposure);
} catch (e) {
console.error(e);
}
});
}
private execute(fn: CallbackFunction) {
if (this._MPing === null) {
this._Queue.push(fn);
} else {
fn();
}
}
•企业购订详异常:
总结
接入前
在过去,我们的应用上线后,对线上运行状况了解不足,错误发生时往往依赖于用户或运营团队的反馈,这使我们常常处于被动应对状态,有时甚至会导致严重的线上问题,如白屏、设备兼容性问题和异常报错等。为了改变这一局面,我们开始寻找和实施解决方案。从去年开始,我们逐步将所有 100+ 应用接入了监控系统,现在我们能够实时监控应用状态,及时发现并解决问题,大大提高了我们的响应速度和服务质量。
接入后
自从接入监控系统后,我们的问题发现和处理方式从被动等待用户反馈转变为了主动监测。现在我们能够即时发现并快速解决问题。此外,我们还利用监控平台对若干应用进行了性能优化,并为关键功能点制定了预先的降级策略,以保障应用的稳定运行。
•页面健康度监控表明,我们目前已有 50+ 个项目的性能评分达到或超过 85 分。通过分析监控数据,我们对每个应用的各个页面进行了详细分析和梳理。在确保流程准确无误的基础上,我们对那些性能较差的页面进行了持续的优化。目前,我们仍在对一些关键但性能表现一般的应用进行进一步的优化工作。
•通过配置 JavaScript 错误和资源错误的告警机制,我们在项目迭代过程中及时解决了多个 JavaScript 问题,有效降低了错误率和告警频率。正如前文所述,对于慧采PC 这类封闭环境,客户公司的网络策略可能会阻止埋点 SDK 和设备指纹等 JavaScript 资源的加载,导致全局变量获取时出现错误。我们通过实施异常捕获和队列机制,不仅规避了部分错误,还确保了埋点数据的准确上报。
•通过收集 HTTP 错误码和业务失败码,并设置相应的告警机制,我们能够在接到告警通知的第一时间内分析并解决问题。例如,我们成功地解决了集团内部遇到的 “color404” 问题等。这种做法加快了问题的响应和解决速度,提高了服务的稳定性和用户满意度。
•自定义监控:通过给接口异常做入参、出参的上报、在关键功能点上报有效信息等,能够在应用出现异常时,快速定位问题并及时修复。
•通过实施定时任务巡检,我们有效地避免了迭代更新上线可能对整个流程造成的影响。同时,巡检工具的使用也确保了外部链接的有效性,进一步保障了应用的稳定运行和用户体验。
规划
监控是一个持续长期的过程,我们致力于不断完善,确保系统的稳定性和安全性。基于现有的监控能力,我们计划实施以下几项优化措施:
1.应用性能提升:我们将持续优化我们的应用,目标是让 90% 以上的应用性能评分达到 90 分以上,并对资源错误和 JavaScript 错误进行有效管理。
2.深化监控细节:我们将扩展监控的深度和广度,确保能够捕捉到所有潜在的异常情况。例如,如果一个仅限采购账号使用的按钮错误地显示了,这应该触发自定义异常上报,并在代码层面实施降级处理。
3.巡检工具升级:我们将继续升级我们的 Chrome 巡检插件,提高其智能化程度和覆盖范围,以保持线上主要流程的健壮性和稳定性。
结尾
我们是企业业务大前端团队,会持续针对各端优化升级我们的监控策略,如果您有任何疑问或者有更好的建议,我们非常欢迎您的咨询和交流。
来源:juejin.cn/post/7400271712359186484
💥图片碎片化展示-Javascript
写在开头
哈喽吖!各位好!😁
今天刚好是周四呢,疯狂星期四快整起来。🍔🍟🍗
最近,小编从玩了两年多的游戏中退游了😔,本来以为会一直就这么玩下去,和队友们相处很融洽,收获了很多开心快乐的时光😭。可惜,游戏的一波更新......准备要开始收割韭菜了,只能无奈选择弃坑了。

小编属于贫民玩家,靠着硬肝与白嫖也将游戏号整得还不错,这两天把号给卖了💰。玩了两年多,竟然还能赚一点小钱,很开心😛。只是...多少有点舍不得的一起组队的队友们,唉。😔
记录一下,希望未来还有重逢一日吧,也希望各位一切安好!😆
好,回到正题,本文将分享一个图片碎片化展示的效果,具体效果如下,请诸君按需食用。

原理
这种特效早在几年前就已经出现,属于老演员了😪,它最早是经常在轮播图(banner)上应用的,那会追求各种花里胡哨的特效,而现在感觉有点返璞归真了,简洁实用就行。
今天咱们来看看它的具体实现原理是如何的,且看图:

一图胜千言,不知道聪明的你是否看明白了?😉
大概原理是:通过容器/图片大小生成一定数量的小块,然后每个小块背景也使用相同图片,再使用 background-size
与 background-position
属性调整背景图片的大小与位置,使小块又合成一整张大图片,这操作和使用"精灵图"的操作是一样的,最后,我们再给每个小块增加动画效果,就大功告成。
简单朴实😁,你可以根据这个原理自个尝试一下,应该能整出来吧。👻
具体实现
布局与样式:
<!DOCTYPE html>
<html>
<head>
<style>
body{
width: 100%;
height: 100vh;
padding: 0;
margin: 0;
display: flex;
justify-content: center;
align-items: center;
}
.box {
width: var(--width);
height: var(--height);
display: flex;
/* 小块自动换行排列 */
flex-wrap: wrap;
justify-content: center;
}
.small-box {
background-image: url('https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/99b070fcb1de471d9af4f4d5d3f71909~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1120&h=1680&s=2088096&e=png&b=d098d0');
box-sizing: border-box;
background-repeat: no-repeat;
}
</style>
</head>
<body>
<div id="box" class="box"></div>
</body>
</html>
生成无数小块填充:
<script>
document.addEventListener('DOMContentLoaded', () => {
const box = document.getElementById('box');
const { width, height } = box.getBoundingClientRect();
// 定义多少个小块,由多少行和列决定
const row = 14;
const col = 10;
// 计算小块的宽高
const smallBoxWidth = width / col;
const smallBoxHeight = height / row;
/** @name 创建小块 **/
function createSmallBox() {
for (let i = 0; i < row; i++) {
for (let j = 0; j < col; j++) {
const smallBox = document.createElement('div');
smallBox.classList.add('small-box');
smallBox.style.width = smallBoxWidth + 'px';
smallBox.style.height = smallBoxHeight + 'px';
smallBox.style.border = '1px solid red';
// 插入小块
box.appendChild(smallBox);
}
}
}
createSmallBox();
});
</script>
上面,生成多少个小块是由人为规定行(row
)与列(col
)来决定。可能有的场景想用小块固定的宽高来决定个数,这也是可以的,只是需要注意处理一下"边界"的情况。😶

调整小块背景图片的大小与位置:
<script>
document.addEventListener('DOMContentLoaded', () => {
// ...
function createSmallBox() {
for (let i = 0; i < row; i++) {
for (let j = 0; j < col; j++) {
// ...
smallBox.style.border = '1px solid red';
// 设置背景偏移量,让小块的背景显示对应图片的位置,和以前那种精灵图一样
const offsetX = j * smallBoxWidth * -1;
const offsetY = i * smallBoxHeight * -1;
smallBox.style.backgroundPosition = `${offsetX}px ${offsetY}px`;
smallBox.style.backgroundSize = `${width}px ${height}px`;
box.appendChild(smallBox);
}
}
}
createSmallBox();
});
</script>
女神拼接成功,到这里就已经完成一大步了,是不是没什么难度!😋

小块样式整好后,接下来,我们需要来给小块增加动画,让它们动起来,并且是有规律的动起来。
先来整个简单的透明度动画,且看:
<!DOCTYPE html>
<html>
<head>
<style>
/* ... */
.small-box {
/* ... */
opacity: 0;
animation: smallBoxAnimate 2000ms linear forwards;
}
@keyframes smallBoxAnimate {
0% {
opacity: 0;
}
40% {
opacity: 0;
}
70% {
opacity: 1;
}
100% {
opacity: 1;
}
}
</style>
</head>
<body>
<script>
document.addEventListener('DOMContentLoaded', () => {
// ...
function createSmallBox() {
for (let i = 0; i < row; i++) {
for (let j = 0; j < col; j++) {
// ...
// smallBox.style.border = '1px solid red';
// 给每个小块增加不同的延时,让动画不同时间执行
const delay = i * 100; // 延迟时间为毫秒(ms),注意不要太小了
smallBox.style.animationDelay = `${delay}ms`;
box.appendChild(smallBox);
}
}
}
createSmallBox();
});
</script>
</body>
</html>
嘿嘿😃,稍微有点意思了吧?


Em...等等,你发现没有?怎么有一些小白条?这可不是小编添加的,小块的边框(border
)已经是注释了的。😓
一开始小编以为是常见的"图片底部白边"问题,直接设置一下 display: block
或者 vertical-align : middle
就能解决,结果还不是,折腾了很久都没有搞掉这个小白条。😤
最后,竟然通过设置 will-change 属性能解决这个问题❗我所知道的 will-change
应该是应用在性能优化上,解决动画流畅度问题上的,想不到这里竟然也能用。
❗不对不对,当初以为是
will-change
能直接完美解决白边的问题,但是感觉还是不对,但又确实能解决。。。(部分电脑屏幕)
但其实,应该是
smallBoxWidth
与smallBoxHeight
变量不是整数的问题,只要小块的宽度与高度保持一个整数,自然就没有这些白边了❗这是比较靠谱的事实,对于当前的高清屏幕来说。
但是,也是很奇怪,在小编另一台电脑(旧电脑)上即使是保持了整数,也会在横向存在一些小白边,太难受了。。。没办法彻底搞定这个问题。
猜测应该是和屏幕分辨率有关,毕竟那才是根源所在。
2024年07月01日
看来得去深度学习一下💪 will-change
属性的原理过程才行,这里也推荐倔友写得一篇文章:传送门。
解决相邻背景图片白条/白边间隙问题:
<script>
document.addEventListener('DOMContentLoaded', () => {
// ...
function createSmallBox() {
for (let i = 0; i < row; i++) {
for (let j = 0; j < col; j++) {
// ...
smallBox.style.willChange = 'transform';
// 在动画执行后,需要重置will-change
const timer = setTimeout(() => {
smallBox.style.willChange = 'initial';
clearTimeout(timer);
}, 2000);
box.appendChild(smallBox);
}
}
}
createSmallBox();
});
</script>
一定要注意 will-change
不可能被滥用,注意重置回来❗
这下女神在动画执行后,也清晰可见了,这是全部小块拼接组成的图片。

在上述代码中,咱们看到,通过 animation-delay
去延迟动画的执行,就能制造一个从上到下的渐变效果。
那么,咱们再改改延迟时间,如:
// const delay = i * 100;
// 改成 ⤵
const delay = j * 100;
效果:

这...好像有那么点意思吧。。。

但是,这渐变...好像还达不到我们开头 gif
的碎片化效果吧?
那么,碎片化安排上:
.small-box {
/* ... */
--rotateX: rotateX(0);
--rotateY: rotateY(0);
transform: var(--rotateX) var(--rotateY) scale(0.8);
}
@keyframes smallBoxAnimate {
0% {
opacity: 0;
transform: var(--rotateX) var(--rotateY) scale(0.8);
}
40% {
opacity: 0;
transform: var(--rotateX) var(--rotateY) scale(0.8);
}
70% {
opacity: 1;
transform: rotateX(0) rotateY(0) scale(0.8);
}
100% {
opacity: 1;
transform: rotateX(0) rotateY(0) scale(1);
}
}
其实就是增加小块的样式动画而已,再加点旋转,再加点缩放,都整上,整上。😆
效果:

是不是稍微高级一点?有那味了?😁
看到上面旋转所用的"样式变量"没有?
--rotateX: rotateX(0);
--rotateY: rotateY(0);
不可能无缘无故突然使用,必然是有深意啦。😁
现在效果还不够炫,咱们将样式变量利用起来,让"相邻两个小块旋转相反":
<script>
document.addEventListener('DOMContentLoaded', () => {
// ...
function createSmallBox() {
for (let i = 0; i < row; i++) {
for (let j = 0; j < col; j++) {
// ...
// 相邻两个小块旋转相反
const contrary = (i + j) % 2 === 0;
smallBox.style.setProperty('--rotateX', `rotateX(${contrary ? -180 : 0}deg)`);
smallBox.style.setProperty('--rotateY', `rotateY(${contrary ? 0 : -180}deg)`);
box.appendChild(smallBox);
}
}
}
createSmallBox();
});
</script>
效果:

这下对味了。😃
总的来说,我们可以通过"延迟"执行动画与改变"旋转"行为,让小块们呈现不同的动画效果,或者你只要有足够多的设想,你可以给小块添加不同的动画效果,相信也能制造出不错的整体效果。
更多效果
下面列举一些通过"延迟"执行动画产生的效果,可以瞧瞧哈。
随机:
const getRandom = (min, max) => Math.floor(Math.random() * (max - min + 1) + min);
const delay = getRandom(0, col + row) * 100;

从左上角到右下角:
const delay = (i + j) * 100;

其他的从"右上角到左下角"或者"左下角到右上角"等等的,只要反向调整一下变量就行了,就靠你自己悟啦,Come On!👻
从中心向四周扩散:
const delay = ((Math.abs(col / 2 - j) + Math.abs(row / 2 - i))) * 100;

从四周向中心聚齐:
const delay = (col / 2 - Math.abs(col / 2 - j) + (col / 2 - Math.abs(row / 2 - i))) * 100;

那么,到这里就差不多了❗
但还有最后一个问题,那就是图片的大量使用与加载时长的情况可能会导致效果展示不佳,这里你最好进行一些防范措施,如:
- 图片链接设置缓存,让浏览器缓存到内存或硬盘中。
- 通过
JS
手动将图片缓存到内存,主要就是创建Image
对象。 - 将图片转成
base64
使用。 - 直接将图片放到代码本地使用。
- ...
以上等等吧,反正最好就是要等图片完整加载后再进行效果展示。
至此,本篇文章就写完啦,撒花撒花。
希望本文对你有所帮助,如有任何疑问,期待你的留言哦。
老样子,点赞+评论=你会了,收藏=你精通了。
来源:juejin.cn/post/7379856289487831074
Flutter局部刷新三剑客
局部刷新作为提高Flutter页面性能的重要手段,是每一个Flutter老手都必须掌握的技巧。当然,我们不用非得使用Riverpod、Provider、Bloc这些状态管理工具来实现局部刷新,Flutter框架本身也给我们提供了很多方便快捷的刷新方案,今天要提的就是Notifier三剑客,用它来处理局部刷新,代码优雅又方便,可谓是居家必备之良器。
ChangeNotifier
ChangeNotifier作为数据提供方,给出了响应式编程的基础,我们先来看看ChangeNotifier的源码。
作为一个mixin,它就是实现了Listenable,这又是个什么呢?
这个抽象类,实际上就是实现了addListener和removeListener两个监听的处理。所以接下来我们看看ChangeNotifier是如何实现者两个方法的。
源码很简单,就是创建的listener添加到_listeners列表中。
移除也很简单。最后看下核心的notifyListeners方法。
这个方法就是遍历_listeners,来触发监听Callback。整体就是一个标准的「订阅-发布」流程。
作为Notifier家族的长辈,它的使用会略复杂一些,我们来看一个例子。首先,需要mixin一个ChangeNotifier。
class CountNotifier with ChangeNotifier {
int count = 0;
void increase() {
++count;
notifyListeners();
}
}
然后再创建一个TestWidget来调用这个ChangeNotifier。
class CountNotifierWidget extends StatefulWidget {
const CountNotifierWidget({super.key});
@override
State<StatefulWidget> createState() {
return _CountNotifierState();
}
}
class _CountNotifierState extends State<CountNotifierWidget> {
final CountNotifier _countNotify = CountNotifier();
int _count = 0;
@override
void initState() {
super.initState();
_countNotify.addListener(updateCount);
}
void updateCount() {
setState(() {
_count = _countNotify.count;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Text("Test: $_count"),
),
floatingActionButton: FloatingActionButton(
onPressed: () => _countNotify.increase(),
child: const Icon(Icons.add),
),
);
}
@override
void dispose() {
super.dispose();
_countNotify.removeListener(updateCount);
}
}
这样当我们修改ChangeNotifier的value的时候,就会Callback到updateCount实现刷新。
这样就形成了一个响应式的基础模型,数据修改,监听者刷新UI,完成了响应式的同时,也实现了局部刷新的功能,提高了性能。
ValueNotifier
在使用ChangeNotifier的时候,每次在修改变量时,都需要手动调用notifyListeners()方法,所以,Flutter创建了一个新的组件——ValueNotifier,它的源码如下。
从源码可以看见,ValueNotifier就是在set方法中,帮你调用了下notifyListeners()方法。同时,ValueNotifier封装了一个泛型变量,简化了ChangeNotifier的创建过程,所以大部分时间我们都是直接使用ValueNotifier。
那么有了它之后,我们就可以省去新建类的步骤,对于单一的基础类型变量,直接创建ValueNotifier即可,就像上面的例子,我们可以直接改造成下面这样。
class _CountNotifierState extends State<CountNotifierWidget> {
final ValueNotifier<int> _countNotify = ValueNotifier(0);
@override
void initState() {
super.initState();
_countNotify.addListener(updateCount);
}
void updateCount() {
setState(() {});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Text("Test: ${_countNotify.value}"),
),
floatingActionButton: FloatingActionButton(
onPressed: () => _countNotify.value++,
child: const Icon(Icons.add),
),
);
}
@override
void dispose() {
super.dispose();
_countNotify.removeListener(updateCount);
}
}
ValueListenableBuilder
我们从ChangeNotifier到ValueNotifier,逐步减少了模板代码的创建,但是依然还有很多问题,比如我们还是需要手动addListener、removeListener或者是dispose,同时,还需要使用setState来刷新页面,如果Context控制不好,很容易造成整个页面的刷新。因此,Flutter在它们的基础之上,又提供了ValueListenableBuilder来解决上面这些问题。
我们继续改造上面的例子。
class _CountNotifierState extends State<CountNotifierWidget> {
final ValueNotifier<int> _countNotify = ValueNotifier(0);
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: ValueListenableBuilder<int>(
valueListenable: _countNotify,
builder: (context, value, child) {
return Text('Value: $value');
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => _countNotify.value++,
child: const Icon(Icons.add),
),
);
}
@override
void dispose() {
super.dispose();
_countNotify.dispose();
}
}
可以发现,我们使用ValueListenableBuilder来根据ValueNotifier的改变而刷新Widget。这样不仅简化了代码模板,而且不再使用setState来进行页面刷新。
ValueListenableBuilder作为一个非常经典的Widget,在它的注释中,就有很多教程和示例。
再看它的源码。
这里需要接收3个参数,其中valueListenable用来接收ValueNotifier,builder用来构建Widget,而child,用来创建不依赖ValueNotifier构建的Widget(这是一个很经典的性能优化的例子,如果子构建成本高,并且不依赖于通知符的值,我们将使用它进行优化)。
这个优化方案非常经典,在Flutter的很多地方都有使用这个技巧,特别是动画这块的处理。通常来说ValueNotifier对应ValueListenableBuilder,Listenable、ChangeNotifier对应AnimatedBuilder。
自定义类型
在使用自定义类型时,例如一个包装类,那么当你改变它的某个属性值时,ValueListenableBuilder是不会刷新的,我们来看下面这个例子。
class Wrapper {
int age;
String name;
Wrapper({this.age = 0, this.name = ''});
}
class _CountNotifierState extends State<CountNotifierWidget> {
final ValueNotifier<Wrapper> _countNotify = ValueNotifier(Wrapper());
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: ValueListenableBuilder<Wrapper>(
valueListenable: _countNotify,
builder: (context, value, child) {
return Text('Value: ${value.age}');
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => _countNotify.value.age = _countNotify.value.age + 1,
child: const Icon(Icons.add),
),
);
}
@override
void dispose() {
super.dispose();
_countNotify.dispose();
}
}
这样的话,ValueListenableBuilder就失去作用了,其原因也很简单,ValueNotifier所监听的数据其实并未发生改变,实例的内存地址没发生改变,所以,直接创建一个新的对象,就可以触发更新了,就像下面这样。
onPressed: () => _countNotify.value = Wrapper(age: 10),
自定义类型局部刷新
上面这种自定义模型的刷新方法还是略显复杂了一点,每次更新的时候,都要copy一下数据来实现更新,实际上,ValueNotifier继承自ChangeNotifier,所以可以通过手动调用notifyListeners的方式来进行刷新,我们改造下上面的例子。
class WrapperNotifier extends ValueNotifier<Wrapper> {
WrapperNotifier(Wrapper value) : super(value);
void increment() {
value.age++;
notifyListeners();
}
}
// 调用处
_countNotify.increment();
通过这种方式,我们可以实现当模型内部变量更新时,局部进行刷新了。
欢迎大家关注我的公众号——【群英传】,专注于「Android」「Flutter」「Kotlin」
我的语雀知识库——http://www.yuque.com/xuyisheng
来源:juejin.cn/post/7381767811679502346
快速理解 并发量、吞吐量、日活、QPS、TPS、RPS、RT、PV、UV、DAU、GMV
并发与并行
- 并发:由于CPU数量或核心数量不够,多个任务并不一定是同时进行的,这些任务交替执行(分配不同的CPU时间片,进程或者线程的上下文切换),所以是伪并行。
- 并行:多个任务可以在同一时刻同时执行,通常需要多个或多核处理器,不需要上下文切换,真正的并行。
并发量(Concurrency)
- 概念:并发或并行,是程序和运维本身要考虑的问题。而并发量,通常是不考虑程序并发或并行执行,只考虑一个服务端程序单位时间内同时可接受并响应多少个请求,通常以秒为单位,也可乘以86400,以天为单位。
- 计算方法,通常通过一些压测工具,例如ApiPost压测,或者ab压测来统计,依ab为例:
Window系统:Apache下bin目录有个ab.exe
CentOS系统:yum -y install httpd-tools.x86_64
ab -c 并发数 -n 请求数 网址
ab -c 10 -n 150 127.0.0.1/ 表示对127.0.0.1这个地址,用10个并发一共请求了150次。而不是1500次,
Time taken for tests: 1.249 seconds,说明并发量为 150 / 1.249 ≈ 120 并发,表示系统最多可承载120个并发每秒。
吞吐量(Throughput)
- 概念:吞吐量是指系统在单位时间能够处理多少个请求,TPS、QPS都是吞吐量的量化指标。
相比于QPS这些具有清晰定义的书面用语,吞吐量偏向口语化。
日活
- 概念:每日活跃用户的数量,通常偏向非技术指标用语,这个概念没有清晰的定义,销售运营嘴里的日活,可能是只有一个人1天访问100次,就叫做日活100,也可以说是日活1,中位数日活50,显然意义不大。
QPS(Query Per Second)
- 概念:每秒查询次数,通常是对读操作的压测指标。服务器在一秒的时间内能处理多少量的请求。和并发量概念差不多,并发量高,就能应对更多的请求。
- 计算方法,通常通过一些压测工具,例如ApiPost压测,或者ab压测来统计,依ab为例:
ab -c 10 -n 150 127.0.0.1/
其中返回一行数据:
Requests per second: 120.94 [#/sec] (mean)
表示该接口QPS在120左右。
TPS(Transactions Per Second)
- 概念:每秒处理的事务数目,通常是对写操作的压测指标。这里的事务不是数据库事务,是指服务器接收到请求,再到处理完后响应的过程。TPS表示一秒事件能够完成几次这样的流程。
TPS对比QPS
- QPS:偏向统计查询性能,一般不涉及数据写操作。
- TPS:偏向统计写入性能,如插入、更新、删除等。
RPS(Request Per Second)
- 概念:每秒请求数,和QPS、TPS概念差不多。没有过于清晰的定义,看你怎么用。
RT(Response Time)
- 概念:响应时间间隔,是指用户发起请求,到接收到请求的时间间隔,越少越好,应当控制在0~150毫秒之间。
PV(Page view)
- 概念:浏览次数统计,一般以天为单位。范围可以是单个页面,也可以是整个网站,一千个用户一天对该页面访问一万次,那该页面PV就是一万。
UV(Unique Visitor)
- 概念:唯一访客数。时间单位通常是天,1万个用户一天访问该网站十万次,那么UV是一万。
- 实现方案:已登录的用户可通过会话区分,未登录的用户可让客户端创建一个唯一标识符当做临时的token用于区分用户。
DAU(Daily Active Use)
- 概念:日活跃用户数量,来衡量服务的用户粘性以及服务的衰退周期。统计方案各不相同,这要看对活跃的定义,访问一次算活跃,还是在线时长超10分钟算活跃,还是用户完成某项指标算活跃。
GMV(Gross Merchandise Volume)
- 概念:单位时间内的成交总额。多用于电商行业,一般包含拍下未支付订单金额。
来源:juejin.cn/post/7400281441803403275
Canvas星空类特效
思路
- 绘制单个星星
- 在画布批量随机绘制星星
- 添加星星移动动画
- 页面resize处理
Vanilla JavaScript实现
- 初始化一个工程
pnpm create vite@latest
# 输入工程名后,类型选择Vanilla
cd <工程目录> pnpm install pnpm dev # 运行本地服务
body {
background-color: black;
overflow: hidden;
}
"use strict";
import './style.css';
document.querySelector('#app').innerHTML = `
`;
// 后续代码全部在main.js下添加即可
- 绘制单个星星
const hue = 220; // 颜色hue值可以根据自己喜好调整
// 离屏canvas不需要写入到静态html里,所以用createElement
const offscreenCanvas = document.createElement("canvas");
const offscreenCtx = offscreenCanvas.getContext("2d");
offscreenCanvas.width = 100;
offscreenCanvas.height = 100;
const half = offscreenCanvas.width / 2;
const middle = half;
// 设定径向渐变的范围,从画布中心到画布边缘
const gradient = offscreenCtx.createRadialGradient(
middle,
middle,
0,
middle,
middle,
half
);
// 添加多级颜色过渡,可以根据自己喜好调整
gradient.addColorStop(0.01, "#fff");
gradient.addColorStop(0.1, `hsl(${hue}, 61%, 33%)`);
gradient.addColorStop(0.5, `hsl(${hue}, 64%, 6%)`);
gradient.addColorStop(1, "transparent");
// 基于渐变填充色,在画布中心为原点绘制一个圆形
offscreenCtx.fillStyle = gradient;
offscreenCtx.beginPath();
offscreenCtx.arc(middle, middle, half, 0, Math.PI * 2);
offscreenCtx.fill();
参考链接:
- 在画布批量绘制星星
其实要绘制星星,我们只需要在画布上基于离屏画布来在指定位置将离屏画布渲染成图片即可,但是批量绘制以及后续的动画需要我们能记录每颗星星的位置、状态和行驶轨迹,所以可以考虑创建一个星星的类。
// 声明存放星星数据的数组,以及最大星星数量
const stars = [];
const maxStars = 1000;
// 用于提供随机值,不用每次都Math.random()
const random = (min, max) => {
if (!max) {
max = min;
min = 0;
}
if (min > max) {
[min, max] = [max, min];
}
return Math.floor(Math.random() * (max - min + 1)) + min;
};
// 用于计算当前以画布为中心的环绕半径
const maxOrbit = (_w, _h) => {
const max = Math.max(_w, _h);
const diameter = Math.round(Math.sqrt(max * max + max * max));
return diameter / 2;
};
class Star {
constructor(_ctx, _w, _h) {
this.ctx = _ctx;
// 最大轨道半径
this.maxOrbitRadius = maxOrbit(_w, _h);
// 轨道半径
this.orbitRadius = random(this.maxOrbitRadius);
// 星星大小(半径)
this.radius = random(60, this.orbitRadius) / 12;
// 环绕轨道中心,即画布中心点
this.orbitX = _w / 2;
this.orbitY = _h / 2;
// 随机时间,用于动画
this.elapsedTime = random(0, maxStars);
// 移动速度
this.speed = random(this.orbitRadius) / 500000;
// 透明度
this.alpha = random(2, 10) / 10;
}
// 星星的绘制方法
draw() {
// 计算星星坐标[x, y],使用sin和cos函数使星星围绕轨道中心做圆周运动
const x = Math.sin(this.elapsedTime) * this.orbitRadius + this.orbitX;
const y = Math.cos(this.elapsedTime) * this.orbitRadius + this.orbitY;
// 基于随机数调整星星的透明度
const spark = Math.random();
if (spark < 0.5 && this.alpha > 0) {
this.alpha -= 0.05;
} else if (spark > 0.5 && this.alpha < 1) {
this.alpha += 0.05;
}
// 调整全局绘制透明度,使后续绘制都基于这个透明度绘制,也就是绘制当前星星
// 因为动画里会遍历每一个星星进行绘制,所以透明度会来回改变
this.ctx.globalAlpha = this.alpha;
// 在星星所在的位置基于离屏canvas绘制一张星星的图片
this.ctx.drawImage(offscreenCanvas, x - this.radius / 2, y - this.radius / 2, this.radius, this.radius);
// 时间基于星星的移动速度递增,为下一帧绘制做准备
this.elapsedTime += this.speed;
}
}
获取当前画布,批量添加星星
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
let w = canvas.width = window.innerWidth;
let h = canvas.height = window.innerHeight;
for (let i = 0; i < maxStars; i++) {
stars.push(new Star(ctx, w, h));
}
- 添加星星的移动动画
function animation() {
// 绘制一个矩形作为背景覆盖整个画布,'source-over'是用绘制的新图案覆盖原有图像
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 0.8;
ctx.fillStyle = `hsla(${hue} , 64%, 6%, 1)`;
ctx.fillRect(0, 0, w, h);
// 绘制星星,'lighter'可以使动画过程中重叠的星星有叠加效果
ctx.globalCompositeOperation = 'lighter';
stars.forEach(star => {
star.draw();
});
window.requestAnimationFrame(animation);
}
// 调用动画
animation();
这样星星就动起来了。
- 页面resize处理
其实只需要在resize事件触发时重新设定画布的大小即可
window.addEventListener('resize', () => {
w = canvas.width = window.innerWidth;
h = canvas.height = window.innerHeight;
});
但是有一个问题,就是星星的运行轨迹并没有按比例变化,所以需要添加两处变化
// 在Star类里添加一个update方法
class Star {
constructor(_ctx, _w, _h) {//...//}
//添加部分
update(_w, _h) {
// 计算当前的最大轨道半径和类之前保存的最大轨道半径的比例
const ratio = maxOrbit(_w, _h) / this.maxOrbitRadius;
// 因为每帧动画都会调用这个方法,但比例没变化时不需要按比例改变移动轨道,所以加个判断
if (ratio !== 1) {
// 重新计算最大轨道半径
this.maxOrbitRadius = maxOrbit(_w, _h);
// 按比例缩放轨道半径和星星的半径
this.orbitRadius = this.orbitRadius * ratio;
this.radius = this.radius * ratio;
// 重新设置轨道中心点
this.orbitX = _w / 2;
this.orbitY = _h / 2;
}
}
draw() {//...//}
}
// 在animation函数里调用update
function animation() {
// ...
stars.forEach(star => {
star.update(w, h);
star.draw();
});
// ...
}
React实现
react实现主要需要注意resize事件的处理,怎样避免重绘时对星星数据初始化,当前思路是使用多个useEffect
import React, { useEffect, useRef, useState } from 'react';
const HUE = 217;
const MAX_STARS = 1000;
const random = (min: number, max?: number) => {
if (!max) {
max = min;
min = 0;
}
if (min > max) {
[min, max] = [max, min];
}
return Math.floor(Math.random() * (max - min + 1)) + min;
};
const maxOrbit = (_w: number, _h: number) => {
const max = Math.max(_w, _h);
const diameter = Math.round(Math.sqrt(max * max + max * max));
return diameter / 2;
};
// 离屏canvas只需要执行一次,但是直接在函数外部使用document.createElement会出问题
const getOffscreenCanvas = () => {
const offscreenCanvas = document.createElement('canvas');
const offscreenCtx = offscreenCanvas.getContext('2d')!;
offscreenCanvas.width = 100;
offscreenCanvas.height = 100;
const half = offscreenCanvas.width / 2;
const middle = half;
const gradient = offscreenCtx.createRadialGradient(middle, middle, 0, middle, middle, half);
gradient.addColorStop(0.01, '#fff');
gradient.addColorStop(0.1, `hsl(${HUE}, 61%, 33%)`);
gradient.addColorStop(0.5, `hsl(${HUE}, 64%, 6%)`);
gradient.addColorStop(1, 'transparent');
offscreenCtx.fillStyle = gradient;
offscreenCtx.beginPath();
offscreenCtx.arc(middle, middle, half, 0, Math.PI * 2);
offscreenCtx.fill();
return offscreenCanvas;
};
class OffscreenCanvas {
static instance: HTMLCanvasElement = getOffscreenCanvas();
}
class Star {
orbitRadius!: number;
maxOrbitRadius!: number;
radius!: number;
orbitX!: number;
orbitY!: number;
elapsedTime!: number;
speed!: number;
alpha!: number;
ratio = 1;
offscreenCanvas = OffscreenCanvas.instance;
constructor(
private ctx: CanvasRenderingContext2D,
private canvasSize: { w: number, h: number; },
) {
this.maxOrbitRadius = maxOrbit(this.canvasSize.w, this.canvasSize.h);
this.orbitRadius = random(this.maxOrbitRadius);
this.radius = random(60, this.orbitRadius) / 12;
this.orbitX = this.canvasSize.w / 2;
this.orbitY = this.canvasSize.h / 2;
this.elapsedTime = random(0, MAX_STARS);
this.speed = random(this.orbitRadius) / 500000;
this.alpha = random(2, 10) / 10;
}
update(size: { w: number, h: number; }) {
this.canvasSize = size;
this.ratio = maxOrbit(this.canvasSize.w, this.canvasSize.h) / this.maxOrbitRadius;
if (this.ratio !== 1) {
this.maxOrbitRadius = maxOrbit(this.canvasSize.w, this.canvasSize.h);
this.orbitRadius = this.orbitRadius * this.ratio;
this.radius = this.radius * this.ratio;
this.orbitX = this.canvasSize.w / 2;
this.orbitY = this.canvasSize.h / 2;
}
}
draw() {
const x = (Math.sin(this.elapsedTime) * this.orbitRadius + this.orbitX);
const y = (Math.cos(this.elapsedTime) * this.orbitRadius + this.orbitY);
const spark = Math.random();
if (spark < 0.5 && this.alpha > 0) {
this.alpha -= 0.05;
} else if (spark > 0.5 && this.alpha < 1) {
this.alpha += 0.05;
}
this.ctx.globalAlpha = this.alpha;
this.ctx.drawImage(this.offscreenCanvas, x - this.radius / 2, y - this.radius / 2, this.radius, this.radius);
this.elapsedTime += this.speed;
}
}
const StarField = () => {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const animationRef = useRefnull>(null);
const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 });
const [initiated, setInitiated] = useState(false);
const [stars, setStars] = useState<Star[]>([]);
// 这里会在画布准备好之后初始化星星,理论上只会执行一次
useEffect(() => {
if (canvasRef.current && canvasSize.w !== 0 && canvasSize.h !== 0 && !initiated) {
const ctx = canvasRef.current!.getContext('2d')!;
const _stars = Array.from({ length: MAX_STARS }, () => new Star(ctx, canvasSize));
setStars(_stars);
setInitiated(true);
}
}, [canvasSize.w, canvasSize.h]);
// 这里用于处理resize事件,并重新设置画布的宽高
useEffect(() => {
if (canvasRef.current) {
const resizeHandler = () => {
const { clientWidth, clientHeight } = canvasRef.current!.parentElement!;
setCanvasSize({ w: clientWidth, h: clientHeight });
};
resizeHandler();
addEventListener('resize', resizeHandler);
return () => {
removeEventListener('resize', resizeHandler);
};
}
}, []);
// 这里用于渲染动画,每次画布有变化时都会触发,星星初始化完成时也会触发一次
useEffect(() => {
if (canvasRef.current) {
const ctx = canvasRef.current.getContext('2d')!;
canvasRef.current!.width = canvasSize.w;
canvasRef.current!.height = canvasSize.h;
const animation = () => {
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 0.8;
ctx.fillStyle = `hsla(${HUE} , 64%, 6%, 1)`;
ctx.fillRect(0, 0, canvasSize.w, canvasSize.h);
ctx.globalCompositeOperation = 'lighter';
stars.forEach((star) => {
if (star) {
star.update(canvasSize);
star.draw();
}
});
animationRef.current = requestAnimationFrame(animation);
};
animation();
return () => {
cancelAnimationFrame(animationRef.current!);
};
}
}, [canvasSize.w, canvasSize.h, stars]);
return (
<canvas ref={canvasRef}>canvas>
);
};
export default StarField;
来源:juejin.cn/post/7399983901811474484
1个demo带你入门canvas
canvas画布是前端一个比较重要的能力,在MDN上看到有关canvas的API时,是否会感到枯燥?今天老弟就给各位带来了1个还说得过去的demo,话不多说,咱们一起来尝尝咸淡。
一、小球跟随鼠标移动
先来欣赏一下这段视频:
从上图我们发现,小球跟着我们的鼠标移动,并且鼠标点到的位置就是小球的中心点。想要实现这样的功能,我们可以将它抽象为下面图里的样子:
是的,一个是画布(canvas)类,一个是小球(Ball)类。
canvas主要负责尺寸、执行绘画、事件监听。
Ball主要负责圆心坐标、半径、以及更新自己的位置。
接下来就是代码部分,我们先来完成canvas类的实现。
1.1、初始化canvas类属性
从上面的视频以及拆解的图里,我们会发现这个画布至少拥有以下几个属性:
- width。画布的宽度
- height。画布的高度
- element。画布的标签元素
- context。画布的渲染上下文
- events。这个画布上的事件集合
- setWidthAndHeight()。设置画布的大小
- draw()。用于执行绘画的函数
- addEventListener()。用于给canvas添加相应的监听函数
因此我们可以得出下面这段代码:
class Canvas {
constructor(props){
this.element = props.element;
this.canvasContext = props.element.getContext('2d');
this.events = [];
this.width = props.width;
this.height = props.height;
}
}
1.2、设置canvas的大小
class Canvas {
// 构造器
constructor(props){
this.element = props.element;
this.canvasContext = props.element.getContext('2d');
this.events = [];
this.width = props.width;
this.height = props.height;
this.setWidthAndHeight(props.width, props.height);
}
// 设置canvas的宽度与高度
setWidthAndHeight(width, height){
this.element.setAttribute('width', width);
this.element.setAttribute('height', height);
}
}
在上面的代码里,我们手动实现了一个setWidthAndHeight
这样的函数,并且当执行new操作的时候,自动这个函数,从而达到设置几何元素大小的作用。
1.3、绘画小球
我们的这个绘画功能应该只负责绘画,也就是说这个小球的位置坐标等信息应该通过传参的形式传入到我们的draw函数里
。
class Canvas {
// 构造器
constructor(props){
this.element = props.element;
this.canvasContext = props.element.getContext('2d');
this.events = [];
this.width = props.width;
this.height = props.height;
this.setWidthAndHeight(props.width, props.height);
}
// 设置canvas的宽度与高度
setWidthAndHeight(width, height){
this.element.setAttribute('width', width);
this.element.setAttribute('height', height);
}
// 绘画小球
draw(ball){
this.canvasContext.beginPath();
this.canvasContext.arc(ball.centerX, ball.centerY, ball.radius, 0, 2 * Math.PI, true);
this.canvasContext.fillStyle = ball.background;
this.canvasContext.fill();
this.canvasContext.closePath();
}
}
1.4、给canvas添加事件监听
根据我们的需求,小球随着鼠标移动,说明我们应该是在canvas标签上监听mouseover事件。我们再来思考一下,其实这个事件监听函数也应该保持纯粹。纯粹的意思就是,你不要在这个方法里去写业务逻辑。这个函数只负责添加相应的事件监听。
接下来我们实现一下这个addEventListener函数。
class Canvas {
// 构造器
constructor(props){
this.element = props.element;
this.canvasContext = props.element.getContext('2d');
this.events = [];
this.width = props.width;
this.height = props.height;
this.setWidthAndHeight(props.width, props.height);
}
// 设置canvas的宽度与高度
setWidthAndHeight(width, height){
this.element.setAttribute('width', width);
this.element.setAttribute('height', height);
}
// 画物体
draw(ball){
this.canvasContext.beginPath();
this.canvasContext.arc(ball.centerX, ball.centerY, ball.radius, 0, 2 * Math.PI, true);
this.canvasContext.fillStyle = ball.background;
this.canvasContext.fill();
this.canvasContext.closePath();
}
// 添加监听器(eventName:要监听的事件,eventCallback:事件对应的处理函数)
addEventListener(eventName, eventCallback){
let finalEventName = eventName.toLocaleLowerCase();
if (!this.events.filter(item => item === finalEventName)?.length > 0){
this.events.push(finalEventName);
}
this.element['on' + finalEventName] = (target) => {
eventCallback && eventCallback(target);
}
}
}
好啦,Canvas类的实现到这里先告一段落,我们来看看小球(Ball)类的实现。
1.5、Ball类的初始化
Ball这个类比较简单,4个属性+一个方法。
属性分别是:
- centerX。小球的圆心横坐标X
- centerY。小球的圆心纵坐标Y
- background。小球的背景色
- radius。小球的半径
方法是updateLocation
函数。这个函数同样也是一个纯函数,只负责更新圆心坐标,更新的值也是由参数传递。
class Ball {
constructor(props){
this.centerX = props.centerX;
this.centerY = props.centerY;
this.radius = props.radius;
this.background = props.background || 'orange';
}
// 更新小球的地理位置
updateLocation(x, y){
this.centerX = x;
this.centerY = y;
}
}
1.6、添加推动器
说的直白点,就是我们现在只有2个class类,但是无法实现想要的效果,现在来想想什么时机去触发draw方法。
根据上面的视频,我们知道需要在canvas标签上添加鼠标over事件,然后在over事件里来实时获取小球的位置信息,最后再触发draw方法。
当鼠标离开canvas画布后,需要将画布上的内容清除,不留痕迹。
这样一来,我们不仅要实现类似桥梁(bridge)的功能,还需要在canvas类上实现“画布清空”的功能。
class Canvas {
// ...其他代码不动
// 清空画布的功能
clearCanvas(){
this.canvasContext.clearRect(0, 0, this.width, this.height);
}
}
// 画布对象
let canvas = new Canvas({
element: document.querySelector('canvas'),
width: 300,
height: 300
});
// 小球对象
let ball = new Ball({
centerX: 0,
centerY: 0,
radius: 30,
background: 'orange'
});
// 给canvas标签添加mouseover事件监听
canvas.addEventListener(
'mousemove',
(target) => {
canvasMouseOverEvent(target, canvas.element, ball);
}
)
canvas.addEventListener(
'mouseleave',
(target) => {
canvasMouseLeave(target, canvas);
}
)
// 鼠标滑动事件
function canvasMouseOverEvent(target, canvasElement, ball){
let pointX = target.offsetX;
let pointY = target.offsetY;
ball.updateLocation(pointX, pointY);
canvas.draw(ball);
}
// 鼠标离开事件
function canvasMouseLeave(target, canvasInstance){
canvasInstance.clearCanvas();
}
这样一来,我们便实现了大致的效果,如下:
我们似乎实现了当初的需求,只是为啥目前的表现跟“刮刮乐”差不多?因为下一次绘画的时候,没有将上次的绘画清空,所以才导致了现在的这个样子。
想要解决这个bug,只需在canvas类的draw方法里,加个清空功能就可以了。
class Canvas {
// 画物体
draw(ball){
this.clearCanvas();
this.canvasContext.beginPath();
this.canvasContext.arc(ball.centerX, ball.centerY, ball.radius, 0, 2 * Math.PI, true);
this.canvasContext.fillStyle = ball.background;
this.canvasContext.fill();
this.canvasContext.closePath();
}
}
如此一来,这个bug就算解决了。
二、最后
好啦,本期内容到这里就告一段落了,希望这个demo能够帮助你了解一下canvas的相关API,如果能够帮到你,属实荣幸,那么我们下期再见啦,拜拜~~
来源:juejin.cn/post/7388056383642206262
丸辣!BigDecimal又踩坑了
丸辣!BigDecimal又踩坑了
前言
小菜之前在国内的一家电商公司自研电商项目,在那个项目中是以人民币的分为最小单位使用Long来进行计算
现在小菜在跨境电商公司也接到了类似的计算需求,在小菜火速完成提交代码后,却被技术leader给叫过去臭骂了一顿
技术leader让小菜将类型改为BigDecimal,小菜苦思不得其解,于是下班后发奋图强,准备搞懂BigDecimal后再对代码进行修改
...
在 Java 中,浮点类型在进行运算时可能会产生精度丢失的问题
尤其是当它们表示非常大或非常小的数,或者需要进行高精度的金融计算时
为了解决这个问题,Java 提供了 BigDecimal
类
BigDecimal 使用各种字段来满足高精度计算,为了后续的描述,这里只需要记住两个字段
precision字段:存储数据十进制的位数,包括小数部分
scale字段:存储小数的位数
BigDecimal的使用方式再后续踩坑中进行描述,最终总结出BigDecimal的最佳实践
BigDecimal的坑
创建实例的坑
错误示例:
在BigDecimal有参构造使用浮点型,会导致精度丢失
BigDecimal d1 = new BigDecimal(6.66);
正确的使用方法应该是在有参构造中使用字符串,如果一定要有浮点数则可以使用BigDecimal.valueOf
private static void createInstance() {
//错误用法
BigDecimal d1 = new BigDecimal(6.66);
//正确用法
BigDecimal d2 = new BigDecimal("6.66");
BigDecimal d3 = BigDecimal.valueOf(6.66);
//6.660000000000000142108547152020037174224853515625
System.out.println(d1);
//6.66
System.out.println(d2);
//6.66
System.out.println(d3);
}
toString方法的坑
当数据量太大时,使用BigDecimal.valueOf
的实例,使用toString方法时会采用科学计数法,导致结果异常
BigDecimal d2 = BigDecimal.valueOf(123456789012345678901234567890.12345678901234567890);
//1.2345678901234568E+29
System.out.println(d2);
如果要打印正常结果就要使用toPlainString
,或者使用字符串进行构造
private static void toPlainString() {
BigDecimal d1 = new BigDecimal("123456789012345678901234567890.12345678901234567890");
BigDecimal d2 = BigDecimal.valueOf(123456789012345678901234567890.12345678901234567890);
//123456789012345678901234567890.12345678901234567890
System.out.println(d1);
//123456789012345678901234567890.12345678901234567890
System.out.println(d1.toPlainString());
//1.2345678901234568E+29
System.out.println(d2);
//123456789012345678901234567890.12345678901234567890
System.out.println(d2.toPlainString());
}
比较大小的坑
比较大小常用的方法有equals
和compareTo
equals
用于判断两个对象是否相等
compareTo
比较两个对象大小,结果为0相等、1大于、-1小于
BigDecimal使用equals时,如果两数小数位数scale不相同,那么就会认为它们不相同,而compareTo则不会比较小数精度
private static void compare() {
BigDecimal d1 = BigDecimal.valueOf(1);
BigDecimal d2 = BigDecimal.valueOf(1.00);
// false
System.out.println(d1.equals(d2));
// 0
System.out.println(d1.compareTo(d2));
}
在BigDecimal的equals方法中能看到,小数位数scale不相等则返回false
public boolean equals(Object x) {
if (!(x instanceof BigDecimal))
return false;
BigDecimal xDec = (BigDecimal) x;
if (x == this)
return true;
//小数精度不相等 返回 false
if (scale != xDec.scale)
return false;
long s = this.intCompact;
long xs = xDec.intCompact;
if (s != INFLATED) {
if (xs == INFLATED)
xs = compactValFor(xDec.intVal);
return xs == s;
} else if (xs != INFLATED)
return xs == compactValFor(this.intVal);
return this.inflated().equals(xDec.inflated());
}
因此,BigDecimal比较时常用compareTo,如果要比较小数精度才使用equals
运算的坑
常见的运算包括加、减、乘、除,如果不了解原理的情况就使用会存在大量的坑
在运算得到结果后,小数位数可能与原始数据发生改变,加、减运算在这种情况下类似
当原始数据为1.00(2位小数位数)和5.555(3位小数位数)相加/减时,结果的小数位数变成3位
private static void calc() {
BigDecimal d1 = BigDecimal.valueOf(1.00);
BigDecimal d2 = BigDecimal.valueOf(5.555);
//1.0
System.out.println(d1);
//5.555
System.out.println(d2);
//6.555
System.out.println(d1.add(d2));
//-4.555
System.out.println(d1.subtract(d2));
}
在加、减运算的源码中,会选择两数中小数位数(scale)最大的当作结果的小数位数(scale)
private static BigDecimal add(final long xs, int scale1, final long ys, int scale2) {
//用差值来判断使用哪个scale
long sdiff = (long) scale1 - scale2;
if (sdiff == 0) {
//scale相等时
return add(xs, ys, scale1);
} else if (sdiff < 0) {
int raise = checkScale(xs,-sdiff);
long scaledX = longMultiplyPowerTen(xs, raise);
if (scaledX != INFLATED) {
//scale2大时用scale2
return add(scaledX, ys, scale2);
} else {
BigInteger bigsum = bigMultiplyPowerTen(xs,raise).add(ys);
//scale2大时用scale2
return ((xs^ys)>=0) ? // same sign test
new BigDecimal(bigsum, INFLATED, scale2, 0)
: valueOf(bigsum, scale2, 0);
}
} else {
int raise = checkScale(ys,sdiff);
long scaledY = longMultiplyPowerTen(ys, raise);
if (scaledY != INFLATED) {
//scale1大用scale1
return add(xs, scaledY, scale1);
} else {
BigInteger bigsum = bigMultiplyPowerTen(ys,raise).add(xs);
//scale1大用scale1
return ((xs^ys)>=0) ?
new BigDecimal(bigsum, INFLATED, scale1, 0)
: valueOf(bigsum, scale1, 0);
}
}
}
再来看看乘法
原始数据还是1.00(2位小数位数)和5.555(3位小数位数),当进行乘法时得到结果的小数位数为5.5550(4位小数)
private static void calc() {
BigDecimal d1 = BigDecimal.valueOf(1.00);
BigDecimal d2 = BigDecimal.valueOf(5.555);
//1.0
System.out.println(d1);
//5.555
System.out.println(d2);
//5.5550
System.out.println(d1.multiply(d2));
}
实际上1.00会被优化成1.0(上面代码示例的结果也显示了),在进行乘法时会将scale进行相加,因此结果为1+3=4位
public BigDecimal multiply(BigDecimal multiplicand) {
//小数位数相加
int productScale = checkScale((long) scale + multiplicand.scale);
if (this.intCompact != INFLATED) {
if ((multiplicand.intCompact != INFLATED)) {
return multiply(this.intCompact, multiplicand.intCompact, productScale);
} else {
return multiply(this.intCompact, multiplicand.intVal, productScale);
}
} else {
if ((multiplicand.intCompact != INFLATED)) {
return multiply(multiplicand.intCompact, this.intVal, productScale);
} else {
return multiply(this.intVal, multiplicand.intVal, productScale);
}
}
}
而除法没有像前面所说的运算方法有规律性,因此使用除法时必须要指定保留小数位数以及舍入方式
进行除法时可以立马指定保留的小数位数和舍入方式(如代码d5)也可以除完再设置保留小数位数和舍入方式(如代码d3、d4)
private static void calc() {
BigDecimal d1 = BigDecimal.valueOf(1.00);
BigDecimal d2 = BigDecimal.valueOf(5.555);
BigDecimal d3 = d2.divide(d1);
BigDecimal d4 = d3.setScale(2, RoundingMode.HALF_UP);
BigDecimal d5 = d2.divide(d1, 2, RoundingMode.HALF_UP);
//5.555
System.out.println(d3);
//5.56
System.out.println(d4);
//5.56
System.out.println(d5);
}
RoundingMode枚举类提供各种各样的舍入方式,RoundingMode.HALF_UP是常用的四舍五入
除了除法必须指定小数位数和舍入方式外,建议其他运算也主动设置进行兜底,以防意外的情况出现
计算价格的坑
在电商系统中,在订单中会有购买商品的价格明细
比如用完优惠卷后总价为10.00,而买了三件商品,要计算每件商品花费的价格
这种情况下10除3是除不尽的,那我们该如何解决呢?
可以将除不尽的余数加到最后一件商品作为兜底
private static void priceCalc() {
//总价
BigDecimal total = BigDecimal.valueOf(10.00);
//商品数量
int num = 3;
BigDecimal count = BigDecimal.valueOf(num);
//每件商品价格
BigDecimal price = total.divide(count, 2, RoundingMode.HALF_UP);
//3.33
System.out.println(price);
//剩余的价格 加到最后一件商品 兜底
BigDecimal residue = total.subtract(price.multiply(count));
//最后一件价格
BigDecimal lastPrice = price.add(residue);
//3.34
System.out.println(lastPrice);
}
总结
普通的计算可以以最小金额作为计算单位并且用Long进行计算,而面对汇率、计算量大的场景可以采用BigDecimal作为计算单位
创建BigDecimal有两种常用的方式,字符串作为构造的参数以及浮点型作为静态方法valueOf��参数,后者在数据大/小的情况下toString方法会采用科学计数法,因此最好使用字符串作为构造器参数的方式
BigDecimal比较大小时,如果需要小数位数精度都相同就采用equals方法,忽略小数位数比较可以使用compareTo方法
BigDecimal进行运算时,加减运算会采用原始两个数据中精度最长的作为结果的精度,乘法运算则是将两个数据的精度相加得到结果的精度,而除法没有规律,必须指定小数位数和舍入模式,其他运算方式也建议主动设置小数位数和舍入模式进行兜底
当遇到商品平摊价格除不尽的情况时,可以将余数加到最后一件商品的价格进行兜底
最后(不要白嫖,一键三连求求拉~)
本篇文章被收入专栏 Java,感兴趣的同学可以持续关注喔
本篇文章笔记以及案例被收入 Gitee-CaiCaiJava、 Github-CaiCaiJava,除此之外还有更多Java进阶相关知识,感兴趣的同学可以starred持续关注喔~
有什么问题可以在评论区交流,如果觉得菜菜写的不错,可以点赞、关注、收藏支持一下~
关注菜菜,分享更多技术干货,公众号:菜菜的后端私房菜
来源:juejin.cn/post/7400096469723643956
全面横评 6 大前端视频播放器 - Vue3 项目应该怎么选?
前言
最近,我在负责公司的音视频开发,使用的技术栈是 Vue3,技术方案采用的是基于第三方库二次封装播放器组件,来实现公司的业务定制需求。
市面上有很多第三方视频库,在最开始进行技术选型的时候,我也花了很多时间。
现在初步把我们公司的音视频组件开发完成了,我对视频第三方库的技术选型进行一个总结分享,希望能帮助到正在进行音视频开发的你。
这次技术选型一共会对比 6 个第三方库:xgplayer、video.js、ckplayer x3、aliplayer、tcplayer、flv.js。
在对比每个第三库时,我都会给出文档地址、示例、Git 地址,便于大家自行对比。
我会给出收集到的每个第三方库的优缺点分析。对于部分重要的第三方库,将进行详细的优缺点分析;而对于涉及闭源等不可接受情况的库,优缺点分析将较为简略,不再深入对比。
因为我们技术团队的英文水平还达不到无障碍阅读英文文档的地步,所以第三库的文档是否支持中文,也是我们考虑的一个因素。
我们技术团队详细对比这些第三方库之后,最后选择的是 xgplayer。
好了,接下来开始分析以上提到的 6 个音视频第三方库~
1. xgplayer(推荐)
这个库也是我们现在所采用的库,整体使用下来感觉很不错,文档很详细,支持自定义插件,社区活跃~
文档地址: h5player.bytedance.com/
示例地址: h5player.bytedance.com/examples/
Git 地址: github.com/bytedance/x…
优点
- 基本满足现有功能,自带截图 等功能
- 中文文档,有清晰详细的功能释义
- 可通过在线配置和生成对应功能代码参考,预览配置后的视频效果,开发体验好
- 项目积极维护
- 近期从 v2 版本升级到了 v3版本,优化了很多功能,开发体验更好
- 支持自定义插件,对开发业务定制需求很有用
缺点
- 直播需要浏览器支持Media Source Extensions
- PC Web端支持直接播放mp4视频,播放HLS、FLV、MPEG-DASH需要浏览器支持Media Source Extensions
- iOS Web端支持直接播放mp4和HLS,不支持播放FLV、MPEG-DASH(iOS webkitwebview 均不支持MediaSource,因此无法支持flv文件转封装播放)
- Android Web端支持直接播放mp4和HLS,播放FLV、MPEG-DASH需要浏览器支持Media Source Extensions
- 进度条拖动:拖动时,视频会一直播放当前拖动视频帧的声音,导致挺起来声音一卡一卡的,而且拖动一停止就立马开始播放视频
- 自动播放限制:对于大多数移动 webview 和浏览器,默认情况下会阻止有声自动播放。可以设置静音起播,达到自动播放的目的,不能保证一定能够自动播放,不同的app和浏览器配置不一样,表现不一样
- 打点功能没有提供图片的配置,可能需要二次开发或者用预览功能
- hls和flv不能同时添加,但是可以自己通过逻辑判断,去允许 hls 和 flv 同时播放
- Android 在网页端打开后截图很模糊
2. video.js(候选)
文档地址: videojs.com/
示例地址: videojs.com/advanced/?v…
Git 地址: github.com/videojs/vid…
优点
- 功能全面:提供暂停、播放等功能,基本满足项目所有功能需求
- 社区情况:社区活跃,项目持续维护
- 插件和主题丰富:可以根据需求进行定制和扩展
- 跨平台和浏览器兼容性:支持跨平台播放,适用于各种设备和操作系统
- 进度条拖动时,视频暂停,且能预览当前拖动所处位置,在放开拖动时,才开始播放视频,体验比较好
缺点
- 英文文档:上手学习播放器难度大,且后期维护成本高(搭建 demo 时,发现英文文档对开发有影响)
- 学习曲线:提供广泛功能,可能需要一定时间来理解其概念、API 等
- 不支持 flv 格式,但是可以通过安装 videojs-flvjs-es6 插件,同时安装 flv.js 库,来提供 flv 格式支持(但是 videojs-flvjs-es6 库的 star 太少,可能会出现其他问题)
- 没有自带截图功能,需要自己开发
3. ckplayer x3(候选)
文档地址: http://www.ckplayer.com/
示例地址: http://www.ckplayer.com/demo.html
Git 地址: gitee.com/niandeng/ck…
优点
- 功能丰富,且提供良好的示例
- 中文文档,文档相对比较丰富和专业
- 格式支持度较高,通过插件还可以播放 ts、mpd 等视频
缺点
- 社区支持不够丰富,如果以后有扩展功能需求,不便开发
- git 仓库 issue 响应慢,后续出问题,可能不便解决
- 文档的左侧菜单的交互不太友好,功能模块分级不够清晰,导致查找 API 不方便
- 没有直接提供视频列表(通道切换)的功能或插件
- 进度条拖动:拖动时,视频一直在播放,且没有显示当前所处拖动位置的预览画面,用户不知道当前拖动所处的具体位置,体验不佳
4. aliplayer(候选)
文档地址: player.alicdn.com/aliplayer/i…
示例地址: player.alicdn.com/aliplayer/p…
Git 地址: github.com/aliyunvideo…
优点
- 基本满足现有功能需求,自带截图、视频列表等功能
- 提供部分功能演示和在线配置
- 中文文档
- 支持4K视频播放,并且具备高分辨率和高比特率视频的优化能力
- 刷新和切换页面的 loading 时间比 xgplayer 短
- 播放器内部集成 flv 和 hls 格式,可以直接播放
缺点
- Web播放器H5模式在移动端不支持播放FLV视频,但可播 HLS(m3u8)
- Web播放器提供的音量调节方法在iOS系统和部分Android系统会失效
- 自动播放限制:由于浏览器自身的限制,在Web播放器SDK中无法通过设置autoplay属性或者调用play()方法实现自动播放。只有视频静音才可以实现自动播放或者通过用户行为手动触发播放
- 截图功能限制:fiv 视频在Safari浏览器下不支持截图功能。即使启用截图按钮也不会出现
- 回放时,必须点击播放按钮那个图标才能播放,体验不佳。且鼠标悬停时,会显示视频控制栏,但点击控制栏,视频无对应功能响应,体验不佳
- 回放播放效果不统一:同样的设置,刷新页面时视频不会自动播放,切换页面再回来,视频会自动播放
- 有些高级功能需要商业授权:Web播放器SDK从2.14.0版本开始支持播放H.265编码协议的视频流,如需使用此功能,您需要先填写表单申请License授权
- 文档:文档目录混乱且杂糅其他播放器不需要的文档
- 进度条拖动:拖动时,视频会一直播放当前拖动视频帧的声音,导致听起来声音一卡一卡的,而且拖动一停止就立马开始播放视频
5. tcplayer(不推荐)
文档地址: cloud.tencent.com/document/pr…
示例地址: tcplayer.vcube.tencent.com/
Git 地址: 闭源,无 git 仓库
优点
- 断点续播:播放失败时自动重试,支持直播的自动重连功能
缺点
- 文档不丰富,示例功能不多
- 闭源项目,出现问题不好解决
- 内置的功能和插件相对阿里云和 CK 较少
- web 端截图功能不支持
6. flv.js(不推荐)
文档地址: github.com/bilibili/fl…
示例地址: bilibili.github.io/flv.js/demo…
Git 地址: github.com/bilibili/fl…
优点
- 由于浏览器对原生Video标签采用了硬件加速,性能很好,支持高清
缺点
- 文档:缺乏详细功能说明文档,只有 md 英文文档,文档阅读不方便
- 项目很久未更新,原作已离开哔站,虽已开源,但后期应该不会有啥版本升级和优化
- 播放 flv 格式需要依赖 Media Source Extensions,但目前所有 iOS 和 Android4.4.4 以下的浏览器都不支持
结语
以上是我对音视频第三方库进行技术选型对比的一个总结。如果有更好的见解或者其他补充,欢迎在评论区留言或者私聊我进行沟通。
来源:juejin.cn/post/7359083412386807818
润开鸿“龙芯+OpenHarmony”开发平台 DAYU431先锋派新品全面开售
近日,江苏润开鸿数字科技有限公司(以下简称“润开鸿”)基于全新龙芯 2K0300 芯片平台推出重磅新品润开鸿 HH-SCDAYU431 先锋派开发平台正式上市,成为润开鸿 DAYU 系列产品中符合 OpenHarmony 生态兼容性标准的第三款龙芯芯片平台产品。当前,该新品已于淘宝“润开鸿企业店”上线,关注“龙芯+OpenHarmony”方案设计与应用的工程师、开发者们可即刻前往了解。
作为 OpenHarmony 项目群初始成员单位、A类捐赠人、核心共建单位,以及最早参与基于“龙芯+OpenHarmony”开发与适配的 OpenHarmony 操作系统发行版厂商,润开鸿本次推出 HH-SCDAYU431 先锋派开发平台,搭载全新龙芯 2K0300 芯片平台,符合 OpenHarmony 生态兼容性标准,旨在助力工程师、开发者们掌握“龙芯+OpenHarmony”适配先机,成为国产自主嵌入式开发的“先锋派”。

龙芯 2K0300 先锋派模组/开发
润开鸿 DAYU431 先锋派开发平台
基于 LoongArch 架构64位 SoC 处理器 2K0300 设计的单板方案,支持 OpenHarmony 小型系统,板卡尺寸为(85mm x 56mm),兼容树莓派 4B 尺寸大小、定位孔及 40 PIN GPIO 定义。板卡接口资源丰富,外设生态扩展方便,支持图形 GUI 开发设计,资料配套齐全。板卡采用全表贴化设计,核心元器件均可采用国产器件替换,具有自主、安全、稳定、可靠、实用性强等特点,可广泛用于工业自动化控制、工业网关,物联网数采、能源电力、智慧水务、轨道交通、教学教具等应用领域的方案学习评估和技术预研。

产品亮点
●高性能低功耗处理器
龙芯 2K0300 处理器基于 LA264 处理器核,采用高集成度设计,满足在低能耗条件下进行高效处理。
●外设生态扩展方便
板卡兼容树莓派4B,可直接复用常见“派”配件模块和开源生态系统,有效降低拥有和使用成本,兼具易学性和可玩性。
●接口丰富
集成网络、LCD、USB、TF卡座、Wi-Fi、音频、ADC、JTAG等接口,扩展能力强,支持高效搭建方案原型和应用开发创新。
●国产自主,安全可控
采用国产龙芯 2K0300 处理器,元器件采用国产方案,板卡国产化率高,具备教育、工业控制等领域推广优势。
●生态兼容性强
支持 OpenHarmony 小型系统,符合生态兼容性标准;支持C/C++/Python等主流编程语言;支持QT、LVGL等多种图形(GUI)框架。
●配套资料齐全
具备完善的产品手册和参考资料。
技术参数

值得一提的是,润开鸿母公司江苏润和软件股份有限公司(以下简称“润和软件”)与广东龙芯中科电子科技有限公司(以下简称“广东龙芯”)的合作最早可以追溯到2022年4月,双方结合各自优势能力、联合多家科技企业共同发起成立了 OpenHarmony LoongArch SIG 组,旨在共建基于 LoongArch 架构平台的 OpenHarmony 国产自主生态及全栈式解决方案。自2022年初开始主导推动 LoongArch 架构在 OpenHarmony 中的适配,润开鸿不断引领技术路径并产出丰富成果,截至目前,已推出 HH-SCDAYU401 (基于龙芯 2K0500 芯片)、HH-SCDAYU410(基于龙芯 2K1000LA 芯片)以及近期上市的 HH-SCDAYU430 蜂鸟开发平台、HH-SCDAYU431 先锋派开发平台(均基于全新龙芯 2K0300 芯片)两款新品;商用设备方面则已成功推出一款龙芯交通控制器设备(HH-SCDAYU410A),并且也已通过 OpenHarmony 兼容性测评。
经过近几年的合作和积累,在适配兼容 OpenHarmony 设备领域,润开鸿是拥有龙芯产品最多、芯片系列最全和经验最丰富的 OpenHarmony 操作系统发行版厂商。未来,润开鸿将持续携手广东龙芯基于“龙芯+OpenHarmony”共同打造全国产化解决方案,在行业场景落地等方面不断加深合作,同时联合更多行业伙伴面向重点产业领域提供有力的技术支撑与使能服务,共同助力国产基础软件与数字经济产业加速形成新质生产力。
收起阅读 »为什么我建议Flutter中通过构造参数给页面传递信息
哈喽,我是老刘
前段时间有人问我这个问题碰到没有:
Flutter - 升级3.19之后页面多次rebuild?
说实话我们没有碰到这个问题
我先来简单解释一下这个问题,本质上是因为使用了 InheritedWidget
通过InheritedWidget向子树传递数据
InheritedWidget可以向其子树中的所有Widget提供数据。这使得无关的Widget能方便地获取同一个InheritedWidget提供的数据,实现Widget树中不直接相关Widget之间的数据共享。
Flutter SDK 中正是通过 InheritedWidget 来共享应用主题(Theme)和 Locale(当前语言环境)信息的。
其使用方法如下
实现一个InheritedWidget
class MyInheritedWidget extends InheritedWidget { // 继承InheritedWidget
final int data;
MyInheritedWidget({required this.data, required Widget child}) : super(child: child);
@override
// 这个方法定义了当数据发送变化时是否通知子树中的子Widget。
// 它返回一个布尔值,true表示通知子Widget,false表示不通知。
bool updateShouldNotify(MyInheritedWidget oldWidget) {
return oldWidget.data != data;
}
// 子Widget可以通过调用MyInheritedWidget.of()静态方法来获取MyInheritedWidget实例,并获取其提供的数据。
static MyInheritedWidget of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType()!;
}
}
获取InheritedWidget中的数据
class MyText extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Text(
'${MyInheritedWidget.of(context).data}',
style: Theme.of(context).textTheme.headline4,
);
}
}
当InheritedWidget中的数据发生变化时,就会通知所有通过InheritedWidget.of()方法注册了关注数据变化的成员。
这时这些子树中的组件就会重绘。
其实前面文章中的问题就是当你使用ModalRoute.of(context)方法获取页面路由的参数时,其实也就是向一个全局级别的InheritedWidget节点注册了关注其变化。
而当这个全局的路由节点的行为发生变化后(页面退栈通知数据变化),就会出现原先没有的重绘现象出现了。
为什么我们的代码没有这个问题
我们在传递页面参数时其实是没有使用ModalRoute.of(context)的方式获取页面参数的。
我们使用的是页面类的构造参数。
举个例子,比如打开一个商品详情页,需要传递商品id作为页面参数。
代码如下
class ProductDetailPage extends StatefulWidget {
final String productId;
const ProductDetailPage({required this.productId});
@override
_ProductDetailPageState createState() => _ProductDetailPageState();
}
class _ProductDetailPageState extends State<ProductDetailPage> {
@override
void initState() {
super.initState();
// 使用productId获取商品详情
}
@override
Widget build(BuildContext context) {
// 根据productId构建UI
return Scaffold(
// ...
);
}
}
定义路由可以使用动态生成路由的方式:
MaterialApp(
onGenerateRoute: (RouteSettings settings) {
// 通过settings.name可以获取传入的路由名称,并据此返回不同的路由
final productId = settings.arguments['id'] as String;
return MaterialPageRoute(
builder: (context) => ProductDetailPage(productId: productId),
);
}
)
那为什么我们要选择这种方式传递页面参数,而不是ModalRoute.of(context)的方式呢?
这其实是一种本能,一种下意识的行为。
两个思维习惯
1、减少不可控因素
老刘写了十多年的代码了,光Flutter就写了快6年。
这么多年的实战形成的习惯就是对不可控的外部依赖心怀警惕。
InheritedWidget就是一种很典型的场景。
如果是自己写的InheritedWidget还好,但如果是外部的,比如系统SDK的。
那么你怎么保证它通知的数据变化时你想要的呢?
这次的问题不就是很典型的例子吗。
远隔千里之外的人修改了几行代码,就对你的App的行为造成了影响。
2、模块化思维
也许你觉得写一个页面就是一个页面。
但是在我看来,很有可能某一天它就是某个页面的一个组件。
假设有一天你的产品要适配pad端
那么很有可能商品列表页和商品详情页会合并成一个页面:左边是列表右边是详情。
这时候原先独立的详情页就是页面的一个组件了。
这时候是不是通过构造参数传递商品id会合理很多?
总结
我们从一个实际的bug出发,解释了为什么建议大家通过构造参数进行页面传参。
进而引出了关于日常编码中的一些很具体的思维习惯。
总之很多时候最简单直接的用法可能也是最好的选择。
来源:juejin.cn/post/7394823316585168933
Flutter-实现悬浮分组列表
在本篇博客中,我们将介绍如何使用 Flutter 实现一个带有分组列表的应用程序。我们将通过 CustomScrollView
和 Sliver
组件来实现该功能。
需求
我们需要实现一个分组列表,分组包含固定的标题和若干个列表项。具体分组如下:
- 水果
- 动物
- 职业
- 菜谱
每个分组包含若干个项目,例如水果组包含苹果、香蕉等。
效果
实现思路
- 定义数据模型:创建
ItemBean
类来表示每个分组的数据。 - 构建主页面:使用
CustomScrollView
和Sliver
组件构建主页面,其中包含多个分组。 - 实现固定标题:通过自定义
SliverPersistentHeaderDelegate
实现固定标题。
实现代码
以下是实现代码:
import 'package:flutter/material.dart';
/// 数据源
/// https://github.com/yixiaolunhui/flutter_xy
class ItemBean {
final String groupName;
final List<String> items;
const ItemBean({required this.groupName, this.items = const []});
static List<ItemBean> get groupListData => const [
ItemBean(groupName: '水果', items: [
'苹果', '香蕉', '橙子', '葡萄', '芒果', '梨', '桃子', '草莓', '西瓜', '柠檬',
'菠萝', '樱桃', '蓝莓', '猕猴桃', '李子', '柿子', '杏', '杨梅', '石榴', '木瓜'
]),
ItemBean(groupName: '动物', items: [
'狗', '猫', '狮子', '老虎', '大象', '熊', '鹿', '狼', '狐狸', '猴子',
'企鹅', '熊猫', '袋鼠', '海豚', '鲨鱼', '斑马', '长颈鹿', '鳄鱼', '孔雀', '乌龟'
]),
ItemBean(groupName: '职业', items: [
'医生', '护士', '教师', '工程师', '程序员', '律师', '会计', '警察', '消防员', '厨师',
'司机', '飞行员', '科学家', '记者', '设计师', '作家', '演员', '音乐家', '画家', '摄影师'
]),
ItemBean(groupName: '菜谱', items: [
'红烧肉', '糖醋排骨', '宫保鸡丁', '麻婆豆腐', '鱼香肉丝', '酸辣汤', '蒜蓉菠菜', '回锅肉', '水煮鱼', '烤鸭',
'蛋炒饭', '蚝油生菜', '红烧茄子', '西红柿炒鸡蛋', '油焖大虾', '香菇鸡汤', '酸菜鱼', '麻辣香锅', '铁板牛肉', '干煸四季豆'
]),
];
}
/// 分组列表
class Gr0upListPage extends StatelessWidget {
const Gr0upListPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('分组列表')),
body: CustomScrollView(
slivers: ItemBean.groupListData.map(_buildGr0up).toList(),
),
);
}
Widget _buildGr0up(ItemBean itemBean) {
return SliverMainAxisGr0up(
slivers: [
SliverPersistentHeader(
pinned: true,
delegate: HeaderDelegate(itemBean.groupName),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(_, index) => _buildItemByUser(itemBean.items[index]),
childCount: itemBean.items.length,
),
),
],
);
}
Widget _buildItemByUser(String item) {
return Container(
alignment: Alignment.center,
height: 50,
child: Row(
children: [
const Padding(
padding: EdgeInsets.only(left: 20, right: 10.0),
child: FlutterLogo(size: 30),
),
Text(
item,
style: const TextStyle(fontSize: 16),
),
],
),
);
}
}
class HeaderDelegate extends SliverPersistentHeaderDelegate {
final String title;
const HeaderDelegate(this.title);
@override
Widget build(
BuildContext context, double shrinkOffset, bool overlapsContent) {
return Container(
alignment: Alignment.centerLeft,
color: Colors.grey,
padding: const EdgeInsets.only(left: 20),
height: 40,
child: Text(title, style: const TextStyle(fontSize: 16)),
);
}
@override
double get maxExtent => 40;
@override
double get minExtent => 40;
@override
bool shouldRebuild(covariant HeaderDelegate oldDelegate) {
return title != oldDelegate.title;
}
}
通过以上代码,我们实现了一个简单的 Flutter 分组列表应用。每个分组都有固定的标题,点击标题可以展开或收起组内的项目。希望这篇博客对你有所帮助!
详情 :github.com/yixiaolunhui/flutter_xy
来源:juejin.cn/post/7388091090350702618
受够了useState的逻辑分散?来,试试用reducer聚合逻辑
useState的缺点
经常写react 的同学都知道,useState
是 React 中的一个 Hook,可以在函数组件中管理组件的状态,并在状态更新时重新渲染组件。
这东西虽然简单好用,但有一个致命缺点:当组件有非常多的状态更新逻辑时,事件处理会非常分散,维护起来很头疼!
比如,一个简单的记事本功能
我需要通过三个不同的事件处理程序来实现任务的添加、删除和修改:
const [tasks, setTasks] = useState([
{ id: 1, title: '去贝加尔湖旅行', completed: false },
{ id: 2, title: '去烟台海边度假', completed: false },
{ id: 3, title: '再去一次厦门看海', completed: false },
]);
// 添加
const addTask = (taskTitle) => {
const newId = tasks.length + 1;
const newTask = { id: newId, title: taskTitle, completed: false };
setTasks([...tasks, newTask]);
};
// 删除
const deleteTask = (taskId) => {
setTasks(tasks.filter(task => task.id !== taskId));
};
// 编辑
const toggleTaskCompletion = (taskId) => {
setTasks(tasks.map(task =>
task.id === taskId ? { ...task, completed: !task.completed } : task
));
};
上面的代码中,每个事件处理程序都通过 setTasks
来更新状态。随着这个组件功能的,其状态逻辑也会越来越多。为了降低这种复杂度,并让所有逻辑都可以存放在一个易于理解的地方,我们可以将这些状态逻辑移到组件之外的一个称为 reducer 的函数中。
使用 reducer 整合状态逻辑
在学习reducer之前,我们先看看使用reducer整合后的代码
import React, { useReducer, useState } from 'react';
// 定义 reducer 函数
const taskReducer = (state, action) => {
switch (action.type) {
case 'ADD_TASK':
const newId = state.length + 1;
return [...state, { id: newId, title: action.payload, completed: false }];
case 'DELETE_TASK':
return state.filter(task => task.id !== action.payload);
case 'TOGGLE_TASK':
return state.map(task =>
task.id === action.payload ? { ...task, completed: !task.completed } : task
);
default:
return state;
}
};
function TaskList() {
const [tasks, dispatch] = useReducer(taskReducer, [
{ id: 1, title: '去贝加尔湖旅行', completed: false },
{ id: 2, title: '去烟台海边度假', completed: false },
{ id: 3, title: '再去一次厦门看海', completed: false },
]);
const [newTaskTitle, setNewTaskTitle] = useState('');
const handleAddTask = () => {
if (newTaskTitle.trim()) {
dispatch({ type: 'ADD_TASK', payload: newTaskTitle });
setNewTaskTitle('');
}
};
return (
<div>
<h2>快乐就是哈哈哈的记事本</h2>
<div>
<input
type="text"
placeholder="添加新任务"
value={newTaskTitle}
onChange={(e) => setNewTaskTitle(e.target.value)}
/>
<button onClick={handleAddTask} style={{ marginLeft: '10px' }}>添加</button>
</div>
<div>
{tasks.map(task => (
<div key={task.id} style={{ marginTop: '10px' }}>
<input
type="checkbox"
checked={task.completed}
onChange={() => dispatch({ type: 'TOGGLE_TASK', payload: task.id })}
/>
{task.title}
<button onClick={() => { /* Add edit functionality here */ }} style={{ marginLeft: '10px' }}>编辑</button>
<button onClick={() => dispatch({ type: 'DELETE_TASK', payload: task.id })} style={{ marginLeft: '10px' }}>删除</button>
</div>
))}
</div>
</div>
);
}
export default TaskList;
能够看出,所有的逻辑被整合到taskReducer这个函数中了,我们的逻辑聚合度很高,非常好维护!
useReducer的基本语法
const [state, dispatch] = useReducer(reducer, initialArg, init?)
在组件的顶层作用域调用 useReducer 以创建一个用于管理状态的 reducer。
import { useReducer } from 'react';
function reducer(state, action) {
// ...
}
function MyComponent() {
const [state, dispatch] = useReducer(reducer, { age: 42 });
// ...
参数
reducer
:用于更新 state 的纯函数。参数为 state 和 action,返回值是更新后的 state。state 与 action 可以是任意合法值。initialArg
:用于初始化 state 的任意值。初始值的计算逻辑取决于接下来的init
参数。- 可选参数
init
:用于计算初始值的函数。如果存在,使用init(initialArg)
的执行结果作为初始值,否则使用initialArg
。
返回值
useReducer
返回一个由两个值组成的数组:
- 当前的 state。初次渲染时,它是
init(initialArg)
或initialArg
(如果没有init
函数)。 - dispatch函数。用于更新 state 并触发组件的重新渲染。
dispatch 函数
dispatch 函数可以用来更新 state的值 并触发组件的重新渲染,它的用法其实和vue的store,react的状态管理库非常相似!
dispacth可以有很多,通过dispacth可以发送数据给reducer函数,函数内部,我们通过action可以拿到所有dispatch发送的数据,然后进行逻辑判断,更改state的值。
通常来说 action 是一个对象,其中 type 属性标识类型,其它属性携带额外信息。
代码解读
熟悉了它的语法后,我们的整合逻辑就非常好理解了。我们简化下逻辑:
import React, { useReducer, useState } from 'react';
// 定义 reducer 函数
const taskReducer = (state, action) => {
switch (action.type) {
// 根据不同逻辑,返回一个新的state的值
default:
return state;
}
};
function TaskList() {
const [tasks, dispatch] = useReducer(taskReducer, [
{ id: 1, title: '去贝加尔湖旅行', completed: false },
{ id: 2, title: '去烟台海边度假', completed: false },
{ id: 3, title: '再去一次厦门看海', completed: false },
]);
// 通过dispatch发送数据给taskReducer
return (
<div>
<h2>快乐就是哈哈哈的记事本</h2>
<div key={task.id} style={{ marginTop: '10px' }}>
<input
type="checkbox"
checked={task.completed}
onChange={() => dispatch({ type: 'TOGGLE_TASK', payload: task.id })}
/>
<button onClick={() => dispatch({ type: 'DELETE_TASK', payload: task.id })} style={{ marginLeft: '10px' }}>删除</button>
</div>
</div>
);
}
export default TaskList;
useReducer的性能优化
我们先看看下面的代码
function createInitialState(username) {
// ...
// 生成初始值的一些逻辑
}
function TodoList({ username }) {
const [state, dispatch] = useReducer(reducer, createInitialState(username));
// ...
createInitialState方法用于生成初始值,但是在每一次渲染的时候都会被调用,如果创建了比较大的数组或计算是比较浪费性能的!
我们可以通过给 useReducer 的第三个参数传入 初始化函数 来解决这个问题:
function createInitialState(username) {
// ...
}
function TodoList({ username }) {
const [state, dispatch] = useReducer(reducer, username, createInitialState);
// ...
需要注意的是你传入的参数是 createInitialState 这个 函数自身,而不是执行 createInitialState() 后的返回值
如果createInitialState可以直接计算出初始值,不需要默认的username,上面的代码可以进一步优化
function createInitialState() {
// ...
}
function TodoList() {
const [state, dispatch] = useReducer(reducer, null, createInitialState);
// ...
来源:juejin.cn/post/7399496845277151242
才4W条数据页面就崩溃了
写过地图需求的同学应该都遇到过地图加载大量点(4W多个点)导致页面十分卡顿的问题吧。
最近项目上线验收,现场直接卡崩溃了,其实在公司还好,因为公司的电脑配置还算可以,没有出现过崩溃的现象(但是也很卡,本来也想偷下懒)。崩溃了怎么办啊(我感觉我更崩溃呢,天天加班赶需求哪有时间做优化的啊)。
原因:用户想要加载所有的点还不做聚合,而且每个点都要做动态扩散效果,还要实时刷新地图数据。
哎,先说我的解决方案吧。
- 取消点的动态扩散效果
- 图层层级显示图标点才会更新
- 只显示可视范围内的点
- 用户操作过程中不更新图层
第一点,我必须拿着我的数据让产品经理去给客户说有动效内存占用在600M到1200M跳动,动效是1s一个循环。那么每次执行动效就会让内存飚到1200M,然后接下来浏览器会回收之前渲染的内存,内存又降至600M。我就说这个实在没法优化只能去掉(我没有试加载动态图片会不会好点,但想来也差不多)。
第二点,我先说明图层的加载方案,当图层层级小于14时就加载全部点否则加载聚合点(what fuck
)。客户就是这么牛,一般不应该是反着来吗。。那必须滴,这么多点客户也看不出点的位置更新,看到了也不知道是那个更新了。所以,出于性能考虑给出的方案是:当图层层级小于14的时候就不更新点。那么多点一起更新
第三点,前端是首次加载的时候把所有的数据都缓存起来的,由服务端推送更新消息,前端收到消息就维护缓存的数据并做相应的更新逻辑(在线离线/GIS等),会维护一个更新队列,如果数据太大的时候就分次更新。好的,说了那么多废话是不是想水文啊
首先,每次更新(用户缩放和拖拽地图与推送)之前需要先拿到当前地图的四个角经纬度,然后调用Turf.js
库提供的# pointsWithinPolygon
方法:
const searchWithin = Turf.multiPolygon([[东],[南],[西],[北]]);
const ptsWithin = Turf.pointsWithinPolygon(Turf.points([...points]), searchWithin);
拿到当前可视范围内的点,再将可视范围内的点渲染到地图上。
第四点,当我开开心心把代码提交上去后,过了一会,我的前端同事给我说感觉页面还是很卡啊(0.0
)。我表示不信,然后实际操作了一下,虽然上面的减少点的操作和减少点的数量让浏览器内存占用降了下来页面也确实不卡了,但是当我去拖动地图的时候发现问题了,怎么感觉拖着拖着的地图有规律的卡。怎么回事呢,再梳理下我明白了,之前的地图刷新时间是10s由于客户觉得刷新太慢了,索性就改成了3s,这一改一个不吱声,3s那不是很大概率当用户正在操作地图的时候地图重新渲染了所以感觉卡。知道问题就好办,判断用户当前是否在操作地图,movestart
事件时表示用户开始操作地图,moveend
事件表示用户结束操作。那就等用户操作地图结束后再更新地图,上手感受了一下一点也不卡了,搞定。
创作不易求,如果你看到这里还请您点赞收藏
来源:juejin.cn/post/7361973121790656562
优秀的程序员都有的十条特征,你中了几条?
之前的文章给大家分享的都是DevOps、自动化测试、新技术趋势等前沿知识和技术,实际上目前能完全掌握这些新技术的开发、测试人员都是少数,毕竟大多是人还是专注于自身工作,用于提升、学习新技术的时间较少,而很多新趋势也并未成熟应用到行业。因此,不必为此焦虑,极速变化中总有一些不变,坚守那些基础的不变的能力,并以积极的心态拥抱变化,才是持续而稳定的成长路径。本期分享一些不依赖于新技术、但作为程序员都可遵循的原则,请根据自身情况取舍、实施。
一、及时更新任务清单
当要实现一个功能点时,最好将较大的任务分割成较小且更清晰的任务,这些任务是相对独立的逻辑单元,可以单独进行测试。列一张这样可完成的较小任务的清单,并在完成之后勾选、更新。这样会形成自我激励,并促使自己去不断完成更多的小任务。
目前主流项目管理软件中,往往内置任务分解和更新功能。如在禅道项目管理软件内,开发负责人进行系统分析,拆解成相对独立的任务并指派给个人,而开发人员可以在自己的页面清晰地看到任务数量及剩余工时,完成后进度将同步更新,这种持续的正向反馈会带来极大的成就感。
二、遵循适当的版本控制
通过创建开发、特性、主分支和设置适当的访问权限来遵循适当的版本控制策略。无论何时开始编码,都要确保先获取代码库的最新版本后再开始。在逻辑部分或功能完成后继续提交/推送代码,不要让代码库长时间处于未提交状态。在将代码提交给版本控制之前,始终在本地机器上测试代码。无论变更多么细微,在输入代码时都要检查修改文件的差异,这将帮助追溯意料之外的变更,并有效避免不必要的Bug。
三、持续重构
代码重构是在不改变源代码的功能行为的情况下改变源代码的过程,目的是通过提高代码的可扩展性、降低代码的复杂度,以此来提高代码的可读性和可维护性。未能执行重构可能会导致技术债务的累积,开发人员会在之后的时间里对这些技术债务付出代价。要知道,没有任何一个开发人员愿意处于这种境况中,他们常常拒绝接触已经工作了很长时间的代码。当需要增强现有特性时,问题就出现了。
如果代码的形式不适合进行简易扩展,那么它将是开发人员的地狱。因此,为了避免出现这种情况,需要始终在代码中寻找可以改进的地方。如果你自己做不到,那就向团队寻求帮助。
四、敲代码前先手写代码
在实际将解决方案转换为代码之前,要养成手写算法/伪代码的习惯。手写还可以帮助你在将代码移至计算机之前规划代码。写出需要的函数和类、以及它们如何交互,可以在之后节省大量时间。尽管要比直接敲代码更耗时间,但这种规范会让你打下牢固的基础,实现之后更稳健的成长。
五、给自己的代码进行注释
在自己写的代码中留下注释,解释为什么要做出某些选择。这将帮助到之后拿到这段代码的人,因为不是每个人都清楚你为什么以这种特定的方式编写代码。不需要对非常明显的编码行为进行注释,因为这无关紧要。正确的代码注释将提高代码库的可维护性。
六、善用搜索引擎和论坛
并不是你遇到的所有问题都能自己找出明显的解决方案。所以记得善用搜索引擎,可能会有数百万的开发人员遇到过与您相同的问题,并且已经找到解决方案。所以,不要花过多时间独自寻求解决方案。很多开发人员低估了搜索作为程序员工作中一部分的重要性。
搜索引擎方面Google是不错的选择,论坛则推荐Stack Overflow。有时候工作需要的更多是知道如何获取知识和解决方案,而非实际编程。
七、寻求他人帮助
编程实际上是一种社交活动。我的程序员朋友都会在某些方面有突出优势,所以每当我有问题的时候,都知道该请教其中的哪一个。当他们有问题的时候,我也会帮助他们。这真的是完成任务的绝佳方法。
互相合作可以遵循敏捷开发的结对编程:两个程序员在一个计算机上共同工作。一个人输入代码,而另一个人审查他输入的每一行代码。二人经常互换角色,工作交替进行。在结对编程中,审查的角色需同时考虑工作的战略性方向,提出改进的意见或找出将来可能出现的问题以便处理。
虽然不能有完全的人力成本全面推行结对编程,但寻求他人建议时,看似无法改变的错误或无法学习的话题,可以通过新思维或对这个话题的新的解释来迅速缓解。所以不要在筒仓里编程,要经常讨论并推进。当你重新开始自己编写代码时,接触到多种想法和思维方式将有助于你解决问题。
八、记住技术永远在变化
我将自己的身份首先视为程序员,第二身份才是编程语言专家,因为总有一天我们现在使用的所有编程语言将不再被使用。比如我从80年代开始使用的某些形式的程序集代码,这些代码现在大部分都已经不存在了。这将发生在任何技术上,无论其自身好坏。总会有一天,没有人会再使用Java。
而另一方面,编程语言有一个广泛的范例,存在着相似的族谱。所以,如果你知道一种和另一种语言相似的语言,那么学会这种语言就很容易。
例如,Python和Ruby几乎是同一种编程语言。二者虽在文化上存在着巨大的差异,但除此之外,它们几乎完全相同,所以当你知道另一个的时候学习一个是非常容易的。因此,不要将自己与任何技术或编程语言绑定在一起,而只将它们视为帮助自己解决手头问题的工具。
九、正视并接受Bug的存在
程序员经常看到在自己开发的功能中报告了很多Bug,这意味着大多数时候,任务是失败的。但如果我们所有的程序都是功能完整的,并且没有任何Bug,那么编程就完全不成其为编程了。事实上,我们正处于编程过程中,这意味着我们要么还缺乏很多功能,要么软件有Bug。所以,在某些方面,你作为一个程序员总是失败的,因为总是存在Bug。这可能很奇怪,但你确实需要对不完美和不工作的事情保持良好的心态,因为这正是我们的工作。
编程是一个长期的过程,在这个过程中,您将一直面临新的障碍。养成记录错误的习惯,这样你以后就不会犯同样的错误了,这表明你作为一个开发人员在不断地学习和提升自己。
十、让重复性任务自动化
经常会有一些任务是需要重复做的。例如运行一组命令或执行某些活动,这些活动涉及在多个应用程序和屏幕之间切换,这会占用您的大部分时间。建议将这些耗时的日常活动转换为通过脚本或简单程序(可以通过单击或命令运行)以某种方式自动化。如针对重复的单元、接口等重复的测试执行,可以进行自动化测试。这将节省你的时间,让你可以专注于更有意义的任务,而不必担心日常繁琐的任务。
做好以上十点,相信你能够从合格的程序员,变为优秀的程序员,那么对于新技术和新知识的拥抱,就是一件水到渠成的事。“你的职责是平整土地,而非焦虑时光。你做三四月的事,在八九月自有答案。”
*参考文章:Nitish Deshpande,10Tips to Become a Software Engineer,2020.
来源:juejin.cn/post/7389931555551133732
小镇做题家必须要跨过的三道坎
其实我们大多数人都还称不上小镇做题家,因为我们大多都是来自乡村,只不过在乡镇做了上了几年的学而已。
大多数人的人生轨迹基本上都是从乡村到乡镇再到县城,最终去到了市级以上的城市读大学,没有资源,没人指路,毕业后也是一脸茫然,最终回到原点!
所以大多数小镇做题家的生活永远都是那么艰难,很难成为出题家,是因为多数人身上都出现了致命的弱点,而这些弱点多数是由原生家庭带来的。
一.自卑
自卑可以说是原生家庭带来的第一原罪,也是很难突破的一关,而导致自卑的导火索一定是贫穷。
因为我们多数人从小都被父母灌输“我们家穷,所以你不应该这样做”。
所以在进入大学后,多数人的心中都有一个魔咒,我不配拥有,不配出去玩,不配谈恋爱,不配穿好看的衣服,即使是自己打工赚的钱,多花一分都觉得有负罪感。
因为心里会想着,父母那么辛苦,我就不应该去花钱,而是要节省,当然节省并没有不好。
但是从逻辑上就已经搞错了,首先如果自己舍不得钱去学习,去投资自己,一个月挣3000能够省下2000,那么这个省钱毫无意义,因为省下的这点钱根本做不了什么,但是会把自己置入一个更深的漩涡,收入无法提升,眼界无法开阔,而是一直盯着一个月省2000这个感人的骗局。
除了用钱有负罪感,还有不敢向上社交,觉得自己是小山旮旯出来的,不配和优秀的人结伴,因为父母从小就说: 我们家条件没人家好,人家有钱,人家会瞧不起你的。所以很多人内心就已经打了退堂鼓,从而错过了和优秀的人链接的机会。
但是事实上真正优秀的人,人家从来不会有瞧不起人的心态,只要你身上有优点,你们之间能够进行价值交换,别人比你还谦卑。
我这几年从互联网行业链接到的人有1000个以上,优秀的人不在少数,我发现越优秀的人越谦虚。
自卑导致的问题还很多,比如做事扭扭咧咧,不敢上台,不敢发表意见,总是躲在角落里。
但是这个社会没有人喜欢扭扭咧咧的人,都喜欢落落大方的人,即使你的知识不专业,你普通话不好,这些都不重要,但是只要你表现出自信,大方的态度,那么大家都会欣赏你,因为这就是勇敢。
二.面子
有一个事实,越脆弱的人越爱面子,越无能的人越爱面子。
这句话虽然说起来有点不好听,但是这是事实,我们这种小镇做题家最要先放下面子。
比如自己不懂很多东西,但是不愿意去问别人,怕被别人说,在学校的时候,有问题不敢问,于是花费很多时间去弄,到最后还是搞不懂。
进入工作后,不懂的也不好意思问同事和领导,怕被别人说菜,怀疑自己。
其实真的没几个人有那个闲心去关注你的过往,你的家庭,相反越是伪装,越容易暴露。
我很喜欢的一段话: 我穷,但是我正在拼命改变、我菜,但是我可以拼命学!
面子的背后是自负,是错失,是沦陷。
三.认知
认知是一个人的天花板,它把人划分了层级。
有一段话说得很好:你永远无法赚到认知之外的钱,你也无法了解认知之外的世界。
我们多数小镇做题家的思维永远停留在老师和父母的教导:好好读书,将来就能考一个好工作,就能找一个铁饭碗。
然后在职场中听老板的:好好工作,任劳任怨,以后给你升职加薪。
然后多数人就深信不疑,就觉得人生的目标就是铁饭碗,其他的都不行,工作努力就一定能得到升职加薪,实际上这一切是在PUA自己。
当被这个社会毒打后,才发现自己是那么无知,那么天真。
而这一切的罪魁祸首就是自己认知不够,总觉得按部就班就一定能顺利过好一生。
————
自卑,面子,认知这三点是我们多数小镇做题家难以跨过的三道坎。
而这三道坎基本上都是原生家庭和教育造成的。
跨过这三道坎的方法就是逃离和向上链接。
施耐庵在几百年前就总结出的一句话:母弱出商贾,父强做侍郎,族望留原籍,家贫走他乡。
显然我们大多数都是属于第四类,所以远走他乡是唯一的选择,只有脱离原生的环境,才能打开视野。
事实也是如此,我们观察身边没有背景,没有经济条件的人,他们的谷底反弹一定是逃离。
绝非留恋原地!
来源:juejin.cn/post/7330295661784875043
web3入门:编写第一个智能合约
1. 引言
Web3 是下一代互联网,它通过区块链技术实现了去中心化。智能合约是 Web3 的核心组件之一,它们是部署在区块链上的自动化程序,可以执行预定义的操作。本学习笔记旨在介绍智能合约的基本概念、开发与部署步骤,并分享一些常见的问题及解决方案。
2. 基础知识
什么是智能合约?
智能合约是一种在区块链上自动执行的程序,具有以下特点:
- 自动化执行:无需人工干预,合约条件一旦满足,程序自动执行。
- 不可篡改:部署到区块链上的合约内容无法被篡改。
- 透明性:所有交易和代码都是公开的,任何人都可以查看。
关键工具
- Solidity:用于编写智能合约的编程语言。
- Remix IDE:在线智能合约开发环境。
- MetaMask:浏览器插件,用于管理以太坊账户并与区块链交互。
- Ganache:本地区块链模拟器,用于测试和开发。
3. 智能合约开发
编写第一个智能合约
以下是一个简单的 Solidity 智能合约例子:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract HelloWorld {
string public greeting;
constructor() {
greeting = "Hello, World!";
}
function setGreeting(string memory _greeting) public {
greeting = _greeting;
}
function getGreeting() public view returns (string memory) {
return greeting;
}
}
编译合约
使用 Remix IDE 编写和编译上述合约:
- 打开 Remix IDE。
- 新建文件并命名为
HelloWorld.sol
。 - 将上述代码粘贴到文件中。
- 选择适当的编译器版本(如
0.8.0
),点击编译按钮。
4. 部署环境设置
安装 Node.js 和 npm
sudo apt update
sudo apt install nodejs npm
安装 Truffle 和 Ganache
npm install -g truffle
npm install -g ganache-cli
创建 Truffle 项目
mkdir MySmartContract
cd MySmartContract
truffle init
配置 Truffle
修改 truffle-config.js
文件以使用本地 Ganache 区块链:
module.exports = {
networks: {
development: {
host: "127.0.0.1",
port: 7545,
network_id: "*"
}
},
compilers: {
solc: {
version: "0.8.0"
}
}
};
复制智能合约 HelloWorld.sol
在项目contracts
文件夹中创建一个新的智能合约文件 HelloWorld.sol
,将上面智能合约HelloWorld.sol内容复制过来
5. 部署智能合约
启动本地区块链Ganache
ganache-cli
编写迁移脚本
在项目migrations
文件夹中创建一个新的迁移脚本 2_deploy_contracts.js
:
const HelloWorld = artifacts.require("HelloWorld");
module.exports = function(deployer) {
deployer.deploy(HelloWorld);
};
部署合约
在项目根目录下运行以下命令:
truffle migrate
6. 互动和验证
连接到已部署的合约
使用 Truffle 控制台与合约互动:
truffle console
在控制台中执行以下命令:
const hello = await HelloWorld.deployed();
let greeting = await hello.getGreeting();
console.log(greeting); // 输出 "Hello, World!"
await hello.setGreeting("Hello, Blockchain!");
greeting = await hello.getGreeting();
console.log(greeting); // 输出 "Hello, Blockchain!"
7. 常见问题及解决方法
合约部署失败
- 检查编译器版本:确保 Truffle 配置中的编译器版本与合约代码中使用的版本匹配。
- 网络设置:确保 Ganache 正在运行且 Truffle 配置中的网络设置正确。
交易被拒绝
- 账户余额不足:确保用于部署合约的账户有足够的以太币。
- Gas 限制不足:增加 Gas 限制。
8. 总结
部署 Web3 智能合约需要掌握 Solidity 编程、开发环境设置以及与区块链的交互。通过本学习笔记,您可以了解从编写智能合约到在本地区块链上部署和测试的全过程。随着 Web3 技术的不断发展,掌握这些技能将对未来的区块链应用开发大有裨益。
项目链接地址:github.com/ctq123/web3…
来源:juejin.cn/post/7399569706183671842
前端 Element Plus 简单完美换肤方案
前言:本次新项目中,要求加一个换肤功能,要求可以换任何颜色。通过自己的摸索,总结出一套最合适且比较简单的换肤方案,我分享出来供大家参考,如有更好的方案,大家可以在评论区交流一下。
先看效果:
直接上干货,不废话
原理就是修改主题变量,在根html标签上添加内联样式变量,如图
因为style权重高,会覆盖element plus的颜色变量,这时我们只要选择自己喜欢的颜色替换就行。
我们开发时可以直接使用--el-color-primary颜色主题变量,更改主题的时候,自己的自定义组件也会随着更改,例如:
li:hover {
border-color: var(--el-color-primary);
}
上代码
我去掉了无关代码,方便大家理解
<template>
<div>主题颜色</div>
<el-color-picker
v-model="color"
@change="colorChange"
:predefine="predefine"
/>
</template>
<script setup lang="ts">
import { ref } from "vue";
import colorTool from "@/utils/theme"; //引入方法
// 换肤主题
const color = ref<string>("#409eff");
const colorChange = (value: string) => {
if (value) {
color.value = value;
}
把颜色存到本地,持久化,解决刷新页面主题丢失问题
localStorage.setItem("COLOR", JSON.stringify(color.value));
设置html标签style样式变量
document.documentElement.style.setProperty("--el-color-primary", color.value);
for (let i = 1; i <= 9; i++) {
document.documentElement.style.setProperty(
`--el-color-primary-light-${i}`,
colorTool.lighten(color.value, i / 10)
);
}
//透明
document.documentElement.style.setProperty(
`--el-color-primary-light-10`,
color.value + 15
);
for (let i = 1; i <= 9; i++) {
document.documentElement.style.setProperty(
`--el-color-primary-dark-${i}`,
colorTool.darken(color.value, i / 10)
);
}
};
if (localStorage.getItem("COLOR")) {
colorChange(JSON.parse(localStorage.getItem("COLOR") as string));
}
默认颜色板
const predefine = ref<string[]>([
"#409eff",
"#009688",
"#536dfe",
"#ff5c93",
"#c62f2f",
"#fd726d",
]);
</script>
<style scoped lang="scss">
</style>
@/utils/theme文件代码
export default {
//hex颜色转rgb颜色
HexToRgb(str: string) {
str = str.replace("#", "");
var hxs: any= str.match(/../g);
for (var i = 0; i < 3; i++) {
hxs[i] = parseInt(hxs[i], 16)
}
return hxs;
},
//rgb颜色转hex颜色
RgbToHex(a:number, b:number, c:number) {
var hexs = [a.toString(16), b.toString(16), c.toString(16)];
for (var i = 0; i < 3; i++) {
if (hexs[i].length == 1) hexs[i] = "0" + hexs[i];
}
return "#" + hexs.join("");
},
//加深
darken(color: string, level: number) {
var rgbc = this.HexToRgb(color);
for (var i = 0; i < 3; i++) rgbc[i] = Math.floor(rgbc[i] * (1 - level));
return this.RgbToHex(rgbc[0], rgbc[1], rgbc[2]);
},
//变淡
lighten(color:string, level:number) {
var rgbc = this.HexToRgb(color);
for (var i = 0; i < 3; i++)
rgbc[i] = Math.floor((255 - rgbc[i]) * level + rgbc[i]);
return this.RgbToHex(rgbc[0], rgbc[1], rgbc[2]);
},
};
这是现成完整的代码,大家可以直接拿来用。
希望本篇文章能帮到你
来源:juejin.cn/post/7399592120146313243
Flutter 3.24 发布啦,快来看看有什么更新
2024年立秋,Flutter 3.24 如期而至,本次更新主要包含 Flutter GPU 的预览,Web 支持嵌入多个 Flutter 视图,还有更多 Cupertino 相关库以及 iOS/MacOS 的更新等,特别是 Flutter GPU 的出现,可以说它为 Impeller 未来带来了全新的可能,甚至官方还展示了小米如何使用 Flutter 为 SU7 新能源车开发 App 的案例。
可以看到,曾经 Flutter 的初代 PM 强势回归之后,Flutter 再一次迎来了新的春风。
Flutter GPU
其实这算是我对 3.24 最感兴趣的更新,因为 Flutter GPU 真的为 Flutter 提供了全新的可能。
Flutter GPU 是 Impeller 对于 HAL 的一层很轻的包装,并搭配了关于着色器和管道编排的自动化能力,也通过 Flutter GPU 就可以使用 Dart 直接构建自定义渲染器,所以 Flutter GPU 可以扩展到 Flutter HAL 中直接渲染的内容。
当然,Flutter GPU 由 Impeller 支持,但重要的是要记住它不是 Impeller ,Impeller 的 HAL 是私有内部代码与 Flutter GPU 的要求非常不同, Impeller 的私有 HAL 和 Flutter GPU 的公共 API 设计之间是存在一定差异化实现。
而通过 Flutter GPU,如曾经的 Scene (3D renderer) 支持,也可以被调整为基于 Flutter GPU 的全新模式实现,因为 Flutter GPU 的 API 允许完全控制渲染通道附件、顶点阶段和数据上传到 GPU 的过程,这种灵活性对于创建复杂的渲染解决方案(从 2D 角色动画到复杂的 3D 场景)至关重要。
可以想象,通过 Flutter GPU,Flutter 开发者可以更简单地对 GPU 进行更精细的控制,通过与 HAL 直接通信,创建 GPU 资源并记录 GPU 命令,从而最大限度的发挥 Flutter 的渲染能力。
有关 Flutter GPU 相关的,详细可见:《Flutter GPU 是什么?为什么它对 Flutter 有跨时代的意义?》
如果你对 Flutter Impeller 和其着色器感兴趣,也可以看:
MacOS PlatformView
其实官方并没有提及这一部分,但是其实从 3.22 就已经有相关实现,相信很多 Flutter 开发都十分关系 PC 上的 PlatformView 和 Webview 的进展,这里也简单汇总下。
关于 macOS 上的 PlatformView 支持,其实 2022 年中的时候,大概是 3.1.0 就有雏形,但是那时候发现了不少问题,例如:
UiKitView
并不适合 macOS ,因为它本质上使用的 iOS 的 UiView ,而 macOS 上需要使用的是 NSView;所以后续推进了AppKitView
的出现,从 MacOS 的 Darwin 平台视图基类添加派生类,能力与UiKitView
大致相同,但两者实现分离- 3.22 基本就已经完成了 macOS 上 Webview 的接入支持, #132583 PR 很早就提交了,但是因为此时的 PlatformView 实现还不支持手势(触控板滚动)等支持,并且也还存在一些点击问题,所以还存于 block
所以目前 AppKitView
已经有了,相关的实现也已经支持,但是还有一些问题 block 住了,另外目前 MacOS 上在 #6221 关于 WebView 的支持上,还存在:
- 不支持滚动 API,
WKWebView
在 macOS 上不公开scrollView
,获取和设置滚动位置的代码不起作用 - 由于 macOS 上的视图结构不同,因此无法设置背景颜色,
NSView
没有与 UIView 相同的颜色和不透明度控制,因此设置背景颜色将需要替代实现
官方也表示,在完善 macOS 的同时,随后也将推出适用于 Windows 的 PlatformView 和 WebView。
而目前 macOS 上 PlatformView 的实现,采用的是 Hybrid composition 模式,这个模式看过我以前文章的应该不会陌生,它的实现相对性能开销上会比较昂贵:
因为 Flutter 中的 UI 是在专用的光栅线程上执行,而该线程很少被阻塞,但是当使用 Hybrid composition 渲染PlatformView 时,Flutter UI 继续从专用的光栅线程合成,但 PlatformView 是在平台线程上执行图形操作。
为了光栅化组合内容,Flutter 需要在在其光栅线程和 PlatformView 线程之间执行同步,因此 PlatformView 线程上的任何卡顿或阻塞操作都会对 Flutter 图形性能产生负面影响。
之前在 Mobile 上出现过的 Hybrid composition 闪烁情况,在这上面还是很大可能会出现,例如 #138936 就提到过类似的问题并修复。
另外还有如 #152178 里的情况,如果 debugRepaintRainbowEnabled 为 true ,PlatformView 可能会不会响应点击效果 。
所以,如果你还在等带 PC 上 PlatformView 和 WebView 等的相关支持,那么今年应该会能看到 MacOS 上比较完善的发布 。
Framewrok
全新 Sliver
3.24 包含了一套可组合在一起以实现动态 App bar 相关行为的全新 Sliver :
SliverPersistentHeader
可以使用这些全新的 Slivers 来实现浮动、固定或者跟随用户滚动而调整大小的 App bar,这些新的 Slivers 与现有的 Slivers 效果类似 SliverAppBar
,但具有更简单的 API 。
例如 PinnedHeaderSliver
,它就可以很便捷地就重现了 iOS 设置应用的 Appbar 的效果:
Cupertino 更新
3.24 优化了 CupertinoActionSheet
的交互效果,现在用手指在 Sheet 的按钮上滑动时,可以有相关的触觉反馈,并且按钮的字体大小和粗细现在与 iOS 相关的原生风格一致。
另外还为 CupertinoButton
添加了新的焦点属性,同时 CupertinoTextField
也可以自定义的 disabled 颜色。
未来 Cupertino 库还会继续推进,本次回归的 PM 主要任务之一就是针对 iOS 和 macOS 进行全新一轮的迭代。
TreeView
two_dimensional_scrollables
发布了全新的 TreeView 以及相关支持,用于构建高性能滚动树,这些滚动树可以随着树的增长向各个方向滚动,TreeSliver
还添加到了用于在一维滑动中的支持。
CarouselView
CarouselView
作为轮播效果的实现,可以包含滑动的项目列表,滚动到容器的边缘,并且 leading 和 trailing item 可以在进出视图时动态更改大小。
其他 Widget 更新
从 3.24 开始,一些非特定的设计核心 Widget 会从 Material 库中被移出到 Widgets 库,包括:
Feedback
Widget 支持设备的触摸和音频反馈,以响应点击、长按等手势ToggleableStateMixin
/ToggleablePainter
用于构建可切换 Widget(如复选框、开关和单选按钮)的基类
AnimationStatus 的增强
AnimationStatus 添加了一些全新的枚举,包括:
- isDismissed
- isCompleted
- isRunning
- isForwardOrCompleted
其中一些已存在于 Animation
子类中 如 AnimationController
和 CurvedAnimation
, 现在除了 AnimationStatus 之外,所有这些状态都可在 Animation 子类中使用。
最后,AnimationController 中添加了 toggle
方法来切换动画的方向。
SelectionArea 更新
SelectionArea 又又又引来更新,本次 SelectionArea
支持更多原生手势,例如使用鼠标单击三次以及在触摸设备上双击,默认情况下,SelectionArea
和 SelectableRegion
都支持这些新手势。
单击三次
- 三次单击 + 拖动:扩展段落块中的选择内容。
- 三次点击:选择单击位置处的段落块。
双击
- 双击+拖动:扩展字块的选择范围(Android/Fuchsia/iOS 和 iOS Web)。
- 双击:选择点击位置的单词(Android/Fuchsia/iOS 和 Android/Fuchsia Web)。
Engine
Impeller
为了今年移除 iOS 上的 Skia 支持,Flutter 一直在努力改进 Impeller 的性能和保真度,例如对文本渲染的一系列改进大大提高了表情符号滚动的性能,消除了滚动大量表情符号时的卡顿,这是对 Impeller 文本渲染功能的一次极好的压力测试。
此外,通过解决一系列问题,还在这个版本中大大提高了 Impeller 文本渲染的保真度,特别是文本粗细、间距和字距调整,现在这些在 Impeller 都和 Skia 的文本保真度相匹配。
Android 预览
3.24 里 Android 继续为预览状态 ,由于Android 14 中的一个错误影响了 Impeller 的 PlatformView API 支持,所以本次延长了 Impeller 在 Android 上的预览期。
目前 Android 官方已经修复了该错误,但在目前市面上已经有许多未修复的 Android 版本在运行,所以解决这些问题意味着需要进行额外的 API 迁移,因此需要额外的稳定发布周期,所以本次推迟了将 Impeller 设为默认渲染器的决定。
改进了 downscaled images 的默认设置
从 3.24 开始,图像的默认值 FilterQuality
已从 FilterQuality.low
调整为FilterQuality.medium
。
因为目前看来, FilterQuality.low
会更容易导致图像看起来出现“像素化”效果,并且渲染速度比 FilterQuality.medium
更慢。
Web
Multi-view 支持
Flutter Web 现在可以利用 Multi-view 嵌入,同时将内容渲染到多个 HTML 元素中,核心是不再只是 Full-screen 模式,此功能称为 “embedded mode” 或者 “multi-view”,可灵活地将 Flutter 视图集成到现有 Web 应用中。
在 multi-view 模式下,Flutter Web 应用不会在启动时立即渲染,相反它会等到 host 应用使用 addView 方法添加第一个“视图” ,host 应用可以动态添加或删除这些视图,Flutter 会相应地调整其 Widget 状态。
要启用 multi-view 模式,可以在 flutter_bootstrap.js
文件中的 initializeEngine
方法, 通过 multiViewEnabled: true
进行设置。
// flutter_bootstrap.js
{{flutter_js}}
{{flutter_build_config}}
_flutter.loader.load({
onEntrypointLoaded: async function onEntrypointLoaded(engineInitializer) {
let engine = await engineInitializer.initializeEngine({
multiViewEnabled: true, // Enables embedded mode.
});
let app = await engine.runApp();
// Make this `app` object available to your JS app.
}
});
设置之后,就可以通过 JavaScript 管理视图,将它们添加到指定的 HTML 元素并根据需要将其移除,每次添加和移除视图都会触发 Flutter 的更新,从而实现动态内容渲染。
// Adding a view...
let viewId = app.addView({
hostElement: document.querySelector('#some-element'),
});
// Removing viewId...
let viewConfig = flutterApp.removeView(viewId);
另外视图的添加和删除通过类的 WidgetsBinding
的 didChangeMetrics
去管理和感知:
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_updateViews();
}
@override
void didUpdateWidget(MultiViewApp oldWidget) {
super.didUpdateWidget(oldWidget);
// Need to re-evaluate the viewBuilder callback for all views.
_views.clear();
_updateViews();
}
@override
void didChangeMetrics() {
_updateViews();
}
Map<Object, Widget> _views = <Object, Widget>{};
void _updateViews() {
final Map<Object, Widget> newViews = <Object, Widget>{};
for (final FlutterView view in WidgetsBinding.instance.platformDispatcher.views) {
final Widget viewWidget = _views[view.viewId] ?? _createViewWidget(view);
newViews[view.viewId] = viewWidget;
}
setState(() {
_views = newViews;
});
}
另外通过 final int viewId = View.of(context).viewId;
也可以识别视图, viewId
可用于唯一标识每个视图。
更多可见 docs.flutter.dev/platform-in…
iOS
Swift Package Manager 初步支持
一直以来 Flutter 都是使用 CocoaPods 来管理 iOS 和 macOS 依赖项,而 Flutter 3.24 增加了对 Swift Package Manager 的早期支持,这对于 Flutter 来说,好处就是:
- Flutter 的 Plugin 可以更贴近 Swift 生态
- 简化 Flutter 安装环境,Xcode 本身就是包含 Swift Package Manager,如果 Flutter 的项目使用 Swift Package Manager,则完全无需安装 Ruby 和 CocoaPods 等环境
而从目前的官方 Package 上看,#146922 上需要迁移支持的 Package 大部分都已经迁移完毕,剩下的主要文档和脚本部分的支持。
更多详细可见 《Flutter 正在迁移到 Swift Package Manager ,未来会弃用 CocoaPods 吗?》
Ecosystem
SharedPreferences 更新
sharedpreferences 插件添加了两个新 API :SharedPreferencesAsync 和 SharedPreferencesWithCache,最重要的变化是 Android 实现使用 PreferencesDataStore 而不是 SharedPreferences。
SharedPreferencesAsync 允许用户直接调用平台来获取设备上保存的最新偏好设置,但代价是异步,速度比使用缓存版本慢一点。这对于可以由其他系统或隔离区更新的偏好设置很有用,因为更新缓存会使缓存失效。
SharedPreferencesWithCache 建立在 SharedPreferencesAsync 之上,允许用户同步访问本地缓存的偏好设置副本。这与旧 API 类似,但现在可以使用不同的参数多次实例化。
这些新 API 旨在将来取代当前的 SharedPreferences API。但是,这是生态系统中最常用的插件之一,我们知道生态系统需要一些时间才能切换到新 API。
DevTools 和 IDE
DevTools Performance 工具新增 Rebuild Stats功能,可以捕获有关在应用中甚至在特定 Flutter 框架中构建 Widget 的次数的信息。
另外,本次还对 Network profiler 和 Flutter Deep Links 等工具进行了完善和关键错误修复,并进行了一些常规改进,如 DevTools 在 VS Code 窗口内打开 和 DevTools在 Android Studio 工具窗口内打开
3.24 版本还对 DevTools Extensions 进行了一些重大改进,现在可以在调试 Dart 或 Flutter 测试时使用 DevTools Extensions ,甚至可以在不调试任何内容而只是在 IDE 中编写代码时使用。
最后
不得不说 Flutter 在新技术投资和跟进上一直很热衷,不管是之前的 WASM Native ,还是 Flutter GPU 的全新尝试,甚至 RN 还在挣扎 Swift Package Manager 的支持时,Flutter 已经初步落地 Swift Package Manager,还有类似 sharedpreferences 跟进到 PreferencesDataStore 等,都可以看出 Flutter 的技术迭代还是相对激进的。
本次更新,Flutter team 也展示了案例:
- 小米的一个小团队如何以及为何使用 Flutter 为 SU7 新能源车开发 App :flutter.dev/showcase/xi…
- 法国铁路公司SNCF Connect 在欧洲的案例,它与奥运会合作,为使数百万游客能够在奥运会期间游览法国
- Whirlpool 正在利用 Flutter 在巴西探索新的销售渠道
- ·····
另外,2024 年 Fluttercon 欧洲举办了首届 Flutter 和 Dart 生态系统峰会,具体讨论了如:
- FFI 和 jnigen/ffigen 缺少更多示例和文档
- method channels 调试插件的支持
- 合并 UI 和平台线程的可能性
- 研究减轻插件开发负担的策略
- 解决包装生态系统碎片化问题
而接下来 9 月份 Fluttercon USA 也将继续在纽约召开深入讨论相关主题,可以看到 Flutter 正在进一步开放和听取社区开发者的意见并改进,Flutter 虽然还有很多坑需要补,但是它也一直在努力变得更好。
所以,骚年,你打算更新 3.24 吃螃蟹了吗?还是打算等 3.24.6 ?
来源:juejin.cn/post/7399952146236571685
为什么很多程序员会觉得领导没能力
相信很多人在职场里待久了,都会遇到自己觉得比较差劲的领导,这些人可能除了向上管理能力很强外(会舔老板),其他能力在你看来都挺一般,专业能力一般,超级缝合怪--上级给他的任何任务他都能分配给你们,然后他再缝合一遍完事。
那么遇到这种领导我们该怎么办呢?多数人想到的是跳槽,这确实是一个解法,但你跳到下家公司也保不齐会有这样的领导呀,今天咱们讨论的这个话题就先把条件限定成你不能跳槽,这个时候你该采用什么方法让自己的上班体验变好一些。
多元化自己的评估标准
首先,不能用鄙视的眼光去看待你的领导,觉得他只会舔老板(能舔、会舔也是一种很强的能力呀),有的时候你觉得你领导能力不行,很有可能是因为你的能力评估标准太单一了。
他或许在工作的某个方面不如你,但是他必定在某些方面有自己的长处,努力发现他的长处,认可他的长处,学习他的长处,可以更有助于你和他的相处,也有利于你的进步。
社会是一个大熔炉,你需要的不仅仅是业务能力和展现的舞台,也需要与社会中不同个体的和谐共处。包容、接纳,都是立身处世的能力。
学会变通和沟通,满足领导的存在感
领导之所以会在很多工作上提意见、瞎指挥、乱指挥,更多的情况可能是他知道自己对工作不熟悉,但觉得自己是领导,会有自己独特的见解,想刷自己的存在感。这种情况下,要学会满足领导的存在感。
举个例子说,你在工作中,领导过来给你提了个意见,这个意见明显是不合适的,那你就可以说,“领导,这个思路好,我们之前没往这个角度想,可以从这个角度延展一下……。”他走了,还不是我们自己把控,毕竟他只是过来刷个存在感的,只要最后的方案让客户满意,业绩给领导,把一些光环放在他身上,让他觉得他起到了作用,这些方案和他有关,他通常也不会计较了。
摸清领导管理的思想和套路
说到这里,找到领导心中的关键因素,是非常必要的。在一个项目里,员工承担的通常只是局部,而领导看的是整体,由于高度不同,所以你们考虑的关键因素是不同的。
所以你要知道领导心里到底想要的是什么,提前做好这方面的预期和准备,以及针对领导提出的你们没有考虑到的方面要虚心接受(毕竟领导跟高层接触的更多,有些战略方向上的事情他们会更清楚)。
比如说,你是一个小编,你在意的是按时完成写作任务、及时发表、赚取眼球,而你的领导主编可能更在意的是你文章的各种数据真实性、转化人群、是否会产生舆情、是否zzzq这些。所以,要搞清领导在意的重要维度,工作才能更有效。
这里有三句话分享给大家:
- 要能够分清你可以改变的事、无法改变的事;
- 不去抱怨你服务改变的事;
- 把精力用在你可以改变的地方。
你的上司,是你改变不了的,但你自己,是可以把握的。当然这篇文章也不是教你怎么委屈自己,只是提供一个不同的角度来讨论"领导不行” 这个事情,以及让你在无法立刻更换环境时,该怎样让当前的环境变得不那么恶劣。
想跳槽的同学还是应该按部就班的准备,骑驴找马有更合适的地方该跳就跳,跳过去了说不定今天学到的这些还能用的上……。
来源:juejin.cn/post/7357911195946336293
书评:细读《我与地坛》看史铁生向死而生的自我救赎
读者前言
以前应该是读过《我与地坛》,可能是在课文里或者在网上推荐的断断续续的章节,它实在太有名了。印象中这好像是一个残疾人的励志故事,这次仔细读后才发现,励志并非它的论调,在故事的最开头就已注定绝望的现实将贯穿全文,也贯穿史铁生的一生。
我在想如果它真的仅是一个励志的故事,那史铁生将如何才能扳回这一局?他要以什么结局收尾才能算圆满?难道他要从一个痛苦的残疾人,最终变成一个充实而快乐的残疾人?
最终我认为《我与地坛》绝非一个励志的故事,它甚至没有刻意传达史铁生的人生观,它只是赤裸裸的展示发生在一个人身上的现实;它只是平静的叙述史铁生在这段苦难岁月中的痛苦、挣扎、崩溃与救赎;它只是一遍一遍又一遍的自我剖析、思考、论证,妄图推翻重塑一个全新的人生观和一个全新的自己。
文章摘要
出版的《我与地坛》书籍是史铁生多部散文诗歌的合集,而《我与地坛》本身只是一篇散文,而且内容是以故事叙述、内心活动、实景描写为主,只有一万多字,非常容易读。
文中分了七个章节,讲述了史铁生在“最狂妄的年龄上忽地残废了双腿”之后,“摇着轮椅”与地坛公园长达十五年相依相伴的故事。在这漫长的岁月里,史铁生独自一人守在园中,在矛盾与挣扎中反复叩问生命的意义。
第一节:我与地坛
地坛就是以前北京还未开发的地坛公园,“园子荒芜冷落得如同一片野地,很少被人记起”,但随着史铁生“忽地残废了双腿”,在十五年前的一个下午,摇着轮椅进入园中,便正式开启了“我与地坛”的篇章。在园子中史铁生开始思考生与死的问题。
在满园弥漫的沉静光芒中,一个人更容易看到时间,并看见自己的身影。
突然的残疾让史铁生近乎绝望,他在痛苦中纠结要不要去死,这样活着还有什么意义。
我一连几小时专心致志地想关于死的事,也以同样的耐心和方式想过我为什么要出生。
就这样几年后他终于明白了,死是一个必然会降临,而不必急于求成的事。这也解决了他的“燃眉之急”,减轻了他在选择生或死的挣扎的痛苦。
一个人,出生了,这就不再是一个可以辩论的问题,而只是上帝交给他的一个事实;上帝在交给我们这件事实的时候,已经顺便保证了它的结果,所以死是一件不必急于求成的事,死是一个必然会降临的节日。这样想过之后我安心多了,眼前的一切不再那么可怕
第二节:母亲
度过了最初痛苦挣扎的几年后,恢复了人气的史铁生,也逐渐回忆起与母亲的生活,儿子经历巨大不幸,母亲的心碎与痛苦也只能隐忍在心里,而当时史铁生沉浸在巨大痛苦中,经常忽视和冷落了母亲。
现在我才想到,当年我总是独自跑到地坛去,曾经给母亲出了一个怎样的难题。
当我不在家里的那些漫长的时间,她是怎样心神不定坐卧难宁,兼着痛苦与惊恐与一个母亲最低限度的祈求。
那时她的儿子还太年轻,还来不及为母亲想,他被命运击昏了头,一心以为自己是世上最不幸的一个,不知道儿子的不幸在母亲那儿总是要加倍的。
有一回我摇车出了小院,想起一件什么事又返身回来,看见母亲仍站在原地,还是送我走时的姿势,望着我拐出小院去的那处墙角,对我的回来竟一时没有反应。
母亲独自照料史铁生多年,直到猝然离世,最终也没能看到史铁生重新回归生活,并在文学上取得如此卓越的成就,这也成为史铁生往后的日子永远无法弥补的遗憾。
有一回我坐在矮树丛中,树丛很密,我看见她没有找到我;她一个人在园子里走,走过我的身旁,走过我经常待的一些地方,步履茫然又急迫。我不知道她已经找了多久还要找多久,我不知道为什么我决意不喊她——但这绝不是小时候的捉迷藏,这也许是出于长大了的男孩子的倔强或羞涩?但这倔强只留给我痛悔,丝毫也没有骄傲。我真想告诫所有长大了的男孩子,千万不要跟母亲来这套倔强,羞涩就更不必,我已经懂了可我已经来不及了。
母亲为什么就不能再多活两年?为什么在她的儿子就快要碰撞开一条路的时候,她却忽然熬不住了?莫非她来此世上只是为了替儿子担忧,却不该分享我的一点点快乐?她匆匆离我去时才只有四十九岁呀!有那么一会儿,我甚至对世界对上帝充满了仇恨和厌恶。
多年来我头一次意识到,这园中不单是处处都有过我的车辙,有过我的车辙的地方也都有过母亲的脚印。
这节里的很多描述,无论是追忆细节亦或内心独白,都极为情真意切,遗憾之情,溢于言表。
第三节:园中四季
史铁生用了非常多的比喻,能将四季比喻成园中的任何东西,十五年的共处,史铁生早已将这个园子与自己融为一体,既是困住自己的牢笼,亦是拯救他的避风港
我甚至现在就能清楚地看见,一旦有一天我不得不长久地离开它,我会怎样想念它,我会怎样想念它并且梦见它,我会怎样因为不敢想念它而梦也梦不到它。
第四节:园中十五年
园子中的十五年,这里曾经的每个人他都记忆犹新,史铁生成为园子中的一部分,仔细观察着这十五年园中的事物更迭,有的人是他多年老友,有的人只有几面之缘,或忽的消失就再也没见。
- 一对老夫妻
- 热爱唱歌的小伙子
- 酗酒的老头
- 捕鸟的汉子
- 朴素优雅的女工程师
- 练长跑的朋友
第五节:智力缺陷的小姑娘
最让史铁生痛心的是一个漂亮的有智力缺陷的小姑娘,可能因为史铁生作为残疾人更能感同身受,更能理解这个世界对他们的不公。被一群小孩欺负的弱智的小女孩,同时也是被迫承受这个世界恶意的他自己。
她呆呆地望着那群跑散的家伙,望着极目之处的空寂,凭她的智力绝不可能把这个世界想明白吧。
哥哥把妹妹扶上自行车后座,带着她无言地回家去了。
无言是对的。要是上帝把漂亮和弱智这两样东西都给了这个小姑娘,就只有无言和回家去是对的。
史铁生并未抱怨这个世界的降临的诸多苦难,因为他看透这个世界的本质,这苦难是无论如何也无法“消灭”的。
假如世界上没有了苦难,世界还能够存在么?要是没有愚钝,机智还有什么光荣呢?要是没了丑陋,漂亮又怎么维系自己的幸运?要是没有了恶劣和卑下,善良与高尚又将如何界定自己又如何成为美德呢?要是没有了残疾,健全会否因其司空见惯而变得腻烦和乏味呢?我常梦想着在人间彻底消灭残疾,但可以相信,那时将由患病者代替残疾人去承担同样的苦难。如果能够把疾病也全数消灭,那么这份苦难又将由(比如说)相貌丑陋的人去承担了。
史铁生深刻的认识到,这可能就是人类的全部剧目,人类有聪明、漂亮、善良、高尚、健全,同时也一定需要愚钝、丑陋、卑鄙、残疾,即使未来能消灭残疾,这种苦难也将由患病者承担,即使疾病也被消灭,那苦难可能也会由相貌丑陋者承担。
因为这个世界本就需要苦难,失去差别的世界将是一潭死水。
于是就有一个最令人绝望的结论等在这里:由谁去充任那些苦难的角色?又由谁去体现这世间的幸福、骄傲和欢乐?只好听凭偶然,是没有道理好讲的。
就命运而言,休论公道。
史铁生终于能接受苦难,虽然充满无奈,但他不会再去抱怨命运的不公,虽然他也是苦难的承受者,但他最终承认“上帝又一次对了”。我觉得从这里能看出,史铁生是真的从心底释怀了。
第六节:人生意义
史铁生在十五年里对生命意义的叩问,总结成三个问题,在反复的纠结与推演中也终于有了答案。这段反复拉扯的心理活动属实是非常真实了。真的很有意思。
其实总共只有三个问题交替着来骚扰我,来陪伴我。第一个是要不要去死,第二个是为什么活,第三个,我干吗要写作。
你看穿了死是一件无须乎着急去做的事,是一件无论怎样耽搁也不会错过的事,便决定活下去试试。
为什么要写作呢?
为了让那个躲在园子深处坐轮椅的人,有朝一日在别人眼里也稍微有点儿光彩,在众人眼里也能有个位置,哪怕那时再去死呢也就多少说得过去了。
要是有人走过来,我就把本子合上把笔叼在嘴里。我怕写不成反落得尴尬。我很要面子。
可是你写成了,而且发表了。
人家说我写的还不坏,他们甚至说:真没想到你写得这么好。我心说你们没想到的事还多着呢。我确实有整整一宿高兴得没合眼。
这一来你中了魔了,整天都在想哪一件事可以写,哪一个人可以让你写成小说。
结果你又发表了几篇,并且出了一点儿小名,可这时你越来越感到恐慌。
我忽然觉得自己活得像个人质,刚刚有点儿像个人了却又过了头,像个人质。
你担心要不了多久你就会文思枯竭,那样你就又完了。
凭什么我总能写出小说来呢?
我为写作而活下来,要是写作到底不是我应该干的事,我想我再活下去是不是太冒傻气了?
恐慌日甚一日,随时可能完蛋的感觉比完蛋本身可怕多了
我想人不如死了好,不如不出生的好,不如压根儿没有这个世界的好。
可你并没有去死。我又想到那是一件不必着急的事。
可是不必着急的事并不证明是一件必要拖延的事呀?
你总是决定活下来,这说明什么?是的,我还是想活。
人为什么活着?因为人想活着,说到底是这么回事,人真正的名字叫作:欲望。
所以您得知道,消灭恐慌的最有效的办法就是消灭欲望。可是我还知道,消灭人性的最有效的办法也是消灭欲望
从开始想为什么不死,到为什么写作,到说服自己开始写,从偷偷写-->发表的兴奋-->玩命写-->文思枯竭的恐慌-->见好就收想放弃-->仍然咬牙坚持-->不明白为何坚持,最终终于想清楚自己其实虽然“不怕死“但也”不想死”,因为人真正的名字叫做欲望。接受了这份欲望,虽然有些苟活的羞愧,但想清楚反而坦然接受了,坦然接受了自己想活着的欲望,也坦然接受了写作就是为了活着,彻底卸下了包袱。
我觉得这段心路历程描写的极为精彩生动,史铁生的内心反复拉扯,矛盾挣扎,活下去的欲望与对现实的恐惧的强烈冲突,我觉得很多受困于自己的人可能都会对这种矛盾非常感同身受,我希望大家都能像史铁生这样,最终坦然的接受。
没有人天生要去当圣人,只是为了更好的生活而已。
当然,很多时候需要很大的勇气,需要痛苦而深刻的反省与自我剖析,可你一旦坦然接受,未来也将是真正的坦途。
第七节:归宿
我忽然觉得,我一个人跑到这世界上来玩真是玩得太久了。
那时您可以想象一个孩子,他玩累了可他还没玩够呢,心里好些新奇的念头甚至等不及到明天。也可以想象是一个老人,无可置疑地走向他的安息地,走得任劳任怨。还可以想象一对热恋中的情人,互相一次次说“我一刻也不想离开你”,又互相一次次说“时间已经不早了”
我说不好我想不想回去。我说不好是想还是不想,还是无所谓。
那时他便明白,每一步每一步,其实一步步都是走在回去的路上。
当牵牛花初开的时节,葬礼的号角就已吹响。
但是太阳,他每时每刻都是夕阳也都是旭日。当他熄灭着走下山去收尽苍凉残照之际,正是他在另一面燃烧着爬上山巅布散烈烈朝辉之时。有一天,我也将沉静着走下山去,扶着我的拐杖。那一天,在某一处山洼里,势必会跑上来一个欢蹦的孩子,抱着他的玩具。
当然,那不是我。但是,那不是我吗?
宇宙以其不息的欲望将一个歌舞炼为永恒。这欲望有怎样一个人间的姓名,大可忽略不计。
到最后一章,我觉得史铁生已经完全想清楚了,不在痛苦与纠结,知行合一,豁达而通透,他内心变得强大而坚韧。他自己一点点塑造起来的内心世界已经可以完全兼容现实世界,他不再去纠结要不要去死,或者追寻生的意义,他也坦然接受了写作这项对他生命非常有意义的工作。
并且他也更坦然的接受了未来最终会走向死亡终点的结局。
《我与地坛》到此完结。
关于读后感
我最直接的感触就是:平静而有力量。
虽然在描述苦难,但通篇并不沉重。文字朴素平实,将漫长的十五年的所思、所感、所经历浓缩成的一篇文章,要表达的内容、感情、哲理其实极为丰富和深刻,但文字既不卖弄也不含蓄,就是很平静的阐述,通俗易懂却回味无穷,特别适合反反复复去读。我对文学并无研究,我猜这可能就是文章的内涵吧。
我相信史铁生同样也是一位平静而有力量的作家,我极为敬佩。这也是我一直以来非常想成为的人,我也总在探寻生命的意义,但我内心远没有这么强大,我常会有类似的矛盾,有时想逃避,或者企图找到一个人生终极的方法论,让我能充实幸福的过完这一生。
但我发现这世界并不存在一个唯一终极真理。我们需要用漫长的时间,不断的经历,不断的自我剖析,思考总结,才能得到一个适合自己的真理和与之匹配的心境。
没有经历足够的体验,和这个反复探索的过程,即使聆听再多再深刻的真理,也依然过不好这一生。
如史铁生文中说:
设若智慧或悟性可以引领我们去找到救赎之路,难道所有的人都能够获得这样的智慧和悟性吗?
终究绝大多数人终生都会被困于自己的牢笼,这世界绝大多数人是没有这样的智慧和悟性的,不过有个好消息就是大多数人也不会经历这样大的苦难,虽然生活有时会给我们带来很多烦恼、遗憾、难过,但同样也会有带来很多温馨、甜蜜、快乐,看来上帝还没那么残忍的。
关于人生意义
人生意义这个话题太过深刻,每个人都有不同的答案。简单聊下:
- 为什么要寻找人生的意义?
人生的意义其实就是一个终极方法论,来指导我们要以什么样的态度,如何过完这一生。
因为人的一生实在太过漫长,我们需要一个【基座】来【支撑】住,让我们过的充实,否则在人生的很多时刻我们容易陷入一种【虚无、虚妄】,这种【虚无】吞噬人心,会让我们觉得整个人生都没有意义。
所以人为什么要思考人生的意义,是为了人类的进步亦或找到人类存在这宇宙的终极秘密吗?其实并不是,只是我们需要它,它能让我们的生活过的更【踏实】
- 如何去寻找人生的意义?
其实不外乎几个方面,代表现代人不同的观念:
- 个人成就与自我实现:通过追求个人目标和梦想来寻找生活的意义。包括事业、提高技能、创造性表达或个人发展。
- 人际关系与社会联系:与家人、朋友、同事和社区建立深厚的关系,为许多人提供了生命的重要意义。通过各种关系,人们可以感受到归属感、爱和支持。
- 精神与宗教信仰:很多人通过宗教信仰或精神的超过寻找人生意义。宗教信仰可以提供一套价值观和生命意义的解释,给予人们心灵的慰藉和方向。
- 责任、奉献与付出:通过帮助他人和为社会做出贡献,找到了生活的意义,家庭责任、社会奉献甚至报效国家。
- 感官体验、自由与权利:追求丰富的体验,比如要去吃没吃过的美食,看没看过的风景,人生不过一场体验,不如潇潇洒洒也算不枉此生。
那什么是对,什么是错,哪个高级,哪个低级呢?
不要忘了我们寻找自己人生的意义,最终目的是为了让自己过的更【踏实】,而不是为了正确答案。与其说哪个更好,不如说哪个能撑得住你的人生,如果你觉得感官体验,吃美食,看风景就是你的人生目标,那你就去做就好了。
只是因为我活着,我才不得不写作。或者说只是因为你还想活下去,你才不得不写作。
在地坛公园十五年,史铁生最终给自己人生意义的答案就是:写作。
文字的力量
为什么《我与地坛》句子很平实,读起来却总有一种厚重感?我觉得是因为史铁生所描述的视角,常是突破了人的局限,跨越了时间的。
人类最大的局限性莫过于时间,因为我们只能活在当下的某个时间节点,所以我们很难跨越时间去思考问题,这导致我们普通人在思考人生意义这种深刻的命题时,答案往往显得浅薄而幼稚,我们总会在未来的某个时刻推翻过去的所思所想。
我们更常在历史书中体会这种厚重感,短短几行字可能就是一个王朝的百年兴衰,我们感到震撼和唏嘘。《我与地坛》同样会有这种感觉,试问谁又能花十五年的时间去观察一个园子的变化呢?
当时间被慢慢拉长,我们会看到一个个过去【因】链接到了未来的【果】。因果之间错综复杂的链接,在时间的加持下一览无遗,我们会感叹事物的变化会如此复杂,一个很简单的事物,也会变得深刻而有哲理。
我想作为被时间局限住的人来说,这种跨越时间的观察与思考才是我们需要的,我们才能从这复杂的变化中,总结出深刻的哲理,来支撑我们漫长的人生
推荐
《我与地坛》这本书很适合反复阅读,浓缩了史铁生十五年的心路历程,如果你目前也处在人生比较迷茫的阶段,想要走出困境,我推荐你读一下这本书,我相信你能从中获得想要的力量。
这本书不仅仅浓缩了史铁生十五年的所思所想所感,更是对现代的生活方式、生命的意义、人生观、价值观、人性和欲望、生与死进行了非常深刻的剖析,立意深远,这种深刻的问题是人类永恒的话题,所以我相信它将流传的很久,给每代人带去思考与力量。
相关文献
来源:juejin.cn/post/7303798788719198245
为什么我们总是被赶着走
最近发生了一些事情,让shigen
不禁的思考:为什么我们总是被各种事情赶着走。
一
第一件事情就是工作上的任务,接触的是一个老系统ERP,听说是2018年就在线上运行的,现在出现问题了,需要我去修改一下。在这里,我需要记录一下技术背景:
ERP系统背景
后端采用的是jfinal框架,让我觉得很奇葩的地方有:
- 接受前端的参数采用的HashMap封装,意味着前端字段传递的值可以为字符串、数字(float double)
- 仅仅一个金额,可以有多种形式:1111.001,1,111.001
- 格式化 1.00000100 小数点保存8位,这样的显示被骂了
- 数据库采用的是oracle,jfinal的ORM工具可以采取任何的类型存入数据表的字段里,我就遇到了‘1.1111’字符串存入到定义为double的字段中
- 原来的设计者存储金额、数量全部采用 flaot、double,凭空出现0.0000000000000001的小数,导致数量金额对不上
- 小数位0.00000000001 会在前端显示成1-e10,直接在sql上格式化
- sql动辄几百行,上千行,各种连表
- sql还会连接字典表,显示某个值代表的含义
- ……
前端不知道啥框架,接近于jquery+原生的js
- 每改一段代码,都需要重启后端服务
- 各种代码冗余
- 后端打包一次40分钟+
- ……
最关键的是:所有的需求口头说,我也是第一次接触,一次需求没理解,被运维的在办公室大声批评:你让用户怎么想?
后来,需求本来要半个月完成,拖了一个月才勉强结束。一次快下班的时候出现了问题,我没有加班,也因为遇到了问题没人帮忙。第二天问进度,没进展,领导叫去看会,说态度不好。后来换组了……
二
第二件事情就是我的公众号更新问题,我在八月份的时候个自己定了一个目标:公众号不停更。到最近一段时间发现:很难保持每天更新的需求了。因为我接触到的技巧很少,每篇文章的成本也很大。就拿我的某个需求为例,我需要先把代码写出来,测试完成之后再去写文章,这整个过程最低也需要两个小时的时间。成本很大,所以我有一次很难定顶住这个压力,推荐了往期的文章。
我也经常关注一些技术类的博客,看他们写的文章发现部分的博客都是互相抄袭的,很难保持高质量。更多的是在贩卖焦虑,打广告。
我希望我的每一篇文章都是有意义的,都是原创的、有价值的。所以,我也在陷入了矛盾中,成本这么大,我需要改变一下更新的节奏吗?
三
最后一件事情就是:我感冒了。
事情是这样的,一连几天没有去跑步了,家里的健腹轮也很少去练了,除了每天骑行了5公里外,我基本没有啥运动量。我以为我吃点维生素B、维生素C我的体质就会好一点,大错特错了。
周一发现嗓子有点干痒疼,晚上还加了班,睡觉的时候已经是凌晨一点了。周二就头很晕、带一点发热的症状,我赶紧下午去医院,在前台测了一下体温,直接烧到了28.4摄氏度。血常规检测发现是病毒性感染,买了两盒药回来了。下午一直在睡觉,睡到了十一点。
也在想:难道我的体质真的这么差吗?如果我坚持那几天戴口罩,坚持运动会不会好一些。我想到了我的拖延症。
我的dock栏永远是满的,各种软件经常打开着,Java、数据库,总是有很多的事情要去做,很忙的样子,最后发现没时间去运动了。一次健腹轮的运动不到十分钟,我都没有去行动。
这次的感冒,让我更加的重视起我的健康了,也让我觉得我丧失了主动性,总是被生活赶着走。
所以,提到了这么多,涉及到了任务的规划、任务中的可变因素……我觉得除了计划之外,更多的是需要保持热爱。不仅仅是热爱生活、热爱运动、热爱事业,更是热爱自己拥有的一切,因为:爱你所爱,即使所爱譬如朝露
。
来源:juejin.cn/post/7280740613891981331
谷歌浏览器禁用第三方Cookie:独裁者的新工具?
2024年,Chrome将要正式禁用第三方Cookie了,这个变化对Web开发者来说是非常重要的,因为它将改变开发者如何设计网站以及如何搜集和使用用户数据。这是怎么一回事,到底有什么具体影响?
什么是Cookie?
随着互联网技术的发展,我们的生活变得越来越数字化,网上购物、社交、阅读新闻成为日常。而在这个数字化的世界中,Cookie扮演了一个不可或缺的角色。
Cookie是一种由浏览器保存在用户电脑上的小块数据,用来帮助网站记住用户的信息和设置。网站可以在前端直接操作Cookie,也可以根据服务器返回的指令设置Cookie,当浏览器请求同一服务器时相应的Cookie会被回传。Cookie让网站能够记住用户的登录状态、购物车中的商品、以及个性化设置等,极大地提升了用户体验。
第三方Cookie是咋回事?
然而,Cookie并非只有一种。其中,第三方Cookie与网站直接设置的第一方Cookie不同,它们通常由第三方广告商或网站分析服务设置。网站一般通过在前端页面引入第三方的Javascript程序文件来实现这种能力。
用于网站分析时,Cookie可以收集和存储有关用户访问网站的数据,如用户的浏览历史、停留时间、点击行为等。这些数据对于网站运营者来说非常有价值,可以帮助他们了解用户的行为和兴趣,优化网站的设计和功能,提升用户的体验。
用于广告时,Cookie可以用来追踪用户在不同网站上的行为,以便提供个性化广告和内容。这种广泛的追踪能力让广告商能够了解用户的喜好和习惯,从而推送更加精准的广告。
为什么要禁用第三方Cookie?
但第三方Cookie也引发了隐私方面的担忧。许多用户和隐私倡导者认为,广告商利用Cookie追踪用户的行为侵犯了个人隐私。
很多同学应该有这样的体验:你在某个网站搜索了某个东西,然后访问其它网站或服务时,网页会向你展示之前搜索过的类似东西。基于某些原因,有时候我们并不想这样被跟踪。
这种担忧导致了对第三方Cookie的禁用呼声。同时隐私担忧不仅仅来自于用户和隐私倡导者,也来自于法规如欧盟的通用数据保护条例(GDPR)和加州消费者隐私法案(CCPA)这样的法律要求。
为了应对这一问题,苹果公司在其Safari浏览器中率先禁用了第三方Cookie,微软和Mozilla也采取了类似措施。
谷歌的行动计划是什么样的?
但是,作为浏览器市场占有率第一的谷歌,却迟迟没有明显的动作,遭到了不少人的非议和批评。
为了解决这些问题,谷歌提出了“隐私沙盒”(Privacy Sandbox)计划,旨在开发一系列新的技术,既能保护用户隐私,又能支持广告商进行有效的广告投放。
想象一下,如果有一个中立的场所,既能让你安心地存放你的个人物品,又能让有需要的人在不侵犯你隐私的情况下了解你的需求,这就是隐私沙盒的理念。
例如,谷歌提出的FLoC(Federated Learning of Cohorts)技术,就是将用户分群,而非单独追踪个人行为,从而在保护个人隐私的同时提供群体级的广告定位。
作为市场份额最大的浏览器,谷歌计划在2024年1月开始逐步禁用第三方Cookie,在2024年第三季度完成第三方Cookie的全面禁用。
禁用导致的问题有哪些?
禁用第三方 Cookie 对网站主、广告商和用户都会产生一系列影响。针对不同群体的影响及解决方案如下:
网站主
第三方 Cookie 的禁用可能会让网站主失去对用户行为的深入分析能力,因为他们将不能再依靠第三方 Cookie 来追踪用户在多个网站上的活动。这可能会影响到网站的个性化服务和广告定位的准确性,进而影响网站收入。
解决方案:网站主可以更多地依赖第一方 Cookie,即直接由网站域设置和读取的 Cookie。这些 Cookie 可以帮助网站主跟踪用户在自己网站上的行为,而不越过隐私边界。此外,网站主可以通过提供更多的价值服务来鼓励用户主动分享信息。
对于拥有多个域名的网站主来说,可以使用同一个根域名来设置Cookie,这样在根域名下的所有子域名都可以访问这些Cookie,这种方法仍然在用户隐私保护的框架内。如果网站主拥有多个相关联的服务,可以实施更安全的单点登录解决方案,比如使用会话令牌和OAuth等协议。
广告商
广告商将难以像过去那样追踪用户在整个互联网的行为,从而无法进行精准的广告定向,这可能导致广告效果下降和收入减少。
解决方案:谷歌提出的隐私沙盒计划中,Event Conversion Measurement API 是一种技术解决方案,它允许广告商在不侵犯用户隐私的情况下测量广告转化率。此外,广告商还可以利用机器学习等技术,通过分析大量的匿名化和聚合数据来预测用户兴趣。不过广告商也有机会使用一些更隐蔽的技术追踪手段来保持原有的业务模式,具体一些技术下文会提到。
用户
用户的隐私得到更好的保护,但可能会失去一些基于个性化广告的便利性,例如推荐系统的准确性可能会下降。
解决方案:用户可以获得更多的隐私控制权,例如通过浏览器设置来决定哪些数据可以被网站使用。同时,随着技术的发展,用户可能会遇到更多基于隐私保护的个性化体验,例如使用本地算法来进行内容推荐,而不需要将个人数据传输到服务器。
谷歌真的做好了吗?
尽管谷歌提出了Privacy Sandbox这样的计划,希望在不依赖个人用户信息的情况下,仍然能够支持广告生态系统,但实际上这个计划也遭到了一些批评。批评者认为,这些新提出的技术可能仍然允许用户被追踪,只不过追踪的方式更加隐蔽了。
例如,FLoC的提出本意是为了代替传统的个人定向广告,它通过对用户进行群组化来推送广告,这样不会直接暴露个人的行为数据。但问题在于,群组化的数据仍然可能被用来间接识别和追踪个人,特别是当某个群组里的用户数量不多时。这就导致了一种新形式的隐私问题,即“群组隐私”的泄露。
还有广告服务商仍可能通过一些技术手段突破隐私限制,比如通过网站转发、Canvas指纹技术、网络信标、用户代理字符串、本地存储和ETag跟踪等。
此外,一些隐私倡导者还担心,谷歌作为一个广告公司,其提出的隐私解决方案可能偏向于维护其在在线广告市场中的主导地位。他们认为,谷歌有动机设计一套系统,搜集用户在搜索、YouTube和其他谷歌服务上的行为数据,使得自家的广告网络相比其他竞争对手拥有优势。而这可能会影响到广告市场的公平竞争,甚至可能对开放网络生态系统构成威胁。
总的来说,谷歌在隐私问题上的努力是一个积极的开始,但隐私保护的路还很长。而对于技术人员而言,理解这些变化和挑战,以及如何在保护用户隐私的同时提供优质服务,将是未来发展的一个重要课题。
关注萤火架构,提升技术不迷路!
来源:juejin.cn/post/7313414896783769609
关于菜狗做了一个chrome小插件

前言
很多时候,老是质疑反问自己,在空闲时间都在干嘛呢?是沉迷于看动漫、美女视频,打游戏?还是在追求更有意义的事呢?自我回答,没错我是属于前者了(dog),然后前后左右思考,还是决定找一些事做,不能一直这么荒废了,起码做一些是一些,积少成多!但是能做什么呢?这又引起我这不聪明的大脑深深的思考,是从自己从事的职位来,还是从自己的兴趣来呢?然后在这段迷茫的时间一直在寻找中,突然有一天看到别人的文章或视频感触深刻,最后还是敲定做项目!然后就着手开始准备做起来,就这么愉快决定行动起来了,奥利给!
于是利用上班摸鱼时间和下班空闲的时间开始行动起来~
起源
在空闲的摸鱼时间里,我经常喜欢看别人写的文章,读完一篇又一篇,大多数时候都会感叹并羡慕。然鹅,特别是对于某些事情,我会有特别深的感触。因为有时候的情绪只是在特定时刻被触发的,所以我想记录下当时的触发感受和情绪。然后我就去寻找相关的插件,结果找到了一个相当不错的插件。试用了一番后,发现效果还不错,于是我决定将关注点留在这里,尝试着制作这类插件,看看自己做的的效果如何。有时候我在想,为什么要费力自己创建一个插件呢?毕竟市面上已经有现成的插件了,为什么不用呢?然而,答案很明了:一是想要找点事情做,二是想要提升一下自己的技能水平。于是,这样的动力激励着我行动起来。
需求
产品需求其实很明确,因为从我的角度来看已经有现成的产品可供参考,然后只需根据个人需求进行定制。因此,产品的主要目的是为了方便我们的生活。
因此,一个产品的设计应当能够满足不同用户的需求,以提供更好的使用体验。这里着重考虑我自己个人使用,因为每个人的习惯和偏好都不尽相同。
收集的功能需求:
- 对内容进行划线
- 记录想法/感想
- 统计列表数据
- 预览原文(主要是针对文章)
- 拷贝(划线、原文、Markdown)内容
- 下载(划线TXT、原文TXT、原文Markdown)内容
目前第一版只涵盖了这些功能需求(感谢ChatGPT,它帮助解决了我作为菜狗的许多问题),后续将根据需要情况进行调整和完善~
下面是完成的功能截图:
突然发现我开发这个插件还有一个小用处哈哈哈,针对类似我这种不会写文章格式的小白来说,有时候真的不知道该如何开始写。但有一个现成的文章作为参考,真是太棒了!它不仅能够给我提供灵感,还能够帮助我了解文章的结构和写作风格,让我更加自信地面对写作挑战!
拷贝的文章markdown格式:
最后打算发版到chrome,目前正在审核中~
总结
可能我是一个老老实实上班族的一个菜狗,但是勇于尝试也挺好的,毕竟在尝试中,我能够不断学习、成长,迎接新的挑战,拓展自己的能力和视野。
历经三个月的努力,终于完成了第一版。其实本来应该在一个多月内完成的,但中途我可能有些懒惰哈哈。虽然我觉得自己做的东西还不尽如人意,但从某种角度来说,至少我迈出了那一步。哪怕是简单的东西,也是通过自己制造出来的,多多少少都有一些成就感。而且,这个过程也丰富了我的技术栈。因此,我在开始思考如何完成一个要好的项目,是否可以继续打造一款完整的产品,让人们使用,如果有人使用我的产品,我也会感到非常满足!
最后思考
1、有时候,当你不知道该做什么时,一定不要让自己闲着。我深以为然,这句话给了我很大启发,我记得是在阅读一篇文章时被深深触动的。
2、有时候,想得再多也不如行动来得有效。即使方向错误,但这也是我从中获得的宝贵经验。总结经验才能不断进步。
3、时间是可以挤出来的,再忙再累也会有时间的。看个人是否愿意付诸行动,但也要适当放松,保持身心健康。
第一次写文章还是有点乱七八糟的,但这也是成长的一部分。还得继续努力,不断学习,能够更好地传达我的想法和观点!
来源:juejin.cn/post/7341642966790144035
努力学习和工作就等于成长吗?
努力学习和工作与成长的关系是一个值得去深思的问题。
有趣的是,参加实习的时候,我将手上的工作做完之后去学其他的技术了,因为那时候刚好比较忙,所以领导就直接提了一个箱子过来,然我去研究一下那个硬件怎么对接。
我看了下文档,只提供了两种语言,C++和JavaScript,显然排除了C++,而是使用JavaScript,不过对于写Java的我来说,虽然也玩过两年的JS,但是明显还是不专业。
我将其快速对接完成后,过了几天,又搞了几台硬件过来叫我对接。
显然这次我不想去写好代码再发给前端了,于是直接拉前段代码来和他们一起开发了。
一个后端程序员硬生生去写了前端。
那么这时候,有些人就会说,“哎呀,能者多劳嘛,你看你多么nb,啥都能干,领导就喜欢这种人了”
屁话,这不是能力,这是陷阱。
一
之前看到一个大佬在他的文章中写道,“如果前端和后端都能干的人,那么大概率是前端能力不怎么滴,后端能力也不怎么滴”。
我们排除那种天生就学习能力特别强的人,这种人天生脑子就是好,学啥都很快,而且学得特别好,但是这样的人是很少数的,和我们大多数人是没关的。
就像有一个大佬,后端特别厉害,手写各种中间件都不在话下,起初我以为他是个全才。
知道有一天,他要出一门教程,然后自己连最基本的CSS和HTML都不会写,然后叫别人给他写。
那么,这能说明他不厉害吗?
各行各业,精英大多都是在自己的领域深耕的。
这个世界最不缺的就是各领域的高手。
在职场中,也并不是什么都会就代表领导赏识你,只能证明你这颗螺丝比较灵活,可以往左边扭,也可以往右边扭。
二
在自己擅长的领域去做,把一件事尽可能垂直。
之前和一朋友聊天,他说他干过python,干过java,干过测试,干过开发,干过实施......
反正差不多什么都干过了,但是为什么后面还是啥也没干成?
我们顶多能说他职业经历丰富,但是不能说他职业经验丰富,经历是故事,而经验才是成长。
可见垂直是很重要的,不过执着追求垂直也未必是一件好事,还要看风往那边吹,不然在时代发展的潮流中也会显得无力。
就像前10年左右,PHP可谓是一领Web开发的龙头!
那句“PHP是世界上最好的语言”可谓是一针强心剂。
可是现在看来,PHP已经谈出Web领域了,很多PHP框架早已转型,比如swoole,swoft等,只留下那句“PHP是世界上最好的语言”摇摇欲坠。
可笑的是,之前看到一个群友说,领导叫他去维护一套老系统,而老系统就是PHP写的,于是他去学了好久ThinkPHP框架,但是过了半年,这个项目直接被Java重构了。
真是造化弄人啊!
三
深度学习和浅尝辄止
在我们还没有工作的时候,在学校看着满入眼帘的技术,心中不免有一种冲动,“老子一定要把它全部学完”
于是从表面去看一遍,会一点了,然后马上在自己学习计划上打一个勾。
但是当遇到另外一个新技术的时候,完全又懵了,于是又重复之前的动作。
这看似学了很多,但是实际上啥也没学会。
个人的精力完全是跟不上时代的发展的,十年前左右,随便会一点编程知识,那找工作简直是别人来请的,但是现在不一样了,即使源码看透了,机会也不多。
而如果掌握了核心,那么无论技术再怎么变革,只需要短暂学习就能熟练了。
就像TCP/IP这么多年了,上层建筑依然是靠它。
四
看似努力,实则自我感动!
在我们读书的时候,总有个别同学看似很努力,但是考试就是考不好。
究其本质,他的努力只是一种伪装,可能去图书馆5个小时,刷抖音就用了四个小时,然后发个朋友圈,“又是对自己负责的一天”。
也有不少人天天加班,然后也会发个朋友圈,“今天的努力只是为了迎接明天更好的自己”。
事实如此吗?
看到希望,有目的性的努力才是人间清醒。
如果觉得自己学得很累,工作得很累,但是实际上啥也没学到,啥也没收获,那么这样得努力是毫无意义的。
这个世界欺骗别人很容易,但是欺骗自己很难!
来源:juejin.cn/post/7303804693192081448
耗时两周,我终于自己搭了一个流媒体服务器
RTSP流媒体播放器
前言:因公司业务需求,研究了下在web端播放rtsp流视频的方案,过程很曲折,但也算是颇有收获。
播放要求
- web网页播放或者手机小程序播放
- 延迟小于500ms
- 支持多路播放
- 免费
舍弃的方案
- 【hls】延时非常高,有时候能达到几十秒,实时性场景直接pass
- 【转rtmp】需要借助flash插件
- 【转图片帧】需要后端借助工具将rtsp视频流每一帧转成图片,再通过websocket协议实时传输到前端,前端用canvas绘制,这种方法对后端转流要求较高,每张图片如果太大会掉帧,延时也不稳定
思路尝试
1、 flvjs + ffmpeg + websocket + node
这套方案的核心为 BiLiBiLi 开源的
flvjs
,原理是在后端利用 转流工具 FFmpeg 将rtsp流
转成flv流
,然后通过websocket
传输flv流
,在利用flvjs
解析成可以在浏览器播放的视频。
flv不支持ios 请自行取舍
2、WebRTC
Webrtc是前端的技术,后端使用有点困难,原理是将
rtsp流
转成webrtc流
,直接在video中播放(需要浏览器支持webrtc)
如何将rtsp转成webrtc 基于两个工具实现
参考链接1 :webrtcstreamer.js 前端实现
参考链接2 : mediamtx转流
3、jsmpeg.js + ffmpeg + websocket + node
这种方案是我测试过免费方案中效果最好的,原理是在后端利用 转流工具 FFmpeg 将
rtsp流
转成图片流
,然后通过websocket
传输图片
,在利用jsmpeg.js
绘制到canvas上显示
优点:
- 可以通时兼容多路视频,且对浏览器内存占用不会太高
- 延迟还可以 测试在300-1000ms左右
缺点:
- 多路无法使用主码流 会把浏览器卡死
- 清晰度不够,画面大概在720p左右
前后端代码放jsbin了 地址 :jsbin.com/hazacak/edi…
注意
使用时请下载ffmpeg 并把ffmpeg添加值环境变量
4 、终极方案:ZLMediaKit +h265webjs
该方案应该是此类问题的终极解决方案(个人认为,有好的方案请共享)
原理:
可以看到ZlMediaKit支持把各种流进行转换输出,我们可以使用输出的流进行播放
为了便捷 推荐你使用ZLM文档提供的Docker镜像,同时ZLM提供各种的restful AP供你使用,可以转流,推流等等,具体查看文档中的 restful API部分内容
其中需要注意的是 API中的secret 在镜像文件 /opt/mdeia/conf 文件夹下 请手动复制出来 每次构建镜像 改值会变化
另外 推荐一个ZLM 的管理界面 github.com/1002victor/…
只需要把代码全部复制到 www目录下即可放心食用
前端部分:
因为前端部分相关的视频库都存在部分协议不支持,没办法完全进行测试
故选择了ZLM官方推荐的 h265webjs这个播放库,经过测试,便捷容易,可安全食用
地址:
来源:juejin.cn/post/7399564369229496358
如何让 localStorage 存储变为响应式
背景
项目上有个更改时区的全局组件,同时还有一个可以更改时区的局部组件,想让更改时区的时候能联动起来,实时响应起来。
其实每次设置完时区的数据之后是存在了前端的 localStorage 里边,时间组件里边也是从 localStorage 拿去默认值来回显。如果当前页面不刷新,那么时间组件就不能更新到最新的 localStorage 数据。
怎么才能让 localStorage 存储的数也变成响应式呢?
实现
- 应该写个公共的方法,不仅仅时区数据能用,万一后边其他数据也能用。
- 项目是 React 项目,那就写个 hook
- 怎么才能让 localStorage 数据变成响应式呢?监听?
失败的案例 1
首先想到的是按照下边这种方式做,
useEffect(()=>{
console.log(11111, localStorage.getItem('timezone'))
},[localStorage.getItem('timezone')])
得到的测试结果肯定是失败的,但是为啥失败?我们也应该知道一下。查了资料说,使用 localStorage.getItem('timezone')
作为依赖项会导致每次渲染都重新计算依赖项,这不是正确的做法。
具体看一下官方文档:useEffect(setup, dependencies?)
在此说一下第二个参数 dependencies:
可选 dependencies
:setup
代码中引用的所有响应式值的列表。响应式值包括 props、state 以及所有直接在组件内部声明的变量和函数。如果你的代码检查工具 配置了 React,那么它将验证是否每个响应式值都被正确地指定为一个依赖项。依赖项列表的元素数量必须是固定的,并且必须像 [dep1, dep2, dep3]
这样内联编写。React 将使用 Object.is
来比较每个依赖项和它先前的值。如果省略此参数,则在每次重新渲染组件之后,将重新运行 Effect 函数。
- 如果你的一些依赖项是组件内部定义的对象或函数,则存在这样的风险,即它们将 导致 Effect 过多地重新运行。要解决这个问题,请删除不必要的 对象 和 函数 依赖项。你还可以 抽离状态更新 和 非响应式的逻辑 到 Effect 之外。
如果你的 Effect 依赖于在渲染期间创建的对象或函数,则它可能会频繁运行。例如,此 Effect 在每次渲染后重新连接,因为 createOptions
函数 在每次渲染时都不同:
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
function createOptions() { // 🚩 此函数在每次重新渲染都从头开始创建
return {
serverUrl: serverUrl,
roomId: roomId
};
}
useEffect(() => {
const options = createOptions(); // 它在 Effect 中被使用
const connection = createConnection();
connection.connect();
return () => connection.disconnect();
}, [createOptions]); // 🚩 因此,此依赖项在每次重新渲染都是不同的
// ...
}
失败的案例 2
一开始能想到的是监听,那就用 window 上监听事件。
在 React 应用中监听 localStorage
的变化,可以使用 window
对象的 storage
事件。这个事件在同一域名的不同文档之间共享,当某个文档修改 localStorage
时,其他文档会收到通知。
写代码...
// useRefreshLocalStorage.js
import { useState, useEffect } from 'react';
const useRefreshLocalStorage = (key) => {
const [storageValue, setStorageValue] = useState(
localStorage.getItem(key)
);
useEffect(() => {
const handleStorageChange = (event) => {
if (event.key === key) {
setStorageValue(event.newValue)
}
};
window.addEventListener('storage', handleStorageChange);
return () => {
window.removeEventListener('storage', handleStorageChange);
};
}, [key]);
return [storageValue];
};
export default useRefreshLocalStorage;
使用方式:
// useTimezone.js
import { useState, useEffect } from "react";
import { getTimezone, timezoneKey } from "@/utils/utils";
import useRefreshLocalStorage from "./useRefreshLocalStorage";
function useTimezone() {
const [TimeZone, setTimeZone] = useState(() => getTimezone());
const [storageValue] = useRefreshLocalStorage(timezoneKey);
useEffect(() => {
setTimeZone(() => getTimezone());
}, [storageValue]);
return [TimeZone];
}
export default useTimezone;
经过测试,失败了,没有效果!!!那到底怎么回事呢?哪里出现问题了?查阅��料经过思考,可能出现的问题的原因有:只能监听同源的两个页面之间的 storage 变更,没法监听同一个页面的变更。
成功的案例
import { useState, useEffect } from "react";
// 自定义 Hook,用于监听 localStorage 中指定键的变化
function useRefreshLocalStorage(localStorage_key) {
// 检查 localStorage_key 是否有效
if (!localStorage_key || typeof localStorage_key !== "string") {
return [null];
}
// 创建一个状态变量来保存 localStorage 中的值
const [storageValue, setStorageValue] = useState(
localStorage.getItem(localStorage_key)
);
useEffect(() => {
// 保存原始的 localStorage.setItem 方法
const originalSetItem = localStorage.setItem;
// 重写 localStorage.setItem 方法,添加事件触发逻辑
localStorage.setItem = function(key, newValue) {
// 创建一个自定义事件,用于通知 localStorage 的变化
const setItemEvent = new CustomEvent("setItemEvent", {
detail: { key, newValue },
});
// 触发自定义事件
window.dispatchEvent(setItemEvent);
// 调用原始的 localStorage.setItem 方法
originalSetItem.apply(this, [key, newValue]);
};
// 事件处理函数,用于处理自定义事件
const handleSetItemEvent = (event) => {
const customEvent = event;
// 检查事件的键是否是我们关心的 localStorage_key
if (event.detail.key === localStorage_key) {
// 更新状态变量 storageValue
const updatedValue = customEvent.detail.newValue;
setStorageValue(updatedValue);
}
};
// 添加自定义事件的监听器
window.addEventListener("setItemEvent", handleSetItemEvent);
// 清除事件监听器和还原原始方法
return () => {
// 移除自定义事件监听器
window.removeEventListener("setItemEvent", handleSetItemEvent);
// 还原原始的 localStorage.setItem 方法
localStorage.setItem = originalSetItem;
};
// 依赖数组,只在 localStorage_key 变化时重新运行 useEffect
}, [localStorage_key]);
// 返回当前的 storageValue
// 为啥没有返回 setStorageValue ?
// 因为想让用户直接操作自己真实的 “setValue” 方法,这里只做一个只读。
return [storageValue];
}
export default useRefreshLocalStorage;
具体的实现步骤如上,每一步也加上了注释。
接下来就是测试了,
useTimezone 针对 timezone 数据统一封装,
// useTimezone.js
import { useState, useEffect } from "react";
import { getTimezone, timezoneKey } from "@/utils/utils";
import useRefreshLocalStorage from "./useRefreshLocalStorage";
function useTimezone() {
const [TimeZone, setTimeZone] = useState(() => getTimezone());
const [storageValue] = useRefreshLocalStorage(timezoneKey);
useEffect(() => {
setTimeZone(() => getTimezone());
}, [storageValue]);
return [TimeZone];
}
export default useTimezone;
具体的业务页面组件中使用,
// 页面中
// ...
import useTimezone from "@/hooks/useTimezone";
export default (props) => {
// ...
const [TimeZone] = useTimezone();
useEffect(()=>{
console.log(11111, TimeZone)
},[TimeZone)
}
测试结果必须是成功的啊!!!
小结
其实想要做到该效果,用全局 store 状态管理也能做到,条条大路通罗马嘛!不过本次需求由于历史原因一直使用的是 localStorage ,索性就想着 如何让 localStorage 存储变为响应式 ?
不知道大家还有什么更好的方法吗?
来源:juejin.cn/post/7399461786348044325
接口一异常你的前端页面就直接崩溃了?
前言
在 JavaScript 开发中,细节处理不当往往会导致意想不到的运行时错误,甚至让应用崩溃。可能你昨天上完线还没问题,第二天突然一大堆人艾特你,你就说你慌不慌。
来吧,咱们来捋一下怎么做才能让你的代码更健壮,即使后端数据出问题了咱前端也能稳得一批。
解构失败报错
不做任何处理直接将后端接口数据进行解构
const handleData = (data)=> {
const { user } = data;
const { id, name } = user;
}
handleData({})
VM244:3 Uncaught TypeError: Cannot destructure property 'id' of 'user' as it is undefined.
解构赋值的规则是,只要等号右边的值不是对象或数组,就先将其转为对象(装箱)。由于 undefined 、null 无法转为对象,所以对它们进行解构赋值时就会报错。
所以当 data 为 undefined 、null 时候,上述代码就会报错。
第二种情况,虽然给了默认值,但是依然会报错
const handleData = (data)=> {
const { user = {} } = data;
const { id, name } = user;
}
handleData({user: null})
ES6 内部使用严格相等运算符(===)判断一个变量是否有值。所以,如果一个对象的属性值不严格等于 undefined ,默认值是不会生效的。
所以当 props.data
为 null
,那么 const { name, age } = null
就会报错!
good:
const handleData = (data)=> {
const { user } = data;
const { id, name } = user || {};
}
handleData({user: null})
数组方法调用报错
从接口拿回来的数据直接用当成数组来用
const handleData = (data)=> {
const { userList } = data;
const newList = userList.map((item)=> item.name)
}
handleData({userList: null})
handleData({userList: 123})
VM394:3 Uncaught TypeError: userList.map is not a function
那么问题来了,如果 userList 不符合预期,不是数组时必然就报错了,所以最好判断一下
good:
const handleData = (data)=> {
const { userList } = data;
if(Array.isArray(userList)){
const newList = userList.map((item)=> item.name)
}
}
handleData({userList: 123})
遍历对象数组报错
遍历对象数组时也要注意 null
或 undefined
的情况
const handleData = (data)=> {
const { userList } = data;
const newList = userList.map((item)=> item?.name)
}
handleData({userList: [ null, undefined ]})
VM547:3 Uncaught TypeError: Cannot read properties of null (reading 'name')
一旦数组中某项值是 undefined 或 null,那么 item.name 必定报错,可能又白屏了。
good:
const handleData = (data)=> {
const { userList } = data;
const newList = userList.map((item)=> item?.name)
}
handleData({userList: [null]})
但是如果是这种情况就不good了
const handleData = (data)=> {
const { userList } = data;
const newList = userList.map((item)=> `用户id是${item?.id},用户名字是${item?.name},用户年龄是${item?.age}岁了`);
}
handleData({userList: [null]})
? 可选链操作符,虽然好用,但也不能滥用。item?.name 会被编译成 item === null || item === void 0 ? void 0 : item.name
,滥用会导致编译后的代码size增大。
good:
const handleData = (data)=> {
const { userList } = data;
const newList = userList.map((item)=> {
const { id, name, age } = item || {};
return `用户id是${id},用户名字是${name},用户年龄是${age}岁了`
});
}
handleData({userList: [null]})
当可选链操作符较多的情况时无论是性能还是可读性都明显上面这种方式更好。
复习一下装箱
大家可以思考一下,以下代码会不会报错
const handleData = (data)=> {
const { userList } = data;
const newList = userList.map((item)=> item.name)
}
handleData({userList: ['', 123]})
是不会报错的,因为在 JavaScript 中,当你在一些基本类型上直接访问属性时这些类型会被自动临时转换成它们对应的对象类型。这种转换称为“装箱”(boxing)。例如:
('').name
空字符串被临时转换成一个字符串对象。由于没有名为 name 的属性,所以它返回 undefined,但不会报错。
let str = "hello";
console.log(str.length); // 5
在这里,str.length 实际上是在字符串对象上调用的,而不是直接在基本类型字符串上。JavaScript 引擎在幕后将字符串 "hello" 装箱为 String 对象,因此可以访问 length 属性。
(123).name
数字 123 被临时转换成一个数字对象。由于没有名为 name 的属性,所以它返回 undefined,但不会报错。
let num = 123;
console.log(num.toFixed(2)); // "123.00"
num.toFixed(2) 调用了数字对象的 toFixed 方法。JavaScript 将数字 123 装箱为 Number 对象。
(null).name
null 是一个特殊的基本类型,当尝试访问其属性时会报错,因为 null 不能被装箱为对象。
try {
const name = (null).name; // TypeError: Cannot read property 'name' of null
} catch (error) {
console.error(error);
}
(undefined).name
undefined 也不能被装箱为对象。
try {
const name = (undefined).name; // TypeError: Cannot read property 'name' of undefined
} catch (error) {
console.error(error);
}
JavaScript 中的基本类型包括:
string
number
boolean
symbol
bigint
null
undefined
对应的对象类型是:
String
Number
Boolean
Symbol
BigInt
装箱的工作原理:
当你访问基本类型的属性或方法时,JavaScript 会自动将基本类型装箱为其对应的对象类型。这个临时的对象允许你访问属性和方法,但它是短暂的,一旦属性或方法访问完成,这个对象就会被销毁。
需要注意的是,null 和 undefined 没有对应的对象类型,不能被装箱。所以访问它们的属性或方法会直接报错!所以时刻警惕 null
和 undefined
这俩坑。
使用对象方法时报错
同理,只要变量能被转成对象,就可以使用对象的方法,但是 undefined 和 null 无法转换成对象。对其使用对象方法时就会报错。
const handleData = (data)=> {
const { user } = data;
const newList = Object.entries(user);
}
handleData({user: null});
VM601:3 Uncaught TypeError: Cannot convert undefined or null to object
下面这两种优化方式都可
good:
const handleData = (data)=> {
const { user } = data;
const newList = Object.entries(user || {})
}
handleData({user: null})
good:
/**
* 判断给定值的类型或获取给定值的类型名称。
*
* @param {*} val - 要判断类型的值。
* @param {string} [type] - 可选,指定的类型名称,用于检查 val 是否属于该类型。
* @returns {string|boolean} - 如果提供了 type 参数,返回一个布尔值表示 val 是* 否属于该类型;如果没有提供 type 参数,返回 val 的类型名称(小写)。
*
* @example
* // 获取类型名称
* console.log(judgeDataType(123)); // 输出 'number'
* console.log(judgeDataType([])); // 输出 'array'
*
* @example
* // 判断是否为指定类型
* console.log(judgeDataType(123, 'number')); // 输出 true
* console.log(judgeDataType([], 'array')); // 输出 true
*/
function judgeDataType(val, type) {
const dataType = Object.prototype.toString.call(val).slice(8, -1).toLowerCase();
return type ? dataType === type : dataType;
}
const handleData = (data)=> {
const { user } = data;
// 判断是否为对象
if(judgeDataType({}, "object")){
const newList = Object.entries(user || {})
}
}
handleData({user: null})
async/await 报错未捕获
这个也是比较容易犯且低级的错误
import React, { useState } from 'react';
const List = () => {
const [loading, setLoading] = useState(false);
const getData = async () => {
setLoading(true);
const res = await fetchListData();
setLoading(false);
}
}
如果 fetchListData() 执行报错,页面就会一直在加载中,所以一定要捕获一下。
good:
const List = () => {
const [loading, setLoading] = useState(false);
const getData = async () => {
setLoading(true);
try {
const res = await queryData();
setLoading(false);
} catch (error) {
setLoading(false);
}
}
}
当然如果觉得这种方式不优雅,用 await-to-js
库或者其他方式都可以,记得捕获就行。
JSON.parse报错
如果传入的不是一个有效的可被解析的 JSON 字符串就会报错啦。
const handleData = (data)=> {
const { userStr } = data;
const user = JSON.parse(userStr);
}
handleData({userStr: 'fdfsfsdd'})
16:06:57.521 VM857:1 Uncaught SyntaxError: Unexpected token 'd', "fdfsfsdd" is not valid JSON
这里没必要去判断一个字符串是否为有效的 JSON 字符串,只要利用 trycatch 来捕获错误即可。
good:
const handleData = (data)=> {
const { userStr } = data;
try {
const user = JSON.parse(userStr);
} catch (error) {
console.error('不是一个有效的JSON字符串')
}
}
handleData({userStr: 'fdfsfsdd'})
动态导入模块失败报错
动态导入某些模块时,也要注意可能会报错
const loadModule = async () => {
const module = await import('./dynamicModule.js');
module.doSomething();
}
如果导入的模块存在语法错误、网络或者跨域问题、文件不存在、循环依赖、甚至文件非常大导致内存不足、模块内的运行时错误等都有可能阻塞后续代码执行。
good:
const loadModule = async () => {
try {
const module = await import('./dynamicModule.js');
module.doSomething();
} catch (error) {
console.error('Failed to load module:', error);
}
}
API 兼容性问题报错
fetch('/api/data')
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error('Error:', error));
低版本 Node 不支持 fetch
,需要更高兼容性的场景使用 axios
等更好。
其他包括小程序开发,web开发等也同理,如果使用了某些不支持的 es 新特性或者较新版本的平台才支持的api也会导致直接报错,使用时做好判断或直接用兼容性更好的写法。
框架在编译时已经帮我们解决了大部分的兼容性问题,但是有些场景还需额外注意。
内存溢出崩溃
滥用内存缓存可能会导致内存溢出
const cache = {};
function addToCache(key, value) {
cache[key] = value;
// 没有清理机制,缓存会无限增长
}
避免闭包持有大对象的引用
function createClosure() {
const largeData = new Array(1000000).fill('x');
return function() {
console.log(largeData.length);
};
}
const closure = createClosure();
// largeData 现在被闭包引用会一直存活在内存中,即使不再直接使用
closure = null; // 手动解除引用
记得清除定时器和事件监听器
// React
useEffect(() => {
const timeoutId = setTimeout(() => {
// 一些操作
}, 1000);
return () => clearTimeout(timeoutId);
}, []);
function setupHandler() {
const largeData = new Array(1000000).fill('x');
const handler = function() {
console.log(largeData.length);
};
document.getElementById('myButton').addEventListener('click', handler);
return function cleanup() {
document.getElementById('myButton').removeEventListener('click', handler);
};
}
const cleanup = setupHandler();
// 在适当的时候调用
// cleanup();
还有深度递归,JSON.parse()
解析超大数据等都可能会对内存造成压力。
总结
以上列举了js在运行时可能会发生错误而导致的应用崩溃的一些边界情况,这些都是在开发时不那么容易察觉,eslint等静态检查工具也无能为力的场景,当然如果用typescript的话还是可以帮助我们避免大部分坑的,如果不用 ts 的话就不可避免的需要考虑这些情况才能写出健壮的代码。
边界场景的容错一定要做,原则上不信任任何外部输入数据的存在性和类型,历史经验告诉我们,不做容错出错只是早晚的事。
帮别人review代码的时候也可以参考以上清单,如果大家还有补充欢迎讨论,最后祝各位大佬没有bug。
来源:juejin.cn/post/7388022210856222732
为了检测360浏览器,我可是煞费苦心……
众所周知,360浏览器一向是特立独行的存在,为了防止用户识别它,隐藏了自己的用户代理(User-Agent)。只有在自己域名下访问,用户代理(User-Agent)才暴露出自己的特征。显然,这样对于开发者想要识别它,造成了不少麻烦。
非360官方网站访问
360官方网站访问
为了识别出360,只能通过Javascript检测360的特殊属性,找和其他浏览器的区别。最常见的方式就是找navigator.mimeTypes
或者navigator.plugins
有哪些不一样的值。为此,我在各个版本的360浏览器(360安全浏览器、360极速浏览器)也找到了各种可以利用的特征。
比如,无论 360安全浏览器 还是 360极速浏览器 的navigator.mimeTypes
都可能存在
application/360softmgrplugin
, application/mozilla-npqihooquicklogin
, application/npjlgplayer3-chrome-jlp
,application/vnd.chromium.remoting-viewer
这几种类型,我们可以通过判断这几个值是否存在识别 360浏览器。不仅如此,在早期的360浏览器中,明明是chrome内核,还保留着IE时代有了showModalDialog
方法,这些都可以用来做识别的依据。
import getMime from '../method/getMime.js';
import _globalThis from '../runtime/globalThis.js';
export default {
name: '360',
match(ua) {
let isMatch = false;
if (_globalThis?.chrome) {
let chrome_version = ua.replace(/^.*Chrome\/([\d]+).*$/, '$1');
if (getMime("type", "application/360softmgrplugin") || getMime("type", "application/mozilla-npqihooquicklogin") || getMime("type", "application/npjlgplayer3-chrome-jlp")) {
isMatch = true;
} else if (chrome_version > 36 && _globalThis?.showModalDialog) {
isMatch = true;
} else if (chrome_version > 45) {
isMatch = getMime("type", "application/vnd.chromium.remoting-viewer");
if (!isMatch && chrome_version >= 69) {
isMatch = getMime("type", "application/asx");
}
}
}
return ua.includes('QihooBrowser')
||ua.includes('QHBrowser')
||ua.includes(' 360 ')
||isMatch;
},
version(ua) {
return ua.match(/QihooBrowser(HD)?\/([\d.]+)/)?.[1]
||ua.match(/Browser \(v([\d.]+)/)?.[1]
||'';
}
};
然而,360并不是只有1种浏览器,还包含了 360安全浏览器, 360极速浏览器,360 AI浏览器等。我们又怎么区分呢,这时候还要寻找它们之间的差别。360AI浏览器较为简单,用户代理(User-Agent)中直接暴露相关信息。
我发现有一个值是360安全浏览器独有的,那就是application/gameplugin
,应该是浏览器内置的游戏插件。可是在后续的版本中也消失了,我又发现navigator.userAgentData.brands
里的值也有细微区别。于是就可以如下处理:
import getMime from '../method/getMime.js';
import _Chrome from './Chrome.js';
import _360 from './360.js';
import _globalThis from '../runtime/globalThis.js';
export default {
name:'360SE',
match(ua,isAsync=false){
let isMatch = false;
if(_360.match(ua)){
if(getMime("type", "application/gameplugin")){
isMatch = true;
}else if(_globalThis?.navigator?.userAgentData?.brands.filter(item=>item.brand=='Not.A/Brand').length){
isMatch = true;
}
}
return ua.includes('360SE')||isMatch;
},
version(ua){
let hash = {
'122':'15.3',
'114':'15.0',
'108':'14.0',
'86':'13.0',
'78':'12.0',
'69':'11.0',
'63':'10.0',
'55':'9.1',
'45':'8.1',
'42':'8.0',
'31':'7.0',
'21':'6.3'
};
let chrome_version = parseInt(_Chrome.version(ua));
return hash[chrome_version]||'';
}
};
而 360极速浏览器的识别依据就相对较多了!各种身份验证的插件都能在里面找到。
import getMime from '../method/getMime.js';
import _Chrome from './Chrome.js';
import _360 from './360.js';
import _globalThis from '../runtime/globalThis.js';
export default {
name:'360EE',
match(ua){
let isMatch = false;
if(getMime('type','application/cenroll.cenroll.version.1')||getMime('type','application/hwepass2001.installepass2001')){
isMatch = true;
}else if(_360.match(ua)){
if(_globalThis?.navigator?.userAgentData?.brands.find(item=>item.brand=='Not A(Brand'||item.brand=='Not?A_Brand')){
isMatch = true;
}
}
return ua.includes('360EE')||isMatch;
},
version(ua){
let hash = {
'122':'22.3', // 360极速X
'119':'22.0', // 360极速X
'108':'14.0', // 360极速
'95':'21.0', // 360极速X
'86':'13.0',
'78':'12.0',
'69':'11.0',
'63':'9.5',
'55':'9.0',
'50':'8.7',
'30':'7.5'
};
let chrome_version = parseInt(_Chrome.version(ua));
return ua.match(/Browser \(v([\d.]+)/)?.[1]
||hash[chrome_version]
||'';
}
};
可惜的是在Mac系统中的情况复杂点,这些插件的方法都不存在,这下又失去了判断的依据了。还有,在一次无意打开网络连接一次的时候,发现了360浏览器在请求一个奇怪的地址,居然返回了浏览器版本信息。
import _globalThis from '../runtime/globalThis.js';
const GetDeviceInfo = () => {
return new Promise((resolve) => {
const randomCv = `cv_${new Date().getTime() % 100000}${Math.floor(Math.random()) * 100}`
const params = { key: 'GetDeviceInfo', data: {}, callback: randomCv }
const Data = JSON.stringify(params)
if(_globalThis?.webkit?.messageHandlers){
_globalThis.webkit.messageHandlers['excuteCmd'].postMessage(Data)
_globalThis[randomCv] = function (response) {
delete _globalThis[randomCv];
resolve(JSON.parse(response||'{}'));
}
}else{
return resolve({});
}
})
};
export default {
name: '360EE',
match(ua) {
return GetDeviceInfo().then(function(response){
return response?.pid=='360csexm'||false;
});
},
version(ua) {
return GetDeviceInfo().then(function(response){
return response?.module_version||'';
});
}
};
原本觉得一切应该就这么顺利了,然后当我从Windows10迁移到windows11的时候,发现原来的浏览器中的插件特征识别已经失效了,我找不到360安全浏览器的识别特征。
于是我疯了……我开始一个个属性对比差异,就是找不到有什么特征是可以区分开的。就在我一筹莫展的时候,我无意间发现,我自己的网站在360安全浏览器中,莫默其妙多了一个奇怪的节点,看样子是一个AI组件。我敢确定,这个节点并不是我写的,于是我断言是360做了什么特殊处理。
经过分析,我发现这是360安全浏览器内置的一个“扩展程序 - 360智脑” ,是默认安装的而且无法卸载。我脑子一下子亮起来了,心想着我可以根据这个节点判断啊,只要检测它是否加载就能判断出来。于是,我写了以下代码:
// 根据检测文档中是否被插入“360智脑”组件,判断是否为360安全浏览器
if(!document?.querySelector('#ai-assist-root')){
return new Promise(function(resolve){
let hander = setTimeout(function(){
resolve(false);
},1500);
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach(function($item){
if($item.id=='ai-assist-root'){
hander&&clearTimeout(hander);
resolve(true);
}
});
}
});
});
observer.observe(document,{childList: true, subtree: true});
});
}
但确实也有不足之处,我只能判断出什么时候加载了节点,但是无法对非360浏览器不加载进行判断。只能通过定时器去做超时处理,这就意味着非360浏览器判断需要一定耗时,这样太不友好了。
就在这时候,我发现这个“扩展程序”是需要加载资源的,而这个资源在浏览器本地。也就意味着,我可以直接直接对资源进行判断,这样并不需要等插件加载超时判断,非360安全浏览器可以较快地判断出“非他”的条件。
// 根据判断扩展程序CSS是否加载成功判断是否为360安全浏览器
return new Promise(function(resolve){
fetch('chrome-extension://fjbbmgamncjadhlpmffehlmmkdnkiadk/css/content.css').then(function(){
resolve(true);
}).catch(function(){
resolve(false);
});
});
于是我终于可以判断出这烦人的“小妖精”了!而这也是浏览器嗅探的其中一项工作,为此我还做了诸如:操作系统、屏幕、处理器架构、GPU、CPU、IP地址、时区、语言、网络等浏览器信息的判断和识别。
浏览器在线检测: passer-by.com/browser/
开源项目仓库地址:github.com/mumuy/brows…
如果你对此感兴趣或者有什么内容要补充,欢迎关注此项目~
来源:juejin.cn/post/7390588322768748580
因为不知道Object.keys,被嫌弃了
关联精彩文章:# 改进tabs组件切换效果,丝滑的动画获得一致好评~
背景
今天,同事看到了我一段遍历读取对象key的代码后,居然嘲笑我技术菜(虽然我确实菜)
const person = {
name: '员工1',
age: 30,
profession: 'Engineer'
// ....
};
// 使用 for 循环读取对象的键
const keys = Object.keys(person);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const value = person[key];
console.log(key + ': ' + value);
}
我说,用for循环读取对象的key不对吗,他二话不说直接给我改代码
const person = {
name: '员工1',
age: 30,
profession: 'Engineer'
};
Object.keys(person).forEach(key => {
console.log(key + ': ' + person[key]);
});
然后很装的走了......钢铁直男啊!我很生气,我这个人比较较劲,我一定要把Object.keys吃透!
Object.keys的基础用法
语法
Object.keys
是 JavaScript 中的一个方法,用于获取对象自身的可枚举属性名称,并以数组形式返回。
Object.keys(obj)
- 参数:要返回其枚举自身属性的对象
- 返回值:一个表示给定对象的所有可枚举属性的字符串数组
不同入参的返回值
- 处理对象,返回可枚举的属性数组
let person = {name:"张三",age:25,address:"深圳",getName:function(){}}
Object.keys(person)
// ["name", "age", "address","getName"]
- 处理数组,返回索引值数组
let arr = [1,2,3,4,5,6]
Object.keys(arr)
// ["0", "1", "2", "3", "4", "5"]
- 处理字符串,返回索引值数组
let str = "saasd字符串"
Object.keys(str)
// ["0", "1", "2", "3", "4", "5", "6", "7"]
Object.keys的常用技巧
Object.keys在处理对象属性、遍历对象和动态生成内容时非常有用。
遍历对象属性
当你需要遍历一个对象的属性时,Object.keys
可以将对象的所有属性名以数组形式返回,然后你可以使用 forEach
或 for...of
来遍历这些属性名。
示例:
const person = {
name: '员工1',
age: 30,
profession: 'Engineer'
};
Object.keys(person).forEach(key => {
console.log(key + ': ' + person[key]);
});
输出:
name: 员工1
age: 30
profession: Engineer
获取对象属性的数量
可以使用 Object.keys
获取对象的属性名数组,然后通过数组的 length
属性来确定对象中属性的数量。
示例:
const person = {
name: '员工2',
age: 30,
profession: 'Engineer'
};
const numberOfProperties = Object.keys(person).length;
console.log(numberOfProperties); // 输出: 3
过滤对象属性
可以使用 Object.keys
来获取对象的属性名数组,然后使用数组的 filter
方法来筛选属性名,从而创建一个新的对象。
示例:
const person = {
name: '员工3',
age: 30,
profession: '钢铁直男'
};
const filteredKeys = Object.keys(person).filter(key => key !== 'age');
const filteredPerson = {};
filteredKeys.forEach(key => {
filteredPerson[key] = person[key];
});
console.log(filteredPerson); // 输出: { name: '员工3', profession: '钢铁直男' }
检查对象是否为空
可以通过检查 Object.keys
返回的数组长度来确定对象是否为空。
示例:
const emptyObject = {};
const nonEmptyObject = { name: '讨厌的人' };
function isEmpty(obj) {
return Object.keys(obj).length === 0;
}
console.log(isEmpty(emptyObject)); // 输出: true
console.log(isEmpty(nonEmptyObject)); // 输出: false
深拷贝对象
虽然 Object.keys
本身并不能进行深拷贝,但它可以与其他方法结合使用来创建对象的深拷贝,特别是当对象的属性是另一层对象时。
示例:
const person = {
name: '快乐就是哈哈哈',
age: 18,
profession: 'coder',
address: {
city: 'Wonderland',
postalCode: '12345'
}
};
function deepCopy(obj) {
const copy = {};
Object.keys(obj).forEach(key => {
if (typeof obj[key] === 'object' && obj[key] !== null) {
copy[key] = deepCopy(obj[key]);
} else {
copy[key] = obj[key];
}
});
return copy;
}
const copiedPerson = deepCopy(person);
console.log(copiedPerson);
是小姐姐,不是小哥哥~
来源:juejin.cn/post/7392115478549069861
Java中的双冒号运算符(::)及其应用
在Java 8引入的Lambda表达式和函数式接口之后,双冒号运算符(::)成为了一项重要的功能。它可以将方法或构造函数作为参数传递,简化了编码和提升了代码的可读性。本文将介绍Java中的双冒号运算符及其常见应用场景。
双冒号运算符(::)的语法
双冒号运算符的语法是类名/对象名::方法名
。具体来说,它有三种不同的使用方式:
- 作为静态方法的引用:
ClassName::staticMethodName
- 作为实例方法的引用:
objectReference::instanceMethodName
- 作为构造函数的引用:
ClassName::new
静态方法引用
首先,我们来看一下如何使用双冒号运算符引用静态方法。假设有一个Utils类,其中有一个静态方法processData
:
public class Utils {
public static void processData(String data) {
System.out.println("Processing data: " + data);
}
}
我们可以使用双冒号运算符将该方法作为参数传递给其他方法:
List<String> dataList = Arrays.asList("data1", "data2", "data3");
dataList.forEach(Utils::processData);
上述代码等效于使用Lambda表达式的方式:
dataList.forEach(data -> Utils.processData(data));
通过使用双冒号运算符,我们避免了重复写Lambda表达式,使代码更加简洁和易读。
实例方法引用
双冒号运算符还可以用于引用实例方法。假设我们有一个User类,包含了一个实例方法getUserInfo
:
public class User {
public void getUserInfo() {
System.out.println("Getting user info...");
}
}
我们可以通过双冒号运算符引用该实例方法:
User user = new User();
Runnable getInfo = user::getUserInfo;
getInfo.run();
上述代码中,我们创建了一个Runnable对象,并将user::getUserInfo
作为方法引用赋值给它。然后,通过调用run
方法来执行该方法引用。
构造函数引用
在Java 8之前,要使用构造函数创建对象,需要通过写出完整的类名以及参数列表来调用构造函数。而使用双冒号运算符,我们可以将构造函数作为方法引用,实现更加简洁的对象创建方式。
假设有一个Person类,拥有一个带有name参数的构造函数:
public class Person {
private String name;
public Person(String name) {
this.name = name;
}
public String getName() {
return name;
}
}
我们可以使用双冒号运算符引用该构造函数并创建对象:
Supplier<Person> personSupplier = Person::new;
Person person = personSupplier.get();
person.getName(); // 调用实例方法
上述代码中,我们使用Person::new
将构造函数引用赋值给Supplier接口,然后通过get
方法创建了Person对象。
总结
本文介绍了Java中双冒号运算符(::)的语法及其常见的应用场景。通过双冒号运算符,我们可以更方便地引用静态方法、实例方法和构造函数,使得代码更加简洁和可读。双冒号运算符是Java 8引入的重要特性,对于函数式编程和Lambda表达式的使用起到了积极的推动作用。
希望本文能够帮助您理解和应用双冒号运算符,提高Java开发的效率和代码质量。如有任何问题或疑惑,欢迎提问!
来源:juejin.cn/post/7316532841923805184
揭秘外卖平台的附近公里设计
背景
相信大家都有点外卖的时候去按照附近公里排序的习惯,那附近的公里是怎么设计的呢?今天shigen
带你一起揭秘。
分析
我们先明确一下需求,每个商家都有一个地址对吧,我们也有一个地址,我们点餐的时候,就是以我们自己所在的位置为圆心,向外辐射,这一圈上有一堆的商家。类似我下方的图展示:
想到了位置,我们自然想到了卫星定位,想到了二维的坐标。那这个需求我们有什么好的设计方案吗?
redis的GEO地理位置坐标这个数据结构刚好能解决我们的需求。
GEO
GEO 是一种地理空间数据结构,它可以存储和处理地理位置信息。它以有序集合(Sorted Set)的形式存储地理位置的经度和纬度,以及与之关联的成员。
以下是 Redis GEO 的一些常见操作:
GEOADD key longitude latitude member [longitude latitude member ...]
:将一个或多个地理位置及其成员添加到指定的键中。 示例:GEOADD cities -122.4194 37.7749 "San Francisco" -74.0059 40.7128 "New York"
GEODIST key member1 member2 [unit]
:计算两个成员之间的距离。 示例:GEODIST cities "San Francisco" "New York" km
GEOPOS key member [member ...]
:获取一个或多个成员的经度和纬度。 示例:GEOPOS cities "San Francisco" "New York"
GEORADIUS key longitude latitude radius unit [WITHCOORD] [WITHDIST] [COUNT count] [ASC|DESC] [STORE key] [STOREDIST key]
:根据给定的经纬度和半径,在指定范围内查找与给定位置相匹配的成员。 示例:GEORADIUS cities -122.4194 37.7749 100 km WITHDIST COUNT 5
Redis 的 GEO 功能可用于许多应用场景,例如:
- 位置服务:可以存储城市、商店、用户等位置信息,并通过距离计算来查找附近的位置。
- 地理围栏:可以存储地理围栏的边界信息,并检查给定的位置是否在围栏内。
- 最短路径:可以将城市或节点作为地理位置,结合图算法,查找两个位置之间的最短路径。
- 热点分析:可以根据位置信息生成热力图,统计热门区域或目标位置的访问频率。
Redis 的 GEO 功能提供了方便且高效的方式来存储和操作地理位置信息,使得处理地理空间数据变得更加简单和快速。
默默的说一句,redis在路径规划下边竟然也这么厉害!
好的,那我们就来开始实现吧。今天我的操作还是用代码来展示,毕竟经纬度在控制台输入可能会出错。
代码实现
今天的案例是将湖北省武汉市各个区的数据存储在redis中,并以我所在的位置计算离别的区距离,以及我最近10km内的区。数据来源
我的测试代码如下,其中的运行结果也在对应的注释上有显示。
因为代码图片的宽度过长,导致代码字体很小,在移动端可尝试横屏观看;在PC端可尝试右键在新标签页打开图片。
以上的代码案例也参考:Redis GEO 常用 RedisTemplate API(Java),感谢作者提供的代码案例支持。
总结
对于需要存储地理数据和需要进行地理计算的需求,可以尝试使用redis进行解决。当然,elasticsearch也提供了对应的数据类型支持。
来源:juejin.cn/post/7275595571733282853
全网显示IP归属地,免费可用,快来看看
前言
经常浏览小视频或各类帖子的朋友们可能已经注意到,目前许多网络平台都会显示作者和评论区用户的IP归属地。那么,这个功能是如何实现的呢?
某些收费平台的API
我们可以利用一些付费平台的API来实现这一功能,比如一些导航软件的开放平台API等。然而,这些服务通常是收费的,而且免费额度有限,适合测试使用,但如果要在生产环境中使用,很可能不够支撑需求。
离线库推荐
那么,有没有免费的离线API库呢?UP现在推荐一个强大的离线库给大家,一个准确率高达99.9%的离线IP地址定位库,查询速度仅需0.0x毫秒,而且数据库仅10兆字节大小。此库提供了Java、PHP、C、Python、Node.js、Golang、C#等多种查询绑定,同时支持Binary、B树和内存三种查询算法。
这个库大家可以在GitHub上搜索:ip2region,即可找到该开源库。
使用
下面使用Java代码给大家演示下如何使用这个IP库,该库目前支持多重主流语言。
1、引入依赖
<dependency>
<groupId>org.lionsoul</groupId>
<artifactId>ip2region</artifactId>
<version>2.7.0</version>
</dependency>
2、下载离线库文件 ip2region.xdb
3、简单使用代码
下面,我们通过Java代码,挑选某个国内的IP进行测试,看看会输出什么样的结果
public class IpTest {
public static void main(String[] args) throws Exception {
// 1、创建 searcher 对象 (修改为离线库路径)
String dbPath = "C:\Users\Administrator\Desktop\ip2region.xdb";
Searcher searcher = null;
try {
searcher = Searcher.newWithFileOnly(dbPath);
} catch (Exception e) {
System.out.printf("failed to create searcher with `%s`: %s\n", dbPath, e);
return;
}
// 2、查询
String ip = "110.242.68.66";
try {
long sTime = System.nanoTime(); // Happyjava
String region = searcher.search(ip);
long cost = TimeUnit.NANOSECONDS.toMicros((long) (System.nanoTime() - sTime));
System.out.printf("{region: %s, ioCount: %d, took: %d μs}\n", region, searcher.getIOCount(), cost);
} catch (Exception e) {
System.out.printf("failed to search(%s): %s\n", ip, e);
}
// 3、关闭资源
searcher.close();
// 备注:并发使用,每个线程需要创建一个独立的 searcher 对象单独使用。
}
}
输出结果为:
{region: 中国|0|河北省|保定市|联通, ioCount: 3, took: 1192 μs}
其中,region的格式为 国家|区域|省份|城市|ISP,缺省的地域信息默认是0。
当然,这个库不只是支持国内的IP,也支持国外的IP。
其他语言可以参考该开源库的说明文档。
总结
这是一个准确率非常高的离线库,如果项目里有IP定位需求的,可以试下该库。
来源:juejin.cn/post/7306334713992708122
如何创建一张被浏览器绝对信任的 https 自签名证书?
在一些前端开发场景中,需要在本地创建 https 服务,Node.js 提供了 https 模块帮助开发者快速创建 https 的服务器,示例代码如下:
const https = require('https')
const fs = require('fs')
const options = {
key: fs.readFileSync('key.pem'),
cert: fs.readFileSync('cert.pem'),
}
const server = https.createServer(options, (req, res) => {
res.writeHead(200)
res.end('hello world\n')
})
server.listen(8080)
与创建 http 服务最大的区别在于:https 服务需要证书。因此需要在 options 选项中提供 key
和 cert
两个字段。大部分前端不知道如何创建 key
和 cert
,虽然网上能查到一些 openssl 命令,但也不知道是什么含义。所谓授人以鱼不如授人以渔,这里先从一些基本概念讲起,然后一步步教大家如何创建一个可以被浏览器绝对信任的自签名证书。
密码学知识
首先要知道加密学中非常重要的一个算法:公开密钥算法(Public Key Cryptography),也称为非对称加密算法(Asymmetrical Cryptography),算法的密钥是一对,分别是公钥(public key)和私钥(private key),一般私钥由密钥对的生成方(比如服务器端)持有,避免泄露,而公钥任何人都可以持有,也不怕泄露。
一句话总结:公钥加密、私钥解密。
公私钥出了用于加解密之外,还能用于数字签名。因为私钥只有密钥对的生成者持有,用私钥签署(注意不是加密)一条消息,然后发送给任意的接收方,接收方只要拥有私钥对应的公钥,就能成功反解被签署的消息。
一句话总结:私钥加签、公钥验证。
由于只有私钥持有者才能“签署”消息,如果不考虑密钥泄露的问题,就不能抵赖说不是自己干的。
数字签名和信任链
基于数字签名技术,假设 A 授权给 B,B 授权给 C,C 授权给 D,那么 D 就相当于拿到了 A 的授权,这就形成了一个完整的信任链。因此:
- 信任链建立了一条从根证书颁发机构(Root CA)到最终证书持有人的信任路径
- 每个证书都有一个签名,验证这个签名需要使用颁发该证书的机构的公钥
- 信任链的作用是确保接收方可以验证数字签名的有效性,并信任签名所代表的身份
以 https 证书在浏览器端被信任为例,整个流程如下:
可以看到,在这套基础设施中,涉及到很多参与方和新概念,例如:
- 服务器实体:需要申请证书的实体(如某个域名的拥有者)
- CA机构:签发证书的机构
- 证书仓库:CA 签发的证书全部保存到仓库中,证书可能过期或被吊销。
- 证书校验方:校验证书真实性的软件,例如浏览器、客户端等。
这些参与方、概念和流程的集合被称为公钥基础设施(Public Key Infrastructure)
X.509 标准
为了能够将这套基础设施跑通,需要遵循一些标准,最常用的标准是 X.509,其内容包括:
- 如何定义证书文件的结构(使用 ANS.1 来描述证书)
- 如何管理证书(申请证书的流程,审核身份的标准,签发证书的流程)
- 如何校验证书(证书签名校验,校验实体属性,比如的域名、证书有效期等)
- 如何对证书进行撤销(包括 CRL 和 OCSP 协议等概念)
X.509标准在网络安全中广泛使用,特别是在 TLS/SSL 协议中用于验证服务器和客户端的身份。在 Node.js 中,当 tls 通道创建之后,可以通过下面两个方法获取客户端和服务端 X.509 证书:
- 获取本地 X.509 证书:getX509Certificate
- 获取对方 X.509 证书:getPeerX509Certificate
生成证书
要想生成证书,首要有一个私钥(private key),私钥的生成方式为:
$ openssl genrsa -out key.pem 2048
会生成一个 key.pem 文件,内容如下:
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCB....
-----END PRIVATE KEY-----
然后,还需要一个与私钥相对应的公钥(public key),生成方式:
$ openssl req -new -sha256 -key key.pem -out csr.pem
按照提示操作即可,下面是示例输入(中国-浙江省-杭州市-西湖-苏堤-keliq):
You are about to be asked to enter information that will be incorporated
int0 your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
-----
Country Name (2 letter code) [AU]:CN
State or Province Name (full name) [Some-State]:Zhejiang
Locality Name (eg, city) []:Hangzhou
Organization Name (eg, company) [Internet Widgits Pty Ltd]:West Lake
Organizational Unit Name (eg, section) []:Su Causeway
Common Name (e.g. server FQDN or YOUR name) []:keliq
Email Address []:email@example.com
Please enter the following 'extra' attributes
to be sent with your certificate request
A challenge password []:123456
An optional company name []:XiHu
生成的文件内容如下:
-----BEGIN CERTIFICATE REQUEST-----
MIIDATCCAekCAQAwgY8xCzAJBgNVBAYTAkNOMREw...
-----END CERTIFICATE REQUEST-----
公钥文件创建之后,接下来有两个选择:
- 将其发送给 CA 机构,让其进行签名
- 自签名
自签名
如果是本地开发,我们选择自签名的方式就行了,openssl 同样提供了命令:
$ openssl x509 -req -in csr.pem -signkey key.pem -out cert.pem
Certificate request self-signature ok
subject=C = CN, ST = Zhejiang, L = Hangzhou, O = West Lake, OU = Su Causeway, CN = keliq, emailAddress = email@example.com
最终得到了 cert.pem,内容如下:
-----BEGIN CERTIFICATE-----
MIIDpzCCAo8CFAf7LQmMUweTSW+ECkjc7g1uy3jCMA0...
-----END CERTIFICATE-----
到这里,所有环节都走完了,再来回顾一下,总共生成了三个文件,环环相扣:
- 首先生成私钥文件
key.pem
- 然后生成与私钥对应的公钥文件
csr.pem
- 最后用公私钥生成证书
cert.pem
实战——信任根证书
用上面自签名创建的证书来创建 https 服务:
const options = {
key: fs.readFileSync('key.pem'),
cert: fs.readFileSync('cert.pem'),
}
启动之后,如果你在浏览器中访问,会发现出错了:
命名是 https 服务,为什么浏览器说不是私密连接呢?因为自签名证书默认是不被浏览器信任的,只需要将 cert.pem 拖到钥匙里面即可,然后修改为「始终信任」,过程中需要验证指纹或者输入密码:
如果你觉得上述流程比较繁琐,可以用下面的命令行来完成:
$ sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain cert.pem
实战——指定主题备用名称
然而,即使添加了钥匙串信任,问题似乎并没有解决,报错还是依旧:
仔细看,其实报错信息发生了变化,从原来的 NET::ERR_CERT_AUTHORITY_INVALID
变成了 NET::ERR_CERT_COMMON_NAME_INVALID
,这又是怎么回事呢?我们点开高级按钮看一下详细报错:
这段话的意思是:当前网站的 SSL 证书中的通用名称(Common Name)与实际访问的域名不匹配。
证书中会包含了一个通用名称字段,用于指定证书的使用范围。如果证书中的通用名称与您访问的域名不匹配,浏览器会出现NET::ERR_CERT_COMMON_NAME_INVALID错误。
一句话描述,证书缺少了主题备用名称(subjectAltName),而浏览器校验证书需要此字段。为了更好的理解这一点,我们可以用下面的命令查看证书的完整信息:
$ openssl x509 -in cert.pem -text -noout
输出结果如下:
Certificate:
Data:
Version: 1 (0x0)
Serial Number:
07:fb:2d:09:8c:53:07:93:49:6f:84:0a:48:dc:ee:0d:6e:cb:78:c2
Signature Algorithm: sha256WithRSAEncryption
Issuer: C = CN, ST = Zhejiang, L = Hangzhou, O = West Lake, OU = Su Causeway, CN = keliq, emailAddress = email@example.com
Validity
Not Before: Nov 15 06:29:36 2023 GMT
Not After : Dec 15 06:29:36 2023 GMT
Subject: C = CN, ST = Zhejiang, L = Hangzhou, O = West Lake, OU = Su Causeway, CN = keliq, emailAddress = email@example.com
Subject Public Key Info:
Public Key Algorithm: rsaEncryption
Public-Key: (2048 bit)
Modulus:
00:ac:63:b1:f1:7a:69:aa:84:ef:9d:0e:be:c1:f7:
80:3f:6f:59:e1:7d:c5:c6:db:ff:2c:f3:99:12:7f:
...
Exponent: 65537 (0x10001)
Signature Algorithm: sha256WithRSAEncryption
Signature Value:
70:d9:59:10:46:dc:7b:b3:19:c8:bd:4b:c5:70:4f:89:b6:6a:
53:1c:f2:35:27:c8:0a:ed:a8:0a:13:1f:46:3e:e7:a7:ff:1f:
...
我们发现,这个证书并没有 Subject Alternative Name 这个字段,那如何增加这个字段呢?有两种方式:
指定 extfile 选项
$ openssl x509 -req \
-in csr.pem \
-signkey key.pem \
-extfile <(printf "subjectAltName=DNS:localhost") \
-out cert.pem
再次用命令查看证书详情,可以发现 Subject Alternative Name 字段已经有了:
...
X509v3 extensions:
X509v3 Subject Alternative Name:
DNS:localhost
X509v3 Subject Key Identifier:
21:65:8F:93:49:BC:DF:8C:17:1B:6C:43:AC:31:3C:A9:34:3C:CB:77
...
用新生成的 cert.pem 启动 https 服务,再次访问就正常了,可以点击小锁查看证书详细信息:
但是如果把 localhost 换成 127.0.0.1 的话,访问依然被拒绝,因为 subjectAltName 只添加了 localhost 这一个域名,所以非 localhost 域名使用此证书的时候,浏览器就会拒绝。
新建 .cnf 文件
这次我们新建一个 ssl.cnf 文件,并在 alt_names 里面多指定几个域名:
[req]
prompt = no
default_bits = 4096
default_md = sha512
distinguished_name = dn
x509_extensions = v3_req
[dn]
C=CN
ST=Zhejiang
L=Hangzhou
O=West Lake
OU=Su Causeway
CN=keliq
emailAddress=keliq@example.com
[v3_req]
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
subjectAltName=@alt_names
[alt_names]
DNS.1 = localhost
IP.2 = 127.0.0.1
IP.3 = 0.0.0.0
然后一条命令直接生成密钥和证书文件:
$ openssl req -newkey rsa:2048 -new -nodes -x509 -days 3650 \
-config openssl.cnf \
-keyout key.pem \
-out cert.pem
再次查看证书详情,观察 Subject Alternative Name 字段:
...
X509v3 extensions:
X509v3 Key Usage:
Digital Signature, Non Repudiation, Key Encipherment
X509v3 Subject Alternative Name:
DNS:localhost, IP Address:127.0.0.1, IP Address:0.0.0.0
X509v3 Subject Key Identifier:
B6:FC:1E:68:CD:8B:97:D0:80:0E:F1:18:D3:39:86:29:90:0B:9D:1F
...
这样无论是访问 localhost 还是 127.0.0.1 或者 0.0.0.0,浏览器都能够信任。
来源:juejin.cn/post/7301574056720744483
云音乐贵州机房迁移总体方案回顾
一、背景
2023年确定要将云音乐整体服务搬迁至贵州机房,项目需要在各种限制条件下,保障2000+应用、100w+QPS的服务稳定迁移,是云音乐历史上规模最大、人员最多、难度最高的技术项目。在此过程中,解决了大量历史技术债务,同时化解了大量新增系统性风险。以下为总体方案回顾。
二、项目难点
- 迁移规模大
- 此次需要云音乐以及旗下独立App的服务均整体迁移至贵州。涉及2000+应用、100w+QPS的稳定迁移,同时涉及中间件、存储、机房、三方依赖服务等整体的搬迁,搬迁规模大。
- 业务复杂度高
- 场景复杂。迁移规模大,带来更广的业务场景覆盖。而不同的场景对数据一致性要求不同、延迟敏感度不同。迁移方案需要考虑各种场景带来的问题,并提供标准化的解决方案。
- 服务间依赖复杂。此次带来约2000+应用的搬迁,各服务间的调用和依赖情况复杂,在分批迁移方案中需要协调,以及解决迁移期间跨机房30msRT上升带来的问题。
- 历史积弊多
- 贵州迁移前,存在诸多历史技术积弊,影响着全站整体的稳定性。
- 新增风险大
- 贵州迁移带来诸多新增风险,且风险大、解决难度高。
- 部分场景无法做到真实环境全流程预演。
- 在基础技术建设上,也有一些不足的情况,影响整体搬迁执行效率、迁移准确性。
- 限制条件严苛
- 云音乐有着大量的用户基数,此次搬迁要求:不停机迁移、不产生P2及以上事故。除此之外还有机器、网络带宽、网络稳定性、网络RT、迁移方案等限制条件。
- 事项推进&协调难度大
- 此次搬迁规模大,同样,参与人员规模大,整体协调难度大
- 此外带来较多的人因风险。可能因极小的细节未执行到位,就会造成全局事故。
三、重点限制&要求
- 尽可能少采购或不采购额外的机器,贵州和杭州无法完全对等部署。
- 杭州与贵州的长传带宽控制在200Gbps以内,且存在闪断的可能性,各迁移方案需要重点考虑闪断带来的影响。
- 贵州机房与杭州机房之间网络延迟约30ms,各方迁移方案需重点考虑机房延迟带来的影响。
- 业务可用性要求:不影响核心重点业务场景的可用性,不出现P2及以上事故。
- 控制迁移方案对业务代码的侵入。
四、分批方案
1. 分批的原则
1.1 团队/领域间解耦
大团队/领域之间的迁移方案尽可能解耦,分不同批次搬迁。好处:
- 可以将问题拆分、领域清晰。
- 大数据、算法、云音乐技术中心串行搬迁,可以实现机器资源池共享,降低机器采购成本。
- 降低单一团队/领域切流时问题处理复杂度。
1.2 服务端流量自闭环
云音乐服务端需要将流量闭环在同一个机房,避免产生跨区域调用。
云音乐经过微服务之后,目前存在千+服务,各服务间依赖复杂。在贵州机房与杭州机房之间网络延迟约30ms的背景下,每产生一次跨区域调用,则RT上升30ms。
1.3 C端优先
优先迁移C端相关的应用及其资源,其次B端。
关于此处,会有同学认为优先B端可能会更稳,但优先采用B端优先,会有如下问题:
- B端服务搬迁后,腾挪的机器有限。
- B端服务与C端服务相差较大,即使B端服务先行搬迁无问题,也不足以证明C端服务就一定没问题。
对于如何保障C端服务搬迁的稳定性,在文章后续章节展开。
1.4 在可用资源范围内
迁移期间,需要在贵州准备与杭州同等规模的机器资源,因此批次不可能不受到资源的限制。其主要受限制资源为:
- 机器资源
- 贵州&杭州的长传带宽资源
因此,按照以上原则进行分批后,若资源仍不足,再根据团队/领域拆分出第二批
2. 最终分批方案
基于以上原则,最终分批方案如下所示
- 大数据、算法、技术中心串行搬迁。
- 心遇因强依赖云信IM服务,与云信服务独立搬迁
- 技术中心应用基本一批次全部搬迁完成。
- 技术中心的转码、公技侧后台、质量侧系统在第二批次搬迁完成。
五、切流方案
1. 切流的原则
1.1 可灰度
能够按照用户ID、设备ID、IP、流量标几个维度逐步灰度切流。
- 利于预热。在服务启动后,缓存、连接池需要随请求逐步预热,若流量直接全部打过来,可能会将服务打垮。
- 利于测试。能够灰度测试整体功能,避免大面积异常。
1.2 可回滚
尽管做了各种稳定性保障来避免回滚,但是如遇到极端情况,仍有整体回滚的可能性。因此搬迁方案必须可回滚。
1.3 控制长传带宽
在切流过程中,杭州和贵州之间会有大量的服务访问、数据传输,从而可能突破长传带宽200Gbps的限制。因此切流方案中必须减少不必要的跨区域流量。
2. 切流方案
2.1 切流点选择
服务端整体通用架构简化后,如上图所示,因此有如下几个切入点:
- 客户端切流。客户端通过动态切换域名配置,可实现流量的切换。切流算法可以与网关使用保持一致,我们在贵州迁移中就采用了此方案,从而大幅降低贵州与杭州的长传带宽。
- DNS切换。因DNS存在缓存过期,不适合作为流量控制的主要手段。在贵州迁移中,我们主要用其作为长尾流量的切换的手段。
- 四层LB切流、Nginx切流。主要由SA侧负责,因自动化和操作复杂度等因素,在贵州迁移中,四层LB切流只用于辅助切流手段,Nginx因过高的人工操作复杂度,不用于切流。
- 网关切流。网关作为服务端广泛接触的首要流量入口,其系统建设相对完善、自动化程度较高,因此作为主要切流手段。在此次迁移中,网关支持按用户ID、设备ID、IP进行按比例切流。
- 定时任务、MQ切换。主要用于定时任务、MQ的流量切换。
- RPC流量控制。RPC流量路由策略与网关保持一致,依据切流比例,进行RPC流量调用。从而避免跨机房RT的不可控。
- 存储层切换。主要负责存储的切换。
2.2 存储层迁移策略
云音乐业务场景较多,不同场景下对数据一致性的要求也不一样,例如:营收下的订单类场景需要数据强一致性,而点赞需要数据最终一致性即可。
在涉及不同的存储时,也有着多种多样的迁移策略。对此,中间件以及各存储层支持了不同的迁移策略选择,各个业务基于不同的场景,选择正确的策略。迁移策略主要如下:
类型 | 迁移策略 |
---|---|
DB | 读本地写远程、读远程写远程、读本地写本地、禁写 |
Redis | 读写远程+需要禁写、读本地写远程+需要禁写、读写本地 |
Memcached | 异步双写、同步双写、不同步 |
2.3 切流步骤
对以上切入点再次进行分类,可再次简化为流量层切流、存储层切换。在正式切流时,我们按照如下步骤进行切流。
3. 回滚方案
先存储层按序切换,然后流量层按序切换。
六、稳定性保障&治理
1. 全域的稳定性风险
- 全域的稳定性风险。我们在做一般的活动稳定性保障时,一般从活动的主链路出发,再梳理相关依赖,从而整理出稳定性保障&治理的重点。而这种方法确不适用于贵州机房迁移,从前面的分批概览图可得知:此次贵州机房迁移带来全域的稳定性风险。
- 墨菲定律:"如果一件事情有出错的可能性,那么它最终一定会出错。"
- 业界没有类似的经验可参考
因此整个项目组也在摸着石头过河,在此过程中,既有大的方案的设计,也有细枝末节的问题发现和推进处理。总结起来,我们总共从以下几个方面着手进行稳定性保障:
- 信息梳理&摸查
- 新增风险发现&处理
- 历史技术债务处理
- 标准化接入
- 监控告警增强
- 应急预案保障
- 业务侧技术方案保障
- 杭州集群下线保障
2. 信息梳理&摸查
盘点梳理机器资源情况、网络带宽、迁移期间服务可用性要求等全局限制条件,从而确定分批方案、迁移思路。
2.1 机器资源盘点
主要盘点核数、内存。在此过程中,也推进了资源利用率优化、废弃服务下线等事宜。 通过如下公式计算机器资源缺口:搬迁机器缺口 = 搬迁所需数量 -(可用数量+可优化数量)
2.2 长传带宽盘点
需要控制云音乐的长传带宽总量 <= 相对安全的带宽量 相对安全的带宽量 = (长传带宽总量 / 2 x 0.8) - 已被占用带宽量
2.3 迁移期间服务可用性要求
若业务允许全站停服迁移、或仅保障少量核心服务不挂,那么整体迁移方案会简单很多。因此业务对迁移期间的可用性要求,关乎着搬迁方案如何设计。 最终讨论后确定,需要:迁移不产生P2及以上事故
2.4 服务间跨区域调用RT摸查
基于Trace链路,预测分批情况下RT增长情况。
3. 新增系统性风险
此次贵州迁移主要带来的新增系统性风险是:
- 因公网质量问题,带来迁移后用户体验差的风险。
- 因跨机房延迟30ms ,带来的业务侧应用雪崩风险。
- 因跨机房传输网络不稳定,带来的整体系统性风险。
- 因杭州和贵州机房同时部署,带来的服务节点数量、API数量、RPC数量翻倍风险
- 因大规模数据变更,带来的系统性能风险。
- 因新机房建设、搬迁,带来的底层基础设施风险。
- 因全域团队协作、大范围配置变更&发布,带来的人因操作、协作风险。
3.1 因公网质量问题,带来迁移后用户体验差的风险
贵州公网质量如何?迁移至贵州之后是否会因公网质量问题,导致用户体验差?由于云音乐用户基数大,且注重用户体验,这个是必须提前摸清的问题。若公网质量真的存在较大问题,云音乐可能会停止贵州迁移项目。
对此,我们通过如下方式进行了公网质量验证和保障:
- 通过客户端预埋逻辑,抽样检测同时请求杭州和贵州机房的RT差异。
- 通过RT的差异,再下钻分析杭州和贵州机房的差异点。
- 解决或排除机房、客户端、域名配置等差异,最终得出公网质量的差异。
- 在正式切流前,解决完成客户端、机房等差异,保障整体网络请求质量。
- 通过QA侧的整体测试。
3.2 因跨机房延迟30ms ,带来的业务侧应用雪崩风险
云音乐C端服务当前的RT普遍在5~70ms之间,若增加30ms,可能会导致请求堆积、线程池打爆等风险。为避免此风险,我们从如下几个方面入手:
- 尽可能同一批次搬迁,避免长期跨机房调用。
- 同一批次应用,基于用户ID、设备ID、IP进行Hash,实现同机房调用优先。
- 无法同一批次搬迁的应用。
- 确保会只跨一次,避免因循环调用等原因导致的多次跨机房。
- 需提供降级方案,对服务弱依赖。
- 服务需通过QA侧的测试。
3.3 因跨机房传输网络不稳定,带来的整体系统性风险
跨机房网络的现状和参考数据:
- 共计2条线,单条带宽为:100Gbps,但建议保持单条利用率在80%及以下。
- 参考网易北京与杭州的长传带宽质量。
- 可能会出现单条中断的情况,在网络侧的表现为网络抖动。若单条线中断,那么发生故障的请求会重连至另一条线。
- 极低概率出现2条线全部中断的情况。
基于以上现状,需要重点考虑并解决:
- 各中间件、存储在切流期间,长传网络出现问题时的表现、应对和兜底措施。例如ZK重连、重连失败后的重连风暴问题。
- 各服务在切流完成后,若仍长期使用长传网络,若长传网络出现问题的表现、应对和兜底措施。
在贵州迁移项目中,我们对以上重点问题进行了梳理和解决,并制定了各种应急预案和极端情况下的回滚方案。
3.4 因杭州和贵州机房同时部署,带来的服务节点数量、API数量、RPC数量翻倍风险
因杭州和贵州机房同时部署,带来的服务节点数量、API数量、RPC数量翻倍风险
在服务节点数量、API数量、RPC数量翻倍后,主要对底层依赖带来连接、重连上的冲击,以及原有连接数上限的冲击。
在我们实际搬迁中,也因遗漏了这一点,导致线上ZK出现瓶颈,进而ZK挂掉的问题。其主要表现为在网关场景下存在数据推送瓶颈。最终通过网关侧的ZK拆分解决该问题。
除此之外,DB、Memcached、Redis、MQ等资源的连接数也可能会超过原先设定的上限,需要评估后进行调整。
3.5 因大规模数据变更,带来的系统性能风险
大规模数据变更的场景包含但不限于:
- 批量调整配置中心值,因达到配置中心的性能瓶颈,导致配置变更时间过长,或服务挂掉。
- 批量的服务部署、重启,因达到K8S、构建机的性能瓶颈,导致部署、重启时间过长,或服务挂掉。
- 对迁移当晚核心路径上的服务进行集中访问、操作,因达到服务的性能瓶颈,导致访问超时、白屏、数据延迟、或服务挂掉的问题。
针对以上风险,我们重点对配置中心、K8S、贵州迁移管控平台等系统进行了性能优化,以支撑整体迁移。
3.6 因新机房建设、搬迁带来的底层基础设施风险。
因新机房建设、搬迁带来的底层基础设施风险包含但不限于:
- 同城双活能力的缺失。为应对此风险,我们在逻辑上继续保留同城双活的能力,并暂时通过机房不同楼层的部署架构,来尽可能弥补同城双活能力的缺失。
- 机器上架、环境搭建、网络传输等需确保达到验收标准。为应对此风险,运维侧提供相关方案保障整体环境,并最终通过业务侧QA验收。
3.7 因全域团队协作、大范围变更&发布,带来的人因操作、协作风险
在贵州迁移前,已经有多次发生因配置变更错误带来的事故。而此项目带来从未有过的全域迁移,全域协作,大范围变更&发布,风险不可谓不高。在此过程中,通过了许多方式来保障事项的落地,其中比较关键的点,也是项目成功的关键点包括:
- 各部门领导与同事的支持。
- 分工明确。在战略、战术、细节、事项推进等多个点均有相关人员把控,各司其职。
- 各项信息的细化梳理&定位。
- 定期的沟通协作会议,通过敏捷式项目管理,进行滚动式问题发现。
- 问题发现、治理、验证必须闭环。
- 尽可能中心系统化、自动化处理。无法自动化的,则提供标准化实施手册。
- 重点问题,case by case,one by one。
4. 历史技术债务处理
在贵州迁移项目中,比较突出的历史债务处理有:
- ZK强依赖问题
- 在线业务Kafka迁移Nydus。
- 配置硬编码
- 服务间依赖改造
- 资源优化&控制
- 心遇依赖拆分
- 元信息不准确
- 组件版本过于陈旧问题
- 测试环境自动化部署成功率低
- 租户多集群拆分为多应用
4.1 ZK强依赖问题
ZK的不稳定已导致云音乐最高出现P1级事故,在贵州迁移项目中,因网络环境、机房环境、迁移复杂度等因素,ZK服务挂掉的概率极大,因此必须不能对其强依赖。
最终中间件侧对其改造,支持ZK发生故障时,其注册信息降级到本地内存读取。并推进相关依赖方进行升级改造。
4.2 在线业务Kafka迁移Nydus。
Nydus作为云音乐主力MQ产品,相较开源Kafka有更好的监控、运维等能力,Kafka在云音乐在线业务中已不再推荐使用。在贵州迁移中,MQ也需要进行两地切换/切流。
主要收益:
- 在线业务稳定性
- Kafka机器资源回收
- MQ切流特性&历史债务收敛
在推进层面:
- 第一里程碑:生产者完成双写
- 第二里程碑:消费者完成双消费
- 第三里程碑:完成废弃TOPIC下线、代码下线等收尾工作
4.3 配置硬编码
在贵州迁移项目中,需要做大量的配置迁移、变更。其主要为:机房名、集群名、机器IP、机器Ingress域名的变化。而这些在配置中心、代码、自动化脚本、JVM参数中均有存在,此外,IP黑白名单还可能涉及到外部厂商的改造变更。
在具体推进上,采用自动化扫描+人工梳理结合,并辅以标准化改造指引文档。
- 自动化扫描:通过代码扫描、配置中心扫描、JVM参数扫描、连接扫描等方式进行问题发现。
- 人工梳理:外部厂商、不受Git管控的脚本、以及运维侧的配置(例如:存储层访问权限的黑白名单等)、以及自动化扫描可能的遗漏,由各研发、运维人员再次自行梳理。
4.4 服务间依赖改造
核心应对杭州与贵州跨机房30ms RT和长传网络不稳定的风险。对循环调用、不合理依赖、强依赖进行改造。
- 减少不必要依赖。
- 必须不能出现服务跨机房强依赖。
- 不能因循环调用导致跨机房RT飙升。
4.5 资源优化&控制
因贵州需要与杭州同等容量部署,可能存在资源不足的情况。对此需要:
- 统一服务的资源利用率标准,推进资源利用率改造
- 对部分服务进行合并、下线、缩容处理。
4.6 心遇依赖拆分
因心遇强依赖云信,且云信IM为心遇核心业务功能,最终确定心遇为独立批次搬迁。因此心遇依赖的中台服务、存储、算法&大数据相关任务,均需拆分出来,不能与云音乐耦合,否则会产生跨机房调用,影响服务稳定性。
4.7 元信息不准确
在此次迁移中,存在较多的元信息不准确的问题,例如:
不足项 | 解释 |
---|---|
应用的元信息需要补充、更新 | 1. 应用归属的团队信息不准确 2. 应用的废弃、待废弃状态未知 3. 测试应用、非业务应用信息偏杂乱 |
应用团队归属信息多处维护,未统一 | 应用在多个平台均有维护,且均存在维护不准确的问题 |
应用的各项依赖信息不全 | 应用依赖的db、redis、memcached资源,以及在配置中心的key无法全面准确拉取 |
应用的各项依赖信息可视化、系统化建设不足 | 1. 应用依赖的组件版本、依赖的存储资源等,缺乏友好的可视化查询能力。 2. 各项信息之间的关联性建设不足 |
底层中间件、存储元信息不全 | 1. 不同的ZK集群的用处缺乏统一维护。 2. 各项元信息反查调用源IP、集群、应用、团队、负责人的能力不足 |
以上问题在迁移中,通过脚本、1对1沟通确认、手动梳理等多种方式进行了临时处理,在贵州迁移后,仍需再全面的系统性规划。
4.8 组件版本过于陈旧问题
有较多的应用长期不升级,与最新版本跨度较大,存在较多的兼容性问题,需要人工进行升级处理。升级流程大致如下:
在迁移中期,我们进行了自动升级平台建设,基本支持以上升级流程自动化。
4.9 测试环境自动部署成功率低
因此次迁移涉及全部的应用在不同环境的部署,全部人工操作的效率过低,因此我们在非线上环境均由脚本自动化部署,而测试环境由于维护不足,部署成功率较低。
4.10 租户多集群拆分为多应用
当前贵州迁移时整体会按照应用维度进行迁移、切流到贵州。因此对于中台租户型应用、多地域注册类型的应用需要拆分。
5. 标准化接入
除了以上提到的历史技术债务处理和新增系统性风险,公共技术侧大都提供了标准化的接入、改造治理方式。例如:
- 贵州迁移中间件方案汇总。涵盖所有涉及中间件的迁移、切流、迁移策略、接入等指导方案。
- 贵州迁移升级指导。涵盖自动升级与手动升级、脚手架应用与非脚手架应用的升级方案。
- 贵州迁移线上部署指导。涵盖贵州线上部署前的各项必要准备事项,以及特殊应用的注意事项。
- 贵州迁移监控大盘观测指导。涵盖各类迁移监控的观测指导。
- 中台、多地域注册拆分指导。涵盖中台租户、多地域注册类型应用的拆分指导方案,以及整体的拆分流程、验证要点等。
- ddb、redis、memcached、KSchedule等非标治理。涵盖各中间件、存储的非标风险列表、处理办法等。
- 杭州集群下线指导。涵盖杭州集群如何观察、缩容、下线、机器回收的指导方案。
6. 监控告警
在监控告警层面,主要提供了:
- 贵州迁移整体大盘监控。提供了迁移相关全局比例,异常流量,异常比例,能够区分是迁移导致的还是本身杭州服务就有问题导致。同时集成资源层相关指标,判断是单个资源有问题还是全部资源有问题。
- 贵州迁移应用监控。提供了单个应用的贵州迁移监控,应用贵州杭州流量比例,异常流量,异常比例,能够区分是贵州还是杭州的问题。同时有资源相关的指标。
- 杭州集群与贵州集群的哨兵监控对比分析。提供指定应用的杭州和贵州集群在CPU利用率、线程池满、异常比例、RT超时等维度的对比。
- 全局/应用的SLO监控。提供核心指标受损监控。
- 应用层面的系统监控。研发可通过哨兵、APM来查看定位具体的问题。
7. 应急预案
在贵州迁移期间,基于以上风险,主要准备如下应急预案:
- 客户端截流。在开启后,客户端将访问本地或CDN缓存,不再向服务端发送请求。
- 全站服务QPS限流至安全阈值。在开启后,全站的后端服务将限流调整至较低的安全阈值上,在极端情况下,避免因跨机房RT、跨机房传输、跨机房访问等因素的性能瓶颈引起服务端雪崩。
- 长传带宽监控&限流。在开启后,部分离线数据传输任务将会被限流。保障在线业务的带宽在安全水位下。
- 回滚方案。当出现重大问题,且无法快速解决时,逐步将存储、流量切回杭州。
- 外网逃生通道。当出现长传网络完全中断,需要回滚至杭州。通过外网逃生通道实现配置、核心数据的回滚。
- 业务领域内的应急预案。各业务领域内,需要考虑切流前的主动降级预案、切流中的应急预案。
- 批量重启。当出现局部服务必须通过重启才能解决的问题时,将会启用批量重启脚本实现快速重启。当出现全局服务必须通过重启才能解决问题时,需要当场评估问题从而选择全量重启或全量回滚至杭州。
8. 业务技术侧方案
业务技术侧方案重点包含但不限于:
- 应用搬迁范围、搬迁批次梳理明确。当上下游依赖的应用处于不同批次时,需要跨团队沟通协调。
- 明确业务影响,从而确定各应用的中间件、存储迁移策略。
- 历史技术债务处理
- 标准化接入
- 核心场景稳定性保障方案
- 核心指标监控建设完善。
- 切流SOP。包括切流前(前2天、前1天、前5分钟)、切流中、切流后各阶段的执行事项。
- 切流降级方案、应急预案
- 切流停止标准
9. 杭州集群下线
在服务迁移至贵州后,若杭州仍有流量调用,需排查流量来源,并推进流量下线或转移至贵州。先缩容观察,无正常流量、CDN回源等之后,再做集群下线。
七、测试&演练
此次贵州迁移,在各应用标准化治理之后,通过系统批量工具完成贵州各项环境的搭建、测试环境的批量部署。
1. 测试环境演练
1.1 准备事项
在测试演练开始前,我们重点做了如下准备:
- 贵州测试环境批量创建。通过迁移工具,实现贵州测试集群的批量创建、配置批量迁移等。
- 应用自动化升级。通过自动升级平台,实现大规模应用的批量升级,支持了各组件、各应用的多次快速验证、快速升级。
- 测试环境自动化部署。通过自动化部署脚本,为支持测试环境能够多次、高效演练。
- SOP梳理&平台建设。通过SOP平台,将SOP文档沉淀为系统能力,实现各SOP能力的系统化。
- 迁移监控大盘建设。通过细化梳理监控指标,构建监控大盘,掌握各应用、各组件在切流期间的表现。
1.2 执行步骤
在测试环境演练,总体思路是逐步扩大验证范围,最终达到全局基本功能基本验证通过。以下为主要演练顺序,每一步视执行结果,再选择是否重复执行。
顺序 | 验证事项 |
---|---|
1 | 验证中间件内部逻辑是否正确: 1. 网关、RPC、存储层路由策略是否正确。 2.验证监控大盘是否正确 3.验证SOP平台是否正确 4.... |
2 | 验证存储层切换是否正确 |
3 | 逐一对各业务团队进行演练: 1.加深各团队对切流能力的感知。 2.验证收集中间件、存储在各领域的表现。 3.验证各团队、各领域迁移策略的合理性 |
4 | 对BFF、FaaS等特殊应用类型进行演练 |
2. 线上环境演练
因测试环境和线上环境仍存在较大的差异,需要摸清线上真实情况,在演练原则和演练目标上均较测试环境演练有更严格、细致的要求。
2.1 演练原则
- 不对线上数据产生污染;
- 不产生线上 P2 以上事故。
2.2 演练目标
分类 | 目标内容 |
---|---|
公技演练目标 | 1. 切流验证,网关,rpc,贵州迁移大盘监控 2.网关切流比例、快慢,数据库 ddb 贵州跨机房建连对业务影响 3.端上切流,网关切流验证 |
业务演练目标 | 1.流量切换,贵州跨机房对业务影响 2.业务指标和SLO 3.业务预案有效性验证 4.RT变化情况 |
存储演练目标 | 1.ddb 复制延迟,连接数(由于跨机房创建DDB连接非常慢, 主要观察流量到贵州后新建连接对应用和数据库影响及恢复情况) 2.redis数据同步、整体表现 |
网络演练目标 | 1.跨机房延迟情况 2.跨机房带宽实际占用 3.网络带宽占用监控 |
2.3 演练终止条件
- P0、P1 核心场景 SLO 95%以下;
- 用户舆情增长波动明显;
- 跨机房网络大规模异常;
- 大量业务指标或者数据异常;
- 贵州流量达到预定 90%。
3. 独立App迁移验证
在云音乐主站正式切流前,先对云音乐旗下独立App进行了线上搬迁验证,保障云音乐迁移时的稳定性。
八、系统沉淀
1. SOP平台
SOP即标准作业程序(Standard Operating Procedure),源自传统工业领域,强调将某项操作以标准化、流程化的方式固化下来。
SOP平台将标准化、流程化的操作进行系统化呈现,并对接各中间件平台,实现操作效率的提升。在贵州迁移过程中,能够实现多部门信息同步、信息检查,并显著降低批量操作的出错概率、执行效率,降低人因风险。同时也可为后续其他大型项目提供基础支撑。
2. 自动升级平台
自动升级平台串联代码升级变更、测试部署、测试验证、线上发布、线上检测,实现升级生命周期重要节点的自动化。在贵州迁移过程中,显著提升整体升级、验证、部署效率。同时可为后续的大规模组件升级、组件风险治理、组件兼容性摸查、Sidecar式升级提供基础支撑。
九、不足反思
1. 元信息建设仍然不足
精准筛选出每项事宜涉及的范围,是顺利进行各项风险治理的前提条件。在此次贵州机房迁移中也暴露出元信息建设不足的问题。
不足项 | 解释 |
---|---|
应用的元信息需要补充、更新 | 1. 应用归属的团队信息不准确 2. 应用的废弃、待废弃状态未知 3. 测试应用、非业务应用信息偏杂乱 |
应用团队归属信息多处维护,未统一 | 应用在多个平台均有维护,且均存在维护不准确的问题 |
应用的各项依赖信息不全 | 应用依赖的db、redis、memcached资源,以及在配置中心的key无法全面准确拉取 |
应用的各项依赖信息可视化、系统化建设不足 | 1. 应用依赖的组件版本、依赖的存储资源等,缺乏友好的可视化查询能力。 2. 各项信息之间的关联性建设不足 |
底层中间件、存储元信息不全 | 1. 不同的ZK集群的用处缺乏统一维护。 2. 各项元信息反查调用源IP、集群、应用、团队、负责人的能力不足 |
2. 各项元信息的创建、更新、销毁标准化、系统化
在贵州迁移过程中,做了历史技术债务处理、标准化接入方式,后续可针对各项元信息的创建、更新、销毁进行标准化、系统化建设。例如:
- 应用、集群的创建和销毁需要前置校验、审批。以及后期的架构治理扫描。
- 借助组件升级平台,实现组件发布、升级的标准化、系统化。
- DB、Redis、Memcached、ZK的申请、使用、接入等标准化、防劣化。
3. 应用配置标准化
目前应用可做配置的入口有:配置中心、properties文件、props文件、JVM参数、硬编码。不同的中间件提供出的配置方式也各有不同,所以各应用的配置比较五花八门。因此可做如下改进:
- 明确各种配置入口的使用标准。比如:什么时候建议用配置中心?什么时候建议用JVM参数?
- 在组件提供侧、应用研发侧均有一定的宣贯、提示。避免配置方式过于杂乱。
- 提供配置统一上报的能力。助力元信息的建设。
4. 批处理能力需再进一步增强
在贵州机房迁移中,除了SOP平台和自动升级平台的系统沉淀外,业务中间件、Horizon部署平台都提供了一定的工具支撑,从而在一定程度上提升了整体迁移的效率。在之后,随着对效率、系统间融合的要求的提高。需要继续在功能、性能、稳定性等多个层面,继续对批处理、系统间融合进行系统化建设。例如:
- 批量拉取、筛选指定条件的应用以及相关依赖信息。
- 基于指定的环境、团队、应用、集群等维度,进行服务的批量重启、部署。此处需要进一步提升测试环境部署成功率
- 基于指定的应用、集群等维度,进行批量的服务复制、配置复制。
5. ZK稳定性、可维护性优化
在贵州迁移中,ZK的问题相对突出,对此也投入了比较多的人力去排查、解决以及推进风险治理。后续仍需要在ZK的稳定性、可维护性上探讨进一步优化的可能性:
- ZK元信息的维护和使用标准。明确各ZK集群的用处、各ZK Path的用处,ZK集群间隔离、复用的标准,并推进相关标准化治理。
- ZK故障时,因开启降级至内存,业务无法重启服务。若故障期间叠加其他事故,则会导致其他事故被放大。
- 其他稳定性、可维护性梳理
6. 公技侧稳定性保障长效机制和系统化建设
尽管在贵州机房迁移中,做了大量的稳定性保障措施,但依赖每个研发对各自负责领域的理解、运维能力。是否能在团队管理、设施管理、服务管理、稳定性管理、架构设计等多方面,探索出一套可持续的长效保障机制?并进行一定的稳定性系统化建设?从而避免点状问题随机发生。
7. 组件生产、发布、治理能力增强
贵州迁移中涉及大量的组件变更与发布,以及业务侧组件升级与治理。组件可以从生产侧和使用侧进行分析,而组件生命周期主要由2条主线贯穿:
- 组件生产发布线:组件的生产、测试验证、发布。
- 组件风险治理线:风险定义、风险发现、升级推进、升级验证
依据此分类,服务端的组件管理仍有较多可提升空间。
来源:juejin.cn/post/7389952004791894016
JavaScript实现访问本地文件夹
这个功能放在之前是不可能实现的,因为考虑到用户的隐私,但是最近有一个新的api可以做到这一点。下面来进行一个简单的功能实现。
如何选择文件夹
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<button>打开文件夹</button>
<script>
var btn = document.querySelector('button');
btn.onclick=function() {
showDirectoryPicker()
}
</script>
</body>
</html>
我们调用showDirectoryPicker这个函数就可以实现一个选择文件夹的功能。
showDirectoryPicker()
options
可选
选项对象,包含以下属性:
id
通过指定 ID,浏览器能够记住不同 ID 所对应的目录。当使用相同的 ID 打开另一个目录选择器时,选择器会打开相同的目录。mode
字符串,默认为"read"
,可对目录进行只读访问。设为"readwrite"
可对目录进行读写访问。startIn
一个FileSystemHandle
对象或者代表某个众所周知的目录的字符串(如:"desktop"
、"documents"
、"downloads"
、"music"
、"pictures"
、"videos"
)。用于指定选择器的起始目录。
返回值
一个 Promise
对象,会兑现一个 FileSystemDirectoryHandle
(en-US) 对象。
异常
AbortError
当用户直接关闭了目录选择器或选择的目录是敏感目录时将会抛出 AbortError。
如何得到文件夹中的文件/子文件夹
首先对于上面所写的东西,我们进行try catch的优化
try {
// 获得文件夹的句柄
const handle = await showDirectoryPicker();
}
catch {
//用户拒绝查看文件
alert('访问失败')
}
之后我们来看一下这个headler打印出来是什么
句柄的简单解释
对于“句柄”,在下一直停留在一知半解的认识层面,近日在下学习Windows编程,决定趁此机会将句柄彻底搞清楚。查阅了一些网络上的资料,发现网络上的讲解大概可以分为两类:一种是以比喻、类比的方式说明,这种方法虽然形象易懂,但并没有从原理上、本质上加以揭示,让人仍然想问“为什么?”、“怎么实现?”。另一种是给出源代码,无可厚非,这当然是最本质的说明了,但这样一来,又显得不够直观,初学者理解起来有一定的难度。鉴于此,在下尽微末之能,结合自己的愚见,在两者之间折中,用图解的方式来将原理呈现出来,做到一目了然。
这里需要说明:
1.这里将句柄所能标识的所有东西(如窗口、文件、画笔等)统称为“对象”。
2.图中一个小横框表示一定大小的内存区域,并不代表一个字节,如标有0X00000AC6的横框表示4个字节。
3.图解的目的是为了直观易懂,所以不一定与源码完全对应,会有一定的简化。
让我们先看图,再解释。
其中,图1是程序运行到某时刻时的内存快照,图2是程序往后运行到另一时刻时的内存快照。红色部分标出了两次的变化。
简单解释:
Windows是一个以虚拟内存为基础的操作系统,很多时候,进程的代码和数据并不全部装入内存,进程的某一段装入内存后,还可能被换出到外存,当再次需要时,再装入内存。两次装入的地址绝大多数情况下是不一样的。也就是说,同一对象在内存中的地址会变化。(对于虚拟内存不是很了解的读者,可以参考有关操作系统方面的书籍)那么,程序怎么才能准确地访问到对象呢?为了解决这个问题,Windows引入了句柄。
系统为每个进程在内存中分配一定的区域,用来存放各个句柄,即一个个32位无符号整型值(32位操作系统中)。每个32位无符号整型值相当于一个指针,指向内存中的另一个区域(我们不妨称之为区域A)。而区域A中存放的正是对象在内存中的地址。当对象在内存中的位置发生变化时,区域A的值被更新,变为当前时刻对象在内存中的地址,而在这个过程中,区域A的位置以及对应句柄的值是不发生变化的。这种机制,用一种形象的说法可以表述为:有一个固定的地址(句柄),指向一个固定的位置(区域A),而区域A中的值可以动态地变化,它时刻记录着当前时刻对象在内存中的地址。这样,无论对象的位置在内存中如何变化,只要我们掌握了句柄的值,就可以找到区域A,进而找到该对象。而句柄的值在程序本次运行期间是绝对不变的,我们(即系统)当然可以掌握它。这就是以不变应万变,按图索骥,顺藤摸瓜。
**所以,我们可以这样理解Windows **句柄:
数值上,是一个32位无符号整型值(32位系统下);逻辑上,相当于指针的指针;形象理解上,是Windows中各个对象的一个唯一的、固定不变的ID;作用上,Windows使用句柄来标识诸如窗口、位图、画笔等对象,并通过句柄找到这些对象。
下面,关于句柄,再交代一些关键性细节:
1.所谓“唯一”、“不变”是指在程序的一次运行中。如果本次运行完,关闭程序,再次启动程序运行,那么这次运行中,同一对象的句柄的值和上次运行时比较,一般是不一样的。
其实这理解起来也很自然,所谓“一把归一把,这把是这把,那把是那把,两者不相干”(“把”是形象的说法,就像打牌一样,这里指程序的一次运行)。
2.句柄是对象生成时系统指定的,属性是只读的,程序员不能修改句柄。
3.不同的系统中,句柄的大小(字节数)是不同的,可以使用sizeof()来计算句柄的大小。
4.通过句柄,程序员只能调用系统提供的服务(即API调用),不能像使用指针那样,做其它的事。
再回归正题。
处理句柄函数
async function processHandler(handle) {
if (handle.kind==='file'){
return handle
}
handle.children=[]
const iter = await handle.entries();//获得文件夹中的所有内容
//iter:异步迭代器
for await (const info of iter){
var subHandle = await processHandler(info[1]);
handle.children.push(subHandle)
}
return handle
}
如何得到文件内容
const root = await processHandler(handle);
// 获得文件内容
const file = await root.children[1].getFile();
const reader = new FileReader();
reader.onload=e=>{
// 读取结果
console.log(e.target.result)
}
reader.readAsText(file,'utf-8')
这里用到的就是一个很简单的文件读了。
下面是完整的代码
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<button>打开文件夹</button>
<script>
var btn = document.querySelector('button');
btn.onclick=async function() {
try {
// 获得文件夹的句柄
const handle = await showDirectoryPicker();
const root = await processHandler(handle);
// 获得文件内容
const file = await root.children[1].getFile();
const reader = new FileReader();
reader.onload=e=>{
// 读取结果
console.log(e.target.result)
}
reader.readAsText(file,'utf-8')
}
catch {
//用户拒绝查看文件
alert('访问失败')
}
}
async function processHandler(handle) {
if (handle.kind==='file'){
return handle
}
handle.children=[]
const iter = await handle.entries();//获得文件夹中的所有内容
//iter:异步迭代器
for await (const info of iter){
var subHandle = await processHandler(info[1]);
handle.children.push(subHandle)
}
return handle
}
</script>
</body>
</html>
来源:juejin.cn/post/7268011328940769315
AI真的可以写出可商用的代码吗?
背景
在MQTT中,代理(Broker)将发布的消息(publish messages)根据它们的主题(topic)匹配到订阅了这些主题的客户端。这个过程对于客户端来说是透明的,因为客户端只需要指定它们感兴趣的主题,而MQTT代理负责将消息分发给所有订阅了匹配主题的客户端。 如果想通过topic作为解耦key,实现一个客户端多个Listener,每个Listener都具备自己的topic及业务处理逻辑(类似RocketMQ消费者模式)。所以MQTT客户端具备把消息根据topic路由到相应的Listener中。 回归主题我希望通过AI帮实现topic路由。(实现matchTopic方法)
private boolean matchTopic(String subscribedTopic, String publishedTopic)
AI写代码
AI选择:通义千问
第一次
指令
通过java实现mqtt的topic路由匹配的方法matchTopic(String subscribedTopic,String publishedTopic)
AI输出(只粘贴代码部分)
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class MqttTopicMatcher {
/**
* 模拟MQTT的topic匹配。
*
* 注意:这个实现是简化的,它不支持通配符在主题字符串中的非末尾位置,
* 并且没有处理多层加号(`+`)和井号(`#`)的复杂情况。
* 它主要用于理解和教学目的。
*
* @param subscribedTopic 订阅的主题,可能包含`+`或`#`通配符
* @param publishedTopic 发布的主题
* @return 如果发布的主题与订阅的主题匹配,则返回true;否则返回false
*/
public static boolean matchTopic(String subscribedTopic, String publishedTopic) {
// 将加号(`+`)替换为正则表达式中的任意字符(`.`)
String regexTopic = subscribedTopic.replace("+", ".");
// 处理井号(`#`):如果订阅的主题以`#`结尾,则替换为任意数量的任意字符(`.*`)
// 注意:这里我们假设`#`只出现在字符串的末尾,且前面没有其他通配符
if (regexTopic.endsWith("#")) {
regexTopic = regexTopic.substring(0, regexTopic.length() - 1) + ".*";
}
// 编译正则表达式
Pattern pattern = Pattern.compile("^" + regexTopic + "$");
// 匹配发布的主题
Matcher matcher = pattern.matcher(publishedTopic);
return matcher.matches();
}
public static void main(String[] args) {
// 示例
String subscribedTopic1 = "sensors/+/level";
String publishedTopic1 = "sensors/temperature/level";
System.out.println(matchTopic(subscribedTopic1, publishedTopic1)); // 应输出 true
String subscribedTopic2 = "sensors/#";
String publishedTopic2 = "sensors/temperature/room1/level";
System.out.println(matchTopic(subscribedTopic2, publishedTopic2)); // 应输出 true
String subscribedTopic3 = "sensors/+";
String publishedTopic3 = "sensors/temperature/level";
System.out.println(matchTopic(subscribedTopic3, publishedTopic3)); // 应输出 false,因为`+`只匹配单个层级
// 注意:这个实现是简化的,不涵盖所有MQTT topic匹配的场景
}
}
验证
public static void main(String[] args) {
boolean a = matchTopic("$SYS/brokers/+/clients/#", "$SYS/brokers/emqx@172.51.0.6/clients/mqttx_b2136a2c/connected");
System.out.println("msg: " + a); // 应该输出 true
boolean b = matchTopic("DEVICE/+/msg", "DEVICE/0000000091238112/msg");
System.out.println("msg: " + b); // 应该输出 true
}
public static boolean matchTopic(String subscribedTopic, String publishedTopic) {
// 将加号(`+`)替换为正则表达式中的任意字符(`.`)
String regexTopic = subscribedTopic.replace("+", ".");
// 处理井号(`#`):如果订阅的主题以`#`结尾,则替换为任意数量的任意字符(`.*`)
// 注意:这里我们假设`#`只出现在字符串的末尾,且前面没有其他通配符
if (regexTopic.endsWith("#")) {
regexTopic = regexTopic.substring(0, regexTopic.length() - 1) + ".*";
}
// 编译正则表达式
Pattern pattern = Pattern.compile("^" + regexTopic + "$");
// 匹配发布的主题
Matcher matcher = pattern.matcher(publishedTopic);
return matcher.matches();
}
实质输出结果:
msg: false
msg: false
结论:
不符合
第二次
指令
public static void main(String[] args) {
boolean a = matchTopic("$SYS/brokers/+/clients/#", "$SYS/brokers/emqx@172.51.0.6/clients/mqttx_b2136a2c/connected");
System.out.println("msg: " + a); // 应该输出 true
boolean b = matchTopic("DEVICE/+/msg", "DEVICE/0000000091238112/msg");
System.out.println("msg: " + b); // 应该输出 true
}
public static boolean matchTopic(String subscribedTopic, String publishedTopic) {
// 将加号(`+`)替换为正则表达式中的任意字符(`.`)
String regexTopic = subscribedTopic.replace("+", ".");
// 处理井号(`#`):如果订阅的主题以`#`结尾,则替换为任意数量的任意字符(`.*`)
// 注意:这里我们假设`#`只出现在字符串的末尾,且前面没有其他通配符
if (regexTopic.endsWith("#")) {
regexTopic = regexTopic.substring(0, regexTopic.length() - 1) + ".*";
}
// 编译正则表达式
Pattern pattern = Pattern.compile("^" + regexTopic + "$");
// 匹配发布的主题
Matcher matcher = pattern.matcher(publishedTopic);
return matcher.matches();
}
实质输出结果:
msg: false
msg: false
AI输出(只粘贴代码部分)
您提供的代码中,matchTopic
方法用于将MQTT主题过滤器subscribedTopic
转换为正则表达式,并检查发布的主题publishedTopic
是否与该正则表达式匹配。但是,这里有几个需要注意的点:
正则表达式边界:
在 MQTT 主题匹配中,通常期望整个主题字符串与过滤器完全匹配(或至少从开头开始匹配,直到过滤器的末尾)。然而,find()
方法在Matcher
类中用于查找与正则表达式匹配的任何子序列,而不是整个字符串。因此,如果publishedTopic
包含比subscribedTopic
更长的前缀,find()
仍然会返回true,即使后续部分不匹配。 完整的字符串匹配:
为了确保整个publishedTopic
字符串与subscribedTopic
匹配(或匹配到subscribedTopic
指定的模式),您应该在正则表达式的前后添加^
和$
锚点。 处理$
前缀:
在MQTT中,$SYS
是一个保留前缀,用于系统主题。在正则表达式中,$
是一个特殊字符,表示字符串的末尾。但是,由于MQTT主题中的$
仅仅是文本的一部分,您不需要在正则表达式中对它进行特殊处理(除非您打算在正则表达式中直接使用$
的特殊含义)。
下面是修改后的matchTopic
方法,它使用matches()
,并确保整个字符串匹配:
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public static boolean matchTopic(String subscribedTopic, String publishedTopic) {
// 将 + 替换为 [^/]+,将 # 替换为 .*,并在字符串前后添加 ^ 和 $ 以确保整个字符串匹配
String regexTopic = "^" + subscribedTopic.replace("+", "[^/]+").replace("#", ".*") + "$";
Pattern pattern = Pattern.compile(regexTopic);
Matcher matcher = pattern.matcher(publishedTopic);
return matcher.matches(); // 使用 matches() 来确保整个字符串匹配
}
public static void main(String[] args) {
boolean a = matchTopic("$SYS/brokers/+/clients/#", "$SYS/brokers/emqx@172.51.0.6/clients/mqttx_b2136a2c/connected");
System.out.println("msg: " + a); // 应该输出 true
boolean b = matchTopic("DEVICE/+/msg", "DEVICE/0000000091238112/msg");
System.out.println("msg: " + b); // 应该输出 true
}
验证
实质输出结果:
msg: false msg: true
结论:
不符合
第三次
指令
结果还是msg: false
AI输出(只粘贴代码部分)
在从你提供的代码来看,问题可能在于正则表达式的构建方式。+
和#
通配符需要正确地转换为正则表达式来匹配MQTT主题。
+
代表单个层级中的任意非斜杠字符。
#
代表从层级的某一点开始到主题结尾的所有层级。
你的代码中已经正确地处理了+
和#
,但可能存在一些细微的问题导致输出结果为false
。
让我们稍微调整一下你的代码,以确保它能够正确地处理MQTT主题的匹配:
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public static boolean matchTopic(String subscribedTopic, String publishedTopic) {
// 将 MQTT 通配符转换为正则表达式
String regexTopic = "^" + subscribedTopic.replace("+", "[^/]+").replace("#", "([^/]+)/.*") + "$";
// 编译正则表达式
Pattern pattern = Pattern.compile(regexTopic);
// 创建匹配器并检查是否匹配
Matcher matcher = pattern.matcher(publishedTopic);
return matcher.matches(); // 使用 matches() 来确保整个字符串匹配
}
public static void main(String[] args) {
boolean a = matchTopic("$SYS/brokers/+/clients/#", "$SYS/brokers/emqx@172.51.0.6/clients/mqttx_b2136a2c/connected");
System.out.println("msg: " + a); // 应该输出 true
boolean b = matchTopic("DEVICE/+/msg", "DEVICE/0000000091238112/msg");
System.out.println("msg: " + b); // 应该输出 true
}
在上述代码中,我将#
的替换部分稍作修改。原来的".*"
可能会匹配任何字符(包括斜杠),这可能会导致意外匹配。通过使用"([^/]+)/.*"
,我们确保了 # 之前的部分必须是一个不包含斜杠的字符串,并且之后可以跟任何东西(包括零个或多个层级)。
让我们尝试运行这段修改后的代码。如果你仍然遇到问题,请告诉我,我们可以进一步调试。
验证
实质输出结果:
msg: false msg: true
结论:
不符合
第四次
指令
运行结果还是不对,$SYS/brokers/emqx@172.51.0.6/clients/mqttx_b2136a2c/connected
,输出结果还是msg: false
AI输出(只粘贴代码部分)
在既然正则表达式看起来是正确的,但仍然没有匹配成功,我们需要进一步检查正则表达式的实现细节。问题可能在于正则表达式如何处理#
之后的内容。 在MQTT中,#
代表从层级的某一点开始到主题结尾的所有层级。这意味着#
之前的部分必须是一个完整的层级,而#
之后的部分可以是零个或多个层级。 让我们再次修改正则表达式以确保这一点得到正确的处理:
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public static boolean matchTopic(String subscribedTopic, String publishedTopic) {
// 将 MQTT 通配符转换为正则表达式
String regexTopic = "^" + subscribedTopic
.replace("+", "[^/]+")
.replace("#", "([^/]+)(/.*)?")
+ "$";
System.out.println("Regex topic: " + regexTopic); // 添加这一行以查看正则表达式
// 编译正则表达式
Pattern pattern = Pattern.compile(regexTopic);
// 创建匹配器并检查是否匹配
Matcher matcher = pattern.matcher(publishedTopic);
return matcher.matches(); // 使用 matches() 来确保整个字符串匹配
}
public static void main(String[] args) {
boolean a = matchTopic("$SYS/brokers/+/clients/#", "$SYS/brokers/emqx@172.51.0.6/clients/mqttx_b2136a2c/connected");
System.out.println("msg: " + a); // 应该输出 true
boolean b = matchTopic("DEVICE/+/msg", "DEVICE/0000000091238112/msg");
System.out.println("msg: " + b); // 应该输出 true
}
这里的关键变化是在#
的替换部分:
"([^/]+)(/.*)?"
:这部分正则表达式意味着#
前面的部分必须是一个不包含斜杠的字符串,而#
后面的部分可以是零个或多个层级。"(.*)" / "(/.*)"
都用来匹配剩余的路径,但(/.*)?
表示这个部分是可选的,即#
后面可以没有更多的层级。
现在,让我们再次运行这段代码。你应该能看到输出的正则表达式,以及最终的匹配结果。如果还是有问题,请提供输出结果以便进一步分析。
验证
实质输出结果:
msg: false msg: true
结论:
不符合
第五次
指令
运行结果还是不对,$SYS/brokers/emqx@172.51.0.6/clients/mqttx_b2136a2c/connected
,怀疑$SYS
影响了正则匹配,输出结果还是msg: false
AI输出(只粘贴代码部分)
你提到$SYS
可能影响了正则表达式的匹配。确实,在正则表达式中,某些特殊字符会被解释为具有特殊的含义,而非直接的文本匹配。在这种情况下,$
是一个特殊字符,在正则表达式中通常用于表示字符串的结尾。因此,我们需要对$SYS
进行转义,以确保它被视为普通文本而不是特殊符号。
让我们修改代码以转义$SYS
字符串中的$
,确保它被视为普通的字符。
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public static boolean matchTopic(String subscribedTopic, String publishedTopic) {
// 将 MQTT 通配符转换为正则表达式
String regexTopic = "^" + subscribedTopic
.replace("+", "[^/]+")
.replace("#", "([^/]+)/.*")
.replace("$", "\$") // 转义 $ 字符
+ "$";
// 编译正则表达式
Pattern pattern = Pattern.compile(regexTopic);
// 创建匹配器并检查是否匹配
Matcher matcher = pattern.matcher(publishedTopic);
return matcher.matches(); // 使用 matches() 来确保整个字符串匹配
}
public static void main(String[] args) {
boolean a = matchTopic("$SYS/brokers/+/clients/#", "$SYS/brokers/emqx@172.51.0.6/clients/mqttx_b2136a2c/connected");
System.out.println("msg: " + a); // 应该输出 true
boolean b = matchTopic("DEVICE/+/msg", "DEVICE/0000000091238112/msg");
System.out.println("msg: " + b); // 应该输出 true
}
在这个版本中,我们添加了一个.replace("$", "\$")
来转义$SYS
中的$
字符。这将确保$SYS
在正则表达式中被视为普通文本。
现在,让我们再次运行这段代码。你应该能看到输出的正则表达式,以及最终的匹配结果。如果还是有问题,请提供输出结果以便进一步分析。
验证
实质输出结果:
msg: true msg: true
结论:
符合
代码最终结果:
对代码今天细微调整,运营到生产环境代码。
private boolean matchTopic(String subscribedTopic, String publishedTopic) {
String regexTopic = "^" + subscribedTopic.replace("+", "[^/]+").replace("#", "([^/]+)/.*").replace("$", "\$") + "$";
// 编译正则表达式
Pattern pattern = Pattern.compile(regexTopic);
// 创建匹配器并检查是否匹配
Matcher matcher = pattern.matcher(publishedTopic);
return matcher.matches(); // 使用 matches() 来确保整个字符串匹配
}
总结
在使用AI工具进行代码生成时,用户可以通过简单的自然语言描述或问题,AI便能快速理解并生成相应的代码片段。这种交互方式非常直观,用户只需提供关键信息和问题,AI便能迅速响应并生成匹配的代码。 AI写代码的体验还体现在其高效性和便捷性上。通过AI的帮助,开发者可以更加专注于项目的核心逻辑和功能实现,而无需在编写基础代码上花费过多时间。这不仅能够提高开发效率,还能在一定程度上减轻开发者的负担,特别是在面对复杂或重复性的编程任务时,AI工具能够显著提升工作效率。 尽管AI工具在代码生成方面展现出了强大的能力,但开发者在使用时仍需保持警惕,确保生成的代码符合项目的实际需求和标准。此外,对于特定领域的复杂应用或特定需求的实现,人类开发者的专业知识和经验仍然是不可或缺的。因此,AI工具与人类开发者的结合将是未来软件开发的一个重要趋势,共同推动软件开发的进步和创新。
来源:juejin.cn/post/7397668641645396022
网站刚线上就被攻击了,随后我一顿操作。。。
大家好,我是冰河~~
自己搭建的网站刚上线,短信接口就被一直攻击,并且攻击者不停变换IP,导致阿里云短信平台上的短信被恶意刷取了几千条,加上最近工作比较忙,就直接在OpenResty上对短信接口做了一些限制,采用OpenResty+Lua的方案成功动态封禁了频繁刷短信接口的IP。
一、临时解决方案
由于事情比较紧急,所以,当发现这个问题时,就先采用快速的临时方案解决。
(1)查看Nginx日志发现被攻击的IP 和接口
[root@binghe ~]# tail -f /var/log/nginx/access.log
发现攻击者一直在用POST请求 /fhtowers/user/getVerificationCode这个接口
(2)用awk和grep脚本过滤nginx日志,提取攻击短信接口的ip(一般这个接口是用来发注册验证码的,一分钟如果大于10次请求的话就不是正常的访问请求了,大家根据自己的实际情况更改脚本)并放到一个txt文件中去,然后重启nginx
[root@binghe ~]# cat denyip.sh
#!/bin/bash
nginx_home=/usr/local/openresty/nginx
log_path=/var/log/nginx/access.log
tail -n5000 $log_path | grep getVerification | awk '{print $1}' |sort | uniq -c | sort -nr -k1 | head -n 100 |awk '{if($1>10)print ""$2""}' >$nginx_home/denyip/blocksip.txt
/usr/bin/nginx -s reload
(3)设置Nginx去读取用脚本过滤出来的blocksip.txt(注意一下,我这里的Nginx是用的openresty,自带识别lua语法的,下面会有讲openresty的用法)
location = /fhtowers/user/getVerificationCode { #短信接口
access_by_lua '
local f = io.open("/usr/local/openresty/nginx/denyip/blocksip.txt") #黑名单列表
for line in f:lines() do
if ngx.var.http_x_forwarded_for == line then #如果ip在黑名单列表里直接返回403
ngx.exit(ngx.HTTP_FORBIDDEN)
end
end
';
proxy_pass http://appservers; #不在名单里就转发给后台的tomcat服务器
}
(4)把过滤脚本放进crontab任务里,一分钟执行一次
[root@binghe ~]# crontab -e
*/1 * * * * sh /root/denyip.sh
(5)查看一下效果,发现攻击者的请求都被返回403并拒绝了
二、OpenResty+Lua方案
临时方案有效果后,再将其调整成使用OpenResty+Lua脚本的方案,来一张草图。
接下来,就是基于OpenResty和Redis实现自动封禁访问频率过高的IP。
2.1 安装OpenResty
安装使用 OpenResty,这是一个集成了各种 Lua 模块的 Nginx 服务器,是一个以Nginx为核心同时包含很多第三方模块的Web应用服务器,使用Nginx的同时又能使用lua等模块实现复杂的控制。
(1)安装编译工具、依赖库
[root@test1 ~]# yum -y install readline-devel pcre-devel openssl-devel gcc
(2)下载openresty-1.13.6.1.tar.gz 源码包,并解压;下载ngx_cache_purge模块,该模块用于清理nginx缓存;下载nginx_upstream_check_module模块,该模块用于ustream健康检查。
[root@test1 ~]# cd /usr/local/
[root@test1 local]# wget https://openresty.org/download/openresty-1.13.6.1.tar.gz
[root@test1 local]# tar -zxvf openresty-1.13.6.1.tar.gz
[root@test1 local]# cd openresty-1.13.6.1/bundle
[root@test1 local]# wget http://labs.frickle.com/files/ngx_cache_purge-2.3.tar.gz
[root@test1 local]# tar -zxvf ngx_cache_purge-2.3.tar.gz
[root@test1 local]# wget https://github.com/yaoweibin/nginx_upstream_check_module/archive/v0.3.0.tar.gz
[root@test1 local]# tar -zxvf v0.3.0.tar.gz
(3)配置需安装的模块
# ./configure --help可查询需要安装的模块并编译安装
[root@test1 openresty-1.13.6.1]# ./configure --prefix=/usr/local/openresty --with-luajit --with-http_ssl_module --user=root --group=root --with-http_realip_module --add-module=./bundle/ngx_cache_purge-2.3/ --add-module=./bundle/nginx_upstream_check_module-0.3.0/ --with-http_stub_status_module
[root@test1 openresty-1.13.6.1]# make && make install
(4)创建一个软链接方便启动停止
[root@test1 ~]# ln -s /usr/local/openresty/nginx/sbin/nginx /bin/nginx
(5)启动nginx
[root@test1 ~]# nginx #启动
[root@test1 ~]# nginx -s reload #reload配置
如果启动时候报错找不到PID的话就用以下命令解决(如果没有更改过目录的话,让它去读nginx的配置文件就好了)
[root@test1 ~]# /usr/local/openresty/nginx/sbin/nginx -c /usr/local/openresty/nginx/conf/nginx.conf
随后,打开浏览器访问页面。
(6)在Nginx上测试一下能否使用Lua脚本
[root@test1 ~]# vim /usr/local/openresty/nginx/conf/nginx.conf
在server里面加一个
location /lua {
default_type text/plain;
content_by_lua ‘ngx.say(“hello,lua!”)’;
}
加完后重新reload配置。
[root@test1 ~]# nginx -s reload
在浏览器里输入 ip地址/lua,出现下面的字就表示Nginx能够成功使用lua了
2.2 安装Redis
(1)下载、解压、编译安装
[root@test1 ~]# cd /usr/local/
[root@test1 local]# wget http://download.redis.io/releases/redis-6.0.1.tar.gz
[root@test1 local]# tar -zxvf redis-6.0.1.tar.gz
[root@test1 local]# cd redis-6.0.1
[root@test1 redis-6.0.1]# make
[root@test1 redis-6.0.1]# make install
(2)查看是否安装成功
[root@test1 redis-6.0.1]# ls -lh /usr/local/bin/
[root@test1 redis-6.0.1]# redis-server -v
Redis server v=3.2.5 sha=00000000:0 malloc=jemalloc-4.0.3 bits=64 build=dae2abf3793b309d
(3)配置redis 创建dump file、进程pid、log目录
[root@test1 redis-6.0.1]# cd /etc/
[root@test1 etc]# mkdir redis
[root@test1 etc]# cd /var/
[root@test1 var]# mkdir redis
[root@test1 var]# cd redis/
[root@test1 redis]# mkdir data log run
(4)修改配置文件
[root@test1 redis]# cd /usr/local/redis-6.0.1/
[root@test1 redis-6.0.1]# cp redis.conf /etc/redis/6379.conf
[root@test1 redis-6.0.1]# vim /etc/redis/6379.conf
#绑定的主机地址
bind 192.168.1.222
#端口
port 6379
#认证密码(方便测试不设密码,注释掉)
#requirepass
#pid目录
pidfile /var/redis/run/redis_6379.pid
#log存储目录
logfile /var/redis/log/redis.log
#dump目录
dir /var/redis/data
#Redis默认不是以守护进程的方式运行,可以通过该配置项修改,使用yes启用守护进程
daemonize yes
(5)设置启动方式
[root@test1 redis-6.0.1]# cd /usr/local/redis-6.0.1/utils/
[root@test1 utils]# cp redis_init_script /etc/init.d/redis
[root@test1 utils]# vim /etc/init.d/redis #根据自己实际情况修改
/etc/init.d/redis文件的内容如下。
#!/bin/sh
#
# Simple Redis init.d script conceived to work on Linux systems
# as it does use of the /proc filesystem.
REDISPORT=6379
EXEC=/usr/local/bin/redis-server
CLIEXEC=/usr/local/bin/redis-cli
PIDFILE=/var/run/redis_${REDISPORT}.pid
CONF="/etc/redis/${REDISPORT}.conf"
case "$1" in
start)
if [ -f $PIDFILE ]
then
echo "$PIDFILE exists, process is already running or crashed"
else
echo "Starting Redis server..."
$EXEC $CONF
fi
;;
stop)
if [ ! -f $PIDFILE ]
then
echo "$PIDFILE does not exist, process is not running"
else
PID=$(cat $PIDFILE)
echo "Stopping ..."
$CLIEXEC -p $REDISPORT shutdown
while [ -x /proc/${PID} ]
do
echo "Waiting for Redis to shutdown ..."
sleep 1
done
echo "Redis stopped"
fi
;;
*)
echo "Please use start or stop as first argument"
;;
esac
增加执行权限,并启动Redis。
[root@test1 utils]# chmod a+x /etc/init.d/redis #增加执行权限
[root@test1 utils]# service redis start #启动redis
(6)查看redis是否启动
2.3 Lua访问Redis
(1)连接redis,然后添加一些测试参数
[root@test1 utils]# redis-cli -h 192.168.1.222 -p 6379
192.168.1.222:6379> set "123" "456"
OK
(2)编写连接Redis的Lua脚本
[root@test1 utils]# vim /usr/local/openresty/nginx/conf/lua/redis.lua
local redis = require "resty.redis"
local conn = redis.new()
conn.connect(conn, '192.168.1.222', '6379') #根据自己情况写ip和端口号
local res = conn:get("123")
if res==ngx.null then
ngx.say("redis集群中不存在KEY——'123'")
return
end
ngx.say(res)
(3)在nginx.conf配置文件中的server下添加以下location
[root@test1 utils]# vim /usr/local/openresty/nginx/conf/nginx.conf
location /lua_redis {
default_type text/plain;
content_by_lua_file /usr/local/openresty/nginx/conf/lua/redis.lua;
}
随后重新reload配置。
[root@test1 utils]# nginx -s reload #重启一下Nginx
(4)验证Lua访问Redis的正确性
在浏览器输入ip/lua_redis, 如果能看到下图的内容表示Lua可以访问Redis。
准备工作已经完成,现在要实现OpenResty+Lua+Redis自动封禁并解封IP了。3.4
2.4 OpenResty+Lua实现
(1)添加访问控制的Lua脚本(只需要修改Lua脚本中连接Redis的IP和端口即可)
ok, err = conn:connect(“192.168.1.222”, 6379)
注意:如果在Nginx或者OpenResty的上层有用到阿里云的SLB负载均衡的话,需要修改一下脚本里的所有…ngx.var.remote_addr,把remote_addr替换成从SLB获取真实IP的字段即可,不然获取到的IP全都是阿里云SLB发过来的并且是处理过的IP,同时,这些IP全都是一个网段的,根本没有办法起到封禁的效果)。
完整的Lua脚本如下所示。
[root@test1 lua]# vim /usr/local/openresty/nginx/conf/lua/access.lua
local ip_block_time=300 --封禁IP时间(秒)
local ip_time_out=30 --指定ip访问频率时间段(秒)
local ip_max_count=20 --指定ip访问频率计数最大值(秒)
local BUSINESS = ngx.var.business --nginx的location中定义的业务标识符,也可以不加,不过加了后方便区分
--连接redis
local redis = require "resty.redis"
local conn = redis:new()
ok, err = conn:connect("192.168.1.222", 6379)
conn:set_timeout(2000) --超时时间2秒
--如果连接失败,跳转到脚本结尾
if not ok then
goto FLAG
end
--查询ip是否被禁止访问,如果存在则返回403错误代码
is_block, err = conn:get(BUSINESS.."-"..ngx.var.remote_addr)
if is_block == '1' then
ngx.exit(403)
goto FLAG
end
--查询redis中保存的ip的计数器
ip_count, err = conn:get(BUSINESS.."-"..ngx.var.remote_addr)
if ip_count == ngx.null then --如果不存在,则将该IP存入redis,并将计数器设置为1、该KEY的超时时间为ip_time_out
res, err = conn:set(BUSINESS.."-"..ngx.var.remote_addr, 1)
res, err = conn:expire(BUSINESS.."-"..ngx.var.remote_addr, ip_time_out)
else
ip_count = ip_count + 1 --存在则将单位时间内的访问次数加1
if ip_count >= ip_max_count then --如果超过单位时间限制的访问次数,则添加限制访问标识,限制时间为ip_block_time
res, err = conn:set(BUSINESS.."-"..ngx.var.remote_addr, 1)
res, err = conn:expire(BUSINESS.."-"..ngx.var.remote_addr, ip_block_time)
else
res, err = conn:set(BUSINESS.."-"..ngx.var.remote_addr,ip_count)
res, err = conn:expire(BUSINESS.."-"..ngx.var.remote_addr, ip_time_out)
end
end
-- 结束标记
::FLAG::
local ok, err = conn:close()
(2)在需要做访问限制的location里加两段代码即可,这里用刚才的/lua做演示
[root@test1 lua]# vim /usr/local/openresty/nginx/conf/nginx.conf
主要是添加如下配置。
access_by_lua_file /usr/local/openresty/nginx/conf/lua/access.lua;
其中,set $business “lua”
是为了把IP放进Redis的时候标明是哪个location的,可以不加这个配置。
随后,重新reload配置。
[root@test1 lua]# nginx -s reload #修改完后重启nginx
(3)打开浏览器访问192.168.1.222/lua 并一直按F5刷新。
随后,连接Redis,查看IP的访问计数。
[root@test1 ~]# redis-cli -h 192.168.1.222 -p 6379
发现redis已经在统计访问lua这个网页ip的访问次数了
这个key的过期时间是30秒,如果30秒没有重复访问20次这个key就会消失,所以说正常用户一般不会触发这个封禁的脚本。
当30秒内访问超过了20次,发现触发脚本了,变成了403
再次查看Redis的key,发现多了一个lua-block-192.168.1.158,过期时间是300秒,就是说在300秒内这个ip无法继续访问192.168.1.222/lua这个页面了。
过五分钟后再去访问这个页面,又可以访问了。
这个脚本的目的很简单:一个IP如果在30秒内其访问次数达到20次则表明该IP访问频率太快了,因此将该IP封禁5分钟。同时由于计数的KEY在Redis中的超时时间设置成了30秒,所以如果两次访问间隔时间大于30秒将会重新开始计数。
大家也可以将这个脚本优化成,第一次封禁5分钟,第二次封禁半小时,第三次封禁半天,第四次封禁三天,第五次永久封禁等等。
好了,今天就到这儿吧,我是冰河,我们下期见~~
来源:juejin.cn/post/7399109720457543721
三大微前端框架,谁是你的理想型?
1. 分享目标:
2. 什么是微前端?
故事开始于三年前…
小明为公司重构了一版新的管理后台,采用了市面上最流行的SPA渲染模式,具体技术栈使用的是 react + react-router。

项目第一版很快就顺利上线了,但在后续的迭代中,遇到一个棘手的问题:产品经理希望快速复用之前项目的某些页面。这让小明犯了难,因为老项目是用“上古神器” jQuery 写的,完全重构成 react,成本非常高。这时后端老哥丢过来一句:“你们前端用 iframe 嵌进来就可以了吧? ” 小明心里很清楚 iframe 有许多小毛病,但在当时,也确实没有比它更好的选择了。
上线后,随着时间的推移,用户产生了困惑:
- 为什么这个页面的弹框不居中了?
- 为什么这个页面的跳转记录无法保存? ...
小明心里其实非常清楚,这一切都是 iframe 带来的弊端。
时间来到三年后的今天,小明听说微前端能够解决 iframe 的各种疑难杂症,于是展开了调研。
市面上对微前端的定义让人眼花缭乱,比如微前端是:

这里给出我对微前端最接地气的定义:

故事开始于三年前…
小明为公司重构了一版新的管理后台,采用了市面上最流行的SPA渲染模式,具体技术栈使用的是 react + react-router。
项目第一版很快就顺利上线了,但在后续的迭代中,遇到一个棘手的问题:产品经理希望快速复用之前项目的某些页面。这让小明犯了难,因为老项目是用“上古神器” jQuery 写的,完全重构成 react,成本非常高。这时后端老哥丢过来一句:“你们前端用 iframe 嵌进来就可以了吧? ” 小明心里很清楚 iframe 有许多小毛病,但在当时,也确实没有比它更好的选择了。
上线后,随着时间的推移,用户产生了困惑:
- 为什么这个页面的弹框不居中了?
- 为什么这个页面的跳转记录无法保存? ...
小明心里其实非常清楚,这一切都是 iframe 带来的弊端。
时间来到三年后的今天,小明听说微前端能够解决 iframe 的各种疑难杂症,于是展开了调研。
市面上对微前端的定义让人眼花缭乱,比如微前端是:
这里给出我对微前端最接地气的定义:
“类似于iframe的效果,但没有它带来的各种问题”——小明。
3. 主流技术方向分类
首先,“微前端”作为近几年国内前端界最火的技术之一,目前存在多个技术流派。我按照它们对 iframe 看法的不同,将主流微前端方案分为了三大派系:革新派、改良派、中间派。

首先,“微前端”作为近几年国内前端界最火的技术之一,目前存在多个技术流派。我按照它们对 iframe 看法的不同,将主流微前端方案分为了三大派系:革新派、改良派、中间派。
3.1. 革新派 qiankun
以 qiankun 为主的革新派认为: iframe 问题很多,应避免使用它。 完全可以利用现有的前端技术自建一套应用隔离渲染方案。
以 qiankun 为主的革新派认为: iframe 问题很多,应避免使用它。 完全可以利用现有的前端技术自建一套应用隔离渲染方案。
3.1.1. 原理:
3.1.1.1. 基于 single-spa
将路由切换与子应用加载、卸载等生命周期结合起来是微前端的一项核心能力。这一步 qiankun 是基于 single-spa 实现的,不同的是它支持以 html 作为加载子应用的入口,不必像 single-spa 那样需要手动梳理资源链接,内部插件 import-html-entry 会自动分析 html 以获取 js 和 css。

将路由切换与子应用加载、卸载等生命周期结合起来是微前端的一项核心能力。这一步 qiankun 是基于 single-spa 实现的,不同的是它支持以 html 作为加载子应用的入口,不必像 single-spa 那样需要手动梳理资源链接,内部插件 import-html-entry 会自动分析 html 以获取 js 和 css。
3.1.1.2. 样式隔离
为了确保子应用之间样式互不影响,qiankun 内置了三种样式隔离模式:
- 默认模式。
原理是加载下一个子应用时,将上一个子应用的 、
等样式相关标签通通删除与替换,来实现样式隔离。缺点是仅支持单例模式(同一时间只能渲染单个子应用),且没法做到主子应用及多个子应用之间的样式隔离。
- 严格模式。
为了确保子应用之间样式互不影响,qiankun 内置了三种样式隔离模式:
- 默认模式。
原理是加载下一个子应用时,将上一个子应用的 、
等样式相关标签通通删除与替换,来实现样式隔离。缺点是仅支持单例模式(同一时间只能渲染单个子应用),且没法做到主子应用及多个子应用之间的样式隔离。
- 严格模式。
可通过 strictStyleIsolation:true
开启。原理是利用 webComponent 的 shadowDOM 实现。但它的问题在于隔离效果太好了,在目前的前端生态中有点水土不服,这里举两个例子。
- 可能会影响 React 事件。比如这个issue 当 Shadow Dom 遇上 React 事件 ,大致原因是在 React 中事件是“合成事件”,在React 17 版本之前,所有用户事件都需要冒泡到 document 上,由 React 做统一分发与处理,如果冒泡的过程中碰到 shadowRoot 节点,就会将事件拦截在 shadowRoot 范围内,此时
event.target
强制指向 shadowRoot,导致在 react 中事件无响应。React 17 之后事件监听位置由 document 改为了挂载 App 组件的 root 节点,就不存在此问题了。
- 弹框样式丢失。 原因是主流UI框架比如 antd 为了避免上层元素的样式影响,通常会把弹框相关的 DOM 通过
document.body.appendChild
插入到顶层 body 的下边。此时子应用中 antd 的样式规则,由于开启了 shadowDom ,只对其下层的元素产生影响,自然就对全局 body 下的弹框不起作用了,造成了样式丢失的问题。
解决方案:调整 antd 入参,让其在当前位置渲染。
- 实验模式。
可通过 experimentalStyleIsolation:true
开启。 原理类似于 vue 的 scope-css,给子应用的所有样式规则增加一个特殊的属性选择器,限定其影响范围,达到样式隔离的目的。但由于需要在运行时替换子应用中所有的样式规则,所以目前性能较差,处于实验阶段。
3.1.1.3. JS 沙箱
确保子应用之间的“全局变量”不会产生冲突。
- 快照沙箱( snapshotSandbox )
- 激活子应用时,对着当前
window
对象照一张相(所有属性 copy 到一个新对象windowSnapshot
中保存起来)。 - 离开子应用时,再对着
window
照一张相,对比离开时的window
与激活时的 window (也就是windowSnapshot
)之间的差异。- 记录变更。Diff 出在这期间更改了哪些属性,记录在
modifyPropsMap
对象中。 - 恢复环境。依靠
windowSnapshot
恢复之前的window
环境。
- 记录变更。Diff 出在这期间更改了哪些属性,记录在
- 下次激活子应用时,从
modifyPropsMap
对象中恢复上一次的变更。
- 单例的代理沙箱 ( LegacySanbox )
与快照沙箱思路很相似,但它不用通过 Diff 前后 window 的方式去记录变更,而是通过 ES6的 Proxy 代理 window 属性的 set 操作来记录变更。由于不用反复遍历 window,所以性能要比快照沙箱好。
- 支持多例的代理沙箱( ProxySandbox )
以上两种沙箱机制,都只支持单例模式(同一页面只支持渲染单个子应用)。
原因是:它们都直接操作的是全局唯一的 window。此时机智的你肯定想到了,假如为每个子应用都分配一个独立的“虚拟window”,当子应用操作 window 时,其实是在各自的“虚拟 window”上操作,不就可以实现多实例共存了?事实上,qiankun 确实也是这样做的。
既然是“代理”沙箱,那“代理”在这的作用是什么呢?
主要是为了实现对全局对象属性 get、set 的两级查找,优先使用fakeWindow,特殊情况(set命中白名单或者get到原生属性)才会改变全局真实window。
如此,qiankun 就对子应用中全局变量的 get 、 set 都实现了管控与隔离。
3.1.2. 优势:
3.1.2.1. 具有先发优势
2019年开源,是国内最早流行起来的微前端框架,在蚂蚁内外都有丰富的应用,后期维护性是可预测的。
3.1.2.2. 开箱即用
虽然是基于国外的 single-spa 二次封装,但提供了更加开箱即用的 API,比如支持直接以 HTML 地址作为加载子应用的入口。
3.1.2.3. 对 umi 用户更加友好
有现成的插件 @umijs/plugin-qiankun 帮助降低子应用接入成本。
3.1.3. 劣势:
3.1.3.1. vite 支持性差
由上可知,代理沙箱实现的关键是需要将子应用的 window “替换”为 fakeWindow,在这一步 qiankun 是通过函数 window 同名参数 + with 作用域绑定的方式,更改子应用 window 指向为 fakeWindow,最终使用 eval(...) 解析运行子应用的代码。
const jsCode = `
(function(window, self, globalThis){
with(this){
// your code
window.a = 1;
b = 2
...
}
}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);
`
eval(jsCode)
问题就出在这个 eval 上, vite 的构建产物如果不做特殊降级,默认打包出的就是 ESModule 语法的代码,使用 eval 解析运行会报下图这个错误。
报错的大意是, import 语法的代码必须放在 中执行。
官方目前推荐的解决方法是关闭沙箱... 但其实还有另一种比较取巧的方案:vite 生态里有一款专门兼容此问题的vite-plugin-qiankun 插件,它的原理是: eval 虽然没办法执行静态 import 语法,但它可以执行动态 import(...) 语法。
所以这款插件的解决方案就是替换子应用代码中的静态 import 为动态 import(),以绕过上述限制。
3.1.3.2. 子应用接入成本较高,详细步骤参考子应用接入文档
umi 用户可忽略这点,尤其是 @umi/max 用户,相比 webpack 接入成本要低很多。
3.1.3.3. JS 沙箱存在性能问题,且并不完善。
大致原因是 with + proxy 带来的性能损耗,详见 JS沙箱的困境 。当然 qiankun 官方也在针对性的进行优化,进展在这篇《改了 3 个字符,10倍的沙箱性能提升?!!》文章中可见一斑 。
3.2. 改良派 wujie
3.2.1. 原理:
wujie 是腾讯出品的一款微前端框架。作为改良派的代表,它认为: iframe 虽然问题很多,但仅把它作为一个 js 沙箱去用,表现还是很稳定的,毕竟是浏览器原生实现的,比自己实现 js 沙箱靠谱多了。至于 iframe 的弊端,可以针对性的去优化:
- DOM 渲染无法突破 iframe 边界?(弹框不居中问题)
那 DOM
就不放 iframe
里渲染了,而是单独提取到一个 webComponent
里渲染,顺便用 shadowDOM
解决样式隔离的问题。
简单说,无界的方案就是:JS 放 iframe 里运行,DOM 放 webComponent 渲染。
那么问题来了: 用 JS 操作 DOM 时,两者如何联系起来呢?毕竟 JS 默认操作的总是全局的 DOM。无界在此处用了一种比较 hack 的方式:代理子应用中所有的 DOM 操作,比如将 document
下的 getElementById、querySelector、querySelectorAll、head、body
等查询类 api 全部代理到 webComponent
。
下图是子应用真实运行时的例子:
至于多实例模式,就更容易理解了。给每个子应用都分配一套 iframe
+ webComponent
的组合,就可以实现相互之间的隔离了!
- 刷新页面会导致子应用路由状态丢失?
通过重写 iframe
实例的history.pushState
和 history.replaceState
,将子应用的 path
记录到主应用地址栏的 query
参数上,当刷新浏览器初始化 iframe
时,从地址栏读到子应用的 path
并使用 iframe
的 history.replaceState
进行同步。
简单理解就是:将子应用路径记录在地址栏参数中。
3.2.2. 优势:
3.2.2.1. 相比 qiankun 接入成本更低。
- 父应用:
- 与 iframe 的接入方式很类似,只需引入一个 React 组件渲染子应用即可。
- 与 iframe 的接入方式很类似,只需引入一个 React 组件渲染子应用即可。
- 子应用理论上不需要做任何改造
3.2.2.2. vite 兼容性好
直接将完整的 ESM 标签块 插入 iframe 中,避免了 qiankun 使用 eval 执行 ESM 代码导致的报错问题。
3.2.2.3. iframe 沙箱隔离性好
3.2.3. 劣势:
3.2.3.1. 坑比较多
- 明坑: 用于 JS 沙箱的 iframe 的 src 必须指向一个同域地址导致的问题。
具体问题描述见下图:
具体问题描述见下图:
此 [issue]() 至今无法在框架层面得到解决,属于 iframe 的原生限制。
手动的解决方案:
- 主应用提供一个路径比如说 https://host/empty ,这个路径不需要返回任何内容,子应用设置 attr 为 {src:'https://host/empty'},这样 iframe 的 src 就是 https://host/empty。
- 在主应用 template 的 head 插入
这样的代码可以避免主应用代码污染。
- 暗坑: 复杂的 iframe 到 webComponent 的代理机制,导致市面上大部分富文本编辑器都无法在无界中完好运行。所以有富文本的项目,尽量别用无界,除非你对富文本库的源码了如指掌。issues 在这里。
3.2.3.2. 长期维护性一般。
3.2.3.3. 内存开销较大
用于 js 沙箱的 iframe 是隐藏在主应用的 body 下面的,相当于是常驻内存,这可能会带来额外的内存开销。
3.3. 中间派 micro-app
3.3.1. 原理:
京东的大前端团队出品。
样式隔离方案与 qiankun 的实验方案类似,也是在运行时给子应用中所有的样式规则增加一个特殊标识来限定 css 作用范围。
子应用路由同步方案与 wujie 类似,也是通过劫持路由跳转方法,同步记录到 url 的 query 中,刷新时读取并恢复。
组件化的使用方式与 wujie 方案类似,这也是 micro-app 主打的宣传点。
最有意思的是它的沙箱方案,居然内置了两种沙箱:
- 类 qiankun 的 with 代理沙箱,据说相比 qiankun 性能高点,但目前微前端框架界并没有一个权威的基准性能测试依据,所以并无有效依据支撑。
- 类 wujie 的 iframe 沙箱,用于兼容 vite 场景。
开发者可以根据自身的实际情况自由选择。
整体感觉 micro-app 是一种偏“现实主义”的框架,它的特点就是取各家所长,最终成为了功能最丰富的微前端框架。
3.3.2. 优势:
3.3.2.1. 支持的功能最丰富。
3.3.2.2. 接入成本低。
3.3.2.3. 文档完善。
micro-zoe.github.io/micro-app/d…
3.3.3. 劣势:
3.3.3.1. 功能丰富导致配置项与 api 太多。
3.3.3.2. 静态资源补全问题。
静态资源补全是基于父应用的,而非子应用这需要开发者自己手动解决。
4. 选型建议
统计时间2023.12.3 | npm周下载量 | star数 | issue数 | 最近更新时间 | 接入成本 | 沙箱支持vite |
---|---|---|---|---|---|---|
qiankun | 22k | 15k | 362/1551 | 12天前 | 高 | ❌ |
wujie | 1.3k | 3.4k | 280/271 | 24天前 | 低 | ✅ |
micro-app | 1.1k | 4.9k | 57/748 | 1个月前 | 中 | ✅ |
- 刚性建议。
- vite 项目且对 js 沙箱有刚需,选 wujie 或者 micro-app。
- 项目存在复杂的交互场景,比如有用到富文本编辑器库,选 wujie 前请做好充分的测试。
- 如果你的团队对主、子应用的开发完全受控,即使有隔离性问题也可以通过治理来解决,那么可以试试更轻量的 single-SPA 方案。
- 如果特别重视稳定性,那无疑是 iframe 最佳... 因为 iframe 存在的问题都是摆在明面的,市面上现有的微前端框架多多少少都有一些隐性问题。
- 综合推荐。
主要从接入成本、功能稳定性、长期维护性三方面来衡量:
- 接入成本: wujie > microApp > qiankun (由低到高)
- 功能稳定性:qiankun > microApp > wujie
- 长期维护性:qiankun > microApp > wujie
看你的团队最看重哪一点,针对性去选择就好了,没有十全十美微前端框架,只有适合自己的。
最后
以上内容,确实会有我强烈的个人理解与观点,这也是我写文章一贯的风格。我并不喜欢那种客观且枯燥无味的文章,读完之后感觉像流水账,给不了读者任何的指导。我认为文章就是要有观点输出,技术文章也不例外,如果非常看重准确无误的表达,可以直接去看说明文档or源码,那应该是最权威的知识。如有错误或者误解,可以评论区或者私信指出,我积极改正。
来源:juejin.cn/post/7309477710523269174
既然有了Kubernetes,为什么还需要 Istio?
如果您听说过Service Mesh并尝试过Istio,您可能会有以下问题:
- 为什么 Istio 运行在 Kubernetes 上?
- Kubernetes 和服务网格在云原生应用架构中分别扮演什么角色?
- Istio 在哪些方面对Kubernetes进行了扩展?它解决了什么问题?
- Kubernetes、Envoy 和 Istio 之间是什么关系?
本文将带您了解 Kubernetes 和 Istio 的内部工作原理。另外,我还会介绍 Kubernetes 中的负载均衡方法,并解释为什么有了 Kubernetes 还需要 Istio。
Kubernetes 本质上是通过声明性配置进行应用程序生命周期管理,而服务网格本质上是提供应用程序间流量、安全管理和可观察性。如果你已经使用 Kubernetes 搭建了一个稳定的应用平台,那么如何为服务之间的调用设置负载均衡和流量控制呢?这就是服务网格发挥作用的地方。
Envoy 引入了 xDS 协议
,该协议受到各种开源软件的支持,例如Istio、MOSN等。Envoy 将 xDS 贡献给服务网格或云原生基础设施。Envoy 本质上是一个现代版本的代理,可以通过 API 进行配置,并基于它衍生出许多不同的使用场景——例如 API 网关、服务网格中的 sidecar 代理和边缘代理。
本文包含以下内容:
- kube-proxy 作用的描述。
- Kubernetes 对于微服务管理的局限性。
- 介绍 Istio 服务网格的功能。
- Kubernetes、Envoy 和 Istio 服务网格中一些概念的比较。
Kubernetes 与服务网格
下图展示了 Kubernetes 和 Service Mesh(每个 pod 一个 sidecar 模型)中的服务访问关系。
流量转发
Kubernetes 集群中的每个节点都会部署一个 kube-proxy 组件,该组件与 Kubernetes API Server 通信,获取集群中服务的信息,然后设置 iptables 规则,将服务请求直接发送到对应的 Endpoint(属于该集群的 pod)。同一组服务)。
服务发现
Istio 可以跟随 Kubernetes 中的服务注册,还可以通过控制平面中的平台适配器与其他服务发现系统对接;然后使用数据平面的透明代理生成数据平面配置(使用 CRD,存储在 etcd 中)。数据平面的透明代理作为sidecar容器部署在每个应用服务的pod中,所有这些代理都需要请求控制平面同步代理配置。代理是“透明的”,因为应用程序容器完全不知道代理的存在。该进程中的 kube-proxy 组件也需要拦截流量,只不过 kube-proxy 拦截进出 Kubernetes 节点的流量,而 sidecar 代理拦截进出 pod 的流量。
服务网格的缺点
由于 Kubernetes 每个节点上运行有很多 pod,将原有的 kube-proxy 路由转发功能放在每个 pod 中会增加响应延迟(由于 sidecar 拦截流量时的跳数更多)并消耗更多资源。为了以细粒度的方式管理流量,将添加一系列新的抽象。这会进一步增加用户的学习成本,但随着技术的普及这种情况会慢慢得到缓解。
服务网格的优点
kube-proxy 设置是全局的,无法对每个服务进行精细控制,而服务网格通过 sidecar 代理将流量控制从 Kubernetes 的服务层中取出,从而实现更大的弹性。
Kube-Proxy 的缺点
首先,如果转发的 Pod 无法正常服务,它不会自动尝试另一个 Pod。每个 pod 都有健康检查机制,当 pod 出现健康问题时,kubelet 会重启 pod,kube-proxy 会删除相应的转发规则。此外,nodePort 类型的服务无法添加 TLS 或更复杂的消息路由机制。
Kube-proxy 实现了 Kubernetes 服务的多个 pod 实例之间的流量负载均衡,但是如何对这些服务之间的流量进行细粒度控制——例如将流量按百分比划分到不同的应用程序版本(这些版本都是同一个应用程序的一部分)服务但在不同的部署上),或者进行灰度发布和蓝绿发布?
Kubernetes 社区提供了一种使用 Deployment 进行灰度发布方法,这本质上是一种通过修改 pod 标签将不同 pod 分配给部署服务的方法
。
Kubernetes Ingress 与 Istio 网关
如上所述,kube-proxy 只能在 Kubernetes 集群内路由流量。Kubernetes 集群的 Pod 位于 CNI 创建的网络中。入口(在 Kubernetes 中创建的资源对象)是为了集群外部的通信而创建的。它由位于 Kubernetes 边缘节点上的入口控制器驱动,负责管理南北流量。Ingress 必须对接各种 Ingress Controller,例如nginx ingress 控制器。Ingress仅适用于HTTP流量,使用简单。它只能通过匹配有限数量的字段(例如服务、端口、HTTP 路径等)来路由流量。这使得无法路由 MySQL、Redis 和各种 RPC 等 TCP 流量。这就是为什么你会看到人们在入口资源注释中编写 nginx 配置语言。直接路由南北流量的唯一方法是使用服务的 LoadBalancer 或 NodePort,前者需要云供应商支持,后者需要额外的端口管理。
Istio Gateway 的功能与 Kubernetes Ingress 类似,负责进出集群的南北向流量
。Istio Gateway 描述了一种负载均衡器,用于承载进出网格边缘的连接。该规范描述了一组开放端口以及这些端口使用的协议、用于负载均衡的 SNI 配置等。 Gateway 是一个 CRD 扩展,它也重用了 sidecar 代理的功能;详细配置请参见Istio 网站
。
Envoy
Envoy 是 Istio 中默认的 sidecar 代理。Istio 基于 Enovy 的 xDS 协议扩展了其控制平面。在谈论 Envoy 的 xDS 协议之前,我们需要先熟悉一下 Envoy 的基本术语。以下是 Envoy 中的基本术语及其数据结构列表;请参阅Envoy 文档
了解更多详细信息。
基本术语
以下是您应该了解的 Enovy 基本术语。
- Downstream:下游主机连接 Envoy,发送请求,接收响应;
即发送请求的主机
。 - Upstream:上游主机接收来自 Envoy 的连接和请求并返回响应;
即接收请求的主机
。 - Listener:Listener 是一个命名的网络地址(例如端口、UNIX 域套接字等);下游客户端可以连接到这些侦听器。Envoy 向下游主机公开一个或多个侦听器以进行连接。
- Cluster:集群是 Envoy 连接的一组逻辑上相同的上游主机。Envoy 通过服务发现来发现集群的成员。或者,可以通过主动健康检查来确定集群成员的健康状态。Envoy 通过负载均衡策略决定集群中的哪个成员来路由请求。
Envoy 中可以设置多个监听器,每个监听器可以设置一个过滤器链(过滤器链表),并且过滤器是可扩展的,以便我们可以更轻松地操纵流量的行为——例如设置加密、私有 RPC 等。
xDS 协议由 Envoy 提出,是 Istio 中默认的 sidecar 代理,但只要实现了 xDS 协议,理论上就可以在 Istio 中用作 sidecar 代理——比如蚂蚁集团开源的MOSN。
Istio 是一个功能非常丰富的服务网格,包括以下功能。
- 流量管理:这是Istio最基本的功能。
- 策略控制:启用访问控制系统、遥测捕获、配额管理、计费等。
- 可观察性:在 sidecar 代理中实现。
- 安全身份验证:Citadel 组件执行密钥和证书管理。
Istio 中的流量管理
Istio 中定义了以下 CRD 来帮助用户进行流量管理。
- 网关:网关描述了运行在网络边缘的负载均衡器,用于接收传入或传出的 HTTP/TCP 连接。
- VirtualService:VirtualService 实际上将 Kubernetes 服务连接到 Istio 网关。它还可以执行其他操作,例如定义一组在寻址主机时应用的流量路由规则。
- DestinationRule:DestinationRule 定义的策略决定流量经过路由后的访问策略。简而言之,它定义了流量的路由方式。其中,这些策略可以定义为负载均衡配置、连接池大小和外部检测(用于识别并驱逐负载均衡池中不健康的主机)配置。
- EnvoyFilter:EnvoyFilter 对象描述代理服务的过滤器,可以自定义 Istio Pilot 生成的代理配置。这种配置一般初级用户很少使用。
- ServiceEntry:默认情况下,Istio 服务网格中的服务无法发现网格之外的服务。ServiceEntry 允许将其他条目添加到 Istio 内的服务注册表中,从而允许网格中自动发现的服务访问并路由到这些手动添加的服务。
Kubernetes、xDS、Istio
回顾了 Kubernetes 的 kube-proxy 组件、xDS 和 Istio 中流量管理的抽象之后,现在让我们仅在流量管理方面对这三个组件/协议进行比较(请注意,这三个组件并不完全相同)。
要点
- Kubernetes 的本质是应用程序生命周期管理,特别是部署和管理(伸缩、自动恢复、发布)。
- Kubernetes 为微服务提供了可扩展且高弹性的部署和管理平台。
- 服务网格基于透明代理,通过 sidecar 代理拦截服务之间的流量,然后通过控制平面配置管理它们的行为。
- 服务网格将流量管理与 Kubernetes 解耦,无需 kube-proxy 组件来支持服务网格内的流量;通过提供更接近微服务应用程序层的抽象来管理服务间流量、安全性和可观察性。
- xDS 是服务网格配置的协议标准之一。
- 服务网格是 Kubernetes 中服务的更高级别抽象。
概括
如果说 Kubernetes 管理的对象是 Pod,那么 Service Mesh 管理的对象就是服务
,所以只要用 Kubernetes 来管理微服务,然后应用 Service Mesh 就可以了。如果您甚至不想管理服务,那么可以使用像Knative
这样的无服务器平台z。
来源:juejin.cn/post/7310878133720301604
2024我给公司亏钱了,数据一致性问题真的马虎不得
最近五阳遇到了线上资损问题,我开始重视分布式事务的数据一致性问题,拿我擅长的场景分析下。
举个🌰例子
在付费会员场景,用户购买会员后享受会员权益。在会员售后场景,用户提交售后,系统需要冻结权益并且原路赔付退款。
系统如何保证冻结权益和订单退款的数据一致性呢?当无法保证数据一致时,会导致什么问题呢?
标题 | 业务结果 |
---|---|
订单退款,但未冻结权益 | 平台资金损失 |
订单未退款,但权益冻结 | 用户资金损失 |
订单退款,权益冻结 | 正常 |
通过这个例子可以看到电商场景中,数据不一致可能会导致资金损失。这是电商场景对数据一致性要求高的原因,很多资损(资金损失)问题都是源于数据不一致。
如何理解数据一致性,一致性体现在哪里?
狭义上的数据一致是指:数据完全相同,在数据库主从延迟场景,主从数据一致是指:主数据副本和从数据副本,数据完全相同,客户端查询主库和查询从库得到的结果是相同的,也就是一致的。
除数据多副本场景使用数据一致性的概念之外,扩展后其他场景也使用这个概念。例如分布式事务中,多个事务参与者各自维护一种数据,当多种数据均处于合法状态且符合业务逻辑的情况下,那就可以说整体处于数据一致了。(并不像副本场景要求数据完全相同)
例如会员订单有支付状态和退款状态,会员优惠券有未使用状态和冻结状态。 在一次分布式事务执行前后,订单和优惠券的状态是一致的,即会员订单退款、会员券冻结;会员订单未退款,会员券状态为可使用;
此外还有异构数据一致性,超时一致。异构数据一致性是指同一种数据被异构到多种存储中间件。例如本地缓存、Redis缓存和数据库,即三级缓存的数据一致性。还有搜索场景,需要保证数据库数据和 ElasticSearch数据一致性,这也是分布式事务问题。
一致性和原子性的区别
原子性 指的是事务是一个不可分割的最小工作单元,事务中的操作要么全部成功,要么全部失败。
一致性 指的是事务执行前后,所有数据均处于一致性状态,一致性需要原子性的支持。如果没有实现原子性,一致性也无法实现。一致性在原子性的基础上,还要求实现数据的正确性。例如在同一个事务中实现多商品库存扣减,多个SQL除了保证同时成功同时失败外,还需要保证操作的正确性。如果所有SQL都返回成功了,但是数据是错误的,这无法接受。这就是一致性的要求。
由此可见,数据一致性本身就要求了数据是正确的。
隔离性是指:其他事务并发访问同一份数据时,多个事务之间应该保持隔离性。隔离性级别:如读未提交、读已提交、可重复读和串行化。
隔离性强调的是多个事务之间互不影响的程度,一致性强调的是一个事务前后,数据均处于一致状态。
什么是强一致性
在分布式事务场景,强一致性是指:任何一个时刻,看到各个事务参与者的数据都是一致的。系统不存在不一致的情况。
值得一提的是,CAP理论指出,数据存在多副本情况下,要保证强一致性(在一个绝对时刻,两份数据是完全一致的)需要牺牲可用性。
也就是说系统发现自身处于不一致状态时,将向用户返回失败状态。直至数据一致后,才能返回最新数据,这将牺牲可用性。
为了保证系统是可用的,可以返回旧的数据,但是无法保证强一致性。
会员售后能保证强一致性吗?
会员售后关键的两个动作:权益冻结和订单退款。 两者能保证强一致性吗?答案是不能。假设权益冻结是一个本地事务性操作,但是订单退款包括订单状态流程、支付系统资金流转等等。
如此复杂的流程难以保证任意一个绝对时刻,用户看到权益冻结后,资金一定到账了;这是售后场景 无法达到强一致性的根本原因。
最终一致性和强一致性
最终一致性不要求系统在任意一个时刻,各参与方数据都是一致的,它要求各参与方数据在一定时间后处于一致状态。
最终一致性没有明确这个时间是多长,所以有人说最终一致性就是没有一致性,谁知道多久一定能保证一致呢。
保证最终一致性的手段有哪些?
TCC
TCC 包含 Try、Confirm 和 Cancel 三个操作。
- 确保每个参与者(服务)都实现了 Try、Confirm 和 Cancel 操作。
- 确保在业务逻辑中,如果 Try 操作成功,后续必须执行 Confirm 操作以完成事务;如果 Try 失败或者 Cancel 被调用,则执行 Cancel 操作撤销之前的操作。
需要说明,如果Confirm执行失败,需要不停不重试Confirm,不得执行Cancel。 按照TCC的语义 Try操作已经锁定了、预占了资源。 Confirm在业务上一定是可以成功的。
TCC的问题在于 分布式事务的任意一个操作都应该提供三个接口,每个参与者都需要提供三个接口,整体交互协议复杂,开发成本高。当发生嵌套的分布式事务时,很难保证所有参与者都实现TCC规范。
为什么TCC 方案包含Try
如果没有Try阶段,只有Confirm和 Cancel阶段,如果Confirm失败了,则调用Cancel回滚。为什么Tcc不这样设计呢?
引入Try阶段,为保证不发生状态回跳的情况。
Try阶段是预占资源阶段,还未实际修改资源。设想资金转账场景, A账户向B账户转账100元。 在Try阶段 A账户记录了预扣100元,B 账户记录了预收100元。 如果A账户不足100元,那么Try阶段失败,调用Cancel回滚,这种情况,在任意时刻,A、B用户视角转账是失败的。
Try阶段成功,则调用Confirm接口,最终A账户扣100元,B收到100元。虽然这无法保证在某个时刻,A、B账户资金绝对一致。但是如果没有Try阶段,那么将发生 状态回跳的情况;
状态回跳:即A账户操作成功了,但是B账户操作失败,A账户资金又被回滚了。那么用户A 看到自己的账户状态就是 钱被扣了,但是过一会钱又回来了。
同理B账户也可能遇到收到钱了,但是过一会钱又没了。在转账场景,这种回跳情况几乎是不能接受的。
引入了Try阶段,就能保证不发生状态回跳的情况。
最大努力通知
最大努力通知是指通知方通过一定的机制最大努力将业务处理结果通知到接收方。一般用于最终一致性时间敏感度低的场景,并且接收方的结果不会影响到发起方的结果。即接收方处理失败时,发送方不会跟随回滚。
在电商场景,很多场景使用最大努力通知型作为数据一致性方案。
会员售后如何保证最终一致性?
回到开头的问题,以下是数据不一致的两种情况。
标题 | 业务结果 |
---|---|
订单退款,但未冻结权益 | 平台资金损失 |
订单未退款,但权益冻结 | 用户资金损失 |
订单退款,权益冻结 | 正常 |
平台资损难以追回,当发生容易复现的平台资损时,会引来更多的用户“薅羊毛”,资损问题将进一步放大,所以平台资损一定要避免。
当发生用户资损时,用户可以通过客服向平台追诉,通过人工兜底的方式,可以保证“最终”数据是一致的。
所以基于这个大的原则,大部分系统都是优先冻结会员权益,待用户权益明确冻结后,才根据实际冻结情况,向用户退款。这最大程度上保证了售后系统的资金安全。
会员售后整体上是最大努力通知一致性方案。当权益冻结后,系统通过可重试的消息触发订单退款,且务必保证退款成功(即便是人工介入情况下)。虽说是最大努力通知型,但不代表一致性弱,事实上支付系统的稳定性要求是最高级别的,订单退款成功的可靠性是能得到保证的。
如果权益冻结是分布式事务,如何保证一致性
一开始我们假设权益冻结是一个本地事务,能保证强一致性,这通常与实际不符。权益包含很多玩法,例如优惠券、优惠资格、会员日等等,权益冻结并非是本地事务,而是分布式事务,如何保证一致性呢?
方案1 TCC方案
假设 权益系统包含三个下游系统,身份系统(记录某个时间段是会员)、优惠券系统、优惠资格系统。 TCC方案将要求三方系统均实现 Try、Confirm、Cancel接口。开发成本和上下游交付协议比较复杂。
方案2 无Try 的 TCC方案
假设无Try阶段,直接Confirm修改资源,修改失败则调用Cancel,那么就会出现状态跳回情况,即优惠券被冻结了,但是后面又解冻了。
这种情况下,系统只需要实现扣减资源和回滚资源 两种接口。 系统设计大大简化
方案3 Prepare + Confirm
Prepare: 检查接口,即检查资源是否可以被修改,但是不会锁定资源。
Confirm: 修改资源接口,实际修改资源状态。
如果Prepare失败,则返回执行失败,由于未预占用资源,所以无需回滚资源。在Prepare成功后,则立即调用Confirm。如果Confirm执行失败,则人工介入。
一定需要回滚吗?
在一致性要求高的场景,需要资源回滚能力,保证系统在一定时间后处于一致状态。如果没有回滚,势必导致在某些异常情况下,系统处于不一致状态,且无法自动恢复。
会员售后场景虽然对资金较为敏感,但不需要资源回滚。理由如下
- 将订单退款置为 权益成功冻结之后,可以保证系统不出现平台资损。即权益未完全冻结,订单是不是退款的。
- 用户资损情况可以通过人工客服兜底解决。
在上述方案3中,先通过Prepare阶段验证了参与方都是可以冻结的,在实际Confirm阶段这个状态很难发生改变。所以大概率Confirm不会失败的。
只有极低的概率发生Confirm失败的情况,即用户在权益冻结的一瞬间,使用优惠券,这将导致资源状态发生改变。
解决此类问题,可以在权益冻结后,评估冻结结果,根据实际的冻结结果,决定如何赔付用户,赔付用户多少钱。所以用户并发用券,也不会影响资金安全。
人工兜底与数据一致性
程序员应该在业务收益、开发成本、数据不一致风险等多个角度评估系统设计的合理性。
会员售后场景,看似数据一致性要求高,仿佛数据不一致,就会产生严重的资损问题。但实际分析后,系统并非需要严格的一致性。
越是复杂的系统设计,系统稳定性越差。越是简洁的系统设计,系统稳定性越高。当选择了复杂的系统设计提高数据一致性,必然需要付出更高的开发成本和维护成本。往往适得其反。
当遇到数据一致性挑战时,不妨跳出技术视角,尝试站在产品视角,思考能否适当调整一下产品逻辑,容忍系统在极端情况下,有短暂时间数据不一致。人工兜底处理极端情况。
大多数情况下,产品经理会同意。
来源:juejin.cn/post/7397013935105769523
Docker容器日志过大?有没有比较简单的方式解决?
Docker容器日志过大?有没有比较简单的方式解决?
1. 问题描述
当我们尝试查看特定 Docker 容器的日志时,通常会使用 docker logs <容器名称>
命令。然而,有时候会发现控制台持续输出日志信息,持续时间可能相当长,直到最终打印完成。这种现象往往源自对 Docker 容器日志长时间未进行处理,导致日志积累过多,占用了系统磁盘空间。因此,为了释放磁盘空间并优化系统性能,我们可以采取一些简单而有效的方法来处理这些庞大的日志文件。
2. docker日志处理机制
需要处理问题,那我们肯定要先了解docker的日志处理机制,了解了基本的机制,能够帮助我们更好的理解问题并解决问题。
2.1 日志查看
docker logs <容器名称>
可以查看docker容器的输出日志,但是这里的日志主要包含标准输出和标准错误输出,一些容器可能会把日志输出到某个日志文件中,比如tomcat,这样使用docker logs <容器名称>
命令是无法查看的。
注意docker logs
命令查看的是容器的全部日志,当日志量很大时会对容器的运行造成影响,可以通过docker logs --tail N container name
查看最新N行的数据,N是一个整数。
2.2 处理机制
当我们启动一个docker容器时,实际上时作为docker daemon的一个子进程运行的,docker daemon可以拿到容器里进程的标准输出与标准错误输出,并通过docker的log driver模块来处理,大致图示如下:
上面图中所列举的就是所支持的Log Driver:
- none:容器没有日志,
docker logs
不输出任何内容 - local:日志以自定义格式存储
- json-file:日志以json格式存储,默认的Log Driver
- syslog:将日志写入syslog。syslog守护程序必须在主机上运行
- journald:将日志写入journald。journald守护程序必须在主机上运行
- gelf:将日志写入Graylog Extended Log Format端点,如Graylog或Logstash
- fluentd:将日志写入fluentd。fluentd守护程序必须在主机上运行
- awslogs:将日志写入Amazon CloudWatch Logs
- splunk:通过HTTP Event Collector将日志写入splunk
- etwlogs:将日志作为ETW(Event Tracing for Windows)事件写入。只在Windows平台可用
- gcplogs:将日志写入Google Cloud Platform Logging
- logentries:将日志写入Rapid7 Logentries
可以使用命令docker info | grep "Logging Driver"
2.3 默认的json-file
json-file Log Driver是Docker默认启用的Driver,将容器的STDOUT/STDERR输出以json的格式写到宿主机的磁盘,日志文件路径为 /var/lib/docker/containers/{container_id}/{container_id}-json.log
格式是这样的:
json-file将每一行日志封装到一个json字符串中。
json-file支持如下配置:
- max-size:单个日志文件的最大大小,单位可以为k、m、g,默认是-1,表示日志文件可以无限大。
- max-file:最多可以存多少个日志文件,默认数量是1,当默认数量大于1时,每个日志文件达到最大存储大小,且数量达到设置数量,产生新日志时会删除掉最旧的一个日志文件。
- labels:指定日志所使用到的标签,使用逗号分割。比如traceId,message两个标签。
- env:指定与日志相关的环境变量,使用逗号分割
- env-rejex:一个正则表达式来匹配与日志相关的环境变量
- compress:是否压缩日志文件
3. 如何解决?
3.1 查看日志大小
我们可以通过如下脚本获取当前所有容器的日志大小,这里时使用docker默认的json-file的形式:
#!/bin/sh
echo "======== docker containers logs file size ========"
logs=$(find /var/lib/docker/containers/ -name *-json.log)
for log in $logs
do
ls -lh $log
done
执行脚本:
json-file的命令开头的一小串字符时容器的id。
例如我有一个docker容器id是2de6f164ee11,我们可以适当修改shell脚本,查看某一个容器的日志大小。
logs=$(find /var/lib/docker/containers/ -name *-json.log | grep "2de6f164ee11")
3.2 删除日志
如果docker容器正在运行,使用rm -rf的方式删除日志后,磁盘空间并没有释放。原因是在Linux或者Unix系统中,通过rm -rf或者文件管理器删除文件,将会从文件系统的目录结构上解除链接(unlink)。如果文件是被打开的(有一个进程正在使用),那么进程将仍然可以读取该文件,磁盘空间也一直被占用。
正确的方式是直接使用命令改写日志文件。
cat /dev/null > *-json.log
cat
: 是一个命令,用于连接文件并打印它们的内容到标准输出(通常是终端)。/dev/null
: 是一个特殊的设备文件,向它写入的内容会被丢弃,读取它将会立即返回结束符。>
: 是重定向操作符,将命令的输出重定向到文件。*-json.log
: 是通配符,用于匹配当前目录中所有以-json.log
结尾的文件。
可以使用如下脚本,直接处理所有的日志文件:
#!/bin/sh
echo "======== start clean docker containers logs ========"
logs=$(find /var/lib/docker/containers/ -name *-json.log)
for log in $logs
do
echo "clean logs : $log"
cat /dev/null > $log
done
echo "======== end clean docker containers logs ========"
注意,虽然使用这种方式可以删除日志,释放磁盘,但是过一段时间后,日志又会涨回来,所以要从根本上解决问题,只需要添加两个参数。没错!就是上面所讲到的max-size和max-file。
3.3 治本操作
在运行docker容器时,添加上max-size和max-file可以解决日志一直增长的问题。
docker run -it --log-opt max-size=10m --log-opt max-file=3 alpine ash
这段启动命令表示总共有三个日志文件,每个文件的最大大小时10m,这样就能将该容器的日志大小控制在最大30m。
4. 总结
在运行容器时,我们就应该优先考虑如何处理日志的问题,后面不必为容器运行后所产生的巨大日志而手足无措。
当然需要删除无用日志可以通过3.1,3.2的操作完成,建议在运行容器的时候加上max-size和max-file参数或者至少加上max-size参数。
来源:juejin.cn/post/7343178660069179432
从组件库中学习颜色主题配置
前言
对于一般前端来说,在颜色选择配置上可能没有设计师那么专业,特别在某些项目中的一些场景颜色配置上可能都是用相近的颜色或者透明度来匹配,没有一个专门的颜色对比输出。
所以本文想给大家讲一下主题色的配置应用,其中antd组件库
给我们提供了十二种自然主题色板,在美感和视觉上感觉非常的舒适自然,可以参考来使用。
我们以antd组件库
的色彩体系中的火山主题为例来讲解下面的内容。(本文会涉及到 sass
的语法)
其中提供的主题色每一种都有从浅至深有 10
个颜色,一般以第 6
种为主题的主色,其中的一些场景也给出了我们对应的颜色级别。

以图为例,告诉我们常用的场景对应的颜色深浅级别
- selected 选中:颜色值为
1
- hover 悬浮:颜色值为
5
- click 点击:颜色值为
7
主题色场景
以上述场景为例我们来实践一下,先列出10种颜色,然后写入在 css
变量中
$color-valcano: (
'valcano-1': #fff2e8,
'valcano-2': #ffd8bf,
'valcano-3': #ffbb96,
'valcano-4': #ff9c6e,
'valcano-5': #ff7a45,
'valcano-6': #fa541c,
'valcano-7': #d4380d,
'valcano-8': #ad2102,
'valcano-9': #871400,
'valcano-10': #610b00
);
:root{
@each $attribute, $value in $color-valcano {
#{'--color-#{$attribute}'}: $value
}
}
对于以上的场景,我们只需要应用对应的 css
变量即可
最终的变量如下
:root {
--color-valcano-1: #fff2e8;
--color-valcano-2: #ffd8bf;
--color-valcano-3: #ffbb96;
--color-valcano-4: #ff9c6e;
--color-valcano-5: #ff7a45;
--color-valcano-6: #fa541c;
--color-valcano-7: #d4380d;
--color-valcano-8: #ad2102;
--color-valcano-9: #871400;
--color-valcano-10: #610b00;
}
示例如下
通过变量后缀带数字这种非常难记住对应的场景值,不利于开发,我们可以再优化一下,把对应的场景细化出来,存储对应的颜色级别。
$scence-color-level: (
'primary': 6,
'selected': 1,
'hover': 5,
'border': 5,
'click': 7
);
:root{
@each $attribute, $value in $scence-color-level {
#{'--color-#{$attribute}'}: map-get($color-valcano, #{'valcano-#{$value}'})
}
}
我们来看看转换之后的变量
:root {
--color-primary: #fa541c;
--color-selected: #fff2e8;
--color-hover: #ff7a45;
--color-border: #ff7a45;
--color-click: #d4380d;
}
这样遇到对应的变量我们就可以不用关心颜色的深浅级别,只需要找对应场景,例如 selected
场景只需要使用变量 var(--color-selected)
就可以了。
当我们想切换其他主题的时候,难道要全部重写一遍,手动变更吗?
我们再来优化一下,将主题的变量变成动态的
$theme: 'valcano';
$theme-color: (
'valcano': $color-valcano,
'lime': $color-lime,
'cyan': $color-cyan,
'purple': $color-purple
);
:root{
@each $attribute, $value in $scence-color-level {
#{'--color-#{$attribute}'}: map-get(map-get($theme-color,$theme), #{'#{$theme}-#{$value}'})
}
}
以代码为例 引入了四种主题valcano
lime
cyan
purple
,若要切换主题,只需要更改变量$theme
即可
可以在代码片段中的 style
中手动更改$theme
变量值,然后运行查看效果
element组件库主题切换
了解完原理并实践之后,我们来看看 element组件库 的切换主题的原理是怎样的?
这是element的主题变量
$colors: () !default;
$colors: map.deep-merge(
(
'white': #ffffff,
'black': #000000,
'primary': (
'base': #409eff,
),
'success': (
'base': #67c23a,
),
'warning': (
'base': #e6a23c,
),
'danger': (
'base': #f56c6c,
),
'error': (
'base': #f56c6c,
),
'info': (
'base': #909399,
),
),
$colors
);
官网提供的覆盖方法
// styles/element/index.scss /* 只需要重写你需要的即可 */
@forward 'element-plus/theme-chalk/src/common/var.scss' with (
$colors: ( 'primary': ( 'base': green, ), ),
);
// 如果只是按需导入,则可以忽略以下内容。
// 如果你想导入所有样式:
// @use "element-plus/theme-chalk/src/index.scss" as *;
官网定义的主题变量是通过 map.deep-merge
来实现主题映射合并。
map.deep-merge
的作用是:用于深度合并两个或多个映射(maps)。它可以在不丢失嵌套映射的情况下合并映射,这对于处理复杂的配置数据结构非常有用。
所以其实就是通过新的配置去合并覆盖它,有点类似 Object.assign()
这种变量对象覆盖的感觉。
其中element组件库也是通过sass函数自动生成需要用到的 css 变量来重构整一个样式系统。
为什么最后都转变成css变量?
- 兼容性:因为CSS 变量是一个非常有用的功能,几乎所有浏览器都支持。
- 动态性:每个组件都是有对应的css变量,想要改变颜色,只需要动态地改变组件内的个别变量即可。
- 多样性:也可以通过js来控制css变量
来源:juejin.cn/post/7398340132161994793
好烦啊,1个SQL干崩核心系统长达12小时!
前言
1个SQL干崩核心系统长达12小时!分享一下这次的故障排查过程
1.故障现象
大周末的接到项目组的电话,反馈应用从凌晨4点开始持续卡顿,起初并未关注,到下午2点左右,核心系统是彻底干绷了,远程接入后发现,数据库后台有大量的异常等待事件
enq:TX -index contention
cursor: pin S wait on X
direct path read
通过监控发现服务器IO和CPU使用率已经高达90%
整个数据库算是夯住了!
根据经验判断应该是性能的问题
2.排查过程
2.1 AWR分析
对于这种性能的问题,首先采集到AWR报告并结合ASH报告分析一下
Direct path read事件尽然排到了第一位!占DB time高达63%,这个等待事件是让一些不常使用的大表数据(冷数据),在全表扫描时,每次都从磁盘读到用户的私有内存(PGA),而不要去挤占有限的、宝贵的、频繁使用的数据(热数据)所在的共享内存(SGA-buffer cache)。
2.2 定位异常SQL
对该TOP SQL分析发现,sql执行频繁,怀疑是执行计划发生变化,如果不把导致问题的根本原因找到,那么很有可能下次还会再发生!
2.3 分析执行计划
通过定位SQL Id,我们去看内存中的执行计划,明显看到了执行计划发生了变化,全表扫占用大量的IO,这里查看执行计划的方法很多。
--该方法是从共享池得到
如果SQL已被age out出share pool,则查找不到
select * from table
(dbms_xplan.display_cursor('&sql_id',null,'typical'));
--该方法是通过awr中得到
select * from table(dbms_xplan.display_awr('&sql_id'));
此时再追踪历史的执行计划发现,从凌晨故障发生开始,执行计划就发生了变化,SQL执行耗费到CPU的平均时间高达上百秒,历史执行计划再次验证了我的判断!
2.4 故障定位
跟业务确认得知,在凌晨业务人员发现,存储空间不够,删除了分区的来释放空间,此处相当于对表结构做了修改,执行计划发生了变化,再加上故障SQL的对应分区,统计信息一直未收集导致这次执行计划发生改变!
3.处理过程
1.定位到SQL的内存地址,从内存中刷出执行计划
select address,hash_value,
executions,parse_calls
from v$sqlarea where
sql_id='4ca86dg34xg62';
--刷出内存
exec sys.dbms_shared_pool.purge('C000000A4C502F40,4103674309','C');
2.收集分区统计信息
BEGIN
-- 为整个表加上统计信息(包括所有分区)
DBMS_STATS.GATHER_TABLE_STATS(
ownname => 'YOUR_SCHEMA', -- 替换为你的模式名
tabname => 'YOUR_PARTITIONED_TABLE', -- 替换为你的分区表名
cascade => TRUE, -- 收集所有分区的统计信息
estimate_percent => DBMS_STATS.AUTO_SAMPLE_SIZE, -- 自动估算采样百分比
method_opt => 'FOR ALL COLUMNS SIZE AUTO', -- 为所有列自动决定采样大小
degree => DBMS_STATS.DEFAULT_DEGREE -- 使用默认并行度
);
END;
/
此时我们再次查看执行计划,正确了!
4.技能拓扑
分区索引的失效,会引起执行计划的改变
1.TRUNCATE、DROP 操作可以导致该分区表的全局索引失效,
而分区索引依然有效,如果操作的分区没有数据,
那么不会影响索引的状态。
需要注意的是,
对分区表的 ADD 操作对分区索引和全局索引没有影响。
2.如果执行 SPLIT 的目标分区含有数据,
那么在执行 SPLIT 操作后,全局索引和分区索引都会
被被置为 UNUSABLE。
如果执行 SPLIT 的目标分区没有数据,
那么不会影响索引的状态。
3.对分区表执行 MOVE 操作后,
全局索引和分区索引都会被置于无效状态。
4.对于分区表而言,除了 ADD 操作之外,
TRUNCATE、DROP、EXCHANGE 和 SPLIT
操作均会导致全局索引失效,
但是可以加上 UPDATE GLOBAL INDEXES 子句让全局索引不失效。
在 12C 之前的版本,对分区表进行删除分区或者 TRUNCATE 分区,合并或者分裂分区MOVE 分区等 DDL 操作时,分区表上的全局索引会失效,通常要加上 UPDATE GLOBAIINDEXES 或者 ONLINE 关键字,可是加上这些关键字之后,本来很快的 DDL 操作可能就要花费很长的时间,而且还要面临锁的问题。“
Oracle 12C推出了分区表全局索引异步维护特性这个特性有效的解决了这个问题,在对分区表进行上述 DDL 操作时,既能快速完成操作,也能保证全局索引有效,然后通过调度JOB 在固定的时候对全局索引进行维护。“
5.总结
警惕Oracle数据库性能“隐形杀手”——Direct Path Read, 如果不把导致问题的根本原因找到,那么很有可能下次还会再发生!
来源:juejin.cn/post/7387610960159473676