全面横评 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
内网开发提效指南
❝
工欲善其事必先利其器,使用过内网开发的小伙伴都知道,CV大法在内网基本就废了,查资料也是非常的不便。对于一名程序员来说,如果把搜索引擎和CV键给他ban了,遇到问题后那基本是寸步难行。今天给大家介绍几种帮助内网开发提效的方法,希望能够帮助到大家。
一、文档站点内网部署
可以把项目中所用技术和框架的文档部署到公司内网中。
以elementPlus为例:
1、访问gh-pages分支https://github.com/element-plus/element-plus/tree/gh-pages
,下载文档站源码。
2、将文档站部署到内网服务器(以nginx为例)。
server {
listen 9800;
server_name localhost;
location / {
root html/element-plus-gh-pages;
index index.html index.htm;
try_files $uri $uri/ /element-plus-gh-pages/index.html;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
}
部署后的访问速度也是非常快的
使用这种方式,随着部署的站点增多,后续框架、文档更新的时候,维护起来相对是比较麻烦的。且只能查看文档,遇到问题需要求助度娘还是不太方便。
下面介绍两种物理外挂,可以直接访问外网。
二、USB跨屏穿越器数据线
个人感觉此方案的体验是最好的,缺点是需要两台电脑,并且需要花钱买一根线,价格在80-200之间。
购买
某宝、某鱼都有销售,我是在某宝85块买的。
使用
连接两台电脑的USB端口即可,会自动安装驱动,那根线实际上就相当于是一个文件中转器,可以实现剪切板、文件的互传。使用体验就跟一台电脑连接了两台显示器一样。如下图所示:
三、手机投屏
本文重点介绍此方案,因为可以白嫖且不需要第二台电脑。一部安卓手机+数据线即可,缺点是文件传输不太方便。它就是一个开源投屏项目scrcpy
。可以看到,此项目在github上拥有高达102k的star数量。
✨亮点
- 亮度 (原生,仅显示设备屏幕)
- 表演 (30~60fps)
- 质量 (1920×1080或以上)
- 低延迟 (70~100ms)
- 启动时间短 (显示第一张图像约1秒)
- 非侵入性 (设备上没有安装任何东西)
- 不需要 ROOT
- 有线无线都可连接
- 可以随便调整界面和码率
- 画面随意裁剪,自带录屏(手游直播利器)
- 支持多设备同时投屏
- 利用电脑的键盘和鼠标可以控制手机
- 把 APK 文件拖拽到电脑窗口即可安装应用到手机,把普通文件拖拽到窗口即可复制到手机
- 手机电脑共享剪贴板
- 自动检测USB连接的设备
- 可直接添加设备的局域网IP,达到无线控制的效果
- 将自动保存连接过的IP地址,下次输入时,自动提醒
- 支持设备别名
- 支持中英两种语言
- Tray menu
- 等等等...
安装
根据不同系统直接去release页面下载对应版本即可:github.com/Genymobile/…
使用
下载解压完,进入软件目录,点击下图按钮打开命令行界面,输入启动命令即可。
命令行输入scrcpy,按回车, 猿神,起洞!
启动之后,即可使用鼠标操作手机,非常的丝滑
1、手机复制文本到电脑
2、电脑复制文本到手机
可以看到,使用投屏的方式,也可以实现CV大法。并且可以使用手机端的外网搜索资料、解决问题等。以下是该项目的快捷键,熟练使用,即可达到人机合一的地步。
快捷键
操作 | 快捷键 | 快捷键 (macOS) | ||
---|---|---|---|---|
切换全屏模式 | Ctrl +f | Cmd +f | ||
将窗口调整为 1:1 | Ctrl +g | Cmd +g | ||
调整窗口大小以删除黑色边框 | Ctrl +x | 双击黑色背景 | Cmd +x | 双击黑色背景 |
设备HOME 键 | Ctrl +h | 鼠标中键 | Ctrl +h | 鼠标中键 |
设备BACK 键 | Ctrl +b | 鼠标右键 | Cmd +b | 鼠标右键 |
设备任务管理 键 | Ctrl +s | Cmd +s | ||
设备菜单 键 | Ctrl +m | Ctrl +m | ||
设备音量+ 键 | Ctrl +↑ | Cmd +↑ | ||
设备音量- 键 | Ctrl +↓ | Cmd +↓ | ||
设备电源 键 | Ctrl +p | Cmd +p | ||
点亮手机屏幕 | 鼠标右键 | 鼠标右键 | ||
关闭设备屏幕(保持镜像) | Ctrl +o | Cmd +o | ||
展开通知面板 | Ctrl +n | Cmd +n | ||
折叠通知面板 | Ctrl +Shift +n | Cmd +Shift +n | ||
将设备剪贴板中的内容复制到计算机 | Ctrl +c | Cmd +c | ||
将计算机剪贴板中的内容粘贴到设备 | Ctrl +v | Cmd +v | ||
将计算机剪贴板中的内容复制到设备 | Ctrl +Shift +v | Cmd +Shift +v | ||
安装APK | 将APK 文件拖入投屏 | 将APK 文件拖入投屏 | ||
传输文件到设备 | 将文件拖入投屏 | 将文件拖入投屏 | ||
启用/禁用FPS计数器(stdout) | Ctrl +i | Cmd +i |
使用小技巧
经过笔者几天的使用,总结出几个小技巧。
1、电脑键盘控制手机进行中文输入,必须使用正确的输入法组合。
手机端:讯飞输入法(搜狗输入法不支持)
电脑端:ENG(使用英文键盘)
2、手机熄屏状态下投屏。 在scrcpy命令后加上熄屏参数即可:scrcpy --turn-screen-off
这样就可以在手机熄屏的状态下,仍可以被电脑操作,达到节省电量和减轻发热的目的。
诸如此类的命令参数还有很多,执行scrcpy --help
就可查看详细的帮助文档。
衍生项目
因为开源的特性,scrcpy也衍生了一些相关项目,列举其中一些:
- QtScrcpy 使用qt重新实现的桌面端,并加强了对游戏的支持。
- scrcpy-gui 为scrcpy的命令行提供了gui界面。
- guiscrcpy 另一个scrcpy的gui界面。
- scrcpy-docker docker版本的scrcpy。
- scrcpy-go go语言版本的scrcpy,增强对游戏的支持。
总结
第二第三种方法虽然建立了内网开发电脑和外网设备的联系,但是是不会被公司的安全系统检测到一机双网的,因为其本质就类似于设计模式中的发布订阅模式,用数据线充当了中间人,两台设备之间方便传输数据了而已,不会涉及到联网。
内网开发的痛点,无非就是复制粘贴、文件传输不便,只要打通这个链路,就能解决此问题。以上三个方法,笔者在实际工作中都用到了,确实极大的提高了工作效率。如果你也在饱受内网开发的折磨,不妨试试这几个方法。
来源:juejin.cn/post/7362464700879716403
独立开发最重要的还是产品要打出差异化
独立开发者解放思维,开放眼界真的很有必要。就算自己一时没有好的 idea,也可以多观察学习一些(成功的)非主流的独立产品。我之前写文说过独立开发有死亡加速三件套:笔记、记账、todo。但是不得不说我还是保守了,俗话说的好:勇敢的人先享受世界。既然我单个产品都没优势,那我 all in one,把笔记、记账、待办都做到一个 app 里行不行?
直觉上我感觉这样不行吧?但是,啊?

原来真有人吃粉会点全家福啊。于是我想你做是能做但是用户就会喜欢吗?
我直接一个好家伙,两千多评价,4.7分!

嫉妒使我面目全非了。看了一下会员价格,买断28。这个 app 在没有苹果推荐的情况下,做到了两千多评价,我估计用户数大于 5 万。我看了一下上线时间,这个 app 前后开发持续了一年多的时间。如果我按照付费转化10%算,那么这个 app 扣掉 15% 苹果渠道费后收入大概有 12 万(50000 * 10% * 28 * 0.85)。但是这个 app 如果运营得当,后面还是会有新用户进来。因为app是极简(没有设计师的委婉说法),设计成本几乎没有。以当下的数据看,算是回本了。后面再来的增长都是利润了。
定位分析:笔记、待办、记账是很主流的用户需求,但是也有很多入门用户都只有很轻度的需求,垂直的 app 都太专业了,如果我想要这些数据在一起,很轻量,没有广告,设计简洁,又一次买断,那么市场上就没有对应的产品。
完成这个app 有两个难点:首先你要判断市场有这么一块空白,第二个难点,因为任何一个单点都没有优势,怎样触达目标用户。
我看了作者的小红书,他选择了一个很新颖的切入点:学生群体。他有一个宣传口号是“专注学生自律养成的工具 app”。学生群体不会有特别重度的需求,又集中,因为付费转化有限所以垂类专业app都不太考虑学生群体,在这个用户定位这个app又找到了独特的切入点。

这个定位有多重要呢,这个帖子带来的用户下载量就能达到 10000。
总的来说,这个 app 给我带来了很大的震撼。希望也能给大家带来启发。
来源:juejin.cn/post/7267409589066563642
一个高并发项目到落地的心酸路
前言
最近闲来没事,一直在掘金上摸鱼,看了不少高并发相关的文章,突然有感而发,想到了几年前做的一个项目,也多少和高并发有点关系。
这里我一边回忆落地细节一边和大家分享下,兴许能给大家带来点灵感。
正文
需求及背景
先来介绍下需求,首先项目是一个志愿填报系统。
核心功能是两块,一是给考试填报志愿,二是给老师维护考生数据。
本来这个项目不是我们负责,奈何去年公司负责这个项目的组遭到了甲方严重的投诉,说很多考生用起来卡顿,甚至把没填上志愿的责任归到系统上。
甲方明确要求,如果这年再出现这种情况,公司在该省的所有项目将面临被替换的风险。
讨论来讨论去,最后公司将任务落到我们头上时,已经是几个月后的事了,到临危受命阶段,剩下不到半年时间。
虽然直属领导让我们不要有心理负担,做好了表扬,做不好锅也不是我们的,但明显感觉到得到他的压力,毕竟一个不小心就能上新闻。
分析
既然开始做了,再说那些有的没的就没用了,直接开始分析需求。
首先,业务逻辑并不算复杂,难点是在并发和数据准确性上。与客户沟通后,大致了解了并发要求后,于是梳理了下。
- 考生端登录接口、考生志愿信息查询接口需要4W QPS
- 考生保存志愿接口,需要2W TPS
- 报考信息查询4W QPS
- 老师端需要4k QPS
- 导入等接口没限制,可以异步处理,只要保证将全部信息更新一遍在20分钟以内即可,同时故障恢复的时间必须在20分钟以内(硬性要求)
- 考生端数据要求绝对精准,不能出现遗漏、错误等和考生操作不一致的数据
- 数据脱敏,防伪
- 资源是有限的,提供几台物理机
大的要求就这么多,主要是在有限资源下需要达到如此高的并发确实需要思考思考,一般的crud根本达不到要求。
方案研讨
接下来我会从当时我们切入问题的点开始,从前期设计到项目落地整个过程的问题及思考,一步步去展示这个项目如何实现的
首先,我们没有去设计表,没有去设计接口,而是先去测试。测试什么?测试我们需要用到或可能用到的中间件是否满足需求
MySQL
首先是MySQL,单节点MySQL测试它的读和取性能,新建一张user表。
向里面并发插入数据和查询数据,得到的TPS大概在5k,QPS大概在1.2W。
查询的时候是带id查询,索引列的查询不及id查询,差距大概在1k。
insert和update存在细微并发差距,但基本可以忽略,影响更新性能目前最大的问题是索引。
如果表中带索引,将降低1k-1.5k的TPS。
目前结论是,mysql不能达到要求,能不能考虑其他架构,比如mysql主从复制,写和读分开。
测试后,还是放弃,主从复制结构会影响更新,大概下降几百,而且单写的TPS也不能达到要求。
至此结论是,mysql直接上的方案肯定是不可行的
Redis
既然MySQL直接查询和写入不满足要求,自然而然想到加入redis缓存。于是开始测试缓存,也从单节点redis开始测试。
get指令QPS达到了惊人的10w,set指令TPS也有8W,意料之中也惊喜了下,仿佛看到了曙光。
但是,redis容易丢失数据,需要考虑高可用方案
实现方案
既然redis满足要求,那么数据全从redis取,持久化仍然交给mysql,写库的时候先发消息,再异步写入数据库。
最后大体就是redis + rocketMQ + mysql的方案。看上去似乎挺简单,当时我们也这样以为 ,但是实际情况却是,我们过于天真了。
这里主要以最重要也是要求最高的保存志愿信息接口开始攻略
故障恢复
第一个想到的是,这些个节点挂了怎么办?
mysql挂了比较简单,他自己的机制就决定了他即使挂掉,重启后仍能恢复数据,这个可以不考虑。
rocketMQ一般情况下挂掉了可能会丢失数据,经过测试发现,在高并发下,确实存在丢消息的现象。
原因是它为了更加高效,默认采用的是异步落盘的模式,这里为了保证消息的绝对不丢失,修改成同步落盘模式。
然后是最关键的redis,不管哪种模式,redis在高并发下挂掉,都会存在丢失数据的风险。
数据丢失对于这个项目格外致命,优先级甚至高于并发的要求。
于是,问题难点来到了如何保证redis数据正确,讨论过后,决定开启redis事务。
保存接口的流程就变成了以下步骤:
1.redis 开启事务,更新redis数据
2.rocketMQ同步落盘
3.redis 提交事务
4.mysql异步入库
我们来看下这个接口可能存在的问题。
第一步,如果redis开始事务或更新redis数据失败,页面报错,对于数据正确性没有影响
第二步,如果rocketMQ落盘报错,那么就会有两种情况。
情况一,落盘失败,消息发送失败,好像没什么影响,直接报错就可。
情况二,如果发送消息成功,但提示发送失败(无论什么原因),这时候将导致mysql和redis数据的最终不一致。
如何处理?怎么知道是redis的有问题还是mysql的有问题?出现这种情况时,如果考生不继续操作,那么这条错误的数据必定无法被更新正确。
考虑到这个问题,我们决定引入一个时间戳字段,同时启动一个定时任务,比较mysql和redis不一致的情况,并自主修复数据。
首先,redis中记录时间戳,同时在消息中也带上这个时间戳并在入库时记录到表中。
然后,定时任务30分钟执行一次,比较redis中的时间戳是否小于mysql,如果小于,便更新redis中数据。如果大于,则不做处理。
同时,这里再做一层优化,凌晨的时候执行一个定时任务,比较redis中时间戳大于mysql中的时间戳,连续两天这条数据都存在且没有更新操作,将提示给我们手动运维。
然后是第三步,消息提交成功但是redis事务提交失败,和第二步处理结果一致,将被第二个定时任务处理。
这样看下来,即使redis崩掉,也不会丢失数据。
第一轮压测
接口实现后,当时怀着期待,信息满满的去做了压测,结果也是当头棒喝。
首先,数据准确性确实没有问题,不管突然kill掉哪个环节,都能保证数据最终一致性。
但是,TPS却只有4k不到的样子,难道是节点少了?
于是多加了几个节点,但是仍然没有什么起色。问题还是想简单了。
重新分析
经过这次压测,之后一个关键的问题被提了出来,影响接口TPS的到底是什么???
一番讨论过后,第一个结论是:一个接口的响应时间,取决于它最慢的响应时间累加,我们需要知道,这个接口到底慢在哪一步或哪几步?
于是用arthas看了看到底慢在哪里?
结果却是,最慢的竟然是redis修改数据这一步!这和测试的时候完全不一样。于是针对这一步,我们又继续深入探讨。
结论是:
redis本身是一个很优秀的中间件,并发也确实可以,选型时的测试没有问题。
问题出在IO上,我们是将考生的信息用json字符串存储到redis中的(为什么不保存成其他数据结构,因为我们提前测试过几种可用的数据结构,发现redis保存json字符串这种性能是最高的),
而考生数据虽然单条大小不算大,但是在高并发下的上行带宽却是被打满的。
于是针对这种情况,我们在保存到redis前,用gzip压缩字符串后保存到redis中。
为什么使用gzip压缩方式,因为我们的志愿信息是一个数组,很多重复的数据其实都是字段名称,gzip和其他几个压缩算法比较后,综合考虑到压缩率和性能,在当时选择了这种压缩算法。
针对超过限制的字符串,我们同时会将其拆封成多个(实际没有超过三个的)key存储。
继续压测
又一轮压测下来,效果很不错,TPS从4k来到了8k。不错不错,但是远远不够啊,目标2W,还没到它的一半。
节点不够?加了几个节点,有效果,但不多,最终过不了1W。
继续深入分析,它慢在哪?最后发现卡在了rocketMQ同步落盘上。
同步落盘效率太低?于是压测一波发现,确实如此。
因为同步落盘无论怎么走,都会卡在rocketMQ写磁盘的地方,而且因为前面已经对字符串压缩,也没有带宽问题。
问题到这突然停滞,不知道怎么处理rocketMQ这个点。
同时,另一个同事在测试查询接口时也带来了噩耗,查询接口在1W2左右的地方就上不去了,原因还是卡在带宽上,即使压缩了字符串,带宽仍被打满。
怎么办?考虑许久,最后决定采用较常规的处理方式,那就是数据分区,既然单个rocketMQ服务性能不达标,那么就水平扩展,多增加几个rocketMQ。
不同考生访问的MQ不一样,同时redis也可以数据分区,幸运的是正好redis有哈希槽的架构支持这种方式。
而剩下的问题就是如何解决考生分区的方式,开始考虑的是根据id进行求余的分区,但后来发现这种分区方式数据分布及其不均匀。
后来稍作改变,根据正件号后几位取余分区,数据分布才较为均匀。有了大体解决思路,一顿操作后继续开始压测。
一点小意外
压测之后,结果再次不如人意,TPS和QPS双双不增反降,继续通过arthas排查。
最后发现,redis哈希槽访问时会在主节点先计算key的槽位,而后再将请求转到对应的节点上访问,这个计算过程竟然让性能下降了20%-30%。
于是重新修改代码,在java内存中先计算出哈希槽位,再直接访问对应槽位的redis。如此重新压测,QPS达到了惊人的2W,TPS也有1W2左右。
不错不错,但是也只到了2W,在想上去,又有了瓶颈。
不过这次有了不少经验,马上便发现了问题所在,问题来到了nginx,仍然是一样的问题,带宽!
既然知道原因,解决起来也比较方便,我们将唯一有大带宽的物理机上放上两个节点nginx,通过vip代理出去,访问时会根据考生分区信息访问不同的地址。
压测
已经记不清第几轮压测了,不过这次的结果还算满意,主要查询接口QPS已经来到了惊人的4W,甚至个别接口来到6W甚至更高。
胜利已经在眼前,唯一的问题是,TPS上去不了,最高1W4就跑不动了。
什么原因呢?查了每台redis主要性能指标,发现并没有达到redis的性能瓶颈(上行带宽在65%,cpu使用率也只有50%左右)。
MQ呢?MQ也是一样的情况,那出问题的大概率就是java服务了。分析一波后发现,cpu基本跑到了100%,原来每个节点的最大链接数基本占满,但带宽竟然还有剩余。
静下心来继续深入探讨,连接数为什么会满了?原因是当时使用的SpringBoot的内置容器tomcat,无论如何配置,最大连接数最大同时也就支持1k多点。
那么很简单的公式就能出来,如果一次请求的响应时间在100ms,那么1000 * 1000 / 100 = 10000。
也就是说单节点最大支持的并发也就1W,而现在我们保存的接口响应时间却有300ms,那么最大并发也就是3k多,目前4个分区,看来1W4这个TPS也好像找到了出处了。
接下来就是优化接口响应时间的环节,基本是一步一步走,把能优化的都优化了一遍,最后总算把响应时间控制在了100ms以内。
那么照理来说,现在的TPS应该会来到惊人的4W才对。
再再次压测
怀着忐忑又激动的心情,再一次进入压测环节,于是,TPS竟然来到了惊人的2W5。
当时真心激动了一把,但是冷静之后却也奇怪,按正常逻辑,这里的TPS应该能达到3W6才对。
为了找到哪里还有未发现的坑(怕上线后来惊喜),我们又进一步做了分析,最后在日志上找到了些许端倪。
个别请求在链接redis时报了链接超时,存在0.01%的接口响应时间高于平均值。
于是我们将目光投向了redis连接数上,继续一轮监控,最终在业务实现上找到了答案。
一次保存志愿的接口需要执行5次redis操作,分别是获取锁、获取考生信息、获取志愿信息、修改志愿信息、删除锁,同时还有redis的事务。
而与之相比,查询接口只处理了两次操作,所以对于一次保存志愿的操作来看,单节点的redis最多支持6k多的并发。
为了验证这个观点,我们尝试将redis事务和加锁操作去掉,做对照组压测,发现并发确实如预期的一样有所提升(其实还担心一点,就是抢锁超时)。
准备收工
至此,好像项目的高并发需求都已完成,其他的就是完善完善细节即可。
于是又又又一次迎来了压测,这一次不负众望,重要的两个接口均达到了预期。
这之后便开始真正进入业务实现环节,待整个功能完成,在历时一个半月带两周的加班后,终于迎来了提测。
提测后的问题
功能提测后,第一个问题又又又出现在了redis,当高并发下突然kill掉redis其中一个节点。
因为用的是哈希槽的方式,如果挂掉一个节点,在恢复时重新算槽将非常麻烦且效率很低,如果不恢复,那么将严重影响并发。
于是经过讨论之后,决定将redis也进行手动分区,分区逻辑与MQ的一致。
但是如此做,对管理端就带来了一定影响,因为管理端是列表查询,所以管理端获取数据需要从多个节点的redis中同时获取。
于是管理端单独写了一套获取数据分区的调度逻辑。
第二个问题是管理端接口的性能问题,虽然管理端的要求没考生端高,但扛不住他是分页啊,一次查10个,而且还需要拼接各种数据。
不过有了前面的经验,很快就知道问题出在了哪里,关键还是redis的连接数上,为了降低链接数,这里采用了pipeline拼接多个指令。
上线
一切准备就绪后,就准备开始上线。说一下应用布置情况,8+4+1+2个节点的java服务,其中8个节点考生端,4个管理端,1个定时任务,2个消费者服务。
3个ng,4个考生端,1个管理端。
4个RocketMQ。
4个redis。
2个mysql服务,一主一从,一个定时任务服务。
1个ES服务。
最后顺利上线,虽然发生了个别线上问题,但总体有惊无险,
而真是反馈的并发数也远没有到达我们的系统极限,开始准备的水平扩展方案也没有用上,无数次预演过各个节点的宕机和增加分区,一般在10分钟内恢复系统,不过好在没有排上用场。
最后
整个项目做下来感觉越来越偏离面试中的高并发模式,说实在的也是无赖之举,
偏离的主要原因我认为是项目对数据准确性的要求更高,同时需要完成高并发的要求。
但是经过这个项目的洗礼,在其中也收获颇丰,懂得了去监控服务性能指标,然后也加深了中间件和各种技术的理解。
做完之后虽然累,但也很开心,毕竟在有限的资源下去分析性能瓶颈并完成项目要求后,还是挺有成就感的。
再说点题外话,虽然项目成功挽回了公司在该省的形象,也受到了总公司和领导表扬,但最后也就这样了,
实质性的东西一点没有,这也是我离开这家公司的主要原由。不过事后回想,这段经历确实让人难忘,也给我后来的工作带来了很大的帮助。
从以前的crud,变得能去解决接口性能问题。这之前一遇上,可能两眼茫然或是碰运气,现在慢慢的会根据蛛丝马迹去探究优化方案。
不知道我在这个项目的经历是否能引起大家共鸣?希望这篇文章能对你有所帮助。
来源:juejin.cn/post/7346021356679675967
【年终总结】置顶帖 我们的2023 (• ᴗ •͈)◞︎ᶫᵒᵛᵉ ♡ & 订婚快乐
前言
想着给自己的博客每年一个置顶帖子,于是便有了此文。
1. 博主的自我介绍
我是一名Java开发工作者,20年大学毕业,专业:计算机科学与技术。
当年高考填报志愿的时候,计算机是一个新兴的蓬勃发展的行业,当时选择这个专业的原因时:一是想着毕业之后进入到这个行业从事相关工作,二是想着这个专业在公务员报考中能报考的岗位也很多。但是大四规划未来人生方向的时候,市场给我们开了一个大大的玩笑,互联网行业乱成了一锅粥,有一句话叫做万物皆可转码,再加之培训班疯狂的向社会输送程序员,市场乱象频频发生,培训班出来的新人能把自己包装成4年5年工作经验,然后各个公司为了能降低用人成本,也为了节省培养新人的时间和开支,也是只招聘工作经验大于3、4年以上的程序员,导致市场对于应届生的接收程度极差,都不愿意招聘应届生,班里的很多学生该培训的培训,该考研考公的备考,该转行的转行....
这个时期能走的路只有三条
- 考研 毕业成为大厂码农 (幻想的)
- 考公
- 和现在就成为码农。
考研呢,也考了,大四下半学期备战了半年,怎么说呢,结果不意外,对自己的认知有偏差,如果能报考一个相对稳妥的双非本科,现在说必定也是一个失业的研究生呢,但是收获最少我把计算机四大件(数据结构、计算机网络、操作系统、计算机组成原理)背了个底朝天,也算是在将近毕业的时候努力的学习了一把大学专业基础的知识。
考公呢,那时候我莫名觉得考公绝对不行,当时运筹帷幄,指点江山,告知自己,现在体制内的各种发生的事情和情况导致未来的公务员绝对不是铁饭碗,加之公务员工资毕竟不像成为程序员肉眼可见的工资那么可观,于是果断的放弃了考公务员,真的,现在有巴掌我第一个扇回去。当时可是20年呀,公务员真的不是很卷,你看看现在,后悔 ....
考研失败之后还是找了个互联网的厂子,现在从事这个行业也有三四年时间了,但是行业的前景却并不像七八年前那样的好。
2、2023年
2023年对我来说绝对是人生最重要的一年之一。
上半年是我老婆人生中最重要的时期之一,她面临着毕业压力(一度让她面临抑郁和崩溃,可能你们会说不就是毕业嘛,哪有那么夸张,我只能说每个人的情况不一样,希望你们一辈子也不要遇见像她导师那样的人...)。
正在外边商场吃饭,然后接到导师电话开始开临时组会。
我们如履薄冰,战战兢兢度过了这段时间。终于,在六月份,她顺利毕业了(万幸)。
(顺利毕业)
(surprise)
七月,她也找到了人生中的第一份工作,我们的生活开始步入正轨。
同样七月终于拥有了人生第一辆车。
(开心)
在家人和我们自己的决定下,我们如期在十月订婚了。这对我们来说是非常重要的一步。
(去银行买的金条,打的金镯子,很划算哈 ~)
(自己买的订婚现场的装饰,pdd 买的,至今为止,我还是觉得我布置的最好看。)
(怎么样呀?jym)
(家人们,教你们一招 五粮液、软华子直接拿下老丈人)
(订婚现场,主持人是我一个妹妹,这张图能看清我桌子上都摆了啥,当天我没拍桌子。)
(订婚书一签,你可不就是我的人了 ~)
(直接拿下)
(小样,还想跑)
(往后余生,我们一起走)
本以为人生会一帆风顺地继续下去,但工作没多久,因为是制造业,厂里的有毒气体使得她呼吸道和身体都在变差,她不得不选择辞职(裸辞)。23年的社会结构变迁使得学历贬值,大学生人数的激增导致工作变得更加稀缺,失业潮开始席卷。
老婆从失业到现在一直住在我租的小房间里,但是我相信人生不可能总是一帆风顺的。
3、技术成长
除了完成基本的工作业务(今年也上线了 3个web网站 和 3个小程序)之外的技术栈扩充:
- 学习了 SpringBoot3.0 和 对应的 Spring 的生态。比如 SpringSecurity 6.0 以上 ...
- 还有 Java 17 的一些新特性和性能。
- 学习了 C# 和 .Net (只能说和 Java 很像,补全版的Java),正在用C# 做了一个对接 OpenAI的一个类似 ChatGpt 一样的对话类的网站。(还不是很熟,但是能写了,面向百度编程)
- 学习了 领域驱动 DDD ,真的感觉要长脑子了,很多系统性的东西在慢慢融合。
- 接触了 Vue3 和 TypeScript (嗯,万物都在往面向对象发展 ~)
- 可以从 需求制定,域名、服务器、云服务 产品的选型,技术选型,前后端开发,部署上线,性能优化,中间件搭建,后期的运维测试。都能自己一个人搞下来了。(还是勉勉强强,但是按照我们现在这种模式的话,稍微简单一点的服务还是没啥问题的、单体服务就更没问题了。没错,我就是小公司全能手)
- 现在 Python 和 Shell 脚本 玩得 6 的一 。(脚本真方便,而且这种解释器语言真好用~)
4、个人成长
这一年中,有一丝丝的人生感悟,分享给大家。
在工作上,大家都知道,写代码到一定程度会有质的飞跃。今年我在工作方面有了一些感悟,但随着对知识的深入,我也意识到自己所不知道的更多。我从最基础的 Java、数据库和 Web 开发开始,逐渐深入研究技术,包括 Java、系统设计和架构、各种数据库进阶以及项目管理,然后扩展到安全与性能优化、沟通能力、业务和行业理解,以及技术趋势和新技术。然而,最近我意识到,一切都归结于一个道理:先做人,再做事。懂得做人,任何问题都能迎刃而解。我对此有些许领悟。
在工作中,技术的学习和提升固然至关重要,但随着不断的实践,我逐渐意识到成功并不仅取决于技术能力,更关键的是人格魅力和人际交往能力。我开始认识到要从意识开始转变。
从最开始的技术基础学习,到技术的深入研究,再到全面提升,这是职业发展的必经之路。但是当在职场中积累了一定经验后,我渐渐明白技术只是成功的一部分。在实际工作中,需要与人打交道、管理团队、协调资源,这些都需要更多的人际关系、沟通技巧和领导力。虽然我现在还在追求进步,但这是必经之路。
要学会做人,在职场上就意味着有坚定的原则和良好的职业道德。这意味着以诚实、正直、宽容的态度对待他人,学会尊重和理解他人的不同,善于倾听,愿意分享和帮助他人。
此外,学会做人也包括与同事的良好合作、与上级的有效沟通、与下属的关心和激励。建立良好的人际关系,培养团队合作精神。
我曾听过这样一段话,我们这一代,因为互联网的高速发展,能轻易接触到信息,就以为自己掌握了很多,但这个世界上真正有价值的东西都需付出等价的代价。先学会做人,再学做事。
5、总结与展望
新的一年:
- 常回家看看,暂时定的两个月最少回去一次吧。
- 好好给老婆准备明年春招。
- 好好写一些博客,将自己的技术分享出来。将自己的博客好好运营一下。
- 好好吃饭、锻炼,我实在是太瘦了,身体是革命的本钱。
- 交易,看看大A水有多深。兄弟们 2700 抄底了~
- 攒点钱,再去一次 汉中-留坝县自驾游。
- 抽时间把婚纱照拍了~
什么都会过去的,抓住现在,不是吗?
(我的头像,在汉中狮子沟牧场拍的)
(开心最重要,不是吗?)
(去年全球总决赛[ wbg vs t1]的时候线下场馆拍的,笑死 ~)
(前几天下雪 恐龙 vs 鸭子)
就这些,希望明年年度总结的时候可以将这些都完成 ~
引用《人世间》种的一句话:
从你出生的那一刻起, 端什么碗,吃什么饭, 经历什么事,什么时候和谁结婚, 都是定数。 别太难为自己,顺其自然就好。 人生的剧本, 你早在天堂就看过了, 你之所以选择这个剧本是因为, 这一生中有你认为值得的地方。
jym 加油 !
来源:juejin.cn/post/7310101033635725375
UI: 为啥你这个页面边框1px看起来这么粗?
背景
最近在开发H5,ui稿给的border:1px solid,因为ui稿上的宽度是750px,我们运行的页面宽度是375px,所以我们需要把所以尺寸/2。所以我们可能会想写border:0.5px solid。但是实际上,我们看页面渲染,仍然是渲染1px而不是0.5
示例代码
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: Arial, sans-serif;
}
.flex {
display: flex;
}
.item {
margin-right: 10px;
padding: 10px;
font-size: 13px;
line-height: 1;
background-color: rgba(242, 243, 245,1);
}
.active {
color: rgba(250, 100, 0, 1);
font-size: 14px;
border: 0.5px solid ;
}
</style>
</head>
<body>
<div class="flex">
<!-- <div class="item active">
active
</div> -->
<div class="item">
item1
</div>
<div class="item">
item2
</div>
<div class="item">
item3
</div>
</div>
</body>
</html>
在没active的情况下
他们的内容都是占13px
在有active的情况下
active占了14px这个是没问题的,因为它font-size是14px嘛,但是我们是设置了border的宽度是0.5px,但展现的却是1px。
再来看看item
它内容占了16px,它受到相邻元素影响是14px+2px的上下边框
为啥border是1px呢
在 CSS 中,边框可以设置为 0.5px,但在某些情况下,尤其是低分辨率的屏幕上,浏览器可能会将其渲染为 1px 或根本不显示。这是因为某些浏览器和显示设备不支持小于 1px 的边框宽度或不能准确渲染出这样的细小边框。
浏览器渲染机制
- 不同浏览器对于小数像素的处理方式不同。一些浏览器可能会将
0.5px
边框四舍五入为1px
,以确保在所有设备上的一致性。
设备像素比
- 在高 DPI(如 Retina 显示器)设备上,
0.5px
边框可能看起来更清晰,因为这些设备可以渲染更细的边框。 - 在低 DPI 设备上,
0.5px
边框可能会被放大或者根本不会被显示。
解决办法
方法一:使用伪类和定位
.active {
color: rgba(250, 100, 0, 1);
font-size: 14px;
position: relative;
}
.active::after {
content: "";
pointer-events: none;
display: block;
position: absolute;
left: 0;
top: 0;
transform-origin: 0 0;
border: 1px #ff892e solid;
box-sizing: border-box;
width: 100%;
height: 100%;
}
另外的item的内容高度也是14px了符合要求
方法二:使用阴影,使用F12看的时候感觉还是有些问题
.active2 {
margin-left: 10px;
color: rgba(250, 100, 0, 1);
font-size: 14px;
position: relative;
box-shadow: 0 0 0 0.5px #ff892e;
}
方法三:使用svg,但这种自己设置了宽度。
<div class="active">
<svg width="100%" height="100%" viewBox="0 0 100 100" preserveAspectRatio="none">
<rect x="0" y="0" width="100" height="100" fill="none" stroke="#ff892e" stroke-width="0.5"></rect>
</svg>
active
</div>
方案四:使用svg加定位,也比较麻烦,而且有其他的问题
<div class="active">
<svg viewBox="0 0 100 100" preserveAspectRatio="none">
<rect x="0" y="0" width="100" height="100" fill="none" stroke="#ff892e" stroke-width="0.5"></rect>
</svg>
<div class="content">active</div>
</div>
.active {
color: rgba(250, 100, 0, 1);
font-size: 14px;
position: relative;
display: inline-block;
}
.active svg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
box-sizing: border-box;
}
.active .content {
position: relative;
z-index: 1;
}
方法五:使用一个父元素 比较麻烦
<div class="border-container">
<div class="active">active</div>
</div>
.border-container {
display: inline-block;
padding: 0.5px;
background-color: #ff892e;
}
.active {
color: rgba(250, 100, 0, 1);
font-size: 14px;
background-color: white;
}
最后
在公司里,我们使用的都是方案一,这样active和item它们的内容高度都是14px了。然后我们再给他们的父盒子加上 align-items: center。这样active的高度是14px,其他都是13px了。但是active的高度会比其他item的盒子高1px,具体看个人需求是否添加吧。
来源:juejin.cn/post/7393656776539963407
基于英雄联盟人物的加载动画,奇怪的需求又增加了!
1、背景
前两天老板找到我说有一个需求,要求使用英雄联盟的人物动画制作一个加载中的组件,类似于下面这样:
我定眼一看:这个可以实现,但是需要UI妹子给切图。
老板:UI? 咱们啥时候招的UI !
我:老板,那不中呀,不切图弄不成呀。
老板:下个月绩效给你A。
我:那中,管管管。
2、调研
发动我聪明的秃头,实现这个需求有以下几种方案:
- 切动画帧,没有UI不中❎。
- 去lol客户端看看能不能搞到什么美术素材,3D模型啥的,可能行❓
- 问下 gpt4o,有没有哪个老表收集的有lol英雄的美术素材,如果有那就更得劲了✅。
经过我一番搜索,发现了这个网站:model-viewer,收集了很多英雄联盟的人物模型,模型里面还有各种动画,还给下载。老表,这个需求稳了50%了!
接下来有几种选择:
- 将模型动画转成动画帧,搞成雪碧图,较为麻烦,且动画不支持切换。
- 直接加载模型,将模型放在进度条上,较为简单,支持切换不同动画,而且可以自由过渡。就是模型文件有点大,初始化加载可能耗时较长。但是后续缓存一下就好了。
聪明的我肯定先选第二个方案呀,你糊弄我啊,我糊弄你。
3、实现
web中加载模型可以使用谷歌基于threejs
封装的 model-viewer
, 使用现代的 web component 技术。简单易用。
先初始化一个vue工程
npm create vue@latest
然后将里面的初始化的组件和app.vue里面的内容都删除。
安装model-viewer
依赖:
npm i three // 前置依赖
npm i @google/model-viewer
修改vite.config.js
,将model-viewer
视为自定义元素,不进行编译
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue({
template: {
// 添加以下内容
compilerOptions: {
isCustomElement: (tag) => ['model-viewer'].includes(tag)
}
}
})
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
assetsInclude: ['./src/assets/heros/*.glb']
})
新建 src/components/LolProgress.vue
<template>
<div class="progress-container">
<model-viewer
:src="hero.src"
disable-zoom
shadow-intensity="1"
:camera-orbit="hero.cameraOrbit"
class="model-viewer"
:style="heroPosition"
:animation-name="animationName"
:camera-target="hero.cameraTarget"
autoplay
ref="modelViewer"
></model-viewer>
<div
class="progress-bar"
:style="{ height: strokeWidth + 'px', borderRadius: strokeWidth / 2 + 'px' }"
>
<div class="progress-percent" :style="currentPercentStyle"></div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch, type PropType } from 'vue'
/** 类型 */
interface Hero {
src: string
cameraOrbit: string
progressAnimation: string
finishAnimation: string
finishAnimationIn: string
cameraTarget: string
finishDelay: number
}
type HeroName = 'yasuo' | 'yi'
type Heros = {
[key in HeroName]: Hero
}
const props = defineProps({
hero: {
type: String as PropType<HeroName>,
default: 'yasuo'
},
percentage: {
type: Number,
default: 100
},
strokeWidth: {
type: Number,
default: 10
},
heroSize: {
type: Number,
default: 150
}
})
const modelViewer = ref(null)
const heros: Heros = {
yasuo: {
src: '/src/components/yasuo.glb',
cameraOrbit: '-90deg 90deg',
progressAnimation: 'Run2',
finishAnimationIn: 'yasuo_skin02_dance_in',
finishAnimation: 'yasuo_skin02_dance_loop',
cameraTarget: 'auto auto 0m',
finishDelay: 2000
},
yi: {
src: '/src/components/yi.glb',
cameraOrbit: '-90deg 90deg',
progressAnimation: 'Run',
finishAnimationIn: 'Dance',
finishAnimation: 'Dance',
cameraTarget: 'auto auto 0m',
finishDelay: 500
}
}
const heroPosition = computed(() => {
const percentage = props.percentage > 100 ? 100 : props.percentage
return {
left: `calc(${percentage + '%'} - ${props.heroSize / 2}px)`,
bottom: -props.heroSize / 10 + 'px',
height: props.heroSize + 'px',
width: props.heroSize + 'px'
}
})
const currentPercentStyle = computed(() => {
const percentage = props.percentage > 100 ? 100 : props.percentage
return { borderRadius: `calc(${props.strokeWidth / 2}px - 1px)`, width: percentage + '%' }
})
const hero = computed(() => {
return heros[props.hero]
})
const animationName = ref('')
watch(
() => props.percentage,
(percentage) => {
if (percentage < 100) {
animationName.value = hero.value.progressAnimation
} else if (percentage === 100) {
animationName.value = hero.value.finishAnimationIn
setTimeout(() => {
animationName.value = hero.value.finishAnimation
}, hero.value.finishDelay)
}
}
)
onMounted(() => {
setTimeout(() => {
console.log(modelViewer.value.availableAnimations)
}, 2000)
})
</script>
<style scoped>
.progress-container {
position: relative;
width: 100%;
}
.model-viewer {
position: relative;
background: transparent;
}
.progress-bar {
border: 1px solid #fff;
background-color: #666;
width: 100%;
}
.progress-percent {
background-color: aqua;
height: 100%;
transition: width 100ms ease;
}
</style>
组件非常简单,核心逻辑如下:
- 根据传入的英雄名称加载模型
- 指定每个英雄的加载中的动画,
- 加载100%,切换完成动作进入动画和完成动画即可。
- 额外的细节处理。
最后修改
app.vue
:
<script setup lang="ts">
import { ref } from 'vue'
import LolProgress from './components/LolProgress.vue'
const percentage = ref(0)
setInterval(() => {
percentage.value = percentage.value + 1
}, 100)
</script>
<template>
<main>
<LolProgress
:style="{ width: '200px' }"
:percentage="percentage"
:heroSize="200"
hero="yasuo"
/>
</main>
</template>
<style scoped></style>
这不就完成了吗,先拿给老板看看。
老板:换个女枪的看看。
我:好嘞。
老板:弄类不赖啊小伙,换个俄洛伊的看看。
4、总结
通过本次需求,了解到了 model-viewer
组件。
老板招个UI妹子吧。
在线体验:github-pages
来源:juejin.cn/post/7377217883305279526
做了这么久前端还不会手写瀑布流?(H5 & 小程序)
前言
做了7年前端我一直不知道瀑布流是什么(怪设计师不争气啊,哈哈哈),我一直以为就是个普通列表,几行css解决的那种。
当然瀑布流确实有css解决方案,但是这个方案对于分页列表来说完全不能用,第二页内容一出来位置都变了。
我看了一下掘金的一些文章,好长啊,觉得还是自己想一下怎么写吧。就自己实现了一遍。希望思路给大家一点帮助。
分析瀑布流

