注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

环信FAQ

环信FAQ

集成常见问题及答案
RTE开发者社区

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

pnpm才是前端工程化项目的未来

web
前言 相信小伙伴们都接触过npm/yarn,这两种包管理工具想必是大家工作中用的最多的包管理工具,npm作为node官方的包管理工具,它是随着node的诞生一起出现在大家的视野中,而yarn的出现则是为了解决npm带来的诸多问题,虽然yarn提高了依赖包的安装...
继续阅读 »

前言


相信小伙伴们都接触过npm/yarn,这两种包管理工具想必是大家工作中用的最多的包管理工具,npm作为node官方的包管理工具,它是随着node的诞生一起出现在大家的视野中,而yarn的出现则是为了解决npm带来的诸多问题,虽然yarn提高了依赖包的安装速度与使用体验,但它依旧没有解决npm的依赖重复安装等致命问题。pnpm的出现完美解决了依赖包重复安装的问题,并且实现了yarn带来的所有优秀体验,所以说pnpm才是前端工程化项目的未来


如果这篇文章有帮助到你,❤️关注+点赞❤️鼓励一下作者,文章公众号首发,关注 前端南玖 第一时间获取最新文章~


npm 与 yarn 存在的问题


早期的npm


在npm@3之前,node_modules结构可以说是整洁可预测的,因为当时的依赖结构是这样的:


node_modules 
└─ 依赖A
├─ index.js
├─ package.json
└─ node_modules
└─ 依赖B
├─ index.js
└─ package.json
└─ 依赖C
├─ index.js
├─ package.json
└─ node_modules
└─ 依赖B
├─ index.js
└─ package.json

每个依赖下面都维护着自己的node_modules,这样看起来确实非常整洁,但同时也带来一些较为严重的问题:



  • 依赖包重复安装

  • 依赖层级过多

  • 模块实例无法共享


依赖包重复安装


从上面的依赖结构我们可以看出,依赖A与依赖C同时引用了依赖B,此时的依赖B会被下载两次。此刻我们想想要是某一个依赖被引用了n次,那么它就需要被下载n次。(此时心里是不是在想,怎么会有如此坑的设计)


01203040_0.jpeg


依赖层级过多


我们再来看另外一种依赖结构:


node_modules 
└─ 依赖A
├─ index.js
├─ package.json
└─ node_modules
└─ 依赖B
├─ index.js
├─ package.json
└─ node_modules
└─ 依赖C
├─ index.js
├─ package.json
└─ node_modules
└─ 依赖D
├─ index.js
└─ package.json

这种依赖层级少还能接受,要是依赖层级多了,这样一层一层嵌套下去,就像一个依赖地狱,不利于维护。


npm@3与yarn


为了解决上述问题,npm3yarn都选择了扁平化结构,也就是说现在我们看到的node_modules里面的结构不再有依赖嵌套了,都是如下依赖结构:


node_modules 
└─ 依赖A
├─ index.js
├─ package.json
└─ node_modules
└─ 依赖C
├─ index.js
├─ package.json
└─ node_modules
└─ 依赖B
├─ index.js
├─ package.json
└─ node_modules

node_modules下所有的依赖都会平铺到同一层级。由于require寻找包的机制,如果A和C都依赖了B,那么A和C在自己的node_modules中未找到依赖C的时候会向上寻找,并最终在与他们同级的node_modules中找到依赖包C。 这样就不会出现重复下载的情况。而且依赖层级嵌套也不会太深。因为没有重复的下载,所有的A和C都会寻找并依赖于同一个B包。自然也就解决了实例无法共享数据的问题


由于这个扁平化结构的特点,想必大家都遇到了这样的体验,自己明明就只安装了一个依赖包,打开node_modules文件夹一看,里面却有一大堆。


nz2.jpeg


这种扁平化结构虽然是解决了之前的嵌套问题,但同时也带来了另外一些问题:



  • 依赖结构的不确定性

  • 扁平化算法的复杂度增加

  • 项目中仍然可以非法访问没有声明过的依赖包(幽灵依赖)


依赖结构的不确定性


这个怎么理解,为什么会产生这种问题呢?我们来仔细想想,加入有如下一种依赖结构:


依赖1.png


A包与B包同时依赖了C包的不同版本,由于同一目录下不能出现两个同名文件,所以这种情况下同一层级只能存在一个版本的包,另外一个版本还是要被嵌套依赖。


那么问题又来了,既然是要一个扁平化一个嵌套,那么这时候是如何确定哪一个扁平化哪一个嵌套的呢?


依赖2.png


这两种结构都有可能,准确点说哪个版本的包被提升,取决于包的安装顺序!


这就是为什么会产生依赖结构的不确定问题,也是 lock 文件诞生的原因,无论是package-lock.json(npm 5.x 才出现)还是yarn.lock,都是为了保证 install 之后都产生确定的node_modules结构。


尽管如此,npm/yarn 本身还是存在扁平化算法复杂package 非法访问的问题,影响性能和安全。


pnpm


前面说了那么多的npmyarn的缺点,现在再来看看pnpm是如何解决这些尴尬问题的。


什么是pnpm



快速的,节省磁盘空间的包管理工具



就这么简单,说白了它跟npmyarn没有区别,都是包管理工具。但它的独特之处在于:



  • 包安装速度极快

  • 磁盘空间利用非常高效


特性


安装包速度快


p1.png


从上图可以看出,pnpm的包安装速度明显快于其它包管理工具。那么它为什么会比其它包管理工具快呢?


我们来可以来看一下各自的安装流程



  • npm/yarn


npm&yarn.png



  1. resolving:首先他们会解析依赖树,决定要fetch哪些安装包。

  2. fetching:安装去fetch依赖的tar包。这个阶段可以同时下载多个,来增加速度。

  3. wrting:然后解压包,根据文件构建出真正的依赖树,这个阶段需要大量文件IO操作。



  • pnpm


pnpm.png


上图是pnpm的安装流程,可以看到针对每个包的三个流程都是平行的,所以速度会快很多。当然pnpm会多一个阶段,就是通过链接组织起真正的依赖树目录结构。


磁盘空间利用非常高效


pnpm 内部使用基于内容寻址的文件系统来存储磁盘上所有的文件,这个文件系统出色的地方在于:



  • 不会重复安装同一个包。用 npm/yarn 的时候,如果 100 个项目都依赖 lodash,那么 lodash 很可能就被安装了 100 次,磁盘中就有 100 个地方写入了这部分代码。但在使用 pnpm 只会安装一次,磁盘中只有一个地方写入,后面再次使用都会直接使用 hardlink

  • 即使一个包的不同版本,pnpm 也会极大程度地复用之前版本的代码。举个例子,比如 lodash 有 100 个文件,更新版本之后多了一个文件,那么磁盘当中并不会重新写入 101 个文件,而是保留原来的 100 个文件的 hardlink,仅仅写入那一个新增的文件


支持monorepo


pnpm 与 npm/yarn 另外一个很大的不同就是支持了 monorepo,pnpm内置了对monorepo的支持,只需在工作空间的根目录创建pnpm-workspace.yaml和.npmrc配置文件,同时还支持多种配置,相比较lerna和yarn workspace,pnpm解决monorepo的同时,也解决了传统方案引入的问题。



monorepo 的宗旨就是用一个 git 仓库来管理多个子项目,所有的子项目都存放在根目录的packages目录下,那么一个子项目就代表一个package



依赖管理


pnpm使用的是npm version 2.x类似的嵌套结构,同时使用.pnpm 以平铺的形式储存着所有的包。然后使用Store + Links和文件资源进行关联。简单说pnpm把会包下载到一个公共目录,如果某个依赖在 sotre 目录中存在了话,那么就会直接从 store 目录里面去 hard-link,避免了二次安装带来的时间消耗,如果依赖在 store 目录里面不存在的话,就会去下载一次。通过Store + hard link的方式,使得项目中不存在NPM依赖地狱问题,从而完美解决了npm3+和yarn中的包重复问题。


store.jpeg


我们分别用npmpnpm来安装vite对比看一下


npmpnpm
npm-demo.pngpnpm-demo.png
所有依赖包平铺在node_modules目录,包括直接依赖包以及其他次级依赖包node_modules目录下只有.pnpm和直接依赖包,没有其他次级依赖包
没有符号链接(软链接)直接依赖包的后面有符号链接(软链接)的标识

pnpm安装的vite 所有的依赖都软链至了 node_modules/.pnpm/ 中的对应目录。 把 vite 的依赖放置在同一级别避免了循环的软链。


软链接 和 硬链接 机制


pnpm 是通过 hardlink 在全局里面搞个 store 目录来存储 node_modules 依赖里面的 hard link 地址,然后在引用依赖的时候则是通过 symlink 去找到对应虚拟磁盘目录下(.pnpm 目录)的依赖地址。


这两者结合在一起工作之后,假如有一个项目依赖了 A@1.0.0B@1.0.0 ,那么最后的 node_modules 结构呈现出来的依赖结构可能会是这样的:


node_modules
└── A // symlink to .pnpm/A@1.0.0/node_modules/A
└── B // symlink to .pnpm/B@1.0.0/node_modules/B
└── .pnpm
├── A@1.0.0
│ └── node_modules
│ └── A -> /A
│ ├── index.js
│ └── package.json
└── B@1.0.0
└── node_modules
└── B -> /B
├── index.js
└── package.json

node_modules 中的 A 和 B 两个目录会软连接到 .pnpm 这个目录下的真实依赖中,而这些真实依赖则是通过 hard link 存储到全局的 store 目录中。


store


pnpm下载的依赖全部都存储到store中去了,storepnpm在硬盘上的公共存储空间。


pnpmstore在Mac/linux中默认会设置到{home dir}>/.pnpm-store/v3;windows下会设置到当前盘符的根目录下。使用名为 .pnpm-store的文件夹名称。


项目中所有.pnpm/依赖名@版本号/node_modules/下的软连接都会连接到pnpmstore中去。



作者:前端南玖
来源:juejin.cn/post/7239875883254300729
收起阅读 »

运维打工人,周末兼职送外卖的一天

运维打工人,周末兼职送外卖的一天 在那个不经意的周末,我决定尝试一份新的工作——为美团外卖做兼职配送员。这份工作对于一向规律生活的我来说,既是突破也是挑战。 早晨,城市的喧嚣还未完全苏醒,空气中带着几分凉意和宁静。准备好出发时,线上生产环境出现问题,协助处理。...
继续阅读 »

运维打工人,周末兼职送外卖的一天


在那个不经意的周末,我决定尝试一份新的工作——为美团外卖做兼职配送员。这份工作对于一向规律生活的我来说,既是突破也是挑战。


早晨,城市的喧嚣还未完全苏醒,空气中带着几分凉意和宁静。准备好出发时,线上生产环境出现问题,协助处理。


收拾好后,戴上头盔,骑上踏板车,开始了自己的第一次外卖配送之旅。


刚开始,我的心情既紧张又兴奋。手机里的订单提示声是今日的任务号角。第一份订单来自一公里外的一家外卖便利店。我快速地在地图上规划路线,开启高德导航,发动踏板车,朝着目的地出发。


123.jpg


由于便利店在园区里面,转了两圈没找到,这是就慌张了,这找不到店咋办了,没办法赶紧问下旁边的老手骑手,也就顺利找到了,便利店,进门问老板,美团104号好了嘛?老板手一指,在架子上自己看。核对没问题,点击已达到店,然后在点击已取货。


然后在导航去收获目的地,找到C栋,找到107门牌号,紧接敲门,说您好,美团外卖到了,并顺利的送达,然后点击已送达,第一单顺利完成,4.8元顺利到手。


其中的小插曲,送给一个顾客时,手机导航提示目的地,结果一看,周围都拆了。没办法给顾客打电话,加微信确认位置具体在哪里,送达时,还差三分钟,这单就要超时了。


1.jpg


配送过程中,我遇到了第一个难题:找不到店家在哪里,我的内心不禁生出些许焦虑。但很快,我调整心态,不懂不知道的地方,需要多多问人。


紧接着,第二份、第三份订单接踵而至。每一次出发和到达,每一条街道和巷弄,我开始逐渐熟悉。


7.jpg


6.jpg


日落时分,我结束了一天的工作。虽然身体有些疲惫,但内心充满了前所未有的充实感。这份工作让我体验到了不一样的人生角色,感受到了城市节奏背后的种种辛劳与甘甜


周末的兼职跑美团外卖,对我来说不仅是一份简单的工作,更是一段特别的人生经历。它教会了我坚持与责任,让我在忙碌中找到了属于自己的节奏,在逆风中学会了更加珍惜每一次到达。


最后实际周六跑了4个小时,周天跑了7个小时,一共跑了71公里,合计收获了137.80,已提现到账。


5.jpg


2.png


作者:平凡的运维之路
来源:juejin.cn/post/7341669201010425893
收起阅读 »

【Java实战项目】SpringBoot + Vue3打造你的在线电子书平台!

今天给大家分享一个基础的Java实战项目,用SpringBoot和Vue3开发一个电子书平台,大家可以尝试做一下这个项目,以此来检验这段时间的学习成果!废话不多说,下面正式进入项目:一、项目介绍1. 项目简介在线电子书微实战项目是一个实践性的基础项目,主要目的...
继续阅读 »

今天给大家分享一个基础的Java实战项目,用SpringBoot和Vue3开发一个电子书平台,大家可以尝试做一下这个项目,以此来检验这段时间的学习成果!废话不多说,下面正式进入项目:

一、项目介绍

1. 项目简介

在线电子书微实战项目是一个实践性的基础项目,主要目的是通过开发一个在线电子书网站来帮助入门学习和实践相关的技术。

预览链接:在线电子书平台项目实战




该项目涵盖了以下主要功能:

  • 电子书管理:主要包括电子书的基本信息、电子书的章节管理、章节信息和章节内容等。

  • 电子书阅读:用户可以浏览在线电子书,并享受连续翻页、目录导航等阅读体验。


通过该项目,可以深入了解电子书网站的设计和开发过程,学习相关的前端和后端技术,并提升实际项目经验。这个项目不仅有助于理论知识的实践运用,还能够培养问题解决的能力。


2. 项目重点

  • 电子书管理:

实现电子书的结构设计,包括电子书信息、章节列表、章节排序和章节编辑等功能,并支持Md格式的内容编写及预览。



  • 电子书阅读界面设计:

重点关注电子书阅读界面的流畅性和易用性,实现灵活的阅读布局和翻页体验,让用户能够快速定位所需内容。


3. 项目目标

旨在通过实践操作,了解和掌握在线电子书平台的基本功能和技术要点。通过完成这些功能,将获得相关的Web前后端技术、数据库管理技术以及用户界面设计等方面的实践经验,并能够将所学知识应用到实际项目中。


4. 项目技术实现

前端技术实现:

主要基于Vite4 + Vue3作为前端框架来进行开发,利用Vue Router进行路由管理,Axios库进行HTTP请求和响应处理等技术和工具。同时还使用Element UI统一页面风格。


为了实现内容的编辑和预览功能,项目还引入了v-md-editor编辑器组件。通过该组件,用户可以方便地编辑和排版电子书的内容,并实时预览效果。这为用户提供了一个直观、便捷的内容管理方式,使其能够快速编辑、修改和发布电子书的内容。


后端技术实现:

项目采用Spring Boot作为后端框架,通过Spring MVC进行请求处理和路由管理,使用MyBatis作为持久层框架进行数据库操作。后端主要实现了API的对接、电子书管理的逻辑处理以及与前端的数据交互。


5. 实现流程


01

规划和设计:

确定项目需求和功能,并进行整体设计和规划,包括前端界面设计、后端API设计以及数据库结构设计等。

02

搭建前端项目框架:

使用Vue3+vite4等前端框架创建项目,并配置相关开发环境和插件,如Vue Router、Axios等。

03

 开发前端页面和功能:

根据设计,开发前端页面组件和功能模块,包括电子书编辑、预览、章节内容管理等功能。

04

设计和创建数据库:

根据需求设计数据库结构,选择MySQL数据库管理系统,创建数据库和相应的表/集合。

05

开发后端API:

使用SpringBoot后端技术,搭建后端服务器,编写API接口,提供与前端交互的数据处理和业务逻辑。

06

处理前后端数据交互:

前端通过Axios等工具发送HTTP请求,后端接收请求,进行数据处理和验证,返回相应的数据结果。

07

数据库操作和持久化:

后端根据接收到的请求,通过数据库操作进行数据的读取、写入、更新和删除等操作,实现数据的持久化存储。

6. 业务流程及页面效果展示

6.1 电子书管理


6.2 电子书的详情(预览)


6.3 电子书基本信息编辑


6.4 电子书章节管理

6.5 电子书章节内容编辑

  • 导入功能:直接导入 Markdown 文件,并将其作为章节内容展示。



  • 导出功能:将章节的内容以 Markdown 文档的形式进行导出


  • 全屏编辑与实时预览:在全屏模式下使用 Markdown 编辑器对章节内容进行编辑,同时能够实时预览编辑后的效果。




7. 总结

在线电子书功能微实战是一项学习实践型的项目,它涉及到多个技术领域,包括前端、后端、数据库和数据交互等。在学习和实践中,我们需要了解项目的需求和功能,并进行规划和设计。


通过选择合适的技术和工具,开发前端页面和后端API实现完整功能,包括电子书编辑、预览、章节内容管理等。这个项目不仅可以帮助我们了解实际的开发流程和技术应用,还可以提升我们的编程能力和实践经验。


二、部署教程


后端部署文档


1、 后端技术栈

  • Java:项目使用Java编程语言进行开发。

  • Spring Boot:项目基于Spring Boot框架搭建。

  • Maven:项目使用Maven作为构建工具和依赖管理工具。

  • MySQL:项目使用MySQL作为关系型数据库。


2、开发环境准备

项目环境所需:

  • 开发工具 IDEA 版本是IntelliJ IDEA 2023.2 (Ultimate Edition)

  • 使用的 Java 环境是 jdk8

  • Maven 使用的 3.8.1

  • Spring Boot 使用的 2.7.10

  • 接口文档 使用 swagger3

  • MySQL 使用的 8.0.31

  • 工具使用 Navicat


3、项目源码下载


源码下载地址:在线电子书平台项目实战源码


4、 构建项目

解压缩下载包得到:electronic_book.sql 数据库SQL文件 和 electronic-book 项目文件夹


4.1. 导入数据库

新建数据库 electronic_book




导入 electronic_book.sql 数据库SQL文件



点击 开始 导入即可

4.2. 编辑器内 导入项目源码

在IDE编辑器内,打开 electronic-book 项目文件夹;


配置项目MAVEN设置,编辑器内 打开 File - Settings,找到MAVEN设置位置;


设置完成后等待MAVEN加载相关依赖,或后续手动在右侧刷新加载。

4.3. 修改数据库配置

在 resources 下 application.yml 文件内,修改合适配置;


4.4. 接口文档查看测试

  • 启动项目


  • 如果端口占用,在 resources 下 application.yml 文件内修改端口再启动


  • 打开 swagger 接口文档查看是否正常启动



5. 源码解析

5.1. 目录结构

电子书业务相关

  • pojo : 电子书相关pojo 实体类及其他

  • mapper : 电子书相关mapper

  • controller - ElectronicBookController: 电子书相关接口

  • controller - ElectronicBookItemController: 电子书内容相关接口

  • service : 电子书相关接口实现


公共类

  • baseClass : 处理统一业务,如参数校验等

  • config - CrosConfig: 处理测试时可能存在的跨域问题

  • config - SwaggerConfig: swagger文档配置

  • enumeration : 业务枚举等

  • exception : 通用异常返回处理

  • result : 统一结果返回

  • utils : 常用工具类


5.2. README

具体源码解析以及电子书管理介绍和流程等,查看源码文件 read.md以及相关文件内注释详情。



前端部署文档

1. 前端技术栈

vite4 + vue3 + scss(1.69.5) + Typescript(5.2.0)+ @kangc/v-md-editor(2.3.18)

  • Vite是一款快速构建现代web应用程序的构建工具,特别适合于Vue.js应用程序。

  • Vue3是一款流行的JavaScript框架。

  • SCSS是一种CSS预处理器,它允许开发者使用类似编程语言的方式来编写CSS,提高代码的可维护性和重用性。

  • TypeScript是一种由微软开发的静态类型检查的JavaScript超集。

  • @kangc/v-md-editor是基于 Vue 开发的 markdown 编辑器组件。


2. 开发环境

  • Node.js 和 npm 的安装
  • 代码编辑器的选择

    推荐:VSCode + Volar(并禁用Vetur) + TypeScript Vue插件(Volar)


3. 准备项目

3.1. 解压项目源码

将解压缩下载包得到的前端项目文件夹(electronic-book-view),放到到指定的文件夹(例如:E:\workspace_dev\project-practice\electronic-book-view)。


3.2. 更改配置文件
  • 代码编辑器中打开项目源码文件夹
  • api地址配置

点击\src\config.ts

// 配置api地址(以实际项目服务器地址为准)
const baseURL ="http://loacalhost:8087/";


4. 启动项目

4.1. 打开命令行界面
4.2. 安装依赖

在命令窗口或者终端窗口输入命令 npm install


4.3. 构建编译

在命令窗口或者终端窗口输入命令 npm run dev


注意:若端口号3000被占用,会自动重新分配一个空闲的端口号,具体以实际为准。

