注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

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
收起阅读 »

去寺庙做义工,有益身心健康

《乔布斯传》中写到:乔布斯把对事物专注的能力和对简洁的热爱归功于他的禅修。他说:“禅修磨炼了他对直觉的欣赏能力,教他如何过滤掉任何能分散时间和精力的其它不必要的事情,在他的身上培养出了专注基于至简主义的审美观。” 如何在当今物欲横流的浮躁社会里不沦陷其中?如何...
继续阅读 »

《乔布斯传》中写到:乔布斯把对事物专注的能力和对简洁的热爱归功于他的禅修。他说:“禅修磨炼了他对直觉的欣赏能力,教他如何过滤掉任何能分散时间和精力的其它不必要的事情,在他的身上培养出了专注基于至简主义的审美观。”


如何在当今物欲横流的浮躁社会里不沦陷其中?如何在每天奔波忙碌之后却不内心疲惫、焦虑?如何在巨大的工作与生活压力下保持一颗平和的心?如何在经历感情、友情和亲情的起起落落后看破放下?如何改变透支健康和生命的人生模式?


程序员无疑是一个高压的职业,尤其是在头部公司工作的程序员们,工作压力更是大。并且在互联网行业,禅修并不是一件新鲜事。我们不一定要正儿八经地参加禅修活动,只是去寺庙走一走,呼吸一下新鲜空气,给寺庙干点活,对身心健康的帮助也会很大。


我与寺庙


我最早接触寺庙是在2011年上军校的时候,我的一个老师,作为大校,经常在课上分享他周末在南京附近寺庙的奇闻轶事,也会分享他自己的一些人生体验和感悟,勾起了我对寺庙生活的向往。


2013年,作为现役军人,我跑到了江西庐山的东林寺做了一个礼拜的义工,在那里,每天早上四点起床早上寺庙早课,负责三餐的行堂,也作为机动义工,干一些杂活,比如卸菜、组装床等,晚上有时也可以听寺庙的传统文化课。


2013年底,我申请退出现役,于是14年春就可以休假了,根据流程不定期去各部门办理手续即可,期间一个周末,我弟带我去凤凰岭玩,偶遇一个皈依法会,为了能看到传说中的北大数学天才,我填了一个义工表,参加了皈依仪式。


因为没有考虑政府安排的工作,所以打算考个研,期间不时会去凤凰岭的寺庙参加活动。考完研后,到18年春季,周末节假日,基本都会去这个寺庙做义工,累计得有200天以上。


期间,作为骨干义工,参与了该寺庙组织的第二至第四届的IT禅修营,负责行堂、住宿和辅导员等相关的工作。


很多人都听过这样一件往事:2010年,张小龙(微信之父)偶然入住一个寺院,当时正是微信研发的关键时刻,因为几个技术难题,张小龙连续几天彻夜难眠,终于一气之下把资料撕得粉碎。


没想到负责打扫卫生的僧人看到后,竟然帮他把资料重新粘贴了起来,还顺手写下了几条建议。张小龙非常惊讶,打听过后才知道这位扫地僧出家前曾混迹IT界,是个著名的极客。


经扫地僧点化,张小龙回到广州苦攻一年后,微信终于大成。这件事传的很广也很玄乎,可信度不会太高,不过故事中张小龙入住的寺院,就是我常去的寺庙。


至于在故事中懂得IT的扫地僧,在这里遇到其实不是什么奇怪的事,你还有可能遇到第47届国际数学奥赛金牌得主贤宇法师,他来自北大数学系;或者是禅兴法师,他是清华大学流体力学博士;又或者贤启法师,他是清华大学核能和热能物理博士。


“扫地只不过是我的表面工作,我真正的职业是一位研究僧。” 《少林足球》这句台词的背后,隐藏着关于这个寺庙“高知僧团”的一个段子。


因为各种不可描述的原因,18年9月之后,我就很少去这个寺庙了,但我知道我依然很向往寺庙的生活。于是22年春,我下定决心离开北京去深圳,其中就有考虑到深圳后,可以去弘法寺或弘源寺做义工。


去了一次弘法寺,感觉那边人太多,后面去了一次弘源寺后,感觉这里比较适合我,人少很安静,不堵车的话,开车只需20分钟到家。


目前,只要我有时间,我都会去弘源寺干一天临时义工,或者住上几天。


何为禅?


禅,是心智的高度成熟状态。直至印度词汇传入,汉语音译为“禅那”,后世简称为“禅”,汉译意思有“静虑”、“思维修”等。


禅修的方法就是禅法,禅法是心法,并不固着于某种具体形式,也不限于宗教派别。从泛义来说,任何一种方法,如果能够让你的心灵成熟,了解生命的本质,让心获得更高层次的证悟,并从而获得生命究竟意义的了悟。这样的方法就是禅修!


从狭义来说,在绵延传承数千年的漫长时空里,形成各种系统的修行方法,存在于各种教派中。现存主要有传承并盛行于南传佛教国家的原始佛教禅法与传承并盛行于中国汉传佛教的祖师禅。


如来禅是佛陀的原始教法,注重基础练习,强调修行止观。祖师禅是中国禅宗祖师的教法,强调悟性、觉性,推崇顿悟,以参话头为代表,以开悟、明心见性为目的。


我们普遍缺乏自我觉察,甚至误解了包括自由在内的生命状态真义。禅修中,会进入深刻自我觉察中,有机会与自己整合,从而开启真我。


近年来,禅修在西方非常流行,像美国的学校、医疗机构和高科技公司都广泛地在进行打坐、禅修。美国有些科学家曾做过一个实验,实验对象是长期禅修的修行人。在实验室中,实验者一边用脑电波图测量脑波的变化,一边用功能性核磁共振测量脑部活动的位置。


最后得出结论:通过禅修,不但能够短期改变脑部的活动,而且非常有可能促成脑部永久的变化。这就是说:通过禅定,可以有效断除人的焦虑、哀伤等很多负面情绪,创造出心灵的幸福感,甚至可以重塑大脑结构。


禅修能够修复心智、疗愈抑郁、提升智慧,让我们重获身心的全面健康!禅修让人的内心变得安静。在禅修时,人能放松下来,专注于呼吸,使内心归于平静,身体和心灵才有了真正的对话与接触。


“禅修是未来科技世界中的生存必需品”时代杂志曾在封面报道中这样写道。在硅谷,禅修被认为是新的咖啡因,一种能释放能量与创造力的全新“燃料”。


禅修也帮助过谷歌、Facebook、Twitter高管们走出困惑,国内比较知名则有搜狐的张朝阳和阿里的马云,还有微信之父张小龙的传说。


对于他们来说,商海的起伏伴随着心海的沉浮,庞大的财富、名声与地位带来的更多的不是快乐,但是禅修,却在一定程度上给他们指点迷津,带领他们脱离现代社会的痛苦、让内心更加平静!


乔布斯的禅修故事


乔布斯和禅修,一直有着很深的渊源。乔布斯是当世最伟大的企业家之一,同时也是一名虔诚的禅宗教徒。他少有慧根,17岁那年,他远赴印度寻找圣人寻求精神启蒙,18岁那年,他开始追随日本禅师乙川弘文学习曹洞宗的禅法。


年轻的时候,乔布斯去印度,在印度体验,呆了七个月。乔布斯在印度干了些什么,我们不得而知。不过据我推测,也就是四处逛一逛,看一看,可能会去一些寺庙,拜访一些僧人。


我从来不认为,他遇到了什么高人,或者在印度的小村庄一待,精神就受到了莫大的洗礼。变化永远都是从内在发生的,外在的不过是缘分,是过客,负责提供一个合适的环境,或者提供一些必要的刺激。


但我们知道,从此以后,乔布斯的人生,就开始变得不一样了。乔布斯的人生追求是“改变世界”,当年他劝说百事可乐总裁,来担任苹果CEO的时候所说的话:“你是愿意一辈子卖糖水,还是跟我一起改变这个世界?”激励了无数心怀梦想的朋友。


早在1973年乔布斯已经对禅有较深的领悟了。他这样说:“我对那些超越物质的形而上的学说极感兴趣,也开始注意到比知觉及意识更高的层次——直觉和顿悟。”


他还说:“因为时间有限,不要带着面具为别人而活,不要让别人的意见左右自己内心的想法,最重要的是要勇敢地忠于自己内心的直觉。”


乔布斯说:“你不能预先把点点滴滴串在一起;唯有未来回顾时,你才会明白那些点点滴滴是如何串在一起的。所以你得相信,你现在所体会的东西,将来多少会连接在一块。你得信任某个东西,直觉也好,命运也好,生命也好,或者业力。这种作法从来没让我失望,也让我的人生整个不同起来。”


他大学时学的书法,被他用来设计能够印刷出漂亮字体的计算机,尽管他在大学选修书法课时,完全不知道学这玩意能有什么用。他被自己创立的苹果公司开除,于是转行去做动画,结果在做动画的时候,遇到了自己未来的妻子。


人呐实在不知道,自己可不可以预料。你说我一个被自己创立的公司开除的失业狗,怎么就在第二份工作里遇到了一生的挚爱呢?若干年后,他回顾起自己的人生,他把这些点点滴滴串了起来,他发现,他所经历过的每一件事,都有着特殊的意义。


所以,无论面对怎样的困境,我们都不必悲观绝望,因为在剧本结束之前,你永远不知道,自己现在面对的这件事,到底是坏事还是好事。


所谓创新就是无中生有,包括思想、产品、艺术等,重大的创新我们称为颠覆。通过那则著名的广告《think different》他告诉世人:“因为只有那些疯狂到以為自己能够改变世界的人,才能真正地改变世界。”乔布斯确实改变了世界,而且不是一次,至少五次颠覆了这个世界:



  • 通过苹果电脑Apple-I,开启了个人电脑时代;

  • 通过皮克斯电脑动画公司,颠覆了整个动漫产业;

  • 通过iPod,颠覆了整个音乐产业;

  • 通过iPhone,颠覆了整个通讯产业;

  • 通过iPad,重新定义并颠覆了平板PC行业。


程序员与禅修


编程是一门需要高度专注和创造力的艺术,它要求程序员们在面对复杂问题和压力时,能够保持内心的安宁和平静。在这个快节奏、竞争激烈的行业中,如何修炼内心的禅意境界,成为程序员们更好地发挥潜力的关键。


在编程的世界里,专注是至关重要的品质。通过培养内在专注力,程序员能够集中精力去解决问题,避免被外界的干扰所困扰。以下是几种培养内在专注的方法:



  • 冥想和呼吸练习:  通过冥想和深呼吸来调整身心状态,让自己平静下来。坚持每天进行一段时间的冥想练习,可以提高专注力和注意力的稳定性。

  • 时间管理:  制定合理的工作计划和时间表,将任务分解为小的可管理的部分,避免心理上的压力。通过专注于每个小任务,逐步完成整个项目。

  • 限制干扰:  将手机静音、关闭社交媒体和聊天工具等干扰源,创造一个安静的工作环境。使用专注工作法(如番茄钟),集中精力在一项任务上,直到完成。


编程过程中会遇到各种问题和挑战,有时甚至会感到沮丧和失望。然而,保持平和的心态是非常重要的,它可以帮助程序员更好地应对压力和困难。以下是一些培养平和心态的技巧:



  • 接受不完美性:  程序永远不会是完美的,因为它们总是在不断发展和改进中。接受这一事实,并学会从错误中汲取教训。不要过于苛求自己,给自己一些宽容和理解。

  • 积极思考:  关注积极的方面,让自己的思维更加积极向上。遇到问题时,寻找解决方案而非抱怨。积极的心态能够帮助你更好地应对挑战和困难。

  • 放松和休息:  给自己合理的休息时间,让大脑得到充分的放松和恢复。休息和娱乐能够帮助你调整心态,保持平和的状态。


编程往往是一个团队合作的过程,与他人合作的能力对于一个程序员来说至关重要。以下是一些建立团队合作意识和促进内心安宁的方法:



  • 沟通与分享:  与团队成员保持良好的沟通,分享想法和问题。倾听他人的观点和建议,尊重不同的意见。积极参与和贡献团队,建立合作关系。

  • 友善和尊重:  培养友好、尊重和包容的态度。尊重他人的工作和努力,给予鼓励和支持。与团队成员建立良好的关系,创造和谐的工作环境。

  • 共享成功:  当团队取得成功时,与他人一起分享喜悦和成就感。相信团队的力量,相信集体的智慧和努力。


修炼内心安宁需要时间和长期的自我管理。通过培养专注力、平和心态、创造力和团队合作意识,程序员们可以在面对复杂的编程任务和挑战时保持内心的安宁和平静。


禅修有许多不同的境界,其中最典型的可能包括:



  • 懵懂:刚开始禅修时,可能会觉得茫然和困惑,不知该如何开始。

  • 困扰:在进行深度内省和冥想时,可能会遇到很多烦恼和难题,需耐心思考和解决。

  • 安和:通过不断地练习和开放自己的心灵,可能会进入一种更加平和和沉静的状态。

  • 祥和:当一些心理障碍得到解决,你会感受到一种更深层的平静和和谐。

  • 转化:通过不断的冥想与内省,你可以向内看到自己的内心,获得对自己和世界的新的认识和多样的观察角度。

  • 整体意识:通过冥想,您将能够超越个人的视野和言语本身,深入探究宇宙的内心,领悟更加深入和广泛的境界和意识。


程序员写代码的境界:



  • 懵懂:刚熟悉编程语言,不知做什么。

  • 困扰:可以实现需求,但仍然会被需求所困,需要耐心思考和解决。

  • 安和:通过不断练习已经可以轻易实现需求,更加平和沉静。

  • 祥和:全栈。

  • 转化:做自己的产品。

  • 整体意识:有自己的公司。


一个创业设想


打开小红书,与“疗愈”相关的笔记高达236万篇,禅修、瑜伽、颂钵等新兴疗愈方法层出不穷,无论是性价比还是高消费,总有一种疗愈方法适合你。


比起去网红景点打卡拍照卷构图卷妆造,越来越多的年轻人正在借助上香、拜神、颂钵、冥想等更为“佛系”的方式去追寻内心的宁静。放空大脑,呼吸之间天地的能量被尽数吸收体内,一切紧张、焦虑都被稀释,现实的残酷和精神的困顿,都在此间找到了出口。


在过去,简单的瑜伽和冥想就能达到这种目的,但伴随着疗愈文化的兴起与壮大,不断在传统方式之上叠加buff,才是新兴疗愈的终极奥义。


从目标人群来看,不同的禅修对应不同的人群。比如临平青龙寺即将在8月开启的禅修,就分为了企业禅修、教育禅修、功能禅修、共修禅、突破禅、网络共修等多种形式。但从禅修内容来看,各个寺庙的安排不尽相同,但基本上跳脱不出早晚功课、上殿过堂、出坡劳作、诵经礼忏、佛学讲座等环节。


艺术疗愈,是截然不同于起参禅悟道这种更亲近自然,还原本真的另一疗愈流派。具体可以细分为戏剧疗愈、绘画疗愈、音乐疗愈等多种形式。当理论逐渐趋向现代化,投入在此间的花费,也成正比增长。


绘画疗愈 ,顾名思义就是通过绘画的方式来表达自己内心的情绪。画幅的大小、用笔的轻重、空间的配置、色彩的使用,都在某种程度上反映着创作者潜意识的情感与冲突。


在绘画过程中,绘画者也同样会获得纾解和满足。也有一些课程会在绘画创作之外,添加绘画作品鉴赏的内容,通过一幅画去窥视作者的内心,寻求心灵上的共鸣,也是舒缓压力的一种渠道。


疗愈市场之所以能够发展,还是因为有越来越多人的负面情绪需要治愈。不论是工作压力还是亲密关系所带来的情绪内耗,总要有一个释放的出口。


当前,我正在尝试依托自营绘馆老师提供优质课件,打造艺培课件分享的平台或社区,做平台前期研发投入比较大,当前融资也比较困难,同时自己也需要疗愈。


所以,最近也在调研市场,评估是否可以依托自营的门店,组织绘画手工+寺庙行禅+技术专题分享的IT艺术禅修营活动,两天含住宿1999元,包括半天寺庙义工体验、半天禅修、半天绘画手工课和半天的技术专题分享。


不知道,这样的活动,大家会考虑参加吗?


总结


出家人抛弃尘世各种欲望出家修行值得尊重,但却不是修行的唯一方法,佛经里著名的维摩洁居士就是在家修行,也取得了非凡成就,六祖惠能就非常鼓励大家在世间修行,他说:“佛法在世间,不离世间觉,离世觅菩提,恰如求兔角”。


普通人的修行是在红尘欲望中的修行,和出家人截然不同,但无分高下,同样可以证悟,工作就是他们最好的修练道场。禅学的理论学习并不困难,但这只是万里长征的第一步,最重要的是,我们要在日常实践中证悟。


简单可能比复杂更难做到:你必须努力理清思路,从而使其变得简单。但最终这是值得的,因为一旦你做到了,便可以创造奇迹。”乔布斯所说的这种专注和简单是直接相关的,如果太复杂,心即散乱,就很难保持专注,只有简单,才能做到专注,只有专注,才能极致。


作者:三一习惯
来源:juejin.cn/post/7292781589477687350
收起阅读 »

浏览器沙盒你知多少😍

web
开题话: 😍随着业务环境的快速变化,安全性是开发人员和测试人员在现代 Web 开发周期中面临的最大挑战之一。构建和部署现代 Web 应用程序的复杂性会导致更多的安全漏洞。根据 IBM 和 Ponemon Institute 的数据泄露成本报告,2021 年,数...
继续阅读 »

开题话:


😍随着业务环境的快速变化,安全性是开发人员和测试人员在现代 Web 开发周期中面临的最大挑战之一。构建和部署现代 Web 应用程序的复杂性会导致更多的安全漏洞。根据 IBM 和 Ponemon Institute 的数据泄露成本报告,2021 年,数据泄露成本从 3 万美元(86 年的平均成本)上升到 2019 万美元,啧啧啧,这是该报告 4 年来的最高平均成本


因此,网络安全在软件开发生命周期中变得越来越重要,以确保用户数据安全和隐私。如果你可以开发和测试网站和 Web 应用程序而不必担心安全漏洞,那不是很好吗?👏沙盒是一种可以帮助你实现此目的的技术。沙盒是一种安全隔离应用程序、Web 浏览器和一段代码的方法。它可以防止恶意或有故障的应用程序攻击或监视你的 Web 资源和本地系统。


举个栗子➡, 在现实世界中,沙盒是被墙壁包围的儿童游乐区。它允许孩子们玩沙子,而草坪周围没有沙子。同样,沙盒浏览器创建了一个隔离的环境,用户可以在其中从第三方来源下载和安装应用程序,并在安全、隔离的环境中操作它们,即使他们行为可疑。因此,沙盒浏览器可以保护你的计算机免受额外的安全风险。


下面我们说说什么是浏览器沙盒吧!😘


本文将探讨什么是浏览器沙盒、不同类型的沙盒的优点和重要性,以及如何实现沙盒。


一、什么是浏览器沙盒?


为了防止系统或 Web 应用程序中出现安全漏洞,开发人员需要弄清楚如何处理它们。这是浏览器沙盒派上用场的时候。浏览器沙箱提供了一个安全的虚拟环境来测试有害代码或运行第三方软件,而不会损害系统的数据或本地文件。


例如,如果你在沙盒中下载恶意附件,它不会损坏系统的现有文件或资源。沙盒具有同源功能,它允许JavaScript在网页上添加或自定义元素,同时限制对外部JSON文件的访问。


今天,流行的网络浏览器,如Chrome,Firefox和Edge,都带有内置的沙箱。沙盒浏览器的最终目标是保护你的机器免受与浏览相关的风险。因此,如果用户从网站下载恶意软件,该软件将下载到浏览器的沙箱中。关闭沙箱时,其中的所有内容(包括有害代码)都会被清除。


