H项目心路历程记(三)

现状

对于一个基于微服务的中台系统来说,需要以什么样的颗粒度来拆分系统。这一直是基于微服务架构的系统中绕不过去的问题。

已H项目来说,当初考量是这么划分微服务的,其中

  • 基础服务: 作用主要是负责用户,角色权限,登陆鉴权,以及一些基础主档数据的日常维护功能
  • 商品服务: 物料,销售商品,款式,平台商品等和实际商品相关的主档数据
  • 库存服务:负责管理各个库存层级库存(总库存,冻结库存,在途库存等),以及展示各种出入库单据,并向其他微服务提供出入库服务接口。
  • 订单服务: 负责接收各个公域平台的ToC订单,售后单(含发货前退款,退货,换货单等),并根据配置策略进行单据路由(分仓,分物流,Hold单,加送赠品,通知仓库发货,通知平台发货等)
  • 接口服务: 主要承揽中台服务和第三方平台间的交互处理(主要是第三方平台发起的主调通知以及回调)

上述五个服务为核心服务,是保证整个中台系统基础业务功能运行的重要组成部分。除此之外还有,

  • 采购服务:负责ToB业务线的处理
  • 财务服务:满足一些企业的财务对账以及开具电子发票的功能
  • 报表服务: 每天,每周定期从其他服务(主要是订单)
  • 会员服务:管理各类会员

等7,8个非核心服务,组成非核心业务。来构成一个完整的中台系统。

拆分策略以及缺点

当时主要考虑到整个中台系统是可以拆分出售,客户可以只选择购买自身需要的服务,而不需要的服务则无需购买服务,所以当初在拆分服务上在业务领域层面上需要更加低颗粒度;同时考虑到再业务高峰期(下午到晚上,就是各位疯狂刷淘宝,京东的时候)各个服务的负载是各不相同的,如果在高峰期对于特定服务需要进行精准扩容的话,拆分的颗粒度也需要更加细致,所以结果上导致整个系统满血运行起来要至少要十几个服务,外加一些没有云平台的中间件。整个Kubernetes环境中,至少要有十五六个Deployment,考虑到系统稳定性,一个Deployment也至少要有2到3个Pod,那整个系统至少就是50个Pod。

对于一个中大型的电商企业再服务器成本上可以负担这样一个能支持50个Pod的Kubernentes集群(考虑到需要一些中间件的云产品,当时估算最小的服务器成本都要到70K/年,一个中大型企业能面对双十一流量的服务器成本都要到300K/年以上)。所以整个系统对于中小型企业来说前期的投入负担相当大(尤其是在后疫情时代的2022年,2023年),以至于早起的好几个客户都是需要我们自己贴补服务器费用的。

复盘

现在重新复盘当初服务的拆分策略,个人觉得对于核心业务来说,相似领域的一些服务应该可以适当的聚合。比如上述的五大核心服务上中的商品服务,订单服务,库存服务三个核心服务应该可以合并在一起。首先这3个核心服务是H项目的最重要的竞争力,几乎不可能出现把这3个服务拆分出售的可能性。

而且在业务高峰期有订单服务有新订单处理时,势必会产生库存的变化,因此会调用到库存服务。三个服务之间的调用是相当频繁的,尤其是负责处理商品主档数据的商品服务,无论是通过HTTP或是其他方式的远程调用的话,都会有许多浪费的带宽。如果把三个服务整合,那么之间的调用基本上就是线程间的交互,无论是效率和可靠性上都会有更加显著的提升。

结尾

怎么拆分服务一直是个难题。虽然事后复盘的时候我的很多想法也不一定是正确,但是什么样的规模的客户需要使用什么样的拆分颗粒度,都是需要仔细考虑的。绝对不是一尘不变的。

H项目心路历程记 (二)

(书接上文)

前文已经吐槽了产品设计以及中间件上的相关问题。接下来讨论一下技术上的一些问题。

关于配置

对于一个Spring的应用来说,应用配置怎么存取其实并不能说是一个技术问题。更应该算是设计或是架构问题。哪些配置应该进入配置文件,通过微服务配置中心(例如Nacos, Apollo,Kubernetes ConfigMap等)来进行在线配置,哪些配置应该通过其他方式来配置,亦或者哪些配置项目看似是配置项其实本质上就是常量。实际上截止现在H项目的确在有些微服务上是完全没有厘清三者之间的区别,以及界限。以至于现在堆积在配置文件中的内容实在太多。多个运行环境需要统一维护的难度实在太大。

对于配置来说,理想的状态应该是下面这样的。

  1. 通过微服务配置中心来影响Spring的配置文件来实现动态配置的方案,通常会伴随Spring容器的重启(有些情况下甚至需要整个Docker容器),所以存在这里的配置项应该是随着运行环境改变才会需要改动的配置。例如:

    • 外部系统的链接信息(比如MySQL, Redis的连接信息)
    • 外部系统的认证凭证(数据库用户密码,第三方系统的密钥等)
    • 因为运行环境不同导致的不同变量 (例如消息队列的Topic,Group需要根据运行环境使用不同的名称)
  2. 通过数据库或是Redis存储的配置信息,这类配置主要是应该以业务级别的配置,可以通过页面进行修改,瞬间或是几分钟内生效,且不会造成微服务重启(无论是Spring或是Docker容器的重启)。对于这类配置有几个问题还是需要注意的。

    • 配置是否需要集中管理,每个微服务,都有自己独特的业务配置,这些业务配置是否需要集中到一个微服务统一管理,还是分散到各自的微服务进行单独管理。
    • 全局性统一管理的业务配置对外保留接口应该利用缓存有效的减少数据库访问的频次。甚至可以只使用内存而不是Redis来缓存这部分内容。如果使用内存的话则需要考虑失效时效和重新装载的机制。
  3. 无效配置,实际上很多配置说是可以修改,其实自从开始使用的那天开始就没有修改过。比如用来做分布式锁的键值。这部分需要梳理出来直接以常量的形式做在代码里。