若启动失败,有报错,请具体问题具体分析,如有不懂也可在云端源想【技术咨询】咨询老师进行答疑解惑(地址:https://www.ydcode.cn/)也可点击文末的阅读原文直接跳转。

4.4. 访问测试

上述启动命令脚本,已经配置默认浏览器打开访问模式,若无正常打开先自行尝试解决,若有问题继续咨询老师解答。


默认打开访问地址为:http://localhost:3000/



4.5. 终止项目

若需要终止项目,首先聚焦在命令窗口或者终端窗口,然后按键CTRL+c即可终止项目。


5. 前端项目说明

请参阅项目的 README.md 文档,其中包含了项目的介绍、安装说明和使用方法等关键信息。以下是其中的重点信息展示:


5.1 引入 markdown 编辑器 介绍

本项目中采用 v-md-editor 进阶版编辑器,进阶版编辑器左侧编辑区域使用 CodeMirror (opens new window)实现。


进阶版编辑器可以根据 CodeMirror 提供的 Api 来自定义扩展编辑区域功能,提高编辑体验。但是文件体积远大于轻量版。使用者可根据所在项目的情况进行选择。

5.2 项目文件结构介绍
/
├── public
│ ├── favicon.ico # vue官方自带标识
│ └── logo.svg # 浏览器标签页logo
├── src # 项目源代码
│ ├── api # 用于存放与后端 API 相关的接口定义。
│ ├── assets # 用于存放项目所需的静态资源文件,例如图片、字体等。
│ ├── components # 用于存放可复用的组件。
│ ├── router # 路由的定义和配置
│ ├── styles # 样式文件
│ │ └── main.scss # 全局的 SCSS 变量等
│ ├── views # 页面组件
│ │ ├──components # 页面所需组件
│ │ └──xxx.vue # 页面
│ ├── app.vue # 应用程序根组件
│ ├── config.ts # 应用程序根组件
│ └── main.ts # 应用程序入口
├── .gitinore # git忽略配置文件
├── env.d.ts # 用于声明环境变量的类型
├── index.html # 整个应用的入口HTML文件
├── package-lock.json # 用于锁定安装时的依赖版本
├── package.json # 应用的配置文件,其中包含了项目的依赖、脚本命令等信息。
├── README.md # 项目的说明文档,通常包含了项目的介绍、安装和使用方法等信息。
├── tsconfig.app.json # 用于前端应用程序的TypeScript编译配置
├── tsconfig.json # TypeScript 项目的配置文件
├── tsconfig.node.json # 用于后端(服务器端)应用程序的TypeScript编译配置
└── vite.config.js # Vite 的配置文件

6. 常见问题处理

  • node和npm版本问题

建议:Vite 4 需要 Node.js 版本 ≥ 16.0.0,npm 版本 ≥ 8.0.0。但是,某些模板需要更高的 Node.js 版本才能工作,如果您的包管理器发出警告,请升级。

  • 依赖安装失败

报错:ERESOLVE unable to resolve dependency tree


三、源码下载

到这里这个运用SpringBoot + Vue3打造在线电子书平台项目介绍及部署就讲完了,小伙伴可以自己尝试写一下这个项目来巩固练习你所学的Java知识哦!

完整的源码下载地址:在线电子书平台源码

收起阅读 »

30岁之前透支,30岁之后还债。

前言 看到不少私信问我为啥没有更新了,我没有一一回复,实在是身体抱恙,心情沉重,加上应付于工作,周旋于家庭,自然挤压了自我空间。 今天思来想去,重新执键,决定久违地又一次写点分享,奉劝大家珍惜身体,愉悦生活。 愉悦二字说来容易,但各位都一样,奔波于现实,劳累于...
继续阅读 »

前言


看到不少私信问我为啥没有更新了,我没有一一回复,实在是身体抱恙,心情沉重,加上应付于工作,周旋于家庭,自然挤压了自我空间。


今天思来想去,重新执键,决定久违地又一次写点分享,奉劝大家珍惜身体,愉悦生活。


愉悦二字说来容易,但各位都一样,奔波于现实,劳累于生活,岂是三言两语就能改变的。


病来如山倒


我又病了,有些意外和突然的,令我措手不及。


一天早上我起来,脖子有些酸,就伸手揉揉捏捏,忽然发现脖颈左侧有一个肿块,仔细拿捏,发现竟然是在里面,而且硬邦邦的,伴有轻微的疼痛感。


当时早上对着镜子拍下来的肿块,我还保留了照片。


1.jpg


立马便一身冷汗冒出,我从未经历过这样的事情,去年身体毕竟出过问题,两相叠加之下,内心更是难以描述。


因为是周一,怀着忐忑的心情去上班了,接下来一直都有些神经兮兮,觉得自己身体出了大问题。


之前我有文章讲过自己去年其实已经检查出血脂的问题,停更半年之久,调养了一番,才真正感觉到身体有所恢复,根据我发文的日期可见一二。


恢复更新的这段时间,报复式地写作和分享,一度不知不觉地排到榜单第二,今天登录看了一下,居然还在月榜前三没下来,也是意外。


话说回来,人一旦身体冒出点病痛,整个心情都显得低沉萎靡,很快就能在方方面面反应出来。


我是硬着头皮上班的,抽空网上查了下好让自己有个心理准备。



百度一搜便是绝症,这是很多年前就知道的,但病急乱投医果然是人之本性,我毅然决然还是搜了。


然后,各种甲状腺之类的就来了,再搜,淋巴瘤也来了,再搜,好家伙,直接恶性肿瘤十有八九了。



面对未知而产生的接近绝望的心情,想必不少人有类似经验。


比如我,下意识先想到的竟然不是我是不是要完蛋了,而是想到自己是家中独子,父母年迈身体有恙,妻子操劳,孩子尚小,家中主要经济来源也是我。


我一旦倒下,实在不敢想,往深了一想各种负面因子都蜂拥而来。


我不知道有多少人和我的性格相似,就是身体出了这种未知的问题,一面觉得应该去医院看看,一面又怕折腾来去最后拿到最不可接受的结果,可能不知道反而能活久一点,大概就是这种心情了。


是的,我大体是个胆子还算大的人,也猛然间抗拒去医院了。


不去医院的结果,就是你每天都在意这个肿块,每天都要摸摸它是不是变小了,是不是消失了,每天都小心呵护着它,甚至还想对它说说话倾诉一下,像是自己偷养的小情人一样。


只盼着某天睡觉醒来,用手一摸,哈哈没有了这样。


我就是差不多一个月都这样惶惶不可终日地度过,直到这周六才被妻子赶去医院做了检查。


透支和还债



30岁之前透支,30岁之后还债。



说来好笑,摸到肿块的第二天吧,还有朋友私信找我合作,换做平时,我肯定欣然接受,并开始设计文稿。


但身体有问题,一切都索然无味了,再次真切地体会到这种被现实打碎一切欲望的撕裂感。


2.png


为什么我30岁之后身体慢慢开始出现各种问题,这两年我有静下心来思考过。


到底还是30岁之前透支太多了,30岁之后你依然养成30岁之前的生活习惯,无异于自杀行为。



我把身体比作一根橡皮筋,它大概只能扯那么长,我长期将它扯那么那么长,我以为它没事,直到有一次我将它扯那么那么那么长,砰的一声它就断了。


我们都无法知道自己的这根橡皮筋到底能扯多长,只要它没断,我们都觉得它还能扯很长,代价就是,只需断一次,你再也无法重来了。



30岁之前,我努力学习各种知识,熬夜那是家常便饭,睡一觉便生龙活虎。


我就像以前上学的三好学生一样,在学校我扎扎实实,放学了我还进补习班,补习班回来了我还上网学知识。


回头想想,真特么离谱啊,我上学都没这样,走上社会了竟然付出了之前在学校几倍的努力。


早知如此,我好好上学读书最后进入一个更优质的圈子,不就少走很多弯路了吗,但是谁又会听当年的老师和父母一番肺腑之言呢。


埋怨过去没有什么意义,只能偶尔借着都市小说幻想一下带着记忆重生回校园的自己。


细数下来,我30岁之前熬过的夜比我加的班还多,我不是天天加班,但好像真的天天熬夜。


可我身体一点问题都没有,我觉得自己不是那种被命运抛弃的人,内心一直这么侥幸,你是不是也和我一样呢。


30岁之后,该来的还是来了,32岁那年,我有一次咳嗽入院,反复高烧,退了又发烧,医生一度以为是新冠,或结核,或白血病什么的,后来全部检查了都不是,发现就是普通的肺部感染。


每天两瓶抗病毒的点滴,大概半个月才逐渐恢复,人都瘦脱相了,这是我人生头一次住院,躺在病床上像废人一样。


等到33岁也就是去年,偶然头晕了一次,那种眩晕,天旋地转,犯恶心,怎么站怎么坐怎么躺都不行,真正要死的感觉。


后面我一度以为是年纪轻轻得了高血压,结果查了下是血脂的问题,还不算严重,但继续下去很可能会变成一些心脑血管疾病。


我难以置信,这可都是老年病啊,我一个30几岁的程序员说来就来了?


调养半年多,肉眼可见身体有好转,我又开始没忍住熬夜了,想做自己的课题,想分享更多的东西,这些都要花时间,而且包括一些其他领域的内容,想得太多,自然花的时间就多。


一不小心就连续熬了一个多月,平均每晚都是2点左右躺下,有时中午还不午休,刷手机找素材。


终于,脖子上起了肿块,让我整个人都蒙圈了,觉得一切努力都是在玩弄自己,忽然间什么都没意思了。


我尽量把这种感受描述出来,希望你们能看明白,真切体会一二。


为什么30岁之后我一熬夜就有问题出现,说白了,30岁之前透支了已经,一来是身体负荷达到临界,二来养成了多年的坏习惯,一时想改还改不过来。



30岁之前真别玩弄自己的身体了xdm,橡皮筋断了就真断了,接不上了,接上了也没以前的弹性了。



健康取决于自律和心情



对于程序员来说,健康取决于两点:自律和心情。



30岁之前,请学会自律,学习时间自律,生活作息自律,一日三餐自律,养成这样的习惯,30岁之后的你会受益匪浅。


自律真的很难,我就是一个很难做到的人,我有倔强地适应过,却又悲哀地失败了。


就像你是一个歇斯底里的人,忽然让你温文尔雅,你又能坚持多久呢。


我用很多鸡汤说服过自己,对于已经30几岁的我来说,也只能维持一段时间。


想看的多,想玩的多,想学的也多,时间是真不够啊,真想向天再借五百年。


我应该算是幸运的那一类,至少我这般透支身体,我还活着,也没用余生去直面绝望。



我用这两年的身体故障给自己上了重要的一课,人死如灯灭。



如果能重来,我一定会学习时间规划,我一定会把每天的时间安排的好好的。


我一定会保证一日三餐不落下,少吃外卖,多吃水果蔬菜。


我一定会保证每晚充足的睡眠,早睡早起,绝不熬夜。


我一定会每天下班和放假抽出一些时间运动和锻炼。


我不是说给自己听的,因为我已经透支了。


我是说给在看文章的你们听的,还年轻点的,还没透支的,请用我的现在当做你可能更坏的未来,早点醒悟,为时不晚。


自律很难,但不自律可能等死,这个选择一点也不难。



工作压力大,作为程序员是避免不了的,所以我以前有劝过大家,薪水的重要性只占一半,你应该追寻一份薪水尚可,但压力一定在承受范围内的工作,这是我认为在国内对于程序员来说相对友好的途径。



我进入IT行业目前为止的整个生涯中,学习阶段听到过传智播客张孝祥老师的猝死,工作阶段听说过附近的4396游戏公司里面30多岁程序员猝死,今年又听到了左耳朵耗子先生的离世。


我想着,那一天,离我和你还有多远。


心情真的很重要,至少能快速反应在身体上。


当我这周六被妻子劝说去检查的时候,我内心一直是紧张的,妻子没去,就在家陪着孩子,跟我说你自己去吧,如果有坏消息就别回复了,等回来再说,如果没什么事那就发个微信。


我想我理解她的意思了,点了点头就骑车去了医院。


医院真不是什么好地方,我就是给医院干活的,我全身上下都讨厌这里。


最煎熬的时间是做彩超前的一个多小时,因为人太多,我得排队,盯着大屏上的号序,我脑子里想了很多事情,甚至连最坏的打算都想好了。


人就很奇怪,越是接近黑暗,越是能回忆起非常多的往事,连高中打篮球挥洒汗水的模样和搞笑的投篮姿势都能想起来。


喊到我的时候,我心跳了一下,然后麻木地进去了,躺下的时候,医生拿着仪器对着我的脖子扫描,此时的我是近一个月以来第一次内心平静,当真好奇怪的感觉。


随着医生一句:没什么事,就一个淋巴结。


犹如审判一般,我感觉一下无罪释放了。


当时听到这句话简直犹如天籁,这会儿想起来还感觉毛孔都在欢快地愉悦。


我问她不是什么肿瘤或甲状腺吧,她说不是,就一个正常的淋巴结,可能是炎症导致了增生,这种一般3个多月至半年才会完全消掉。


这是当时拍的结果


3.jpg


拿给主任医师看了之后,对方也说一点事没有,只是告诫我别再熬夜了。


我不知道人生还会给我几次机会,但我从20几岁到30几岁,都没有重视过这个问题,也没有认真思考过。


直到最近,我才发现,活着真好。


当晚是睡得最踏实的一晚,一点梦都没做,中途也没醒,一觉到天亮。


更离谱的是,早上我摸了一下脖子,竟然真的小了点,这才短短一天,说了都没人信。


我头一次相信,心情真的会影响身体,你心情好了,身体的器官和血液仿佛都欢腾了起来。


如何保持一个好心情,原来这般重要,我拿自己的身体给大家做实验了,有用!



希望大家每天在自律的基础上保持好心情,不负年华,不负自己。



总结


xdm,好好活着,快乐活着。


作者:程序员济癫
来源:juejin.cn/post/7300564263344128051
收起阅读 »

纯前端也可以访问文件系统!

web
前言 周末逛github的时候,发现我们只需要在github域名上加上1s他就能够打开一个vscode窗口来阅读代码,比起在github仓库中查看更加方便 然后我就想网页端vscode能不能打开我本地的项目呢,带着这个疑惑我打开了网页版vscode,它居然真...
继续阅读 »

前言


周末逛github的时候,发现我们只需要在github域名上加上1s他就能够打开一个vscode窗口来阅读代码,比起在github仓库中查看更加方便


vs0.png


然后我就想网页端vscode能不能打开我本地的项目呢,带着这个疑惑我打开了网页版vscode,它居然真的可以打开我本地的项目代码!


vs1.png


难道又出了新的API让前端的能力更进一步了?打开MDN查了一下相关文档,发现了几个新的API


showOpenFilePicker



用来选择文件



vs-f1.png


语法


showOpenFilePicker()

参数



  • options:(可选)包含以下属性

    • multiple:布尔值,默认为false。为true表示允许用户选择多个文件

    • excludeAcceptAllOption:布尔值,默认为false。默认情况下,文件选择器带有一个允许用户选择所有类型文件的过滤选项(展开于文件类型选项中)。设置此选项为 true 以使该过滤选项不可用。

    • types:表示允许选择的文件类型的数组




返回值


返回一个promise对象,会兑现一个包含 FileSystemFileHandle 对象的 Array 数组。


体验


<template>
<div class="open_file" @click="openFile">打开文件</div>
</template>

<script setup lang="ts">
const openFile = async () => {
const res = await window.showOpenFilePicker();
console.log(res);
};
</script>

默认只能打开一个文件,可以传入multiple:true打开多个文件


vs3.png


showDirectoryPicker



用来选择目录



vs2.png


语法


属于浏览器全局方法,直接调用即可


showDirectoryPicker()

参数



  • options:(可选)包含以下属性

    • multiple:布尔值,默认为false。为true表示允许用户选择多个文件

    • excludeAcceptAllOption:布尔值,默认为false。默认情况下,文件选择器带有一个允许用户选择所有类型文件的过滤选项(展开于文件类型选项中)。设置此选项为 true 以使该过滤选项不可用。

    • types:表示允许选择的文件类型的数组




返回值


返回一个promise对象,会兑现一个包含 FileSystemFileHandle 对象的 Array 数组。


体验


<template>
<div class="open_file" @click="openFile">打开文件</div>
<div class="open_file" @click="openDir">打开文件夹</div>
</template>

<script setup lang="ts">
const openFile = async () => {
const res = await window.showOpenFilePicker({
// multiple: true,
});
console.log(res.length);
};

const openDir = async () => {
const res = await window.showDirectoryPicker();
console.log(res);
};
</script>

vs4.png


扩展


FileSystemFileHandle


FileSystemFileHandle提供了一些方法可以用来获取和操作文件



  • getFile:返回一个Promise对象,用于获取文件;

  • createSyncAccessHandle:返回一个FileSystemSyncAccessHandle对象,用于同步访问文件;

  • createWritable:返回一个Promise对象,用于创建一个可写流,用于写入文件;


FileSystemDirectoryHandle


FileSystemDirectoryHandle对象是一个代表文件系统中的目录的对象,它同样提供了方法来获取和操作目录



  • entries:返回一个AsyncIterable对象,用于获取目录中的所有文件和目录;

  • keys:返回一个AsyncIterable对象,用于获取目录中的所有文件和目录的名称;

  • values:返回一个AsyncIterable对象,用于获取目录中的所有文件和目录的FileSystemHandle对象;

  • getFileHandle:返回一个Promise对象,用于获取目录中的文件;

  • getDirectoryHandle:返回一个Promise对象,用于获取目录中的目录;

  • removeEntry:返回一个Promise对象,用于删除目录中的文件或目录;

  • resolve:返回一个Promise对象,用于获取目录中的文件或目录;


entrieskeysvalues这三个方法都是用来获取目录中的所有文件和目录的,它们返回的都是一个AsyncIterable对象,我们可以通过for await...of语法来遍历它。


开发编辑器


了解完这些知识点,我们就可以来开发一个简陋网页版编辑器了,初期只包含打开文件、打开文件夹、查看文件、切换文件


编辑器大概长这样:


vs5.png


打开文件夹


const openDir = async () => {
const res = await window.showDirectoryPicker({});
const detalAction = async (obj: any) => {
if (obj.entries) {
const dirs = obj.entries();
for await (const entry of dirs) {
if (entry[1].entries) {
// 文件夹,递归处理
detalAction(entry[1]);
} else {
// 文件
fileList.value.push({
name: entry[0],
path: obj.name,
fileHandle: entry[1],
});
}
}
}
};
await detalAction(res);
showCode(fileList.value[0], 0);
console.log("--fileList--", fileList);
};

这里主要是递归处理文件夹,返回一个文件列表


读取文件内容


const showCode = async (item: any, index: number) => {
const file = await item.fileHandle.getFile();
const text = await file.text();
codeText.value = text;
currentIndex.value = index;
};

展示文件内容


使用highlight.js来高亮展示代码


<div class="show_code">
<pre v-highlight>
<code class="lang-dart">
{{ codeText }}
</code>
</pre>
</div>

最终效果如下:


vs6.gif


想不到吧,这种功能现在纯前端就能够实现了,当然还可以做的更复杂一点,包括修改保存等功能,保存可以使用showSaveFilePickerAPI,它可以写入文件,同样是返回一个promise。感兴趣的可以试着完善编辑器的功能。


作者:前端南玖
来源:juejin.cn/post/7277045020423045176
收起阅读 »

读《代码整洁之道》有感

最近读了一本书,名字大家都看到了:《代码整洁之道》,之前一直只是听说过这本书的大名,却一直没有进行拜读,最近想起来了就想着看一看,不看不要紧,看了之后就像吃了炫迈,根本停不下来。。。虽然这本书已经出版了十几年的时间,但里面的理论到现在为止也不过时。 有人也许...
继续阅读 »

image.png
最近读了一本书,名字大家都看到了:《代码整洁之道》,之前一直只是听说过这本书的大名,却一直没有进行拜读,最近想起来了就想着看一看,不看不要紧,看了之后就像吃了炫迈,根本停不下来。。。虽然这本书已经出版了十几年的时间,但里面的理论到现在为止也不过时。



有人也许会以为,关于代码的书有点儿落后于时代--代码不再是问题;我们应当关注模型和需求。确实,有人说过我们正在临近代码的终结点。很快,代码就会自动产生出来,不需要再人工编写。程序员完全没用了,因为商务人士可以从规约直接生成程序。扯淡!我们永远抛不掉代码,因为代码呈现了需求的细节。在某些层面上,这些细节无法被忽略或抽象,必须明确之。将需求明确到机器可以执行的细节程度,就是编程要做的事。而这种规约正是代码。



看这本书的那种感觉很奇妙,有时感觉作者说地真对!有时感觉作者骂地真对!有时感觉作者讽刺地真对!还有时看到作者列出的真实代码中的错误示例,再看到作者写出的优化后的代码,内心不禁在想:太妙了,代码本应这样啊!


没错,代码本应该是整洁的,也本应该是好理解、易扩展的!我们常说的设计模式也并不是一种炫技,而是几十年来的老前辈们总结出来的经验,是为了让你的代码更好维护的,是一种理所应当。


在工作中遇到烂代码的可能性是 100%,即使是很厉害的大佬写的代码,在不知情的情况下让你去看,看了一会后都会得出以下结论:“写的啥玩意啊,看都看不懂,乱七八糟的语法糖,考虑过后面工作的人么?什么设计模式,什么各种模块,直接写一块不好么?” 假设的可能有点夸张,但也都是人之常情。有时工作中遇到的烂代码是假的,可能是由于当前自己的技术水平不够,不理解;当然还有一部分可能真的是烂,但是这种情况下还是要做出一些改变!


现在让大家看几个月之前自己写的代码可能都会觉得写的一团糟,用当前的眼光来看可能会有更好的方式或方法来实现,如果你有这种想法的话,请付诸实践!不要等,哪怕是一个单词的拼写错误、一段本不应该写两遍的逻辑、一段没有进行格式化的代码。。。。亦或者是比较大规模的代码改动,改完之后可扩展性会更强,维护起来会更加容易。千万不要等,不要忍受当前的烂代码,代码本就是一直在重构的一个过程,没有哪段代码从出来就不改。下面这段话是书里的内容:



我们都曾经瞟一眼自己亲手造成的混乱,决定弃之而不顾,走向新一天。我们都曾经看到自己的烂程序居然能运行,然后断言能运行的烂程序总比什么都没有强。我们都曾经说过有朝一日再回头清理。当然,在那些日子里,我们都没听过勒布朗(LeBlanc)法则:稍后等于永不(Later equals never).



当然很多人会说:“项目中的屎山代码,我能在上面雕花已经很厉害了,还要干什么,即使我知道那块写的不好,但我也不会去动,因为现在它处于一个稳定的状态,如果我去修改了之后,出了问题全是自己背,吃力不讨好!”这也确实是很多人的现状,考虑的也不无道理,但在这里咱们单纯从代码的角度来看,从写代码的初心来看,早早的就背道而驰了。



代码格式不可忽略,必须严肃对待。代码格式关平沟通而沟通是专业开发者的头等大事。 或许你认为“让代码能工作”才是专业开发者的头等大事。然而,我希望本书能让你抛掉那种想法。你今天编写的功能,极有可能在下一版本中被修改,但代码的可读性却会对以后可能发生的修改行为产生深远影响。原始代码修改之后很久,其代码风格和可读性仍会影响到可维护性和扩展性。即便代码已不复存在,你的风格和律条仍存活下来。



我有代码洁癖,看不了没有格式的代码,看着有的项目中一个函数几百行甚至更多,里面各种重复逻辑,if/else不知道嵌套了多少层,表面看是逻辑复杂,再转念一想,为什么不用工厂、或者写一些别的类来简化下逻辑。这时肯定有人会站起来反对:“明明很简单的逻辑,非得使用什么设计模式,搞得一团乱还看不懂。。” 如果只是一个if/else,或者逻辑比较简单肯定没必要,但是逻辑复杂的情况下光if/else也足够将人搞晕,且当需求改变时代码变得难以维护。


我之前一直觉得写完代码格式化是正常的,是基本操作,但是工作中发现好像不是一个基本操作,格式化并不涉及到专业能力,而是态度,连格式化都懒得做,你说你写出的代码经过了严格的测试。。。。想起之前上学时老师经常说的一句话:作业会不会是能力问题,而做不做就是态度问题了。


说这些并没有什么恶意,仅是这本书的读后感,读完后就好像和作者已经是相识多年的老友,相视一笑。


作者:Zhujiang
来源:juejin.cn/post/7341019035186298915
收起阅读 »

如果让你实现实时消息推送你会用什么技术?轮询、websocket还是sse

web
前言 在日常的开发过程中,我们经常能碰见到需要主动推送消息给客户端数据的业务场景,比如数据大屏幕实时数据,聊天消息推送等等。 本文介绍sse: 服务端向客户端推送数据的方式有哪几种呢? WebSocket SSE 长轮询 轮询简介 长轮询是一种模拟实时通...
继续阅读 »

前言


在日常的开发过程中,我们经常能碰见到需要主动推送消息给客户端数据的业务场景,比如数据大屏幕实时数据,聊天消息推送等等。
本文介绍sse:


image.png
服务端向客户端推送数据的方式有哪几种呢?



  • WebSocket

  • SSE

  • 长轮询


轮询简介


长轮询是一种模拟实时通信的技术。在传统的Http请求中,客户端向服务端发送请求,并且在完成请求后立即响应,然后连接关闭。这意味着客户端需要不停的发送请求来更新数据。


相比之下,长轮询的思想是客户端发送一个Http到服务端,服务端不立即返回响应。相反,服务端会保持该请求打开,直到有新的数据可用或超时。如果有新的数据可用,服务端会立即返回响应,并关闭连接。此时,客户端会重新发起一个新的请求,继续等待新的数据。


使用长轮询的优势在于,它在大部分的浏览器中有更好的兼容性,因为它使用的是Http协议。缺点就是较高的延迟性、较大的资源消耗、以及大量并发操作可能导致服务端资源的瓶颈和一些浏览器对并发请求数目进行了限制比如chorme最大并发数目为6,这个限制前提是针对同一个域名下,超过这一限制后续请求就会堵塞。


websocket简介


websocket是一个双向通信的协议,它支持客户端和服务端彼此之间进行通信。功能强大。
缺点就是是一个新的协议,ws/wss,也就是说支持http协议的不一定支持ws协议。相比较websocket结构复杂并且比较重。


SSE简介


sse是一个单向通讯的协议也是一个长链接,它只能支持服务端主动向客户端推送数据,但是无法让客户端向服务端推送消息。


SSE的优点是,它是一个轻量级的协议,相对于websockte来说,他的复杂度就没有那么高,相对于客户端的消耗也比较少。而且_SSE使用的是http协议_(websocket使用的是ws协议),也就是现有的服务端都支持SSE,无需像websocket一样需要服务端提供额外的支持。


websocket和SSE有什么区别?


轮询


对于当前计算机的发展来说,几乎很少出现同时不支持websocket和sse的情况,所以轮询是在极端情况下浏览器实在是不支持websocket和see的下策。


Websocket和SSE


我们一般的服务端和客户端的通讯基本上使用这两个方案。首先声明:这两个方案没有绝对的好坏,只有在不同的业务场景下更好的选择。


SSE的官方对于SSE和Websocket的评价是



  1. WebSocket是全双工通道,可以双向通信,功能更强;SSE是单向通道,只能服务器向浏览器端发送。

  2. WebSocket是一个新的协议,需要服务器端支持;SSE则是部署在HTTP协议之上的,现有的服务器软件都支持。

  3. SSE是一个轻量级协议,相对简单;WebSocket是一种较重的协议,相对复杂。

  4. SSE默认支持断线重连,WebSocket则需要额外部署。

  5. SSE支持自定义发送的数据类型。


Websocket和SSE分别适用于什么业务场景?


对于SSE来说,它的优点就是轻,而且对于服务端的支持度要更好。换言之,可以使用SSE完成的功能需求,没有必要使用更重更复杂的websocket。


比如:数据大屏的实时数据,消息中心的消息推送等一系列只需要服务端单方面推送而不需要客户端同时进行反馈的需求,SSE就是不二之选。


对于Websocket来说,他的优点就是可以同时支持客户端和服务端的双向通讯_。所适用的业务场景:最典型的就是聊天功能。这种服务端需要主动向客户端推送信息,并且客户端也有向服务端推送消息的需求时,Websocket就是更好的选择。


SSE有哪些主要的API?


建立一个SSE链接 :var source = new EventSource(url);


SSE连接状态


source.readyState



  • 0,相当于常量EventSource.CONNECTING,表示连接还未建立,或者连接断线。

  • 1,相当于常量EventSource.OPEN,表示连接已经建立,可以接受数据。

  • 2,相当于常量EventSource.CLOSED,表示连接已断,且不会重连。


SSE相关事件



  • open事件(连接一旦建立,就会触发open事件,可以定义相应的回调函数)

  • message事件(收到数据就会触发message事件)

  • error事件(如果发生通信错误(比如连接中断),就会触发error事件)


数据格式


Content-Type: text/event-stream //文本返回格式  
Cache-Control: no-cache  //不要缓存
Connection: keep-alive //长链接标识

如何实操一个SSE链接?Demo↓


这里Demo前端使用的就是最基本的html静态页面连接,没有使用任何框架。后端选用语言是node,框架是Express。


理论上,把这两段端代码复制过去跑起来就直接可以用了。



  1. 第一步,建立一个index.html文件,然后复制前端代码Demo到index.html文件中,打开文件

  2. 第二步,进入一个新的文件夹,建立一个index.js文件,然后将后端Demo代码复制进去,然后在该文件夹下执行


npm init          //初始化npm         
npm i express     //下载node express框架
node index        //启动服务

上面三行之中,第一行的Content-Type必须指定 MIME 类型为event-steam
每一次发送的信息,由若干个message组成,每个message之间用\n\n分隔。每个message内部由若干行组成,每一行都是如下格式。


[field]: value\n

上面的field可以取四个值。


-   data
- event
- id
- retry

此外,还可以有冒号开头的行,表示注释。通常,服务器每隔一段时间就会向浏览器发送一个注释,保持连接不中断。


: This is a comment

data 字段


数据内容用data字段表示


data:  message\n\n

如果数据很长,可以分成多行,最后一行用\n\n结尾,前面行都用\n结尾。


data: begin message\n
data: continue message\n\n

下面是一个发送 JSON 数据的例子。


data: {\n
data: "foo": "bar",\n
data: "baz", 555\n
data: }\n\n

id 字段


数据标识符用id字段表示,相当于每一条数据的编号。


id: msg1\n
data: message\n\n

浏览器用lastEventId属性读取这个值。一旦连接断线,浏览器会发送一个 HTTP 头,里面包含一个特殊的Last-Event-ID头信息,将这个值发送回来,用来帮助服务器端重建连接。因此,这个头信息可以被视为一种同步机制。


event 字段


event字段表示自定义的事件类型,默认是message事件。浏览器可以用addEventListener()监听该事件。




event: foo\n
data: a foo event\n\n

data: an unnamed event\n\n

event: bar\n
data: a bar event\n\n


retry 字段


服务器可以用retry字段,指定浏览器重新发起连接的时间间隔。




retry: 10000\n


两种情况会导致浏览器重新发起连接:一种是时间间隔到期,二是由于网络错误等原因,导致连接出错。


上面的代码创造了三条信息。第一条的名字是foo,触发浏览器的foo事件;第二条未取名,表示默认类型,触发浏览器的message事件;第三条是bar,触发浏览器的bar事件。


image.png
前端代码


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<ul id="ul"></ul>
</body>
<script>
//生成li元素
function createLi(data) {
let li = document.createElement("li");
li.innerHTML = String(data.message);
return li;
}

//判断当前浏览器是否支持SSE
let source = "";
if (!!window.EventSource) {
source = new EventSource("http://localhost:8088/sse/");
} else {
throw new Error("当前浏览器不支持SSE");
}

//对于建立链接的监听
source.onopen = function (event) {
console.log(source.readyState);
console.log("长连接打开");
};

//对服务端消息的监听
source.onmessage = function (event) {
console.log(JSON.parse(event.data));
console.log("收到长连接信息");
let li = createLi(JSON.parse(event.data));
document.getElementById("ul").appendChild(li);
};

//对断开链接的监听
source.onerror = function (event) {
console.log(source.readyState);
console.log("长连接中断");
};
</script>
</html>



后端代码


const express = require("express"); //引用框架
const app = express(); //创建服务
const port = 8088; //项目启动端口

//设置跨域访问
app.all("*", function (req, res, next) {
//设置允许跨域的域名,*代表允许任意域名跨域
res.header("Access-Control-Allow-Origin", "*");
//允许的header类型
res.header(
"Access-Control-Allow-Headers",
"Content-Type, Authorization, X-Requested-With"
);
//跨域允许的请求方式
res.header("Access-Control-Allow-Methods", "PUT,POST,GET,DELETE,OPTIONS");
// 可以带cookies
res.header("Access-Control-Allow-Credentials", true);
if (req.method == "OPTIONS") {
res.sendStatus(200);
} else {
next();
}
});

app.get("/sse", (req, res) => {
res.set({
"Content-Type": "text/event-stream", //设定数据类型
"Cache-Control": "no-cache", // 长链接拒绝缓存
Connection: "keep-alive", //设置长链接
});

console.log("进入到长连接了");
//持续返回数据
setInterval(() => {
console.log("正在持续返回数据中ing");
const data = {
message: `Current time is ${new Date().toLocaleTimeString()}`,
};
res.write(`data: ${JSON.stringify(data)}\n\n`);
}, 1000);
});

//创建项目
app.listen(port, () => {
console.log(`项目启动成功-http://localhost:${port}`);
});


20240229103040.gif


参考文章:http://www.ruanyifeng.com/blog/2017/0…


作者:917号先生
来源:juejin.cn/post/7340621143009067027
收起阅读 »

还在直接用localStorage么?全网最细:本地存储二次封装(含加密、解密、过期处理)

web
背景 很多人在用 localStorage 或 sessionStorage 的时候喜欢直接用,明文存储,直接将信息暴露在;浏览器中,虽然一般场景下都能应付得了且简单粗暴,但特殊需求情况下,比如设置定时功能,就不能实现。就需要对其进行二次封装,为了在使用上增加...
继续阅读 »



背景


很多人在用 localStoragesessionStorage 的时候喜欢直接用,明文存储,直接将信息暴露在;浏览器中,虽然一般场景下都能应付得了且简单粗暴,但特殊需求情况下,比如设置定时功能,就不能实现。就需要对其进行二次封装,为了在使用上增加些安全感,那加密也必然是少不了的了。为方便项目使用,特对常规操作进行封装。


结构设计


在封装一系列操作本地存储的API之前,先准备了一个全局对象,对具体的操作进行判断,如下:


interface globalConfig {
type: 'localStorage' | 'sessionStorage';
prefix: string;
expire: number;
isEncrypt: boolean;
}

const config: globalConfig = {
type: 'localStorage', //存储类型,localStorage | sessionStorage
prefix: 'react-view-ui_0.0.1', //版本号
expire: 24 * 60, //过期时间,默认为一天,单位为分钟
isEncrypt: true, //支持加密、解密数据处理
};


  1. type 表示存储类型,为 localStoragesessionStorage

  2. prefix 表示视图唯一标识,如果配置可在浏览器视图中放在前缀显示;

  3. expire 表示过期时间,默认为一天,单位为分钟;

  4. isEncrypt 表示支持加密、解密数据处理;


加密准备工作


这里是用到了 crypto-js 来处理加密和解密,可先下载包并导入。


npm i --save-dev crypto-js

import CryptoJS from 'crypto-js';

crypto-js 设置密钥和密钥偏移量,可以采用将一个私钥经 MD5 加密生成16位密钥获得。


const SECRET_KEY = CryptoJS.enc.Utf8.parse('3333e6e143439161'); //十六位十六进制数作为密钥
const SECRET_IV = CryptoJS.enc.Utf8.parse('e3bbe7e3ba84431a'); //十六位十六进制数作为密钥偏移量

加密


const encrypt = (data: object | string): string => {
//加密
if (typeof data === 'object') {
try {
data = JSON.stringify(data);
} catch (e) {
throw new Error('encrypt error' + e);
}
}
const dataHex = CryptoJS.enc.Utf8.parse(data);
const encrypted = CryptoJS.AES.encrypt(dataHex, SECRET_KEY, {
iv: SECRET_IV,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7,
});
return encrypted.ciphertext.toString();
};

解密


const decrypt = (data: string) => {
//解密
const encryptedHexStr = CryptoJS.enc.Hex.parse(data);
const str = CryptoJS.enc.Base64.stringify(encryptedHexStr);
const decrypt = CryptoJS.AES.decrypt(str, SECRET_KEY, {
iv: SECRET_IV,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7,
});
const decryptedStr = decrypt.toString(CryptoJS.enc.Utf8);
return decryptedStr.toString();
};

这两个API都是将获取到的本地存储的value作为参数进行传递,这样就实现了加密和解密。


在传入数据进行处理、改变的时候需要进行解密;
在数据需要传出时需要进行加密。


核心API实现


setStorage 设置值


Storage 本身是不支持过期时间设置的,要支持设置过期时间,可以效仿 Cookie 的做法,setStorage(key, value, expire) 方法,接收三个参数,第三个参数就是设置过期时间的,用相对时间,单位分钟,要对所传参数进行类型检查。可以设置统一的过期时间,也可以对单个值得过期时间进行单独配置。


const setStorage = (key: string, value: any, expire: number = 24 * 60): boolean => {
//设定值
if (value === '' || value === null || value === undefined) {
//空值重置
value = null;
}
if (isNaN(expire) || expire < 0) {
//过期时间值合理性判断
throw new Error('Expire must be a number');
}
const data = {
value, //存储值
time: Date.now(), //存储日期
expire: Date.now() + 1000 * 60 * expire, //过期时间
};
//是否需要加密,判断装载加密数据或原数据
window[config.type].setItem(
autoAddPreFix(key),
config.isEncrypt ? encrypt(JSON.stringify(data)) : JSON.stringify(data),
);
return true;
};

getStorageFromKey 根据key获取value


首先要对 key 是否存在进行判断,防止获取不存在的值而报错。对获取方法进一步扩展,只要在有效期内就可以获取 Storage 值,如果过期则直接删除该值,并返回 null。


const getStorageFromKey = (key: string) => {
//获取指定值
if (config.prefix) {
key = autoAddPreFix(key);
}
if (!window[config.type].getItem(key)) {
//不存在判断
return null;
}
const storageVal = config.isEncrypt
? JSON.parse(decrypt(window[config.type].getItem(key) as string))
: JSON.parse(window[config.type].getItem(key) as string);
const now = Date.now();
if (now >= storageVal.expire) {
//过期销毁
removeStorageFromKey(key);
return null;
//不过期回值
} else {
return storageVal.value;
}
};

getAllStorage 获取所有存储值


const getAllStorage = () => {
//获取所有值
const storageList: any = {};
const keys = Object.keys(window[config.type]);
keys.forEach((key) => {
const value = getStorageFromKey(key);
if (value !== null) {
//如果值没有过期,加入到列表中
storageList[key] = value;
}
});
return storageList;
};

getStorageLength 获取存储值数量


const getStorageLength = () => {
//获取值列表长度
return window[config.type].length;
};

removeStorageFromKey 根据key删除存储值


const removeStorageFromKey = (key: string) => {
//删除值
if (config.prefix) {
key = autoAddPreFix(key);
}
window[config.type].removeItem(key);
};

clearStorage 清空存储列表


const clearStorage = () => {
window[config.type].clear();
};

autoAddPreFix 基于全局配置的prefix参数添加前缀


const autoAddPreFix = (key: string) => {
//添加前缀,保持浏览器Application视图唯一性
const prefix = config.prefix || '';
return `${prefix}_${key}`;
};

这是一个不导出的函数,作为整体封装的内部工具函数,在setStorage、getStorageFromKey、removeStorageFromKey会使用到。


导出函数列表


提供了6个函数的处理能力,足够应对实际业务的大部分操作。


export {
setStorage,
getStorageFromKey,
getAllStorage,
getStorageLength,
removeStorageFromKey,
clearStorage,
};

使用


在实际业务中使用,则将函数导入即可,这里先看下笔者的文件目录吧:
在这里插入图片描述
实际使用:


import {
setStorage,
getStorageFromKey,
getAllStorage,
getStorageLength,
removeStorageFromKey,
clearStorage
} from '../../_util/storage/config'

setStorage('name', 'fx', 1)
setStorage('age', { now: 18 }, 100000)
setStorage('history', [1, 2, 3], 100000)
console.log(getStorageFromKey('name'))
removeStorageFromKey('name')
console.log(getStorageFromKey('name'))
console.log(getStorageLength());
console.log(getAllStorage());
clearStorage();

接下来看一下浏览器视图:


在这里插入图片描述


可以看到,key经过处理加入了config.prefix的前缀,有了唯一性。
value经过了加密处理。


再看一下通过get方式获取到的控制台值输出:


在这里插入图片描述


很完美,实际业务会把前缀清除返回进行处理,视图中有前缀绑定以及加密处理,保证了本地存储的安全性。


完整代码


config.ts:


import { encrypt, decrypt } from './encry';
import { globalConfig } from './interface';

const config: globalConfig = {
type: 'localStorage', //存储类型,localStorage | sessionStorage
prefix: 'react-view-ui_0.0.1', //版本号
expire: 24 * 60, //过期时间,默认为一天,单位为分钟
isEncrypt: true, //支持加密、解密数据处理
};

const setStorage = (key: string, value: any, expire: number = 24 * 60): boolean => {
//设定值
if (value === '' || value === null || value === undefined) {
//空值重置
value = null;
}
if (isNaN(expire) || expire < 0) {
//过期时间值合理性判断
throw new Error('Expire must be a number');
}
const data = {
value, //存储值
time: Date.now(), //存储日期
expire: Date.now() + 1000 * 60 * expire, //过期时间
};
//是否需要加密,判断装载加密数据或原数据
window[config.type].setItem(
autoAddPreFix(key),
config.isEncrypt ? encrypt(JSON.stringify(data)) : JSON.stringify(data),
);
return true;
};

const getStorageFromKey = (key: string) => {
//获取指定值
if (config.prefix) {
key = autoAddPreFix(key);
}
if (!window[config.type].getItem(key)) {
//不存在判断
return null;
}

const storageVal = config.isEncrypt
? JSON.parse(decrypt(window[config.type].getItem(key) as string))
: JSON.parse(window[config.type].getItem(key) as string);
const now = Date.now();
if (now >= storageVal.expire) {
//过期销毁
removeStorageFromKey(key);
return null;
//不过期回值
} else {
return storageVal.value;
}
};
const getAllStorage = () => {
//获取所有值
const storageList: any = {};
const keys = Object.keys(window[config.type]);
keys.forEach((key) => {
const value = getStorageFromKey(autoRemovePreFix(key));
if (value !== null) {
//如果值没有过期,加入到列表中
storageList[autoRemovePreFix(key)] = value;
}
});
return storageList;
};
const getStorageLength = () => {
//获取值列表长度
return window[config.type].length;
};
const removeStorageFromKey = (key: string) => {
//删除值
if (config.prefix) {
key = autoAddPreFix(key);
}
window[config.type].removeItem(key);
};
const clearStorage = () => {
window[config.type].clear();
};
const autoAddPreFix = (key: string) => {
//添加前缀,保持唯一性
const prefix = config.prefix || '';
return `${prefix}_${key}`;
};
const autoRemovePreFix = (key: string) => {
//删除前缀,进行增删改查
const lineIndex = config.prefix.length + 1;
return key.substr(lineIndex);
};

export {
setStorage,
getStorageFromKey,
getAllStorage,
getStorageLength,
removeStorageFromKey,
clearStorage,
};


encry.ts:


import CryptoJS from 'crypto-js';

const SECRET_KEY = CryptoJS.enc.Utf8.parse('3333e6e143439161'); //十六位十六进制数作为密钥
const SECRET_IV = CryptoJS.enc.Utf8.parse('e3bbe7e3ba84431a'); //十六位十六进制数作为密钥偏移量

const encrypt = (data: object | string): string => {
//加密
if (typeof data === 'object') {
try {
data = JSON.stringify(data);
} catch (e) {
throw new Error('encrypt error' + e);
}
}
const dataHex = CryptoJS.enc.Utf8.parse(data);
const encrypted = CryptoJS.AES.encrypt(dataHex, SECRET_KEY, {
iv: SECRET_IV,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7,
});
return encrypted.ciphertext.toString();
};

const decrypt = (data: string) => {
//解密
const encryptedHexStr = CryptoJS.enc.Hex.parse(data);
const str = CryptoJS.enc.Base64.stringify(encryptedHexStr);
const decrypt = CryptoJS.AES.decrypt(str, SECRET_KEY, {
iv: SECRET_IV,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7,
});
const decryptedStr = decrypt.toString(CryptoJS.enc.Utf8);
return decryptedStr.toString();
};

export { encrypt, decrypt };


interface.ts:


interface globalConfig {
type: 'localStorage' | 'sessionStorage';
prefix: string;
expire: number;
isEncrypt: boolean;
}

export type { globalConfig };


总结


前端开发中直接使用明文存储在本地是比较常见的一件事情同时也是不安全的一件事,对本地存储进行二次封装可以提高安全性,并且有了API的支持,可以在本地存储操作时更加简单。


作者:sorryhc
来源:juejin.cn/post/7237441562664681529
收起阅读 »

如何成为领导眼中不可或缺的人

这篇文章主要还是阅读所感,总结出来共勉,不喜勿喷,也欢迎有不同意见的指教。 首先,主要是以问题起步,如何成为一个优秀的架构师? 这个问题主要分成两种情况,其一就是所谓的面霸架构师,其二则是领导眼中不可或缺的人。 第一种,只要经历过项目的架构,并且理解项目背后...
继续阅读 »

这篇文章主要还是阅读所感,总结出来共勉,不喜勿喷,也欢迎有不同意见的指教。



首先,主要是以问题起步,如何成为一个优秀的架构师?


这个问题主要分成两种情况,其一就是所谓的面霸架构师,其二则是领导眼中不可或缺的人。


第一种,只要经历过项目的架构,并且理解项目背后所要解决的场景问题,以及把里面所用到的技术背后的原理搞清楚,那么你就是一个面霸架构师,可是面霸架构师并不一定就是领导眼中不可或缺的人。那么问题就来到了,如何成为一个领导眼中不可或缺的人。


以下以一个大佬的真实经历来解答这个问题。



ps: 下文中的我指 xx 大佬。



无关职责,帮助领导解决技术难题


第一个经历,我工作第三年的时候,认识了一个老板。某个周六,他打电话给我,说他们的系统碰到了一个问题,做了某一个操作之后,整个页面就会冻结,怎么点都没有用,他们的技术人员没有头绪,客户也一直在催,让我赶紧帮忙看看。


我打开看了一下,的确是做了操作之后,整个界面都无法点击或者输入信息了,然后我重现了很多次问题,发现整个界面冻结的时候,好像颜色有点不一样,会不会是一个透明的浮层置顶了?最终确认,确实是 bug 造成浮层没有退出。


那么为什么他们的技术人员都没有头绪?因为他们主要是后端开发,js 工作经验比较少,而我的经历也属于偏向后端,可是我能跟领导说,这个问题不属于我的专业范围,因为我是做后端的吗?


领导并不会在乎你的职责是什么,领导喜欢的是可以帮他解决技术难题的人。



总结: 要有能够帮助领导解决技术难题的能力(无关职责)。



理解领导的非技术难题


第二个经历,这是我在外企当中碰到的一个问题。


有一天,公司的领导过来问我,"你有没有觉得我们的开发速度很慢?是不是我们的技术不行?"


"您能跟我详细说说,是哪些地方慢?"


"产品部的人跟我说,他们现在提一个需求,经常要好几个月才能上线,有时候一个简单改文字的需求都是这样。"


这个问题确实不好解释清楚,因为这次沟通一开始就不在一个维度上。开发人员认为,说开发速度慢,应该是指开始开发到最终上线的时间久。可是领导认为,一个需求从提出到最终上线的时间久,就是开发速度慢。


那么开发速度慢算是一个技术问题吗?可以算,也可以不算。那么最终如何解决?开发团队最终一起讨论列出了所有影响开发效率的问题,然后能用技术解决就用技术解决。


所以有时候领导跟你谈的问题并不是单纯的技术问题,你需要把领导的问题转化成技术可以解决的问题。



总结: 理解领导的非技术难题,并最终转化成技术可以解决的问题。



搞清楚领导对你的期望值


可能有人会想,开发效率低这种事情不应该找架构师,这是管理的问题。下面接着说第三个经历。xx 公司系统用了 4 年,架构相对比较老旧,然后有一个新的项目,需求比较多,一位架构师提议,趁着这次的需求把架构更新一下,


然后就跟领导仔细讨论了架构更新的代价和好处,最终达成了一致的意见。


可是任何一个项目都会有各种各样的变数,比如业务方的临时需求变更,再比如有些系统可以不用迁移,只需要对接一下,也会有有一部分人不熟悉新架构,就需要多花一点时间去学习,最终项目延期了。


某一次会议,领导说架构师不行,这次系统上线以后如果不稳定,就把架构师开了,主要还是因为项目延期了。开发也解释了一下不全是架构师的问题,还有一些需求变更,可是领导认为需求改动不大,不至于延期一两个月。然后开发团队私下商量着保住这个架构师,不能让他一个人担责。


后来一次聚餐,领导解释他的压力也很大,本来跟老板说好可以按时完成,结果拖了这么久。


事后团队回顾了一下,这件事情之所以是架构师担责,其实最重要的一个原因就是老板对架构师的期望值是什么,是开发效率,系统稳定性保障,还是复杂问题的突破?


所以整件事由架构师承担的原因就是,大家对架构师的期望值是不一样的。所以,作为架构师最重要的一点就是要明白公司对你的期望值。



总结: 明白公司或者领导对你的期望值。



最后


当然,以上经历并不能代表所有公司的评判标准,并且架构师的优秀也有很多维度可以讲,但是这三个故事可以给我们一些启发。



  1. 要有能够帮助领导解决技术难题的能力(无关职责)。

  2. 理解领导的非技术难题,并最终转化成技术可以解决的问题。

  3. 明白公司或者领导对你的期望值。


以上的三个要点,个人也比较赞同的。


作者:夕水
来源:juejin.cn/post/7340834858275389492
收起阅读 »

为了不和测试扯皮, 我抄了这个vite插件

web
前言 这算是我的第二个vite插件了,第一个是腾讯云OSS上传,思路以及部份代码借鉴了浏览器API调用工程师的项目,在此基础上完善和增加配置项。 如果文章对你有帮助的话,记得一键三连哟。有问题和疑惑的话也可以在评论区留言。我会第一时间回复大家,如果觉得我的文章...
继续阅读 »

前言


这算是我的第二个vite插件了,第一个是腾讯云OSS上传,思路以及部份代码借鉴了浏览器API调用工程师的项目,在此基础上完善和增加配置项。


如果文章对你有帮助的话,记得一键三连哟。有问题和疑惑的话也可以在评论区留言。我会第一时间回复大家,如果觉得我的文章哪里有知识点错误的话,也恳请能够告知,把错的东西理解成对的,无论在什么行业,都是致命的。


背景


项目环境比较多,经常漏发错发版本,导致测试和开发间的扯皮比较多。于是,这个插件便截天地气运而生🤪🤪🤪。


功能特性


目的: 在控制台显示当前运行代码的构建人、构建时间、分支、最新的COMMIT信息等, 方便确认是否漏发错发版本。

注意: 只在GitLab流水线有用


效果预览


可以在控制台查看代码的部署信息



安装


pnpm i -D vite-plugin-gitlab-flow

或者


yarn add -D vite-plugin-gitlab-flow

或者


npm i -D vite-plugin-gitlab-flow

基本使用


vite.config.js/ts中配置


import vitePluginGitLabFlow from "vite-plugin-gitlab-flow";

plugins: [
vitePluginGitLabFlow({
projectName: '榕树工具',
debug: true,
extra: [
{
keys: 'VITE_APP_TITLE',
label: '项目title'
}
],
styles:{
color: 'red'
}
}),
]

配置项


optionsdescriptiontypedefault
projectName?项目名称,默认取package.json里的name字段。stringpackage.name
debug?debug模式booleanfalse
extra?额外需要显示的字段,需要是env里面有的字段,可开启debug模式查看string[]
styles?自定义样式Style{}

实现原理


import type { Plugin, HtmlTagDescriptor } from 'vite';
import dayjs from 'dayjs';
import fs from 'fs';
import {Properties,PropertiesHyphen} from 'csstype';
interface Style extends Properties, PropertiesHyphen {}
export const defaultStyle:Style = {
color: 'white',
background: 'green',
'font-size': '16px',
border: '1px solid #fff',
'text-shadow': '1px 1px black',
padding: '2px 5px',
}

interface GitLabFlowOptions {
projectName?: string,
debug?:boolean,
extra?:{
label:string
keys:string,
}[],
styles?:Style
}
export default function gitLabFlow(options: GitLabFlowOptions={}): Plugin {
let {debug=false,extra=[],styles=defaultStyle}=options
let styleOption=''
for (const styleOptionKey in styles) {
styleOption+=`${styleOptionKey}:${styles[styleOptionKey]};`
}
const env = process.env;

const pkg:any = JSON.parse(fs.readFileSync(process.cwd() + '/package.json', 'utf-8'));

let packageInfo: any = JSON.parse(fs.readFileSync(process.cwd() + '/node_modules/vite-plugin-gitlab-flow/package.json', 'utf-8'))

const appInfo = {
projectName: options.projectName || pkg.name,
name:pkg.name,
version:pkg.version,
lastBuildTime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
};

let extStr=`
console.log("%c插件名称:
${packageInfo.name} 当前版本: V${packageInfo.version}","${styleOption}" );
console.log("%c插件作者:
${packageInfo.author} 仓库地址: ${packageInfo.homepage}","${styleOption}");
console.log("%c项目名称:
${appInfo.projectName}", "${styleOption}");
console.log("%c打包时间:
${appInfo.lastBuildTime}","${styleOption}");
console.log("%c流水线执行人:
${env.GITLAB_USER_NAME || '-'}", "${styleOption}");
console.log("%c标签:
${env.CI_COMMIT_REF_NAME || '-'}", "${styleOption}");
console.log("%cCOMMIT信息:
${env.CI_COMMIT_TITLE || '-'} ${env.CI_COMMIT_SHA || '-'}", "${styleOption}");
`


// 新增自定义字段
extra.forEach(({label,keys})=>{
extStr+=`console.log("%c${label}: ${env?.[keys] || '-'}","${styleOption}");`
})

// debugger模式
if (debug){
extStr+=`console.log('appInfo', ${JSON.stringify(appInfo)});`
extStr+=`console.log('packageInfo', ${JSON.stringify(packageInfo)});`
extStr+=`console.log('env', ${JSON.stringify(env)});`
}

return {
name: 'vite-plugin-gitlab-flow',
apply: 'build',
transformIndexHtml(html): HtmlTagDescriptor[] {
return [
{
tag: 'script',
attrs: { defer: true },
children: extStr,
injectTo: 'body'
},
]
}
};
}

引用



作者:小猪努力学前端
来源:juejin.cn/post/7211428447921422396
收起阅读 »

勇敢的人先拿到结果

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

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


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


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


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


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


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


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


勇于放弃


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


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


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


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


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


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


这也是很多人的通病。


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


敢想敢干


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


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


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


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


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


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


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

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

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

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



场景需求


image.png

总结:




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

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



前言


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


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


image.png

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


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


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


正文思路


基本demo


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


需要该需求的页面


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

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

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

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

</script>


放入 public 文件下 worker.js


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

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


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



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



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


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


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


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


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


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


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


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


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

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


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


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

let sum = 0;
let msg;

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

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


image.png


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

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

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

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

</script>


image.png

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


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


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

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

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

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

</script>


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


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

let sum = 0;
let msg;

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

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


image.png

定时重置



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



开启定时


myWorker.postMessage('start');

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


停止定时


myWorker.postMessage('end');

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


重置定时


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

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


最后


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

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


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

优化代码


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

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

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

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

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

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

</script>


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

let sum = 0;
let msg;

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

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


4. 切换浏览器标签页


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


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

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


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


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


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

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

最终代码


替换成20分钟了


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

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

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

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

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

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

</script>


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

let sum = 0;
let msg;

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

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

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

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

背景


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


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


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


场景



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

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

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

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

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


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


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


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


ip2region


Ip2region 是什么


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


Ip2region 特性


1、IP 数据管理框架


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


2、数据去重和压缩


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


3、极速查询响应


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



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

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


Ip2region 支持那些语言


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



Ip2region怎么用


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


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


下载ip2region.xdb包


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



package 获取


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

完全基于文件的查询


package main

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

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

查询结果


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



缓存整个 xdb 数据


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


package main

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

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

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

查询结果:



方案比对



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

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


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


生产如何使用


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



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

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


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


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


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

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

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

背景


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


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


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


初期的技术选型


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


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



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

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

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


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


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


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



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

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

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


为什么选择egg.js做后端


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



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

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


中间的各种折腾


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



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

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

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


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


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


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


后期技术方案的调整



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

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

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

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


人员招聘,团队的管理


人员招聘


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



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

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


团队管理


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



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

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

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

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

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



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

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

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


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



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

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



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

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

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

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

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

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

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

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


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


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


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


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


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


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


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


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


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


95‘ 后教师彭老师


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


图片


▲图/ 学生:???


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


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


图片


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


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


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


图片


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


图片


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


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


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


图片


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


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


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


???


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


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


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


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


图片


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


图片


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


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


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


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


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


阿姨,你来真的啊?


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


图片


▲图/帮人带孩子真好玩


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


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


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


图片


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


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


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


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


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


阿姨,你这是来真的啊?


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


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


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


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


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


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


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


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


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


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


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


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


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


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

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

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

简介


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


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


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


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


如何设计表单操作界面?


模块划分


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



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



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

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

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


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




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


步骤


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



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

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

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

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

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

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

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


实战规划


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


布局规划


先来看下这张规划图:



共分四个模块:



  1. 表单元素;

  2. 元素布局;

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

  4. 预览。



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


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


流程模版开发规划-扩展


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


如下例子:


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



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

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

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

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

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

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


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


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

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

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

你好呀,我是歪歪。


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



seata.io/zh-cn/blog/…




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


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


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


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


你懂我意思吧。



先说问题


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


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


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


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


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


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



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


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


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


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


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


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


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


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


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


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


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


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


再看代码


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



seata.io/zh-cn/blog/…




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


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



io.seata.common.util.IdWorker



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



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


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



io.seata.common.util.IdWorker#nextId




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



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



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



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


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


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


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



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



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


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


它能怎么办?


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


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


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


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


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



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



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



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



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


Byte.SIZE = 8。


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


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


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


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



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



最后一行是这样的:



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



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


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


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


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


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



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


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


简单到你无法想象:



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


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



io.seata.common.util.IdWorker#initTimestampAndSequence




主要看第一行:



long timestamp = getNewestTimestamp();



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


twepoch 是什么玩意?



是 2020-05-03 的时间戳。


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


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


他真的,我哭死...


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


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



然后再转化为时间:



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


算起来也没几年了。


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


twepoch 就是这么个用途。


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



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



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


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


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


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


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


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



优雅,实在优雅。


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



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


看一下 nextId 方法:




io.seata.common.util.IdWorker#nextId





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


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


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


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


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


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


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


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


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


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


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


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


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


4096/ms,约 400w/s。


你啥场景啊,怎么牛偪?


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


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


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


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


理论上确实有可能。


但是实际上...


看看官方的回复:



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


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



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



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



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


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


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


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



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


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



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


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


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



分析一波


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



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


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


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



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



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


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



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



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


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


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


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



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


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


分裂呗:



又来了 A-seq3 怎么办?


问题不大,还放的下:



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


只有继续分裂了:



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


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


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



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


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


那么最多有多少个节点?



2 的 10 次方,1024 个节点。


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



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



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


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


再或者,我给你画个图:



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


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



页分裂


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


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


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


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



mysql.taobao.org/monthly/202…



还是先搞个图:



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


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


会变成这样:



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


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


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



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


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


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


比如我插入 10 和 11。


插入 10 之后是这样的:



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



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


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


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


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


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


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


比如还是这个图:



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


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


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



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


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



同理插入 9 也是这样的:



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


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


哦豁,你说这怎么办?


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



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


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


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


反正我是学不动了。


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


有分裂,就肯定有合并。


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


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


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



自己看一下吧:



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



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

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

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

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

官网:html2canvas

海报示例:

在这里插入图片描述




介绍


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

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


关于


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


它是如何工作的


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

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




开始


准备工作


安装依赖


npm install html2canvas

在需要的页面引入依赖


import html2canvas from 'html2canvas'

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


定义海报结构


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


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

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

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



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

image.png


script部分


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

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



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



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

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





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

在这里插入图片描述


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




下面就是下载下来的图片

在这里插入图片描述

在这里插入图片描述




注意事项


1、多个script




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


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


2、html2canvas不要用image标签


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


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


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

● background-blend-mode

● border-image

● box-decoration-break

● box-shadow

● filter

● font-variant-ligatures

● mix-blend-mode

● object-fit

● repeating-linear-gradient()

● writing-mode

● zoom

● ......

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




最后


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

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

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




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

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

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

前言


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


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


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


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


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


使用部分


结合GetX, 使用方式如下:


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

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

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

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

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

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


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


代码封装部分


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


import 'package:flutter/material.dart';

class MvcController with ChangeNotifier {
late BuildContext context;

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

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

void onDispose() {}
}

封装的 view 代码如下


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

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

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

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

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

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

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

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

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

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

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


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

结语


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


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


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


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


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


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

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

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

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

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

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

初出茅庐,功败垂成

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

先说为什么不复杂?

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

image.png

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

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

image.png

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

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

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

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

image.png

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

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

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

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

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

千万不要粗心大意

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

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

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

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

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

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

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

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

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

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

为什么没有测试

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

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

image.png

事情办砸了,十分遗憾

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

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

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

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

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

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


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

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

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

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

感谢您的阅读!



介绍


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


HTMX - 返璞归真🚲


htmx-演示


为谁而设:



  • 你希望减少JavaScript的编写量

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


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


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


Wasp - 全栈,开箱即用🚀


开放SaaS


为谁而设:



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

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

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


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


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



  • 全栈身份验证

  • 数据库架构

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

  • 简单且灵活的部署

  • 全栈类型安全

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

  • 等等……


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


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


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


Solid.js - 一流的reactivity库 ↔️


扎实的例子


适合人群:



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

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


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


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


Astro - 静态网站之王👑


天文示例


适合人群:



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

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


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


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


Svelte - 简单而有效🎯


精简演示


适合人群:



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

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


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


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


Qwik - 非常快🚤


qwik演示


适合人群:



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

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


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


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


结论


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


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


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

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

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

准备工作

1、获取环信Appkey

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

2、创建一个新用户

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

3、下载Demo 应用

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


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

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

点击确定

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

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

4、下载Demo源码

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

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

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

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

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

项目跑通!大功告成!

参考文档:

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

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

收起阅读 »

自适应iframe高度

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

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

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

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

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

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


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

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


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

跨域问题

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

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

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

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


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

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

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

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


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

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


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

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

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

收起阅读 »

vue项目部署自动检测更新

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

前言


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


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


原理


自动更新.png


封装函数 auto-update.js


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

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

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

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

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

引入


在main.js中引入


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

使用element-ui的notify提示更新


修改auto-update.js



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

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

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

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

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

编写模板


CnNotify.vue文件


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

使用


App.vue


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

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

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

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

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


业务背景:


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


时间段更新更新任务:


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


单元预算恢复任务:


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


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


数据库:


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


定时任务数据源:


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


机器配置和垃圾回收器:


单台机器用的8C16G


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


定时任务处理逻辑


对于单元,


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


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


伪代码:


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

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


计算逻辑伪代码:


1、当前时间

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


3、start_time<当前时间

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


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


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


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


任务执行现象:


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


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


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


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



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


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


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


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


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


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


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


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


再次执行任务


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


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



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


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


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


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


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


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


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


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


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


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


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


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


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



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


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


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


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


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


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


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


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


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



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


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


(1)游标查询


伪代码如下


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

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

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

} while (CollectionsJ.isNotEmpty(showStatuses));


