Todo-Tomato 是一款融合待办事项管理和番茄工作法,用于高效处理工作事务的工作利器,本文对 Todo-Tomato 使用的技术进行简要解读。

话不多说,先放上Todo-Tomato的界面截图。

Todo-Tomato界面

本文的技术解读基于Todo-Tomato v1.0.0版本

# 技术选型

使用目前比较流行的前后端分离进行开发:

  • 前端技术栈:Vue.js + vue-router + vuex + axios + element-ui

  • 后端技术栈:Spring Boot + Spring JPA + MySQL + druid + Redis

  • 部署:阿里云ECS + Ubuntu16.04 + Nginx + OpenJDK8 + HTTPS

由于使用的技术比较繁杂,这里选取一些个人觉得比较有记录价值的技术点进行说明。

# 后端技术点

# Log4j2日志配置

Configuration:
  status: warn

  Properties:
    Property:
      - name: log.level.console
        value: info
      - name: log.level.com.yupaits.todotomato
        value: info
      - name: log.base
        value: /root/logs
      - name: project.name
        value: todo-tomato
      - name: log.pattern
        value: "%d - ${project.name} - %p [%t] [%C{0}:%M] - %c - %m%n"

  Appenders:
    Console:
      name: CONSOLE
      target: SYSTEM_OUT
      ThresholdFilter:
        level: ${sys:log.level.console}
        onMatch: ACCEPT
        onMismatch: DENY
      PatternLayout:
        pattern: ${log.pattern}
    RollingFile:
      - name: ROLLIING_FILE
        ignoreExceptions: false
        fileName: ${log.base}/${project.name}.log
        filePattern: "${log.base}/${project.name}.%d{yyyy-MM-dd}.%i.log"
        PatternLayout:
          pattern: ${log.pattern}
        Policies:
          TimeBasedTriggeringPolicy:
            interval: 1
            modulate: true
          SizeBasedTriggeringPolicy:
            size: "10 MB"
        DefaultRolloverStrategy:
          max: 1000

  Loggers:
    Root:
      level: info
      AppenderRef:
        - ref: CONSOLE
        - ref: ROLLIING_FILE
    Logger:
      - name: log.level.com.yupaits.todotomato
        additivity: false
        level: ${sys:log.level.com.yupaits.todotomato}
        AppenderRef:
          - ref: CONSOLE
          - ref: ROLLIING_FILE
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

注意: filePatternPatternLayout.pattern 配置,当存在 %d%i 等日志专用变量地时候,yaml配置文件需要加上 "",否则配置不会被正确读取。

# AuthorizationInterceptor鉴权拦截器

  • WebConfig.java
@Configuration
public class WebConfig extends WebMvcConfigurerAdapter {

    @Autowired
    private ObjectMapper objectMapper;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(new AuthorizationInterceptor(objectMapper)).addPathPatterns("/api/**");
    }
}
1
2
3
4
5
6
7
8
9
10
11
  • AuthorizationInterceptor.java
public class  AuthorizationInterceptor implements HandlerInterceptor {

    private ObjectMapper objectMapper;

    public AuthorizationInterceptor(ObjectMapper objectMapper) {
    this.objectMapper = objectMapper;
    }

    @Override
    public boolean preHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o) throws Exception {
    Boolean authorized = (Boolean) httpServletRequest.getSession().getAttribute(Constants.AUTHORIZED_KEY);
    boolean hasAuth = authorized != null && authorized;
    if (!hasAuth) {
        httpServletResponse.setStatus(HttpStatus.UNAUTHORIZED.value());
        httpServletResponse.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
        objectMapper.writeValue(httpServletResponse.getWriter(), ResultCode.UNAUTHORIZED.getMsg());
    }
    return hasAuth;
    }

    @Override
    public void postHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, ModelAndView modelAndView) throws Exception {

    }

    @Override
    public void afterCompletion(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, Exception e) throws Exception {

    }
}
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

# BaseEntity JPA实体类基类

  • BaseEntity.java
@Data
@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
public abstract class BaseEntity<ID extends Serializable> implements Serializable {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private ID id;

    @Column(name = "create_at")
    @CreatedDate
    private Date createAt;

    @Column(name = "create_by")
    @CreatedBy
    private String createBy;

    @Column(name = "last_modified_at")
    @LastModifiedDate
    private Date lastModifiedAt;

    @Column(name = "last_modified_by")
    @LastModifiedBy
    private String lastModifiedBy;

    @PrePersist
    protected void onCreate() {
    createAt = new Date();
    lastModifiedAt = createAt;
    }

    @PreUpdate
    protected void onUpdate() {
    lastModifiedAt = new Date();
    }
}
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
  • EntityAuditorAware.java