数据清理以及归档

对于电商行业的零售业务来说每天产生的单据比如订单少做200,300条,多的是上千条。加上明细数据。几个月以后这些数据就会给MySQL的库表增加检索压力。整个H项目在这块是相当薄弱的。只依靠云平台的能力,对一些不需要留档的数据进行清理。远远没有能定期归档。

对于数据清理这块大致可以分为这几类

  • 定期可以无脑物理删除的数据,比如一些日志类数据。
  • 定期直接归档的数据,归档后即可清理的数据。
  • 业务必须处理完成后才能归档并清理的数据。例如订单数据等。

对于数据归档的时候,还需要考虑一些相关联单据数据一并归档。比如订单归档时,同时需要归档相关联的退换货单,发货单,以及操作日志等等。 归档格式基本上就是考虑单条数据以JSON格式存储,然后一个时间段的数据做成压缩包存储在OSS上,然后提供给客户下载。

而对于归档数据的检索功能,客户选择需要归档的数据,我们可以将归档的数据(每个压缩包内的所有数据)导入到一个主数据库以外的,临时文档数据库(例如MongoDB),然后针对这个临时的文档数据库进行检索。当然提供的检索项目也会远少于热数据的检索项目。这个临时文档数据库基本上保持一两天数据,定期就全部回收。

未完待续

H项目心路历程记 (一)

前言

2021年年中从前一家公司离职,经过朋友介绍到了现在公司参与了Halfling项目(以下简称H项目),并从零开始的设计开发到第一个客户交付的全部流程。

H项目是一个面向零售行业在电商时代的智能一体化中台产品。项目最早是从2021年中开始立项投入,期间经过数个版本迭代,以及2022年初的魔都封城等诸多困难。终于在2023年Q2季度完成了针对首个客户的上线。

作为从头开始全程参与项目的技术人员,经过2年多的产品研发期间有不少想吐槽和想说的。

业务设计

最初H项目是以打造一个面向零售行业业务中台为目的进行项目立项的,其中一个很重要的点就是希望除核心组件以外都可以独立售卖。所以在架构设计的使用当下最流行的微服务架构(基于Spring Boot + Kubernetes集群的解决方案), 再做业务拆分到服务的时候,进行了更细颗粒度的拆分。所以前前后后一共拆分了近20个微服务。

实际上到了2022年Q2前后,两个核心服务订单管理和库存服务实现以后发现,各个业务服务中间的耦合程度比想象中的还要精密,单个服务拆开单独售卖几乎不可能(应该说是完全不可能)。 然而由于服务拆分的过细,导致一个服务需要频繁访问其他服务获取大量数据。尤其类似订单关联数据比如商品的主档数据,会穿越好几个服务。而每个服务都会重复去获取主档数据,造成了大量的额外请求。 虽然最后在所有服务底部兜底使用同一个Redis作为缓存来减少额外的请求调用。

随着时间来到2023年产品组负责人离职后,整个H项目的方向有从行业的业务中台降级成中小企业的订单管理系统(Order Management System, OMS)。前期做的很多设计比如用户权限体系(当初需求是要精确到每个页面的每个按钮,如果下拉列表框有外调请求也需要做权限控制)等就显的很重,导致用户在使用以及配置都很不方便。而有些设计就显得稍显欠缺。 所以整个2022年Q4到2023年都是在对业务层上反复横跳,比如库存模型和商品模型的调整。这点是相当影响团队士气,同时也加剧了研发组和产品组之间的矛盾。

然后还有一个很重要的问题是多租户的态度上。早起项目是以服务中大体量客户为目的,所以设计之初并没有考虑多租户的问题。但实际项的进展过程中发现部分中大体量的客户也需要类似多租户的功能。这类客户需求与其说是需求多租户,不如说是希望有一个多租户级别的数据隔离,每个业务部门只能操作自己的业务部门的数据(数据包含商品,品牌,属性,平台店铺,订单,售后单据等),而总部依据可以查看一些汇总信息(例如销售对账单据等)。而随着2022年疫情状况严峻,很多中大客户对于内部平台升级和替换都变的更加谨慎和保守。所以当项目方向变成中小企业OMS时,多租户SaaS化的需求就更加迫切了。所以后期如何在原有系统上在扩展一套多租户SaaS化功能也是个课题。

中间件

H项目使用的中间件中比较特别的有分布式任务管理系统xxl-job,消息队列RocketMQ,这两个中间件都是第一次使用。关于这两个中间件也有不是可以谈谈的。

分布式任务管理系统 XXL-JOB

作为一个开源的分布式任务管理系统,xxl-job足够简单且相当容易上手使用,无论是基于Spring,还是原生Java做开发都是相当简单的。

而不足之处也很明显,最近1年版本迭代速度明显变慢,有些功能和BUG都有待处理的。比如想实动态创建定时任务这类功能就必须自己动手去对xxl-job-admin(调度中心)进行修改。

对于任务执行日志这块,现在xxl-job也是文件格式在执行器所在服务器上进行输出的,当执行器容器化或是运行在Kubernetes集群中的时候,当容器或是Pod重启后,就会丢失日志。这点在后期运维以及调试上还是有点麻烦的。

消息队列 RocketMQ

