因为公司的业务需要,需要办理一张境外的银行卡,恰好我自己一直挺想搞一张境外银行卡,经过相关同事的推荐以及小红书调研,最终决定办理一张招商永隆银行卡(实体银行,大多数同事都是选择的这一家)和一张众安银行卡(虚拟银行),下面就这次的办卡做下简单的记录。
香港有很多的银行,比如汇丰、渣打、中银等,我因为自己个人业务需要,最终只办理了招商永隆卡和 ZA Bank 两张卡,就这两张卡做一个简介,其他银行可以自己按需办理。
以上便是我这次去香港办卡的一些总结,这次应该是我第二次去香港了,第一次去是 2018 年的时候,去香港买 iPhone X,不得不感慨香港的物价是真的贵啊,基本上是内地的两到三倍。另外香港的交通也比较复杂,根据导航走,几次都迷失在高楼大厦之间。这次在香港逗留的时间比较短,行程比较匆忙,跟几个同事一起去的,大家在办完卡之后,简单吃了个烧鹅饭(54 港币一个人),当天下午五点便匆匆搭上回长沙的高铁,结束了这次香港之旅。
]]>起因是每次使用 Postman 调试服务接口的时候,如果服务一旦被重启,对应服务的ip和端口就会改变,就需要重新配置服务的ip和端口,非常的繁琐以及麻烦。所以就针对这个问题,做了一点研究,简单的记录下。
实际开发中,我们一般会有多套环境,比如:开发环境dev、测试环境test、生产环境prod等等。每个环境,我们配置好对应的consul(注册中心)环境变量、对应服务名环境变量,如图所示:
建议以服务名为目录,在对应的目录层级,维护如下的脚本,动态的获取注册中心上对应的指定服务的ip和端口:
pm.sendRequest(pm.environment.get("consul_service") + "trade-assets-service",
function(err, res) {
if (err) {
console.log(err);
} else {
pm.environment.set("trade-assets-service", res.json()[0].ServiceAddress + ":" + res.json()[0].ServicePort);
}
});
注: "trade-assets-service" 根据自己目录名变化
维护好了动态获取脚本后,就可以正常的模拟对应的request请求,请求里面的ip和端口只需要填充对应的服务的占位符即可:
]]>今年12月份我和女朋友马上就要领证了,想着我们俩自从疫情之后,就从来没有一起出去旅游过,所以女朋友提议,将原本用于求婚和购买钻石的预算,用来作为这次难忘之旅的资金,两人一拍即合,随即便踏上了上海魔都之旅。
在做好一系列准备工作后,包括预订酒店、购买机票和火车票、制定详细攻略等,我们于11月18日周六上午9点从长沙出发,飞往了上海。顺利地,我们于10点45分抵达了上海。我们的运气真是不错,这个周末上海的天气异常宜人。而且正值冬季,感受到那抹暖暖的阳光,简直美不胜收。在这个季节里漫步在街头,仿佛是置身于一片宁静而温暖的画卷中,让人感到无比惬意。
上海的地铁网络非常发达,整个旅途我们基本上都是乘坐地铁交通。而且我发现一个有趣的细节,就是上海的地铁站似乎没有像其他地方(比如长沙地铁)那样,工作人员拿个金属检测仪在乘客身上扫来扫去,这一点真的是好评啊。只需行李通过安检,就能完成整个安检流程,这样的效率和体验真的提升了不少。
一踏上上海的土地,我们迅速乘坐地铁前往第一站:沪西老弄堂面馆(静安寺店)。在B站上听到不少UP主推荐过这家面馆,加上我自己也是一位面食爱好者,自然是迫不及待地想要品尝一下上海的地道味道。抵达时恰好是饭点,因此排队的人群相对较多,我们排了差不多半个小时的样子才吃上。
虽然价格相对于外面一般的面馆稍贵,人均消费在40多元,但我们决定尝尝他们家的招牌菜。我们点了一份大肠拌面、一份蛤蜊猪肝拌面,以及一份炸猪排,总共花费了93.5块。面的分量十分充足,浇头和面搅拌均匀后,让人垂涎欲滴。大肠、蛤蜊和猪肝的味道都带有一丝甜味,非常独特。每一口面都充满了风味,吃完后味蕾得到了极大的满足感。至于炸猪排,虽然相对普通,但搭配当地的辣酱油沾食,味道也颇为不错。总的来说,这次的用餐体验是物有所值的。
从沪西老弄堂面馆吃完中饭后,我们搭乘地铁前往上海的著名景点:「外滩」,去看上海的地标:「东方明珠」。当天是周六,天气格外宜人,晴空万里,简直是理想的拍照日。外滩的人流相对较少,主要是一些观光游客和当地居民悠闲漫步。沿着黄埔江畔,走走停停,晒着冬日暖暖的阳光,好是惬意。我们还前往了万国建筑、和平饭店以及南京步行街,沿途漫步在上海的繁华之中,深刻感受上海这座城市的生活气息。
武康路是我从关注的博主 hayami 那里得知的,这次来上海,当然不能错过这个著名的地方,去亲身感受一下武康大楼和武康路的魅力。武康路两旁是各种有着历史沉淀的精致老洋房,整条街道保持着干净整洁。沿途行走,感受到老洋房的宁静与历史沧桑。街道两边都是一些打扮精致的帅哥和美女在散步,沿着武康路一路走走逛逛,体验下上海的city walk文化。
这次上海之行中,最让人难以忘怀的莫过于去上海迪士尼乐园,这简直是无法与其他任何经历相提并论。周日一大早,我们早早地离开了酒店,搭乘去往迪士尼的班车。上午9点,我们抵达了迪士尼乐园,或许是因为周日的天气宜人,游客络绎不绝。在此之前,我们在小红书上查阅攻略,得知周日可能相对人少,然而实际情况却并非如此,人潮涌动,倍感焦虑。不过好在女朋友一路耐心的开导,让我有了更多的期待。即便人再多,也无法减弱我们对迪士尼的期待与热情,哈哈哈。
在上午9:30左右入园后,我们首先体验的是「雷鸣山漂流」。这个项目相对来说比较温和,八个人一组,全程需要穿上雨衣,以防「湿身」。整个漂流过程中,最为刺激的部分莫过于穿越那个黑色的隧道,一片漆黑之中,突然响起的雷声,隧道内部还有一只巨大的恐龙在咆哮,营造出了一种紧张刺激的氛围。(ps:这个我网上找的图,当时没法带手机拍摄)
在完成「雷鸣山漂流」的体验后,我们前往观看了「风暴来临:杰克船长之惊天特技大冒险」舞台表演。这对我来说是在迪士尼见到的最为震撼的演出之一。演员们在游客进入剧场之前就开始与观众互动,引导大家积极参与。整个演出高潮迭起,而最令人难忘的时刻莫过于在一片雾气中,舞台前的视野模糊不清,然后幕布缓缓升起,揭开了整个实景海盗船的神秘面纱。这一刻,我不禁起了鸡皮疙瘩,视觉上的冲击感太过强烈。真心推荐这个项目,是迪士尼乐园中不可错过的精彩表演。
第二个让我深感震撼的项目是「奇幻冬日巡演」。在这个表演中,迪士尼的各位朋友们都纷纷登场,包括米奇、米妮、唐老鸭、白雪公主、七个小矮人等童年时代的动画人物。他们欢快地与游客打招呼,沿着巡演路线边走边跳。看到这些可爱的动画人物真实地出现在眼前,仿佛时光倒流回到了那个纯真的童年时代,让我感到无比怀念。这一刻,迪士尼乐园成为了一个魔法般的世界,带领我重温了那段美好的童年回忆。
后面我们还去体验了「创极速光轮」,这是一个相当刺激的项目,有点类似过山车的感觉。欣赏了「冰雪奇缘:欢唱盛会」表演,这是一场充满歌舞的节目,是个逛累了可以休息的好去处。
最后,我们来到了城堡附近拍照,这里的景色非常出色,尤其是与另一半一同来迪士尼时,务必要在这里留下美好瞬间。城堡周围拍照真的特别出片,是个打卡必备的地方。这次在迪士尼的冒险之旅,不仅让我们体验了刺激的项目,还让我们在梦幻般的城堡前留下了珍贵的回忆。
确实,迪士尼的物价确实有些离谱,火鸡腿85元一份,玉米35元一根,的确是比一般地方高出不少。在迪士尼游玩时,可以自备一些小零食和小面包,在排队等待的时候解决一下饥饿感。另外,如果想要吃饭,可以考虑到园区外的迪士尼小镇。那里的物价相对便宜一些,我们两个人晚餐在「大食代」,点了一份酸菜鱼和凤爪,总共花费100出头吧,味道还不错,而且能够吃饱。
这次突发奇想的旅行对我而言太有意义了。自2017年以来,我一直没有好好出去玩过,这次和女朋友一起在上海度过的两天,让我收获了极大的快乐。我们手牵手漫步在上海的街头巷尾,品尝了当地特色美食;一同进入了迪士尼童话世界,体验了各种刺激的项目,观赏了震撼的表演,与可爱的卡通人物亲密接触。上海这座城市充满了独特的魅力,让我留下了美好的回忆,期待着再次踏上这片充满生机的土地。
最近我的 IDEA 因为 Clash 的问题,出现了各种奇奇怪的问题,就这些问题的解决做一个简单的记录。
由于我在 Mac 上开了 Clash 代理软件,接管了系统代理,打开 IDEA 的 Appearance & Behavior --> System Settings --> HTTP Proxy
界面,提示 You have JVM property "https.proxyHost" set to "127.0.0.1",解决方案就是:移除掉 Java 自带的 http 和 socket 代理,采用系统代理,选择 Help -> Edit Custom VM Options
,增加如下的配置:
-Dhttp.proxyHost
-Dhttp.proxyPort
-Dhttps.proxyHost
-Dhttps.proxyPort
-DsocksProxyHost
-DsocksProxyPort
重启 IDEA 即可解决。
因为公司有专门的 Maven 私服,而这个私服是需要通过代理才能访问,无法直接访问,这个只需要在 Maven 的 setting.xml
配置文件中,增加 HTTP 代理就行,让 Maven 强制走 Clash 代理,比如我的 Clash 的 HTTP 代理端口是 7890,则配置如下:
<proxies>
<proxy>
<id>clash proxy</id>
<active>true</active>
<protocol>http</protocol>
<host>127.0.0.1</host>
<port>7890</port>
</proxy>
</proxies>
该问题是于 IDEA 里为 Maven 的 importer 和 runner 设置的 JVM 最大堆内存(-Xmx)过小而导致的,只需要将 Maven 如下的两个地方设置堆内存设置大点就行: 然后重新刷新下,就不会报内存不足了。
]]>作者:迈克尔·莫斯 时间:2022-04-02 09:45:26 数量:24个笔记
第四章 到底是谷物还是糖?:
第五章 我想经常看到运尸袋:
第八章 液体黄金:
第九章 午餐时间你说了算:
第十章 政府传递的信息:
第十一章 无糖、无脂肪、无买卖:
第十二章 人们热爱盐:
第十三章 消费者渴求同样绝妙的咸味:
第十四章 我对公众深感抱歉:
后记 我们为廉价食品而着迷:
"明明这么痛苦,这么难过,为什么就是不能放弃跑步?因为全身细胞都在蠢蠢欲动,想要感受强风迎面吹拂的滋味。"
我非常喜欢《强风吹拂》里面的这句话,为此我将它写在了我的 running page 上,以此来激励自己一直坚持跑下去,当我问我自己,你为什么不能放弃跑步?我思索了一会儿,我将自己这几年跑步心路历程归纳为这几个关键字:痛苦、恐惧、和解,下面我来说说这几个关键字的含义。
没错我以前是一个「胖子」,我在 2017 年的时候,体重一度达到了巅峰 93 kg,几乎任何一个人看见我,他们的第一句话都是「你怎么这么胖了?」,但是那个时候我的心态倒是蛮好的,整天乐呵呵的,该吃吃该喝喝的生活还是照样继续,并没有把朋友们的话放在心上。但是,你之前有多放肆,后面就有多痛苦,之后大体重带来的副作用也渐渐开始出现了:
当我脱光衣服,面对着镜子里的自己,那简直是不忍直视,仿佛镜子里的自己对我说:「兄弟,我不想再胖下去了」,确实啊,不能再这样下去了!咔嚓...随着相机快门声的响起,我拍下了这张充满油腻的照片(PS: 这张照片,到现在我一直留在我的相册里,以此来警示现在的我),然后正式开始了减肥之旅,也正式开启了我的跑步之路。
刚开始跑步的时候是痛苦的,首先是身体上的,由于身体的负重,跑个 3 公里,6~7 分配速,面红耳赤,喘不上气,迈不开步子,非常的痛苦,其次是心理上的,因为跑步是孤独的,所以你没有任何的鼓励,也没有任何的支持,你只能自己去控制自己的情绪,所以你的心理状态也是很绝望的。
前面 1-3 个月非常痛苦,大部分的人都熬不过这段时间,因为我们会在这段时间给自己找各种借口理由,所以通常熬过初始阶段的 3 个月,基本上我们的身体会开始慢慢的适应这种运动的节奏,运动习惯一但养成,这将会是一个非常好的开端。
中间也受过几次比较严重的伤,其中比较严重的一次,膝盖痛的走路都成问题,为此被迫休息一两个月,虽然后面陆陆续续的有些小的伤病,但是问题都不大,休息个一两天一般都自己恢复了。
随着持续的锻炼,慢慢的也带来了正向的反馈,体重开始下降,精神状态发生了翻天覆地的变化,开始对跑步上瘾,开始享受多巴胺带来的快乐。但是另外一方面,我居然出现了一个「恐惧」心理,我特别害怕自己又胖回去了,以至于我疯狂的加大运动量,有段时间几乎每天都跑,压根不给身体恢复的时间。
其实之所以会产生害怕的心理,归根结底还是对自己身材的一种焦虑,这种焦虑是害怕反弹。但是后面想明白了,大多数时候我们没必要对这种心理有太大负担,科学家提出了成功的减肥定义:「人刻意的减重 10% 的体重以上,并且至少保持一年以上」,如果能达到这个标准,加上合理的运动和饮食,也能继续保持现有的身材不反弹。
其实我发现大部分喜欢跑步的人,或者说喜欢运动的人,他们身上大多有一个特点,就是不怎么「卷」,当我们处在一个焦虑倍增的大都市,大家都被迫面临各种压力,而跑步对我最大的影响是慢慢的拥有了一种可以对抗焦虑的力量,当我觉得自己非常的焦虑的时候,我会选择去跑个 20 公里的 LSD(Long Slow Distance,直译为长距离慢跑),20 公里都能跑完,还有什么事情解决不了,每个人都需要有自己的方式来对抗现实的压力。
最近两年,我的心态发生了些许的变化,我开始对自己和解了,每天早上 5 公里的晨跑不再是机械式的任务,而是成为了生活的一部分,有时候没状态、想睡个懒觉,那就不跑了,现在的跑步,不再以减肥为目的,而是为了让自己保持一个比较好的精神状态。在 80% 的时间严格限制自己的情况下,剩下的 20% 的时间,我们可以做自己任何想做的事情,并且不需要有任何的心里负担。
跑步其实是一件非常有意思的事情,由其是当你在城市里面跑步的时候,你会看到各种形形色色的人,有行色匆匆从地铁口进出的上班族,有背着书包穿着校服嬉戏打闹的学生,有坐在大排档喝酒撸串吹牛皮的中年大叔,有略显疲惫坐在公交车上刷手机的年轻人,每个角落都在上演着各种悲欢离合,自己也莫名的会被带入别人的生活里,不由自主的去揣测他们背后的人生故事。
]]>作者:吉姆·洛尔 托尼·施瓦茨 时间:2022-01-13 17:33:43 数量:14 个笔记
第三章 高效表现有节奏——劳逸结合的平衡:
第四章 体能精力——为身体添柴加火:
第五章 情感精力——把威胁转化为挑战:
第七章 意志精力——活出人生的意义:
第八章 明确目标——知道什么最重要才能全情投入:
第九章 正视现实——你的精力管理做得如何:
第十章 付诸行动——积极仪式习惯的力量:
git reset --soft HEAD^
:--soft
:撤销 commit,不撤销 git add .
操作,不删除改动的代码--mixed
:撤销 commit,撤销 git add .
操作,不删除改动的代码--hard
:撤销 commit,撤销 git add .
操作,删除改动的代码,危险操作HEAD~2
yyyy-MM-dd
作者:Paul Hudson,日期:2019年1月18日
对于很多人来说,这篇文章看起来可能会比较奇怪,因为我们大多数人已经习惯了Apple的API文档的使用方式,并且也能快速的找到我们自己想要的东西。
但是有一个有意思的事实是:去年很多朋友希望我写一些关于如何阅读Apple开发文档的文章,比如有:你是如何去查看iOS的API接口的?如何在这些开发文档中找到你想要的东西?如何深入了解这些文档或者接口的底层原理?
你是不是曾经也希望有人帮助你去理解Apple的开发文档呢?其实并不只你一个人,有很多人的都有相同的苦恼。所以我希望这篇文章能对你有所帮助:我会尽力去解释它的整体结构,它有什么好的地方和不好的地方,以及我是如何使用Apple的开发者文档的。
更重要的一点是,我将向你展示那些有经验的人是如何去搜寻相关资料,并且这些资料要比Apple的在线文档更有价值。
任何的API文档应该有以下5种特性之一:
通俗点讲,Apple第1点做的很不错,第2和第3点也做的很多,但是第4点做的相当少,第5点几乎就没有。
所以,如果你正在寻找「如何使用Y去做X」的具体示例,你可以尝试从我的「swift的基础知识」教程学起,这也正是这篇教程的用途。
理解 Apple 的文档所要解决的问题,将帮助你最大限度地利用它。它不是一个结构化的教程——它也不会向你介绍一系列的概念来帮助你实现一个目标,它只是作为苹果支持的数千个API 的参考指南。
Apple的在线开发文档在:https://developer.apple.com/documentation/,虽然你有一个Xcode的本地离线版本,但是和我交流过的绝大多数的人都是使用的在线的版本,因为他们可以更容易的找到他们想要的东西。
Apple绝大多数的文档都有接口描述,这也是你看的最多的。我想用一个实际的例子,所以请从在你的浏览器中打开https://developer.apple.com/documentation/ (那是所有苹果开发者文档的主页)。
你会看到苹果所有的 API 都被分成了App Frameworks、Graphics 、Games等类别,并且你已经看到了一个重要的东西:所有深蓝色的文本都是可点击的,点击后它将带你进入特定框架的API文档。它使用相同的字体和大小,没有下划线,老实说,深蓝色链接和黑色文本之间没有太大区别,但你仍然需要留意这些链接,其中有很多,你将大量使用它们并进行深入研究。
现在请从App Frameworks类别中选择UIKit,你会看到它的简要概述(为iOS创建用户界面),一个标记为「重要」的黄色大框,然后是一个类别列表。这些黄色的框框确实值得注意:尽管它们被频繁使用,但它们几乎总能阻止你犯一些基础的错误,从而在以后引发一些奇怪的问题。
这个页面描述了UIKit的类别列表,这是也是大多数人通常会迷失的地方:他们想要学习一些类似于UIImage的东西,以至于他们必须查看整个列表,然后在合适的地方找到它。
在这种情况下,你可能会查看「Resource Management」这个类别,因为它的副标题写着 「管理存储在主可执行文件之外的图像、字符串、storyboards 和 nib 文件」,这听起来好像很有希望。 但是,你会感到失望,你需要向下滚动页面到「Images and PDF」 部分才能找到 UIImage
。
这就是为什么我交谈过的大多数人只使用他们最喜欢的搜索引擎,他们从搜索引擎中输入他们关心的类,只要它有一个像「UI」、「SK」或类似的前缀,通常搜索引擎的第一结果就是他们想要的。
不要误解我的意思,我知道这种方法并不理想。但是当你要搜索一个类,你要么去搜索引擎搜索,要么去 https://developer.apple.com/documentation/ 查询,选择一个框架,选择一个类别,然后再选择一个类,很显然第一种方式会更快。
重要的是:无论你选择哪种方法,结果都是一样的,最终都会出现在同一个地方,所以选择最适合你自己的搜索方式就行。现在,请你找到并选择UIImage
。
一旦你选择了你关心的类,页面就会有四个主要组成部分:概述、版本摘要、接口和关系。
概述是我前面提到的「描述一个API应该做什么以及用例指导」,我要求你选择 UIImage,因为它是文本描述的比较好的一个例子。
当这是我第一次使用的类时,尤其是最近才引入的类,我通常会阅读它的概述。 但是对于其他的类,任何我以前至少使用过一次的类,我会直接跳过它,并尝试找我想要的具体类容。 请记住一点,Apple 文档的设计目的并不是作为一种学习工具:当你有特定的目的时,它的效果才是最好的。
如果你并不总是为你所选择的Apple 平台的最新版本进行开发,那么页面右侧的「版本摘要」侧边栏就非常重要了。在这种情况下,你会看到 iOS 2.0+, tvOS 9.0+和watchOS 2.0+,这告诉我们 UIImage 这个类何时在这三个操作系统上第一次使用,并且它仍然可用,如果它被弃用(不再可用),你会看到类似 iOS 2.0-9.0 的东西。
这个页面上的真正内容,以及苹果开发框架中作为特定类主页的所有页面上的内容,都列在「主题」标题下。这将列出这个类支持的所有属性和方法,再细分为使用类别:「获取图像数据」,「获取图像大小和比例」等等。
如果你选择的类有任何自定义初始化器,它们应该总是首先显示。UIImage有很多自定义初始化器,你会看到它们都被列为签名,只是描述它期望的参数的部分。所以,你会看到这样的:
init?(named: String)
init(imageLiteralResourceName: String)
Tip:如果你看到的是Objective-C代码,确保你的语言是Swift。你可以在页面的右上角执行此操作,当重要的 iOS 测试版引入新的变化时,你也可以在此处启用 API 更改选项。
请记住,初始化器被写为 init?
而不是init
, 是有可能失败的,因为init?
返回一个可选的,以便在初始化失败时可以返回nil。
在初始化器的正下方,你有时会看到一些比较特殊的用于创建类的实例方法。这些不是Swift意义上的初始化方式,但它们确实创建了类的实例。对于UIImage,你会看到这样的东西:
class func animatedImageNamed(String, duration: TimeInterval) -> UIImage?
class func
意味着你可以调用UIImage.animatedImageNamed()
.
在初始化器之后,事情变得不那么有组织性了:你会发现属性、方法和枚举全部混合在一起。虽然你可以通过滚动页面找到你要找的东西,但我可以大胆的说,大多数人只是Cmd+F在页面上找到一些文本!
有以下三点需要注意:
UIImage
包含嵌套的枚举 ReizingMode
。UIViewController
,会有额外的文档页面和它们的方法和属性混合在一起。你看他们旁边的页面图标,都会加上一个简单的英文标题,比如「定位内容相对于安全区」。在页面底部,你会找到对应的关系,它告诉你它继承自哪个类(在本例中,它直接来自 NSObject),以及它遵循的所有协议。 当你查看协议关系更复杂的 Swift 类型时,本节会更有帮助。
你已经选择了一个框架和类,现在是时候查看一个特定的属性或方法了。 查找并选择此方法:
class func animatedResizableImageNamed(String, capInsets: UIEdgeInsets, resizingMode: UIImage.ResizingMode, duration: TimeInterval) -> UIImage?
你应该在创建专用图像对象类别中找到它。
这不是一个复杂的方法,但它确实展示了这些页面的重要部分:
class func animatedResizableImageNamed
, 然后是方法页面标题中显示的表单(animatedResizableImageNamed(_:capInsets:resizingMode:duration:))
,以及方法页面的声明部分中的表单。UIImage.ResizingMode
,你会去哪里取决于你点击的是UIImage
还是ResizingMode
。 (提示:你通常需要单击右侧的那个)UIImage
是一个旧类,它没有太多改变,所以它的文档状态很好。但是一些较新的api,以及许多不像UIKit
那样受欢迎的老API,仍然没有得到足够的文档支持。例如,来自SceneKit
的SCNAnimation
,或来自UIKit
的UITextDragPreviewRenderer
:都是在iOS 11中引入的,并且在发布 18 个月后,它们的文档中仍然包含「没有可用的概述」。
当你看到「没有可用的概述」时,你的心会沉下去,但不要放弃:让我告诉你我接下来要做什么……
尽管 Apple 的在线文档非常好,但你经常会遇到「没有可用的概述」,或者你发现没有足够的信息来回答你的问题。
康威定律指出,「设计系统的架构受制于产生这些设计的组织的沟通结构」,也就是说,如果你以某种方式工作,你也会以类似的方式设计东西。
Apple 在我们行业的独特地位使他们以一种相当不寻常的方式工作,这几乎可以肯定这与你自己公司的工作方式完全不同。他们有API审查讨论,试图研究在两种语言下API应该是什么样子,他们有专门的团队来制作文档和示例代码。
但是他们获得示例代码的门槛非常高:通常需要非常好的代码才能获得示例代码,并且要经过多层审查,例如法律问题。虽然我可以在一个小时内输出一个项目,然后直接把它作为一篇文章实时发布,但 Apple 做同样的事情要花更长的时间,他们非常重视自己的形象。如果你曾经好奇为什么Swift 官方博客上很少有文章出现,现在你知道了!
现在我说这些的原因是Apple 有一个被广泛使用的捷径:他们的工程师在他们的代码中留下注释的门槛似乎很低,这意味着你经常会在Xcode中找到有价值的信息。这些评论就像金粉一样:它们直接来自Apple 的开发者而不是他们的开发者发行团队,尽管我非常喜欢devpub,但很高兴直接从源头那里找到。
还记得我之前提到 SceneKit
的 SCNAnimation
在 Apple 的开发者网站上没有记录吗? 好吧,让我们看看 Xcode 可以显示什么:按 Shift+Cmd+O
调出 Open Quickly
菜单,确保右侧的 Swift 图标是彩色的而不是空心的,然后输入SCNAnimation
。
你将看到列出的一些选项,但你正在寻找在 SCNAnimation.h
中定义的选项。 如果你不确定,最好选择 YourClassName.h
文件。
无论如何,如果你打开SCNAnimation.h, Xcode会显示一个生成的SCNAnimation头文件的版本。因为原始版本是Objective-C,所以Xcode为Swift做了一个实时翻译,这就是 Open Quickly 框中带颜色的 Swift 标志的含义。
现在,如果你按下 Command+F 并搜索「class SCAnimation」,你会发现:
/**
SCNAnimation represents an animation that targets a specific key path.
*/
@available(iOS 11.0, *)
open class SCNAnimation : NSObject, SCNAnimationProtocol, NSCopying, NSSecureCoding {
/*!
Initializers
*/
/**
Loads and returns an animation loaded from the specified URL.
@param animationUrl The url to load.
*/
public /*not inherited*/ init(contentsOf animationUrl: URL)
而这仅仅是开始。 是的,该类及其所有内部结构都有文档,包括使用说明、默认值等。 所有这些确实应该在在线文档中,但无论出于何种原因,它仍然没有出现,所以准备好查找代码可以作为一个有用的补充。
此时,你应该能够查找你喜欢的任何代码的在线文档,并查找头文件注释以获取额外的使用说明。
但是,在你准备好面对所有 Apple 开发文档之前,你还需要了解两件事。
首先,你会经常遇到标记为「已归档」、「旧版」或「已停用」的文档,即使是相对较新的文档。 当它真的很旧时,你会看到这样的消息:「这个文档可能不代表当前开发的最佳实践,下载和其他资源的链接可能不再有效」。
尽管Apple 是世界上最大的公司之一,但它的工程和开发团队还没有达到人满为患的地步,他们不可能在更新所有内容的同时还涵盖新的 API。所以,你看到「归档」文档或类似文件时,请进行判断:如果它是Swift 的某个版本,至少你知道它是最近的,即使不是,你可能仍然会发现有很多有价值的信息。
其次,Apple 还有一些特别有价值且非常出色的文档。 这些都列在 https://developer.apple.com 的页脚中,但主要的是人机交互界面指南。 这份文档这将带你了解苹果平台应用设计的各个部分,包括用图片来说明关键点,并提供大量具体建议。 尽管此文档是构建 iOS 应用程序时要考虑的非常重要的一个文档,但令人惊讶的是,似乎很少有开发人员阅读过它!
我之前写过关于 Apple 文档问题的文章,虽然那里没有鼓励,但至少它可以帮助你在挣扎时感觉不那么孤单。
幸运的是,我有很多可能更有用的材料:
你认为阅读 Apple 文档最有效的方法是什么? 在 Twitter 上和我交流你的想法:@twostraws。
]]>2021年的年度跑步数据如下所示:
总距离 | 总跑步次数 | 时间 | 平均配速 | 平均心率 | 半马 | 10km+ | 5km+ | 晨跑 | 夜跑 |
---|---|---|---|---|---|---|---|---|---|
1026km | 172次 | 97h37min | 5‘42 | 150 | 2次 | 24次 | 136次 | 121次 | 38次 |
其中每个月的具体数据如下:
月份 | 距离 | 次数 | 平均配速 | 时间 |
---|---|---|---|---|
1月 | 81.8km | 16次 | 6‘11 | 8h26min |
2月 | 20.6km | 4次 | 5‘32 | 1h54min |
3月 | 102km | 16次 | 5’09 | 8h45min |
4月 | 100.6km | 15次 | 5‘15 | 8h48min |
5月 | 101.4km | 16次 | 5’32 | 9h20min |
6月 | 69.6km | 14次 | 5‘17 | 6h8min |
7月 | 90km | 17次 | 5’46 | 8h41min |
8月 | 73.6km | 16次 | 5‘28 | 6h43min |
9月 | 82km | 14次 | 5‘56 | 8h6min |
10月 | 92.8km | 11次 | 5’49 | 9h |
11月 | 104.7km | 19次 | 6‘13 | 10h51min |
12月 | 106.5km | 14次 | 6’07 | 10h51min |
我给自己每个月的目标是100km的跑量,其中只有5个月达成了目标,其他的月份都差了一点,2月份的跑量最低,是因为2月份我不小心意外受伤,下巴缝了六七针,被迫休息了一个月,6789四个月因为在准备换城市和换工作,所以整体的跑量相对少一点,虽然整体的数据跟预期的差了一点(1026km/1200km),但是也还算不错吧,好歹也有1026km的跑量,跟2020年对比要好上一点。
更多跑步数据见我的跑步主页:https://running.leeyom.top
2021年我调整了作息时间,将跑步时间改到了早上,从汇总数据可以看出,晨跑的次数占大头。在深圳的时候,天气比较暖和,通常是早上6点起床,然后洗漱简单收拾一下,6点半出门,晨跑的距离比较短,一般也就5km左右,时间大概控制在30min左右,回到长沙后,由于长沙的天气变化莫测,将起床的时间调到了6点30,其他的保持不变,通常情况下跑一休一,有时候状态好,可能跑二休一,如果天天跑,久而久之就会有厌跑的情绪,要学会适当的休息,给身体恢复的时间。晚上一般是11点30前睡觉,也就是保持7个小时的睡眠时间,加上中午午休的1个小时,基本上能保持一整天精神状态的饱满。周末的话,通常会跑多一点,一般会跑个10km+的距离,总的来说,一周的平均跑步次数在3-4次,这个次数不多也不少。
人类行为只有5%是受自我意识支配的。我们是习惯的造物,因而我们的行为有95%都是自动反应或对于某种需求或紧急情况的应激反应。
跑步时间这个问题,我觉得还是得根据个人的情况来,比如你像我一样,晚上没有时间,那就早上跑,如果你早上起不来,那就晚上跑,如果你早晚都没有时间,中午休息时间,也可以动一下嘛,所以这个东西,不在于你有没有时间,而在于你想不想的问题,时间它就像海绵里的水,挤挤总是会有的。
整体如下:
人们可以被物质奖励或外部激励所驱使;但是,只有在自由选择并享受事物本身的情况下人们才会表现出更多热情,从中获得更多乐趣。
2021年买了一双新的跑鞋Nike Next% 2,虽然价格略贵,但是真的非常的喜欢,现在基本上就穿着这双鞋跑步,飞马37现在还在鞋架上,洗干净其实也还是能跑的。另外换了新的手机,从iPhone X 换到iPhone 13,也算是对自己年底的奖励,因为想多记录点生活,所以我通常跑步会带上手机,跑完后会随便拍几张,发到推上记录下自己跑步的碎片时光。Apple Watch 主要用来记录跑步的心率,AirPods 用来跑步的时候听播客和音乐,Nike Run Club 无广告,跟Apple Watch搭配,用来记录跑步的数据,腰包用的是keep家的,之前用过他们家的一代腰包,这个是改进款,感觉调节的松紧带反而没有一代的好用了。
跑步确实挺无聊的,所以跑步的时候一般喜欢听播客和音乐来打发时间,2021年我主要在听这些播客和音乐:
2021年读了一本书叫做《精力管理》,对我启发很大,书中讲到人的精力由四个部分组成:体能,情感,思维和意志,其中体能是精力的底层基建,建立起体能消耗和恢复的节奏性平衡,能够确保精力储备保持在相对稳定的水平。我现在给自己的定位是,不再以减肥为目的,而是通过跑步,维持一个相对较好的精神状态,让精力维持在相对稳定的水平。跑步除了在精神层面带来的好处,在身体上也有很大的益处,2021年一整年有氧适能在都是高于平均,另外,回长沙后伙食变好了🐶,再个由于自己工种的缘故(长时间坐着),体重较之前略有提升,但是好在通过跑步,还能在可控范围内。
作家村上春树在《当我谈跑步时,我在谈些什么》说到:“希望一人独处的念头,始终不变地存于心中。所以一天跑一个小时,来确保只属于自己的沉默的时间,对我的精神健康来说,成了具有重要意义的功课。”,确实啊,当代社会太浮躁了,我们总觉得自己很忙,我们越是忙碌,越会高看自己,认为自己对他人来说不可或缺。我们无法陪伴亲人朋友,不知疲倦,没日没夜,只管四处救火,不给自己留下喘息的时间,这就是现代社会的“成功典型”,真的,偶尔可以停下来,跑步1h,灵魂可以喘口气!
最后,祝大家2022年元旦快乐!新年快乐!
]]>应朋友@凯佬
的要求,特意写一篇基于 Github Issues
博客的搭建教程,整体的过程非常简单,后端参考了 @yihong0618 的 gitblog 项目,发布 issues
,做数据备份,前端参考了@LoeiFy 的 Mirror 项目,用于做前端可视化界面,感谢二位大佬的开源精神,所有的服务全部免费(ps:如果你需要自定义域名,自定义域名需要自己付费购买),感谢 Github!
首先需要 fork 我的项目 blog,也可以 fork @yihong0618 的 gitblog 项目,都差不多,然后修改 generate_readme.yml
文件,这个文件是触发自动备份的 CI/CD 配置文件,修改如下的地方:
env:
GITHUB_NAME: superleeyom
GITHUB_EMAIL: xxx@qq.com
改成你自己的 Github 用户名和邮箱,接着在你自己的这个 blog 仓库下,创建 Environment secrets
环境变量 G_T
:
这个G_T
是 Github 的访问授权 Token,注意保密,不要泄漏,Token 的获取如下图,scope 如果不知道选啥,全部勾上:
修改 main.py
脚本,修改你自己的定制化的 README.md
的 header:
MD_HEAD = """**<p align="center">[Leeyom's Blog](https://blog.leeyom.top)</p>**
**<p align="center">用于记录一些幼稚的想法和脑残的瞬间~</p>**
## 联系方式
- Twitter:[@super_leeyom](https://twitter.com/super_leeyom)
- Telegram:[@super_leeyom](https://t.me/super_leeyom)
- Email:[leeyomwang@163.com](mailto:leeyomwang@163.com)
- Blog:[https://blog.leeyom.top](https://blog.leeyom.top)
"""
在 issues
列表,提前建好 labels
标签,后面一旦提交了新的 issues
,或者你修改了 issues
,README.md
会根据你给当前 issues
所打的 labels
标签进行分类,比如我建了如下的几个 labels
,其中TODO
和Top
,用于生成 TODO List 和置顶,比如你给这个 issues
加上了 Top
标签,那么他会出现在 README.md
置顶分类里面,这两个建议加上,剩下的按你自己的意愿加:
如果你是 fork 我的项目,建议先把 backup 文件夹下里面的 md 文件删除!因为那是我的 blog 备份文件!
有了这个项目,我们就可以通过 Github Actions
,只要有 issues
发布或者修改,都会触发自动构建,备份issues
生产 md 文件,然后刷新 README.md
文件。
后期你要发布文章,只需要创建一个 issues
,然后打好标签,点击发布即可,剩下的都是自动化构建,不需要人为参与。
首先你得先创建一个 github用户名.github.io
的仓库,必须是公共仓库,比如我的:superleeyom.github.io,然后你把我这个仓库 superleeyom.github.io 的文件全部拷贝到你刚创建的仓库里面,删除 Archive
文件夹,这个是我以前备份的 md 文件,对你没啥用!
修改 docs
目录和根目录下的两个 CNAME
文件,里面的内容是你的自定义的域名,比如我的自定义域名是:blog.leeyom.top
,如果没有自定义域名,默认填:github用户名.github.io
。
修改 docs 目录下的 index.html
文件,比如我的:
window.config = {
organization: false,// 默认是 false,如果你的项目是属于 GitHub 组织 的,请设置为 true
order: 'CREATED_AT',// 文章排序,以 创建时间 或者 更新时间,可选值 'UPDATED_AT','CREATED_AT'
title: "Leeyom's Blog",// 博客标题
user: 'superleeyom',// GitHub 用户名,必须
repository: 'blog',// GitHub 项目名,指定文章内容来源 issues,必须
authors: 'Leeyom',// 博客作者,以 ',' 分割,GitHub 用户名默认包含在内
ignores: '',// 文章忽略的 issues ID
hash: 'ghp_VkKID%Qnlgg$SXfIt!UmH&uCLCtHFU$XJHK^YmxvZy5sZWV5b20udG9w',// hash,必须
perpage: 5,// 分页数量
}
其中关于 hash 的获取,参考:「获取 hash」
去阿里云或者腾讯云,申请一个新的域名,将域名解析到你自己刚创建的 github 仓库 github用户名.github.io
,不需要自定义域名的可忽略这一步:
在 github用户名.github.io
仓库下,将 page 的 source 指向到 docs 目录,指定你的自定义域名即可,若没有自定义域名,Custom domain
可以不用设置:
最后改下你的 README.md
,里面的内容是我自己瞎写的,换成你自己的,然后等个 5 分钟,访问你的域名,比如我的是:https://blog.leeyom.top(没有自定义域名默认的是:https://github用户名.github.io
),看是否能正常访问,如果不能正常访问,请在当前 issues 下留言,我看到会回复。
虽然 Github Issues 的定位就不是为博客而生的,这也注定了它有诸多不足之处,比如无法限制别人发 issue ,但是对于那些不想折腾,内容才是王道的程序员朋友来说,免费、Markdown、代码高亮、标签、评论、图床、备份、Github大厂背书,Github Issues 也不乏是个与自己和他人沟通的好地方。
当我自己真实面对这个选择的时候,自己内心有过纠结与彷惶。说实话,我还是挺喜欢深圳的,喜欢深圳的天气,一年9个月可以穿短袖短裤,喜欢深圳的大海,夏天可以去西涌海滩游泳冲浪,喜欢深圳的交通,去哪里都可以坐公交地铁,喜欢深圳的就业环境,工作机会多。
可为啥想要离开深圳呢?
我是2016年来到深圳的,今年2021年,刚好第5个年头了,我觉得我想离开深圳的最大的一个原因是自己对房价的绝望吧。目前福田区、宝安区、南山区,稍微像样点的小区,二手房价没有低于6w+的,买一套70平两房,总价420万,首付130万,还不算税,普通人估计掏空家里的6个钱包都不够首付。房价构成了阶级的壁垒,人生的难度都不一样,自然就有人要做出选择,能留在深圳扎根的,是社会的精英阶层,当然离开也并不可耻,它是一种生活方式的选择,在认清现实和理想后,选择一条合适自己的路走也不是不行。
当我再次回忆深圳的这五年,发现自己的人生轨迹中也留下了一些痕迹:
16年6月成为社会人后找到了第一份正式工作,爬了深圳最高峰梧桐山、七娘山。
17年3月份开始跑步减肥一直坚持到现在,整个人发生了翻天覆地的变化。
17年5月去现场看了老罗锤子科技2017年的春季新品发布会。
17年7月份有幸去了一趟台湾,从台南到台北,感受了下祖国宝岛台湾的风土人情。
18年12月16号,深圳国际马拉松日,完成了人生的第一个半马。
19年10月带上了牙套,开始矫正之旅,到21年的7月,终于矫正完成。
19年11月,跑了深圳龙华区微型公益马拉松比赛,顺利完赛,非常的激动和开心。
20年花了4个半月,经历了科二和科三的两次挂科后终于拿到了驾照。
........
深圳的孤独是刻在骨子里的,在深圳,很多人耐得住工作的压力,却耐不住夜深人静的孤独。熟识的朋友一个个都离开了深圳,周末放假,独自在几平米的小单间,半夜醒来,手机上还播放着昨晚的音乐,合上手机听着老旧风扇的嗡嗡声却是愈加的孤独和疲惫。
突然想起了非常喜欢的一首歌曲,4 Non Blondes 的《What's Up》,里面的歌词恰好就映射了我现在的心境:
I try all the time, in this institution 我一直在尽力着,在这样一个笼牢里 And I pray, oh my god do I pray 我祈祷,我的上帝!我虔诚的祈祷! I pray every single day,For a revolution 我每天都在祈求,为了一个彻彻底底的改变 And so I cry sometimes,When I'm lying in bed 有时候我会躺在床上大哭 Just to get it all out,What's in my head 企图把脑袋清空 And I am,I am feeling a little peculiar 但是我总觉得会怪怪的 And so I wake in the morning,And I step outside 于是我早上醒来,走出门外 And I take a deep breath and I get real high 深深呼吸一口气,感觉很振奋 And I scream at the top of my lungs 我用力大声呼喊 What's going on? 他妈的,搞什么鬼
深圳就像一座围城;
里面的人想出去,外面的人拼命的想进来;
或许几年后,我再回来看这篇文章;
后悔?庆幸?开心?难过?
管他呢!
]]>用户在 Kubernetes 中可以部署各种容器,其中一部分是通过 HTTP、HTTPS 协议对外提供七层网络服务,另一部分是通过 TCP、UDP 协议提供四层网络服务。而 Kubernetes 定义的 Service 资源就是用来管理集群中四层网络的服务访问。
Kubernetes 的 ServiceTypes
允许指定 Service 类型,默认为 ClusterIP
类型。ServiceTypes
的可取值如下:
通过集群的内部 IP
暴露服务。当我们的服务只需要在集群内部被访问时,可以使用该类型。打个比方吧(实际生产不推荐这样做),比如你的一个服务调用另外一个服务,需要明确知道另外一个服务的ip,那这个时候,就可以为该被调用方Pod创建一个service,固定一个ip,此时这个ip只能是在这个集群内部访问,创建一个Service的时候,无论是那种的ServiceTypes
都会生成一个虚拟ip,腾讯云TKE上叫服务ip。
通过每个集群节点上的 IP
和静态端口(NodePort)暴露服务。NodePort
服务会路由到 ClusterIP
服务,该 ClusterIP
服务会自动创建。通过请求 <NodeIP>:<NodePort>
,可从集群的外部访问该 NodePort
服务。
假设现在有一个集群,集群内有四个节点,这个四个节点对外的节点ip分别是:10.21.2.10、10.21.2.11、10.21.2.12、10.21.2.13
。假设现在为某个工作负载pod 创建了service,设置主机端口为30003,那这个时候,我们可以通过任意一个节点ip+主机端口号,就可以访问该pod上的服务,比如:10.21.2.10:30003、10.21.2.11:30003 都可以访问。这种的使用场景,比如说某个pod上的服务是属于网关类型的,需要将ip+端口号配置到nginx上进行反向代理,则可以考虑使用这种方式。
也叫负载均衡器,可以向公网或者内网暴露服务。负载均衡器可以路由到 NodePort
服务,或直接转发到处于 VPC-CNI 网络条件下的容器中。这种类型的使用场景,比如内网需要和公网打通的情况下,即可通过内网ip直接访问到公网后端的pod。创建完成后的服务在集群外可通过负载均衡域名或 IP + 服务端口 访问服务,集群内可通过服务名 + 服务端口 访问服务。
腾讯云上内网和公网的打通是通过构建子网的方式,对应pod的service创建后,会生成一个ipv4地址,在内网直接ping该ipv4地址,是可以ping通的。LoadBalancer 就感觉有点像是 ClusterIP 和NodePort的超集,用这张图可以理解下:
]]>【强制】所有的 POJO 类属性必须使用包装数据类型。
数据库的查询结果可能是 null,因为自动拆箱,用基本数据类型接收有 NPE 风险。
【强制】在使用 java.util.stream.Collectors 类的 toMap()方法转为 Map 集合时,一定要注意当 value 为 null 时会抛 NPE 异常。
List<Pair<String, double>> pairArrayList = new ArrayList<>(2);
pairArrayList.add(new Pair<>("version1", 8.3));
pairArrayList.add(new Pair<>("version2", null));
// 抛出 NullPointerException 异常
pairArrayList.stream().collect(Collectors.toMap(Pair::getKey, Pair::getValue));
因为 HashMap 的 merge 方法里,若 value 为 null,则会抛 NPE:
@Override
public V merge(K key, V value,BiFunction<? super V, ? super V, ? extends V> remappingFunction) {
if (value == null)
// 抛出 NullPointerException 异常
throw new NullPointerException();
//....
}
这个问题java9
已经修复,所以也可以尝试升级jdk,或者把value为null的过滤掉就行。另外注意:如果key相同也会抛异常 ,改成如下代码相同的key就不会报异常,新key的value会替换旧key的value:
pairArrayList.stream().collect(Collectors.toMap(Pair::getKey, Pair::getValue,(v1, v2) -> v2))
【强制】使用集合转数组的方法,必须使用集合的 toArray(T[] array),传入的是类型完全一 致、长度为 0 的空数组。
List<String> list = new ArrayList<>(2);
list.add("guan");
list.add("bao");
// array 的值为:["guan","bao",null,null]
String[] array = list.toArray(new String[4]);
如果循环遍历 array 的时候,不注意,就有可能抛 NPE:
for (String s : array) {
// 抛出 NullPointerException 异常
System.out.println(s.length());
}
所以建议数组空间大小的 length 设置为0,动态创建与 list size 相同的数组,性能最好。
String[] array = list.toArray(new String[0]);
【强制】在使用 Collection 接口任何实现类的 addAll()方法时,都要对输入的集合参数进行 NPE 判断。
List<String> list = new ArrayList<>(2);
list.add("guan");
list.add("bao");
List<String> listSecond = null;
// 抛出 NullPointerException 异常
list.addAll(listSecond);
观察 ArrayList 的 addAll 方法的源码:
public Boolean addAll(Collection<? extends E> c) {
// 若c为null,这里会抛NPE
Object[] a = c.toArray();
int numNew = a.length;
ensureCapacityInternal(size + numNew);
// Increments modCount
System.arraycopy(a, 0, elementData, size, numNew);
size += numNew;
return numNew != 0;
}
所以正确的做法是,对输入参数做判空化处理:
// CollUtil.emptyIfNull(List<T> set)方法是 hutool里面的对集合null设置为empty的方法,方法内实际为:
// (null == set) ? Collections.emptyList() : set
list.addAll(CollUtil.emptyIfNull(listSecond));
【推荐】高度注意 Map 类集合 K/V 能不能存储 null 值的情况。
【强制】三目运算符(condition? 表达式 1 : 表达式 2) 中,高度注意表达式 1 和 2 在类型对齐时,可能抛出因自动拆箱导致的 NPE 异常。
Integer a = 1;
Integer b = 2;
Integer c = null;
Boolean flag = false;
// a*b的结果是int类型,那么c会强制拆箱成int类型,抛出NPE异常
Integer result = (flag ? a * b : c);
a*b
包含了算术运算,因此会触发自动拆箱过程(会调用 intValue 方法),此时a*b
的结果是 int 类型,那么c会强制拆箱成 int 类型,以下两种场景会触发类型对齐的拆箱操作:
表达式 1 或表达式 2 的值只要有一个是原始类型。
表达式 1 或表达式 2 的值的类型不一致,会强制拆箱升级成表示范围更大的那个类型。
【强制】当某一列的值全是 NULL 时,count(col)的返回结果为 0,但 sum(col)的返回结果为 NULL,因此使用 sum()时需注意 NPE 问题。
可以使用如下方式来避免 sum 的 NPE 问题:SELECT IFNULL(SUM(column), 0) FROM table;
【推荐】方法的返回值可以为 null,不强制返回空集合,或者空对象等,必须添加注释充分说 明什么情况下会返回 null 值。
防止 NPE 是调用者的责任。即使被调用方法返回空集合或者空对象,对调用者来说,也并非高枕无忧,必须考虑到远程调用失败、序列化失败、运行时异常等场景返回 null 的情况。
【强制】当 switch 括号内的变量类型为 String 并且此变量为外部参数时,必须先进行 null 判断。
public static void method(String param) {
switch(param) {
case "sth":
System.out.println("it's sth");
break;
case "null":
System.out.println("it's null");
break;
default:
System.out.println("default");
}
}
public static void main(String[] args) {
// 会报NPE异常
method(null);
}
【强制】字符串判断相等,常量一定要写在前面「这个手册上没有,我自己加的,哈哈」
String str1 = null;
String str2 = "hello world";
// 错误的写法,会抛NPE
str1.equals(str2);
// 正确写法
str2.equals(str1);
其他的可能产生 NPE 的场景:
public int f() {
Integer a = null;
// 抛出NPE异常
return a;
}
另外这种情况也会自动拆箱产生NPE:
Integer num = null;
// 抛出NPE异常
if(num > 1) {
// do someing
}
// 数据库查询用户信息
User user = userMapper.getById(123);
// user可能为null,抛出NPE异常
String userName = user.getUserName();
ApiResponse<UserInfoDTO> apiResponse = userFeign.getById(123);
// user可能为null
User user = apiResponse.getData();
if(user!=null){
String userName = user.getUserName();
}
对于 Session 中获取的数据,建议进行 NPE 检查,避免空指针。
级联调用 obj.getA().getB().getC();
一连串调用,易产生 NPE。
集合里的元素即使 isNotEmpty,取出的数据元素也可能为 null,因为 List 里面也可以存 null。
可以考虑使用 JDK8 的 Optional 类来防止 NPE 问题。
在地铁上看完的,篇幅不长,算是一个科普理财的小册子,半小时到一个小时就可以看完,整书用通俗的话语解释那些投资和理财的专业名词,风趣幽默,挺有意思,摘抄了几句印象比较深的句子,记录一下。
有些基金就只照着指数配置股票,这就叫被动型基金,也叫指数基金。
股票上涨时急着抛,下跌时却不撒手,这种行为叫作处置效应。
国家政策、利率变动,甚至自然灾害,都有可能带偏经济大环境,这就是系统性风险。
而且基金定投一般选的是指数基金,投资跟着指数走,降低了基金经理决策跑偏的风险。
货币基金是好东西,但如果它发展过速、不受约束,就可能会变成金融之癌,损害实体经济。
要求限制原先的T+0提现。如果想在T+0的货币基金平台上提取超出规定限额的钱,提取时间就要变成T+N。
限制T+0提现,既可防止挤兑风险,又能引导大家把鸡蛋放在不同的篮子里。
在金融市场上有这样一种投资方式,它原本是保证买卖正常运行,可最后却华丽转身,成为一种投机工具。 它既能让人一夜暴富,也能让人几代贫穷。
期货不是货,而是一张合约,是按标准填写的远期交易合约。
阿蛛觉得涨得差不多了,于是把期货卖了出去,这叫平仓
在实际的业务场景中,业务开发团队需要针对公司安全部门需求,针对涉及客户安全数据或者一些商业性敏感数据,如身份证号、手机号、银行卡号、客户号等个人信息,都需要进行数据脱敏。
搭建和生产环境一模一样的预发布环境,需要把生产环境的存量原文数据 加密后存储到预发环境。
目前常见的脱敏算法包括 AES 加密、K 匿名、加星、屏蔽、洗牌、全保留、格式保留、令牌化等,算法及其主要用途介绍如下:
算法 | 主要用途 |
---|---|
K 匿名 | 通过如 K-匿名的算法对原始数据加入扰动和泛化,使得脱敏后的数据无法唯一对应回原数据,用于保留部分统计信息 |
加星 | 最基础的脱敏方法,保留字段一部分信息的同时通过加星遮蔽局部信息 |
屏蔽 | 用特殊字符如星号 * 作为掩码来将字段信息屏蔽掉 |
洗牌 | 将目标字段在样本中洗牌,使得脱敏后的信息无法唯一对应回原始样本,常用于脱敏后作为测试样例或保留部分统计信息 |
全保留 | 字段全保留,不脱敏,常用于字段无需脱敏的场景,如对主键不脱敏 |
格式保留 | 保留字段的原格式的前提下将其中一部分信息进行随机化,达到脱敏的同时仍然可以提供一部分该类型的信息,常用于脱敏后作为测试样例 |
令牌化 | 通过不可逆的算法将目标字段脱敏,但保留一份令牌使得可以在必要时还原 |
AES 加密 | 一种对称加密算法,必要时可通过 AESKey 对脱敏结果进行还原 |
目前 Java 生态环境下,数据脱敏中间件这块,做的最好的应该数 ShardingSphere,引用 ShardingSphere 官方文档关于数据脱敏模块的说明:
数据脱敏模块属于ShardingSphere分布式治理这一核心功能下的子功能模块。它通过对用户输入的SQL进行解析,并依据用户提供的脱敏配置对SQL进行改写,从而实现对原文数据进行加密,并将原文数据(可选)及密文数据同时存储到底层数据库。在用户查询数据时,它又从数据库中取出密文数据,并对其解密,最终将解密后的原始数据返回给用户。
更多关于 ShardingSphere 的数据脱敏原理,可以查阅官方文档。
那如果说,我只是想简单的做一些数据清洗和隐私脱敏,有什么的好的工具类吗?那可以使用 hutool 的 DesensitizedUtil 工具类就行,例如:
String phone = "13488883888";
String encryptPhone = DesensitizedUtil.mobilePhone(phone);
// 打印:134****3888
System.out.println(encryptPhone);
在 ShardingSphere 的关于数据脱敏的场景分析里,针对已上线的历史数据,需要业务方自己进行清洗。那怎么清洗?简单说下自己的想法:
首先数据库对那些需要脱敏的列,新增额外的加密列,比如需要对 email进行脱敏,则新建额外的加密列 encrypt_email。
系统接入 sharding-jdbc,并配置好脱敏规则。
写个脚本,多线程同时遍历需要脱敏的历史数据,将明文列(email)取出,更新到加密列(encrypt_email),由于sharding-jdbc 会根据脱敏规则,对SQL进行解析、改写,最后加密列存储的其实是加密后的数据。
这里将使用 ShardingSphere 的 sharding-jdbc 组件简单的演示如何进行数据脱敏。
user 表结构,其中 email 为明文列,encrypt_email 为密文列。
CREATE TABLE `user` (
`id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`name` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '姓名',
`age` int unsigned NOT NULL COMMENT '年龄',
`email` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '明文邮箱 ',
`encrypt_email` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '' COMMENT '加密邮箱',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_email` (`email`) USING BTREE COMMENT '邮箱唯一索引'
) ENGINE=InnoDB AUTO_INCREMENT=13 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
这里只展示 sharding-jdbc 的配置部分:
spring:
# sharding-jdbc配置
shardingsphere:
# 数据源配置
datasource:
name: ds
ds:
driver-class-name: com.mysql.cj.jdbc.Driver
type: com.zaxxer.hikari.HikariDataSource
jdbc-url: jdbc:mysql://127.0.0.1:3306/shardingsphere?useUnicode=true&useSSL=true&characterEncoding=utf8
username: root
password: '123456'
max-total: 100
encrypt:
# 加密器配置
encryptors:
# 加密器的名字,这里设置为:email_encryptor
email_encryptor:
# 加密方式,内置MD5/AES
type: aes
props:
# 配置AES加密器的KEY属性
aes.key.value: 123456abc
# 脱敏表配置
tables:
# 脱敏表名
user:
columns:
# 脱敏逻辑列,真实面向用户编写SQL
email:
# 存储明文列
plainColumn: email
# 存储加密列
cipherColumn: encrypt_email
# 使用的加密器
encryptor: email_encryptor
props:
# 是否使用密文列查询
query.with.cipher.column: true
# 是否打印SQL,默认false
sql.show: true
由于使用 ShardingSphere 的数据源 datasource 后,无需再配置 Spring 自带的 datasource,另外遇到个小问题就是,如果按照 ShardingSphere 官方关于数据脱敏的 springboot配置(官网示例 properties 格式,实际也可以转成yml 格式):
url: jdbc:mysql://127.0.0.1:3306/shardingsphere?useUnicode=true&useSSL=true&characterEncoding=utf8
会报错 jdbcUrl is required with driverClassName
,换成 jdbc-url
则正常启动:
jdbc-url: jdbc:mysql://127.0.0.1:3306/shardingsphere?useUnicode=true&useSSL=true&characterEncoding=utf8
插入一条数据:
userService.save(new User("韩武江", 28, "hanwujiang@163.com"));
观察控制台打印的两条log:
# 逻辑SQL
Logic SQL: INSERT INTO user ( name,age,email ) VALUES ( ?,?,? )
# 实际SQL
Actual SQL: ds ::: INSERT INTO user ( name,age,encrypt_email, email ) VALUES (?, ?, ?, ?) ::: [韩武江, 28, dad+OyyGMYILeBGwiWHU+tmzk4uJ7CCz4mB1va9Ya1M=, hanwujiang@163.com]
再观察数据库:
加密列 encrypt_email 被 sharding-jdbc 加密存储。
查询数据,并设置 query.with.cipher.column: true
,开启密文列查询:
User user = userService.getByEmail("jianglanhe@gmail.com");
System.out.println(JSONUtil.toJsonStr(user));
观察控制台打印的log:
# 逻辑SQL
Logic SQL: SELECT id,name,age,email,encrypt_email FROM user WHERE email=?
# 实际SQL
Actual SQL: ds ::: SELECT id,name,age,encrypt_email AS email,encrypt_email FROM user WHERE encrypt_email=? ::: [dad+OyyGMYILeBGwiWHU+tmzk4uJ7CCz4mB1va9Ya1M=]
# 打印结果
{"encryptEmail":"dad+OyyGMYILeBGwiWHU+tmzk4uJ7CCz4mB1va9Ya1M=","name":"韩武江","id":13,"age":28,"email":"hanwujiang@163.com"}
若设置query.with.cipher.column: false
,关闭密文列查询:
观察控制台打印的log:
# 逻辑SQL
Logic SQL: SELECT id,name,age,email,encrypt_email FROM user WHERE email=?
# 实际SQL
Actual SQL: ds ::: SELECT id,name,age,email AS email,encrypt_email FROM user WHERE email=? ::: [hanwujiang@163.com]
# 打印结果
{"encryptEmail":"dad+OyyGMYILeBGwiWHU+tmzk4uJ7CCz4mB1va9Ya1M=","name":"韩武江","id":13,"age":28,"email":"hanwujiang@163.com"}
我原本以为在开启密文查询的情况,实际SQL都 encrypt_email AS email
了,最终返回实体里面的email应该是密文:
SELECT
id,
`name`,
age,
encrypt_email AS email,
encrypt_email
FROM
`user`
WHERE
encrypt_email = 'dad+OyyGMYILeBGwiWHU+tmzk4uJ7CCz4mB1va9Ya1M=';
但是对比可以发现,开启密文列查询配置后,实际sharding-jdbc会在中间把密文解密后返回,最终返回实体里面的email应该是解密后的明文,密文存在的还是在密文列 encrypt_email 中。
假如后期业务上线一段时间后,需要完全删除明文列,只保留密文列,在不改变代码的基础上是否可行?那直接在数据库层,把email列删除:
然后修改 sharding-jdbc 配置,去掉明文列,不改写任何其他代码层面的东西:
# 脱敏表配置
tables:
user:
columns:
email:
# plainColumn: email
cipherColumn: encrypt_email
encryptor: email_encryptor
然后查询数据:
// 为了演示,这里改用id查询,如果用email查询的话,肯定为空
User user = userService.getById(13);
System.out.println(JSONUtil.toJsonStr(user));
观察控制台,发现其实依然能正常执行,且不报错:
# 逻辑SQL
Logic SQL: SELECT id,name,age,email,encrypt_email FROM user WHERE id=?
# 实际SQL
Actual SQL: ds ::: SELECT id,name,age,encrypt_email AS email,encrypt_email FROM user WHERE id=? ::: [13]
# 打印结果
{"encryptEmail":"dad+OyyGMYILeBGwiWHU+tmzk4uJ7CCz4mB1va9Ya1M=","name":"韩武江","id":13,"age":28,"email":"dad+OyyGMYILeBGwiWHU+tmzk4uJ7CCz4mB1va9Ya1M="}
至于为什么会正常运行,因为:
因为有logicColumn存在,用户的编写SQL都面向这个虚拟列,Encrypt-JDBC就可以把这个逻辑列和底层数据表中的密文列进行映射转换
假如你删除 email列,但是配置中还配置了 plainColumn: email
,那代码执行的时候,则会报错:Cause: java.sql.SQLSyntaxErrorException: Unknown column 'email' in 'field list'
。
我个人的感觉是 sharding-jdbc 确实做到了屏蔽底层对数据的脱敏处理 ,但是要接入 sharding-jdbc 的前提是,团队有制定严格的SQL规范 ,这样可能接入数据库中间件的时候,才会出现比较少的问题,对于一些老系统,动辄几百行的SQL,各种复杂函数,还是放弃接入的好,到时候只会是一步一个坑。
另外如果想要满足文章开头的第二个需求,也就是把生产库的数据同步到预发布,同时要屏蔽部分敏感数据,大部分的云厂商,都有提供脱敏工具,比如我们自己在用的腾讯云的 DBbrain,就可以支持数据脱敏,但是实际使用还不是怎么完善,有待改进。
]]>马上再过几天,就可以拆牙套了,想记录下自己这快两年整牙的辛酸史,顺便给想矫正牙齿的朋友分享点经验。因为我小时候,门牙就不太整齐,牙齿比较拥挤,并且家里当时条件也不太好,所以就没有去医院矫正,长大后,这也就成了我的一块心病,导致向人微笑的时候,常常抿嘴,不够自信。后面参加工作后,自己有了一定的积蓄后,我便开始准备着手矫正牙齿的事情,我在 19 年的国庆节向父母提了这个事情,他们当时还是挺支持我的,只是表示你要自己能受得了这份罪就去弄,不要后悔就行,就这样,我开始了我的牙齿矫正之路。
在决定了准备矫正牙齿的时候,我所在的公司有位女同事,恰好也在带牙套,然后向她咨询了很多关于矫正相关的事情,比如医生的选择、矫正价格、矫正年龄一些疑问等等,了解了一些琐碎的细节后,我便开始了医院的选择,我去实际考察了几家医院,有私立的也有公立的,大概对比了下:
医院类型 | 价格 | 医生流动性 | 对比 |
---|---|---|---|
公立医院 | 金属自锁25000+ | 稳定 | 医生稳定,基本上会跟着走完整个治疗流程,价格透明,但是价格较贵 |
私立医院 | 金属自锁15000+,可能存在乱收费现象 | 可能离职 | 医生流动性较高,主治医生可能会离职,价格较便宜,可能会有隐形消费 |
最后经过评估,我最终还是选择去公立医院,贵就贵点吧,图个心安,最终选择的是「南方医科大学深圳口腔医院」,主治医生是林宝山医生,林医生人很 nice,没有给人太多的严肃感,就像朋友一样,言谈举止挺幽默的。第一天,首先先开始拍片,照了头部的 CT 扫描,林医生跟我大致跟我聊了下我目前牙齿的情况,问我目前的诉求,以及我的一些疑问,其实回想下,当时我关注的问题有如下几个:
牙齿矫正跟年龄其实没有太大的关系,只要牙周健康 ,年龄都没有太大的问题,四五十岁的人都有在矫正。
像我自己的话,经过医生的判断,最终需要拔掉了四颗智齿,上下左右各两颗,分了两次拔,一次两颗,拔牙那滋味,我依然清楚记得拔完牙齿后,我整个背都被汗水浸湿了,拔牙麻药消了后,真是生不如死,感觉咽口水都痛,第二天起来,脸都是肿的,总之矫正牙齿,就得做好拔牙的心里准备。
放一张当时拔牙时留下的照片:
一般来说,牙齿情况越复杂,所需时间越久。大部分矫正周期是 1~3 年,比较严重的骨性的,大概要 3年+,我这种由于只是牙齿不齐,医生大致说需要差不多两年(24 个月)时间,实际其实大概花了 21 个月,比预计的稍微要快点。
我记得回形针出了一期视频「如何科学地矫正牙齿」,里面有讲到牙套的选择,我选择的是金属自锁牙套,没有选择陶瓷和隐适美,主要是考虑价格太贵,且矫正周期太长了,最后经过医生的建议,选择带常见的金属自锁牙套。
通常一个月需要去医院复诊一次,每次复诊医生都需要调整钢丝的力度,来调整牙齿的位置。所以的话,如果要矫正,一定要选择在工作地或者上学的地方矫正,否则异地复诊是很麻烦的。
我是2019年10月20日正式戴上的牙套,刚开始戴上牙套的第一个月,异物感、口腔溃疡、托槽刮嘴、牙齿酸软无力、扎嘴、拔智齿 ,实在是太痛苦了,由于进食不太方便,整整喝了一个月的流食,那一个月瘦了好几斤。
戴上牙套后,吃东西就是个麻烦事了,吃个面条或者青菜,牙套托槽上挂了一嘴的面条和青菜,像苹果、坚果这类比较硬的东西,只能暂时说拜拜了,再个就得经常刷牙,一日三餐,不是在刷牙,就是在刷牙的路上。
大概在适应了几个月后,牙齿基本上就能和牙套友好的和解了,口腔溃疡有了很大的缓解,我也开始习惯了牙套的生活。这个时候,突然疫情就爆发了,大家纷纷都戴上了口罩,所以啊,2020 年真是矫正牙齿的大好时机啊!大家都戴着口罩,人家也看不到你有没有戴牙套啊,就能偷偷摸摸的就完成了矫正,哈哈。
矫正中期,我遇到主要三个头疼的问题:
吃东西的时候,如果不注意,咬到硬的东西,托槽掉了,这个时候,又得去医院处理,然后面临医生的拷问,所以那段时间,隔三差五的跑医院,最后吃任何东西我小心翼翼的,生怕咬掉托槽。
由于医生需要加力调整牙齿的移动,每次复诊完,牙齿都是酸软无力,啥也咬不动,那两三天,就只能吃流食。
调整咬合的时候,牙齿垫高后,咬合面积的减小,这个时候吃东西又是个麻烦事了,没办法,咀嚼不了,只能生吞。
到了矫正后期的话,基本上就是微调,比如调中线,调咬合,这个时候,牙齿基本上都已经排齐,托槽基本上也不会掉了,就感觉,牙套跟口腔已经融为一体了,已经完全适应了它的存在,自己也不太在意别人对自己牙齿的看法,总是露出这一口钢牙对别人傻笑。当然了,矫正后期,大家可能都会遇到一个问题,就是「牙套脸 」,造成牙套脸的原因如下:
在牙齿矫正期间,饮食方面的改变,通常选择吃一些容易咀嚼的食物。这样会带来咀嚼肌运动减弱,长时间可能会造成咀嚼肌群萎缩。
如果已经出现了所谓的「牙套脸」也不用太担心,一般咀嚼功能恢复后,面部的状态也会逐渐的恢复起来 ,但是也不一定百分百的恢复,毕竟还要考虑到年龄增长带来的变化。
矫正牙齿我觉得是我人生中比较重要的一件事情 ,虽然过程确实挺痛苦的,但是能带来一口整齐的牙齿和灿烂的笑容,我觉得挺值的。人类的可塑性和适应能力是很强的,哪怕是根深蒂固的牙齿,依然借助外力也可以矫正过来。所以如果你有矫正的想法,现在就开始行动吧!因为:
]]>之前有看到 @yihong 发表的《程序员跑步指南》博客,突然也想写写最近这几年自己跑步的一些感悟,一直拖到现在,因为这只是我作为一名业余跑步爱好者的一些经验和感悟,这些建议可能缺乏专业性,毕竟每个人所处的环境,身体状况都不一样,所以仅供参考哈。
距离 | 最好成绩 |
---|---|
5km | 21m |
10km | 45m |
半马 | 1h50m |
全马 | 暂时还没跑过 |
今年是我今年跑步的第五个年头了,总跑量应该有 5000km+ 了吧,我是从 2017 年正式开始跑步,当时最重的时候应该有 90kg+,变胖带来的烦恼就是,每次去商场买衣服,都只能买xxxl号的,并且任何衣服上身后,那个肚子实在太明显了,但凡有段时间没见到我的人,见到我的第一句话都是:“你咋这么胖了”,所以至此开始,便下定决心开始减肥,开启了我的跑步之旅🏃。
我真切感受到的好处,绝不骗人:
减重 20kg,真是太爽了,现在吃啥都没负担
释放多巴胺,调节情绪,享受内心短暂的平静,尤其是在 996 这种压抑环境下
减掉多余脂肪,让身材变苗条,穿衣更好看,现在可以穿 M/L 的码了
增强免疫力,提高身体素质,普通感冒不吃药也能自愈,脂肪肝也没有了
增强心肺功能,上楼再也不喘了
其实也就两个选择,晨跑or夜跑,我自己的话,比较喜欢早上跑,所以晨跑居多,偶尔夜跑,一般尽量保持“跑一休一”的状态,也就是一周保持3到4次的跑步,每次大概在5km~10km,偶尔周末会跑10km以上,晨跑的话,一般早上5:30的闹钟,洗漱30分钟,然后6点出门前往跑步的地方。到底是晨跑好还是夜跑好,这个因人而异,选择自己合适的就好。
我在开始跑步前期,撒腿就跑,什么热身,拉伸都是不存在的,有点儿急功近利,那带来的自然就是伤痛了。所以不要嫌跑前热身/跑后拉伸的那15分钟浪费时间,这十多分钟,可以让我们远离伤痛。
我比较推荐 Nike Training 上的 「跑者放松」和「跑者热身」两个课程,跑前和跑后可以跟着做一做。
我就讲讲我自己吧,我有两个方法:
『奖励机制』:比如周末有时候当天跑了10km,那今天就奖励自己去吃顿汉堡王,或者跑步坚持一年了,给自己奖励个 Apple Watch 手表等等,这种奖励能让我的跑步有更多的成就感。
『目标制』:我目前给自己定了的是每个月100km的跑量,其实大概算一下,如果按每次跑5km~10km的距离的话,大概一个月有15次的运动机会,也就是一个月有一半时间是锻炼状态,一般时间是休息状态,我感觉这这种大多数人都能接受吧?不用为每天都跑,而造成心理上的压力,有时候完不成也没事,尽量朝着这个目标努力靠近就行。
另外有这么种情况,有时候经常跑一个地方,跑多了会觉得无聊,至少我是这么觉得的,所以我的办法是,有时候在一个地方跑的无聊了,偶尔换个地方跑,今天操场不想跑,就去河道跑,或者干脆不设置目的地,跑到哪里算哪里,久而久之,方圆 10 公里基本上被我摸透了。
啥钱都可以省,跑鞋的钱咱不能省,毕竟脚是最先个地面接触的,一双好的跑鞋,能很大程度上减少膝盖受伤,同时也能提高跑步成绩。跑鞋的话,目前只穿过 Nike 的飞马系列,其他的品牌没穿过,就不多做啥评价了,不得不说,Nike 家的飞马系列,真的是日常训练的经典跑鞋,跑步小白,不知道买啥跑鞋,可以试试飞马系列,应该不会差多少,如果要跑竞速的话,可以考虑买 Next% 系列,我的下一双鞋就准备打算买 Nike ZoomX Vaporfly Next%,更多 Nike 的跑鞋可以参考网友整理的「Nike 跑鞋矩阵」。
关于手表,有时候我的朋友问我:“最近想买个手表用来跑步,有啥推荐的吗?”,我的一般回答是:“你先坚持跑个一个月试试,如果能坚持下来,再考虑要不要买个手表。” 因为大部分人买手表都是一时冲动,买了后过几天,便束之高阁。买手表有用吗?肯定有用,监控心率,不用带手机,在线听音乐等等。我目前自己在用的是 Apple Watch 4 蜂窝版,除了续航,几乎没有啥太大的短板,使用 iPhone 的朋友可以考虑购买。
我跑步前期用的是「咕咚」和「keep」,但是越到后面,这两个 App 广告越来越多,卖货,短视频,直播,我就想跑个步,搞这么多乱七八糟的东西,之前舍不得换软件,是因为上面有太多的跑步数据舍不得,也是机缘巧合,在 V 站上遇到了 @yihong 在宣传他的开源项目 running_page,在 @yihong 的热心帮助下,终于把自己的跑步数据拿到手了,当时还写了篇文章:《咕咚和keep跑步数据导入Nike Run Club》记录了下,有这个需求的朋友可以试试看。
后面就彻底换成 Nike Run Club,就凭它免费、无广告,这两点,足以让我用下去了,整体的这大半年使用下来,感觉还挺不错的,完美配合 Apple Watch。
其实感觉如果能 10km 进 1 小时的话,我觉得半马应该没啥太大问题,当然如果有更高的追求,跑全马的话,可能就需要系统的训练,引用推友@gdp8 的话:
成绩提升是个系统工程: 跑量、核心、减重、控制饮食、避免受伤等等,如果很难做到,自己跑得开心就好。
我有幸跑过一次马拉松,哇,那现场氛围,太值了,大家一起奋力奔跑的感觉真好!如果可以,请一定要参加一场马拉松试试!不在乎成绩,只在乎过程!
所以,你为啥要跑步?我的答案是:
]]>明明这么痛苦,这么难过,为什么就是不能放弃跑步?因为全身细胞都在蠢蠢欲动,想要感受强风迎面吹拂的滋味。––《强风吹拂》
首先提前将需要安装的字体拷贝到 Jenkins 所在的机器上(/media/front/
目录下)
cd /media/front/
ls
simhei.ttf simsun.ttc
修改 Jenkins 的自动化配置,这里只展示核心的部分 shell 脚本片段:
# 进入项目编译后的target目录
cd /home/jenkins/data/soft/model/${MODEL_NAME}${BUILD_ID}/${MODEL_NAME}/target
# 创建临时目录,并拷贝字体到临时目录
mkdir front
cp -r /media/front/* front/
...
...
# 安装字体
RUN apk add --update ttf-dejavu fontconfig \
&& rm -rf /var/cache/apk/* \
WORKDIR /usr/share/fonts/
COPY front/* /usr/share/fonts/
WORKDIR /
解析下 shell 脚本,其中安装字体那块,是构建 Docker 镜像的部分 Dockerfile 命令,下面解析这几句命令:
RUN apk add --update ttf-dejavu fontconfig \
&& rm -rf /var/cache/apk/* \
因为我们使用的基础镜像是FROM retail-harbor.aqara.com/retail/apline-jdk-iptables:v0.0.1
,基于Linux 发行版Alpine
,所以安装软件的指令是 apk
,类似于 CentOS 的 yum
,Ubuntu 的 apt-get
。
由于安装字体需要安装软件fontconfig
,所以需要执行apk add --update ttf-dejavu fontconfig
,为了减少镜像的大小,需要删除安装后的缓存,执行rm -rf /var/cache/apk/*
,fontconfig
安装完成后,会自动在/usr/share/
下创建两个目录,分别是fontconfig
和fonts
目录,接下来要做的就是把物理机的字体,拷贝到镜像的fronts
目录中去。
为了保持
Dockerfile
文件的可读性,可理解性,以及可维护性,建议将长的或复杂的RUN
指令用反斜杠\
分割成多行,参考文档。
踩了一个坑就是,始终无法将本地的字体文件拷贝到镜像中去,镜像的定制实际上就是定制每一层所添加的配置、文件,每一个 RUN
的行为都会建立一层新的镜像,所以如果我没有指定工作目录 WORKDIR
的话,实际拷贝的时候,是找不到 /usr/share/fonts/
这个路径。
使用
WORKDIR
指令可以来指定工作目录(或者称为当前目录),以后各层的当前目录就被改为指定的目录,如该目录不存在,WORKDIR
会帮你建立目录,更多有关WORKDIR
命令,参考文档。
WORKDIR /usr/share/fonts/
COPY front/* /usr/share/fonts/
# 拷贝完字体后,将工作目录切回根目录,因为接下来是要执行根目录下shell脚本entrypoint.sh
WORKDIR /
USER root
ENTRYPOINT ["sh","entrypoint.sh" ]
这里需要注意,COPY
这类指令中的源文件的路径都是相对路径,比如COPY front/* /usr/share/fonts/
,这个 front
目录就是相对路径,因为我们此时上下文路径就是项目编译后的target目录:
# 进入项目编译后的target目录
cd /home/jenkins/data/soft/model/${MODEL_NAME}${BUILD_ID}/${MODEL_NAME}/target
作者:银行螺丝钉;数量:33个笔记;时间:2021-04-19 22:17:00
目前公司的服务是用 Spring Cloud 框架,且服务采用 k8s 进行部署,但是有新的服务需要升级的时候,虽然采用目前采用的滚动更新的方式,但是由于服务注册到 Eureka 上去的时候,会有30秒到1分钟左右不等的真空时间,这段时间会造成线上服务短时间的不能访问,所以在服务升级的时候,让服务能平滑升级达到用户无感的效果这是非常有必要的。
在 Spring Cloud 的服务中,用户访问的一般都是网关(Gateway 或 Zuul),通过网关进行一次中转再去访问内部的服务,但是通过网关访问内部服务时需要一个过程,一般流程是这样的:服务启动好了后会先将自己注册信息(服务名->ip:端口)注册(上报)到 Eureka 注册中心,以便其他服务能访问到它,然后其他服务会定时访问(轮询 fetch 的默认时间间隔是 30s )注册中心以获取到 Eureka 中最新的服务注册列表。
那么通过k8s按照滚动更新新的方式来更新服务的话,就可能出现这样的情况:
在 T 时刻,serverA_1(老服务)已经 down 了,serverA_2(新服务)已经启动好,并已注册到了 eureka 中,但是对于 gateway 中缓存的注册列表中存在的仍是 serverA_1(老服务)的注册信息,那么此时用户去访问 serverA 就会报错的,因为serverA_1 所在的容器都已经 stop 了。
eureka:
client:
# 表示eureka client间隔多久去拉取服务注册信息,默认为30秒
registryFetchIntervalSeconds: 5
ribbon:
# ribbon本地服务列表刷新间隔时间,默认为30秒
ServerListRefreshInterval: 5000
eureka:
server:
# eureka server清理无效节点的时间间隔,默认60秒
eviction-interval-timer-in-ms: 5000
# eureka server刷新readCacheMap(二级缓存)的时间,默认时间30秒
response-cache-update-interval-ms: 5000
以上两个优化主要是缩短服务上线下线的时候,尽可能快的刷新 eureka client 端和 server 端服务注册列表的缓存。
因为我们用的是 zuul 网关,开启重试机制,防止在滚动更新的时候,由于网关层服务注册列表的缓存,将请求打到已下线的节点,zuul 请求失败后,会自动重试一次,重试其他可用节点,不至于直接报错给用户:
ribbon:
# 同一实例最大重试次数,不包括首次调用
MaxAutoRetries: 0
# 重试其他实例的最大重试次数,不包括首次所选的server
MaxAutoRetriesNextServer: 1
# 是否所有操作都进行重试
OkToRetryOnAllOperations: false
zuul:
# 开启Zuul重试功能
retryable: true
关于 OkToRetryOnAllOperations 属性,默认值是 false,只有在请求是 GET 的时候会重试,如果设置为 true的话,这样设置之后所有的类型的方法(GET、POST、PUT、DELETE等)都会进行重试,server 端需要保证接口的幂等性,例如发生 read timeout 时,若接口不是幂等的,则可能会造成脏数据,这个是需要注意的点!
利用k8s的容器回调 PreStop 钩子,在容器被stop终止之前,将需要被 down 掉的服务主动从注册中心进行移除,针对容器,有两种类型的回调处理程序可供实现:
Exec - 在容器的 cgroups 和名称空间中执行特定的命令,命令所消耗的资源计入容器的资源消耗。
lifecycle:
preStop:
exec:
command:
- bash
- -c
- 'curl -X "POST" "http://127.0.0.1:9401/ticket/actuator/service-registry?status=DOWN" -H "Content-Type: application/vnd.spring-boot.actuator.v2+json;charset=UTF-8";sleep 90'
同时指定一下 k8s 优雅终止宽限期:terminationGracePeriodSeconds: 90
,command 配置中添加了一个 sleep 时间,主要是作为服务停止的缓冲时间,解决可能有部分的请求存在未处理完成,就被停止的问题。这里采用的是 Eurek Client 自带的强制下线接口,这里需要注意的是,此方式需要服务引入spring-boot-starter-actuator
组件,要求该服务对/actuator/service-registry
加入白名单,同时基础镜像得安装 curl
命令才行。
HTTP - 对容器上的特定端点执行 HTTP 请求。
lifecycle:
preStop:
httpGet:
path: /eureka/stop/client
port: 8080
用 http 的方式,则需要我们在每个服务的里面,在代码层面将当前服务主动从注册中心进行移除:
@RestController
public class EurekaShutdownController {
@Autowired
private EurekaClient eurekaClient;
@GetMapping("/eureka/stop/client")
public ResultDto stopEurekaClient() {
eurekaClient.shutdown();
return new ResultDto(Consts.ErrCode.SUCCESS, "服务下线成功!");
}
}
需要注意的是,如果该服务需有黑白名单,记得要把/eureka/stop/client
加入白名单,如果有的服务有设置 context-path,注意需要加前缀,否则被拦截,就没有什么作用了。
在服务的 k8s 的 deployment 配置文件中添加 redainessProbe 和 livenessProbe,但是这两个有什么区别呢?
LivenessProbe(存活探针):存活探针主要作用是,用指定的方式进入容器检测容器中的应用是否正常运行,如果检测失败,则认为容器不健康,那么 Kubelet
将根据 Pod
中设置的 restartPolicy
(重启策略)来判断,Pod 是否要进行重启操作,如果容器配置中没有配置 livenessProbe
存活探针,Kubelet
将认为存活探针探测一直为成功状态。
livenessProbe:
initialDelaySeconds: 35
periodSeconds: 5
timeoutSeconds: 10
httpGet:
scheme: HTTP
port: 8081
path: /actuator/health
上面 Pod 中启动的容器是一个 SpringBoot 应用,其中引用了 Actuator
组件,提供了 /actuator/health
健康检查地址,存活探针可以使用 HTTPGet
方式向服务发起请求,请求 8081
端口的 /actuator/health
路径来进行存活判断。
ReadinessProbe(就绪探针):用于判断容器中应用是否启动完成,当探测成功后才使 Pod 对外提供网络访问,设置容器 Ready
状态为 true
,如果探测失败,则设置容器的 Ready
状态为 false
。对于被 Service 管理的 Pod,Service
与 Pod
、EndPoint
的关联关系也将基于 Pod 是否为 Ready
状态进行设置,如果 Pod 运行过程中 Ready
状态变为 false
,则系统自动从 Service
关联的 EndPoint
列表中移除,如果 Pod 恢复为 Ready
状态。将再会被加回 Endpoint
列表。通过这种机制就能防止将流量转发到不可用的 Pod 上。
readinessProbe:
initialDelaySeconds: 30
periodSeconds: 10
httpGet:
scheme: HTTP
port: 8081
path: /actuator/health
periodSeconds
参数表示探针每隔多久检测一次,这里设置为 10s,参数 initialDelaySeconds
代表首次探针的延迟时间,这里的 30 就是指待 pod 启动好了后,再等待 30 秒再进行存活性检测,跟存活指针一样,使用 HTTPGet
方式向服务发起请求,请求 8081
端口(不同的服务端口可能不一样,按照实际端口进行修改)的 /actuator/health
(如果有的服务有设置 context-path,注意需要加前缀)路径来进行存活判断,若请求成功,代表服务已就绪,这样配置的话就会达到新的服务启动好了30秒后 k8s 才会让旧服务 down 掉,而30秒后,经过优化 Eureka 配置后,基本上所有的服务都已经从 Eureka 获取到了新服务的注册信息了。
这里在实际操作的时候,LivenessProbe
的 initialDelaySeconds
的值通常要大于 ReadinessProbe
的 initialDelaySeconds
的值,否则 pod 节点会起不起来,因为此时 pod 还没有就绪,存活指针就去探测的话,肯定是会失败的,这时候 k8s 会认为此 pod 已经不存活,就会把 pod 销毁重建。
首先先明确旧 Pod 是怎么下线的,如果是 linux 系统,会默认执行kill -15
的命令,通知 web 应用停止,最后 Pod 删除。那什么叫优雅停机?他的作用是什么?简单说就是,在对应用进程发送停止指令之后,能保证正在执行的业务操作不受影响。应用接收到停止指令之后的步骤应该是,停止接收访问请求,等待已经接收到的请求处理完成,并能成功返回,这时才真正停止应用。SpringBoot 2.3
目前已支持了优雅停机,当使用server.shutdown=graceful
启用时,在 web 容器关闭时,web 服务器将不再接收新请求,并将等待活动请求完成的缓冲期。但是我们公司使用的 SpringBoot 版本为 2.1.5.RELEASE
,需要通过编写部分额外的代码去实现优雅停机,根据 web 容器的不同,有分为 tomcat
和 undertow
的解决方案:
/**
* 优雅关闭 Spring Boot tomcat
*/
@Slf4j
@Component
public class GracefulShutdownTomcat implements TomcatConnectorCustomizer, ApplicationListener<ContextClosedEvent> {
private volatile Connector connector;
private final int waitTime = 30;
@Override
public void customize(Connector connector) {
this.connector = connector;
}
@Override
public void onApplicationEvent(ContextClosedEvent contextClosedEvent) {
this.connector.pause();
Executor executor = this.connector.getProtocolHandler().getExecutor();
if (executor instanceof ThreadPoolExecutor) {
try {
ThreadPoolExecutor threadPoolExecutor = (ThreadPoolExecutor) executor;
threadPoolExecutor.shutdown();
if (!threadPoolExecutor.awaitTermination(waitTime, TimeUnit.SECONDS)) {
log.warn("Tomcat thread pool did not shut down gracefully within " + waitTime + " seconds. Proceeding with forceful shutdown");
}
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
}
}
}
@EnableDiscoveryClient
@SpringBootApplication
public class ShutdownApplication {
public static void main(String[] args) {
SpringApplication.run(ShutdownApplication.class, args);
}
@Autowired
private GracefulShutdownTomcat gracefulShutdownTomcat;
@Bean
public ServletWebServerFactory servletContainer() {
TomcatServletWebServerFactory tomcat = new TomcatServletWebServerFactory();
tomcat.addConnectorCustomizers(gracefulShutdownTomcat);
return tomcat;
}
}
/**
* 优雅关闭 Spring Boot undertow
*/
@Component
public class GracefulShutdownUndertow implements ApplicationListener<ContextClosedEvent> {
@Autowired
private GracefulShutdownUndertowWrapper gracefulShutdownUndertowWrapper;
@Autowired
private ServletWebServerApplicationContext context;
@Override
public void onApplicationEvent(ContextClosedEvent contextClosedEvent) {
gracefulShutdownUndertowWrapper.getGracefulShutdownHandler().shutdown();
try {
UndertowServletWebServer webServer = (UndertowServletWebServer)context.getWebServer();
Field field = webServer.getClass().getDeclaredField("undertow");
field.setAccessible(true);
Undertow undertow = (Undertow) field.get(webServer);
List<Undertow.ListenerInfo> listenerInfo = undertow.getListenerInfo();
Undertow.ListenerInfo listener = listenerInfo.get(0);
ConnectorStatistics connectorStatistics = listener.getConnectorStatistics();
while (connectorStatistics.getActiveConnections() > 0){}
} catch (Exception e) {
// Application Shutdown
}
}
}
@Component
public class GracefulShutdownUndertowWrapper implements HandlerWrapper {
private GracefulShutdownHandler gracefulShutdownHandler;
@Override
public HttpHandler wrap(HttpHandler handler) {
if(gracefulShutdownHandler == null) {
this.gracefulShutdownHandler = new GracefulShutdownHandler(handler);
}
return gracefulShutdownHandler;
}
public GracefulShutdownHandler getGracefulShutdownHandler() {
return gracefulShutdownHandler;
}
}
public class UnipayProviderApplication {
public static void main(String[] args) {
SpringApplication.run(UnipayProviderApplication.class);
}
@Autowired
private GracefulShutdownUndertowWrapper gracefulShutdownUndertowWrapper;
@Bean
public UndertowServletWebServerFactory servletWebServerFactory() {
UndertowServletWebServerFactory factory = new UndertowServletWebServerFactory();
factory.addDeploymentInfoCustomizers(deploymentInfo -> deploymentInfo.addOuterHandlerChainWrapper(gracefulShutdownUndertowWrapper));
factory.addBuilderCustomizers(builder -> builder.setServerOption(UndertowOptions.ENABLE_STATISTICS, true));
return factory;
}
}
ok,经过以上的优化后,基本上就能做到在用户无感知的情况下,进行滚动更新。
最近在帮同事优化一个慢查询,这张主表的数量在 100w+,它具体的问题就是,查询条件非常多,大约有 30 多个可选的查询条件,这些查询的字段分散在数据库的各个表中,导致 left join
的表特别多,大约 left join
七八张表,这种情况下分页查询,查询时间在 5~6 秒,非常的影响查询体验。
-- 示例伪 sql
select o.id
t1.name,
t2.name,
...
from order o
left join table_1 t1
left join table_2 t2
left join table_3 t3
left join table_4 t4
left join table_5 t5
...
where o.id = 1323
and t1.id = 2323
and t2.name = 'xxx'
...
通常情况下,用户使用的查询条件只会有两到三个,所以就可以根据用户实际的查询条件,动态的 left join
相关的表,比如 mybatis 里可以这样编写:
<if test="(ticketWebQueryDto.snCode != null and ticketWebQueryDto.snCode != '')">
left join ticket_product_detail tpd on t.ticket_id = tpd.ticket_id
</if>
<if test="ticketWebQueryDto.pickingUserName != null and ticketWebQueryDto.pickingUserName != ''">
left join ticket_picking tp on tp.ticket_id = t.ticket_id
</if>
这样一来,left join 的表就可以减少不少。
select id from xxx
直接使用 index 里面的值就返回结果的。但是一旦用了 select *
,就会有其他列需要读取,这时在读完 index 以后还需要去读 data 才会返回结果。这两种处理方式性能差异非常大,特别是返回行数比较多,并且读数据需要 IO 的时候,可能会有几十上百倍的差异。主查询只返回主表 id 情况下,充分利用索引的优势,通常我们的主表存放的都是其他表的 id 字段,但是页面展示的都是 name,这时候如果我们为了省事,一次性将所需要的字段的 name select
出来,势必会降低查询效率,增加回表的次数,降低索引的命中率,所以大数据量情况下,将查询分散到应用层面,而非数据库层面,整体的效率会提升很大。
-- 示例伪 sql
select o.id
from order o
left join table_1 t1
left join table_2 t2
left join table_3 t3
left join table_4 t4
left join table_5 t5
...
where o.id = 1323
and t1.id = 2323
and t2.name = 'xxx'
...
核心思路就是,将多表关联查询,拆解为多个单表查询,然后在进行数据整合,由于是分页查询,所以主键 id 数量肯定是有限制的,通常是 10~20 个,所以在代码层面,我们批量查询主表(单表查询):
// 以下代码均为伪代码,只讲解下思路
// 分页联合查询,但是 select 的 column 只有主表的主键 id
IPage<TicketWebListDto> page = ticketMapper.listByPage(pageParam, ticketWebQueryDto);
List<BigInteger> ticketIdList = list.stream().map(TicketWebListDto::getTicketId).collect(Collectors.toList());
// 批量单表查询主表
List<Ticket> ticketPageList = ticketService.getTicketPageListByTicketIds(ticketIdList);
Map<BigInteger, Ticket> ticketMap = ticketPageList.stream().collect(Collectors.toMap(Ticket::getTicketId, ticket -> ticket));
收集其他需要 left join 表的主键id,并进行多次单表查询:
// 收集其他表的主键id
List<String> workerIdList = new ArrayList<>(ticketPageList.size());
List<String> providerIdList = new ArrayList<>(ticketPageList.size());
List<String> customerAddressIdList = new ArrayList<>(ticketPageList.size());
for (Ticket ticket : ticketPageList) {
workerIdList.add(ticket.getWorkerId());
providerIdList.add(ticket.getProviderId());
customerAddressIdList.add(ticket.getCustomerAddressId());
}
// 单表查询1
List<ProviderWorker> providerWorkerList = CollUtil.emptyIfNull(providerWorkerService.getWorkerInfoByWorkerIds(workerIdList));
// 单表查询2
List<ProviderObj> providerObjList =CollUtil.emptyIfNull(providerObjService.getProviderInfoByProviderIds(providerIdList));
// 单表查询3
List<CustomerInfoVO> addressList = CollUtil.emptyIfNull(customerRecvAddressService.getCustomerInfoByAddressIds(customerAddressIdList));
将查询到的单表数据进行内存映射,构建k-v键值对,key 是单表主键id,value 就是我们查询到的数据,这一步的目的是为了接下来循环的构建返回前端视图层 VO 的时候,直接就可以从内存里面获取我们的单表数据:
Map<String, ProviderWorker> workerMap = providerWorkerList.stream().collect(Collectors.toMap(ProviderWorker::getWorkerId, providerWorker -> providerWorker));
Map<String, ProviderObj> providerObjMap = providerObjList.stream().collect(Collectors.toMap(ProviderObj::getProviderId, providerObj -> providerObj));
Map<String, CustomerInfoVO> customerInfoVOMap = addressList.stream().collect(Collectors.toMap(CustomerInfoVO::getAddressId, customerInfoVO -> customerInfoVO));
循环遍历,开始构建视图层 VO :
// 构建视图层
List<TicketWebListDto> list = page.getRecords();
list.forEach(t ->{
// 从内存中获取构建的数据
Ticket ticketPage = ticketMap.get(t.getTicketId());
ProviderObj providerObj = ObjectUtil.defaultIfNull(providerObjMap.get(ticketPage.getProviderId()), new ProviderObj());
ProviderWorker providerWorker = ObjectUtil.defaultIfNull(workerMap.get(ticketPage.getWorkerId()), new ProviderWorker());
CustomerInfoVO customerInfoVO = ObjectUtil.defaultIfNull(customerInfoVOMap.get(ticketPage.getCustomerAddressId()), new CustomerInfoVO());
// 设置value
t.setProviderName(providerObj.getProviderName());
t.setWorkerName(providerWorker.getWorkerName());
t.setCustomerName(customerInfoVO.getName());
// ...
});
return page;
世上只有一种英雄主义,就是在认清了生活真相后依然热爱生活。
真的特别推荐阅读!
作者:麦家;数量:39个笔记;时间:2021-03-20 11:23:07
对应的 Spring Boot 后台服务,如果增加了 server.servlet.context-path
配置,则会指定项目路径,是构成 url 地址的一部分,比如,在没有加此配置前,我们获取用户列表接口是这样访问:
http://127.0.0.1:8090/user/list
设定项目路径,server.servlet.context-path=demo
,则用户访问接口路径变为:
http://127.0.0.1:8090/demo/user/list
中文名称叫「普罗米修斯」,普罗米修斯主要用于事件监控和警告,它可以和 Spring Boot 的子项目 Spring Boot Actuator 进行整合,它为应用提供了强大的监控能力,目前网上有很多的整合的示例,本文不在这里细讲了:
在 SkyWalking 上监控到,有很多服务的普罗米修斯监控请求,出现了 404:
后面经过排查,就是由于应用设置了 context-path
的原因造成的,由于普罗米修斯监控站点走的是
http://${host}:${port}/actuator/prometheus
这种 url,但是实际我们的服务都是加了context-path
,也就是
http://${host}:${port}/${context-path}/actuator/prometheus
,就导致普罗米修斯在 fetch 的时候,直接404,无法获取监控信息。
由于 prometheus 是通过 Eureka 发现服务的,观察 prometheus 的配置文件 prometheus.yml
:
scrape_configs:
- job_name: 'eureka-prometheus'
# 采集的路径
metrics_path: '/actuator/prometheus'
# eureka 注册中心地址
eureka_sd_configs:
- server: http://192.168.100.93:8761/eureka
由于后台服务都是注册在 Eureka 上的,比如我们查看某个服务在 Eureka 上的注册信息,浏览器访问:http://192.168.100.93:8761/eureka/apps/${application-name}
,例如这个服务返回的注册信息:
可以看出我们并没有将服务的指标路径(抓取路径)写入到 Eureka 的元数据(metadata) 中,所以 prometheus 最终发起的获取监控信息请求是http://ip:port+metrics_path:
,比如:http://10.233.99.10:9425/actuator/prometheus
,那假设这个服务没有设置 context-path
,它肯定是可以正常返回监控信息:
如果设置了 context-path
,它最终依旧还是以 http://10.233.99.10:9425/actuator/prometheus
去访问,那肯定就会提示 404 了。
加了server.servlet.context-path
以后,抓取的路径就不再是 http://10.233.99.10:9425/actuator/prometheus
了,而是变成了 http://10.233.99.10:9425/inventory/actuator/prometheus
了。之前我们 prometheus.yml
文件里静态配置抓取目标的 metrics_path
是/actuator/prometheus
,但是现在不能这样写了,因为加了应用上下文路径,而且每个服务都不一样,所以为了能够根据各服务动态自定义指标路径,需要如下处理:
在服务的application.yml
文件里,增加如下的配置:
eureka:
instance:
metadata-map:
"prometheus.scrape": "true"
"prometheus.path": "${server.servlet.context-path}/actuator/prometheus"
"prometheus.port": "${server.port}"
prometheus 是通过 Eureka 发现服务的,因此只有将服务的指标路径(抓取地址)写到 Eureka 里,prometheus 才能拿到,换言之,只有服务在注册的时候,将自己暴露的端点(endpoint)以元数据的方式写到 Eureka 中, prometheus 才能正确的从目标抓取数据。
修改 prometheus.yml
,去掉指定的metrics_path
, 改为通过 Eureka 获取抓取目标:
scrape_configs:
- job_name: 'eureka-prometheus'
eureka_sd_configs:
- server: http://192.168.100.93:8761/eureka
relabel_configs:
- source_labels: [__meta_eureka_app_instance_metadata_prometheus_path]
action: replace
target_label: __metrics_path__
regex: (.+)
重启对应的后台服务,不出意外,prometheus 就能正常的获取监控信息了。
作者:阿图·葛文德;数量:23个笔记;时间:2021-03-10 10:12:36
假设有两个小组负责维护两个组件,example-service
和 example-ui
,这两个组件不在同一个代码仓库,example-service
的版本号信息:
<artifactId>example-service</artifactId>
<version>1.0</version>
<packaging>jar</packaging>
其中 example-ui
项目依赖于 example-service
:
<dependency>
<groupId>com.xxx.yyy</groupId>
<artifactId>example-service</artifactId>
<version>1.0</version>
</dependency>
而这两个项目每天都会构建多次,我们知道,maven 的依赖管理是基于版本管理的,对于发布状态的 artifact,如果版本号相同,即使我们内部的镜像服务器上的组件比本地新,maven 也不会主动下载的。 假如 example-service
增加了一些新的功能,这时候就得升级 example-service
的版本号,然后 deploy 到 maven 私服上去,由于升级了 example-service
的版本号为 1.1,example-ui 由于是依赖方,开发阶段,它想要使用example-service
的新功能,则要跟着把 example-service
的版本号到 1.1,如果example-service
更新的很频繁,每次构建你都要升级 example-service
的版本,效率就非常低。
那引入 SNAPSHOT
和 RELEASE
版本控制,这两种版本是分别在不同的 maven 仓库,前者是快照版本,用于开发环境,后者是稳定正式版本,用于生产环境,那在开发阶段,我们需要将 example-service
的版本号改为:
<artifactId>example-service</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>jar</packaging>
在该模块的版本号后加上 -SNAPSHOT
即可(注意这里必须是大写),然后 deploy 到私服,在 maven-snapshots
仓库下,version
列根据发布时间不同自动在 1.0 后面加上了当前时间,以此区别不同的快照版本:
example-ui
项目里,引入 example-service
快照版本:
<dependency>
<groupId>com.xxx.yyy</groupId>
<artifactId>example-service</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
这样的话,每次 example-ui
构建时,会优先去远程仓库中查看是否有最新的 example-service-1.0-SNAPSHOT.jar
,不需要频繁的去修改example-service
的版本号。等到两个组件要正式上线,example-service
的版本号改为:
<artifactId>example-service</artifactId>
<version>1.1-RELEASE</version>
<packaging>jar</packaging>
然后 deploy 到私服,example-ui
项目里,引入 example-service
正式升级版本:
<dependency>
<groupId>com.xxx.yyy</groupId>
<artifactId>example-service</artifactId>
<version>1.1-RELEASE</version>
</dependency>
所以总的来说,对于 Maven 版本号,我们最好这样约定:
由于 GFW
的缘故,有时候要去 Github
上克隆代码,半天 git clone
不下来,改过 host
,设置过代理镜像,发现根本不管用,最后整来整去,花钱买个好点的梯子,设置好 Git
代理,要省不少事情。
为 Git
设置全局代理(前提你已经买了比较好的梯子),根据代理协议的不同,在终端执行如下命令:
# 代理协议是socket5,我这里监听端口是1086,实际改成你自己的监听端口
git config --global http.proxy socks5://127.0.0.1:1086
git config --global https.proxy socks5://127.0.0.1:1086
# 代理协议是http,用这个,实际改成你自己的监听端口
git config --global http.proxy http://127.0.0.1:1080
git config --global https.proxy https://127.0.0.1:1080
在哪里可以查看梯子的代理协议?比如我用的是 ClashX,截图如下:
如果是 Shadowsocks 截图如下:
我们大部分情况下,由于 GFW
的缘故,只需要对 Github
设置代理,国内的比如 Gitee
其实没有必要走代理,推荐这样设置,只针对 Github
设置部分代理:
# 代理协议是socket5(推荐)
git config --global http.https://github.com.proxy socks5://127.0.0.1:1086
git config --global https.https://github.com.proxy socks5://127.0.0.1:1086
# 代理协议是http
git config --global http.https://github.com.proxy http://127.0.0.1:1080
git config --global https.https://github.com.proxy http://127.0.0.1:1080
取消 Git
的全局/部分代理:
git config --global --unset http.proxy
git config --global --unset https.proxy
没有设置代理前,平均 6.00 KiB/s
:
$ git clone https://github.com/mybatis/mybatis-3.git
Cloning into 'mybatis-3'...
remote: Enumerating objects: 3, done.
remote: Counting objects: 100% (3/3), done.
remote: Compressing objects: 100% (3/3), done.
^Cceiving objects: 0% (86/352273), 44.00 KiB | 6.00 KiB/s
设置代理后,平均 6.90 MiB/s
:
$ git clone https://github.com/mybatis/mybatis-3.git
Cloning into 'mybatis-3'...
remote: Enumerating objects: 3, done.
remote: Counting objects: 100% (3/3), done.
remote: Compressing objects: 100% (3/3), done.
remote: Total 352273 (delta 0), reused 0 (delta 0), pack-reused 352270
Receiving objects: 100% (352273/352273), 104.22 MiB | 6.90 MiB/s, done.
Resolving deltas: 100% (302817/302817), done.
没有对比就没有伤害,fuck GFW!!!
Maven
也是跟 Git
一样,拉取中央仓库的依赖时候,由于 GFW
的缘故,不设置代理的情况下,半天依赖是拉取不下来,通过设置 settings.xml
,配置代理也可以解决依赖下载速度过慢的问题:
<proxies>
<proxy>
<id>ClashX</id>
<active>true</active>
<protocol>socks5</protocol>
<host>127.0.0.1</host>
<port>1086</port>
<!--不需要设置代理的ip或域名,多个用|分隔,比如公司自己搭建的maven私服镜像,阿里云镜像等-->
<nonProxyHosts>172.16.xx.xx|maven.aliyun.com</nonProxyHosts>
</proxy>
</proxies>
设置完毕后,依赖下载丝滑流畅😂,更加具体配置的可以参考 Maven
官方配置文档:Configuring a proxy,fuck GFW!!!
最近也开通了 Netflix,Netflix 其实挺费流量的,为了防止梯子的流量超标,所以打算借助 Github Actions + telegram 做一个简单的监控,整体的思路其实很简单,没啥太大的难度,就是模拟梯子服务网站的登录,然后爬取页面的流量汇总数据,然后每天 9:30 将流量的使用情况发送到 telegram,同时如果可使用的流量少于 20% 的时候,推送报警到 telegram,代码目前放到了 github 上 proxy-traffic-monitor,实现细节就不讲了,代码比较简单,直接看代码就行。
创建一个 telegram bot 🤖,如果不会创建的话,参见 telegram 的官方文档:Creating a new bot,或者直接谷歌搜下,一大堆的教程,保存 telegram bot
的 token
,这个很重要。
创建好机器人🤖后,接下来就是要获取聊天id,也就是 chatId
打开你创建的机器人,随便发点啥,比如发个:hello world
浏览器输入:https://api.telegram.org/bot(这里加上你的token)/getUpdates
,会返回如下示例:
{
"ok": true,
"result": {
"message_id": 3,
"from": {
"id": 1432925625,
"is_bot": true,
"first_name": "SuperLeeyom",
"username": "SuperLeeyomBot"
},
"chat": {
"id": 599877436,
"first_name": "Leeyom",
"username": "super_leeyom",
"type": "private"
},
"date": 1612000615,
"text": "这是一条神奇的消息~"
}
}
取到 chat 下面的 id ,这个就是聊天 id 了,比如我这里的就是 599877436
。
然后打开浏览器,输入:https://api.telegram.org/bot(这里加上你的token)/sendMessage?chat_id=(你的chatId)&text=这是一条神奇的消息~
,不出意外你应该能收到一条消息,注意一定要是代理情况下你才能收到,毕竟 telegram 在国内无法使用的。
准备MonoCloud
和ByWave
这两家的代理的账号和密码,目前我使用时这两家的服务,还行吧,价格比较贵,但是比较稳定吧。
fork 项目proxy-traffic-monitor
Settings-Secrets
选项下,点击New repository secret
,创建我们准备工作的几个工作常量,如果只用其中一家,另外一家的可以账号密码可设置为空:BY_WAVE_USER_NAME
:bywave 账号BY_WAVE_PASSWORD
:bywave 密码MONO_CLOUD_USER_NAME
:monoCloud 账号MONO_CLOUD_PASSWORD
:monoCloud 密码TG_CHAT_ID
:telegram 聊天 idTG_TOKEN
:telegram bot token目前有两个定时,分别是daily.yml
和warn.yml
,前者是每天 9:30 点执行一次,汇总流量使用情况发送到 telegram,后者是每隔 2 个小时执行一次,监控可用流量的是否已经少于 20%,若少于 20% 会推送到telegram 进行预警,若要调整时间,可以修改这两个 yml 的 cron
表达式。
我这里默认关闭warn.yml
这个自动化任务了,因为我发现,ByWave 好像已经对对 github actions 的 ip做限制了,可能我测试的太频繁了吧😂,自己有需要的再打开这个注释吧
on:
workflow_dispatch:
# schedule:
# - cron: "0 */2 * * *"
ByWave 有防爬虫机制,所以定时任务太频繁,有可能会被限制 ip 地址,导致 github actions 自动化执行的时候,无法登录,如果被限制了,可以通过更换代理 ip 的方式:
Proxy proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress("xxx.xxx.xxx.xxx", 80));
loginRequest.setProxy(proxy);
如果喜欢,就点个 star 吧,以上就是这些了!Enjoy!
本源码只用于学习和交流,禁止用于商业目的。
]]>twitter
联系上了 yihong,yihong 是个非常热情,乐于助人的人,在他的帮助下,我成功了拿到了咕咚和 keep 上的跑步数据,并且在他的安利下,加上本身实在是受不了国内运动软件上各种广告,正式从 keep 换到了 Nike Run Club
(后面简称 nrc)。
切换到 nrc 后,之前其实我有折腾过想把之前在咕咚和 keep 上的数据导入到 nrc,毕竟积累了好几千公里的跑量,放弃掉实在太可惜。后面通过 yihong 的提供的思路,可以尝试将咕咚、keep 的跑步数据导出 gpx,然后再把 gpx 导入到类似
Garmin Connect
等平台,然后在 nrc 上与佳明进行绑定,通过曲线救国,就可以将数据导入进 nrc。
gpx 是一种 XML 格式,专门为应用软件设计的通用 GPS 数据格式,它可以用来描述路点、轨迹、路程,大部分的运动类软件都支持此类通用格式的导入。
早在一个月前,我尝试如下的的步骤:
但是很遗憾并没有成功,后面我就没有在弄了。就在这两天,yihong 说他和另外一个网友,搞定了咕咚数据的抓取,所以又开始着手重新尝试。我仔细想了下,我当时的步骤是先创建 Garmin Connect
的账号,然后把 gpx 数据上传到佳明,最后再到 nrc
上关联 Garmin
。是不是我的步骤不对?是不是 Garmin
是主动把数据推送给 Nike
的?所以在我没关联之前,就把数据上传了,没有触发推送?带着这些疑问,所以我又尝试了如下的步骤(最好全程都开启代理的情况下进行):
以上便是我整个同步过程的一些记录,如果导入后,没啥动静,建议在佳明那边删除掉已导入的 gpx 数据,在佳明那边解除 nike 绑定,然后再重新绑定,再重新导入。我觉得要想保证导入成功需要注意如下几点:
connect.garmin.cn
由于在通过 running_page
项目生成 keep 和咕咚的 gpx 数据的时候,由于 keep 数据不完整性,实际生成的 gpx 文件不是很完整,丢失了差不多 1000 公里的数据,但是也无所谓了,能拿到 80% 的数据我已经很开心了,哈哈。
最后贴下:
Nike Run Club
id:635709492@qq.com
,欢迎互相关注鼓励使用 redis 自带的查询工具:
redis-cli -p 6379 -a 密码 --bigkeys
比如我执行结果:
[root@localhost ~]# redis-cli -p 6379 -a pd123456 --bigkeys
# Scanning the entire keyspace to find biggest keys as well as
# average sizes per key type. You can use -i 0.1 to sleep 0.1 sec
# per 100 SCAN commands (not usually needed).
[00.00%] Biggest hash found so far 'alarm:monitor:virtual.67481375665548lumi.light8.0.8107' with 1 fields
[27.15%] Biggest hash found so far 'alarmDefinitionCache' with 2 fields
[28.14%] Biggest hash found so far 'AIOT_DEVICE_INFO' with 62867 fields
[45.33%] Biggest hash found so far 'RESOURCE_LAST_TS' with 111030 fields
[56.45%] Biggest set found so far 'SMART_HOTEL_USER_CACHE' with 4 members
[57.53%] Biggest zset found so far '1h~keys' with 2 members
-------- summary -------
Sampled 128971 keys in the keyspace!
Total key length in bytes is 7387660 (avg len 57.28)
Biggest set found 'SMART_HOTEL_USER_CACHE' has 4 members
Biggest hash found 'RESOURCE_LAST_TS' has 111030 fields
Biggest zset found '1h~keys' has 2 members
0 strings with 0 bytes (00.00% of keys, avg size 0.00)
0 lists with 0 items (00.00% of keys, avg size 0.00)
1 sets with 4 members (00.00% of keys, avg size 4.00)
128969 hashs with 337016 fields (100.00% of keys, avg size 2.61)
1 zsets with 2 members (00.00% of keys, avg size 2.00)
我们可以看到打印结果分为两部分,扫描过程部分,只显示了扫描到当前阶段里最大的 key。summary 部分给出了每种数据结构中最大的 Key 以及统计信息。
redis-cli --bigkeys
的优点是可以在线扫描,不阻塞服务;缺点是信息较少,内容不够精确。扫描结果中只有 string 类型是以字节长度为衡量标准的。List、set、zset 等都是以元素个数作为衡量标准,只能看出来一个数据结构下有多少数据,看不出来到底占多少内存,看着数值大的并不一定有问题,也不一定占用空间很大,所以这个工具只能用来做大致分析。
那其实最好的办法就是离线分析,这里推荐一个工具:redis-rdb-tools,整体的思路就是,导出 redis 的 rdb 备份文件,生成内存报告,把所有 key 转换为 JSON,转存别的 DB 等,这里的 DB 就用 sqlite 就行。
先用 redis-cli 工具连上 Redis 执行 bgsave,备份完成后,将 rdb 文件下载到本地。
安装 redis-rdb-tools
:
pip install rdbtools python-lzf
或者:
git clone https://github.com/sripathikrishnan/redis-rdb-tools
cd redis-rdb-tools
sudo python setup.py install
若提示缺少组件,按照提示安装好即可。
若没有安装 sqlite
,先安装 sqlite。
然后生成内存快照:rdb -c memory dump.rdb > memory.csv
,生成 CSV 格式的内存报告,这一步可能会比较久,我处理的 rdb 文件 2 个多 g,跑了有一二十分钟,生成的 CSV 文件有 1个g的大小。包含的列有:数据库 ID,数据类型,key,内存使用量(byte),编码。内存使用量包含 key、value 和其他值。
导入 memory.csv
到 sqlite
数据库,数量量比较大的话,需要等一会儿。
$ sqlite3 memory.db
SQLite version 3.32.3 2020-06-18 14:16:19
Enter ".help" for usage hints.
sqlite> create table memory(database int,type varchar(128),key varchar(128),size_in_bytes int,encoding varchar(128),num_elements int,len_largest_element varchar(128));
sqlite> .mode csv memory
sqlite> .import memory.csv memory
查询内容占用最高的几个 key:
sqlite> select key,size_in_bytes from memory order by size_in_bytes desc limit 11;
key,size_in_bytes
RESOURCE_LAST_TS,895383788
AIOT_DEVICE_INFO,228425612
DEVICE_STATUS_LAST_TS_ONLINE,47604980
DEVICE_STATUS_LAST_TS_BIND,44006972
RETAIL:TRADE:TRADE-ORDER-TEMP,22917444
RETAIL:TRADE:TRADE-ORDER-MAPPER,3396012
retail_biz_config_data:ticket_problem,750140
areaTree,655416
retail_biz_config_data:provider_product,486796
经过内存分析,内存占用率排前十的key:
RESOURCE_LAST_TS
占用 853.9 MB
AIOT_DEVICE_INFO
占用 217.84 MB
DEVICE_STATUS_LAST_TS_ONLINE
占用 45.3996 MB
DEVICE_STATUS_LAST_TS_BIND
占用 41.9683 MB
RETAIL:TRADE:TRADE-ORDER-TEMP
占用 21.8558 MB
RETAIL:TRADE:TRADE-ORDER-MAPPER
占用 3.2387 MB
retail_biz_config_data:ticket_problem
占用 0.715389 MB
areaTree
占用 0.625053 MB
retail_biz_config_data:provider_product
占用 0.464245 MB
retail_biz_config_data:config_fault
占用 0.298893 MB
找到了内存占用率比较高的 key 后,就可以去针对此 key 进行下一步的优化。
决定禁止使用 keys 命令;
避免一次查询所有的成员,要使用 scan 命令进行分批的,游标式的遍历;
通过机制严格控制 Hash、Set、Sorted Set 等结构的数据大小;
将排序、并集、交集等操作放在客户端执行,以减少 Redis 服务器运行压力;
删除 (del) 一个大数据的时候,可能会需要很长时间,所以建议用异步删除的方式 unlink,它会启动一个新的线程来删除目标数据,而不阻塞 Redis 的主线程。
现在就目前主流的分布式 id 生成方案做一个总结。
这个 jdk 自带的工具类就能实现:
UUID.randomUUID().toString()
MySQL 官方有明确的建议主键要尽量越短越好,36 个字符长度的 UUID 不符合要求,如果作为数据库主键,在 InnoDB 引擎下,UUID 的无序性可能会引起数据位置频繁变动,严重影响性能。
利用 MySQL 数据库的自增主键,来作为唯一的全局 id,专门用一张表来生成主键,表结构如下:
CREATE TABLE `distributed_id` (
`id` bigint(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`extra` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL DEFAULT '' COMMENT '额外字段',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci COMMENT='主键id生成';
extra
是额外字段,因为你要总要插入点什么,才能生成一个自增主键 id,这种方式存在问题是,每次生成一个id,都要请求MySQL,如果 MySQL 挂掉了,那么也就无法生成唯一 id 了,且 ID 发号性能瓶颈限制在单台 MySQL 的读写性能。
既然一台 MySQL 有风险,那么可以将 MySQL 扩展为两台,采用双主模式集群,设置每台 MySQL 的步长和起始值,其中一台 MySQL 的起始值为 1,步长为 2,一台的起始值为 2,步长为 2,配置如下:
# mysql-1,这里需要注意,执行如下命令前,最好先删除「主键id生成表」,否则可能不生效
# 最好连接 mysql 命令行界面执行,Navicat 等工具有时候会不生效
set global auto_increment_offset = 1;
set global auto_increment_increment = 2;
# mysql-2
set global auto_increment_offset = 2;
set global auto_increment_increment = 2;
# 查询是否修改成功
SHOW global VARIABLES LIKE 'auto_inc%';
通过如上的配置后,MySQL-1
生成:1、3、5、7 ...
系列主键 id,MySQL-2
生成 2、4、6、8 ...
系列的主键 id,即使其中一台 MySQL 挂了,另外一台 MySQL 还可以继续生成 id,然后专门搭建一个后台服务 distributedd-service 服务,暴露 http 接口,专门用于其他服务生成分布式 id。这里我写了一个简单示例:distributedd-service ,可以参考一下;
但是这种还是有很大局限性,系统水平扩展比较困难,比如定义好了步长和机器台数之后,如果要添加机器该怎么做?首先,要调整新增加 MySQL 的起始值足够大,其次,之前两台的 MySQL 的步长肯定得修改,而且在修改步长的同时,很有可能会产生重复的 id,并且这种方式强依赖数据库,每次生成一个 id 都要请求数据库,数据库压力还是很大,每次获取 ID 都得读写一次数据库,只能靠堆机器来提高性能。
那能不能批量获取呢?那就是号段模式,号段模式的意思就是,每次从数据库只获取一个号段,比如 (1,1000]
,然后分布式 id 服务 distributedd-service
在本地加载到内存中,然后采用自增的方式来生成 id,不需要每次都请求数据库。等这个号段使用完了,再去数据库申请一个新的号段。这种方式的好处就是解除了对数据库的强依赖,即使数据库挂了,分布式 id 服务 server 在本地还能支撑一段时间,另外为了提高分布式 id 服务的可用性,发号器服务也可以部署成集群,防止单点故障,但是也有个小缺陷的地方,就是假如分布式 id 服务重启了,就可能会丢失一部分的 id 号段。
那关于号段模式的实践,目前已经有开源的方案,就是滴滴的tinyid。
DB 号段算法的数据库设计如下:
CREATE TABLE `tiny_id_info` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
`biz_type` varchar(64) NOT NULL COMMENT '业务类型,不同的业务id隔离',
`max_id` int(11) NOT NULL COMMENT '当前最大可用id',
`step` int(11) NOT NULL COMMENT '号段的长度,也就是步长',
`version` int(11) NOT NULL COMMENT '乐观锁,每次更新都加上version,保证并发更新的准确性',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='db号段表';
那么我们可以通过如下几个步骤来获取一个可用的号段:
select id, biz_type, max_id, step, version from tiny_id_info where biz_type='test';
new_max_id = max_id + step
update tiny_id_info set max_id=#{new_max_id} , verison=version+1 where id=#{id} and max_id=#{max_id} and version=#{version}
(max_id, new_max_id]
分布式 id 发号器服务 distributedd-service
对外提供 http 服务,用于生成 id,搭建发号器服务 server 集群,每个节点都相当于一个 id 号段,请求经过负载均衡,落到某个节点上,从事先加载好的号段中获取一个 id,如果号段还没有加载,或者已经用完,则向 db 再申请一个新的可用号段,读写数据库的频率从 1 减小到了 1/step ,多台 server 之间因为号段生成算法的原子性,而保证每台 server 上的可用号段不重,从而使 id 生成不重。其实这种在并发量不高的情况下,其实基本上已经满足大部分使用场景,但是如果并发量比较高的话,还是会存在一些问题:
TinyId 给出了如下的优化方案:
双号段缓存:在号段使用到一定的程度,起一个线程,异步去加载下一个号段,保证内存中始终有可用的号段,则可避免性能波动。
多db支持:既然一个 db 可能单点故障,那就部署多台 db,原理跟上面讲的数据库多主模式一样,数据库 A 只生成奇数 id,数据库 B 只生成偶数 id。id 生成服务,随机向多个 db 申请号段。这时候,原本 DB 号段算法的数据库需要进行修改下:
CREATE TABLE `tiny_id_info` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
`biz_type` varchar(64) NOT NULL COMMENT '业务类型,不同的业务id隔离',
`max_id` int(11) NOT NULL COMMENT '当前最大可用id',
`step` int(11) NOT NULL COMMENT '号段的长度,也就是步长',
`delta` int(4) NOT NULL COMMENT 'id每次的增量,理解为id的自增步长,类似auto_increment_increment',
`remainder` int(4) NOT NULL COMMENT '代表余数',
`version` int(11) NOT NULL COMMENT '乐观锁,每次更新都加上version,保证并发更新的准确性',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='db号段表';
通过 delta 和 remainder 两个字段我们可以根据使用方的需求灵活设计 db 个数,使 db 能水平进行扩展,比如将AB两个 db 的 delta 都设置为 2,remainder A 设置为 0,B 设置为 1,则 A 则只生成偶数号段,B 则生成奇数号段。
增加tinyid-client:既然 http 会有网络开销,那就把 id 在本地,也就是客户端生成,就是说,id 生成服务 server 不直接生产id了,而是转为生产号段,id 生成的逻辑,转到本地,通过提供一个 tinyid-client
sdk 组件,引入到其他服务里面,在本地构建双号段、id生成,如此 id 生成则变成纯本地操作,性能大大提升。
雪花算法(snowflake)是 twitter 开源的分布式 ID 生成算法,雪花算法的描述:指定机器 & 同一时刻 & 某一并发序列,是唯一的,据此可生成一个 64 bits 的唯一ID(long)。
算法产生的是一个 long 型 64 比特位的值,由于一般分布式 id 均为正数(0 是代表正数,1 代表负数),第 1 位固定为 0,接下来是 41 位的毫秒单位的时间戳,我们可以计算下:
2^41/1000*60*60*24*365 = 69年
也就是这个时间戳可以使用 69 年不重复,这个对于大部分系统够用了。10 位的数据机器位,所以可以部署在 1024 个节点。12 位的序列,在毫秒的时间戳内计数。 支持每个节点每毫秒产生 4096 个 ID 序号,所以最大可以支持单节点差不多四百万的并发量。
雪花算法要保证 id 不重复,最重要的就是要保证 workerid 不重复,也就是机器码不能重复。如果要部署的服务节点不多,直接可以通过 jvm 的启动参数方式传过来,应用启动的时候获取启动参数(workId 终端 ID 和 datacenterId 数据中心 ID),保证每个节点启动的时候传入不同的启动参数即可。
java -jar xxx.jar -DworkerId=1 -DdatacenterId=123
这里我用 Java 工具类库 Hutool 封装的工具类 IdUtil 生成唯一 id,伪代码如下:
long workerid = System.getProperty("workerId ");
long datacenterId = System.getProperty("datacenterId ");
Snowflake snowflake = IdUtil.getSnowflake(workerid, datacenterId);
long distributedId = snowflake.nextId();
如果机器特别多,人工去为每台机器去指定一个机器 id,人力成本太大且容易出错,并且雪花算法,强依赖机器时钟,如果机器上时钟回拨,会导致发号重复或者服务会处于不可用状态。所以一些大厂对 snowflake 进行了改造,比如百度的 uid-generator,美团的 Leaf 等开源方案。
目前美团 Leaf 有两种方案,一种也是基于号段模式,叫 Leaf-segment
数据库方案,他的原理其实跟滴滴的 tinyid 类似的,这里就不再复述了。另外一种是基于雪花算法的,叫 Leaf-snowflake
方案,Leaf-snowflake
方案完全沿用 snowflake 方案的 bit 位设计,即是 “1+41+10+12”
的方式组装 ID 号。Leaf-snowflake 方案使用 Zookeeper 持久顺序节点的特性自动对 snowflake 节点配置 workId:
Leaf-snowflake
服务,连接 Zookeeper,在 leaf_forever 父节点下检查自己是否已经注册过(是否有该顺序子节点)。除了每次会去 zk 拿数据以外,也会在本机文件系统上缓存一个 workerId 文件。当 ZooKeeper 出现问题,恰好机器出现问题需要重启时,能保证服务能够正常启动。
另外关于时钟回拨问题,Leaf-snowflake
方案在服务启动阶段,就会去检测机器时间是否发生了大步长的回拨:
更细节的问题,可以参考美团技术团队写的:《Leaf——美团点评分布式ID生成系统》。
百度的 UidGenerator 以组件(sdk)形式工作在应用项目中, 支持自定义 workerId 位数和初始化策略, 从而适用于 docker 等虚拟化环境下实例自动重启、漂移等场景。 在实现上, UidGenerator 通过借用未来时间来解决 sequence 天然存在的并发限制; 采用RingBuffer 来缓存已生成的 UID, 并行化 UID 的生产和消费, 同时对 CacheLine 补齐,避免了由 RingBuffer 带来的硬件级「伪共享」问题,最终单机 QPS 可达 600 万。
相比于标准的雪花算法比特位的分布,百度的 UidGenerator 稍微有些不一样:
百度的 UidGenerator
默认提供的 workid 生成策略:应用在启动时会往数据库表 WORKER_NODE 表中去插入一条数据,数据插入成功后返回的该数据对应的自增唯一 id 就是该机器的 workId。
DROP TABLE IF EXISTS WORKER_NODE;
CREATE TABLE WORKER_NODE
(
ID BIGINT NOT NULL AUTO_INCREMENT COMMENT 'auto increment id',
HOST_NAME VARCHAR(64) NOT NULL COMMENT 'host name',
PORT VARCHAR(64) NOT NULL COMMENT 'port',
TYPE INT NOT NULL COMMENT 'node type: ACTUAL or CONTAINER',
LAUNCH_DATE DATE NOT NULL COMMENT 'launch date',
MODIFIED TIMESTAMP NOT NULL COMMENT 'modified time',
CREATED TIMESTAMP NOT NULL COMMENT 'created time',
PRIMARY KEY(ID)
)
COMMENT='DB WorkerID Assigner for UID Generator',ENGINE = INNODB;
但是百度的 uid-generator 项目已经基本上不维护了,但是思想还是挺棒的,如果可以的话,自己可以按照这个思路改造。
除了上面主流的方案,还有一些其他的方案:
就目前这些方案来说,我个人更比较推荐:滴滴TinyId 和 美团 Leaf 两种开源方案,这两种方案已经经过了成熟的商业实践,适用于大部分的公司业务,我们自己公司目前使用的就是美团Leaf-segment
数据库方案。
ps -ef | grep {processName}
ps -ef | grep {pid}
netstat -tunlp | grep {port}
示例:
# netstat -tunlp | grep 8080
tcp6 0 0 :::8080 :::* LISTEN 29150/java
则 29150 为当前端口所对应的进程 pid
lsof -i tcp:{port}
netstat -nap | grep {pid}
lsof -p {pid}|grep LISTEN
ps -A -ostat,ppid,pid,cmd | grep -e '^[Zz]'
-A
参数列出所有进程-o
自定义输出字段 我们设定显示字段为 stat(状态), ppid(进程父id), pid(进程id),cmd(命令)这四个参数# 查看最消耗CPU的进程
ps -eo pid,ppid,%mem,%cpu,cmd --sort=-%cpu | head
# 查看最消耗内存的进程
ps -eo pid,ppid,%mem,%cpu,cmd --sort=-%mem | head
]]>这本书挺契合当前形势的,跟今年的武汉肺炎差不多,讲的是全世界的人都得了一种失明症,整个社会文明秩序崩塌的事情。
整书让我比较印象深刻,当看到被关在精神病院的女人们,为了食物,答应那些盲人歹徒们的肮脏行为,心里非常气愤,在想女人们和他们的男人们为什么不奋起反抗,但是想想,在整个社会秩序文明崩塌的时候,人的本能,活下去才是唯一目的吧!在看到盲人中唯一能看的见的女人,医生的妻子,站起来反抗,拿着剪刀杀了盲人歹徒首领,真是看的让人拍手称快。书中非常让人心疼了就是医生的妻子了,全世界唯一一个能看得见的人,带着一群盲人,从恐怖的精神病院逃出来,在看到那种炼狱般的世界后,依然坚强的活着,给大家带来光明的希望。
全书的最后那句:“我想我们没有失明,我想我们现在是盲人;能看得见的盲人;能看但又看不见的盲人。”,何尝不是啊?现如今,虽然我们生理上能看得见,但是灵魂和心里我们很多人都是盲人。如果有一天我们真的失明了,你有活下去的勇气吗?以下便是看书过程中一些比较印象深刻的句子:
如果在实施任何行为之前我们都能预想到它的一切后果并认真加以考虑,先是眼前的后果,然后是可证明的后果,接着是可能的后果,进而是可以想象到的后果,那么我们根本就不会去做了,即使开始做了,思想也能立即让我们停下来。我们一切言行的好和坏的结果将分布在,假设以一种整饬均衡的形式,未来的每一天当中,包括那些因为我们已不在人世而无从证实也无法表示祝贺或请求原谅的永无止境的日子。有人会说,这就是人们常说的不朽。
面对死神,我们最希望看到仇恨能失去力量和毒性。
世界就是这样,真相往往以谎言为伪装达到其目的。
到了把语言化为行动的时候,原来那么坚定的勇气开始消退,面对刺激鼻孔和眼睛的恶劣现实她开始崩溃。
即便在最坏的不幸之中,也能找到足够的善让人耐心地承受此种不幸。
甚至说法律从诞生那一天起就对所有人同等对待,而民主与特权水火不容。
在我们被迫生活的这个地狱里,在我们自己打造的这个地狱中的地狱里,如果说廉耻二字还有一点意义的话,应当感谢那个有胆量进入鬣狗的巢穴杀死鬣狗的人
穿袈裟的不一定是和尚,执权杖的不一定是国王,最好不要忘记这条真理。
当焦急折磨着我们的时候,当肉体由于疼痛和痛苦不肯听从我们指挥的时候,就能看到我们自己渺小的兽性了
正义的报复是人道主义的举动,如果受害者没有向残忍的家伙报复的权利,那就没有正义可言了
没有答案,答案在最需要的时候总是不肯出现,而很多时候唯一可能的答案却是,你必须耐心等待。
他们都成了没有性别的轮廓,成了边缘模糊的污渍,成了隐没在黑暗中的阴影。
医生的妻子把手搭在作家的肩上,作家伸出两只手,摸到她的手,慢慢拉到自己唇边,您不要迷失,千万不要迷失,他说,这句话出人意料,寓意难明,好像是不经意说出来的。
她会再生吗,戴墨镜的姑娘问;她不会,医生的妻子回答说,但活着的人们需要再生,从本身再生,而他们不肯;我们已经半死了,医生说;我们还半活着,妻子回答说。
我想我们没有失明,我想我们现在是盲人;能看得见的盲人;能看但又看不见的盲人。
拿一个不存在的 key 去查询数据,如果缓存里面查询不到,就会去数据库里面查询,如果有人恶意拿不存在的 key 疯狂请求,会把数据库压垮,这就是缓存穿透,下面用一段伪代码:
List<String> cacheList = redis.get(key);
if(CollUtil.isEmpty(cacheList)){
List<String> list = mysql.getList(key);
if(CollUtil.isNotEmpty(list)){
redis.set(key,list,3 * 60);
}
return list;
}
return cacheList;
通常来说,解决缓存穿透有两种方式:
为不存在的 key 设置空值
伪代码如下:
List<String> cacheList = redis.get(key);
if(CollUtil.isEmpty(cacheList)){
// 不管有没有在数据库中查询到数据,都给key设置值
List<String> list = mysql.getList(key);
redis.set(key,list,3 * 60);
return list;
}
return cacheList;
使用布隆过滤器
在某个时间点,大批的 key 出现过期,导致所有的请求全部打到数据库上,把数据库压垮,这种就是缓存雪崩,通常解决缓存雪崩有如下的几种方案:
当前的某个热点 key 缓存过期,同一时间,有大量的请求同时来访问这个 key,导致所有的请求都打到数据库上去了,把数据库压垮。那通常遇到这种问题的话,一般就是使用排斥锁,当然也有一种粗暴的办法,就是设置永不过期,但是这种粗暴方式,大多数情况下不适用。
关于排斥锁,可以这样理解,第一个请求达到请求 key 发现缓存里面没有,允许它去数据库查询,同时加锁,这样第二个请求,第三个请求…都会被锁阻塞到当前,当第一个请求从数据库查询到数据后,将数据缓存到 Redis 中,然后释放锁,这样第二个,第三个请求...,就直接可以从缓存中拿数据,就不会再打到数据库,这样就减少了数据库的并发压力。
String get(String key) {
String value = redis.get(key);
if (value == null) {
if (redis.setnx(key_mutex, "1")) {
// 给锁设置一个过期时间,防止持有锁的人挂了,导致锁不能释放
redis.expire(key_mutex, 3 * 60)
// 从DB中查询数据并缓存
value = db.get(key);
redis.set(key, value);
// 释放锁
redis.delete(key_mutex);
return value;
} else {
//其他线程休息100毫秒后重试
Thread.sleep(100);
get(key);
}
}
return value;
}
其实对于这些热点 key,最好还是有个独立的服务,去定时的刷新缓存,这样的话,很大的程度上可以避免这种问题。
]]>EXPIRE key seconds
:用于设置秒级精度的生存时间,它可以让键在指定的秒数之后自动被移除PEXPIRE key milliseconds
:用于设置毫秒级精度的生存时间,它可以让键在指定的毫秒数之后自动被移除EXPIREAT key timestamp
:将键 key 的过期时间设置为 timestamp 所指定的秒数时间戳PEXPIREAT key timestamp
:将键 key 的过期时间设置为 timestamp 所指定的毫秒数时间戳虽然有多种不同单位和不同形式的设置命令,但实际上EXPIRE
、PEXPIRE
、EXPIREAT
三个命令都是使用PEXPIREAT
命令来实现的:无论客户端执行的是以上四个命令中的哪一个,经过转换之后,最终的执行效果都和执行PEXPIREAT
命令一样。
在使用键过期功能时,组合使用 SET
命令和 EXPIRE/PEXIRE
命令的做法非常常见:
SET key value [EX seconds] [PX milliseconds]
:在设置 key 的时候,同时设置过期时间,此命令等价于两条命令:
SET key value
EXPIRE key seconds
使用带有 EX
选项或 PX
选项的 SET
命令除了可以减少命令的调用数量并提升程序的执行速度之外,更重要的是保证了操作的原子性,使得「为键设置值」和「为键设置生存时间」这两个操作可以一起执行。如果拆分成 SET
和 EXPIRE
两条命令执行的话,如果 Redis 服务器在成功执行 SET
命令之后因为故障下线,导致 EXPIRE
命令没有被执行,那么 SET
命令设置的缓存就会一直存在,而不会因为过期而自动被移除。
EXPIREAT/PEXPIREAT
,还是 EXPIRE/PEXIRE
,它们都只能对整个键进行设置,而无法对键中的某个元素进行设置,比如,用户只能对整个集合或者整个散列设置生存时间/过期时间,但是却无法为集合中的某个元素或者散列中的某个字段单独设置生存时间/过期时间,这也是目前 Redis 的自动过期功能的一个缺陷。
能设置过期时间,自然也就能移除过期时间,PERSIST
命令就是PEXPIREAT
命令的反操作:PERSIST key
命令在过期字典中查找给定的键,并解除键和值(过期时间)在过期字典中的关联。
TTL
命令以秒为单位返回键的剩余生存时间,而PTTL
命令则以毫秒为单位返回键的剩余生存时间:
TTL key
PTTL key
Redis 里面有一个过期字典 expires,专门存储 Redis key 的过期时间,一个键的 key,有两个指向,一个指向实际的 value,一个指向它的的过期时间,如下图所示
判定一个键是否过期,主要分为两步:
Redis 服务器实际使用的是惰性删除和定期删除两种策略,其中惰性删除策略会调用 expireIfNeeded
函数对键进行检查:
expireIfNeeded
函数将输入键从数据库中删除expireIfNeeded
函数不做动作示意图如下:
而 Redis 的定期删除策略,activeExpireCycle
函数就会被调用,它在规定的时间内,分多次遍历服务器中的各个数据库,从数据库的 expires 过期字典中随机检查一部分键的过期时间,并删除其中的过期键,如下图所示:
Redis 的内存回收机制主要体现在以下两个方面:
删除到达过期时间的键对象,就是上面说的过期键删除策略。
内存使用达到maxmemory
上限时触发内存溢出控制策略。
当 Redis 所用内存达到 maxmemory
上限时会触发相应的溢出控制策略。 具体策略受 maxmemory-policy
参数控制,Redis 支持 6 种策略:
noeviction
:默认策略,不会删除任何数据,拒绝所有写入操作并返 回客户端错误信息(error)OOM command not allowed when used memory
,此时 Redis 只响应读操作。volatile-lru
:根据 LRU 算法删除设置了超时属性(expire)的键,直到腾出足够空间为止。如果没有可删除的键对象,回退到 noeviction 策略。allkeys-lru
:根据 LRU 算法删除键,不管数据有没有设置超时属性, 直到腾出足够空间为止。allkeys-random
:随机删除所有键,直到腾出足够空间为止。volatile-random
:随机删除过期键,直到腾出足够空间为止。volatile-ttl
:根据键值对象的 ttl 属性,删除最近将要过期数据。如果没有,回退到 noeviction 策略。内存溢出控制策略可以采用如下命令动态配置:
config set maxmemory-policy {policy}
当 Redis 一直工作在内存溢出(used_memory>maxmemory)
的状态下且设置非 noeviction 策略时,会频繁地触发回收内存的操作,影响 Redis 服务器的性能。频繁执行回收内存成本很高,主要包括查找可回收键和删除键的开销,如果当前 Redis 有从节点,回收内存操作对应的删除命令会同步到从节点,导致写放大的问题。
由于业务扩展问题,目前公司有 a 和 b 两个账号中心服务,分别对应的是运营端和服务商端,这两个账号系统的访问域名分别是a.aqara.cn
和b.aqara.cn
,其中 b 账号中心由其他的团队负责开发,用户登录成功后,会返回用户的信息(userInfo
)和访问令牌(token
),前端会将他们缓存在客户端的 Cookies
里面,由于共用同一个二级域名(.aqara.cn
),前端Cookies
里面缓存的数据是共用的。
就会存在这种问题:同一个浏览器,用户在标签 A 登录A用户,然后又重新打开标签页 B,登录用户 B,这样就会导致,第二个用户会把第一个用户的信息覆盖掉,但是此时用户无感知,Cookies
里面存储的令牌和用户信息就会被覆盖掉。这样的话假如请求的数据(比如查看个人信息)是基于 token 拿用户信息的话,由于后台的网关层,有把 token 作为键,用户信息作为 value,缓存用户用户信息,有时候就会导致 A 用户拿到 B 用户的数据。如果恰好 a 用户和 b 用户都有访问某接口的权限,就会造成,怎么我操作后,显示的操作人确实另外一个人的名字。
简单粗暴
a.aqara.cn
和b.aqara.com
,由于是不同的二级域名,这样前端的 Cookies
就是隔离开来的,相关之间不会有任何的影响。 这种得确认更换域名后,会不会影响其他的业务。服务端
在用户登录的时候,返回用户的数据,如果不想被覆盖,只能换成不一样的,可以设置为用户名 (username)+sessionkey
使每一个用户的 sessionkey
都不一样,但是由于 b 账号中心的登录接口不是我们掌控的,此方案不太好实施。
用户登陆后分配一个临时标识(sid),所有的请求和响应均携带此标识,后台用来区分用户,实际就是将判断上移到应用层面。一边这个标识生成后会放到 redis,设置一定的有效时间。伪代码如下:
String sid = request.getReuqestParam("sid");
String token = request.getReuqestParam("token");
String userId = redis.get("sid");
if(StrUtil.isBlank(userId)){
throw new BizException("当前用户不存在");
}
String userIdFromToen = JwtUtil.parseToken("token");
if(!userIdFromToen.equal(userId)){
throw new BizException("当前用户已被替换");
}
用户在登出后,把服务端要及时的缓存的用户信息给清除掉。
前端:
登陆成功后将 sid
后存储到本地,在每个需要验证的页面加上这个参数,当用户刷新页面时与本地存储的值进行比较,不符合就跳转登陆页(或弹出提示框,提醒当前用户已更换用户,是否继续执行此操作,用户刷新页面后,就会刷新当前用户的菜单权限,用户信息等等)。
后登陆的用户会覆盖上一个用户的本地值,而 url
里的参数不会变所以会导致 URL
中获取的值和本地不一致,目前 qq 邮箱采用也是这种方式。
前端伪代码:
router.beforeEach((to, from, next) => {
// 工具方法,获取url地址中sid的值
let urlSid = getPop('sid');
// 获取localStorege中sid的值
let localSid = getLocalStorage('sid').sid
console.log('url=%s,local=%s ', urlSid, localSid)
// 先判断两个参数是否存在
if (urlSid && localSid) {
if (urlSid == localSid) {
next()
} else {
let url = window.location.href.replace(/[\?,\#]\S*/g, '');
window.location.href = url;
next({
path: "/"
});
}
}
})
GitHub Actions
不是特别熟悉,以为它适合于跑类似于脚本语言 Python
,不太适合与 Java
这类需要借助于 JVM 的语言,恰好最近有一个简单的想法就是想把 Chrome
书签同步到 Github
,并将书签生成 README.md
文件,就尝试下用 GitHub Actions
去构建 Java
,实际验证了其实是可行的,GitHub Actions
完全可以跑 Java
做一些自动化操作。
官网的定义就是:
在 GitHub Actions 的仓库中自动化、自定义和执行软件开发工作流程。 您可以发现、创建和共享操作以执行您喜欢的任何作业(包括 CI/CD),并将操作合并到完全自定义的工作流程中。
做 Java 的其实都知道 Jenkins
,其实就是和 Jenkins
差不多,用于自动化构建的,只不过 GitHub Actions
基于 Github 平台。
你只要在你的仓库下,创建.github/workflow
目录,并在此目录下创建*.yml
的文件,就可以开启 GitHub Actions
,yml
文件主要用于配置自动化构建,这里我就拿我的这次实践的chrome_bookmarks_sync.yml
示例:
# 此 action 的名字
name: ChromeBookmarksSyncApplication
on:
# 开启手动执行
workflow_dispatch:
# 触发条件,当有代码push到master分支的时候,就触发一次构建
push:
branches: [ master ]
# 触发条件,当有pr发起的时候,就触发一次构建
pull_request:
branches: [ master ]
# 自定义的环境变量,实际需要换成你自己的
env:
GITHUB_NAME: superleeyom
GITHUB_EMAIL: 635709492@qq.com
# 任务
jobs:
build:
# 设置系统环境
runs-on: ubuntu-latest
steps:
# 检出代码
- uses: actions/checkout@v2
# 设置jdk版本号
- name: Set up JDK 1.8
uses: actions/setup-java@v1
with:
java-version: 1.8
# 执行maven命令,进行编译,并执行脚本,生成 README.md
- name: execute application
run: mvn -B clean compile exec:java --file pom.xml
# 提交代码
- name: update README.md
uses: github-actions-x/commit@v2.6
with:
github-token: ${{ secrets.G_TOKEN }}
commit-message: ":memo: update README.md"
files: README.md
rebase: 'true'
name: ${{ env.GITHUB_NAME }}
email: ${{ env.GITHUB_EMAIL }}
更多的 GitHub Actions
用例,可以参考官方的文档。
其实思路很简单,首先使用 Chrome 插件「书签同步」,将书签信息(bookmark.json
)上传到 Github 仓库,然后通过 github action
去读取书签数据,然后生成 README.md
文件。
没法科学上传的前提下,可以通过CrxDL.COM去下载该插件,关键字搜索「书签同步」进行下载安装,设置流程的话,参考插件使用指南:
登录Github,在 Settings->Personal access tokens->Generate new token
生成一个访问 token
生成的 token 需要勾选 repo 权限,保存生成的 token
点击插件 icon,依次输入用户名、凭据、仓库名、文件存放路径(在仓库提前创建好*.json
文件)
如果需要记住用户数据,需要打开 Remember Me
开关
填写完用户数据后,便可以进行「上传」或「下载」操作
由于项目是用 Maven
构建的,所以我当时的想法是通过用 mvn clean package
命令,写个单元测试方法,去触发并执行 Java 类方法,后面经过试验发现是可行的,但是觉得此方法比较 low
啊,应该是还有其他方法的,后面经过查询资料,其实 Maven
是可以通过插件 exec-maven-plugin
,运行 Java main 方法:
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>1.2.1</version>
<configuration>
<!-- 指定main方法入口 -->
<mainClass>com.bookmark.action.ChromeBookmarksSyncApplication</mainClass>
</configuration>
</plugin>
对应的本地测试命令:mvn clean compile exec:java
,实际的 github action
的 yml 文件里的写法有点区别:mvn -B clean compile exec:java --file pom.xml
,需要指定 pom 文件。另外如果你想执行 mvn
命令的时候传递命令参数到 main 方法,可以这样:mvn clean compile exec:java -Dexec.args="arg0 arg1 arg2"
,这样在就可以接收到自定义参数了:
public class ChromeBookmarksSyncApplication {
public static void main(String[] args) {
// 打印:[arg0 arg1 arg2]
System.out.println("打印接收到的参数:"+JSONUtil.toJsonStr(args));
GenerateReadmeUtil.generateReadme();
System.exit(0);
}
}
这样是不是我们可以在 yml
配置中自定义的参数,就可以通过 mvn
命令传递进来呢?对吧?
关于文件读取和写入的路径问题,实际我们在本地测试的时候,对于 bookmark.json
和README.md
应该取绝对路径,在GenerateReadmeUtil.java
类中:
private static final String BOOKMARK_JSON_PATH = "/Users/leeyom/workspace/github/chrome-bookmarks-sync/bookmark.json";
private static final String README_PATH = "/Users/leeyom/workspace/github/chrome-bookmarks-sync/README.md";
但是实际在 github action
中,取的是相对地址,如果取绝对地址,会报文件找不到的问题:
private static final String BOOKMARK_JSON_PATH = "bookmark.json";
private static final String README_PATH = "README.md";
fork 仓库 chrome-bookmarks-sync仓库
修改chrome_bookmarks_sync.yml
文件的环境变量:
env:
GITHUB_NAME: 改成你自己的github用户名
GITHUB_EMAIL: 改成你自己的github邮箱
设置 G_TOKEN
常量,复制你创建的 github token
,在该仓库下:Settings-->Secrets-->New repository secret
,将此常量填入进去,变量名设置为G_TOKEN
即可。
安装 Chrome
插件「书签同步」,依次输入用户名、凭据、仓库名、文件存放路径
填写完用户数据后,便可以进行「上传」或「下载」操作,然后借助 github action
,就可以自动生成 README.md
RDB持久化
、AOF持久化
和RDB-AOF混合持久化
等多种持久化方式以供用户选择。如果用户有需要,也可以完全关闭持久化功能,让服务器处于无持久化状态
。
RDB
的全称是 Redis DataBase
,RDB持久化
是 Redis 默认使用的持久化功能,通过创建以.rdb
后缀结尾的二进制文件,该文件包含了服务器在各个数据库中存储的键值对数据等信息。Redis 提供了三种创建 RDB 文件的方法,分别是:手动执行SAVE命令
、手动执行BGSAVE命令
、通过配置选项自动创建等三种方式。
那 RDB 文件的结构是咋样的呢?它由如下几部分组成:
结构 | 解释 |
---|---|
RDB 文件标识符 | 文件最开头的部分为 RDB 文件标识符,这个标识符的内容为"REDIS"这5个字符。Redis 服务器在尝试载入 RDB 文件的时候,可以通过这个标识符快速地判断该文件是否为真正的 RDB 文件。 |
版本号 | 版本号是一个字符串格式的数字,长度为4个字符。新版 Redis 服务器总是能够向下兼容旧版 Redis 服务器生成的 RDB 文件。比如,生成第9版 RDB 文件的 Redis 5.0 既能够正常读入由 Redis 4.0 生成的第8版 RDB 文件。 |
设备附加信息 | 记录了生成 RDB 文件的 Redis 服务器及其所在平台的信息,比如服务器的版本号、宿主机器的架构、创建 RDB 文件时的时间戳、服务器占用的内存数量等。 |
数据库数据 | 记录了 Redis 服务器存储的0个或任意多个数据库的数据,各个数据库的数据将按照数据库号码从小到大进行排列,每个数据库里面存放的是键值对数据。 |
Lua 脚本缓存 | 如果 Redis 服务器启用了复制功能,那么服务器将在 RDB 文件的 Lua 脚本缓存部分保存所有已被缓存的 Lua 脚本。这样一来,从服务器在载入 RDB 文件完成数据同步之后,就可以继续执行主服务器发来的 EVALSHA 命令了。 |
EOF | 用于标识 RDB 正文内容的末尾,它的实际值为二进制值 0xFF。 |
CRC64校验和 | RDB文件的末尾是一个以无符号64位整数表示的 CRC64 校验和,用于校验 RDB 文件是否有出错或损坏的情况。 |
Redis 服务器载入 RDB 文件的整体流程:打开 RDB 文件 --> 检查文件头 --> 检查版本号 --> 读取设备信息 --> 重建数据库 --> 重建脚本缓存 --> 对比校验和 --> 数据载入完毕。
可以通过SAVE
命令,以同步方式创建出一个记录了服务器当前所有数据库数据的 RDB 文件。SVAE
命令是一个无参数命令,创建成功后,返回 OK 提示:
redis> SAVE
OK
由于是同步方式进行创建的 RDB 文件,那在SAVE
命令执行期间,Redis 服务器将阻塞,直到RDB文件创建完毕为止。如果 Redis 服务器在执行SAVE
命令时已经拥有了相应的 RDB 文件,那么服务器将使用新创建的 RDB 文件代替已有的 RDB 文件。
那BGSAVE
其实就是解决SAVE
命令的阻塞问题的,它与SAVE
命令的不同之处在于,BGSAVE
命令是异步执行的,BGSAVE
不会直接使用 Redis 服务器进程创建 RDB文件,而是使用子进程创建 RDB 文件。
redis> BGSAVE
Background saving started
用户执行BGSAVE
命令,Redis 的整个执行流程如下:
SAVE
命令,创建新的RDB文件虽然说BGSAVE
命令是异步执行的,Redis 服务器在BGSAVE
命令执行期间仍然可以继续处理其他客户端发送的命令请求,不会阻塞服务器,但由于执行BGSAVE
命令需要创建子进程,所以父进程占用的内存数量越大,创建子进程这一操作耗费的时间也会越长,因此 Redis 服务器在执行BGSAVE
命令时,仍然可能会由于创建子进程而被短暂地阻塞。
除了手动执行BGSAVE
和SAVE
命令创建RDB文件外,Redis 还支持通过配置选项自动创建 RDB 文件:
save <seconds> <changes>
对于 seconds
和 changes
参数,可以这么理解:如果服务器在 seconds
秒之内,对其包含的各个数据库总共执行了至少 changes
次修改,那么服务器将自动执行一次BGSAVE
命令。
当用户向 Redis 服务器提供多个 save 选项,只要满足其中任意一个选项,就会自动执行一次BGSAVE
命令。
save 6000 1
save 6000 10
save 6000 100
为了避免因满足条件,而频繁触发执行BGSAVE
命令,Redis 服务器在每次成功创建 RDB 文件之后,负责自动触发BGSAVE
命令的时间计数器以及修改次数计数器都会被清零并重新开始计数。
无论用户使用的是SAVE
命令还是BGSAVE
命令,停机时服务器丢失的数据量将取决于创建 RDB 文件的时间间隔:间隔越长,停机时丢失的数据也就越多。
RDB 持久化是一种全量持久化操作,它在创建 RDB 文件时需要存储整个服务器包含的所有数据,并因此消耗大量计算资源和内存资源,所以用户是不太可能通过增大 RDB 文件的生成频率来保证数据安全的。
从 RDB 持久化的特征来看,它更像是一种数据备份手段而非一种普通的数据持久化手段。为了解决 RDB 持久化在停机时可能会丢失大量数据这一问题,并提供一种真正符合用户预期的持久化功能,Redis 推出 AOF 持久化模式。
与全量式的 RDB 持久化功能不同,AOF 提供的是增量式的持久化功能,这种持久化的核心原理在于:服务器每次执行完写命令之后,都会以协议文本的方式将被执行的命令追加到 AOF 文件的末尾。这样一来,服务器在停机之后,只要重新执行 AOF 文件中保存的 Redis 命令,就可以将数据库恢复至停机之前的状态,有点像 MySQL 的 binlog
。
时间 | 事件 | AOF 文件记录的命令 |
---|---|---|
T0 | 执行命令:SET K1 V1 |
SELECT 0 SET K1 V1 |
T1 | 执行命令:SET K2 V2 |
SELECT 0 SET K1 V1 SET K2 V3 |
随着服务器不断地执行命令,被执行的命令也会不断地被保存到 AOF 文件中。其实在实际的 AOF 文件中,命令都是以 Redis 网络协议的方式保存的,比如:
*2\r\n$6\r\nSELECT\r\n$1\r\n0\r\n
*3\r\n$3\r\nSET\r\n$2\r\nk1\r\n$2\r\nv1\r\n
*3\r\n$3\r\nSET\r\n$2\r\nk2\r\n$2\r\nv2\r\n
Redis 服务器执行:
appendonly yes
:开启 AOF 持久化功能,Redis 服务器在默认情况下将创建一个名为appendonly.aof
的文件作为AOF 文件。
appendonly no
:关闭 AOF 持久化功能。
为了提高程序的写入性能,现代化的操作系统通常会把针对硬盘的多次写操作优化为一次写操作。当程序调用 write 系统对文件进行写入时,系统并不会直接把数据写入硬盘,而是会先将数据写入位于内存的缓冲区中,等到指定的时限到达或者满足某些写入条件时,系统才会执行 flush 系统调用,将缓冲区中的数据冲洗至硬盘。
Redis 向用户提供了appendfsync
选项,以此来控制系统冲洗 AOF 文件的频率:
appendfsync <value>
通常有三个可选值,分别是:
always
:每执行一个写命令,就对 AOF 文件执行一次冲洗操作。如果服务器停机,此时最多只会丢失一个命令的数据,但使用这种冲洗方式将使 Redis 服务器的性能降低至传统关系数据库的水平。everysec
:每隔 1s,就对 AOF 文件执行一次冲洗操作。服务器在停机时最多只会丢失 1s 之内产生的命令数据,这是一种兼顾性能和安全性的折中方案。no
:不主动对 AOF 文件执行冲洗操作,由操作系统决定何时对 AOF 进行冲洗。服务器在停机时将丢失系统最后一次冲洗 AOF 文件之后产生的所有命令数据,至于数据量的具体大小则取决于系统冲洗 AOF 文件的频率。所以 Redis 使用everysec
作为appendfsync
选项的默认值。除非有明确的需求,否则用户不应该随意修改appendfsync
选项的值。
随着服务器不断运行,被执行的命令将变得越来越多,而负责记录这些命令的 AOF 文件也会变得越来越大。与此同时,如果服务器曾经对相同的键执行过多次修改操作,那么 AOF 文件中还会出现多个冗余命令。
SELECT 0
SET msg "hello world! "
SET msg "good morning! "
SET msg "happy birthday! "
SADD fruits "apple"
SADD fruits "banana"
SADD fruits "cherry"
SADD fruits "dragon fruit"
SREM fruits "dragon fruit"
SADD fruits "durian"
RPUSH job-queue 10086
以上命令,重写后最终可以简化为:
SELECT 0
SET msg "happy birthday! "
SADD fruits "apple" "banana" "cherry" "durian"
RPUSH job-queue 10086
为了减少冗余命令,让 AOF 文件保持“苗条”,并提供数据恢复操作的执行速度,Redis 提供了 AOF 重写功能BGREWRITEAOF
命令,该命令能够生成一个全新的 AOF 文件,并且文件中只包含恢复当前数据库所需的尽可能少的命令。
与 RDB 持久化的 BGSAVE
命令一样,BGREWRITEAOF
命令也是一个异步命令,Redis 服务器在接收到该命令之后会创建出一个子进程,由它扫描整个数据库并生成新的 AOF 文件。当新的 AOF 文件生成完毕,子进程就会退出并通知 Redis 服务器(父进程),然后 Redis 服务器就会使用新的 AOF 文件代替已有的 AOF 文件,借此完成整个重写操作。
除了手动执行BGREWRITEAOF
命令,也可以通过配置自动触发BGREWRITEAOF
命令:
auto-aof-rewrite-min-size <value>
:选项用于设置触发自动 AOF 文件重写所需的最小 AOF 文件体积,当 AOF 文件的体积大于给定值时,服务器将自动执行BGREWRITEAOF
命令,该值的默认值是 64mb。
auto-aof-rewrite-percentage <value>
:它控制的是触发自动 AOF 文件重写所需的文件体积增大比例。举个例子,如果此值设置为 100,表示如果当前 AOF 文件的体积比最后一次 AOF 文件重写之后的体积增大了一倍(100%),那么将自动执行一次BGREWRITEAOF
命令。
优点:
everysec
选项,用户可以将数据丢失的时间窗口限制在 1s 之内。缺点:
BGREWRITEAOF
命令与 RDB 持久化使用的BGSAVE
命令一样都需要创建子进程,所以在数据库体积较大的情况下,进行 AOF 文件重写将占用大量资源,并导致服务器被短暂地阻塞。由于 RDB 持久化和 AOF 持久化都有各自的优缺点,因此在很长一段时间里,如何选择合适的持久化方式成了很多 Redis 用户面临的一个难题。为了解决这个问题,Redis 从 4.0 版本开始引入 RDB-AOF 混合持久化模式,这种模式是基于 AOF 持久化模式构建而来的,如果用户打开了服务器的 AOF 持久化功能,并且将
aof-use-rdb-preamble <value>
设置为 yes,那么 Redis 服务器在执行 AOF 重写操作时,就会像执行BGSAVE
命令那样,根据数据库当前的状态生成出相应的 RDB 数据,并将这些数据写入新建的 AOF 文件中,至于那些在 AOF 重写(BGREWRITEAOF
)开始之后执行的 Redis 命令,则会继续以协议文本的方式追加到新 AOF 文件的末尾,即已有的 RDB 数据的后面。
所以,开启了 RDB-AOF 混合持久化后,服务器生成的 AOF 文件将由两个部分组成,其中位于 AOF 文件开头的是 RDB 格式的数据,而跟在 RDB 数据后面的则是 AOF 格式的数据。
结构 |
---|
RDB 数据 |
AOF 数据 |
当一个支持 RDB-AOF 混合持久化模式的 Redis 服务器启动并载入 AOF 文件时,它会检查 AOF 文件的开头是否包含了 RDB 格式的内容:
所以为了避免全新的 RDB-AOF 混合持久化功能给传统的 AO F持久化功能使用者带来困惑,Redis 目前默认是没有打开 RDB-AOF 混合持久化功能的:aof-use-rdb-preamble no
,如果要开启,需要用户手动设置 value 为 yes。
即使用户没有显式地开启 RDB 持久化功能和 AOF 持久化功能,Redis 服务器也会默认使用以下配置进行 RDB 持久化:
save 6010000
save 300100
save 3600 1
如果用户想要彻底关闭这一默认的 RDB 持久化行为,让 Redis 服务器处于完全的无持久化状态,那么可以在服务器启动时向它提供以下配置选项:
save ""
这样一来,服务器将不会再进行默认的 RDB 持久化,从而使得服务器处于完全的无持久化状态中。处于这一状态的服务器在关机之后将丢失关机之前存储的所有数据,这种服务器可以用作单纯的内存缓存服务器。
如何优雅的关闭 Redis 服务器呢?那就是使用 SHUTDOWN
命令,执行该命令,将执行如下动作:
所以只要服务器启用了持久化功能,那么使用SHUTDOWN
命令来关闭服务器就不会造成任何数据丢失。SHUTDOWN
命令提供的 save
选项或者 nosave
选项,显式地指示服务器在关闭之前是否需要执行持久化操作:
SHUTDOWN [save|nosave]
如果用户给定的是 save
选项,那么无论服务器是否启用了持久化功能,服务器都会在关闭之前执行一次持久化操作。如果用户给定的是 nosave
选项,那么服务器将不执行持久化操作,直接关闭服务器。在这种情况下,如果服务器在关闭之前曾经修改过数据库,那么它将丢失那些尚未保存的数据。
总的来说,在数据持久化这个问题上,Redis 4.0 及之后版本的使用者都应该优先使用 RDB-AOF 混合持久化;对于 Redis 4.0 之前版本的使用者,因为 RDB 持久化更接近传统意义上的数据备份功能,而 AOF 持久化则更接近于传统意义上的数据持久化功能,所以如果用户不知道自己具体应该使用哪种持久化功能,那么可以优先选用 AOF 持久化作为数据持久化手段,并将 RDB 持久化用作辅助的数据备份手段。
]]>LVS 是一个开源的软件,可以实现传输层四层负载均衡。LVS 是 Linux Virtual Server 的缩写,意思是 Linux 虚拟服务器。目前有三种 IP 负载均衡技术(VS/NAT、VS/TUN和VS/DR);八种调度算法:轮询、加权轮询、源地址散列、目标地址散列、最小连接数、加权最少连接数、最短期望延迟、最少队列调度。
NAT:
TUN:
DR:
Keepalived 是基于 vrrp 协议的一款高可用软件。Keepailived 有一台主服务器和多台备份服务器,在主服务器和备份服务器上面部署相同的服务配置,使用一个虚拟 IP 地址(Virtual IP,简称 VIP)对外提供服务,当主服务器出现故障时,虚拟 IP 地址会自动漂移到备份服务器,能够真正做到主服务器和备份服务器故障时 IP 瞬间无缝交接。
创建基础镜像 centos_base
,基础镜像里面安装了常用的工具,比如 vim
、wget
、zlib
等,打包镜像的时候,为了方便,使用 docker
的commit
命令,但是实际推荐还是使用 Dockerfile
定制镜像,使用 docker commit
意味着所有对镜像的操作都是黑箱操作,生成的镜像也被称为黑箱镜像,官方并不推荐。
# 拉取centos7镜像
docker pull centos:7
# 创建容器centos1
docker run -itd --name centos1 centos:7
# 进入容器centos1
docker exec -it centos1 bash
# 进入容器centos1后,安装常用的工具
yum update
yum install -y vim
yum install -y wget
yum install -y gcc-c++
yum install -y pcre pcre-devel
yum install -y zlib zlib-devel
yum install -y openssl-devel
yum install -y popt-devel
yum install -y initscripts
yum install -y net-tools
# 常用工具安装完成后,退出容器centos1,然后将该容器重新打包成新的镜像centos_base
docker commit -a 'leeyom' -m 'centos with common tools' centos1 centos_base
删除之前创建的 centos1
容器,重新以镜像 centos_base
为基础镜像创建容器centos_temp
,并安装 keepalived
和nginx
。
# 终止centos1容器
docker container stop centos1
# 删除centos1容器
docker container rm centos1
# 创建基础镜像容器centos_temp,使用privileged参数,表示容器内的root用户拥有真正的root权限
# 容器内需要使用systemctl服务,需要加上/usr/sbin/init
docker run -it --name centos_temp -d --privileged centos_base /usr/sbin/init
# 进入centos_temp容器
docker exec -it centos_temp bash
# 安装nginx的依赖库
rpm -Uvh http://nginx.org/packages/centos/7/noarch/RPMS/nginx-release-centos-7-0.el7.ngx.noarch.rpm
# 安装nginx
yum install -y nginx
# 启动nginx
systemctl start nginx.service
# 测试nginx是否安装成功,若安装成功会显示nginx的欢迎界面的html代码
curl 172.17.0.2
# 下载keepalived
wget http://www.keepalived.org/software/keepalived-1.2.18.tar.gz
# 解压keepalived安装包
tar -zxvf keepalived-1.2.18.tar.gz -C /usr/local/
# 安装keepalived依赖的插件openssl
yum install -y openssl openssl-devel
# 编译keepalived
cd /usr/local/keepalived-1.2.18/ && ./configure --prefix=/usr/local/keepalived
make && make install
# 将keepalived安装成系统服务
mkdir /etc/keepalived
cp /usr/local/keepalived/etc/keepalived/keepalived.conf /etc/keepalived/
cp /usr/local/keepalived/etc/sysconfig/keepalived /etc/sysconfig/
cp /usr/local/keepalived/sbin/keepalived /usr/sbin/
修改keepalived
的配置文件 keepalived.conf
,同时设置 keepalived
开机自启。
# 备份配置文件
cp /etc/keepalived/keepalived.conf /etc/keepalived/keepalived.conf.backup
# 删除默认的配置文件,自己重新创建一个keepalived.conf文件
cd /etc/keepalived/
rm -f keepalived.conf
vim keepalived.conf
keepalived.conf
文件的配置内容如下:
vrrp_script chk_nginx {
# nginx心跳检测脚本
script "/etc/keepalived/nginx_check.sh"
interval 2
weight -20
}
vrrp_instance VI_1 {
# 指定master
state MASTER
interface eth0
# 路由id,所有服务器指定一致
virtual_router_id 121
# 当前容器ip地址
mcast_src_ip 172.17.0.2
priority 100
nopreempt
advert_int 1
authentication {
auth_type PASS
auth_pass 1111
}
track_script {
chk_nginx
}
# 虚拟ip
virtual_ipaddress {
172.17.0.100
}
}
keepalived
是通过检测 keepalived
进程是否存在判断服务器是否宕机,如果 keepalived
进程在但是nginx
进程不在了那么keepalived
是不会做主备切换,所以我们需要写个脚本来监控 nginx
进程是否存在,如果 nginx
不存在就将 keepalived
进程杀掉:
# 在/etc/keepalived/目录下创建监控脚本
vim nginx_check.sh
# 脚本内容
#!/bin/bash
A=`ps -C nginx –no-header |wc -l`
if [ $A -eq 0 ];then
/usr/local/nginx/sbin/nginx
sleep 2
if [ `ps -C nginx --no-header |wc -l` -eq 0 ];then
killall keepalived
fi
fi
# 给脚本赋予执行权限
chmod +x nginx_check.sh
# keepalived开机自启
systemctl enable keepalived.service
chkconfig keepalived on
systemctl start keepalived.service
将nginx
设置为开机自启:
systemctl enable nginx.service
chkconfig nginx on
检测虚拟 ip 是否成功,在宿主机内执行如下的命令:
curl 172.17.0.100
如果出现 nginx
欢迎界面,则表示成功:
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
body {
width: 35em;
margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif;
}
</style>
</head>
<body>
<h1>Welcome to nginx master !</h1>
<p>If you see this page, the nginx web server is successfully installed and
working. Further configuration is required.</p>
<p>For online documentation and support please refer to
<a href="http://nginx.org/">nginx.org</a>.<br/>
Commercial support is available at
<a href="http://nginx.com/">nginx.com</a>.</p>
<p><em>Thank you for using nginx.</em></p>
</body>
</html>
将centos_temp
容器重新打包成镜像centos_kn
,然后利用这个新镜像再创建两个容器centos_master
和centos_slave
,实现热备效果。
# 创建新的镜像centos_kn
docker commit -a 'leeyom' -m 'centos with keepalived nginx' centos_temp centos_kn
# 删除容器centos_temp
docker container stop centos_temp
docker container rm centos_temp
# 用centos_kn镜像创建主服务器容器centos_master
docker run --privileged -tid --name centos_master --restart=always centos_kn /usr/sbin/init
进入centos_master
,修改centos_master
容器里面nginx
欢迎页的标题为:Welcome to nginx master
,用于区分我们当前访问的是master
容器的nginx
。
vim /usr/share/nginx/html/index.html
创建从服务器容器centos_slave
,并进入容器,修改keepalived.conf
配置文件,主要是state
和priority
、mcast_src_ip
三个参数的调整,其中master
节点的priority
值一定要比slave
大才行。
# 创建容器centos_slave
docker run --privileged -tid --name centos_slave --restart=always centos_kn /usr/sbin/init
# 进入容器
docker exec -it centos_slave bash
# 编辑keepalived.conf文件
vim /etc/keepalived/keepalived.conf
vrrp_script chk_nginx {
script "/etc/keepalived/nginx_check.sh"
interval 2
weight -20
}
vrrp_instance VI_1 {
state SLAVE
interface eth0
virtual_router_id 121
mcast_src_ip 172.17.0.3
priority 80
nopreempt
advert_int 1
authentication {
auth_type PASS
auth_pass 1111
}
track_script {
chk_nginx
}
virtual_ipaddress {
172.17.0.100
}
}
修改centos_slave
容器里面nginx
欢迎页的标题为:Welcome to nginx slave
,用于区分我们当前访问的是slave
容器的nginx
。
vim /usr/share/nginx/html/index.html
修改完后,重新加载keepalived
服务:
systemctl daemon-reload
systemctl restart keepalived.service
开始测试:
分别在宿主机,centos_master
、centos_slave
中进行一下命令测试,如果nginx
都显示为master
的欢迎页面,说明配置是没啥问题的。
curl 172.17.0.100
关闭centos_master
容器,模拟master
机器故障,在centos_slave
执行命令测试,如果nginx
显示的欢迎页面由master
切换到了slave
,说明进行了故障转移,vip主机进行了漂移,主机挂掉后,备用机顶上。
curl 172.17.0.100
重新启动centos_master
容器,再次执行命令测试,看nginx
欢迎页面标题,slave
切换到了master
,如果切换成功,说明我们配置到此成功了。
curl 172.17.0.100
以上便是模拟的nginx-keepalived
双机热备机制,到此,所有的验证和预期的一致,也达到我们借助docker
为基础来实现了整套基于Nginx+Keepalived
高可用的方案了。
systemctl daemon-reload
:重新加载systemctl enable keepalived.service
: 设置开机自动启动systemctl disable keepalived.service
:取消开机自动启动systemctl start keepalived.service
:启动systemctl stop keepalived.service
:停止systemctl status keepalived.service
:查看服务状态作者:尼克·利特尔黑尔斯;数量:13个笔记;时间:2020-11-22 11:18:11
其实我这台黑苹果,今年年初三月份的时候就装好了,周末趁着有空,把系统升级到了 macOS Big Sur
,在此总结下自己的整个的安装的一些心得。
我这台黑苹果主机的整体配置清单如下:
cpu
:intel i5 9400 散片 淘宝 1300元主板
:华擎B365M-ITX/AC 淘宝 659元显卡
:盈通 rx580 4g 2304sp 满血版 508元硬盘
:海康威视 C2000 pro 256g SSD 京东 335 元 + 「自用剩余的闪迪」 SATA3 256g SSD 内存条
:宇瞻 DDR4 2666 16g*2 天猫 978元散热器
:ID-COOLING IS-40X 淘宝 89元显卡延长线
:傻瓜超人 ADT01 淘宝 99元(方便后期加显卡)电源
:海盗船 sf450 金牌 + 定制线 淘宝 728元机箱
:傻瓜超人 K55 + 定制铝侧板两块 淘宝 405 元无线网卡
:BCM94360CS2 无线网卡+转接卡 淘宝 160元总计
:5261 元由于我是第一次装机,一来就装 ITX
主机,各种懵逼,好在说明书也挺全的,从下午一直装到晚上凌晨三四点,才把机器点亮。
那为啥要组装一台黑苹果呢?而不直接买白的呢?这个嘛,迫于入了苹果生态的坑,加上老笔记本 MacBook Pro (Retina, 13-inch, Early 2015) 性能已经逐渐跟不上了,所以就萌生了组台 itx 黑苹果主机,那说到底,其实就是「穷」。
黑苹果有优点也有缺点,优点是:
缺点:
装黑苹果,要想省事的话,去论坛,比如国内的:远景论坛、黑果小兵,国外的:tonymacx86,按照别人已经有装成功过的硬件,并且对应的EFI也都有,那就对着采购一套相同的配件,尽可能选择免驱的硬件,基本上不会出大的问题,比如我的这个网卡BCM94360CS2 就是原笔记本的拆机原件。当然,你前提得懂一些基本的硬件和软件知识,否则还是花点钱,直接远程交给某宝来装。
由于我自己装的这套配置,已经有成功的 (B365ITX-Hackintosh-OC) 例子,只要按照原作者给的提示,把对应的该删的删了,整体的安装过程,基本上很顺畅。整体的安装流程,参考的是黑果大神「黑果小兵」的教程「天逸510s Mini兼macOS BigSur安装教程」,镜像也是用的黑果小兵大神封装的「macOS BigSur 11.0.1 20B29 正式版」。
目前主流的两到黑苹果引导方式:OpenCore(简称OC)和 Clover(简称四叶草),目前 Clover 逐渐被淘汰了,很多的驱动 Kext 都放弃适配 Clover,大家目前都开始使用 OC 做为黑苹果的首选引导方式。
在安装过程中没遇到大的问题,就是通过「时间机器」恢复备份的时候,遇到一个比较坑爹的事情,我顺利的进入系统后,我打开系统自带的「迁移助理」,想恢复原有的备份,但是每次恢复到一半的时候,老是自动关机,重试几次,都是一样,很让人崩溃。最后,我不得不再次抹盘重装,在刚刚进入系统初始化的时候,系统提示是否要导入已有的备份,我从这里开始进行恢复,不等彻底登录进入系统后,通过「迁移助理」进行恢复,嚯,好家伙,顺顺利利的恢复成功了,你说这奇怪不奇怪。
再个就是修改引导工具 OpenCore 的相关的配置的时候,不生效的问题。比如原作者的 EFI,启动的时候开启了啰嗦模式(-v
),系统启动的时候,总会打印一大串的debug的代码,所以为了隐藏这个,得把 EFI 里 config.plist
的 boot-args
属性里的 -v
去掉即可,但是发现去掉后,重启依旧不生效,后面通过找资料,最后发现,需要用工具 Hackinttools
,清除 NVRAM
里这行配置的缓存(选中删除即可),否则不会生效。
另外启动的时候,不想显示引导菜单的话,将 showpicker
改成 false
,其他的话,目前没有遇到啥问题,由于大部分原作者已经调试好了,基本上就不需要调整了,感谢!
目前这台黑苹果的整体的完整度在99%吧,唯一的缺点就是,睡眠的话,需要手动去点击系统左上角的「睡眠」选项,有时候系统无法自动进入睡眠。我自己也使用了大半年了,整体上和苹果的台式机 iMac、Mac Pro 基本上无任何的差别,也能配合 iPad、iPhone、Apple Watch 进行隔空投送、接力、随航、解锁 Mac等等一系列的功能。在会自己折腾的情况下,还是挺香的。所以如果你有差不多相同的配置,可以试试我这个 EFI。
ip_hash
nginx
官网是这样定义的:
Specifies that a group should use a load balancing method where requests are distributed between servers based on client IP addresses. The first three octets of the client IPv4 address, or the entire IPv6 address, are used as a hashing key. The method ensures that requests from the same client will always be passed to the same server except when this server is unavailable. In the latter case client requests will be passed to another server. Most probably, it will always be the same server as well.
翻译过来就是:
指定组应使用负载平衡方法,其中根据客户端IP地址在服务器之间分配请求。 客户端IPv4地址的前三个八位位组或整个IPv6地址用作哈希密钥。 该方法确保了来自同一客户端的请求将始终传递到同一服务器,除非该服务器不可用。 在后一种情况下,客户端请求将传递到另一台服务器。 最有可能的是,它也将永远是同一台服务器。
假设目前有三台服务器,采用 nginx 的ip_hash
负载均衡策略,假设现在有三台 Tomcat 服务器,其对应的节点 index
分别是:0、1、2,此时有四个客户端访问 nginx,那根据哈希算法:hash(ip)%node_counts = index
,最终得到的各个客户端的请求会落到如下的机器上(假设四个客户端ip的哈希值分别为:5、6、7、8):
ip
:指的是客户端的 ip 地址的前三个八位位组,打个比方:138.23.324.13
,那就是取138.23.324
,所以如果同一个局域网内访问 nginx 的时候,如果机器的前三个八位一致,比如这两个ip:138.23.324.13
、138.23.324.14
的请求最终只会落在同一台的节点上node_counts
:节点数量index
:节点的标识但是使用哈希算法会存在一些问题,比如要删除一个节点3,这时候用户的请求落地情况会变成这样:
原本用户4是访问的节点3,这时候就会转为访问节点1,这时候在之前节点3上的用户会话就会丢失,增加一个节点也是同理,同样会存在用户会话丢失的情况。如何正确让一台服务器下线?那在 nginx 官网的文档中写道:如果需要临时删除其中一个服务器,则应该使用 down 参数标记它,以便保存当前客户机 IP 地址的散列。
If one of the servers needs to be temporarily removed, it should be marked with the
down
parameter in order to preserve the current hashing of client IP addresses.
示例:
upstream backend {
ip_hash;
server backend1.example.com;
server backend2.example.com;
server backend3.example.com down;
server backend4.example.com;
}
如果采用一致性哈希算法,出现以上问题的概率就会低很多。
如上图,圆环上有 0-2^32-1
个节点,每个节点,按顺时针方向排列递增,用户的请求 ip,通过哈希算法,会落在圆环上的某个节点上。按顺时针方向,用户1、用户2会访问节点1,用户3、用户4会访问节点2,用户5、用户6会访问节点3,用户7访问到节点4。假如此时删除了节点3,那么原本的用户5和用户6的请求,则会落到节点4上,其他用户的访问均不会受到影响,只会有用户5和用户6的会话信息会丢失,不会造成全局的变动。
另外还有一个优点,如果发现节点1访问量很大,负载高于其他节点,这就说明节点1存储的数据是热点数据。这时候,为了减少节点1的负载,我们可以在热点数据位置再加入一个node,用来分担热点数据的压力。
]]>./nginx -s stop
:强制停止nginx
./nginx -s quit
: 优雅停止nginx,即处理完所有请求后再停止服务
./nginx -t
:检测配置文件是否有语法错误
./nginx -v
: 查看nginx的版本号
./nginx -V
:查看版本号和配置选项信息
./nginx -c
:设置配置文件(默认是:/etc/nginx/nginx.conf
)
./nginx -s reload
: 重新加载配置文件
docker pull nginx
docker run -itd --name nginx-demo -p 8080:80 nginx
-itd
:-t
选项让 Docker 分配一个伪终端(pseudo-tty)并绑定到容器的标准输入上, -i
则让容器的标准输入保持打开,-d
是后台运行--name nginx-demo
:指定容器实例名称nginx-demo
-p 8080:80
:将本机 8080 端口映射为容器的 80 端口docker exec -it nginx-demo bash
docker container stop nginx-demo
docker container rm nginx-demo
docker container start nginx-demo
docker container ls
docker container ls -a
# 设置worker进程的用户,指的linux中的用户,会涉及到nginx操作目录或文件的一些权限
user nginx;
# worker进程工作数设置,一般来说CPU有几个,就设置几个
worker_processes 1;
# 设置日志级别,debug | info | notice | warn | error | crit | alert | emerg,错误级别从左到右越来越大
error_log /var/log/nginx/error.log warn;
# 设置nginx进程 pid
pid /var/run/nginx.pid;
# 设置工作模式
events {
# 每个worker允许连接的客户最大连接数
worker_connections 1024;
}
# http 是指令块,针对http网络传输的一些指令配置
http {
# include 引入外部配置,提高可读性,避免单个配置文件过大
include /etc/nginx/mime.types;
# 设置HTTP默认的 content-type
default_type application/octet-stream;
# 设置日志格式,各项含义如下:
# $remote_addr:客户端ip
# $remote_user:远程客户端用户名,一般为:’-’
# $time_local:时间和时区
# $request:请求的url以及method
# $status:响应状态码
# $body_bytes_send:响应客户端内容字节数
# $http_referer:记录用户从哪个链接跳转过来的
# $http_user_agent:用户所使用的代理,一般来时都是浏览器
# $http_x_forwarded_for:通过代理服务器来记录客户端的ip
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
# sendfile 使用高效的文件传输,提升传输性能,启用后才能使用tcp_nopush,指当数据表累积到一定的大小后才发送,提高效率
sendfile on;
#tcp_nopush on;
# 设置客户端与服务端请求的超时时间,保证客户端多次请求的时候不会重复建立新的连接,节约资源损耗
keepalive_timeout 65;
# 开启gzip压缩功能,提高传输效率,节约带宽
#gzip on;
# include 引入外部配置,提高可读性,避免单个配置文件过大
include /etc/nginx/conf.d/*.conf;
}
假如服务器路径为:/home/leeyom/files/img/header.png
root 路径完全匹配访问:
location /leeyom {
root /home
}
用户访问的请求为:url:port/leeyom/files/img/header.png
alias 可以为你的路径做一个别名,对用户透明:
location /hello {
alias /home/leeyom
}
用户访问的请求为:url:port/hello/files/img/header.png
,相当于给 leeyom
目录做一个别名。
空格
:默认匹配,普通匹配
location / {
root /home
}
用户可以访问 home
目录下的所有文件。
=
:精确匹配
location = /leeyom/files/img/header.png {
root /home;
}
用户只能访问此路径/home/leeyom/files/img/header.png
下的header.png
图片。
~*
:匹配正则表达式,不区分大小写
location ~* \.(GIF|jpg|png|jpeg|gif) {
root /home;
}
用户可以访问 home
目录下的只要后缀为GIF|jpg|png|jpeg|gif
的文件,由于不区分大小写,如果访问的是 header.GIF
图片,会重定向访问header.gif
图片。
~
:匹配正则表达式,区分大小写
location ~ \.(GIF|jpg|png|jpeg|gif) {
root /home;
}
用户可以访问 home
目录下的只要后缀为GIF|jpg|png|jpeg|gif
的文件。
^~
:以某个字符路径开头请求
location = ^~ /leeyom/files/img {
root /home;
}
用户只能访问此路径/home/leeyom/files/img/
下的文件。
在 server
块里面增加:
# 允许跨域请求的域,*代表允许所有的域
add_header 'Access-Control-Allow-Origin' *;
# 允许带上cookie请求
add_header 'Access-Control-Allow-Credentials' 'true';
# 允许请求的header,比如:Authorization,Content-Type,Accept,Origin,User-Agent 等
add_header 'Access-Control-Allow-Headers' *;
# 允许请求的方法,比如:GET、POST、PUT、DELETE
add_header 'Access-Control-Allow-Methods' *;
# 对源站点进行验证(白名单),多个域名用空格隔开
valid_referers *.leeyom.com;
# 非法访问则返回403
if($invalid_referer){
return 403;
}
upstream tomcats {
server 192.168.1.174:8080;
server 192.168.1.175:8080;
server 192.168.1.176:8080;
}
server {
listen 80;
server_name www.tomcats.com;
location / {
proxy_pass: http://tomcats;
}
}
访问www.tomcats.com
,将以轮询方式,分别访问三台 Tomcat,当然也可以使用加权轮询,例如:
server 192.168.1.174:8080 weight=1;
server 192.168.1.175:8080 weight=2;
server 192.168.1.176:8080 weight=5;
weight
的值越大,当前服务器的 Tomcat 被访问的几率越大。
server 192.168.1.174:8080 max_conns=2;
server 192.168.1.175:8080 max_conns=2;
server 192.168.1.176:8080 max_conns=2;
max_conns
:限制每台server的连接数,用于保护避免过载,可起到限流作用;server 192.168.1.174:8080 weight=1;
server 192.168.1.175:8080 weight=2;
server 192.168.1.176:8080 weight=5 slow_start=60s;
slow_start
:缓慢启动,weight
逐渐增大,使某台服务器慢慢加入集群,方便该服务器完成一些前置化的操作,该指令需要注意:hash
和random load balancing
中;down
:标记服务节点不可用backup
:表示当前服务器节点是备用机, 只有在其他的服务器都宕机以后, 自己才会加入到集群中, 被用户访问到backup
参数不能使用在hash
和random load balancing
中;max_fails
:表示失败几次,则标记 server 已宕机,踢出服务,默认值为1fail_timeout
:表示失败的重试时间,默认值 10smax_fails=2 fail_timeout=15s
:15 秒内,请求某一 server 失败达 2 次后,则认为此 server 已经宕机,随后再过 15 秒,这 15 秒内不会有新的请求到达刚宕机的节点,会请求到正常的运行的 server,15秒后会有新请求再次请求挂掉的 server,如果还是失败,重复之前的操作;upstream tomcats {
server 192.168.1.174:8080;
server 192.168.1.175:8080;
server 192.168.1.176:8080;
# 设置长连接处理的数量
keepalive 32;
}
server {
listen 80;
server_name www.tomcats.com;
location / {
proxy_pass: http://tomcats;
# 设置长连接http的版本号
proxy_http_version 1.1;
# 清除 connection header 信息
proxy_set_header Connection "";
}
}
# proxy_cache_path 设置缓存目录
# keys_zone 设置共享内存以及占用空间大小
# max_size 设置缓存大小
# inactive 超过此时间则被清理
# use_temp_path 临时目录,使用后会影响nginx性能
proxy_cache_path /usr/local/nginx/upstream_cache keys_zone=mycache:5m max_size=1g inactive=1m use_temp_path=off;
location / {
proxy_pass http://tomcats;
# 启用缓存,和keys_zone一致
proxy_cache mycache;
# 针对200和304状态码缓存时间为8小时
proxy_cache_valid 200 304 8h;
}
安装 ssl
模块
将 ssl 证书*.crt
和私钥*.key
拷贝到/usr/local/nginx/conf
目录中
新增 server 监控 443 端口:
server{
listen 443;
server_name www.leeyom.me;
# 开启ssl
ssl on;
# 配置ssl证书
ssl_certificate yourdomain.com.crt;
# 配置证书秘钥
ssl_certificate_key yourdomain.com.key;
# ssl会话cache
ssl_session_cache shared:SSL:1m;
# ssl会话超时时间
ssl_session_timeout 5m;
# 配置加密套件,写法遵循 openssl 标准
ssl_protocols TLSv1 TLSv1.1 TLSv1.2; ssl_ciphers ECDHE-RSA-AES128-G
}
首先明白这个几个简称的含义:
BIO、NIO、异步阻塞、AIO 这四者之间有啥区别呢?那就拿生活的实例来解释一下:
从这个生活实例中能可以看得出来:
同步
就是我需要自己每隔一段时间,以轮训的方式去看看有没有空的坑位;异步
则是有人拉完茅坑会通知你,通知你后,你再回去蹲;阻塞
就是你在等待的过程中,你不去做任何的事情,就干等着;非阻塞
就是你在等待的过程中,可以去做其他的事情,比如抽烟、玩手机等;总结就是:异步的优势显而易见,大大优化用户体验, 非阻塞使得系统资源开销远远小于阻塞模式,因为系统不需要创建新的进程(或线程),大大地节省了系统资源。
发现一篇解说的很清楚的文章:《常见的IO模型有哪些?Java中的BIO、NIO、AIO 有啥区别?》
]]>假如有如下的一个数据结构:
[
{
"userId": 1,
"name": "王二狗",
"className": "classA"
},
{
"userId": 2,
"name": "李老四",
"className": "classA"
},
{
"userId": 3,
"name": "张翠花",
"className": "classB"
},
{
"userId": 4,
"name": "李雷",
"className": "classB"
}
]
需要将它按班级className
进行分组,即如下的数据结构:
{
"classA": [
{
"userId": 1,
"name": "王二狗",
"className": "classA"
},
{
"userId": 2,
"name": "李老四",
"className": "classA"
}
],
"classB": [
{
"userId": 3,
"name": "张翠花",
"className": "classB"
},
{
"userId": 4,
"name": "李雷",
"className": "classB"
}
]
}
学生实体类 Student.java
:
@Data
@AllArgsConstructor
public class Student {
private long userId;
private String name;
private String className;
}
采用Java8函数式编程 groupingBy
语法进行快速分组:
Student s1 = new Student(1, "王二狗", "classA");
Student s2 = new Student(2, "李老四", "classA");
Student s3 = new Student(3, "张翠花", "classB");
Student s4 = new Student(4, "李雷", "classB");
List<Student> list = new ArrayList<>();
list.add(s1);
list.add(s2);
list.add(s3);
list.add(s4);
Map<String, List<Student>> map = list.stream().collect(Collectors.groupingBy(Student::getClassName));
System.out.println(JSONUtil.toJsonStr(map));
还是这个数据结构:
[
{
"userId": 1,
"name": "王二狗",
"className": "classA"
},
{
"userId": 2,
"name": "李老四",
"className": "classA"
},
{
"userId": 3,
"name": "张翠花",
"className": "classB"
},
{
"userId": 4,
"name": "李雷",
"className": "classB"
}
]
只不过要按用户的 userId
进行分组,分组后的数据格式如下:
{
"1": {
"className": "classA",
"userId": 1,
"name": "王二狗"
},
"2": {
"className": "classA",
"userId": 2,
"name": "李老四"
},
"3": {
"className": "classB",
"userId": 3,
"name": "张翠花"
},
"4": {
"className": "classB",
"userId": 4,
"name": "李雷"
}
}
采用Java8函数式编程的 toMap
语法进行快速分组:
Map<Long, Student> studentMap = list.stream().collect(Collectors.toMap(Student::getUserId, student -> student, (k1, k2) -> k1));
对于简单的集合,比如字符串,整型类的集合,采用 distinct
进行快速去重:
List<String> strList = Arrays.asList("a", "b", "c", "c", "d");
List<String> distinctList = strList.stream().distinct().collect(Collectors.toList());
System.out.println(JSONUtil.toJsonStr(distinctList));
对于集合元素是对象的,根据对象指定的属性进行去重:
// 根据className去重
List<Student> unique = list.stream().collect(Collectors.collectingAndThen(
Collectors.toCollection(() -> new TreeSet<>(Comparator.comparing(Student::getClassName))), ArrayList::new));
System.out.println(JSONUtil.toJsonStr(unique));
// 根据userId和className去重
List<Student> unique2 = list.stream().collect(Collectors.collectingAndThen(
Collectors.toCollection(() -> new TreeSet<>(Comparator.comparing(o -> o.getUserId() + ";" + o.getClassName()))), ArrayList::new));
System.out.println(JSONUtil.toJsonStr(unique2));
Student s1 = new Student(1, "王二狗", "classA",10);
Student s2 = new Student(2, "李老四", "classA",9);
Student s3 = new Student(3, "张翠花", "classB",8);
Student s4 = new Student(4, "李雷", "classB", 12);
List<Student> list = new ArrayList<>();
list.add(s1);
list.add(s2);
list.add(s3);
list.add(s4);
// 按照年龄进行升序
list.sort(Comparator.comparing(Student::getAge));
// 按照年龄进行降序
list.sort(Comparator.comparing(Student::getAge).reversed());
// 查找学生当中是否存在一个叫王二狗的同学
boolean matchResult = list.stream().anyMatch(student -> "王二狗".equals(student.getName()));
List<String> cities = Arrays.asList("Milan", "London", "New York", "San Francisco");
String citiesCommaSeparated = String.join(",", cities);
System.out.println(citiesCommaSeparated);
// 输出: Milan,London,New York,San Francisco
首先看一段简单代码:
public class Pair<T> {
T first;
T second;
public Pair(T first, T second) {
this.first = first;
this.second = second;
}
public T getFirst() {
return first;
}
public T getSecond() {
return second;
}
}
这就是一个简单的泛型类,T 表示类型参数,泛型就是类型参数化,处理的数据类型不是固定的,而是可以作为参数传入。如下代码所示,对于构造方法 Pair(T first, T second)
既可以传 Integer 类型的参数,也可以传 String 类型的参数:
Pair<Integer> minmax = new Pair<Integer>(1,100);
Pair<String> kv = new Pair<String>("name", "老王");
参数类型也可以多种,类 Pair 可以改成如下所示:
public class Pair<U, V> {
U first;
V second;
public Pair(U first, V second) {
this.first = first;
this.second = second;
}
public U getFirst() {
return first;
}
public V getSecond() {
return second;
}
}
这样一来,构造方法 Pair(U first, V second)
就可以接收不同类型的参数了,既可以是 Integer,也可以是 String:
Pair<String> kv = new Pair<String>(1, "老王");
那假如我们不用泛型类,参数类型直接用 Oeject ,其实也可以满足基本的需求,将类 Pair 修改成如下的方式:
public class Pair {
Object first;
Object second;
public Pair(Object first, Object second) {
this.first = first;
this.second = second;
}
public Object getFirst() {
return first;
}
public Object getSecond() {
return second;
}
}
Pair minmax = new Pair(1,100);
Integer min = (Integer)minmax.getFirst();
Integer max = (Integer)minmax.getSecond();
但是这样会有什么坏处呢,假如我们在写代码的时候,不小心把类型弄错,但是编译器在编译的时候是不会有任何问题,在运行时候,就会抛类型转换异常:
Pair minmax = new Pair("王二狗",100);
Integer min = (Integer)minmax.getFirst();//其实这里已经类型转换异常,但是编译器编译的时候不会报错
Integer max = (Integer)minmax.getSecond();
但是如果使用了泛型的话,就可以避免这类错误,在编译期间,编译器就会报错:
Pair<String, Integer> pair = new Pair<>("王二狗",1);
Integer min = minmax.getFirst();//提示编译错误,String 类型,无法转换为 Integer 类型
Integer max = minmax.getSecond();
总结一下就是:Java 泛型是通过擦除实现的,对于泛型类,Java 编译器会将泛型代码转换为普通的非泛型代码,就像上面的普通 Pair 类代码及其使用代码一样,将类型参数 T 擦除,替换为 Object,插入必要的强制类型转换,泛型的两个好处就是:更好的安全性,更好的可读性。
接口也是可以泛型的,比如 Comparator 接口都是泛型的:
public interface Comparator<T> {
int compare(T o1, T o2);
}
那么在实现这两个类的时候,实现方法里面就得指定具体的参数类型:
public class StringComparator implements Comparator<String> {
@Override
public int compare(String o1, String o2) {
// 业务逻辑...
return 0;
}
}
除了泛型类,方法也可以是泛型的,而且,一个方法是不是泛型的,与它所在的类是不是泛型没有什么关系。我们看个例子就知道了:
public static < U, V > Pair<U, V> makePair( U first, V second ){
Pair<U, V> pair = new Pair<>( first, second );
return(pair);
}
如果没有参数类型的限定,那么类型参数 T 在擦除的时候,只能把它当作 Object,但 Java 支持限定这个参数的一个上界,也就是说,参数必须为给定的上界类型或其子类型,这个限定是通过 extends 关键字来表示的。例如,上面的 Pair 类,可以定义一个子类NumberPair,限定两个类型参数必须为 Number:
public class NumberPair<U extends Number, V extends Number> extends Pair<U, V> {
public NumberPair(U first, V second) {
super(first, second);
}
}
限定类型后,如果类型使用错误,编译器会提示。指定边界后,类型擦除时就不会转换为 Object 了,而是会转换为它的边界类型 Number,这也是容易理解的。
上面看到上界是指定的类,当然了,上界也可以是指定的接口,那么类型 T,就必须实现该上界接口,如下所示:
public static < T extends Comparable<T> > T max( T[] arr ){
T max = arr[0];
for ( int i = 1; i < arr.length; i++ ){
if ( arr[i].compareTo( max ) > 0 ){
max = arr[i];
}
}
return(max);
}
max 方法计算一个泛型数组中的最大值,计算最大值需要进行元素之间的比较,要求元素实现 Comparable 接口,所以给类型参数设置了一个上边界 Comparable, T 必须实现 Comparable 接口。
上界类型,除了类、接口,也可以是其他类型,举个例子:
public class DynamicArray<E> {
private static final int DEFAULT_CAPACITY = 10;
private int size;
private Object[] elementData;
public DynamicArray() {
this.elementData = new Object[DEFAULT_CAPACITY];
}
private void ensureCapacity(int minCapacity) {
int oldCapacity = elementData.length;
if (oldCapacity >= minCapacity) {
return;
}
int newCapacity = oldCapacity * 2;
if (newCapacity < minCapacity) {
newCapacity = minCapacity;
}
elementData = Arrays.copyOf(elementData, newCapacity);
}
public void add(E e) {
ensureCapacity(size + 1);
elementData[size++] = e;
}
public E get(int index) {
return (E) elementData[index];
}
public int size() {
return size;
}
public E set(int index, E element) {
E oldValue = get(index);
elementData[index] = element;
return oldValue;
}
}
public class DynamicArray<E> {
public < T extends E > void addAll( DynamicArray<T> c ){
for ( int i = 0; i < c.size; i++ ){
// 业务逻辑...
}
}
}
E 是 DynamicArray 的类型参数,T 是 addAll 的类型参数,T 的上界限定为 E。
<? extends E>
表示有限定通配符,匹配 E 或 E 的某个子类型,具体什么子类型是未知的,如:
public class DynamicArray<E> {
public < T extends E > void addAll( DynamicArray<T> c ){
for ( int i = 0; i < c.size; i++ ){
// 业务逻辑...
}
}
}
改成有限定通配符方式:
public class DynamicArray<E> {
public void addAll( DynamicArray<? extends E> c ){
for ( int i = 0; i < c.size; i++ ){
// 业务逻辑...
}
}
}
那 <T extends E>
和 <? extends E>
到底有什么关系?
<T extends E>
用于定义类型参数,它声明了一个类型参数T,可放在泛型类定义中类名后面、泛型方法返回值前面。<? extends E>
用于实例化类型参数,它用于实例化泛型变量中的类型参数,只是这个具体类型是未知的,只知道它是E或E的某个子类型。形如 DynamicArray<? >
,称为无限定通配符,举个例子,在 DynamicArray 中查找指定的元素:
public static int indexOf(DynamicArray<?> arr, Object elm){
for (int i=0; i<arr.size(); i++){
if(arr.get(i).equals(elm)){
return i;
}
}
return -1;
}
无限定通配符,也可以改成类型参数,如下,两者写法是等价的:
public static <T> int indexOf(DynamicArray<T> arr, Object elm){
for (int i=0; i<arr.size(); i++){
if(arr.get(i).equals(elm)){
return i;
}
}
return -1;
}
但是通配符形式是比较简洁,但是有一个重要的限制:只能读,不能写,如下所示:
DynamicArray<Integer> ints = new DynamicArray<>();
DynamicArray<? extends Number> numbers = ints;
Integer a = 200;
numbers.add(a);//错误!
numbers.add((Number)a);//错误!
numbers.add((Object)a);//错误!
因为 ?问号就是表示类型安全无知, ? extends Number
表示是Number的某个子类型,但不知道具体子类型,如果允许写入,Java 就无法确保类型安全性,所以干脆禁止。现在我们再来看泛型方法到底应该用通配符的形式还是加类型参数。两者到底有什么关系?我们总结如下:
<? super E>
,称为超类型通配符,表示E的某个父类型,它与 <? extends E>
正好相反,有了它,可以灵活的进行写入。我们给 DynamicArray 添加一个方法,将当前容器中的元素添加到传入的目标容器中:
public void copyTo(DynamicArray<E> dest){
for (int i=0; i<size; i++){
dest.add(get(i));
}
}
DynamicArray<Integer> ints = new DynamicArray<Integer>();
ints.add(100);
ints.add(34);
DynamicArray<Number> numbers = new DynamicArray<Number>();
ints.copyTo(numbers);
Integer 是 Number 的子类,将 Integer 对象拷贝入 Number 容器,这种用法应该是合情合理的,但 Java会 提示编译错误,理由我们之前也说过了,期望的参数类型是 DynamicArray<Number>
, DynamicArray<Integer>
并不适用。这里使用超类型通配符就可以解决这个问题:
public void copyTo(DynamicArray<? super E> dest){
for (int i=0; i<size; i++){
dest.add(get(i));
}
}
这样,编译器就不会报错了,所以总结一下:
<? super E>
用于灵活写入或比较,使得对象可以写入父类型的容器,使得父类型的比较方法可以应用于子类对象,它不能被类型参数形式替代。<? >
和 <? extends E>
用于灵活读取,使得方法可以读取E或E的任意子类型的容器对象,它们可以用类型参数的形式替代,但通配符形式更为简洁。Pair<int> minmax = new Pair<int>(1,100);
是不支持的,解决方法是使用基本类型对应的包装类。Pair<Integer> minmax = new Pair<Integer>(1,100);
Pair<Integer>.class
不支持if(p1 instanceof Pair<Integer>)
不支持if(p1 instanceof Pair<? >)
支持T elm = new T();
不支持,但是可以借助反射机制实现:public static <T> T create(Class<T> type){
try {
return type.newInstance();
}
catch (Exception e) {
return null;
}
}
public class Singleton<T> {
private static T instance;
public synchronized static T getInstance(){
if(instance==null){
//创建实例
}
return instance;
}
}
T extends Base & Comparable & Serializable