
SpringCloud项目(学成在线)
原项目:黑马《学成在线》项目,这个项目对SpringBoot熟悉并提升至SpringCloud的同学蛮不错的,bug基本都能在评论区找到解决方法,下面是链接:
黑马程序员Java项目企业级微服务实战《学成在线》,基于SpringCloud、SpringCloudAlibaba技术栈开发,项目搭建到选课支付学习全通关_哔哩哔哩_bilibili
我的SpringCloud理论知识也是黑马入门的,看了老久了,如果直接看上面理论上有困难可以先看下面的:
2024最新SpringCloud微服务开发与实战,java黑马商城项目微服务实战开发(涵盖MybatisPlus、Docker、MQ、ES、Redis高级等)_哔哩哔哩_bilibili
当然如果你刚入门SpringBoot我不建议步子迈太大,接下来就对《学成在线》主要有价值部分进行分析:
知识点部分
后端部分
先来看整体图片,我将分模块讲解和重点标出我认为比较重要的知识点,见下图:
介绍以下各个模块的功能
xucheng-plus-auth:负责统一认证入口,配置令牌设置相关等
/**
* @description 授权服务器配置
* @author Mr.M
* @date 2022/9/26 22:25
* @version 1.0
*/
@Configuration
@EnableAuthorizationServer
public class AuthorizationServer extends AuthorizationServerConfigurerAdapter {
@Resource(name="authorizationServerTokenServicesCustom")
private AuthorizationServerTokenServices authorizationServerTokenServices;
@Autowired
private AuthenticationManager authenticationManager;
//客户端详情服务
@Override
public void configure(ClientDetailsServiceConfigurer clients)
throws Exception {
clients.inMemory()// 使用in-memory存储
.withClient("XcWebApp")// client_id
// .secret("XcWebApp")//客户端密钥
.secret(new BCryptPasswordEncoder().encode("XcWebApp"))//客户端密钥
.resourceIds("xuecheng-plus")//资源列表
.authorizedGrantTypes("authorization_code", "password","client_credentials","implicit","refresh_token")// 该client允许的授权类型authorization_code,password,refresh_token,implicit,client_credentials
.scopes("all")// 允许的授权范围
.autoApprove(false)//false跳转到授权页面
//客户端接收授权码的重定向地址
.redirectUris("http://www.51xuecheng.cn")
;
}
//令牌端点的访问配置
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
endpoints
.authenticationManager(authenticationManager)//认证管理器
.tokenServices(authorizationServerTokenServices)//令牌管理服务
.allowedTokenEndpointRequestMethods(HttpMethod.POST);
}
//令牌端点的安全配置
@Override
public void configure(AuthorizationServerSecurityConfigurer security){
security
.tokenKeyAccess("permitAll()") //oauth/token_key是公开
.checkTokenAccess("permitAll()") //oauth/check_token公开
.allowFormAuthenticationForClients() //表单认证(申请令牌)
;
}
}
上述代码是定义了一个Spring Security OAuth2授权服务器配置类,OAuth介绍以及使用原因:
微信扫码认证,这是一种第三方认证的方式,这种认证方式是基于OAuth2协议实现,
OAUTH协议为用户资源的授权提供了一个安全的、开放而又简易的标准。同时,任何第三方都可以使用OAUTH认证服务,任何服务提供商都可以实现自身的OAUTH认证服务,因而OAUTH是开放的。业界提供了OAUTH的多种实现如PHP、JavaScript,Java,Ruby等各种语言开发包,大大节约了程序员的时间,因而OAUTH是简易的。互联网很多服务如Open API,很多大公司如Google,Yahoo,Microsoft等都提供了OAUTH认证服务,这些都足以说明OAUTH标准逐渐成为开放资源授权的标准。
Oauth2是一个标准的开放的授权协议,应用程序可以根据自己的要求去使用Oauth2,本项目使用Oauth2实现如下目标:
1、学成在线访问第三方系统的资源。
本项目要接入微信扫码登录所以本项目要使用OAuth2协议访问微信中的用户信息。
2、外部系统访问学成在线的资源 。
同样当第三方系统想要访问学成在线网站的资源也可以基于OAuth2协议。
3、学成在线前端(客户端) 访问学成在线微服务的资源。
本项目是前后端分离架构,前端访问微服务资源也可以基于OAuth2协议进行认证。
例如下图
简单来说,OAth2协议是用来解决授权问题的,就和之前的jwt作用差不多,不过它安全性更好,例如上图登陆时不将隐私信息直接告诉服务商,而是通过微信认证后而授权服务商。
/**
* @author Mr.M
* @version 1.0
* @description 安全管理配置
* @date 2022/9/26 20:53
*/
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true,prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
//配置用户信息服务
// @Bean
// public UserDetailsService userDetailsService() {
// //这里配置用户信息,这里暂时使用这种方式将用户存储在内存中
// InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
// manager.createUser(User.withUsername("zhangsan").password("123").authorities("p1").build());
// manager.createUser(User.withUsername("lisi").password("456").authorities("p2").build());
// return manager;
// }
@Bean
public PasswordEncoder passwordEncoder() {
// //密码为明文方式
// return NoOpPasswordEncoder.getInstance();
return new BCryptPasswordEncoder();
}
@Autowired
DaoAuthenticationProviderCustom daoAuthenticationProviderCustom;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.authenticationProvider(daoAuthenticationProviderCustom);
}
//配置安全拦截机制
@Override
protected void configure(HttpSecurity http) throws Exception {
/*这行代码是用来禁用Spring Security中的CSRF(跨站请求伪造)保护的。
CSRF是一种攻击方式,攻击者诱导受害者访问一个包含恶意请求的页面,
从而在受害者的不知情下执行恶意操作。
在Spring Security中,默认情况下会启用CSRF保护,以防止这种攻击。
但是,在某些情况下,你可能需要禁用CSRF保护*/
// http.csrf().disable()
// .authorizeRequests()
// .antMatchers("/r/**").authenticated()//访问/r开始的请求需要认证通过
// .anyRequest().permitAll()//其它请求全部放行
// .and()
// .formLogin().successForwardUrl("/login-success");//登录成功跳转到/login-success
http
.csrf().disable()
.authorizeRequests()
.anyRequest().permitAll()
.and()
.formLogin()
.successForwardUrl("/login-success");
}
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
重写了SpringSecurity
PasswordEncoder passwordEncoder()
:配置密码编码器。这里使用
BCryptPasswordEncoder
,这是一种强哈希算法,用于密码存储和验证。注释掉的
NoOpPasswordEncoder
是明文密码编码器,不推荐在生产环境中使用。
DaoAuthenticationProviderCustom daoAuthenticationProviderCustom
:注入自定义的
DaoAuthenticationProvider
,用于自定义认证逻辑。
configure(AuthenticationManagerBuilder auth)
:配置认证管理器,将自定义的认证提供者添加到认证管理器中。
configure(HttpSecurity http)
:配置HTTP安全,包括请求的授权规则和登录配置。
注释掉的代码禁用了CSRF保护,并配置了访问
/r/**
路径需要认证,其它请求全部放行,登录成功后跳转到/login-success
。现在的配置是禁用CSRF保护,允许所有请求,登录成功后跳转到
/login-success
。
authenticationManagerBean()
:返回
AuthenticationManager
的Bean,这是Spring Security的核心组件,用于处理认证请求。
@Slf4j
@Controller
public class WxLoginController {
@Autowired
WxAuthService wxAuthService;
@RequestMapping("/wxLogin")
public String wxLogin(String code, String state) throws IOException {
log.debug("微信扫码回调,code:{},state:{}",code,state);
//远程调用请求微信申请令牌,拿到令牌查询用户信息,将用户信息写入本项目数据库
XcUser xcUser = wxAuthService.wxAuth(code);
if(xcUser==null){
return "redirect:http://www.51xuecheng.cn/error.html";
}
String username = xcUser.getUsername();
return "redirect:http://www.51xuecheng.cn/sign.html?username="+username+"&authType=wx";
}
}
微信登录login如上
@FeignClient(value = "checkcode",fallbackFactory = CheckCodeClientFactory.class)
@RequestMapping("/checkcode")
public interface CheckCodeClient {
@PostMapping(value = "/verify")
public Boolean verify(@RequestParam("key") String key,@RequestParam("code") String code);
}
远程调用其他服务
@Slf4j
@Component
public class CheckCodeClientFactory implements FallbackFactory<CheckCodeClient> {
@Override
public CheckCodeClient create(Throwable throwable) {
return new CheckCodeClient() {
@Override
public Boolean verify(String key, String code) {
log.debug("调用验证码服务熔断异常:{}", throwable.getMessage());
return null;
}
};
}
}
调用失败执行的熔断方法
除此之外比较重要的还有一个service多个serviceImpl实现时的调用问题(Service注解后写别名就行),其他的细枝末节不重要。
xucheng-plus-base:负责通用类,依赖管理
@Configuration
public class LocalDateTimeConfig {
/*
* 序列化内容
* LocalDateTime -> String
* 服务端返回给客户端内容
* */
@Bean
public LocalDateTimeSerializer localDateTimeSerializer() {
return new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}
/*
* 反序列化内容
* String -> LocalDateTime
* 客户端传入服务端数据
* */
@Bean
public LocalDateTimeDeserializer localDateTimeDeserializer() {
return new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}
//long转string避免精度损失
@Bean
public ObjectMapper jacksonObjectMapper(Jackson2ObjectMapperBuilder builder) {
ObjectMapper objectMapper = builder.createXmlMapper(false).build();
//忽略value为null 时 key的输出
objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
SimpleModule module = new SimpleModule();
module.addSerializer(Long.class, ToStringSerializer.instance);
module.addSerializer(Long.TYPE, ToStringSerializer.instance);
objectMapper.registerModule(module);
return objectMapper;
}
// 配置
@Bean
public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilderCustomizer() {
return builder -> {
builder.serializerByType(LocalDateTime.class, localDateTimeSerializer());
builder.deserializerByType(LocalDateTime.class, localDateTimeDeserializer());
};
}
}
处理精度问题和时间转换问题,顺便设置一些util和exception类型,通用组件pom方便后续调用时直接
xucheng-plus-checkcode:负责验证相关实现
/**
* Kaptcha图片验证码配置类
* @author 攀博课堂(www.pbteach.com)
* @version 1.0
**/
@Configuration
public class KaptchaConfig {
//图片验证码生成器,使用开源的kaptcha
@Bean
public DefaultKaptcha producer() {
Properties properties = new Properties();
properties.put("kaptcha.border", "no");
properties.put("kaptcha.textproducer.font.color", "black");
properties.put("kaptcha.textproducer.char.space", "10");
properties.put("kaptcha.textproducer.char.length","4");
properties.put("kaptcha.image.height","34");
properties.put("kaptcha.image.width","138");
properties.put("kaptcha.textproducer.font.size","25");
properties.put("kaptcha.noise.impl","com.google.code.kaptcha.impl.NoNoise");
Config config = new Config(properties);
DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
defaultKaptcha.setConfig(config);
return defaultKaptcha;
}
}
验证码图片生成
@Component
public class PhoneMessageUtils {
@Autowired
RedisCheckCodeStore redisCheckCodeStore;
public void SendPhoneMessage(String phone, String code) {
if(StringUtils.isEmpty(phone)||StringUtils.isEmpty(code)){
throw new RuntimeException("手机号或验证码为空");
}
Config config = new Config()
// 您的AccessKey ID
.setAccessKeyId(StaticParam.accessKeyId)
// 您的AccessKey Secret (这两个还不知道的去我前两次关于阿里云的有教程哪里找)
.setAccessKeySecret(StaticParam.accessKeySecret);
config.endpoint = "dysmsapi.aliyuncs.com";
Client client = null;
try {
client = new Client(config);
SendSmsRequest request = new SendSmsRequest();
request.setSignName(StaticParam.SignName);//签名名称
request.setTemplateCode(StaticParam.TemplateCode);//模版Code
request.setPhoneNumbers(phone);//电话号码
request.setTemplateParam("{\"code\":\""+code+"\"}");//模版参数
SendSmsResponse response = client.sendSms(request);
redisCheckCodeStore.set(phone,code,60);
System.out.println("发送成功:"+new Gson().toJson(response));
} catch (Exception e) {
e.printStackTrace();
}
}
}
向手机发送验证邮件信息
@Component
public class EmailMessageUtils {
@Autowired
JavaMailSender sender;
@Autowired
RedisCheckCodeStore redisCheckCodeStore;
public void SentMessage(String mailbox, String code){
//SimpleMailMessage是一个比较简易的邮件封装,支持设置一些比较简单内容
SimpleMailMessage message = new SimpleMailMessage();
//设置邮件标题
message.setSubject("在线学习系统注册验证码");
//设置邮件内容
//拿到验证码
// String code=redisCheckCodeStore.get(mailbox);
message.setText("尊敬的 在线学习系统用户:\n" +
"\n" +
"我们收到了一项请求,要求通过您的电子邮件地址访问您的邮箱:"+mailbox+"。您的验证码为:\n" +code+
"\n" +
"如果您并未请求此验证码,则可能是他人正在尝试访问此邮箱。请勿将此验证码转发给或提供给任何人。\n" +
"\n" +
"此致\n" +
"\n" +
"徐星海管理员敬上");
//设置邮件发送给谁,可以多个,这里就发给你的QQ邮箱
message.setTo(mailbox);
//邮件发送者,这里要与配置文件中的保持一致
message.setFrom("haoxie80769@163.com");
//OK,万事俱备只欠发送
sender.send(message);
// storageRedis(mailbox,code);
redisCheckCodeStore.set(mailbox, code, 60);
}
}
向邮箱发验证邮件
xucheng-plus-content:负责课程内容管理
@ApiOperation("课程分页查询接口")
// @PreAuthorize("hasAuthority('xc_teachmanager_course_list')")//指定权限标识符,拥有此权限才可以访问此方法
@PostMapping("/course/list")
public PageResult<CourseBase> list(PageParams pageParams, @RequestBody(required = false) QueryCourseParamsDto queryCourseParams){
SecurityUtil.XcUser user = SecurityUtil.getUser();
//用户所属机构id
Long companyId = null;
if(StringUtils.isNotEmpty(user.getCompanyId())){
companyId = Long.parseLong(user.getCompanyId());
}
PageResult<CourseBase> courseBasePageResult = courseBaseInfoService.queryCourseBaseList(companyId,pageParams, queryCourseParams);
return courseBasePageResult;
}
基操的分页查询
/**
* xxl-job config
*
* @author xuxueli 2017-04-28
*/
@Configuration
public class XxlJobConfig {
private Logger logger = LoggerFactory.getLogger(XxlJobConfig.class);
@Value("${xxl.job.admin.addresses}")
private String adminAddresses;
@Value("${xxl.job.accessToken}")
private String accessToken;
@Value("${xxl.job.executor.appname}")
private String appname;
@Value("${xxl.job.executor.address}")
private String address;
@Value("${xxl.job.executor.ip}")
private String ip;
@Value("${xxl.job.executor.port}")
private int port;
@Value("${xxl.job.executor.logpath}")
private String logPath;
@Value("${xxl.job.executor.logretentiondays}")
private int logRetentionDays;
@Bean
public XxlJobSpringExecutor xxlJobExecutor() {
logger.info(">>>>>>>>>>> xxl-job config init.");
XxlJobSpringExecutor xxlJobSpringExecutor = new XxlJobSpringExecutor();
xxlJobSpringExecutor.setAdminAddresses(adminAddresses);
xxlJobSpringExecutor.setAppname(appname);
xxlJobSpringExecutor.setAddress(address);
xxlJobSpringExecutor.setIp(ip);
xxlJobSpringExecutor.setPort(port);
xxlJobSpringExecutor.setAccessToken(accessToken);
xxlJobSpringExecutor.setLogPath(logPath);
xxlJobSpringExecutor.setLogRetentionDays(logRetentionDays);
return xxlJobSpringExecutor;
}
/**
* 针对多网卡、容器内部署等情况,可借助 "spring-cloud-commons" 提供的 "InetUtils" 组件灵活定制注册IP;
*
* 1、引入依赖:
* <dependency>
* <groupId>org.springframework.cloud</groupId>
* <artifactId>spring-cloud-commons</artifactId>
* <version>${version}</version>
* </dependency>
*
* 2、配置文件,或者容器启动变量
* spring.cloud.inetutils.preferred-networks: 'xxx.xxx.xxx.'
*
* 3、获取IP
* String ip_ = inetUtils.findFirstNonLoopbackHostInfo().getIpAddress();
*/
}
比较重要的xxl-job,可以设置定时任务(执行器,java代码实现也可以,但麻烦且难以控制),值得深入学习
以下是控制面板
<select id="selectTreeNodes" resultType="com.xuecheng.content.model.dto.CourseCategoryTreeDto" parameterType="string">
with recursive t1 as (
select * from course_category p where id= #{id}
union all
select t.* from course_category t inner join t1 on t1.id = t.parentid
)
select * from t1 order by t1.id, t1.orderby
</select>
树形递归查询,或许下次问ai好了,脑壳痛...
xucheng-plus-gateway:负责网关认证(看门的)
/**
* @author Mr.M
* @version 1.0
* @description 网关认证过虑器
* @date 2022/9/27 12:10
*/
@Component
@Slf4j
public class GatewayAuthFilter implements GlobalFilter, Ordered {
//白名单
private static List<String> whitelist = null;
static {
//加载白名单
try (
InputStream resourceAsStream = GatewayAuthFilter.class.getResourceAsStream("/security-whitelist.properties");
) {
Properties properties = new Properties();
properties.load(resourceAsStream);
Set<String> strings = properties.stringPropertyNames();
whitelist= new ArrayList<>(strings);
} catch (Exception e) {
log.error("加载/security-whitelist.properties出错:{}",e.getMessage());
e.printStackTrace();
}
}
@Autowired
private TokenStore tokenStore;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
//请求的url
String requestUrl = exchange.getRequest().getPath().value();
AntPathMatcher pathMatcher = new AntPathMatcher();
//白名单放行
for (String url : whitelist) {
if (pathMatcher.match(url, requestUrl)) {
return chain.filter(exchange);
}
}
//检查token是否存在
String token = getToken(exchange);
if (StringUtils.isBlank(token)) {
return buildReturnMono("没有认证",exchange);
}
//判断是否是有效的token
OAuth2AccessToken oAuth2AccessToken;
try {
oAuth2AccessToken = tokenStore.readAccessToken(token);
boolean expired = oAuth2AccessToken.isExpired();
if (expired) {
return buildReturnMono("认证令牌已过期",exchange);
}
return chain.filter(exchange);
} catch (InvalidTokenException e) {
log.info("认证令牌无效: {}", token);
return buildReturnMono("认证令牌无效",exchange);
}
}
/**
* 获取token
*/
private String getToken(ServerWebExchange exchange) {
String tokenStr = exchange.getRequest().getHeaders().getFirst("Authorization");
if (StringUtils.isBlank(tokenStr)) {
return null;
}
String token = tokenStr.split(" ")[1];
if (StringUtils.isBlank(token)) {
return null;
}
return token;
}
private Mono<Void> buildReturnMono(String error, ServerWebExchange exchange) {
ServerHttpResponse response = exchange.getResponse();
String jsonString = JSON.toJSONString(new RestErrorResponse(error));
byte[] bits = jsonString.getBytes(StandardCharsets.UTF_8);
DataBuffer buffer = response.bufferFactory().wrap(bits);
response.setStatusCode(HttpStatus.UNAUTHORIZED);
response.getHeaders().add("Content-Type", "application/json;charset=UTF-8");
return response.writeWith(Mono.just(buffer));
}
@Override
public int getOrder() {
return 0;
}
}
负责配合SpringSecurity和token检查权限
xucheng-plus-generator:负责前端的一些实现和类初始化
这个模块还看不太懂,但没什么相关的重要的技术相关类
xucheng-plus-learning:负责选课学习功能实现
@Override
public RestResponse<String> getVideo(String userId, Long courseId, Long teachplanId, String mediaId) {
//查询课程信息
CoursePublish coursepublish = contentServiceClient.getCoursepublish(courseId);
//判断如果为null不再继续
if(coursepublish == null){
return RestResponse.validfail("课程不存在");
}
//远程调用内容管理服务根据课程计划id(teachplanId)去查询课程计划信息,如果is_preview的值为1表示支持试学
//也可以从coursepublish对象中解析出课程计划信息去判断是否支持试学
//todo:如果支持试学调用媒资服务查询视频的播放地址,返回
//用户已登录
if(StringUtils.isNotEmpty(userId)){
//获取学习资格
XcCourseTablesDto learningStatus = myCourseTablesService.getLearningStatus(userId, courseId);
//学习资格,[{"code":"702001","desc":"正常学习"},{"code":"702002","desc":"没有选课或选课后没有支付"},{"code":"702003","desc":"已过期需要申请续期或重新支付"}]
String learnStatus = learningStatus.getLearnStatus();
if("702002".equals(learnStatus)){
return RestResponse.validfail("无法学习,因为没有选课或选课后没有支付");
}else if("702003".equals(learnStatus)){
return RestResponse.validfail("已过期需要申请续期或重新支付");
}else{
//有资格学习,要返回视频的播放地址
//程调用媒资获取视频播放地址
RestResponse<String> playUrlByMediaId = mediaServiceClient.getPlayUrlByMediaId(mediaId);
return playUrlByMediaId;
}
}
//如果用户没有登录
//取出课程的收费规则
String charge = coursepublish.getCharge();
if("201000".equals(charge)){
//有资格学习,要返回视频的播放地址
//远程调用媒资获取视频播放地址
RestResponse<String> playUrlByMediaId = mediaServiceClient.getPlayUrlByMediaId(mediaId);
return playUrlByMediaId;
}
return RestResponse.validfail("课程需要购买");
}
}
获取视频(实际获取的是视频的网址)
xucheng-plus-media:负责媒资的管理
这是比较最重要的一个模块,也是当初这个项目比较吸引我的地方,其实本作者之前苦阿里云对象存储OSS久矣,三个月的试用期太短了,包括还有这个项目之前的发送手机验证码的阿里云服务(每月100条,连续3个月),好贵(穷学生一个),接下来介绍一下分布式文件系统:
要理解分布式文件系统首先了解什么是文件系统:
文件系统是负责管理和存储文件的系统软件,操作系统通过文件系统提供的接口去存取文件,用户通过操作系统访问磁盘上的文件。
下图指示了文件系统所处的位置:
常见的文件系统:FAT16/FAT32、NTFS、HFS、UFS、APFS、XFS、Ext4等 。
现在有个问题,一此短视频平台拥有大量的视频、图片,这些视频文件、图片文件该如何存储呢?如何存储可以满足互联网上海量用 户的浏览。
今天讲的分布式文件系统就是海量用户查阅海量文件的方案。
我们阅读百度百科去理解分布式文件系统的定义:
通过概念可以简单理解为:一个计算机无法存储海量的文件,通过网络将若干计算机组织起来共同去存储海量的文件,去接收海量用户的请求,这些组织起来的计算机通过网络进行通信,如下图:
好处:
1、一台计算机的文件系统处理能力扩充到多台计算机同时处理。
2、一台计算机挂了还有另外副本计算机提供数据。
3、每台计算机可以放在不同的地域,这样用户就可以就近访问,提高访问速度。
市面上有哪些分布式文件系统的产品呢?
1、NFS
特点:
1)在客户端上映射NFS服务器的驱动器。
2)客户端通过网络访问NFS服务器的硬盘完全透明。
2、GFS
1)GFS采用主从结构,一个GFS集群由一个master和大量的chunkserver组成。
2)master存储了数据文件的元数据,一个文件被分成了若干块存储在多个chunkserver中。
3)用户从master中获取数据元信息,向chunkserver存储数据。
3、HDFS
HDFS,是Hadoop Distributed File System的简称,是Hadoop抽象文件系统的一种实现。HDFS是一个高度容错性的系统,适合部署在廉价的机器上。HDFS能提供高吞吐量的数据访问,非常适合大规模数据集上的应用。 HDFS的文件分布在集群机器上,同时提供副本进行容错及可靠性保证。例如客户端写入读取文件的直接操作都是分布在集群各个机器上的,没有单点性能压力。
1)HDFS采用主从结构,一个HDFS集群由一个名称结点和若干数据结点组成。
2) 名称结点存储数据的元信息,一个完整的数据文件分成若干块存储在数据结点。
3)客户端从名称结点获取数据的元信息及数据分块的信息,得到信息客户端即可从数据块来存取数据。
本项目采用MinIO构建分布式文件系统,MinIO 是一个非常轻量的服务,可以很简单的和其他应用的结合使用,它兼容亚马逊 S3 云存储服务接口,非常适合于存储大容量非结构化的数据,例如图片、视频、日志文件、备份数据和容器/虚拟机镜像等。
它一大特点就是轻量,使用简单,功能强大,支持各种平台,单个文件最大5TB,兼容 Amazon S3接口,提供了 Java、Python、GO等多版本SDK支持。
官网:https://min.io
中文:https://www.minio.org.cn/,http://docs.minio.org.cn/docs/
MinIO集群采用去中心化共享架构,每个结点是对等关系,通过Nginx可对MinIO进行负载均衡访问。
去中心化有什么好处?
在大数据领域,通常的设计理念都是无中心和分布式。Minio分布式模式可以帮助你搭建一个高可用的对象存储服务,你可以使用这些存储设备,而不用考虑其真实物理位置。
它将分布在不同服务器上的多块硬盘组成一个对象存储服务。由于硬盘分布在不同的节点上,分布式Minio避免了单点故障。如下图:
Minio使用纠删码技术来保护数据,它是一种恢复丢失和损坏数据的数学算法,它将数据分块冗余的分散存储在各各节点的磁盘上,所有的可用磁盘组成一个集合,上图由8块硬盘组成一个集合,当上传一个文件时会通过纠删码算法计算对文件进行分块存储,除了将文件本身分成4个数据块,还会生成4个校验块,数据块和校验块会分散的存储在这8块硬盘上。
使用纠删码的好处是即便丢失一半数量(N/2)的硬盘,仍然可以恢复数据。 比如上边集合中有4个以内的硬盘损害仍可保证数据恢复,不影响上传和下载,如果多于一半的硬盘坏了则无法恢复。
接下来看看项目中怎么使用它:
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.4.3</version>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.8.1</version>
</dependency>
已上传图片文件为例:
@ApiOperation("上传图片")
@RequestMapping(value = "/upload/coursefile",consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public UploadFileResultDto uploadFile(@RequestPart("filedata")MultipartFile filedata,
@RequestParam(value= "objectName",required=false) String objectName) throws IOException {
//准备上传文件的信息
UploadFileParamsDto uploadFileParamsDto = new UploadFileParamsDto();
//原始文件名称
uploadFileParamsDto.setFilename(filedata.getOriginalFilename());
//文件大小
uploadFileParamsDto.setFileSize(filedata.getSize());
//文件类型
uploadFileParamsDto.setFileType("001001");
//创建一个临时文件
File tempFile = File.createTempFile("minio", ".temp");
filedata.transferTo(tempFile);
Long companyId = 1232141425L;
//文件路径
String localFilePath = tempFile.getAbsolutePath();
//调用service上传图片-objectname传按照静态页面,不传则按年月日传图片
UploadFileResultDto uploadFileResultDto = mediaFileService.uploadFile(companyId, uploadFileParamsDto, localFilePath,objectName);
return uploadFileResultDto;
}
//将文件上传minio
public boolean addMediaFilesToMinIO(String localFilePath,String mimeType,String bucket, String objectName){
//上传文件的参数信息
try {
UploadObjectArgs uploadObjectArgs = UploadObjectArgs.builder()
.bucket(bucket)
.filename(localFilePath)
.object(objectName)//添加子目录
.contentType(mimeType)//默认根据扩展名确定文件内容类型,也可以指定
.build();
//上传文件
minioClient.uploadObject(uploadObjectArgs);
log.debug("上传文件成功,bucket={},objectName={}",bucket,objectName);
return true;
} catch (Exception e) {
e.printStackTrace();
log.error("上传文件出错:bucket={},objectName={},错误信息={}",bucket,objectName,e.getMessage());
}
return false;
}
//上传文件
//在进行事务控制的时候,只要里面有网络访问的东西,千万不要用数据库的事务去控制
// @Transactional
@Override
public UploadFileResultDto uploadFile(Long companyId, UploadFileParamsDto uploadFileParamsDto, String localFilePath,String objectName) {
//文件名
String filename = uploadFileParamsDto.getFilename();
//先得到扩展名
String extension = filename.substring(filename.lastIndexOf("."));
//得到mimeType
String mimeType = getMimeType(extension);
//子目录
String defaultFolderPath = getDefaultFolderPath();
//文件的md5值
String fileMd5 = getFileMd5(new File(localFilePath));
if(StringUtils.isEmpty(objectName)){
//使用默认年月日去存储
objectName = defaultFolderPath+fileMd5+extension;
}
//上传文件到minio
boolean result = addMediaFilesToMinIO(localFilePath, mimeType, bucket_mediafiles, objectName);
if(!result){
XueChengPlusException.cast("上传文件失败");
}
//入库文件信息
MediaFiles mediaFiles = currentProxy.addMediaFilesToDb(companyId, fileMd5, uploadFileParamsDto, bucket_mediafiles, objectName);
if(mediaFiles==null){
XueChengPlusException.cast("文件上传后保存信息失败");
}
//准备返回的对象
UploadFileResultDto uploadFileResultDto = new UploadFileResultDto();
BeanUtils.copyProperties(mediaFiles,uploadFileResultDto);
return uploadFileResultDto;
}
界面如下:
很好,以后就不用担惊受怕了,想传啥传啥
除此之外,还有一个很重要(对我和我的毕设)的技术点,那就是视频的分片上传:
另外提一嘴,视频格式mp4支持web端播放,avi就不行,所以在这个项目里需要将avi视频转为mp4格式
项目利用ffmpeg,听说那些玩爬虫爬流媒体视频的人用它合并视频片,而且听说这玩意在音视频程序员邻域蛮厉害的(不太清楚反正牛且好用就对了)
以下是上传大文件代码(例如视频,现实中云盘也是这样做的)
//上传分块
@Override
public RestResponse uploadChunk(String fileMd5, int chunk, String localChunkFilePath) {
//分块文件的路径
String chunkFilePath = getChunkFileFolderPath(fileMd5)+chunk;
//获取mimeType
String mimeType = getMimeType(null);
//将分块文件上传到minio
boolean b = addMediaFilesToMinIO(localChunkFilePath, mimeType, bucket_video, chunkFilePath);
if(!b){
return RestResponse.validfail(false,"上传分块文件失败");
}
//上传成功
return RestResponse.success(true);
}
上传完别忘了合并
//合并分块
@Override
public RestResponse mergechunks(Long companyId, String fileMd5, int chunkTotal, UploadFileParamsDto uploadFileParamsDto) {
//分块文件所在的目录
String chunkFileFolderPath = getChunkFileFolderPath(fileMd5);
//找到所有的分块文件调用minio的sdk进行文件合并
List<ComposeSource> sources = Stream.iterate(0, i -> ++i)
.limit(chunkTotal)
.map(i -> ComposeSource.builder()
.bucket(bucket_video)
.object(chunkFileFolderPath+i)
.build())
.collect(Collectors.toList());
//合并后文件的objectname
String filename = uploadFileParamsDto.getFilename();
//扩展名
String extension = filename.substring(filename.lastIndexOf("."));
String objectName = getFilePathByMd5(fileMd5,extension);
ComposeObjectArgs composeObjectArgs = ComposeObjectArgs
.builder()
.bucket(bucket_video)
.object(objectName)//最终合并后的文件objectname
.sources(sources)
.build();
//=========合并文件
//报错size 1048576 must be greater than 5242880,minio默认的分块文件大小为5M
try {
minioClient.composeObject(composeObjectArgs);
} catch (Exception e) {
e.printStackTrace();
log.error("合并文件失败:bucket:{},objectName:{},错误信息:{}",bucket_video,objectName,e.getMessage());
return RestResponse.validfail(false,"合并文件异常");
}
//=======校验合并后的和源文件是否一致,一致才成功
//先从minio下载合并后的文件
File file = downloadFileFromMinIO(bucket_video,objectName);
try(FileInputStream fileInputStream = new FileInputStream(file)){
//计算合并后文件的md5
String mergeFile_md5 = DigestUtils.md5Hex(fileInputStream);
//校验合并后的文件md5和源文件md5是否一致
if(!fileMd5.equals(mergeFile_md5)){
//校验失败
log.error("合并文件校验失败:源文件md5:{},合并后文件md5:{}",fileMd5,mergeFile_md5);
return RestResponse.validfail(false,"合并文件校验失败");
}
//文件大小
uploadFileParamsDto.setFileSize(file.length());
}catch (Exception e){
e.printStackTrace();
return RestResponse.validfail(false,"合并文件校验失败");
}
//这里不用下载合并后的文件,使用statObject方法,通过返回值调用etag()方法,得到的就是minio中文件的md5值
// StatObjectResponse statObjectResponse = null;
// try {
// statObjectResponse = minioClient.statObject(StatObjectArgs.builder().bucket(bucket_video).object(objectName).build());
// } catch (Exception e) {
// log.error("从minio下载文件异常:bucket:{},objectName:{},错误信息:{}",bucket_video,objectName,e.getMessage());
// return RestResponse.validfail(false,"从minio下载文件异常");
// }
// String md5Hex = statObjectResponse.etag();
//=======将文件信息保存到数据库
MediaFiles mediaFiles =currentProxy.addMediaFilesToDb(companyId, fileMd5, uploadFileParamsDto, bucket_video, objectName);
if(mediaFiles==null){
return RestResponse.validfail(false,"文件入库失败");
}
//=======清理分块文件
clearChunkFiles(chunkFileFolderPath,chunkTotal);
return RestResponse.success(true);
}
最后清除
/**
* 清除分块文件
* @param chunkFileFolderPath 分块文件路径
* @param chunkTotal 分块文件总数
*/
private void clearChunkFiles(String chunkFileFolderPath,int chunkTotal){
Iterable<DeleteObject> objects = Stream.iterate(0, i -> ++i).limit(chunkTotal).map(i -> new DeleteObject(chunkFileFolderPath+ i)).collect(Collectors.toList());;
RemoveObjectsArgs removeObjectsArgs = RemoveObjectsArgs.builder().bucket(bucket_video).objects(objects).build();
Iterable<Result<DeleteError>> results = minioClient.removeObjects(removeObjectsArgs);
//要想真正删除
results.forEach(f->{
try {
DeleteError deleteError = f.get();
} catch (Exception e) {
e.printStackTrace();
}
});
}
以及从minio下载文件回来
/**
* 从minio下载文件
* @param bucket 桶
* @param objectName 对象名称
* @return 下载后的文件
*/
public File downloadFileFromMinIO(String bucket,String objectName){
//临时文件
File minioFile = null;
FileOutputStream outputStream = null;
try{
InputStream stream = minioClient.getObject(GetObjectArgs.builder()
.bucket(bucket)
.object(objectName)
.build());
//创建临时文件
minioFile=File.createTempFile("minio", ".merge");
outputStream = new FileOutputStream(minioFile);
IOUtils.copy(stream,outputStream);
return minioFile;
} catch (Exception e) {
e.printStackTrace();
}finally {
if(outputStream!=null){
try {
outputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
return null;
}
对了,又漏一嘴,这个项目的文件分块是文件传给前端,前端传给后端,后端传给minio这个过程,后两个过程采用md5对文件加密进行保密。
//获取文件的md5
private String getFileMd5(File file) {
try (FileInputStream fileInputStream = new FileInputStream(file)) {
String fileMd5 = DigestUtils.md5Hex(fileInputStream);
return fileMd5;
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
半年以前我还以为md5只能用于字符串之类的加密,未想到连文件也可以,没事,文件也是数据流文件,合理。
还有一个比较重要的知识点,java创建异步线程池(使劲霍霍资源)
@XxlJob("videoJobHandler")
public void videoJobHandler() throws Exception {
// 分片参数
int shardIndex = XxlJobHelper.getShardIndex();//执行器的序号,从0开始
int shardTotal = XxlJobHelper.getShardTotal();//执行器数量
//确定cpu的核心数
int processors = Runtime.getRuntime().availableProcessors();
//查询待处理的任务
List<MediaProcess> mediaProcessList = mediaFileProcessService.getMediaProcessList(shardIndex, shardTotal, processors);
//任务数量
int size = mediaProcessList.size();
log.debug("取到的视频处理任务数:{}",size);
if(size<=0){
return;
}
//创建一个线程池
ExecutorService executorService = Executors.newFixedThreadPool(size);
//使用的计数器
CountDownLatch countDownLatch = new CountDownLatch(size);
mediaProcessList.forEach(mediaProcess -> {
//将任务加入线程池
executorService.execute(()->{
try {
//任务id
Long taskId = mediaProcess.getId();
//文件id就是MD5
String fileId = mediaProcess.getFileId();
//争抢任务-乐观锁
boolean b = mediaFileProcessService.startTask(taskId);
if (!b) {
log.debug("任务抢占失败,taskId:{}", taskId);
return;
}
//下载minio视频到本地
//桶
String bucket = mediaProcess.getBucket();
//objectName
String objectName = mediaProcess.getFilePath();
File file = mediaFileService.downloadFileFromMinIO(bucket, objectName);
if (file == null) {
log.debug("下载视频出错,taskId:{},bucket:{},objectName:{}", taskId, bucket, objectName);
//保存任务处理失败的结果
mediaFileProcessService.saveProcessFinishStatus(taskId, "3", fileId, null, "下载视频到本地失败");
return;
}
//源avi视频的路径
String video_path = file.getAbsolutePath();
//转换后mp4文件的名称
String mp4_name = fileId + ".mp4";
//转换后mp4文件的路径
//先创建一个临时文件,作为转换后的文件
File mp4File = null;
try {
mp4File = File.createTempFile("minio", ".mp4");
} catch (IOException e) {
log.debug("创建临时文件异常,{}", e.getMessage());
//保存任务处理失败的结果
mediaFileProcessService.saveProcessFinishStatus(taskId, "3", fileId, null, "创建临时文件异常");
return;
}
String mp4_path = mp4File.getAbsolutePath();
//创建工具类对象
Mp4VideoUtil videoUtil = new Mp4VideoUtil(ffmpegpath, video_path, mp4_name, mp4_path);
//开始视频转换,成功将返回success
String result = videoUtil.generateMp4();
//开始视频转换,成功success,失败返回失败原因
if (!result.equals("success")) {
log.debug("视频转码失败,原因:{},taskId:{},bucket:{},objectName:{}", result, taskId, bucket, objectName);
mediaFileProcessService.saveProcessFinishStatus(taskId, "3", fileId, null, result);
return;
}
//上传mp4文件到minio todo:有疑问-objectName
objectName = getFilePath(fileId, ".mp4");
boolean b1 = mediaFileService.addMediaFilesToMinIO(mp4File.getAbsolutePath(), "video/mp4", bucket, objectName);
if (!b1) {
log.debug("上传mp4视频到minio失败,taskId:{},bucket:{},objectName:{}", taskId, bucket, objectName);
//保存任务处理失败的结果
mediaFileProcessService.saveProcessFinishStatus(taskId, "3", fileId, null, "上传mp4视频到minio失败");
return;
}
//MP4文件的url
String url = "/"+bucket+"/"+getFilePath(fileId, ".mp4");
//todo:有疑问-url"/"+bucket+"/"+objectName
//更新任务状态为成功
mediaFileProcessService.saveProcessFinishStatus(taskId, "2", fileId, url, null);
// mp4File.delete();
}finally{
//计数器减去1
countDownLatch.countDown();
}
});
});
//阻塞,等所有线程都完成任务-整流,为0放行,最多等30分钟,指定一个最大限度的等待时间
//阻塞最多等待一定的时间后就解除阻塞
//只有线程释放了下一轮任务才能继续
countDownLatch.await(30,TimeUnit.MINUTES);
}
异步线程池的作用
在Java中,ExecutorService
接口代表一个异步执行任务的服务。通过Executors.newFixedThreadPool(size)
创建的线程池,其中的任务会在后台线程中异步执行,不会阻塞主线程的执行。
具体来说,当你调用executorService.execute(Runnable command)
方法时,任务会被提交到线程池中,并立即返回,不会等待任务 执行完成。任务会在后台线程中异步执行,你可以继续执行其他代码,而不需要等待当前任务完成。
例如,在你的代码中,mediaProcessList.forEach(mediaProcess -> { ... })
中的每个任务都会被提交到线程池中异步执行,主线 程会继续执行后续代码,而不需要等待每个任务完成。
串行便并行的效率提升巨大
xucheng-plus-message-sdk:负责消息队列(MQ)
这个项目主要利用MQ集成订单服务,我没有实现支付功能(以为我发现前端代码有问题,不过后台传来的base64编码的二维码倒是可以正常显示)
这里依旧先来介绍MQ:
什么是MQ
消息队列(Message Queue,简称MQ),从字面意思上看,本质是个队列,FIFO先入先出,只不过队列中存放的内容是message而已。
其主要用途:不同进程Process/线程Thread之间通信。
为什么会产生消息队列?有几个原因:
不同进程(process)之间传递消息时,两个进程之间耦合程度过高,改动一个进程,引发必须修改另一个进程,为了隔离这两个进程,在两进程间抽离出一层(一个模块),所有两进程之间传递的消息,都必须通过消息队列来传递,单独修改某一个进程,不会影响另一个;
不同进程(process)之间传递消息时,为了实现标准化,将消息的格式规范化了,并且,某一个进程接受的消息太多,一下子无法处理完,并且也有先后顺序,必须对收到的消息进行排队,因此诞生了事实上的消息队列;
本项目采用RabbitMQ:
RabbitMQ是一款使用Erlang语言开发的,实现AMQP(高级消息队列协议)的开源消息中间件。首先要知道一些RabbitMQ的特点,官网可查:
可靠性。支持持久化,传输确认,发布确认等保证了MQ的可靠性。
灵活的分发消息策略。这应该是RabbitMQ的一大特点。在消息进入MQ前由Exchange(交换机)进行路由消息。分发消息策略有:简单模式、工作队列模式、发布订阅模式、路由模式、通配符模式。
支持集群。多台RabbitMQ服务器可以组成一个集群,形成一个逻辑Broker。
多种协议。RabbitMQ支持多种消息队列协议,比如 STOMP、MQTT 等等。
支持多种语言客户端。RabbitMQ几乎支持所有常用编程语言,包括 Java、.NET、Ruby 等等。
可视化管理界面。RabbitMQ提供了一个易用的用户界面,使得用户可以监控和管理消息 Broker。
插件机制。RabbitMQ提供了许多插件,可以通过插件进行扩展,也可以编写自己的插件。
界面:
@Slf4j
@Configuration
public class PayNotifyConfig{
//交换机
public static final String PAYNOTIFY_EXCHANGE_FANOUT = "paynotify_exchange_fanout";
//支付结果通知消息类型
public static final String MESSAGE_TYPE = "payresult_notify";
//支付通知队列
public static final String PAYNOTIFY_QUEUE = "paynotify_queue";
//声明交换机,且持久化
@Bean(PAYNOTIFY_EXCHANGE_FANOUT)
public FanoutExchange paynotify_exchange_fanout() {
// 三个参数:交换机名称、是否持久化、当没有queue与其绑定时是否自动删除
return new FanoutExchange(PAYNOTIFY_EXCHANGE_FANOUT, true, false);
}
//支付通知队列,且持久化
@Bean(PAYNOTIFY_QUEUE)
public Queue course_publish_queue() {
return QueueBuilder.durable(PAYNOTIFY_QUEUE).build();
}
//交换机和支付通知队列绑定
@Bean
public Binding binding_course_publish_queue(@Qualifier(PAYNOTIFY_QUEUE) Queue queue, @Qualifier(PAYNOTIFY_EXCHANGE_FANOUT) FanoutExchange exchange) {
return BindingBuilder.bind(queue).to(exchange);
}
}
xucheng-plus-orders:负责支付相关功能实现
这个模块也绑定了RabbitMq,不过支付宝(沙箱环境)扫码支付前端并未实现,但后端代码依旧有借鉴意义
@RequestMapping("/alipaytest")
public void doPost(HttpServletRequest httpRequest,
HttpServletResponse httpResponse) throws ServletException, IOException, AlipayApiException {
AlipayClient alipayClient = new DefaultAlipayClient(AlipayConfig.URL, APP_ID, APP_PRIVATE_KEY, AlipayConfig.FORMAT, AlipayConfig.CHARSET, ALIPAY_PUBLIC_KEY,AlipayConfig.SIGNTYPE);
//获得初始化的AlipayClient
AlipayTradeWapPayRequest alipayRequest = new AlipayTradeWapPayRequest();//创建API对应的request
// alipayRequest.setReturnUrl("http://domain.com/CallBack/return_url.jsp");//官方不建议->重定向
alipayRequest.setNotifyUrl("http://tjxt-user-t.itheima.net/xuecheng/orders/paynotify");//在公共参数中设置回跳和通知地址
alipayRequest.setBizContent("{" +
" \"out_trade_no\":\"202219899910105667\"," +
" \"total_amount\":0.1," +
" \"subject\":\"Iphone999 999G\"," +
" \"product_code\":\"QUICK_WAP_WAY\"" +
" }");//填充业务参数
String form = alipayClient.pageExecute(alipayRequest).getBody(); //调用SDK生成表单
httpResponse.setContentType("text/html;charset=" + AlipayConfig.CHARSET);
httpResponse.getWriter().write(form);//直接将完整的表单html输出到页面
httpResponse.getWriter().flush();
}
//支付结果通知
@PostMapping("/paynotifytest")
public void paynotify(HttpServletRequest request,HttpServletResponse response) throws IOException, AlipayApiException {
//获取支付宝POST过来反馈信息
Map<String,String> params = new HashMap<String,String>();
Map requestParams = request.getParameterMap();
for (Iterator iter = requestParams.keySet().iterator(); iter.hasNext();) {
String name = (String) iter.next();
String[] values = (String[]) requestParams.get(name);
String valueStr = "";
for (int i = 0; i < values.length; i++) {
valueStr = (i == values.length - 1) ? valueStr + values[i]
: valueStr + values[i] + ",";
}
//乱码解决,这段代码在出现乱码时使用。如果mysign和sign不相等也可以使用这段代码转化
//valueStr = new String(valueStr.getBytes("ISO-8859-1"), "gbk");
params.put(name, valueStr);
}
//获取支付宝的通知返回参数,可参考技术文档中页面跳转同步通知参数列表(以下仅供参考)//
//获取支付宝的通知返回参数,可参考技术文档中页面跳转同步通知参数列表(以上仅供参考)//
//计算得出通知验证结果
//boolean AlipaySignature.rsaCheckV1(Map<String, String> params, String publicKey, String charset, String sign_type)
boolean verify_result = AlipaySignature.rsaCheckV1(params, ALIPAY_PUBLIC_KEY, AlipayConfig.CHARSET, "RSA2");
if(verify_result){//验证成功
//////////////////////////////////////////////////////////////////////////////////////////
//请在这里加上商户的业务逻辑程序代码
//商户订单号
String out_trade_no = new String(request.getParameter("out_trade_no").getBytes("ISO-8859-1"),"UTF-8");
//支付宝交易号
String trade_no = new String(request.getParameter("trade_no").getBytes("ISO-8859-1"),"UTF-8");
//交易状态
String trade_status = new String(request.getParameter("trade_status").getBytes("ISO-8859-1"),"UTF-8");
//——请根据您的业务逻辑来编写程序(以下代码仅作参考)——
if(trade_status.equals("TRADE_FINISHED")){
//判断该笔订单是否在商户网站中已经做过处理
//如果没有做过处理,根据订单号(out_trade_no)在商户网站的订单系统中查到该笔订单的详细,并执行商户的业务程序
//请务必判断请求时的total_fee、seller_id与通知时获取的total_fee、seller_id为一致的
//如果有做过处理,不执行商户的业务程序
//注意:
//如果签约的是可退款协议,退款日期超过可退款期限后(如三个月可退款),支付宝系统发送该交易状态通知
//如果没有签约可退款协议,那么付款完成后,支付宝系统发送该交易状态通知。
} else if (trade_status.equals("TRADE_SUCCESS")){
//判断该笔订单是否在商户网站中已经做过处理
//如果没有做过处理,根据订单号(out_trade_no)在商户网站的订单系统中查到该笔订单的详细,并执行商户的业务程序
//请务必判断请求时的total_fee、seller_id与通知时获取的total_fee、seller_id为一致的
//如果有做过处理,不执行商户的业务程序
System.out.println(trade_status);
//注意:
//如果签约的是可退款协议,那么付款完成后,支付宝系统发送该交易状态通知。
}
//——请根据您的业务逻辑来编写程序(以上代码仅作参考)——
response.getWriter().write("success");
//////////////////////////////////////////////////////////////////////////////////////////
}else{//验证失败
response.getWriter().write("fail");
}
}
整体看起来挺简洁的
xucheng-plus-parent:所有模块的父模块
所有模块的依赖项,通过它将一些通用配置传递,本项目的根基
xucheng-plus-search:负责搜索功能实现
还记得我上一个项目还用的mysql语句实现搜索,还只是一些字段,速度也堪忧,后来学了才知道,mysql内部的innodb搜索引擎处理此类请求使用遍历方法查找...
这里使用ElasticSearch(大名鼎鼎,某个上市公司的招牌):
ES是一个开源的高扩展的分布式全文检索引擎,它可以近乎实时的存储、检索数据;本身扩展性很好,可以扩展到上百台服务器,处理PB级别(大数据时代)的数据。ES也使用Java开发并使用Lucene作为其核心来实现所有索引和搜索的功能,但是它的目的是通过简单的RestFul API来隐藏Lucene的复杂性,从而让全文检索变得简单。
现今,ES已经是全世界排名第一的搜索引擎类应用!
上代码:
@Configuration
public class ElasticsearchConfig {
@Value("${elasticsearch.hostlist}")
private String hostlist;
@Bean
public RestHighLevelClient restHighLevelClient(){
//解析hostlist配置信息
String[] split = hostlist.split(",");
//创建HttpHost数组,其中存放es主机和端口的配置信息
HttpHost[] httpHostArray = new HttpHost[split.length];
for(int i=0;i<split.length;i++){
String item = split[i];
httpHostArray[i] = new HttpHost(item.split(":")[0], Integer.parseInt(item.split(":")[1]), "http");
}
//创建RestHighLevelClient客户端
return new RestHighLevelClient(RestClient.builder(httpHostArray));
}
}
@Override
public SearchPageResultDto<CourseIndex> queryCoursePubIndex(PageParams pageParams, SearchCourseParamDto courseSearchParam) {
//设置索引
SearchRequest searchRequest = new SearchRequest(courseIndexStore);
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
//source源字段过虑
String[] sourceFieldsArray = sourceFields.split(",");
searchSourceBuilder.fetchSource(sourceFieldsArray, new String[]{});
if(courseSearchParam==null){
courseSearchParam = new SearchCourseParamDto();
}
//关键字
if(StringUtils.isNotEmpty(courseSearchParam.getKeywords())){
//匹配关键字
MultiMatchQueryBuilder multiMatchQueryBuilder = QueryBuilders.multiMatchQuery(courseSearchParam.getKeywords(), "name", "description");
//设置匹配占比
multiMatchQueryBuilder.minimumShouldMatch("70%");
//提升另个字段的Boost值
multiMatchQueryBuilder.field("name",10);
boolQueryBuilder.must(multiMatchQueryBuilder);
}
//过虑
if(StringUtils.isNotEmpty(courseSearchParam.getMt())){
boolQueryBuilder.filter(QueryBuilders.termQuery("mtName",courseSearchParam.getMt()));
}
if(StringUtils.isNotEmpty(courseSearchParam.getSt())){
boolQueryBuilder.filter(QueryBuilders.termQuery("stName",courseSearchParam.getSt()));
}
if(StringUtils.isNotEmpty(courseSearchParam.getGrade())){
boolQueryBuilder.filter(QueryBuilders.termQuery("grade",courseSearchParam.getGrade()));
}
//分页
Long pageNo = pageParams.getPageNo();
Long pageSize = pageParams.getPageSize();
int start = (int) ((pageNo-1)*pageSize);
searchSourceBuilder.from(start);
searchSourceBuilder.size(Math.toIntExact(pageSize));
//布尔查询
searchSourceBuilder.query(boolQueryBuilder);
//高亮设置
HighlightBuilder highlightBuilder = new HighlightBuilder();
highlightBuilder.preTags("<font class='eslight'>");
highlightBuilder.postTags("</font>");
//设置高亮字段
highlightBuilder.fields().add(new HighlightBuilder.Field("name"));
searchSourceBuilder.highlighter(highlightBuilder);
//请求搜索
searchRequest.source(searchSourceBuilder);
//聚合设置
buildAggregation(searchRequest);
SearchResponse searchResponse = null;
try {
searchResponse = client.search(searchRequest, RequestOptions.DEFAULT);
} catch (IOException e) {
e.printStackTrace();
log.error("课程搜索异常:{}",e.getMessage());
return new SearchPageResultDto<CourseIndex>(new ArrayList(),0,0,0);
}
//结果集处理
SearchHits hits = searchResponse.getHits();
SearchHit[] searchHits = hits.getHits();
//记录总数
TotalHits totalHits = hits.getTotalHits();
//数据列表
List<CourseIndex> list = new ArrayList<>();
for (SearchHit hit : searchHits) {
String sourceAsString = hit.getSourceAsString();
CourseIndex courseIndex = JSON.parseObject(sourceAsString, CourseIndex.class);
//取出source
Map<String, Object> sourceAsMap = hit.getSourceAsMap();
//课程id
Long id = courseIndex.getId();
//取出名称
String name = courseIndex.getName();
//取出高亮字段内容
Map<String, HighlightField> highlightFields = hit.getHighlightFields();
if(highlightFields!=null){
HighlightField nameField = highlightFields.get("name");
if(nameField!=null){
Text[] fragments = nameField.getFragments();
StringBuffer stringBuffer = new StringBuffer();
for (Text str : fragments) {
stringBuffer.append(str.string());
}
name = stringBuffer.toString();
}
}
courseIndex.setId(id);
courseIndex.setName(name);
list.add(courseIndex);
}
SearchPageResultDto<CourseIndex> pageResult = new SearchPageResultDto<>(list, totalHits.value,pageNo,pageSize);
//获取聚合结果
List<String> mtList= getAggregation(searchResponse.getAggregations(), "mtAgg");
List<String> stList = getAggregation(searchResponse.getAggregations(), "stAgg");
pageResult.setMtList(mtList);
pageResult.setStList(stList);
return pageResult;
}
@Slf4j
@Service
public class IndexServiceImpl implements IndexService {
@Autowired
RestHighLevelClient client;
@Override
public Boolean addCourseIndex(String indexName,String id,Object object) {
String jsonString = JSON.toJSONString(object);
IndexRequest indexRequest = new IndexRequest(indexName).id(id);
//指定索引文档内容
indexRequest.source(jsonString,XContentType.JSON);
//索引响应对象
IndexResponse indexResponse = null;
try {
indexResponse = client.index(indexRequest, RequestOptions.DEFAULT);
} catch (IOException e) {
log.error("添加索引出错:{}",e.getMessage());
e.printStackTrace();
XueChengPlusException.cast("添加索引出错");
}
String name = indexResponse.getResult().name();
System.out.println(name);
return name.equalsIgnoreCase("created") || name.equalsIgnoreCase("updated");
}
@Override
public Boolean updateCourseIndex(String indexName,String id,Object object) {
String jsonString = JSON.toJSONString(object);
UpdateRequest updateRequest = new UpdateRequest(indexName, id);
updateRequest.doc(jsonString, XContentType.JSON);
UpdateResponse updateResponse = null;
try {
updateResponse = client.update(updateRequest, RequestOptions.DEFAULT);
} catch (IOException e) {
log.error("更新索引出错:{}",e.getMessage());
e.printStackTrace();
XueChengPlusException.cast("更新索引出错");
}
DocWriteResponse.Result result = updateResponse.getResult();
return result.name().equalsIgnoreCase("updated");
}
@Override
public Boolean deleteCourseIndex(String indexName,String id) {
//删除索引请求对象
DeleteRequest deleteRequest = new DeleteRequest(indexName,id);
//响应对象
DeleteResponse deleteResponse = null;
try {
deleteResponse = client.delete(deleteRequest, RequestOptions.DEFAULT);
} catch (IOException e) {
log.error("删除索引出错:{}",e.getMessage());
e.printStackTrace();
XueChengPlusException.cast("删除索引出错");
}
//获取响应结果
DocWriteResponse.Result result = deleteResponse.getResult();
return result.name().equalsIgnoreCase("deleted");
}
}
使用起来感觉还行,查询速度快到飞起,他还有自己独立的添加索引的语法,值得学习。
可通过命令:GET /_cat/indices?v 查看所有的索引,通过此命令判断kibana是否正常连接elasticsearch。
索引相当于MySQL中的表,Elasticsearch与MySQL之间概念的对应关系见下表:
要使用elasticsearch需要建立索引,Mapping相当于表结构,Mapping创建后其字段不能删除,如果要删除需要删除整个索引,下边介绍创建索引、查询索引、删除索引的方法:
1、创建索引,并指定Mapping。
PUT/course-publish
{
"settings": {
"number_of_shards": 1,
"number_of_replicas": 0
},
"mappings": {
"properties": {
"id": {
"type": "keyword"
},
"companyId": {
"type": "keyword"
},
"companyName": {
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart",
"type": "text"
},
"name": {
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart",
"type": "text"
},
"users": {
"index": false,
"type": "text"
},
"tags": {
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart",
"type": "text"
},
"mt": {
"type": "keyword"
},
"mtName": {
"type": "keyword"
},
"st": {
"type": "keyword"
},
"stName": {
"type": "keyword"
},
"grade": {
"type": "keyword"
},
"teachmode": {
"type": "keyword"
},
"pic": {
"index": false,
"type": "text"
},
"description": {
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart",
"type": "text"
},
"createDate": {
"format": "yyyy-MM-dd HH:mm:ss",
"type": "date"
},
"status": {
"type": "keyword"
},
"remark": {
"index": false,
"type": "text"
},
"charge": {
"type": "keyword"
},
"price": {
"type": "scaled_float",
"scaling_factor": 100
},
"originalPrice": {
"type": "scaled_float",
"scaling_factor": 100
},
"validDays": {
"type": "integer"
}
}
}
}
2、查询索引
通过 GET /_cat/indices?v 查询所有的索引,查找course-publish是否创建成功。
通过GET /course-publish/_mapping 查询course-publish的索引结构。
{
"course-publish" : {
"mappings" : {
"properties" : {
"charge" : {
"type" : "keyword"
},
"companyId" : {
"type" : "keyword"
},
"companyName" : {
"type" : "text",
"analyzer" : "ik_max_word",
"search_analyzer" : "ik_smart"
},
"createDate" : {
"type" : "date",
"format" : "yyyy-MM-dd HH:mm:ss"
},
"description" : {
"type" : "text",
"analyzer" : "ik_max_word",
"search_analyzer" : "ik_smart"
},
"grade" : {
"type" : "keyword"
},
"id" : {
"type" : "keyword"
},
"mt" : {
"type" : "keyword"
},
"mtName" : {
"type" : "keyword"
},
"name" : {
"type" : "text",
"analyzer" : "ik_max_word",
"search_analyzer" : "ik_smart"
},
"originalPrice" : {
"type" : "scaled_float",
"scaling_factor" : 100.0
},
"pic" : {
"type" : "text",
"index" : false
},
"price" : {
"type" : "scaled_float",
"scaling_factor" : 100.0
},
"remark" : {
"type" : "text",
"index" : false
},
"st" : {
"type" : "keyword"
},
"stName" : {
"type" : "keyword"
},
"status" : {
"type" : "keyword"
},
"tags" : {
"type" : "text",
"analyzer" : "ik_max_word",
"search_analyzer" : "ik_smart"
},
"teachmode" : {
"type" : "keyword"
},
"users" : {
"type" : "text",
"index" : false
},
"validDays" : {
"type" : "integer"
}
}
}
}
}
3、删除索引
如果发现创建的course-publish不正确可以删除重新创建。
删除索引后当中的文档数据也同时删除,一定要谨慎操作!
删除索引命令:DELETE /course-publish
xucheng-plus-system:负责数据字典同时负责一些必要的系统配置
没什么好讲的知识点...
前端部分
其实重要的就微信支付和分片上传部分,还有一个静态页面存储(但我觉得没必要)
微信二维码生成:
//请用微信生成二维码
function generateWxQrcode(token) {
var wxObj = new WxLogin({
self_redirect:true,
id:"login_container",
appid: "wxed9954c01bb89b47",
scope: "snsapi_login",
redirect_uri: "http://localhost:8160/auth/wxLogin",
state: token,
style: "",
href: ""
});
}
分片上传部分:
/**
* 上传入口类。
* @class Uploader
* @constructor
* @grammar new Uploader( opts ) => Uploader
* @example
* var uploader = WebUploader.Uploader({
* swf: 'path_of_swf/Uploader.swf',
*
* // 开起分片上传。
* chunked: true
* });
*/
function Uploader( opts ) {
this.options = $.extend( true, {}, Uploader.options, opts );
this._init( this.options );
}
// default Options
// widgets中有相应扩展
Uploader.options = {};
Mediator.installTo( Uploader.prototype );
// 批量添加纯命令式方法。
$.each({
upload: 'start-upload',
stop: 'stop-upload',
getFile: 'get-file',
getFiles: 'get-files',
addFile: 'add-file',
addFiles: 'add-file',
sort: 'sort-files',
removeFile: 'remove-file',
cancelFile: 'cancel-file',
skipFile: 'skip-file',
retry: 'retry',
isInProgress: 'is-in-progress',
makeThumb: 'make-thumb',
md5File: 'md5-file',
getDimension: 'get-dimension',
addButton: 'add-btn',
predictRuntimeType: 'predict-runtime-type',
refresh: 'refresh',
disable: 'disable',
enable: 'enable',
reset: 'reset'
}, function( fn, command ) {
Uploader.prototype[ fn ] = function() {
return this.request( command, arguments );
};
});
$.extend( Uploader.prototype, {
state: 'pending',
_init: function( opts ) {
var me = this;
me.request( 'init', opts, function() {
me.state = 'ready';
me.trigger('ready');
});
},
nginx部分
单独tomcat的并发数并不高,但利用nginx实现反向代理可以极大提高并发数
http {
include mime.types;
default_type application/octet-stream;
client_max_body_size 100M; # 设置客户端请求体最大值
client_body_buffer_size 128k; # 设置请求体缓存区大小
server_names_hash_bucket_size 64;
#xxh
#log_format main '$remote_addr - $remote_user [$time_local] "$request" '
# '$status $body_bytes_sent "$http_referer" '
# '"$http_user_agent" "$http_x_forwarded_for"';
#access_log logs/access.log main;
sendfile on;
#tcp_nopush on;
#keepalive_timeout 0;
keepalive_timeout 65;
#gzip on;
#文件服务
upstream fileserver{
server 192.168.101.65:9000 weight=10;
}
#后台网关
upstream gatewayserver{
server 127.0.0.1:63010 weight=10;
}
#前端开发服务
upstream uidevserver{
server 127.0.0.1:8601 weight=10;
}
server {
listen 80;
server_name www.51xuecheng.cn;
#rewrite ^(.*) https://$server_name$1 permanent;
#charset koi8-r;
ssi on;
ssi_silent_errors on;
#access_log logs/host.access.log main;
location / {
alias D:/code_xxh/xc-ui-pc-static-portal/;
index index.html index.htm;
}
#api
location /api/ {
proxy_pass http://gatewayserver/;
}
# #扫码登录
# location /xuecheng/ {
# proxy_pass http://gatewayserver/;
# }
#静态资源
location /static/img/ {
alias D:/code_xxh/xc-ui-pc-static-portal/img/;
}
location /static/css/ {
alias D:/code_xxh/xc-ui-pc-static-portal/css/;
}
location /static/js/ {
alias D:/code_xxh/xc-ui-pc-static-portal/js/;
}
location /static/plugins/ {
alias D:/code_xxh/xc-ui-pc-static-portal/plugins/;
add_header Access-Control-Allow-Origin http://ucenter.51xuecheng.cn;
add_header Access-Control-Allow-Credentials true;
add_header Access-Control-Allow-Methods GET;
}
location /plugins/ {
alias D:/code_xxh/xc-ui-pc-static-portal/plugins/;
}
location /course/preview/learning.html {
alias D:/code_xxh/xc-ui-pc-static-portal/course/learning.html;
}
location /course/search.html {
root D:/code_xxh/xc-ui-pc-static-portal;
}
location /course/learning.html {
root D:/code_xxh/xc-ui-pc-static-portal;
}
location /course/ {
proxy_pass http://fileserver/mediafiles/course/;
}
#openapi
location /open/content/ {
proxy_pass http://gatewayserver/content/open/;
}
location /open/media/ {
proxy_pass http://gatewayserver/media/open/;
}
#error_page 404 /404.html;
# redirect server error pages to the static page /50x.html
#
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
# proxy the PHP scripts to Apache listening on 127.0.0.1:80
#
#location ~ \.php$ {
# proxy_pass http://127.0.0.1;
#}
# pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
#
#location ~ \.php$ {
# root html;
# fastcgi_pass 127.0.0.1:9000;
# fastcgi_index index.php;
# fastcgi_param SCRIPT_FILENAME /scripts$fastcgi_script_name;
# include fastcgi_params;
#}
# deny access to .htaccess files, if Apache's document root
# concurs with nginx's one
#
#location ~ /\.ht {
# deny all;
#}
}
server {
listen 80;
server_name file.51xuecheng.cn;
#charset koi8-r;
ssi on;
ssi_silent_errors on;
#access_log logs/host.access.log main;
location /video {
proxy_pass http://fileserver;
}
location /mediafiles {
proxy_pass http://fileserver;
}
}
server {
listen 80;
server_name teacher.51xuecheng.cn;
#charset koi8-r;
ssi on;
ssi_silent_errors on;
#access_log logs/host.access.log main;
#location / {
# alias D:/itcast2022/xc_edu3.0/code_1/dist/;
# index index.html index.htm;
#}
location / {
proxy_pass http://uidevserver;
}
location /api/ {
proxy_pass http://gatewayserver/;
}
}
server {
listen 80;
server_name tjxt-user-t.itheima.net;
#charset koi8-r;
ssi on;
ssi_silent_errors on;
#access_log logs/host.access.log main;
location /xuecheng/ {
proxy_pass http://gatewayserver/;
}
}
server {
listen 80;
server_name ucenter.51xuecheng.cn;
#charset koi8-r;
ssi on;
ssi_silent_errors on;
#access_log logs/host.access.log main;
location / {
alias D:/code_xxh/xc-ui-pc-static-portal/ucenter/;
index index.html index.htm;
}
location /include {
proxy_pass http://127.0.0.1;
}
location /img/ {
proxy_pass http://127.0.0.1/static/img/;
}
location /api/ {
proxy_pass http://gatewayserver/;
}
}
# 微信回调
server {
listen 8160;
server_name localhost;
ssi on;
ssi_silent_errors on;
location / {
proxy_pass http://gatewayserver;
}
#静态资源 。注意需要换成自己电脑xc-ui-pc-static-portal对应目录
location /static/img/ {
alias D:/code_xxh/xc-ui-pc-static-portal/img/;
}
location /static/css/ {
alias D:/code_xxh/xc-ui-pc-static-portal/css/;
}
location /static/js/ {
alias D:/code_xxh/xc-ui-pc-static-portal/js/;
}
}
}
events {
worker_connections 1024; #nginx的最大并发访问量
}
总结及图片展示
最后图片展示
最后感谢
感谢伟大的互联网开源精神