//下层sql

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

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

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



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


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


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


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


(2)深度分页引起慢sql


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


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


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

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


但是我们的sql


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

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



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


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


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


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


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



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


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


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


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



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


伪代码如下:


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

throw new ApplicationException(ex);
}
}

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


(4)优化效果


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



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


查询db监控:



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



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


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


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


监控如下


每次查询出来的记录数:



每次需要更新的记录数:



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


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


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


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


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


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

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


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



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



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



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



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


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


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


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


负载后的机器cpu是这样的



优化效果数据汇总:


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


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


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


写到最后:


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


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

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

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

接口多参数优雅封装


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


我以前的做法


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


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

);
return res;
}

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


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


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

);

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


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


解决办法


在MDN上看到了这个URLSearchParams接口


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


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


下面是更改后的代码


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


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


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

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

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

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


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


一次偶然的沟通


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


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


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


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


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


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


开始尝试提前还贷,真香


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


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


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


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


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


提前还款,比理财强多了


这次还贷以后,我又申请了提前还款, 提前还 24 期,只需要 9 万,也就是 9 万顶 24 万;提前还 60 期,只需要 24 万,相当于 24 万顶 60 万。


image.png


image.png


还有比提前还贷收益更高,风险更低的理财方式吗?没有! 除了存款外,任何理财都是有风险的。债券和基金收益和风险挂钩,想找到收益5%的债券基金,要承担亏本风险。你惦记人家的利息,人家惦记你的本金!


股票的风险更不必说,我买白酒股票已经被套的死死,只能躺平装死。(劝大家不要入 A 股)


提前还贷划算吗?


我目前的贷款利息是 4.85%,而存到银行的利息不会超过 3% ,很多货币基金只有 2%了。两者利息差高达 3%,肯定是提前还贷款更加合适。


要明白,一年两年短期的利息差还好,但是房贷可是高达 25 年。25年 170 万贷款 3% 的利息差,这个金额太大了。提前还了,省下来的钱还是很多的。例如刚才截图里展示的 提前还 24 万顶了 60 万的房贷。


网上很多砖家说,“要考虑通货膨胀因素,4.85% 的贷款利率和实际通货膨胀比起来不高,提前还款不划算。”


砖家说话都是昧良心的。提前还贷款是否划算,只需要和存款利率比就行了,不需要和通货膨胀比。因为把钱存在银行也会因为通货膨胀贬值。只有把钱 全都消费,全部花光才不会受通货膨胀的困扰,建议砖家,多消费,把家底败光,这样最划算!


砖家们一定是害怕太多人提前还贷,影响了银行的放贷生意。今年上半年,提前还贷已经成潮流,有些银行坐不住,甚至关闭了提前还贷的入口…… 所以要抓紧,没准哪天就提高了还贷门槛,或者直接禁止。


程序员群体收入高,手里闲钱多,可以考虑提前还贷款,比存银行划算多了,别再给银行打工了!


作者:五阳神功
来源:juejin.cn/post/7301530293378727971
收起阅读 »

记一次页面截图需求

web
需求背景 上图是我所负责的监控产品,页面上有大量的图表,用户的述求是能对页面截屏从而直接分享给别人。 那么就有小伙伴要发问了,为什么不直接把页面链接分享给别人呢? 首先,页面可能有权限校验,被分享的人可能没有该页面的访问权限,而图片不会有这个问题;其次,实践...
继续阅读 »

需求背景



上图是我所负责的监控产品,页面上有大量的图表,用户的述求是能对页面截屏从而直接分享给别人。


那么就有小伙伴要发问了,为什么不直接把页面链接分享给别人呢?


首先,页面可能有权限校验,被分享的人可能没有该页面的访问权限,而图片不会有这个问题;其次,实践表明,如果分享的是链接,用户的点击意愿很低,如果不是直接相关的人往往不会点开链接查看,而如果是图片的话,非常直观,往往第一眼就传递了很多信息给被分享的人。


那么又有小伙伴要发问了,既然如此,为何不让用户自己装一个截屏软件自己截算了?


考虑两个点,第一是不一定所有用户都有一个好用的截屏的软件(特别是在Mac上,大伙应该深有体会),并且页面如果需要滚动截屏,用户的操作就会比较麻烦,因此页面上能提供一个一键截屏的按钮就十分便利了;第二是如果由页面提供截图能力,可以很好地定制最终图片上所呈现的页面,比如可以调整一下布局,修改某些元素。


不过需要注意的是,我们要实现的截屏并不是一个真正的截屏,而是相当于dom的快照,针对传入的dom生成图片。


方案调研


那么咱就来研究研究,市面上都有哪些截屏的方案。


后端方案


一种比较常见的方案,是在服务端使用puppeteer(或者playwright啥的)起一个无头浏览器,渲染完页面后截图返回给前端,比如金山文档就是这么做的。


但是吧,这种方案的缺陷很明显。首先毋庸置疑的是,服务端的压力会变大,成本会变高;其次,最终生成的图片往往与用户所看到的页面有些出入,比如金山文档的截屏,如果源文档是些奇奇怪怪的字体,最终生成的图片里的字体就会是默认字体,另外布局什么的也可能会不一致;


源文档:



生成的图片:



那么后端方案优点也就与缺点一一对应,首先是对用户设备的消耗较小,性能较差的设备也能使用;其次是对于同一页面,后端方案生成图片能够完全一致,不会因为用户的机型不同导致页面布局发生变化,而且更重要的一点是,生成图片基本上都依赖于canvas,而canvas这东西有个坑,它对宽、高、面积有一定的限制,并且不同浏览器、不同设备的限制还不太一样,并且同一设备同一浏览器也会因为用户的设备可用资源受到影响,在生成canvas之前也不能拿到这个限制,这个限制在IOS设备上最为严重(有意思的是canvas是苹果提出的标准),参考javascript - Maximum size of a element - Stack Overflow,因此采用后端方案能够保证结果的一致性。



前端方案


有的小伙伴会说了,浏览器自带截屏功能的,直接用多好呀。是的,浏览器有一个截屏功能,但是我们在JS代码里并没法直接调用,并且浏览器自带的截屏,也无法实现上述所说的修改页面元素的能力。


浏览器自带截屏:



那么比较靠谱的前端截屏方案其实就两种,一种自己实现渲染,将dom一一渲染到canvas上后生成图片,比如html2canvas;另一种是借助foreignObject,将svg绘制到canvas上再生成图片,代表作为dom-to-image


html2canvas


html2canvas可以说是最古老的前端截屏实现方案了,也称得上是独一档的实现。它的原理简单来说就是克隆传入的dom,遍历克隆树,通过getBoundingClientRect获取元素的位置、大小,getComputedStyle获取元素样式,然后使用canvas的底层API,一点一点画出来的。


可想而知,这个过程是多么复杂,相当于自己实现了一套渲染引擎,并且css越来越复杂,想要完全绘制到canvas,够呛,所以html2canvas现在有一个很大的缺点就是对css的支持不够好。


另外,由于它自建了一套渲染,需要处理的情况非常多,所以包体积相当大,官网标注的gzip压缩后也有45kB。



除了上述原因外,真正让我放弃这个库的原因是,它太老了,它真的太老了,作为一个十几年前的库,它现在已经年久失修,上次更新都是两年前,而且看着只是文档修改。



并且已经堆积了800+ issue没有处理,基本上是不维护状态了。



更有意思的是,即使这个库已经存在了十几年,并且有大量页面将其应用到了生产环境,其中不乏一些大公司产品,比如腾讯文档(别问我怎么知道的,问就是我写的),但是它的作者仍在Readme里边写到:



dom-to-image


dom-to-image的基本原理十分简单,不需要做什么复杂的渲染,利用到了svg元素的foreignObject:



只需要把dom丢到foreignObject里边,就会在svg里边渲染出来,因为是浏览器的标准,也不用担心对css的支持不够友好:




其实,到这一步,你会发现已经达到将dom转成图片的目的了,svg本来就是图片。但是你可能会需要其他格式的图片,并且这样生成的svg体积实在是大了点,包含了大量冗余的信息。所以这里还是用到canvas,通过drawImage把svg画到canvas上,再通过canvas的toDataUrl生成图片链接。


从体积上看,不到10kB,是完全可以接受的:


看看它的代码仓库,可以看到已经七八年不更新了,并且有200+ issue没有处理,也基本上处于不维护状态了。:




如果能够满足需求,也不是不能用,遗憾的是,不太能满足我的需求。


首先是资源跨域问题,其实资源本身是支持跨域的,但是原始html中的标签没有加上crossorigin属性,导致生成图片时会报跨域错误,像页面里的图片、外链css啥的得做点特殊处理才能用。另外还有些奇奇怪怪的问题,可以看看issue,反正是不太能用。


dom-to-image-more


dom-to-image-more听名字也能听出来是fork的dom-to-image,解决了dom-to-image的部分bug,增加了一些能力。最重要的能力应该是解决了上述提到的跨域问题,它把link标签做了一下拦截,使用fetch去请求对应的src,加上了跨域配置,然后再对返回结果进行处理。另外还有一个有意思的点,在dom-to-image中,获取元素的样式是通过document.getComputedStyle拿到每个dom节点的样式,然后通过行内样式插入到对应的标签上,会导致最后生成的图片上包含了大量的行内样式,体积自然就比较大;而dom-to-image-more做了一个优化,利用沙盒获取到了元素的默认样式,再和getComputedStyle作比较,只插入不同于默认样式的属性,从而极大地减小了图片的体积,自然而然,这个复杂度高了点,生成图片的耗时稍微长点。


体积很理想,不到6kB:



之前看最新的更新在两年前,但是近期好像又有更新,说明还是有人在维护的:



但是最终还是没有用它,因为有个痛点,在我的场景下用了很多icon,而这些icon都是svg格式的,它们通过defs - SVG定义了一次,然后使用时都是通过 - SVG引用的;但是这个库没有处理这种情况,导致生成图片时只复制了use元素,而没有将其对应的defs元素复制过去,从而导致最终生成的图片上丢失了这些icon。


html-to-image


html-to-image也是fork的dom-to-image,修了部分bug,增加了一些能力。这个库相较于dom-to-image,特点是优化了文件结构,增加typescript支持,对比上述的dom-to-image-more,处理好了svg use和svg defs的情况,在有use的情况下会去找到对应的defs元素并添加进来。但是,它没有解决跨域问题。


另外还有个痛点,之前提到的icon,它们的样式吧,上面我们提到了,是通过getComputedStyle获取到,然后插入到行内样式实现的;对于普通的dom元素而言,这样做没有问题,因为这些dom使用的地方就是它们定义的地方;但是对于svg defs和svg use这样的元素而言,在定义时它的样式就已经被行内样式写死了,使用的时候就没办法覆盖定义时的样式,导致我的彩色icon全变成黑色了:


原图:



生成的图片:



看了下源代码,确实没有针对这点进行处理,所以还是放弃了,另外可想而知的是,像webcomponent这样定义和使用分离的情况,估计也存在样式不能覆盖的问题。


modern-screenshot


modern-screenshot也是基于dom-to-image,但它不是直接fork的dom-to-image,而是上面提到的html-to-image,所以相当于是dom-to-image的孙子辈了。


这个库既然是fork的html-to-image,自然也就继承了html-to-image良好的文件结构以及优秀的ts支持;并且这个库有意思的是,它还整合了dom-to-image-more的优化,不会产生跨域的问题了;对于svg use和svg defs,它更进一步,复用已有的defs,减小了生成图片的体积;另外还有个点,它用到了webworker并行地发起网络请求。


东抄抄西补补,modern-screenshot是目前我看到的效果最理想的前端截屏方案,并且这个库的作者仍在维护:


最近的更新发生在三周前,包体积gzip压缩后不到10kB,完全可以接受。


美中不足的是,这个库依然没有解决上述提到的svg use样式不能覆盖问题。其实想想也明白,通过getComputedStyle再写入行内样式的方式,这个问题是避免不了的。不过,考虑到svg defs元素一般都是icon在使用,而这些icon一般来说不会被外界样式所影响,所以针对svg defs和svg use标签,我们不通过getComputedStyle获取其样式,而是直接使用dom.cloneNode获取的样式,这样就不会写死行内样式,从而解决了这个问题。于是给该项目提了一个PR,也顺利合入:



当然这种解法并不严谨,但是绝大部分情况下应该够用,至少在我的场景下已经足够满足需求,因此最终我也是选择了使用modern-screenshot来实现截屏的需求。


modern-screenshot使用


modern-screenshot用起来也很简单,安装完成之后,只需少量代码即可使用:


// html
<div class="container">
<tw-el-tooltip content="生成图片" placement="top" hide-after="0">
<div class="config-button" @click="generateImage">
<el-icon
:config="common_system_picture"
color="#898A8C"
>
</el-icon>
</div>
</tw-el-tooltip>

</div>

// js
import { domToJpeg } from 'modern-screenshot';

const generateImage = async () => {
// 获取要生成图片的dom节点
const node = document.getElementById('service-analyzer-main');
if (!node) {
return;
}
try {
const dataUrl = await domToJpeg(node, {
// 传入配置
scale: 2,
});

// 通过a标签自动下载图片
const a = document.createElement('a');
a.href = dataUrl;
a.download = route.path.split('/').at(-1) + '.jpg';
a.click();
a.remove();
ElMessage.success('图片生成成功,请耐心等待下载');
} catch (error) {
ElMessage.error('图片生成失败');
}
};

原图(使用截屏软件截的长图):



通过modern-screenshot生成的图片:



当然,这是最基本的使用。如果涉及到一些复杂的操作,比如需要在截图时,对某些元素进行修改,比如把图片转成url展示,就需要在截图前遍历dom树进行一些转换再生成图片了。如果这都还不能满足需求,可能就需要专门实现一个预览模式,通过iframe打开预览模式的页面再生成图片了,腾讯文档就是这么做的。


还有些坑


苹果两倍屏


用Mac的小伙伴应该知道,Mac的屏幕是所谓的Retina屏,它的像素密度是普通屏幕的两倍,因此如果直接生成图片的话,在Mac上看起来会是比较模糊的,因此在生成图片时需要将其放大两倍。


截图元素有滚动条导致图片截断


如果截图元素有滚动条,会导致最终生成的图片只包含当前滚动条区域内的内容。如果想要截取完整内容,可以通过将临时截图元素的宽高设置为fit-content,让其展示完整,截图完成后再修改为原始宽高即可。不可避免的,这种方式会对原始元素产生一些影响,滚动条会“跳一下”,不过问题不大,实在介意的话可以加个遮罩层,在生成图片时盖住就好。


缩放后canvas元素模糊


如果在生成图片时设置scale选项,将其放大,可以看到最终生成的图片上canvas元素虽然放大了,但是并不够清晰。这个是没办法避免的,毕竟canvas是像素级别的画布,做不到无损放大,同理可以推断像素图比如jpg、png这些经过放大后也会模糊。


元素内部的滚动元素


另外还有一个坑点,如果截图元素内部的元素有滚动条,生成的图片只能包含可视区域内的部分。其实这是合理的,当然不能全都包含进来,问题是,我有一个页面,截图元素内部存在滚动元素,但是这个滚动元素的默认滚动位置是居中的,而生成图片时这个元素的滚动位置只能是左上角,因此最终生成的图片就没有我想要的内容:


原图:



生成的图片:



而且好像并没有办法指定滚动条的位置。


这让我想到之前看到别人分享的一个有意思的bug,说是他的服务接入了第三方地图服务,但是不管他如何调试,找遍了官方文档,他的页面始终是蓝色的。后续排查了很久,终于发现,原来这个地图默认定位是在经纬度(0, 0),而这里是一片大海。。。



内容过长时生成空白图片


这个其实就是我一开始提到的canvas存在限制的问题:javascript - Maximum size of a element - Stack Overflow


小结


那么事情就是这么个事情啦,主要是记录一下当时我做截图需求过程中调研的一些方案,以及对应的优缺点,并记录了一些坑。其中也包含了一些我在选择三方库时的考量,比如npm下载量、最近更新情况、仓库维护情况、包体积大小、项目功能完善程度、仓库质量、ts支持情况等。


看到这里,如果对你有所帮助的话,可以给我一些鼓励哦。


作者:超级无敌大怪兽
来源:juejin.cn/post/7339671825646338057
收起阅读 »

同学们说我染上面试了

web
目前大三,最近刚分手,想着正好沉下心来去搞就业,寒假期间抱着摸自己底的心态去试试面试,海投了许多厂,小厂,中厂,大厂都有,给我的感觉就是大部分内容都是八股,怎么跟别人的面试不一样,本期就来记录下我面试中遇到的那些面试题,希望对大家春招有所帮助 一、聊聊盒子模型...
继续阅读 »

目前大三,最近刚分手,想着正好沉下心来去搞就业,寒假期间抱着摸自己底的心态去试试面试,海投了许多厂,小厂,中厂,大厂都有,给我的感觉就是大部分内容都是八股,怎么跟别人的面试不一样,本期就来记录下我面试中遇到的那些面试题,希望对大家春招有所帮助


一、聊聊盒子模型


盒模型是css描述布局用的概念,盒子模型有两种,默认情况下为标准盒模型,还有一种为IE或者怪异盒模型,这个是曾经IE使用的盒模型,如今使用需要打上box-sizing: border-box;


对于标准盒模型,一个盒子最终的宽度为width + padding + border + margin。而对于IE盒模型,一个盒子最终的宽度为width + margin,也就是说,IE盒模型的宽度其实就是标准盒模型的width + padding + border,最终自身的真实宽度会被边框和内边距挤掉一部分,当你为了防止盒子被撑大的时候你可以使用IE盒模型


二、vue3的响应式如何实现


vue3的响应式主要是靠es6的代理proxy来实现的,proxy可以拦截对象,进行读值操作,能够捕获到数据的修改


reactive能够将引用类型变成响应式,ref通常用于将原始数据类型变成响应式,但是ref也可以将引用类型变成响应式,另外还有个副作用函数effect,当响应式数据发生变更,副作用函数就会重新执行


三、computed和watch是什么,有什么应用场景


computed是计算属性,依赖响应式数据,只要发生变更就会重新计算,另外computed有缓存机制,多次使用计算属性的逻辑时只会执行一次。所以当数据是根据其他响应式数据计算而来的时候,可使用computed


watch是监听器,监听一个数据的变化,当数据变化时,就会执行一个回调,可以用于处理异步,另外watch在刚进页面的时候就会执行一次


四、说说你对BFC的理解


BFC全称Block Formatting Context也就是块级格式化上下文,是css描述块级盒子的一种方式


BFC可以让浮动元素的高度也计算在内,因此常用于清除浮动,另外BFC还可以阻止子元素的margin-top和父容器重叠。


像是overflow: auto | hidden | scroll 以及左右浮动,还有相对,固定定位display: flex | grid | inline-block | table开头的属性都可以触发BFC


五、浏览器的事件循环


js事件循环机制是浏览器用于处理js代码执行顺序的机制


因为js设计之初仅仅是个脚本语言,所以为了性能,将其定义为单线程,代码一定会有耗时和不耗时代码,也就是异步和同步代码,并且设计师为了更精细地控制异步代码地执行顺序,又将异步分为宏任务,微任务,因此事件循环机制就是描述同步,宏任务和微任务的执行优先顺序


在浏览器的一个事件循环中,先是执行同步,再是执行微任务,最后才是宏任务,之后宏任务又是下一轮事件循环的开启,像是js全局的打印就是一个同步代码,常见的宏任务有三个定时器,和scriptI/O页面渲染。常见的微任务有promise.thenMutationObserverProcess.nextTick


六、浏览器输入url之后发生了什么


先进行url解析,浏览器解析输入的url,提取出协议,主机名,路径等信息


再是dns解析,浏览器将主机名解析成相应的ip地址


然后建立tcp连接,这个过程就是三次握手,确保客户端和服务器之间的连接正常建立


再是发送http请求,浏览器向服务器发送http请求,请求中包括了方法,路径,请求头等信息


然后服务器会处理请求,根据请求的内容进行处理,查询数据库,读取文件


然后服务器会返回响应,将生成的响应数据通过tcp连接发送回浏览器


浏览器接收响应并进行渲染页面,这个过程包括了解析html,css代码生成相应的dom树和cssom树,然后两树结合形成Render Tree,回流,重绘


断开连接,也就是tcp四次挥手


七、flex:1代表什么


flex: 1通常用于做分配父容器的宽度,比如做三栏布局时,两边固定写死宽度,中间给个flex: 1,那么他会继承到中间剩余的所有宽度


不过其实flex是有三个参数的,flex: 1其实是flex: 1 1 auto的缩写,第一个参数是flex-grow也就是放大比例,1代表会放大,也是默认值,第二个参数flex-shrink是收缩,默认值1代表空间不足时等比例收缩,第三个参数flex-basis是定义弹性元素的初始大小


八、讲讲diff算法


diff算法就是用在比较两颗虚拟dom树的差异,最小化地对真实dom进行修改,就是找不同。


这个比较过程先是对两颗dom树进行深度优先遍历,然后同级节点比较,比较相同位置地节点,类型属性是否不同,不同则替换,相同继续下去


两个相同类型的节点有不同的属性diff算法也会更新到真实dom,如果两个节点有不同的子节点,diff算法也会递归找到子节点地差异


当比较列表节点地时候,diff算法会尽可能地复用已有地节点,而不是删除再重新创建。另外当diff算法发现某个节点已经不同于之前的虚拟dom树,它会立即停止对改节点地进一步比较,避免性能浪费


九、js数据类型判断


typeof可以判断除了null之外的原始数据类型,还可以判断函数


instanceof只能判断引用数据类型,它是通过原型链来查找的


