分类: 未分类

  • ES+Redis+MySQL,这个高可用架构设计太顶了!

    快乐分享,Java干货及时送达👇

    来源:dbaplus
    • 一、背景
    • 二、ES高可用方案
    • 三、会员Redis缓存方案
    • 四、高可用会员主库方案
    • 五、异常会员关系治理
    • 六、展望:更精细化的流控和降级策略

    一、背景

    会员系统是一种基础系统,跟公司所有业务线的下单主流程密切相关。如果会员系统出故障,会导致用户无法下单,影响范围是全公司所有业务线。所以,会员系统必须保证高性能、高可用,提供稳定、高效的基础服务。

    随着同程和艺龙两家公司的合并,越来越多的系统需要打通同程APP、艺龙APP、同程微信小程序、艺龙微信小程序等多平台会员体系。例如微信小程序的交叉营销,用户买了一张火车票,此时想给他发酒店红包,这就需要查询该用户的统一会员关系。因为火车票用的是同程会员体系,酒店用的是艺龙会员体系,只有查到对应的艺龙会员卡号后,才能将红包挂载到该会员账号。除了上述讲的交叉营销,还有许多场景需要查询统一会员关系,例如订单中心、会员等级、里程、红包、常旅、实名,以及各类营销活动等等。所以,会员系统的请求量越来越大,并发量越来越高,今年五一小长假的秒并发tps甚至超过2万多。在如此大流量的冲击下,会员系统是如何做到高性能和高可用的呢?这就是本文着重要讲述的内容。

    二、ES高可用方案

    1. ES双中心主备集群架构

    同程和艺龙两家公司融合后,全平台所有体系的会员总量是十多亿。在这么大的数据体量下,业务线的查询维度也比较复杂。有的业务线基于手机号,有的基于微信unionid,也有的基于艺龙卡号等查询会员信息。这么大的数据量,又有这么多的查询维度,基于此,我们选择ES用来存储统一会员关系。ES集群在整个会员系统架构中非常重要,那么如何保证ES的高可用呢?

    首先我们知道,ES集群本身就是保证高可用的,如下图所示:

    图片

    当ES集群有一个节点宕机了,会将其他节点对应的Replica Shard升级为Primary Shard,继续提供服务。但即使是这样,还远远不够。例如ES集群都部署在机房A,现在机房A突然断电了,怎么办?例如服务器硬件故障,ES集群大部分机器宕机了,怎么办?或者突然有个非常热门的抢购秒杀活动,带来了一波非常大的流量,直接把ES集群打死了,怎么办?面对这些情况,让运维兄弟冲到机房去解决?这个非常不现实,因为会员系统直接影响全公司所有业务线的下单主流程,故障恢复的时间必须非常短,如果需要运维兄弟人工介入,那这个时间就太长了,是绝对不能容忍的。那ES的高可用如何做呢?我们的方案是ES双中心主备集群架构。

    我们有两个机房,分别是机房A和机房B。我们把ES主集群部署在机房A,把ES备集群部署在机房B。会员系统的读写都在ES主集群,通过MQ将数据同步到ES备集群。此时,如果ES主集群崩了,通过统一配置,将会员系统的读写切到机房B的ES备集群上,这样即使ES主集群挂了,也能在很短的时间内实现故障转移,确保会员系统的稳定运行。最后,等ES主集群故障恢复后,打开开关,将故障期间的数据同步到ES主集群,等数据同步一致后,再将会员系统的读写切到ES主集群。

    2. ES流量隔离三集群架构

    双中心ES主备集群做到这一步,感觉应该没啥大问题了,但去年的一次恐怖流量冲击让我们改变了想法。那是一个节假日,某个业务上线了一个营销活动,在用户的一次请求中,循环10多次调用了会员系统,导致会员系统的tps暴涨,差点把ES集群打爆。这件事让我们后怕不已,它让我们意识到,一定要对调用方进行优先级分类,实施更精细的隔离、熔断、降级、限流策略。首先,我们梳理了所有调用方,分出两大类请求类型。第一类是跟用户的下单主流程密切相关的请求,这类请求非常重要,应该高优先级保障。第二类是营销活动相关的,这类请求有个特点,他们的请求量很大,tps很高,但不影响下单主流程。基于此,我们又构建了一个ES集群,专门用来应对高tps的营销秒杀类请求,这样就跟ES主集群隔离开来,不会因为某个营销活动的流量冲击而影响用户的下单主流程。如下图所示:

    图片

    3. ES集群深度优化提升

    讲完了ES的双中心主备集群高可用架构,接下来我们深入讲解一下ES主集群的优化工作。有一段时间,我们特别痛苦,就是每到饭点,ES集群就开始报警,搞得每次吃饭都心慌慌的,生怕ES集群一个扛不住,就全公司炸锅了。那为什么一到饭点就报警呢?因为流量比较大, 导致ES线程数飙高,cpu直往上窜,查询耗时增加,并传导给所有调用方,导致更大范围的延时。那么如何解决这个问题呢?通过深入ES集群,我们发现了以下几个问题:

    • ES负载不合理,热点问题严重。ES主集群一共有几十个节点,有的节点上部署的shard数偏多,有的节点部署的shard数很少,导致某些服务器的负载很高,每到流量高峰期,就经常预警。
    • ES线程池的大小设置得太高,导致cpu飙高。我们知道,设置ES的threadpool,一般将线程数设置为服务器的cpu核数,即使ES的查询压力很大,需要增加线程数,那最好也不要超过“cpu core * 3 / 2 + 1”。如果设置的线程数过多,会导致cpu在多个线程上下文之间频繁来回切换,浪费大量cpu资源。
    • shard分配的内存太大,100g,导致查询变慢。我们知道,ES的索引要合理分配shard数,要控制一个shard的内存大小在50g以内。如果一个shard分配的内存过大,会导致查询变慢,耗时增加,严重拖累性能。
    • string类型的字段设置了双字段,既是text,又是keyword,导致存储容量增大了一倍。会员信息的查询不需要关联度打分,直接根据keyword查询就行,所以完全可以将text字段去掉,这样就能节省很大一部分存储空间,提升性能。
    • ES查询,使用filter,不使用query。因为query会对搜索结果进行相关度算分,比较耗cpu,而会员信息的查询是不需要算分的,这部分的性能损耗完全可以避免。
    • 节约ES算力,将ES的搜索结果排序放在会员系统的jvm内存中进行。
    • 增加routing key。我们知道,一次ES查询,会将请求分发给所有shard,等所有shard返回结果后再聚合数据,最后将结果返回给调用方。如果我们事先已经知道数据分布在哪些shard上,那么就可以减少大量不必要的请求,提升查询性能。

    经过以上优化,成果非常显著,ES集群的cpu大幅下降,查询性能大幅提升。ES集群的cpu使用率:

    会员系统的接口耗时:

    图片

    三、会员Redis缓存方案

    一直以来,会员系统是不做缓存的,原因主要有两个:第一个,前面讲的ES集群性能很好,秒并发3万多,99线耗时5毫秒左右,已经足够应付各种棘手的场景。第二个,有的业务对会员的绑定关系要求实时一致,而会员是一个发展了10多年的老系统,是一个由好多接口、好多系统组成的分布式系统。所以,只要有一个接口没有考虑到位,没有及时去更新缓存,就会导致脏数据,进而引发一系列的问题,例如:用户在APP上看不到微信订单、APP和微信的会员等级、里程等没合并、微信和APP无法交叉营销等等。那后来为什么又要做缓存呢?是因为今年机票的盲盒活动,它带来的瞬时并发太高了。虽然会员系统安然无恙,但还是有点心有余悸,稳妥起见,最终还是决定实施缓存方案。

    1. ES近一秒延时导致的Redis缓存数据不一致问题的解决方案

    在做会员缓存方案的过程中,遇到一个ES引发的问题,该问题会导致缓存数据的不一致。我们知道,ES操作数据是近实时的,往ES新增一个Document,此时立即去查,是查不到的,需要等待1秒后才能查询到。如下图所示:

    ES的近实时机制为什么会导致redis缓存数据不一致呢?具体来讲,假设一个用户注销了自己的APP账号,此时需要更新ES,删除APP账号和微信账号的绑定关系。而ES的数据更新是近实时的,也就是说,1秒后你才能查询到更新后的数据。而就在这1秒内,有个请求来查询该用户的会员绑定关系,它先到redis缓存中查,发现没有,然后到ES查,查到了,但查到的是更新前的旧数据。最后,该请求把查询到的旧数据更新到redis缓存并返回。就这样,1秒后,ES中该用户的会员数据更新了,但redis缓存的数据还是旧数据,导致了redis缓存跟ES的数据不一致。如下图所示:

    面对该问题,如何解决呢?我们的思路是,在更新ES数据时,加一个2秒的redis分布式并发锁,为了保证缓存数据的一致性,接着再删除redis中该会员的缓存数据。如果此时有请求来查询数据,先获取分布式锁,发现该会员ID已经上锁了,说明ES刚刚更新的数据尚未生效,那么此时查询完数据后就不更新redis缓存了,直接返回,这样就避免了缓存数据的不一致问题。如下图所示:

    图片

    上述方案,乍一看似乎没什么问题了,但仔细分析,还是有可能导致缓存数据的不一致。例如,在更新请求加分布式锁之前,恰好有一个查询请求获取分布式锁,而此时是没有锁的,所以它可以继续更新缓存。但就在他更新缓存之前,线程block了,此时更新请求来了,加了分布式锁,并删除了缓存。当更新请求完成操作后,查询请求的线程活过来了,此时它再执行更新缓存,就把脏数据写到缓存中了。发现没有?主要的问题症结就在于“删除缓存”和“更新缓存”发生了并发冲突,只要将它们互斥,就能解决问题。如下图所示:

    图片

    实施了缓存方案后,经统计,缓存命中率90%+,极大缓解了ES的压力,会员系统整体性能得到了很大提升。

    2. Redis双中心多集群架构

    接下来,我们看一下如何保障Redis集群的高可用。如下图所示:

    关于Redis集群的高可用,我们采用了双中心多集群的模式。在机房A和机房B各部署一套Redis集群。更新缓存数据时,双写,只有两个机房的redis集群都写成功了,才返回成功。查询缓存数据时,机房内就近查询,降低延时。这样,即使机房A整体故障,机房B还能提供完整的会员服务。

    四、高可用会员主库方案

    上述讲到,全平台会员的绑定关系数据存在ES,而会员的注册明细数据存在关系型数据库。最早,会员使用的数据库是SqlServer,直到有一天,DBA找到我们说,单台SqlServer数据库已经存储了十多亿的会员数据,服务器已达到物理极限,不能再扩展了。按照现在的增长趋势,过不了多久,整个SqlServer数据库就崩了。你想想,那是一种什么样的灾难场景:会员数据库崩了,会员系统就崩了;会员系统崩了,全公司所有业务线就崩了。想想就不寒而栗,酸爽无比,为此我们立刻开启了迁移DB的工作。

    1. MySql双中心Partition集群方案

    经过调研,我们选择了双中心分库分表的MySql集群方案,如下图所示:

    会员一共有十多亿的数据,我们把会员主库分了1000多个分片,平分到每个分片大概百万的量级,足够使用了。MySql集群采用1主3从的架构,主库放在机房A,从库放在机房B,两个机房之间通过专线同步数据,延迟在1毫秒内。会员系统通过DBRoute读写数据,写数据都路由到master节点所在的机房A,读数据都路由到本地机房,就近访问,减少网络延迟。这样,采用双中心的MySql集群架构,极大提高了可用性,即使机房A整体都崩了,还可以将机房B的Slave升级为Master,继续提供服务。

    双中心MySql集群搭建好后,我们进行了压测,测试下来,秒并发能达到2万多,平均耗时在10毫秒内,性能达标。

    2. 会员主库平滑迁移方案

    接下来的工作,就是把会员系统的底层存储从SqlServer切到MySql上,这是个风险极高的工作,主要有以下几个难点:

    • 会员系统是一刻都不能停机的,要在不停机的情况下完成SqlServer到MySql的切换,就像是在给高速行驶的汽车换轮子。
    • 会员系统是由很多个系统和接口组成的,毕竟发展了10多年,由于历史原因,遗留了大量老接口,逻辑错综复杂。这么多系统,必须一个不落的全部梳理清楚,DAL层代码必须重写,而且不能出任何问题,否则将是灾难性的。
    • 数据的迁移要做到无缝迁移,不仅是存量10多亿数据的迁移,实时产生的数据也要无缝同步到mysql。另外,除了要保障数据同步的实时性,还要保证数据的正确性,以及SqlServer和MySql数据的一致性。

    基于以上痛点,我们设计了“全量同步、增量同步、实时流量灰度切换”的技术方案。

    首先,为了保证数据的无缝切换,采用实时双写的方案。因为业务逻辑的复杂,以及SqlServer和MySql的技术差异性,在双写mysql的过程中,不一定会写成功,而一旦写失败,就会导致SqlServer和MySql的数据不一致,这是绝不允许的。所以,我们采取的策略是,在试运行期间,主写SqlServer,然后通过线程池异步写MySql,如果写失败了,重试三次,如果依然失败,则记日志,然后人工排查原因,解决后,继续双写,直到运行一段时间,没有双写失败的情况。通过上述策略,可以确保在绝大部分情况下,双写操作的正确性和稳定性,即使在试运行期间出现了SqlServer和MySql的数据不一致的情况,也可以基于SqlServer再次全量构建出MySql的数据,因为我们在设计双写策略时,会确保SqlServer一定能写成功,也就是说,SqlServer中的数据是全量最完整、最正确的。如下图所示:

    讲完了双写,接下来我们看一下“读数据”如何灰度。整体思路是,通过A/B平台逐步灰度流量,刚开始100%的流量读取SqlServer数据库,然后逐步切流量读取MySql数据库,先1%,如果没有问题,再逐步放流量,最终100%的流量都走MySql数据库。在逐步灰度流量的过程中,需要有验证机制,只有验证没问题了,才能进一步放大流量。那么这个验证机制如何实施呢?方案是,在一次查询请求里,通过异步线程,比较SqlServer和 MySql的查询结果是否一致,如果不一致,记日志,再人工检查不一致的原因,直到彻底解决不一致的问题后,再逐步灰度流量。如下图所示:

    图片

    所以,整体的实施流程如下:

    图片

    首先,在一个夜黑风高的深夜,流量最小的时候,完成SqlServer到MySql数据库的全量数据同步。接着,开启双写,此时,如果有用户注册,就会实时双写到两个数据库。那么,在全量同步和实时双写开启之间,两个数据库还相差这段时间的数据,所以需要再次增量同步,把数据补充完整,以防数据的不一致。剩下的时间,就是各种日志监控,看双写是否有问题,看数据比对是否一致等等。这段时间是耗时最长的,也是最容易发生问题的,如果有的问题比较严重,导致数据不一致了,就需要从头再来,再次基于SqlServer全量构建MySql数据库,然后重新灰度流量,直到最后,100%的流量全部灰度到MySql,此时就大功告成了,下线灰度逻辑,所有读写都切到MySql集群。

    3. MySql和ES主备集群方案

    做到这一步,感觉会员主库应该没问题了,可dal组件的一次严重故障改变了我们的想法。那次故障很恐怖,公司很多应用连接不上数据库了,创单量直线往下掉,这让我们意识到,即使数据库是好的,但dal组件异常,依然能让会员系统挂掉。所以,我们再次异构了会员主库的数据源,双写数据到ES,如下所示:

    如果dal组件故障或MySql数据库挂了,可以把读写切到ES,等MySql恢复了,再把数据同步到MySql,最后把读写再切回到MySql数据库。如下图所示:

    图片

    五、异常会员关系治理

    会员系统不仅仅要保证系统的稳定和高可用,数据的精准和正确也同样重要。举个例子,一个分布式并发故障,导致一名用户的APP账户绑定了别人的微信小程序账户,这将会带来非常恶劣的影响。首先,一旦这两个账号绑定了,那么这两个用户下的酒店、机票、火车票订单是互相可以看到的。你想想,别人能看到你订的酒店订单,你火不火,会不会投诉?除了能看到别人的订单,你还能操作订单。例如,一个用户在APP的订单中心,看到了别人订的机票订单,他觉得不是自己的订单,就把订单取消了。这将会带来非常严重的客诉,大家知道,机票退订费用是挺高的,这不仅影响了该用户的正常出行,还导致了比较大的经济损失,非常糟糕。

    针对这些异常会员账号,我们进行了详细的梳理,通过非常复杂烧脑的逻辑识别出这些账号,并对会员接口进行了深度优化治理,在代码逻辑层堵住了相关漏洞,完成了异常会员的治理工作。如下图所示:

    图片

    六、展望:更精细化的流控和降级策略

    任何一个系统,都不能保证百分之一百不出问题,所以我们要有面向失败的设计,那就是更精细化的流控和降级策略。

    1. 更精细化的流控策略

    热点控制。针对黑产刷单的场景,同一个会员id会有大量重复的请求,形成热点账号,当这些账号的访问超过设定阈值时,实施限流策略。

    基于调用账号的流控规则。这个策略主要是防止调用方的代码bug导致的大流量。例如,调用方在一次用户请求中,循环很多次来调用会员接口,导致会员系统流量暴增很多倍。所以,要针对每个调用账号设置流控规则,当超过阈值时,实施限流策略。

    全局流控规则。我们会员系统能抗下tps 3万多的秒并发请求量,如果此时,有个很恐怖的流量打过来,tps高达10万,与其让这波流量把会员数据库、es全部打死,还不如把超过会员系统承受范围之外的流量快速失败,至少tps 3万内的会员请求能正常响应,不会让整个会员系统全部崩溃。

    图片

    2. 更精细化的降级策略

    基于平均响应时间的降级。会员接口也有依赖其他接口,当调用其他接口的平均响应时间超过阈值,进入准降级状态。如果接下来 1s 内进入的请求,它们的平均响应时间都持续超过阈值,那么在接下的时间窗口内,自动地熔断。

    基于异常数和异常比例的降级。当会员接口依赖的其他接口发生异常,如果1分钟内的异常数超过阈值,或者每秒异常总数占通过量的比值超过阈值,进入降级状态,在接下的时间窗口之内,自动熔断。

    目前,我们最大的痛点是会员调用账号的治理。公司内,想要调用会员接口,必须申请一个调用账号,我们会记录该账号的使用场景,并设置流控、降级策略的规则。但在实际使用的过程中,申请了该账号的同事,可能异动到其他部门了,此时他可能也会调用会员系统,为了省事,他不会再次申请会员账号,而是直接沿用以前的账号过来调用,这导致我们无法判断一个会员账号的具体使用场景是什么,也就无法实施更精细的流控和降级策略。所以,接下来,我们将会对所有调用账号进行一个个的梳理,这是个非常庞大且繁琐的工作,但无路如何,硬着头皮也要做好。

    
    

  • 如何设计API返回码(错误码)?

    快乐分享,Java干货及时送达👇

    来源:ken.io/note/api-errorcode-or-resultcode-desgin
    • 一、前言
    • 二、HTTP 状态码参考
    • 三、参数约定
    • 四、个性化 Message
    • 五、返回信息的统一处理

    一、前言

    客户端请求 API,通常需要通过返回码来判断 API 返回的结果是否符合预期,以及该如何处理返回的内容等

    相信很多同学都吃过返回码定义混乱的亏,有的 API 用返回码是 int 类型,有的是 string 类型,有的用 0 表示成功,又有的用 1 表示成功,还有用”true” 表示成功,碰上这种事情,只能说:头疼

    API 返回码的设计还是要认真对待,毕竟好的返回码设计可以降低沟通成本以及程序的维护成本

    二、HTTP 状态码参考

    以 HTTP 状态码为例,为了更加清晰的表述和区分状态码的含义,HTTP 状态做了分段。

    图片

    对于后端开发来说,我们通常见到的都是:

    2XX 状态码,比如 200-> 请求成功,

    5XX 状态码,比如 502-> 服务器异常,通常就是服务没正常运行,或者代码执行出错

    通过状态码即可初步判断问题原因,HTTP 状态的设计思路值得借鉴。

    三、参数约定

    虽说是返回码设计,但是只有 code 是不行的,还要有对应的 message,让人可以看懂

    参考 HTTP 状态码的思路,我们对错误码进行分段

    通过这样的设计,不论是程序还是人都可以非常方便的区分 API 的返回结果,关键是统一!

    四、个性化 Message

    通常我们的 message 都是写给工程师看的,但是在不同的场景下,同样的错误,可能需要给用户看到不一样的错误提示。

    比方说 20000-29999 表示订单创建失败:

    • 20001,订单创建失败,存在进行中的订单
    • 20002,订单创建失败,上一个订单正在排队创建中

    这两种错误情况如果是给用户看,可能就只适合看到:很抱歉,您有一个正在进行中的订单,请到我的订单列表中处理。

    但是对于 API 来说,返回的信息又必须是准确的,但用户看到的就必须转译,这个转译的工作调用方可以做,但是通常 API 提供者来提供个性化的 Message 能力会更好

    我们可以把转译的消息配置到数据库,并缓存到 Redis 或者 API 本机

    图片

    然后在请求处理结束即将返回的时候,根据 application_id+code,去匹配替换 message

    图片

    这样我们就可以让手机 APP 的用户、微信小程序的用户、网页下单的企业用户看到不同的消息

    五、返回信息的统一处理

    有了统一的 code,我们就可以通过 Nginx 或者 APM 工具统计 API 请求 Code 数量及分布信息。

    我们可以根据单位时间内 99999 的数量来做 API 的异常告警

    我们可以根据 Code 的返回饼图,帮助我们发现系统、业务流程中的问题

    等等

    总之,好的返回码设计,可以帮助我们提高沟通效率,降低代码的维护成本。

    
    

  • 3 个腾讯开源的 GitHub 项目,足够惊艳!

    快乐分享,Java干货及时送达👇

    来源:网络

      01、系统清理工具

      腾讯开源了一个系统清理工具:腾讯柠檬清理,该软件可以系统性解决 macOS 设备空间问题。

      重点聚焦清理功能,对上百款软件提供定制化的清理方案,提供专业的清理建议,帮助用户轻松完成一键式的清理。

      主要功能包括:深度扫描清理、大文件清理、重复文件清理、相似照片清理、浏览器隐私清理、应用卸载、开启启动项管理、自定义状态栏展示信息。

      开源地址:

      https://github.com/Tencent/lemon-cleaner

      02、开源的 Markdown 编辑器

      Cherry Markdown Editor 是一款 Javascript Markdown 编辑器,具有开箱即用、轻量简洁、易于扩展等特点,它可以运行在浏览器或服务端 (NodeJs).

      当 Cherry Markdown 编辑器支持的语法不满足开发者需求时,可以快速的进行二次开发或功能扩展。

      同时,CherryMarkdown 编辑器应该由纯 JavaScript 实现,不应该依赖  Angular、Vue、React 等框架技术,框架只提供容器环境即可。

      开源地址:

      https://github.com/Tencent/cherry-markdown

      支持 Markdown 语法

      表格支持

      图标

      多光标批量编辑

      03、代码安全指南

      面向开发人员梳理的代码安全指南,旨在梳理 API 层面的风险点并提供详实可行的安全编码方案。该代码安全指南可用于开发人员日常参考或者修改漏洞时进行修复指引。

      开源地址:

      https://github.com/Tencent/secguide


    • 废物利用,拿自己的旧电脑搭建个服务器吧!

      最近总是想搭建自己的网站,奈何皮夹里空空如也,服务器也租不起,更别说域名了。于是我就寻思能否自己搭建个服务器,还不要钱呢?

      还真行!!!

      经过几天的冲浪,我发现有两个免费的建站工具:Apache和Nginx

      由于两个工具建站方法差不多,所以我就以Nginx为例

      1.安装Nginx

      首先前往Nginx官网(nginx.org)进行下载,也可以直接用我提供的链接下载1.23版本:http://nginx.org/download/nginx-1.23.1.zip

      安装完之后解压,然后你会看到如下目录:

      由于Nginx的功能很多,而我们今天只是搭建个服务器,所以只会用到其中的一部分。

      2.配置Nginx

      进入conf文件夹,打开nginx.conf文件进行编辑,里面的配置很多,我对其中一些重要的配置进行了说明(前面有“#”的表示并没有真正写入配置,若要加入,只需去掉“#”):

      3.启动Nginx服务

      配置完Nginx后,返回Nginx根目录,找到nginx.exe,双击运行它,你会看到有个小黑框一闪而过,这说明Nginx已经成功启动!你可以打开浏览器,输入:虚拟主机名称:监听的端口(刚刚的配置),回车,就会看到如下网页:

      恭喜你,已经成功搭建了Nginx服务器!

      4.为你的网站添加文件

      光开启了服务可还不够,如果别人看到你的网站只有干巴巴的一段文字,有什么用?接下来,进入刚刚配置的文件夹位置,在该文件夹下新建一个txt,打开后输入这段代码:

      html>
      html lang="en">
      head>
          meta charset="UTF-8">
          meta name="viewport" content="width=device-width, initial-scale=1.0">
          title>Documenttitle>
          style>
              * {
                  margin0;
                  padding0;
              }
              html {
                  height100%;
              }
              body {
                  height100%;
              }
              .container {
                  height100%;
                  background-imagelinear-gradient(to right, #fbc2eb, #a6c1ee);
              }
              .login-wrapper {
                  background-color#fff;
                  width358px;
                  height588px;
                  border-radius15px;
                  padding0 50px;
                  position: relative;
                  left50%;
                  top50%;
                  transformtranslate(-50%, -50%);
              }
              .header {
                  font-size38px;
                  font-weight: bold;
                  text-align: center;
                  line-height200px;
              }
              .input-item {
                  display: block;
                  width100%;
                  margin-bottom20px;
                  border0;
                  padding10px;
                  border-bottom1px solid rgb(128125125);
                  font-size15px;
                  outline: none;
              }
              .input-item:placeholder {
                  text-transform: uppercase;
              }
              .btn {
                  text-align: center;
                  padding10px;
                  width100%;
                  margin-top40px;
                  background-imagelinear-gradient(to right, #a6c1ee, #fbc2eb);
                  color#fff;
              }
              .msg {
                  text-align: center;
                  line-height88px;
              }
              a {
                  text-decoration-line: none;
                  color#abc1ee;
              }
          
      style>
      head>
      body>
          div class="container">
              div class="login-wrapper">
                  div class="header">Logindiv>
                  div class="form-wrapper">
                      input type="text" name="username" placeholder="username" class="input-item">
                      input type="password" name="password" placeholder="password" class="input-item">
                      div class="btn">Logindiv>
                  div>
                  div class="msg">
                      Don't have account?
                      a href="#">Sign upa>
                  div>
              div>
          div>
      body>
      html>

      再将文件名改为index.html,保存,最后再次打开浏览器,输入虚拟主机名称:监听的端口(刚刚的配置),回车,你就会看见如下页面:

      是不是非常好看?这个index.html其实是用HTML+CSS写出来的,感兴趣的同学可以去学习一下。

      除了html文件,你还可以在该文件夹里放任何文件,如:图片,视频,压缩包等等。

      5.内网穿透

      服务器搭建完了,网页也有了,但其实除了跟你在同一个局域网下的人,都无法访问你的网站。

      这里就要用到内网穿透了,所谓内网穿透,也即是局域网能够直接通过公网的ip去访问,极大的方便用户的日常远程的一些操作的使用。这里我建议大家使用飞鸽内网穿透,使用方法如下:

      5.1 注册

      进入飞鸽内网穿透官网,进行注册,这步就不多讲了。

      5.2 开通隧道

      注册好后,我们点击“开通隧道”选项,选择“免费节点”,有实力的也可以选贵的。

      然后填写信息,其中前置域名可以自定,本地ip端口一定要设置成:你的内网ip:刚配置的端口号。

      最后点击确认开通,就OK了,这样你就得到了免费域名+免费公网ip。

      5.3 启动服务

      点击此链接,根据电脑系统下载客户端。下载完后解压,一共有两个文件:傻瓜式运行点击我.vbs和npc.exe。

      点击傻瓜式运行点击我.vbs,打开后会看见一个弹窗,让你填写指令。我们切回飞鸽官网,点击“隧道管理”,如下图:根据电脑系统选择指令,点击复制,然后切回刚才的弹窗,将指令输入进去,点击确定。

      这样内网穿透就成功了!打开浏览器,输入刚才开通的隧道的访问地址(上图被抹掉的地方),回车,同样能开到之前编写的网页,就成功了。

      来源:blog.csdn.net/Lucas0623/article/

      details/126584307

    • Google Guava 工具包用起来太爽了!

      快乐分享,Java干货及时送达👇

      文章来源:https://www.jianshu.com/p/97778b21bd00

      前言


      目前Google Guava在实际应用中非常广泛,本篇博客将以博主对Guava使用的认识以及在项目中的经验来给大家分享!正如标题所言,学习使用Google Guava可以让你快乐编程,写出优雅的JAVA代码!


      以面向对象思想处理字符串:

      Joiner/Splitter/CharMatcher


      JDK提供的String还不够好么?

      也许还不够友好,至少让我们用起来还不够爽,还得操心!

      举个栗子,比如String提供的split方法,我们得关心空字符串吧,还得考虑返回的结果中存在null元素吧,只提供了前后trim的方法(如果我想对中间元素进行trim呢)。

      那么,看下面的代码示例,guava让你不必在操心这些:

      Joiner/Splitter

      Joiner是连接器,Splitter是分割器,通常我们会把它们定义为static final,利用on生成对象后在应用到String进行处理,这是可以复用的。要知道apache commons StringUtils提供的都是static method。更加重要的是,guava提供的Joiner/Splitter是经过充分测试,它的稳定性和效率要比apache高出不少,这个你可以自行测试下~

      发现没有我们想对String做什么操作,就是生成自己定制化的Joiner/Splitter,多么直白,简单,流畅的API!

      对于Joiner,常用的方法是  跳过NULL元素:skipNulls()  /  对于NULL元素使用其他替代:useForNull(String)

      对于Splitter,常用的方法是:trimResults()/omitEmptyStrings()。注意拆分的方式,有字符串,还有正则,还有固定长度分割(太贴心了!)

      其实除了Joiner/Splitter外,guava还提供了字符串匹配器:CharMatcher

      CharMatcher

      CharMatcher,将字符的匹配和处理解耦,并提供丰富的方法供你使用!

      对基本类型进行支持

      guava对JDK提供的原生类型操作进行了扩展,使得功能更加强大!

      Ints

      guava提供了Bytes/Shorts/Ints/Iongs/Floats/Doubles/Chars/Booleans这些基本数据类型的扩展支持,只有你想不到的,没有它没有的!


      对JDK集合的有效补充


      灰色地带:Multiset

      JDK的集合,提供了有序且可以重复的List,无序且不可以重复的Set。那这里其实对于集合涉及到了2个概念,一个order,一个dups。那么List vs Set,and then some ?

      Multiset

      Multiset是什么,我想上面的图,你应该了解它的概念了。Multiset就是无序的,但是可以重复的集合,它就是游离在List/Set之间的“灰色地带”!

      (至于有序的,不允许重复的集合嘛,guava还没有提供,当然在未来应该会提供UniqueList,我猜的,哈哈)

      来看一个Multiset的示例:

      Multiset Code

      Multiset自带一个有用的功能,就是可以跟踪每个对象的数量。


      Immutable vs unmodifiable


      来我们先看一个unmodifiable的例子:


      unmodifiable

      你看到JDK提供的unmodifiable的缺陷了吗?

      实际上,Collections.unmodifiableXxx所返回的集合和源集合是同一个对象,只不过可以对集合做出改变的API都被override,会抛出UnsupportedOperationException。

      也即是说我们改变源集合,导致不可变视图(unmodifiable View)也会发生变化,oh my god!

      当然,在不使用guava的情况下,我们是怎么避免上面的问题的呢?

      defensive copies

      上面揭示了一个概念:Defensive Copies,保护性拷贝。

      OK,unmodifiable看上去没有问题呢,但是guava依然觉得可以改进,于是提出了Immutable的概念,来看:

      Immutable

      就一个copyOf,你不会忘记,如此cheap~

      用Google官方的说法是:we’re using just one class,just say exactly what we mean,很了不起吗(不仅仅是个概念,Immutable在COPY阶段还考虑了线程的并发性等,很智能的!),O(∩_∩)O哈哈~

      guava提供了很多Immutable集合,比如ImmutableList/ImmutableSet/ImmutableSortedSet/ImmutableMap/……

      看一个ImmutableMap的例子:

      ImmutableMap


      可不可以一对多:Multimap

      JDK提供给我们的Map是一个键,一个值,一对一的,那么在实际开发中,显然存在一个KEY多个VALUE的情况(比如一个分类下的书本),我们往往这样表达:Map>,好像有点臃肿!臃肿也就算了,更加不爽的事,我们还得判断KEY是否存在来决定是否new 一个LIST出来,有点麻烦!更加麻烦的事情还在后头,比如遍历,比如删除,so hard……

      来看guava如何替你解决这个大麻烦的:

      Multimap

      友情提示下,guava所有的集合都有create方法,这样的好处在于简单,而且我们不必在重复泛型信息了。

      get()/keys()/keySet()/values()/entries()/asMap()都是非常有用的返回view collection的方法。

      Multimap的实现类有:ArrayListMultimap/HashMultimap/LinkedHashMultimap/TreeMultimap/ImmutableMultimap/……


      可不可以双向:BiMap

      JDK提供的MAP让我们可以find value by key,那么能不能通过find key by value呢,能不能KEY和VALUE都是唯一的呢。这是一个双向的概念,即forward+backward。

      在实际场景中有这样的需求吗?比如通过用户ID找到mail,也需要通过mail找回用户名。没有guava的时候,我们需要create forward map AND create backward map,and now just let guava do that for you.

      BiMap

      biMap / biMap.inverse() / biMap.inverse().inverse() 它们是什么关系呢?

      你可以稍微看一下BiMap的源码实现,实际上,当你创建BiMap的时候,在内部维护了2个map,一个forward map,一个backward map,并且设置了它们之间的关系。

      因此,biMap.inverse()  != biMap ;biMap.inverse().inverse() == biMap


      可不可以多个KEY:Table


      我们知道数据库除了主键外,还提供了复合索引,而且实际中这样的多级关系查找也是比较多的,当然我们可以利用嵌套的Map来实现:Map>。为了让我们的代码看起来不那么丑陋,guava为我们提供了Table。

      Table

      Table涉及到3个概念:rowKey,columnKey,value,并提供了多种视图以及操作方法让你更加轻松的处理多个KEY的场景。

      函数式编程:Functions

      Functions

      上面的代码是为了完成将List集合中的元素,先截取5个长度,然后转成大写。

      函数式编程的好处在于在集合遍历操作中提供自定义Function的操作,比如transform转换。我们再也不需要一遍遍的遍历集合,显著的简化了代码!

      对集合的transform操作可以通过Function完成

      断言:Predicate


      Predicate最常用的功能就是运用在集合的过滤当中!

      filter

      需要注意的是Lists并没有提供filter方法,不过你可以使用Collections2.filter完成!


      check null and other:

      Optional、Preconditions

      在guava中,对于null的处理手段是快速失败,你可以看看guava的源码,很多方法的第一行就是:Preconditions.checkNotNull(elements);

      要知道null是模糊的概念,是成功呢,还是失败呢,还是别的什么含义呢?

      Preconditions/Optional

      Cache is king

      对于大多数互联网项目而言,缓存的重要性,不言而喻!

      如果我们的应用系统,并不想使用一些第三方缓存组件(如redis),我们仅仅想在本地有一个功能足够强大的缓存,很可惜JDK提供的那些SET/MAP还不行!

      CacheLoader

      首先,这是一个本地缓存,guava提供的cache是一个简洁、高效,易于维护的。为什么这么说呢?因为并没有一个单独的线程用于刷新 OR 清理cache,对于cache的操作,都是通过访问/读写带来的,也就是说在读写中完成缓存的刷新操作!

      其次,我们看到了,我们非常通俗的告诉cache,我们的缓存策略是什么,SO EASY!在如此简单的背后,是guava帮助我们做了很多事情,比如线程安全。

      让异步回调更加简单

      JDK中提供了Future/FutureTask/Callable来对异步回调进行支持,但是还是看上去挺复杂的,能不能更加简单呢?比如注册一个监听回调。

      异步回调

      我们可以通过guava对JDK提供的线程池进行装饰,让其具有异步回调监听功能,然后在设置监听器即可!


      Summary

      到这里,这篇文章也只介绍了guava的冰山一角,其实还有很多内容:

      guava package

      比如反射、注解、网络、并发、IO等等

      好了,希望这篇文章让你快速进阶,快乐编程!

      
      

    • 面试官:一千万的数据,你是怎么查询的?

      快乐分享,Java干货及时送达👇

      来源:juejin.cn/post/6863668253898735629
      • 前言

      • 准备数据

        • 创建表

        • 创建数据脚本

      • 开始测试

        • 普通分页查询

      • 如何优化

        • 优化偏移量大问题

        • 优化数据量大问题

      • SELECT * 它不香吗?

      • 结束


      前言

      • 面试官:来说说,一千万的数据,你是怎么查询的?
      • B哥:直接分页查询,使用limit分页。
      • 面试官:有实操过吗?
      • B哥:肯定有呀

      此刻献上一首《凉凉》

      也许有些人没遇过上千万数据量的表,也不清楚查询上千万数据量的时候会发生什么。

      今天就来带大家实操一下,这次是基于MySQL 5.7.26做测试

      准备数据

      没有一千万的数据怎么办?

      创建呗

      代码创建一千万?那是不可能的,太慢了,可能真的要跑一天。可以采用数据库脚本执行速度快很多。

      创建表

      CREATE TABLE `user_operation_log`  (
        `id` int(11) NOT NULL AUTO_INCREMENT,
        `user_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
        `ip` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
        `op_data` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
        `attr1` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
        `attr2` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
        `attr3` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
        `attr4` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
        `attr5` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
        `attr6` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
        `attr7` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
        `attr8` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
        `attr9` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
        `attr10` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
        `attr11` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
        `attr12` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
        PRIMARY KEY (`id`) USING BTREE
      ) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;

      创建数据脚本

      采用批量插入,效率会快很多,而且每1000条数就commit,数据量太大,也会导致批量插入效率慢

      DELIMITER ;;
      CREATE PROCEDURE batch_insert_log()
      BEGIN
        DECLARE i INT DEFAULT 1;
        DECLARE userId INT DEFAULT 10000000;
       set @execSql = 'INSERT INTO `test`.`user_operation_log`(`user_id`, `ip`, `op_data`, `attr1`, `attr2`, `attr3`, `attr4`, `attr5`, `attr6`, `attr7`, `attr8`, `attr9`, `attr10`, `attr11`, `attr12`) VALUES';
       set @execData = '';
        WHILE i   set @attr = "'测试很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长的属性'";
        set @execData = concat(@execData, "(", userId + i, ", '10.0.69.175', '用户登录操作'"",", @attr, ",", @attr, ",", @attr, ",", @attr, ",", @attr, ",", @attr, ",", @attr, ",", @attr, ",", @attr, ",", @attr, ",", @attr, ",", @attr, ")");
        if i % 1000 = 0
        then
           set @stmtSql = concat(@execSql, @execData,";");
          prepare stmt from @stmtSql;
          execute stmt;
          DEALLOCATE prepare stmt;
          commit;
          set @execData = "";
         else
           set @execData = concat(@execData, ",");
         end if;
        SET i=i+1;
        END WHILE;

      END;;
      DELIMITER ;

      开始测试

      哥的电脑配置比较低:win10 标压渣渣i5 读写约500MB的SSD

      由于配置低,本次测试只准备了3148000条数据,占用了磁盘5G(还没建索引的情况下),跑了38min,电脑配置好的同学,可以插入多点数据测试

      SELECT count(1) FROM `user_operation_log`

      返回结果:3148000

      三次查询时间分别为:

      • 14060 ms
      • 13755 ms
      • 13447 ms

      普通分页查询

      MySQL 支持 LIMIT 语句来选取指定的条数数据, Oracle 可以使用 ROWNUM 来选取。

      MySQL分页查询语法如下:

      SELECT * FROM table LIMIT [offset,] rows | rows OFFSET offset
      • 第一个参数指定第一个返回记录行的偏移量
      • 第二个参数指定返回记录行的最大数目

      下面我们开始测试查询结果:

      SELECT * FROM `user_operation_log` LIMIT 10000, 10

      查询3次时间分别为:

      • 59 ms
      • 49 ms
      • 50 ms

      这样看起来速度还行,不过是本地数据库,速度自然快点。

      换个角度来测试

      相同偏移量,不同数据量

      SELECT * FROM `user_operation_log` LIMIT 10000, 10
      SELECT * FROM `user_operation_log` LIMIT 10000, 100
      SELECT * FROM `user_operation_log` LIMIT 10000, 1000
      SELECT * FROM `user_operation_log` LIMIT 10000, 10000
      SELECT * FROM `user_operation_log` LIMIT 10000, 100000
      SELECT * FROM `user_operation_log` LIMIT 10000, 1000000

      查询时间如下:

      图片

      从上面结果可以得出结束:数据量越大,花费时间越长

      相同数据量,不同偏移量

      SELECT * FROM `user_operation_log` LIMIT 100, 100
      SELECT * FROM `user_operation_log` LIMIT 1000, 100
      SELECT * FROM `user_operation_log` LIMIT 10000, 100
      SELECT * FROM `user_operation_log` LIMIT 100000, 100
      SELECT * FROM `user_operation_log` LIMIT 1000000, 100

      图片

      从上面结果可以得出结束:偏移量越大,花费时间越长

      SELECT * FROM `user_operation_log` LIMIT 100, 100
      SELECT id, attr FROM `user_operation_log` LIMIT 100, 100

      如何优化

      既然我们经过上面一番的折腾,也得出了结论,针对上面两个问题:偏移大、数据量大,我们分别着手优化

      优化偏移量大问题

      采用子查询方式

      我们可以先定位偏移位置的 id,然后再查询数据

      SELECT * FROM `user_operation_log` LIMIT 1000000, 10SELECT id FROM `user_operation_log` LIMIT 1000000, 1SELECT * FROM `user_operation_log` WHERE id >= (SELECT id FROM `user_operation_log` LIMIT 1000000, 1) LIMIT 10

      查询结果如下:

      从上面结果得出结论:

      • 第一条花费的时间最大,第三条比第一条稍微好点
      • 子查询使用索引速度更快

      缺点:只适用于id递增的情况

      id非递增的情况可以使用以下写法,但这种缺点是分页查询只能放在子查询里面

      注意:某些 mysql 版本不支持在 in 子句中使用 limit,所以采用了多个嵌套select

      SELECT * FROM `user_operation_log` WHERE id IN (SELECT t.id FROM (SELECT id FROM `user_operation_log` LIMIT 1000000, 10) AS t)

      采用 id 限定方式

      这种方法要求更高些,id必须是连续递增,而且还得计算id的范围,然后使用 between,sql如下

      SELECT * FROM `user_operation_log` WHERE id between 1000000 AND 1000100 LIMIT 100

      SELECT * FROM `user_operation_log` WHERE id >= 1000000 LIMIT 100

      查询结果如下:

      图片

      从结果可以看出这种方式非常快

      注意:这里的 LIMIT 是限制了条数,没有采用偏移量

      优化数据量大问题

      返回结果的数据量也会直接影响速度

      SELECT * FROM `user_operation_log` LIMIT 1, 1000000

      SELECT id FROM `user_operation_log` LIMIT 1, 1000000

      SELECT id, user_id, ip, op_data, attr1, attr2, attr3, attr4, attr5, attr6, attr7, attr8, attr9, attr10, attr11, attr12 FROM `user_operation_log` LIMIT 1, 1000000

      查询结果如下:

      图片

      从结果可以看出减少不需要的列,查询效率也可以得到明显提升

      第一条和第三条查询速度差不多,这时候你肯定会吐槽,那我还写那么多字段干啥呢,直接 * 不就完事了

      注意本人的 MySQL 服务器和客户端是在_同一台机器_上,所以查询数据相差不多,有条件的同学可以测测客户端与MySQL分开

      SELECT * 它不香吗?

      在这里顺便补充一下为什么要禁止 SELECT *。难道简单无脑,它不香吗?

      主要两点:

      • 用 “SELECT * ” 数据库需要解析更多的对象、字段、权限、属性等相关内容,在 SQL 语句复杂,硬解析较多的情况下,会对数据库造成沉重的负担。
      • 增大网络开销,* 有时会误带上如log、IconMD5之类的无用且大文本字段,数据传输size会几何增涨。特别是MySQL和应用程序不在同一台机器,这种开销非常明显。

      结束

      最后还是希望大家自己去实操一下,肯定还可以收获更多,欢迎留言!!

      创建脚本我给你正好了,你还在等什么!!!

      
      

    • SpringBoot + K8S 中的滚动发布、优雅停机、弹性伸缩、应用监控、配置分离

      快乐分享,Java干货及时送达👇

      来源:blog.csdn.net/qq_14999375/article/details/123309636

      • 前言

      • 配置

        • 健康检查

        • 滚动更新

        • 弹性伸缩

        • Prometheus集成

        • 配置分离

      • 汇总配置

        • 业务层面

        • 运维层面


      前言

      K8s + SpringBoot实现零宕机发布:健康检查+滚动更新+优雅停机+弹性伸缩+Prometheus监控+配置分离(镜像复用)

      配置

      健康检查

      • 健康检查类型:就绪探针(readiness)+ 存活探针(liveness)
      • 探针类型:exec(进入容器执行脚本)、tcpSocket(探测端口)、httpGet(调用接口)
      业务层面

      项目依赖 pom.xml


          org.springframework.boot
          spring-boot-starter-actuator

      定义访问端口、路径及权限 application.yaml

      management:
        server:
          port: 50000                         # 启用独立运维端口
        endpoint:                             # 开启health端点
          health:
            probes:
              enabled: true
        endpoints:
          web:
            exposure:
              base-path: /actuator            # 指定上下文路径,启用相应端点
              include: health

      将暴露/actuator/health/readiness/actuator/health/liveness两个接口,访问方式如下:

      http://127.0.0.1:50000/actuator/health/readiness
      http://127.0.0.1:50000/actuator/health/liveness
      运维层面

      k8s部署模版deployment.yaml

      apiVersion: apps/v1
      kind: Deployment
      spec:
        template:
          spec:
            containers:
            - name: {APP_NAME}
              image: {IMAGE_URL}
              imagePullPolicy: Always
              ports:
              - containerPort: {APP_PORT}
              - name: management-port
                containerPort: 50000         # 应用管理端口
              readinessProbe:                # 就绪探针
                httpGet:
                  path: /actuator/health/readiness
                  port: management-port
                initialDelaySeconds: 30      # 延迟加载时间
                periodSeconds: 10            # 重试时间间隔
                timeoutSeconds: 1            # 超时时间设置
                successThreshold: 1          # 健康阈值
                failureThreshold: 6          # 不健康阈值
              livenessProbe:                 # 存活探针
                httpGet:
                  path: /actuator/health/liveness
                  port: management-port
                initialDelaySeconds: 30      # 延迟加载时间
                periodSeconds: 10            # 重试时间间隔
                timeoutSeconds: 1            # 超时时间设置
                successThreshold: 1          # 健康阈值
                failureThreshold: 6          # 不健康阈值

      滚动更新

      k8s资源调度之滚动更新策略,若要实现零宕机发布,需支持健康检查

      apiVersion: apps/v1
      kind: Deployment
      metadata:
        name: {APP_NAME}
        labels:
          app: {APP_NAME}
      spec:
        selector:
          matchLabels:
            app: {APP_NAME}
        replicas: {REPLICAS}    # Pod副本数
        strategy:
          type: RollingUpdate    # 滚动更新策略
          rollingUpdate:
            maxSurge: 1                   # 升级过程中最多可以比原先设置的副本数多出的数量
            maxUnavailable: 1             # 升级过程中最多有多少个POD处于无法提供服务的状态
      优雅停机

      在K8s中,当我们实现滚动升级之前,务必要实现应用级别的优雅停机。否则滚动升级时,还是会影响到业务。使应用关闭线程、释放连接资源后再停止服务

      业务层面

      项目依赖 pom.xml


          org.springframework.boot
          spring-boot-starter-actuator

      定义访问端口、路径及权限 application.yaml

      spring:
        application:
          name: 
        profiles:
          active: @profileActive@
        lifecycle:
          timeout-per-shutdown-phase: 30s     # 停机过程超时时长设置30s,超过30s,直接停机

      server:
        port: 8080
        shutdown: graceful                    # 默认为IMMEDIATE,表示立即关机;GRACEFUL表示优雅关机

      management:
        server:
          port: 50000                         # 启用独立运维端口
        endpoint:                             # 开启shutdown和health端点
          shutdown:
            enabled: true
          health:
            probes:
              enabled: true
        endpoints:
          web:
            exposure:
              base-path: /actuator            # 指定上下文路径,启用相应端点
              include: health,shutdown

      将暴露/actuator/shutdown接口,调用方式如下:

      curl -X POST 127.0.0.1:50000/actuator/shutdown
      运维层面

      确保dockerfile模版集成curl工具,否则无法使用curl命令

      FROM openjdk:8-jdk-alpine
      #构建参数
      ARG JAR_FILE
      ARG WORK_PATH="/app"
      ARG EXPOSE_PORT=8080

      #环境变量
      ENV JAVA_OPTS=""
          JAR_FILE=${JAR_FILE}

      #设置时区
      RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && echo 'Asia/Shanghai' >/etc/timezone
      RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g' /etc/apk/repositories  
          && apk add --no-cache curl
      #将maven目录的jar包拷贝到docker中,并命名为for_docker.jar
      COPY target/$JAR_FILE $WORK_PATH/


      #设置工作目录
      WORKDIR $WORK_PATH


      # 指定于外界交互的端口
      EXPOSE $EXPOSE_PORT
      # 配置容器,使其可执行化
      ENTRYPOINT exec java $JAVA_OPTS -jar $JAR_FILE

      k8s部署模版deployment.yaml

      注:经验证,java项目可省略结束回调钩子的配置

      此外,若需使用回调钩子,需保证镜像中包含curl工具,且需注意应用管理端口(50000)不能暴露到公网

      apiVersion: apps/v1
      kind: Deployment
      spec:
        template:
          spec:
            containers:
            - name: {APP_NAME}
              image: {IMAGE_URL}
              imagePullPolicy: Always
              ports:
              - containerPort: {APP_PORT}
              - containerPort: 50000
              lifecycle:
                preStop:       # 结束回调钩子
                  exec:
                    command: ["curl""-XPOST""127.0.0.1:50000/actuator/shutdown"]

      弹性伸缩

      为pod设置资源限制后,创建HPA

      apiVersion: apps/v1
      kind: Deployment
      metadata:
        name: {APP_NAME}
        labels:
          app: {APP_NAME}
      spec:
        template:
          spec:
            containers:
            - name: {APP_NAME}
              image: {IMAGE_URL}
              imagePullPolicy: Always
              resources:                     # 容器资源管理
                limits:                      # 资源限制(监控使用情况)
                  cpu: 0.5
                  memory: 1Gi
                requests:                    # 最小可用资源(灵活调度)
                  cpu: 0.15
                  memory: 300Mi
      ---
      kind: HorizontalPodAutoscaler            # 弹性伸缩控制器
      apiVersion: autoscaling/v2beta2
      metadata:
        name: {APP_NAME}
      spec:
        scaleTargetRef:
          apiVersion: apps/v1
          kind: Deployment
          name: {APP_NAME}
        minReplicas: {REPLICAS}                # 缩放范围
        maxReplicas: 6
        metrics:
          - type: Resource
            resource:
              name: cpu                        # 指定资源指标
              target:
                type: Utilization
                averageUtilization: 50

      Prometheus集成

      业务层面

      项目依赖 pom.xml



          org.springframework.boot
          spring-boot-starter-actuator


          io.micrometer
          micrometer-registry-prometheus

      定义访问端口、路径及权限 application.yaml

      management:
        server:
          port: 50000                         # 启用独立运维端口
        metrics:
          tags:
            application: ${spring.application.name}
        endpoints:
          web:
            exposure:
              base-path: /actuator            # 指定上下文路径,启用相应端点
              include: metrics,prometheus

      将暴露/actuator/metric/actuator/prometheus接口,访问方式如下:

      http://127.0.0.1:50000/actuator/metric
      http://127.0.0.1:50000/actuator/prometheus
      运维层面

      deployment.yaml

      apiVersion: apps/v1
      kind: Deployment
      spec:
        template:
          metadata:
            annotations:
              prometheus:io/port: "50000"
              prometheus.io/path: /actuator/prometheus  # 在流水线中赋值
              prometheus.io/scrape: "true"              # 基于pod的服务发现

      配置分离

      方案:通过configmap挂载外部配置文件,并指定激活环境运行

      作用:配置分离,避免敏感信息泄露;镜像复用,提高交付效率

      通过文件生成configmap

      # 通过dry-run的方式生成yaml文件
      kubectl create cm -n   --from-file=application-test.yaml --dry-run=1 -oyaml > configmap.yaml

      # 更新
      kubectl apply -f configmap.yaml

      挂载configmap并指定激活环境

      apiVersion: apps/v1
      kind: Deployment
      metadata:
        name: {APP_NAME}
        labels:
          app: {APP_NAME}
      spec:
        template:
          spec:
            containers:
            - name: {APP_NAME}
              image: {IMAGE_URL}
              imagePullPolicy: Always
              env:
                - name: SPRING_PROFILES_ACTIVE   # 指定激活环境
                  value: test
              volumeMounts:                      # 挂载configmap
              - name: conf
                mountPath: "/app/config"         # 与Dockerfile中工作目录一致
                readOnly: true
            volumes:
            - name: conf
              configMap:
                name: {APP_NAME}

      汇总配置

      业务层面

      项目依赖 pom.xml



          org.springframework.boot
          spring-boot-starter-actuator


          io.micrometer
          micrometer-registry-prometheus

      定义访问端口、路径及权限 application.yaml

      spring:
        application:
          name: project-sample
        profiles:
          active: @profileActive@
        lifecycle:
          timeout-per-shutdown-phase: 30s     # 停机过程超时时长设置30s,超过30s,直接停机

      server:
        port: 8080
        shutdown: graceful                    # 默认为IMMEDIATE,表示立即关机;GRACEFUL表示优雅关机

      management:
        server:
          port: 50000                         # 启用独立运维端口
        metrics:
          tags:
            application: ${spring.application.name}
        endpoint:                             # 开启shutdown和health端点
          shutdown:
            enabled: true
          health:
            probes:
              enabled: true
        endpoints:
          web:
            exposure:
              base-path: /actuator            # 指定上下文路径,启用相应端点
              include: health,shutdown,metrics,prometheus

      运维层面

      确保dockerfile模版集成curl工具,否则无法使用curl命令

      FROM openjdk:8-jdk-alpine
      #构建参数
      ARG JAR_FILE
      ARG WORK_PATH="/app"
      ARG EXPOSE_PORT=8080

      #环境变量
      ENV JAVA_OPTS=""
          JAR_FILE=${JAR_FILE}

      #设置时区
      RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && echo 'Asia/Shanghai' >/etc/timezone
      RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g' /etc/apk/repositories  
          && apk add --no-cache curl
      #将maven目录的jar包拷贝到docker中,并命名为for_docker.jar
      COPY target/$JAR_FILE $WORK_PATH/


      #设置工作目录
      WORKDIR $WORK_PATH


      # 指定于外界交互的端口
      EXPOSE $EXPOSE_PORT
      # 配置容器,使其可执行化
      ENTRYPOINT exec java $JAVA_OPTS -jar $JAR_FILE

      k8s部署模版deployment.yaml

      apiVersion: apps/v1
      kind: Deployment
      metadata:
        name: {APP_NAME}
        labels:
          app: {APP_NAME}
      spec:
        selector:
          matchLabels:
            app: {APP_NAME}
        replicas: {REPLICAS}                            # Pod副本数
        strategy:
          type: RollingUpdate                           # 滚动更新策略
          rollingUpdate:
            maxSurge: 1
            maxUnavailable: 0
        template:
          metadata:
            name: {APP_NAME}
            labels:
              app: {APP_NAME}
            annotations:
              timestamp: {TIMESTAMP}
              prometheus.io/port: "50000"               # 不能动态赋值
              prometheus.io/path: /actuator/prometheus
              prometheus.io/scrape: "true"              # 基于pod的服务发现
          spec:
            affinity:                                   # 设置调度策略,采取多主机/多可用区部署
              podAntiAffinity:
                preferredDuringSchedulingIgnoredDuringExecution:
                - weight: 100
                  podAffinityTerm:
                    labelSelector:
                      matchExpressions:
                      - key: app
                        operator: In
                        values:
                        - {APP_NAME}
                    topologyKey: "kubernetes.io/hostname" # 多可用区为"topology.kubernetes.io/zone"
            terminationGracePeriodSeconds: 30             # 优雅终止宽限期
            containers:
            - name: {APP_NAME}
              image: {IMAGE_URL}
              imagePullPolicy: Always
              ports:
              - containerPort: {APP_PORT}
              - name: management-port
                containerPort: 50000         # 应用管理端口
              readinessProbe:                # 就绪探针
                httpGet:
                  path: /actuator/health/readiness
                  port: management-port
                initialDelaySeconds: 30      # 延迟加载时间
                periodSeconds: 10            # 重试时间间隔
                timeoutSeconds: 1            # 超时时间设置
                successThreshold: 1          # 健康阈值
                failureThreshold: 9          # 不健康阈值
              livenessProbe:                 # 存活探针
                httpGet:
                  path: /actuator/health/liveness
                  port: management-port
                initialDelaySeconds: 30      # 延迟加载时间
                periodSeconds: 10            # 重试时间间隔
                timeoutSeconds: 1            # 超时时间设置
                successThreshold: 1          # 健康阈值
                failureThreshold: 6          # 不健康阈值
              resources:                     # 容器资源管理
                limits:                      # 资源限制(监控使用情况)
                  cpu: 0.5
                  memory: 1Gi
                requests:                    # 最小可用资源(灵活调度)
                  cpu: 0.1
                  memory: 200Mi
              env:
                - name: TZ
                  value: Asia/Shanghai
      ---
      kind: HorizontalPodAutoscaler            # 弹性伸缩控制器
      apiVersion: autoscaling/v2beta2
      metadata:
        name: {APP_NAME}
      spec:
        scaleTargetRef:
          apiVersion: apps/v1
          kind: Deployment
          name: {APP_NAME}
        minReplicas: {REPLICAS}                # 缩放范围
        maxReplicas: 6
        metrics:
          - type: Resource
            resource:
              name: cpu                        # 指定资源指标
              target:
                type: Utilization
                averageUtilization: 50

    • 偷学一波 Vue 3 !

      背景

      大家好,我是小哈~

      最近私底下准备整个前后端分离的博客项目,前端这块在技术选型上选择了 Vue 3,但是对于一个搞后端的,这块是盲点,虽然以前在中台的时候,前端组因为人手有限需要后端成员自行学会联调接口,也给咱培训了一下基础的用法(内心是拒绝的),但是也好久没再碰了,现在只有大致的印象。

      而且 Vue 3 相对于 Vue 2 又更新了一些新特性, 于是学习了一波,在这里给大家分享出来,算是入门。

      PS: 教程第一时间会发布在个站犬小哈教程上:www.quanxiaoha.com

      目录

      • 什么是 Vue ?

        • 渐进式框架

        • 组件化

      • Vue 3 环境安装

        • 第一步:安装 Node.js 环境

        • 第二步:验证是否真的安装成功了

      • 创建第一个 Vue 3 项目

        • 项目目录说明

      • 启动项目

      • 打包项目

      • 安装 VSCode

        • VSCode 简介

        • 下载安装包

        • 开始安装

        • 使用界面

      • VSCode 设置中文

        • 开始设置

        • 汉化后的界面

      • 开发 Vue 3 必备的 VSCode 插件

        • 一、Vue Language Features (Volar) 插件

        • 二、Vue 3 Snippets 插件

        • 如何安装插件?

      • 使用 VSCode 开发第一个 Vue 应用

        • 打开项目

        • 核心文件说明

      什么是 Vue ?

      Vue (发音为 /vjuː/,类似 view) 是一款用于构建用户界面的 JavaScript 渐进式框架。它基于标准 HTML、CSS 和 JavaScript 构建,并提供了一套声明式的、响应式的、组件化的编程模型,帮助你高效地开发用户界面。无论是简单还是复杂的界面,Vue 都可以轻松搞定。

      下面是一段示例代码,其功能用于实现一个简单的计数器:

      import { createApp } from 'vue'

      createApp({
        data() {
          return {
            count: 0
          }
        }
      }).mount('#app')
      "app">
        
        

      上面的示例展示了 Vue 两个核心的功能点:

      • 声明式:Vue 基于标准的 HTML 语法上做了一层拓展,我们可以通过声明式的描述 HTML 与 JavaScript 状态之间的关系,如示例中的插值语句 {{ count }}、点击事件 @click="count++"
      • 响应式渲染 :Vue 会自动跟踪 JavaScript 状态,并实时更新 Dom 元素。无需再像 JQuery 那样手动更新 Dom 元素。

      渐进式框架

      Vue 是一个功能强大框架,也是一个生态。你可以用不同的方式来使用它:

      • 无需构建步骤,渐进式增强静态的 HTML
      • 在任何页面中作为 Web Components 嵌入
      • 单页应用 (SPA)
      • 全栈 / 服务端渲染 (SSR)
      • Jamstack / 静态站点生成 (SSG)
      • 开发桌面端、移动端、WebGL,甚至是命令行终端中的界面

      怎么理解渐进式这个词?

      你可以这样理解它:Vue 非常灵活,可以渐近式的适配不同的开发场景。举个栗子,比如老项目使用的 JQuery,而我又想使用 Vue, 新建页面时,仅需引入 Vue 的库,就可以通过它来开发了,无需构建步骤。由此可见,Vue 在设计上非常注重灵活性,我们 “可以被逐步集成” 它。

      组件化

      当我们通过构建工具来创建项目时,会看到工程目录中有以 .vue 为后缀的类似 HTML 的文件,它们就是 Vue 组件,文件内部会将一个组件的逻辑(JavaScript), 模板(HTML) 和样式 (CSS)封装在一起。

      Vue 3 环境安装

      第一步:安装 Node.js 环境

      访问 Node.js 官网:https://nodejs.org/en,点击左侧的下载按钮,下载 Node.js LTS 版本的安装包:

      注意:学习 Vue 3, 你需要安装 Node.js 16.0 版本或者更高, LTS 表示该安装包是一个被长期支持的版本,可以理解成是一个稳定版本。

      下载 Node.js 安装包

      下载完成后,双击开始安装:

      Node.js 安装文件

      无脑点击下一步【Next】按钮即可,其中,需要勾选接受协议,以及自选选择安装路径,小哈这里直接使用的默认安装路径 C盘:

      安装 Node.js

      继续点击【Next】按钮, 然后进入安装:

      安装 Node.js

      等待其安装成功:

      Node.js 正在安装中

      然后点击【Finish】按钮,到这里 Node.js 就安装成功了:

      Node.js 安装完成

      第二步:验证是否真的安装成功了

      按住快捷键 win + R 输入 cmd 打开命令行,或者使用 PowerShell 等其他命令行工具,执行如下命令:

      node -v
      npm -v

      如果能够正确输出版本号,则表示 Vue 的环境搭建成功:

      确认 Node.js 环境是否安装成功

      创建第一个 Vue 3 项目

      D 盘下创建一个 vue-projects 文件夹,用于统一存放 Vue 项目,然后打开命令行,执行如下命令,进入到该文件夹中:

      cd D:vue-projects

      然后,执行初始化 Vue 项目命令:

      npm init vue@latest

      TIP: 该命令会安装并执行 create-vue, 它是 Vue 官方的项目脚手架工具。

      创建第一个 Vue 项目

      执行过程中,会提示你命名新项目,以及是否需要开启一些诸如 TypeScript 和测试支持之类的可选功能,这里如果不确定,统一敲击回车键选择 No 即可。当你看到命令行中提示 Done , 表示你已经创建好了第一个 Vue 应用。

      项目目录说明

      创建好了第一个 Vue 应用后,进入该项目文件夹,看下目录结构:

      Vue 3 应用目录结构

      解释一下目录结构以及相关文件的作用:

      • node_modules : 项目依赖包文件夹,比如通过 npm install 包名 安装的包都会放在这个目录下;
      • public : 公共资源目录,用于存放公共资源,如 favicon.ico 图标等;
      • index.html : 首页;
      • package.json : 项目描述以及依赖;
      • package-lock.json : 版本管理使用的文件;
      • README.md : 用于项目描述的 markdown 文档;
      • src : 核心文件目录,源码都放在这里面;

      进入 src 文件夹,目录如下:

      src 目录
      • assets : 静态资源目录,用于存放样式、图片、字体等;
      • components: 组件文件夹,通用的组件存放目录;
      • App.vue: 主组件,也是页面的入口文件,所有页面都是在 App.vue 下进行路由切换的;
      • main.js : 入口 Javascript 文件;

      启动项目

      进入到想要启动的项目文件夹中,执行如下命令,为项目创建依赖并执行:

      # 进入项目文件夹
      cd 
      # 安装项目所需依赖
      npm install
      # 启动项目
      npm run dev

      启动成功后,会提示项目的访问地址,如 http://localhost:5173/:

      启动 Vue 项目

      在浏览器地址栏中访问该地址,即可访问该 Vue 项目啦,整个过程还是非常简单的。

      Vue 启动页面展示

      打包项目

      首先,通过命令行进入到项目所在目录中,当需要将 Vue 项目打包发布到生产环境时,执行如下命令:

      npm run build
      打包 Vue 项目

      执行成功后,会在项目文件夹中看到多了一个 dist 文件夹:

      该文件下放置的就是编译后的静态文件,如 htmlcssjs 等相关文件:

      Vue 项目编译后的文件

      至此,该 Vue 项目就打包好了。

      安装 VSCode

      为了更高效率的开发 Vue 3,我们需要有个趁手的兵器,也就是开发工具。比较常见的如 VSCode 、Webstorm 等,但是官方推荐使用 VSCode, 那我们就通过 VSCode 来开发 Vue 3。

      VSCode 简介

      VSCode 全称 Visual Studio Code,是微软出的一款轻量级代码编辑器,它具有如下特点:

      • 开源且免费;
      • 代码智能提示、自动补全功能;
      • 可自定义配置;
      • 支持丰富的文件格式;
      • 代码调试功能强大;
      • 各种方便的快捷键;
      • 强大的插件拓展功能;

      下载安装包

      前往 VSCode 官网:https://code.visualstudio.com/ 下载对应系统的安装包,小哈这里用 Windows 系统来演示:

      官网下载 VSCode 安装包

      开始安装

      下载成功后,双击安装包开始安装 VSCode:

      VSCode 安装包

      勾选【我同意此协议】,点击下一步按钮:

      同意安装协议

      自定义安装路径,小哈这里安装在了 D 盘,可自行选择安装位置,继续点击下一步按钮:

      自定义 VSCode 安装路径

      继续点击下一步按钮:

      勾选【创建桌面快捷方式】,点击下一步:

      创建 VSCode 桌面快捷启动方式

      点击【安装】:

      开始安装 VSCode

      等待一分钟左右,即可安装成功,然后点击【完成】按钮:

      VSCode 安装完成

      使用界面

      启动成功后,即可看到如下界面,至此,VSCode 就安装成功啦~

      VSCode 界面

      VSCode 设置中文

      TIP: 汉化是可选项,针对初学者来说,全英文化的 VSCode 可能不太友好,所以,根据自己的需求来确定是否需要汉化,小哈个人推荐不要汉化,用着用着就习惯了。

      开始设置

      在 VSCode 的左侧栏,可以看到插件市场选项,如下图所示:

      VSCode 插件市场

      打开插件市场,搜索关键词【中文】,即可看到中文汉化插件,点击【Install】安装:

      VSCode 安装中文插件

      安装成功后,右下角会提示是否需要立刻重启 VSCode 来使汉化生效,点击重启:

      重启 VSCode

      汉化后的界面

      重启 VSCode 后,你就可以看到所有菜单均已被设置成中文了:

      中文汉化后的 VSCode

      开发 Vue 3 必备的 VSCode 插件

      本小节中,我们将在 VSCode 中安装上开发 Vue 3 必备的 2 个插件。

      一、Vue Language Features (Volar) 插件

      简介:这是一款专用于构建 Vue 的拓展,想要在 VSCode 上开发 Vue 3 应用,这款插件必不可少。

      Volar 插件

      二、Vue 3 Snippets 插件

      简介:Vue 3 代码自动提示和代码补全插件,提升编码效率。

      Vue 3 Snippets 插件

      如何安装插件?

      前面的VSCode 安装中文汉化插件一节中,已经详细演示了如何在 VSCode 中安装想要的插件,不清楚的小伙伴可以跳转前面小节查阅。这里就不重复讲了。

      使用 VSCode 开发第一个 Vue 应用

      前面小节中已经通过命令行创建了第一个 Vue 应用,本小节中,我们将通过 VSCode 来打开它,并通过 Vue 的双向绑定功能,实现一个简单的计数器功能。

      打开项目

      点击 VSCode 左上角菜单:文件 -> 打开文件夹,导入之前创建好的 vue-test 项目:

      TIP : 或者你也可以将项目文件夹直接拖入 VSCode 来打开项目。

      打开 Vue 项目

      导入成功后,视图如下:

      核心文件说明

      在前面创建项目小节中,我们已经了解了各个文件夹,以及文件的大致用途。这里小哈针对最核心的 3 个文件再详细说明一下,分别是:

      • index.html :首页;
      • main.js  :主 js 文件;
      • App.vue : 主组件;

      这 3 者之间的关系如下:

      依赖关系

      当打开一个 Vue 3 应用,首先先看 index.html 文件,它是首页,代码如下,这里小哈已经添加好注释说明:


      "en">
        
          "UTF-8">
          "icon" href="/favicon.ico">
          "viewport" content="width=device-width, initial-scale=1.0">
          Vite App
        
        
          
          
      "app">

          
          
        


      再来看 main.js 文件:

      import { createApp } from 'vue' // 引入 createApp 方法
      import App from './App.vue'     // 引入 App.vue 组件

      import './assets/main.css'      // 引入 main.css 样式文件

      // 创建应用,并将 App 根组件挂载到 
      "#app">
       中
      createApp(App).mount('#app')

      再看 app.vue 组件代码:






      作为初学者,为了更方便的学习,我们先将多余的代码删除掉,只保留结构,如下图所示:

      结构分为 3 个部分:

      • script : 节点中间用于放置 javascript 代码;

      • template : 节点中间用于放置 html 代码;

      • style : 节点中间用于放置 css 样式代码;






      然后,我们在 标签下添加一个

      标题:






      保存代码并刷新页面,效果如下:

      再次修改代码,添加一个用于计数的






      保存代码并刷新页面,点击按钮,可以看到当点击按钮时,会对 count 进行 +1 操作,由于该变量是个响应式的,变量数值变化后也会同步渲染到按钮上,非常简单就实现了一个双向绑定功能。

      结语

      本文中,小哈带着大家了解了 Vue, 以及上手安装了 Vue 3 的环境,最后通过官方推荐的 VSCode 开发工具开了第一个计数小项目,希望对学习 Vue 3 有兴趣的小伙伴有所帮助,后期还会持续分享 Vue 3 相关的文章,欢迎关注。

      
      

    • Controller层代码就该这么写,简洁又优雅!

      作者:gelald
      来源:juejin.cn/post/7123091045071454238

      说到 Controller,相信大家都不陌生,它可以很方便地对外提供数据接口。它的定位,我认为是「不可或缺的配角」,说它不可或缺是因为无论是传统的三层架构还是现在的COLA架构,Controller 层依旧有一席之地,说明他的必要性;说它是配角是因为 Controller 层的代码一般是不负责具体的逻辑业务逻辑实现,但是它负责接收和响应请求

      从现状看问题

      Controller 主要的工作有以下几项

      • 接收请求并解析参数
      • 调用 Service 执行具体的业务代码(可能包含参数校验)
      • 捕获业务逻辑异常做出反馈
      • 业务逻辑执行成功做出响应
      //DTO
      @Data
      public class TestDTO {
          private Integer num;
          private String type;
      }


      //Service
      @Service
      public class TestService {

          public Double service(TestDTO testDTO) throws Exception {
              if (testDTO.getNum() 0) {
                  throw new Exception("输入的数字需要大于0");
              }
              if (testDTO.getType().equals("square")) {
                  return Math.pow(testDTO.getNum(), 2);
              }
              if (testDTO.getType().equals("factorial")) {
                  double result = 1;
                  int num = testDTO.getNum();
                  while (num > 1) {
                      result = result * num;
                      num -= 1;
                  }
                  return result;
              }
              throw new Exception("未识别的算法");
          }
      }


      //Controller
      @RestController
      public class TestController {

          private TestService testService;

          @PostMapping("/test")
          public Double test(@RequestBody TestDTO testDTO) {
              try {
                  Double result = this.testService.service(testDTO);
                  return result;
              } catch (Exception e) {
                  throw new RuntimeException(e);
              }
          }

          @Autowired
          public DTOid setTestService(TestService testService) {
              this.testService = testService;
          }
      }

      如果真的按照上面所列的工作项来开发 Controller 代码会有几个问题

      1. 参数校验过多地耦合了业务代码,违背单一职责原则
      2. 可能在多个业务中都抛出同一个异常,导致代码重复
      3. 各种异常反馈和成功响应格式不统一,接口对接不友好

      改造 Controller 层逻辑

      统一返回结构

      统一返回值类型无论项目前后端是否分离都是非常必要的,方便对接接口的开发人员更加清晰地知道这个接口的调用是否成功(不能仅仅简单地看返回值是否为 null 就判断成功与否,因为有些接口的设计就是如此),使用一个状态码、状态信息就能清楚地了解接口调用情况

      //定义返回数据结构
      public interface IResult {
          Integer getCode();
          String getMessage();
      }

      //常用结果的枚举
      public enum ResultEnum implements IResult {
          SUCCESS(2001"接口调用成功"),
          VALIDATE_FAILED(2002"参数校验失败"),
          COMMON_FAILED(2003"接口调用失败"),
          FORBIDDEN(2004"没有权限访问资源");

          private Integer code;
          private String message;

          //省略get、set方法和构造方法
      }

      //统一返回数据结构
      @Data
      @NoArgsConstructor
      @AllArgsConstructor
      public class ResultT> {
          private Integer code;
          private String message;
          private T data;

          public static  Result success(T data) {
              return new Result(ResultEnum.SUCCESS.getCode(), ResultEnum.SUCCESS.getMessage(), data);
          }

          public static  Result success(String message, T data) {
              return new Result(ResultEnum.SUCCESS.getCode(), message, data);
          }

          public static Result> failed() {
              return new Result(ResultEnum.COMMON_FAILED.getCode(), ResultEnum.COMMON_FAILED.getMessage(), null);
          }

          public static Result> failed(String message) {
              return new Result(ResultEnum.COMMON_FAILED.getCode(), message, null);
          }

          public static Result> failed(IResult errorResult) {
              return new Result(errorResult.getCode(), errorResult.getMessage(), null);
          }

          public static  Result instance(Integer code, String message, T data) {
              Result result = new Result();
              result.setCode(code);
              result.setMessage(message);
              result.setData(data);
              return result;
          }
      }

      统一返回结构后,在 Controller 中就可以使用了,但是每一个 Controller 都写这么一段最终封装的逻辑,这些都是很重复的工作,所以还要继续想办法进一步处理统一返回结构

      统一包装处理

      Spring 中提供了一个类 ResponseBodyAdvice ,能帮助我们实现上述需求

      ResponseBodyAdvice 是对 Controller 返回的内容在 HttpMessageConverter 进行类型转换之前拦截,进行相应的处理操作后,再将结果返回给客户端。那这样就可以把统一包装的工作放到这个类里面。

      public interface ResponseBodyAdviceT> {
          boolean supports(MethodParameter returnType, Class extends HttpMessageConverter>> converterType);

          @Nullable
          beforeBodyWrite(@Nullable T body, MethodParameter returnType, MediaType selectedContentType, Class extends HttpMessageConverter>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response);
      }

      • supports:判断是否要交给 beforeBodyWrite 方法执行,ture:需要;false:不需要
      • beforeBodyWrite:对 response 进行具体的处理
      // 如果引入了swagger或knife4j的文档生成组件,这里需要仅扫描自己项目的包,否则文档无法正常生成
      @RestControllerAdvice(basePackages = "com.example.demo")
      public class ResponseAdvice implements ResponseBodyAdviceObject> {
          @Override
          public boolean supports(MethodParameter returnType, Class extends HttpMessageConverter>> converterType) {
              // 如果不需要进行封装的,可以添加一些校验手段,比如添加标记排除的注解
              return true;
          }
        

          @Override
          public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class extends HttpMessageConverter>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
              // 提供一定的灵活度,如果body已经被包装了,就不进行包装
              if (body instanceof Result) {
                  return body;
              }
              return Result.success(body);
          }
      }

      经过这样改造,既能实现对 Controller 返回的数据进行统一包装,又不需要对原有代码进行大量的改动

      处理 cannot be cast to java.lang.String 问题

      如果直接使用 ResponseBodyAdvice,对于一般的类型都没有问题,当处理字符串类型时,会抛出 xxx.包装类 cannot be cast to java.lang.String 的类型转换的异常

      ResponseBodyAdvice 实现类中 debug 发现,只有 String 类型的 selectedConverterType 参数值是 org.springframework.http.converter.StringHttpMessageConverter,而其他数据类型的值是 org.springframework.http.converter.json.MappingJackson2HttpMessageConverter

      • String 类型
      • 其他类型 (如 Integer 类型)

      现在问题已经较为清晰了,因为我们需要返回一个 Result 对象

      所以使用 MappingJackson2HttpMessageConverter 是可以正常转换的

      而使用 StringHttpMessageConverter 字符串转换器会导致类型转换失败

      现在处理这个问题有两种方式

      1. beforeBodyWrite 方法处进行判断,如果返回值是 String 类型就对 Result 对象手动进行转换成 JSON 字符串,另外方便前端使用,最好在 @RequestMapping 中指定 ContentType
      @RestControllerAdvice(basePackages = "com.example.demo")
      public class ResponseAdvice implements ResponseBodyAdviceObject> {
          ...
          @Override
          public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class extends HttpMessageConverter>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
              // 提供一定的灵活度,如果body已经被包装了,就不进行包装
              if (body instanceof Result) {
                  return body;
              }
              // 如果返回值是String类型,那就手动把Result对象转换成JSON字符串
              if (body instanceof String) {
                  try {
                      return this.objectMapper.writeValueAsString(Result.success(body));
                  } catch (JsonProcessingException e) {
                      throw new RuntimeException(e);
                  }
              }
              return Result.success(body);
          }
          ...
      }

      @GetMapping(value = "/returnString", produces = "application/json; charset=UTF-8")
      public String returnString() {
          return "success";
      }
      1. 修改 HttpMessageConverter 实例集合中 MappingJackson2HttpMessageConverter 的顺序。因为发生上述问题的根源所在是集合中 StringHttpMessageConverter 的顺序先于 MappingJackson2HttpMessageConverter 的,调整顺序后即可从根源上解决这个问题
      • 网上有不少做法是直接在集合中第一位添加 MappingJackson2HttpMessageConverter
      @Configuration
      public class WebConfiguration implements WebMvcConfigurer {
          
          @Override
          public void configureMessageConverters(List> converters) {
              converters.add(0new MappingJackson2HttpMessageConverter());
          }
      }

      • 诚然,这种方式可以解决问题,但其实问题的根源不是集合中缺少这一个转换器,而是转换器的顺序导致的,所以最合理的做法应该是调整 MappingJackson2HttpMessageConverter 在集合中的顺序
      @Configuration
      public class WebMvcConfiguration implements WebMvcConfigurer {

          /**
           * 交换MappingJackson2HttpMessageConverter与第一位元素
           * 让返回值类型为String的接口能正常返回包装结果
           *
           * @param converters initially an empty list of converters
           */

          @Override
          public void configureMessageConverters(List> converters) {
              for (int i = 0; i             if (converters.get(i) instanceof MappingJackson2HttpMessageConverter) {
                      MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter = (MappingJackson2HttpMessageConverter) converters.get(i);
                      converters.set(i, converters.get(0));
                      converters.set(0, mappingJackson2HttpMessageConverter);
                      break;
                  }
              }
          }
      }

      参数校验

      Java API 的规范 JSR303 定义了校验的标准 validation-api ,其中一个比较出名的实现是 hibernate validationspring validation 是对其的二次封装,常用于 SpringMVC 的参数自动校验,参数校验的代码就不需要再与业务逻辑代码进行耦合了

      @PathVariable 和 @RequestParam 参数校验

      Get 请求的参数接收一般依赖这两个注解,但是处于 url 有长度限制和代码的可维护性,超过 5 个参数尽量用实体来传参

      对 @PathVariable 和 @RequestParam 参数进行校验需要在入参声明约束的注解

      如果校验失败,会抛出 MethodArgumentNotValidException 异常

      @RestController(value = "prettyTestController")
      @RequestMapping("/pretty")
      @Validated
      public class TestController {

          private TestService testService;

          @GetMapping("/{num}")
          public Integer detail(@PathVariable("num") @Min(1) @Max(20) Integer num) {
              return num * num;
          }

          @GetMapping("/getByEmail")
          public TestDTO getByAccount(@RequestParam @NotBlank @Email String email) {
              TestDTO testDTO = new TestDTO();
              testDTO.setEmail(email);
              return testDTO;
          }

          @Autowired
          public void setTestService(TestService prettyTestService) {
              this.testService = prettyTestService;
          }
      }

      校验原理

      在 SpringMVC 中,有一个类是 RequestResponseBodyMethodProcessor ,这个类有两个作用(实际上可以从名字上得到一点启发)

      1. 用于解析 @RequestBody 标注的参数
      2. 处理 @ResponseBody 标注方法的返回值

      解析 @RequestBoyd 标注参数的方法是 resolveArgument

      public class RequestResponseBodyMethodProcessor extends AbstractMessageConverterMethodProcessor {
            /**
           * Throws MethodArgumentNotValidException if validation fails.
           * @throws HttpMessageNotReadableException if {@link RequestBody#required()}
           * is {@code true} and there is no body content or if there is no suitable
           * converter to read the content with.
           */

          @Override
          public Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
              NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory)
       throws Exception 
      {

            parameter = parameter.nestedIfOptional();
            //把请求数据封装成标注的DTO对象
            Object arg = readWithMessageConverters(webRequest, parameter, parameter.getNestedGenericParameterType());
            String name = Conventions.getVariableNameForParameter(parameter);

            if (binderFactory != null) {
              WebDataBinder binder = binderFactory.createBinder(webRequest, arg, name);
              if (arg != null) {
                //执行数据校验
                validateIfApplicable(binder, parameter);
                //如果校验不通过,就抛出MethodArgumentNotValidException异常
                //如果我们不自己捕获,那么最终会由DefaultHandlerExceptionResolver捕获处理
                if (binder.getBindingResult().hasErrors() && isBindExceptionRequired(binder, parameter)) {
                  throw new MethodArgumentNotValidException(parameter, binder.getBindingResult());
                }
              }
              if (mavContainer != null) {
                mavContainer.addAttribute(BindingResult.MODEL_KEY_PREFIX + name, binder.getBindingResult());
              }
            }

            return adaptArgumentIfNecessary(arg, parameter);
          }
      }

      public abstract class AbstractMessageConverterMethodArgumentResolver implements HandlerMethodArgumentResolver {
        /**
          * Validate the binding target if applicable.
          * 

      The default implementation checks for {@code @javax.validation.Valid},
          * Spring's {@link org.springframework.validation.annotation.Validated},
          * and custom annotations whose name starts with "Valid".
          * @param binder the DataBinder to be used
          * @param parameter the method parameter descriptor
          * @since 4.1.5
          * @see #isBindExceptionRequired
          */


         protected void validateIfApplicable(WebDataBinder binder, MethodParameter parameter) {
          //获取参数上的所有注解
            Annotation[] annotations = parameter.getParameterAnnotations();
            for (Annotation ann : annotations) {
            //如果注解中包含了@Valid、@Validated或者是名字以Valid开头的注解就进行参数校验
               Object[] validationHints = ValidationAnnotationUtils.determineValidationHints(ann);
               if (validationHints != null) {
              //实际校验逻辑,最终会调用Hibernate Validator执行真正的校验
              //所以Spring Validation是对Hibernate Validation的二次封装
                  binder.validate(validationHints);
                  break;
               }
            }
         }
      }

      @RequestBody 参数校验

      Post、Put 请求的参数推荐使用 @RequestBody 请求体参数

      对 @RequestBody 参数进行校验需要在 DTO 对象中加入校验条件后,再搭配 @Validated 即可完成自动校验

      如果校验失败,会抛出 ConstraintViolationException 异常

      //DTO
      @Data
      public class TestDTO {
          @NotBlank
          private String userName;

          @NotBlank
          @Length(min = 6, max = 20)
          private String password;

          @NotNull
          @Email
          private String email;
      }

      //Controller
      @RestController(value = "prettyTestController")
      @RequestMapping("/pretty")
      public class TestController {

          private TestService testService;

          @PostMapping("/test-validation")
          public void testValidation(@RequestBody @Validated TestDTO testDTO) {
              this.testService.save(testDTO);
          }

          @Autowired
          public void setTestService(TestService testService) {
              this.testService = testService;
          }
      }

      校验原理

      声明约束的方式,注解加到了参数上面,可以比较容易猜测到是使用了 AOP 对方法进行增强

      而实际上 Spring 也是通过 MethodValidationPostProcessor 动态注册 AOP 切面,然后使用 MethodValidationInterceptor 对切点方法进行织入增强

      public class MethodValidationPostProcessor extends AbstractBeanFactoryAwareAdvisingPostProcessor implements InitializingBean {
        
          //指定了创建切面的Bean的注解
         private Class extends Annotation> validatedAnnotationType = Validated.class;
        
          @Override
          public void afterPropertiesSet() {
              //为所有@Validated标注的Bean创建切面
              Pointcut pointcut = new AnnotationMatchingPointcut(this.validatedAnnotationType, true);
              //创建Advisor进行增强
              this.advisor = new DefaultPointcutAdvisor(pointcut, createMethodValidationAdvice(this.validator));
          }

          //创建Advice,本质就是一个方法拦截器
          protected Advice createMethodValidationAdvice(@Nullable Validator validator) {
              return (validator != null ? new MethodValidationInterceptor(validator) : new MethodValidationInterceptor());
          }
      }

      public class MethodValidationInterceptor implements MethodInterceptor {
          @Override
          public Object invoke(MethodInvocation invocation) throws Throwable {
              //无需增强的方法,直接跳过
              if (isFactoryBeanMetadataMethod(invocation.getMethod())) {
                  return invocation.proceed();
              }
            
              Class>[] groups = determineValidationGroups(invocation);
              ExecutableValidator execVal = this.validator.forExecutables();
              Method methodToValidate = invocation.getMethod();
              Set> result;
              try {
                  //方法入参校验,最终还是委托给Hibernate Validator来校验
                   //所以Spring Validation是对Hibernate Validation的二次封装
                  result = execVal.validateParameters(
                      invocation.getThis(), methodToValidate, invocation.getArguments(), groups);
              }
              catch (IllegalArgumentException ex) {
                  ...
              }
              //校验不通过抛出ConstraintViolationException异常
              if (!result.isEmpty()) {
                  throw new ConstraintViolationException(result);
              }
              //Controller方法调用
              Object returnValue = invocation.proceed();
              //下面是对返回值做校验,流程和上面大概一样
              result = execVal.validateReturnValue(invocation.getThis(), methodToValidate, returnValue, groups);
              if (!result.isEmpty()) {
                  throw new ConstraintViolationException(result);
              }
              return returnValue;
          }
      }

      自定义校验规则

      有些时候 JSR303 标准中提供的校验规则不满足复杂的业务需求,也可以自定义校验规则

      自定义校验规则需要做两件事情

      1. 自定义注解类,定义错误信息和一些其他需要的内容
      2. 注解校验器,定义判定规则
      //自定义注解类
      @Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER})
      @Retention(RetentionPolicy.RUNTIME)
      @Documented
      @Constraint(validatedBy = MobileValidator.class)
      public @interface Mobile 
      {
          /**
           * 是否允许为空
           */

          boolean required() default true;

          /**
           * 校验不通过返回的提示信息
           */

          String message() default "不是一个手机号码格式";

          /**
           * Constraint要求的属性,用于分组校验和扩展,留空就好
           */

          Class>[] groups() default {};
          Class extends Payload>[] payload() default {};
      }

      //注解校验器
      public class MobileValidator implements ConstraintValidatorMobileCharSequence> {

          private boolean required = false;

          private final Pattern pattern = Pattern.compile("^1[34578][0-9]{9}$"); // 验证手机号

          /**
           * 在验证开始前调用注解里的方法,从而获取到一些注解里的参数
           *
           * @param constraintAnnotation annotation instance for a given constraint declaration
           */

          @Override
          public void initialize(Mobile constraintAnnotation) {
              this.required = constraintAnnotation.required();
          }

          /**
           * 判断参数是否合法
           *
           * @param value   object to validate
           * @param context context in which the constraint is evaluated
           */

          @Override
          public boolean isValid(CharSequence value, ConstraintValidatorContext context) {
              if (this.required) {
                  // 验证
                  return isMobile(value);
              }
              if (StringUtils.hasText(value)) {
                  // 验证
                  return isMobile(value);
              }
              return true;
          }

          private boolean isMobile(final CharSequence str) {
              Matcher m = pattern.matcher(str);
              return m.matches();
          }
      }

      自动校验参数真的是一项非常必要、非常有意义的工作。 JSR303 提供了丰富的参数校验规则,再加上复杂业务的自定义校验规则,完全把参数校验和业务逻辑解耦开,代码更加简洁,符合单一职责原则。

      自定义异常与统一拦截异常

      原来的代码中可以看到有几个问题

      1. 抛出的异常不够具体,只是简单地把错误信息放到了 Exception 中
      2. 抛出异常后,Controller 不能具体地根据异常做出反馈
      3. 虽然做了参数自动校验,但是异常返回结构和正常返回结构不一致

      自定义异常是为了后面统一拦截异常时,对业务中的异常有更加细颗粒度的区分,拦截时针对不同的异常作出不同的响应

      而统一拦截异常的目的一个是为了可以与前面定义下来的统一包装返回结构能对应上,另一个是我们希望无论系统发生什么异常,Http 的状态码都要是 200 ,尽可能由业务来区分系统的异常

      //自定义异常
      public class ForbiddenException extends RuntimeException {
          public ForbiddenException(String message) {
              super(message);
          }
      }

      //自定义异常
      public class BusinessException extends RuntimeException {
          public BusinessException(String message) {
              super(message);
          }
      }

      //统一拦截异常
      @RestControllerAdvice(basePackages = "com.example.demo")
      public class ExceptionAdvice {

          /**
           * 捕获 {@code BusinessException} 异常
           */

          @ExceptionHandler({BusinessException.class})
          public ResulthandleBusinessException(BusinessException ex
      {
              return Result.failed(ex.getMessage());
          }

          /**
           * 捕获 {@code ForbiddenException} 异常
           */

          @ExceptionHandler({ForbiddenException.class})
          public ResulthandleForbiddenException(ForbiddenException ex
      {
              return Result.failed(ResultEnum.FORBIDDEN);
          }

          /**
           * {@code @RequestBody} 参数校验不通过时抛出的异常处理
           */

          @ExceptionHandler({MethodArgumentNotValidException.class})
          public ResulthandleMethodArgumentNotValidException(MethodArgumentNotValidException ex
      {
              BindingResult bindingResult = ex.getBindingResult();
              StringBuilder sb = new StringBuilder("校验失败:");
              for (FieldError fieldError : bindingResult.getFieldErrors()) {
                  sb.append(fieldError.getField()).append(":").append(fieldError.getDefaultMessage()).append(", ");
              }
              String msg = sb.toString();
              if (StringUtils.hasText(msg)) {
                  return Result.failed(ResultEnum.VALIDATE_FAILED.getCode(), msg);
              }
              return Result.failed(ResultEnum.VALIDATE_FAILED);
          }

          /**
           * {@code @PathVariable} 和 {@code @RequestParam} 参数校验不通过时抛出的异常处理
           */

          @ExceptionHandler({ConstraintViolationException.class})
          public ResulthandleConstraintViolationException(ConstraintViolationException ex
      {
              if (StringUtils.hasText(ex.getMessage())) {
                  return Result.failed(ResultEnum.VALIDATE_FAILED.getCode(), ex.getMessage());
              }
              return Result.failed(ResultEnum.VALIDATE_FAILED);
          }

          /**
           * 顶级异常捕获并统一处理,当其他异常无法处理时候选择使用
           */

          @ExceptionHandler({Exception.class})
          public Resulthandle(Exception ex
      {
              return Result.failed(ex.getMessage());
          }

      }

      总结

      做好了这一切改动后,可以发现 Controller 的代码变得非常简洁,可以很清楚地知道每一个参数、每一个 DTO 的校验规则,可以很明确地看到每一个 Controller 方法返回的是什么数据,也可以方便每一个异常应该如何进行反馈

      这一套操作下来后,我们能更加专注于业务逻辑的开发,代码简洁、功能完善,何乐而不为呢?

      
      

    • 这套开源系统太牛了!仅需一分钟,安装部署一套自己的 SAAS 云建站平台!

      项目介绍

      最近在逛网站的时候发现一个不错的开源项目,这个项目目前收获了 4.3K Star,觉得不错,值得拿出来和大家分享下。

      本项目系统是🔥一个可通过后台任意开通多个网站,每个网站使用自己的账号进行独立管理。让每个互联网公司都可私有化部署自己的SAAS云建站平台(延续了织梦、帝国CMS的模版方式,一台1核2G服务器可建立几万个独立网站。历经11年,不断完善,拒绝半成品!)。

      简介

      网站使用方面,延续了帝国CMS、织梦CMS的建站方式,有模版页面、模版变量、栏目绑定模版、内容管理等,用过帝国、织梦的,可快速使用!

      整体结构简介, SAAS云建站系统,可通过后台任意开通多个网站,每个网站使用自己的账号进行独立管理。让每个互联网公司都可私有化部署自己的SAAS云建站平台。

      建站服务人员,招聘一个计算机专业的大学生,懂点html、会点PS作图,就完全足够,刚毕业大学生具有认真、学习能力强、工资成本相对更省等优点,必须首选。至于后台Java开发人员、服务器运维,统统干掉不要,用本系统做网站,已经不需要服务器运维及Java开发。

      功能

      在线开通网站,无需任何操作服务器操作

      • 可通过后台(系统中的代理后台)在线开通网站

      • 用户可通过手机号+验证码方式自助开通网站(须配置短信通道购买短信验证码条数)

      域名及绑定

      • 开通的网站,系统自动分配一个二级域名,以供测试。(本系统安装时输入的域名,自动分配的二级域名就是从这个上自动分配出来的)

      • 网站可以绑定自己的顶级域名,在网站管理后台-域名设置 中,按照提示步骤进行设置、解析,即可完成绑定。

      • 如果网站想绑定多个顶级域名,可以在功能插件-多域名绑定中绑定多个。不过不建议一个网站绑定多个,多个对SEO优化不好

      模板

      • 模板采用 HTML 方式制作模板,可通过网站后台任意编写html(及js、css等)代码

      • 模板体系还包含模板变量(多个模板页中有公共的代码块,可以作为模板变量)、全局变量等,方便模板页中动态引用

      • 模板编辑时内置代码编辑器,更方便编辑书写代码

      • 内置半可视化的界面编辑(待升级完善,有垃圾代码产生,推荐用纯代码方式编辑)

      • 云端模板库百多套模板开放免费使用,是默认自带的,安装本系统后,创建一个网站,登录进入网站管理后台时,就可以看到选择模板这里

      网站访问及生成网站

      • 开源版本在网站访问时,会直接将服务器磁盘上的 html 文件拿来显示

      • 企业版在网站访问时,因为企业版采用云存储,html文件不在服务器,存在于云存储(分布式存储)上,系统会先从内存中读缓存,缓存没有再从云存储读。

      • 两者在性能、使用行上基本都差不多,无非就是后续可扩展性及安全性,企业版考虑的更多。

      • 网站做好后可以点击网站管理后台中的 生成整站 ,即可一键生成网站所有的 html 静态页面。

      • 网站访问

      安全

      • 数据、附件等都在你自己服务器或者相关华为云阿里云账户上,数据都在手里!不少老板的心里,数据自己掌握着心里头才是安全的,我们系统在这方面让你安心。

      • 系统完全独立运行,不受我们控制。我们万一哪天一不小心倒闭了,没事,您安装的私有SAAS云建站不受影响,你是独立的。(有的单位像是油田,是不开外网的,纯粹内网访问,支撑无外网环境的正常使用,足以证明其完全的独立)

      • 安防检测-网站分离。在某些场景,如政府单位,会定期进行安防检测,本系统可以将 网站访问-后端管理 完全分离独立,管理后台进行了什么设置,MQ推送通知网站访问服务器进行网站更新,而网站访问服务器,就只有固定的html、及 sitemap.xml 等访问请求可进入,从入口层就对安全进行保障。(这种的是需要我们介入进行协助部署)

      • 备份还原。可对模板进行备份及还原操作,改动某个模板时,可以先导出一个备份,如果改错了,还可以通过备份,有选择的将某个模板页进行还原回原本正常的样子。

      • 系统开源,可用于商业用途!但开源版本的我们网站管理后台左下角的标识要带着,至于所做的网站,访问看到的网站不需要放置我方任何标识。多么宽松的条件。

      快速出网站

      • 快速做网站。开通网站-登录网站管理后台-选好模板-改改文字图片-绑定域名-上线 ,你完全可以不用管服务器、模板html代码,将时间用在正确的地方。

      • 快速复制网站。内置网站模板导出导入功能,你做好的网站,可以快速复制同样的出来上线交付

      • 对系统的所有操作、网站访问、是哪个人进行的操作等,都会进行详细记录。以便有异常时可以对其分析、追踪、及精准统计(需要配合ES使用,ElasticSearch云模块价格不菲,一个月三百多)

      高效

      • 网站生成静态html页面,当打开网站时,直接显示的静态html页面,不需要服务器处理什么耗时逻辑运算。

      • 配套软件 扒网站工具 https://gitee.com/mail_osc/templatespider 看好哪个网站,自动扒下来做成模版。所见网站,皆可为我所用

      可扩展及功能定制

      • 开放式模板机制,同帝国CMS、织梦CMS的模板方式,网站想怎么显示就能怎么写html,同时有完善的模板开发辅助软件、插件、及文档。

      • 成熟的插件机制,有数十种扩展插件可直接拿来使用或看其源代码参考,同时有完善的插件开发示例及说明、二次开发文档可供参考 (wm.zvo.cn)

      部分截图

      最后,想学习这个项目的可以查看项目地址:

      • https://gitee.com/mail_osc/wangmarket