阿里出品的消息队列如果基于云产品来使用的话问题不大(阿里云卖的价钱也是有点黑的),但是在运维上还是有点问题的比如Topic,Group这些东西都只能人工去添加,阿里云并没有提供导入服务。全系统如果使用消息队列的场景多的话,维护这些Topic和Group还是很酸爽的。还有一点RocketMQ重复消息这一点是无法保证的。用阿里云的原话

绝大多数情况下,消息是不重复的。作为一款分布式消息中间件,在网络抖动、应用处理超时等异常情况下,无法保证消息不重复,但是能保证消息不丢失。

实际运用上,在大数据量并发的情况下,我们实际上是遇到了消息重复消费的问题。这点还是需要通过代码干预(例如增加一个唯一MsgId,对MsgId做分布式锁来解决)。

还有一点就是在旧版本SDK的默认配置下消费者的负载是有点小问题,总是有一个节点会比其他节点更容易消费到大量消息。据阿里云自己说明在新版本SDK的默认负载配置也已经修复这个问题了。

(未完待续)

AKS中重写规则踩坑小记录

前言

最近在做标准产品在不同云平台中的部署验证,有幸体验了一下微软的Azure。负责采购的运维部门这次采用了Application Gateway来搭配AKS(Azure Kubernetes Service)对外暴露服务,正好借着这个机会来体验一下Application Gateway

应用场景

  1. 域名api.demo.com指向Application Gateway的IP地址
  2. AKS内部2个Service, gateway-servicebackend-service分别需要通过Application Gateway对外暴露。
  3. /gateway/指向gateway-service, 然后/backend/指向backend-service。而且两个Service都没有context-path,所以需要做一个Rewrite重写URI到Service的根目录上。

定义重写集

打开AKS对应的应用程序网关设置 > 重写。选择添加重写集。在1. 名称和关联这个Tab上只需要填写名称这项即可(名称后面在做ingress时需要使用), 关联的传递规则不需要选择。2. 重写规则配置里添加一个重写规则,然后填上重写规则的名称,并添加条件(默认新建重写规则时,只会生成操作,不会生成条件)

条件做如下设置

  • 要检查的变量类型 : 服务器变量
  • 服务器变量: request_uri
  • 区分大小写:
  • 运算符: 等号(=)
  • 要匹配的模式: /(gateway|backend)/?(.*)

操作做如下设置

  • 重写类型: URL
  • 操作类型: 设置
  • 组件: URL路径和URL查询字符串
  • URL路径值: /{var_request_uri_2}
  • 重新计算路径映射: 不选中
  • URL查询字符串值: 留空不设值

特殊说明

操作里的URL路径值不能使用正则表达式GROUP替换组,例如$1$2之类的。Azure自己定义了一套对应的替换组命名规则。具体可以参考这个网页使用应用程序网关重写 HTTP 标头和 URL

另外一个需要注意一点,如果在条件里选择了服务器变量request_uri的时候,注意这个request_uri是完整的原始请求URI(携带了查询参数)。例如: 在请求http://api.demo.com/gateway/search?foo=bar&hello=world中,request_uri的值将为/gateway/search?foo=bar&hello=world。由于request_uri里包含了查询参数,所以在操作组件中建议勾选URL路径和URL查询字符串。如果只选择URL路径的情况下可能出现无法预期的错误。以我们上述的配置来说明。

对象URL: http://api.demo.com/gateway/search?foo=bar&hello=world

组件 URL路径和URL查询字符串 URL路径
结果 /search?foo=bar&hello=world /search?foo=bar&hello=world?foo=bar&hello=world

ACK的Ingress设置

当选择了Application Gateway作为对外暴露Service的方式时,Kubernetes集群里(kube-system命名空间里)多一个Application Gateway Ingress Controller(Azure工单时通常会简称为agic)的Deployment,所以对外暴露服务时可以像传统nginx ingress controller一样添加一个Ingress对象即可(甚至配置也和ngic大致相同,只是多了2个annotations)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
# 这里指定重写规则集(不是重写规则的名字)
appgw.ingress.kubernetes.io/rewrite-rule-set: rule-backend
# 指定说明你这里ingress的类型是agic
kubernetes.io/ingress.class: azure/application-gateway
name: backend-ingress
namespace: default
spec:
rules:
- host: api.demo.com
http:
paths:
- backend:
service:
name: gateway-service
port:
number: 8080
path: /gateway/
pathType: Prefix
- backend:
service:
name: backend-service
port:
number: 8080
path: /backend/
pathType: Prefix

总结

由于微软云这块文档有部分缺失,导致在配置这块花了一点时间去排查,甚至开了工单。总结下来Ingress的配置主要是根据请求路径路由到对应的Service,重写规则集才是实际负责根据正则来进行匹配重写。

Spring Cloud Kubernetes环境下使用Jasypt

前言

最近半年着手开始做了基于微服务的中台项目,整个项目的技术栈采用的是Java + Spring Cloud + Kubernetes + Istio

业务开放上还是相当顺利的。但是在安全审核上,运维组提出了一个简易。现在项目一些敏感配置,例如MySQL用户的密码,Redis的密码等现在都是明文保存在Kubernetes的ConfigMap中的(是的,我们并没有Nacos作为微服务的配置中心)。这样可能存在安全隐患。

首次尝试

既然有问题,那就解决问题。要给配置文件中的属性项目加密很简单,稍微Google一下,就有现成的方案了。

现在比较常用的解决方案就是集成Jasypt,然后通过jasypt-spring-boot-starter来融合进Spring。

POM包加入jasypt-spring-boot-starter

1
2
3
4
5
<dependency>
<groupId>com.github.ulisesbocchio</groupId>
<artifactId>jasypt-spring-boot-starter</artifactId>
<version>3.0.4</version>
</dependency>

