注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

SpringBoot接收参数的19种方式

1. Get 请求 1.1 以方法的形参接收参数 1.这种方式一般适用参数比较少的情况 @RestController @RequestMapping("/user") @Slf4j public class UserController { @Ge...
继续阅读 »

1. Get 请求


1.1 以方法的形参接收参数


1.这种方式一般适用参数比较少的情况


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {

@GetMapping("/detail")
public Result<User> getUserDetail(String name,String phone) {
log.info("name:{}",name);
log.info("phone:{}",phone);
return Result.success(null);
}
}


2.参数用 @RequestParam 标注,表示这个参数需要必传,否则会报错。


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {

@GetMapping("/detail")
public Result<User> getUserDetail(@RequestParam String name,@RequestParam String phone) {
log.info("name:{}",name);
log.info("phone:{}",phone);
return Result.success(null);
}
}


1.2 以实体类接收参数


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {

@GetMapping("/detail")
public Result<User> getUserDetail(User user) {
log.info("name:{}",user.getName());
log.info("phone:{}",user.getPhone());
return Result.success(null);
}
}




注:Get 请求以实体类接收参数时,不能用 RequestParam 注解进行标注,因为不支持这样的方式获取参数。




1.3 通过 HttpServletRequest 接收参数


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@GetMapping("/detail")
public Result<User> getUserDetail(HttpServletRequest request) {
String name = request.getParameter("name");
String phone = request.getParameter("phone");
log.info("name:{}",name);
log.info("phone:{}",phone);
return Result.success(null);
}
}



1.4 通过 @PathVariable 注解接收参数


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {

@GetMapping("/detail/{name}/{phone}")
public Result<User> getUserDetail(@PathVariable String name,@PathVariable String phone) {
log.info("name:{}",name);
log.info("phone:{}",phone);
return Result.success(null);
}
}



1.5 接收数组参数


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {

@GetMapping("/detail")
public Result<User> getUserDetail(String[] names) {
Arrays.asList(names).forEach(name->{
System.out.println(name);
});
return Result.success(null);
}
}



1.6 接收集合参数



springboot 接收集合参数,需要用 RequestParam 注解绑定参数,否则会报错!!



@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {

@GetMapping("/detail")
public Result<User> getUserDetail(@RequestParam List<String> names) {
names.forEach(name->{
System.out.println(name);
});
return Result.success(null);
}
}



2. Post 请求


2.1 以方法的形参接收参数


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {

@PostMapping("/save")
public Result<User> getUserDetail(String name,String phone) {
log.info("name:{}",name);
log.info("phone:{}",phone);
return Result.success(null);
}
}




注:和 Get 请求一样,如果方法形参用 RequestParam 注解标注,表示这个参数需要必传。



2.2 通过 param 提交参数,以实体类接收参数


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {

@PostMapping("/save")
public Result<User> getUserDetail(User user) {
log.info("name:{}",user.getName());
log.info("phone:{}",user.getPhone());
return Result.success(null);
}
}




注:Post 请求以实体类接收参数时,不能用 RequestParam 注解进行标注,因为不支持这样的方式获取参数。




2.3 通过 HttpServletRequest 接收参数


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {

@PostMapping("/save")
public Result<User> getUserDetail(HttpServletRequest httpServletRequest) {
log.info("name:{}",httpServletRequest.getParameter("name"));
log.info("phone:{}",httpServletRequest.getParameter("phone"));
return Result.success(null);
}
}



2.4 通过 @PathVariable 注解进行接收


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {

@PostMapping("/save/{name}")
public Result<User> getUserDetail(@PathVariable String name) {
log.info("name:{}",name);
return Result.success(null);
}
}



2.5 请求体以 form-data 提交参数,以实体类接收参数


form-data 是表单提交的一种方式,比如常见的登录请求。


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@PostMapping("/save")
public Result<User> getUserDetail(User user) {
log.info("name:{}",user.getName());
log.info("phone:{}",user.getPhone());
return Result.success(null);
}
}



2.6 请求体以 x-www-form-urlencoded 提交参数,以实体类接收参数


x-www-form-urlencoded 也是表单提交的一种方式,只不过提交的参数被进行了编码,并且转换成了键值对。


例如你用form-data 提交的参数:


name: 知否君
age: 22

用 x-www-form-urlencoded 提交的参数:


name=%E5%BC%A0%E4%B8%89&age=22

@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@PostMapping("/save")
public Result<User> getUserDetail(User user) {
log.info("name:{}",user.getName());
log.info("phone:{}",user.getPhone());
return Result.success(null);
}
}



2.7 通过 @RequestBody 注解接收参数



注:RequestBody 注解主要用来接收前端传过来的 body 中 json 格式的参数。



2.7.1 接收实体类参数


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@PostMapping("/save")
public Result<User> getUserDetail(@RequestBody User user) {
log.info("name:{}",user.getName());
log.info("phone:{}",user.getPhone());
return Result.success(null);
}
}



2.7.2 接收数组和集合


接收数组


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@PostMapping("/save")
public Result<User> getUserDetail(@RequestBody String[] names) {
Arrays.asList(names).forEach(name->{
System.out.println(name);
});
return Result.success(null);
}
}


接收集合


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@PostMapping("/save")
public Result<User> getUserDetail(@RequestBody List<String> names) {
names.forEach(name->{
System.out.println(name);
});
return Result.success(null);
}
}



2.8 通过 Map 接收参数


1.以 param 方式传参, RequestParam 注解接收参数


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@PostMapping("/save")
public Result<User> getUserDetail(@RequestParam Map<String,Object> map) {
System.out.println(map);
System.out.println(map.get("name"));
return Result.success(null);
}
}



2.以 body json 格式传参,RequestBody 注解接收参数


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@PostMapping("/save")
public Result<User> getUserDetail(@RequestBody Map<String,Object> map) {
System.out.println(map);
System.out.println(map.get("name"));
return Result.success(null);
}
}



2.9 RequestBody 接收一个参数


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@PostMapping("/save")
public Result<User> getUserDetail(@RequestBody String name) {
System.out.println(name);
return Result.success(null);
}
}



3. Delete 请求


3.1 以 param 方式传参,以方法形参接收参数


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@DeleteMapping("/delete")
public Result<User> getUserDetail(@RequestParam String name) {
System.out.println(name);
return Result.success(null);
}
}



3.2 以 body json 方式传参,以实体类接收参数



注:需要用 RequestBody 注解,否则接收的参数为 null



@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@DeleteMapping("/delete")
public Result<User> getUserDetail(@RequestBody User user) {
System.out.println(user);
return Result.success(null);
}
}



3.3 以 body json 方式传参,以 map 接收参数



注:需要用 RequestBody 注解,否则接收的参数为 null



@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@DeleteMapping("/delete")
public Result<User> getUserDetail(@RequestBody Map<String,Object> map) {
System.out.println(map);
return Result.success(null);
}
}



3.4 PathVariable 接收参数


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@DeleteMapping("/delete/{name}")
public Result<User> getUserDetail(@PathVariable String name) {
System.out.println(name);
return Result.success(null);
}
}



作者:知否技术
来源:juejin.cn/post/7343243744479625267
收起阅读 »

在上海做程序员这么多年,退休后我的工资是多少?

大家好,我是拭心。 最近看到一个很可惜的事:有个阿姨在深圳缴纳了 12 年社保,第 13 年家里突然有事不得不回老家,回去后没再缴纳社保,结果退休后无法领退休工资,还得出来打工赚钱。 之所以这样,是因为阿姨及其家人对退休的相关知识了解不多,痛失一笔收入。 吸取...
继续阅读 »

image.png


大家好,我是拭心。


最近看到一个很可惜的事:有个阿姨在深圳缴纳了 12 年社保,第 13 年家里突然有事不得不回老家,回去后没再缴纳社保,结果退休后无法领退休工资,还得出来打工赚钱。


之所以这样,是因为阿姨及其家人对退休的相关知识了解不多,痛失一笔收入。


吸取教训,作为在上海工作多年的打工人,为了老有所依,我花了些时间学习了养老金相基本知识,并且估算了一下退休后每月能拿到的钱,这篇文章来聊聊。


文章主要内容:



  1. 如何能在上海领退休工资

  2. 我退休后大概能领多少钱

  3. 退休工资的组成


如何能在上海领退休工资


image.png


上海作为全国 GDP 第一的城市,居民的收入也是很可观的。平均工资在 2023 年达到了 12183,平均退休工资在全国也位列前茅:


image.png


从上图可以看到,上海的平均退休工资居然有五千多!不用早起不用挤地铁,躺在家里每月就能领五千多,花不完,根本花不完啊!


那么问题来了,我们如何能领到上海的退休工资?


主要有 2 个条件:



  1. 达到退休年龄:男性满 60 周岁,女性满 50 周岁(灵活就业人员需要满 55 周岁)

  2. 退休前累计缴费社保 >= 180 个月(也就是 15 年),其中在上海至少缴满 120 个月(10 年)


第二点很关键:在上海需要缴满社保 10 年,上海 + 其他地方累计需要缴满 15 年。 比如小张在上海工作并缴纳社保满 10 年,然后去青岛缴纳 5 年,最后可以在上海领退休工资;但如果在青岛缴纳了 10 年,在上海缴纳了 5 年,就无法在上海领退休工资了。


需要强调的是,这里说的是「累计缴满」,即使中间有断开也没关系。


还有一个细节是,发工资不等于缴纳社保,个别不正规公司会漏缴社保,这需要我们打工人自己多关注。 身边有朋友遇到过:刚毕业加入的公司规模很小,人力资源不靠谱,干了半年多只缴纳了两个月社保。


怎么看公司有没有给自己缴社保呢?我们可以从随申办上查询:


image.png


OK,这就是在上海领退休工资的条件。


退休后大概能领多少钱


掐指一算,社保缴满 15 年的任务我已经完成了一半,但还有二十九年才能领钱,心里苦啊😭。


虽然拿不到,但我对能领多少钱还是非常好奇的,究竟比平均退休工资高还是被平均?🤔


经过一番搜索,我终于发现了退休工资的计算方法。


国家社会保险公共服务平台网站中,有一个「企业职工养老保险待遇测算」的功能:


image.png



国家社会保险公共服务平台 -> 养老保险 -> 企业职工养老保险待遇测算


链接:si.12333.gov.cn/157569.jhtm…



我们只需要输入年龄、预计退休年龄、当前缴纳年限、目前及之前平均工资、养老保险个人账户余额及未来大概工资即可测算退休工资。


如果不知道你的「养老保险个人账户余额」有多少,可以从随申办 app 搜索「养老金」查询余额:


image.png


在填完所有需要的信息后,我的预算结果是这样的:


image.png


我的天,个十百千万,居然有两万五?这还花的完??


几秒后冷静下来,我才发现算错了。。。


两万五应该是这样算的:按照当前收入,再连续缴纳 29 年。 😂


image.png


对于我们程序员,保持收入 29 年基本是不可能的,我还是重新调整参数再看看吧。


我现在社保缴纳了七年半,如果缴够 15 年社保,退休后我能领多少钱呢?


image.png


答案是一万四!看着还不错哈,每天能有 480 元左右,就是不知道 30 年后的物价怎么样了😂。


如果再悲观一点,社保缴纳到 35 岁(然后最低标准缴够 15 年),退休后大概能领多少呢?


image.png


答案是一万元!


看了下人民币的贬值率,30 年后的一万元不知道有没有今天三千块钱的购买力😷。


OK,这就是我退休后大概能领到的工资范围。


退休工资的组成


从上面的预算结果中我们可以看到,养老金由三部分组成:基础养老金、个人账户养老金和过渡性养老金,它们都是什么意思呢?


1.基础养老金 = 退休时平均工资 ×(1+平均缴费指数)÷ 2 × 累计缴费年限 × 1%。


退休时平均工资指的是退休时所在地区上年度的社会平均工资。也就是说经济发达地区的基础养老保险金,要高于欠发达地区。


平均缴费指数指的参保人选择的缴纳比例(一般在0.6-3之间)。每个月社保的缴费比例越高,相应的基础养老金越高。


例如,小张退休时,上年度的社会平均工资是 12000。虎虎的缴费指数平均值是 1,累计缴存了15年,他的基础养老金约为:12000*(1+1)/2 * 15 * 1% = 1800。


2.个人账户养老金 = 养老保险个人账户累计金额 ÷ 养老金计发月数。


我们缴纳社保时,一部分会进入个人社保账户,一部分会进入国家统筹账户。个人账户的部分,直接影响退休养老金的计算。


计发月数和我们退休的年龄有直接的关系,退休的越晚,计发月数越少;退休的时间越早,计发月数越多。一般来说,按照 60岁 退休,计发月数是 139 个月。



退休金的计发月数只是用来计算退休金,而不是说只能领这么久的退休金。



例如,小张社保的个人缴纳比例为 8%,社保的计算基数是 9339,他选择在 60 岁退休,那他的个人账户养老金约为:9339 * 8% * 12 * 15 / 139 = 967.49。


3.过渡性养老金 = 退休时平均工资 × 建立个人账户前的缴费年限 × 1.3% × 平均缴费指数


过渡性养老金,是指在养老保险制度发生变化(比如缴费标准提高、计算方法改变、退休年龄调整)的时候,给予受影响群体的资金补充。


这个奖金的计算规则说法不一,一种比较广泛的计算方法是:退休时所在地区的平均工资 x 缴费指数 x 缴费年限 x 过渡系数,其中过渡系数大概在 1% 到 1.4% 之间。


OK,这就是养老金三部分组成的含义。


总结


好了,这篇文章到这里就结束了,主要讲了:



  1. 如何能上海领退休工资:缴纳 10~15 年社保,到达退休年龄

  2. 我退休后大概能领多少钱:30 年后的一万左右

  3. 退休工资的三部分组成:基础养老金、个人账户养老金和过渡性养老金


通过写这篇文章,我对养老金的认识更多了一些,希望国家繁荣昌盛,让我退休的时候能多领点钱!




作者:张拭心
来源:juejin.cn/post/7327480122407141388
收起阅读 »

计算机还值得学吗?互联网还能来吗?

这几天,高考的话题热度不减,作为一名有着数百位粉丝的微V,我决定来蹭一波流量😎。 2004年,高三,最后一次模拟考试,班级第1,年级第10,能上中科大。 半个月后,高考,班级第30名,比一本线还少了20来分,史上最烂。 我想学电子信息类的,报了一些之前根本瞧不...
继续阅读 »

这几天,高考的话题热度不减,作为一名有着数百位粉丝的微V,我决定来蹭一波流量😎。


2004年,高三,最后一次模拟考试,班级第1,年级第10,能上中科大。


半个月后,高考,班级第30名,比一本线还少了20来分,史上最烂。


我想学电子信息类的,报了一些之前根本瞧不上的学校,全部被拒。不得已,去了医科大。


医科大录取通知书


医学,甚是无趣和枯燥;自学了1年的数学和计算机专业课后,跨考中科大的计算机系,一战上岸。


中科大录取通知书


2012年,毕业,北漂,辗转了3个知名互联网公司,直接下属20余人,负责的项目日流水数千万。


2022年,因为家庭和户口的原因,回老家上班,断崖式降薪。从头开始,下属归零,继续当大头兵。


四处降本增笑的今天,互联网公司还能去吗?还能报计算机专业吗?实话说,我不知道,也没有答案。


我只能说我从不后悔放弃医学,读研时选择了计算机专业,更不后悔进入了互联网行业。


理由如下:



  1. 我对医学实在是没兴趣,无论是教书还是当医生,肯定都是混日子,误人子弟或害人性命,天理不容。

  2. 读研,虽然没能研究出什么名堂,但是凭着兴趣做了一些 APP,顺利敲开了互联网大厂的的大门。

  3. 相对来说,互联网还是比较公平的,只要技术过的去,迟早会晋升涨薪;北京10年,我薪资翻了10余倍。

  4. 我父母都是农民,无法在经济上提供帮助。靠着相对不错的收入,我完成了结婚、生娃、买房、买车的大事。

  5. 开发,没有太多人际关系的破事,安心做好自己的事就行。喝酒?谁爱喝谁喝,反正我不喝,问就是吃头孢。


奖杯


肯定会有人喷我,站在了风口上,赶上了互联网的红利。今时不同往日,广进搞的飞起,保住饭碗就不错了。


另外,996太辛苦,有命赚没命花;ChatGPT 太牛逼,码农的饭碗迟早被砸;最难受的是,35岁就得滚蛋。


以上,大部分是事实,而且很操蛋。以下是我的个人观点,不喜勿喷:



  1. 对于没背景的小镇做题家来说,互联网依然是不错的选择,至少能让你前期的财务状况比较好

  2. 广进、35岁魔咒,我也不知道怎么办。说句废话,降低负债,降低欲望,趁年轻,尽量多赚点

  3. 互联网加班虽然多,但996是少数,我周末几乎没加过;ChatGPT,未来不好说,现在干不掉码农


甘蔗没有两头甜,如果能找到「钱多事少离家近」的金饭碗,谁特么愿意做社畜?


QQ公仔


总结,在没有更好的选择的前提下,计算机值得一学,互联网也可以来。当然,不能像我对医学那么抵触。


以上,不构成志愿和职业的选择建议,风险自负。


作者:野生的码农
来源:juejin.cn/post/7385054068525514788
收起阅读 »

是的,JDK 也有不为人知的“屎山”!

在前几天我写了一篇文章分享了为何避免使用 Collectors.toMap(),感兴趣的可以去瞧一眼:Stream很好,Map很酷,但答应我别用toMap()。 评论区也有小伙伴提到自己也踩过同样的坑,在那篇文章里介绍了 toMap() 有哪些的易踩的坑,今天...
继续阅读 »

在前几天我写了一篇文章分享了为何避免使用 Collectors.toMap(),感兴趣的可以去瞧一眼:Stream很好,Map很酷,但答应我别用toMap()


评论区也有小伙伴提到自己也踩过同样的坑,在那篇文章里介绍了 toMap() 有哪些的易踩的坑,今天就让我们好好的扒一扒 Map 的底裤,看看这背后不为人知的故事。


要讲 Map,可以说 HashMap 是日常开发使用频次最高的,我愿称其为古希腊掌管性能的神。


举个简单的例子,如何判断两个集合是否存在交集?最简单也最粗暴的方式,两层 for 遍历暴力检索,别跟我提什么时间空间复杂度,给我梭哈就完事。


public void demo() {  
List<Integer> duplicateList = new ArrayList<>();
List<Integer> list1 = List.of(1, 2, 3, 4);
List<Integer> list2 = List.of(3, 4, 5, 6);
for (Integer l1 : list1) {
for (Integer l2 : list2) {
if (Objects.equals(l1, l2)) {
duplicateList.add(l1);
}
}
}
System.out.println(duplicateList);
}

image.png


敲下回车提交代码之后,当还沉浸在等待领导夸你做事又稳又快的时候,却发现领导黑着脸向你一步步走来。


刚准备开始摸鱼的你吓得马上回滚了提交,在一番资料查询之后你发现了原来可以通过 Map 实现 O(n) 级的检索效率,你意气风发的敲下一段新的代码:


public void demo() {  
List<Integer> duplicateList = new ArrayList<>();
List<Integer> list1 = List.of(1, 2, 3, 4);
List<Integer> list2 = List.of(3, 4, 5, 6);

Map<Integer, Integer> map = new HashMap<>();
list2.forEach(it -> map.put(it, it));
for (Integer l : list1) {
if (Objects.nonNull(map.get(l))) {
duplicateList.add(l);
}
}
System.out.println(duplicateList);
}

重新提交代码起身上厕所,你昂首挺胸的特地从领导面前路过,领导回了你一个肯定的眼神。


image.png


让我们回到 HashMap 的身上,作为八股十级选手而言的你,什么数据结构红黑树可谓信手拈来,但我们今天不谈八股,只聊聊背后的一些设计理念。


众所周知,在 HashMap 中有且仅允许存在一个 keynull 的元素,当 key 已存在默认的策略是进行覆盖,比如下面的示例最终 map 的值即 {null=2}


Map<Integer, Integer> map = new HashMap<>();  
map.put(null, 1);
map.put(null, 2);
System.out.println(map);

同时 HashMap 对于 value 的值并没有额外限制,只要你愿意,你甚至可以放几百万 value 为空的元素像下面这个例子:


Map<Integer, Integer> map = new HashMap<>();  
map.put(1, null);
map.put(2, null);
map.put(3, null);
map.put(4, null);
map.put(5, null);
System.out.println(map);

这也就引出了今天的重点!


stream 中使用 Collectors.toMap() 时,如果你不注意还是按照惯性思维那么它就会让你感受一下什么叫做暴击。就像上一篇文章提到的其异常触发机制,但却并不知道为什么要这么设计?


作为网络冲浪小能手,我反手就是在 stackoverflow 发了提问,咱虽然笨但主打一个好学。


image.png


值得一提的是,评论区有个老哥回复的相当戳我,他的回复如下:


image.png


用我三脚猫的英语水平翻译一下,大概意思如下:



因为人家 toMap() 并没有说返回的是 HashMap,所以你凭什么想要人家遵循跟 HashMap 一样的规则呢?



我滴个乖乖,他讲的似乎好有道理的样子。


image.png


我一开始也差点信了,但其实你认真看 toMap() 的内部实现,你会发现其返回的不偏不倚正好就是 HashMap


image.png


如果你还不信,以上篇文章的代码为例,执行后获取其类型可以看到输出就是 HashMap


image.png


这时候我的 CPU 又烧了,这还是我认识的 HashMap,怎么开始跟 stream 混之后就开始六亲不认了,是谁说的代码永远不会变心的?


image.png


一切彷佛又回到了起点,为什么在新的 stream 中不遵循大家已经熟悉规范,而是要改变习惯对此做出限制?


stackoverflow 上另外的一个老哥给出的他的意见:


image.png


让我这个四级 751 分老手再给大家做个免费翻译官简化一下观点:



Collectors.toMap() 的文档中已经标注其并不保证返回 Map 的具体类型,以及是否可变、序列化性以及是否线程安全,而 JDK 拥有众多的版本,可能在你的环境已经平稳运行了数年,但换个环境之后在不同的 JDK 下可能程序就发生了崩溃。因此,这些额外的保障实际上还帮了你的忙。



回头去看 toMap() 方法上的文档说明,确实也像这位老哥提到的那样。


image.png


而在 HashMap 中允许 KeyValue 为空带来的一个问题在此时也浮现了出来,当存入一个 value 为空的元素时,再后续执行 get() 再次读取时,存在一个问题那就是二义性。


很显然执行 get() 返回的结果将为空,那这个空究竟是 Map 中不存在这个元素?还是我存入的元素其 value 为空?这一点我想只有老天爷知道,而这种二义性所带来的问题在设计层面显然是一个失误。


那么到这里,我们就可以得到一个暴论:HashMap 允许 key 和 value 为空就是 JDK 留下的“屎山”!


为了验证这一结论,我们可以看看在新的 ConcurrentHashMapJDK 是怎么做的?查看源码可以看到,在 put() 方法的一开始就执行了 keyvalue 的空值校验,也验证了上面的猜想。


image.png


这还原不够支撑我们的结论,让我们继续深挖这背后还有什么猫腻。


首先让我看看是谁写的 ConcurrentHashMap,在 openjdkGitHub 仓库类文档注释可以看到主要的开发者是 Doug Lea


image.png


Doug Lea 又是何方大佬,通过维基百科的可以看到其早期是 Java 并发社区的主席,他参与了一众的 JDK 并发设计工作,可谓吾辈偶像。


image.png


在网络搜罗相关的资讯找到对应的话题,虽然图中的链接已经不存在了,但还是能从引用的内容看出其核心的原因正是为了规避的结果的模糊性,与前文我们讨论的二义性不尽相同。


image.png


那为什么 JDK 不同步更新 HashMap 的设计理念,在新版 HashMap 中引入 keyvalue 的非空校验?


我想剩下的理由只有一个:HashMap 的使用范围实在太广,就算是 JDK 自己也很难在不变更原有结构的基础上进行改动,而在 JDK 1.2 便被提出并广泛应用,对于一个发展了数十年的语言而言,兼容性是十分重要的一大考量。


因此,我们可以看到,在后续推出的 Map 中,往往对 keyValue 都作了进一步的限制,而对于 HashMap 而言,可能 JDK 官方也是有心无力吧。


到这里基本也就盖棺定论了,但本着严谨的态度大胆假设小心求证,让我们再来看看大家伙的意见,万一不小心就被人网暴了。


image.png


stackoverflow 上另外几篇有关 Map 回答下可以看到,许多人都认为 HashMap 支持空值是一个存在缺陷的设计。


image.png


感兴趣的小伙伴可以去原帖查看,这里我就不再展开介绍了,原帖链接:Why does Map.of not allow null keys and values?


看到这里,下次别人或者老板再说你写的代码是屎山的时候,请昂首挺胸自信的告诉他 JDk 都会犯错,我写的这点又算得了什么?


image.png


作者:烽火戏诸诸诸侯
来源:juejin.cn/post/7384629198130610215
收起阅读 »

掘金滑块验证码安全升级,继续破解

web
去年发过一篇文章,《使用前端技术破解掘金滑块验证码》,我很佩服掘金官方的气度,不但允许我发布这篇文章,还同步发到了官方公众号。最近发现掘金的滑块验证码升级了,也许是我那篇文章起到了一些作用,逼迫官方加强了安全性,这是一个非常好的现象。不过,这并不是终点,我们还...
继续阅读 »

去年发过一篇文章,《使用前端技术破解掘金滑块验证码》,我很佩服掘金官方的气度,不但允许我发布这篇文章,还同步发到了官方公众号。最近发现掘金的滑块验证码升级了,也许是我那篇文章起到了一些作用,逼迫官方加强了安全性,这是一个非常好的现象。

不过,这并不是终点,我们还是可以继续破解。验证码的安全性是在用户体验和安全性之间的一个平衡,如果安全性太高,用户体验就会变差,如果用户体验太好,安全性就会变差。掘金的滑块验证码是一个很好的例子,它的安全性和用户体验之间的平衡做得非常好,并且我们破解的难度体验也非常好。 😄

本次升级的内容

掘金的滑块验证码升级了,主要有以下几个方面的改进:

  1. 首先验证码不再是掘金自己的验证码了,而是使用了字节的校验服务,可以看到弹窗是一个 iframe,并且域名是 bytedance.com

我们都知道掘金被字节收购了,可以猜测验证码的升级是字节跳动的团队做的。

  1. 验证码的图形不再是拼图,而是随机的不同形状,比如爱心、六角星、圆环、月亮、盾牌等。
  2. 增加了干扰缺口,主要是大小或旋转这种操作。

下面看一下改版后的滑块验证码:

我在文章的评论区看到了一些关于这次升级或相关的讨论:

本文将继续破解这次升级后的滑块验证码,看看这次升级对破解的难度有多大影响,如果你还没有了解过如何破解滑块验证码,请先看我之前的文章。

iframe

这次升级,整个滑块都掉用的是外部链接,使用 iframe 呈现,那么在 puppeteer 中如何处理呢?

await page.waitForSelector('iframe');
const elementHandle = await page.$('iframe');
const frame = await elementHandle.contentFrame();

实际上,我们只需要等待 iframe 加载完成,然后获取 iframe 的内容即可。

Frame 对象和 Page 对象有很多相似的方法,比如 frame.$frame.evaluate 等,我们可以直接使用这些方法来操作 iframe 中的元素。

验证码的识别

上一篇文章采用比较简单的判断方式,当时缺口处有明显的白边,所以只需要找到这个白边即可。

但是本次升级后,缺口不再是白边,而是阴影的效果,并且缺口的形状也不再是拼图,大概率都是曲线的边,所以再判断缺口的方式就不再适用了。

现在我们可以采用一种新的方式,通过对比滑块图片和缺口区域的像素值相似程度来判断缺口位置。

首先还是二值化处理,将图片转换为黑白两色:

可以看到左侧缺口和右侧缺口非常相似,只是做了一点旋转作为干扰。

再看一下,iframe 中还有一个很重要的东西,就是校验的图片:

 

它是一个 png 图片,所以我们可以把它也转换成二值化,简单的方式就是将透明色转换为白色,非透明色转换为黑色,如果想提高识别精度,可以与背景图一样,通过灰度、二值化的转换方式。

// 获取缺口图像
const captchaVerifyImage = document.querySelector(
'#captcha-verify_img_slide',
) as HTMLImageElement;
// 创建一个画布,将 image 转换成canvas
const captchaCanvas = document.createElement('canvas');
captchaCanvas.width = captchaVerifyImage.width;
captchaCanvas.height = captchaVerifyImage.height;
const captchaCtx = captchaCanvas.getContext('2d');
captchaCtx.drawImage(
captchaVerifyImage,
0,
0,
captchaVerifyImage.width,
captchaVerifyImage.height,
);
const captchaImageData = captchaCtx.getImageData(
0,
0,
captchaVerifyImage.width,
captchaVerifyImage.height,
);
// 将像素数据转换为二维数组,同样处理灰度、二值化,将像素点转换为0(黑色)或1(白色)
const captchaData: number[][] = [];
for (let h = 0; h < captchaVerifyImage.height; h++) {
captchaData.push([]);
for (let w = 0; w < captchaVerifyImage.width; w++) {
const index = (h * captchaVerifyImage.width + w) * 4;
const r = captchaImageData.data[index] * 0.2126;
const g = captchaImageData.data[index + 1] * 0.7152;
const b = captchaImageData.data[index + 2] * 0.0722;
if (r + g + b > 30) {
captchaData[h].push(0);
} else {
captchaData[h].push(1);
}
}
}

为了对比图形的相似度,二值化后的数据我们页采用二维数组的方式存储,这样可以方便的对比两个图形的相似度。

如果想观测二值化后的真是效果,可以把二位数组转换为颜色,并覆盖到原图上:

// 通过 captchaData 0 黑色  1 白色 的值,绘制到 canvas 上,查看效果
for (let h = 0; h < captchaVerifyImage.height; h++) {
for (let w = 0; w < captchaVerifyImage.width; w++) {
captchaCtx.fillStyle =
captchaData[h][w] == 1 ? 'rgba(0,0,0,0)' : 'black';
captchaCtx.fillRect(w, h, 1, 1);
}
}
captchaVerifyImage.src = captchaCanvas.toDataURL();

数据拿到后,我们可以开始对比两个图形的相似度,这里就采用非常简单的对比方式,从左向右,逐个像素点对比,横向每个图形的像素一致的点数量纪录下来,然后取最大值,这个最大值就是缺口的位置。

这里我们先优化一下要对比的数据,我们只需要对比缺口的顶部到底部这段的数据,截取这一段,可以减少对比的性能消耗。

// 获取captchaVerifyImage 相对于 .verify-image 的偏移量
const captchaVerifyImageBox = captchaVerifyImage.getBoundingClientRect();
const captchaVerifyImageTop = captchaVerifyImageBox.top;
// 获取缺口图像的位置
const imageBox = image.getBoundingClientRect();
const imageTop = imageBox.top;
// 计算缺口图像的位置,top 向上取整,bottom 向下取整
const top = Math.floor(captchaVerifyImageTop - imageTop);
// data 截取从 top 列到 top + image.height 列的数据
const sliceData = data.slice(top, top + image.height);

然后循环对比两个图形的像素点,计算相似度:

// 循环对比 captchaData 和 sliceData,从左到右,每次增加一列,返回校验相同的数量
const equalPoints = [];
// 从左到右,每次增加一列
for (let leftIndex = 0; leftIndex < sliceData[0].length; leftIndex++) {
let equalPoint = 0;
// 新数组 sliceData 截取 leftIndex - leftIndex + captchaVerifyImage.width 列的数据
const compareSliceData = sliceData.map((item) =>
item.slice(leftIndex, leftIndex + captchaVerifyImage.width),
);
// 循环判断 captchaData 和 compareSliceData 相同值的数量
for (let h = 0; h < captchaData.length; h++) {
for (let w = 0; w < captchaData[h].length; w++) {
if (captchaData[h][w] === compareSliceData[h][w]) {
equalPoint++;
}
}
}
equalPoints.push(equalPoint);
}
// 找到最大的相同数量,大概率为缺口位置
return equalPoints.indexOf(Math.max(...equalPoints));

对比时像素较多,不容易直接看到效果,这里写一个简单的二位数组对比,方便各位理解:

[
[0, 1, 0],
[1, 0, 1],
[0, 1, 0],
]
[
[0, 0, 0, 1, 0, 0],
[0, 0, 1, 0, 1, 0],
[0, 0, 0, 1, 0, 0],
]

循环对比,那么第3列开始,匹配的数量可以达到9,所以返回 3,这样就是滑块要移动的位置。

干扰缺口其实对我们这个识别方式没什么影响,最多可能会增加一些失败的概率,我个人测试了一下,识别成功率有 95% 左右。

总结

这次升级后,掘金的滑块验证码的安全性有了一定的提升,还是可以继续破解的,只是难度有所增加。最后再奉劝大家不要滥用这个技能,这只是为了学习和研究,不要用于非法用途。如果各位蹲局子,可不关我事啊。 🤔️


作者:codexu
来源:juejin.cn/post/7376276140595888137
收起阅读 »

队友升职,被迫解锁 Jenkins(所以,前端需要学习Jenkins吗?🤔)

web
入坑 Jenkins 作为一个前端,想必大家都会有这个想法:“Jenkins 会用就行了,有啥好学的”。 我一直都是这么想的,不就会点个开始构建就行了嘛! 可是碰巧我们之前负责 Jenkins 的前端同事升了职,碰巧这个项目组就剩了两个人,碰巧我比较闲,于是这...
继续阅读 »

入坑 Jenkins


作为一个前端,想必大家都会有这个想法:“Jenkins 会用就行了,有啥好学的”。


我一直都是这么想的,不就会点个开始构建就行了嘛!


可是碰巧我们之前负责 Jenkins 的前端同事升了职,碰巧这个项目组就剩了两个人,碰巧我比较闲,于是这个“活”就落在我的头上了。


yali.jpeg


压力一下就上来了,一点不懂 Jenkins 可咋整?


然而现实是没有一点儿压力。


刚开始的时候挺轻松,也就是要发版的流程到我这了,我直接在对应项目上点击开始构建,so easy!可是某一天,突然遇到一个 bug:我们每次 web 端项目发完后,桌面端的 hybrid 包需要我手动改 OSS 上配置文件的版本号,正巧那天忘记更新版本号了,导致桌面端应用本地的 hybrid 没有更新。。。


领导:你要不就别手动更新了,弄成自动化的

我:😨 啊!什么,我我我不会,是不可能的


小弟我之前没有接触过 Jenkins,看着那一堆配置着实有点费脑,于是就只能边百度学习边输出,从 Jenkins 安装开始到配置不同类型的构建流程,踩过不少坑,最后形成这篇文章。如果有能帮到大家的点,我就很开心了,毕竟我也是刚接触的!


说说我经历过的前端部署流程


按照我的经历,我把前端部署流程分为了以下几个阶段:即原始时代 -> 脚本化时代 -> CI/CD 时代。


jenkins-history.png


原始时代


最开始的公司运维是一个小老头,他只负责管理服务器资源,不管各种项目打包之类的。我们就只能自己打包,再手动把构建的文件丢到服务器上。


整体流程就是:本地合并代码 --> 本地打包 --> 上传服务器;


上传服务器可以分为这几个小步骤:打开 xshell --> 连接服务器 --> 进入 tomcat 目录 --> 通过 ftp 上传本地文件。


可能全套下来需要 5 分钟左右。


脚本化时代


为了简化,我写了一个 node 脚本,通过ssh2-sftp-client上传服务器这一步骤脚本化:


const chalk = require('chalk')
const path = require('path')
const fs = require('fs')
const Client = require('ssh2-sftp-client')
const sftp = new Client()
const envConfig = require('./env.config')

const defalutConfig = {
port: '22',
username: 'root',
password: '123',
localStatic: './dist.tar.gz',
}

const config = {
...defalutConfig,
host: envConfig.host,
remoteStatic: envConfig.remoteStatic,
}

const error = chalk.bold.red
const success = chalk.bold.green
function upload(config, options) {
if (!fs.existsSync('./dist') && !fs.existsSync(options.localStatic)) {
return
}
// 标志上传dist目录
let isDist = false
sftp
.connect(config)
.then(() => {
// 判断gz文件存在时 上传gz 不存在时上传dist
if (fs.existsSync(options.localStatic)) {
return sftp.put(options.localStatic, options.remoteStatic)
} else if (fs.existsSync('./dist')) {
isDist = true
return sftp.uploadDir('./dist', options.remoteStatic.slice(0, -12))
}
})
.then(() => {
sftp.end()
if (!isDist) {
const { Client } = require('ssh2')
const conn = new Client()
conn
.on('ready', () => {
// 远程解压
const remoteModule = options.remoteStatic.replace('dist.tar.gz', '')
conn.exec(
`cd ${remoteModule};tar xvf dist.tar.gz`,
(err, stream) => {
if (err) throw err
stream
.on('close', (code) => {
code === 0
conn.end()
// 解压完成 删除本地文件
fs.unlink(options.localStatic, (err) => {
if (err) throw err
})
})
.on('data', (data) => {})
}
)
})
.connect(config)
}
})
.catch((err) => {
sftp.end()
})
}

// 上传文件
upload(config, {
localStatic: path.resolve(__dirname, config.localStatic), // 本地文件夹路径
remoteStatic: config.remoteStatic, // 服务器文件夹路径器
})

upload-dist.png


最后只要通过执行yarn deploy即可实现打包并上传,用了一段时间,队友也都觉得挺好用的,毕竟少了很多手动操作,效率大大提升。


CI/CD 时代


不过用了没多久后,来了个新的运维小年轻,一上来就整了个 Jenkins ,取代了我们手动打包的过程,只要我们点击部署就可以了,当时就感觉 Jenkins 挺方便的,但又觉得和前端没多大关系,也就没学习。


不过也挺 Jenkins 的,为啥呢?



当时和测试说的最多的就是“我在我这试试.....我这没问题啊,你刷新一下”,趁这个时候,赶紧打包重新部署下。有了 Jenkins 后,打包都有记录了,测试一看就知道我在哄她了 🙄



Jenkins 解决了什么问题


我觉得在了解一个新事物前,应该先了解下它的出现解决了什么问题。


以我的亲身经历来看,Jenkins 的出现使得 拉取代码 -> 打包 -> 部署 -> 完成后工作(通知、归档、上传CDN等)这一繁琐的流程不需要人为再去干预,一键触发 🛫。


jenkins-vs-old.png


只需要点击开始构建即可,如何你觉得还得每次打开 jenkins 页面去点击构建,可以通过设置代码提交到 master 或合并代码时触发构建,这样就不用每次手动去点击构建了,省时更省力 🚴🏻‍♂️。


Jenkins 部署


Jenkins 中文帮助文档


Jenkins 提供了多种安装方式,我的服务器是 Centos,按照官方教程进行部署即可。


官方提供两种方式进行安装:


方式一:


sudo wget -O /etc/yum.repos.d/jenkins.repo https://pkg.jenkins.io/redhat-stable/jenkins.repo
sudo rpm --import https://pkg.jenkins.io/redhat-stable/jenkins.io.key

yum install jenkins

方式二:


直接下载 rpm 包进行安装,地址:mirrors.jenkins-ci.org/redhat/


wget https://pkg.jenkins.io/redhat/jenkins-2.449-1.1.noarch.rpm
rpm -ivh jenkins-2.449-1.1.noarch.rpm

安装过程


我是使用方式二进行安装的,来看下具体过程。


首先需要安装 jdk17 以上的版本



  1. 下载对应的 jdk


    wget https://download.oracle.com/java/17/latest/jdk-17_linux-x64_bin.tar.gz


  2. 解压并放到合适位置


    tar xf jdk-17_linux-x64_bin.tar.gz
    mv jdk-17.0.8/ /usr/lib/jvm


  3. 配置 Java 环境变量


    vim /etc/profile
    export JAVA_HOME=/usr/lib/jvm/jdk-17.0.8
    export CLASSPATH=$JAVA_HOME/lib:$JRE_HOME/lib:$CLASSPATH
    export PATH=$JAVA_HOME/bin:$JRE_HOME/bin:$PATH


  4. 验证


    java -version

    jenkins-java-version.png



接着安装 Jenkins,需要注意:Jenkins 一定要安装最新版本,因为插件要求最新版本,最新的 2.449。



  1. 下载 rpm 包


    cd /usr/local/jenkins
    wget https://mirrors.jenkins-ci.org/redhat/jenkins-2.449-1.1.noarch.rpm


  2. 安装 Jenkins


    rpm -ivh jenkins-2.449-1.1.noarch.rpm


  3. 启动 Jenkins


    systemctl start jenkins



jenkins-install-error.png


你以为就这么简单?肯定会报错的,通过百度报错信息,报错原因是:Java 环境不对,百度到的解决方法:


修改/etc/init.d/jenkins文件,添加 JDK,但是目录下并没有这个文件,继续百度得知:


使用 systemctl 启动 jenkins 时,不会使用 etc/init.d/jenkins 配置文件,而是使用 /usr/lib/systemd/system/jenkins.service文件


于是修改:


vim /usr/lib/systemd/system/jenkins.service

jenkins-service-java.png


搜索 Java,找到上面这一行,打开注释,修改为对应的 JDK 位置:


Environment="JAVA_HOME=/usr/lib/jvm/jdk-17.0.10"

重新启动 Jenkins:


systemctl restart jenkins

查看启动状态,出现如下则说明 Jenkins 启动完成:


jenkins-install-success.png
接着在浏览器通过 ip:8090 访问,出现如下页面,说明安装成功。


jenkins-install-success-ip.png


此时需要填写管理员密码,通过 cat /var/lib/jenkins/secrets/initialAdminPassword 即可获取。


Jenkins 配置


出现上述界面,填写密码成功后等待数秒,即可出现如下界面:


jenkins-install-plugins.png


选择 安装推荐的插件


jenkins-install-plugins-wait.png


这个过程稍微有点慢,可以整理整理文档,等待安装完成。


安装完成后,会出现此页面,需要创建一个管理员用户。


jenkins-install-ok.png


点击开始使用 Jenkins,即可进入 Jenkins 首页。


jenkins-home.png


至此,Jenkins 安装完成 🎉🎉🎉。


安装过程遇到的问题



  1. 没有经验第一次安装,参考网上文档推荐的是 JDK8,结果安装的 Jenkins 至少需要 JDK 11,导致安装失败;

  2. 第二次安装,按照网上的文档安装,不是最新版本,导致部分插件安装失败;


    release

    版本


  3. 配置修改问题



    • Jenkins 默认的配置文件位于 /usr/lib/systemd/system/jenkins.service

    • 默认目录安装在 /var/lib/jenkins/

    • 默认工作空间在 /var/lib/jenkins/workspace



  4. 修改端口号为 8090


    vim /usr/lib/systemd/system/jenkins.service

    修改 Environment="JENKINS_PORT=8090",修改完后执行:


    systemctl daemon-reload
    systemctl restart jenkins



如何卸载 Jenkins


安装过程遇到了不少坑,基本都是卸载了重新安装,于是就总结了以下卸载的命令。


# 查找是否存在 Jenkins 安装包
rpm -ql jenkins
# 卸载 Jenkins
rpm -e jenkins
# 再次查看 此时会提示:未安装软件包 jenkins
rpm -ql jenkins
# 删除所有 Jenkins 相关目录
find / -iname jenkins | xargs -n 1000 rm -rf

Jenkins 版本更新


Jenkins 发布版本很频繁,基本为一周一次,参考 Jenkins 更新


项目创建


点击 + 新建Item,输入名称,选择类型:


jenkins-create-project.png


有多种类型可供选择,这里我们主要讲这两种:Freestyle project 和 Pipeline。


Freestyle project


jenkins-create-freestyle.jpeg


选择这种类型后,就可以通过各种 web 表单(基础信息、源码、构建步骤等),配置完整的构建步骤,对于新手来说,易上手且容易理解,如果第一次接触,创建项目就选择 Freestyle project 即可。


总共有以下几个环节需要配置:



  • General

  • 源码管理

  • 构建触发器

  • 构建环境

  • Build Steps

  • 构建后操作


此时我们点击 OK,创建完如下所示都是空白的,也可以通过创建时的复制选项,复制之前项目的配置:


jenkins-create-configure.png


接着就如同填写表单信息,一步步完成构建工作。


General


项目基本信息也就是对所打包项目的描述信息:


jenkins-configure-general.png


比如描述这里,可以写项目名称、描述、输出环境等等。


Discard old builds 丢弃旧的构建

可以理解为清初构建历史,Jenkins 每打包一次就会产生一个构建历史记录,在构建历史中可以看到从第一次到最新的构建信息,这会导致磁盘空间消耗。


点击配置名称或勾选,会自动展开配置项。这里我们可以设置保持构建的最大个数5,则当前项目的构建历史记录只会保留最新的 5 个,并自动删除掉最老的构建。


jenkins-configure-discard.png


这个可以按照自己的需求来设置,比如保留 7 天的构建记录或保留最多 100 个构建记录。


Jenkins 的大多数配置都有 高级 选项,在高级选项中可以做更详细的配置。


This project is parameterized

可以理解为此构建后续过程可能用到的参数,可以是手动输入或选项等,如:git 分支、构建环境、特定的配置等等。通过这种方式,项目可以更加灵活和可配置,以适应不同的构建需求和环境。


默认有 8 种参数类型:



  1. Boolean Parameter: checkbox 选择,如果需要设置 true/false 值时,可以添加此参数类型

  2. Choice Parameter:选择,多个选项

  3. Credentials Parameter:账号证书等参数

  4. File Parameter:文件上传

  5. Multi-line String parameters:多行文本参数

  6. Password Parameter:密码参数

  7. Run Parameter:用于选择执行的 job

  8. String Parameter:单行文本参数


Git Parameter 需要在 系统管理 -> 插件管理 搜索 Git Parameter 插件进行安装,安装完成后重启才会有这个参数。


通过 添加参数 来设置后续会用到的参数,比如设置名称为 delopyTagGit Parameter 参数来指定要构建的分支,设置名称为 DEPLOYPATHChoice Parameter 参数来指定部署环境等等。


jenkins-configure-parameter.png


源码管理


Repositories

一般公司项目都是从 gitlab 上拉代码,首先设置 Repository URL,填写 git 仓库地址,比如:https://gitlab.com/xxx/xxx.git


填写完后会报错如下:


jenkins-configure-git-error.png


可以通过添加 Credentials 凭证解决,在 Jenkins 中,Git 的 Credentials 是用于访问 Git 仓库的认证信息,这些凭据可以是用户名和密码、SSH 密钥或其他认证机制,以确保 Jenkins 能够安全的与 Git 仓库进行交互,即构建过程中自动拉取代码、执行构建任务等


方式一:在当前页面填写帐号、密码

选择添加 -> Jenkins -> 填写 git 用户名、密码等信息生成一个新的 Credentials,然后重新选择我们刚刚添加的 Credentials,报错信息自动消失


jenkins-configure-git.png


这样添加会有一个问题,就是如果有多个项目时,每次都需要手动填写 Git 账户和密码信息。


方式二:Jenkins 全局凭证设置

Global Credentials 中设置全局的凭证。


jenkins-configure-git-credentials.png


然后在项目中配置时可以直接选择我们刚刚添加的 Credentials,报错信息自动消失。


Branches to build

这里构建的分支,可以设置为我们上面设置的 delopyTag 参数,即用户自己选择的分支进行构建。


构建触发器


特定情况下出发构建,如定时触发、代码提交或合并时触发、其他任务完成时触发等。


如果没有特殊的要求时,这一步完全可以不用设置,在需要构建时我们只需要手动点击开始构建即可。


构建环境


构建环境是在构建开始之前的准备工作,如清除上次构建、指定构建工具、设置 JDK 、Node 版本、生成版本号等。


Provide Node & npm bin/folder to PATH

默认是没有这一项的,但前端部署需要 Node 环境支持,所以需要在 系统管理 -> 插件管理 搜索 nodejs 插件进行安装,安装完成后重启才会展示这项配置。


但此时还是不能选择的,需要在 系统管理 -> 全局工具配置 中先安装 NodeJs,根据不同环境配置,可同时安装多个 NodeJs 版本


jenkins-configure-nodeJs.png


之后在 Provide Node 处才有可供选择的 Node 环境。


jenkins-configure-provide-node.png


Create a formatted version number

这个就是我用来解决了一开始问题的配置项,也就是把每次打包的结果上传到 OSS 服务器上时生成一个新的版本号,在 Electron 项目中通过对比版本号,自动更新对应的 hybrid 包,领导都爱上我了 😜。


首先需要安装插件 Version Number Plugin,在 系统管理 -> 插件管理 中搜索安装,然后重启 Jenkins 即可


jenkins-configure-version.png



  1. Environment Variable Name


    类似于第一步的构建参数,可以在其他地方使用。


  2. Version Number Format String


    用于设置版本号的格式,如1.x.x,Jenkins 提供了许多内置的环境变量:



    • BUILD_DAY:生成的日期

    • BUILD_WEEK:生成年份中的一周

    • BUILD_MONTH:生成的月份

    • BUILD_YEAR:生成的年份

    • BUILDS_TAY:在此日历日期完成的生成数

    • BUILDS_THIS_WEEK:此日历周内完成的生成数

    • BUILDS_THIS_MONTH:此日历月内完成的生成数

    • BUILDS_THIS_YEAR:此日历年中完成的生成数

    • BUILDS_ALL_TIME:自项目开始以来完成的生成数



  3. 勾选 Build Display Name Use the formatted version number for build display name 后


    此时每次构建后就会生成一个个版本号:


    jenkins-configure-version-result.png


  4. 把这个参数传递到后续的 OSS 上传的 Shell 脚本中即可。


如果想要重置版本号,只要设置Number of builds since the start of the project为 0 即可,此时就会从 1.7.0 重新开始。


Build Steps


这是最为重要的环节,主要用于定义整个构建过程的具体任务和操作,包括执行脚本、编译代码、打包应用等。


我们可以通过 Shell 脚本来完成前端项目常见的操作:安装依赖、打包、压缩、上传到 OSS 等。


点击 增加构建步骤 -> Execute shell,在上方输入 shell 脚本,常见的如下:


#环境变量
echo $PATH
#node版本号
node -v
#npm版本号
npm -v


#进入jenkins workspace的项目目录
echo ${WORKSPACE}
cd ${WORKSPACE}

#下载依赖包
yarn
#开始打包
yarn run build

#进入到打包目录
cd dist
#删除上次打包生成的压缩文件
rm -rf *.tar.gz

#上传oss,如果没有需要可删除此段代码
ossurl="xxx"
curl "xxx" > RELEASES.json
node deploy-oss.cjs -- accessKeyId=$OSS_KEY accessKeySecret=$OSS_SECRET zipDir=tmp.zip ossUrl=xxx/v${BUILD_VERSION}.zip
node deploy-oss.cjs -- accessKeyId=$OSS_KEY accessKeySecret=$OSS_SECRET zipDir=RELEASES.json ossUrl=xxx/RELEASES.json

#把生成的项目打包成压缩包方便传输到远程服务器
tar -zcvf `date +%Y%m%d%H%M%S`.tar.gz *
#回到上层工作目录
cd ../

构建后操作


通过上面的构建步骤,我们已经完成了项目的打包,此时我们需要执行一些后续操作,如部署应用、发送通知、触发其他 Job等操作。


Send build artifacts over SSH


通过 Send build artifacts over SSH,我们可以将构建好的产物(一般是压缩后的文件)通过 ssh 发送到指定的服务器上用于部署,比如 Jenkins 服务器是 10.10,需要将压缩文件发送到 10.11 服务器进行部署,需要以下步骤:



  1. 安装插件


    系统管理 -> 插件管理 中搜索插件 Publish over SSH 安装,用于处理文件上传工作;


  2. 配置服务器信息


    系统管理 -> System 中搜索 Publish over SSH 进行配置。


    jenkins-publish-over-SSH.png


    需要填写用户名、密码、服务器地址等信息,完成后点击 Test Configuration,如果配置正确,会显示 Success,否则会出现报错信息。


    这里有两种方式连接远程服务器,第一种是密码方式,输入服务器账户密码等信息即可;


    第二种是秘钥方式,在服务器生成密钥文件,并且将私钥全部拷贝,记住是全部,要携带起止标志-----BEGIN RSA PRIVATE KEY-----或-----END RSA PRIVATE KEY----,粘贴在 高级 -> key 即可。


    此处的 Remote Directory 是远程服务器接收 Jenkins 打包产物的目录,必须在对应的服务器手动创建目录,如 /home/jenkins


  3. 项目配置


    选择需要上传的服务器,接着设置需要传输的文件,执行脚本,移动文件到对应的目录。


    jenkins-configure-ssh.png



Transfer Set 参数配置


  • Source files:需要传输的文件,也就是通过上一步 Build Steps 后生成的压缩文件,这个路径是相对于“工作空间”的路径,即只需要输入 dist/*.tar.gz 即可

  • Remove prefix:删除传输文件指定的前缀,如 Source files 设置为dist/*.tar.gz ,此时设置 Remove prefix/dist,移除前缀,只传输 *.tar.gz 文件;如果不设置酒会传输 dist/*.tar.gz 包含了 dist 整个目录,并且会自动在上传后的服务器中创建 /dist 这个路径。如果只需要传输压缩包,则移除前缀即可

  • Remote directory:文件传输到远程服务器上的具体目录,会与 Publish over SSH 插件系统配置中的 Remote directory 进行拼接,如我们之前设置的目录是 /home/jenkins,此处在写入 qmp_pc_ddm,那么最终上传的路径为 /home/jenkins/qmp_pc_ddm,与之前不同的是,如果此路径不存在时会自动创建,这样设置后,Jenkins 服务器构建后的产物会通过 ssh 上传到此目录,供下一步使用。

  • Exec command


    文件传输完成后执行自定义 Shell 脚本,比如移动文件到指定目录、解压文件、启动服务等。


    #!/bin/bash

    #进入远程服务器的目录
    project_dir=/usr/local/nginx/qmp_pc_ddm/${DEPLOYPATH}
    cd $project_dir

    #移动压缩包
    sudo mv /home/jenkins/qmp_pc_ddm/*.tar.gz .

    #找到新的压缩包
    new_dist=`ls -ltr *.tar.gz | awk '{print $NF}' |tail -1`
    echo $new_dist

    #解压缩
    sudo tar -zxvf $new_dist

    #删除压缩包
    sudo rm *.tar.gz

    这一步可以使用之前定义的参数,如 ${DEPLOYPATH},以及 Jenkins 提供的变量:如 ${WORKSPACE} 来引用 Jenkins 的工作空间路径等。



Build other projects


添加 Build other projects,在项目构建成功后,触发相关联的应用开始打包。


jenkins-configure-other.png
另外还可以配置企业微信通知、生成构建报告等工作。


此时,所有的配置都设置完成,我们点击保存配置,返回到构建页。


构建


jenkins-start-build.png


点击 Build with parameters 选择对应的分支和部署环境,点击开始构建


在控制台输出中,可以看到打包的详细过程,


可以看到我们在Build Steps中执行的 Shell 脚本的输出如下:


jenkins-result-build.png


以及我们通过 Publish Over SSH 插件将构建产物传输的指定服务器的输出:


jenkins-result-ssh.png


最终需要部署的服务器就有了以下文件:


jenkins-remote-directory.png


Pipeline


对于简单的构建需求或新手用户来说,我们可以直接选择 FreeStyle project。而对于复杂的构建流程或需要更高灵活性和扩展性的场景来说,Pipeline 则更具优势。


通过 新建任务 -> 流水线 创建一个流水线项目。


jenkins-pipeline-white.png


开始配置前请先阅读下流水线章节。


生成方式


首先,Jenkins 流水线是一套插件,在最开始的插件推荐安装时会自动安装,如果选择自定义安装时,需要手动安装这一套插件。


Jenkins 流水线的定义有两种方式:Pipeline scriptPipeline script from SCM


jenkins-pipeline-type.png


Pipeline script


Pipeline script 是直接在 Jenkins 页面的配置中写脚本,可直接定义和执行,比较直观。


jenkins-pipeline-page.png


Pipeline script from SCM


Pipeline script from SCM 是将脚本文件和项目代码放在一起,即 Jenkinsfile,也可自定义名称。


jenkins-pipeline-code.png


当 Jenkins 执行构建任务时,会从 git 中拉取该仓库到本地,然后读取 Jenkinsfile 的内容执行相应步骤,通常认为在 Jenkinsfile 中定义并检查源代码控制是最佳实践


当选择 Pipeline script from SCM 后,需要设置 SCM 为 git,告诉 Jenkins 从指定的 Git 仓库中拉取包含 Pipeline 脚本的文件。


jenkins-pipeline-code-scm.png


如果没有对应的文件时,任务会失败并发出报错信息。


jenkins-pipeline-code-error.png


重要概念


了解完上面的基础配置,我们先找一段示例代码,粘贴在项目的配置中:


pipeline {
agent any
stages {
stage('Build') {
steps {
echo 'Build'
}
}
stage('Test') {
steps {
echo 'Test'
}
}
stage('Deploy') {
steps {
echo 'Deploy'
}
}
}
}

看下它的输出结果:


jenkins-pipeline-result.png


接着看一下上面语法中几个重要的概念。


流水线 pipline


定义了整个项目的构建过程, 包括:构建、测试和交付应用程序的阶段。


流水线顶层必须是一个 block,pipeline{},作为整个流水线的根节点,如下:


pipeline {
/* insert Declarative Pipeline here */
}

节点 agent


agent 用来指定在哪个代理节点上执行构建,即执行流水线,可以设置为 any,表示 Jenkins 可以在任何可用的代理节点上执行构建任务。


但一般在实际项目中,为了满足更复杂的构建需求,提高构建效率和资源利用率,以及确保构建环境的一致性,会根据项目的具体需求和资源情况,设置不同的代理节点来执行流水线。


如:


pipeline {
agent {
node {
label 'slave_2_34'
}
}
...
}

可以通过 系统管理 -> 节点列表 增加节点,可以看到默认有一个 master 节点,主要负责协调和管理整个 Jenkins 系统的运行,包括任务的调度、代理节点的管理、插件的安装和配置等。


jenkins-agent-master.png


阶段 stage


定义流水线的执行过程,如:Build、Test 和 Deploy,可以在可视化的查看目前的状态/进展。


注意:参数可以传入任何内容。不一定非得 BuildTest,也可以传入 打包测试,与红框内的几个阶段名对应。


jenkins-pipeline-console.png


步骤 steps


执行某阶段具体的步骤。


语法


了解上述概念后,我们仅仅只能看懂一个 Pipeline script 脚本,但距离真正的动手写还有点距离,此时就需要来了解下流水线语法


我将上面通过 Freestyle project 的脚本翻译成 Pipeline script 的语法:


pipeline {
agent any
triggers {
gitlab(triggerOnPush: true, triggerOnMergeRequest: true, branchFilterType: 'All')
}
parameters {
gitParameter branchFilter: 'origin/(.*)', defaultValue: 'master', name: 'delopyTag', type: 'PT_BRANCH'
}
stages {
stage('拉取代码') {
steps {
git branch: "${params.delopyTag}", credentialsId: 'xxx', url: 'https://xxx/fe/qmp_doc_hy.git'
}
}
stage('安装依赖') {
steps {
nodejs('node-v16.20.2') {
sh '''
#!/bin/bash
source /etc/profile
echo "下载安装包"
yarn config set registry https://registry.npmmirror.com
yarn
'''
}
sleep 5
}
}
stage('编译') {
steps {
sh '''
#!/bin/bash
source /etc/profile
yarn run build
sleep 5
if [ -d dist ];then
cd dist
rm -rf *.tar.gz

tar -zcvf `date +%Y%m%d%H%M%S`.tar.gz *
fi
'''
sleep 5
}
}
stage('解压') {
steps {
echo '解压'
sshPublisher(
publishers: [
sshPublisherDesc(
configName: 'server(101.201.181.27)',,
transfers: [
sshTransfer(
cleanRemote: false,
excludes: '',
execCommand: '''#!/bin/bash
#进入远程服务器的目录
project_dir=/usr/local/nginx/qmp_pc_ddm_${DEPLOYPATH}/${DEPLOYPATH}
if [ ${DEPLOYPATH} == "ddm" ]; then
project_dir=/usr/local/nginx/qmp_pc_ddm/dist
fi
cd $project_dir

sudo mv /home/jenkins/qmp_pc_ddm/*.tar.gz .

#找到新的压缩包
new_dist=`ls -ltr *.tar.gz | awk \'{print $NF}\' |tail -1`

#解压缩
sudo tar -zxvf $new_dist

#删除压缩包
sudo rm *.tar.gz

#发布完成
echo "环境发布完成"
''',
execTimeout: 120000,
flatten: false,
makeEmptyDirs: false,
noDefaultExcludes: false,
patternSeparator: '[, ]+',
remoteDirectory: 'qmp_pc_ddm',
remoteDirectorySDF: false,
removePrefix: 'dist/',
sourceFiles: 'dist/*.tar.gz'
)
],
usePromotionTimestamp: false,
useWorkspaceInPromotion: false,
verbose: false
)
]
)
}
}
}
post {
success {
echo 'success.'
deleteDir()
}
}
}

接下来,我们一起来解读下这个文件。


首先,所有的指令都是包裹在 pipeline{} 块中,


agent


enkins 可以在任何可用的代理节点上执行构建任务。


environment


用于定义环境变量,它们会保存为 Groovy 变量和 Shell 环境变量:定义流水线中的所有步骤可用的环境变量 temPath,在后续可通过 $tmpPath 来使用;


环境变量可以在全局定义,也可在 stage 里单独定义,全局定义的在整个生命周期里可以使用,在 stage 里定义的环境变量只能在当前步骤使用。


Jenkins 有一些内置变量也可以通过 env 获取(env 也可以读取用户自己定义的环境变量)。


steps {
echo "Running ${env.BUILD_ID} on ${env.JENKINS_URL}"
}

这些变量都是 String 类型,常见的内置变量有:



  • BUILD_NUMBER:Jenkins 构建序号;

  • BUILD_TAG:比如 jenkins-JOBNAME{JOB_NAME}-{BUILD_NUMBER};

  • BUILD_URL:Jenkins 某次构建的链接;

  • NODE_NAME:当前构建使用的机器


parameters


定义流水线中可以接收的参数,如上面脚本中的 gitParameter,只有安装了 Git Parameters 插件后才能使用,name 设置为delopyTag,在后续可通过 ${params.delopyTag} 来使用;


还有以下参数类型可供添加:


parameters {
booleanParam(name: 'isOSS', defaultValue: true, description: '是否上传OSS')
choice(name: 'select', choices: ['A', 'B', 'C'], description: '选择')
string(name: 'temp', defaultValue: '/temp', description: '默认路径')
text(name: 'showText', defaultValue: 'Hello\nWorld', description: '')
password(name: 'Password', defaultValue: '123', description: '')
}

triggers


定义了流水线被重新触发的自动化方法,上面的配置是:当 Git 仓库有新的 push 操作时触发构建


stages 阶段



  • 阶段一:拉取代码


    git:拉取代码,参数 branch 为分支名,我们使用上面定义的 ${params.delopyTag}credentialsId 以及 url,如果不知道怎么填,可以在 流水线语法 -> 片段生成器 中填写对应信息后,自动生成,如下:


    jenkins-stage-git.png


    再复制到此处即可。


  • 阶段二:安装依赖


    steps 中,sh 是 Jenkins pipeline 的语法,通过它来执行 shell 脚本。


    #!/bin/bash表示使用 bash 脚本;
    source /etc/profile 用于将指定文件中的环境变量和函数导入当前 shell。


    执行 yarn 安装依赖。


  • 阶段三:编译


    执行 yarn build 打包,


    if [ -d dist ]; 是 shell 脚本中的语法,用于测试 dist 目录是否存在,通过脚本将打包产物打成一个压缩包。


  • 阶段四:解压


    将上步骤生成的压缩包,通过 Publish over SSH 发送到指定服务器的指定位置,执行 Shell 命令解压。


    不会写 Publish over SSH 怎么办?同样,可以在 流水线语法 -> 片段生成器 中填写对应信息后,自动生成,如下:


    jenkins-generate-publish.png



post


当流水线的完成状态为 success,输出 success。


deleteDir() 函数用于删除当前工作目录中的所有文件和子目录。这通常用于清理工作区,确保在下一次构建之前工作区是干净的,以避免由于残留文件或目录引起的潜在问题。


构建看看效果


可以直接通过 Console Output 查看控制台输出,当然在流水线项目中自然要通过流水线去查看了。


jenkins-pipeline-result-in.png



  1. 效果一


    jenkins-pipeline-result1.png


    Pipeline Overview 中记录了每个步骤的执行情况、开始时间和耗时等信息,但是没有详细信息,详细信息就要在 Pipeline Console 中进行查看。


  2. 效果二


    安装插件 Blue Ocean,相当于同时结合了 Pipeline Overview 和 Pipeline Console,可以同时看到每个步骤的执行情况等基本信息,以及构建过程中的详细信息。


    jenkins-pipeline-result2.png


    通过 Blue Ocean 也可以直接创建流水线,选择代码仓库,然后填写对应的字段,即可快速创建流水线项目,如创建 gitlab 仓库:


    jenkins-blue-create.png


    或者直接连接 github 仓库,需要 token,直接点击红框去创建即可:


    jenkins-blue-create1.png



通过项目中的 Jenkinsfile 构建


再把对应的 Pipeline script 代码复制到对应代码仓库的 Jenkinsfile 文件,设置为 Pipeline script from SCM,填写 git 信息。


jenkins-pipeline-config-scm.png


正常情况下,Jenkins 会自动检测代码仓库的 Jenkinsfile 文件,如果选择的文件没有 Jenkinsfile 文件时就会报错,如下:


jenkins-pipeline-scm-error.png


正常按照流水线的执行流程,打开 Blue Ocean,查看构建结果,如下:


jenkins-pipeline-scm-result.png


片段生成器


如果你觉得上述代码手写麻烦,刚开始时又不会写,那么就可以使用片段代码生成器来帮助我们生成流水线语法。


进入任务构建页面,点击 流水线语法 进入:


配置构建过程遇到的问题



  1. Jenkins 工作空间权限问题


    jenkins-pipeline-error.png


    修复:


    chown -R jenkins:jenkins /var/lib/jenkins/workspace


  2. Git Parameters 不显示问题


    当配置完 Git Parameters 第一次点击构建时,会报如下错误,找了很久也没有找到解决方法,于是就先使用 master 分支构建了一次,构建完成之后再次点击构建这里就正常显示了,猜测是没构建前没有 git 仓库的信息,构建完一次后就有了构建信息,于是就正常显示了。


    jenkins-pipeline-error1.png



总结


本文对 Jenkins 的基本教程就到此为止了,主要讲了 Jenkins 的安装部署,FreeStyle project 和 Pipeline 的使用,以及插件安装、配置等。如果想要学,跟着我这个教程实操一遍,Jenkins 就基本掌握了,基本工作中遇到的问题都能解决,剩下的就只能在实际工作中慢慢摸索了。


再说回最初的话题,前端需不需要学习 Jenkins。我认为接触新的东西,然后学习并掌握,拓宽了技术面,虽然是一种压力,也是得到了成长的机会,在这个前端技术日新月异的时代,前端们不仅要熟练掌握前端技术,还需要具备一定的后端知识和自动化构建能力,才能不那么容易被大环境淘汰。


以上就是本文的全部内容,希望这篇文章对你有所帮助,欢迎点赞和收藏 🙏,如果发现有什么错误或者更好的解决方案及建议,欢迎随时联系。


作者:翔子丶
来源:juejin.cn/post/7349561234931515433
收起阅读 »

三十而立却未立,缺少的是女朋友还是技术能力?

作为一个从事 Web 工作 8 年来的相关人员的一点心路历程,希望我的经历能给大家带来稍许乐趣。 迷茫,特别迷茫 俗话说得好:“岂能尽如人意,但求无愧于心”,工作 8 年来,我经常这样自我安慰。不过这并不影响我也经常感觉无所适从,烦闷与迷茫。尤其是到了一些特殊...
继续阅读 »

作为一个从事 Web 工作 8 年来的相关人员的一点心路历程,希望我的经历能给大家带来稍许乐趣。


迷茫,特别迷茫


俗话说得好:“岂能尽如人意,但求无愧于心”,工作 8 年来,我经常这样自我安慰。不过这并不影响我也经常感觉无所适从,烦闷与迷茫。尤其是到了一些特殊的年月节点,这种焦虑感总是更加强烈。


那到底有什么迷茫的呢?一言以蔽之,有了对比,就有了伤害。正如标题所言,女朋友和技术能力,换一个通俗的话,也可以叫“美女与金钱”,当然更常规的说法,是“家庭与事业”。


如果简单横向对比起来,我迷茫确实看起来不意外:



  • 我好歹也是正儿八经 985 大学软件工程方向本科毕业,也算是科班出身;

  • 工作了 8 年,不仅是被同学、绝大部分同行从业人员从薪资水平、发展前景、人际交往、生活质量等各方向甩在身后,甚至都比不上复读一年考上不知名二本学校、去年才毕业的表弟;

  • 没房没车,没有成婚,还背井离乡,漂泊千里之外;

  • 日子看起来浑浑噩噩,没有什么远大志向,也没什么乐衷的兴趣……


怎么就变成这样了呢,我觉得我有老老实实、脚踏实地地做事情啊。回想自己从业这些年:



  • 从一开始的 JSP + Spring MVC + MySQL 这套原始的 Java Web 开发;

  • 到当时外面还比较时髦的 MEAN(MongoDB、Express.js、Angular 和 Node.js);

  • 后来回归到 Angular + Spring 这套,然后改为现在常用的 Vue + Spring,其中还一度以为 WebFlux 会有大用;

  • 当然前几年除了做些全栈开发,还不得不兼备 K8s 相关一大套的运维技能;

  • TiDB、Redis、ES、Prometheus 什么的都要搞一搞,Flink 什么的也得弄一弄,加上一大堆第三方自动化、监控等工具的使用配置;

  • 现在没事时用 Python 写个脚本处理一些批量任务,自己搞搞 Flutter 练手自己用的 APP。


我都觉得自己还是挺厉害的,因为这些就没一个是学校里教的东西,都是出来挨打自学的。


但实际上的现状呢,我还是呆在一个电子厂里面,拿着千把块,做着鸡毛蒜皮的事情,下班就回到公司的宿舍,龟缩起来。这样 855 毫无意义的日子,居然一呆就是 8 年了。


“可怜之人必有可恨之处?”


那我当然是自以为是的可怜了,毕竟如果真得像我说的那样出色,是金子自然会发光了,也怎么可能愿意继续呆在这种地方,离最近的地铁站、火车站都要30多分钟公交的制造业工厂里面?


确实,扯开嘴巴滋哇乱叫谁不会,有什么因就有什么果了。



  • 大四的时候,跨专业自学准备心理学方向的考研,错过了秋招;没考上之后,当时的技术能力,已经不支撑找个满意的工作了。

  • 做中学,两年后的 18 年正是行业发展高潮,准备出去看看。结果年轻,血气方刚,在领导的 PUA 和自以为是没能干出一点功绩就离开,不满意,然后留下来。

  • 又之后的一年之余,已经发现技术水平和人生阅历和同行差距过大,还是骑驴找马。在得到几个 offer 之后,却不知原因突然想回老家城市,这些深圳广州的机会就莫名其妙放弃了,重庆的眼高手低又没找到满意的。

  • 之后疫情时代,在一些大城市比如 SH、SZ 等出现强烈的排外现象之后,越发想要回家。但重庆的互联网行业,和主流城市差距可太大了。当时当地政府甚至在大力发展实体制造业,老家区县招商建工厂,租 100 亩送 100 亩。

  • 疫情尾期和这两年,什么“前端已死”、行业落寞,找工作难度陡升,试想,什么样的公司会找一个 8 年工作经验的初中级前端?全栈?运维?……


去年我找工作从 5 月份找到 10 月份,沟通了 200 多个岗位,只有 20 多个接收了简历,约到 3 个网上面试,最后一个没过。除了一些需要线下面试的没法去,也有面试的匹配度也不够、岁数不够年轻等其他因素。8 年来最多就管理过不到 10 人的小团队,当然不到一年就结束了,也没有能力发展管理岗。


与自己和解是不是自欺欺人?


会不会有种“咎由自取”的感觉,我偶尔也会想:



  • 如果 18 年我去了深圳而不是听信领导的话留在了东莞这里,我的发展轨迹会不会有所改善?

  • 更有甚,如果大学不是脑袋一热为了自救去考什么心理学专业的研究生,好好学习技能找工作或者考本校,会不会又是另一番风景?

  • 甚至更早,如果当年高考没有发挥失常,或者要是考得更差一点,去个师范,实现我儿时的理想,成为一名教师,情感上是不是更能自洽?


有句网络流行语是这样说的:有人看段子,有人照镜子。曾几何时,我也这样觉得:



  • 反正现在没车没房没女友,离家又远没外债;

  • 物质能力虽不高,但消费欲望不强;

  • 不能为国家做大贡献,但也还没有给社会添乱;

  • 下班回宿舍看看视频、打打游戏、玩玩手机,偶尔出去打打球,散散步……


没有复杂的人际关系,没有太大的家庭工作压力,清闲时间也比较充足,简简单单三餐一宿,我明明很惬意的,也明明已经惬意了 8 年来。


——“你一个月多少工资?” 、“怎么才这点?”

——“你现在什么级别?” 、“怎么才这个级别?”

——“你开什么车?” 、“什么?你连驾-照都没有?”

——“你孩子几岁了?” 、“啊,你还单身?”

——“天啦,你怎么混成这样了?”
……


“人的悲喜并不相通,我只觉得他们吵闹”。“墨镜一带,谁都不爱”,我脑袋摇成螺旋桨,我飞走咯,千里之外~


未立,缺少的是女朋友?


我的看法认为:可能不是。


没有什么是一成不变的,比如年龄。我这个年纪可能不仅和更年轻的同行抢岗位抢不过,也可能在另一个相亲市场也抢不过。


虽然嘴巴上可能有的人觉得单身好,而且现在这个男女关系和社会认同比较复杂的时代。前段时候和老同学聊天聊到近况,他们都一直以为我是一个不婚主义者。当然,这并不影响我们老一辈甚至再老一辈亲戚的期盼,他们偶尔也会认为,结婚之后,一个人才成长了,他们才会放心。


你别说,你还真别说。这半年我没有写博客,也没有太多了解“行业寒冬”的发展情况,有一部分原因还真是因为年初聊见了个相亲对象。这对我是一个完全没有经历过的赛道,难得的是我感觉还不差,虽然发展极为缓慢,但还没有遇到网上那样的“悲惨经历”,当然,也可能是异地的原因。


我要经历这种事,只能是亲戚朋友帮忙,加上微信之后聊了聊,整体氛围很好,就这么聊了一个多月。本来过年的时候约个见面的,但没想到升级了,直接他们父母到我家来坐了坐,然后又邀请我父母去她家吃了饭。这在农村的意思就是老一辈的过场已经走完了,双方家长没有意见,我们能不能成、就全看自己了。


这半年虽然几乎天天都有聊,绝大多数情况下都很愉快,我也变得有些期待每次的聊天;平时也有礼尚往来,偶尔互有一些小惊喜小礼物;五一节我也回去见了面,牵牵小手,后来得知当天她出门之后才发现来例假、身体不适但还是陪我走了将近三万步的路、甚至没让我发现异样……


但问题的关键在于,似乎都没有聊到什么重点和关键的问题,没有实际的发展,感觉温度没有理想上升。仔细想想,把这每天和她相关的一两个小时删除掉,那和我这些年的日子几乎没什么区别,好像一样是挺自在惬意的,她甚至都没有给我一些需要我去翻视频学点“人情世故”才能处理的问题和情景。


本来以为是好事,但我的榆木脑袋才终于不得不承认异地一定是个大问题。所以到现在,我这股子想回家的心情就变成了内因和外因相结合的无懈可击的推力。但是却还没有热切到一拍脑袋裸辞先回家,再看天的程度。


未立,缺少的是技术能力?


我的看法认为:可能也不是。


虽然我个人学的东西有一点点乱,但怎么说呢,并不影响我自娱自乐。偶尔开发一个自用的小玩意儿,还盲目觉得挺有成就感。


而且,从实际情况来讲,现在的“技术能力”真的不是那么的重要,如果是做产品,可能一些经验能力也不可或缺,但会写代码的人,可是一抓一大把。


比如说,现在的 AI 大模型几乎是热到爆的话题,也算是百花齐放,也各自杀红了眼,现在的新东西,不说自己有个 AI,都不好意思大声讲话,新出的 PC 都挂上 AI PC,魅族都不做手机,改名为 AI 终端了。


作为普通用户和普通个人开发者角度来讲,现在使用这些大模型 API 其实非常便宜了。价格战百万 token 才几十块甚至几块钱,文本对话、文生图、图生文,也都有一定的可用性了。


但是呢,但是呢,能拿来做什么呢?有创造性的同行都已经借着东风,扶摇直上九万里了,我还在感慨好便宜啊,除了BAT平台,这两天还去零一万物、深度求索等平台注册了账号,部分也少少充值了些。但是,虽然好便宜啊,可是能用来做点什么呢?我还真的没有创造性。




既然都说到这里,也厚脸皮顺便说一句,五月底主流厂商大模型在线服务大幅度降价时,还有一些主流厂商推出永久免费的版本。我就简单拿 BAT 的免费版本来试了一下,顺带加上之前的极简记账、随机菜品功能,使用Flutter开发,想做个了简单自用的生活工具助手类的 APP,放在 github 了: ai-light-life(智能轻生活) ,虽然很简陋也不完善,但感兴趣的朋友可以看看。


ai-light-life截图.jpg


当然也希望可以到 我 Github 仓库 看看一些其他可能有点意思的东西,比如运动健身相关、听歌休闲娱乐、Web 基础知识什么的。万一能帮到大家了,也不忘点 Star 支持下,谢谢。


生活不需要别人来定义


可能“三十而立”意思是指人在三十岁前后有所成就。少年老成的例子很多,大器晚成的人物也不少,但到最后,这都是别人来定义的这个“立”的含义。


就如见世面,有的人是“周游列国、追求自由”,有的人是“四体勤、五谷分”,有的人的成就是“成家立业,香车美女环绕”,有的人是“著作等身”,也有的人却是成为“艾尔登之王”……外面的人看到的或许不同,但那份自己内心的快乐,是为了、也是应该能够取悦自己的。


今天是我三十岁生日,大概500天前我列了三十岁前想要完成的 10 件小事,结果当然只完成了小部分:



  • 体重减到正常 BMI 值;

  • 开发一个能自用的 APP/入门一门外语;

  • LOL 上个白金/LOLM 上个宗师;

  • 谈一次恋爱;

  • 出去旅游一次;

  • 换一份工作,换一个城市;

  • 补上自己的网站博客,整理自己的硬盘;

  • 看 10 本名著,并写下每本不多于 5000 字的读后感;

  • 完成一部中篇小说;

  • 完成 50 篇用心写的博文,可包含那 10 篇读后感。


人生是一条连续的时间线,除了起止点,中间这段旅程,并不会因为某一刻的变化而停下来,最多是慢下来;三十岁之前没有完成的事情,三十岁之后依旧可以去做;以前看得太重的东西,以后还可以改变很多;珍惜的事情太多,抱怨的时间太少;人生这段路,就这么些年,就该为自己走走看;路虽然走得不同,但走路的心情,却可以自己来定。


取悦自己真的比迎合他人要轻松和快乐许多。


共勉吧诸君,感谢垂阅。


作者:小流苏生
来源:juejin.cn/post/7385474787698065417
收起阅读 »

为什么都放弃了LangChain?

或许从诞生那天起,LangChain 就注定是一个口碑两极分化的产品。 看好 LangChain 的人欣赏它丰富的工具和组建和易于集成等特点,不看好 LangChain 的人,认为它注定失败 —— 在这个技术变化如此之快的年代,用 LangChain 来构建一...
继续阅读 »

或许从诞生那天起,LangChain 就注定是一个口碑两极分化的产品。


看好 LangChain 的人欣赏它丰富的工具和组建和易于集成等特点,不看好 LangChain 的人,认为它注定失败 —— 在这个技术变化如此之快的年代,用 LangChain 来构建一切根本行不通。


夸张点的还有:


「在我的咨询工作中,我花了 70% 的精力来说服人们不要使用 langchain 或 llamaindex。这解决了他们 90% 的问题。」


最近,一篇 LangChain 吐槽文再次成为热议焦点:


图片


作者 Fabian Both 是 AI 测试工具 Octomind 的深度学习工程师。Octomind 团队会使用具有多个 LLM 的 AI Agent 来自动创建和修复 Playwright 中的端到端测试。


图片


这是一个持续一年多的故事,从选择 LangChain 开始,随后进入到了与 LangChain 顽强斗争的阶段。在 2024 年,他们终于决定告别 LangChain。


让我们看看他们经历了什么:


「LangChain 曾是最佳选择」


我们在生产中使用 LangChain 超过 12 个月,从 2023 年初开始使用,然后在 2024 年将其移除。


在 2023 年,LangChain 似乎是我们的最佳选择。它拥有一系列令人印象深刻的组件和工具,而且人气飙升。LangChain 承诺「让开发人员一个下午就能从一个想法变成可运行的代码」,但随着我们的需求变得越来越复杂,问题也开始浮出水面。


LangChain 变成了阻力的根源,而不是生产力的根源。


随着 LangChain 的不灵活性开始显现,我们开始深入研究 LangChain 的内部结构,以改进系统的底层行为。但是,由于 LangChain 故意将许多细节做得很抽象,我们无法轻松编写所需的底层代码。


众所周知,人工智能和 LLM 是瞬息万变的领域,每周都会有新的概念和想法出现。而 LangChain 这样围绕多种新兴技术创建的抽象概念,其框架设计很难经得起时间考验。


LangChain 为什么如此抽象


起初,当我们的简单需求与 LangChain 的使用假设相吻合时,LangChain 还能帮上忙。但它的高级抽象很快就让我们的代码变得更加难以理解,维护过程也令人沮丧。当团队用在理解和调试 LangChain 的时间和用在构建功能上的时间一样时,这可不是一个好兆头。


LangChain 的抽象方法所存在的问题,可以通过「将一个英语单词翻译成意大利语」这一微不足道的示例来说明。


下面是一个仅使用 OpenAI 软件包的 Python 示例:


图片


这是一段简单易懂的代码,只包含一个类和一个函数调用。其余部分都是标准的 Python 代码。


将其与 LangChain 的版本进行对比:


图片


代码大致相同,但相似之处仅此而已。


我们现在有三个类和四个函数调用。但令人担忧的是,LangChain 引入了三个新的抽象概念:



  • Prompt 模板: 为 LLM 提供 Prompt;

  • 输出解析器: 处理来自 LLM 的输出;

  • 链: LangChain 的「LCEL 语法」覆盖 Python 的 | 操作符。


LangChain 所做的只是增加了代码的复杂性,却没有带来任何明显的好处。


这种代码对于早期原型来说可能没什么问题。但对于生产使用,每个组件都必须得到合理的理解,这样在实际使用条件下才不至于意外崩溃。你必须遵守给定的数据结构,并围绕这些抽象设计应用程序。


让我们看看 Python 中的另一个抽象比较,这次是从 API 中获取 JSON。


使用内置的 http 包:


图片


使用 requests 包:


图片


高下显而易见。这就是好的抽象的感觉。


当然,这些都是微不足道的例子。但我想说的是,好的抽象可以简化代码,减少理解代码所需的认知负荷。


LangChain 试图通过隐藏细节,用更少的代码完成更多的工作,让你的生活变得更轻松。但是,如果这是以牺牲简单性和灵活性为代价的,那么抽象就失去了价值。


LangChain 还习惯于在其他抽象之上使用抽象,因此你往往不得不从嵌套抽象的角度来思考如何正确使用 API。这不可避免地会导致理解庞大的堆栈跟踪和调试你没有编写的内部框架代码,而不是实现新功能。


LangChain 对开发团队的影响


一般来说,应用程序大量使用 AI Agent 来执行不同类型的任务,如发现测试用例、生成 Playwright 测试和自动修复。


当我们想从单一 Sequential Agent 的架构转向更复杂的架构时,LangChain 成为了限制因素。例如,生成 Sub-Agent 并让它们与原始 Agent 互动。或者多个专业 Agent 相互交互。


在另一个例子中,我们需要根据业务逻辑和 LLM 的输出,动态改变 Agent 可以访问的工具的可用性。但是 LangChain 并没有提供从外部观察 Agent 状态的方法,这导致我们不得不缩小实现范围,以适应 LangChain Agent 的有限功能。



一旦我们删除了它,我们就不再需要将我们的需求转化为适合 LangChain 的解决方案。我们只需编写代码即可。



那么,如果不使用 LangChain,你应该使用什么框架呢?也许你根本不需要框架。


**我们真的需要构建人工智能应用程序的框架吗?

**


LangChain 在早期为我们提供了 LLM 功能,让我们可以专注于构建应用程序。但事后看来,如果没有框架,我们的长期发展会更好。


LangChain 一长串的组件给人的印象是,构建一个由 LLM 驱动的应用程序非常复杂。但大多数应用程序所需的核心组件通常如下:



  • 用于 LLM 通信的客户端

  • 用于函数调用的函数 / 工具 

  • 用于 RAG 的向量数据库

  • 用于跟踪、评估等的可观察性平台。


Agent 领域正在快速发展,带来了令人兴奋的可能性和有趣的用例,但我们建议 —— 在 Agent 的使用模式得到巩固之前,暂时保持简单。人工智能领域的许多开发工作都是由实验和原型设计驱动的。


以上是 Fabian Both 一年多来的切身体会,但 LangChain 并非全然没有可取之处。


另一位开发者 Tim Valishev 表示,他会再坚持使用 LangChain 一段时间:



我真的很喜欢 Langsmith:



  • 开箱即用的可视化日志 

  • Prompt playground,可以立即从日志中修复 Prompt,并查看它在相同输入下的表现 

  • 可直接从日志轻松构建测试数据集,并可选择一键运行 Prompt 中的简单测试集(或在代码中进行端到端测试) 

  • 测试分数历史 

  • Prompt 版本控制 



而且它对整个链的流式传输提供了很好的支持,手动实现这一点需要一些时间。


何况,只依靠 API 也是不行的,每家大模型厂商的 API 都不同,并不能「无缝切换」。


图片


图片


图片


你怎么看?


原文链接:http://www.octomind.dev/blog/why-we…


作者:机器之心
来源:juejin.cn/post/7383894854152437811
收起阅读 »

Nest:常用 15 个装饰器知多少?

web
nest 很多功能基于装饰器实现,我们有必要好好了解下有哪些装饰器:创建 nest 项目: nest new all-decorator -p npm @Module({}) 这是一个类装饰器,用于定义一个模块。模块是 Nest.js 中组织代码的单元,可以...
继续阅读 »

nest 很多功能基于装饰器实现,我们有必要好好了解下有哪些装饰器:
创建 nest 项目:


nest new all-decorator -p npm

@Module({})


这是一个类装饰器,用于定义一个模块。
模块是 Nest.js 中组织代码的单元,可以包含控制器、提供者等:
image.png


@Controller() 和 @Injectable()


这两个装饰器也是类装饰器,前者控制器负责处理传入的请求和返回响应,后者定义一个服务提供者,可以被注入到控制器或其他服务中。
通过 @Controller@Injectable 分别声明 controller 和 provider:
image.png


@Optional、@Inject


创建可选对象(无依赖注入),可以用 @Optional 声明一下,这样没有对应的 provider 也能正常创建这个对象。
image.png
注入依赖也可以用 @Inject 装饰器。


@Catch


filter 是处理抛出的未捕获异常,通过 @Catch 来指定处理的异常:
image.png


@UseXxx、@Query、@Param


使用 @UseFilters 应用 filter 到 handler 上:
image.png
image.png
除了 filter 之外,interceptor、guard、pipe 也是这样用:
image.png


@Body


如果是 post、put、patch** **请求,可以通过 @Body 取到 body 部分:
image.png
我们一般用 dto 定义的 class 来接收验证请求体里的参数。


@Put、@Delete、@Patch、@Options、@Head


@Put、@Delete、@Patch、@Options、@Head 装饰器分别接受 put、delete、patch、options、head 请求:
image.png


@SetMetadata


通过 @SetMetadata 指定 metadata,作用于 handler 或 class
image.png
然后在 guard 或者 interceptor 里取出来:
image.png


@Headers


可以通过 @Headers 装饰器取某个请求头或者全部请求头:
image.png


@Ip


通过 @Ip 拿到请求的 ip,通过 @Session 拿到 session 对象:
image.png


@HostParam


@HostParam 用于取域名部分的参数。
下面 host 需要满足 xxx.0.0.1 到这个 controller,host 里的参数就可以通过 @HostParam 取出来:
image.png


@Req、@Request、@Res、@Response


前面取的这些都是 request 里的属性,当然也可以直接注入 request 对象:
image.png
@Req 或者 @Request 装饰器,这俩是同一个东西。


使用 @Res 或 @Response 注入 response 对象,但是注入 response 对象之后,服务器会一直没有响应。
因为这时候 Nest 就不会把 handler 返回值作为响应内容了。我们可以自己返回响应:
image.png
Nest 这么设计是为了避免相互冲突。
如果你不会自己返回响应,可以设置 passthrough 为 true 告诉 Nest:
image.png


@Next


除了注入 @Res 不会返回响应外,注入 @Next 也不会。
当你有两个 handler 来处理同一个路由的时候,可以在第一个 handler 里注入 next,调用它来把请求转发到第二个 handler。
image.png


@HttpCode


handler 默认返回的是 200 的状态码,你可以通过 @HttpCode 修改它:
image.png


@Header


当然,你也可以修改 response header,通过 @Header 装饰器:
image.png


作者:云牧
来源:juejin.cn/post/7340554546253611023
收起阅读 »

零 rust 基础前端使直接上手 tauri 开发一个小工具

起因 有一天老爸找我,他们公司每年都要在线看视频学习,要花费很多时间,问我有没有办法可以自动学习。 在这之前,我还给我老婆写了个浏览器插件,解决了她的在线学习问题,她学习的是一个叫好医生的学习网站,我通过研究网站的接口和代码,帮她开发出了一键学习全部课程和自动...
继续阅读 »

起因


有一天老爸找我,他们公司每年都要在线看视频学习,要花费很多时间,问我有没有办法可以自动学习。


在这之前,我还给我老婆写了个浏览器插件,解决了她的在线学习问题,她学习的是一个叫好医生的学习网站,我通过研究网站的接口和代码,帮她开发出了一键学习全部课程和自动考试的插件,原本需要十来天的学习时间,分分钟就解决了。


有兴趣的可以看一下,好医生自动学习+考试插件源码


正因为这次的经历,我直接接下了这个需求,毕竟可以在家人面前利用自己的能力去帮他们解决问题,是一件非常骄傲的事。


事情并没有那么简单


我回家一看,他们的学习平台是个桌面端的软件(毕竟是银行的平台,做的比那个好医生严谨的多),内嵌的浏览器,无法打开控制台,更没办法装插件,甚至视频学习调了什么接口,有什么漏洞都无法发现,我感觉有点无能为力。


但是牛逼吹出去了,也得想办法做。


技术选型


既然没办法找系统漏洞去快速学习,那只能按部就班的去听课了,我第一想到的方式是用按键精灵写个脚本,去自动点击就可以了。但是我爸又想给他的同事用,再教他们用按键精灵还是有点上手成本的,所以我打算自己开发一个小工具去实现。


由于我是个前端开发者,做桌面端首先想到的是 Electron,因为我有一些开发经验,所以并不难,但打包后的体积太大,本来一个小工具,做这么大,这不是显得我技术太烂嘛。


所以我选择了 tauri 去开发。


需求分析


首先我想到的方式就是:



  1. 用鼠标框选一个区域,然后记录这个区域的颜色信息,记录区域坐标。

  2. 不断循环识别这个区域,匹配颜色。

  3. 如果匹配到颜色,则点击这个区域。


例如,本节课程学习后,会弹出提示框,进入下一节学习,那么可以识别这个按钮,如果屏幕出现这个按钮,则点击,从而实现自动学习的目的。


我还给它起了个很形象的名字,叫做打地鼠。


image.png


由于要点击的不一定只有一个下一节,可能还有其他章节的可能要学习,所以还实现了多任务执行,这样可以识别多个位置。


有兴趣可以看一下源码


零基础入门 rust


Tauri 已经提供了很多可以在前端调用的接口去实现很多桌面端的功能,但也不能完全能满足我本次开发的需求,所以还是要学习一点 rust 的语法。


这里简单说一下我学到的一些简单语法,方便大家快速入门。由于功能简单,我们并不需要了解 rust 那些高深的内容,了解基础语法即可,不然想学会 rust 我觉得真心很难。我们完全可以先入门,再深入。


适合人群


有一定其他编程语言(C/Java/Go/Python/JavaScript/Typescript/Dart等)基础。你至少得会写点代码是吧。


环境安装


推荐使用 rustup 安装 rust,rustup 是官方提供的的安装工具。


curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

安装后,检查版本,这类似安装 node 后查看版本去验证是否成功安装。


rustc --version

>>> rustc 1.73.0 (cc66ad468 2023-10-03)

cargo 是 rust 官方的包管理工具,类似 npm,这里也校验一下是否成功安装。


cargo --version


如果提示不存在指令,重新打开终端再尝试。



编辑器


官方推荐 Clion,是开发 rust 首选开发工具。


不过作为前端,我们依然希望可以使用 vscode 去开发,当然,这也是没有问题的。


vscode 需要搭配 rust-analyzer 一起使用。


除了上面提到的两个命令,还有 rustup 命令也可以直接使用了:


rustup component add rust-analyzer

执行后就都配置好了,可以进行语法的学习了。


变量与常量的声明


定义变量和常量的声明 javascript 和 rust 是一样的,都是通过 let 和 const,但是在定义变量时还是有一些区别的:



  • 默认情况下,变量是不可变的。(这点对于前端同学来说是不是很奇怪?)

  • 如果你想定义一个可变的变量,需要在变量名前面加上 mut


let x = 1;
x = 2; ❌

let mut x = 1;
x = 2; ✅

如果你不想用 mut,你也可以使用相同的名称声明新的变量:


let x = 1;
let x = x + 1;

Rust 里常量的命名规范是使用全大写字母,每个单词之间使用下划线分开,虽然 JS 没有强制的规范,但是我们也是这么做的。


数据类型


对于只了解 javascript 的同学,这个是非常重要的一环,因为 rust 需要在定义变量时做出类型的定义。即使是有过 typescript 开发经验的同学,这里也有着非常大的区别。这里只说一些与 js 区别较大的地方。


数字


首先 ts 对于数字的类型都是统一的 number,但是 rust 区别就比较大了,分为有符号整型,无符号整型,浮点型。


有 i8、i16、i32、i64、i128、u8、u16、u32、u64、isize、usize、f32、f64。


虽然上面看起来有这么多种类型去定义一个数字类型,实际上它们只是去定义了这个值所占用的空间,新手其实不用太过于纠结这里。如果你不知道应该选择哪种类型,直接使用默认的i32即可,速度也很快。有符号就是分正负(+,-),无符号只有正数。浮点型在现代计算机里上 f64 和 f32 运行速度差不多,f64 更加精确,所以不用太纠结。


数组


数组定义也有很大区别,你需要一开始就定义好数组的长度:


let a: [i32; 5] = [0; 5];

这表示定义一个包含 5 个元素的数组,所有元素都初始化为 0。一旦定义,数组的大小就不能改变了。


这是不是让前端同学很难理解,那么如何定义一个可变的数组呢?这好像更符合前端的思维。


在 Rust 中,Vec 是一个动态数组,也就是说,它可以在运行时增加或减少元素。


let v: Vec<i32> = Vec::new();
v.push(4);

这是不是更符合前端的直觉?毕竟后面我们要使用鼠标框选一个范围的颜色,这个颜色数组是不固定的,所以要用到 Vec


数据类型就说到这,其他的有兴趣自行了解即可。


引用包


rust 同 javascript 一样,也可以引入其他包,但语法上就不太一样了,例如:


use autopilot::{geometry::Point, screen, mouse};

强行翻译成 es module 引入:


import { Point, screen, mouse } from 'autopilot';

看到 :: 是不是有点懵逼,javascript 可没有这样的东西,你可以直觉的把它和 . 想象成一样就行。


:: 主要用于访问模块(module)或类型(type)的成员。例如,你可以使用 :: 来访问模块中的函数或常量,或者访问枚举的成员。


. 用于访问结构体(struct)、枚举(enum)或者 trait 对象的实例成员,包括字段(field)和方法(method)。


其他语法


循环:


for i in 0..colors.len() {}

条件判断:


if colors[i] != screen_colors[i] {

}

他们就是少了括号,还有一些高级的语法是 ES 没有的,这都很好理解。


那么我说这样就算入门了,不算过分吧?如果你要学一个语言,千万别因为它难而不敢上手,你直接上手去做,遇坑就填,你会进步很快。


如果你觉得这样很难写代码,那么我建议你买个 copilot 或者平替通义灵码,你上手写点小东西应该就不成问题了,毕竟我就这样就开始做了。


软件开发


Tauri 官网翻译还不全,读起来可能有点吃力,借助翻译工具将就着看吧,我有心帮大家翻译,但是提了 pr,好几天也没人审核。


你可以把 tauri 当作前端和后端不分离的项目,webview 就是前端,rust 写后端。


创建项目


tauri 提供了很多方式去帮你创建一个新的项目:


image.png


这里初始化一个 vite + vue + ts 的项目:


image.png


最后的目录结构可以看一下:


image.png


src 就是前端的目录。


src-tauri 就是后端的目录。


前端


前端是老本行,不想说太多的东西,大家都很熟悉,把页面写出来就可以了。


值得一提的就是 tauri 提供的一些接口,这些接口可以让我们实现一些浏览器上无法实现的功能。


与后端通讯


import { invoke } from "@tauri-apps/api";

invoke('event_name', payload)

通过 invoke 可以调用 rust 方法,并通过 payload 去传递参数。


窗口间传递信息


这里的窗口指的是软件的窗口,不是浏览器的标签页。由于我们要框选一块显示器上的区域,所以要创建一个新的窗口去实现,而选择后要将数据传递给主窗口。


import { listen } from '@tauri-apps/api/event';

listen<{ index: number}>("location", async (event) => {
const index = event.payload.index;
// ...
})

获取窗口实例


例如隐藏当前窗口的操作:


import { getCurrent } from '@tauri-apps/api/window';

const win = getCurrent()
win.hide() // 显示窗口即 win.show()

与之相似的还有:



  • appWindow 获取主窗口实例。

  • getAll 获取所有窗口实例,可以通过 label 来区分窗口。


最主要的是 WebviewWindow,可以通过他去创建一个新的窗口。


const screenshot = new WebviewWindow("screenshot", {
title: "screenshot",
decorations: false,
// 对应 views/screenshot.vue
url: `/#/screenshot?index=${props.index}`,
alwaysOnTop: true,
transparent: true,
hiddenTitle: true,
maximized: true,
visible: false,
resizable: false,
skipTaskbar: false,
})

这里我们创建了一个最大化、透明的窗口,且它位于屏幕最上方,页面指向就是 vue-router 的路由,index 是因为我们不确定要创建多少个窗口,用于区分。


可以通过创建这样的透明窗口,然后实现一个框选区域的功能,这对于前端来说,并不难。


例如鼠标点击左键,滑动鼠标,再松开左键,绘制这个矩形,再加一个按钮。


image.png


随后将位置信息传递给主窗口,并关闭这个透明窗口。


后端


首先,src-tauri/src/main.rs 是已经创建好的入口文件,里面已有一些内容,不用都了解。


暴露给前端的方法


tauri::Builder::default().invoke_handler(tauri::generate_handler![scan_once, ...])

通过 invoke_handler 可以暴露给前端 invoke 调用的方法。



! 在 rust 中是指宏调用,主要是方便,并不是 javascript 里的非的含义,这里注意下。



获取屏幕颜色


这里为了性能,我只获取了 x 起始位置到 x 结束位置,y 轴取中间一行的颜色。


use autopilot::{geometry::Point, screen};

pub fn scan_colors(start_x: f64, end_x: f64, y: f64) -> Vec<[u8; 3]> {
// 双重循环,根据 start_x, end_x, y 定义坐标数组
let mut points: Vec<Point> = Vec::new();
let mut x = start_x;
while x < end_x {
points.push(Point::new(x, y));
x += 1.0;
}
// 循环获取坐标数组的颜色
let mut colors: Vec<[u8; 3]> = Vec::new();
for point in points {
let pixel = screen::get_color(point).unwrap();
colors.push([pixel[0], pixel[1], pixel[2]]);
}
return colors;
}

这样就获取到一组颜色数组,包含了 RGB 信息。


这里安装了一个叫 autopilot 的包,可以通过 cargo add autopilot 安装,他可以获取屏幕的颜色,也可以操作鼠标。


鼠标操作


使用 autopilot::mouse 可以进行鼠标操作,移动至 x、y 坐标、病点击鼠标左键。


use autopilot::{geometry::Point, mouse};

mouse::move_to(Point::new(x, y));
mouse::click(mouse::Button::Left, );

配置权限


src-tauri/tauri.conf.json 中配置 allowlist,如果不想了解都有哪些权限,直接 all: true,全部配上,以后再慢慢了解。


"tauri": {
"macOSPrivateApi": true,
"allowlist": {
"all": true,
},
}

注意 mac 上如果使用透明窗口,还需要配置 macOSPrivateApi。


整体流程就是这样的,其他都是细节处理,有兴趣可以看下源码。


构建


我爸的电脑是 windows,而我的是 mac,所以需要构建一个 windows 安装包,但是 tauri 依赖本机库和开链,所以想跨平台编译是不可能的,最好的方法就是托管在 GitHub Actions 这种 CI/CD 平台去做。


在项目下创建 .github/workflows/release.yml,它将会在你发布 tag 时触发构建。


name: Release

on:
push:
tags:
- 'v*'
workflow_dispatch:

concurrency:
group: release-${{ github.ref }}
cancel-in-progress: true

jobs:
publish:
strategy:
fail-fast: false
matrix:
platform: [macos-latest, windows-latest]

runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with:
version: 8
run_install: true

- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'

- name: install Rust stable
uses: actions-rs/toolchain@v1
with:
toolchain: stable

- name: Build Vite + Tauri
run: pnpm build

- name: Create release
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tagName: v__VERSION__ # the action automatically replaces \_\_VERSION\_\_ with the app version
releaseName: 'v__VERSION__'
releaseBody: 'See the assets to download and install this version.'
releaseDraft: true
prerelease: false

这里提供一个实例,具体情况具体修改。


secrets.GITHUB_TOKEN 并不需要你配置,他是自动获取的,主要是获得权限去操作你的仓库。因为构建完成会自动创建 release,并上传安装包。


你还需要修改一下仓库的配置:


image.png


选中 Read and write permissions,勾选
Allow GitHub Actions to create and approve pull requests。


image.png


当你发布 tag 后,会触发 action 执行。


image.png


可见,打包速度真的很慢。


Actions 执行完毕后,进入 Releases 页面,可以看到安装包已经发布。


image.png


总结



  • 关于 taurielectron,甚至是 flutterqt 这种技术方向没必要讨论谁好谁坏,主要还是考虑项目的痛点,去选择适合自己的方式,没必要捧高踩低。

  • Rust 真的很难学,我上文草草几句入门,其实并没有那么简单,刚上手会踩很多坑,甚至无从下手不会写代码。我主要的目的是希望大家有想法就要着手去做,毕竟站在岸上学不会游泳。Flutter 使用 dart,我曾经写写过两个 app,相比于 rustdart 对于前端同学来说可以更轻松的学习。

  • Tauri 我目前还是比较看好,也很看好 rust,大家有时间的话还是值得学习一下,尤其是 2.0 版本还支持了移动端。

  • 看到很多同学,在学习一门语言或技术时,总是不知道做什么,不只是工作,其实我们身边有很多事情都可以去做,可能只是你想不到。我平时真的是喜欢利用代码去搞一些奇奇怪怪的事,例如我写过 vscode 摸鱼插件、自动学习视频的 chrome 插件、互赞平台、小电影爬虫等等,这些都是用 javascript 就实现的。你可以做的很多,给自己提一个需求,然后不要怕踩坑,踩坑的过程是你进步最快的过程,享受它。


作者:codexu
来源:juejin.cn/post/7320288231194755122
收起阅读 »

告别破解版烦恼!Navicat Premium Lite免费版它来了

作为一名后端开发者,在开发过程中使用可视化工具查看数据库中的数据是我们的基本操作。Navicat作为一款广受欢迎的数据库连接工具,深受我们喜爱和挑战。我们喜爱它强大的功能和直观的操作习惯,但又对它的收费模式感到不满。个人使用可以通过破解解决,然而在公司环境下,...
继续阅读 »

作为一名后端开发者,在开发过程中使用可视化工具查看数据库中的数据是我们的基本操作。Navicat作为一款广受欢迎的数据库连接工具,深受我们喜爱和挑战。我们喜爱它强大的功能和直观的操作习惯,但又对它的收费模式感到不满。个人使用可以通过破解解决,然而在公司环境下,由于侵权问题,我们通常被禁止使用,这令我们感到很不便。然而,最近Navicat推出了一款免费的产品——Navicat Premium Lite。


_20240628065825.jpg


Navicat Premium Lite


Navicat Premium Lite 是 Navicat 的精简版,拥有基本数据库操作所需的核心功能。它允许你从单个应用程序同时连接到各种数据库平台,包括 MySQL、Redis、PostgreSQL、SQL Server、Oracle、MariaDB、SQLite 和 MongoDB。Navicat Premium Lite 提供简化的数据库管理体验,使其成为用户的实用选择。


下载地址:https://www.navicat.com.cn/download/direct-download?product=navicat170_premium_lite_cs_x64.exe&location=1


文档地址: https://www.navicat.com.cn/products/navicat-premium-lite


安装及功能对比



  • 由于这个版本是免费版,不需要破解,所以安装我们此处就不多作介绍。

  • 功能对比


功能对比列表地址:https://www.navicat.com.cn/products/navicat-premium-feature-matrix


Navicat Premium Lite 基础功能都是有的,但是和企业版的相比,还是缺失了一些功能,具体大家可查看官网地址,我们此处列举部分


_20240628063823.jpg


_20240628063823.jpg


使用感受


整体使用了下,感觉和破解版使用的差别基本不大,缺失的功能几乎无影响。


_20240628064405.jpg


_20240628064405.jpg


总结


Navicat Premium Lite不仅仅是一款功能全面的数据库管理工具,更是因其免费且功能强大而备受青睐的原因。对于个人开发者、小型团队以及教育用途来说,Navicat Premium Lite提供了一个完全满足需求的解决方案,而无需支付高昂的许可费用。其稳定性、易用性和丰富的功能使得它在数据库管理领域中具备了极高的竞争力。


作者:修己xj
来源:juejin.cn/post/7384997446219743272
收起阅读 »

语言≠思维,大模型学不了推理:一篇Nature让AI社区炸锅了

方向完全搞错了? 大语言模型(LLM)为什么空间智能不足,GPT-4 为什么用语言以外的数据训练,就能变得更聪明?现在这些问题有 「标准答案」了。 近日,一篇麻省理工学院(MIT)等机构发表在顶级学术期刊《自然》杂志的文章观察到,人类大脑生成和解析语言的神经...
继续阅读 »

方向完全搞错了?



大语言模型(LLM)为什么空间智能不足,GPT-4 为什么用语言以外的数据训练,就能变得更聪明?现在这些问题有 「标准答案」了。


近日,一篇麻省理工学院(MIT)等机构发表在顶级学术期刊《自然》杂志的文章观察到,人类大脑生成和解析语言的神经网络并不负责形式化推理,而且提出推理并不需要语言作为媒介。


这篇论文声称「语言主要是用于交流的工具,而不是思考的工具,对于任何经过测试的思维形式都不是必需的」,引发了科技领域社区的大讨论。


图片


难道真的如语言学家乔姆斯基所言,追捧 ChatGPT 是浪费资源,大语言模型通向通用人工智能(AGI)的路线完全错了?


让我们看看这篇论文《Language is primarily a tool for communication rather than thought》是怎么说的。


图片


论文链接:http://www.nature.com/articles/s4…


语言是人类智能的一个决定性特征,但它所起的作用或多或少一直存在争议。该研究提供了神经科学等相关学科角度的最新证据,以论证现代人类的语言是一种交流工具,这与我们使用语言进行思考的流行观点相反。


作者首先介绍了支持人类语言能力的大脑网络。随后回顾语言和思维双重分离的证据,并讨论语言的几种特性,这些特性表明语言是为交流而优化的。该研究得出结论认为,尽管语言的出现无疑改变了人类文化,但语言似乎并不是复杂思维(包括符号思维)的先决条件。相反,语言是传播文化知识的有力工具,它可能与我们的思维和推理能力共同进化,并且只反映了人类认知的标志性复杂性,而不是产生这种复杂性。


图片


图 1


研究证据挑战了语言对于思维的重要性。如图 1 所示,使用 fMRI 等成像工具,我们可以识别完整、健康的大脑中的语言区域,然后检查在完成需要不同思维形式的任务时,语言区域的相关响应。


 人类大脑中的语言网络


从人脑的生物学结构来看,语言生成和语言理解由左半球一组相互连接的大脑区域支持,通常称为语言网络(图 1a;Box 2 描述了它与语言神经生物学经典模型的关系)。


图片


Box 2。许多教科书仍然使用 Wernicke 提出的语言神经基础模型,并由 Lichteim 和 Geschwind 进行了阐述和修订。该模型包括两个皮层区域:Broca 区位于下额叶皮层,Wernicke 区位于后上颞叶皮层。这两个区域分别支持语言产生和理解,并通过一条背侧纤维束(弓状束)连接。


语言网络有两个非常重要的特性:


首先,语言区域表现出输入和输出模态的独立性,这是表征抽象性的关键特征。主要表现为在理解过程中,这些大脑区域对跨模态(口头、书面或手语)的语言输入做出反应。同样,在语言生成过程中,无论我们是通过口语还是书面语来产生信息,这些区域都是活跃的。这些区域支持语言理解和生成(图 1a)这一事实表明,它们很可能存储了我们的语言知识,这对于编码和解码语言信息都是必需的。


其次,语言区还能对词义和句法结构进行表征和处理。特别是,关于脑磁图和颅内记录研究的证据表明,语言网络的所有区域都对词义以及词间句法和语义依赖性敏感(图 1a)。总之,语言网络中语言表征的抽象性以及网络对语言意义和结构的敏感性使其成为评估语言在思维和认知中的作用假设的明确目标((Box 3)。


我们对人类语言和认知能力,以及它们之间关系的理解仍然不完整,还有一些悬而未决的问题:



  • 语言表征的本质是什么?

  • 思维是否依赖于符号表征?

  • 儿童学习语言时,语言网络是如何成长的?


语言对于任何经过检验的思维形式都不是必需的


经典的方法是通过研究大脑损伤或疾病的个体来推断大脑与行为之间的关联和分离。这种方法依赖于观察大脑某部分受损时个体行为的变化,从而推测不同大脑区域的功能和行为之间的联系。


有证据表明 —— 有许多个体在语言能力上有严重的障碍,影响到词汇和句法能力,但他们仍然表现出在许多思考形式上的完整能力:他们可以解决数学问题,进行执行规划和遵循非言语指令,参与多种形式的推理,包括形式逻辑推理、关于世界的因果推理和科学推理(见图 1b)。  


研究表明,尽管失去了语言能力,一些患有严重失语症的人仍然能够进行所有测试形式的思考和推理,他们在各种认知任务中的完整表现就是明证。他们根本无法将这些想法映射到语言表达上,无论是在语言生成中(他们无法通过语言向他人传达自己的想法),还是在理解中(他们无法从他人的单词和句子中提取意义)(图 1b)。当然,在某些脑损伤病例中,语言能力和(某些)思维能力都可能受到影响,但考虑到语言系统与其他高级认知系统的接近性,这是可以预料的。


尤其是一些聋哑儿童,他们长大后很少或根本没有接触过语言,因为他们听不见说话,而他们的父母或看护人不懂手语。缺乏语言接触会对认知的许多方面产生有害影响,这是可以预料的,因为语言是了解世界的重要信息来源。尽管如此,语言剥夺的个体无疑表现出复杂的认知功能能力:他们仍然可以学习数学、进行关系推理、建立因果链,并获得丰富而复杂的世界知识。换句话说,缺乏语言表征并不会使人从根本上无法进行复杂的(包括符号的)思考,尽管推理的某些方面确实表现出延迟。因此,在典型的发展中,语言和推理是平行发展的。


完整的语言并不意味着完整的思维


以上证据表明,迄今为止测试的所有类型的思维都可以在没有语言的情况下实现。


接下来,论文讨论了语言和思维双重分离的另一面:与语言介导思维的观点相反,完整的语言系统似乎并不意味着完整的推理能力。


图片


图片


人类语言是由交流压力塑造的。


来自发育性和后天性脑部疾病的证据表明,即使语言能力基本完好,也可能存在智力障碍。


例如,有些遗传疾病导致智力受损程度不同,但患有这些疾病的人的语言能力似乎接近正常水平;还有一些精神层面有缺陷的人,会影响思考和推理能力,但同样不会影响语言。最后,许多获得性脑损伤的个体在推理和解决问题方面表现出困难,但他们的语言能力似乎完好无损。换句话说,拥有完整的语言系统并不意味着自动具备思考能力:即使语言能力完好无损,思考能力也可能受损。


总的来说,这篇论文回顾了过去二十年的相关工作。失语症研究的证据表明:所有经过检验的思维形式在没有语言的情况下都是可能的。fMRI 成像证据表明:参与多种形式的思考和推理并不需要语言网络。因此,语言不太可能成为任何形式思维的关键基础。


MIT 研究得出结论的同时,顶尖 AI 领域学者最近也发表了对大模型发展的担忧。上个星期四 Claude 3.5 的发布号称拥有研究生水平的推理能力,提升了行业的标准。不过也有人表示经过实测可见,它仍然具有 Transformer 架构的局限性。


对此,图灵奖获得者 Yann LeCun 表示,问题不在于 Transformer,而是因为 Claude 3.5 仍然是一个自回归大模型。无论架构细节如何,使用固定数量的计算步骤来计算每个 token 的自回归 LLM 都无法进行推理。


图片


LeCun 也评论了这篇 Nature 论文,对思维不等于语言表示赞同。


图片


对此,你怎么看?


参考内容:


news.ycombinator.com/item?id=407…


x.com/ylecun/stat…


作者:机器之心
来源:juejin.cn/post/7383934765370425353
收起阅读 »

还在使用 iconfont,上传图标审核好慢,不如自己做一个

web
之前使用 iconfont 是非常方便的,上传之后立马生效,项目里面直接引用即可,但是现在因为政策的收紧,每次上传图标都要等待十几分钟二十分钟的审核时间,这怎么能忍,有这个时间我都能写一个页面了好吧。 忍受不了就自己做,说干就干,于是我写了一个 svg 转图标...
继续阅读 »

之前使用 iconfont 是非常方便的,上传之后立马生效,项目里面直接引用即可,但是现在因为政策的收紧,每次上传图标都要等待十几分钟二十分钟的审核时间,这怎么能忍,有这个时间我都能写一个页面了好吧。


忍受不了就自己做,说干就干,于是我写了一个 svg 转图标字体的脚手架,所有的内容都自己维护,不再受制于人,感觉就是爽。


svg2font: 一个高效的 SVG 图标字体生成工具


github.com/tenadolante…


在现代 Web 开发中,使用图标是一种常见的做法。图标不仅能美化界面,还能提高可用性和可访问性。传统上,我们使用图片文件(如 PNG、JPG 等)来显示图标,但这种方式存在一些缺陷,例如图片文件较大、不能任意缩放、无法通过 CSS 设置颜色等。相比之下,使用字体图标具有许多优势,如文件体积小、可无限缩放、可通过 CSS 设置颜色和阴影等。


svg2font 就是一个用于将 SVG 图标转换为字体图标的工具,它可以帮助我们轻松地在项目中集成和使用字体图标。本文将详细介绍 svg2font 的使用方法、应用场景和注意事项。


安装


svg2font 是一个基于 Node.js 的命令行工具,因此需要先安装 Node.js 环境。安装完成后,可以使用 npm 或 yarn 在项目中安装 svg2font:


# 使用npm
npm install @tenado/svg2font -D

# 使用yarn
yarn add @tenado/svg2font -D

初始化配置


安装完成后,需要初始化 svg2font 的配置文件。在项目根目录执行以下命令:


npx svg2font init

该命令会在项目根目录下生成一个 svg2font.config.js 文件,内容如下:


module.exports = {
inputPath: "src/assets/svgs", // SVG图标文件夹路径
outputPath: "src/assets/font", // 生成字体文件的输出路径
fontFamily: "tenadoIcon", // 字体名称
fontPrefix: "", // 字体前缀
};

你可以根据实际需求修改这些配置项。


生成字体图标


配置完成后,就可以执行以下命令生成字体图标了:


npx svg2font sync

该命令会读取 inputPath 指定的 SVG 图标文件夹,将其中的 SVG 文件转换为字体文件(包括.eot、.ttf、.woff、.woff2 等格式),并输出到 outputPath 指定的路径下。同时,它还会生成一个 config.json 文件,记录了每个图标的 Unicode 编码和 CSS 类名。


在项目中使用字体图标


生成字体文件后,需要在项目中引入相应的 CSS 文件,才能正常使用字体图标。svg2font 会自动生成一个 index.min.css 文件,包含了所有字体图标的 CSS 定义。你可以在项目的入口文件(如 main.js)中导入该 CSS 文件:


import "./src/assets/font/index.min.css";

之后,你就可以在 HTML 中使用字体图标了。例如,如果你有一个名为 ticon-color-pick 的图标,可以这样使用:


<span class="ticon-color-pick"></span>

查看图标列表


如果你想查看当前项目包含的所有图标,可以执行以下命令:


npx svg2font example

该命令会根据 config.json 文件生成一个静态 HTML 页面,列出了所有图标及其对应的 CSS 类名和 Unicode 编码。它还会启动一个本地服务器,方便你在浏览器中预览这个页面。


注意事项


使用 svg2font 时,需要注意以下几点:


1.SVG 文件命名: 确保 SVG 文件名不包含特殊字符或空格,否则可能会导致生成字体时出错。


2.SVG 文件优化: 在将 SVG 文件转换为字体之前,建议先对 SVG 文件进行优化,以减小文件大小。你可以使用工具如 SVGO 或 SVG Optimizer 来优化 SVG 文件。


3.字体支持:不同浏览器和操作系统对字体格式的支持程度不同。为了最大程度地兼容各种环境,svg2font 会生成多种字体格式(.eot、.ttf、.woff、.woff2 等)。


4.字体缓存: 浏览器会缓存字体文件,因此在更新字体图标时,需要确保浏览器加载了最新的字体文件。你可以在 CSS 文件中为字体文件添加版本号或时间戳,以强制浏览器重新加载字体文件。


总结


svg2font 是一个功能强大且易于使用的 SVG 图标字体生成工具。它可以帮助你轻松地将 SVG 图标转换为字体格式,并在 Web 应用程序、跨平台应用程序或图标库中使用这些字体图标。通过使用 svg2font,你可以提高页面性能、确保图标显示一致性,并享受字体图标带来的诸多优势。


无论你是 Web 开发人员、移动应用程序开发人员,还是 UI 设计师,svg2font 都值得一试。它简单易用,且具有丰富的功能和配置选项,可以满足不同项目的需求。快来试试 svg2font,让你的项目与众不同吧!


作者:是阿派啊
来源:juejin.cn/post/7384808085348483087
收起阅读 »

时隔5年重拾前端开发,却倒在了环境搭建上

web
背景 去年不是降本增“笑”,“裁员”广进来着吗,公司有个项目因此停止了,最近又说这个项目还是很有必要的,就又重新启动这个项目了,然后让我这个“大聪明”把环境重新跑起来。让我无奈的是,原项目的团队成员都已经被增“笑”了,只留下了一堆不知从哪开始着手的文档。 后端...
继续阅读 »

背景


去年不是降本增“笑”,“裁员”广进来着吗,公司有个项目因此停止了,最近又说这个项目还是很有必要的,就又重新启动这个项目了,然后让我这个“大聪明”把环境重新跑起来。让我无奈的是,原项目的团队成员都已经被增“笑”了,只留下了一堆不知从哪开始着手的文档。


后端还好,前端我心里就犯嘀咕了,毕竟已经5年没有关注过前端了,上次写前端代码用的还是一个基于Angular构建的移动框架inoic,不知道大家用过没有。


好在这个项目前端也用的Angular框架,本以为整个过程会很顺利,然而,结果总是事与愿违。果不其然,在搭建前端开发环境时就给我上了一课,整个过程让我抓耳挠腮,遂特此记录。


环境搭建心路历程


跟着文档操作


前端文档中对环境搭建有进行说明,一共有4个步骤,大概是这样的:



  1. 确认node环境,需要某个及以上版本。

  2. 安装@angular/cli。

  3. 安装依赖。

  4. 启动项目。


看到这里,我第一反应是“啊?现在前端这么麻烦的吗?”,我记得以前在浏览器直接打开页面就可以访问了。咱也不懂,跟着说明操作就行。



  1. 我本地不知道啥时候装了nodejs,执行node -v后输出v18.13.0,符合要求。ok

  2. @angular/cli这是啥,咋也不懂,执行安装命令就行,输出看上去是没有问题。ok

  3. 安装依赖我理解跟Maven的依赖管理一样,先不管,执行。ok

  4. 到这一步,我觉得应该可以顺利启动,看一看这个项目的庐山真面目了,结果执行 npm start 后报下面这个错。


出现问题一:nodeJS版本过高


Error: error:0308010C:digital envelope routines::unsupported
......
......

{
'opensslErrorStack': [ 'error:03000086:digital envelope routines::initialization error' ],
'library': 'digital envelope routines',
'reason': 'unsupported',
'code': 'ERR_OSSL_EVP_UNSUPPORTED'
}
......
......

百度一看,原因是node 17版本之后,OpenSSL3.0对算法和密钥大小增加了严格的限制。


解决呗,降版本呗,node官网 下载了v14.12.0。


出现问题二:nodeJS版本低于Angular CLI版本


降版本之后重新运行npm start,您猜猜怎么着


在这里插入图片描述


Node.js version v14.12.0 detected.
The Angular CLI requires a minimum Node.js version of v18.13.

Please update your Node.js version or visit https://nodejs.org/ for additional instructions.

很明显,新老版本冲突了,又是版本问题,又是一顿百度之后,发现知乎上的一个帖子跟我这问题现象是一样的:“node是最新版,npm启动项目使用的不是最新版的node,请问这个怎么解决?


跟着下面的评论又安装了nvm(Node Version Manager),最后一顿操作后,莫名其妙的启动了。


事后才反应过来,这个问题的根本原因是:Angular CLI是在node版本为18.3时安装的,版本更新到14.12.0后需要删除依赖重新安装。


但是我不确定的是对应的npm版本会不会一同更新,有知道的小伙伴评论区交流一下。\color{blue}{但是我不确定的是对应的npm版本会不会一同更新,有知道的小伙伴评论区交流一下。}


不过nvm确实好用,至少不用担心node和npm版本问题,比如下面的命令:


[xxx % ] nvm use --delete-prefix v18.13.0
Now using node v18.13.0 (npm v8.19.3)

学到的第一个知识:nvm


这里记录下nvm安装过程



  1. clone this repo in the root of your user profile


  2. cd ~/.nvm and check out the latest version with git checkout v0.39.7

  3. activate nvm by sourcing it from your shell: . ./nvm.sh


配置环境变量


export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion" # This loads nvm bash_completion

引发的思考


技术发展日新月异


早在几年前,程序员是要前后端一起开发的,不分什么前后端,我从最开始的HTML、JavaScript开始用到AngularJS这些框架,印象最深刻的是还要解决兼容IE浏览器。没想到现在的前端也会有版本管理、组件化等等,可见技术更新迭代速度之快。


前端的重要性


当初在选择后端的时候认为前端技术无非就那些,没有什么挑战。事实上,前后端没有分离之前,市场上的应用页面也是极其简洁的,前后端一起兼顾是没有精力写出那么好看的界面和交互的。所以“前端已死”的观点我是不认可的。


降本增“笑”被迫全栈


前几天参加了开发者社区的线下聚会,聊了一下行情。有小伙伴吐槽,因为在降本增“笑”的原因,现在他们被公司要求要写前端,被迫向全栈发展,竟意外发现开发效率极其高。还有小伙伴说“前端被裁的剩下几个人,一个前端对接十个后端。”。是呀,在降本增“笑”之后,老板恨不得让一个人干十个人的活。


与时俱进


不论是几年前的前后端分离还是降本增“笑”带来的被迫全栈,还是最近“前端已死”的观点,一切都是行业发展所需要的。我们需要做到的是:不断学习和更新自己的知识和技能,以适应行业的发展和变化。


作者:王二蛋呀
来源:juejin.cn/post/7327599804325052431
收起阅读 »

cesium 鼠标动态绘制墙及墙动效

web
实现在cesium中基于鼠标动态绘制墙功能 1. 基本架构设计 绘制墙的交互与绘制线的交互几乎一模一样,只是一些生成wall实体的计算方法不一样,所以可以看这篇文章 cesium 鼠标动态绘制线及线动效 juejin.cn/post/728826… 了解相关...
继续阅读 »

实现在cesium中基于鼠标动态绘制墙功能



1. 基本架构设计


绘制墙的交互与绘制线的交互几乎一模一样,只是一些生成wall实体的计算方法不一样,所以可以看这篇文章 cesium 鼠标动态绘制线及线动效 juejin.cn/post/728826… 了解相关的架构设计


2. 关键代码实现


2.1 绘制线交互相关事件


事件绑定相关与动态绘制线一样,这里不再重复代码


绘制形状代码有区别:
为了实现墙贴地,要实时计算minimumHeights,maximumHeights的值,min中算出地形高度,max中再地形高度的基础上再加上墙的高度


  /**
* 绘制形状,用于内部临时画墙
* @param positionData 位置数据
* @param config 墙的配置项
* @returns
*/

private drawShape(positionData: Cartesian3[], config?: WallConfig) {
const wallConfig = config || new WallConfig();
const material = this.createMaterial(wallConfig);
// @ts-ignore
const pArray = positionData._callback();

const shape = this.app.viewerCesium.entities.add({
wall: {
positions: positionData,
material: material,
maximumHeights: new CallbackProperty(() => {
let heights: number[] = [];
for (let i = 0; i < pArray.length; i++) {
const cartographic = Cartographic.fromCartesian(pArray[i]);
const height = cartographic.height;
heights.push(height);
}
const data = Array.from(heights, (x) => x + wallConfig.height);
return data;
}, false),
minimumHeights: new CallbackProperty(() => {
let heights: number[] = [];
for (let i = 0; i < pArray.length; i++) {
const cartographic = Cartographic.fromCartesian(pArray[i]);
const height = cartographic.height;
heights.push(height);
}
const data = Array.from(heights);
return data;
}, false)
}
});
return shape;
}

2.2 创建材质相关


  /**
* 创建材质
* @param config 墙的配置项
* @returns
*/

private createMaterial(config: WallConfig) {
let material = new ColorMaterialProperty(Color.fromCssColorString(config.style.color));
if (config.style.particle.used) {
material = new WallFlowMaterialProperty({
image: config.style.particle.image,
forward: config.style.particle.forward ? 1.0 : -1.0,
horizontal: config.style.particle.horizontal,
speed: config.style.particle.speed,
repeat: new Cartesian2(config.style.particle.repeat, 1.0)
});
}

return material;
}

创建WallFlowMaterialProperty.js(具体为何如此请看这篇文章,cesium自定义材质 juejin.cn/post/728795…


import { Color, defaultValue, defined, Property, createPropertyDescriptor, Material, Event, Cartesian2 } from 'cesium';

const defaultColor = Color.TRANSPARENT;
import defaultImage from '../../../assets/images/effect/line-color-yellow.png';
const defaultForward = 1;
const defaultHorizontal = false;
const defaultSpeed = 1;
const defaultRepeat = new Cartesian2(1.0, 1.0);

class WallFlowMaterialProperty {
constructor(options) {
options = defaultValue(options, defaultValue.EMPTY_OBJECT);

this._definitionChanged = new Event();
// 定义材质变量
this._color = undefined;
this._colorSubscription = undefined;
this._image = undefined;
this._imageSubscription = undefined;
this._forward = undefined;
this._forwardSubscription = undefined;
this._horizontal = undefined;
this._horizontalSubscription = undefined;
this._speed = undefined;
this._speedSubscription = undefined;
this._repeat = undefined;
this._repeatSubscription = undefined;
// 变量初始化
this.color = options.color || defaultColor; //颜色
this.image = options.image || defaultImage; //材质图片
this.forward = options.forward || defaultForward;
this.horizontal = options.horizontal || defaultHorizontal;
this.speed = options.speed || defaultSpeed;
this.repeat = options.repeat || defaultRepeat;
}

// 材质类型
getType() {
return 'WallFlow';
}

// 这个方法在每次渲染时被调用,result的参数会传入glsl中。
getValue(time, result) {
if (!defined(result)) {
result = {};
}

result.color = Property.getValueOrClonedDefault(this._color, time, defaultColor, result.color);
result.image = Property.getValueOrClonedDefault(this._image, time, defaultImage, result.image);
result.forward = Property.getValueOrClonedDefault(this._forward, time, defaultForward, result.forward);
result.horizontal = Property.getValueOrClonedDefault(this._horizontal, time, defaultHorizontal, result.horizontal);
result.speed = Property.getValueOrClonedDefault(this._speed, time, defaultSpeed, result.speed);
result.repeat = Property.getValueOrClonedDefault(this._repeat, time, defaultRepeat, result.repeat);

return result;
}

equals(other) {
return (
this === other ||
(other instanceof WallFlowMaterialProperty &&
Property.equals(this._color, other._color) &&
Property.equals(this._image, other._image) &&
Property.equals(this._forward, other._forward) &&
Property.equals(this._horizontal, other._horizontal) &&
Property.equals(this._speed, other._speed) &&
Property.equals(this._repeat, other._repeat))
);
}
}

Object.defineProperties(WallFlowMaterialProperty.prototype, {
isConstant: {
get: function get() {
return (
Property.isConstant(this._color) &&
Property.isConstant(this._image) &&
Property.isConstant(this._forward) &&
Property.isConstant(this._horizontal) &&
Property.isConstant(this._speed) &&
Property.isConstant(this._repeat)
);
}
},

definitionChanged: {
get: function get() {
return this._definitionChanged;
}
},

color: createPropertyDescriptor('color'),
image: createPropertyDescriptor('image'),
forward: createPropertyDescriptor('forward'),
horizontal: createPropertyDescriptor('horizontal'),
speed: createPropertyDescriptor('speed'),
repeat: createPropertyDescriptor('repeat')
});

Material.WallFlowType = 'WallFlow';
Material._materialCache.addMaterial(Material.WallFlowType, {
fabric: {
type: Material.WallFlowType,
uniforms: {
// uniforms参数跟我们上面定义的参数以及getValue方法中返回的result对应,这里值是默认值
color: defaultColor,
image: defaultImage,
forward: defaultForward,
horizontal: defaultHorizontal,
speed: defaultSpeed,
repeat: defaultRepeat
},
// source编写glsl,可以使用uniforms参数,值来自getValue方法的result
source: `czm_material czm_getMaterial(czm_materialInput materialInput)
{
czm_material material = czm_getDefaultMaterial(materialInput);

vec2 st = materialInput.st;
vec4 fragColor;
if (horizontal) {
fragColor = texture(image, fract(vec2(st.s - speed*czm_frameNumber*0.005*forward, st.t)*repeat));
} else {
fragColor = texture(image, fract(vec2(st.t - speed*czm_frameNumber*0.005*forward, st.t)*repeat));
}

material.emission = fragColor.rgb;
material.alpha = fragColor.a;

return material;
}`

},
translucent: true
});

export { WallFlowMaterialProperty };


2.3 添加wall实体


  /**
* 根据已知数据添加一个墙
* @param config 墙的配置项
*/

add(config: WallConfig) {
const configCopy = cloneDeep(config);

const positions = configCopy.positions;

const material = this.createMaterial(configCopy);

let distance = new DistanceDisplayCondition();
if (configCopy.distanceDisplayCondition) {
distance = new DistanceDisplayCondition(
configCopy.distanceDisplayCondition.near,
configCopy.distanceDisplayCondition.far
);
}

let heights: number[] = [];
for (let i = 0; i < positions.length; i++) {
const cartographic = Cartographic.fromCartesian(positions[i]);
const height = cartographic.height;
heights.push(height);
}

this.app.viewerCesium.entities.add({
id: 'wallEntity_' + configCopy.id,
wall: {
positions: positions,
maximumHeights: Array.from(heights, (x) => x + configCopy.height),
minimumHeights: Array.from(heights),
material: material,
distanceDisplayCondition: distance
}
});

this._wallConfigList.set('wallEntity_' + configCopy.id, config);
}

3. 业务端调用


调用方式与动态绘制线一样,是同一种架构设计,这里不再重复代码


4. 效果


wall动画.webp


wall动画1.webp


wall动画2.webp


wall动画3.webp


作者:山河木马
来源:juejin.cn/post/7288606110335565883
收起阅读 »

前端如何生成临时链接?

web
前言 前端基于文件上传需要有生成临时可访问链接的能力,我们可以通过URL.createObjectURL和FileReader.readAsDataURAPI来实现。 URL.createObjectURL() URL.createObjectURL() 静态...
继续阅读 »



前言


前端基于文件上传需要有生成临时可访问链接的能力,我们可以通过URL.createObjectURLFileReader.readAsDataURAPI来实现。


URL.createObjectURL()


URL.createObjectURL() 静态方法会创建一个 DOMString,其中包含一个表示参数中给出的对象的URL。这个 URL 的生命周期和创建它的窗口中的 document 绑定。这个新的URL 对象表示指定的 File 对象或 Blob 对象。


1. 语法


let objectURL = URL.createObjectURL(object);

2. 参数


用于创建 URL 的 File 对象、Blob 对象或者 MediaSource 对象。


3. 返回值


一个DOMString包含了一个对象URL,该URL可用于指定源 object的内容。


4. 示例


"file" id="file">

document.querySelector('#file').onchange = function (e) {
console.log(e.target.files[0])
console.log(URL.createObjectURL(e.target.files[0]))
}

0f40e1fff9674142889f8bacc6d455b9.png


将上方console控制台打印的blob文件资源地址粘贴到浏览器中


blob:http://localhost:8080/1ece2bb1-b426-4261-89e8-c3bec43a4020

5cc4d088c5c941b7950f6f930cb9a1bc.png


URL.revokeObjectURL()


在每次调用 createObjectURL() 方法时,都会创建一个新的 URL 对象,即使你已经用相同的对象作为参数创建过。当不再需要这些 URL 对象时,每个对象必须通过调用 URL.revokeObjectURL() 方法来释放。


浏览器在 document 卸载的时候,会自动释放它们,但是为了获得最佳性能和内存使用状况,你应该在安全的时机主动释放掉它们。


1. 语法


window.URL.revokeObjectURL(objectURL);

2. 参数 objectURL


一个 DOMString,表示通过调用 URL.createObjectURL() 方法返回的 URL 对象。


3. 返回值


undefined


4. 示例


"file" id="file">
<img id="img1" style="width: 200px;height: auto" />
<img id="img2" style="width: 200px;height: auto" />

document.querySelector('#file').onchange = function (e) {
const file = e.target.files[0]

const URL1 = URL.createObjectURL(file)
console.log(URL1)
document.querySelector('#img1').src = URL1
URL.revokeObjectURL(URL1)

const URL2 = URL.createObjectURL(file)
console.log(URL2)
document.querySelector('#img2').src = URL2
}

ecba01284f034c42a2bf4200054b0e9f.png


与FileReader.readAsDataURL(file)区别


1. 主要区别



  • 通过FileReader.readAsDataURL(file)可以获取一段data:base64的字符串

  • 通过URL.createObjectURL(blob)可以获取当前文件的一个内存URL


2. 执行时机



  • createObjectURL是同步执行(立即的)

  • FileReader.readAsDataURL是异步执行(过一段时间)


3. 内存使用



  • createObjectURL返回一段带hashurl,并且一直存储在内存中,直到document触发了unload事件(例如:document close)或者执行revokeObjectURL来释放。

  • FileReader.readAsDataURL则返回包含很多字符的base64,并会比blob url消耗更多内存,但是在不用的时候会自动从内存中清除(通过垃圾回收机制)


4. 优劣对比



  • 使用createObjectURL可以节省性能并更快速,只不过需要在不使用的情况下手动释放内存

  • 如果不在意设备性能问题,并想获取图片的base64,则推荐使用FileReader.readAsDataURL




作者:sorryhc
来源:juejin.cn/post/7333236033038778409
收起阅读 »

Vue3 实现最近很火的酷炫功能:卡片悬浮发光

web
前言 大家好,我是林三心,用最通俗易懂的话讲最难的知识点是我的座右铭,基础是进阶的前提是我的初心~ 有趣的动画效果 前几天在网上看到了一个很有趣的动画效果,如下,光会跟随鼠标在卡片上进行移动,并且卡片会有视差的效果 那么在 Vue3 中应该如何去实现这个效果呢...
继续阅读 »

前言


大家好,我是林三心,用最通俗易懂的话讲最难的知识点是我的座右铭,基础是进阶的前提是我的初心~


有趣的动画效果


前几天在网上看到了一个很有趣的动画效果,如下,光会跟随鼠标在卡片上进行移动,并且卡片会有视差的效果


那么在 Vue3 中应该如何去实现这个效果呢?



基本实现思路


其实实现思路很简单,无非就是分几步:



  • 首先,卡片是相对定位,光是绝对定位

  • 监听卡片的鼠标移入事件mouseenter,当鼠标进入时显示光

  • 监听卡片的鼠标移动事件mouseover,鼠标移动时修改光的left、top,让光跟随鼠标移动

  • 监听卡片的鼠标移出事件mouseleave,鼠标移出时,隐藏光


我们先在 Index.vue 中准备一个卡片页面,光的CSS效果可以使用filter: blur() 来实现



可以看到现在的效果是这样



实现光源跟随鼠标


在实现之前我们需要注意几点:



  • 1、鼠标移入时需要设置卡片 overflow: hidden,否则光会溢出,而鼠标移出时记得还原

  • 2、获取鼠标坐标时需要用clientX/Y而不是pageX/Y,因为前者会把页面滚动距离也算进去,比较严谨


刚刚说到实现思路时我们说到了mouseenter、mousemove、mouseleave,其实mouseenter、mouseleave 这二者的逻辑比较简单,重点是 mouseover 这个监听函数


而在 mouseover 这个函数中,最重要的逻辑就是:光怎么跟随鼠标移动呢?


或者也可以这么说:怎么计算光相对于卡片盒子的 left 和 top


对此我专门画了一张图,相信大家一看就懂怎么算了




  • left = clientX - x - width/2

  • height = clientY - y - height/2


知道了怎么计算,那么逻辑的实现也很明了了~封装一个use-light-card.ts



接着在页面中去使用



这样就能实现基本的效果啦~



卡片视差效果


卡片的视差效果需要用到样式中 transform 样式,主要是配置四个东西:



  • perspective:定义元素在 3D 变换时的透视效果

  • rotateX:X 轴旋转角度

  • rotateY:Y 轴旋转角度

  • scale3d:X/Y/Z 轴上的缩放比例



现在就有了卡片视差的效果啦~



给所有卡片添加光源


上面只是给一个卡片增加光源,接下来可以给每一个卡片都增加光源啦!!!




让光源变成可配置


上面的代码,总感觉这个 hooks 耦合度太高不太通用,所以我们可以让光源变成可配置化,这样每个卡片就可以展示不同大小、颜色的光源了~像下面一样



既然是配置化,那我们希望是这么去使用 hooks 的,我们并不需要自己在页面中去写光源的dom节点,也不需要自己去写光源的样式,而是通过配置传入 hooks 中



所以 hooks 内部要自己通过操作 DOM 的方式,去添加、删除光源,可以使用createElement、appendChild、removeChild 去做这些事~



完整源码


<!-- Index.vue -->

<template>
<div class="container">
<!-- 方块盒子 -->
<div class="item" ref="cardRef1"></div>
<!-- 方块盒子 -->
<div class="item" ref="cardRef2"></div>
<!-- 方块盒子 -->
<div class="item" ref="cardRef3"></div>
</div>
</template>

<script setup lang="ts">
import { useLightCard } from './use-light-card';

const { cardRef: cardRef1 } = useLightCard();
const { cardRef: cardRef2 } = useLightCard({
light: {
color: '#ffffff',
width: 100,
},
});
const { cardRef: cardRef3 } = useLightCard({
light: {
color: 'yellow',
},
});
</script>

<style scoped lang="less">
.container {
background: black;
width: 100%;
height: 100%;
padding: 200px;
display: flex;
justify-content: space-between;

.item {
position: relative;
width: 125px;
height: 125px;
background: #1c1c1f;
border: 1px solid rgba(255, 255, 255, 0.1);
}
}
</style>


// use-light-card.ts

import { onMounted, onUnmounted, ref } from 'vue';

interface IOptions {
light?: {
width?: number; // 宽
height?: number; // 高
color?: string; // 颜色
blur?: number; // filter: blur()
};
}

export const useLightCard = (option: IOptions = {}) => {
// 获取卡片的dom节点
const cardRef = ref<HTMLDivElement | null>(null);
let cardOverflow = '';
// 光的dom节点
const lightRef = ref<HTMLDivElement>(document.createElement('div'));
// 设置光源的样式

const setLightStyle = () => {
const { width = 60, height = 60, color = '#ff4132', blur = 40 } = option.light ?? {};
const lightDom = lightRef.value;
lightDom.style.position = 'absolute';
lightDom.style.width = `${width}px`;
lightDom.style.height = `${height}px`;
lightDom.style.background = color;
lightDom.style.filter = `blur(${blur}px)`;
};

// 设置卡片的 overflow 为 hidden
const setCardOverflowHidden = () => {
const cardDom = cardRef.value;
if (cardDom) {
cardOverflow = cardDom.style.overflow;
cardDom.style.overflow = 'hidden';
}
};
// 还原卡片的 overflow
const restoreCardOverflow = () => {
const cardDom = cardRef.value;
if (cardDom) {
cardDom.style.overflow = cardOverflow;
}
};

// 往卡片添加光源
const addLight = () => {
const cardDom = cardRef.value;
if (cardDom) {
cardDom.appendChild(lightRef.value);
}
};
// 删除光源
const removeLight = () => {
const cardDom = cardRef.value;
if (cardDom) {
cardDom.removeChild(lightRef.value);
}
};

// 监听卡片的鼠标移入
const onMouseEnter = () => {
// 添加光源
addLight();
setCardOverflowHidden();
};

// use-light-card.ts

// 监听卡片的鼠标移动
const onMouseMove = (e: MouseEvent) => {
// 获取鼠标的坐标
const { clientX, clientY } = e;
// 让光跟随鼠标
const cardDom = cardRef.value;
const lightDom = lightRef.value;
if (cardDom) {
// 获取卡片相对于窗口的x和y坐标
const { x, y } = cardDom.getBoundingClientRect();
// 获取光的宽高
const { width, height } = lightDom.getBoundingClientRect();
lightDom.style.left = `${clientX - x - width / 2}px`;
lightDom.style.top = `${clientY - y - height / 2}px`;

// 设置动画效果
const maxXRotation = 10; // X 轴旋转角度
const maxYRotation = 10; // Y 轴旋转角度

const rangeX = 200 / 2; // X 轴旋转的范围
const rangeY = 200 / 2; // Y 轴旋转的范围

const rotateX = ((clientX - x - rangeY) / rangeY) * maxXRotation; // 根据鼠标在 Y 轴上的位置计算绕 X 轴的旋转角度
const rotateY = -1 * ((clientY - y - rangeX) / rangeX) * maxYRotation; // 根据鼠标在 X 轴上的位置计算绕 Y 轴的旋转角度

cardDom.style.transform = `perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg)`; //设置 3D 透视
}
};
// 监听卡片鼠标移出
const onMouseLeave = () => {
// 鼠标离开移出光源
removeLight();
restoreCardOverflow();
};

onMounted(() => {
// 设置光源样式
setLightStyle();
// 绑定事件
cardRef.value?.addEventListener('mouseenter', onMouseEnter);
cardRef.value?.addEventListener('mousemove', onMouseMove);
cardRef.value?.addEventListener('mouseleave', onMouseLeave);
});

onUnmounted(() => {
// 解绑事件
cardRef.value?.removeEventListener('mouseenter', onMouseEnter);
cardRef.value?.removeEventListener('mousemove', onMouseMove);
cardRef.value?.removeEventListener('mouseleave', onMouseLeave);
});

return {
cardRef,
};
};


结语 & 加学习群 & 摸鱼群


我是林三心



  • 一个待过小型toG型外包公司、大型外包公司、小公司、潜力型创业公司、大公司的作死型前端选手;

  • 一个偏前端的全干工程师;

  • 一个不正经的掘金作者;

  • 一个逗比的B站up主;

  • 一个不帅的小红书博主;

  • 一个喜欢打铁的篮球菜鸟;

  • 一个喜欢历史的乏味少年;

  • 一个喜欢rap的五音不全弱鸡


如果你想一起学习前端,一起摸鱼,一起研究简历优化,一起研究面试进步,一起交流历史音乐篮球rap,可以来俺的摸鱼学习群哈哈,点这个,有7000多名前端小伙伴在等着一起学习哦 --> 摸鱼沸点


作者:Sunshine_Lin
来源:juejin.cn/post/7373867360019742758
收起阅读 »

CSDN 搬运了 Github 所有项目,骚操作一波接一波

最近几天,CSDN 旗下的代码平台 GitCode 在未获得任何作者授权的情况下,批量搬运了 Github 上的几乎所有开源项目:GitCode 不仅把项目所有信息都搬运到自己平台上,还给每个开发者、组织都创建了主页。如果作者想要编辑和操作自己的主页和项目,只...
继续阅读 »

最近几天,CSDN 旗下的代码平台 GitCode 在未获得任何作者授权的情况下,批量搬运了 Github 上的几乎所有开源项目:


GitCode 不仅把项目所有信息都搬运到自己平台上,还给每个开发者、组织都创建了主页。如果作者想要编辑和操作自己的主页和项目,只能使用 Github 授权登录并创建 GitCode 平台的账号才能操作。


GitCode 甚至把项目 README 中的 github 字样都替换成了gitcode...

CSDN 的骚操作远不止这些,它们甚至创建了一批 CSDN 小号,并使用 AI 发布大量 GitCode 项目的相关内容,以进行引流。大家都知道,CSDN 的内容在很多搜索引擎中的权重是比较高的,这一骚操作就回导致搜索结果又多了很多垃圾信息。


AI 盛行的今天,大模型需要使用大量互联网信息进行训练。而 CSDN 用 AI 生成垃圾内容发布到网络上,多多少少会对大模型的质量产生影响,大模型又会生成更多垃圾内容,最终形成恶性循环,想想都可怕。

最搞笑的是,GitCode 在搬运 Github 项目时似乎没有做筛选,搬运了很多违法、违规的项目(懂得都懂,导致网站短暂 404,真是搬起石头砸了自己的脚。


此事发生后,很多开发者都出来声讨 GitCode,并要求其删除账号和项目:


最后不得不吐槽一句,这么一个半成品网站(网站随处可见的Bug),就不要拿出来搞事情了,很难看的。

有网友整理了 CSDN 的五宗罪:


图源:
https://github.com/Catherina0/evil-CSDN

作者:极速星空4DO

来源:www.toutiao.com/article/7384999821570064950/

收起阅读 »

你想活出怎样的人生?

hi~好久不见,距离上次发文隔了有段时间了,这段时间,我是裸辞去感受了一下前端市场的水深火热,那么这次咱们不聊技术,就说一说最近这段时间的经历和一些感触吧。 先说一下自己的个人情况,目前做前端四年,双非本,非科班,技术栈Vue和小程序,读过源码,刷过算法,写过...
继续阅读 »

hi~好久不见,距离上次发文隔了有段时间了,这段时间,我是裸辞去感受了一下前端市场的水深火热,那么这次咱们不聊技术,就说一说最近这段时间的经历和一些感触吧。


先说一下自己的个人情况,目前做前端四年,双非本,非科班,技术栈Vue和小程序,读过源码,刷过算法,写过开源,工作地点在武汉。


我是在三月初裸辞向公司提的离职,并在四月初离开。在做出裸辞这个决定之前,其实也是犹豫了好久,因为在上家公司做开发还是很愉快的,同时看网上大家对于如今的市场行情评价都是寒气逼人,所以对于这次的裸辞我思考了有半年之久。


我的想法有几个点:



  1. 上家公司整体规模偏小,而且项目的复杂度并不太高,技术上的成长主要靠个人,所以如果在这里继续做下去,技术,眼界,薪资可能都会比较受限,越往后越会出现技术不匹配年限的问题。如果公司一旦出现了点什么问题,那么个人在市面上可选择的岗位就会十分受限。

  2. 互联网下行的情况在前两年就已经出现了,然而每年又都会有一大批新的大学生加入到这个行业,那么可能真的今年就是往后十年中最好的一年了,之后一定是会越来越卷的。

  3. 对自己的技术还算是有些信心,觉得不至于会找不到合适的工作。


综合考虑了以上几点,决定就勇敢一次,迈出这一步,不论后面的结果如何都是自己的选择。


面试


然后,就聊一聊最近这段时间面试的感触吧。先说结论,别的城市倒不清楚,就只说武汉,行情的确是有些差的,主要体现在小公司开不起价,大点公司(武汉其实也没什么大公司)又很难过简历筛,再加之岗位有限,所以整体的感受就是水深火热


从三月中下旬开始投递简历,一直到五月底决定去向,这期间在招聘软件上打了上百次招呼,拿到十二个面试机会,通过的有七家,最终选择了离家还算比较近,工作流程以及规模还不错的一家公司入了职。


这段时间可以说是要比平时上班还要累的,工作日每天起来就会去刷一刷招聘软件,去看看有没有新出的职位可以聊一下的,但渐渐的就会发现,招聘软件翻来覆去就那么几家公司,还都是常年招聘的,新出的机会可能要好久才会遇到一次。


能约到面试的几天心态还会好一些,可一旦连续几天没有约到面试,投递简历都石沉大海,那个时候内心就会开始有些焦虑,很容易会想要不要随便找一家将就下得了,但好在每次有这种想法的时候,都会有新的面试邀约出现,也算是挺幸运的了。而且根据每次面试的过程来看,目前我点的技能点是完全够用了的,甚至面一些小公司的时候,有时能清晰的感受到在吊打面试官,这也算是无形中增加了我的信心吧,能够让我继续战斗下去~ 而且也非常感谢在找工作时给我鼓励的掘友,当时面了一家公司,而面试官是一位掘友的朋友,可能下去后面试官和掘友提起了我的面试,晚上在掘金收到了掘友的私信,说我的技术一定没问题的,而且算法可以,一定要去投一投大公司~ 当天收到私信时,可以说真的是热泪盈眶,感受到了寒冬中的小小温暖,真的非常感谢~


然后说一下面试体验吧,面试体验真的和公司规模成正比的。


窒息的面试体验


我面的这几家,有一些小公司的面试官或者hr真的各种作妖:



  • 有的时候吊打了面试官,然后hr来谈薪想压价,拿什么压我都能理解,毕竟公司给到hr的预算可能有限,但是拿技术来压,真就不理解,面试官都没说什么,甚至当场说技术确实很不错,然后一个hr来尝试根据之前做的项目找漏洞去聊技术,聊复杂度去压价,真的是让人难以理解。

  • 有的公司则是非常的小,然后面试官应该就是公司领导吧,给了一份笔试题,做完后去面试,笔试当时做了15分钟,面试只12分钟,而面试的时候在刚进行2分钟我就已经想结束面试直接走人了,面试官就是对着他出的一份稀烂的笔试题一个个问,我也一个个给他答,每答一个他都先把你的答案给否定,然后尝试从回答中找漏洞,没有找到那就再问一个他自己现编的很奇怪的问题,真就离谱,也真是我素质还算好,没有当场去怼他,当时面的12分钟真的是折磨

  • 再不然有些面试官,就是简历也不细看,就会去问一些冷门API的用法,这一家当时我已经面到后期了,见了形形色色的面试官,所以也不惯着,直接就问他,你问这个有什么用呢?你是想招干活的人,还是想招可培养的人?那你面试问一个API能问出来什么呢?


愉快的面试体验


说完了小公司的体验,再说一些体验还不错的面试吧,一个体验比较好的面试给人的感觉就是,对方是能把我掌握的技术深度和广度都给探到,并且双方面试过程更像是探讨的过程



  • 有的面试官会在听你介绍项目难点以及解决方案的时候,逐步的引导你去思考出更优的解决方案

  • 有的面试官则会给你一种感觉就是,这个面试官真的很大佬,比如我遇到的一个面试官精通源码,虽然我也看过并且写过源码文章,但在很多细节的地方还是会有所遗忘,在面试的过程中,有的地方思路乱了,面试官则会在我把我知道的都讲完之后,去完整的给梳理一次思路,并说明整个的运行流程。


这两种面试官其实都有一个共同的点,就是他是在找你技术的深度和解决问题的能力,让你尽可能的展示自己,而不是对着一份面试题或者就是想刁难你找优越感


最后的选择


最终,选择的这家,其实薪资上的涨幅很小,但工作强度会比上一家大上不少。面了2个月,这个过程很累,我也没有太多的能量去接着去面试了,而这家公司整体面试体验给我的感觉还可以,就先入职看看喽~


然后,关于自己的职业发展,目前其实是有些迷茫的,刚入行前端的时候,感觉当时的机会还是很多的,能看到很多大厂的招聘要求以及结合一些在网上看到的一些大佬的经历,然后我就做出了规划:去研究源码和算法参与一些开源,当工作经验够3年之后,去尝试投递一下大厂,看一看新的机会。可是现在,当经验,技能可以达到要求之后,市场却凉下来了,不是92的学历或者大厂的履历,连简历筛都很难过的去,小一点的公司也想用较低的工资去招一个经验丰富的人,然后面试就还会问对加班的看法,甚至有的还会问无效加班接不接受,感觉整个市场都是一个让人无法理解的样子


最后


上面聊了这么多,不管怎样,也确实是当前武汉前端求职环境的现状大佬当然无所畏惧),所以,如果有朋友还跃跃欲试想换个环境,那我建议也是,如果可以的话找好再走,不要着急但这个问题的点就在于,很多公司会要求线下面试,就算线上面试,时间安排其实也会很不方便),可以投递一下先试试水,感受一下市场。但如果是有自己的规划或者实在是想要换个环境的朋友,可以根据我上面说的,只要能做好心理预期可能会连续打招呼两三天,甚至一周都没有回应),确保自己的心态稳定因为这本来就不是个人的问题,我们能做的就是把所掌握的技术准备充分就可以了),其实也可以一试,机会是有的,但是不多,需要自己去争取,并把握住


最后的最后,关于起这个标题,其实是我在一开始写这篇文章的时候脑海中就浮现的宫崎骏的这个电影和这句话。。。关于这个电影,网上有很多的评价,有的人会觉得这个电影不知道到底想说些什么,教会我们些什么。那有没有可能,老爷子其实也没打算教我们什么,当下的环境已经塞给我们太多东西,可以单纯的感受一下宫崎骏为我们创造的奇幻世界也是挺好的~ 你想活出怎样的人生其实都没有问题,或奋斗,或躺平,或去大城市,或留在小城市都只是一个选择,一种体验而已,没什么对错之分。所以这句话是在问掘友,也是在问我自己吧~


后续的个人规划,其实我也还没有很明确,现阶段,打算先继续搞一搞自己感兴趣的技术吧,不管环境怎样,个人的状态怎样,只要是在向前的,我想总归是好的吧,后续也会继续输出一些有意思的内容,掘友们共勉~


作者:沽汣
来源:juejin.cn/post/7376177615441117238
收起阅读 »

移动前端混合开发技术演进之路

本文是azuo和萌妹俩技术创作之旅的第15篇原创文章,内容创作@azuo😄,精神支持@大头萌妹😂 前言:本文主要探讨了移动混合开发( Hybrid APP) 开发的技术演进历程,将阐述了webview(H5)、React Native、小程序技术等在其中所扮...
继续阅读 »

本文是azuo和萌妹俩技术创作之旅的第15篇原创文章,内容创作@azuo😄,精神支持@大头萌妹😂



前言:本文主要探讨了移动混合开发( Hybrid APP) 开发的技术演进历程,将阐述了webview(H5)、React Native、小程序技术等在其中所扮演的关键角色及带来的变革。原生能力缺失、长时间白屏、用户操作响应不及时等web开发的问题是如何被解决的?


一、诞生背景


早期移动应用开发,由于机器硬件性能的方面影响,为了更好的用户体验(操作响应、流畅度和原生的能力),主要集中在原生应用开发上。


1.1 原生开发的缺点


原生应用开发周期和更新周期长,也逐渐在快速的迭代的互联网产品产生矛盾。


缺点:



  • 开发周期长:开发调试需要编译打包,动辄就需要几分钟甚至十几分钟,相比H5的亚秒级别的热更能力,是在太长了;

  • 更新周期长:正常的发版需要用户手动更新,无法做到H5这种发布即更新的效率。

  • 使用前需要安装;

  • 需要多端开发;(Android和iOS两端开发人力成本高)


1.2 web开发的缺点


原生应用的研发效率问题,也逐渐在快速的迭代的互联网产品产生矛盾。这时候,开发人就自然而然的想到web技术能力,快速开发和发版生效和跨平台能力。


web技术开发的H5界面,相比原生应用,缺点也很明显:



  1. 缺少系统的提供原生能力;

  2. 页面白屏时间长(原生基本可以做到1秒内,h5普遍在2秒以上);

  3. 用户操作响应不及时(动画卡、点击没有反应);


把Native开发和web开发的优缺点整合一下,就诞生了Hybrid App。Hybrid App技术从诞生到现在一直在解决这3个问题。


二、 提供原生能力


JSBridge技术是由 Hybrid 鼻祖框架phoneGap带到开发者的视野中,解决了第一个问题。它通过webview桥接(JSBridge)的方式层解决web开发能力不足的问题,让web页面可以用系统提供原生能力。


2.1 技术原理


Android原生开发提供了各种view控件(类比Dom元素:div、canvas、iframe),其中就用一个webview(类比iframe)。JSBridge 就像其名称中的『Bridge』的意义一样,是 Native 和非 Native 之间的桥梁,它的核心是 构建 Native 和非 Native 间消息通信的通道,而且是 双向通信的通道


image.png


双向通信的通道:



  • JS 向 Native 发送消息 : 调用相关功能、通知 Native 当前 JS 的相关状态等。

  • Native 向 JS 发送消息 : 回溯调用结果、消息推送、通知 JS 当前 Native 的状态等。


2.2 实现细节


Android可以通过webview将一些原生的Java方法注入到window上供Javascript调用。Javascript也可以直接在window上挂着全局对象给webview执行。


2.2.1  JavaScript 调用 Native


Android 可以采用下面的方式:


public class JSBridgeActivity extends Activity{ 
private WebView Wv;

@Override
publicvoidonCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
Wv = (WebView)findViewById(R.id.webView);
Wv.getSettings().setJavaScriptEnabled(true);
// 4.2 使用 @JavascriptInterface
Wv.addJavascriptInterface(new JavaScriptInterface(this), "nativeBridge");
// TODO 显示 WebView
}
}


public class JavaScriptInterface{
@JavascriptInterface
public void postMessage(String webMessage){
// Native 逻辑
}
}

前端调用方式:


// android会在window上注入nativeBridge对象
window.nativeBridge.postMessage(message);

native层除了上述方式被Javascript调用,还有可以拦截alert、confirm、console的日志输出、请求URL(伪协议)等方式,来的获取到Javascript调用native的意图。


2.2.2 Native 调用 JavaScript


相比于 JavaScript 调用 Native, Native 调用 JavaScript 较为简单, WebView 组件,都以子组件的形式存在于 View/Activity 中,直接调用相应的 API 即可(类比浏览器的window中的原生方法)。


// android 4.4之前
webView.loadUrl("javascript:"+javascriptString)

// android 4.4之后
webView.evaluateJavascript(
javaScriptString, // js表达式
new ValueCallback<String>() { // 表达式的值通过回调给native
@Override
public void onReceiveValue(String value){
// 鉴权拦截,一般估计页面域名白名单的方式
JSONObject json = new JSONObject(value)
switch(json.bridgeName){
// 处理
}

}
}
);

2.3  JSBridge 接口


JSBridge 技术是对JavaScript 和 Native之间的封装成JS SDK方便前端JS调用,主要功能有两个:调用 Native和 接收Native 被调。


(function () {
var id = 0,
callbacks = {};


window.JSBridge = {
// 调用 Native
invoke: function(bridgeName, callback, data) {
// 判断环境,获取不同的 nativeBridge
var thisId = id ++; // 获取唯一 id
callbacks[thisId] = callback; // 存储 Callback
nativeBridge.postMessage(JSON.stringify{
bridgeName: bridgeName,
data: data || {},
callbackId: thisId // 传到 Native 端
});
},
receiveMessage: function(msg) {
var bridgeName = msg.bridgeName,
data = msg.data || {},
callbackId = msg.callbackId; // Native 将 callbackId 原封不动传回
// 具体逻辑
// bridgeName 和 callbackId 不会同时存在
if (callbackId) {
if (callbacks[callbackId]) { // 找到相应句柄
callbacks[callbackId](msg.data); // 执行调用
}
} elseif (bridgeName) {


}
}
};
})();

JSBridge通过建立一个通信桥梁,使得JavaScript和原生代码可以相互调用,实现高效的数据传输和交互。这个过程是跨线程异步调用的,数据传输一般会经过两次序列化(还有提升的空间)


三、解决白屏


3.1 白屏产生的原因


原生APP安装后启动页面,在正常情况是不用再从网络获取资源,只需要请求后端接口获取数据就可以完成渲染了,网页不需要安装才,每次打开web页面都会从远程服务加载资源后,再请求后端数据后才能渲染。在用户等待资源加载过程和浏览器渲染未完成中,就会出现白屏。造成白屏的主要原因 -- 资源网络加载


首屏渲染SSR.drawio.png


3.2 离线包技术


离线包主要是识别特定url地址(通常是url参数=离线批次id,即:_bid=1221)后保存到用户手机硬盘。用户下次打开H5页面就可以不用走网络请求。离线包一包也会提供预下载能力,保证首次打开H5页面也可以获得收益。



离线包是完整的资源分发系统,需要一个完整的技术团队来建设和维护的。



3.2.1 离线包分发过程


分发流程中主要涉及4种角色:



  • 离线配置平台:配置平台可以提供离线配置能力、离线包管理(上传、禁用、清空)、离线包使用统计、离线包准入审核(自动(包大小限制)+人工(解决特殊case))

  • 离线配置服务: 配置服务主要提供服务层能力,实现离线配置服务,离线包更新服务,离线资源长传下载服务、离线资源使用统计服务

  • 离线SDK: 端内接入离线SDK,SDK主要与离线配置服务进行交互,完成离线资源的管理和接入配置能力

  • Native侧 : 实现拦截请求在特定的协议下接入离线资源


image.png


3.2.2 离线包加载过程


离线包的加载流程


image.png


3.2.3 拦截实现细节


实现WebViewClient: 继承WebViewClient类,并重写shouldInterceptRequest方法。这个方法会在WebView尝试加载一个URL时被调用,你可以在这里检查请求的URL,并决定是否拦截这个请求。


public class MyWebViewClient extends WebViewClient {  
private InputStream getOfflineResource(String url) {
// ... 你的实现代码 ...
return null; // 示例返回null,实际中应该返回InputStream
}

@Override
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
String url = request.getUrl().toString();

// 检查这个URL是否在你的离线包中
InputStream inputStream = getOfflineResource(url);
if (inputStream != null) {
// 如果在离线包中找到了资源,就返回一个WebResourceResponse对象
return new WebResourceResponse(
"text/html", // MIME类型,这里以HTML为例
"UTF-8", // 编码
inputStream
);
}
// 如果没有在离线包中找到资源,就返回null,让WebView按照默认的方式去加载这个URL
// 走网络请求获取
}
}

// 在你的Activity或Fragment中
WebView webView = findViewById(R.id.webview);
webView.setWebViewClient(new MyWebViewClient());

3.3 服务端渲染(SSR )


在3.1 白屏产生的原因,影响白屏的因素是JS和CSS资源和数据请求。如果,html请求得到的内容中直接包含首屏内容所需要内联的CSS和Dom结构。


首屏渲染.drawio (4).png


SSR通过在服务端(BFF)直接完成有内容的HTML组装。webview获取到html内容就可以直接渲染。减少白屏时间和不可交互时间。


3.3.1 增量更新和并行请求


SSR将本来一个简单框架HTML,增加了首屏内容所需要的完整CSS和Dom内容。这样的话,HTML请求的包体积就增大了多。其中:



  • 跟版本相关的样式文件CSS (变更频率低)

  • 跟用户信息相关的Dom内容(变更频率高)


HTML根据内容变更频率进行页面分割如下:


<!DOCTYPE html>
<html lang="en">
<head>
<title>OPPO用户体验评价</title>
<meta charset="UTF-8">
<script content="head">window._time = Date.now()</script>
<meta name="renderer" content="webkit|chrome">
<meta name="format-detection" content="telephone=no" />
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="x5-orientation" content="portrait">
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-COMPATIBLE" content="IE=Edge,chrome=1">
<meta name="nightmode" content="disable">
<meta name="color-scheme" content="light">
<!-- css内联内容开始 -->
<style>
/*http://www.xxx.com/wj-prod/style.css*/
/**
* 替换url的css内容,内容比较多
*/

</style>
<!-- css内联内容结束 -->
</head>
<body>
<!-- dom内容开始 -->
<div id="app">
<!-- 拼接好的html结果 -->
<div>
<span></span>
</div>
</div>
<!-- dom内容结束 -->
<!-- 数据内容开始 -->
<script content="page-data">
// 直出的数据,方便vue、react等框架回填状态,声明式UI才必须
window.syncData = {/**服务端获取的数据**/}
</script>
<!-- 数据内容结束 -->
<script crossorigin="anonymous" src="//cdn.xxx.com/wj-prod/client.bundle.js?_t=1"></script>
</body>
</html>

客户端和BFF层大概工作流程如下:


image.png


首屏渲染.drawio (5).png


手机QQ将这套方案开源了:github.com/Tencent/Vas… (我曾经也是这套方案的参与者和使用者)


3.4 总结


为了更快的渲染出页面,发展了离线包技术、服务器端渲染(SSR)、Webview启动并行等一系列的技术方案,这些技术可以单个使用,也可以组合使用。



  • 对于首次加载的页面,使用服务器端渲染(SSR)和Webview启动并行,是可以很好的解决白屏问题,适用H5活动页面。

  • 对于二次加载的页面,使用离线包技术、服务器端渲染(SSR)和Webview启动并行,可以在不经过网络请求也可以展示页面,适用固定入口客户端页面;


四、解决卡顿


使用过程发现H5网页相比于原生页面,更容卡顿,甚至造成页面卡死的问题。这个章节就主要解决为啥浏览器渲染的H5会比原生卡?Hybrid开发用哪些技术如何解决这个问题?


4.1 浏览器渲染的慢


浏览器技术的发展历程已有超过30年的历史,Chrome内核有超过2400万行代码,有很重的历史包袱。


4.1.2 渲染流程


浏览器渲染页面使用了多线程的架构,发生卡顿的主要原因在:渲染线程和JS引擎线程,他两是互斥的,Javascript长时间执行会导致渲染线程无法工作。
image.png


GUI渲染线程(GUI Thread):



  1. 负责渲染浏览器界面。

  2. 解析HTML、CSS,构建DOM树和CSS规则树,并合成渲染树。

  3. 布局(Layout)和渲染(Paint)页面内容。

  4. 与JS引擎线程互斥,当JS引擎线程执行时GUI渲染线程被挂起,GUI更新会被保存在一个队列中,等JS引擎空闲时立即执行。


JS引擎线程(JS Engine Thread):



  1. 也称为JS内核(在Chrome中为V8)。

  2. 负责解析和执行JavaScript代码。

  3. 单线程设计,JS运行过长会阻塞GUI渲染。


事件触发线程(Event Dispatch Thread):



  1. 用于控制事件循环。

  2. 当事件(如点击、鼠标移动等)被触发时,该线程会将事件放到对应的事件队列中,等待JS引擎线程处理。


合成器线程(Compositor Thread)和光栅线程(Raster Thread):



  1. 这两个线程在渲染器进程中运行,以高效流畅地渲染页面。

  2. 合成器线程负责将不同的图层组合成最终用户看到的页面。

  3. 光栅线程则负责将图层内容转换为位图,以便在屏幕上显示。


以用户点击操作为例:


image.png


如果界面的刷新帧率是60帧,在不掉帧的情况。执行时间只有 1000 ms / 60 = 16.66 ms。上图中间的JS引擎线程和渲染线程的执行是串行,而且不能超过16.66 ms。(留给JS引擎和渲染线程执行的时间本身不多,60帧只有有16ms,120帧只有8ms)这就是浏览器为啥比原生渲染卡。


4.2 声明式UI


浏览器渲染慢的主要原因是JS引擎线程和渲染进程的执行互斥, 那么,最简单解决方式就是将渲染线程改造按照帧率来调度,不再等JS引擎线程全部执行完再去渲染。但是,由于浏览器最初涉及的JS引擎线程是为了应对命令式UI渲染方案,命令式UI对界面的修改是不可预测。


4.2.1 命令式UI


命令式UI关注于如何达到某个特定的用户界面状态,通过编写具体的操作指令来直接操纵界面元素。关注于操作步骤和过程,需要编写具体的代码来实现每个步骤。


// dom找到需要变更的节点
const list = document.querySelector('#content')
// 修改样式
list.style.display = 'none'
// 增加内容
list.innerHTML += `<div class="item">列表内容</div>`

优点: 是入门简单,讲究一个精确控制直接操作。


缺点: 直接操作界面,带来对UI界面渲染的不可以预测性;


4.2.1 声明式UI


声明式UI(Declarative UI)是一种用户界面编程范式,它关注于描述UI的期望状态,而不是直接编写用于改变UI的命令。在声明式UI中,开发者通过声明性的方式定义UI的结构、样式和行为,而具体的渲染和更新工作则由框架或库自动完成。


声明式UI编程范式:


image.png


function List(people) {
const listItems = people.map(person =>
<li key={person.id}>
<img
src={getImageUrl(person)}
alt={person.name}
/>

<p>
<b>{person.name}</b>
{' ' + person.profession + ' '}
known for {person.accomplishment}
</p>
</li>

);
return <ul>{listItems}</ul>;
}

优点: 入门难度有所增加,代码更加简洁,带来更高和可维护性,可以直接根据数据预测UI更新


缺点: 入门难度有所增加,灵活性没有命令式UI高;


4.2.1 虚拟DOM


声明式UI强调数据驱动UI更新,一般声明式UI框架中,都还会引入虚拟DOM技术。虚拟DOM(Virtual DOM)是一种在前端开发中广泛使用的技术,它通过JavaScript对象来模拟真实的DOM结构,从而优化Web应用程序的性能和渲染效率。



  • 核心思想:将页面的状态抽象为JavaScript对象表示,避免直接操作真实的DOM,从而提高性能和渲染效率。

  • 工作流程:



    • 初始渲染:首先,通过JavaScript对象(虚拟DOM)表示整个页面的结构。这个虚拟DOM是一个轻量级的映射,保存着真实DOM的层次结构和信息。

    • 更新状态:当应用程序的状态发生变化时,如用户交互或数据更新,虚拟DOM会被修改。这个过程操作的是内存中的JavaScript对象,而不是直接操作真实的DOM。

    • 生成新的虚拟DOM:状态变化后,会生成一个新的虚拟DOM,反映更新后的状态。

    • 对比和更新:通过算法(如Diff算法)将新的虚拟DOM与旧的虚拟DOM进行对比,找出它们之间的差异。

    • 生成变更操作:根据对比结果,找出需要更新的部分,并生成相应的DOM操作(如添加、删除、修改节点等)。

    • 应用变更:将生成的DOM操作应用到真实的DOM上,只更新需要变更的部分,而不是整个页面重新渲染。




virtual-dom为例,虚拟Dom的渲染流程大致如下:


import h from 'virtual-dom/h'
import diff from 'virtual-dom/diff'
import patch from 'virtual-dom/patch'

// 第一步:定义渲染函数,UI = F( state)中的f,
// 开发人员编写渲染模版(react对于是jsx,vue对应的template),由构建工具生成;
function render(count) {
return h('text', { attributes: { count } }, [String(count)])
}

// 第二步:初始化vtree
let tree = render(count) // We need an initial tree

// UI变更
setTimeout(function () {
// 第三步:更新state,重新生成vtree
count++
const newTree = render(count)

// 第四步:对比新旧vtree的差异
const patches = diff(tree, newTree)
console.info('patches', patches)

// 第五步:增量更新dom
// patch(rootNode, patches)

tree = newTree
}, 1000)

相比于命令式UI的开发,声明式UI和虚拟DOM技术结合后,UI渲染过程表示用简单的数据结构就可以表述(第四步骤得到结果序列化),能序列化的好处就是可以很简单完成跨线程处理。


4.3 React Native


声明式UI和虚拟DOM是由React带到开发的视野中。虚拟DOM除了提供声明式UI的高性能渲染能力,它还有一个强大的能力--抽象能力。



4.3.1 组件抽象


在开发者的代码与实际的渲染之间加入一个抽象层,这就可以带来很多可能性。对于React Native 渲染实现:



  • 在IOS平台中则调用Objective-C 的API 去渲染iOS 组件;

  • 在Android平台则调用Java API 去渲染Android 组件,而不是渲染到浏览器DOM 上。


image.png


React Native的渲染是使用不同的平台UI Manager 来渲染UI。因此,React Native对UI开发的基础组件进行整合和对应


React NativeAndroid ViewIOS ViewWeb Dom
<view><ViewGr0up><UIView<div>
<Text><TextView><UITextView><p>
<Image><ImageView><UIImageView><img>

4.3.2 样式渲染


组件结构通过抽象的基础可以完成每个平台的转换。UI界面开发出来结构还需要样式编写。React Native引用了Yoga。Yoga是 C语言写的一个 CSS3/Flexbox 的跨平台 实现的Flexbox布局引擎,意在打造一个跨iOS、Android、Windows平台在内的布局引擎,兼容Flexbox布局方式,让界面布局更加简单。


4.3.3 线程模型


在React Native中,渲染由一个JS线程和原生线程。JS线程负责解析和执行JavaScript代码,而原生线程则负责渲染界面和执行原生操作。JS执行的结果(dom diff)异步通知原生层。


image.png


4.3.3 总结


React Native借助虚拟DOM的抽象能力,把逻辑层的JS代码执行单独抽到JS引擎中执行,不再与UI渲染互斥,可以留更多时间给UI渲染线程。


UI渲染相比浏览器渲染性能提升主要在两点:



  • JS层不再互斥UI渲染;

  • UI渲染由浏览器渲染改成原生渲染;


UI放到Natie层渲染,逻辑放在JS层执行,Natice层与JS层通过JSBridge(24年底会默认替换成JSI,以提高数据通信性能,有兴趣可以去了解)进行通信。


Weex和快应用的实现原理跟React Native类似,主要的差异是在编写声明式UI的DSL,这里就不一一讲解


4.4 微信小程序


微信小程序是从公众号的H5演变而来的。2015年微信对外发布JS-SDK(JS Bridge)提供微信的原生能力(类似早期的phoneGap的),解决了移动网页能力不足的问题。但是,页面加载白屏、网页安全和卡顿问题依旧没被解决。


微信在2017年设计一个全新的系统来解决这些问题,它需要使得所有的开发者都能做到:



  • 快速的加载

  • 更强大的能力

  • 原生的体验

  • 易用且安全的微信数据开放

  • 高效和简单的开发


4.4.1 双线程架构


有了虚拟DOM这个抽象层,UI界面开发的的逻辑层和视图层可以分离。小程序的渲染层和逻辑层分别由两个线程管理(视图层是 WebView,逻辑层是 JS 引擎


image.png



  • 视图层主要负责页面的渲染,每一个页面Page View对应一个Webview(不能超过10个页面栈)。

  • 逻辑层负责js的执行,一个JS执行的沙箱环境;


微信小程序的双线程有如下主要优点:



  1. javascript脚本执行不会抢占ui渲染资源,使整体页面渲染更快;

  2. 每个PageView是由一个webview单独渲染,页面切换效果上更接近原生,比公众号h5网页浏览体验要好;

  3. 安全管控,独立的沙箱环境运行javascript逻辑代码,避免了浏览器的开放api操作dom、跳转页面等,更加安全。


4.4.2 开发的DSL


小程序包含一个描述整体程序的 app 和多个描述各自页面的 page。


个小程序主体部分由三个文件组成,必须放在项目的根目录,如下:


文件必需作用
app.js小程序逻辑
app.json小程序公共配置
app.wxss小程序公共样式表

一个小程序页面由四个文件组成,分别是:


文件类型必需作用
js页面逻辑
wxml页面结构
json页面配置
wxss页面样式表

WXML和WXSS是微信官方创造的DSL,需要进行编译后才能被Webview解析执行。可以从微信开发者工具包文件中找到 wcc 和 wcsc 两个编译工具



  • wcc 编译器可以将 wxml 文件编译成 JS 文件

  • wcsc 编译器可以将 wxss 文件编译成 JS 文件。


 
WXML(WeiXin Markup Language)是框架设计的一套标签语言,结合基础组件事件系统,可以构建出页面的结构。(类比虚拟DOM中的Render函数)


<!--wxml-->
<view>
<text class="text">{{message}}</text>
</view>

将wcc拷贝到当前的index.wxml同级目录, 执行


./wcc -js index.wxml >> wxml.js

将wxml.js的内容复制到浏览器的console中执行后,输入:


$gwx('index.wxml')({
message: 'hello world'
})

可以获得vtree:


{
"tag": "wx-page",
"children": [
{
"tag": "wx-view",
"attr": {},
"children": [
{
"tag": "wx-text",
"attr": {
"class": "text"
},
"children": [
"hello world"
],
"raw": {},
"generics": {}
}
],
"raw": {},
"generics": {}
}
]
}

WXSS (WeiXin Style Sheets)是一套样式语言,用于描述 WXML 的组件样式。(跟CSS类似,增加了rpx相对尺寸,可以参考REM的响应式布局)


page{
display:flex;
background-color: #fff;
}
.wrap{
width:320rpx;
height: 200rpx;
}
.text{
color:red;
font-size:12px
}

将wcsc拷贝到当前的index.wxss同级目录, 执行


./wcsc -js index.wxss >> wxss.js

最后将wxss.js的内容拷贝到浏览器去运行,即可得到:


image.png


(page的样式转化成了body,rpx转成px)


4.4.3 逻辑层和渲染层


逻辑层主要执行app.js和每个页面Page构造器。最终将Page中data修改后的结果通过setData同步给渲染进程。


image.png


逻辑层是一个沙箱的执行环境,该环境不存在DOM API、window、document等对象API和全局对象。换句话来说,小程序相比传统H5是更加安全。小程序中访问用户相关信息是不能像H5直接调用浏览器API,需要经过用户授权才或者由用户操作触发才可以被调用。


小程序的渲染层是在webview执行的,主要将运行wxml和wxss编译后的代码;



  • wxss文件编译成js,之后后会往head中插入style样式

  • wxml编译成声明式UI的render函数,接受逻辑层的data来更新vtree,dom diff ,增量更新dom


render函数中的data由逻辑层调用setData跨线程传给渲染层, 渲染层相比传统的浏览器渲染页面少了渲染前的data生成。相比React Native,渲染层仍然会执行JS(主要虚拟Dom更新)。


image.png


逻辑层和渲染层的在不同平台的实现方式:


运行环境逻辑层渲染层
iOSJavaScriptCoreWKWebView
AndroidV8XWeb(腾讯自研,基于Mobile Chrome内核)
PCChrome内核Chrome内核
小程序开发工具NW.jsChrome WebView

4.4.4 Skyline渲染引擎


小程序早期的渲染层是使用webview,每个PageView对一个webview,内存开销是很多。



Skyline渲染引擎其实可以被看作一个被优化后的webview,并在其内置了更加优秀的动画系统、跨线程传说方案



微信增加了渲染引擎 Skyline,其使用更精简高效的渲染管线,并带来诸多增强特性,让 Skyline 拥有更接近原生渲染的性能体验。


image.png


Skyline 创建了一条渲染线程来负责 Layout, Composite 和 Paint 等渲染任务,并在 AppService 中划出一个独立的上下文,来运行之前 WebView 承担的 JS 逻辑、DOM 树创建等逻辑。这种新的架构相比原有的 WebView 架构,有以下特点:



  • 界面更不容易被逻辑阻塞,进一步减少卡顿

  • 无需为每个页面新建一个 JS 引擎实例(WebView),减少了内存、时间开销

  • 框架可以在页面之间共享更多的资源,进一步减少运行时内存、时间开销

  • 框架的代码之间无需再通过 JSBridge 进行数据交换,减少了大量通信时间开销


 Skyline 的首屏时间比 WebView 快 66%


image.png


Skyline 的内存占用比 WebView 减少 50%


image.png


详细可以参考:developers.weixin.qq.com/miniprogram…


4.4.5 总结


微信小程序采用双线程的架构方案,即解决web困扰已久的安全问题,而且也在一定程度上优化了页面渲染性能。虚拟DOM的抽象能力,使得PageView可以是WebView、React-Native-Like、Flutter 等来渲染


微信小程序也有类似离线包的技术,将用户访问的小程序缓存在微信APP的安装目录中,来解决页面白屏问题。首次加载白屏问题通过native层loading页面来遮盖,因此,小程序首次使用也会有2到3秒的加载过程(小程序分包要求,加载包不能超过2M,加载时间可以做到可控😄)。。


4.5 总结


React Native、Weex、微信小程序、快应用等技术,提供了一整套开发完备的技术和工具来实现混合开发。包括不限于:



  • 平台提供基础UI组件为基础;

  • 声明式UI作为首选,虚拟DOM的抽象能力,UI渲染框架可以多层级多语言实现;

  • 双线程和JSBridge(JSI),使得JS逻辑执行和UI渲染分离;

  • 完整工具类,编译、打包、HMR;

  • 分包,一个应用可以由多个模块包组成;

  • 亚秒级别的热更新能力;


后面出现的Flutter、ArkUI框架也基本围绕这些技术理念进行整合(当然还有编译技术的优化JIT向AOT,带来更快的启动速度)。


(Flutter、ArkTS带来更快的启动速度的技术方案后面再补到文章内吧)


五、发展历程


混合开发的发展史是一段技术革新和演进的过程,它标志着移动应用开发从单一平台向跨平台、高效率的方向转变。


image.png



  • JSBridge让JavaScript拥有原生能力,JSI等技术让JavaScript直面C++,带来更加高效的传输速度;

  • 离线包技术,兼顾加载和留存,SRR仍是很有效优化首屏速度的手段;

  • 分包技术是提高加载速度和开发效率;

  • 声明式U开发范式,加上虚拟Dom抽象能力,解偶上层开发与底层渲染框架,新的渲染框架不断涌现;

  • JSCore引擎的双线程架构,打破逻辑层和UI层间的互斥,即解决Web困扰已久的安全问题,也缓解浏览器渲染性能问题;


作者:azuo
来源:juejin.cn/post/7382051737362284559
收起阅读 »

扫码出入库与web worker

web
我为什么会用到这个呢,那还得从最近项目的一个扫码出入库的需求说起,之前客户的扫码出入库都是c端的,在效率方面没有明显的问题,但是后面这个项目的升级,就把c端的扫码部分摞到了B端了 大体需求是这样,客户用无线扫码枪扫回运单上的条码,然后扫码枪使用HID键盘模式(...
继续阅读 »

我为什么会用到这个呢,那还得从最近项目的一个扫码出入库的需求说起,之前客户的扫码出入库都是c端的,在效率方面没有明显的问题,但是后面这个项目的升级,就把c端的扫码部分摞到了B端了


大体需求是这样,客户用无线扫码枪扫回运单上的条码,然后扫码枪使用HID键盘模式(扫码枪相当于一个键盘),在一个一直聚焦的输入框输入扫到的条码,然后我这边监听到条码调接口录入库,成功后再语音播报扫码结果,同时刷新结果,刷新统计信息。


听上去很简单是不是,想象是美好的,可现实就残酷了,在初始版的时候,功能是做出来了,本地出入库都没问题,但是发到生产就悲催了,乱七八糟的问题


比如



  • 1.扫码枪精度的问题,扫码识别率低下,扫10次才能正确识别1次

  • 2.扫出来的码,断码,原本以为扫码枪扫一次就等同于我复制一个条码进输入框,可结果是扫码枪一次扫入,输入框接受的条码就像一个字符串流一样,一个字符一个字符进入的,这就导致中间间隔稍微长一点,就被错误的识别为另外一个条码(扫码是多个码连续扫入的)

  • 3.语音播报延迟,经常会有语音播放不出或者播放一半,这个..


这个就很让人无语,明明本地啥问题也没有


第一个问题,扫码枪精度,确实是有,因为我做的时候拿的扫码枪是一个有线的扫码枪,那识别率才叫一个高,准确率差不多95%,几乎没遇到解码啥的问题,可换成无线的扫码枪就傻眼,第一个问题就很烦,想到几千个客户没办法统一更换扫码枪,于是就想想优化一下条码编码呢,
我这边条码是用的jsBarcode组件,默认的编码类型CODE128,嗯~~问题会不会出在条码规范上呢


我去查了一下,条码的编码规范大致有以下几种


条码类型类别描述常见应用编码长度
UPC-A1D通用产品代码,常见于零售业零售商品12位数字
UPC-E1DUPC-A的压缩版本小型零售商品6位数字
EAN-131D欧洲商品编号,国际通用图书、零售商品13位数字
EAN-81DEAN-13的压缩版本小型商品8位数字
Code 391D可变长度,包含字母、数字和特殊字符工业、政府可变长度
Code 1281D高密度条码,表示所有128个ASCII字符物流、运输可变长度
Interleaved 2 of 5 (ITF)1D数字条码,每两个数字组成一对交错编码分销、仓储偶数位数字
QR Code2D可存储大量数据,包括文字、数字、二进制数据和汉字支付、信息分享、广告可变长度
Data Matrix2D高密度编码,适用于小型物品标识电子元器件、医疗设备可变长度
PDF4172D可编码大量数据身-份-正件、运输标签可变长度
Aztec Code2D高容错性,适用于票务和登机牌票务、登机牌可变长度

我这里着重说说CODE39和CODE128;我发现CODE39生成的条码比CODE128生成的长很多,我这把无线扫码枪扫很久都扫不出来,识别超慢,这个很奇怪,之前客户C端系统找技术查了一下,编码规范是CODE39,我就懵逼了,都是CODE39,为啥我们生成的码就识别这么慢,捣鼓了很久也没个结果,如果有哪位知道的可以给我说一下,就索性放弃这种编码模式,改用CODE128吧,查了一下,这是一种效率更高的编码方式,CODE39条码较长的主要原因在于它的编码效率较低,每个字符占用的空间较大,而CODE128通过更加紧凑和高效的编码方式,能够在同样的内容下生成更短的条码,于是撺掇同事把所有的条码都用CODE128生成,至此,扫码枪识别效率低的问题算事过去了


然后就是第二点,扫出来的码,断码问题,这个也因为换了短码好那么一点,可扫出来也经常有解码内容变长,的问题,暂时还在想办法优化


最后就是语音播报延迟,卡壳,甚至没有语音的情况,这个问题比较恼火,我这边组件是使用的开源库howler.js,这个库的优点就是兼容性好,可以播放包括mp3, opus, ogg, wav, aac, m4a, m4b, mp4, webm, 等多种格式,而且还支持分轨sprite播放,这个是我的最初的代码


import config from "./config";
import "./lib/howler.min";
const ENV = import.meta.env;

class VoiceReport {
public list = [];
constructor() {
this.initVoice();
}
// 目录放在@/assets/voice/ 下面
public voiceList: any = import.meta.globEager("@/assets/voice/*.mp3");
public voiceNameList = Object.keys(this.voiceList);
// 初始化语音播报器列表
initVoice = () => {
config.forEach((v) => {
const item = {
name: `Ref${v.codeType}${v.codeKey}`,
code: v.codeKey,
codeName: v.codeName,
voice: "",
};
const voiceIndex = this.voiceNameList.findIndex((voice) =>
String(voice).includes(v.codeKey)
);
if (voiceIndex > -1) {
item.path = this.voiceNameList[voiceIndex];
item.voice = this.voiceList[this.voiceNameList[voiceIndex]];
}
this.list.push(item);
});
};
// 播放
play = (code: string) => {
const Stream = this.list.find((v) => v.code == code);
let StreamVoide = null;
if (ENV?.DEV) {
StreamVoide = Stream?.path;
} else {
StreamVoide = Stream?.voice?.default;
}
// 提供的条码不在列表中
if (!StreamVoide) return;
try {
const sound = new Howl({
src: [StreamVoide],
volume: 1.0,
html5: true,
onplayerror: (e) => {
console.log("error", e);
},
});
sound.play();
} catch (e) {
console.log(e);
}
};
}

export default VoiceReport;


这个倒是能放,可能不能优化呢


我首先想到的是就从播放器本身优化呢,我想着会不会是加载的延迟或者加载文件过多,想着将所有的文件进行合并,再生成sprite信息,弄是弄了,可是不论如何就是load报错,我再把这个多个mp3合并成一个文件@/assets/voice/fullStack.mp3,进行生成sprite,来加载,加载是加载上来了,可同样遇到播放错误,播放的track根本不是我期望的那个


这个是错误代码:



import config from "./config";
import "./lib/howler.min";
import fullVoice from "@/assets/voice/fullStack.mp3";
const ENV = import.meta.env;

class player {
public list: any = [];
public player: any = {};
constructor() {
this.initVoice();
}
// 目录放在@/assets/voice/ 下面
public voiceList: any = import.meta.globEager("@/assets/voice/*.mp3");
public fullVoice: any = fullVoice;
public voiceNameList = Object.keys(this.voiceList);
public sprite: any = {};
public streamVoide: any = [];
// 时间戳转换为秒
timeStringToSeconds = (timeStr: string) => {
const parts = timeStr.split(":");
const hours = parseInt(parts[0]);
const minutes = parseInt(parts[1]);
const seconds = parseInt(parts[2]);

return hours * 3600 + minutes * 60 + seconds;
}
// 初始化语音播报器列表
initVoice = () => {
config.forEach((v, index) => {
const item = {
name: `Ref${v.codeType}${v.codeKey}`,
code: v.codeKey,
codeName: v.codeName,
voice: {},
path: "",
duration: this.timeStringToSeconds(v.duration ?? 0) * 1000,
durationStart: 0,
durationEnd: 0,
};
item.durationStart = !index ? 0 : this.list[index - 1].durationEnd;
item.durationEnd = item.durationStart + item.duration;
this.sprite[v.codeKey] = [item.durationStart, item.durationEnd];
const voiceIndex = this.voiceNameList.findIndex((voice) =>
String(voice).includes(v.codeKey)
);
if (voiceIndex > -1) {
item.path = this.voiceNameList[voiceIndex];
item.voice = this.voiceList[this.voiceNameList[voiceIndex]];
}
this.list.push(item);
/* eslint-disable */
// @ts-ignore
this.streamVoide.push(ENV?.DEV ? item.path : item.voice?.default);
});
/* eslint-disable */
// @ts-ignore
this.player = new Howl({
src: this.streamVoide,
volume: 1.0,
html5: true,
sprite: this.sprite,
onplayerror: (e: any) => {
console.log("play error", e);
},
onload: (e: any) => {
console.log("error", e);
}
});
window.player = this.player;
console.log(this.sprite, this.player, fullVoice)
};
// 播放
play = (code: string) => {
try {
this.player.play(code);
} catch (e) {
console.log(e);
}
};
}

export default player;

到现在还在持续找解决方案中,
最后,不得不把希望寄托在异步任务请求导致阻塞主线程这个猜想上,因为每完成一次扫码,会发起三个请求



  • 入库请求

  • 刷新结果列表请求

  • 刷新统计请求


这么多请求一起,接口稍微一慢就有可能导致播放卡顿的问题
这个在我经过一段时间的搜索之后发现,发现webworker可以处理这个问题


web worker

根据MDN的说法

Web Workers 是 Web 内容在后台线程中运行脚本的一种简单方法。工作线程可以在不干扰用户界面的情况下执行任务。此外,他们还可以使用 fetch() 或 XMLHttpRequest API 发出网络请求。创建后,工作人员可以通过将消息发布到该代码指定的事件处理程序来向创建它的 JavaScript 代码发送消息(反之亦然)。


既然是独立于主线程之外的一个,那就不可避免的会遇到身份验证和通信的问题,对于发起的请求没有携带身份信息,这个好办,就自己在封装一个axios方法fetch,将身份信息传过去ok,这里主要贴一下worker的内容,也很简单


import type { WorkerMessageDataType } from "../types/types";
import fetch from "@/utils/fetch";
import { throttle } from "lodash";
let Ajax: any = null;

// 从主线程接受数据
self.onmessage = function (e: WorkerMessageDataType) {
console.log("Worker: 收到请求", e);
const type = e.data?.type || "";
const data = e.data?.data || {};
// 一定要初始化
if (type == "init") {
const headers: any = e.data?.headers;
Ajax = fetch(headers);
}
// 请求刷新统计数据
if (type == "refreshScanCountData") refreshScanCountData();
// 请求刷新列表扫码结果
if (type == "refreshDataList") refreshDataList();
// 请求入库
if (type == "checkAddIntoStock") checkAddIntoStock(data);
};

// 向主线程发送数据
const sedData = (type: string, data: object) => {
const param = {
type,
data: data || {},
};
self.postMessage(param);
};

// 刷新统计数据,查询统计信息api
const refreshScanCountData = throttle(() => {
Ajax({
method: "post",
url: `/api/CountStatistics`,
data: {},
}).then((res: any) => {
sedData("refreshScanCountData", res);
});
}, 500);

// 刷新扫码结果数据
const refreshDataList = throttle(() => {
Ajax({
method: "post",
url: `/api/scanToStorage/page`,
data: {},
}).then((res: any) => {
sedData("refreshDataList", res);
});
}, 500);

// 请求入库
const checkAddIntoStock = (data: { barcode: string; [x: string]: any }) => {
Ajax({
method: "post",
url: `/api/scanToStorage`,
data,
})
.then((res: any) => {
// 刷新统计数据
refreshScanCountData();
// 刷新列表
refreshDataList();
sedData("checkAddIntoStock", {
barcode: data.barcode,
...res,
status: true,
});
})
.catch(() => {
sedData("checkAddIntoStock", {
barcode: data.barcode,
status: false,
});
});
};

在主线程页面写一个方法,初始化一下这个worker


// 加载worker
const initWorker = () => {
const headers = {
Authorization: "bearer " + sessionStorage.getItem("token"),
token: sessionStorage.getItem("token"),
currRoleId: sessionStorage.getItem("roleId"),
};
// 初始化,加入身份信息
WebWorker.postMessage({ type: "init", headers });
// 从worker接受消息
WebWorker.onmessage = (e) => {
console.log("Main script: Received result", e.data);
const type = e.data?.type || "";
const data = e.data?.data || {};

// 异步更新统计信息
if (type == "refreshScanCountData") {
ScanCountData.value = data;
}
// 刷新表格数据
if (type == "refreshDataList") {
dataTable.value.updateData(data);
}
};
};


这样就可以了,即便是这样,依然还有好多问题没解决,这个是我的第一篇文章,难免有错误疏漏,这个需求并没结束,我还会持续跟进更新的


作者:kiohang
来源:juejin.cn/post/7380342160581492747
收起阅读 »

用空闲时间做了一个小程序-二维码生成器

web
一直在摸鱼中赚钱的大家好呀~ 先向各位鱼友们汇报一下情况,目前小程序已经有900+的鱼友注册使用过。虽然每天都有新的鱼友注册,但是鱼友增长的还很缓慢。自从国庆前的文字转语音的工具上线到现在已经将近有1个月没有更新小程序了。但是今天终终终终终于又有个小工具上线了...
继续阅读 »

一直在摸鱼中赚钱的大家好呀~


先向各位鱼友们汇报一下情况,目前小程序已经有900+的鱼友注册使用过。虽然每天都有新的鱼友注册,但是鱼友增长的还很缓慢。自从国庆前的文字转语音的工具上线到现在已经将近有1个月没有更新小程序了。但是今天终终终终终于又有个小工具上线了,希望这个小工具可以帮助到更多的鱼友们(没错就是你们)


这次更新的工具是一个二维码生成器,虽然很多小程序存在这个工具,但是本人也是想尝试一下实现这个工具。老规矩,先来看下知名UI设计师设计的页面。







同样在工具tab页中增加了二维码生成器模块。从UI图中可以看出第一个表单页面不是很难,就是一个文本框、两个颜色选择、一个图片上传。这个页面我在开发中也是很快就完成了,没有什么技术含量。


当我做到颜色选择弹窗的时候是想从网上找一个现成的插件。但是找了半天没有找到合适的,只能自己手动开发一个。既然要做颜色选择器的功能就要先了解一下颜色的两种格式 (我这边的实现就这两种格式)


颜色的HEX格式

颜色的HEX格式是#+六位数字/字母,其中六位数字/字母是一种十六进制的表达方式。这六位分别两个一组,从左到右分别表示绿00表示最小,十进制是0FF表示最大,十进制是255。通俗点讲,某个颜色的数值越大,包含这个颜色就越多。如:#000000-黑色、#FFFFFF-白色、#FF0000-红色、#00FF00-绿色、#0000FF-蓝色。


颜色的RGB格式

颜色的RGB格式是rgb(0-255,0-255,0-255), 其中0-255就是HEX格式的十进制表达方式。这三个数值从左到右分别表示绿0表示最小;255表示最大。通俗点讲,某个颜色的数值越大,包含这个颜色就越多。如:rgb(0,0,0)-黑色、rgb(255,255,255)-白色、rgb(255,0,0)-红色、rgb(0,255,0)-绿色、rgb(0,0,255)-蓝色。


有了上面的概念,我的思路也就出来了。让用户分别选择这三种颜色的数值,然后通过用户选择的三种颜色的数值转成目标颜色,就可以完成颜色选择的功能。思路出来了之后就告知了UI,然后按照我的思路将效果图出了出来 (没错,就是先实现后出图)。实现中主要使用了vant-ui组件库的popupslider两个组件 (聪明人都喜欢用现成的)。贴一下部分实现代码:


show="{{ show }}" 
title="展示弹出层"
position="bottom"
bind:close="cancelHandle"
custom-style="background-color: #F3F3F9;border-radius: 40rpx 40rpx 0rpx 0rpx;"
root-portal>
class="color-popup">
class="popup-header flex flex_j_c--space-between flex_a_i--center">
class="flex-item_f-1">
class="title flex-item_f-1">{{ title }}
class="flex-item_f-1 flex flex_j_c--flex-end">
name="cross" size="32rpx" bind:tap="cancelHandle" />


class="color-picker" wx:for="{{ pickers }}" wx:key="index" wx:if="{{ index !== 3 }}">
class="color-picker-label">{{ item.label }}
class="flex flex_a_i--center">
class="slider-wrap flex-item_f-1 {{ item.field }}">
value="{{ item.value }}" min="{{ 0 }}" max="{{ 255 }}" data-index="{{ index }}" bind:change="changeHandle" bind:drag="changeHandle" custom-class="slider" bar-height="60rpx" active-color="transparent" use-button-slot>
class="slider-button" slot="button">


class="slider-value">{{ item.value }}


class="color-preview-box flex flex_a_i--center">
class="preview-box-wrap">
class="preview-box" style="background-color: {{ rgbaStyle }};">
class="preview-label">颜色预览

class="presets-box-wrap flex-item_f-1 flex flex_j_c--space-between">
class="presets-box flex flex_j_c--center flex_a_i--center {{ rgbaStyle === item.rgbaStyle ? 'active' : '' }}" wx:for="{{ presets }}" wx:key="index" style="background-color: {{ item.rgbaStyle }};" data-row="{{ item }}" bind:tap="chooseHandle">
class="active-box">



class="confirm-wrap flex">
class="hex-box flex flex_a_i--center flex_j_c--space-between">
#
{{ hex }}

class="confirm-button-box flex-item_f-1">
type="primary" custom-class="confirm-button" bind:click="confirmHandle" round>确定





import { rgb2Hex } from '../../utils/util'

const presets = [
[0, 0, 0, 255], [102, 102, 102, 255],
[0, 95, 244, 255], [100, 196, 102, 255],
[247, 206, 70, 255], [235, 77, 61, 255],
]

Component({
options: {
addGlobalClass: true
},
properties: {
show: {
type: Boolean,
value: false
},
title: {
type: String,
value: ''
},
value: {
type: Array,
value: [0, 0, 0, 255],
observer: function(val) {
const { pickers } = this.data
if(val.length) {
this.setData({
pickers: pickers.map((item, index) => {
return {...item, value: val[index]}
}),
})
this.setColor(val)
} else {
this.setData({
pickers: pickers.map((item, index) => {
return {...item, value: index === 3 ? 255 : 0}
}),
})
const rgba = [0, 0, 0, 255]
this.setColor(rgba)
}
}
}
},
data: {
pickers: [
{ field: 'r', label: '红色', value: 0 },
{ field: 'g', label: '绿色', value: 0 },
{ field: 'b', label: '蓝色', value: 0 },
{ field: 'a', label: '透明度', value: 255 },
],
rgba: [],
hex: '',
rgbaStyle: '',
presets: [
...presets.map(rgba => {
return {
rgba,
rgbaStyle: `rgba(${ rgba.join(',') })`
}
})
]
},
methods: {
changeHandle(e) {
const { detail, currentTarget: { dataset: { index } } } = e
const key = `pickers[${ index }].value`
this.setData({
[key]: typeof detail === 'object' ? detail.value : detail
})
const rgba = this.data.pickers.map(item => item.value)
this.setColor(rgba)
},
chooseHandle(e) {
const { rgba } = e.currentTarget.dataset.row
this.setData({
pickers: this.data.pickers.map((item, index) => {
return {...item, value: rgba[index]}
}),
})
this.setColor(rgba)
},
// 设置颜色
setColor(rgba) {
const hex = rgb2Hex(...rgba)
const rgbaStyle = `rgba(${ rgba.join(',') })`
this.setData({ rgba, hex: hex.replace('#', ''), rgbaStyle })
},
confirmHandle(e) {
this.triggerEvent('confirm', { rgba: this.data.rgba, rgbaStyle: this.data.rgbaStyle })
},
cancelHandle() {
this.triggerEvent('cancel')
},
}
})

到此颜色选择器的组件已经实现了,还剩下一个预览下载的页面。我这边的实现并不是直接页面跳转,因为这边预览之后返回是希望还保留预览之前的数据的。如果直接离开当前页面并清除了数据,不符合用户预期的。所以使用了一个假页。微信小程序提供了一个 page-container 的页面容器,效果类似于 popup 弹出层,页面内存在该容器时,当用户进行返回操作,关闭该容器不关闭页面。


如果二维码中含中文的静态码使用微信扫描后是无法正常展示内容的(后期安排上二维码解析的功能)


感谢大家观看我今日的水文,文笔实在是不行,欢迎鱼友们给小程序提提意见,或者有什么有趣的想法也可以与楼主提一提。最后希望大家到我的小程序来多坐坐。





作者:拖孩
来源:juejin.cn/post/7384350475736989731
收起阅读 »

多级校验、工作流,这样写代码才足够优雅!

责任链模式,简而言之,就是将多个操作组装成一条链路进行处理。 请求在链路上传递,链路上的每一个节点就是一个处理器,每个处理器都可以对请求进行处理,或者传递给链路上的下一个处理器处理。 责任链模式的应用场景,在实际工作中,通常有如下两种应用场景。 操作需要经...
继续阅读 »

责任链模式,简而言之,就是将多个操作组装成一条链路进行处理。


请求在链路上传递,链路上的每一个节点就是一个处理器,每个处理器都可以对请求进行处理,或者传递给链路上的下一个处理器处理。


图片


责任链模式的应用场景,在实际工作中,通常有如下两种应用场景。



  • 操作需要经过一系列的校验,通过校验后才执行某些操作。

  • 工作流。企业中通常会制定很多工作流程,一级一级的去处理任务。


下面通过两个案例来学习一下责任链模式。


案例一:创建商品多级校验场景


以创建商品为例,假设商品创建逻辑分为以下三步完成:


①创建商品、


②校验商品参数、


③保存商品。


第②步校验商品又分为多种情况的校验,必填字段校验、规格校验、价格校验、库存校验等等。


这些检验逻辑像一个流水线,要想创建出一个商品,必须通过这些校验。如下流程图所示:


图片


图片


伪代码如下:


创建商品步骤,需要经过一系列的参数校验,如果参数校验失败,直接返回失败的结果;通过所有的参数校验后,最终保存商品信息。


图片


图片


如上代码看起来似乎没什么问题,它非常工整,而且代码逻辑很清晰。



PS:我没有把所有的校验代码都罗列在一个方法里,那样更能产生对比性,但我觉得抽象并分离单一职责的函数应该是每个程序员最基本的规范!



但是随着业务需求不断地叠加,相关的校验逻辑也越来越多,新的功能使代码越来越臃肿,可维护性较差。


更糟糕的是,这些校验组件不可复用,当你有其他需求也需要用到一些校验时,你又变成了Ctrl+C , Ctrl+V程序员,系统的维护成本也越来越高。如下图所示:


图片


图片


伪代码同上,这里就不赘述了。


终于有一天,你忍无可忍了,决定重构这段代码。


使用责任链模式优化:创建商品的每个校验步骤都可以作为一个单独的处理器,抽离为一个单独的类,便于复用。


这些处理器形成一条链式调用,请求在处理器链上传递,如果校验条件不通过,则处理器不再向下传递请求,直接返回错误信息;若所有的处理器都通过检验,则执行保存商品步骤。


图片


图片


案例一实战:责任链模式实现创建商品校验


UML图:一览众山小


图片


图片


AbstractCheckHandler表示处理器抽象类,负责抽象处理器行为。其有3个子类,分别是:



  • NullValueCheckHandler:空值校验处理器

  • PriceCheckHandler:价格校验处理

  • StockCheckHandler:库存校验处理器


AbstractCheckHandler 抽象类中, handle()定义了处理器的抽象方法,其子类需要重写handle()方法以实现特殊的处理器校验逻辑;


protected ProductCheckHandlerConfig config 是处理器的动态配置类,使用protected声明,每个子类处理器都持有该对象。


该对象用于声明当前处理器、以及当前处理器的下一个处理器nextHandler,另外也可以配置一些特殊属性,比如说接口降级配置、超时时间配置等。


AbstractCheckHandler nextHandler 是当前处理器持有的下一个处理器的引用,当前处理器执行完毕时,便调用nextHandler执行下一处理器的handle()校验方法;


protected Result next() 是抽象类中定义的,执行下一个处理器的方法,使用protected声明,每个子类处理器都持有该对象。


当子类处理器执行完毕(通过)时,调用父类的方法执行下一个处理器nextHandler。


HandlerClient 是执行处理器链路的客户端,HandlerClient.executeChain()方法负责发起整个链路调用,并接收处理器链路的返回值。


商品参数对象:保存商品的入参


ProductVO是创建商品的参数对象,包含商品的基础信息。


并且其作为责任链模式中多个处理器的入参,多个处理器都以ProductVO为入参进行特定的逻辑处理。


实际业务中,商品对象特别复杂。咱们化繁为简,简化商品参数如下:


/**
 * 商品对象
 */

@Data
@Builder
public class ProductVO {
    /**
     * 商品SKU,唯一
     */

    private Long skuId;
    /**
     * 商品名称
     */

    private String skuName;
    /**
     * 商品图片路径
     */

    private String Path;
    /**
     * 价格
     */

    private BigDecimal price;
    /**
     * 库存
     */

    private Integer stock;
}

抽象类处理器:抽象行为,子类共有属性、方法


AbstractCheckHandler:处理器抽象类,并使用@Component注解注册为由Spring管理的Bean对象,这样做的好处是,我们可以轻松的使用Spring来管理这些处理器Bean。


/**
 * 抽象类处理器
 */

@Component
public abstract class AbstractCheckHandler {

    /**
     * 当前处理器持有下一个处理器的引用
     */

    @Getter
    @Setter
    protected AbstractCheckHandler nextHandler;


    /**
     * 处理器配置
     */

    @Setter
    @Getter
    protected ProductCheckHandlerConfig config;

    /**
     * 处理器执行方法
     * @param param
     * @return
     */

    public abstract Result handle(ProductVO param);

    /**
     * 链路传递
     * @param param
     * @return
     */

    protected Result next(ProductVO param) {
        //下一个链路没有处理器了,直接返回
        if (Objects.isNull(nextHandler)) {
            return Result.success();
        }

        //执行下一个处理器
        return nextHandler.handle(param);
    }

}

在AbstractCheckHandler抽象类处理器中,使用protected声明子类可见的属性和方法。


使用 @Component注解,声明其为Spring的Bean对象,这样做的好处是可以利用Spring轻松管理所有的子类,下面会看到如何使用。


抽象类的属性和方法说明如下:



  • public abstract Result handle():表示抽象的校验方法,每个处理器都应该继承AbstractCheckHandler抽象类处理器,并重写其handle方法,各个处理器从而实现特殊的校验逻辑,实际上就是多态的思想。

  • protected ProductCheckHandlerConfig config:表示每个处理器的动态配置类,可以通过“配置中心”动态修改该配置,实现处理器的“动态编排”和“顺序控制”。配置类中可以配置处理器的名称、下一个处理器、以及处理器是否降级等属性。

  • protected AbstractCheckHandler nextHandler:表示当前处理器持有下一个处理器的引用,如果当前处理器handle()校验方法执行完毕,则执行下一个处理器nextHandler的handle()校验方法执行校验逻辑。

  • protected Result next(ProductVO param):此方法用于处理器链路传递,子类处理器执行完毕后,调用父类的next()方法执行在config 配置的链路上的下一个处理器,如果所有处理器都执行完毕了,就返回结果了。


ProductCheckHandlerConfig配置类 :


/**
 * 处理器配置类
 */

@AllArgsConstructor
@Data
public class ProductCheckHandlerConfig {
    /**
     * 处理器Bean名称
     */

    private String handler;
    /**
     * 下一个处理器
     */

    private ProductCheckHandlerConfig next;
    /**
     * 是否降级
     */

    private Boolean down = Boolean.FALSE;
}

子类处理器:处理特有的校验逻辑


AbstractCheckHandler抽象类处理器有3个子类分别是:



  • NullValueCheckHandler:空值校验处理器

  • PriceCheckHandler:价格校验处理

  • StockCheckHandler:库存校验处理器


各个处理器继承AbstractCheckHandler抽象类处理器,并重写其handle()处理方法以实现特有的校验逻辑。


NullValueCheckHandler:空值校验处理器。针对性校验创建商品中必填的参数。如果校验未通过,则返回错误码ErrorCode,责任链在此截断(停止),创建商品返回被校验住的错误信息。注意代码中的降级配置!


super.getConfig().getDown()是获取AbstractCheckHandler处理器对象中保存的配置信息,如果处理器配置了降级,则跳过该处理器,调用super.next()执行下一个处理器逻辑。


同样,使用@Component注册为由Spring管理的Bean对象,


/**
 * 空值校验处理器
 */

@Component
public class NullValueCheckHandler extends AbstractCheckHandler{

    @Override
    public Result handle(ProductVO param) {
        System.out.println("空值校验 Handler 开始...");
        
        //降级:如果配置了降级,则跳过此处理器,执行下一个处理器
        if (super.getConfig().getDown()) {
            System.out.println("空值校验 Handler 已降级,跳过空值校验 Handler...");
            return super.next(param);
        }
        
        //参数必填校验
        if (Objects.isNull(param)) {
            return Result.failure(ErrorCode.PARAM_NULL_ERROR);
        }
        //SkuId商品主键参数必填校验
        if (Objects.isNull(param.getSkuId())) {
            return Result.failure(ErrorCode.PARAM_SKU_NULL_ERROR);
        }
        //Price价格参数必填校验
        if (Objects.isNull(param.getPrice())) {
            return Result.failure(ErrorCode.PARAM_PRICE_NULL_ERROR);
        }
        //Stock库存参数必填校验
        if (Objects.isNull(param.getStock())) {
            return Result.failure(ErrorCode.PARAM_STOCK_NULL_ERROR);
        }
        
        System.out.println("空值校验 Handler 通过...");
        
        //执行下一个处理器
        return super.next(param);
    }
}

PriceCheckHandler:价格校验处理。


针对创建商品的价格参数进行校验。这里只是做了简单的判断价格>0的校验,实际业务中比较复杂,比如“价格门”这些防范措施等。


/**
 * 价格校验处理器
 */

@Component
public class PriceCheckHandler extends AbstractCheckHandler{
    @Override
    public Result handle(ProductVO param) {
        System.out.println("价格校验 Handler 开始...");

        //非法价格校验
        boolean illegalPrice =  param.getPrice().compareTo(BigDecimal.ZERO) <= 0;
        if (illegalPrice) {
            return Result.failure(ErrorCode.PARAM_PRICE_ILLEGAL_ERROR);
        }
        //其他校验逻辑...

        System.out.println("价格校验 Handler 通过...");

        //执行下一个处理器
        return super.next(param);
    }
}

StockCheckHandler:库存校验处理器。


针对创建商品的库存参数进行校验。


/**
 * 库存校验处理器
 */

@Component
public class StockCheckHandler extends AbstractCheckHandler{
    @Override
    public Result handle(ProductVO param) {
        System.out.println("库存校验 Handler 开始...");

        //非法库存校验
        boolean illegalStock = param.getStock() < 0;
        if (illegalStock) {
            return Result.failure(ErrorCode.PARAM_STOCK_ILLEGAL_ERROR);
        }
        //其他校验逻辑..

        System.out.println("库存校验 Handler 通过...");

        //执行下一个处理器
        return super.next(param);
    }
}

客户端:执行处理器链路


HandlerClient客户端类负责发起整个处理器链路的执行,通过executeChain()方法。


如果处理器链路返回错误信息,即校验未通过,则整个链路截断(停止),返回相应的错误信息。


public class HandlerClient {

  public static Result executeChain(AbstractCheckHandler handler, ProductVO param) {
      //执行处理器
      Result handlerResult = handler.handle(param);
      if (!handlerResult.isSuccess()) {
          System.out.println("HandlerClient 责任链执行失败返回:" + handlerResult.toString());
          return handlerResult;
      }
      return Result.success();
  }
}

以上,责任链模式相关的类已经创建好了。


接下来就可以创建商品了。


创建商品:抽象步骤,化繁为简


createProduct()创建商品方法抽象为2个步骤:①参数校验、②创建商品。


参数校验使用责任链模式进行校验,包含:空值校验、价格校验、库存校验等等,只有链上的所有处理器均校验通过,才调用saveProduct()创建商品方法;否则返回校验错误信息。


createProduct()创建商品方法中,通过责任链模式,我们将校验逻辑进行解耦。createProduct()创建商品方法中不需要关注都要经过哪些校验处理器,以及校验处理器的细节。


/**
 * 创建商品
 * 
@return
 */

@Test
public Result createProduct(ProductVO param) {

    //参数校验,使用责任链模式
    Result paramCheckResult = this.paramCheck(param);
    if (!paramCheckResult.isSuccess()) {
        return paramCheckResult;
    }

    //创建商品
    return this.saveProduct(param);
}

参数校验:责任链模式


参数校验paramCheck()方法使用责任链模式进行参数校验,方法内没有声明具体都有哪些校验,具体有哪些参数校验逻辑是通过多个处理器链传递的。如下:


/**
 * 参数校验:责任链模式
 * 
@param param
 * 
@return
 */

private Result paramCheck(ProductVO param) {

    //获取处理器配置:通常配置使用统一配置中心存储,支持动态变更
    ProductCheckHandlerConfig handlerConfig = this.getHandlerConfigFile();

    //获取处理器
    AbstractCheckHandler handler = this.getHandler(handlerConfig);

    //责任链:执行处理器链路
    Result executeChainResult = HandlerClient.executeChain(handler, param);
    if (!executeChainResult.isSuccess()) {
        System.out.println("创建商品 失败...");
        return executeChainResult;
    }

    //处理器链路全部成功
    return Result.success();
}

paramCheck()方法步骤说明如下:


👉 步骤1:获取处理器配置。


通过getHandlerConfigFile()方法获取处理器配置类对象,配置类保存了链上各个处理器的上下级节点配置,支持流程编排、动态扩展。


通常配置是通过Ducc(京东自研的配置中心)、Nacos(阿里开源的配置中心)等配置中心存储的,支持动态变更、实时生效。


基于此,我们便可以实现校验处理器的编排、以及动态扩展了。


我这里没有使用配置中心存储处理器链路的配置,而是使用JSON串的形式去模拟配置,大家感兴趣的可以自行实现。


/**
 * 获取处理器配置:通常配置使用统一配置中心存储,支持动态变更
 * @
return
 */

private ProductCheckHandlerConfig getHandlerConfigFile() {
    //配置中心存储的配置
    String configJson = "{"handler":"nullValueCheckHandler","down":true,"next":{"handler":"priceCheckHandler","next":{"handler":"stockCheckHandler","next":null}}}";
    //转成Config对象
    ProductCheckHandlerConfig handlerConfig = JSON.parseObject(configJson, ProductCheckHandlerConfig.class);
    return handlerConfig;
}

ConfigJson存储的处理器链路配置JSON串,在代码中可能不便于观看,我们可以使用json.cn等格式化看一下,如下,配置的整个调用链路规则特别清晰。


图片


图片


getHandlerConfigFile()类获到配置类的结构如下,可以看到,就是把在配置中心储存的配置规则,转换成配置类ProductCheckHandlerConfig对象,用于程序处理。



注意,此时配置类中存储的仅仅是处理器Spring Bean的name而已,并非实际处理器对象。



图片


图片


接下来,通过配置类获取实际要执行的处理器。


👉 步骤2:根据配置获取处理器。


上面步骤1通过getHandlerConfigFile()方法获取到处理器链路配置规则后,再调用getHandler()获取处理器。


getHandler()参数是如上ConfigJson配置的规则,即步骤1转换成的ProductCheckHandlerConfig对象;


根据ProductCheckHandlerConfig配置规则转换成处理器链路对象。代码如下:


 * 使用Spring注入:所有继承了AbstractCheckHandler抽象类的Spring Bean都会注入进来。Map的Key对应Bean的name,Value是name对应相应的Bean
 */
@Resource
private Map handlerMap;

/**
 * 获取处理器
 * 
@param config
 * 
@return
 */

private AbstractCheckHandler getHandler (ProductCheckHandlerConfig config) {
    //配置检查:没有配置处理器链路,则不执行校验逻辑
    if (Objects.isNull(config)) {
        return null;
    }
    //配置错误
    String handler = config.getHandler();
    if (StringUtils.isBlank(handler)) {
        return null;
    }
    //配置了不存在的处理器
    AbstractCheckHandler abstractCheckHandler = handlerMap.get(config.getHandler());
    if (Objects.isNull(abstractCheckHandler)) {
        return null;
    }
    
    //处理器设置配置Config
    abstractCheckHandler.setConfig(config);
    
    //递归设置链路处理器
    abstractCheckHandler.setNextHandler(this.getHandler(config.getNext()));

    return abstractCheckHandler;
}

👉 👉 步骤2-1:配置检查。


代码14~27行,进行了配置的一些检查操作。如果配置错误,则获取不到对应的处理器。代码23行handlerMap.get(config.getHandler())是从所有处理器映射Map中获取到对应的处理器Spring Bean。



注意第5行代码,handlerMap存储了所有的处理器映射,是通过Spring @Resource注解注入进来的。注入的规则是:所有继承了AbstractCheckHandler抽象类(它是Spring管理的Bean)的子类(子类也是Spring管理的Bean)都会注入进来。



注入进来的handlerMap中 Map的Key对应Bean的name,Value是name对应的Bean实例,也就是实际的处理器,这里指空值校验处理器、价格校验处理器、库存校验处理器。如下:


图片


图片


这样根据配置ConfigJson(👉 步骤1:获取处理器配置)中handler:"priceCheckHandler"的配置,使用handlerMap.get(config.getHandler())便可以获取到对应的处理器Spring Bean对象了。


👉 👉 步骤2-2:保存处理器规则。


代码29行,将配置规则保存到对应的处理器中abstractCheckHandler.setConfig(config),子类处理器就持有了配置的规则。


👉 👉 步骤2-3:递归设置处理器链路。


代码32行,递归设置链路上的处理器。


//递归设置链路处理器 abstractCheckHandler.setNextHandler(this.getHandler(config.getNext()));

这一步可能不太好理解,结合ConfigJson配置的规则来看,似乎就很很容易理解了。


图片


图片


由上而下,NullValueCheckHandler 空值校验处理器通过setNextHandler()方法设置自己持有的下一节点的处理器,也就是价格处理器PriceCheckHandler。


接着,PriceCheckHandler价格处理器,同样需要经过步骤2-1配置检查、步骤2-2保存配置规则,并且最重要的是,它也需要设置下一节点的处理器StockCheckHandler库存校验处理器。


StockCheckHandler库存校验处理器也一样,同样需要经过步骤2-1配置检查、步骤2-2保存配置规则,但请注意StockCheckHandler的配置,它的next规则配置了null,这表示它下面没有任何处理器要执行了,它就是整个链路上的最后一个处理节点。


通过递归调用getHandler()获取处理器方法,就将整个处理器链路对象串联起来了。如下:


图片


图片



友情提示:递归虽香,但使用递归一定要注意截断递归的条件处理,否则可能造成死循环哦!



实际上,getHandler()获取处理器对象的代码就是把在配置中心配置的规则ConfigJson,转换成配置类ProductCheckHandlerConfig对象,再根据配置类对象,转换成实际的处理器对象,这个处理器对象持有整个链路的调用顺序。


👉 步骤3:客户端执行调用链路。


public class HandlerClient {

  public static Result executeChain(AbstractCheckHandler handler, ProductVO param) {
      //执行处理器
      Result handlerResult = handler.handle(param);
      if (!handlerResult.isSuccess()) {
          System.out.println("HandlerClient 责任链执行失败返回:" + handlerResult.toString());
          return handlerResult;
      }
      return Result.success();
  }
}

getHandler()获取完处理器后,整个调用链路的执行顺序也就确定了,此时,客户端该干活了!


HandlerClient.executeChain(handler, param)方法是HandlerClient客户端类执行处理器整个调用链路的,并接收处理器链路的返回值。


executeChain()通过AbstractCheckHandler.handle()触发整个链路处理器顺序执行,如果某个处理器校验没有通过!handlerResult.isSuccess(),则返回错误信息;所有处理器都校验通过,则返回正确信息Result.success()


总结:串联方法调用流程


基于以上,再通过流程图来回顾一下整个调用流程。


图片


图片


测试:代码执行结果


场景1:创建商品参数中有空值(如下skuId参数为null),链路被空值处理器截断,返回错误信息


//创建商品参数
ProductVO param = ProductVO.builder()
      .skuId(null).skuName("华为手机").Path("http://...")
      .price(new BigDecimal(1))
      .stock(1)
      .build();

测试结果


图片


图片


场景2:创建商品价格参数异常(如下price参数),被价格处理器截断,返回错误信息


ProductVO param = ProductVO.builder()
      .skuId(1L).skuName("华为手机").Path("http://...")
      .price(new BigDecimal(-999))
      .stock(1)
      .build();

测试结果


图片


图片


场景 3:创建商品库存参数异常(如下stock参数),被库存处理器截断,返回错误信息。


//创建商品参数,模拟用户传入
ProductVO param = ProductVO.builder()
      .skuId(1L).skuName("华为手机").Path("http://...")
      .price(new BigDecimal(1))
      .stock(-999)
      .build();

测试结果


图片


图片


场景4:创建商品所有处理器校验通过,保存商品。


![15](C:\Users\18796\Desktop\文章\15.png)![15](C:\Users\18796\Desktop\文章\15.png)![15](C:\Users\18796\Desktop\文章\15.png)![15](C:\Users\18796\Desktop\文章\15.png)//创建商品参数,模拟用户传入
ProductVO param = ProductVO.builder()
      .skuId(1L).skuName("华为手机").Path("http://...")
      .price(new BigDecimal(999))
      .stock(1).build();

测试结果


图片


责任链的优缺点


图片


图片


作者:程序员蜗牛
来源:juejin.cn/post/7384632888321179659
收起阅读 »

dockerhub国内镜像站集体下线?别慌,教你丝滑拉取镜像~

web
前言想必大家都听说了,国内镜像站几乎都用不了,对于开发者来说,无疑是个不好的消息。在docker pull时直接超时失败,拉取不下来镜像。那么有没有什么办法解决呢?有!还不止一种。通过docker配置文件配置可用的国内镜像源设置代理自建镜像仓库方法1已经不太好...
继续阅读 »

前言

想必大家都听说了,国内镜像站几乎都用不了,对于开发者来说,无疑是个不好的消息。在docker pull时直接超时失败,拉取不下来镜像。那么有没有什么办法解决呢?有!还不止一种。

  1. 通过docker配置文件配置可用的国内镜像源
  2. 设置代理
  3. 自建镜像仓库

方法1已经不太好使了,能找到可用的不多,有的还存在没有最新的镜像问题。

方法2可行,不过得要有科学上网的工具,再会一点配置代理的知识,操作起来稍稍复杂。

本文主要介绍第三种方法,上手快,简单,关键还0成本!

准备工作

  1. 登录阿里云,找到容器镜像服务,创建一个个人版实例。(第一次使用的话,会让设置访问密码。记住,后面会用)
  2. 找到仓库管理-命名空间,新建一个命名空间且设置为公开

微信截图_20240626174632.png 3.不要创建镜像仓库,回到访问凭证

可以看到,如下2个信息,一个是你的阿里云用户名,一个是你的仓库地址(后面有用)

sudo docker login --username=阿里云用户名 registry.cn-beijing.aliyuncs.com

github配置

  1. fork项目,地址: docker_image_pusher

(感谢tech-shrimp提供的工具)

  1. 在fork后的项目中通过Settings-Secret and variables-Actions-New Repository secret路径,配置4个环境变量
  • ALIYUN_NAME_SPACE-命名空间
  • ALIYUN_REGISTRY_USER-阿里云用户名
  • ALIYUN_REGISTRY_PASSWORD-访问密码
  • ALIYUN_REGISTRY-仓库地址

企业微信截图_20240626203514.png

3.配置要拉取的镜像 打开项目images.txt,每一行配置一个镜像,格式:name:tag 比如

企业微信截图_20240626213138.png

提交修改的文件,则会自动在Actions中创建一个workflow。等待片刻即可(1分钟左右)

企业微信截图_20240626212730.png

5.回到阿里云容器镜像服务控制台-镜像仓库

企业微信截图_20240626213555.png

可以看到镜像已成功拉取并同步到你自己的仓库中。

测试效果

我自己操作了下把nginx的镜像给拉了过来,找台服务器测试一下速度

演示.gif 哈哈!这速度杠杠的吧! 用这个方式的好处是,借助github的action机制,直接从dockerhub上拉取任何你想要的镜像,也不用担心国内镜像站版本更新不及时的问题。再从自建的仓库中pull下来就可以啦! 如果有小伙伴没捣鼓成功的,可以留言给我。


作者:临时工
来源:juejin.cn/post/7384623060199473171
收起阅读 »

微信小程序全新渲染引擎Skyline(入门篇)

web
前言 最近看小程序文档的时候发现了 swiper 组件新增了 Skyline 特有的属性,直接使用竟然没有效果。 不信邪的我打算来研究研究究竟什么是 Skyline!经过一系列文档阅读与实践,长时间闭门造车的我打开了新世界的大门,我惊讶的发现 Skyline...
继续阅读 »

前言


最近看小程序文档的时候发现了 swiper 组件新增了 Skyline 特有的属性,直接使用竟然没有效果。



不信邪的我打算来研究研究究竟什么是 Skyline!经过一系列文档阅读与实践,长时间闭门造车的我打开了新世界的大门,我惊讶的发现 Skyline 引擎很可能是微信小程序未来发展的重点方向,有着更类似原生的交互体验,新增的特性让人连连称叹,特以此文来总结性地介绍一下 Skyline。


双线程模型


了解 Skyline 之前,我们有必要重新复习一下什么是小程序的双线程模型。


如官方文档所言,小程序的运行环境分成渲染层和逻辑层,其中 WXML 模板和 WXSS 样式工作在渲染层,JS 脚本工作在逻辑层。小程序的渲染层和逻辑层分别由2个线程管理:



  • 渲染层的界面使用了WebView 进行渲染,一个小程序存在多个界面,所以渲染层存在多个WebView线程;

  • 逻辑层采用JsCore线程运行JS脚本。


这两个线程的通信会经由微信客户端(原生) 做中转,逻辑层发送网络请求也经由微信客户端 (原生) 转发,有了微信小程序客户端 (原生) 作为媒介系统,使得我们开发者能够专注于数据与逻辑。


如上所述,小程序的通信模型如下图所示。



什么是 Skyline 引擎


前文提到,基于 WebView 和原生控件混合渲染的方式,小程序优化扩展了 Web 的基础能力,所以小程序相对于普通的Web页面有着更为良好的性能与体验。


由于 Web 在移动端的表现与原生应用仍有一定差距,亦或许是 Web 的优化遇到了瓶颈,为了进一步优化小程序性能,微信在 WebView 渲染之外新增了一个渲染引擎,也就是我们本文的重磅主角: Skyline,它使用更精简高效的渲染管线,并带来诸多增强特性,让 Skyline 拥有更接近原生渲染的性能体验。


Skyline 引擎 vs Webview 引擎


我们知道:WebView 的 JS 逻辑、DOM 树创建、CSS 解析、样式计算、Layout、Paint (Composite) 都发生在同一线程,在 WebView 上执行过多的 JS 逻辑可能阻塞渲染,导致界面卡顿,大致流程如下图所示。



但是,在 Skyline 环境下改变了这个情况,它创建了一条渲染线程来负责计算图层布局,图层的绘制以及整合图层页面等渲染任务,并在 AppService 中划出一个独立的上下文,来运行之前 WebView 承担的 JS 逻辑、DOM 树创建等逻辑。



据官方统计数据表明,Skyline 与 WebView 性能相比,具有如下优势:


Skyline 的首屏时间比 WebView 快约 66%



单个页面 Skyline 的占用比 WebView 减少约 35%


单个页面 Skyline 的占用比 WebView 减少 35%,两个页面 Skyline 的内存占用比 WebView 减少 50%,随着打开的页面变多,内存差距越明显。



Skyline 引擎的优点



  • 界面更不容易被逻辑阻塞,进一步减少卡顿

  • 无需为每个页面新建一个 JS 引擎实例(WebView),减少了内存、时间开销

  • 框架可以在页面之间共享更多的资源,进一步减少运行时内存、时间开销

  • 框架的代码之间无需再通过 JSBridge 进行数据交换,减少了大量通信时间开销

  • 保持和原有架构的兼容性,基于 WebView 环境的小程序代码基本上无需任何改动即可直接在新的架构下运行


更多Skyline的特性更新请详见Skyline 渲染引擎 / 概览 / 特性 | 微信开放文档


Skyline 引擎的缺点



  • WXS效率可能有所下降 (WXS 由于被移到 AppService 中,虽然逻辑本身无需改动,但询问页面信息等接口会变为异步,效率也可能有所下降)


但是,也不必过多的担心,微信推出了新的 Worklet 机制,它比原有的 WXS 更靠近渲染流程,用以高性能地构建各种复杂的动画效果。


Skyline 引擎的使用


前文提到,我想使用 swiper 组件新增的 Skyline 特有属性无果,是因为我没有完成 Skyline的配置。如果想要使用 Skyline引擎,我们可以按页面级别来选择性的配置是走 Skyline 引擎或是 Webview 引擎来渲染。


// page.json
{
"renderer": "skyline"
}

// page.json
{
"renderer": "webview"
}

配置完成之后,我们就可以愉快的使用 Skyline 专有的新特性了。


Skyline 引擎的兼容性


我们可能会担心开启了 Skyline 的渲染模式会不会带来兼容性问题。官方表示:



所以我们完全可以放下对兼容性的顾虑,拥抱新的 Skyline 引擎,让大部分的用户优先体验到新一代微信小程序的渲染技术,做第一批吃螃蟹的人!对于我们开发者而言,有必要深入了解一下Skyline引擎的更新带来了哪些开发层面的变化与创新,毕竟,吃螃蟹的人会越来越多嘛。


后记


感谢您的阅读,本文仅为微信小程序 Skyline 引擎的入门介绍篇,后续会持续更新有关 Skyline 引擎相关实际操作及使用的文章,如有兴趣,欢迎持续关注。


作者:阿李贝斯
来源:juejin.cn/post/7298927261210361882
收起阅读 »

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

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

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

感谢您的阅读!



介绍


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


HTMX - 返璞归真🚲


htmx-演示


为谁而设:



  • 你希望减少JavaScript的编写量

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


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


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


Wasp - 全栈,开箱即用🚀


开放SaaS


为谁而设:



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

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

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


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


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



  • 全栈身份验证

  • 数据库架构

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

  • 简单且灵活的部署

  • 全栈类型安全

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

  • 等等……


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


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


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


Solid.js - 一流的reactivity库 ↔️


扎实的例子


适合人群:



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

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


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


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


Astro - 静态网站之王👑


天文示例


适合人群:



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

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


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


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


Svelte - 简单而有效🎯


精简演示


适合人群:



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

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


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


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


Qwik - 非常快🚤


qwik演示


适合人群:



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

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


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


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


结论


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


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


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

我们都被困在系统里

前言 Hi 你好,我是东东拿铁,一个正在探索个人IP的后端程序员。 2020年外卖最火热的时候,有一篇文章《外卖骑手,困在系统里》。 作为一个互联网从业人员,我之前从未有机会体会到,当每一个工作都要被时间和算法压榨时,我会是一种怎样的感受。 而最近的一段经历...
继续阅读 »

前言


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


2020年外卖最火热的时候,有一篇文章《外卖骑手,困在系统里》。



作为一个互联网从业人员,我之前从未有机会体会到,当每一个工作都要被时间和算法压榨时,我会是一种怎样的感受。


而最近的一段经历,我感觉也被困在系统里了。


起因


如果你是一个研发人员,免不了要值班、处理线上问题。当然这都很正常,每个系统都有bug或者咨询类的问题。


由于我们面临的客户比较多,加上系统有一些易用性的问题或bug,提出来的问题不少。


公司有一项政策,当客服人员提交工单之后,系统对每一个单子有超时时间,如果超出一定时间你还未提交,罚款50元。


挺奇葩的,谁能保证1个小时就一定能排查出问题呢?


于是就会有一个场景,如果赶上问题多,一下子来5、6个工单,恰巧遇到不容易排查的耽误时间的话,处理后面的工单,都面临着超时的压力。



之前同事们对值班这件事,充满了怨言,大多都会吐槽几个点



  1. 系统bug太多了,又是刚刚某某需求改出来的问题

  2. 需求设计不合理,很多奇怪的操作导致了系统问题

  3. 客服太懒了,明明可以自己搜,非得提个工单问

  4. 基础设施差,平台不好用


我不太爱吐槽,但当工单一下子来的太多的时候,我不由自主的陷入机械的处理问题中,压缩思考的时间,只求不要超时就好。


明明系统有很多问题需要解决、流程也有很多可以优化,可当系统给到我们的压力越来越多时,我们便不去思考,陷入只有吐槽、怨言和避免罚款的状态。


当陷入了系统的支配,只能被动接受,甚至有了一些怨言的时候,我意识到,这样的状态,是有问题的。


被困住的打工人


外卖员为什么不遵守交通规则呢?


外卖小哥为了多赚钱、避免处罚,我之前也很不理解,为什么为了避免处罚,连自己的生命安全都可以置之不顾。



但转念一想,我们虽然不用在马路上奔波,可受到“系统”的压力,可是一点也不比外卖员少。


大家一定有过类似的经历:你骑车或者开车去上班,距离打卡时间所剩无几,你在迟到的边缘疯狂试探,可能多一个红绿灯,你就赶不上了,这时候你会不会狠踩几脚油门、闯一个黄灯,想要更快一点呢?


但随着裁员、降本增效、各类指标的压力越来越大,我们被迫不停的内卷,不断压榨自己,才能满足职场要求越来越严格的“算法”,比如,每半年一次的绩效考核,月度或者季度的OKR、KPI,还有处理不完的线上问题、事故,充斥在我们的脑海里面。


其实我们何尝不是“外卖员”呢?外卖员是为了不被扣钱,我们是为了年终奖、晋升罢了。


所以回过头来看,其实我们早早的就被困在“系统”中了,为了满足系统的要求,我们不得不埋头苦干,甚至加班透支身体,作出很多非常短线思维的事情。


但为什么,我之前从来没有过被困住的感觉,为什么我现在才回过神来,意识到这个问题呢?


我想,大概是越简单的事情,你作出的反应就越快、越激烈。而越复杂、时间越长的事情,你作出的反应就越缓慢,甚至忽略掉。


比如上班即将迟到的你,你会立刻意识到,迟到可能会受到处罚。但是年终评估你的绩效目标时,你或许只有在最后的几个月才会意识到,某某事情没完成,年终奖或许要少几个月而感到着急。


积极主动


最近正好在读《高效能人士的七个习惯》,其中第一个习惯就是积极主动


书中说到:人性的本质是主动而非被动的,人类不仅能针对特定环境选择回应方式,更能主动创造有利的环境。


我们面对的问题可以分为三类:



  • 可直接控制的(问题与自身的行为有关)

  • 可间接控制的(问题与他人的行为有关)

  • 无法控制的(我们无能为力的问题,例如我们的过去或现实的环境)


对于这三类问题,积极主动的话,应该如何加以解决呢。


可直接控制的问题


针对可直接控制的问题,可以通过培养正确习惯来解决。


从程序员角度来看,线上bug多,可以在开发前进行技术设计,上线前进行代码CR,自动化测试,帮助自己避免低级的问题。


面对处理工单时咨询量特别多的问题,随手整理个文档出来,放到大家都可以看到的地方。


可间接控制的


对于可间接控制的,我们可以通过改进施加影响的方法来解决。


比如流程机制的不合理,你可以通过向上反馈的方式施加影响,提出自己的建议而不是吐槽。


无法控制的


对于无法控制的,我们要做的就是改变面部曲线,以微笑、真诚与平和来接受现实。


虽然反馈问题的人或许能力参差不齐,导致工单量很多,但我们意识到这一点是无法避免的,不如一笑而过,这样才不至于被问题左右。


说在最后


好了,文章到这里就要结束了。


最近由于值班的原因,陷入了一段时间的无效忙碌中,每一天都很累,几乎抽不出时间来思考,所以更新的频率也降下来了。


但还好,及时的意识到问题,把最近的一点思考分享出来,希望我们每个人都不会被“系统”困住。




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

第一次使用缓存,因为没预热,翻车了

缓存不预热会怎么样?我帮大家淌了路。缓存不预热会导致系统接口性能下降,数据库压力增加,更重要的是导致我写了两天的复盘文档,在复盘会上被骂出了翔。 悲惨的上线时刻 事情发生在几年前,我刚毕业时,第一次使用缓存内心很激动。需求场景是虚拟商品页面需要向用户透出库存状...
继续阅读 »

缓存不预热会怎么样?我帮大家淌了路。缓存不预热会导致系统接口性能下降,数据库压力增加,更重要的是导致我写了两天的复盘文档,在复盘会上被骂出了翔。


悲惨的上线时刻


事情发生在几年前,我刚毕业时,第一次使用缓存内心很激动。需求场景是虚拟商品页面需要向用户透出库存状态,提单时也需要校验库存状态是否可售卖。但是由于库存状态的计算包含较复杂的业务逻辑,耗时比较高,在500ms以上。如果要在商品页面透出库存状态那么商品页面耗时增加500ms,这几乎是无法忍受的事情。


如何实现呢?最合适的方案当然是缓存了,我当时设计的方案是如果缓存有库存状态直接读缓存,如果缓存查不到,则计算库存状态,然后加载进缓存,同时设定过期时间。何时写库存呢? 答案是过期后,cache miss时重新加载进缓存。 由于计算逻辑较复杂,库存扣减等用户写操作没有同步更新缓存,但是产品认可库存状态可以有几分钟的状态不一致。为什么呢?


因为仓库有冗余库存,就算库存状态不一致导致超卖,也能容忍。同时库存不足以后,需要运营补充库存,而补充库存的时间是肯定比较长的。虽然补充库存完成几分钟后,才变为可售卖的,产品也能接受。 梳理完缓存的读写方案,我就沉浸于学习Redis的过程。


第一次使用缓存,我把时间和精力都放在Redis存储结构,Redis命令,Redis为什么那么快等方面的关注。如饥似渴的学习Redis知识。


直到上线阶段我也没有意识到系统设计的缺陷。


代码写的很快,测试验证也没有问题。然而上线过程中,就开始噼里啪啦的报警,开始我并没有想到报警这事和我有关。直到有人问我,“XXX,你是不是在上线库存状态的需求?”。


我人麻了,”怎么了,啥事”,我颤抖的问


“商品页面耗时暴涨,赶紧回滚”。一个声音传来


“我草”,那一瞬间,我的血压上涌,手心发痒,心跳加速,头皮发麻,颤抖的手不知道怎么在发布系统点回滚,“我没回滚过啊,咋回滚啊?”


“有降级开关吗”? 一个声音传来。


"没写..."。我回答的时候觉得自己真是二笔,为啥没加降级啊。(这也是复盘被骂的重要原因)


那么如何对缓存进行预热呢?


如何预热缓存


灰度放量


灰度放量实际上并不是缓存预热的办法,但是确实能避免缓存雪崩的问题。例如这个需求场景中,如果我没有放开全量数据,而是选择放量1%的流量。这样系统的性能不会有较大的下降,并且逐步放量到100%。


虽然这个过程中,没有主动同步数据到缓存,但是通过控制放量的节奏,保证了初始化缓存过程中,不会出现较大的耗时波动。


例如新上线的缓存逻辑,可以考虑逐渐灰度放量。


扫描数据库刷缓存


如果缓存维度是商品维度或者用户维度,可以考虑扫描数据库,提前预热部分数据到缓存中。


开发成本较高。除了开发缓存部分的代码,还需要开发扫描全表的任务。为了控制缓存刷新的进度,还需要使用线程池增加并发,使用限流器限制并发。这个方案的开发成本较高。


通过数据平台刷缓存


这是比较好的方式,具体怎么实现呢?


数据平台如果支持将数据库离线数据同步到Hive,Hive数据同步到Kafka,我们就可以编写Hive SQL,建立ETL任务。把业务需要被刷新的数据同步到Kafka中,再消费Kafka,把数据写入到缓存中。在这个过程中通过数据平台控制并发度,通过Kafka 分片和消费线程并发度控制 缓存写入的速率。


这个方案开发逻辑包括ETL 任务,消费Kafka写入缓存。这两部分的开发工作量不大。并且相比扫描全表任务,ETL可以编写更加复杂的SQL,修改后立即上线,无需自己控制并发、控制限流。在多个方面ETL刷缓存效率更高。


但是这个方案需要公司级别支持 多个存储系统之间可以进行数据同步。例如mysql、kafka、hive等。


除了首次上线,是否还有其他场景需要预热缓存呢?


需要预热缓存的其他场景


如果Redis挂了,数据怎么办


刚才提到上线前,一定要进行缓存预热。还有一个场景:假设Redis挂了,怎么办?全量的缓存数据都没有了,全部请求同时打到数据库,怎么办。


除了首次上线需要预热缓存,实际上如果缓存数据丢失后,也需要预热缓存。所以预热缓存的任务一定要开发的,一方面是上线前预热缓存,同时也是为了保证缓存挂掉后,也能重新预热缓存。


假如有大量数据冷启动怎么办


假如促销场景,例如春节抢红包,平时非活跃用户会在某个时间点大量打开App,这也会导致大量cache miss,进而导致雪崩。 此时就需要提前预热缓存了。具体的办法,可以考虑使用ETL任务。离线加载大量数据到Kafka,然后再同步到缓存。


总结



  1. 一定要预热缓存,不然线上接口性能和数据库真的扛不住。

  2. 可以通过灰度放量,扫描全表、ETL数据同步等方式预热缓存

  3. Redis挂了,大量用户冷启动的促销场景等场景都需要提前预热缓存。


作者:五阳
来源:juejin.cn/post/7277461864349777972
收起阅读 »

半夜被慢查询告警吵醒,limit深度分页的坑

故事梅雨季,闷热的夜,令人窒息,窗外一道道闪电划破漆黑的夜幕,小猫塞着耳机听着恐怖小说,辗转反侧,终于睡意来了,然而挨千刀的手机早不振晚不振,偏偏这个时候振动了一下,一个激灵,没有按捺住对内容的好奇,点开了短信,卧槽?告警信息,原来是负责的服务出现慢查询了。小...
继续阅读 »

故事

梅雨季,闷热的夜,令人窒息,窗外一道道闪电划破漆黑的夜幕,小猫塞着耳机听着恐怖小说,辗转反侧,终于睡意来了,然而挨千刀的手机早不振晚不振,偏偏这个时候振动了一下,一个激灵,没有按捺住对内容的好奇,点开了短信,卧槽?告警信息,原来是负责的服务出现慢查询了。小猫想起来,今天在下班之前上线了一个版本,由于新增了一个业务字段,所以小猫写了相关的刷数据的接口,在下班之前调用开始刷历史数据。

考虑到表的数据量比较大,一次性把数据全部读取出来然后在内存里面去刷新数据肯定是不现实的,所以小猫采用了分页查询的方式依次根据条件查询出结果,然后进行表数据的重置。没想到的是,数据量太大,分页的深度越来越深,渐渐地,慢查询也就暴露出来了。

慢查询告警

强迫症小猫瞬间睡意全无,翻起来打开电脑开始解决问题。

那么为什么用使用limit之后会出现慢查询呢?接下来老猫和大家一起来剖析一下吧。

剖析流程

limit分页为什么会变慢?

在解释为什么慢之前,咱们来重现一下小猫的慢查询场景。咱们从实际的例子推进。

做个小实验

假设我们有一张这样的业务表,商品Product表。具体的建表语句如下:

CREATE TABLE `Product` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`type` tinyint(3) unsigned NOT NULL DEFAULT '1' ,
`spuCode` varchar(50) NOT NULL DEFAULT '' ,
`spuName` varchar(100) NOT NULL DEFAULT '' ,
`spuTitle` varchar(300) NOT NULL DEFAULT '' ,
`channelId` bigint(20) unsigned NOT NULL DEFAULT '0',
`sellerId` bigint(20) unsigned NOT NULL DEFAULT '0'
`mallSpuCode` varchar(32) NOT NULL DEFAULT '',
`originCategoryId` bigint(20) unsigned NOT NULL DEFAULT '0' ,
`originCategoryName` varchar(50) NOT NULL DEFAULT '' ,
`marketPrice` decimal(10,2) unsigned NOT NULL DEFAULT '0.00',
`status` tinyint(3) unsigned NOT NULL DEFAULT '1' ,
`isDeleted` tinyint(3) unsigned NOT NULL DEFAULT '0',
`timeCreated` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`timeModified` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) ,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `uk_spuCode` (`spuCode`,`channelId`,`sellerId`),
KEY `idx_timeCreated` (`timeCreated`),
KEY `idx_spuName` (`spuName`),
KEY `idx_channelId_originCategory` (`channelId`,`originCategoryId`,`originCategoryName`) USING BTREE,
KEY `idx_sellerId` (`sellerId`)
) ENGINE=InnoDB AUTO_INCREMENT=12553120 DEFAULT CHARSET=utf8mb4 COMMENT='商品表'

从上述建表语句中我们发现timeCreated走普通索引。 接下来我们根据创建时间来执行一下分页查询:

当为浅分页的时候,如下:

select * from Product where timeCreated > "2020-09-12 13:34:20" limit 0,10

此时执行的时间为: "executeTimeMillis":1

当调整分页查询为深度分页之后,如下:

select * from Product where timeCreated > "2020-09-12 13:34:20" limit 10000000,10

此时深度分页的查询时间为: "executeTimeMillis":27499

此时看到这里,小猫的场景已经重现了,此时深度分页的查询已经非常耗时。

剖析一下原因

简单回顾一下普通索引和聚簇索引

我们来回顾一下普通索引和聚簇索引(也有人叫做聚集索引)的关系。

大家可能都知道Mysql底层用的数据结构是B+tree(如果有不知道的伙伴可以自己了解一下为什么mysql底层是B+tree),B+tree索引其实可以分为两大类,一类是聚簇索引,另外一类是非聚集索引(即普通索引)。

(1)聚簇索引:InnoDB存储表是索引组织表,聚簇索引就是一种索引组织形式,聚簇索引叶子节点存放表中所有行数据记录的信息,所以经常会说索引即数据,数据即索引。当然这个是针对聚簇索引。

02.png

由图可知在执行查询的时候,从根节点开始共经历了3次查询即可找到真实数据。倘若没有聚簇索引的话,就需要在磁盘上进行逐个扫描,直至找到数据为止。显然,索引会加快查询速度,但是在写入数据的时候,由于需要维护这颗B+树,因此在写入过程中性能也会下降。

(2)普通索引:普通索引在叶子节点并不包含所有行的数据记录,只是会在叶子节点存本身的键值和主键的值,在检索数据的时候,通过普通索引子节点上的主键来获取想要找到的行数据记录。

03.png

由图可知流程,首先从非聚簇索引开始寻找聚簇索引,找到非聚簇索引上的聚簇索引后,就会到聚簇索引的B+树上进行查询,通过聚簇索引B+树找到完整的数据。该过程比较专业的叫法也被称为“回表”。

看一下实际深度分页执行过程

有了以上的知识基础我们再来回过头看一下上述深度分页SQL的执行过程。 上述的查询语句中idx_timeCreated显然是普通索引,咱们结合上述的知识储备点,其深度分页的执行就可以拆分为如下步骤:

1、通过普通索引idx_timeCreated,过滤timeCreated,找到满足条件的记录ID;

2、通过ID,回到主键索引树,找到满足记录的行,然后取出展示的列(回表);

3、扫描满足条件的10000010行,然后扔掉前10000000行,返回。

结合看一下执行计划:

04.png

原因其实很清晰了: 显然,导致这句SQL速度慢的问题出现在第2步。其中发生了10000010次回表,这前面的10000000条数据完全对本次查询没有意义,但是却占据了绝大部分的查询时间。

再深入一点从底层存储来看,数据库表中行数据、索引都是以文件的形式存储到磁盘(硬盘)上的,而硬盘的速度相对来说要慢很多,存储引擎运行sql语句时,需要访问硬盘查询文件,然后返回数据给服务层。当返回的数据越多时,访问磁盘的次数就越多,就会越耗时。

替换limit分页的一些方案。

上述我们其实已经搞清楚深度分页慢的原因了,总结为“无用回表次数过多”。

那怎么优化呢?相信大家应该都已经知道了,其核心当然是减少无用回表次数了。

有哪些方式可以帮助我们减少无用回表次数呢?

子查询法

思路:如果把查询条件,转移回到主键索引树,那就不就可以减少回表次数了。 所以,咱们将实际的SQL改成下面这种形式:

select * FROM Product where id >= (select p.id from Product p where p.timeCreated > "2020-09-12 13:34:20" limit 10000000, 1) LIMIT 10;

测试一下执行时间: "executeTimeMillis":2534

我们可以明显地看到相比之前的27499,时间整整缩短了十倍,在结合执行计划观察一下。

05.png

我们综合上述的执行计划可以看出,子查询 table p查询是用到了idx_timeCreated索引。首先在索引上拿到了聚集索引的主键ID,省去了回表操作,然后第二查询直接根据第一个查询的 ID往后再去查10个就可以了!

显然这种优化方式是有效的。

使用inner join方式进行优化

这种优化的方式其实和子查询优化方法如出一辙,其本质优化思路和子查询法一样。 我们直接来看一下优化之后的SQL:

select * from Product p1 inner join (select p.id from Product p where p.timeCreated > "2020-09-12 13:34:20" limit 10000000,10) as p2 on p1.id = p2.id

测试一下执行的时间: "executeTimeMillis":2495

06.png

咱们发现和子查询的耗时其实差不多,该思路是先通过idx_timeCreated二级索引树查询到满足条件的主键ID,再与原表通过主键ID内连接,这样后面直接走了主键索引了,同时也减少了回表。

上面两种方式其核心优化思想都是减少回表次数进行优化处理。

标签记录法(锚点记录法)

我们再来看下一种优化思路,上述深度分页慢原因我们也清楚了,一次性查询的数据太多也是问题,所以我们从这个点出发去优化,每次查询少量的数据。那么我们可以采用下面那种锚点记录的方式。类似船开到一个地方短暂停泊之后继续行驶,那么那个停泊的地方就是抛锚的地方,老猫喜欢用锚点标记来做比方,当然看到网上有其他的小伙伴称这种方式为标签记录法。其实意思也都差不多。

这种方式就是标记一下上次查询到哪一条了,下次再来查的时候,从该条开始往下扫描。我们直接看一下SQL:

select * from Product p where p.timeCreated > "2020-09-12 13:34:20" and id>10000000 limit 10

显然,这种方式非常快,耗时如下: "executeTimeMillis":1

但是这种方式显然是有缺陷的,大家想想如果我们的id不是连续的,或者说不是自增形式的,那么我们得到的数据就一定是不准确的。与此同时咱们也不能跳页查看,只能前后翻页。

当然存在相同的缺陷,我们还可以换一种写法。

select * from Product p where p.timeCreated > "2020-09-12 13:34:20" and id between 10000000 and 10000010  

这种方式也是一样存在上述缺陷,另外的话更要注意的是between ...and语法是两头都是闭区域间。上述语句如果ID连续不断地情况下,咱们最终得到的其实是11条数据,并不是10条数据,所以这个地方还是需要注意的。

存入到es中

上述罗列的几种分页优化的方法其实已经够用了,那么如果数据量再大点的话咋整,那么我们可能就要选择其他中间件进行查询了,当然我们可以选择es。那么es真的就是万能药吗?显然不是。ES中同样存在深度分页的问题,那么针对es的深度分页,那么又是另外一个故事了,这里咱们就不展开了。

写到最后

那么半夜三更爬起来优化慢查询的小猫究竟有没有解决问题呢?电脑前,小猫长吁了一口气,解决了! 我们看下小猫的优化方式:

select * from InventorySku isk inner join (select id from InventorySku where inventoryId = 6058 limit 109500,500 ) as d on isk.id = d.id

显然小猫采用了inner join的优化方法解决了当前的问题。

相信小伙伴们后面遇到这类问题也能搞定了。


作者:程序员老猫
来源:juejin.cn/post/7384652811554308147
收起阅读 »

零成本搭建个人图床服务器

前言 图床服务器是一种用于存储和管理图片的服务器,可以给我们提供将图片上传后能外部访问浏览的服务。这样我们在写文章时插入的说明图片,就可以集中放到图床里,既方便多平台文章发布,又能统一管理和备份。 当然下面通过在 GitHub 上搭建的图床,不光不用成本,而且...
继续阅读 »

前言


图床服务器是一种用于存储和管理图片的服务器,可以给我们提供将图片上传后能外部访问浏览的服务。这样我们在写文章时插入的说明图片,就可以集中放到图床里,既方便多平台文章发布,又能统一管理和备份。


当然下面通过在 GitHub 上搭建的图床,不光不用成本,而且还能上传视频或音乐。操作方法和以前在 GitHub 上搭建静态博客类似,但是中间会多一些一些工具介绍和技巧。


流程



  • 创建仓库

  • 设置仓库

  • 连接仓库

  • 应用 Typora


创建仓库


创建仓库和平时的代码托管一样,添加一个 public 权限仓库,用默认的 main 分支。当然也可以提前创建一个目录,但是根目录最好有一个 index.html。



设置仓库


设置仓库主要是添加提交 Token,和配置 GitHub Pages 参数。而这两小步的设置,在前面文章 "Hexo 博客搭建" 有比较详细介绍,所以这里就稍微文字带过了。


Token 生成


登陆 GitHub -> Settings -> Developer settings -> Personal access tokens -> Tokens (classic),然后点击 "Generate new token",填写备注和过期时间,权限主要勾选 "repo"、"workflow"、"user"。最后生成 "ghp_" 前缀的字符串就是 Token 了,复制并保存下来。


GitHub Pages 配置


进入仓库页 -> Settings -> Pages,设置 Branch,指定仓库的分支和分支根目录,Source 选择 "Deploy from a branch",最后刷新或者重新进入,把访问链接地址复制保存下来。



连接仓库


连接可以除了 API 方式,也可以用第三方的工具,比如 "PicGo"。工具位置自行搜索哈,下面以他为例,演示工具的连接配置、文件上传和访问测试。


连接配置


找到 "图床设置" -> "GitHub",下面主要填写仓库名(需带上账户名),分支名(默认 main 即可),Token(上面生成保存下来的),存储路径(后带斜杠)可以填写已存在,如果不存在则在仓库根目录下新建。



文件上传


文件格式除了下面指定的如 Markdown、HTML、URL 外,还能上传图片音乐视频等(亲测有效)。点击 "上传区",将文件直接拖动到该窗口,提示上传成功后,进入 GitHub 仓库下查看是否存在。 



访问测试


访问就是能将仓库里的图片或视频以外链的方式展示,就像将文件放在云平台的存储桶一样。将前面 GitHub Pages 开启的链接复制下来,然后拼接存储路径和文件名就可以访问了。



应用 Typora


Typora 通过 PicGo 软件自动上传图片到 GitHub 仓库中。打开 Typora 的文件 -> 偏好设置 -> 图像 -> 上传图片 -> 配置 PicGo 路径,然后指定一下 PicGo 的安装位置。 



开始使用


可以点击 "验证图片上传选项",验证成功就代表已经将 Typora 的图标上传到仓库,也可以直接将图片复制到当前 md 文档位置。



![image-20240608145607117](https://raw.githubusercontent.com/z11r00/zd_image_bed/main/img/image-20240608145607117.png)

上传成功后会将返回一个如上面的远程链接,并且无法打开和显示,这是就要在 PicGo 工具的图床设置中。将自己 GitHUb 上的域名设定为自定义域名,格式 "域名 / 仓库名", 在 Typora 上传图片后重启就可展示了。


image-20240612104856943


作者:北桥苏
来源:juejin.cn/post/7384320850722553867
收起阅读 »

12306全球最大票务系统与Gemfire介绍

全球最大票务系统 自2019年12月12日发售春运首日车票,截至2020年1月9日,12306全渠道共发售车票4.12亿张,日均售票能力达到了2000万张,平均一年售出30亿张火车票,也就是说12306已经发展成全球交易量最大的实时票务系统。 12306发布数...
继续阅读 »

全球最大票务系统


自2019年12月12日发售春运首日车票,截至2020年1月9日,12306全渠道共发售车票4.12亿张,日均售票能力达到了2000万张,平均一年售出30亿张火车票,也就是说12306已经发展成全球交易量最大的实时票务系统。


12306发布数据显示,2020年春运期间,40天的春运期间,12306最高峰日网站点击量为1495亿次,这相当于每个中国人一天在12306上点击了100次,平均每秒点击量为170多万次。而全球访问量最大的搜索引擎网站, 谷歌日访问量也不过是56亿次,一个12306的零头。 再看一下大家习惯性做对比的淘宝,2019年双十一当天,淘宝的日活跃用户为4.76亿,相当于每个人也在淘宝上点击300多次,才能赶上12306的峰值点击量。


上亿人口,40天时间,30亿次出行,12306之前,全球没有任何一家公司和产品接手过类似的任务。这个网站是在数亿人苛刻的目光中,做一件史无前例却又必须成功的事情。


历史发展


10年前铁道部顶着重重压力决心要解决买车票这个全民难题,2010年春运首日12306网站开通并试运行,2011年12月23日网站正式上线,铁道部兑现了让网络售票覆盖所有车次的承诺,不料上线第一天,全民蜂拥而入,流量暴增,网站宕机,除此之外,支付流程繁琐支付渠道单一,各种问题不断涌现,宕机可能会迟到,但永远不会缺席,12306上线的第二年,网站仍然难以支撑春运的巨大流量,很多人因为网站的各种问题导致抢票失败,甚至耽误了去线下买票的最佳时机,铁道部马不解鞍听着批评,一次又一次给12306改版升级,这个出生的婴儿几乎是在骂声中长大的。2012年9月,中秋国庆双节来临之前,12306又一次全站崩溃,本来大家习以为常的操作,却被另一个消息彻底出炉,这次崩溃之前,铁道部曾花了3.3亿对系统进行升级,中标的不是IBM惠普EMC等大牌厂商,而是拥有国字号背景的太极股份和同方股份,铁道部解释说3.3亿已经是最低价了,但没人能听进去,大家只关心他长成了什么样,没人关心他累不累,从此之后,铁道部就很少再发声明了。


2013年左右,各种互联网公司表示我行我上,开发了各种抢票网站插件。当时360浏览器靠免费抢票创下国内浏览器使用率的最高纪录,百度猎豹搜狗UC也纷纷加入,如今各类生活服务APP,管他干啥的,都得植入购票抢票功能和服务,12306就这样被抢票软件围捕了。不同的是,过去抢票是免费,现在由命运馈赠的火车票,都在明面上标好了价格,比如抢票助力包,一般花钱买10元5份,也可以邀请好友砍一刀,抢票速度上,分为低快高级光速VIP等等速度,等级越高就越考验钱包。


2017年12306上线了选座和接续换乘功能,从此爱人可以自由抢靠窗座,而且夹在两人之间坐立不安,换乘购票也变得简单。2019年上线官方捡漏神器候补购票功能,可以代替科技黄牛,自动免费为旅客购买退票余票。......


阿里云当时主要是给他们提供虚拟机服务,主要是做IaaS这一层,就是基础设施服务这一层,2012年熟悉阿里云历史的应该都知道,那个时候阿里云其实还是很小的一个厂商,所以不要盲目夸大阿里云在里面起的作用。


技术难点


1、巨大流量,高请求高并发。


2、抢票流量。每天放出无数个爬虫机器人,模拟真人登陆12306,不间断的刷新网站余票,这会滋生很多的灰色流量,也会给12306本身的话造成非常大的压力。


3、动态库存。电商的任务是购物结算,库存是唯一且稳定的,而12306每卖出一张车票,不仅要减少首末站的库存,还要同时减少一趟列车所有过路站的。



以北京西到深圳福田的G335次高铁为例,表面上看起来中间有16个车站及16个SKU,但实际上不同的起始站都会产生新的SKU。我们将所有起始和终点的可能性相加,就是16+15+14一直加到一,一共136个SKU,而每种票对应三种座位,所以一共是408个商品。然后更复杂的是用户每买一张票会影响其他商品的库存,假如用户买了一张北京西的高碑店东的票,那北京始发的16个SKU库存都要减一,但是它并不影响非北京始发车票的库存,
更关键的是这些SKU间有的互斥,有的不互斥,优先卖长的还是优先卖短程的呢,每一次火车票的出售都会引发连锁变化,让计算量大大增加,如果再叠加当前的选座功能,计算数量可能还要再翻倍,而这些计算数据需要在大量购票者抢票的数秒,甚至数毫秒内完成,难度可想而知有多多大。



4、随机性。你永远都不知道哪一个人会在哪一天,去到哪一个地点,而双十一的预售和发货,其实已经提前准备了一个月,甚至几个月,并不是集中在双十一那天爆发的那一天。所以必须要有必须要有动态扩容的能力。


读扩散和写扩散


上面说的动态库存,就比如 A -> B -> C -> D 共 4 个车站,假如乘客买了 B -> C 的车票,那么同时会影响到 A->C,A->D,B->C,B->D,涉及了多个车站的排列组合,这里计算是比较耗费性能的。


那么这里就涉及到了 “读扩散” 和 “写扩散” 的问题,在 12 年的时候,12306 使用的就是读扩散,也就是在扣减余票库存的时候,直接扣减对应车站,而在查询的时候,进行动态计算。而写扩散就是在写的时候,就动态计算每个车站应该扣除多少余票库存,在查询的时候直接查即可。


12306本身他其实是读的流量远远大于写的流量,我个人是认为写扩散其实会更好一点。


Pivotal Gemfire


Redis 在互联网公司中使用的是比较多的,而在银行、12306 很多实时交易的系统中,很多采用 Pivotal Gemfire作为解决方案。Redis 是开源的缓存解决方案,而 Pivotal Gemfire 是商用的,我们在互联网项目中为什么使用 Redis 比较多呢,就是因为 Redis 是开源的,不要钱,开源对应的也就是稳定性不是那么的强,并且开源社区也不会给你提供解决方案,毕竟你是白嫖的,而在银行以及 12306 这些系统中,它们对可靠性要求非常的高,因此会选择商用的 Pivotal Gemfire,不仅性能强、高可用,而且 Gemfire 还会提供一系列的解决方案,据说做到了分布式系统中的 CAP


12306 的性能瓶颈就在于余票的查询操作上,上边已经说了,12306 是采用读扩散,也就是客户买票之后,扣减库存只扣减对应车站之间的余票库存,在读的时候,再来动态的计算每个站点应该有多少余票,因此读性能是 12306 的性能瓶颈


当时 12306 也尝试了许多其他的解决方案,比如 cassandra 和 mamcached,都扛不住查询的流量,而使用 Gemfire 之后扛住了流量,因此就使用了 Gemfire。2012年6月一期先改造12306的主要瓶颈——余票查询系统。 9月份完成代码改造,系统上线。2012年国庆,又是网上订票高峰期间,大家可以显著发现,可以登录12306,虽然还是很难订票,但是查询余票很快。2012年10月份,二期用GemFire改造订单查询系统(客户查询自己的订单记录)2013年春节,又是网上订票高峰期间,大家可以显著发现,可以登录12306,虽然还是很难订票,但是查询余票很快,而且查询自己的订票和下订单也很快。


技术改造之后,在只采用10几台X86服务器实现了以前数十台小型机的余票计算和查询能力,单次查询的最长时间从之前的15秒左右下降到0.2秒以下,缩短了75倍以上。 2012年春运的极端高流量并发情况下,系统几近瘫痪。而在改造之后,支持每秒上万次的并发查询,高峰期间达到2.6万个查询/秒吞吐量,整个系统效率显著提高;订单查询系统改造,在改造之前的系统运行模式下,每秒只能支持300-400个查询/秒的吞吐量,高流量的并发查询只能通过分库来实现。改造之后,可以实现高达上万个查询/秒的吞吐量,而且查询速度可以保障在20毫秒左右。新的技术架构可以按需弹性动态扩展,并发量增加时,还可以通过动态增加X86服务器来应对,保持毫秒级的响应时间。


通过云计算平台虚拟化技术,将若干X86服务器的内存集中起来,组成最高可达数十TB的内存资源池,将全部数据加载到内存中,进行内存计算。计算过程本身不需要读写磁盘,只是定期将数据同步或异步方式写到磁盘。GemFire在分布式集群中保存了多份数据,任何一台机器故障,其它机器上还有备份数据,因此通常不用担心数据丢失,而且有磁盘数据作为备份。GemFire支持把内存数据持久化到各种传统的关系数据库、Hadoop库和其它文件系统中。大家知道,当前计算架构的瓶颈在存储,处理器的速度按照摩尔定律翻番增长,而磁盘存储的速度增长很缓慢,由此造成巨大高达10万倍的差距。这样就很好理解GemFire为什么能够大幅提高系统性能了。Gemfire 的存储和计算都在一个地方,它的存储和实时计算的性能目前还没有其他中间件可以取代。


但是 Gemfire 也存在不足的地方,对于扩容的支持不太友好的,因为它里边有一个 Bucket 类似于 Topic 的概念,定好 Bucket 之后,扩容是比较难的,在 12306 中,也有过测试,需要几十个T的内存就可以将业务数据全部放到内存中来,因此直接将内存给加够,也就不需要很频繁的扩容。


12306业务解决方案


当然在优化中,我们靠改变架构加机器可以提升速度效率,剩下的也需要业务上的优化。


1、验证码。如果说是淘宝啊这种网站,他用这种验证码,用12306的验证码,可能大家都不会用了,对不对,但是12306他比较特殊,因为铁路全国就他一家,所以说他可以去做这个事情,他不用把用户体验放在第一位
。他最高的优先级是怎么把票给需要的人手上。


当然这个利益的确是比较大,所以也会采用这种人工打码的方式,可以雇一批大学生去做这个验证码识别。


2、候补。候补车票其实相当于整个系统上,它是一个异步的过程,你可以在这里排队,后面的话也没有抢到票,后面再通知你。


3、分时段售票。对于抢票来说,瞬时抢票会导致对服务器有瞬间很大的压力,因此从业务设计上来说需要将抢票的压力给分散开,比如今天才开启抢15天之后的车票。2点抢票,3点抢票等等。


总结


只有程序员才知道,一个每天完成超过1500万个订单,承受近1500亿次点击的系统到底有多难,在高峰阶段的时候,平均每秒就要承受170多万次的点击,面对铁路运输这种特殊的运算模式,也能够保证全国人民在短时间内抢到回家的票,12306就是在无数国人的苛责和质疑中,创造了一个世界的奇迹。


12306除了技术牛,还有着自己的人情关怀,系统会自动识别购票者的基本信息,如果识别出订单里有老人会优先给老人安排下铺儿童和家长会尽量安排在邻近的位置,12306 在保证所有人都能顺利抢到回家的票的同时,还在不断地增加更多的便利,不仅在乎技术问题,更在乎人情异味,12306可能还不够完美,但他一直在努力变得更好,为我们顺利回家提供保障,这是背后无数程序员日夜坚守的结果,我们也应该感谢总设计师单杏花女士,所以你可以调侃,可以批评,但不能否认12306背后所做出的所有努力!


作者:jack_xu
来源:juejin.cn/post/7381747852831653929
收起阅读 »

秒懂双亲委派机制

前言 最近有位小伙伴问了我一个问题:JDBC为什么会破坏双亲委派机制? 这个问题挺有代表性的。 双亲委派机制是Java中非常重要的类加载机制,它保证了类加载的完整性和安全性,避免了类的重复加载。 这篇文章就跟大家一起聊聊,Java中类加载的双亲委派机制到底是怎...
继续阅读 »

前言


最近有位小伙伴问了我一个问题:JDBC为什么会破坏双亲委派机制?


这个问题挺有代表性的。


双亲委派机制是Java中非常重要的类加载机制,它保证了类加载的完整性和安全性,避免了类的重复加载。


这篇文章就跟大家一起聊聊,Java中类加载的双亲委派机制到底是怎么回事,有哪些破坏双亲委派机制的案例,为什么要破坏双亲委派机制,希望对你会有所帮助。


1 为什么要双亲委派机制?


我们的Java在运行之前,首先需要把Java代码转换成字节码,即class文件。


然后JVM需要把字节码通过一定的方式加载到内存中的运行时数据区


这种方式就是类加载器(ClassLoader)。


再通过加载、验证、准备、解析、初始化这几个步骤完成类加载过程,然后再由jvm执行引擎的解释器和JIT即时编译器去将字节码指令转换为本地机器指令进行执行。


我们在使用类加载器加载类的时候,会面临下面几个问题:



  1. 如何保证类不会被重复加载?类重复加载会出现很多问题。

  2. 类加载器是否允许用户自定义?

  3. 如果允许用户自定义,如何保证类文件的安全性?

  4. 如何保证加载的类的完整性?


为了解决上面的这一系列的问题,我们必须要引入某一套机制,这套机制就是:双亲委派机制


2 什么是双亲委派机制?


接下来,我们看看什么是双亲委派机制。


双亲委派机制的基本思想是:当一个类加载器试图加载某个类时,它会先委托给其父类加载器,如果父类加载器无法加载,再由当前类加载器自己进行加载。


这种层层委派的方式有助于保障类的唯一性,避免类的重复加载,并提高系统的安全性和稳定性。


在Java中默认的类加载器有3层:



  1. 启动类加载器(Bootstrap Class Loader):负责加载 %JAVA_HOME%/jre/lib 目录下的核心Java类库,比如:rt.jar、charsets.jar等。它是最顶层的类加载器,通常由C++编写。

  2. 扩展类加载器(Extension Class Loader):负责加载Java的扩展库,一般位于/lib/ext目录下。

  3. 应用程序类加载器(Application Class Loader):也称为系统类加载器,负责加载用户类路径(ClassPath)下的应用程序类。


用一张图梳理一下,双亲委派机制中的3种类加载器的层次关系:图片


但这样不够灵活,用户没法控制,加载自己想要的一些类。


于是,Java中引入了自定义类加载器。


创建一个新的类并继承ClassLoader类,然后重写findClass方法。


该方法主要是实现从那个路径读取 ar包或者.class文件,将读取到的文件用字节数组来存储,然后可以使用父类的defineClass来转换成字节码。


如果想破坏双亲委派的话,就重写loadClass方法,否则不用重写。


类加载器的层次关系改成:图片


双亲委派机制流程图如下:图片


具体流程大概是这样的:



  1. 需要加载某个类时,先检查自定义类加载器是否加载过,如果已经加载过,则直接返回。

  2. 如果自定义类加载器没有加载过,则检查应用程序类加载器是否加载过,如果已经加载过,则直接返回。

  3. 如果应用程序类加载器没有加载过,则检查扩展类加载器是否加载过,如果已经加载过,则直接返回。

  4. 如果扩展类加载器没有加载过,则检查启动类加载器是否加载过,如果已经加载过,则直接返回。

  5. 如果启动类加载器没有加载过,则判断当前类加载器能否加载这个类,如果能加载,则加载该类,然后返回。

  6. 如果启动类加载器不能加载该类,则交给扩展类加载器。扩展类加载器判断能否加载这个类,如果能加载,则加载该类,然后返回。

  7. 如果扩展类加载器不能加载该类,则交给应用程序类加载器。应用程序类加载器判断能否加载这个类,如果能加载,则加载该类,然后返回。

  8. 如果应用程序类加载器不能加载该类,则交给自定义类加载器。自定义类加载器判断能否加载这个类,如果能加载,则加载该类,然后返回。

  9. 如果自定义类加载器,也无法加载这个类,则直接抛ClassNotFoundException异常。


这样做的好处是:



  1. 保证类不会重复加载。加载类的过程中,会向上问一下是否加载过,如果已经加载了,则不会再加载,这样可以保证一个类只会被加载一次。

  2. 保证类的安全性。核心的类已经被启动类加载器加载了,后面即使有人篡改了该类,也不会再加载了,防止了一些有危害的代码的植入。


3 破坏双亲委派机制的场景


既然Java中引入了双亲委派机制,为什么要破坏它呢?


答:因为它有一些缺点。


下面给大家列举一下,破坏双亲委派机制最常见的场景。


3.1 JNDI


JNDI是Java中的标准服务,它的代码由启动类加载器去加载。


但JNDI要对资源进行集中管理和查找,它需要调用由独立厂商在应用程序的ClassPath下的实现了JNDI接口的代码,但启动类加载器不可能“认识”这些外部代码。


为了解决这个问题,Java后来引入了线程上下文类加载器(Thread Context ClassLoader)。


这个类加载器可以通过java.lang.Thread类的setContextClassLoader()方法进行设置。


如果创建线程时没有设置,他将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。


有了线程上下文加载器,JNDI服务就可以使用它去加载所需要的SPI代码,也就是父类加载器请求子类加载器去完成类加载的动作,这样就打破了双亲委派机制。


3.2 JDBC


原生的JDBC中Driver驱动本身只是一个接口,并没有具体的实现,具体的实现是由不同数据库类型去实现的。


例如,MySQL的mysql-connector.jar中的Driver类具体实现的。


原生的JDBC中的类是放在rt.jar包,是由启动类加载器进行类加载的。


在JDBC中需要动态去加载不同数据库类型的Driver实现类,而mysql-connector.jar中的Driver实现类是用户自己写的代码,启动类加载器肯定是不能加载的,那就需要由应用程序启动类去进行类加载。


为了解决这个问题,也可以使用线程上下文类加载器(Thread Context ClassLoader)。


3.3  Tomcat容器


Tomcat是Servlet容器,它负责加载Servlet相关的jar包。


此外,Tomcat本身也是Java程序,也需要加载自身的类和一些依赖jar包。


这样就会带来下面的问题:



  1. 一个Tomcat容器下面,可以部署多个基于Servlet的Web应用,但如果这些Web应用下有同名的Servlet类,又不能产生冲突,需要相互独立加载和运行才行。

  2. 但如果多个Web应用,使用了相同的依赖,比如:SpringBoot、Mybatis等。这些依赖包所涉及的文件非常多,如果全部都独立,可能会导致JVM内存不足。也就是说,有些公共的依赖包,最好能够只加载一次。

  3. 我们还需要将Tomcat本身的类,跟Web应用的类隔离开。


这些原因导致,Tomcat没有办法使用传统的双亲委派机制加载类了。


那么,Tomcat加载类的机制是怎么样的?


图片



  • CommonClassLoader:是Tomcat最基本的类加载器,它加载的类可以被Tomcat容器和Web应用访问。

  • CatalinaClassLoader:是Tomcat容器私有的类加载器,加载类对于Web应用不可见。

  • SharedClassLoader:各个Web应用共享的类加载器,加载的类对于所有Web应用可见,但是对于Tomcat容器不可见。

  • WebAppClassLoader:各个Web应用私有的类加载器,加载类只对当前Web应用可见。比如不同war包应用引入了不同的Spring版本,这样能加载各自的Spring版本,相互隔离。


3.4 热部署


由于用户对程序动态性的追求,比如:代码热部署、代码热替换等功能,引入了OSGi(Open Service Gateway Initiative)。


OSGi中的每一个模块(称为Bundle)。


当程序升级或者更新时,可以只停用、重新安装然后启动程序的其中一部分,对企业来说这是一个非常诱人的功能。


OSGi的Bundle类加载器之间只有规则,没有固定的委派关系。


各个Bundle加载器是平级关系。


不是双亲委派关系。




作者:苏三说技术
来源:juejin.cn/post/7383894631312769074
收起阅读 »

ThreadLocal不香了,ScopedValue才是王道

ThreadLocal的缺点在Java中,当多个方法要共享一个变量时,我们会选择使用ThreadLocal来进行共享,比如:  以上代码将字符串“dadudu”通过设置到ThreadLocal中,从而可以做到在main()方法中赋值,在a(...
继续阅读 »

ThreadLocal的缺点

在Java中,当多个方法要共享一个变量时,我们会选择使用ThreadLocal来进行共享,比如:  以上代码将字符串“dadudu”通过设置到ThreadLocal中,从而可以做到在main()方法中赋值,在a()b()方法中获取值,从而共享值。

生命在于思考,我们来想想ThreadLocal有什么缺点:

  1. 第一个就是权限问题,也许我们只需要在main()方法中给ThreadLocal赋值,在其他方法中获取值就可以了,而上述代码中a()b()方法都有权限给ThreadLocal赋值,ThreadLocal不能做权限控制。
  2. 第二个就是内存问题,ThreadLocal需要手动强制remove,也就是在用完ThreadLocal之后,比如b()方法中,应该调用其remove()方法,但是我们很容易忘记调用remove(),从而造成内存浪费

ScopedValue

而JDK21中的新特性ScopedValue能不能解决这两个缺点呢?我们先来看一个ScopedValue的Demo: 

首先需要通过ScopedValue.newInstance()生成一个ScopedValue对象,然后通过ScopedValue.runWhere()方法给ScopedValue对象赋值,runWhere()的第三个参数是一个lambda表达式,表示作用域,比如上面代码就表示:给NAME绑定值为"dadudu",但是仅在调用a()方法时才生效,并且在执行runWhere()方法时就会执行lambda表达式。

比如上面代码的输出结果为: 

从结果可以看出在执行runWhere()时会执行a()a()方法中执行b()b()执行完之后返回到main()方法执行runWhere()之后的代码,所以,在a()方法和b()方法中可以拿到ScopedValue对象所设置的值,但是在main()方法中是拿不到的(报错了),b()方法之所以能够拿到,是因为属于a()方法调用栈中。

所以在给ScopedValue绑定值时都需要指定一个方法,这个方法就是所绑定值的作用域,只有在这个作用域中的方法才能拿到所绑定的值。

ScopedValue也支持在某个方法中重新开启新的作用域并绑定值,比如: 

以上代码中,在a()方法中重新给ScopedValue绑定了一个新值“xiaodudu”,并指定了作用域为c()方法,所以c()方法中拿到的值为“xiaodudu”,但是b()中仍然拿到的是“dadudu”,并不会受到影响,以上代码的输出结果为: 

甚至如果把代码改成: 

以上代码在a()方法中有两处调用了c()方法,我想大家能思考出c1c2输出结果分别是什么: 

所以,从以上分析可以看到,ScopedValue有一定的权限控制:就算在同一个线程中也不能任意修改ScopedValue的值,就算修改了对当前作用域(方法)也是无效的。另外ScopedValue也不需要手动remove,关于这块就需要分析它的实现原理了。

实现原理

大家先看下面代码,注意看下注释: 

执行main()方法时,main线程执行过程中会执行runWhere()方法三次,而每次执行runWhere()时都会生成一个Snapshot对象,Snapshot对象中记录了所绑定的值,而Snapshot对象有一个prev属性指向上一次所生成的Snapshot对象,并且在Thread类中新增了一个属性scopedValueBindings,专门用来记录当前线程对应的Snapshot对象。

比如在执行main()方法中的runWhere()时:

  1. 会先生成Snapshot对象1,其prev为null,并将Snapshot对象1赋值给当前线程的scopedValueBindings属性,然后执行a()方法
  2. 在执行a()方法中的runWhere()时,会先生成Snapshot对象2,其prevSnapshot对象1,并将Snapshot对象2赋值给当前线程的scopedValueBindings属性,使得在执行b()方法时能从当前线程拿到Snapshot对象2从而拿到所绑定的值,runWhere()内部在执行完b()方法后会取prev,从而取出Snapshot对象1,并将Snapshot对象1赋值给当前线程的scopedValueBindings属性,然后继续执行a()方法后续的逻辑,如果后续逻辑调用了get()方法,则会取当前线程的scopedValueBindings属性拿到Snapshot对象1,从Snapshot对象1中拿到所绑定的值就可以了,而对于Snapshot对象2由于没有引用则会被垃圾回收掉。

所以,在用ScopedValue时不需要手动remove。

好了,关于ScopedValue就介绍到这啦,下次继续分享JDK21新特性,欢迎大家关注我的公众号:Hoeller,第一时间接收我的原创技术文章,谢谢大家的阅读。


作者:IT周瑜
来源:juejin.cn/post/7287241480770928655
收起阅读 »

开发经理:谁在项目里面用Stream. paraller()直接gun

大家好,我是小玺,今天给大家分享一下项目中关于Stream.parallel() 碰到的坑。 Stream.parallel() 是Java 8及以上版本引入的Stream API中的一个方法,它用于将一个串行流转换为并行流。并行流可以在多个处理器上同时执行操...
继续阅读 »

大家好,我是小玺,今天给大家分享一下项目中关于Stream.parallel() 碰到的坑。


Stream.parallel() 是Java 8及以上版本引入的Stream API中的一个方法,它用于将一个串行流转换为并行流。并行流可以在多个处理器上同时执行操作,从而显著提高对大量数据进行处理的性能。


踩坑日记


某个大型项目,晚上十一点多有个用户对小部分数据进行某项批量操作后,接口大半天没有反应最后返回超时报错,但是过了一段时间后,出现了部分数据被修改成功,部分数据则没有反应。用户立马跳起来,打电话投诉到公司领导层,于是乎领导层对上至开发经理和PM,下至小开发进行会议批斗,要求马上排查并解决问题,毕竟项目这么大,当初也是要求测试做过压测的,怎么出现这么大的生产事故。


1712648893920.png


于是乎开发和实施运维分头行事,开发人员排查问题,实施人员先把问题数据维护好,不能应该用户使用。一群开发也是很疑惑,开发和测试环境都没法复现出问题,简单过一下代码也没看出个所以然,由于时间问题,不得不呼叫一手开发经理帮忙看看,开发经理后台接口看完Stream.parallel()进行的操作代码立马就炸了,git看了下提交人【会笑】,把这个开发从头到脚喷了一遍。


在对会笑单独进行了长达半小时的“耐心教育”后(ps:问题安排另一名开发同事修复),开发经理给团队的所有后端开发人员又都教育了一遍。原来会笑在用并行流的时候,没有考虑线程池配置和事务问题,把一堆数据进行了批量更新,Stream.parallel()并行流默认使用的是ForkJoinPool.commonPool()作为线程池,该线程池默认最大线程数就是CPU核数。


1712648957687.png


雀食对于一些初中级开发来说,开发过程中往往喜欢用一些比较新颖的写法来实现但是对新语法又是一知半解的,Stream.parallel()作为Java的新特性,也就成了其中一个反面教材。如果操作数据量不大的情况,其实没有必要用到Stream.parallel(),效率反而会变差。


注意事项



  1. 线程安全:并行流并不能保证线程安全性,因此,如果流中的元素是共享资源或操作本身不是线程安全的,你需要确保正确同步或使用线程安全的数据结构。

  2. 数据分区:Java的并行流机制会自动对数据进行分区,但在某些情况下,数据分区的开销可能大于并行带来的收益,特别是对于小规模数据集。

  3. 效率考量:并非所有的流操作都能从并行化中受益,有些操作(如短流操作或依赖于顺序的操作)并行执行反而可能导致性能下降。而且,过多的上下文切换也可能抵消并行带来的优势。

  4. 资源消耗:并行流默认使用的线程池大小可能与机器的实际物理核心数相适应,但也可能与其他并发任务争夺系统资源。

  5. 结果一致性:并行流并不保证执行的顺序性,也就是说,如果流操作的结果依赖于元素的处理顺序,则不应该使用并行流。

  6. 事务处理:在涉及到事务操作时,通常需要避免在并行流中直接处理,如上述例子所示,应当将事务边界放在单独的服务方法内,确保每个线程内的事务独立完成。


Tips:线程数可以通JVM启动参数-Djava.util.concurrent.ForkJoinPool.common.parallelism=20进行修改


作者:小玺
来源:juejin.cn/post/7355431482687864883
收起阅读 »

记一次难忘的json反序列化问题排查经历

前言 最近我在做知识星球中的商品秒杀系统,昨天遇到了一个诡异的json反序列化问题,感觉挺有意思的,现在拿出来跟大家一起分享一下,希望对你会有所帮助。 案发现场 我最近在做知识星球中的商品秒杀系统,写了一个filter,获取用户请求的header中获取JWT的...
继续阅读 »

前言


最近我在做知识星球中的商品秒杀系统,昨天遇到了一个诡异的json反序列化问题,感觉挺有意思的,现在拿出来跟大家一起分享一下,希望对你会有所帮助。


案发现场


我最近在做知识星球中的商品秒杀系统,写了一个filter,获取用户请求的header中获取JWT的token信息。


然后根据token信息,获取到用户信息。


在转发到业务接口之前,将用户信息设置到用户上下文当中。


这样接口中的业务代码,就能通过用户上下文,获取到当前登录的用户信息了。


我们的token和用户信息,为了性能考虑都保存到了Redis当中。


用户信息是一个json字符串。


当时在用户登录接口中,将用户实体,使用fastjson工具,转换成了字符串:


JSON.toJSONString(userDetails);

保存到了Redis当中。


然后在filter中,通过一定的key,获取Redis中的字符串,反序列化成用户实体。


使用的同样是fastjson工具:


JSON.parseObject(json, UserEntity.class);

但在反序列化的过程中,filter抛异常了:com.alibaba.fastjson.JSONException: illegal identifier : \pos 1, line 1, column 2{"accountNonExpired":true,"accountNonLocked":true,"authorities":[{"authority":"admin"}],"credentialsNonExpired":true,"enabled":true,"id":13,"password":"$2a$10$o3XfeGr0SHStAwLuJRW6y.kE0UTerQfv3SXrAcVLuJ6M3hEsC9RKe","roles":["admin"],"username":"admin"}


2 分析问题


我刚开始以为是json数据格式有问题。


将json字符串复制到在线json工具:http://www.sojson.com,先去掉化之后,再格式数据,发现json格式没有问题:![图片](p3-juejin.byteimg.com/tos-cn-i-k3…)


然后写了一个专门的测试类,将日志中打印的json字符串复制到json变量那里,使用JSON.parseObject方法,将json字符串转换成Map对象:


public class Test {

    public static void main(String[] args) {
        String json = "{"accountNonExpired":true,"accountNonLocked":true,"authorities":[{"authority":"admin"}],"credentialsNonExpired":true,"enabled":true,"id":13,"password":"$2a$10$o3XfeGr0SHStAwLuJRW6y.kE0UTerQfv3SXrAcVLuJ6M3hEsC9RKe","roles":["admin"],"username":"admin"}";
        Map map = JSON.parseObject(json, Map.class);
        // 输出解析后的 JSON 对象
        System.out.println(map);
    }
}

执行结果:


{password=$2a$10$o3XfeGr0SHStAwLuJRW6y.kE0UTerQfv3SXrAcVLuJ6M3hEsC9RKe, credentialsNonExpired=true, roles=["admin"], accountNonExpired=true, id=13, authorities=[{"authority":"admin"}], enabled=true, accountNonLocked=true, username=admin}

竟然转换成功了。


这就让我有点懵逼了。。。


为什么相同的json字符串,在Test类中能够正常解析,而在filter当中却不行?


当时怕搞错了,debug了一下filter,发现获取到的json数据,跟Test类中的一模一样:图片


带着一脸的疑惑,我做了下面的测试。


8000页BAT大佬写的刷题笔记,让我offer拿到手软


莫非是反序列化工具有bug?


3 改成gson工具


我尝试了一下将json的反序列化工具改成google的gson,代码如下:


 Map map = new Gson().fromJson(userJson, Map.class);

运行之后,报了一个新的异常:com.google.gson.JsonSyntaxException: java.lang.IllegalStateException: Expected BEGIN_OBJECT but was STRING at line 1 column 2 path $


这里提示json字符串中包含了:$


$是特殊字符,password是做了加密处理的,里面包含$.,这两种特殊字符。


为了快速解决问题,我先将这两个特字符替换成空字符串:


json = json.replace("$","").replace(".","");

日志中打印出的json中的password,已经不包含这两个特殊字符了:


2a10o3XfeGr0SHStAwLuJRW6ykE0UTerQfv3SXrAcVLuJ6M3hEsC9RKe

但调整之后代码报了下面的异常:com.google.gson.JsonSyntaxException: com.google.gson.stream.MalformedJsonException: Expected name at line 1 column 2 path $.


跟刚刚有点区别,但还是有问题。


4 改成jackson工具


我又尝试了一下json的反序列化工具,改成Spring自带的的jackson工具,代码如下:


ObjectMapper objectMapper = new ObjectMapper();
try {
    Map map = objectMapper.readValue(json, Map.class);
catch (JsonProcessingException e) {
    e.printStackTrace();
}

调整之后,反序列化还是报错:com.fasterxml.jackson.core.JsonParseException: Unexpected character ('' (code 92)): was expecting double-quote to start field name


3种反序列化工具都不行,说明应该不是fastjson的bug导致的当前json字符串,反序列化失败。


到底是什么问题呢?


5 转义


之前的数据,我在仔细看了看。


里面是对双引号,是使用了转义的,具体是这样做的:"


莫非还是这个转义的问题?


其实我之前已经注意到了转义的问题,但使用Test类测试过,没有问题。


当时的代码是这样的:


public class Test {

    public static void main(String[] args) {
        String json = "{"accountNonExpired":true,"accountNonLocked":true,"authorities":[{"authority":"admin"}],"credentialsNonExpired":true,"enabled":true,"id":13,"password":"$2a$10$o3XfeGr0SHStAwLuJRW6y.kE0UTerQfv3SXrAcVLuJ6M3hEsC9RKe","roles":["admin"],"username":"admin"}";
        Map map = JSON.parseObject(json, Map.class);
        // 输出解析后的 JSON 对象
        System.out.println(map);
    }
}

里面也包含了一些转义字符。


我带着试一试的心态,接下来,打算将转义字符去掉。


看看原始的json字符串,解析有没有问题。


怎么去掉转义字符呢?


手写工具类,感觉不太好,可能会写漏一些特殊字符的场景。


8000页BAT大佬写的刷题笔记,让我offer拿到手软


我想到了org.apache.commons包下的StringEscapeUtils类,它里面的unescapeJava方法,可以轻松去掉Java代码中的转义字符。


于是,我调整了一下代码:


json = StringEscapeUtils.unescapeJava(json);
JSON.parseObject(json, UserEntity.class);

这样处理之后,发现反序列化成功了。


总结


这个问题最终发现还是转义的问题。


那么,之前Test类中json字符串,也使用了转义,为什么没有问题?


当时的代码是这样的:


public class Test {

    public static void main(String[] args) {
        String json = "{"accountNonExpired":true,"accountNonLocked":true,"authorities":[{"authority":"admin"}],"credentialsNonExpired":true,"enabled":true,"id":13,"password":"$2a$10$o3XfeGr0SHStAwLuJRW6y.kE0UTerQfv3SXrAcVLuJ6M3hEsC9RKe","roles":["admin"],"username":"admin"}";
        Map map = JSON.parseObject(json, Map.class);
        System.out.println(map);
    }
}

但在filter中的程序,在读取到这个json字符串之后,发现该字符串中包含了``转义符号,程序自动把它变成了\


调整一下Test类的main方法,改成三个斜杠的json字符串:


public static void main(String[] args) {
    String json = "{\"accountNonExpired\":true,\"accountNonLocked\":true,\"authorities\":[{\"authority\":\"admin\"}],\"credentialsNonExpired\":true,\"enabled\":true,\"id\":13,\"password\":\"$2a$10$o3XfeGr0SHStAwLuJRW6y.kE0UTerQfv3SXrAcVLuJ6M3hEsC9RKe\",\"roles\":[\"admin\"],\"username\":\"admin\"}";
    Map map = JSON.parseObject(json, Map.class);
    System.out.println(map);
}

执行结果:Exception in thread "main" com.alibaba.fastjson.JSONException: illegal identifier : \pos 1, line 1, column 2{"accountNonExpired":true,"accountNonLocked":true,"authorities":[{"authority":"admin"}],"credentialsNonExpired":true,"enabled":true,"id":13,"password":"$2a$10$o3XfeGr0SHStAwLuJRW6y.kE0UTerQfv3SXrAcVLuJ6M3hEsC9RKe","roles":["admin"],"username":"admin"}抛出了跟文章最开始一样的异常。


说明其实就是转义的问题。


之前,我将项目的日志中的json字符串,复制到idea的Test的json变量中,当时将最外层的双引号一起复制过来了,保存的是1个斜杠的数据。


这个操作把我误导了。


而后面从在线的json工具中,把相同的json字符串,复制到idea的Test的json变量中,在双引号当中粘贴数据,保存的却是3个斜杠的数据,它会自动转义。


让我意识到了问题。


好了,下次如果遇到类似的问题,可以直接使用org.apache.commons包下的StringEscapeUtils类,先去掉转义,再反序列化,这样可以快速解决问题。


此外,这次使用了3种不同的反序列化工具,也看到了其中的一些差异。


作者:苏三说技术
来源:juejin.cn/post/7385081262871003175
收起阅读 »

当了程序员之后?(真心话)

分享是最有效的学习方式。 博客:blog.ktdaddy.com/ 地铁上刷到一个话题,觉得挺有意思的,如下。 看到很多朋友在下面吐槽,有说加班是真的多,有说找对象是真的难,有说程序员爱穿格子衫是假爱背电脑是真的等等,大家吐槽得都挺欢乐的。 老猫也开始复...
继续阅读 »

分享是最有效的学习方式。
博客:blog.ktdaddy.com/



地铁上刷到一个话题,觉得挺有意思的,如下。


1709213979295.png


看到很多朋友在下面吐槽,有说加班是真的多,有说找对象是真的难,有说程序员爱穿格子衫是假爱背电脑是真的等等,大家吐槽得都挺欢乐的。


1709824704115.png


老猫也开始复盘这些年的经历,更多想聊的可能还是一个后端程序员的真实感悟。


入行


俗话说“男怕入错行,女怕嫁错郎。”相信很多朋友在进入一个行业之前都是深思熟虑的,亦或者是咨询过一些人,亦或者是查阅了挺多资料。然而老猫入行则相当奇葩,不是蓄谋已久,而是心血来潮。


一切都得从一部电视剧开始,不晓得大家有没有看过这部电视剧,佟丽娅主演的“我的经济适用男”。


1709215705556.png


12年的一部电视剧,挺老了,主要女主放弃富二代的追求和"成熟稳重老实巴交的IT男"好上了的桥段。当时心智单纯的老猫可谓看的是热血沸腾啊。一拍桌子,“发可油,劳资今后就要当那个男主,这结局多好啊,抱得美人归啊这是,我要学IT!”。当时老猫的专业是电子信息类的专业,后来基本就放弃了本专业,大学基本逃课就跑去学软件去了。


就这么上了贼船,一晃十年过去了。多年前,躲在实验室里,开发了一个简单的坦克大战的游戏,感觉自己是最牛逼的,子弹爱怎么飞怎么飞,坦克能开多块就开多快,那时候觉得自己就是这个IT世界的主角,“control evety thing”。在这样一个程序的世界里,所有的事儿都是自己说了算。


踏上社会后,遭遇社会惨无人道地毒打之后,发现要做的就是提升造火箭吹牛逼的能力,工作中是个crud-boy。键盘上磨损最严重的那几个键是“ctrl”,“c”,“v”,“x”。当年那个意气风发的少年已经不复存在,我是一个弱鸡螺丝钉。


1709217726156.png


工作十年


大部分后端程序员也主要是围绕着业务在转,所以crud可能占了大部分时间。


话虽如此,但还是有点除此以外的收获,这些收获甚至潜移默化地影响着我的生活。


技术日新月异,今天这个框架,明天那个架构,今天这种实现牛逼,明天那种部署更6等等,到头来发现自己一直都是在追着技术跑。也确实如果不奔跑的话,可能就会被淘汰。作为程序员来说适应变化也是程序员的一种品质,但是老猫觉得具备下面这些可能会更加重要一些,这些可能也是唯一不变的。


抽象思维很重要


第一次听到“架构师”这个职位的时候,觉得那一定是一个需要超强技术能力的人才能胜任的岗位。


后来才发现原来架构师也分种类,“业务架构”,“技术架构”等等。再后来发现无论哪种架构,其实他们身上会有一种共同的东西,那就是优秀的抽象思维。


啥是抽象思维?百度百科上是这么说的:


抽象思维,又称词的思维或者逻辑思维,是指用词进行判断、推理并得出结论的过程。
抽象思维以词为中介来反映现实。这是思维的最本质特征,也是人的思维和动物心理的根本区别。

说的比较官方,甚至有点不好懂。


大家以前上语文课的时候,有没有做过阅读理解,老师在讲课的时候常常我们概述一下这段文字到底讲了什么东西,越精简越好,可能要求20个字以内。其实这个过程就是在锻炼咱们的抽象思维能力以及概括能力。


在软件后端领域,当业务传达某一个需求的时候,往往需要产品去提炼一道,那么此时就是产品抽象,继而产品又将需求传达给相关的研发负责人,研发负责人设计出相关的实现模型,那么这又是技术抽象,这些抽象的过程就是将复杂的业务流程和逻辑转化为可管理和可重用的组件的过程。它的目的是简化系统的实现,聚焦于应用程序的核心功能,同时隐藏不必要的细节。抽象后设计出各种基础能力,通过对基础能力的组合和拼接,支持复杂多变的业务逻辑和业务形态。


gw1.png


具备抽象思维,能够让我们从复杂的业务中迅速切入业务关键点。在生活中可能表现在透过现象看到本质,或者碰到问题能够快速给出有效解决方案或思路。例如老猫上次遇到的“真-丢包事件”。


分层思维很重要


说到分层思维,应该准确地来说是建立在能够清晰的抽象出事务本质的基础上,而后再去做分层。


很多地方都会存在分层思想。生活中就有,大家双休日没事的时候估计会逛商场,商城的模式一般就是底层停车场,一层超市,二层卖服装的,三层儿童乐园,卖玩具的,四层吃饭看电影娱乐的等等。


再去聊到技术上的分层思想,例如OSI七层模型,大家在面试的时候甚至都碰到过。


gw2.png


抛开这些,其实我们对自己当前负责的一些业务,一些系统也需要去做一些分层划分,这些分层可以让我们更好地看清业务系统之间的关系。例如老猫之前梳理的一张图。


gw3.png


通过这样的分层梳理,我们可能更好地理解当前的系统组成以及层级关系。(备注一下,老猫这里画图工具用的还是wps绘制的)。


结构化思维很重要


结构化思维又是咋回事儿?
不着急,打个比方,咱们看下面一组数据:
213421790346567560889
现在有个要求,咱们需要记下这些数字,以及出现的次数。短时间内想要记住可能比较困难
如果我们把这些数字的内容调整下,变成下面这样:
00112233445566778899
是不是清晰了很多?


所谓的结构化思维,就是从无序到有序的一种思考过程,将搜集到的信息、数据、知识等素材按一定的逻辑进行分析、整理,呈现出有序的结构,继而化繁为简。有结构的信息更适合大脑记忆和理解。


人类大脑在处理信息的时候,有两个特点:


第一,不能一次太多,太多信息会让我们的大脑觉得负荷过大;乔治·米勒在他的论文《奇妙的数字7±2》中提出,人类大脑短期记忆无法一次容纳7个以上的记忆项目,比较容易记住的是3个项目,当然最容易的是1个。


第二,喜欢有规律的信息。有规律的信息能减少复杂度,Mitchell Waldrop在《复杂》一书中,提出一种用信息熵来进行复杂性度量的方法,所谓信息熵就是一条信息包含信息量的大小。举个例子,假设一条消息由符号A、C、G和T组成。如果序列高度有序,很容易描述,例如“A A A A A A A … A”,则熵为零。而完全随机的序列则有最大熵值。


ccfc037aa9b4e852ef2a16f8e58c4a86.png


老猫在写文章的时候喜欢先列一下要写的提纲,然后再跟着提纲一点一点的往下写,写定义,写实现,写流程。


虽然本文偷了个懒,没有写思维导图,老猫一般再聊到干货的时候都会和大家先列一下提纲。这种提纲其实也是结构化的一种。当我们遇到复杂系统需求的时候,咱们不妨先列个提纲,将需要做的按照自己定义好的顺序罗列好,这样解决起来会更加容易一些。


太过理性可能也不好


程序员做久了,做一件事情的时候都会去想着先做什么然后做什么一步一步,有时候会显得过于机械,不知变通,
有时候可能也会太过较真,大直男显得情商比较低,会多多少少给别人带去一些不便,记得在银行办理业务的时候会指出业务员说话的逻辑漏洞,然后不停地追问,最终可能导致业务员尴尬地叫来业务经理解释等等。


程序员思维做事情,可能在日常生活中比较严谨,但是很多时候还是会显得比较死板。


总结


以上是老猫觉得除了技术以外,觉得一个后端程序员应该具备的一些思考方式以及工作方式,当然也可能只是老猫的方法论,如果大家有其他的工作领悟,也欢迎大家留言,大家一起分享一下经验。


作者:程序员老猫
来源:juejin.cn/post/7343493283073507379
收起阅读 »

Flutter桌面应用开发:深入Flutter for Desktop

Flutter 是一个开源的 UI 工具包,用于构建高性能、高保真、多平台的应用程序,包括移动、Web 和桌面。 安装和环境配置 安装Prerequisites: Java Development Kit (JDK): 安装JDK 8或更高版本,因为Flutt...
继续阅读 »

Flutter 是一个开源的 UI 工具包,用于构建高性能、高保真、多平台的应用程序,包括移动、Web 和桌面。


安装和环境配置


安装Prerequisites:


Java Development Kit (JDK): 安装JDK 8或更高版本,因为Flutter要求JDK 1.8或更高。配置环境变量JAVA_HOME指向JDK的安装路径。
Flutter SDK:


下载Flutter SDK:


访问Flutter官方网站下载适用于Windows的Flutter SDK压缩包。
解压并选择一个合适的目录安装,例如 C:\src\flutter
将Flutter SDK的bin目录添加到系统PATH环境变量中。例如,添加 C:\src\flutter\bin


Git:


如果还没有安装Git,可以从Git官网下载并安装。
在安装过程中,确保勾选 "Run Git from the Windows Command Prompt" 选项。


Flutter Doctor:


打开命令提示符或PowerShell,运行 flutter doctor 命令。这将检查你的环境是否完整,并列出任何缺失的组件,如Android Studio、Android SDK等。


Android Studio (如果计划开发Android应用):


下载并安装Android Studio,它包含了Android SDK和AVD Manager。
安装后,通过Android Studio设置向导配置Android SDK和AVD。
确保在系统环境变量中配置了ANDROID_HOME指向Android SDK的路径,通常是\Sdk


iOS Development (如果计划开发iOS应用):


你需要安装Xcode和Command Line Tools,这些只适用于macOS。
在终端中运行xcode-select --install以安装必要的命令行工具。


验证安装:


运行 flutter doctor --android-licenses 并接受所有许可证(如果需要)。
再次运行 flutter doctor,确保所有必需的组件都已安装并配置正确。


开始开发:


创建你的第一个Flutter项目:flutter create my_first_app
使用IDE(如VS Code或Android Studio)打开项目,开始编写和运行代码。


基础知识


在Flutter桌面应用开发中,Dart语言是核心。基础Flutter应用展示来学习Dart语言魅力:


import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}

class MyHomePage extends StatefulWidget {
MyHomePage({Key? key, required this.title}) : super(key: key);

final String title;

@override
_MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;

void _incrementCounter() {
setState(() {
// This call to setState tells the Flutter framework that something has
// changed in this State object, which causes it to re-build the widget.
_counter++;
});
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'You have pushed the button this many times:',
),
Text(
'$_counter',
style: Theme.of(context).textTheme.headline4,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: Icon(Icons.add),
), // This trailing comma makes auto-formatting nicer for build methods.
);
}
}

导入库:



  • import 'package:flutter/material.dart';: 导入Flutter的Material库,包含了许多常用的UI组件。


主入口点:


void main() => runApp(MyApp());: 应用的主入口点,启动MaterialApp。


MyApp StatelessWidget:


MyApp是一个无状态的Widget,用于配置应用的全局属性。


MyHomePage StatefulWidget:



  • MyHomePage是一个有状态的Widget,它有一个状态类_MyHomePageState,用于管理状态。

  • title参数在构造函数中传递,用于初始化AppBar的标题。


_MyHomePageState:



  • _counter变量用于存储按钮点击次数。

  • _incrementCounter方法更新状态,setState通知Flutter需要重建Widget。

  • build方法构建Widget树,根据状态_counter更新UI。


UI组件:



  • Scaffold提供基本的布局结构,包括AppBar、body和floatingActionButton。

  • FloatingActionButton是一个浮动按钮,点击时调用_incrementCounter。

  • Text组件显示文本,AppBar标题和按钮点击次数。

  • ColumnCenter用于布局管理。


Flutter应用


创建项目目录:


选择一个合适的位置创建一个新的文件夹,例如,你可以命名为my_flutter_app。


初始化Flutter项目:


打开终端或命令提示符,导航到你的项目目录,然后运行以下命令来初始化Flutter应用:


   cd my_flutter_app
flutter create .

这个命令会在当前目录下创建一个新的Flutter应用。


检查项目:


初始化完成后,你应该会看到以下文件和文件夹:



  • lib/:包含你的Dart代码,主要是main.dart文件。

  • pubspec.yaml:应用的配置文件,包括依赖项。

  • android/ios/:分别用于Android和iOS的原生项目配置。


运行应用:


为了运行应用,首先确保你的模拟器或物理设备已经连接并准备好。然后在终端中运行:


   flutter run

这将构建你的应用并启动它在默认的设备上。


编辑代码:


打开lib/main.dart文件,这是你的应用的入口点。你可以在这里修改代码以自定义你的应用。例如,你可以修改MaterialApphome属性来指定应用的初始屏幕。


热重载:


当你修改代码并保存时,可以使用flutter pub get获取新依赖,然后按r键(或在终端中输入flutter reload)进行热重载,快速查看代码更改的效果。


布局和组件


Flutter提供了丰富的Widget库来构建复杂的布局。下面是一个使用Row, Column, Expanded, 和 ListView的简单布局示例,展示如何组织UI组件。


import 'package:flutter/material.dart';

void main() {
runApp(MyApp());
}

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Desktop Layout Example',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: Scaffold(
appBar: AppBar(title: Text("Desktop App Layout")),
body: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
RaisedButton(onPressed: () {}, child: Text('Button 1')),
RaisedButton(onPressed: () {}, child: Text('Button 2')),
],
),
SizedBox(height: 20),
Expanded(
child: ListView.builder(
itemCount: 20,
itemBuilder: (context, index) {
return ListTile(title: Text('Item $index'));
},
),
),
],
),
),
);
}
}

Column和Row是基础的布局Widget,Expanded用于占据剩余空间,ListView.builder动态构建列表项,展示了如何灵活地组织UI元素。


状态管理和数据流


在Flutter中,状态管理是通过Widget树中的状态传递和更新来实现的。最基础的是使用StatefulWidgetsetState方法,但复杂应用通常会采用更高级的状态管理方案,如ProviderRiverpodBloc


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

class Counter with ChangeNotifier {
int _count = 0;

int get count => _count;

void increment() {
_count++;
notifyListeners();
}
}

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (context) => Counter(),
child: MaterialApp(
home: Scaffold(
body: Center(
child: Consumer(
builder: (context, counter, child) {
return Text(
'${counter.count}',
style: TextStyle(fontSize: 24),
);
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
Provider.of(context, listen: false).increment();
},
child: Icon(Icons.add),
),
),
),
);
}
}

状态管理示例引入了Provider库,ChangeNotifier用于定义状态,ChangeNotifierProvider在树中提供状态,Consumer用于消费状态并根据状态更新UI,Provider.of用于获取状态并在按钮按下时调用increment方法更新状态。这种方式解耦了状态和UI,便于维护和测试。


路由和导航


Flutter使用Navigator进行页面间的导航。


import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Navigation Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
initialRoute: '/',
routes: {
'/': (context) => HomePage(),
'/details': (context) => DetailsPage(),
},
);
}
}

class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Home Page')),
body: Center(
child: ElevatedButton(
child: Text('Go to Details'),
onPressed: () {
Navigator.pushNamed(context, '/details');
},
),
),
);
}
}

class DetailsPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Details Page')),
body: Center(child: Text('This is the details page')),
);
}
}

MaterialApproutes属性定义了应用的路由表,initialRoute指定了初始页面,Navigator.pushNamed用于在路由表中根据名称导航到新页面。这展示了如何在Flutter中实现基本的页面跳转逻辑。


响应式编程


Flutter的UI是完全响应式的,意味着当状态改变时,相关的UI部分会自动重建。使用StatefulWidgetsetState方法是最直接的实现方式。


import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: CounterPage(),
);
}
}

class CounterPage extends StatefulWidget {
@override
_CounterPageState createState() => _CounterPageState();
}

class _CounterPageState extends State<CounterPage> {
int _counter = 0;

void _incrementCounter() {
setState(() {
_counter++;
});
}

@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'You have pushed the button this many times:',
),
Text(
'$_counter',
style: Theme.of(context).textTheme.headline4,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: Icon(Icons.add),
),
);
}
}

StatefulWidgetsetState的使用体现了Flutter的响应式特性。当调用_incrementCounter方法更新_counter状态时,Flutter框架会自动调用build方法,仅重绘受影响的部分,实现了高效的UI更新。这种模式确保了UI始终与最新的状态保持一致,无需手动管理UI更新逻辑。


平台交互


Flutter提供了Platform类来与原生平台进行交互。


import 'package:flutter/foundation.dart';

void main() {
runApp(MyApp());
}

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Platform.isAndroid ? AndroidScreen() : DesktopScreen(),
);
}
}

class AndroidScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Text('This is an Android screen');
}
}

class DesktopScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Text('This is a Desktop screen');
}
}

性能优化


优化主要包括减少不必要的渲染、使用高效的Widget和数据结构、压缩资源等。例如,使用const关键字创建常量Widget以避免不必要的重建:


class MyWidget extends StatelessWidget {
const MyWidget({Key? key}) : super(key: key);

@override
Widget build(BuildContext context) {
return Container(
color: Colors.blue,
child: const Text('Optimized Widget', style: TextStyle(fontSize: 24)),
);
}
}

调试和测试


Flutter提供了强大的调试工具,如热重载、断点、日志输出等。测试方面,可以使用test包进行单元测试和集成测试:


import 'package:flutter_test/flutter_test.dart';

void main() {
test('Counter increments correctly', () {
final counter = Counter(0);
expect(counter.value, equals(0));
counter.increment();
expect(counter.value, equals(1));
});
}

打包和发布


发布Flutter应用需要构建不同平台的特定版本。在桌面环境下,例如Windows,可以使用以下命令:


flutter build windows

这将生成一个.exe文件,可以分发给用户。确保在pubspec.yaml中配置好应用的元数据,如版本号和描述。


Flutter工作原理分析


Flutter Engine:



  • Flutter引擎是Flutter的基础,它负责渲染、事件处理、文本布局、图像解码等功能。引擎是用C++编写的,部分用Java或Objective-C/Swift实现原生平台的接口。

  • Skia是Google的2D图形库,用于绘制UI。在桌面应用中,Skia直接与操作系统交互,提供图形渲染。

  • Dart VM运行Dart代码,提供垃圾回收和即时编译(JIT)或提前编译(AOT)。


Flutter Framework:



  • Flutter框架是用Dart编写的,它定义了Widget、State和Layout等概念,以及动画、手势识别和数据绑定等机制。

  • WidgetsFlutterBinding是框架与引擎的桥梁,它实现了将Widget树转换为可绘制的命令,这些命令由引擎执行。


Widgets:



  • Flutter中的Widget是UI的构建块,它们是不可变的。StatefulWidget和State类用于管理可变状态。

  • 当状态改变时,setState方法被调用,导致Widget树重新构建,进而触发渲染。


Plugins:



  • 插件是Flutter与原生平台交互的方式,它们封装了原生API,使得Dart代码可以访问操作系统服务,如文件系统、网络、传感器等。

  • 桌面应用的插件需要针对每个目标平台(Windows、macOS、Linux)进行实现。


编译和运行流程:



  • 使用flutter build命令,Dart代码会被编译成原生代码(AOT编译),生成可执行文件。

  • 运行时,Flutter引擎加载并执行编译后的代码,同时初始化插件和设置渲染管线。


调试和热重载:



  • Flutter支持热重载,允许开发者在运行时快速更新代码,无需重新编译整个应用。

  • 调试工具如DevTools提供了对应用性能、内存、CPU使用率的监控,以及源代码级别的调试。


性能优化:



  • Flutter通过AOT编译和Dart的垃圾回收机制来提高性能。

  • 使用const关键字创建Widget可以避免不必要的重建,减少渲染开销。


作者:天涯学馆
来源:juejin.cn/post/7378015213347913791
收起阅读 »

一条SQL 最多能查询出来多少条记录?

问题 一条这样的 SQL 语句能查询出多少条记录? select * from user 表中有 100 条记录的时候能全部查询出来返回给客户端吗? 如果记录数是 1w 呢? 10w 呢? 100w 、1000w 呢? 虽然在实际业务操作中我们不会这么干,...
继续阅读 »

问题


一条这样的 SQL 语句能查询出多少条记录?


select * from user 

表中有 100 条记录的时候能全部查询出来返回给客户端吗?


如果记录数是 1w 呢? 10w 呢? 100w 、1000w 呢?


虽然在实际业务操作中我们不会这么干,尤其对于数据量大的表不会这样干,但这是个值得想一想的问题。


寻找答案


前提:以下所涉及资料全部基于 MySQL 8


max_allowed_packet


在查询资料的过程中发现了这个参数 max_allowed_packet



上图参考了 MySQL 的官方文档,根据文档我们知道:



  • MySQL 客户端 max_allowed_packet 值的默认大小为 16M(不同的客户端可能有不同的默认值,但最大不能超过 1G)

  • MySQL 服务端 max_allowed_packet 值的默认大小为 64M

  • max_allowed_packet 值最大可以设置为 1G(1024 的倍数)


然而 根据上图的文档中所述



The maximum size of one packet or any generated/intermediate string,or any parameter sent by the mysql_smt_send_long_data() C API function




  • one packet

  • generated/intermediate string

  • any parameter sent by the mysql_smt_send_long_data() C API function


这三个东东具体都是什么呢? packet 到底是结果集大小,还是网络包大小还是什么? 于是 google 了一下,搜索排名第一的是这个:



根据 “Packet Too Large” 的说明, 通信包 (communication packet) 是



  • 一个被发送到 MySQL 服务器的单个 SQL 语句

  • 或者是一个被发送到客户端的单行记录

  • 或者是一个从主服务器 (replication source server) 被发送到从属服务器 (replica) 的二进制日志事件。


1、3 点好理解,这也同时解释了,如果你发送的一条 SQL 语句特别大可能会执行不成功的原因,尤其是insert update 这种,单个 SQL 语句不是没有上限的,不过这种情况一般不是因为 SQL 语句写的太长,主要是由于某个字段的值过大,比如有 BLOB 字段。


那么第 2 点呢,单行记录,默认值是 64M,会不会太大了啊,一行记录有可能这么大的吗? 有必要设置这么大吗? 单行最大存储空间限制又是多少呢?


单行最大存储空间


MySQL 单行最大宽度是 65535 个字节,也就是 64KB 。无论是 InnoDB 引擎还是 MyISAM 引擎。



通过上图可以看到 超过 65535 不行,不过请注意其中的错误提示:“Row size too large. The maximum row size for the used table type, not counting BLOBs, is 65535” ,如果字段是变长类型的如 BLOB 和 TEXT 就不包括了,那么我们试一下用和上图一样的字段长度,只把最后一个字段的类型改成 BLOB 和 TEXT


mysql> CREATE TABLE t (a VARCHAR(10000), b VARCHAR(10000),
c VARCHAR(10000), d VARCHAR(10000), e VARCHAR(10000),
f VARCHAR(10000), g TEXT(6000)) ENGINE=InnoDB CHARACTER SET latin1;
Query OK, 0 rows affected (0.02 sec)

可见无论 是改成 BLOB 还是 TEXT 都可以成功。但这里请注意,字符集是 latin1 可以成功,如果换成 utf8mb4 或者 utf8mb3 就不行了,会报错,仍然是 :“Row size too large. The maximum row size for the used table type, not counting BLOBs, is 65535.” 为什么呢?


因为虽然不包括 TEXT 和 BLOB, 但总长度还是超了!


我们先看一下这个熟悉的 VARCHAR(255) , 你有没有想过为什么用 255,不用 256?



在 4.0 版本以下,varchar(255) 指的是 255 个字节,使用 1 个字节存储长度即可。当大于等于 256 时,要使用 2 个字节存储长度。所以定义 varchar(255) 比 varchar(256) 更好。


但是在 5.0 版本以上,varchar(255) 指的是 255 个字符,每个字符可能占用多个字节,例如使用 UTF8 编码时每个汉字占用 3 字节,使用 GBK 编码时每个汉字占 2 字节。



例子中我们用的是 MySQL8 ,由于字符集是 utf8mb3 ,存储一个字要用三个字节, 长度为 255 的话(列宽),总长度要 765 字节 ,再加上用 2 个字节存储长度,那么这个列的总长度就是 767 字节。所以用 latin1 可以成功,是因为一个字符对应一个字节,而 utf8mb3 或 utf8mb4 一个字符对应三个或四个字节,VARCHAR(10000) 就可能等于要占用 30000 多 40000 多字节,比原来大了 3、4 倍,肯定放不下了。


另外,还有一个要求,列的宽度不要超过 MySQL 页大小 (默认 16K)的一半,要比一半小一点儿。 例如,对于默认的 16KB InnoDB 页面大小,最大行大小略小于 8KB。


下面这个例子就是超过了一半,所以报错,当然解决办法也在提示中给出了。


mysql> CREATE TABLE t4 (
c1 CHAR(255),c2 CHAR(255),c3 CHAR(255),
c4 CHAR(255),c5 CHAR(255),c6 CHAR(255),
c7 CHAR(255),c8 CHAR(255),c9 CHAR(255),
c10 CHAR(255),c11 CHAR(255),c12 CHAR(255),
c13 CHAR(255),c14 CHAR(255),c15 CHAR(255),
c16 CHAR(255),c17 CHAR(255),c18 CHAR(255),
c19 CHAR(255),c20 CHAR(255),c21 CHAR(255),
c22 CHAR(255),c23 CHAR(255),c24 CHAR(255),
c25 CHAR(255),c26 CHAR(255),c27 CHAR(255),
c28 CHAR(255),c29 CHAR(255),c30 CHAR(255),
c31 CHAR(255),c32 CHAR(255),c33 CHAR(255)
) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET latin1;
ERROR 1118 (42000): Row size too large (> 8126). Changing some columns to TEXT or BLOB may help.
In current row format, BLOB prefix of 0 bytes is stored inline.

那么为什么是 8K,不是 7K,也不是 9K 呢? 这么设计的原因可能是:MySQL 想让一个数据页中能存放更多的数据行,至少也得要存放两行数据(16K)。否则就失去了 B+Tree 的意义。B+Tree 会退化成一个低效的链表。


你可能还会奇怪,不超过 8K ?你前面的例子明明都快 64K 也能存下,那 8K 到 64K 中间这部分怎么解释?


答:如果包含可变长度列的行超过 InnoDB 最大行大小, InnoDB 会选择可变长度列进行页外存储,直到该行适合 InnoDB ,这也就是为什么前面有超过 8K 的也能成功,那是因为用的是VARCHAR这种可变长度类型。



当你往这个数据页中写入一行数据时,即使它很大将达到了数据页的极限,但是通过行溢出机制。依然能保证你的下一条数据还能写入到这个数据页中。


我们通过 Compact 格式,简单了解一下什么是 页外存储行溢出


MySQL8 InnoDB 引擎目前有 4 种 行记录格式:



  • REDUNDANT

  • COMPACT

  • DYNAMIC(默认 default 是这个)

  • COMPRESSED


行记录格式 决定了其行的物理存储方式,这反过来又会影响查询和 DML 操作的性能。



Compact 格式的实现思路是:当列的类型为 VARCHAR、 VARBINARY、 BLOB、TEXT 时,该列超过 768byte 的数据放到其他数据页中去。



在 MySQL 设定中,当 varchar 列长度达到 768byte 后,会将该列的前 768byte 当作当作 prefix 存放在行中,多出来的数据溢出存放到溢出页中,然后通过一个偏移量指针将两者关联起来,这就是 行溢出机制



假如你要存储的数据行很大超过了 65532byte 那么你是写入不进去的。假如你要存储的单行数据小于 65535byte 但是大于 16384byte,这时你可以成功 insert,但是一个数据页又存储不了你插入的数据。这时肯定会行溢出!



MySQL 这样做,有效的防止了单个 varchar 列或者 Text 列太大导致单个数据页中存放的行记录过少的情况,避免了 IO 飙升的窘境。


单行最大列数限制


mysql 单表最大列数也是有限制的,是 4096 ,但 InnoDB 是 1017



实验


前文中我们疑惑 max_allowed_packet 在 MySQL8 的默认值是 64M,又说这是限制单行数据的,单行数据有这么大吗? 在前文我们介绍了行溢出, 由于有了 行溢出 ,单行数据确实有可能比较大。


那么还剩下一个问题,max_allowed_packet 限制的确定是单行数据吗,难道不是查询结果集的大小吗 ? 下面我们做个实验,验证一下。


建表


CREATE TABLE t1 (
c1 CHAR(255),c2 CHAR(255),c3 CHAR(255),
c4 CHAR(255),c5 CHAR(255),c6 CHAR(255),
c7 CHAR(255),c8 CHAR(255),c9 CHAR(255),
c10 CHAR(255),c11 CHAR(255),c12 CHAR(255),
c13 CHAR(255),c14 CHAR(255),c15 CHAR(255),
c16 CHAR(255),c17 CHAR(255),c18 CHAR(255),
c19 CHAR(255),c20 CHAR(255),c21 CHAR(255),
c22 CHAR(255),c23 CHAR(255),c24 CHAR(255),
c25 CHAR(255),c26 CHAR(255),c27 CHAR(255),
c28 CHAR(255),c29 CHAR(255),c30 CHAR(255),
c31 CHAR(255),c32 CHAR(192)
) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET latin1;


经过测试虽然提示的是 Row size too large (> 8126) 但如果全部长度加起来是 8126 建表不成功,最终我试到 8097 是能建表成功的。为什么不是 8126 呢 ?可能是还需要存储一些其他的东西占了一些字节吧,比如隐藏字段什么的。


用存储过程造一些测试数据,把表中的所有列填满


create
definer = root@`%` procedure generate_test_data()
BEGIN
DECLARE i INT DEFAULT 0;
DECLARE col_value TEXT DEFAULT REPEAT('a', 255);
WHILE i < 5 DO
INSERT INTO t1 VALUES
(
col_value, col_value, col_value,
col_value, REPEAT('b', 192)
);
SET i = i + 1;
END WHILE;
END;


max_allowed_packet 设置的小一些,先用 show VARIABLES like '%max_allowed_packet%'; 看一下当前的大小,我的是 67108864 这个单位是字节,等于 64M,然后用 set global max_allowed_packet =1024 将它设置成允许的最小值 1024 byte。 设置好后,关闭当前查询窗口再新建一个,然后再查看:



这时我用 select * from t1; 查询表数据时就会报错:



因为我们一条记录的大小就是 8K 多了,所以肯定超过 1024byte。可见文档的说明是对的, max_allowed_packet 确实是可以约束单行记录大小的。


答案


文章写到这里,我有点儿写不下去了,一是因为懒,另外一个原因是关于这个问题:“一条 SQL 最多能查询出来多少条记录?” 肯定没有标准答案


目前我们可以知道的是:



  • 你的单行记录大小不能超过 max_allowed_packet

  • 一个表最多可以创建 1017 列 (InnoDB)

  • 建表时定义列的固定长度不能超过 页的一半(8k,16k...)

  • 建表时定义列的总长度不能超过 65535 个字节


如果这些条件我们都满足了,然后发出了一个没有 where 条件的全表查询 select * 那么.....


首先,你我都知道,这种情况不会发生在生产环境的,如果真发生了,一定是你写错了,忘了加条件。因为几乎没有这种要查询出所有数据的需求。如果有,也不能开发,因为这不合理。


我考虑的也就是个理论情况,从理论上讲能查询出多少数据不是一个确定的值,除了前文提到的一些条件外,它肯定与以下几项有直接的关系



  • 数据库的可用内存

  • 数据库内部的缓存机制,比如缓存区的大小

  • 数据库的查询超时机制

  • 应用的可用物理内存

  • ......


说到这儿,我确实可以再做个实验验证一下,但因为懒就不做了,大家有兴趣可以自己设定一些条件做个实验试一下,比如在特定内存和特定参数的情况下,到底能查询出多少数据,就能看得出来了。


虽然我没能给出文章开头问题的答案,但通过寻找答案也弄清楚了 MySQL 的一些限制条件,并加以了验证,也算是有所收获了。


参考



作者:xiaohezi
来源:juejin.cn/post/7255478273652834360
收起阅读 »

使用uniapp制作安卓app容器

1. 背景项目需要做一个安卓app,而且不需要上架应用市场,部门也没有安卓开发,想着就套个webview就行了吧。没有选择react native之类的是因为这些工具需要安装很多环境工具,我只是开发一个壳子没必要这么复杂。用webview也方便快速修复页面问题...
继续阅读 »

1. 背景

项目需要做一个安卓app,而且不需要上架应用市场,部门也没有安卓开发,想着就套个webview就行了吧。没有选择react native之类的是因为这些工具需要安装很多环境工具,我只是开发一个壳子没必要这么复杂。

webview也方便快速修复页面问题。

所以最后选择了uniapp,但是uniapp本身就是套在一个大的webview下的, 所以再套一个webview难免会有一些意想不到的问题,下面就是一些踩过的坑记录。

2. 项目初始化

新建项目就默认模板就行,我只需要壳子。

image.png 启动了之后可以看到有两个调试工具

image.png

第一个就是网页上常用的vue调试工具,可以看到vue组件属性啥的,第二个就是类似chrome的控制台,但是无法查看元素,还有就是必须让设备和电脑在同一个网段下才行,不然连接不上。

hbuilder的控制台本身也有一些输出,比如页面的console

image.png

但是这里输出对象的时候不是很方便查看,如果你需要的话就打开上面说的第二个调试工具。

3. webview使用

整个项目很简单,大概就这样一个页面

<template>
<web-view :src='PROJECT_PATH' @message="onMessage">web-view>
template>
<script>
// ...
script>

3.1 网页与app通信

这是最重要的一个功能,可以参考官方文档

网页和app交互总结起来就是这两点:

  • 网页 -> APPwindow.uni.postMessage();
  • APP -> 网页webview.evalJS()

3.1.1. 网页 -> APP

首先要在项目中引入uni.webview.js,这个就相当于jsbridge,可以让网页操作uniapp

初始化完成后会在window上挂载一个uni对象,通过uni.postMessage就能往app发送消息,app中监听onMessage就行。

这里有几个小坑:

  1. 发送的格式window.uni.postMessage({ data: 数据 }),必须要有个字段data,这样app才能收到数据。源码

image.png 2. 发送的数据不需要序列化成字符串,uniapp会转换json。 3. appmessage事件中接收到事件参数应该这样解构

function onMessage(e) {
const {
type,
data
} = e.detail.data[0]
}

3.1.2. APP -> 网页

app向网页传输消息就直接调用网页的js就行了。这里我统一封装了一个函数:

// app向网页发送消息
const deliverMessage = (msg) => {
// 调用webview中的deliverMessage函数
// 这个函数是我在网页挂载的一个全局函数,调用deliverMessage后会触发页面中的一些事件
currentWebview.evalJS(`deliverMessage(${JSON.stringify(msg)})`)
}

上面的代码例子中出现的currentWebview需要我们自己去获取。

// vue2中
const rootWebview = this.$scope.$getAppWebview()
this.currentWebview = rootWebview.children()[0]

// vue3中
import {
getCurrentInstance,
ref,
} from "vue";
const currentWebview = ref(null)
const vueInstance = getCurrentInstance()
const rootWebview = vueInstance.proxy.$scope.$getAppWebview()
currentWebview.value = rootWebview.children()[0]

这里也有一个坑,rootWebview.children()如果你一渲染就获取是无法获取到webview实例的,具体原因没有深入研究,估计是异步的原因

这里提供两个思路:

  1. 加一个定时器,延迟获取webview,这个方法虽然听起来不保险,但是实际测试还是挺稳当的。关键是简单
setTimeout(() => {
currentWebview.value = rootWebview.children()[0]
}, 1000)
  1. 你要是觉得定时器不保险,那就使用plusapi手动创建webview。但是消息处理这块比较麻烦。官网参考
<template>

template>
// 我这里vue3为例
onMounted(() => {
plus.globalEvent.addEventListener('plusMessage', ({data: {type, args}}) => {
// 是网页调用uni的api
if(type === 'WEB_INVOKE_APPSERVICE') {
const {data: {name, arg}} = args
// 是发送消息事件
if(name === 'postMessage') {
// arg就是传过来的数据
}
}
})
const wv = plus.webview.create("", "webview", {
'uni-app': 'none',
})
wv.loadURL(网页地址)
rootWebview.append(wv);
})

plus.globalEvent.addEventListener这个是翻源码找到的,主要是我不想改uni.webview.js的源码,所以只有找到正确的监听事件。

WEB_INVOKE_APPSERVICEuniapp内部定义的一个名字,反正就是用来交互操作的命名空间。

这样基础的互操作就有了。

3.1.3. 整个流程

  1. 网页调用window.uni.postMessage({ data }) => app监听(用组件的onMessage或者自定义的globalEvent
  2. app调用网页定义的函数deliverMessage并传递参数,网页中的deliverMessage内部处理监听
// 网页中的deliverMessage
window.deliverMessage = (msg) => {
// 触发网页注册的监听器
eventListeners.forEach((listener) => {

});
};

3.2. 返回拦截

默认情况下,手机按下返回键,app会响应提示是否退出,但是实际我需要网页进入二级路由的时候,按下手机返回键是返回上一级路由而不是退出。当路由是一级路由时才提示是否退出app

import {
onBackPress,
onShow,
} from '@dcloudio/uni-app'
// 页面当前的路由信息
const pageRoute = shallowRef()
onBackPress(() => {
// tab页正常app返回逻辑
if (pageRoute.value?.isTab) {
return false
} else {
// 二级路由拦截app返回
return true
}
})

pageRoute是页面当前路由信息,页面通过监听路由变化触发routeChange事件,将路由信息传给app。当按下返回键的时候,判断当前路由配置是不是tab页,如果是就正常退出,不是就拦截返回。

4. 总结

有了通信功能,很多操作就可以实现了,比如获取设备safeArea,获取设备联网状态等等。


作者:头上有煎饺
来源:juejin.cn/post/7313740940773097482

收起阅读 »

跟一位 40+ 岁的同学沟通之后,差点泪崩

个人情况 这位同学咱们叫他【大哥】吧。 大哥 今年 42 岁了,03 年毕业,专科学历(那个时候的专科学历还是很值钱的),从毕业之后一直都在做开发相关的工作。接触过 c、c#、.net、java、android、前端 整个的技术栈还是非常丰富的(毕竟 20多年...
继续阅读 »



个人情况


这位同学咱们叫他【大哥】吧。


大哥 今年 42 岁了,03 年毕业,专科学历(那个时候的专科学历还是很值钱的),从毕业之后一直都在做开发相关的工作。接触过 c、c#、.net、java、android、前端 整个的技术栈还是非常丰富的(毕竟 20多年 的工作经验)。


期间也进入过一些大厂,比如:微博、阿里 等。目前在北方的某二线城市,刚经历了裁员。



现在的行情说起“裁员”大家不要感觉很丢人,很多时候被裁员并不是因为你的个人能力不行,仅仅只是因为 公司盈利下降,甚至持续亏损 所导致的。



目前,找工作接近 3 个月,面试寥寥无几,拿到的几个 offer 也都薪资跌幅巨大(40% 以上)。


大哥 一直认为自己是非常努力的那批人(不努力当初也进不了大厂),一直在苦心钻研技术,并且尝试各种架构以及解决方案,甚至为此放弃了一些陪伴家人的时间。


同时,因为一直在关注技术,大哥 也出现了一些 程序员常见的问题 就是 不善与人交流、不善于表达自己的感受。


成长的模式


每个人都想变得更好,但只有一些人有勇气做出改变


对于很多人来说,我们都喜欢循规蹈矩,按照固定的方式进行生活。如果你是学生,那么每天都会重复 上课、下课、打游戏的生活。如果你是职场人,每天都会重复 上班、下班、加班 的生活。这会让你在短时间内找到自己的价值,或者找到你认为的价值。


这样的生活很轻松,但却会让我们陷入到一个固化的模式之中。世界是不断变化的,一旦变化来临(裁员),那么我们会变得无所是从。


所以,不要陷入固化的模式之中


如果你是一名学生,你希望取得好成绩,同时也保持社交生活。你会想出去,和朋友喝咖啡,打球、谈恋爱,尽情享受青春岁月。如果你是一名职业人士,固定的薪水会让你变得懒惰。你醒来,去上班,开始做任务,到了一天结束时,你就没有精力拿起笔记本电脑做其他事情了。写作、游戏或者其他的等爱好都会被束之高阁。


你看,这是另一种模式。它会让你拥有 更多的可变性,从而开始适应变化!


关于努力的一些谬论


我见过很多 35+ 以上的开发者,有的现在已经可以按照自己的期望进行生活,但是更多的目前依然无法选择自己的生活方式。


其实大家都很努力,但是结果却截然不同。


网上有各种信息告诉我们需要努力,但是却从没有告诉我们 应该如何选择努力的方向


从而导致很多同学会认为,身为程序员,我们只需要专注技术就可以了。殊不知 “学的数理化,走遍天下都不怕” 的时代已经过去了。



努力是对的,但是我们需要找到正确的方向。“方向不对,努力白费” 这句话,大家应该都非常熟悉



所以,尝试寻找自己适合的方向,不要 尽信 一些 “鸡汤文章”。如果你没有找到自己的路,而是遵循别人设定的规则,当结果没有出现时,你就会认为这是自己的错,从而陷入内耗。


殊不知,很大的可能是因为 这些并不适合你。就像你明明是一个人,却非要学习如何挥动翅膀一样。


尽信书,不如无书。找到自己适合的方式。



  1. 先想清楚,你想要成为一个什么样的人

  2. 然后明白,这样的人需要具备什么能力

  3. 对你来说,如何可以练习拥有这些能力

  4. 坚持或者放弃,取决于你的目标是否发生了变化


每个人都需要寻找自己的平衡


生活会变得越来越好,这句话其实是 错误的


生活一定是一个波浪,起起伏伏才是常态:



在完善自己的过程中,也要尝试接受自己


很多同学会在自己没有达到期望的时候 “惩罚自己”,认为这是自己的错误,或者是能力问题。从而更加逼迫自己努力。


但是逼迫来的努力,结果通常不会太好。就像 如果你把减肥认为是一个痛苦的过程,那么你就很难减肥成功一样


所以,尽量尝试获得 即时反馈



  • 当你获得一些成就时,为自己庆祝一下。找一些朋友进行分享

  • 不要过分执着于结果,只要航线没有偏离即可

  • 培养一些好奇心,很多东西都是有关联的,不要闭门造车

  • 多思考"为什么",寻找一些事物的本质原因,同时也提醒自己 不要忘记自己为什么开始~

作者:程序员Sunday
来源:juejin.cn/post/7379865729024737292
收起阅读 »

前任开发在代码里下毒了,支付下单居然没加幂等

故事又是一个风和日丽没好的一天,小猫戴着耳机,安逸地听着音乐,撸着代码,这种没有会议的日子真的是巴适得板。不料祸从天降,组长火急火燎地跑过来找到了小猫。“快排查一下,目前有A公司用户反馈积分被多扣了”。小猫回忆了一下“不对啊,这接口我也没动过啊,前几天对外平台...
继续阅读 »

故事

又是一个风和日丽没好的一天,小猫戴着耳机,安逸地听着音乐,撸着代码,这种没有会议的日子真的是巴适得板。

不料祸从天降,组长火急火燎地跑过来找到了小猫。“快排查一下,目前有A公司用户反馈积分被多扣了”。

小猫回忆了一下“不对啊,这接口我也没动过啊,前几天对外平台的老六直接找我要个支付接口,我就给他了的,以前的代码,我都没有动过的......”。

于是小猫一边疑惑一边翻看着以前的代码,越看脸色越差......

42175B273A64E95B1B5B66D392256552.jpg

小猫做的是一个标准的积分兑换商城,以前和客户合作的时候,客户直接用的是小猫单位自己定制的h5页面。这次合作了一家公司有点特殊,由于公司想要定制化自己个性化的H5,加上本身A公司自己有开发能力,所以经过讨论就以接口的方式直接将相关接口给出去,A客户H5开发完成之后自己来对接。

慢慢地,原因也水落石出,之前好好的业务一直没有问题是因为商城的本身H5页面做了防重复提交,由于量小,并且一般对接方式用的都是纯H5,所以都没有什么问题,然后这次是直接将接口给出去了,完了接口居然没有加幂等......

小猫躺枪,数据订正当然是少不了了,事故报告当然也少不了了。

正所谓前人挖坑,后人遭殃,前人锅后人背。

聊聊幂等

接口幂等梗概

这个案例其实就是一个典型的接口幂等案例。那么老猫就和大家从以下几个方面好好剖析一下接口幂等吧。

interfacemd.png

什么是接口幂等

比较专业的术语:其任意多次执行所产生的影响均与第一次执行的影响相同。 大白话:多次调用的情况下,接口最终得到的结果是一致的。

那么为什么需要幂等呢?

  1. 用户进行提交动作的时候,由于网络波动等原因导致后端同步响应不及时,这样用户就会一直点点点,这样机会发生重复提交的情况。
  2. 分布式系统之间调用的情况下,例如RPC调用,为了防止网络波动超时等造成的请求失败,都会添加重试机制,导致一个请求提交多次。
  3. 分布式系统经常会用到消息中间件,当由于网络原因,mq没有收到ack的情况下,就会导致消息的重复投递,从而就会导致重复提交行为。
  4. 还有就是恶意攻击了,有些业务接口做的比较粗糙,黑客找到漏洞之后会发起重复提交,这样就会导致业务出现问题。打个比方,老猫曾经干过,邻居小孩报名了一个画画比赛,估计是机构培训发起的,功能做的也差,需要靠投票赢得某些礼品,然后老猫抓到接口信息之后就模拟投票进行重复刷了投票。

那么哪些接口需要做幂等呢?

首先我们说是不是所有的接口都需要幂等?是不是加了幂等就好呢?显然不是。 因为接口幂等的实现某种意义上是要消耗系统性能的,我们没有必要针对所有业务接口都加上幂等。

这个其实并不能做一个完全的定义说哪个就不用幂等,因为很多时候其实还是得结合业务逻辑一起看。但是其中也是有规律可循的。

既然我们说幂等就是多次调用,接口最终得到结果一致,那么很显然,查询接口肯定是不要加幂等的,另外一些简单删除数据的接口,无论是逻辑删除还是物理删除,看场景的情况下其实也不用加幂等。

但是大部分涉及到多表更新行为的接口,咱们最好还是得加上幂等。

接口幂等实战方案

前端防抖处理

前端防抖主要可以有两种方案,一种是技术层面的,一种是产品层面的:

  1. 技术层面:例如提交控制在100ms内,同一个用户最多只能做一次订单提交的操作。
  2. 产品层面:当然用户点击提交之后,按钮直接置灰。

基于数据库唯一索引

  1. 利用数据库唯一索引。我们具体来看一下流程,咱们就用小猫遇到的例子。如下:

unique-key.png

过程描述:

  • 建立一张去重表,其中某个字段需要建立唯一索引,例如小猫这个场景中,咱们就可以将订单提交流水单号作为唯一索引存储到我们的数据库中,就模型上而言,可以将其定义为支付请求流水表。
  • 客户端携带相关流水信息到后端,如果发现编号重复,那么此时就会插入失败,报主键冲突的错误,此时我们针对该错误做一下业务报错的二次封装给到客户另一个友好的提示即可。

数据库乐观锁实现

什么是乐观锁,它假设多用户并发的事务在处理时不会彼此互相影响,各事务能够在不产生锁的情况下处理各自影响的那部分数据。 说得直白一点乐观锁就是一个马大哈。总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,只在更新的时候会判断一下在此期间别人有没有去更新这个数据。

例如提交订单的进行支付扣款的时候,本来可能更新账户金额扣款的动作是这样的:

update Account set balance = balance-#{payAmount} where accountCode = #{accountCode}

加上版本号之后,咱们的代码就是这样的。

update Account set balance = balance-#{payAmount},version=version +1 where accountCode = #{accountCode} and version = #{currVersion}

这种情况下其实就要求客户端每次在请求支付下单的时候都需要上层客户端指定好当前的版本信息。 不过这种幂等的处理方式,老猫用的比较少。

数据库悲观锁实现

悲观锁的话具有强烈的独占和排他特性。大白话谁都不信的主。所以我们就用select ... for update这样的语法进行行锁,当然老猫觉得单纯的select ... for update只能解决同一时刻大并发的幂等,所以要保证单号重试这样非并发的幂等请求还是得去校验当前数据的状态才行。就拿当前的小猫遇到的场景来说,流程如下:

pessimistic.png

begin;  # 1.开始事务
select * from order where order_code='666' for update # 查询订单,判断状态,锁住这条记录
if(status !=处理中){
//非处理中状态,直接返回;
return ;
}
## 处理业务逻辑
update order set status='完成' where order_code='666' # 更新完成
update stock set num = num - 1 where spu='xxx' # 库存更新
commit; # 5.提交事务

这里老猫一再想要强调的是在校验的时候还是得带上本身的业务状态去做校验,select ... for update并非万能幂等。

后端生成token

这个方案的本质其实是引入了令牌桶的机制,当提交订单的时候,前端优先会调用后端接口获取一个token,token是由后端发放的。当然token的生成方式有很多种,例如定时刷新令牌桶,或者定时生成令牌并放到令牌池中,当然目的只有一个就是保住token的唯一性即可。

生成token之后将token放到redis中,当然需要给token设置一个失效时间,超时的token也会被删除。

当后端接收到订单提交的请求的时候,会先判断token在缓存中是否存在,第一次请求的时候,token一定存在,也会正常返回结果,但是第二次携带同一个token的时候被拒绝了。

流程如下:

token.png

有个注意点大家可以思考一下: 如果用户用程序恶意刷单,同一个token发起了多次请求怎么办? 想要实现这个功能,就需要借助分布式锁以及Lua脚本了,分布式锁可以保证同一个token不能有多个请求同时过来访问,lua脚本保证从redis中获取令牌->比对令牌->生成单号->删除令牌这一系列行为的原子性。

分布式锁+状态机(订单状态)

现在很多的业务服务都是分布式系统,所以就拿分布式锁来说,关于分布式锁,老猫在此不做赘述,之前老猫写过redis的分布式锁和实现,还有zk锁和实现,具体可见链接:

当然和上述的数据库悲观锁类似,咱们的分布式锁也只能保证同一个订单在同一时间的处理。其次也是要去校订单的状态,防止其重复支付的,也就是说,只要支付的订单进入后端,都要将原先的订单修改为支付中,防止后续支付中断之后的重复支付。

在上述小猫的流程中还没有涉及到现金补充,如果涉及到现金补充的话,例如对接了微信或者支付宝的情况,还需要根据最终的支付回调结果来最终将订单状态进行流转成支付完成或者是支付失败。

总结

在我们日常的开发中,一些重要的接口还是需要大家谨慎对待,即使是前任开发留下的接口,没有任何改动,当有人咨询的时候,其实就要好好去了解一下里面的实现,看看方案有没有问题,看看技术实现有没有问题,这应该也是每一个程序员的基本素养。

另外的,在一些重要的接口上,尤其是资金相关的接口上,幂等真的是相当的重要。小伙伴们,你们觉得呢?如果大家还有好的解决方案,或者有其他思考或者意见也欢迎大家的留言。


作者:程序员老猫
来源:juejin.cn/post/7324186292297482290
收起阅读 »

Node.js 正在衰退吗?通过一些关键指标告诉你事实如何!

web
关于 “Node.js 凉了吗?” 类似话题大家平常在某乎上也有看到过。 近日 Node.js 官方 Twitter 上转载了一则帖子,看来国外也有此讨论。Node.js TSC 成员 & fastifyjs 首席维护者 @Matteo Collin...
继续阅读 »

关于 “Node.js 凉了吗?” 类似话题大家平常在某乎上也有看到过。



近日 Node.js 官方 Twitter 上转载了一则帖子,看来国外也有此讨论。Node.js TSC 成员 & fastifyjs 首席维护者 @Matteo Collina 对此进行了回复,表示关于 Node.js 衰退的传言被大大夸大了。Node.js 不仅不会消失,而且正在积极进化以满足现代 Web 开发的需求



以下内容翻译自 @Matteo Collina 的博文


在过去的 15 年里,Node.js 一直是 Web 开发的基石。自 2009 年发布以来,它从一个简单的小众技术,发展到如今支持超过 630 万个网站、无数的 API,并被财富 500 强中的 98% 所使用。


作为一个强大的开源运行时环境,Node.js 非常适合数字化转型的挑战。基于熟悉的 JavaScript 基础,Node.js 拥有轻量且事件驱动的架构,这使其非常适合构建可扩展的实时应用程序,能够处理大量并发请求——这是当今 API 驱动世界的关键需求。


结合其活跃且不断增长的开源社区以及 OpenJS 基金会的强力支持,Node.js 已成为当代 Web 开发的支柱。


但最近,有关 Node.js 衰落的传言开始流传。这些说法有多少可信度呢?


在这篇博客中,我们将深入探讨一些关键指标,这些指标描绘了一个繁荣的 Node.js 生态系统,并展现了其光明的未来。我们还将看看已经发布并即将在 Node.js 上推出的主要功能。


技术是永无止境的循环


有些人可能认为新技术不可避免地会使旧技术过时。但事实上,进步往往是建立在现有基础之上的。以 COBOL 为例,这种编程语言创建于 1959 年,今天仍在积极使用。虽然它可能不是前沿 Web 开发的首选,但 COBOL 在银行、金融和政府机构的核心业务系统维护中仍然至关重要。根据最新的 Tiobe 指数,COBOL 正在上升,其受欢迎程度在 Ruby 和 Rust 之间。其持久的相关性突显了一个关键点:技术进步并不总是意味着抛弃过去。


COBOL 正在崛起(来源: tiobe.com/tiobe-index)


让我们考虑另一个 Web 开发领域的老将:jQuery。这款 JavaScript 库比 Node.js 早三年发布,拥有令人印象深刻的使用统计数据——超过 95% 的 JavaScript 网站和 77% 的所有网站都在使用它。jQuery 的持久受欢迎程度表明,技术的年龄并不一定决定其相关性。就像 jQuery 一样,Node.js 尽管更年轻,但也有潜力保持其作为 Web 开发人员宝贵工具的地位。


94.4% 支持 JS 的网站都使用了 jQuery -(来源: w3techs.com/technologies/overview/javascrip..)


Node.js 目前的势头


根据 StackOverflow 的调查,Node.js 是最受欢迎的技术。这种成功依赖于 Node.js 和 npm 注册表的强大组合。这个创新的二人组解决了大规模软件复用的挑战,这是以前无法实现的。


来源:StackOverflow


因此,预先编写的代码模块的使用激增,巩固了 Node.js 作为开发强国的地位。



Readable-stream 的下载量从 2022 年的略高于 30 亿增长到 2023 年的接近 70 亿,意味着使用量在三年内翻了一番。


Node.js 的总下载量:Node.js 每月有高达 1.3 亿的下载量。


然而,理解这一数字包含什么很重要。这些下载量中的很大一部分实际上是头文件。在 npm i 命令期间,这些头文件是临时下载的,用于编译二进制插件。编译完成后,插件会存储在系统上供以后使用。


来源:nodedownloads.nodeland.dev


按操作系统划分的下载量中,Linux 位居榜首。这是有道理的,因为 Linux 通常是持续集成(CI)的首选——软件在开发过程中经过的自动化测试过程。虽然 Linux 主导 CI,但开源项目(OSS)通常在 Windows 上进行额外测试以确保万无一失。


这种高下载量的趋势转化为实际使用。在 2021 年,Node.js 二进制文件的下载量为 3000 万到 2024 年这一数字跃升至 5000 万。在 2023 年,Docker Hub 上的 Node.js 镜像获得了超过 8 亿次下载,提供了 Node.js 在生产环境中使用情况的宝贵洞察。


保持应用程序安全:更新你的 Node.js 版本


许多开发人员和团队无意中让他们的应用程序面临风险,因为他们没有更新 Node.js。以下是保持最新版本的重要性。


Node.js 提供了长期支持(LTS)计划,以确保关键应用程序的稳定性和安全性。然而,版本最终会到达其生命周期的终点,这意味着它们不再接收安全补丁。使用这些过时版本构建的应用程序将面临攻击风险。


例如,Node.js 版本 14 和 16 现在已经被弃用。尽管如此,这些版本每月仍有数百万次下载 —— Node 16 在 2 月份被下载了 2500 万次,而 Node 14 则约为 1000 万次*。令人震惊的是,一些开发人员甚至在使用更旧的版本,如 Node 10 和 12。


LTS 计划


好消息是:更新 Node.js 很容易。推荐的方法是每隔两个 LTS 版本进行升级。例如,如果你当前使用的是 Node.js 16(已不再支持),你应该迁移到最新的 LTS 版本,即目前的 Node.js 20。不要让过时的软件使你的应用程序暴露于安全威胁中。


Node.js 努力确保你的安全


Node.js 非常重视安全性。安全提交会由 Node 技术指导委员会(TSC)进行彻底评估,以确定其有效性。该团队努力确保快速响应时间,目标是在提交报告后 5 天内做出初步响应,通常在 24 小时内实现。


初次响应平均时间


安全修复每季度批量发布。去年,TSC 总共收到了 80 个提交。


Node.js 安全提交


没有 Open Source Security Foundation(OpenSSF)的支持,这种对安全性的承诺是不可能实现的。通过 OpenSSF 领导的 Alpha-Omega 项目,由微软、谷歌和亚马逊资助,Node.js 获得了专门用于提高其安全态势的拨款。该项目于 2022 年启动,旨在通过促进更快的漏洞识别和解决,使关键的开源项目更加安全。这一合作以及 Node.js 对安全工作的专门资金,展示了其保护用户安全的强烈承诺。


安全工作总资金


近年来发布的主要功能


让我们来看看过去几年引入的一些功能。


ESM


Node.js 已经采用了 ECMAScript 模块(ESM)。ESM 提供了一种现代的代码结构方式,使其更清晰和易于维护。


ESM 的一个关键优势是能够在 import 语句中显式声明依赖项。这改善了代码的可读性,并帮助你跟踪项目的依赖关系。因此,ESM 正迅速成为新 Node.js 项目的首选模块格式。


以下是如何在 Node 中使用 ESM 模块的演示:


// addTwo.mjs
function addTwo(num) {
return num + 2;
}

export { addTwo };

// app.mjs
import { addTwo } from './addTwo.mjs';

// 打印:6
console.log(addTwo(4));

线程


Node 还推出了工作线程,允许用户将复杂的计算任务卸载到独立的线程。这释放了主线程来处理用户请求,从而带来更流畅和响应更快的用户体验。


const {
Worker,
isMainThread,
setEnvironmentData,
getEnvironmentData,
} = require('node:worker_threads');

if (isMainThread) {
setEnvironmentData('Hello', 'World!');
const worker = new Worker(__filename);
} else {
console.log(getEnvironmentData('Hello')); // 打印“World!”。
}

Fetch


Node.js 现在内置了 Fetch API 的实现,这是一种现代且符合规范的方式来通过网络获取资源。这意味着你可以编写更清晰和一致的代码,而不必依赖外部库。


Node.js 还引入了几个与 Fetch 一起的新功能,以增强 Web 平台的兼容性。这些功能包括:



  • Web Streams:高效处理大数据流,而不会使应用程序不堪重负。

  • FormData:轻松构建和发送表单数据用于 Web 请求。

  • StructuredClone():创建复杂数据结构的深拷贝。

  • textEncoder() 和 textDecoder():无缝处理文本编码和解码任务。

  • Blob:表示各种用途的原始二进制数据。


结合 Fetch,这些新增功能使你能够在 Node.js 环境中完全构建现代 Web 应用程序。


const res = await fetch('https://example.com');
const json = await res.json();
console.log(json);

Promises


Node.js 提供了内置的 Promise 功能,提供了一种更清晰和结构化的方式来处理异步任务的结果(成功或失败)。


与回调地狱相比,使用 Promises 可以编写更自然、更易于理解的代码。


以下是使用 fs/promises 模块中的 readFile 方法的实际示例,展示了 Promises 如何简化异步文件读取:


import { readFile } from 'node:fs/promises';

try {
const filePath = new URL('./package.json', import.meta.url);
const contents = await readFile(filePath, { encoding: 'utf8' });
console.log(contents);
} catch (err) {
console.error(err.message);
}

Node 独有的核心模块


Node.js 引入了核心模块和用户引入模块的明确区分,使用 "node:" 前缀来标识核心模块


这个前缀像是一个标签,立即将模块标识为 Node.js 的核心构建块。这种区分有几个好处:



  • 减少混淆:不再将核心模块误认为是用户创建的模块。

  • 简化选择:使用 "node:" 前缀轻松选择所需的特定核心模块。


这种变化还防止用户使用可能与未来核心模块冲突的名称注册到 npm 注册表中,如下所示:


import test from 'node:test';
import assert from 'node:assert';

Watch


在引入此功能之前,nodemon 是文件更改监视中最流行的包。


现在,--watch 标志提供了:



  • 自动文件监视:它监视您导入的文件,准备在发生任何更改时立即采取行动。

  • 即时重启:每当修改监视的文件时,Node.js 自动重启,确保您的应用程序反映最新更新。

  • 测试协同作用:--watch 标志与测试运行器友好地协作,在文件更改后自动重新运行测试。这使得开发工作流程变得流畅,提供持续反馈。

  • 为了更精细的控制,--watch-path 标志允许您指定要监视的确切文件。


AsyncLocalStorage


AsyncLocalStorage 允许在 Web 请求或任何其他异步持续时间内存储数据。它类似于其他语言中的线程本地存储。


AsyncLocalStorage 增强了开发人员创建像 React 服务器组件这样的功能,并作为 Next.js 请求存储的基础。这些组件简化了 React 应用程序的服务器端渲染,最终提高了开发者体验。


import http from 'node:http';
import { AsyncLocalStorage } from 'node:async_hooks';

const asyncLocalStorage = new AsyncLocalStorage();

function logWithId(msg) {
const id = asyncLocalStorage.getStore();
console.log(`${id !== undefined ? id : '-'}:`, msg);
}

let idSeq = 0;
http.createServer((req, res) => {
asyncLocalStorage.run(idSeq++, () => {
logWithId('start');
// Imagine any chain of async operations here
setImmediate(() => {
logWithId('finish');
res.end();
});
});
}).listen(8080);

http.get('http://localhost:8080');
http.get('http://localhost:8080');
// 输出:
// 0: start
// 1: start
// 0: finish
// 1: finish

WebCrypto


这个标准化的 API 在 Node.js 环境中直接提供了强大的加密工具集。


使用 WebCrypto,您可以利用以下功能:



  • 密钥生成:创建强大的加密密钥以保护您的数据。

  • 加密和解密:对敏感信息进行加密,以安全存储和传输,并在需要时解密。

  • 数字签名:签署数据以确保真实性并防止篡改。

  • 哈希:生成数据的唯一指纹以进行验证和完整性检查。


通过将 WebCrypto 集成到您的 Node.js 应用程序中,您可以显著增强其安全性,并保护用户数据。


const { subtle } = require('node:crypto').webcrypto;

(async function () {
const key = await subtle.generateKey({
name: 'HMAC',
hash: 'SHA-256',
length: 256
}, true, ['sign', 'verify']);

const enc = new TextEncoder();
const message = enc.encode('I love cupcakes');

const digest = await subtle.sign({
name: 'HMAC'
}, key, message);
})();

实用工具


Node 开始提供了许多实用工具。其核心团队认为用户不应该安装新模块来执行基本实用程序。其中一些实用程序包括以下内容。


Utils.ParseArgs()


Node.js 提供了一个名为 Utils.ParseArgs() 的内置实用程序(或来自 node 模块的 parseArgs 函数),简化了解析应用程序中的命令行参数的任务。这消除了对外部模块的需求,使您的代码库更精简。


那么,Utils.ParseArgs() 如何帮助?它接受传递给您的 Node.js 脚本的命令行参数,并将它们转换为更可用的格式,通常是一个对象。这个对象使得在代码中访问和利用这些参数变得容易。


import { parseArgs } from 'node:util';

const args = ['-f', '--bar', 'b'];
const options = {
foo: {
type: 'boolean',
short: 'f',
},
bar: {
type: 'string',
},
};

const {
values,
positionals,
} = parseArgs({ args, options });

console.log(values, positionals);
// 输出:[Object: null prototype] { foo: true, bar: 'b' } []

单一可执行应用程序


单个可执行应用程序使得通过 Node 分发应用程序成为可能。这在构建和分发 CLI 到用户时非常强大。


这个功能将应用程序代码注入到 Node 二进制文件中。可以分发二进制文件而不必安装 Node/npm。目前仅支持单个 CommonJS 文件。


为了简化创建单个可执行文件,Node.js 提供了一个由 Postman Labs 开发的辅助模块 postject。


权限系统


Node.js 进程对系统资源的访问以及可以执行的操作可以通过权限来管理。还可以通过权限管理其他模块可以访问的模块。


process.permission.has('fs.write');
// true
process.permission.deny('fs.write', '/home/user');

process.permission.has('fs.write');
// true
process.permission.has('fs.write', '/home/user');
// false

测试运行器


它使用 node:test、--test 标志和 npm test。它支持子测试、skip/only 和生命周期钩子。它还支持函数和计时器模拟;模块模拟即将推出。


它还通过 --experimental-test-coverage 提供代码覆盖率和通过 -test-reporter 和 -test-reporter-destination 提供报告器。基于 TTY,默认为 spec、TAP 或 stdout。


import test from 'node:test';
import test from 'test';

test('synchronous passing test', (t) => {
// This test passes because it does not throw an exception.
assert.strictEqual(1, 1);
});

test('synchronous failing test', (t) => {
// This test fails because it throws an exception.
assert.strictEqual(1, 2);
});

test('asynchronous passing test', async (t) => {
// This test passes because the Promise returned by the async
// function is settled and not rejected.
assert.strictEqual(1, 1);
});

test('asynchronous failing test', async (t) => {
// This test fails because the Promise returned by the async
// function is rejected.
assert.strictEqual(1, 2);
});

test('failing test using Promises', (t) => {
// Promises can be used directly as well.
return new Promise((resolve, reject) => {
setImmediate(() => {
reject(new Error('this will cause the test to fail'));
});
});
});

test('callback passing test', (t, done) => {
// done() is the callback function. When the setImmediate() runs, it invokes
// done() with no arguments.
setImmediate(done);
});

test('callback failing test', (t, done) => {
// When the setImmediate() runs, done() is invoked with an Error object and
// the test fails.
setImmediate(() => {
done(new Error('callback failure'));
});
});

require(esm)


一个新的标志已经发布,允许开发者同步地引入 ESM 模块。


'use strict';

const { answer } = require('./esm.mjs');
console.log(answer);


另外,一个新的标志 --experimental-detect-module 允许 Node.js 检测模块是 commonJS 还是 esm。这个新标志简化了在 JavaScript 中编写 Bash 脚本。


WebSocket


WebSocket 是 Node.js 最受欢迎的功能请求之一。这个功能也是符合规范的。



为 Node.js 做贡献


作为一种开源技术,Node.js 主要由志愿者和协作者维护。由于 Node.js 的受欢迎程度不断提高,维护工作也越来越具有挑战性,需要更多的帮助。


Node.js 核心协作者维护 nodejs/node GitHub 仓库。Node.js 核心协作者的 GitHub 团队是 @nodejs/collaborators。协作者具有:



  • 对 nodejs/node 仓库的提交访问权限

  • 对 Node.js 持续集成(CI)作业的访问权限


无论是协作者还是非协作者都可以对 Node.js 源代码提出修改建议。提出修改建议的机制是 GitHub 拉取请求(pull request)。协作者审查并合并(land)拉取请求。


在拉取请求能够合并之前,必须得到两个协作者的批准。(如果拉取请求已经开放超过 7 天,一个协作者的批准就足够了。)批准拉取请求表示协作者对变更负责。批准必须来自不是变更作者的协作者。


如果协作者反对提出的变更,则该变更不能合并。例外情况是,如果 TSC 投票批准变更,尽管存在反对意见。通常,不需要涉及 TSC。


通常,讨论或进一步的更改会导致协作者取消他们的反对。


从根本上说,如果您想对 Node.js 的未来有发言权,请开始贡献!



总结


关于 Node.js 衰退的传言被大大夸大了。深入研究这些指标后,可以清楚地看到:Node.js 不仅不会消失,而且正在积极进化以满足现代 Web 开发的需求


凭借庞大的用户基础、繁荣的开源社区和不断创新的功能,Node.js 仍然是一个强大而多功能的平台。最近增加的 ESM、工作线程、Fetch API 和内置模块表明了它在技术前沿保持领先的承诺。


此外,Node.js 通过专门的团队和严格的流程优先考虑安全性。它的开放协作模式欢迎像您这样的开发人员的贡献,确保平台的光明未来。


因此,无论您是经验丰富的开发人员还是刚刚起步,Node.js 都为构建可扩展和高效的 Web 应用程序提供了一个有力的选择。丰富的资源、活跃的社区和对持续改进的承诺使其成为您下一个项目的坚实基础


参考:



作者:五月君
来源:juejin.cn/post/7379667550505304075
收起阅读 »

uni-app利用renderjs实现截取视频第一帧画面作为封面图

web
需求背景 如下图,使用 uni-app 做 app 时,要上传图片和视频,这里选择图片和视频分别使用的 uni.chooseImage 和 uni.chooseVideo,上传使用的 uni.uploadFile,问题就是这些 API 还有上传成功后服务器返回...
继续阅读 »



需求背景


如下图,使用 uni-app 做 app 时,要上传图片和视频,这里选择图片和视频分别使用的 uni.chooseImageuni.chooseVideo,上传使用的 uni.uploadFile,问题就是这些 API 还有上传成功后服务器返回内容中都没有提供视频封面图,于是只能使用一个固定的图片来充当视频封面,但是这样用户体验很不好


image.png


解决思路


在获取到视频链接后,如果我们可以让视频在后台自动播放,出现第一帧画面后再将它给停掉,在这个过程中利用 canvas 截取到视频播放的第一帧画面保存起来,那不就可以作为视频封面了吗?没那么容易,平时在 H5 环境中,到目前为止就行了,但问题是,现在我这里是 App,然后 uni-app 自带的 video 组件没法截取画面,而 App 环境又没法用 H5 环境的 video 标签,它甚至没有 document 对象, 技术框架上不兼容, 那怎么办?


这时候就需要用到 renderjs 了,毕竟它的核心作用之一就是 “在视图层操作dom,运行 for webjs库”。


那思路就有了,在 renderjs 模块中监听原始模块中的文件列表,当更改时(新增、删除),在 renderjs 中动态创建 video 元素,让它自动静音播放,使用 canvas 截取第一帧画面后销毁 video 元素并将图片传递给原始模块,原始模块将其设置为对应视频的封面


代码逻辑


<template>
<view :prop="canvasList" :change:prop="canvas.getVideoCanvas">
<view v-for="(item,index) in fileList" :key="index">
<image v-if="item.type===0" :src="item.url" @click="previewImage(item.url)">image>
<view v-else @click="previewVideoSrc = item.url">

<image mode="widthFix" :src="item.cover">image>

<u-icon class="play-icon" name="play-right-fill" size="30" color="#fff">u-icon>
view>
view>
<view class="preview-full" v-if="previewVideoSrc!=''">
<video :autoplay="true" :src="previewVideoSrc" :show-fullscreen-btn="false">
<cover-view class="preview-full-close" @click="previewVideoSrc=''"> ×
cover-view>
video>
view>
view>
template>

<script>
import { deepClone } from '@/utils'
// 原始模块
export default {
data() {
return {
previewVideoSrc: '', // 预览视频url
fileList: [
{ url: '', type: 0 },
{ url: '', type: 1 },
{ url: '', type: 1 },
] // 真正用来展示和传递的文件列表,type: 0代表图片,1代表视频
}
},
computed: {
// 用于 renderjs 模块监听,不用 fileList 是因为后续还有更改它(为其内部元素添加 cover )属性
// 监听 fileList 然后又更改它会导致循环递归,这里使用 deepClone 也是为了让 canvasList 不与
// fileList 产生关联
canvasList() {
return deepClone(this.fileList)
}
},
methods: {
// 预览图片
previewImage(url) {
uni.previewImage({
urls: [url]
});
},
// 生成视频封面
getVideoPoster({ index, cover }) {
this.$set(this.fileList[index], 'cover', cover)
},
}
}
script>
<script module="canvas" lang="renderjs">
// renderjs 模块
export default {
methods: {
getVideoCanvas(nV, oV, ownerInstance) {
if(oV !== undefined && Array.isArray(nV) && nV.length > 0) {
nV.forEach((item, index) => {
// 如果是视频
if(item.type == 1) {
// 防止一次性执行过多逻辑导致卡顿
setTimeout(() => {
// 创建video标签
let video = document.createElement("video")
// 设置为自动播放和静音
video.setAttribute('autoplay', 'autoplay')
video.setAttribute('muted', 'muted')
// 设置播放源
video.innerHTML = ''
// 创建 canvas 元素和 2d 画布
let canvas = document.createElement('canvas')
let ctx = canvas.getContext('2d')
// 监听 video 的 canplay 事件
video.addEventListener('canplay', function () {
// 设置宽高
let anw = document.createAttribute("width");
anw.nodeValue = 80;
let anh = document.createAttribute("height");
anh.nodeValue = 80;
canvas.setAttributeNode(anw);
canvas.setAttributeNode(anh);
// 画布渲染
ctx.drawImage(video, 0, 0, 80, 80);
// 生成 base64 图片
let base64 = canvas.toDataURL('image/png')
// 暂停并销毁 video 元素
video.pause()
video.remove();
// 传递数据给逻辑层
ownerInstance.callMethod('getVideoPoster', {
index,
cover: base64
})
}, false)
}, index * 120)
}
})
}
}
}
}
script>

成果展示


image.png


还有另一个地方,之前就是这样的,都是用的默认图片当作封面:


image.png


经过处理后就是这样啦:


image.png


7.gif


作者:鹏北海
来源:juejin.cn/post/7322762833690066981
收起阅读 »

2023年给一位团队成员绩效“打c”的经历

2023年作为疫情开放的第一个年份,国家整体的经济形势还处于低迷阶段。IT行业同样如此,各公司都保持着降本增效的节奏,企业招聘人才的需求明显放缓,有人力缺口的岗位都优先考虑内部人才的转岗。在这样的大环境下,IT行业的从业者们想要离职会更加谨慎,不会再如以前那样...
继续阅读 »

2023年作为疫情开放的第一个年份,国家整体的经济形势还处于低迷阶段。IT行业同样如此,各公司都保持着降本增效的节奏,企业招聘人才的需求明显放缓,有人力缺口的岗位都优先考虑内部人才的转岗。在这样的大环境下,IT行业的从业者们想要离职会更加谨慎,不会再如以前那样“老子干的不爽,就离职换个公司”。


即便如此,每个公司依然还会有一些人,总过得浑浑噩噩,需要别人踢一脚就走一步,不去主动承担和思考事情,只能做一些确定性很强和设计好的工作。2023年,我们团队就有这样的一名成员,最终不好的绩效,也只能落到他头上。


然而,最终和他进行绩效约谈的时候,他却完全不认可,表现出非常激烈的逆反情绪,认为他的职级不需要去承担过多的事情。而我作为他的直接管理者,这次约谈的过程显然不够成功,现在整体再复盘一下这个过程。


前提背景


员工背景: 进入公司已经超过三年,近一年由于部门变动转入我的团队。本身的职级较低,由于之前工作平平没有太多起色,所以一直也没有得到过晋升,而年龄却已经越来越大。


进入我的团队后,团队内年龄与其不相上下的成员,职级已比他高出较多。从而,也引起了他心态的失衡,总觉得公司亏欠他的,他的能力不应该得不到晋升。所以在工作时,只愿做自己职级内的事情,也不愿意承担更多。


过程管控


我作为他的直接管理者,发现问题后私下跟他聊过。跟他说过几次,他做事情太被动,工作时对外沟通经常带着个人情绪,需要更加积极正面的去承担事情。为了打消他的顾虑,也跟他说明了,只要你的工作能力有所提升,对团队有所帮助,我会尽量帮助你晋升。


然而,一个人心态的问题,是一个历史长期积累的过程。他并没有因为和我的几次沟通,就打破了自己的认知,对外依旧较封闭,对内能力又显得不足。而他自己却认识不到,总认为他在当前的职级上,已经足够了,除非公司让其晋升,不然他也不会付出更多。


为了打破这种僵硬的局面,作为管理者我安排了一项稍有困难的任务给他,这既是机会也是挑战。第一、让其认识到自身的不足;第二、如果他能够较好的完成任务,也就为后面的晋升提供了保障。


也就是这么一次任务,不但目的没有达到,最后还惹得双方都陷入了僵局。



这项任务还未开始3个月前,我就跟他说:要开始熟悉相关的业务和代码了,后面会有大的项目变更,需要提前做好准备。


前期我并没有明确说明,要交付什么产物。更多的是,给他自己空间,让他在一个相对宽松的时间内,把整体的业务和细节都了解清楚,能够在组内进行一次分享。


任务我已经给出去了,在项目开始前将近3个月的时间, 他并没有给到我任何反馈,也没有交付任何相关的文档。


随着时间的推移,项目开始启动了,基于这样的工作态度和结果,我本不打算让他再负责这个项目。但是上级管理者,也希望能够给予他一次机会,做好了能够为后面的晋升,提供较好的铺垫。


就是这样的安排,由于他前期没有较好的准备,后面在落地方案评审时漏洞百出,导致项目出现了延期的风险。所以最终不得不由我直接来接管项目,重新分配和协调各个研发人员,最终确保了项目的质量和进度。



约谈结果


对于这样的团队成员,既不能给团队带来正向的帮助,也不能让其自身得到成长。这是一个双输的局面,管理者要让团队保持正向的发展,就必须要勇于去解决这样的问题。


所以年度的绩效考核,就必须亮明你的态度,即使公司没有淘汰的指标,你也要去做那个坏人,把不合适的人从团队清除掉。只是,我没有想到这个过程如此艰难。


下面从他的视角,来反驳这个结果的几个观点:



  • 他的职级,只需要配合做好相应的开发任务就可以,不需要去主导事情。

  • 那个有挑战的项目,不管过程怎样,结果是好的,项目按时按质的上线了。

  • 给他不好的绩效,需要参照公司的标准,给出明确的原因。


然后,带着强烈的情绪说要去投诉,甚至要上升到CTO、CEO 那边。 投诉没有问题,我也表明了态度, 你可以向上申请表达自己的诉求, 但是我也会持有自己的观点和建议。


最终,当然也不会因为他的申诉就改变结果。只是,这个现状本应该在管理的过程中,就应该让其感知到,不要等到最后的环节,才让双方都陷入难堪的局面。


反思总结


作为一个管理者,要面对各式各样的研发人员。有的人优秀,上来就能够跟你站在一个视角看问题;有的人有潜力,需要你给出机会和试错空间,让其成长;有的人就该辞退,针对这些人,你尤其要做好备战。作为管理者,既要有开放和怀柔的心态去留住人才,也要有铁血的手腕去清退团队的毒瘤。


清退毒瘤,是一项艰难但必要的任务,如何去做呢?



  1. 评估情况: 评估对团队的影响,是否对团队的合作和效率产生负面影响,是否违反了团队的价值观和行为准则。要确保有足够的证据来支持你的决定。

  2. 沟通和反馈: 与他进行一对一沟通,明确表达你对他们行为的关注,并提供具体的例子。给予他们改进的机会,并讨论如何改变。

  3. 制定行动计划: 如果没有改善他们的行为,你需要制定一个行动计划。包括培训和指导。

  4. 寻求支持: 寻求其他团队成员和上级的支持。


作者:云游者
来源:juejin.cn/post/7341368001203699747
收起阅读 »

Stream很好,Map很酷,但答应我别用toMap()

在 JDK 8 中 Java 引入了让人欲罢不能的 stream 流处理,可以说已经成为了我日常开发中不可或缺的一部分。 当完成一次流处理之后需要返回一个集成对象时,已经肌肉记忆的敲下 collect(Collectors.toList()) 或者 colle...
继续阅读 »

JDK 8Java 引入了让人欲罢不能的 stream 流处理,可以说已经成为了我日常开发中不可或缺的一部分。


当完成一次流处理之后需要返回一个集成对象时,已经肌肉记忆的敲下 collect(Collectors.toList()) 或者 collect(Collectors.toSet())。你可能会想,toListtoSet 都这么便捷顺手了,当又怎么能少得了 toMap() 呢。


答应我,一定打消你的这个想法,否则这将成为你噩梦的开端。


image.png


什么?你不信,没有什么比代码让人更痛彻心扉,让我们直接上代码。


让我们先准备一个用户实体类。


@Data
@AllArgsConstructor
public class User {

private int id;

private String name;
}

假设有这么一个场景,你从数据库读取 User 集合,你需要将其转为 Map 结构数据,keyvalue 分别为 useridname


很快,你啪的一下就写出了下面的代码:


public class UserTest {
@Test
public void demo() {
List<User> userList = new ArrayList<>();
// 模拟数据
userList.add(new User(1, "Alex"));
userList.add(new User(1, "Beth"));

Map<Integer, String> map = userList.stream()
.collect(Collectors.toMap(User::getId, User::getName));
System.out.println(map);
}
}

运行程序,你已经想好了开始怎么摸鱼,结果啪的一下 IllegalStateException 报错就拍你脸上,你定睛一看怎么提示 Key 值重复。


image.png


作为优秀的八股文选手,你清楚的记得 HashMap 对象 Key 重复是进行替换。你不信邪,断点一打,堆栈一看,硕大的 uniqKeys 摆在了面前,凭借四级 424 分的优秀战绩你顿时菊花一紧,点开一看,谁家好人 map key 还要去重判断啊。


image.png


好好好,这么玩是吧,你转身打开浏览器一搜,原来需要自己手动处理重复场景,啪的一下你又重新改了一下代码:


public class UserTest {
@Test
public void demo() {
List<User> userList = new ArrayList<>();
// 模拟数据
userList.add(new User(1, "Alex"));
userList.add(new User(2, null));

Map<Integer, String> map = userList.stream()
.collect(Collectors.toMap(User::getId, User::getName, (oldData, newData) -> newData));
System.out.println(map);
}
}

再次执行程序,你似乎已经看到知乎的摸鱼贴在向你招手了,结果啪的一下 NPE 又拍在你那笑容渐渐消失的脸上。



静下心来,本着什么大风大浪我没见过的心态,断点堆栈一气呵成,而下一秒你又望着代码陷入了沉思,我是谁?我在干什么?




鼓起勇气,你还不信今天就过不去这个坎了,大手一挥,又一段优雅的代码孕育而生。


public class UserTest {
@Test
public void demo() {
List<User> userList = new ArrayList<>();
// 模拟数据
userList.add(new User(1, "Alex"));
userList.add(new User(1, "Beth"));
userList.add(new User(2, null));

Map<Integer, String> map = userList.stream()
.collect(Collectors.toMap(
User::getId,
it -> Optional.ofNullable(it.getName()).orElse(""),
(oldData, newData) -> newData)
);
System.out.println(map);
}
}

优雅,真是太优雅了,又是 Stream 又是 Optional,可谓是狠狠拿捏技术博文的 G 点了。


image.png


这时候你回头一看,我需要是什么来着?这 TM 不是一个循环就万事大吉了吗,不信邪的你回归初心,回归了 for 循环的怀抱,又写了一版。


public class UserTest {
@Test
public void demo() {
List<User> userList = new ArrayList<>();
// 模拟数据
userList.add(new User(1, "Alex"));
userList.add(new User(1, "Beth"));
userList.add(new User(2, null));

Map<Integer, String> map = new HashMap<>();
userList.forEach(it -> {
map.put(it.getId(), it.getName());
});
System.out.println(map);
}
}

看着运行完美无缺的代码,你一时陷入了沉思,数分钟过去了,你删除了 for 循环,换上 StreamOptional 不羁的外衣,安心的提交了代码,这口细糠一定也要让好同事去尝一尝。


image.png


作者:烽火戏诸诸诸侯
来源:juejin.cn/post/7383643463534018579
收起阅读 »

写了一个责任链模式,bug 无数...

责任链模式是一种行为设计模式, 允许你将请求沿着处理者链进行发送。 收到请求后, 每个处理者均可对请求进行处理, 或将其传递给链上的下个处理者。 使用场景 责任链的使用场景还是比较多的: 多条件流程判断:权限控制 ERP 系统流程审批:总经理、人事经理、项...
继续阅读 »

责任链模式是一种行为设计模式, 允许你将请求沿着处理者链进行发送。


收到请求后, 每个处理者均可对请求进行处理, 或将其传递给链上的下个处理者。


图片


使用场景


责任链的使用场景还是比较多的:



  • 多条件流程判断:权限控制

  • ERP 系统流程审批:总经理、人事经理、项目经理

  • Java 过滤器的底层实现 Filter


如果不使用该设计模式,那么当需求有所改变时,就会使得代码臃肿或者难以维护,例如下面的例子。


| 反例


假设现在有一个闯关游戏,进入下一关的条件是上一关的分数要高于 xx:



  • 游戏一共 3 个关卡

  • 进入第二关需要第一关的游戏得分大于等于 80

  • 进入第三关需要第二关的游戏得分大于等于 90


那么代码可以这样写:


//第一关
public class FirstPassHandler {
    public int handler(){
        System.out.println("第一关-->FirstPassHandler");
        return 80;
    }
}

//第二关
public class SecondPassHandler {
    public int handler(){
        System.out.println("第二关-->SecondPassHandler");
        return 90;
    }
}

//第三关
public class ThirdPassHandler {
    public int handler(){
        System.out.println("第三关-->ThirdPassHandler,这是最后一关啦");
        return 95;
    }
}

//客户端
public class HandlerClient {
    public static void main(String[] args) {

        FirstPassHandler firstPassHandler = new FirstPassHandler();//第一关
        SecondPassHandler secondPassHandler = new SecondPassHandler();//第二关
        ThirdPassHandler thirdPassHandler = new ThirdPassHandler();//第三关

        int firstScore = firstPassHandler.handler();
        //第一关的分数大于等于80则进入第二关
        if(firstScore >= 80){
            int secondScore = secondPassHandler.handler();
            //第二关的分数大于等于90则进入第二关
            if(secondScore >= 90){
                thirdPassHandler.handler();
            }
        }
    }
}

那么如果这个游戏有 100 关,我们的代码很可能就会写成这个样子:


if(第1关通过){
    // 第2关 游戏
    if(第2关通过){
        // 第3关 游戏
        if(第3关通过){
           // 第4关 游戏
            if(第4关通过){
                // 第5关 游戏
                if(第5关通过){
                    // 第6关 游戏
                    if(第6关通过){
                        //...
                    }
                }
            }
        }
    }
}

这种代码不仅冗余,并且当我们要将某两关进行调整时会对代码非常大的改动,这种操作的风险是很高的,因此,该写法非常糟糕。


| 初步改造


如何解决这个问题,我们可以通过链表将每一关连接起来,形成责任链的方式,第一关通过后是第二关,第二关通过后是第三关....


这样客户端就不需要进行多重 if 的判断了:


public class FirstPassHandler {
    /**
     * 第一关的下一关是 第二关
     */

    private SecondPassHandler secondPassHandler;

    public void setSecondPassHandler(SecondPassHandler secondPassHandler) {
        this.secondPassHandler = secondPassHandler;
    }

    //本关卡游戏得分
    private int play(){
        return 80;
    }

    public int handler(){
        System.out.println("第一关-->FirstPassHandler");
        if(play() >= 80){
            //分数>=80 并且存在下一关才进入下一关
            if(this.secondPassHandler != null){
                return this.secondPassHandler.handler();
            }
        }

        return 80;
    }
}

public class SecondPassHandler {

    /**
     * 第二关的下一关是 第三关
     */

    private ThirdPassHandler thirdPassHandler;

    public void setThirdPassHandler(ThirdPassHandler thirdPassHandler) {
        this.thirdPassHandler = thirdPassHandler;
    }

    //本关卡游戏得分
    private int play(){
        return 90;
    }

    public int handler(){
        System.out.println("第二关-->SecondPassHandler");

        if(play() >= 90){
            //分数>=90 并且存在下一关才进入下一关
            if(this.thirdPassHandler != null){
                return this.thirdPassHandler.handler();
            }
        }

        return 90;
    }
}

public class ThirdPassHandler {

    //本关卡游戏得分
    private int play(){
        return 95;
    }

    /**
     * 这是最后一关,因此没有下一关
     */

    public int handler(){
        System.out.println("第三关-->ThirdPassHandler,这是最后一关啦");
        return play();
    }
}

public class HandlerClient {
    public static void main(String[] args) {

        FirstPassHandler firstPassHandler = new FirstPassHandler();//第一关
        SecondPassHandler secondPassHandler = new SecondPassHandler();//第二关
        ThirdPassHandler thirdPassHandler = new ThirdPassHandler();//第三关

        firstPassHandler.setSecondPassHandler(secondPassHandler);//第一关的下一关是第二关
        secondPassHandler.setThirdPassHandler(thirdPassHandler);//第二关的下一关是第三关

        //说明:因为第三关是最后一关,因此没有下一关
        //开始调用第一关 每一个关卡是否进入下一关卡 在每个关卡中判断
        firstPassHandler.handler();

    }
}

| 缺点


现有模式的缺点:



  • 每个关卡中都有下一关的成员变量并且是不一样的,形成链很不方便

  • 代码的扩展性非常不好,最新设计模式面试题整理好了


| 责任链改造


既然每个关卡中都有下一关的成员变量并且是不一样的,那么我们可以在关卡上抽象出一个父类或者接口,然后每个具体的关卡去继承或者实现。


有了思路,我们先来简单介绍一下责任链设计模式的基本组成:



  • 抽象处理者(Handler)角色: 定义一个处理请求的接口,包含抽象处理方法和一个后继连接。

  • 具体处理者(Concrete Handler)角色: 实现抽象处理者的处理方法,判断能否处理本次请求,如果可以处理请求则处理,否则将该请求转给它的后继者。

  • 客户类(Client)角色: 创建处理链,并向链头的具体处理者对象提交请求,它不关心处理细节和请求的传递过程。


图片


public abstract class AbstractHandler {

    /**
     * 下一关用当前抽象类来接收
     */

    protected AbstractHandler next;

    public void setNext(AbstractHandler next) {
        this.next = next;
    }

    public abstract int handler();
}

public class FirstPassHandler extends AbstractHandler{

    private int play(){
        return 80;
    }

    @Override
    public int handler(){
        System.out.println("第一关-->FirstPassHandler");
        int score = play();
        if(score >= 80){
            //分数>=80 并且存在下一关才进入下一关
            if(this.next != null){
                return this.next.handler();
            }
        }
        return score;
    }
}

public class SecondPassHandler extends AbstractHandler{

    private int play(){
        return 90;
    }

    public int handler(){
        System.out.println("第二关-->SecondPassHandler");

        int score = play();
        if(score >= 90){
            //分数>=90 并且存在下一关才进入下一关
            if(this.next != null){
                return this.next.handler();
            }
        }

        return score;
    }
}

public class ThirdPassHandler extends AbstractHandler{

    private int play(){
        return 95;
    }

    public int handler(){
        System.out.println("第三关-->ThirdPassHandler");
        int score = play();
        if(score >= 95){
            //分数>=95 并且存在下一关才进入下一关
            if(this.next != null){
                return this.next.handler();
            }
        }
        return score;
    }
}

public class HandlerClient {
    public static void main(String[] args) {

        FirstPassHandler firstPassHandler = new FirstPassHandler();//第一关
        SecondPassHandler secondPassHandler = new SecondPassHandler();//第二关
        ThirdPassHandler thirdPassHandler = new ThirdPassHandler();//第三关

        // 和上面没有更改的客户端代码相比,只有这里的set方法发生变化,其他都是一样的
        firstPassHandler.setNext(secondPassHandler);//第一关的下一关是第二关
        secondPassHandler.setNext(thirdPassHandler);//第二关的下一关是第三关

        //说明:因为第三关是最后一关,因此没有下一关

        //从第一个关卡开始
        firstPassHandler.handler();

    }
}

| 责任链工厂改造


对于上面的请求链,我们也可以把这个关系维护到配置文件中或者一个枚举中。我将使用枚举来教会大家怎么动态的配置请求链并且将每个请求者形成一条调用链。


图片


public enum GatewayEnum {
    // handlerId, 拦截者名称,全限定类名,preHandlerId,nextHandlerId
    API_HANDLER(new GatewayEntity(1"api接口限流""cn.dgut.design.chain_of_responsibility.GateWay.impl.ApiLimitGatewayHandler"null2)),
    BLACKLIST_HANDLER(new GatewayEntity(2"黑名单拦截""cn.dgut.design.chain_of_responsibility.GateWay.impl.BlacklistGatewayHandler"13)),
    SESSION_HANDLER(new GatewayEntity(3"用户会话拦截""cn.dgut.design.chain_of_responsibility.GateWay.impl.SessionGatewayHandler"2null)),
    ;

    GatewayEntity gatewayEntity;

    public GatewayEntity getGatewayEntity() {
        return gatewayEntity;
    }

    GatewayEnum(GatewayEntity gatewayEntity) {
        this.gatewayEntity = gatewayEntity;
    }
}

public class GatewayEntity {

    private String name;

    private String conference;

    private Integer handlerId;

    private Integer preHandlerId;

    private Integer nextHandlerId;
}

public interface GatewayDao {

    /**
     * 根据 handlerId 获取配置项
     * 
@param handlerId
     * 
@return
     */

    GatewayEntity getGatewayEntity(Integer handlerId);

    /**
     * 获取第一个处理者
     * 
@return
     */

    GatewayEntity getFirstGatewayEntity();
}

public class GatewayImpl implements GatewayDao {

    /**
     * 初始化,将枚举中配置的handler初始化到map中,方便获取
     */

    private static Map gatewayEntityMap = new HashMap<>();

    static {
        GatewayEnum[] values = GatewayEnum.values();
        for (GatewayEnum value : values) {
            GatewayEntity gatewayEntity = value.getGatewayEntity();
            gatewayEntityMap.put(gatewayEntity.getHandlerId(), gatewayEntity);
        }
    }

    @Override
    
public GatewayEntity getGatewayEntity(Integer handlerId) {
        return gatewayEntityMap.get(handlerId);
    }

    @Override
    
public GatewayEntity getFirstGatewayEntity() {
        for (Map.Entry entry : gatewayEntityMap.entrySet()) {
            GatewayEntity value = entry.getValue();
            //  没有上一个handler的就是第一个
            if (value.getPreHandlerId() == null) {
                return value;
            }
        }
        return null;
    }
}

public class GatewayHandlerEnumFactory {

    private static GatewayDao gatewayDao = new GatewayImpl();

    // 提供静态方法,获取第一个handler
    public static GatewayHandler getFirstGatewayHandler() {

        GatewayEntity firstGatewayEntity = gatewayDao.getFirstGatewayEntity();
        GatewayHandler firstGatewayHandler = newGatewayHandler(firstGatewayEntity);
        if (firstGatewayHandler == null) {
            return null;
        }

        GatewayEntity tempGatewayEntity = firstGatewayEntity;
        Integer nextHandlerId = null;
        GatewayHandler tempGatewayHandler = firstGatewayHandler;
        // 迭代遍历所有handler,以及将它们链接起来
        while ((nextHandlerId = tempGatewayEntity.getNextHandlerId()) != null) {
            GatewayEntity gatewayEntity = gatewayDao.getGatewayEntity(nextHandlerId);
            GatewayHandler gatewayHandler = newGatewayHandler(gatewayEntity);
            tempGatewayHandler.setNext(gatewayHandler);
            tempGatewayHandler = gatewayHandler;
            tempGatewayEntity = gatewayEntity;
        }
    // 返回第一个handler
        return firstGatewayHandler;
    }

    /**
     * 反射实体化具体的处理者
     * 
@param firstGatewayEntity
     * 
@return
     */

    private static GatewayHandler newGatewayHandler(GatewayEntity firstGatewayEntity) {
        // 获取全限定类名
        String className = firstGatewayEntity.getConference();
        try {
            // 根据全限定类名,加载并初始化该类,即会初始化该类的静态段
            Class clazz = Class.forName(className);
            return (GatewayHandler) clazz.newInstance();
        } catch (ClassNotFoundException | IllegalAccessException | InstantiationException e) {
            e.printStackTrace();
        }
        return null;
    }

}

public class GetewayClient {
    public static void main(String[] args) {
        GetewayHandler firstGetewayHandler = GetewayHandlerEnumFactory.getFirstGetewayHandler();
        firstGetewayHandler.service();
    }
}

设计模式有很多,责任链只是其中的一种,我觉得很有意思,非常值得一学。设计模式确实是一门艺术,仍需努力呀!




作者:程序员蜗牛
来源:juejin.cn/post/7383643463534067731
收起阅读 »

如果失业了,我们还能干啥?

这个事其实一直存在脑子的。为啥呢?因为我们听到太多了,太多了,35岁是个坎。事实上,找工作也是如此,很多行业都是有年龄限制的。找不到自己原来的行业的工作了。那就只有转行了。   对于我们这种菜鸟级别人,现实是残酷的。转行又谈何容易呀?但是真的到那一天,地步了,...
继续阅读 »

这个事其实一直存在脑子的。为啥呢?因为我们听到太多了,太多了,35岁是个坎。事实上,找工作也是如此,很多行业都是有年龄限制的。找不到自己原来的行业的工作了。那就只有转行了。


  对于我们这种菜鸟级别人,现实是残酷的。转行又谈何容易呀?但是真的到那一天,地步了,也不得不转。这不仅仅是我一个的想法,同事也是,群里的网友也是。于是乎,我们失业了,我们能干啥?经常被讨论起来。


   我也经常观察和想一些可行的。太远太陌生的咱也想不到。我想到的是开滴滴,顺风车,送外卖,送快递,干工地,开一个小餐馆,干保安,干搬运,干家政服务,干修理,洗空调。最后就是回老家养牛养鸡养鸭养猪之类。


  我先说几个我亲眼看到的,我觉得是非常可行的。


    之前公司有一个小小的箱子需要扔掉,然后叫了物业过来。大概是50x50x80这么大小。你们可知道这么一点东西,扔掉要多少钱么?100块。听到简直不敢相信。还有换灯泡,物业过来帮忙换多少钱一个?50元。就那么一两分钟的事。如果你不愿意,那只有自己换了。所以公司一个都没有叫物业做。扔箱子交给收废品的,换灯炮就我们男同事换。


     到了现在的公司,于是又遇到相同的事,这次换一个灯泡,你们听了都会惊讶的。400多一个。真的贵得离谱。只是咱没有工具,还有公司不允许,不然我就能干好。


    空调原来是要洗的,不过之前是不知道怎么洗,现在看了他们洗一次,知道非常简单。洗一台大概30分钟。收费是50到70元一台。真的很容易!


所以我把这些看到的分享到一个群里。说以后咱干这个!


这些天我还拍到一些服务图片。


一 收费服务


image.png


二 拆卸代扔服务


image.png


他们收费都比较贵,那咱比他们便宜三分之一?是不是可以把业务接过来?


   这些肯定比干工地轻松一些,而且赚得不比工地少。只要把服务干好了,回头客,口碑好了,不愁没有活。也不会存在所谓失业了。


三 其他大佬建议


image.png


image.png


四 其他人总结


4686dc607d2bcaa4f136e1245041a3f.jpg


五 最新发现——流动小贩


这是一个卖鞋子的小贩,生意真的好。60元一双。我都买了两双。


image.png


image.png


以前还发现卖衣服,包包的,接近过年边,到一些服装,箱包批发市场进货,成包,成捆的。因为都是一些外贸尾货,退单,样式不错,价格便宜,质量也不错。然后以一个大家可以接受的价格卖。比如30元,60元,90元等等。老人,年轻人都爱。因为大家没有那么讲究的。讲究的是实用。


  有时候自己焦虑,是因为害怕,习惯了熟悉路径。不愿意改变罢了。其实都是未必要的。


  正所谓车到山前必有路,船到桥头自然直,一切顺其自然!只要自己不懒,不要所谓的面子,一生还是可以顺顺当当的。


  当然,如果有厉害的高人指引,带路,贵人相助,那肯定可以过得更好。那是另当别论了。


  这些就是我当前想到的,了解到的。


作者:大巨头
来源:juejin.cn/post/7286762580877901865
收起阅读 »

微信小程序区分环境开发 and 合理绕过官方上线审核

web
前言:首先说明一点,虽然绕过官方审核,是不推荐的行为,但是实际的项目开发中,难免会有一些需求或功能在发布上线时,会被官方拒绝。例如类目不对的情况,由于企业性质或其他原因,无法申请相关类目要求的资质;或者申请资质办理难度大、所需时间漫长,无法在上线节点前申请完成...
继续阅读 »

前言:

首先说明一点,虽然绕过官方审核,是不推荐的行为,但是实际的项目开发中,难免会有一些需求或功能在发布上线时,会被官方拒绝。

例如类目不对的情况,由于企业性质或其他原因,无法申请相关类目要求的资质;或者申请资质办理难度大、所需时间漫长,无法在上线节点前申请完成,但是实际业务中确实有此需求。

这就需要在上线时先合理绕过官方审核,以期能顺利发布成功,不耽误业务使用。

一、背景和问题描述

很多开发者在开发项目的时候发现,上线微信小程序最难的不是开发阶段,而是微信审核机制。因为微信为了自身平台规避法律风险,开发的很多功能需要提供相关的正件或者资质,就像前面所说,相关的资质办理难度大,或者一般的公司根本办不下来。那么绕过审核就是一个很重要的上线技巧。
我们之前开发的一个微信小程序,涉及一些视频,发布审核时,被官方认定需要补充“教育服务-在线视频课程类目”。如下图所示:

image.png

但是我们项目中的视频内容是关于“用车知识的介绍和使用须知”,并不属于教育类视频或直播课程,而且我们也拥有“教育服务 > 在线教育”的服务类目,可能跟“在线视频课程”类目不一样。

image.png

可是实际业务中确实需要此功能,那么该如何顺利上线呢?

二、解决思路

因为需要此功能,那么:

  1. 体验版环境下必须能正常展示,才能让测试同事正常测试。
  2. 在提交审核时,即在开发版环境下,此模块需要隐藏,才能绕过官方审核,使审核通过。
  3. 在发布审核成功后,即在正式版环境下,此模块需正常展示,可供用户使用。

三、解决方案

我这边实现了两种解决方法,供大家参考:

方案一

核心: 使用 wx.getAccountInfoSync()

功能描述: 获取当前账号信息。线上小程序版本号仅支持在正式版小程序中获取,开发版和体验版中无法获取。

可参考微信小程序官方文档: 获取当前账号信息:Object wx.getAccountInfoSync()

image.png

具体使用方法如下:

  1. 在小程序项目的app.js文件中的onLaunch中获取小程序账号信息:

image.png

onLaunch: function () {
//启动时动态获取小程序的 appid
const accountinfo = wx.getAccountInfoSync()

wx.setStorageSync('miniProgram', accountinfo.miniProgram)
},
  1. 然后在需要做判断的模块的页面获取miniProgram,我这边是在展示视频模块入口页面获取:
  • js文件中获取账号信息的值:

image.png

data: {
miniProgram: wx.getStorageSync('miniProgram'),
},
  • html文件中进行判断:

image.png

注:我是使用miniProgram.version的值进行判断的。
因为此值是线上小程序版本号,只有在线上环境中才会有值,所以只会在线上环境中展示,提交审核的开发环境中看不到此模块。
而在体验版环境下,我不会加wx:if="{{miniProgram.version}}"这个代码,只有在提交审核时加上。缺点就是需要改动代码,但是能完美避开审核,使审核顺利通过。

方案二

核心: 使用小程序视频插件。
优点: 完美继承完美继承小程序原生的所有特性和事件。不用改代码。

后期我们开发了一个小程序的视频插件,在展示视频的页面中,使用视频插件代替。这样也能完美通过审核。

image.png

这个小程序视频插件作用是,专门为没有视频播放资质的小程序提供视频播放功能,解决视频播放资质问题。

思路来源于官方解答:

image.png

涉小程序插件功能介绍: developers.weixin.qq.com/miniprogram…

涉小程序类目资质、适用范围参考:developers.weixin.qq.com/miniprogram…

以上,希望对大家有帮助!


作者:小蹦跶儿
来源:juejin.cn/post/7340154170234552370
收起阅读 »

跟骑手学习送外卖,这家具身智能公司的机器人已经上岗挣钱了

你点过无人机送的外卖吗? 在深圳、上海等一线城市,让无人机给自己送个外卖已经不是什么新鲜事。但它送的方式可能和你想的不太一样。 想象中的无人机送外卖 be like: 而现实中的无人机送外卖 be like: 也就是说,它不会把外卖直接送到你家阳台,而是和...
继续阅读 »

你点过无人机送的外卖吗?


在深圳、上海等一线城市,让无人机给自己送个外卖已经不是什么新鲜事。但它送的方式可能和你想的不太一样。


想象中的无人机送外卖 be like:


图片


而现实中的无人机送外卖 be like:


图片


也就是说,它不会把外卖直接送到你家阳台,而是和你家有一段距离的外卖柜。你需要下楼走一段距离才能拿到。于是,有些网友发出灵魂追问:「你猜我为什么点外卖?」


所以,现在问题就变成了:从家到外卖柜这段距离怎么办?解决思路也很简单:让一个送货机器人帮你送完这段路。


这是具身智能机器人公司推行科技(Infermove)最近放出来的一段视频。从中可以看出,在无人机到达指定地点后,送货机器人可以把货「拿」过来,放到自己的「肚子」里,然后再送到指定小区、写字楼的指定楼层,实现无缝接驳。


其实,除了帮无人机送剩下的路程,它还能自己 cover 全程。在过去的 18 个月里,推行科技的机器人已经帮山姆会员店等商家送了几万单货。要知道,这些店铺和目的地之间往往隔了几条街,因此机器人需要在非机动车道上和人、自行车、电动车一起穿行、过马路,还要自己进小区、坐电梯,把外卖、商品送到用户手里。为了适应接驳无人机等更复杂的工作,推行科技给这些机器人安上了手臂,这样它们就能完成拿取包装袋、按电梯、推拉门等需要上肢才能完成的任务。


难得的是,在和人类骑手一致的考核制度下,这些机器人的履约率(按时送达的百分比)已达 98.5%,因此拿到的报酬已经可以覆盖自身的成本,做到了单个机器人盈亏平衡。这在还没进入大规模落地阶段的具身智能领域是非常稀有的。


为了了解这个机器人背后的技术和创业思路,机器之心和推行科技创始人卢鹰翔、龙禹含展开了深入对谈。他们指出,让机器人在充满变数的开放物理世界中穿行并不是一件简单的事。为了克服其中的困难,他们走了一条类似于特斯拉的数据驱动路线,利用自研的「骑手影子系统」在短时间内获取了大量高质量数据,因此机器人的表现才能如此出色。未来,他们还将在自然语言、多模态等方向持续迭代,让这个机器人更加实用。


走进开放物理世界,机器人如何工作?  


机器之心:能否简单介绍一下,公司现在在做一件什么事,长期愿景是什么?


卢鹰翔我们希望以数据驱动的方式,打造出可以在开放物理世界中自主移动的机器人。具体而言,我们是通过利用人类驾驶的两轮电瓶车、电动轮椅等产生的驾驶数据,用模仿学习和强化学习的方法,来逐步实现一款能够应对开放物理世界的硬件无关(hardware-agnostic)的具身智能产品。


我们开始行动的第一步就是解决「数据从哪来」的问题。21 年创业之初我们先是搭建了一套基于轮椅平台的「端到端」算法架构,利用轮椅驾驶数据训练末端移动机器人,并在硅谷进行了 8 公里的路测。后来我们意识到末端物流场景是更高效的数据来源,于是开始打造「骑手影子系统」,利用末端物流场景下的骑手骑行数据和机器人产品落地数据构建双数据闭环


目前我们在末端物流场景已经落地了 18 个月,比如给苏州、深圳的山姆会员店等前置仓做物流配送。我们的机器人和公路无人配送车有一个很显著的区别。无人配送车只完成运输任务的中间一段,不会进入小区、商场、写字楼等场所,如果用来进行外卖、商超等本地生活类配送,两端都需要有人参与。相比之下,我们的物流机器人以做到「门到门」的配送为设计目标。比如对于我们合作的奶茶门店,我们的机器人会开进商场,停在柜台前等待装单,装单之后离开商场,跨过两条街,驶入写字楼或小区,然后自己找到电梯、坐电梯上到具体的楼层,把货物送达指定地点。这在许多场景下已经非常贴近骑手的服务能力。所以我们做的事情更多的是属于具身智能这个范畴。


到了去年底、今年初这个时间,我们发现落地环境给我们提出了一些更高的要求。一是特定场所进一步的通达,像操作按钮或开关、按电梯。二是外卖等常见商品的抓取、捡拾。三是打开有把手的推拉门等交互场景。


在这些需求的驱动下,我们开始有针对性地研发上肢能力。这和其他具身智能领域的公司可能有所不同,他们有些会去优化做菜、叠衣服等上肢能力,而我们是根据常见的客户需求有针对性地去解决上述几个问题。


机器之心:利用您提到的上肢能力,你们研发了什么产品?


卢鹰翔:今年 618,我们落地了一款具备上肢操作能力的物流机器人。它的下半身是一个带有装载能力的移动机器人本体,上半身支持三维世界的单臂交互能力。


这个机器人首先用于支持无人机的外卖配送接驳。无人机的降落地点通常和顾客还有一段距离,这个机器人首先要能够把无人机卸下来的货物装进自己的货仓,然后至少要坐一次电梯。有些电梯可能没有梯控,需要手动按按钮。机器人的上肢就是在这些场景中发挥作用。


无人机接驳是个新场景,其实在目前已有的场景中,我们也可以利用这个上肢去干两件事情。一是我们会在它的上面整合一个 RFID(射频识别)芯片,让机器人自己刷卡进小区,而不是依赖保安手动操作。二是在取货人迟迟不来的情况下,让机器人主动把货物从「肚子」里拿出来,放到架子、门口等指定地点,就像骑手放外卖一样。这样可以省去大量的等待时间,提高配送效率。


机器之心:这个机器人可以上台阶吗?它是不是只能送一些设施比较好的小区?


卢鹰翔:这里面其实涉及到三个问题。


第一个问题:能不能上台阶?我们现在的这款物流机器人是不能上台阶的,因为它下面是四个轮子。这是从经济角度考虑做出的一个选择,因为四轮底盘目前是最成熟、最常见的。不过这个轮子经过了特殊设计,有一定的越障能力,能跨越 7 厘米以内的单级台阶或凹陷。


此外,我刚才提到一个概念,叫硬件无关(hardware-agnostic)。其实我们这个系统也成功适配过一些异形底盘,比如四足、双轮足,这些底盘是可以上楼梯的,但可能没有那么稳定。所以,要不要让机器人上台阶其实是取决于我们客户的需求,如果客户想用四条腿的机器狗送外卖或快递,而且愿意接受它的价格,那么我们在技术上是可以打磨的


第二个问题:我们的机器人可以到达什么样的环境?其实我们国家去年出台了一部《无障碍环境建设法》,它对于公共场所提出的要求是:两条腿能到的地方,轮椅都要能到。这部法律不仅要求所有增量的公共场所、建筑物都要满足无障碍要求,目前已有的存量场所也要逐渐完成合规改造。这对于我们来说是一个有利的环境,因为我们机器人的设计尺寸参照的是电动轮椅的国家标准,所以轮椅能到的地方,我们基本上都能到


第三个问题:到不了的地方怎么办?我们现在的应用场景本质上是人机混合,而不是有你无我的一种局面。就是说一个货仓会部署一部分机器人,一部分骑手,大家一起接单。系统在派单的时候会进行一些目的地的筛选。而且这个筛选系统本就存在,不需要额外的开发成本。


从自动驾驶到具身智能,挑战升维


机器之心:公司现在的人才配置是怎样的?这些人才搭建起了一个怎样的技术栈?


卢鹰翔:我们的团队其实是自动驾驶、机器人、机器学习、机械等各个专业背景的人组合起来的一个团队。创始团队成员之前都在硅谷做自动驾驶,就是 L4、Robotaxi 这些方向,之前我们负责研发的车型还拿到了加州政府发放的第二块可以无安全员上路的 Robotaxi 牌照,第一块发给了 Waymo。我们的思路是搭建一套数据驱动的技术栈,类似于美国的特斯拉和英国的 Wayve。受到他们的启发,我们研发了一套「骑手影子系统」,利用骑手驾驶的两轮电瓶车来获取用于算法迭代的训练数据,目的是实现机器人在开放物理世界而不只是公路上的自主移动能力。这种算法架构的好处是性能的天花板非常高,理论上可以无限拟人。


机器之心:公司很多人才都是自动驾驶出身的,这和其他很多具身智能公司的班底其实很相似。能否谈一下,从单纯做自动驾驶扩展到交互维度更高的具身智能,你们遇到了哪些新的挑战? 


卢鹰翔:第一个挑战是环境的不规律。与公路上的自动驾驶汽车相比,我们机器人面临的物理环境是非结构化的,规律性更差。我们知道,公路是按照严格的国家标准来修筑的,但当我们去解决一个开放物理世界中的自主移动问题的时候,这个有利的条件就不存在了。我们现在的落地环境主要是城市,尚有一些建筑规范。但我们落地的其他场景,比如农村,规律性要更差。未来,我们可能还要扩展到野外。


第二个挑战是规则的缺失。公路上有明确的交通规则,也有交警来维持秩序,这相当于人为地让大家的行为变得有规律。这对于机器人来说是非常有利的一个客观条件。但在具身智能所面对的开放物理世界,交通参与者变得更加复杂,包括骑各种车的人甚至宠物,他们的行为要更加随机。


第三个挑战是辅助工具的缺失。公路交通有成熟的生态,所以有一些辅助工具被开发出来,比如百度地图,它可以告诉你前方堵车或施工,请绕行。但开放的物理世界中就缺乏这样的工具。


要解决前两个问题,我们需要大量的训练数据。但是这类数据是非常稀缺的。我们知道,ChatGPT 利用的是人类过去几十年积攒下来的互联网数据。物理世界的数据可能在有了自动驾驶这样的行业之后才被系统地收集,这和互联网数据完全不在一个量级。而我们想要的开放物理世界的训练数据就更稀缺了。针对这个数据获取难题,我们最初的想法是利用人驾驶的电动轮椅来获取众包数据。在接触到末端物流场景和客户之后,我们逐渐迭代成现在这种利用骑手载具,也就是骑手驾驶的电瓶车来获取。


打破数据魔咒杀手锏 ——「量大管饱」的骑手影子系统


机器之心:能否详细介绍一下你们的数据获取思路?


卢鹰翔:在数据获取层面,市面上有几种不同的思路,多数情况下这些思路是并存的。各家公司可能会以不同的比例去选择一种组合方式。


首先说仿真数据。有一部分公司会比较认同仿真数据的价值,比如去年 Hint0n 以顾问身份加入的 Vayu Robotics 机器人公司。我们也用仿真数据,有自己的仿真模拟器。但相比之下,我们更看重真实数据,我们认为真实数据的价值是无可替代的。仿真数据对于我们来说主要是在真实数据的基础上降本增效。


真实数据的获取也分为两种,一种是 on policy 的,一种是 off policy 的。on policy 数据就是部署的机器人在每天使用过程中产生的数据。这种数据目前是非常稀缺且昂贵的,因为它要在机器人落地之后才会有,这就会变成一个「先有鸡还是先有蛋」的问题。所以我们就要突破这个技术瓶颈,实现对 off policy 的数据的利用能力。


简单来说就是,如果只是利用我们部署在山姆的一些机器人来获取数据,它的效率非常低,成本也很高。但是,如果能利用骑手驾驶电瓶车产生的数据,还有一些电动轮椅产生的数据,我们的系统就能够在短时间内获取大量数据,而且这些数据的营养也很丰富。


作为一家看重仿真数据的公司,Vayu Robotics 也是认同真实数据的价值的。他们会在硅谷雇佣一些骑手,产生一些真实世界的数据,然后在这个基础上利用仿真模拟器去训练。


但这方面我们存在一些国情优势。我国是一个非机动车大国,一方面,这意味着我们机器人的应用场景会比较大、比较丰富,覆盖各个城市的大街小巷。另一方面,这也意味着我们的骑手产生的数据是量大管饱的。相比之下,美国的一些公司就不太容易大量获取这类数据,需要请一些专业的人,以高昂的成本去采集。


机器之心:您说的「量大管饱」是怎样一个概念?


卢鹰翔:我这里有一些数据。中国骑手平均每人每天会跑 100 到 200 公里。我们在苏州一个普通超市落地的前置仓,一般配备 15 到 20 个骑手。这些骑手一个月产生的数据轻轻松松就会超过 10 万公里,一年肯定可以超过百万公里,通常可以接近 200 万公里。


作为对比,国内最头部的做 Robotaxi 的 L4 公司,自成立以来积累的数据基本上也只有几百万公里,像 Waymo 这样的全球头部公司也就两千万公里。当然,里程数是一个比较简单的维度。但在这个简单的维度上,我们利用骑手影子系统仅在单一前置仓落地不到两年所产生的数据量,就相当于一家国内头部自动驾驶公司自成立以来的路测积累总和


我们还有一个对比对象,就是特斯拉。他们在 2014 年就推出了第一款搭载 Autopilot 软硬件的车型,开始收集驾驶数据。截至今年初特斯拉推出V12.3,他们在过去十年间一共积累了将近20亿公里人类驾驶数据用于智能驾驶系统的训练,在全球范围内也称得上遥遥领先。而对于中国的600万活跃骑手群体而言,20亿公里只是他们一两天跑的量,我们叫「中国骑手一天,特斯拉汽车十年」。这就是所谓的量大管饱。可以说,骑手影子系统为我们迭代产品提供了非常可靠的数据保障。


但除了量大管饱,骑手影子系统产生的数据还有一些优势。第一是成本。我们是让骑手在送单的过程中积累数据,这对于他们来说没有边际成本,我们的成本也非常低。第二是数据的丰富度。骑手的数据是在真实的生产环境中产生的,而且越是经济发达、人口密集、接近城市中心的地方,它产生的数据就越多。这些数据包含一年四季、各种天气状况。它本身的复杂度、代表度都很好,避免了高度同质化的情况。


所以,无论是从数量、质量还是成本来说,这个系统产生的数据都符合「好数据」的标准。目前,我们已经开始和一些销售电动两轮车的主机厂合作,打算在印度部署这个系统,这也是一个量大管饱的环境。


机器之心:能否详细介绍一下「 骑手影子系统」的技术细节?


卢鹰翔:这个系统主要通过一套车载硬件采三种数据。一是环境数据,即通过摄像头采集路况、障碍物等视觉数据。二是定位数据,通过比较便宜的 RTK 来采集。三是操作数据,即骑手在某种特定情况下进行了什么样的操作,比如踩油门、刹车或者左拐右拐。在采到这些数据后,我们就通过模仿学习和强化学习的方式,让模型去学习人类的行为,逐渐向人类行为靠拢。


机器之心:这个系统能让机器人知道实时路况?


卢鹰翔:是的,因为末端道路的通行能力会非常频繁地发生变化,解决机器人末端移动不仅要解决 AI 问题,还要解决情报问题。就像老司机也需要百度地图来提示前方道路有堵车一样。比如说,在非机动车道上,我们经常会遇到两个拦路桩,它们将道路分成三条。通常中间的那条最好走。但如果临时出现一个商贩占据了中间这条路,开始在那里卖红薯,这条路就走不通了。这个时候,机器人需要提前知道怎么选择最佳路线。而经过这里的骑手自然会做出应变,比如他可能说「师傅能不能让一让」,如果商贩让开了,机器人就能知道这条路是可以通行的。如果不让,骑手就会选择一条次优路线,机器人也能知道。完成这些只需要骑手实时回传 RTK 定位数据。这和百度地图实时提醒前方堵车的原理是相似的。


不仅已落地,还能盈亏平衡


机器之心:刚才提到,去年,图灵奖得主 Hint0n 加入了一家名叫 Vayu Robotics 的机器人公司。在您看来,这家公司有哪些吸引 Hint0n 的特点?


卢鹰翔:当时 Hint0n 自己发了一个帖子来阐述他加入 Vayu 的原因,就是看中了末端物流这个场景的高安全性和可落地性。


我们知道,Hint0n 非常关注 AI 安全。他在帖子里提到,这个送货机器人的动能只有汽车的 1%。拿我们这个机器人来说,它的极限动能也就 500 焦耳,这相当于一个 70 公斤的人从一把椅子高的地方跌落产生的能量。所以如果这个机器人不小心撞到人,它至多把人撞疼,不会撞伤,容错率很高。


图片


图片


高安全性带来的是高可落地性。我们知道,像 Waymo 这样的公司在 Robotaxi 方面已经做得非常好了,平均五万公里左右才接管一次,但距离大规模落地似乎还是遥遥无期。其中一个很大的原因就是它的场景容错率太低了。而 Vayu 和我们选的都是一些高容错率的场景。除了末端物流,其实我们还落地了一些类似场景,比如帮机场驱鸟、帮鱼塘抛洒鱼料。从技术路线上来讲,大家都不约而同地看好这个路线。但相比之下,我们在数据上具备一定的国情优势。


机器之心:你们的机器人盈亏情况如何?  


卢鹰翔:我们可以达到单个机器人的盈亏平衡。


我们落地的末端物流主要是外卖和商超两大块,客户分别是国内在这两个场景市占率最高的两大平台。


商超领域我们其实跑得挺成熟的,比如在苏州,我们给山姆送了 18 个月,累计送了 3 万多单。这 3 万多单累计下来是盈亏平衡的。我可以分享几个数据。第一个是平均效率,国内骑手平均每天送 35 到 40 单,我们的机器人平均每天可以送 20 单,相当于两台机器人可以干一个人的活儿。第二个是履约率,即有多少单是按时、无损送达的,这个数值可能更有意义。通常来讲,我们机器人的履约率可以达到 98.5%,按照达达对于骑手的考核标准,这可以达到 A 级(以 98% 为界)。在这个场景中,我们的机器人和骑手是在一个地方排队的,不需要前置仓为它们配备额外的人力。考核标准也和骑手一样。


外卖是一个比商超更有挑战性的领域。它是多点对多点的配送,也要保证时效。在这个场景中,我们的机器人和人的考核标准也是一样的,超时或出现其他问题也要扣钱。


在跟人类骑手进行平等的奖惩考核的情况下,机器人挣到的钱可以覆盖它的成本,包括折旧、电费、维修费、管理员工资等等。在具身智能产品还没有大规模量产的当下,这种盈亏平衡的情况是非常稀有的。


未来迭代方向:上肢、自然语言和多模态


机器之心:现在,这个机器人拥有上肢了,交互变得更加复杂,你们遇到了哪些新的挑战? 


龙禹含:最大的一个挑战还是数据问题。当机器人的能力扩展到上肢,它的数据是更加稀缺的,全球的科研机构、公司都在花很大的力气去收集数据。但即便如此,数据的多样性依然不足,实际训练出来的模型泛化性也不是很强。比如谷歌的 RT 项目,在做厨房场景时,他们有一个机器人数据厨房,专门用来收集数据。但离开这个厨房进入到真实场景后,他们机器人的成功率还是会大幅下降。


不过,我们机器人的动作相对来说没有那么复杂,比如不用去学叠衣服等涉及柔性物体的动作,也不会像谷歌那样有很多步骤。它的动作基本上可以拆解为一些子问题,比如操作电梯的按钮、操作货物包装袋、拉开门让底盘出去等。在拆解出这些子问题后,我们就可以专门去收集这些场景的数据,然后利用一些模仿学习的算法去学习,让这件事情跑起来。在跑起来之后,我们的机器人会看到一些成功的案例,也会看到一些失败的案例。在看过各种各样的包装袋、门、电梯之后,它的能力就会逐步提升。


机器之心:现在具身智能的一大方向是让机器人听懂自然语言,甚至基于多模态信息来进行推理决策,推行科技在这方面有没有一些计划?


卢鹰翔让机器人听懂自然语言这件事情肯定会去做,而且已经在我们的规划之中,下一代产品就会具备这样一个能力。本身我们机器人产品的应用场景就比较贴近人的日常生活,直接用自然语言交互将是非常实用的一个功能。


龙禹含:关于多模态,其实我们的机器人现在已经在用多模态大模型了。即使是完成刚才提到的按电梯按钮、取货、开关门这样的操作,如果想达到一个比较好的泛化能力,现在最稳定的路径就是利用大模型的多模态能力。


目前我们机器人里的多模态大模型主要用于解决一些视觉问题,比如物体识别、目标物估计。这有别于传统的自动驾驶,后者只针对某些类别,比如汽车、行人、电动车,去做识别。我们的机器人要识别不同样子、不同位置的电梯按钮,不同形状的纸袋、塑料袋以及不同类别的门,它面对的要求更高了,所以我们用多模态大模型来解决这些问题。


机器之心:很多人认为,人形机器人会是具身智能的最终形态,您怎么看?推行科技是否有必要去做人形机器人?


卢鹰翔:说人形机器人会是具身智能的最终形态,这背后的主要逻辑是:目前人类生存的物理世界,比如房子,本身是为人类躯体设计的,所以人形机器人会具备最广泛的通用性。但我们认为,碳基智能和硅基智能之间有一个很大的区别。碳基智能只能支持特定的躯体,比如一个人的大脑只能驱动一个人,一个狗的大脑只能驱动一只狗。但硅基智能可以同时支持多种形态,比如一套智能驾驶系统可以装在本田的车上,也可以装到丰田的车上。所以硅基智能本身不太受具体形态的限制


在认识到这个区别后,我们认为,具身智能不一定非要定义一个最终形态,比如变成人形去适应人类的生存环境。反之,它可以是环境本身。也就是说,它不一定非要去一辆汽车、一幢房子、一条生产线上去工作,它可以是这个汽车、房子、生产线本身。它可以同时存在多种物理形态。


具体到产品开发思路上,我们不会跟风去做一个人形机器人,而是根据客户、场景的需求来决定把机器人做成什么样子,比如它按电梯或者开门需要一只手,我们就给它安一只手。


龙禹含:我补充一下。其实在产品迭代的过程中,我们考虑过两种方向,一种是比较贴近于人的方向,一种就是现在这种方向。我们之所以做出现在这种选择,其实主要是考虑这个产品需要大规模在实际场景中落地。如果做成接近于人的形态,还要在非机动车道上达到接近骑手的速度,我们觉得是不适配的。而且还存在交规风险和居民、客户接受度的风险。未来,我们还是会根据客户的需求以及成本等因素来选择合适的形态。


数据驱动贯穿始终


机器之心:前段时间,李飞飞教授创立了一个空间智能公司,您如何看待这个方向?


卢鹰翔:在看到新闻后,我们也做了一些调研,就是研究李飞飞教授这个公司具体要做什么。我们问了她实验室的学生,结果学生暂时也不太清楚。考虑到李飞飞教授之前一个非常重要的贡献是 ImageNet,而具身智能领域现在既没有特别好的训练数据集,也没有特别成熟的预训练模型,所以我们猜测,她这个新公司可能会在数据方向做一些事情,比如三维场景中人和机器之间相互关系的数据的收集,然后用这些数据去辅助机器人基础大模型的训练。


机器之心:李飞飞等具身智能领域的研究者有没有给你们的创业之路提供一些启发?


龙禹含:数据魔咒已经成为当前具身智能领域的一个共识。李飞飞等研究者给我们的启发,就是要尽快去实际场景中获得更多高质量的数据,而且是用商业化的方式低成本地去获取,然后再反过来推动技术的进一步发展和落地。这是我们在创立推行科技之初就确立的思路。在具身智能领域,这个思路已经被李飞飞教授这样的业界前辈反复印证。这让我们在这个方向的努力变得更加坚定。


作者:机器之心
来源:juejin.cn/post/7383957030345670666
收起阅读 »

HTTP3为什么抛弃了经典的TCP,转而拥抱 QUIC 呢

我们在看一些关于计算机网络的数据或文章的时候,最常听到的就是 TCP、UDP、HTTP 这些,除此之外,我们或多或少可能听过 QUIC这个东西,一般跟这个词一起出现的是 HTTP3,也就是HTTP协议的3.0版本,未来2.x 版本的升级方案。QUIC...
继续阅读 »

我们在看一些关于计算机网络的数据或文章的时候,最常听到的就是 TCP、UDP、HTTP 这些,除此之外,我们或多或少可能听过 QUIC这个东西,一般跟这个词一起出现的是 HTTP3,也就是HTTP协议的3.0版本,未来2.x 版本的升级方案。

QUIC 由 Google 主导设计研发。我们都知道 HTTP 协议是应用层协议,在传输层它使用的是 TCP 作为传输协议,当然这仅仅是对于 HTTP/1 和 HTTP/2 而言的。而 QUIC 的设计的对标协议就是 TCP ,也就是说将来只要能使用 TCP 的地方,都可以用 QUIC 代替。

Google 最开始的目的就是为了替换 HTTP 协议使用的 TCP 协议,所以最开始的名字叫做 HTTP over QUIC,后来由 IETF 接管后更名为 HTTP/3。所以,现在说起 HTTP/3 ,实际指的就是利用 QUIC 协议的版本。

TCP 不好吗,为什么还要 QUIC

TCP 协议作为传输层最负盛名的协议,可谓是久经考验。只要一说到 TCP ,我们都能说出来它是一种面向连接的、可靠的、基于字节流的传输层通信协议。

TCP 通过三次握手的方式建立连接,并且通过序号、ACK确认、丢包重传以及流量控制、拥塞控制等各种繁琐又细致的方式来保证它的可靠性。

关于 TCP 的更多细节,有兴趣的可以读读我之前写的《轻解计算机网络》里的 一个网管的自我修养之TCP协议

看上去很完美了,那为什么还要重新搞个 QUIC 出来呢,而且还被作为下一代 HTTP 的实现协议。确实不存在完美的协议,TCP 协议在整个发展过程中经过了多次改进,但是由于牵扯到互联网世界浩如烟海的各种设备,每次更新、修改都要考虑兼容问题,历史包袱太重,以至于尾大不掉。

所以为了弥补 TCP 的不足,在 TCP 上直接修改不太可能,那最好的方案就是重新开发一套协议。这种协议要吸收 TCP 的精华,又要解决 TCP 的不足,这就是 QUIC 出现的意义。

TCP 的问题-队头阻塞

时至今日,互联网上大多数网站都已经支持 HTTP/2 协议了,你可以在浏览器开发者工具中看一下网络请求,其中的 Protocol 表示网络请求采用的协议。

image.png

HTTP/2的一个主要特性是使用多路复用(multiplexing),因而它可以通过同一个TCP连接发送多个逻辑数据流。复用使得很多事情变得更快更好,它带来更好的拥塞控制、更充分的带宽利用、更长久的TCP连接————这些都比以前更好了,链路能更容易实现全速传输。标头压缩技术也减少了带宽的用量。

采用HTTP/2后,浏览器对每个主机一般只需要 一个 TCP连接,而不是以前常见的六个连接。

如下图所示,HTTP/2 在使用 TCP 传输数据的时候,可以在一个连接上传输两个不同的流,红色是一个流,绿色是另外一个流,但是仍然是按顺序传输的,假设其中有一个包丢了,那整个链路上这个包后面的部分都要等待。

image.png

这就造成了阻塞,虽然一个连接可传多个流,但仍然存在单点问题。这个问题就叫做队头阻塞。

QUIC 如何解决的

TCP 这个问题是无解的,QUIC 就是为了彻底解决这个问题。

如下图所示,两台设备之间建立的是一个 QUIC 连接,但是可以同时传输多个相互隔离的数据流。例如黄色是一个数据流,蓝色是一个数据流,它俩互不影响,即便其中一个数据流有丢包的问题,也完全不会影响到其他的数据流传输。

这样一来,也就解决了 TCP 的队头阻塞问题。

image.png

为什么要基于 UDP 协议

QUIC 虽然是和TCP 平行的传输协议,工作在传输层,但是其并不是完全采用全新设计的,而是对 UDP 协议进行了包装。

UDP 是无连接的,相对于 TCP 来说,无连接就是不可靠的,没有三次握手,没有丢包重传,没有各种各样的复杂算法,但这带来了一个好处,那就是速度快。

而 QUIC 为了达到 TCP 的可靠性,所以在 UDP 的基础上增加了序号机制、丢包重传等等 UDP 没有而 TCP 具有的特性。

既然这么多功能都做了,还差一个 UDP 吗,完全全新设计一个不好吗,那样更彻底呀。

之所以不重新设计应该有两个原因:

  1. UDP 本身就是非常经典的传输层协议,对于快速传输来说,其功能完全没有问题。
  2. 还有一个重要的原因,前面也说到了,互联网上的设备太多,而很多设备只认 TCP 和 UDP 协议,如果设计一个完全全新的协议,很难实施。

QUIC 协议

不需要三次握手

QUIC 建立连接的速度是非常快的,不需要 TCP 那样的三次握手,称之为 0-RTT(零往返时间)及 1-RTT(1次往返时间)。

QUIC 使用了TLS 1.3传输层安全协议,所以 QUIC 传输的数据都是加密的,也就是说 HTTP/3 直接就是 HTTPS 的,不存在 HTTP 的非加密版本。

正是因为这样,所以,QUIC 建立连接的过程就是 TLS 建立连接的过程,如下图这样,首次建立连接只需要 1-RTT。

image.png

而在首次连接建立之后,QUIC 客户端就缓存了服务端发来的 Server Hello,也就是加密中所需要的一些内容。在之后重新建立连接时,只需要根据缓存内容直接加密数据,所以可以在客户端向服务端发送连接请求的同时将数据也一并带过去,这就是 0-RTT 。

连接不依靠 IP

QUIC 在建立连接后,会为这个连接分配一个连接 ID,用这个 ID 可以识别出具体的连接。

假设我正在家里用 WIFI 发送请求,但是突然有事儿出去了,马上切换到了蜂窝网络,那对于 QUIC 来说就没有什么影响。因为这个连接没有变,所以仍然可以继续执行请求,数据该怎么传还怎么传。

而如果使用的是 TCP 协议的话,那只能重新建立连接,重传之前的数据,因为 TCP 的寻址依靠的是 IP 和 端口。

未来展望

随着 QUIC 协议的不断完善和推广,其应用场景将更加广泛,对互联网传输技术产生深远的影响。未来的互联网,将是 QUIC 和 HTTP3 主导的时代。

要知道,HTTP/1 到 HTTP/2,中间用了整整 16 年才完成,这还只是针对协议做改进和优化,而 QUIC 可谓是颠覆性的修改,可想而知,其过程只会更加漫长。



作者:古时的风筝
来源:juejin.cn/post/7384266820466180148
收起阅读 »