@Component
public class EntityAuditorAware implements AuditorAware<String> {

    @Override
    public String getCurrentAuditor() {
    return Constants.ADMINISTRATOR;
    }
}
1
2
3
4
5
6
7
8
  • Task.java
@Data
@EqualsAndHashCode(callSuper = false)
@ToString(callSuper = true)
@Entity
@Table(name = "tdtmt_task")
public class Task extends BaseEntity<Integer> {
    private static final long serialVersionUID = 1L;

    @Column( name = "todo_id")
    private Integer todoId;

    @Column(name = "task_desc")
    private String taskDesc;

    @Column(name = "is_done")
    private Boolean done = Boolean.FALSE;

    public boolean isValid(boolean withPk) {
    boolean isValid = ValidateUtils.validId(todoId) && StringUtils.isNotBlank(taskDesc);
    return withPk ? ValidateUtils.validId(this.getId()) && isValid : isValid;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

# 前端技术点

# axios自定义实例配置

import axios from 'axios'
import router from '../router'
import store from '../store'

const Api = axios.create({
  baseURL: 'https://***.***.com'
});

Api.interceptors.response.use(res => {
  return res.data;
}, error => {
  if (error.response.status === 401) {
    store.dispatch('logout');
    router.push('/login');
  }
  return Promise.reject(error.response);
});

export default Api
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

# router.beforeEach路由钩子

前端项目主观上只有两个界面,登录页和主页。因此在路由 beforeEach 钩子函数中的鉴权逻辑比较简单。

router.beforeEach((to, from, next) => {
  const hasAuth = store.getters.hasAuth;
  if (to.path === '/') {
    if (!hasAuth) {
      next('/login');
    }
  } else {
    if (hasAuth) {
      next('/');
    }
  }
  next();
})
1
2
3
4
5
6
7
8
9
10
11
12
13

# 全局Api

main.js 中加入如下代码,将自定义的axios实例 Api 注入到全局Vue对象中,之后在Vue组件中就可以使用 this.Api.get() 的方式进行http请求。

import Api from './api'

Vue.prototype.Api = Api
1
2
3

# 部署技术点

# 申请免费SSL证书用于站点HTTPS化

Let's Encrypt 提供了免费SSL证书的申请服务。推荐使用With Shell Access方式,使用命令行工具 Certbot 申请证书。

完成证书申请之后,使用 certbot renew --dry-run 测试更新证书,可以正常更新的话,添加如下的 cron 任务定期更新证书。

# 每天3:00更新证书
0 3 * * * certbot renew >> ~/cron/cert.log --renew-hook "/usr/sbin/nginx -s reload"
1
2

使用 --renew-hook 才能保证使用的是最新的证书。

# Nginx HTTPS反向代理

Nginx配置如下:

http {
    
  sendfile on;
  tcp_nopush on;
  tcp_nodelay on;
  keepalive_timeout 65;
  types_hash_max_size 2048;
    
  include /etc/nginx/mime.types;
  default_type application/octet-stream;

  ssl_protocols TLSv1 TLSv1.1 TLSv1.2; # Dropping SSLv3, ref: POODLE
  ssl_prefer_server_ciphers on;
    
  access_log /var/log/nginx/access.log;
  error_log /var/log/nginx/error.log;
    
  gzip on;
  gzip_disable "msie6";
    
  gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;

  include /etc/nginx/conf.d/*.conf;
  include /etc/nginx/sites-enabled/*;

  server {
    listen 443 ssl;
    server_name jenkins.***.com;

    ssl_certificate /etc/letsencrypt/live/jenkins.***.com-0001/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/jenkins.***.com-0001/privkey.pem;

    ssl_session_timeout 5m;

    location / {
      proxy_pass http://127.0.0.1:8080;
    }
  }

  server {
    listen 443 ssl;
    server_name rabbit.***.com;

    ssl_certificate /etc/letsencrypt/live/rabbit.***.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/rabbit.***.com/privkey.pem;

    ssl_session_timeout 5m;

    location / {
        proxy_pass http://127.0.0.1:15672;
    }
  }
  
  server {
    listen 443 ssl;
    server_name todo-tomato.***.com;

    ssl_certificate /etc/letsencrypt/live/todo-tomato.***.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/todo-tomato.***.com/privkey.pem;

    ssl_session_timeout 5m;

    root /usr/share/todo-tomato;
    index index.html;

    location /api {
      proxy_pass http://127.0.0.1:***;
    }
    
    location /checkVisitCode {
      proxy_pass http://127.0.0.1:***;
    }
    
    location /signOut {
      proxy_pass http://127.0.0.1:***;
    }
  }
}
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

该配置同时将服务器上的 JenkinsRabbitMQ 也进行了HTTPS反向代理。