Array.isArray只能判断是否为数组


Object.prototype.toString.call可以判断所有的数据类型,返回一个字符串,比较起来会麻烦点


十、手写防抖节流


防抖就是在规定的时间内没有第二次操作,才执行,规定的时间内一直触发,就不会执行。


防抖如下


function debounce(fn, delay){
let timer
return function(){
let args = arguments
if(timer) clearTimeout(timer);
timer = setTimeout(() => {
fn.call(this,...args)
},delay)
}
}

防抖就是接收一个函数和一个时间,然后在函数中创建一个定时器,如果定时器不存在,那么就会创建一个定时器,并在时间过期之后执行传入的函数,如果定时器存在那么久掐灭这个定时器


节流就是规定的时间内只执行一次,假设手速很快,但是触发事件地速度是恒速


节流如下


function throttle(fn, delay){
let prevTime = Date.now();
return function(){
if(Date.now() - prevTime > delay){
fn.apply(this,arguments)
prevTime = Date.now()
}
}
}

节流同样,接受一个函数,一个时间,只要两次时间差大于传入的时间才会执行函数,并且只有满足了这个条件,prevTime才会更新为当前时间


十一、vite为什么比webpack快


vite使用了ES Module作为开发基础,在开发过程中能够以原生的ES Module直接加载和解析模块


vite可以在你修改代码后,只重新加载和应用发生改变的部分,而非整个页面,并且当你启动项目时,vite只会加载你正在编辑得文件,而非整个项目,vite只在需要时加载代码,而不是一次性加载所有的内容,并且vite可以缓存已经构建过的代码,下次启动时直接使用缓存


十二、如何解决闭包导致的内存泄露


可以使用WeakMap或者WeakSet来存储外部变量,因为二者是弱引用,时刻会被垃圾回收机制回收


如果闭包处于事件处理函数中,可以借助事件委托的机制,将事件处理函数放到父元素上,而非每个子元素上,事件委托就是借助了冒泡的机制


十三、聊聊cookie


cookie是个小型的文本文件,由服务器端发送到用户的浏览器,并存储在用户的计算机上。cookie主要是来跟踪用户的会话信息,存储用户的偏好设置


cookie通常用于身份认证来保证网站资源的安全性,而非大量数据的本地存储,当用户访问一个网站时,服务器会创建一个唯一的会话标识符,存在cookie中,这样用户在同一网站就可以保持登录状态,不用重复登录


十四、聊聊vue的生命周期


vue的生命周期有如下几个阶段,每个阶段都有两个钩子,除了最后一个销毁,时间一到自动触发钩子


创建阶段:创建之前,初始化选项式api,创建之后


挂载阶段:挂载之前,初始渲染dom节点,挂载之后


更新阶段:更新前,更新后,这里的更新是根据数据源的变化


销毁阶段:销毁前,销毁后,移除所有的监听器


错误捕获:子组件报错时触发


十五、vue中父子组件如何通讯


父传子,需要父组件v-bind绑定属性用于传值,子组件用props接收


子传父,需要子组件通过$emit发布该事件,且携带参数


最后


不知道为什么,面了这么多,很少碰到让我手写,大部分都是聊八股和项目。


作者:Dolphin_海豚
来源:juejin.cn/post/7340479361219002405
收起阅读 »

js如何控制一次只加载一张图片,加载完成后再加载下一张

web
今天看到一个面试题,是关于img图片加载方面的,有必要记录一下。其实关于这个问题,只要知道图片什么时候加载完成就能解决了。通过onload事件判断Img标签加载完成实现逻辑:新建一个Image对象实例,为实例对象设置src属性等,在onload事件中添加此实例...
继续阅读 »

今天看到一个面试题,是关于img图片加载方面的,有必要记录一下。其实关于这个问题,只要知道图片什么时候加载完成就能解决了。

通过onload事件判断Img标签加载完成

实现逻辑:新建一个Image对象实例,为实例对象设置src属性等,在onload事件中添加此实例对象到父元素中,然后将图片地址数组中的第一个元素剔除,继续调用此方法直到存储图片地址的数组为空。

代码

const imgArrs = [...]; // 图片地址
const content = document.getElementById('content');
const loadImg = () => {
if (!imgArrs.length) return;
const img = new Image(); // 新建一个Image对象
img.src = imgArrs[0];
img.setAttribute('class', 'img-item');
img.onload = () => { // 监听onload事件
// setTimeout(() => { // 使用setTimeout可以更清晰的看清实现效果
content.appendChild(img);
imgArrs.shift();
loadImg();
// }, 1000);
}
img.onerror = () => {
// do something here
}
}
loadImg();


实现效果

lp_img_load.gif

加上setTimeout后,看到的效果更加明显,我这里加了500毫秒的延迟(录屏软件只支持录制8秒的时间...)

setTimeout_load_img.gif

其实我在网上还看到了一种答案,通过onreadystatechange事件实现监听,于是在我本地调试了一下,发现并不能实现,img实例对象上并没有这个属性方法。查了查MDN,发现目前仅有XmlHttpRequest对象和Document对象中存在onreadystatechange属性,而对于其它元素onreadystatechange此属性并不存在。

因此对于其它元素需要慎用onreadystatechange事件

不过我电脑上目前只有ChormeSafari两种浏览器,对于onreadystatechange测试的覆盖面不全,所以我上面的结论可能还需要进一步验证才行,感兴趣的掘友可以调试一下~。

扩展知识

img标签是什么时候发送图片资源请求的?

  1. HTML文档渲染解析,如果解析到img标签的src时,浏览器就会立刻开启一个线程去请求图片资源。
  2. 动态创建img标签,设置src属性时,即使这个img标签没有添加到dom元素中,也会立即发送一个请求。
// 例1:
const img = new Image();
img.src = 'http://xxxx.com/x/y/z/ccc.png';

上面的代码如果运行起来后,就会发送请求。 如图:

image.png

再看一个例子:创建了一个div元素,然后将存放img标签元素的变量添加到div元素内,而div元素此时并不在dom文档中,页面不会展示该div元素,那么浏览器会发送请求吗?

// 例2:
const img = ``;
const dom = document.createElement('div');
dom.innerHtml = img;

答案:会请求。如图:

image.png

通过设置css属性能否做到禁止发送图片请求资源?

  1. img标签设置样式display:none或者visibility: hidden,隐藏img标签,无法做到禁止发送请求。
"http://xxx.com/x/sdf.png" style="display: none;">
或者
"http://xxx.com/x/sdf.png" style="visibility: hidden;">
  1. 将图片设置为元素的背景图片,但此元素不存在,可以做到禁止发送请求。
DOCTYPE html>
<html lang='en'>
<head>
<meta charset='UTF-8'>
<title>title>
<style>
.test {
height: 200px;
background-image: url('http://eb118-file.cdn.bcebos.com/upload/39148b2a545b48bf9b4ee95fd1b7f1eb_1515564089.png?');
}
style>
head>
<body>
<div>div>
body>
html>

dom文档中不存在test元素时,即使设置了背景图片,也不会发送请求,只有test元素存在时才会发送请求。

另外这个例子其实有点不太贴切,img标签background-image二者有着本质的区别。一个属于HTML标签,另一个属于css样式,加载机制和解析顺序也不同。

一个完整的页面是由jshtmlcss组成的,按照解析机制,html元素会优先解析,尽管css样式是放在head标签内的,但也不意味着它会优先加载,它只有等到html文档加载完成后才会执行。而img标签属于网页内容,所以img标签会随着网页解析渲染优先于css样式表加载出来。

文章中若有描述不正确的地方,欢迎掘友们纠正~。

参考文章


作者:娜个小部呀
来源:juejin.cn/post/7340167256267391012
收起阅读 »

小小导出,我大前端足矣!

web
如果你觉得你现在做的事情很累,很难,那一定是你方法用错了。 一、问题剖析 那是一个倾盆大雨的早上,花瓣随风雨落在我的肩膀上,是五颜六色的花朵。 我轻轻抚摸着他,随后拨开第一朵花瓣,她不爱我。 拨开第二朵,她爱我。 正当我沉迷于甜蜜的幻想中,后端小白🙋喊道:...
继续阅读 »



如果你觉得你现在做的事情很累,很难,那一定是你方法用错了。



2.jpg


一、问题剖析


那是一个倾盆大雨的早上,花瓣随风雨落在我的肩膀上,是五颜六色的花朵。


我轻轻抚摸着他,随后拨开第一朵花瓣,她不爱我。


拨开第二朵,她爱我。


正当我沉迷于甜蜜的幻想中,后端小白🙋喊道:这个导出你前端应该就能做的吧!


🙋🏻‍♂️那是自然,有什么功能是我大前端做不了的,必须得让你们大开眼界。


二、为什么导出要前端做?


前端导出的场景:



  1. 轻量级数据:如果要导出的表格数据相对较小,可以直接在前端生成和导出,避免服务器端的处理和通信开销。

  2. 数据已存在于前端:如果表格数据已经以 JSON 或其他形式存在于前端,可以直接利用前端技术将其导出为 Excel、CSV 或其他格式。

  3. 实时生成/计算:如果导出的表格需要根据用户输入或动态生成,可以使用前端技术基于用户操作实时生成表格,并提供导出功能。

  4. 快速响应:前端导出表格可以提供更快的响应速度,避免等待服务器端的处理和下载时间。


后端导出的场景:



  1. 大量数据:如果要导出的表格数据量很大,超过了前端处理能力或网络传输限制,那么在服务器端进行导出会更高效。

  2. 安全性和数据保护:敏感数据不适合在前端暴露,因此在服务器端进行导出可以更好地控制和保护数据的安全。

  3. 复杂的业务逻辑:如果导出涉及复杂的业务逻辑、数据处理或数据查询,使用服务器端的计算能力和数据库访问更合适。

  4. 跨平台支持:如果需要支持多个前端平台(如 Web、移动应用等),将导出功能放在服务器端可以提供一致的导出体验。


三、讲解一下在前端做的导出


xlsx、xlsx-style


如果是只做表格导出:http://www.npmjs.com/package/xls…


如果导出要包含样式:http://www.npmjs.com/package/xls…


import XLSX from "xlsx";

exportData() {
let tableName = '表格'

if(!getVal(this.dataList, 'length')){
this.$message.info("暂时数据");
return
}


// 处理头部
let headers = {
"B2": "字段-B2",
"E2": "字段-E2",
}
const props = [ "B2", "E2" ]
let tmp_dataListFilter = [
{
"B2": "字段-B2",
"E2": "字段-E2",
},
{
"E2": "2",
"B2": "2",
}
]

tmp_dataListFilter.unshift(headers) // 将表头放入数据源前面
let wb = XLSX.utils.book_new();
let contentWs = XLSX.utils.json_to_sheet(tmp_dataListFilter, {
skipHeader: true, // 是否忽略表头,默认为false
origin: "A2" // 设置插入位置
});
// /单独设置某个单元格内容
contentWs["A1"]={
t:"s",
v:tableName,
};
// /设置单元格合并!merges为一个对象数组,每个对象设定了单元格合并的规侧,
// /{s:{r:0,c:},e:{r:0,c:2}为一个规则,s:起始位置,e:结束位置,r:行,c:列
contentWs["!merges"]=[{ s:{r:0,c:0 },e:{r:0,c:props.length - 1 }}]

// 设置单元格的宽度
contentWs["!cols"] = []
props.forEach(p => contentWs["!cols"].push({wch: 35}))
XLSX.utils.book_append_sheet(wb,contentWs,tableName) // 表格内的下面的tab
XLSX.writeFile(wb,tableName + ".xlsx"); // 导出文件名字
},

package.json


"xlsx": "^0.15.5",
"xlsx-style": "^0.8.13"

大概效果如下:


3.png


感觉前端导出也很容易。


哦哦,那你别高兴太早。


四、需求升级:单元格要居中和加粗。


xlsx


尝试使用xlsx-style设样式。


官方文档:github.com/rockboom/Sh…


文档说给单元格设置s为对象


4.png


let contentWs = XLSX.utils.json_to_sheet(tmp_dataListFilter, {
skipHeader: true, // 是否忽略表头,默认为false
origin: "A2", // 设置插入位置
});
// /单独设置某个单元格内容
contentWs["A1"] = {
t: "s",
v: tableName,
s:{ // 这个是关键s
font: { bold: true },
alignment: { horizontal: 'center' }
}
};

发现设置无效。


有人说要改xlsx、xlsx-style源码:


大概的意思是:修改xlsx.extendscript.js、xlsx.full.min.js更改文件变量。


发现仍然无效。


使用binary方式保存



  1. 首先保存的时候 type要改成 binary方式

  2. 保存的时候需要使用 xlsx-style模块


var writingOpt = { 
bookType: 'xlsx',
bookSST: true,
type: 'binary' // <--- 1.改这里
}


/*
2. type:'array'改为'binary' 后因为下面代码会报错, 打不开excel
new Blob([wbout], { type: 'application/octet-stream' }
要文本转换成数组缓存后再生成二进制对象
*/


// 添加String To ArrayBuffer
function s2ab(s) {
var buf = new ArrayBuffer(s.length);
var view = new Uint8Array(buf);
for (var i = 0; i < s.length; i++) {
view[i] = s.charCodeAt(i) & 0xFF;
}
return buf;
}

let blob = new Blob([s2ab(wbout)], { type: 'application/octet-stream' })

FileSaver.saveAs(blob, exportName)

可以下载了。但依然样式没起作用。


使用 xlsx-style 模块生成文件


首先安装模块


npm install xlsx-style 

在项目里安装报好多错误直接强制安装,不检查依赖。


npm install xlsx-style -force

安装完成后 找不到cptable模块会报错

报错内容如下:


./node_modules/xlsx-style/dist/cpexcel.js Module not found: Error: Can't resolve './cptable' in 

这个问题在vue.config.js里配置一下就可以解决。

其他框架自己找找方法,反正只要不让他报错能启动就行。


module.exports = {
// ...其他配置省略
configureWebpack: {
// ...其他配置省略
externals:{
'./cptable':'var cptable'
},
},

安装完xlsx-style后改代码


import XLSX2 from "xlsx-style";    // 1. 引入模块

// 2. 使用`xlsx-style` 生成。 XLSX.write => XLSX2.write
var wbout = XLSX2.write(wb, writingOpt)

仍然无效。


总结xlsx


大概的意思是说:默认不支持改变样式,想要支持改变样式,需要使用它的收费版本。


本着勤俭节约的原则,很多人使用了另一个第三方库:xlsx-style[4] ,但是使用起来极其复杂,还需要改 node_modules 源码,这个库最后更新时间也定格在了 6年前。还有一些其他的第三方样式拓展库,质量参差不齐。


使用成本和后期的维护成本很高,不得不放弃。


ExcelJS


ExcelJS终于可以了


ExcelJS[5] 周下载量 450k,github star 9k,并且拥有中文文档,对国内开发者很友好。虽然文档是以README 的形式,可读性不太好,但重在内容,常用的功能基本都有覆盖。


最近更新时间是6个月内,试用了一下,集成很简单,再加之文档丰富,就选它了。


npm install exceljs
npm install file-saver // 下载到本地还需要另一个库:file-saver

基本操作


//导入ExcelJS
import ExcelJS from "exceljs";

//下载文件
download_file(buffer, fileName) {
console.log("导出");
let fileURL = window.URL.createObjectURL(new Blob([buffer]));
let fileLink = document.createElement("a");
fileLink.href = fileURL;
fileLink.setAttribute("download", fileName);
document.body.appendChild(fileLink);
fileLink.click();
}

导出xlsx表格的代码


//下面是导出的函数
async export() {
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet("Sheet1");
//这里是数据列表
const data = [
{ id: 1, name: "艾伦", age: 20, sex: "男", achievement: 90 },
{ id: 2, name: "柏然", age: 25, sex: "男", achievement: 86 },
];
// 设置列,这里的width就是列宽
worksheet.columns = [
{ header: "序号", key: "id", width: 10},
{ header: "姓名", key: "name", width: 10 },
];

// 批量插入数据
data.forEach(item => worksheet.addRow(item));

// 写入文件
const buffer = await workbook.xlsx.writeBuffer();
//下载文件
this.download_file(buffer, "填报汇总.xlsx");
}

设置行高和列宽


列宽上面已经有了,这里说明一下行高怎么设置

worksheet.getRow(2).height = 30;


合并单元格


worksheet.mergeCells("B1:C1");


自定义表格样式


//设置样式表格样式,font里面设置字体大小,颜色(这里是argb要注意),加粗
//alignment 设置单元格的水平和垂直居中
const B1 = worksheet.getCell('B1')
B1.font = { size: 20, color:{ argb: 'FF8B008B' }, bold: true }
B1.alignment = { horizontal: 'center', vertical: 'middle' }

ExcelJS实战


import ExcelJS from "exceljs";

//下载文件
download_file(buffer, fileName) {
console.log("导出");
let fileURL = window.URL.createObjectURL(new Blob([buffer]));
let fileLink = document.createElement("a");
fileLink.href = fileURL;
fileLink.setAttribute("download", fileName);
document.body.appendChild(fileLink);
fileLink.click();
},
async exportClick() {
const loading = this.$loading({
lock: true,
text: "数据导出中,请耐心等待!",
spinner: "el-icon-loading",
background: "rgba(0, 0, 0, 0.7)",
});

this.tableData = [
{ a: 1, b:2 }
]
const enterpriseVisitsColumns = [
{
prop: "a",
label: "银行",
},
{
prop: "b",
label: "企业数",
}
]

// 表格数据:this.tableData
if (!(this.tableData && this.tableData.length)) {
this.$message.info("暂无数据");
loading.close();
return;
}

let tableName = this.tableName; // 表格名
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet(tableName);
const props = enterpriseVisitsColumns();
//这里是数据列表
const data = this.tableData;
// 设置列,这里的width就是列宽
let arr = [];
props.forEach((p) => {
arr.push({
header: p.label,
key: p.prop,
width: 25,
});
});
worksheet.columns = arr;

// 插入一行到指定位置,现在我往表格最前面加一行,值为表名
const rowIndex = 1; // 要插入的行位置
const newRow = worksheet.insertRow(rowIndex);
// 设置新行的单元格值
newRow.getCell(1).value = tableName; // 值为表名

// 批量插入数据,上面插一条,这里就是从第二行开始加
data.forEach((item) => worksheet.addRow(item));

//设置样式表格样式,font里面设置字体大小,颜色(这里是argb要注意),加粗
//alignment 设置单元格的水平和垂直居中
// const B1 = worksheet.getCell("B1");
// B1.font = { size: 20, color: { argb: "FF8B008B" }, bold: true };
// B1.alignment = { horizontal: "center", vertical: "middle" };

// 合并单元格,就是把A1开始到J1的单元格合并
worksheet.mergeCells("A1:J1");

// 批量设置所有表格数据的样式
worksheet.eachRow((row, rowNumber) => {
let size = rowNumber == 1 ? 16 : rowNumber == 2 ? 12 : "";
//设置表头样式
row.eachCell((cell) => {
cell.font = {
size,
// color:{ argb: 'FF8B008B' },
bold: true,
};
cell.alignment = { horizontal: "center", vertical: "middle" };
});

//设置所有行高
row.height = 30;
});

// 写入文件
const buffer = await workbook.xlsx.writeBuffer();
//下载文件
this.download_file(buffer, tableName + ".xlsx");

loading.close();
},

后记


导出功能并不是说都是前端或者后端实现,要具体情况,具体分析,我相信哪方都可以做,但谁适合做,这个才是我们需要去思考的。


就如同我们项目中,该例子后面也是前端实现的,大数据分页当然还是得后端同学来实现较好。


如果有其他更好的方法也欢迎评论区见,这里提供的只是诸多方法之一。


最后,祝君能拿下满意的offer。




作者:Dignity_呱
来源:juejin.cn/post/7339814359886348328
收起阅读 »

MyBatis-Plus 效能提升秘籍:掌握这些注解,事半功倍!

MyBatis-Plus是一个功能强大的MyBatis扩展插件,它提供了许多便捷的注解,让我们在开发过程中能够更加高效地完成数据库操作,本文将带你一一了解这些注解,并通过实例来展示它们的魅力。一、@Tablename注解这个注解用于指定实体类对应的数据库表名。...
继续阅读 »

MyBatis-Plus是一个功能强大的MyBatis扩展插件,它提供了许多便捷的注解,让我们在开发过程中能够更加高效地完成数据库操作,本文将带你一一了解这些注解,并通过实例来展示它们的魅力。

一、@Tablename注解

这个注解用于指定实体类对应的数据库表名。如果你的表名和实体类名不一致,就需要用到它:

@TableName("user_info")
public class UserInfo {
// 类的属性和方法
}

在上述代码中,即使实体类名为UserInfo,但通过@TableName注解,我们知道它对应数据库中的"user_info"表。

二、@Tableld注解

每个数据库表都有主键,@TableId注解用于标识实体类中的主键属性。通常与@TableName配合使用,确保主键映射正确。

AUTO(0),
NONE(1),
INPUT(2),
ASSIGN_ID(3),
ASSIGN_UUID(4),
/** @deprecated */
@Deprecated
ID_WORKER(3),
/** @deprecated */
@Deprecated
ID_WORKER_STR(3),
/** @deprecated */
@Deprecated
UUID(4);

Description

  • INPUT 如果开发者没有手动赋值,则数据库通过自增的方式给主键赋值,如果开发者手动赋值,则存入该值。

  • AUTO 默认就是数据库自增,开发者无需赋值。

  • ASSIGN_ID MP 自动赋值,雪花算法。

  • ASSIGN_UUID 主键的数据类型必须是 String,自动生成 UUID 进行赋值。

// 自己赋值
//@TableId(type = IdType.INPUT)
// 默认使用的雪花算法,长度比较长,所以使用Long类型,不用自己赋值
@TableId
private Long id;

测试

@Test
void save(){
// 由于id加的有注解,这里就不用赋值了
Student student = new Student();
student.setName("天明");
student.setAge(18);
mapper.insert(student);
}

Description

雪花算法

雪花算法是由Twitter公布的分布式主键生成算法,它能够保证不同表的主键的不重复性,以及相同表的主键的有序性。

核心思想:

  • 长度共64bit(一个long型)。

  • 首先是一个符号位,1bit标识,由于long基本类型在Java中是带符号的,最高位是符号位,正数是0,负数是1,所以id一般是正数,最高位是0。

  • 41bit时间截(毫秒级),存储的是时间截的差值(当前时间截 - 开始时间截),结果约等于69.73年。

  • 10bit作为机器的ID(5个bit是数据中心,5个bit的机器ID,可以部署在1024个节点)。

  • 12bit作为毫秒内的流水号(意味着每个节点在每毫秒可以产生 4096 个 ID)。

Description

优点: 整体上按照时间自增排序,并且整个分布式系统内不会产生ID碰撞,并且效率较高。

三、@TableField注解

当你的实体类属性名与数据库字段名不一致时,@TableField注解可以帮助你建立二者之间的映射关系。

  • 映射非主键字段,value 映射字段名;

  • exist 表示是否为数据库字段 false,如果实体类中的成员变量在数据库中没有对应的字段,则可以使用 exist,VO、DTO;

  • select 表示是否查询该字段;

  • fill 表示是否自动填充,将对象存入数据库的时候,由 MyBatis Plus 自动给某些字段赋值,create_time、update_time。

Description

自动填充

1)给表添加 create_time、update_time 字段。

Description

2)实体类中添加成员变量。

package com.md.entity;

import com.baomidou.mybatisplus.annotation.*;
import com.md.enums.StatusEnum;
import lombok.Data;
import java.util.Date;

@Data
@TableName(value = "student")
public class Student {
@TableId
private Long id;

// 当该字段名称与数据库名字不一致
@TableField(value = "name")
private String name;

// 不查询该字段
@TableField(select = false)
private Integer age;

// 当数据库中没有该字段,就忽略
@TableField(exist = false)
private String gender;

// 第一次添加填充
@TableField(fill = FieldFill.INSERT)
private Date createTime;

// 第一次添加的时候填充,但之后每次更新也会进行填充
@TableField(fill = FieldFill.INSERT_UPDATE)
private Date updateTime;

}

3)创建自动填充处理器。

注意:不要忘记添加 @Component 注解。

package com.md.handler;

import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;

import java.util.Date;

/**
* @author md
* @Desc 对实体类中使用的自动填充注解进行编写
* @date 2020/10/26 17:29
*/
// 加入注解才能生效
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {

@Override
public void insertFill(MetaObject metaObject) {
this.setFieldValByName("createTime", new Date(), metaObject);
this.setFieldValByName("updateTime", new Date(), metaObject);
}

@Override
public void updateFill(MetaObject metaObject) {
this.setFieldValByName("updateTime", new Date(), metaObject);
}
}

4)测试

@Test
void save(){
// 由于id加的有注解,这里就不用赋值了
Student student = new Student();
student.setName("韩立");
student.setAge(11);
// 时间自动填充
mapper.insert(student);
}

Description

5)更新

当该字段发生变化的时候时间会自动更新。

@Test
void update(){
Student student = mapper.selectById(1001);
student.setName("韩信");
mapper.updateById(student);
}

Description

四、@TableLogic注解

在很多应用中,数据并不是真的被删除,而是标记为已删除状态。@TableLogic注解用于标识逻辑删除字段,通常配合逻辑删除功能使用。

1、逻辑删除

物理删除: 真实删除,将对应数据从数据库中删除,之后查询不到此条被删除的数据。

逻辑删除: 假删除,将对应数据中代表是否被删除字段的状态修改为“被删除状态”,之后在数据库中仍旧能看到此条数据记录。

使用场景: 可以进行数据恢复。

2、实现逻辑删除

step1: 数据库中创建逻辑删除状态列。
Description

step2: 实体类中添加逻辑删除属性。

@TableLogic
@TableField(value = "is_deleted")
private Integer deleted;

3、测试

测试删除: 删除功能被转变为更新功能。

-- 实际执行的SQL
update user set is_deleted=1 where id = 1 and is_deleted=0

测试查询: 被逻辑删除的数据默认不会被查询。

-- 实际执行的SQL
select id,name,is_deleted from user where is_deleted=0

你还在苦恼找不到真正免费的编程学习平台吗?可以试试【云端源想】!课程视频、知识库、微实战、云实验室、一对一咨询……你想要的全部学习资源这里都有,重点是现在还是免费的!点这里即可查看!

五、@Version注解

乐观锁是一种并发控制策略,@Version注解用于标识版本号字段,确保数据的一致性。

乐观锁

Description
标记乐观锁,通过 version 字段来保证数据的安全性,当修改数据的时候,会以 version 作为条件,当条件成立的时候才会修改成功。

version = 2

  • 线程1:update … set version = 2 where version = 1
  • 线程2:update … set version = 2 where version = 1

1.数据库表添加 version 字段,默认值为 1。

2.实体类添加 version 成员变量,并且添加 @Version。

package com.md.entity;

import com.baomidou.mybatisplus.annotation.*;
import com.md.enums.StatusEnum;
import lombok.Data;
import java.util.Date;

@Data
@TableName(value = "student")
public class Student {
@TableId
private Long id;
@TableField(value = "name")
private String name;
@TableField(select = false)
private Integer age;
@TableField(exist = false)
private String gender;
@TableField(fill = FieldFill.INSERT)
private Date createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private Date updateTime;

@Version
private Integer version; //版本号

}

3.注册配置类

在 MybatisPlusConfig 中注册 Bean。

package com.md.config;

import com.baomidou.mybatisplus.extension.plugins.OptimisticLockerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.PaginationInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
* @author md
* @Desc
* @date 2020/10/26 20:42
*/
@Configuration
public class MyBatisPlusConfig {
/**
* 乐观锁
*/
@Bean
public OptimisticLockerInterceptor optimisticLockerInterceptor(){
return new OptimisticLockerInterceptor();
}
}

六、@EnumValue注解

mp框架对枚举进行处理的一个注解。

使用场景: 创建枚举类,在需要存储数据库的属性上添加@EnumValue注解。

public enum SexEnum {

MAN(1, "男"),
WOMAN(2, "女");

@EnumValue
private Integer key;
}

MyBatis-Plus的注解是开发者的好帮手,它们简化了映射配置,提高了开发效率。希望以上的介绍能帮助新手朋友们快速理解和运用这些常用注解,让你们在MyBatis-Plus的世界里游刃有余!记得实践是最好的学习方式,快去动手试试吧!

收起阅读 »

技术人的绩效评审发年终奖那些事儿

前言 这几天陆续开工了,收益不好的公司,没有年会,没有年终奖,没有开工红包,没有团建,也没有聚餐,唯一有的可能是降薪裁员... 收益好的公司开了年会,年终奖加倍... 接下来就来聊聊关于技术人的绩效评审以及年终奖那些事儿 以下基于个人经历展开讨论和思考,如果...
继续阅读 »


前言


这几天陆续开工了,收益不好的公司,没有年会,没有年终奖,没有开工红包,没有团建,也没有聚餐,唯一有的可能是降薪裁员...


收益好的公司开了年会,年终奖加倍...


接下来就来聊聊关于技术人的绩效评审以及年终奖那些事儿



以下基于个人经历展开讨论和思考,如果有不同的观点,欢迎交流探讨



关于年终奖


一般来讲,只要公司收益好,一般是多少都会发点年终奖的,例如13、4、5、6,7薪,过节费,项目奖,年终奖,xx绩效奖,部门xx奖等等



公司要是效益不好的话,可能就是这些各种发钱的项目就没有了,可能会有一点过节费,如果效益差到工资社保都拖欠的话,可能就是考虑能不能年底也发点工资的情况了


总的来说年终奖主要和公司收益挂钩,公司收益好,多少有点,收益不好,无


如果发年终奖的话,在相关制度下总量可能就那么多,发到个人这边一般就是和绩效挂钩了,绩效评级高,同类型岗位情况下发的多点,绩效低,发的少,那么公司是怎么对技术人进行绩效评审呢



微小型公司可能不需要绩效,发钱基本老板一个人就决定了


特殊情况的公司或者部门各种特殊的情况也有



这里分享一下我司今年出的年终奖方案



以下半年部门净收入目标完成率作为基础指标,实现目标销售净额的,按3%计提奖金池,未完成目标的,按3%×目标完成率计提奖金池,超过目标的,超过部分按10%增加奖金池。由分管领导与部门沟通后参照员工年终评估结果出具分配方案(年终评估为D或E员工无奖金)



负责人,其他前台部门等年终奖的计算方式是另外的方式,有兴趣的可以留言讨论,这里先不展开了


员工评估 D, E 是有硬指标的,具体占比百分之几咱也不知道,每年的评审结果都是只有领导知道


由于公司业务效益不行,净收入为负,亏损状态,所以我们这个部门的员工年终奖——无



关于绩效评审


先来看看我司对技术人员工的评估方式


第一步:直系领导直接打分,提交对应的表到人力部门


第二步:人力部门根据任务系统中的个人任务情况,以及考勤情况打分


第三步:人力总监最终决定给xxx员工涨薪,发奖金



日常任务由直系领导安排发放,包括任务工时评估,注意了,这里任务工时不是开发者评估的,大部分都是负责人直接评好写到周任务表上去的,有的任务用时会和开发者咨询协商


人力部门看的那个任务系统中的任务,一般是业务线负责人不忙的时候根据腾讯文档周任务表中的任务后期补上去的


日常的很多临时工作在周任务表上体现不出来的,然后很多周任务表中的任务没有写到任务系统中,任务系统中的任务只是创建,开始,完成状态,没有工时的体现


由于任务系统是人力部门单独推动的,最终的情况就是实际工作内容和任务系统记录其实是脱节状态,为了建任务而建任务


不同业务线的直系领导角色也不一样,有的是纯管理,有的是半后端开发半管理,有的是产品



总结一下就是直系领导决定主要的打分情况,人力部门根据基于一线负责人的打分结合考勤和任务情况,决定要不要涨薪,发多少奖金


以上就是我司绩效评审的一些情况,这也是相当一部分比例公司(部门/团队)的常规操作


从客观数据角度来看,基本没有体现岗位产出方面客观可量化的指标,唯一能量化的指标就是一个考勤了,两三个人的主观评价就决定了一个技术人的升职、加薪、辞退、奖金发放的问题


万物都有存在的道理,毕竟,大部分公司都是草台班子,甚至更水


从客观数据角度思考对于技术人的工作评审


先来看看技术人的实际工作都有哪些?


我们拿技术人中的开发人员来举例,从一个任务安排到工作完成提交都有哪些常见步骤:


参加需求评审会,了解需求,设计实现方案,代码编写,提交代码,线上测试,修复bug,输出文档,技术分享等等


开发人员工作产出一般通俗点讲就是开发了多少需求,功能,解决了多少bug等等


在技术圈内一般讨论一个开发人员是否大佬,是否能力出众的标准一般是:



  1. 解决问题的快慢程度(不局限于技术问题)

  2. 掌握技术栈的深度和广度

  3. 是否能和其他不同的工种、部门良好协作

  4. 技术方案执行落地能够良好取舍

  5. 定期更新技术栈,使用相对合适的技术解决业务问题

  6. 提交功能的bug数量

  7. 代码可读性是否良好

  8. 代码是否足够简洁优雅

  9. 开发的功能是否健壮

  10. 其他人接手可维护性是否容易

  11. 输出的文档是否专业,格式简洁明了

  12. ...


相对来讲,开发人员工作产出基本都是可以量化的,关于工作量化的问题,鲁迅说: 任何岗位的工作都是可以量化的


为了评审量化产出,也不能为了量化而量化,那样没任何意义了,浪费团队大把时间扯皮,还影响工作


关于量化产出激励团队方面,我个人比较喜欢敏捷开发那种模式,相对公正,公开


例如:在敏捷开发里面是日常工作是按照评估的人天,人时等方式


任务评估为了保证相对公平性是有这么一个前提的,团队岗位不是一个人评估


例如前端开发岗位,至少俩个人,一个人评估1人天,一个人评估10人天,这显然是有明显差距的,这种情况下一般 master 角色和团队其他成员参与讨论决定,master 也就是项目经理或者项目负责人的角色进行一定的把控


任务是个人根据需求池中的任务自己拉到个人任务表里,而不是单纯的分配,这种机制有个很不错的方式,就是优秀的人在一起会激发更强大的创造力



这里推荐看下美剧 《硅谷》 中 Dinesh 和 Gilfoyle 敏捷开发时的竞争桥段



还有就是不同的开发任务难易程度不一样,举个例子,有两个小任务,同样都是2人天,第一个任务需要大量的尝试新方案,进行相应的测试,并编写一定数据量的代码;第二个任务是使用现有方案,直接写一定量的代码,如常见的业务需求


这种情况的任务如果在项目工期相对紧张的情况,任务是自我选择的话绝大多数都会选择第二个任务,虽然量大,没有风险,不会出现研究过程某个地方卡壳导致任务延期(延期也有对应的处理机制,这里先不讨论)


但是这样很可能会出现难度高的任务会放到最后,没人做了,所以要有人为把控优先级,并且进行合理安排进行一定的调整


同样都是2天的任务,接受度是不一样的,这种任务一般会增加一个难度系数,也有的是进行难度分类,例如:难,一般,容易


最终任务产出统计时,工时还需要乘以难度系数


理想情况下以上的操作基本能实现大部分开发工作产出的量化,对于年终的工作评审结果相对公平客观



注意!!!


这里有个点,敏捷的机制是激励那些那些高效率高产出的人,是一种公开透明的激励机制


良好落地敏捷也是需要一定条件的,如团队需要有一定规模,并且要进行培训和认同这种机制,而且岗位对应人员至少是一个人,级别不能相差太大等等



每一种机制都是基于特定人群设计的,如果用错了人群可能机制流程就变成了纯形式化了


基于客观数据的评审方式


简单分析了一下后,基于客观数据相对公正的评审方式可看如下例子


