项目–SpringBoot+Vue交易平台
概述
前言
本人做了一个交易平台,后端是SpringBoot,前端是Vue。这个电商平台有分商家页面和普通用户页面,后续还会增加一个独属于管理员的页面。这个电商平台通过QQ登录进行注册,并且为每一个注册的的用户分配一个默认好友,可以进行聊天,每一个普通的用户都可以通过申请提交成为一名商家,成为商家之后才可以进行增加自家的商品。
其实本人之前也做过一个前后的耦合的thymeleaf和SpringBoot的商城,后续也曾想过下一个项目是不是该做一个博客,音乐平台,或者是视频网站,最后考虑到,用工具的方法有千千万万,重要的是对工具的了解程度如何,你能不能更加深入的对单个方面有较为深刻的理解,于是这个项目也是一个电商平台,但在实现了前后端分离的同时,还加入了很多新的技术,并且在一些方面有了更为优秀的解决方案。
特点
看文章有时候就和看别人家的注释一样,不一定能快速分清楚对方的注释是关键语句还是在水代码行数,所以前面先列出本人做的项目的特点,节约一下阅读的时间,后面会有较为详细的描述。
实现的模板:基本CRUD和购物车,秒杀商品,搜索,Socket通信,security安全拦截,微服务等。
一些特点:
- 使用了redis,在用户访问商品时候会把商品放入redis缓存器,减少数据库的频繁访问,并且使用redis集群实现数据的安全
- 使用了redis实现了商品的秒杀,对突然间大量的请求会将其放入redis而不放入数据库,等秒杀活动过了一定时间再使用定时任务,慢慢写入数据库,由于redis的单线程保证了秒杀的安全,也减少了数据库的访问压力,后续的云服务还可以通过将商品放入消息队列的方式,进一步提高性能。
- 图片的类型是String,而不是流文件,这里结合了七牛云做图床,实现图片数据库和本地数据库的分离
- 每一个用户会有一个默认的好友,以用来测试socket模块,socket模块实现了用户的实时在线聊天,并且还会保存用户的聊天信息。
- 申请了QQ互联的功能,只需要QQ扫码便可以进行登录,QQ互联是和VUE 进行结合的,会产生一个全局的JWT来保证用户的权限认证,后端的security也是用JWT来和前端进行权限认证
- 搜索模块结合了Elasticsearch,以便于用户进行模糊类型的搜索
- 商品平台里面的数据是使用Jsoup爬虫从京东爬取而来的,并且使用雪花算法为每一个商品生成了一个全局唯一的UID
- 结合eureka和ribbon实现注册中心和负载均衡,降低访问压力
项目讲述
所用技术
- SpringBoot (后端)
- MySQL (数据库)
- MyBatis (访问数据库)
- swagger (集成文档)
- SpringSecurity (登录与权限控制)
- JWT (单点登录)
- Jsoup (爬虫,用于补充数据库的数据)
- fastjson (转JSON工具,用于前后端数据交互)
- 七牛云 (图床)
- netty-socketio (用于用户之间的通信)
- qq互联 (实现QQ登录)
- redis (中间件,用于数据的缓存)
- elasticsearch (搜索引擎)
- spring-boot-admin (管理后台)
- eureka (微服务的注册中心)
- ribbon (负载均衡)
- docker (容器化)
- Nginx (反向代理)
- aliyunEcs (服务器)
- vue (前端)
- axios (前端api)
- Element-ui (前端UI)
基本CRUD
无聊的操作总是千篇一律,有意思的源码也可能涉及跨域。
一个基本的CRUD,是由mybatis来操作mysql数据库来实现的,mybatis其实也很讲究,有一级缓存二级缓存等等很多原理,mysql也有innodb等等知识,然而这东西在讲系统时讲出来比较麻烦,所以在这篇展示项目的文章中,我仅仅根据我的所写的代码去讲述为什么需要这么写,暂时不写这些工具的原理。
这个是mybatis中mapper的规范,所有的命名都是根据以下来确定的:
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
| @Mapper public interface ProductMapper { long countByExample(ProductExample example);
int deleteByExample(ProductExample example);
int deleteByPrimaryKey(Long id);
int insert(Product record);
int insertOrUpdate(Product record);
int insertOrUpdateSelective(Product record);
int insertSelective(Product record);
List<Product> selectByExample(ProductExample example);
Product selectByPrimaryKey(Long id);
int updateByExampleSelective(@Param("record") Product record, @Param("example") ProductExample example);
int updateByExample(@Param("record") Product record, @Param("example") ProductExample example);
int updateByPrimaryKeySelective(Product record);
int updateByPrimaryKey(Product record);
int updateBatch(List<Product> list);
int batchInsert(@Param("list") List<Product> list); }
|
其中运用到了插件mybatiscodehelper
在控制层中有一个 BaseController :
1 2 3 4 5 6 7 8 9
| @RestController public class BaseController { @Autowired ProductService productService; @Autowired UserService userService; }
|
而全部的控制器都是通过继承来实现依赖注入的:
1 2 3 4 5 6
| @RestController public class ProductController extends BaseController{ }
|
这样继承省去了很多代码量
在后端中,购物车模块的代码为:
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
| @PostMapping("/add_all_cart_order") public Result add_all_cart_order(@Validated @RequestBody List<OrderSheet> orderSheets , HttpServletResponse response){ BigDecimal sum=new BigDecimal(0); for (OrderSheet o:orderSheets) { sum.add(o.getSumMoney()); } String state="余额不足"; User user=userService.selectById(orderSheets.get(0).getUserId()); BigDecimal money=user .getMoney() .subtract(sum); if (money.compareTo(new BigDecimal(0))==1){ for (OrderSheet o:orderSheets) { o.setState("未收货"); orderSheetService.insert(o); shoppingCartService.deleteByPrimaryKey(o.getId()); user.setMoney(user.getMoney().subtract(sum)); userService.updateByPrimaryKey(user); state="支付成功"; }}
return Result.succ(state);
}
|
大多数支付方面的代码都与其类似。
秒杀商品
首先我先举例一个方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| @PostMapping("/addorder") public Result add_order(@Validated @RequestBody OrderSheet orderSheet, HttpServletResponse response) { orderSheet.setId(new RandomId().nextId()); orderSheet.setTime(LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); Product product=productService.selectOneById(orderSheet.getProductId()); orderSheet.setSumMoney(product.getPrice().multiply(new BigDecimal(orderSheet.getAmount())));
Boolean success=orderRedis.addOrderByRedis(orderSheet); String message = success ? "抢购成功" : "抢购失败"; if (Objects.equals(message, "抢购失败")){ return Result.succ(message); }
orderSheetService.insert(orderSheet); return Result.succ(JSONObject.toJSONString(orderSheet.getId(),true));
}
|
orderSheetService就是可以被替换为秒杀模块,通过orderRedis.addOrderByRedis来实现将商品放入redis,而不涉及数据库。
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
| @Service public class OrderRedisImpl implements OrderRedis{ @Override public Boolean addOrderByRedis(OrderSheet orderSheet) { Long productId = orderSheet.getProductId(); Jedis jedis = null; JedisPool pool = new JedisPool(new JedisPoolConfig(), "localhost"); jedis = pool.getResource(); try { if (!jedis.exists("product_stock_"+productId)){ byte[] bytes = jedis.get(("get_product_"+productId).getBytes()); Product p =(Product) SerializeUtil.unserialize(bytes); jedis.set("product_stock_"+productId,p.getStock()+""); }else { if (jedis.get("product_stock_"+productId).equals("0")){ return false; } jedis.decrBy("product_stock_"+productId , 1); jedis.lpush(("order_product_"+productId).getBytes() ,SerializeUtil.serialize(orderSheet)); } } catch (IOException e) { e.printStackTrace(); } finally { if (jedis != null) { jedis.close(); } }
pool.destroy();
return true; } }
|
这里的做法比较简易,这里使用的是Jedis,通过从线程池里获取Jedis的方式保证了Jedis的线程安全,然后由于redis是单线程的,所以Jedis的API的所有操作都具有着原子性。以此实现线程安全的同时能够把数据放入redis 当中,然后通过TaskService :
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
| @Service public class TaskServiceImpl implements TaskService {
@Override
@Scheduled(fixedRate = 1000 * 5) public void purchaseTask() { Jedis jedis = null; JedisPool pool = new JedisPool(new JedisPoolConfig(), "localhost"); jedis = pool.getResource(); try {
System.out.println("定时任务开始......"); List<byte[]> bytes=jedis.lrange("order_product_885036".getBytes(), 0, -1); for (byte[] b:bytes) { OrderSheet orderSheet =(OrderSheet) SerializeUtil.unserialize(b); System.out.println(JSON.toJSONString(orderSheet)); } System.out.println("定时任务结束......");
} finally { if (jedis != null) { jedis.close(); } }
pool.destroy(); }
}
|
在后半夜时分,再将任务写入数据库。
搜索
搜索模块用的是Elasticsearch,这里不得不吐槽一下Elasticsearch的Api更新速度飞快,版本更新也很快,动不动就方法废弃,部署起来真的是不容易:
现在版本,根据官方文档来看,需要配置 RestHighLevelClient :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| @Configuration public class ElasticsearchConfig {
@Bean RestHighLevelClient elasticsearchClient() { ClientConfiguration configuration = ClientConfiguration.builder() .connectedTo("192.168.78.128:9200")
.build(); RestHighLevelClient client = RestClients.create(configuration).rest(); return client; } }
|
然后通过继承ElasticsearchRepository:
1 2 3 4 5 6 7 8 9
| public interface ProductRepository extends ElasticsearchRepository<Product ,Long> {
List<Product> findAllByNameLike(String name);
}
|
来调用方法:
1 2 3 4 5 6
| @GetMapping("/es_search_product_by_name/{name}") public Result es_search_product_by_name(@PathVariable(name = "name") String name){ List<Product> allByNameLike = productRepository.findAllByNameLike(name);
return Result.succ(allByNameLike); }
|
聊天模块
聊天模块需要配置SocketIoConfig:
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
| @Configuration public class SocketIoConfig {
@Value("${socketio.host}") private String host;
@Value("${socketio.port}") private Integer port;
@Value("${socketio.bossCount}") private int bossCount;
@Value("${socketio.workCount}") private int workCount;
@Value("${socketio.allowCustomRequests}") private boolean allowCustomRequests;
@Value("${socketio.upgradeTimeout}") private int upgradeTimeout;
@Value("${socketio.pingTimeout}") private int pingTimeout;
@Value("${socketio.pingInterval}") private int pingInterval;
@Bean public SocketIOServer socketIOServer() { SocketConfig socketConfig = new SocketConfig(); socketConfig.setTcpNoDelay(true); socketConfig.setSoLinger(0); com.corundumstudio.socketio.Configuration config = new com.corundumstudio.socketio.Configuration(); config.setSocketConfig(socketConfig);
config.setPort(port); config.setBossThreads(bossCount); config.setWorkerThreads(workCount); config.setAllowCustomRequests(allowCustomRequests); config.setUpgradeTimeout(upgradeTimeout); config.setPingTimeout(pingTimeout); config.setPingInterval(pingInterval); return new SocketIOServer(config); } }
|
这里不得不再提一下,聊天记录所用的类有哪些属性:
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
| @ApiModel(value = "com-sodse-trade-domain-ChatRecord") public class ChatRecord implements Serializable { @ApiModelProperty(value = "") private Long id;
@ApiModelProperty(value = "是自己发送为1,不是自己发送为0") private Integer isSend;
@ApiModelProperty(value = "") private String createTime;
@ApiModelProperty(value = "") private String content;
@ApiModelProperty(value = "") private Long userId;
@ApiModelProperty(value = "聊天对象id ,每当用户发送一条信息,都会写入一次数据库,其中is_send为1表示自己发的,然后同时给被接受者反向写入id但是send值为0,表示被接收。,第二,当用户删除自己的聊天框时,使用delete把该用户id和对象的id全部满足的记录删除") private Long talkerId;
private static final long serialVersionUID = 1L; }
|
然后就是最为关键的方法SocketIoService :
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
| @Service(value = "socketIOService") public class SocketIoServiceImpl implements SocketIoService {
private static Map<String, SocketIOClient> clientMap = new ConcurrentHashMap<>();
@Autowired private SocketIOServer socketIOServer;
@PostConstruct private void autoStartUp() throws Exception { start(); }
@PreDestroy private void autoStop() throws Exception { stop(); }
@Override public void start() throws Exception { socketIOServer.addConnectListener(client -> {
String loginUser = getParamsByClient(client).get("loginUser").get(0); clientMap.put(loginUser, client); });
socketIOServer.addDisconnectListener(client -> { String loginUser = getParamsByClient(client).get("loginUser").get(0); if (loginUser != null && !"".equals(loginUser)) { clientMap.remove(loginUser); client.disconnect(); } });
socketIOServer.addEventListener(PUSH_EVENT, PushMessage.class, (client, data, ackSender) -> { }); socketIOServer.start();
}
@Override public void stop() { if (socketIOServer != null) { socketIOServer.stop(); socketIOServer = null; } }
@Override public void pushMessageToUser(PushMessage pushMessage) { clientMap.get(pushMessage.getLoginUser()).sendEvent(PUSH_EVENT, pushMessage); }
private Map<String, List<String>> getParamsByClient(SocketIOClient client) { Map<String, List<String>> params = client.getHandshakeData().getUrlParams(); return params; } }
|
这里最为关键的语句我认为应该是:
1
| private static Map<String, SocketIOClient> clientMap = new ConcurrentHashMap<>();
|
通过建立一个ConcurrentHashMap,socket能够检测用户是否在线,在线就将用户put进ConcurrentHashMap,不在线就将其移除,若被发送消息的用户在线时,socket就会将这个消息通过ConcurrentHashMap中获得的用户信息来发送给相应的用户,这个特点在于这是后端主动发起消息给前端的,也是我在考虑到了聊天消息是怎么产生的时候,所了解的。
Security
之前实现过一个前后端耦合的商城,那里的Security配置都比较简单,而这里用的是前后端分离的vue和springboot结合,两者甚至都不在同一个端口,所以这里的security配置也有了很大的改观,并且利用了jwt来作为两个端口的认证凭证。
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
| @Configuration @EnableWebSecurity @EnableGlobalMethodSecurity(prePostEnabled=true) public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired MyUserDetailsService myUserDetailsService;
@Bean public JwtTokenFilter authenticationTokenFilterBean() throws Exception { return new JwtTokenFilter(); }
@Bean public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); }
@Override protected void configure( AuthenticationManagerBuilder auth ) throws Exception { auth.userDetailsService( myUserDetailsService ).passwordEncoder( new BCryptPasswordEncoder() ); }
@Override protected void configure( HttpSecurity httpSecurity ) throws Exception {
httpSecurity .csrf() .disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and() .authorizeRequests()
.antMatchers("/").permitAll()
.antMatchers("/user").hasRole("USER") .antMatchers("/admin").hasRole("ADMIN")
.antMatchers(HttpMethod.OPTIONS, "/**").permitAll() .antMatchers(HttpMethod.POST, "/login").permitAll() .antMatchers(HttpMethod.POST, "/register").permitAll()
.and() .cors() .and()
;
;
httpSecurity .addFilterBefore(authenticationTokenFilterBean(), UsernamePasswordAuthenticationFilter.class); httpSecurity.headers().cacheControl();
}
}
|
这里用的是:auth.userDetailsService( myUserDetailsService ).passwordEncoder( new BCryptPasswordEncoder() );也就是myUserDetailsService所继承的UserDetails通过重写方法来进行的密码认证。
在登录阶段,使用的是:
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
| @Service public class AuthServiceImpl implements AuthService {
@Autowired private AuthenticationManager authenticationManager;
@Autowired @Qualifier("myUserDetailsService") private UserDetailsService userDetailsService;
@Autowired MyUserDetailsService myUserDetailsService;
@Autowired private JwtTokenUtil jwtTokenUtil;
@Autowired private UserService userService;
@Override public String login( String username, String password ) { UsernamePasswordAuthenticationToken upToken = new UsernamePasswordAuthenticationToken( username, password ); final Authentication authentication = authenticationManager.authenticate(upToken); SecurityContextHolder.getContext().setAuthentication(authentication); final UserDetails userDetails = userDetailsService.loadUserByUsername( username ); final String token = jwtTokenUtil.generateToken(userDetails); return token; }
@Override public Integer register( User userToAdd ) {
final String username = userToAdd.getUsername(); if( !userService.findByUsername(username).isEmpty() ) { return null; } userToAdd=qq_register(userToAdd); BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); final String rawPassword = userToAdd.getPassword(); userToAdd.setPassword( encoder.encode(rawPassword) ); return userService.insert(userToAdd); } }
|
通过jwtTokenUtil.generateToken(userDetails),来对Security内置的UserDetails生成JWT
这里还有一个JWT的拦截器:
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
| @Component public class JwtTokenFilter extends OncePerRequestFilter {
@Qualifier("myUserDetailsService") @Autowired private UserDetailsService userDetailsService; @Autowired MyUserDetailsService myUserDetailsService;
@Autowired private JwtTokenUtil jwtTokenUtil;
@Override protected void doFilterInternal ( HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
String authHeader = request.getHeader( Const.HEADER_STRING ); if (authHeader != null && authHeader.startsWith( Const.TOKEN_PREFIX )) { final String authToken = authHeader.substring( Const.TOKEN_PREFIX.length() ); String username = jwtTokenUtil.getUsernameFromToken(authToken); if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { UserDetails userDetails = userDetailsService.loadUserByUsername(username); if (jwtTokenUtil.validateToken(authToken, userDetails)) { UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails( request)); SecurityContextHolder.getContext().setAuthentication(authentication); } } } chain.doFilter(request, response); } }
|
这拦截器首先在WebSecurityConfig中被定义了,Security就会使用这个拦截器来作为authenticationTokenFilter的认证
1 2 3 4
| public JwtTokenFilter authenticationTokenFilterBean() throws Exception { return new JwtTokenFilter(); }
|
微服务
在这一模块仅仅使用了eureka和ribbon,eureka作为注册中心,而ribbon作为负载均衡,当然,后续可以使用feign来进行负载均衡。并且还可在多模块的时候再继续使用Hystrix来实现服务熔断,以及和zipkin来实现分布式的链路追踪。
新建一个工程,配置eureka-server,并且在application.yml文件中配置:
1 2 3 4 5 6 7 8 9 10 11 12 13
| spring: application: name: eureka_server server: port: 8761 eureka: instance: hostname: eureka_server_8761 client: register-with-eureka: false fetch-registry: false service-url: defaultZone: http://localhost:8761/eureka/
|
同样的在原来的主体工程中添加:
1 2 3 4 5 6 7 8
| server: port: 8001 eureka: instance: prefer-ip-address: true client: service-url: defaultZone: http://localhost:8761/eureka/
|
就已经实现了注册中心,然后就是负载均衡:
在主体工程中添加:
并且添加RestTemplate:
1 2 3 4 5
| @Bean @LoadBalanced public RestTemplate restTemplate(){ return new RestTemplate(); }
|
从而controller中注入RestTemplate来实现负载均衡。
后续暂无。。。。