详解微服务,它是怎么演进的?微服务解决了哪些问题?我们应该如何设计微服务?
作者:@万明珠
喜欢这篇文章的话,就点个关注吧,会持续分享高质量Python文章,以及其它相关内容。
微服务概览
在项目开发初期,秉承着怎么简单怎么来的原则,我们一般都会选择单体架构,比如最经典的 MVC。
传统的 Web 应用就如上图展示的那样,所有的功能模块都被打包在一个单一的代码库中,形成一个所谓的巨石应用。在早期业务量不大、代码量不复杂的时候,这种模式在测试、开发、部署等方面都非常方便。但随着业务的增长,功能模块的增加,这种模式的弊端就凸显出来了。因为是单体架构,哪怕只修改了其中一小部分功能,都要导致整个应用重新部署。最关键的是,你不知道修改的部分会不会对其它模块产生影响,因为模块之间不是隔离的。
所以尽管单体架构也是模块化逻辑,会按照功能进行模块上的划分,但最终还是会打包并部署为单体式应用。而随着业务变得复杂,模块也会越来越多,它们全部杂糅在一起,以至于任何单个开发者都不可能搞懂它。应用也会变得无法扩展、可靠性降低,最终敏捷开发和部署变得无法完成。
那如何解决呢?最直接的想法就是化繁为简,分而治之。按照业务逻辑或功能将整个应用进行拆分,形成多个服务,每个服务只专注于某个局部的功能,而服务与服务之间是隔离的。这样在修改或升级某个服务的时候,就不会对其它服务造成影响了,比如服务 A 需要升级,那么代码修改完毕后只需要重新部署服务 A 即可,其它服务对应的功能不受影响,从而实现平滑部署、平滑升级。
而这便是微服务的意义所在,也是它要解决的问题。另外关于微服务,大家可能还听到过一个词叫 SOA(面向服务的架构模式),这两者的关系也很简单,你可以把微服务想象成 SOA 的一种实践。
那么采用微服务,都需要注意哪些呢?或者说设计一个微服务需要遵循哪些原则呢?
1)小即是美
小的服务代码少,Bug 也少,易于测试和维护,也更容易不断迭代和完善。
2)单一职责
《代码整洁之道》这本书提到过,一个函数只做一件事情,复杂的功能由多个函数配合来完成。对于微服务来说也是如此,一个微服务只需完成一个功能,然后多个服务之间采用 RPC 进行通讯,彼此配合共同组装出业务场景需要的数据。
3)尽可能早地创建原型
在单体架构中,依赖关系是 class 与 class,而在微服务当中,依赖关系则变成了服务与服务。当服务需要彼此调用时,应尽可能早的提供服务的 API,建立服务契约,达成服务间沟通的一致性约定,至于实现和完善可以慢慢再做。
4)可移植性比效率更重要
在单体架构中,语言是统一的,但对微服务来说则没有此要求。因为服务之间是独立的,针对不同的功能可以选择最适合的语言去开发,那么很明显,服务之间如何通信以及数据如何序列化就需要我们考虑了。而考量的标准,你可能想到的是性能和效率,不错,这的确是一个非常值得参考的因素。比如序列化和反序列化,它是服务彼此调用的一个必须过程,因此性能和效率会产生很大影响。
但除此之外,还有空间开销,也就是序列化之后的二进制数据的体积大小。序列化后的字节数据体积越小,网络传输的数据量就越小,传输数据的速度也就越快。因为每个微服务都是独立进程,隔离部署,彼此通信意味着要跨进程、跨网络,那么网络传输的速度将直接关系到请求响应的耗时。
而除了上面两点,还有最最重要的一点,相信你也猜到了,就是通信协议、序列化协议的通用性和兼容性,该协议一定要能很好地兼容多语言。事实上在协议的选择上,与效率、性能、序列化后的体积相比,其通用性和兼容性的优先级会更高,因为它直接关系到服务调用的稳定性和可用率,对于服务的性能来说,服务的可靠性显然更加重要。我们更加看重协议在版本升级后的兼容性是否很好,是否支持更多的对象类型,是否是跨平台、跨语言的,是否有很多人已经用过并且踩过了很多的坑,其次我们才会去考虑性能、效率和空间开销。
当然还有一点要特别强调,除了协议的通用性和兼容性,协议的安全性也是非常重要的一个参考因素,甚至应该放在第一位去考虑。因为如果协议存在安全漏洞,那么线上的服务就很可能被入侵。
目前来讲,业界开发微服务时基本都选择 gRPC 框架,它的通讯协议基于 HTTP2,序列化协议使用 Protobuf。
到底什么是微服务
围绕业务功能构建的,服务关注单一业务,服务之间采用轻量级的通信机制,可以全自动独立部署,可以使用不同的编程语言和数据存储技术。微服务架构通过业务拆分实现服务组件化,通过组件组合快速开发系统,而业务单一的服务组件又可以独立部署,使得整个系统变得清晰灵活。
移动端或浏览器的流量通过网关转发给下游的微服务,然后我们看到每个微服务独占一个 DB,这也是微服务的特点之一,可以使整个系统的吞吐变得更好。像以前的巨石架构,整个系统都依赖同一个数据库,那么一旦数据库出现瓶颈,要怎么去扩容呢?过程会非常麻烦。但微服务独占一个 DB,如果出现瓶颈,只需要扩容这一个 DB 即可。此外,如果 DB 所在节点因为某些原因挂掉了,对于微服务而言也只会影响这一个服务。当然还有缓存,每个微服务也是独占一个缓存。
所以微服务里面有非常重要的几个点:
- 原子服务:服务关注单一的业务场景,它的 API 一定是围绕这一个业务构建的。
- 独立进程:每个服务启动后对应一个进程,可以独立部署。
- 隔离部署:服务与服务是隔离的,彼此互不影响。所以我们发现容器技术就非常适合微服务,对每个服务构建一个镜像,可以用于交付,如果要运行则基于镜像创建容器即可。并且容器启动时可以限制它使用的 CPU、内存,避免资源浪费,如果后续资源不够了就再启动一个容器,非常方便。由于每个微服务只关注单一业务,那么最终整个系统启动的容器也会非常多,因此还需要一个合理的容器编排工具,于是便有了 K8S。因此不难发现,微服务也间接推动了云原生技术的发展,两者是相辅相成的。
- 去中心化的服务治理:如果某个组件要被大量依赖,那么它就会成为热点,一旦挂掉,影响层面会非常大。而微服务之间是直连的,可以避免中心化依赖。当然像注册中心这种,它是没办法去中心化的,因为要被多个微服务共享用于服务发现。
微服务的不足
微服务虽然有很多优点,然世间没有银弹(万全之策),所以微服务自然也有它的缺点。
- 微服务应用是分布式系统,由此会带来固有的复杂性,开发者不得不使用 RPC 或消息传递,来实现进程间通信。此外必须要额外处理消息传递中速度过慢或服务不可用等局部失效问题,比如网络故障导致 RPC 调用过慢。
- 数据一致性会变得困难。在单体应用中,由于只有一个数据库,所以在一个 transaction 里面怼几张表都行,出错了会自动回滚,一致性可以得到很好的保证。但微服务的 DB 是独占的,所以会面临更新不同服务所使用的不同数据库,因此会涉及到分布式事务,从而对开发者提出了更高的要求和挑战,
- 测试一个基于微服务架构的应用也是很复杂的任务,一个微服务可能联动多个其它微服务,服务的数量在变多,所以测试也会变得困难。
- 服务模块之间的依赖,一个业务场景一般需要联动多个微服务才能将数据组装出来,那么功能的升级就可能会涉及多个服务模块的修改。
- 对运维基础设施的要求非常高,比如要统计每个服务归属哪个部门、负责哪个业务,谁是开发人员、谁是测试人员、谁是 SRE 人员等等。由于应用的数量在变多,那么还需要一个可靠的发布和上线的运行时平台。当然还有日志采集、指标汇总,比如之前单体应用的时候可能就几台机器,日志都打到本地磁盘上,直接 tail -f 查看即可,但微服务化之后显然不难这么做,而是需要统一的日志收集。再比如服务出现错误,单体应用的时候直接查看堆栈信息即可,但对于微服务而言,还要进行链路追踪,搞清楚整个服务的调用链,否则很难定位到底是哪一个服务出错了。因此以上种种,都对基础设施提出了很大的挑战。
马丁·福勒(Martin Fowler)在他的一篇文章中指出了生产率和复杂度之间的关系,在复杂度较小时,单体应用的生产率更高,微服务架构反而降低了生产率。但当复杂度达到了一定规模,无论采用单体应用还是微服务架构,都会降低系统的生产率。区别是:单体应用的生产率开始急剧下降,而微服务架构则能缓解生产率下降的程度。
图中的 x 轴是系统复杂度,y 轴是开发的生产力,绿色曲线表示单体应用,蓝色曲线表示微服务架构。这两者有一个交叉点,这个点之后单体应用的生产率急剧下降,微服务则是平缓下降,因此这个点就是单体应用和微服务切换的交叉点。但问题是这个点什么时候会出现、怎么衡量,马丁·福勒并没有说,所以只能具体问题具体分析,这也是一个技术领导者需要考虑的事情,一般来说可以从业务角度、代码质量、以及技术人员等角度进行参考。
组件服务化
如果想实现微服务,那么方式可以选择组件服务化。传统实现组件的方式是通过库(Library),库和应用一起运行在进程中,但这也意味着库的局部变化会导致整个应用的重新部署。而通过服务来实现组件,可以将应用拆散为一系列的服务运行在不同的进程中,那么单一服务的局部变化只需重新部署对应的服务进程即可。
图片同样来自马丁·福勒的一篇文章,左半部分是单体架构图,单体应用将所有功能放到一个进程中,如果想扩展,那么就将整个应用复制到多台服务器上;右半部分是微服务架构,该架构将功能分离,放到多个不同的进程中,并按需扩展。
那如果我们想实现一个微服务,比如用 Go 实现,需要哪些工具呢?
- Kit:首先需要一个 Kit,即微服务的基础库(框架),比如 go-micro,里面包含注册中心、日志抽象、应用生命周期的管理、依赖注入等基于功能。
- Service:包含围绕单一业务构建的业务代码,以及 Kit 依赖,和第三方依赖组成的业务微服务(比如服务 A 依赖 B 和 C,整体组成一个业务单元)。
- RPC + Message Queue:服务之间需要通信,所以要有一个合适的 RPC 框架以及消息队列,并且要首先考虑兼容性和可移植性。
本质上讲就是多个微服务组合(Compose)完成了一个完整的用户场景(Usecase),或者说业务场景。以前在单体应用中,我们老是说面向资源的 API,但在微服务中应该提供面向业务场景的 API。因为一个业务场景需要的数据不只来自一个服务,可能需要调用七八个微服务才能把数据组装出来,所以我们不能直接把底层的微服务暴露给客户端,而是要基于业务场景提前将数据组装好,然后暴露出统一的 API。关于这里面的一些细节,我们稍后再说。
组织架构
在单体应用中,每个团队按照职能来进行划分,比如 UI 团队、中间件团队、DBA 团队等等。
不同职能的人属于不同的团队,做项目的时候就从不同职能部门选出一些人来负责项目,但这样的组织架构有一个问题,就是跨职能部门沟通协调的成本过高。鲁迅曾说过:you build it,you run it,意思是这代码是你写的,那么你就要管理它整个的生命周期,因为没有人比你更熟悉代码应该如何测试,出了问题应该如何排查。我们不应该把代码开发完之后的验证工作交给另一个团队,因为它对代码并不熟悉,也不应该把代码的交付流程交给运维去做,因为如果出问题了,运维还是会打电话给你,让你去排查。
康威定律(康威法则,Conway's Law)是马尔文·康威 在 1967 年提出的:设计系统的架构受制于产生这些设计的组织的沟通结构。因此康威定律告诉我们,如果实施了微服务,那么组织架构的变动也要跟着做出相应的调整,这样才有可能适应微服务的发展。
这种传统的组织架构就跟烟囱一样,一层丢给另一层,显然它不适合微服务架构的特点。而微服务架构的特点是每个微服务是独立的,团队可以独立开发,独立测试,独立部署,服务是自治的。相应的团队组成人员也有产品,技术,测试,团队成员在自己内部就可以完整地进行微服务的各种功能的开发,形成闭环。
因此这就要打破原先传统的那种按职能划分的组织团队形式,把不同职能的人组织在一个团队内,组成一个跨职能的产品组织架构。这样才能将一个微服务的功能架构、设计、开发、测试、部署、上线运行,放在一个组织内部完成,从而形成完整的业务、开发、交付闭环。
比如测试团队可以提供一套完整的测试平台,方便开发人员去做 API 测试、单元测试、回归测试。再比如运维,之前是开发完成之后,交给运维部署、申请资源,而现在则是运维将资源申请、线上交付做成工单,具体任务则由开发来做,运维只负责审批。总之核心就是:开发团队对软件在生产环境的运行负全部责任,自己开发、自己测试、自己上线、自己看监控图定位问题。
去中心化
去中心化,分为三个部分:
- 数据去中心化:每个微服务独占一个 DB、独占一个 Cache,而不是所有服务都访问同一个中心数据库,否则一个慢查询可能拖累整个系统。
- 治理去中心化:避免将所有流量都汇集到统一的组件中,然后再由该组件做分发。
- 技术去中心化:每个服务面临的业务场景不同,可以针对性地选择合适的技术解决方案,不一定要收敛于一种,比如语言可以选择 Go、Python 等,可以多样化。但同时也要避免过度多样化,如果语言非常多,那么很难构建统一的 Kit 库,而且要开发很多套 SDK,总之要结合团队的实际情况进行取舍。
总之一定不要忘记:每个微服务独享自身的数据存储设施(包括缓存、数据库等),不像传统应用共享一个缓存和数据库,这样有利于服务的独立性,隔离相关干扰。
基础设置自动化
无自动化不微服务,自动化包括测试和部署。单一进程的传统应用被拆分为一系列的多进程服务后,意味着开发、调试、测试、部署的复杂度都会相应增大,因此必须要有合适的自动化基础设施来支持微服务架构模式,否则开发、运维成本将大大增加。
比如 CI/CD,可以使用 Gitlab + Gitlab Webhooks + Kubernetes 实现,其中 Gitlab 做代码托管,并且它也提供了相应的钩子,当收到提交之后,会自动往指定的地址发一个 POST 请求,而收到请求后,我们可以启动一个容器进行测试,非常方便。当然这种模式需要你已经部署了一个外部测试系统,在 GitLab 发生特定事件(如代码提交)时触发这个外部服务,就可以通过 Webhooks 指定服务地址。但如果你想要在 GitLab 中实现端到端的自动化流程,包括自动测试,而无需单独维护一套外部测试系统,那么使用 GitLab CI/CD 和 Gitlab Runner 是最直接的方式。
总之:如果你需要更大的灵活性并且已经有一个成熟的外部测试系统,Webhooks 可能是一个好的选择。如果你希望更容易地在 GitLab 内部管理整个自动化流程,那么使用 GitLab CI/CD 和 Runner 会是更加直接和高效的方法。
可用性 & 兼容性设计
有一个著名的思想叫 Design For Failure,即面向失败进行设计。微服务架构采用进程间通信,引入了额外的复杂性和需要处理的新问题,如网络延迟、消息格式、负载均衡和容错,忽略其中任何一点都属于对分布式计算的误解。举个例子,在单体架构中 for 循环 50 次是非常正常的现象,比如 50 次普通的函数调用。但微服务之间是 RPC 调用,需要跨网络,就肯定不能循环 50 次了,否则网络扇出会非常大。因此微服务要提供一个粗粒度的接口,其它微服务在调用的时候,要能够做到多条数据、一波返回。
所以在微服务架构中,我们要做好可用性,而保证可用性的手段可以通过以下方式来解决。
- 隔离
- 超时控制
- 负载保护
- 限流
- 降级
- 重试
- 负载均衡
这些我们后面会详细介绍,当然除了可用性之外还要保证兼容性。一旦采用了微服务架构模式,那么在服务需要变更时要特别小心,服务提供者的变更可能会引发服务消费者的兼容性和破坏,因此时刻谨记保持服务契约(接口)的兼容性。
在 API 设计的时候可以参考伯斯塔尔法则,发送时要保守,接收时要开放。发送时保守意味着最小化地传递必要的消息,冗余数据不要传递,接收时开放意味着最大限度地容忍冗余数据,保证兼容性。
微服务设计
微服务的发展也不是一蹴而就的,它有自己的演进过程。如果将单体架构拆分成微服务,一般会按照垂直功能进行拆分,可以暴露出一批微服务,最初的版本就像下面这样。
像用户服务、推荐服务、广告服务、订单服务等等,它们明显不是同一种应用,因此可以单独拆出来,形成单独的服务。然后客户端或浏览器通过负载均衡器,将请求转发到下游的微服务,这就是微服务最初的设计。但不难发现,当前这种缺乏统一出口的设计存在非常多的缺陷。
1)客户端到微服务直接通信,强耦合。
外网的流量经过 ELB 直接打到微服务上面,这种情况下很难做到微服务的升级和重构。随着业务的迭代,你的服务也要升级,但升级的时候不能破坏以前的接口,这就给开发人员带来了很大的麻烦。
浏览器还好,如果是移动端的话,你的接口外网在用,有人请求的时候就要保证他的服务质量。否则的话,你只能强迫用户升级 APP,但这会让用户的体验变差。一个好的 APP,应该是不管什么版本都能正常使用,不能因为服务更新,就导致老版本的 APP 无法使用。所以如果客户端到微服务之间的通信强耦合,那么在升级服务的时候就要小心了,要保证能够兼容老版本的 APP,显然这会带来很大的麻烦,比如接口都已经 v4 版本了,但还要考虑 v1 版本的可用性。
2)需要多次请求,客户端聚合数据,工作量大,延迟高。
当我们打开网站的首页时,比如京东,它会展示很多的内容,因此会访问用户服务、推荐服务、广告服务、商品服务等等,它需要调用下游多个子服务才能将页面需要的数据组装出来。如果客户端和微服务直接通信,那么意味着客户端要和这些微服务的开发人员依次对接,显然沟通成本会非常高。而且既然客户端要请求每个服务的接口,那么数据组装的工作是不是也要由客户端来做呢,因为要把不同服务返回的数据聚合在一起。
所以数据组装的工作如果交给客户端来做,那么肯定会影响交付和发版的速度。并且用户在使用的时候体验也不好,客户端并行访问多个服务,某个服务如果失败了,还要做容错,然后再组装数据,这会带来很高的延迟。因为客户端是面向用户的,一定要讲究效率,因此我们提出了前轻后重这个概念,数据组装工作应该在服务端全部做掉,客户端拿到数据之后直接渲染即可。
3)协议不利于统一,各个部门之间有差异,需要端来兼容。
不同的微服务可能是不同部门开发的,每个部门的协议也可能不一样,对于客户端来说不利于统一,需要做兼容。
4)面向端的 API 适配,耦合到了服务内部。
每个微服务直接对外的话,意味着它们都要对不同的端(比如移动端、Web 端)做 API 的适配和兼容。比如手机和平板要做适配、IOS 和安卓要做适配、并且大小尺寸等等也要做适配,因为它们的交互是不同的。对于微服务来说应该只负责返回数据,但目前这种设计将端的 API 适配耦合到了每一个微服务内部。
5)统一逻辑无法收敛,比如安全认证、限流。
安全就如同水池一样,有一处漏水就毫无安全可言,只有对外暴露的 API 的表面积越小,系统才越安全。像安全认证、限流这种工作应该收敛到一个地方,交给某个组件统一来做,而不是每个微服务把这种工作都做一遍,否则一个接口有问题,整个系统就全崩了。这个应该很好理解,像我们平时写代码一样,如果某段逻辑要被大量重复使用,我们也会选择将它抽象成一个函数,而不是每个地方都将相同的逻辑重复一遍。
所以像安全认证、鉴权、限流等跨横切面的逻辑,我们应该上沉到某个组件统一去做。
总而言之,最初版本的微服务设计存在很大的缺陷,于是我们便引入了 BFF 层。
BFF
BFF 可以认为是一种适配服务,将后端的微服务进行适配(主要包括聚合裁剪和格式适配等逻辑),向无线端设备暴露友好统一的 API,方便无线端设备介入访问后端服务。说白了就是做数据编排的,面向不同的用户场景提供一个统一的 API,以后客户端只需要和 BFF 团队对接即可,BFF 来统一调用底层的微服务(包括数据组装、容错等等)。所以 BFF 的全称是 Backend For Frontend,也就是面向前端的后端,还是很形象的。
通过新增 BFF 用于统一的服务出口,在服务内部进行大量的 dataset join,按照业务场景来设计粗粒度的 API,从而给后续服务的演进带来很多优势。
- 轻量交互:协议精简、聚合。
- 差异服务:数据裁剪以及聚合,针对终端定制化 API。
- 动态升级:原有系统兼容升级,会更新服务而非协议。比如底层微服务升级了,但它不会影响客户端,因为和客户端对接的是 BFF 而不是微服务。对于微服务而言,只需要保证和 BFF 之间是兼容的即可。所以引入了 BFF 之后,微服务不再对外,而是对内暴露面向资源的 API,然后 BFF 将这些 API 返回的数据组装好之后,再对外提供面向业务场景的 API。
- 沟通效率提升:BFF 团队一般都是从不同的微服务团队中捞一批人,而他们对自己的微服务肯定是非常熟悉的,然后再由 BFF 这一小波人去和客户端对接,从而减少沟通成本、提升效率。
从目前来看似乎还不错,但依旧存在问题,所有的流量都要经过 BFF,那么 BFF 就存在单点故障(Single Point of Failure),严重的代码缺陷或流量洪峰可能引发系统宕机,当然这一点可以通过集群部署的方式来解决。但问题是,如果只有一个 BFF 团队,那么它要负责所有数据的组装,会导致业务集成的复杂度变得非常高。根据康威法则,单块的 BFF 和多团队之间就会出现不匹配问题,团队之间沟通协调成本高,交付效率低下。
于是很容易想到的一个解决方案就是,继续拆,将一个 BFF 按照功能拆分出多个 BFF。并且为了避免单点故障,每个 BFF 都多节点部署。
比如拆分之后,账号相关的对应一个 BFF,商品相关的对应一个 BFF,其它杂七杂八的等等对应一个 BFF,当然具体怎么拆分应该取决于你当前的业务。总之拆分之后,对接的人会变少、也更精确。比如一个几百人的 BFF 团队,客户端要对接的时候都不知道该找谁,但将 BFF 拆分之后,就可以让不同的客户端和不同的 BFF 团队对接,从而提升效率。
API Gateway
虽然引入了 BFF 之后解决了很多的问题,但像跨横切面逻辑,比如安全认证、日志监控、限流熔断等等,随着时间的推移,代码也会变得越来越复杂,导致技术债越堆越多。因为 BFF 是专门做数据组装的,它的业务集成度很高,不应该和这些跨横切面逻辑(通俗一点说就是通用逻辑)组合在一起。否则这些功能升级的时候,会导致很多的 BFF 也要一起更新,这显然是不合理的,因为 BFF 就是面向业务场景做数据组装的。
于是就引入了网关(API Gateway),跨横切面的功能可以全部上沉到网关(无状态、无业务逻辑),将业务集成度高的 BFF 层和通用功能服务层进行分层处理。所以网关承担了重要的角色,它是解耦拆分和后续升级迁移的利器。在网关的配合下,单块 BFF 实现了解耦拆分,各业务团队可以独立开发和交互自己的微服务,研发效率大大提升。另外,把跨横切面逻辑从 BFF 上剥离开之后,BFF 的开发人员可以更加专注于业务逻辑交互,实现了架构上的关注分离(Separation of Concerns)。
因此我们的业务流量实际为:移动端 -> 4/7 层负载均衡 -> API Gateway -> BFF -> Mircoservice。
微服务应该如何拆分
采用微服务架构时遇到的第一个问题就是如何划分服务的边界,在实际项目中会采用如下方式。
- 单一职责原则:每个微服务应该只负责一个特定的业务功能,单一职责原则有助于保持服务的聚焦和简单,便于独立开发和维护。如果一个服务承担了过多的职责,它可能会变得臃肿和复杂,从而影响到整个系统的健壮性和可维护性。
- 业务领域驱动划分:根据应用程序的业务需求和领域知识来对服务进行划分,这通常需要与业务专家和开发人员紧密合作,共同识别和定义各个领域的边界。通过领域驱动的划分,可以确保各个微服务的业务逻辑紧密联系在一起,便于维护和拓展。
- 最小可行服务:在进行微服务划分时,应该尽量保持服务的粒度足够小,以降低系统复杂性和提高灵活性。但同时也需要注意避免服务过于细碎,导致系统管理和协调成本过高。为了实现这个平衡,可以采用最小可行服务原则,即每个服务都应尽可能小,但仍能独立完成一个完整的业务功能。
- 高内聚低耦合:微服务划分应该追求高内聚低耦合的原则,高内聚意味着服务内部的组件和功能之间有紧密的关联,而低耦合则是指各个服务之间的依赖关系尽可能简单和少。通过高内聚低耦合的设计,可以提高系统的稳定性、可扩展性和可维护性。
- 数据自治原则:每个微服务应该对其所使用的数据有完全的控制权,包括数据存储、查询和更新等操作。通过数据自治原则,可以避免服务间的数据耦合,降低系统的复杂性和风险。
- 技术多样性:微服务架构允许使用不同的技术栈来实现不同的服务,这有助于充分利用各种技术的优势,提高系统性能和可扩展性。但同时也需要注意控制技术多样性带来的管理和维护成本。
微服务的安全应该如何保证
对于外网的请求来说,我们通常会在 API Gateway 进行统一的认证拦截,这种跨横切面的逻辑都是由网关来做的。一旦认证成功,会将 Token 注入到 Header 中,然后通过 RPC 传递给 BFF,当然为了安全起见,网关一般会将原来的 Token 踢掉、注入一个新的 Token。BFF 在拿到 Token 之后进行解析,得到用户信息,并注入到 Context 中,传给下游的一系列微服务。
而对于内部服务之间的调用,一般也要进行身份认证和授权,尽管这些服务都部署在内网,不对外暴露。具体来说,微服务间的调用有以下几种模式:
- Full Trust:两个微服务之间可以直接调用,没有认证和鉴权。
- Half Trust:服务知道调用者是谁,但不做鉴权,也是谁都可以调用。
- Zero Trust:零信任,两个微服务既要做身份认证、也要做细粒度接口的判定,同时还要做身份的加解密。
一般为了安全起见,会选择 Zero Trust,实现方式可以通过颁发 Token 或者证书,Token 实现起来简单,但安全性会比证书低一些,基本上大部分公司选择的都是 Token。
gRPC
RPC 指的是远程过程调用(Remote Procedure Call),简单理解就是一个节点请求另一个节点提供的服务。假设有两台服务器 A 和 B,一个部署在 A 服务器上的应用,想要调用 B 服务器上某个应用提供的函数 / 方法。但由于不在同一个内存空间,所以不能直接调用,而是需要通过网络来表达调用的语义和传达调用的数据。
所以你会感觉,RPC 仿佛就是为微服务而生的。
显然与 RPC 对应的则是本地过程调用,我们本地调用一个函数便是最常见的本地过程调用。但是很明显,将本地过程调用变成远程过程调用会面临各种各样的问题。我们举个例子:
func Add(a, b int) int {
return a + b
}
func main() {
sum := Add(1, 2)
fmt.Println(sum)
}
以上便是一个本地过程调用,非常简单,但如果是远程过程调用就不一样了。假设上面的 Add 函数部署在另一个节点上,那么在本地要如何去调用呢?显然这么做的话,我们需要面临如下问题:
1)Call 的 ID 映射
远程服务中肯定不止一个函数,那我们要怎么告诉远程机器,调用的是 Add 函数,而不是 Sub 或其它的函数呢?首先在本地调用中,直接通过函数指针即可,编译器或解释器会自动帮我们找到指针指向的函数。但在远程调用中则不行,因为它们不在同一个节点,自然更不在同一进程,而两个进程的地址空间是不一样的。所以在 RPC 中,每个函数必须都有一个唯一的 ID,客户端在远程过程调用时,必须要附上这个 ID。然后客户端和服务端还需要各自维护一个 "函数和 Call ID 之间的映射关系",相同的函数对应的 Call ID 必须一致。当客户端需要进程远程调用时,根据映射关系找到函数对应的 Call ID,传递给服务端;然后服务端再根据 Call ID 找到要调用的函数,并进行调用。
2)序列化和反序列化
这个相信你很熟悉,在做 Web 开发的时候会经常用到。比如 Python 编写的 Web 服务返回一个字典,那么它要如何变成 Go 的 map 呢?显然是先将 Python 的字典序列化成 JSON,然后 Go 再将 JSON 反序列化成 map。而 JSON 便是两者之间的媒介,它是一种数据格式,也是一种协议。这在 RPC 中也是同理,因为是远程调用,那么必然要涉及的数据的传输。那么问题来了,我们调用的时候肯定是需要传递参数的,那这些参数要怎么传递呢?而且客户端和服务端使用的语言也可以不一样,比如客户端使用 Python,服务端使用 C++、Java 等等,而不同语言对应的数据结构不同,例如我们不可能在 C++、Java 里面操作 Python 中的字典、类实例等等。
所以还是协议,这是显而易见、最直接的解决办法。我们在传递参数的时候可以将内存中的对象序列化成一个可以在网络中传输的二进制对象,这个对象不是某个语言独有的,而是大家都认识。然后传输之后,服务端再将这个对象反序列化成对应语言的数据结构,同理服务端返回内容给客户端也是相同的过程。
所以我们还是想到了 HTTP + JSON,因为它们用的太广泛了,客户端发送 HTTP 请求,通过 JSON 传递参数;然后服务端处理来自客户端的请求,并将传递的 JSON 反序列化成对应的数据结构,并执行相应的逻辑;执行完毕之后,再将返回的结果也序列化成 JSON 交给客户端,客户端再将其反序列化。显然这是一个非常通用的流程,而实现了 RPC 的框架(gRPC)也是同样的套路,只不过它没有采用 HTTP + JSON 的方式,因为这种协议是非常松散的,至于 gRPC 到底用的是什么协议我们一会儿说。
3)网络传输
因为是远程调用,那么必然涉及到网络的传输,因此就需要有一个网络传输层。网络传输层需要把 Call ID 和序列化的参数字节流传递给服务端,服务端逻辑执行完毕之后再将结果序列化并返回给客户端。只要能完成这个过程,那么都可以作为传输层使用。因此 RPC 所使用的协议是可以有多种的,只要能完成传输即可,尽管大部分 RPC 框架使用的都是 TCP 协议,但其实 UDP 也可以,而 gRPC 则直接使用了 HTTP2。
以上我们就简单地介绍了一下什么是 RPC,说白了它是一种技术手段、或者说概念,如果想实现 RPC 通讯,那么需要使用专门的 RPC 框架,比如 Thrift、gRPC 等等。所以任何一个 RPC 框架都可以用在微服务当中,但目前主流的选择都是 gRPC。
gRPC 是一个高性能、通用的开源 RPC 框架,由 Google 为了面向移动应用开发,基于 HTTP/2 协议、Protobuf(Protocol Buffers)数据序列化协议所设计,适用于微服务开发,支持众多的开发语言,基本上主流语言都支持(都有对应的库)。此外 gRPC 还提供了一种简单的方法来精确地定义服务,以及为 IOS、Android 和后台支持服务自动生成可靠性很强的客户端功能库。客户端充分利用高级流和链接功能,从而有助于节省带宽、降低 TCP 连接次数、节省 CPU 使用、增加电池寿命等等。
关于 gRPC 的具体使用,可以私下去了解,这里就不详细介绍了。
服务注册与服务发现
服务注册:将提供某个功能的服务信息(通常是这个服务的 IP 和端口)注册到一个公共的组件上去,比如 Consul、Etcd、ZooKeeper。服务发现:新注册的服务模块能够及时地被其它调用者发现,不管是服务新增还是服务删减都能实现自动发现。你可以理解为:
// 服务注册
NameServer->register(newServer);
// 服务发现
NameServer->getAllServer();
在传统的数据请求架构中,其实是没有什么服务注册和服务发现的,因为请求模型足够简单。
各个客户端请求 Server 服务器,所有的业务逻辑都在这个 Server 内完成,这是常见的网络请求模型架构。对于小型服务来说,这个架构是最合适的,因为它稳定且简单,Server 服务器的更新和维护也很简单。
后期,随着我们的用户数渐渐变多,单台服务器的压力扛不住的时候,就要用到负载均衡技术,增加多台服务器来抗压,后端的数据库也可以用主从的方式来提升并发量。
然而这个时候,依旧没有服务注册和服务发现的影子,因为这个架构依然足够简单和清晰。只要不断地增加后端 Server 服务器的数量,系统的整体稳定性就能得到保证,各个 Server 服务器的更新和维护仍然很简单。那啥时候才需要用到服务注册和服务发现呢?答案是微服务时代。
在微服务时代,所有的服务尽量都被拆分成最小的粒度,原先所有的服务都混在一起,现在会按照功能拆分成 N 个微服务,这样做的好处是深度解耦,一个服务只负责自己的事情就好,能够实现快速的迭代更新。坏处就是服务的管理和控制变得异常复杂和繁琐,人工维护难度变大。比如一个服务被拆分成了 User服务、Order服务、Goods服务、Search服务等等,每个服务对应 N 个实例,服务之间还会相互关联和调用,因此这种错综复杂的网络结构就使得服务的维护变得比之前困难了许多。
在不用服务注册之前,我们可以想象一下,要怎么去维护这种复杂的关系网络呢?答案就是写死。将其它模块的 IP 和端口写死在自己的配置文件里,甚至写死在代码里,每次要新增或移除一个服务的实例的时候,就去通知其它所有相关联的服务去修改。随之而来的就是各个项目的配置文件的反复更新、每隔一段时间大规模的 IP 修改和机器裁撤,非常的痛苦。
在微服务时代,一个服务从创建到上线会变得异常频繁,每一个接口依赖的服务,可能随时会动态改变,靠手工去写配置和变更配置,对于运维和开发同学来说简直就是灾难。因此为了解决这个问题,便有了服务注册和服务发现。
每一个服务在启动运行的时候,都会前往注册中心进行注册,比如当前有 User服务、Order 服务。
// 给 User 服务申请一个独有的专属名字
UserNameServer = NameServer->apply('User');
// User 服务对应的容器启动后,要进行注册
// 因为可能有多个容器,每个容器都要注册
UserServer1 = {ip: 192.178.1.1, port: 3445};
UserServer2 = {ip: 192.178.1.2, port: 3445};
UserServer3 = {ip: 192.178.1.3, port: 3445};
UserServer4 = {ip: 192.178.1.4, port: 3445};
UserServer5 = {ip: 192.178.1.5, port: 3445};
UserNameServer->register(UserServer1);
UserNameServer->register(UserServer2);
UserNameServer->register(UserServer3);
UserNameServer->register(UserServer4);
UserNameServer->register(UserServer5);
// 给 Order 服务申请一个独有的专属名字
OrderNameServer = NameServer->apply('Order');
// 注册 Order 服务
OrderServer1 = {ip: 192.178.1.1, port: 3446}
OrderServer2 = {ip: 192.178.1.2, port: 3446}
OrderNameServer->register(OrderServer1);
OrderNameServer->register(OrderServer2);
这样,每个服务实例在启动时,它的网络地址会被写到注册表上;当服务实例终止时,再从注册表中删除;服务实例的注册表会通过心跳机制动态刷新。
服务注册之后,就是服务发现了。假如 Order 服务想要获取 User 服务相关的信息,那么会向注册中心发送请求,然后注册中心返回 User 服务相关的信息。
// 服务发现,获取 User 服务的列表
list = NameServer->getAllServer('User');
// list 的内容
[
{
"ip": "192.178.1.1",
"port": 3445
},
{
"ip": "192.178.1.2",
"port": 3445
},
{
"ip": "192.178.1.3",
"port": 3445
},
{
"ip": "192.178.1.4",
"port": 3445
},
{
"ip": "192.178.1.5",
"port": 3445
}
]
通过服务发现,就获得了 User 服务所有的 IP 列表,然后再通过负载均衡算法选择一个合适的服务实例进行访问。当然也有些注册中心已经提供了 DNS 解析功能和负载均衡功能,它会直接返回一个可用的 IP,客户端直接访问即可,不用自己再去做选择。这样一来我们就可以通过服务注册和服务发现的方式,维护各个服务 IP 列表的更新,各个服务只需要向注册中心去获取某个服务的 IP 就可以了,不用再写死 IP,整个服务的维护也变得轻松了很多,彻底解放了双手。
健康检查(Health Check)
可能你会觉得,引入一个注册中心也挺费劲的,难道仅仅是为了解决新增服务、动态获取 IP 的问题吗?答案当然不是,服务注册和服务发现不仅仅解决了服务调用写死 IP 以及杂乱无章的管理状态,更重要的一点是它还管理了服务器的存活状态,也就是健康检查。很多注册中心都提供了健康检查功能,注册服务的这一组实例,如果某一个出现宕机或者服务死掉的时候,注册中心就会标记这个实例的状态为故障,或者干脆踢掉这台机器,这样一来就实现了自动监控和管理。
健康检查有多种实现方式,比如每隔几秒就发一次心跳信息,如果返回的 HTTP 状态不是 200,那么就标记这台实例不可用。
// 服务发现,获取 User 服务的列表
list = NameServer->getAllServer('User');
// list 的内容
[
{
"ip": "192.178.1.1",
"port": 3445,
"status": "success"
},
{
"ip": "192.178.1.2",
"port": 3445,
"status": "success"
},
{
"ip": "192.178.1.3",
"port": 3445,
"status": "error" // 故障,服务不可用
},
{
"ip": "192.178.1.4",
"port": 3445,
"status": "success"
},
{
"ip": "192.178.1.5",
"port": 3445,
"status": "success"
}
]
我们通过 status 字段是不是 "success" 即可判断服务是否是可用的,并且有的注册中心会提供 DNS 解析功能,直接把有问题的机器给去掉,服务发现后返回的实例列表都是正常可用的。同时,当服务不可用时,注册中心也会提供发送邮件或者消息的功能,及时地提醒你服务出现故障。这样我们就通过健康检查功能,来及时地规避问题,降低影响。
需要注意的是,当出现故障的服务被修复并重新启动后,健康检查会通过,然后这台机器会重新被标记为健康,这样服务发现就又可以发现这台机器了,整个服务注册和服务发现实现了闭环。
注册中心的选择
以上我们就解释了服务注册与服务发现的整个过程,它们依赖一个注册中心,所以注册中心的选择就变得至关重要了。目前主流的选择有 ZooKeeper、Consul、Etcd、Eureka 等等,我们应该选择哪一种呢?
首先 ZooKeeper 其实并不适合做注册中心,因为它存在性能问题,在海量服务注册和发现的场景下,性能表现较差。其次它本身是一个分布式协调组件,属于 CP 模型,保证数据的强一致性,并不是为注册中心而生。因为对于服务发现来说,可以弱一致,只要保证最终一致性即可,比如服务提供方新上线一个节点时,不一定要被立刻感知到,这中间允许存在短暂的延迟。
因此注册中心需要的是一个 AP 系统,比如 Eureka,它是 Spring Cloud 默认使用的组件。当然 CP 模型也可以,像 Consul 在跨 ID、多数据中心等场景下发挥了很大的优势,因此 Consul 也是一个不错的选择。
多集群
按照服务的重要性,我们一般会划分为四个等级。
- L0:公司级核心服务,如 APP 推荐、视频播放、支付平台等。
- L1:部门级核心服务,一般是 L0 服务中依赖的主要服务、核心的二类服务,如视频播放页的一键三连功能、动态、搜索等。
- L2:用户可直接使用的其它服务,如播单、分享、专栏、答题等。
- L3:其它后台类服务,或对用户体验无影响的服务。
对于 L0 服务来说,比如账号服务,一旦挂掉,影响层面会非常大,所以我们需要从以下几个角度来考虑服务稳定性。
- 从节点角度考虑:可以通过多个节点保证可用性,我们通常使用 N+2 的方式来冗余节点,形成一套集群。
- 从集群角度考虑:如果整个集群故障了怎么办?因此可以考虑部署多套集群,如果当前集群故障了,那么切换到下一个集群。
- 从机房角度考虑:多套集群都位于同一个机房内,那么如果因为地震等原因,导致整个机房不可用该怎么办?因此可以考虑多机房,部署在不同的地区,也就是异地多活。
不难看出,如果想保证绝对的安全,那么难度会越来越高。一个节点容易故障,那么就部署多个节点组成集群;集群也有可能出故障,那么就冗余几套一模一样的集群;如果冗余的多套集群位于同一个机房内,那么依旧有可能出故障,于是可以多机房。整个过程的难度越来越大,资金投入也越来越高。关于多机房我们以后有机会再聊,目前就先聚焦一个机房内,看看如果一个集群出故障了,我们应该如何应对和解决。
关于多集群,我们可以利用 PaaS 平台,给某个 appid 服务(每个服务都有唯一的 appid)建立多套集群,物理上相当于多套资源,逻辑上维护 Cluster 的概念。不同集群的服务启动后,从环境变量里可以获取当前服务的 Cluster,在服务发现注册的时候带入这些元信息。
注意:建立多集群之后,使用的资源一定是隔离的,不会出现多个集群访问同一个 Cache,多套冗余的集群对应多套独占的缓存,从而带来更好的性能和冗余能力。那么问题来了,假设我们建立了三个集群,那么是只用其中一个(剩余两个只用作故障切换),还是三个一起用呢?
从资源利用的角度来讲,肯定是三个集群一起用,比如我们有直播业务、视频业务、游戏业务,它们各自对应一套集群,假设集群的名称为 live、video、game。那么直播业务的流量会打到 cluster=live 的集群,视频业务的流量会打到 cluster=video 的集群,游戏业务的流量会打到 cluster=game 的集群,正常情况下我们也是这么设计的。
但问题是这种设计依旧存在问题,如果 cluster=live 的集群挂掉了怎么办?很明显要进行流量切换,将流量转发到视频和游戏业务对应的集群,但这个过程会很麻烦,因为在配置文件中指定了要使用 cluster=live 的集群。或者还有一种办法,将切换的功能放到注册中心去做,但对注册中心的侵入会强一些,因为要调度流量必须要引入一些额外的逻辑。
不过即便实现了流量切换,其实还是存在问题,就是缓存的命中率下降,因为每个集群都是独占缓存的。比如玩直播的人可能不玩游戏,那么当流量切换之后,live 集群的缓存里的数据,在 game 集群的缓存里面都没有,那么请求就会透传到数据库,导致数据库压力非常大。那么应该怎么办呢?既然不同业务的数据正交,再加上流量切换又非常麻烦,那么不妨让每个业务连接所有的集群。不管是直播、视频还是游戏,它们都可以访问 live、video、game 集群,经过负载均衡,每个集群的缓存在理论上预测的应该都很不错。假设这个时候 live 集群挂掉了,那么此时唯一要做的就是让注册中心将 live 集群的所有节点全部踢下线,那么流量就会倾斜到剩余两个集群,但 Cache 已经预热过了。
所以物理上虽然划分了多套互相隔离的资源池,但在逻辑上它们统一为一套集群,即 gRPC 客户端默认会忽略服务发现中的 Cluster 信息,按照全部节点,全部连接。
多租户
微服务架构中允许多系统共存,也就是一个服务可以有多个版本,这样就可以让流量路由到不同的版本,我们把这种允许多系统共存的模式称为多租户。多租户系统允许多个租户共享同一套应用程序和基础设施,每个租户都拥有自己的数据和隐私保护。其中租户可以是测试,金丝雀发布,影子系统(shadow systems),甚至服务层或者产品线,使用租户能够保证代码的隔离性并且能够基于流量租户做路由决策。
我们举例说明,比如影子系统,我们希望能够在线上做全链路压测,但同时又不影响线上环境,一套完整的测试环境被虚拟出来之后,压测的是线上的资源,但不影响在线的业务,这就叫影子系统。甚至我们希望能够在线上演练完整的功能,也不影响在线业务,就是流量只能由测试人员,线上的用户不受影响,那么如何能做到代码的隔离以及基于流量租户做路由决策呢?
以上图为例,正常情况下流量从 A 到 B,从 B 再到 C 和 D,但现在我们对服务 B 进行了更新(记作 B'),那么怎么测试 B' 和 A、C、D 之间的交互是否正常呢?可能有人想到灰度发布(金丝雀发布)和蓝绿发布。
灰度发布是指在生产环境中逐步推出新版本服务,只有一小部分用户流量使用该版本,并根据反馈逐步扩大规模,最终完全替换旧版本。灰度发布允许快速检测新版本与旧版本之间是否存在兼容性问题、性能问题或其它问题,减轻了在实施全新发布时可能遭受的损失。比如 20 个节点灰掉其中一个,但如果出问题,依旧会影响二十分之一的流量,所以灰度发布成本还是蛮大的,影响 1/N 的用户,其中 N 为节点数量。
蓝绿发布是一种与灰度发布类似的策略,但是在新版本服务推出之前,先在一个完全独立的环境中进行测试并准备好一个完整但不对外公开的备份环境。当新版本准备好并经过测试之后,将新环境与旧环境进行切换,让新环境提供服务。如果在切换后出现问题,可以立即将流量切换回旧环境中,从而减少影响范围和恢复时间。虽然看起来也很好,但蓝绿发布仍存在相应的问题。
- 如果有多个服务要同时上线,难道要搭多套测试环境吗,如果不同服务使用了同一套测试环境,那么服务更新后的代码有可能会被其它服务冲掉。
- 搭建多套测试环境也比较耗费资源,会带来额外的硬件成本。
- 最关键的是,测试环境只能测试功能是否正常,它无法模拟线上真实的流量情况。
因此我们可以使用一个名叫染色发布的策略,把待测试的服务 B' 放在一个隔离的沙箱环境中启动,并且在沙箱环境中可以访问集成环境 C 和 D。我们把测试流量路由到服务 B',同时保证生产流量会正常流入到集成服务 B,也就是说此时线上会同时存在服务 B 和服务 B',它们和 A、C、D 之间的交互是一样的,但服务 B' 只处理测试流量。
那么问题来了,我们怎么控制流量是经过 B 还是经过 B' 呢?可以参考多集群,服务在启动后可以通过设置环境变量来标识自己隶属哪一套集群,然后注册到注册中心里面。同样的,染色发布也可以设置一个环境变量,比如 COLOR=RED,然后注册到注册中心里面。而 RPC 框架在往注册中心捞服务节点的 IP 和端口的时候,可以通过元数据拿到标识集群和染色相关的字段,比如 CLUSTER 和 COLOR。
然后基于 COLOR 是否为空可以维护两套连接池,正常的流量路由到 COLOR 等于空的节点,也就是没有染色的节点,测试流量路由到 COLOR 不为空的节点。这么做就带来了两个好处:
- 流量路由:能够基于流入栈中的流量类型做路由;
- 隔离性:能够可靠地隔离测试和生产环境中的资源,保证对关键业务的微服务没有副作用;
所以整个过程就是给入站请求绑定上下文(HTTP Header),然后进程内部使用 Context 传递,跨服务使用 Metadata 传递,在这个架构中每一个基础组件都能理解租户信息,并且能够基于租户路由和隔离流量。比如流量进来先经过 A,并带上标签 COLOR=RED,然后 A 通过负载均衡找到了 B'。同理 B' 也要通过负载均衡找到带染色标签的 C 和 D,但是发现找不到,于是会做降级,访问正常的 C 和 D。
以上是测试单个服务,如果是全链路压测,效果也是一样的。通过染色作区分,从而隔离线上流量和测试流量。
当然啦,在我们的平台中也应允许对运行不同的微服务有更多的控制,比如指标和日志。在微服务架构中,典型的基础组件是日志、指标、存储、消息队列、缓存以及配置,基于租户信息隔离数据时需要分别处理基础组件。因为测试服务时,我们不可能再单独部署一套数据库、缓存,那样成本太高,而是会利用现有的基础组件,通过将染色信息打进去来区分是线上流量还是测试流量,从而更好地定位问题。比如 Redis,线上流量进入 1 号库,测试流量进入 10 号库,测试完了之后再将 10 号库 FLUSH 掉;数据库也是同理,单独创建一个 DataBase,其中 Schema 和表结构全部一样,但只用于测试,测试完之后再 DROP 掉。
所以多租户架构本质上可以描述为:利用服务发现注册租户信息,注册成特定的租户,发请求的时候通过 HTTP Header 携带一个染色标签进入网关,然后挂载到 Context 或者 Metadata 里面,然后 RPC 框架通过负载均衡将流量路由到指定的节点。因此通过多租户 + 流量路由 + 染色发布,能够保证一套 K8S 平台可以虚拟出无数多套测试环境。
小结
以上我们就简单介绍了微服务的起源、微服务解决了哪些问题,以及我们应该如何设计一个微服务。但微服务在实际使用时还会面临诸多问题,我们还需要使用一些额外手段来保证服务正常,比如隔离、超时重试、降级、熔断、限流等等,这些后续我们有机会再细说。
本文参考自:
如果觉得文章对您有所帮助,可以请囊中羞涩的作者喝杯柠檬水,万分感谢,愿每一个来到这里的人都生活愉快,幸福美满。
微信赞赏
支付宝赞赏
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek “源神”启动!「GitHub 热点速览」
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· C# 集成 DeepSeek 模型实现 AI 私有化(本地部署与 API 调用教程)
· DeepSeek R1 简明指南:架构、训练、本地部署及硬件要求
· NetPad:一个.NET开源、跨平台的C#编辑器