日常任务得分 * 权重系数 + 直系领导打分 * 权重系数 + 部门领导/CXO 打分 * 权重系统 + 人事考勤得分 * 权重系数 + 其他得分


日常任务得分权重 > 领导打分权重 > ...


单纯衡量技术人的工作的话,应该主要以产出为主,其他环节占比相对小一点


相对来讲这种方式各方都有参与环节,基于数据数据说话是比较客观公正的


以上都是理想状态,一个公司的做事风格和方式和创始人有着直接关系,最终落地后的是个什么东西还得看公司老板和高管是如何做的


写在最后


这个世界没有绝对的公平合理



欢迎大家讨论交流,如果喜欢本文章或感觉文章有用,动动你那发财的小手点赞、收藏、关注再走呗 ^_^ 


微信公众号:草帽Lufei




作者:草帽lufei
来源:juejin.cn/post/7337630368907198502
收起阅读 »

麻了,一个操作把MySQL主从复制整崩了

前言 最近公司某项目上反馈mysql主从复制失败,被运维部门记了一次大过,影响到了项目的验收推进,那么究竟是什么原因导致的呢?而主从复制的原理又是什么呢?本文就对排查分析的过程做一个记录。 主从复制原理 我们先来简单了解下MySQL主从复制的原理。 主库m...
继续阅读 »

前言


最近公司某项目上反馈mysql主从复制失败,被运维部门记了一次大过,影响到了项目的验收推进,那么究竟是什么原因导致的呢?而主从复制的原理又是什么呢?本文就对排查分析的过程做一个记录。


主从复制原理


我们先来简单了解下MySQL主从复制的原理。




  1. 主库master 服务器会将 SQL 记录通过 dump 线程写入到 二进制日志binary log 中;

  2. 从库slave 服务器开启一个 io thread 线程向服务器发送请求,向 主库master 请求 binary log。主库master 服务器在接收到请求之后,根据偏移量将新的 binary log 发送给 slave 服务器。

  3. 从库slave 服务器收到新的 binary log 之后,写入到自身的 relay log 中,这就是所谓的中继日志。

  4. 从库slave 服务器,单独开启一个 sql thread 读取 relay log 之后,写入到自身数据中,从而保证主从的数据一致。


以上是MySQL主从复制的简要原理,更多细节不展开讨论了,根据运维反馈,主从复制失败主要在IO线程获取二进制日志bin log超时,一看主数据库的binlog日志竟达到了4个G,正常情况下根据配置应该是不超过300M。



binlog写入机制


想要了解binlog为什么达到4个G,我们来看下binlog的写入机制。


binlog的写入时机也非常简单,事务执行过程中,先把日志写到 binlog cache ,事务提交的时候,再把binlog cache写到binlog文件中。因为一个事务的binlog不能被拆开,无论这个事务多大,也要确保一次性写入,所以系统会给每个线程分配一个块内存作为binlog cache




  1. 上图的write,是指把日志写入到文件系统的page cache,并没有把数据持久化到磁盘,所以速度比较快

  2. 上图的fsync,才是将数据持久化到磁盘的操作, 生成binlog日志中


生产上MySQL中binlog中的配置max_binlog_size为250M, 而max_binlog_size是用来控制单个二进制日志大小,当前日志文件大小超过此变量时,执行切换动作。,该设置并不能严格控制Binlog的大小,尤其是binlog比较靠近最大值而又遇到一个比较大事务时,为了保证事务的完整性,可能不做切换日志的动作,只能将该事务的所有$QL都记录进当前日志,直到事务结束。一般情况下可采取默认值。


所以说怀疑是不是遇到了大事务,因而我们需要看看binlog中的内容具体是哪个事务导致的。


查看binlog日志


我们可以使用mysqlbinlog这个工具来查看下binlog中的内容,具体用法参考官网:https://dev.mysql.com/doc/refman/8.0/en/mysqlbinlog.html



  1. 查看binlog日志


./mysqlbinlog --no-defaults --base64-output=decode-rows -vv /mysqldata/mysql/binlog/mysql-bin.004816|more


  1. 以事务为单位统计binlog日志文件中占用的字节大小


./mysqlbinlog --no-defaults --base64-output=decode-rows -vv /mysqldata/mysql/binlog/mysql-bin.004816|grep GTID -B1|grep '^# at' | awk '{print $3}' | awk 'NR==1 {tmp=$1} NR>1 {print ($1-tmp, tmp, $1); tmp=$1}'|sort -n -r|more


生产中某个事务竟然占用4个G。



  1. 通过start-positionstop-position统计这个事务各个SQL占用字节大小


./mysqlbinlog --no-defaults --base64-output=decode-rows --start-position='xxxx' --stop-position='xxxxx' -vv /mysqldata/mysql/binlog/mysql-bin.004816 |grep '^# at'| awk '{print $3}' | awk 'NR==1 {tmp=$1} NR>1 {print ($1-tmp, tmp, $1); tmp=$1}'|sort -n -r|more


发现最大的一个SQL竟然占用了32M的大小,那超过10M的大概有多少个呢?



  1. 通过超过10M大小的数量


./mysqlbinlog --no-defaults --base64-output=decode-rows --start-position='xxxx' --stop-position='xxxxx' -vv /mysqldata/mysql/binlog/mysql-bin.004816|grep '^# at' | awk '{print $3}' | awk 'NR==1 {tmp=$1} NR>1 {print ($1-tmp, tmp, $1); tmp=$1}'|awk '$1>10000000 {print $0}'|wc -l


统计结果显示竟然有200多个,毛估一下,也有近4个G了



  1. 根据pos, 我们看下究竟是什么SQL导致的


./mysqlbinlog --no-defaults --base64-output=decode-rows --start-position='xxxx' --stop-position='xxxxx' -vv /mysqldata/mysql/binlog/mysql-bin.004816|grep '^# atxxxx' -C5| grep -v '###' | more


根据sql,分析了下,这个表正好有个blob字段,统计了下blob字段总合大概有3个G大小,然后我们业务上有个导入操作,这是一个非常大的事务,会频繁更新这表中记录的更新时间,导致生成binlog非常大。


问题: 明明只是简单的修改更新时间的语句,压根没有动blob字段,为什么生产的binlog这么大?因为生产的binlog采用的是row模式。


binlog的模式


binlog日志记录存在3种模式,而生产使用的是row模式,它最大的特点,是很精确,你更新表中某行的任何一个字段,会记录下整行的内容,这也就是为什么blob字段都被记录到binlog中,导致binlog非常大。此外,binlog还有statementmixed两种模式。



  1. STATEMENT模式 ,基于SQL语句的复制



  • 优点: 不需要记录每一行数据的变化,减少binlog日志量,节约IO,提高性能。

  • 缺点: 由于只记录语句,所以,在statement level下 已经发现了有不少情况会造成MySQL的复制出现问题,主要是修改数据的时候使用了某些定的函数或者功能的时候会出现。



  1. ROW模式,基于行的复制


5.1.5版本的MySQL才开始支持,不记录每条sql语句的上下文信息,仅记录哪条数据被修改了,修改成什么样了。



  • 优点: binlog中可以不记录执行的sql语句的上下文相关的信息,仅仅只需要记录那一条被修改。所以rowlevel的日志内容会非常清楚的记录下每一行数据修改的细节。不会出现某些特定的情况下的存储过程或function,以及trigger的调用和触发无法被正确复制的问题

  • 缺点: 所有的执行的语句当记录到日志中的时候,都将以每行记录的修改来记录,会产生大量的日志内容。



  1. MIXED模式


从5.1.8版本开始,MySQL提供了Mixed格式,实际上就是StatementRow的结合。


Mixed模式下,一般的语句修改使用statment格式保存binlog。如一些函数,statement无法完成主从复制的操作,则采用row格式保存binlog


总结


最终分析下来,我们定位到原来是由于大事务+blob字段大致binlog非常大,最终我们采用了修改业务代码,将blob字段单独拆到一张表中解决。所以,在设计开发过程中,要尽量避免大事务,同时在数据库建模的时候特别考虑将blob字段独立成表。


作者:JAVA旭阳
来源:juejin.cn/post/7231473194339532861
收起阅读 »

使用java自己简单搭建内网穿透

思路 内网穿透是一种网络技术,适用于需要远程访问本地部署服务的场景,比如你在家里搭建了一个网站或者想远程访问家里的电脑。由于本地部署的设备使用私有IP地址,无法直接被外部访问,因此需要通过公网IP实现访问。通常可以通过购买云服务器获取一个公网IP来实现这一目的...
继续阅读 »

思路


内网穿透是一种网络技术,适用于需要远程访问本地部署服务的场景,比如你在家里搭建了一个网站或者想远程访问家里的电脑。由于本地部署的设备使用私有IP地址,无法直接被外部访问,因此需要通过公网IP实现访问。通常可以通过购买云服务器获取一个公网IP来实现这一目的。


实际上,内网穿透的原理是将位于公司或其他工作地点的私有IP数据发送到云服务器(公网IP),再从云服务器发送到家里的设备(私有IP)。从私有IP到公网IP的连接是相对简单的,但是从公网IP到私有IP就比较麻烦,因为公网IP无法直接找到私有IP。


为了解决这个问题,我们可以让私有IP主动连接公网IP。这样,一旦私有IP连接到了公网IP,公网IP就知道了私有IP的存在,它们之间建立了连接关系。当公网IP收到访问请求时,就会通知私有IP有访问请求,并要求私有IP连接到公网IP。这样一来,公网IP就建立了两个连接,一个是用于访问的连接,另一个是与私有IP之间的连接。最后,通过这两个连接之间的数据交换,实现了远程访问本地部署服务的目的。


代码操作


打开IDEA创建一个mave项目,删除掉src,创建两个模块clientservice,一个是在本地的运行,一个是在云服务器上运行的,这边socket(tcp)连接,我使用的是AIO,AIO的函数回调看起来好复杂。


先编写service服务端,创建两个ServerSocket服务,一个是监听16000的,用来外来连接的,另一是监听16088是用来client访问的,也就是给serviceclient之间交互用的。先讲一个extListener他是监听16000,当有外部请求来时,也就是在公司访问时,先判断registerChannel是不是有clientservice,没有就关闭连接。有的话就下发指令告诉client有访问了赶快给我连接,连接会存在channelQueue队列里,拿到连接后,两个连接交换数据就行。


private static final int extPort = 16000;
private static final int clintPort = 16088;


private static AsynchronousSocketChannel registerChannel;

static BlockingQueue<AsynchronousSocketChannel> channelQueue = new LinkedBlockingQueue<>();

public static void main(String[] args) throws IOException {

final AsynchronousServerSocketChannel listener =
AsynchronousServerSocketChannel.open().bind(new InetSocketAddress("192.168.1.10", clintPort));

listener.accept(null, new CompletionHandler<AsynchronousSocketChannel, Void>() {
public void completed(AsynchronousSocketChannel ch, Void att) {

// 接受连接,准备接收下一个连接
listener.accept(null, this);

// 处理连接
clintHandle(ch);
}

public void failed(Throwable exc, Void att) {
exc.printStackTrace();
}
});


final AsynchronousServerSocketChannel extListener =
AsynchronousServerSocketChannel.open().bind(new InetSocketAddress("localhost", extPort));

extListener.accept(null, new CompletionHandler<AsynchronousSocketChannel, Void>() {

private Future<Integer> writeFuture;

public void completed(AsynchronousSocketChannel ch, Void att) {
// 接受连接,准备接收下一个连接
extListener.accept(null, this);

try {
//判断是否有注册连接
if(registerChannel==null || !registerChannel.isOpen()){
try {
ch.close();
} catch (IOException e) {
e.printStackTrace();
}
return;
}
//下发指令告诉需要连接
ByteBuffer bf = ByteBuffer.wrap(new byte[]{1});
if(writeFuture != null){
writeFuture.get();
}
writeFuture = registerChannel.write(bf);

AsynchronousSocketChannel take = channelQueue.take();

//clint连接失败的
if(take == null){
ch.close();
return;
}

//交换数据
exchangeDataHandle(ch,take);

} catch (Exception e) {
e.printStackTrace();
}

}

public void failed(Throwable exc, Void att) {
exc.printStackTrace();
}
});

Scanner in = new Scanner(System.in);
in.nextLine();


}

看看clintHandle方法是怎么存进channelQueue里的,很简单client发送0,就认为他是注册的连接,也就交互的连接直接覆盖registerChannel,发送1的话就是用来交换数据的,扔到channelQueue,发送2就异常的连接。


private static void clintHandle(AsynchronousSocketChannel ch) {

final ByteBuffer buffer = ByteBuffer.allocate(1);
ch.read(buffer, null, new CompletionHandler<Integer, Void>() {
public void completed(Integer result, Void attachment) {
buffer.flip();
byte b = buffer.get();
if (b == 0) {
registerChannel = ch;
} else if(b == 1){
channelQueue.offer(ch);
}else{
//clint连接不到
channelQueue.add(null);
}

}

public void failed(Throwable exc, Void attachment) {
exc.printStackTrace();
}
});
}

再编写client客户端,dstHostdstPort是用来连接service的ip和端口,看起来好长,实际上就是client连接service,第一个连接成功后向service发送了个0告诉他是注册的连接,用来交换数据。当这个连接收到service发送的1时,就会创建新的连接去连接service


private static final String dstHost = "192.168.1.10";
private static final int dstPort = 16088;

private static final String srcHost = "localhost";
private static final int srcPort = 3389;


