每天都很煎熬,领导派的活太难,真的想跑路了
人在江湖身不由己,无论是领导的亲信还是团队的边缘,都可能遇到这种情况———不得不干一件特别难以推进的事情,茫然无措,不知如何推进。每天陷入焦虑和自我怀疑中……
这种事情一般有一些共同特点。
- 结果和目标极其模糊。
- 需要协调其他团队干活但是对方很不配合。
- 领导也不知道怎么干
领导往往是拍脑袋提想法,他们也不知道具体如何执行。反过来说,如果领导明确知道怎么做,能亲自指导技术方案、亲自解决关键问题,那问题就好办了,只要跟着领导冲锋陷阵就好了,就不存在烦恼了。
遇到这种棘手的事情,如果自己被夹在中间,真的非常难受啊!
今天重点聊聊领导拍脑袋、心血来潮想做的那些大事 如果让你摊上了,你该怎么做!
1、提高警惕!逆风局翻盘难!
互联网行业目前处于稳定发展期,很少会出现突然迅猛增长的业务,也很少有公司能够迅速崛起。这是整个行业的大背景。因此,我们应该对任何不确定或模糊的目标表示怀疑,因为它们更有可能成为我们的绊脚石,而不是机遇。即使在王者荣耀这样的游戏里,要逆风翻盘也很困难,更何况在工作中呢。
当领导提出一个棘手的问题时,我们应立刻警惕,这可能不是一个好的机会,而是一个陷阱。我们不应该被领导画的饼所迷惑,而是要冷静客观地思考。哪些目标和结果是难以达到的,这些目标和结果就是领导给我们画的大饼!
领导给出任务后,我们就要努力完成。通常情况下,他们会给我们一大堆任务,需要我们确认各种事情。简而言之,他们只是有个想法,而调研报告和具体实施方案就需要我们去做。
如果领导是一位优秀而谦虚的人,通常在我们完成调研后,会根据调研结果来判断这个想法是否可行。如果不可行,他们会立即放弃,而我们也不会有什么损失。
但是,一旦领导有了一个想法,肯定是希望我们来完成的,即便我们在调研后认为不可行,大多数情况下,他们也不会接受我们的结论!因此,我们的调研工作必须极度认真,如果我们认为不可行,就要清楚地阐述不可行的理由,要非常充分。
这是我们第一次逃离的机会,我们必须重视这次机会,并抓住机会。
2、积极想办法退出
对于这种模糊不靠谱的事情,能避开就避开,不要犹豫。因为这种事情往往占用大量时间,但很难取得显著的成果。对于这种时间周期长、收益低、风险高的事情,最好保持距离。
你还需要忍受巨大的机会成本
在你长期投入这种事情的过程中,如果团队接到更好的项目和需求,那肯定不会考虑你。你只能羡慕别人的机会。
因此,如果可以撤退的话,最好离开这种费力不讨好的活远远的!
子曰:吾日三省吾身,这事能不能不干,这事能不能晚点干,这事能不能推给别人干。
如何摆脱这件事呢?
2.1 借助更高优事情插入,及时抽身
例如,突然出现了一件更为紧急的事情,这就是脱身的机会。与此同时,我们也可以为领导保留一些颜面,因为随着工作的进展,领导也会意识到这件事情的意义不大,很难取得实质成果。但是,如果我们一开始就表示不再继续做这件事,那么领导可能会觉得自己的判断出了问题,失去面子。所以,我们可以寻找一个时机,给领导下台阶。
或者,突然出现了一个需求,与我们目前的重构方案存在冲突。这是一个很好的借口。重构方案和未来产品规划产生了冲突,我们应优先满足产品规划和需求。重构方案需要后续再次评估,以找到更好的解决方案。
2.2 自己规划更重要的事情,并说服领导
当你对系统优化没有想法时,不要怪领导给你找事干。
如果领导有一个系统重构的计划和目标需要你执行,但是你不想干,或者你认为这件事不靠谱。那么你可以思考一个更可行、更有效、更能带来收益的重构方案,并与领导进行汇报。如果领导认为你的计划更加重要且更具可行性,那他可能会放弃自己的想法。
这就是主动转被动的策略。这时你的技术能力将接受考验,你能提出一个更优秀的系统重构方向吗?你能提出一个更佳的系统建设方向吗?
2.3 选择更好的时机做这件事
如果领导让你去做技术重构,而这件事的优先级不如产品需求高,上下游团队也不愿意配合你,而且领导给你的人力和时间资源也不够充裕,你应该怎么办呢?可以考虑与产品需求一起进行技术重构。也就是说,边开发需求,边进行技术重构。这样做有以下好处:可以借助于产品的力量,很自然地协调上下游团队与你一同进行重构。同时也能推动测试同事进行更全面的测试。在资源上遇到的问题,也可以让产品帮助解决。
所以,技术重构最好和产品需求结合起来进行。如果技术重构规模庞大,记得一定要分阶段进行,避免因技术重构导致产品需求延期哦。
2.4 坦诚自己能力不足,暂时无法完成这件事,以后再干行不行
可以考虑向领导坦然承认自己的能力还不足以立即执行这项任务,因此提出先缓一缓,先熟悉一下这个系统的建议。我可以多做一些需求,以此来熟悉系统,然后再进行重构。
我曾经接手一个系统,领导分配给我一个非常复杂的技术重构任务。当时我并没有足够聪明,没有拒绝,而是勉强去做,结果非常不理想,还导致了线上P0级别的事故发生!
新领导告诉我,"先想清楚如何实施,再去行动。盲目地勉强上阵只会带来糟糕的结果。当你对一个系统不熟悉的时候,绝对不能尝试对其进行重构。"
先熟悉系统至少三个月到半年。再谈重构系统!
2.5 拖字诀,拖到领导不想干这件事!
拖到领导不想干的时候,就万事大吉了。
注意这是最消极的策略,运气好,拖着拖着就不用干了。但如果运气不佳,拖延只会让任务在时间上更加紧迫,而且还会招致领导的不满。
使用拖延策略很可能得罪领导,给他们留下不良的印象。
因此,在使用此策略时应谨慎行事!
2.6 退出时毫不犹豫,不要惋惜沉默成本
如果有撤退的机会,一定不要犹豫,不要为自己付出的投入感到遗憾,不要勉强继续前进,也不必试图得到明确的结果。错误的决策只会带来错误的结果。一定要及时止损。
因为我曾经犯过类似的错误,本来有机会撤退,但是考虑到已经付出了很多,想要坚持下去。幸好有一位同事更加冷静,及时制止了我。事后我反思,庆幸及时撤退,否则后果真的不敢想象啊。
3、适当焦虑
每个人都喜欢做确定性的事情,面对不确定的事情每个人都会感到焦虑。为此可能你每天都很焦虑,甚至开始对工作和与领导见面感到厌恶。之所以这个事情让你感到不适,是因为它要求你跳出舒适区。
但是,请记住,适度的焦虑是正常的。告诉自己,这并没有什么大不了的。即使做得不好,顶多被领导责备一下而已。不值得让生活充满焦虑,最重要的是保持身心健康和快乐。
当你沉浸在焦虑中时,可能会对工作和领导感到厌烦。这样一来,你可能会对和领导沟通感到反感。这种情况是可怕的,因为你需要不断和领导沟通才能了解他真正的意图。如果失去了沟通,这个事情肯定不会有好的结果。
因此,一定要保持适度的焦虑。
3.1 沟通放在第一位
面对模糊的目标和结果,你需要反复和领导沟通,逐步确认他的意图。或者在沟通中,让领导他自己逐渐确定的自己的意图。在这方面有几个技巧~
3.2 直接去工位找他
如果在线上沟通,领导回复可能慢,可能沟通不通畅。单独约会议沟通,往往领导比较忙,没空参加。所以有问题可以直接去工位找他,随时找他沟通问题。提高效率
3.3 没听懂的话让领导说清楚
平常时候领导没说清楚,无所谓,影响不大。例如普通的产品需求,领导说的不清楚没关系,找产品问清楚就行。
面对目标不明确的项目,领导的意图就十分重要。因为你除了问领导,问其他人没用。领导就是需求的提出方,你不问领导你问谁。 在这种情况下,没听懂的事情必须要多问一嘴。把领导模糊的话问清楚。
不要怕啰嗦,也不要自己瞎揣摩领导的意图。每个人的想法都不同,瞎猜没用。
3.4 放低姿态
如果领导和你说这件事不用干了,你肯定拍手叫好。很多烦恼,领导一句话,就能帮你摆平!
放低姿态就是沟通时候,该叫苦叫苦,该求助就求助,别把自己当成超人,领导提啥要求都不打折扣的行为完全没必要。可以和领导叫叫苦,可以活跃气氛,让领导多给自己点资源,包括人和时间。
说白了,就是和 领导 “撒娇”。这方面女生比较有优势,男生可能拉不下脸。之前的公司,我真见识过,事情太多,干不完,希望领导给加人,但被领导拒绝。 然后她就哭了,最后还真管用!是个女同事。
男孩子想想其他办法撒娇吧。评论区留下你们的办法!
3.5 维护几个和领导的日常话题
平常如果有机会和领导闲聊天,一定不要社交恐惧啊! 闲聊天很能提升双方的信任关系,可以多想想几个话题。例如车、孩子、周末干啥、去哪旅游了等等。
提升了信任关系,容易在工作中和领导更加融洽。说白了就是等你需要帮忙的时候,领导会多卖你人情!
4 积极想替代方案————当领导提的想法不合理时
积极寻求替代方案,不要被领导的思路局限!引导众人朝着正确的方向前进!
不同领导的水平和对技术问题的认知不尽相同,他们注重整体大局,而员工更注重细节。这种差异导致了宏观和微观层面之间存在信息不对称,再加上个人经验、路径依赖导致的个人偏见,使得领导的想法不一定正确,也不一定能够顺利实施。
就我个人的经历来说,领导要求我进行一次技术重构。由于我对这个项目还不够熟悉,所以我完全按照领导的方案去操作,没有怀疑过。事后回顾,发现这个方案过于繁重,其实只需要调整前端接口就能解决问题,但最终我们却对底层数据库存储、业务代码和接口交互方式进行了全面改变。
最终收益并不高,反而导致了一个严重的故障。既没有获得功劳,也没有得到应有的认可。
事后反思,我意识到我不应该盲目按照领导的方案去执行,而是应该怀着质疑和批判的态度去思考他的方案。多寻求几个备选方案,进行横向比较,找到成本最低、实施最简单的方案。
4.1 汇报材料高大上,实现方案短平快
私底下,可以对老板坦诚这件事,就是没什么搞头。但是对外文章要写得高大上!
技术方案要高大上,实现方案要短平快。
面对不确定的目标、面对不好完成的任务,要适当吹牛逼和画饼。汇报文档可以和实现方案有出入。
模糊的目标,往往难以执行和完成,技术方案越复杂,越容易出问题。本来就没什么收益,还引出一堆线上问题,只能当项目失败的背锅侠,得不偿失。
一定要想办法,把实现方案做的简单。这样有3个好处;
- 降低实现难度,减少上线风险。
- 缩短开发周期,尽快摆脱这个项目。
- 把更多的时间放在汇报材料上。代码没人看!!!
程序员一般情况下习惯于实话实说,如果说假话,一定是被人逼得。
不会写文档?# 写文档不用发愁,1000个互联网常用词汇送给你
不会写技术方案?# 不会画图? 17 张图教你写好技术方案!
5、申请专门的团队攻克难关!
例如重构系统涉及到上下游系统,一个人搞不定的!要向领导寻求帮助,让上下游同事一起干这件事。
让熟悉系统的人跟自己一起做,拉更多的人入伙!多个人一起承担重任! 这种组织上的安排,只能由领导出面解决。
假如别的同事经常打扰你,总让你确认这件事,确认那件事,总让你帮忙梳理文档,你愿意配合吗? 每个人都很忙,没人愿意长期给你干活。
让领导帮忙成立重构小组!然后你可以给每个人都分派任务,比自己独自硬扛,成功概率大很多。
虽然重构的目标不明确,但你可以尝试明确每个人的责任,设置短期的里程碑。例如前三天梳理整理资料,每天开早会, push大家干活。(这样很招人恨!没办法,领导卷的)
5.1 寻求合作的最大公约数
重大项目往往需要多个团队同时配合,即便你申请了专门的小组跟进这件事,但是别人可能出工不出力!
他们不配合的原因在于:不光没有收益,付出还很多。成本和收益不对等,人家不愿意很正常。保持平常心!不要带着脾气看待这件事!
略微想一下就明白,既然你觉得这件事风险高、收益低,难道其他人看不出来吗?
作为项目的负责人推动事情更加困难。当别人不配合时,除了把矛盾上升到上层领导外,还有哪些更好的办法呢?
- 平时多和相关同学打好关系。平时奶茶咖啡多送点,吃别人嘴短,到时候求人时候很管事的。
- 调动对方的积极性!例如重构系统需要人家配合,但是这件事对他们又没有收益。可以和他们一起头脑风暴,想一下对方系统可以做哪些重构。当双方一拍即合,各取所需时,才能合作融洽。双赢的合作,才能顺利。
- 多作妥协。上下游系统的交互边界很难划分,如果交互存在争议,可以适当让步,换取对方的积极合作。完成胜于完美!
总之,涉及多个团队合作时,除了依靠上层领导的强硬干预之外,还要想一些合作共赢的方案!
6、争取更多的资源支持
没有完不成的事情,只要资源充裕,任何事情都是有希望的。当你面临棘手的问题时,除了打起12分的精气神,还要多想想和领导申请资源啊!
最重要的包括人力资源、时间资源。如果空口白牙就要人,可能比较困难。
这需要你在调研阶段深入思考,预想到系统的挑战点,把任务细分,越细越好,然后拿着排期表找领导,要人、要时间。
如果人和时间都不给!可以多试几次,软磨硬泡也是好办法!
此外还有别的办法,例如 ”偷工减料"。你可以和领导沟通,方案中哪些内容不重要,是否可以砍掉。”既然你不给人,砍掉不重要的部分,减少工作量,总可以吧"
除此之外,还可以考虑分期做。信用卡可以分期付款,技术重构当然也可以分期优化!
7、能分期就分期
对于技术重构类工作,一定要想办法分期重构,不要一次性只求大而全!
- 越复杂的技术方案越容易出问题!
- 越长的开发周期越容易出问题!
- 越想一次性完成,越容易忙中出错!
分期的好处自不必说,在设计方案时一定要想如何分期完成。
如果对一个系统不熟悉,建议分期方案 先易后难!先做简单的,逐渐地你对系统会有更深入的理解!
如果对一个系统很熟悉,可以考虑先难后易。先把最困难的完成!后面会轻松很多!
但是我还是建议庞大的重构工作,先易后难!先做简单的,拖着拖着,也许就不需要重构了呢!
8、即便没有功劳但是要收获苦劳
当一件事干成很难的时候,要想办法把损失降到最低。一定要想着先保护自己!别逞能!
工作几年的朋友应该知道,不是所有的项目都能成功!甚至大部分项目在商业上是失败的!做不成一件事很正常!
如果一件事很难办成,功劳就不要想了。但是可以赚一份苦劳。
这要求你能把自己的困难说给领导,例如其他团队不配合!你可以一直和领导反馈,并寻求领导的帮助。
日常工作的内容也要有文档留存。工作以周报形式单独和领导汇报!要让领导知道你每周的进展,向领导传递一个事实:“每一周你都努力地在做事,并且也都及时汇报了,日后干不成,可别只怪我一人啊!”
接到一个烫手山芋,处理起来很难~ 斗智斗勇,所以能躲开还是躲开啊!
9、转变观念:放弃责任心,领导关注的内容重点完成
出于责任心的角度,我们可能认为领导提出的方案并不正确,甚至认为领导给自己派的工作完全没有意义。
你可能认为领导的Idea 不切合实际!
出于责任心,你有你的想法,你有你的原则!你认为系统这样重构更适合!但那又怎样,除非你有足够的理由说服领导,否则改变不了什么。
站在更高的位置能看的更远,一般领导都会争取团队利益最大化。虽然看起来不切实际,但是努力拼一拼,也许能给团队带来更大的利益。这可能是领导的想法!说白了,就是领导想让团队多去冲锋陷阵,多把一些不可能变成可能!
和领导保持节奏,领导更关注哪件事,就尽力把这件事做好! 放弃自己所谓的“责任心”。
10、挑战、机遇、风险并存。
在互联网稳定期,各行各业都在内卷,公司内部更是在内卷!
在没有巨大增量的团队和公司里,靠内卷出成绩是很困难的事情。有时候真的很绝望,每一分钟都想躺平 。
像这种目标不明确、执行方案不明确、结果不明确、需要协调其他团队干活的难事越来越多!风险高、低收益的事情谁都不想干!
但是一旦能做成,对于个人也是极大地锻炼。所以大家不要一味地悲观,遇到这种棘手的事情,多和领导沟通,多想想更优的解决方案。也许能走出一条捷径,取得极大的成果~
来源:juejin.cn/post/7290469741867565092
Easy-Es:像mybatis-plus一样,轻松操作ES
0. 引言
es的java客户端不太友好的语法一直饱受诟病,书写一个查询语句可能需要书写一大串的代码,如果能像mybatis--plus一样,支持比较灵活方便的语句生成器那就好了。
于是为elasticsearch而生的ORM框架Easy-Es诞生了,使用及其方便快捷,今天我们就一起来学习easy-es,对比看看原生java-client方便之处在哪儿。
1. Easy-Es简介
Easy-Es是以elasticsearch官方提供的RestHighLevelClient
为基础,而开发的一款针对es的ORM框架,类似于es版的mybatis-plus,可以让开发者无需掌握es复杂的DSL语句,只要会mysql语法即可使用es,快速实现es客户端语法
2. Easy-Es使用
1、引入依赖
<!-- 引入easy-es最新版本的依赖-->
<dependency>
<groupId>org.dromara.easy-es</groupId>
<artifactId>easy-es-boot-starter</artifactId>
<version>2.0.0-beta3</version>
</dependency>
<!-- 排除springboot中内置的es依赖,以防和easy-es中的依赖冲突-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-high-level-client</artifactId>
</exclusion>
<exclusion>
<groupId>org.elasticsearch</groupId>
<artifactId>elasticsearch</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-high-level-client</artifactId>
<version>7.14.0</version>
</dependency>
<dependency>
<groupId>org.elasticsearch</groupId>
<artifactId>elasticsearch</artifactId>
<version>7.14.0</version>
</dependency>
2、添加配置项,这里只配置了几个基本的配置项,更多配置可参考官网文档:easy-es 配置介绍
easy-es:
# es地址、账号密码
address: 192.168.244.11:9200
username: elastic
password: elastic
3、在启动类中添加es mapper文件的扫描路径
@EsMapperScan("com.example.easyesdemo.mapper")
4、创建实体类,通过@IndexName
注解申明索引名称及分片数, @IndexField
注解申明字段名、数据类型、分词器等,更多介绍参考官方文档:essy-es 注解介绍
@IndexName(value = "user_easy_es")
@Data
public class UserEasyEs {
@IndexId(type = IdType.CUSTOMIZE)
private Long id;
private String name;
private Integer age;
private Integer sex;
@IndexField(fieldType = FieldType.TEXT, analyzer = Analyzer.IK_SMART, searchAnalyzer = Analyzer.IK_SMART)
private String address;
@IndexField(fieldType = FieldType.DATE, dateFormat = "yyyy-MM-dd HH:mm:ss||yyyy-MM-dd||epoch_millis")
private Date createTime;
private String createUser;
}
5、创建mapper类,继承BaseEsMapper
类,注意这里的mapper一定要创建到第3步中设置的mapper扫描路径下com.example.easyesdemo.mapper
public interface UserEsMapper extends BaseEsMapper<UserEasyEs> {
}
6、创建controller,书写创建索引、新增、修改、查询的接口
@RestController
@RequestMapping("es")
@AllArgsConstructor
public class UserEsController {
private final UserEsMapper userEsMapper;
/**
* 创建索引
* @return
*/
@GetMapping("create")
public Boolean createIndex(){
return userEsMapper.createIndex();
}
@GetMapping("save")
public Integer save(Long id){
UserEasyEs user = new UserEasyEs();
user.setId(id);
user.setName("用户"+id);
user.setAddress("江苏省无锡市滨湖区");
user.setAge(30);
user.setSex(1);
user.setCreateUser("admin");
user.setCreateTime(new Date());
Long count = userEsMapper.selectCount(EsWrappers.lambdaQuery(UserEasyEs.class).eq(UserEasyEs::getId, id));
if(count > 0){
return userEsMapper.updateById(user);
}else{
return userEsMapper.insert(user);
}
}
@GetMapping("search")
public List<UserEasyEs> search(String name, String address){
List<UserEasyEs> userEasyEs = userEsMapper.selectList(
EsWrappers.lambdaQuery(UserEasyEs.class)
.eq(UserEasyEs::getName, name)
.match(UserEasyEs::getAddress, address)
);
return userEasyEs;
}
}
7、分别调用几个接口
- 创建索引
kibana中查询索引,发现创建成功
- 新增接口
这里新增了4笔
数据新增成功
- 数据查询
如上便是针对easy-es的简单使用,这里的用法都与mp类似,上手相当简单,不用再写那些复杂的DSL语句了
3. 拓展介绍
- 条件构造器
上述演示,我们构造查询条件时,使用了
EsWrappers
来构造条件,用法与mp及其类型,大家根据提示就可以推导出方法如何书写,更详细的使用说明可以查看官方文档:easy-es 条件构造器介绍
- 索引托管
如果想要自动根据创建的es实体类来创建对应的索引,那么只需要调整索引的托管模式为非手动模式即可,因为这里我不需要自动同步数据,所以选择
非平滑模式
easy-es:
global-config:
process_index_mode: not_smoothly
其中三种模式的区别为:
平滑模式:smoothly,索引的创建、数据更新迁移等都由easy-es自动完成
非平滑模式:not_smoothly,索引自动创建,但不会自动迁移数据
手动模式:manual,全部操作由用户手动完成,默认模式
- 数据同步
如果数据源是来自mysql, 那么建议使用canal来进行同步,canal的使用可在我主页搜索。
其次还有DataX, Logstash等同步工具,当然你也可以使用easy-es提供的CRUD接口,来手动同步数据
- 日志打印
通过开启日志,可以在控制台打印执行的DSL语句,更加方便我们在开发阶段进行问题排查
logging:
level:
tracer: trace # 开启trace级别日志,在开发时可以开启此配置,则控制台可以打印es全部请求信息及DSL语句,为了避免重复,开启此项配置后,可以将EE的print-dsl设置为false.
- 聚合查询
easy-es实现的聚合查询,只要是针对gr0up by这类聚合,也就是es中的Terms aggregation
,以及最大值、最小值、平均值、求和,而对于其他类型的聚合,还在不断更新中,但这里大家也需要了解,es的聚合和mysql的聚合完全是不一样的维度和复杂度,es支持非常多的聚合查询,所以其他类型的实现还需要借助RestHighLevelClient
来实现
我们利用easy-es来实现下之前书写的聚合案例
@RestController
@AllArgsConstructor
@RequestMapping("order")
public class OrderEsController {
private final OrderTestEsMapper orderEsMapper;
@GetMapping("search")
public String search(){
SearchResponse search = orderEsMapper.search(EsWrappers.lambdaQuery(OrderTest.class).groupBy(OrderTest::getStatus));
// 包装查询结果
Aggregations aggregations = search.getAggregations();
Terms terms = (Terms)aggregations.asList().get(0);
List<? extends Terms.Bucket> buckets = terms.getBuckets();
HashMap<String,Long> statusRes = new HashMap<>();
buckets.forEach(bucket -> {
statusRes.put(bucket.getKeyAsString(),bucket.getDocCount());
});
System.out.println("---聚合结果---");
System.out.println(statusRes);
return statusRes.toString();
}
}
可以看到实际上的查询语句就一行,而其他的都是对返回结果的封装,因为es本身返回的数据是封装到嵌套的对象中的,所以我们需要对其进行包装
对比原始的查询语句,其易用性上的提升还是很明显的
4. 总结
至此对easy-es的介绍就结束了,可以看到如果是针对es实现CRUD上,easy-es表现出非常好的便捷性,而在复杂的聚合查询中,仍然还有进步空间,目前还需要借助RestHighLevelClient
,但easy-es的出现,为未来提供更好用的ES ORM框架,提供了希望和方向
文中演示代码见:gitee.com/wuhanxue/wu…
来源:juejin.cn/post/7271896547594682428
一次低端机 WebView 白屏的兼容之路
问题
项目:Vite4 + Vue3,APP WebView 项目
页面在 OPPO A5 手机上打不开,页面空白。
最开始是客户端在看,然后发现一个警告,大概也因为没看出什么问题,给到 Web 前端。
相关背景
为了方便描述过程的行为,先做一些相关背景的介绍。知道这些背景才能更好的了解问题的复杂。这些在解决问题的过程中始终是干扰因素,在反复调试试错的过程中才梳理总结出来,这里把它们列出来。
使用测试 App,其中有两个入口,一个是本地调试,这个地址是写在 App 里的,也就是要修改这个地址需要客户端重新出包;一个是项目的测试地址,这个地址测试可以进行配置。
修改客户端,重新出包,是很麻烦的,所以尽量避免。
项目配置了 HTTPS 支持,所以开发地址是 https 开头。但是也能启动 http 的地址。
关于项目支持 HTTPS,可以参考之前写的这篇:juejin.cn/post/732783…。
之所以要支持 HTTPS 是因为 iOS WebView 只支持 HTTPS 的地址。
而安卓 App WebView 却需要 HTTP 打开,原因是安卓 WebView 反馈不支持本地地址 HTTPS 的方式。但是在本机上用 MuMu 模拟器打开 App,是能打开本地 HTTPS 地址的,之前也尝试过给安卓手机安装根证书,但是还是不行,得到以上反馈。
所以我本地开发安卓在电脑 MuMu 上调试,iOS 可以用手机调试,如果要安卓真机本地调试,需要去掉本地 HTTPS 的支持,使用 HTTP 的地址(自然,iOS 本地调试就不能同时进行了)
快速尝试
拿到问题之后,快速进行问题验证,在 OPPO A5 上,进入 APP 中,打开本地调试,用 HTTP 的方式。发现确实白屏,查看了客户端相关的日志,发现一个警告:
[INFO:CONSOLE(9)] "The key "viewport-fit" is not recognized and ignored.", source: xxx
于是修改 viewport-fit,发现并没有区别,这只是一个提示,应该没有影响。
于是采用最简代码法,排除法,用最简单的页面进行测试,看是否能正常打开,确定 WebView 没有问题。直到确定 script type="module"
引入的 main.ts
的代码没有起作用。
于是,基本上确定 Vite 的开发模式在 OPPO A5 WebView 中有问题。那不支持 ESM,就是兼容性问题?
快速查看解决方案,引入官方插件:@vitejs/plugin-legacy。但是怎么测试呢?要验证兼容性是否生效,只能验证打包构建后的代码,而不是通过本地调试进行测试。那只能发布到测试了,但这样岂不是要改一点就要发布一次,这是没办法进行的。但是第一次,还是发布一下看有没有生效。
不出意料,没那么容易解决!测试地址依然白屏。
如何调试
确定如何方便的调试是解决问题的必要条件。
几天后又开始看这个问题。
浏览器是否能打开页面?
首先在 App 中进行调试是比较麻烦的,需求启动 App,那么能否在浏览器中进行测试呢?很遗憾,期间用手机系统浏览器打开测试地址是正常的,后面打开本地地址也是正常的。所以浏览器和 App WebView 是有区别的。
启动本地服务查看构建后的页面
兼容插件只是解决打包后的构建产物,想要看打包后的效果,于是我想到将打包后的文件起一个 Web 服务,这样就可以打开打包后的页面 index.html,而且手机访问同一网络,扫码就可以打开这个页面。
找了 Chrome 插件 Web Server for Chrome,发现已经不能用了- 找了 VS code 插件 Live Server,服务启了,但是有个报错。
- 换用 http-server,启动服务 xxx:8080。正常打开页面,手机也能访问。
那么考虑我们的实际问题,如何在手机调试呢?将本地调试改成本地起的服务 xxx:8080,看 WebView 能否打开,这样每次修改、打包,生成新的打包后文件,刷新 WebView 就可以了。
但是前面说了,找 APP 出包很麻烦,改一个地址要出个包,费时。还有其他的办法吗?如果把本地启的服务端口改成 5173 不就不用改 App 了吗,可以直接用本地调试来进行测试,突然又想到本地调试的地址是 HTTPS,可是启动的本地服务好像没法改成 HTTPS。
通过测试地址增加本地调试入口
又想到 App 中的本地调试入口本应该做成一个公共页面,里面放上很多可能的入口。这样只需要 APP 改一次,之后想要什么入口,可以自己添加。改这个调试入口还是需要 App 改动,还是麻烦。我可以在项目中增加一个页面 debug.html(因为项目是多页面应用),这样我增加页面\增加调试入口,发布一下测试就生效了,在测试入口就能看到,这样更快。于是做了一个公共页面。
Vite preview
而且突然想到根本不需要自己起一个服务,Vite 项目,Vite preview 就是把打包后的页面启动服务。地址是:xxx:4173。
修改测试地址为本地预览
然而,OPPO A5 WebView 本来就是打不开我们的系统,那么 WebView 打不开测试地址自然也就没法打开我的本地预览了。但是,测试地址是可以配置的,所以为了快速调试,让测试配置了我的本地预览地址 xxx:4173。
这样,终于在 APP WebView 中打开了我本地预览的页面。
如何查看 App WebView 的日志
手机连接电脑,adb 日志:
看起来这几个报错是正常的,报错信息也说了:
vite: loading legacy chunks, syntax error above and the same error below should be ignored
但是页面没有加载,不知道 WebView 打开页面和页面加载之间发生了什么。
Vite 兼容插件的原理
这期间,反复详细理解原理,是否是插件的使用不对。
用一句话说就是,Vite 兼容插件让构建打包的产物多了传统版本的 chunk 和对应 ES 语言特性的 polyfill,来支持传统浏览器的运行。兼容版的 chunk 只会在传统浏览器中运行。它是如何做到的呢?
- 通过 script type="module" 中的代码,判断当前浏览器是否是现代浏览器。如果是,设置全局变量:window.__vite_is_modern_browser = true。判断的依据是:
- import.meta.url;
- import("_").catch(() => 1);
- async function* g() { }
- 通过 script type="module",如果是现代浏览器,直接退出;如果不是,加载兼容文件:
- 通过 script type="nomodule",加载兼容 polyfill 文件;
- 通过 script type="nomodule",加载兼容入口文件;
传统浏览器不执行 type="module" 的代码,执行 type="nomodule" 的代码。
现代浏览器执行 type="module" 的代码,不执行 type="nomodule" 的代码。
为什么需要 type="module" 的代码?这里是针对浏览器支持 ESM 却不支持以上 3 个语法的情况,仍然使用兼容模式。
详细可以看参考文章,以及查看打包构建产物。
除了知乎那篇文章,我几乎翻遍了搜到的 vite 兼容 空白 白屏 相关的文章,参考相关的配置。这个插件就是很常规的使用,几乎没有看到有任何特殊的配置或处理。就是生成了兼容的代码,低版本浏览器就能使用而已,似乎没人碰到过我的问题。
尝试解决
前面说了,用手机系统浏览器打开页面,竟然正常。怀疑是不是 WebView 的问题。
WebView 的内核版本
借了几个低端机型,几个安卓 5.x 6.x 的系统,结果手机浏览器都能正常打开。
打印 console.log(navigator.appVersion)
,WebView 中:
5.0 (Linux; Android 8.1.0; PBAT00 Build/OPM1.171019.026; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/62.0.3202.84 Mobile Safari/537.36 uniweb/ma75 uniweb-apk-version/0.6.0 uniweb-script-version/0.6.0 uniweb-channel/netease Unisdk/2.1 NetType/wifi os/android27 ngwebview/4.1 package_name/com.netease.sky udid/944046b939d510b1 webview_orbit/1.2(1)
而手机浏览器版本为 Chrome 90,其他手机有 Chrome70。总之,OPPO A5 WebView 的内核 Chrome 版本较低。
Vite 文档对于构建生产版本浏览器兼容性的介绍:
用于生产环境的构建包会假设目标浏览器支持现代 JavaScript 语法。默认情况下,Vite 的目标是能够 支持原生 ESM script 标签、支持原生 ESM 动态导入 和 import.meta 的浏览器
原生 ESM script 标签的支持:
原生 ESM 动态导入的支持:
import.meta 的支持:
所以,原来 Chrome 62 支持 ESM,但是不支持其他 2 个。通过日志,也可以知道不支持兼容插件尝试的 3 个语法,因为打印了那句警告,来自 module 的代码位置,window.__vite_is_modern_browser 不为 true。
从 Android 4.4 开始,系统 WebView 使用 Chrome 内核。
手机系统浏览器内核和系统 WebView 不一样,手机系统的 WebView 也可能不是安卓默认的。
兼容生效了吗?
但还是不知道低版本的浏览器兼容性是否生效,当前我们只能确定 Chrome 62 的 WebView 中兼容有问题,那是否在浏览器中就正常呢?或者更低的版本兼容是否生效?(毕竟不支持 ESM 的浏览器版本的代码执行又不一样)
target 配置不对?
target 配置的是目标浏览器,针对这些浏览器生成对应的兼容代码,期间我一直调整 target 的配置,使用 .broswerlistrc 文件配置,target 直接配置,参考不同的配置方案,确保包含了 Chrome 62。
又是如何调试?
想要下载安卓 Chrome 62 进行测试,但是搜了一圈也没找到。
后来想到不一定要手机浏览器进行测试,Chrome 也行。这里面有点思维上的转换,之前我测试只能通过 WebView 进行调试,因为浏览器上没有问题。现在确定了是浏览器 Chrome 版本的问题,那么我们还是可以通过 PC 浏览器进行测试。
于是范围更大一些,找到了 Chrome 的所有历史版本,不得不说,Chrome 提供的下载真是太有用了,对于测试兼容性非常有帮助! 而且我所担心的覆盖现有浏览器版本的问题完全不存在,下载之后直接运行。
安装 Chrome 62,打开页面,果然空白。终于在浏览器复现,确定就是兼容的问题,而不是 WebView 的问题。安装安卓版本的 Chrome 62,也同样复现。
下载 win 的 Chrome 62,虽然在 refs 里找到同版本的记录:xxx。但是没找到同版本的下载,不过也都是 62,应该没问题。下打开页面,打开控制台,和在 adb 中看到的报错一样,只是这里是红色的:
安装更低版本的 Chrome,同样复现,说明不支持 ESM 的兼容也出现问题。同时可以看到那句提示没有了:
过程当然也没那么顺利,下载 Chrome 的过程中,Chrome 62 直接可以运行,下载 Chrome 59 却没法打开。于是又下载了 Chrome 55,mini exe 文件,可以直接打开。
报错到底要不要处理?
通过 adb日志可以看到报错,也可以从打包后的代码看到:对于语法报错是可以忽略的,因为那是预期中的行为。可是之后的代码为什么没执行了呢?
回到现在的问题,这个报错不是不需要处理吗?但是加载了兼容的 js,页面却没有渲染元素。此时隐隐觉得报错可能还是要处理,至少可能最后一个报错有点问题?
但是这个报错实在难以查看,之前我把它当作和前两个报错一样的来源。现在只剩这个报错了,问题是这是打包压缩后的代码,完全不知道真正的问题是什么。
通过请教网友,做了一些尝试:
通过对插件配置:renderModernChunks: false,只生成兼容代码,依然报错。
通过修改 Vite 配置:build.minify: false 不压缩代码,尝试查看报错位置。新的报错:
升级 Vite。新的报错:
所以每次的报错都不一样,越来越奇怪。不过看起来似乎是同一个原因导致的。
在构建源码中调试
通过在构建后的源码中打印,其中 excute 函数中,有两个参数 exports module,但是在其中使用 module.meta 报错,说明其他文件在使用这个方法是并没有传参。看起来像是模块规范的问题(commonJS 和 ES Module)。
ChatGPT
在这期间,也在 ChatGPT 搜素方法:
就尝试了一下 format: 'es',顺便看到有个配置 compact: true
,好像也是压缩,就顺手改成 false,这样全部不要压缩,方便看报错。
结果竟然 OK 了,页面打开,没有报错!
是这个配置生效的吗?通过排除,发现竟然是 compact 的原因。这个配置不是 Vite 本身的,是 Vite 使用的 rollup 的配置:
果然是插件冲突的结果。
再搜素 execute
,已经没有带参数了:
再次感叹 Webpack 配置工程师
build.sourcemap
后来想到开启 sourcemap 来定位报错的原始文件位置,未开启:
开启 sourcemap:
如果在打包过程中对代码进行了混淆或压缩,可能会导致 Source Map 无法准确映射到原始代码位置。
这就完了?
中午去吃个饭,下午回来,本以为打包发布验证一下就完了,结果测试地址能打开页面了,却和我在电脑浏览器上看到的一样,没有正常加载页面。晴天霹雳!
说明一下:我们这个项目是 App Hybrid 应用,Web 前端和服务端的通信通过客户端中转,所以在非客户端环境是拿不到服务端的数据的,联调测试都是在 App 中进行的。
但为什么前面一直用浏览器测试呢?虽然数据获取不到,但是如果页面加载出来了,说明打包代码是没问题,所以在前面页面显示了背景图等元素,id="app"
中有了内容以后,就是兼容版本的 js 正常执行了。但是再说一次,真实的环境是 WebView,最终的结果还是要看 WebView。
目前在 App 中还是没有显示完整的网页加载过程,又陷入了困境。页面已经没有了报错(除了 Vite 那个可以被忽略的),难道生成的兼容 js 有问题? 如果是具体的兼容代码有问题,那整个项目的代码又如何排查呢?
虽然没有头绪,但是隐隐觉得应该再坚持一下。继续分析,如果已经显示了页面,兼容的 js 已经执行了,那么数据接口请求了吗?
于是联系服务端,查看服务器日志,确定页面加载没有请求接口,说明请求接口的代码没执行。我用高版本 Chrome 查看 Preview(之前没做这一步),果然除了看到页面元素,还进行了请求尝试。于是又运用排除法,一步步打印首页加载的过程。
在这之前,又是如何调试的问题,我们把测试入口改为我本地的 preview 地址。
加入打印日志,本地 build,Preview,打印了日志信息,果然接口请求的部分未执行。排查发现是之前调起 WebView 导航的代码出问题,可能是 jsBridge 还未加载。
但是为什么其他设备没有出现这个问题呢,可能是设备性能较差。于是把相关代码放到 jsBridge 加载完成后执行,修复了这样一个隐藏的 bug。
但是为什么不报错呢??这真的是很不好的体验,之前 Vue router 不报错的问题也类似。
总结
同样,我们再回头看那个最初的报错:
vite: loading legacy chunks, syntax error above and the same error below should be ignored
上面的报错、项目相同的报错可以被忽略。很容易让人忽略了报错,明确提到了下面的报错,但是 Vite 打包中下面没有出现同样的报错了(我目前的打包和其他文章中提到的不一样,比如知乎文章提到的代码);而且相同的报错到底是什么相同,同样是语法报错而已啊。
这句提示值得商榷。
function __vite_legacy_guard() {
import.meta.url;
import("_").catch(() => 1);
async function* g() {};
};
主要是因为出问题的恰恰就是中间的版本,Chrome 62,Vite 让它报错又能正常运行。遇到这种情况少之又少,从权衡上来说,好像也没有问题
本质上,只是在解决一个打包后的文件报错的问题,问题是一开始并没有定位到这个问题,其次是打包后的报错仍然难以定位具体错误位置。然后这其中还涉及到项目自身环境的各种干扰。
几点感悟:
- 坚持不懈,这是解决问题的唯一原因。
- 总结熟练调试很重要,要快速找到方便调试的方法。
- 没有报错是开发的一大痛点。
- 针对当前的问题更深入的分析原因,更广泛的尝试。
- 多用 ChatGPT,ChatGPT 的强大在于它没有弱点,没有缺项。
说明
通过这个案例,希望能给大家一点解决问题的启发。是遇到类似的问题时:
- 了解相关的问题
- 熟悉相关的概念
- 学习解决问题的方法
- 学习调试的方法
- 坚持的重要性
参考
【原理揭秘】Vite 是怎么兼容老旧浏览器的?你以为仅仅依靠 Babel?
来源:juejin.cn/post/7386493910820667418
研发都认为DBA很Low?我反手一个嘴巴子
前言
我用十多年的DBA经验告诉你,如果你作为研发觉得DBA很Low,你是会吃苦头的
“你以为DBA就是安装一下数据库,管理一下数据库?你丢个SQL给DBA优化下?你的日志爆满了DBA给你清理一下?DBA帮你安装下中间件?你以为的DBA只是做这些事?”
秉持着和平交流的学习态度,我这里精选了几位高赞粉丝的精彩回答
1.救火能力
1.1 调优
IT界并没有一个通行的 ”拳头“ 来判断谁low,谁更low。有时候,研发写的程序,新功能发布后,就出现磁盘IO出现瓶颈了、或者CPU飙高到100%了,但是这个时候,只是表象,只知道Linux机器的资源耗尽了,DBA得先找到资源消耗在哪了,才能进一步分析原因,用数据说话是应用的问题,才能责令程序员整改。
SQL调优是一个复杂的过程,涉及多个方面,包括但不限于SQL语句的编写、索引的使用、表的连接策略、数据库的统计信息、系统资源的利用等。调优的难度取决于多个因素,包括查询的复杂性、数据量、硬件资源、数据库的工作负载和现有的优化策略。
在这里给大家分享一个执行计划变,1个SQL把系统干崩的情景,由于业务用户检索数据范围过大,导致执行计划谓词越界,通过矫正执行计划及开启操作系统大页,服务器DB一直存在的CPU高负载从75%降低到25%!
生产问题,瞬息万变,DBA要同时熟悉业务,并对硬件、网络要精通,要在这样的复杂情况下作出正确的决策,这一点我想难度不小吧。
1.2 高可用
数据库高可用是指DB集群中任何一个节点的故障都不会影响用户的使用,连接到故障节点的用户会被自动转移到健康节点,从用户感受而言, 是感觉不到这种切换。
那么DBA在高可用的配置方面,下面就是某制造业大厂,应用层的链接方式
--jdbc应用端的连接
jdbc:oracle:thin:@(DESCRIPTION =
(ADDRESS_LIST =(ADDRESS = (PROTOCOL = TCP)(HOST = rac1-vip)
(PORT = 1521))(ADDRESS = (PROTOCOL = TCP)(HOST = rac2-vip)
(PORT = 1521))(LOAD_BALANCE = no)(FAILOVER = yes))
(CONNECT_DATA =(SERVER = DEDICATED)(SERVICE_NAME = dbserver)))
那么这种配置FAILOVER = yes,Net会从多个地址中按顺序选择一个地址进行连接,直到连接成功为止,那么就会保证数据库单节点故障,自动的切换,高可用是故障发生的第一个救命的稻草,系统上线前一定要测试好,才能确保数据库的高可用,这期间DBA功不可没!
还有客户要求选择的一套国产数据库支持核心业务,那么作为DBA在选型及业务适配上就发挥作用了,跟研发确认发现应用是兼容PG的,而且客户要求要同时兼容OLAT和OLTP业务,看下以下这套openGauss国产数据库的高可用架构。
1.openGauss高可用:CM
通过配置VIP故障转移,OLTP连接VIP,进行事物交易
同时支持动态配置CM集群故障切换策略和数据库集群脑裂故障恢复策略,
从而能够尽可能确保集群数据的完整性和一致性。
2.写重定向,报表分析业务连接,支持读写分离
主备节点开启控制参数 enable_remote_execute=on之后
通过备库发起的写操作,会重定向到主库执行
2.监控能力
这方面我是最有发言权了,SA一直是我的本职工作,从机房硬件部署、弱电以及数据库的安装实施,很多东西需要依赖于DBA来做,全力保障应用的稳定性,而且监控到的指标随时可以推送到邮件以及微信。这期间我也发现了很多天窗,原来还可以这么干?
2.1 服务器监控
首先监控Linux服务嘛,那肯定是要全方位系统的监控,网络、磁盘、CPU、内存等等,这才叫监控,那么其实给大家推荐一款免费的监控工作
Prometheus提供了从指标暴露,到指标抓取、存储和可视化,以及最后的监控告警等组件。
数据库监控
Zabbix聚焦于帮助用户通过性能优化和功能升级来快速响应业务需求,从而满足客户的高期望值,并提升IT运维人员的生产力。在可扩展性与性能、稳定性与高可用、可观测性几个领域获得持续提升。监控做不好,救火救到老!拿下Zabbix,现在!立刻!马上!!
1.监控Oracle
博客地址:
https://jeames.blog.csdn.net/article/details/126825934
2.监控PostgreSQL
博客地址:
https://jeames.blog.csdn.net/article/details/120300581
3.监控MySQL
博客地址:
https://jeames.blog.csdn.net/article/details/126825934
3 数据源赋能者
从AI、智能化到云迁移和安全性,业务和技术趋势不断重塑DBA在组织中的角色.DBA 群体站在时代的岔路口,国产数据库太多了应该怎么选?DBA 会被云上数据库抛弃吗?应该如何应对新时代挑战?职业终点在哪里?
1.云数据库解决方案
DBA要善于利用云原生保障数据安全和优化成本
2.数据安全与合规
随着数据保护法律的出台、日益严峻的网络攻击,
DBA必须掌握加密、访问控制和审计等技能
3.灾难恢复和业务连续性
随着企业愈加依赖数据的连续性,
快速恢复丢失数据并最大限度地减少停机时间至关重要
4.自动化和脚本编写
自动化和脚本编写对于DBA管理重复性任务和提高效率尤为关键
5.有效的沟通和协作
有效的沟通和协作仍然是DBA的重要技能。
能够向同事清楚地传达技术信息、与跨职能团队合作,
打破IT部门和业务部门之间的信息差,确保数据库的策略与组织目标保持一致。
4.总结
在一个公司写了屎山代码的研发,可以拍拍屁股走人,然后继续去下一个企业再写个屎山。反正不会追着代码跨省找你。而一个搞崩了系统的DBA,这个闯祸经历将成为他的黑历史,并影响到他未来的就业.因为需要专业DBA的好企业,基本都是几百台服务器起步的大项目,难免不会查背景,这就导致DBA如果想干得好,圈子会越来越小,请记住是干得好,不是混得好,混是会出事的。
好了,以上就是我对DBA的理解了,有不足之处还望指正。
来源:juejin.cn/post/7386505099848646710
谈谈前端如何防止数据泄漏
最近突然发现了一个好玩的事情,部分网站进去的时候几乎都是死的,那种死是区别于我们常见的网站的死:
- 不能选中文字
- 不能复制粘贴文字
- 不能鼠标右键显示选项
- 不能打开控制台
- ……
各种奇葩的操作应接不暇,像极了我最初接触的某库。shigen
的好奇心直接拉满,好家伙,这是咋做的呀。一顿操作之后,发现这种是为了防止网站的数据泄露(高大上)。在我看来,不是为了装X就是为了割韭菜。
咱废话也不多说,就手动来一个,部分代码参考文章:如何防止网站信息泄露(复制/水印/控制台)。
那shigen
实现的效果是这样的:
用魔法生成了一个页面,展示的是李白的《将进酒》。我需要的功能有尽可能的全面,禁止复制、选择、调试……
找了很多的方式,最后能自豪的展示出来的功能有:
- 禁止选择
- 禁止鼠标右键
- 禁止复制粘贴
- 禁止调试资源(刷新页面的方式)
- 常见的页面水印
那其实也没有特别的技术含量,我就在这里展示了,希望能作为工具类供大家使用。
页面部分
html5+css,没啥好讲的。
html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
font-family: "Microsoft YaHei", sans-serif;
line-height: 1.6;
padding: 20px;
text-align: center;
background-color: #f8f8f8;
}
.poem-container {
max-width: 600px;
margin: 0 auto;
background-color: #fff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
h1 {
font-size: 1.5em;
margin-bottom: 20px;
}
p {
text-indent: 2em;
font-size: 1.2em;
}
style>
<title>李白《将进酒》title>
head>
<body>
<div class="poem-container">
<h1>将进酒h1>
<p>君不见,黄河之水天上来,奔流到海不复回。p>
<p>君不见,高堂明镜悲白发,朝如青丝暮成雪。p>
<p>人生得意须尽欢,莫使金樽空对月。p>
<p>天生我材必有用,千金散尽还复来。p>
<p>烹羊宰牛且为乐,会须一饮三百杯。p>
<p>岑夫子,丹丘生,将进酒,杯莫停。p>
<p>与君歌一曲,请君为我倾耳听。p>
<p>钟鼓馔玉不足贵,但愿长醉不复醒。p>
<p>古来圣贤皆寂寞,惟有饮者留其名。p>
<p>陈王昔时宴平乐,斗酒十千恣欢谑。p>
<p>主人何为言少钱,径须沽取对君酌。p>
<p>五花马,千金裘,呼儿将出换美酒,与尔同销万古愁。p>
div>
body>
js部分
禁止选中
// 防止用户选中
function disableSelect() {
// 方式:给body设置样式
document.body.style.userSelect = 'none';
// 禁用input的ctrl + a
document.keyDown = function(event) {
const { ctrlKey, metaKey, keyCode } = event;
if ((ctrlKey || metaKey) && keyCode === 65) {
return false;
}
}
};
禁止复制、粘贴、剪切
document.addEventListener('copy', function(e) {
e.preventDefault();
});
document.addEventListener('cut', function(e) {
e.preventDefault();
});
document.addEventListener('paste', function(e) {
e.preventDefault();
});
禁止鼠标右键
// 防止右键
window.oncontextmenu = function() {
event.preventDefault()
return false
}
禁止调试资源
这个我会重点分析。
let threshold = 160 // 打开控制台的宽或高阈值
window.setInterval(function() {
if (window.outerWidth - window.innerWidth > threshold ||
window.outerHeight - window.innerHeight > threshold) {
// 如果打开控制台,则刷新页面
window.location.reload()
}
}, 1000)
这个代码的意思很好理解,当我们F12的时候,页面的宽度肯定会变小的,我们这个时候和屏幕的宽度比较,大于我们设置的阈值,我们就算用户在调试页面了。这也是我目前找到的比较好的方式了。但是,但是,认真思考一下以下问题需要你考虑吗?
- 页面频繁加载,流量的损失大吗
- 页面刷新,后端接口频繁调用,接口压力、接口幂等性
所以,我觉得这种方式不优雅,极度的不优雅,但是有没有别的好的解决办法。
加水印
// 生成水印
function generateWatermark(keyword = 'shigen-demo') {
// 创建Canvas元素
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
// 设置Canvas尺寸和字体样式
canvas.width = 100;
canvas.height = 100;
context.font = '10px Arial';
context.fillStyle = 'rgba(0,0,0,0.1)';
// 绘制文字到Canvas上
context.fillText(keyword, 10, 50);
// 生成水印图像的URL
const watermarkUrl = canvas.toDataURL();
// 在页面上显示水印图像(或进行其他操作)
const divDom = document.createElement('div');
divDom.style.cssText = `
position: fixed;
z-index: 99999;
top: -10000px;
bottom: -10000px;
left: -10000px;
right: -10000px;
transform: rotate(-45deg);
pointer-events: none;
background-image: url(${watermarkUrl});
`;
document.body.appendChild(divDom);
}
代码不需要理解,部分的参数去调整一下,就可以拿来就用了。
我一想,我最初接触到这种页面水印的时候,是在很老的OA办公系统,到后来用到了某书,它的app页面充满了水印,包括浏览器端的页面。
所以,我也实现了这个。but,but,有一种技术叫做OCR,大白话讲就是文字识别。我把图片截个图,让某信、某书识别以下,速度和效果那叫一个nice,当然也可能把水印也识别出来了。聪敏的开发者会把水印的颜色和文字的颜色设置成一种,这个时候需要准确的文字那可得下一番功夫了。换句话说,不是定制化的OCR,准确的识别出信息,真的够呛。
还有的很多页面实现了js的数据加密、接口数据加密。但是道高一尺,魔高一丈,各种都是在一种相互进步的。就看实际的业务场景和系统的设计了。
来源:juejin.cn/post/7300102080903675915
从劝退 flutter_screenutil 聊到不同尺寸 UI 适配的最佳实践
先说优点
💡 先说优点叠个甲,毕竟库本身没有太大问题,往往都是使用的人有问题。
由于是基于设计稿进行屏幕适配的框架,在处理不同尺寸的屏幕时,都可以使用相同的 尺寸数值+单位
,实现对设计稿等比例的适配,同时保真程度一般很高。
在有设计稿的情况下,只使用 Container + GestureDetector 都可以做到快速的开发,可谓是十分的无脑梭哈。
在:只考虑移动端、可以接受使用大屏幕手机看小屏幕 ui、不考虑大字体的模式、被强烈要求还原设计稿、急着开发。的情况下,还是挺好用的。
为什么劝退?
来到我劝退师最喜欢的一个问题,为什么劝退。如果做得不好,瞎搞乱搞,那就是我劝退的对象。
在亲身使用了两个项目并结合群里的各种疑惑,我遇到常见的有如下问题:
如何实现对平板甚至是桌面设备的适配?
由于基于设计稿尺寸,平板、桌面等设备的适配基本上是没法做的,要做也是费力不讨好的事。
千万不要想着说,我通过屏幕宽度断点来使用不同的设计稿,当用户拉动边框来修改页面的宽度时,体验感是很崩溃的。而且三套设计稿要写三遍不同的代码,就更不提了。(这里说三遍代码的原因是,计算 .w
.h
的布局,数据会跟随设计稿变化)
如何适配大字体无障碍?
因为大字体缩放在满屏的 .w
.h
下,也就是写死了尺寸的情况下,字体由于随系统字体放大,布局是绝对会溢出的。很多项目开发到最后上线才意识到自己有大字体无障碍的用户,甚至某些博客上,使用了一句:
MediaQuery.of(context).copyWith(textScaleFactor: 1.0),
来处理掉自己的用户,强制所有屏幕字体不可缩放。一时的勉强敷衍过去,最后只能等项目慢慢腐烂。
为什么在 1.w 的情况下会很糊?同样是 16.sp 为什么肉眼可见的不一样大?
库的原理很简单,提供了一堆的 api 相对于设计图的宽高去做等比例计算,所以必然存在一个问题,计算结果是浮点数。可是?浮点数有什么问题吗?
梳理一下原理:已知屏幕设计图宽度 sdw
、组件设计图宽度 dw
,根据屏幕实际宽度 sw
,去计算得出组件实际宽度 w
。
w = sw / sdw * dw
可是设计图的屏幕宽度 sdw
作为分母时,并不能保证总是可以被表示为有限小数。举个例子:库的文档中给的示例是 const Size(360, 690),
的尺寸,如果我需要一个 100.w
会得到多少?在屏幕宽度为 420 的情况下,得到组件宽度应该为 116.6666... 的无限小数。
这会导致最终在栅格化时会面临消除小数点像素的锯齿问题。一旦有像素点的偏差,就会导致边缘模糊。
字体对尺寸大小更为敏感,一些非矢量的字体甚至只有几个档位的大小,当使用 14.5、15、15.5 的字体大小时,可能会得到一样的视觉大小,再加上 .sp 去计算一道,误差更是放大。
具体是否会发生在栅格化阶段,哪怕文章有误也无所谓,小数点像素在物理意义上就是不存在的,总是会面临锯齿平滑的处理,导致无法像素级还原 UI。
为什么部分屏幕下会溢出?
我们知道了有小数点问题,那么不得不说起计算机编程常见的一个不等式:
0.1 + 0.2 != 0.3
由于底层表示浮点数本身就有的精度问题,现在让 Flutter 去做这个加法,一样会溢出。考虑以下代码:
Row(
children: [
SizedBox(width: 60.w),
SizedBox(width: 100.w),
SizedBox(width: 200.w),
],
);
在一个总共宽度 360.w 的设计图上,可能出现了溢出,如果不去使用多个屏幕来调试,根本不会觉得异常,毕竟设计图是这样做的,我也是这样写的,怎么可能有错呢?
然而恰恰是库本身的小数问题,加上编程届常见的底层浮点数精度问题,导致边缘溢出一点点像素。
我使用了 screenutil 为什么和真实的单位 1px 1rem 1dp 的大小不同呢?
哪怕是 .sp
都是基于设计图等比例缩放的,使用 screenutil 就从来不存在真实大小,计算的结果都是基于设计稿的相对大小。就连 .w
和 .h
都没法保证比例相同,导致所有布局优先使用 .w
来编写代码的库,还想保证和真实尺寸相等?
为什么需要响应式 UI?
说个题外话:在面试淘菜菜的时候真的会有点崩不住,他们问如何做好不同屏幕的适配,我说首先这是 UI 出图的问题,如果 UI 出的图是响应式的,那没问题,照着写,闭着眼都能适配。
但是如果设计图不是响应式的,使用 flutter_screenutil 可以做到和设计图高保真等比还原,但是如果做多平台就需要 UI 根据屏幕断点出不同平台的设计图。
面试官立即就打断我说他们的 UI 只会出一份图。我当场就沉默了,然后呢?也不说话了?是因为只有移动端用户,或者说贵公司 UI 太菜了,还是说都太菜了。菜就给我往下学 ⏬
首先 UI 的响应式设计是 UI 的责任
抛开国情不谈,因为国内的 UI 能做到设计的同时,UI 还是响应式的,这样的 UI 设计师很少很少,他们能把主题规范好,约定好,已经是不得了的了。
但即使如此,响应式 UI 设计也还是应该归于 UI 设计中,在设计图中去根据不同的尺寸,拖动验证不同的布局效果是很容易的。在不同的尺寸下,应该怎么调整元素个数,应该如何去布局元素,只有 UI 使用响应式的写法去实现了,UI 和开发之间的无效交流才会减少。
响应式的 UI 可以避免精度问题
早在 19 年我就有幸翻阅了一本 iOS 的 UI 设计规范,当时有个特别的点特别印象深刻:尺寸大小应该为 2 的整数次幂,或者 4 的倍数。因为这样做,在显示和计算上会较为友好。
💡 这其实是有点历史原因的,之前的 UI 在栅格化上做得并不是很好,锯齿化严重也是常态,所以使用可以被 2 整除的尺寸,一方面使用起来只有几个档位,方便调整;另一方面这样的尺寸可以在像素的栅格化上把小数除尽。
举个例子,在屏幕中间显示一个 300 宽度的卡片,和边距 16 的卡片,哪一个更响应式,无疑是后者,前者由于需要计算 300 相对与设计稿屏幕的宽度,后者只需要准确的执行 16 的边距就好,中间的卡片宽度随屏幕的宽度自动变化。
同样的例子,带有 Expanded 布局的 Row 组件,相比直接给定每个子组件尺寸导致精度问题的布局,更能适配不同的屏幕。因为 Row 会先放置固定大小的组件,剩余空间由 Expanded 去计算好传给子组件,原理和 Web 开发中的 flex 布局一样。
响应式布局是通用的规范
如果有 Web 开发经验的,应该会知道 Web 的屏幕是最多变的,但是设计起来也可以很规范,常见的 bootstrap 框架就提到了断点这个观点,指出了当我们去做 UI 适配的时候,需要根据不同的屏幕大小去做适配。同时 flex 布局也是 Web 布局中常用的响应式布局手段。
在设计工具中,响应式 UI 也没有那么遥远,去下载一份 Material Design 的 demo,对里面的组件自由的拉伸缩放,再对比一下自己通过输入尺寸大小拼凑在一起的 UI,找找参数里面哪里有差异。
怎么做响应式 UI
这里直接放一个谷歌大会的演讲,我相信下面的总结其实都可以不用看了,毕竟本实验室没有什么可补充的,但是我们还是通过从外到内、从整体到局部的顺序来梳理一下如何去做一个响应式的 UI,从而彻底告别使用 flutter_screenutil。
http://www.youtube.com/watch?v=LeK…
SafeArea
一个简单的组件,可以确保内部的 UI 不会因为愚蠢的设备圆角、前置挖孔摄像头、折叠屏链接脚、全面屏边框等原因而被意外的裁剪,将重要的内容,显示在“安全区”中。
屏幕断点
让 UI 根据不同的尺寸的窗口变化而变化,首先就要使用 MediaQuery.sizeOf(context);
和 LayoutBuilder()
来实现对窗口的宽度的获取,然后通过不同的屏幕断点,去构建不同情况下的 UI。
其中 LayoutBuilder
还能获取当前约束下的宽度,以实现页面中子区域的布局,比如 Drawer
的宽度,对话框的宽度,导航的宽度。
这里举了个例子,使用媒体查询获得窗口宽度之后,展示不同的 Dialog
:
写出如此优雅的断点代码只需要三步:
- 抽象:找到全屏对话框和普通对话框中共同的属性,并将功能页面提取出来。
- 测量:思考应该使用窗口级别的宽度(MediaQuery),还是某个约束下的宽度(LayoutBuilder)。
- 分支:编写如上图所示的带有断点逻辑的代码。
GridView
熟悉了移动端的 ListView 布局之后,切换到 GridView 布局并适配到平板、桌面端,是一件十分自然的事,只需要根据情况使用不同的 gridDelegate
属性来设置布局方式,就能简单的适配。
这里一般使用 SliverGridDelegateWithMaxCrossAxisExtent(maxCrossAxisExtent: )
方法来适配,传入一个期望的最大宽度,使其在任何屏幕上看到的子组件都自然清晰,GridView 会根据宽度计算出合适的一行里合适的列数。
Flex 布局,但是 Flutter 版
前面说过了尽量不要去写固定尺寸的几个元素加起来等于屏幕宽度,没有那么巧合的事情。在 Row/Column 中,善用 Expanded 去展开子组件占用剩余空间,善用 Flexible 去缩紧子组件,最后善用 Spacer 去占用空白,结合 MainAxisAlignment 的属性,你会发现布局是那样的自然。
只有部分组件是固定尺寸的
例如 Icon 一般默认 24,AppBar 和 BottomNavigationBar 高度为 56,这些是写在 MD 设计中的固定尺寸,但是一般不去修改。图片或许是固定尺寸的,但是一般也使用 AspectRatio 来固定宽高比。
我曾经也说过一个普遍的公理,因为有太多初学者容易因为这个问题而出错了。
当你去动态计算宽高的时候,可能是布局思路有问题了。
在大多数情况下,你的布局都不应该计算宽高,交给响应式布局,让组件通过自己的能力去得出自己的位置、约束、尺寸。
举一个遇到过的群友问题,他使用了 stack 布局包裹了应用栏和一个滚动布局,由于SliverAppBar 拉伸后的高度会变化,他想去动态的计算下方的滚动布局的组件起始位置。这个问题就连描述出来都是不可思议的,然后他问我,我应该如何去获取这个 AppBar 的高度,因为我想计算下方组件的高度。(原问题记不清了,但是这样的需求是不成立的)
最后,多看文档
最后补上关于 MD3 设计中,关于布局的文档,仔细学习:
最后的最后,响应式布局其实是一个很宽的话题,这里没法三言两语说完,只能先暂时在某些领域劝退使用这个库。任何觉得可能布局困难的需求,都可以发到评论区讨论,下一篇文章我们将根据几个案例来谈谈具体的实践。
来源:juejin.cn/post/7386947074640298038
没用的东西,你连个内存泄漏都排查不出来!!
背景 (书接上回)
- ui妹子的无理要求,我通通满足了。但是不出意外的话,意外就出来了。
- 此功能在上线之后,我们的业务在客户app内使用刷脸的时候会因为内存过高导致app将webview杀死。
- 然后我们leader爆了,让我排查问题。可是可是,我哪里会排查内存泄漏呀。
- 我:我不会。你自己不会上吗?你tm天天端个茶,抽个烟,翘个二郎腿,色眯眯的看着ui妹妹。
- 领导:污蔑,你纯粹就是污蔑。我tm现在就可以让你滚蛋,你信吗?
- 我:我怕你个鸟哦,我还不知道你啥水平,你tm能写出来个防抖节流,我就给你磕头。
- 领导:hi~ui妹妹,今天过的好吗,来,哥哥这里有茶喝。(此时ui妹妹路过)。你赶快给我干活,以后ui妹妹留给你。
- 艹!你早这么说不就好了。

开始学习
Chrome devTools查看内存情况
- 打开
Chrome
的无痕模式,这样做的目的是为了屏蔽掉Chrome
插件对我们之后测试内存占用情况的影响
- 打开开发者工具,找到
Performance
这一栏,可以看到其内部带着一些功能按钮,例如:开始录制按钮;刷新页面按钮;清空记录按钮;记录并可视化js内存、节点、事件监听器按钮;触发垃圾回收机制按钮等
简单录制一下百度页面,看看我们能获得什么,如下动图所示:
从上图中我们可以看到,在页面从零到加载完成这个过程中
JS Heap
(js堆内存)、documents
(文档)、Nodes
(DOM节点)、Listeners
(监听器)、GPU memory
(GPU
内存)的最低值、最高值以及随时间的走势曲线,这也是我们主要关注的点
看看开发者工具中的Memory
一栏,其主要是用于记录页面堆内存的具体情况以及js堆内存随加载时间线动态的分配情况
堆快照就像照相机一样,能记录你当前页面的堆内存情况,每快照一次就会产生一条快照记录
如上图所示,刚开始执行了一次快照,记录了当时堆内存空间占用为
33.7MB
,然后我们点击了页面中某些按钮,又执行一次快照,记录了当时堆内存空间占用为32.5MB
。并且点击对应的快照记录,能看到当时所有内存中的变量情况(结构、占总占用内存的百分比...)
在开始记录后,我们可以看到图中右上角有起伏的蓝色与灰色的柱形图,其中
蓝色
表示当前时间线下占用着的内存;灰色
表示之前占用的内存空间已被清除释放
在得知有内存泄漏的情况存在时,我们可以改用Memory
来更明确得确认问题和定位问题
首先可以用Allocation instrumentation on timeline
来确认问题,如下图所示:
内存泄漏的场景
- 闭包使用不当引起内存泄漏
- 全局变量
- 分离的
DOM
节点 - 控制台的打印
- 遗忘的定时器
1. 闭包使用不当引起内存泄漏
使用Performance
和Memory
来查看一下闭包导致的内存泄漏问题
<button onclick="myClick()">执行fn1函数button>
<script>
function fn1 () {
let a = new Array(10000) // 这里设置了一个很大的数组对象
let b = 3
function fn2() {
let c = [1, 2, 3]
}
fn2()
return a
}
let res = []
function myClick() {
res.push(fn1())
}
script>
在退出
fn1
函数执行上下文后,该上下文中的变量a
本应被当作垃圾数据给回收掉,但因fn1
函数最终将变量a
返回并赋值给全局变量res
,其产生了对变量a
的引用,所以变量a
被标记为活动变量并一直占用着相应的内存,假设变量res
后续用不到,这就算是一种闭包使用不当的例子
设置了一个按钮,每次执行就会将fn1
函数的返回值添加到全局数组变量res
中,是为了能在performacne
的曲线图中看出效果,如图所示:
- 在每次录制开始时手动触发一次垃圾回收机制,这是为了确认一个初始的堆内存基准线,便于后面的对比,然后我们点击了几次按钮,即往全局数组变量
res
中添加了几个比较大的数组对象,最后再触发一次垃圾回收,发现录制结果的JS Heap曲线刚开始成阶梯式上升的,最后的曲线的高度比基准线要高,说明可能是存在内存泄漏的问题 - 在得知有内存泄漏的情况存在时,我们可以改用
Memory
来更明确得确认问题和定位问题 - 首先可以用
Allocation instrumentation on timeline
来确认问题,如下图所示:
- 在我们每次点击按钮后,动态内存分配情况图上都会出现一个
蓝色的柱形
,并且在我们触发垃圾回收后,蓝色柱形
都没变成灰色柱形,即之前分配的内存并未被清除 - 所以此时我们就可以更明确得确认内存泄漏的问题是存在的了,接下来就精准定位问题,可以利用
Heap snapshot
来定位问题,如图所示:
- 第一次先点击快照记录初始的内存情况,然后我们多次点击按钮后再次点击快照,记录此时的内存情况,发现从原来的
1.1M
内存空间变成了1.4M
内存空间,然后我们选中第二条快照记录,可以看到右上角有个All objects
的字段,其表示展示的是当前选中的快照记录所有对象的分配情况,而我们想要知道的是第二条快照与第一条快照的区别在哪,所以选择Object allocated between Snapshot1 and Snapshot2
即展示第一条快照和第二条快照存在差异的内存对象分配情况,此时可以看到Array的百分比很高,初步可以判断是该变量存在问题,点击查看详情后就能查看到该变量对应的具体数据了
以上就是一个判断闭包带来内存泄漏问题并简单定位的方法了
2. 全局变量
全局的变量一般是不会被垃圾回收掉的当然这并不是说变量都不能存在全局,只是有时候会因为疏忽而导致某些变量流失到全局,例如未声明变量,却直接对某变量进行赋值,就会导致该变量在全局创建,如下所示:
function fn1() {
// 此处变量name未被声明
name = new Array(99999999)
}
fn1()
- 此时这种情况就会在全局自动创建一个变量
name
,并将一个很大的数组赋值给name
,又因为是全局变量,所以该内存空间就一直不会被释放 - 解决办法的话,自己平时要多加注意,不要在变量未声明前赋值,或者也可以
开启严格模式
,这样就会在不知情犯错时,收到报错警告,例如
function fn1() {
'use strict';
name = new Array(99999999)
}
fn1()
3. 分离的DOM
节点
假设你手动移除了某个dom
节点,本应释放该dom节点所占用的内存,但却因为疏忽导致某处代码仍对该被移除节点有引用,最终导致该节点所占内存无法被释放,例如这种情况
<div id="root">
<div class="child">我是子元素div>
<button>移除button>
div>
<script>
let btn = document.querySelector('button')
let child = document.querySelector('.child')
let root = document.querySelector('#root')
btn.addEventListener('click', function() {
root.removeChild(child)
})
script>
该代码所做的操作就是点击按钮后移除
.child
的节点,虽然点击后,该节点确实从dom
被移除了,但全局变量child
仍对该节点有引用,所以导致该节点的内存一直无法被释放,可以尝试用Memory
的快照功能来检测一下,如图所示
同样的先记录一下初始状态的快照,然后点击移除按钮后,再点击一次快照,此时内存大小我们看不出什么变化,因为移除的节点占用的内存实在太小了可以忽略不计,但我们可以点击第二条快照记录,在筛选框里输入
detached
,于是就会展示所有脱离了却又未被清除的节点对象
解决办法如下图所示:
<div id="root">
<div class="child">我是子元素div>
<button>移除button>
div>
<script>
let btn = document.querySelector('button')
btn.addEventListener('click', function() {
let child = document.querySelector('.child')
let root = document.querySelector('#root')
root.removeChild(child)
})
script>
改动很简单,就是将对
.child
节点的引用移动到了click
事件的回调函数中,那么当移除节点并退出回调函数的执行上文后就会自动清除对该节点的引用,那么自然就不会存在内存泄漏的情况了,我们来验证一下,如下图所示:
结果很明显,这样处理过后就不存在内存泄漏的情况了
4. 控制台的打印
<button>按钮button>
<script>
document.querySelector('button').addEventListener('click', function() {
let obj = new Array(1000000)
console.log(obj);
})
script>
我们在按钮的点击回调事件中创建了一个很大的数组对象并打印,用performance
来验证一下
开始录制,先触发一次垃圾回收清除初始的内存,然后点击三次按钮,即执行了三次点击事件,最后再触发一次垃圾回收。查看录制结果发现
JS Heap
曲线成阶梯上升,并且最终保持的高度比初始基准线高很多,这说明每次执行点击事件创建的很大的数组对象obj
都因为console.log
被浏览器保存了下来并且无法被回收
接下来注释掉console.log
,再来看一下结果:
<button>按钮button>
<script>
document.querySelector('button').addEventListener('click', function() {
let obj = new Array(1000000)
// console.log(obj);
})
script>
可以看到没有打印以后,每次创建的obj
都立马被销毁了,并且最终触发垃圾回收机制后跟初始的基准线同样高,说明已经不存在内存泄漏的现象了
其实同理 console.log
也可以用Memory
来进一步验证
未注释 console.log
注释掉了console.log
最后简单总结一下:在开发环境下,可以使用控制台打印便于调试,但是在生产环境下,尽可能得不要在控制台打印数据。所以我们经常会在代码中看到类似如下的操作:
// 如果在开发环境下,打印变量obj
if(isDev) {
console.log(obj)
}
这样就避免了生产环境下无用的变量打印占用一定的内存空间,同样的除了
console.log
之外,console.error
、console.info
、console.dir
等等都不要在生产环境下使用
5. 遗忘的定时器
定时器也是平时很多人会忽略的一个问题,比如定义了定时器后就再也不去考虑清除定时器了,这样其实也会造成一定的内存泄漏。来看一个代码示例:
<button>开启定时器button>
<script>
function fn1() {
let largeObj = new Array(100000)
setInterval(() => {
let myObj = largeObj
}, 1000)
}
document.querySelector('button').addEventListener('click', function() {
fn1()
})
script>
这段代码是在点击按钮后执行fn1
函数,fn1
函数内创建了一个很大的数组对象largeObj
,同时创建了一个setInterval
定时器,定时器的回调函数只是简单的引用了一下变量largeObj
,我们来看看其整体的内存分配情况吧:
按道理来说点击按钮执行fn1
函数后会退出该函数的执行上下文,紧跟着函数体内的局部变量应该被清除,但图中performance
的录制结果显示似乎是存在内存泄漏问题的,即最终曲线高度比基准线高度要高,那么再用Memory
来确认一次:
- 在我们点击按钮后,从动态内存分配的图上看到出现一个蓝色柱形,说明浏览器为变量
largeObj
分配了一段内存,但是之后这段内存并没有被释放掉,说明的确存在内存泄漏的问题,原因其实就是因为setInterval
的回调函数内对变量largeObj
有一个引用关系,而定时器一直未被清除,所以变量largeObj
的内存也自然不会被释放 - 那么我们如何来解决这个问题呢,假设我们只需要让定时器执行三次就可以了,那么我们可以改动一下代码:
<button>开启定时器button>
<script>
function fn1() {
let largeObj = new Array(100000)
let index = 0
let timer = setInterval(() => {
if(index === 3) clearInterval(timer);
let myObj = largeObj
index ++
}, 1000)
}
document.querySelector('button').addEventListener('click', function() {
fn1()
})
script>
现在我们再通过performance
和memory
来看看还不会存在内存泄漏的问题
performance
这次的录制结果就能看出,最后的曲线高度和初始基准线的高度一样,说明并没有内存泄漏的情况
memory
这里做一个解释,图中刚开始出现的蓝色柱形是因为我在录制后刷新了页面,可以忽略;然后我们点击了按钮,看到又出现了一个蓝色柱形,此时就是为fn1
函数中的变量largeObj
分配了内存,3s
后该内存又被释放了,即变成了灰色柱形。所以我们可以得出结论,这段代码不存在内存泄漏的问题
简单总结一下: 大家在平时用到了定时器,如果在用不到定时器后一定要清除掉,否则就会出现本例中的情况。除了
setTimeout
和setInterval
,其实浏览器还提供了一个API
也可能就存在这样的问题,那就是requestAnimationFrame
- 好了好了,学完了,ui妹妹我来了

- ui妹妹:去你m的,滚远点

好了兄弟们,内存泄漏学会了吗?
来源:juejin.cn/post/7309040097936474175
面包会有的,玫瑰也会有的
前言
在杭州持续半个多月的阴雨中,迎来了“大火收汁”的7月🥵。转眼间这个2024年也过去了一半,我也从大学毕业做了2年的“来杭州讨饭的🐕”了。本来最近是有点忙的,7月底要疗休养,但是突然来了不少活,不过现在要等接口开发好,所以还是来做一下年中暨成为社畜两周年总结了。
减肥(膝盖要紧)
减不动了🤣,之前走路走太多了,把自己走出了滑膜炎,现在多走几千步膝盖就会疼(想想自己养成走路习惯还是因为买 huawei 手环下载的 APP 上面的成就奖牌,当时为了拿成就一天两万多步)。之前减肥是真的快啊,三四个月从 83kg 减到 68kg ,最近 4 个多月没怎么运动了,也就保持在 71kg 左右。家里人也不让我减了,说再减就难看了。
补牙(双连:一战成名)
去年体检被发现有了一颗蛀牙,今年才有时间去补,第一次体验补牙还挺新奇的(不过很快就不新奇了)。好在只蛀到一点点神经,上了点药阻断一下就补上了。原本以为这种事短时间内不会再体验了,结果 2 个月前在我品尝我的梅干菜肉饼时,我左上的一颗槽牙被神奇地磕掉了一小块😅......好嘛,再次喜提牙科医生的修补打磨。
工作学习(平平淡淡)
我这个小小前端每天也就是砌个div了,这半年没整太多新东西,就是基于公司的已有平台,加加feature、修修bug,把官网PC端和移动端换了一遍样式,现在官网到处是UI加的毛玻璃特效,在我这个 8G 内存小 thinkbook 机子的浏览器上肉眼可见的卡顿🫠(可能因为我的核显不行吧,我加了搜到的translateZ(0)
也没体会到加速),没办法可能用户电脑都很强能带的动吧。
不过说了好几个月的基于 umi + qiankun 的新平台在下半年终于是要交付了,又重新过了一遍代码和开发流程。有一说一,真的很麻烦,整个平台根本就不大,完全想不通为什么要上微前端,我们目前开发团队算上外包同学也就3个前端了,而且后续可能就我一个人负责这个平台的前端开发和维护😣。
工作之外重温了一下之前了解过的 SolidJS、Svelte 和 Tauri,写了几个小 Demo
练练手:
3月份打算今年11月考软考高项的,但是刚看了一个月的书,就通知只有上半年考了🫤。我寻思报名费这么贵,两三个月准备时间岂不是做慈善?!那就明年5月份再考吧,下半年该把书啊什么的再拿出来看了。
吃喝玩乐(还得是家乡的味道)
5月份,带女朋友回了老家徐州玩。感觉物价比起过去涨了好多,尤其是节假日的酒店(不敢想要是高铁也在节假日涨价,我过年还回不回得起家),苏宁广场的绿茶餐厅比杭州in77的还贵。宝莲寺在最近徐州旅游火起来之前我都没听说过😹。羊肉串、菜煎饼、米线、蒸菜、冷面味道还是好吃的👍,不过在家待的几天都是出去下馆子,没怎么吃到家里人做的菜,饭馆基本都是除了几道特色菜好吃,其他都一般般。
还得是过年时,家里做的好吃🫡。
再秀两张在宝莲寺的情侣照😎
展望
女朋友也毕业了,后面就是两个人在杭州打拼了。现在的工作其实挺好的,但是没有机会爬上去,爬不上去就没法在这个房价、物价如此离谱的城市留下。爸妈总是跟我说他们当初在一起的时候什么也没有,后面还是一起打拼出了这个家,面包总会有的。不过我想社会发展到现在,想要的也不一样了,面包要有,玫瑰也要有的吧。
hh,再写就要丧起来了。说不准哪天就中彩-票了,什么面包、玫瑰都不是事😇,全都做成鲜花饼😡,硌不坏牙的那种。
来源:juejin.cn/post/7386848746913366056
我是计算机专业研二的学生,我焦虑死了
最近两周,总有大三或研二的同学在微信上跟我说:
“学长,我想在明年上半年的时候找个中大厂的实习,以及在秋招的时候拿个好点儿的offer。
但我现在没有项目经验,八股文才刚刚开始看,算法只会刷几十道简单和中等难度的,现在时间一天天过去,我自己并不在学习状态,感觉非常焦虑,甚至焦虑得晚上睡不着觉。”
其实,我非常理解同学们有这种焦虑状态,而形成这种状态的原因,我的分析如下。
恐惧来源于未知,而焦虑来自于不可控,大部分又焦虑又没有学习状态的同学,往往都有类似困惑:
- 我是双非学历,学了还有用吗?会不会大厂的简历筛选都不通过?
- 我现在开始学,一天10个小时这种节奏,还来得及吗?
- 我到底应该学习哪些东西,先后顺序是什么,这些东西需要学到什么程度才行?
而正是这种努力了也不一定更好,自己的命运无法掌控的感觉,让他们产生了不安全感和焦虑。
恰恰,大部分处于这种状态的同学,都是学习成绩和技术能力处于中等水平的。
因为按照现在这市场行情,学习成绩和技术能力处于下游的同学,早就已经放飞自我、彻底躺平了。
他们的想法是:毕业后的工作爱找成啥样就找成啥样呗,反正最后有个工作干着就行。现在这经济环境,就算努力了也大概率没什么好的结果,反而希望越大,失望就越大,再给自己累出一身病来,得不偿失。
而在各方面都比较优秀的同学,感到焦虑的人也不是很多,毕竟那份骨子里的自信和从容还是有的。
下面,我就给这类焦虑的同学支支招,用我自己思考的“双标一力”策略来化解这种负面情绪。
“双标一力”,即:制定目标 + 拆解目标 + 执行力。
制定目标
有句话是这样说的,梦想还是要有的,万一实现了呢?
但如果真的把不切实际的梦想作为目标,容易让自己整体的执行落地计划和动作变形,导致自己越来越焦虑,最终彻底摆烂。
BTW:这里所说的目标,是以校招生的身份,拿到心仪公司的offer。
举个例子,如果你是双非二本学历,技术能力也没牛逼到傲视群雄的地步,我不建议你把目标定为互联网大厂。
你可以把目标更切实际地定为,找个福利待遇中上等的公司,保证先上牌桌,再图发展。
如果你不知道如何给自己制定目标,我建议你采用“参照对标”的方式。
比如:你目前的学习成绩和技术储备在学校中排名前 30%的话,可以把去年前20%的学长学姐所去的公司作为目标。毕竟,去年和今年的招聘市场环境相差不大。
为什么今年前30%对去年的前20%呢?很简单,就是留一些空间让你去追赶的。
拆解目标
制定完目标后,你的焦虑感依然是存在的,还是觉得心里没底。
接下来就要看,你想要拿到目标公司的offer,都需要做哪些技术储备,以及需要储备到什么程度了。
举个例子,现在你想拿到互联网大厂的Java后端岗的offer,那以下几个方面的储备是缺一不可的。其中包括:
- Java技术栈的相关八股文(一期:Java、Spring生态、MyBatis、MySQL、Redis、JVM、操作系统、计网;二期:ES、MQ、Netty等)。
- 500+的算法题。
- 两段以上的项目经历,以及熟悉项目中对应的技术点。
- 至少3个月的大厂实习经历。
下面我们继续对其进行拆解,以此得出不同时间节点需要做完哪些事情。
我们按照秋招九月份开始,那如果具备上述至少3个月的大厂实习经历的话,那意味着最迟六月初就要去大厂开始实习了。
我们继续往前推算,如果6月份入职实习,那预留一个半月找实习的时间,是比较充裕的,也就是4月中旬。
接下来,我们再看项目、八股文和算法应该如何进行安排。
其中,项目和八股文是有先后顺序的,如果你在没练手过任何项目的情况下,直接去背八股文,那这个过程会非常痛苦。
但反过来,你没有储备任何八股文,但只要掌握Java基本语法和SpringBoot、MyBatis的用法,跟着黑马、尚硅谷、慕课的视频敲两个新手小白项目,那应该是不难的。
而刷算法这件事情,只要你大学期间有些数据结构和算法的底子,那直接开始即可。另外,算法储备会比较耗时,所以越早开始越好。
那整体的拆解路径也就出来了,我们假设从2023年的12月中旬开始,进行如下安排:
- 项目 + 算法双管齐下,以二月中旬为期限,在这两个月中,动手敲两个小项目 + 初刷200个算法题。
- 在二月中旬到三月中旬期间,以一期八股文为主 + 再储备100个算法题为辅。
- 在三月中旬到四月中旬,继续以一期八股文为主 + 简历中项目的技术点为主 + 二刷300道算法题为辅。
- 四月中旬到六月初,一期八股文 + 简历中项目的技术点 + 300道算法(三刷、四刷)三管齐下。如果拿到了实习offer,那马上调转方向,在入职实习前的几天里,all in两三个月没碰过的项目,这样到了公司能快速上手。
- 六月初到九月初,在公司里好好工作实习,争取可以有亮点经历写在简历上,并巩固好。另外八股文和算法不要停,继续扩充广度,即:一期、二期八股文 + 500算法题。
执行力
这点没什么好说的,干就完了。真正的聪明人,都喜欢心无旁骛地下笨功夫。
我相信,只要你能用心坚持一个月,看着自己之前的学习计划正在如期逐步落地,你的焦虑感就会变为成就感。
结语
希望我写的这些,能够对正在焦虑中的大三、研二的计算机专业同学有所帮助。
来源:juejin.cn/post/7310147188252704787
前端超进化-小公司不用自研也能搞基建(全开源工具版)
蛮荒时代
快看,这个男人叫小帅,他进了一家只有4个人的信息高科技有限公司,还妄想改变世界。
前端只有一个人,所谓的发版,就是直接本地打包,然后代码通过ftp工具扔到服务器上,代码能跑就行。
农耕时代
这种不靠谱的开发模式进行了一段时间,直到某一天,ftp把服务器搞挂了的同时,本地的文件也丢了。所以他的leader小强意识到是时候需要改变了。
首先他们开始使用了git,代码不再是只存在本地,更是存在了云端。同时新建了dev/prod/maste分支来保证相对于各个环境的独立。
在某次把dev环境代码打包上传到生产后,为了区分环境和避免再次发生这种事故,小强也开始使用了在线打包工具jenkins,
jenkins的引入也有效的避免了服务器账号外泄的风险,现在的服务器账号相对开发者是黑盒的。
手工时代
慢慢的,公司的生意做的越来越好,老板已经开始看奔驰了。
前端也从1个人加到了3个人,成立前端技术部,小帅觉得自己快要走上人生巅峰了。人加了3个,问题也越来越多。
第一个重要的问题就是,git分支总冲突。
因为以前没有制定git规范,所以大家很随意的在dev和master上开发和提交代码。
所以约定了git的相关规范,每次用feature分支作为开发分支,feature通过merge合并到dev和上线release分支。
同时使用pre-commit来规范提交的commit信息。
第二个问题,是代码风格的问题。
人多了之后,大家的代码风格不一致了。比如有的人是2空格党,有个是4空格党,有人组件用驼峰命名,有人用大驼峰。为了统一风格,于是决定使用使用统一的风格检查。
同时了为了避免一些低级的语法错误和dirty code,引入了eslint进行代码检查。
通过Prettier对代码风格进行统一和格式化,通过Husky来在提交前进行link检查。在提交后,通过github Ci来进行二次检查。
至此,在手工时代,前端团队完成了代码风格和基础的lint检查。
工业时代
老板的爸爸原来是顾总,看到儿子创业很开心,给儿子调了5个亿的现金,于是大家开始快马加鞭,突击项目。
前端人数也直接爆炸,来到了20+,各种各样的项目如火如荼的进行,于是各种解决问题的前端方案应运而生。
微前端 single spa/qiankun
20个人的开发量,在一个项目中,代码量爆炸,每次启动打包,都巨慢无比。同时,之前一些零散的项目也需要并入到这个项目中,但是目前主技术栈用的react,零散项目有个用vue,有的用angular。
为了解决这些问题,所以要开始微前端的改造。
整体项目采用了single spa,通过统一的框架底层,将各个不同的项目聚合在一起。
微前端改造后,将整体的大项目拆分成了各自独立的项目。同时各自独立的项目有自己的仓库,各自独立打包。在运行时,各独立项目的停止也不影响其他项目。
物料库
现在各个项目独立开来,拥有各自的仓库,所以每次有新模块的加入,需要新建仓库,所以模版物料应运而生。
模版物料 degit
类似Vue-cli creat-react 都有做模版物料,里面包含了一个git地址,通过git拉取模版物料。
模版物料里包含了一个项目所需的相关配置,例如打包工具,框架cli,框架主模块,eslint,prettier,示例代码等。每个项目直接进行npm安装即可开始。
我们可以使用gedit来达成此目标
npx degit [git地址]
# 例如 npx degit https://github.com/tinlee/1000-project-demo
业务组件物料 VitePress/storybook
有一些物料我们需要结合业务场景,比如省市区选择器,这种物料通常都具有统一的api,ui,可以用于多个项目的使用。在基础的ui组件的基础上,还结合了业务属性。
这类物料也需要有展示的站点,我们可以使用VitePress/storybook来展示,也可以结合类似Dumi等。这些文档工具都是支持markdown和组件的混用插入。
npm私服 Sinopia
既然相关的业务组件库已经搭建完成,我们是希望不通过外网访问的,所以一个npm私服也必须要有。 这里通过Sinopia来进行私服的搭建。npm私服,在拉取的时候,检测当前服务器没有该包时会从npmjs获取,而上传的时候,只发到私服,而不会提交到npmjs。
文档工具 Outline
现在组件库很多,相关的技术文档很多,在以往的情况下,公司使用了腾讯/飞书等进行文档管理,但是很多内容不希望外部知道的内容。 所以使用Outline搭建了一个内部的文档工具。
Mock服务 YApi
以往前端和后端都口头约定,然后通过后端的swagger来生成文档。但是这存在一个问题,文档的生成滞后,前端没办法在正式接口之前进行mock。所以引入YApi,通过在Yapi上填写mock数据,进行数据的模拟。
jekins升级 docker/k8s
因为以往的构建,都是在一个项目中,现在微前端允许多框架,多环境构建。为了解决环境不统一的问题,引入了docker部署,对于多环境/多机器进行部署。
前端监控 sentry
Sentry 是一套开源的实时的异常收集、追踪、监控系统。这套解决方案由对应各种语言的 SDK 和一套庞大的数据后台服务组成,通过 Sentry SDK 的配置,还可以上报错误关联的版本信息、发布环境。同时 Sentry SDK 会自动捕捉异常发生前的相关操作,便于后续异常追踪。异常数据上报到数据服务之后,会通过过滤、关键信息提取、归纳展示在数据后台的 Web 界面中。
和平时代
基于上一次的工业大爆发,前端已经趋于稳定,公司业务也趋于稳定,老板的兰博已经停在了楼下。
在逐步的追求效率,追求速度的时期结束后,逐渐迎来了和平发展时期。这时候,团队开始关注自动化和创新。
灰度发布/一键回滚 k8s
各服务厂商都有提供灰度发布平台,结合服务商自己的服务可以对前端代码进行多环境,多场景,多条件的灰度部署及运维。
比如腾讯的服务网格,阿里的serverless等均有各种不同的服务。最基本的部署服务,就是根据k8s部署了多个不同的pod节点,然后根据规则匹配,对灰度环境进行的流量进行管理。
自动化测试 sonic
基于自动化测试的云平台,可以帮助每次执行真机的自动化测试,保证主流程的准确稳定。
性能监控/优化 Lighthouse/Sentry
chrome自带的Lighthouse可以进行本地性能的分析,同时搭配Sentry可以进行日志监控。
低代码/营销搭建 lowcode engine
公司一定时间之后一定需要一套做营销搭建和内部页面组织的工具,阿里开源的 lowcode engine可以在低代码领域进行快速的迭代。
AI客服 kimi/文心/通义
结合公司的文本资料库,将内容喂给Ai后,可以生成自己的Ai客服,解答一些常见的问题。
我是天元,立志做
1000个有趣的项目
的前端,公众号:前端cssandjs
如果你喜欢的话,请
点赞,收藏,转发
。
归寂
已经跟随公司奋斗了几年的小帅,拿着合同终止通知书,看着老板的兰博大牛,觉得是时候应该向社会输送自己了。
来源:juejin.cn/post/7364971296163414050
一个迷茫的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