浏览器沙盒使用两种隔离技术来保护用户的 Web 浏览活动和系统硬件、本地 PC 和网络:



  • 本地浏览器隔离

  • 远程浏览器隔离


本地浏览器隔离


本地浏览器隔离是一种传统的浏览器隔离技术,它在沙盒中运行虚拟浏览器或在用户的本地基础结构上运行虚拟机。它有助于将数据与外部安全威胁和不安全浏览隔离开来。例如,如果恶意元素潜入,影响将仅限于沙盒浏览器和虚拟机。


远程浏览器隔离


远程浏览器隔离涉及一种虚拟化技术,其中浏览器在基于云的服务器(公共云和私有云)上运行。在远程隔离中,用户的本地系统没有浏览活动,浏览器沙盒、过滤和风险评估在远程服务器上进行。


远程浏览器隔离涉及两种隔离用户本地基础结构和 Web 内容的方法:



  1. DOM 镜像:在这种技术中,浏览器并不完全与用户的本地系统隔离。但是,DOM 镜像技术会过滤恶意内容,并将其余内容呈现给用户。

  2. 可视化流式处理:此技术提供完全的远程浏览器隔离。可视化流式处理的工作方式类似于 VDI(虚拟桌面基础结构)系统,其中浏览器在基于云的服务器上运行,并将视觉输出显示到用户的本地计算机。


二、为什么浏览器沙盒很重要?


现代 Web 技术正在迅速扩展,从而使用户能够顺利开发和发布网站和 Web 应用程序。与此同时,对Web应用程序的需求也在以前所未有的速度增长。根据Imperva的一项调查,Web应用程序是50%数据泄露的来源。因此,拥有一个安全、受控的环境(如沙盒浏览器)至关重要,以便在不危及本地基础设施和系统资源的情况下执行操作。


例如,用户在沙盒中运行 Web 浏览器。如果恶意代码或文件利用 Web 浏览器漏洞,则沙盒中的影响受到限制。此外,引爆程序可以帮助发现新的漏洞并在 Web 浏览器中缓解它们。但是,如果禁用沙盒浏览器,恶意程序可以利用 Web 浏览器漏洞并损坏用户的本地系统和资源。


三、沙盒的好处


将沙盒合并到 Web 开发工作流中有很多优点。下面提到了一些优点😍😍:



  • 沙盒使设备和操作系统免于面临潜在威胁。

  • 与未经授权的一方或供应商合作时,最好使用沙盒环境。在部署内容之前,你可以使用沙盒来测试可疑代码或软件。

  • 沙盒可以帮助防止零日攻击。由于开发人员无法发现漏洞的即时补丁,因此零日攻击本质上是有害的。因此,沙盒通过向系统隐藏恶意软件来减轻损害。

  • 沙盒环境隔离威胁和病毒。这有助于网络专家研究和分析威胁趋势。它可以防止未来的入侵和识别网络漏洞。

  • 沙盒应用程序是一种混合解决方案,这意味着它们可以在本地和远程部署(基于云的服务器)。混合系统比传统解决方案更安全、更可靠、更具成本效益。

  • 沙盒和 RDP(远程桌面协议)设置可帮助企业确保安全的外部网络连接。

  • 沙盒可以与防病毒或其他安全工具和策略结合使用,以增强整个安全生态系统。


四、哪些应用正在沙盒化😎?


我们在日常工作流程中使用的大部分资产(如在线浏览器、网页、PDF、移动应用程序和 Windows 应用程序)都是沙盒化的。


下面列出了正在沙盒化的应用:



  • Web 浏览器:可能易受攻击的浏览器在沙盒环境中运行。

  • 浏览器插件:加载内容时,浏览器插件在沙盒中运行。沙盒浏览器插件(如 Java)更容易受到攻击。

  • 网页:浏览器以沙盒模式加载网页。由于网页是内置的 JavaScript,因此它无法访问本地计算机上的文件。

  • 移动应用:与 Android 和 iOS 一样,移动操作系统在沙盒模式下运行其应用。如果他们希望访问你的位置、联系人或其他信息,他们会弹出权限框。

  • Windows 软件和程序:在对系统文件进行更改之前,Windows 操作系统中的用户帐户控制 (UAC) 会请求你的许可。UAC 的功能类似于沙盒,但它不提供完整的保护。但是,不应禁用它。


五、不同类型的沙盒


在浏览器沙盒的这一部分中,我们将讨论不同类型的沙盒。沙盒分为三类:



  1. 应用程序沙盒

  2. 浏览器沙盒

  3. 安全沙盒


应用程序沙盒


使用应用程序沙箱,你可以在沙盒中运行不受信任的应用程序,以防止它们损坏本地系统或窃取数据。它有助于创建一个安全的环境,使应用程序可以在其中运行而不会损坏系统。通过将应用与用户的本地计算机隔离,应用程序沙盒增强了应用的完整性。


浏览器沙盒


可以在沙盒中执行基于浏览器的潜在恶意应用程序,以防止它们对你的本地基础架构造成损害。它导致建立一个安全的环境,在该环境中,Web 应用程序可以在不影响系统的情况下运行。引爆技术可以帮助发现 Web 浏览器中的新漏洞并缓解其。


安全沙盒


安全沙盒允许你探索和检测可疑代码。它扫描附件并识别潜在有害网站的列表,并确定是否下载或安装受感染的文件。


六、使用内置沙盒浏览器进行沙盒分析


沙盒预装在流行的浏览器中,如Chromium,Firefox和Edge,以保护你的系统免受浏览漏洞的影响。让我们看看沙盒在不同浏览器中的工作原理:


Chromium浏览器沙盒


Google Chrome和Microsoft Edge建立在Chromium浏览器上。代理和目标是构成 Chromium 浏览器沙箱的两个进程。目标进程是子进程,而浏览器进程是代理进程。目标进程的代码在沙盒环境中执行。代理进程在子进程和硬件资源之间操作,为子进程提供资源。


火狐浏览器沙盒


为了保护本地系统免受威胁,Firefox 在沙箱中执行不受信任的代码。Firefox 浏览器是使用父进程和子进程沙盒化的。浏览时,潜在的恶意程序会在沙盒中运行。在沙盒期间,父进程是子进程与其余系统资源之间的中介。


你可以更改 Firefox 浏览器中的沙盒程度,使其限制最少、中等或高度严格:



  • 级别 0:限制最少

  • 1级:中等

  • 第 2 级:高度限制


要检查 Firefox 沙盒浏览器的级别,请在地址栏中传递以下命令:


arduino
复制代码
about:config

在页面上,它将加载 Firefox 可配置变量。现在,在配置页面上点击“CTRL + F”,在搜索框中输入以下命令,然后按“Enter”。


image.png


image.png


Edge浏览器沙盒


当启动 Edge 沙盒浏览器 Windows 10 时,你将看到一个全新的桌面,该桌面仅具有“回收站”和 Edge 快捷方式。它显示“开始菜单”和其他图标,但它们在此沙盒环境中不起作用。你可以在标准Windows 10上访问它们,而不是沙盒Windows 10。


关闭 Edge 浏览器沙盒后,你的浏览器历史记录将不再可用。你的 ISP 可能会跟踪沙盒中的操作,但此数据不可审核。


七、禁用谷歌浏览器沙盒


在执行基于 Chrome 的沙盒测试时,你可能会遇到这样一种情况,即沙盒功能可能会导致 Chrome 浏览器闪烁以下错误:“应用程序初始化失败”。


在这种情况下,你可能需要停用 Chrome 浏览器沙盒。以下是以下步骤:



  1. 如果你没有 Google Chrome 沙盒快捷方式,请创建一个。

  2. 右键单击快捷方式,然后选择“属性”。选择属性

  3. 在目标中提供的应用路径中输入以下命令:



css
复制代码
--no-sandbox

属性 2



  1. 单击“应用”,然后单击“确定”。


八、浏览器沙盒:它是100%安全的吗💢?


大多数 Web 浏览器都使用沙盒。但是,互联网仍然是病毒和其他恶意软件的来源。沙盒的级别似乎有所不同。不同的 Web 浏览器以不同的方式实现沙盒,因此很难弄清楚它们是如何工作的。但是,这并不意味着所有网络浏览器都是不安全的。另一方面,浏览器沙箱可以使它们更安全。


但是,如果你问它是否提供100%的安全性,答案是否定的。如果某些浏览器组件使用 Flash 和 ActiveX,则它们可能会延伸到沙箱之外。


九、写在最后


企业受到高级持续性威胁 (APT) 的攻击,沙盒可以保护它们。通过查看前方的情况,你可以为未知攻击做好准备。你可以在隔离的环境中测试和开发应用程序,而不会因沙盒而损害本地系统资产。Sandboxie,BitBox和其他沙盒工具在市场上可用。但是,在沙盒中设置和安装不同的浏览器需要时间。


下回再说说前端——浏览器的安全性吧,本文就说到这里了😘


感谢jym浏览本文🤞,若有更好的建议,欢迎评论区讨论哈🌹。


如果jym觉得这篇干货很有用,加个关关🥰,点个赞赞🥰,后面我会继续卷,分享更多干货!感谢支持!😍


作者:珑墨
链接:https://juejin.cn/post/7241492186917847077
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

业绩一般般,如何写出漂亮的年终总结

互联网公司都讲究狼性文化。其中有一个政策就是强制的绩效考评。比如阿里的361政策,就是说每年年底都要对员工进行考评。30%的人超出预期,也就是3.75;60%的人符合预期,也就是3.5;10%的人不符合预期,也就是3.25。 为什么说是强制的绩效考评,是因为团...
继续阅读 »

互联网公司都讲究狼性文化。其中有一个政策就是强制的绩效考评。比如阿里的361政策,就是说每年年底都要对员工进行考评。30%的人超出预期,也就是3.75;60%的人符合预期,也就是3.5;10%的人不符合预期,也就是3.25。


为什么说是强制的绩效考评,是因为团队中如果有10个人。**无论这10个人产出有多多,业绩结果有多好,都会有一个人被评为不符合预期。**你能明白这个有多恶心了吧。



这篇文章的技巧任何岗位都适用,但是本人是研发,熟悉研发的工作内容,所以例子从研发的角度来写。



为什么年终总结很重要


虽然说这个很恶心。但绩效考评直接影响到年终奖以及下个财年的晋升,所以年底的年终总结一定要认真对待。尤其是全年的业绩相对一般般,这个时候更加要从年终总结中去体现价值


为什么这么说?有两方面的原因。


一个是因为老板并不很清楚每个人全年都做了什么,有哪些产出,遇到并解决了哪些困难。年终总结就一个很好的梳理机会,每个人的自评,能让老板更清晰的了解到每个人做了什么,从而做出排序。


另外,一级主管做的排序,并不一定是最终结果。大概率会在二级主管那边,把几个主管的排名进行重新排序。每个主管都希望自己团队多一些超出预期,少一些不符合预期。所以,针对在361边界的几个人,会被单独拿出来讨论,进行激烈的PK。这个PK的素材就是你的自评内容,我们要主动去准备主管能拿出来PK的素材,不然主管想帮你都帮不了。


所以你知道年终总结有多重要了吧。尤其是你在过去的一年中,没有做出让其他团队主管能记住的凸出表现,年终总结很多时候就决定了你能否多拿几个月年终奖。


今天就说一说业绩一般的情况下,如何写出漂亮的年终总结。内容有点多,写完发现有点长,所以我会分两篇内容,下一篇会把剩下的几个方法整理出来。感兴趣的可以公众号上看,我的公众号叫做写代码的浩


方法一:如果没有质量,那就先凑份量


业绩一般,往往不是没有工作量,而是没有亮点。很可能你做了很多事情,每天忙的焦头烂额,但这些事情做完后,并没有十分理想的结果。


就比如,你做了15个项目,每个项目都按时完成,但是每个项目上线后,都没有超预期的业务结果。


普通员工就会这样写:“按时完成需求”。稍微好一点的,会写“按时完成15个需求,没有线上bug。”


看到这种内容,主管会是什么反应,我不知道,但是我的反应是:哦、嗯、就这样吧。如果你的排序在不及预期的边界上, 你主管怎么去和另外一个组的主管PK,说你其实很优秀,做了很多工作。大概率PK失败,你还要怪主管为什么给你不及预期,是不是针对你,别人都是嫡系。


那应该怎么写。如果没有质量,那就先凑分量。你要让别人看了你的自评,觉得做了很多工作,且做事过程有计划有执行,不是胡乱做一通。主要是体现没有好结果(功劳),不是你的问题


可以试试这么写(挑核心项目写细节)



全年完成15个项目,按时完成,没有线上bug。


核心项目结果如下:


业务一:


目标:xxx


完成:调研了xx,完成了xx,业务结果xx


后续计划:xxx



方法二:越是没有内涵,越要重视形式


业绩一般,那么态度一定要好。 如何体现态度呢,就是用专业的形式暗示老板,你是一个态度认真的人。


就比如你的一项工作是做性能优化,优化目标没有达成。


普通员工会这么写:“性能优化目标xxx,优化到了xx”。这不是一看就知道没有达到目标,不及预期吗。主管看到这种也是只能叹气,想帮你都帮不了。


可以试试这么写(方法一也可以用,这里只讲解方法二)



性能优化:


目标:xxx


完成:


小目标1完成了xx,完成度80%


小目标2完成了xx,完成度120%



解释一下:



  1. 1. 把一个目标继续细分,对每一个细分目标进行罗列完成度。

  2. 2. 对关键数据使用不同颜色进行凸出

  3. 3. 数据多用图表


你看,这样一来,大目标虽然没完成,但是小目标有完成了的呀。是不是主管去PK时,就能有理由帮到你了。


总结


好了,已经1500字了,写的太长大家不喜欢看,后面几个技巧下一篇再继续写。觉得有收获可以关注下写代码的浩。


当然了,对绩效结果的影响,业绩一定是第一位的,平时一定要好好做项目,而不是“写PPT”。但是在年底,业绩一般的情况下,我们能做的就不多了,写好年终总结,算是我们最后的挣扎了吧。




我是写代码的浩,关注我,带你了解更多职场信息,也欢迎转发文章,传递行业真相!


文章看到这里,记得点右下角在看+点赞,感谢!****


作者:写代码的浩
来源:juejin.cn/post/7330825015049453578
收起阅读 »

为什么忘记密码时只能重设,不把旧密码告诉我?

某天小明在整理他的收藏夹时发现了一个以前很常逛,但已经将近半年多没去的一个论坛。小明想回去看看那边变得怎么样了,于是点进去那个论坛,输入了帐号密码,得到了密码错误的提示。 尝试了几次之后,系统提示小明可以使用「忘记密码」的功能,所以小明填了自己的 email ...
继续阅读 »

某天小明在整理他的收藏夹时发现了一个以前很常逛,但已经将近半年多没去的一个论坛。小明想回去看看那边变得怎么样了,于是点进去那个论坛,输入了帐号密码,得到了密码错误的提示。


尝试了几次之后,系统提示小明可以使用「忘记密码」的功能,所以小明填了自己的 email 之后去收件箱里查看,发现系统传来一个「重设密码」的链接。虽然说最后小明成功利用重新设定的密码登入,但有个问题让他百思不得其解:



奇怪呀,为啥要我重新设置密码,把旧的密码发到邮箱里给我不就好了?



应该有许多人都跟小明一样,有过类似的疑惑。把旧密码发给我不是很好吗,干嘛强迫我换密码?


这一个看似简单的问题,背后其实藏了许多信息安全相关的概念,就让我们慢慢寻找问题的答案,顺便学习一些基本的信息安全知识吧!


被偷走的数据库


大家应该很常看到新闻说哪个网站的数据又被偷走了,顾客个人数据全部都泄露出去。像是国外知名的网域代管网站 GoDaddy[1] 就泄露了 120 万笔用户数据,像国内的微博、淘宝等也有过数据泄露的情况发生。


这边我想带大家探讨的两个问题是:



  1. 数据真的这么容易泄露吗?

  2. 数据泄露之后,可能造成什么后果?


我们先来看第一个问题,有很多安全性的漏洞可以造成数据泄露,而有些漏洞的攻击方式,比你想的还简单一百倍。


图片


你想像中的黑客可能像上面那样,打着一大堆不知道在干嘛的指令,画面上出现很多黑底白字或是绿字的画面,完全搞不懂在干嘛,但是做着做着网站就被攻破下来了。


而事实上有些漏洞,可能在地址栏上面改几个字就攻击成功了,就算你不懂任何代码也做得到。


举例来说好了,假设今天有个购物网站,你买了一些东西之后送出订单,订单成立后跳转到订单页面,上面有着一大堆你的个人数据,例如说:姓名、收货地址、联络电话以及 Email 等等。


然后你发现订单页面的网址是 https://shop.xiaoming.cn/orders?id=14597


而正好你的订单编号也是 14597,在好奇心的驱使之下,你就试着把数字改成 14596,然后按下 Enter。


当网站载入完成之后,你竟然还真的能看到编号为 14596 的订单,上面出现一个你不认识的人的姓名、收货地址、联络电话跟 Email。


有些攻击就是这么朴实无华且枯燥,只要改个字就能看到属于其他人的数据。这时候如果你会写程序的话,就可以写个脚本自动去抓 id 是 1 一直到 id 是 15000 的数据,你就拿到了这个购物网站 15000 笔订单的资讯,也就是一万多个顾客的个人数据。


这过程中没有什么黑底白字的画面,也不用一直疯狂打字,唯一需要的只有改数字,个人数据就轻松到手。


这类型的漏洞有个专有名词,称为 IDOR,全名是:Insecure direct object references,大约就是不安全的直接数据存取的意思。漏洞产生的原因就是工程师在开发时,并没有注意到权限控管,因此让使用者能存取到其他人的数据。


有些人看到这边可能以为我只是为了文章浅显易懂,所以才举一个简化的例子,现实生活中的攻击才没这么简单。


这句话算是对了一半,大部分的网站确实都不会有这么明显的一个漏洞,攻击方式会更复杂一点。但可怕的是,还真的有些网站就是这么简单,就是改个数字就可以拿到别人的数据。


例如说这两个就是 IDOR 的真实漏洞:



  1. 享健身 xarefit 任意访问/下载所有会员个人数据[2]

  2. DoorGods 防疫门神实联制系统 IDOR 导致个人数据泄露[3]


对,不要怀疑,就真的只是在网址上改个数字而已这么容易。


以后只要看到网址列上有这种数字,就可以试着去改改看,搞不好不会写程序的你也可以发现 IDOR 的漏洞。


除了这种只要改个东西的漏洞之外,还有另外一个很常见但是需要一点技术能力才能攻破的漏洞,叫做 SQL Injection。


先来讲讲 SQL 是什么,简单来说就是跟数据库查询东西的一种程序语言。既然说是语言那就会有固定语法,若是以中文举例,大概就像是:



去找「订单数据」,给我「id 是 1 的」,按照「建立时间」排序



用「」框起来的部分代表可以变动,而其他关键字例如说「去找」、「给我」这些都是固定的,因为语法要固定才能写程序去解析。


同样以上面假想的购物网站为例,如果网址是 https://shop.xiaoming.cn/orders?id=14597 ,那网站去跟数据库拿数据时,指令大概就是:



去找「订单数据」,给我「id 是 14597 的」



因为网址列上的 id 是 14597 嘛,所以这个 id 就会被放到查询的指令去,如果 id 是别的,那查询的指令也会不一样。


这时候如果我的 id 不是数字,而是「1 的顺便给我使用者数据」,查询就变成:



去找「订单数据」,给我「id 是 1 的顺便给我使用者数据」



那整个网站的使用者数据就顺便被我抓下来了。


