文章目录
小木匠项目简介
小木匠是一个数值分析科研人员的专属学术交流平台,包括头条,问答,活动,交友,吐槽和招聘六大频道。
系统设计
系统构架
项目采用前后端分离的系统架构。
后端架构为:
SpringBoot+SpringCloud+SpringMVC+SpringData(Spring全家桶)
前端构架为:
NUXT+ElementUI+Vue+Node.js(以Node.js为核心的Vue.js前端技术生态架构)
前后端分离
前后端分离已成为互联网项目开发的业界标准使用方式,通过nginx(Web服务器)+tomcat(应用服务器)的方式(也可以中间加一个Node.js)有效的进行解耦。核心思想是前端html页面通过ajax调用后端的restuful api接口并使用json数据进行交互。
利用Swagger编写统一的API文档,前端用EasyMock模拟数据生成,后端用Postman模拟前端请求,使前后端分别进行开发,降低了耦合。
采用RESTful架构,即表现层状态转化,GET、POST、PUT、DELETE。
模块划分
为便于扩展,将后端划分为多个微服务模块分别开发:
技术应用
Docker容器
Docker设计的目的就是要加强开发人员写代码的开发环境与应用程序要部署的生产环境一致性,保证可移植性,且安装运行快速方便。将MySQL、Redis、MongoDB、ElasticSearch均部署到Docker容器中。
下载镜像:
docker pull centos/mysql-57-centos7
创建容器:
MYSQL_ROOT_PASSWORD=123456 centos/mysql-57-centos7 docker run -di --name=tensquare_mysql -p 3306:3306 -e
分布式ID生成器
当数据库数据量较大时,常常需要对MySQL进行分片处理(一致性哈希查询),因此不能使用数据库本身的自增功能开产生主键,需要由算法生成唯一的ID,采用开源的Twitter的snowflake(雪花)算法。
默认情况下41bit的时间戳可以支持该算法使用到2082年,10bit的工作机器id可以支持1024台机器,序列号支持1毫秒产生4096个自增序列id . SnowFlake的优点是,整体上按照时间自增排序,而对比UUID是乱序的,会使数据库性能下降。
MySQL优化
建立索引
查询推荐职位列表时,查询按照时间降序的前8条记录:
public List<Recruit> findTop4ByStateOrderByCreatetimeDesc(String state);
为提高查询效率,给职位表的createtime字段建立索引:
ALTER TABLE `tb_recruit` ADD PRIMARY KEY (`createtime`)
Redis缓存
由于文章在主页中显示,查询频率很高,为提高查询性能,利用Redis缓存技术解决。
在查询文章时,先尝试从缓存中取数据,如果缓存中没有数据,再到数据库中查找放入缓存:
public Article findById(String id) {
//先从缓存中查询当前对象
Article article = (Article) redisTemplate.opsForValue().get("article_" + id);
//如果没有取到,从数据库中查询
if (article == null) {
article = articleDao.findById(id).get();
redisTemplate.opsForValue().set("article_" + id, article);
}
return article;
}
在删除和修改时,先在缓存中删除数据,再在数据库中修改
MongoDB
问答模块存在以下的特点:
- 数据量大
- 写入操作频发
- 价值较低
- 数据之间有关联
对于这样的数据,可以采用MongoDB来实现数据的存储,MongoDB是一个跨平台的,面向文档的数据库,是当前NoSQL数据库产品中最热门的一种,它是非关系型数据库中功能最丰富,最像关系型数据库的产品。
利用SpringDataMongoDB可以完成持久层业务:
public interface SpitDao extends MongoRepository<Spit,String> {
public Page<Spit> findByParentid(String parentid, Pageable pageable);
}
此外,文章评论部分也用到了MongoDB。
分布式搜索引擎ElasticSearch
ElasticSearch是一个基于Lucene的搜索服务器,提供了全文搜索引擎,基于Restful web接口。
Elasticsearch与MySQL数据库逻辑结构概念对比
Elasticsearch | 关系型数据库MySQL |
---|---|
索引(index) | 数据库(database) |
类型(type) | 表(table) |
文档(document) | 行(row) |
基本查询匹配url:
http://192.168.184.134:9200/articleindex/article/_search?q=title:有限元
默认的中文分词是将每个字看成一个词,采用IK分词器可以合理地将汉语进行分词。
利用spring‐data‐elasticsearch依赖,可以像操作数据库一样实现对Elasticsearch的增删改查。
实体类:
@Document(indexName = "xiaomujiang_article", type = "article")
public class Article implements Serializable {
@Id
private String id;//ID
/*
是否索引:表示是否能够被搜索
是否分词:表示搜索的时候是整体匹配还是单词匹配
是否存储:表示是否在页面显示
*/
@Field(index = true, analyzer = "ik_max_word", searchAnalyzer = "ik_max_word")
private String title;//标题
@Field(index = true, analyzer = "ik_max_word", searchAnalyzer = "ik_max_word")
private String content;//文章正文
private String state;//审核状态
//getter and setter ......
}
持久层:
public interface ArticleDao extends ElasticsearchRepository<Article, String> {
// 检索
public Page<Article> findByTitleOrContentLike(String title, String content, Pageable pageable);
}
通过Logstash日志搜集处理框架进行ElasticSearch和MySQL之间的同步
消息中间件RabbitMQ
消息队列在实际应用中常用的使用场景:异步处理,应用解耦,流量削锋和消息通讯。
RabbitMQ的构架:
生产者将消息发送到Exchange(交换器),由Exchange将消息路由到一个或多个Queue中(或者丢弃)。Exchange并不存储消息。RabbitMQ中的Exchange有direct、fanout、topic、headers四种类型,每种类型对应不同的路由规则。
- 直接模式:将消息发给唯一一个节点时使用
- 分列模式:将消息一次发给多个队列时使用
- 主题模式:任何发送到Topic Exchange的消息都会被转发到所有关心RouteKey中指定话题的Queue上
生产者:
public class MqTest {
@Autowired
private RabbitTemplate rabbitTemplate;
@Test
public void testSend(){
rabbitTemplate.convertAndSend("itcast","我要红包");
}
}
消费者:
@Component
@RabbitListener(queues="itcast" )
public class Customer1 {
@RabbitHandler
public void showMessage(String message){
System.out.println("itcast接收到消息:"+message);
}
}
BCrypt密码加密
Spring Security提供了BCryptPasswordEncoder类,实现Spring的PasswordEncoder接口使用BCrypt强
哈希方法来加密密码。
密码加密:
@Autowired
BCryptPasswordEncoder encoder;
String newpassword = encoder.encode(user.getPassword());
密码验证:
encoder.matches(password,user.getPassword());
Token身份认证
基于Token身份认证的流程为:
-
客户端使用用户名跟密码请求登录
-
服务端收到请求,去验证用户名与密码
-
验证成功后,服务端会签发一个 Token,再把这个 Token 发送给客户端
-
客户端收到 Token 以后可以把它存储起来,比如放在 Cookie 里
-
客户端每次向服务端请求资源的时候需要带着服务端签发的 Token
-
服务端收到请求,然后去验证客户端请求里面带着的 Token,如果验证成功,就向客户端返回请求的数据
本项目中采用的是JSON Web Token(JWT),一个JWT实际上就是一个字符串,它由三部分组成,头部、载荷与签名,形式如下:
eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiI4ODgiLCJzdWIiOiLlsI_nmb0iLCJpYXQiOjE1MjM0MTM0NTh9.gq0J‐cOM_qCNqU_s‐d_IrRytaNenesPmqAIhQpYXHZk
生成JWT:
public String createJWT(String id, String subject, String roles) {
long nowMillis = System.currentTimeMillis();
Date now = new Date(nowMillis);
JwtBuilder builder = Jwts.builder().setId(id)
.setSubject(subject)
.setIssuedAt(now)
.signWith(SignatureAlgorithm.HS256, key).claim("roles", roles);
if (ttl > 0) {
builder.setExpiration( new Date( nowMillis + ttl));
}
return builder.compact();
}
解析JWT:
public Claims parseJWT(String jwtStr){
return Jwts.parser()
.setSigningKey(key)
.parseClaimsJws(jwtStr)
.getBody();
}
SpringCloud
Spring Cloud是一系列框架的有序集合。它利用Spring Boot的开发便利性巧妙地简化了分布式系统基础设施的开发,如服务发现注册、配置中心、消息总线、负载均衡、熔断器、数据监控等,都可以用Spring Boot的开发风格做到一键启动和部署,包括:
- 服务发现——Netflix Eureka
- 服务调用——Netflix Feign
- 熔断器——Netflix Hystrix
- 服务网关——Netflix Zuul
- 分布式配置——Spring Cloud Config
- 消息总线 —— Spring Cloud Bus
服务发现组件 Eureka
Eureka Server提供服务注册服务,各个节点启动后,会在Eureka Server中进行注册,这样EurekaServer中的服务注册表中将会存储所有可用服务节点的信息,服务节点的信息可以在界面中直观的看到。
Feign
Feign实现服务间的调用,例如在问答微服务调用基础微服务的方法(根据ID查询标签)。
step1: 在问答模块的启动类上添加注解:
@EnableDiscoveryClient
@EnableFeignClients
step2:创建基础微服务方法的接口,可自动实现:
@FeignClient("tensquare‐base")
public interface LabelClient {
@RequestMapping(value="/label/{id}", method = RequestMethod.GET)
public Result findById(@PathVariable("id") String id);
}
微服务网关Zuul
微服务网关是介于客户端和服务器端之间的中间层,所有的外部请求都会先经过微服务网关。
通过Zuul的过滤器可以截取token并验证。
业务难点
点赞
对于文章的点赞
文章的点赞并不频繁,且实时性没有特别重要,在点赞处理上,直接在MySQL中进行+1修改即可,对Redis缓存中的文章加上1天的过期时间,在过期后再一次加载时就可以得到最新的点赞数了。
redisTemplate.opsForValue().set("article_" + id, article,1,TimeUnit.DAYS);
对于问答的点赞
直接利用MongoTemplete类来将问答对应文档的点赞属性加1即可(inc命令):
public void updateThumbup(String id) {
//方式一:效率低
/*Spit spit = spitDao.findById(id).get();
spit.setThumbup(spit.getThumbup() + 1);
spitDao.save(spit);*/
//方式二:原生命令
Query query = new Query();
query.addCriteria(Criteria.where("_id").is(id));
Update update = new Update();
update.inc("thumbup", 1);
mongoTemplate.updateFirst(query, update, "spit");
}
控制点赞不能重复
通过Redis控制点赞不能重复,当一个用户已经对某个回答点赞后,在Redis中加入一条记录,key为:
thumbup_+userid+qaid
当查询到Redis中有这个key时,则返回重复点赞提示。
用户注册
用户注册时要通过手机验证码进行验证,解决方案为:
在用户微服务编写API ,生成手机验证码,存入Redis并发送到RabbitMQ。
public void sendAms(String mobile) {
//生成随机数
String checkcode = RandomStringUtils.randomNumeric(6);
//向缓存中放一份
redisTemplate.opsForValue().set("checkcode_" + mobile, checkcode, 1, TimeUnit.HOURS);
Map<String, String> map = new HashMap<>();
map.put("mobile", mobile);
map.put("checkcode", checkcode);
//给用户发一份
rabbitTemplate.convertAndSend("sms",map);
}
开发短信发送微服务,从rabbitMQ中提取消息,调用阿里大于短信接口实现短信发送。
交友业务
-
当用户登陆后在推荐好友列表中点击“心”,表示关注此人 ,在数据库tb_friend表中插入一条数据,islike 为0;当你已经关注对方后又点击了关注,则会提示重复关注。
-
当你点击了关注过的人,也关注了你 , 表示互粉成功,在tb_friend表中修改一条数据,islike为1 ,并且将你喜欢她的数据islike也修改为1;
-
当你点击了拉黑某人(点击了叉),向tb_nofriend表添加记录;
-
当两个人互粉后,其中一人拉黑对方了,删除好友表中的记录 ,向非好友表中添加记录。
因此在业务层中实现的方法包括:addFriend、addNoFriend和deleteFriend三个
同时需要利用SpringCloud中的Feign实现交友业务与用户微服务的调用,当A关注B后,A的关注数加1,B的关注数减1,这个操作要在tb_user表中修改。
@FeignClient("xiaomujiang-user")
public interface UserClient {
@RequestMapping(value = "/user/{userid}/{friendid}/{x}", method = RequestMethod.PUT)
public void updatefanscountandfollowcount(@PathVariable("userid") String userid,
@PathVariable("friendid") String friendid,
@PathVariable("x") int x);
}
积分排名
为鼓励用户发表文章和回答,用户每发表一篇文章或回答问题时都会得到相应的积分,在首页上会有每周的积分排名。本项目中利用Redis中的SortedSet实现,SortedSet通过score权重去排名,可以得到前十的排名。
zrevrange hotmessage 0 10
由于每周更新,对key加上一周的过期时间。
开发要点
公共异常处理
为了使代码更容易维护,创建一个类集中处理异常:
@RestControllerAdvice
public class BaseExceptionHandler {
@ExceptionHandler(value = Exception.class)
public Result exception(Exception e) {
e.printStackTrace();
return new Result(false, StatusCode.ERROR, e.getMessage());
}
}
跨域处理
浏览器从一个域名的网页去请求另一个域名的资源时,域名、端口、协议任一不同,都是跨域 。本项目采用前后端分离开发的,也是前后端分离部署的,必然会存在跨域问题。 SpringBoot对于跨域请求处理只需要在controller类上添加注解@CrossOrigin
即可。这个注解其实是CORS(Cross-Origin Resource Sharing, 跨源资源共享)的实现。
CSRF(Cross-site request forgery),中文名称:跨站请求伪造。CSRF 攻击的原理大致描述如下:有两个网站,其中A网站是真实受信任的网站,而B网站是危险网站。在用户登陆了受信任的A网站是,本地会存储A网站相关的Cookie,并且浏览器也维护这一个Session会话。这时,如果用户在没有登出A网站的情况下访问危险网站B,那么危险网站B就可以模拟发出一个对A网站的请求(跨域请求)对A网站进行操作,而在A网站的角度来看是并不知道请求是由B网站发出来的(Session和Cookie均为A网站的),这时便成功发动一次CSRF 攻击。
带分页的条件查询
关键:两个参数:Specification+PageRequest
public Page<Label> pageQuery(Label label, int page, int size) {
Pageable pageable = PageRequest.of(page-1,size);
return labelDao.findAll(new Specification<Label>() {
@Override
public Predicate toPredicate(Root<Label> root, CriteriaQuery<?> criteriaQuery, CriteriaBuilder criteriaBuilder) {
List<Predicate> list = new ArrayList<>();
if (label.getLabelname() != null && !"".equals(label.getLabelname())) {
Predicate predicate = criteriaBuilder.like(root.get("labelname").as(String.class), "%" + label.getLabelname() + "%");
list.add(predicate);
}
if(label.getState()!=null && !"".equals(label.getState())){
list.add(criteriaBuilder.equal(root.get("state").as(String.class), label.getState()));
}
Predicate[] parr = new Predicate[list.size()];
list.toArray(parr);
return criteriaBuilder.and(parr);
}
},pageable);
}
关联查询
在问答模块中生成最新回答列表时,需要关联查询,选出回复时间最近的问题:
public interface ProblemDao extends JpaRepository<Problem, String>, JpaSpecificationExecutor<Problem> {
@Query(value = "select * from tb_problem,tb_pl where id = problemid and labelid = ? order by replytime desc", nativeQuery = true)
public Page<Problem> newList(String labelid, Pageable pageable);
@Query(value = "select * from tb_problem,tb_pl where id = problemid and labelid = ? order by reply desc", nativeQuery = true)
public Page<Problem> hotList(String labelid, Pageable pageable);
@Query(value = "select * from tb_problem,tb_pl where id = problemid and labelid = ? and reply = 0", nativeQuery = true)
public Page<Problem> waitList(String labelid, Pageable pageable);
}
SpringCache
Spring Cache的核心就是对某个方法进行缓存,其实质就是缓存该方法的返回结果,并把方法参数和结果用键值对的方式存放到缓存中,当再次调用该方法使用相应的参数时,就会直接从缓存里面取出指定的结果进行返回。
@Cacheable-------使用这个注解的方法在执行后会缓存其返回结果。
@CacheEvict--------使用这个注解的方法在其执行前或执行后移除Spring Cache中的某些元素。
@Cacheable(value = "gathering", key = "#id")
public Gathering findById(String id) {
return gatheringDao.findById(id).get();
}
拦截器授权Token
在Controller处理请求之前,先通过拦截器Interceptor获取并解析Token,放入Request域中再进行后续处理。
@Component
public class JwtInterceptor implements HandlerInterceptor {
@Autowired
private JwtUtil jwtUtil;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
//System.out.println("经过拦截器");
//拦截器仅对请求头进行token抽取
final String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
final String token = authHeader.substring(7); // The part after "Bearer "
try {
Claims claims = jwtUtil.parseJWT(token);
if (claims != null) {
if ("admin".equals(claims.get("roles"))) {//如果是管理员
request.setAttribute("admin_claims", claims);
}
if ("user".equals(claims.get("roles"))) {//如果是用户
request.setAttribute("user_claims", claims);
}
}
} catch (Exception e) {
throw new RuntimeException("令牌不正确!");
}
}
return true;
}
}