Dockerfile中增加java参数

1
2
...
ENTRYPOINT ["sh","-c","java $JAVA_OPTS -jar app.jar --jasypt.encryptor.password=helloworld $PARAMS"]

在ConfigMap中添加加密属性

1
2
3
4
5
6
7
apiVersion: v1
kind: ConfigMap
metadata:
name: demo
data:
application.yaml: |-
test2: ENC(94Y7Ds3+RKraxQQlura9sDx+9yF0zDLMGMwi2TjyCFZOkkHfreRFSb6fxbyvCKs7)

利用actuator接口测试

management.endpoints.web.exposure.include属性中增加env,这样我们就可以通过调用/actuator/env来查看一下env接口返回的整个Spring 容器中所有的PropertySource。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
...
"propertySources": [
{
"name": "bootstrapProperties-configmap.demo.default",
"properties": {
"test2": {
"value": "Hello,world"
}
}
}
...
]
}

OK, 这下配置项已经加密了。问题解决了。

但是…

新的问题

自从项目集成了Jayspt以后,出现了一个奇怪的问题。每次项目试图通过修改ConfigMap的配置文件,然后试图通过spring-cloud-starter-kubernetes-fabric8-config来做自动Reload,都失败了。然而查阅应用日志,并没有出现任何异常。无奈只能打开spring-cloudjasypt-spring-bootDEBUG日志。

进过几天对日志和两边源代码的分析。终于找到了原因

原因

在Spring Boot启动时jasypt-spring-boot会将下面6种配置(并不仅限与这6种配置文件)

  • Classpath下的application.yaml
  • Classpath下的bootstrap.yaml
  • 集群里名称为${spring.cloud.kubernetes.config.name}的ConfigMap
  • 集群里名称为${spring.cloud.kubernetes.config.name}-kubernetes的ConfigMap
  • Java启动参数
  • 环境变量

转换成jasypt-spring-boot自己的PropertySource实现类EncryptableMapPropertySourceWrapper

但是如果使用Kubernetes的ConfigMap来作微服务配置中心的时候,Spring Cloud会在ConfigurationChangeDetector中查找配置类org.springframework.cloud.bootstrap.config.BootstrapPropertySource, 并依据BootstrapPropertySource的类型来判断容器内的配置与集群中ConfigMap里的配置是否有差异,来触发配置reload。

由于jasypt-spring-boot已经将所有的配置文件转型成了EncryptableMapPropertySourceWrapper, 所以ConfigurationChangeDetector无法找到BootstrapPropertySource所以会一直任务ConfigMap的里的配置没有变化,导致整个Reload失效(无论是使用polling还是event方式)

解决问题

为了保证ConfigMap变化后自动Reload的功能,所以jasypt-spring-boot不能把BootstrapPropertySource转换成EncryptableMapPropertySourceWrapper

所以我们需要设置jasypt.encryptor.skip-property-sources配置项, Classpath中的application.yaml需要增加配置

1
2
3
jasypt:
encryptor:
skip-property-sources: org.springframework.cloud.bootstrap.config.BootstrapPropertySource

skip-property-sources配置项配置后,加密项目就不能配置在ConfigMap里了,毕竟已经被我们忽略了。那么我们只能另外找一个PropertySource来存放加密项目了。

Classpath中的两个Yaml由于编译时会被Maven打包进Jar文件,会牵涉多个CI/CD多个流程显然不合适,启动参数配置项的也要影响到Docker镜像制作这个流程。所以判断下来最适合的PropertySource就是环境变量了。

环境变量增加加密项

在Kubernetes的部署Yaml中,添加加密数据项application.test.str

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: demo
name: demo
spec:
replicas: 1
selector:
matchLabels:
app: demo
template:
metadata:
labels:
app: demo
spec:
containers:
- env:
- name: TZ
value: Asia/Shanghai
- name: application.test.str
value: >-
ENC(94Y7Ds3+RKraxQQlura9sDx+9yF0zDLMGMwi2TjyCFZOkkHfreRFSb6fxbyvCKs7)
....

如果需要更加严密的加密方针的话,我们可以把环境变量的内容放进Kubernetes的Secrets中。

在ConfigMap中引用application.test.str

1
2
3
4
5
6
7
8
apiVersion: v1
kind: ConfigMap
metadata:
name: demo
data:
application.yaml: |-
test2: ENC(94Y7Ds3+RKraxQQlura9sDx+9yF0zDLMGMwi2TjyCFZOkkHfreRFSb6fxbyvCKs7)
test3: ${application.test.str}

通过actuator接口来测试

通过actuator\env接口来测试一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
...
"propertySources": [
{
"name": "bootstrapProperties-configmap.demo.default",
"properties": {
"test2": {
"value": "ENC(94Y7Ds3+RKraxQQlura9sDx+9yF0zDLMGMwi2TjyCFZOkkHfreRFSb6fxbyvCKs7)"
},
"test3": {
"value": "Hello,world"
}
}
}
...
]
}

这样ConfigMap中的配置项test3就可以通过环境变量引用并使用加密配置项了。同时修改ConfigMap依然可以触发auto reload了。这下终于算是解决了。

重学Java (一) 泛型

1. 前言

泛型编程自从 Java 5.0 中引入后已经超过15个年头了。对于现在的 Java 码农来说熟练使用泛型编程已经是家常便饭的事情了。所以本文就在不对泛型的基础使用在做说明了。 如果你还不会使用泛型的话,可以参考下面两个链接

这篇文章就简答聊一下,我实际在开发工作中很少用的到泛型方法这个知识点,以及在实际项目中有哪些东西会使用到泛型。