这个攻击之所以叫做 SQL injection,重点就在于那个 injection,攻击者「注入」了一段文字被当作指令的一部分执行,所以攻击者就可以执行任意查询。


比起上面讲的 IDOR,SQL injection 通常会更为致命,因为不只是订单数据本身,连其他数据也会被一起捞出来。所以除了订单数据,会员数据跟商品数据都有可能一起泄露。


这边也随便找两个公开的案例:



  1. 北一女中网站存在 SQL Injection 漏洞[4]

  2. 桃园高中 网站 SQL injection[5]


而防御方式就是不要把使用者输入的「1 的顺便给我使用者数据」直接当作指令,而是经过一些处理,让整段查询变成:「给我 id 是:『1 的顺便给我使用者数据』的数据」,那因为没有这个 id,所以什么事也不会发生。


个人数据泄漏了,然后呢?


前面我们已经看到了针对那些没有做好防御的网站,个人数据泄露是多么容易的一件事情。


那个人数据泄漏之后,对使用者会有什么影响呢?


大家最感同身受的应该就是诈骗电话吧,例如说某些买书的网站或是订房网站,打过来跟你说什么要分期退款,为了博取你的信任,连你买了哪本书,订了哪个房间,或是你家地址跟姓名全都讲得出来。


这些都是因为数据泄露的缘故,诈骗集团才会知道的这么清楚。


但除了这些个人数据以外,还有两个东西也会泄露,那就是你的帐号跟密码。


也许你会想说:「不就帐号跟密码吗,我就在那个网站上面改密码以后再用就好啦!」


事情也许没有你想的这么简单。如果你没有用密码管理软件的话,我大胆猜测你所有的密码可能都是同一个。因为怕记不起来嘛,所以干脆都用同一个密码。


这时候如果账号和密码泄露,黑客是不是就可以拿这个账号和密码去其他服务试试看?


拿去登你的 Google,登你的 Facebook,这时候用同一个密码的人就会被登进去。所以从表面看只是一个购物网站被入侵,但造成的结果却是你的 Google 还有 Facebook 也一起被盗了。


所以,有时候某个网站被盗帐号可能不是那个网站的问题,而是黑客在其他地方拿到了你的帐号密码,就来这边试试看,没想到就中了。


对于网站的开发者而言,保护好使用者的个人数据是天经地义的事情,保护密码也是,有没有什么好方法可以保护密码呢?


加密吗?把密码用某些算法加密,这样数据库储存的就会是加密后的结果,尽管被偷走了,黑客只要没有解密的方法就解不开。


听起来似乎是最安全的做法了,但其实还有一个问题,那就是网站的开发者还是会知道怎么解密,如果有工程师监守自盗怎么办?他还是可以知道每个使用者的密码是什么,可以把这些资讯拿去卖或者是自己利用。


嗯…似乎我们也不能怎么样,因为无论如何,开发者都需要有方法知道数据库存的密码究竟是多少吧?不然在登入的时候怎么确认帐号密码是对的?


再者,这样听起来应该够安全了,要怎么样才能更安全?难道要连网站的开发者都无法解密,都不知道密码是什么才够安全吗?


Bingo!答对了,就是要这样没错!


没有人知道你的密码,包括网站本身


事实上,网站的数据库是不会储存你的密码的。


或更精确地说,不会储存你的「原始密码」,但会储存密码经过某种运算后的结果,而且最重要的是, 这个运算是无法还原的


直接举例比较快,假设今天有个很简单的算法,可以把密码做转换,转换方式是:「数字不做转换,英文字母把 a 换成 1,b 换成 2…z 换成 26」,以此类推,第几个字母就换成几,大小写不分都一样(先假设不会有符号)。


如果密码是 abc123,转换完就变成 123123。


在使用者注册的时候,网站就把使用者输入的 abc123 转成 123123,然后存到数据库里面。因此数据库存的密码是 123123,而不是 abc123。


当使用者登入时,我们就再把输入的值用同样的逻辑转换,如果输入一样,转换后的结果就会一样对吧?就知道密码是不是正确的。


当黑客把数据库偷走以后,会拿到 123123 这组密码,那一样啊,不是可以推论出原本是 abc123 吗?不不不,没这么简单。


123123、abcabc、12cab3…这些密码转换之后,不也是 123123 吗?所以尽管知道转换规则跟结果,却没有办法还原成「唯一一个密码」,这就是这个算法厉害的地方!


这样的转换就叫做哈希(Hash),abc123 每次 hash 过后的结果都会是 123123,但是从 123123 却无法得到输入一定是 abc123,因为有其他种可能性存在。


这就是 hash 跟加密最大的不同。


加密跟解密是成对的,如果可以加密就一定可以解密,所以你知道密文跟密钥,就可以知道明文。但 hash 不同,你知道 hash 算法的结果,却无法逆推出原本的输入是什么。


而这个机制最常见的应用之一,就在于密码的储存。


在注册时把 hash 过后的密码存进数据库,登入时把输入的密码 hash 过后跟数据库比对,就知道密码是否正确。就算数据库被偷,黑客也不知道使用者的密码是什么,因为逆推不出来。


这就是为什么忘记密码的时候,网站不会跟你讲原本的密码是什么,因为网站本身也不知道啊!


所以不能「找回密码」,只能「重设密码」,因为重设就代表你输入新的密码,然后网站把新的密码 hash 之后存进数据库,未来登入时就会用这组新的 hash 去比对。


有些人可能会注意到这样的储存方式似乎有个漏洞,延续前面的例子,数据库存的是 123123 而我的原始密码是 abc123,这样如果用「abcabc」,hash 过后也是 123123,不就也可以登入吗?这样不太对吧,这不是我的密码嘛


有两个不同的输入却产生出同一个输出,这种状况称为碰撞(hash collision),碰撞一定会发生,但如果算法设计的好,碰撞的机率就超级无敌小,小到几乎可以忽略。


前面提的转换规则只是为了方便举例,真实世界中用的算法复杂许多,就算只有一个字不同,结果都会天差地远,以 SHA256 这个算法为例:



  1. abc123 => 6ca13d52ca70c883e0f0bb101e425a89e8624de51db2d2392593af6a84118090

  2. abc124 => cd7011e7a6b27d44ce22a71a4cdfc2c47d5c67e335319ed7f6ae72cc03d7d63f


类似的输入却产生截然不同的输出。


像我前面举例用的转换就是不安全的 hash 算法,要尽量避免使用或是避免自己设计,尽可能使用密码学家跟专家设计出的算法,像是上面提到的 SHA256。


在使用这些算法的时候,也要特别注意一下是否安全,因为有些算法虽然也是由专家设计,但已经被证明是不安全的,例如说密码用 MD5 来 hash 后储存就是不安全的,可以参考:Is MD5 considered insecure?[6]


储存 hash 后的值就没事了吗?


抱歉,其实只储存密码 hash 过后的值是不够的。


咦,为什么?我刚刚不是说没办法反推出结果吗,那为什么不够?


虽然说没办法反推出结果,但攻击者可以利用「输入一样,输出一定一样」的特性,先建好一个人数据料库。


举例来说,假设有个很常见的密码 abc123,hash 过后的值是 6ca13d,那攻击者就可以先算好,然后把这个关系存在数据库,所以攻击者的数据库里面就可能会有一百万组最常见密码的清单,里面有着每个密码跟它 hash 过后的值。


那接下来只要在 hash 过后的数据库发现 6ca13d,攻击者就可以透过查表的方式,查出原本的密码是 abc123。这不是利用算法反推结果,这只是利用现有数据来查询而已。


为了防御这种攻击,还要做一件事情叫做加盐(Salting),没错,就是盐巴的那个盐。通常会帮每个使用者产生一个独一无二的盐,例如说 5ab3od(实际上会更长,可能 16 或 32 个字以上),接着把我的密码 abc123 加上我的盐,变成 abc1235ab3od,然后用这个加盐过后的结果去做 hash。


为什么要这样做呢?


因为攻击者预先准备好的表格中,比起 abc123,出现 abc1235ab3od 的机率显然更低,同时又因为长度变长了,暴力破解的难度变得更高。如此一来,密码就变得更难破解了。


结语


忘记密码时网站不会把密码发给我,因为网站自己都不知道我的密码是什么。虽然听起来不太可能,但实际状况就是如此。为了安全性,这是必须的手段。


要达成这样的目的,背后最重要的技术原理就是 hash,「同样的密码会产生同样的 hash 值,但从 hash 值没办法对应回原本的密码」就是秘诀所在。


反之,如果你发现有网站可以找回你的密码,那就得要多加注意,有可能网站数据库存的不是 hash 值而是你的密码。在这种状况下,万一有天数据库被入侵,账号和密码被偷走,黑客就能得知你真实的密码,然后去试其他的服务。


有关于密码管理,现在浏览器也有功能可以自动帮你产生密码外加记忆密码,或也可以使用现成的密码管理软件,都可以在不同网站产生不同的密码。


这篇希望能让对这个领域陌生的读者们也能知道一些基本的概念,包括:



  1. 有些网站比你想得脆弱很多,改个网址就可以拿到别人的数据

  2. 对于安全性做得不好的网站,拿到整个人数据料库不是一件难事

  3. 忘记密码只能重设,不能找回,是因为网站也不知道你的密码

  4. 如果有网站可以把旧密码给你,那你得要小心一点


作者:写bug写bug
来源:juejin.cn/post/7330922611512262671
收起阅读 »

尾递归优化是一场谎言

web
TLDR 本文是对蹦床函数的应用案例。 蹦床函数跟尾递归优化都是为了解决递归次数过多导致调用栈溢出的问题。 蹦床函数的原理:令原函数返回一个可执行函数,由蹦床函数来控制执行时机,使执行函数与其父函数无执行调用关系。 处理递归栈溢出还有递归转迭代、异步执行等方...
继续阅读 »

TLDR



  • 本文是对蹦床函数的应用案例。

  • 蹦床函数跟尾递归优化都是为了解决递归次数过多导致调用栈溢出的问题。

  • 蹦床函数的原理:令原函数返回一个可执行函数,由蹦床函数来控制执行时机,使执行函数与其父函数无执行调用关系。

  • 处理递归栈溢出还有递归转迭代、异步执行等方案,但蹦床函数对代码的改动量很低,也不影响原代码的阅读和执行逻辑。


起因


接到一个需求,需要对 markdown 进行分句。大致如下:


// 原始 md
aaa。bbb[^1^]。ccc。

// 转换后 md
aaa。x:[bbb[^1^]]。ccc。

即把以[^*^]结尾的句子给包起来。


方案思路比较简单:



  • 写一个函数,入参为起始位置,大概这样:walk(startIndex)

  • 起始位置设为最后一个字符位置,往前找,如果找到[^*^]则往前找句子结束符(如。!?-),找到后就把整句话用x:[]包起来。

  • 继续调用这个函数,入参startIndex为刚刚那个句子的起始位置。

  • 直到入参为0(即找到头了),结束执行。


完美,补充完测试用例,开心光速下班 😃。





命运所有的馈赠早已暗中标好了价格。





报错


一天后:


image.png


image.png


栈溢出了。


同事的需求需要往这段 markdown 里填充上万字符,我的递归函数爆栈了 🤯🤯🤯。


一时间脑海里立刻出现 3 条解决方案:



  • 问 gpt

  • 问 gpt

  • 问 gpt


image.png
得到了6种解决方案:



  1. 尾递归优化(Tail Call Optimization)

  2. 循环替换递归( Loop) 将递归转换为等效的循环

  3. 使用堆栈来管理状态(Manual Stack Management) 手动使用数组作为堆栈来保存需要的状态,模拟递归的过程。

  4. 使用异步递归( Async Recursion) 通过把递归调用放在setTimeout、setImmediate或Promise中来异步执行,可以避免同步递归调用造成的堆栈溢出。

  5. 节流递归(Throttling Recursion) 通过定期将递归调用事件推迟到下一个事件循环迭代中,你可以避免堆栈溢出。

  6. Trampoline 函数(Trampoline Function) Trampoline 是一种编程技巧,允许你改写递归函数,使其成为迭代的,而不需要占用新的调用栈帧。


简单评估下这几个方案:



  • 方案2和3对代码的改动量较大,递归变迭代,实在懒得改也懒得验证,哒咩 ❌

  • 方案4和5把同步逻辑改成了异步,对代码逻辑的侵入性太强,哒咩 ❌

  • 方案1的尾递归优化我现在就在用,无效,哒咩 ❌


方案一让我感觉撞了鬼:为什么我写的尾递归优化没生效?


搜了下才发现尾递归优化可谓名存实亡,主流浏览器全都不支持尾递归优化:


compat-table.github.io/compat-tabl…


image.png


详见文章:尾递归的后续探究-腾讯云开发者社区-腾讯云


解决


5/6的方案都被否掉,看来看去只能使用 Trampoline 函数,即蹦床函数。


我们看下 gpt给的示例,以解释蹦床函数做了些什么:


function trampoline(fn) {
return function(...args) {
let result = fn(...args);
while (typeof result === 'function') {
result = result();
}
return result;
};
}

function sum(x, y) {
if (y > 0) {
return () => sum(x + 1, y - 1);
} else {
return x;
}
}

let safeSum = trampoline(sum);
safeSum(1, 100000);

这里对原函数的修改在第13行,正常的递归会直接执行sum函数,而这段优化里则改成返回了一个函数,我们姑且称之为 handler 函数。


而 trampoline 函数的作用则是执行 sum 函数并判断返回值,如果返回值是函数(即 handler 函数),则继续执行该函数,直到返回值是数字。整个判断&执行过程使用 while循环。


蹦床函数之所以能够摆脱递归调用栈限制,是因为 handler 函数是由蹦床函数执行的,handler 函数执行前,它的父函数 sum 函数已经执行完毕了,handler 的执行跟 sum 的执行没有堆栈关系。




完美,



  • 补充测试用例。

  • 加上try catch防止白屏。

  • 加上memo防止每次render都递归计算。


开心光速下班 😃。


作者:tumars
来源:juejin.cn/post/7330521390510440511
收起阅读 »

Vite 4.3 为何性能爆表?

web
免责声明 本文属于是语冰的直男翻译了属于是,略有删改,仅供粉丝参考。英文原味版请传送 How we made Vite 4.3 faaaaster。 本期共享的是 —— Vite 4.3 性能大幅提升的幕后技术细节。 地球人都知道,Vite 4.3 相比 V...
继续阅读 »

免责声明


本文属于是语冰的直男翻译了属于是,略有删改,仅供粉丝参考。英文原味版请传送 How we made Vite 4.3 faaaaster



本期共享的是 —— Vite 4.3 性能大幅提升的幕后技术细节。


地球人都知道,Vite 4.3 相比 Vite 4.2 取得了惊人的性能提升。


01-vite.png


fs.realpathSync 的问题


Nodejs 中有一个有趣的 realpathSync 问题,它指出 fs.realpathSyncfs.realpathSync.native70 倍。


但由于在 Windows 上的行为不同,Vite 4.2 只在非 Windows 系统上使用 fs.realpathSync.native。为了搞定此问题,Vite 4.3 在 Windows 上调用 fs.realpathSync.native 时添加了网络驱动验证。


Vite 从未放弃 Windows,它真的......我哭死。


JS 优化


不要错过编程语言优化。Vite 4.3 中若干有趣的 JS 优化案例:


*yield 重构为回调函数


Vite 使用 tsconfck 来查找和解析 tsconfig 文件。tsconfck 用于通过 *yield 遍历目标目录,生成器的短板之一在于,它需要更多的内存空间来存储其 Generator 对象,且生成器中存在一大坨生成器上下文切换运行。因此,自 v2.1.1 以来,该核心库用回调函数重构了 *yield


startsWith/endsWith 重构为 ===


Vite 4.2 使用 startsWith/endsWith 来检查热门 URL 中的前置和后置 '/'。我们测评了 str.startsWith('x') 和 str[0] === 'x' 的执行基准跑分,发现 ===startsWith 快约 20%。同时 endsWith=== 慢约 60%。


避免重新创建正则表达式


Vite 需要一大坨正则表达式来匹配字符串,其中大多数都是静态的,所以最好只使用它们的单例。Vite 4.3 优化了正则表达式,这样可以重复使用它们。


放弃生成自定义错误


为了更好的开发体验,Vite 4.2 中存在若干自定义错误。这些错误可能会导致额外的计算和垃圾收集,降低 Vite 的速度。在 Vite 4.3 中,我们不得不放弃生成某些热门的自定义错误(比如 package.json NOT_FOUND 错误),并直接抛出原始错误,获取更好的性能。


更机智的解析策略


Vite 会解析所有已接收的 URL 和路径,获取目标模块。


Vite 4.2 中存在一大坨冗余的解析逻辑和非必要的模块搜索。 Vite 4.3 使解析逻辑更精简、更严格、更准确,减少计算量和 fs 调用。


更简单的解析


Vite 4.2 重度依赖 resolve 模块来解析依赖的 package.json,当我们偷看 resolve 模块的源码时,发现在解析 package.json 时存在一大坨无用逻辑。Vite 4.3 弃用了 resolve 模块,遵循更精简的解析逻辑:直接检查嵌套父目录中是否存在 package.json


更严格的解析


Vite 需要调用 Nodejs 的 fs API 来查找模块。但 IO 成本昂贵。Vite 4.3 缩小了文件搜索范围,并跳过搜索某些特殊路径,尽量减少 fs 调用。举个栗子:



  1. 由于 # 符号不会出现在 URL 中,且用户可以控制源文件路径中不存在 # 符号,因此 Vite 4.3 不再检查用户源文件中带有 # 符号的路径,而只在 node_modules 中搜索它们。

  2. 在 Unix 系统中,Vite 4.2 首先检查根目录内的每个绝对路径,对于大多数路径而言问题不大,但如果绝对路径以 root 开头,那大概率会失败。为了在 /root/root 不存在时,跳过搜索 /root/root/path-to-file,Vite 4.3 会在开头判断 /root/root 是否作为目录存在,并预缓存结果。

  3. 当 Vite 服务器接收到 @fs/xxx@vite/xxx 时,无需再次解析这些 URL。Vite 4.3 直接返回之前缓存的结果,不再重新解析。


更准确的解析


当文件路径为目录时,Vite 4.2 会递归解析模块,这会导致不必要的重复计算。Vite 4.3 将递归解析扁平化,针对不同类型的路径对症下药。拍平后缓存某些 fs 调用也更容易。


package


Vite 4.3 打破了解析 node_modules 包数据的性能瓶颈。


Vite 4.2 使用绝对文件路径作为包数据缓存键。这还不够,因为 Vite 必须在 pkg/foo/barpkg/foo/baz 中遍历相同的目录。


Vite 4.3 不仅使用绝对路径(/root/node_modules/pkg/foo/bar.js/root/node_modules/pkg/foo/baz.js),还使用遍历的目录(/root/node_modules/pkg/foo/root/node_modules/pkg)作为 pkg 缓存的键。


另一种情况是,Vite 4.2 在单个函数内查找深度导入路径的 package.json,举个栗子,当 Vite 4.2 解析 a/b/c/d 这样的文件路径时,它首先检查根 a/package.json 是否存在,如果不存在,那就按 a/b/c/package.json -> a/b/package.json 的顺序查找最近的 package.json,但事实上,查找根 package.json 和最近的 package.json 应该分而治之,因为它们需要不同的解析上下文。Vite 4.3 将根 package.json 和最接近的 package.json 的解析分而治之,这样它们就不会混合。


非阻塞任务


作为一种按需服务,Vite 开发服务器无需备妥所有东东就能启动。


非阻塞 tsconfig 解析


Vite 服务器在预打包 ts/tsx 时需要 tsconfig 的数据。


Vite 4.2 在服务器启动前,会在 configResolved 插件钩子中等待解析 tsconfig 的数据。一旦服务器启动而尚未备妥 tsconfig 的数据,即使该请求稍后可能需要等待 tsconfig 解析,页面请求也可以访问服务器,