public static void main(String[] args) throws IOException {

System.out.println("dst:"+dstHost+":"+dstPort);
System.out.println("src:"+srcHost+":"+srcPort);

//使用aio
final AsynchronousSocketChannel client = AsynchronousSocketChannel.open();

client.connect(new InetSocketAddress(dstHost, dstPort), null, new CompletionHandler<Void, Void>() {
public void completed(Void result, Void attachment) {
//连接成功
byte[] bt = new byte[]{0};
final ByteBuffer buffer = ByteBuffer.wrap(bt);
client.write(buffer, null, new CompletionHandler<Integer, Void>() {
public void completed(Integer result, Void attachment) {

//读取数据
final ByteBuffer buffer = ByteBuffer.allocate(1);
client.read(buffer, null, new CompletionHandler<Integer, Void>() {
public void completed(Integer result, Void attachment) {
buffer.flip();

if (buffer.get() == 1) {
//发起新的连
try {
createNewClient();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
buffer.clear();
// 这里再次调用读取操作,实现循环读取
client.read(buffer, null, this);
}

public void failed(Throwable exc, Void attachment) {
exc.printStackTrace();
}
});


}

public void failed(Throwable exc, Void attachment) {
exc.printStackTrace();
}
});


}

public void failed(Throwable exc, Void attachment) {
exc.printStackTrace();
}
});
Scanner in = new Scanner(System.in);
in.nextLine();

}

createNewClient方法,尝试连接本地服务,如果失败就发送2,成功就发送1,这个会走 serviceclintHandle方法,成功的话就会让两个连接交换数据。


private static void createNewClient() throws IOException {

final AsynchronousSocketChannel dstClient = AsynchronousSocketChannel.open();
dstClient.connect(new InetSocketAddress(dstHost, dstPort), null, new CompletionHandler<Void, Void>() {
public void completed(Void result, Void attachment) {

//尝试连接本地服务
final AsynchronousSocketChannel srcClient;
try {
srcClient = AsynchronousSocketChannel.open();
srcClient.connect(new InetSocketAddress(srcHost, srcPort), null, new CompletionHandler<Void, Void>() {
public void completed(Void result, Void attachment) {

byte[] bt = new byte[]{1};
final ByteBuffer buffer = ByteBuffer.wrap(bt);
Future<Integer> write = dstClient.write(buffer);
try {
write.get();
//交换数据
exchangeData(srcClient, dstClient);
exchangeData(dstClient, srcClient);
} catch (Exception e) {
closeChannels(srcClient, dstClient);
}


}

public void failed(Throwable exc, Void attachment) {
exc.printStackTrace();
//失败
byte[] bt = new byte[]{2};
final ByteBuffer buffer = ByteBuffer.wrap(bt);
dstClient.write(buffer);
}
});

} catch (IOException e) {
e.printStackTrace();
//失败
byte[] bt = new byte[]{2};
final ByteBuffer buffer = ByteBuffer.wrap(bt);
dstClient.write(buffer);
}

}

public void failed(Throwable exc, Void attachment) {
exc.printStackTrace();
}
});
}

下面是exchangeData交换数据方法,看起好麻烦,效果就类似IOUtils.copy(InputStream,OutputStream),一个流写入另一个流。


private static void exchangeData(AsynchronousSocketChannel ch1, AsynchronousSocketChannel ch2) {
try {
final ByteBuffer buffer = ByteBuffer.allocate(1024);

ch1.read(buffer, null, new CompletionHandler<Integer, CompletableFuture<Integer>>() {

public void completed(Integer result, CompletableFuture<Integer> readAtt) {

CompletableFuture<Integer> future = new CompletableFuture<>();

if (result == -1 || buffer.position() == 0) {
// 处理连接关闭的情况或者没有数据可读的情况

try {
readAtt.get(3,TimeUnit.SECONDS);
} catch (Exception e) {
e.printStackTrace();
}

closeChannels(ch1, ch2);
return;
}

buffer.flip();

CompletionHandler readHandler = this;

ch2.write(buffer, future, new CompletionHandler<Integer, CompletableFuture<Integer>>() {
@Override
public void completed(Integer result, CompletableFuture<Integer> writeAtt) {

if (buffer.hasRemaining()) {
// 如果未完全写入,则继续写入
ch2.write(buffer, writeAtt, this);

} else {
writeAtt.complete(1);
// 清空buffer并继续读取
buffer.clear();
if(ch1.isOpen()){
ch1.read(buffer, writeAtt, readHandler);
}
}

}

@Override
public void failed(Throwable exc, CompletableFuture<Integer> attachment) {
if(!(exc instanceof AsynchronousCloseException)){
exc.printStackTrace();
}
closeChannels(ch1, ch2);
}
});

}

public void failed(Throwable exc, CompletableFuture<Integer> attachment) {
if(!(exc instanceof AsynchronousCloseException)){
exc.printStackTrace();
}
closeChannels(ch1, ch2);
}
});

} catch (Exception ex) {
ex.printStackTrace();
closeChannels(ch1, ch2);
}

}

private static void closeChannels(AsynchronousSocketChannel ch1, AsynchronousSocketChannel ch2) {
if (ch1 != null && ch1.isOpen()) {
try {
ch1.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (ch2 != null && ch2.isOpen()) {
try {
ch2.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}

测试


我这边就用虚拟机来测试,用云服务器就比较麻烦,得登录账号,增加开放端口规则,上传代码。我这边用Hyper-V快速创建了虚拟机,创建一个windows 10 MSIX系统,安装JDK8,下载地址:http://www.azul.com/downloads/?… 。怎样把本地编译好的class放到虚拟机呢,虚拟机是可以访问主机ip的,我们可以弄一个web的文件目录下载给虚拟机访问,人生苦短我用pyhton,下面python简单代码


if __name__ == '__main__':
# 定义服务器的端口
PORT = 8000

# 创建请求处理程序
Handler = http.server.SimpleHTTPRequestHandler

# 设置工作目录
os.chdir("C:\netTunnlDemo\client\target")

# 创建服务器
with socketserver.TCPServer(("", PORT), Handler) as httpd:
print(f"服务启动在端口 {PORT}")
httpd.serve_forever()

到class的目录下运行cmd,执行java -cp . org.example.Main,windows 默认远程端口3389。


最后效果


QQ截图20240225075018.png


总结


使用AIO导致代码长,逻辑并不复杂,完整代码,供个人学习:断续/netTunnlDemo (gitee.com)


作者:cloudy491
来源:juejin.cn/post/7338973258895802431
收起阅读 »

小程序使用有赞 UI 库

web
引入有赞 UI 库 1、初始化 npm 在小程序 package.json 所在的目录(代码根目录)中执行下面命令,进行初始化: npm init ps. 这里一路按 enter 键就可以了,命令窗口可以在目录下通过 shift + 鼠标右键选择 Powe...
继续阅读 »

引入有赞 UI 库


1、初始化 npm

在小程序 package.json 所在的目录(代码根目录)中执行下面命令,进行初始化:


npm init 

ps. 这里一路按 enter 键就可以了,命令窗口可以在目录下通过 shift + 鼠标右键选择 PowerShell 打开 。


2、安装 Vant 包

在上面的基础上,输入下面命令,安装有赞 UI 库:


npm i vant-weapp -S --production

如果这里报 rollbackFailedOptional 错误,可以试试修改 npm 的资源镜像链接,输入下面命令:


npm config set registry http://registry.npm.taobao.org

然后再执行上面安装命令,应该就可以了。


在这里插入图片描述


3、使用 npm 模块

点击微信开发者工具右上角详情,选择本地设置,勾选上下面的 “使用 npm 模块”


在这里插入图片描述


4、构建 npm

点击开发者工具中的菜单栏:工具 --> 构建 npm


在这里插入图片描述


5、修改 app.json

将 app.json 中的 "style": "v2" 去除


在这里插入图片描述


6、修改 project.config.json

在根目录下的 project.config.json 文件中,通过 ctrl + f 搜索 packNpmManually ,修改配置,使开发者工具可以正确索引到 npm 依赖的位置。


在这里插入图片描述


改成如下图


在这里插入图片描述
代码如下:


        "packNpmManually": true,
"packNpmRelationList": [
{
"packageJsonPath": "./package.json",
"miniprogramNpmDistDir": "./"
}
],



到这应该就完成安装了,下面看看使用。


使用有赞 UI 库


1、引入控件

在 app.json (或 Page 的 json)中引入控件


"usingComponents": {
"van-button": "@vant/weapp/button/index"
}

2、使用控件

引入组件后,可以在 wxml 中直接使用组件


<van-button type="primary">按钮</van-button>

示例


这里拿一个 Dialog 弹出框作为示例,因为官方文档有问题,在 Page 中引入错了,真的是把我坑到了。


1、引入 Dialog 控件

app.jsonindex.json中引入组件


"usingComponents": {
"van-dialog": "@vant/weapp/dialog/index"
}

2、在 WXML 中设置 Dialog

这里有两种用法,一种是把 Dialog 当布局组件使用,一种是像 wx.showModel 一样弹出对话框,无论哪种都要在 WXML 中写 van-dialog。


这里以后一种用法为例,在 WXML 中随便找个地方,填入下面代码:


<van-dialog id="van-dialog" />

3、在 Page 中使用

先引入组件,就是这里把我坑了,主要就是没有 dist 这个目录了,有赞也不提示。


//import Dialog from 'path/to/@vant/weapp/dist/dialog/dialog';
import Dialog from '../../../miniprogram_npm/@vant/weapp/dialog/dialog';

这里用的相对路径,后面路径是对的,需要直接改下!


在需要的时候像下面一样使用就可以,不过我是觉得还不如微信自带的好看哦!


Dialog.confirm({
title: '标题',
message: '弹窗内容',
})
.then(() => {
// on confirm
})
.catch(() => {
// on cancel
});

4、在 Dialog 中有原生控件

这里提一下,如果 Dialog 中有原生控件,消失的时候原生控件回后消失,很奇怪。例如对话框里面放了一个 canvas 来显示二维码,关闭对话框时,对话框消失了,二维码延迟一会才消失,这时候可以通过变量,先隐藏二维码,再隐藏对话框,有这个思路,就能解决了。


结语


如果不想自己设计各种控件的话,用有赞的 UI 库还是很方便的,但是如果给了设计图,要改这些控件还是有点麻烦。


作者:方大可
来源:juejin.cn/post/7222897518500708407
收起阅读 »

新项目跑不起来,人和项目总得有一个能跑

web
前言 有人说面试造火箭,进去拧螺丝;其实个人觉得问的问题是项目中涉及的点 || 热门的技术栈都是很好的面试体验,不要是旁门左道冷门的知识,实际上并不会用到的。 进入新公司或者接手新项目,都会遇到如何将项目跑起,跑不起的话,可能就得人跑了。 每个人的花期不...
继续阅读 »

前言


  • 有人说面试造火箭,进去拧螺丝;其实个人觉得问的问题是项目中涉及的点 || 热门的技术栈都是很好的面试体验,不要是旁门左道冷门的知识,实际上并不会用到的。

  • 进入新公司或者接手新项目,都会遇到如何将项目跑起,跑不起的话,可能就得人跑了。





每个人的花期不同。


就像万特特在《这世界很好,你也不差》中讲的,每个人花期不同,不必焦虑有人比你提前拥有。



1.jpg


一、问题剖析


那是一个风和日丽的早上,我想要去看漫天霞光。


要和心上人手挽手走在街上,


清晨的花香,傍晚的夕阳。


正当我沉迷于甜蜜的幻想中,前同事发来:我遇到一个问题,帮我看看呗!


真是的,慌慌张张的,说吧~


问题是这样子的:


现在有一个新项目,B项目从A项目复制过去,在原有的功能上扩展。


我又多嘴的问了一句,为啥要复制一份过去啊!


他回道:因为现在要做国际化的需求,之前的项目没有考虑到,改起来很麻烦,打算先拷贝一份出来,通过java程序执行一下,先把大部分的先转化一下,这样子不会影响原本项目的开发。


我又对嘴了一下,开新分支不可以嘛!


他说:因为是给两个地区用的,可能在A页面,两个区的需求不同,改起来也麻烦,需求不同,就没必要硬写在一起了。


懂了!


二、gitlab仓库中有两种方式拉取代码仓库:ssh和https


2.png


ssh


首先,确保你已经在 GitLab 上配置了 SSH 密钥。如果没有,你需要生成 SSH 密钥并将公钥添加到 GitLab 的个人设置中。


https


使用 HTTPS 协议拉取代码需要输入你的 GitLab 用户名和密码,或者是访问令牌(Access Token)。


两者



  • 使用 SSH 协议可以避免频繁输入用户名和密码,但需要提前配置好 SSH 密钥。

  • 使用 HTTPS 协议在拉取代码时需要提供用户名和密码或者访问令牌,相对来说更加方便快捷。


一般来说,都是用https拉取仓库,但我怎么拉都拉不下来。


提示:


SSL certificate problem: self signed certificate

翻译:SSL证书问题:自签名证书

于是,我尝试运行ssh的方式拉取。


我们首先需要先生成秘钥


ssh-keygen -t rsa -C “your_email@youremail.com”  
// 命令中的email,就是gitlab中的账号,需要保持一致

直接三个Enter就行,然后会提示输入密码(可输可不输)


3.png


在~/.ssh/下会生成两个文件,id_rsa和id_rsa.pub


4.png


id_rsa是私钥

id_rsa.pub是公钥

在我们c盘的用户里面有个.ssh文件夹


C:\Users\Lenovo\.ssh

gitlab添加秘钥


5.png


这样子就配置好了,可以通过ssh方式拉取了。


git clone 项目远程仓库ssh地址

项目已拉取。


此时要推送的时候,一直让我输入密码,但我输入完之后,仍然要求输入。


我使用的是tortoisegit


连接gitlab,总是弹出git@xxx.com’s password 对话框


然后打开TortoiseGit设置,如下图进入相应页面,选择相对路径的ssh.exe文件


6.png


查找git在哪里,只需要输入 where git


7.png


到这里项目已经拉取,也能推送了。


接下来运行项目。


三、运行的时候,先安装依赖,发现npm安装失败。


发现npm i


8.png


于是,我尝试cnpm i安装,安装是可以安装,但npm run serve的时候还是报错了。


四、于是,我找同事要来了他本地的依赖,想着在我本地看看能不能跑起来。


但因我和同事的环境(npm、node)环境不一样。


于是,我和他保持一致的版本node:12


但又因为还是有些不同,npm run serve的时候,报了:


error  in ./src/styles/element-variables.scss  
Syntax Error: Error: Missing binary. See message above.

看起来是,sass的问题,于是想着重新安装一下


一般来说sass、node-sass问题经常会出现。


npm install --save-dev sass-loader node-sass

仍然不行,那切换一下node的版本,从node10-node14都轮流切换尝试都不行。


我使用的是nvm管理node版本。


9.png


但点进去看只有10.14.1版本有node_modules文件夹依赖


10.png


其他的版本貌似没有,本来想着和同事保持12版本的node,然后把他的依赖复制给我,但我本地的node12没有node_modules文件夹


关于上面的scss文件引起的:Syntax Error: Error: Missing binary. See message above.


下载fibers


运行的时候报错:而且会弹出框说:中止/忽略(其实是fibers缺少二进制文件执行)


Try running this to fix the issue: D:\Program Files\nodejs\node.exe E:\vue-project\node_modules\fibers/build
Error: Cannot find module 'E:\vue-project\node_modules\fibers\bin\win32-x64-83\fibers'
Require stack:

我们发现了fibers引起的错误


有回答说:


项目node_moudules/fibers/bin文件夹中没有win32-x64-83模块,缺少win32-x64-83文件夹下的fibers.node。


11.png


1.在github下载对应系统版本的node文件


win32-x64-83_binding.node 文件下载地址:github-releases


2.下载后的win32-x64-83_binding.node文件改名为fibers.node。


3.保存fibers.node在项目node_moudules/fibers/bin新建的win32-x64-83文件夹中。


4.然后重新执行run serve就可以


rebuild node-sass

执行


npm rebuild node-sass

如果提示 stack Error: EACCES: permission denied, mkdir 错误,则执行命令:


npm rebuild node-sass --unsafe-perm

失败告终!



但我简单粗暴直接把fibers文件夹给删除重新跑就可以了。(√)



五、后面想了一下,项目的背景,是从A项目复制过来的,那我把A项目的依赖复制过来运行下看看。


复制过来后,可以运行了,但因为B新项目装了vue-i18n国际化依赖,于是我安装一下,运行,终于可以了。


但项目打开页面的时候,还是报错了。


12.webp


看起来,vue-router版本和i18n版本冲突了


我的解决办法是把i18n的版本 改为了 8.26.7 ,再启动项目就可以了


npm install vue-i18n@8.26.7 -S

虽然B项目的package.json写着是版本9的,但这不影响我运行。


只不过在页面用到这个依赖的时候可能会有一些不同。


完成搞定。


后记


那晚,我们一起找了家烧烤店,他说:今日之事,都在酒里了,一口闷。


道不清,理还乱,别是一般滋味在心头~


刚开始接手一个项目的时候,依赖啊,版本啊,什么的都很头疼,但一步一步来,见招拆招,无非就是node版本、npm版本、cnpm他们的故事。


如果有其他更好的方法也欢迎评论区见,这里提供的只是诸多方法之一。



作者:Dignity_呱
来源:juejin.cn/post/7339376488028307456
收起阅读 »

MyBatis实现多行合并(collection标签使用)

一、举个栗子 现有如下表结构,用户表、角色表、用户角色关联表。 一个用户有多个角色,一个角色有可以给多个用户,也即常见的多对多场景。 现有这样一个需求,分页查询用户数据,除了用户ID和用户名称字段,还要查出这个用户的所有角色。 从上面的表格我们可以看出,用...
继续阅读 »

一、举个栗子


现有如下表结构,用户表、角色表、用户角色关联表。
一个用户有多个角色,一个角色有可以给多个用户,也即常见的多对多场景
在这里插入图片描述


现有这样一个需求,分页查询用户数据,除了用户ID和用户名称字段,还要查出这个用户的所有角色
在这里插入图片描述
从上面的表格我们可以看出,用户有三个,但每个人的角色不止一个,而且有重复的角色,这里角色的数据从多行合并到了1行


二、难点分析


SQL存在的问题:



想使用SQL实现上面的效果不是不可以,但是很复杂且效率低下,尤其这个地方还需要分页,所以为了保证查询效率,我们需要把逻辑放到服务端来写;



服务端存在的问题:



服务端可以把需要的数据都查询出来,然后自己判断整合,首先十分复杂不说,而且这里有个问题:如果角色也是一个查询条件如何处理呢?



三、解决方案


核心方案就是使用Mybatis的collection标签自动实现多行合并。


下面是collection标签的一些介绍
在这里插入图片描述


常见写法


<resultMap id="ExtraBaseResultMap" type="com.example.mybatistest.entity.UserInfoDO">
<!--
WARNING - @mbg.generated
-->

<result column="user_id" jdbcType="INTEGER" property="userId"/>
<result column="user_name" jdbcType="INTEGER" property="userName"/>
<collection javaType="java.util.ArrayList" ofType="com.example.mybatistest.entity.MyRole"
property="roleList">

<result column="role_id" jdbcType="INTEGER" property="roleId"/>
<result column="role_name" jdbcType="VARCHAR" property="roleName"/>
</collection>
</resultMap>

四、尝试一下


1. 准备材料


(1)数据库脚本


/*
Navicat Premium Data Transfer

Source Server : 本机
Source Server Type : MySQL
Source Server Version : 80021
Source Host : localhost:3306
Source Schema : mybatis-test

Target Server Type : MySQL
Target Server Version : 80021
File Encoding : 65001

Date: 23/06/2022 19:16:34
*/


SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for my_role
-- ----------------------------
DROP TABLE IF EXISTS `my_role`;
CREATE TABLE `my_role` (
`role_id` int NOT NULL COMMENT '角色主键',
`role_code` varchar(32) DEFAULT NULL COMMENT '角色code',
`role_name` varchar(32) DEFAULT NULL COMMENT '角色名称',
PRIMARY KEY (`role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

-- ----------------------------
-- Records of my_role
-- ----------------------------
BEGIN;
INSERT INTO `my_role` VALUES (1, 'admin', '超级管理员');
INSERT INTO `my_role` VALUES (2, 'visitor', '游客');
COMMIT;

-- ----------------------------
-- Table structure for my_user
-- ----------------------------
DROP TABLE IF EXISTS `my_user`;
CREATE TABLE `my_user` (
`user_id` int NOT NULL COMMENT '用户主键',
`user_name` varchar(32) DEFAULT NULL COMMENT '用户名称',
`user_gender` tinyint DEFAULT NULL COMMENT '用户性别,1:男/2:女/3:未知',
PRIMARY KEY (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

-- ----------------------------
-- Records of my_user
-- ----------------------------
BEGIN;
INSERT INTO `my_user` VALUES (1, '用户1', 1);
COMMIT;

-- ----------------------------
-- Table structure for my_user_role_rel
-- ----------------------------
DROP TABLE IF EXISTS `my_user_role_rel`;
CREATE TABLE `my_user_role_rel` (
`rel_id` int NOT NULL COMMENT '角色主键',
`role_id` int DEFAULT NULL COMMENT '角色ID',
`user_id` int DEFAULT NULL COMMENT '用户ID',
PRIMARY KEY (`rel_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

-- ----------------------------
-- Records of my_user_role_rel
-- ----------------------------
BEGIN;
INSERT INTO `my_user_role_rel` VALUES (1, 1, 1);
INSERT INTO `my_user_role_rel` VALUES (2, 2, 1);
COMMIT;

SET FOREIGN_KEY_CHECKS = 1;


(2)pom.xml


<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">

<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.0</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>mybatis-test</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>mybatis-test</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.16.22</version>
</dependency>
<!-- 我这里使用的是mybatis-plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.3.2</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-extension</artifactId>
<version>3.3.2</version>
</dependency>
<dependency>
<groupId>javax.persistence</groupId>
<artifactId>javax.persistence-api</artifactId>
</dependency>

<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>

</project>


(3)application.properties


# 数据库配置
spring.datasource.type=com.zaxxer.hikari.HikariDataSource
spring.datasource.url=jdbc:mysql://localhost:3306/mybatis-test?characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true
spring.datasource.username=root
spring.datasource.password=xxx
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

# mybatis
mybatis.configuration.auto-mapping-behavior=full
mybatis.configuration.map-underscore-to-camel-case=true
mybatis-plus.mapper-locations=classpath*:/mybatis/mapper/*.xml

2. 项目代码


(1)目录结构


在这里插入图片描述


(2)各类代码


MybatisTestApplication.java


import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
@MapperScan({"com.example.mybatistest.mapper"})
public class MybatisTestApplication {

public static void main(String[] args) {
SpringApplication.run(MybatisTestApplication.class, args);
}

}


QueryController.java


import com.example.mybatistest.entity.UserInfoDO;
import com.example.mybatistest.service.UserRoleRelService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
@RequestMapping("/mybatis")
public class QueryController {

@Autowired
private UserRoleRelService userRoleRelService;

@GetMapping("/queryList")
public List<UserInfoDO> queryList() {
return userRoleRelService.queryList();
}
}

UserRoleRelService.java


import com.example.mybatistest.entity.UserInfoDO;

import java.util.List;

public interface UserRoleRelService {
List<UserInfoDO> queryList();
}

UserRoleRelServiceImpl.java


import com.example.mybatistest.entity.UserInfoDO;
import com.example.mybatistest.repository.MyUserRoleRelRepository;
import com.example.mybatistest.service.UserRoleRelService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class UserRoleRelServiceImpl implements UserRoleRelService {

@Autowired
private MyUserRoleRelRepository myUserRoleRelRepository;

@Override
public List<UserInfoDO> queryList() {
return myUserRoleRelRepository.queryList();
}
}

MyUserRoleRelRepository.java


import com.example.mybatistest.entity.UserInfoDO;

import java.util.List;

public interface MyUserRoleRelRepository {
List<UserInfoDO> queryList();
}

MyUserRoleRelRepositoryImpl.java


import com.example.mybatistest.entity.UserInfoDO;
import com.example.mybatistest.mapper.MyUserRoleRelMapper;
import com.example.mybatistest.repository.MyUserRoleRelRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public class MyUserRoleRelRepositoryImpl implements MyUserRoleRelRepository {
@Autowired
public MyUserRoleRelMapper myUserRoleRelMapper;

@Override
public List<UserInfoDO> queryList() {
return myUserRoleRelMapper.queryList();
}
}

MyUserRoleRelMapper.java


import com.example.mybatistest.entity.UserInfoDO;
import org.apache.ibatis.annotations.Mapper;

import java.util.List;

@Mapper
public interface MyUserRoleRelMapper {
List<UserInfoDO> queryList();
}

MyRole.java


import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.Column;


@Builder
@Data
@TableName("my_role")
@NoArgsConstructor
@AllArgsConstructor
public class MyRole {

@Column(name = "role_id")
private Integer roleId;

@Column(name = "role_name")
private String roleName;
}

MyUserRoleRel.java


import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.Column;

@Builder
@Data
@TableName("my_user_role_rel")
@NoArgsConstructor
@AllArgsConstructor
public class MyUserRoleRel {

@Column(name = "rel_id")
private Integer relId;

@Column(name = "user_id")
private Integer userId;

@Column(name = "role_id")
private Integer roleId;
}

UserInfoDO.java


import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.List;

@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserInfoDO {
private Integer userId;

private String userName;

private List<MyRole> roleList;
}

MyUserRoleRelMapper.xml


<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.mybatistest.mapper.MyUserRoleRelMapper">
<resultMap id="BaseResultMap" type="com.example.mybatistest.entity.MyUserRoleRel">
<!--
WARNING - @mbg.generated
-->

<result column="rel_id" jdbcType="INTEGER" property="relId"/>
<result column="role_id" jdbcType="INTEGER" property="roleId"/>
<result column="user_id" jdbcType="INTEGER" property="userId"/>
</resultMap>
<resultMap id="ExtraBaseResultMap" type="com.example.mybatistest.entity.UserInfoDO">
<!--
WARNING - @mbg.generated
-->

<result column="user_id" jdbcType="INTEGER" property="userId"/>
<result column="user_name" jdbcType="INTEGER" property="userName"/>
<collection javaType="java.util.ArrayList" ofType="com.example.mybatistest.entity.MyRole"
property="roleList">

<result column="role_id" jdbcType="INTEGER" property="roleId"/>
<result column="role_name" jdbcType="VARCHAR" property="roleName"/>
</collection>
</resultMap>
<select id="queryList" resultMap="ExtraBaseResultMap">
SELECT
t3.user_id,
t3.user_name,
t2.role_id,
t2.role_name
FROM
my_user_role_rel t1
LEFT JOIN my_role t2 ON t1.role_id = t2.role_id
LEFT JOIN my_user t3 ON t1.user_id = t3.user_id
</select>
</mapper>

3. 实现效果


在这里插入图片描述


这里可以看到roleList里面有两条数据,说明mybatis已经自动聚合完成了。


4. 一些缺点


缺点1、查询条件不能支持很多


虽然Mybatis可以帮我们实现多行合并的功能,但并不是没有问题的。
当使用角色当做查询条件时,由于角色已经指定了,那么roleList里面必定只有这一个角色,不再会有聚合效果,也就看不到这个用户所有的角色了。
我只能说具体看产品要求吧,大部分时候上面那种问题产品都是可以接受的。


缺点2、不支持分页


这个缺点也看业务场景吧,产品可以接受就用,不能接受就别用,我这里只是介绍有这么一个办法。


作者:summo
来源:juejin.cn/post/7337849561479708735
收起阅读 »

微服务下,如何实现多设备同时登录或强制下线?

分享技术,用心生活 前言:你有没有遇到过这样的需求,产品要求实现同一个用户根据后台设置允许同时登录,或者不准同时登录时,需要强制踢下线前一个的场景。本文将带领大家实现一个简单的这种场景需求。 先来看一下简单的时序图,方便后续理解。 sequenceDi...
继续阅读 »

分享技术,用心生活





前言:你有没有遇到过这样的需求,产品要求实现同一个用户根据后台设置允许同时登录,或者不准同时登录时,需要强制踢下线前一个的场景。本文将带领大家实现一个简单的这种场景需求。





先来看一下简单的时序图,方便后续理解。


sequenceDiagram
用户->>过滤器: 请求登录
过滤器->>业务系统: 是否允许同时登录
业务系统-->>过滤器: 返回是/否
过滤器-->>用户: 登录成功(踢下线)

首先我们需要有一个后台设置开关来控制允不允许用户多设备同时登录的功能(没有也无妨,假定允许),其次在登录后,需要保存用户的userId-token的关系缓存。再回头看上面的时序图,是不是已经能理解实现的原理了。


如果你的架构是微服务,那么可以使用redis来存登录关系缓存,单体架构可以直接存session即可。本文是微服务架构,所以采用的是redis。


本文的前提都是基于同一个用户的情况下,下文不再赘述。


1 构造登录缓存关系


如果要实现同一用户多设备同时登录,那必然需要在session(微服务中可以用redis做session共享)中能找到用户的每一个登录状态,如果只是简单的缓存用户信息是实现不了的,登录时那就必须要有一个唯一值token,这样每次登录token不一样,但是指向的用户是同一个。


user


usertoken中维护的是前缀:用户id,这里不需要维护多个,因为用的reids的hash数据类型,多个登录时,添加新行即可;user部分,这里维护的是多个,即登录一次就有一条记录;因为根据业务需要,后续需要从缓存中获取用户其他信息。



  • 允许多设备同时登录:usertoken只有1条,user可能会有多条

  • 不允许多设备同时登录(有则强制下线):usertoken只有1条,user只有1条


    /**
* 登录成功后缓存用户信息
*
* @param req
* @return
*/

public void cacheUserInfo(CacheUserInfoReqDTO req) {
// 1、缓存用户信息
cacheUser(req);
cacheAuth(req.getUid(), req.getRoles(), req.getPermissions());

// 2、更新token与userId关系
String userTokenRelationKey = RedisKeyHelper.getUserTokenRelationKey(req.getEntId() + SymbolConstant.COLON + req.getUid());
redisAdapter.set(userTokenRelationKey, req.getToken(), RedisTtl.USER_LOGIN_SUCCESS);
}

2 过滤器配置


登录鉴权部分和用户登录状态上下文不在本文范围内,此处忽略


登录成功后,每一个请求到达过滤器时,通过请求header中的token来获取登录信息;因为我们存的缓存key前缀都包含userId,所以要想得到用户信息,需要使用到redis的scan命令来获取。(scan最好配置count来限制,保证性能


@Override
protected Mono<Void> innerFilter(ServerWebExchange exchange, WebFilterChain chain) {
String token = filterContext.getToken();
if (StringUtils.isBlank(token)) {
throw new DataValidateException(GatewayReturnCodes.TOKEN_MISSING);
}

// scan获取user的key
String userKey = "";
Set<String> scan = redisAdapter.scan(GatewayRedisKeyPrefix.USER_KEY.getKey() + "*" + token);
if (scan.isEmpty()) {
throw new DataValidateException(GatewayReturnCodes.TOKEN_EXPIRED_LOGIN_SUCCESS);
}
userKey = scan.iterator().next();

MyUser myUser = (MyUser) redisAdapter.get(userKey);
if (myUser == null) {
throw new BusinessException(GatewayReturnCodes.TOKEN_EXPIRED_LOGIN_SUCCESS);
}

// 将用户信息塞入http header
// do something...
return chain.filter(exchange.mutate().request(newServerHttpRequest).build());
}

这样保证即使有多设备同时登录,也能获取到登录信息和上下文。


3 如何做强制下线呢?


其实也很简单,在登录前可以通过AOP方式做校验,如果已登录了,那么这里就清除session或用户缓存,再继续进行正常登录即可。再简单一点可以直接在登录service中添加校验


核心逻辑


 String userTokenRelationKey = RedisKeyHelper.getUserTokenRelationKey(req.getEntId() + SymbolConstant.COLON + userEntList.get(0).getUserId());
String redisToken = (String) redisAdapter.get(userTokenRelationKey);
if (StringUtils.isNotEmpty(redisToken) && !redisToken.equals(token)) {
throw new BusinessException(UserReturnCodes.MULTI_DEVICE_LOGIN);
}

这里用于判断是否已有登录,并返回给前端提示。用于前端其他业务处理
如果不需要给前端提示,不用返回前端,直接进行清除session或用户缓存逻辑。


 String userTokenRelationKey = RedisKeyHelper.getUserTokenRelationKey(req.getEntId() + SymbolConstant.COLON + userEntity.getId());
// 获取当前已用户的登录token
String redisToken = (String) redisAdapter.get(userTokenRelationKey);
// 踢下线之前全部登录
Response<Void> exitLoginResponse = gatewayRpc.allExit(ExitLoginReqDTO.builder().token(redisToken).userId(userEntity.getId()).build());

4 演示



  • 演示强制下线


这里我用用户id为4做演示


先正常第一次登录,提示成功,并且redis中有1条user记录
redis_succ
redis_succ


再次登录,我这里是返回给前端处理了,所以会有提示信息。


login


前端效果


message


最后,扩展一下,如果要实现登录后强制修改默认密码、登录时间段限制等场景,你会怎么实现呢?


作者:临时工
来源:juejin.cn/post/7258155447831920700
收起阅读 »

前端更新部署后通知用户刷新

web
前言 周五晚上组里说前端有bug,正在吃宵夜的我眉头一紧,立即打开了钉钉(手贱...),看了一下这不是前几天刚解决的吗,果然,使用刷新大法就解决,原因不过是用户一直停留在页面上,新的版本发布后,没有刷新拿不到新的资源。 现在大部分的前端系统都是SPA,用户在使...
继续阅读 »

前言


周五晚上组里说前端有bug,正在吃宵夜的我眉头一紧,立即打开了钉钉(手贱...),看了一下这不是前几天刚解决的吗,果然,使用刷新大法就解决,原因不过是用户一直停留在页面上,新的版本发布后,没有刷新拿不到新的资源。


现在大部分的前端系统都是SPA,用户在使用中对系统更新无感知,切换菜单等并不能获取最新资源,如果前端是覆盖性部署,切换菜单请求旧资源,这个旧资源已经被覆盖(hash打包的文件),还会出现一直无响应的情况。


那么,当前端部署更新后,提示一直停留在系统中的用户刷新系统很有必要。


解决方案



  1. 在public文件夹下加入manifest.json文件,记录版本信息

  2. 前端打包的时候向manifest.json写入当前时间戳信息

  3. 在入口JS引入检查更新的逻辑,有更新则提示更新

    • 路由守卫router.beforeResolve(Vue-Router为例),检查更新,对比manifest.json文件的响应头Etag判断是否有更新

    • 通过Worker轮询,检查更新,对比manifest.json文件的响应头Etag判断是否有更新。当然你如果不在乎这点点开销,可不使用Worker另开一个线程




Public下的加入manifest.json文件


{
"timestamp":1706518420707,
"msg":"更新内容如下:\n--1.添加系统更新提示机制"
}

这里如果是不向用户提示更新内容,可不填,前段开发者也无需维护manifest.json的msg内容,这里主要考虑到如果用户在填长表单的时候,填了一大半,你这时候给用户弹个更新提示,用户无法判断是否影响当前表单填写提交,如果将更新信息展示出来,用户感知更新内容,可判断是否需要立即刷新,还是提交完表单再刷新。


webpack向manifest.json写入当前时间戳信息


	// 版本号文件
const filePath = path.resolve(`./public`, 'manifest.json')
// 读取文件内容
readFile(filePath, 'utf8', (err, data) => {
if (err) {
console.error('读取文件时出错:', err)
return
}
// 将文件内容转换JSON
const dataObj = JSON.parse(data)
dataObj.timestamp = new Date().getTime()
// 将修改后的内容写回文件
writeFile(filePath, JSON.stringify(dataObj), 'utf8', err => {
if (err) {
console.error('写入文件时出错:', err)
return
}
})
})

如果你无需维护更新内容的话,可直接写入timestamp


// 生成版本号文件
const filePath = path.resolve(`./public`, 'manifest.json')
writeFileSync(filePath, `${JSON.stringify({ timestamp: new Date().getTime() })}`)


检查更新的逻辑


入口文件main.js处引入


我这里检查更新的文件是放在utils/checkUpdate


// 检查版本更新
import '@/utils/checkUpdate'

checkUpdate文件内容如下


import router from '@/router'
import { Modal } from 'ant-design-vue'
if (process.env.NODE_ENV === 'production') {
let lastEtag = ''
let hasUpdate = false
let worker = null

async function checkUpdate() {
try {
// 检测前端资源是否有更新
let response = await fetch(`/manifest.json?v=${Date.now()}`, {
method: 'head'
})
// 获取最新的etag
let etag = response.headers.get('etag')
hasUpdate = lastEtag && etag !== lastEtag
lastEtag = etag
} catch (e) {
return Promise.reject(e)
}
}

async function confirmReload(msg = '', lastEtag) {
worker &&
worker.postMessage({
type: 'pause'
})
try {
Modal.confirm({
title: '温馨提示',
content: '系统后台有更新,请点击“立即刷新”刷新页面\n' + msg,
okText: '立即刷新',
cancelText: '5分钟后提示我',
onOk() {
worker.postMessage({
type: 'destroy'
})
location.reload()
},
onCancel() {
worker &&
worker.postMessage({
type: 'recheck',
lastEtag: lastEtag
})
}
})
} catch (e) {}
}

// 路由拦截
router.beforeResolve(async (to, from, next) => {
next()
try {
await checkUpdate()
if (hasUpdate) {
worker.postMessage({
type: 'destroy'
})
location.reload()
}
} catch (e) {}
})

// 利用worker轮询
worker = new Worker(
/* webpackChunkName: "checkUpdate.worker" */ new URL('../worker/checkUpdate.worker.js', import.meta.url)
)

worker.postMessage({
type: 'check'
})
worker.onmessage = ({ data }) => {
if (data.type === 'hasUpdate') {
hasUpdate = true
confirmReload(data.msg, data.lastEtag)
}
}
}


这里因为缺换路由本来就要刷新页面,用户可无需感知系统更新信息,直接通过请求头的Etag即可,这里的Fetch方法就用head获取相应头就好了。


checkUpdate.worker.js文件如下


let lastEtag
let hasUpdate = false
let intervalId = ''
async function checkUpdate() {
try {
// 检测前端资源是否有更新
let response = await fetch(`/manifest.json?v=${Date.now()}`, {
method: 'get'
})
// 获取最新的etag和data
let etag = response.headers.get('etag')
let data = await response.json()
hasUpdate = lastEtag !== undefined && etag !== lastEtag
if (hasUpdate) {
postMessage({
type: 'hasUpdate',
msg: data.msg,
lastEtag: lastEtag,
etag: etag
})
}
lastEtag = etag
} catch (e) {
return Promise.reject(e)
}
}

// 监听主线程发送过来的数据
addEventListener('message', ({ data }) => {
if (data.type === 'check') {
// 每5分钟执行一次
// 立即执行一次,获取最新的etag,避免在setInterval等待中系统更新,第一次获取的etag是新的,但是lastEtag还是undefined,不满足条件,错失刷新时机
checkUpdate()
intervalId = setInterval(checkUpdate,5 * 60 * 1000)
}
if (data.type === 'recheck') {
// 每5分钟执行一次
hasUpdate = false
lastEtag = data.lastEtag
intervalId = setInterval(checkUpdate, 5 * 60 * 1000)
}
if (data.type === 'pause') {
clearInterval(intervalId)
}
if (data.type === 'destroy') {
clearInterval(intervalId)
close()
}
})


如果不使用worker直接讲轮询逻辑放在checkUpdate即可


Worker引入


从 webpack 5 开始,你可以使用 Web Workers 代替 worker-loader


new Worker(new URL('./worker.js', import.meta.url));

以下版本的就只能用worker-loader


也可以逻辑写成字符串,然后通过ToURL给new Worker,如下:


function createWorker(f) {
const blob = new Blob(['(' + f.toString() +')()'], {type: "application/javascript"});
const blobUrl = window.URL.createObjectURL(blob);
const worker = new Worker(blobUrl);
return worker;
}

createWorker(function () {
self.addEventListener('message', function (event) {
// 消费信息
self.postMessage('send message')
}, false);
})


worker数据通信



// 主线程
var uInt8Array = new Uint8Array(new ArrayBuffer(10));
for (var i = 0; i < uInt8Array.length; ++i) {
 uInt8Array[i] = i * 2; // [0, 2, 4, 6, 8,...]
}
worker.postMessage(uInt8Array);
// Worker 线程
self.onmessage = function (e) {
 var uInt8Array = e.data;
 postMessage('Inside worker.js: uInt8Array.toString() = ' + uInt8Array.toString());
 postMessage('Inside worker.js: uInt8Array.byteLength = ' + uInt8Array.byteLength);
};


但是,拷贝方式发送二进制数据,会造成性能问题。比如,主线程向 Worker 发送一个 500MB 文件,默认情况下浏览器会生成一个原文件的拷贝。为了解决这个问题,JavaScript 允许主线程把二进制数据直接转移给子线程,但是一旦转移,主线程就无法再使用这些二进制数据了,这是为了防止出现多个线程同时修改数据的麻烦局面。这种转移数据的方法,叫做Transferable Objects。这使得主线程可以快速把数据交给 Worker,对于影像处理、声音处理、3D 运算等就非常方便了,不会产生性能负担。


如果要直接转移数据的控制权,就要使用下面的写法。


// Transferable Objects 格式
worker.postMessage(arrayBuffer, [arrayBuffer]);

// 例子
var ab = new ArrayBuffer(1);
worker.postMessage(ab, [ab]);


然而,并不是所有的对象都可以被转移。只有那些被设计为可转移的对象(用[ Transferable ] IDL 扩展属性修饰),比如ArrayBuffer、MessagePort,ImageBitmap,OffscreenCanvas,才能通过这种方式来传递。转移操作是不可逆的,一旦对象被转移,原始上下文中的引用将不再有效。转移对象可以显著减少复制数据所需的时间和内存。


作者:Zayn
来源:juejin.cn/post/7329280514628534313
收起阅读 »

决定了,转产品经理啦。

今天来聊一下产品经理这个话题,掘金上有一个读者私信问,对代码不是特别有天赋,想转行产品经理可行吗?前景如何? 那刚好之前有另外一个读者转了产品经理,女生,关键是她对当前的薪资还非常满意,“撩”天起来也特别丝滑(😂)。 那希望小姐姐的分享,能给大家带来一些启发...
继续阅读 »

今天来聊一下产品经理这个话题,掘金上有一个读者私信问,对代码不是特别有天赋,想转行产品经理可行吗?前景如何?


那刚好之前有另外一个读者转了产品经理,女生,关键是她对当前的薪资还非常满意,“撩”起来也特别丝滑(😂)。



那希望小姐姐的分享,能给大家带来一些启发和帮助🤔,尤其是 25 届 26 届,以及打算转行产品的小伙伴,注意啦。


一、你觉得找工作最关键的一环是什么?


首先首先,要有一份看的过去的简历,这里有个区别就是,如果你是 985 那种好学校没有实习经历投什么公司都可以。


但是如果你是双非一本二本之类的学校,没有实习经历就别去投大公司了,先把简历写好投个 500 人以内的小公司实习着。



7-10 月份之间挑两个月实习着,然后是多看网上的(牛客网,b 站,知乎产品面经非常多,b 站模拟面试视频也挺多的)一些面经,熟悉面试官的套路,懂得面试中常问的问题及思路。


深刻总结自己大学三年经历(实习,成绩,获奖经历),在反思自己的各个经历时,一定要有条理的、详尽的把自己的经历描述清楚,简历上写概要,但是当面试官问到你其中经历时,一定要能条理清晰地表述出来;


之后是在面试过程中根据自己的面试经验,总结面试官们对自己简历的关注重点,多次改版


Boss 直聘投递简历,大量投递简历(boss 记录我总共沟通过的 hr 是 982 位,目总共投递了 196 份简历,视频面试 23 家),说真的产品面试多面几次你就知道哪些问题会经常问了,面试期间录音,面试结束后立刻记录并分析面试过程中的问题,并不断反思自己的不足。


二、学生阶段对找工作最有用的是什么?


大一大二大三在学校都没怎么学习,基本上都是班级倒数前十,我记得上 Java 课那个老师每次都叫全班倒数前十坐第一排,所以我的学习是真的很差。


但是我这个人性格活泼开朗有社交牛杂证,喜欢玩各种 app,喜欢上网,性格内向的女生做产品不太推荐,产品面试有的时候还是要跟面试官画大饼的,要敢说敢想敢画饼。


三、自学了哪些课程?


纯纯靠在 b 站搜索产品经理学习,看视频自己总结复盘,做笔记。


四、大学四年过得怎么样?


大学前三年都在谈恋爱吧,反正没学习,不过也没挂过科,就是普通女生,后来失恋了才知道要好好考虑未来了。其实我也就是大三暑假才开始学习的,计算机专业应届生想面试产品实习好好学一个月都能行!



五、为什么走上了这条路?


因为对于我来说,计算机本身不喜爱,软件学习不好,不能硬去做软件开发,这样坐下来自己就很痛苦,软件技术就把自己的发展方向卡的死死的,转行产品经理是计算机专业学生一个更好的方向。



六、有去实习吗?


我是 9 月份才实习的,说实话有点晚了,你们最好别像我一样这么晚,那个时候找的是一个 300 多人的小公司实习,公司做的是 B 端的产品,我和带我那个导师玩的很好。


我当时跟她做一个她全程负责已经上线的项目,项目资料啥的都给我了,但是她把项目从头到尾细节都给我讲了十几遍,所以我能把这个项目完全梳理清楚。


还有产品经理的特质要求你必须要善于思考,所以虽然你做的事可能是基层的工作,但是你在整个过程中不断思考,这点是面试官比较看重的。


七、为什么选择了产品岗做了哪些准备?


本科双非软件工程专业女生,从大一开始就知道自己并不喜欢技术岗,也做不来,真正明确目标是在大三暑假,在 b 站刷到有关计算机专业做产品的视频,后来仔细了解了产品经理的相关工作,就想尝试入行。



八、推荐 B 站学习资料/或者其他资料?


B 站学习资料其实一搜产品经理学习会有一大堆,我觉得每个 up 主的视频都可以看看,另外可以关注一下人人都是产品经理这个网站,这个也可以下载 app,还有关注一些写的比较好的产品公众号,经常浏览一下培养产品思维。


九、有什么好的学习建议吗?


我觉得要多加一些产品交流学习群,因为一个人可以走得很快,但是一群人可以走得更远,一个人自学的同时也要看看别人的情况,多和未来要做产品以及现在已经做产品的人做朋友,在面试这块还能一起模拟一下。



十、工作后和学校时最大的区别?


工作后和在学校时最大的差别就是,工作后你是赚钱,而在学校你是花钱。


说真的我领第一个月实习工资的时候虽然只有 4000,但是我很开心,第二家公司实习 7000 的时候我存款都一万多了,感觉工作赚钱真好。


十一、工作后的心得体会?


其实工作后和大学时还是有一点比较相似的。


那就是“没人真正关心你的个人发展”。


你就是你,除了你自己和你的父母外,没有人会真正在意你的未来发展,你是出人头地还是泯然众人。每个人都很忙的,所以你要自己好好在意自己,自己去时常反省,自己去寻找资源学习提升,自己好好整自己的简历。



十二、对未来的展望?


因为我第一段实习和目前这家公司都是做 B 端产品的,自己本人也喜欢 B 端产品,我未来产品发展方向肯定是往 B 端走的,希望能在毕业后 3-5 年成长为高级产品经理。


十三、计算机专业做产品的优势?


很多产品招聘要求都是计算机相关专业优先,这一点在产品面试里面可以突出重点。懂一点技术这样可以和技术人员更好的沟通交流,解决 bug。



十四、有什么祝福语吗?


提前祝大家龙年暴富,也祝二哥掘金上早日百万粉。


希望每个和我一样同是计算机专业但是不想做开发的兄弟姐妹都不要放弃!可以来找我一起学习交流产品面试经验哦。


作者:沉默王二
来源:juejin.cn/post/7330933094159908916
收起阅读 »

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

你好呀,我是歪歪。 前几天我在网上冲浪,看到一个哥们在吐槽,说他工作三年多了,没使用过多线程。 虽然八股文背的滚瓜烂熟,但是没有在实际开发过程中写的都是业务代码,没有使用过线程池,心里还是慌得一比。 我只是微微一笑,这不是很正常吗? 业务代码中一般也使不上多线...
继续阅读 »

你好呀,我是歪歪。


前几天我在网上冲浪,看到一个哥们在吐槽,说他工作三年多了,没使用过多线程。


虽然八股文背的滚瓜烂熟,但是没有在实际开发过程中写的都是业务代码,没有使用过线程池,心里还是慌得一比。


我只是微微一笑,这不是很正常吗?


业务代码中一般也使不上多线程,或者说,业务代码中不知不觉你以及在使用线程池了,你再 duang 的一下搞一个出来,反而容易出事。


所以提到线程池的时候,我个人的观点是必须把它吃得透透的,但是在业务代码中少用或者不用多线程。


关于这个观点,我给你盘一下。


Demo


首先我们还是花五分钟搭个 Demo 出来。


我手边刚好有一个之前搭的一个关于 Dubbo 的 Demo,消费者、生产者都有,我就直接拿来用了:



这个 Demo 我也是跟着网上的 quick start 搞的:



cn.dubbo.apache.org/zh-cn/overv…




可以说写的非常详细了,你就跟着官网的步骤一步步的搞就行了。


我这个 Demo 稍微不一样的是我在消费者模块里面搞了一个 Http 接口:



在接口里面发起了 RPC 调用,模拟从前端页面发起请求的场景,更加符合我们的开发习惯。


而官方的示例中,是基于了 SpringBoot 的 CommandLineRunner 去发起调用:



只是发起调用的方式不一样而已,其他没啥大区别。


需要说明的是,我只是手边刚好有一个 Dubbo 的 Demo,随手就拿来用了,但是本文想要表达的观点,和你使不使用 Dubbo 作为 RPC 框架,没有什么关系,道理是通用的。


上面这个 Demo 启动起来之后,通过 Http 接口发起一次调用,看到控制台服务提供方和服务消费方都有对应的日志输出,准备工作就算是齐活儿了:



上菜


在上面的 Demo 中,这是消费者的代码:



这是提供者的代码:



整个调用链路非常的清晰:



来,请你告诉我这里面有线程池吗?


没有!


是的,在日常的开发中,我就是写个接口给别人调用嘛,在我的接口里面并没有线程池相关的代码,只有 CRUD 相关的业务代码。


同时,在日常的开发中,我也经常调用别人提供给我的接口,也是一把梭,撸到底,根本就不会用到线程池。


所以,站在我,一个开发人员的角度,这个里面没有线程池。


合理,非常合理。


但是,当我们换个角度,再看看,它也是可以有的。


比如这样:



反应过来没有?


我们发起一个 Http 调用,是由一个 web 容器来处理这个请求的,你甭管它是 Tomcat,还是 Jetty、Netty、Undertow 这些玩意,反正是个 web 容器在处理。


那你说,这个里面有线程池吗?


在方法入口处打个断点,这个 http-nio-8081-exec-1 不就是 Tomcat 容器线程池里面的一个线程吗:



通过 dump 堆栈信息,过滤关键字可以看到这样的线程,在服务启动起来,啥也没干的情况下,一共有 10 个:



朋友,这不就是线程池吗?


虽然不是你写的,但是你确实用了。


我写出来的这个 test 接口,就是会由 web 容器中的一个线程来进行调用。所以,站在 web 容器的角度,这里是有一个线程池的:



同理,在 RPC 框架中,不管是消费方,还是服务提供方,也都存在着线程池。


比如 Dubbo 的线程池,你可以看一下官方的文档:



cn.dubbo.apache.org/zh-cn/overv…




而对于大多数的框架来说,它绝不可能只有一个线程池,为了做资源隔离,它会启用好几个线程池,达到线程池隔离,互不干扰的效果。


比如参与 Dubbo 一次调用的其实不仅一个线程池,至少还有 IO 线程池和业务线程池,它们各司其职:



我们主要关注这个业务线程池。


反正站在 Dubbo 框架的角度,又可以补充一下这个图片了:



那么问题来了,在当前的这个情况下?


当有人反馈:哎呀,这个服务吞吐量怎么上不去啊?


你怎么办?


你会 duang 的一下在业务逻辑里面加一个线程池吗?



大哥,前面有个 web 容器的线程池,后面有个框架的线程池,两头不调整,你在中间加个线程池,加它有啥用啊?


web 容器,拿 Tomcat 来说,人家给你提供了线程池参数调整的相关配置,这么一大坨配置,你得用起来啊:



tomcat.apache.org/tomcat-9.0-…




再比如 Dubbo 框架,都给你明说了,这些参数属于性能调优的范畴,感觉不对劲了,你先动手调调啊:



你把这些参数调优弄好了,绝对比你直接怼个线程池在业务代码中,效果好的多。


甚至,你在业务代码中加入一个线程池之后,反而会被“反噬”。


比如,你 duang 的一下怼个线程池在这里,我们先只看 web 容器和业务代码对应的部分:



由于你的业务代码中有线程池的存在,所以当接受到一个 web 请求之后,立马就把请求转发到了业务线程池中,由线程池中的线程来处理本次请求,从而释放了 web 请求对应的线程,该线程又可以里面去处理其他请求。


这样来看,你的吞吐量确实上去了。


在前端来看,非常的 nice,请求立马得到了响应。


但是,你考虑过下游吗?


你的吞吐量上涨了,下游同一时间处理的请求就变多了。如果下游跟不上处理,顶不住了,直接就是崩给你看怎么办?



而且下游不只是你一个调用方,由于你调用的太猛,导致其他调用方的请求响应不过来,是会引起连锁反应的。


所以,这种场景下,为了异步怼个线程池放着,我觉得还不如用消息队列来实现异步化,顶天了也就是消息堆积嘛,总比服务崩了好,这样更加稳妥。


或者至少和下游勾兑一下,问问我们这边吞吐量上升,你们扛得住不。


有的小伙伴看到这里可能就会产生一个疑问了:歪师傅,你这个讲得怎么和我背的八股文不一样啊?


巧了,你背过的八股文我也背过,现在我们来温习一下我们背过的八股文。


什么时候使用线程池呢?


比如一个请求要经过若干个服务获取数据,且这些数据没有先后依赖,最终需要把这些数据组合起来,一并返回,这样经典的场景:



用户点商品详情,你要等半天才展示给用户,那用户肯定骂骂咧咧的久走了。


这个时候,八股文上是怎么说的:用线程池来把串行的动作改成并行。



这个场景也是增加了服务 A 的吞吐量,但是用线程池就是非常正确的,没有任何毛病。


但是你想想,我们最开始的这个案例,是这个场景吗?



我们最开始的案例是想要在业务逻辑中增加一个线程池,对着一个下游服务就是一顿猛攻,不是所谓的串行改并行,而是用更多的线程,带来更多的串行。


这已经不是一个概念了。


还有一种场景下,使用线程池也是合理的。


比如你有一个定时任务,要从数据库中捞出状态为初始化的数据,然后去调用另外一个服务的接口查询数据的最终状态。



如果你的业务代码是这样的:


//获取订单状态为初始化的数据(0:初始化 1:处理中 2:成功 3:失败)
//select * from order where order_status=0;
ArrayList initOrderInfoList = queryInitOrderInfoList();
//循环处理这批数据
for(OrderInfo orderInfo : initOrderInfoList){
    //捕获异常以免一条数据错误导致循环结束
    try{
        //发起rpc调用
        String orderStatus = queryOrderStatus(orderInfo.getOrderId);
        //更新订单状态
        updateOrderInfo(orderInfo.getOrderId,orderStatus);  
    } catch (Exception e){
        //打印异常
    }
}

虽然你框架中使用了线程池,但是你就是在一个 for 循环中不停的去调用下游服务查询数据状态,是一条数据一条数据的进行处理,所以其实同一时间,只是使用了框架的线程池中的一个线程。


为了更加快速的处理完这批数据,这个时候,你就可以怼一个线程池放在 for 循环里面了:


//循环处理这批数据
for(OrderInfo orderInfo : initOrderInfoList){
    //使用线程池
    executor.execute(() -> {
        //捕获异常以免一条数据错误导致循环结束
        try {
            //发起rpc调用
            String orderStatus = queryOrderStatus(orderInfo.getOrderId);
            //更新订单状态
            updateOrderInfo(orderInfo.getOrderId, orderStatus);
        } catch (Exception e) {
            //打印异常
        }
    });
}


需要注意的是,这个线程池的参数怎么去合理的设置,是需要考虑的事情。


同时这个线程池的定位,就类似于 web 容器线程池的定位。


或者这样对比起来看更加清晰一点:



定时任务触发的时候,在发起远程接口调用之前,没有线程池,所以我们可以启用一个线程池来加快数据的处理。


而 Http 调用或者 RPC 调用,框架中本来就已经有一个线程池了,而且也给你提供了对应的性能调优参数配置,那么首先考虑的应该是把这个线程池充分利用起来。


如果仅仅是因为异步化之后可以提升服务响应速度,没有达到串行改并行的效果,那么我更加建议使用消息队列。


好了,本文的技术部分就到这里啦。


下面这个环节叫做[荒腔走板],技术文章后面我偶尔会记录、分享点生活相关的事情,和技术毫无关系。我知道看起来很突兀,但是我喜欢,因为这是一个普通博主的生活气息。


荒腔走板



不知道你看完文章之后,有没有产生一个小疑问:最开始部分的 Demo 似乎用处并不大?


是的,我最开始构思的行文结构是是基于 Demo 在源码中找到关于线程池的部分,从而引出其实有一些我们“看不见的线程池”的存在的。


原本周六我是有一整天的时间来写这篇文章,甚至周五晚上还特意把 Demo 搞定,自己调试了一番,该打的断点全部打上,并写完 Demo 那部分之后,我才去睡觉的,想得是第二天早上起来直接就能用。


按照惯例周六睡个懒觉的,早上 11 点才起床,自己慢条斯理的做了一顿午饭,吃完饭已经是下午 1 点多了。


本来想着在沙发上躺一会,结果一躺就是一整个下午。期间也想过起来写一会文章,坐在电脑前又飞快的躺回到沙发上,就是觉得这个事情索然无味,当下的那一刻就想躺着,然后无意识的刷手机,原本是拿来写文章中关于源码的部分的时间就这样浪费了。


像极了高中时的我,周末带大量作业回家,准备来个悬梁刺股,弯道超车,结果变成了一睡一天,捏紧刹车。


高中的时候,时间浪费了是真的可惜。


现在,不一样了。


荒腔走板这张图片,就是我躺在沙发上的时候,别人问我在干什么时随手拍的一张。


我并不为躺了一下午没有干正事而感到惭愧,浪费了的时间,才是属于自己的时间。


很久以前我看到别人在做一些浪费时间的事情的时候,我心里可能会嘀咕几句,劝人惜时。


这两年我不会了,允许自己做自己,允许别人做别人。


作者:why技术
来源:juejin.cn/post/7297980721590272040
收起阅读 »

解决前端跨团队统一的隐性拦路虎

前言 春节刚归来,我们不搞那么烧脑,先来一篇浅显易懂的文章,期望给大家带来一些新的解题思路。 背景 过去多年无论是一款插件推广,还是组件库统一,无论是一次机制流程制定,还是前端工程化体系建设,相信很多同学与我一样,在跨团队方案推广统一过程中,前期无论做好多详实...
继续阅读 »

默认标题__2024-02-06+15_50_28.png


前言


春节刚归来,我们不搞那么烧脑,先来一篇浅显易懂的文章,期望给大家带来一些新的解题思路。


背景


过去多年无论是一款插件推广,还是组件库统一,无论是一次机制流程制定,还是前端工程化体系建设,相信很多同学与我一样,在跨团队方案推广统一过程中,前期无论做好多详实的准备,最终都会有一种未竟全功的感觉。


推广过程中,总会有人摆出历史包袱过重这一拦路虎“说服”我们,比如”我这项目不维护了,无需升级“,”我这项目框架太老旧了,无法升级“,或两者兼有之,到底改哪些项目多取决于双方自行判断,说穿了其实是双方“非不能也,乃不欲也”。


危害


一方面前端项目下线充满不确定性,业务不维护不代表页面无访问,旧有项目中总有一些页面残留,需要长期持续跟进。


另一方面过去多年前端技术生态快速向前发展,造成了不同部门、时期,从jquery、vue2、vue3、react、angular到webpack3、4、5、gulp、vite等前端基建五花八门的场景,仅23年我们团队就先后接入过webpack5、vite、pinia、rspack等前端架构局部优化,跨团队统一需要做大量兼容工作,全量统一困难。


前端项目的业务和技术特点,造成了前端项目数量基本越垒越多,每个项目总有几个有流量的页面时不时跳出来恶心人。造成了前端基建越来越庞杂,兼容成本偏高,总是不能全量升级。形成了前端项目独特的长尾化问题,项目长尾化,基建长尾化,团队意识长尾化。随着时间延续会带来升级维护困难和难以言表的线上偶发惊吓。


根源和解决思路


基于我司经验,问题产生根源一是前端团队资源有限,并不能覆盖全部项目;二是没有统一标准,项目缺乏统一标准管理,各团队自我决策改动范围;三是缺乏强制机制,并不能保证完成效果和时间。


资源有限是个基本无法解决的问题,我们只能从标准和强制机制两个角度去解决,基于此我们针对性的制定了转转自己的项目动态分级标准和强制倒逼机制。


项目动态分级


分级指的是用客观统一的数据标准反映项目重要性,规避主观评判。动态指的是随着时间延续,项目走过新建、迭代、维护、下线的生命周期,客观数据也随生命周期波谷、波峰、波谷往复更替。项目动态分级的最大好处是将有限资源聚焦在重点项目上。


以过去我司推进项目代码规范为例,我们设计时采用项目月活跃分支数、月代码提交行数、项目用户日访问量几个指标确定项目级别;


044ab65d-0883-49c5-ac41-07a588af6c2b.png


比如只有同时满足日访问量UV高于10000、日PV高于100000、月活跃分支数多于4个、月代码提交行数多于200的项目才确定为移动端重点项目,其它项目为非重点项目,每日或每周可以跑定时任务更新项目分级数据,具体数据可以通过拉取git api和公司前端埋点数据获得。


针对重点项目,可以制定2-4个月的改造时间节点和达标标准,非重点项目可以不做改造或制定其它策略。


我司前端项目数不到千,仅将项目分成重要和不重要两个级别已经够用,如场景有必要,也可将项目进一步拆分为更多级别。


指标边界值


大家可能对上面UV > 10000或月活跃分支数 > 4 等指标边界值是怎么确定的感兴趣?


说一下我们的思路,相信每个前端团队过去都手动收集过团队中的重点项目有哪些,这些手动数据可以作为我们的衡量基础,可以不断尝试调整我们的指标边界值,计算出的重点项目一般达到基本覆盖我们手动收集的95%以上重点项目即可,以此来确定边界值指标,一般结果肯定会大于我们的手动集,大家拿到结果可以人工再去分析下具体项目,基本能够发现多出来的项目大部分都是真实的重点项目,大部分都是因为我们谈到的历史包袱问题,不提罢了。


注意如果动态项目池,每日或每周都有较大变化,可能是你的指标不太合理,可以考虑扩大或缩小边界值来解决。另外注意突然进入重点动态项目池的项目,一般下个周期有可能就波动出去了,此类项目可以不做处理,也可推动解决,留好一定改动时间即可。


多指标好处


相比单指标确定边界值,多指标有哪些好处呢?


多指标好处一个是覆盖全部场景,比如我司移动端项目框架为Vue,有大量用户访问,中后台项目技术栈为React,没有大量用户访问。如果仅使用用户访问量指标,中后台项目全部会被排除在重点项目外,如果仅使用项目活跃度,移动端最看重的用户访问量不被纳入,项目区分度不够,但是两者相结合,恰好能覆盖移动端和PC端的全量场景。


另一个是多指标得出的结果稳定性更好,不会因单指标剧烈变化造成结果波动太大的情况,比如去年618和双11期间,尽管我司用户访问量大增,但重点项目集并没有什么变化。


强制倒逼机制


另外怎么确保deadline无延期呢?


我司采用的解决办法是与上线环节绑定,通过强拦截和审批拦截的方式,确保各团队能在最终时间节点之前,完成全部项目的改造。除此之外,强制机制能够倒逼各团队提升生产率,将很多重复性工作自动化,比如上文说到的代码规范,总会有同学梳理出一套本地自动化方案,帮我们完成配套建设。当我们标准达成、配套健全后,就可以考虑下扩大范围或者提高标准的事情了。


另外大家需要注意,跨组推进工作中很重要的一点是前置沟通,给同学们留下足够的时间,比如2-6个月,达成时间共识很重要,不要自觉良好一刀切,想想过去经历的很多跨组项目,半年能彻底搞完就很不错了。


252bfa83-7993-42bc-9cea-799da0876e5d.png


思路&基建复用


本文谈到的整套分级和强制思路,后续逐步复用在我司代码重复度、复杂度、线上异常治理、性能指标等多项跨团队公共事项落地中,有些指标直接复用同一套项目分级标准即可,有些指标需要一定的改动,比如性能指标,就从重点项目,变为重点页面即可,大部分基础能力和解决问题的思路仍可复用。


总结


保持公司各团队基建、标准、机制统一,长期看是非常有必要的事,但过去受制于历史包袱问题这一隐性拦路虎,前端很难做到跨团队统一方案的效用最大化,总会留下一些尾巴,本文通过转转过去的实践,给出了项目分级和强制机制的解体思路,非常适合前端资源并不那么充足的公司,至少能够保证公司重要的项目长期标准统一。


另外整个思路的落地,除标准制定外,还涉及到数据抽取、各指标检测、CICD集成、报警机制、系统开发等多方面的具体工作,每一部分都能作为一个单独的模块去做分享,比如代码规范涉及到的项目代码增量存量检测、代码复杂度、重复度检测,线上异常治理涉及到的实时报警策略、错误分级策略等,因篇幅关系,本文不再赘述,期待后面其他转转er的文章吧!!!


作者:转转技术团队
来源:juejin.cn/post/7337589994464854016
收起阅读 »

用 Puppeteer 把繁琐工作给自动化了,太爽啦!

web
最近在鱼皮的编程导航星球做嘉宾,需要输出一些内容。 而很多内容我之前写过,所以想复制过来。 这时候我就遇到了一个令人头疼的问题: 知识星球的编辑器也太难用了! 比如我在掘金编辑器里这样的 markdown 内容: 复制到星球编辑器是这样的: markdow...
继续阅读 »

最近在鱼皮的编程导航星球做嘉宾,需要输出一些内容。


而很多内容我之前写过,所以想复制过来。


这时候我就遇到了一个令人头疼的问题:


知识星球的编辑器也太难用了!


比如我在掘金编辑器里这样的 markdown 内容:



复制到星球编辑器是这样的:



markdown 语法是识别了,但图片没有自动上传。


如果用富文本格式,格式又不对:



而且 gif 没有识别出来,还是需要手动传一次。


这意味着如果文中有几十张图片,那我需要单独把这几十张图片保存到本地,然后光标定位到对应位置,点击上传图片,把图片插进去。


也就是这样:




把每个图片下载下来,保存为不同的后缀名(png、jpg、gif),然后再定位到对应位置,删除原来的链接,插入图片。


然后这样重复十几次,每篇文章都这样来一遍。


是不是想想都觉得很痛苦。。。


那有什么好的办法解决这个问题呢?


于是我想到了 puppeteer。



它是一个网页自动化的 Node.js 工具,基本所有你手动在浏览器里做的事情,都可以用它来自动化完成。


比如点击、移动光标、输入等等。


那前面那个繁琐的问题自然也可以用 puppeteer 自动化来做,解放我们的生产力。


我们来分析下整个流程:


首先打开星球编辑器页面,如果没登录会跳到登录页:



这一步要扫码,没法自动化。


登录之后进入编辑器页面,输入内容:



这时候我们要把其中的图片链接分析出来,自动下载到本地的目录中。


然后记录每个链接所在的行数,把光标移动到对应的行数,点击上传按钮:



上传这一步也要手动来做,选择之前自动下载的图片就行。


然后光标会自动移动到下一个位置,再点击上传按钮,直到所有图片上传完。


文件浏览器这一步是操作系统的功能,没法自动化。


我们把下载图片、在对应位置插入图片的过程给自动化了。只有登录、选择文件这两步还要还要手动做。


但这样已经方便太多了。


流程理清了,我们就来写下代码吧:


import puppeteer from 'puppeteer';

const browser = await puppeteer.launch({
headless: false,
defaultViewport: {
width: 0,
height: 0
}
});

const page = await browser.newPage();

await page.goto('http://www.baidu.com');

await page.focus('#kw');

await page.keyboard.type('hello', {
delay: 200
});

await page.click('#su');

引入 puppeteer,跑一个 chrome 浏览器,创建一个页面,导航到 baidu,输入 hello,点击搜索。


puppeteer 的 api 还是很容易懂的。


其中 defaultViewport 设置宽高为 0 是让网页充满整个窗口。


然后我们把它跑起来,因为用到了 es module、顶层 await,需要在 package.json 声明 type 为 module:



声明 type 为 module 就是所有的模块都是 es module 的意思。


然后把它跑起来:



可以看到脚本正确执行了。


然后我们让它打开星球编辑器的网址:


import puppeteer from 'puppeteer';

const browser = await puppeteer.launch({
headless: false,
defaultViewport: {
width: 0,
height: 0
}
});

const page = await browser.newPage();

await page.goto('https://wx.zsxq.com/dweb2/article?groupId=51122858222824');


确实跳到登录了:



扫码登录之后进入星球页面,就可以写文章了。



但是,下次跑脚本还是要再登录。


我们不是登录过了么?为啥还需要登录?


因为 chrome 默认的数据保存在一个目录中,叫 userDataDir,而这个目录默认是临时生成的,所以每次保存数据的目录都不一样。


这就导致了每次都需要登陆。


所以我们指定一个固定的 userDataDir 就好了。


import puppeteer from 'puppeteer';
import os from 'os';
import path from 'path';

const browser = await puppeteer.launch({
headless: false,
defaultViewport: {
width: 0,
height: 0
},
userDataDir: path.join(os.homedir(), '.puppeteer-data')
});

const page = await browser.newPage();

await page.goto('https://wx.zsxq.com/dweb2/article?groupId=51122858222824');

通过 os.homedir() 拿到 home 目录,再下面新建一个 .puppeteer-data 的目录来保存用户数据。


这样登录一次之后,下次就不再需要登录了:



这时候可以看到 userDataDir 下是保存了用户数据的:



接下来就是编辑部分的自动化了。


我们要做的事情有这么两件:



  • 提取文本中的所有链接,自动下载。

  • 光标定位到每个链接的位置,自动点击上传按钮。


执行这俩自动化脚本的过程最好让用户控制,比如输入 download-img 就自动下载图片,输入 upload-next 光标就自动定位到下个位置,点击上传。


所以我们引入 readline 这个内置模块接收用户输入。


import readline from 'readline';

const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});

rl.on('line', async (command) => {
switch(command) {
case 'upload-next':
await uploadNext();
break;
case 'download-img':
await downloadImg();
break;
default:
break;
}
});

async function uploadNext() {
console.log('------');
}
async function downloadImg() {
console.log('+++++++');
}

调用 creatInterface api,指定 input、output 为标准输入输出。


然后当收到一行的输入的时候,根据内容决定执行什么方法:



我们先实现 download-img 的部分:



可以看到,编辑器部分的内容就是 .ql-editor 下的一个个 p 标签。


那我们只要取出所有的 p 标签,选出 ![]() 格式的内容就好了。


这需要一个正则,我们先把这个正则写出来:


整体格式是这样的:


![]()

但[] 和 () 需要转义:


!\[\]\(\)

中间部分是除了 [] 和 () 的任意字符出现任意次,也就是这样:


[^\[\]\(\)]*

并且 () 里的内容需要提取,需要用小括号包裹。


完整正则就是这样的:


!\[[^\[\]\(\)]*\]\(([^\[\]\(\)]*)\)

我们测试下:



可以看到 () 中的内容被正确提取出来了。


然后在网页里取出所有的 p 标签,根据内容过滤,把链接和行数记录下来:


const links = await page.evaluate(() => {
let links = [];
const lines = document.querySelectorAll('.ql-editor p');
for(let i = 0; i < lines.length; i ++) {
const matchRes =lines[i].textContent.trim().match(/!\[[^\[\]\(\)]*\]\(([^\[\]\(\)]*)\)/)
if (matchRes) {
links.push({
index: i,
link: matchRes && matchRes[1],
});
}
}
return links;
})

用 page.evaluate 方法在网页里远程执行一段 js,拿到它的返回结果。


这里拿到的就是所有的图片链接:



其实严格来说这不叫行数,而是第几个 p 标签,想要定位到对应的 p 标签,只要点击它就好了。



我们记录的下标是从 0 开始,而 nth-child 从 1 开始,所以要加 1。


可以看到,光标定位到了正确的位置:



不过先不着急定位光标,我们先把图片下载给搞定。


下载部分的代码如下:


import https from 'https';
import fs from 'fs';

function downloadFile(url, destinationPath, progressCallback) {
let resolve , reject;
const promise = new Promise((x, y) => { resolve = x; reject = y; });

const request = https.get(url, response => {
if (response.statusCode !== 200) {
const error = new Error(`Download failed: server returned code ${response.statusCode}. URL: ${url}`);
response.resume();

reject(error);
return;
}
const file = fs.createWriteStream(destinationPath);

file.on('finish', () => resolve ());
file.on('error', error => reject(error));

response.pipe(file);

const totalBytes = parseInt(response.headers['content-length'], 10);
if (progressCallback)
response.on('data', onData.bind(null, totalBytes));
});
request.on('error', error => reject(error));
return promise;

function onData(totalBytes, chunk) {
progressCallback(totalBytes, chunk.length);
}
}

用 https 模块的 get 方法请求 url,然后把 response 用流的方式写入文件,并且通过 content-length 的响应头拿到总长度。


这样,在每次 data 方法里就能根据总长度,当前 chunk 的长度,算出下载进度。


我们测试下:


const url = 'https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/66399947ea6b45289c8d77b6d4568cc5~tplv-k3u1fbpfcp-watermark.image'

let currentTotal = 0;
downloadFile(url, './1.gif', (totalBytes, chunkBytes) => {
const percent = (currentTotal/totalBytes * 100).toFixed(1);
console.log('总长度:' + totalBytes + 'B', '当前已下载:' + currentTotal + 'B','进度' + percent + '%');
currentTotal += chunkBytes;
})


可以看到,图片下载成功了!


但是,我们现在是知道这是个 gif 才给它加上 .gif 后缀,要是任意一个链接,怎么知道它的格式呢?


这个可以用 image-size 这个包:


import sizeOf from 'image-size';
import fs from 'fs';

const buffer = fs.readFileSync('./1.image');
const dimensions = sizeOf(buffer);

console.log(dimensions);

它能拿到图片的类型和宽高信息:



这样我们在下载完改下名就可以了。


const url = 'https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/66399947ea6b45289c8d77b6d4568cc5~tplv-k3u1fbpfcp-watermark.image'

let currentTotal = 0;
let filePath = './1.image';
downloadFile(url, filePath, (totalBytes, chunkBytes) => {
const percent = (currentTotal/totalBytes * 100).toFixed(1);
console.log('总长度:' + totalBytes + 'B', '当前已下载:' + currentTotal + 'B','进度' + percent + '%');
currentTotal += chunkBytes;

if(currentTotal >= totalBytes) {
const {type} = sizeOf(fs.readFileSync(filePath));
fs.renameSync(filePath, filePath + '.' + type)
}
})

当下载完之后,拿到图片信息,重命名一下,把后缀名改成新的。


注意下图中文件名字的变化:



这样,下载图片就搞定了。


我们把它集成到自动化流程中。


先指定下文件保存位置和文件名:


我们在 home 目录下创建一个 .img 目录吧,然后文件名是 1.image、2.image 的形式。


const imgPath = path.join(os.homedir(), '.img');

fs.rmSync(imgPath, {
recursive: true
});
fs.mkdirSync(imgPath);

for(let i = 0; i< links.length; i++) {
const filePath = path.join(imgPath, (i+1) + '.image');
fs.writeFileSync(filePath, 'aaaa')
}


每次先清空 .img 目录,再创建。


执行之后,确实在 .img 目录下创建了对应的图片文件:



然后把下载图片和重命名的逻辑集成进来:


fs.rmSync(imgPath, {
recursive: true
});
fs.mkdirSync(imgPath);

for(let i = 0; i< links.length; i++) {
const filePath = path.join(imgPath, (i+1) + '.image');
let currentTotal = 0;
downloadFile(links[i].link, filePath, (totalBytes, chunkBytes) => {
currentTotal += chunkBytes;

if(currentTotal >= totalBytes) {
setTimeout(() => {
const {type} = sizeOf(fs.readFileSync(filePath));
fs.renameSync(filePath, filePath + '.' + type)
console.log(`${filePath} 下载完成,重命名为 ${filePath + '.' + type}`);
}, 1000);
}
})
}

这里加了一个 setTimeout,1s 之后执行重命名的逻辑,保证在文件下载完之后再重命名。


效果是这样的:



在 .img 下可以看到所有的图片都下载并重命名成功了:



有 png 也有 gif



下一步只要在不同的位置插入就好了。


我们再来做光标定位的部分。


这部分前面演示过,就是触发对应 p 标签的 click 就好了。


let cursor = 0;
async function uploadNext() {
if(cursor >= links.length) {
return;
}
await page.click(`.ql-editor p:nth-child(${links[cursor].index + 1})`);
await page.evaluate((index) => {
const p = document.querySelector(`.ql-editor p:nth-child(${index + 1})`);
p.textContent = '';
}, links[cursor].index);
await page.click('.ql-image');
cursor ++;
}

我们定义一个游标,从 0 开始,先点击第一个 link 的 p 标签,把它的内容清空,插入下载的图片。


然后再次执行就是插入下一个。


这样依次插入。


我们来试试:


首先,打开编辑器页面,自己登录和输入 markdown 内容:



然后输入 download-img 来下载图片:



之后执行 upload-next 插入第一张图片:



再执行 upload-next 插入第二张图片:



插入的位置非常正确!



依次 upload-next 就能把所有图片插入完成。


对比下之前的体验:


一张张下载图片,根据不同的格式来重命名,然后一张张找到对应的位置,删除原来的链接,插入图片。


现在的体验:


输入 download-img 自动下载图片,不断执行 upload-next 选择图片,自动插入到正确的位置。


这体验差距很明显吧!


这就是用 puppeteer 自动化以后的工作流。


全部代码如下:


import puppeteer from 'puppeteer';
import os from 'os';
import path from 'path';
import fs from 'fs';
import readline from 'readline';
import sizeOf from 'image-size';
import downloadFile from './download.js';

const browser = await puppeteer.launch({
headless: false,
defaultViewport: {
width: 0,
height: 0
},
userDataDir: path.join(os.homedir(), '.puppeteer-data')
});

const page = await browser.newPage();

await page.goto('https://wx.zsxq.com/dweb2/article?groupId=51122858222824');

const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});

rl.on('line', async (command) => {
switch(command) {
case 'upload-next':
await uploadNext();
break;
case 'download-img':
await downloadImg();
break;
default:
break;
}
});



let links = [];
async function downloadImg() {
links = await page.evaluate(() => {
let links = [];
const lines = document.querySelectorAll('.ql-editor p');
for(let i = 0; i < lines.length; i ++) {
const matchRes =lines[i].textContent.trim().match(/!\[[^\[\]\(\)]*\]\(([^\[\]\(\)]*)\)/)
if (matchRes) {
links.push({
index: i,
link: matchRes && matchRes[1],
});
}
}
return links;
})

const imgPath = path.join(os.homedir(), '.img');

fs.rmSync(imgPath, {
recursive: true
});
fs.mkdirSync(imgPath);


for(let i = 0; i< links.length; i++) {
const filePath = path.join(imgPath, (i+1) + '.image');
let currentTotal = 0;
downloadFile(links[i].link, filePath, (totalBytes, chunkBytes) => {
currentTotal += chunkBytes;

if(currentTotal >= totalBytes) {
setTimeout(() => {
const {type} = sizeOf(fs.readFileSync(filePath));
fs.renameSync(filePath, filePath + '.' + type)
console.log(`${filePath} 下载完成,重命名为 ${filePath + '.' + type}`);
}, 1000);
}
})
}

console.log(links);
}

let cursor = 0;
async function uploadNext() {
if(cursor >= links.length) {
return;
}
await page.click(`.ql-editor p:nth-child(${links[cursor].index + 1})`);
await page.evaluate((index) => {
const p = document.querySelector(`.ql-editor p:nth-child(${index + 1})`);
p.textContent = '';
}, links[cursor].index);
await page.click('.ql-image');
cursor ++;
}

import https from 'https';
import fs from 'fs';

export default function downloadFile(url, destinationPath, progressCallback) {
let resolve , reject;
const promise = new Promise((x, y) => { resolve = x; reject = y; });

const request = https.get(url, response => {
if (response.statusCode !== 200) {
const error = new Error(`Download failed: server returned code ${response.statusCode}. URL: ${url}`);
response.resume();

reject(error);
return;
}
const file = fs.createWriteStream(destinationPath);

file.on('finish', () => resolve ());
file.on('error', error => reject(error));

response.pipe(file);

const totalBytes = parseInt(response.headers['content-length'], 10);
if (progressCallback)
response.on('data', onData.bind(null, totalBytes));
});
request.on('error', error => reject(error));
return promise;

function onData(totalBytes, chunk) {
progressCallback(totalBytes, chunk.length);
}
}

总结


星球编辑器不好用,每次都要把图片手动下载下来然后插入对应位置,我们通过 puppeteer 把这个流程自动化了。


puppeteer 是一个自动化测试工具,基本所有浏览器手动的操作都能自动化。


我们用 readline 模块读取用户输入,当输入 download-img 的时候,拿到所有的 p 标签,过滤出链接的内容,把信息记录下来。


自动下载图片并用 image-size 读取图片类型来重命名。


然后输入 upload-next,会通过点击对应 p 标签实现光标定位,然后点击上传按钮来选择图片。


自动化以后的工作流程简单太多了,繁琐的工作都给自动化了,体验爽翻了!


作者:zxg_神说要有光
来源:juejin.cn/post/7230757380819812407
收起阅读 »

js如何实现当文本内容过长时,中间显示省略号...,两端正常展示

web
前一阵做需求时,有个小功能实现起来废了点脑细胞,觉得可以记录一下。 产品的具体诉求是:用户点击按钮进入详情页面,详情页内的卡片标题内容过长时,标题的前后两端正常展示,中间用省略号...表示,并且鼠标悬浮后,展示全部内容。 关于鼠标悬浮展示全部内容的代码就不放在...
继续阅读 »

前一阵做需求时,有个小功能实现起来废了点脑细胞,觉得可以记录一下。


产品的具体诉求是:用户点击按钮进入详情页面,详情页内的卡片标题内容过长时,标题的前后两端正常展示,中间用省略号...表示,并且鼠标悬浮后,展示全部内容。


关于鼠标悬浮展示全部内容的代码就不放在这里了,本文主要写关于实现中间省略号...的代码。


实现思路



  1. 获取标题盒子的真实宽度, 我这里用的是clientWidth;

  2. 获取文本内容所占的实际宽度;

  3. 根据文字的大小计算出每个文字所占的宽度;

  4. 判断文本内容的实际宽度是否超出了标题盒子的宽度;

  5. 通过文字所占的宽度累加之和与标题盒子的宽度做对比,计算出要截取位置的索引;

  6. 同理,文本尾部的内容需要翻转一下,然后计算索引,截取完之后再翻转回来;


代码


html代码


<div class="title" id="test">近日,银行纷纷下调大额存单利率,但银行定期存款仍被疯抢。银行理财经理表示:有意向购买定期存款要尽快,不确定利率是否会再降。</div>

css代码: 设置文本不换行,同时设置overflow:hidden让文本溢出盒子隐藏


.title {
width: 640px;
height: 40px;
line-height: 40px;
font-size: 14px;
color: #00b388;
border: 1px solid #ddd;
overflow: hidden;
/* text-overflow: ellipsis; */
white-space: nowrap;
/* box-sizing: border-box; */
padding: 0 10px;
}

javascript代码:


获取标题盒子的宽度时要注意,如果在css样式代码中设置了padding, 就需要获取标题盒子的左右padding值。 通过getComputedStyle属性获取到所有的css样式属性对应的值, 由于获取的padding值都是带具体像素单位的,比如: px,可以用parseInt特殊处理一下。


获取盒子的宽度的代码,我当时开发时是用canvas计算的,但计算的效果不太理想,后来逛社区,发现了嘉琪coder大佬分享的文章,我这里就直接把代码搬过来用吧, 想了解的掘友可以直接滑到文章末尾查看。


判断文本内容是否超出标题盒子


 // 标题盒子dom
const dom = document.getElementById('test');

// 获取dom元素的padding值
function getPadding(el) {
const domCss = window.getComputedStyle(el, null);
const pl = Number.parseInt(domCss.paddingLeft, 10) || 0;
const pr = Number.parseInt(domCss.paddingRight, 10) || 0;
console.log('padding-left:', pl, 'padding-right:', pr);
return {
left: pl,
right: pr
}
}
// 检测dom元素的宽度,
function checkLength(dom) {
// 创建一个 Range 对象
const range = document.createRange();

// 设置选中文本的起始和结束位置
range.setStart(dom, 0),
range.setEnd(dom, dom.childNodes.length);

// 获取元素在文档中的位置和大小信息,这里直接获取的元素的宽度
let rangeWidth = range.getBoundingClientRect().width;

// 获取的宽度一般都会有多位小数点,判断如果小于0.001的就直接舍掉
const offsetWidth = rangeWidth - Math.floor(rangeWidth);
if (offsetWidth < 0.001) {
rangeWidth = Math.floor(rangeWidth);
}

// 获取元素padding值
const { left, right } = getPadding(dom);
const paddingWidth = left + right;

// status:文本内容是否超出标题盒子;
// width: 标题盒子真实能够容纳文本内容的宽度
return {
status: paddingWidth + rangeWidth > dom.clientWidth,
width: dom.clientWidth - paddingWidth
};
}

通过charCodeAt返回指定位置的字符的Unicode编码, 返回的值对应ASCII码表对应的值,0-127包含了常用的英文、数字、符号等,这些都是占一个字节长度的字符,而大于127的为占两个字节长度的字符。


截取和计算文本长度


// 计算文本长度,当长度之和大于等于dom元素的宽度后,返回当前文字所在的索引,截取时会用到。
function calcTextLength(text, width) {
let realLength = 0;
let index = 0;
for (let i = 0; i < text.length; i++) {
charCode = text.charCodeAt(i);
if (charCode >= 0 && charCode <= 128) {
realLength += 1;
} else {
realLength += 2 * 14; // 14是字体大小
}
// 判断长度,为true时终止循环,记录索引并返回
if (realLength >= width) {
index = i;
break;
}
}
return index;
}

// 设置文本内容
function setTextContent(text) {
const { status, width } = checkLength(dom);
let str = '';
if (status) {
// 翻转文本
let reverseStr = text.split('').reverse().join('');

// 计算左右两边文本要截取的字符索引
const leftTextIndex = calcTextLength(text, width);
const rightTextIndex = calcTextLength(reverseStr, width);

// 将右侧字符先截取,后翻转
reverseStr = reverseStr.substring(0, rightTextIndex);
reverseStr = reverseStr.split('').reverse().join('');

// 字符拼接
str = `${text.substring(0, leftTextIndex)}...${reverseStr}`;
} else {
str = text;
}
dom.innerHTML = str;
}

最终实现的效果如下:


image.png


上面就是此功能的所有代码了,如果想要在本地试验的话,可以在本地新建一个html文件,复制上面代码就可以了。


下面记录下从社区内学到的相关知识:



  1. js判断文字被溢出隐藏的几种方法;

  2. JS获取字符串长度的几种常用方法,汉字算两个字节;


1、 js判断文字被溢出隐藏的几种方法


1. Element-plus这个UI框架中的表格组件实现的方案。


通过document.createRangedocument.getBoundingClientRect()这两个方法实现的。也就是我上面代码中实现的checkLength方法。


2. 创建一个隐藏的div模拟实际宽度


通过创建一个不会在页面显示出来的dom元素,然后把文本内容设置进去,真实的文本长度与标题盒子比较宽度,判断是否被溢出隐藏了。


function getDomDivWidth(dom) {
const elementWidth = dom.clientWidth;
const tempElement = document.createElement('div');
const style = window.getComputedStyle(dom, null)
const { left, right } = getPadding(dom); // 这里我写的有点重复了,可以优化
tempElement.style.cssText = `
position: absolute;
top: -9999px;
left: -9999px;
white-space: nowrap;
padding-left:${style.paddingLeft};
padding-right:${style.paddingRight};
font-size: ${style.fontSize};
font-family: ${style.fontFamily};
font-weight: ${style.fontWeight};
letter-spacing: ${style.letterSpacing};
`
;
tempElement.textContent = dom.textContent;
document.body.appendChild(tempElement);
const obj = {
status: tempElement.clientWidth + right + left > elementWidth,
width: elementWidth - left - right
}
document.body.removeChild(tempElement);
return obj;
}

3. 创建一个block元素来包裹inline元素


这种方法是在UI框架acro design vue中实现的。外层套一个块级(block)元素,内部是一个行内(inline)元素。给外层元素设置溢出隐藏的样式属性,不对内层元素做处理,这样内层元素的宽度是不变的。因此,通过获取内层元素的宽度和外层元素的宽度作比较,就可以判断出文本是否被溢出隐藏了。


// html代码
<div class="title" id="test">
<span class="content">近日,银行纷纷下调大额存单利率,但银行定期存款仍被疯抢。银行理财经理表示:有意向购买定期存款要尽快,不确定利率是否会再降。</span>
</div>

// 创建一个block元素来包裹inline元素
const content = document.querySelector('.content');
function getBlockDomWidth(dom) {
const { left, right } = getPadding(dom);
console.log(dom.clientWidth, content.clientWidth)
const obj = {
status: dom.clientWidth < content.clientWidth + left + right,
width: dom.clientWidth - left - right
}
return obj;
}

4. 使用canvas中的measureText方法和TextMetrics对象来获取元素的宽度


通过Canvas 2D渲染上下文(context)可以调用measureText方法,此方法会返回TextMetrics对象,该对象的width属性值就是字符占据的宽度,由此也能获取到文本的真实宽度,此方法有弊端,比如说兼容性,精确度等等。


// 获取文本长度
function getTextWidth(text, font = 14) {
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d")
context.font = font
const metrics = context.measureText(text);
return metrics.width
}

2、JS获取字符串长度的几种常用方法


1. 通过charCodeAt判断字符编码


通过charCodeAt获取指定位置字符的Unicode编码,返回的值对应ASCII码表对应的值,0-127包含了常用的英文、数字、符号等,这些都是占一个字节长度的字符,而大于127的为占两个字节长度的字符。


function calcTextLength(text) {
let realLength = 0;
for (let i = 0; i < text.length; i++) {
charCode = text.charCodeAt(i);
if (charCode >= 0 && charCode <= 128) {
realLength += 1;
} else {
realLength += 2;
}
}
return realLength;
}

2. 采取将双字节字符替换成"aa"的做法,取长度


function getTextWidth(text) {
return text.replace(/[^\x00-\xff]/g,"aa").length;
};

参考文章


1. JS如何判断文字被ellipsis了?


2. Canvas API 中文网


3. JS获取字符串长度的常用方法,汉字算两个字节


4. canvas绘制字体偏上不居中问题、文字垂直居中后偏上问题、measureText方法和TextMetrics对象


作者:娜个小部呀
来源:juejin.cn/post/7329967013923962895
收起阅读 »

uniapp踩坑合集

web
1、onPullDownRefresh下拉刷新不生效 pages.json对应的style中enablePullDownRefresh设置为true,开启下拉刷新 { "path" : "pages/list/list", "style" : ...
继续阅读 »

1、onPullDownRefresh下拉刷新不生效


pages.json对应的style中enablePullDownRefresh设置为true,开启下拉刷新
{
"path" : "pages/list/list",
"style" :
{
"navigationBarTitleText": "页面标题名称",
"enablePullDownRefresh": true
}
}

2、onReachBottom上拉加载不生效


page中css样式设置了height:100%;
修改为height:auto;即可

3、onPageScroll生命周期不触发


最外层css样式设置了以下样式
height: 100%;
overflow: scroll;

4、onBackPress监听页面返回生命周期


使用场景:APP手机左滑返回时控制执行某些操作,不直接返回上一页(例如:弹框打开时关闭弹框)


注意事项


1、onBackPress上不可使用async,会导致无法阻止默认返回


2、支付宝小程序只有真机可以监听到非navigateBack引发的返回事件(使用小程序开发工具时不会触发onBackPress),不可以阻止默认返回行为


3、只有在该函数中返回值为 true 时,才表示不执行默认的返回,自行处理此时的业务逻辑


4、当不阻止页面返回却直接调用页面路由相关接口(如:uni.switchTab)时,可能会导致页面显示异常,可以通过延迟调用路由相关接口解决


5、H5 平台,顶部导航栏返回按钮支持 onBackPress(),浏览器默认返回按键及Android手机实体返回键不支持 onBackPress()


6、暂不支持直接在自定义组件中配置该函数,目前只能是在页面中来处理。


//场景1:弹框打开时,返回执行关闭弹框
//html
"searchPop" type="right" @change="popupChange">
<view class="popup-con">1111view>


//js
export default {
data() {
return {
boxShow: false
}
},
onBackPress(options) {
if( this.boxShow ){
this.$refs.searchPop.close();
return true
}
//其他情况执行默认返回
},
methods: {
popupChange(e) {
this.boxShow = e.show;
},
}
}

//场景2,多级返回时
export default {
data() {
return {
boxShow: false
}
},
onBackPress(options) {
if( this.boxShow ){
this.$refs.searchPop.close();
return true
}else{
if (options.from === 'navigateBack') {
return false;
}
uni.navigateBack({
delta: 2
});
}
},
methods: {
popupChange(e) {
this.boxShow = e.show;
},
}
}


5、遮罩层不能遮底部导航栏


应用场景:APP升级弹框提示


uni文档api界面——交互反馈中uni.showModal可以遮罩底部导航栏;
uni.showToast(OBJECT)、uni.showLoading(OBJECT)都无法遮罩底部导航栏;


目前可以采用两种方式解决:自定义底部导航栏、打开时隐藏底部导航栏


方法一:自定义底部导航栏


1、在app.vue页面的onLaunch生命周期中隐藏原生底部  
onLaunchfunction() {
console.log('App Launch')
uni.hideTabBar();
}

2、自己封装tab组件
<template>
<view class="foot-bar">
<view v-if="hasBorder" class="foot-barBorder">view>
<view class="foot-con">
<view class="foot-list" v-for="(item,index) in tabList" :key="index" @tap="tabJump(index,item.pagePath)">
<img v-if="index!=selectedIndex" class="foot-icon" :src="'/'+item.iconPath" mode="heightFix" />
<img v-else class="foot-icon" :src="'/'+item.selectedIconPath" mode="heightFix" />
<text v-if="index!=selectedIndex" :style="textStyle">{{item.text}}text>
<text v-else :style="textSelectStyle">{{item.text}}text>
view>
view>
view>
template>

<script>
export default {
name: "tabBar",
props: {
hasBorder: {
type: Boolean,
default: false
},
selectedIndex:{
type:[String,Number],
default:0
},
textStyle: {
type: Object,
default () {
return {
color:'#999'
}
}
},
textSelectStyle:{
type: Object,
default () {
return {
color: 'rgb(0, 122, 255)'
}
}
}
},
data() {
return {
tabList: [{
"pagePath": "pages/tabBar/component/component",
"iconPath": "static/component.png",
"selectedIconPath": "static/componentHL.png",
"text": "内置组件"
},
{
"pagePath": "pages/tabBar/API/API",
"iconPath": "static/api.png",
"selectedIconPath": "static/apiHL.png",
"text": "接口"
}, {
"pagePath": "pages/tabBar/extUI/extUI",
"iconPath": "static/extui.png",
"selectedIconPath": "static/extuiHL.png",
"text": "扩展组件"
}, {
"pagePath": "pages/tabBar/template/template",
"iconPath": "static/template.png",
"selectedIconPath": "static/templateHL.png",
"text": "模板"
}
]
};
},
methods:{
tabJump(index,url){
if( index == this.selectedIndex ){
return
}
uni.switchTab({
url: '/' + url
})
}
}
}
script>

<style lang="scss" scoped>
.foot-bar {
position: fixed;
left: 0px;
right: 0px;
bottom: 0px;
z-index: 998;
width: 100vw;
.foot-barBorder {
position: absolute;
left: 0px;
right: 0px;
top: -1px;
width: 100vw;
height: 1px;
background-color: #eee;
}
.foot-con {
background-color: #fff;
width: 100vw;
height: 50px;
display: flex;
flex-direction: row;
align-items: center;
.foot-list {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
.foot-icon{
width: auto;
height:30px;
}
text{
font-size: 12px;
}
}
}
}
style>


3、需要底部导航的页面引入组件,当前页面是导航栏第几个,selectedIndex就等于几,从0开始
<template>
<view>
<TabBar :selectedIndex="0">TabBar>
view>
template>
<script>
import TabBar from "@/components/tabBar/tabBar";
export default {
components:{
TabBar
},
data() {
return {}
}
}
script>


方法二:打开时隐藏底部导航栏,关闭时打开导航栏


uni.hideTabBar();uni.showTabBar(); 官方文档


image.png


image.png


6、条件编译的正确写法


语法:以 #ifdef 或 #ifndef 加 %PLATFORM% 开头,以 #endif 结尾



  • #ifdef:if defined 仅在某平台存在

  • #ifndef:if not defined 除了某平台均存在

  • %PLATFORM%:平台名称


//仅出现在 App 平台下的代码  
#ifdef APP-PLUS
需条件编译的代码
#endif

//除了 H5 平台,其它平台均存在的代码  
#ifndef H5
需条件编译的代码
#endif

在 H5 平台或微信小程序平台存在的代码  
#ifdef H5 || MP-WEIXIN
需条件编译的代码
#endif

//css样式中  
page{
padding-top:24rpx;
/* #ifdef  H5 */
padding-top:34rpx;
/* #endif */
}

//.vue页面中  
<template>

<view>NFC扫码view>

template>

//page.json页面中  
//json文件中
//API 的条件编译
//生命周期中
//methods方法中
mounted(){
// #ifdef APP-PLUS
//APP更新
this.checkUpdate();
//#endif
}

7、限制input输入类型,replace不生效


//不生效代码
"Code" type="number" placeholder="请输入号码" clearable trim="all" :inputBorder="false" @input="gunChange" maxlength="11">

methods:{
gunChange(e){
this.addForm.oilGunCode = e.replace(/[^\d]/g, '');
},
}

使用v-model绑定值时,replace回显不生效;将v-model修改为:value即可生效;


//生效代码
all" :inputBorder="false" @input="gunChange" maxlength="11">

限制只能输入数字:/[^\d]/g/\D/g (但无法限制0开头)
限制只能输入大小写字母、数字、下划线:/[^\w_]/g
限制只能输入小写字母、数字、下划线:/[^a-z0-9_]/g
限制只能输入数字和点:/[^\d.]/g
限制只能输入中文:/[^\u4e00-\u9fa5]/g
限制只能输入英文(大小写均可):/[^a-zA-Z]/g
去除空格:/\s+/g

8、常见的登录验证


"name" placeholder="请输入用户名" clearable trim="all" maxlength="11">
<uni-easyinput v-model="tell" placeholder="请输入手机号" clearable trim="all" maxlength="11">uni-easyinput>

methods:{
submitHandle(){
//姓名 2-5为的汉字
var reg0 = /^[\u4e00-\u9fa5]{2,5}$/,
//用户名正则,4到16位(字母,数字,下划线,减号)
var reg = /^[a-zA-Z0-9_-]{4,16}$/;
//密码强度正则,最少6位,包括至少1个大写字母,1个小写字母,1个数字,1个特殊字符
var reg2 = /^.*(?=.{6,})(?=.*\d)(?=.*[A-Z])(?=.*[a-z])(?=.*[!@#$%^&*? ]).*$/;
//Email正则
var reg3 = /^([A-Za-z0-9_\-\.])+\@([A-Za-z0-9_\-\.])+\.([A-Za-z]{2,4})$/;
//手机号正则
var reg4 = /^((13[0-9])|(14[5|7])|(15([0-3]|[5-9]))|(18[0,5-9]))\d{8}$/;
//身-份-证号(18位)正则
var reg5 = /^[1-9]\d{5}(18|19|([23]\d))\d{2}((0[1-9])|(10|11|12))(([0-2][1-9])|10|20|30|31)\d{3}[0-9Xx]$/;
//车牌号正则
var reg6 = /^[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领A-Z]{1}[A-Z]{1}[A-Z0-9]{4}[A-Z0-9挂学警港澳]{1}$/;

if( !reg.test(this.name) ){
uni.showToast(
title:'用户名格式不正确!'
)
}
},
}

作者:CRMEB技术团队
来源:juejin.cn/post/7272185503822086203
收起阅读 »

React Server Components引发的分歧与机遇

web
介绍 React Server Components 在以前,当用户访问一个 React 应用时,服务端会返回一个空的 HMTL 文件,里面包含一个或多个 JavaScript 文件,浏览器解析 HTML,然后下载 JavaScript 文件,并在客户端呈现网...
继续阅读 »

介绍 React Server Components


在以前,当用户访问一个 React 应用时,服务端会返回一个空的 HMTL 文件,里面包含一个或多个 JavaScript 文件,浏览器解析 HTML,然后下载 JavaScript 文件,并在客户端呈现网页。


React Server Components(RSC)的出现拓展了 React 的范围。顾名思义,React Server Components 就是 React 的服务端组件,它们只在服务端运行,可以调用服务端的方法、访问数据库等。RSC 每次预渲染后把 HTML 发送到客户端,由客户端进行水合(hydrate)并正式渲染。这种做法的好处是,一部分原本要打包在客户端 JavaScript 文件里的代码,现在可以放在服务端运行了,从而减轻客户端的负担,提升应用的整体性能和响应速度。


「充分利用服务器资源」是发布 RSC 的最大动机,换句话说就是:一切不需要交互的内容都应当放到服务端。React 官方举了一个非常典型的例子——渲染 markdown 内容,


// 客户端组件渲染

import marked from 'marked'; // 35.9K (11.2K gzipped)
import sanitizeHtml from 'sanitize-html'; // 206K (63.3K gzipped)

function NoteWithMarkdown({text}) {
const html = sanitizeHtml(marked(text));
return (/* 渲染 */);
}

这个例子中,如果用客户端组件渲染,客户端至少要下载 200 多k的文件才能渲染出内容,但这里的 markdown 内容其实不需要交互,也不会因为用户的操作产生更新信息的需求,非常符合使用 RSC 的理念。如果使用 RSC,


// 服务器组件渲染

import marked from 'marked'; // 零打包大小
import sanitizeHtml from 'sanitize-html'; // 零打包大小

function NoteWithMarkdown({text}) {
// 与之前相同
}

依赖包放在服务端,服务端只返回用户需要看到的内容,客户端包一下子就小了 200 多k。


直到这里,社区主流观点都是积极的,直到 Next.js 基于 RSC 的特性野蛮狂奔,分歧出现了。


社区分歧


出现分歧的最根本原因是 React 引入了服务端的概念,服务端组件和客户端组件有着明显差异:



  • 服务器组件不能使用像 useState 和 useEffect 这样的 React hook;客户端则可以;

  • 服务器组件无权访问浏览器 API;客户端有完整的浏览器 API 权限;

  • 服务端有权限直接访问服务端程序和 API;而客户端组件只能通过请求访问部分程序。


随着 Next.js v13 和 v14 版本发布,React 仍然是金丝雀版本的 RSC 被 Next.js 搬到生产环境,‘use client’‘use server’ 被越来越多人讨论,开发者们说现在有「两个 React」,社区开始争吵 React 这些年在进步还是在退步?


WechatIMG4559.jpeg


社区里反对的声音


首先是知名软件工程师 Cassidy Williams,她指出 React 这两年的发展问题:



  • 「两个 React」带来的新概念对大多数人来说并不是清晰易懂的知识,这种分裂可能导致了额外的混淆和学习障碍。

  • 自 2022 年 6 月以来 React 不仅没有新的发布,还鼓励开发者使用上层框架,而这些上层框架不等 RSC 升级成稳定版,就发布了基于 RSC 的特性(就差点名 Next.js 了)。

  • React 近些年有成员加入其他上层框架的团队,不仅疏于更新版本,还疏于更新文档。


React Query 的开发者 Tanner Linsley 也对 React 的发展表达了担忧和不满:



  • 自从 React 引入 hooks 和 suspense API 以来,React 过分专注于少数几个概念,这些新概念虽然在技术上推动了单线程 UI API 的极限和边界,但对他日常为用户提供价值的工作影响甚微。

  • 从 RSC 发布看出来,React 团队对客户端性能已经没有那么强烈的追求了。


地图技术和可视化技术专家 Tom MacWright 对 React 生态系统的分裂进行了批评:



  • 当前 React 更新缓慢,反而说两个上层框架Remix(由 Shopify 资助)和 Next.js(由 Vercel 资助)在激烈竞争。

  • React 团队和 Next.js 团队交集过多,让 Vercel 获得了领先优势,那些不属于 Vercel 和 Facebook 生态系统的其他框架,如 Remix,它们会受到 React 中已修复但未发布错误的影响。


社区里积极的态度


面对社区里越来越多的反对声音,React 主要贡献者 Dan Abramov 也多次发表里自己看法,他对技术的变革持开放态度:



  • Next.js 的 App Router 有着雄心壮志,但是现在还是发展初期,未来会迭代得更优秀。

  • 客户端组件的工作是 UI = f(state),服务端组件的工作是 UI = f(data),React 希望组合二者的优势,实现 UI = f(data, state),他号召社区共同推动实现这一目标。

  • 对于 Next.js 把 RSC 发布到生产版本,Dan 认为“生产就绪”是一个主观的判断,虽然 RSC 还是金丝雀版本,但是 Facebook 也已经大量使用了。他认为在实践中验证才能更快完善技术,最终达到成熟和稳定。

  • 新技术的发展是一个渐进的过程,涉及到不断的测试、反馈和迭代,社区的力量非常重要。


总的来说,Dan 是希望大家放下偏见,共同在实践中摸索出 React 下一阶段的变革。


我的观点


在 RSC 的讨论中,我比较认同 Dan 提出的开放和包容性的观点。我认为,面对技术的发展,要抛弃个人偏见,可以实践验证,也可以持续观察它们的发展。只有心态上拥抱变革,开发者才能在变革中找到机遇。


RSC 在提升现代 Web 应用开发绝对是有积极意义的,最显而易见的优势是它可以提高大型应用的性能、减少客户端负载、优化数据获取流程等,通过 RSC 完成这些工作会比以往的 SSR 方案要更加方便。


随着 Node v20 的发布和 RSC 的应用,前端和服务端的距离进一步缩小,我们有机会见证前端工作“后端化”——前端工程师会处理更多传统上属于后端的工作,如数据查询优化、服务器资源管理等。这实际上为前端工程师打开了一扇门,让我们有机会更全面地掌握整个 web 应用的开发流程,也就是我们常说的“全栈开发”。这样的转变势必会提高前端的职业天花板和扩大前端工作的广度。


作者:BigYe程普
来源:juejin.cn/post/7330602636934774823
收起阅读 »

React 19 发布在即,抢先学习一下新特性

web
React 上一次发布版本还要追溯到2022年6月14日,版本号是18.2.0。在大前端领域,一项热门技术更新如此缓慢属实罕见。这也引起社区里一些大佬的不满,在我的上一篇文章里有提到,感兴趣的朋友可以点击查看:React 社区里的分歧。 在社区不满的声音越来越...
继续阅读 »

React 上一次发布版本还要追溯到2022年6月14日,版本号是18.2.0。在大前端领域,一项热门技术更新如此缓慢属实罕见。这也引起社区里一些大佬的不满,在我的上一篇文章里有提到,感兴趣的朋友可以点击查看:React 社区里的分歧


在社区不满的声音越来越大的背景下,React 新版本的消息终于来了。


React 团队也回应了迟迟未发布新的正式版本的质疑:此前发布到 Canary 版本的多项特性,因为这些特性是相互关联的,所以 React 团队需要投入大量时间确保它们能够协同工作,然后才能逐步发布到 Stable 版本。


事实也确实如此,虽然在这将近两年的时间里 React 没有发布正式版本,但是 Canary 却有一些重磅更新,例如:useuseOptimistic hook,use clientuse server 指令。这些更新客观上丰富了 React 生态系统,特别是推动了 Next.js 和 Remix 等全栈框架的高速发展。


React 团队已经确定,下一个版本将是大版本号,即版本号会是 19.0.0。


v19 新特性预测


现在,让我们根据 React 团队最新发布的消息,来抢先学习一下 v19 版本可能正式发布的新特性。


自动记忆化


你是否还记得 React Conf 2021 上黄玄介绍的 React Forget?




现在,它来了。


它是一个编译器,目前已经在 instagram 的生产环境中应用,React 团队计划在 Meta 的更多平台中应用,并且未来会进行开源发布。


在使用新编译器以前,我们使用 useMemouseCallbackmemo 来手动缓存状态,以减少不必要的重新渲染,这种实现方式虽然可行,但 React 团队认为这并不是他们认为理想的方式,他们一直寻找让 React 在状态变化时自动且只重新渲染必要部分的方案。经过多年的攻坚,现在新的编译器成功落地了。


新的 React 编译器会是一个开箱即用的特性,对开发者来说是又一次开发范式的改变,这也是 v19 最让人期待的功能。


好玩的是,React 团队在介绍新编译器时完全没有提到“React Forget”,这也让好事的网友爆梗了:They forget React Forget & forget to mentioned Forget in the Forget section.🤣


Actions


React Actions 是 React 团队在探索客户端向服务器发送数据的解决方案过程中发展出来的,这个功能允许开发者向 DOM 元素(如 <form/>)传递一个函数:

<form action={search}>
<input name="query" />
<button type="submit">Search</button>
</form>

action 函数可以同步或异步操作。使用 action 时,React 将为开发者管理数据提交的生命周期,我们可以通过 useFormStatususeFormState 这两个 hook 来访问表单操作的当前状态和响应。


action 可以在执行数据库变更(如增加、删除、更新数据)和实现表单(如登录表单、注册表单)等客户端到服务器交互的场景中使用。


action 不仅可以与 useFormStatususeFormState 结合使用,还可以用与 useOptimisticuse server 结合使用。详细展开篇幅就会很长了,你可以关注我,很快我会单独写一篇文章介绍 action 的详细用法。


指令:use client 与 use server


use clientuse server 两个指令在 Canary 版本发布已久,终于也要在 v19 版本里加入 Stable 版本了。


此前社区频频有人因为 Next.js 在生产环境使用这两个指令而指责 Next.js 在破坏 React 生态、批评 React 团队纵容 Next.js 超前使用非稳定特性。其实大可不必,因为这两个指令就是为 Next.js 和 Remix 这样的全栈框架设计的,短期内普通开发者使用 React 开发应用几乎不会用到它们。


如果你是使用 React,而不是使用全栈框架,你只需要了解这两个指令的作用即可:use clientuse server 标记了前端和服务端两个环境的“分割点”,use client 指示打包工具生成一个 <script> 标签,而 use server 告诉打包工具生成一个POST端点。这两个指令能够让开发者在一份文件里同时写客户端代码和服务端代码。



💡 如果你对这两个指令感兴趣,可以来看我的另一篇文章:「🌍NextJS v13服务端组件和客户端组件及最佳实践



useOptimistic 乐观更新



💡 乐观更新:是一种在前端开发中常用的处理异步操作反馈的策略。它基于一种“乐观”的假设:即假设无论我们向服务器发送什么请求,这些操作都将成功执行,因此在得到服务器响应之前,我们就提前在用户界面上渲染这些改变。

使用场景:点赞、评论、任务添加编辑等。



useOptimistic 是一个新的 hook,很可能在 v19 版本中被标记为稳定版。useOptimistic 允许你在异步操作(如网络请求)进行时,乐观地更新 UI。它通过接受当前状态和一个更新函数作为参数,返回一个在异步操作期间可能会有所不同的状态副本。你需要提供一个函数,这个函数接收当前状态和操作的输入,并返回在操作等待期间使用的乐观状态。


它的用法定义如下:

const [optimisticState, addOptimistic] = useOptimistic(state, updateFn);

// or

const [optimisticState, addOptimistic] = useOptimistic(
state,
// updateFn
(currentState, optimisticValue) => {
// merge and return new state with optimistic value
}
);

参数



  • state: 初始状态值,以及在没有操作进行时返回的值。

  • updateFn(currentState, optimisticValue) : 一个函数,接收当前状态和传递给 addOptimistic 的乐观值,返回结果乐观状态。updateFn 接收两个参数:currentStateoptimisticValue。返回值将是 currentStateoptimisticValue 的合并值。


返回值



  • optimisticState: 产生的乐观状态。当有操作正在进行,它等于 updateFn 返回的值,没有操作正在进行,它等于 state

  • addOptimistic: 这是在进行乐观更新时调用的调度函数。它接受一个参数 optimisticValue(任意类型),并调用带有 stateoptimisticValueupdateFn


更详细的用法如下:

import { useOptimistic } from 'react';

function AppContainer() {
const [state, setState] = useState(initialState); // 假设有一个初始状态
const [optimisticState, addOptimistic] = useOptimistic(
state,
// updateFn
(currentState, optimisticValue) => {
// 合并返回:新状态、乐观值
return { ...currentState, ...optimisticValue };
}
);

// 假设有一个异步操作,如提交表单
function handleSubmit(data) {
// 在实际数据提交前,使用乐观更新
addOptimistic({ data: 'optimistic data' });

// 然后执行异步操作
fetch('/api/submit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
})
.then(response => response.json())
.then(realData => {
// 使用实际数据更新状态
setState(prevState => ({ ...prevState, data: realData }));
});
}

return (
// 使用 optimisticState 来渲染 UI
<div>{optimisticState.data}</div>
);
}



useOptimistic 会在异步操作进行时先渲染预期的结果,等到异步操作完成,状态更新后,再渲染真实的返回结果(无论成功和失败)。


其它更新


除此之外,React 团队成员 Andrew Clark 还透露2024年还会有以下变化:



  • forwardRef → ref is a prop:简化对子组件内部元素或组件的引用方式,使 ref 作为一个普通的prop传递

  • React.lazy → RSC, promise-as-child:增强了代码分割和懒加载能力

  • useContext → use(Context):提供一种新的方式来访问 Context

  • throw promise → use(promise):改进异步数据加载的处理方式

  • <Context.Provider> → <Context>:简化了上下文提供者的使用


但目前 React 官网没有对以上潜在更新提供详细的信息。


总结


React 的愿景很大,他们希望打破前端和后端的边界,在维持自身客户端能力优势的基础上,同时为社区的全栈框架提供基建。我非常认可他们的做法,因为打破了端的边界,才能帮助前端工程师打破职业天花板。


React 19 会是引入 hooks 之后又一次里程碑式版本,Andrew Clark 说新版本将在 3 月或 4 月发布,让我们拭目以待!


作者:BigYe程普
来源:juejin.cn/post/7339221543992426559
收起阅读 »

MyBatis-Plus快速入门指南:零基础学习也能轻松上手

在Java开发的世界里,持久层框架的选择对于项目的成功至关重要。今天,我们要聊的主角是MyBatis-Plus——一个增强版的MyBatis,它以其强大的功能、简洁的代码和高效的性能,正在成为越来越多开发者的新宠。那么,MyBatis-Plus到底是什么?又该...
继续阅读 »

在Java开发的世界里,持久层框架的选择对于项目的成功至关重要。今天,我们要聊的主角是MyBatis-Plus——一个增强版的MyBatis,它以其强大的功能、简洁的代码和高效的性能,正在成为越来越多开发者的新宠。

那么,MyBatis-Plus到底是什么?又该如何快速入门呢?让我们一起探索这个强大的工具。

一、MyBatis-Plus简介

1、简介

MyBatis-Plus (opens new window)(简称 MP)是一个 MyBatis (opens new window)的增强工具,在 MyBatis 的基础上只做增强不做改变,为简化开发、提高效率而生。

Description

2、特性

无侵入: 只做增强不做改变,引入它不会对现有工程产生影响,如丝般顺滑。

损耗小: 启动即会自动注入基本 CURD,性能基本无损耗,直接面向对象操作,BaseMapper。

强大的 CRUD 操作: 内置通用 Mapper、通用 Service,仅仅通过少量配置即可实现单表大部分 CRUD 操作,更有强大的条件构造器,满足各类使用需求,简单的CRUD操作不用自己编写。

支持 Lambda 形式调用: 通过 Lambda 表达式,方便的编写各类查询条件,无需再担心字段写错。

支持主键自动生成: 支持多达 4 种主键策略(内含分布式唯一 ID 生成器 - Sequence),可自由配置,完美解决主键问题。

支持 ActiveRecord 模式: 支持 ActiveRecord 形式调用,实体类只需继承 Model 类即可进行强大的 CRUD 操作。

支持自定义全局通用操作: 支持全局通用方法注入( Write once, use anywhere )。

内置代码生成器: 采用代码或者 Maven 插件可快速生成 Mapper 、 Model 、 Service 、 Controller 层代码,支持模板引擎,更有超多自定义配置等您来使用(自动生成代码)。

内置分页插件: 基于 MyBatis 物理分页,开发者无需关心具体操作,配置好插件之后,写分页等同于普通 List 查询。

分页插件支持多种数据库: 支持 MySQL、MariaDB、Oracle、DB2、H2、HSQL、SQLite、Postgre、SQLServer 等多种数据库。

内置性能分析插件: 可输出 SQL 语句以及其执行时间,建议开发测试时启用该功能,能快速揪出慢查询。

内置全局拦截插件: 提供全表 delete 、 update 操作智能分析阻断,也可自定义拦截规则,预防误操作。

3、框架结构

Description

二、快速入门

1.开发环境

2.创建数据库和表

1)创建表单

CREATE DATABASE `mp_study` /*!40100 DEFAULT CHARACTER SET utf8mb4 */;
use `mp_study`;
CREATE TABLE `user` (
`id` bigint(20) NOT NULL COMMENT '主键ID',
`name` varchar(30) DEFAULT NULL COMMENT '姓名',
`age` int(11) DEFAULT NULL COMMENT '年龄',
`email` varchar(50) DEFAULT NULL COMMENT '邮箱',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

2)添加数据

INSERT INTO user (id, name, age, email) VALUES
(1, 'Jone', 18, 'test1@baomidou.com'),
(2, 'Jack', 20, 'test2@baomidou.com'),
(3, 'Tom', 28, 'test3@baomidou.com'),
(4, 'Sandy', 21, 'test4@baomidou.com'),
(5, 'Billie', 24, 'test5@baomidou.com');

3. 创建SpringBoot工程

1)初始化工程

2)导入依赖

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.1</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>

4. 编写代码

1)配置application.yml

# DataSource Config
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/mybatis_plus?useSSL=false&useUnicode=true&characterEncoding=utf-8&serverTimezone=GMT%2B8
username: root
password: 1234

2)启动类

在Spring Boot启动类中添加@MapperScan注解,扫描mapper包

@MapperScan("cn.frozenpenguin.mapper")
@SpringBootApplication
public class MybatisPlusStudyApplication {

public static void main(String[] args) {
SpringApplication.run(MybatisPlusStudyApplication.class, args);
}
}

3)添加实体类

@Data//lombok注解
public class User {
private Long id;
private String name;
private Integer age;
private String email;
}

4)添加mapper
BaseMapper是MyBatis-Plus提供的模板mapper,其中包含了基本的CRUD方法,泛型为操作的实体类型