2. 泛型方法

在阅读代码的时候我们经常会看到下面这样的方法 (这段代码摘自 java.util.AbstractCollection)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
 public <T> T[] toArray(T[] a) {
// Estimate size of array; be prepared to see more or fewer elements
int size = size();
T[] r = a.length >= size ? a :
(T[])java.lang.reflect.Array
.newInstance(a.getClass().getComponentType(), size);
Iterator<E> it = iterator();

for (int i = 0; i < r.length; i++) {
if (! it.hasNext()) { // fewer elements than expected
if (a == r) {
r[i] = null; // null-terminate
} else if (a.length < i) {
return Arrays.copyOf(r, i);
} else {
System.arraycopy(r, 0, a, 0, i);
if (a.length > i) {
a[i] = null;
}
}
return a;
}
r[i] = (T)it.next();
}
// more elements than expected
return it.hasNext() ? finishToArray(r, it) : r;
}

那么 pulic 关键字后面的那个 <T> 就是用来标记这个方法是一个泛型方法。 那什么是泛型方法呢。

官方的解释是这样的

1
Generic methods are methods that introduce their own type parameters. This is similar to declaring a generic type, but the type parameter's scope is limited to the method where it is declared. Static and non-static generic methods are allowed, as well as generic class constructors.

通俗点来将就是将一个方法泛型化,让一个普通的类的某一个方法具有泛型功能。 如果在一个泛型类中增加一个泛型方法,那这个泛型方法就可以有一套独立于这个类的泛型类型。

通过一个简单的例子, 我们来看看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* GenericClass 这个泛型类是一个简单的套皮的 HashMap
*/
public class GenericClass<K, V> {

private Map<K, V> map = new HashMap<>();

public V put(K key, V value) {
return map.put(key, value);
}

public V get(K key) {
return map.get(key);
}

// 泛型方法 genericMethod 可以接受一个全新的、作用域只限本函数的泛型类型T
public <T> T genericMethod(T t) {
return t;
}

}

实际使用起来

1
2
3
4
5
6
GenericClass<String, Integer> map = new GenericClass<>();
// put 和 get 方法的参数必须使用定义时指定的 String 和 Integer
System.out.println(map.put("One", 1));
System.out.println(map.get("One"));
// 泛型方法 genericMethod 就可以接受一个 String 和 Integer 以外的类型
System.out.println(map.genericMethod(new Double(1.0)).getClass());

我们再来看看 JDK 中使用到泛型方法的例子。我们最常使用的泛型容器 ArrayList 中有个 toArray 方法。JDK 在它的实现中就提供了两个版本,其中一个就是泛型方法的版本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{
// 这是一个普通版本,返回一个Object的数组
public Object[] toArray() {
return Arrays.copyOf(elementData, size);
}

// 这是一个泛型方法的版本,将容器里存储的元素输出到 T[] 数组中。 其中 T 必须是 E 的父类,否则 System.arraycopy 会抛出 ArrayStoreException 异常
public <T> T[] toArray(T[] a) {
if (a.length < size)
// Make a new array of a's runtime type, but my contents:
return (T[]) Arrays.copyOf(elementData, size, a.getClass());
System.arraycopy(elementData, 0, a, 0, size);
if (a.length > size)
a[size] = null;
return a;
}
}

泛型方法总体上来说就是可以给与现有的方法实现上,增加一个更加灵活的实现可能。

3. 实战应用

在实际的项目中,对于泛型的使用,除了像倾倒垃圾一样往泛型容易里塞各种 java bean 和其他泛型对象。还能怎么使用泛型呢?

我们在实际的一些项目中,会对数据库中的一些表(多数时候是全部)先实现 CRUD (Create, Read, Update, Delete)的操作,并从这些操作中延伸出一些简单的 REST 风格的 WebAPI 接口,然后才会根据实际业务需要实现一些更复杂的业务接口。

大体上会是下面这个样子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98

// 这是一个简单的 Entity 对象
// 通常现在的 Java 应用都会使用到 Lombok 和 Spring Boot
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
@Entity
@Table(name = "user")
public class User {
@Id
private Long id;
private String username;
private String password;
}

// 然后这个是 DAO 接口继承自 spring-data 的 JpaRepository
public interface UserDao extends JpaRepository<User, Long> {
}

// 在来是一个访问 User 资源的 Service 和他的实现
public interface UserService {
List<User> findAll();
Optional<User> findById(Long id);
User save (User user)
void deleteById(Long id);
}

@Service
public class UserSerivceImpl implements UserService {
private UserDao userDao;
public UserServiceImpl(UserDao userDao) {
this.userDao = userDao;
}

@Override
public List<User> findAll() {
return this.dao.findAll();
}

@Override
public Optional<User> findById(Long id) {
return this.dao.findById(id);
}

@Override
public User save(User user) {
return this.dao.save(user);
}

@Override
public void deleteById(Long id) {
this.dao.deleteById(id);
}
}

// 最后就是 WebAPI 的接口了
@RestController
@RequestMapping("/user/")
public class UserController{
private UserService userService;
public UserController(userService userService) {
this.userService = userService;
}

@GetMapping
@ResponseBody
public List<User> fetch() {
return this.userService.findAll();
}

@GetMapping("{id}")
@ResponseBody
public User get(@PathVariable("id") Long id) {
// 由于是示例这里就不考虑没有数据的情况了
return this.userService.findById(id).get();
}

@PostMapping
@ResponseBody
public User create(@RequestBody User user) {
return this.userService.save(user);
}

@PutMapping("{id}")
@ResponseBody
public User update(@RequestBody User user) {
return this.userService.save(user);
}

@DeleteMapping("{id}")
@ResponseBody
public User delete(@PathVariable("id") Long id) {
User user = this.userService.findById(id);
this.userService.deleteById(id);
return user;
}
}

