普通视图

发现新文章,点击刷新页面。
昨天以前旅行者的随想

嗯学英语核心业务设计实践

前言

最近在设计开发嗯学英语项目,今天想简单讲点儿故事,一个关于英语学习软件背后复杂的技术设计思考,以及我打算如何实践,让它以何种方式运作。

摸索

英语类学习软件我用过不少,使用时间最长的,那自然是多邻国了。你看你学了这么久英语了哈,如果现在要你来做一款英语学习软件,你要如何设计(doge

功能梳理

一句话总结,就是查询单词信息,记录用户学习状态。看着很简单对吧?相信我,如果有人这么跟你提需求让你实现,还跟你说很简单,你一定想拍死他。

开个玩笑哈哈,现在我们来详细分析一下业务:

  • 首先我们得让用户选择学习哪一个词库的,我们就拿《考研英语词汇》来说吧,一共 4533 个词。

  • 在用户选择词库后,我们可以生成一条该用户对这个词库的学习汇总数据,也就是哪些单词用户“学习”了,总共学了多少个等等。

  • 然后用户在软件上获取单词进行学习,在学习完成后,数据将会进行入库操作。这里的入库操作就涉及到比较多的点了,比如咱们要记录单词用户是否“学过”,也就是说不管题目是答对还是答错,都算学过。然后就是单词的进度统计,总共多少个,学了多少个了。以及把用户做错的也记录下来,形成错题本。

这里有必要给大家解释一下,为什么会需要记录学习数据。相信很多小伙伴应该都听过艾宾浩斯遗忘曲线,而我们让用户学习,也不可能一个请求就给一个单词,那样不仅效率低,而且服务器也会绷不住。正确的做法是,生成一个 List,相信用 Excel 背过单词的小伙伴是会深有体会的。因为某个单词用户学习了,入库肯定是会做时间记录的,结合用户的行为,以及错题本,就可以根据一些“记忆算法”来生成 List。由于这个功能只是整块业务的一小部分,这里就不作展开了。

通过这个开发中的初版 UI 草图,我们可以理解一下,以上的功能映射到用户端的大致模样。

数据库表设计

梳理完业务功能后,首先我们要设计数据库表。在消耗了几杯咖啡,挠掉了一些头发后,我设计了如下的数据库表结构(当然,开发迭代的过程中也是会做出调整的):

  • 用户活动词库表,用来记录用户当前正在学习的词库、学过的词库,以及统计已学单词数量。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
CREATE TABLE `enstudy_user_book_dict` (
 `id` bigint NOT NULL AUTO_INCREMENT,
 `user_id` bigint NOT NULL COMMENT '用户 id',
 `book_id` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '词典id',
 `book_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '词典名称',
 `studied` int NULL DEFAULT NULL COMMENT '已学词数',
 `action` tinyint NOT NULL DEFAULT 0 COMMENT '用户使用状态:0->停用状态;1->使用状态',
 `creator` bigint NULL DEFAULT NULL COMMENT '创建者',
 `updater` bigint NULL DEFAULT NULL COMMENT '更新者',
 `create_time` datetime NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '创建时间',
 `update_time` datetime NULL DEFAULT NULL COMMENT '更新时间',
 `del` tinyint NOT NULL DEFAULT 1 COMMENT '逻辑删除:0->删除状态;1->可用状态',
 PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '用户活动词库表' ROW_FORMAT = Dynamic;
  • 用户学习行为表,用来记录用户“学习”过的单词,以及学习时间等。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
CREATE TABLE `enstudy_user_work_actions` (
 `id` bigint NOT NULL AUTO_INCREMENT,
 `word_id` bigint NOT NULL COMMENT '单词id',
 `user_id` bigint NOT NULL COMMENT '用户 id',
 `book_id` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '词典 id',
 `state` tinyint NULL DEFAULT 0 COMMENT '学习状态:0->未学;1->已学',
 `creator` bigint NULL DEFAULT NULL COMMENT '创建者',
 `updater` bigint NULL DEFAULT NULL COMMENT '更新者',
 `create_time` datetime NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '创建时间',
 `update_time` datetime NULL DEFAULT NULL COMMENT '更新时间',
 `del` tinyint NOT NULL DEFAULT 1 COMMENT '逻辑删除:0->删除状态;1->可用状态',
 PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '用户学习行为表' ROW_FORMAT = Dynamic;
  • 用户错题本,用来记录用户学习错误的单词,以及次数。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
CREATE TABLE `enstudy_user_wrong_word` (
 `id` bigint NOT NULL AUTO_INCREMENT,
 `word_id` bigint NOT NULL COMMENT '单词id',
 `user_id` bigint NOT NULL COMMENT '用户 id',
 `book_id` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '词典 id',
 `fail_count` int NULL DEFAULT 0 COMMENT '错误次数',
 `creator` bigint NULL DEFAULT NULL COMMENT '创建者',
 `updater` bigint NULL DEFAULT NULL COMMENT '更新者',
 `create_time` datetime NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '创建时间',
 `update_time` datetime NULL DEFAULT NULL COMMENT '更新时间',
 `del` tinyint NOT NULL DEFAULT 1 COMMENT '逻辑删除:0->删除状态;1->可用状态',
 PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '用户错题本' ROW_FORMAT = Dynamic;

流程

现在咱们来看看,整套流程是怎么走的:

就是一个很典型的 Spring Boot 单体架构服务,对吧?用户端操作,请求到后端服务,然后后端服务操作数据库,并返回。有一点需要注意,服务本身是“无状态”的,所有的状态咱们都在数据库和分布式缓存中进行维护,否则的话就没法舒适地扩展了。

这里的“无状态”指的是,服务端的无状态认证(Authentication),属于“会话状态”的讨论范畴,不要弄混淆了。因为我把用户的状态写入了分布式内存中,而不是当前服务的缓存中,所以不会因为扩展了多个实例而导致状态隔离。

那么到这里为止,咱们就把核心业务给设计完了。

改进

服务器瓶颈改进

由于嗯学英语并不是离线客户端模式,所以还是很依赖于云服务的。我们不可能说,开发一个软件,就只能让一两个人用,人一多就崩溃对吧?那么我们这里做个假设,我们的用户跟多邻国一样多(可以压测模拟),这时候应该如何调整架构来支持呢?

首先说一下目前嗯学英语的整个技术栈和架构,然后我们以此为基础代入思考。

前端:Nuxt 3、Vue 3、TypeScript、Naive UI、TailwindCSS

后端:Spring Boot 3、OpenJDK 17

数据库及中间件:MySQL、MongoDB、Redis

CI/CD:GitHub Actions、Kubernetes

我的云服务资源能拿来演示的没那么多,我这里就单个服务分配 512 MB 内存来进行演示

在我们开始进行压测后,不难发现单台服务根本顶不住多少“用户”同时使用。好在我们一开始设计时,就考虑到了扩展的问题。我把服务部署在了 K8S 里面,而且嗯学英语服务是无状态的,那么基于 Service(服务)有状态副本集(StatefulSet)来完成有序的、优雅的部署和扩缩,只需要增加容器组副本数量就行了。

这样一来,客户端不需要关心它们调用了哪个后端副本,通过快速扩容,我们也能容纳更多的请求,现在能够支撑上 w 用户同时使用了。

数据库瓶颈改进

在压测了一段时间后,我们可以发现瓶颈来到了数据库这边。数据库 CPU 占用高、连接数过高(too many connections)、wait timeout 等问题全来了。

我们知道,MySQL 的最大连接数是有一个上限的,但是呢,我们不可能直接设置为上限的那个数,需要根据服务器的 CPU、内存大小等来设置一个相对合理的值。我一般默认 210,最大 1000(别问我能不能再大点儿,得加钱!!!)。

数据库咱们没法像服务那样直接更改部署副本大小就行,要考虑的因素特别的多,毕竟数据才是值钱的东西呢!这里场景的方案就是上集群,一个主实例(读写,也可以多个主实列)和多个辅助实例(只读),这里贴一张 MySQL 文档中的图片:

反映到嗯学英语架构中来,应该是这样的:

初次在 K8S 部署学习 MySQL 集群的话,可以试试 Bitnami 的 MySQL Chart,基于这个入手会更好!

在实际开发中不可能一句上集群就能上的,说起来轻松的事儿,只适合面试吹牛。作为一名开发,还是得先从代码设计层面去解决问题。仔细回想一下,单词数据、错题本之类的数据,是不是都是读多写少的场景?那么我们就没有必要每次都去数据库读了,而是读取之后做缓存,在每次更新数据之后,再更新缓存,这样能降低一些数据库的压力。

连接数解决

这个一般微服务场景下会比较好做一些,要么基于 K8S 的微服务,要么 Spring Cloud,由于我们的嗯学英语前期采用的是 Spring Boot 单体项目,如果用这个方案的话,是需要对架构进行调整的。但是我觉得多邻国肯定不是单体服务,所以还是像拿出来讲讲。

咱们前面不是扩容了很多服务嘛,那么假设每一个服务,我们都分配了最大 50 个连接数的数据库连接池。理论上来说,服务副本数量越多,会占用的数据库连接数也就会变多。

有一句很有名的话,“计算机科学中的所有问题都可以通过另一个间接层次来解决”,据说是大卫·惠勒说的。把这个思路引进来,我们可以发现增加服务副本,是为了缓解核心业务里面对于数据库的“相同”操作,那么我们可不可以把这些操作抽象出来,封装成“间接层”呢?那当然是没问题的了!

在加入了代理服务之后,就能极大地缓解这个问题了。注意哈这一套在微服务里面要好做一些,单体服务弄这个的话,维护起来相对麻烦。

异步

在解决了上述问题之后,假设某一天嗯学英语突然爆火了,10w+ 的用户全部跑来试用(咱们增加压测线程来模拟),咱们的服务器还是崩啦!

虽说大部分场景下,每个用户对数据库中同一条数据的操作相对来说并不多,但是也没法保证出现死锁或者锁竞争的问题。我们的业务,也是在每次用户操作发送请求后,直接入库,等待操作完了之后,才返回的。虽然是第一次做,但是我们可以分析下多邻国看看有没有什么思路。

多邻国在每次完成一个单元的测试后,都会有一个“短暂”的结算动画,甚至有时候去个人档案页面,看到的数据统计和成就,并不是刷新好了的,可能要等个 1~3 秒左右。这类学习类 App 对于用户来说,是具有很高的容忍度的,配合上动画,反而体验看上去也很不错。那么在这中间缓冲的一点时间,就可以让后端服务“慢慢地”去入库就行了,用户感知也就没那么明显了,只需要最大程度的保证数据能保存下来就行。

这里可以在代理服务里面用线程池去处理,或者是用消息队列,然后让代理服务去消费它。在嗯学英语的架构中,我会选择使用 RabbitMQ(消息队列中这个我用的最熟,选其它的也没问题),原因也很简单:我需要能优雅的管理代理服务的集群副本的增加和减少,不能直接更改数量就什么都不管了,而引入消息队列的话,我会省心一些。

消息队列的引入,不仅异步写入降低了一些负载压力,同时也能极大的降低死锁和锁竞争出现的概率。出现这个问题,本质上还是太多的请求同时都来操作这一条数据导致的,我们让请求进入队列,就能避免啦!引入消息队列后,代理服务现在就成为了“消费节点”,我们依旧可以通过线程池或者增加副本的方式,来提升“消费能力”。

除了架构层面,我们在开发时,在代码层面也需要多多注意,应避免全表扫描对表中的所有行记录进行加锁。很多小伙伴开发时可能暂时不会考虑这么多,但是功能完成后,我还是建议看一看执行计划(EXPLAIN)。因为有时候光看 SQL 不一定看得出来,而且不同的数据量执行的 SQL,执行计划也可能差别很大。同时也要对 Slowest queries 进行监控,有剑不用和无剑可用那不是一回事儿。

最后

这就是我对嗯学英语核心业务的思考、设计和改进的一个过程了,当然也有很多应该做的东西也还没做,但实际上“黑天鹅”事件真出现了,也是很难顶得住的。后面继续开发迭代的过程中,我可能也会做出很多调整,等开发完了开源出来,可能也会跟本文所讲的有很大的偏差。

毕竟还有很多内容没考虑进来,比如中间件都挂了怎么办?我要怎么对每一个链路进行“观测”(以及错误追踪)?线上出了问题怎么告警(当然是咱们的老朋友 Prometheus 和 Grafana 啦)?怎么查询日志(ELK 太重了,每个 Pod 都打开看也麻烦)?依托于 K8S,我能以低成本去构建出满足这些的嗯学英语,从而更多时间能放在代码层面去思考。

最后感谢你能看到这里,有想法欢迎与我交流!

累,也要咬牙坚持

坚持才是最难的

嗯,不知不觉,坚持每天刷多邻国已经 365 天了。

说来惭愧,第一次学习英语能坚持这么长时间。想到高考的英语分数才只有 40 多分,这一年的时间,我基本完成了英语入门。英语一直被我认为是世界上最难学习的东西,没有之一。所以我这几天也想了很多,就从今天开始,整个下半年,大部分的空闲时间都要拿来学英语,我相信自己可以的,所以今天就是 Day 1 了。

资料基本上都买齐了,《薄冰英语语法手册》和《新概念英语 1 》的课本、语法练习以及练习册。准备了 2 个 B5 的本子,一个记录每天学过的单词,一个记录重要的语法和句子。2 个 A7 的小本子,一个用来每天晚上写第二天的学习计划(Todo),一个用来记录每天的学习情况(Summary)。

我做笔记,学完之后做完总结就基本上再也不会去看了。但是这个过程,对我来说是个很重要的思考过程,可以理解为大脑思考在现实世界的一个投影。我学习编程的时候也喜欢这么干,不管是笔记、思维导图、技术分享还是写博客。

为什么要从 0 开始学

其实不管是编程、英语还是数学,在这一类领域中,每个知识点/块之间的依赖性是很大的。这也就意味着学习是有门槛的,虽然丢掉了依赖路径上的一部分,也能学的不错,但是最基础的内容,才是最重要的。只有对哪些被以来的基础领域,掌握了,学习更高阶的领域的时候,才会“往返自然”。

所以这也是我为什么要从新概念英语 1 开始学,我真的想学好。

计划

准备先把统考英语考过,然后拿到专升本的毕业证和学位证。所以今年剩下的时间,就要拼命学习英语了!!!

虽然高考结束,只上了专科,但是我真的不甘心。我不希望我的人生就是这个样子,在学校、在社会上,我都经历了太多嘲笑,但是我觉得这都是应该的,谁叫我自己不努力呢?但如果我自己也选择了摆烂,那这绝不是我想要的人生。

毕业之后,我并没有选择全日制的专升本,因为我觉得自己读书这么没用,不想让家里再花钱供我读书了。所以我花了 8500 块钱,报了个非全日制的专升本,一边升本、一边工作赚钱。顺利的话,明年就能拿到双证了,加油!💪

我不知道我能走多远,也不知道考试都能不能过。但是我想给自己一次机会,我不想年纪大了又后悔。所以明年专升本毕业后,就可以尝试报名考考看了,即使没考上,但是无数个日夜学习的英语、数学以及计算机学科专业基础知识,我想也能帮助我在编程这条路上走得更远吧。工作前两年摸爬滚打,挺多的学习时间全部拿来学习编程了,现在至少工作上的问题,碰到了可以全部解决,也养成了一套自己的学习方法和编程思维。从杭州回武汉也一年了,虽然武汉的工资相对来说不那么高,但是目前的现状还算不错,双休不加班,这些时间都可以被我利用起来。

我是 99 年的,今年 24 了,我还有梦想,我还要走的更远。我也希望能遇到更好的女生,也希望面对女生时不会因为自己差而感到自卑。我也想交更多优秀的朋友但前提是先把自己变优秀了。

就这样吧,大半夜写点东西,希望偶然看到博客的你,也能生活快乐,勇敢去追逐自己的梦想。

基于 Kubernetes 和 SkyWalking 的 Spring 微服务监控实践

前言

最近在哔哩哔哩做了一期直播技术分享,算是人生中第一次吧🙂内容是基于 Kubernetes 和 Skywalking 的 Spring 微服务监控实践,实现 Spring 云原生可观测性。自己排练的时候,准备了一个多小时的内容,结果直播时太紧张,半个多小时就讲完了😂

本来打算用 PPT 整理一些概念和注意点来讲,然后结合项目讲解项目怎么配置、怎么部署,以及请求进来了,怎么通过 Skywalking“观测”请求走过的调用栈,以及查看日志等。由于时间问题,部署这一块就省略掉了,只展示了最终的效果,毕竟部署特别费时间。后面也打算从 0 开始,搭建 K8S,然后部署 nacos、redis、数据库之类的,再把项目和 skywalking 给部署上去。直播录屏正好可以拿来做项目的视频部署教程,也锻炼锻炼自己临场实践能力,以及出问题了怎么查资料去解决。

开播前后有很多朋友的鼓励和支持,这里谢谢大家!本文的内容算是与视频内容的互补。

让我们开始

为什么需要 Skywalking?

在 Spring 微服务场景中开发过的小伙伴应该都了解,我们在排查问题的过程中,往往会因为调用栈太长、跨过的服务太多、请求量太大、无法准确追踪异步线程等,浪费大量的时间。这种情况并不像单元测试对功能性方法测试一样,很方便的就能看到报错信息以及 Debug,我们需要有一种方式,帮我们追踪每一个”请求“线程,来实现”观测“。

拿图中的架构来举例子,请求打到外部网关入口,然后被转发到 Spring-Gateway 所在的 ServiceNodePort,对应的流量会负载均衡到每一个这个 Service 内的 Pod。然后 Spring Gateway 会根据路由,将流量转发到对应的业务服务。业务服务可能会操作数据库、redis 以及消息队列之类的中间件,最终处理完之后,将响应回对应的请求。而这个过程产生的日志和监控数据,将会被上报给 Skywalking OAP,我们访问 Skywalking UI 的 Service 就能看到界面了,通过各个图表获取我们需要的信息。

程序在出现异常时,会将 traceId 一并响应给请求方,这样就可以通过 traceId 去 Skywalking 查询相应的数据了。在上线前,它有一个典型的案例场景:测试自己复现了 Bug 之后,拿到 traceId 给开发,开发去 UI 查看日志和数据。极大地降低了测试和开发人员间的友好互动频率、维护了团队的良好氛围形象、提升了 bug 响应修复的速度,同时避免了在测试电脑上有 bug 但在开发电脑上世界和平的现象(笑死

注意:这里每个负载间都是通过 Service 的虚拟 IP 来通信的,不能够使用 Pod IP,因为重新部署后 IP 会发生变化,同时也将无法做到负载均衡了。

Skywalking 是什么?

SkyWalking 是一个开源可观测平台,用于收集、分析、聚合和可视化来自服务和云原生基础设施的数据。SkyWalking 提供了一种简单的方法来保持分布式系统的清晰视图,甚至跨云。它是一种现代 APM,专为云原生、基于容器的分布式系统而设计。

SkyWalking 为服务(Service)、服务实例(Service Instance)、端点(Endpoint)、进程(Process)提供可观察性能力。

服务可以映射成我们的 Spring 的每一个服务,在 Kubernetes 中也就是 Service,而服务实例对应着 Service 内的 Pod(举个例子:咱们的 Spring Gateway 网关”服务“,可以启动多个实例对吧?)。而在开发中我们最常关注的是端点,端点用于传入请求的服务中的路径,例如 HTTP URI 路径或 gRPC 服务类 + 方法签名。

举个例子,前端请求后端的登录接口,http://127.0.0.1:8000/pisces-admin/user/login,就可以是一个 endpoint,这个 endpoint 的内容是 POST:/user/login

SkyWalking 在逻辑上分为四个部分:Probes、Platform backend、Storage 和 UI。作为开发,我们最需要关注 的是 probes 和 ui,probes 可以理解为程序中的“探针”,而 ui 是我们“观测”数据指标的界面。

如何收集 Java 程序的数据?

介绍完 Skywalking 的一些概念之后,我们最关心的肯定就是如何从 Java 程序中收集数据。刚才我们说到了”探针“,我们就是要靠它来实现。

在 SkyWalking 中,探针是指集成到目标系统中的代理或 SDK 库,负责收集遥测数据,包括跟踪和指标。

SkyWalking Probes 可以分为四种不同的类别:

  • Language based native agent,也就是基于语言的原生代理
  • Service Mesh probes
  • 3rd-party instrument library
  • eBPF agent

显而易见,我们要用的就是针对于 Java 的基于语言的原生代理,也就是 Skywalking Java Agent,它为 Java 项目提供本机跟踪/指标/日志记录/事件功能。

如何使用

在项目中使用 skywalking agent

本文以 logback 日志实现为例,首先我们要将下面的包引入进我们的项目:

1
2
3
4
5
6
7
8
9
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-sleuth</artifactId>
</dependency>
<dependency>
<groupId>org.apache.skywalking</groupId>
<artifactId>apm-toolkit-logback-1.x</artifactId>
<version>${skywalking.version}</version>
</dependency>

注意:只要你的微服务项目配置了全局的 spring-cloud 版本,那么这里的 sleuth 就不需要单独指定版本,它会默认继承你当前 spring-cloud-dependencies 指定的版本。

  • logback-spring.xml 中添加 gRPC reporter.
1
2
3
4
5
6
7
<appender name="grpc-log" class="org.apache.skywalking.apm.toolkit.log.logback.v1.x.log.GRPCLogClientAppender">
<encoder class="ch.qos.logback.core.encoder.LayoutWrappingEncoder">
<layout class="org.apache.skywalking.apm.toolkit.log.logback.v1.x.mdc.TraceIdMDCPatternLogbackLayout">
<Pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%X{tid}] [%thread] %-5level %logger{36} -%msg%n</Pattern>
</layout>
</encoder>
</appender>

注意,name 参数可以自定义,但是记得加上 appender

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true" scanPeriod="10 seconds">
<!-- gRPC reporter 在这一层级 -->
<root level="info">
<!-- 就是下面这一行 -->
<appender-ref ref="grpc-log"/>
</root>
</configuration>
  • 下面这行插件的配置,如果不添加的话,会使用默认值:
log.max_message_size=${SW_GRPC_LOG_MAX_MESSAGE_SIZE:10485760}

到这里,我们已经配置好基本的内容,这下你的服务已经可以上报数据了。

Skywalking 服务配置

程序中配置好了,那么我们把数据上报到哪里,以及如何查看呢?这里以开发环境为例子,毕竟如果在本地开发的话,还是需要调试的。

  • 安装 skywalking

我们先去官网下载 Skywalking APM,下载完之后解压,本地我们不做特殊配置,直接双击 apache-skywalking-apm-bin\bin\startup.bat 就可以启动了。启动之后,访问 http://localhost:8080,就可以看到页面啦!

启动完之后进去是没有数据的,等程序上报数据就好了。

  • 安装 skywalking java agent

我们需要在程序启动时,让 agent 以插件的形式加载进来,当然也需要下载这些包了。去官网下载 Skywalking Java Agent,然后解压之后得到 skywalking-agent 文件夹。点进去之后我们可以看到一个 jar 包 skywalking-agent.jar,记住这个包在你硬盘上的绝对路径。

由于 Spring Gateway 是基于 WebFlux 的,所以我们要添加 2 个插件进来。进入 skywalking-agent\optional-plugins 文件夹,找到如下 2 个 jar 包:

apm-spring-cloud-gateway-3.x-plugin-8.13.0.jar
apm-spring-webflux-5.x-plugin-8.13.0.jar

然后复制到 skywalking-agent\plugins 文件夹下就行了。

注意,具体的版本,取决于你当前所用的版本!

idea 配置

接下来我们去 idea 中配置,以便让 idea 中的 Java 服务启动时能顺利加载插件。

我们先打开 Run/Debug Configurations,然后找到我们对应的服务(没有的就添加),选择 Modify options,勾选 Environment variablesAdd VM options 选项。

然后添加对应的启动参数:

第一个红框框的内容,是 JVM Options 参数:

-javaagent:D:\env\skywalking\skywalking-agent\skywalking-agent.jar

-javaagent: 后面的是你开发环境的 skywalking-agent.jar 包的绝对路径位置。

第二个红框框,是配置 SkyWalking Agent 的环境变量:

SW_AGENT_NAME=pisces-gateway;SW_AGENT_COLLECTOR_BACKEND_SERVICES=127.0.0.1:11800

SW_AGENT_NAME 的值填写你要指定的 Service Names,如果你需要同时配置 Service Groups,你可以这样写:

SW_AGENT_NAME=Pisces-Cloud::pisces-gateway;

:: 符号前面的就是 Service Groups,后面的就是 Service Names

SW_AGENT_COLLECTOR_BACKEND_SERVICES 的值填写你的 SkyWalking OAPAgent Backend Service 的地址。

图表信息

直播时基本上都介绍过了,这里挑几个典型大致讲讲吧。

图中我们看到的是服务概览,以及整个服务链路的拓扑图。当然,关于链路追踪重要的部分,还得是 Trace 和 Log,下面我以 POST:/user/login 这个登录的端点来举例子。

调用链路信息

我们可以看到每一个上报过的端点,以及端点对应的信息。图中端点的请求走过的每一个不同的服务,都是用了不同的颜色区分开了。而竖着的直线,就是对应着当前的服务,中间的每一个跨度都能点开查看日志,包括执行的 SQL 语句,然后只要输出了日志信息,都可以关联到对应的 LOG,是不是特别方便?下面放一张全貌图。

服务业务数据图表

图中的注释已经说的很详细了,有一点需要注意的是,消息队列相关的那 2 个图表,需要你当前服务具有”消息消费者“的时候,才会显示数据的,毕竟是展示 Consuming 数据嘛。

JVM 数据图表

每一个实例进去,都能够看到对应的 JVM 的信息,毕竟有时候需要诊断 JVM 数据来定位问题嘛。我们可以看到有内存/CPU占用情况,新生代、老年代的 gc 时间和次数,各种状态的线程(守护线程、阻塞线程、可运行线程、等待线程)次数啦等等。

毕竟对于初学者来说,学习使用各种工具去查看这些指标,是需要一定的精力的。尤其是碰到线上不给任何权限的那种,这时候程序能够主动上报数据,我们就能直观的看到了。

注意,线上环境的任何观测/上报数据的接口,都不建议对外公开访问,做好白名单/黑名单机制拦住它。

开发中的注意点

跨线程追踪

程序中教程会碰到,需要用异步线程处理的情况,那么这种时候如果需要对异步线程进行追踪的话,可以采用跨线程追踪方案。

  • 首先我们先引入包:
1
2
3
4
5
<dependency>
<groupId>org.apache.skywalking</groupId>
<artifactId>apm-toolkit-trace</artifactId>
<version>${skywalking.version}</version>
</dependency>
  • 拿下面这个场景来说,我需要发送一条消息:
1
2
3
CompletableFuture.runAsync(() -> {
messageSender.sendBark(String.format("时间:%s,用户:%s 登录系统!", LocalDateTime.now(), account));
});

这种写法是无法追踪到的,在引入包之后,我们可以这么写:

1
2
3
CompletableFuture.runAsync(RunnableWrapper.of(() -> {
messageSender.sendBark(String.format("时间:%s,用户:%s 登录系统!", LocalDateTime.now(), account));
}));

值得注意的是,CompletableFuture.runAsync() 是没有返回值的,所以用的是 RunnableWrapper.of(),如果我们用的是 CompletableFuture.supplyAsync() 的话,就需要换成支持返回值的 SupplierWrapper.of() 方法。

全局异常处理

全局异常处理这基本上业务开发都会用到,但是我们抛给前端的信息中,需要带上 traceId,这样不管是用户还是测试,都可以根据报错信息中的 traceId 反馈问题,排查也就减轻了一些负担。

  • 首先引入相关的包:
1
2
3
4
5
<dependency>
<groupId>org.apache.skywalking</groupId>
<artifactId>apm-toolkit-trace</artifactId>
<version>${skywalking.version}</version>
</dependency>
  • 通过 Skywalking 手动 API,我们可以获取到 traceId:
1
String traceId = TraceContext.traceId();
  • 然后加到异常处理中响应给前端:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@Slf4j
@ControllerAdvice
public class GlobalExceptionHandler {
/**
* 全局异常拦截 handleException
*/
@ResponseBody
@ExceptionHandler(Exception.class)
public CommonResult<?> handleException(Exception ex) {
log.error("全局异常信息.[异常原因={}]", ex.getMessage(), ex);
return CommonResult.failed(HttpStatus.ERROR, "系统异常,请联系管理员!", null,"traceId=" + TraceContext.traceId());
}
}
  • 响应的异常信息如下:
1
2
3
4
5
6
{
code: 500,
message: "系统异常,请联系管理员!",
data: null,
traceId: "traceId=7d2705aebedb40a1985af4ed20f569b0.510.16686682751090531"
}

traceId 生成规则

生成的 traceId 一大串,怎么读懂它?(虽然只是个符号而已,但是我们也可以了解下)

先看源码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
private static final String PROCESS_ID = UUID.randomUUID().toString().replaceAll("-", "");
private static final ThreadLocal<IDContext> THREAD_ID_SEQUENCE = ThreadLocal.withInitial(
() -> new IDContext(System.currentTimeMillis(), (short) 0));
public static String generate() {
return StringUtil.join(
'.',
PROCESS_ID,
String.valueOf(Thread.currentThread().getId()),
String.valueOf(THREAD_ID_SEQUENCE.get().nextSeq())
);
}

我们知道,traceId 是由 2 个点分隔开的一串长字符串。第一个点前面是生成的 UUID,并替换掉了 - 符号;中间的三个数字,是当前线程的线程 ID;最后的内容是根据时间戳 * 10000 + 当前线程中的 seq 生成的。UUID 生成后就不会变了,线程 ID 也是一样。

注意,这里的线程 ID,并不是请求线程的线程 ID,说它是当前实例的生成器的线程 ID 更合适。而且 UUID 和线程 ID,每一个不同的实例生成的也是不同的,只是每一个实例下相同。这点其实也很好验证,每个 Service 启动多个实例,然后来一波多线程压测就显而易见了。

最后

这是第一次做直播技术分享,也非常感谢大家的支持!虽然第一次看的人不多,但是也留下了录屏,而且依旧可以写博客发出来,算是一次不错的经历,以后也打算继续做直播技术了,偶尔直播技术分享,同时也欢迎大家多多跟我交流技术呀!

Dynamic k8s 集群实现方案

前言

本文要探讨的,是一种极低成本,但是可用度较高的 K8S 集群方案,提供多种实现思路,以及部分可实现方案。我踩过一些坑,在现有资源下,其实有很多种玩法,但如果有小伙伴问:你怎么不用 xxx 家的 xx 服务啊?怎么不用 xxx 方式啊?

说到底,如果你是为了“生产级”而考虑,那确实也没问题,可靠性最重要。但我这种,贯彻的宗旨是“白嫖”,比较适合自己折腾当玩具、开源项目演示站之类的。比如生产级会在集群外配置 LoadBalancer,但是我就不会考虑,因为要花钱。而且我还得考虑,其中一个节点集群挂了,或者号没了,我能在极短的时间内切换到正常的集群上去。

方案

思路

我先来解释下这张图的意思吧~我是用 Pisces-Cloud 项目为例,因为是个前后端分离的微服务,所以前端直接放在 vercel。因为我觉得前端资源就没必要放在集群里了,而是应该放在 cdn 上,让用户能最快的访问静态资源,同时降低后端的压力(毕竟服务器只需要处理 API 请求就行了。

然后域名是放在 Cloudflare 进行解析的,目前是直接解析到集群的 Nginx 入口,再进入集群内部,通过 Service 生成的 Virtual IP,提供一个 NodePort 来访问,Nginx 对这个 NodePort 进行反向代理。

注意,在这一步的 Virtual IP 需要创建无状态负载来实现,因为默认的应该是 Headless,只供集群内部使用。

k8s 里面,有多种方式可以向外暴露进行访问,比如 hostNetWork、hostPort、NodePort、LoadBalancer、Ingress 等。我之前用的是 Ingress 的域名路由方式,对 L7 进行转发,但这种方式有个小问题,配置好的域名后面还要带端口号访问,就很别扭。后来索性直接改了下 Ingress Controller 的端口,改成 80 和 443 了,那样就去掉了端口号。

这几天迁移后,就按照图片上的方式,放在集群外的那台 Nginx 上面的,证书也是在上面配置的。至于 Workers 那一步,咱们放到后面讲,目前我还没上,只提供思路,哪天有空弄好了补充完整的。

然后就是整个后端服务这边,各种配套的监控、中间件,都是在集群内部配置的。由于可以直接用 Helm,在配置一主三从的时候,反而比用 Docker 跑方便的多,毕竟改改 yaml 文件就好了嘛(🐶

我的实现

这套玩法里面最重要的,是 Cloudflare 到集群这一块。第一张图片上 Host-0、Host-1 这些,就是不同的集群。可能有小伙伴要好奇了,为什么要弄多个单独的集群,而不是一个 master 对应多个 node 呢?我之前是这么玩的,但是我这个机器是白嫖的 Oracle Cloud 的 4C24G 的 ARM 机器,一个号只能开一个,所以走不了一套内网,而且跨地域延迟也高,时不时还调度失败。。。虽然可以通过公网把几台机器组成内网实现,但是延迟是个大问题,并且时不时丢包。

除了折磨人,可用性方面还是可以的。比如 Host-0 挂了,我直接把域名解析到 Host-1,服务就能正常使用了。数据同步,之前阿里云搞活动卖 20 一年的 RDS,拿来干这个不错,但现在已经过期了,因为要求不高,我是定时手动用 Navicat 去同步备份的。这勉强算是一种毕竟 Low 的异地容灾吧。。。宕机时间从收到告警邮件到登录 Cloudflare 切换 DNS 解析为止,花不了多久。

你要问我最大的缺点是什么?我觉得应该是限制于 ARM 的架构,导致基于 Jenkins 的 DevOps 系统安装困难(不是不能哈,兼容性问题需要解决。

基于 Cloudflare Workers 的动态负载均衡

这个思路呢,我是打算基于 Reflare 这个项目做的。用 Cloudflare Workers 本身做反向代理,应该问题是不大的,我自己也用了很久了,而且这玩意除了反代,搭建网站,搞负载均衡本身也是支持的。

Reflare 有个实验性的功能,叫“Dynamic Route”,也就是动态路由。实际上是将路由基于 Worker KV 存取。这样的话,是不是可以对集群进行监控,然后触发制定的规则后(比如30秒一次,失败5次以上),通过 Cloudflare API 修改路由呢?(这个项目的仪表板正在开发中)这里直接用 Redis 替代 Worker KV 也是可以的。

使用 cloudflared 将 Kubernetes 应用程序公开到 Internet

这里是推友提供的思路,原本只知道能搞内网穿透,没想到竟然也能干这个活。

具体思路应该是这样:把 cloudflared 部署到 k8s 中,那么就能用 Tunnel 创建连接了。注意文档上说“同一个 Tunnel 可以从 的多个实例运行 cloudflared,使您能够运行多个 cloudflared 副本以在传入流量发生变化时扩展您的系统。”。假设这时 k8s 集群挂了,那么上面运行的 cloudflared 实例也就无法和 Tunnel 通信了,反过来 Tunnel 也就无法将流量代理到这个 k8s 集群了。比我上面说的基于 Workers 的要容易实现一些,毕竟可以直接将流量路由到 Cloudflare 的负载均衡器了。

果然还是大家骚点子多啊,正好国庆放假在家把这个折腾好🙃

最后

文章可能有点水了🙃不过这里主要还是提供一种思路,看到这篇文章的你,在生产环境千万别这么玩......如果你像看具体的操作的话,这个项目的文档里面,我后面会抽时间写完这部分。不过比较熟悉 k8s 的小伙伴,其实看完就大致明白了。

虽然俺是 Java 开发,但是万一咱下一家公司就用 k8s 呢是吧?(其实现在这家就在用)不是说要去学怎么做 k8s 开发,但是至少得了解咱们开发的程序在 k8s 上部署的大致流程。虽然生产都是运维去搞,但是咱们熟悉的话,在查问题时会更从容🙂

参考资料:

Spring Cloud on GitHub Actions

GitHub Actions

概览

GitHub Actions 是由 GitHub 推出的持续集成服务。可以创建工作流程来构建和测试存储库的每个拉取请求,或将合并的拉取请求部署到生产环境。GitHub Actions 不仅仅是 DevOps,还允许您在存储库中发生其他事件时运行工作流程。 例如,您可以运行工作流程,以便在有人在您的存储库中创建新问题时自动添加相应的标签。GitHub 提供 Linux、Windows 和 macOS 虚拟机来运行工作流程,或者您可以在自己的数据中心或云基础架构中托管自己的自托管运行器。

它能做什么?

GitHub Actions 支持绝大多数你所熟知的语言,你可以用它进行代码检查、测试、CI/CD、项目管理,甚至能跑 Kubernetes 和薅羊毛。。。

如果我们要搜一些常见的操作,一般会想到 awesome actions,你也可以在 GitHub Market 搜索。

核心概念

主要讲 3 点:工作流程、作业、步骤。其它的内容,请自行翻阅 文档。不过话说回来,只需要了解这 3 个概念就够了,知道了它是怎么运行的,你就能在脑海中构思你每干一件事需要哪些步骤,然后直接去搜索有没有现成的步骤可以使用,当然,是在没有解决方案,只能自己造轮子了。

先来讲讲工作流程,我们要在 GitHub 上面做操作,肯定得有一个仓库,这是最基本的条件。其次,你也许以前就注意到了,很多仓库里面,都有 .github/workflows 文件夹,这是 GitHub Actions 必须的要求。文件夹下,存放的就是 YAML 文件了,实际上一个工作流程,就是对应一个 yaml 文件的。

比如说,我新建了一个 github-actions-demo.yml 文件,那么就相当于创建了一个名为 github-actions-demo 的工作流程。当然,名字是可以在 yaml 文件中配置的。

这是我从文档中抠下来的图,根据这张图我们来理解一下这 3 个概念。“工作流程”是一个可以配置的自动化过程,并且是由 YAML 文件定义的事件触发时运行,或者可以手动触发以及定时触发。我们可以看到,工作流程中是可以包含多个“作业”的,也就是 jobs,比如你可以定义 job1job2 ...每一个作业中,也可以存在多个“步骤”,可以是自定义的脚本,也可以是其它操作等等。

这里提到了一个新的概念,叫做“事件”。如果不好理解,那么我就说 6 个字解释下:“事件触发流程”。比如说你可以定义有人创建 pr、打开一个 issue、推送到存储库,或者是定时运行,来触发工作流程的运行。

这里说一个容易被误解的点,官方的图上看上去每个作业是顺序运行的,但它们也是可以并行运行的哦~😊

理论上来说,在耗尽 GitHub Actions 限定的资源和时间之前,单次工作流程,是可以一直跑的,那可玩性也就大大提升啦!虽然很多骚操作不太推荐就是了,毕竟是免费资源,不能滥用😂

说了这么多,来进入正题吧,啰嗦半天主要是怕搜到这里的朋友,没弄明白 GitHub Actions 是怎么回事,已经轻车熟路的朋友跳过就行啦!

Spring Boot

在 Spring Cloud 之前,我们需要先讲一下 Spring Boot 的操作,毕竟也是基于它的嘛。这里就用基于 Spring Boot 的 Pisces-Lfs 项目来举例。由于我们要打包基于 Docker 的镜像,那么得先写好 Dockerfile 文件。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# 该镜像需要依赖的基础镜像
FROM openjdk:8-jdk-alpine
# 设置环境变量
ENV TZ=Asia/Shanghai JAVA_OPTS="-Xms512m -Xmx512m" SPRING_CONFIG="-Dspring.config.location=/root/lfs/application-docker.yml"
# 设置时区
RUN set -eux; \
 ln -snf /usr/share/zoneinfo/$TZ /etc/localtime; \
 echo $TZ > /etc/timezone
# 创建文件夹
RUN mkdir -p /root/lfs
# 拷贝 jar 包,并重命名
COPY lfs-admin/target/lfs-admin-1.0.jar /lfs-admin.jar
# 指定 docker 容器启动时运行 jar 包
ENTRYPOINT exec java ${JAVA_OPTS} -jar ${SPRING_CONFIG} /lfs-admin.jar
# 指定维护者的名字
MAINTAINER besscroft

然后我们再创建 docker-buildx.yml 文件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
# 工作流程名称
name: "Java CI with Multi-arch Docker Image"

# 指定事件触发条件和分支
on:
 push:
 branches: [ main ]
 pull_request:
 branches: [ main ]

# 一个作业
jobs:
 docker:
 name: Running Compile Java Multi-arch Docker Image
 # 基于 ubuntu-latest 运行时
 runs-on: ubuntu-latest
 steps:
 # 设置 Java 环境的操作
 - uses: actions/checkout@v3
 - name: Setup Java
 uses: actions/setup-java@v3
 with:
 # 指定 jdk 版本
 java-version: '8'
 # 注意,这里的 adopt 是因为我需要用这个,你可以根据自身情况换成其它发行版
 distribution: 'adopt'
 cache: 'maven'
 - name: Build with Maven
 # maven 构建
 run: mvn -B package -Dmaven.test.skip=true --file pom.xml
 - name: Login to Docker Hub
 # 登录 docker 仓库
 uses: docker/login-action@v1
 with:
 username: ${{ secrets.DOCKERHUB_USERNAME }}
 password: ${{ secrets.DOCKERHUB_TOKEN }}
 - name: Set up QEMU
 # 设置 QEMU,以便支持多平台构建
 uses: docker/setup-qemu-action@v1
 - name: Set up Docker Buildx
 id: buildx
 uses: docker/setup-buildx-action@v1
 - name: Build and push
 id: docker_build
 uses: docker/build-push-action@v2
 with:
 context: ./
 # 指定 Dockerfile 文件位置
 file: ./Dockerfile
 # 打包支持的镜像架构,因为我需要 aarch64 的镜像,所以这里加上了 arm64
 platforms: linux/amd64,linux/arm64
 push: true
 # 镜像标签
 tags: ${{ secrets.DOCKERHUB_USERNAME }}/lfs:latest

工作流程中,有使用到环境变量的地方,也就是用户名和 token,这个不可能直接放在文件中的,不然就泄露了。一般我们存放在 Actions secrets 中,使用环境变量配置进行读取,如下图所示。

Spring Cloud

了解了 Spring Boot 如何操作后,Spring Cloud 实际上也就很简单了。我用自己的 Pisces-Cloud 项目来举例,一个微服务下的子模块,假设全部在一个项目空间内的话,通常来说,打包构建只需要一次,就可以将所有的服务构建完成,那么问题就好办多了。

同样的,我们要对每个服务创建一个 Dockerfile 文件,然后再新建一个 docker-buildx.yml 文件即可。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
name: "Java CI with Multi-arch Docker Image"

on:
 push:
 branches: [ main ]

jobs:
 docker:
 name: Running Compile Java Multi-arch Docker Image=
 runs-on: ubuntu-latest
 steps:
 - uses: actions/checkout@v3
 - name: Setup Java
 uses: actions/setup-java@v3
 with:
 java-version: '8'
 distribution: 'adopt'
 cache: 'maven'
 - name: Build with Maven
 run: mvn -B package -Dmaven.test.skip=true --file pom.xml
 - name: Login to Docker Hub
 uses: docker/login-action@v1
 with:
 username: ${{ secrets.DOCKERHUB_USERNAME }}
 password: ${{ secrets.DOCKERHUB_TOKEN }}
 - name: Set up QEMU
 uses: docker/setup-qemu-action@v1
 - name: Set up Docker Buildx
 id: buildx
 uses: docker/setup-buildx-action@v1
 # 从这里开始看
 - name: Build and push admin
 id: docker_build_admin
 uses: docker/build-push-action@v2
 with:
 context: ./
 file: ./pisces-admin/admin-boot/Dockerfile
 platforms: linux/amd64,linux/arm64
 push: true
 tags: ${{ secrets.DOCKERHUB_USERNAME }}/pisces-admin:latest
 - name: Build and push auth
 id: docker_build_auth
 uses: docker/build-push-action@v2
 with:
 context: ./
 file: ./pisces-auth/Dockerfile
 platforms: linux/amd64,linux/arm64
 push: true
 tags: ${{ secrets.DOCKERHUB_USERNAME }}/pisces-auth:latest
 - name: Build and push gateway
 id: docker_build_gateway
 uses: docker/build-push-action@v2
 with:
 context: ./
 file: ./pisces-gateway/Dockerfile
 platforms: linux/amd64,linux/arm64
 push: true
 tags: ${{ secrets.DOCKERHUB_USERNAME }}/pisces-gateway:latest

注意到区别了吗?实际上只多了“步骤”,就是对每一个 Dockerfile 都创建一个单独的“步骤”,来进行镜像的打包,只需要将这些步骤全部放到 maven 构建之后即可。

一些建议

我这里只提供了能同时满足 x86 平台和 arm 平台的容器构建方案,其它功能其实是由 Docker 本身实现的。

1
2
3
4
5
docker run -d --name pisces-gateway \
 -p 8000:8000 \
 -e JAVA_OPTS="-Xms512m -Xmx512m -Duser.timezone=GMT+08 -Dfile.encoding=UTF8" \
 -e SPRING_CONFIG="--spring.profiles.active=prod --spring.cloud.nacos.discovery.server-addr=http://127.0.0.1:8848" \
 besscroft/pisces-gateway:latest

比如说这里 Docker 容器启动时设置 JVM 参数,以及配置参数。

对于发布版本镜像,开源社区中比较规范一点的玩法是,创建 release.yml 文件,通过给分支打上 tag 标签来触发事件,进行工作流程。其它的骚操作就不属于本文讨论范围啦!

我的 Java 日志记录最佳实践

为什么是日志?

对于大多数后端开发来说,日志可能是应用程序给予你反馈的唯一方式。在开发环境,你通常能用 Idea 之类的集成开发工具进行 DeBug 调试,但是在生产环境呢?部分人也许用过远程调试,但生产环境原则上来讲,是不允许你进行任何调试操作的。所以,拥有一份便于查看的日志,对排查问题相当重要。

日志的好处往往在一个系统中是被低估的,甚至很多人根本不重视它。人写日记,是为了记录每一天都干了些什么,而软件的日志,应当如此。作为一名优秀的软件开发工程师,我们应该利用好日志来解决问题,并辅助了解整个系统的健康状况。

如果你对日志还没有系统性的了解,或者不清楚什么是日志,可以参考 Wiki 百科的 日志文件 词条,一步步去了解。

我的最佳实践

以下是我的 Java 日志记录最佳实践。

日志信息的优化

日志虽然能帮助我们排查线上问题,但是日志信息太多的话,也并不是一件好事。所以,结合你自己的系统和业务,对日志记录的繁简程度恰到好处,是一件并不容易的事情。

方法之后

弄清楚这个标题之前,我们得先明确一件事——日志在业务中用来记录已经发生的事情。我来举一个通用的例子吧:

1
2
3
4
5
6
7
// 方法之前打印日志
log.info("准备执行登录操作.[loginParam={}]", loginParam);
userService.login(loginParam);
// 方法之后打印日志
String result = userService.login(loginParam);
log.info("登录执行完成.[result={}]", result);

首先,登录方法,可能是执行了服务间的调用,或者是本地方法。我们从第一个日志是看不出 login() 是否调用成功的,在某些情况下,可能无法产生任何有效的信息。比如说你【没注意到】被调用方抛出的异常、或者说压根就没有做异常处理(相信不少人有过这种经历),那么你这时候就会陷入怀疑之中,根本看不出来是什么情况导致的。

这种情况我是真的碰到过,可能是经验不足导致的,以至于后来专门去了解了日志的相关知识。在这之前,我确实也比较容易忽略它的重要性。

我们再来看第二个日志,当我们看到它的时候,说明它之前的操作是已经成功了。如果说调用失败了,那么你是看不到日志的,应该能直接看到异常。

不同架构下的日志

在传统的单体项目中,我们一般只需对一次请求间经过的所有日志都加上【唯一的 id】—— RequestID,将所有执行过程串联起来。

在 SOA/微服务 等架构下,情况就有所不同了,因为我们需要考虑到服务之间的可跟踪性。比如说我们需要在一个【统一的入口】,使用【唯一的请求标识符】,标记这次请求所执行到的所有的日志。这样才能对日志进行跟踪,虽然每个服务直接打印的日志信息,在海里日志信息里面没有什么特别的关联。但是当我们清洗日志数据之后,可以根据【唯一标识符】,去筛选出所有相关的日志信息。

一般的,我们会用 Elasticsearch 或者 Clickhouse 存储日志,用 Kibana 或者 Grafana 来进行日志分析。这时你会发现,在排查线上问题的过程中,你只要找到对应请求的【唯一标识符】,你就能迅速在海量日志中,找到对应的信息。

分离参数和消息

开发中比较常见的日志类型,主要有两种,一种是咱们自定义的日志消息,另一种是业务参数,当然,可能还有异常信息之类的。下面是一个简单的例子:

1
2
3
4
5
6
7
// 没有分开的情况
restTemplate.getForObject(url, String.class, vars);
log.info("请求路径:{}成功啦!", url);
// 分开的情况
restTemplate.getForObject(url, String.class, vars);
log.info("请求成功啦![url={}]", url);

咋一看上去,2 种日志格式,似乎没多大区别。但实际上,第一种在某些情况下增加了阅读难度,我们不妨想一下,在开发中,是否有碰到过提示消息很长的日志语句?或者是业务参数特别的长,这种情况下将业务参数放在末尾是否更好呢?而且如果要添加新参数的话,重写起来相对而言也更麻烦不是吗?当然,第一种可能从某些角度来说,写起来会爽一些。

如果说你接触过 LogStash 的话,那么你应该了解 Grok 插件,第一种写法,将会导致Grok 模式的解析变得更加困难,而第二种写法,就不存在这种情况,因为语法很清晰。

日志格式的优化

拿 SpringBoot 项目来举例说明,一般我们会在对应的 xml 文件中配置日志的输出格式。这些文件可能是 logback-spring.xmllog4j2.xml 等。

日志格式结构化

一般来说,日志输出文件中,主要会包含如下内容:

  • 日志打印时间
  • 日志输出级别
  • 自定义的【唯一标识】
  • 线程名称
  • 调用链标识
  • 日志内容
  • 程序异常堆栈
pattern="[%d{yyyy-MM-dd HH:mm:ss}] %-5level {requestId=%X{requestId}} %class{36} %L %M - %msg%xEx%n"

我们可以看到这个例子,开头是日志时间、其次是日志级别、然后是 requestId ,消息放在了最后。实际上格式并没有太多特殊的要求,更多的是你需要结合项目自身情况,和你的团队成员共同制定应该用什么格式。

日志级别控制

日志级别有 8 种,去掉全部显示和全部关闭,还剩如下 6 个级别:

  • TRACE — TRACE 级别指定比 DEBUG 更细粒度的信息事件,因为级别很低,所以一般用不到。
  • DEBUG — DEBUG 级别指定对调试应用程序最有用的细粒度信息事件,一般用于开发过程中打印运行(技术)信息。
  • INFO — INFO 级别指定信息消息,在粗粒度级别突出显示应用程序的进度,一般用于打印业务信息。
  • WARN — WARN 级别表示潜在的有害情况,一些特定的错误信息,也会是一些提示。
  • ERROR — ERROR 级别指定可能仍允许应用程序继续运行的错误事件,一般打印错误和异常信息。
  • FATAL — FATAL 级别指定可能导致应用程序中止的非常严重的错误事件。

一般来说,DEBUG 和 INFO、WARN 和 ERROR 容易混淆。在一个开发团队中,通常日志级别的设定标准是需要达成一致的,可以参考 [The Syslog Protocol](The Syslog Protocol)。最好是对不同级别的日志,定义不同的输出格式,而不是由开发人员自由发挥。

日志性能优化

我们要确保在打印日志时不会增加额外的系统响应时间,毕竟有些地方的日志是必须的。尽可能以异步方式写入日志,可以先写入到本地日志文件,再使用日志转发器发送给相应的服务,也可以使用消息队列,但这种方式可能会存在延迟,不过相对于对业务性能的影响而言,都是可以接受的。

日志输出文件

我们需要将不同的日志输出到不同的文件,因为不是很多小伙伴参与的项目,都是使用了诸如 ELK 之类的东西。这时候为了方便对线上错误进行分析,如果所有的错误日志同时被输出到了一个单独的文件之中,那么排查起来将会省事很多。

一些场景下的使用规范

  • 使用标准的日志对象,而不是直接创建日志实现类。
1
2
3
4
5
// 在类上面加上注解
@Slf4j
// 或者直接使用下面这行代码创建
private static final Logger log = LoggerFactory.getLogger(Main.class);

面向接口编程,而不是面向实现,这是软件设计模式之一。上面 2 种方式,实现的效果是一样的(编译后再看你就明白了),在项目中统一使用某一种即可!

  • 不推荐标准输出
1
2
3
4
5
6
7
try {
// todo
} catch (Exception ex) {
Syste.out.println(ex.getMessage()); // 不推荐
 System.err.println(ex.getMessage()); // 不推荐
 log.error("error message", ex); // 推荐
}
  • 不推荐 printStacktrace
1
2
3
4
5
6
try {
// todo
} catch (Exception ex) {
ex.printStacktrace(); // 不推荐
 log.error("error message", ex); // 推荐
}

这个 printStacktrace() 几乎所有人都用过,不知道大家有没有点进去看过一眼源码,实际上跟上面那一条说的道理是一样的。

1
2
3
public void printStackTrace() {
printStackTrace(System.err);
}
  • 注意不要输出掉了关键信息
1
2
3
4
5
6
7
try {
// todo
} catch (Exception ex) {
log.error(ex.getMessage()); // 不推荐
 log.error("error message", ex.getMessage()); // 不推荐
 log.error("error message", ex); // 推荐
}

这种 ex.getMessage() 写法,我在不少项目见过,曾经我也经常用,但随着学习的深入,发现这样做其实并不太好。因为这样的日志输出方式,往往会丢失掉最重要的 StackTrace 信息。

  • 日志异常二选一
1
2
3
4
5
6
try {
// todo
} catch (Exception ex) {
log.error("error message", ex); // 两者冲突
 throw new RuntimeException(ex); // 推荐
}

如果捕获异常后,因为业务需求,需要再次往外抛异常,那么可以不用写上面那句错误日志。因为这种情况下,异常都是交由最终捕获方去进行了处理的,很容易造成重复日志输出。你想一想,全局异常处理的地方,是不是会单独打印一条错误日志?当然,还得看项目本身的实际情况,这里只是推荐二选一。

  • 别 TM 在循环中打印日志了 🥲
1
2
3
for (int i=0; i < vars.size(); i++) {
log.info("不要在循环中打日志!!!");
}

注意上面这个循环,如果循环次数很多,想象一下,是不是会产生很多无意义的日志?而且可能会影响程序的性能!

最后

想玩转日志,要注意的点有很多,日志容易被人们忽略,但它也很重要。日志跟踪、日志聚合、日志指标等等,都有不少知识点。这篇文章汇集了我自 20 年毕业到现在以来,对于日志系统而言踩过的坑,以及一些学习的总结。当然,要学的还有很多,不同团队、不同项目采用的方案也不同。但是你对日志系统有一个全面的认识之后,不管面对哪一种情况,你都能游刃有余。哪怕是参与到团队日志规范的制定,你也不会听的云里雾里,甚至可以提出你自己的观点。全面认知过日志系统后,你也能大幅度减轻你和同事对线上问题排查的负担。

当然,还有更多关于日志的内容,我这里没有写,如果你想了解,请善用搜索引擎。

扩展阅读:美团技术团队:如何优雅地记录操作日志?

普罗米修斯の初体験

前言

为什么我会选择普罗米修斯(Prometheus)?Prometheus 是按照 Google SRE 运维之道的理念构建的,具有实用性和前瞻性。同时也是基于 Go 语言开发的,性能好,安装部署也简单,甚至跨平台(包括 arm 平台)。作为对服务基础和业务监控,Prometheus 是一个非常好的选择。

什么是普罗米修斯?

咱们这里引用官方的术语: Prometheus 是一个开源系统监控和警报工具包,它可以收集系统信息,并将其发送到一个或多个监控中心。Prometheus 将其指标收集并存储为时间序列数据,即指标信息与记录它的时间戳一起存储,以及称为标签的可选键值对。

这里引入官网的图,说明 Prometheus 的架构及其一些生态系统组件:

安装

安装方式有很多,二进制包或者 Docker 都可以,这里我们选择二进制包。

安装环境

这里俺用的是 Ubuntu 20.04,别问我为啥,主要是我内存最大的机器就是这台了(24G 内存),只不过,它是 arm64 架构的,所以下面的教程是运行在 arm64 架构的服务器上面的,当然,你也可以用本教程在 amd64 架构下安装,只有一些细微的区别,咱们下面会讲到。

安装 Prometheus

下载 Prometheus

你可以去官网,或者 GitHub 的发布页面下载安装包,这里我下载的是 GitHub 仓库的包。

  • 如果你是 amd64 架构的服务器
1
2
3
4
wget https://github.com/prometheus/prometheus/releases/download/v2.31.0/prometheus-2.31.0.linux-amd64.tar.gz
tar xfz prometheus-2.31.0.linux-amd64.tar.gz
sudo cp prometheus-2.31.0.linux-amd64/prometheus /usr/local/bin/
sudo cp prometheus-2.31.0.linux-amd64/promtool /usr/local/bin/
  • 如果你是 arm64 架构的服务器
1
2
3
4
wget https://github.com/prometheus/prometheus/releases/download/v2.31.0/prometheus-2.31.0.linux-arm64.tar.gz
tar xfz prometheus-2.31.0.linux-arm64.tar.gz
sudo cp prometheus-2.31.0.linux-arm64/prometheus /usr/local/bin/
sudo cp prometheus-2.31.0.linux-arm64/promtool /usr/local/bin/

最主要的差异,就是要下载不同的包,后面的配置几乎相同

配置 Prometheus

  • 检查
1
prometheus --version

执行命令,出现如下图所示,就成功了!

我们在刚才解压后的文件夹下面,可以找到一个子目录 prometheus ,然后可以找到一个配置文件 prometheus.yml 。咱们现在需要把 prometheus.yml 这个初始配置文件复制到 /etc/prometheus 目录下,然后简单配置就可以启动啦。当然,你也可以按照自己的需求来配置,具体可以参考官方的配置文档

1
2
sudo mkdir -p /etc/prometheus
sudo cp prometheus.yml /etc/prometheus/
  • 默认的部分配置如下:
1
2
3
4
scrape_configs:
 - job_name: "prometheus"
 static_configs:
 - targets: ["localhost:9090"]

我们可以看到端口是 9090 ,你可以按需求改为其它的。

启动 Prometheus

接下来咱们启动看看

1
prometheus --config.file "/etc/prometheus/prometheus.yml"
  • 如果发生了异常,则可以使用 prometool 工具来检查你的配置文件。
1
promtool check config "/etc/prometheus/prometheus.yml"

如果输出如下提示,说明没问题了!

Checking /etc/prometheus/prometheus.yml
SUCCESS: 0 rule files found

配置 service 方式运行 Prometheus

  • 新建一个 service 文件
1
sudo vim /etc/systemd/system/prometheus.service
  • 编辑如下内容保存即可!
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
[Unit]
Description=prometheus
[Service]
User=root
ExecStart=prometheus --config.file "/etc/prometheus/prometheus.yml"
Restart=on-abort
[Install]
WantedBy=multi-user.target
  • 设置开机启动
1
sudo systemctl enable prometheus
  • 启动 Prometheus
1
sudo systemctl start prometheus

这样便大功告成啦!

配置 HTTPS 和反向代理

如果你的服务器是 HTTPS 的,那么需要配置 HTTPS 的证书和私钥,这里俺使用的是 Let's Encrypt 的证书,可以去官网下载。具体如何操作就不说了,如果你不会,应该去学习如何使用 Nginx,并学会配置 HTTPS 证书。

  • 配置 Nginx 反向代理

这里我放出我的配置吧,你可以根据你的需求,参考使用:

location /
{
proxy_pass http://127.0.0.1:9090;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header REMOTE-HOST $remote_addr;
add_header X-Cache $upstream_cache_status;
#Set Nginx Cache
add_header Cache-Control no-cache;
}

Prometheus 启动监听在本地回环地址 localhost:9090 ,所以公网是无法直接访问的,我也不太建议你开放防火墙,这样能带来一定的安全保护。

预览

这时候咱们访问域名,就能看到页面啦!

这里提示,我这不是生产环境,只是拿来练手学习,所以无所谓,生产环境不建议这么做!可以通过 ssh 端口转发方式实现远程访问。

在微服务项目中引入 knife4j

什么是 Knife4j ?

knife4j 是为 Java MVC 框架集成 Swagger 生成 Api 文档的增强解决方案。说白了,如果项目开发为前后端分离开发的话,这个插件就非常的省事儿,不用再很麻烦的写接口文档了。之前用过 Swagger 来生成文档,但是在某些情况下,Swagger 却并不适合国内的项目,尤其是验收文档。给甲方的验收文档往往包含接口文档,这是 knife4j 的导出就派上用场了。

没有好不好用,只有适不适合!

引入项目

说明

本文章以我自己的开源项目 aurora-mall 为例,详细讲述在 Spring Cloud 2020 & Alibaba 2021 中,应该如何引用。为什么必须加上这个说明呢?因为不同的项目,引入方式还是有一些差别的。

开始

导包

  • 在项目的根 pom.xml 文件中导包。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
<properties>
<knife4j.version>2.0.4</knife4j.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-micro-spring-boot-starter</artifactId>
<version>${knife4j.version}</version>
</dependency>
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-spring-boot-starter</artifactId>
<version>${knife4j.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
  • 在网关服务的 pom.xml 中,导包
1
2
3
4
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-spring-boot-starter</artifactId>
</dependency>
  • 在每一个需要生成文档的服务的 pom.xml 中,导包(一般放在 common 中,然后不需要屏蔽即可)
1
2
3
4
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-micro-spring-boot-starter</artifactId>
</dependency>

gateway 服务模块的接口,需要多一个包,用来输出文档到前端(包含UI包)它会把我们所有的微服务都聚合到一个文档,统一输出到前端,其它服务只需控制数据,这样便于节约资源!

生产环境屏蔽

目前 Springfox-Swagger 以及 Knife4j 提供的资源接口包括如下:如果你要用的话,记得白名单放行

资源 说明
/doc.html Knife4j提供的文档访问地址
/v2/api-docs-ext Knife4j提供的增强接口地址,自2.0.6 版本后删除
/swagger-resources Springfox-Swagger提供的分组接口
/v2/api-docs Springfox-Swagger提供的分组实例详情接口
/swagger-ui.html Springfox-Swagger提供的文档访问地址
/swagger-resources/configuration/ui Springfox-Swagger提供
/swagger-resources/configuration/security Springfox-Swagger提供

当我们部署系统到生产系统,为了接口安全,需要屏蔽所有 Swagger 的相关资源

如果使用 SpringBoot 框架,只需在 application.properties 或者 application.yml 配置文件中配置

1
2
3
4
5
knife4j:
 # 开启增强配置 
 enable: true
 # 开启生产环境屏蔽
 production: true

Java 化配置

  • 配置基类

咱们一般会放在 common 包里面。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
package com.besscroft.aurora.mall.common.config;
import com.besscroft.aurora.mall.common.domain.SwaggerProperties;
import org.springframework.context.annotation.Bean;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.service.*;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spi.service.contexts.SecurityContext;
import springfox.documentation.spring.web.plugins.Docket;
import java.util.ArrayList;
import java.util.List;
/**
 * swagger 通用配置
 *
 * @Author Bess Croft
 * @Date 2021/4/2 12:28
 */
public abstract class BaseSwaggerConfig {
@Bean
public Docket createRestApi() {
SwaggerProperties swaggerProperties = swaggerProperties();
Docket docket = new Docket(DocumentationType.SWAGGER_2)
.apiInfo(apiInfo(swaggerProperties))
.select()
.apis(RequestHandlerSelectors.basePackage(swaggerProperties.getApiBasePackage()))
.paths(PathSelectors.any())
.build();
if (swaggerProperties.isEnableSecurity()) {
docket.securitySchemes(securitySchemes()).securityContexts(securityContexts());
}
return docket;
}
private ApiInfo apiInfo(SwaggerProperties swaggerProperties) {
return new ApiInfoBuilder()
.title(swaggerProperties.getTitle())
.description(swaggerProperties.getDescription())
.contact(new Contact(swaggerProperties.getContactName(), swaggerProperties.getContactUrl(), swaggerProperties.getContactEmail()))
.version(swaggerProperties.getVersion())
.build();
}
private List<ApiKey> securitySchemes() {
// 设置请求头信息
 List<ApiKey> result = new ArrayList<>();
ApiKey apiKey = new ApiKey("Authorization", "Authorization", "header");
result.add(apiKey);
return result;
}
private List<SecurityContext> securityContexts() {
// 设置需要登录认证的路径
 List<SecurityContext> result = new ArrayList<>();
result.add(getContextByPath("/*/.*"));
return result;
}
private SecurityContext getContextByPath(String pathRegex) {
return SecurityContext.builder()
.securityReferences(defaultAuth())
.forPaths(PathSelectors.regex(pathRegex))
.build();
}
private List<SecurityReference> defaultAuth() {
List<SecurityReference> result = new ArrayList<>();
AuthorizationScope authorizationScope = new AuthorizationScope("global", "accessEverything");
AuthorizationScope[] authorizationScopes = new AuthorizationScope[1];
authorizationScopes[0] = authorizationScope;
result.add(new SecurityReference("Authorization", authorizationScopes));
return result;
}
/**
 * 自定义Swagger配置
 */
public abstract SwaggerProperties swaggerProperties();
}
  • 新建 SwaggerProperties 类
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
@Data
@Builder
@EqualsAndHashCode(callSuper = false)
public class SwaggerProperties {
/**
 * API文档生成基础路径
 */
private String apiBasePackage;
/**
 * 是否要启用登录认证
 */
private boolean enableSecurity;
/**
 * 文档标题
 */
private String title;
/**
 * 文档描述
 */
private String description;
/**
 * 文档版本
 */
private String version;
/**
 * 文档联系人姓名
 */
private String contactName;
/**
 * 文档联系人网址
 */
private String contactUrl;
/**
 * 文档联系人邮箱
 */
private String contactEmail;
/**
 * 版权信息
 */
private String license;
/**
 * 版权协议地址
 */
private String licenseUrl;
}
  • 在网关服务新增 Swagger 资源配置
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
package com.besscroft.aurora.mall.gateway.config;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.config.GatewayProperties;
import org.springframework.cloud.gateway.route.RouteLocator;
import org.springframework.cloud.gateway.support.NameUtils;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;
import springfox.documentation.swagger.web.SwaggerResource;
import springfox.documentation.swagger.web.SwaggerResourcesProvider;
import java.util.ArrayList;
import java.util.List;
/**
 * swagger资源配置
 *
 * @Author Bess Croft
 * @Date 2021/4/2 12:37
 */
@Slf4j
@Component
@Primary
@AllArgsConstructor
public class SwaggerResourceConfig implements SwaggerResourcesProvider {
private final RouteLocator routeLocator;
private final GatewayProperties gatewayProperties;
@Override
public List<SwaggerResource> get() {
List<SwaggerResource> resources = new ArrayList<>();
List<String> routes = new ArrayList<>();
//获取所有路由的ID
 routeLocator.getRoutes().subscribe(route -> routes.add(route.getId()));
//过滤出配置文件中定义的路由->过滤出Path Route Predicate->根据路径拼接成api-docs路径->生成SwaggerResource
 gatewayProperties.getRoutes().stream().filter(routeDefinition -> routes.contains(routeDefinition.getId())).forEach(route -> {
route.getPredicates().stream()
.filter(predicateDefinition -> ("Path").equalsIgnoreCase(predicateDefinition.getName()))
.forEach(predicateDefinition -> resources.add(swaggerResource(route.getId(),
predicateDefinition.getArgs().get(NameUtils.GENERATED_NAME_PREFIX + "0")
.replace("**", "v2/api-docs"))));
});
return resources;
}
private SwaggerResource swaggerResource(String name, String location) {
log.info("name:{},location:{}", name, location);
SwaggerResource swaggerResource = new SwaggerResource();
swaggerResource.setName(name);
swaggerResource.setLocation(location);
swaggerResource.setSwaggerVersion("2.0");
return swaggerResource;
}
}
  • 配置网关的 Swagger 处理器,自定义 Swagger 的各个配置节点
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
package com.besscroft.aurora.mall.gateway.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
import springfox.documentation.swagger.web.*;
import java.util.Optional;
/**
 * 自定义Swagger的各个配置节点
 *
 * @Author Bess Croft
 * @Date 2021/3/30 21:35
 */
@RestController
public class SwaggerHandler {
@Autowired(required = false)
private SecurityConfiguration securityConfiguration;
@Autowired(required = false)
private UiConfiguration uiConfiguration;
private final SwaggerResourcesProvider swaggerResources;
@Autowired
public SwaggerHandler(SwaggerResourcesProvider swaggerResources) {
this.swaggerResources = swaggerResources;
}
/**
 * Swagger安全配置,支持oauth和apiKey设置
 */
@GetMapping("/swagger-resources/configuration/security")
public Mono<ResponseEntity<SecurityConfiguration>> securityConfiguration() {
return Mono.just(new ResponseEntity<>(
Optional.ofNullable(securityConfiguration).orElse(SecurityConfigurationBuilder.builder().build()), HttpStatus.OK));
}
/**
 * Swagger UI配置
 */
@GetMapping("/swagger-resources/configuration/ui")
public Mono<ResponseEntity<UiConfiguration>> uiConfiguration() {
return Mono.just(new ResponseEntity<>(
Optional.ofNullable(uiConfiguration).orElse(UiConfigurationBuilder.builder().build()), HttpStatus.OK));
}
/**
 * Swagger资源配置,微服务中的各个服务的api-docs信息
 */
@GetMapping("/swagger-resources")
public Mono<ResponseEntity> swaggerResources() {
return Mono.just((new ResponseEntity<>(swaggerResources.get(), HttpStatus.OK)));
}
}
  • 在每个业务系统服务配置 Swagger
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package com.besscroft.aurora.mall.admin.config;
import com.besscroft.aurora.mall.common.config.BaseSwaggerConfig;
import com.besscroft.aurora.mall.common.domain.SwaggerProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import springfox.documentation.swagger2.annotations.EnableSwagger2;
/**
 * swagger配置
 *
 * @Author Bess Croft
 * @Date 2021/3/30 13:43
 */
@Configuration
@EnableSwagger2
@Profile(value = {"dev"})
public class SwaggerConfiguration extends BaseSwaggerConfig {
@Override
public SwaggerProperties swaggerProperties() {
return SwaggerProperties.builder()
.apiBasePackage("com.besscroft.aurora.mall.admin.controller")
.title("极光商城开发文档")
.description("后台相关接口文档")
.contactName("Bess Croft")
.contactEmail("besscroft@foxmail.com")
.contactUrl("https://github.com/besscroft/aurora-mall")
.license("Open Source")
.licenseUrl("https://github.com/besscroft/aurora-mall/blob/main/LICENSE")
.version("0.1.0")
.enableSecurity(true)
.build();
}
}

这里注意2点:

  • @Profile 注解可以规定在哪个环境下生效,咱们只控制开发环境就ok了。
  • apiBasePackage 设置生成的接口在哪个包里面。

同时,如果网关配置了白名单机制,记得放行 /v2/api-docs 地址!因为这里咱没做认证功能!(对于线上生产环境来说,白名单可以更好地控制访问权限,能一定程度保证安全性!)

启动

然后咱们启动项目,来查看是否配置成功!

访问分组接口地址:

1
http://localhost:8000/swagger-resources
[
{
"name": "mall-auth",
"url": "/mall-auth/v2/api-docs",
"swaggerVersion": "2.0",
"location": "/mall-auth/v2/api-docs"
},
{
"name": "mall-admin",
"url": "/mall-admin/v2/api-docs",
"swaggerVersion": "2.0",
"location": "/mall-admin/v2/api-docs"
},
{
"name": "mall-elasticsearch",
"url": "/mall-elasticsearch/v2/api-docs",
"swaggerVersion": "2.0",
"location": "/mall-elasticsearch/v2/api-docs"
},
{
"name": "mall-log",
"url": "/mall-log/v2/api-docs",
"swaggerVersion": "2.0",
"location": "/mall-log/v2/api-docs"
}
]

有正常值,表示接口返回正常!

  • 访问前端地址:
1
http://localhost:8000/doc.html#/home

注意,实例中的 8000,指的是网关的端口号!

使用注解

实际上就是 Swagger 的注解,比如说:

  • 接口类上面可以加 @Api(tags = "主页接口")

  • 接口方法上可以加 @ApiOperation(value = "首页排行接口")

  • 实体类上可以加 @ApiModel(value = "登录对象")

  • 实体类字段上可以加 @ApiModelProperty(value = "用户名", dataType = "String")

  • 当然,不加也可以默认扫描读取!

白名单

最后,放上一些可能需要放行的白名单地址:

1
2
3
4
5
6
7
"/doc.html"
"/v2/api-docs-ext"
"/swagger-resources"
"/v2/api-docs"
"/swagger-ui.html"
"/swagger-resources/configuration/ui"
"/swagger-resources/configuration/security"

最后

我觉得 knife4j 最大的好处就是,可以导出离线文档,有:Markdown、Word、Html、OpenAPI 格式!如果它正好命中你的需求,不妨试试引入到项目中呢!

愉快的五一假期

浅谈

这篇文章,是在五一假期结束之后写的,毕竟五一还是有点忙的!抽了2天时间来重构Hexo的代码,解决了很多未知的问题,每天都忙到凌晨,相信有过共同经历的人会明白的🤣总会有些花里胡哨的瑕疵,反正我是忍不了。😅

博客

我原先是采取的GitHub+coding方案,后来删掉了coding的仓库,只保留了GitHub Pages。最主要的还是用不习惯,虽然在某些地区访问的话,速度会更快。但是想必大多数用Hexo搭建博客,而且托管在GitHub的同学,是不在意只有少部分人来访博客的。因为需要大量的人访问,或者有这种需求的人,都会买云服务器。

正好coding的仓库空出来了,我就把GitHub的先放着,在coding上从头开始搭建、调试。等一切准备好了之后,在推送到GitHub上来,这样也不容易出问题。

煎熬的过程

环境在电脑上已经有了,于是到G盘创建了一个新文件夹,用于安装初始化Hexo,然后安装推送插件之后直接推送到coding上。确定搭建完成了最基本的之后,就在本地慢慢调试。主题依旧用NexT主题,只不过换成了NexT.Muse,然后照着几个不同的、比较知名的博客的样式,做出了一些修改,也就是你们现在看到的样子。虽然不是很漂亮,但是也够看了😁都说程序猿的审美不行,我觉得程序猿们要大胆的说:不!给他们看看,我们不只有格子衫😎

注意一定要按时备份!

设计整个页面的过程,是基于chrome的,一般来说现在的新浏览器都算是支持比较好的。虽然个人比较喜欢后端,但至少前端知识好歹是知道一点的,也不至于无从上手🤣许多CSS样式,都是放在了custom.styl文件中。经过了这次折磨之后,现在可算是知道了以前期末的时候,都是老师留情了的。没有个几百上千行的CSS代码,怎么好意思及格呢。。。

在快完成的时候,也是遇到了一些小挫折的。。。

嗯~ o( ̄▽ ̄)o,控制台不报错,压根都不知道问题出在哪儿了。去Google查了查,重新安装了所有的相关依赖包也依旧没有解决问题。没错,绝望的我,又重新开始了一遍,但是因为相关文件都已经配置好了,基本上就是复制粘贴了一遍。最后熬了2天夜,终于还是弄完了。

对于想直接要博客的整份源码的同学,我只能说,没有人会给的啦,原因很简单!这不应该是一个索取和给予的过程,而应该是一个讨论交流的过程。也就是说,你问我博客的任何东西是怎么实现的,我都会告诉你(后面也会抽时间写相关的文章),但是你直接要我当然不会给啊,毕竟也是自己的心血!

博客重构前后,源码从原先的200多M变成了40多M(包含了public文件夹),但是东西一样不少,博客反而更美观,功能更强大。。。这个问题一直没想通,这多出来的100多M到底是啥!

爱你三千遍

五一去影院看了《复仇者联盟4》,不知道为啥,竟然有点心疼灭霸😥最好笑的镜头

♂friend

五一还去了同学家吃饭,来了一些初中同学,大家都在聊以前初中时的沙雕欢乐的事情(。^▽^)

嗯~ o( ̄▽ ̄)o至于这个菜嘛,虽然我没参与,但是是大家一起努力弄出来的:

哈哈,卖相不好,味道倒是还凑合!越长越大,就越是让人怀恋以前读书时的美(sha)好(diao)日子呢!

黑苹果教程

写在前面

这可能、应该、也许是我写的最后一篇黑苹果相关文章(至少在我买新电脑之前),以后精力全部放在学习上,建议后来想要研究黑苹果的同学们早日弃坑,把时间放在更宝贵的事情上吧。经过一些调查,很多同学们装黑苹果的原因无非就是:折腾,当然也有其它的回答。但是,折腾的同时也确实可以学到很多东西。我想说的是,虽然黑苹果非常适合爱折腾的你,但是,折腾明白之后、或者是折腾不来,那么还是劝你早日退坑。。。

配置一览表

OMEN by HP Laptop 15-ce0xx HP 暗影精灵3
BIOS版本 F.18-11/09/2018
CPU i7-7700HQ
显卡 nVidia GTX 1050
声卡 ALC295
硬盘 HP SSD EX950 512G、HGST 1T

如果你的电脑和我同配置的话,EFI文件可以直接用我的安装使用,配置相仿的不可以直接使用,自己可以根据我的配置按需调整。但是请注意,我GitHub里的并不是完整的EFI,但是足够跟我同配置的同学安装使用了。

安装准备

下载

相关的文件我都在我的GitHub发布了,打不开或者下载不来的,自己多研究研究吧,一切随缘!!!(当然这个项目可能还不太完善,我会慢慢整理的,避免大家走弯路!当然,你有什么好资源,也可以让我上传,毕竟这是个开源项目!仅限于学习研究使用!),没错,为了方便大家下载,我把文件打包了,不打包有的小伙伴下载不来。👉GitHub

一些说明

  • 声卡正常,键盘可以调声音
  • 摄像头正常
  • 触摸板正常
  • 睡眠和唤醒正常,合上盖子再打开也正常,我不知道白苹果是怎样的,但是我试了很多次,没发现问题。
  • 电池正常
  • 有线网卡正常
  • 亮度正常,但是不能用键盘调整,在设置里面可以调整(我得再去爪巴帖子了😓
  • 独显因为不能驱动,所以屏蔽
  • 蓝牙
  • WiFi :(

BIOS设置

HP的BIOS建议:

  • 关闭安全模式

其它品牌建议还要:设置为AHCI模式

嗯~ o( ̄▽ ̄)o,有空再写,不着急。。。

准备

在开始前,我们建议你准备一个最好大于8G的U盘、一台电脑(不熟练的人建议2台)、一个具有你Windows系统恢复介质的U盘(因为装黑苹果导致Windows挂掉的人不在少数),一个用来备份的移动硬盘(如果你没有需要备份的文件请随意)。

  1. 原版镜像,注意是原版的加工封装的OS镜像,带不带Clover引导无所谓,新手建议带上,那样就不用自己配置了!
  2. U盘烧写工具,有很多,我用的TransMac,所以建议使用TransMac。
  3. 硬盘管理工具,这个当然是DG了!
  4. EFI启动引导项管理工具,推荐EasyUEFI或者UEFIBCD。
  5. 强制性要求:主板必须支持UEFI,这篇文章是适用于UEFI启动的,如果不是的话,你看这篇文章也没意义。
  6. 硬盘为GPT格式。
  7. 给黑苹果的EFI分区一定要大于200M!!!

安装MacOS

开机按F9进入Boot Manager引导管理,选择你制作的那个U盘,然后回车!

然后进入Clover主菜单

移动光标到Boot OS X Install from ***回车,如果无法进入安装界面,需要打开啰嗦模式进行排错,不过新手可能看不懂,但是也要拍照,给知道的人帮忙看看。

引导MacOS

基本上就是图中的样子啦!稍等片刻。具体时间决定于你的配置,耐心等待一小会儿进入安装程序界面。

语言选择

建议选择简体中文哦!😋

点击下一步,出现macOS实用工具界面,选择磁盘工具

进入磁盘工具之后,选择显示所有设备:

选择你在Windows下事先创建好,准备给macos用的那个分区,点击抹掉按钮,选择默认的Mac OS扩展(日志型),将名称修改为你自己喜欢的名字即可,点击抹掉按钮。

之前专门强调了备份了的啊!!!千万注意!!!

如果这时候报错,是由于EFI分区尺寸小于200MB而引起的抹盘失败的错误,甚至可能会导致后面的安装失败!!!

安装开始啦!

这个时候我们选择希望,哦不,是选择安装macOS

进入安装界面

下一步

选择继续,然后选择你才格式化了的硬盘。

在这期间,它会把USB安装盘上的安装文件预复制到要安装的系统分区里,这个过程在HP暗影精灵3上它跑得飞快(我放在固态上了),数据复制完后,它会自动重启

其实我感觉安装,和安装完了的设置,压根没必要写啥教程,因为你能进入这个界面的话,说明你的能力足够你一个人完成。

设置向导

不多说废话了,也不放图,简单粗暴的列几条建议:

  • 首先会让你选择国家,在这一步我选择中国!
  • 设置键盘,根据自己的习惯选择
  • 如果是新安装,建议不传输信息到这台Mac
  • 数据恢复,嗯,跳过吧
  • Apple ID登录,建议进入系统后再登录
  • 创建用户,这一步很简单
  • 反正有啥要你同步的一律先不干,也不要连接到互联网,建议进入系统后再慢慢设置。

安装完成后

千万别以为这样就完事儿了,Windows安装完之后都还需要联网安装更新呢,后面更艰巨的任务在等着你,小老弟🤣

挂载EFI分区

首先你肯定得把你U盘里的EFI文件弄到电脑上的EFI分区里去,如果你不愿意干这一步,那么你每次启动MacOS都将插上U盘。虽然我有一段时间这么干过,因为我觉得不插上U盘的时候,感觉MacOS系统就不存在了一样,要用的时候直接插上U盘即可!如果你想将U盘里的EFI复制到磁盘的EFI分区里,却苦于找不到看不见EFI分区,这个时候是该让diskutil登场了。

查看磁盘分区表:

diskutil list

挂载磁盘EFI分区:

sudo diskutil mount <这里填你的分区,比如:disk0s1,具体的查看你的磁盘分区表>

挂载U盘EFI分区:

sudo diskutil mount <这里填你的分区,比如:disk0s1,具体的查看你的磁盘分区表>

打开Finder,注意后面有个.

open .

左侧会显示挂载了两个EFI分区,将U盘EFI目录全部复制到磁盘的EFI分区即可。

合并EFI分区

这里有一点需要注意:如果之前安装过Windows系统的话,会存在EFI的目录,只是EFI的目录下面只有BOOT和Microsoft这两个目录,如果希望添加macOS的Clover引导的话,可以将USB的EFI分区里面的EFI目录下面的CLOVER复制到磁盘里的EFI目录下,也就是执行的是合并的操作,让EFI同时支持WINDOWS和macOS的引导.千万不要全部复制,否则有可能造成EFI无法启动Windows.

复制EFI分区

如果磁盘上的EFI分区里为空的,可以直接将USB的EFI分区下面的EFI目录直接复制到磁盘上的EFI分区里.

Windows下的骚操作

挂载EFI分区

Windows操作系统下面,打开cmd窗口,输入命令:

diskpart
list disk # 磁盘列表
select disk n # 选择EFI分区所在的磁盘,n为磁盘号
list partition # 磁盘分区列表
select partition n # 选择EFI分区,n为EFI分区号
set id="ebd0a0a2-b9e5-4433-87c0-68b6b72699c7" # 设置为EFI分区
assign letter=X # x为EFI分区盘符

您可以重复输入命令同时挂载USB的EFI分区和磁盘的EFI分区 打开资源管理器,会出现一个盘符为X的磁盘,格式化为fat32格式,将USB的EFI分区下面的EFI目录复制到安装磁盘的EFI分区下。

合并EFI分区

这里有一点需要注意:如果之前安装过Windows系统的话,会存在EFI的目录,只是EFI的目录下面只有BOOT和Microsoft这两个目录,如果希望添加macOS的Clover引导的话,可以将USB的EFI分区里面的EFI目录下面的CLOVER复制到磁盘里的EFI目录下,也就是执行的是合并的操作,让EFI同时支持WINDOWS和macOS的引导.千万不要全部复制,否则有可能造成EFI无法启动Windows.

复制EFI分区

如果磁盘上的EFI分区里为空的,可以直接将USB的EFI分区下面的EFI目录直接复制到磁盘上的EFI分区里.

利用EasyUEFI来挂载

什么?看不懂这些指令,怕出错?我懂你!你可以在Windows系统下利用诸如EasyUEFI等工具来操作。

我们打开下载好的diskgenius工具

EFI分区的文件一般是这样的

打开EsayUEFI,点击中间的绿色加号,进入添加引导项的页面,选择"Linux或其它操作系统",并且在Description这一项写入这个引导项的名字(名字可以随便乱取,但是别忘记!)。然后在下面的硬盘选择区域选择一个你自己新建的ESP分区,然后点击下方的Browse浏览文件。

然后选择自己配置好的EFI中的CLOVERx64.efi这个文件以完成添加。

然后回到工具的首页,将该引导项置顶(置不置顶看你自己选择了,想要开机可以选择双系统就置顶,想要开机直接进入Windows,就放在Windows Boot Manager后面)

到这里就大功告成啦!

EasyUEFI报错
  • 这个可能是因为BIOS设置问题,如果加了BIOS密码,会报这个错误。还有可能就是EFI分区没有正常挂载的原因。遇到这个问题,有的时候照样能够添加进去引导项,但是无法移动引导项的顺序。这样的话,你可以先添加,再进入BIOS设置引导顺序,或者是直接在BIOS里面添加启动项。如果EasyUEFI完全没有作用,你可以尝试进入PE卸载ESP分区重新启动或是先备份系统的EFI分区,然后使用DG将原本的EFI分区删除,重建以后重启即可。
  • 可能是当前版本的EasyUEFI出现了问题、或者是你下的有问题、或者是当前版本与你的电脑不兼容
利用BOOTICE
  1. 打开BOOTICE软件,选择物理磁盘,选择欲操作的目标磁盘,点击分区管理,弹出分区管理的窗口,点击分配盘符,为ESP分区分配一个盘符,点击确定
  2. 选择UEFI,点击修改启动序列,点击添加按钮,菜单标题填写:CLOVER,选择启动文件,在打开的窗口里选择ESP分区下的目录\EFI\CLOVER\CLOVERX64.EFI,点击保存当前启动项设置

下载链接

迅雷离线下载:点击下载感谢@难忘情怀提供下载资源

http下载链接:点击下载感谢@难忘情怀提供下载资源

百度网盘:点击链接 接头暗号:s68r MD5 (macOS Mojave 10.14.3(18D42) Installer with Clover 4859.dmg) = 450c55e5c5d3f4bfae6bb55ff2a33aea

EFI下载/更新

点击这里进入魔法门

特别鸣谢

@原味菠萝 @黑果小兵 为本教程提供的的大力支持!

❌
❌