以小红书的瀑布流为例,相同宽度不同高度的卡片堆叠在一起形成瀑布流。
这里有两个难点:
- 卡片高度如何确定?
- 堆叠布局如何实现?
卡片的高度 = padding + imageHeight + textHeight....
不固定的内容包括:图片高度、标题行数
也就是说当我们解决了图片和标题的高度问题,那么瀑布流的第一个问题就解决了。(感觉已经写好代码了一样)
堆叠问题——因为css没有这样的布局方式,所以肯定得用js实现。最简单的解决方案就是对每一个盒子进行绝对定位。
这个问题就转换成计算现有盒子的定位问题。
从问题到代码
第一个问题——图片高度
无论是企业业务场景还是个人开发,通过后端返回图片的width、height都是合理且轻松的。
前端去获取图片信息,无疑让最重要的用户体验变得糟糕。前端获取图片信息并不困难,但是完全没有必要。
所以我直接考虑后端返回图片信息的情况。
const realImageHeight = imageWidth / imageHeight * cardContentWidth;
图片高度轻松解决,无平台差异
第二个问题——文字高度
从小红书可以看出,标题有些两行有些一行,也有些三行。
如果你固定一行,这个问题完全可以跳过。
- 方案一:我们可以用字数和宽度来计算可能得行数
优势:速度快,多平台复用
劣势:不准确(标题包括英文中文) - 方案二:我们可以先渲染出来再获取行数
优势:准确
劣势:相对而言慢,不同平台方法不同
准确是最重要的!选择方案二
其实方案二也有两种方案,一种是用canvas模拟,这样可以最大限度摆脱平台(h5、小程序)的限制,
然而我试验后,canvas还没找到准确的计算的方法(待后续更新)
第二种就是用div渲染一遍,获取行数或者高度。
创建一个带有指定样式的 div 元素
function createDiv(style: string): HTMLDivElement {
const div = document.createElement('div');
div.style.cssText = style;
document.body.appendChild(div);
return div;
}
计算文本数组在指定字体大小和容器宽度下的行数
/**
* 计算文本数组在指定字体大小和容器宽度下的行数
* @param texts - 要渲染的文本数组
* @param fontSize - 字体大小(以像素为单位)
* @param lineHeight - 字体高度(以像素为单位)
* @param containerWidth - 容器宽度(以像素为单位)
* @param maxLine - 最大行数(以像素为单位)
* @returns 每个文本实际渲染时的行数数组
*/
export function calculateTextLines(
texts: string[],
fontSize: number,
lineHeight: number,
containerWidth: number,
maxLine?: number
): number[] {
// 创建一个带有指定样式的 div 元素
const div = createDiv(`font-size: ${fontSize}px; line-height: ${lineHeight}px; width: ${containerWidth}px; white-space: pre-wrap;`);
const results: number[] = [];
texts.forEach((text) => {
div.textContent = text;
// 获取 div 的高度,并根据字体大小计算行数
const divHeight = div.offsetHeight;
const lines = Math.ceil(divHeight / lineHeight);
maxLine && lines > maxLine ? results.push(maxLine) : results.push(lines);
});
// 清理 div
removeElement(div);
return results;
}
这个问题小程序如何解决放在文末
第三个问题——每个卡片的定位问题
解决了上面的问题,就解决了盒子高度的问题,这个问题完全就是相同宽度不同高度盒子的堆放问题了
问题的完整描述是这样的:
写一个ts函数实现将一堆小盒子,按一定规则顺序推入大盒子里
函数输入:小盒子高度列表
小盒子:不同小盒子高度不一致,宽度为stackWidth,彼此间隔gap
大盒子:高度无限制,宽度为width
堆放规则:优先放置高度低的位置,高度相同时优先放在左侧
返回结果:不同盒子的高度和位置信息
如果你有了这么清晰的描述,接下去的工作你只需要交给gpt来写你的函数
// 返回的盒子信息
export interface Box {
x: number;
y: number;
height: number;
}
// 盒子堆叠的方法类
export class BoxPacker {
// 返回的小盒子信息列表
private boxes: Box[] = [];
// 大盒子宽度
private width: number;
// 小盒子宽度
private stackWidth: number;
// 小盒子间隔
private gap: number;
constructor(width: number, stackWidth: number, gap: number) {
this.width = width;
this.stackWidth = stackWidth;
this.gap = gap;
this.boxes = [];
}
// 添加单个盒子
public addBox(height: number): Box[] {
return this.addBoxes([height]);
}
// 添加多个盒子(一般用这个方法)
public addBoxes(heights: number[], isReset?: boolean): Box[] {
isReset && (this.boxes = [])
console.log('this.boxes—————— ', JSON.stringify(this.boxes) )
for (const height of heights) {
const position = this.findBestPosition();
const newBox: Box = { x: position.x, y: position.y, height };
this.boxes.push(newBox);
}
return this.boxes;
}
// 查找定位函数
private findBestPosition(): { x: number; y: number } {
let bestX = 0;
let bestY = Number.MAX_VALUE;
for (let x = 0; x <= this.width - this.stackWidth; x += this.stackWidth + this.gap) {
const currentY = this.getMaxHeightInColumn(x, this.stackWidth);
if (currentY < bestY || (currentY === bestY && x < bestX)) {
bestX = x;
bestY = currentY;
}
}
return { x: bestX, y: bestY };
}
private getMaxHeightInColumn(startX: number, width: number): number {
return this.boxes
.filter(box => box.x >= startX && box.x < startX + width)
.reduce((maxHeight, box) => Math.max(maxHeight, box.y + box.height + this.gap), 0);
}
}
这样我们就实现了根据高度获取定位的功能了
来实现一波
核心的代码就是获取每个盒子的定位、宽高信息
// 实例
const boxPacker = useMemo(() => {
return new BoxPacker(width, stackWidth, gap)
}, []);
const getCurrentPosition = (currentData: DataItem[], reset?: boolean) => {
// 获取标题文本行数列表
const textLines = calculateTextLines(currentData.map(item => item.title),card.fontSize,card.lineHeight, cardContentWidth)
// 获取图片高度列表
const imageHeight = currentData.map(item => (item.imageHeight / item.imageWidth * cardContentWidth))
// 获取小盒子高度列表
const cardHeights = imageHeight.map((h, index) => (
h + textLines[index] * card.lineHeight + card.padding * 2 + (card?.otherHeight || 0)
)
);
// 获取盒子定位信息
const boxes = boxPacker.addBoxes(
cardHeights,
reset
)
// 返回盒子列表信息
return boxes.map((box, index) => ({
...box,
title: currentData[index]?.title,
url: currentData[index]?.url,
imageHeight: imageHeight[index],
}))
}
set获取到的盒子信息
const [boxPositions, setBoxPositions] = useState<(Box & Pick<DataItem, 'url' | 'title' | 'imageHeight'>)[]>([]);
useEffect(() => {
// 首次和刷新
if (page === 1) {
setBoxPositions(getCurrentPosition(data, true))
} else {
// 加载更多
setBoxPositions(getCurrentPosition(data.slice((page - 1) * pageSize, page * pageSize)))
}
}, [])
效果如下