大致一个表的一套相关接口就是这个样子的。如果你的数据库中有大量表的话,而且每个表都需要提供 REST 风格的 WebAPI 接口的话,那么这将是一个相当枯燥的而又及其容易出错的工作。

为了不让这项枯燥而又容易犯错的工作占去我们宝贵的私人时间,我们可以通过泛型和继承的技巧来重构从 Service 层到 Controller 的这段代码(感谢 spring-data 提供了 JpaRepository, 让我们不至于从 DAO 层重构)

3.1 Service 层的重构

首先是 Service 接口的重构,我们 Service 层接口就是定义了一组 CRUD 的操作,我们可以将这组 CRUD 操作抽象到一个父接口,然后所有 Service 层的接口都将继承自这个父接口。而接口中出现的 Entity 和主键的类型(上例中 User 的主键 id 的类型是 Long)就可以用泛型来展现。

1
2
3
4
5
6
7
8
9
10
11
// 这里泛型表示 E 来指代 Entity, ID 用来指代 Entity 主键的类型
public interface ICrudService<E, ID> {
List<E> findAll();
Optional<E> findById(ID id);
E save(E e);
void deleteById(ID id);
}

// 然后 Service 层的接口,就可以简化成这样
public interface UserService extends ICrudService<User, Long> {
}

同样 Service 层的实现也可以使用相似的方法具体实现可以抽象到一个基类中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 相比 ICrudService 这里有多了一个泛型 T 来代表 Entity 对应的 DAO, 我们的每一个 DAO 都继承自
// spring-data 的 JpaRepository 所以,这里可以使用到泛型的边界
public abstract class AbstractCrudService<T extends JpaRepository<E, ID>, E, ID> {
private T dao;
public AbstractCrudService(T dao) {
this.dao = dao;
}

public List<E> findAll() {
return this.dao.findAll();
}

public Optional<E> findById(ID id) {
return this.dao.findById(id);
}

public E save(E e) {
return this.dao.save(e);
}

public void deleteById(ID id) {
this.dao.deleteById(id);
}
}

// 那 Service 的实现类可以简化成这样
@Service
public class UserServiceImpl extends AbstractCrudService<UserDao, User, Long> implements UserService {
public UserServiceImpl(UserDao dao) {
supper(dao);
}
}

同样我们可以通过相同的方法来对 Controller 层进行重构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// Controller 层的基类
public abstract class AbstractCrudController<T extends ICrudService<E, ID>, E, ID> {
private T service;
public AbstractCrudController(T service) {
this.service = service;
}

@GetMapping
@ResponseBody
public List<E> fetch() {
return this.service.findAll();
}

@GetMapping("{id}")
@ResponseBody
public E get(@PathVariable("id") ID id) {
// 由于是示例这里就不考虑没有数据的情况了
return this.service.findById(id).get();
}

@PostMapping
@ResponseBody
public E create(@RequestBody E e) {
return this.service.save(e);
}

@PutMapping("{id}")
@ResponseBody
public E update(@RequestBody E e) {
return this.service.save(e);
}

@DeleteMapping("{id}")
@ResponseBody
public E delete(@PathVariable("id") ID id) {
E e = this.service.findById(id).get();
this.service.deleteById(id);
return e;
}
}

// 具体的 WebAPI
@RestController
@RequestMapping("/user/")
public class UserController extends AbstractCrudController<UserService, User, Long> {
public UserController(UserService service) {
super(service);
}
}

经过重构可以消减掉 Servcie 和 Controller 中的大量重复代码,使代码更容易维护了。

4. 结尾

关于泛型就简单的说这些了,泛型作为 Java 日常开发中一个常用的知识点,其实还有很多知识点可以供我们挖掘,奈何本人才疏学浅,这么多年工作下来,只积累出来这么点内容。

文末放上示例代码的代码库:

2020年阅读总结

多灾多难的2020年已经没有几天了,整个2020年要说还真是像一记刹车,去年很多想法,计划都有因为疫情原因搁置了。平时的生活更是从宅家变成了重度宅家的状态,自然就多出了许多大段大段的连续时间,可以用来好好玩玩大作,看看小说。仔细盘点一下今年总共看了10本小说外加2本科普书籍( 2020年阅读书单
),算是自2018年以来读书最多的一个年份了。其中不乏拖了近3年的那本《西方文化中的数学》。接着就来絮叨絮叨今年看的这12本书吧。


《莱博维茨的赞歌》

第一次被安利《莱博维茨的赞歌》的机核的电台节目辐射-视觉、音乐以及文学, 当时听完节目就相当好奇,可以被一部载入史册的CRPG游戏誉为精神文本的小说到底是个什么样子的。但是当时新星版的译本几乎就是绝本了,某鱼的二手价也要已经飙到了3位数。直到今年年初在核市节目上听到了中信版译本的消息,才有机会买来拜读。

全书通过3个故事讲述了核战灭世后莱博维茨修道院从保卫文明,到重建,然后看着文明再度毁灭的故事。各种无不透露着辐射那股 War, War never changed的味道。

《占星术杀人事件》《屋顶上的小丑》《亿男》

这3本书都是2019年上海书展上买的。前两本都是岛田庄司的推理名篇,其中《占星术杀人事件》在本格派推理史上有着非凡的地位,各种被抄袭从未被超越过。《屋顶上的小丑》要是最吸引人的话就是多线叙事,每条线都掐在恰如其分的地方。《亿男》则是挂着悬疑羊头卖着人生哲理。