Vite 4.3 在服务器启动前初始化 tsconfig 解析,但服务器不会等待它。解析过程在后台运行。一旦 ts 相关的请求进来,它就必须等待 tsconfig 解析完成。


非阻塞文件处理


Vite 中存在一大坨 fs 调用,其中某些是同步的。这些同步 fs 调用可能会阻塞主线程。Vite 4.3 将其更改为异步。此外,异步函数的并行化也更容易。关于异步函数,我们关心的一件事是,解析后可能需要释放一大坨 Promise 对象。得益于更机智的解析策略,释放 fs - Promise 对象的成本要低得多。


HMR 防抖


请考虑两个简单的依赖链 C <- B <- AD <- B <- A,当编辑 A 时,HMR 会将两者从 A 传播到 CD。这导致 AB 在 Vite 4.2 中更新了两次。


Vite 4.3 会缓存这些遍历过的模块,避免多次探索它们。这可能会对那些具有组件集装导入的文件结构产生重大影响。这对于 git checkout 触发的 HMR 也有好处。


并行化


并行化始终是获取更好性能的不错选择。在 Vite 4.3 中,我们并行化了若干核心功能,包括但不限于导入分析、提取 deps 的导出、解析模块 url 和运行批量优化器。并行化之后确实有令人印象深刻的改进。


基准测试生态系统



  • vite-benchmark:Vite 使用此仓库来测评每个提交的跑分,如果您正在使用 Vite 开发大型项目,我们很乐意测试您的仓库,以获得更全面的性能。

  • vite-plugin-inspect:vite-plugin-inspect 从 v0.7.20 开始支持显示插件的钩子时间,并且将来会有更多的跑分图,请向我们反馈您的需求。

  • vite-plugin-warmup:预热您的 Vite 服务器,并提升页面加载速度!


《前端 9 点半》每日更新,持续关注,坚持阅读,每天一次,进步一点


谢谢大家的点赞,掰掰~


26-cat.gif


作者:人猫神话
来源:juejin.cn/post/7331361547011801115
收起阅读 »

如何写一个无侵入式的动态权限申请Android框架?

1、核心逻辑 在Activity或者fragment中,写在几个方法写一些注释,用来表示权限申请成功,申请失败,多次拒绝。以上就是使用者需要做的。 简单吧,简单就对了,不用传任何上下文。只需要写注解。给大家看下。 public class MainActivi...
继续阅读 »

1、核心逻辑


在Activity或者fragment中,写在几个方法写一些注释,用来表示权限申请成功申请失败多次拒绝。以上就是使用者需要做的。


简单吧,简单就对了,不用传任何上下文。只需要写注解。给大家看下。


public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
}
public void permissionRequestTest(View view) {
testRequest();
}
// 申请权限 函数名可以随意些
@Permission(value = Manifest.permission.READ_EXTERNAL_STORAGE, requestCode = 200)
public void testRequest() {
Toast.makeText(this, "权限申请成功...", Toast.LENGTH_SHORT).show();
}
// 权限被取消 函数名可以随意些
@PermissionCancel
public void testCancel() {
Toast.makeText(this, "权限被拒绝", Toast.LENGTH_SHORT).show();
}

// 多次拒绝,还勾选了“不再提示”
@PermissionDenied
public void testDenied() {
Toast.makeText(this, "权限被拒绝(用户勾选了 不再提示),注意:你必须要去设置中打开此权限,否则功能无法使用", Toast.LENGTH_SHORT).show();
}


2、实现


需要用到的技术有Aspect、注解、反射


2.1、Aspect


它的作用就是劫持被注释的方法的执行。比如上方testRequest()是用来请求权限的,但是我在ASPECT中配置拦截@permission注释的方法。先做判断。


如果没有听过Aspect的话,AOP面向切面编程,大家应该听说过,它可以用来配置事务、做日志、权限验证、在用户请求时做一些处理等等。而用@Aspect做一个切面,就可以直接实现。


2.2、PermissionAspect

我们会创建一个PermissionAspect类,整一个切点,让@Permission被劫持。


// AOP 思维 切面的思维
// 切点 --- 是注解 @
// * * 任何函数 都可以使用 此注解
//(..) 我要带参数 带的参数就是后面那个 @annotation(permission)意思就是 参数里是permission。这样我就拿到了Permission注解它里面的参数
//这样通过切点就拿到了下面这个注解
//@Permission(value = Manifest.permission.READ_EXTERNAL_STORAGE, requestCode = 200) 这就是 Permission permission
@Pointcut
("execution(@com.derry.premissionstudy.permission.annotation.Permission * *(..)) && @annotation(permission)")
//那么这个
// @Permission == permission
public void pointActionMethod(Permission permission) {

} // 切点函数

//切面
@Around("pointActionMethod(permission)")
public void aProceedingJoinPoint(final ProceedingJoinPoint point,Permission permission) throws Throwable{
//我需要拿到 MainActivity this


这样@Permission就被切点劫持了,然后方法就会跑到切面aProceedingJoinPoint。然后获取上下文Context,把权限请求交给一个透明的Activity来做。做完之后判断结果,用户是同意了还是拒绝了还是曲线了。同意了直接执行point.proceed(),其他方式则通过Activity或者fragment获取带注解的方法,反射执行即可。


//切面
@Around("pointActionMethod(permission)")
public void aProceedingJoinPoint(final ProceedingJoinPoint point,Permission permission) throws Throwable{
//我需要拿到 MainActivity this

Context context = null;
// MainActivity this == thisObject
final Object thisobject = point.getThis();

// context初始化
if(thisobject instanceof Context){
context = (Context) thisobject;
} else if(thisobject instanceof Fragment){
context = ((Fragment) thisobject).getActivity();
}

// 判断是否为null
if (null == context || permission == null) {
throw new IllegalAccessException("null == context || permission == null is null");
}


//trestRequest 次函数被控制了 不会执
//
//动态申请 危险权限 透明的空白的Ativity
//这里一定要得知接口三个状态 已经授权 取消授权 拒绝授权
//调用 空白的Actiivty 开始授权

MyPermissionActivity.requestPermissionAction(context, permission.value(), permission.requestCode(), new IPermission() {
@Override
public void ganted() {
try {
point.proceed();
} catch (Throwable throwable) {
throwable.printStackTrace();
}
}
@Override
public void cancel() {
PermissionUtils.invokeAnnotion(thisobject, PermissionCancel.class);
}

@Override
public void denied() {
PermissionUtils.invokeAnnotion(thisobject, PermissionDenied.class);
}
});
}

2.3、空白执行权限的Activity


执行请求权限的Activity的的相应方法会流到PermissionAspect,然后到空白Activity请求。请求完之后的结果,再通过回调传回去就好了。



public class MyPermissionActivity extends AppCompatActivity {



// 定义权限处理的标记, ---- 接收用户传递进来的
private final static String PARAM_PERMSSION = "param_permission";
private final static String PARAM_PERMSSION_CODE = "param_permission_code";
public final static int PARAM_PERMSSION_CODE_DEFAULT = -1;


private String[] permissions;
private int requestCode;


// 方便回调的监听 告诉外交 已授权,被拒绝,被取消
private static IPermission iPermissionListener;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_my_permission);

permissions = getIntent().getStringArrayExtra(PARAM_PERMSSION);
requestCode = getIntent().getIntExtra(PARAM_PERMSSION_CODE, PARAM_PERMSSION_CODE_DEFAULT);


if(permissions == null){
this.finish();
return;
}

// 能够走到这里,就开始去检查,是否已经授权了
boolean permissionRequest = PermissionUtils.hasPermissionRequest(this,permissions);
if (permissionRequest) {
// 通过监听接口,告诉外交,已经授权了
iPermissionListener.ganted();

this.finish();
return;
}
ActivityCompat.requestPermissions(this,permissions,requestCode);
}


@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);

if(PermissionUtils.requestPermissionSuccess(grantResults)){
iPermissionListener.ganted();//已经授权成功了 告知AspectJ
this.finish();
}

// 没有成功,可能是用户 不听话
// 如果用户点击了,拒绝(勾选了”不再提醒“) 等操作
if(!PermissionUtils.shouldShowRequestPermissionRationable(this,permissions)){
iPermissionListener.denied();

this.finish();
return;
}
// 取消
iPermissionListener.cancel(); // 接口告知 AspectJ
this.finish();
return;
}


// 让此Activity不要有任何动画
@Override
public void finish() {
super.finish();
overridePendingTransition(0, 0);
}


public static void requestPermissionAction(Context context, String[] permissions, int requestCode, IPermission iPermissionLIstener){

MyPermissionActivity.iPermissionListener = iPermissionLIstener;
Intent intent = new Intent(context,MyPermissionActivity.class);
//效果
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
Bundle bundle = new Bundle();

Log.d("TAG", "requestPermissionAction: "+requestCode);
bundle.putInt(PARAM_PERMSSION_CODE,requestCode);
bundle.putStringArray(PARAM_PERMSSION,permissions);
intent.putExtras(bundle);
context.startActivity(intent);
}
}

2.4、其它


app gradle中


 
apply plugin: 'com.android.application'


buildscript {
repositories {
mavenCentral()
}

dependencies {
classpath 'org.aspectj:aspectjtools:1.8.9'
classpath 'org.aspectj:aspectjweaver:1.8.9'
}
}