public interface UserMapper extends BaseMapper<User> {
}

5)测试

@Autowired
private UserMapper userMapper;

@Test
void test01(){
List<User> users = userMapper.selectList(null);
for (User user : users) {
System.out.println(user);
}
}

结果
Description
注意:

IDEA在 userMapper 处报错,因为找不到注入的对象,因为类是动态创建的,但是程序可以正确执行。为了避免报错,可以在mapper接口上添加 @Repository注解。

6)添加日志

我们所有的sql现在是不可见的,我们希望知道它是怎么执行的,所以我们必须要看日志!

在application.yml中配置日志输出

# 配置日志
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
mapper-locations:

三、基本CRUD

1.插入

 @Test
void insert()
User user = new User(null, "lisi", 2, "aaa@qq.com");
int insert = userMapper.insert(user);
System.out.println("受影响行数"+insert);
//1511332162436071425
System.out.println(user.getId());
}

id设置为null,却插入了1511332162436071425,这是因为MyBatis-Plus在实现插入数据时,会默认基于雪花算法的策略生成id。

2.删除

1)通过id删除记录

@Test
void testDeleteById(){
//DELETE FROM user WHERE id=?
int result = userMapper.deleteById(1);
System.out.println("受影响行数:"+result);
}
  1. 通过id批量删除记录