《神经漫游者》

威廉吉布森的蔓生三部曲之一,开创了赛博朋克的科幻流派,说实话这本书我看的很痛苦,很多地方实在读不懂,到现在我还是不明白那些无法读懂的地方到底是原著如此,还是翻译的不行,亦或者是Kindle版排版太烂了。

《神们自己》,《神的九十亿个名字》

阿瑟克拉克的中篇小说和短篇小说集,这两本书阅读体验奇佳,尤其是在读完《神经漫游者》后,虽然三本书被Amazon打在同一个包里来卖的,但是光从排版上就比《神经漫游者》高出不少,所以才会有我对后者排版问题的质疑。

《神们自己》三线叙事,讲述了2个平行宇宙中的2个物种在能源,种族存续,家庭伦理上的各种讨论和故事。 《神的九十亿个名字》作为短篇小说集收录了好几篇风格题材迥异的小说,花上十几分钟读上一个故事是相当惬意的。

《佐伊的战争》 《人类决裂》 《万物的终结》

约翰斯卡尔齐的《老人的战争》系列的后3部,前三部大概是3,4年前读的吧,在这个宇宙观里最吸引人的设定就绿皮的人类防卫军战士,以及跨种族间的政治斗争。系列的六本书相当于在这个大设定下讲的六个故事。每个故事虽说都是独立的故事,但是前后皆有关联,而且最重要的就是每本的长度都算不上很长,相当适合于一口气读完。

《西方文化中的数学》

这本书大概是从2017年开始读的,当时意气奋发觉得对于理科生出身的我,书中内容应该很好理解,没想到最后不光是文化还是数学读的都是一知半解。只能说自大了。

《一想到还有95%的问题留给人类,我就放心了》

大概是18年还是19年前后对量子物理和宇宙物理开始有了兴趣,虽然各中理论并不是很了解,但是仍然很好奇的买了这本书,全书插画相当有趣,同时使用浅显易懂的语言来解释各种复杂的物理现象,以及当今人类物理知识的边界。强烈推荐给想假装成学霸的朋友。


2021年读什么

具体读啥还没想好基本上还是以科幻和悬疑为主,但是肯定会读的应该有海明威的《丧钟为谁而鸣》和小林泰三的《醉步男》吧。

Largrange项目架构与设计回顾 (二)

Largrange项目架构与设计回顾 (一) 里面我讲了一下项目开始架构和技术选型的一些内容。这一章来聊聊业务设计上的这点事。

总体来说项目设计的时候,我们对业务模块的划分和拆解总体上来说都是遵循着高内聚,低耦合的原则来进行划分的。大部分和业务相关的服务,都还是能很好的进行功能划分的。但是有一些和具体业务关联性并不是很高的服务在界定与实现时出现了一些问题。

任务调度服务

由于平台方面会有一些控制命令下发给Android设备,而且下发的命令并不一定都是实时的,大部分都是指定一个时间来下发,所以在设计的时候就考虑到需要一个任务调度服务(以下略称cron),来处理这些下发指令以及未来可能会有的定时批处理任务的业务需求。技术选型的时候考虑到整个服务是构建在Kubernetes上的一个分布式的微服务架构,所以就没有选择Spring Scheduler,而是使用了Quarter来支撑整个cron

从技术选型上来说cron没有什么问题,但是在设计如何使用cron上还是有点问题的, 我们先来看看已推送服务为例,整个平台中cron的处理流程是怎样的。

  1. Platform 调用 cron的创建定时任务Job的接口(接口的参数为推送时使用的相关参数)
  2. cron 创建Quartz的Job和Trigger,并将Job和Trigger的Name(Quartz中Job和Trigger的唯一标识符)返回个Platform
  3. cron 在指定时间触发推送的Job,即调用Push服务的推送接口
  4. Push的推送接口调用第三方服务商的推送服务,并将第三方推送服务的调用结果返回给cron
  5. cron通过调用Platform预留的推送服务回调地址将第三方推送服务调用接口返还给Platform
  6. Platform 将第三方推送服务的调用结果留档保存,并继续业务处理

设计之初考虑到不想在cron中牵扯到具体的业务, 所以设计了一个Platform的回调接口来处理推送后的具体业务处理。虽然保证了cron尽量减少了和业务逻辑接触与数据库的访问。但是前前后后要访问集群内部的其他微服务2次,额外增加了网络开销,以及因为网络通信造成额外的通信失败风险。而且针对每种不同的定时任务,都需要额外开发一个QuartzJobBean,很难形成统一的QuartzJobBean来进行处理。今后如果还有相似项目还是需要重新考量一下如何设计一个更加完善的定时任务。

总结

暂时就想到了这些东西,今后想到啥还会继续在这里补存。

Largrange项目架构与设计回顾 (一)

项目背景

从去年年底开始一个老客户希望在他们的一个传统的机械设备(后面略称 E机关 )上外装一个Android设备。 Android设备和 E机关 之间通过串口或是RJ45接口进行数据交互,主要是Android设备获取 E机关 内部的数据,并不会通过接口来控制 E机关 。 Android设备则通过4G 来和平台交互,上报Android设备的状态数据,同时接受平台的控制。以此来实现让传统机械设备也能拥抱物联网的概念。

最后围绕着客户的需求分成了3个项目来并行推进

  1. Android设备的硬件设备的设计、选材、样机制作与量产规划
  2. 在Android设备上,进行与E机关以及平台进行交互的APP开发
  3. 用于Android设备交互的平台的架构、设计与开发

我们平台Team就负责 3.用于Android设备交互的平台 并命名Lagrange (拉格朗日) 。

