注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

vue3的宏到底是什么东西?

web
前言 从vue3开始vue引入了宏,比如defineProps、defineEmits等。我们每天写vue代码时都会使用到这些宏,但是你有没有思考过vue中的宏到底是什么?为什么这些宏不需要手动从vue中import?为什么只能在setup顶层中使用这些宏? ...
继续阅读 »

前言


vue3开始vue引入了宏,比如definePropsdefineEmits等。我们每天写vue代码时都会使用到这些宏,但是你有没有思考过vue中的宏到底是什么?为什么这些宏不需要手动从vueimport?为什么只能在setup顶层中使用这些宏?


vue 文件如何渲染到浏览器上


要回答上面的问题,我们先来了解一下从一个vue文件到渲染到浏览器这一过程经历了什么?


我们的vue代码一般都是写在后缀名为vue的文件上,显然浏览器是不认识vue文件的,浏览器只认识html、css、jss等文件。所以第一步就是通过webpack或者vite将一个vue文件编译为一个包含render函数的js文件。然后执行render函数生成虚拟DOM,再调用浏览器的DOM API根据虚拟DOM生成真实DOM挂载到浏览器上。


progress.png


vue3的宏是什么?


我们先来看看vue官方的解释:



宏是一种特殊的代码,由编译器处理并转换为其他东西。它们实际上是一种更巧妙的字符串替换形式。



宏是在哪个阶段运行?


通过前面我们知道了vue 文件渲染到浏览器上主要经历了两个阶段。


第一阶段是编译时,也就是从一个vue文件经过webpack或者vite编译变成包含render函数的js文件。此时的运行环境是nodejs环境,所以这个阶段可以调用nodejs相关的api,但是没有在浏览器环境内执行,所以不能调用浏览器的API


第二阶段是运行时,此时浏览器会执行js文件中的render函数,然后依次生成虚拟DOM和真实DOM。此时的运行环境是浏览器环境内,所以可以调用浏览器的API,但是在这一阶段中是不能调用nodejs相关的api


而宏就是作用于编译时,也就是从vue文件编译为js文件这一过程。


举个defineProps的例子:在编译时defineProps宏就会被转换为定义props相关的代码,当在浏览器运行时自然也就没有了defineProps宏相关的代码了。所以才说宏是在编译时执行的代码,而不是运行时执行的代码。


一个defineProps宏的例子


我们来看一个实际的例子,下面这个是我们的源代码:


<template>
<div>content is {{ content }}div>
<div>title is {{ title }}div>
template>

<script setup lang="ts">
import {ref} from "vue"
const props = defineProps({
content: String,
});
const title = ref("title")
script>

在这个例子中我们使用defineProps宏定义了一个类型为String,属性名为contentprops,并且在template中渲染content的内容。


我们接下来再看看编译成js文件后的代码,代码我已经进行过简化:


import { defineComponent as _defineComponent } from "vue";
import { ref } from "vue";

const __sfc__ = _defineComponent({
props: {
content: String,
},
setup(__props) {
const props = __props;
const title = ref("title");
const __returned__ = { props, title };
return __returned__;
},
});

import {
toDisplayString as _toDisplayString,
createElementVNode as _createElementVNode,
Fragment as _Fragment,
openBlock as _openBlock,
createElementBlock as _createElementBlock,
} from "vue";

function render(_ctx, _cache, $props, $setup) {
return (
_openBlock(),
_createElementBlock(
_Fragment,
null,
[
_createElementVNode(
"div",
null,
"content is " + _toDisplayString($props.content),
1 /* TEXT */
),
_createElementVNode(
"div",
null,
"title is " + _toDisplayString($setup.title),
1 /* TEXT */
),
],
64 /* STABLE_FRAGMENT */
)
);
}
__sfc__.render = render;
export default __sfc__;

我们可以看到编译后的js文件主要由两部分组成,第一部分为执行defineComponent函数生成一个 __sfc__ 对象,第二部分为一个render函数。render函数不是我们这篇文章要讲的,我们主要来看看这个__sfc__对象。


看到defineComponent是不是觉得很眼熟,没错这个就是vue提供的API中的 definecomponent函数。这个函数在运行时没有任何操作,仅用于提供类型推导。这个函数接收的第一个参数就是组件选项对象,返回值就是该组件本身。所以这个__sfc__对象就是我们的vue文件中的script代码经过编译后生成的对象,后面再通过__sfc__.render = renderrender函数赋值到组件对象的render方法上面。


我们这里的组件选项对象经过编译后只有两个了,分别是props属性和setup方法。明显可以发现我们原本在setup里面使用的defineProps宏相关的代码不在了,并且多了一个props属性。没错这个props属性就是我们的defineProps宏生成的。


convert.png


我们再来看一个不在setup顶层调用defineProps的例子:


<script setup lang="ts">
import {ref} from "vue"
const title = ref("title")

if (title.value) {
const props = defineProps({
content: String,
});
}
script>

运行这个例子会报错:defineProps is not defined


我们来看看编译后的js代码:


import { defineComponent as _defineComponent } from "vue";
import { ref } from "vue";

const __sfc__ = _defineComponent({
setup(__props) {
const title = ref("title");
if (title.value) {
const props = defineProps({
content: String,
});
}
const __returned__ = { title };
return __returned__;
},
});

明显可以看到由于我们没有在setup的顶层调用defineProps宏,在编译时就不会将defineProps宏替换为定义props相关的代码,而是原封不动的输出回来。在运行时执行到这行代码后,由于我们没有任何地方定义了defineProps函数,所以就会报错defineProps is not defined


总结


现在我们能够回答前面提的三个问题了。



  • vue中的宏到底是什么?


    vue3的宏是一种特殊的代码,在编译时会将这些特殊的代码转换为浏览器能够直接运行的指定代码,根据宏的功能不同,转换后的代码也不同。


  • 为什么这些宏不需要手动从vueimport


    因为在编译时已经将这些宏替换为指定的浏览器能够直接运行的代码,在运行时已经不存在这些宏相关的代码,自然不需要从vueimport


  • 为什么只能在setup顶层中使用这些宏?


    因为在编译时只会去处理setup顶层的宏,其他地方的宏会原封不动的输出回来。在运行时由于我们没有在任何地方定义这些宏,当代码执行到宏的时候当然就会报错。



如果想要在vue中使用更多的宏,可以使用 vue macros。这个库是用于在vue中探索更多的宏和语法糖,作者是vue的团队成员 三咲智子


作者:欧阳码农
来源:juejin.cn/post/7335721246931189795
收起阅读 »

【如诗般写代码】你甚至连注释都没玩明白

web
引言要问我认为最难的事是什么,那只有维护前人的代码。我经常能看见一个文件里,写上百行,没有几个函数,没有注释,没有换行,一头扎进去,闷头写到天昏地暗的什么修复bug,提升性能之类的都不是事,会用调试工具和分时渲染就够了但是看屎山是真的难受注释篇利用注释在编辑器...
继续阅读 »

引言

要问我认为最难的事是什么,那只有维护前人的代码。
我经常能看见一个文件里,写上百行,没有几个函数,没有注释,没有换行,
一头扎进去,闷头写到天昏地暗的

什么修复bug,提升性能之类的都不是事,会用调试工具和分时渲染就够了
但是看屎山是真的难受


注释篇

  1. 利用注释在编辑器开启代码提示

    image.png

    看到区别了吗,左边用的是文档注释,鼠标悬浮能看到变量描述
    右边用的是行内注释,没有任何作用

    初步认识了文档注释的好处后,很多人可能还是不用,因为嫌麻烦。
    所以编辑器也为你着想了,以 VSCode为例,输入 /**,就会自动生成文档注释
    如果在函数上面,再按下回车,还能补齐函数参数文档,如下图所示

    comment.gif


  1. 利用文档注释描述函数功能

    当我鼠标悬浮在函数上时,就能看到他的各种描述,从其他文件导入也有效
    image.png


  1. 智能提示当前参数描述,以及类型等

    这里也能用快捷键呼出,详见我上篇文章:# 【最高效编码指南】也许你不会用VSCode | IDEA

    image.png


  1. 添加 JS 类型,实现类似 TS 的效果

    这里我呼出代码提示,但是他并没有给我补全任何方法,因为他不知道你的类型是什么

    image.png

    如果是强类型语言的话,那就会给你补全代码
    那么动态类型如何实现呢?以 JS 为例,使用文档注释即可,也就是前阵子沸沸扬扬的利用 JSDoc 代替 TS

    image.png

    不仅于此,连枚举都能实现,反正 TS 有的,他应该都有,我没有详细研究

    image.png

  2. 文档注释指令

    如下图所示,我想在文档注释里写上用法,但是他的格式十分丑陋,而且没有语法高亮

    image.png

    于是我使用 example 指令,告诉他这是一个示例,这时就有语法高亮了

    image.png

    指令还有很多,你们输入 @ 就会有提示了,比如 deprecated,标记已弃用
    这时你使用它就会有个提示,并且划上一根线

    image.png

  3. MarkDown 文档注释

    有时候,指令可能不够用,这时就可以使用 MarkDown 语法了

    image.png

  4. 结合 TS

    定义类型时,写上文档注释,当你鼠标悬浮时,就能查看对应注释

    image.png

    函数重载情况下,文档注释要写在类型上才行,下面这种无效

    image.png

    要写在类型定义的地方才行

    image.png

  5. 总结

    如果你用的是变量、函数或是 TS 定义类型,你要写注释,那就一定要写 文档注释,我跪下来求求你了 😭

减少条件分支语句

  1. 策略模式,写个映射表即可。这个有一点开发经验的应该都知道吧

    如果遇到复杂情况,映射表里也可以写函数,执行后返回逻辑

    image.png

  2. 提前返回

    这里第 2 种提前返回就减少了一层嵌套,实际开发中,能减少更多嵌套语句

    image.png

  3. 多个相等判断,使用数组代替

    image.png

代码七宗罪

让我来细数一下这坨代码的罪行,然后引出另一个主题,美化代码
下面这段,这简直是"甲级战犯",

  1. 一堆变量写了或者导入了不用,放那恶心谁呢
  2. 注释了的代码不删 (虽然可能有用,但是真丑)
  3. 都什么年代了,还在用var (坏处下面说)
  4. 用行内注释和没写区别不大,要写就写文档注释 (文档注释的优点上面解释了,不再赘述)
  5. 小学生流水账一般的代码,连个函数入口都没提供,想一句写一句
  6. 连个代码格式化都不会,多按几个回车,你的键盘不会烂掉;每个分段加个注释,你的速度慢不了多少
  7. 硬编码,所有类型用字符串直接区分,你万一要改怎么办?

image.png

语义化

我经常能看见一个文件里,写上百行,没有几个函数,没有注释,没有换行
一头扎进去,闷头写到天昏地暗的,比如下面这种

image.png

这玩意要我一行一行看?我是真的被恶心坏了
写代码要突出一个重点,看个大概,然后才能快速排查,看第三方库源码也是如此

我的习惯是写一个主入口,你叫 main | init | start 什么的都行,我只希望你能写上
然后主入口按照逻辑,给每个函数命名,这样一眼就能看出来你在干什么
如下图所示,这是我的偏好

image.png

我喜欢利用函数全局提升,把初始化函数放在文件顶部。这样每次打开一个文件,就能立刻看到大概逻辑
所以我很少用匿名函数,像上面那种全部写一坨,还都是匿名函数,我真的很难看出来谁是函数,谁是变量

这就引出一个新问题,函数的二义性

函数二义性

众所周知, JS 的类就是函数,里面有自己的 this,可以 new 一个函数

image.png

你要知道他是函数还是类,一般是通过首字母是否大写区分
但是这仅仅是弱规范,人家爱咋写咋写,所以后来出现了匿名函数(主要还是为了解决 this)

匿名函数没有自己的 this 指向,没有 arguments,如下图

image.png

而且用 const 定义,所以也就没了函数提升,严格来说,匿名函数才是真函数

不过我觉得直接写匿名函数有点丑,而且写起来似乎繁琐一点,虽然我都是用代码片段生成的
如果用了匿名函数,那么我就没了函数提升了

所以我仅仅在以下情况使用匿名函数

  1. 作为回调函数
  2. 不需要 this
  3. 函数重载

函数重载我来说说吧,应该挺多人不知道。
比如下图,针对每一种情况,写一遍类型,这样就能更加清楚描述函数的所有参数情况

image.png

不过这样好麻烦,而且好丑啊,于是可以用接口,这时你用 function 就实现不了了

image.png

var 的坏处

  1. var 会变量提升,你可能拿到 undefined

    image.png

  2. var 没有块级作用域,会导致变量共享

    按照常识,下面代码应该输出 0,1,2,3,4

    image.png

    但是你里面是异步打印,于是等你打印时,i 以及加了5次了,又没有块级作用域,所以你拿到的是同一个东西

    在古时候,是用立即执行函数解决的,如下图。因为函数会把变量存起来传给内部

    image.png

    现在用 let 就行了

    image.png

    所以我求求你别用 var 了

格式化

这里可能有争议性,仅仅是我个人喜欢,看着舒服

大多数写前端的,基本人手一个 Prettier 插件自动格式化,再来个 EsLint
然后也懒得看配置,默认就是 2 格缩进,回车多了会被删掉什么的

这样下来,整个文件就相当臃肿,密密麻麻的,我看着很难受

我的风格如下

  • 用 4 格缩进
  • 代码按照语义类型分块,写上块级文档注释
  • import 语句下面空两行,这样更加直观
  • 每一段,用独特醒目的文档注释划分
  • 定义变量优先使用 const,并且只写一个 const
  • 函数参数过长,则一行放一个参数
  • 写行内样式以及较长字符串时( 比如函数作为字符串 ),用特殊的宽松格式书写,保持类似代码的格式化
  • if 分支语句,要多空一行,看着清爽
  • 三目运算,用三行来写
  • 条件判断尽量提前 return,减少分支缩进

下面来用图演示一下,不然看着上面的描述抽象

代码按照语义类型分块,写上块级文档注释

每一段逻辑写完,用个醒目的、大块的文档注释分开。
全部执行的逻辑,放在一个 init 函数中

image.png

定义变量优先使用 const,并且只写一个 const

比如声明变量,我喜欢这么写
按照分类,类型不同则换行,并且写上注释,仅用一个 const

image.png

来看看大众写法,可以说 99.9878987%的人都这么写,这种我一看就难受

image.png

如果你用 let,并且用 4 格缩进,那么你就刚好对齐了,能少写一个回车
不过尽量使用 const

image.png

函数参数过长,则一行放一个参数

如果你这么写,那我看完会头晕眼花,属实是又臭又长的参数列表

image.png

如果你这么写,我会夸你代码和人一样好看

image.png

三目运算格式化

这俩,你说谁的可读性高,肯定是分三行写的好看啊

image.png

字符串以及对象格式化

这俩,你说谁看得舒服,那肯定是 2 啊
我看了身边的人和网上的很多代码,大多数都是 1 这种

image.png

不管你是用字符串,还是对象等方式表达,你都应该写 2 这种样式

image.png

分支语句

这俩哪种好看还用说吗,肯定是左边的好啊。但是你用 Prettier 格式化的话,应该就变成右边的了
同理 try catch 之类的也是一样

image.png

最后,多用换行,我跪下来求求你了 😭


作者:寅时码
来源:juejin.cn/post/7335277377621639219

收起阅读 »

前端项目如何准确预估个人工时

补充 看来很多小伙伴对这个问题感兴趣,大家不要忽视了压工时这个事。 领导为什么会压工时? 使他的KPI更好看 不清楚做这个东西实际要多长时间 因为第2点的原因,他也无法去争取合理时间 部分人看着下属加班,有种大权在握,言出法随的畅快感 码农为什么不要轻易答...
继续阅读 »

补充


看来很多小伙伴对这个问题感兴趣,大家不要忽视了压工时这个事。


领导为什么会压工时?



  1. 使他的KPI更好看

  2. 不清楚做这个东西实际要多长时间

  3. 因为第2点的原因,他也无法去争取合理时间

  4. 部分人看着下属加班,有种大权在握,言出法随的畅快感


码农为什么不要轻易答应压工时?



  • 无形中会打击你的自信心,当自信心全无的时候,要么是职业生涯结束,要么是变成人人都跑来拿捏一手的角色

  • 轻易妥协,会让你的说的话,可信度降低。毕竟,别人随便说一下,激一下,你就妥协了,那很容易就让人觉得,你就是随意乱说一个时间

  • 这会妨碍你对自己真实能力的认知和评估


被压工时了怎么办?



  • 偶尔有少量任务,被压了少量工时,个人认为是可以接受的,毕竟不可能一切都能按规划走

  • 大量工作被压工时,那就告知延期风险,你的工作速度应该保持不变,完不成,就让项目延期。如何解决延期问题?那是领导的事情,不是一个小码农应该操心的。


没怎么压工时,但把工作时间延长了?



  • 首先,工作该是什么速度,就是什么速度,不要替领导着急着赶进度

  • 其次,反馈这有延期风险,建议领导增派人手。(记得先和其他成员通个气)

  • 该提加班就提加班,调休或加班工资是你应得的,累了就调休,你是人,不是机器


为什么要给自己留缓冲时间?加缓冲时间是工作不饱和?



  • 加缓冲时间不是工作不饱和

  • 8小时工作日,你不可能每分每秒都在写代码,谁也做不到。

  • 你不可能熟悉每个API,总有要你查资料的时候,而查资料,可能你查了4-5个地方,才总结出正确用法,这需要额外的时间

  • 你的工作随时可能被人打断,比如:开会,喝水,同事问你问题等等,这都会耗费你的时间

  • 你拉取代码,提交代码,思考实现方式,和业务进一步确认需求细节,和UI沟通交互细节,自测,造mock数据,这都需要时间

  • 如果没有缓冲时间,一个任务延期,可能会导致,后续N个任务都延期。

  • 即使从项目角度分析,足够的缓冲时间,有利于降低项目延期风险


工作总是被人打断怎么办?



  • 比如:开会,比如插入了一个紧急工作任务,这种较长时间的打断,那就将这些被占用的时间,写进工作日志,即时向项目组反馈,要求原本的工作任务加工时或延迟开始

  • 被同事问问题。几句话能说清楚的,那不妨和他直说。几句话说不清楚的,那只能等你有空的时候,再给他解答。要优先考虑完成自己的工作任务。


大方的承认自己的不足,能力多大,就做多少事,明确自己的定位


可能有的小伙伴,可能被别人激一下,被人以质疑的语句问一下,后续就被人牵着鼻子走了。有很大因素是因为不敢承认某方面的工作能力暂有欠缺。其实大方的承认即可,有问题,那就暴露问题,如果项目组其他成员会,那就让他来教你,这也属于沟通协作。如果没人会,那说明这是一个需要集思广益的公共问题。


可能有同学觉得自己就是个小码农甚至因为自己是外包,不敢发表自己的想法和见解,其实大可不必,只要你就事论事,有理有据,完全可以大方说出来,你不说出来,你永远只能从自己的角度看这个问题,你无法确认自己是对的还是错的。错了咱改,对了继续保持。既不贬低别人,也不看轻自己,以平常心讨论即可。


明确自己的定位,就是个普通码农,普通干活的,项目延期了,天塌了也是领导想办法解决。自己不会的就反馈,别人不会自己会的,那就友好分享。不会的,不要羞于请教。干不过来了,及时告知领导,让其协调解决。坦坦荡荡,不卑不亢。


前提



  1. 此方法是在没有技术阻碍的前提条件下预估,如果有技术障碍,请先解决技术阻碍

  2. 此方法需要根据个人实际情况调整

  3. 这里以普通的以vue,element-plus,axios为基础技术的管理系统为例

  4. 这些都是个人见解,欢迎在评论区提出不同观点

  5. 请先以一个普通的CRUD界面,测算自己的基本编码速度


为啥评估会不准确


自我评估时





领导给你评估时

功能领导认为的领导忘记的领导认为的时间实际时间
加个字段加个显示字段而已,实际只要3分钟吧码农要找到对应代码,查看代码上下文,或许还涉及样式的修改,后端接口可能还没有这个字段, 还要自测20分钟2小时
做个纯列表页面前端只要把后端的字段显示出来就好了吧,肯定会很快可能没有直接可用的组件,即使有,前端可能需要查组件文档,看具体用法, 还得处理loading状态,空状态,然后还得查看后端接口文档,看哪些字段需要额外处理,最后还得自测,甚至可能在真正对接前,需要自己造mock接口2小时8小时
编辑/新增界面就写个表单,前端把数据提交给后端就完事了前端需要理解业务逻辑,需要做数据校验,对于类似下拉数据,图片上传,可能还要和后端沟通,数据从哪里取,分别表示什么意思,怎么上传图片,提交数据后,成功后要怎么做,以及失败的异常处理,用户填了一半数据之后,刷新了界面,应该如何处理,后端接口没出来前,需要自己mock接口,用来自测4小时3天
一个响应式界面就一个普通界面应该不至于有什么难度吧忽略了这是一个响应式界面,前端需要与UI设计师沟通,确认在不同情况,界面如何响应,以及思考如何实现,如果业务数据还会对界面的响应式产生影响,那还得进一步深入分析8小时3天
实现多语言功能多语言,不就是用编码代替原本的文字嘛,根本不需要额外的时间处理吧前端需要考虑多语言数据从哪里来,多语言切换之后对原本布局的影响,多语言切换之后,表单错误提示的处理方式不给额外时间3-4天
做个3/4环直接使用图表插件,调下API就出来了前端可能需要进行数据转换,需要查看图表插件的文档,图表插件可能没有现成的,需要通过搜索引擎找类似的示例,然后模仿实现,甚至图表插件根本无法实现这种效果,需要用其他技术实现3小时4天
前期一个连续的类似界面上一个界面和这个类似,把上个界面的代码复制过来,改改字段和接口,应该能很快完成很多界面看着一样,但实际业务逻辑差别很大,只是界面表现形式类似,有些字段是动态显示/隐藏的,有些可以固定写,表单字段的验证逻辑,可能也不一样。并且上一个界面的代码都还没写,还没测试,这里还有很多不确定因素,直接复制还可能导致,同一个错误,在多个界面同时出现2-3小时前一个界面花了多久,这个界面可能还是花了差不多的时间
仿照xx官网的效果,做个静态界面好多网站都是这个效果,这应该是烂大街的效果吧某个效果可能是知识盲区,需要查资料2天1周,甚至可能做不了
参考公司内部其他系统界面,实现类似界面现成的东西,这系统也上线好久了,应该把代码复制过来,稍微改改就OK了吧当前这个人从未接触过这个系统,对这个系统一点都不了解,了解需要时间,可能另外的项目有自己的框架,和当前系统的框架不同,无法直接使用, 另外一个项目无法直接给项目代码给你,只能让人给你讲解,但讲解人没时间或不是随时都有时间,或就是随意讲讲,另一个项目的这个界面,可能是经过多人集思广益,多轮讨论与重构才最终得到了这个效果5小时3-5天
用低代码平台实现个界面就是拖拖组件的事情,代码都不用写,应该很快组件可能没有,有组件可能某些业务逻辑在这个低代码平台难以实现,需要咨询低代码平台的提供方,但低代码提供方,几个人需要服务几十个人,无法优先给你解答,即使解答了,可能给出的方案不通用(或者他们还需要继续问他们内部团队的人),遇到下个类似的情况,原来的解决方案又无效了。难以调试或无法调试,前端原本的知识储备,在低代码平台,仅剩原始的js语法有效2天3周

总原则



  • 不要duang的一下,对整个界面/模块进行评估,应该对行列,表单项,逻辑点,进行评估,然后将总的时间加起来,就是这个界面的预估工时

  • 要至少多估20%的时间,一个是因为你很难持续性的投入(比如:有人突然问你问题,上厕所,喝水,或有事请假)

  • 请将一天的工作时间最多算6.5小时(因为你可能需要开会,可能被其他事情打断,可能有时不在状态,同时也算是给自己留点思考时间)

  • 尽量不要在过了一遍需求之后,立马评估工时(不要被项目经理或业务的节奏带偏),而是要自己再思考一遍需求,想想大概的实现逻辑,重难点等等,尽量不要当天给出工时评估

  • 如果是给别人评估工时,那尽可能给别人多评点工时

  • 工期紧的时候,加人有必要,但1个人干7天的活,7个人未必能1天干完

  • 有公共组件和没有公共组件完成同样的功能,所需要的时间可能天差地别, 因此,请确保先完成公共组件的开发

  • 请先将业务逻辑理顺,把工作进行拆分,直至自己能正确预估的范围内


前端有哪些地方需要耗费工时



  • 思考实现方式

  • 静态UI界面还原与响应式

  • 业务逻辑实现

  • 动态UI交互

  • 后端接口mock

  • 后端接口对接

  • 自测


前端项目应该分成几步实现



  1. 整体项目搭建以及规范与约束确认

  2. 整体页面结构,路由结构搭建

  3. 统一UI风格,以及公共组件实现

  4. 具体的界面实现


1,2点应该由项目组长完成
3点应该由项目组长以及技术较强的组员共同完成


常见的公共组件工时

组件工时
查询按钮60 分钟
提交按钮60 分钟
confirm按钮60 分钟
下拉按钮60 分钟
分页表格360 分钟
JSON配置分页表格240 分钟
动态表单360 分钟
JSON动态表单360 分钟
模态框90 分钟
抽屉组件90 分钟
select组件90 分钟
tree组件120 分钟
cascade组件90 分钟
日期选择组件60 分钟
日期范围选择组件120 分钟
axios封装360 分钟
卡片组件60 分钟
面包屑组件60 分钟

列表页拆分与编码工时预估




首先做总体拆分,分成3大部分



  1. 头部的搜索表单

每个表单项30分钟左右,每个功能按钮40分钟左右


因此这里是1个表单项(30分钟),2个功能按钮(80分钟),总计110分钟



  1. 中间的工具栏




P.S. 这里没算右侧工具条,只算了左侧功能按钮


因为是列表页,添加角色这个按钮,只考虑是个简单按钮加个点击事件,至于点击按钮之后的角色添加界面的工时不放在列表页评估,而是在添加角色界面单独评估,因此添加角色按钮算30分钟


批量操作按钮,应该使用公共组件的下拉按钮组件,以及与分页表格组件配合实现,因此算40-60分钟


因此这里整体应该总计在70分钟内



  1. 主体的分页表格




应该使用公共组件的分页表格组件实现



  • 普通列(直接显示字段值的列,和简单转换的列)每列算20分钟

  • 操作列按每个操作按钮另算

  • 复杂转换列按40-60分钟算

  • 排序列按40-60分钟算

  • 分页表格组件调用30分钟


从界面看,这里有6列,checkbox列和序号列,是分页表格组件实现的,无需再算工时,除操作列和创建时间外,其他都属于普通列算20分钟每列,创建时间列算40分钟,因此总共100分钟


操作列角色成员,角色权限和修改,都需要打开一个抽屉界面(抽屉界面里的东西另算,不算在列表页中),删除需要调后端接口以及确认,因此



  • 角色成员按钮: 20分钟

  • 角色权限按钮: 20分钟

  • 修改按钮: 20分钟

  • 删除按钮: 30分钟


总计: 100 + 20*3 + 30 = 190分钟


因此整个列表页工时


列表页需要mock 1个接口,列表接口,算20分钟


110 + 70 + 190 + 20 = 390 分钟 = 6.5小时


再在390分钟的基础上再多加20% = 390*1.2 = 468 分钟 = 7.8 小时


P.S.



  1. 添加角色/角色成员/角色权限这是独立界面,需要单独计算时间。计算方式也与上面的类似

  2. 没有单独计算自测时间,个人认为理想情况应该对1个界面,加2-3小时自测时间

  3. 没有计算联调时间,联调时间应该另算

  4. 没有计算UI还原时间,对于复杂UI界面或UI还原度要求高的界面,应该单独计算UI还原时间

  5. 对于复杂的业务逻辑,可以将业务逻辑拆解为一条条的业务逻辑项,每个业务逻辑项以40分钟左右每条作为参考实现时间

  6. 没有考虑思考时间,对于复杂的业务逻辑,或者没做过的界面形态,或者复杂的界面形态等,必须将思考时间计算进来,或者说,在已经基本想明白怎么去实现的基础上,再去评估工时


被误解的敏捷开发模式

错误的敏捷开发




  • 敏捷开发就是强调一个快字

  • 敏捷开发就是不断的压榨工时

  • 敏捷开发就是不停的加班


正确的敏捷开发



  • 测试在项目之初就介入,编写完测试用例之后,共享给开发,方便开发自测

  • 将一个完整的项目进行合理拆分,拆分为若干独立小迭代

  • 每个小迭代完成之后,进行提测以及收集用户试用反馈,尽早反馈,以及尽早发现问题

  • 在小迭代提测期间,应该让开发略作修整(改bug或修整)和总结(总结共性问题,避免下阶段,再重复出现这些共性问题),而非让开发立马进入下阶段开发,否则容易造成,开发一边赶下阶段需求,一边赶上阶段bug

  • 个人认为敏捷开发,重点在于敏捷,灵巧好掉头,分阶段交付,及早发现问题,拥抱需求变化。而非简单的抽着鞭子让程序员加班赶工996或007


相关文章


作者:悟空和大王
链接:https://juejin.cn/post/7330071686489636904
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

情人节即分手,FreeNginx 来了

时间线 2019 年 3 月 11 日,F5 Networks 宣布以 6.7 亿美元收购 Nginx。 2022.01.18, NGINX 创始人 Igor Sysoev 从 NGINX 和 F5 离职:“Igor Sysoev 选择离开 F5,以便将...
继续阅读 »

38d432781be446559f99d3d139ef6be7.png


时间线



2019 年 3 月 11 日,F5 Networks 宣布以 6.7 亿美元收购 Nginx。




2022.01.18, NGINX 创始人 Igor Sysoev 从 NGINX 和 F5 离职:“Igor Sysoev 选择离开 F5,以便将更多的时间留给陪伴家人和朋友,以及个人项目。感谢他所做的一切让全世界的网站变得更好。”




在 2024.2.14 情人节这天,作为 nginx 的长期核心开发者之一,马克西姆-杜宁(Maxim Dounin)宣布创建一个名为 Freenginx 的新分叉项目。



他在宣布 Freenginx 时说道


"你们可能知道,F5 于 2022 年关闭了莫斯科办事处,从那时起我就不再为 F5 工作了。 不过,我们已经达成协议,我将继续作为志愿者参与nginx开发。在近两年的时间里,我一直致力于改进nginx,免费为大家提供更好的nginx。
不幸的是,F5一些新的非技术管理人员最近决定干涉nginx多年来使用的安全策略,无视策略和开发者的立场。


这很好理解:他们拥有这个项目,可以对其做任何事情,包括以市场为导向的行为,无视开发者和社区的立场。 不过,这还是与我们的协议相矛盾。 更重要的是,我无法再控制F5内部对nginx的修改,也不再将nginx视为一个为公众利益开发和维护的自由开源项目。


因此,从今天起,我将不再参与F5运营的nginx开发。 取而代之的是,我将启动另一个项目,由开发者而非公司实体运行。


目标是使nginx开发不受公司任意行为的影响。 欢迎提供帮助和贡献。 希望大家都能从中受益。


freenginx.org上的简短声明



freenginx.org的目标是使nginx的开发不受任意公司行为的影响。



image.png


开源和商业


利益与目标不同决定了开源项目的不同发展方向,这不好评说好坏对错。



作为商业公司,F5毕竟真金白银花了那么多钱拥有了nginx,全职人员的成本付出,这肯定需要往商业化方向考量,希望能找到商业与开源的平衡。




Maxim Dounin 有着开发者的自由理想园,站在开发者和开源使用者的角度看开源项目的发展,nginx 能更开放更自由,方向由社区掌控。也希望 freenginx 能发展顺利。



oracle-jdk vs openjdk, mysql vs mariadb, 现在有了 nginx vs freenginx, 我们现在可以开始关注 Freenginx 的未来发展,看未来有多少其他开发者会专注于这个新的分叉。


Nginx 擦边广告,使用 HertzBeat 快速监控 Nginx



HertzBeat 是一款我们开源的实时监控系统,无需Agent,性能集群,兼容Prometheus,自定义监控和状态页构建能力。github.com/dromara/her…




它支持对应用服务,应用程序,数据库,缓存,操作系统,大数据,中间件,Web服务器,云原生,网络,自定义等监控。下面广告演示下如果使用 HertzBeat 快速监控 Nginx 服务状态。



1. 部署 HertzBeat


docker run -d -p 1157:1157 -p 1158:1158 --name hertzbeat tancloud/hertzbeat


2. 部署 Nginx


本地部署启动 Nginx, 默认监控 Nginx 可用性,若监控更多指标,则需启用 Nginx 的 ngx_http_stub_status_modulengx_http_reqstat_module 监控模块


参考文档:hertzbeat.com/zh-cn/docs/…


3. 在 HertzBeat 添加 Nginx 监控


访问 HertzBeat 控制页面,在 应用服务监控 -> Nginx服务器 添加对端 Nginx 监控,配置对端IP端口等参数。



确认添加后就OK啦,后续我们就可以看到 Nginx 的相关指标数据状态,还可以设置告警阈值通知等,当 Nginx 挂了或者某个指标异常过高时,通过邮件钉钉微信等通知我们。





10分钟搞定,快来使用 HertzBeat 24小时自动观测你的 Nginx 状态


在 Github Star 我们!


github.com/dromara/her…


gitee.com/dromara/her…



部分内容来源于 http://www.msn.com/zh-cn/chann…



作者:Dromara开源社区
来源:juejin.cn/post/7335089578321854498
收起阅读 »

localhost和127.0.0.1的区别是什么?

今天在网上逛的时候看到一个问题,没想到大家讨论的很热烈,就是标题中这个: localhost和127.0.0.1的区别是什么? 前端同学本地调试的时候,应该没少和localhost打交道吧,只需要执行 npm run 就能在浏览器中打开你的页面窗口,地址栏显...
继续阅读 »

今天在网上逛的时候看到一个问题,没想到大家讨论的很热烈,就是标题中这个:


localhost和127.0.0.1的区别是什么?



前端同学本地调试的时候,应该没少和localhost打交道吧,只需要执行 npm run 就能在浏览器中打开你的页面窗口,地址栏显示的就是这个 http://localhost:xxx/index.html


可能大家只是用,也没有去想过这个问题。


联想到我之前合作过的一些开发同学对它们俩的区别也没什么概念,所以我觉得有必要普及下。


localhost是什么呢?


localhost是一个域名,和大家上网使用的域名没有什么本质区别,就是方便记忆。


只是这个localhost的有效范围只有本机,看名字也能知道:local就是本地的意思。


张三和李四都可以在各自的机器上使用localhost,但获取到的也是各自的页面内容,不会相互打架。


从域名到程序


要想真正的认清楚localhost,我们还得从用户是如何通过域名访问到程序说起。


以访问百度为例。


1、当我们在浏览器输入 baidu.com 之后,浏览器首先去DNS中查询 baidu.com 的IP地址。


为什么需要IP地址呢?打个比方,有个人要寄快递到你的公司,快递单上会填写:公司的通讯地址、公司名称、收件人等信息,实际运输时快递会根据通信地址进行层层转发,最终送到收件人的手中。网络通讯也是类似的,其中域名就像公司名称,IP地址就像通信地址,在网络的世界中只有通过IP地址才能找到对应的程序。


DNS就像一个公司黄页,其中记录着每个域名对应的IP地址,当然也有一些域名可能没做登记,就找不到对应的IP地址,还有一些域名可能会对应多个IP地址,DNS会按照规则自动返回一个。我们购买了域名之后,一般域名服务商会提供一个域名解析的功能,就是把域名和对应的IP地址登记到DNS中。


这里的IP地址从哪里获取呢?每台上网的电脑都会有1个IP地址,但是个人电脑的IP地址一般是不行的,个人电脑的IP地址只适合内网定位,就像你公司内部的第几栋第几层,公司内部人明白,但是直接发给别人,别人是找不到你的。如果你要对外部提供服务,比如百度这种,你就得有公网的IP地址,这个IP地址一般由网络服务运营商提供,比如你们公司使用联通上网,那就可以让联通给你分配一个公网IP地址,绑定到你们公司的网关服务器上,网关服务器就像电话总机,公司内部的所有网络通信都要通过它,然后再在网关上设置转发规则,将网络请求转发到提供网络服务的机器上。


2、有了IP地址之后,浏览器就会向这个IP地址发起请求,通过操作系统打包成IP请求包,然后发送到网络上。网络传输有一套完整的路由协议,它会根据你提供的IP地址,经过路由器的层层转发,最终抵达绑定该IP的计算机。


3、计算机上可能部署了多个网络应用程序,这个请求应该发给哪个程序呢?这里有一个端口的概念,每个网络应用程序启动的时候可以绑定一个或多个端口,不同的网络应用程序绑定的端口不能重复,再次绑定时会提示端口被占用。通过在请求中指定端口,就可以将消息发送到正确的网络处理程序。


但是我们访问百度的时候没有输入端口啊?这是因为默认不输入就使用80和443端口,http使用80,https使用443。我们在启动网络程序的时候一定要绑定一个端口的,当然有些框架会自动选择一个计算机上未使用的端口。



localhost和127.0.0.1的区别是什么?


有了上边的知识储备,我们就可以很轻松的搞懂这个问题了。


localhost是域名,上文已经说过了。


127.0.0.1 呢?是IP地址,当前机器的本地IP地址,且只能在本机使用,你的计算机不联网也可以用这个IP地址,就是为了方便开发测试网络程序的。我们调试时启动的程序就是绑定到这个IP地址的。


这里简单说下,我们经常看到的IP地址一般都是类似 X.X.X.X 的格式,用"."分成四段。其实它是一个32位的二进制数,分成四段后,每一段是8位,然后每一段再转换为10进制的数进行显示。


那localhost是怎么解析到127.0.0.1的呢?经过DNS了吗?没有。每台计算机都可以使用localhost和127.0.0.1,这没办法让DNS来做解析。


那就让每台计算机自己解决了。每台计算机上都有一个host文件,其中写死了一些DNS解析规则,就包括 localhost 到 127.0.0.1 的解析规则,这是一个约定俗成的规则。


如果你不想用localhost,那也可以,随便起个名字,比如 wodehost,也解析到 127.0.0.1 就行了。


甚至你想使用 baidu.com 也完全可以,只是只能自己自嗨,对别人完全没有影响。


域名的等级划分


localhost不太像我们平常使用的域名,比如 http://www.juejin.cn 、baidu.com、csdn.net, 这里边的 www、cn、com、net都是什么意思?localhost为什么不需要?


域名其实是分等级的,按照等级可以划分为顶级域名、二级域名和三级域名...


顶级域名(TLD):顶级域名是域名系统中最高级别的域名。它位于域名的最右边,通常由几个字母组成。顶级域名分为两种类型:通用顶级域名和国家顶级域名。常见的通用顶级域名包括表示工商企业的.com、表示网络提供商的.net、表示非盈利组织的.org等,而国家顶级域名则代表特定的国家或地区,如.cn代表中国、.uk代表英国等。


二级域名(SLD):二级域名是在顶级域名之下的一级域名。它是由注册人自行选择和注册的,可以是个性化的、易于记忆的名称。例如,juejin.cn 就是二级域名。我们平常能够申请到的也是这种。目前来说申请 xxx.com、xxx.net、xxx.cn等等域名,其实大家不太关心其顶级域名com\net\cn代表的含义,看着简短好记是主要诉求。


三级域名(3LD):三级域名是在二级域名之下的一级域名。它通常用于指向特定的服务器或子网。例如,在blog.example.com中,blog就是三级域名。www是最常见的三级域名,用于代表网站的主页或主站点,不过这只是某种流行习惯,目前很多网站都推荐直接使用二级域名访问了。


域名级别还可以进一步细分,大家可以看看企业微信开放平台这个域名:developer.work.weixin.qq.com,com代表商业,qq代表腾讯,weixin代表微信,work代表企业微信,developer代表开发者。这种逐层递进的方式有利于域名的分配管理。


按照上边的等级定义,我们可以说localhost是一个顶级域名,只不过它是保留的顶级域,其唯一目的是用于访问当前计算机。


多网站共用一个IP和端口


上边我们说不同的网络程序不能使用相同的端口,其实是有办法突破的。


以前个人博客比较火的时候,大家都喜欢买个虚拟主机,然后部署个开源的博客程序,抒发一下自己的感情。为了挣钱,虚拟主机的服务商会在一台计算机上分配N多个虚拟主机,大家使用各自的域名和默认的80端口进行访问,也都相安无事。这是怎么做到的呢?


如果你有使用Nginx、Apache或者IIS等Web服务器的相关经验,你可能会接触到主机头这个概念。主机头其实就是一个域名,通过设置主机头,我们的程序就可以共用1个网络端口。


首先在Nginx等Web程序中部署网站时,我们会进行一些配置,此时在主机头中写入网站要使用的域名。


然后Nginx等Web服务器启动的时候,会把80端口占为己有。


然后当某个网站的请求到达Nginx的80端口时,它会根据请求中携带的域名找到配置了对应主机头的网络程序。


然后再转发到这个网络程序,如果网络程序还没有启动,Nginx会把它拉起来。


私有IP地址


除了127.0.0.1,其实还有很多私有IP地址,比如常见的 192.168.x.x。这些私有IP地址大部分都是为了在局域网内使用而预留的,因为给每台计算机都分配一个独立的IP不太够用,所以只要局域网内不冲突,大家就可劲的用吧。你公司可以用 192.168.1.1,我公司也可以用192.168.1.1,但是如果你要访问我,就得通过公网IP进行转发。


大家常用的IPv4私有IP地址段分为三类:


A类:从10.0.0.0至10.255.255.255


B类:从172.16.0.0至172.31.255.255


C类:从192.168.0.0至192.168.255.255。


这些私有IP地址仅供局域网内部使用,不能在公网上使用。


--


除了上述三个私有的IPv4地址段外,还有一些保留的IPv4地址段:


用于本地回环测试的127.0.0.0至127.255.255.255地址段,其中就包括题目中的127.0.0.1,如果你喜欢也可以给自己分配一个127.0.0.2的IP地址,效果和127.0.0.1一样。


用于局域网内部的169.254.0.0至169.254.255.255地址段,这个很少接触到,如果你的电脑连局域网都上不去,可能会看到这个IP地址,它是临时分配的一个局域网地址。


这些地址段也都不能在公网上使用。


--


近年来,还有一个现象,就是你家里或者公司里上网时,光猫或者路由器对外的IPv4地址也不是公网IP了,这时候获得的可能是一个类似 100.64.x.x 的地址,这是因为随着宽带的普及,运营商手里的公网IP也不够了,所以运营商又加了一层局域网,而100.64.0.0 这个网段是专门分给运营商做局域网用的。如果你使用阿里云等公有云,一些云产品的IP地址也可能是这个,这是为了将客户的私有网段和公有云厂商的私有网段进行有效的区分。


--


其实还有一些不常见的专用IPv4地址段,完整的IP地址段定义可以看这里:http://www.iana.org/assignments…



IPv6


你可能也听说过IPv6,因为IPv4可分配的地址太少了,不够用,使用IPv6甚至可以为地球上的每一粒沙子分配一个IP。只是喊了很多年,大家还是喜欢用IPv4,这里边原因很多,这里就不多谈了。


IPv6地址类似:XXXX:XXXX:XXXX:XXXX:XXXX:XXXX:XXXX:XXXX

它是128位的,用":"分成8段,每个X是一个16进制数(取值范围:0-F),IPv6地址空间相对于IPv4地址有了极大的扩充。比如:2001:0db8:3c4d:0015:0000:0000:1a2f:1a2b 就是一个有效的IPv6地址。


关于IPv6这里就不多说了,有兴趣的可以再去研究下。


关注萤火架构,加速技术提升!


作者:萤火架构
来源:juejin.cn/post/7321049446443417638
收起阅读 »

前端实现excel_xlsx文件预览

web
使用的框架: React 要使用的库: exceljs、handsontable 1. 概述 接到一个任务,是要前端实现文件预览效果,百度了一圈,发现也没有什么好的方法可以完美的将表格渲染出来。在前端中有sheetjs和exceljs可以对xlsx文件进行解...
继续阅读 »

使用的框架: React


要使用的库: exceljs、handsontable



1. 概述


接到一个任务,是要前端实现文件预览效果,百度了一圈,发现也没有什么好的方法可以完美的将表格渲染出来。在前端中有sheetjsexceljs可以对xlsx文件进行解析,本来一开始我用的是sheetjs,但是在样式获取上遇到了麻烦,所以我改用了exceljs,不过很难受,在样式获取时同样遇到了不小的麻烦,但是我懒得换回sheetjs了,那就直接使用exceljs吧。


要实现xlsx文件预览效果,我的想法是使用一个库对xlsx文件进行解析,然后使用另一个库对解析出来的数据在页面上进行绘制,综上,我采用的方案是:exceljs+handsontable


2. 实现步骤


2.1 安装库


使用命令: npm i exceljs handsontable @handsontable/react


2.2 使用exceljs解析数据并使用handsontable进行渲染


直接贴代码了:


import Excel from 'exceljs'
import { useState } from 'react';

import { HotTable } from '@handsontable/react';
import { registerAllModules } from 'handsontable/registry';
import 'handsontable/dist/handsontable.full.min.css';
import { textRenderer, registerRenderer } from 'handsontable/renderers';

// 注册模块
registerAllModules();

export default function XLSXPreView() {
const [data, setData] = useState([]);

const handleFile = async (e) => {
const file = e.target.files[0];

const workbook = new Excel.Workbook();
await workbook.xlsx.load(file)

// 第一个工作表
const worksheet = workbook.getWorksheet(1);

// 遍历工作表中的所有行(包括空行)
const sheetData = [];
worksheet.eachRow({ includeEmpty: true }, function(row, rowNumber) {
// console.log('Row ' + rowNumber + ' = ' + JSON.stringify(row.values));
// 使用row.values获取每一行的值时总会多出一条空数据(第一条),这里我把它删除
const row_values = row.values.slice(1);
sheetData.push(row_values)
});
setData(sheetData);
}

return (
<>
<input type="file" onChange={handleFile}/>
<div id='table_view'>
<HotTable
data={data}
readOnly={true}
rowHeaders={true}
colHeaders={true}
width="100vw"
height="auto"
licenseKey='non-commercial-and-evaluation'// 一定得加这个handsontable是收费的加了这个才能免费用
/>


</div>
</>

)
}

到这里,已经实现了从xlsx文件中获取数据,并使用handsontable将表格中的数据渲染出来,示例结果如下,如果只需要将数据显示出来,并不需要将样式什么的一起复现了,那到这里就已经结束了!


image.png


但事实上,这并不是我要做到效果,我的xlsx里面还有样式什么的,也需要复现,头疼😔


3. 其它的杂七杂八


3.1 单元格样式


事实上,在exceljs解析xlsx文件时,它顺带一起把样式获取到了,通过worksheet.getCell(1, 1).style可以获取对应单元格的样式,如下,背景色存放在fill.fgColor中,字体颜色存放在font.color中,这样的话只需要将这些样式一一赋值给handsontable组件再添加样式就好了。


image.png


但是实际操作的时候却遇到了问题,先说excel中的颜色,在选择颜色时,应该都会打开下面这个选项框吧,如果你选择的是标准色,它获取到的颜色就是十六进制,但是如果你选择主题中的颜色,那就是另一种结果了,并且还会有不同的深暗程度tint,这就很难受了!


image.png


随后在控制台中打印了workbook,发现它把主题返回了,可以通过work._themes.theme1获取,不过获取到的是xml格式的字符串,由于xml我没学,我不会,所以我就把它转换成json来进行处理了。


第一步


安装xml转json的库: npm i fast-xml-parser


import {XMLParser} from 'fast-xml-parser'

// 将主题xml转换成json
const themeXml = workbook._themes.theme1;
const options = {
ignoreAttributes: false,
attributeNamePrefix: '_'
}
const parser = new XMLParser(options);
const json = parser.parse(themeXml)
setThemeJson(json);


其实它的theme好像是固定的,也可以在一些格式转换的网站中直接转换成json然后放到一个json文件中,读取就行,我这里就直接放到一个state中了!



第二步


接下来就是重头戏了!设置单元格样式...


首先安装一个处理颜色的库color,用来根据tint获得不同明暗程度的颜色: npm i color


下面是获取颜色的函数:


// 根据主题和明暗度获取颜色
const getThemeColor = (themeJson, themeId, tint) => {
let color = '';
const themeColorScheme = themeJson['a:theme']['a:themeElements']['a:clrScheme'];
switch (themeId) {
case 0:
color = themeColorScheme['a:lt1']['a:sysClr']['_lastClr'];
break;
case 1:
color = themeColorScheme['a:dk1']['a:sysClr']['_lastClr'];
break;
case 2:
color = themeColorScheme['a:lt2']['a:srgbClr']['_val'];
break;
case 3:
color = themeColorScheme['a:dk2']['a:srgbClr']['_val'];
break;
default:
color = themeColorScheme[`a:accent${themeId-3}`]['a:srgbClr']['_val'];
break;
}
// 根据tint修改颜色深浅
color = '#' + color;
const colorObj = Color(color);
if(tint){
if(tint>0){// 淡色
color = colorObj.lighten(tint).hex();
}else{ // 深色
color = colorObj.darken(Math.abs(tint)).hex();
}
}
return color;
}
// 获取颜色
const getColor = (obj, themeJson) => {
if('argb' in obj){ // 标准色
// rgba格式去掉前两位: FFFF0000 -> FF0000
return '#' + obj.argb.substring(2);
}else if('theme' in obj){ // 主题颜色
if('tint' in obj){
return getThemeColor(themeJson, obj.theme, obj.tint);
}else{
return getThemeColor(themeJson, obj.theme, null);
}
}
}

然后设置handonsontable的单元格的一些样式:颜色、加粗、下划线、边框balabala...的


顺带把行高和列宽一起设置了,这个还比较简单,就一笔带过了...


3.2 合并单元格


从获取到的sheet中有一个_meages属性,该属性中存放了表格中所有的合并单元格区域,所以只需要将它们重新渲染在handsontable中就好。


image.png


然后就实现了表格的一些基本功能的预览,结果如下图:


image.png


3. 总结(附全代码)


其实这个的本质主要就是通过ecxeljs解析表格文件的数据,然后通过handsontable将它们重新绘制在页面上,个人觉得这种方法并不好,因为表格里的操作太多了要把它们一一绘制工作量实在是太大了,而且很麻烦,如有有更好的方案希望大佬们告诉我一下,我这里把表格的一些常用到的功能实现了预览,还有想表格里放图片什么的都没有实现,如果有需要,可以根据需求再进行进行写。



我写的其实还有一点bug,单元格的边框样式我只设置了solid和dashed,但事实上excel中单元格的边框有12种样式,而且还有对角线边框,设置起来好麻烦,我就不弄了,大家用的时候注意一下哈,有需要的话可以自己修改一下!



附上全部代码:


/**
* exceljs + handsontable
*/

import Excel from 'exceljs'
import { useState } from 'react';

import { HotTable } from '@handsontable/react';
import { registerAllModules } from 'handsontable/registry';
import 'handsontable/dist/handsontable.full.min.css';
import { textRenderer, registerRenderer } from 'handsontable/renderers';

import {XMLParser} from 'fast-xml-parser'
import Color from 'color';

// 注册模块
registerAllModules();

// 根据主题和明暗度获取颜色
const getThemeColor = (themeJson, themeId, tint) => {
let color = '';
const themeColorScheme = themeJson['a:theme']['a:themeElements']['a:clrScheme'];
switch (themeId) {
case 0:
color = themeColorScheme['a:lt1']['a:sysClr']['_lastClr'];
break;
case 1:
color = themeColorScheme['a:dk1']['a:sysClr']['_lastClr'];
break;
case 2:
color = themeColorScheme['a:lt2']['a:srgbClr']['_val'];
break;
case 3:
color = themeColorScheme['a:dk2']['a:srgbClr']['_val'];
break;
default:
color = themeColorScheme[`a:accent${themeId-3}`]['a:srgbClr']['_val'];
break;
}
// 根据tint修改颜色深浅
color = '#' + color;
const colorObj = Color(color);
if(tint){
if(tint>0){// 淡色
color = colorObj.lighten(tint).hex();
}else{ // 深色
color = colorObj.darken(Math.abs(tint)).hex();
}
}
return color;
}
// 获取颜色
const getColor = (obj, themeJson) => {
if('argb' in obj){ // 标准色
// rgba格式去掉前两位: FFFF0000 -> FF0000
return '#' + obj.argb.substring(2);
}else if('theme' in obj){ // 主题颜色
if('tint' in obj){
return getThemeColor(themeJson, obj.theme, obj.tint);
}else{
return getThemeColor(themeJson, obj.theme, null);
}
}
}
// 设置边框
const setBorder = (style) =>{
let borderStyle = 'solid';
let borderWidth = '1px';
switch (style) {
case 'thin':
borderWidth = 'thin';
break;
case 'dotted':
borderStyle = 'dotted';
break;
case 'dashDot':
borderStyle = 'dashed';
break;
case 'hair':
borderStyle = 'solid';
break;
case 'dashDotDot':
borderStyle = 'dashed';
break;
case 'slantDashDot':
borderStyle = 'dashed';
break;
case 'medium':
borderWidth = '2px';
break;
case 'mediumDashed':
borderStyle = 'dashed';
borderWidth = '2px';
break;
case 'mediumDashDotDot':
borderStyle = 'dashed';
borderWidth = '2px';
break;
case 'mdeiumDashDot':
borderStyle = 'dashed';
borderWidth = '2px';
break;
case 'double':
borderStyle = 'double';
break;
case 'thick':
borderWidth = '3px';
break;
default:
break;
}
// console.log(borderStyle, borderWidth);
return [borderStyle, borderWidth];
}

export default function XLSXPreView() {
// 表格数据
const [data, setData] = useState([]);
// 表格
const [sheet, setSheet] = useState([]);
// 主题
const [themeJson, setThemeJson] = useState([]);
// 合并的单元格
const [mergeRanges, setMergeRanges] = useState([]);

registerRenderer('customStylesRenderer', (hotInstance, td, row, column, prop, value, cellProperties) => {
textRenderer(hotInstance, td, row, column, prop, value, cellProperties);
// console.log(cellProperties);
// 填充样式
if('fill' in cellProperties){
// 背景颜色
if('fgColor' in cellProperties.fill && cellProperties.fill.fgColor){
td.style.background = getColor(cellProperties.fill.fgColor, themeJson);
}
}
// 字体样式
if('font' in cellProperties){
// 加粗
if('bold' in cellProperties.font && cellProperties.font.bold){
td.style.fontWeight = '700';
}
// 字体颜色
if('color' in cellProperties.font && cellProperties.font.color){
td.style.color = getColor(cellProperties.font.color, themeJson);
}
// 字体大小
if('size' in cellProperties.font && cellProperties.font.size){
td.style.fontSize = cellProperties.font.size + 'px';
}
// 字体类型
if('name' in cellProperties.font && cellProperties.font.name){
td.style.fontFamily = cellProperties.font.name;
}
// 字体倾斜
if('italic' in cellProperties.font && cellProperties.font.italic){
td.style.fontStyle = 'italic';
}
// 下划线
if('underline' in cellProperties.font && cellProperties.font.underline){
// 其实还有双下划线,但是双下划綫css中没有提供直接的设置方式,需要使用额外的css设置,所以我也就先懒得弄了
td.style.textDecoration = 'underline';
// 删除线
if('strike' in cellProperties.font && cellProperties.font.strike){
td.style.textDecoration = 'underline line-through';
}
}else{
// 删除线
if('strike' in cellProperties.font && cellProperties.font.strike){
td.style.textDecoration = 'line-through';
}
}

}
// 对齐
if('alignment' in cellProperties){
if('horizontal' in cellProperties.alignment){ // 水平
// 这里我直接用handsontable内置类做了,设置成类似htLeft的样子。
//(handsontable)其实至支持htLeft, htCenter, htRight, htJustify四种,但是其是它还有centerContinuous、distributed、fill,遇到这几种就会没有效果,也可以自己设置,但是我还是懒的弄了,用到的时候再说吧
const name = cellProperties.alignment.horizontal.charAt(0).toUpperCase() + cellProperties.alignment.horizontal.slice(1);
td.classList.add(`ht${name}`);
}
if('vertical' in cellProperties.alignment){ // 垂直
// 这里我直接用handsontable内置类做了,设置成类似htTop的样子。
const name = cellProperties.alignment.vertical.charAt(0).toUpperCase() + cellProperties.alignment.vertical.slice(1);
td.classList.add(`ht${name}`);
}
}
// 边框
if('border' in cellProperties){
if('left' in cellProperties.border && cellProperties.border.left){// 左边框
const [borderWidth, borderStyle] = setBorder(cellProperties.border.left.style);
let color = '';
// console.log(row, column, borderWidth, borderStyle);
if(cellProperties.border.left.color){
color = getColor(cellProperties.border.left.color, themeJson);
}
td.style.borderLeft = `${borderStyle} ${borderWidth} ${color}`;
}
if('right' in cellProperties.border && cellProperties.border.right){// 左边框
const [borderWidth, borderStyle] = setBorder(cellProperties.border.right.style);
// console.log(row, column, borderWidth, borderStyle);
let color = '';
if(cellProperties.border.right.color){
color = getColor(cellProperties.border.right.color, themeJson);
}
td.style.borderRight = `${borderStyle} ${borderWidth} ${color}`;
}
if('top' in cellProperties.border && cellProperties.border.top){// 左边框
const [borderWidth, borderStyle] = setBorder(cellProperties.border.top.style);
let color = '';
// console.log(row, column, borderWidth, borderStyle);
if(cellProperties.border.top.color){
color = getColor(cellProperties.border.top.color, themeJson);
}
td.style.borderTop = `${borderStyle} ${borderWidth} ${color}`;
}
if('bottom' in cellProperties.border && cellProperties.border.bottom){// 左边框
const [borderWidth, borderStyle] = setBorder(cellProperties.border.bottom.style);
let color = '';
// console.log(row, column, borderWidth, borderStyle);
if(cellProperties.border.bottom.color){
color = getColor(cellProperties.border.bottom.color, themeJson);
}
td.style.borderBottom = `${borderStyle} ${borderWidth} ${color}`;
}
}

});

const handleFile = async (e) => {
const file = e.target.files[0];

const workbook = new Excel.Workbook();
await workbook.xlsx.load(file)

const worksheet = workbook.getWorksheet(1);

// const sheetRows = worksheet.getRows(1, worksheet.rowCount);
setSheet(worksheet)

// console.log(worksheet.getCell(1, 1).style);

// 遍历工作表中的所有行(包括空行)
const sheetData = [];
worksheet.eachRow({ includeEmpty: true }, function(row, rowNumber) {
// console.log('Row ' + rowNumber + ' = ' + JSON.stringify(row.values));
// 使用row.values获取每一行的值时总会多出一条空数据(第一条),这里我把它删除
const row_values = row.values.slice(1);
sheetData.push(row_values)
});
setData(sheetData);

// 将主题xml转换成json
const themeXml = workbook._themes.theme1;
const options = {
ignoreAttributes: false,
attributeNamePrefix: '_'
}
const parser = new XMLParser(options);
const json = parser.parse(themeXml)
setThemeJson(json);

// 获取合并的单元格
const mergeCells = [];

for(let i in worksheet._merges){
const {top, left, bottom, right} = worksheet._merges[i].model;
mergeCells.push({ row: top-1, col: left-1, rowspan: bottom-top+1 , colspan: right-left+1})
}
setMergeRanges(mergeCells)
console.log(worksheet);
}

return (
<>
<input type="file" onChange={handleFile}/>
<div id='table_view'>
<HotTable
data={data}
readOnly={true}
rowHeaders={true}
colHeaders={true}
width="100vw"
height="auto"
licenseKey='non-commercial-and-evaluation'
rowHeights={function(index) {
if(sheet.getRow(index+1).height){
// exceljs获取的行高不是像素值事实上它是23px - 13.8 的一个映射所以需要将它转化为像素值
return sheet.getRow(index+1).height * (23 / 13.8);
}
return 23;// 默认
}}
colWidths={function(index){
if(sheet.getColumn(index+1).width){
// exceljs获取的列宽不是像素值事实上它是81px - 8.22 的一个映射所以需要将它转化为像素值
return sheet.getColumn(index+1).width * (81 / 8.22);
}
return 81;// 默认
}}
cells={(row, col, prop) =>
{
const cellProperties = {};
const cellStyle = sheet.getCell(row+1, col+1).style

if(JSON.stringify(cellStyle) !== '{}'){
// console.log(row+1, col+1, cellStyle);
for(let key in cellStyle){
cellProperties[key] = cellStyle[key];
}
}
return {...cellProperties, renderer: 'customStylesRenderer'};
}}
mergeCells={mergeRanges}
/>

</div>
</>

)
}

作者:汤圆要吃咸的
来源:juejin.cn/post/7264461721279774780
收起阅读 »

告别axios,这个库让你爱上前端分页!

web
嗨,我们又见面了! 今天咱们聊聊前端分页加载那些事儿。你有没有遇到过这样的烦恼:在做分页的时候,要手动维护各种状态,比如页码、每页显示数量、总数据量等等,还要处理各种边界情况,哎呀妈呀,真是太麻烦了! 那么,有没有什么好办法能让我们从这些繁琐的工作中解脱出来呢...
继续阅读 »

嗨,我们又见面了!


今天咱们聊聊前端分页加载那些事儿。你有没有遇到过这样的烦恼:在做分页的时候,要手动维护各种状态,比如页码、每页显示数量、总数据量等等,还要处理各种边界情况,哎呀妈呀,真是太麻烦了!


那么,有没有什么好办法能让我们从这些繁琐的工作中解脱出来呢?这时候,alovajs就派上用场了!


alovajs:轻量级请求策略库


alovajs是一个轻量级的请求策略库,它可以帮助我们轻松处理分页请求。它支持开发者使用声明式实现各种复杂的请求,比如请求共享、分页请求、表单提交、断点续传等等。使用alovajs,我们可以用很少的代码就实现高效、流畅的请求功能。比如,在Vue中,你可以这样使用alovajs进行分页请求:


const alovaInstance = createAlova({
// VueHook用于创建ref状态,包括请求状态loading、响应数据data、请求错误对象error等
statesHook: VueHook,
requestAdapter: GlobalFetch(),
responded: response => response.json()
});

const { loading, data, error } = useRequest(
alovaInstance.Get('https://api.alovajs.org/profile', {
params: {
id: 1
}
})
);

看到了吗?只需要几行代码,alovajs就帮我们处理了分页请求的各种细节,我们再也不用手动维护那些繁琐的状态了!


对比axios,alovajs的优势


和axios相比,alovajs有哪些优势呢?首先,alovajs与React、Vue等现代前端框架深度融合,可以自动管理请求相关数据,大大提高了开发效率。其次,alovajs在性能方面做了很多优化,比如默认开启了内存缓存和请求共享,这些都能显著提高请求性能,提升用户体验的同时还能降低服务端的压力。最后,alovajs的体积更小,压缩后只有4kb+,相比之下,axios则有11+kb。


总之,如果你想在分页加载方面做得更轻松、更高效,alovajs绝对值得一试!



作者:古韵
来源:juejin.cn/post/7331924057925533746
收起阅读 »

indexOf的第二个参数你用过嘛🤔

web
大家好,我是哈默。indexOf 是我们非常熟悉的一个方法,它可以用来获取某一个元素在一个数组里的位置,我们一般就会使用 array.indexOf(element) 的方法来进行使用。 但是,大家有没有使用过 indexOf 的第二个参数呢?第二个参数的使用...
继续阅读 »

大家好,我是哈默。indexOf 是我们非常熟悉的一个方法,它可以用来获取某一个元素在一个数组里的位置,我们一般就会使用 array.indexOf(element) 的方法来进行使用。


但是,大家有没有使用过 indexOf 的第二个参数呢?第二个参数的使用会经常出现在一些优秀的库的源码当中,用于依次分析(或者说扫描)某一个字符串。


比如命令行美化输出的 chalk 库中就有此应用,因为 chalk 库的原理就是对于我们输出在终端的内容进行处理,然后将处理后的字符串显示在终端上。


indexOf 基本用法


首先,我们还是先来回顾一下 indexOf 的最基本用法。


给定一个数组:[10, 20, 30],寻找这个数组中 30 的位置,是 2


const arr = [10, 20, 30];
const element = 30;
const index = arr.indexOf(element);

console.log(index); // 2

indexOf 的第二个参数


明确了 indexOf 的基本用法以后,它的第 2 个参数有什么用呢?


其实是起到了一个调整从哪里开始查找的作用。


我们来看一个例子:


const arr = [10, 20, 30];
const element = 10;
const index = arr.indexOf(element);

console.log(index); // 0

const arr2 = [10, 20, 30, 10];
const element2 = 10;
const index2 = arr2.indexOf(element2, 1);

console.log(index2); // 3

可以看到,同样是查找 [10, 20, 30, 10] 当中 10 的位置,但是因为第一次是从数组第 1 个元素开始查找的,所以得到的结果是 0。


而第二次是从数组的第 2 个元素开始查找的,所以得到的结果是 3。


优秀库源码里的使用


明确了 indexOf 第二个参数的使用之后,我们再来看一下在一些优秀的库的源码里面,它们是如何利用起这个第二个参数的作用的。



⚠️注意:我下面会以 String.prototype.indexOf 举例,而上面举的例子是以 Array.prototype.indexOf 为例,但是这两个 API 的第二个参数都是起到一个搜索位置的作用,所以在这里可以一起学习一下



这里,我们只会分析它的思想,具体的实现在具体的源码里会存在差异,但思想是相同的。


我们首先定义一个方法,addEmoji,接受三个参数:


/**
* 在一个 string 的 targetString 后面,加上一个 emoji
* @param string 原始 string
* @param targetString 加 emoji 的那个 string
* @param emoji 加入的 emoji
* @returns 处理后的最终结果
*/

function addEmoji(string, targetString, emoji) {
let result = "";

// 一系列处理
// ...

return result;
}

我们最终会这样调用,在 大家好,我是哈默,今天是一个好天气。 这个字的后面,加上 👍 的 emoji:


const res = addEmoji("大家好,我是哈默,今天是一个好天气。", "好", "👍");
console.log(res);

那么首先我们就可以使用 indexOf 方法来从输入的字符串里找到 的位置:


function addEmoji(string, targetString, emoji) {
// 找到 targetString 的位置
let index = string.indexOf(targetString);

let result = "";

// 记录当前扫描到的位置,现在是在参数 string 的开头位置
// 因为 string 当中,可能会存在多个 targetString,所以我们会跳着进行扫描,也就是会使用 indexOf 的第二个参数
let currentScanIndex = 0;

return result;
}

如果我们找到了 targetString,即 index !== -1,那么我们就在 targetString 后,加上一个 emoji:


function addEmoji(string, targetString, emoji) {
// 找到 targetString 的位置
let index = string.indexOf(targetString);

let result = "";

// 记录当前扫描到的位置,现在是在参数 string 的开头位置
// 因为 string 当中,可能会存在多个 targetString,所以我们会跳着进行扫描,也就是会使用 indexOf 的第二个参数
let currentScanIndex = 0;

// 如果找到了 targetString
if (index !== -1) {
// 在 targetString 后面增加 emoji
result += string.slice(currentScanIndex, index) + targetString + emoji;
// 将当前扫描位置,移动到 targetString 之后的那个位置上
currentScanIndex = index + targetString.length;
}

// 将 targetString 之后的内容追加到 result 里
result += string.slice(currentScanIndex);

return result;
}

此时,我们在第一个 字后面,加上了 👍,得到的结果:


res1.png


但是,我们这个字符串中,还有一个 好天气,也就是存在多个 targetString,所以我们这里不能是 if 只执行一次,而是要做一个循环。


我们可以使用一个 while 循环:


function addEmoji(string, targetString, emoji) {
// 找到 targetString 的位置
let index = string.indexOf(targetString);

let result = "";

// 记录当前扫描到的位置,现在是在参数 string 的开头位置
// 因为 string 当中,可能会存在多个 targetString,所以我们会跳着进行扫描,也就是会使用 indexOf 的第二个参数
let currentScanIndex = 0;

// 如果找到了 targetString
while (index !== -1) {
// 在 targetString 后面增加 emoji
result += string.slice(currentScanIndex, index) + targetString + emoji;
// 将当前扫描位置,移动到 targetString 之后的那个位置上
currentScanIndex = index + targetString.length;
+ // 重点来了!!!我们要从当前扫描的位置开始,去寻找 targetString
+ index = string.indexOf(targetString, currentScanIndex);
}

// 将 targetString 之后的内容追加到 result 里
result += string.slice(currentScanIndex);

return result;
}

此时,我们便成功的给第二个 ,也加上了 emoji:


res2.png


这个地方我们就使用到了之前提到的 indexOf 的第二个参数:


// 重点来了!!!我们要从当前扫描的位置开始,去寻找 targetString
index = string.indexOf(targetString, currentScanIndex);

我们是从当前扫描到的位置 currentScanIndex 开始,查找 targetString 的,这样我们就可以找到下一个 targetString 了。


所以,这里的思想就是通过 indexOf 的第二个参数,帮助我们能够依次扫描一个字符串,依次找到我们想要找的那个元素的位置,然后做相应的处理。


总结


indexOf 的第二个参数,叫 fromIndex,看到这里,大家应该也能很好的理解这个 fromIndex 的作用了,就是从哪里开始找嘛!


作者:我是哈默
来源:juejin.cn/post/7332858431571230747
收起阅读 »

春晚刘谦魔术的模拟程序

web
昨晚春晚上刘谦的两个魔术表演都非常精彩,尤其是第二个魔术,他演绎了经典的约瑟夫环问题! 什么是约瑟夫环问题? 约瑟夫环(Josephus problem)是一个经典的数学问题,最早由古罗马历史学家弗拉维奥·约瑟夫斯提出,但它的名字是在19世纪由德国数学家约瑟夫...
继续阅读 »

昨晚春晚上刘谦的两个魔术表演都非常精彩,尤其是第二个魔术,他演绎了经典的约瑟夫环问题!


什么是约瑟夫环问题?


约瑟夫环(Josephus problem)是一个经典的数学问题,最早由古罗马历史学家弗拉维奥·约瑟夫斯提出,但它的名字是在19世纪由德国数学家约瑟夫·乔瑟夫斯(Josef Stein)命名的。


问题的描述是这样的:假设有n个人(编号从1到n)站成一个圆圈,从第一个人开始报数,报到某个数字(例如k)的人就被杀死,然后从下一个人开始重新报数并继续这个过程,直到只剩下一个人留下来。


问题的关键是找出存活下来的那个人的编号。


结合扑克牌解释约瑟夫环问题


1、考虑最简单的情况


假设有2张牌,编号分别是1和2。


首先将1放到后面,扔掉2。剩下的就是最开始放在最上边的那张1。


2、稍微复杂一点的情况,牌的张数是2的n次方


比如有8张牌,编号分别是1、2、3、4、5、6、7、8。


第一轮会把2、4、6、8扔掉,剩下1、3、5、7按顺序放在后面,又退化成了4张牌的情况。


第二轮会把3、7扔掉,剩下1、5按顺序放在后面,又退化成了2张牌的情况。


第三轮把5扔掉,剩下1,就是最初在最前面的那张。


结论:如果牌的张数是2^n,最后剩下的一定是最开始放在牌堆顶的那张。


3、考虑任意的情况,牌的张数是2^n+m


比如牌的张数是11,等于8+3。把1放到后面,把2扔掉,把3放到后面,把4扔掉,把5放到后面,把6扔掉,现在剩下的编号序列是7、8、9、10、11、1、3、5,这又是8张牌的情况!最后一定剩下的是现在牌堆顶的7!


因此,只要提前知道牌的张数,就一定能马上推导出最终是剩下哪一张牌。一切的魔法都是数学!!都是算法!!


见证奇迹的时刻!魔术的流程



  1. 4张牌对折后撕开,就是8张,叠放在一起就是ABCDABCD。注意,ABCD四个数字是完全等价的。

  2. 根据名字字数,把顶上的牌放到下面,但怎么放都不会改变循环序列的相对位置。譬如2次,最后变成CDABCDAB;譬如3次,最后换成DABCDABC。但无论怎么操作,第4张和第8张牌都是一样的。

  3. 把顶上3张插到中间任意位置。这一步非常重要!因为操作完之后必然出现第1张和第8张牌是一样的!以名字两个字为例,可以写成BxxxxxxB(这里的x是其他和B不同的牌)。

  4. 拿掉顶上的牌放到一边,记为B。剩下的序列是xxxxxxB,一共7张牌。

  5. 南方人/北方人/不确定,分别拿顶上的1/2/3张牌插到中间,但是不会改变剩下7张牌是xxxxxxB的结果。

  6. 男生拿掉1张,女生拿掉2张。也就是男生剩下6张,女生剩下5张。分别是xxxxxB和xxxxB。

  7. 循环7次,把最顶上的放到最底下,男生和女生分别会是xxxxBx和xxBxx。

  8. 最后执行约瑟夫环过程!操作到最后只剩下1张。当牌数为6时(男生),剩下的就是第5张牌;当牌数为5时(女生),剩下的就是第3张牌。Bingo!就是第4步拿掉的那张牌!


下面是完整的 JavaScript 代码实现:


// 定义一个函数,用于把牌堆顶n张牌移动到末尾
function moveCardBack(n, arr) {
// 循环n次,把队列第一张牌放到队列末尾
for (let i = 0; i < n; i++) {
const moveCard = arr.shift(); // 弹出队头元素,即第一张牌
arr.push(moveCard); // 把原队头元素插入到序列末尾
}
return arr;
}

// 定义一个函数,用于把牌堆顶n张牌移动到中间的任意位置
function moveCardMiddleRandom(n, arr) {
// 插入在arr中的的位置,随机生成一个idx
// 这个位置必须是在n+1到arr.length-1之间
const idx = Math.floor(Math.random() * (arr.length - n - 1)) + n + 1;
// 执行插入操作
const newArr = arr.slice(n, idx).concat(arr.slice(0, n)).concat(arr.slice(idx));
return newArr;
}

// 步骤1:初始化8张牌,假设为"ABCDABCD"
let arr = ["A", "B", "C", "D", "A", "B", "C", "D"];
console.log("步骤1:拿出4张牌,对折撕成8张,按顺序叠放。");
console.log("此时序列为:" + arr.join('') + "\n---");

// 步骤2(无关步骤):名字长度随机选取,这里取2到5(其实任意整数都行)
const nameLen = Math.floor(Math.random() * 4) + 2;
// 把nameLen张牌移动到序列末尾
arr = moveCardBack(nameLen, arr);
console.log(`步骤2:随机选取名字长度为${nameLen},把第1张牌放到末尾,操作${nameLen}次。`);
console.log(`此时序列为:${arr.join('')}\n---`);

// 步骤3(关键步骤):把牌堆顶三张放到中间任意位置
arr = moveCardMiddleRandom(3, arr);
console.log(`步骤3:把牌堆顶3张放到中间的随机位置。`);
console.log(`此时序列为:${arr.join('')}\n---`);

// 步骤4(关键步骤):把最顶上的牌拿走
const restCard = arr.shift(); // 弹出队头元素
console.log(`步骤4:把最顶上的牌拿走,放在一边。`);
console.log(`拿走的牌为:${restCard}`);
console.log(`此时序列为:${arr.join('')}\n---`);

// 步骤5(无关步骤):根据南方人/北方人/不确定,把顶上的1/2/3张牌插入到中间任意位置
// 随机选择1、2、3中的任意一个数字
const moveNum = Math.floor(Math.random() * 3) + 1;
arr = moveCardMiddleRandom(moveNum, arr);
console.log(`步骤5:我${moveNum === 1 ? '是南方人' : moveNum === 2 ? '是北方人' : '不确定自己是哪里人'},\
${moveNum}张牌插入到中间的随机位置。`
);
console.log(`此时序列为:${arr.join('')}\n---`);

// 步骤6(关键步骤):根据性别男或女,移除牌堆顶的1或2张牌
const maleNum = Math.floor(Math.random() * 2) + 1; // 随机选择1或2
for (let i = 0; i < maleNum; i++) { // 循环maleNum次,移除牌堆顶的牌
arr.shift();
}
console.log(`步骤6:我是${maleNum === 1 ? '男' : '女'}生,移除牌堆顶的${maleNum}张牌。`);
console.log(`此时序列为:${arr.join('')}\n---`);

// 步骤7(关键步骤):把顶部的牌移动到末尾,执行7次
arr = moveCardBack(7, arr);
console.log(`步骤7:把顶部的牌移动到末尾,执行7次`);
console.log(`此时序列为:${arr.join('')}\n---`);

// 步骤8(关键步骤):执行约瑟夫环过程。把牌堆顶一张牌放到末尾,再移除一张牌,直到只剩下一张牌。
console.log(`步骤8:把牌堆顶一张牌放到末尾,再移除一张牌,直到只剩下一张牌。`);
while (arr.length > 1) {
const luck = arr.shift(); // 好运留下来
arr.push(luck);
console.log(`好运留下来:${luck}\t\t此时序列为:${arr.join('')}`);
const sadness = arr.shift(); // 烦恼都丢掉
console.log(`烦恼都丢掉:${sadness}\t\t此时序列为:${arr.join('')}`);
}
console.log(`---\n最终结果:剩下的牌为${arr[0]},步骤4中留下来的牌也是${restCard}`);


这段代码实现了昨晚春晚上刘谦的第二个魔术表演的过程,并提供了详细的解释。享受魔术的魅力吧!


image-20240210161329783


image-20240210161339317


看到观看的人这么多,除了JavaScript,下面我补充了一些其他语言的实现


import random

# 定义一个函数,用于把牌堆顶n张牌移动到末尾
def move_card_back(n, arr):
   for i in range(n):
       move_card = arr.pop(0)
       arr.append(move_card)
   return arr

# 定义一个函数,用于把牌堆顶n张牌移动到中间的任意位置
def move_card_middle_random(n, arr):
   idx = random.randint(n + 1, len(arr) - 1)
   new_arr = arr[n:idx] + arr[0:n] + arr[idx:]
   return new_arr

# 步骤1:初始化8张牌,假设为"ABCDABCD"
arr = ["A", "B", "C", "D", "A", "B", "C", "D"]
print("步骤1:拿出4张牌,对折撕成8张,按顺序叠放。")
print("此时序列为:" + ''.join(arr) + "\n---")

# 步骤2(无关步骤):名字长度随机选取,这里取2到5(其实任意整数都行)
name_len = random.randint(2, 5)
move_card_back(name_len, arr)
print("步骤2:随机选取名字长度为" + str(name_len) + ",把第1张牌放到末尾,操作" + str(name_len) + "次。")
print("此时序列为:" + ''.join(arr) + "\n---")

# 步骤3(关键步骤):把牌堆顶三张放到中间任意位置
arr = move_card_middle_random(3, arr)
print("步骤3:把牌堆顶3张放到中间的随机位置。")
print("此时序列为:" + ''.join(arr) + "\n---")

# 步骤4(关键步骤):把最顶上的牌拿走
rest_card = arr.pop(0)
print("步骤4:把最顶上的牌拿走,放在一边。")
print("拿走的牌为:" + rest_card)
print("此时序列为:" + ''.join(arr) + "\n---")

# 步骤5(无关步骤):根据南方人/北方人/不确定,把顶上的1/2/3张牌插入到中间任意位置
# 随机选择1、2、3中的任意一个数字
move_num = random.randint(1, 3)
arr = move_card_middle_random(move_num, arr)
print("步骤5:我" + ("是南方人" if move_num == 1 else "是北方人" if move_num == 2 else "不确定自己是哪里人") + ",把" + str(move_num) + "张牌插入到中间的随机位置。")
print("此时序列为:" + ''.join(arr) + "\n---")

# 步骤6(关键步骤):根据性别男或女,移除牌堆顶的1或2张牌
male_num = random.randint(1, 2)
for i in range(male_num):
   arr.pop(0)
print("步骤6:我是" + ("男" if male_num == 1 else "女") + "生,移除牌堆顶的" + str(male_num) + "张牌。")
print("此时序列为:" + ''.join(arr) + "\n---")

# 步骤7(关键步骤):把顶部的牌移动到末尾,执行7次
for i in range(7):
   move_card = arr.pop(0)
   arr.append(move_card)
print("步骤7:把顶部的牌移动到末尾,执行7次")
print("此时序列为:" + ''.join(arr) + "\n---")

# 步骤8(关键步骤):执行约瑟夫环过程。把牌堆顶一张牌放到末尾,再移除一张牌,直到只剩下一张牌。
print("步骤8:把牌堆顶一张牌放到末尾,再移除一张牌,直到只剩下一张牌。")
while len(arr) > 1:
   luck = arr.pop(0)
   arr.append(luck)
   print("好运留下来:" + luck + "\t\t此时序列为:" + ''.join(arr))
   sadness = arr.pop(0)
   print("烦恼都丢掉:" + sadness + "\t\t此时序列为:" + ''.join(arr))
print("---\n最终结果:剩下的牌为" + arr[0] + ",步骤4中留下来的牌也是" + rest_card)


java


import java.util.ArrayList;
import java.util.List;
import java.util.Random;

public class Main {
   public static void main(String[] args) {
       List<String> arr = new ArrayList<>();
       arr.add("A");
       arr.add("B");
       arr.add("C");
       arr.add("D");
       arr.add("A");
       arr.add("B");
       arr.add("C");
       arr.add("D");

       System.out.println("步骤1:拿出4张牌,对折撕成8张,按顺序叠放。");
       System.out.println("此时序列为:" + String.join("", arr));
       System.out.println("---");

       Random rand = new Random();

       int nameLen = rand.nextInt(4) + 2;
       moveCardBack(nameLen, arr);
       System.out.println("步骤2:随机选取名字长度为" + nameLen + ",把第1张牌放到末尾,操作" + nameLen + "次。");
       System.out.println("此时序列为:" + String.join("", arr));
       System.out.println("---");

       moveCardMiddleRandom(3, arr);
       System.out.println("步骤3:把牌堆顶3张放到中间的随机位置。");
       System.out.println("此时序列为:" + String.join("", arr));
       System.out.println("---");

       String restCard = arr.remove(0);
       System.out.println("步骤4:把最顶上的牌拿走,放在一边。");
       System.out.println("拿走的牌为:" + restCard);
       System.out.println("此时序列为:" + String.join("", arr));
       System.out.println("---");

       int moveNum = rand.nextInt(3) + 1;
       moveCardMiddleRandom(moveNum, arr);
       System.out.println("步骤5:我" + (moveNum == 1 ? "是南方人" : moveNum == 2 ? "是北方人" : "不确定自己是哪里人") + ",把" + moveNum + "张牌插入到中间的随机位置。");
       System.out.println("此时序列为:" + String.join("", arr));
       System.out.println("---");

       int maleNum = rand.nextInt(2) + 1;
       for (int i = 0; i < maleNum; i++) {
           arr.remove(0);
      }
       System.out.println("步骤6:我是" + (maleNum == 1 ? "男" : "女") + "生,移除牌堆顶的" + maleNum + "张牌。");
       System.out.println("此时序列为:" + String.join("", arr));
       System.out.println("---");

       for (int i = 0; i < 7; i++) {
           String moveCard = arr.remove(0);
           arr.add(moveCard);
      }
       System.out.println("步骤7:把顶部的牌移动到末尾,执行7次");
       System.out.println("此时序列为:" + String.join("", arr));
       System.out.println("---");

       System.out.println("步骤8:把牌堆顶一张牌放到末尾,再移除一张牌,直到只剩下一张牌。");
       while (arr.size() > 1) {
           String luck = arr.remove(0);
           arr.add(luck);
           System.out.println("好运留下来:" + luck + "\t\t此时序列为:" + String.join("", arr));
           String sadness = arr.remove(0);
           System.out.println("烦恼都丢掉:" + sadness + "\t\t此时序列为:" + String.join("", arr));
      }
       System.out.println("---\n最终结果:剩下的牌为" + arr.get(0) + ",步骤4中留下来的牌也是" + restCard);
  }

   private static void moveCardBack(int n, List<String> arr) {
       for (int i = 0; i < n; i++) {
           String moveCard = arr.remove(0);
           arr.add(moveCard);
      }
  }

   private static void moveCardMiddleRandom(int n, List<String> arr) {
       Random rand = new Random();
       int idx = rand.nextInt(arr.size() - n - 1) + n + 1;
       List<String> newArr = new ArrayList<>(arr.subList(n, idx));
       newArr.addAll(arr.subList(0, n));
       newArr.addAll(arr.subList(idx, arr.size()));
       arr.clear();
       arr.addAll(newArr);
  }
}


以及c++代码


#include <iostream>
#include <vector>
#include <algorithm>
#include <cstdlib>
#include <ctime>

void moveCardBack(int n, std::vector<std::string>& arr) {
   for (int i = 0; i < n; i++) {
       std::string moveCard = arr[0];
       arr.erase(arr.begin());
       arr.push_back(moveCard);
  }
}

void moveCardMiddleRandom(int n, std::vector<std::string>& arr) {
   int idx = rand() % (arr.size() - n - 1) + n + 1;
   std::vector<std::string> newArr;
   newArr.insert(newArr.end(), arr.begin() + n, arr.begin() + idx);
   newArr.insert(newArr.end(), arr.begin(), arr.begin() + n);
   newArr.insert(newArr.end(), arr.begin() + idx, arr.end());
   arr = newArr;
}

int main() {
   srand(time(0));

   std::vector<std::string> arr = {"A", "B", "C", "D", "A", "B", "C", "D"};
   std::cout << "步骤1:拿出4张牌,对折撕成8张,按顺序叠放。" << std::endl;
   std::cout << "此时序列为: ";
   for (const std::string& card : arr) {
       std::cout << card;
  }
   std::cout << std::endl;
   std::cout << "---" << std::endl;

   int nameLen = rand() % 4 + 2;
   moveCardBack(nameLen, arr);
   std::cout << "步骤2:随机选取名字长度为" << nameLen << ",把第1张牌放到末尾,操作" << nameLen << "次。" << std::endl;
   std::cout << "此时序列为: ";
   for (const std::string& card : arr) {
       std::cout << card;
  }
   std::cout << std::endl;
   std::cout << "---" << std::endl;

   moveCardMiddleRandom(3, arr);
   std::cout << "步骤3:把牌堆顶3张放到中间的随机位置。" << std::endl;
   std::cout << "此时序列为: ";
   for (const std::string& card : arr) {
       std::cout << card;
  }
   std::cout << std::endl;
   std::cout << "---" << std::endl;

   std::string restCard = arr[0];
   arr.erase(arr.begin());
   std::cout << "步骤4:把最顶上的牌拿走,放在一边。" << std::endl;
   std::cout << "拿走的牌为: " << restCard << std::endl;
   std::cout << "此时序列为: ";
   for (const std::string& card : arr) {
       std::cout << card;
  }
   std::cout << std::endl;
   std::cout << "---" << std::endl;

   moveCardMiddleRandom(rand() % 3 + 1, arr);
   std::cout << "步骤5:我" << (rand() % 2 == 0 ? "是南方人" : "是北方人") << ",把" << rand() % 3 + 1 << "张牌插入到中间的随机位置。" << std::endl;
   std::cout << "此时序列为: ";
   for (const std::string& card : arr) {
       std::cout << card;
  }
   std::cout << std::endl;
   std::cout << "---" << std::endl;

   int maleNum = rand() % 2 + 1;
   for (int i = 0; i < maleNum; i++) {
       arr.erase(arr.begin());
  }
   std::cout << "步骤6:我" << (maleNum == 1 ? "男" : "女") << "生,移除牌堆顶的" << maleNum << "张牌。" << std::endl;
   std::cout << "此时序列为: ";
   for (const std::string& card : arr) {
       std::cout << card;
  }
   std::cout << std::endl;
   std::cout << "---" << std::endl;

   for (int i = 0; i < 7; i++) {
       std::string moveCard = arr[0];
       arr.erase(arr.begin());
       arr.push_back(moveCard);
  }
   std::cout << "步骤7:把顶部的牌移动到末尾,执行7次" << std::endl;
   std::cout << "此时序列为: ";
   for (const std::string& card : arr) {
       std::cout << card;
  }
   std::cout << std::endl;
   std::cout << "---" << std::endl;

   std::cout << "步骤8:把牌堆顶一张牌放到末尾,再移除一张牌,直到只剩下一张牌。" << std::endl;
   while (arr.size() > 1) {
       std::string luck = arr[0];
       arr.erase(arr.begin());
       arr.push_back(luck);
       std::cout << "好运留下来: " << luck << "\t\t此时序列为: ";
       for (const std::string& card : arr) {
           std::cout << card;
      }
       std::cout << std::endl;

       std::string sadness = arr[0];
       arr.erase(arr.begin());
       std::cout << "烦恼都丢掉: " << sadness << "\t\t此时序列为: ";
       for (const std::string& card : arr) {
           std::cout << card;
      }
       std::cout << std::endl;
  }
   std::cout << "---\n最终结果: " << arr[0] << ", 步骤4中留下来的牌也是" << restCard << std::endl;

   return 0;
}

作者:小u
来源:juejin.cn/post/7332865125640044556
收起阅读 »

rpc比http好吗,缪论?

是什么,如何理解 RPC(Remote Procedure Call) 直译就是远程过程调用 HTTP(HyperText Transfer Protorl) 直译就是超文本传输协议 RPC和HTTP都是 请求-响应协议,但是因为出现的时机、设计理念、约定协议...
继续阅读 »

是什么,如何理解


RPC(Remote Procedure Call) 直译就是远程过程调用


HTTP(HyperText Transfer Protorl) 直译就是超文本传输协议


RPC和HTTP都是 请求-响应协议,但是因为出现的时机、设计理念、约定协议、效率、应用范围、使用规则等不同,所以是不同的名字,本质都是为了分布式系统间的通信而生,是一种应用层通信(请求-响应)协议(从OSI网络模型来看)。



  • RPC是 Bruce Jay Nelson 在1981年创造的术语,HTTP是在1990年左右产生的(可以参看维基百科)


RPC协议 和 RPC,到底叫什么?RPC协议=RPC


HTTP协议、HTTP,到底叫什么?HTTP协议=HTTP


RPC|HTTP只是大家的简称



  • HTTP协议不仅仅只有协议,还有超文本,传输,以及很多功能(比如编解码、面试经常背的各种参数的作用)

  • RPC协议也不仅仅只有协议,还有 编解码,服务注册发现,负载均衡等


RPC协议本质上定义了一种通信的流程,而具体的实现技术是没有约束的,每一种RPC框架都有自己的实现方式,我认为HTTP也是RPC的一种实现方式


协议直白来讲是一种约定,rpc和http都是为了服务器间的通信而生,都需要制定一套标准协议来进行通信。不过HTTP比较火,是一个全世界的统一约定,使用比较广泛。但通用也意味着冗余,所以后来又产生了很多RPC框架(自定义协议,具备优秀的性能等)


我们可以自定义RPC请求/响应 包含的消息头和消息体结构,自定义编解码方式,自定义网络通信方式,只要clientserver消息的发送和解析能对应即可,这些问题确认下来,一个RPC框架就设计出来了


下面先从请求过程看一下RPC和HTTP都会经历哪些阶段,然后再分阶段去做对比


一次请求的过程



阶段阶段分层RPCHTTP
client: 业务逻辑xx业务逻辑层
client: 客户端构造请求,发起调用编解码thrift|json|protobuf等json|图片等
client: 根据传输协议构造数据流协议层thrift|gRPC|Kitex|dubbo等HTTP1 |HTTP1.1|HTTP2|QUIC等
client: 服务发现服务发现自定义内部服务发现组件DNS
client: 网络通信:传输数据流网络通信层接口层:netty|netpool,根据OS的API做了一些封装本质:TCP|UDP|HTTP系列接口层:HTTP内部自己实现,目前不清楚咋做的本质:TCP|UDP
server: 把数据流解析为协议结构协议层略,同上略,同上
server: 解析协议中的请求体编解码略,同上略,同上
server: 执行业务逻辑xx业务逻辑层略,同上略,同上

从请求链路可以看到,最核心的只有三层:编解码、协议、网络通信


下面会从这3个角度去对比HTTP和RPC


HTTP VS RPC自定义协议


HTTP和RPC 2个关键词不具备可比较性,因为RPC包含了HTTP。


但是RPC自定义协议(thrift, protobuf, dubbo, kitex-thrift等) 是RPC的具体实现,HTTP也是RPC的具体实现,它们是具备可比较性的


编解码(序列化)



  • 序列化: 指将程序运行过程中的动态内存数据(java的class、go的struct)转化为硬盘中静态二进制数据的过程,以方便网络传输。

  • 反序列化:指将硬盘中静态二进制数据转化为程序运行过程中的动态内存数据的过程,以方便程序计算。


HTTP/1.1 一般用json


自定义RPC协议 一般用 thrift、protobuf


kitex序列化协议


维度json(HTTP/1.1)protobuf(gRPC)
优点1. 可读性好、使用简单,学习成本低1. 序列化后的体积比json小 => 传输效率高
2. 序列化/反序列化速度快 => 性能损耗小
缺点1. JSON 进行序列化的额外空间开销比较大
2. JSON 没有类型,比如无法区分整数和浮点
像 Java 、Go这种强类型语言,不是很友好,解析速度比较慢(需要通过反射解决)
1. 不可读,都是二进制
适用场景适用于服务提供者与服务调用者之间传输的数据量要相对较小的情况,否则会严重影响性能追求高性能的场景

协议层


编码之后,数据转换成字节流,但是RPC通信时,每次请求发送的数据大小不是固定的,那么为了区分消息的边界,避免粘包、半包等现象,我们需要定义一种协议,来使得接收方能够正确地读出不定长的内容。简单点说,通信协议就是约定客户端和服务器端传输什么数据,以及如何解析数据。


维度HTTP/1.1kitex-TTHeader
优点1. 灵活,可以自定义很多字段
2. 几乎所有设备都可以支持HTTP协议
1. 灵活,通用,可以自定义
  • 自定义必要字段即可 => 减小报文体积,提高传输效率
    2. 性能优秀
  • 缺点1. 包含许多为了适应浏览器的冗余字段,这些是内部服务用不到的,性能差1. 部分设备存在不能支持,通用性欠佳

    可参考



    可以思考一下 序列化、传输协议、网络通信的关系,下面以kitex为例进行分析


    kitex codec 接口定义kitex thrift 序列化实现kitex ttheader协议,kitex 发送请求核心代码



    可以发现 Encode中,先根据message构造出header,写入out,然后再把data(实际的业务数据)写到out。


    encode函数完全遵守 ttheader协议去构造数据。


    最后再把out通过网络库发送出去



    网络通信层


    网络通信层主要提供一个易用的网络库,封装了操作系统提供的socket api。


    维度HTTP/1.1kitex框架
    实现方式一般采用短连接需要3次握手(可以配置长链接添加请求头Keep-Alive: timeout=20)- 长连接,指在一个连接上可以连续发送多个数据包,在连接保持期间,如果没有数据包发送,需要双方发链路检测包rpc框架维护一个tcp连接池,每次用完不断开连接,通过心跳检测断开连接(探测服务是否有问题)- 支持短连接、长连接池、连接多路复用以及连接池状态监控。
    优点1. 几乎所有设备都可以支持HTTP协议1. 不用每次请求都经历三次握手&四次挥手,减少延时
    缺点1. 每次请求都要新建连接,性能差1. 部分设备存在不能支持,通用性欠佳

    HTTP的长连接和TCP长连接不是一个东西,需要注意下,TCP Keepalive是操作系统实现的功能,并不是TCP协议的一部分,需要在操作系统下进行相关配置(只能保证网络没问题,不能代表服务没问题)


    其中 HTTP2 拥有多路复用、优先级控制、头部压缩等优势


    可以参考


    kitex:连接类型


    RPC自定义协议 和 HTTP的使用场景


    公司内部的微服务,对客户端提供的服务 适合用RPC,更好的性能


    对外服务、单体服务、为前端提供的服务适合用HTTP


    我的思考


    rpc在编解码、协议层、网络通信 都比HTTP有更大的优势,那为啥不把HTTP换成RPC呢



    1. 人的认知,HTTP已经深入人心(或者说生态好,通用性强),几乎所有的机器、浏览器和语言默认都会支持。但是自定义RPC协议 可能很多人都没听过(比如kitex、dubbo等),还让别人支持,根本不可能。

      • 需要建设全局的DNS等等,HTTP链路中的组件都需要换成 自定义的那一套,成本极高。

      • 但是公司内部可以搞成一套,可以极大提高性能,何乐而不为。

      • 我见过的案例是很多时候并没有深入思考为什么用,而是大家都这么用,我也这么用。



    2. 浏览器只支持 http协议。而且浏览器不支持自定义编解码的解析

      • 为啥大家面向浏览器/前端 不用自定义编解码?

        • 举例:protobuf不支持前端语言,但是支持java

        • 就是自定义编解码框架支持语言有限,很多语言没有工具可以做,并且浏览器也不支持。对于问题排查比较困难。

        • github.com/protocolbuf…



      • http不仅可以传输json、还可以传输二进制、图片等。所以协议层可以用http,编解码用protobuf/thrift也是可行的。

        • 公司内部实际案例:服务端和客户端交互时,为了提高性能,采用protobuf编解码数据,使用http协议传输数据。

          • 但是每次请求/响应数据都是不可读的。服务端会把protobuf编码前的数据转为json,用于打印log/存储,方便排查问题。





      • 参考 丨隋堤倦客丨的评论





    • RPC框架 可以自定义负载均衡,重试机制,高可用,流量控制等策略。这些是HTTP不能支持的

      • 我理解是协议层用的http,但是内部的运行机制还是自定义的。http只是定义了传输数据的格式。举个例子:http的流量控制其实用的是 tcp的滑动窗口,http协议本身不具备这些功能。但是rpc是可以自己加这些功能的。这些功能必然有数据传输,这个传输协议用的http。

      • 参考 leewp同学的评论




    参考


    如何保活主流RPC框架长连接,Dubbo的心跳机制,值得学习_牛客博客


    3.8 既然有 HTTP 协议,为什么还要有 RPC?


    4.15 TCP Keepalive 和 HTTP Keep-Alive 是一个东西吗?


    RPC 漫谈: 连接问题


    聊一聊Go网络编程(一)--TCP连接通信 - 掘金


    Kitex前传:RPC框架那些你不得不知的故事


    kitex 传输协议


    dubbo RPC 协议


    作者:cli
    来源:juejin.cn/post/7264454873588449336
    收起阅读 »

    async/await 你可能正在将异步写成同步

    web
    前言 你是否察觉到自己随手写的异步函数,实际却是“同步”的效果! 正文 以一个需求为例:获取给定目录下的全部文件,返回所有文件的路径数组。 第一版 思路很简单:读取目录内容,如果是文件,添加进结果数组,如果还是目录,我们递归执行。 import path fr...
    继续阅读 »

    前言


    你是否察觉到自己随手写的异步函数,实际却是“同步”的效果!


    正文


    以一个需求为例:获取给定目录下的全部文件,返回所有文件的路径数组。


    第一版


    思路很简单:读取目录内容,如果是文件,添加进结果数组,如果还是目录,我们递归执行。


    import path from 'node:path'
    import fs from 'node:fs/promises'
    import { existsSync } from 'node:fs'

    async function findFiles(root) {
    if (!existsSync(root)) return

    const rootStat = await fs.stat(root)
    if (rootStat.isFile()) return [root]

    const result = []
    const find = async (dir) => {
    const files = await fs.readdir(dir)
    for (let file of files) {
    file = path.resolve(dir, file)
    const stat = await fs.stat(file)
    if (stat.isFile()) {
    result.push(file)
    } else if (stat.isDirectory()) {
    await find(file)
    }
    }
    }
    await find(root)
    return result
    }

    机智的你是否已经发现了问题?


    我们递归查询子目录的过程是不需要等待上一个结果的,但是第 20 行代码,只有查询完一个子目录之后才会查询下一个,显然让并发的异步,变成了顺序的“同步”执行。


    那我们去掉 20 行的 await 是不是就可以了,当然不行,这样的话 await find(root) 在没有完全遍历目录之前就会立刻返回,我们无法拿到正确的结果。


    思考一下,怎么修改它呢?......让我们看第二版代码。


    第二版


    import path from 'node:path'
    import fs from 'node:fs/promises'
    import { existsSync } from 'node:fs'

    async function findFiles(root) {
    if (!existsSync(root)) return

    const rootStat = await fs.stat(root)
    if (rootStat.isFile()) return [root]

    const result = []
    const find = async (dir) => {
    const task = (await fs.readdir(dir)).map(async (file) => {
    file = path.resolve(dir, file)
    const stat = await fs.stat(file)
    if (stat.isFile()) {
    result.push(file)
    } else if (stat.isDirectory()) {
    await find(file)
    }
    })
    return Promise.all(task)
    }
    await find(root)
    return result
    }

    我们把每个子目录内容的查询作为独立的任务,扔给 Promise.all 执行,就是这个简单的改动,性能得到了质的提升,让我们看看测试,究竟能差多少。


    对比测试


    console.time('v1')
    const files1 = await findFiles1('D:\\Videos')
    console.timeEnd('v1')

    console.time('v2')
    const files2 = await findFiles2('D:\\Videos')
    console.timeEnd('v2')

    console.log(files1?.length, files2?.length)

    result


    版本二快了三倍不止,如果是并发的接口请求被不小心搞成了顺序执行,差距比这还要夸张。


    作者:justorez
    来源:juejin.cn/post/7332031293877485578
    收起阅读 »

    为什么大家都不想回家过年了

    大家好,我是林家少爷,是一位专注于前端技术,立志成为前端技术专家的热血青年。 2023年,看到身边的朋友多多少少都经历了裁员,降薪,甚至被迫自离。 我还好,2023年唯一值得庆幸的事情就是我工资涨薪2k,不过年终奖依旧无望。 今年年会都取消了,过年礼品也没见影...
    继续阅读 »

    大家好,我是林家少爷,是一位专注于前端技术,立志成为前端技术专家的热血青年。


    2023年,看到身边的朋友多多少少都经历了裁员,降薪,甚至被迫自离。


    我还好,2023年唯一值得庆幸的事情就是我工资涨薪2k,不过年终奖依旧无望。
    今年年会都取消了,过年礼品也没见影。


    坦白讲,自从放开疫情之后,行情不仅没有好转,反而更差了,预计2024年要比之前更难,所以,2024年到2025年,这两年还是多攒钱,多搞钱,然后把钱存下来,尽量多存钱,比什么都强。


    好不容易熬到了过年,本来想回到温暖的港湾,把2023年的事情好的不好的都说出来跟家里人倾诉一下。


    没想到面对的,是家里人的催婚,在一线城市工资那么高,钱都到哪里去了,人家谁谁才比你大三个月都已经二胎了......


    甚至过年还要走亲戚,甚至拉着亲戚说介绍对象。


    这些我都理解,因为我正在经历这一切。


    我的观点是,2023年赚钱是真不容易,打工的面对工作量增加,平时无偿加班,领导PUA(可能领导也被更高级的领导PUA),还有被迫降薪,薪资被压年底年终奖(然后这中间可操作空间很大),而且大城市本来消费就高,有好些同事被裁员还拿不到裁员费,大过年还要跳楼拉横幅争取,以上总总,我都亲眼看过,赚钱太难了。


    我从2020年开始,一直都在做副业,中间经历了一次失败的创业,2021年回归职场,也是行情最差的时候,没想到后面两年越来越差,也明显感觉2023年副业赚钱越来越卷,任何一点赚钱的项目也会被互联网给公开,摊平了信息差,然后大家一窝蜂地涌进来,自己流量也被抢占了,很快这个项目又得放弃了,我还没统计2023年我的副业赚了多少,但肯定不超过10万,离我定的目标(超过主业工资持续3个月以上)还差很远,而且总是被加班,老板周末问话给打断,有时候自己也被气炸了,好几次都想不干了,但是看着自己还没有做起来的副业盘子,贸贸然走人收入立马断了,也是要重新找工作的,就忍了下来。


    大家看到这里就知道我当时内心有多矛盾,但是我都坚持了下来,我相信总有一天我可以真正把副业做起来,真正拥有属于自己的事业,手上有许多现钱,不需要看任何人眼色,我能活成我自己。


    回到过年这个话题,很多老一辈就觉得,大过年的就应该走走亲戚,见见七八姑八大姨,互相聊聊家常,好不热闹。


    但是我身边很多同事,不包括程序员,其实都是偏内向的人(包括我也是),就想着在家里跟家里人倾诉一下,哪怕不倾诉,也是关在家里面,把房间打扫的干净整洁,安静的看书,或者玩玩游戏,或者搞钱,就是不想去见一些八竿子打不着的亲戚,这是一种精神内耗。


    说实话,他们真的只想回家好好休息,啥事都不做,饭店有家人做好的满满一桌饭菜,上班那点屁事就不管他了~


    催婚,算了吧,想当年我们的目标都是考清华北大,985,211,为了高考付出了多少个不眠之夜,但是人人都考得上吗?


    尤其是奔三的女孩子,我能体会她们被家里人各种催婚的痛苦,真的会把人逼疯。


    婚姻大事,岂非儿戏,还是要找到同频人,先谈恋爱,再结婚,感情基础决定上层建筑,只有这样才能长长久久。


    搭伙过日子,那只适合70年代,不适合我们,随意搭伙,闪婚,没有前期磨合阶段,大概率会闪离,现在离婚率高不是没有原因的,所以还需慎重。


    至于面对家里走亲戚,出到社会都知道不过是演戏而已,跟着演,演到底即可。


    不是个好演员做不了一个好销售,不是好销售做不了一个会赚钱的程序员。


    这句话我自己总结出来的,会自我营销就是要会演戏,把自己都骗过了才能骗别人。


    前期会比较累,慢慢就习惯了。


    实在忍受不了,下一年大不了不回家了,图个清静。


    点到为止,祝大家新年快乐。


    作者:林家少爷
    来源:juejin.cn/post/7332593229337100303
    收起阅读 »

    小镇做题家必须要跨过的三道坎

    其实我们大多数人都还称不上小镇做题家,因为我们大多都是来自乡村,只不过在乡镇做了上了几年的学而已。 大多数人的人生轨迹基本上都是从乡村到乡镇再到县城,最终去到了市级以上的城市读大学,没有资源,没人指路,毕业后也是一脸茫然,最终回到原点! 所以大多数小镇做题家的...
    继续阅读 »

    其实我们大多数人都还称不上小镇做题家,因为我们大多都是来自乡村,只不过在乡镇做了上了几年的学而已。


    大多数人的人生轨迹基本上都是从乡村到乡镇再到县城,最终去到了市级以上的城市读大学,没有资源,没人指路,毕业后也是一脸茫然,最终回到原点!


    所以大多数小镇做题家的生活永远都是那么艰难,很难成为出题家,是因为多数人身上都出现了致命的弱点,而这些弱点多数是由原生家庭带来的。


    一.自卑


    自卑可以说是原生家庭带来的第一原罪,也是很难突破的一关,而导致自卑的导火索一定是贫穷。


    因为我们多数人从小都被父母灌输“我们家穷,所以你不应该这样做”。


    所以在进入大学后,多数人的心中都有一个魔咒,我不配拥有,不配出去玩,不配谈恋爱,不配穿好看的衣服,即使是自己打工赚的钱,多花一分都觉得有负罪感。


    因为心里会想着,父母那么辛苦,我就不应该去花钱,而是要节省,当然节省并没有不好。


    但是从逻辑上就已经搞错了,首先如果自己舍不得钱去学习,去投资自己,一个月挣3000能够省下2000,那么这个省钱毫无意义,因为省下的这点钱根本做不了什么,但是会把自己置入一个更深的漩涡,收入无法提升,眼界无法开阔,而是一直盯着一个月省2000这个感人的骗局。


    除了用钱有负罪感,还有不敢向上社交,觉得自己是小山旮旯出来的,不配和优秀的人结伴,因为父母从小就说: 我们家条件没人家好,人家有钱,人家会瞧不起你的。所以很多人内心就已经打了退堂鼓,从而错过了和优秀的人链接的机会。


    但是事实上真正优秀的人,人家从来不会有瞧不起人的心态,只要你身上有优点,你们之间能够进行价值交换,别人比你还谦卑。


    我这几年从互联网行业链接到的人有1000个以上,优秀的人不在少数,我发现越优秀的人越谦虚。


    自卑导致的问题还很多,比如做事扭扭咧咧,不敢上台,不敢发表意见,总是躲在角落里。


    但是这个社会没有人喜欢扭扭咧咧的人,都喜欢落落大方的人,即使你的知识不专业,你普通话不好,这些都不重要,但是只要你表现出自信,大方的态度,那么大家都会欣赏你,因为这就是勇敢。


    二.面子


    有一个事实,越脆弱的人越爱面子,越无能的人越爱面子。


    这句话虽然说起来有点不好听,但是这是事实,我们这种小镇做题家最要先放下面子。


    比如自己不懂很多东西,但是不愿意去问别人,怕被别人说,在学校的时候,有问题不敢问,于是花费很多时间去弄,到最后还是搞不懂。


    进入工作后,不懂的也不好意思问同事和领导,怕被别人说菜,怀疑自己。


    其实真的没几个人有那个闲心去关注你的过往,你的家庭,相反越是伪装,越容易暴露。


    我很喜欢的一段话: 我穷,但是我正在拼命改变、我菜,但是我可以拼命学!


    面子的背后是自负,是错失,是沦陷。


    三.认知


    认知是一个人的天花板,它把人划分了层级。


    有一段话说得很好:你永远无法赚到认知之外的钱,你也无法了解认知之外的世界。


    我们多数小镇做题家的思维永远停留在老师和父母的教导:好好读书,将来就能考一个好工作,就能找一个铁饭碗。

    然后在职场中听老板的:好好工作,任劳任怨,以后给你升职加薪。


    然后多数人就深信不疑,就觉得人生的目标就是铁饭碗,其他的都不行,工作努力就一定能得到升职加薪,实际上这一切是在PUA自己。


    当被这个社会毒打后,才发现自己是那么无知,那么天真。


    而这一切的罪魁祸首就是自己认知不够,总觉得按部就班就一定能顺利过好一生。


    ————


    自卑,面子,认知这三点是我们多数小镇做题家难以跨过的三道坎。


    而这三道坎基本上都是原生家庭和教育造成的。


    跨过这三道坎的方法就是逃离和向上链接。


    施耐庵在几百年前就总结出的一句话:母弱出商贾,父强做侍郎,族望留原籍,家贫走他乡。


    显然我们大多数都是属于第四类,所以远走他乡是唯一的选择,只有脱离原生的环境,才能打开视野。


    事实也是如此,我们观察身边没有背景,没有经济条件的人,他们的谷底反弹一定是逃离。


    绝非留恋原地!


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

    压缩炸弹,Java怎么防止

    一、什么是压缩炸弹,会有什么危害 1.1 什么是压缩炸弹 压缩炸弹(ZIP):一个压缩包只有几十KB,但是解压缩后有几十GB,甚至可以去到几百TB,直接撑爆硬盘,或者是在解压过程中CPU飙到100%造成服务器宕机。虽然系统功能没有自动解压,但是假如开发人员在不...
    继续阅读 »

    一、什么是压缩炸弹,会有什么危害


    1.1 什么是压缩炸弹


    压缩炸弹(ZIP):一个压缩包只有几十KB,但是解压缩后有几十GB,甚至可以去到几百TB,直接撑爆硬盘,或者是在解压过程中CPU飙到100%造成服务器宕机。虽然系统功能没有自动解压,但是假如开发人员在不细心观察的情况下进行一键解压(不看压缩包里面的文件大小),可导致压缩炸弹爆炸。又或者压缩炸弹藏在比较深的目录下,不经意的解压缩,也可导致压缩炸弹爆炸。


    以下是安全测试几种经典的压缩炸弹


    graph LR
    A(安全测试的经典压缩炸弹)
    B(zip文件42KB)
    C(zip文件10MB)
    D(zip文件46MB)
    E(解压后5.5G)
    F(解压后281TB)
    G(解压后4.5PB)

    A ---> B --解压--> E
    A ---> C --解压--> F
    A ---> D --解压--> G

    style B fill:#FFC0CB,stroke:#FFC0CB,stroke-width:2px
    style C fill:#FFA07A,stroke:#FFA07A,stroke-width:2px
    style D fill:#FFFFE0,stroke:#FFFFE0,stroke-width:2px
    style E fill:#FFC0CB,stroke:#FFC0CB,stroke-width:2px
    style F fill:#FFA07A,stroke:#FFA07A,stroke-width:2px
    style G fill:#FFFFE0,stroke:#FFFFE0,stroke-width:2px


    压缩炸弹(也称为压缩文件炸弹、炸弹文件)是一种特殊的文件,它在解压缩时会迅速膨胀成极其庞大的文件,可能导致系统资源耗尽、崩溃或磁盘空间耗尽。


    压缩炸弹的原理是利用文件压缩算法中的重复模式和递归压缩的特性。它通常是一个非常小的压缩文件,但在解压缩时会生成大量的重复数据,导致文件大小迅速增长。这种文件的设计意图是迫使系统进行大量的解压缩操作,以消耗系统资源或填满磁盘空间。


    压缩炸弹可能对系统造成严重的影响,包括系统崩溃、资源耗尽、拒绝服务攻击等。因此,它被视为一种恶意的计算机攻击工具,常常被用于恶意目的或作为安全测试中的一种工具。



    1.2 压缩炸弹会有什么危害


    graph LR
    A(压缩炸弹的危害)
    B(资源耗尽)
    C(磁盘空间耗尽)
    D(系统崩溃)
    E(拒绝服务攻击)
    F(数据丢失)

    A ---> B
    A ---> C
    A ---> D
    A ---> E
    A ---> F

    style B fill:#FFC0CB,stroke:#FFC0CB,stroke-width:2px
    style C fill:#FFA07A,stroke:#FFA07A,stroke-width:2px
    style D fill:#FFFFE0,stroke:#FFFFE0,stroke-width:2px
    style E fill:#98FB98,stroke:#98FB98,stroke-width:2px
    style F fill:#00FFFF,stroke:#00FFFF,stroke-width:2px

    压缩炸弹可能对计算机系统造成以下具体的破坏:



    1. 资源耗尽:压缩炸弹在解压缩时会生成大量的重复数据,导致系统的CPU、内存和磁盘资源被迅速占用。这可能导致系统反应迟缓、无法响应用户的请求,甚至系统崩溃。

    2. 磁盘空间耗尽:由于压缩炸弹的膨胀特性,它可能在解压缩过程中填满磁盘空间。这会导致系统无法写入新的数据,造成磁盘空间耗尽,影响系统的正常运行。

    3. 系统崩溃:当一个压缩炸弹被解压缩时,系统可能由于资源耗尽或磁盘空间耗尽而崩溃。这可能导致系统重启或需要进行紧急修复,造成数据丢失或系统不可用的情况。

    4. 拒绝服务攻击:大规模的解压缩操作可能消耗大量系统资源,导致系统无法正常提供服务。这被恶意攻击者利用,用于进行拒绝服务攻击,使目标系统无法响应合法用户的请求。

    5. 数据丢失:在某些情况下,压缩炸弹可能会导致系统文件或数据的损坏或丢失。这可能发生在磁盘空间被耗尽时,写入操作无法成功完成的情况下。



    重要提示:压缩炸弹可能对计算机系统造成不可逆的损害,请不要尝试创建、传播或使用压缩炸弹,以保护计算机和网络的安全。



    二、怎么检测和处理压缩炸弹,Java怎么防止压缩炸弹


    2.1 个人有没有方法可以检测压缩炸弹?


    有一些方法可以识别和处理潜在的压缩炸弹,以防止对系统造成破坏。以下是一些常见的方法:


    graph LR
    A(个人检测压缩炸弹)
    B(安全软件和防病毒工具)
    C(文件大小限制)
    D(文件类型过滤)

    A ---> B --> E(推荐)
    A ---> C --> F(太大的放个心眼)
    A ---> D --> G(注意不认识的文件类型)

    style B fill:#FFC0CB,stroke:#FFC0CB,stroke-width:2px
    style C fill:#FFA07A,stroke:#FFA07A,stroke-width:2px
    style D fill:#FFFFE0,stroke:#FFFFE0,stroke-width:2px
    style E fill:#FFC0CB,stroke:#FFC0CB,stroke-width:2px
    style F fill:#FFA07A,stroke:#FFA07A,stroke-width:2px
    style G fill:#FFFFE0,stroke:#FFFFE0,stroke-width:2px


    1. 安全软件和防病毒工具(推荐):使用最新的安全软件和防病毒工具可以帮助检测和阻止已知的压缩炸弹。这些工具通常具备压缩文件扫描功能,可以检查文件是否包含恶意的压缩炸弹。

    2. 文件大小限制:设置对文件大小的限制可以帮助防止解压缩过程中出现过大的文件。通过限制解压缩操作的最大文件大小,可以减少对系统资源和磁盘空间的过度消耗。

    3. 文件类型过滤:识别和过滤已知的压缩炸弹文件类型可以帮助阻止这些文件的传输和存储。通过检查文件扩展名或文件头信息,可以识别潜在的压缩炸弹,并阻止其传输或处理。


    2.2 Java怎么防止压缩炸弹


    在java中实际防止压缩炸弹的方法挺多的,可以采取以下措施来防止压缩炸弹:


    graph LR
    A(Java防止压缩炸弹)
    B(解压缩算法的限制)
    C(设置解压缩操作的资源限制)
    D(使用安全的解压缩库)
    E(文件类型验证和过滤)
    F(异步解压缩操作)
    G(安全策略和权限控制)

    A ---> B
    A ---> C
    A ---> D
    A ---> E
    A ---> F
    A ---> G

    style B fill:#FFC0CB,stroke:#FFC0CB,stroke-width:2px
    style C fill:#FFA07A,stroke:#FFA07A,stroke-width:2px
    style D fill:#FFFFE0,stroke:#FFFFE0,stroke-width:2px
    style E fill:#98FB98,stroke:#98FB98,stroke-width:2px
    style F fill:#ADD8E6,stroke:#ADD8E6,stroke-width:2px
    style G fill:#00FFFF,stroke:#00FFFF,stroke-width:2px


    1. 解压缩算法的限制:限制解压缩算法的递归层数和重复模式的检测可以帮助防止解压缩过程无限膨胀。通过限制递归的深度和检测重复模式,可以及时中断解压缩操作并避免过度消耗资源。

    2. 设置解压缩操作的资源限制:使用Java的java.util.zipjava.util.jar等类进行解压缩时,可以设置解压缩操作的资源限制,例如限制解压缩的最大文件大小、最大递归深度等。通过限制资源的使用,可以减少对系统资源的过度消耗。

    3. 使用安全的解压缩库:确保使用的解压缩库是经过安全验证的,以避免存在已知的压缩炸弹漏洞。使用官方或经过广泛验证的库可以减少受到压缩炸弹攻击的风险。

    4. 文件类型验证和过滤:在解压缩之前,可以对文件进行类型验证和过滤,排除潜在的压缩炸弹文件。通过验证文件的类型、扩展名和文件头信息,可以识别并排除不安全的压缩文件。

    5. 异步解压缩操作:将解压缩操作放在异步线程中进行,以防止阻塞主线程和耗尽系统资源。这样可以保持应用程序的响应性,并减少对系统的影响。

    6. 安全策略和权限控制:实施严格的安全策略和权限控制,限制用户对系统资源和文件的访问和操作。确保只有受信任的用户或应用程序能够进行解压缩操作,以减少恶意使用压缩炸弹的可能性。


    2.2.1 使用解压算法的限制来实现防止压缩炸弹


    在前面我们说了Java防止压缩炸弹的一些策略,下面我将代码实现通过解压缩算法的限制来实现防止压缩炸弹。


    先来看看我们实现的思路


    graph TD
    A(开始) --> B[创建 ZipFile 对象]
    B --> C[打开要解压缩的 ZIP 文件]
    C --> D[初始化 zipFileSize 变量为 0]
    D --> E{是否有更多的条目}
    E -- 是 --> F[获取 ZIP 文件的下一个条目]
    F --> G[获取当前条目的未压缩大小]
    G --> H[将解压大小累加到 zipFileSize 变量]
    H --> I{zipFileSize 是否超过指定的大小}
    I -- 是 --> J[调用 deleteDir方法删除已解压的文件夹]
    J --> K[抛出 IllegalArgumentException 异常]
    K --> L(结束)
    I -- 否 --> M(保存解压文件) --> E
    E -- 否 --> L

    style A fill:#00FFFF,stroke:#00FFFF,stroke-width:2px
    style B fill:#FFC0CB,stroke:#FFC0CB,stroke-width:2px
    style C fill:#FFA07A,stroke:#FFA07A,stroke-width:2px
    style D fill:#FFFFE0,stroke:#FFFFE0,stroke-width:2px
    style E fill:#98FB98,stroke:#98FB98,stroke-width:2px
    style F fill:#ADD8E6,stroke:#ADD8E6,stroke-width:2px
    style G fill:#00FFFF,stroke:#00FFFF,stroke-width:2px
    style H fill:#E6E6FA,stroke:#E6E6FA,stroke-width:2px
    style I fill:#ADD8E6,stroke:#ADD8E6,stroke-width:2px
    style J fill:#00FFFF,stroke:#00FFFF,stroke-width:2px
    style K fill:#E6E6FA,stroke:#E6E6FA,stroke-width:2px
    style L fill:#FFA07A,stroke:#FFA07A,stroke-width:2px
    style M fill:#ADD8E6,stroke:#ADD8E6,stroke-width:2px

    实现流程说明如下:



    1. 首先,通过给定的 file 参数创建一个 ZipFile 对象,用于打开要解压缩的 ZIP 文件。

    2. zipFileSize 变量用于计算解压缩后的文件总大小。

    3. 使用 zipFile.entries() 方法获取 ZIP 文件中的所有条目,并通过 while 循环逐个处理每个条目。

    4. 对于每个条目,使用 entry.getSize() 获取条目的未压缩大小,并将其累加到 zipFileSize 变量中。

    5. 如果 zipFileSize 超过了给定的 size 参数,说明解压后的文件大小超过了限制,此时会调用 deleteDir() 方法删除已解压的文件夹,并抛出 IllegalArgumentException 异常,以防止压缩炸弹攻击。

    6. 创建一个 File 对象 unzipped,表示解压后的文件或目录在输出文件夹中的路径。

    7. 如果当前条目是一个目录,且 unzipped 不存在,则创建该目录。

    8. 如果当前条目不是一个目录,确保 unzipped 的父文件夹存在。

    9. 创建一个 FileOutputStream 对象 fos,用于将解压后的数据写入到 unzipped 文件中。

    10. 通过 zipFile.getInputStream(entry) 获取当前条目的输入流。

    11. 创建一个缓冲区 buffer,并使用循环从输入流中读取数据,并将其写入到 fos 中,直到读取完整个条目的数据。

    12. 最后,在 finally 块中关闭 fos 和 zipFile 对象,确保资源的释放。


    实现代码工具类


    import java.io.File;
    import java.io.FileOutputStream;
    import java.io.InputStream;
    import java.util.Enumeration;
    import java.util.zip.ZipEntry;
    import java.util.zip.ZipFile;

    /**
    * 文件炸弹工具类
    *
    * @author bamboo panda
    * @version 1.0
    * @date 2023/10
    */

    public class FileBombUtil {

    /**
    * 限制文件大小 1M(限制单位:B)[1M=1024KB 1KB=1024B]
    */

    public static final Long FILE_LIMIT_SIZE = 1024 * 1024 * 1L;

    /**
    * 文件超限提示
    */

    public static final String FILE_LIMIT_SIZE_MSG = "The file size exceeds the limit";

    /**
    * 解压文件(带限制解压文件大小策略)
    *
    * @param file 压缩文件
    * @param outputfolder 解压后的文件目录
    * @param size 限制解压之后的文件大小(单位:B),示例 3M:1024 * 1024 * 3L (FileBombUtil.FILE_LIMIT_SIZE * 3)
    * @throws Exception IllegalArgumentException 超限抛出的异常
    * 注意:业务层必须抓取IllegalArgumentException异常,如果msg等于FILE_LIMIT_SIZE_MSG
    * 要考虑后面的逻辑,比如告警
    */

    public static void unzip(File file, File outputfolder, Long size) throws Exception {
    ZipFile zipFile = new ZipFile(file);
    FileOutputStream fos = null;
    try {
    Enumerationextends ZipEntry> zipEntries = zipFile.entries();
    long zipFileSize = 0L;
    ZipEntry entry;
    while (zipEntries.hasMoreElements()) {
    // 获取 ZIP 文件的下一个条目
    entry = zipEntries.nextElement();
    // 将解缩大小累加到 zipFileSize 变量
    zipFileSize += entry.getSize();
    // 判断解压文件累计大小是否超过指定的大小
    if (zipFileSize > size) {
    deleteDir(outputfolder);
    throw new IllegalArgumentException(FILE_LIMIT_SIZE_MSG);
    }
    File unzipped = new File(outputfolder, entry.getName());
    if (entry.isDirectory() && !unzipped.exists()) {
    unzipped.mkdirs();
    continue;
    } else if (!unzipped.getParentFile().exists()) {
    unzipped.getParentFile().mkdirs();
    }

    fos = new FileOutputStream(unzipped);
    InputStream in = zipFile.getInputStream(entry);

    byte[] buffer = new byte[4096];
    int count;
    while ((count = in.read(buffer, 0, buffer.length)) != -1) {
    fos.write(buffer, 0, count);
    }
    }
    } finally {
    if (null != fos) {
    fos.close();
    }
    if (null != zipFile) {
    zipFile.close();
    }
    }

    }

    /**
    * 递归删除目录文件
    *
    * @param dir 目录
    */

    private static boolean deleteDir(File dir) {
    if (dir.isDirectory()) {
    String[] children = dir.list();
    //递归删除目录中的子目录下
    for (int i = 0; i < children.length; i++) {
    boolean success = deleteDir(new File(dir, children[i]));
    if (!success) {
    return false;
    }
    }
    }
    // 目录此时为空,可以删除
    return dir.delete();
    }

    }

    测试类


    import java.io.File;

    /**
    * 文件炸弹测试类
    *
    * @author bamboo panda
    * @version 1.0
    * @date 2023/10
    */

    public class Test {

    public static void main(String[] args) {
    File bomb = new File("D:\temp\3\zbsm.zip");
    File tempFile = new File("D:\temp\3\4");
    try {
    FileBombUtil.unzip(bomb, tempFile, FileBombUtil.FILE_LIMIT_SIZE * 60);
    } catch (IllegalArgumentException e) {
    if (FileBombUtil.FILE_LIMIT_SIZE_MSG.equalsIgnoreCase(e.getMessage())) {
    FileBombUtil.deleteDir(tempFile);
    System.out.println("原始文件太大");
    } else {
    System.out.println("错误的压缩文件格式");
    }
    } catch (Exception e) {
    e.printStackTrace();
    }
    }

    }

    三、总结


    文件炸弹是一种恶意的计算机程序或文件,旨在利用压缩算法和递归结构来创建一个巨大且无限增长的文件或文件集
    合。它的目的是消耗目标系统的资源,如磁盘空间、内存和处理能力,导致系统崩溃或无法正常运行。文件炸弹可能是有意制造的攻击工具,用于拒绝服务(DoS)攻击或滥用资源的目的。


    文件炸弹带来的危害极大,作为开发人员,我们必须深刻认识到文件炸弹的危害性,并始终保持高度警惕,以防止这种潜在漏洞给恐怖分子以可乘之机。


    总而言之,我们作为开发人员,要深刻认识到文件炸弹的危害性,严防死守,不给恐怖分子任何可乘之机。通过使用安全工具、限制文件大小、及时更新软件、定期备份数据以及加强安全意识,我们可以有效地防止文件炸弹和其他恶意活动对系统造成损害。


    在中国,网络安全和计算机犯罪问题受到相关法律法规的管理和监管。以下是一些中国关于网络安全和计算机犯罪方面的法律文献,其中也涉及到文件炸弹的相关规定:




    1. 《中华人民共和国刑法》- 该法律规定了各种计算机犯罪行为的法律责任,包括非法控制计算机信息系统、破坏计算机信息系统功能、非法获取计算机信息系统数据等行为,这些行为可能涉及到使用文件炸弹进行攻击。

    2. 《中华人民共和国网络安全法》- 该法律是中国的基本法律,旨在保障网络安全和维护国家网络空间主权。它规定了网络安全的基本要求和责任,包括禁止制作、传播软件病毒、恶意程序和文件炸弹等危害网络安全的行为。

    3. 《中华人民共和国计算机信息系统安全保护条例》- 这是一项行政法规,详细规定了计算机信息系统安全的保护措施和管理要求。其中包含了对恶意程序、计算机病毒和文件炸弹等威胁的防范要求。



    作者:独爱竹子的功夫熊猫
    来源:juejin.cn/post/7289667869557178404
    收起阅读 »

    突发奇想(吃饱了撑的) vue3能实现react中的hooks吗?

    web
    前言在前端领域,Vue 和 React 是两个备受欢迎的 JavaScript 框架,它们都为开发者提供了灵活而强大的工具。React 引入了 Hooks 的概念,使得函数组件可以拥有状态和其他 React 特性,而不再需要使用类组件。那么,对于那些突发奇想、...
    继续阅读 »

    前言

    在前端领域,Vue 和 React 是两个备受欢迎的 JavaScript 框架,它们都为开发者提供了灵活而强大的工具。React 引入了 Hooks 的概念,使得函数组件可以拥有状态和其他 React 特性,而不再需要使用类组件。那么,对于那些突发奇想、吃饱了撑的时候,我们是否可以在 Vue 3 中实现类似 React Hooks 的功能呢?

    首先,我们需要了解 React Hooks 的核心思想。React Hooks 允许函数组件拥有状态,副作用和其他 React 特性,而无需使用类组件。这是通过使用 useStateuseEffect 等特殊的函数来实现的。

    Vue 3 也引入了 Composition API,它在一定程度上类似于 React Hooks。Composition API 允许我们在函数组件中组织和重用逻辑。虽然它不是完全相同的实现,但能够达到类似的效果。

    useState

    React 中的 useState:

    useState 是 React 中的一个 Hook,用于在函数组件中引入状态。通过 useState,我们可以在函数组件中保存和更新状态,而不必使用类组件。

    基本语法如下:

    import React, { useState } from 'react';

    function ExampleComponent() {
    // 使用 useState 定义状态变量 count 和更新函数 setCount,并初始化为 0
    const [count, setCount] = useState(0);

    return (
    <div>
    <p>Count: {count}p>
    <button onClick={() => setCount(count + 1)}>
    加一
    button>
    div>
    );
    }

    下面是一个使用 vue3实现类似于 useState 的例子:

    import { ref, UnwrapRef } from "vue";

    type UpdateFunction = (nextState: UnwrapRef) => UnwrapRef;
    function isUpdateFc(
    nextState: UnwrapRef | UpdateFunction
    ): nextState is UpdateFunction {
    return typeof nextState === "function";
    }

    export default function useState(initialState: T) {
    const state = ref(initialState);
    const useState = (nextState: UnwrapRef | UpdateFunction) => {
    // 检测传入的是不是函数,如果是函数就把state传给函数,把函数执行返回值赋给重新state
    if (isUpdateFc(nextState)) {
    state.value = nextState(state.value);
    } else {
    state.value = nextState;
    }
    };
    return [state, useState] as const;
    }

    <template>
    <div>
    <button>{{ count }}button>
    <button @click="() => handerCount()">+button>
    div>
    template>

    useEffect

    React 中的 useEffect:

    useEffect 是 React 中的一个重要 Hook,用于处理副作用操作,比如数据获取、订阅、手动操作 DOM 等。它在函数组件渲染完成后执行,可以用于管理组件的生命周期。

    基本语法如下:

    import React, { useState, useEffect } from 'react';

    function ExampleComponent() {
    const [data, setData] = useState(null);

    useEffect(() => {
    // 在组件渲染完成后执行的副作用操作
    fetchData(); // 例如,发起数据请求
    }, []); // 第二个参数是依赖数组,为空数组表示只在组件挂载和卸载时执行

    return (
    <div>
    {/* 组件渲染的内容 */}
    div>
    );
    }

    在 Vue 3 中使用 onMounted, onUpdated, onUnmounted,watch 实现类似功能:

    在 Vue 3 中,可以使用一系列的生命周期钩子和 watch 函数来实现与 useEffect 类似的效果。

    1. onMounted: 在组件挂载后执行。
    2. onUpdated: 在组件更新后执行。
    3. onUnmounted: 在组件卸载前执行。
    4. watch: 监听特定数据的变化。

    下面是一个使用 vue3实现类似于 useEffect 的例子:

    import { ref, onMounted, watch, onUnmounted, onUpdated } from "vue";

    type EffectCleanup = void | (() => void);
    export default function useEffect(
    setup: () => EffectCleanup,
    dependencies?: readonly unknown[]
    ): void {
    const cleanupRef = ref<EffectCleanup | null>(null);
    const runEffect = () => {
    // 判断下一次执行副作用前还有没有清理函数没有执行
    if (cleanupRef.value) {
    cleanupRef.value();
    }
    // 执行副作用,并赋值清理函数
    cleanupRef.value = setup();
    };
    // 组件挂载的时候执行一次副作用
    onMounted(runEffect);
    // 判断有没有传依赖项,有的话就watch监听
    if (dependencies && dependencies.length > 0) {
    watch(dependencies, runEffect);
    } else if(dependencies === undefined) {
    // 没有传依赖项就组件每次渲染都要执行副作用
    onUpdated(runEffect)
    }
    // 组件销毁的使用如果有清理函数就执行清理函数
    onUnmounted(() => {
    if (cleanupRef.value) {
    cleanupRef.value();
    }
    });
    }

    useReducer

    React 中的 useReducer:

    useReducer 是 React 中的另一个 Hook,用于处理具有复杂状态逻辑的组件。它接受一个包含当前状态和触发状态更新的函数的 reducer,以及初始状态。通过 useReducer,我们可以更好地管理和处理复杂的状态变更逻辑。

    基本语法如下:

    import React, { useReducer } from 'react';

    // 定义 reducer 函数
    const reducer = (state, action) => {
    switch (action.type) {
    case 'increment':
    return { count: state.count + 1 };
    case 'decrement':
    return { count: state.count - 1 };
    default:
    return state;
    }
    };

    function ExampleComponent() {
    // 使用 useReducer,传入 reducer 函数和初始状态
    const [state, dispatch] = useReducer(reducer, { count: 0 });

    return (
    <div>
    <p>Count: {state.count}p>
    <button onClick={() => dispatch({ type: 'increment' })}>加一button>
    <button onClick={() => dispatch({ type: 'decrement' })}>减一button>
    div>
    );
    }

    通过刚刚实现的 useState 来实现类似 useReducer 的功能:

    import { UnwrapRef } from "vue";
    import useState from "./useState";

    type ReducerType = (state: T, action: A) => any;
    export default function useReducer(
    reducer: ReducerType<UnwrapRef, A>,
    initialArg: T,
    init?:
    (value: T) => T
    ) {
    // 根据传没传init函数来初始化state
    const [state, setState] = useState(init ? init(initialArg) : initialArg);
    const dispatch = (action: A) => {
    // 通过reducer函数的返回结果来修改state的值
    setState((state) => reducer(state, action));
    };
    return [state, dispatch] as const;
    }

    <template>
    <div>
    <div>
    <p>Count: {{ state.count }}p>
    <button @click="() => dispatch({ type: 'increment' })">
    加一
    button>
    <button @click="() => dispatch({ type: 'decrement' })">
    减一
    button>
    div>
    div>
    template>

    useCallback

    React 中的 useCallback:

    useCallback 是 React 中的一个 Hook,用于返回一个 memoized 版本的回调函数,避免在每次渲染时都创建新的回调函数。这在防止不必要的渲染和优化性能方面非常有用。

    基本语法如下:

    import React, { useState, useCallback } from 'react';

    function ExampleComponent() {
    const [count, setCount] = useState(0);

    // 使用 useCallback 返回 memoized 版本的回调函数
    const handleClick = useCallback(() => {
    setCount(count + 1);
    }, [count]); // 依赖数组中的值发生变化时,重新创建回调函数

    return (
    <div>
    <p>Count: {count}p>
    <button onClick={handleClick}>加一button>
    div>
    );
    }

    下面是一个使用 useState 和 vue3 中的 watch 模拟实现类似 useCallback 的例子:

    import { watch } from "vue";
    import useState from "./useState";

    type FnType = (...args: T[]) => any;
    export default function useCallback(fn: FnType, dependencies: D[]) {
    const [callback, setCallback] = useState(fn);
    // 如果依赖项有变更就把fn重新赋值没有就直接返回callback
    watch(
    dependencies,
    () => {
    setCallback((cb: FnType) => cb = fn);
    },
    { immediate: false }
    );
    return callback;
    }

    <template>
    <div>
    <button>{{ count }}button>
    <button @click="() => handerCount()">+button>
    div>
    template>

    useMemo

    React 中的 useMemo:

    useMemo 是 React 中的一个 Hook,用于记忆(memoize)计算结果,避免在每次渲染时都重新计算。它对于在渲染期间执行昂贵的计算并确保只在依赖项更改时重新计算结果非常有用。

    基本语法如下:

    import React, { useState, useMemo } from 'react';

    function ExampleComponent() {
    const [count, setCount] = useState(0);

    // 使用 useMemo 记忆计算结果
    const expensiveCalculation = useMemo(() => {
    console.log('计算了一次...');
    return count * 2;
    }, [count]); // 依赖数组中的值发生变化时,重新计算结果

    return (
    <div>
    <p>Count1: {count}p>
    <p>Count2: {expensiveCalculation}p>
    <button onClick={() => setCount(count + 1)}>加一button>
    div>
    );
    }

    下面是一个使用 useStateuseEffect 和 vue3 的 computed 模拟实现类似 useMemo 的例子:

    import { UnwrapRef, computed } from "vue";
    import useEffect from "./useEffect";
    import useState from "./useState";

    export default function useMemo(
    calculateValue: () => R,
    dependencies: T[]
    ) {
    const [cache, setCache] = useStatenull>(null);
    // 判断依赖项有没有变更,没有就直接返回缓存,有的话就重新计算
    useEffect(() => {
    setCache((cache) => {
    return (cache = computed(calculateValue) as UnwrapRef);
    });
    }, dependencies);
    return cache as UnwrapRef;
    }

    <template>
    <div>
    <div>平方: {{ squareSum }}div>
    <div>平方: {{ squareSum }}div>
    <button @click="handelNumbers">更改numbersbutton>
    div>
    template>

    useRef

    React 中的 useRef:

    useRef 是 React 中的一个 Hook,主要用于在函数组件中创建一个可变的对象,该对象的 current 属性被初始化为传入的参数。通常用于获取或存储组件中的引用(reference),并且不会触发组件重新渲染。

    基本语法如下:

    import React, { useRef, useEffect } from 'react';

    function ExampleComponent() {
    const myRef = useRef(null);

    useEffect(() => {
    // 使用 myRef.current 访问引用的 DOM 元素
    console.log(myRef.current);
    }, []);

    return <div ref={myRef}>获取DOMdiv>;
    }

    下面是一个使用 Vue 3 的 ref 模拟实现 useRef 的例子:

    import { ref, Ref } from "vue";

    function isHTMLElement(obj: unknown): obj is HTMLElement {
    return obj instanceof HTMLElement;
    }

    function useRefextends HTMLElement>(initialValue: T | null): Refnull>;
    function useRefextends unknown>(
    initialValue: T extends HTMLElement ? never : T
    ): { current: T };

    function useRef(
    initialValue: unknown
    ): Ref<HTMLElement | null> | { current: unknown } {
    // 判断传入的是不是一个HTML节点
    // 这里可能有点问题就是,或者传入null也会被判定为HTML节点,我没想到怎么解决这个问题
    if (isHTMLElement(initialValue) || initialValue === null) {
    return ref(initialValue);
    } else {
    // 不是就返回一个普通对象
    return {
    current: initialValue,
    };
    }
    }

    export default useRef;

    <template>
    <div>
    <input ref="myInputRef" type="text" />
    <p>Counter: {{ counterRef.current }}p>
    <button @click="incrementCounter">加一button>
    div>
    template>

    补充

    对于react中的createContext,useContext和vue3中的provide,inject很像。

    React 中的 createContext 和 useContext:

    1. createContext: 用于创建一个上下文对象,它包含一个 Provider 组件和一个 Consumer 组件。createContext 接受一个默认值,这个默认值在组件树中找不到对应的 Provider 时被使用。
    const MyContext = React.createContext(defaultValue);
    1. useContext: 用于在函数组件中订阅上下文的变化,获取当前 Provider 提供的值。
    const contextValue = useContext(MyContext);

    Vue3 中的 provide 和 inject:

    1. provide: 用于在父组件中提供数据,被提供的数据可以被子组件通过 inject 访问到。provide 接受一个对象,对象的属性即为提供的数据。

    1. inject: 用于在子组件中注入父组件提供的数据。可以是一个数组,也可以是一个对象,对象的属性为子组件中的变量名,值为从父组件中注入的数据。

    相似之处:

    • 目的相同: 无论是 React 中的上下文和钩子,还是 Vue 3 中的 provide 和 inject,它们都旨在实现组件之间的状态共享,提供一种在组件树中传递数据的方式。
    • 使用方式: 在使用上,它们都在父组件中提供数据,并在子组件中获取数据。
    • 避免了 props 层层传递: 这些机制都避免了将数据通过 props 层层传递的麻烦,特别在深层嵌套的组件树中,可以更方便地进行状态管理。

    总体而言,虽然具体的实现和语法有所不同,但这些机制在概念上非常相似,都是为了解决在组件树中共享数据的问题。

    总结

    本文源于作者的一时灵感,尝试探讨在 Vue 3 中是否能实现类似 React Hooks 的功能。虽然这个想法是出于好奇和娱乐,但在实际的开发中或许并没有太多实际用途。

    通过对比 React Hooks 和 Vue 3 Composition API,我们发现两者在语法和实现上存在一些差异,但本质上都为开发者提供了在函数组件中组织和重用逻辑的方式。这种灵活性是前端技术不断演进的体现,而每一种方式都有其适用的场景。

    在实际项目中,选择使用 React Hooks 还是 Vue3 Composition API 取决于团队和个人的偏好,以及项目的具体需求。技术的发展是不断前行的过程,而我们在其中的探索和实践都是宝贵的经验。

    愿读者在技术的海洋中,既能保持对新鲜事物的好奇心,又能在实际项目中选择合适的工具,取得更好的开发效果。无论是整活还是严肃的技术探讨,都让我们在编码的世界里保持一份热爱和乐趣。


    作者:辛克莱
    来源:juejin.cn/post/7328229830134972425
    收起阅读 »

    换个角度学TS,也许你能熟悉它

    web
    前言 TS绝非为了炫技的存在, 一切为了类型安全, 牢记这句话, 牢记这句话, 牢记这句话!!!感谢林不渡和光神大佬, 本文基本上都是从大佬们那里学来的。 一道开胃菜 function memoize

    前言


    TS绝非为了炫技的存在, 一切为了类型安全, 牢记这句话, 牢记这句话, 牢记这句话!!!感谢林不渡和光神大佬, 本文基本上都是从大佬们那里学来的。


    一道开胃菜


    function memoizeextends (...args: any[]) => any>(fn: T) {
    const cache = new Map()
    return (...args: Parameters<typeof fn>) => {
    const key = JSON.stringify(fn)
    if (cache.has(key)) {
    return cache.get(key)
    }
    const result = fn(...args)
    cache.set(key, result)
    return result
    }
    }

    const add = (a: number, b: number) => a + b
    const memoAdd = memoize(add)
    console.log(memoAdd(1, 2)) // 3
    console.log(memoAdd(1, 2)) // 3

    上面这个缓存函数的有个泛型T, 泛型T被约束为是一个可以是任意类型参数和返回任意类型的函数, 函数被调用时才明确知道T的类型, 比如下面的add函数, 这个时候TS类型就自动推导T是一个a、b参数类型为number返回一个number的函数, 我们看缓存函数内部返回一个函数接收的参数类型应该和T的参数类型一致, 这个时候可以使用TS内置的工具类型Parameters, 它可以返回一个函数类型的参数数组类型, 所以我们typeof fn, 就可以拿到fn的类型, 或者直接使用Parameters


    我们来看看Parameters是怎么实现的:


    type Parametersextends (...args: any) => any> =
    T extends (...args: infer P) => any ? P : never;

    Parameters工具类型接收一个泛型T被约束为可以是任意的函数类型, 后面用了infer类型推导, 可以在类型被使用时推导出具体的类型, T如果是(...args: infer P) => any的子类型, 就返回P, 否则返回never(表示永不可达)。


    不熟悉这种语法没关系, 我们把TS的内置类型都实现一遍,熟能生巧。


    TS内置类型工具


    Awaited


    // 基础用法
    type promise = Promise<string>
    type p = Awaited // string

    // 定义一个返回 Promise 的函数
    function fetchData(): Promise<string> {
    return new Promise((resolve) => {
    setTimeout(() => {
    resolve('成功啦啦啦');
    }, 1000);
    });
    }
    // 使用 Awaited 获取 Promise 结果的类型
    type ResultType = Awaited<ReturnType<typeof fetchData>>;

    const result: ResultType = 'Awaited'; // 此处类型会被推断为 string

    async function useResult() {
    const data = await fetchData();
    console.log(data); // 此处 data 的类型已经被推断为 string
    }
    useResult();

    这里的ReturnType和Parameters是配对的, 一个是获取函数类型的参数类型, 一个是获取函数类型的返回值类型, 我们看看ReturnType是怎么实现的


    type ReturnTypeextends (...args: any) => any> =
    T extends (...args: any) => infer R ? R : any;

    我们发现和上面Parameters的实现如出一辙, 只是把infer推断从参数的位置移动到了函数返回值的位置。不过ReturnType如果不满足条件返回的是any而不是never了, 这并没有什么影响。所以ReturnType拿到的类型是定义promise函数的返回类型Promise, 而我们的Awaited就是要拿到Promise里面的类型string


    这里有个思路


    type MyAwait = T extends Promise ? P : never
    type p = MyAwait<Promise<string>> // string

    利用infer推断Promise里面的类型, 好的, 恭喜你, 你已经了解TS了, 可是这并不全面, 如果Promise返回的还是一个Promise呢


    type MyAwait = T extends Promise ? P : never
    type p = MyAwait<Promise<Promise<string>>> // Promise

    递归?如果P还是一个Promise就递归调用MyAwait直到拿到最里面的类型, 没错就是你想的这样, 非常完美


    type MyAwait = T extends Promise // T如果是Promise的子类型
    ? P extends Promise<unknown> // 如果推断出来的P还是一个Promise
    ? MyAwait

    // 递归MyAwait


    : P // 不是Promise就直接返回P
    : T; // 如果泛型传的都不是一个promise直接返回T
    type p = MyAwait<Promise<Promise<string>>>; // string


    我们来看看TS内部是如何实现的


    type Awaited = T extends null | undefined
    ? T // 对于 `null | undefined` 这种特殊情况,当未启用 `--strictNullChecks` 时,直接返回 T
    : T extends object & { then(onfulfilled: infer F, ...args: infer _): any }
    // 仅当类型 T 是一个对象并且具有可调用 `then` 方法时,才会进行下一步处理
    ? F extends (value: infer V, ...args: infer _) => any
    // 如果 `then` 方法的参数是可调用的,提取其第一个参数的类型
    ? Awaited // 递归地解开该值的嵌套异步类型
    : never // `then` 方法的参数不可调用
    : T; // 非对象或不具有 `then` 方法的类型

    Partial


    // 基础用法
    type obj = {
    a: 1,
    b: 2
    }
    type obj2 = Partial
    /**
    * type obj2 = {
    a?: 1 | undefined;
    b?: 2 | undefined;
    }
    */


    我们来实现实现,上面我们讲的是infer提取类型如果有深层的话就是extends配合三元递归,这个Partial就可以用映射成新类型?:表示的就是可选, 可选后除了原来的类型还联合了undefined类型, 这是为了类型安全考虑可选后就可能没有值。


    // 基础用法
    type obj = {
    a: 1,
    b: 2
    }
    type MyPartial = {
    [K in keyof T]?: T[K]
    }
    type obj2 = MyPartial
    /**
    * type obj2 = {
    a?: 1 | undefined;
    b?: 2 | undefined;
    }
    */


    原来的obj类型被构造成了一个新的类型obj2,还是一个对象, 对象里面的每一个键(K)是(in)对象里面所有的键(keyof T)?:(可选) T[K](T对象里面的K键的值),反复想想是不是这回事。


    如果有多个对象嵌套,就递归


    type obj = {
    a: 1,
    b: {
    c: 2
    }
    }
    type DeepPartial = {
    [K in keyof T]?: T[K] extends object ? DeepPartial : T[K]
    }
    type obj2 = DeepPartial
    /**
    * type obj2 = {
    a?: 1 | undefined;
    b?: DeepPartial<{
    c: 2;
    }> | undefined;
    }
    */


    Required


    // 基础用法
    type obj = {
    a: 1,
    b: {
    c: 2
    }
    }
    type MyPartial = {
    [K in keyof T]?: T[K]
    }
    type obj2 = Required<MyPartial>
    /**
    *type obj2 = {
    a: 1;
    b: {
    c: 2;
    };
    }
    */


    Required就是把可选的变成必传的,非常简单,只需要把?去掉


    // 基础用法
    type obj = {
    a: 1,
    b: {
    c: 2
    }
    }
    type MyPartial = {
    [K in keyof T]?: T[K]
    }
    type MyRequired = {
    [K in keyof T]-?: T[K]
    }
    type obj2 = MyRequired<MyPartial>
    /**
    *type obj2 = {
    a: 1;
    b: {
    c: 2;
    };
    }
    */


    直接-?就可以了,神奇吧,那如果对象嵌套了,我想你会举一反三了吧,没错还是它递归


    type DeepRequired = {
    [K in keyof T]-?: T[K] extends object ? DeepRequired : T[K]
    }

    Readonly


    type obj = {
    a: 1,
    b: {
    c: 2
    }
    }
    type obj2 = Readonly
    /**
    * type obj2 = {
    readonly a: 1;
    readonly b: {
    c: 2;
    };
    }
    */


    Readonly就是把对象里面的值变成只读的,看这个obj2的类型,我想你知道怎么做了吧


    type MyReadonly = {
    readonly [K in keyof T]: T[K]
    }

    type DeepReadonly = {
    readonly [K in keyof T]: T[K] extends object ? DeepReadonly : T[K]
    }

    Record


    type obj = Record<string, any>
    /**
    * type obj = {
    [x: string]: any;
    }
    */


    其实根据上面学的,你已经会实现它了


    type MyRecordextends keyof any, T> = {
    [P in K]: T
    }

    type obj = MyRecord<string, any>
    /**
    * type obj = {
    [x: string]: any;
    }
    */


    K extends keyof any: 这里使用了泛型约束,确保 K 是任何类型的键集合的子集。keyof any 表示任何类型的所有键的联合。[P in K]: T: 这是一个映射类型。它表示对于 K 中的每个键 P,都创建一个属性,其值类型是 T


    Pick


    type MyPickextends object, K extends keyof T> = {
    [P in K]: T[K]
    }

    type obj = MyPick<{a: 1, b: 2}, 'a'>
    /***
    * type obj = {
    a: 1;
    }
    */


    Omit


    type MyOmitextends object, K extends keyof T> =
    PickExclude>

    type obj = MyOmit<{ a: 1, b: 2 }, 'a'>
    /***
    * type obj = {
    b: 2;
    }
    */



    • Exclude: 使用 keyof T 获取对象 T 的所有键,然后使用 Exclude 排除其中与 K 相同的键。这样得到的是 T 中除去 K 对应键的键集合。

    • Pick: 使用 Pick 从对象 T 中选择具有特定键集合的属性。在这里,我们选择了 T 中除去 K 对应键的其他所有属性。


    我们来看看Exclude的实现


    Exclude


    type MyExclude = T extends U ? never : T
    type T0 = MyExclude<"a" | "b" | "c", "a">;
    // type T0 = "b" | "c"

    如果T中存在U就剔除(never)否则保留


    Extract


    很明显就是Exclude的反向操作


    type MyExtract = T extends U ? T : never
    type T0 = MyExtract<"a" | "b" | "c", "a">;
    // type T0 = "a"

    NonNullable


    type T0 = NonNullable<string | number | undefined>;
    type T1 = NonNullable<string[] | null | undefined>;
    type NonNullable = T & {};

    T0 的类型是 string | number。这是因为 NonNullable 类型别名被定义为 T & {},这意味着它接受一个类型 T,并返回一个新的类型,该新类型是 T 和空对象类型的交叉类型。由于 TypeScript 中的交叉类型(T & {})会过滤掉 nullundefined,因此 T0 的类型就是排除了 undefinedstring | number


    也可以这样实现


    type MyNonNullable = T extends null | undefined ? never : T;

    ConstructorParameters


    type MyConstructorParametersextends abstract new (...args: any) => any> =
    T extends abstract new (...args: infer P) => any ? P : never;
    class C {
    constructor(a: number, b: string) {}
    }
    type T3 = MyConstructorParameters<typeof C>;
    // type T3 = [a: number, b: string]

    还是老套路infer推断,这个和我们上面那个获取函数参数类型差不多的,换了个形式。



    • T extends abstract new (...args: any) => any: 这是对输入类型 T 进行约束,要求它是一个抽象类的构造函数类型。

    • T extends abstract new (...args: infer P) => any ? P : never: 这是一个条件类型,检查 T 是否符合指定的构造函数模式。如果符合,就返回构造函数的参数类型 P,否则返回 never


    InstanceType


    class C {
    x = 0;
    y = 0;
    }
    type MyInstanceTypeextends abstract new (...args: any) => any> =
    T extends abstract new (...args: any) => infer R ? R : never;
    type T0 = MyInstanceType<typeof C>;
    // type T0 = C

    和我们上面那个实现的获取函数类型返回值类型如出一辙和获取类的构造器类型是配对的。



    • T extends abstract new (...args: any) => any: 这是对输入类型 T 进行约束,要求它是一个抽象类的构造函数类型。

    • T extends abstract new (...args: any) => infer R ? R : any: 这是一个条件类型,检查 T 是否符合指定的构造函数模式。如果符合,就返回构造函数的实例类型 R,否则返回 never


    ThisParameterType


    function toHex(this: Number) {
    return this.toString(16);
    }
    function numberToString(n: ThisParameterType<typeof toHex>) {
    return toHex.apply(n);
    }

    像这种提取类型的都用infer,先猜一猜,内部肯定是判断T是不是一个函数, 第一个参数this infer R是就返回R不是就返回never。
    我们看看答案


    type ThisParameterType =
    T extends (this: infer U, ...args: never) => any
    ? U
    : unknown;

    和我们猜想的差不多,我想你现在应该可以类型编程了吧。


    TS内部还有四个内置类型是通过JS来实现的,我们就不研究了


    `Uppercase`
    `Lowercase`
    `Capitalize`
    `Uncapitalize`

    可以看看我的这篇文章vue里面对于TS的使用 # 突发奇想(吃饱了撑的) vue3能实现react中的hooks吗?


    祝大家TS的编程技术越来越好吧,本人非常菜,大佬轻喷。


    作者:辛克莱
    来源:juejin.cn/post/7332435905926070322

    JS 不写分号踩了坑,但也可以不踩坑

    web
    前言 “所有直觉性的 “当然应该加分号” 都是保守的、未经深入思考的草率结论。” —— 尤雨溪。 重新认识分号在代码中的作用,为什么要写分号?又为什么可以没有分号? 踩的坑 写一个方法将秒数转为“xx天xx时xx分xx秒”的形式 const ONEDAYSEC...
    继续阅读 »

    前言


    “所有直觉性的 “当然应该加分号” 都是保守的、未经深入思考的草率结论。” —— 尤雨溪。

    重新认识分号在代码中的作用,为什么要写分号?又为什么可以没有分号?


    踩的坑


    写一个方法将秒数转为“xx天xx时xx分xx秒”的形式


    const ONEDAYSECOND = 24 * 60 * 60
    const ONEHOURSECOND = 60 * 60
    const ONEMINUTESECOND = 60

    function getQuotientandRemainder(dividend,divisor){
    const remainder = dividend % divisor
    const quotient = (dividend - remainder) / divisor
    return [quotient,remainder]
    }

    function formatSeconds(time){
    let restTime,day,hour,minute
    restTime = time
    [day,restTime] = getQuotientandRemainder(restTime,ONEDAYSECOND)
    [hour,restTime] = getQuotientandRemainder(restTime,ONEHOURSECOND)
    [minute,restTime] = getQuotientandRemainder(restTime,ONEMINUTESECOND)
    return day + '天' + hour + '时' + minute + '分' + restTime + '秒'
    }
    console.log(formatSeconds(time)) // undefined天undefined时undefined分NaN,NaN秒

    按照这段代码执行完后,day、hour、minute这些变量得到的都是 undefined,而 restTime 则好像得到一个数组。

    问题就在于 13、14、15、16 行之间没有添加分号,导致解析时,没有将这三行解析成三条语句,而是解析成一条语句。最终的表达式就是这样的:


    restTime = time[day,restTime] = getQuotientandRemainder(restTime,ONEDAYSECOND)[hour,restTime] = getQuotientandRemainder(restTime,ONEHOURSECOND)[minute,restTime] = getQuotientandRemainder(restTime,ONEMINUTESECOND)

    那执行的过程相当于给 restTime 进行赋值,表达式从左往右执行,最终表达式的值为右值。最右边的值就是 getQuotientandRemainder(restTime,ONEMINUTESECOND),由于在计算过程中 restTime 还没有被赋值,一直是 undefined,所以经过 getQuotientandRemainder 计算后得到的数组对象每个成员都是 NaN,最终赋值给 restTime 就是这样一个数组。


    分号什么时候会“自动”出现


    有时候好像不写分号也不会出问题,比如这种情况:


    let a,b,c
    a = 1
    b = 2
    c = 3
    console.log(a,b,c) // 1 2 3

    这是因为,JS 进行代码解析的时候,能够识别出语句的结束位置并“自动添加分号“,从而能够解析出“正确”的抽象语法树,最终执行的结果也就是我们所期待的。

    JS 有一个语法特性叫做 ASI (Automatic Semicolon Insertion),就是上面说到的”自动添加分号”的东西,它有一定的插入规则,在满足时会为代码自动添加分号进行断句,在我们不写分号的时候,需要了解这个规则,才能不踩坑。(当然这里说的加分号并不是真正的加分号,只是一种解析规则,用分号来代表语句间的界限)


    ASI 规则


    JS 只有在出现换行符的时候才会考虑是否添加分号,并且会尽量“少”添加分号,也就是尽量将多行语句合成一行,仅在必要时添加分号。


    1. 行与行之间合并不符合语法时,插入分号


    比如上面那个自动添加分号的例子,就是合并多行时会出现语法错误。

    a = 1b = 2 这里 1b 是不合法的,因此会加入分号使其合法,变为 a = 1; b = 2


    2. 在规定[no LineTerminator here]处,插入分号


    这种情况很有针对性,针对一些特定的关键字,如 return continue break throw async yield,规定在这些关键字后不能有换行符,如果在这些关键字后有了换行符,JS 会自动在这些关键字后加上分号。
    看下面这个例子🌰:


    function a(){
    return
    123
    }
    console.log(a()) // undefined

    function b(){
    return 123
    }
    console.log(b()) // 123

    在函数a中,return 后直接换行了,那么 return 和 123 就会被分成两条语句,所以其实 123 根本不会被执行到,而 return 也是啥也没返回。


    3. ++、--这类运算符,若在一行开头,则在行首插入分号


    ++ 和 -- 既可以在变量前,也可以在变量后,如果它们在行首,当多行进行合并时,会产生歧义,到底是上一行变量的运算,还是下一行变量的运算,因此需要加入分号,处理为下一行变量的运算。


    a
    ++
    b
    // 添加分号后
    a
    ++b

    如果你的预期是:


    a++ 
    b

    那么就会踩坑了。


    4. 在文件末尾发现语法无法构成合法语句时,会插入分号


    这条和 1 有些类似


    不写分号时需要注意⚠️


    上面的 ASI 规则中,JS 都是为了正确运行代码,必须按照这些规则来分析代码。而它不会做多余的事,并且在遵循“尽量合并多行语句”的原则下,它会将没有语法问题的多行语句都合并起来。这可能违背了你的逻辑,你想让每行独立执行,而不是合成一句。开头贴出的例子,就是这样踩坑的,我并不想一次次连续的对数组进行取值🌚。

    因此我们要写出明确的语句,可以被合并的语句,明确是多条语句时需要加上分号。


    (如果你的项目中使用了某些规范,它不想让你用分号,别担心,它只是不想让你在行尾用分号,格式化时它会帮你把分号移到行首)像这样:


    // before lint
    restTime = time;
    [day, restTime] = getQuotientandRemainder(restTime, ONEDAYSECOND);
    [hour, restTime] = getQuotientandRemainder(restTime, ONEHOURSECOND);
    [minute, restTime] = getQuotientandRemainder(restTime, ONEMINUTESECOND);

    // after lint
    restTime = time
    ;[day, restTime] = getQuotientandRemainder(restTime, ONEDAYSECOND)
    ;[hour, restTime] = getQuotientandRemainder(restTime, ONEHOURSECOND)
    ;[minute, restTime] = getQuotientandRemainder(restTime, ONEMINUTESECOND)

    参考



    作者:用户9787521254131
    来源:juejin.cn/post/7269645636210458635
    收起阅读 »

    基于 localStorage 实现有过期时间的存储方式

    web
    我们知道 localStorage 中的数据是长期保存的,除非手动删除,否则他会一直存在。如果我们想实现一种数据有过期时间的存储方式,该怎么实现呢? 首先应该想到的是 cookie,cookie 本身就有有效期的配置,当某个 cookie 时,浏览器自动清理该...
    继续阅读 »

    我们知道 localStorage 中的数据是长期保存的,除非手动删除,否则他会一直存在。如果我们想实现一种数据有过期时间的存储方式,该怎么实现呢?


    首先应该想到的是 cookie,cookie 本身就有有效期的配置,当某个 cookie 时,浏览器自动清理该 cookie。可是使用 cookie 存储数据,有个不好的地方,很多我们存储的数据,本就是我们前端自己用到的,后端根本用不到。可是存储到 cookie 中后,页面中所有的 cookie 都会随着请求发送给后端,造成传输的 cookie 比较长,而且没有必要。


    低调低调


    因此,我们可以基于 localStorage 来实现一套这样的有过期时间的存储方式。我们在之前的文章 如何重写 localStorage 中的方法 中,也了解了一些重写 localStorage 的方法。这里我们是自己在外层封装一层的方式,来调用 localStorage。


    我这里封装的类名叫: LocalExpiredStorage,即有过期时间的 localStorage。


    1. 实现与 localStorage 基本一致的 api


    我们为了实现跟 localStorage 使用上的一致性体验,这里我们自己的 api 名称和实现方式跟 localStorage 基本一致。


    interface SetItemOptions {
    maxAge?: number; // 从当前时间往后多长时间过期
    expired?: number; // 过期的准确时间点,优先级比maxAge高
    }

    class LocalExpiredStorage {
    private prefix = "local-"; // 用于跟没有过期时间的key进行区分

    constructor(prefix?: string) {
    if (prefix) {
    this.prefix = prefix;
    }
    }

    setItem(key: string, value: any, options?: SetItemOptions) {}
    getItem(key: string): any {}
    removeItem(key: string) {}
    clearAllExpired() {}
    }
    const localExpiredStorage = new LocalExpiredStorage();
    export default localExpiredStorage;

    可以看到我们实现的类里,有三个变化:



    1. setItem()方法新增了一个 options 参数,这里主要是为了配置过期时间,这里有两种配置方式,一种是可以设置多长时间后过期,比如 2 个小时后过期(开发者不用特殊计算 2 个小时后的时间节点);再一种是设置过期的时间节点,该值可以是格式化的时间,也可以是时间戳;

    2. 有一个 prefix 属性,在具体实现中,我们会将 prefix 属性与操作的 key 进行拼接,标识该 key 是具有过期时间特性的,方便我们自己的类进行处理;

    3. 新增了一个 clearAllExpired() 方法,这是为了清理所有已经过期的 key,避免占用缓存;该方法在应用的入口处就应当调用,便于及时清理;


    上面是我们的大致框架,接下来我们来具体实现下这些方法。


    干饭


    2. 具体实现


    接下来我们来一一实现这些方法。


    2.1 setItem


    这里我们新增了一个 options 参数,用来配置过期时间:



    • expired: 固定的过期时间点,比如点击关闭按钮,当天不再展示,那过期时间就是今天晚上的 23:59:59,可以使用该属性;

    • maxAge: 从当前时间起,设置多长时间后过期;比如点击某个提示,3 天内不再展示,使用该属性就比较方便;


    假如两个属性都设置了,我这里约定 expired 属性的优先级更高一些。


    class LocalExpiredStorage {
    private prefix = "local-"; // 用于跟没有过期时间的key进行区分

    constructor(prefix?: string) {
    if (prefix) {
    this.prefix = prefix;
    }
    }

    setItem(key: string, value: any, options?: SetItemOptions) {
    const now = Date.now();
    let expired = now + 1000 * 60 * 60 * 3; // 默认过期时间为3个小时

    // 这里我们限定了 expired 和 maxAge 都是 number 类型,
    // 您也可以扩展支持到 string 类型或者如 { d:2, h:3 } 这种格式
    if (options?.expired) {
    expired = options?.expired;
    } else if (options?.maxAge) {
    expired = now + options.maxAge;
    }

    // 我们这里用了 dayjs 对时间戳进行格式化,方便快速识别
    // 若没这个需要,也可以直接存储时间戳,减少第三方类库的依赖
    localStorage.setItem(
    `${this.prefix}${key}`,
    JSON.stringify({
    value,
    start: dayjs().format("YYYY/MM/DD hh:mm:ss"), // 存储的起始时间
    expired: dayjs(expired).format("YYYY/MM/DD hh:mm:ss"), // 存储的过期时间
    })
    );
    }
    }

    我们在过期时间的实现过程中,目前只支持了 number 类型,即需要传入一个时间戳,参与运算。您也可以扩展到 string 类型(比如'2024/11/23 14:45:34')或者其他格式{ d:2, h:3 } 这种格式。


    设置好过期时间后,我们将 value,存储的起始时间和过期时间,转义成 json string 存储起来。我们这里用了 dayjs 对时间戳进行格式化,方便开发者可以快速地识别。若没有这个需要,也可以直接存储时间戳,减少第三方类库的依赖。


    该方法并没有支持永久存储的设定,若您需要永久存储,可以直接使用 localStorage 来存储。


    2.2 getItem


    获取某 key 存储的值,主要是对过期时间的判断。


    class LocalExpiredStorage {
    private prefix = "local-"; // 用于跟没有过期时间的key进行区分

    constructor(prefix?: string) {
    if (prefix) {
    this.prefix = prefix;
    }
    }

    getItem(key: string): any {
    const result = localStorage.getItem(`${this.prefix}${key}`);
    if (!result) {
    // 若key本就不存在,直接返回null
    return result;
    }
    const { value, expired } = JSON.parse(result);
    if (Date.now() <= dayjs(expired).valueOf()) {
    // 还没过期,返回存储的值
    return value;
    }
    // 已过期,删除该key,然后返回null
    this.removeItem(key);
    return null;
    }
    removeItem(key: string) {
    localStorage.removeItem(`${this.prefix}${key}`);
    }
    }

    在获取 key 时,主要经过 3 个过程:



    1. 若本身就没存储这个 key,直接返回 null;

    2. 已存储了该 key 的数据,解析出数据和过期时间,若还在有效期,则返回存储大数据;

    3. 若已过期,则删除该 key,然后返回 null;


    这里我们在删除数据时,使用了this.removeItem(),即自己实现的删除方法。本来我们也是要实现这个方法的,那就直接使用了吧。


    2.3 clearAllExpired


    localStorage 中的数据并不会自动清理,我们需要一个方法用来手动批量清理已过期的数据。


    class LocalExpiredStorage {
    private prefix = "local-"; // 用于跟没有过期时间的key进行区分

    clearAllExpired() {
    let num = 0;

    // 判断 key 是否过期,然后删除
    const delExpiredKey = (key: string, value: string | null) => {
    if (value) {
    // 若value有值,则判断是否过期
    const { expired } = JSON.parse(value);
    if (Date.now() > dayjs(expired).valueOf()) {
    // 已过期
    localStorage.removeItem(key);
    return 1;
    }
    } else {
    // 若 value 无值,则直接删除
    localStorage.removeItem(key);
    return 1;
    }
    return 0;
    };

    const { length } = window.localStorage;
    const now = Date.now();

    for (let i = 0; i < length; i++) {
    const key = window.localStorage.key(i);

    if (key?.startsWith(this.prefix)) {
    // 只处理我们自己的类创建的key
    const value = window.localStorage.getItem(key);
    num += delExpiredKey(key, value);
    }
    }
    return num;
    }
    }

    在项目的入口处添加上该方法,用户每次进入项目时,都会自动清理一次已过期的 key。


    醒一醒


    3. 完整的代码


    上面我们是分步讲解的,这里我们放下完整的代码。同时,我也在 GitHub 上放了一份:wenzi0github/local-expired-storage


    interface SetItemOptions {
    maxAge?: number; // 从当前时间往后多长时间过期
    expired?: number; // 过期的准确时间点,优先级比maxAge高
    }

    class LocalExpiredStorage {
    private prefix = "local-"; // 用于跟没有过期时间的key进行区分

    constructor(prefix?: string) {
    if (prefix) {
    this.prefix = prefix;
    }
    }

    // 设置数据
    setItem(key: string, value: any, options?: SetItemOptions) {
    const now = Date.now();
    let expired = now + 1000 * 60 * 60 * 3; // 默认过期时间为3个小时

    // 这里我们限定了 expired 和 maxAge 都是 number 类型,
    // 您也可以扩展支持到 string 类型或者如 { d:2, h:3 } 这种格式
    if (options?.expired) {
    expired = options?.expired;
    } else if (options?.maxAge) {
    expired = now + options.maxAge;
    }

    // 我们这里用了 dayjs 对时间戳进行格式化,方便快速识别
    // 若没这个需要,也可以直接存储时间戳,减少第三方类库的依赖
    localStorage.setItem(
    `${this.prefix}${key}`,
    JSON.stringify({
    value,
    start: dayjs().format("YYYY/MM/DD hh:mm:ss"), // 存储的起始时间
    expired: dayjs(expired).format("YYYY/MM/DD hh:mm:ss"), // 存储的过期时间
    })
    );
    }

    getItem(key: string): any {
    const result = localStorage.getItem(`${this.prefix}${key}`);
    if (!result) {
    // 若key本就不存在,直接返回null
    return result;
    }
    const { value, expired } = JSON.parse(result);
    if (Date.now() <= dayjs(expired).valueOf()) {
    // 还没过期,返回存储的值
    return value;
    }
    // 已过期,删除该key,然后返回null
    this.removeItem(key);
    return null;
    }

    // 删除key
    removeItem(key: string) {
    localStorage.removeItem(`${this.prefix}${key}`);
    }

    // 清除所有过期的key
    clearAllExpired() {
    let num = 0;

    // 判断 key 是否过期,然后删除
    const delExpiredKey = (key: string, value: string | null) => {
    if (value) {
    // 若value有值,则判断是否过期
    const { expired } = JSON.parse(value);
    if (Date.now() > dayjs(expired).valueOf()) {
    // 已过期
    localStorage.removeItem(key);
    return 1;
    }
    } else {
    // 若 value 无值,则直接删除
    localStorage.removeItem(key);
    return 1;
    }
    return 0;
    };

    const { length } = window.localStorage;
    const now = Date.now();

    for (let i = 0; i < length; i++) {
    const key = window.localStorage.key(i);

    if (key?.startsWith(this.prefix)) {
    // 只处理我们自己的类创建的key
    const value = window.localStorage.getItem(key);
    num += delExpiredKey(key, value);
    }
    }
    return num;
    }
    }
    const localExpiredStorage = new LocalExpiredStorage();
    export default localExpiredStorage;

    使用:


    localExpiredStorage.setItem("key", "value", { maxAge: 5000 }); // 有效期为5000毫秒
    localExpiredStorage.setItem("key", "value", {
    expired: Date.now() + 1000 * 60 * 60 * 12,
    }); // 有效期为 12 个小时,自己计算到期的时间戳

    // 获取数据
    localExpiredStorage.getItem("key");

    // 删除数据
    localExpiredStorage.removeItem("key");

    // 清理所有过期的key
    localExpiredStorage.clearAllExpired();

    4. 总结


    这个功能本身不难,也有很多开发者自己实现过。这里我也是总结下之前实现的过程。



    作者:小蚊酱
    来源:juejin.cn/post/7215775714417655867
    收起阅读 »

    代码字体 ugly?试试这款高颜值代码字体

    Monaspace 是有 GitHub 开源的代码字体,包含 5 种变形字体的等宽代码字体家族,颜值 Up,很难不喜欢。 来看一下这 5 种字体分别是: 1️⃣ Radon 手写风格字体 2️⃣ Krypton 机械风格字体 3️⃣ Xenon 衬线风格字...
    继续阅读 »

    Monaspace 是有 GitHub 开源的代码字体,包含 5 种变形字体的等宽代码字体家族,颜值 Up,很难不喜欢。


    来看一下这 5 种字体分别是:


    1️⃣ Radon 手写风格字体



    2️⃣ Krypton 机械风格字体

    3️⃣ Xenon 衬线风格字体



    4️⃣ Argon 人文风格字体



    5️⃣ Neon 现代风格字体



    👉 项目地址:github.com/githubnext/…


    下载方式


    MacOS


    使用 brew 安装:


    brew tap homebrew/cask-fonts
    brew install font-monaspace

    Windows


    下载该文件:github.com/githubnext/…


    拖到 C:\Windows\Fonts 中,点击安装


    下载好后,如果是 VSCode 文件,可以在设置中找到 font-family,改为:'Monaspace Radon', monospace





    作者:吴楷鹏
    来源:juejin.cn/post/7332435905925562418
    收起阅读 »

    2024年,为啥我不建议应届生再去互联网?

    最近快过年了,和还留在成都的一些研究生同学吃了顿饭,其中博士姐姐因为今年刚刚毕业,所以在饭局里面还跟我们谈了一下今年的师弟师妹们的去向。 她说今年虽然就业挺难的,但是师弟师妹们的工作还都挺好的,有去成飞的,有去选调的还有去了一些大国企研究所的。然后我就问今年没...
    继续阅读 »

    最近快过年了,和还留在成都的一些研究生同学吃了顿饭,其中博士姐姐因为今年刚刚毕业,所以在饭局里面还跟我们谈了一下今年的师弟师妹们的去向。


    她说今年虽然就业挺难的,但是师弟师妹们的工作还都挺好的,有去成飞的,有去选调的还有去了一些大国企研究所的。然后我就问今年没有去互联网的吗?她说有哇,有学弟去了美团,钱还开得挺多的,有学弟去了个独角兽做算法,但是也就这两个人去了互联网相关的了。


    其实听到这个我还是蛮感慨的,在我毕业的时候互联网还是如日中天,大多数计算机毕业的孩子首选的就是去互联网狠狠的大赚一笔。短短3年间,去互联网的应届生就屈指可数了,一方面是这两年互联网大厂缩招严重,进互联网没有我们当年那么容易。另一方面是,在大环境不容乐观的今天,以及互联网增长见顶的背景下,互联网的工作其实已经不是应届生的首选工作了。


    实际上,即使在今年你能过千军万马杀出重围拿到互联网的offer,作为一个过来人我也不是很建议你再去趟互联网这趟浑水。因为,作为一个新人在一个注定下行的行业当中,你可能搭上的不是通往财富自由的快车道,很快你需要考虑的可能就是你还能不能保住你手头的这份工作的问题。


    说一个老生常谈的事情,互联网的增长确实见底了,阿里、腾讯、网易的股票最近狂跌,阿里都跌回2014年了,只有抖音还依然坚挺一些但是依然看不到未来成长的空间。从2014年到2024年,正好十年的时间,互联网员工们加班加点996,熬夜爆肝的奋斗,最终的结果尽然是回到了原点。


    其实,这个事情也并不奇怪,这些互联网大厂只是坐在电梯里面的人,他们都觉得自己能够取得成功是因为自己在电梯里面做俯卧撑。实际上,跟你在电梯上做啥没有关系,你之所以能够成功只是因为你恰好赶上了这班电梯而已,跟你在里面睡觉还是瞎折腾关系都不大。如今风停了,电梯开始往下走了,作为个体你非要去搭上这个末班车并且期待在踩在早就已经上电梯的这群人的头上的话,那么我只能跟你说,祝你好运了。


    其实,作为一名应届生的时候我对职场也没有清醒的认识,以为职场上的同事和学校的同学一样大家和和气气不争不抢的。但是,正是抱着这样的心态我入职了互联网之后的短短一年时间内,才深刻感受到了社会的毒打和职场真实的样貌。所以,我不知道在学校的应届生们有没有做好准备在互联网面对全方位的竞争,这种竞争不仅仅是技术,不仅仅是加班,更是向上管理和领导处理好关系。和国企、外企、体制内不一样,互联网的大多数公司是有强制末尾淘汰的,有些公司甚至连新人保护期都没有,那么你觉得你作为一个活蹦乱跳的应届生,这个名额是老油条扛呢还是你呢?


    另外,以前的人扎堆朝互联网冲是因为真的有财富自由的机会的,那时候啥app都没有,张小龙找几个应届生关小黑屋都能写出未来的国民级app微信。16年的字节也还是个小公司,那时候往互联网里面冲的话搞不好真的可以一年能够赚到别人一辈子赚不到的钱,所以去互联网真是一点儿问题都没有。你那时候不去互联网我都会拿着鞭子抽你,劝你上进一点儿!但是都2024年了,市场永远比个人知道一个方向的未来,还是那句话你想创业互联网都拉不到风投的年代,你还能奢望能够实现财富自由吗?


    The End

    其实作为一名程序员还是挺享受写有趣代码的过程的,也希望做一点儿东西能够被大家认可,所以我劝退互联网但是并不是劝退计算机。


    即使是Chatgpt大行其道的今天,我也不认为未来某一天机器能够真正意义上取代程序员,要取代也是从另外一个维度上取代,比如说根据需求直接生成机器码而不是生成代码的这种形式。虽然互联网是一片红海,但是像新的技术VR、物联网、工业软件、芯片和智能机器人等行业,在我们国家还是蕴含着无限机会的。但是,我并不认为去到我上面所说的这些行业工资收入上能够超过现在的互联网大厂给出的工资,我的意思真的有想法的人可以尝试在这些领域去找到自己的一席之地,尤其是在校学生。


    你去卷一个注定下山的行业无论它钱给多少都是毫无意义的,因为入职就可能就是你职业生涯的巅峰。相比起来,我觉得华子未来比这些靠着广告赚钱的公司都更有前景,因为是真的有一些核心技术在的。


    所以,选择一个还没有走过巅峰的行业,提前布局才是更有未来的职业选择。


    作者:浣熊say
    来源:juejin.cn/post/7327447632111419443
    收起阅读 »

    url请求参数带有特殊字符“%、#、&”时,参数被截断怎么办?

    web
    是的,最近又踩坑了! 事情是这样的,我们测试小姐姐在一个全局搜索框里输入了一串特殊字符“%%%”,然后进行搜索,结果报错了。而输入常规字符,均可以正常搜索。 一排查,发现特殊字符“%%%”并未成功传给后端。 我们的这个全局搜索功能是需要跳转页面才能查看到搜索结...
    继续阅读 »

    是的,最近又踩坑了!


    事情是这样的,我们测试小姐姐在一个全局搜索框里输入了一串特殊字符“%%%”,然后进行搜索,结果报错了。而输入常规字符,均可以正常搜索。


    一排查,发现特殊字符“%%%”并未成功传给后端。


    我们的这个全局搜索功能是需要跳转页面才能查看到搜索结果的。所以,搜索条件是作为参数拼接在页面url上的。


    正常的传参:


    image.png


    当输入的是特殊字符“%、#、&”时,参数丢失


    image.png


    也就是说,当路由请求参数带有浏览器url中的特殊含义字符时,参数会被截断,无法正常获取参数。


    那么怎么解决这个问题呢?


    方案一:encodeURIComponent/decodeURIComponent


    拼接参数时,利用encodeURIComponent()进行编码,接收参数时,利用decodeURIComponent()进行解码。


    // 编码
    this.$router.push({path: `/crm/global-search/search-result?type=${selectValue}&text=${encodeURIComponent(searchValue)}`});

    // 解码
    const text = decodeURIComponent(this.$route.query.text)

    此方法对绝大多数特殊字符都适用,但是唯独输入“%”进行搜索时不行,报错如下。


    image.png


    所以在编码之前,还需进行一下如下转换:



    this.$router.push({path: `/crm/global-search/search-result?type=${selectValue}&text=${encodeURIComponent(encodeSpecialChar(searchValue))}`});


    /**
    * @param {*} char 字符串
    * @returns
    */

    export const encodeSpecialChar = (char) => {
    // #、&可以不用参与处理
    const encodeArr = [{
    code: '%',
    encode: '%25'
    },{
    code: '#',
    encode: '%23'
    }, {
    code: '&',
    encode: '%26'
    },]
    return char.replace(/[%?#&=]/g, ($) => {
    for (const k of encodeArr) {
    if (k.code === $) {
    return k.encode
    }
    }
    })
    }


    方案二: qs.stringify()


    默认情况下,qs.stringify()方法会使用encodeURIComponent方法对特殊字符进行编码,以保证URL的合法性。


    const qs = require('qs');

    const searchObj = {
    type: selectValue,
    text: searchValue
    };
    this.$router.push({path: `/crm/global-search/search-result?${qs.stringify(searchObj)}`});


    使用了qs.stringify()方法,就无需使用encodeSpecialChar方法进行转换了。


    作者:HED
    来源:juejin.cn/post/7332048519156776979
    收起阅读 »

    系统干崩了,只认代码不认人

    各位朋友听我一句劝,写代码提供方法给别人调用时,不管是内部系统调用,还是外部系统调用,还是被动触发调用(比如MQ消费、回调执行等),一定要加上必要的条件校验。千万别信某些同事说的这个条件肯定会传、肯定有值、肯定不为空等等。这不,临过年了我就被坑了一波,弄了个生...
    继续阅读 »

    各位朋友听我一句劝,写代码提供方法给别人调用时,不管是内部系统调用,还是外部系统调用,还是被动触发调用(比如MQ消费、回调执行等),一定要加上必要的条件校验。千万别信某些同事说的这个条件肯定会传、肯定有值、肯定不为空等等。这不,临过年了我就被坑了一波,弄了个生产事故,年终奖基本是凉了半截。


    为了保障系统的高可用和稳定,我发誓以后只认代码不认人。文末总结了几个小教训,希望对你有帮助。


    一、事发经过


    我的业务场景是:业务A有改动时,发送MQ,然后应用自身接受到MQ后,再组合一些数据写入到Elasticsearch。以下是事发经过:



    1. 收到一个业务A的异常告警,当时的告警如下:



    2. 咋一看觉得有点奇怪,怎么会是Redis异常呢?然后自己连了下Redis没有问题,又看了下Redis集群,一切正常。所以就放过了,以为是偶然出现的网络问题。

    3. 然后技术问题群里 客服 反馈有部分用户使用异常,我警觉性的感觉到是系统出问题了。赶紧打开了系统,确实有偶发性的问题。

    4. 于是我习惯性的看了几个核心部件:



      1. 网关情况、核心业务Pod的负载情况、用户中心Pod的负载情况。

      2. Mysql的情况:内存、CPU、慢SQL、死锁、连接数等。



    5. 果然发现了慢SQL和元数据锁时间过长的情况。找到了一张大表的全表查询,数据太大,执行太慢,从而导致元数据锁持续时间太长,最终数据库连接数快被耗尽。


    SELECT xxx,xxx,xxx,xxx FROM 一张大表


    1. 立马Kill掉几个慢会话之后,发现系统仍然没有完全恢复,为啥呢?现在数据库已经正常了,怎么还没完全恢复呢?又继续看了应用监控,发现用户中心的10个Pod里有2个Pod异常了,CPU和内存都爆了。难怪使用时出现偶发性的异常呢。于是赶紧重启Pod,先把应用恢复。

    2. 问题找到了,接下来就继续排查为什么用户中心的Pod挂掉了。从以下几个怀疑点开始分析:



      1. 同步数据到Elasticsearch的代码是不是有问题,怎么会出现连不上Redis的情况呢?

      2. 会不会是异常过多,导致发送异常告警消息的线程池队列满了,然后就OOM?

      3. 哪里会对那张业务A的大表做不带条件的全表查询呢?



    3. 继续排查怀疑点a,刚开始以为:是拿不到Redis链接,导致异常进到了线程池队列,然后队列撑爆,导致OOM了。按照这个设想,修改了代码,升级,继续观察,依旧出现同样的慢SQL 和 用户中心被干爆的情况。因为没有异常了,所以怀疑点b也可以被排除了。

    4. 此时基本可以肯定是怀疑点c了,是哪里调用了业务A的大表的全表查询,然后导致用户中心的内存过大,JVM来不及回收,然后直接干爆了CPU。同时也是因为全表数据太大,导致查询时的元数据锁时间过长造成了连接不能够及时释放,最终几乎被耗尽。

    5. 于是修改了查询业务A的大表必要校验条件,重新部署上线观察。最终定位出了问题。


    二、问题的原因


    因为在变更业务B表时,需要发送MQ消息( 同步业务A表的数据到ES),接受到MQ消息后,查询业务A表相关连的数据,然后同步数据到Elasticsearch。


    但是变更业务B表时,没有传业务A表需要的必要条件,同时我也没有校验必要条件,从而导致了对业务A的大表的全表扫描。因为:


    某些同事说,“这个条件肯定会传、肯定有值、肯定不为空...”,结果我真信了他!!!

    由于业务B表当时变更频繁,发出和消费的MQ消息较多,触发了更多的业务A的大表全表扫描,进而导致了更多的Mysql元数据锁时间过长,最终连接数消耗过多。


    同时每次都是把业务A的大表查询的结果返回到用户中心的内存中,从而触发了JVM垃圾回收,但是又回收不了,最终内存和CPU都被干爆了。


    至于Redis拿不到连接的异常也只是个烟雾弹,因为发送和消费的MQ事件太多,瞬时间有少部分线程确实拿不到Redis连接。


    最终我在消费MQ事件处的代码里增加了条件校验,同时也在查询业务A表处也增加了的必要条件校验,重新部署上线,问题解决。


    三、总结教训


    经过此事,我也总结了一些教训,与君共勉:



    1. 时刻警惕线上问题,一旦出现问题,千万不能放过,赶紧排查。不要再去怀疑网络抖动问题,大部分的问题,都跟网络无关。

    2. 业务大表自身要做好保护意识,查询处一定要增加必须条件校验。

    3. 消费MQ消息时,一定要做必要条件校验,不要相信任何信息来源。

    4. 千万别信某些同事说,“这个条件肯定会传、肯定有值、肯定不为空”等等。为了保障系统的高可用和稳定,咱们只认代码不认人

    5. 一般出现问题时的排查顺序:



      1. 数据库的CPU、死锁、慢SQL。

      2. 应用的网关和核心部件的CPU、内存、日志。



    6. 业务的可观测性和告警必不可少,而且必须要全面,这样才能更快的发现问题和解决问题。




    作者:不焦躁的程序员
    来源:juejin.cn/post/7331628641360248868
    收起阅读 »

    别再只用axios了,试试这个更轻量的网络请求库!

    web
    嗨,我们又见面了!今天我要和大家分享一个前端工程师都会遇到的问题:如何优化移动端网络请求。相信很多人都用过 axios 或 fetch API,但你是否觉得处理分页逻辑、缓存、请求防抖等很麻烦呢?别担心,我找到了一个解决方案——Alova.js。 Alova...
    继续阅读 »

    嗨,我们又见面了!今天我要和大家分享一个前端工程师都会遇到的问题:如何优化移动端网络请求。相信很多人都用过 axios 或 fetch API,但你是否觉得处理分页逻辑、缓存、请求防抖等很麻烦呢?别担心,我找到了一个解决方案——Alova.js。



    Alova.js 是一个轻量级的请求策略库,它可以帮助我们简化网络请求的编写,让我们更专注于业务逻辑。它提供了多种服务端数据缓存模式,比如内存模式和持久化模式,这些都能提升用户体验,同时降低服务端的压力。而且,Alova.js 只有 4kb+,体积是 axios 的 30%,非常适合移动端使用。


    Alova.js 的基础请求功能非常简单,比如你可以这样请求数据:


    const todoDetail = alova.Get('/todo', { params: { id: 1 } });
    const { loading, data, error } = useRequest(todoDetail);

    它还提供了分页请求、表单提交、验证码发送、文件上传等多种请求策略,大大减少了我们的工作量。比如,使用分页请求策略,你只需要这样:


    const {
    loading,
    data,
    isLastPage,
    page,
    pageSize,
    pageCount,
    total,
    } = usePagination((page, pageSize) => queryStudents(page, pageSize));

    怎么样,是不是很简单?Alova.js 还支持 Vue、React、React Native、Svelte 等多种前端框架,以及 Next、Nuxt、SvelteKit 等服务端渲染框架,非常适合现代前端开发。


    感兴趣的话,可以去 Alova.js 的官网看看:Alova.js 官网。也可以在评论区分享你对 Alova.js 的看法哦!嘿嘿,今天就聊到这里,下次见!👋
    有任何问题,你可以加入以下群聊咨询,也可以在github 仓库中发布 Discussions,如果遇到问题,也请在github 的 issues中提交,我们会在最快的时间解决。


    作者:古韵
    来源:juejin.cn/post/7332388389944819748
    收起阅读 »

    打工人回家过年:只想休息,讨厌拜年、走亲戚、被催婚

    本文来自公众号 成功同学 大家好,我是杨成功。 昨天楼下吃饭,听到一个女孩在打电话,声音很大,听起来很生气。 原因是父母让她过年回去的时候给亲戚带礼物,女孩不愿意,和父母吵起来了。 女孩说:“今年本来就没攒下钱,回家来回的车票就花了一大笔,给你们带礼物也花了...
    继续阅读 »

    本文来自公众号 成功同学



    大家好,我是杨成功。


    昨天楼下吃饭,听到一个女孩在打电话,声音很大,听起来很生气。


    原因是父母让她过年回去的时候给亲戚带礼物,女孩不愿意,和父母吵起来了。


    女孩说:“今年本来就没攒下钱,回家来回的车票就花了一大笔,给你们带礼物也花了不少,为啥非得给亲戚带礼物?你们别光考虑你们的面子,能不能考虑一下我,年后还要交房租...”


    听到这里,我心里一痛。


    作为一个资深北漂,我被戳中了。


    很多人以为呆在北上广的人光鲜亮丽,实际上也只是两点一线的打工人;看起来钱赚的不少,实际上开销大到离谱,一年到头剩不下多少。


    今年互联网裁员潮,一片一片地裁,搞的大家人心惶惶。好几个朋友上午还在开心地写代码,下午就被请到会议室喝茶。


    有些拿不到赔偿的伙伴年底还在跑仲裁,真的很不容易。


    如果连父母都不能理解的话,我实在不敢想象,这个女孩回家过年的压力有多大。


    前几天有一条热搜:为什么年轻人不愿意回家过年了?


    年轻人不愿意回家过年,很多父母的第一反应是不孝顺,白眼狼,在外面呆野了。


    哎,谁不想回家过年啊,不回去肯定是不开心,而且不是一点点不开心,是压力重重。


    可能父母认为,孩子回家过年就图个热闹,到七大姑八大姨家串门拜年,见一见亲戚朋友兄弟姐妹,喝酒吃肉聊天,好不开心。


    其实不是的,真不是。就拿我来说,我回家只想睡觉嗑瓜子看电视,不洗脸不洗头谁都不见,同学聚会我都不想去。除非是几个关系极好的发小,其他任何社交局都是负担。


    除了社交压力,还有经济压力。


    像开头说的那个女孩一样,回一趟家要花车票钱、礼物钱、亲戚孩子压岁钱、给老人钱。赚钱了还好,如果一年没赚钱,这些人情开销就是一笔负担。


    累了一整年,只想回家休息,好好过个年,结果还要看钱包。


    当然还有催婚压力。


    像我这个年纪,马上奔三的人,过年回家见个人就是“找对象了没”。我家人比较开明,最多开玩笑问一句,亲戚朋友问就是“明年”。


    但我知道很多朋友、尤其女性朋友,过年催婚会把人逼疯。


    有些父母的催婚极其致命:“快三十了还不结婚,过了三十谁要你?你不成家我都没脸出门;人家谁谁都二胎了,你到底想咋样?你对得起...”。


    现在是 2024 年啊,找对象的难度不比打工挣钱低。如果再和父母吵上一架,这个年过的还有啥意思。


    这一层层的压力,早把年轻人回家过年的热情打散了,过个年比上班还累。


    现在能理解为啥年轻人不回家过年了吗?


    对父母来说,如果孩子愿意回家过年,就别要求那么多了,人回来图个开心就好。


    如果孩子在读大学,回家后就是想享受一下。你就让他睡到自然醒,让他每天蓬头垢面打游戏看电视,反正呆不了几天。


    如果愿意出去走亲戚,那就带上,不愿意也别勉强。更不要动不动就要求上酒桌,给长辈敬个酒,还得提一个,真的很尴尬。


    如果孩子在上班,一年已经很累了,她回家可能只想休息。父母们管好自己的嘴,少催婚,少安排相亲,少要求这要求那。


    更不要说谁谁家孩子赚了多少钱,谁谁家都抱孙子了。这样大家都不舒服,开开心心过个年不好吗?


    可能会有父母认为:我不催她都不上心。


    想想上学的时候,天天盯着学习,不能上网,不能找对象,不能玩这玩那,结果考上985了吗?


    结婚这事催不得,终身大事,你不能随便拉一个就领证吧,现在又不是70年代。


    如果逼的太急,很可能孩子明年就不回来过年了,骂也没有用。


    社会压力大,年轻人不比上一代轻松。多一点体贴关照,少一点要求,开心过年。


    车上没网,有感而发,到此为止。


    祝各位假期快乐,新年快乐。


    作者:杨成功
    来源:juejin.cn/post/7332293353197748258
    收起阅读 »

    年会结束,立马辞职了!

    那是发生在多年前的一件事,当时我也是在那家公司做 Java 开发。公司很大,大到去了很长一段时间都感觉毫无存在感。 那年年会,作为技术部的我,依然被安排到一个比较边缘化的桌子,这么多年走来,早已经习惯了这样的安排。 可能只有我们做技术人的心里才会觉得“技术牛逼...
    继续阅读 »

    副本_最后一天__2024-02-06+18_22_58.jpeg


    那是发生在多年前的一件事,当时我也是在那家公司做 Java 开发。公司很大,大到去了很长一段时间都感觉毫无存在感。


    那年年会,作为技术部的我,依然被安排到一个比较边缘化的桌子,这么多年走来,早已经习惯了这样的安排。


    可能只有我们做技术人的心里才会觉得“技术牛逼,技术万岁!”,但在公司领导层看来,这技术研发部就是整个公司开销最大的一个部门,又不能直接产生效益,但开除了又不合适,还要靠他们干活呢,这真是一件即讽刺、又无奈的事儿啊。


    说回正题,那年公司所有人依旧是尴尬的、极不情愿的、又不得不碍于情面凑在一起,听完了所谓的又毫无意义的年终总结,然后又敷衍的敬完酒之后,才能装模作样的挥手告别亲爱的同事。


    我之所以,要等待年会的第二天才告诉我的顶头上司“我要离职”的主要原因是,年会的时候才给大家集中发年终奖。


    我也是领到钱之后就不装了,我摊牌了,第二天就找到了领导,告诉他,我要离职了。这个时候上司也知道你的心思,话已经收出来了,尤其是离职的事,大概率是劝不回来了,毕竟覆水难收。大家都是明白人,寒暄了几句之后,就签了离职的申请。


    工作就像谈对象,合不来也没必要勉强。那时候开发的行情还很好,出去面试 4 家公司,最少也能拿 3 个 Offer,所以跳槽基本都是裸跳,一副此地不留爷,自有留爷处的傲娇姿态。


    然而,年终奖是拿到手了,新工作也很快又着落了,薪资每次跳槽也能涨到自己满意的数,但干着干着发现,好像还是原来的配方,还是原来的味道,好像也不是理想中的工作嘛。


    于是,在周而复始的折腾中才发现,只要是给别人上班,永远不会有理想中的工作,因为上班的本质是你替别人办事,别人给你发薪水,工作从来都是简单的雇佣关系,那来的别人要为你的理想来买单嘛,这本来就不合理,只是想明白这点时,以是上班了十年之后(此处可见自己的笨拙)。


    理解了这点之后,我才发现,给任何公司上班的区别不会太大,无非是钱多钱少、活多活少、周围人好相处与否的细微差别,但碍于生计,又不得不苟延残喘的上下班,这可能是大部分打工人的真实感受和现状了。


    但即使这样,你依然会发现,你的岗位正在被新人所替代,你的选择也变的越来越少,你的挣钱能力也变的越来越弱,这可能就是所谓的“中年危机”吧。所以说“中年危机”这个词,不是那个行业的专属名称,而是所有行业共性,那要怎么解决呢?


    三个小小的建议:



    1. 尽量不要买房:不要和自己过不去,买房一时爽,还贷“火葬场”。我有一个朋友,一个月 2.1W 的房贷,生活中哪怕有一点点小小的变动,对于他来说都是不可承受之殇。“如履薄冰”也不过如此吧?

    2. 培养自己的第二职业:找到自己感兴趣点,并且它能帮你长久的带来经济收益最好,不求大富大贵,只要能够日常开支已经很不错了。任何时候有准备都比没准备要强很多。还有,在做之前,不要怕起步晚、进步慢,只要肯坚持,终会有收获。路虽远,行则将至;事虽难,做则必成。

    3. 提升自己主业的能力:任何时候,提升自己主业的能力,都是收益最大的投资,也是最明智的投资,当你看不清前进的道路时,当你感觉人生黯淡无光事,唯有干好自己目前本职的工作,才是最优的选择,这也能让你为以后的新计划积攒足够的能量。


    最后,愿新的一年里:奔赴热爱、享受自由,找到自己热爱的事,并为之努力。加油,XDM~


    作者:Java中文社群
    来源:juejin.cn/post/7332227724801753140
    收起阅读 »

    记录一次我们的PostgreSQL数据库被攻击了

    数据库所有表被删除了 这个程序员把我们的数据库表都删除了,然后新建了一个数据库redme_to_recover数据库 里面还有一张表,表里是让你支付,然后给你数据下载地址。 通过查看Docker里部署的PostgreSQL执行日志是没有操作记录的 根据数据...
    继续阅读 »

    数据库所有表被删除了


    微信图片_20240126160520.png


    这个程序员把我们的数据库表都删除了,然后新建了一个数据库redme_to_recover数据库


    里面还有一张表,表里是让你支付,然后给你数据下载地址。


    通过查看Docker里部署的PostgreSQL执行日志是没有操作记录的


    微信图片_20240126162925.png


    根据数据库的日志确定,1月24号13点数据库被重启了。


    25号的日志非常少,错误信息都是客户端连接失败,无法从客户端接收数据。(25号系统还是正常的)


    26号02时的日志就显示tdd表没了(这时候应该是所有表都没了)。


    中间没有删除表的操作日志,跟大佬请教了一下,确定应该是有人登录了我们的Linux系统。然后从Linux系统层面直接删除的表资源数据,没有通过PGSQL操作,没有删除操作记录。


    我对黑客攻击的数据库进行了修改密码,然后发现密码失效了,无论输入什么密码,都能正常登录数据库。


    我是怎么恢复的


    1、将原来的PG数据库镜像删除,重新修改了端口号和数据库密码然后启动数据库容器。


    docker ps -a 列出所有的Docker容器,包括正在运行和已经停止的容器。


    docker rm [容器id/容器名称] 删除PostgreSQL容器。


    docker run 启动一个新的容器。
    image.png


    2、将Linux账户登录密码修改。


    3、修改端口号和数据库配置密码后,重新打包我们的数据处理程序。


    4、修改Nacos里配置的接口服务程序的数据库连接配置。


    5、将表结构恢复,系统表和业务表结构,系统表包括账户角色等信息(幸亏我们同事有备份)


    6、丢失了历史业务数据


    image.png


    作者:悟空啊
    来源:juejin.cn/post/7328003589297291276
    收起阅读 »

    可视化 Java 项目

    有一定规模的 IT 公司,只要几年,必然存在大量的代码,比如腾讯,2019 年一年增加 12.9 亿行代码,现在只会更多。不管是对于公司,还是对于个人,怎么低成本的了解这些代码的对应业务,所提供的能力,都是非常有必要的! 今天,阿七就带大家破解这个难题,根据这...
    继续阅读 »

    有一定规模的 IT 公司,只要几年,必然存在大量的代码,比如腾讯,2019 年一年增加 12.9 亿行代码,现在只会更多。不管是对于公司,还是对于个人,怎么低成本的了解这些代码的对应业务,所提供的能力,都是非常有必要的!


    今天,阿七就带大家破解这个难题,根据这个文档,你能使用 AI 编程技术,根据包含 Java 完整代码的项目实现可视化下面三个方面的内容:



    • 模块和功能:应用内部的业务模块和功能,及相互间的关系,为用户提供应用的整体视图。

    • 类和接口:应用模块提供的业务能力以及对应的类和接口,以及接口对应业务流程语义化。

    • 方法实现语义化:方法实现逻辑的语义化和可视化;


    一、先秀一下成果


    一)Java 项目概览图


    根据一个 Java 项目,可以生成下面这样的项目整体概览图,对于不需要了解实现细节的产品、运营同学,直接看这个图,就能够了解这个 Java 项目在干什么、能提供什么能力。


    对于部分技术同学,不需要了解代码详情的,也可以直接看这个图即可。满足新入职同学对于接手不常变更项目的理解和全局业务的了解!


    PS:由于保密需要,所有的成果图仅为示例图。实际的图会更好看、更震撼,因为一个 Java 项目的功能模块可能很多,提供的能力可能很多。



    对于需要了解技术细节的同学,点击入口,能看到当前方法的流程图,快速了解当前方法提供的能力,具体的细节。还能迅速发现流程上可能存在的问题,快速纠正。


    二)具体方法流程图



    有了上面的两层可视化图表,不管是产品、技术、测试、运营以及小领导,都能快速的根据一个 Java 项目获取到他所需要的层级的信息,降低开发人员通过阅读代码梳理业务逻辑和代码逻辑的时间,尤其是新入职的同学。这个时间据统计,基本上在 25%-30%(百度、阿里等大公司调研数据更大,为 55%-60%),对于新同学,这个比例会更大!


    二、实现步骤


    一)整体概述图怎么生成?


    一个 Java 项目所有对外接口在做的事情,就是一个 Java 项目的核心业务。这个对外接口包括:HTTP 接口、Dubbo 接口、定时任务。


    1、获取一个 Java 项目所有对外接口


    1)通过 Trace 平台


    可以查询到一个 Java 项目所有对外的 HTTP 接口和 Dubbo 接口,通过注解可以查询一个 Java 项目所有定时任务。


    优点:



    • 数据准确,跑出来的数据,一定是还在用的接口;
      缺点:

    • 需要依赖 Trace 平台数据,部分公司可能没有 Trace 平台。


    2)通过 JavaParser 工具


    可以通过 JavaParser 工具,扫描整个 Java 项目代码。找到所有的对外入口。


    优点:



    • 不依赖 Trace 数据;
      缺点:

    • 可能不准确,因为有些接口已经不被使用了。


    2、获取对外接口的方法内容


    1)根据 HTTP 的接口 url 可以反解析出来这个 url 对应的方法的全路径。


    具体来说,在项目中获取 Spring 上下文,Spring 上下文中有一个 Bean 叫 RequestMappingHandlerMapping,这个 Bean 中提供了一个方法 getHandlerMethods,这个方法中保存了一个 Java 项目中所有的对外 HTTP 方法。


    这个方法返回一个 Map对象,key 是 HTTP 接口的 URL,value 就是这个 URL 对应方法的全路径名称。



    2)根据方法全路径,获取方法内容


    根据上面的全路径名,使用 Spoon 框架我们能拿到对应方法的方法体。



    fr.inria.gforge.spoon
    spoon-core


    我们让 ChatGPT 帮我们写代码,提示词:



    写一个 Java 方法,使用 Spoon 框架解析 Java 方法的完整内容
    其中入参是方法全路径名




    PS:这个代码一会还有用,我们往下递归的话,能拿到这个 Controller 方法调用的所有方法体。


    3、根据方法内容生成方法注释


    就和 GitHub Copilot 和百度 Comate 代码助手一样,GPT 可以根据代码生成方法注释,提示词:



    角色: 你是一个 Java 技术专家。

    任务: # 号开头的是一个 Java 方法。请你逐行阅读代码,然后为这个 Java 方法生成一句话注释。

    限制:不要超过 20 个字



    举个例子,我有个工具方法,使用 GPT 为他生成注释,如下:



    4、生成 Java 项目一句话描述



    角色: 你是一个 Java 技术专家。

    任务: --- 符号以上的是一个 Java 项目中所有对外方法的注释,请你逐行阅读这些注释,然后给这个 Java 项目生成一句话描述。

    限制: 结果不要超过两句话。



    这个利用的是 GPT 的总结概要的能力,GPT 能总结论文、总结文章,他也能总结一段描述 Java 项目的文字。这样就能获取对于一个 Java 项目的一句话描述,也就是项目概览图的第一层。


    5、总结:生成项目概览图


    我们要求 GPT 根据 Java 项目的一句话描述,和所有对完方法的方法注释,生成思维导图数据。为了项目概览图的层级更可读、更清晰,我们可以要求 GPT 根据方法注释的相似性进行分类,形成项目概览图的第二层。第三层就是所有项目中对外方法的注释。


    生成思维导图,可以让 GPT 根据结构内容生成 puml 格式的思维导图数据,我们把 puml 格式的数据存储为 puml 文件,然后使用 xmind 或者在线画图工具 processOn 打开就能看到完整的思维导图。


    参考提示词如下:



    应用代码:appCodeValue

    项目描述:appCodeDescValue

    项目描述:appCodeDescValue

    方法描述:methodDescListValue

    角色:你是一个有多年经验的 Java 技术专家,在集成 Java 项目方面有丰富的经验。

    任务:根据 Java 项目中所有公共接口的描述信息生成思维导图。

    要求:思维导图只有四个层级。

    详细要求:思维导图的中心主题是 appCodeValue,第一层分支是 appCodeDescValue;第二层分支是公共接口的分类;下层分支是每个分类下方法的描述信息。

    返回正确格式的 opml 思维导图 xml 数据,并且内容是中文。



    二)流程图怎么生成?


    1、获取递归代码


    直接问 GPT,让 GPT 改造上面的获取方法体的方法。


    prompt;



    {获取方法体的方法}

    上面的 Java 代码是使用 Spoon 框架解析 Java 方法的完整内容
    其中入参是方法全路径名

    任务:现在要求你改造这个方法,除了打印当前方法的完整内容,还要求递归打印所有调用方法的方法体内容,包含被调用方法调用的方法





    这样,我们能获取到一个 controller 方法所有递归调用的方法,每个方法生成自己的流程图,最后通过流程图嵌套的形式进行展示。


    比如这个例子,当前能看到的是当前方法的流程图,带 + 号的内容,是当前方法调用方法的流程图。这样方便我们按照自己需要的深度去了解当前方法的具体实现流程!


    2、无效代码剪枝


    按照上面生成的流程图可能分支很多,还有一些无效的信息,影响用户判断,我们可以通过删除一些业务无关代码的方法,精简流程图。


    比如,我们可以删除日志、监控等与业务逻辑无关的代码,删除没有调用的代码(现在市面上有些这种技术方案,可以检测当前项目中没有被实际调用的代码)。


    3、生成流程图


    先让 GPT 根据代码生成结构化的 Json 数据。



    给你一段 Java 代码,请你使用 spoon 输出结构化的 Json 数据。要求:请你直接输出结构的 json 结果数据,不需要过程代码



    然后,可以让 GPT 根据 Json 数据生成流程图数据,使用流程图工具打开即可。



    给你一段 Spoon 结构化 Java 代码的 Json 数据,整理对应 Java 代码的意思,生成一个流程图数据,流程图使用 PlantUML。现在请输出能直接绘制 PlantUML 图的数据




    三、改进方案


    我们可以从下面几个方面改进这个项目,从而实现真正落地,解决实际公司需求:



    1. 获取代码,修改为从 gitlab 等代码仓库直接拉取,这样使用的时候不需要将工具包导入到具体的 Java 项目中。

    2. 优化生图,提前生成全量图标,通过浏览器的形式进行访问。

    3. 增加图表内容手动校正功能,生成不准确的,支持开发人员手动调整。

    4. 增加检索功能,可以按照自然语言进行检索。

    5. 把项目中的方法和类信息存起来,生成更准确的图标。

    6. 根据完整项目代码,反向生成项目概要图,可能能得到更准确的概要图。

    7. 递归方法流程图,可以使用流程图嵌套,如下进行展示。



    四、总结


    AI 在编程领域,除了大厂都在卷的代码助手,结合自己公司还有很多可探索的地方,比如本文说的可视化 Java 项目,还可以通过分析日志,进行异常、故障的根因分析,做到快速定位问题,帮助快速解决问题,减少影响。


    如果故障根因分析这个工具做出来了,阿里云的 P0 故障,滴滴的 P0 故障,还有很多大中小厂的故障,是不是能更快恢复?减少声誉、金钱损失?


    就说,项目可视化这个需求,据我了解的内部消息,有些互联网中大厂已经在使用这个方式进行落地了。另外,我陪伴群里也有同学接触到了类似不少甲方的类似的强需求,如果想深入这块技术的同学,不管是进互联网大厂还是做自己的副业产品都是不错的方向!


    作者:伍六七AI编程
    来源:juejin.cn/post/7311652298227990563
    收起阅读 »

    记录一次类似页面抽出经历

    web
    一、背景 刚入职了一家新公司,摸鱼式的把每个项目都打开看了下。不够五分钟便惊出了一身冷汗,午饭时分,便和领导聊了下这些项目中的通病。其中就有一个问题就是代码复用的问题,一个Vue文件6000多行代码,多个文件中还有很多重复的代码。领导听了休息了片刻便拍案而起,...
    继续阅读 »

    一、背景


    刚入职了一家新公司,摸鱼式的把每个项目都打开看了下。不够五分钟便惊出了一身冷汗,午饭时分,便和领导聊了下这些项目中的通病。其中就有一个问题就是代码复用的问题,一个Vue文件6000多行代码,多个文件中还有很多重复的代码。领导听了休息了片刻便拍案而起,好的小陈你就把这几个类似的页面抽出来吧!我:。。。。默默扒饭。


    二、问题和方案


    类似登录页这种几百年不变的页面,多个项目不管是逻辑还是UI基本上都是一样的,多个项目要用。虽然CV也挺快,但是如果逻辑一改的话,yi那其实还是挺麻烦的。(领导视角)


    方案一:Iframe嵌入主项目❌


    一开始是想把要引入的页面打包然后通过Iframe引入,但是这样的话会存在域不同的问题,而无法随心所欲的操作本地存储之类的东西。虽然可以用postMessage的方法进行通信传输数据,但是要传输到主页面的信息一多的话,很难分清楚哪个数据是所需要的。在尝试了半天之后,PASS了这个方案。


    方案二:将页面打包成组件,然后在主项目中注册且使用✔


    通过采用lib库模式打包Vue页面为组件,然后在主项目中引入,便可以实现页面的复用。然后引入组件也可以自由的访问本地存储等东西。
    打包命令:


    vue-cli-service build --target lib --name main --dest lib src/components/index.js

    详情可以参考官网的指南
    构建目标 | Vue CLI (vuejs.org)


    接下来便是痛苦且折磨的试错之路😖


    初步实现



    1. 要引入页面的项目结构(components中的About和Main中的文件即为要打包的文件)
      image.png

    2. 配置库打包的文件

      简单说明下这两个文件的作用:

      Main文件夹下面的index.js作用:包含Main的Vue文件注册成全局组件的方法;

      components文件夹下index.js作用:暴露出一个方法可以批量注册components下的组件。

      image.png


    接下来看下这两个文件的具体内容

    Main下面的index.js
    image.png


    components下面的index.js
    image.png


    看了下这两个文件的内容,写过Vue插件的铁铁们应该都很熟悉,对其实就是把页面当成组件了。有的铁铁举手问,小陈那个initRouter是啥呀,小陈后面为铁铁们解答,我们一步一步慢慢实现。

    Main页面如下
    image.png


    接下来便是通过命令行打包成组件的步骤了
    image.png


    现在打包的项目这边的任务就告一段落,后面我们看下主项目要如何引用这个被打包的组件。
    image.png
    只需要在主项目的main中注册我们打包的组件就可以使用了,然后结构出来的Main和About正是刚才我们在components下index.js暴露的两个组件,componentPage则是components下index.js暴露的默认的install方法用于注册。启动下主项目试试就发现我们引入的两个组件都加到主项目路由里面去了。心头一甜但是隐约觉得事情没这么简单。😱


    image.png
    image.png


    三、遇到的问题及解决策略


    问题、组件需要使用主项目的路由


    以登录页为例子,在用户验证完身份之后,需要跳转到主项目中的其他页面。例如跳转home需要跳转到主项目的home页面,在主项目中点击会报错,因为组件路由根本没有这个路由配置,所以需要把主项目的路由引入到组件中,那要怎么做捏?容小陈慢慢解释。


    image.png


    解决:在注册的时候引入主项目的路由


    通过initRouter在注册组件的时候,把主项目的路由引入到组件中,然后在需要使用主项目路由的时候,使用getCurRouter给组件路由赋值成主项目的路由即可。只不过在使用router的js文件都需要使用getCurRouter。Vue文件中则不需要做任何配置,因为this.router/this.route访问的均是主项目的路由。

    组件的路由文件配置
    image.png
    组件下components的index.js配置
    image.png
    Main中跳转的方法
    image.png
    顺便一提:判断生产还是开发环境都是为了开发的时候,不用做额外的配置,只是方法比较笨。如果大佬们有更好的方法麻烦踢一下小陈。


    总结


    这是小陈第一次在掘金上写文章,可能这篇文章的作用不是很大,但也是记录小陈解决问题的载体。文章有啥不清楚的或者不合理的地方还麻烦铁铁们和小陈促膝长谈。但是此次的实践还是让小陈对Vue的一些知识这块有了新的理解。然后日后还请大佬们多多指教。

    Demo的地址: only-for-test: 仅用来测试的仓库 (gitee.com)


    作者:用户1863710796985
    来源:juejin.cn/post/7250667613020291109
    收起阅读 »

    整理下最近做的产品里 比较典型的代码规范问题

    前言 最近负责了一个产品的前端代码Code Review的工作,90%代码是从之前做过的一个项目merge过来的,由于当时开发周期紧张,没有做好足够的Code Review流程,导致代码质量很差,而产品的代码质量要求就很高。 前端开发30个左右,技术经验高的1...
    继续阅读 »

    前言


    最近负责了一个产品的前端代码Code Review的工作,90%代码是从之前做过的一个项目merge过来的,由于当时开发周期紧张,没有做好足够的Code Review流程,导致代码质量很差,而产品的代码质量要求就很高。


    前端开发30个左右,技术经验高的10年左右,低的2-3年,经过几轮的code review,整理几个比较常见,比较典型的例子,简单的总结下。


    ESLint


    首先,可以引入 ESLint 静态代码检测工具,它可以保证高质量的代码,尽量减少和提早发现一些错误。同时也支持IDE自动检查提示。


    具体可以参考之前的文章:
    ESLint配合VSCode 统一团队前端代码规范


    IDE Format


    当然也离不开Code Format格式化,需要配置一套固定的Format格式,保证团队内所有代码格式化统一。


    我不太喜欢 Prettier 的换行机制,弄得大片大片的换行,可读性也差。团队大多用的VSCode,所以就用VSCode内置的Format功能,再加下可配置项,这样只要用VSCode开发,就会默认使用统一的代码格式化。


    具体可以参考之前的文章:只用VSCode自带的Format功能,满足可配置的代码格式化需求


    代码规范及习惯


    下面介绍一些团队内经常遇到的代码规范、质量问题,还有一些很不好的开发习惯。


    大小写



    1. 常量名:都大写或首字母大写;

    2. 变量:首字母小写,驼峰;

    3. dom id:全小写

    4. class name:全小写

    5. React router path:全小写,路由跳转的url一样。

    6. React 组件名(类\函数):大写开头,驼峰,尽量与文件名一致(除了index.jsx)


    catch


    这里一般是指请求后台api的catch,而且common已经封装好了fetch方法,并处理了公共异常,比如根据status提示不一样的提示语弹框:



    1. 由于common有封装,大多情况下不需要业务加catch处理;

    2. 如果有catch,必须要求throw,原因:

      1. throw会让程序中断,就是说不会再继续执行后续代码;

      2. F12 console里会打浏览器默认的error log,非常必要。



    3. 如果加了catch或finally,一定要测下程序走到这里的case,并想想是否有必要加。


    减少非必要的可选链操作符 (?.)


    产品里经常用到的操作符,用的很无脑,经常遇到这种代码:


    // 1
    const a = obj?.a?.b?.c?.d;

    // 2
    <div>{this.state?.name}</div>

    // 3
    const arr = list.filter(item=> ...);
    if (arr?.length) { ... }

    // 4
    if(item.a) { ... }
    fn(item?.b);

    // 5
    if (item) {
    fn(item?.b);
    }


    1. 如果变量或属性不能是空,不要加问号;

    2. 假如一个后台返回的值,不能是空,空就是bug了,这个时候前台加了问号,如果真有bug,就不容易发现了(反之会直接console抛错,很容易发现)

    3. 理解其原理用法,想想如果真是空,对后续是否有影响?这个值是否可能是空?

    4. 不要盲目加,有个点儿就加问号。

    5. 另一个目的:增加代码可读性,维护性。


    common 控件属性


    在使用common或第三方控件时:



    1. 一定要理解每个属性的作用,以及默认值;

    2. 不必须要设置的属性,不要设置;

    3. 如果属性有默认值,而且你用到的也是默认值,有些情况是不要设置的;

    4. 目的:方便维护,增加可读性。


    sessionStorage 和 localStorage


    思考两个问题:



    1. 是否真的了解两者的区别以及作用?

    2. 你是否真的需要它们?


    async await


    这里对于新手,会有很多不正确的用法,但代码运行没问题,只是用法不规范:



    1. 使用之前,一定要弄懂async await是做啥用的,不要滥用、乱用。

    2. 很多地方是不需要用的。

    3. 下面举例几个错误用法:


    fn = async () => {
    // 整个方法内部都没有用到await
    }

    fn = async () => {
    return await request(); // 不需要加async await
    }

    fn = async () => {
    const result = await request();
    return Promise.resolve(result); // 可以直接return
    }

    深拷贝



    1. 例如:JSON.parse(JSON.stringify(obj\array))

    2. 有些开发会用的很频繁,很无脑,有很多情况下,浅拷贝就可以满足、或者根本不需要拷贝的情况下就使用了,造成了很多额外开销。

    3. 需要理解 引用类型、浅拷贝、深拷贝 三个概念。


    React Hooks


    这里指React官方提供的Hooks,比如 useEffect useCallback useMemo memo 这几个“常用”的。


    发现业务中使用的很频繁,这里简单说下我的理解:



    • useEffect:注意第二个参数 deps,有些情况下,不是所有用到的参数都加到 deps里,会导致bug。

    • useCallbackmemo:大多数地方都是不需要的使用的(90%以上)。

    • useMemo:复杂逻辑可以用,其它情况不需要。

    • 以上,如果用的不对,反而会导致 业务bug负优化,甚至 反向优化

    • 这里说的比较浅,总结一个大致结论,详细说明网上很多。


    如果提升代码经验和意识


    简单总结几点:



    1. 写代码时要多问、多想、多调,不要功能好事了就完事了。

    2. 多看别人写的代码,比如团队内级别高的开发、网上大佬写的、第三方源码。

    3. 多review自己写过的代码,并优化。


    总结


    本文写的比较杂、也都比较浅,因为涉及到的知识点、经验太多了,不是三言两语就能说明白,详细的重要的点,也会在后续文章中详细讲解。


    作者:Mark大熊
    来源:juejin.cn/post/7235109911780311101
    收起阅读 »

    文档都写不好,当个屁的架构师!

    大家好,我是冰河~~ 最近有很多小伙伴,也不乏身边的一些同事问我:哎,架构师为什么要写这么多文档啊?有啥用呢?不能跟开发一样多写写代码吗?天天写文档,又感觉自己的文档写不好,有什么写文档的技巧吗? 今天也正好看到一篇文章,就给大家统一回复下这个问题。 软件设计...
    继续阅读 »

    大家好,我是冰河~~


    最近有很多小伙伴,也不乏身边的一些同事问我:哎,架构师为什么要写这么多文档啊?有啥用呢?不能跟开发一样多写写代码吗?天天写文档,又感觉自己的文档写不好,有什么写文档的技巧吗?


    今天也正好看到一篇文章,就给大家统一回复下这个问题。


    软件设计文档就是架构师的主要工作成果,它需要阐释工作过程中的各种诉求,描绘软件的完整蓝图,而软件设计文档的主要组成部分就是软件模型。


    软件设计过程可以拆分成 需求分析、概要设计和详细设计 三个阶段。


    在需求分析阶段,主要是通过用例图来描述系统的功能与使用场景;对于关键的业务流程,可以通过活动图描述;如果在需求阶段就提出要和现有的某些子系统整合,那么可以通过时序图描述新系统和原来的子系统的调用关系;可以通过简化的类图进行领域模型抽象,并描述核心领域对象之间的关系;如果某些对象内部会有复杂的状态变化,比如用户、订单这些,可以用状态图进行描述。


    在概要设计阶段,通过部署图描述系统最终的物理蓝图;通过组件图以及组件时序图设计软件主要模块及其关系;还可以通过组件活动图描述组件间的流程逻辑。


    在详细设计阶段,主要输出的就是类图和类的时序图,指导最终的代码开发,如果某个类方法内部有比较复杂的逻辑,那么可以将这个方法的逻辑用活动图进行描述。


    我们在每个设计阶段使用几种UML模型对领域或者系统进行建模,然后将这些模型配上必要的文字说明写入到文档中,就可以构成一篇软件设计文档了。


    由于时间关系,今天就跟大家聊到这里,后续给大家分享系统写架构文档的方法论。


    好了,今天就到这儿吧,我是冰河,我们下期见~~


    作者:冰_河
    来源:juejin.cn/post/7330835892276838441
    收起阅读 »

    简单一招竟把nginx服务器性能提升50倍

    需求背景 接到重点业务需求要分轮次展示数据,预估最高承接 9w 的 QPS,作为后端工程师下意识的就是把接口写好,分级缓存、机器扩容、线程拉满等等一系列连招准备,再因为数据更新频次两只手都数得过来,我们采取了最稳妥的处理方式,直接生成静态文件拿 CDN 抗量 ...
    继续阅读 »

    需求背景


    接到重点业务需求要分轮次展示数据,预估最高承接 9w 的 QPS,作为后端工程师下意识的就是把接口写好,分级缓存、机器扩容、线程拉满等等一系列连招准备,再因为数据更新频次两只手都数得过来,我们采取了最稳妥的处理方式,直接生成静态文件拿 CDN 抗量


    架构流程大致如下所示:



    数据更新后会重新生成新一轮次的文件,刷新 CDN 的时候会触发大量回源请求,应用服务器极端情况得 hold 住这 9w 的 QPS


    第一次压测


    双机房一共 40 台 4C 的机器,25KB 数据文件,5w 的 QPS 直接把 CPU 打到 90%


    这明显不符合业务需求啊,咋办?先无脑加机器试试呗


    就在这时测试同学反馈压测的数据不对,最后一轮文件最大会有 125KB,雪上加霜


    于是乎文件替换,机器数量整体翻一倍扩到 80 台,服务端 CPU 依然是瓶颈,QPS 加不上去了



    到底是哪里在消耗 CPU 资源呢,整体架构已经简单到不能再简单了


    这时候我们注意到为了节省网络带宽 nginx 开启了 gzip 压缩,是不是这小子搞的鬼


    server
    {
    listen 80;

    gzip on;
    gzip_disable "msie6";
    gzip_vary on;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_buffers 16 8k;
    gzip_http_version 1.1;
    gzip_types text/plain application/css text/css application/xml text/javascript application/javascript application/x-javascript;

    ......
    }



    第二次压测


    为了验证这个猜想,我们把 nginx 中的 gzip 压缩率从 6 调成 2,以减少 CPU 的计算量



    gzip_comp_level 2;



    这轮压下来 CPU 还是很快被打满,但 QPS 勉强能达到 9w,坐实了确实是 gzip 在耗 CPU



    nginx 作为家喻户晓的 web 服务器,以高性能高并发著称,区区一个静态数据文件就把应用服务器压的这么高,一定是哪里不对


    第三次压测


    明确了 gzip 在耗 CPU 之后我们潜下心来查阅了相关资料,发现了一丝进展


    html/css/js 等静态文件通常包含大量空格、标签等重复字符,重复出现的部分使用「距离加长度」表达可以减少字符数,进而大幅降低带宽,这就是 gzip 无损压缩的基本原理


    作为一种端到端的压缩技术,gzip 约定文件在服务端压缩完成,传输中保持不变,直到抵达客户端。这不妥妥的理论依据嘛~


    nginx 中的 gzip 压缩分为动态压缩和静态压缩两种


    •动态压缩


    服务器给客户端返回响应时,消耗自身的资源进行实时压缩,保证客户端拿到 gzip 格式的文件


    这个模块是默认编译的,详情可以查看 nginx.org/en/docs/htt…


    •静态压缩


    直接将预先压缩过的 .gz 文件返回给客户端,不再实时压缩文件,如果找不到 .gz 文件,会使用对应的原始文件


    这个模块需要单独编译,详情可以查看 nginx.org/en/docs/htt…


    如果开启了 gzip_static always,而且客户端不支持 gzip,还可以在服务端加装 gunzip 来帮助客户端解压,这里我们就不需要了


    查了一下 jdos 自带的 nginx 已经编译了 ngx_http_gzip_static_module,省去了重新编译的麻烦事



    接下来通过 GZIPOutputStream 在本地额外生成一个 .gz 的文件,nginx 配置上静态压缩再来一次



    gzip_static on;




    面对 9w 的QPS,40 台机器只用了 7% 的 CPU 使用率完美扛下


    为了探底继续加压,应用服务器 CPU 增长缓慢,直到网络流出速率被拉到了 89MB/s,担心影响宿主机其他容器停止压力,此时 QPS 已经来到 27w


    qps 5w->27w 提升 5 倍,CPU 90%->7% 降低 10 倍,整体性能翻了 50 倍不止,这回舒服了~


    写在最后


    经过一连串的分析实践,似乎静态压缩存在“压倒性”优势,那什么场景适合动态压缩,什么场景适合静态压缩呢?一番探讨后得出以下结论



    纯静态不会变化的文件适合静态压缩,提前使用gzip压缩好避免CPU和带宽的浪费。动态压缩适合API接口返回给前端数据这种动态的场景,数据会发生变化,这时候就需要nginx根据返回内容动态压缩,以节省服务器带宽



    作为一名后端工程师,nginx 是我们的老相识了,抬头不见低头见。日常工作中配一配转发规则,查一查 header 设置,基本都是把 nginx 作为反向代理使用。这次是直接访问静态资源,调整过程的一系列优化加深了我们对 gzip 的动态压缩和静态压缩的基本认识,这在 NG 老炮儿眼里显得微不足道,但对于我们来说却是一次难得的技能拓展机会


    在之前的职业生涯里,我们一直聚焦于业务架构设计与开发,对性能的优化似乎已经形成思维惯性。面对大数据量长事务请求,减少循环变批量,增大并发,增加缓存,实在不行走异步任务解决,一般瓶颈都出现在 I/O 层面,毕竟磁盘慢嘛,减少与数据库的交互次数往往就有效果,其他大概率不是问题。这回有点儿不一样,CPU 被打起来的原因就是出现了大量数据计算,在高并发请求前,任何一个环节都可能产生性能问题


    作者:京东零售 闫创


    来源:京东云开发者社区 转载请注明来源


    作者:京东云开发者
    来源:juejin.cn/post/7328766815101206547
    收起阅读 »

    多租户架构设计思考

    共享数据库,共享表 描述 所有租户的数据都在同一个数据库表内,以租户字段:tenant_id来区分。 优点 成本低,实现方式简单,适合中小型项目的快速实现。 缺点 数据隔离性差,某一个租户的数据量大的时候,会影响其他租户数据的操作效率。 需要在表上增加租户字...
    继续阅读 »

    共享数据库,共享表


    描述


    所有租户的数据都在同一个数据库表内,以租户字段:tenant_id来区分。


    优点


    成本低,实现方式简单,适合中小型项目的快速实现。


    缺点



    • 数据隔离性差,某一个租户的数据量大的时候,会影响其他租户数据的操作效率。

    • 需要在表上增加租户字段,对系统有一定的侵入性。

    • 数据备份困难,因为所有租户的数据混合在一起,所以针对某个租户数据的备份、恢复会比较麻烦。


    实现方式


    **方式一:**编写Mybatis拦截器,拦截增删改查操作,动态的增加租户条件,如:


    SELECT * FROM sys_user;

    修改成:


    SELECTG * FROM sys_user WHERE tenant_id = 100;

    这种方案并不靠谱,因为动态修改SQL语句不是一个好的处理方式,如果SQL解析没有做好,或者出现复杂SQL,那么很容易产生bug。


    **方式二:**编写Mybatis拦截器,拦截增删改查操作,判断是否有租户条件,如:


    SELECT * FROM sys_user WHERE id=1;

    使用jsqlparser工具解析SQL,判断出该SQL语句没有tenant_id的条件,那么抛出异常,不允许执行。


    这种方案比较稳妥,因为只做判断不做修改。


    查询操作的优先级不高,如果不在乎数据敏感,可以不拦截。


    要注意的是修改操作,稍不注意容易被某一个租户影响其他租户的数据。


    共享数据库,独立一张表


    描述


    所有租户的数据都在同一个数据库中,但是各自有一个独立的表,如:


    # 1号租户的用户表
    sys_user_1

    # 2号租户的用户表
    sys_user_2

    ...

    优点


    成本低,数据隔离性比共享表稍好,并且不用新增租户字段,对系统没有侵入性。


    缺点



    • 数据隔离性虽然比共享表好了些,但是因为仍在同一数据库下,所以某一个租户影响其他租户的数据操作效率问题依然存在。

    • 数据备份困难的问题依然存在。


    实现方式


    **方式一:**编写Mybatis拦截器,拦截增删改查操作,动态的修改表名称,如:


    SELECT * FROM sys_user;

    修改成:


    SELECT * FROM sys_user_1;

    同样的,这种动态修改SQL语句的方式并不推荐,所以我们有另一种方式。


    **方式二:**将表名作为参数传入


    本来在Mapper.xml中,查询语句是这样的:


    SELECT * FROM sys_user WHERE id = #{userId};

    现在改成:


    SELECT * FROM #{tableName} WHERE id = #{userId};

    这样可以避免动态修改SQL语句操作。


    独立数据库


    描述


    每个租户都单独分配一个数据库,数据完全独立,如:


    database_1;
    database_2;
    ...

    优点



    • 数据隔离性最好,不需要添加租户id字段,租户之间不会被彼此影响。

    • 便于数据备份和恢复。

    • 便于扩展。


    缺点



    • 经费成本高,尤其在有多个租户的情况下。

    • 运维成本高。


    结论


    一般来说,当数据量不高的时候,选择共享数据库共享表的方式,表内加个租户id字段做区分,数据量或者用户量多起来,就可以直接升级到独立数据库的方式,因为独立表的方式处理起来是有些麻烦的,倒不如加个字段来的方便。


    作者:失败的面
    来源:juejin.cn/post/7282953307529953291
    收起阅读 »

    你要写过年,就不能只写万家灯火与团圆

    昨天晚上八点过,下了地铁,走到出租屋的楼下,原本热闹的小区,也变得冷冷清清的,来到我经常吃肉沫粉的小店门口,小雨缠绵,透过那层破旧的透明胶纸,看到老板和老板娘在收拾行李。 我轻轻撩开胶纸,问老板还有吃的吗,他笑着说:兄弟,刚好还有最后一份,你来得正巧,卖给你后...
    继续阅读 »

    昨天晚上八点过,下了地铁,走到出租屋的楼下,原本热闹的小区,也变得冷冷清清的,来到我经常吃肉沫粉的小店门口,小雨缠绵,透过那层破旧的透明胶纸,看到老板和老板娘在收拾行李。


    我轻轻撩开胶纸,问老板还有吃的吗,他笑着说:兄弟,刚好还有最后一份,你来得正巧,卖给你后我们就该回家了。


    可以看出他们心中是很开心的,两个孩子也在不停叨唠:回家了,回家了。


    老板做好粉给我端来,可能是最后一份,料加得特别足,我吃了一半就饱了,然后擦了擦嘴,给老板说了声新年快乐,老板和老板娘笑眯眯回了我一句:兄弟,新年快乐,明年见!


    于是我就上楼了,往日上楼都有不少人在等电梯,今日五个电梯门都停在一楼,大家都回去过年了吧,再过两天,这个城市可能会更加冷清。


    回到出租屋后,坐在桌子前,回想很多事情,我觉得可以动笔了!


    一.“不想回家过年”的人


    下班后,打了个滴滴去20公里以外的地方办点事,一上车和师傅就开始聊了起来,师傅问我还不回家过年吗?


    图片


    我对他说:还有好几天呢,除夕再回去。


    我反问他:只有几天过年了,为啥还不回去过年呢?


    他说道:平时跑车都没啥生意,过年生意好一点,多跑几天,和你一样也是除夕当天才回去。


    他说好几天没遇到我这么大的单了,60块钱,平时都是10块,8块的,一天也就能跑两百块钱左右,最近一天能跑500左右。


    我们在车上一直聊,聊他的年轻时进厂打工过年回家的时光,他14岁时就去浙江进厂,每年回家过年也就能带几千块钱过年,有一年从义乌坐了三天的大巴车回来,路上堵车,事故,经历“九九八十一难”才到家。


    回来过年打了几天的麻将,几千块钱全部输完后,给家里要了几百块钱后,又灰溜溜地出门进厂了。这样的日子反反复复了六七年,一分钱都没存到。


    后面觉得这样不行,于是家里给他说了个媳妇,还是卖了一块土地,才勉强把彩礼凑齐了。


    成家后有了孩子,压力大了,于是就在家乡的县城干工地,一干就是十几年,直到35岁的时候,存了十几万块钱,2019年在县城首付买了一套房子。


    没过多久,疫情就来了,他说没活干,收入彻底断了,但是房贷没有断,于是刷信用卡,借钱来还房贷,后面疫情稍微放开后,就想办法搞了一个二手车来跑,那会跑十几二十公里都很难拉到一个人,一天勉强能跑八九十块钱,勉强能够一家人吃饭,但是房贷还是要想其它的办法。


    他说为啥不敢提前回去过年,就是因为还要还房贷,所以不敢松一口气。


    聊了大概一个小时,一路堵车,我到站了,下车后他递了一支烟给我,说道:兄弟,很久没有和别人聊这么久了,新年快乐。


    我也对他说了一句新年快乐。


    我给了一个好评,并且打赏了十块钱。


    是啊,我何尝不是很久没有和别人聊这么久了呢,我们都在自己该走的路上马不停蹄奔跑,一切还不是为了生活!


    没有谁不想提前回家过年,没有谁不想回家看看父母,没有谁不想回家去感受热乎乎的饭菜!


    可是回到了家,生活又该怎么继续继续呢?


    二.想回家过年却回不了的人


    上个月从广州回来,广州南站已经是人山人海了,那会朋友说抢回广西的票已经很难抢了,都不知道还能不能回去过年。


    图片


    这两天和在广东打工的朋友聊了下,他说根本抢不到票,不知道还能不能回家。


    我打开了手机购票软件,全是暂无余票,建议抢票,抢到票的人是幸运的,但是抢不到票的人,此刻心中又是何种感受。


    因为只有火车,高铁,大巴是中国大部分人能消费得起的,大部分根本不舍得买一张机票。


    和滴滴师傅聊天时,他说他的哥哥和嫂子现在还在义乌进厂,由于抢不到火车票和高铁票,他们看了看机票,需要1400元,两个人就需要差不多3000多,这已经顶得上他们一个人一个月的工资了。


    所以想了想还是不回了,打了几千块钱给家里的老人和孩子,让他们自己过年了。


    可能下一次见到家中的老父母和孩子又是下一年了,不知道下次回家的时候,孩子看他们的眼神是不是会有一丝陌生,老人的眼神是不是又多了几分期待。


    还记得在我小时候,父母在外省打工,过年的时候,他们背着很大的牛仔背包,里面有被子,衣服,只要能带回来的东西都带回来了,那时候父母还算年轻,但是回到家的时候我却感觉有点陌生。


    因为长时间不见他们,当见到他们的时候,虽然心里很高兴,但是却一时表现不出来,反而会流下泪水。


    我在农村看了太多这样的场景,爸爸妈妈在外打工,过年回来过年,孩子在门前呆呆坐着,叫了他几声都没答应,最后大哭了起来。


    是啊,有谁能在几年时间里没见到自己的爸爸妈妈,当见到的时候能不大哭呢?


    不过这就是中国大部分农村的实际情况,父母因为要赚钱回来修房子,供孩子上学,所以很多父母过年不舍得花费太多路费回来。


    除了交通工具和回家路费的限制,还有很多因为工作不能回家过年的人,他们很想回来,但是却不能回来,他们有工人,有白领,有交警,有驻守边疆的战士......


    此刻,不管你过年在厂区里面加班,在写字楼工作,在路上指挥车辆,还是在祖国的边疆驻守。


    我都对你们表示尊敬,祝你们新年快乐!


    三.不敢回家过年的人


    总有人有家不敢回。


    图片


    可能网上的过年文案都是阖家欢乐,大团圆,但是在社会的深处,总有很多人不敢回家,或者不好意思回家。


    远在深圳的朋友,和我聊天说不敢回家过年了,钱是钱没赚到,女朋友是女朋友没找到,回去面对逐渐变老的父母,心中不忍。


    这几年赚钱是真的特别难,朋友在深圳搞销售,因为销售很不稳定,并且是个苦活,他一个月也就能赚几千块钱,除了花销,还要还债,就留不下几个钱。


    后面觉得送外卖可能能多赚一点,但是送了不久,和别人电车又撞了,还受了伤,于是只能放弃,直接去找了一个工厂进。


    我们大多数人总是看到大城市的繁华,以为都能赚到钱。但是大城市里面,大部分人都是拿着最微薄的工资,干着最累的活,最后还存不了几个钱。


    可能你觉得在几十层的写字楼里面工作的白领都是年薪几十几百万,但是实际情况是,大多数都是几千块,每天通勤都是按小时来计算,加班后回到出租屋已经累趴,一趟就睡。


    但是一年下来却赚不了几个钱,在亲人朋友的眼中以为你在大城市混得不错,但是苦只有自己知道。


    所以带着这种压力和心理负担,很多人不敢回家。


    还有一些怕回去被催婚,被相亲,被攀比,所以索性直接留在打工的地方过年,因为觉得自己不甘随便找个人结婚,不想去和谁比这比那,索性选择一个人留下来。


    也许大年三十你看到了漫天的烟花,饭桌上丰盛的菜肴,但是总有人在没人看到的地方吃着泡面,烟花爆开的一瞬间,他的眼泪刚好掉下。


    我经历过这样的日子,我曾看到别人团圆而自己孤身一人而落泪,也曾看到万家灯火而自己在黑暗中哭泣。


    四.无家可回的人


    总有人想过年,但是却没有家回的人。


    图片


    在我还是学生的时候,有一个朋友过年不知道去哪里过,他常年都在外面打工,过年的时候回来,我们在一起喝酒,一起聊天,但是到最后,每个人都回家了,他独自一个人去酒店了。


    他父母在他小的时候就离婚了,并且父母都对他不管不顾,在他十几岁的时候就独自出门打工了,他已经没啥亲人了,所以回到家乡只是来找一个曾经的感觉。


    还记得前两年,除夕的前一天我们在一起玩耍,我叫他和我去我家一起过年,他拒绝了,后面被另外的两个朋友硬拉着去他们家过年。


    当时他的眼睛里面充满泪花,我从他的眼神里面看到了别人没有的坚强。


    是呀,可能在我们的世界里,过年是个再寻常不过的日子了,但是在他的世界里,过年却是一件无法奢求的事情。


    像我朋友这样情况的人还是比较多的。


    不过我的朋友,请你相信,你失去的终究会翻倍给你偿还,你得到的会加倍给你馈赠。


    ---------------


    行笔到此,心中百感交集。


    过年是中国人独有的传统,在这个日子里面,是团圆,是喜庆,是期待……


    按理这个日子应该用华丽的辞藻和温馨的言语来写。


    但是在自己经历了很多事,看到了很多现实场景的时候,我无法动笔去写空洞的句子。


    最后给“不想回家过年”,想回家过年却回不了,不敢回家过年,无家可回,无年可过的朋友们说一句,也给我自己说一句。


    这个世界总有一盏灯会为你亮着,总有一个眼神,为你等待着。


    新年快乐!


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

    一种好用的KV存储封装方案

    一、 概述 众所周知,用kotlin委托属性去封装KV存储库,可以优化数据的访问。 封装方法有多种,各有优劣。 通过反复实践,笔者摸索出一套比较好用的方案,借此文做个简单的分享。 代码已上传Github: github.com/BillyWei01/… 项目...
    继续阅读 »

    一、 概述


    众所周知,用kotlin委托属性去封装KV存储库,可以优化数据的访问。

    封装方法有多种,各有优劣。

    通过反复实践,笔者摸索出一套比较好用的方案,借此文做个简单的分享。


    代码已上传Github: github.com/BillyWei01/…

    项目中是基于SharePreferences封装的,但这套方案也适用于其他类型的KV存储框架。


    二、 封装方法


    此方案封装了两类委托:



    1. 基础类型

      基础类型包括 [boolean, int, float, long, double, String, Set<String>, Object] 等类型。

      其中,Set<String> 本可以通过 Object 类型囊括,

      但因为Set<String>是 SharePreferences 内置支持的类型,这里我们就直接内置支持了。

    2. 扩展key的基础类型

      基础类型的委托,定义属性时需传入常量的key,通过委托所访问到的是key对应的value

      而开发中有时候需要【常量+变量】的key,基础类型的委托无法实现。

      为此,方案中实现了一个 CombineKV 类。

      CombineKV通过组合[key+extKey]实现通过两级key来访问value的效果。

      此外,方案基于CombineKV封装了各种基础类型的委托,用于简化API,以及约束所访问的value的类型。


    2.1 委托实现


    基础类型BasicDelegate.kt

    扩展key的基础类型: ExtDelegate.kt


    这里举例一下基础类型中的Boolean类型的委托实现:


    class BooleanProperty(private val key: String, private val defValue: Boolean) :
    ReadWriteProperty<KVData, Boolean> {
    override fun getValue(thisRef: KVData, property: KProperty<*>): Boolean {
    return thisRef.kv.getBoolean(key, defValue)
    }

    override fun setValue(thisRef: KVData, property: KProperty<*>, value: Boolean) {
    thisRef.kv.putBoolean(key, value)
    }
    }

    class NullableBooleanProperty(private val key: String) :
    ReadWriteProperty<KVData, Boolean?> {
    override fun getValue(thisRef: KVData, property: KProperty<*>): Boolean? {
    return thisRef.kv.getBoolean(key)
    }

    override fun setValue(thisRef: KVData, property: KProperty<*>, value: Boolean?) {
    thisRef.kv.putBoolean(key, value)
    }
    }

    经典的 ReadWriteProperty 实现:

    分别重写 getValue 和 setValue 方法,方法中调用KV存储的读写API。

    由于kotlin区分了可空类型和非空类型,方案中也分别封装了可空和非空两种委托。


    2.2 基类定义


    实现了委托之后,我们将各种委托API封装到一个基类中:KVData


    abstract class KVData {
    // 存储接口
    abstract val kv: KVStore

    // 基础类型
    protected fun boolean(key: String, defValue: Boolean = false) = BooleanProperty(key, defValue)
    protected fun int(key: String, defValue: Int = 0) = IntProperty(key, defValue)
    protected fun float(key: String, defValue: Float = 0f) = FloatProperty(key, defValue)
    protected fun long(key: String, defValue: Long = 0L) = LongProperty(key, defValue)
    protected fun double(key: String, defValue: Double = 0.0) = DoubleProperty(key, defValue)
    protected fun string(key: String, defValue: String = "") = StringProperty(key, defValue)
    protected fun stringSet(key: String, defValue: Set<String> = emptySet()) = StringSetProperty(key, defValue)
    protected fun <T> obj(key: String, encoder: ObjectEncoder<T>, defValue: T) = ObjectProperty(key, encoder, defValue)

    // 可空的基础类型
    protected fun nullableBoolean(key: String) = NullableBooleanProperty(key)
    protected fun nullableInt(key: String) = NullableIntProperty(key)
    protected fun nullableFloat(key: String) = NullableFloatProperty(key)
    protected fun nullableLong(key: String) = NullableLongProperty(key)
    protected fun nullableDouble(key: String) = NullableDoubleProperty(key)
    protected fun nullableString(key: String) = NullableStringProperty(key)
    protected fun nullableStringSet(key: String) = NullableStringSetProperty(key)
    protected fun <T> nullableObj(key: String, encoder: NullableObjectEncoder<T>) = NullableObjectProperty(key, encoder)

    // 扩展key的基础类型
    protected fun extBoolean(key: String, defValue: Boolean = false) = ExtBooleanProperty(key, defValue)
    protected fun extInt(key: String, defValue: Int = 0) = ExtIntProperty(key, defValue)
    protected fun extFloat(key: String, defValue: Float = 0f) = ExtFloatProperty(key, defValue)
    protected fun extLong(key: String, defValue: Long = 0L) = ExtLongProperty(key, defValue)
    protected fun extDouble(key: String, defValue: Double = 0.0) = ExtDoubleProperty(key, defValue)
    protected fun extString(key: String, defValue: String = "") = ExtStringProperty(key, defValue)
    protected fun extStringSet(key: String, defValue: Set<String> = emptySet()) = ExtStringSetProperty(key, defValue)
    protected fun <T> extObj(key: String, encoder: ObjectEncoder<T>, defValue: T) = ExtObjectProperty(key, encoder, defValue)

    // 扩展key的可空的基础类型
    protected fun extNullableBoolean(key: String) = ExtNullableBooleanProperty(key)
    protected fun extNullableInt(key: String) = ExtNullableIntProperty(key)
    protected fun extNullableFloat(key: String) = ExtNullableFloatProperty(key)
    protected fun extNullableLong(key: String) = ExtNullableLongProperty(key)
    protected fun extNullableDouble(key: String) = ExtNullableDoubleProperty(key)
    protected fun extNullableString(key: String) = ExtNullableStringProperty(key)
    protected fun extNullableStringSet(key: String) = ExtNullableStringSetProperty(key)
    protected fun <T> extNullableObj(key: String, encoder: NullableObjectEncoder<T>) = ExtNullableObjectProperty(key, encoder)

    // CombineKV
    protected fun combineKV(key: String) = CombineKVProperty(key)
    }

    使用时,继承KVData,然后实现kv, 返回一个KVStore的实现类即可。


    举例,如果用SharedPreferences实现KVStore,可如下实现:


    class SpKV(name: String): KVStore {
    private val sp: SharedPreferences =
    AppContext.context.getSharedPreferences(name, Context.MODE_PRIVATE)
    private val editor: SharedPreferences.Editor = sp.edit()

    override fun putBoolean(key: String, value: Boolean?) {
    if (value == null) {
    editor.remove(key).apply()
    } else {
    editor.putBoolean(key, value).apply()
    }
    }

    override fun getBoolean(key: String): Boolean? {
    return if (sp.contains(key)) sp.getBoolean(key, false) else null
    }

    // ...... 其他类型
    }


    更多实现可参考: SpKV


    三、 使用方法


    object LocalSetting : KVData("local_setting") {
    override val kv: KVStore by lazy {
    SpKV(name)
    }
    // 是否开启开发者入口
    var enableDeveloper by boolean("enable_developer")

    // 用户ID
    var userId by long("user_id")

    // id -> name 的映射。
    val idToName by extNullableString("id_to_name")

    // 收藏
    val favorites by extStringSet("favorites")

    var gender by obj("gender", Gender.CONVERTER, Gender.UNKNOWN)
    }


    定义委托属性的方法很简单:



    • 和定义变量类似,需要声明变量名类型

    • 和变量声明不同,需要传入key

    • 如果要定义自定义类型,需要传入转换器(实现字符串和对象类型的转换),以及默认值


    基本类型的读写,和变量的读写一样。

    例如:


    fun test1(){
    // 写入
    LocalSetting.userId = 10001L
    LocalSetting.gender = Gender.FEMALE

    // 读取
    val uid = LocalSetting.userId
    val gender = LocalSetting.gender
    }

    读写扩展key的基本类型,则和Map的语法类似:


    fun test2() {
    if (LocalSetting.idToName[1] == null || LocalSetting.idToName[2] == null) {
    Log.d("TAG", "Put values to idToName")
    LocalSetting.idToName[1] = "Jonn"
    LocalSetting.idToName[2] = "Mary"
    } else {
    Log.d("TAG", "There are values in idToName")
    }
    Log.d("TAG", "idToName values: " +
    "1 -> ${LocalSetting.idToName[1]}, " +
    "2 -> ${LocalSetting.idToName[2]}"
    )
    }

    扩展key的基本类型,extKey是Any类型,也就是说,以上代码的[],可以传入任意类型的参数。


    四、数据隔离


    4.1 用户隔离


    不同环境(开发环境/测试环境),不同用户,最好数据实例是分开的,相互不干扰。

    比方说有 uid='001' 和 uid='002' 两个用户的数据,如果需要隔离两者的数据,有多种方法,例如:



    1. 拼接uid到key中。


      如果是在原始的SharePreferences的基础上,是比较好实现的,直接put(key+uid, value)即可;

      但是如果用委托属性定义,可以用上面定义的扩展key的类型。


    2. 拼接uid到文件名中。


      但是不同用户的数据糅合到一个文件中,对性能多少有些影响:



      • 在多用户的情况下,实例的数据膨胀;

      • 每次访问value, 都需要拼接uid到key上。


      因此,可以将不同用户的数据保存到不同的实例中。

      具体的做法,就是拼接uid到路径或者文件名上。



    基于此分析,我们定义两种类型的基类:



    • GlobalKV: 全局数据,切换环境和用户,不影响GlobalKV所访问的数据实例。

    • UserKV: 用户数据,需要同时区分 “服务器环境“ 和 ”用户ID“。


    open class GlobalKV(name: String) : KVData() {
    override val kv: KVStore by lazy {
    SpKV(name)
    }
    }

    abstract class UserKV(
    private val name: String,
    private val userId: Long
    ) : KVData() {
    override val kv: SpKV by lazy {
    // 拼接UID作为文件名
    val fileName = "${name}_${userId}_${AppContext.env.tag}"
    if (AppContext.debug) {
    SpKV(fileName)
    } else {
    // 如果是release包,可以对文件名做个md5,以便匿藏uid等信息
    SpKV(Utils.getMD5(fileName.toByteArray()))
    }
    }
    }

    UserKV实例:


    /**
    * 用户信息
    */

    class UserInfo(uid: Long) : UserKV("user_info", uid) {
    companion object {
    private val map = ArrayMap<Long, UserInfo>()

    // 返回当前用户的实例
    fun get(): UserInfo {
    return get(AppContext.uid)
    }

    // 根据uid返回对应的实例
    @Synchronized
    fun get(uid: Long): UserInfo {
    return map.getOrPut(uid) {
    UserInfo(uid)
    }
    }
    }

    var gender by intEnum("gender", Gender.CONVERTER)
    var isVip by boolean("is_vip")

    // ... 其他变量
    }

    UserKV的实例不能是单例(不同的uid对应不同的实例)。

    因此,可以定义companion对象,用来缓存实例,以及提供获取实例的API。


    保存和读取方法如下:

    先调用get()方法获取,然后其他用法就和前面描述的用法一样了。


    UserInfo.get().gender = Gender.FEMALE

    val gender = UserInfo.get().gender

    4.2 环境隔离


    有一类数据,需要区分环境,但是和用户无关。

    这种情况,可以用UserKV, 然后uid传0(或者其他的uid用不到的数值)。


    /**
    * 远程设置
    */

    object RemoteSetting : UserKV("remote_setting", 0L) {
    // 某项功能的AB测试分组
    val fun1ABTestGr0up by int("fun1_ab_test_group")

    // 服务端下发的配置项
    val setting by combineKV("setting")
    }

    五、小结


    通过属性委托封装KV存储的API,可使原来“类名 + 操作 + key”的方式,变更为“类名 + 属性”的方式,从而简化KV存储的使用。
    另外,这套方案也提到了保存不同用户数据到不同实例的演示。


    方案内容不多,但其中包含一些比较实用的技巧,希望对各位读者有所帮助。


    作者:呼啸长风
    来源:juejin.cn/post/7323449163420303370
    收起阅读 »

    java 实现后缀表达式

    一、概述 后缀表达式(也称为逆波兰表达式)是一种数学表达式的表示方法,其中操作符位于操作数的后面。这种表示法消除了括号,并且在计算机科学和计算中非常有用,因为它更容易计算和解析。 与中缀表达式(通常我们使用的数学表达式,例如"a * (b + c)")不同,后...
    继续阅读 »

    一、概述


    后缀表达式(也称为逆波兰表达式)是一种数学表达式的表示方法,其中操作符位于操作数的后面。这种表示法消除了括号,并且在计算机科学和计算中非常有用,因为它更容易计算和解析。


    与中缀表达式(通常我们使用的数学表达式,例如"a * (b + c)")不同,后缀表达式的运算符放在操作数之后,例如:“a b c + *”。后缀表达式的计算方法是从左到右遍历表达式,遇到操作数时将其压入栈,遇到操作符时从栈中弹出所需数量的操作数进行计算,然后将结果重新压入栈。这个过程一直持续到整个表达式处理完毕,最终栈中只剩下一个结果,即表达式的计算结果。


    后缀表达式具有以下优点:



    1. 不需要括号,因此消除了歧义。

    2. 更容易计算,因为遵循一定的计算顺序。

    3. 适用于计算机的堆栈操作,因此在编译器和计算器中经常使用。


    转换中缀表达式为后缀表达式需要使用算法,通常是栈数据结构。


    二、后缀表达式的运算顺序


    后缀表达式的运算顺序是从左到右遍历表达式,遇到操作数时将其压入栈,遇到操作符时从栈中弹出所需数量的操作数进行计算,然后将计算结果重新压入栈。这个过程一直持续到整个表达式处理完毕,最终栈中只剩下一个结果,即表达式的计算结果。


    后缀表达式的运算顺序是非常直观的,它遵循从左到右的顺序。当计算后缀表达式时,按照以下规则:



    1. 从左到右扫描后缀表达式中的每个元素(操作数或操作符)。

    2. 如果遇到操作数,将其推入栈。

    3. 如果遇到操作符,从栈中弹出所需数量的操作数进行计算,然后将计算结果推回栈中。

    4. 重复这个过程,直到遍历完整个后缀表达式。


    三、常规表达式转化为后缀表达式



    • 创建两个栈,一个用于操作符(操作符栈),另一个用于输出后缀表达式(输出栈)。

    • 从左到右遍历中缀表达式的每个元素。

    • 如果是操作数,将其添加到输出栈。

    • 如果是操作符:

    • 如果操作符栈为空,直接将该操作符推入操作符栈。

      否则,比较该操作符与操作符栈栈顶的操作符的优先级。如果当前操作符的优先级较高,将其推入操作符栈。

      如果当前操作符的优先级较低或相等,从操作符栈中弹出并添加到输出栈,然后重复比较直到可以推入操作符栈。

      如果遇到左括号"(“,直接推入操作符栈。

      如果遇到右括号”)“,将操作符栈中的操作符弹出并添加到输出栈,直到遇到匹配的左括号”("。

      最后,将操作符栈中的剩余操作符全部弹出并添加到输出栈。

      完成遍历后,输出栈中的内容就是中缀表达式转化为后缀表达式的结果。


    四、代码实现


    /**
    * 定义操作符的优先级
    */

    private Map<String, Integer> opList =
    Map.of("(",3,")",3,"*",2,"/",2,"+",1,"-",1);

    public List<String> getPostExp(List<String> source) {

    // 数字栈
    Stack<String> dataStack = new Stack<>();
    // 操作数栈
    Stack<String> opStack = new Stack<>();
    // 操作数集合
    for (int i = 0; i < source.size(); i++) {
    String d = source.get(i).trim();
    // 操作符的操作
    if (opList.containsKey(d)) {
    operHandler(d,opStack,dataStack);
    } else {
    // 操作数直接入栈
    dataStack.push(d);
    }
    }
    // 操作数栈中的数据,到压入到栈中
    while (!opStack.isEmpty()) {
    dataStack.push(opStack.pop());
    }
    List<String> result = new ArrayList<>();
    while (!dataStack.isEmpty()) {
    String pop = dataStack.pop();
    result.add(pop);
    }
    // 对数组进行翻转
    return CollUtil.reverse(result);
    }

    /**
    * 对操作数栈的操作
    * @param d,当前操作符
    * @param opStack 操作数栈
    */

    private void operHandler(String d, Stack<String> opStack,Stack<String> dataStack) {
    // 操作数栈为空
    if (opStack.isEmpty()) {
    opStack.push(d);
    return;
    }
    // 如果遇到左括号"(“,直接推入操作符栈。
    if (d.equals("(")) {
    opStack.push(d);
    return;
    }
    // 如果遇到右括号”)“,将操作符栈中的操作符弹出并添加到输出栈,直到遇到匹配的左括号”("。
    if (d.equals(")")) {
    while (!opStack.isEmpty()) {
    String pop = opStack.pop();
    // 不是左括号
    if (!pop.equals("(")) {
    dataStack.push(pop);
    } else {
    return;
    }
    }
    }
    // 操作数栈不为空
    while (!opStack.isEmpty()) {
    // 获取栈顶元素和优先级
    String peek = opStack.peek();
    Integer v = opList.get(peek);
    // 获取当前元素优先级
    Integer c = opList.get(d);
    // 如果当前操作符的优先级较低或相等,且不为(),从操作符栈中弹出并添加到输出栈,然后重复比较直到可以推入操作符栈
    if (c < v && v != 3) {
    // 出栈
    opStack.pop();
    // 压入结果集栈
    dataStack.push(peek);
    } else {
    // 操作符与操作符栈栈顶的操作符的优先级。如果当前操作符的优先级较高,将其推入操作符栈。
    opStack.push(d);
    break;
    }
    }
    }

    测试代码如下:


    PostfixExpre postfixExpre = new PostfixExpre();

    List<String> postExp = postfixExpre.getPostExp(
    Arrays.asList("9", "+", "(" , "3", "-", "1", ")", "*", "3", "+", "10", "/", "2"));

    System.out.println(postExp);

    输出如下:


    [9, 3, 1, -, 3, *, 10, 2, /, +, +]


    五、求后缀表示值


    使用栈来实现


        /****
    * 计算后缀表达式的值
    * @param source
    * @return
    */

    public double calcPostfixExpe(List<String> source) {

    Stack<String> data = new Stack<>();
    for (int i = 0; i < source.size(); i++) {
    String s = source.get(i);
    // 如果是操作数
    if (opList.containsKey(s)) {
    String d2 = data.pop();
    String d1 = data.pop();
    Double i1 = Double.valueOf(d1);
    Double i2 = Double.valueOf(d2);
    Double result = null;
    switch (s) {
    case "+":
    result = i1 + i2;break;
    case "-":
    result = i1 - i2;break;
    case "*":
    result = i1 * i2;break;
    case "/":
    result = i1 / i2;break;
    }
    data.push(String.valueOf(result));
    } else {
    // 如果是操作数,进栈操作
    data.push(s);
    }
    }
    // 获取结果
    String pop = data.pop();
    return Double.valueOf(pop);
    }

    测试


    PostfixExpre postfixExpre = new PostfixExpre();

    List<String> postExp = postfixExpre.getPostExp(
    Arrays.asList("9", "+", "(" , "3", "-", "1", ")", "*", "3", "+", "10", "/", "2"));

    System.out.println(postExp);

    double v = postfixExpre.calcPostfixExpe(postExp);

    System.out.println(v);

    结果如下:


    [9, 3, 1, -, 3, *, 10, 2, /, +, +]
    20.0

    作者:小希爸爸
    来源:juejin.cn/post/7330583100059762697
    收起阅读 »

    我发现了 Android 指纹认证 Api 内存泄漏

    我发现了 Android 指纹认证 Api 内存泄漏 目前很多市面上的手机基本都有指纹登陆功能。Google 也提供了调用相关功能 API,安全类的App 也基本都在使用。接下来就一起捋一捋今天的主角 BiometricPrompt 先说问题,使用Biome...
    继续阅读 »

    我发现了 Android 指纹认证 Api 内存泄漏


    目前很多市面上的手机基本都有指纹登陆功能。Google 也提供了调用相关功能 API,安全类的App 也基本都在使用。接下来就一起捋一捋今天的主角 BiometricPrompt


    先说问题,使用BiometricPrompt 会造成内存泄漏,目前该问题试了 Android 11 到 13 都发生,而且没有什么好的办法。目前想到的最好的方法是漏的少一点。当然谁有好的办法欢迎留言。


    问题再现


    先看动画


    在这里插入图片描述


    动画中操作如下



    1. MainAcitivity 跳转到 SecondActivity

    2. SecondActivity 调用 BiometricPrompt 三次

    3. 从SecondActivity 返回到 MainAcitivity


    以下是使用 BiometricPrompt 的代码


    public fun showBiometricPromptDialog() {
    val keyguardManager = getSystemService(
    Context.KEYGUARD_SERVICE
    ) as KeyguardManager;

    if (keyguardManager.isKeyguardSecure) {
    var biometricPromptBuild = BiometricPrompt.Builder(this).apply {// this is SecondActivity
    setTitle("verify")
    setAllowedAuthenticators(BiometricManager.Authenticators.DEVICE_CREDENTIAL or BiometricManager.Authenticators.BIOMETRIC_WEAK)
    }
    val biometricPromp = biometricPromptBuild.build()
    biometricPromp.authenticate(CancellationSignal(), mExecutor, object :
    BiometricPrompt.AuthenticationCallback() {

    })
    }
    else {
    Log.d("TAG", "showLockScreen: isKeyguardSecure is false");
    }
    }

    以上逻辑 biometricPromp 是局部变量,应该没有问题才对。


    内存泄漏如下


    在这里插入图片描述
    可以看到每启动一次生物认证,创建的 BiometricPrompt 都不会被回收。


    规避方案:


    修改方案也简单


    方案一:



    1. biometricPromp 改为全局变量。

    2. this 改为 applicationContext


    方案一存在的问题,SecondActivity 可能频繁创建,所以 biometricPromp 还会存在多个实例。


    方案二(目前想到的最优方案):



    1. biometricPromp 改为单例

    2. this 改为 applicationContext


    修改后,App memory 中只存在一个 biometricPromp ,且没有 Activity 被泄漏。


    想到这里,应该会觉得奇怪,biometricPromp 为什么不会被回收?提供的 API 都看过了,没有发现什么方法可以解决这个问题。直觉告诉我这个可能是系统问题,下来分析下BiometricPrompt 吧。


    BiometricPrompt 源码分析


    在这里插入图片描述


    App 相关信息通过 BiometricPrompt 传递到 System 进程,System 进程再通知 SystemUI 显示认证界面。


    App 信息传递到 System 进程,应该会使用 Binder。这个查找 BiometricPrompt 使用哪些 Binder。


    private final IBiometricServiceReceiver mBiometricServiceReceiver =
    new IBiometricServiceReceiver.Stub() {

    ......
    }

    源码中发现 IBiometricServiceReceiver 比较可疑,IBiometricServiceReceiver 是匿名内部类,内部是持有 BiometricPrompt 对象的引用。


    接下来看下 System Server 进程信息(注:系统是 UserDebug 的手机,才可以查看,买的手机版本是不支持的)


    在这里插入图片描述



    😂 App 使用优化后(方案二)App 只存在一个 IBiometricServiceReceiver ,而 system 进程中存在三个 IBiometricServiceReceiver 的 binder proxy。 每次启动 BiometricPrompt 都会创建一个。这个就不解释为什么会出现三个binder proxy,感兴趣可以看下面推荐的文章。GC root 是 AuthSession。

    再看下 AuthSession 的实例数


    在这里插入图片描述


    果然 AuthSession 也存在三个。


    在这里插入图片描述


    这里有个知识点,binder 也是有生命周期的,三个 Proxy 这篇文章也是解释了的。有兴趣的可以了看下。


    Binder | 对象的生命周期


    一开始,我以为 AuthSession 没有被置空,看下代码,发现 AOSP 的代码,还是比较严谨的,有置空的操作。


    细心的同学发现,上图中 AuthSession 没有被任何对象引用,AuthSession 就是 GC Root,哈哈哈。


    问题解密


    一个实例什么情况可以作为GC Root,有兴趣的同学,可以自行百度,这里就不卖关子了,直接说问题吧。


    Binder.linkToDeath()


    public void linkToDeath(@NonNull DeathRecipient recipient, int flags) {
    }

    需要传递 IBinder.DeathRecipient ,这个 DeathRecipient 会被作为 GC root。当调用 unlinkToDeath(@NonNull DeathRecipient recipient, int flags),GC root 才被收回。


    AuthSession 初始化的时候,会调用 IBiometricServiceReceiver .linkToDeath。


    public final class AuthSession implements IBinder.DeathRecipient {
    AuthSession(@NonNull Context context,
    ......
    @NonNull IBiometricServiceReceiver clientReceiver,
    ......
    ) {
    Slog.d(TAG, "Creating AuthSession with: " + preAuthInfo);
    ......
    try {
    mClientReceiver.asBinder().linkToDeath(this, 0 /* flags */);//this 变成 GC root
    } catch (RemoteException e) {
    Slog.w(TAG, "Unable to link to death");
    }

    setSensorsToStateUnknown();
    }
    }

    Jni 中 通过 env->NewGlobalRef(object),告诉虚拟机 AuthSession 是 GC Root。


    core/jni/android_util_Binder.cpp

    static void android_os_BinderProxy_linkToDeath(JNIEnv* env, jobject obj,
    jobject recipient, jint flags)
    // throws RemoteException
    {
    if (recipient == NULL) {
    jniThrowNullPointerException(env, NULL);
    return;
    }

    BinderProxyNativeData *nd = getBPNativeData(env, obj);
    IBinder* target = nd->mObject.get();

    LOGDEATH("linkToDeath: binder=%p recipient=%p\n", target, recipient);

    if (!target->localBinder()) {
    DeathRecipientList* list = nd->mOrgue.get();
    sp<JavaDeathRecipient> jdr = new JavaDeathRecipient(env, recipient, list);//java 中 DeathRecipient 会被封装为 JavaDeathRecipient
    status_t err = target->linkToDeath(jdr, NULL, flags);
    if (err != NO_ERROR) {
    // Failure adding the death recipient, so clear its reference
    // now.
    jdr->clearReference();
    signalExceptionForError(env, obj, err, true /*canThrowRemoteException*/);
    }
    }
    }

    JavaDeathRecipient(JNIEnv* env, jobject object, const sp<DeathRecipientList>& list)
    : mVM(jnienv_to_javavm(env)), mObject(env->NewGlobalRef(object)),// object -> DeathRecipient 变为 GC root
    mObjectWeak(NULL), mList(list)
    {
    // These objects manage their own lifetimes so are responsible for final bookkeeping.
    // The list holds a strong reference to this object.
    LOGDEATH("Adding JDR %p to DRL %p", this, list.get());
    list->add(this);

    gNumDeathRefsCreated.fetch_add(1, std::memory_order_relaxed);
    gcIfManyNewRefs(env);
    }

    unlinkToDeath 最终会在 Jni 中 通过 env->DeleteGlobalRef(mObject),告诉虚拟机 AuthSession 不是GC root。


    virtual ~JavaDeathRecipient()
    {
    //ALOGI("Removing death ref: recipient=%p\n", mObject);
    gNumDeathRefsDeleted.fetch_add(1, std::memory_order_relaxed);
    JNIEnv* env = javavm_to_jnienv(mVM);
    if (mObject != NULL) {
    env->DeleteGlobalRef(mObject);// object -> DeathRecipient GC root 被撤销
    } else {
    env->DeleteWeakGlobalRef(mObjectWeak);
    }
    }

    解决方式


    AuthSession 置空的时候调用 IBiometricServiceReceiver 的 unlinkToDeath 方法。


    总结


    以上梳理的其实就是 Binder 的造成的内存泄漏。


    问题严重性来看,也不算什么大问题,因为调用 BiometricPrompt 的进程被杀,system 进程相关实例也就回收释放了。一般 app 也不太可能出现,常驻进程,而且还频繁调用手机认证的。


    这里主要介绍了一种容易被忽略的内存泄漏,Binder.linktoDeath()。
    Google issuetracker


    参考资料


    Binder | 对象的生命周期


    作者:Jingle_zhang
    来源:juejin.cn/post/7202066794299129914
    收起阅读 »

    曹贼,莫要动‘我’网站 —— MutationObserver

    web
    前言 本篇主要讲述了如何禁止阻止控制台调试以及使用了MutationObserver构造方法禁止修改DOM样式。 正文 话说在三国时期,大都督周瑜为了向世人宣布自己盛世容颜的妻子小乔,并专门创建了一个网站来展示自己的妻子 这么好看的看的小乔,谁看谁不糊,更何...
    继续阅读 »

    前言


    本篇主要讲述了如何禁止阻止控制台调试以及使用了MutationObserver构造方法禁止修改DOM样式。


    正文


    话说在三国时期,大都督周瑜为了向世人宣布自己盛世容颜的妻子小乔,并专门创建了一个网站来展示自己的妻子


    image.png
    这么好看的看的小乔,谁看谁不糊,更何况曹老板。这天,曹操在浏览网页的时候,无意间发现了周瑜的这个网站,看着美若天仙的小乔,曹操的眼泪止不住的从嘴角流了下来。赶紧将网站上的照片保存了下来。

    这个消息最后传到了周瑜的耳朵里,他只是想展示小乔,可不是为了让别人下载的。于是在自己的网站上做了一些预防措施。

    为了防止他人直接在网站上直接下载图片,周瑜将右键的默认事件给关闭了,并且为了防止有人打开控制台,并对图片保存,采取了以下方法:


    禁用右键和F12键


    //给整个document添加右击事件,并阻止默认行为
    document.addEventListener("contextmenu", function (e) {
    e.preventDefault();
    return false;
    });

    //给整个页面禁用f12按键 keyCode即将被禁用 不再推荐使用 但仍可以使用
    document.addEventListener("keydown", function (e) {
    //当点了f3\f6\f10之后,即使禁用了f12键依旧可以打开控制台,所以一并禁用
    if (
    [115, 118, 121, 123].includes(e.keyCode) ||
    ["F3", "F6", "F10", "F12"].includes(e.key) ||
    ["F3", "F6", "F10", "F12"].includes(e.code) ||
    //ctrl+f 效果和f3效果一样 点开搜索之后依旧可以点击f12 打开控制台 所以一并禁用
    //缺点是此网站不再能够 **全局搜索**
    (e.ctrlKey && (e.key == "f" || e.code == "KeyF" || e.keyCode == 70))||
    //禁用专门用于打开控制台的组合键
    (e.ctrlKey && e.shiftKey && (e.key == "i" || e.code == "KeyI" || e.keyCode == 73))
    ) {
    e.preventDefault();
    return false;
    }
    });

    当曹操再次想保存小乔照片的时候,发现使用网页的另存了已经没用了。这能难倒曹老板吗,破解方法,在浏览器的右上角进行操作就可打开控制台,这个地方是浏览器自带的,没办法禁用


    image.png
    这番操作之后,曹操可以选择元素保存那个图片了。周瑜的得知了自己的禁用措施被破解后,赶忙连夜加班打补丁,于是又加了一些操作,禁止打开控制台后进行操作


    禁用控制台


    如何判定控制台被打开了,可以使用窗口大小来判定


    function resize() {
    var threshold = 100;
    //窗口的外部减窗口内超过100就判定窗口被打开了
    var widthThreshold = window.outerWidth - window.innerWidth > threshold;
    var heightThreshold = window.outerHeight - window.innerHeight > threshold;
    if (widthThreshold || heightThreshold) {
    console.log("控制台打开了");
    }
    }
    window.addEventListener("resize", resize);

    但是也容易被破解,只要让控制台变成弹窗窗口就可以了


    也可以使用定时器进行无限debugger,因为只有在控制台打开的时候debugger才会生效。关闭控制台的时候,并不会影响功能。当前网页内存占用比较大的时候,定时器的占用并不明显。在当前网页占用比较小的时候,一直开着定时器才会有较为明显的提升


      setInterval(() => {
    (function () {})["constructor"]("debugger")();
    }, 500);

    破解方法一样有,在debugger的位置右键禁用调试就可以了。这样控制台就可以正常操作了


    image.png
    既然有方法破解,就还要做一层措施,既然是要保存图片,那就把img转成canvas,这样即使打开控制台也没办法进行对图片的保存


    //获取dom
    const img = document.querySelector(".img");
    const canvas = document.querySelector("#canvas");
    //img转成canvas
    canvas.width = img.width;
    canvas.height = img.height;
    ctx = canvas.getContext("2d");
    ctx.drawImage(img, 0, 0, img.width, img.height);
    document.body.removeChild(img);

    经过一夜的努力,该加的措施都加上了。周瑜心想这下就没办法保存我的小乔了吧。

    来到曹操这边,再次打开周瑜的小破站,还想故技重施时,发现已经有了各种显示,最后也没难倒曹操,那些阻碍也都被破解了。但是到保存图片的时候傻眼了,竟然已经不是图片格式了,那就没办法下载了呀。但是小乔真的很养神,曹操心有不甘,于是使用了最后一招,既然没办法下载那就截图,虽然有损画质,但是依旧能看。


    得知如此情况的大都督周瑜不淡定了,从未见过如此厚颜无耻之人,竟然使用截图。


    006APoFYly1g2qcclw1frg308w06ox2t.gif
    话说魔高一尺,道高一丈,周瑜再次熬夜加班进行对网站的优化。于是使用了全屏水印+MutationObserver监听水印dom的方法。即使截图也让他看着不舒服。


    MutationObserver


    MutationObserver是一个构造函数,接口提供了监视对 DOM 树所做更改的能力。它被设计为旧的 Mutation Events 功能的替代品,该功能是 DOM3 Events 规范的一部分。

    它接收一个回调函数,每当监听的dom发生改变时,就会调用这个函数,函数传入一个参数,数组包对象的格式,里面记录着dom的变化以及dom的信息。


    image.png
    返回的实例是一个新的、包含监听 DOM 变化回调函数的 MutationObserver 对象。有三个方法observedisconnecttakeRecords



    • observe接收两个参数,第一个为要监听的dom元素,第二个则是一些配置对象,当调用 observe() 时,childListattributes 和 characterData 中,必须有一个参数为 true。否则会抛出 TypeError 异常。配置对象如下:

      • subtree:当为 true 时,将会监听以 target 为根节点的整个子树。包括子树中所有节点的属性,而不仅仅是针对 target。默认值为 false

      • childList:当为 true 时,监听 target 节点中发生的节点的新增与删除(同时,如果 subtree 为 true,会针对整个子树生效)。默认值为 false

      • attributes:当为 true 时观察所有监听的节点属性值的变化。默认值为 true,当声明了 attributeFilter 或 attributeOldValue,默认值则为 false

      • attributeFilter:一个用于声明哪些属性名会被监听的数组。如果不声明该属性,所有属性的变化都将触发通知。

      • attributeOldValue:当为 true 时,记录上一次被监听的节点的属性变化;可查阅监听属性值了解关于观察属性变化和属性值记录的详情。默认值为 false

      • characterDate:当为 true 时,监听声明的 target 节点上所有字符的变化。默认值为 true,如果声明了 characterDataOldValue,默认值则为 false

      • characterDateOldValue:当为 true 时,记录前一个被监听的节点中发生的文本变化。默认值为 false



    • disconnect方法用来停止观察(当被观察dom节点被删除后,会自动停止对该dom的观察),不接受任何参数

    • takeRecords:方法返回已检测到但尚未由观察者的回调函数处理的所有匹配 DOM 更改的列表,使变更队列保持为空。此方法最常见的使用场景是在断开观察者之前立即获取所有未处理的更改记录,以便在停止观察者时可以处理任何未处理的更改。


    该构造函数监听的dom即使在控制台中被更改属性或值,也会被监听到。


    使用MutationObserver对水印dom进行监听,并限制更改。


    <style>
    //定义水印的样式
    #watermark {
    width: 100vw;
    height: 100vh;
    position: absolute;
    left: 0;
    top: 0;
    font-size: 34px;
    color: #32323238;
    font-weight: 700;
    display: flex;
    flex-wrap: wrap;
    justify-content: space-evenly;
    align-content: space-evenly;
    z-index: 9999999;
    }
    #watermark span {
    transform: rotate(45deg);
    }
    </style>

    <script>
    //获取水印dom
    const watermark = document.querySelector("#watermark");
    //克隆水印dom ,用作后备,永远不要改变
    const _watermark = watermark.cloneNode(true);
    //获取水印dom的父节点
    const d = watermark.parentNode;
    //获取水印dom的后一个节点
    let referenceNode;
    [...d.children].forEach((item, index) => {
    if (item == watermark) referenceNode = d.children[index + 1];
    });
    //定义MutationObserver实例observe方法的配置对象
    const prop = {
    childList: true,//针对整个子树
    attributes: true,//属性变化
    characterData: true,//监听节点上字符变化
    subtree: true,//监听以target为根节点的整个dom树
    };
    //定义MutationObserver
    const observer = new MutationObserver(function (mutations) {
    //在这里每次坚挺的dom发生改变时 都会运行,传入的参数为数组对象格式
    mutations.forEach((item) => {
    //这里可以只针对监听dom的样式来判断
    if (item.attributeName === "style") {
    //获取父节点的所有子节点,因为时伪数组,使用扩展运算符转以下
    [...d.children].forEach((v) => {
    //判断一下,是父节点里的那个节点被改变了,并且删除那个被改变的节点(也就是删除水印节点)
    if (item.target.id && v == document.querySelector(`#${item.target.id}`)) {
    v.remove();
    }
    });
    //原水印节点被删除了,这里使用克隆的水印节点,再次克隆
    const __watermark = _watermark.cloneNode(true);
    //这里的this指向是MutationObserver的实例对象,所以同样可以使用observe监听dom
    //监听第二次克隆的dom
    this.observe(__watermark, prop);
    //因为水印dom被删除了,再将克隆的水印dom添加到原来的位置 就是referenceNode节点的前面
    d.insertBefore(__watermark, referenceNode);
    }
    });
    });
    在初始化的时候监听初始化的水印dom
    observer.observe(watermark, prop);
    </script>



    这样,每当对水印dom进行更改样式的时候,就会删除该节点,并重新添加一个初始的水印dom,即使突破重重困难打开开控制台,用户也是无法对dom 进行操作。


    视频转Gif_爱给网_aigei_com.gif


    隔天曹操再次打开网页,发现网页上的水印,心里不足为惧,心想区区水印能难倒自己?操作到最后却发现,不论如何对水印dom进行操作,都无法改变样式。虽说只是为了保存图片,但是截图有着这样水印,任谁也不舒服呀。曹操大怒,刚吃了两口的饭啪的一下就盖在了桌子上......


    20230508094549_33500.gif
    然而曹操不知道的是,在控制台中,获取dom节点右键是可以只下载获取的那个节点的......


    image.png


    结尾


    文章主要是以鬼畜恶搞的方式讲述了,如何禁止用户打开控制台(还有重写toSring,consloe.log等一些方法,但我并没有没有实现,所以这里并没有写上),并且如何使用MutationObserver构造函数来监听页面中的dom元素。其实大多情况下并没有这方面的项目需求,完全可以当扩展知识看了。


    写的不好的地方可以提出意见,虚心请教!


    作者:iceCode
    来源:juejin.cn/post/7290862554657423396
    收起阅读 »

    什么是Spring Boot中的@Async

    异步方法 随着硬件和软件的高度发展,现代应用变得更加复杂和要求更高。由于 高需求,工程师总是试图寻找新的方法来提高应用程序性能和响应能力。慢节奏应用程序的一种解决方案是实施异步方法。异步处理是一种执行任务并发运行的进程或函数,无需等待一个任务完成后再开始另一个...
    继续阅读 »

    异步方法


    随着硬件和软件的高度发展,现代应用变得更加复杂和要求更高。由于

    高需求,工程师总是试图寻找新的方法来提高应用程序性能和响应能力。慢节奏应用程序的一种解决方案是实施异步方法。异步处理是一种执行任务并发运行的进程或函数,无需等待一个任务完成后再开始另一个任务。在本文中,我将尝试探索 Spring Boot 中的异步方法和 @Async 注解,试图解释多线程和并发之间的区别,以及何时使用或避免它。


    Spring中的@Async是什么?


    Spring 中的 @Async 注解支持方法调用的异步处理。它指示框架在单独的线程中执行该方法,允许调用者继续执行而无需等待该方法完成。这

    提高了应用程序的整体响应能力和吞吐量。


    要使用@Async,您必须首先通过将@EnableAsync注释添加到配置类来在应用程序中启用异步处理:


    @Configuration
    @EnableAsync
    public class AppConfig {
    }

    接下来,用@Async注解来注解你想要异步执行的方法:



    @Service
    public class AsyncService {
    @Async
    public void asyncMethod() {
    // Perform time-consuming task
    }
    }

    @Async 与多线程和并发有何不同?


    有时,区分多线程和并发与并行执行可能会让人感到困惑,但是,两者都与并行执行相关。他们每个人都有自己的用例和实现:



    • @Async 注解是 Spring 框架特定的抽象,它支持异步执行。它提供了轻松使用异步的能力,在后台处理所有艰苦的工作,例如线程创建、管理和执行。这使用户能够专注于业务逻辑而不是底层细节。

    • 多线程是一个通用概念,通常指操作系统或程序同时管理多个线程的能力。由于 @Async 帮助我们自动完成所有艰苦的工作,在这种情况下,我们可以手动处理所有这些工作并创建一个多线程环境。 Java 具有ThreadExecutorService等必要的类来创建和使用多线程。

    • 并发是一个更广泛的概念,它涵盖多线程和并行执行技术。它是

      系统在一个或多个处理器上同时执行多个任务的能力。


    综上所述,@Async是一种更高层次的抽象,它为开发人员简化了异步处理,而多线程和并发更多的是手动管理并行执行。


    何时使用 @Async 以及何时避免它。


    使用异步方法似乎非常直观,但是,必须考虑到这种方法也有注意事项。


    在以下情况下使用@Async:



    • 您拥有可以并发运行的独立且耗时的任务,而不会影响应用程序的响应能力。

    • 您需要一种简单而干净的方法来启用异步处理,而无需深入研究低级线程管理。


    在以下情况下避免使用 @Async:



    • 您想要异步执行的任务具有复杂的依赖性或需要大量的协调。在这种情况下,您可能需要使用更高级的并发 API,例如CompletableFuture或反应式编程库,例如 Project Reactor。

    • 您必须精确控制线程的管理方式,例如自定义线程池或高级同步机制。在这些情况下,请考虑使用 Java 的ExecutorService或其他并发实用程序。


    在 Spring Boot 应用程序中使用 @Async。


    在此示例中,我们将创建一个简单的 Spring Boot 应用程序来演示 @Async 的使用。

    让我们创建一个简单的订单管理服务。



    1. 创建一个具有最低依赖要求的新 Spring Boot 项目:


      org.springframework.boot:spring-boot-starter

      org.springframework.boot:spring-boot-starter-web

      Web 依赖用于 REST 端点演示目的。 @Async 带有引导启动程序。


    2. 将 @EnableAsync 注释添加到主类或应用程序配置类(如果我们使用它):


    @SpringBootApplication
    @EnableAsync
    public class AsyncDemoApplication {
    public static void main(String[] args) {
    SpringApplication.run(AsyncDemoApplication.class, args);
    }
    }

    @Configuration
    @EnableAsync
    public class ApplicationConfig {}


    1. 对于最佳解决方案,我们可以做的是,创建一个自定义 Executor bean 并根据我们的需要在同一个 Configuration 类中对其进行自定义:


       @Configuration
    @EnableAsync
    public class ApplicationConfig {

    @Bean
    public Executor getAsyncExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(5);
    executor.setMaxPoolSize(10);
    executor.setQueueCapacity(100);
    executor.setThreadNamePrefix("");
    executor.initialize();
    return executor;
    }
    }

    通过此配置,我们可以控制最大和默认线程池大小。以及其他有用的定制。



    1. 使用 @Async 方法创建 OrderService 类:


    @Service
    public class OrderService {

    @Async
    public void saveOrderDetails(Order order) throws InterruptedException {
    Thread.sleep(2000);
    System.out.println(order.name());
    }

    @Async
    public CompletableFuture<String> saveOrderDetailsFuture(Order order) throws InterruptedException {
    System.out.println("Execute method with return type + " + Thread.currentThread().getName());
    String result = "Hello From CompletableFuture. Order: ".concat(order.name());
    Thread.sleep(5000);
    return CompletableFuture.completedFuture(result);
    }

    @Async
    public CompletableFuture<String> compute(Order order) throws InterruptedException {
    String result = "Hello From CompletableFuture CHAIN. Order: ".concat(order.name());
    Thread.sleep(5000);
    return CompletableFuture.completedFuture(result);
    }
    }

    我们在这里所做的是创建 3 种不同的异步方法。第一个saveOrderDetails服务是一个简单的异步

    服务,它将开始异步计算。如果我们想使用现代异步Java功能,

    例如CompletableFuture,我们可以通过服务来实现saveOrderDetailsFuture。通过这个服务,我们可以调用一个线程来等待@Async的结果。应该注意的是,CompletableFuture.get()在结果可用之前会阻塞。如果我们想在结果可用时执行进一步的异步操作,我们可以使用thenApplythenAccept或 CompletableFuture 提供的其他方法。



    1. 创建一个 REST 控制器来触发异步方法:


    @RestController
    public class AsyncController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
    this.orderService = orderService;
    }

    @PostMapping("/process")
    public ResponseEntity<Void> process(@RequestBody Order order) throws InterruptedException {
    System.out.println("PROCESSING STARTED");
    orderService.saveOrderDetails(order);
    return ResponseEntity.ok(null);
    }

    @PostMapping("/process/future")
    public ResponseEntity<String> processFuture(@RequestBody Order order) throws InterruptedException, ExecutionException {
    System.out.println("PROCESSING STARTED");
    CompletableFuture<String> orderDetailsFuture = orderService.saveOrderDetailsFuture(order);
    return ResponseEntity.ok(orderDetailsFuture.get());
    }

    @PostMapping("/process/future/chain")
    public ResponseEntity<Void> processFutureChain(@RequestBody Order order) throws InterruptedException, ExecutionException {
    System.out.println("PROCESSING STARTED");
    CompletableFuture<String> computeResult = orderService.compute(order);
    computeResult.thenApply(result -> result).thenAccept(System.out::println);
    return ResponseEntity.ok(null);
    }
    }

    现在,当我们访问/process端点时,服务器将立即返回响应,同时

    继续saveOrderDetails()在后台执行。 2秒后,服务完成。第二个端点 -/process/future将使用我们的第二个选项,CompletableFuture在这种情况下,5 秒后,服务将完成,并将结果存储在CompletableFuture我们可以进一步使用future.get()来访问结果。在最后一个端点 - 中/process/future/chain,我们优化并使用了异步计算。控制器使用相同的服务方法CompletableFuture,但不久之后,我们将使用thenApply,thenAccept方法。服务器立即返回响应,我们不需要等待5秒,计算将在后台完成。在这种情况下,最重要的一点是对异步服务的调用,在我们的例子中compute()必须从同一类的外部完成。如果我们在一个方法上使用@Async并在同一个类中调用它,它将不起作用。这是因为Spring使用代理来添加异步行为,并且在内部调用方法会绕过代理。为了使其发挥作用,我们可以:



    • 将 @Async 方法移至单独的服务或组件。

    • 使用 ApplicationContext 获取代理并调用其上的方法。


    总结


    Spring 中的 @Async 注解是在应用程序中启用异步处理的强大工具。通过使用@Async,我们不需要陷入并发管理和多线程的复杂性来增强应用程序的响应能力和性能。但要决定何时使用 @Async 或使用替代并发

    使用程序,了解其局限性和用例非常重要。


    作者:it键盘侠
    来源:juejin.cn/post/7330227149176881161
    收起阅读 »

    前端实现 word 转 png

    web
    在此之前 word 转图片的需求都是在后端实现的,用的是 itext 库,但是 itext 收费的,商用需要付费。近期公司内部安全部门发出侵权警告,要求整改。 所以采用前端实现 word 文档转图片功能。 一、需求 用户在页面上上传 .docx 格式的文件...
    继续阅读 »

    在此之前 word 转图片的需求都是在后端实现的,用的是 itext 库,但是 itext 收费的,商用需要付费。近期公司内部安全部门发出侵权警告,要求整改。


    所以采用前端实现 word 文档转图片功能。



    一、需求



    1. 用户在页面上上传 .docx 格式的文件

    2. 前端拿到文件,解析并生成 .png 图片

    3. 上传该图片到文件服务器,并将图片地址作为缩略图字段


    二、难点


    目前来看,前端暂时无法直接实现将 .docx 文档转成图片格式的需求


    三、解决方案


    既然直接转无法实现,那就采用迂回战术



    1. 先转成 html(用到库 docx-preview

    2. 再将 html 转成 canvas(用到库 html2canvas

    3. 最后将 canvas 转成 png


    四、实现步骤




    1. .docx 文件先转成 html 格式,并插入到目标节点中


      安装 docx-preview 依赖: pnpm add docx-preview --save




    jsx
    复制代码
    import { useEffect } from 'react';
    import * as docx from 'docx-preview';

    export default ({ file }) => {
    useEffect(() => {
    // file 为上传好的 docx 格式文件
    docx2Html(file);
    }, [file]);

    /**
    * @description: docx 文件转 html
    * @param {*} file: docx 格式文件
    * @return {*}
    */
    const docx2Html = file => {
    if (!file) {
    return;
    }
    // 只处理 docx 文件
    const suffix = file.name?.substr(file.name.lastIndexOf('.') + 1).toLowerCase();
    if (suffix !== 'docx') {
    return;
    }
    // 生成 html 后挂载的 dom 节点
    const htmlContentDom = document.querySelector('#htmlContent');
    const docxOptions = Object.assign(docx.defaultOptions, {
    debug: true,
    experimental: true,
    });
    docx.renderAsync(file, htmlContentDom, null, docxOptions).then(() => {
    console.log('docx 转 html 完成');
    });
    };

    return <div id='htmlContent' />;
    };

    此时,在 idhtmlContent 的节点下,就可以看到转换后的 html 内容了( htmlContent 节点的宽高等 css 样式自行添加)




    1. html 转成 canvas


      安装 html2canvas 依赖: pnpm add html2canvas --save




    jsx
    复制代码
    import html2canvas from 'html2canvas';

    /**
    * @description: dom 元素转为图片
    * @return {*}
    */
    const handleDom2Img = async () => {
    // 生成 html 后挂载的 dom 节点
    const htmlContentDom = document.querySelector('#htmlContent');
    // 获取刚刚生成的 dom 元素
    const htmlContent = htmlContentDom.querySelectorAll('.docx-wrapper>section')[0];
    // 创建 canvas 元素
    const canvasDom = document.createElement('canvas');
    // 获取 dom 宽高
    const w = parseInt(window.getComputedStyle(htmlContent).width, 10);
    // const h = parseInt(window.getComputedStyle(htmlContent).height, 10);

    // 设定 canvas 元素属性宽高为 DOM 节点宽高 * 像素比
    const scale = window.devicePixelRatio; // 缩放比例
    canvasDom.width = w * scale; // 取文档宽度
    canvasDom.height = w * scale; // 缩略图是正方形,所以高度跟宽度保持一致

    // 按比例增加分辨率,将绘制内容放大对应比例
    const canvas = await html2canvas(htmlContent, {
    canvas: canvasDom,
    scale,
    useCORS: true,
    });
    return canvas;
    };


    1. 将生成好的 canvas对象转成 .png 文件,并下载


    jsx
    复制代码
    // 将 canvas 转为 base64 图片
    const base64Str = canvas.toDataURL();

    // 下载图片
    const imgName = `图片_${new Date().valueOf()}`;
    const aElement = document.createElement('a');
    aElement.href = base64Str;
    aElement.download = `${imgName}.png`;
    document.body.appendChild(aElement);
    aElement.click();
    document.body.removeChild(aElement);
    window.URL.revokeObjectURL(base64Str);

    五、总结


    前端无法直接实现将 .docx 文档转成图片格式,所以要先将 .docx 文档转换成 html 格式,并插入页面文档节点中,然后根据 html 内容生成canvas对象,最后将 canvas对象转成 .png 文件


    有以下两个缺点:



    1. 只能转 .docx 格式的 word 文档,暂不支持 .doc 格式;

    2. 无法自动获取文档第一页来生成图片内容,需要先将 word 所有页面生成为 html,再通过 canvas 手动裁切,来确定图片宽高。

    作者:Tim_
    链接:https://juejin.cn/post/7331799381896151067
    来源:稀土掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    收起阅读 »

    Android-桌面小组件RemoteViews播放动画

    一、前言 前段时间什么比较火?当然是木鱼了,木鱼一敲,烦恼全消~在这个节奏越来越快的社会上,算是一个不错的解压利器! 我们也紧跟时事,推出了  我要敲木鱼(各大市场均可以下载哦~) 咳咳,扯远了,说回正题 我们在后台收到大量反馈,说是希望添加桌面组件敲木鱼功能...
    继续阅读 »

    一、前言


    前段时间什么比较火?当然是木鱼了,木鱼一敲,烦恼全消~在这个节奏越来越快的社会上,算是一个不错的解压利器!


    我们也紧跟时事,推出了  我要敲木鱼(各大市场均可以下载哦~)


    咳咳,扯远了,说回正题


    我们在后台收到大量反馈,说是希望添加桌面组件敲木鱼功能。好嘛,用户的话就是圣旨,那必须要安排上,正好我也练练手。


    老规矩,先来看下我实现的效果



    这个功能看着很简单对吧,却也花了我一天半的时间。主要用来实现敲击动画了!!


    二、代码实现


    1、新建小组件



     2、修改界面样式


    主要会生成3个关键文件(文件名根据你设置的来)

    ①、APPWidget  类,继承于 AppWidgetProvider,本质是一个 BroadCastReceiver


    ②、layout/widget.xml ,小组件布局文件


    ③、xml/widget_info.xml ,小组件信息说明文件


    同时会在 AndroidManifest中注册好


    类似如下代码:


         <receiver
    android:name=".receiver.MuyuAppWidgetBig"
    android:exported="false">
    <intent-filter>
    <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
    <action android:name="com.fyspring.bluetooth.receiver.action_appwidget_muyu_knock" />
    </intent-filter>

    <meta-data
    android:name="android.appwidget.provider"
    android:resource="@xml/app_widget_info_big" />
    </receiver>

    3、添加敲木鱼逻辑代码


    通过 APPWidget 的模板代码我们知道,内部通过 RemoteViews 来进行更新View,而我们都知道 RemoteViews 是无法通过 findViewById 来转成对应的 view,更无法对其添加 Animator。那么我们该怎么办来给桌面木鱼组件添加一个 缩放动画呢?


    给你三秒时间考虑下,这里我可花了一天时间来研究....


    通过 layoutAnimation !!!


    layoutAnimation 是在 ViewGr0up 创建之后,显示时作用的,作用时间是:ViewGr0up 的首次创建显示,之后再有改变就不行了。


    虽然 RemoteViews 不能执行 findViewById,但它提供了两个关键方法: remoteViews.removeAllViews  和  remoteViews.addView 。如果我们在点击时,向组件布局中添加一个带有 layoutAnimation 的布局,不是就可以间接播放动画了么?


    关键代码:


    private fun doAnimation(context: Context?, remoteViews: RemoteViews?) {
    remoteViews?.removeAllViews(R.id.muyu_rl)
    val remoteViews2 = RemoteViews(context?.packageName, R.layout.anim_layout)
    remoteViews2.setImageViewResource(R.id.widget_muyu_iv, R.mipmap.ic_muyu)
    remoteViews?.addView(R.id.muyu_rl, remoteViews2)
    }

    小组件布局:


    <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    style="@style/Widget.BlueToothDemo.AppWidget.Container"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:background="@drawable/shape_round_bg"
    android:theme="@style/Theme.BlueToothDemo.AppWidgetContainer">

    <LinearLayout
    android:layout_width="140dp"
    android:layout_height="140dp"
    android:gravity="center_horizontal"
    android:orientation="vertical">

    <TextView
    android:id="@+id/appwidget_text"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_centerHorizontal="true"
    android:contentDescription="测试桌面木鱼"
    android:text="已敲0次"
    android:textColor="@color/white"
    android:textSize="18sp"
    android:textStyle="bold" />

    <RelativeLayout
    android:id="@+id/muyu_rl"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <ImageView
    android:id="@+id/widget_muyu_iv"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:layout_centerHorizontal="true"
    android:layout_margin="15dp"
    android:src="@mipmap/ic_muyu" />

    </RelativeLayout>
    </LinearLayout>
    </RelativeLayout>

    添加替换的动画布局(anim_layout.xml),注意两边的木鱼ImgView 的 ID保持一致,因为要统一设置点击事件!!


    <?xml version="1.0" encoding="utf-8"?>
    <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layoutAnimation="@anim/muyu_anim">

    <ImageView
    android:id="@+id/widget_muyu_iv"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:src="@mipmap/ic_muyu2" />
    </RelativeLayout>

    动画文件:(muyu_anim.xml)


    <?xml version="1.0" encoding="utf-8"?>
    <layoutAnimation xmlns:android="http://schemas.android.com/apk/res/android"
    android:animation="@anim/scale_anim"/>


    动画文件:(scale_anim.xml)


    <?xml version="1.0" encoding="utf-8"?>
    <scale xmlns:android="http://schemas.android.com/apk/res/android"
    android:duration="100"
    android:fromXScale="0.9"
    android:fromYScale="0.9"
    android:interpolator="@android:anim/accelerate_interpolator"
    android:pivotX="50%"
    android:pivotY="50%"
    android:toXScale="1"
    android:toYScale="1" />

    关键动画代码就是以上这些,如果有问题欢迎私信。希望大家在新的一年里,木鱼一敲,烦恼全消~


    欢迎体验下我做的木鱼,记得搜  我要敲木鱼  哦~~


    作者:今夜太冷不宜私奔丶
    来源:juejin.cn/post/7323025855154962459
    收起阅读 »

    低成本创建数字孪生场景-开发篇

    web
    介绍 本文承接《低成本创建数字孪生场景-数据篇》,获取到了数据之后就是愉快的开发环节,我们用到了开源项目CesiumJS做为GIS技术框架。 CesiumJS项目从创建至今已经有12年历史,提供了创建3D地球和2D地图的强大功能,它支持多种地图数据源,可以创建...
    继续阅读 »

    介绍


    本文承接《低成本创建数字孪生场景-数据篇》,获取到了数据之后就是愉快的开发环节,我们用到了开源项目CesiumJS做为GIS技术框架。


    CesiumJS项目从创建至今已经有12年历史,提供了创建3D地球和2D地图的强大功能,它支持多种地图数据源,可以创建复杂的3D地形和城市模型。CesiumJS的功能强大,但入门难度比较高,需要提前了解很多概念和设计理念,为方便理解本案例仅仅使用其提供的一些基础功能。


    Guanlianx_5.gif


    需求说明


    为了搭建1个简易的山区小乡镇场景,我们首先梳理下需求,把任务分解好。



    1. 在底图上叠加各种图层

      • 支持叠加地形图层、3DTiles图层、数据图层

      • 支持多种方式分发图层数据



    2. 鼠标与图层元素的交互

      • 鼠标移动时,使用屏幕事件处理器监听事件,获取当前屏幕坐标

      • 如果已经有高亮的元素,将其恢复为正常状态

      • 以当前屏幕坐标为起点发送射线,获取射线命中的元素,如果有命中的元素就高亮它

      • 鼠标点击时使用屏幕事件处理器获取命中元素,如果命中了,就判断元素是否描边状态,有则取消描边,没有则增加描边轮廓



    3. 加载Gltf等其他模型

      • 模型与其他图层元素一样,可以被光标拾取

      • 模型支持播放自带动画




    准备工作


    数据分发服务


    当前案例涉及的图层数据以文件类为主,为方便多处使用,需要将图层的服务独立部署,这里有两个方案:



    1. 自行搭建静态文件服务器,网上搜一个常用的node.js静态服务脚本即可

    2. 把文件放到cesium ion上,如果你要使用cesium ion资产,需要注意配好defaultAccessToken,具体调用方式看下文的代码实现


    安装依赖


    以下为本案例的前端工程使用的核心框架版本


    依赖版本
    vue^3.2.37
    vite^2.9.14
    Cesium^1.112.0

    代码实现



    1. 地图基本场景,本示例使用vite+vue3开发,html非常简单,只需要一个

      标签即可,在cesiumjs中以Viewer为起点调用其他控件,因此我们实例化一个Cesium.viewer, 这里面有非常多配置参数,详细请看开发文档


      import * as Cesium from 'cesium'
      import 'cesium/Build/Cesium/Widgets/widgets.css'

      Cesium.Ion.defaultAccessToken = '可以把一些GIS资产放到Cesium ION上托管,Tokenw为调用凭证'

      // 地图中心
      const center = [1150, 29]

      // cesium实例
      let viewer = null

      // 容器
      const cesiumContainer = ref(null)

      onMounted(async () => {
      await init()
      })

      async function init() {
      viewer = new Cesium.Viewer(cesiumContainer.value, {
      timeline: true, //显示时间轴
      animation: true, //开启动画
      sceneModePicker: true, //场景内容可点击
      baseLayerPicker: true, //图层可点击
      infoBox: false, // 自动信息弹窗
      shouldAnimate: true // 允许播放动画
      })
      // 初始化镜头视角
      restoreCameraView()

      // 开启地形深度检测
      viewer.scene.globe.depthTestAgainstTerrain = true
      // 开启全局光照
      viewer.scene.globe.enableLighting = true
      // 开启阴影
      viewer.shadows = true

      })

      // 设置初始镜头
      function restoreCameraView(){
      viewer.camera.flyTo({
      destination: Cesium.Cartesian3.fromDegrees(center[0], center[1], 0),
      orientation: {
      heading: Cesium.Math.toRadians(0), // 相机的方向
      pitch: Cesium.Math.toRadians(-90), // 相机的俯仰角度
      roll: 0 // 相机的滚动角度
      }
      })
      }

      // 加载地形图层
      async function initTerrainLayer() {
      const tileset = await Cesium.CesiumTerrainProvider.fromUrl(
      'http://localhost:9003/terrain/c8Wcm59W/',
      {
      requestWaterMask: true,
      requestVertexNormals: false
      }
      )
      viewer.terrainProvider = tileset
      }


    2. 在地图上叠加地形图层,图层数据可以自行部署


      // 方法1: 加载本地地形图层
      async function initTerrainLayer() {
      const tileset = await Cesium.CesiumTerrainProvider.fromUrl(
      'http://localhost:9003/terrain/c8Wcm59W/',
      {
      requestWaterMask: true,
      requestVertexNormals: false
      }
      )
      viewer.terrainProvider = tileset
      }

      // 方法2: 加载Ion地形图层
      async function initTerrainLayer() {
      const tileset = await Cesium.CesiumTerrainProvider.fromIonAssetId(1,{
      requestVertexNormals: true
      }
      )
      viewer.terrainProvider = tileset
      }


    3. 加载3DTiles图层,与地形图层类似,换成了Cesium3DTileset类。需要注意使用url加载需要自行解决跨域问题


      const tileset = await Cesium.Cesium3DTileset.fromUrl(
      'http://localhost:9003/model/tHuVnsJXZ/tileset.json',
      {}
      )
      // 将图层加入到场景
      viewer.scene.primitives.add(tileset)

      // 适当调整图层位置
      const translation = getTransformMatrix(tileset, { x: 0, y: 0, z: 86 })
      tileset.modelMatrix = Cesium.Matrix4.fromTranslation(translation)

      // 获取变化矩阵
      function getTransformMatrix (tileset, { x, y, z }) {
      // 高度偏差,正数为向上偏,负数为向下偏,根据真实的模型位置不断进行调整
      const heightOffset = z
      // 计算tileset的绑定范围
      const boundingSphere = tileset.boundingSphere
      // 计算中心点位置
      const cartographic = Cesium.Cartographic.fromCartesian(boundingSphere.center)
      // 计算中心点位置坐标
      const surface = Cesium.Cartesian3.fromRadians(cartographic.longitude,
      cartographic.latitude, 0)
      // 偏移后的三维坐标
      const offset = Cesium.Cartesian3.fromRadians(cartographic.longitude + x,
      cartographic.latitude + y, heightOffset)

      return Cesium.Cartesian3.subtract(offset, surface, new Cesium.Cartesian3())
      }


    4. 鼠标事件交互,鼠标悬浮,在改变选中元素的状态之前,需要将它的当前状态保存下来以便下次可以恢复。


      // 缓存高亮状态
      const highlighted = {
      feature: undefined,
      originalColor: new Cesium.Color()
      }

      // 鼠标与物体交互事件
      function initMouseInteract () {
      // 事件处理器
      const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas)

      // 鼠标悬浮选中
      handler.setInputAction((event) => {
      // 将原有高亮对象恢复
      if (Cesium.defined(highlighted.feature)) {
      highlighted.feature.color = highlighted.originalColor
      highlighted.feature = undefined
      }
      // 获取选中对象
      const pickedFeature = viewer.scene.pick(event.endPosition)

      if (Cesium.defined(pickedFeature)) {
      // 高亮选中对象
      if (pickedFeature !== moveSelected.feature) {
      highlighted.feature = pickedFeature
      Cesium.Color.clone(pickedFeature.color, highlighted.originalColor)
      pickedFeature.color = Cesium.Color.YELLOW
      }
      }
      }, Cesium.ScreenSpaceEventType.MOUSE_MOVE)


    5. 鼠标事件,鼠标点击,描边轮廓使用了Cesium自带的后期效果处理器,不需要自行编写着色器等操作,因此实现起来很便捷。只需要将选中的元素放到 效果的selected对象数组内就行了。


      // 缓存后期效果
      let edgeEffect = null

      function initMouseInteract(){
      // 鼠标点击选中
      handler.setInputAction((event) => {

      // 获取选中对象
      const pickedFeature = viewer.scene.pick(event.position)

      if (!Cesium.defined(pickedFeature)) {
      return null
      } else {

      // 描边效果:兼容GLTF和3DTiles
      setEdgeEffect(pickedFeature.primitive || pickedFeature)

      // 如果拾取的要素包含属性信息,则打印出来
      if (Cesium.defined(pickedFeature.getPropertyIds)) {
      const propertyNames = pickedFeature.getPropertyIds()
      const props = propertyNames.map(key => {
      return {
      name: key,
      value: pickedFeature.getProperty(key)
      }
      })
      console.info(props)
      }
      }
      }, Cesium.ScreenSpaceEventType.LEFT_CLICK)
      }

      // 选中描边
      function setEdgeEffect (feature) {
      if (edgeEffect == null) {
      // 后期效果
      const postProcessStages = viewer.scene.postProcessStages

      // 增加轮廓线
      const stage = Cesium.PostProcessStageLibrary.createEdgeDetectionStage()
      stage.uniforms.color = Cesium.Color.LIME //描边颜色
      stage.uniforms.length = 0.05 // 产生描边的阀值
      stage.selected = [] // 用于放置对元素

      // 将描边效果放到场景后期效果中
      const silhouette = Cesium.PostProcessStageLibrary.createSilhouetteStage([stage])
      postProcessStages.add(silhouette)

      edgeEffect = stage
      }

      // 选多个元素进行描边
      const matchIndex = edgeEffect.selected.findIndex(v => v._batchId === feature._batchId)
      if (matchIndex > -1) {
      edgeEffect.selected.splice(matchIndex, 1)
      } else {
      edgeEffect.selected.push(feature)
      }

      }


    6. 加载gltf模型, gltf加载后需要进行一次矩阵变换modelMatrix, 加载后启动指定索引的动画进行播放。


      // 加载模型
      async function loadGLTF () {

      let animations = null

      let modelMatrix = Cesium.Transforms.eastNorthUpToFixedFrame(
      Cesium.Cartesian3.fromDegrees(lng,lat,altitude)
      )

      const model = await Cesium.Model.fromGltfAsync({
      url: './static/gltf/windmill.glb',
      modelMatrix: modelMatrix,
      scale: 30,
      // minimumPixelSize: 128, // 设定模型最小显示尺寸
      gltfCallback: (gltf) => {
      animations = gltf.animations
      }
      })

      model.readyEvent.addEventListener(() => {
      const ani = model.activeAnimations.add({
      index: animations.length - 1, // 播放第几个动画
      loop: Cesium.ModelAnimationLoop.REPEAT, //循环播放
      multiplier: 1.0 //播放速度
      })
      ani.start.addEventListener(function (model, animation) {
      console.log(`动画开始: ${animation.name}`)
      })
      })

      viewer.scene.primitives.add(model)
      }



    部署说明



    1. 场景演示包括前端工程、GIS数据分发服务、服务端接口几个部分

    2. 前端工程使用vue3开发,其中CesiumJs通过NPM依赖包引入

    3. 场景中相关图层均为静态文件,可放入主工程静态目录中,也可以独立部署(需解决跨域访问),或者使用cesiumlab3分发服务便于管理

    4. web端场景对终端设备和浏览器有一定要求,具体配置需要进一步测试


    总结


    在本文中并没有涉及到服务端数据的接入,数据接入进来后,我们可以利用Cesium在GIS开发领域强大功能,与three.js的webGL开发优势,两者相互融合创建更多数据可视化效果。那么关于Cesium和three.js的融合开发还在初步探索阶段,希望下一次有精彩内容分享给大家。


    Hengjiang3.gif


    相关链接


    最新版cesium集成threejs


    Cesium和Three.js结合的5个方案


    Cesium实现更实用的3D描边效果


    作者:gyratesky
    来源:juejin.cn/post/7331626882552872986
    收起阅读 »

    前端将dom转换成图片

    web
    一、问题描述 在工作的过程中会遇到要将dom变成图片并下载的问题,刚开始使用的html2canvas进行转换的,但是我老大告诉我说,咱们做的是面向国外的网页,插件太大会导致页面加载慢的问题(国外部分地区网络没有国内这么发达),就让我用原生dom或一些比较小的插...
    继续阅读 »

    一、问题描述


    在工作的过程中会遇到要将dom变成图片并下载的问题,刚开始使用的html2canvas进行转换的,但是我老大告诉我说,咱们做的是面向国外的网页,插件太大会导致页面加载慢的问题(国外部分地区网络没有国内这么发达),就让我用原生dom或一些比较小的插件,在原生dom下载的时候遇到了context.drawImage(element, 0, 0, width, height)这一方法传入参数要传类型HTMLCanvasElement的问题,所以要将一个HTMLElement转换成HTMLCanvasElement,但是经过一些信息的查找,我发现有个很好用且轻量化的插件,可以完美解决这一问题,所以这里给大家推荐一个轻量级的插件dom-to-image(23kb),这个插件可以不用进行类型转换,直接将dom元素转换成需要的文件格式。


    二、dom-to-image的使用


    2.1 dom-to-image的安装


    在终端输入以下代码进行dom-to-image安装



    npm install dom-to-image



    2.2 dom-to-image引入


    2.2.1 vue项目引入


    在需要使用这个插件的页面使用以下代码进行局部引入


    import domToImage from 'dom-to-image';

    然后就可以通过以下代码进行图片的转换了


    const palGradientGap = document.getElementById('element')
    const canvas = document.createElement('canvas')
    canvas.width = element.offsetWidth
    canvas.height = element.offsetHeight
    this.domtoimage.toPng(element).then(function (canvas) {
    const link = document.createElement('a')
    link.href = canvas
    link.download = 'image.png' // 下载文件的名称
    link.click()
    })

    当然也可以进行全局引入
    创建一个domToImage.js文件写入以下代码


    import Vue from 'vue'; 
    import domToImage from 'dom-to-image';
    const domToImagePlugin = {
    install(Vue) {
    Vue.prototype.$domToImage = domToImage;
    }
    };
    Vue.use(domToImagePlugin);

    然后再入口文件main.js写入以下代码全局引入插件


    import Vue from 'vue'
    import App from './App.vue'
    import './domToImage.js'; // 引入全局插件
    Vue.config.productionTip = false
    new Vue({ render: h => h(App), }).$mount('#app')

    三、dom-to-image相关方法



    1. toSvg(node: Node, options?: Options): Promise<string>:将 DOM 元素转换为 SVG 图片,并返回一个 Promise 对象。


      参数说明:



      • node:要转换为图片的 DOM 元素。

      • options:可选参数对象,用于配置转换选项。



    2. toPng(node: Node, options?: Options): Promise<string>:将 DOM 元素转换为 PNG 图片,并返回一个 Promise 对象。


      参数说明:



      • node:要转换为图片的 DOM 元素。

      • options:可选参数对象,用于配置转换选项。



    3. toJpeg(node: Node, options?: Options): Promise<string>:将 DOM 元素转换为 JPEG 图片,并返回一个 Promise 对象。


      参数说明:



      • node:要转换为图片的 DOM 元素。

      • options:可选参数对象,用于配置转换选项。



    4. toBlob(node: Node, options?: Options): Promise<Blob>:将 DOM 元素转换为 Blob 对象,并返回一个 Promise 对象。


      参数说明:



      • node:要转换为图片的 DOM 元素。

      • options:可选参数对象,用于配置转换选项。



    5. toPixelData(node: Node, options?: Options): Promise<Uint8ClampedArray>:将 DOM 元素转换为像素数据,并返回一个 Promise 对象。


      参数说明:



      • node:要转换为图片的 DOM 元素。

      • options:可选参数对象,用于配置转换选项。



    6. toCanvas(node: Node, options?: Options): Promise<HTMLCanvasElement>:将 DOM 元素转换为 Canvas 对象,并返回一个 Promise 对象。


      参数说明:



      • node:要转换为图片的 DOM 元素。

      • options:可选参数对象,用于配置转换选项。




    其中,Options 参数是一个可选的配置对象,用于设置转换选项。以下是一些常用的选项:



    • width:输出图像的宽度,默认值为元素的实际宽度。

    • height:输出图像的高度,默认值为元素的实际高度。

    • style:要应用于元素的样式对象。

    • filter:要应用于元素的 CSS 滤镜。

    • bgcolor:输出图像的背景颜色,默认值为透明。

    • quality:输出图像的质量,仅适用于 JPEG 格式,默认值为 0.92。


    作者:crazy三笠
    来源:juejin.cn/post/7331626882553937946
    收起阅读 »

    新来个架构师,把xxl-job原理讲的炉火纯青~~

    大家好,我是三友~~ 今天来继续探秘系列,扒一扒轻量级的分布式任务调度平台Xxl-Job背后的架构原理 公众号:三友的java日记 核心概念 这里还是老样子,为了保证文章的完整性和连贯性,方便那些没有使用过的小伙伴更加容易接受文章的内容,快速讲一讲Xxl-...
    继续阅读 »

    大家好,我是三友~~


    今天来继续探秘系列,扒一扒轻量级的分布式任务调度平台Xxl-Job背后的架构原理



    公众号:三友的java日记



    核心概念


    这里还是老样子,为了保证文章的完整性和连贯性,方便那些没有使用过的小伙伴更加容易接受文章的内容,快速讲一讲Xxl-Job中的概念和使用


    如果你已经使用过了,可直接跳过本节和下一节,快进到后面原理部分讲解


    1、调度中心


    调度中心是一个单独的Web服务,主要是用来触发定时任务的执行


    它提供了一些页面操作,我们可以很方便地去管理这些定时任务的触发逻辑


    调度中心依赖数据库,所以数据都是存在数据库中的


    调度中心也支持集群模式,但是它们所依赖的数据库必须是同一个


    所以同一个集群中的调度中心实例之间是没有任何通信的,数据都是通过数据库共享的



    2、执行器


    执行器是用来执行具体的任务逻辑的


    执行器你可以理解为就是平时开发的服务,一个服务实例对应一个执行器实例


    每个执行器有自己的名字,为了方便,你可以将执行器的名字设置成服务名


    3、任务


    任务什么意思就不用多说了


    一个执行器中也是可以有多个任务的



    总的来说,调用中心是用来控制定时任务的触发逻辑,而执行器是具体执行任务的,这是一种任务和触发逻辑分离的设计思想,这种方式的好处就是使任务更加灵活,可以随时被调用,还可以被不同的调度规则触发。




    来个Demo


    1、搭建调度中心


    调度中心搭建很简单,先下载源码



    github.com/xuxueli/xxl…



    然后改一下数据库连接信息,执行一下在项目源码中的/doc/db下的sql文件



    启动可以打成一个jar包,或者本地启动就是可以的


    启动完成之后,访问下面这个地址就可以访问到控制台页面了



    http://localhost:8080/xxl-job-admin/toLogin



    用户名密码默认是 admin/123456


    2、执行器和任务添加


    添加一个名为sanyou-xxljob-demo执行器



    任务添加



    执行器选择我们刚刚添加的,指定任务名称为TestJob,corn表达式的意思是每秒执行一次


    创建完之后需要启动一下任务,默认是关闭状态,也就不会执行




    创建执行器和任务其实就是CRUD,并没有复杂的业务逻辑



    按照如上配置的整个Demo的意思就是


    每隔1s,执行一次sanyou-xxljob-demo这个执行器中的TestJob任务


    3、创建执行器和任务


    引入依赖


    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
            <version>2.2.5.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>com.xuxueli</groupId>
            <artifactId>xxl-job-core</artifactId>
            <version>2.4.0</version>
        </dependency>
    </dependencies>

    配置XxlJobSpringExecutor这个Bean


    @Configuration
    public class XxlJobConfiguration {

        @Bean
        public XxlJobSpringExecutor xxlJobExecutor() {
            XxlJobSpringExecutor xxlJobSpringExecutor = new XxlJobSpringExecutor();
            //设置调用中心的连接地址
            xxlJobSpringExecutor.setAdminAddresses("http://localhost:8080/xxl-job-admin");
            //设置执行器的名称
            xxlJobSpringExecutor.setAppname("sanyou-xxljob-demo");
            //设置一个端口,后面会讲作用
            xxlJobSpringExecutor.setPort(9999);
            //这个token是保证访问安全的,默认是这个,当然可以自定义,
            // 但需要保证调度中心配置的xxl.job.accessToken属性跟这个token是一样的
            xxlJobSpringExecutor.setAccessToken("default_token");
            //任务执行日志存放的目录
            xxlJobSpringExecutor.setLogPath("./");
            return xxlJobSpringExecutor;
        }

    }

    XxlJobSpringExecutor这个类的作用,后面会着重讲


    通过@XxlJob指定一个名为TestJob的任务,这个任务名需要跟前面页面配置的对应上


    @Component
    public class TestJob {

        private static final Logger logger = LoggerFactory.getLogger(TestJob.class);

        @XxlJob("TestJob")
        public void testJob() {
            logger.info("TestJob任务执行了。。。");
        }

    }

    所以如果顺利的话,每隔1s钟就会打印一句TestJob任务执行了。。。


    启动项目,注意修改一下端口,因为调用中心默认也是8080,本地起会端口冲突


    最终执行结果如下,符合预期



    讲完概念和使用部分,接下来就来好好讲一讲Xxl-Job核心的实现原理


    从执行器启动说起


    前面Demo中使用到了一个很重要的一个类



    XxlJobSpringExecutor



    这个类就是整个执行器启动的入口



    这个类实现了SmartInitializingSingleton接口


    所以经过Bean的生命周期,一定会调用afterSingletonsInstantiated这个方法的实现


    这个方法干了很多初始化的事,这里我挑三个重要的讲,其余的等到具体的功能的时候再提


    1、初始化JobHandler


    JobHandler是个什么?


    所谓的JobHandler其实就是一个定时任务的封装



    一个定时任务会对应一个JobHandler对象


    当执行器执行任务的时候,就会调用JobHandler的execute方法


    JobHandler有三种实现:



    • MethodJobHandler

    • GlueJobHandler

    • ScriptJobHandler


    MethodJobHandler是通过反射来调用方法执行任务



    所以MethodJobHandler的任务的实现就是一个方法,刚好我们demo中的例子任务其实就是一个方法


    所以Demo中的任务最终被封装成一个MethodJobHandler


    GlueJobHandler比较有意思,它支持动态修改任务执行的代码


    当你在创建任务的时候,需要指定运行模式为GLUE(Java)



    之后需要在操作按钮点击GLUE IDE编写Java代码



    代码必须得实现IJobHandler接口,之后任务执行的时候就会执行execute方法的实现


    如果你需要修改任务的逻辑,只需要重新编辑即可,不需要重启服务


    ScriptJobHandler,通过名字也可以看出,是专门处理一些脚本的


    运行模式除了BEANGLUE(Java)之外,其余都是脚本模式


    而本节的主旨,所谓的初始化JobHandler就是指,执行器启动的时候会去Spring容器中找到加了@XxlJob注解的Bean


    解析注解,然后封装成一个MethodJobHandler对象,最终存到XxlJobSpringExecutor成员变量的一个本地的Map缓存中



    缓存key就是任务的名字



    至于GlueJobHandler和ScriptJobHandler都是任务触发时才会创建


    除了上面这几种,你也自己实现JobHandler,手动注册到JobHandler的缓存中,也是可以通过调度中心触发的


    2、创建一个Http服务器


    除了初始化JobHandler之外,执行器还会创建一个Http服务器


    这个服务器端口号就是通过XxlJobSpringExecutor配置的端口,demo中就是设置的是9999,底层是基于Netty实现的



    这个Http服务端会接收来自调度中心的请求


    当执行器接收到调度中心的请求时,会把请求交给ExecutorBizImpl来处理



    这个类非常重要,所有调度中心的请求都是这里处理的


    ExecutorBizImpl实现了ExecutorBiz接口


    当你翻源码的时候会发现,ExecutorBiz还有一个ExecutorBizClient实现



    ExecutorBizClient的实现就是发送http请求,所以这个实现类是在调度中心使用的,用来访问执行器提供的http接口



    3、注册到调度中心


    当执行器启动的时候,会启动一个注册线程,这个线程会往调度中心注册当前执行器的信息,包括两部分数据



    • 执行器的名字,也就是设置的appname

    • 执行器所在机器的ip和端口,这样调度中心就可以访问到这个执行器提供的Http接口


    前面提到每个服务实例都会对应一个执行器实例,所以调用中心会保存每个执行器实例的地址




    这里你可以把调度中心的功能类比成注册中心



    任务触发原理


    弄明白执行器启动时干了哪些事,接下来讲一讲Xxl-Job最最核心的功能,那就是任务触发的原理


    任务触发原理我会分下面5个小点来讲解



    • 任务如何触发?

    • 快慢线程池的异步触发任务优化

    • 如何选择执行器实例?

    • 执行器如何去执行任务?

    • 任务执行结果的回调


    1、任务如何触发?


    调度中心在启动的时候,会开启一个线程,这个线程的作用就是来计算任务触发时机,这里我把这个线程称为调度线程


    这个调度线程会去查询xxl_job_info这张表


    这张表存了任务的一些基本信息和任务下一次执行的时间


    调度线程会去查询下一次执行的时间 <= 当前时间 + 5s的任务


    这个5s是XxlJob写死的,被称为预读时间,提前读出来,保证任务能准时触发


    举个例子,假设当前时间是2023-11-29 08:00:10,这里的查询就会查出下一次任务执行时间在2023-11-29 08:00:15之前执行的任务



    查询到任务之后,调度线程会去将这些任务根据执行时间划分为三个部分:



    • 当前时间已经超过任务下一次执行时间5s以上,也就是需要在2023-11-29 08:00:05(不包括05s)之前的执行的任务

    • 当前时间已经超过任务下一次执行时间,但是但不足5s,也就是在2023-11-29 08:00:052023-11-29 08:00:10(不包括10s)之间执行的任务

    • 还未到触发时间,但是一定是5s内就会触发执行的



    对于第一部分的已经超过5s以上时间的任务,会根据任务配置的调度过期策略来选择要不要执行



    调度过期策略就两种,就是字面意思



    • 直接忽略这个已经过期的任务

    • 立马执行一次这个过期的任务


    对于第二部分的超时时间在5s以内的任务,就直接立马执行一次,之后如果判断任务下一次执行时间就在5s内,会直接放到一个时间轮里面,等待下一次触发执行


    对于第三部分任务,由于还没到执行时间,所以不会立马执行,也是直接放到时间轮里面,等待触发执行


    当这批任务处理完成之后,不论是前面是什么情况,调度线程都会去重新计算每个任务的下一次触发时间,然后更新xxl_job_info这张表的下一次执行时间


    到此,一次调度的计算就算完成了


    之后调度线程还会继续重复上面的步骤,查任务,调度任务,更新任务下次执行时间,一直死循环下去,这就实现了任务到了执行时间就会触发的功能


    这里在任务触发的时候还有一个很有意思的细节


    由于调度中心可以是集群的形式,每个调度中心实例都有调度线程,那么如何保证任务在同一时间只会被其中的一个调度中心触发一次?


    我猜你第一时间肯定想到分布式锁,但是怎么加呢?


    XxlJob实现就比较有意思了,它是基于八股文中常说的通过数据库来实现的分布式锁的


    在调度之前,调度线程会尝试执行下面这句sql



    就是这个sql



    select * from xxl_job_lock where lock_name = 'schedule_lock' for update



    一旦执行成功,说明当前调度中心成功抢到了锁,接下来就可以执行调度任务了


    当调度任务执行完之后再去关闭连接,从而释放锁


    由于每次执行之前都需要去获取锁,这样就保证在调度中心集群中,同时只有一个调度中心执行调度任务


    最后画一张图来总结一下这一小节



    2、快慢线程池的异步触发任务优化


    当任务达到了触发条件,并不是由调度线程直接去触发执行器的任务执行


    调度线程会将这个触发的任务交给线程池去执行


    所以上图中的最后一部分触发任务执行其实是线程池异步去执行的


    那么,为什么要使用线程池异步呢?


    主要是因为触发任务,需要通过Http接口调用具体的执行器实例去触发任务



    这一过程必然会耗费时间,如果调度线程去做,就会耽误调度的效率


    所以就通过异步线程去做,调度线程只负责判断任务是否需要执行


    并且,Xxl-Job为了进一步优化任务的触发,将这个触发任务执行的线程池划分成快线程池慢线程池两个线程池



    在调用执行器的Http接口触发任务执行的时候,Xxl-Job会去记录每个任务的触发所耗费的时间


    注意并不是任务执行时间,只是整个Http请求耗时时间,这是因为执行器执行任务是异步执行的,所以整个时间不包括任务执行时间,这个后面会详细说


    当任务一次触发的时间超过500ms,那么这个任务的慢次数就会加1


    如果这个任务一分钟内触发的慢次数超过10次,接下来就会将触发任务交给慢线程池去执行


    所以快慢线程池就是避免那种频繁触发并且每次触发时间还很长的任务阻塞其它任务的触发的情况发生


    3、如何选择执行器实例?


    上一节说到,当任务需要触发的时候,调度中心会向执行器发送Http请求,执行器去执行具体的任务


    那么问题来了



    由于一个执行器会有很多实例,那么应该向哪个实例请求?



    这其实就跟任务配置时设置的路由策略有关了



    从图上可以看出xxljob支持多种路由策略


    除了分片广播,其余的具体的算法实现都是通过ExecutorRouter的实现类来实现的



    这里简单讲一讲各种算法的原理,有兴趣的小伙伴可以去看看内部的实现细节


    第一个、最后一个、轮询、随机都很简单,没什么好说的


    一致性Hash讲起来比较复杂,你可以先看看这篇文章,再去查看Xxl-Job的代码实现



    zhuanlan.zhihu.com/p/470368641



    最不经常使用(LFU:Least Frequently Used):Xxl-Job内部会有一个缓存,统计每个任务每个地址的使用次数,每次都选择使用次数最少的地址,这个缓存每隔24小时重置一次


    最近最久未使用(LRU:Least Recently Used):将地址存到LinkedHashMap中,它利用LinkedHashMap可以根据元素访问(get/put)顺序来给元素排序的特性,快速找到最近最久未使用(未访问)的节点


    故障转移:调度中心都会去请求每个执行器,只要能接收到响应,说明执行器正常,那么任务就会交给这个执行器去执行


    忙碌转移:调度中心也会去请求每个执行器,判断执行器是不是正在执行当前需要执行的任务(任务执行时间过长,导致上一次任务还没执行完,下一次又触发了),如果在执行,说明忙碌,不能用,否则就可以用


    分片广播:XxlJob给每个执行器分配一个编号,从0开始递增,然后向所有执行器触发任务,告诉每个执行器自己的编号和总共执行器的数据


    我们可以通过XxlJobHelper#getShardIndex获取到编号,XxlJobHelper#getShardTotal获取到执行器的总数据量


    分片广播就是将任务量分散到各个执行器,每个执行器只执行一部分任务,加快任务的处理


    举个例子,比如你现在需要处理30w条数据,有3个执行器,此时使用分片广播,那么此时可将任务分成3分,每份10w条数据,执行器根据自己的编号选择对应的那份10w数据处理



    当选择好了具体的执行器实例之后,调用中心就会携带一些触发的参数,发送Http请求,触发任务


    4、执行器如何去执行任务?


    相信你一定记得我前面在说执行器启动是会创建一个Http服务器的时候提到这么一句



    当执行器接收到调度中心的请求时,会把请求交给ExecutorBizImpl来处理



    所以前面提到的故障转移和忙碌转移请求执行器进行判断,最终执行器也是交给ExecutorBizImpl处理的


    执行器处理触发请求是这个ExecutorBizImpl的run方法实现的



    当执行器接收到请求,在正常情况下,执行器会去为这个任务创建一个单独的线程,这个线程被称为JobThread



    每个任务在触发的时候都有单独的线程去执行,保证不同的任务执行互不影响



    之后任务并不是直接交给线程处理的,而是直接放到一个内存队列中,线程直接从队列中获取任务



    这里我相信你一定有个疑惑



    为什么不直接处理,而是交给队列,从队列中获取任务呢?



    那就得讲讲不正常的情况了


    如果调度中心选择的执行器实例正在处理定时任务,那么此时该怎么处理呢?**


    这时就跟阻塞处理策略有关了



    阻塞处理策略总共有三种:



    • 单机串行

    • 丢弃后续调度

    • 覆盖之前调度


    单机串行的实现就是将任务放到队列中,由于队列是先进先出的,所以就实现串行,这也是为什么放在队列的原因


    丢弃调度的实现就是执行器什么事都不用干就可以了,自然而然任务就丢了


    覆盖之前调度的实现就很暴力了,他是直接重新创建一个JobThread来执行任务,并且尝试打断之前的正在处理任务的JobThread,丢弃之前队列中的任务



    打断是通过Thread#interrupt方法实现的,所以正在处理的任务还是有可能继续运行,并不是说一打断正在运行的任务就终止了



    这里需要注意的一点就是,阻塞处理策略是对于单个执行器上的任务来生效的,不同执行器实例上的同一个任务是互不影响的


    比如说,有一个任务有两个执行器A和B,路由策略是轮询


    任务第一次触发的时候选择了执行器实例A,由于任务执行时间长,任务第二次触发的时候,执行器的路由到了B,此时A的任务还在执行,但是B感知不到A的任务在执行,所以此时B就直接执行了任务


    所以此时你配置的什么阻塞处理策略就没什么用了


    如果业务中需要保证定时任务同一时间只有一个能运行,需要把任务路由到同一个执行器上,比如路由策略就选择第一个


    5、任务执行结果的回调


    当任务处理完成之后,执行器会将任务执行的结果发送给调度中心



    如上图所示,这整个过程也是异步化的



    • JobThread会将任务执行的结果发送到一个内存队列中

    • 执行器启动的时候会开启一个处发送任务执行结果的线程:TriggerCallbackThread

    • 这个线程会不停地从队列中获取所有的执行结果,将执行结果批量发送给调度中心

    • 调用中心接收到请求时,会根据执行的结果修改这次任务的执行状态和进行一些后续的事,比如失败了是否需要重试,是否有子任务需要触发等等


    到此,一次任务的就算真正处理完成了


    最后


    最后我从官网捞了一张Xxl-Job架构图



    奈何作者不更新呐,导致这个图稍微有点老了,有点跟现有的架构对不上


    比如说图中的自研RPC(xxl-rpc)部分已经替换成了Http协议,这主要是拥抱生态,方便跨语言接入


    但是不要紧,大体还是符合现在的整个的架构


    从架构图中也可以看出来,本文除了日志部分的内容没有提到,其它的整个核心逻辑基本上都讲到了


    而日志部分其实是个辅助的作用,让你更方便查看任务的运行情况,对任务的触发逻辑是没有影响的,所以就没讲了


    所以从本文的讲解再到官方架构图,你会发现整个Xxl-Job不论是使用还是实现都是比较简单的,非常的轻量级


    说点什么


    好了,到这又又成功讲完了一款框架或者说是中间件的核心架构原理,不知道你有没有什么一点收获


    如果你觉得有点收获,欢迎点赞、在看、收藏、转发分享给其他需要的人


    你的支持就是我更新文章最大的动力,非常地感谢!


    其实这篇文章我在十一月上旬的时候我就打算写了


    但是由于十一月上旬之后我遇到一系列烦心事,导致我实在是没有精力去写


    现在到月底了,虽然烦心事只增不少,但是我还是想了想,觉得不能再拖了,最后也是连续肝了几个晚上,才算真正完成


    所以如果你发现文章有什么不足和问题,也欢迎批评指正


    好了,本文就讲到这里了,让我们下期再见,拜拜!


    作者:zzyang90
    来源:juejin.cn/post/7329860521241640971
    收起阅读 »

    为什么不推荐用 UUID 作为 Mysql 的主键

    学习改变命运,技术铸就辉煌。 大家好,我是銘,全栈开发程序员。 UUID 是什么 我们先来了解一下 UUID 是什么?UUID 是指Universally Unique Identifier,翻译为中文是通用唯一识别码,UUID 的目的是让分布式系统中的所有...
    继续阅读 »

    学习改变命运,技术铸就辉煌。



    大家好,我是銘,全栈开发程序员。


    UUID 是什么


    我们先来了解一下 UUID 是什么?UUID 是指Universally Unique Identifier,翻译为中文是通用唯一识别码,UUID 的目的是让分布式系统中的所有元素都能有唯一的识别信息。如此一来,每个人都可以创建不与其它人冲突的 UUID,就不需考虑数据库创建时的名称重复问题。


    UUID 的十六个八位字节被表示为 32个十六进制数字,以连字号分隔的五组来显示,形式为 8-4-4-4-12,总共有 36个字符(即三十二个英数字母和四个连字号)。例如:


    123e4567-e89b-12d3-a456-426655440000
    xxxxxxxx-xxxx-Mxxx-Nxxx-xxxxxxxxxxxx

    能否用 UUID 做主键


    先说答案 , 能,但是性能会比使用自增主键差一些,那原因是什么,我们具体分析:


    我们平时建表的时候,一般都像下面这样,不会去使用 UUID,使用AUTO INCREMENT直接把主键 id 设置成自增,每次 +1


    CREATE TABLE `user`(
    `id` int NOT NULL AUTO INCREMENT COMMENT '主键',
    `name` char(10NOT NULL DEFAULT '' COMMENT '名字',
     PRIMARY KEY (`id`)
     )ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

    那为什么把主键设置成自增呢, 我们在数据库保存数据的时候,就类似与下面的表格一样,这每一行数据,都是**保存在一个 16K 大小的页里 **。


    idnameage
    1张三11
    2李四22
    3王五33

    每次都去遍历所有的行性能会不好,于是为了加速搜索,我们可以根据主键 id,从小到大排列这些行数据,将这些数据页用双向链表的形式组织起来,再将这些页里的部分信息提取出来放到一个新的 16kb 的数据页里,再加入层级的概念。于是,一个个数据页就被组织起来了,成为了一棵 B+ 树索引。


    当我们在建表 sql 里面声明 AUTO INCREMENT 的时候,myqsl 的 innodb 引擎,就会为主键 id 生成一个主键索引,里面就是通过 B+ 树的形式来维护这套索引。


    那么现在,我们需要关注两个点,



    1. 数据页大小是固定的 16k

    2. 数据页内,以及数据页之间,数据主键 id 是从小到大排序的


    所以,由于数据页大小固定了 16k ,当我们需要插入一条数据的时候,数据页就会慢慢的被放满,当超过 16k 的时候,这个数据页就可能会进行分裂。


    针对 B+ 树的叶子节点,如果主键是自增的,那么它产生的 id 每次都比前次要大,所以每次都会将数据家在 B+ 树的尾部,B+ 树的叶子节点本质是双向链表,查找它的首部和尾部,时间复杂度 O(1),如果此时最末尾的数据也满了,那创建个新的页就好。


    如果bb,上次 id=12111111,这次 id=343435455,那么为了让新加入数据后 B+ 树的叶子节点海涅那个保持有序,那么就需要旺叶子节点的中间找,查找的时间复杂度是 O(lgn),如果这个页满了,那就需要进行页分裂,并且页分裂的操作是需要加悲观锁的。


    所以,我们一般都建议把主键设置成自增,这样可以提高效率,提高性能


    那什么情况下不设置主键自增


    mysql分库分表下的id


    在分库分表的情况下,插入的 id 都是专门的 id 服务生成的,如果要严格按照自增的话,那么一般就会通过 redis 来生成,按批次去获得,比如一次性获取几百个,用完了再去获取,但是如果 redis 服务挂了,功能就完全没法用了,那有么有不依赖与第三方组件的方法呢?


    雪花算法


    使用时间戳+机器码+流水号,一个字段实现了时间顺序、机器编码、创建时间。去中心化,方便排序,随便多表多库复制,并可抽取出生成时间,雪花ID主要是用在数据库集群上,去中心化,ID不会冲突又能相对排序。


    总结


    一般情况下,我们不推荐使用 UUID 来作为数据库的主键,只有分库分表的时候,才建议使用 UUID 来作为主键。


    作者:銘聊技术
    来源:juejin.cn/post/7328366295091200038
    收起阅读 »

    JS 前端框架的新年预言

    web
    免责声明 本文属于是语冰的直男翻译了属于是,略有删改,仅供粉丝参考。英文原味版请传送 2024 Predictions by JavaScript Frontend Framework Maintainers。 本期共享的是 —— 来自 React/Next...
    继续阅读 »

    免责声明


    本文属于是语冰的直男翻译了属于是,略有删改,仅供粉丝参考。英文原味版请传送 2024 Predictions by JavaScript Frontend Framework Maintainers



    本期共享的是 —— 来自 React/Next.js/Angular/Solid 的维护者和创建者科普了它们计划在新年里框架改进的未来规划。


    fe-2024.png


    React:新年预览


    Meta(前脸书)的 React 工程经理 E.W. 表示,React 团队预计在新的一年里会有更多的框架采用 RSC(React 服务服务端组件)。


    “对于大多数人而言,RSC 已经对其所了解的 React 作用域产生了重大变化,从只是一个 UI 层,到对构建 App 的方式产生更大的影响,以享受最佳的用户体验和开发体验,尤其以前对于 SPA(单页应用程序)还不够好,”E.W. 如是说。


    虽然它没有具体爆料来年的任何新进展,但 E.W. 确实表示它们会发布并共享某些去年开始可公开的进展。举个栗子,在 React Advanced 上,该团队向与会者展示了 React Forget,这是 React 的自动记忆编译器。E.W. 表示,React Forget 意味着,开发者不再需要使用 useMemo/useCallback


    “在 React Native EU 上,我们表示,从 0.73 版本开始,我们会把 Web 开发者熟悉的 Chrome 开发工具移植到 React Native 中,”E.W. 补充道。“我们还共享了我们对 Static Hermes 的研究,它是我们的 JS 原生编译器,它不仅有可能加快 React Native App 的速度,还能从根本上改变 JS 的有效用途。”


    Next.js:正在运行的新编译器


    Next.js 推出了一款新的 App 服务器,旨在支持去年的 RSC(React 服务端组件)和 SA(服务端操作)。Vercel 的产品主管 L.R. 表示,它会继续支持旧版的 App 服务器,并且它们的路由系统是可互换的。这种互操作性意味着,开发者可以把时间花在添加新功能上。


    “有些客户已经使用 Next.js 开发了五六年,它们采用这些新功能也需要多年时间,”L.R. 讲道。“我们希望让大家尽可能顺利地度过这段旅程。”


    新的一年里,Next.js 想要解决一大坨问题,但其中一个优先事项可能是简化缓存。它说,就开发体验而言,这可能会更容易。


    “通常情况下,生态系统中的一大坨开发者必须引入一大坨额外软件包,或学习如何使用其他工具来请求、缓存和重新验证,”L.R. 说。“Next.js 现在已经内置了一大坨十分给力的同款功能,但这也意味着,大家需要学习其他东西,目前用户初步的反馈是,‘这很棒棒哒;它十分给力,但如果能更简单一点的话,我们会不吝赞词。’”


    Next.js 团队还会继续关注性能优化,它称之为“我们的持续投资”。


    它补充道,这可能会在新年里以新编译器的形式出现,这将加快在开发者的机器上启动 Next.js 的速度。该编译器已经投入使用了大约一年,Vercel 一直在内部将其用于其产品和 App。它说,由 Rust 提供支持的编译器,在无缓存的情况下比以前有缓存的编译器更快。


    L.R. 说:“我们推出该功能指日可待,大家都可以默认启动它,而且它比现存的 Webpack 编译解决方案更快。” “开发者希望它们的工具更快。它们永远不会抱怨它变得更快。因此,很有趣的是,可以看到工具作者,而不是工具的用户,而是实际的工具开发者转向 Rust 等较低阶的工具,帮助斩获咫尺之间的性能优势。”


    目标三是继续为 Next.js 的未来 10 年奠定基础。


    “你知道的,这个新的路由系统显然让我们十分鸡冻。我们相信这是未来的基础,”它说。“但这也需要时间。大家会尝试,用户会提出功能请求,它们会希望看到事情发生改变。我们认为这是未来五到十年的一项非常长期的投资。”


    它补充说,“有一天”但可能不是今年的目标是,寻求一种更棒的方案来处理 Next.js 内部的内容。


    “今天,它能奏效,我们仍然可以连接到想要的任何内容源,但存在某些方案可以简化开发体验,”它补充道。“与其说这是一项要求,不如说是一种美好的享受,这就是为什么私以为我们无法在新年实现此目标,但我想在未来用它搞点事情。”


    Angular:可选的 Zone.js


    谷歌 Angular DevRel 技术主管兼经理 M.G. 表示,在过去的一年里,Angular 的两大成就是:



    • 引入了 Signal(信号)的细粒度响应性

    • 引入了可延迟视图


    它讲道,明年会在此基础上,进一步关注细粒度响应性,并使 Zone.js 成为可选选项。


    在 Angular 中,Zone 是跨异步任务持续存在的执行上下文。Zone 的 GitHub 仓库对此进行了详细解释,但 Zone 有五大职责,包括但不限于拦截异步任务调度和包装错误处理的回调,以及跨异步操作的 Zone 追踪。Zone.js 可以创建跨异步操作持久存在的上下文,并为异步操作提供生命周期钩子。


    “我们正在探索为现存项目启用可选的 Zone.js,开发者应该可以通过重构现存 App 来利用该功能,”M.G. 如是说。“诉诸可选的 Zone.js,我们期望优化加载时间,并提升初始渲染速度。研究细粒度响应性将其提升到另一个水平,使我们能够只检测组件模板的局部变化。”


    它说,这些功能将带来更快的运行时间。


    在另一个性能游戏中,Angular 正在考虑是否默认启用混合渲染。它补充说,可以选择退出混合渲染,因为它会增加托管要求和成本。


    “我们瞄到了 SSG(静态站点生成)和 SSR(服务端渲染)的巨大价值,凭借我们在 v17 中奠定的坚硬基建,我们正在努力进行最后的润色,以便从一开始就实现这种体验,”M.G. 如是说。


    它补充道,另一个优先事项是落实 Signal 的征求意见。


    开发者还可能会见证 Angular 文档的改进。根据其开发者调查,开发者希望享受进阶的学习体验,其中一部分包括使 Angular.dev 成为 Angular 的全新官网主页。它补充道,开发者还优先考虑了初始加载时间(混合渲染、部分水合和可选的 Zone.js 部分应该解决此问题),以及组件创作(Angular 计划进一步简化组件创作)。


    “我们致力于可持续迭代功能,并与时俱进地渐进增强它们,”M.G. 讲道。“开发者将能够从新年里的所有优化中受益,并将在接下来的几年中享受更好的开发体验和性能。”


    Solid:聚焦原语


    “Solid 之父”R.C. 表示,Solid 开发者可以关注新年的 SolidStart 1.0 和 Solid.js 2.0。SolidStart 是一个元框架,这意味着,它构建于 Solid.js 框架之上。它说,它相相当于 Svelte 的 SvelteKit。


    SolidStart 的官网文档是这样解释的:


    “Web App 通常包含一大坨组件:数据库、服务器、前端、打包器、数据请求/变更、缓存和基建。编排这些组件极具挑战性,并且通常需要跨 App 堆栈大量共享状态和冗余逻辑。进入 SolidStart:一种元框架,它提供了将所有这些组件万法归一的平台。”


    由于 SolidStart 仍处于测试阶段,R.C. 基本上有机会使用生态系统中已有的内容来使其变得更好。


    “其中一个重要的部分是,我们现在不再编写自己的部署适配器,而是使用 Nitro,它也为 Nuxt 框架提供支持,这让我们可以部署到所有不同的平台,”R.C. 讲道。


    另一个例子是,任何 Solid 路由器都可以在 SolidStart 中奏效。


    “这意味着,对路由器的底层部分大量更新,这样它们能够“梦幻联动”,但我非常满意的最终结果是,我们的志愿者小团队需要维护的代码更少了,而且它为开发者提供了很大的灵活性和控制力,”它说。“它们不会被迫采用单一的解决方案,这对我而言兹事体大,因为每个人都有自己的需求。正如我所言,如果您构建正确的基建,并弄清楚这些构建模块是什么,大家可以做更多的事情。”


    它说,最终的结果是一个具有“可交换”部分的元框架,而且不太我行我素。在越来越多的元框架决定开发者技术方案的世界中,Solid 团队一直在思考正确的原语片段的影响。


    “于我而言,它始终是关于构建基元块,这是一个非常工程化的焦点,我认为这是它与众不同的部分原因,”它说。“我一直喜欢提供选择,而且私以为如果我们有正确的原语、正确的片段,我们就可以构建正确的解决方案。”


    它表示,Solid 2.0 应该会在新年中后期的某个时间点发布。它说,目前它们正在设计如何处理异步系统的原型。


    “Solid 2.0 也将是重量级版本,因为我们正在重新审视响应式系统,并研究如何解决异步 Signal 或异步系统,”R.C. 讲道。


    它补充道,Solid 试图平衡控制与性能。


    “我们的社区中有一大坨热心人,它们非常有技术头脑,既关心性能,也关心控制,”它说。“我们确实吸引了一大坨自己真正想要控制构建的方方面面的用户。”


    作者:人猫神话
    来源:juejin.cn/post/7331925629082566707
    收起阅读 »

    什么样的领导值得追随?

    俗话说:士为知己者死,女为悦己者容。作为一名技术人员,什么样的领导值得追随?今天就来一起聊一聊。 1. 有实权 权力意味着什么?哈哈,懂的都懂。纵观现在的互联网大厂,盘根错节,明争暗斗的利益团体一个又一个,所以你追随的领导一定要有实权,跟着有实权的领导,很容易...
    继续阅读 »



    俗话说:士为知己者死,女为悦己者容。作为一名技术人员,什么样的领导值得追随?今天就来一起聊一聊。


    1. 有实权


    权力意味着什么?哈哈,懂的都懂。纵观现在的互联网大厂,盘根错节,明争暗斗的利益团体一个又一个,所以你追随的领导一定要有实权,跟着有实权的领导,很容易获得优质,能拿结果的资源,晋升也就有了更好故事。大厂里一直流传着这样一句暗语:“代码写得好,不如 PPT做得好,PPT做得好,不如老板舔得好”。


    如果你追随的领导形同虚设,在公司就很容易边缘化,在团队合作上也可能没有多大的话语权,试想,跟着这样的领导,前途在哪里?


    2. 有能力


    尽管**实权**在成败上起着决定性的作用,但是绝大多数的技术人员都比较单纯,不愿意参与那种复杂的权利争斗,而且,技术人,能力才是立足之根本。因此,需要选择有能力的领导,要和强者一起赛跑。有能力的领导可以让你快速的学习,快速的成长,对于未来的职业发展才能做到进可攻,退可守。


    有能力主要体现在下面几点:



    1. 技术和业务能力,是团队的一个指明灯;

    2. 能辨才,领导一定要能掌握团队成员的优缺点,在项目上能够根据各自所长合理分配任务。

    3. 将才,作为领导,也许技术能力不是最强的,但是一定要有领导人才的能力,也就是我们说的将才。 


       



    3. 有担当


    作为技术人,在职业生涯中犯错误是在所难免的,比如出现比较大的线上事故,这个时候,领导愿不愿意和组员一起承担责任,就能很好的体现领导有没有担当,值不值得追随。


    4. 会分享


    特别喜欢《亮剑》中李云龙的角色,尽管他满嘴骂骂咧咧,但是在利益分配上他绝对是王者,为了团队的荣誉他可以和上级叫板,为了团队的利益他可以和其他部队的领导呲牙咧嘴,所以,作为他的下属心甘情愿为他赴死,他带领的队伍战斗力超强。


    反观职场,领导能不能主动满足下属的诉求,愿不愿意主动为下属争取利益,能不能为下属指定合理的成长计划,都能充分体现出领导愿不愿意和组员一起分享。


    5. 有野心


    “一个不想当将军的士兵不是一个好士兵”,“一个不想当大领导的领导不是一个没有野心领导”,如果一个在权利,业务,能力上都是野心勃勃的领导,不能证明他一定很好,但是一定不会很差。如果你跟的领导对于团队扩展和业务发展都没有很大的欲望,在公司内部不会去抢资源,拿结果,甚至出现得过且过,甘于平庸,那么,跟随这样的领导,你也只能平庸。


    6. 奖罚分明


    对于领导,团队管理是他最重要的职责,因此,如何能管好团队?如何激发组员的斗志?这就要求领导一定赏罚分明,团队一定要有清晰的赏罚制度,不要搞平均主义,有竞争才能让组员有动力,为团队创造更多的价值。


    如何选择领导呢?


    本文总结了值得追随的领导可能具备的 6个特质,但是很多小伙伴会问:我在跳槽前根本不知道自己的领导是谁,他的能力如何?我该如何选择领导?这里也总结几个意见,希望能帮到你:



    1. 跳槽尽量选择技术内推,这样就可以从他那边知道你未来的领导大概是什么样子,具备什么样的能力。

    2. 公司内部转岗,平时可以多关注不同部门的领导,择机选择自己喜欢的领导转岗。

    3. 改命,如果真的没有遇到欣赏自己的领导,就要更加提升技术,让自己具备选择领导的资本。


    金无足赤,人无完人,或许你的领导无法同时具备上述 6个特质,但是,只要拥有3~4个,我个人觉得该领导从一定意义上就已经很优秀。而且,日常工作和领导相处的时候,我们需要多换位思考,领导的哪些做法让你不爽,哪些做法让你心悦诚服,如果有一天你也当了领导,你该如何服众。就算不做领导,换位思考,也可以让自己在团队中更好地沟通,成长。


    最后,把猿哥的座右铭送给你: 投资自己才是最大的财富。 由于水平有限,如果文章存在缺点和错误,欢迎批评指正。


    作者:猿java
    来源:juejin.cn/post/7329807974968131618
    收起阅读 »

    Android开发中“真正”的仓库模式

    原文标题:The “Real” Repository Pattern in Android 原文地址:proandroiddev.com/the-real-re… 原文发表日期:2019.9.5 作者:Denis Brandi 翻译:tommwq 翻译日期:2...
    继续阅读 »

    • 原文标题:The “Real” Repository Pattern in Android

    • 原文地址:proandroiddev.com/the-real-re…

    • 原文发表日期:2019.9.5

    • 作者:Denis Brandi

    • 翻译:tommwq

    • 翻译日期:2024.1.3



    Figure 1: 仓库模式


    多年来我见过很多仓库模式的实现,我想其中大部分是错误而无益的。


    下面是我所见最多的5个错误(一些甚至出现在Android官方文档中):



    1. 仓库返回DTO而非领域模型。

    2. 数据源(如ApiService、Dao等)使用同一个DTO。

    3. 每个端点集合使用一个仓库,而非每个实体(或DDD聚合根)使用一个仓库。

    4. 仓库缓存全部模型,即使是频繁更新的域。

    5. 数据源被多个仓库共享使用。


    那么要如何把仓库模式做对呢?


    1. 你需要领域模型


    这是仓库模式的关键点,我想开发者难以正确实现仓库模式的原因在于他们不理解领域是什么。


    引用Martin Fowler的话,领域模型是:



    领域中同时包含行为和数据的对象模型。



    领域模型基本上表示企业范围的业务规则。


    对于不熟悉领域驱动设计构建块或分层架构(六边形架构,洋葱架构,干净架构等)的人来说,有三种领域模型:



    1. 实体:实体是具有标识(ID)的简单对象,通常是可变的。

    2. 值对象:没有标识的不可变对象。

    3. 聚合根(仅限DDD):与其他实体绑定在一起的实体(通常是一组关联对象的聚合)。


    对于简单领域,这些模型看起来与数据库和网络模型(DTO)很像,不过它们也有很多差异:



    • 领域模型包含数据和过程,其结构最适于应用程序。

    • DTO是表示JSON/XML格式请求/应答或数据库表的对象模型,其结构最适于远程通信。


    Listing 1: 领域模型示例


    // Entity
    data class Product(
    val id: String,
    val name: String,
    val price: Price,
    val isFavourite: Boolean
    ) {
    // Value object
    data class Price(
    val nowPrice: Double,
    val wasPrice: Double
    ) {
    companion object {
    val EMPTY = Price(0.0, 0.0)
    }
    }
    }

    Listing 2: 网络DTO示例


    // Network DTO
    data class NetworkProduct(
    @SerializedName("id")
    val id: String?,
    @SerializedName("name")
    val name: String?,
    @SerializedName("nowPrice")
    val nowPrice: Double?,
    @SerializedName("wasPrice")
    val wasPrice: Double?
    )

    Listing 3: 数据库DTO示例


    // Database DTO
    @Entity(tableName = "Product")
    data class DBProduct(
    @PrimaryKey
    @ColumnInfo(name = "id")
    val id: String,
    @ColumnInfo(name = "name")
    val name: String,
    @ColumnInfo(name = "nowPrice")
    val nowPrice: Double,
    @ColumnInfo(name = "wasPrice")
    val wasPrice: Double
    )

    如你所见,领域模型不依赖框架,对象字段提倡使用多值属性(正如你看到的Price逻辑分组),并使用空对象模式(域不可为空)。而DTO则与框架(Gson、Room)耦合。


    幸好有这样的隔离:



    • 应用程序的开发变得更容易,因为不需要检查空值,多值属性也减少了字段数量。

    • 数据源变更不会影响高层策略。

    • 避免了“上帝模型”,带来更多的关注点分离。

    • 糟糕的后端接口不会影响高层策略(想象一下,如果你需要执行两个网络请求,因为后端无法在一个接口中提供所有信息。你会让这个问题影响你的整个代码库吗?)


    2. 你需要数据转换器


    这是将DTO转换成领域模型,以及进行反向转换的地方。


    多数开发者认为这种转换是无趣又无效的,他们喜欢将整个代码库,从数据源到界面,与DTO耦合。


    这也许能让第一个版本更快交付,但不在表示层中隐藏业务规则和用例,而是省略领域层并将界面与数据源耦合会产生一些只会在生产环境遇到的故障(比如后端没有发送空字符串,而是发送null,并因此引发NPE)。


    以我所见,转换器写起来快,测起来也简单。即使实现过程缺乏趣味,它也能保护我们不会因数据源行为的改变而受到意外影响。


    如果你没有时间(或者干脆懒得)进行数据转换,你可以使用对象转换框架,比如如modelmapper.org/ ,来加速开发过程。


    我不喜欢在代码中使用框架,为减少样板代码,我建立了一个泛型转换接口,以免为每个转换器建立独立接口:


    interface Mapper<I, O> {
    fun map(input: I): O
    }

    以及一组泛型列表转换器,以免实现特定的“列表到列表”转换:


    // Non-nullable to Non-nullable
    interface ListMapper<I, O>: Mapper<List<I>, List<O>>

    class ListMapperImpl<I, O>(
    private val mapper: Mapper<I, O>
    ) : ListMapper<I, O> {
    override fun map(input: List<I>): List<O> {
    return input.map { mapper.map(it) }
    }
    }


    // Nullable to Non-nullable
    interface NullableInputListMapper<I, O>: Mapper<List<I>?, List<O>>

    class NullableInputListMapperImpl<I, O>(
    private val mapper: Mapper<I, O>
    ) : NullableInputListMapper<I, O> {
    override fun map(input: List<I>?): List<O> {
    return input?.map { mapper.map(it) }.orEmpty()
    }
    }


    // Non-nullable to Nullable
    interface NullableOutputListMapper<I, O>: Mapper<List<I>, List<O>?>

    class NullableOutputListMapperImpl<I, O>(
    private val mapper: Mapper<I, O>
    ) : NullableOutputListMapper<I, O> {
    override fun map(input: List<I>): List<O>? {
    return if (input.isEmpty()) null else input.map { mapper.map(it) }
    }
    }

    注:在这篇文章中我展示了如何使用简单的函数式编程,以更少的样板代码实现相同的功能。


    3. 你需要为每个数据源建立独立模型


    假设在网络和数据库中使用同一个模型:


    @Entity(tableName = "Product")
    data class ProductDTO(
    @PrimaryKey
    @ColumnInfo(name = "id")
    @SerializedName("id")
    val id: String?,
    @ColumnInfo(name = "name")
    @SerializedName("name")
    val name: String?,
    @ColumnInfo(name = "nowPrice")
    @SerializedName("nowPrice")
    val nowPrice: Double?,
    @ColumnInfo(name = "wasPrice")
    @SerializedName("wasPrice")
    val wasPrice: Double?
    )

    刚开始你可能会认为这比使用两个模型开发起来要快得多,但是你注意到它的风险了吗?


    如果没有,我可以为你列出一些:



    • 你可能会缓存不必要的内容。

    • 在响应中添加新字段将需要变更数据库(除非添加@Ignore注解)。

    • 所有不应当在请求中发送的字段都需要添加@Transient注解。

    • 除非使用新字段,否则必须要求网络和数据库中的同名字段使用相同的数据类型(例如你无法解析网络响应中的字符串nowPrice并缓存双精度浮点数nowPrice)。


    如你所见,这种方法最终将比独立模型需要更多的维护工作。


    4. 你应该只缓存所需内容


    如果要显示存储在远程目录中的产品列表,并且对本地保存的愿望清单中的每个产品显示经典的心形图标。


    对于这个需求,需要:



    • 获取产品列表。

    • 检查本地存储,确认产品是否在愿望清单中。


    这个领域模型很像前面例子,添加了一个字段表示产品是否在愿望清单中:


    // Entity
    data class Product(
    val id: String,
    val name: String,
    val price: Price,
    val isFavourite: Boolean
    ) {
    // Value object
    data class Price(
    val nowPrice: Double,
    val wasPrice: Double
    ) {
    companion object {
    val EMPTY = Price(0.0, 0.0)
    }
    }
    }

    网络模型也和前面的示例类似,数据库模型则不再需要。


    对于本地的愿望清单,可以将产品id保存在SharedPreferences中。不要使用数据库把简单的事情复杂化。


    最后是仓库代码:


    class ProductRepositoryImpl(
    private val productApiService: ProductApiService,
    private val productDataMapper: Mapper<DataProduct, Product>,
    private val productPreferences: ProductPreferences
    ) : ProductRepository {

    override fun getProducts(): Single<Result<List<Product>>> {
    return productApiService.getProducts().map {
    when(it) {
    is Result.Success -> Result.Success(mapProducts(it.value))
    is Result.Failure -> Result.Failure<List<Product>>(it.throwable)
    }
    }
    }

    private fun mapProducts(networkProductList: List<NetworkProduct>): List<Product> {
    return networkProductList.map {
    productDataMapper.map(DataProduct(it, productPreferences.isFavourite(it.id)))
    }
    }
    }

    其中依赖的类定义如下:


    // A wrapper for handling failing requests
    sealed class Result<T> {
    data class Success<T>(val value: T) : Result<T>()
    data class Failure<T>(val throwable: Throwable) : Result<T>()
    }

    // A DataSource for the SharedPreferences
    interface ProductPreferences {
    fun isFavourite(id: String?): Boolean
    }

    // A DataSource for the Remote DB
    interface ProductApiService {
    fun getProducts(): Single<Result<List<NetworkProduct>>>
    fun getWishlist(productIds: List<String>): Single<Result<List<NetworkProduct>>>
    }

    // A cluster of DTOs to be mapped int0 a Product
    data class DataProduct(
    val networkProduct: NetworkProduct,
    val isFavourite: Boolean
    )

    现在,如果只想获取愿望清单中的产品要怎么做呢?实现方式是类似的:


    class ProductRepositoryImpl(
    private val productApiService: ProductApiService,
    private val productDataMapper: Mapper<DataProduct, Product>,
    private val productPreferences: ProductPreferences
    ) : ProductRepository {

    override fun getWishlist(): Single<Result<List<Product>>> {
    return productApiService.getWishlist(productPreferences.getFavourites()).map {
    when (it) {
    is Result.Success -> Result.Success(mapWishlist(it.value))
    is Result.Failure -> Result.Failure<List<Product>>(it.throwable)
    }
    }
    }

    private fun mapWishlist(wishlist: List<NetworkProduct>): List<Product> {
    return wishlist.map {
    productDataMapper.map(DataProduct(it, true))
    }
    }
    }

    5. 后记


    我多次熟练使用这种模式,我想它是一个时间节约神器,尤其在大型项目中。


    然而我多次看到开发者使用这种模式仅仅是因为“不得不”,而非他们了解它的真正优势。


    希望你觉得这篇文章有趣也有用。


    作者:tommwq
    来源:juejin.cn/post/7319698586421542953
    收起阅读 »

    211 毕业就入职 30 人的小公司是什么体验

    为什么“选择”了 30 人的小公司? 作为一个 211 毕业的学生,进入 30 人的小公司不管是 8 年前还是现在,应该都是比较稀少的,但是当面的我阴差阳错进了这样一个小公司。 为什么我选择进入这样一个 30 人的小公司呢?主要原因是因为没得选。 当时我在大学...
    继续阅读 »

    为什么“选择”了 30 人的小公司?


    作为一个 211 毕业的学生,进入 30 人的小公司不管是 8 年前还是现在,应该都是比较稀少的,但是当面的我阴差阳错进了这样一个小公司。


    为什么我选择进入这样一个 30 人的小公司呢?主要原因是因为没得选。


    当时我在大学读的商科,跟计算机有关的课程只学了计算机基础、数据库基础和 C 语言编程基础,而且那个时候觉得这几门课程都是编外课程,没有好好学,C 语言课程期末考试还是老师放水以 60 分擦边通过。


    社会消息闭塞,大学都要毕业了,也不知道社会上有哪些岗位,同寝室的同学也在打游戏中度过。


    之后被一个考验小组拉进去考验,他们都准备的金融学专硕,我家穷,就准备考经济学硕士,结果没考上(现在还是比较庆幸没考上的,否则现在不知道干啥去了,个人性格也不适合证券之类的工作)。


    没考上,毕业之后也不知道干啥,就来北京又考了一年,又没考上。之后进了一个小的 Java 培训机构培训,从此入行!


    毕竟没什么基础,结课之后面试了几家,因为生活难以为继了,选择第一个给 offer 的 30 人小公司。


    现在工作 8 年了,也经历了从 30 人的小公司、 2000 人+的传统上市企业、互联网大小厂,有兴趣可以看之前的文章:。


    与大公司相比,小公司有哪些不好的地方


    首先,工作环境一般都是一栋楼里面的一个小办公室,甚至有的直接在居民楼里办公,办公环境没有大公司好;


    其次,薪资福利待遇相比大公司更低,而且社保等基础福利打折扣,很多小公司缴纳社保和公积金都是按照当地最低标准缴纳,相对大部分大公司会少很多钱;


    再次,管理混乱,不管是老板还是管理者,都没有受过相应的教育或者训练,比较随心所欲,很多决策都是老板的一言堂,很难总结出来统一的成功经验。


    小公司有哪些优点


    首先,小公司对能力的培养更加全面,你可能需要同时干产产品经理、开发、测试、运维等多个角色的活,更能理解整个软件的生命周期,如果你要换岗位,如果你有在小公司的工作经历,可能会更加容易。


    其次,小公司更加自由,做一个项目,它不会限制你使用的技术,只要你能实现需求,不会管你用的什么技术、什么技术方案,你可以更加容易的实现你的技术想法,验证你的想法。


    再次,小公司可能更好交朋友,因为小公司人少,更多的是刚毕业的学生,更容易真心相待,我现在从进入社会之后交的朋友,有好几个都是第一家小公司的时候交的。


    最后,培养更加全面,公司有一个同事,因为各方面比较优秀,在甲方爸爸的心中认可度比较高,自己成立了一个小公司,还是接原来甲方的需求,成功的从小员工变身为老板,后来还扩招了好几个员工,妥妥的打败大厂一般总监。


    收获


    感谢这家公司,给了我这样一个,没有技术背景、没有实习经历、技术也不够强的毕业生一个入行的机会。


    在这家公司,我收获了 IT 圈的第一波朋友,也收获了工程化的思想,积攒了各类技术的经验,为我之后的工作提供了丰厚的积累。


    而且,在这里,我积累了大量的技术经验和经历,也为跳槽到大公司提供了跳板。


    最后,欢迎大家分享自己入职小公司的经历,让更多人了解小公司,给自己的职业选择多一个方向!


    作者:六七十三
    来源:juejin.cn/post/7287053284787683363
    收起阅读 »

    为什么要用雪花ID替代数据库自增ID?

    今天咱们来看一道数据库中比较经典的面试问题:为什么要使用雪花 ID 替代数据库自增 ID?同时这道题也出现在了浩鲸科技的 Java 面试中,下面我们一起来看吧。 浩鲸科技的面试题如下:   1.什么是雪花 ID? 雪花 ID(Snowflake ID...
    继续阅读 »

    今天咱们来看一道数据库中比较经典的面试问题:为什么要使用雪花 ID 替代数据库自增 ID?同时这道题也出现在了浩鲸科技的 Java 面试中,下面我们一起来看吧。


    浩鲸科技的面试题如下:
    image.png 


    1.什么是雪花 ID?


    雪花 ID(Snowflake ID)是一个用于分布式系统中生成唯一 ID 的算法,由 Twitter 公司提出。它的设计目标是在分布式环境下高效地生成全局唯一的 ID,具有一定的有序性。


    雪花 ID 的结构如下所示:
    image.png
    这四部分代表的含义



    1. 符号位:最高位是符号位,始终为 0,1 表示负数,0 表示正数,ID 都是正整数,所以固定为 0。

    2. 时间戳部分:由 41 位组成,精确到毫秒级。可以使用该 41 位表示的时间戳来表示的时间可以使用 69 年。

    3. 节点 ID 部分:由 10 位组成,用于表示机器节点的唯一标识符。在同一毫秒内,不同的节点生成的 ID 会有所不同。

    4. 序列号部分:由 12 位组成,用于标识同一毫秒内生成的不同 ID 序列。在同一毫秒内,可以生成 4096 个不同的 ID。


    2.Java 版雪花算法实现


    接下来,我们来实现一个 Java 版的雪花算法:


    public class SnowflakeIdGenerator {

    // 定义雪花 ID 的各部分位数
    private static final long TIMESTAMP_BITS = 41L;
    private static final long NODE_ID_BITS = 10L;
    private static final long SEQUENCE_BITS = 12L;

    // 定义起始时间戳(可根据实际情况调整)
    private static final long EPOCH = 1609459200000L;

    // 定义最大取值范围
    private static final long MAX_NODE_ID = (1L << NODE_ID_BITS) - 1;
    private static final long MAX_SEQUENCE = (1L << SEQUENCE_BITS) - 1;

    // 定义偏移量
    private static final long TIMESTAMP_SHIFT = NODE_ID_BITS + SEQUENCE_BITS;
    private static final long NODE_ID_SHIFT = SEQUENCE_BITS;

    private final long nodeId;
    private long lastTimestamp = -1L;
    private long sequence = 0L;

    public SnowflakeIdGenerator(long nodeId) {
    if (nodeId < 0 || nodeId > MAX_NODE_ID) {
    throw new IllegalArgumentException("Invalid node ID");
    }
    this.nodeId = nodeId;
    }

    public synchronized long generateId() {
    long currentTimestamp = timestamp();
    if (currentTimestamp < lastTimestamp) {
    throw new IllegalStateException("Clock moved backwards");
    }
    if (currentTimestamp == lastTimestamp) {
    sequence = (sequence + 1) & MAX_SEQUENCE;
    if (sequence == 0) {
    currentTimestamp = untilNextMillis(lastTimestamp);
    }
    } else {
    sequence = 0L;
    }
    lastTimestamp = currentTimestamp;
    return ((currentTimestamp - EPOCH) << TIMESTAMP_SHIFT) |
    (nodeId << NODE_ID_SHIFT) |
    sequence;
    }

    private long timestamp() {
    return System.currentTimeMillis();
    }

    private long untilNextMillis(long lastTimestamp) {
    long currentTimestamp = timestamp();
    while (currentTimestamp <= lastTimestamp) {
    currentTimestamp = timestamp();
    }
    return currentTimestamp;
    }
    }

    调用代码如下:


    public class Main {
    public static void main(String[] args) {
    // 创建一个雪花 ID 生成器实例,传入节点 ID
    SnowflakeIdGenerator idGenerator = new SnowflakeIdGenerator(1);
    // 生成 ID
    long id = idGenerator.generateId();
    System.out.println(id);
    }
    }

    其中,nodeId 表示当前节点的唯一标识,可以根据实际情况进行设置。generateId 方法用于生成雪花 ID,采用同步方式确保线程安全。具体的生成逻辑遵循雪花 ID 的位运算规则,结合当前时间戳、节点 ID 和序列号生成唯一的 ID。



    需要注意的是,示例中的时间戳获取方法使用了 System.currentTimeMillis(),根据实际需要可以替换为其他更精确的时间戳获取方式。同时,需要确保节点 ID 的唯一性,避免不同节点生成的 ID 重复。



    3.雪花算法问题


    虽然雪花算法是一种被广泛采用的分布式唯一 ID 生成算法,但它也存在以下几个问题:



    1. 时间回拨问题:雪花算法生成的 ID 依赖于系统的时间戳,要求系统的时钟必须是单调递增的。如果系统的时钟发生回拨,可能导致生成的 ID 重复。时间回拨是指系统的时钟在某个时间点之后突然往回走(人为设置),即出现了时间上的逆流情况。

    2. 时钟回拨带来的可用性和性能问题:由于时间依赖性,当系统时钟发生回拨时,雪花算法需要进行额外的处理,如等待系统时钟追上上一次生成 ID 的时间戳或抛出异常。这种处理会对算法的可用性和性能产生一定影响。

    3. 节点 ID 依赖问题:雪花算法需要为每个节点分配唯一的节点 ID 来保证生成的 ID 的全局唯一性。节点 ID 的分配需要有一定的管理和调度,特别是在动态扩容或缩容时,节点 ID 的管理可能较为复杂。


    4.如何解决时间回拨问题?


    百度 UidGenerator 框架中解决了时间回拨的问题,并且解决方案比较经典,所以咱们这里就来给大家分享一下百度 UidGenerator 是怎么解决时间回拨问题的?



    UidGenerator 介绍:UidGenerator 是百度开源的一个分布式唯一 ID 生成器,它是基于 Snowflake 算法的改进版本。与传统的 Snowflake 算法相比,UidGenerator 在高并发场景下具有更好的性能和可用性。它的实现源码在:github.com/baidu/uid-g…



    UidGenerator 是这样解决时间回拨问题的:UidGenerator 的每个实例中,都维护一个本地时钟缓存,用于记录当前时间戳。这个本地时钟会定期与系统时钟进行同步,如果检测到系统时钟往前走了(出现了时钟回拨),则将本地时钟调整为系统时钟。


    4.为什么要使用雪花 ID 替代数据库自增 ID?


    数据库自增 ID 只适用于单机环境,但如果是分布式环境,是将数据库进行分库、分表或数据库分片等操作时,那么数据库自增 ID 就有问题了。


    例如,数据库分片之后,会在同一张业务表的分片数据库中产生相同 ID(数据库自增 ID 是由每个数据库单独记录和增加的),这样就会导致,同一个业务表的竟然有相同的 ID,而且相同 ID 背后存储的数据又完全不同,这样业务查询的时候就出问题了。


    所以为了解决这个问题,就必须使用分布式中能保证唯一性的雪花 ID 来替代数据库的自增 ID。


    5.扩展:使用 UUID 替代雪花 ID 行不行?


    如果单从唯一性来考虑的话,那么 UUID 和雪花 ID 的效果是一致的,二者都能保证分布式系统下的数据唯一性,但是即使这样,也不建议使用 UUID 替代雪花 ID,因为这样做的问题有以下两个:



    1. 可读性问题:UUID 内容很长,但没有业务含义,就是一堆看不懂的“字母”。

    2. 性能问题:UUID 是字符串类型,而字符串类型在数据库的查询中效率很低。


    所以,基于以上两个原因,不建议使用 UUID 来替代雪花 ID。


    小结


    数据库自增 ID 只适用于单机数据库环境,而对于分库、分表、数据分片来说,自增 ID 不具备唯一性,所以要要使用雪花 ID 来替代数据库自增 ID。但雪花算法依然存在一些问题,例如时间回拨问题、节点过度依赖问题等,所以此时,可以使用雪花算法的改进框架,如百度的 UidGenerator 来作为数据库的 ID 生成方案会比较好。


    作者:Java中文社群
    来源:juejin.cn/post/7307066138487521289
    收起阅读 »

    安卓拍照、裁切、选取图片实践

    安卓拍照、裁切、选取图片实践 前言 最近项目里面有用到裁切功能,没弄多复杂,就是系统自带的,顺便就总结了一下系统拍照、裁切、选取的使用。网上的资料说实话真是没什么营养,但是Android官网上的说明也有点太简单了,真就要实践出真理。 更新 最近花了点时间把拍照...
    继续阅读 »

    安卓拍照、裁切、选取图片实践


    前言


    最近项目里面有用到裁切功能,没弄多复杂,就是系统自带的,顺便就总结了一下系统拍照、裁切、选取的使用。网上的资料说实话真是没什么营养,但是Android官网上的说明也有点太简单了,真就要实践出真理。


    更新


    最近花了点时间把拍照、裁切的功能整理了下,并解决了下Android11上裁切闪退的问题、相册裁切闪退问题,就不多写一篇文章了,可以看我github的demo:


    TakePhotoFragment.kt


    BitmapFileUtil.kt


    拍照


    本来拍照是没什么难度的,不就是调用intent去系统相机拍照么,但是由于文件权限问题,Uri这东西就能把人很头疼。下面是代码(onActivityResult见后文):


        private fun openCamera() {
    val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
    // 应用外部私有目录:files-Pictures
    val picFile = createFile("Camera")
    val photoUri = getUriForFile(picFile)
    // 保存路径,不要uri,读取bitmap时麻烦
    picturePath = picFile.absolutePath
    // 给目标应用一个临时授权
    intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
    //android11以后强制分区存储,外部资源无法访问,所以添加一个输出保存位置,然后取值操作
    intent.putExtra(MediaStore.EXTRA_OUTPUT, photoUri)
    startActivityForResult(intent, REQUEST_CAMERA_CODE)
    }

    private fun createFile(type: String): File {
    // 在相册创建一个临时文件
    val picFile = File(requireContext().getExternalFilesDir(Environment.DIRECTORY_PICTURES),
    "${type}_${System.currentTimeMillis()}.jpg")
    try {
    if (picFile.exists()) {
    picFile.delete()
    }
    picFile.createNewFile()
    } catch (e: IOException) {
    e.printStackTrace()
    }

    // 临时文件,后面会加long型随机数
    // return File.createTempFile(
    // type,
    // ".jpg",
    // requireContext().getExternalFilesDir(Environment.DIRECTORY_PICTURES)
    // )

    return picFile
    }

    private fun getUriForFile(file: File): Uri {
    // 转换为uri
    return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
    //适配Android 7.0文件权限,通过FileProvider创建一个content类型的Uri
    FileProvider.getUriForFile(
    requireActivity(),
    "com.xxx.xxx.fileProvider", file
    )
    } else {
    Uri.fromFile(file)
    }
    }

    简单说明


    这里的file是使用getExternalFilesDir和Environment.DIRECTORY_PICTURES生成的,它的文件保存在应用外部私有目录:files-Pictures里面。这里要注意不能存放在内部的私有目录里面,不然是无法访问的,外部私有目录虽然也是私有的,但是外面是可以访问的,这里拿官网上的说明:



    在搭载 Android 9(API 级别 28)或更低版本的设备上,只要您的应用具有适当的存储权限,就可以访问属于其他应用的应用专用文件。为了让用户更好地管理自己的文件并减少混乱,以 Android 10(API 级别 29)及更高版本为目标平台的应用在默认情况下被授予了对外部存储空间的分区访问权限(即分区存储)。启用分区存储后,应用将无法访问属于其他应用的应用专属目录。



    Uri的获取


    再一个比较麻烦的就是Uri的获取了,网上有一大堆资料,不过我这也贴一下,网上的可能有问题。



    manifest.xml



            <provider
    android:name="androidx.core.content.FileProvider"
    android:authorities="com.xxx.xxx.fileProvider"
    android:exported="false"
    android:grantUriPermissions="true">

    <meta-data
    android:name="android.support.FILE_PROVIDER_PATHS"
    android:resource="@xml/file_paths"
    />

    </provider>


    res -> xml -> file_paths.xml



    <?xml version="1.0" encoding="utf-8"?>
    <paths xmlns:android="http://schemas.android.com/apk/res/android">
    <!--1、对应内部内存卡根目录:Context.getFileDir()-->
    <files-path
    name="int_root"
    path="/" />

    <!--2、对应应用默认缓存根目录:Context.getCacheDir()-->
    <cache-path
    name="app_cache"
    path="/" />

    <!--3、对应外部内存卡根目录:Environment.getExternalStorageDirectory()-->
    <external-path
    name="ext_root"
    path="/" />

    <!--4、对应外部内存卡根目录下的APP公共目录:Context.getExternalFileDir(String)-->
    <external-files-path
    name="ext_pub"
    path="/" />

    <!--5、对应外部内存卡根目录下的APP缓存目录:Context.getExternalCacheDir()-->
    <external-cache-path
    name="ext_cache"
    path="/" />

    </paths>

    ps. 注意authorities这个最好填自己的包名,不然有两个应用用了同样的authorities,后面的应用会安装不上。


    path里面填 “/” 和 “*” 是有区别的,前者包含了子目录,后面只包含当前目录,最好就是用 “/”,不然创建个子文件夹,到时候访问搞出了线上问题,那就凉凉喽(还好我遇到的时候测试测出来了)。


    打开相册


    这里打开相册用的是SAF框架,使用intent去选取(onActivityResult见后文)。


        private fun openAlbum() {
    val intent = Intent()
    intent.type = "image/*"
    intent.action = "android.intent.action.GET_CONTENT"
    intent.addCategory("android.intent.category.OPENABLE")
    startActivityForResult(intent, REQUEST_ALBUM_CODE)
    }

    裁切


    裁切这里比较麻烦,参数比较多,而且Uri那里有坑,不能使用provider,再一个就是图片传递那因为安卓版本变更,不会传略缩图了,很坑。


        private fun cropImage(path: String) {
    cropImage(getUriForFile(File(path)))
    }

    private fun cropImage(uri: Uri) {
    val intent = Intent("com.android.camera.action.CROP")
    // Android 7.0需要临时添加读取Url的权限
    intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
    // intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
    intent.setDataAndType(uri, "image/*")
    // 使图片处于可裁剪状态
    intent.putExtra("crop", "true")
    // 裁剪框的比例(根据需要显示的图片比例进行设置)
    // if (Build.MANUFACTURER.contains("HUAWEI")) {
    // //硬件厂商为华为的,默认是圆形裁剪框,这里让它无法成圆形
    // intent.putExtra("aspectX", 9999)
    // intent.putExtra("aspectY", 9998)
    // } else {
    // //其他手机一般默认为方形
    // intent.putExtra("aspectX", 1)
    // intent.putExtra("aspectY", 1)
    // }

    // 设置裁剪区域的形状,默认为矩形,也可设置为圆形,可能无效
    // intent.putExtra("circleCrop", true);
    // 让裁剪框支持缩放
    intent.putExtra("scale", true)
    // 属性控制裁剪完毕,保存的图片的大小格式。太大会OOM(return-data)
    // intent.putExtra("outputX", 400)
    // intent.putExtra("outputY", 400)

    // 生成临时文件
    val cropFile = createFile("Crop")
    // 裁切图片时不能使用provider的uri,否则无法保存
    // val cropUri = getUriForFile(cropFile)
    val cropUri = Uri.fromFile(cropFile)
    intent.putExtra(MediaStore.EXTRA_OUTPUT, cropUri)
    // 记录临时位置
    cropPicPath = cropFile.absolutePath

    // 设置图片的输出格式
    intent.putExtra("outputFormat", Bitmap.CompressFormat.JPEG.toString())

    // return-data=true传递的为缩略图,小米手机默认传递大图, Android 11以上设置为true会闪退
    intent.putExtra("return-data", false)

    startActivityForResult(intent, REQUEST_CROP_CODE)
    }

    回调处理


    下面是对上面三个操作的回调处理,一开始我觉得uri没什么用,还制造麻烦,后面发现可以通过流打开uri,再去获取bitmap,好像又不是那么麻烦了。


        override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)
    if (resultCode == RESULT_OK) {
    when(requestCode) {
    REQUEST_CAMERA_CODE -> {
    // 通知系统文件更新
    // requireContext().sendBroadcast(Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE,
    // Uri.fromFile(File(picturePath))))
    if (!enableCrop) {
    val bitmap = getBitmap(picturePath)
    bitmap?.let {
    // 显示图片
    binding.image.setImageBitmap(it)
    }
    }else {
    cropImage(picturePath)
    }
    }
    REQUEST_ALBUM_CODE -> {
    data?.data?.let { uri ->
    if (!enableCrop) {
    val bitmap = getBitmap("", uri)
    bitmap?.let {
    // 显示图片
    binding.image.setImageBitmap(it)
    }
    }else {
    cropImage(uri)
    }
    }
    }
    REQUEST_CROP_CODE -> {
    val bitmap = getBitmap(cropPicPath)
    bitmap?.let {
    // 显示图片
    binding.image.setImageBitmap(it)
    }
    }
    }
    }
    }

    private fun getBitmap(path: String, uri: Uri? = null): Bitmap? {
    var bitmap: Bitmap?
    val options = BitmapFactory.Options()
    // 先不读取,仅获取信息
    options.inJustDecodeBounds = true
    if (uri == null) {
    BitmapFactory.decodeFile(path, options)
    }else {
    val input = requireContext().contentResolver.openInputStream(uri)
    BitmapFactory.decodeStream(input, null, options)
    }

    // 预获取信息,大图压缩后加载
    val width = options.outWidth
    val height = options.outHeight
    Log.d("TAG", "before compress: width = " +
    options.outWidth + ", height = " + options.outHeight)

    // 尺寸压缩
    var size = 1
    while (width / size >= MAX_WIDTH || height / size >= MAX_HEIGHT) {
    size *= 2
    }
    options.inSampleSize = size
    options.inJustDecodeBounds = false
    bitmap = if (uri == null) {
    BitmapFactory.decodeFile(path, options)
    }else {
    val input = requireContext().contentResolver.openInputStream(uri)
    BitmapFactory.decodeStream(input, null, options)
    }
    Log.d("TAG", "after compress: width = " +
    options.outWidth + ", height = " + options.outHeight)

    // 质量压缩
    val baos = ByteArrayOutputStream()
    bitmap!!.compress(Bitmap.CompressFormat.JPEG, 80, baos)
    val bais = ByteArrayInputStream(baos.toByteArray())
    options.inSampleSize = 1
    bitmap = BitmapFactory.decodeStream(bais, null, options)

    return bitmap
    }

    这里还做了一个图片的质量压缩和采样压缩,需要注意的是采样压缩的采样率只能是2的倍数,如果需要按任意比例采样,需要用到Matrix,不是很难,读者可以研究下。


    权限问题


    如果你发现你没有申请权限,那你的去申请一下相机权限;如果你发现你还申请了储存权限,那你可以试一下去掉储存权限,实际还是可以使用的,因为这里并没有用到外部储存,都是应用的私有储存内,具体关于储存的适配,可以看我转载的这几篇文章,我觉得写的非常好:


    Android 存储基础


    Android 10、11 存储完全适配(上)


    Android 10、11 存储完全适配(下)


    结语


    以上代码都经过我这里实践了,确认了可用,可能写法不是最优,可以避免使用绝对路径,只使用Uri。至于请求码、布局什么的,读者自己改一下加一个就行,核心部分已经在这了。如果需要完整代码,可以看下篇文章末尾!


    Android 不申请权限储存、删除相册图片


    作者:方大可
    来源:juejin.cn/post/7222874734186037285
    收起阅读 »