@Test
void testDeleteBatchIds(){
//DELETE FROM user WHERE id IN ( ? , ? , ? )
int result = userMapper.deleteBatchIds(ids);
System.out.println("受影响行数:"+result);
}
  1. 通过map条件删除记录
@Test
void testDeleteByMap(){
//DELETE FROM user WHERE name = ? AND age = ?
Map<String,Object> map=new HashMap<>();
map.put("age",12);
map.put("name","lisi");
int result = userMapper.deleteByMap(map);
System.out.println("受影响行数:"+result);
}

3. 修改

@Test
void testUpdateById(){
//SELECT id,name,age,email FROM user WHERE id=?
User user = new User(10L, "hello", 12, null);
int result = userMapper.updateById(user);
//注意:updateById参数是一个对象
System.out.println("受影响行数:"+result);
}

4.自动填充

  • 创建时间、修改时间!这些个操作都是自动化完成的,我们不希望手动更新!

  • 阿里巴巴开发手册:所有的数据库表:gmt_create、gmr_modified、几乎所有的表都要配置上!而且需要自动化!

方式一:数据库级别(工作中不允许修改数据库)

1)在表中新增字段 create_time, update_time;

Description

2)再次测试插入方法,我们需要先把实体类同步!

3)再次更新查看结果即可。

Description

方式二:代码级别

  • 删除数据库的默认值,更新操作

  • 实体类的字段属性上需要加注解

@TableField(fill = FieldFill.INSERT)
private Date createTime;

@TableField(fill = FieldFill.INSERT_UPDATE)
private Date updateTime;
  • 编写处理器处理注解
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
// 起始版本 3.3.0(推荐使用)
this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now());
}


@Override
public void updateFill(MetaObject metaObject) {
// 起始版本 3.3.0(推荐)
this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
}
}
  • 测试插入

  • 测试更新、观察时间即可

在这里给大家分享一下【云端源想】学习平台,无论你是初学者还是有经验的开发者,这里都有你需要的一切。包含课程视频、知识库、微实战、云实验室、一对一咨询等等,现在功能全部是免费的,

点击这里,立即开始你的学习之旅!

5.查询

  • 与查询基本一致;

  • 根据id查询用户信息;

  • 根据多个id查询多个用户信息;

  • 通过map条件查询用户信息;

  • 查询所有数据;

@Test
void test01(){
List<User> users = userMapper.selectList(null);
for (User user : users) {
System.out.println(user);
}
}

通过观察BaseMapper中的方法,大多方法中都有Wrapper类型的形参,此为条件构造器,可针 对于SQL语句设置不同的条件,若没有条件,则可以为该形参赋值null,即查询(删除/修改)所有数据。

6.通用Service

说明:

  • 通用 Service CRUD 封装IService接口,进一步封装 CRUD;

  • 采用 get 查询单行;

  • remove 删除;

  • list 查询集合;

  • page 分页;

  • 前缀命名方式区分 Mapper 层避免混淆;

  • 泛型 T 为任意实体对象;

  • 建议如果存在自定义通用 Service 方法的可能,请创建自己的 IBaseService 继承 Mybatis-Plus 提供的基类;

  • 官网地址:https://baomidou.com/pages/49cc81/#service-crud-%E6%8E%A5%E5%8F%A3。

1)IService

MyBatis-Plus中有一个接口 IService和其实现类 ServiceImpl,封装了常见的业务层逻辑 详情查看源码IService和ServiceImpl。

2)创建Service接口和实现

/**
* UserService继承IService模板提供的基础功能
*/
public interface UserService extends IService<User> {
}
/**
* ServiceImpl实现了IService,提供了IService中基础功能的实现
* 若ServiceImpl无法满足业务需求,则可以使用自定的UserService定义方法,并在实现类中实现
*/
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
}

3)测试查询记录数

@Test
void testGetCount(){
long count = userService.count();
System.out.println("总记录数:" + count);
}

4)测试批量插入

@Test
void testSaveBatch(){
// SQL长度有限制,海量数据插入单条SQL无法实行,
// 因此MP将批量插入放在了通用Service中实现,而不是通用Mapper
ArrayList<User> users = new ArrayList<>();
for (int i = 0; i < 5; i++) {
User user = new User();
user.setName("lyl"+i);
user.setAge(20+i);
users.add(user);
}
//SQL:INSERT INTO t_user ( username, age ) VALUES ( ?, ? )
userService.saveBatch(users);
}

原理:先把user对象存到list(存在内存中),然后直接save集合list中的所有user。

MyBatis-Plus作为MyBatis的增强版,不仅继承了MyBatis的所有优点,还在此基础上做了大量的改进和扩展。它的出现,无疑为Java开发者提供了一个更为强大、便捷的数据操作工具。

技术的世界总是在不断进步,而我们作为开发者,也需要不断学习新的工具和技术。MyBatis-Plus正是这样一把钥匙,它能打开高效数据操作的大门。希望本文能帮助您快速入门MyBatis-Plus!

收起阅读 »

【HTML】交友软件上照片的遮罩是如何做的

web
笑谈 我不知道大家有没有在夜深人静的时候感受到孤苦难耐,🐶。于是就去下了一些交友软件来排遣寂寞。可惜的是,有些交友软件真不够意思,连一些漂亮小姐姐的图片都要进行遮罩,完全不考虑兄弟们的感受,😠。所以今天,我们就一起来看看这些软件的遮罩是如何做的,🐶。 调研...
继续阅读 »

笑谈


我不知道大家有没有在夜深人静的时候感受到孤苦难耐,🐶。于是就去下了一些交友软件来排遣寂寞。可惜的是,有些交友软件真不够意思,连一些漂亮小姐姐的图片都要进行遮罩,完全不考虑兄弟们的感受,😠。所以今天,我们就一起来看看这些软件的遮罩是如何做的,🐶。


调研


市场上这些交友软件比较多,就拿一个我朋友他经常玩的一个软件来研究,叫做《XX之恋》,重申一下,我这里没有任何打广告的嫌疑,毕竟是我朋友玩的,🐶。我们接下来看这软件中遮罩的图片。



注:我实在没有在网上找到该软件这些有遮罩的图片,所以只好从自己的主页上截取了下,如果有当事人认为这是自己的话,请速与我联系,我会及时删除的。




正如上面所见,该软件的遮罩效果还是非常不错的,为什么说非常不错呢?个人认为有两个亮点,🐶保命。



  1. 这个遮罩效果让我们知道对面是女生。

  2. 这个遮罩效果也仅仅只能让我们知道对面是女生。


言归正传,这种效果在我们悠久的前端历史上,有一种专业名词 --> 毛玻璃效果



碎碎念:我看了蛮多毛玻璃的技术文章,这个技术大家说都是为了让能人阅读的时候更赏心悦目,能用来遮小姐姐也算是很不错的创新了。🐶



实现


现在我们只需要两样东西,一个是小姐姐的图片,一个是前端的小知识。我都准备好啦。首先我们先介绍知识点。


backdrop-filter属性


我们看MDN的介绍文档


可以让你为一个元素后面区域添加图形效果(如模糊或颜色偏移)。因为它适用于元素背后的所有元素,为了看到效果,必须使元素或其背景至少部分透明。


backdrop-filter有如下常用属性(带了数值,方便理解)



  • 🌟blur(2px): 对元素背后的背景应用2像素的模糊效果。

  • brightness(60%): 将元素背景的亮度调整为原始亮度的60%。

  • contrast(40%): 将元素背景的对比度调整为原始对比度的40%。

  • ...............等


我们主要使用的便是blur属性,对背景图片模糊,达到类似的效果。


我采用的小姐姐图片如下




思路:使用两个Class。第一个Class的背景图片是上面的小姐姐,第二个Class完全覆盖第一个Class,设置blur10px即可。如下是代码。

效果图如下:





最后,希望大家多多点赞支持,兄弟们的点赞支持是我继续写文章的动力!



作者:鑫宝Code
来源:juejin.cn/post/7333986476030935050
收起阅读 »