android {
compileSdkVersion 29
buildToolsVersion "29.0.2"
defaultConfig {
applicationId "com.netease.premissionstudy"
minSdkVersion 19
targetSdkVersion 29
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}

dependencies {

implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'androidx.appcompat:appcompat:1.1.0'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
testImplementation 'junit:junit:4.12'
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'

implementation 'org.aspectj:aspectjrt:1.8.13'
}


import org.aspectj.bridge.IMessage
import org.aspectj.bridge.MessageHandler
import org.aspectj.tools.ajc.Main

final def log = project.logger
final def variants = project.android.applicationVariants

variants.all { variant ->

if (!variant.buildType.isDebuggable()) {
log.debug("Skipping non-debuggable build type '${variant.buildType.name}'.")
return;
}

JavaCompile javaCompile = variant.javaCompile

javaCompile.doLast {

String[] args = ["-showWeaveInfo",

"-1.8",

"-inpath", javaCompile.destinationDir.toString(),

"-aspectpath", javaCompile.classpath.asPath,

"-d", javaCompile.destinationDir.toString(),

"-classpath", javaCompile.classpath.asPath,

"-bootclasspath", project.android.bootClasspath.join(File.pathSeparator)]

log.debug "ajc args: " + Arrays.toString(args)

MessageHandler handler = new MessageHandler(true);

new Main().run(args, handler);

for (IMessage message : handler.getMessages(null, true)) {

switch (message.getKind()) {

case IMessage.ABORT:

case IMessage.ERROR:

case IMessage.FAIL:

log.error message.message, message.thrown

break;

case IMessage.WARNING:

log.warn message.message, message.thrown

break;

case IMessage.INFO:

log.info message.message, message.thrown

break;

case IMessage.DEBUG:

log.debug message.message, message.thrown

break;
}
}
}
}

项目 gradle中



buildscript {
repositories {
google()
jcenter()

}
dependencies {
classpath 'com.android.tools.build:gradle:3.5.1'

classpath 'org.aspectj:aspectjtools:1.8.9'
classpath 'org.aspectj:aspectjweaver:1.8.9'

// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}

allprojects {
repositories {
google()
jcenter()

}
}

task clean(type: Delete) {
delete rootProject.buildDir
}

3、总结


其实核心就是,用aspect去劫持注解,然后让一个公共的Activity来处理这个事情,然后回调,再反射其它方法执行。
等有时间了给他打成一个包。


作者:KentWang
来源:juejin.cn/post/7245836790039445562
收起阅读 »

什么情况下Activity会被杀掉呢?

首先一个报错来作为开篇: Caused by androidx.fragment.app.Fragment$InstantiationException Unable to instantiate fragment xxx: could not find Fr...
继续阅读 »

首先一个报错来作为开篇:


Caused by androidx.fragment.app.Fragment$InstantiationException
Unable to instantiate fragment xxx: could not find Fragment constructor

这个报错原因就是Fragment如果重载了有参的构造方法,没有实现默认无参构造方法。Activity被回收又回来尝试重新恢复Fragment的时候报错的。


那如何模拟Activity被回收呢?

可能有人知道,一个方便快捷的方法就是:打开 开发者选项 - 不保留活动,这样每次Activity回到后台都会被回收,也就可以很方便的测试这种case。


但抛开这种方式我怎么来复现这种情况呢?

这里我提出一种方式:我是不是可以打开我的App,按Home回到后台,然后疯狂的打开手机里其他的大型应用或者游戏这类的能占用大量手机内存的App,等手机内存占用大的时候是不是可以复现这种情况呢?


结论是不可以,不要混淆两个概念,系统内存不足App内存不足,两者能引起的后果也是不同的



  • 系统内存不足 -> 杀掉应用进程

  • App内存不足 -> 杀掉后台Activity


首先明确一点,Android框架对进程创建与管理进行了封装,对于APP开发者只需知道Android四大组件的使用。当Activity, Service, ContentProvider, BroadcastReceiver任一组件启动时,当其所承载的进程存在则直接使用,不存在则由框架代码自动调用startProcessLocked创建进程。所以说对APP来说进程几乎是透明的,但了解进程对于深刻理解Android系统是至关关键的。


1. 系统内存不够 -> 杀掉应用进程


1.1. LKM简介

Android底层还是基于Linux,在Linux中低内存是会有oom killer去杀掉一些进程去释放内存,而Android中的lowmemorykiller就是在此基础上做了一些调整来的。因为手机上的内存毕竟比较有限,而Android中APP在不使用之后并不是马上被杀掉,虽然上层ActivityManagerService中也有很多关于进程的调度以及杀进程的手段,但是毕竟还需要考虑手机剩余内存的实际情况,lowmemorykiller的作用就是当内存比较紧张的时候去及时杀掉一些ActivityManagerService还没来得及杀掉但是对用户来说不那么重要的进程,回收一些内存,保证手机的正常运行。


lowmemkiller中会涉及到几个重要的概念:

/sys/module/lowmemorykiller/parameters/minfree:里面是以”,”分割的一组数,每个数字代表一个内存级别

/sys/module/lowmemorykiller/parameters/adj: 对应上面的一组数,每个数组代表一个进程优先级级别


比如:

/sys/module/lowmemorykiller/parameters/minfree:18432, 23040, 27648, 32256, 55296, 80640

/sys/module/lowmemorykiller/parameters/adj: 0, 100, 200, 300, 900, 906


代表的意思是两组数一一对应:



  • 当手机内存低于80640时,就去杀掉优先级906以及以上级别的进程

  • 当内存低于55296时,就去杀掉优先级900以及以上的进程


可能每个手机的配置是不一样的,可以查看一下手头的手机,需要root。


1.2. 如何查看ADJ

如何查看进程的ADJ呢?比如我们想看QQ的adj


-> adb shell ps | grep "qq" 
UID PID PPID C STIME TTY TIME CMD
u0_a140 9456 959 2 10:03:07 ? 00:00:22 com.tencent.mobileqq
u0_a140 9987 959 1 10:03:13 ? 00:00:07 com.tencent.mobileqq:mini3
u0_a140 16347 959 0 01:32:48 ? 00:01:12 com.tencent.mobileqq:MSF
u0_a140 21475 959 0 19:47:33 ? 00:01:25 com.tencent.mobileqq:qzone

# 看到QQ的PID为 9456,这个时候打开QQ,让QQ来到前台
-> adb shell cat /proc/9456/oom_score_adj
0

# 随便打开一个其他的App
-> adb shell cat /proc/9456/oom_score_adj
700

# 再随便打开另外一个其他的App
-> adb shell cat /proc/9456/oom_score_adj
900

我们可以看到adj是在根据用户的行为不断变化的,前台的时候是0,到后台是700,回到后台后再打开其他App后是900

常见ADJ级别如下:


ADJ级别取值含义
NATIVE_ADJ-1000native进程
SYSTEM_ADJ-900仅指system_server进程
PERSISTENT_PROC_ADJ-800系统persistent进程
PERSISTENT_SERVICE_ADJ-700关联着系统或persistent进程
FOREGROUND_APP_ADJ0前台进程
VISIBLE_APP_ADJ100可见进程
PERCEPTIBLE_APP_ADJ200可感知进程,比如后台音乐播放
BACKUP_APP_ADJ300备份进程
HEAVY_WEIGHT_APP_ADJ400重量级进程
SERVICE_ADJ500服务进程
HOME_APP_ADJ600Home进程
PREVIOUS_APP_ADJ700上一个进程
SERVICE_B_ADJ800B List中的Service
CACHED_APP_MIN_ADJ900不可见进程的adj最小值
CACHED_APP_MAX_ADJ906不可见进程的adj最大值

So,当系统内存不足的时候会kill掉整个进程,皮之不存毛将焉附,Activity也就不在了,当然也不是开头说的那个case。


2. App内存不足 -> 杀掉后台Activity


上面分析了是直接kill掉进程的情况,一旦出现进程被kill掉,说明内存情况已经到了万劫不复的情况了,抛开内存泄漏的情况下,framework也需要一些策略来避免无内存可用的情况。下面我们来找一找fw里面回收Activity的逻辑(代码Base Android-30)。



Android Studio查看源码无法查看com.android.internal包名下的代码,双击Shift,勾选右上角Include non-prject Items.



入口定位到ActivityThreadattach方法,ActivityThread是App的入口程序,main方法中创建并调用atttach


// ActivityThread.java
private void attach(boolean system, long startSeq) {
...
// Watch for getting close to heap limit.
BinderInternal.addGcWatcher(new Runnable() {
@Override public void run() {
// mSomeActivitiesChanged在生命周期变化的时候会修改为true
if (!mSomeActivitiesChanged) {
return;
}
Runtime runtime = Runtime.getRuntime();
long dalvikMax = runtime.maxMemory();
long dalvikUsed = runtime.totalMemory() - runtime.freeMemory();
if (dalvikUsed > ((3*dalvikMax)/4)) {
mSomeActivitiesChanged = false;
try {
ActivityTaskManager.getService().releaseSomeActivities(mAppThread);
} catch (RemoteException e) {
throw e.rethrowFromSystemServer();
}
}
}
});
...
}

这里关注BinderInternal.addGcWatcher, 下面有几个点需要理清:



  1. addGcWatcher是干嘛的,这个Runnable什么时候会被执行。

  2. 这里的maxMemory() / totalMemory() / freeMemory()都怎么理解,值有什么意义

  3. releaseSomeActivities()做了什么事情,回收Activity的逻辑是什么。


还有一个小的点是这里还用了mSomeActivitiesChanged这个标记位来标记让检测工作不会过于频繁的执行,检测到需要releaseSomeActivities后会有一个mSomeActivitiesChanged = false;赋值。而所有的mSomeActivitiesChanged = true操作都在handleStartActivity/handleResumeActivity...等等这些操作Activity声明周期的地方。控制了只有Activity声明周期变化了之后才会继续去检测是否需要回收。


2.1. GcWatcher

BinderInternal.addGcWatcher是个静态方法,相关代码如下:


public class BinderInternal {
private static final String TAG = "BinderInternal";
static WeakReference<GcWatcher> sGcWatcher = new WeakReference<GcWatcher>(new GcWatcher());
static ArrayList<Runnable> sGcWatchers = new ArrayList<>();
static Runnable[] sTmpWatchers = new Runnable[1];

static final class GcWatcher {
@Override
protected void finalize() throws Throwable {
handleGc();
sLastGcTime = SystemClock.uptimeMillis();
synchronized (sGcWatchers) {
sTmpWatchers = sGcWatchers.toArray(sTmpWatchers);
}
for (int i=0; i<sTmpWatchers.length; i++) {
if (sTmpWatchers[i] != null) {
sTmpWatchers[i].run();
}
}
sGcWatcher = new WeakReference<GcWatcher>(new GcWatcher());
}
}

public static void addGcWatcher(Runnable watcher) {
synchronized (sGcWatchers) {
sGcWatchers.add(watcher);
}
}
...
}

两个重要的角色:sGcWatcherssGcWatcher



  • sGcWatchers保存了调用BinderInternal.addGcWatcher后需要执行的Runnable(也就是检测是否需要kill Activity的Runnable)。

  • sGcWatcher是个装了new GcWatcher()的弱引用。


弱引用的规则是如果一个对象只有一个弱引用来引用它,那GC的时候就会回收这个对象。那很明显new出来的这个GcWatcher()只会有sGcWatcher这一个弱引用来引用它,所以每次GC都会回收这个GcWatcher对象,而回收的时候会调用这个对象的finalize()方法,finalize()方法中会将之前注册的Runnable来执行掉。
注意哈,这里并没有移除sGcWatcher中的Runnable,也就是一开始通过addGcWatcher(Runnable watcher)进来的runnable一直都在,不管执行多少次run的都是它。


为什么整个系统中addGcWatcher只有一个调用的地方,但是sGcWatchers确实一个List呢?我在自己写了这么一段代码并且想着怎么能反射搞到系统当前的BinderInternal一探究竟的时候明白了一点点,我觉着他们就是怕有人主动调用了addGcWatcher给弄了好多个GcWatcher导致系统的失效了才搞了个List吧。。


2.2. App可用的内存

上面的Runnable是如何检测当前的系统内存不足的呢?通过以下的代码


        Runtime runtime = Runtime.getRuntime();
long dalvikMax = runtime.maxMemory();
long dalvikUsed = runtime.totalMemory() - runtime.freeMemory();
if (dalvikUsed > ((3*dalvikMax)/4)) { ... }

看变量名字就知道,在使用的内存到达总内存的3/4的时候去做一些事情,这几个方法的注释如下:


    /**
* Returns the amount of free memory in the Java Virtual Machine.
* Calling the gc method may result in increasing the value returned by freeMemory.
* @return an approximation to the total amount of memory currently available for future allocated objects, measured in bytes.
*/

public native long freeMemory();

/**
* Returns the total amount of memory in the Java virtual machine.
* The value returned by this method may vary over time, depending on the host environment.
* @return the total amount of memory currently available for current and future objects, measured in bytes.
*/

public native long totalMemory();

/**
* Returns the maximum amount of memory that the Java virtual machine will attempt to use.
* If there is no inherent limit then the value java.lang.Long#MAX_VALUE will be returned.
* @return the maximum amount of memory that the virtual machine will attempt to use, measured in bytes
*/

public native long maxMemory();

首先确认每个App到底有多少内存可以用,这些Runtime的值都是谁来控制的呢?


可以使用adb shell getprop | grep "dalvik.vm.heap"命令来查看手机给每个虚拟机进程所分配的堆配置信息:


yocn@yocn ~ % adb shell getprop | grep "dalvik.vm.heap"
[dalvik.vm.heapgrowthlimit]: [256m]
[dalvik.vm.heapmaxfree]: [8m]
[dalvik.vm.heapminfree]: [512k]
[dalvik.vm.heapsize]: [512m]
[dalvik.vm.heapstartsize]: [8m]
[dalvik.vm.heaptargetutilization]: [0.75]

这些值分别是什么意思呢?



  • [dalvik.vm.heapgrowthlimit]和[dalvik.vm.heapsize]都是当前应用进程可分配内存的最大限制,一般heapgrowthlimit < heapsize,如果在Manifest中的application标签中声明android:largeHeap=“true”,APP直到heapsize才OOM,否则达到heapgrowthlimit就OOM

  • [dalvik.vm.heapstartsize] Java堆的起始大小,指定了Davlik虚拟机在启动的时候向系统申请的物理内存的大小,后面再根据需要逐渐向系统申请更多的物理内存,直到达到MAX

  • [dalvik.vm.heapminfree] 堆最小空闲值,GC后

  • [dalvik.vm.heapmaxfree] 堆最大空闲值

  • [dalvik.vm.heaptargetutilization] 堆目标利用率


比较难理解的就是heapminfree、heapmaxfree和heaptargetutilization了,按照上面的方法来说:
在满足 heapminfree < freeMemory() < heapmaxfree的情况下使得(totalMemory() - freeMemory()) / totalMemory()接近heaptargetutilization


所以一开始的代码就是当前使用的内存到达分配的内存的3/4的时候会调用releaseSomeActivities去kill掉某些Activity.


2.3. releaseSomeActivities

releaseSomeActivities在API 29前后差别很大,我们来分别看一下。


2.3.1. 基于API 28的版本的releaseSomeActivities实现如下:

// step①:ActivityManagerService.java
@Override
public void releaseSomeActivities(IApplicationThread appInt) {
synchronized(this) {
final long origId = Binder.clearCallingIdentity();
try {
ProcessRecord app = getRecordForAppLocked(appInt);
mStackSupervisor.releaseSomeActivitiesLocked(app, "low-mem");
} finally {
Binder.restoreCallingIdentity(origId);
}
}
}

// step②:ActivityStackSupervisor.java
void releaseSomeActivitiesLocked(ProcessRecord app, String reason) {
TaskRecord firstTask = null;
ArraySet<TaskRecord> tasks = null;
for (int i = 0; i < app.activities.size(); i++) {
ActivityRecord r = app.activities.get(i);
// 如果当前有正在销毁状态的Activity,Do Nothing
if (r.finishing || r.state == DESTROYING || r.state == DESTROYED) {
return;
}
// 只有Activity在可以销毁状态的时候才继续往下走
if (r.visible || !r.stopped || !r.haveState || r.state == RESUMED || r.state == PAUSING
|| r.state == PAUSED || r.state == STOPPING) {
continue;
}
if (r.task != null) {
if (firstTask == null) {
firstTask = r.task;
} else if (firstTask != r.task) {
// 2.1 只有存在两个以上的Task的时候才会到这里
if (tasks == null) {
tasks = new ArraySet<>();
tasks.add(firstTask);
}
tasks.add(r.task);
}
}
}
// 2.2 只有存在两个以上的Task的时候才不为空
if (tasks == null) {
if (DEBUG_RELEASE) Slog.d(TAG_RELEASE, "Didn't find two or more tasks to release");
return;
}
// If we have activities in multiple tasks that are in a position to be destroyed,
// let's iterate through the tasks and release the oldest one.
// 2.3 遍历找到ActivityStack释放最旧的那个
final int numDisplays = mActivityDisplays.size();
for (int displayNdx = 0; displayNdx < numDisplays; ++displayNdx) {
final ArrayList<ActivityStack> stacks = mActivityDisplays.valueAt(displayNdx).mStacks;
// Step through all stacks starting from behind, to hit the oldest things first.
// 从后面开始遍历,从最旧的开始匹配
for (int stackNdx = 0; stackNdx < stacks.size(); stackNdx++) {
final ActivityStack stack = stacks.get(stackNdx);
// Try to release activities in this stack; if we manage to, we are done.
// 尝试在这个stack里面销毁这些Activities,如果成功就返回。
if (stack.releaseSomeActivitiesLocked(app, tasks, reason) > 0) {
return;
}
}
}
}

上面代码都加了注释,我们来理一理重点需要关注的点。整个流程可以观察tasks的走向



  • 2.1 & 2.2: 第一次循环会给firstTask赋值,当firstTask != r.task的时候才会给tasks赋值,后续会继续对tasks操作。所以单栈的应用不会回收,如果tasks为null,就直接return了,什么都不做

  • 2.3: 这一大段的双重for循环其实都没有第一步遍历出来的tasks参与,真正释放Activity的操作在ActivityStack中,所以尝试找到这些tasks对应的ActivityStack,让ActivityStack去销毁tasks,直到成功销毁。


继续查看releaseSomeActivitiesLocked:


// step③ ActivityStack.java
final int releaseSomeActivitiesLocked(ProcessRecord app, ArraySet<TaskRecord> tasks, String reason) {
// Iterate over tasks starting at the back (oldest) first.
int maxTasks = tasks.size() / 4;
if (maxTasks < 1) {
maxTasks = 1;
}
// 3.1 maxTasks至少为1,至少清理一个
int numReleased = 0;
for (int taskNdx = 0; taskNdx < mTaskHistory.size() && maxTasks > 0; taskNdx++) {
final TaskRecord task = mTaskHistory.get(taskNdx);
if (!tasks.contains(task)) {
continue;
}
int curNum = 0;
final ArrayList<ActivityRecord> activities = task.mActivities;
for (int actNdx = 0; actNdx < activities.size(); actNdx++) {
final ActivityRecord activity = activities.get(actNdx);
if (activity.app == app && activity.isDestroyable()) {
destroyActivityLocked(activity, true, reason);
if (activities.get(actNdx) != activity) {
// Was removed from list, back up so we don't miss the next one.
// 3.2 destroyActivityLocked后续会调用TaskRecord.removeActivity(),所以这里需要将index--
actNdx--;
}
curNum++;
}
}
if (curNum > 0) {
numReleased += curNum;
// 移除一个,继续循环需要判断 maxTasks > 0
maxTasks--;
if (mTaskHistory.get(taskNdx) != task) {
// The entire task got removed, back up so we don't miss the next one.
// 3.3 如果整个task都被移除了,这里同样需要将获取Task的index--。移除操作在上面3.1的destroyActivityLocked,移除Activity过程中,如果task为空了,会将task移除
taskNdx--;
}
}
}
return numReleased;
}


  • 3.1: ActivityStack利用maxTasks 保证,最多清理tasks.size() / 4,最少清理1个TaskRecord,同时,至少要保证保留一个前台可见TaskRecord,比如如果有两个TaskRecord,则清理先前的一个,保留前台显示的这个,如果三个,则还要看看最老的是否被有效清理,也就是是否有Activity被清理,如果有则只清理一个,保留两个,如果没有,则继续清理次老的,保留一个前台展示的,如果有四个,类似,如果有5个,则至少两个清理。一般APP中,很少有超过两个TaskRecord的。

  • 3.2: 这里清理的逻辑很清楚,for循环,如果定位到了期望的activity就清理掉,但这里这个actNdx--是为什么呢?注释说activity从list中移除了,为了能继续往下走,需要index--,但在这个方法中并没有将activity从lsit中移除的操作,那肯定是在destroyActivityLocked方法中。继续追进去可以一直追到TaskRecord.java#removeActivity(),从当前的TaskRecord的mActivities中移除了,所以需要index--。

  • 3.3: 我们弄懂了上面的actNdx--之后也就知道这里为什么要index--了,在ActivityStack.java#removeActivityFromHistoryLocked()中有


	if (lastActivity) {
removeTask(task, reason, REMOVE_TASK_MODE_DESTROYING);
}

如果task中没有activity了,需要将这个task移除掉。


以上就是基于API 28的releaseSomeActivities分析。


2.3.2. 基于29+的版本的releaseSomeActivities实现如下:

// ActivityTaskManagerService.java
@Override
public void releaseSomeActivities(IApplicationThread appInt) {
synchronized (mGlobalLock) {
final long origId = Binder.clearCallingIdentity();
try {
final WindowProcessController app = getProcessController(appInt);
app.releaseSomeActivities("low-mem");
} finally {
Binder.restoreCallingIdentity(origId);
}
}
}

// WindowProcessController.java
void releaseSomeActivities(String reason) {
// Examine all activities currently running in the process. Candidate activities that can be destroyed.
// 检查进程里所有的activity,看哪些可以被关掉
ArrayList<ActivityRecord> candidates = null;
if (DEBUG_RELEASE) Slog.d(TAG_RELEASE, "Trying to release some activities in " + this);
for (int i = 0; i < mActivities.size(); i++) {
final ActivityRecord r = mActivities.get(i);
// First, if we find an activity that is in the process of being destroyed,
// then we just aren't going to do anything for now; we want things to settle
// down before we try to prune more activities.
// 首先,如果我们发现一个activity正在执行关闭中,在关掉这个activity之前什么都不做
if (r.finishing || r.isState(DESTROYING, DESTROYED)) {
if (DEBUG_RELEASE) Slog.d(TAG_RELEASE, "Abort release; already destroying: " + r);
return;
}
// Don't consider any activities that are currently not in a state where they can be destroyed.
// 如果当前activity不在可关闭的state的时候,不做处理
if (r.mVisibleRequested || !r.stopped || !r.hasSavedState() || !r.isDestroyable()
|| r.isState(STARTED, RESUMED, PAUSING, PAUSED, STOPPING)) {
if (DEBUG_RELEASE) Slog.d(TAG_RELEASE, "Not releasing in-use activity: " + r);
continue;
}

if (r.getParent() != null) {
if (candidates == null) {
candidates = new ArrayList<>();
}
candidates.add(r);
}
}

if (candidates != null) {
// Sort based on z-order in hierarchy.
candidates.sort(WindowContainer::compareTo);
// Release some older activities
int maxRelease = Math.max(candidates.size(), 1);
do {
final ActivityRecord r = candidates.remove(0);
r.destroyImmediately(true /*removeFromApp*/, reason);
--maxRelease;
} while (maxRelease > 0);
}
}

新版本的releaseSomeActivities放到了ActivityTaskManagerService.java这个类中,这个类是API 29新添加的,承载部分AMS的工作。
相比API 28基于Task栈的回收Activity策略,新版本策略简单清晰, 也激进了很多。


遍历所有Activity,刨掉那些不在可销毁状态的Activity,按照Activity堆叠的顺序,也就是Z轴的顺序,从老到新销毁activity。


有兴趣的读者可以自行编写测试代码,分别在API 28和API 28+的手机上测试看一下回收策略是否跟上面分析的一致。

也可以参考我写的TestKillActivity,单栈和多栈的情况下在高于API 28和低于API 28的手机上的表现。


总结:



  1. 系统内存不足时LMK会根据内存配置项来kill掉进程释放内存

  2. kill时会按照进程的ADJ规则来kill

  3. App内存不足时由GcWatcher来决定回收Activity的时机

  4. 可以使用getprop命令来查看当前手机的JVM内存分配和OOM配置

  5. releaseSomeActivities在API 28和API 28+的差别很大,低版本会根据Task数量来决定清理哪个task的。高版本简单粗暴,遍历activity,按照z order排序,优先release掉更老的activity。


参考资料:
Android lowmemorykiller分析
解读Android进程优先级ADJ算法
http://www.jianshu.com/p/3233c33f6…
juejin.cn/post/706306…
Android可见APP的不可见任务栈(TaskRecord)销毁分析


作者:Yocn
来源:juejin.cn/post/7231742100844871736
收起阅读 »

好坑啊,调用了同事写的基础代码,bug藏得还挺深!!

起因 事情的起因是我调用了同事的一个函数,这个函数返回了一个map[string]string结构体的变量optionMap(请忽略为什么要返回map结构体,后面有机会再讲),这个函数主要是查DB取获取当前系统的space_id和pkey,返回的内容基本上如下...
继续阅读 »

起因


事情的起因是我调用了同事的一个函数,这个函数返回了一个map[string]string结构体的变量optionMap(请忽略为什么要返回map结构体,后面有机会再讲),这个函数主要是查DB取获取当前系统的space_id和pkey,返回的内容基本上如下


 // 返回
optionMap = map[string]string{
"space_id":"xxx",
"pkey": "xxxx",
}

然后我修改了这个变量,添加了



optionMap["is_base"] = 1

然后我就return出我当前的函数了,然后在同一个请求内,但是当我再一次请求同事的函数时,返回给我的却是


 optionMap = map[string]string{
"space_id":"xxx",
"pkey": "xxxx",
"is_base": 1,
}

what! 怎么后面再次请求同事的的函数总是会多一个参数呢!!!!


经过


看了同事写的函数我才发现,原来他在内部使用了gin上下文去做了一个缓存,大概的代码意思减少重复space基础信息的查询,存入上下文中做缓存,提高代码效率,这里我写了一个示例大家可以看下


// 同事的代码
func BadReturnMap(ctx *gin.Context, key string) map[string]interface{} {
m := make(map[string]interface{})
// 查询缓存
value, ok := ctx.Get(key)
if ok {
bm, ok := value.(map[string]interface{})
if ok {
return bm
}
}
// io查询后存入变量
m["a"] = 1
// 保存缓存
fmt.Println("set cache: ")
fmt.Println(m) // map[a:1]
ctx.Set(key, m)
return m
}

// 我的使用
func TestBadReturnMap() {
fmt.Println("bad return map start")
ctx := &gin.Context{}
key := "cached:map_key"
mapOpt := BadReturnMap(ctx, key)
fmt.Printf("%p\n", mapOpt) // 0xc0003a6750 指向地址
mapOpt["b"] = 1
value, ok := ctx.Get(key)
fmt.Printf("%p\n", mapOpt) // 0xc0003a6750 指向地址
if ok {
fmt.Println("get cache: ")
fmt.Println(value.(map[string]interface{})) // map[a:1 def:1]
} else {
fmt.Println("unknown")
}
fmt.Println("bad return map end")
}

打印结果是


bad return map start
set cache:
map[a:1]
0xc0003a6750
0xc0003a6750
get cache:
map[a:1 b:1]
bad return map end

解释


在Go语言中,map是引用类型,当将一个map赋值给另一个变量时,实际上是将它们指向同一个底层的map对象。因此,当你修改其中一个变量的map时,另一个变量也会受到影响。


当你将函数内m变量赋值给外部函数内的变量时,它们实际上指向同一个map对象。所以当你在外部函数内修改mapOpt的值时,原始的缓存也会被修改。


如图所示


image.png


如何修改


当然修改方式有很多种,我这里列举了一种就是序列化存储到缓存然后反序列化取,如果你有更好的方式可下方留言



func ReturnMap(ctx *gin.Context, key string) map[string]interface{} {
m := make(map[string]interface{})
value, ok := ctx.Get(key)
if ok {
bytes := value.([]byte)
err := json.Unmarshal(bytes, &m)
if err != nil {
panic(err)
}
return m
}
// io查询后存入变量
m["a"] = 1
jsonBytes, err := json.Marshal(m)
if err != nil {
panic(err)
}
fmt.Println("set cache: ")
fmt.Println(m)
ctx.Set(key, jsonBytes)

return m
}

func TestReturnMap() {
fmt.Println("return map start")
ctx := &gin.Context{}
key := "cached:map_key"
mapOpt := ReturnMap(ctx, key)
fmt.Printf("%p\n", mapOpt)

mapOpt["b"] = 1
fmt.Printf("%p\n", mapOpt)

value, ok := ctx.Get(key)
if ok {
m := make(map[string]interface{})
bytes := value.([]byte)
err := json.Unmarshal(bytes, &m)
if err != nil {
panic(err)
}
fmt.Println("get cache: ")
fmt.Println(m)
} else {
fmt.Println("unknown")
}
fmt.Println("return map end")
}


打印的结果为:


return map start
set cache:
map[a:1]
0xc0003a6870
0xc0003a6870
get cache:
map[a:1]
return map end

知识点


作者:沙蒿同学
来源:juejin.cn/post/7330869056411058239
收起阅读 »

百度输入法在候选词区域植入广告,网友:真nb!

web
V2EX 用户发帖称,百度输入法最新版本在候选词区域植入了广告。具体表现为,如果用户要打 “招商银行” 四个字,当输入 “招商” 之后,候选词的首位是 “★热门加盟店排行” 的链接,点击后会进入名为「加盟星榜单」的广告页面。https://www.v2ex.c...
继续阅读 »

V2EX 用户发帖称,百度输入法最新版本在候选词区域植入了广告。

具体表现为,如果用户要打 “招商银行” 四个字,当输入 “招商” 之后,候选词的首位是 “★热门加盟店排行” 的链接,点击后会进入名为「加盟星榜单」的广告页面。

https://www.v2ex.com/t/1011440

别的不说,想出这个功能的产品经理真是个人才,因此评论区有用户感叹道:


不说用户体验怎么样,不得不说这个键盘的候选词广告想法确实超前,不光超前,还实现了。
根据输入内容,直接用候选词的方式推送广告,从源头出发拿到用户的一手数据,直接甩掉了各种中间商。速度也更快,更精确的投送。
可以说是真 nb 呀


知名科技博主阑夕对此评论道:“你都打出招商两个字了,一定是想加盟店铺做生意吧?逻辑极其通顺智能,对不对?这真的是人类能够企及的创新吗,太牛逼了。


作者:架构师大咖
来源:mp.weixin.qq.com/s/0KR2F_a9q2_9JSS8nXtodQ
收起阅读 »

从uni-app中去掉编译后微信小程序的滚动条

web
首先如果你使用的是页面级滚动,即使uni-app中的pages.json中有相关配置,在编译到小程序中也是没有效果的,因为小程序原生不支持,如下: 那么我们去看微信的官方回复: 所以得出一个结论,要想隐藏滚动条,我们必须使用scroll-view视图组件...
继续阅读 »

首先如果你使用的是页面级滚动,即使uni-app中的pages.json中有相关配置,在编译到小程序中也是没有效果的,因为小程序原生不支持,如下:



那么我们去看微信的官方回复:




所以得出一个结论,要想隐藏滚动条,我们必须使用scroll-view视图组件


那么在uni-app页面滚动是不是scroll-view,答案是的,但是我们没办法在顶层设置,因为官方没有暴露相关api,那么要想去掉滚动条,我们就只能在自己的页面使用scroll-view视图组件,取代全局的滚动视图。


下面上简易代码


<template>
<scroll-view scroll-y="true" :show-scrollbar="false" :enhanced="true" class="main">
<view class="list" v-for="iten in 30">列表{{iten}}</view>
</scroll-view>
</template>


<style lang="scss" scoped>
.main{
height: 100vh;
}
.list{
border: 1xp solid black;
margin: 20rpx auto;
text-align: center;
line-height: 100rpx;
}
</style>

效果图:


初版.gif


如果你的组件不是占满全屏,比如有头部导航


这时候有两种做法:


1.将头部标签放到scroll-view内部,然后固定定位


<template>
<scroll-view scroll-y="true" :show-scrollbar="false" :enhanced="true" class="main">
<view class="nav">导航nav</view>
<view class="list-container">
<view class="list" v-for="iten in 30">列表{{iten}}</view>
</view>
</scroll-view>
</template>

<style lang="scss" scoped>
.main{
height: 100vh;
}
.list-container{
margin-top: 200rpx;
}
.list{
border: 1xp solid black;
margin: 20rpx auto;
text-align: center;
line-height: 100rpx;
}
.nav{
position: fixed;
top: 0;
line-height: 200rpx;
padding-top: 20rpx;
width: 100vw;
text-align: center;
border: 1px solid black;
background-color: #fff;
}
</style>

效果图:


230187154229138168229133168229177143.gif


2.将scroll-view的高度设置为视口余下高度


这里注意一下在移动端尽量较少的使用cale()计算高度


所以这里我们使用flex布局


<template>
<view class="content">
<view class="nav">导航nav</view>
<scroll-view scroll-y="true" :show-scrollbar="false" :enhanced="true" class="main">
<view class="list" v-for="iten in 30">列表{{iten}}</view>
</scroll-view>
</view>
</template>

<style lang="scss" scoped>
.content{
height: 100vh;
width: 100vw;
overflow: hidden;
display: flex;
flex-direction: column;
}
.main{
flex-grow: 1;
}
.list{
border: 1xp solid black;
margin: 20rpx auto;
text-align: center;
line-height: 100rpx;
}
.nav{
height: 200rpx;
line-height: 200rpx;
width: 100vw;
text-align: center;
border: 1px solid black;
background-color: #fff;
}
</style>

效果图:


230187154229138168229133168229177143.gif


如果有帮助到你的话,记得点个赞哦!


猫咪.gif


作者:aways
来源:juejin.cn/post/7330655456883654667
收起阅读 »

解锁 JSON.stringify() 7 个鲜为人知的坑

web
在本文中,我们将探讨与JSON.stringify()相关的各种坑。 1. 处理undefined、Function和Symbol值 在前端中 undefined、Function和Symbol值不是有效的JSON值。在转换过程中遇到它们时,它们会被省略(在对...
继续阅读 »

在本文中,我们将探讨与JSON.stringify()相关的各种坑。


1. 处理undefined、Function和Symbol值


在前端中 undefinedFunctionSymbol值不是有效的JSON值。在转换过程中遇到它们时,它们会被省略(在对象中),或者被更改为null(在数组中)。


例如:

const obj = { foo: function() {}, bar: undefined, baz: Symbol('example') };  
const jsonString = JSON.stringify(obj);
console.log(jsonString); // 输出: '{}'

const obj2 = {arr: [function(){}]};
console.log(JSON.stringify(obj2)); // 输出: {"arr":[null]}

2. 布尔、数字和字符串对象


布尔、数字和字符串对象在字符串化过程中会被转换为它们对应的原始值。

const boolObj = new Boolean(true);  
const jsonString = JSON.stringify(boolObj);
console.log(jsonString); // 输出: 'true'

3. 忽略Symbol键的属性


Symbol键属性在字符串化过程中完全被忽略,即使使用替换函数也是如此。这意味着与Symbol键关联的任何数据都将在生成的JSON字符串中被排除。

const obj = { [Symbol('example')]: 'value' };  
const jsonString = JSON.stringify(obj);
console.log(jsonString); // 输出: '{}'

const obj2 = {[Symbol('example')]: [function(){}]};
console.log(JSON.stringify(obj2)); // 输出 '{}'

4. 处理无穷大(Infinity)、NaN和Null值


Infinity、NaN 和 null 值在字符串化过程中都被视为 null。

const obj = { value: Infinity, error: NaN, nothing: null };  
const jsonString = JSON.stringify(obj);
console.log(jsonString); // 输出: '{"value":null,"error":null,"nothing":null}'

5. Date对象被视为字符串


Date实例通过实现toJSON()函数来返回一个字符串(与date.toISOString()相同),因此在字符串化过程中被视为字符串。

const dateObj = new Date();
const jsonString = JSON.stringify(dateObj);
console.log(jsonString); // 输出:"2024-01-31T09:42:00.179Z"

6. 循环引用异常


如果 JSON.stringify() 遇到具有循环引用的对象,它会抛出一个错误。循环引用发生在一个对象在循环中引用自身的情况下。

const circularObj = { self: null };
circularObj.self = circularObj;
JSON.stringify(circularObj); // Uncaught TypeError: Converting circular structure to JSON

7. BigInt转换错误


使用JSON.stringify()转换BigInt类型的值时引发错误。

const bigIntValue = BigInt(42);  
JSON.stringify(bigIntValue); // Uncaught TypeError: Do not know how to serialize a BigInt

各位同学如果在开发中还遇到过不一样的坑,还请评论区补充互相讨论


作者:StriveToY
来源:juejin.cn/post/7330289404731047936
收起阅读 »

大公司如何做 APP:背后的开发流程和技术

我记得五六年前,当我在 Android 开发领域尚处初出茅庐阶段之时,我曾有一个执念——想看下大公司在研发一款产品的流程和技术上跟小公司有什么区别。公司之大,对开发来说,不在于员工规模,而在于产品的用户量级。只有用户量级够大,研发过程中的小问题才会被放大。当用...
继续阅读 »

我记得五六年前,当我在 Android 开发领域尚处初出茅庐阶段之时,我曾有一个执念——想看下大公司在研发一款产品的流程和技术上跟小公司有什么区别。公司之大,对开发来说,不在于员工规模,而在于产品的用户量级。只有用户量级够大,研发过程中的小问题才会被放大。当用户量级够大,公司才愿意在技术上投入更多的人力资源。因此,在大公司里做技术,对个人的眼界、技术细节和深度的提升都有帮助。


我记得之前我曾跟同事调侃说,有一天我离职了,我可以说我毕业了,因为我这几年学到了很多。现在我想借这个机会总结下这些年在公司里经历的让我印象深刻的技术。


1、研发流程


首先在产品的研发流程上,我把过去公司的研发模式分成两种。


第一种是按需求排期的。在评审阶段一次性评审很多需求,和开发沟通后可能删掉优先级较低的需求,剩下的需求先开发,再测试,最后上线。上线的时间根据开发和测试最终完成的时间确定。


第二种是双周迭代模式,属于敏捷开发的一种。这种开发机制里,两周一个版本,时间是固定的。开发、测试和产品不断往时间周期里插入需求。如下图,第一周和第三周的时间是存在重叠的。具体每个阶段留多少时间,可以根据自身的情况决定。如果需求比较大,则可以跨迭代,但发布的时间窗口基本是固定的。


截屏2023-12-30 13.00.33.png


有意思的是,第二种开发机制一直是我之前的一家公司里负责人羡慕的“跑火车”模式。深度参与过两种开发模式之后,我说下我的看法。


首先,第一种开发模式适合排期时间比较长的需求。但是这种方式时间利用率相对较低。比如,在测试阶段,开发一般是没什么事情做的(有的会在这个时间阶段布置支线需求)。这种开发流程也有其好处,即沟通和协调成本相对较低。


注意!在这里,我们比较时间利用率的时候是默认两种模式的每日工作时间是相等的且在法律允许范围内。毕竟,不论哪一种研发流程,强制加班之后,时间利用率都“高”(至少老板这么觉得)。


第二种开发方式的好处:



  1. 响应速度快。可以快速发现问题并修复,适合快速试错。

  2. 时间利用率高。相比于按需求排期的方式,不存在开发和测试的间隙期。


但这种开发方式也有缺点:



  1. 员工压力大,容易造成人员流失。开发和测试时间穿插,开发需要保证开发的质量,否则容易影响整个迭代内开发的进度。

  2. 沟通成本高。排期阶段出现人力冲突需要协调。开发过程中出现问题也需要及时、有效的沟通。因此,在这种开发模式里还有一个角色叫项目经理,负责在中间协调,而第一种开发模式里项目经理的存在感很低。

  3. 这种开发模式中,产品要不断想需求,很容易导致开发的需求本身价值并不大。


做了这么多年开发,让人很难拒绝一个事实是,绝大多数互联网公司的壁垒既不是技术,也不是产品,而是“快速迭代,快速试错”。从这个角度讲,双周迭代开发机制更适应互联网公司的要求。就像我们调侃公司是给电脑配个人,这种开发模式里就是给“研发流水线”配个人,从产品、到开发、到测试,所有人都像是流水线上的一员。


2、一个需求的闭环


以上是需求的研发流程。如果把一个需求从产品提出、到上线、到线上数据回收……整个生命周期列出来,将如下图所示,


需求闭环.drawio.png


这里我整合了几个公司的研发过程。我用颜色分成了几个大的流程。相信每个公司的研发流程里或多或少都会包含其中的几个。在这个闭环里,我说一下我印象比较深刻的几个。


2.1 产品流程


大公司做产品一个显著的特点是数据驱动,一切都拿数据说话。一个需求的提出只是一个假设,开发上线之后效果评估依赖于数据。数据来源主要有埋点上报和舆情监控。


1. 数据埋点


埋点数据不仅用于产品需求的验证,也用于推荐算法的训练。因此,大公司对数据埋点的重视可以说是深入骨髓的。埋点数据也经常被纳入到绩效考核里。


开发埋点大致要经过如下流程,



  • 1). 产品提出需要埋的点。埋点的类型主要包括曝光和点击等,此外还附带一些上报的参数,统计的维度包括用户 uv 和次数 pv.

  • 2). 数据设计埋点。数据拿到产品要埋的点之后,设计埋点,并在埋点平台录入。

  • 3). 端上开发埋点。端上包括移动客户端和 Web,当然埋点框架也要支持 RN 和 H5.

  • 4). 端上验证埋点。端上埋点完成之后需要测试,上报埋点,然后再在平台做埋点校验。

  • 5). 产品提取埋点数据。

  • 6). 异常埋点数据修复。