小程序获取文本高度
从上面的分析可以看出来只有文本高度实现是不同的,如果canvas方案实验成功,说不定还能做到大一统。
目前没成功大家就先看看我的目前方案:先实际渲染文字然后读取信息,然后获取实际高度
import React, {useEffect, useMemo, useState} from 'react'
import { View } from '@tarojs/components'
import Taro from "@tarojs/taro";
import './index.less'
import {BoxPacker} from "./flow";
const data = [
'vwyi这是一个标题,这是一个标题,这是一个标题,这是一个标题',
'这是一个标题',
'这是一个标题,这是一个标题,这是一个标题,这是一个标题',
'这是一个标题',
'这是一个标题,这是一个标题,这是一个标题,一个标题',
'这是一个标题,这是一个标题,这是一个标题,这题',
'这是一个标题,这是一个标题,这是一',
'这是一个标题,这是一个标题,这是一',
];
function Index() {
const boxPacker = useMemo(() => new BoxPacker(320, 100, 5), []);
const [boxPositions, setBoxPositions] = useState<any[]>([])
function getTextHeights() {
return new Promise((resolve, reject) => {
Taro.createSelectorQuery()
.selectAll('#textContainer .text-item')
.boundingClientRect()
.exec(res => {
if (res && res[0]) {
const heights = res[0].map(item => item.height);
resolve(heights);
} else {
reject('No buttons found');
}
});
});
}
useEffect(() => {
getTextHeights().then(h => {
setBoxPositions(boxPacker.addBoxes(h))
})
}, [])
return (
<View className="flow-container">
<View id="textContainer">
{
data.map((item, index) => (<View key={index} className="text-item">{item}</View>))
}
</View>
<View className="text-box-container">
{boxPositions.map((position, index) => (
<View
key={index}
className="text-box"
style={{
left: `${position.x}px`,
top: `${position.y}px`,
height: `${position.height}px`,
width: '100px', // 假设盒子的宽度固定为100px
}}
>
{`${data[index]}`}
</View>
))}
</View>
</View>
)
}
export default Index
来源:juejin.cn/post/7397278180644372521
Llama + Dify,在你的电脑搭建一套AI工作流
点赞 + 关注 + 收藏 = 学会了
本文简介
最近字节在推Coze,你可以在这个平台制作知识库、制作工作流,生成一个具有特定领域知识的智能体。
那么,有没有可能在本地也部署一套这个东西呢?这样敏感数据就不会泄露了,断网的时候也能使用AI。
刚好最近 Llama 3.1
发布了,本文就以 Llama 3.1
作为基础模型,配合 Dify
在本地搭建一套“Coze”。
跟着本文一步步操作,保证能行!
Dify是什么?
Dify 官网(difyai.com/) 的自我介绍:Dify
是开源的 LLM
应用开发平台。提供从 Agent
构建到 AI workflow
编排、RAG
检索、模型管理等能力,轻松构建和运营生成式 AI 原生应用。比 LangChain
更易用。
动手搭建
在本地搭建这个平台很简单,其实 Dify文档(docs.dify.ai/v/zh-hans) 里都写得明明白白了,而且还有中文文档。
具体来说需要做以下几步:
- 安装
Ollama
- 下载大模型
- 安装
Docker
- 克隆
Dify
源代码至本地 - 启动
Dify
- 配置模型
接下来一步步操作。
安装 Ollama
简单来说 Ollama
是运行大语言模型的环境,这是 Ollama
的官网地址 (ollama.com/ ),打开它,点击 Download 按钮下载 Ollama
客户端,然后傻瓜式安装即可(一直点“下一步”)。
安装完成后就能看到一个羊驼的图标,点击运行它即可。
下载大模型
安装完 Ollama
后,我们到 Ollama
官网的模型页面(ollama.com/library)挑选一下模型。
这里面有很多开源模型,比如阿里的千问2,搜索 qwen2
就能找到它。
本文使用 Llama 3.1
,这是前两天才发布的模型,纸面参数贼强。
打开 Llama 3.1
模型的地址(ollama.com/library/lla…),根据你需求选择合适的版本,我选的是 8b 版。
选好版本后,复制上图右侧红框的命令,到你电脑的终端中运行。
如果你还没下载过这个模型它就会自动下载,如果已经下载过它就会运行这个模型。
运行后,你就可以在终端和大模型对话了。
当然,我们不会这么原始的在终端和大模型对话,我们可是要搞工作流的!
安装 Docker
前面的基础步骤都搞掂了,接下来就要开始为运行 Dify
做准备了。
先安装一下 Docker
,打开 Docker
官网(http://www.docker.com/),根据你系统下载对应的安装包,然后还是傻瓜式安装即可。
克隆 Dify 源代码至本地
要使用 Dify
,首先要将它拉到你电脑里。
git clone https://github.com/langgenius/dify.git
在你电脑里找个位置(目录),用 git
将 Dify
克隆下来,用上面这条命令克隆就可以了。
启动 Dify
进入 Dify 源代码的 docker 目录,执行一键启动命令:
cd dify/docker
cp .env.example .env
docker compose up -d
启动完成后,你的 docker
里就会看到这个
此时你在浏览器输入 http://localhost
就能看到这个界面。
首次打开 Dify
需要你设置一下管理员的账号。
然后用管理员账号登录,可以看到下面这个页面。
点击“创建空白应用”就可以创建聊天助手、文本生成应用、Agent、工作流。
我们点击"工作流"就能看到类似Coze的工作流编辑界面了。
配置模型
在配置工作流之前,我们需要给 Dify
配置大语言模型。
点击页面右上角的管理员头像,然后选择“设置”。
选择“模型供应商”,然后点击“Ollama”的卡片添加模型。
在添加 Ollama
模型时,弹窗的左下角有一个“如何继承 Ollama”的按钮,点击它会跳转到 Dify
官方文档教你怎么配置,但这里可能会有个小坑。
前面我们已经使用 Ollama
把 Llama 3.1
运行起来了,在浏览器打开 `http://localhost:11434 看到这个界面证明模型运行成功。
此时在“添加 Ollama”将资料填写好,“基础 URL”里输入 http://localhost:11434
即可。
如果你是 Mac 电脑,填入以上资料有可能会报这个错:
An error occurred during credentials validation: HTTPConnectionPool(host='localhost', port=11434): Max retries exceeded with url: /api/chat (Caused by NewConnectionError('<urllib3.connection.HTTPConnection object at 0xffff5e310af0>: Failed to establish a new connection: [Errno 111] Connection refused'))
此时你需要在“基础 URL”里填入 http://host.docker.internal:11434
。
遇到问题可以看 Dify
官方文档的 FAQ。
添加完成后你就可以在模型列表里看到它了。
除了接入 Ollama
外,Dify
还支持接入 OpenAI
等闭源模型,但需要你去 OpenAI
那边买个服务。
以上就是本文的全部内容啦,如果本文对你有帮助的话也请你分享给你的朋友~
点赞 + 关注 + 收藏 = 学会了
来源:juejin.cn/post/7395902224091971594
好消息!uniapp也能开发鸿蒙了,但坏消息是……
相信不少前端从业者一听uniapp支持开发鸿蒙Next后非常振奋。猫林老师作为7年前端er也是非常激动,第一时间体验了下。在这里也给大家分享一下我的看法
uniapp开发鸿蒙优势
- 对于前端开发者而言,几乎无需增加额外的学习成本
- 一套代码,通用在Android、iOS、HarmonyOS,小公司狂喜(可以只招一位牛马完成所有工作)
- 能迅猛将现有项目移植到鸿蒙平台,迅速掌握鸿蒙用户流量以及争取政府补贴
- 以及更多猫林老师没想到的优点(抱歉,实在憋不出来了)
uniapp开发鸿蒙缺点
- 这真的是可以大吐特吐的地方了,uniapp目前支持鸿蒙的方案是web渲染方案,也就是说相当于利用鸿蒙内部的
webview
显示一个网页 - 那这有什么不好呢?
- 首先是渲染性能达不到原生、其次是逻辑代码是JS实现,而JS引擎慢,这就导致启动速度和运行速度弱于原生
- JS与原生UI层或者原生API通信可能会卡顿
- 其次是目前仅支持vue3,对于还在守着vue2的古早前端也不友好
- 以上结论来自uniapp官网说明,如下图
- 因此猫林老师不认为目前的uniapp适合鸿蒙开发,所以如果有志于抢占鸿蒙风口的同学,可以坚定信心了,还是得好好学习鸿蒙原生开发。
uniapp未来会好吗?
- 上述缺点其实DCloud官方(uniapp所属)也意识到了,所以一直在打造新一代的uniapp,也即uni-app x
- 这套新平台追求解决所有跨平台开发框架性能无法媲美原生的痛点,通过不同平台编译成不同语言来实现:在iOS平台编译为swift、在Android平台编译为kotlin、在Web和小程序平台编译为js、在鸿蒙next平台上编译为ArkTS。就相当于你用vue的语法写了原生的代码。
- 因此,未来的uniapp还是非常值得期待的!
- 但现阶段,虽然uni-app x也已经对外发布,但是对于鸿蒙的支持还在不断的完善。并且鸿蒙自身也在不断的升级迭代,所以现阶段的uni-app x暂时还是无法展现完整的鸿蒙开发之美。期望未来能越来越好,为鸿蒙生态提供强有效的生产力。
总结
uniapp支持鸿蒙是一个好消息,未来也值得期待。但是现阶段用来作为找鸿蒙开发岗位的工作还是不太合适。
来源:juejin.cn/post/7397323478851158050
不戴眼镜也可以看清啦!记录我的摘镜经历
大家好,我是 Gopal。好久不见,甚是想念!
本篇文章记录一下今年我做的一个比较大胆的决定——做近视手术。
首先声明一下,本篇文章不是广告,纯分享个人经历,看完这篇文章,至于要不要做视力矫正?怎么做?个人可以根据需要自行决定哈。
我为什么要摘镜
先说一下,我的情况。我是初中毕业后开始慢慢近视的,至今的话,也有十来年了,眼镜都配了好多副。有近视的同学应该都知道,近视确确实实带给我们很多的烦恼,我举几个例子:
- 看 3D 电影,我得带两副眼镜,一副是自己的,一副是 3D 眼镜。
- 游泳的时候,脱掉眼镜,啥也看不到。
- 打篮球或者一些激烈的运动,眼镜经常脱落。
- 每天都得擦拭眼镜,对于我这种有点强迫症来讲的人,会是每天的工作。
- 早上起来第一件事情,找眼镜。
- ...
基于以上的种种,摘镜意愿,我个人是比较强烈的。当我听说现在有手术可以进行视力矫正时候,是非常兴奋的。我之后做了相关的调查,我们来看一下视力矫正有哪些方案吧。
视力矫正有哪些方案
这里我会介绍得比较粗糙一些,毕竟我不是专业的,知识都是我从网络中总结得来。
近视原因:我们大部分人的近视都是轴性近视,是由于眼球的前后长度(轴长)延长造成的,可以看以下的图片。在正常眼中,平行光线入眼后在视网膜上形成焦点。而在近视眼中,焦点位置落在视网膜之前,近视眼想看清,就得调整屈光度,使其聚焦在视网膜上。
眼镜通过镜片调节屈光度,而近视手术的大致有两种方式进行:
- 第一种,通过角膜手术改变角膜的形态和屈光力,使物象成像到视网膜上。
- 第二种,更换晶体或向眼内植入补偿晶体,从而达到类似的效果,这种类似于带隐形眼镜。
其中有一个特点就是晶体是有可逆性,激光手术的话,是不可逆的。以上两种并没有说哪种是最优解,需要通过医院检查才能决定。一般来说,角膜屈光手术建议近视度数小于 1000 度,散光小于 600 度。如果因为度数过高、或角膜太薄不能做角膜屈光手术,可以考虑做晶体植入手术。
现在主流的手术方案有以下四种(前面三种都属于角膜手术),具体的这里不展开了,大家有想法可以自行搜索哈。
- Smart全激光
- 半飞秒
- 全飞秒Smile
- ICL晶体植入术
而我通过检查之后,选择了全飞秒Smile。主要是我度数还好,角膜厚度也足够。
手术前中后
首先约了一家上市的机构做检查,看得出还是比较慎重,各个方面评估能不能做,以及有哪些方案,最后会有专门的人给我讲解。
做完检查之后,我当时就约了隔周去做,因为手术前几天是需要滴眼药水,而且不能佩戴隐形眼镜等。
手术当天,做术前检查,交代注意事项/术后用药、签署同意书等。说实话,我还是比较紧张的,毕竟把眼睛这么重要的部位交给医生。
然后就是手术了,真正的手术时间也就几分钟,当躺在手术室中,一开始我以为医生还在给我做前置检查,没想到几分钟后医生告诉我已经结束了,可以回家了。手术过程滴麻药,基本不会痛的。整个激光过程,眼睛是会有开睑器撑开固定,手术过程眼睛是同时睁开/闭上的。手术过程中,需要在打激光的过程中配合,眼睛保持不动不眨,听好医生指令。
手术后,我和我对象就坐地铁回去了,回家的路上,我一直戴着墨镜,随着麻药的失效,会有一种较强的异物感。虽然我知道这是一个正常现象(医生提前已告知,手术后将会在4-6小时内会出现流泪、畏光、白雾感、异物感、酸胀感等刺激症状,其轻重因人而异),但是当时真的挺怕的,毕竟可不是小事。(所以最好手术当天最好还是有人一起去)。
当天下午,我就感觉好很多了。我做的事全飞秒,周六做的手术,周一的时候,我就可以正常上下班了,基本不会影响工作。唯一我感知到的一点点影响就是切换屏幕的时候,聚焦会需要一点点时间,很短,医生说是正常现象。不过中间需要注意用眼,然后注意按照医嘱滴眼药水等。这里我贴几个术后的注意事项,当然你应该根据你的主治医生来:
- 术后一周内清淡饮食,吃辛辣刺激食物。
- 术后两周内洗头洗脸时不宜将水溅入眼内,切忌揉眼。(手术室给的眼罩睡觉时带上)。
- 术后一个月内勿游泳,不要在眼部使用化妆品并避免异物进入眼内。
- 术后一个月在室外请戴太阳镜(全激光术后戴三个月),室内、晚上不需佩戴。
- 术后三个月内尽量避免剧烈运动,术后一周后可以适量健身运动,但须注意在活动中不要伤及手术眼。
- 术后视力恢复是逐步提高的过程,开始阶段看近会感到稍有模糊,雾感。此症状会逐渐消失,视力提高有快有慢,双眼视力恢复会有波动与近视度数及个人对治疗反应差异有关,六个月基本稳定。
在写本文的时候,已经有两个多月了,感觉日常生活没啥影响。在一个月复诊的时候,我的视力一边是 1.2,一边是 1.5。如果说有啥「副作用」的话,有两点。一个是晚上的视力(在光线比较差的地方)会稍微有点差,另外一个看一些光源(比如路灯),会有一点炫光。不过这些术前医生有给我说过,我是有做好心理准备的。
整体而言,目前为止,我对这次的近视手术还是很满意的。
手术价格以及副作用
这个我相信不少人比较关注,我做的全飞秒,全部下来大概 1.8w 左右(公司跟这家机构有合作,跟我说是打了折扣,我了解了一下,在深圳,这个价格其实差不多)。激光和半分秒会稍微便宜一些。晶体植入是最贵的(大概 3w 多)。具体的还需要根据不同的城市和机构看。据说虽然近视手术简单,但是它那个机器是需要给专利费用的,用一次给一次专利费用。整体上讲,这个还是可以接受的。另外,近视手术不能报医保。
比如价格,我觉得大家最关心的问题是副作用,或者说风险系数。任何手术都是有风险的,包括近视手术。网络上会有人说:
- 近视手术这么好,为什么医生也还带眼镜?
- 近视手术这么好,为什么马化腾还带眼镜?
- ...
首先,先不否认大家的疑虑,毕竟眼睛这么重要的东西,要在上面动手术,想想都怕。但是,我们也需要辩证的看待这些问题。
- 近视手术是需要满足一定条件的,不满足的话,医院是不敢给你做的。
- 近视手术是不会导致你瞎的,从原理上来说,近视手术是眼睛前面部分的手术,不涉及眼部深层组织,比如晶状体、视网膜等,更加不可能致盲。
- 近视手术存在发生并发症的可能性,可能性大小而已。(别人没有,并不代表自己没有)比如眼干等。虽然现在手术已经很成熟,但是在你决定要做之前,一定要先了解清楚,看你能不能承担这个风险。
- 近视手术只是当前帮你调整好你的视力,假如你不注意用眼的话,是存在再次近视的风险的。所以一般建议成年之后,度数稳定之后再做这个手术。
- ...
最后再强调一遍,近视手术是一种改善型的手术,不做其实本质上对于自身身体健康没有影响,如果摘镜意愿强烈的同学可以尝试去了解一下。
写在最后
目前为止,我感受到更多的是摘镜之后给我生活带来的便利,基本算是满足了我摘镜的预期!
我之前一直在想,假如哪天我落在一座荒岛上,估计是活不下去的。因为假如一旦我眼镜坏了,那么我就「看不清」这个世界了。
不过现在医学科技的进步解决了我的这个问题。
来源:juejin.cn/post/7293788137662038050
面试官:假如有几十个请求,如何去控制并发?
面试官:看你简历上做过图片或文件批量下载,那么假如我一次性下载几十个,如何去控制并发请求的?
让我想想,额~, 选中ID,循环请求?,八嘎!肯定不是那么沙雕的做法,这样做服务器直接崩溃啦!突然灵光一现,请求池!!!
我:利用Promise模拟任务队列,从而实现请求池效果。
面试官:大佬!
废话不多说,正文开始:
众所周知,浏览器发起的请求最大并发数量一般都是6~8
个,这是因为浏览器会限制同一域名下的并发请求数量,以避免对服务器造成过大的压力。
首先让我们来模拟大量请求的场景
const ids = new Array(100).fill('')
console.time()
for (let i = 0; i < ids.length; i++) {
console.log(i)
}
console.timeEnd()
一次性并发上百个请求,要是配置低一点,又或者带宽不够的服务器,直接宕机都有可能,所以我们前端这边是需要控制的并发数量去为服务器排忧解难。
什么是队列?
先进先出就是队列,push
一个的同时就会有一个被shift
。我们看下面的动图可能就会更加的理解:
我们接下来的操作就是要模拟上图的队列行为。
定义请求池主函数函数
export const handQueue = (
reqs // 请求数量
) => {}
接受一个参数reqs
,它是一个数组,包含需要发送的请求。函数的主要目的是对这些请求进行队列管理,确保并发请求的数量不会超过设定的上限。
定义dequeue函数
const dequeue = () => {
while (current < concurrency && queue.length) {
current++;
const requestPromiseFactory = queue.shift() // 出列
requestPromiseFactory()
.then(() => { // 成功的请求逻辑
})
.catch(error => { // 失败
console.log(error)
})
.finally(() => {
current--
dequeue()
});
}
}
这个函数用于从请求池中取出请求并发送。它在一个循环中运行,直到当前并发请求数current
达到最大并发数concurrency
或请求池queue
为空。对于每个出队的请求,它首先增加current
的值,然后调用请求函数requestPromiseFactory
来发送请求。当请求完成(无论成功还是失败)后,它会减少current
的值并再次调用dequeue
,以便处理下一个请求。
定义返回请求入队函数
return (requestPromiseFactory) => {
queue.push(requestPromiseFactory) // 入队
dequeue()
}
函数返回一个函数,这个函数接受一个参数requestPromiseFactory
,表示一个返回Promise的请求工厂函数。这个返回的函数将请求工厂函数加入请求池queue
,并调用dequeue
来尝试发送新的请求,当然也可以自定义axios,利用Promise.all
统一处理返回后的结果。
实验
const enqueue = requestQueue(6) // 设置最大并发数
for (let i = 0; i < reqs.length; i++) { // 请求
enqueue(() => axios.get('/api/test' + i))
}
我们可以看到如上图所示,请求数确实被控制了,只有有请求响应成功的同时才会有新的请求进来,极大的降低里服务器的压力,后端的同学都只能喊6。
整合代码
import axios from 'axios'
export const handQueue = (
reqs // 请求总数
) => {
reqs = reqs || []
const requestQueue = (concurrency) => {
concurrency = concurrency || 6 // 最大并发数
const queue = [] // 请求池
let current = 0
const dequeue = () => {
while (current < concurrency && queue.length) {
current++;
const requestPromiseFactory = queue.shift() // 出列
requestPromiseFactory()
.then(() => { // 成功的请求逻辑
})
.catch(error => { // 失败
console.log(error)
})
.finally(() => {
current--
dequeue()
});
}
}
return (requestPromiseFactory) => {
queue.push(requestPromiseFactory) // 入队
dequeue()
}
}
const enqueue = requestQueue(6)
for (let i = 0; i < reqs.length; i++) {
enqueue(() => axios.get('/api/test' + i))
}
}
实战文章
之前写过一篇关于web-worker
大文件切片的案例文章,就是利用了此特性感兴趣的小伙伴可以看看web-worker的基本用法并进行大文件切片上传(附带简易node后端)
来源:juejin.cn/post/7356534347509645375
原来我们是这样对工作失去兴趣的
一、前言
相信很多人有过接手别人的系统,也有将自己负责的系统交接给别人的经历。既有交接出去不用在费力治理维护技术债务的喜悦,也有接手对方系统面对一系列维护问题的愁容满面。
但是被人嫌弃的系统曾经也是「创建人」心中的白月光啊,是什么导致了「白月光」变成了「牛夫人」呢?是996,工期倒排、先上再优化,还是随时变动的需求?
让我们来复盘系统是怎么一步一步腐化的,让我们丢失了最初的兴趣,同时总结一些经验教训以及破局之策。
二、白月光到牛夫人的经历
一般当我们设计一个系统时,总是会抱着要把该项目打造为「干净整洁」的项目的想法,
但是随着时间的推移,最后总是不可避免的变成了这样:
2.1、从0到1
我们发现大多数人对于创建新项目总是会抱有极大的激情兴趣,会充分的考虑架构设计。但是对于接手的项目就会缺乏耐心。
这种心理在《人月神话》一书中被说为编程职业的乐趣:
“首先,这种快乐是一种创建事物的纯粹快乐。如同小孩在玩泥巴时 感到快乐一样,成年人喜欢创建事物,特别是自己进行设计 。我想这种 快乐是上帝创造世界的折射,一种呈现在每片独特的、崭新的树叶和雪 花上的喜悦。”
“第四,这种快乐是持续学习的快乐,它来自于这项工作的非重复特性。人们所面临的问题总有这样那样的不同,因而解决问题的人可以从 中学习新的事物,有时是实践上的,有时是理论上的,或者兼而有之。”
正是由于这样的心理,人们在面对新系统时,可以实践自身所学,主动思考如何避开曾经遇到的坑。满足了内心深处的对于创造渴望。
当一个项目是从0到1开始设计的,并且前期是由少数「高手」成员主导开发的话,一般不会有债务体现。当然明面上没有债务,不代表没有埋下债务的种子。
2.2、抢占市场、快速迭代
系统投入市场得到验证后,如果顺利,短期会收获大量用户。伴随着用户指数增长的同时,各种产品需求也会随着而来。一般在这个阶段将会是「工期倒排、先上再优化,需求随时变动」的高发期。
同时由于需求的爆发,为了提高团队的交付率,在这个阶段会引入大量的“新人”。随着带来的就是新老思想的碰撞,新的同学不一定认同之前的架构设计。
在这个阶段,如果团队存在主心骨,可以“游说”多方势力,平衡技术产品、新老开发之间的矛盾,那么在这个阶段引入的债务将会还好。但是如果团队缺乏这样的角色,就会导致公说公有理婆说婆有理,最后的结果就是架构会朝着多个方向发展,一个项目里充斥着多种不同思路的设计。有些甚至是矛盾的。如果还有【又不是不能用】的想法出现,那将是灭顶之灾。
但是在这个阶段对于参与者又是幸福的,一份【有市场、有用户、有技术、有价值】的项目,无论是对未来的晋升还是跳槽都是极大的谈资。
2.3、维护治理
褪去了前期“曾经沧海难为水,除却巫山不是云”的热恋后,剩下的就是生活的柴米油盐。系统的最终结局也是“维护治理”。
在这个阶段,需求的数量将大大减少,但是由于前期的“快速建设”,一个小小的需求,我们可能需要耗费数周的时间去迭代。而且系统越来越复杂,迭代越来越困难。
同时每天需要花费大量的时间处理客诉、定位bug,精力被完全分散。系统的设计和技术慢慢的变得僵化。并且由于用户量巨大,每次改动都要承担很大的线上风险。这样的情况对于程序员的精力、体力都是一场内耗,并且如果领导的重心也不在此项目时,更会加重这种内耗。于是该系统就会显得“人老色衰”,曾经的「白月光」也就变成了「牛夫人」。
三、牛夫人不好吗?
3.1、缺乏成就感
《人月神话》中关于程序员职业的苦恼曾说过以下几点:
- 对于系统编程人员而言,对其他人的依赖是一件非常痛苦的事情。他依靠其他人的程序,而这些程序往往设计得并不合理、实现拙劣、发布不完整(没有源代码或测试用例)或者文档记录得很糟。所以,系统编程人员不得不花费时间去研究和修改,而它们在理想情况下本应该是可靠的、完整的。
- 下一个苦恼---概念性设计是有趣的,但寻找琐碎的bug却是一项重复性的活动。伴随着创造性活动的,往往是枯燥沉闷的时间和艰苦的 劳动。程序编制工作也不例外。
- 最后一个苦恼 ,有时也是一种无奈—当投入了大量辛苦的劳动 ,***产品在即将完成或者终于完成的时候,却已显得陈旧过时。***可能是同事或竞争对手已在追逐新的、更好的构思; 也许替代方案不仅仅是在构思 ,而且己经在安排了。
随着业务趋于稳定,能够发挥创造性的地方越来越少,剩下的更多是沉闷、枯燥的维护工作。并且公司的资源会更聚焦在新业务、新方向,旧系统获得的关注更少,自然而然就缺乏成就感。也就形成了【只见新人笑,不见旧人哭】
3.2、旧系统复杂、难以维护
《A Philosophy of Software Design》一书中对复杂性进行了如下定义:“Complexity is anything related to the structure of a software system that makes it hard to understand and modify the system.”,即任何使得软件难于理解和修改的因素都是复杂性。
作者John 教授又分别从三个角度进行了解释复杂性的来源:
3.2.1、变更放大
复杂性的第一个征兆是,看似简单的变更需要在许多不同地方进行代码修改。对于复杂的系统来说,如果代码没有聚敛,那么一次小小的需求可能会导致多处修改。同时为了保证不出故障,需要对涉及的修改点、功能点进行完整的覆盖测试。这种隐藏在背后的工作量是巨大的。
3.2.2、认知负荷
复杂性的第二个症状是认知负荷,这是指开发人员需要多少知识才能完成一项任务。当一个系统经过多年的迭代开发,其复杂度将是指数级别的。并且会充斥这很多只有“当事人”才能理解的“离谱”功能。这就对维护者提出了极高的要求,需要掌握很多“冰山下”的知识才能做到手到擒来。而这对维护者的耐心、能力又是一次挑战。
3.2.3、未知的未知
未知的未知是指必须修改哪些代码才能完成任务,或者说开发人员必须获得哪些信息才能成功地执行任务。这一项也是John Ousterhout教授认为复杂性中最糟糕的一个表现形式。
这句话看起来比较抽象,如果映射到我们日常的工作中就是“我也不知道为啥这么改就好了”、“在我这是好的呀”、“刚刚还能运行啊,不知道为啥现在突然不行了”。
这种情况就是我们不知道改动的这行代码是否能让程序正常运转,也不知道这行代码的改动是否又会引发新的问题。这时候我们发现,那些“上帝类”真的就只有上帝能拯救了。
我曾经维护一个老系统,源码是通过文件相互传递的,没有仓库,里面的框架是自己撸的轮子,没有任何说明文档。服务部署是先在本地编译成二进制文件,然后在上传到服务器启动。每次改动上线都是就是一次生死劫,幸好没过多久,这个系统就被放弃了。
四、为何变成了牛夫人
4.1、伪敏捷
“敏捷”已经成为了国内公司的银弹了。
需求不做市场分析、不考虑用户体验、不做设计分析、不考虑前因后果,美其名曰“敏捷”。
工期倒排、先上再说、明天能不能上、这个问题上了再优化,美其名曰“敏捷”。
我曾经参与过一个项目,最开始是给了三个月的时间完成产品规划+开发,但是项目立项后领导层迟迟无法达成统一,一直持续进行了两个月的讨论后,终于确定了产品模型,进入开发。到这里留给开发的时间还剩一个月,咬咬牙也能搞定。但是开发真正进场,上报了需要开发的周期后,上面觉得太慢了,要求3周搞定,过了一天还是觉得太慢,要求2周,最后变成了要求4天搞定。遇到这种情况能怎么办,只能加人,只能怎么快怎么来。
之前阿里程序员也开玩笑式的说出了类似的场景:“2月13号上午省领导问逍遥子全省的健康码今天上线行不行,逍遥子说可以。等消息传达到产研团队的时候已经是中午了,然后团队在下午写了第一行代码。”
4.2、人的认知局限
《人月神话》一书中提到了一种组建团队的方式「外科手术团队」:“十个人,其中七个专业人士在解决问题,而系统是一个人或者最多两个人思考的产物,因此客观上达到了概念的一致性。”
也就是团队只需要一个或者两个掌舵人,负责规划团队的方向和系统架构,其余人配合他完成任务。目前我所待过的团队也基本是按照这个模式组成的,领导会负责重要事情的决策和团队分歧时的拍板,其余人则相互配合完成目标任务。但是这样的模式也导致了一个问题:掌舵人的认知上限就决定了团队的上限,而这种认知上限天然就会导致系统架构设计存在局限性,而这种局限性又会随着“伪敏捷”放大。
4.3、人员流动
经历过这种离职交接、活水交接的打工人应该深有体会,很多项目一旦步入这个阶段,大多数负责人就会开始放飞自我,怎么快怎么来,只想快点结束这段工作,快速奔赴下一段旅程。
从人性的角度是很难评价这种情况的,毕竟打工人和老板天然就不是一个战线的,甚至可能是对立面的。而我们大多数人都不可能是圣人,从自身角度从发这种行为是无可厚非的。
五、如何保持白月光
这里想首先抛个结论,系统变腐化是不可避免的。就类似人一样,随着时间的流逝,也会从以前一个连续熬夜打游戏看小说第二天依旧生龙活虎的青年变为一个在工位坐半小时都腰酸背痛,快走几步都喘的中年人。而这都来源于生活的压力、家庭的压力、工作的压力。同样的,面对业务的压力、抢占市场的压力、盈利的压力,系统也不可避免会变成“中年人”。
就像人一样会使用护肤品、健身等手段延缓自己的衰老一样,我们也可以使用一些手段延缓系统“衰老”。
在网上,已经有无数的文章教怎么避免代码腐化了,例如“DDD领域驱动设计”、“业务建模”、“重构”等等。
今天我想从别的角度聊聊怎么延缓代码腐化。
5.1、避免通用
软件领域有个特点,那就是复用。程序员们总是在思考怎么样写一段到处通用的代码,以不变应万变。特别是当国内提出中台战略后,这种情况就如脱缰的野马一般,不可阻挡。要是你做的业务、架构不带上xx中台,赋能xx,你都觉得你低人一等。
但是其实我们大部分人做的都是业务系统,本身就是面向某块特定市场的、特定用户的。这就天然决定了其局限性。
很多时候你会发现你用了100%的力气,设计了一个80%你认为有用的通用中台,最后只有20%产生了作用,剩下60%要么再也没有动过,要么就是被后来参与者喷的体无完肤。
当然这里也不说,设计时就按照当前产品提出的需求设计就行,一点扩展的余地都不留,而是在「通用」与「业务需求」之间取一个平衡。而这种平衡就取决于经验了。如果你没有这方面经验,那你就去找产品要「抄袭」的是哪个产品,看看他们有哪些功能,可以预留这些功能的设计点。
5.2、Clean Code
说实话,国内的业务系统80%都没有到需要谈论架构设计的地步。能够做到以下几点已经赢麻了:
- 良好的代码注释和相关文档存档【重中之重】
- 避免过长参数
- 避免过长方法和类
- 少量的设计模式
- 清晰的命名
- 有效的Code Review【不是那种帮我CR下,对方1秒后回复你一个done】
5.3、学会拒绝
自从国内开始掀起敏捷开发的浪潮后,在项目管理方面就出现了一个莫名其妙的指标:每次迭代的需求数都有会有一个数值,而且还不能比上一次迭代的少。
这种情况出现的原因是需求提出者无法确定这个需求可以带来多大的收益,这个收益是否满足老板的要求。那么他只能一股脑上一堆,这样即使最后效果不及预期,也可以怪罪于用户不买账。不是他不努力。
在这种时候,就需要开发学会识别哪些是真需求,哪些是伪需求,对于伪需求要学会说不。当然说不,不是让你上来就是开喷,而是你可以提出更加合理的理由,甚至你可以提出其他需求代替伪需求。这一般需要你对这块业务有非常深入的研究,同时对系统有上帝视角。
基本上在每个公司的迭代周期都是有时间要求的,比如我们就是两周一迭代,如果需求是你可控的,那么你就有更多的时间和心思在维护系统上,延缓他的衰老。
来源:juejin.cn/post/7312724606605918249