项目GitHub地址:https://github.com/pengchenyu111/CloudAlibabaTemplate
本项目使用Spring Cloud Alibaba的技术组件来进行开发,涉及到了服务拆分、服务限流降级熔断、鉴权、远程调用、短信邮件服务等,编写此项目的目的是为了以此为模板,以后编写新项目时可以直接把其中的一些模块拿来用。
本人之前为了拓宽技术栈,在B站上学习Spring Cloud Alibaba,老师讲的不错,这里推荐一下链接:https://www.bilibili.com/video/BV1nK4y1j7gL
但是学习后发现没有项目实战一切都是瞎掰,因此自己写了一个小demo。此外推荐一本书:Spring Cloud Alibaba 微服务原理与实战 其中的原理讲的很清楚了。
- 数据库:MySQL
- 持久层:Mybatis-plus
- Spring Cloud Alibaba相关:
- 服务注册与发现:Nacos
- 服务限流、降级、熔断:Sentinel
- 分布式事务:Seata
- 消息中心:Spring Coud Stream
- 消息中间件:RocketMQ
- 网关:Gateway
- 鉴权:Spring Cloud OAuth2
- 远程调用:Open Feign
- 缓存:Redis、JetCache
- 短信邮件服务:Aliyun
- 接口文档:Swagger
- 其他中间件:
- Java bean工具:Lombok
- 对象映射工具:Mapstruct
- 常用工具包:Hutool
- JSON序列化工具:Jackson
本人由于没钱,只能在VMWare上搞了台虚拟机。
硬件配置:2核4G内存,40G硬盘
软件配置:OS:Centos7
至于上述技术栈中要安装的中间件,本人选择用docker来简化安装配置过程,其详细的安装过程请参考项目文件夹下的基础软件的安装.docx,若还需要安装其他软件。请参考:https://blog.csdn.net/qq_43284141/article/details/111249765
某些子模块中的application.yml配置文件中的配置是去nacos中拉取的,这些配置在nacos_config.sql中,所以你需要配置nacos的持久化。
详细介绍请参考我的CSDN博客:https://blog.csdn.net/qq_43284141
模块名 | 功能 | 说明 |
---|---|---|
template-common | 项目的公共模块 | |
template-authorization-server | 鉴权中心 | 有些接口必须使用Token才有权访问 |
template-gateway-server | 网关 | 接口统一 入口 |
template-movie-server | 业务微服务:电影 | 业务微服务都是根据项目的具体业务编写的 |
template-general-user-server | 业务微服务:普通用户 | 业务微服务都是根据项目的具体业务编写的 |
template-sms-mail-server | 短信邮件服务 | 基于阿里云提供的接口开发 |
注:某些模块中分为api和service子模块。api中为Java Bean和feign远程调用接口,service中为具体的业务逻辑。
此模块中存放整个项目的常用配置、工具和常量等,以后每个子模块都应依赖该模块。
-
WebLogAspect
接口调用日志切面,详细记录的接口的调用url、请求类型、请求参数、返回结果、消耗时间等。
-
GlobalExceptionHandler
全局异常处理器
-
ResourceServerConfig
资源服务器配置,在这里读取公钥,并配置JWT转换器、Token仓库和需要权限认证的路径等。
有关公钥、私钥的生成与使用请参考template-authorization-server子模块中的密钥说明.txt文件。
-
SwaggerAutoConfiguration
Swagger接口文档的配置,注意配置了安全规则,那么在网页上浏览接口说明时,只有正确的token才能使用接口发送和接收数据。
-
RedisConfig
Redis的相关配置
-
MybatisPlusConfig
Mybatis-plus的相关配置,分页插件、乐观锁和ID生成器
-
OAuth2FeignConfig
feign远程调用鉴权相关配置,主要看为了传递token
-
还有几个配置,由于比较简单,这里不一一展开了
本项目所有接口的返回值都用**ResponseObject**包装;
常量中存放了如错误码等其他功能常量
-
AuthorizationServerConfig和WebSecurityConfig
这是鉴权中心最重要的两个配置类,这两个类配置了需要鉴权的路径和Token的相关配置。Token分为外部和内部Token,这是因为项目中供外部调用者使用的接口需要外部token(token中含有用户信息)来鉴权,而在当服务与服务之间相互调用时,不需要用户信息所以需要另一种token。
@Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.inMemory() // 第三方客户端的名称 .withClient(OUTSIDE_AUTH_NAME) // 第三方客户端的密钥 .secret(passwordEncoder.encode(OUTSIDE_AUTH_SECRET)) // 第三方客户端的授权范围 .scopes("all") .authorizedGrantTypes("password", "refresh_token") // token的有效期 .accessTokenValiditySeconds(ACCESS_TOKEN_VALIDITY) // refresh_token的有效期 .refreshTokenValiditySeconds(REFRESH_TOKEN_VALIDITY) .and() .withClient(INSIDE_AUTH_NAME) .secret(passwordEncoder.encode(INSIDE_AUTH_SECRET)) .authorizedGrantTypes("client_credentials") .scopes("all") .accessTokenValiditySeconds(INSIDE_TOKEN_VALIDITY); super.configure(clients); }
UserServiceDetailsServiceImpl 实现 UserDetailsService接口,在登录时获取用户的账号、密码、身份和权限等信息,用来和用户的输入进行比较。
注意:此处不是真正的登录,只是鉴权,具体登录要在比如普通用户或管理员的模块中去实现。
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
// 通过login_type区分是管理员还是普通用户登录
String loginType = requestAttributes.getRequest().getParameter("login_type");
if (StringUtils.isEmpty(loginType)) {
throw new AuthenticationServiceException("登录类型不能为null");
}
UserDetails userDetails = null;
try {
// 若通过refresh_token获取新token,则对username进行纠正
String grantType = requestAttributes.getRequest().getParameter("grant_type");
if (LoginConstant.REFRESH_TYPE.equals(grantType.toUpperCase())) {
username = adjustUsername(username, loginType);
}
switch (loginType) {
case LoginConstant.ADMIN_TYPE:
userDetails = loadSysUserByUsername(username);
break;
case LoginConstant.GENERAL_USER_TYPE:
userDetails = loadGeneralUserByUsername(username);
break;
default:
throw new AuthenticationServiceException("暂不支持的登录方式:" + loginType);
}
} catch (IncorrectResultSizeDataAccessException e) {
throw new UsernameNotFoundException("用户名" + username + "不存在");
}
return userDetails;
}
-
获取外部token
注意同时在Authorization中设置Basic Auth,填写你设置的外部访问的username和password
-
获取内部token
访问:http://127.0.0.1:9999/oauth/token?grant_type=client_credentials
注意同时在Authorization中设置Basic Auth,填写你设置的内部访问的username和password
网关是这个项目的一个重要组成部分,我们将在这个部分来做接口的访问限制,包括访问路径、限流、降级、熔断等。
在配置文件中编写了某些子模块的访问路径,以及Sentinel结合Nacos的限流降级规则:
server:
port: 80
spring:
application:
name: gateway-server
cloud:
nacos:
server-addr: 192.168.126.13:8848
discovery:
namespace: 0d70f7cc-3925-4f2a-a212-bd7053c89864
group: DEFAULT_GROUP
gateway:
discovery:
locator:
enabled: true
lower-case-service-id: true
routes:
- id: movie-server-service_router
uri: lb://movie-server-service
predicates:
- Path=/movie_detail/**
- id: general-user-server-service_router
uri: lb://general-user-server-service
predicates:
- Path=/user/**
- id: sms-mail-server-service_router
uri: lb://sms-mail-server-service
predicates:
- Path=/sms_mail/**
# Sentinel 限流与降级
sentinel:
transport:
dashboard: 192.168.126.13:8858
datasource:
# 用Nacos做规则持久化
ds1-flow.nacos:
serverAddr: 192.168.126.13:8848
namespace: 0d70f7cc-3925-4f2a-a212-bd7053c89864
groupId: DEFAULT_GROUP
dataId: gw-flow.json
ruleType: gw-flow
ds2-api-group.nacos:
serverAddr: 192.168.126.13:8848
namespace: 0d70f7cc-3925-4f2a-a212-bd7053c89864
groupId: DEFAULT_GROUP
dataId: api-group.json
ruleType: gw-api-group
ds3-degrade.nacos:
serverAddr: 192.168.126.13:8848
namespace: 0d70f7cc-3925-4f2a-a212-bd7053c89864
groupId: DEFAULT_GROUP
dataId: gw-degrade.json
ruleType: degrade
redis:
host: 192.168.126.13
port: 6379
password: Pcy90321.
Nacos中持久化的限流降级规则如下,具体每个字段的含义可以通过查看AbstractRule的实现类来了解:
gw-flow.json
[
{
"resource": "movie-server-service_router",
"resourceMode": 0 ,
"grade": 1,
"count": 5,
"intervalSec": 2,
"controlBehavior": 0,
"burst": 2,
"maxQueueingTimeoutMs": 500
},
{
"resource": "login-api-group",
"resourceMode": 1,
"grade": 1,
"count": 2,
"intervalSec": 2,
"controlBehavior": 0,
"burst": 2,
"maxQueueingTimeoutMs": 500
}
]
api-group.json
[
{
"apiName": "login-api-group",
"predicateItems": [
{
"pattern": "/user/login"
},
{
"pattern": "/admin/login"
}
]
}
]
gw-degrade.json
[
{
"resource": "movie-server-service_router",
"limitApp": "default",
"grade": 0,
"count": 500,
"timeWindow": 2,
"minRequestAmount": 5,
"slowRatioThreshold": 1.0,
"statIntervalMs": 1000
}
]
-
JwtCheckFilter
过滤出所有从网关走的且需要token访问的接口,访问时该接口携带的token必须有效才能通过网关。
该模块为短信邮件服务,此模块包括短信邮件的发送服务和发送记录详情服务。此模块中,只有发送详情服务提供feign远程调用服务,发送服务是通过消息来异步调用的。具体的发送示意如下:
调用者发送消息===>Spring cloud Stream ===>消费者接收消息===>执行本地事务(发邮件、存记录)
注意:在这里的消息都是事务消息!
@Slf4j
@RocketMQTransactionListener(txProducerGroup = MQConstant.MAIL_TX_GROUP)
public class MailTransactionListener implements RocketMQLocalTransactionListener {
@Autowired
private MailService mailService;
@Autowired
private MailSendRecordService mailSendRecordService;
@Override
public RocketMQLocalTransactionState executeLocalTransaction(Message message, Object o) {
try {
String msg = new String((byte[]) message.getPayload());
ObjectMapper objectMapper = new ObjectMapper();
MailMessage mailMessage = objectMapper.readValue(msg, MailMessage.class);
String txId = (String) message.getHeaders().get(RocketMQHeaders.TRANSACTION_ID);
// 发送短信
boolean flag = mailService.singleSendMailTo(mailMessage, txId);
return flag ? RocketMQLocalTransactionState.COMMIT : RocketMQLocalTransactionState.ROLLBACK;
} catch (JsonProcessingException e) {
log.info("Json转换出错,msg => {}", e.getMessage());
return RocketMQLocalTransactionState.ROLLBACK;
} catch (ClientException e) {
log.info("邮件接口调用出错,requestId => {},errCode => {},errMsg => {},errorDescription => {}",
e.getRequestId(), e.getErrCode(), e.getErrMsg(), e.getErrorDescription());
return RocketMQLocalTransactionState.ROLLBACK;
} catch (Exception e) {
log.info("msg => {}", e.getMessage());
return RocketMQLocalTransactionState.ROLLBACK;
}
}
@Override
public RocketMQLocalTransactionState checkLocalTransaction(Message message) {
String txId = (String) message.getHeaders().get(RocketMQHeaders.TRANSACTION_ID);
log.info("检查事务id => {}", txId);
MailSendRecord record = mailSendRecordService.queryMailSendRecordByTxId(txId);
return record == null ? RocketMQLocalTransactionState.ROLLBACK : RocketMQLocalTransactionState.COMMIT;
}
}
其中singleSendMailTo方法上使用Seata的@GlobalTransactional注解来开启事务
@GlobalTransactional
public boolean singleSendMailTo(MailMessage mailMessage, String txId) throws Exception {
// 发送邮件
MailSendRecord record = singleSendMail(mailMessage, txId);
// 存入发送记录到数据库
boolean isStore = storeMailRecord(record);
return record != null && isStore;
}
其中的ACCESS_KEY请使用你自己的阿里云的,否则本人在阿里云控制台上一旦发现您的盗取使用,我将追究您的赔偿责任!
其中的ACCESS_KEY请使用你自己的阿里云的,否则本人在阿里云控制台上一旦发现您的盗取使用,我将追究您的赔偿责任!
其中的ACCESS_KEY请使用你自己的阿里云的,否则本人在阿里云控制台上一旦发现您的盗取使用,我将追究您的赔偿责任!
邮件发送接口:
private MailSendRecord singleSendMail(MailMessage mailMessage, String txId) throws Exception {
IAcsClient client = createClient(MailConstant.REGION, MailConstant.ACCESS_KEY_ID, MailConstant.ACCESS_KEY_SECRET);
SingleSendMailRequest request = new SingleSendMailRequest();
// 发信地址
request.setAccountName(mailMessage.getAccountName());
// 0:为随机账号 1:为发信地址
request.setAddressType(1);
// 邮件标签,和阿里云上保持一致
request.setTagName(mailMessage.getTagName());
// 是否启用管理控制台中配置好回信地址(状态须验证通过),取值范围是字符串true或者false
request.setReplyToAddress(true);
// 目标地址
request.setToAddress(mailMessage.getToAddress());
// 邮件主题
request.setSubject(mailMessage.getSubject());
//如果采用byte[].toString的方式的话请确保最终转换成utf-8的格式再放入htmlbody和textbody,若编码不一致则会被当成垃圾邮件。
//注意:文本邮件的大小限制为3M,过大的文本会导致连接超时或413错误
request.setHtmlBody(mailMessage.getMailHTMLBody());
request.setTextBody(mailMessage.getMailTextBody());
// 调用阿里云接口发送邮件
SingleSendMailResponse singleSendMailResponse = client.getAcsResponse(request);
log.info("发件人 => {},收件人 => {},请求id => {}", mailMessage.getAccountName(), mailMessage.getToAddress(), singleSendMailResponse.getRequestId());
// 装配返回对象
MailSendRecord record = MailSendRecord.builder()
.accountName(mailMessage.getAccountName())
.toAddress(mailMessage.getToAddress())
.subject(mailMessage.getSubject())
.tagName(mailMessage.getTagName())
.mailHtmlBody(mailMessage.getMailHTMLBody())
.mailTextBody(mailMessage.getMailTextBody())
.sendTime(DateUtil.parse(DateUtil.now()))
.successFlag(singleSendMailResponse.getEnvId() == null ? "0" : "1")
.requestId(singleSendMailResponse.getRequestId())
.transactionId(txId)
.build();
return record;
}
短信发送接口:
@GlobalTransactional
public boolean sendVerificationTo(String phoneNumber, String txId) throws Exception {
// 发送验证码短信
Client client = createClient(SmsConstant.ACCESS_KEY_ID, SmsConstant.ACCESS_KEY_SECRET);
String verificationCode = generateVerifyCode();
SendSmsRequest sendSmsRequest = new SendSmsRequest()
.setPhoneNumbers(phoneNumber)
.setSignName(SmsConstant.SIGN_NAME)
.setTemplateCode(SmsConstant.TEMPLATE_CODE)
.setTemplateParam("{code:" + verificationCode + "}");
SendSmsResponse sendSmsResponse = client.sendSms(sendSmsRequest);
boolean isSuccess = "OK".equals(sendSmsResponse.getBody().getCode());
log.info("目标用户 => {},验证码 => {},信息发送是否发送成功 => {}", phoneNumber, verificationCode, isSuccess);
// 数据库存入记录
VerificationCodeSendRecord record = VerificationCodeSendRecord.builder()
.phoneNumber(phoneNumber)
.verificationCode(verificationCode)
.sendTime(DateUtil.parse(DateUtil.now()))
.successFlag(isSuccess ? "1" : "0")
.requestId(sendSmsResponse.getBody().getRequestId())
.transactionId(txId)
.build();
boolean isStored = verificationCodeSendRecordService.save(record);
return isSuccess && isStored;
}
第一、你得熟悉Spring Cloud的基础知识和Spring Cloud Alibaba 各个组件的使用!
第二、你得配置好运行环境,包括OS和各个组件的相关配置!如果有必要,您可以联系我,获取虚拟机的镜像文件,免得您自己去配置这些繁琐的内容(可能只需要您配置下您的网络的网关)!
第三、SQL文件已在上面给出,导入即可!
开源万岁,拥抱开源!
如果您觉得我的项目写的不错,请给我的github项目一颗小星星哦!
pengchenyu