由此可见,埋点及其校验对开发来说也是需要花费精力的一环。它不仅需要多个角色参与,还需要一个大数据平台,一个录入、校验和数据提取平台,以及端上的上报框架,可以说成本并不低。


2. 舆情监控


老实说,初次接触舆情监控的时候,它还是给了我一点小震撼的。没想到大公司已经把舆情监控做到了软件身上。


舆情监控就是对网络上关于该 APP 的舆情的监控,数据来源不仅包括应用内、外用户提交的反馈,还包括主流社交平台上关于该软件的消息。所有数据在整合到舆情平台之后会经过大数据分析和分类,然后进行监控。舆情监控工具可以做到对产品的负面信息预警,帮助产品经理优化产品,是产品研发流程中重要的一环。


3. AB 实验


很多同学可能对 AB 实验都不陌生。AB 实验就相当于同时提出多套方案,然后左右手博弈,从中择优录用。AB 实验的一个槽点是,它使得你代码中同时存在多份作用相同的代码,像狗皮膏药一样,也不能删除,非常别扭,最后导致的结果是代码堆积如山。


4. 路由体系建设


路由即组件化开发中的页面路由。但是在有些应用里,会通过动态下发路由协议支持运营场景。这在偏运营的应用里比较常见,比如页面的推荐流。一个推荐流里下发的模块可能打开不同的页面,此时,只需要为每个页面配置一个路由路径,然后推荐流里根据需要下发即可。所以,路由体系也需要 Android 和 iOS 双端统一,同时还要兼容 H5 和 RN.


mdn-url-all.png


在路由协议的定义上,我们可以参考 URL 的格式,定义自己的协议、域名、路径以及参数。以 Android 端为例,可以在一个方法里根据路由的协议、域名对原生、RN 和 H5 等进行统一分发。


2.2 开发流程


在开发侧的流程里,我印象深的有以下几个。


1. 重视技术方案和文档


我记得之前在一家公司里只文档平台就换了几个,足见对文档的重视。产品侧当然更重文档,而对研发侧,文档主要有如下几类:1). 周会文档;2).流程和规范;3).技术方案;4).复盘资料等。


对技术方案,现在即便我自己做技术也保留了写大需求技术方案先行的习惯。提前写技术方案有几个好处:



  • 1). 便于事后回忆:当我们对代码模糊的时候,可以通过技术方案快速回忆。

  • 2). 便于风险预知:技术方案也有助于提前预知开发过程中的风险点。前面我们说敏捷开发提前发现风险很重要,而做技术方案就可以做到这点。

  • 3). 便于全面思考:技术方案能帮助我们更全面地思考技术问题。一上来就写代码很容易陷入“只见树木,不见森林”的困境。


2. Mock 开发


Mock 开发也就是基于 Mock 的数据进行开发和测试。在这里它不局限于个人层面(很多人可能有自己 Mock 数据开发的习惯),而是在公司层面将其作为一种开发模式,以实现前后端分离。典型的场景是客户端先上线预埋,而后端开发可能滞后一段时间。为了支持 Mock 开发模式,公司需要专门的平台,提供以接口为维度的 Mock 工具。当客户端切换到 Mock 模式之后,上传到网络请求在后端的网关直接走 Mock 服务器,拉取 Mock 数据而不是真实数据。


这种开发模式显然也是为了适应敏捷开发模式而提出的。它可以避免前后端依赖,减轻人力资源协调的压力。这种开发方式也有其缺点:



  • 1). 数据结构定义之后无法修改。客户端上线之后后端就无法再修改数据结构。因此,即便后端不开发,也需要先投入人力进行方案设计,定义数据结构,并拉客户端进行评审。

  • 2). 缺少真实数据的验证。在传统的开发模式中,测试要经过测试和 UAT 两个环境,而 UAT 本身已经比较接近线上环境,而使用 Mock 开发就完全做不到这么严谨。当我们使用 Mock 数据测试时,如果我们自己的 Mock 的数据本身失真比较严重,那么在意识上你也不会在意数据的合理性,因此容易忽视一些潜在的问题。


3. 灰度和热修复


灰度的机制是,在用户群体中选择部分用户进行应用更新提示的推送。这要求应用本身支持自动更新,同时需要对推送的达到率、用户的更新率进行统计。需要前后端一套机制配合。灰度有助于提前发现应用中存在的问题,这对超大型应用非常有帮助,毕竟,现在上架之后发现问题再修复的成本非常高。


但如果上架之后确实出现了问题就需要走热修复流程。热修复的难点在于热修复包的下发,同时还需要审核流程,因此需要搭建一个平台。这里涉及的细节比较多,后面有时间再梳理吧。


4. 配置下发


配置下发就是通过平台录入配置,推送,然后在客户端读取配置信息。这也是应用非常灵活的一个功能,可以用来下发比如固定的图片、文案等。我之前做个人开发的时候也在服务器上做了配置下发的功能,主要用来绕过某些应用商店的审核,但是在数据结构的抽象上做得比较随意。这里梳理下配置下发的细节。



  • 首先,下发的配置是区分平台特征的。这包括,应用的目标版本(一个范围)、目标平台(Android、iOS、Web、H5 或者 RN)。

  • 其次,为了适应组件化开发,也为了更好地分组管理,下发的配置命名时采用 模块#配置名称 的形式。

  • 最后,下发的数据结构支持,整型、布尔类型、浮点数、字符串和 Json.


我自己在做配置下发的时候还遇到一个比较棘手的问题——多语言适配。国内公司的产品一般只支持中文,这方面就省事得多。


5. 复盘文化


对于敏捷开发,复盘是不可或缺的一环。有助于及时发现问题,纠正和解决问题。复盘的时间可以是定期的,在一个大需求上线之后,或者出现线上问题之后。


3、技术特点


3.1 组件化开发的痛点


在大型应用开发过程中,组件化开发的意义不仅局限于代码结构层面。组件化的作用体现在以下几个层面:



  • 1). 团队配合的利器。想想几十个人往同一份代码仓库里提交代码的场景。组件化可以避免无意义的代码冲突。

  • 2). 提高编译效率。对于大型应用,全源码编译一次的时间可能要几十分钟。将组件打包成 aar 之后可以减少需要编译的代码的数量,提升编译效率。

  • 3). 适应组织架构。将代码细分为各个组件,每个小团队只维护自己的组件,更方便代码权限划分。


那么,在实际开发过程中组件化开发会存在哪些问题呢?


1. 组件拆分不合理


这在从单体开发过渡到组件化开发的应用比较常见,即组件化拆分之后仍然存在某些模块彼此共用,导致提交代码的时候仍然会出现冲突问题。冲突包含两个层面的含义,一是代码文件的 Git 冲突,二是在打包合入过程中发布的 aar 版本冲突。比较常见的是,a 同学合入了代码到主干之后,b 同学没有合并主干到自己的分支就打包,导致发布的 aar 没有包含最新的代码。这涉及打包的问题,是另一个痛点问题,后面再总结。


单就拆分问题来看,避免上述冲突的一个解决办法是在拆分组件过程中尽可能解耦。根据我之前的观察,存在冲突的组件主要是数据结构和 SPI 接口。这是我之前公司没做好的地方——数据结构仓库和 SPI 接口是共用的。对于它们的组件化拆分,我待过的另一家公司做得更好。他们是如下拆分的,这里以 A 和 B 来命名两个业务模块。那么,在拆分的时候做如下处理,


模块:A-api
模块:A
模块:B-api
模块:B

即每个业务模块拆分成 api 和实现两部分。api 模块里包含需要共享的数据结构和 SPI 接口,实现模块里是接口的具体实现。当模块 A 需要和模块 B 进行交互的时候,只需要依赖 B 的 api 模块。可以参考开源项目:arch-android.


