一个迷茫的25岁前端程序员的自述
一直听说程序员的危机在 35 岁,没想到我的危机从 25 岁就开始了。
我甚至不知道自己是不是 25 岁,也可能是 26 岁,或者 27 岁,1998 年的生日,按照 2023 - 1998 的算法就是 25,按照我老家那边的算法就是 26,也有人说是 27 ,无所谓了。
看着自己的掘金,上一次沉下心来好好的写一篇文章还是六个月前,那时候为了跳槽找工作,又写网站,又写文章的,过了那阵后来就不行了。
再往后稀稀拉拉的也在草稿箱里写了一些东西,东一榔头西一棒子,但没有一篇能看的,能发布出来的。
今年掘金社区的年终数据肯定不会好看,当初还想着要一年比一年强,结果一年快过去了了也没发布几篇文章,数据也都一般。
就连本文也是硬着头皮强迫自己写的,看看能不能借此调整调整心态。
也看过一些鸡汤文,像什么写给迷茫的xxx啊,写给年轻人啊,说实话没半点哔用,什么让你调整心态,自我提升,学好这个学好那个,我但凡要有那个劲,也不会迷茫了。
技术停滞不前
刚入行的时候,完成工作后剩下的时间,都会去看看技术视频啊,看看别人的博客啊,再或者看看一些三方库的文档和源码啊。
也可能是因为那会实在太菜,连工作都完成不了,就得被迫去提升基础能力。
再后来当了个小组长,虽然小组只有仨人,但是毕竟责任在,能给别人分配分配任务啦,给别人解决点小 bug 啦,再或者以组长的身份整两句啦,说实话稍微有那么一点点优越感,也就是靠着这一点点优越感,天天嚷嚷着要当一个架构师,这也学学那也学学,还看完了一个架构师教程,确实学到了不少东西,实际也用到了不少。
后来到北京,再次成为了一个底层的外包 coder, 每天除了听话和写业务代码也不用管别的。
后面公司又开始搞低代码,这回连代码也不用写了,就天天去系统上拖着玩,写配置,真心没意思。
说心里话,以我现在的能力吧,应付业务肯定是没有问题的,简单的页面基本不用思考随便写了,稍微复杂一点的动动脑筋,遇到困难就上网搜搜解决方案,问问 chatgpt,最不济实在搞不出来了,找个大佬问两句,最后也都能解决。
总结起来就是,能干活吗?能干。 能力强吗?不强。
技术文章收藏了不少,掘金小册也买了,视频也保存了一大堆,各种各样的。现在的状态就是看不下去,视频看一会就走神了,讲的什么完全记不得,还要后退回去再听一遍,文字更是看不进去。没有耐心去看文档,看不明白就开始烦了,觉得自己真傻逼的同时又不愿意去研究。
看视频的时候记不住,也不愿意记笔记。看文字的时候看不懂,也不愿意去实际操作一下。
现在把工作完成了,就摸鱼,看新闻,到处逛逛,刷短视频,真的不愿意学一点。
下班之后的时间就更不用说了,根本不愿意动一点脑子。
我有时以为是不是我生活的太好了,有吃有喝,没有压力。但转念一想现在如果给我大的压力,以我现在的心态能不能支撑的住。
但平时也经常想,自己就是一个破的被人看不起的外包,跟正式员工区别对待的外包,身边比我强的人比比皆是,那些月薪几万的正式员工,还有组长,组长的组长。前进的路很长,但就是不想走,不知道怎么走。
想学习吧,不知道从何学起,确定了要学习的内容吧,学不下去。
博客也不写了,开源仓库也不维护了,是躺平,亦或是摆烂,每天看着窗外发呆也占据了大部分时间。
生活百无聊赖
工作没有动力,生活亦是如此。
我现在工作的地方是北京顺义,住的地方就算是个村子,村子外面就是野外,没有一点城市的车水马龙和霓虹招展,一到黑天除了路灯照到的地方就是黑漆漆,没有广场,也没有广场舞。
每天下班回家就待在家里玩游戏,看电视剧。不知道会不会有人和我一样,玩游戏和看电视本应该是消遣娱乐的事情,但我感觉到的只有无聊,玩游戏的时候无聊,看电视剧的时候也一样。但你又不能什么都不干,你又得干点什么事情来让时间过度到需要睡觉的时候。
饭也不用自己做,公司的食堂一天三顿饭都有,早上要花钱买,一顿六七块钱左右,中午和晚上免费,不吃白不吃,每天六点下班,七点开饭,我都要在公司多摸一小时的鱼。也不用思考吃什么,在食堂供应的几种餐食里面选一个也不是什么困难的事情。
我这个人没有什么兴趣爱好,熬过上班的时间,就是玩游戏,看电视,看直播。也只是消磨时间,谈不上兴致勃勃。就没有上学时候宁可挨打也要偷摸去网吧通宵玩游戏的那个心境了。
其次,也没有什么社交,老家有几个朋友,平时在群里瞎聊两句,过年的时候一起吃个饭,也没别的了。之前在大连有几个朋友,现在也是极不频繁的闲聊两句。到北京以后呢,没朋友,没亲戚,自己一个人住单间,也没有室友。
也没有什么圈子,游戏基本上都是单排,就连游戏交流群都没加一个,有的时候发挥好了,别的人主动加我好友就一起玩两把。
也不爱运动,健身更是无稽之谈,每天最大的运动量就是从家骑自行车到公司,再从公司骑自行车回家,还是因为我不想早起挤公交。
平时极不愿意与陌生人打交道,路上碰见不太熟的都会装没看见。可以说是内向吧,也可以说社恐,甚至说我孤僻也没觉得有什么不妥。
但我一直都是这样吗,好像也不是,我大学时候是学院的辩论队教练,在数百人的场地里演讲,宣讲,打辩论赛,教学,做评委述票都迎刃有余,各个学院都有熟人,一块玩桌游,狼人杀,有很多朋友也爱交朋友。
似乎就是从毕业以后,又或许是分手以后,又或许是来到北京以后,短短的数月时间,感觉自己老了很多岁一样。
你问我是放不下她吗,好像也不是,平时也不怎么想,甚至连长什么样都记不清了。
那是一种什么感觉,大概就是放下她了,但没放下失去她吧。
假期回家,很多人说给我介绍对象,又期待又觉得无所谓,或许他们只是寒暄吧,也或许真的会介绍。不想,不愿意,又或者不耐烦去重新认识一个人。
假期在家的时候,以前不屑一顾的小夜市变成了我的一处乌托邦,太长时间没见过的市井气息,小吃摊前围绕的吃客,卖玩具的老板在尽力展示,还有踩高跷的表演,扭秧歌的队伍。还有一伙一伙的广场舞,我不跳,但我爱看,爱看大爷大妈们乐在其中的表情。
哪一瞬间开始觉得自己是个废物
我在朋友圈问过这个问题,得到的答案基本都是 “每一瞬间”,或许他们只是在说笑,也或许有的人真的这么想。但对我来说不是这样的。
我以前一直都自我感觉挺良好的。或者说自我感觉不错。
大学以前的成绩都是中等偏上水平,成绩还行,也拿奖状什么的,经常被夸奖。
大学也是一个普通本科,比上不足比下有余吧,虽然大学挂了很多科,但恰好赶上疫情,学校降低了毕业要求,顺利毕业了。
毕业以后找不到工作,交了 200 的定金准备去培训公司培训了,然后面试的最后一家告诉我通过了,我就没去培训。
边工作边学习,后来跳槽还当了个小组长,工资翻了接近一倍。
第一个对象在大一认识的,谈了一年。后来这个是疫情期间认识的,谈了三年。
家里条件一般,但是能吃饱饭,父母自给自足也不用我支援。
当时的我工作不愁,感情不愁,身体健康,生活没有压力。说自我感觉良好没有什么问题吧。
从小被灌输的概念都是近朱者赤近墨者黑,身边的人优秀才会激励你进步,但对我来说,身边优秀的人越多越容易摆烂。
以前在小公司当组长的时候更愿意去提升,现在当底层反而想躺平。
当你从最优秀的那些人中的一员成为最差的人中的一个,落差是很大的,体验感是十分差劲的。虽然工资翻了一倍,但生活变差了很多倍。
20 岁的时候单身,觉得无所谓,成天就是玩,30 岁的时候单身就会焦虑,担心自己能不能找到对象,或者说随着年龄越来越大,越担心能不能找到一个好女孩。
无论是分手,还是当外包,其实都还好,虽然说心情不太美妙,但一直也没对自己产生这么大的恶意。
直到国庆放假回家,我叔叔家有个小弟,比我小几岁。他的两个同学来接他同学聚会。俩孩子开着车,拿着伴手礼,奶,酒,还有水果。跟我叔叔我爸他们一起喝酒,言谈举止,人情世故活脱脱是个小大人,几杯白酒下肚啥事没有。来到陌生的村子跟几个陌生的人打麻将一点不紧张,输赢先不说,就这份勇气我就没有。明明我比他们要大几岁,可在他们面前我反而像个小孩子。
就在那一瞬间,我才意识到我是多么的没用,老话讲人比人得死,货比货得扔不是没有道理的,不需要别人来对比,自己都会自愧不如。
驾-照考了好几年了,却连车也不会开,要说买一个二手的便宜的车开,确实能买得起,但是不敢。
三十来岁的人了,别人都开着车到处跑,我还停留在父母去哪,我就跟着去哪的阶段。
我还给自己找借口,人家是租车公司的经理,每天跟各种人打交道,我就是个坐办公室的,没有什么历练。
怪性格也好,赖工作性质也罢。说白了就是没见过什么世面,没有什么阅历,三十来岁的年龄,十几岁孩子的见识。
说一句废物一点也不为过吧。
羡慕这两个字我已经说够了
大学玩的最好的朋友,现在在大连,经常跟我秀,自己有车,有房,有媳妇,媳妇有店,在要孩子。
再看看我自己,没车,没房,没媳妇,在村里租房子,在排位上分。
我虽然表面不屑一顾,说一句厉害坏了,但心里只有羡慕。
正式员工隔三差五发福利,半袖,外套,纪念品,外包什么都没有,只有羡慕。
国庆放假,没抢到高铁票,在公司拼了个车回去。
车是什么牌子,不知道,只知道看起来很好,应该不便宜。同事开车,闲聊中得知他比我小一岁,喊我哥,带着自己对象回家过节。
我坐在后排,闭着眼。坐着比我小一岁,领着对象回家过节,开着看起来不便宜的同事的车,心里只有羡慕。
回家吃婚宴,看着布置好的新房和现场的典礼,没有心思截门要红包,心里只有羡慕。
我从来不羡慕那些有钱人,富二代,也不羡慕那些天才,超能力,我觉得他们离我太远,跟我没有关系。但我真的羡慕明明跟我差不多,但是比我懂事的孩子。
羡慕这两个字,我已经说够了。
写在最后
现在的心态就是活着没意思,又不敢死。
说到底,我现在的生活已经比一部分人要好了,我也知道有很多人羡慕我这样的生活,也有很多不如我的人心态比我要阳光,要积极。
或许我是在无病呻吟,或许我是生在福中不知福,又或许我是闲的没事,但我只是把我内心的真实想法表达出来而已,我无意伤害任何人,包括我自己。
本来想在最后贴上自己的开源项目地址,要一些 star 的,想想还是算了。
就希望自己能快些振作起来,让生活走上正轨吧。
至于这要花费多长时间,我也不得而知,希望很快吧。
来源:juejin.cn/post/7288300174913159222
一种适合H5屏幕适配方案
一、动态rem适配方案:适合H5项目的适配方案
1. @media媒体查询适配
首先,我们需要设置一个根元素的基准值,这个基准值通常根据视口宽度进行计算。可以在项目的 CSS 文件中,通过媒体查询动态调整根元素的 font-size
。
html {
font-size: 16px; /* 默认基准值 */
}
...
@media (min-width: 1024px) {
html {
font-size: 14px; /* 适配较大屏幕 */
}
}
@media (min-width: 1440px) {
html {
font-size: 16px; /* 适配超大屏幕 */
}
}
2. PostCSS 插件(自动转换)实现 px2rem
手动转换 px
为 rem
可能很繁琐,因此可以使用 PostCSS
插件 postcss-pxtorem
来自动完成这一转换。
2.1 安装 postcss-pxtorem
首先,在项目中安装 postcss-pxtorem 插件:
npm install postcss-pxtorem --save-dev
2.2 配置 PostCSS
然后,在项目根目录创建或编辑 postcss.config.js 文件,添加 postcss-pxtorem 插件配置:
/* postcss.config.cjs */
module.exports = {
plugins: {
'postcss-pxtorem': {
rootValue: 16, // 基准值,对应于根元素的 font-size
unitPrecision: 5, // 保留小数点位数
propList: ['*', '!min-width', '!max-width'], // 排除 min-width 和 max-width 属性
selectorBlackList: [], // 忽略的选择器
replace: true, // 替换而不是添加备用属性
mediaQuery: false, // 允许在媒体查询中转换 px
minPixelValue: 0 // 最小的转换数值
}
}
};
/* vite */
export default defineConfig({
css: {
postcss: './postcss.config.cjs',
}
})
3. 在 CSS/SCSS 中使用 px
在编写样式时,依然可以使用 px
进行布局:
.container {
width: 320px;
padding: 16px;
}
.header {
height: 64px;
margin-bottom: 24px;
}
4. 构建项目
通过构建工具(如 webpack/vite
)运行项目时,PostCSS
插件会自动将 px
转换为 rem
。
5. 可以不用@media媒体查询,动态动态调整font-size
为了实现更动态的适配,可以通过 JavaScript
动态设置根元素的 font-size
:
/**utils/setRootFontSize**/
function setRootFontSize(): void {
const docEl = document.documentElement;
const clientWidth = docEl.clientWidth;
if (!clientWidth) return;
const baseFontSize = 16; // 基准字体大小
const designWidth = 1920; // 设计稿宽度
docEl.style.fontSize = (baseFontSize * (clientWidth / designWidth)) + 'px';
}
export default setRootFontSize;
/**utils/setRootFontSize**/
/**APP**/
import setRootFontSize from '../utils/setRootFontSize';
import { useEffect } from 'react';
export default function App() {
useEffect(() => {
// 设置根元素的字体大小
setRootFontSize();
// 窗口大小改变时重新设置
window.addEventListener('resize', setRootFontSize);
// 清除事件监听器
return () => {
window.removeEventListener('resize', setRootFontSize);
};
}, []);
return (
<>
<div>
<MyRoutes />
</div>
</>
)
}
/**APP**/
这样,无论视口宽度如何变化,页面元素都会根据基准值动态调整大小,确保良好的适配效果。
通过上述步骤,可以实现布局使用 px
,并动态转换为 rem
的适配方案。这个方案不仅使得样式编写更加简洁,还提高了适配的灵活性。
注:如果你使用了 setRootFontSize 动态调整根元素的 font-size
,就不再需要使用 @media 查询来调整根元素的字体大小了。这是因为 setRootFontSize
函数已经根据视口宽度动态调整了 font-size,从而实现了自适应。
- 动态调整根元素
font-size
的优势:
- 更加灵活:可以实现更加平滑的响应式调整,而不是依赖固定的断点。
- 统一管理:所有的样式都依赖根元素的 font-size,维护起来更加简单。
@media
媒体查询的优势:
- 尽管不再需要用
@media
查询来调整根元素的font-size
,但你可能仍然需要使用@media
查询来处理其他的响应式设计需求,比如调整布局、隐藏或显示元素等。
- 尽管不再需要用
这种方式简化了响应式设计,使得样式统一管理更加简单,同时保留了灵活性和适应性。
6. 效果对比(非H5界面)
图一为界面px
适配,效果为图片,文字等大小固定不变。
图二为动态rem
适配:整体随界面扩大而扩大,能够保持相对比例。
7. Tips
- 动态
rem
此方案比较适合H5屏幕适配 - 注意:
PostCSS
转换rem
应排除min-width
、max-width
、min-height
和max-height
,以免影响整体界面
二、其他适配
1. 弹性盒模型(Flexbox)
Flexbox
是一种布局模型,能够轻松地实现响应式布局。它允许元素根据容器的大小自动调整位置和大小。
.container {
display: flex;
flex-wrap: wrap;
}
.item {
flex: 1 1 100%; /* 默认情况下每个元素占满一行 */
}
@media (min-width: 600px) {
.item {
flex: 1 1 50%; /* 在较宽的屏幕上,每个元素占半行 */
}
}
@media (min-width: 1024px) {
.item {
flex: 1 1 33.33%; /* 在更宽的屏幕上,每个元素占三分之一行 */
}
}
2. 栅格系统(Grid System)
栅格系统是一种常见的响应式布局方案,广泛应用于各种框架(如 Bootstrap
)。通过定义行和列,可以轻松地创建复杂的布局。
.container {
display: grid;
grid-template-columns: 1fr; /* 默认情况下每行一个列 */
gap: 10px;
}
@media (min-width: 600px) {
.container {
grid-template-columns: 1fr 1fr; /* 在较宽的屏幕上,每行两个列 */
}
}
@media (min-width: 1024px) {
.container {
grid-template-columns: 1fr 1fr 1fr; /* 在更宽的屏幕上,每行三个列 */
}
}
3. 百分比和视口单位
使用百分比(%
)、视口宽度(vw
)、视口高度(vh
)等单位,可以根据视口尺寸调整元素大小。
/* 示例:百分比和视口单位 */
.container {
width: 100%;
height: 50vh; /* 高度为视口高度的一半 */
}
.element {
width: 50%; /* 宽度为容器的一半 */
height: 10vw; /* 高度为视口宽度的 10% */
}
4. 响应式图片
根据设备分辨率和尺寸加载不同版本的图片,以提高性能和视觉效果。可以使用 srcset 和 sizes 属性。
<!-- 示例:响应式图片 -->
<img
src="small.jpg"
srcset="medium.jpg 600w, large.jpg 1024w"
sizes="(max-width: 600px) 100vw, (max-width: 1024px) 50vw, 33.33vw"
alt="Responsive Image">
5. CSS Custom Properties(CSS变量)
使用 CSS 变量可以更灵活地定义和调整样式,同时通过 JavaScript
动态改变变量值实现响应式设计。
:root {
--main-padding: 20px;
}
.container {
padding: var(--main-padding);
}
@media (min-width: 600px) {
:root {
--main-padding: 40px;
}
}
来源:juejin.cn/post/7384265691162886178
利用高德地图API实现实时天气
前言
闲来无事,利用摸鱼时间实现实时天气的小功能
目录
效果图
这里样式我就不做处理了,地图可以不用做展示,只需要拿到获取到天气的结果,结合自己的样式展示就可以了,未来天气可以结合echarts进行展示,页面效果更佳
实现
- 登录高德开放平台控制台
- 创建 key
这里应用名称可以随便取(个人建议功能名称或者项目称)
3.获取 key 和密钥
4.获取当前城市定位
首先,先安装依赖
npm install @amap/amap-jsapi-loader --save
或者
pnpm add @amap/amap-jsapi-loader --save
页面使用时引入即可
import AMapLoader from "@amap/amap-jsapi-loader"
/**在index.html引入密钥,不添加会导致某些API调用不成功*/
<script type="text/javascript">window._AMapSecurityConfig =
{securityJsCode: "安全密钥"}</script>
/** 1. 调用AMapLoader.load方法,通过传入一个对象作为参数来指定加载地图时的配置信息。
* - key: 申请好的Web端开发者Key,是必填项,用于授权您的应用程序使用高德地图API。
* - version: 指定要加载的JSAPI版本,不指定时默认为1.4.15。
* - plugins: 需要使用的插件列表,如比例尺、缩放控件等。
*/
function initMap() {
AMapLoader.load({
key: "申请好的Web端开发者Key", // 申请好的Web端开发者Key,首次调用 load 时必填
version: "2.0", // 指定要加载的 JSAPI 的版本,缺省时默认为 1.4.15
plugins: [
"AMap.ToolBar",
"AMap.Scale",
"AMap.HawkEye",
"AMap.MapType",
"AMap.Geolocation",
"AMap.Geocoder",
"AMap.Weather",
"AMap.CitySearch",
"AMap.InfoWindow",
"AMap.Marker",
"AMap.Pixel",
], // 需要使用的的插件列表,如比例尺'AMap.Scale'等
})
.then((AMap) => {
map.value = new AMap.Map("container", {
//设置地图容器id
resizeEnable: true,
viewMode: "3D", //是否为3D地图模式
zoom: 10, //初始化地图级别
center: locationArr.value, //初始化地图中心点位置
});
getGeolocation(AMap);
getCitySearch(AMap, map.value);
})
.catch((e) => {
console.log(e);
});
}
// 浏览器定位
const getGeolocation = (AMap: any) => {
const geolocation = new AMap.Geolocation({
enableHighAccuracy: true, //是否使用高精度定位,默认:true
timeout: 1000, //超过10秒后停止定位,默认:5s
position: "RB", //定位按钮的停靠位置
offset: [10, 20], //定位按钮与设置的停靠位置的偏移量,默认:[10, 20]
zoomToAccuracy: true, //定位成功后是否自动调整地图视野到定位点
});
map.value.addControl(geolocation);
geolocation.getCurrentPosition(function (status: string, result: any) {
if (status == "complete") {
onComplete(result);
} else {
onError(result);
}
});
};
// IP定位获取当前城市信息
const getCitySearch = (AMap: any, map: any) => {
const citySearch = new AMap.CitySearch();
citySearch.getLocalCity(function (
status: string,
result: {
city: string;
info: string;
}
) {
if (status === "complete" && result.info === "OK") {
console.log(
"🚀 ~ file: map-container.vue:88 ~ getCitySearch ~ result:",
result
);
// 查询成功,result即为当前所在城市信息
getWeather(AMap, map, result.city);
}
});
};
onMounted(() => {
initMap();
});
5.通过定位获取城市名称、区域编码,查询目标城市/区域的实时天气状况
const getWeather = (AMap: any, map: any, city: string) => {
const weather = new AMap.Weather();
weather.getLive(
city,
function (
err: any,
data: {
city: string;
weather: string;
temperature: string;
windDirection: string;
windPower: string;
humidity: string;
reportTime: string;
}
) {
if (!err) {
const str = [];
str.push("<h4 >实时天气" + "</h4><hr>");
str.push("<p>城市/区:" + data.city + "</p>");
str.push("<p>天气:" + data.weather + "</p>");
str.push("<p>温度:" + data.temperature + "℃</p>");
str.push("<p>风向:" + data.windDirection + "</p>");
str.push("<p>风力:" + data.windPower + " 级</p>");
str.push("<p>空气湿度:" + data.humidity + "</p>");
str.push("<p>发布时间:" + data.reportTime + "</p>");
const marker = new AMap.Marker({
map: map,
position: map.getCenter(),
});
const infoWin = new AMap.InfoWindow({
content:
'<div class="info" style="position:inherit;margin-bottom:0;background:#ffffff90;padding:10px">' +
str.join("") +
'</div><div class="sharp"></div>',
isCustom: true,
offset: new AMap.Pixel(0, -37),
});
infoWin.open(map, marker.getPosition());
marker.on("mouseover", function () {
infoWin.open(map, marker.getPosition());
});
}
}
);
}
完整代码
<template>
<div id="container"></div>
</template>
<script setup lang="ts">
import AMapLoader from "@amap/amap-jsapi-loader";
import { ref, onMounted, watch, reactive } from "vue";
const props = defineProps({
search: {
type: String,
default: "杭州市",
},
});
const isFalse = ref(false);
const map = ref<any>(null);
let locationArr = ref<any>();
watch(
() => props.search,
(newValue) => {
console.log("search", newValue);
initMap();
}
);
function initMap() {
AMapLoader.load({
key: "申请好的Web端开发者Key", // 申请好的Web端开发者Key,首次调用 load 时必填
version: "2.0", // 指定要加载的 JSAPI 的版本,缺省时默认为 1.4.15
plugins: [
"AMap.ToolBar",
"AMap.Scale",
"AMap.HawkEye",
"AMap.MapType",
"AMap.Geolocation",
"AMap.Geocoder",
"AMap.Weather",
"AMap.CitySearch",
"AMap.InfoWindow",
"AMap.Marker",
"AMap.Pixel",
], // 需要使用的的插件列表,如比例尺'AMap.Scale'等
})
.then((AMap) => {
map.value = new AMap.Map("container", {
//设置地图容器id
resizeEnable: true,
viewMode: "3D", //是否为3D地图模式
zoom: 10, //初始化地图级别
center: locationArr.value, //初始化地图中心点位置
});
getGeolocation(AMap);
getCitySearch(AMap, map.value);
})
.catch((e) => {
console.log(e);
});
}
// 浏览器定位
const getGeolocation = (AMap: any) => {
const geolocation = new AMap.Geolocation({
enableHighAccuracy: true, //是否使用高精度定位,默认:true
timeout: 1000, //超过10秒后停止定位,默认:5s
position: "RB", //定位按钮的停靠位置
offset: [10, 20], //定位按钮与设置的停靠位置的偏移量,默认:[10, 20]
zoomToAccuracy: true, //定位成功后是否自动调整地图视野到定位点
});
map.value.addControl(geolocation);
geolocation.getCurrentPosition(function (status: string, result: any) {
if (status == "complete") {
onComplete(result);
} else {
onError(result);
}
});
};
// IP定位获取当前城市信息
const getCitySearch = (AMap: any, map: any) => {
const citySearch = new AMap.CitySearch();
citySearch.getLocalCity(function (
status: string,
result: {
city: string;
info: string;
}
) {
if (status === "complete" && result.info === "OK") {
console.log(
"🚀 ~ file: map-container.vue:88 ~ getCitySearch ~ result:",
result
);
// 查询成功,result即为当前所在城市信息
getWeather(AMap, map, result.city);
}
});
};
// 天气
const getWeather = (AMap: any, map: any, city: string) => {
const weather = new AMap.Weather();
weather.getLive(
city,
function (
err: any,
data: {
city: string;
weather: string;
temperature: string;
windDirection: string;
windPower: string;
humidity: string;
reportTime: string;
}
) {
console.log("🚀 ~ file: map-container.vue:96 ~ .then ~ data:", data);
if (!err) {
const str = [];
str.push("<h4 >实时天气" + "</h4><hr>");
str.push("<p>城市/区:" + data.city + "</p>");
str.push("<p>天气:" + data.weather + "</p>");
str.push("<p>温度:" + data.temperature + "℃</p>");
str.push("<p>风向:" + data.windDirection + "</p>");
str.push("<p>风力:" + data.windPower + " 级</p>");
str.push("<p>空气湿度:" + data.humidity + "</p>");
str.push("<p>发布时间:" + data.reportTime + "</p>");
const marker = new AMap.Marker({
map: map,
position: map.getCenter(),
});
const infoWin = new AMap.InfoWindow({
content:
'<div class="info" style="position:inherit;margin-bottom:0;background:#ffffff90;padding:10px">' +
str.join("") +
'</div><div class="sharp"></div>',
isCustom: true,
offset: new AMap.Pixel(0, -37),
});
infoWin.open(map, marker.getPosition());
marker.on("mouseover", function () {
infoWin.open(map, marker.getPosition());
});
}
}
);
// 未来4天天气预报
weather.getForecast(
city,
function (err: any, data: { forecasts: string | any[] }) {
console.log(
"🚀 ~ file: map-container.vue:186 ~ getWeather ~ data:",
data
);
if (err) {
return;
}
var strs = [];
for (var i = 0, dayWeather; i < data.forecasts.length; i++) {
dayWeather = data.forecasts[i];
strs.push(
`<p>${dayWeather.date}  ${dayWeather.dayWeather}  ${dayWeather.nightTemp}~${dayWeather.dayTemp}℃</p><br />`
);
}
}
);
};
function onComplete(data: any) {
console.log("🚀 ~ file: map-container.vue:107 ~ onComplete ~ data:", data);
const lngLat = [data.position.lng, data.position.lat];
locationArr.value = lngLat;
}
function onError(data: any) {
console.log("🚀 ~ file: map-container.vue:113 ~ onError ~ data:", data);
// 定位出错
}
onMounted(() => {
initMap();
});
</script>
<style scoped lang="less">
#container {
padding: 0px;
margin: 0px;
width: 100%;
height: 100%;
}
</style>
来源:juejin.cn/post/7316746866040619035
MySQL 高级(进阶)SQL 语句
MySQL 高级(进阶)SQL 语句
1. MySQL SQL 语句
1.1 常用查询
常用查询简单来说就是 增、删、改、查
对 MySQL 数据库的查询,除了基本的查询外,有时候需要对查询的结果集进行处理。 例如只取 10 条数据、对查询结果进行排序或分组等等
1、按关键字排序
PS:类比于windows 任务管理器
使用 SELECT 语句可以将需要的数据从 MySQL 数据库中查询出来,如果对查询的结果进行排序,可以使用 ORDER BY 语句来对语句实现排序,并最终将排序后的结果返回给用户。这个语句的排序不光可以针对某一个字段,也可以针对多个字段
(1)语法
SELECT column1, column2, … FROM table_name ORDER BY column1, column2, …
ASC
|DESC
ASC 是按照升序进行排序的,是默认的排序方式,即 ASC 可以省略。SELECT 语句中如果没有指定具体的排序方式,则默认按 ASC方式进行排序。
DESC 是按降序方式进 行排列。当然 ORDER BY 前面也可以使用 WHERE 子句对查询结果进一步过滤。
准备工作:
create database k1;
use k1;
create table location (Region char(20),Store_Name char(20));
insert int0 location values('East','Boston');
insert int0 location values('East','New York');
insert int0 location values('West','Los Angeles');
insert int0 location values('West','Houston');
create table store_info (Store_Name char(20),Sales int(10),Date char(10));
insert int0 store_info values('Los Angeles','1500','2020-12-05');
insert int0 store_info values('Houston','250','2020-12-07');
insert int0 store_info values('Los Angeles','300','2020-12-08');
insert int0 store_info values('Boston','700','2020-12-08');
1.2 SELECT
显示表格中一个或数个字段的所有数据记录 语法:SELECT "字段" FROM "表名";
SELECT Store_Name FROM location;
SELECT Store_Name FROM Store_Info;
1.3 DISTINCT
不显示重复的数据记录
语法:SELECT DISTINCT "字段" FROM "表名";
SELECT DISTINCT Store_Name FROM Store_Info;
1.4 AND OR
且 或
语法:SELECT "字段" FROM "表名" WHERE "条件1" {[AND|OR] "条件2"}+ ;
1.5 in
显示已知的值的数据记录
语法:SELECT "字段" FROM "表名" WHERE "字段" IN ('值1', '值2', ...);
SELECT * FROM store_info WHERE Store_Name IN ('Los Angeles', 'Houston');
1.6 BETWEEN
显示两个值范围内的数据记录
语法:SELECT "字段" FROM "表名" WHERE "字段" BETWEEN '值1' AND '值2';
2. 通配符 —— 通常与 LIKE 搭配 一起使用
% :百分号表示零个、一个或多个字符
_ :下划线表示单个字符
'A_Z':所有以 'A' 起头,另一个任何值的字符,且以 'Z' 为结尾的字符串。例如,'ABZ' 和 'A2Z' 都符合这一个模式,而 'AKKZ' 并不符合 (因为在 A 和 Z 之间有两个字符,而不是一个字符)。
'ABC%': 所有以 'ABC' 起头的字符串。例如,'ABCD' 和 'ABCABC' 都符合这个模式。 '%XYZ': 所有以 'XYZ' 结尾的字符串。例如,'WXYZ' 和 'ZZXYZ' 都符合这个模式。
'%AN%': 所有含有 'AN'这个模式的字符串。例如,'LOS ANGELES' 和 'SAN FRANCISCO' 都符合这个模式。
'_AN%':所有第二个字母为 'A' 和第三个字母为 'N' 的字符串。例如,'SAN FRANCISCO' 符合这个模式,而 'LOS ANGELES' 则不符合这个模式。
2.1 LIKE
匹配一个模式来找出我们要的数据记录
语法:SELECT "字段" FROM "表名" WHERE "字段" LIKE {模式};
SELECT * FROM store_info WHERE Store_Name like '%os%';
2.2 ORDER BY
按关键字排序
语法:SELECT "字段" FROM "表名" [WHERE "条件"] ORDER BY "字段" [ASC, DESC];
注意:ASC
是按照升序进行排序的,是默认的排序方式。 DESC
是按降序方式进行排序
SELECT Store_Name,Sales,Date FROM store_info ORDER BY Sales DESC;
3. 函数
3.1数学函数
abs(x) | 返回 x 的绝对值 |
---|---|
rand() | 返回 0 到 1 的随机数 |
mod(x,y) | 返回 x 除以 y 以后的余数 |
power(x,y) | 返回 x 的 y 次方 |
round(x) | 返回离 x 最近的整数 |
round(x,y) | 保留 x 的 y 位小数四舍五入后的值 |
sqrt(x) | 返回 x 的平方根 |
truncate(x,y) | 返回数字 x 截断为 y 位小数的值 |
ceil(x) | 返回大于或等于 x 的最小整数 |
floor(x) | 返回小于或等于 x 的最大整数 |
greatest(x1,x2...) | 返回集合中最大的值,也可以返回多个字段的最大的值 |
least(x1,x2...) | 返回集合中最小的值,也可以返回多个字段的最小的值 |
SELECT abs(-1), rand(), mod(5,3), power(2,3), round(1.89);
SELECT round(1.8937,3), truncate(1.235,2), ceil(5.2), floor(2.1), least(1.89,3,6.1,2.1);
3.2 聚合函数
avg() | 返回指定列的平均值 |
---|---|
count() | 返回指定列中非 NULL 值的个数 |
min() | 返回指定列的最小值 |
max() | 返回指定列的最大值 |
sum(x) | 返回指定列的所有值之和 |
SELECT avg(Sales) FROM store_info;
SELECT count(Store_Name) FROM store_info;
SELECT count(DISTINCT Store_Name) FROM store_info;
SELECT max(Sales) FROM store_info;
SELECT min(Sales) FROM store_info;
SELECT sum(Sales) FROM store_info;
3.3 字符串函数
trim() | 返回去除指定格式的值 |
---|---|
concat(x,y) | 将提供的参数 x 和 y 拼接成一个字符串 |
substr(x,y) | 获取从字符串 x 中的第 y 个位置开始的字符串,跟substring()函数作用相同 |
substr(x,y,z) | 获取从字符串 x 中的第 y 个位置开始长度为 z 的字符串 |
length(x) | 返回字符串 x 的长度 |
replace(x,y,z) | 将字符串 z 替代字符串 x 中的字符串 y |
upper(x) | 将字符串 x 的所有字母变成大写字母 |
lower(x) | 将字符串 x 的所有字母变成小写字母 |
left(x,y) | 返回字符串 x 的前 y 个字符 |
right(x,y) | 返回字符串 x 的后 y 个字符 |
repeat(x,y) | 将字符串 x 重复 y 次 |
space(x) | 返回 x 个空格 |
strcmp(x,y) | 比较 x 和 y,返回的值可以为-1,0,1 |
reverse(x) | 将字符串 x 反转 |
如 sql_mode 开启了 PIPES_AS_CONCAT,"||" 视为字符串的连接操作符而非或运算符,和字符串的拼接函数Concat相类似,这和Oracle数据库使用方法一样的
SELECT Region || ' ' || Store_Name FROM location WHERE Store_Name = 'Boston';
SELECT substr(Store_Name,3) FROM location WHERE Store_Name = 'Los Angeles';
SELECT substr(Store_Name,2,4) FROM location WHERE Store_Name = 'New York'
SELECT TRIM ([ [位置] [要移除的字符串] FROM ] 字符串);
**[位置]:的值可以为 LEADING (起头), TRAILING (结尾), BOTH (起头及结尾)。 **
[要移除的字符串]:从字串的起头、结尾,或起头及结尾移除的字符串。缺省时为空格。
SELECT TRIM(LEADING 'Ne' FROM 'New York');
SELECT Region,length(Store_Name) FROM location;
SELECT REPLACE(Region,'ast','astern')FROM location;
4. GR0UP BY
对GR0UP BY后面的字段的查询结果进行汇总分组,通常是结合聚合函数一起使用的
GR0UP BY 有一个原则
- 凡是在 GR0UP BY 后面出现的字段,必须在 SELECT 后面出现;
- 凡是在 SELECT 后面出现的、且未在聚合函数中出现的字段,必须出现在 GR0UP BY 后面
语法:SELECT "字段1", SUM("字段2") FROM "表名" GR0UP BY "字段1";
SELECT Store_Name, SUM(Sales) FROM store_info GR0UP BY Store_Name ORDER BY sales desc;
5. 别名
字段別名 表格別名
语法:SELECT "表格別名"."字段1" [AS] "字段別名" FROM "表格名" [AS] "表格別名";
SELECT A.Store_Name Store, SUM(A.Sales) "Total Sales" FROM store_info A GR0UP BY A.Store_Name;
6. 子查询
子查询也被称作内查询或者嵌套查询,是指在一个查询语句里面还嵌套着另一个查询语 句。子查询语句是先于主查询语句被执行的,其结果作为外层的条件返回给主查询进行下一 步的查询过滤
连接表格,在WHERE 子句或 HAVING 子句中插入另一个 SQL 语句
语法:SELECT "字段1" FROM "表格1" WHERE "字段2" [比较运算符] #外查询 (SELECT "字段1" FROM "表格2" WHERE "条件"); #内查询
[比较运算符]
可以是符号的运算符,例如 =、>、<、>=、<= ;也可以是文字的运算符,例如 LIKE、IN、BETWEEN
SELECT SUM(Sales) FROM store_info WHERE Store_Name IN
(SELECT Store_Name FROM location WHERE Region = 'West');
SELECT SUM(A.Sales) FROM store_info A WHERE A.Store_Name IN
(SELECT Store_Name FROM location B WHERE B.Store_Name = A.Store_Name);
7. EXISTS
用来测试内查询有没有产生任何结果,类似布尔值是否为真 #如果有的话,系统就会执行外查询中的SQL语句。若是没有的话,那整个 SQL 语句就不会产生任何结果。
语法:SELECT "字段1" FROM "表格1" WHERE EXISTS (SELECT \* FROM "表格2" WHERE "条件");
SELECT SUM(Sales) FROM store_info WHERE EXISTS (SELECT * FROM location WHERE Region = 'West');
8. 连接查询
准备工作
create database k1;
use k1;
create table location (Region char(20),Store_Name char(20));
insert int0 location values('East','Boston');
insert int0 location values('East','New York');
insert int0 location values('West','Los Angeles');
insert int0 location values('West','Houston');
create table store_info (Store_Name char(20),Sales int(10),Date char(10));
insert int0 store_info values('Los Angeles','1500','2020-12-05');
insert int0 store_info values('Houston','250','2020-12-07');
insert int0 store_info values('Los Angeles','300','2020-12-08');
insert int0 store_info values('Boston','700','2020-12-08');
UPDATE store_info SET store_name='Washington' WHERE sales=300;
inner join(内连接):只返回两个表中联结字段相等的行
left join(左连接):返回包括左表中的所有记录和右表中联结字段相等的记录
right join(右连接):返回包括右表中的所有记录和左表中联结字段相等的记录
8.1 内连接
MySQL 中的内连接就是两张或多张表中同时符合某种条件的数据记录的组合。通常在 FROM 子句中使用关键字 INNER JOIN 来连接多张表,并使用 ON 子句设置连接条件,内连接是系统默认的表连接,所以在 FROM 子句后可以省略 INNER 关键字,只使用 关键字 JOIN。同时有多个表时,也可以连续使用 INNER JOIN 来实现多表的内连接,不过为了更好的性能,建议最好不要超过三个表
(1) 语法 求交集
SELECT column_name(s)FROM table1 INNER JOIN table2 ON table1.column_name = table2.column_name;
SELECT * FROM location A INNER JOIN store_info B on A.Store_Name = B.Store_Name ;
内连查询:通过inner join 的方式将两张表指定的相同字段的记录行输出出来
8.2 左连接
左连接也可以被称为左外连接,在 FROM 子句中使用 LEFT JOIN 或者 LEFT OUTER JOIN 关键字来表示。左连接以左侧表为基础表,接收左表的所有行,并用这些行与右侧参 考表中的记录进行匹配,也就是说匹配左表中的所有行以及右表中符合条件的行。
SELECT * FROM location A LEFT JOIN store_info B on A.Store_Name = B.Store_Name ;
左连接中左表的记录将会全部表示出来,而右表只会显示符合搜索条件的记录,右表记录不足的地方均为 NULL
8.3 右连接
右连接也被称为右外连接,在 FROM 子句中使用 RIGHT JOIN 或者 RIGHT OUTER JOIN 关键字来表示。右连接跟左连接正好相反,它是以右表为基础表,用于接收右表中的所有行,并用这些记录与左表中的行进行匹配
SELECT * FROM location A RIGHT JOIN store_info B on A.Store_Name = B.Store_Name ;
9. UNION ----联集
将两个SQL语句的结果合并起来,两个SQL语句所产生的字段需要是同样的数据记录种类
UNION :生成结果的数据记录值将没有重复,且按照字段的顺序进行排序
语法:[SELECT 语句 1] UNION [SELECT 语句 2];
SELECT Store_Name FROM location UNION SELECT Store_Name FROM store_info;
UNION ALL :将生成结果的数据记录值都列出来,无论有无重复
语法:[SELECT 语句 1] UNION ALL [SELECT 语句 2];
SELECT Store_Name FROM location UNION ALL SELECT Store_Name FROM store_info;
9.1 交集值
取两个SQL语句结果的交集
SELECT A.Store_Name FROM location A INNER JOIN store_info B ON A.Store_Name = B.Store_Name;
SELECT A.Store_Name FROM location A INNER JOIN store_info B USING(Store_Name);
取两个SQL语句结果的交集,且没有重复
SELECT DISTINCT A.Store_Name FROM location A INNER JOIN store_info B USING(Store_Name);
SELECT DISTINCT Store_Name FROM location WHERE (Store_Name) IN (SELECT Store_Name FROM store_info);
SELECT DISTINCT A.Store_Name FROM location A LEFT JOIN store_info B USING(Store_Name) WHERE B.Store_Name IS NOT NULL;
SELECT A.Store_Name FROM (SELECT B.Store_Name FROM location B INNER JOIN store_info C ON B.Store_Name = C.Store_Name) A
GR0UP BY A.Store_Name;
SELECT A.Store_Name FROM
(SELECT DISTINCT Store_Name FROM location UNION ALL SELECT DISTINCT Store_Name FROM store_info) A
GR0UP BY A.Store_Name HAVING COUNT(*) > 1;
9.2 无交集值
显示第一个SQL语句的结果,且与第二个SQL语句没有交集的结果,且没有重复
SELECT DISTINCT Store_Name FROM location WHERE (Store_Name) NOT IN (SELECT Store_Name FROM store_info);
SELECT DISTINCT A.Store_Name FROM location A LEFT JOIN store_info B USING(Store_Name) WHERE B.Store_Name IS NULL;
SELECT A.Store_Name FROM
(SELECT DISTINCT Store_Name FROM location UNION ALL SELECT DISTINCT Store_Name FROM store_info) A
GR0UP BY A.Store_Name HAVING COUNT(*) = 1;
10. case
是 SQL 用来做为 IF-THEN-ELSE 之类逻辑的关键字
语法:
SELECT CASE ("字段名")
WHEN "条件1" THEN "结果1"
WHEN "条件2" THEN "结果2"
...
[ELSE "结果N"]
END
FROM "表名";
"条件" 可以是一个数值或是公式。 ELSE 子句则并不是必须的。
SELECT Store_Name, CASE Store_Name
WHEN 'Los Angeles' THEN Sales * 2
WHEN 'Boston' THEN 2000
ELSE Sales
END
"New Sales",Date
FROM store_info;
#"New Sales" 是用于 CASE 那个字段的字段名。
11. 正则表达式
匹配模式 | 描述 | 实例 |
---|---|---|
^ | 匹配文本的结束字符 | ‘^bd’ 匹配以 bd 开头的字符串 |
$ | 匹配文本的结束字符 | ‘qn$’ 匹配以 qn 结尾的字符串 |
. | 匹配任何单个字符 | ‘s.t’ 匹配任何 s 和 t 之间有一个字符的字符串 |
* | 匹配零个或多个在它前面的字符 | ‘fo*t’ 匹配 t 前面有任意个 o |
+ | 匹配前面的字符 1 次或多次 | ‘hom+’ 匹配以 ho 开头,后面至少一个m 的字符串 |
字符串 | 匹配包含指定的字符串 | ‘clo’ 匹配含有 clo 的字符串 |
p1|p2 | 匹配 p1 或 p2 | ‘bg|fg’ 匹配 bg 或者 fg |
[...] | 匹配字符集合中的任意一个字符 | ‘[abc]’ 匹配 a 或者 b 或者 c |
[^...] | 匹配不在括号中的任何字符 | ‘[^ab]’ 匹配不包含 a 或者 b 的字符串 |
{n} | 匹配前面的字符串 n 次 | ‘g{2}’ 匹配含有 2 个 g 的字符串 |
{n,m} | 匹配前面的字符串至少 n 次,至多m 次 | ‘f{1,3}’ 匹配 f 最少 1 次,最多 3 次 |
语法:SELECT "字段" FROM "表名" WHERE "字段" REGEXP {模式};
SELECT * FROM store_info WHERE Store_Name REGEXP 'os';
SELECT * FROM store_info WHERE Store_Name REGEXP '^[A-G]';
SELECT * FROM store_info WHERE Store_Name REGEXP 'Ho|Bo';
12. 存储过程
存储过程是一组为了完成特定功能的SQL语句集合。
存储过程在使用过程中是将常用或者复杂的工作预先使用SQL语句写好并用一个指定的名称存储起来,这个过程经编译和优化后存储在数据库服务器中。当需要使用该存储过程时,只需要调用它即可。存储过程在执行上比传统SQL速度更快、执行效率更高。
存储过程的优点:
1、执行一次后,会将生成的二进制代码驻留缓冲区,提高执行效率
2、SQL语句加上控制语句的集合,灵活性高
3、在服务器端存储,客户端调用时,降低网络负载
4、可多次重复被调用,可随时修改,不影响客户端调用
5、可完成所有的数据库操作,也可控制数据库的信息访问权限
12.1 创建存储过程
DELIMITER $$ #将语句的结束符号从分号;临时改为两个$$(可以是自定义)
CREATE PROCEDURE Proc() #创建存储过程,过程名为Proc,不带参数
-> BEGIN #过程体以关键字 BEGIN 开始
-> select * from Store_Info; #过程体语句
-> END $$ #过程体以关键字 END 结束
DELIMITER ; #将语句的结束符号恢复为分号
实例
DELIMITER $$ #将语句的结束符号从分号;临时改为两个$$(可以自定义)
CREATE PROCEDURE Proc5() #创建存储过程,过程名为Proc5,不带参数
-> BEGIN #过程体以关键字 BEGIN 开始
-> create table user (id int (10), name char(10),score int (10));
-> insert int0 user values (1, 'cyw',70);
-> select * from cyw; #过程体语句
-> END $$ #过程体以关键字 END 结束
DELIMITER ; #将语句的结束符号恢复为分号
12.2 调用存储过程
CALL Proc;
12.3 查看存储过程
SHOW CREATE PROCEDURE [数据库.]存储过程名; #查看某个存储过程的具体信息
SHOW CREATE PROCEDURE Proc;
SHOW PROCEDURE STATUS [LIKE '%Proc%'] \G
12.4 存储过程的参数
**IN 输入参数:**表示调用者向过程传入值(传入值可以是字面量或变量)
**OUT 输出参数:**表示过程向调用者传出值(可以返回多个值)(传出值只能是变量)
**INOUT 输入输出参数:**既表示调用者向过程传入值,又表示过程向调用者传出值(值只能是变量)
DELIMITER $$
CREATE PROCEDURE Proc6(IN inname CHAR(16))
-> BEGIN
-> SELECT * FROM store_info WHERE Store_Name = inname;
-> END $$
DELIMITER ;
CALL Proc6('Boston');
12.5 修改存储过程
ALTER PROCEDURE <过程名>[<特征>... ]
ALTER PROCEDURE GetRole MODIFIES SQL DATA SQL SECURITY INVOKER;
MODIFIES sQLDATA:表明子程序包含写数据的语句
SECURITY:安全等级
invoker:当定义为INVOKER时,只要执行者有执行权限,就可以成功执行。
12.6 删除存储过程
存储过程内容的修改方法是通过删除原有存储过程,之后再以相同的名称创建新的存储过程。如果要修改存储过程的名称,可以先删除原存储过程,再以不同的命名创建新的存储过程。
DROP PROCEDURE IF EXISTS Proc;
#仅当存在时删除,不添加 IF EXISTS 时,如果指定的过程不存在,则产生一个错误
13. 条件语句
if-then-else ···· end if
mysql> delimiter $$
mysql>
mysql> CREATE PROCEDURE proc8(IN pro int)
->
-> begin
->
-> declare var int;
-> set var=pro*2;
-> if var>=10 then
-> update t set id=id+1;
-> else
-> update t set id=id-1;
-> end if;
-> end $$
mysql> delimiter ;
14. 循环语句
while ···· end while
mysql> delimiter $$
mysql>
mysql> create procedure proc9()
-> begin
-> declare var int(10);
-> set var=0;
-> while var<6 do
-> insert int0 t values(var);
-> set var=var+1;
-> end while;
-> end $$
mysql> delimiter ;
15. 视图表 create view
15.1 视图表概述
视图,可以被当作是虚拟表或存储查询。
视图跟表格的不同是,表格中有实际储存数据记录,而视图是建立在表格之上的一个架构,它本身并不实际储存数据记录。
临时表在用户退出或同数据库的连接断开后就自动消失了,而视图不会消失。
视图不含有数据,只存储它的定义,它的用途一般可以简化复杂的查询。
比如你要对几个表进行连接查询,而且还要进行统计排序等操作,写sql语句会很麻烦的,用视图将几个表联结起来,然后对这个视图进行查询操作,就和对一个表查询一样,很方便。
15.2 视图表能否修改?
首先我们需要知道,视图表保存的是select语句的定义,所以视图表可不可以修改需要视情况而定。
- 如果 select 语句查询的字段是没有被处理过的源表字段,则可以通过视图表修改源表数据;
- 如果select 语句查询的字段是被 gr0up by语句或 函数 处理过的字段,则不可以直接修改视图表的数据。
create view v_store_info as select store_name,sales from store_info;
update v_store_info set sales=1000 where store_name='Houston';
create view v_sales as select store_name,sum(sales) from store_info gr0up by store_name having sum(sales)>1000;
update v_sales set store_name='xxxx' where store_name='Los Angeles';
15.3 基本语法
15.3.1 创建视图表
语法
create view "视图表名" as "select 语句";
create view v_region_sales as select a.region region,sum(b.sales) sales from location a
inner join store_info b on a.store_name = b.store_name gr0up by region;
15.4 查看视图表
语法
select * from 视图表名;
select * from v_region_sales;
15.5 删除视图表
语法
drop view 视图表名;
drop view v_region_sales;
15.6 通过视图表求无交集值
将两个表中某个字段的不重复值进行合并
只出现一次(count =1 ) ,即无交集
通过
create view 视图表名 as select distinct 字段 from 左表 union all select distinct 字段 from 右表;
select 字段 from 视图表名 gr0up by 字段 having count(字段)=1;
#先建立视图表
create viem v_union as select distinct store_name from location union all select distinct store_name from store_info;
#再通过视图表求无交集
select store_name from v_union gr0up by store_name having count(*)=1;
来源:juejin.cn/post/7291952951047929868
哇塞,新来个架构师,把Nacos注册中心讲得炉火纯青,佩服佩服~~
大家好,我是三友~~
今天就应某位小伙伴的要求,来讲一讲Nacos作为服务注册中心底层的实现原理
不知你是否跟我一样,在使用Nacos时有以下几点疑问:
- 临时实例和永久实例是什么?有什么区别?
- 服务实例是如何注册到服务端的?
- 服务实例和服务端之间是如何保活的?
- 服务订阅是如何实现的?
- 集群间数据是如何同步的?CP还是AP?
- Nacos的数据模型是什么样的?
- ...
本文就通过探讨上述问题来探秘Nacos服务注册中心核心的底层实现原理。
虽然Nacos最新版本已经到了2.x版本,但是为了照顾那些还在用1.x版本的同学,所以本文我会同时去讲1.x版本和2.x版本的实现
临时实例和永久实例
临时实例和永久实例在Nacos中是一个非常非常重要的概念
之所以说它重要,主要是因为我在读源码的时候发现,临时实例和永久实例在底层的许多实现机制是完全不同的
临时实例
临时实例在注册到注册中心之后仅仅只保存在服务端内部一个缓存中,不会持久化到磁盘
这个服务端内部的缓存在注册中心届一般被称为服务注册表
当服务实例出现异常或者下线之后,就会把这个服务实例从服务注册表中剔除
永久实例
永久服务实例不仅仅会存在服务注册表中,同时也会被持久化到磁盘文件中
当服务实例出现异常或者下线,Nacos只会将服务实例的健康状态设置为不健康,并不会对将其从服务注册表中剔除
所以这个服务实例的信息你还是可以从注册中心看到,只不过处于不健康状态
这是就是两者最最最基本的区别
当然除了上述最基本的区别之外,两者还有很多其它的区别,接下来本文还会提到
这里你可能会有一个疑问
为什么Nacos要将服务实例分为临时实例和永久实例?
主要还是因为应用场景不同
临时实例就比较适合于业务服务,服务下线之后可以不需要在注册中心中查看到
永久实例就比较适合需要运维的服务,这种服务几乎是永久存在的,比如说MySQL、Redis等等
MySQL、Redis等服务实例可以通过SDK手动注册
对于这些服务,我们需要一直看到服务实例的状态,即使出现异常,也需要能够查看时实的状态
所以从这可以看出Nacos跟你印象中的注册中心不太一样,他不仅仅可以注册平时业务中的实例,还可以注册像MySQL、Redis这个服务实例的信息到注册中心
在SpringCloud环境底下,一般其实都是业务服务,所以默认注册服务实例都是临时实例
当然如果你想改成永久实例,可以通过下面这个配置项来完成
spring
cloud:
nacos:
discovery:
#ephemeral单词是临时的意思,设置成false,就是永久实例了
ephemeral: false
这里还有一个小细节
在1.x版本中,一个服务中可以既有临时实例也有永久实例,服务实例是永久还是临时是由服务实例本身决定的
但是2.x版本中,一个服务中的所有实例要么都是临时的要么都是永久的,是由服务决定的,而不是具体的服务实例
所以在2.x可以说是临时服务和永久服务
为什么2.x把临时还是永久的属性由实例本身决定改成了由服务决定?
其实很简单,你想想,假设对一个MySQL服务来说,它的每个服务实例肯定都是永久的,不会出现一些是永久的,一些是临时的情况吧
所以临时还是永久的属性由服务本身决定其实就更加合理了
服务注册
作为一个服务注册中心,服务注册肯定是一个非常重要的功能
所谓的服务注册,就是通过注册中心提供的客户端SDK(或者是控制台)将服务本身的一些元信息,比如ip、端口等信息发送到注册中心服务端
服务端在接收到服务之后,会将服务的信息保存到前面提到的服务注册表中
1、1.x版本的实现
在Nacos在1.x版本的时候,服务注册是通过Http接口实现的
代码如下
整个逻辑比较简单,因为Nacos服务端本身就是用SpringBoot写的
但是在2.x版本的实现就比较复杂了
2、2.x版本的实现
2.1、通信协议的改变
2.x版本相比于1.x版本最主要的升级就是客户端和服务端通信协议的改变,由1.x版本的Http改成了2.x版本gRPC
gRPC是谷歌公司开发的一个高性能、开源和通用的RPC框架,Java版本的实现底层也是基于Netty来的
之所以改成了gRPC,主要是因为Http请求会频繁创建和销毁连接,白白浪费资源
所以在2.x版本之后,为了提升性能,就将通信协议改成了gRPC
根据官网显示,整体的效果还是很明显,相比于1.x版本,注册性能总体提升至少2倍
虽然通信方式改成了gRPC,但是2.x版本服务端依然保留了Http注册的接口,所以用1.x的Nacos SDK依然可以注册到2.x版本的服务端
2.2、具体的实现
Nacos客户端在启动的时候,会通过gRPC跟服务端建立长连接
这个连接会一直存在,之后客户端与服务端所有的通信都是基于这个长连接来的
当客户端发起注册的时候,就会通过这个长连接,将服务实例的信息发送给服务端
服务端拿到服务实例,跟1.x一样,也会存到服务注册表
除了注册之外,当注册的是临时实例时,2.x还会将服务实例信息存储到客户端中的一个缓存中,供Redo操作
所谓的Redo操作,其实就是一个补偿机制,本质是个定时任务,默认每3s执行一次
这个定时任务作用是,当客户端与服务端重新建立连接时(因为一些异常原因导致连接断开)
那么之前注册的服务实例肯定还要继续注册服务端(断开连接服务实例就会被剔除服务注册表)
所以这个Redo操作一个很重要的作用就是重连之后的重新注册的作用
除了注册之外,比如服务订阅之类的操作也需要Redo操作,当连接重新建立,之前客户端的操作都需要Redo一下
小总结
1.x版本是通过Http协议来进行服务注册的
2.x由于客户端与服务端的通信改成了gRPC长连接,所以改成通过gRPC长连接来注册
2.x比1.x多个Redo操作,当注册的服务实例是临时实例是,出现网络异常,连接重新建立之后,客户端需要将服务注册、服务订阅之类的操作进行重做
这里你可能会有个疑问
既然2.x有Redo机制保证客户端与服务端通信正常之后重新注册,那么1.x有类似的这种Redo机制么?
当然也会有,接下往下看。
心跳机制
心跳机制,也可以被称为保活机制,它的作用就是服务实例告诉注册中心我这个服务实例还活着
在正常情况下,服务关闭了,那么服务会主动向Nacos服务端发送一个服务下线的请求
Nacos服务端在接收到请求之后,会将这个服务实例从服务注册表中剔除
但是对于异常情况下,比如出现网络问题,可能导致这个注册的服务实例无法提供服务,处于不可用状态,也就是不健康
而此时在没有任何机制的情况下,服务端是无法知道这个服务处于不可用状态
所以为了避免这种情况,一些注册中心,就比如Nacos、Eureka,就会用心跳机制来判断这个服务实例是否能正常
在Nacos中,心跳机制仅仅是针对临时实例来说的,临时实例需要靠心跳机制来保活
心跳机制在1.x和2.x版本的实现也是不一样的
1.x心跳实现
在1.x中,心跳机制实现是通过客户端和服务端各存在的一个定时任务来完成的
在服务注册时,发现是临时实例,客户端会开启一个5s执行一次的定时任务
这个定时任务会构建一个Http请求,携带这个服务实例的信息,然后发送到服务端
在Nacos服务端也会开启一个定时任务,默认也是5s执行一次,去检查这些服务实例最后一次心跳的时间,也就是客户端最后一次发送Http请求的时间
- 当最后一次心跳时间超过15s,但没有超过30s,会把这服务实例标记成不健康
- 当最后一次心跳超过30s,直接把服务从服务注册表中剔除
这就是1.x版本的心跳机制,本质就是两个定时任务
其实1.x的这个心跳还有一个作用,就是跟上一节说的gRPC时Redo操作的作用是一样的
服务在处理心跳的时候,发现心跳携带这个服务实例的信息在注册表中没有,此时就会添加到服务注册表
所以心跳也有Redo的类似效果
2.x心跳实现
在2.x版本之后,由于通信协议改成了gRPC,客户端与服务端保持长连接,所以2.x版本之后它是利用这个gRPC长连接本身的心跳来保活
一旦这个连接断开,服务端就会认为这个连接注册的服务实例不可用,之后就会将这个服务实例从服务注册表中提出剔除
除了连接本身的心跳之外,Nacos还有服务端的一个主动检测机制
Nacos服务端也会启动一个定时任务,默认每隔3s执行一次
这个任务会去检查超过20s没有发送请求数据的连接
一旦发现有连接已经超过20s没发送请求,那么就会向这个连接对应的客户端发送一个请求
如果请求不通或者响应失败,此时服务端也会认为与客户端的这个连接异常,从而将这个客户端注册的服务实例从服务注册表中剔除
所以对于2.x版本,主要是两种机制来进行保活:
- 连接本身的心跳机制,断开就直接剔除服务实例
- Nacos主动检查机制,服务端会对20s没有发送数据的连接进行检查,出现异常时也会主动断开连接,剔除服务实例
小总结
心跳机制仅仅针对临时实例而言
1.x心跳机制是通过客户端和服务端两个定时任务来完成的,客户端定时上报心跳信息,服务端定时检查心跳时间,超过15s标记不健康,超过30s直接剔除
1.x心跳机制还有类似2.x的Redo作用,服务端发现心跳的服务信息不存在会,会将服务信息添加到注册表,相当于重新注册了
2.x是基于gRPC长连接本身的心跳机制和服务端的定时检查机制来的,出现异常直接剔除
健康检查
前面说了,心跳机制仅仅是临时实例用来保护的机制
而对于永久实例来说,一般来说无法主动上报心跳
就比如说MySQL实例,肯定是不会主动上报心跳到Nacos的,所以这就导致无法通过心跳机制来保活
所以针对永久实例的情况,Nacos通过一种叫健康检查的机制去判断服务实例是否活着
健康检查跟心跳机制刚好相反,心跳机制是服务实例向服务端发送请求
而所谓的健康检查就是服务端主动向服务实例发送请求,去探测服务实例是否活着
健康检查机制在1.x和2.x的实现机制是一样的
Nacos服务端在会去创建一个健康检查任务,这个任务每次执行时间间隔会在2000~7000毫秒之间
当任务触发的时候,会根据设置的健康检查的方式执行不同的逻辑,目前主要有以下三种方式:
- TCP
- HTTP
- MySQL
TCP的方式就是根据服务实例的ip和端口去判断是否能连接成功,如果连接成功,就认为健康,反之就任务不健康
HTTP的方式就是向服务实例的ip和端口发送一个Http请求,请求路径是需要设置的,如果能正常请求,说明实例健康,反之就不健康
MySQL的方式是一种特殊的检查方式,他可以执行下面这条Sql来判断数据库是不是主库
默认情况下,都是通过TCP的方式来探测服务实例是否还活着
服务发现
所谓的服务发现就是指当有服务实例注册成功之后,其它服务可以发现这些服务实例
Nacos提供了两种发现方式:
- 主动查询
- 服务订阅
主动查询就是指客户端主动向服务端查询需要关注的服务实例,也就是拉(pull)的模式
服务订阅就是指客户端向服务端发送一个订阅服务的请求,当被订阅的服务有信息变动就会主动将服务实例的信息推送给订阅的客户端,本质就是推(push)模式
在我们平时使用时,一般来说都是选择使用订阅的方式,这样一旦有服务实例数据的变动,客户端能够第一时间感知
并且Nacos在整合SpringCloud的时候,默认就是使用订阅的方式
对于这两种服务发现方式,1.x和2.x版本实现也是不一样
服务查询其实两者实现都很简单
1.x整体就是发送Http请求去查询服务实例,2.x只不过是将Http请求换成了gRPC的请求
服务端对于查询的处理过程都是一样的,从服务注册表中查出符合查询条件的服务实例进行返回
不过对于服务订阅,两者的机制就稍微复杂一点
在Nacos客户端,不论是1.x还是2.x都是通过SDK中的NamingService#subscribe
方法来发起订阅的
当有服务实例数据变动的时,客户端就会回调EventListener
,就可以拿到最新的服务实例数据了
虽然1.x还是2.x都是同样的方法,但是具体的实现逻辑是不一样的
1.x服务订阅实现
在1.x版本的时候,服务订阅的处理逻辑大致会有以下三步:
第一步,客户端在启动的时候,会去构建一个叫PushReceiver的类
这个类会去创建一个UDP Socket,端口是随机的
其实通过名字就可以知道这个类的作用,就是通过UDP的方式接收服务端推送的数据的
第二步,调用NamingService#subscribe
来发起订阅时,会先去服务端查询需要订阅服务的所有实例信息
之后会将所有服务实例数据存到客户端的一个内部缓存中
并且在查询的时候,会将这个UDP Socket的端口作为一个参数传到服务端
服务端接收到这个UDP端口后,后续就通过这个端口给客户端推送服务实例数据
第三步,会为这次订阅开启一个不定时执行的任务
之所以不定时,是因为这个当执行异常的时候,下次执行的时间间隔就会变长,但是最多不超过60s,正常是10s,这个10s是查询服务实例是服务端返回的
这个任务会去从服务端查询订阅的服务实例信息,然后更新内部缓存
这里你可能会有个疑问
既然有了服务变动推送的功能,为什么还要定时去查询更新服务实例信息呢?
其实很简单,那就是因为UDP通信不稳定导致的
虽然有Push,但是由于UDP通信自身的不确定性,有可能会导致客户端接收变动信息失败
所以这里就加了一个定时任务,弥补这种可能性,属于一个兜底的方案。
这就是1.x版本的服务订阅的实现
2.x服务订阅的实现
讲完1.x的版本实现,接下来就讲一讲2.x版本的实现
由于2.x版本换成了gRPC长连接的方式,所以2.x版本服务数据变更推送已经完全抛弃了1.x的UDP做法
当有服务实例变动的时候,服务端直接通过这个长连接将服务信息发送给客户端
客户端拿到最新服务实例数据之后的处理方式就跟1.x是一样了
除了处理方式一样,2.x也继承了1.x的其他的东西
比如客户端依然会有服务实例的缓存
定时对比机制也保留了,只不过这个定时对比的机制默认是关闭状态
之所以默认关闭,主要还是因为长连接还是比较稳定的原因
当客户端出现异常,接收不到请求,那么服务端会直接跟客户端断开连接
当恢复正常,由于有Redo操作,所以还是能拿到最新的实例信息的
所以2.x版本的服务订阅功能的实现大致如下图所示
这里还有个细节需要注意
在1.x版本的时候,任何服务都是可以被订阅的
但是在2.x版本中,只支持订阅临时服务,对于永久服务,已经不支持订阅了
小总结
服务查询1.x是通过Http请求;2.x通过gRPC请求
服务订阅1.x是通过UDP来推送的;2.x就基于gRPC长连接来实现的
1.x和2.x客户端都有服务实例的缓存,也有定时对比机制,只不过1.x会自动开启;2.x提供了一个开个,可以手动选择是否开启,默认不开启
数据一致性
由于Nacos是支持集群模式的,所以一定会涉及到分布式系统中不可避免的数据一致性问题
1、服务实例的责任机制
再说数据一致性问题之前,先来讨论一下服务实例的责任机制
什么是服务实例的责任机制?
比如上面提到的服务注册、心跳管理、监控检查机制,当只有一个Nacos服务时,那么自然而言这个服务会去检查所有的服务实例的心跳时间,执行所有服务实例的健康检查任务
但是当出现Nacos服务出现集群时,为了平衡各Nacos服务的压力,Nacos会根据一定的规则让每个Nacos服务只管理一部分服务实例的
当然每个Nacos服务的注册表还是全部的服务实例数据
这个管理机制我给他起了一个名字,就叫做责任机制,因为我在1.x和2.x都提到了responsible
这个单词
本质就是Nacos服务对哪些服务实例负有心跳监测,健康检查的责任。
2、CAP定理和BASE理论
谈到数据一致性问题,一定离不开两个著名分布式理论
- CAP定理
- BASE理论
CAP定理中,三个字母分别代表这些含义:
- C,Consistency单词的缩写,代表一致性,指分布式系统中各个节点的数据保持强一致,也就是每个时刻都必须一样,不一样整个系统就不能对外提供服务
- A,Availability单词的缩写,代表可用性,指整个分布式系统保持对外可用,即使从每个节点获取的数据可能都不一样,只要能获取到就行
- P,Partition tolerance单词的缩写,代表分区容错性。
所谓的CAP定理,就是指在一个分布式系统中,CAP这三个指标,最多同时只能满足其中的两个,不可能三个都同时满足
为什么三者不能同时满足?
对于一个分布式系统,网络分区是一定需要满足的
而所谓分区指的是系统中的服务部署在不同的网络区域中
比如,同一套系统可能同时在北京和上海都有部署,那么他们就处于不同的网络分区,就可能出现无法互相访问的情况
当然,你也可以把所有的服务都放在一个网络分区,但是当网络出现故障时,整个系统都无法对外提供服务,那这还有什么意义呢?
所以分布式系统一定需要满足分区容错性,把系统部署在不同的区域网络中
此时只剩下了一致性和可用性,它们为什么不能同时满足?
其实答案很简单,就因为可能出现网络分区导致的通信失败。
比如说,现在出现了网络分区的问题,上图中的A网络区域和B网络区域无法相互访问
此时假设往上图中的A网络区域发送请求,将服务中的一个值 i 属性设置成 1
如果保证可用性,此时由于A和B网络不通,此时只有A中的服务修改成功,B无法修改成功,此时数据AB区域数据就不一致性,也就没有保证数据一致性
如果保证一致性,此时由于A和B网络不通,所以此时A也不能修改成功,必须修改失败,否则就会导致AB数据不一致
虽然A没修改成功,保证了数据一致性,AB还是之前相同的数据,但是此时整个系统已经没有写可用性了,无法成功写数据了。
所以从上面分析可以看出,在有分区容错性的前提下,可用性和一致性是无法同时保证的。
虽然无法同时一致性和可用性,但是能不能换种思路来思考一下这个问题
首先我们可以先保证系统的可用性,也就是先让系统能够写数据,将A区域服务中的i修改成1
之后当AB区域之间网络恢复之后,将A区域的i值复制给B区域,这样就能够保证AB区域间的数据最终是一致的了
这不就皆大欢喜了么
这种思路其实就是BASE理论的核心要点,优先保证可用性,数据最终达成一致性。
BASE理论主要是包括以下三点:
- 基本可用(Basically Available):系统出现故障还是能够对外提供服务,不至于直接无法用了
- 软状态(Soft State):允许各个节点的数据不一致
- 最终一致性,(Eventually Consistent):虽然允许各个节点的数据不一致,但是在一定时间之后,各个节点的数据最终需要一致的
BASE理论其实就是妥协之后的产物。
3、Nacos的AP和CP
Nacos其实目前是同时支持AP和CP的
具体使用AP还是CP得取决于Nacos内部的具体功能,并不是有的文章说的可以通过一个配置自由切换。
就以服务注册举例来说,对于临时实例来说,Nacos会优先保证可用性,也就是AP
对于永久实例,Nacos会优先保证数据的一致性,也就是CP
接下来我们就来讲一讲Nacos的CP和AP的实现原理
3.1、Nacos的AP实现
对于AP来说,Nacos使用的是阿里自研的Distro协议
在这个协议中,每个服务端节点是一个平等的状态,每个服务端节点正常情况下数据是一样的,每个服务端节点都可以接收来自客户端的读写请求
当某个节点刚启动时,他会向集群中的某个节点发送请求,拉取所有的服务实例数据到自己的服务注册表中
这样其它客户端就可以从这个服务节点中获取到服务实例数据了
当某个服务端节点接收到注册临时服务实例的请求,不仅仅会将这个服务实例存到自身的服务注册表,同时也会向其它所有服务节点发送请求,将这个服务数据同步到其它所有节点
所以此时从任意一个节点都是可以获取到所有的服务实例数据的。
即使数据同步的过程发生异常,服务实例也成功注册到一个Nacos服务中,对外部而言,整个Nacos集群是可用的,也就达到了AP的效果
同时为了满足BASE理论,Nacos也有下面两种机制保证最终节点间数据最终是一致的:
- 失败重试机制
- 定时对比机制
失败重试机制是指当数据同步给其它节点失败时,会每隔3s重试一次,直到成功
定时对比机制就是指,每个Nacos服务节点会定时向所有的其它服务节点发送一些认证的请求
这个请求会告诉每个服务节点自己负责的服务实例的对应的版本号,这个版本号随着服务实例的变动就会变动
如果其它服务节点的数据的版本号跟自己的对不上,那就说明其它服务节点的数据不是最新的
此时这个Nacos服务节点就会将自己负责的服务实例数据发给不是最新数据的节点,这样就保证了每个节点的数据是一样的了。
3.2、Nacos的CP实现
Nacos的CP实现是基于Raft算法来实现的
在1.x版本早期,Nacos是自己手动实现Raft算法
在2.x版本,Nacos移除了手动实现Raft算法,转而拥抱基于蚂蚁开源的JRaft框架
在Raft算法,每个节点主要有三个状态
- Leader,负责所有的读写请求,一个集群只有一个
- Follower,从节点,主要是负责复制Leader的数据,保证数据的一致性
- Candidate,候选节点,最终会变成Leader或者Follower
集群启动时都是节点Follower,经过一段时间会转换成Candidate状态,再经过一系列复杂的选择算法,选出一个Leader
这个选举算法比较复杂,完全值得另写一篇文章,这里就不细说了。不过立个flag,如果本篇文章点赞量超过28个,我连夜爆肝,再来一篇。
当有写请求时,如果请求的节点不是Leader节点时,会将请求转给Leader节点,由Leader节点处理写请求
比如,有个客户端连到的上图中的Nacos服务2
节点,之后向Nacos服务2
注册服务
Nacos服务2
接收到请求之后,会判断自己是不是Leader节点,发现自己不是
此时Nacos服务2
就会向Leader节点发送请求,Leader节点接收到请求之后,会处理服务注册的过程
为什么说Raft是保证CP的呢?
主要是因为Raft在处理写的时候有一个判断过程
- 首先,Leader在处理写请求时,不会直接数据应用到自己的系统,而是先向所有的Follower发送请求,让他们先处理这个请求
- 当超过半数的Follower成功处理了这个写请求之后,Leader才会写数据,并返回给客户端请求处理成功
- 如果超过一定时间未收到超过半数处理成功Follower的信号,此时Leader认为这次写数据是失败的,就不会处理写请求,直接返回给客户端请求失败
所以,一旦发生故障,导致接收不到半数的Follower写成功的响应,整个集群就直接写失败,这就很符合CP的概念了。
不过这里还有一个小细节需要注意
Nacos在处理查询服务实例的请求直接时,并不会将请求转发给Leader节点处理,而是直接查当前Nacos服务实例的注册表
这其实就会引发一个问题
如果客户端查询的Follower节点没有及时处理Leader同步过来的写请求(过半响应的节点中不包括这个节点),此时在这个Follower其实是查不到最新的数据的,这就会导致数据的不一致
所以说,虽然Raft协议规定要求从Leader节点查最新的数据,但是Nacos至少在读服务实例数据时并没有遵守这个协议
当然对于其它的一些数据的读写请求有的还是遵守了这个协议。
JRaft对于读请求其实是做了很多优化的,其实从Follower节点通过一定的机制也是能够保证读到最新的数据
数据模型
在Nacos中,一个服务的确定是由三部分信息确定
- 命名空间(Namespace):多租户隔离用的,默认是
public
- 分组(Gr0up):这个其实可以用来做环境隔离,服务注册时可以指定服务的分组,比如是测试环境或者是开发环境,默认是
DEFAULT_GR0UP
- 服务名(ServiceName):这个就不用多说了
通过上面三者就可以确定同一个服务了
在服务注册和订阅的时候,必须要指定上述三部分信息,如果不指定,Nacos就会提供默认的信息
不过,在Nacos中,在服务里面其实还是有一个集群的概念
在服务注册的时候,可以指定这个服务实例在哪个集体的集群中,默认是在DEFAULT
集群下
在SpringCloud环境底下可以通过如下配置去设置
spring
cloud:
nacos:
discovery:
cluster-name: sanyoujavaCluster
在服务订阅的时候,可以指定订阅哪些集群下的服务实例
当然,也可以不指定,如果不指定话,默认就是订阅这个服务下的所有集群的服务实例
我们日常使用中可以将部署在相同区域的服务划分为同一个集群,比如杭州属于一个集群,上海属于一个集群
这样服务调用的时候,就可以优先使用同一个地区的服务了,比跨区域调用速度更快。
总结
到这,终终终于总算是讲完了Nacos作为注册中心核心的实现原理
来源:juejin.cn/post/7347325319198048283
你居然还去服务器上捞日志,搭个日志收集系统难道不香么!
1 ELK日志系统
经典的ELK架构或现被称为Elastic Stack。Elastic Stack架构为Elasticsearch + Logstash + Kibana + Beats的组合:
- Beats负责日志的采集
- Logstash负责做日志的聚合和处理
- ES作为日志的存储和搜索系统
- Kibana作为可视化前端展示
整体架构图:
2 EFK日志系统
容器化场景中,尤其k8s环境,用户经常使用EFK架构。F代表Fluent Bit,一个开源多平台的日志处理器和转发器。Fluent Bit可以:
- 让用户从不同来源收集数据/日志
- 统一并发到多个目的地
- 完全兼容Docker和k8s环境
3 PLG日志系统
3.1 Prometheus+k8s日志系统
PLG
Grafana Labs提供的另一个日志解决方案PLG逐渐流行。PLG架构即Promtail + Loki + Grafana的组合:
Grafana,开源的可视化和分析软件,允许用户查询、可视化、警告和探索监控指标。Grafana主要提供时间序列数据的仪表板解决方案,支持超过数十种数据源。
Grafana Loki是一组可以组成一个功能齐全的日志堆栈组件,与其它日志系统不同,Loki只建立日志标签的索引而不索引原始日志消息,而是为日志数据设置一组标签,即Loki运营成本更低,效率还提高几个数量级。
Loki设计理念
Prometheus启发,可实现可水平扩展、高可用的多租户日志系统。Loki整体架构由不同组件协同完成日志收集、索引、存储等。
各组件如下,Loki’s Architecture深入了解。Loki就是like Prometheus, but for logs。
Promtail是一个日志收集的代理,会将本地日志内容发到一个Loki实例,它通常部署到需要监视应用程序的每台机器/容器上。Promtail主要是用来发现目标、将标签附加到日志流以及将日志推送到Loki。截止到目前,Promtail可以跟踪两个来源的日志:本地日志文件和systemd日志(仅支持AMD64架构)。
4 PLG V.S ELK
4.1 ES V.S Loki
ELK/EFK架构确实强,经多年实际环境验证。存储在ES中的日志通常以非结构化JSON对象形式存储在磁盘,且ES为每个对象都建索引,以便全文搜索,然后用户可特定查询语言搜索这些日志数据。
而Loki数据存储解耦:
- 既可在磁盘存储
- 也可用如Amazon S3云存储系统
Loki日志带有一组标签名和值,只有标签对被索引,这种权衡使它比完整索引操作成本更低,但针对基于内容的查询,需通过LogQL再单独查询。
4.2 Fluentd V.S Promtail
相比Fluentd,Promtail专为Loki定制,它可为运行在同一节点的k8s Pods做服务发现,从指定文件夹读取日志。Loki类似Prometheus的标签方式。因此,当与Prometheus部署在同一环境,因为相同的服务发现机制,来自Promtail的日志通常具有与应用程序指标相同的标签,统一标签管理。
4.3 Grafana V.S Kibana
Kibana提供许多可视化工具来进行数据分析,高级功能如异常检测等机器学习功能。Grafana针对Prometheus和Loki等时间序列数据打造,可在同一仪表板上查看日志指标。
参考
来源:juejin.cn/post/7295623585364082739
程序员想独立赚钱的几个注意点
1、始终保持好奇心,喜欢折腾新鲜事物,并且能够很快付诸于行动,有想法立马行动起来,赶紧把东西搞出来,然后推出去。
2、普通人不要沉迷于技术,时刻想着通过技术赚到钱才是最重要的。
3、一切以最小的代价赚到钱为第一原则。
4、要务实,哪怕是些小的事情,哪怕是别人看不上的东西,只要能赚钱就要敢于去做。
5、要聚焦,每个小阶段,踏踏实实做好一件小事情。
6、除了懂产品之外,还要学会做运营,搞流量,做好SEO推广。
7、持续阅读,持续写作。
8、有钱也要谨慎,尽量小成本试错,钱要花在刀刃上。
9、不要迷恋自己的产品,如果做出来的东西有人愿意买走,那就果断出手,可以拿到一笔钱之后继续做其他想做的事情。
10、做运营就离不开做社群运营,引导用户进群,盘活用户,促进成交。
11、只要你的产品有价值,那就可以大胆收费。毕竟程序员除了追求技术也要赚钱养家,也要体面生活。
12、如果没有资本做背景,那就选个小赛道,做个隐形冠军。
13、如果想脱离打工人,想自己独立赚钱,那就是条不确定的路,要敢于面对不确定性。
来源:mp.weixin.qq.com/s/3RVdGWpvk6AUqzBIB-4qXQ
记 · 在 AI 公司入职一个月的体验与感悟
已经在一家 AI 公司入职了一个月,对坐班有些厌恶的我,没想到有一天也会开始通勤打卡。而经历了这一个月的工作,我对坐班的态度有所转变,开始理解这种工作方式对我的意义。是时候分享入职这期间的工作内容与感受。
背景
直入正题,先说职位背景。该职位的技术要求大致如下,仅做参考。
## 任职要求
1. 本科及以上学历,计算机科学、软件工程等相关专业, 硕士优先;
2. 扎实的 HTML、CSS、JavaScript 基础(vanilla) 功底 ;
3. 熟练使用 React、React Native 和 Next.js 进行前端开发
4. 了解前端性能优化技术,如代码压缩、懒加载等
5. 熟悉前端工程化工具
6. 具备良好的问题解决能力和团队协作精神
7. 熟练阅读英文技术文档
8. 有优异前端项目开发经验者优先
## 加分项:
- 贡献开源社区
- 有 AI 相关项目经验。
- 有前端性能优化和 SEO 优化经验。
- 有良好的产品思维和设计(UI/UX)意识。
- 有同理心思维。
- 具有一定的审美感。
很贴合国外主流的技术栈(至于为何,看后文便知),比较巧的是,我的 Web 全栈学习路线就是偏国外的技术栈。因此在技术栈上,这家公司是我喜欢的,恰巧又是 AI 开发,能让我尝试到一些前沿技术,也正好是想我折腾的。
求职经历
我是 Boss 直聘上找的(这里没给 boss 直聘打广告,我甚至还是第一次使用 boss 直聘),我有想过找人内推,但由于家庭因素被限定在福州这座城市,而内推的所在的城市往往都是那些一线城市,加上我的八股文和算法很不过关(我也很不情愿刷),到时候面试那关估计也不乐观。
因此就在 Boss 上碰碰运气,也顺带体验一下新人都是怎么找工作的。
从五一的时候开始准备简历和项目,在5号开始投简历,投递简历一关我是直接怼着工作经验1-3年的来投,而不是投应届或实习岗。因为我确实有一些工作经验,只不过不是正常的坐班打卡的形式,这在之前的博客中有说到。
在这期间共投了20多家,基本都是已读不回,就更别说投递简历了。后来我才了解到,原来 HR 回复消息是要花钱的,发布一个岗位也是。
唯一回复的还是我现在入职的这家,而且我还投了两份过去,一份是给 HR 的(没回),一份是给技术 leader 的(leader 回了)。
面试被鸽
可能是由于当时这个岗位急招的原因,在 boss 直聘上也没多说什么,leader 就约明早 11 点来公司现场初步面试聊天一下。这期间还发生了一个小变故,我到公司了,可联系不上面试官,打了微信电话也无果。待了10来分钟后我就走了,等了约一个小时都没信息,那我大概率是被鸽了,还不提前和我通知一声,然后在boss上留下了这句评价🥲。
初入职场,初次面试就这种情况,说真的我当时都有点心灰意冷了,我猜想是不是因为有其他合适的人选,于是就不招我了,就连信息也不给我打一个招呼,相当于把我拉黑似得。随后我就到附近的麦当劳花了 10 元的套餐安慰了一下自己,麦!
开始面试
直到到下午一点多的时候,面试官回复我说当时他们在开会,期间不让携带电子设备。早上就当一面过了,问我下午有没有时间,直接二面技术面(code test)过了就直接拿offer。
这时我才知道,原来早上也仅仅只是我的猜想,但我还是有点不想去了,心情有点不太愉悦,但想了想也懒得计较了,过去就当聊天罢了。到了下午面试问的就偏前端基础、八股文那些问题,其实我回答的巨烂,确实也没好好刷题,也不喜欢刷题,就面试了。自己写代码是由业务环境下驱动的,并从中寻求最佳实践。但好在我的技术面是比较广的,很多前沿的前端相关的工具库或多或少都使用过,也能侃侃而谈,加上个人 blog 和 github 这两个大加分项。就进入到了一个代码考核测试,不限框架,不限规则,使用公司的电脑打开 codesandbox 写一个todo list,前提是不使用任何 AI 工具。
这不正好到了我的强项,之前学某个框架的时候,不知道写什么demo,就写 todo list 来练手😂。恰好这次我就使用 next.js app router + Tailwindcss 的模版并且使用 form 标签的 action 和 use server 来实现新增功能。 能体现出我有在使用 next.js,而且用上了一些新特性,就拿到 offer 了。
听完之后是不是莫名的感觉这个 offer 拿的好像有点莫名其妙的感觉😂,不管怎么样结果是好的就行了。
不过拿到 offer 后,我并没有选择马上入职,经历了一次被鸽的经历,对该公司的印象带有一些怀疑。其次就是这是一家初创 AI 公司,规模不大,从应届生找工作的角度,第一份正式的工作的起点很关键,如果能直接进大厂,后续跳槽到其他公司大概率也不成问题。
但在当地我投递了 20 多家已读不回的情况下,加上这份已有的 offer 不等人(急招),加上我家里人给我推荐的工作内容我并不是很满意,于是思考了两天,最终还是选择入职了这家公司。
薪资
比较令我差异的是我与企业签订的直接劳动合同,可能是因为我直接投递 1-3 年的工作经验,但我此时的身份还是应届生,按理来说我应该是签订实习合同后,转正再签劳动合同,难道说我已经提前转正了?。不过也好,这样和学校的三方协议都可以不用签了,直接给劳动合同便可。
试用期 3 个月,薪资打 8 折。薪资在我当地还算 ok,但对于我而言并不理想。可能是会的比较多(全栈?全干!),加上曾经赚过比这还高上许多的薪资,从内心的角度多少是有些不平衡。不过目前还是试用期,薪资这方面后续也能再谈。
接下来尤为重要的上班体验才是让我觉得没后悔入职这家公司。
上班体验
介绍一下公司部门的办公工具
办公管理:企业微信
团队协作:Slack
任务看板:Trello
代码仓库:Github
代码托管:Vercel
视频会议:Zoom
你会发现除了企业微信,其他的应用都是国外的。怎么看都不是一家国内的企业吧,这是因为我部门的 Leader 是海外留学的,这也就不难理解工具是国外应用,技术栈选型是 React 生态了。
入职的第一周部门开了个小会,就是简单介绍了一下部门的任务职责,每个成员自我介绍。重点是提供一个优质的学习环境,像是技术书籍,电子设备,UI 模版或是技术会议的门票等费用,只要对部门有利,能提升自己,都可以找他报销。
我已经找 Leader 报销了个 magic ui pro,大约 420 块,直接找财务刷卡,付款的感觉是真爽,我是真爱了🥰。
几天后,公司来了一个阿里做 B 端低代码开发的同事,也是负责前端开发,这不,我可以间接和这个老哥那学习大厂相关经验,我还正愁着没大厂相关的经验😄。
我询问他来这家公司的原因,他说被裁了,在家接外包一年了,不稳定就准备找工作,恰好这家公司急招,于是就来了。
:::warning 补
端午节后,这位老哥提离职了,原因的话我就不具体说了,可能是因为年龄大了,不适合坐班了。虽然早有预感,但还是有点不舍。因为现在部门的前端重任都在我这了😭
:::
团建
在我入职的第一周周末 Leader 为整个部门安排团建,由于这个部门成立不到 2 周,来的都是新成员,让我们自己组个局,去外面吃个饭。
也是在团建的时候了解到同事的履历一个个都不简单,有 985 的,有海外留学的,有在阿里、网易待过的,还有我这不堪回首的经历 🤡。
后面原定在 61 安排整个公司的团建,但由于天气和周末时间去的人少的因素而取消了,这我就不多说了。
端午之后的第一个工作日的中午,补过端午节部门聚餐的,这我也不多说了。就是怎么感觉这频率有点不太对,然后实际项目产出也还停留在 Spring 1 的阶段,让我有些不自在。
福利
部门每个月都会定一个最佳员工奖,我很荣幸获得部门本月的最佳员工,也感谢部门成员的认可,奖励是 300 元奖金或一日自由假。
甚至还有一张奖状,就是这奖状怎么有点像给小学生似的。(事后我才了解到这奖状还是用打印机打印的😂)
目前我已经能感受到最大的福利就是那个 magicui 动效库的模版,当然了,这个是要给公司的官网用上的,我也是蹭公司的福,给自己的站点用上了这个动效库。
此外像节日福利,如这次端午节,就是聚餐和发粽子,这也就没什么好说的。
通勤
公司距离我租房的地方只有 2 公里,每日的通勤总时间大约 40 分钟,早上大约 8 点起床,我通常坐公交车到公司附近的早餐店吃个早饭,吃完差不多 8 点 40分~50 分。中午外卖就不说了。下午下班从公交车和走路做个选择,吃完饭回到家。
黑客松
黑客松(hackathon),也称编程马拉松比赛。我是第一次听说过这个词,Leader 给定两个选题一个是打造某市地铁智能出行,另一个是给某商场的提供贴心的购物体验,发挥自己近一个月所学的知识,去创造一个供用户使用的 AI 程序,月底交付,奖金 3000 元/小组,抽签分组。我们当时部门有个人提了一嘴,要不我们两小组自己商量一下,把奖金平分得了😂。
不过对于这个行为,我个人认为目的是为了激励员工之间协同合作,但同时也免不了技术上的内耗,毕竟这个比赛不是我们的主要工作内容。
工作内容
我想肯定有很多人对 AI 开发的刻板印象是要会大模型开发,会懂得微调,会懂得人工智能算法。这个想法也没错,但从开发 AI 应用的角度,其实蛮需要前端的,尤其是会全栈框架的前端。
这里我不得不惊叹 next.js 的生态,很多 AI 相关的例子可以直接从 Vercel 的 AI Template 下学习,预览是否有你需要的功能,Clone 到本地,然后运行项目,对某些部分进行更改。搭建 AI 应用也是异常的快。
仿 AI SDK 网站效果
Leader 下发的一个任务,入职的前两周主要让我熟悉一些怎么使用 next.js 配合 vercel 的 ai sdk 来开发 AI 应用,如怎么调用 openai 的模型,实现一个 ai chatbot。给定了一个任务就是仿造 AI SDK,由于该项目没有开源,自然就只能另辟蹊径。
首先就是仿造页面了,这个作为前端开发,实现起来也算容易,更何况这个这个页面的样式使用 Tailwindcss 编写,直接通过审查元素仿造就行了。
其次在功能实现上,ai sdk 文档都提供了非常完善的解决方案,照着文档将代码稍微改写一下便可,具体的细节就不演示了。
官网首页
两周后开始正式项目开发了,首当其冲的就是官网页。
这里当时 Leader 问我有没有用过 Gatsbyjs,要用这个框架搭建一个官网。我表明我没用过,但我提了一嘴如果要搭建偏内容向的网站,可以考虑 Astro,我愿意折腾一番(我也一直想学 Astro 的)。不过最终在开发时间和成本的商讨下还是选择使用 next.js 来搭建,leader 还顺带给我推荐了一个动效库 magicui,叫我看看里面的案例,看看能不能给官网加点动效。 之后就有了上文提到报销 magicui 的事。
Rag bot
篇幅实在有限,有关 RAG 的不做过多解释,它可以让你的 AI 应用更具有权威性,让数据的来源可靠,而非胡乱生成数据。
RAG 的基本流程就是:
- 用户输入提问
- 检索:根据用户提问对 向量数据库 进行相似性检测,查找与回答用户问题最相关的内容
- 增强:根据检索的结果,生成 prompt。 一般都会涉及 “仅依赖下述信息源来回答问题” 这种限制 llm 参考信息源的语句,来减少幻想,让回答更加聚焦
- 生成:将增强后的 prompt 传递给 llm,返回数据给用户
在这个应用开发中,借鉴了 ragbot-starter 这个开源项目,同时向量数据库选用 datastax 公司的Astra DB。
恰好在开发这个应用的期间,我也正好在学习 Langchain.js,所以在数据处理这部分有点得心应手,目前应用还只停留在处理本地文件或用户上传的文件,只需要配置各种 File Loader 便可。
使用 RN 实现 chatbot
先看 Gif 效果。
第一次用 Screen Studio,显示的不是很好,还请见谅,主要就是实现一个流式文本效果。
这里简单说下怎么实现的,就用 react-native-reusables 的模版(React native 的 Shadcn/ui) + react-native-gen-ui 实现的,不过后者的功能比较单一,后续估计要改代码了。代码就不贴了,我怕涉嫌代码泄露(其实已经泄露差不多了)。
收获
要我说最大的收获不是遇到一个氛围不错的公司,遇到一个好 leader,也不是接触 AI 开发从中学到了什么,更不是增进了我的技术栈。而是让我养成良好的习惯,开始正常一日三餐,开始作息规律,开始将工作与生活分离,身体状态也渐渐好了起来。
下图为 5 月的生物作息,基本都保持 0 点前入睡。(不过在我写这篇文章的时候已经两点了🥱)
过去几年内我的作息与饮食都非常糟糕,能明显的感觉到状态有所下滑,编写代码的效率和能力也明显不如以前,有些力不从心。今年都快过去一半了,而我仅仅完成了2篇博文的写作,文章的输出效率明显不行😮💨。
如今经历了这一个月的坐班生活,可能是因为坐班而改变,也可能是公司的氛围,不管是那种,让我跳出我原有舒适区,重新拾起对新颖事物的兴趣,重新点燃学习某个技术的热情,重新找回了自我。
结尾
在我还没找工作之前,从我几个同届毕业的同学和网友的反馈得知今年的就业环境异常险峻。不仅如此,我还在网络上看到了大量工作者对自身工作的抱怨与不满,这些现象让我在工作前让我对未来的就业前景感到了一些不安。
当我亲身入局感受一番,也不禁开始低声叹气。开始思考是什么原因导致了如今大环境不好的现象,人为的制造就业焦虑,还是当下现实本就如此。当我跳出思考,回到现实难道环境好就一定挣钱多,环境差就一定挣钱少吗?社会似乎并不是这么简单的等式。
我逐渐意识到,无论大环境如何,每个人的努力和选择仍是至关重要。在面对不确定性和挑战时,保持学习和进步的态度,以及寻找自己的核心竞争力,才是应对困境的关键。
真正的职业安全感并不完全来自于外部环境,而是来自于我们自身不断提升的能力和适应变化的灵活性。
来源:juejin.cn/post/7379446118990282789
超级火爆的前端视频方案 FFmpeg ,带你体验一下~
前言
大家好,我是林三心,用最通俗易懂的话讲最难的知识点是我的座右铭,基础是进阶的前提是我的初心~
ffmpeg
FFmpeg 是一个开源的、跨平台的多媒体框架,它可以用来录制、转换和流式传输音频和视频。它包括了一系列的库和工具,用于处理多媒体内容,比如 libavcodec(一个编解码库),libavformat(一个音视频容器格式库),libavutil(一个实用库),以及 ffmpeg 命令行工具本身。
FFmpeg 被广泛用于各种应用中,包括视频转换、视频编辑、视频压缩、直播流处理等。它支持多种音视频编解码器和容器格式,因此能够处理几乎所有类型的音视频文件。由于其功能强大和灵活性,FFmpeg 成为了许多视频相关软件和服务的底层技术基础。
很多网页都是用 ffmpeg 来进行视频切片,比如一个视频很大,如果通过一个连接去请求整个视频的话,那势必会导致加载时间过长,严重阻碍了用户观感
所以很多视频网站都会通过视频切片的方式来优化用户观感,就是一部分一部分地去加载出来,这样有利于用户的体验
安装 ffmpeg
安装包下载
首先到 ffmpeg 的安装网页:http://www.gyan.dev/ffmpeg/buil…
下载解压后将文件夹改名为 ffmpeg
环境变量配置
环境变量配置是为了能在电脑上使用 ffmpeg
命令行
体验 ffmpeg
先准备一个视频,比如我准备了一个视频,总共 300 多 M
视频切片
并在当前的目录下输入以下的命令
ffmpeg -i jhys.mkv
-c:v libx264
-c:a aac
-hls_time 60
-hls_segment_type mpegts
-hls_list_size 0
-f hls
-max_muxing_queue_size 1024
output.m3u8
接着 ffmpeg 会帮你将这个视频进行分片
直到切片步骤执行完毕,我们可以看到视频已经别切成几个片了
在这个命令中:
- -i input_video.mp4 指定了输入视频文件。
- -c:v libx264 -c:a aac 指定了视频和音频的编解码器。
- -hls_time 10 指定了每个 M3U8 片段的时长,单位为秒。在这里,每个片段的时长设置为 10 秒。
- -hls_segment_type mpegts 指定了 M3U8 片段的类型为 MPEG-TS。
- -hls_list_size 0 设置 M3U8 文件中包含的最大片段数。这里设置为 0 表示没有限制。
- -f hls 指定了输出格式为 HLS。
- -max_muxing_queue_size 1024 设置了最大复用队列大小,以确保输出不会超过指定大小。
- 最后输出的文件为 output.m3u8
视频播放
创建一个简单的前端项目
可以看到浏览器会加载所有的视频切片
来源:juejin.cn/post/7361998447908864011
写给Java开发的16个小建议
前言
开发过程中其实有很多小细节要去注意,只有不断去抠细节,写出精益求精的代码,从量变中收获质变。
技术的进步并非一蹴而就,而是通过无数次的量变,才能引发质的飞跃。我们始终坚信,只有对每一个细节保持敏锐的触觉,才能绽放出完美的技术之花。
从一行行代码中,我们品味到了追求卓越的滋味。每一个小小的优化,每一个微妙的改进,都是我们追求技艺的印记。我们知道,只有更多的关注细节,才能真正理解技术的本质,洞察其中的玄机。正是在对细节的把握中,我们得以成就更好的技术人生。
耐心看完,你一定会有所收获。
补充
20230928
针对评论区指出的第14条示例的问题,现已修正。
原来的示例贴在这里,接受大家的批评:
1. 代码风格一致性:
代码风格一致性可以提高代码的可读性和可维护性。例如,使用Java编程中普遍遵循的命名约定(驼峰命名法),使代码更易于理解。
// 不好的代码风格
int g = 10;
String S = "Hello";
// 好的代码风格
int count = 10;
String greeting = "Hello";
2. 使用合适的数据结构和集合:
选择适当的数据结构和集合类可以改进代码的性能和可读性。例如,使用HashSet来存储唯一的元素。
// 不好的例子 - 使用ArrayList存储唯一元素
List<Integer> list = new ArrayList<>();
list.add(1);
list.add(2);
list.add(1); // 重复元素
// 好的例子 - 使用HashSet存储唯一元素
Set<Integer> set = new HashSet<>();
set.add(1);
set.add(2);
set.add(1); // 自动忽略重复元素
3. 避免使用魔法数值:
使用常量或枚举来代替魔法数值可以提高代码的可维护性和易读性。
// 不好的例子 - 魔法数值硬编码
if (status == 1) {
// 执行某些操作
}
// 好的例子 - 使用常量代替魔法数值
final int STATUS_ACTIVE = 1;
if (status == STATUS_ACTIVE) {
// 执行某些操作
}
4. 异常处理:
正确处理异常有助于代码的健壮性和容错性,避免不必要的try-catch块可以提高代码性能。
// 不好的例子 - 捕获所有异常,没有具体处理
try {
// 一些可能抛出异常的操作
} catch (Exception e) {
// 空的异常处理块
}
// 好的例子 - 捕获并处理特定异常,或向上抛出
try {
// 一些可能抛出异常的操作
} catch (FileNotFoundException e) {
// 处理文件未找到异常
} catch (IOException e) {
// 处理其他IO异常
}
5. 及时关闭资源:
使用完资源后,及时关闭它们可以避免资源泄漏,特别是对于文件流、数据库连接等资源。
更好的处理方式参见第16条,搭配try-with-resources
食用最佳
// 不好的例子 - 未及时关闭数据库连接
Connection conn = null;
Statement stmt = null;
try {
conn = DriverManager.getConnection(DB_URL, DB_USER, DB_PASSWORD);
stmt = conn.createStatement();
// 执行数据库查询操作
} catch (SQLException e) {
e.printStackTrace();
} finally {
// 数据库连接未关闭
}
// 好的例子 - 使用try-with-resources确保资源及时关闭,避免了数据库连接资源泄漏的问题
try (Connection conn = DriverManager.getConnection(DB_URL, DB_USER, DB_PASSWORD);
Statement stmt = conn.createStatement()) {
// 执行数据库查询操作
} catch (SQLException e) {
e.printStackTrace();
}
6. 避免过度使用全局变量:
过度使用全局变量容易引发意外的副作用和不可预测的结果,建议尽量避免使用全局变量。
// 不好的例子 - 过度使用全局变量
public class MyClass {
private int count;
// 省略其他代码
}
// 好的例子 - 使用局部变量或实例变量
public class MyClass {
public void someMethod() {
int count = 0;
// 省略其他代码
}
}
7. 避免不必要的对象创建:
避免在循环或频繁调用的方法中创建不必要的对象,可以使用对象池、StringBuilder等技术。
// 不好的例子 - 频繁调用方法创建不必要的对象
public String formatData(int year, int month, int day) {
String formattedDate = String.format("%d-d-d", year, month, day); // 每次调用方法都会创建新的String对象
return formattedDate;
}
// 好的例子 - 避免频繁调用方法创建不必要的对象
private static final String DATE_FORMAT = "%d-d-d";
public String formatData(int year, int month, int day) {
return String.format(DATE_FORMAT, year, month, day); // 重复使用同一个String对象
}
8. 避免使用不必要的装箱和拆箱:
避免频繁地在基本类型和其对应的包装类型之间进行转换,可以提高代码的性能和效率。
// 不好的例子
Integer num = 10; // 不好的例子,自动装箱
int result = num + 5; // 不好的例子,自动拆箱
// 好的例子 - 避免装箱和拆箱
int num = 10; // 好的例子,使用基本类型
int result = num + 5; // 好的例子,避免装箱和拆箱
9. 使用foreach循环遍历集合:
使用foreach循环可以简化集合的遍历,并提高代码的可读性。
// 不好的例子 - 可读性不强,并且增加了方法调用的开销
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
for (int i = 0; i < names.size(); i++) {
System.out.println(names.get(i)); // 不好的例子
}
// 好的例子 - 更加简洁,可读性更好,性能上也更优
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
for (String name : names) {
System.out.println(name); // 好的例子
}
10. 使用StringBuilder或StringBuffer拼接大量字符串:
在循环中拼接大量字符串时,使用StringBuilder或StringBuffer可以避免产生大量临时对象,提高性能。
// 不好的例子 - 每次循环都产生新的字符串对象
String result = "";
for (int i = 0; i < 1000; i++) {
result += "Number " + i + ", ";
}
// 好的例子 - StringBuilder不会产生大量临时对象
StringBuilder result = new StringBuilder();
for (int i = 0; i < 1000; i++) {
result.append("Number ").append(i).append(", ");
}
11. 使用equals方法比较对象的内容:
老生常谈的问题,在比较对象的内容时,使用equals方法而不是==操作符,确保正确比较对象的内容。
// 不好的例子
String name1 = "Alice";
String name2 = new String("Alice");
if (name1 == name2) {
// 不好的例子,使用==比较对象的引用,而非内容
}
// 好的例子
String name1 = "Alice";
String name2 = new String("Alice");
if (name1.equals(name2)) {
// 好的例子,使用equals比较对象的内容
}
12. 避免使用多个连续的空格或制表符:
多个连续的空格或制表符会使代码看起来杂乱不堪,建议使用合适的缩进和空格,保持代码的清晰可读。
// 不好的例子
int a = 10; // 不好的例子,多个连续的空格和制表符
String name = "John"; // 不好的例子,多个连续的空格和制表符
// 好的例子
int a = 10; // 好的例子,适当的缩进和空格
String name = "John"; // 好的例子,适当的缩进和空格
13. 使用日志框架记录日志:
在代码中使用日志框架(如Log4j、SLF4J)来记录日志,而不是直接使用System.out.println(),可以更灵活地管理日志输出和级别。
// 不好的例子:
System.out.println("Error occurred"); // 不好的例子,直接输出日志到控制台
// 好的例子:
logger.error("Error occurred"); // 好的例子,使用日志框架记录日志
14. 避免在循环中创建对象:
在循环中频繁地创建对象会导致大量的内存分配和垃圾回收,影响性能。尽量在循环外部创建对象,或使用对象池来复用对象,从而减少对象的创建和销毁开销。
// 不好的例子 - 在循环过程中频繁地创建和销毁对象,增加了垃圾回收的负担
for (int i = 0; i < 1000; i++) {
// 在每次循环迭代中创建新的对象,增加内存分配和垃圾回收的开销
Person person = new Person("John", 30);
System.out.println("Name: " + person.getName() + ", Age: " + person.getAge());
}
// 好的例子 - 在循环外部创建对象,减少内存分配和垃圾回收的开销
Person person = new Person("John", 30);
for (int i = 0; i < 1000; i++) {
System.out.println("Name: " + person.getName() + ", Age: " + person.getAge());
// 可以根据需要修改 person 对象的属性
person.setName("Alice");
person.setAge(25);
}
15. 使用枚举替代常量:
这条其实和第3条一个道理,使用枚举可以更清晰地表示一组相关的常量,并且能够提供更多的类型安全性和功能性。
// 不好的例子 - 使用常量表示颜色
public static final int RED = 1;
public static final int GREEN = 2;
public static final int BLUE = 3;
// 好的例子 - 使用枚举表示颜色
public enum Color {
RED, GREEN, BLUE
}
16. 使用try-with-resources语句:
在处理需要关闭的资源(如文件、数据库连接等)时,使用try-with-resources语句可以自动关闭资源,避免资源泄漏。
// 不好的例子 - 没有使用try-with-resources
FileReader reader = null;
try {
reader = new FileReader("file.txt");
// 执行一些操作
} catch (IOException e) {
// 处理异常
} finally {
if (reader != null) {
try {
reader.close();
} catch (IOException e) {
// 处理关闭异常
}
}
}
// 好的例子 - 使用try-with-resources自动关闭资源
try (FileReader reader = new FileReader("file.txt")) {
// 执行一些操作
} catch (IOException e) {
// 处理异常
}
总结
这16个小建议,希望对你有所帮助。
技术的路上充满挑战,但要相信,把细节搞好,技术会越来越牛。小小的优化,微小的改进,都能让我们的代码变得更好。
时间飞逝,我们要不断学习,不断努力。每个技术小突破都是我们不懈努力的成果。要用心倾听,用心琢磨,这样才能在技术的道路上越走越远。
从每一次写代码的过程中,我们收获更多。我们要踏实做好每个细节。在代码的世界里,细节是我们的罗盘。
坚持初心,不忘初心!
来源:juejin.cn/post/7261835383201726523
AI 时代计算机专业会涨薪还是降薪
此前,在 2024 年世界政府峰会,英伟达 CEO 黄仁勋在被问及“如果站在科技的前沿,人们到底应该学习什么”时表示:“学计算机的时代过去了,生命科学是未来”。老黄的这个观点再结合现在 AI 的能力越来越强,这让报考计算机专业的考生会担心:未来 AI 时代计算机专业会涨薪还是降薪?
未来的事情其实很难预测,我们只能根据一些历史经验来推导一下。
为什么计算机专业相对薪资较高?
通常员工的薪资由两个主要因素决定:1. 创造的价值;2. 技能的稀缺性;
像 Google、Meta、OpenAI 这些公司的程序员工资高,一方面他们创造了很大价值,另一方面他们所做的事情需要一定的技能,而掌握这些技能的人才相对较少。
AI 会让计算机专业薪资更高还是更低?
计算机专业从就业来看是比较广泛的,不仅仅是程序员,还有数据科学、人工智能、QA、产品设计、项目管理、开发管理等等方向,所以不能简单的谈 AI 对计算机专业对薪资的影响,而是对可能的岗位的影响。
对于技术岗位来说 AI 会创造更大价值,管理岗也许会贬值
从创造的价值来看,有了 AI 的加持,可以预见对于技术性的岗位,创造的价值都能更大,比如程序员借助 GitHub Copilot 辅助,生成代码效率会更高;借助 AI,QA 可以更多的让测试自动化起来;产品经理借助 AI,节约了大量写产品设计文档的时间。
但对于一些偏管理的岗位来说,无论是项目管理还是人员管理,在 AI 时代创造的价值可能反而会降低。一方面软件工程方面的进步,像 Scrum 这样的开发流程,项目经理的作用有限;另一方面随着程序开发效率的提升,团队会趋向小型化,有很多善用 AI 的超级个体,沟通成本会大幅下降,不需要太多的管理者。
高级 AI 开发、产品设计技能会更稀缺、基础编程和测试岗位会减少
稀缺性体现在两个方面:1. 这个技能掌握的难度;2. 是不是供小于求
按照 AI 能力的发展趋势来看,目前 AI 在编程还只能是 Copilot(副驾驶)这样的辅助角色,但即使如此,也能普遍提升 20% 左右的效率;几年过后可能就到 50% 了,直到最终替代人类编程。
这也意味着,对于基础编程和测试,掌握的难度会大幅降低,随着 AI 和自动化工具的进步,一些基本的编程任务和软件测试可以通过自动化工具来完成,岗位会减少。
短期来说它还不能马上替代的是:集成 AI 的产品设计、对需求进行分析拆解、复杂项目的架构设计、对复杂项目进行维护这些相对复杂的技能。也就是高级的编程、架构师、产品设计这些岗位,掌握的难度高,不容易被替代。
那么供求关系如何呢?未来 AI 时代,计算机专业相关的岗位是更多了还是更少了?
从去年开始,无论是应用还是服务,都在集成 AI,像苹果和微软,甚至都在操作系统层面为 AI 进行了重构,相应的,这会创造很多新的开发需求,有些类似于当年移动互联网,各个应用、服务都要提供移动版本,产生了很多岗位需求。可以预见中短期,未来 10-20 年以内,主要的服务和应用,都会集成 AI,并且随着 AI 能力的增强,持续的升级完善。这样的升级,会先从科技公司开始,然后再延伸到各个行业。
所以未来 10-20 年,我预计计算机岗位需求还是会和现在差不多,但是技能要求会有些变化,不再纯粹的是传统的编程,还需要对使用 AI、集成 AI 相关的技能要有掌握。这方面对于新从业者还有优势,没有历史包袱,可以很快适应,相反一些不愿意学习新技能的计算机专业从业者,反而学习适应的会差一些。
如果整体供求关系和现在差不多,而 AI 能创造更大价值,未来计算机专业薪资应该会更高,但前提是你得是属于掌握了 AI 技能的人才。
怎么可以让自己赶上 AI 时代的红利也能拿高薪?
不建议你只是为了高薪选择计算机专业
首先不建议你只是为了高薪选择计算机专业,这个行业看起来光鲜其实背后也很残酷,比如加班严重、年龄大了可能会被优化、新技术层出不穷。我见过很多因为高薪选择这个行业,但并喜欢,所以并不会花多少时间去学习去精进自己,几年后再找工作就会比较难。
建议多积累相关项目经验
然后建议多积累项目经验。计算机专业,最终都是要通过软件项目去创造出产品,进而通过产品创造价值。所以想拿高薪,一个基本前提就是你掌握了构建软件项目的部分关键技能,比如说编程、产品设计、测试等。当然如果你想当独立开发者,自己去产品,那要求会更高,除了掌握计算机专业技能,还得要一些营销的能力。
在 AI 时代,找工作对于新人不一定更友好,因为基础岗位很多会被 AI 代替,除了大公司,企业会倾向于招有经验的,这就意味着你能自己先积累经验,让自己更有竞争力。
要积累项目经验,可以参与开源项目,可以做一点给自己或者亲戚朋友用的小产品,可以去公司实习或者找一份相关的工作。
如果不是计算机专业也有机会
无论是不是 AI 时代,对于热爱计算机但是不是计算机专业的人来说,一直都有机会,见过太多非计算机专业自学成才的例子。
AI 时代,学习对新人来说却是要容易很多,比如学习编程,以前一个很大的门槛是没有老师指导、遇到问题没有人帮忙解决,而现在像 ChatGPT、Claude 这些大语言模型,可以随时随地咨询技术问题,遇到技术上的故障也可以帮助解决,让学习比以前容易很多。
即使是计算机专业、已经有几年工作经验的,在 AI 时代也一样需要再学习,因为在 AI 时代,对技能的要求会发生变化,比如你能借助 AI 提升开发效率,不然可能会被那些善用 AI 的同事卷下去;比如你得有能力帮助公司构建出 AI 时代的产品。
最后
如果你报考的是计算机专业,即使未来 AI 时代,也不必担心薪水下降;但未来找工作对新手不一定友好,需要在毕业前通过实习和自学多积累项目经验和 AI 相关技能。
如果你没能报考计算机专业,但是热爱计算机专业,也一样有机会,自学成才的例子很多,尤其在 AI 时代,学习的门槛会更低,花点功夫可以比科班学的还好。
祝今年的考生们都选到自己心仪的专业,毕业拿高薪。
来源:juejin.cn/post/7386290071189635083
2024转行前端第6年
前言
上一篇《外行转码农,焦虑到躺平》分享我的从业经历收到大家很多关注,这里继续分享一下我的生活
我是16毕业在厂里干了两年,18年7月转行前端开发。在后面4年,也就是18~22年是很投入的,这几年上完班回到家基本也在学习,学技术考证,可谓说没有自己的生活,这几年是我技术进步最快的几年,也是很快乐的几年,大家懂得全身投入做一件事的快乐吧。
近几年,感觉自己很难突破自己的瓶颈了,慢慢将重心转为生活。也要声明一下,这两年我可不是完全躺平,对应工作我还是很负责的去做,只是工作不是我的全部了。分享一下这几年的收获吧
近几年收获
2019
- 中级软件设计师
2020
- 基金从业
- 证券从业
2021
- pmp
- 彩铅绘画
2022
- 计算机专业课程系统学习
- 背了5000单词
2023
- 算法 150道
- 浅浅玩了区块链技术、android、ios开发
- 中级经济师
2024
- 缝纫
- 计划考 中级会计
看书
我之前基本不怎么看书的,后面慢慢与书籍成为了朋友。这几年读了上百本书,主要涉及:哲学、心理学、经济学、文学、历史、政治。这些可是我之前不怎么关注的,曾经的我很封闭,对全球地理概念都不清楚那种。老爸也说我现在知识面扩展了很多。
醒悟
感觉28岁是我的醒悟元年,告别过往原谅也放过自己,生活从新开始吧。
之前的我,对于朋友没有秘密,很依赖别人,后面慢慢懂得自己的痛苦只能自己慢慢消化,我学会了用 “行为认知疗法”治愈自己,很推荐大家去了解。
分享
我把这些放到b站了,大概感兴趣可以去看哦,搜索 liucheng58
读书笔记
最近不开心看的两本书
-《蛤蟆先生去看心理医生》
“PLOM代表了四个英文单词,意思就是‘可怜弱小的我呀’。这个游戏你每局都赢了,也可以说是输了,这取决于你自己的看法。”
把我们的人生当作一次游戏,跳出自我,静静俯视它的发展。个人在这场游戏中,可能就是游戏背景中一颗小小草,在画面中一闪而过,没有人注意到它刚被怪兽踩了一脚。
而自我的人一直盯着想着那颗小草,而忽略了整个游戏的乐趣。世界上有很多有趣的事情,不要总是计较一时的得失——致自己。
“‘成人自我状态’指我们用理性而不是情绪化的方式来行事。它让我们能应对此时此地正在发生的现实状况。”苍鹭回答。“‘成人自我状态’指我们用理性而不是情绪化的方式来行事。它让我们能应对此时此地正在发生的现实状况。”苍鹭回答。
我这两年最大的三个变化
1、遇事不发脾气,冷静思考当下如何解决;
2、如果没法推脱,就怀着快乐的心态去做吧,和那个任性的小孩好好商量,讲解做这件事的好处,不要抱怨的把事情做了,费力不讨好;
3、聆听他人,角度不同想法不同,没有谁对谁错。
-《幸福之路》
一个人应该认真建立自己的意念,让它与理性相信的方向一致,不要让任何非理性的信仰不经过检视就占据自己的心灵。
回头看老一辈很多坚信的观念我们都无法接受,这就是他们小时候接受到的信息刺激。一个人改变自己的观念很难,改变既有的思维模式很难,但是如果能打破它们,我们就是放过了自己。之前看到做法就是每日自检,找出困扰你的想法,分解并破解。
史铁生说他只能躺在床上的时候他无比怀念坐轮椅的时候,只有体验人生角色的酸痛苦辣,才能做到感同身受,仅仅看书冥想很多东西无法吸收,我想这也是成佛要经历81难吧
一个把注意力转向自我内心的人,会找不到任何值得关注的事;而那些对外界事物兴致勃勃的人,当他偶尔把注意力转移到自己的灵魂,他会发现所有以前采集、累积的各式各样有趣的材料,都已经被转换重组成美丽且有价值的东西
我的理解是:不要太在乎一时的得失、不要经常悔恨自己的错误,其实命运早已注定。我们应该跳出对自身的注视,面临抉择充分收集信息,然后一条路走到黑,然后让它自由发展。其他时候我的精力要跳出自身,去体验大好河山,体验先人深邃精神。
所有需要技巧的工作都有带给人乐趣的共性,只要这技巧有价值和无限的进步空间
就像程序员很多人最开始是喜欢而从事,写代码是个建设的过程,看着代码运行成一个个应用。但是工作3-5年后,很熟练了,很难有进步空间,很多人就怠慢了。最重要的是很多人意识到,再牛赚的钱也是很有限的。
缝纫
最近朋友圈分享自己第一件缝纫作品 ,收到朋友很多赞赏,我感觉我有点飘了。这里我再分享一次
绘画
我五音不全,但是很喜欢绘画,21年报了个兴趣班,画了一些彩铅,这两年没画了。
买了平板、画笔和procreate软件,后面打算学习一下平板绘画,感觉还是电子的容易保存。后面如果开动了,再分享我的成果。
猫猫
2020年5月养了第一只中华田园猫,后面养过5只中华田园猫:莞莞、果果、蕉蕉;救助过两只流浪猫:小黄、小白;
小黄是在地下车库别人车底,小白是在公园草丛,两只猫猫都是连续叫了好几天,又怕人那种小猫;后面我蹲了几小时,用捕猫笼给抓住的;它们都是又瘦又脏,养肥了洗了澡,教了它们使用猫砂,后面给它们找了人家。
做饭
我现在每天都自己带饭,我做饭的效率很高,周末将肉切好,炒两个菜一般半小时可以搞定。
面食
买了蒸锅、烤箱、电饼铛,做了包子、面包和饼饼,这些也是我很爱吃的
最后
我的人生有很多遗憾,遗憾成熟太晚、遗憾高考失利没复读,但是现在也放下了,我总要体会我人生角色的起起伏伏,痛苦使我成长。
我的执行力还是差了点,很多想做的都搁置了。自己习惯依赖,随波逐流,总希望别人给自己人生一个规划,这几年也是一个去依赖的过程。
我之前对自己要求很高,现在功利心下降了,做的事情都是随性而行,才发现自己兴趣很多。
我买了中国三维地图和世界三维地图,曾经很想抛开现有的去外面看看,我爸说我幼稚不成熟,这几年这个想法也放弃了,不知道是成熟了还是向生活妥协了。
接下来,我希望自己用一个平静的心态度过此生,不以物喜不以己悲。
希望被大家温柔以待,不要中伤我。
来源:juejin.cn/post/7349931303787839499
外行转码农,焦虑到躺平
下一篇《2024转行前端第6年》展示我躺平后捣鼓的东西
介绍自己
本人女,16年本科毕业,学的机械自动化专业,和大部分人一样,选专业的时候是拍大腿决定的。
恍恍惚惚度过大学四年,考研时心比天高选了本专业top5学校,考研失败,又不愿调剂,然后就参加校招大军。可能外貌+绩点优势,很顺利拿到了很多工厂offer,然后欢欢喜喜拖箱带桶进厂。
每天两点一线生活,住宿吃饭娱乐全在厂区,工资很低但是也没啥消费,住宿吃饭免费、四套厂服覆盖春夏秋冬。
我的岗位是 inplan软件维护 岗位,属于生产资料处理部门,在我来之前6年该岗位一直只有我师傅一个人,岗位主要是二次开发一款外购的软件,软件提供的api是基于perl语言,现在很少有人听过这个perl吧。该岗位可能是无数人眼里的神仙岗位吧,我在这呆了快两年,硬是没写过一段代码...
inplan软件维护 岗位的诞生就是我的师傅开创的,他原本只是负责生产资料处理,当大家只顾着用软件时,他翻到了说明书上的API一栏,然后写了一段代码,将大家每日手工一顿操作的事情用一个脚本解决了,此后更是停不下来,将部门各种excel数据处理也写成了脚本,引起了部门经理的注意,然后就设定了该岗位。
然而,将我一个对部门工作都不了解的新人丢在这个岗位,可想我的迷茫。开始半年师傅给我一本厚厚的《perl入门到精通》英文书籍,让我先学会 perl 语言。(ps:当时公司网络不连外网,而我也没有上网查资料的习惯,甚至那时候对电脑操作都不熟练...泪目)
师傅还是心地很善良很单纯的人,他隔一段时间会检查我的学习进度,然而当他激情澎拜给我讲着代码时,我竟控制不住打起了瞌睡,然后他就不管我了~~此后我便成了部门透明人物,要是一直透明下去就好了。我懒散的工作态度引起了部门主管的关注,于是我成了他重点关注的对象,我的工位更是移到了他身后~~这便是我的噩梦,一不小心神游时,主管的脸不知啥时凑到了我的电脑屏幕上~~~😱
偶然发现我的师傅在学习 php+html+css+js,他打算给部门构建一个网站,传统的脚本语言还是太简陋了。我在网上翻到了 w3scool离线文档 ,这一下子打开了我的 代码人生。后面我的师傅跳槽了,我在厂里呆了两年觉得什么都没学到,也考虑跳槽了。
后面的经历也很魔幻,误打误撞成为了一名前端开发工程师。此时是2018年,算是前端的鼎盛之年吧,各种新框架 vue/react/angular 都火起来了,各种网站/手机端应用如雨后春笋。我的前端之路还算顺利吧,下面讲讲我的经验吧
如何入门
对于外行转码农还是有一定成本的,省心的方式就是报班吧,但是个人觉得不省钱呀。培训班快则3个月,多的几年,不仅要交上万的培训费用,这段时间0收入,对于家境一般的同学,个人不建议报班。
但是现在市场环境不好,企业对你的容忍度不像之前那么高。之前几年行业缺人,身边很多只懂皮毛的人都可以进入,很多人在岗位半年也只能写出简单的页面,逻辑复杂一点就搞不定~~即使被裁了,也可以快速找到下家。这样的日子应该一去不复返了,所以我们还是要具备的实力,企业不是做慈善的,我们入职后还是要对的起自己的一份工资。
讲讲具体怎么入门吧
看视频:
b站上有很多很多免费的视频,空闲之余少刷点段子,去看看这些视频。不要问我看哪个,点击量大的就进去看看,看看过来人的经验,看看对这个行业的介绍。提高你的信息量,普通人的差距最大就在信息量的多少
还是看视频:
找一个系统的课程,系统的学习 html+css+js+vue/react,我们要动手写一些demo出来。可以找一些优秀的项目,自己先根据它的效果自己实现,但后对着源码看看自己的局限,去提升。
做笔记:
对于新人来说,就是看了视频感觉自己会了,但是写起来很是费力。为啥呢?因为你不知道也记不住有哪些api,所以我们在看视频学习中,有不知道的语法就记下来。
我之前的经验就是手动抄写,最初几年抄了8个笔记本,但是后面觉得不是很方便,因为笔记没有归纳,后续整理笔记困难,所以我们完全可以用电子档的形式,这方便后面的归纳修改。
回顾:
我们的笔记做了就要经常的翻阅,温故而知新,经常翻阅我们的笔记,经常去总结,突然有一天你的思维就上升了一个高度。
- 慢慢你发现写代码就是不停调用api的过程
- 慢慢你会发现程序里的美感,一个设计模式、一种新思维。我身边很多人都曾经深深沉迷过写代码,那种成就感带来的心流,这是物质享受带来不了的
输出:
就是写文章啦,写文章让我们总结回顾知识点,发现知识的盲区,在这个过程中进行了深度思考。更重要的是,对于不严谨的同学来说,研究一个知识点很容易浅尝则止,写文章驱动自己去更深层系统挖掘。不管对于刚入行的还是资深人士,我觉得输出都是很重要的。
持续提升
先谈谈学历歧视吧,现在很多大厂招聘基本条件就是211、985,对此很是无奈,但是我内心还是认可这种要求的,我对身边的本科985是由衷的佩服的。我觉得他们高考能考上985,身上都是有过人之处的,学习能力差不了。
见过很多工作多年的程序员,但是他们的编码能力无法描述,不管是逻辑能力、代码习惯、责任感都是很差的,写代码完全是应付式的,他们开发的代码如同屎山。额,但是我们也不要一味贬低他人,后面我也学会了尊重每一个人,每个人擅长的东西不一样,他可能不擅长写代码,但是可能他乐观的心态是很多人不及的、可能他十分擅长交际...
但是可能的话,我们还是要不断提高代码素养
- 广度:我们实践中,很多场景没遇到,但是我们要提前去了解,不要等需要用、出了问题才去研究。我们要具备一定的知识面覆盖,机会是给有准备的人的。
- 深度:对于现在面试动不动问源码的情况,很多人是深恶痛绝的,曾经我也是,但是当我沉下心去研究的时候,才发现这是有道理的。阅读源码不仅挺高知识的广度,更多让我们了解代码的美感
具体咋做呢,我觉得几下几点吧。(ps:我自己也做的不好,道理都懂,很难做到优秀呀~~~)
- 扩展广度:抽空多看看别人的文章,留意行业前沿技术。对于我们前端同学,我觉得对整个web开发的架构都要了解,后端同学的mvc/高并发/数据库调优啥的,运维同学的服务器/容器/流水线啥的都要有一定的了解,这样可以方便的与他们协作
- 提升深度:首先半路出家的同学,前几年不要松懈,计算机相关知识《操作系统》《计算机网络》《计算机组成原理》《数据结构》《编译原理》还是要恶补一下,这是最基础的。然后我们列出自己想要深入研究的知识点,比如vue/react源码、编译器、低代码、前端调试啥啥的,然后就沉下心去研究吧。
职业规划
现在整个大环境不好了,程序员行业亦是如此,身边很多人曾经的模式就是不停的卷,卷去大厂,跳一跳年薪涨50%不是梦,然而现在不同了。寒风凌凌,大家只想保住自己的饭碗(ps:不同层次情况不同呀,很多大厂的同学身边的同事还是整天打了鸡血一般)
曾经我满心只有工作,不停的卷,背面经刷算法。22年下半年市场明显冷下来,大厂面试机会都没有了,年过30,对大厂的执念慢慢放下。
我慢慢承认并接受了自己的平庸,然后慢慢意识到,工作只是生活的一部分。不一定要担任ceo,才算走上人生巅峰。最近几年,我爱上了读书,以前只觉得学理工科还是实用的,后面慢慢发现每个行业有它的美感~
最后引用最近的读书笔记结尾吧,大家好好体会一下论语的“知天命”一词,想通了就不容易焦虑了~~~
自由就是 坦然面对生活,看清了世界的真相依然热爱生活。宠辱不惊,闲看庭前花开花落。去留无意,漫随天外云卷云舒。
来源:juejin.cn/post/7343138429860347945
已老实!公司的代码再也不敢乱改了!
开篇
大家好,我是聪。想必对于很多初入职场,心中怀着无限激情的兄弟们,对于接手老代码都会有很多愤慨,碰到同事的代码十分丑陋应不应该改!我也是这样,我相信有很多人同样有跟我一样的经历。满打满算实习 + 正式工作,我也敲了两年多代码,我今天来说说我自己的看法吧。
亲身经历
我第一次接手老代码的时候,映入我眼帘的就是侧边栏满页的黄色提示以及代码下面的众多黄色波浪线,以及提交代码时的提示,如下图:
我内心 OS:
1)大干一场,把黄色波浪线全干掉!
2)同事这写的也太不优雅了吧,改成我这样!
3)这代码怎么也没格式化,我来 Ctrl + Alt + L 格式化一波!
已老实,求放过
干掉黄色波浪线,将代码改 ”优雅“ 结局如下:
1)不声不吭动了同事代码,换来同事怒骂,毕竟人家逻辑写好,然后你按你想法来搞,也没有跟人家商量。
2)后续领导找你加需求,你发现原来之前的代码有妙用,你悔不当初,被扣绩效。
3)格式化后,在项目修改记录上面是你的修改,这代码出问题,负责人先来找你。
说说我的看法
代码能跑不要动
前几日我要在老项目中,新增一点小功能,在新增完功能后,我扫了一眼代码,发现有几处逻辑根本不会执行,比如:抛异常后,执行删除操作类似,我也不会去义愤填膺的去干掉这块代码,毕竟我想到一点!项目都跑七八年没出问题了,能跑就别动它。
代码强迫症不要强加于别人
前几日在某金看见了这样一个沸点:
这样的事情其实在小公司经常发生,你觉得它写的不优雅,封装少,可能是别人也有别人的难处,至少不能将自己想法强加于别人,比如领导突然来一个需求,跟你说今天你得完成,然后第二天这个需求,你要这样改、再给我加点新需求上去,你能想到的封装其实只是你冷静下来,而且没有近乎疯狂的迭代需求得到的想法,当你每天都要在原代码上面疯狂按照领导要求修改,可能你会有自己的看法。
新增代码,尽量不影响以前逻辑
新增代码的时候,尽量按照以前的规则逻辑来进行,比如我改的一个老项目,使用的公司自己写的一套 SQL 处理逻辑,我总不能说不行!我用不惯这个!我要用 MyBatis!!!!那真的直接被 T 出门口了。
尊重他人代码风格
每个人的代码风格都有所不同,这个很正常,不同厨师的老师教法不一样,做出的味道还不一样呢,没有最好的代码,只有更适合的代码,刚好我就有这样的例子:
我注入 Spring 依赖喜欢用构造注入、用 Lombook 的注解 @RequiredArgsConstructor 注入,我同事喜欢 @Autowired ,我能说他不准用这个吗,这个是人家的习惯,虽然 Spring 也不推荐使用这个,但改不改这个都不会影响公司收益,反而能少一件事情,促进同事友好关系,哈哈哈哈,我是这样认为的。
处理好同事之间的关系
哈哈哈哈这个真的就是人情事故了,你换位想象一下,如果你写的幸幸苦苦的代码,新来的同事或者实习生,来批评你的代码不规范,要 Diss 你,偷偷改你代码,就算他说的超级对,你心里都十分不好受,会想一万个理由去反驳。
我一般如果需求需要改动同事的代码,我会先虚心的向同事请求,xx哥,我这个需求要改动你这边的代码来配合一下,你来帮我一起看看,你这部分的代码这样改合理吗,或者你自己改下你自己的部分,然后我合并一下~ 谢谢 xx哥。
最后
希望大家都能遇到与自己志同道合的同事一起快乐的开发~,我经历的可能没有大家伙的多,大家如果还碰见因为改老代码发生的惨案,欢迎大家一起来分享,我也来跟大佬们学习一下~
来源:juejin.cn/post/7383342927508799539
离职前同事将下载大文件功能封装成了npm包,赚了145块钱
这几天有个同事离职了,本来那是last day,还有半个小时,但他还在那里勤勤恳恳的写着代码。我很好奇,就问:老张,你咋还不做好准备,都要撤了,还奋笔疾书呐。他说:等会儿和你说。
等了半个小时,他说:走,一起下班。我跟你说个好东西。
我说:好的。
老张一边走一边跟我说:公司的下载大文件代码不好。
我说哪里不好了,不是都用了很久了。
他说,那些代码,每次项目需要的时候,还得拷过来拷过去的,有时候拷着拷着就拷丢了,还得去网上现找代码,很不好。
我问:那然后呢?
他说:我这两天把这段代码封装了一下,封装成了npm包。以后,大家就直接调用就可以了,不用重复造轮子,或者担心轮子走丢了。我说那太好了。
他说:我把这个npm包给你,以后你就说自己写的,下个季度晋升的时候,你就说,为公司解决了代码冗余,重复造轮子的问题,而且让下载大文件功能更加便捷,节省开发时间,提升了开发效率。
我说:那怎么好啊,得请你吃个饭啊,你都要走了。不过,你先跟我说说,怎么用这个npm包啊。
下载大文件版
比如我们现有的成形的项目,大家使用axios或者fetch,一定在项目里已经封装好了请求,所以直接调用服务端给的请求地址,获取到blob数据流信息就可以了。但是拿到blob数据流以后,这段代码得四处拷贝,重复造轮子,很不好。所以可以这样使用,高效、便捷。
下载js-tool-big-box工具包
执行安装命令
npm install js-tool-big-box
项目中引入ajaxBox对象,下载文件的公共方法,downFile 在这个对象下面。
import { ajaxBox } from 'js-tool-big-box';
调用实现下载
比如你在项目中已经封装好了axios或者fetch的实现,那么只需要正常发送请求,然后调用方法即可,使用非常方便。
fetch('https://test.aaa.com/getPDF').then(res => res.blob()).then((blob) => {
ajaxBox.downFile(blob, '优乐的美.pdf');
});
在这个方法中,你只要将接口返回的信息流转为blob流,然后传入 downFile 方法中,然后再传入一个参数做为下载后的文件名即可。
fetch请求 + 下载实现版本
我又问他,的确是很多项目里,请求都已经封装好了。但我之前做过一个项目,功能很简单,大部分都是展示类的。但产品在一个详情页,让我加下载功能,我的请求并没有做封装。
然后呢,服务端告诉我,这个下载文件的接口,还需要传入参数params,需要传入headers,你这个方法就不适用了吧?
他想了一下,说。也是可以的,你听我说啊。
定义请求参数们
const url = 'https://test.aaaa.com/getPDF';
const headers = {
'Content-Type':'application/x-www-form-urlencoded;charset=UTF-8',
'CCC-DDD': 'js-tool-big-box-demo-header'
}
const params = {
name: '经海路大白狗',
startDate: '2024-03-05',
endDate: '2024-04-05',
}
你看这些参数了吗?url就是下载文件需要的那个接口,如果是get请求呢,你就按照get形式把参数拼接上去,如果是post形式呢,你就需要后面的这个params变量做为入参数据。如果服务端需要headers呢,你就再将headers定义好,准备往过传。
调用实现
ajaxBox.downFileFetch(url, '相的约奶的茶.mp4', 'get', headers, dataParams);
你看到这个 downFileFetch 方法了吧,他也在 ajaxBox 对象下面。
第一个参数呢,表示服务端接口,如果是get请求呢,就把参数拼接上去;
第二个参数呢,表示下载后文件名,比如 down.pdf 这样;
第三个参数呢,默认是get请求,如果不想写get呢,你就写个null,但是你得写进去,如果服务端要求是个post请求呢,你就写post;
第四个参数呢,就是headers啦,服务端需要你就传过去,不需要你就写个null;
第五个参数呢,如果是psot请求,你就传入json对象过去,如果没有参数,你就不写也行,写个null也行。
我说:你这个工具库真是棒,js-tool-big-box,就是前端JS的一个大盒子啊。他说:是的,里面还有很多特别实用的方法,用了这个工具库后,前端项目可以少些很多公共方法,少引很多第三方库,很不错的。我也要离职了,你在公司就说这是你开发的。
我说:那我得请你吃饭啊。于是,我去买了一瓶茅台王子酒,花了260元,定了两份炒饼,花了30元。
等吃完,我说,你这个工具库可以啊,直接从我这里挣了290元。
他说:看你说的,酒你喝了一半,炒饼你吃了一份。我这顶多也就是145元啊。
看完不过瘾?这里有更全的js-tool-big-box使用指南哦,掘金链接直达(只会Vue的我,一入职就让用React,用了这个工具库,我依然高效 - 掘金 (juejin.cn))
最后告诉你个消息:
js-tool-big-box的npm地址是(js-tool-big-box 的npm地址)
js-tool-big-box的git仓库地址(js-tool-big-box的代码仓库地址)
来源:juejin.cn/post/7379524605104848946
34岁程序员带全家离开北京的故事
哈喽大家好,我是大圣,正如标题所说,我离开北京了
今天来聊一下我在北京的这17年,北京好在哪,以及为什么要选择离开, 以及下一步的打算
想
提前声明,我下面会说一些北京和英国的优点,我个人只在英国生活的一个月,我体验到的优点肯定非常片面,也欢迎评论区讨论
离开北京的当天我还兴致勃勃的拿着gopro 准备拍个视频,但是下了楼看到北京天,突然很感慨,没了拍视频的兴致,拍几个照留个纪念吧
北京好在哪
我2007年来北京念书,咱们国家排名前50的那200所高校之一,我们家家庭条件不是特别好,但是北京让我开了眼界
我来自一个国家级贫困县,我跟我哥俩人上大学比较费钱,到我大学毕业那天起,我们家外面还欠着外债,经济虽然窘迫,但是我也感受到了后海步行街的灯红酒绿,鸟巢水立方世界级赛事,毕业之后互联网黄金时代的革命,也正是北京各地的创业咖啡厅里,很多投资人和创业者激情的演讲,让我接触到软件编程,成就了现在的我
可以说一线城市虽然压力很大,但还是给了普通人很多的机会,如果我毕业就在我们老家那边阜阳,或者努努力去省会合肥,生活应该也很棒,我可能就不会有现在这么多选择
北京很好,我在这里工作,学习编程,创业,我33岁的时候好像完成了人生第一阶段的任务,在北京有房有车,我媳妇有北京户口,看起来养娃也没什么问题,那为什么要离开呢
两个原因吧,养娃和个人职业实现
从我媳妇查出来怀孕的那天,我兴奋了一整天,但是到了晚上就开始和媳妇讨论,怎么让他成为一个快乐的孩子,讨论了俩星期吧
我其实已经卷出来了,但是我不想让孩子也走这条路,我觉得比较辛苦,他现在还只是一个胚胎,后面的中高考,考研考公,面试,裁员等等,有没有什么事情能让他快乐一些,以后大学报志愿的时候,也不会只看就业率如何来选择,讨论来 讨论去,最后冒出一个新想法,要不出国试试,都说老外快乐教育
第二个就是我跟媳妇个人的价值实现,我媳妇是低我三届的学妹,我俩处对象那会他还在读研,她学设计的,腾讯当产品经理实习生,为了北京户口去了国企,算是为家庭做了一些贡献,我感觉国企的工作稍显琐碎一些
我在的互联网行业,很刺激很好玩,很严重的35岁裁员危机,最近大家也能看到很多新闻,工资确实高,裁员也猛
我们都希望能有一个有趣的,持久的职业生涯,能够有大量的时间,去学习怎么成为一个合格的父母,少赚一些可以的,但是离开北京,国内的城市好像很难实现
而且我跟我媳妇一直都有一个环游世界的梦想,只是一只没机会实现,所以,出国这两个字 就在我们家出现了
为什么选英国
首先基本大家都有点英语基础,但是学起来很难受,所以真的不想学二外,并且咱们普通人,基本投资移民和咱们也没啥关系,不花钱的工签就是性价比最高的
所以符合这个要求的,能找到程序员工作的美国,新加坡,加拿大,澳洲,英国,新西兰,和一些欧洲的国家
然后不想努力奋斗了,排除了美国和新加坡
支持远程工作,不卷,最好时区和国内有重合,我国内的卖课服务可以继续
当然准备去后,英国有一些优点我觉得可以参考,可能其他国家也有 欢迎补充
- 英国好学校还挺多,以后对孩子教育估计不错
- 英国没有蚊子蛇,想想夏天就爽
- 英国PR拿到后,每年只需要入境一次就可以续,所以我可以五年后继续回国定居,同时保持英国PR,如果孩子以后回国念书,我这几年也不算浪费
- 英国有阿森纳,对我来说优势太大了,我已经期待去唱north london forever了,掉点眼泪估计是必须的
- 这边的文化生活还是挺丰富的,博物馆,演出之类的
- 比较严格的八小时工作制,是真的不加班,而且remote的还挺多的,很多非remote的也是每周去两天左右,有很多的时间来做课程,陪家人,年假也挺多的,我记得是20多天,准备度假的时候再用
- 空气我觉得也不错,无论晴天阴天,都没什么发白的雾霾的感觉
不过以上都不是硬性条件,比如美国,加拿大澳大利亚我也会看机会,我也会考虑去奋斗,整体比较随缘, 我之前也分享过一些学英语的内容,然后给英国这边remote了半年后,问了下工签政策,元旦那会就担保过来了,然后体验了一个月,觉得还能接受,然后回国 准备搬家的事
打算
英国当然也有特别多的缺点,比如我一个朋友就因为税务和天气问题,准备去新加坡,也有一个因为职业发展问题准备去北美奋斗,也同时有朋友努力学英语去澳洲,和从澳洲努力准备回国
都是个人选择,主要就是你想过什么样的生活
英国吃的也不咋地,我也不太喜欢这边的酒吧文化,也没有国内安全,所以我也在探索
我在英国还是remote,所以暂时就在伦敦和周边生活,反正5年后拿绿卡,后面需要很努力的带娃,花大量的时间生活,旅游,比如在欧洲多玩一玩
同时继续发展卖课视野,做一个好的讲师,研发远程,或者web3的开发课程,除了国内也开辟一下欧洲市场
伦敦这边公园特别多,我住的两个地方周边溜达十分钟,都有草坪非常好的公园,非常适合遛娃和遛狗
而且也很适合养狗,狗可以坐地铁,火车,非常方便
我家孩子也一岁了,我希望以后我能成为一个合格的父亲把,能够陪他成长,在我的能力范围内,给他多一些选择,多快乐一些
找一些能持续成长的爱好,能够和孩子一起成长,比如踢球网球,重新开始打dota2,黑悟空也准备配个电脑好好玩,回归生活
下一期聊一下英国这边的生活体验,成本啥的吧,开心工作,努力生活
视频版
来源:juejin.cn/post/7380513226155868214
一个小公司的技术开发心酸事
背景
长话短说,就是在2022年6月的时候加入了一家很小创业公司。老板不太懂技术,也不太懂管理,靠着一腔热血加上对实体运输行业的了解,加上盲目的自信,贸然开始创业,后期经营困难,最终散伙。
自己当时也是不察,贸然加入,后边公司经营困难,连最后几个月的工资都没给发。
当时老板的要求就是尽力降低人力成本,尽快的开发出来App(Android+IOS),老板需要尽快的运营起来。
初期的技术选型
当时就自己加上一个刚毕业的纯前端开发以及一个前面招聘的ui,连个人事、测试都没有。
结合公司的需求与自己的技术经验(主要是前端和nodejs的经验),选择使用如下的方案:
- 使用
uni-app
进行App
的开发,兼容多端,也可以为以后开发小程序什么的做方案预留,主要考虑到的点是比较快,先要解决有和无的问题; - 使用
egg.js
+MySQL
来开发后端,开发速度会快一点,行业比较小众,不太可能会遇到一些较大的性能问题,暂时看也是够用了的,后期过渡到midway.js
也方便; - 使用
antd-vue
开发运营后台,主要考虑到与uni-app
技术栈的统一,节省转换成本;
也就是初期选择使用egg.js
+ MySQL
+ uni-app
+ antd-vue
,来开发两个App和一个运营后台,快速解决0到1的问题。
关于App开发技术方案的选择
App的开发方案有很多,比如纯原生、flutter、uniapp、react-native/taro等,这里就当是的情况做一下选择。
- IOS与Android纯原生开发方案,需要新招人,两端同时开发,两端分别测试,这个资金及时间成本老板是不能接受的;
- flutter,这个要么自己从头开始学习,要么招人,相对于纯原生的方案好一点,但是也不是最好的选择;
- react-native/taro与uni-app是比较类似的选择,不过考虑到熟练程度、难易程度以及开发效率,最终还是选择了uni-app。
为什么选择egg.js做后端
很多时候方案的选择并不能只从技术方面考虑,当是只能选择成本最低的,当时的情况是egg.js
完全能满足。
- 使用一些成熟的后端开发方案,如Java、、php、go之类的应该是比较好的技术方案,但对于老板来说不是好的经济方案;
egg.js
开发比较简单、快捷,个人也比较熟悉,对于新成员的学习成本也很低,对于JS有一定水平的也能很快掌握egg.js后端的开发
。
中间的各种折腾
前期开发还算顺利,在规定的时间内,完成了开发、测试、上线。但是,老板并没有如前面说的,很快运营,很快就盈利,运营的开展非常缓慢。中间还经历了各种折腾的事情。
- 老板运营遇到困难,就到处找一些专家(基本跟我们这事情没半毛钱关系的专家),不断的提一些业务和ui上的意见,不断的修改;
- 期间新来的产品还要全部推翻原有设计,重新开发;
- 还有个兼职的领导非要说要招聘原生开发和Java开发重新进行开发,问为什么,也说不出什么所以然,也是道听途说。
反正就是不断提出要修改产品、设计、和代码。中间经过不断的讨论,摆出自己的意见,好在最终技术方案没修改,前期的工作成果还在。后边加了一些新的需求:系统升级1.1、ui升级2.0、开发小程序版本、开发新的配套系统(小程序版本)以及开发相关的后台、添加即时通信服务、以及各种小的功能开发与升级;
中间老板要加快进度了就让招人,然后又无缘无故的要开人,就让人很无奈。最大的运营问题,始终没什么进展,明显的问题并不在产品这块,但是在这里不断的折腾这群开发,也真是难受。
明明你已经很努力的协调各种事情、站在公司的角度考虑、努力写代码,却仍然无济于事。
后期技术方案的调整
- 后期调整了App的打包方案;
- 在新的配套系统中,使用
midway.js
来开发新的业务,这都是基于前面的egg.js
的团队掌握程度,为了后续的开发规范,做此升级; - 内网管理公用npm包,开发业务组件库;
- 规范代码、规范开发流程;
人员招聘,团队的管理
人员招聘
如下是对于当时的人员招聘的一些感受:
- 小公司的人员招聘是相对比较难的,特别是还给不了多少钱的;
- 好在我们选择的技术方案,只要对于JS掌握的比较好就可以了,前后端都要开发一点,也方便人员工作调整,避免开发资源的浪费。
团队管理
对于小团队的管理的一些个人理解:
- 小公司刚起步,就应该实事求是,以业务为导向;
- 小公司最好采取全栈的开发方式,避免任务的不协调,造成开发资源的浪费;
- 设置推荐的代码规范,参照大家日常的代码习惯来制定,目标就是让大家的代码相对规范;
- 要求按照规范的流程设计与开发、避免一些流程的问题造成管理的混乱和公司的损失;
- 如按照常规的业务开发流程,产品评估 => 任务分配 => 技术评估 => 开发 => 测试 => cr => 上线 => 线上问题跟踪处理;
- 行之有效可量化的考核规范,如开发任务的截止日期完成、核心流程开发文档的书写、是否有线上bug、严谨手动修改数据库等;
- 鼓励分享,相互学习,一段工作经历总要有所提升,有所收获才是有意义的;
- 及时沟通反馈、团队成员的个人想法、掌握开发进度、工作难点等;
最后总结及选择创业公司避坑建议!important
- 选择创业公司,一定要确认老板是一个靠谱的人,别是一个总是画饼的油腻老司机,或者一个优柔寡断,没有主见的人,这样的情况下,大概率事情是干不成的;
- 老板靠谱,即使当前的项目搞不成,也可能未来在别的地方做出一番事情;
- 初了上边这个,最核心的就是,怎么样赚钱,现在这种融资环境,如果自己不能赚钱,大概率是活不下去的@自己;
- 抓住核心矛盾,解决主要问题,业务永远是最重要的。至于说选择的开发技术、代码规范等等这些都可以往后放;
- 对上要及时反馈自己的工作进度,保持好沟通,老板总是站在更高一层考虑问题,肯定会有一些不一样的想法,别总自以为什么什么的;
- 每段经历最好都能有所收获,人生的每一步都有意义。
来源:juejin.cn/post/7257085326471512119
关于鸿蒙开发,我暂时放弃了
起因
在最近鸿蒙各种新闻资讯说要鸿蒙不再兼容android之后,我看完了鸿蒙视频,并简单的撸了一个demo。
鸿蒙的arkui
,使用typescript
作为基调,然后响应式开发
,对于我这个old android
来说,确实挺惊艳的。而且在模拟器中运行起来也很快,写demo的过程鸡血满满,着实很愉快。
后面自己写的文章,也在掘金站点上获得了不错的评价。
打击
今天下午,刚好同事有一个遥遥领先(meta 40 pro
),鸿蒙4.0版本
。
怀着秀操作的想法,在同事手机
上运行了起来。very nice。 一切出奇的顺利。
but ...
尼玛,点击的时候,直接卡住不对,黑屏。让人瞬间崩溃。
本着优先怀疑自己的原则,我找了一个官方的demo。 运行起来。
额...
尼玛。还是点击之后卡住了,大概30s之后,才跳转到新的页面。
这一切,让我熬夜掉的头发瞬间崩溃。
放弃了...
放弃了...
后续
和其他学习鸿蒙的伙伴沟通,也遇到了同样的问题,真机不能运行
,会卡线程。但是按下home键,再次回到界面,页面会刷新过来
。
我个人暂时决定搁置对于鸿蒙开发的学习了,后续如果慢慢变得比较成熟之后,再次接触学习吧。
后续个人计划:
- 1、还是会持续关注后续版本是否真机能运行,传言api 10对黑屏和真机无法运行的修复了。奈何官方所有渠道的编译器都没有api 10 的模拟器,真机4.0按道理是支持api10,但是还是黑屏,再持续观察吧。
插个眼
。 - 2、为了贯彻执行持续学习。后续可能会持续更新jetpack compose相关内容,包含且不局限于
compose desktop
以及multi platform
最新情报:有网友告知我,在meta60上是运行没问题的,可能是最新版4.0是ok的,那么结论就是目前真机适配不完善
来源:juejin.cn/post/7304538094736343052
我写了一个程序,让端口占用无路可逃
作为一个 Java
工程师,经常会遇到这么个场景:IDEA
里的程序正在运行,此时直接关闭了 IDEA
而没有先关闭正在运行的服务。
在绝大多数情境下,此方式都无伤大雅,但总有一些抽风的场景运行的程序并没有被正常的关闭,也就导致了重启项目时将会提示 xxxx
端口已被占用。
在 Windows
下此方式解决也十分简单,在命令行输入下述两个命令即可根据端口关闭对应的进程。
# 端口占用进程
netstat -ano | findstr <port>
# 进程关闭
taskkill -PID <pid> -F
虽然说也不麻烦但却很繁杂,试想一下当遇到这种情况下,我需要先翻笔记找出这两个命令,在打开命令行窗口执行,一套连招下来相当影响编程情绪。
因此,我决定写一个程序能够便捷的实现这个操作,最好是带 GUI
页面。
说干就干,整个程序功能其实并不复杂,对于页面的展示要求也不高,我就确定下来了直接通过 Java Swing
实现 GUI
部分。而对于命令执行部分,在 Java
中提供了 Process
类可用于执行命令。
先让我们看下 Process
的作用方式,以最简单的 ping baidu.com
测试为例。
public void demo() {
ProcessBuilder processBuilder = new ProcessBuilder();
List<String> command = new ArrayList<>();
command.add("ping");
command.add("www.baidu.com");
processBuilder.command(command);
try {
Process process = processBuilder.start();
try (
InputStreamReader ir = new InputStreamReader(process.getInputStream(), "GBK");
BufferedReader br = new BufferedReader(ir)
) {
String line;
while ((line = br.readLine()) != null) {
System.out.println(line);
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
运行上述的代码,在控制台可以得到下图结果:
在上述程序中,ProcessBuilder
用于构建命令,processBuilder.start()
则相当于你敲下回车执行,而执行的结果的则以 IO
流的形式返回,这里通过 readLine()
将返回的结果逐行的形式进行读取。
了解的大概原理之后,剩下的事情就简单了,只需要将之前提到的两个命令以同样的方式通过 Process
执行就可以,再通过 Java Swing 进行一个页面展示就可以。
具体的实现并不复杂,这里就不详细展开介绍,完整的项目代码已经上传到 GitHub
,感兴趣的小伙伴可自行前往查看,仓库地址:windows-process。
下面主要介绍程序的使用与效果,开始前可以去上述提到的仓库 relase
里将打包完成的 exe 程序下载,下载地址。
下载后启动 window process.exe
程序,在启动之后会先弹出下图的提示,这是因为使用了 exe4j
打包程序,选择确认即可。
选择确认之后即会展示下图页面,列表中展示的数据即 netstat -ano
命令返回的结果,
在选中列表任意一条进程记录后,会将该进程对应的端口号和 PID
填充至上面的输入框中。
同时,可在 Port
输入框中输入对应的端口号实现快速查询,若需要停止某个进程,则将点击对应端口进程记录其 PID
会自动填入输入框中,然后单击 Kill
按钮,成功停止进程后将会进行相应的提示。
最后的最后,再臭不要脸的给自己要个赞,觉得不错的可以去 GitHub
仓库上下载下来看看,如果能点个 star
更是万分感谢,这里再贴一下仓库地址:windows-process。
来源:juejin.cn/post/7385499574881026089
null 不好,我真的推荐你使用 Optional
"Null 很糟糕." - Doug Lea。
Doug Lea 是一位美国的计算机科学家,他是 Java 平台的并发和集合框架的主要设计者之一。他在 2014 年的一篇文章中说过:“Null sucks.”1,意思是 null 很糟糕。他认为 null 是一种不明确的表示,它既可以表示一个值不存在,也可以表示一个值未知,也可以表示一个值无效。这样就会导致很多逻辑错误和空指针异常,给程序员带来很多麻烦。他建议使用 Optional 类来封装可能为空的值,从而提高代码的可读性和健壮性。
"发明 null 引用是我的十亿美元错误。" - Sir C. A. R. Hoare。
Sir C. A. R. Hoare 是一位英国的计算机科学家,他是快速排序算法、Hoare 逻辑和通信顺序进程等重要概念的发明者。他在 2009 年的一个软件会议上道歉说:“I call it my billion-dollar mistake. It was the invention of the null reference in 1965.”,意思是他把 null 引用称为他的十亿美元错误。他说他在 1965 年设计 ALGOL W 语言时,引入了 null 引用的概念,用来表示一个对象变量没有指向任何对象。他当时认为这是一个很简单和自然的想法,但后来发现这是一个非常糟糕的设计,因为它导致了无数的错误、漏洞和系统崩溃。他说他应该使用一个特殊的对象来表示空值,而不是使用 null。
自作者从事 Java 编程一来,就与 null 引用相伴,与 NullPointerException 相遇已经是家常便饭了。
null 引用是一种表示一个对象变量没有指向任何对象的方式,它是 Java 语言中的一个特殊值,也是导致空指针异常(NullPointerException)的主要原因。虽然 null 引用可以用来表示一个值不存在或未知,也可以用来节省内存空间。但是它也不符合面向对象的思想,因为它不是一个对象,不能调用任何方法或属性。
可以看到,null 引用并不好,我们应该尽量避免使用 null,那么我们该怎么避免 null 引用引起的逻辑错误和运行时异常嘞?
其实这个问题 Java 的设计者也知道,于是他们在 Java8 之后设计引入了 Optional 类解决这个问题,本文将给大家详细介绍下 Optional 类的设计目的以及使用方法。
Optional 类是什么?
Optional 类是 java 8 中引入的一个新的类,它的作用是封装一个可能为空的值,从而避免空指针异常(NullPointerException)。Optional 类可以看作是一个容器,它可以包含一个非空的值,也可以为空。Optional 类提供了一些方法,让我们可以更方便地处理可能为空的值,而不需要显式地进行空值检查或者使用 null。
推荐作者开源的 H5 商城项目waynboot-mall,这是一套全部开源的微商城项目,包含三个项目:运营后台、H5 商城前台和服务端接口。实现了商城所需的首页展示、商品分类、商品详情、商品 sku、分词搜索、购物车、结算下单、支付宝/微信支付、收单评论以及完善的后台管理等一系列功能。 技术上基于最新得 Springboot3.0、jdk17,整合了 MySql、Redis、RabbitMQ、ElasticSearch 等常用中间件。分模块设计、简洁易维护,欢迎大家点个 star、关注我。
github 地址:github.com/wayn111/way…
Optional 类的设计
Optional 类的设计是基于函数式编程的思想,它借鉴了 Scala 和 Haskell 等语言中的 Option 类型。Optional 类实现了 java.util.function 包中的 Supplier、Consumer、Predicate、Function 等接口,这使得它可以和 lambda 表达式或者方法引用一起使用,形成更简洁和优雅的代码。
Optional 类被 final 修饰,因此它是一个不可变的类,它有两个静态方法用于创建 Optional 对象。
Optional.empty()
Optional.empty 表示一个空的 Optional 对象,它不包含任何值。
// 创建一个空的 Optional 对象
Optional empty = Optional.empty();
Optional.of(T value)
Optional.of 表示一个非空的 Optional 对象,它包含一个非空的值。
// 创建一个非空的 Optional 对象
Optional hello = Optional.of("Hello");
Optional.ofNullable(T value)
注意,如果我们使用 Optional.of 方法传入一个 null 值,会抛出 NullPointerException。如果我们不确定一个值是否为空,可以使用 Optional.ofNullable 方法,它会根据值是否为空,返回一个相应的 Optional 对象。例如:
// 创建一个可能为空的 Optional 对象
Optional name = Optional.ofNullable("Hello");
Optional 对象的使用方法
Optional 对象提供了一些方法,让我们可以更方便地处理可能为空的值,而不需要显式地进行空值检查或者使用 null。以下是一些常用的方法。
isPresent()
判断 Optional 对象是否包含一个非空的值,返回一个布尔值。
get()
如果 Optional 对象包含一个非空的值,返回该值,否则抛出 NoSuchElementException 异常。
// 使用 isPresent 和 get 方法
Optional name = Optional.ofNullable("tom");
if (name.isPresent()) {
System.out.println("Hello, " + name.get());
} else {
System.out.println("Name is not available");
}
// 输出:Hello tom
ifPresent(Consumer action)
如果 Optional 对象包含一个非空的值,执行给定的消费者操作,否则什么也不做。
// 使用 ifPresent(Consumer action)
Optional name = Optional.ofNullable("tom");
name.ifPresent(s -> {
System.out.println("Hello, " + name.get());
});
// 输出:Hello tom
orElse(T other)
如果 Optional 对象包含一个非空的值,返回该值,否则返回给定的默认值。
// 使用 orElse(T other)
Optional name = Optional.ofNullable(null);
String greeting = "Hello, " + name.orElse("Guest");
System.out.println(greeting);
// 输出:Hello Guest
orElseGet(Supplier supplier)
如果 Optional 对象包含一个非空的值,返回该值,否则返回由给定的供应者操作生成的值。
// 使用 orElseGet(Supplier supplier)
Optional name = Optional.ofNullable(null);
String greeting = "Hello, " + name.orElseGet(() -> "Guset");
System.out.println(greeting);
// 输出:Hello Guset
orElseThrow(Supplier exceptionSupplier)
如果 Optional 对象包含一个非空的值,返回该值,否则抛出由给定的异常供应者操作生成的异常。
// 使用 orElseThrow(Supplier exceptionSupplier)
Optional name = Optional.ofNullable(null);
String greeting = "Hello, " + name.orElseThrow(() -> new NullPointerException("null"));
// 抛出 java.lang.NullPointerException: null 异常
map(Function mapper)
如果 Optional 对象包含一个非空的值,对该值应用给定的映射函数,返回一个包含映射结果的 Optional 对象,否则返回一个空的 Optional 对象。
// 使用 map(Function mapper)
Optional name = Optional.ofNullable("tom");
String greeting = "Hello, " + name.map(s -> s.toUpperCase()).get();
System.out.println(greeting);
// 输出:Hello TOM
flatMap(Function> mapper)
如果 Optional 对象包含一个非空的值,对该值进行 mapper 参数操作,返回新的 Optional 对象,否则返回一个空的 Optional 对象。
// 使用 flatMap(Function> mapper)
Optional name = Optional.ofNullable("tom");
String greeting = name.flatMap(s -> Optional.of("Hello " + s)).get();
System.out.println(greeting);
// 输出:Hello tom
filter(Predicate predicate)
如果 Optional 对象包含一个非空的值,并且该值满足给定的谓词条件,返回包含该值的 Optional 对象,否则返回一个空的 Optional 对象。
// filter(Predicate predicate)
Optional name = Optional.ofNullable("tom");
String greeting = "Hello " + name.filter(s -> !s.isEmpty()).get();
System.out.println(greeting);
// 输出:Hello tom
Java 9 中 Optional 改进
Java 9 中 Optional 类有了一些改进,主要是增加了三个新的方法,分别是 stream()、ifPresentOrElse() 和 or()。这些方法可以让我们更方便地处理可能为空的值,以及和流或其他返回 Optional 的方法结合使用。我来详细讲解一下这些方法的作用和用法。
stream()
这个方法可以将一个 Optional 对象转换为一个 Stream 对象,如果 Optional 对象包含一个非空的值,那么返回的 Stream 对象就包含这个值,否则返回一个空的 Stream 对象。这样我们就可以利用 Stream 的各种操作来处理 Optional 的值,而不需要显式地判断是否为空。我们可以用 stream() 方法来过滤一个包含 Optional 的列表,只保留非空的值,如下所示:
List> list = Arrays.asList(
Optional.empty(),
Optional.of("A"),
Optional.empty(),
Optional.of("B")
);
// 使用 stream() 方法过滤列表,只保留非空的值
List filteredList = list.stream()
.flatMap(Optional::stream)
.collect(Collectors.toList());
System.out.println(filteredList);
// 输出 [A, B]
ifPresentOrElse(Consumer action, Runnable emptyAction)
这个方法可以让我们在 Optional 对象包含值或者为空时,执行不同的操作。它接受两个参数,一个是 Consumer 类型的 action,一个是 Runnable 类型的 emptyAction。如果 Optional 对象包含一个非空的值,那么就执行 action.accept(value),如果 Optional 对象为空,那么就执行 emptyAction.run()。这样我们就可以避免使用 if-else 语句来判断 Optional 是否为空,而是使用函数式编程的方式来处理不同的情况。我们可以用 ifPresentOrElse() 方法来打印 Optional 的值,或者提示不可用,如下所示 :
Optional optional = Optional.of(1);
optional.ifPresentOrElse(
x -> System.out.println("Value: " + x),
() -> System.out.println("Not Present.")
);
optional = Optional.empty();
optional.ifPresentOrElse(
x -> System.out.println("Value: " + x),
() -> System.out.println("Not Present.")
);
// 输出:Value: 1
// 输出:Not Present.
or(Supplier> supplier)
这个方法可以让我们在 Optional 对象为空时,返回一个预设的值。它接受一个 Supplier 类型的 supplier,如果 Optional 对象包含一个非空的值,那么就返回这个 Optional 对象本身,如果 Optional 对象为空,那么就返回 supplier.get() 返回的 Optional 对象。这样我们就可以避免使用三元运算符或者其他方式来设置默认值,而是使用函数式编程的方式来提供备选值。我们可以用 or() 方法来设置 Optional 的默认值,如下所示:
Optional optional = Optional.of("Hello ");
Supplier> supplier = () -> Optional.of("tom");
optional = optional.or(supplier);
optional.ifPresent(x -> System.out.println(x));
optional = Optional.empty();
optional = optional.or(supplier);
optional.ifPresent(x -> System.out.println(x));
// 输出:Hello
// 输出:tom
为什么我推荐你使用 Optional 类
最后我总结一下使用 Optional 类的几个好处:
- 可以避免空指针异常,提高代码的健壮性和可读性。
- 可以减少显式的空值检查和 null 的使用,使代码更简洁和优雅。
- 可以利用函数式编程的特性,实现更灵活和高效的逻辑处理。
- 可以提高代码的可测试性,方便进行单元测试和集成测试。
总之,Optional 类是一个非常有用的类,它可以帮助我们更好地处理可能为空的值,提高代码的质量和效率。所以我强烈推荐你在 Java 开发中使用 Optional 类,你会发现它的魅力和好处。
来源:juejin.cn/post/7302322661957845028
搭建个人直播间,实现24小时B站、斗鱼、虎牙等无人直播!
不知道大家平时看不看直播呢?现在有各式各样的直播,游戏直播、户外直播、带货直播、经典电视/电影直播等等。
电视、电影直播是24小时不间断无人直播,如斗鱼/虎牙中的一起看,这种直播要如何实现呢?
其实非常简单,只需要一台服务器和视频资源就能完成。
再借助于直播推流工具,如 KPlayer
,将电视剧、电影等媒体资源推流到直播间,就能实现24小时无人直播了!
关注微信公众号:【Java陈序员】,获取开源项目分享、AI副业分享、超200本经典计算机电子书籍等。
KPlayer 简介
KPlayer
—— ByteLang Studio
设计开发的一款用于在 Linux
环境下进行媒体资源推流的应用程序。
只需要简单的修改配置文件即可达到开箱即用的目的,不需要了解众多推流适配、视频编解码的细节即可方便的将媒体资源在主流直播平台上进行直播。意愿是提供一个简单易上手、扩展丰富、性能优秀适合长时间不间断推流的直播推流场景。
功能特色:
- 本地/网络视频资源的无缝推流,切换资源不导致断流
- 可自定义配置的编码参数,例如分辨率、帧率等
- 自定义多输出源,适合相同内容一次编码多路推流节省硬件资源
- 提供缓存机制避免相同内容二次编解码,大大降低在循环场景下对硬件资源的消耗
- 丰富的API接口在运行时对播放行为和资源动态控制
- 提供基础插件并具备自定义插件开发的能力
项目地址:https://github.com/bytelang/kplayer-go
在线文档:https://docs.kplayer.net/v0.5.8/
安装 KPlayer
KPlayer
支持一键安装、手动安装和 Docker
安装。
一键安装
通过 ssh
进入到你的服务器中,找到合适的目录并运行以下的命令进行下载:
curl -fsSL get.kplayer.net | bash
手动安装(可选)
1、下载压缩包
wget http://download.bytelang.cn/kplayer-v0.5.8-linux_amd64.tar.gz
2、解压压缩包
tar zxvf kplayer-v0.5.8-linux_amd64.tar.gz
安装完成
1、执行 cd kplayer
进入到 kplayer
目录,使用 ll
查看文件列表:
-rw-r--r-- 1 root root 285 3月 23 18:23 config.json.example
-rwxr-xr-x 1 root root 27M 7月 29 11:12 kplayer
config.json.example
是KPlayer
最小化的配置信息示例kplayer
是KPlayer
服务启动、停止的执行脚本命令
2、使用 ./kplayer
命令查看当前版本
创建配置文件
1、使用 cp
命令重命名并复制一份 config.json.example
cp config.json.example config.json
2、修改配置文件
{
"version": "2.0.0",
"resource": {
"lists": [
"/video/example_1.mp4",
"/video/example_2.mp4"
]
},
"output": {
"lists": [
{
"path": "rtmp://127.0.0.1:1935/push"
}
]
}
}
resource.lists
视频资源文件路径output.lists
直播推流地址,在B站、斗鱼、虎牙等直播平台中开启直播后,将会得到推流地址与推流码
开启直播
上传视频
上传视频资源到服务器,并修改 KPlayer
中的 resource.lists
视频路径
❗❗❗注意:直播的媒体文件必须得有平台版权,否则就会被投诉,封禁直播间❗
{
"version": "2.0.0",
"resource": {
"lists": [
"/data/software/movie/WechatMomentScreenshot.mp4",
"/data/software/movie/IT Tools.mp4",
"/data/software/movie/EasyCode.mp4",
"/data/software/movie/TinyRDM.mp4",
"/data/software/movie/Fooocus.mp4",
"/data/software/movie/Stirling-PDF.mp4"
]
},
"output": {
"lists": [
{
"path": "rtmp://127.0.0.1:1935/push"
}
]
}
}
}
获取推流地址
以开启B站直播为例。
1、点击首页直播
2、点击网页右侧的开播设置
3、选择分类,点击开播
前提需要身-份-证和姓名实名认证
4、复制直播间地址
rtmp://live-push.bilivideo.com/live-bvc/?streamname=live_*********_********&key=**************&schedule=rtmp&pflag=1
5、将直播间地址配置到 KPlayer
配置文件中的 output.lists
直播推流地址
{
"version": "2.0.0",
"resource": {
"lists": [
"/data/software/movie/WechatMomentScreenshot.mp4",
"/data/software/movie/IT Tools.mp4",
"/data/software/movie/EasyCode.mp4",
"/data/software/movie/TinyRDM.mp4",
"/data/software/movie/Fooocus.mp4",
"/data/software/movie/Stirling-PDF.mp4"
]
},
"output": {
"lists": [
{
"path": "rtmp://live-push.bilivideo.com/live-bvc/?streamname=live_*********_********&key=**************&schedule=rtmp&pflag=1"
}
]
}
}
运行 KPlayer
执行以下命令启动 KPlayer
./kplayer play start
后台运行 KPlayer
./kplayer play start --daemon
测试访问
打开直播间地址,可以看到已经开始直播了。
斗鱼、虎牙等其他直播平台的直播配置也是类似的流程,只需要获取到平台的直播推流地址,并进行配置即可!可以同时配置多个平台同时进行直播!
配置循环播放
KPlayer
提供了很多的配置项,有资源配置、播放配置等。
如:可以配置循环播放视频,这样就可以保证24小时不间断的循环播放视频。
{
"version": "2.0.0",
"resource": {
"lists": [
"/data/software/movie/WechatMomentScreenshot.mp4",
"/data/software/movie/IT Tools.mp4",
"/data/software/movie/EasyCode.mp4",
"/data/software/movie/TinyRDM.mp4",
"/data/software/movie/Fooocus.mp4",
"/data/software/movie/Stirling-PDF.mp4"
]
},
"output": {
"lists": [
{
"path": "rtmp://live-push.bilivideo.com/live-bvc/?streamname=live_*********_********&key=**************&schedule=rtmp&pflag=1"
}
]
},
## 播放配置
"play": {
"fill_strategy": "ratio",
## 启用推流编码缓存,会生成缓存,命中缓存节约CPU资源
"skip_invalid_resource": true,
"cache_on": true,
# 播放模式为按顺序且循环播放
"play_model": "loop"
}
}
更多的配置信息可参考
KPlayer
提供的文档。
Docker 安装 KPlayer
1、创建缓存目录 /data/software/docker/kplayer/cache
cd /data/software/docker/kplayer
mkdir cache
2、创建配置文件 /data/software/docker/kplayer/config.json
cd /data/software/docker/kplayer
touch config.json
填入配置信息:
{
"version": "2.0.0",
"resource": {
"lists": [
"/data/software/movie/WechatMomentScreenshot.mp4",
"/data/software/movie/IT Tools.mp4",
"/data/software/movie/EasyCode.mp4",
"/data/software/movie/TinyRDM.mp4",
"/data/software/movie/Fooocus.mp4",
"/data/software/movie/Stirling-PDF.mp4"
]
},
"output": {
"lists": [
{
"path": "rtmp://live-push.bilivideo.com/live-bvc/?streamname=live_*********_********&key=**************&schedule=rtmp&pflag=1"
}
]
},
## 播放配置
"play": {
"fill_strategy": "ratio",
## 启用推流编码缓存,会生成缓存,命中缓存节约CPU资源
"skip_invalid_resource": true,
"cache_on": true,
# 播放模式为按顺序且循环播放
"play_model": "loop"
}
}
2、创建 docker-compose.yml
version: "3.3"
services:
kplayer:
container_name: kplayer
volumes:
- "/data/software/movie:/video"
- "/data/software/docker/kplayer/config.json:/kplayer/config.json"
- "/data/software/docker/kplayer/cache:/kplayer/cache"
restart: always
image: "bytelang/kplayer"
3、启动容器
docker-compose up -d
以上,就是利用服务器搭建个人直播间的全流程,整个步骤不是很复杂。
我们可以利用闲置的服务器,将自己收藏的电影、电视等资源进行全天候直播,每天还能获得一定的收益!
❗❗❗注意:直播的媒体文件必须得有平台版权,否则就会被投诉,封禁直播间❗
最后
推荐的开源项目已经收录到 GitHub
项目,欢迎 Star
:
https://github.com/chenyl8848/great-open-source-project
或者访问网站,进行在线浏览:
https://chencoding.top:8090/#/
来源:juejin.cn/post/7385929329640226828
使用双异步后,从 191s 优化到 2s
大家好,我是哪吒。
在开发中,我们经常会遇到这样的需求,将Excel的数据导入数据库中。
一、一般我会这样做:
- 通过POI读取需要导入的Excel;
- 以文件名为表名、列头为列名、并将数据拼接成sql;
- 通过JDBC或mybatis插入数据库;
操作起来,如果文件比较多,数据量都很大的时候,会非常慢。
访问之后,感觉没什么反应,实际上已经在读取 + 入库了,只是比较慢而已。
读取一个10万行的Excel,居然用了191s,我还以为它卡死了呢!
private void readXls(String filePath, String filename) throws Exception {
@SuppressWarnings("resource")
XSSFWorkbook xssfWorkbook = new XSSFWorkbook(new FileInputStream(filePath));
// 读取第一个工作表
XSSFSheet sheet = xssfWorkbook.getSheetAt(0);
// 总行数
int maxRow = sheet.getLastRowNum();
StringBuilder insertBuilder = new StringBuilder();
insertBuilder.append("insert int0 ").append(filename).append(" ( UUID,");
XSSFRow row = sheet.getRow(0);
for (int i = 0; i < row.getPhysicalNumberOfCells(); i++) {
insertBuilder.append(row.getCell(i)).append(",");
}
insertBuilder.deleteCharAt(insertBuilder.length() - 1);
insertBuilder.append(" ) values ( ");
StringBuilder stringBuilder = new StringBuilder();
for (int i = 1; i <= maxRow; i++) {
XSSFRow xssfRow = sheet.getRow(i);
String id = "";
String name = "";
for (int j = 0; j < row.getPhysicalNumberOfCells(); j++) {
if (j == 0) {
id = xssfRow.getCell(j) + "";
} else if (j == 1) {
name = xssfRow.getCell(j) + "";
}
}
boolean flag = isExisted(id, name);
if (!flag) {
stringBuilder.append(insertBuilder);
stringBuilder.append('\'').append(uuid()).append('\'').append(",");
for (int j = 0; j < row.getPhysicalNumberOfCells(); j++) {
stringBuilder.append('\'').append(value).append('\'').append(",");
}
stringBuilder.deleteCharAt(stringBuilder.length() - 1);
stringBuilder.append(" )").append("\n");
}
}
List collect = Arrays.stream(stringBuilder.toString().split("\n")).collect(Collectors.toList());
int sum = JdbcUtil.executeDML(collect);
}
private static boolean isExisted(String id, String name) {
String sql = "select count(1) as num from " + static_TABLE + " where ID = '" + id + "' and NAME = '" + name + "'";
String num = JdbcUtil.executeSelect(sql, "num");
return Integer.valueOf(num) > 0;
}
private static String uuid() {
return UUID.randomUUID().toString().replace("-", "");
}
二、谁写的?拖出去,斩了!
优化1:先查询全部数据,缓存到map中,插入前再进行判断,速度快了很多。
优化2:如果单个Excel文件过大,可以采用 异步 + 多线程 读取若干行,分批入库。
优化3:如果文件数量过多,可以采一个Excel一个异步,形成完美的双异步读取插入。
使用双异步后,从 191s 优化到 2s,你敢信?
下面贴出异步读取Excel文件、并分批读取大Excel文件的关键代码。
1、readExcelCacheAsync控制类
@RequestMapping(value = "/readExcelCacheAsync", method = RequestMethod.POST)
@ResponseBody
public String readExcelCacheAsync() {
String path = "G:\\测试\\data\\";
try {
// 在读取Excel之前,缓存所有数据
USER_INFO_SET = getUserInfo();
File file = new File(path);
String[] xlsxArr = file.list();
for (int i = 0; i < xlsxArr.length; i++) {
File fileTemp = new File(path + "\\" + xlsxArr[i]);
String filename = fileTemp.getName().replace(".xlsx", "");
readExcelCacheAsyncService.readXls(path + filename + ".xlsx", filename);
}
} catch (Exception e) {
logger.error("|#ReadDBCsv|#异常: ", e);
return "error";
}
return "success";
}
2、分批读取超大Excel文件
@Async("async-executor")
public void readXls(String filePath, String filename) throws Exception {
@SuppressWarnings("resource")
XSSFWorkbook xssfWorkbook = new XSSFWorkbook(new FileInputStream(filePath));
// 读取第一个工作表
XSSFSheet sheet = xssfWorkbook.getSheetAt(0);
// 总行数
int maxRow = sheet.getLastRowNum();
logger.info(filename + ".xlsx,一共" + maxRow + "行数据!");
StringBuilder insertBuilder = new StringBuilder();
insertBuilder.append("insert int0 ").append(filename).append(" ( UUID,");
XSSFRow row = sheet.getRow(0);
for (int i = 0; i < row.getPhysicalNumberOfCells(); i++) {
insertBuilder.append(row.getCell(i)).append(",");
}
insertBuilder.deleteCharAt(insertBuilder.length() - 1);
insertBuilder.append(" ) values ( ");
int times = maxRow / STEP + 1;
//logger.info("将" + maxRow + "行数据分" + times + "次插入数据库!");
for (int time = 0; time < times; time++) {
int start = STEP * time + 1;
int end = STEP * time + STEP;
if (time == times - 1) {
end = maxRow;
}
if(end + 1 - start > 0){
//logger.info("第" + (time + 1) + "次插入数据库!" + "准备插入" + (end + 1 - start) + "条数据!");
//readExcelDataAsyncService.readXlsCacheAsync(sheet, row, start, end, insertBuilder);
readExcelDataAsyncService.readXlsCacheAsyncMybatis(sheet, row, start, end, insertBuilder);
}
}
}
3、异步批量入库
@Async("async-executor")
public void readXlsCacheAsync(XSSFSheet sheet, XSSFRow row, int start, int end, StringBuilder insertBuilder) {
StringBuilder stringBuilder = new StringBuilder();
for (int i = start; i <= end; i++) {
XSSFRow xssfRow = sheet.getRow(i);
String id = "";
String name = "";
for (int j = 0; j < row.getPhysicalNumberOfCells(); j++) {
if (j == 0) {
id = xssfRow.getCell(j) + "";
} else if (j == 1) {
name = xssfRow.getCell(j) + "";
}
}
// 先在读取Excel之前,缓存所有数据,再做判断
boolean flag = isExisted(id, name);
if (!flag) {
stringBuilder.append(insertBuilder);
stringBuilder.append('\'').append(uuid()).append('\'').append(",");
for (int j = 0; j < row.getPhysicalNumberOfCells(); j++) {
stringBuilder.append('\'').append(value).append('\'').append(",");
}
stringBuilder.deleteCharAt(stringBuilder.length() - 1);
stringBuilder.append(" )").append("\n");
}
}
List collect = Arrays.stream(stringBuilder.toString().split("\n")).collect(Collectors.toList());
if (collect != null && collect.size() > 0) {
int sum = JdbcUtil.executeDML(collect);
}
}
private boolean isExisted(String id, String name) {
return ReadExcelCacheAsyncController.USER_INFO_SET.contains(id + "," + name);
}
4、异步线程池工具类
@Async的作用就是异步处理任务。
- 在方法上添加@Async,表示此方法是异步方法;
- 在类上添加@Async,表示类中的所有方法都是异步方法;
- 使用此注解的类,必须是Spring管理的类;
- 需要在启动类或配置类中加入@EnableAsync注解,@Async才会生效;
在使用@Async时,如果不指定线程池的名称,也就是不自定义线程池,@Async是有默认线程池的,使用的是Spring默认的线程池SimpleAsyncTaskExecutor。
默认线程池的默认配置如下:
- 默认核心线程数:8;
- 最大线程数:Integet.MAX_VALUE;
- 队列使用LinkedBlockingQueue;
- 容量是:Integet.MAX_VALUE;
- 空闲线程保留时间:60s;
- 线程池拒绝策略:AbortPolicy;
从最大线程数可以看出,在并发情况下,会无限制的创建线程,我勒个吗啊。
也可以通过yml重新配置:
spring:
task:
execution:
pool:
max-size: 10
core-size: 5
keep-alive: 3s
queue-capacity: 1000
thread-name-prefix: my-executor
也可以自定义线程池,下面通过简单的代码来实现以下@Async自定义线程池。
@EnableAsync// 支持异步操作
@Configuration
public class AsyncTaskConfig {
/**
* com.google.guava中的线程池
* @return
*/
@Bean("my-executor")
public Executor firstExecutor() {
ThreadFactory threadFactory = new ThreadFactoryBuilder().setNameFormat("my-executor").build();
// 获取CPU的处理器数量
int curSystemThreads = Runtime.getRuntime().availableProcessors() * 2;
ThreadPoolExecutor threadPool = new ThreadPoolExecutor(curSystemThreads, 100,
200, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(), threadFactory);
threadPool.allowsCoreThreadTimeOut();
return threadPool;
}
/**
* Spring线程池
* @return
*/
@Bean("async-executor")
public Executor asyncExecutor() {
ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();
// 核心线程数
taskExecutor.setCorePoolSize(24);
// 线程池维护线程的最大数量,只有在缓冲队列满了之后才会申请超过核心线程数的线程
taskExecutor.setMaxPoolSize(200);
// 缓存队列
taskExecutor.setQueueCapacity(50);
// 空闲时间,当超过了核心线程数之外的线程在空闲时间到达之后会被销毁
taskExecutor.setKeepAliveSeconds(200);
// 异步方法内部线程名称
taskExecutor.setThreadNamePrefix("");
/**
* 当线程池的任务缓存队列已满并且线程池中的线程数目达到maximumPoolSize,如果还有任务到来就会采取任务拒绝策略
* 通常有以下四种策略:
* ThreadPoolExecutor.AbortPolicy:丢弃任务并抛出RejectedExecutionException异常。
* ThreadPoolExecutor.DiscardPolicy:也是丢弃任务,但是不抛出异常。
* ThreadPoolExecutor.DiscardOldestPolicy:丢弃队列最前面的任务,然后重新尝试执行任务(重复此过程)
* ThreadPoolExecutor.CallerRunsPolicy:重试添加当前的任务,自动重复调用 execute() 方法,直到成功
*/
taskExecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
taskExecutor.initialize();
return taskExecutor;
}
}
5、异步失效的原因
- 注解@Async的方法不是public方法;
- 注解@Async的返回值只能为void或Future;
- 注解@Async方法使用static修饰也会失效;
- 没加@EnableAsync注解;
- 调用方和@Async不能在一个类中;
- 在Async方法上标注@Transactional是没用的,但在Async方法调用的方法上标注@Transcational是有效的;
三、线程池中的核心线程数设置问题
有一个问题,一直没时间摸索,线程池中的核心线程数CorePoolSize、最大线程数MaxPoolSize,设置成多少,最合适,效率最高。
借着这个机会,测试一下。
1、我记得有这样一个说法,CPU的处理器数量
将核心线程数CorePoolSize设置成CPU的处理器数量,是不是效率最高的?
// 获取CPU的处理器数量
int curSystemThreads = Runtime.getRuntime().availableProcessors() * 2;
Runtime.getRuntime().availableProcessors()获取的是CPU核心线程数,也就是计算资源。
- CPU密集型,线程池大小设置为N,也就是和cpu的线程数相同,可以尽可能地避免线程间上下文切换,但在实际开发中,一般会设置为N+1,为了防止意外情况出现线程阻塞,如果出现阻塞,多出来的线程会继续执行任务,保证CPU的利用效率。
- IO密集型,线程池大小设置为2N,这个数是根据业务压测出来的,如果不涉及业务就使用推荐。
在实际中,需要对具体的线程池大小进行调整,可以通过压测及机器设备现状,进行调整大小。
如果线程池太大,则会造成CPU不断的切换,对整个系统性能也不会有太大的提升,反而会导致系统缓慢。
我的电脑的CPU的处理器数量是24。
那么一次读取多少行最合适呢?
测试的Excel中含有10万条数据,10万/24 = 4166,那么我设置成4200,是不是效率最佳呢?
测试的过程中发现,好像真的是这样的。
2、我记得大家都习惯性的将核心线程数CorePoolSize和最大线程数MaxPoolSize设置成一样的,都爱设置成200。
是随便写的,还是经验而为之?
测试发现,当你将核心线程数CorePoolSize和最大线程数MaxPoolSize都设置为200的时候,第一次它会同时开启150个线程,来进行工作。
这个是为什么?
3、经过数十次的测试
- 发现核心线程数好像差别不大
- 每次读取和入库的数量是关键,不能太多,因为每次入库会变慢;
- 也不能太少,如果太少,超过了150个线程,就会造成线程阻塞,也会变慢;
四、通过EasyExcel读取并插入数据库
EasyExcel的方式,我就不写双异步优化了,大家切记陷入低水平勤奋的怪圈。
1、ReadEasyExcelController
@RequestMapping(value = "/readEasyExcel", method = RequestMethod.POST)
@ResponseBody
public String readEasyExcel() {
try {
String path = "G:\\测试\\data\\";
String[] xlsxArr = new File(path).list();
for (int i = 0; i < xlsxArr.length; i++) {
String filePath = path + xlsxArr[i];
File fileTemp = new File(path + xlsxArr[i]);
String fileName = fileTemp.getName().replace(".xlsx", "");
List list = new ArrayList<>();
EasyExcel.read(filePath, UserInfo.class, new ReadEasyExeclAsyncListener(readEasyExeclService, fileName, batchCount, list)).sheet().doRead();
}
}catch (Exception e){
logger.error("readEasyExcel 异常:",e);
return "error";
}
return "suceess";
}
2、ReadEasyExeclAsyncListener
public ReadEasyExeclService readEasyExeclService;
// 表名
public String TABLE_NAME;
// 批量插入阈值
private int BATCH_COUNT;
// 数据集合
private List LIST;
public ReadEasyExeclAsyncListener(ReadEasyExeclService readEasyExeclService, String tableName, int batchCount, List list) {
this.readEasyExeclService = readEasyExeclService;
this.TABLE_NAME = tableName;
this.BATCH_COUNT = batchCount;
this.LIST = list;
}
@Override
public void invoke(UserInfo data, AnalysisContext analysisContext) {
data.setUuid(uuid());
data.setTableName(TABLE_NAME);
LIST.add(data);
if(LIST.size() >= BATCH_COUNT){
// 批量入库
readEasyExeclService.saveDataBatch(LIST);
}
}
@Override
public void doAfterAllAnalysed(AnalysisContext analysisContext) {
if(LIST.size() > 0){
// 最后一批入库
readEasyExeclService.saveDataBatch(LIST);
}
}
public static String uuid() {
return UUID.randomUUID().toString().replace("-", "");
}
}
3、ReadEasyExeclServiceImpl
@Service
public class ReadEasyExeclServiceImpl implements ReadEasyExeclService {
@Resource
private ReadEasyExeclMapper readEasyExeclMapper;
@Override
public void saveDataBatch(List list) {
// 通过mybatis入库
readEasyExeclMapper.saveDataBatch(list);
// 通过JDBC入库
// insertByJdbc(list);
list.clear();
}
private void insertByJdbc(List list) {
List sqlList = new ArrayList<>();
for (UserInfo u : list){
StringBuilder sqlBuilder = new StringBuilder();
sqlBuilder.append("insert int0 ").append(u.getTableName()).append(" ( UUID,ID,NAME,AGE,ADDRESS,PHONE,OP_TIME ) values ( ");
sqlBuilder.append("'").append(ReadEasyExeclAsyncListener.uuid()).append("',")
.append("'").append(u.getId()).append("',")
.append("'").append(u.getName()).append("',")
.append("'").append(u.getAge()).append("',")
.append("'").append(u.getAddress()).append("',")
.append("'").append(u.getPhone()).append("',")
.append("sysdate )");
sqlList.add(sqlBuilder.toString());
}
JdbcUtil.executeDML(sqlList);
}
}
4、UserInfo
@Data
public class UserInfo {
private String tableName;
private String uuid;
@ExcelProperty(value = "ID")
private String id;
@ExcelProperty(value = "NAME")
private String name;
@ExcelProperty(value = "AGE")
private String age;
@ExcelProperty(value = "ADDRESS")
private String address;
@ExcelProperty(value = "PHONE")
private String phone;
}
来源:juejin.cn/post/7315730050577694720
SpringBoot统一结果返回,统一异常处理,大牛都这么玩
引言
在开发Spring Boot应用时,我们经常面临着不同的控制器方法需要处理各种不同类型的响应结果,以及在代码中分散处理异常可能导致项目难以维护的问题。你是否曾经遇到过在不同地方编写相似的返回格式,或者在处理异常时感到有些混乱?这些看似小问题的积累,实际上可能对项目产生深远的影响。统一结果返回和统一异常处理并非只是为了规范代码,更是为了提高团队的协作效率、降低项目维护的难度,并使代码更易于理解和扩展。
本文的目的是帮助你更好地理解和应用Spring Boot中的统一结果返回和统一异常处理。通过详细的讨论和实例演示,我们将为你提供一套清晰的指南,让你能够在自己的项目中轻松应用这些技术,提高代码质量,减轻开发压力。
统一结果返回
统一结果返回是一种通过定义通用的返回格式,使所有的响应结果都符合同一标准的方法。这有助于提高代码的一致性,减少重复代码的编写,以及使客户端更容易理解和处理API的响应。统一结果返回不仅规范了代码结构,还能提高团队协作效率,降低项目维护的难度。
接下来让我们一起看看在SpringBoot中如何实现统一结果返回。
1. 定义通用的响应对象
当实现统一结果返回时,需要创建一个通用的响应对象,定义成功和失败的返回情况,并确保在接口中使用这个通用返回对象。
@Setter
@Getter
public class ResultResponse<T> implements Serializable {
private static final long serialVersionUID = -1133637474601003587L;
/**
* 接口响应状态码
*/
private Integer code;
/**
* 接口响应信息
*/
private String msg;
/**
* 接口响应的数据
*/
private T data;
}
2. 定义接口响应状态码
统一结果返回的关键之一是规定一套通用的状态码。这有助于客户端更容易地理解和处理 API 的响应,同时也为开发者提供了一致的标准。通常,一些 HTTP 状态码已经被广泛接受,如:
200 OK
:表示成功处理请求。201 Created
:表示成功创建资源。204 No Content
:表示成功处理请求,但没有返回任何内容。
对于错误情况,也可以使用常见的 HTTP 状态码,如:
400 Bad Request
:客户端请求错误。401 Unauthorized
:未授权访问。404 Not Found
:请求资源不存在。500 Internal Server Error
:服务器内部错误。
除了 HTTP 状态码外,你还可以定义自己的应用程序特定状态码,以表示更具体的情况。确保文档中清晰地说明了每个状态码所代表的含义,使开发者能够正确地解释和处理它们。
public enum StatusEnum {
SUCCESS(200 ,"请求处理成功"),
UNAUTHORIZED(401 ,"用户认证失败"),
FORBIDDEN(403 ,"权限不足"),
SERVICE_ERROR(500, "服务器去旅行了,请稍后重试"),
PARAM_INVALID(1000, "无效的参数"),
;
public final Integer code;
public final String message;
StatusEnum(Integer code, String message) {
this.code = code;
this.message = message;
}
}
3. 定义统一的成功和失败的处理方法
定义统一的成功和失败的响应方法有助于保持代码一致性和规范性,简化控制器逻辑,提高代码复用性,降低维护成本,提高可读性,促进团队协作,以及更便于进行测试。
/**
* 封装成功响应的方法
* @param data 响应数据
* @return reponse
* @param <T> 响应数据类型
*/
public static <T> ResultResponse<T> success(T data) {
ResultResponse<T> response = new ResultResponse<>();
response.setData(data);
response.setCode(StatusEnum.SUCCESS.code);
return response;
}
/**
* 封装error的响应
* @param statusEnum error响应的状态值
* @return
* @param <T>
*/
public static <T> ResultResponse<T> error(StatusEnum statusEnum) {
return error(statusEnum, statusEnum.message);
}
/**
* 封装error的响应 可自定义错误信息
* @param statusEnum error响应的状态值
* @return
* @param <T>
*/
public static <T> ResultResponse<T> error(StatusEnum statusEnum, String errorMsg) {
ResultResponse<T> response = new ResultResponse<>();
response.setCode(statusEnum.code);
response.setMsg(errorMsg);
return response;
}
4. web层统一响应结果
在web层使用统一结果返回的目的是将业务逻辑的处理结果按照预定的通用格式进行封装,以提高代码的一致性和可读性。
@RestController
@RequestMapping("user")
@Validated
@Slf4j
public class UserController {
private IUserService userService;
/**
* 创建用户
* @param requestVO
* @return
*/
@PostMapping("create")
public ResultResponse<Void> createUser(@Validated @RequestBody UserCreateRequestVO requestVO){
return ResultResponse.success(null);
}
/**
* 根据用户ID获取用户信息
* @param userId 用户id
* @return 用户信息
*/
@GetMapping("info")
public ResultResponse<UserInfoResponseVO> getUser(@NotBlank(message = "请选择用户") String userId){
final UserInfoResponseVO responseVO = userService.getUserInfoById(userId);
return ResultResponse.success(responseVO);
}
@Autowired
public void setUserService(IUserService userService) {
this.userService = userService;
}
}
调用接口,响应的信息统一为:
{
"code": 200,
"msg": null,
"data": null
}
{
"code": 200,
"msg": null,
"data": {
"userId": "121",
"userName": "码农Academy"
}
}
统一结果返回通过定义通用的返回格式、成功和失败的返回情况,以及在控制器中使用这一模式,旨在提高代码的一致性、可读性和可维护性。采用统一的响应格式简化了业务逻辑处理流程,使得开发者更容易处理成功和失败的情况,同时客户端也更容易理解和处理 API 的响应。这一实践有助于降低维护成本、提高团队协作效率,并促进代码的规范化。
统一异常处理
统一异常处理的必要性体现在保持代码的一致性、提供更清晰的错误信息、以及更容易排查问题。通过定义统一的异常处理方式,确保在整个应用中对异常的处理保持一致,减少了重复编写相似异常处理逻辑的工作,同时提供友好的错误信息帮助开发者和维护人员更快地定位和解决问题,最终提高了应用的可维护性和可读性。
1.定义统一的异常类
我们需要定义服务中可能抛出的自定义异常类。这些异常类可以继承自RuntimeException
,并携带有关异常的相关信息。即可理解为局部异常,用于特定的业务处理中异常。手动埋点抛出。
@Getter
public class ServiceException extends RuntimeException{
private static final long serialVersionUID = -3303518302920463234L;
private final StatusEnum status;
public ServiceException(StatusEnum status, String message) {
super(message);
this.status = status;
}
public ServiceException(StatusEnum status) {
this(status, status.message);
}
}
2.异常处理器
创建一个全局的异常处理器,使用@ControllerAdvice
或者 @RestControllerAdvice
注解和@ExceptionHandler
注解来捕获不同类型的异常,并定义处理逻辑。
2.1 @ControllerAdvice注解
用于声明一个全局控制器建言(Advice),相当于把@ExceptionHandler
、@InitBinder
和@ModelAttribute
注解的方法集中到一个地方。常放在一个特定的类上,这个类被认为是全局异常处理器,可以跨足多个控制器。
当时用
@ControllerAdvice
时,我们需要在异常处理方法上加上@ResponseBody
,同理我们的web接口。但是如果我们使用@RestControllerAdvice
就可以不用加,同理也是web定义的接口
2.2 @ExceptionHandler
注解
用于定义异常处理方法,处理特定类型的异常。放在全局异常处理器类中的具体方法上。
通过这两个注解的配合,可以实现全局的异常处理。当控制器中抛出异常时,Spring Boot会自动调用匹配的@ExceptionHandler
方法来处理异常,并返回定义的响应。
@Slf4j
@ControllerAdvice
public class ExceptionAdvice {
/**
* 处理ServiceException
* @param serviceException ServiceException
* @param request 请求参数
* @return 接口响应
*/
@ExceptionHandler(ServiceException.class)
@ResponseBody
public ResultResponse<Void> handleServiceException(ServiceException serviceException, HttpServletRequest request) {
log.warn("request {} throw ServiceException \n", request, serviceException);
return ResultResponse.error(serviceException.getStatus(), serviceException.getMessage());
}
/**
* 其他异常拦截
* @param ex 异常
* @param request 请求参数
* @return 接口响应
*/
@ExceptionHandler(Exception.class)
@ResponseBody
public ResultResponse<Void> handleException(Exception ex, HttpServletRequest request) {
log.error("request {} throw unExpectException \n", request, ex);
return ResultResponse.error(StatusEnum.SERVICE_ERROR);
}
}
3.异常统一处理使用
在业务开发过程中,我们可以在service
层处理业务时,可以手动抛出业务异常。由全局异常处理器进行统一处理。
@Service
@Slf4j
public class UserServiceImpl implements IUserService {
private IUserManager userManager;
/**
* 创建用户
*
* @param requestVO 请求参数
*/
@Override
public void createUser(UserCreateRequestVO requestVO) {
final UserDO userDO = userManager.selectUserByName(requestVO.getUserName());
if (userDO != null){
throw new ServiceException(StatusEnum.PARAM_INVALID, "用户名已存在");
}
}
@Autowired
public void setUserManager(IUserManager userManager) {
this.userManager = userManager;
}
}
@RestController
@RequestMapping("user")
@Validated
@Slf4j
public class UserController {
private IUserService userService;
/**
* 创建用户
* @param requestVO
* @return
*/
@PostMapping("create")
public ResultResponse<Void> createUser(@Validated @RequestBody UserCreateRequestVO requestVO){
userService.createUser(requestVO);
return ResultResponse.success(null);
}
@Autowired
public void setUserService(IUserService userService) {
this.userService = userService;
}
}
当我们请求接口时,假如用户名称已存在,接口就会响应:
{
"code": 1000,
"msg": "用户名已存在",
"data": null
}
统一异常处理带来的好处包括提供一致的异常响应格式,简化异常处理逻辑,记录更好的错误日志,以及更容易排查和解决问题。通过统一处理异常,我们确保在整个应用中对异常的处理方式一致,减少了重复性代码的编写,提高了代码的规范性。简化的异常处理逻辑降低了开发者的工作负担,而更好的错误日志有助于更迅速地定位和解决问题,最终提高了应用的可维护性和稳定性。
其他类型的异常处理
在项目开发过程中,我们还有一些常见的特定异常类型,比如MethodArgumentNotValidException
和UnexpectedTypeException
等,并为它们定义相应的异常处理逻辑。这些特定异常可能由于请求参数校验失败或意外的数据类型问题而引起,因此有必要为它们单独处理,以提供更具体和友好的异常响应。
1.MethodArgumentNotValidException
由于请求参数校验失败引起的异常,通常涉及到使用@Valid
注解或者@Validated
进行请求参数校验。我们可以在异常处理器中编写@ExceptionHandler
方法,捕获并处理MethodArgumentNotValidException
,提取校验错误信息,并返回详细的错误响应。
/**
* 参数非法校验
* @param ex
* @return
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseBody
public ResultResponse<Void> handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) {
try {
List<ObjectError> errors = ex.getBindingResult().getAllErrors();
String message = errors.stream().map(ObjectError::getDefaultMessage).collect(Collectors.joining(","));
log.error("param illegal: {}", message);
return ResultResponse.error(StatusEnum.PARAM_INVALID, message);
} catch (Exception e) {
return ResultResponse.error(StatusEnum.SERVICE_ERROR);
}
}
当我们使用@Valid
注解或者@Validated
进行请求参数校验不通过时,响应结果为:
{
"code": 1000,
"msg": "请输入地址信息,用户年龄必须小于60岁,请输入你的兴趣爱好",
"data": null
}
关于
@Valid
注解或者@Validated
进行参数校验的功能请参考:SpringBoot优雅校验参数
2.UnexpectedTypeException
意外的数据类型异常,通常表示程序运行时发生了不符合预期的数据类型问题。一个常见的使用场景是在数据转换或类型处理的过程中。例如,在使用 Spring 表单绑定或数据绑定时,如果尝试将一个不符合预期类型的值转换为目标类型,就可能抛出 UnexpectedTypeException
。这通常会发生在将字符串转换为数字、日期等类型时,如果字符串的格式不符合目标类型的要求。
我们可以在异常处理器中编写@ExceptionHandler
方法,捕获并处理UnexpectedTypeException
,提供适当的处理方式,例如记录错误日志,并返回合适的错误响应。
@ExceptionHandler(UnexpectedTypeException.class)
@ResponseBody
public ResultResponse<Void> handleUnexpectedTypeException(UnexpectedTypeException ex,
HttpServletRequest request) {
log.error("catch UnexpectedTypeException, errorMessage: \n", ex);
return ResultResponse.error(StatusEnum.PARAM_INVALID, ex.getMessage());
}
当发生异常时,接口会响应:
{
"code": 500,
"msg": "服务器去旅行了,请稍后重试",
"data": null
}
3.ConstraintViolationException
javax.validation.ConstraintViolationException
是 Java Bean Validation(JSR 380)中的一种异常。它通常在使用 Bean Validation 进行数据校验时,如果校验失败就会抛出这个异常。即我们在使用自定义校验注解时,如果不满足校验规则,就会抛出这个错误。
@ExceptionHandler(ConstraintViolationException.class)
@ResponseBody
public ResultResponse<Void> handlerConstraintViolationException(ConstraintViolationException ex, HttpServletRequest request) {
log.error("request {} throw ConstraintViolationException \n", request, ex);
return ResultResponse.error(StatusEnum.PARAM_INVALID, ex.getMessage());
}
案例请参考:SpringBoot优雅校验参数,注册ConstraintValidator示例中的
@UniqueUser
校验。
4.HttpMessageNotReadableException
表示无法读取HTTP消息的异常,通常由于请求体不合法或不可解析。
@ResponseBody
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResultResponse<Void> handleHttpMessageNotReadableException(HttpMessageNotReadableException ex,
HttpServletRequest request) {
log.error("request {} throw ucManagerException \n", request, ex);
return ResultResponse.error(StatusEnum.SERVICE_ERROR);
}
5.HttpRequestMethodNotSupportedException
Spring Framework 中的异常类,表示请求的 HTTP 方法不受支持。当客户端发送了一个使用不被服务器支持的 HTTP 方法(如 GET、POST、PUT、DELETE等)的请求时,可能会抛出这个异常。
@ExceptionHandler({HttpRequestMethodNotSupportedException.class, HttpMediaTypeException.class})
@ResponseBody
public ResultResponse<Void> handleMethodNotSupportedException(Exception ex) {
log.error("HttpRequestMethodNotSupportedException \n", ex);
return ResultResponse.error(StatusEnum.HTTP_METHOD_NOT_SUPPORT);
}
全局异常处理与局部异常处理在Spring Boot应用开发中扮演不同角色。全局异常处理通过统一的异常处理器确保了整个应用对异常的处理一致性,减少了冗余代码,提高了代码的整洁度。然而,这种方式可能在灵活性上略显不足,无法满足每个具体控制器或业务场景的个性化需求。
相比之下,局部异常处理能够为每个控制器或业务场景提供更具体、灵活的异常处理逻辑,允许定制化的异常响应。这使得在复杂的项目中更容易处理特定的异常情况,同时提供更详细的错误信息。然而,局部异常处理可能带来代码冗余和维护难度的问题,特别是在大型项目中。
在实际应用中,选择全局异常处理还是局部异常处理应根据项目规模和需求进行权衡。对于小型项目或简单场景,全局异常处理可能是一种更简单、合适的选择。而对于大型项目或需要个性化异常处理的复杂业务逻辑,局部异常处理则提供了更为灵活的方案。最佳实践是在项目中根据具体情况灵活使用这两种方式,以平衡一致性和个性化需求。
最佳实践与注意事项
1. 最佳实践
- 统一响应格式: 在异常处理中,使用统一的响应格式有助于客户端更容易理解和处理错误。通常,返回一个包含错误码、错误信息和可能的详细信息的响应对象。
- 详细错误日志: 在异常处理中记录详细的错误日志,包括异常类型、发生时间、请求信息等。这有助于快速定位和解决问题。
- 使用HTTP状态码: 根据异常的性质,选择适当的HTTP状态码。例如,使用
HttpStatus.NOT_FOUND
表示资源未找到,HttpStatus.BAD_REQUEST
表示客户端请求错误等。 - 异常分类: 根据异常的种类,合理分类处理。可以定义不同的异常类来表示不同的异常情况,然后在异常处理中使用
@ExceptionHandler
分别处理。 - 全局异常处理: 使用全局异常处理机制来捕获未被特定控制器处理的异常,以确保应用在整体上的健壮性。
2 注意事项
- 不滥用异常: 异常应该用于表示真正的异常情况,而不是用作控制流程。滥用异常可能导致性能问题和代码可读性降低。
- 不忽略异常: 避免在异常处理中忽略异常或仅仅打印日志而不进行适当的处理。这可能导致潜在的问题被掩盖,难以追踪和修复。
- 避免空的catch块: 不要在
catch
块中什么都不做,这样会使得异常难以被发现。至少在catch
块中记录日志,以便了解异常的发生。 - 适时抛出异常: 不要过于吝啬地抛出异常,但也不要无谓地滥用。在必要的时候使用异常,例如表示无法继续执行的错误情况。
- 测试异常场景: 编写单元测试时,确保覆盖异常场景,验证异常的正确抛出和处理。
总结
异常处理在应用开发中是至关重要的一环,它能够提高应用的健壮性、可读性和可维护性。全局异常处理和局部异常处理各有优劣,需要根据项目的规模和需求来灵活选择。通过采用统一的响应格式、详细的错误日志、适当的HTTP状态码等最佳实践,可以使异常处理更为有效和易于管理。同时,注意避免滥用异常、忽略异常、适时抛出异常等注意事项,有助于确保异常处理的质量。在开发过程中,持续关注和优化异常处理,将有助于提高应用的稳定性和用户体验。
本文已收录我的个人博客:码农Academy的博客,专注分享Java技术干货,包括Java基础、Spring Boot、Spring Cloud、Mysql、Redis、Elasticsearch、中间件、架构设计、面试题、程序员攻略等
来源:juejin.cn/post/7322463748006248459
不要再用 StringBuilder 拼接字符串了,来试试字符串模板
引言
字符串操作是 Java 中使用最频繁的操作,没有之一。其中非常常见的操作之一就是对字符串的组织,由于常见所以就衍生了多种方案。比如我们要实现 x + y = ?
,方案有如下几种
- 使用
+
进行字符串拼接
String s = x + " + " + y + " = " + (x + y);
- 使用 StringBuilder
String s = new StringBuilder()
.append(x)
.append(" + ")
.append(y)
.append(" = ")
.append(x + y)
.toString()
String::format
和String::formatted
将格式字符串从参数中分离出来
String s = String.format("%2$d + %1$d = %3$d", x, y, x + y);
or
String s = "%2$d + %1$d = %3$d".formatted(x, y, x + y);
java.text.MessageFormat
String s = MessageFormat.format("{0} + {1} = {2}", x,y, x + y);
这四种方案虽然都可以解决,但很遗憾的是他们或多或少都有点儿缺陷,尤其是面对 Java 13 引入的文本块(Java 13 新特性—文本块)更是束手无措。
字符串模板
为了简化字符串的构造和格式化,Java 21 引入字符串模板功能,该特性主要目的是为了提高在处理包含多个变量和复杂格式化要求的字符串时的可读性和编写效率。
它的设计目标是:
- 通过简单的方式表达混合变量的字符串,简化 Java 程序的编写。
- 提高混合文本和表达式的可读性,无论文本是在单行源代码中(如字符串字面量)还是跨越多行源代码(如文本块)。
- 通过支持对模板及其嵌入式表达式的值进行验证和转换,提高根据用户提供的值组成字符串并将其传递给其他系统(如构建数据库查询)的 Java 程序的安全性。
- 允许 Java 库定义字符串模板中使用的格式化语法(java.util.Formatter ),从而保持灵活性。
- 简化接受以非 Java 语言编写的字符串(如 SQL、XML 和 JSON)的 API 的使用。
- 支持创建由字面文本和嵌入式表达式计算得出的非字符串值,而无需通过中间字符串表示。
该特性处理字符串的新方法称为:Template Expressions,即:模版表达式。它是 Java 中的一种新型表达式,不仅可以执行字符串插值,还可以编程,从而帮助开发人员安全高效地组成字符串。此外,模板表达式并不局限于组成字符串——它们可以根据特定领域的规则将结构化文本转化为任何类型的对象。
STR 模板处理器
STR
是 Java 平台定义的一种模板处理器。它通过用表达式的值替换模板中的每个嵌入表达式来执行字符串插值。使用 STR 的模板表达式的求值结果是一个字符串。
STR
是一个公共静态 final 字段,会自动导入到每个 Java 源文件中。
我们先看一个简单的例子:
@Test
public void STRTest() {
String sk = "死磕 Java 新特性";
String str1 = STR."{sk},就是牛";
System.out.println(str1);
}
// 结果.....
死磕 Java 新特性,就是牛
上面的 STR."{sk},就是牛"
就是一个模板表达式,它主要包含了三个部分:
- 模版处理器:
STR
- 包含内嵌表达式(
{blog}
)的模版 - 通过
.
把前面两部分组合起来,形式如同方法调用
当模版表达式运行的时候,模版处理器会将模版内容与内嵌表达式的值组合起来,生成结果。
这个例子只是 STR模版处理器一个很简单的功能,它可以做的事情有很多。
- 数学运算
比如上面的 x + y = ?
:
@Test
public void STRTest() {
int x = 1,y =2;
String str = STR."{x} + {y} = {x + y}";
System.out.println(str);
}
这种写法是不是简单明了了很多?
- 调用方法
STR模版处理器还可以调用方法,比如:
String str = STR."今天是:{ LocalDate.now()} ";
当然也可以调用我们自定义的方法:
@Test
public void STRTest() {
String str = STR."{getSkStr()},就是牛";
System.out.println(str);
}
public String getSkStr() {
return "死磕 Java 新特性";
}
- 访问成员变量
STR模版处理器还可以访问成员变量,比如:
public record User(String name,Integer age) {
}
@Test
public void STRTest() {
User user = new User("大明哥",18);
String str = STR."{user.name()}今年{user.age()}";
System.out.println(str);
}
需要注意的是,字符串模板表达式中的嵌入表达式数量没有限制,它从左到右依次求值,就像方法调用表达式中的参数一样。例如:
@Test
public void STRTest() {
int i = 0;
String str = STR."{i++},{i++},{i++},{i++},{i++}";
System.out.println(str);
}
// 结果......
0,1,2,3,4
同时,表达式中也可以嵌入表达式:
@Test
public void STRTest() {
String name = "大明哥";
String sk = "死磕 Java 新特性";
String str = STR."{name}的{STR."{sk},就是牛..."}";
System.out.println(str);
}
// 结果......
大明哥的死磕 Java 新特性,就是牛...
但是这种嵌套的方式会比较复杂,容易搞混,一般不推荐。
多行模板表达式
为了解决多行字符串处理的复杂性,Java 13 引入文本块(Java 13 新特性—文本块),它是使用三个双引号("""
)来标记字符串的开始和结束,允许字符串跨越多行而无需显式的换行符或字符串连接。如下:
String html = """
<html>
<body>
<h2>skjava.com</h2>
<ul>
<li>死磕 Java 新特性</li>
<li>死磕 Java 并发</li>
<li>死磕 Netty</li>
<li>死磕 Redis</li>
</ul>
</body>
</html>
""";
如果字符串模板表达式,我们就只能拼接这串字符串了,这显得有点儿繁琐和麻烦。而字符串模版表达式也支持多行字符串处理,我们可以利用它来方便的组织html、json、xml等字符串内容,比如这样:
@Test
public void STRTest() {
String title = "skjava.com";
String sk1 = "死磕 Java 新特性";
String sk2 = "死磕 Java 并发";
String sk3 = "死磕 Netty";
String sk4 = "死磕 Redis";
String html = STR."""
<html>
<body>
<h2>{title}</h2>
<ul>
<li>{sk1}</li>
<li>{sk2}</li>
<li>{sk3}</li>
<li>{sk4}</li>
</ul>
</body>
</html>
""";
System.out.println(html);
}
如果决定定义四个 sk
变量麻烦,可以整理为一个集合,然后调用方法生成 <li>
标签。
FMT 模板处理器
FMT 是 Java 定义的另一种模板处理器。它除了与STR模版处理器一样提供插值能力之外,还提供了左侧的格式化处理。下面我们来看看他的功能。比如我们要整理模式匹配的 Switch 表达在 Java 版本中的迭代,也就是下面这个表格
Java 版本 | 更新类型 | JEP | 更新内容 |
---|---|---|---|
Java 17 | 第一次预览 | JEP 406 | 引入模式匹配的 Swith 表达式作为预览特性。 |
Java 18 | 第二次预览 | JEP 420 | 对其做了改进和细微调整 |
Java 19 | 第三次预览 | JEP 427 | 进一步优化模式匹配的 Swith 表达式 |
Java 20 | 第四次预览 | JEP 433 | |
Java 21 | 正式特性 | JEP 441 | 成为正式特性 |
如果使用 STR 模板处理器,代码如下:
@Test
public void STRTest() {
SwitchHistory[] switchHistories = new SwitchHistory[]{
new SwitchHistory("Java 17","第一次预览","JEP 406","引入模式匹配的 Swith 表达式作为预览特性。"),
new SwitchHistory("Java 18","第二次预览","JEP 420","对其做了改进和细微调整"),
new SwitchHistory("Java 19","第三次预览","JEP 427","进一步优化模式匹配的 Swith 表达式"),
new SwitchHistory("Java 20","第四次预览","JEP 433",""),
new SwitchHistory("Java 21","正式特性","JEP 441","成为正式特性"),
};
String history = STR."""
Java 版本 更新类型 JEP 更新内容
{switchHistories[0].javaVersion()} {switchHistories[0].updateType()} {switchHistories[0].jep()} {switchHistories[0].content()}
{switchHistories[1].javaVersion()} {switchHistories[1].updateType()} {switchHistories[1].jep()} {switchHistories[1].content()}
{switchHistories[2].javaVersion()} {switchHistories[2].updateType()} {switchHistories[2].jep()} {switchHistories[2].content()}
{switchHistories[3].javaVersion()} {switchHistories[3].updateType()} {switchHistories[3].jep()} {switchHistories[3].content()}
{switchHistories[4].javaVersion()} {switchHistories[4].updateType()} {switchHistories[4].jep()} {switchHistories[4].content()}
""";
System.out.println(history);
}
得到的效果是这样的:
Java 版本 更新类型 JEP 更新内容
Java 17 第一次预览 JEP 406 引入模式匹配的 Swith 表达式作为预览特性。
Java 18 第二次预览 JEP 420 对其做了改进和细微调整
Java 19 第三次预览 JEP 427 进一步优化模式匹配的 Swith 表达式
Java 20 第四次预览 JEP 433
Java 21 正式特性 JEP 441 成为正式特性
是不是很丑?完全对不齐,没法看。为了解决这个问题,就可以采用FMT模版处理器,在每一列左侧定义格式:
@Test
public void STRTest() {
SwitchHistory[] switchHistories = new SwitchHistory[]{
new SwitchHistory("Java 17","第一次预览","JEP 406","引入模式匹配的 Swith 表达式作为预览特性。"),
new SwitchHistory("Java 18","第二次预览","JEP 420","对其做了改进和细微调整"),
new SwitchHistory("Java 19","第三次预览","JEP 427","进一步优化模式匹配的 Swith 表达式"),
new SwitchHistory("Java 20","第四次预览","JEP 433",""),
new SwitchHistory("Java 21","正式特性","JEP 441","成为正式特性"),
};
String history = FMT."""
Java 版本 更新类型 JEP 更新内容
%-10s{switchHistories[0].javaVersion()} %-9s{switchHistories[0].updateType()} %-10s{switchHistories[0].jep()} %-20s{switchHistories[0].content()}
%-10s{switchHistories[1].javaVersion()} %-9s{switchHistories[1].updateType()} %-10s{switchHistories[1].jep()} %-20s{switchHistories[1].content()}
%-10s{switchHistories[2].javaVersion()} %-9s{switchHistories[2].updateType()} %-10s{switchHistories[2].jep()} %-20s{switchHistories[2].content()}
%-10s{switchHistories[3].javaVersion()} %-9s{switchHistories[3].updateType()} %-10s{switchHistories[3].jep()} %-20s{switchHistories[3].content()}
%-10s{switchHistories[4].javaVersion()} %-9s{switchHistories[4].updateType()} %-10s{switchHistories[4].jep()} %-20s{switchHistories[4].content()}
""";
System.out.println(history);
}
输出如下:
Java 版本 更新类型 JEP 更新内容
Java 17 第一次预览 JEP 406 引入模式匹配的 Swith 表达式作为预览特性。
Java 18 第二次预览 JEP 420 对其做了改进和细微调整
Java 19 第三次预览 JEP 427 进一步优化模式匹配的 Swith 表达式
Java 20 第四次预览 JEP 433
Java 21 正式特性 JEP 441 成为正式特性
来源:juejin.cn/post/7323251349302706239
SpringBoot接收参数的19种方式
1. Get 请求
1.1 以方法的形参接收参数
1.这种方式一般适用参数比较少的情况
@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@GetMapping("/detail")
public Result<User> getUserDetail(String name,String phone) {
log.info("name:{}",name);
log.info("phone:{}",phone);
return Result.success(null);
}
}
2.参数用 @RequestParam 标注,表示这个参数需要必传,否则会报错。
@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@GetMapping("/detail")
public Result<User> getUserDetail(@RequestParam String name,@RequestParam String phone) {
log.info("name:{}",name);
log.info("phone:{}",phone);
return Result.success(null);
}
}
1.2 以实体类接收参数
@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@GetMapping("/detail")
public Result<User> getUserDetail(User user) {
log.info("name:{}",user.getName());
log.info("phone:{}",user.getPhone());
return Result.success(null);
}
}
注:Get 请求以实体类接收参数时,不能用 RequestParam 注解进行标注,因为不支持这样的方式获取参数。
1.3 通过 HttpServletRequest 接收参数
@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@GetMapping("/detail")
public Result<User> getUserDetail(HttpServletRequest request) {
String name = request.getParameter("name");
String phone = request.getParameter("phone");
log.info("name:{}",name);
log.info("phone:{}",phone);
return Result.success(null);
}
}
1.4 通过 @PathVariable 注解接收参数
@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@GetMapping("/detail/{name}/{phone}")
public Result<User> getUserDetail(@PathVariable String name,@PathVariable String phone) {
log.info("name:{}",name);
log.info("phone:{}",phone);
return Result.success(null);
}
}
1.5 接收数组参数
@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@GetMapping("/detail")
public Result<User> getUserDetail(String[] names) {
Arrays.asList(names).forEach(name->{
System.out.println(name);
});
return Result.success(null);
}
}
1.6 接收集合参数
springboot 接收集合参数,需要用 RequestParam 注解绑定参数,否则会报错!!
@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@GetMapping("/detail")
public Result<User> getUserDetail(@RequestParam List<String> names) {
names.forEach(name->{
System.out.println(name);
});
return Result.success(null);
}
}
2. Post 请求
2.1 以方法的形参接收参数
@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@PostMapping("/save")
public Result<User> getUserDetail(String name,String phone) {
log.info("name:{}",name);
log.info("phone:{}",phone);
return Result.success(null);
}
}
注:和 Get 请求一样,如果方法形参用 RequestParam 注解标注,表示这个参数需要必传。
2.2 通过 param 提交参数,以实体类接收参数
@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@PostMapping("/save")
public Result<User> getUserDetail(User user) {
log.info("name:{}",user.getName());
log.info("phone:{}",user.getPhone());
return Result.success(null);
}
}
注:Post 请求以实体类接收参数时,不能用 RequestParam 注解进行标注,因为不支持这样的方式获取参数。
2.3 通过 HttpServletRequest 接收参数
@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@PostMapping("/save")
public Result<User> getUserDetail(HttpServletRequest httpServletRequest) {
log.info("name:{}",httpServletRequest.getParameter("name"));
log.info("phone:{}",httpServletRequest.getParameter("phone"));
return Result.success(null);
}
}
2.4 通过 @PathVariable 注解进行接收
@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@PostMapping("/save/{name}")
public Result<User> getUserDetail(@PathVariable String name) {
log.info("name:{}",name);
return Result.success(null);
}
}
2.5 请求体以 form-data 提交参数,以实体类接收参数
form-data 是表单提交的一种方式,比如常见的登录请求。
@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@PostMapping("/save")
public Result<User> getUserDetail(User user) {
log.info("name:{}",user.getName());
log.info("phone:{}",user.getPhone());
return Result.success(null);
}
}
2.6 请求体以 x-www-form-urlencoded 提交参数,以实体类接收参数
x-www-form-urlencoded 也是表单提交的一种方式,只不过提交的参数被进行了编码,并且转换成了键值对。
例如你用form-data 提交的参数:
name: 知否君
age: 22
用 x-www-form-urlencoded 提交的参数:
name=%E5%BC%A0%E4%B8%89&age=22
@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@PostMapping("/save")
public Result<User> getUserDetail(User user) {
log.info("name:{}",user.getName());
log.info("phone:{}",user.getPhone());
return Result.success(null);
}
}
2.7 通过 @RequestBody 注解接收参数
注:RequestBody 注解主要用来接收前端传过来的 body 中 json 格式的参数。
2.7.1 接收实体类参数
@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@PostMapping("/save")
public Result<User> getUserDetail(@RequestBody User user) {
log.info("name:{}",user.getName());
log.info("phone:{}",user.getPhone());
return Result.success(null);
}
}
2.7.2 接收数组和集合
接收数组
@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@PostMapping("/save")
public Result<User> getUserDetail(@RequestBody String[] names) {
Arrays.asList(names).forEach(name->{
System.out.println(name);
});
return Result.success(null);
}
}
接收集合
@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@PostMapping("/save")
public Result<User> getUserDetail(@RequestBody List<String> names) {
names.forEach(name->{
System.out.println(name);
});
return Result.success(null);
}
}
2.8 通过 Map 接收参数
1.以 param 方式传参, RequestParam 注解接收参数
@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@PostMapping("/save")
public Result<User> getUserDetail(@RequestParam Map<String,Object> map) {
System.out.println(map);
System.out.println(map.get("name"));
return Result.success(null);
}
}
2.以 body json 格式传参,RequestBody 注解接收参数
@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@PostMapping("/save")
public Result<User> getUserDetail(@RequestBody Map<String,Object> map) {
System.out.println(map);
System.out.println(map.get("name"));
return Result.success(null);
}
}
2.9 RequestBody 接收一个参数
@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@PostMapping("/save")
public Result<User> getUserDetail(@RequestBody String name) {
System.out.println(name);
return Result.success(null);
}
}
3. Delete 请求
3.1 以 param 方式传参,以方法形参接收参数
@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@DeleteMapping("/delete")
public Result<User> getUserDetail(@RequestParam String name) {
System.out.println(name);
return Result.success(null);
}
}
3.2 以 body json 方式传参,以实体类接收参数
注:需要用 RequestBody 注解,否则接收的参数为 null
@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@DeleteMapping("/delete")
public Result<User> getUserDetail(@RequestBody User user) {
System.out.println(user);
return Result.success(null);
}
}
3.3 以 body json 方式传参,以 map 接收参数
注:需要用 RequestBody 注解,否则接收的参数为 null
@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@DeleteMapping("/delete")
public Result<User> getUserDetail(@RequestBody Map<String,Object> map) {
System.out.println(map);
return Result.success(null);
}
}
3.4 PathVariable 接收参数
@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@DeleteMapping("/delete/{name}")
public Result<User> getUserDetail(@PathVariable String name) {
System.out.println(name);
return Result.success(null);
}
}
来源:juejin.cn/post/7343243744479625267
在上海做程序员这么多年,退休后我的工资是多少?
大家好,我是拭心。
最近看到一个很可惜的事:有个阿姨在深圳缴纳了 12 年社保,第 13 年家里突然有事不得不回老家,回去后没再缴纳社保,结果退休后无法领退休工资,还得出来打工赚钱。
之所以这样,是因为阿姨及其家人对退休的相关知识了解不多,痛失一笔收入。
吸取教训,作为在上海工作多年的打工人,为了老有所依,我花了些时间学习了养老金相基本知识,并且估算了一下退休后每月能拿到的钱,这篇文章来聊聊。
文章主要内容:
- 如何能在上海领退休工资
- 我退休后大概能领多少钱
- 退休工资的组成
如何能在上海领退休工资
上海作为全国 GDP 第一的城市,居民的收入也是很可观的。平均工资在 2023 年达到了 12183,平均退休工资在全国也位列前茅:
从上图可以看到,上海的平均退休工资居然有五千多!不用早起不用挤地铁,躺在家里每月就能领五千多,花不完,根本花不完啊!
那么问题来了,我们如何能领到上海的退休工资?
主要有 2 个条件:
- 达到退休年龄:男性满 60 周岁,女性满 50 周岁(灵活就业人员需要满 55 周岁)
- 退休前累计缴费社保 >= 180 个月(也就是 15 年),其中在上海至少缴满 120 个月(10 年)
第二点很关键:在上海需要缴满社保 10 年,上海 + 其他地方累计需要缴满 15 年。 比如小张在上海工作并缴纳社保满 10 年,然后去青岛缴纳 5 年,最后可以在上海领退休工资;但如果在青岛缴纳了 10 年,在上海缴纳了 5 年,就无法在上海领退休工资了。
需要强调的是,这里说的是「累计缴满」,即使中间有断开也没关系。
还有一个细节是,发工资不等于缴纳社保,个别不正规公司会漏缴社保,这需要我们打工人自己多关注。 身边有朋友遇到过:刚毕业加入的公司规模很小,人力资源不靠谱,干了半年多只缴纳了两个月社保。
怎么看公司有没有给自己缴社保呢?我们可以从随申办上查询:
OK,这就是在上海领退休工资的条件。
退休后大概能领多少钱
掐指一算,社保缴满 15 年的任务我已经完成了一半,但还有二十九年才能领钱,心里苦啊😭。
虽然拿不到,但我对能领多少钱还是非常好奇的,究竟比平均退休工资高还是被平均?🤔
经过一番搜索,我终于发现了退休工资的计算方法。
在国家社会保险公共服务平台网站中,有一个「企业职工养老保险待遇测算」的功能:
国家社会保险公共服务平台 -> 养老保险 -> 企业职工养老保险待遇测算
链接:si.12333.gov.cn/157569.jhtm…
我们只需要输入年龄、预计退休年龄、当前缴纳年限、目前及之前平均工资、养老保险个人账户余额及未来大概工资即可测算退休工资。
如果不知道你的「养老保险个人账户余额」有多少,可以从随申办 app 搜索「养老金」查询余额:
在填完所有需要的信息后,我的预算结果是这样的:
我的天,个十百千万,居然有两万五?这还花的完??
几秒后冷静下来,我才发现算错了。。。
两万五应该是这样算的:按照当前收入,再连续缴纳 29 年。 😂
对于我们程序员,保持收入 29 年基本是不可能的,我还是重新调整参数再看看吧。
我现在社保缴纳了七年半,如果缴够 15 年社保,退休后我能领多少钱呢?
答案是一万四!看着还不错哈,每天能有 480 元左右,就是不知道 30 年后的物价怎么样了😂。
如果再悲观一点,社保缴纳到 35 岁(然后最低标准缴够 15 年),退休后大概能领多少呢?
答案是一万元!
看了下人民币的贬值率,30 年后的一万元不知道有没有今天三千块钱的购买力😷。
OK,这就是我退休后大概能领到的工资范围。
退休工资的组成
从上面的预算结果中我们可以看到,养老金由三部分组成:基础养老金、个人账户养老金和过渡性养老金,它们都是什么意思呢?
1.基础养老金 = 退休时平均工资 ×(1+平均缴费指数)÷ 2 × 累计缴费年限 × 1%。
退休时平均工资指的是退休时所在地区上年度的社会平均工资。也就是说经济发达地区的基础养老保险金,要高于欠发达地区。
平均缴费指数指的参保人选择的缴纳比例(一般在0.6-3之间)。每个月社保的缴费比例越高,相应的基础养老金越高。
例如,小张退休时,上年度的社会平均工资是 12000。虎虎的缴费指数平均值是 1,累计缴存了15年,他的基础养老金约为:12000*(1+1)/2 * 15 * 1% = 1800。
2.个人账户养老金 = 养老保险个人账户累计金额 ÷ 养老金计发月数。
我们缴纳社保时,一部分会进入个人社保账户,一部分会进入国家统筹账户。个人账户的部分,直接影响退休养老金的计算。
计发月数和我们退休的年龄有直接的关系,退休的越晚,计发月数越少;退休的时间越早,计发月数越多。一般来说,按照 60岁 退休,计发月数是 139 个月。
退休金的计发月数只是用来计算退休金,而不是说只能领这么久的退休金。
例如,小张社保的个人缴纳比例为 8%,社保的计算基数是 9339,他选择在 60 岁退休,那他的个人账户养老金约为:9339 * 8% * 12 * 15 / 139 = 967.49。
3.过渡性养老金 = 退休时平均工资 × 建立个人账户前的缴费年限 × 1.3% × 平均缴费指数
过渡性养老金,是指在养老保险制度发生变化(比如缴费标准提高、计算方法改变、退休年龄调整)的时候,给予受影响群体的资金补充。
这个奖金的计算规则说法不一,一种比较广泛的计算方法是:退休时所在地区的平均工资 x 缴费指数 x 缴费年限 x 过渡系数,其中过渡系数大概在 1% 到 1.4% 之间。
OK,这就是养老金三部分组成的含义。
总结
好了,这篇文章到这里就结束了,主要讲了:
- 如何能上海领退休工资:缴纳 10~15 年社保,到达退休年龄
- 我退休后大概能领多少钱:30 年后的一万左右
- 退休工资的三部分组成:基础养老金、个人账户养老金和过渡性养老金
通过写这篇文章,我对养老金的认识更多了一些,希望国家繁荣昌盛,让我退休的时候能多领点钱!
来源:juejin.cn/post/7327480122407141388
计算机还值得学吗?互联网还能来吗?
这几天,高考的话题热度不减,作为一名有着数百位粉丝的微V,我决定来蹭一波流量😎。
2004年,高三,最后一次模拟考试,班级第1,年级第10,能上中科大。
半个月后,高考,班级第30名,比一本线还少了20来分,史上最烂。
我想学电子信息类的,报了一些之前根本瞧不上的学校,全部被拒。不得已,去了医科大。
医学,甚是无趣和枯燥;自学了1年的数学和计算机专业课后,跨考中科大的计算机系,一战上岸。
2012年,毕业,北漂,辗转了3个知名互联网公司,直接下属20余人,负责的项目日流水数千万。
2022年,因为家庭和户口的原因,回老家上班,断崖式降薪。从头开始,下属归零,继续当大头兵。
四处降本增笑的今天,互联网公司还能去吗?还能报计算机专业吗?实话说,我不知道,也没有答案。
我只能说我从不后悔放弃医学,读研时选择了计算机专业,更不后悔进入了互联网行业。
理由如下:
- 我对医学实在是没兴趣,无论是教书还是当医生,肯定都是混日子,误人子弟或害人性命,天理不容。
- 读研,虽然没能研究出什么名堂,但是凭着兴趣做了一些 APP,顺利敲开了互联网大厂的的大门。
- 相对来说,互联网还是比较公平的,只要技术过的去,迟早会晋升涨薪;北京10年,我薪资翻了10余倍。
- 我父母都是农民,无法在经济上提供帮助。靠着相对不错的收入,我完成了结婚、生娃、买房、买车的大事。
- 开发,没有太多人际关系的破事,安心做好自己的事就行。喝酒?谁爱喝谁喝,反正我不喝,问就是吃头孢。
肯定会有人喷我,站在了风口上,赶上了互联网的红利。今时不同往日,广进搞的飞起,保住饭碗就不错了。
另外,996太辛苦,有命赚没命花;ChatGPT 太牛逼,码农的饭碗迟早被砸;最难受的是,35岁就得滚蛋。
以上,大部分是事实,而且很操蛋。以下是我的个人观点,不喜勿喷:
- 对于没背景的小镇做题家来说,互联网依然是不错的选择,至少能让你前期的财务状况比较好
- 广进、35岁魔咒,我也不知道怎么办。说句废话,降低负债,降低欲望,趁年轻,尽量多赚点
- 互联网加班虽然多,但996是少数,我周末几乎没加过;ChatGPT,未来不好说,现在干不掉码农
甘蔗没有两头甜,如果能找到「钱多事少离家近」的金饭碗,谁特么愿意做社畜?
总结,在没有更好的选择的前提下,计算机值得一学,互联网也可以来。当然,不能像我对医学那么抵触。
以上,不构成志愿和职业的选择建议,风险自负。
来源:juejin.cn/post/7385054068525514788
是的,JDK 也有不为人知的“屎山”!
在前几天我写了一篇文章分享了为何避免使用 Collectors.toMap()
,感兴趣的可以去瞧一眼:Stream很好,Map很酷,但答应我别用toMap()。
评论区也有小伙伴提到自己也踩过同样的坑,在那篇文章里介绍了 toMap()
有哪些的易踩的坑,今天就让我们好好的扒一扒 Map
的底裤,看看这背后不为人知的故事。
要讲 Map
,可以说 HashMap
是日常开发使用频次最高的,我愿称其为古希腊掌管性能的神。
举个简单的例子,如何判断两个集合是否存在交集?最简单也最粗暴的方式,两层 for
遍历暴力检索,别跟我提什么时间空间复杂度,给我梭哈就完事。
public void demo() {
List<Integer> duplicateList = new ArrayList<>();
List<Integer> list1 = List.of(1, 2, 3, 4);
List<Integer> list2 = List.of(3, 4, 5, 6);
for (Integer l1 : list1) {
for (Integer l2 : list2) {
if (Objects.equals(l1, l2)) {
duplicateList.add(l1);
}
}
}
System.out.println(duplicateList);
}
敲下回车提交代码之后,当还沉浸在等待领导夸你做事又稳又快的时候,却发现领导黑着脸向你一步步走来。
刚准备开始摸鱼的你吓得马上回滚了提交,在一番资料查询之后你发现了原来可以通过 Map
实现 O(n)
级的检索效率,你意气风发的敲下一段新的代码:
public void demo() {
List<Integer> duplicateList = new ArrayList<>();
List<Integer> list1 = List.of(1, 2, 3, 4);
List<Integer> list2 = List.of(3, 4, 5, 6);
Map<Integer, Integer> map = new HashMap<>();
list2.forEach(it -> map.put(it, it));
for (Integer l : list1) {
if (Objects.nonNull(map.get(l))) {
duplicateList.add(l);
}
}
System.out.println(duplicateList);
}
重新提交代码起身上厕所,你昂首挺胸的特地从领导面前路过,领导回了你一个肯定的眼神。
让我们回到 HashMap
的身上,作为八股十级选手而言的你,什么数据结构红黑树可谓信手拈来,但我们今天不谈八股,只聊聊背后的一些设计理念。
众所周知,在 HashMap
中有且仅允许存在一个 key
为 null
的元素,当 key 已存在默认的策略是进行覆盖,比如下面的示例最终 map
的值即 {null=2}
。
Map<Integer, Integer> map = new HashMap<>();
map.put(null, 1);
map.put(null, 2);
System.out.println(map);
同时 HashMap
对于 value
的值并没有额外限制,只要你愿意,你甚至可以放几百万 value
为空的元素像下面这个例子:
Map<Integer, Integer> map = new HashMap<>();
map.put(1, null);
map.put(2, null);
map.put(3, null);
map.put(4, null);
map.put(5, null);
System.out.println(map);
这也就引出了今天的重点!
在 stream
中使用 Collectors.toMap()
时,如果你不注意还是按照惯性思维那么它就会让你感受一下什么叫做暴击。就像上一篇文章提到的其异常触发机制,但却并不知道为什么要这么设计?
作为网络冲浪小能手,我反手就是在 stackoverflow
发了提问,咱虽然笨但主打一个好学。
值得一提的是,评论区有个老哥回复的相当戳我,他的回复如下:
用我三脚猫的英语水平翻译一下,大概意思如下:
因为人家
toMap()
并没有说返回的是HashMap
,所以你凭什么想要人家遵循跟HashMap
一样的规则呢?
我滴个乖乖,他讲的似乎好有道理的样子。
我一开始也差点信了,但其实你认真看 toMap()
的内部实现,你会发现其返回的不偏不倚正好就是 HashMap
。
如果你还不信,以上篇文章的代码为例,执行后获取其类型可以看到输出就是 HashMap
。
这时候我的 CPU
又烧了,这还是我认识的 HashMap
,怎么开始跟 stream
混之后就开始六亲不认了,是谁说的代码永远不会变心的?
一切彷佛又回到了起点,为什么在新的 stream
中不遵循大家已经熟悉规范,而是要改变习惯对此做出限制?
stackoverflow
上另外的一个老哥给出的他的意见:
让我这个四级 751
分老手再给大家做个免费翻译官简化一下观点:
在
Collectors.toMap()
的文档中已经标注其并不保证返回 Map 的具体类型,以及是否可变、序列化性以及是否线程安全,而JDK
拥有众多的版本,可能在你的环境已经平稳运行了数年,但换个环境之后在不同的JDK
下可能程序就发生了崩溃。因此,这些额外的保障实际上还帮了你的忙。
回头去看 toMap()
方法上的文档说明,确实也像这位老哥提到的那样。
而在 HashMap
中允许 Key
与 Value
为空带来的一个问题在此时也浮现了出来,当存入一个 value
为空的元素时,再后续执行 get()
再次读取时,存在一个问题那就是二义性。
很显然执行 get()
返回的结果将为空,那这个空究竟是 Map 中不存在这个元素?还是我存入的元素其 value
为空?这一点我想只有老天爷知道,而这种二义性所带来的问题在设计层面显然是一个失误。
那么到这里,我们就可以得到一个暴论:HashMap 允许 key 和 value 为空就是 JDK 留下的“屎山”!
为了验证这一结论,我们可以看看在新的 ConcurrentHashMap
中 JDK
是怎么做的?查看源码可以看到,在 put()
方法的一开始就执行了 key
与 value
的空值校验,也验证了上面的猜想。
这还原不够支撑我们的结论,让我们继续深挖这背后还有什么猫腻。
首先让我看看是谁写的 ConcurrentHashMap
,在 openjdk
的 GitHub
仓库类文档注释可以看到主要的开发者是 Doug Lea
。
那 Doug Lea
又是何方大佬,通过维基百科的可以看到其早期是 Java
并发社区的主席,他参与了一众的 JDK
并发设计工作,可谓吾辈偶像。
在网络搜罗相关的资讯找到对应的话题,虽然图中的链接已经不存在了,但还是能从引用的内容看出其核心的原因正是为了规避的结果的模糊性,与前文我们讨论的二义性不尽相同。
那为什么 JDK
不同步更新 HashMap
的设计理念,在新版 HashMap
中引入 key
与 value
的非空校验?
我想剩下的理由只有一个:HashMap
的使用范围实在太广,就算是 JDK 自己也很难在不变更原有结构的基础上进行改动,而在 JDK 1.2
便被提出并广泛应用,对于一个发展了数十年的语言而言,兼容性是十分重要的一大考量。
因此,我们可以看到,在后续推出的 Map
中,往往对 key
与 Value
都作了进一步的限制,而对于 HashMap
而言,可能 JDK
官方也是有心无力吧。
到这里基本也就盖棺定论了,但本着严谨的态度大胆假设小心求证,让我们再来看看大家伙的意见,万一不小心就被人网暴了。
在 stackoverflow
上另外几篇有关 Map
回答下可以看到,许多人都认为 HashMap
支持空值是一个存在缺陷的设计。
感兴趣的小伙伴可以去原帖查看,这里我就不再展开介绍了,原帖链接:Why does Map.of not allow null keys and values?。
看到这里,下次别人或者老板再说你写的代码是屎山的时候,请昂首挺胸自信的告诉他 JDk
都会犯错,我写的这点又算得了什么?
来源:juejin.cn/post/7384629198130610215
掘金滑块验证码安全升级,继续破解
去年发过一篇文章,《使用前端技术破解掘金滑块验证码》,我很佩服掘金官方的气度,不但允许我发布这篇文章,还同步发到了官方公众号。最近发现掘金的滑块验证码升级了,也许是我那篇文章起到了一些作用,逼迫官方加强了安全性,这是一个非常好的现象。
不过,这并不是终点,我们还是可以继续破解。验证码的安全性是在用户体验和安全性之间的一个平衡,如果安全性太高,用户体验就会变差,如果用户体验太好,安全性就会变差。掘金的滑块验证码是一个很好的例子,它的安全性和用户体验之间的平衡做得非常好,并且我们破解的难度体验也非常好。 😄
本次升级的内容
掘金的滑块验证码升级了,主要有以下几个方面的改进:
- 首先验证码不再是掘金自己的验证码了,而是使用了字节的校验服务,可以看到弹窗是一个 iframe,并且域名是
bytedance.com
。
我们都知道掘金被字节收购了,可以猜测验证码的升级是字节跳动的团队做的。
- 验证码的图形不再是拼图,而是随机的不同形状,比如爱心、六角星、圆环、月亮、盾牌等。
- 增加了干扰缺口,主要是大小或旋转这种操作。
下面看一下改版后的滑块验证码:
我在文章的评论区看到了一些关于这次升级或相关的讨论:
本文将继续破解这次升级后的滑块验证码,看看这次升级对破解的难度有多大影响,如果你还没有了解过如何破解滑块验证码,请先看我之前的文章。
iframe
这次升级,整个滑块都掉用的是外部链接,使用 iframe 呈现,那么在 puppeteer 中如何处理呢?
await page.waitForSelector('iframe');
const elementHandle = await page.$('iframe');
const frame = await elementHandle.contentFrame();
实际上,我们只需要等待 iframe 加载完成,然后获取 iframe 的内容即可。
Frame 对象和 Page 对象有很多相似的方法,比如 frame.$
、frame.evaluate
等,我们可以直接使用这些方法来操作 iframe 中的元素。
验证码的识别
上一篇文章采用比较简单的判断方式,当时缺口处有明显的白边,所以只需要找到这个白边即可。
但是本次升级后,缺口不再是白边,而是阴影的效果,并且缺口的形状也不再是拼图,大概率都是曲线的边,所以再判断缺口的方式就不再适用了。
现在我们可以采用一种新的方式,通过对比滑块图片和缺口区域的像素值相似程度来判断缺口位置。
首先还是二值化处理,将图片转换为黑白两色:
可以看到左侧缺口和右侧缺口非常相似,只是做了一点旋转作为干扰。
再看一下,iframe 中还有一个很重要的东西,就是校验的图片:
它是一个 png 图片,所以我们可以把它也转换成二值化,简单的方式就是将透明色转换为白色,非透明色转换为黑色,如果想提高识别精度,可以与背景图一样,通过灰度、二值化的转换方式。
// 获取缺口图像
const captchaVerifyImage = document.querySelector(
'#captcha-verify_img_slide',
) as HTMLImageElement;
// 创建一个画布,将 image 转换成canvas
const captchaCanvas = document.createElement('canvas');
captchaCanvas.width = captchaVerifyImage.width;
captchaCanvas.height = captchaVerifyImage.height;
const captchaCtx = captchaCanvas.getContext('2d');
captchaCtx.drawImage(
captchaVerifyImage,
0,
0,
captchaVerifyImage.width,
captchaVerifyImage.height,
);
const captchaImageData = captchaCtx.getImageData(
0,
0,
captchaVerifyImage.width,
captchaVerifyImage.height,
);
// 将像素数据转换为二维数组,同样处理灰度、二值化,将像素点转换为0(黑色)或1(白色)
const captchaData: number[][] = [];
for (let h = 0; h < captchaVerifyImage.height; h++) {
captchaData.push([]);
for (let w = 0; w < captchaVerifyImage.width; w++) {
const index = (h * captchaVerifyImage.width + w) * 4;
const r = captchaImageData.data[index] * 0.2126;
const g = captchaImageData.data[index + 1] * 0.7152;
const b = captchaImageData.data[index + 2] * 0.0722;
if (r + g + b > 30) {
captchaData[h].push(0);
} else {
captchaData[h].push(1);
}
}
}
为了对比图形的相似度,二值化后的数据我们页采用二维数组的方式存储,这样可以方便的对比两个图形的相似度。
如果想观测二值化后的真是效果,可以把二位数组转换为颜色,并覆盖到原图上:
// 通过 captchaData 0 黑色 或 1 白色 的值,绘制到 canvas 上,查看效果
for (let h = 0; h < captchaVerifyImage.height; h++) {
for (let w = 0; w < captchaVerifyImage.width; w++) {
captchaCtx.fillStyle =
captchaData[h][w] == 1 ? 'rgba(0,0,0,0)' : 'black';
captchaCtx.fillRect(w, h, 1, 1);
}
}
captchaVerifyImage.src = captchaCanvas.toDataURL();
数据拿到后,我们可以开始对比两个图形的相似度,这里就采用非常简单的对比方式,从左向右,逐个像素点对比,横向每个图形的像素一致的点数量纪录下来,然后取最大值,这个最大值就是缺口的位置。
这里我们先优化一下要对比的数据,我们只需要对比缺口的顶部到底部这段的数据,截取这一段,可以减少对比的性能消耗。
// 获取captchaVerifyImage 相对于 .verify-image 的偏移量
const captchaVerifyImageBox = captchaVerifyImage.getBoundingClientRect();
const captchaVerifyImageTop = captchaVerifyImageBox.top;
// 获取缺口图像的位置
const imageBox = image.getBoundingClientRect();
const imageTop = imageBox.top;
// 计算缺口图像的位置,top 向上取整,bottom 向下取整
const top = Math.floor(captchaVerifyImageTop - imageTop);
// data 截取从 top 列到 top + image.height 列的数据
const sliceData = data.slice(top, top + image.height);
然后循环对比两个图形的像素点,计算相似度:
// 循环对比 captchaData 和 sliceData,从左到右,每次增加一列,返回校验相同的数量
const equalPoints = [];
// 从左到右,每次增加一列
for (let leftIndex = 0; leftIndex < sliceData[0].length; leftIndex++) {
let equalPoint = 0;
// 新数组 sliceData 截取 leftIndex - leftIndex + captchaVerifyImage.width 列的数据
const compareSliceData = sliceData.map((item) =>
item.slice(leftIndex, leftIndex + captchaVerifyImage.width),
);
// 循环判断 captchaData 和 compareSliceData 相同值的数量
for (let h = 0; h < captchaData.length; h++) {
for (let w = 0; w < captchaData[h].length; w++) {
if (captchaData[h][w] === compareSliceData[h][w]) {
equalPoint++;
}
}
}
equalPoints.push(equalPoint);
}
// 找到最大的相同数量,大概率为缺口位置
return equalPoints.indexOf(Math.max(...equalPoints));
对比时像素较多,不容易直接看到效果,这里写一个简单的二位数组对比,方便各位理解:
[
[0, 1, 0],
[1, 0, 1],
[0, 1, 0],
]
[
[0, 0, 0, 1, 0, 0],
[0, 0, 1, 0, 1, 0],
[0, 0, 0, 1, 0, 0],
]
循环对比,那么第3列开始,匹配的数量可以达到9,所以返回 3,这样就是滑块要移动的位置。
干扰缺口其实对我们这个识别方式没什么影响,最多可能会增加一些失败的概率,我个人测试了一下,识别成功率有 95% 左右。
总结
这次升级后,掘金的滑块验证码的安全性有了一定的提升,还是可以继续破解的,只是难度有所增加。最后再奉劝大家不要滥用这个技能,这只是为了学习和研究,不要用于非法用途。如果各位蹲局子,可不关我事啊。 🤔️
来源:juejin.cn/post/7376276140595888137
队友升职,被迫解锁 Jenkins(所以,前端需要学习Jenkins吗?🤔)
入坑 Jenkins
作为一个前端,想必大家都会有这个想法:“Jenkins 会用就行了,有啥好学的”。
我一直都是这么想的,不就会点个开始构建
就行了嘛!
可是碰巧我们之前负责 Jenkins 的前端同事升了职,碰巧这个项目组就剩了两个人,碰巧我比较闲,于是这个“活”就落在我的头上了。
压力一下就上来了,一点不懂 Jenkins 可咋整?
然而现实是没有一点儿压力。
刚开始的时候挺轻松,也就是要发版的流程到我这了,我直接在对应项目上点击开始构建
,so easy!可是某一天,突然遇到一个 bug:我们每次 web 端项目发完后,桌面端的 hybrid 包需要我手动改 OSS 上配置文件的版本号,正巧那天忘记更新版本号了,导致桌面端应用本地的 hybrid 没有更新。。。
领导:你要不就别手动更新了,弄成自动化的
我:😨 啊!什么,我我我不会,是不可能的
小弟我之前没有接触过 Jenkins,看着那一堆配置着实有点费脑,于是就只能边百度学习边输出,从 Jenkins 安装开始到配置不同类型的构建流程,踩过不少坑,最后形成这篇文章。如果有能帮到大家的点,我就很开心了,毕竟我也是刚接触的!
说说我经历过的前端部署流程
按照我的经历,我把前端部署流程分为了以下几个阶段:即原始时代 -> 脚本化时代 -> CI/CD 时代。
原始时代
最开始的公司运维是一个小老头,他只负责管理服务器资源,不管各种项目打包之类的。我们就只能自己打包,再手动把构建的文件丢到服务器上。
整体流程就是:本地合并代码 --> 本地打包 --> 上传服务器;
上传服务器可以分为这几个小步骤:打开 xshell --> 连接服务器 --> 进入 tomcat 目录 --> 通过 ftp 上传本地文件。
可能全套下来需要 5 分钟左右。
脚本化时代
为了简化,我写了一个 node 脚本,通过ssh2-sftp-client
将上传服务器
这一步骤脚本化:
const chalk = require('chalk')
const path = require('path')
const fs = require('fs')
const Client = require('ssh2-sftp-client')
const sftp = new Client()
const envConfig = require('./env.config')
const defalutConfig = {
port: '22',
username: 'root',
password: '123',
localStatic: './dist.tar.gz',
}
const config = {
...defalutConfig,
host: envConfig.host,
remoteStatic: envConfig.remoteStatic,
}
const error = chalk.bold.red
const success = chalk.bold.green
function upload(config, options) {
if (!fs.existsSync('./dist') && !fs.existsSync(options.localStatic)) {
return
}
// 标志上传dist目录
let isDist = false
sftp
.connect(config)
.then(() => {
// 判断gz文件存在时 上传gz 不存在时上传dist
if (fs.existsSync(options.localStatic)) {
return sftp.put(options.localStatic, options.remoteStatic)
} else if (fs.existsSync('./dist')) {
isDist = true
return sftp.uploadDir('./dist', options.remoteStatic.slice(0, -12))
}
})
.then(() => {
sftp.end()
if (!isDist) {
const { Client } = require('ssh2')
const conn = new Client()
conn
.on('ready', () => {
// 远程解压
const remoteModule = options.remoteStatic.replace('dist.tar.gz', '')
conn.exec(
`cd ${remoteModule};tar xvf dist.tar.gz`,
(err, stream) => {
if (err) throw err
stream
.on('close', (code) => {
code === 0
conn.end()
// 解压完成 删除本地文件
fs.unlink(options.localStatic, (err) => {
if (err) throw err
})
})
.on('data', (data) => {})
}
)
})
.connect(config)
}
})
.catch((err) => {
sftp.end()
})
}
// 上传文件
upload(config, {
localStatic: path.resolve(__dirname, config.localStatic), // 本地文件夹路径
remoteStatic: config.remoteStatic, // 服务器文件夹路径器
})
最后只要通过执行yarn deploy
即可实现打包并上传,用了一段时间,队友也都觉得挺好用的,毕竟少了很多手动操作,效率大大提升。
CI/CD 时代
不过用了没多久后,来了个新的运维小年轻,一上来就整了个 Jenkins ,取代了我们手动打包的过程,只要我们点击部署就可以了,当时就感觉 Jenkins 挺方便的,但又觉得和前端没多大关系,也就没学习。
不过也挺烦
Jenkins 的,为啥呢?
当时和测试说的最多的就是“我在我这试试.....我这没问题啊,你刷新一下”,趁这个时候,赶紧打包重新部署下。有了 Jenkins 后,打包都有记录了,测试一看就知道我在哄她了 🙄
Jenkins 解决了什么问题
我觉得在了解一个新事物前,应该先了解下它的出现解决了什么问题。
以我的亲身经历来看,Jenkins 的出现使得 拉取代码 -> 打包 -> 部署 -> 完成后工作(通知、归档、上传CDN等)
这一繁琐的流程不需要人为再去干预,一键触发 🛫。
只需要点击开始构建即可,如何你觉得还得每次打开 jenkins 页面去点击构建,可以通过设置代码提交到 master 或合并代码时触发构建,这样就不用每次手动去点击构建了,省时更省力 🚴🏻♂️。
Jenkins 部署
Jenkins 提供了多种安装方式,我的服务器是 Centos,按照官方教程进行部署即可。
官方提供两种方式进行安装:
方式一:
sudo wget -O /etc/yum.repos.d/jenkins.repo https://pkg.jenkins.io/redhat-stable/jenkins.repo
sudo rpm --import https://pkg.jenkins.io/redhat-stable/jenkins.io.key
yum install jenkins
方式二:
直接下载 rpm 包进行安装,地址:mirrors.jenkins-ci.org/redhat/
wget https://pkg.jenkins.io/redhat/jenkins-2.449-1.1.noarch.rpm
rpm -ivh jenkins-2.449-1.1.noarch.rpm
安装过程
我是使用方式二进行安装的,来看下具体过程。
首先需要安装 jdk17 以上的版本
- 下载对应的 jdk
wget https://download.oracle.com/java/17/latest/jdk-17_linux-x64_bin.tar.gz
- 解压并放到合适位置
tar xf jdk-17_linux-x64_bin.tar.gz
mv jdk-17.0.8/ /usr/lib/jvm
- 配置 Java 环境变量
vim /etc/profile
export JAVA_HOME=/usr/lib/jvm/jdk-17.0.8
export CLASSPATH=$JAVA_HOME/lib:$JRE_HOME/lib:$CLASSPATH
export PATH=$JAVA_HOME/bin:$JRE_HOME/bin:$PATH
- 验证
java -version
接着安装 Jenkins,需要注意:Jenkins 一定要安装最新版本,因为插件要求最新版本,最新的 2.449。
- 下载 rpm 包
cd /usr/local/jenkins
wget https://mirrors.jenkins-ci.org/redhat/jenkins-2.449-1.1.noarch.rpm
- 安装 Jenkins
rpm -ivh jenkins-2.449-1.1.noarch.rpm
- 启动 Jenkins
systemctl start jenkins
你以为就这么简单?肯定会报错的,通过百度报错信息,报错原因是:Java 环境不对,百度到的解决方法:
修改/etc/init.d/jenkins
文件,添加 JDK,但是目录下并没有这个文件,继续百度得知:
使用 systemctl
启动 jenkins 时,不会使用 etc/init.d/jenkins
配置文件,而是使用 /usr/lib/systemd/system/jenkins.service
文件。
于是修改:
vim /usr/lib/systemd/system/jenkins.service
搜索 Java,找到上面这一行,打开注释,修改为对应的 JDK 位置:
Environment="JAVA_HOME=/usr/lib/jvm/jdk-17.0.10"
重新启动 Jenkins:
systemctl restart jenkins
查看启动状态,出现如下则说明 Jenkins 启动完成:
接着在浏览器通过 ip:8090
访问,出现如下页面,说明安装成功。
此时需要填写管理员密码,通过 cat /var/lib/jenkins/secrets/initialAdminPassword
即可获取。
Jenkins 配置
出现上述界面,填写密码成功后等待数秒,即可出现如下界面:
选择 安装推荐的插件
这个过程稍微有点慢,可以整理整理文档,等待安装完成。
安装完成后,会出现此页面,需要创建一个管理员用户。
点击开始使用 Jenkins,即可进入 Jenkins 首页。
至此,Jenkins 安装完成 🎉🎉🎉。
安装过程遇到的问题
- 没有经验第一次安装,参考网上文档推荐的是 JDK8,结果安装的 Jenkins 至少需要 JDK 11,导致安装失败;
- 第二次安装,按照网上的文档安装,不是最新版本,导致部分插件安装失败;
- 配置修改问题
- Jenkins 默认的配置文件位于
/usr/lib/systemd/system/jenkins.service
- 默认目录安装在
/var/lib/jenkins/
- 默认工作空间在
/var/lib/jenkins/workspace
- Jenkins 默认的配置文件位于
- 修改端口号为
8090
vim /usr/lib/systemd/system/jenkins.service
修改
Environment="JENKINS_PORT=8090"
,修改完后执行:
systemctl daemon-reload
systemctl restart jenkins
如何卸载 Jenkins
安装过程遇到了不少坑,基本都是卸载了重新安装,于是就总结了以下卸载的命令。
# 查找是否存在 Jenkins 安装包
rpm -ql jenkins
# 卸载 Jenkins
rpm -e jenkins
# 再次查看 此时会提示:未安装软件包 jenkins
rpm -ql jenkins
# 删除所有 Jenkins 相关目录
find / -iname jenkins | xargs -n 1000 rm -rf
Jenkins 版本更新
Jenkins 发布版本很频繁,基本为一周一次,参考 Jenkins 更新
项目创建
点击 + 新建Item
,输入名称,选择类型:
有多种类型可供选择,这里我们主要讲这两种:Freestyle project 和 Pipeline。
Freestyle project
选择这种类型后,就可以通过各种 web 表单(基础信息、源码、构建步骤等),配置完整的构建步骤,对于新手来说,易上手且容易理解,如果第一次接触,创建项目就选择 Freestyle project 即可。
总共有以下几个环节需要配置:
- General
- 源码管理
- 构建触发器
- 构建环境
- Build Steps
- 构建后操作
此时我们点击 OK,创建完如下所示都是空白的,也可以通过创建时的复制
选项,复制之前项目的配置:
接着就如同填写表单信息,一步步完成构建工作。
General
项目基本信息也就是对所打包项目的描述信息:
比如描述这里,可以写项目名称、描述、输出环境等等。
Discard old builds 丢弃旧的构建
可以理解为清初构建历史,Jenkins 每打包一次就会产生一个构建历史记录,在构建历史
中可以看到从第一次到最新的构建信息,这会导致磁盘空间消耗。
点击配置名称或勾选,会自动展开配置项。这里我们可以设置保持构建的最大个数
为5
,则当前项目的构建历史记录只会保留最新的 5 个,并自动删除掉最老的构建。
这个可以按照自己的需求来设置,比如保留 7 天的构建记录或保留最多 100 个构建记录。
Jenkins 的大多数配置都有 高级
选项,在高级选项中可以做更详细的配置。
This project is parameterized
可以理解为此构建后续过程可能用到的参数,可以是手动输入或选项等,如:git 分支、构建环境、特定的配置等等。通过这种方式,项目可以更加灵活和可配置,以适应不同的构建需求和环境。
默认有 8 种参数类型:
- Boolean Parameter: checkbox 选择,如果需要设置 true/false 值时,可以添加此参数类型
- Choice Parameter:选择,多个选项
- Credentials Parameter:账号证书等参数
- File Parameter:文件上传
- Multi-line String parameters:多行文本参数
- Password Parameter:密码参数
- Run Parameter:用于选择执行的 job
- String Parameter:单行文本参数
Git Parameter
需要在 系统管理 -> 插件管理
搜索 Git Parameter
插件进行安装,安装完成后重启才会有这个参数。
通过 添加参数
来设置后续会用到的参数,比如设置名称为 delopyTag
的 Git Parameter
参数来指定要构建的分支,设置名称为 DEPLOYPATH
的 Choice Parameter
参数来指定部署环境等等。
源码管理
Repositories
一般公司项目都是从 gitlab 上拉代码,首先设置 Repository URL
,填写 git 仓库地址,比如:https://gitlab.com/xxx/xxx.git
填写完后会报错如下:
可以通过添加 Credentials 凭证解决,在 Jenkins 中,Git 的 Credentials 是用于访问 Git 仓库的认证信息,这些凭据可以是用户名和密码、SSH 密钥或其他认证机制,以确保 Jenkins 能够安全的与 Git 仓库进行交互,即构建过程中自动拉取代码、执行构建任务等。
方式一:在当前页面填写帐号、密码
选择添加 -> Jenkins -> 填写 git 用户名、密码
等信息生成一个新的 Credentials,然后重新选择我们刚刚添加的 Credentials,报错信息自动消失
这样添加会有一个问题,就是如果有多个项目时,每次都需要手动填写 Git 账户和密码信息。
方式二:Jenkins 全局凭证设置
在 Global Credentials 中设置全局的凭证。
然后在项目中配置时可以直接选择我们刚刚添加的 Credentials,报错信息自动消失。
Branches to build
这里构建的分支,可以设置为我们上面设置的 delopyTag
参数,即用户自己选择的分支进行构建。
构建触发器
特定情况下出发构建,如定时触发、代码提交或合并时触发、其他任务完成时触发等。
如果没有特殊的要求时,这一步完全可以不用设置,在需要构建时我们只需要手动点击开始构建即可。
构建环境
构建环境是在构建开始之前的准备工作,如清除上次构建、指定构建工具、设置 JDK 、Node 版本、生成版本号等。
Provide Node & npm bin/folder to PATH
默认是没有这一项的,但前端部署需要 Node 环境支持,所以需要在 系统管理 -> 插件管理
搜索 nodejs
插件进行安装,安装完成后重启才会展示这项配置。
但此时还是不能选择的,需要在 系统管理 -> 全局工具配置
中先安装 NodeJs,根据不同环境配置,可同时安装多个 NodeJs 版本。
之后在 Provide Node
处才有可供选择的 Node 环境。
Create a formatted version number
这个就是我用来解决了一开始问题的配置项,也就是把每次打包的结果上传到 OSS 服务器上时生成一个新的版本号,在 Electron 项目中通过对比版本号,自动更新对应的 hybrid 包,领导都爱上我了 😜。
首先需要安装插件 Version Number Plugin
,在 系统管理 -> 插件管理
中搜索安装,然后重启 Jenkins 即可
- Environment Variable Name
类似于第一步的构建参数,可以在其他地方使用。
- Version Number Format String
用于设置版本号的格式,如
1.x.x
,Jenkins 提供了许多内置的环境变量:
- BUILD_DAY:生成的日期
- BUILD_WEEK:生成年份中的一周
- BUILD_MONTH:生成的月份
- BUILD_YEAR:生成的年份
- BUILDS_TAY:在此日历日期完成的生成数
- BUILDS_THIS_WEEK:此日历周内完成的生成数
- BUILDS_THIS_MONTH:此日历月内完成的生成数
- BUILDS_THIS_YEAR:此日历年中完成的生成数
- BUILDS_ALL_TIME:自项目开始以来完成的生成数
- 勾选 Build Display Name Use the formatted version number for build display name 后
此时每次构建后就会生成一个个版本号:
- 把这个参数传递到后续的 OSS 上传的 Shell 脚本中即可。
如果想要重置版本号,只要设置Number of builds since the start of the project
为 0 即可,此时就会从 1.7.0
重新开始。
Build Steps
这是最为重要的环节,主要用于定义整个构建过程的具体任务和操作,包括执行脚本、编译代码、打包应用等。
我们可以通过 Shell 脚本来完成前端项目常见的操作:安装依赖、打包、压缩、上传到 OSS 等。
点击 增加构建步骤 -> Execute shell
,在上方输入 shell 脚本,常见的如下:
#环境变量
echo $PATH
#node版本号
node -v
#npm版本号
npm -v
#进入jenkins workspace的项目目录
echo ${WORKSPACE}
cd ${WORKSPACE}
#下载依赖包
yarn
#开始打包
yarn run build
#进入到打包目录
cd dist
#删除上次打包生成的压缩文件
rm -rf *.tar.gz
#上传oss,如果没有需要可删除此段代码
ossurl="xxx"
curl "xxx" > RELEASES.json
node deploy-oss.cjs -- accessKeyId=$OSS_KEY accessKeySecret=$OSS_SECRET zipDir=tmp.zip ossUrl=xxx/v${BUILD_VERSION}.zip
node deploy-oss.cjs -- accessKeyId=$OSS_KEY accessKeySecret=$OSS_SECRET zipDir=RELEASES.json ossUrl=xxx/RELEASES.json
#把生成的项目打包成压缩包方便传输到远程服务器
tar -zcvf `date +%Y%m%d%H%M%S`.tar.gz *
#回到上层工作目录
cd ../
构建后操作
通过上面的构建步骤,我们已经完成了项目的打包,此时我们需要执行一些后续操作,如部署应用、发送通知、触发其他 Job等操作。
Send build artifacts over SSH
通过 Send build artifacts over SSH,我们可以将构建好的产物(一般是压缩后的文件)通过 ssh 发送到指定的服务器上用于部署,比如 Jenkins 服务器是 10.10,需要将压缩文件发送到 10.11 服务器进行部署,需要以下步骤:
- 安装插件
在
系统管理 -> 插件管理
中搜索插件Publish over SSH
安装,用于处理文件上传工作; - 配置服务器信息
在
系统管理 -> System
中搜索Publish over SSH
进行配置。
需要填写用户名、密码、服务器地址等信息,完成后点击
Test Configuration
,如果配置正确,会显示Success
,否则会出现报错信息。
这里有两种方式连接远程服务器,第一种是密码方式,输入服务器账户密码等信息即可;
第二种是秘钥方式,在服务器生成密钥文件,并且将私钥全部拷贝,记住是全部,要携带起止标志-----BEGIN RSA PRIVATE KEY-----或-----END RSA PRIVATE KEY----,粘贴在
高级 -> key
即可。
此处的
Remote Directory
是远程服务器接收 Jenkins 打包产物的目录,必须在对应的服务器手动创建目录,如/home/jenkins
。 - 项目配置
选择需要上传的服务器,接着设置需要传输的文件,执行脚本,移动文件到对应的目录。
Transfer Set 参数配置
Source files
:需要传输的文件,也就是通过上一步 Build Steps 后生成的压缩文件,这个路径是相对于“工作空间”的路径,即只需要输入dist/*.tar.gz
即可Remove prefix
:删除传输文件指定的前缀,如Source files
设置为dist/*.tar.gz
,此时设置Remove prefix
为/dist
,移除前缀,只传输*.tar.gz
文件;如果不设置酒会传输dist/*.tar.gz
包含了 dist 整个目录,并且会自动在上传后的服务器中创建/dist
这个路径。如果只需要传输压缩包,则移除前缀即可Remote directory
:文件传输到远程服务器上的具体目录,会与 Publish over SSH 插件系统配置中的Remote directory
进行拼接,如我们之前设置的目录是/home/jenkins
,此处在写入qmp_pc_ddm
,那么最终上传的路径为/home/jenkins/qmp_pc_ddm
,与之前不同的是,如果此路径不存在时会自动创建,这样设置后,Jenkins 服务器构建后的产物会通过 ssh 上传到此目录,供下一步使用。Exec command
文件传输完成后执行自定义 Shell 脚本,比如移动文件到指定目录、解压文件、启动服务等。
#!/bin/bash
#进入远程服务器的目录
project_dir=/usr/local/nginx/qmp_pc_ddm/${DEPLOYPATH}
cd $project_dir
#移动压缩包
sudo mv /home/jenkins/qmp_pc_ddm/*.tar.gz .
#找到新的压缩包
new_dist=`ls -ltr *.tar.gz | awk '{print $NF}' |tail -1`
echo $new_dist
#解压缩
sudo tar -zxvf $new_dist
#删除压缩包
sudo rm *.tar.gz
这一步可以使用之前定义的参数,如
${DEPLOYPATH}
,以及 Jenkins 提供的变量:如${WORKSPACE}
来引用 Jenkins 的工作空间路径等。
Build other projects
添加 Build other projects,在项目构建成功后,触发相关联的应用开始打包。
另外还可以配置企业微信通知、生成构建报告等工作。
此时,所有的配置都设置完成,我们点击保存
配置,返回到构建页。
构建
点击 Build with parameters
选择对应的分支和部署环境,点击开始构建
在控制台输出中,可以看到打包的详细过程,
可以看到我们在Build Steps
中执行的 Shell 脚本的输出如下:
以及我们通过 Publish Over SSH 插件将构建产物传输的指定服务器的输出:
最终需要部署的服务器就有了以下文件:
Pipeline
对于简单的构建需求或新手用户来说,我们可以直接选择 FreeStyle project。而对于复杂的构建流程或需要更高灵活性和扩展性的场景来说,Pipeline 则更具优势。
通过 新建任务 -> 流水线
创建一个流水线项目。
开始配置前请先阅读下流水线章节。
生成方式
首先,Jenkins 流水线是一套插件,在最开始的插件推荐安装时会自动安装,如果选择自定义安装时,需要手动安装这一套插件。
Jenkins 流水线的定义有两种方式:Pipeline script
和 Pipeline script from SCM
。
Pipeline script
Pipeline script 是直接在 Jenkins 页面的配置中写脚本,可直接定义和执行,比较直观。
Pipeline script from SCM
Pipeline script from SCM 是将脚本文件和项目代码放在一起,即 Jenkinsfile
,也可自定义名称。
当 Jenkins 执行构建任务时,会从 git 中拉取该仓库到本地,然后读取 Jenkinsfile
的内容执行相应步骤,通常认为在 Jenkinsfile
中定义并检查源代码控制是最佳实践。
当选择 Pipeline script from SCM
后,需要设置 SCM 为 git
,告诉 Jenkins 从指定的 Git 仓库中拉取包含 Pipeline 脚本的文件。
如果没有对应的文件时,任务会失败并发出报错信息。
重要概念
了解完上面的基础配置,我们先找一段示例代码,粘贴在项目的配置中:
pipeline {
agent any
stages {
stage('Build') {
steps {
echo 'Build'
}
}
stage('Test') {
steps {
echo 'Test'
}
}
stage('Deploy') {
steps {
echo 'Deploy'
}
}
}
}
看下它的输出结果:
接着看一下上面语法中几个重要的概念。
流水线 pipline
定义了整个项目的构建过程, 包括:构建、测试和交付应用程序的阶段。
流水线顶层必须是一个 block,pipeline{},作为整个流水线的根节点,如下:
pipeline {
/* insert Declarative Pipeline here */
}
节点 agent
agent 用来指定在哪个代理节点上执行构建,即执行流水线,可以设置为 any
,表示 Jenkins 可以在任何可用的代理节点上执行构建任务。
但一般在实际项目中,为了满足更复杂的构建需求,提高构建效率和资源利用率,以及确保构建环境的一致性,会根据项目的具体需求和资源情况,设置不同的代理节点来执行流水线。
如:
pipeline {
agent {
node {
label 'slave_2_34'
}
}
...
}
可以通过 系统管理 -> 节点列表
增加节点,可以看到默认有一个 master 节点,主要负责协调和管理整个 Jenkins 系统的运行,包括任务的调度、代理节点的管理、插件的安装和配置等。
阶段 stage
定义流水线的执行过程,如:Build、Test 和 Deploy,可以在可视化的查看目前的状态/进展。
注意:参数可以传入任何内容。不一定非得 Build
、Test
,也可以传入 打包
、测试
,与红框内的几个阶段名对应。
步骤 steps
执行某阶段具体的步骤。
语法
了解上述概念后,我们仅仅只能看懂一个 Pipeline script 脚本,但距离真正的动手写还有点距离,此时就需要来了解下流水线语法。
我将上面通过 Freestyle project 的脚本翻译成 Pipeline script 的语法:
pipeline {
agent any
triggers {
gitlab(triggerOnPush: true, triggerOnMergeRequest: true, branchFilterType: 'All')
}
parameters {
gitParameter branchFilter: 'origin/(.*)', defaultValue: 'master', name: 'delopyTag', type: 'PT_BRANCH'
}
stages {
stage('拉取代码') {
steps {
git branch: "${params.delopyTag}", credentialsId: 'xxx', url: 'https://xxx/fe/qmp_doc_hy.git'
}
}
stage('安装依赖') {
steps {
nodejs('node-v16.20.2') {
sh '''
#!/bin/bash
source /etc/profile
echo "下载安装包"
yarn config set registry https://registry.npmmirror.com
yarn
'''
}
sleep 5
}
}
stage('编译') {
steps {
sh '''
#!/bin/bash
source /etc/profile
yarn run build
sleep 5
if [ -d dist ];then
cd dist
rm -rf *.tar.gz
tar -zcvf `date +%Y%m%d%H%M%S`.tar.gz *
fi
'''
sleep 5
}
}
stage('解压') {
steps {
echo '解压'
sshPublisher(
publishers: [
sshPublisherDesc(
configName: 'server(101.201.181.27)',,
transfers: [
sshTransfer(
cleanRemote: false,
excludes: '',
execCommand: '''#!/bin/bash
#进入远程服务器的目录
project_dir=/usr/local/nginx/qmp_pc_ddm_${DEPLOYPATH}/${DEPLOYPATH}
if [ ${DEPLOYPATH} == "ddm" ]; then
project_dir=/usr/local/nginx/qmp_pc_ddm/dist
fi
cd $project_dir
sudo mv /home/jenkins/qmp_pc_ddm/*.tar.gz .
#找到新的压缩包
new_dist=`ls -ltr *.tar.gz | awk \'{print $NF}\' |tail -1`
#解压缩
sudo tar -zxvf $new_dist
#删除压缩包
sudo rm *.tar.gz
#发布完成
echo "环境发布完成"
''',
execTimeout: 120000,
flatten: false,
makeEmptyDirs: false,
noDefaultExcludes: false,
patternSeparator: '[, ]+',
remoteDirectory: 'qmp_pc_ddm',
remoteDirectorySDF: false,
removePrefix: 'dist/',
sourceFiles: 'dist/*.tar.gz'
)
],
usePromotionTimestamp: false,
useWorkspaceInPromotion: false,
verbose: false
)
]
)
}
}
}
post {
success {
echo 'success.'
deleteDir()
}
}
}
接下来,我们一起来解读下这个文件。
首先,所有的指令都是包裹在 pipeline{}
块中,
agent
enkins 可以在任何可用的代理节点上执行构建任务。
environment
用于定义环境变量,它们会保存为 Groovy 变量和 Shell 环境变量:定义流水线中的所有步骤可用的环境变量 temPath
,在后续可通过 $tmpPath
来使用;
环境变量可以在全局定义,也可在 stage 里单独定义,全局定义的在整个生命周期里可以使用,在 stage 里定义的环境变量只能在当前步骤使用。
Jenkins 有一些内置变量也可以通过 env 获取(env 也可以读取用户自己定义的环境变量)。
steps {
echo "Running ${env.BUILD_ID} on ${env.JENKINS_URL}"
}
这些变量都是 String 类型,常见的内置变量有:
- BUILD_NUMBER:Jenkins 构建序号;
- BUILD_TAG:比如 jenkins-{BUILD_NUMBER};
- BUILD_URL:Jenkins 某次构建的链接;
- NODE_NAME:当前构建使用的机器
parameters
定义流水线中可以接收的参数,如上面脚本中的 gitParameter,只有安装了 Git Parameters 插件后才能使用,name 设置为delopyTag
,在后续可通过 ${params.delopyTag}
来使用;
还有以下参数类型可供添加:
parameters {
booleanParam(name: 'isOSS', defaultValue: true, description: '是否上传OSS')
choice(name: 'select', choices: ['A', 'B', 'C'], description: '选择')
string(name: 'temp', defaultValue: '/temp', description: '默认路径')
text(name: 'showText', defaultValue: 'Hello\nWorld', description: '')
password(name: 'Password', defaultValue: '123', description: '')
}
triggers
定义了流水线被重新触发的自动化方法,上面的配置是:当 Git 仓库有新的 push 操作时触发构建
stages 阶段
- 阶段一:拉取代码
git:拉取代码,参数
branch
为分支名,我们使用上面定义的${params.delopyTag}
,credentialsId
以及url
,如果不知道怎么填,可以在流水线语法 -> 片段生成器
中填写对应信息后,自动生成,如下:
再复制到此处即可。
- 阶段二:安装依赖
在
steps
中,sh
是 Jenkins pipeline 的语法,通过它来执行 shell 脚本。
#!/bin/bash
表示使用 bash 脚本;
source /etc/profile
用于将指定文件中的环境变量和函数导入当前 shell。
执行
yarn
安装依赖。 - 阶段三:编译
执行
yarn build
打包,
if [ -d dist ];
是 shell 脚本中的语法,用于测试dist
目录是否存在,通过脚本将打包产物打成一个压缩包。 - 阶段四:解压
将上步骤生成的压缩包,通过
Publish over SSH
发送到指定服务器的指定位置,执行 Shell 命令解压。
不会写
Publish over SSH
怎么办?同样,可以在流水线语法 -> 片段生成器
中填写对应信息后,自动生成,如下:
post
当流水线的完成状态为 success
,输出 success。
deleteDir() 函数用于删除当前工作目录中的所有文件和子目录。这通常用于清理工作区,确保在下一次构建之前工作区是干净的,以避免由于残留文件或目录引起的潜在问题。
构建看看效果
可以直接通过 Console Output
查看控制台输出,当然在流水线项目中自然要通过流水线去查看了。
- 效果一
Pipeline Overview 中记录了每个步骤的执行情况、开始时间和耗时等信息,但是没有详细信息,详细信息就要在 Pipeline Console 中进行查看。
- 效果二
安装插件
Blue Ocean
,相当于同时结合了 Pipeline Overview 和 Pipeline Console,可以同时看到每个步骤的执行情况等基本信息,以及构建过程中的详细信息。
通过 Blue Ocean 也可以直接创建流水线,选择代码仓库,然后填写对应的字段,即可快速创建流水线项目,如创建 gitlab 仓库:
或者直接连接 github 仓库,需要 token,直接点击红框去创建即可:
通过项目中的 Jenkinsfile 构建
再把对应的 Pipeline script 代码复制到对应代码仓库的 Jenkinsfile
文件,设置为 Pipeline script from SCM,填写 git 信息。
正常情况下,Jenkins 会自动检测代码仓库的 Jenkinsfile
文件,如果选择的文件没有 Jenkinsfile 文件时就会报错,如下:
正常按照流水线的执行流程,打开 Blue Ocean,查看构建结果,如下:
片段生成器
如果你觉得上述代码手写麻烦,刚开始时又不会写,那么就可以使用片段代码生成器来帮助我们生成流水线语法。
进入任务构建页面,点击 流水线语法
进入:
配置构建过程遇到的问题
- Jenkins 工作空间权限问题
修复:
chown -R jenkins:jenkins /var/lib/jenkins/workspace
- Git Parameters 不显示问题
当配置完 Git Parameters 第一次点击构建时,会报如下错误,找了很久也没有找到解决方法,于是就先使用 master 分支构建了一次,构建完成之后再次点击构建这里就正常显示了,猜测是没构建前没有 git 仓库的信息,构建完一次后就有了构建信息,于是就正常显示了。
总结
本文对 Jenkins 的基本教程就到此为止了,主要讲了 Jenkins 的安装部署,FreeStyle project 和 Pipeline 的使用,以及插件安装、配置等。如果想要学,跟着我这个教程实操一遍,Jenkins 就基本掌握了,基本工作中遇到的问题都能解决,剩下的就只能在实际工作中慢慢摸索了。
再说回最初的话题,前端需不需要学习 Jenkins。我认为接触新的东西,然后学习并掌握,拓宽了技术面,虽然是一种压力,也是得到了成长的机会,在这个前端技术日新月异的时代,前端们不仅要熟练掌握前端技术,还需要具备一定的后端知识和自动化构建能力,才能不那么容易被大环境淘汰。
以上就是本文的全部内容,希望这篇文章对你有所帮助,欢迎点赞和收藏 🙏,如果发现有什么错误或者更好的解决方案及建议,欢迎随时联系。
来源:juejin.cn/post/7349561234931515433
三十而立却未立,缺少的是女朋友还是技术能力?
作为一个从事 Web 工作 8 年来的相关人员的一点心路历程,希望我的经历能给大家带来稍许乐趣。
迷茫,特别迷茫
俗话说得好:“岂能尽如人意,但求无愧于心”,工作 8 年来,我经常这样自我安慰。不过这并不影响我也经常感觉无所适从,烦闷与迷茫。尤其是到了一些特殊的年月节点,这种焦虑感总是更加强烈。
那到底有什么迷茫的呢?一言以蔽之,有了对比,就有了伤害。正如标题所言,女朋友和技术能力,换一个通俗的话,也可以叫“美女与金钱”,当然更常规的说法,是“家庭与事业”。
如果简单横向对比起来,我迷茫确实看起来不意外:
- 我好歹也是正儿八经 985 大学软件工程方向本科毕业,也算是科班出身;
- 工作了 8 年,不仅是被同学、绝大部分同行从业人员从薪资水平、发展前景、人际交往、生活质量等各方向甩在身后,甚至都比不上复读一年考上不知名二本学校、去年才毕业的表弟;
- 没房没车,没有成婚,还背井离乡,漂泊千里之外;
- 日子看起来浑浑噩噩,没有什么远大志向,也没什么乐衷的兴趣……
怎么就变成这样了呢,我觉得我有老老实实、脚踏实地地做事情啊。回想自己从业这些年:
- 从一开始的 JSP + Spring MVC + MySQL 这套原始的 Java Web 开发;
- 到当时外面还比较时髦的 MEAN(MongoDB、Express.js、Angular 和 Node.js);
- 后来回归到 Angular + Spring 这套,然后改为现在常用的 Vue + Spring,其中还一度以为 WebFlux 会有大用;
- 当然前几年除了做些全栈开发,还不得不兼备 K8s 相关一大套的运维技能;
- TiDB、Redis、ES、Prometheus 什么的都要搞一搞,Flink 什么的也得弄一弄,加上一大堆第三方自动化、监控等工具的使用配置;
- 现在没事时用 Python 写个脚本处理一些批量任务,自己搞搞 Flutter 练手自己用的 APP。
我都觉得自己还是挺厉害的,因为这些就没一个是学校里教的东西,都是出来挨打自学的。
但实际上的现状呢,我还是呆在一个电子厂里面,拿着千把块,做着鸡毛蒜皮的事情,下班就回到公司的宿舍,龟缩起来。这样 855 毫无意义的日子,居然一呆就是 8 年了。
“可怜之人必有可恨之处?”
那我当然是自以为是的可怜了,毕竟如果真得像我说的那样出色,是金子自然会发光了,也怎么可能愿意继续呆在这种地方,离最近的地铁站、火车站都要30多分钟公交的制造业工厂里面?
确实,扯开嘴巴滋哇乱叫谁不会,有什么因就有什么果了。
- 大四的时候,跨专业自学准备心理学方向的考研,错过了秋招;没考上之后,当时的技术能力,已经不支撑找个满意的工作了。
- 做中学,两年后的 18 年正是行业发展高潮,准备出去看看。结果年轻,血气方刚,在领导的 PUA 和自以为是没能干出一点功绩就离开,不满意,然后留下来。
- 又之后的一年之余,已经发现技术水平和人生阅历和同行差距过大,还是骑驴找马。在得到几个 offer 之后,却不知原因突然想回老家城市,这些深圳广州的机会就莫名其妙放弃了,重庆的眼高手低又没找到满意的。
- 之后疫情时代,在一些大城市比如 SH、SZ 等出现强烈的排外现象之后,越发想要回家。但重庆的互联网行业,和主流城市差距可太大了。当时当地政府甚至在大力发展实体制造业,老家区县招商建工厂,租 100 亩送 100 亩。
- 疫情尾期和这两年,什么“前端已死”、行业落寞,找工作难度陡升,试想,什么样的公司会找一个 8 年工作经验的初中级前端?全栈?运维?……
去年我找工作从 5 月份找到 10 月份,沟通了 200 多个岗位,只有 20 多个接收了简历,约到 3 个网上面试,最后一个没过。除了一些需要线下面试的没法去,也有面试的匹配度也不够、岁数不够年轻等其他因素。8 年来最多就管理过不到 10 人的小团队,当然不到一年就结束了,也没有能力发展管理岗。
与自己和解是不是自欺欺人?
会不会有种“咎由自取”的感觉,我偶尔也会想:
- 如果 18 年我去了深圳而不是听信领导的话留在了东莞这里,我的发展轨迹会不会有所改善?
- 更有甚,如果大学不是脑袋一热为了自救去考什么心理学专业的研究生,好好学习技能找工作或者考本校,会不会又是另一番风景?
- 甚至更早,如果当年高考没有发挥失常,或者要是考得更差一点,去个师范,实现我儿时的理想,成为一名教师,情感上是不是更能自洽?
有句网络流行语是这样说的:有人看段子,有人照镜子。曾几何时,我也这样觉得:
- 反正现在没车没房没女友,离家又远没外债;
- 物质能力虽不高,但消费欲望不强;
- 不能为国家做大贡献,但也还没有给社会添乱;
- 下班回宿舍看看视频、打打游戏、玩玩手机,偶尔出去打打球,散散步……
没有复杂的人际关系,没有太大的家庭工作压力,清闲时间也比较充足,简简单单三餐一宿,我明明很惬意的,也明明已经惬意了 8 年来。
——“你一个月多少工资?” 、“怎么才这点?”
——“你现在什么级别?” 、“怎么才这个级别?”
——“你开什么车?” 、“什么?你连驾-照都没有?”
——“你孩子几岁了?” 、“啊,你还单身?”
——“天啦,你怎么混成这样了?”
……
“人的悲喜并不相通,我只觉得他们吵闹”。“墨镜一带,谁都不爱”,我脑袋摇成螺旋桨,我飞走咯,千里之外~
未立,缺少的是女朋友?
我的看法认为:可能不是。
没有什么是一成不变的,比如年龄。我这个年纪可能不仅和更年轻的同行抢岗位抢不过,也可能在另一个相亲市场也抢不过。
虽然嘴巴上可能有的人觉得单身好,而且现在这个男女关系和社会认同比较复杂的时代。前段时候和老同学聊天聊到近况,他们都一直以为我是一个不婚主义者。当然,这并不影响我们老一辈甚至再老一辈亲戚的期盼,他们偶尔也会认为,结婚之后,一个人才成长了,他们才会放心。
你别说,你还真别说。这半年我没有写博客,也没有太多了解“行业寒冬”的发展情况,有一部分原因还真是因为年初聊见了个相亲对象。这对我是一个完全没有经历过的赛道,难得的是我感觉还不差,虽然发展极为缓慢,但还没有遇到网上那样的“悲惨经历”,当然,也可能是异地的原因。
我要经历这种事,只能是亲戚朋友帮忙,加上微信之后聊了聊,整体氛围很好,就这么聊了一个多月。本来过年的时候约个见面的,但没想到升级了,直接他们父母到我家来坐了坐,然后又邀请我父母去她家吃了饭。这在农村的意思就是老一辈的过场已经走完了,双方家长没有意见,我们能不能成、就全看自己了。
这半年虽然几乎天天都有聊,绝大多数情况下都很愉快,我也变得有些期待每次的聊天;平时也有礼尚往来,偶尔互有一些小惊喜小礼物;五一节我也回去见了面,牵牵小手,后来得知当天她出门之后才发现来例假、身体不适但还是陪我走了将近三万步的路、甚至没让我发现异样……
但问题的关键在于,似乎都没有聊到什么重点和关键的问题,没有实际的发展,感觉温度没有理想上升。仔细想想,把这每天和她相关的一两个小时删除掉,那和我这些年的日子几乎没什么区别,好像一样是挺自在惬意的,她甚至都没有给我一些需要我去翻视频学点“人情世故”才能处理的问题和情景。
本来以为是好事,但我的榆木脑袋才终于不得不承认异地一定是个大问题。所以到现在,我这股子想回家的心情就变成了内因和外因相结合的无懈可击的推力。但是却还没有热切到一拍脑袋裸辞先回家,再看天的程度。
未立,缺少的是技术能力?
我的看法认为:可能也不是。
虽然我个人学的东西有一点点乱,但怎么说呢,并不影响我自娱自乐。偶尔开发一个自用的小玩意儿,还盲目觉得挺有成就感。
而且,从实际情况来讲,现在的“技术能力”真的不是那么的重要,如果是做产品,可能一些经验能力也不可或缺,但会写代码的人,可是一抓一大把。
比如说,现在的 AI 大模型几乎是热到爆的话题,也算是百花齐放,也各自杀红了眼,现在的新东西,不说自己有个 AI,都不好意思大声讲话,新出的 PC 都挂上 AI PC,魅族都不做手机,改名为 AI 终端了。
作为普通用户和普通个人开发者角度来讲,现在使用这些大模型 API 其实非常便宜了。价格战百万 token 才几十块甚至几块钱,文本对话、文生图、图生文,也都有一定的可用性了。
但是呢,但是呢,能拿来做什么呢?有创造性的同行都已经借着东风,扶摇直上九万里了,我还在感慨好便宜啊,除了BAT平台,这两天还去零一万物、深度求索等平台注册了账号,部分也少少充值了些。但是,虽然好便宜啊,可是能用来做点什么呢?我还真的没有创造性。
既然都说到这里,也厚脸皮顺便说一句,五月底主流厂商大模型在线服务大幅度降价时,还有一些主流厂商推出永久免费的版本。我就简单拿 BAT 的免费版本来试了一下,顺带加上之前的极简记账、随机菜品功能,使用Flutter开发,想做个了简单自用的生活工具助手类的 APP,放在 github 了: ai-light-life(智能轻生活) ,虽然很简陋也不完善,但感兴趣的朋友可以看看。
当然也希望可以到 我 Github 仓库 看看一些其他可能有点意思的东西,比如运动健身相关、听歌休闲娱乐、Web 基础知识什么的。万一能帮到大家了,也不忘点 Star 支持下,谢谢。
生活不需要别人来定义
可能“三十而立”意思是指人在三十岁前后有所成就。少年老成的例子很多,大器晚成的人物也不少,但到最后,这都是别人来定义的这个“立”的含义。
就如见世面,有的人是“周游列国、追求自由”,有的人是“四体勤、五谷分”,有的人的成就是“成家立业,香车美女环绕”,有的人是“著作等身”,也有的人却是成为“艾尔登之王”……外面的人看到的或许不同,但那份自己内心的快乐,是为了、也是应该能够取悦自己的。
今天是我三十岁生日,大概500天前我列了三十岁前想要完成的 10 件小事,结果当然只完成了小部分:
- 体重减到正常 BMI 值;
- 开发一个能自用的 APP/入门一门外语;
- LOL 上个白金/LOLM 上个宗师;
- 谈一次恋爱;
- 出去旅游一次;
- 换一份工作,换一个城市;
- 补上自己的网站博客,整理自己的硬盘;
- 看 10 本名著,并写下每本不多于 5000 字的读后感;
- 完成一部中篇小说;
- 完成 50 篇用心写的博文,可包含那 10 篇读后感。
人生是一条连续的时间线,除了起止点,中间这段旅程,并不会因为某一刻的变化而停下来,最多是慢下来;三十岁之前没有完成的事情,三十岁之后依旧可以去做;以前看得太重的东西,以后还可以改变很多;珍惜的事情太多,抱怨的时间太少;人生这段路,就这么些年,就该为自己走走看;路虽然走得不同,但走路的心情,却可以自己来定。
取悦自己真的比迎合他人要轻松和快乐许多。
共勉吧诸君,感谢垂阅。
来源:juejin.cn/post/7385474787698065417
为什么都放弃了LangChain?
或许从诞生那天起,LangChain 就注定是一个口碑两极分化的产品。
看好 LangChain 的人欣赏它丰富的工具和组建和易于集成等特点,不看好 LangChain 的人,认为它注定失败 —— 在这个技术变化如此之快的年代,用 LangChain 来构建一切根本行不通。
夸张点的还有:
「在我的咨询工作中,我花了 70% 的精力来说服人们不要使用 langchain 或 llamaindex。这解决了他们 90% 的问题。」
最近,一篇 LangChain 吐槽文再次成为热议焦点:
作者 Fabian Both 是 AI 测试工具 Octomind 的深度学习工程师。Octomind 团队会使用具有多个 LLM 的 AI Agent 来自动创建和修复 Playwright 中的端到端测试。
这是一个持续一年多的故事,从选择 LangChain 开始,随后进入到了与 LangChain 顽强斗争的阶段。在 2024 年,他们终于决定告别 LangChain。
让我们看看他们经历了什么:
「LangChain 曾是最佳选择」
我们在生产中使用 LangChain 超过 12 个月,从 2023 年初开始使用,然后在 2024 年将其移除。
在 2023 年,LangChain 似乎是我们的最佳选择。它拥有一系列令人印象深刻的组件和工具,而且人气飙升。LangChain 承诺「让开发人员一个下午就能从一个想法变成可运行的代码」,但随着我们的需求变得越来越复杂,问题也开始浮出水面。
LangChain 变成了阻力的根源,而不是生产力的根源。
随着 LangChain 的不灵活性开始显现,我们开始深入研究 LangChain 的内部结构,以改进系统的底层行为。但是,由于 LangChain 故意将许多细节做得很抽象,我们无法轻松编写所需的底层代码。
众所周知,人工智能和 LLM 是瞬息万变的领域,每周都会有新的概念和想法出现。而 LangChain 这样围绕多种新兴技术创建的抽象概念,其框架设计很难经得起时间考验。
LangChain 为什么如此抽象
起初,当我们的简单需求与 LangChain 的使用假设相吻合时,LangChain 还能帮上忙。但它的高级抽象很快就让我们的代码变得更加难以理解,维护过程也令人沮丧。当团队用在理解和调试 LangChain 的时间和用在构建功能上的时间一样时,这可不是一个好兆头。
LangChain 的抽象方法所存在的问题,可以通过「将一个英语单词翻译成意大利语」这一微不足道的示例来说明。
下面是一个仅使用 OpenAI 软件包的 Python 示例:
这是一段简单易懂的代码,只包含一个类和一个函数调用。其余部分都是标准的 Python 代码。
将其与 LangChain 的版本进行对比:
代码大致相同,但相似之处仅此而已。
我们现在有三个类和四个函数调用。但令人担忧的是,LangChain 引入了三个新的抽象概念:
- Prompt 模板: 为 LLM 提供 Prompt;
- 输出解析器: 处理来自 LLM 的输出;
- 链: LangChain 的「LCEL 语法」覆盖 Python 的 | 操作符。
LangChain 所做的只是增加了代码的复杂性,却没有带来任何明显的好处。
这种代码对于早期原型来说可能没什么问题。但对于生产使用,每个组件都必须得到合理的理解,这样在实际使用条件下才不至于意外崩溃。你必须遵守给定的数据结构,并围绕这些抽象设计应用程序。
让我们看看 Python 中的另一个抽象比较,这次是从 API 中获取 JSON。
使用内置的 http 包:
使用 requests 包:
高下显而易见。这就是好的抽象的感觉。
当然,这些都是微不足道的例子。但我想说的是,好的抽象可以简化代码,减少理解代码所需的认知负荷。
LangChain 试图通过隐藏细节,用更少的代码完成更多的工作,让你的生活变得更轻松。但是,如果这是以牺牲简单性和灵活性为代价的,那么抽象就失去了价值。
LangChain 还习惯于在其他抽象之上使用抽象,因此你往往不得不从嵌套抽象的角度来思考如何正确使用 API。这不可避免地会导致理解庞大的堆栈跟踪和调试你没有编写的内部框架代码,而不是实现新功能。
LangChain 对开发团队的影响
一般来说,应用程序大量使用 AI Agent 来执行不同类型的任务,如发现测试用例、生成 Playwright 测试和自动修复。
当我们想从单一 Sequential Agent 的架构转向更复杂的架构时,LangChain 成为了限制因素。例如,生成 Sub-Agent 并让它们与原始 Agent 互动。或者多个专业 Agent 相互交互。
在另一个例子中,我们需要根据业务逻辑和 LLM 的输出,动态改变 Agent 可以访问的工具的可用性。但是 LangChain 并没有提供从外部观察 Agent 状态的方法,这导致我们不得不缩小实现范围,以适应 LangChain Agent 的有限功能。
一旦我们删除了它,我们就不再需要将我们的需求转化为适合 LangChain 的解决方案。我们只需编写代码即可。
那么,如果不使用 LangChain,你应该使用什么框架呢?也许你根本不需要框架。
**我们真的需要构建人工智能应用程序的框架吗?
**
LangChain 在早期为我们提供了 LLM 功能,让我们可以专注于构建应用程序。但事后看来,如果没有框架,我们的长期发展会更好。
LangChain 一长串的组件给人的印象是,构建一个由 LLM 驱动的应用程序非常复杂。但大多数应用程序所需的核心组件通常如下:
- 用于 LLM 通信的客户端
- 用于函数调用的函数 / 工具
- 用于 RAG 的向量数据库
- 用于跟踪、评估等的可观察性平台。
Agent 领域正在快速发展,带来了令人兴奋的可能性和有趣的用例,但我们建议 —— 在 Agent 的使用模式得到巩固之前,暂时保持简单。人工智能领域的许多开发工作都是由实验和原型设计驱动的。
以上是 Fabian Both 一年多来的切身体会,但 LangChain 并非全然没有可取之处。
另一位开发者 Tim Valishev 表示,他会再坚持使用 LangChain 一段时间:
我真的很喜欢 Langsmith:
- 开箱即用的可视化日志
- Prompt playground,可以立即从日志中修复 Prompt,并查看它在相同输入下的表现
- 可直接从日志轻松构建测试数据集,并可选择一键运行 Prompt 中的简单测试集(或在代码中进行端到端测试)
- 测试分数历史
- Prompt 版本控制
而且它对整个链的流式传输提供了很好的支持,手动实现这一点需要一些时间。
何况,只依靠 API 也是不行的,每家大模型厂商的 API 都不同,并不能「无缝切换」。
你怎么看?
来源:juejin.cn/post/7383894854152437811
Nest:常用 15 个装饰器知多少?
nest 很多功能基于装饰器实现,我们有必要好好了解下有哪些装饰器:
创建 nest 项目:
nest new all-decorator -p npm
@Module({})
这是一个类装饰器,用于定义一个模块。
模块是 Nest.js 中组织代码的单元,可以包含控制器、提供者等:
@Controller() 和 @Injectable()
这两个装饰器也是类装饰器,前者控制器负责处理传入的请求和返回响应,后者定义一个服务提供者,可以被注入到控制器或其他服务中。
通过 @Controller
、@Injectable
分别声明 controller 和 provider:
@Optional、@Inject
创建可选对象(无依赖注入),可以用 @Optional
声明一下,这样没有对应的 provider 也能正常创建这个对象。
注入依赖也可以用 @Inject 装饰器。
@Catch
filter 是处理抛出的未捕获异常,通过 @Catch
来指定处理的异常:
@UseXxx、@Query、@Param
使用 @UseFilters 应用 filter 到 handler 上:
除了 filter 之外,interceptor、guard、pipe 也是这样用:
@Body
如果是 post、put、patch** **请求,可以通过 @Body 取到 body 部分:
我们一般用 dto 定义的 class 来接收验证请求体里的参数。
@Put、@Delete、@Patch、@Options、@Head
@Put、@Delete、@Patch、@Options、@Head 装饰器分别接受 put、delete、patch、options、head 请求:
@SetMetadata
通过 @SetMetadata
指定 metadata,作用于 handler 或 class
然后在 guard 或者 interceptor 里取出来:
@Headers
可以通过 @Headers 装饰器取某个请求头或者全部请求头:
@Ip
通过 @Ip 拿到请求的 ip,通过 @Session 拿到 session 对象:
@HostParam
@HostParam 用于取域名部分的参数。
下面 host 需要满足 xxx.0.0.1 到这个 controller,host 里的参数就可以通过 @HostParam 取出来:
@Req、@Request、@Res、@Response
前面取的这些都是 request 里的属性,当然也可以直接注入 request 对象:
@Req 或者 @Request 装饰器,这俩是同一个东西。
使用 @Res 或 @Response 注入 response 对象,但是注入 response 对象之后,服务器会一直没有响应。
因为这时候 Nest 就不会把 handler 返回值作为响应内容了。我们可以自己返回响应:
Nest 这么设计是为了避免相互冲突。
如果你不会自己返回响应,可以设置 passthrough 为 true 告诉 Nest:
@Next
除了注入 @Res 不会返回响应外,注入 @Next 也不会。
当你有两个 handler 来处理同一个路由的时候,可以在第一个 handler 里注入 next,调用它来把请求转发到第二个 handler。
@HttpCode
handler 默认返回的是 200 的状态码,你可以通过 @HttpCode 修改它:
@Header
当然,你也可以修改 response header,通过 @Header 装饰器:
来源:juejin.cn/post/7340554546253611023
零 rust 基础前端使直接上手 tauri 开发一个小工具
起因
有一天老爸找我,他们公司每年都要在线看视频学习,要花费很多时间,问我有没有办法可以自动学习。
在这之前,我还给我老婆写了个浏览器插件,解决了她的在线学习问题,她学习的是一个叫好医生的学习网站,我通过研究网站的接口和代码,帮她开发出了一键学习全部课程和自动考试的插件,原本需要十来天的学习时间,分分钟就解决了。
有兴趣的可以看一下,好医生自动学习+考试插件源码。
正因为这次的经历,我直接接下了这个需求,毕竟可以在家人面前利用自己的能力去帮他们解决问题,是一件非常骄傲的事。
事情并没有那么简单
我回家一看,他们的学习平台是个桌面端的软件(毕竟是银行的平台,做的比那个好医生严谨的多),内嵌的浏览器,无法打开控制台,更没办法装插件,甚至视频学习调了什么接口,有什么漏洞都无法发现,我感觉有点无能为力。
但是牛逼吹出去了,也得想办法做。
技术选型
既然没办法找系统漏洞去快速学习,那只能按部就班的去听课了,我第一想到的方式是用按键精灵写个脚本,去自动点击就可以了。但是我爸又想给他的同事用,再教他们用按键精灵还是有点上手成本的,所以我打算自己开发一个小工具去实现。
由于我是个前端开发者,做桌面端首先想到的是 Electron,因为我有一些开发经验,所以并不难,但打包后的体积太大,本来一个小工具,做这么大,这不是显得我技术太烂嘛。
所以我选择了 tauri 去开发。
需求分析
首先我想到的方式就是:
- 用鼠标框选一个区域,然后记录这个区域的颜色信息,记录区域坐标。
- 不断循环识别这个区域,匹配颜色。
- 如果匹配到颜色,则点击这个区域。
例如,本节课程学习后,会弹出提示框,进入下一节学习,那么可以识别这个按钮,如果屏幕出现这个按钮,则点击,从而实现自动学习的目的。
我还给它起了个很形象的名字,叫做打地鼠。
由于要点击的不一定只有一个下一节,可能还有其他章节的可能要学习,所以还实现了多任务执行,这样可以识别多个位置。
有兴趣可以看一下源码。
零基础入门 rust
Tauri 已经提供了很多可以在前端调用的接口去实现很多桌面端的功能,但也不能完全能满足我本次开发的需求,所以还是要学习一点 rust 的语法。
这里简单说一下我学到的一些简单语法,方便大家快速入门。由于功能简单,我们并不需要了解 rust 那些高深的内容,了解基础语法即可,不然想学会 rust 我觉得真心很难。我们完全可以先入门,再深入。
适合人群
有一定其他编程语言(C/Java/Go/Python/JavaScript/Typescript/Dart等)基础。你至少得会写点代码是吧。
环境安装
推荐使用 rustup 安装 rust,rustup 是官方提供的的安装工具。
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
安装后,检查版本,这类似安装 node 后查看版本去验证是否成功安装。
rustc --version
>>> rustc 1.73.0 (cc66ad468 2023-10-03)
cargo 是 rust 官方的包管理工具,类似 npm,这里也校验一下是否成功安装。
cargo --version
如果提示不存在指令,重新打开终端再尝试。
编辑器
官方推荐 Clion,是开发 rust 首选开发工具。
不过作为前端,我们依然希望可以使用 vscode 去开发,当然,这也是没有问题的。
vscode 需要搭配 rust-analyzer 一起使用。
除了上面提到的两个命令,还有 rustup
命令也可以直接使用了:
rustup component add rust-analyzer
执行后就都配置好了,可以进行语法的学习了。
变量与常量的声明
定义变量和常量的声明 javascript 和 rust 是一样的,都是通过 let 和 const,但是在定义变量时还是有一些区别的:
- 默认情况下,变量是不可变的。(这点对于前端同学来说是不是很奇怪?)
- 如果你想定义一个可变的变量,需要在变量名前面加上
mut
。
let x = 1;
x = 2; ❌
let mut x = 1;
x = 2; ✅
如果你不想用 mut,你也可以使用相同的名称声明新的变量:
let x = 1;
let x = x + 1;
Rust 里常量的命名规范是使用全大写字母,每个单词之间使用下划线分开,虽然 JS 没有强制的规范,但是我们也是这么做的。
数据类型
对于只了解 javascript 的同学,这个是非常重要的一环,因为 rust 需要在定义变量时做出类型的定义。即使是有过 typescript 开发经验的同学,这里也有着非常大的区别。这里只说一些与 js 区别较大的地方。
数字
首先 ts 对于数字的类型都是统一的 number,但是 rust 区别就比较大了,分为有符号整型,无符号整型,浮点型。
有 i8、i16、i32、i64、i128、u8、u16、u32、u64、isize、usize、f32、f64。
虽然上面看起来有这么多种类型去定义一个数字类型,实际上它们只是去定义了这个值所占用的空间,新手其实不用太过于纠结这里。如果你不知道应该选择哪种类型,直接使用默认的i32
即可,速度也很快。有符号就是分正负(+,-),无符号只有正数。浮点型在现代计算机里上 f64 和 f32 运行速度差不多,f64 更加精确,所以不用太纠结。
数组
数组定义也有很大区别,你需要一开始就定义好数组的长度:
let a: [i32; 5] = [0; 5];
这表示定义一个包含 5 个元素的数组,所有元素都初始化为 0。一旦定义,数组的大小就不能改变了。
这是不是让前端同学很难理解,那么如何定义一个可变的数组呢?这好像更符合前端的思维。
在 Rust 中,Vec 是一个动态数组,也就是说,它可以在运行时增加或减少元素。
let v: Vec<i32> = Vec::new();
v.push(4);
这是不是更符合前端的直觉?毕竟后面我们要使用鼠标框选一个范围的颜色,这个颜色数组是不固定的,所以要用到 Vec
。
数据类型就说到这,其他的有兴趣自行了解即可。
引用包
rust 同 javascript 一样,也可以引入其他包,但语法上就不太一样了,例如:
use autopilot::{geometry::Point, screen, mouse};
强行翻译成 es module 引入:
import { Point, screen, mouse } from 'autopilot';
看到 ::
是不是有点懵逼,javascript 可没有这样的东西,你可以直觉的把它和 .
想象成一样就行。
::
主要用于访问模块(module)或类型(type)的成员。例如,你可以使用 :: 来访问模块中的函数或常量,或者访问枚举的成员。
.
用于访问结构体(struct)、枚举(enum)或者 trait 对象的实例成员,包括字段(field)和方法(method)。
其他语法
循环:
for i in 0..colors.len() {}
条件判断:
if colors[i] != screen_colors[i] {
}
他们就是少了括号,还有一些高级的语法是 ES 没有的,这都很好理解。
那么我说这样就算入门了,不算过分吧?如果你要学一个语言,千万别因为它难而不敢上手,你直接上手去做,遇坑就填,你会进步很快。
如果你觉得这样很难写代码,那么我建议你买个 copilot 或者平替通义灵码,你上手写点小东西应该就不成问题了,毕竟我就这样就开始做了。
软件开发
Tauri 官网翻译还不全,读起来可能有点吃力,借助翻译工具将就着看吧,我有心帮大家翻译,但是提了 pr,好几天也没人审核。
你可以把 tauri 当作前端和后端不分离的项目,webview 就是前端,rust 写后端。
创建项目
tauri 提供了很多方式去帮你创建一个新的项目:
这里初始化一个 vite + vue + ts 的项目:
最后的目录结构可以看一下:
src
就是前端的目录。
src-tauri
就是后端的目录。
前端
前端是老本行,不想说太多的东西,大家都很熟悉,把页面写出来就可以了。
值得一提的就是 tauri 提供的一些接口,这些接口可以让我们实现一些浏览器上无法实现的功能。
与后端通讯
import { invoke } from "@tauri-apps/api";
invoke('event_name', payload)
通过 invoke
可以调用 rust 方法,并通过 payload 去传递参数。
窗口间传递信息
这里的窗口指的是软件的窗口,不是浏览器的标签页。由于我们要框选一块显示器上的区域,所以要创建一个新的窗口去实现,而选择后要将数据传递给主窗口。
import { listen } from '@tauri-apps/api/event';
listen<{ index: number}>("location", async (event) => {
const index = event.payload.index;
// ...
})
获取窗口实例
例如隐藏当前窗口的操作:
import { getCurrent } from '@tauri-apps/api/window';
const win = getCurrent()
win.hide() // 显示窗口即 win.show()
与之相似的还有:
appWindow
获取主窗口实例。getAll
获取所有窗口实例,可以通过label
来区分窗口。
最主要的是 WebviewWindow
,可以通过他去创建一个新的窗口。
const screenshot = new WebviewWindow("screenshot", {
title: "screenshot",
decorations: false,
// 对应 views/screenshot.vue
url: `/#/screenshot?index=${props.index}`,
alwaysOnTop: true,
transparent: true,
hiddenTitle: true,
maximized: true,
visible: false,
resizable: false,
skipTaskbar: false,
})
这里我们创建了一个最大化、透明的窗口,且它位于屏幕最上方,页面指向就是 vue-router 的路由,index 是因为我们不确定要创建多少个窗口,用于区分。
可以通过创建这样的透明窗口,然后实现一个框选区域的功能,这对于前端来说,并不难。
例如鼠标点击左键,滑动鼠标,再松开左键,绘制这个矩形,再加一个按钮。
随后将位置信息传递给主窗口,并关闭这个透明窗口。
后端
首先,src-tauri/src/main.rs
是已经创建好的入口文件,里面已有一些内容,不用都了解。
暴露给前端的方法
tauri::Builder::default().invoke_handler(tauri::generate_handler![scan_once, ...])
通过 invoke_handler
可以暴露给前端 invoke
调用的方法。
!
在 rust 中是指宏调用,主要是方便,并不是 javascript 里的非的含义,这里注意下。
获取屏幕颜色
这里为了性能,我只获取了 x 起始位置到 x 结束位置,y 轴取中间一行的颜色。
use autopilot::{geometry::Point, screen};
pub fn scan_colors(start_x: f64, end_x: f64, y: f64) -> Vec<[u8; 3]> {
// 双重循环,根据 start_x, end_x, y 定义坐标数组
let mut points: Vec<Point> = Vec::new();
let mut x = start_x;
while x < end_x {
points.push(Point::new(x, y));
x += 1.0;
}
// 循环获取坐标数组的颜色
let mut colors: Vec<[u8; 3]> = Vec::new();
for point in points {
let pixel = screen::get_color(point).unwrap();
colors.push([pixel[0], pixel[1], pixel[2]]);
}
return colors;
}
这样就获取到一组颜色数组,包含了 RGB 信息。
这里安装了一个叫 autopilot
的包,可以通过 cargo add autopilot
安装,他可以获取屏幕的颜色,也可以操作鼠标。
鼠标操作
使用 autopilot::mouse 可以进行鼠标操作,移动至 x、y 坐标、病点击鼠标左键。
use autopilot::{geometry::Point, mouse};
mouse::move_to(Point::new(x, y));
mouse::click(mouse::Button::Left, );
配置权限
在 src-tauri/tauri.conf.json
中配置 allowlist,如果不想了解都有哪些权限,直接 all: true
,全部配上,以后再慢慢了解。
"tauri": {
"macOSPrivateApi": true,
"allowlist": {
"all": true,
},
}
注意 mac 上如果使用透明窗口,还需要配置 macOSPrivateApi。
整体流程就是这样的,其他都是细节处理,有兴趣可以看下源码。
构建
我爸的电脑是 windows,而我的是 mac,所以需要构建一个 windows 安装包,但是 tauri 依赖本机库和开链,所以想跨平台编译是不可能的,最好的方法就是托管在 GitHub Actions
这种 CI/CD 平台去做。
在项目下创建 .github/workflows/release.yml
,它将会在你发布 tag
时触发构建。
name: Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: true
jobs:
publish:
strategy:
fail-fast: false
matrix:
platform: [macos-latest, windows-latest]
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with:
version: 8
run_install: true
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: install Rust stable
uses: actions-rs/toolchain@v1
with:
toolchain: stable
- name: Build Vite + Tauri
run: pnpm build
- name: Create release
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tagName: v__VERSION__ # the action automatically replaces \_\_VERSION\_\_ with the app version
releaseName: 'v__VERSION__'
releaseBody: 'See the assets to download and install this version.'
releaseDraft: true
prerelease: false
这里提供一个实例,具体情况具体修改。
secrets.GITHUB_TOKEN
并不需要你配置,他是自动获取的,主要是获得权限去操作你的仓库。因为构建完成会自动创建 release,并上传安装包。
你还需要修改一下仓库的配置:
选中 Read and write permissions,勾选
Allow GitHub Actions to create and approve pull requests。
当你发布 tag 后,会触发 action 执行。
可见,打包速度真的很慢。
Actions 执行完毕后,进入 Releases 页面,可以看到安装包已经发布。
总结
- 关于
tauri
和electron,甚至是
flutter
、qt
这种技术方向没必要讨论谁好谁坏,主要还是考虑项目的痛点,去选择适合自己的方式,没必要捧高踩低。 Rust
真的很难学,我上文草草几句入门,其实并没有那么简单,刚上手会踩很多坑,甚至无从下手不会写代码。我主要的目的是希望大家有想法就要着手去做,毕竟站在岸上学不会游泳。Flutter
使用dart
,我曾经写写过两个 app,相比于rust
,dart
对于前端同学来说可以更轻松的学习。Tauri
我目前还是比较看好,也很看好rust
,大家有时间的话还是值得学习一下,尤其是 2.0 版本还支持了移动端。- 看到很多同学,在学习一门语言或技术时,总是不知道做什么,不只是工作,其实我们身边有很多事情都可以去做,可能只是你想不到。我平时真的是喜欢利用代码去搞一些奇奇怪怪的事,例如我写过
vscode
摸鱼插件、自动学习视频的chrome
插件、互赞平台、小电影爬虫等等,这些都是用 javascript 就实现的。你可以做的很多,给自己提一个需求,然后不要怕踩坑,踩坑的过程是你进步最快的过程,享受它。
来源:juejin.cn/post/7320288231194755122
告别破解版烦恼!Navicat Premium Lite免费版它来了
作为一名后端开发者,在开发过程中使用可视化工具查看数据库中的数据是我们的基本操作。Navicat作为一款广受欢迎的数据库连接工具,深受我们喜爱和挑战。我们喜爱它强大的功能和直观的操作习惯,但又对它的收费模式感到不满。个人使用可以通过破解解决,然而在公司环境下,由于侵权问题,我们通常被禁止使用,这令我们感到很不便。然而,最近Navicat推出了一款免费的产品——Navicat Premium Lite。
Navicat Premium Lite
Navicat Premium Lite 是 Navicat 的精简版,拥有基本数据库操作所需的核心功能。它允许你从单个应用程序同时连接到各种数据库平台,包括 MySQL、Redis、PostgreSQL、SQL Server、Oracle、MariaDB、SQLite 和 MongoDB。Navicat Premium Lite 提供简化的数据库管理体验,使其成为用户的实用选择。
文档地址: https://www.navicat.com.cn/products/navicat-premium-lite
安装及功能对比
- 由于这个版本是免费版,不需要破解,所以安装我们此处就不多作介绍。
- 功能对比
功能对比列表地址:https://www.navicat.com.cn/products/navicat-premium-feature-matrix
Navicat Premium Lite 基础功能都是有的,但是和企业版的相比,还是缺失了一些功能,具体大家可查看官网地址,我们此处列举部分
_20240628063823.jpg
使用感受
整体使用了下,感觉和破解版使用的差别基本不大,缺失的功能几乎无影响。
_20240628064405.jpg
总结
Navicat Premium Lite不仅仅是一款功能全面的数据库管理工具,更是因其免费且功能强大而备受青睐的原因。对于个人开发者、小型团队以及教育用途来说,Navicat Premium Lite提供了一个完全满足需求的解决方案,而无需支付高昂的许可费用。其稳定性、易用性和丰富的功能使得它在数据库管理领域中具备了极高的竞争力。
来源:juejin.cn/post/7384997446219743272
语言≠思维,大模型学不了推理:一篇Nature让AI社区炸锅了
方向完全搞错了?
大语言模型(LLM)为什么空间智能不足,GPT-4 为什么用语言以外的数据训练,就能变得更聪明?现在这些问题有 「标准答案」了。
近日,一篇麻省理工学院(MIT)等机构发表在顶级学术期刊《自然》杂志的文章观察到,人类大脑生成和解析语言的神经网络并不负责形式化推理,而且提出推理并不需要语言作为媒介。
这篇论文声称「语言主要是用于交流的工具,而不是思考的工具,对于任何经过测试的思维形式都不是必需的」,引发了科技领域社区的大讨论。
难道真的如语言学家乔姆斯基所言,追捧 ChatGPT 是浪费资源,大语言模型通向通用人工智能(AGI)的路线完全错了?
让我们看看这篇论文《Language is primarily a tool for communication rather than thought》是怎么说的。
论文链接:http://www.nature.com/articles/s4…
语言是人类智能的一个决定性特征,但它所起的作用或多或少一直存在争议。该研究提供了神经科学等相关学科角度的最新证据,以论证现代人类的语言是一种交流工具,这与我们使用语言进行思考的流行观点相反。
作者首先介绍了支持人类语言能力的大脑网络。随后回顾语言和思维双重分离的证据,并讨论语言的几种特性,这些特性表明语言是为交流而优化的。该研究得出结论认为,尽管语言的出现无疑改变了人类文化,但语言似乎并不是复杂思维(包括符号思维)的先决条件。相反,语言是传播文化知识的有力工具,它可能与我们的思维和推理能力共同进化,并且只反映了人类认知的标志性复杂性,而不是产生这种复杂性。
图 1
研究证据挑战了语言对于思维的重要性。如图 1 所示,使用 fMRI 等成像工具,我们可以识别完整、健康的大脑中的语言区域,然后检查在完成需要不同思维形式的任务时,语言区域的相关响应。
人类大脑中的语言网络
从人脑的生物学结构来看,语言生成和语言理解由左半球一组相互连接的大脑区域支持,通常称为语言网络(图 1a;Box 2 描述了它与语言神经生物学经典模型的关系)。
Box 2。许多教科书仍然使用 Wernicke 提出的语言神经基础模型,并由 Lichteim 和 Geschwind 进行了阐述和修订。该模型包括两个皮层区域:Broca 区位于下额叶皮层,Wernicke 区位于后上颞叶皮层。这两个区域分别支持语言产生和理解,并通过一条背侧纤维束(弓状束)连接。
语言网络有两个非常重要的特性:
首先,语言区域表现出输入和输出模态的独立性,这是表征抽象性的关键特征。主要表现为在理解过程中,这些大脑区域对跨模态(口头、书面或手语)的语言输入做出反应。同样,在语言生成过程中,无论我们是通过口语还是书面语来产生信息,这些区域都是活跃的。这些区域支持语言理解和生成(图 1a)这一事实表明,它们很可能存储了我们的语言知识,这对于编码和解码语言信息都是必需的。
其次,语言区还能对词义和句法结构进行表征和处理。特别是,关于脑磁图和颅内记录研究的证据表明,语言网络的所有区域都对词义以及词间句法和语义依赖性敏感(图 1a)。总之,语言网络中语言表征的抽象性以及网络对语言意义和结构的敏感性使其成为评估语言在思维和认知中的作用假设的明确目标((Box 3)。
我们对人类语言和认知能力,以及它们之间关系的理解仍然不完整,还有一些悬而未决的问题:
- 语言表征的本质是什么?
- 思维是否依赖于符号表征?
- 儿童学习语言时,语言网络是如何成长的?
语言对于任何经过检验的思维形式都不是必需的
经典的方法是通过研究大脑损伤或疾病的个体来推断大脑与行为之间的关联和分离。这种方法依赖于观察大脑某部分受损时个体行为的变化,从而推测不同大脑区域的功能和行为之间的联系。
有证据表明 —— 有许多个体在语言能力上有严重的障碍,影响到词汇和句法能力,但他们仍然表现出在许多思考形式上的完整能力:他们可以解决数学问题,进行执行规划和遵循非言语指令,参与多种形式的推理,包括形式逻辑推理、关于世界的因果推理和科学推理(见图 1b)。
研究表明,尽管失去了语言能力,一些患有严重失语症的人仍然能够进行所有测试形式的思考和推理,他们在各种认知任务中的完整表现就是明证。他们根本无法将这些想法映射到语言表达上,无论是在语言生成中(他们无法通过语言向他人传达自己的想法),还是在理解中(他们无法从他人的单词和句子中提取意义)(图 1b)。当然,在某些脑损伤病例中,语言能力和(某些)思维能力都可能受到影响,但考虑到语言系统与其他高级认知系统的接近性,这是可以预料的。
尤其是一些聋哑儿童,他们长大后很少或根本没有接触过语言,因为他们听不见说话,而他们的父母或看护人不懂手语。缺乏语言接触会对认知的许多方面产生有害影响,这是可以预料的,因为语言是了解世界的重要信息来源。尽管如此,语言剥夺的个体无疑表现出复杂的认知功能能力:他们仍然可以学习数学、进行关系推理、建立因果链,并获得丰富而复杂的世界知识。换句话说,缺乏语言表征并不会使人从根本上无法进行复杂的(包括符号的)思考,尽管推理的某些方面确实表现出延迟。因此,在典型的发展中,语言和推理是平行发展的。
完整的语言并不意味着完整的思维
以上证据表明,迄今为止测试的所有类型的思维都可以在没有语言的情况下实现。
接下来,论文讨论了语言和思维双重分离的另一面:与语言介导思维的观点相反,完整的语言系统似乎并不意味着完整的推理能力。
人类语言是由交流压力塑造的。
来自发育性和后天性脑部疾病的证据表明,即使语言能力基本完好,也可能存在智力障碍。
例如,有些遗传疾病导致智力受损程度不同,但患有这些疾病的人的语言能力似乎接近正常水平;还有一些精神层面有缺陷的人,会影响思考和推理能力,但同样不会影响语言。最后,许多获得性脑损伤的个体在推理和解决问题方面表现出困难,但他们的语言能力似乎完好无损。换句话说,拥有完整的语言系统并不意味着自动具备思考能力:即使语言能力完好无损,思考能力也可能受损。
总的来说,这篇论文回顾了过去二十年的相关工作。失语症研究的证据表明:所有经过检验的思维形式在没有语言的情况下都是可能的。fMRI 成像证据表明:参与多种形式的思考和推理并不需要语言网络。因此,语言不太可能成为任何形式思维的关键基础。
MIT 研究得出结论的同时,顶尖 AI 领域学者最近也发表了对大模型发展的担忧。上个星期四 Claude 3.5 的发布号称拥有研究生水平的推理能力,提升了行业的标准。不过也有人表示经过实测可见,它仍然具有 Transformer 架构的局限性。
对此,图灵奖获得者 Yann LeCun 表示,问题不在于 Transformer,而是因为 Claude 3.5 仍然是一个自回归大模型。无论架构细节如何,使用固定数量的计算步骤来计算每个 token 的自回归 LLM 都无法进行推理。
LeCun 也评论了这篇 Nature 论文,对思维不等于语言表示赞同。
对此,你怎么看?
参考内容:
news.ycombinator.com/item?id=407…
来源:juejin.cn/post/7383934765370425353
还在使用 iconfont,上传图标审核好慢,不如自己做一个
之前使用 iconfont 是非常方便的,上传之后立马生效,项目里面直接引用即可,但是现在因为政策的收紧,每次上传图标都要等待十几分钟二十分钟的审核时间,这怎么能忍,有这个时间我都能写一个页面了好吧。
忍受不了就自己做,说干就干,于是我写了一个 svg 转图标字体的脚手架,所有的内容都自己维护,不再受制于人,感觉就是爽。
svg2font: 一个高效的 SVG 图标字体生成工具
在现代 Web 开发中,使用图标是一种常见的做法。图标不仅能美化界面,还能提高可用性和可访问性。传统上,我们使用图片文件(如 PNG、JPG 等)来显示图标,但这种方式存在一些缺陷,例如图片文件较大、不能任意缩放、无法通过 CSS 设置颜色等。相比之下,使用字体图标具有许多优势,如文件体积小、可无限缩放、可通过 CSS 设置颜色和阴影等。
svg2font 就是一个用于将 SVG 图标转换为字体图标的工具,它可以帮助我们轻松地在项目中集成和使用字体图标。本文将详细介绍 svg2font 的使用方法、应用场景和注意事项。
安装
svg2font 是一个基于 Node.js 的命令行工具,因此需要先安装 Node.js 环境。安装完成后,可以使用 npm 或 yarn 在项目中安装 svg2font:
# 使用npm
npm install @tenado/svg2font -D
# 使用yarn
yarn add @tenado/svg2font -D
初始化配置
安装完成后,需要初始化 svg2font 的配置文件。在项目根目录执行以下命令:
npx svg2font init
该命令会在项目根目录下生成一个 svg2font.config.js 文件,内容如下:
module.exports = {
inputPath: "src/assets/svgs", // SVG图标文件夹路径
outputPath: "src/assets/font", // 生成字体文件的输出路径
fontFamily: "tenadoIcon", // 字体名称
fontPrefix: "", // 字体前缀
};
你可以根据实际需求修改这些配置项。
生成字体图标
配置完成后,就可以执行以下命令生成字体图标了:
npx svg2font sync
该命令会读取 inputPath 指定的 SVG 图标文件夹,将其中的 SVG 文件转换为字体文件(包括.eot、.ttf、.woff、.woff2 等格式),并输出到 outputPath 指定的路径下。同时,它还会生成一个 config.json 文件,记录了每个图标的 Unicode 编码和 CSS 类名。
在项目中使用字体图标
生成字体文件后,需要在项目中引入相应的 CSS 文件,才能正常使用字体图标。svg2font 会自动生成一个 index.min.css 文件,包含了所有字体图标的 CSS 定义。你可以在项目的入口文件(如 main.js)中导入该 CSS 文件:
import "./src/assets/font/index.min.css";
之后,你就可以在 HTML 中使用字体图标了。例如,如果你有一个名为 ticon-color-pick 的图标,可以这样使用:
<span class="ticon-color-pick"></span>
查看图标列表
如果你想查看当前项目包含的所有图标,可以执行以下命令:
npx svg2font example
该命令会根据 config.json 文件生成一个静态 HTML 页面,列出了所有图标及其对应的 CSS 类名和 Unicode 编码。它还会启动一个本地服务器,方便你在浏览器中预览这个页面。
注意事项
使用 svg2font 时,需要注意以下几点:
1.SVG 文件命名: 确保 SVG 文件名不包含特殊字符或空格,否则可能会导致生成字体时出错。
2.SVG 文件优化: 在将 SVG 文件转换为字体之前,建议先对 SVG 文件进行优化,以减小文件大小。你可以使用工具如 SVGO 或 SVG Optimizer 来优化 SVG 文件。
3.字体支持:不同浏览器和操作系统对字体格式的支持程度不同。为了最大程度地兼容各种环境,svg2font 会生成多种字体格式(.eot、.ttf、.woff、.woff2 等)。
4.字体缓存: 浏览器会缓存字体文件,因此在更新字体图标时,需要确保浏览器加载了最新的字体文件。你可以在 CSS 文件中为字体文件添加版本号或时间戳,以强制浏览器重新加载字体文件。
总结
svg2font 是一个功能强大且易于使用的 SVG 图标字体生成工具。它可以帮助你轻松地将 SVG 图标转换为字体格式,并在 Web 应用程序、跨平台应用程序或图标库中使用这些字体图标。通过使用 svg2font,你可以提高页面性能、确保图标显示一致性,并享受字体图标带来的诸多优势。
无论你是 Web 开发人员、移动应用程序开发人员,还是 UI 设计师,svg2font 都值得一试。它简单易用,且具有丰富的功能和配置选项,可以满足不同项目的需求。快来试试 svg2font,让你的项目与众不同吧!
来源:juejin.cn/post/7384808085348483087
时隔5年重拾前端开发,却倒在了环境搭建上
背景
去年不是降本增“笑”,“裁员”广进来着吗,公司有个项目因此停止了,最近又说这个项目还是很有必要的,就又重新启动这个项目了,然后让我这个“大聪明”把环境重新跑起来。让我无奈的是,原项目的团队成员都已经被增“笑”了,只留下了一堆不知从哪开始着手的文档。
后端还好,前端我心里就犯嘀咕了,毕竟已经5年没有关注过前端了,上次写前端代码用的还是一个基于Angular构建的移动框架inoic,不知道大家用过没有。
好在这个项目前端也用的Angular框架,本以为整个过程会很顺利,然而,结果总是事与愿违。果不其然,在搭建前端开发环境时就给我上了一课,整个过程让我抓耳挠腮,遂特此记录。
环境搭建心路历程
跟着文档操作
前端文档中对环境搭建有进行说明,一共有4个步骤,大概是这样的:
- 确认node环境,需要某个及以上版本。
- 安装@angular/cli。
- 安装依赖。
- 启动项目。
看到这里,我第一反应是“啊?现在前端这么麻烦的吗?”,我记得以前在浏览器直接打开页面就可以访问了。咱也不懂,跟着说明操作就行。
- 我本地不知道啥时候装了nodejs,执行node -v后输出v18.13.0,符合要求。ok
- @angular/cli这是啥,咋也不懂,执行安装命令就行,输出看上去是没有问题。ok
- 安装依赖我理解跟Maven的依赖管理一样,先不管,执行。ok
- 到这一步,我觉得应该可以顺利启动,看一看这个项目的庐山真面目了,结果执行 npm start 后报下面这个错。
出现问题一:nodeJS版本过高
Error: error:0308010C:digital envelope routines::unsupported
......
......
{
'opensslErrorStack': [ 'error:03000086:digital envelope routines::initialization error' ],
'library': 'digital envelope routines',
'reason': 'unsupported',
'code': 'ERR_OSSL_EVP_UNSUPPORTED'
}
......
......
百度一看,原因是node 17版本之后,OpenSSL3.0对算法和密钥大小增加了严格的限制。
解决呗,降版本呗,node官网 下载了v14.12.0。
出现问题二:nodeJS版本低于Angular CLI版本
降版本之后重新运行npm start
,您猜猜怎么着
Node.js version v14.12.0 detected.
The Angular CLI requires a minimum Node.js version of v18.13.
Please update your Node.js version or visit https://nodejs.org/ for additional instructions.
很明显,新老版本冲突了,又是版本问题,又是一顿百度之后,发现知乎上的一个帖子跟我这问题现象是一样的:“node是最新版,npm启动项目使用的不是最新版的node,请问这个怎么解决?”
跟着下面的评论又安装了nvm(Node Version Manager),最后一顿操作后,莫名其妙的启动了。
事后才反应过来,这个问题的根本原因是:Angular CLI是在node版本为18.3时安装的,版本更新到14.12.0后需要删除依赖重新安装。
不过nvm确实好用,至少不用担心node和npm版本问题,比如下面的命令:
[xxx % ] nvm use --delete-prefix v18.13.0
Now using node v18.13.0 (npm v8.19.3)
学到的第一个知识:nvm
这里记录下nvm安装过程
- clone this repo in the root of your user profile
- cd ~/ from anywhere then git clone github.com/nvm-sh/nvm.… .nvm
- cd ~/.nvm and check out the latest version with git checkout v0.39.7
- activate nvm by sourcing it from your shell: . ./nvm.sh
配置环境变量
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion" # This loads nvm bash_completion
引发的思考
技术发展日新月异
早在几年前,程序员是要前后端一起开发的,不分什么前后端,我从最开始的HTML、JavaScript开始用到AngularJS这些框架,印象最深刻的是还要解决兼容IE浏览器。没想到现在的前端也会有版本管理、组件化等等,可见技术更新迭代速度之快。
前端的重要性
当初在选择后端的时候认为前端技术无非就那些,没有什么挑战。事实上,前后端没有分离之前,市场上的应用页面也是极其简洁的,前后端一起兼顾是没有精力写出那么好看的界面和交互的。所以“前端已死”的观点我是不认可的。
降本增“笑”被迫全栈
前几天参加了开发者社区的线下聚会,聊了一下行情。有小伙伴吐槽,因为在降本增“笑”的原因,现在他们被公司要求要写前端,被迫向全栈发展,竟意外发现开发效率极其高。还有小伙伴说“前端被裁的剩下几个人,一个前端对接十个后端。”。是呀,在降本增“笑”之后,老板恨不得让一个人干十个人的活。
与时俱进
不论是几年前的前后端分离还是降本增“笑”带来的被迫全栈,还是最近“前端已死”的观点,一切都是行业发展所需要的。我们需要做到的是:不断学习和更新自己的知识和技能,以适应行业的发展和变化。
来源:juejin.cn/post/7327599804325052431
cesium 鼠标动态绘制墙及墙动效
实现在cesium中基于鼠标动态绘制墙功能
1. 基本架构设计
绘制墙的交互与绘制线的交互几乎一模一样,只是一些生成wall实体的计算方法不一样,所以可以看这篇文章 cesium 鼠标动态绘制线及线动效 juejin.cn/post/728826… 了解相关的架构设计
2. 关键代码实现
2.1 绘制线交互相关事件
事件绑定相关与动态绘制线一样,这里不再重复代码
绘制形状代码有区别:
为了实现墙贴地,要实时计算minimumHeights,maximumHeights的值,min中算出地形高度,max中再地形高度的基础上再加上墙的高度
/**
* 绘制形状,用于内部临时画墙
* @param positionData 位置数据
* @param config 墙的配置项
* @returns
*/
private drawShape(positionData: Cartesian3[], config?: WallConfig) {
const wallConfig = config || new WallConfig();
const material = this.createMaterial(wallConfig);
// @ts-ignore
const pArray = positionData._callback();
const shape = this.app.viewerCesium.entities.add({
wall: {
positions: positionData,
material: material,
maximumHeights: new CallbackProperty(() => {
let heights: number[] = [];
for (let i = 0; i < pArray.length; i++) {
const cartographic = Cartographic.fromCartesian(pArray[i]);
const height = cartographic.height;
heights.push(height);
}
const data = Array.from(heights, (x) => x + wallConfig.height);
return data;
}, false),
minimumHeights: new CallbackProperty(() => {
let heights: number[] = [];
for (let i = 0; i < pArray.length; i++) {
const cartographic = Cartographic.fromCartesian(pArray[i]);
const height = cartographic.height;
heights.push(height);
}
const data = Array.from(heights);
return data;
}, false)
}
});
return shape;
}
2.2 创建材质相关
/**
* 创建材质
* @param config 墙的配置项
* @returns
*/
private createMaterial(config: WallConfig) {
let material = new ColorMaterialProperty(Color.fromCssColorString(config.style.color));
if (config.style.particle.used) {
material = new WallFlowMaterialProperty({
image: config.style.particle.image,
forward: config.style.particle.forward ? 1.0 : -1.0,
horizontal: config.style.particle.horizontal,
speed: config.style.particle.speed,
repeat: new Cartesian2(config.style.particle.repeat, 1.0)
});
}
return material;
}
创建WallFlowMaterialProperty.js(具体为何如此请看这篇文章,cesium自定义材质 juejin.cn/post/728795…
import { Color, defaultValue, defined, Property, createPropertyDescriptor, Material, Event, Cartesian2 } from 'cesium';
const defaultColor = Color.TRANSPARENT;
import defaultImage from '../../../assets/images/effect/line-color-yellow.png';
const defaultForward = 1;
const defaultHorizontal = false;
const defaultSpeed = 1;
const defaultRepeat = new Cartesian2(1.0, 1.0);
class WallFlowMaterialProperty {
constructor(options) {
options = defaultValue(options, defaultValue.EMPTY_OBJECT);
this._definitionChanged = new Event();
// 定义材质变量
this._color = undefined;
this._colorSubscription = undefined;
this._image = undefined;
this._imageSubscription = undefined;
this._forward = undefined;
this._forwardSubscription = undefined;
this._horizontal = undefined;
this._horizontalSubscription = undefined;
this._speed = undefined;
this._speedSubscription = undefined;
this._repeat = undefined;
this._repeatSubscription = undefined;
// 变量初始化
this.color = options.color || defaultColor; //颜色
this.image = options.image || defaultImage; //材质图片
this.forward = options.forward || defaultForward;
this.horizontal = options.horizontal || defaultHorizontal;
this.speed = options.speed || defaultSpeed;
this.repeat = options.repeat || defaultRepeat;
}
// 材质类型
getType() {
return 'WallFlow';
}
// 这个方法在每次渲染时被调用,result的参数会传入glsl中。
getValue(time, result) {
if (!defined(result)) {
result = {};
}
result.color = Property.getValueOrClonedDefault(this._color, time, defaultColor, result.color);
result.image = Property.getValueOrClonedDefault(this._image, time, defaultImage, result.image);
result.forward = Property.getValueOrClonedDefault(this._forward, time, defaultForward, result.forward);
result.horizontal = Property.getValueOrClonedDefault(this._horizontal, time, defaultHorizontal, result.horizontal);
result.speed = Property.getValueOrClonedDefault(this._speed, time, defaultSpeed, result.speed);
result.repeat = Property.getValueOrClonedDefault(this._repeat, time, defaultRepeat, result.repeat);
return result;
}
equals(other) {
return (
this === other ||
(other instanceof WallFlowMaterialProperty &&
Property.equals(this._color, other._color) &&
Property.equals(this._image, other._image) &&
Property.equals(this._forward, other._forward) &&
Property.equals(this._horizontal, other._horizontal) &&
Property.equals(this._speed, other._speed) &&
Property.equals(this._repeat, other._repeat))
);
}
}
Object.defineProperties(WallFlowMaterialProperty.prototype, {
isConstant: {
get: function get() {
return (
Property.isConstant(this._color) &&
Property.isConstant(this._image) &&
Property.isConstant(this._forward) &&
Property.isConstant(this._horizontal) &&
Property.isConstant(this._speed) &&
Property.isConstant(this._repeat)
);
}
},
definitionChanged: {
get: function get() {
return this._definitionChanged;
}
},
color: createPropertyDescriptor('color'),
image: createPropertyDescriptor('image'),
forward: createPropertyDescriptor('forward'),
horizontal: createPropertyDescriptor('horizontal'),
speed: createPropertyDescriptor('speed'),
repeat: createPropertyDescriptor('repeat')
});
Material.WallFlowType = 'WallFlow';
Material._materialCache.addMaterial(Material.WallFlowType, {
fabric: {
type: Material.WallFlowType,
uniforms: {
// uniforms参数跟我们上面定义的参数以及getValue方法中返回的result对应,这里值是默认值
color: defaultColor,
image: defaultImage,
forward: defaultForward,
horizontal: defaultHorizontal,
speed: defaultSpeed,
repeat: defaultRepeat
},
// source编写glsl,可以使用uniforms参数,值来自getValue方法的result
source: `czm_material czm_getMaterial(czm_materialInput materialInput)
{
czm_material material = czm_getDefaultMaterial(materialInput);
vec2 st = materialInput.st;
vec4 fragColor;
if (horizontal) {
fragColor = texture(image, fract(vec2(st.s - speed*czm_frameNumber*0.005*forward, st.t)*repeat));
} else {
fragColor = texture(image, fract(vec2(st.t - speed*czm_frameNumber*0.005*forward, st.t)*repeat));
}
material.emission = fragColor.rgb;
material.alpha = fragColor.a;
return material;
}`
},
translucent: true
});
export { WallFlowMaterialProperty };
2.3 添加wall实体
/**
* 根据已知数据添加一个墙
* @param config 墙的配置项
*/
add(config: WallConfig) {
const configCopy = cloneDeep(config);
const positions = configCopy.positions;
const material = this.createMaterial(configCopy);
let distance = new DistanceDisplayCondition();
if (configCopy.distanceDisplayCondition) {
distance = new DistanceDisplayCondition(
configCopy.distanceDisplayCondition.near,
configCopy.distanceDisplayCondition.far
);
}
let heights: number[] = [];
for (let i = 0; i < positions.length; i++) {
const cartographic = Cartographic.fromCartesian(positions[i]);
const height = cartographic.height;
heights.push(height);
}
this.app.viewerCesium.entities.add({
id: 'wallEntity_' + configCopy.id,
wall: {
positions: positions,
maximumHeights: Array.from(heights, (x) => x + configCopy.height),
minimumHeights: Array.from(heights),
material: material,
distanceDisplayCondition: distance
}
});
this._wallConfigList.set('wallEntity_' + configCopy.id, config);
}
3. 业务端调用
调用方式与动态绘制线一样,是同一种架构设计,这里不再重复代码
4. 效果
来源:juejin.cn/post/7288606110335565883
前端如何生成临时链接?
前言
前端基于文件上传需要有生成临时可访问链接的能力,我们可以通过URL.createObjectURL
和FileReader.readAsDataUR
API来实现。
URL.createObjectURL()
URL.createObjectURL()
静态方法会创建一个 DOMString
,其中包含一个表示参数中给出的对象的URL。这个 URL 的生命周期和创建它的窗口中的 document
绑定。这个新的URL 对象表示指定的 File
对象或 Blob
对象。
1. 语法
let objectURL = URL.createObjectURL(object);
2. 参数
用于创建 URL 的 File
对象、Blob
对象或者 MediaSource
对象。
3. 返回值
一个DOMString
包含了一个对象URL,该URL可用于指定源 object的内容。
4. 示例
"file" id="file">
document.querySelector('#file').onchange = function (e) {
console.log(e.target.files[0])
console.log(URL.createObjectURL(e.target.files[0]))
}
将上方console控制台打印的blob文件资源地址粘贴到浏览器中
blob:http://localhost:8080/1ece2bb1-b426-4261-89e8-c3bec43a4020
URL.revokeObjectURL()
在每次调用 createObjectURL()
方法时,都会创建一个新的 URL 对象,即使你已经用相同的对象作为参数创建过。当不再需要这些 URL 对象时,每个对象必须通过调用 URL.revokeObjectURL()
方法来释放。
浏览器在 document 卸载的时候,会自动释放它们,但是为了获得最佳性能和内存使用状况,你应该在安全的时机主动释放掉它们。
1. 语法
window.URL.revokeObjectURL(objectURL);
2. 参数 objectURL
一个 DOMString
,表示通过调用 URL.createObjectURL()
方法返回的 URL 对象。
3. 返回值
undefined
4. 示例
"file" id="file">
<img id="img1" style="width: 200px;height: auto" />
<img id="img2" style="width: 200px;height: auto" />
document.querySelector('#file').onchange = function (e) {
const file = e.target.files[0]
const URL1 = URL.createObjectURL(file)
console.log(URL1)
document.querySelector('#img1').src = URL1
URL.revokeObjectURL(URL1)
const URL2 = URL.createObjectURL(file)
console.log(URL2)
document.querySelector('#img2').src = URL2
}
与FileReader.readAsDataURL(file)区别
1. 主要区别
- 通过
FileReader.readAsDataURL(file)
可以获取一段data:base64
的字符串 - 通过
URL.createObjectURL(blob)
可以获取当前文件的一个内存URL
2. 执行时机
createObjectURL
是同步执行(立即的)FileReader.readAsDataURL
是异步执行(过一段时间)
3. 内存使用
createObjectURL
返回一段带hash
的url
,并且一直存储在内存中,直到document
触发了unload
事件(例如:document close
)或者执行revokeObjectURL
来释放。FileReader.readAsDataURL
则返回包含很多字符的base64
,并会比blob url
消耗更多内存,但是在不用的时候会自动从内存中清除(通过垃圾回收机制)
4. 优劣对比
- 使用
createObjectURL
可以节省性能并更快速,只不过需要在不使用的情况下手动释放内存 - 如果不在意设备性能问题,并想获取图片的
base64
,则推荐使用FileReader.readAsDataURL
来源:juejin.cn/post/7333236033038778409
Vue3 实现最近很火的酷炫功能:卡片悬浮发光
前言
大家好,我是林三心,用最通俗易懂的话讲最难的知识点是我的座右铭,基础是进阶的前提是我的初心~
有趣的动画效果
前几天在网上看到了一个很有趣的动画效果,如下,光会跟随鼠标在卡片上进行移动,并且卡片会有视差的效果
那么在 Vue3 中应该如何去实现这个效果呢?
基本实现思路
其实实现思路很简单,无非就是分几步:
- 首先,卡片是
相对定位
,光是绝对定位
- 监听卡片的鼠标移入事件
mouseenter
,当鼠标进入时显示光 - 监听卡片的鼠标移动事件
mouseover
,鼠标移动时修改光的left、top
,让光跟随鼠标移动 - 监听卡片的鼠标移出事件
mouseleave
,鼠标移出时,隐藏光
我们先在 Index.vue
中准备一个卡片页面,光的CSS效果可以使用filter: blur()
来实现
可以看到现在的效果是这样
实现光源跟随鼠标
在实现之前我们需要注意几点:
- 1、鼠标移入时需要设置卡片
overflow: hidden
,否则光会溢出,而鼠标移出时记得还原 - 2、获取鼠标坐标时需要用
clientX/Y
而不是pageX/Y
,因为前者会把页面滚动距离也算进去,比较严谨
刚刚说到实现思路时我们说到了mouseenter、mousemove、mouseleave
,其实mouseenter、mouseleave
这二者的逻辑比较简单,重点是 mouseover
这个监听函数
而在 mouseover
这个函数中,最重要的逻辑就是:光怎么跟随鼠标移动呢?
或者也可以这么说:怎么计算光相对于卡片盒子的 left 和 top
对此我专门画了一张图,相信大家一看就懂怎么算了
- left = clientX - x - width/2
- height = clientY - y - height/2
知道了怎么计算,那么逻辑的实现也很明了了~封装一个use-light-card.ts
接着在页面中去使用
这样就能实现基本的效果啦~
卡片视差效果
卡片的视差效果需要用到样式中 transform
样式,主要是配置四个东西:
- perspective:定义元素在 3D 变换时的透视效果
- rotateX:X 轴旋转角度
- rotateY:Y 轴旋转角度
- scale3d:X/Y/Z 轴上的缩放比例
现在就有了卡片视差的效果啦~
给所有卡片添加光源
上面只是给一个卡片增加光源,接下来可以给每一个卡片都增加光源啦!!!
让光源变成可配置
上面的代码,总感觉这个 hooks 耦合度太高不太通用,所以我们可以让光源变成可配置化,这样每个卡片就可以展示不同大小、颜色的光源了~像下面一样
既然是配置化,那我们希望是这么去使用 hooks 的,我们并不需要自己在页面中去写光源的dom节点,也不需要自己去写光源的样式,而是通过配置传入 hooks 中
所以 hooks 内部要自己通过操作 DOM 的方式,去添加、删除光源,可以使用createElement、appendChild、removeChild
去做这些事~
完整源码
<!-- Index.vue -->
<template>
<div class="container">
<!-- 方块盒子 -->
<div class="item" ref="cardRef1"></div>
<!-- 方块盒子 -->
<div class="item" ref="cardRef2"></div>
<!-- 方块盒子 -->
<div class="item" ref="cardRef3"></div>
</div>
</template>
<script setup lang="ts">
import { useLightCard } from './use-light-card';
const { cardRef: cardRef1 } = useLightCard();
const { cardRef: cardRef2 } = useLightCard({
light: {
color: '#ffffff',
width: 100,
},
});
const { cardRef: cardRef3 } = useLightCard({
light: {
color: 'yellow',
},
});
</script>
<style scoped lang="less">
.container {
background: black;
width: 100%;
height: 100%;
padding: 200px;
display: flex;
justify-content: space-between;
.item {
position: relative;
width: 125px;
height: 125px;
background: #1c1c1f;
border: 1px solid rgba(255, 255, 255, 0.1);
}
}
</style>
// use-light-card.ts
import { onMounted, onUnmounted, ref } from 'vue';
interface IOptions {
light?: {
width?: number; // 宽
height?: number; // 高
color?: string; // 颜色
blur?: number; // filter: blur()
};
}
export const useLightCard = (option: IOptions = {}) => {
// 获取卡片的dom节点
const cardRef = ref<HTMLDivElement | null>(null);
let cardOverflow = '';
// 光的dom节点
const lightRef = ref<HTMLDivElement>(document.createElement('div'));
// 设置光源的样式
const setLightStyle = () => {
const { width = 60, height = 60, color = '#ff4132', blur = 40 } = option.light ?? {};
const lightDom = lightRef.value;
lightDom.style.position = 'absolute';
lightDom.style.width = `${width}px`;
lightDom.style.height = `${height}px`;
lightDom.style.background = color;
lightDom.style.filter = `blur(${blur}px)`;
};
// 设置卡片的 overflow 为 hidden
const setCardOverflowHidden = () => {
const cardDom = cardRef.value;
if (cardDom) {
cardOverflow = cardDom.style.overflow;
cardDom.style.overflow = 'hidden';
}
};
// 还原卡片的 overflow
const restoreCardOverflow = () => {
const cardDom = cardRef.value;
if (cardDom) {
cardDom.style.overflow = cardOverflow;
}
};
// 往卡片添加光源
const addLight = () => {
const cardDom = cardRef.value;
if (cardDom) {
cardDom.appendChild(lightRef.value);
}
};
// 删除光源
const removeLight = () => {
const cardDom = cardRef.value;
if (cardDom) {
cardDom.removeChild(lightRef.value);
}
};
// 监听卡片的鼠标移入
const onMouseEnter = () => {
// 添加光源
addLight();
setCardOverflowHidden();
};
// use-light-card.ts
// 监听卡片的鼠标移动
const onMouseMove = (e: MouseEvent) => {
// 获取鼠标的坐标
const { clientX, clientY } = e;
// 让光跟随鼠标
const cardDom = cardRef.value;
const lightDom = lightRef.value;
if (cardDom) {
// 获取卡片相对于窗口的x和y坐标
const { x, y } = cardDom.getBoundingClientRect();
// 获取光的宽高
const { width, height } = lightDom.getBoundingClientRect();
lightDom.style.left = `${clientX - x - width / 2}px`;
lightDom.style.top = `${clientY - y - height / 2}px`;
// 设置动画效果
const maxXRotation = 10; // X 轴旋转角度
const maxYRotation = 10; // Y 轴旋转角度
const rangeX = 200 / 2; // X 轴旋转的范围
const rangeY = 200 / 2; // Y 轴旋转的范围
const rotateX = ((clientX - x - rangeY) / rangeY) * maxXRotation; // 根据鼠标在 Y 轴上的位置计算绕 X 轴的旋转角度
const rotateY = -1 * ((clientY - y - rangeX) / rangeX) * maxYRotation; // 根据鼠标在 X 轴上的位置计算绕 Y 轴的旋转角度
cardDom.style.transform = `perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg)`; //设置 3D 透视
}
};
// 监听卡片鼠标移出
const onMouseLeave = () => {
// 鼠标离开移出光源
removeLight();
restoreCardOverflow();
};
onMounted(() => {
// 设置光源样式
setLightStyle();
// 绑定事件
cardRef.value?.addEventListener('mouseenter', onMouseEnter);
cardRef.value?.addEventListener('mousemove', onMouseMove);
cardRef.value?.addEventListener('mouseleave', onMouseLeave);
});
onUnmounted(() => {
// 解绑事件
cardRef.value?.removeEventListener('mouseenter', onMouseEnter);
cardRef.value?.removeEventListener('mousemove', onMouseMove);
cardRef.value?.removeEventListener('mouseleave', onMouseLeave);
});
return {
cardRef,
};
};
结语 & 加学习群 & 摸鱼群
我是林三心
- 一个待过小型toG型外包公司、大型外包公司、小公司、潜力型创业公司、大公司的作死型前端选手;
- 一个偏前端的全干工程师;
- 一个不正经的掘金作者;
- 一个逗比的B站up主;
- 一个不帅的小红书博主;
- 一个喜欢打铁的篮球菜鸟;
- 一个喜欢历史的乏味少年;
- 一个喜欢rap的五音不全弱鸡
如果你想一起学习前端,一起摸鱼,一起研究简历优化,一起研究面试进步,一起交流历史音乐篮球rap,可以来俺的摸鱼学习群哈哈,点这个,有7000多名前端小伙伴在等着一起学习哦 --> 摸鱼沸点
来源:juejin.cn/post/7373867360019742758
CSDN 搬运了 Github 所有项目,骚操作一波接一波
最近几天,CSDN 旗下的代码平台 GitCode 在未获得任何作者授权的情况下,批量搬运了 Github 上的几乎所有开源项目:
GitCode 不仅把项目所有信息都搬运到自己平台上,还给每个开发者、组织都创建了主页。如果作者想要编辑和操作自己的主页和项目,只能使用 Github 授权登录并创建 GitCode 平台的账号才能操作。
GitCode 甚至把项目 README 中的 github 字样都替换成了gitcode...
CSDN 的骚操作远不止这些,它们甚至创建了一批 CSDN 小号,并使用 AI 发布大量 GitCode 项目的相关内容,以进行引流。大家都知道,CSDN 的内容在很多搜索引擎中的权重是比较高的,这一骚操作就回导致搜索结果又多了很多垃圾信息。
AI 盛行的今天,大模型需要使用大量互联网信息进行训练。而 CSDN 用 AI 生成垃圾内容发布到网络上,多多少少会对大模型的质量产生影响,大模型又会生成更多垃圾内容,最终形成恶性循环,想想都可怕。
最搞笑的是,GitCode 在搬运 Github 项目时似乎没有做筛选,搬运了很多违法、违规的项目(懂得都懂),导致网站短暂 404,真是搬起石头砸了自己的脚。
此事发生后,很多开发者都出来声讨 GitCode,并要求其删除账号和项目:
最后不得不吐槽一句,这么一个半成品网站(网站随处可见的Bug),就不要拿出来搞事情了,很难看的。
有网友整理了 CSDN 的五宗罪:
图源:
https://github.com/Catherina0/evil-CSDN
作者:极速星空4DO
来源:www.toutiao.com/article/7384999821570064950/
收起阅读 »你想活出怎样的人生?
hi~好久不见,距离上次发文隔了有段时间了,这段时间,我是裸辞去感受了一下前端市场的水深火热,那么这次咱们不聊技术,就说一说最近这段时间的经历和一些感触吧。
先说一下自己的个人情况,目前做前端四年,双非本,非科班,技术栈Vue和小程序,读过源码,刷过算法,写过开源,工作地点在武汉。
我是在三月初裸辞向公司提的离职,并在四月初离开。在做出裸辞这个决定之前,其实也是犹豫了好久,因为在上家公司做开发还是很愉快的,同时看网上大家对于如今的市场行情评价都是寒气逼人,所以对于这次的裸辞我思考了有半年之久。
我的想法有几个点:
- 上家公司整体规模偏小,而且项目的复杂度并不太高,技术上的成长主要靠个人,所以如果在这里继续做下去,技术,眼界,薪资可能都会比较受限,越往后越会出现技术不匹配年限的问题。如果公司一旦出现了点什么问题,那么个人在市面上可选择的岗位就会十分受限。
- 互联网下行的情况在前两年就已经出现了,然而每年又都会有一大批新的大学生加入到这个行业,那么可能真的今年就是往后十年中最好的一年了,之后一定是会越来越卷的。
- 对自己的技术还算是有些信心,觉得不至于会找不到合适的工作。
综合考虑了以上几点,决定就勇敢一次,迈出这一步,不论后面的结果如何都是自己的选择。
面试
然后,就聊一聊最近这段时间面试的感触吧。先说结论,别的城市倒不清楚,就只说武汉,行情的确是有些差的,主要体现在小公司开不起价,大点公司(武汉其实也没什么大公司)又很难过简历筛,再加之岗位有限,所以整体的感受就是水深火热。
从三月中下旬开始投递简历,一直到五月底决定去向,这期间在招聘软件上打了上百次招呼,拿到十二个面试机会,通过的有七家,最终选择了离家还算比较近,工作流程以及规模还不错的一家公司入了职。
这段时间可以说是要比平时上班还要累的,工作日每天起来就会去刷一刷招聘软件,去看看有没有新出的职位可以聊一下的,但渐渐的就会发现,招聘软件翻来覆去就那么几家公司,还都是常年招聘的,新出的机会可能要好久才会遇到一次。
能约到面试的几天心态还会好一些,可一旦连续几天没有约到面试,投递简历都石沉大海,那个时候内心就会开始有些焦虑,很容易会想要不要随便找一家将就下得了,但好在每次有这种想法的时候,都会有新的面试邀约出现,也算是挺幸运的了。而且根据每次面试的过程来看,目前我点的技能点是完全够用了的,甚至面一些小公司的时候,有时能清晰的感受到在吊打面试官,这也算是无形中增加了我的信心吧,能够让我继续战斗下去~ 而且也非常感谢在找工作时给我鼓励的掘友,当时面了一家公司,而面试官是一位掘友的朋友,可能下去后面试官和掘友提起了我的面试,晚上在掘金收到了掘友的私信,说我的技术一定没问题的,而且算法可以,一定要去投一投大公司~ 当天收到私信时,可以说真的是热泪盈眶,感受到了寒冬中的小小温暖,真的非常感谢~
然后说一下面试体验吧,面试体验真的和公司规模成正比的。
窒息的面试体验
我面的这几家,有一些小公司的面试官或者hr真的各种作妖:
- 有的时候吊打了面试官,然后hr来谈薪想压价,拿什么压我都能理解,毕竟公司给到hr的预算可能有限,但是拿技术来压,真就不理解,面试官都没说什么,甚至当场说技术确实很不错,然后一个hr来尝试根据之前做的项目找漏洞去聊技术,聊复杂度去压价,真的是让人难以理解。
- 有的公司则是非常的小,然后面试官应该就是公司领导吧,给了一份笔试题,做完后去面试,笔试当时做了15分钟,面试只12分钟,而面试的时候在刚进行2分钟我就已经想结束面试直接走人了,面试官就是对着他出的一份稀烂的笔试题一个个问,我也一个个给他答,每答一个他都先把你的答案给否定,然后尝试从回答中找漏洞,没有找到那就再问一个他自己现编的很奇怪的问题,真就离谱,也真是我素质还算好,没有当场去怼他,当时面的12分钟真的是折磨。
- 再不然有些面试官,就是简历也不细看,就会去问一些冷门API的用法,这一家当时我已经面到后期了,见了形形色色的面试官,所以也不惯着,直接就问他,你问这个有什么用呢?你是想招干活的人,还是想招可培养的人?那你面试问一个API能问出来什么呢?
愉快的面试体验
说完了小公司的体验,再说一些体验还不错的面试吧,一个体验比较好的面试给人的感觉就是,对方是能把我掌握的技术深度和广度都给探到,并且双方面试过程更像是探讨的过程。
- 有的面试官会在听你介绍项目难点以及解决方案的时候,逐步的引导你去思考出更优的解决方案。
- 有的面试官则会给你一种感觉就是,这个面试官真的很大佬,比如我遇到的一个面试官精通源码,虽然我也看过并且写过源码文章,但在很多细节的地方还是会有所遗忘,在面试的过程中,有的地方思路乱了,面试官则会在我把我知道的都讲完之后,去完整的给梳理一次思路,并说明整个的运行流程。
这两种面试官其实都有一个共同的点,就是他是在找你技术的深度和解决问题的能力,让你尽可能的展示自己,而不是对着一份面试题或者就是想刁难你找优越感。
最后的选择
最终,选择的这家,其实薪资上的涨幅很小,但工作强度会比上一家大上不少。面了2个月,这个过程很累,我也没有太多的能量去接着去面试了,而这家公司整体面试体验给我的感觉还可以,就先入职看看喽~
然后,关于自己的职业发展,目前其实是有些迷茫的,刚入行前端的时候,感觉当时的机会还是很多的,能看到很多大厂的招聘要求以及结合一些在网上看到的一些大佬的经历,然后我就做出了规划:去研究源码和算法,参与一些开源,当工作经验够3年之后,去尝试投递一下大厂,看一看新的机会。可是现在,当经验,技能可以达到要求之后,市场却凉下来了,不是92的学历或者大厂的履历,连简历筛都很难过的去,小一点的公司也想用较低的工资去招一个经验丰富的人,然后面试就还会问对加班的看法,甚至有的还会问无效加班接不接受,感觉整个市场都是一个让人无法理解的样子。
最后
上面聊了这么多,不管怎样,也确实是当前武汉前端求职环境的现状(大佬当然无所畏惧),所以,如果有朋友还跃跃欲试想换个环境,那我建议也是,如果可以的话找好再走,不要着急(但这个问题的点就在于,很多公司会要求线下面试,就算线上面试,时间安排其实也会很不方便),可以投递一下先试试水,感受一下市场。但如果是有自己的规划或者实在是想要换个环境的朋友,可以根据我上面说的,只要能做好心理预期(可能会连续打招呼两三天,甚至一周都没有回应),确保自己的心态稳定(因为这本来就不是个人的问题,我们能做的就是把所掌握的技术准备充分就可以了),其实也可以一试,机会是有的,但是不多,需要自己去争取,并把握住。
最后的最后,关于起这个标题,其实是我在一开始写这篇文章的时候脑海中就浮现的宫崎骏的这个电影和这句话。。。关于这个电影,网上有很多的评价,有的人会觉得这个电影不知道到底想说些什么,教会我们些什么。那有没有可能,老爷子其实也没打算教我们什么,当下的环境已经塞给我们太多东西,可以单纯的感受一下宫崎骏为我们创造的奇幻世界也是挺好的~ 你想活出怎样的人生其实都没有问题,或奋斗,或躺平,或去大城市,或留在小城市都只是一个选择,一种体验而已,没什么对错之分。所以这句话是在问掘友,也是在问我自己吧~
后续的个人规划,其实我也还没有很明确,现阶段,打算先继续搞一搞自己感兴趣的技术吧,不管环境怎样,个人的状态怎样,只要是在向前的,我想总归是好的吧,后续也会继续输出一些有意思的内容,掘友们共勉~
来源:juejin.cn/post/7376177615441117238
移动前端混合开发技术演进之路
本文是azuo和萌妹俩技术创作之旅的第15篇原创文章,内容创作@azuo😄,精神支持@大头萌妹😂
前言:本文主要探讨了移动混合开发( Hybrid APP) 开发的技术演进历程,将阐述了webview(H5)、React Native、小程序技术等在其中所扮演的关键角色及带来的变革。原生能力缺失、长时间白屏、用户操作响应不及时等web开发的问题是如何被解决的?
一、诞生背景
早期移动应用开发,由于机器硬件性能的方面影响,为了更好的用户体验(操作响应、流畅度和原生的能力),主要集中在原生应用开发上。
1.1 原生开发的缺点
原生应用开发周期和更新周期长,也逐渐在快速的迭代的互联网产品产生矛盾。
缺点:
- 开发周期长:开发调试需要编译打包,动辄就需要几分钟甚至十几分钟,相比H5的亚秒级别的热更能力,是在太长了;
- 更新周期长:正常的发版需要用户手动更新,无法做到H5这种发布即更新的效率。
- 使用前需要安装;
- 需要多端开发;(Android和iOS两端开发人力成本高)
1.2 web开发的缺点
原生应用的研发效率问题,也逐渐在快速的迭代的互联网产品产生矛盾。这时候,开发人就自然而然的想到web技术能力,快速开发和发版生效和跨平台能力。
web技术开发的H5界面,相比原生应用,缺点也很明显:
- 缺少系统的提供原生能力;
- 页面白屏时间长(原生基本可以做到1秒内,h5普遍在2秒以上);
- 用户操作响应不及时(动画卡、点击没有反应);
把Native开发和web开发的优缺点整合一下,就诞生了Hybrid App。Hybrid App技术从诞生到现在一直在解决这3个问题。
二、 提供原生能力
JSBridge技术是由 Hybrid 鼻祖框架phoneGap带到开发者的视野中,解决了第一个问题。它通过webview桥接(JSBridge)的方式层解决web开发能力不足的问题,让web页面可以用系统提供原生能力。
2.1 技术原理
Android原生开发提供了各种view控件(类比Dom元素:div、canvas、iframe),其中就用一个webview(类比iframe)。JSBridge 就像其名称中的『Bridge』的意义一样,是 Native 和非 Native 之间的桥梁,它的核心是 构建 Native 和非 Native 间消息通信的通道,而且是 双向通信的通道。
双向通信的通道:
- JS 向 Native 发送消息 : 调用相关功能、通知 Native 当前 JS 的相关状态等。
- Native 向 JS 发送消息 : 回溯调用结果、消息推送、通知 JS 当前 Native 的状态等。
2.2 实现细节
Android可以通过webview将一些原生的Java方法注入到window上供Javascript调用。Javascript也可以直接在window上挂着全局对象给webview执行。
2.2.1 JavaScript 调用 Native
Android 可以采用下面的方式:
public class JSBridgeActivity extends Activity{
private WebView Wv;
@Override
publicvoidonCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
Wv = (WebView)findViewById(R.id.webView);
Wv.getSettings().setJavaScriptEnabled(true);
// 4.2 使用 @JavascriptInterface
Wv.addJavascriptInterface(new JavaScriptInterface(this), "nativeBridge");
// TODO 显示 WebView
}
}
public class JavaScriptInterface{
@JavascriptInterface
public void postMessage(String webMessage){
// Native 逻辑
}
}
前端调用方式:
// android会在window上注入nativeBridge对象
window.nativeBridge.postMessage(message);
native层除了上述方式被Javascript调用,还有可以拦截alert、confirm、console的日志输出、请求URL(伪协议)等方式,来的获取到Javascript调用native的意图。
2.2.2 Native 调用 JavaScript
相比于 JavaScript 调用 Native, Native 调用 JavaScript 较为简单, WebView 组件,都以子组件的形式存在于 View/Activity 中,直接调用相应的 API 即可(类比浏览器的window中的原生方法)。
// android 4.4之前
webView.loadUrl("javascript:"+javascriptString)
// android 4.4之后
webView.evaluateJavascript(
javaScriptString, // js表达式
new ValueCallback<String>() { // 表达式的值通过回调给native
@Override
public void onReceiveValue(String value){
// 鉴权拦截,一般估计页面域名白名单的方式
JSONObject json = new JSONObject(value)
switch(json.bridgeName){
// 处理
}
}
}
);
2.3 JSBridge 接口
JSBridge 技术是对JavaScript 和 Native之间的封装成JS SDK方便前端JS调用,主要功能有两个:调用 Native和 接收Native 被调。
(function () {
var id = 0,
callbacks = {};
window.JSBridge = {
// 调用 Native
invoke: function(bridgeName, callback, data) {
// 判断环境,获取不同的 nativeBridge
var thisId = id ++; // 获取唯一 id
callbacks[thisId] = callback; // 存储 Callback
nativeBridge.postMessage(JSON.stringify{
bridgeName: bridgeName,
data: data || {},
callbackId: thisId // 传到 Native 端
});
},
receiveMessage: function(msg) {
var bridgeName = msg.bridgeName,
data = msg.data || {},
callbackId = msg.callbackId; // Native 将 callbackId 原封不动传回
// 具体逻辑
// bridgeName 和 callbackId 不会同时存在
if (callbackId) {
if (callbacks[callbackId]) { // 找到相应句柄
callbacks[callbackId](msg.data); // 执行调用
}
} elseif (bridgeName) {
}
}
};
})();
JSBridge通过建立一个通信桥梁,使得JavaScript和原生代码可以相互调用,实现高效的数据传输和交互。这个过程是跨线程异步调用的,数据传输一般会经过两次序列化(还有提升的空间)
三、解决白屏
3.1 白屏产生的原因
原生APP安装后启动页面,在正常情况是不用再从网络获取资源,只需要请求后端接口获取数据就可以完成渲染了,网页不需要安装才,每次打开web页面都会从远程服务加载资源后,再请求后端数据后才能渲染。在用户等待资源加载过程和浏览器渲染未完成中,就会出现白屏。造成白屏的主要原因 -- 资源网络加载;
3.2 离线包技术
离线包主要是识别特定url地址(通常是url参数=离线批次id,即:_bid=1221)后保存到用户手机硬盘。用户下次打开H5页面就可以不用走网络请求。离线包一包也会提供预下载能力,保证首次打开H5页面也可以获得收益。
离线包是完整的资源分发系统,需要一个完整的技术团队来建设和维护的。
3.2.1 离线包分发过程
分发流程中主要涉及4种角色:
- 离线配置平台:配置平台可以提供离线配置能力、离线包管理(上传、禁用、清空)、离线包使用统计、离线包准入审核(自动(包大小限制)+人工(解决特殊case))
- 离线配置服务: 配置服务主要提供服务层能力,实现离线配置服务,离线包更新服务,离线资源长传下载服务、离线资源使用统计服务
- 离线SDK: 端内接入离线SDK,SDK主要与离线配置服务进行交互,完成离线资源的管理和接入配置能力
- Native侧 : 实现拦截请求在特定的协议下接入离线资源
3.2.2 离线包加载过程
离线包的加载流程
3.2.3 拦截实现细节
实现WebViewClient: 继承WebViewClient类,并重写shouldInterceptRequest方法。这个方法会在WebView尝试加载一个URL时被调用,你可以在这里检查请求的URL,并决定是否拦截这个请求。
public class MyWebViewClient extends WebViewClient {
private InputStream getOfflineResource(String url) {
// ... 你的实现代码 ...
return null; // 示例返回null,实际中应该返回InputStream
}
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
String url = request.getUrl().toString();
// 检查这个URL是否在你的离线包中
InputStream inputStream = getOfflineResource(url);
if (inputStream != null) {
// 如果在离线包中找到了资源,就返回一个WebResourceResponse对象
return new WebResourceResponse(
"text/html", // MIME类型,这里以HTML为例
"UTF-8", // 编码
inputStream
);
}
// 如果没有在离线包中找到资源,就返回null,让WebView按照默认的方式去加载这个URL
// 走网络请求获取
}
}
// 在你的Activity或Fragment中
WebView webView = findViewById(R.id.webview);
webView.setWebViewClient(new MyWebViewClient());
3.3 服务端渲染(SSR )
在3.1 白屏产生的原因,影响白屏的因素是JS和CSS资源和数据请求。如果,html请求得到的内容中直接包含首屏内容所需要内联的CSS和Dom结构。
SSR通过在服务端(BFF)直接完成有内容的HTML组装。webview获取到html内容就可以直接渲染。减少白屏时间和不可交互时间。
3.3.1 增量更新和并行请求
SSR将本来一个简单框架HTML,增加了首屏内容所需要的完整CSS和Dom内容。这样的话,HTML请求的包体积就增大了多。其中:
- 跟版本相关的样式文件CSS (变更频率低)
- 跟用户信息相关的Dom内容(变更频率高)
HTML根据内容变更频率进行页面分割如下:
<!DOCTYPE html>
<html lang="en">
<head>
<title>OPPO用户体验评价</title>
<meta charset="UTF-8">
<script content="head">window._time = Date.now()</script>
<meta name="renderer" content="webkit|chrome">
<meta name="format-detection" content="telephone=no" />
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="x5-orientation" content="portrait">
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-COMPATIBLE" content="IE=Edge,chrome=1">
<meta name="nightmode" content="disable">
<meta name="color-scheme" content="light">
<!-- css内联内容开始 -->
<style>
/*http://www.xxx.com/wj-prod/style.css*/
/**
* 替换url的css内容,内容比较多
*/
</style>
<!-- css内联内容结束 -->
</head>
<body>
<!-- dom内容开始 -->
<div id="app">
<!-- 拼接好的html结果 -->
<div>
<span></span>
</div>
</div>
<!-- dom内容结束 -->
<!-- 数据内容开始 -->
<script content="page-data">
// 直出的数据,方便vue、react等框架回填状态,声明式UI才必须
window.syncData = {/**服务端获取的数据**/}
</script>
<!-- 数据内容结束 -->
<script crossorigin="anonymous" src="//cdn.xxx.com/wj-prod/client.bundle.js?_t=1"></script>
</body>
</html>
客户端和BFF层大概工作流程如下:
手机QQ将这套方案开源了:github.com/Tencent/Vas… (我曾经也是这套方案的参与者和使用者)
3.4 总结
为了更快的渲染出页面,发展了离线包技术、服务器端渲染(SSR)、Webview启动并行等一系列的技术方案,这些技术可以单个使用,也可以组合使用。
- 对于首次加载的页面,使用服务器端渲染(SSR)和Webview启动并行,是可以很好的解决白屏问题,适用H5活动页面。
- 对于二次加载的页面,使用离线包技术、服务器端渲染(SSR)和Webview启动并行,可以在不经过网络请求也可以展示页面,适用固定入口客户端页面;
四、解决卡顿
使用过程发现H5网页相比于原生页面,更容卡顿,甚至造成页面卡死的问题。这个章节就主要解决为啥浏览器渲染的H5会比原生卡?Hybrid开发用哪些技术如何解决这个问题?
4.1 浏览器渲染的慢
浏览器技术的发展历程已有超过30年的历史,Chrome内核有超过2400万行代码,有很重的历史包袱。
4.1.2 渲染流程
浏览器渲染页面使用了多线程的架构,发生卡顿的主要原因在:渲染线程和JS引擎线程,他两是互斥的,Javascript长时间执行会导致渲染线程无法工作。
GUI渲染线程(GUI Thread):
- 负责渲染浏览器界面。
- 解析HTML、CSS,构建DOM树和CSS规则树,并合成渲染树。
- 布局(Layout)和渲染(Paint)页面内容。
- 与JS引擎线程互斥,当JS引擎线程执行时GUI渲染线程被挂起,GUI更新会被保存在一个队列中,等JS引擎空闲时立即执行。
JS引擎线程(JS Engine Thread):
- 也称为JS内核(在Chrome中为V8)。
- 负责解析和执行JavaScript代码。
- 单线程设计,JS运行过长会阻塞GUI渲染。
事件触发线程(Event Dispatch Thread):
- 用于控制事件循环。
- 当事件(如点击、鼠标移动等)被触发时,该线程会将事件放到对应的事件队列中,等待JS引擎线程处理。
合成器线程(Compositor Thread)和光栅线程(Raster Thread):
- 这两个线程在渲染器进程中运行,以高效流畅地渲染页面。
- 合成器线程负责将不同的图层组合成最终用户看到的页面。
- 光栅线程则负责将图层内容转换为位图,以便在屏幕上显示。
以用户点击操作为例:
如果界面的刷新帧率是60帧,在不掉帧的情况。执行时间只有 1000 ms / 60 = 16.66 ms。上图中间的JS引擎线程和渲染线程的执行是串行,而且不能超过16.66 ms。(留给JS引擎和渲染线程执行的时间本身不多,60帧只有有16ms,120帧只有8ms)这就是浏览器为啥比原生渲染卡。
4.2 声明式UI
浏览器渲染慢的主要原因是JS引擎线程和渲染进程的执行互斥, 那么,最简单解决方式就是将渲染线程改造按照帧率来调度,不再等JS引擎线程全部执行完再去渲染。但是,由于浏览器最初涉及的JS引擎线程是为了应对命令式UI渲染方案,命令式UI对界面的修改是不可预测。
4.2.1 命令式UI
命令式UI关注于如何达到某个特定的用户界面状态,通过编写具体的操作指令来直接操纵界面元素。关注于操作步骤和过程,需要编写具体的代码来实现每个步骤。
// dom找到需要变更的节点
const list = document.querySelector('#content')
// 修改样式
list.style.display = 'none'
// 增加内容
list.innerHTML += `<div class="item">列表内容</div>`
优点: 是入门简单,讲究一个精确控制和直接操作。
缺点: 直接操作界面,带来对UI界面渲染的不可以预测性;
4.2.1 声明式UI
声明式UI(Declarative UI)是一种用户界面编程范式,它关注于描述UI的期望状态,而不是直接编写用于改变UI的命令。在声明式UI中,开发者通过声明性的方式定义UI的结构、样式和行为,而具体的渲染和更新工作则由框架或库自动完成。
声明式UI编程范式:
function List(people) {
const listItems = people.map(person =>
<li key={person.id}>
<img
src={getImageUrl(person)}
alt={person.name}
/>
<p>
<b>{person.name}</b>
{' ' + person.profession + ' '}
known for {person.accomplishment}
</p>
</li>
);
return <ul>{listItems}</ul>;
}
优点: 入门难度有所增加,代码更加简洁,带来更高和可维护性,可以直接根据数据预测UI更新 。
缺点: 入门难度有所增加,灵活性没有命令式UI高;
4.2.1 虚拟DOM
声明式UI强调数据驱动UI更新,一般声明式UI框架中,都还会引入虚拟DOM技术。虚拟DOM(Virtual DOM)是一种在前端开发中广泛使用的技术,它通过JavaScript对象来模拟真实的DOM结构,从而优化Web应用程序的性能和渲染效率。
- 核心思想:将页面的状态抽象为JavaScript对象表示,避免直接操作真实的DOM,从而提高性能和渲染效率。
- 工作流程:
- 初始渲染:首先,通过JavaScript对象(虚拟DOM)表示整个页面的结构。这个虚拟DOM是一个轻量级的映射,保存着真实DOM的层次结构和信息。
- 更新状态:当应用程序的状态发生变化时,如用户交互或数据更新,虚拟DOM会被修改。这个过程操作的是内存中的JavaScript对象,而不是直接操作真实的DOM。
- 生成新的虚拟DOM:状态变化后,会生成一个新的虚拟DOM,反映更新后的状态。
- 对比和更新:通过算法(如Diff算法)将新的虚拟DOM与旧的虚拟DOM进行对比,找出它们之间的差异。
- 生成变更操作:根据对比结果,找出需要更新的部分,并生成相应的DOM操作(如添加、删除、修改节点等)。
- 应用变更:将生成的DOM操作应用到真实的DOM上,只更新需要变更的部分,而不是整个页面重新渲染。
以virtual-dom为例,虚拟Dom的渲染流程大致如下:
import h from 'virtual-dom/h'
import diff from 'virtual-dom/diff'
import patch from 'virtual-dom/patch'
// 第一步:定义渲染函数,UI = F( state)中的f,
// 开发人员编写渲染模版(react对于是jsx,vue对应的template),由构建工具生成;
function render(count) {
return h('text', { attributes: { count } }, [String(count)])
}
// 第二步:初始化vtree
let tree = render(count) // We need an initial tree
// UI变更
setTimeout(function () {
// 第三步:更新state,重新生成vtree
count++
const newTree = render(count)
// 第四步:对比新旧vtree的差异
const patches = diff(tree, newTree)
console.info('patches', patches)
// 第五步:增量更新dom
// patch(rootNode, patches)
tree = newTree
}, 1000)
相比于命令式UI的开发,声明式UI和虚拟DOM技术结合后,UI渲染过程表示用简单的数据结构就可以表述(第四步骤得到结果序列化),能序列化的好处就是可以很简单完成跨线程处理。
4.3 React Native
声明式UI和虚拟DOM是由React带到开发的视野中。虚拟DOM除了提供声明式UI的高性能渲染能力,它还有一个强大的能力--抽象能力。
4.3.1 组件抽象
在开发者的代码与实际的渲染之间加入一个抽象层,这就可以带来很多可能性。对于React Native 渲染实现:
- 在IOS平台中则调用Objective-C 的API 去渲染iOS 组件;
- 在Android平台则调用Java API 去渲染Android 组件,而不是渲染到浏览器DOM 上。
React Native的渲染是使用不同的平台UI Manager 来渲染UI。因此,React Native对UI开发的基础组件进行整合和对应
React Native | Android View | IOS View | Web Dom |
---|---|---|---|
<view> | <ViewGr0up> | <UIView | <div> |
<Text> | <TextView> | <UITextView> | <p> |
<Image> | <ImageView> | <UIImageView> | <img> |
4.3.2 样式渲染
组件结构通过抽象的基础可以完成每个平台的转换。UI界面开发出来结构还需要样式编写。React Native引用了Yoga。Yoga是 C语言写的一个 CSS3/Flexbox 的跨平台 实现的Flexbox布局引擎,意在打造一个跨iOS、Android、Windows平台在内的布局引擎,兼容Flexbox布局方式,让界面布局更加简单。
4.3.3 线程模型
在React Native中,渲染由一个JS线程和原生线程。JS线程负责解析和执行JavaScript代码,而原生线程则负责渲染界面和执行原生操作。JS执行的结果(dom diff)异步通知原生层。
4.3.3 总结
React Native借助虚拟DOM的抽象能力,把逻辑层的JS代码执行单独抽到JS引擎中执行,不再与UI渲染互斥,可以留更多时间给UI渲染线程。
UI渲染相比浏览器渲染性能提升主要在两点:
- JS层不再互斥UI渲染;
- UI渲染由浏览器渲染改成原生渲染;
UI放到Natie层渲染,逻辑放在JS层执行,Natice层与JS层通过JSBridge(24年底会默认替换成JSI,以提高数据通信性能,有兴趣可以去了解)进行通信。
Weex和快应用的实现原理跟React Native类似,主要的差异是在编写声明式UI的DSL,这里就不一一讲解
4.4 微信小程序
微信小程序是从公众号的H5演变而来的。2015年微信对外发布JS-SDK(JS Bridge)提供微信的原生能力(类似早期的phoneGap的),解决了移动网页能力不足的问题。但是,页面加载白屏、网页安全和卡顿问题依旧没被解决。
微信在2017年设计一个全新的系统来解决这些问题,它需要使得所有的开发者都能做到:
- 快速的加载
- 更强大的能力
- 原生的体验
- 易用且安全的微信数据开放
- 高效和简单的开发
4.4.1 双线程架构
有了虚拟DOM这个抽象层,UI界面开发的的逻辑层和视图层可以分离。小程序的渲染层和逻辑层分别由两个线程管理(视图层是 WebView,逻辑层是 JS 引擎
- 视图层主要负责页面的渲染,每一个页面Page View对应一个Webview(不能超过10个页面栈)。
- 逻辑层负责js的执行,一个JS执行的沙箱环境;
微信小程序的双线程有如下主要优点:
- javascript脚本执行不会抢占ui渲染资源,使整体页面渲染更快;
- 每个PageView是由一个webview单独渲染,页面切换效果上更接近原生,比公众号h5网页浏览体验要好;
- 安全管控,独立的沙箱环境运行javascript逻辑代码,避免了浏览器的开放api操作dom、跳转页面等,更加安全。
4.4.2 开发的DSL
小程序包含一个描述整体程序的 app 和多个描述各自页面的 page。
个小程序主体部分由三个文件组成,必须放在项目的根目录,如下:
文件 | 必需 | 作用 |
---|---|---|
app.js | 是 | 小程序逻辑 |
app.json | 是 | 小程序公共配置 |
app.wxss | 否 | 小程序公共样式表 |
一个小程序页面由四个文件组成,分别是:
文件类型 | 必需 | 作用 |
---|---|---|
js | 是 | 页面逻辑 |
wxml | 是 | 页面结构 |
json | 否 | 页面配置 |
wxss | 否 | 页面样式表 |
WXML和WXSS是微信官方创造的DSL,需要进行编译后才能被Webview解析执行。可以从微信开发者工具包文件中找到 wcc 和 wcsc 两个编译工具
- wcc 编译器可以将 wxml 文件编译成 JS 文件
- wcsc 编译器可以将 wxss 文件编译成 JS 文件。
WXML(WeiXin Markup Language)是框架设计的一套标签语言,结合基础组件、事件系统,可以构建出页面的结构。(类比虚拟DOM中的Render函数)
<!--wxml-->
<view>
<text class="text">{{message}}</text>
</view>
将wcc拷贝到当前的index.wxml同级目录, 执行
./wcc -js index.wxml >> wxml.js
将wxml.js的内容复制到浏览器的console中执行后,输入:
$gwx('index.wxml')({
message: 'hello world'
})
可以获得vtree:
{
"tag": "wx-page",
"children": [
{
"tag": "wx-view",
"attr": {},
"children": [
{
"tag": "wx-text",
"attr": {
"class": "text"
},
"children": [
"hello world"
],
"raw": {},
"generics": {}
}
],
"raw": {},
"generics": {}
}
]
}
WXSS (WeiXin Style Sheets)是一套样式语言,用于描述 WXML 的组件样式。(跟CSS类似,增加了rpx相对尺寸,可以参考REM的响应式布局)
page{
display:flex;
background-color: #fff;
}
.wrap{
width:320rpx;
height: 200rpx;
}
.text{
color:red;
font-size:12px
}
将wcsc拷贝到当前的index.wxss同级目录, 执行
./wcsc -js index.wxss >> wxss.js
最后将wxss.js的内容拷贝到浏览器去运行,即可得到:
(page的样式转化成了body,rpx转成px)
4.4.3 逻辑层和渲染层
逻辑层主要执行app.js和每个页面Page构造器。最终将Page中data修改后的结果通过setData同步给渲染进程。
逻辑层是一个沙箱的执行环境,该环境不存在DOM API、window、document等对象API和全局对象。换句话来说,小程序相比传统H5是更加安全。小程序中访问用户相关信息是不能像H5直接调用浏览器API,需要经过用户授权才或者由用户操作触发才可以被调用。
小程序的渲染层是在webview执行的,主要将运行wxml和wxss编译后的代码;
- wxss文件编译成js,之后后会往head中插入style样式
- wxml编译成声明式UI的render函数,接受逻辑层的data来更新vtree,dom diff ,增量更新dom
render函数中的data由逻辑层调用setData跨线程传给渲染层, 渲染层相比传统的浏览器渲染页面少了渲染前的data生成。相比React Native,渲染层仍然会执行JS(主要虚拟Dom更新)。
逻辑层和渲染层的在不同平台的实现方式:
运行环境 | 逻辑层 | 渲染层 |
---|---|---|
iOS | JavaScriptCore | WKWebView |
Android | V8 | XWeb(腾讯自研,基于Mobile Chrome内核) |
PC | Chrome内核 | Chrome内核 |
小程序开发工具 | NW.js | Chrome WebView |
4.4.4 Skyline渲染引擎
小程序早期的渲染层是使用webview,每个PageView对一个webview,内存开销是很多。
Skyline渲染引擎其实可以被看作一个被优化后的webview,并在其内置了更加优秀的动画系统、跨线程传说方案
微信增加了渲染引擎 Skyline,其使用更精简高效的渲染管线,并带来诸多增强特性,让 Skyline 拥有更接近原生渲染的性能体验。
Skyline 创建了一条渲染线程来负责 Layout, Composite 和 Paint 等渲染任务,并在 AppService 中划出一个独立的上下文,来运行之前 WebView 承担的 JS 逻辑、DOM 树创建等逻辑。这种新的架构相比原有的 WebView 架构,有以下特点:
- 界面更不容易被逻辑阻塞,进一步减少卡顿
- 无需为每个页面新建一个 JS 引擎实例(WebView),减少了内存、时间开销
- 框架可以在页面之间共享更多的资源,进一步减少运行时内存、时间开销
- 框架的代码之间无需再通过 JSBridge 进行数据交换,减少了大量通信时间开销
Skyline 的首屏时间比 WebView 快 66%
Skyline 的内存占用比 WebView 减少 50%
详细可以参考:developers.weixin.qq.com/miniprogram…
4.4.5 总结
微信小程序采用双线程的架构方案,即解决web困扰已久的安全问题,而且也在一定程度上优化了页面渲染性能。虚拟DOM的抽象能力,使得PageView可以是WebView、React-Native-Like、Flutter 等来渲染
微信小程序也有类似离线包的技术,将用户访问的小程序缓存在微信APP的安装目录中,来解决页面白屏问题。首次加载白屏问题通过native层loading页面来遮盖,因此,小程序首次使用也会有2到3秒的加载过程(小程序分包要求,加载包不能超过2M,加载时间可以做到可控😄)。。
4.5 总结
React Native、Weex、微信小程序、快应用等技术,提供了一整套开发完备的技术和工具来实现混合开发。包括不限于:
- 平台提供基础UI组件为基础;
- 声明式UI作为首选,虚拟DOM的抽象能力,UI渲染框架可以多层级多语言实现;
- 双线程和JSBridge(JSI),使得JS逻辑执行和UI渲染分离;
- 完整工具类,编译、打包、HMR;
- 分包,一个应用可以由多个模块包组成;
- 亚秒级别的热更新能力;
后面出现的Flutter、ArkUI框架也基本围绕这些技术理念进行整合(当然还有编译技术的优化JIT向AOT,带来更快的启动速度)。
(Flutter、ArkTS带来更快的启动速度的技术方案后面再补到文章内吧)
五、发展历程
混合开发的发展史是一段技术革新和演进的过程,它标志着移动应用开发从单一平台向跨平台、高效率的方向转变。
- JSBridge让JavaScript拥有原生能力,JSI等技术让JavaScript直面C++,带来更加高效的传输速度;
- 离线包技术,兼顾加载和留存,SRR仍是很有效优化首屏速度的手段;
- 分包技术是提高加载速度和开发效率;
- 声明式U开发范式,加上虚拟Dom抽象能力,解偶上层开发与底层渲染框架,新的渲染框架不断涌现;
- JSCore引擎的双线程架构,打破逻辑层和UI层间的互斥,即解决Web困扰已久的安全问题,也缓解浏览器渲染性能问题;
来源:juejin.cn/post/7382051737362284559
扫码出入库与web worker
我为什么会用到这个呢,那还得从最近项目的一个扫码出入库的需求说起,之前客户的扫码出入库都是c端的,在效率方面没有明显的问题,但是后面这个项目的升级,就把c端的扫码部分摞到了B端了
大体需求是这样,客户用无线扫码枪扫回运单上的条码,然后扫码枪使用HID键盘模式(扫码枪相当于一个键盘),在一个一直聚焦的输入框输入扫到的条码,然后我这边监听到条码调接口录入库,成功后再语音播报扫码结果,同时刷新结果,刷新统计信息。
听上去很简单是不是,想象是美好的,可现实就残酷了,在初始版的时候,功能是做出来了,本地出入库都没问题,但是发到生产就悲催了,乱七八糟的问题
比如
- 1.扫码枪精度的问题,扫码识别率低下,扫10次才能正确识别1次
- 2.扫出来的码,断码,原本以为扫码枪扫一次就等同于我复制一个条码进输入框,可结果是扫码枪一次扫入,输入框接受的条码就像一个字符串流一样,一个字符一个字符进入的,这就导致中间间隔稍微长一点,就被错误的识别为另外一个条码(扫码是多个码连续扫入的)
- 3.语音播报延迟,经常会有语音播放不出或者播放一半,这个..
这个就很让人无语,明明本地啥问题也没有
第一个问题,扫码枪精度,确实是有,因为我做的时候拿的扫码枪是一个有线的扫码枪,那识别率才叫一个高,准确率差不多95%,几乎没遇到解码啥的问题,可换成无线的扫码枪就傻眼,第一个问题就很烦,想到几千个客户没办法统一更换扫码枪,于是就想想优化一下条码编码呢,
我这边条码是用的jsBarcode组件,默认的编码类型CODE128,嗯~~问题会不会出在条码规范上呢
我去查了一下,条码的编码规范大致有以下几种
条码类型 | 类别 | 描述 | 常见应用 | 编码长度 |
---|---|---|---|---|
UPC-A | 1D | 通用产品代码,常见于零售业 | 零售商品 | 12位数字 |
UPC-E | 1D | UPC-A的压缩版本 | 小型零售商品 | 6位数字 |
EAN-13 | 1D | 欧洲商品编号,国际通用 | 图书、零售商品 | 13位数字 |
EAN-8 | 1D | EAN-13的压缩版本 | 小型商品 | 8位数字 |
Code 39 | 1D | 可变长度,包含字母、数字和特殊字符 | 工业、政府 | 可变长度 |
Code 128 | 1D | 高密度条码,表示所有128个ASCII字符 | 物流、运输 | 可变长度 |
Interleaved 2 of 5 (ITF) | 1D | 数字条码,每两个数字组成一对交错编码 | 分销、仓储 | 偶数位数字 |
QR Code | 2D | 可存储大量数据,包括文字、数字、二进制数据和汉字 | 支付、信息分享、广告 | 可变长度 |
Data Matrix | 2D | 高密度编码,适用于小型物品标识 | 电子元器件、医疗设备 | 可变长度 |
PDF417 | 2D | 可编码大量数据 | 身-份-正件、运输标签 | 可变长度 |
Aztec Code | 2D | 高容错性,适用于票务和登机牌 | 票务、登机牌 | 可变长度 |
我这里着重说说CODE39和CODE128;我发现CODE39生成的条码比CODE128生成的长很多,我这把无线扫码枪扫很久都扫不出来,识别超慢,这个很奇怪,之前客户C端系统找技术查了一下,编码规范是CODE39,我就懵逼了,都是CODE39,为啥我们生成的码就识别这么慢,捣鼓了很久也没个结果,如果有哪位知道的可以给我说一下,就索性放弃这种编码模式,改用CODE128吧,查了一下,这是一种效率更高的编码方式,CODE39条码较长的主要原因在于它的编码效率较低,每个字符占用的空间较大,而CODE128通过更加紧凑和高效的编码方式,能够在同样的内容下生成更短的条码,于是撺掇同事把所有的条码都用CODE128生成,至此,扫码枪识别效率低的问题算事过去了
然后就是第二点,扫出来的码,断码问题,这个也因为换了短码好那么一点,可扫出来也经常有解码内容变长,的问题,暂时还在想办法优化
最后就是语音播报延迟,卡壳,甚至没有语音的情况,这个问题比较恼火,我这边组件是使用的开源库howler.js,这个库的优点就是兼容性好,可以播放包括mp3, opus, ogg, wav, aac, m4a, m4b, mp4, webm, 等多种格式,而且还支持分轨sprite播放,这个是我的最初的代码
import config from "./config";
import "./lib/howler.min";
const ENV = import.meta.env;
class VoiceReport {
public list = [];
constructor() {
this.initVoice();
}
// 目录放在@/assets/voice/ 下面
public voiceList: any = import.meta.globEager("@/assets/voice/*.mp3");
public voiceNameList = Object.keys(this.voiceList);
// 初始化语音播报器列表
initVoice = () => {
config.forEach((v) => {
const item = {
name: `Ref${v.codeType}${v.codeKey}`,
code: v.codeKey,
codeName: v.codeName,
voice: "",
};
const voiceIndex = this.voiceNameList.findIndex((voice) =>
String(voice).includes(v.codeKey)
);
if (voiceIndex > -1) {
item.path = this.voiceNameList[voiceIndex];
item.voice = this.voiceList[this.voiceNameList[voiceIndex]];
}
this.list.push(item);
});
};
// 播放
play = (code: string) => {
const Stream = this.list.find((v) => v.code == code);
let StreamVoide = null;
if (ENV?.DEV) {
StreamVoide = Stream?.path;
} else {
StreamVoide = Stream?.voice?.default;
}
// 提供的条码不在列表中
if (!StreamVoide) return;
try {
const sound = new Howl({
src: [StreamVoide],
volume: 1.0,
html5: true,
onplayerror: (e) => {
console.log("error", e);
},
});
sound.play();
} catch (e) {
console.log(e);
}
};
}
export default VoiceReport;
这个倒是能放,可能不能优化呢
我首先想到的是就从播放器本身优化呢,我想着会不会是加载的延迟或者加载文件过多,想着将所有的文件进行合并,再生成sprite信息,弄是弄了,可是不论如何就是load报错,我再把这个多个mp3合并成一个文件@/assets/voice/fullStack.mp3,进行生成sprite,来加载,加载是加载上来了,可同样遇到播放错误,播放的track根本不是我期望的那个
这个是错误代码:
import config from "./config";
import "./lib/howler.min";
import fullVoice from "@/assets/voice/fullStack.mp3";
const ENV = import.meta.env;
class player {
public list: any = [];
public player: any = {};
constructor() {
this.initVoice();
}
// 目录放在@/assets/voice/ 下面
public voiceList: any = import.meta.globEager("@/assets/voice/*.mp3");
public fullVoice: any = fullVoice;
public voiceNameList = Object.keys(this.voiceList);
public sprite: any = {};
public streamVoide: any = [];
// 时间戳转换为秒
timeStringToSeconds = (timeStr: string) => {
const parts = timeStr.split(":");
const hours = parseInt(parts[0]);
const minutes = parseInt(parts[1]);
const seconds = parseInt(parts[2]);
return hours * 3600 + minutes * 60 + seconds;
}
// 初始化语音播报器列表
initVoice = () => {
config.forEach((v, index) => {
const item = {
name: `Ref${v.codeType}${v.codeKey}`,
code: v.codeKey,
codeName: v.codeName,
voice: {},
path: "",
duration: this.timeStringToSeconds(v.duration ?? 0) * 1000,
durationStart: 0,
durationEnd: 0,
};
item.durationStart = !index ? 0 : this.list[index - 1].durationEnd;
item.durationEnd = item.durationStart + item.duration;
this.sprite[v.codeKey] = [item.durationStart, item.durationEnd];
const voiceIndex = this.voiceNameList.findIndex((voice) =>
String(voice).includes(v.codeKey)
);
if (voiceIndex > -1) {
item.path = this.voiceNameList[voiceIndex];
item.voice = this.voiceList[this.voiceNameList[voiceIndex]];
}
this.list.push(item);
/* eslint-disable */
// @ts-ignore
this.streamVoide.push(ENV?.DEV ? item.path : item.voice?.default);
});
/* eslint-disable */
// @ts-ignore
this.player = new Howl({
src: this.streamVoide,
volume: 1.0,
html5: true,
sprite: this.sprite,
onplayerror: (e: any) => {
console.log("play error", e);
},
onload: (e: any) => {
console.log("error", e);
}
});
window.player = this.player;
console.log(this.sprite, this.player, fullVoice)
};
// 播放
play = (code: string) => {
try {
this.player.play(code);
} catch (e) {
console.log(e);
}
};
}
export default player;
到现在还在持续找解决方案中,
最后,不得不把希望寄托在异步任务请求导致阻塞主线程这个猜想上,因为每完成一次扫码,会发起三个请求
- 入库请求
- 刷新结果列表请求
- 刷新统计请求
这么多请求一起,接口稍微一慢就有可能导致播放卡顿的问题
这个在我经过一段时间的搜索之后发现,发现webworker可以处理这个问题
web worker
根据MDN的说法
Web Workers 是 Web 内容在后台线程中运行脚本的一种简单方法。工作线程可以在不干扰用户界面的情况下执行任务。此外,他们还可以使用 fetch() 或 XMLHttpRequest API 发出网络请求。创建后,工作人员可以通过将消息发布到该代码指定的事件处理程序来向创建它的 JavaScript 代码发送消息(反之亦然)。
既然是独立于主线程之外的一个,那就不可避免的会遇到身份验证和通信的问题,对于发起的请求没有携带身份信息,这个好办,就自己在封装一个axios方法fetch,将身份信息传过去ok,这里主要贴一下worker的内容,也很简单
import type { WorkerMessageDataType } from "../types/types";
import fetch from "@/utils/fetch";
import { throttle } from "lodash";
let Ajax: any = null;
// 从主线程接受数据
self.onmessage = function (e: WorkerMessageDataType) {
console.log("Worker: 收到请求", e);
const type = e.data?.type || "";
const data = e.data?.data || {};
// 一定要初始化
if (type == "init") {
const headers: any = e.data?.headers;
Ajax = fetch(headers);
}
// 请求刷新统计数据
if (type == "refreshScanCountData") refreshScanCountData();
// 请求刷新列表扫码结果
if (type == "refreshDataList") refreshDataList();
// 请求入库
if (type == "checkAddIntoStock") checkAddIntoStock(data);
};
// 向主线程发送数据
const sedData = (type: string, data: object) => {
const param = {
type,
data: data || {},
};
self.postMessage(param);
};
// 刷新统计数据,查询统计信息api
const refreshScanCountData = throttle(() => {
Ajax({
method: "post",
url: `/api/CountStatistics`,
data: {},
}).then((res: any) => {
sedData("refreshScanCountData", res);
});
}, 500);
// 刷新扫码结果数据
const refreshDataList = throttle(() => {
Ajax({
method: "post",
url: `/api/scanToStorage/page`,
data: {},
}).then((res: any) => {
sedData("refreshDataList", res);
});
}, 500);
// 请求入库
const checkAddIntoStock = (data: { barcode: string; [x: string]: any }) => {
Ajax({
method: "post",
url: `/api/scanToStorage`,
data,
})
.then((res: any) => {
// 刷新统计数据
refreshScanCountData();
// 刷新列表
refreshDataList();
sedData("checkAddIntoStock", {
barcode: data.barcode,
...res,
status: true,
});
})
.catch(() => {
sedData("checkAddIntoStock", {
barcode: data.barcode,
status: false,
});
});
};
在主线程页面写一个方法,初始化一下这个worker
// 加载worker
const initWorker = () => {
const headers = {
Authorization: "bearer " + sessionStorage.getItem("token"),
token: sessionStorage.getItem("token"),
currRoleId: sessionStorage.getItem("roleId"),
};
// 初始化,加入身份信息
WebWorker.postMessage({ type: "init", headers });
// 从worker接受消息
WebWorker.onmessage = (e) => {
console.log("Main script: Received result", e.data);
const type = e.data?.type || "";
const data = e.data?.data || {};
// 异步更新统计信息
if (type == "refreshScanCountData") {
ScanCountData.value = data;
}
// 刷新表格数据
if (type == "refreshDataList") {
dataTable.value.updateData(data);
}
};
};
这样就可以了,即便是这样,依然还有好多问题没解决,这个是我的第一篇文章,难免有错误疏漏,这个需求并没结束,我还会持续跟进更新的
来源:juejin.cn/post/7380342160581492747
用空闲时间做了一个小程序-二维码生成器
一直在摸鱼中赚钱的大家好呀~
先向各位鱼友们汇报一下情况,目前小程序已经有900+的鱼友注册使用过。虽然每天都有新的鱼友注册,但是鱼友增长的还很缓慢。自从国庆前的文字转语音的工具上线到现在已经将近有1个月没有更新小程序了。但是今天终终终终终于又有个小工具上线了,希望这个小工具可以帮助到更多的鱼友们(没错就是你们)
。
这次更新的工具是一个二维码生成器,虽然很多小程序存在这个工具,但是本人也是想尝试一下实现这个工具。老规矩,先来看下知名UI设计师设计的页面。
同样在工具tab页中增加了二维码生成器模块。从UI图中可以看出第一个表单页面不是很难,就是一个文本框、两个颜色选择、一个图片上传。这个页面我在开发中也是很快就完成了,没有什么技术含量。
当我做到颜色选择弹窗的时候是想从网上找一个现成的插件。但是找了半天没有找到合适的,只能自己手动开发一个。既然要做颜色选择器的功能就要先了解一下颜色的两种格式 (我这边的实现就这两种格式)。
颜色的HEX格式
颜色的HEX
格式是#
+六位数字/字母
,其中六位数字/字母
是一种十六进制的表达方式。这六位分别两个一组,从左到右分别表示红
、绿
、蓝
。00
表示最小,十进制是0
;FF
表示最大,十进制是255
。通俗点讲,某个颜色的数值越大,包含这个颜色就越多。如:#000000
-黑色、#FFFFFF
-白色、#FF0000
-红色、#00FF00
-绿色、#0000FF
-蓝色。
颜色的RGB格式
颜色的RGB
格式是rgb(0-255,0-255,0-255)
, 其中0-255
就是HEX格式的十进制表达方式。这三个数值从左到右分别表示红
、绿
、蓝
。0
表示最小;255
表示最大。通俗点讲,某个颜色的数值越大,包含这个颜色就越多。如:rgb(0,0,0)
-黑色、rgb(255,255,255)
-白色、rgb(255,0,0)
-红色、rgb(0,255,0)
-绿色、rgb(0,0,255)
-蓝色。
有了上面的概念,我的思路也就出来了。让用户分别选择这三种颜色的数值,然后通过用户选择的三种颜色的数值转成目标颜色,就可以完成颜色选择的功能。思路出来了之后就告知了UI,然后按照我的思路将效果图出了出来 (没错,就是先实现后出图)。实现中主要使用了vant-ui
组件库的popup
和slider
两个组件 (聪明人都喜欢用现成的)。贴一下部分实现代码:
show="{{ show }}"
title="展示弹出层"
position="bottom"
bind:close="cancelHandle"
custom-style="background-color: #F3F3F9;border-radius: 40rpx 40rpx 0rpx 0rpx;"
root-portal>
class="color-popup">
class="popup-header flex flex_j_c--space-between flex_a_i--center">
class="flex-item_f-1">
class="title flex-item_f-1">{{ title }}
class="flex-item_f-1 flex flex_j_c--flex-end">
name="cross" size="32rpx" bind:tap="cancelHandle" />
class="color-picker" wx:for="{{ pickers }}" wx:key="index" wx:if="{{ index !== 3 }}">
class="color-picker-label">{{ item.label }}
class="flex flex_a_i--center">
class="slider-wrap flex-item_f-1 {{ item.field }}">
value="{{ item.value }}" min="{{ 0 }}" max="{{ 255 }}" data-index="{{ index }}" bind:change="changeHandle" bind:drag="changeHandle" custom-class="slider" bar-height="60rpx" active-color="transparent" use-button-slot>
class="slider-button" slot="button">
class="slider-value">{{ item.value }}
class="color-preview-box flex flex_a_i--center">
class="preview-box-wrap">
class="preview-box" style="background-color: {{ rgbaStyle }};">
class="preview-label">颜色预览
class="presets-box-wrap flex-item_f-1 flex flex_j_c--space-between">
class="presets-box flex flex_j_c--center flex_a_i--center {{ rgbaStyle === item.rgbaStyle ? 'active' : '' }}" wx:for="{{ presets }}" wx:key="index" style="background-color: {{ item.rgbaStyle }};" data-row="{{ item }}" bind:tap="chooseHandle">
class="active-box">
class="confirm-wrap flex">
class="hex-box flex flex_a_i--center flex_j_c--space-between">
#
{{ hex }}
class="confirm-button-box flex-item_f-1">
type="primary" custom-class="confirm-button" bind:click="confirmHandle" round>确定
import { rgb2Hex } from '../../utils/util'
const presets = [
[0, 0, 0, 255], [102, 102, 102, 255],
[0, 95, 244, 255], [100, 196, 102, 255],
[247, 206, 70, 255], [235, 77, 61, 255],
]
Component({
options: {
addGlobalClass: true
},
properties: {
show: {
type: Boolean,
value: false
},
title: {
type: String,
value: ''
},
value: {
type: Array,
value: [0, 0, 0, 255],
observer: function(val) {
const { pickers } = this.data
if(val.length) {
this.setData({
pickers: pickers.map((item, index) => {
return {...item, value: val[index]}
}),
})
this.setColor(val)
} else {
this.setData({
pickers: pickers.map((item, index) => {
return {...item, value: index === 3 ? 255 : 0}
}),
})
const rgba = [0, 0, 0, 255]
this.setColor(rgba)
}
}
}
},
data: {
pickers: [
{ field: 'r', label: '红色', value: 0 },
{ field: 'g', label: '绿色', value: 0 },
{ field: 'b', label: '蓝色', value: 0 },
{ field: 'a', label: '透明度', value: 255 },
],
rgba: [],
hex: '',
rgbaStyle: '',
presets: [
...presets.map(rgba => {
return {
rgba,
rgbaStyle: `rgba(${ rgba.join(',') })`
}
})
]
},
methods: {
changeHandle(e) {
const { detail, currentTarget: { dataset: { index } } } = e
const key = `pickers[${ index }].value`
this.setData({
[key]: typeof detail === 'object' ? detail.value : detail
})
const rgba = this.data.pickers.map(item => item.value)
this.setColor(rgba)
},
chooseHandle(e) {
const { rgba } = e.currentTarget.dataset.row
this.setData({
pickers: this.data.pickers.map((item, index) => {
return {...item, value: rgba[index]}
}),
})
this.setColor(rgba)
},
// 设置颜色
setColor(rgba) {
const hex = rgb2Hex(...rgba)
const rgbaStyle = `rgba(${ rgba.join(',') })`
this.setData({ rgba, hex: hex.replace('#', ''), rgbaStyle })
},
confirmHandle(e) {
this.triggerEvent('confirm', { rgba: this.data.rgba, rgbaStyle: this.data.rgbaStyle })
},
cancelHandle() {
this.triggerEvent('cancel')
},
}
})
到此颜色选择器的组件已经实现了,还剩下一个预览下载的页面。我这边的实现并不是直接页面跳转,因为这边预览之后返回是希望还保留预览之前的数据的。如果直接离开当前页面并清除了数据,不符合用户预期的。所以使用了一个假页。微信小程序提供了一个 page-container 的页面容器,效果类似于 popup
弹出层,页面内存在该容器时,当用户进行返回操作,关闭该容器不关闭页面。
如果二维码中含中文的静态码使用微信扫描后是无法正常展示内容的(后期安排上二维码解析的功能)
感谢大家观看我今日的水文,文笔实在是不行,欢迎鱼友们给小程序提提意见,或者有什么有趣的想法也可以与楼主提一提。最后希望大家到我的小程序来多坐坐。
来源:juejin.cn/post/7384350475736989731
多级校验、工作流,这样写代码才足够优雅!
责任链模式,简而言之,就是将多个操作组装成一条链路进行处理。
请求在链路上传递,链路上的每一个节点就是一个处理器,每个处理器都可以对请求进行处理,或者传递给链路上的下一个处理器处理。
责任链模式的应用场景,在实际工作中,通常有如下两种应用场景。
- 操作需要经过一系列的校验,通过校验后才执行某些操作。
- 工作流。企业中通常会制定很多工作流程,一级一级的去处理任务。
下面通过两个案例来学习一下责任链模式。
案例一:创建商品多级校验场景
以创建商品为例,假设商品创建逻辑分为以下三步完成:
①创建商品、
②校验商品参数、
③保存商品。
第②步校验商品又分为多种情况的校验,必填字段校验、规格校验、价格校验、库存校验等等。
这些检验逻辑像一个流水线,要想创建出一个商品,必须通过这些校验。如下流程图所示:
图片
伪代码如下:
创建商品步骤,需要经过一系列的参数校验,如果参数校验失败,直接返回失败的结果;通过所有的参数校验后,最终保存商品信息。
图片
如上代码看起来似乎没什么问题,它非常工整,而且代码逻辑很清晰。
PS:我没有把所有的校验代码都罗列在一个方法里,那样更能产生对比性,但我觉得抽象并分离单一职责的函数应该是每个程序员最基本的规范!
但是随着业务需求不断地叠加,相关的校验逻辑也越来越多,新的功能使代码越来越臃肿,可维护性较差。
更糟糕的是,这些校验组件不可复用,当你有其他需求也需要用到一些校验时,你又变成了Ctrl+C , Ctrl+V程序员,系统的维护成本也越来越高。如下图所示:
图片
伪代码同上,这里就不赘述了。
终于有一天,你忍无可忍了,决定重构这段代码。
使用责任链模式优化:创建商品的每个校验步骤都可以作为一个单独的处理器,抽离为一个单独的类,便于复用。
这些处理器形成一条链式调用,请求在处理器链上传递,如果校验条件不通过,则处理器不再向下传递请求,直接返回错误信息;若所有的处理器都通过检验,则执行保存商品步骤。
图片
案例一实战:责任链模式实现创建商品校验
UML图:一览众山小
图片
AbstractCheckHandler表示处理器抽象类,负责抽象处理器行为。其有3个子类,分别是:
- NullValueCheckHandler:空值校验处理器
- PriceCheckHandler:价格校验处理
- StockCheckHandler:库存校验处理器
AbstractCheckHandler 抽象类中, handle()
定义了处理器的抽象方法,其子类需要重写handle()
方法以实现特殊的处理器校验逻辑;
protected ProductCheckHandlerConfig config 是处理器的动态配置类,使用protected声明,每个子类处理器都持有该对象。
该对象用于声明当前处理器、以及当前处理器的下一个处理器nextHandler,另外也可以配置一些特殊属性,比如说接口降级配置、超时时间配置等。
AbstractCheckHandler nextHandler 是当前处理器持有的下一个处理器的引用,当前处理器执行完毕时,便调用nextHandler执行下一处理器的handle()校验方法;
protected Result next()
是抽象类中定义的,执行下一个处理器的方法,使用protected声明,每个子类处理器都持有该对象。
当子类处理器执行完毕(通过)时,调用父类的方法执行下一个处理器nextHandler。
HandlerClient 是执行处理器链路的客户端,HandlerClient.executeChain()
方法负责发起整个链路调用,并接收处理器链路的返回值。
商品参数对象:保存商品的入参
ProductVO是创建商品的参数对象,包含商品的基础信息。
并且其作为责任链模式中多个处理器的入参,多个处理器都以ProductVO为入参进行特定的逻辑处理。
实际业务中,商品对象特别复杂。咱们化繁为简,简化商品参数如下:
/**
* 商品对象
*/
@Data
@Builder
public class ProductVO {
/**
* 商品SKU,唯一
*/
private Long skuId;
/**
* 商品名称
*/
private String skuName;
/**
* 商品图片路径
*/
private String Path;
/**
* 价格
*/
private BigDecimal price;
/**
* 库存
*/
private Integer stock;
}
抽象类处理器:抽象行为,子类共有属性、方法
AbstractCheckHandler:处理器抽象类,并使用@Component注解注册为由Spring管理的Bean对象,这样做的好处是,我们可以轻松的使用Spring来管理这些处理器Bean。
/**
* 抽象类处理器
*/
@Component
public abstract class AbstractCheckHandler {
/**
* 当前处理器持有下一个处理器的引用
*/
@Getter
@Setter
protected AbstractCheckHandler nextHandler;
/**
* 处理器配置
*/
@Setter
@Getter
protected ProductCheckHandlerConfig config;
/**
* 处理器执行方法
* @param param
* @return
*/
public abstract Result handle(ProductVO param);
/**
* 链路传递
* @param param
* @return
*/
protected Result next(ProductVO param) {
//下一个链路没有处理器了,直接返回
if (Objects.isNull(nextHandler)) {
return Result.success();
}
//执行下一个处理器
return nextHandler.handle(param);
}
}
在AbstractCheckHandler抽象类处理器中,使用protected声明子类可见的属性和方法。
使用 @Component注解,声明其为Spring的Bean对象,这样做的好处是可以利用Spring轻松管理所有的子类,下面会看到如何使用。
抽象类的属性和方法说明如下:
- public abstract Result handle():表示抽象的校验方法,每个处理器都应该继承AbstractCheckHandler抽象类处理器,并重写其handle方法,各个处理器从而实现特殊的校验逻辑,实际上就是多态的思想。
- protected ProductCheckHandlerConfig config:表示每个处理器的动态配置类,可以通过“配置中心”动态修改该配置,实现处理器的“动态编排”和“顺序控制”。配置类中可以配置处理器的名称、下一个处理器、以及处理器是否降级等属性。
- protected AbstractCheckHandler nextHandler:表示当前处理器持有下一个处理器的引用,如果当前处理器handle()校验方法执行完毕,则执行下一个处理器nextHandler的handle()校验方法执行校验逻辑。
- protected Result next(ProductVO param):此方法用于处理器链路传递,子类处理器执行完毕后,调用父类的next()方法执行在config 配置的链路上的下一个处理器,如果所有处理器都执行完毕了,就返回结果了。
ProductCheckHandlerConfig配置类 :
/**
* 处理器配置类
*/
@AllArgsConstructor
@Data
public class ProductCheckHandlerConfig {
/**
* 处理器Bean名称
*/
private String handler;
/**
* 下一个处理器
*/
private ProductCheckHandlerConfig next;
/**
* 是否降级
*/
private Boolean down = Boolean.FALSE;
}
子类处理器:处理特有的校验逻辑
AbstractCheckHandler抽象类处理器有3个子类分别是:
- NullValueCheckHandler:空值校验处理器
- PriceCheckHandler:价格校验处理
- StockCheckHandler:库存校验处理器
各个处理器继承AbstractCheckHandler抽象类处理器,并重写其handle()处理方法以实现特有的校验逻辑。
NullValueCheckHandler:空值校验处理器。针对性校验创建商品中必填的参数。如果校验未通过,则返回错误码ErrorCode,责任链在此截断(停止),创建商品返回被校验住的错误信息。注意代码中的降级配置!
super.getConfig().getDown()
是获取AbstractCheckHandler处理器对象中保存的配置信息,如果处理器配置了降级,则跳过该处理器,调用super.next()
执行下一个处理器逻辑。
同样,使用@Component注册为由Spring管理的Bean对象,
/**
* 空值校验处理器
*/
@Component
public class NullValueCheckHandler extends AbstractCheckHandler{
@Override
public Result handle(ProductVO param) {
System.out.println("空值校验 Handler 开始...");
//降级:如果配置了降级,则跳过此处理器,执行下一个处理器
if (super.getConfig().getDown()) {
System.out.println("空值校验 Handler 已降级,跳过空值校验 Handler...");
return super.next(param);
}
//参数必填校验
if (Objects.isNull(param)) {
return Result.failure(ErrorCode.PARAM_NULL_ERROR);
}
//SkuId商品主键参数必填校验
if (Objects.isNull(param.getSkuId())) {
return Result.failure(ErrorCode.PARAM_SKU_NULL_ERROR);
}
//Price价格参数必填校验
if (Objects.isNull(param.getPrice())) {
return Result.failure(ErrorCode.PARAM_PRICE_NULL_ERROR);
}
//Stock库存参数必填校验
if (Objects.isNull(param.getStock())) {
return Result.failure(ErrorCode.PARAM_STOCK_NULL_ERROR);
}
System.out.println("空值校验 Handler 通过...");
//执行下一个处理器
return super.next(param);
}
}
PriceCheckHandler:价格校验处理。
针对创建商品的价格参数进行校验。这里只是做了简单的判断价格>0的校验,实际业务中比较复杂,比如“价格门”这些防范措施等。
/**
* 价格校验处理器
*/
@Component
public class PriceCheckHandler extends AbstractCheckHandler{
@Override
public Result handle(ProductVO param) {
System.out.println("价格校验 Handler 开始...");
//非法价格校验
boolean illegalPrice = param.getPrice().compareTo(BigDecimal.ZERO) <= 0;
if (illegalPrice) {
return Result.failure(ErrorCode.PARAM_PRICE_ILLEGAL_ERROR);
}
//其他校验逻辑...
System.out.println("价格校验 Handler 通过...");
//执行下一个处理器
return super.next(param);
}
}
StockCheckHandler:库存校验处理器。
针对创建商品的库存参数进行校验。
/**
* 库存校验处理器
*/
@Component
public class StockCheckHandler extends AbstractCheckHandler{
@Override
public Result handle(ProductVO param) {
System.out.println("库存校验 Handler 开始...");
//非法库存校验
boolean illegalStock = param.getStock() < 0;
if (illegalStock) {
return Result.failure(ErrorCode.PARAM_STOCK_ILLEGAL_ERROR);
}
//其他校验逻辑..
System.out.println("库存校验 Handler 通过...");
//执行下一个处理器
return super.next(param);
}
}
客户端:执行处理器链路
HandlerClient客户端类负责发起整个处理器链路的执行,通过executeChain()
方法。
如果处理器链路返回错误信息,即校验未通过,则整个链路截断(停止),返回相应的错误信息。
public class HandlerClient {
public static Result executeChain(AbstractCheckHandler handler, ProductVO param) {
//执行处理器
Result handlerResult = handler.handle(param);
if (!handlerResult.isSuccess()) {
System.out.println("HandlerClient 责任链执行失败返回:" + handlerResult.toString());
return handlerResult;
}
return Result.success();
}
}
以上,责任链模式相关的类已经创建好了。
接下来就可以创建商品了。
创建商品:抽象步骤,化繁为简
createProduct()
创建商品方法抽象为2个步骤:①参数校验、②创建商品。
参数校验使用责任链模式进行校验,包含:空值校验、价格校验、库存校验等等,只有链上的所有处理器均校验通过,才调用saveProduct()
创建商品方法;否则返回校验错误信息。
在createProduct()
创建商品方法中,通过责任链模式,我们将校验逻辑进行解耦。createProduct()
创建商品方法中不需要关注都要经过哪些校验处理器,以及校验处理器的细节。
/**
* 创建商品
* @return
*/
@Test
public Result createProduct(ProductVO param) {
//参数校验,使用责任链模式
Result paramCheckResult = this.paramCheck(param);
if (!paramCheckResult.isSuccess()) {
return paramCheckResult;
}
//创建商品
return this.saveProduct(param);
}
参数校验:责任链模式
参数校验paramCheck()
方法使用责任链模式进行参数校验,方法内没有声明具体都有哪些校验,具体有哪些参数校验逻辑是通过多个处理器链传递的。如下:
/**
* 参数校验:责任链模式
* @param param
* @return
*/
private Result paramCheck(ProductVO param) {
//获取处理器配置:通常配置使用统一配置中心存储,支持动态变更
ProductCheckHandlerConfig handlerConfig = this.getHandlerConfigFile();
//获取处理器
AbstractCheckHandler handler = this.getHandler(handlerConfig);
//责任链:执行处理器链路
Result executeChainResult = HandlerClient.executeChain(handler, param);
if (!executeChainResult.isSuccess()) {
System.out.println("创建商品 失败...");
return executeChainResult;
}
//处理器链路全部成功
return Result.success();
}
paramCheck()
方法步骤说明如下:
👉 步骤1:获取处理器配置。
通过getHandlerConfigFile()
方法获取处理器配置类对象,配置类保存了链上各个处理器的上下级节点配置,支持流程编排、动态扩展。
通常配置是通过Ducc(京东自研的配置中心)、Nacos(阿里开源的配置中心)等配置中心存储的,支持动态变更、实时生效。
基于此,我们便可以实现校验处理器的编排、以及动态扩展了。
我这里没有使用配置中心存储处理器链路的配置,而是使用JSON串的形式去模拟配置,大家感兴趣的可以自行实现。
/**
* 获取处理器配置:通常配置使用统一配置中心存储,支持动态变更
* @return
*/
private ProductCheckHandlerConfig getHandlerConfigFile() {
//配置中心存储的配置
String configJson = "{"handler":"nullValueCheckHandler","down":true,"next":{"handler":"priceCheckHandler","next":{"handler":"stockCheckHandler","next":null}}}";
//转成Config对象
ProductCheckHandlerConfig handlerConfig = JSON.parseObject(configJson, ProductCheckHandlerConfig.class);
return handlerConfig;
}
ConfigJson存储的处理器链路配置JSON串,在代码中可能不便于观看,我们可以使用json.cn等格式化看一下,如下,配置的整个调用链路规则特别清晰。
图片
getHandlerConfigFile()
类获到配置类的结构如下,可以看到,就是把在配置中心储存的配置规则,转换成配置类ProductCheckHandlerConfig
对象,用于程序处理。
注意,此时配置类中存储的仅仅是处理器Spring Bean的name而已,并非实际处理器对象。
图片
接下来,通过配置类获取实际要执行的处理器。
👉 步骤2:根据配置获取处理器。
上面步骤1通过getHandlerConfigFile()
方法获取到处理器链路配置规则后,再调用getHandler()
获取处理器。
getHandler()
参数是如上ConfigJson配置的规则,即步骤1转换成的ProductCheckHandlerConfig
对象;
根据ProductCheckHandlerConfig
配置规则转换成处理器链路对象。代码如下:
* 使用Spring注入:所有继承了AbstractCheckHandler抽象类的Spring Bean都会注入进来。Map的Key对应Bean的name,Value是name对应相应的Bean
*/
@Resource
private Map handlerMap;
/**
* 获取处理器
* @param config
* @return
*/
private AbstractCheckHandler getHandler (ProductCheckHandlerConfig config) {
//配置检查:没有配置处理器链路,则不执行校验逻辑
if (Objects.isNull(config)) {
return null;
}
//配置错误
String handler = config.getHandler();
if (StringUtils.isBlank(handler)) {
return null;
}
//配置了不存在的处理器
AbstractCheckHandler abstractCheckHandler = handlerMap.get(config.getHandler());
if (Objects.isNull(abstractCheckHandler)) {
return null;
}
//处理器设置配置Config
abstractCheckHandler.setConfig(config);
//递归设置链路处理器
abstractCheckHandler.setNextHandler(this.getHandler(config.getNext()));
return abstractCheckHandler;
}
👉 👉 步骤2-1:配置检查。
代码14~27行,进行了配置的一些检查操作。如果配置错误,则获取不到对应的处理器。代码23行handlerMap.get(config.getHandler())
是从所有处理器映射Map中获取到对应的处理器Spring Bean。
注意第5行代码,handlerMap存储了所有的处理器映射,是通过Spring @Resource注解注入进来的。注入的规则是:所有继承了AbstractCheckHandler抽象类(它是Spring管理的Bean)的子类(子类也是Spring管理的Bean)都会注入进来。
注入进来的handlerMap中 Map的Key对应Bean的name,Value是name对应的Bean实例,也就是实际的处理器,这里指空值校验处理器、价格校验处理器、库存校验处理器。如下:
图片
这样根据配置ConfigJson(👉 步骤1:获取处理器配置)中handler:"priceCheckHandler"
的配置,使用handlerMap.get(config.getHandler())
便可以获取到对应的处理器Spring Bean对象了。
👉 👉 步骤2-2:保存处理器规则。
代码29行,将配置规则保存到对应的处理器中abstractCheckHandler.setConfig(config)
,子类处理器就持有了配置的规则。
👉 👉 步骤2-3:递归设置处理器链路。
代码32行,递归设置链路上的处理器。
//递归设置链路处理器 abstractCheckHandler.setNextHandler(this.getHandler(config.getNext()));
这一步可能不太好理解,结合ConfigJson配置的规则来看,似乎就很很容易理解了。
图片
由上而下,NullValueCheckHandler
空值校验处理器通过setNextHandler()
方法设置自己持有的下一节点的处理器,也就是价格处理器PriceCheckHandler。
接着,PriceCheckHandler价格处理器,同样需要经过步骤2-1配置检查、步骤2-2保存配置规则,并且最重要的是,它也需要设置下一节点的处理器StockCheckHandler库存校验处理器。
StockCheckHandler库存校验处理器也一样,同样需要经过步骤2-1配置检查、步骤2-2保存配置规则,但请注意StockCheckHandler的配置,它的next规则配置了null,这表示它下面没有任何处理器要执行了,它就是整个链路上的最后一个处理节点。
通过递归调用getHandler()
获取处理器方法,就将整个处理器链路对象串联起来了。如下:
图片
友情提示:递归虽香,但使用递归一定要注意截断递归的条件处理,否则可能造成死循环哦!
实际上,getHandler()
获取处理器对象的代码就是把在配置中心配置的规则ConfigJson,转换成配置类ProductCheckHandlerConfig
对象,再根据配置类对象,转换成实际的处理器对象,这个处理器对象持有整个链路的调用顺序。
👉 步骤3:客户端执行调用链路。
public class HandlerClient {
public static Result executeChain(AbstractCheckHandler handler, ProductVO param) {
//执行处理器
Result handlerResult = handler.handle(param);
if (!handlerResult.isSuccess()) {
System.out.println("HandlerClient 责任链执行失败返回:" + handlerResult.toString());
return handlerResult;
}
return Result.success();
}
}
getHandler()获取完处理器后,整个调用链路的执行顺序也就确定了,此时,客户端该干活了!
HandlerClient.executeChain(handler, param)
方法是HandlerClient客户端类执行处理器整个调用链路的,并接收处理器链路的返回值。
executeChain()
通过AbstractCheckHandler.handle()
触发整个链路处理器顺序执行,如果某个处理器校验没有通过!handlerResult.isSuccess()
,则返回错误信息;所有处理器都校验通过,则返回正确信息Result.success()
。
总结:串联方法调用流程
基于以上,再通过流程图来回顾一下整个调用流程。
图片
测试:代码执行结果
场景1:创建商品参数中有空值(如下skuId参数为null),链路被空值处理器截断,返回错误信息
//创建商品参数
ProductVO param = ProductVO.builder()
.skuId(null).skuName("华为手机").Path("http://...")
.price(new BigDecimal(1))
.stock(1)
.build();
测试结果
图片
场景2:创建商品价格参数异常(如下price参数),被价格处理器截断,返回错误信息
ProductVO param = ProductVO.builder()
.skuId(1L).skuName("华为手机").Path("http://...")
.price(new BigDecimal(-999))
.stock(1)
.build();
测试结果
图片
场景 3:创建商品库存参数异常(如下stock参数),被库存处理器截断,返回错误信息。
//创建商品参数,模拟用户传入
ProductVO param = ProductVO.builder()
.skuId(1L).skuName("华为手机").Path("http://...")
.price(new BigDecimal(1))
.stock(-999)
.build();
测试结果
图片
场景4:创建商品所有处理器校验通过,保存商品。
![15](C:\Users\18796\Desktop\文章\15.png)![15](C:\Users\18796\Desktop\文章\15.png)![15](C:\Users\18796\Desktop\文章\15.png)![15](C:\Users\18796\Desktop\文章\15.png)//创建商品参数,模拟用户传入
ProductVO param = ProductVO.builder()
.skuId(1L).skuName("华为手机").Path("http://...")
.price(new BigDecimal(999))
.stock(1).build();
测试结果
责任链的优缺点
来源:juejin.cn/post/7384632888321179659
dockerhub国内镜像站集体下线?别慌,教你丝滑拉取镜像~
前言
想必大家都听说了,国内镜像站几乎都用不了,对于开发者来说,无疑是个不好的消息。在docker pull时直接超时失败,拉取不下来镜像。那么有没有什么办法解决呢?有!还不止一种。
- 通过docker配置文件配置可用的国内镜像源
- 设置代理
- 自建镜像仓库
方法1已经不太好使了,能找到可用的不多,有的还存在没有最新的镜像问题。
方法2可行,不过得要有科学上网的工具,再会一点配置代理的知识,操作起来稍稍复杂。
本文主要介绍第三种方法,上手快,简单,关键还0成本!
准备工作
- 登录阿里云,找到容器镜像服务,创建一个个人版实例。(第一次使用的话,会让设置访问密码。记住,后面会用)
- 找到仓库管理-命名空间,新建一个命名空间且设置为公开
3.不要创建镜像仓库,回到访问凭证
可以看到,如下2个信息,一个是你的阿里云用户名,一个是你的仓库地址(后面有用)
sudo docker login --username=阿里云用户名 registry.cn-beijing.aliyuncs.com
github配置
- fork项目,地址: docker_image_pusher
(感谢tech-shrimp提供的工具)
- 在fork后的项目中通过Settings-Secret and variables-Actions-New Repository secret路径,配置4个环境变量
- ALIYUN_NAME_SPACE-命名空间
- ALIYUN_REGISTRY_USER-阿里云用户名
- ALIYUN_REGISTRY_PASSWORD-访问密码
- ALIYUN_REGISTRY-仓库地址
3.配置要拉取的镜像 打开项目images.txt,每一行配置一个镜像,格式:name:tag 比如
提交修改的文件,则会自动在Actions
中创建一个workflow。等待片刻即可(1分钟左右)
5.回到阿里云容器镜像服务控制台-镜像仓库
可以看到镜像已成功拉取并同步到你自己的仓库中。
测试效果
我自己操作了下把nginx的镜像给拉了过来,找台服务器测试一下速度
哈哈!这速度杠杠的吧! 用这个方式的好处是,借助github的action机制,直接从dockerhub上拉取任何你想要的镜像,也不用担心国内镜像站版本更新不及时的问题。再从自建的仓库中pull下来就可以啦! 如果有小伙伴没捣鼓成功的,可以留言给我。
来源:juejin.cn/post/7384623060199473171
微信小程序全新渲染引擎Skyline(入门篇)
前言
最近看小程序文档的时候发现了 swiper 组件新增了 Skyline 特有的属性,直接使用竟然没有效果。
不信邪的我打算来研究研究究竟什么是 Skyline!经过一系列文档阅读与实践,长时间闭门造车的我打开了新世界的大门,我惊讶的发现 Skyline 引擎很可能是微信小程序未来发展的重点方向,有着更类似原生的交互体验,新增的特性让人连连称叹,特以此文来总结性地介绍一下 Skyline。
双线程模型
了解 Skyline 之前,我们有必要重新复习一下什么是小程序的双线程模型。
如官方文档所言,小程序的运行环境分成渲染层和逻辑层,其中 WXML 模板和 WXSS 样式工作在渲染层,JS 脚本工作在逻辑层。小程序的渲染层和逻辑层分别由2个线程管理:
- 渲染层的界面使用了WebView 进行渲染,一个小程序存在多个界面,所以渲染层存在多个WebView线程;
- 逻辑层采用JsCore线程运行JS脚本。
这两个线程的通信会经由微信客户端(原生) 做中转,逻辑层发送网络请求也经由微信客户端 (原生) 转发,有了微信小程序客户端 (原生) 作为媒介系统,使得我们开发者能够专注于数据与逻辑。
如上所述,小程序的通信模型如下图所示。
什么是 Skyline 引擎
前文提到,基于 WebView 和原生控件混合渲染的方式,小程序优化扩展了 Web 的基础能力,所以小程序相对于普通的Web页面有着更为良好的性能与体验。
由于 Web 在移动端的表现与原生应用仍有一定差距,亦或许是 Web 的优化遇到了瓶颈,为了进一步优化小程序性能,微信在 WebView 渲染之外新增了一个渲染引擎,也就是我们本文的重磅主角: Skyline,它使用更精简高效的渲染管线,并带来诸多增强特性,让 Skyline 拥有更接近原生渲染的性能体验。
Skyline 引擎 vs Webview 引擎
我们知道:WebView 的 JS 逻辑、DOM 树创建、CSS 解析、样式计算、Layout、Paint (Composite) 都发生在同一线程,在 WebView 上执行过多的 JS 逻辑可能阻塞渲染,导致界面卡顿,大致流程如下图所示。
但是,在 Skyline 环境下改变了这个情况,它创建了一条渲染线程来负责计算图层布局,图层的绘制以及整合图层页面等渲染任务,并在 AppService 中划出一个独立的上下文,来运行之前 WebView 承担的 JS 逻辑、DOM 树创建等逻辑。
据官方统计数据表明,Skyline 与 WebView 性能相比,具有如下优势:
Skyline 的首屏时间比 WebView 快约 66%
单个页面 Skyline 的占用比 WebView 减少约 35%
单个页面 Skyline 的占用比 WebView 减少 35%,两个页面 Skyline 的内存占用比 WebView 减少 50%,随着打开的页面变多,内存差距越明显。
Skyline 引擎的优点
- 界面更不容易被逻辑阻塞,进一步减少卡顿
- 无需为每个页面新建一个 JS 引擎实例(WebView),减少了内存、时间开销
- 框架可以在页面之间共享更多的资源,进一步减少运行时内存、时间开销
- 框架的代码之间无需再通过 JSBridge 进行数据交换,减少了大量通信时间开销
- 保持和原有架构的兼容性,基于 WebView 环境的小程序代码基本上无需任何改动即可直接在新的架构下运行
更多Skyline的特性更新请详见Skyline 渲染引擎 / 概览 / 特性 | 微信开放文档
Skyline 引擎的缺点
- WXS效率可能有所下降 (WXS 由于被移到 AppService 中,虽然逻辑本身无需改动,但询问页面信息等接口会变为异步,效率也可能有所下降)
但是,也不必过多的担心,微信推出了新的 Worklet 机制,它比原有的 WXS 更靠近渲染流程,用以高性能地构建各种复杂的动画效果。
Skyline 引擎的使用
前文提到,我想使用 swiper 组件新增的 Skyline 特有属性无果,是因为我没有完成 Skyline的配置。如果想要使用 Skyline引擎,我们可以按页面级别来选择性的配置是走 Skyline 引擎或是 Webview 引擎来渲染。
// page.json
{
"renderer": "skyline"
}
// page.json
{
"renderer": "webview"
}
配置完成之后,我们就可以愉快的使用 Skyline 专有的新特性了。
Skyline 引擎的兼容性
我们可能会担心开启了 Skyline 的渲染模式会不会带来兼容性问题。官方表示:
所以我们完全可以放下对兼容性的顾虑,拥抱新的 Skyline 引擎,让大部分的用户优先体验到新一代微信小程序的渲染技术,做第一批吃螃蟹的人!对于我们开发者而言,有必要深入了解一下Skyline引擎的更新带来了哪些开发层面的变化与创新,毕竟,吃螃蟹的人会越来越多嘛。
后记
感谢您的阅读,本文仅为微信小程序 Skyline 引擎的入门介绍篇,后续会持续更新有关 Skyline 引擎相关实际操作及使用的文章,如有兴趣,欢迎持续关注。
来源:juejin.cn/post/7298927261210361882