设计&架构

整个平台这块不仅需要向Android设备的APP提供数据交互接口,还需要有一个供相关运营人员使用的前端Web应用,以及与云平台(主要是Aliyun) 交互的功能。所以考虑到多方面使用微服务的架构来实现整个平台端。
由于E机关的工作工况不能保证长期较的稳定的连接到4G网络,所以我们并不考虑使用Socket长连接的方式来做APP和平台之间的数据交互,要实现平台对Android设备进行反控的话,只能实现推送方式来把控制命令下发给Android设备,所以还需要有一个第三方推送服务商交互的服务。同时控制推送也有实时和非实时以及定期推送的需求,所以可能还需要一个任务调度的服务。

根据对上述业务进行梳理,我们将项目分成几个服务

  • 提供Android设备的交互接口的 App Service
  • 提供运营人员使用前端Web应用 Platform Web Service
  • 前端Web应用使用到的一些接口 Platform Service
  • 负责第三方推送服务商交互的 Push Service
  • 提供云平台鉴权用的 Auth Service
  • 用来管理任务调度的 Cron Service

由于前一个项目实施的时候没有使用容器部署,每当访问量峰值的时候,我们这些码农兼运维就各种加班,所以这次项目决定直接将服务容器化,同时选择了Kubernetes来管理容器。由于客观原因线上的Kubernetes直接购买了Aliyun的托管版Kubernetes服务。 内部的开发测试环境则使用了 Rancher 2.0 来构建Kubernetes集群。

技术选型

a. 后端服务选型

由于不考虑长连接的原因,所以在后端服务在技术选型上基本就不考虑Netty了,直接上Spring大礼包。

  • Spring Boot 开发接口
  • Spring Data 配合 JPA 来进行数据的持久化
  • Spring Cloud Kubenetes 来做数据的Config的autoreload
  • Quartz 负责处理任务调度

服务之间调用都使用HTTP服务, 服务发现也有Kubernetes的DNS机制支持。服务网格则选择了比较成熟的Istio,主要还是Aliyun的Kubernetes可以集成Istio,部署和使用都相当方便。

b. 前端Web应用选型

因为前端Web应用主要是给运营人员使用,所以我们考虑使用Single Page Application来做个前后端分离的Web应用。框架这块由于团队成员基本上没有什么前端开发经验,基本都是后台写Java的码农,所以框架选择有点随性,直接就点名了vue.js。前端控件库则用的是阿里系的Antd。

c. 数据持久层

主数据库选择了mysql,缓存用的redis。这些都是团队比较熟悉的。由于一些特殊的业务需求和使用场景,我们还加了一个mongodb来做为一个副数据库,主要存放一些特殊业务使用的数据。

d. DevOps

用了相当传统的GitLab CE 加上Jenkins的组合,实现前后端代码的自动编译,推送到私有的镜像仓库(使用Aliyun的镜像仓库服务)

最后

以上基本是项目最早做设计时候的各种考量。暂时先写这么多,过两天再回顾一下当初设计上有哪些觉得不足的地方。

阿里云Kubernetes上线踩坑记

1
2
Update:
2020-04-08 增加istio-ingressgateway高可用的设置

最近公司因为项目需要,在阿里云上部署了一个Kubernetes集群。虽然阿里云的文档说的还算细致,但是还是有些没有明确说明的细节。

1. 购买篇

申请项目预算的时候,只考虑到Worker节点,1个SLB节点以及域名和证书的预算。但是实际购买的时候发现还有许多额外的开销。

1.1 SNAT

这个和EIP一并购买,可以方便通过公网使用kubectl访问集群。关于SNAT网关至今不是很明白需要购买这个服务的意义何在,只是为了一个EIP来访问集群吗?

1.2 Ingress

这个选上了后,阿里云会给你买个SLB而且还是带公网访问的,如果你后期考虑使用Istio的话,建议你集群创建后,直接停止这个SLB,以免产生额外的费用。

1.3 日志服务

通过阿里云的日志服务来收集应用的的日志,挺好用的。但是另外收费,如果有能力的自建日志服务的可不购买。

2. Istio

阿里云的Kubernetes集群完美集成了Istio,根据向导就能很简单的部署成功。

2.1 额外的SLB

Istio的Gateway 需要绑定一个新的SLB,和Ingress的SLB不能是同一个,又是一笔额外的开销

2.2 集群外访问

这个在阿里云的Istio FAQ中有提到,按照指导很容易解决

2.2 SLB的443监听

为了方便443端口的证书绑定,我们直接删除了SLB上原有的443监听(TCP协议), 重新建了一个443监听(HTTPS协议),指向和80端口同样的虚拟服务器组。但是设置健康检查时一直出错,经过排查发现SLB健康检查发送的请求协议是HTTP 1.0的,Istio的envoy直接反悔了426(Upgrade Required)这个状态码,所以我们无奈只能把健康检查的检查返回状态改为http_4xx,这样就能通过SLB的健康检查了。

2.3 istio-ingressgateway的高可用

istio-ingressgateway要达成高可用,只需要增加通过伸缩POD就可以实现,于istio-ingressgateway对应的SLB中的虚拟服务器组也会自动增加,完全不需要进行额外的手动设定。

由于istio-ingressgateway中挂载了HPAHorizontalPodAutoscaler(简称HPA),通常三节点的集群中最小POD数只有1台,在3节点的集群中,要实现高可用,需要手动修改HPA,增加最小POD数。


基本上现在遇到了这些坑,再有在总结吧。

Your browser is out-of-date!

Update your browser to view this website correctly.&npsb;Update my browser now

×