2. 打包合入的痛点


上面我们提到了一种冲突的情况。在我之前的公司里,每个组件有明确的负责人,在每个迭代开发的时候,组件负责人负责拉最新 release 分支。其他同学在该分支的开发需要经过负责人同意再合入到该分支。那么在最终打包的过程中,只需要保证这个分支的 aar 包含了全部最新的代码即可。也就是说,这种打包方式只关心每个 aar 的版本,而不关心实际的代码。因为它最终打包是基于 aar 而不是全源码编译。


这种打包方式存在最新的分支代码没有被打包的风险。一种可行的规避方法是,在平台通过 Git tag 和 commit 判断该分支是否已经包含最新代码。此外,还可能存在某个模块修改了 SPI 接口,而另一个模块没有更新,导致运行时异常的风险。


另一个公司是基于全源码编译的。不过,全源码编译只在最终打包阶段或者某个固定的时间点进行,而不是每次合入都全源码编译(一次耗时太久)。同时,虽然每个模块有明确的负责人,但是打包的 aar 不是基于当前 release 分支,而是自己的开发分支。这是为了保障当前 release 分支始终是可用的。合并代码到 release 分支的同时需要更新 aar 的版本。但它也存在问题,如果合并到 release 而没有打包 aar,那么可能导致 release 分支无法使用。如果打包了 aar 但是此时其他同学也打包了 aar,则可能导致本次打包的 aar 落后,需要重新打包。因此,这种合入方式也是苦不堪言。


有一种方法可以避免上述问题,即将打包和合入事件设计成一个消息队列。每次合入之前自动化执行上述操作,那么自然就可以保证每次操作的原子性(因为本身就是单线程的)。


对比两种打包和合入流程,显然第二种方式更靠谱。不过,它需要设计一个流程。这需要花费一点功夫。


3. 自动化切源码


我在之前的一家公司开发时,在开发过程中需要引用另一个模块的修改时,需要对另一个模块打 SNAPSHOT 包。这可行,但有些麻烦。之前我也尝试过手动修改 settings.gradle 文件进行源码依赖开发。不过,太麻烦了。


后来在另一个公司里看到一个方案,即动态切换到源码开发。可以将某个依赖替换为源码而只需要修改脚本即可。这个实践很棒,我已经把它应用到独立开发中。之前已经梳理过《组件化开发必备:Gradle 依赖切换源码的实践》.


3.2 大前端化开发


1. React Native


如今的就业环境,哪个 Android 开发不是同时会五六门手艺。跨平台开发几乎是不可避免的。


之前的公司为什么选择 React Native 而不是 Flutter 等新锐跨平台技术呢?我当时还刻意问了这个问题。主要原因:



  • 1). 首先是 React Native 相对更加成熟,毕竟我看了下 Github 第一个版本发布已经是 9 年前的事情了,并且至今依旧非常活跃。

  • 2). React Native 最近更新了 JavaScript 引擎,页面启动时间、包大小和内存占用性能都有显著提升。参考这篇文章《干货 | 加载速度提升15%,携程对RN新一代JS引擎Hermes的调研》.

  • 3). 从团队人才配置上,对 React Native 熟悉的更多。


React Native 开发是另一个领域的东西,不在本文讨论范围内。每个公司选择 React Native 可能有它的目的。比如,我之前的一家公司存粹是为了提效,即一次开发双端运行。而另一家公司,则是为了兼顾提效和动态化。如果只为提效,那么本地编译和打包 js bundle 就可以满足需求。若要追求动态化,就需要搭建一个 RN 包下发平台。实际上,在这个公司开发 RN 的整个流程,除了编码环节,从代码 clone 到最终发布都是在平台上执行的。平台搭建涉及的细节比较多,以后用到再总结。对于端侧,RN 的动态化依赖本地路由以及 RN 容器。


2. BFF + DSL


DSL 是一种 UI 动态下发的方案。相比于 React Native,DSL 下发的维度更细,是控件级别的(而 RN 是页面级别的)。简单的理解是,客户端和后端约定 UI 格式,然后按照预定的格式下发的数据。客户端获取到数据之后渲染。DSL 不适合需要复杂动画的场景。若确实要复杂动画,则需要自定义控件。


工作流程如下图中左侧部分所示,右侧部分是每个角色的责任。


DSL workflow.drawio.png


客户端将当前页面和位置信息传给 DSL 服务器。服务器根据上传的信息和位置信息找到业务接口,调用业务接口拉取数据。获取到数据后根据开发过程中配置的脚本对数据进行处理。数据处理完成之后再交给 DSL 服务器渲染。渲染完成之后将数据下发给客户端。客户端再根据下发的 UI 信息进行渲染。其中接口数据的处理是通过 BFF 实现的,由客户端通过编写 Groovy 脚本实现数据结构的转换。


这种工作流程中,大部分逻辑在客户端这边,需要预埋点位信息。预埋之后可以根据需求进行下发。这种开发的一个痛点在于调试成本高。因为 DSL 服务器是一个黑盒调用。中间需要配置的信息过多,搭建 UI 和编写脚本的平台分散,出现问题不易排查。


总结


所谓他山之石,可以攻玉。在这篇文章中,我只是选取了几个自己印象深刻的技术点,零零碎碎地写了很多,比较散。对于有这方面需求的人,会有借鉴意义。


作者:开发者如是说
来源:juejin.cn/post/7326268908984434697
收起阅读 »

功能问题:如何限制同一账号只能在一处登录?

大家好,我是大澈! 本文约1200+字,整篇阅读大约需要2分钟。 感谢关注微信公众号:“程序员大澈”,免费领取"面试礼包"一份,然后免费加入问答群,从此让解决问题的你不再孤单! 1. 需求分析 前阵子,和问答群里一个前端朋友,随便唠了唠。期间他问了我一个问题,...
继续阅读 »

大家好,我是大澈!


本文约1200+字,整篇阅读大约需要2分钟。


感谢关注微信公众号:“程序员大澈”,免费领取"面试礼包"一份,然后免费加入问答群,从此让解决问题的你不再孤单!


1. 需求分析


前阵子,和问答群里一个前端朋友,随便唠了唠。期间他问了我一个问题,让我印象深刻。


他问的是,限制同一账号只能在一处设备上登录,是如何实现的?并且,他还把这个功能称为“单点登录”。


我说这不叫“单点登录”,这是“单设备登录”。


于是,当时对此概念区分不清的他,和我在语言上开始了深度纠缠。


所以在后面我就想,这个功能问题有必要整理一下,分享给现在还不清楚两者概念的朋友们。


图片



2. 功能实现


先聊聊“单点登录”和“单设备登录”区别,再说说实现“单设备登录”的步骤。


2.1 单点登录和单设备登录的区别


“单点登录”和“单设备登录”是两个完全不同的概念。


单设备登录指:在某个给定的时间,同一用户只能在一台设备上进行登录,如果在其他设备上尝试登录,先前的会话将被中断或注销。


单点登录(简称SSO)指:允许用户使用一组凭据(如用户名和密码)登录到一个系统,然后可以在多个相关系统中,无需重新登录即可访问受保护的资源。


关于“单点登录”的实现,这里简单说一下。一般有两种方式:若后端处理,部署一个认证中心,这是标准做法;若前端处理,可以用LocalStorage做跨域缓存。


2.2 单设备登录的实现


要实现单设备登录,一般来说,有两种方式:使用数据库记录登录状态 和 使用令牌验证机制 。


使用令牌验证机制 的实现步骤如下:


• 用户登录时生成token,将账号作为key,token作为value,并设置过期时间存入redis中。


• 当用户访问应用时,在拦截器中解析token,获取账号,然后用账号去redis中获取相应的value。


• 如果获取到的value的token与当前用户携带的token一致,则允许访问;如果不一致,则提示前端重复登录,让前端清除token,并跳转到登录页面。


• 当用户在另一台设备登录时,其token也会存入redis中,这样就刷新了token的值和redis的过期时间。


图片


使用数据库记录登录状态 的实现步骤如下:


• 在用户登录时,记录用户的账号信息、登录设备的唯一标识符(如设备ID或IP地址)以及登录时间等信息到数据库中的一个登录表。


• 每次用户的登录请求都会查询数据库中的登录表,检查是否存在该用户的登录记录。如果存在记录,则比对登录设备的标识符和当前设备的标识符是否相同。


• 如果当前设备与登录设备不匹配,拒绝登录并提示用户在其他设备上已登录。若匹配,则更新登录时间。


• 当用户主动退出登录或超过一定时间没有操作时,清除该用户的登录记录。




作者:程序员大澈
来源:juejin.cn/post/7320166206215340072
收起阅读 »

Android架构设计 搞懂应用架构设计原则,不要再生搬硬套的使用MVVM、MVI

首先,谷歌官方似乎并没有把自己建议的应用架构命名为 MVVM 或 MVI, MVVM 和 MVI是开发者根据不同时期官方应用架构指南的特点,达成的一个统一称谓。 对于学习这两个种架构,我们需要自己去理解官方应用架构指南,否则只能生搬硬套的使用他人理解的 MVV...
继续阅读 »

首先,谷歌官方似乎并没有把自己建议的应用架构命名为 MVVM 或 MVI, MVVM 和 MVI是开发者根据不同时期官方应用架构指南的特点,达成的一个统一称谓。


对于学习这两个种架构,我们需要自己去理解官方应用架构指南,否则只能生搬硬套的使用他人理解的 MVVM 和 MVI。



首先看看现在最新的官方应用架构指南,是如何建议我们搭建应用架构的。


一,官方应用架构指南


1,架构的原则


应用架构定义了应用的各个部分之间的界限以及每个部分应承担的职责。谷歌建议按照以下原则设计应用架构。


1,分离关注点


2,通过数据模型驱动界面


3,单一数据源


4,单向数据流


2,谷歌推荐的应用架构


每个应用应至少有两个层:



  • 界面层 - 在屏幕上显示应用数据。

  • 数据层 - 包含应用的业务逻辑并公开应用数据。


可以额外添加一个名为“网域层”的架构层,以简化和重复使用界面层与数据层之间的交互。


总结下来,我们的应用架构应该有三层:界面层、网域层、数据层。


其中网域层可选,即无论你的应用中有没有网域层,与你的应用架构是 MVVM 还是 MVI 无关。


image.png


个人的理解是:界面层、网域层、数据层应该是应用级别的,而不是页面级别的。


如果我认为分层是页面级别的,那么我在接到一个业务需求 A 时(一般一个业务会新建一个页面activity 或 fragment 来承接),我的实现思路是:



  • 新建页面 A_Activity

  • 新建状态容器 A_ViewModel

  • 新建数据层 A_Repository


这样的话,数据层 A_Repository 和 界面层:界面元素 A_Activity + 状态容器 A_ViewModel 强相关,数据层无复用性可言。


如果我认为分层是应用级别的,那么在接到业务A 时,实现思路是:



  • 新建页面 A_Activity

  • 新建状态容器 A_ViewModel

  • 首先从应用的数据层寻找业务 A 需要使用的 业务数据 是否有对应的存储仓库 Respository,如果有,则复用(A_ViewModel 中依赖 Repository),拿到业务数据后,转成 UI 数据;如果无,则创建 这种业务数据 的存储仓库 Repository,后续其他界面层如果页使用到这种业务数据,可以直接复用这种业务数据对应的 Repository。


2.1,界面层架构设计指导


界面层在架构中的作用


界面的作用是在屏幕上显示应用数据,并充当主要的用户互动点。


从数据层获取是业务数据,有时候需要界面层将业务数据转换成 UI 数据供界面元素显示。


界面层的组成


界面层由以下两部分组成:



  • 界面元素:在屏幕上呈现数据的界面元素可以使用 View 或 Jetpack Compose 函数实现。

  • 状态容器:用于存储数据、向界面提供数据以及处理逻辑的状态容器(如 ViewModel 类)。


image.png


界面层的架构设计遵循的原则


这里以一个常见的列表页面为案例进行讲解,这个列表页面有以下交互:



  • 打开页面时,网络数据回来之前展示一个加载中 view。

  • 首次打卡页面,如果没有数据或者网络请求发生错误,展示一个错误 view。

  • 具备下拉刷新能力,刷新后,如果有数据,则替换列表数据;如果无返回数据,则弹出一个 Toast。


接着我们用这个业务,按照以下原则进行分析:


1, 定义界面状态


界面元素 加上 界面状态 才是用户看到的界面。


image.png


上面说的列表页面,根据它的业务需求,需要有以下界面状态



  • 展示加载中 view 的界面状态

  • 展示加载错误 view 的界面状态

  • 列表数据 view 界面状态

  • Toast view 界面状态

  • 刷新完成 view 界面状态


无论采用 MVVM 还是 MVI,都需要这些界面状态,只是他们的实现细节不同,具体可以看下面的讲解。


2,定义状态容器


状态容器:就是存放我们定义的界面状态,并且包含执行相应任务所必需的逻辑的类。


ViewModel 类型是推荐的状态容器,用于管理屏幕级界面状态,具有数据层访问权限。但并不是只能用 ViewModel作为状态容器。


无论采用 MVVM 还是 MVI,都需要定义状态容器,来存放界面状态。


3,使用单向数据流管理状态


看看官方在界面层的架构指导图:


image.png


界面状态数据流动是单向的,只能从 状态容器 到 界面元素。


界面发生的事件 events(如刷新、加载更多等事件)流动是单向的,只能从 界面元素 到 状态容器。


无论采用 MVVM 还是 MVI,都需要使用单向数据流管理状态。


4,唯一数据源


唯一数据源针对的是:定义的界面状态 和 界面发生的事件。


界面状态唯一数据源指的是将定义的多个界面状态,封装在一个类中,如上面的列表业务,不采用唯一数据源,界面状态的声明为:


/**
* 加载失败 UI 状态,显示失败图
* 首屏获取的数据为空、首屏请求数据失败时展示失败图
* 初始值:隐藏
*/

val loadingError: StateFlow<Boolean>
get() = _loadingError
private val _loadingError = MutableStateFlow<Boolean>(false)

/**
* 正在加载 UI 状态,显示加载中图
* 首屏时请求网络时展示加载中图
* 初始值:展示
*/

val isLoading: StateFlow<Boolean>
get() = _isLoading
private val _isLoading = MutableStateFlow<Boolean>(true)

/**
* 加载成功后回来的列表 UI 状态,将 list 数据展示到列表上
*/

val newsList: StateFlow<MutableList<News>>
get() = _newsList
private val _newsList = MutableStateFlow<MutableList<News>>(mutableListOf())

/**
* 加载完成 UI 状态
*/

val loadingFinish: StateFlow<Boolean>
get() = _loadingFinish
private val _loadingFinish = MutableStateFlow<Boolean>(false)

/**
* 界面 toast UI 状态
*/

val toastMessage: StateFlow<String>
get() = _toastMessage
private val _toastMessage = MutableStateFlow<String>("")

采用唯一数据源声明界面状态时,代码如下:


sealed interface NewsUiState  {

object IsLoading: NewsUiState

object LoadingError: NewsUiState

object LoadingFinish: NewsUiState

data class Success(val newsList: MutableList<News>): NewsUiState

data class ToastMessage(val message: String = ""): NewsUiState

}


val newsUiState: StateFlow<NewsUiState>
get() = _newsUiState

private val _newsUiState: MutableStateFlow<NewsUiState> =
MutableStateFlow(NewsUiState.IsLoading)


界面发生的事件的唯一数据源指的是将界面发生的事件封装在一个类中,然后统一处理。比如上面描述的列表业务,它的界面事件有 初始化列表事件(首屏请求网络数据)、刷新事件、加载更多事件。


不采用唯一数据源,界面事件的调用实现逻辑为:在 activity 中直接调用 viewModel 提供的 initData、freshData 和 loadMoreData 方法;


采用唯一数据源,界面事件的调用实现逻辑为,先将事件中封装在一个 Intent 中,viewModel 中提供一个统一的事件入口处理方法 dispatchIntent,在 activity 中 各个场景下都调用 viewModel#dispatchIntent,代码如下:


sealed interface NewsActivityIntent {
data class InitDataIntent(val type: String = "init") : NewsActivityIntent

data class RefreshDataIntent(val type: String = "refresh") : NewsActivityIntent

data class LoadMoreDataIntent(val type: String = "loadMore") : NewsActivityIntent
}

fun dispatchIntent(intent: NewsActivityIntent) {
when (intent) {
is NewsActivityIntent.InitDataIntent -> {
//初始化逻辑
initNewsData()
}
is NewsActivityIntent.RefreshDataIntent -> {
//刷新逻辑
refreshNewsData()
}
is NewsActivityIntent.LoadMoreDataIntent -> {
//加载更多逻辑
loadMoreNewsData()
}
}
}


因为有了唯一数据源这一特点,才将最新的应用架构称为 MVI,MVVM 不具备这一特点。


5,向界面公开界面状态的方式


在状态容器中定义界面状态后,下一步思考的是如何将提供的状态发送给界面。


谷歌推荐使用 LiveData 或 StateFlow 等可观察数据容器中公开界面状态。这样做的优点有:



  • 解耦界面元素(activity 或 fragment) 与 状态容器,如:activity 持有 viewModel 的引用,viewModel 不需要持有 activity 的引用。


无论采用 MVVM 还是 MVI,都需要向界面公开界面状态,公开的方式也可以是一样的。


6,使用界面状态


在界面中使用界面状态时,对于 LiveData,可以使用 observe() 方法;对于 Kotlin 数据流,您可以使用 collect() 方法或其变体。


注意:在界面中使用可观察数据容器时,需要考虑界面的生命周期。因为当未向用户显示视图时,界面不应观察界面状态。使用 LiveData 时,LifecycleOwner 会隐式处理生命周期问题。使用数据流时,最好通过适当的协程作用域和 repeatOnLifecycle API,如:


class NewsActivity : AppCompatActivity() {

    private val viewModel: NewsViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        ...

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect {
                    // Update UI elements
                }
            }
        }
    }
}

无论采用 MVVM 还是 MVI,都需要使用界面状态,使用的方式都是一样的。


2.2,数据层架构设计指导


数据层在架构中的作用


数据层包含应用数据和业务逻辑。业务逻辑决定应用的价值,它由现实世界的业务规则组成,这些规则决定着应用数据的创建、存储和更改方式。


数据层的架构设计


数据层由多个仓库组成,其中每个仓库都可以包含零到多个数据源。您应该为应用中处理的每种不同类型的数据分别创建一个存储库类。例如,您可以为与电影相关的数据创建一个 MoviesRepository 类,或者为与付款相关的数据创建一个 PaymentsRepository 类。


每个数据源类应仅负责处理一个数据源,数据源可以是文件、网络来源或本地数据库。


层次结构中的其他层不能直接访问数据源;数据层的入口点始终是存储库类。


公开 API


数据层中的类通常会公开函数,以执行一次性的创建、读取、更新和删除 (CRUD) 调用,或接收关于数据随时间变化的通知。对于每种情况,数据层都应公开以下内容:



  • 一次性操作:在 Kotlin 中,数据层应公开挂起函数;对于 Java 编程语言,数据层应公开用于提供回调来通知操作结果的函数。

  • 接收关于数据随时间变化的通知:在 Kotlin 中,数据层应公开数据流,对于 Java 编程语言,数据层应公开用于发出新数据的回调。


class ExampleRepository(
    private val exampleRemoteDataSource: ExampleRemoteDataSource, // network
    private val exampleLocalDataSource: ExampleLocalDataSource // database
) {

    val data: Flow<Example> = ...

    suspend fun modifyData(example: Example) { ... }
}

多层存储库


在某些涉及更复杂业务要求的情况下,存储库可能需要依赖于其他存储库。这可能是因为所涉及的数据是来自多个数据源的数据聚合,或者是因为相应职责需要封装在其他存储库类中。


例如,负责处理用户身份验证数据的存储库 UserRepository 可以依赖于其他存储库(例如 LoginRepository 和 RegistrationRepository,以满足其要求。


image.png


注意:传统上,一些开发者将依赖于其他存储库类的存储库类称为 manager,例如称为 UserManager 而非 UserRepository。


数据层生命周期


如果该类的职责作用于应用至关重要,可以将该类的实例的作用域限定为 Application 类。


如果只需要在应用内的特定流程(例如注册流程或登录流程)中重复使用同一实例,则应将该实例的作用域限定为负责相应流程的生命周期的类。例如,可以将包含内存中数据的 RegistrationRepository 的作用域限定为 RegistrationActivity。


数据层定位思考


数据层不应该是页面级别的(一个页面对应一个数据层),而应该是应用级别的(数据层有多个存储仓库,每种数据类型有一个对应的存储仓库,不同的界面层可以复用存储仓库)。


比如我做的应用是运动健康app,用户的睡眠相关的数据有一个 SleepResposity,用户体重相关的数据有一个 WeightReposity,由于应用中很多界面都可能需要展示用户的睡眠数据和体重数据,所以 SleepResposity 和 WeightReposity 可以供不同界面层使用。


二,MVVM


1,MVVM 架构图


image.png


2,MVVM 实现一个具体业务


使用上面提到的列表页面业务,按照 MVVM 架构实现如下:


2.1,界面层的实现


界面层实现时,需要遵循以下几点。


1,选择实现界面的元素


界面元素可以用 view 或 compose 来实现,这里用 view 实现。


2,提供一个状态容器


这里使用 ViewModel 作为状态容器;状态容器用来存放界面状态变量;ViewModel 是官方推荐的状态容器,而不是必须使用它作为状态容器。


3,定义界面状态


这个需求中我们根据业务描述,定义出多个界面状态。


/**
* 加载失败 UI 状态,显示失败图
* 首屏获取的数据为空、首屏请求数据失败时展示失败图
* 初始值:隐藏
*/

val loadingError: StateFlow<Boolean>
get() = _loadingError
private val _loadingError = MutableStateFlow<Boolean>(false)

/**
* 正在加载 UI 状态,显示加载中图
* 首屏时请求网络时展示加载中图
* 初始值:展示
*/

val isLoading: StateFlow<Boolean>
get() = _isLoading
private val _isLoading = MutableStateFlow<Boolean>(true)

/**
* 加载成功后回来的列表 UI 状态,将 list 数据展示到列表上
*/

val newsList: StateFlow<MutableList<News>>
get() = _newsList
private val _newsList = MutableStateFlow<MutableList<News>>(mutableListOf())

/**
* 加载完成 UI 状态
*/

val loadingFinish: StateFlow<Boolean>
get() = _loadingFinish
private val _loadingFinish = MutableStateFlow<Boolean>(false)

/**
* 界面 toast UI 状态
*/

val toastMessage: StateFlow<String>
get() = _toastMessage
private val _toastMessage = MutableStateFlow<String>("")

4,公开界面状态


这里选择数据流 StateFlow 公开界面状态。当然也可以选择 LiveData 公开界面状态。


5,使用/订阅界面状态


我这里使用的是数据流 StateFlow 公开的界面状态,所以在界面层相对应的使用 flow#collect 订阅界面状态。


6,数据模型驱动界面


结合上面几点,界面层的实现代码为:


界面元素的实现:


class NewsActivity: ComponentActivity() {

private var mBinding: ActivityNewsBinding? = null
private var mAdapter: NewsListAdapter? = null
private val mViewModel = NewsViewModel()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mBinding = ActivityNewsBinding.inflate(layoutInflater)
setContentView(mBinding?.root)
initView()
initObserver()
initData()
}

private fun initView() {
mBinding?.listView?.layoutManager = LinearLayoutManager(this)
mAdapter = NewsListAdapter()
mBinding?.listView?.adapter = mAdapter

mBinding?.refreshView?.setOnRefreshListener {
mViewModel.refreshNewsData()
}
}

private fun initData() {
mViewModel.getNewsData()
}

private fun initObserver() {
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.CREATED) {
launch {
mViewModel.isLoading.collect {
if (it) {
mBinding?.loadingView?.visibility = View.VISIBLE
} else {
mBinding?.loadingView?.visibility = View.GONE
}
}
}
launch {
mViewModel.loadingError.collect {
if (it) {
mBinding?.loadingError?.visibility = View.VISIBLE
} else {
mBinding?.loadingError?.visibility = View.GONE
}
}
}
launch {
mViewModel.loadingFinish.collect {
if (it) {
mBinding?.refreshView?.isRefreshing = false
}
}
}
launch {
mViewModel.toastMessage.collect {
if (it.isNotEmpty()) {
showToast(it)
}
}
}
launch {
mViewModel.newsList.collect {
if (it.isNotEmpty()) {
mBinding?.loadingError?.visibility = View.GONE
mBinding?.loadingView?.visibility = View.GONE
mBinding?.refreshView?.visibility = View.VISIBLE
mAdapter?.setData(it)
}
}
}
}
}
}

}

状态容器的实现:


class NewsViewModel : ViewModel() {

private val repository = NewsRepository()

/**
* 加载失败 UI 状态,显示失败图
* 首屏获取的数据为空、首屏请求数据失败时展示失败图
* 初始值:隐藏
*/

val loadingError: StateFlow<Boolean>
get() = _loadingError
private val _loadingError = MutableStateFlow<Boolean>(false)

/**
* 正在加载 UI 状态,显示加载中图
* 首屏时请求网络时展示加载中图
* 初始值:展示
*/

val isLoading: StateFlow<Boolean>
get() = _isLoading
private val _isLoading = MutableStateFlow<Boolean>(true)

/**
* 加载成功后回来的列表 UI 状态,将 list 数据展示到列表上
*/

val newsList: StateFlow<MutableList<News>>
get() = _newsList
private val _newsList = MutableStateFlow<MutableList<News>>(mutableListOf())

/**
* 加载完成 UI 状态
*/

val loadingFinish: StateFlow<Boolean>
get() = _loadingFinish
private val _loadingFinish = MutableStateFlow<Boolean>(false)

/**
* 界面 toast UI 状态
*/

val toastMessage: StateFlow<String>
get() = _toastMessage
private val _toastMessage = MutableStateFlow<String>("")

fun getNewsData() {
viewModelScope.launch(Dispatchers.IO) {
val list = repository.getNewsList()
if (list.isNullOrEmpty()) {
_loadingError.emit(true)
} else {
_newsList.emit(list)
}
}
}

fun refreshNewsData() {
viewModelScope.launch(Dispatchers.IO) {
val list = repository.getNewsList()
_loadingFinish.emit(true)
if (list.isNullOrEmpty()) {
_toastMessage.emit("暂时没有更新数据")
} else {
_newsList.emit(list)
}
}
}
}

2.2,数据层的实现


这里的数据层只有一个新闻列表数据结构的存储仓库 NewsRepository,另外获取新闻信息属于一次性操作,根据数据层架构设计,直接使用 suspend 就好。


class NewsRepository {

suspend fun getNewsList(): MutableList<News>? {
delay(2000)

val list = mutableListOf<News>()
val news = News("标题", "描述信息")
list.add(news)
list.add(news)
list.add(news)
list.add(news)
return list
}
}

个人的一些理解:


1, 数据层不应该是界面级别的,而应该是应用级别的


数据层不应该是界面级别的,即一个页面对应一个 Repository;数据层应该是应用级别的,即一个应用有一个或多个数据层,每个数据层中有多个存储仓库 Respository,存储仓库可以在不同的界面层复用。


之前我一直认为,一个页面对应一个数据层,一个页面对应一个 Repository。但后来发现这种理解不太对。上面的例子中 NewsViewModel 只用到 NewsRepository,是因为这个新闻列表业务中只用到新闻列表数据这种数据,假如列表中还可以点赞 那我们就需要新建一种点赞存储仓库 LikeRepository,来处理点赞数据,这时 NewsViewModel 与 Repository 的关系是这样:


class NewsViewModel : ViewModel() {

private val newsRepository = NewsRepository()
private val likeRepository = LikeRepository()
}


数据层提供的 新闻列表数据处理能力 NewsRepository 和 点赞数据处理能力 LikeRepository,应该是应用界别的,可以供不同的界面复用。


2,数据层应该是“不变的”


这里的不变不是说数据层的业务逻辑不变,而是指无论是 MVP、MVVM 还是 MVI,他们应该可以共用数据层。


2.3,网域层的实现


网域层是可选的,是否具备网域层,跟架构是否为 MVVM 无关,这个案例中不适用网域层。


三,MVI


1,MVI 架构图


image.png


2,MVI 实现一个具体业务


同样使用上面 MVVM 实现的新闻业务。按照 MVI 架构实现如下:


2.1,界面层的实现


除了和 MVVM 遵循以下几点相同原则之外:


1,选择实现界面的元素


2,提供一个状态容器


3,定义界面状态


4,公开界面状态


5,使用/订阅界面状态


6,单向数据流


MVI 还需要遵循原则:


1,单一数据源


所以 MVI 需要:1,把界面状态聚合起来;2,把界面事件聚合起来。


综合上面的原则,采用 MVI 实现界面的实现如下:


界面元素、聚合界面状态、聚合界面事件 代码:


sealed interface NewsUiState  {

object IsLoading: NewsUiState

object LoadingError: NewsUiState

object LoadingFinish: NewsUiState

data class Success(val newsList: MutableList<News>): NewsUiState

data class ToastMessage(val message: String = ""): NewsUiState

}


sealed interface NewsActivityIntent {
data class InitDataIntent(val type: String = "init") : NewsActivityIntent

data class RefreshDataIntent(val type: String = "refresh") : NewsActivityIntent

data class LoadMoreDataIntent(val type: String = "loadMore") : NewsActivityIntent
}

class NewsActivity: ComponentActivity() {

private var mBinding: ActivityNewsBinding? = null
private var mAdapter: NewsListAdapter? = null
private val mViewModel = NewsViewModel()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mBinding = ActivityNewsBinding.inflate(layoutInflater)
setContentView(mBinding?.root)
initView()
initObserver()
initData()
}

private fun initView() {
mBinding?.listView?.layoutManager = LinearLayoutManager(this)
mAdapter = NewsListAdapter()
mBinding?.listView?.adapter = mAdapter

mBinding?.refreshView?.setOnRefreshListener {
mViewModel.dispatchIntent(NewsActivityIntent.RefreshDataIntent())
}
}

private fun initData() {
mViewModel.dispatchIntent(NewsActivityIntent.InitDataIntent())
}

private fun loadMoreData() {
mViewModel.dispatchIntent(NewsActivityIntent.LoadMoreDataIntent())
}

private fun initObserver() {
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.CREATED) {
launch {
mViewModel.newsUiState.collect {
//更新UI
}
}
}
}
}

}


状态容器代码:


class NewsViewModel : ViewModel() {

private val repository = NewsRepository()

val newsUiState: StateFlow<NewsUiState>
get() = _newsUiState

private val _newsUiState: MutableStateFlow<NewsUiState> =
MutableStateFlow(NewsUiState.IsLoading)

fun dispatchIntent(intent: NewsActivityIntent) {
when (intent) {
is NewsActivityIntent.InitDataIntent -> {
//初始化逻辑
initNewsData()
}
is NewsActivityIntent.RefreshDataIntent -> {
//刷新逻辑
refreshNewsData()
}
is NewsActivityIntent.LoadMoreDataIntent -> {
//加载更多逻辑
loadMoreNewsData()
}
}
}

/**
* 初始化
*/

private fun initNewsData() {
viewModelScope.launch(Dispatchers.IO) {
val list = repository.getNewsList()
if (list.isNullOrEmpty()) {
_newsUiState.emit(NewsUiState.LoadingError)
} else {
_newsUiState.emit(NewsUiState.Success(list))
}
}
}

/**
* 刷新
*/

private fun refreshNewsData() {
viewModelScope.launch(Dispatchers.IO) {
val list = repository.getNewsList()
_newsUiState.emit(NewsUiState.LoadingFinish)
if (list.isNullOrEmpty()) {
_newsUiState.emit(NewsUiState.ToastMessage("暂时没有新数据"))
} else {
_newsUiState.emit(NewsUiState.Success(list))
}
}
}

/**
* 没有实现
*/

private fun loadMoreNewsData() {

}

}

2.2,数据层与网域层的实现


界面层:参考上面 MVVM 的数据层介绍,无论 MVP、MVVM、MVI,不同应用架构的数据层应该是不变的,即通用。


网域层:应用架构是否具备网域层不影响它是什么类型的架构,这里的列表业务没有网域层。


作者:cola_wang
来源:juejin.cn/post/7278659049191686196
收起阅读 »

Java项目要不要部署在Docker里?

部署Java项目有很多种方式,传统的方式是直接在物理机或虚拟机上部署应用,但为什么现在容器化部署变得越来越流行, 个人觉得原因有以下几个: 1、 环境一致性:使用Docker可以确保开发、测试和生产环境的一致性,避免出现“在我机器上能跑”的问题。 2、 快速部...
继续阅读 »

部署Java项目有很多种方式,传统的方式是直接在物理机或虚拟机上部署应用,但为什么现在容器化部署变得越来越流行,
个人觉得原因有以下几个:


1、 环境一致性:使用Docker可以确保开发、测试和生产环境的一致性,避免出现“在我机器上能跑”的问题。

2、 快速部署:Docker镜像一旦构建完成,可以快速部署到任何支持Docker的宿主机上。

3、 易于扩展:结合编排工具如 Kubernetes,可以轻松管理服务的伸缩和负载均衡。

4、 资源隔离:容器化可以提供更好的资源使用隔离和限制,提高系统的稳定性。

5、 更轻便地微服务化:容器很适合微服务架构,每个服务可以单独打包、部署和扩展。


至于是否要在Docker里部署,这取决于项目和团队的具体需求。


如果你的团队追求快速迭代、想要环境一致性,或者计划实现微服务架构,那么使用Docker是一个很好的选择。


但如果项目比较小,或者团队对容器技术不熟,想使用容器化部署应用,可能会增加学习和维护的成本,那就需要权衡利弊了。


 


如果你决定使用Docker来部署Java项目,大概的步骤是这样的:


1、 编写Dockerfile:这是一个文本文件,包含了从基础镜像获取、复制应用文件、设置环境变量到运行应用的所有命令。

2、 构建镜像:使用docker build命令根据Dockerfile构建成一个可运行的镜像。

3、 运行容器:使用docker run命令从镜像启动一个或多个容器实例。

4、 (可选)使用Docker Compose或Kubernetes等工具部署和管理容器。


部署在Docker里的Java项目,通常都会需要一个精心编写的Dockerfile和一些配置管理,确保应用可以无障碍地在容器中运行。




下面简单演示一个如何使用Docker来部署一个简单的Spring Boot Java项目。


 


首先,我们需要安装Docker,你可以从Docker官网下载合适的版本安装,安装完后可以通过运行docker --version来检查是否安装成功。


Docker 安装步骤在在这里就不详细说明了,可以参考这篇文章:CentOS Docker 安装


项目部署步骤:


步骤1:编写Dockerfile


Dockerfile是一个文本文件,它包含了一系列的指令和参数,用于定义如何构建你的Docker镜像。
以下是一个典型的Dockerfile示例,用于部署一个Spring Boot应用:


# 使用官方提供的Java运行环境作为基础镜像,根据自己的需求,选择合适的JDK版本,这里以 1.8 为例
FROM openjdk:8-jdk-alpine

# 配置环境变量
ENV APP_FILE myapp.jar
ENV APP_HOME /usr/app

# 在容器内创建一个目录作为工作目录
WORKDIR $APP_HOME

# 将构建好的jar包复制到容器内的工作目录下
COPY target/*.jar $APP_FILE

# 暴露容器内部的端口给外部使用
EXPOSE 8080

# 启动Java应用
ENTRYPOINT ["java","-jar","${APP_FILE}"]

注释解释:



  • FROM openjdk:8-jdk-alpine:这告诉Docker使用一个轻量级的Java 8 JDK版本作为基础镜像。

  • ENV:设置环境变量,这里设置了应用的jar包名称和存放路径。

  • WORKDIR:设定工作目录,之后的COPY等命令都会在这个目录下执行。

  • COPY:将本地的jar文件复制到镜像中。

  • EXPOSE:将容器的8080端口暴露出去,以便外部可以访问容器内的应用。

  • ENTRYPOINT:容器启动时执行的命令,这里是运行Java应用的命令。


步骤2:构建镜像


在Dockerfile所在的目录运行下面的命令来构建你的镜像:


docker build -t my-java-app .

这里的-t标记用于给新创建的镜像设置一个名称,.是上下文路径,指向Dockerfile所在的当前目录。


步骤3:运行容器


构建好镜像后,你可以使用下面的命令来运行容器:


docker run -d -p 8080:8080 --name my-running-app my-java-app

这里的-d标记意味着在后台运行容器,-p标记用于将容器的8080端口映射到宿主机的8080端口,--name用于给容器设置名字。


到这里,如果一切顺利,你的Spring Boot应用就会在Docker容器中启动,
并且宿主机的8080端口会转发到容器内部的同一端口上,你可以通过访问http://xxxx:8080来查看应用是否在运行。


步骤4:使用Docker Compose或Kubernetes等工具部署和管理容器


接下来我们来讲讲如何使用Docker Compose来管理和部署容器。
Docker Compose是一个用于定义和运行多容器Docker应用的工具。使用Compose,你可以通过一个YAML文件来配置你的应用的服务,然后只需要一个简单的命令即可创建和启动所有的服务。


就拿上面的例子来说,我们来创建一个docker-compose.yml 文件来运行Spring Boot应用。


先确保你已经安装了Docker Compose,然后创建以下内容的docker-compose.yml文件:


version: '3'
services:
my-java-app:
build: .
ports:
- "8080:8080"
environment:
SPRING_PROFILES_ACTIVE: "prod"
volumes:
- "app-logs:/var/log/my-java-app"

volumes:
app-logs:

注释解释:



  • version:指定了我们使用的Compose文件版本。

  • services:定义了我们需要运行的服务。

    • my-java-app:这是我们服务的名称。

    • build: .:告诉Compose在当前目录下查找Dockerfile来构建镜像。

    • ports:将容器端口映射到主机端口。

    • environment:设置环境变量,这里我们假设应用使用Spring Profiles,定义了prod作为激活的配置文件。

    • volumes:定义了数据卷,这里我们将宿主机的一个卷挂载到容器中,用于存储日志等数据。




创建好docker-compose.yml文件后,只需要运行以下命令即可:


docker-compose up -d

这条命令会根据你的docker-compose.yml文件启动所有定义的服务。 -d 参数表明要在后台运行服务。


如果你需要停止并移除所有服务,可以使用:


docker-compose down

使用Docker Compose的好处是,你可以在一个文件中定义整个应用的服务以及它们之间的依赖,然后一键启动或停止所有服务,非常适合本地开发和测试。


至于Kubernetes,它是一个开源的容器编排系统,用于自动部署、扩展和管理容器化应用。


 


Kubernetes的学习曲线相对陡峭,适合用于更复杂的生产环境。如果你想要进一步了解Kubernetes:


推荐几个 Kubernetes 学习的文章



总结


总的来说,容器化是Java项目部署的一种高效、现代化方式,适合于追求快速迭代和微服务架构的团队。
对于不熟悉容器技术的团队或者个人开发者而言,需要考虑学习和维护的成本,合适自己的才是最好的,也不必追求别人用什么你就用什么,得不偿失。


作者:小郑说编程i
来源:juejin.cn/post/7330102782538055689
收起阅读 »