四川大学软件学院 2022 年春季 IT 企业实训项目开发概述

IT 企业实训

个人主页:https://tzq0301.cn

时间安排

09:15 ~ 12:15

14:15 ~ 17:15

wiki

URL: wiki.suncaper.net

PPT

成员分工

童昭旗
  • 负责 Java 后端微服务的构建
    • 微服务网关
    • 用户管理
    • 初访管理
    • 咨询管理
    • 值班管理
    • 统计分析
    • 通用模块
    • 短信服务
  • 负责 Redis 集群的配置
  • 负责 MongoDB 的购买与配置
  • 负责 SMS 的购买与配置
  • 负责 OSS 的购买与配置
  • 负责 RabbitMQ 的购买与配置
  • 负责 GitHub CI 环境配置
LJZ

负责导出模块编写:

  • 导出 PDF:接受一次心理咨询的所有记录,并将它们制作成 pdf 文件——心理咨询报告。
  • 导出 ZIP:压缩多个心理咨询报告为zip格式并导出。
  • 导出 XLSX:导出多个咨询师的汇总数据,附带图表。

所有导出文件都被上传到 OSS,响应消息中只有对应文件的 URL。

负责导出模块的部署和维护,负责接口测试与集成测试工作。

GYP

负责前端的“初访员”“心理助理”以及“咨询师”界面的编写。

YY

负责前端的“管理员”“登录页面”“学生”界面以及“主界面框架”的编写。

技术架构图

系统架构设计

部署架构图

PCS部署图

Technical Stack

  1. Java & Go & HTML & CSS & JavaScript
  2. Guava
  3. Spring Framework
  4. Spring Web WebFlux + RESTful API
  5. Spring Data Reactive + MongoDB
  6. Spring Boot
  7. TDD - JUnit
  8. Slf4j & Log4j2
  9. Redis Cluster
  10. Spring Security + Json Web Token
  11. Message Queue(RabbitMQ)
  12. Spring Cloud Alibaba
  13. Nacos (Service Discovery & Config Center)
  14. WebClient (HTTP Client)
  15. Spring Gateway
  16. Reactive LoadBalance
  17. Object Storage Service
  18. Short Message Service
  19. Vue
  20. Element-UI
Web 框架

Spring WebFlux —— 响应式的、异步非阻塞的 Web 框架,依托于异步 IO 框架 Netty,使用一种基于数据流(Data Stream)和变化传递(Propagation of Change)的声明式(Declarative)的编程范式

Reactive Spring Boot Best Sale, UP TO 65% OFF | ebuilding.es

数据持久化存储方案

MongoDB —— 内置 GridFS,支持大容量的存储;文档结构的存储方式,能够更便捷的获取数据;内置 Sharding,支持集群扩充性能优越,将热数据存储在物理内存中,使得热数据的读写变得十分快;MongoDB 的 BSON 格式的数据,比 MySQL 的传统 SQL 表更符合 Java 面向对象的业务数据模型

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-IubWYuXu-1648015733273)(https://www.bloorresearch.com/wp-content/uploads/2013/03/MONGO-DB-logo-300x470–x.png)]

项目使用阿里云的云数据库 MongoDB 服务作为业务模型持久化存储的方案,选型为三节点副本集实例,价值 ¥9.99。

image-20220311192115979

数据缓存方案

Redis —— 响应速度快;并发安全;支持集群部署

Three Ways to do CRUD Operations On Redis · Async Blog

项目使用 Docker Compose 编排了**“一主二从”三节点的高可用 Redis 集群**,继“五个一百精品项目申报”的分布式一致 ID 生成后,本项目使用 Redis 进行 JWT 令牌与手机验证码的数据存储与时长失效管理,并对“高读取低写入”数据进行缓存服务,并使用命名空间对不同业务的数据进行分隔。

image-20220311191844012

image-20220311191813184

认证 & 授权

Spring Security —— 基于 Spring 的企业应用系统提供声明式的安全访问控制解决方案的安全框架

JWT —— 用于作为 JSON 对象在各方之间安全地传输信息

项目使用 Spring Security + JWT 作为用户认证与授权的解决方案,使用 Spring Security 并以用户角色为粒度进行接口权限的访问控制,并使用无状态的 JWT 在客户端对用户登录状态、用户信息等进行存储,保存少量有效信息以减少对数据库的访问,且无需进行 session 的多机数据共享配置。

Spring Boot - Spring Security + JWT Complete Tutorial With Example

微服务框架

Spring Cloud —— 服务治理平台,是若干个框架的集合,提供了全套的分布式系统解决方案。

项目基于 Spring Cloud 对 Spring Cloud Alibaba、Spring Cloud Gateway、Spring Cloud Load Balancer 等框架进行集成。

微服务注册中心

Nacos —— 支持基于 DNS 和基于 RPC 的服务发现:服务实例在启动时注册到服务注册表,并在关闭时注销。

community

项目使用 Nacos 作为微服务注册中心对项目的微服务进行服务注册以便可以只使用服务名即可对相应微服务进行访问,无需手动维护所有的服务访问 ip 地址列表,无需手动构建多服务的负载均衡策略;Nacos 在自动或手动下线服务,使用消息机制通知客户端,服务实例的修改很快响应,而传统的服务注册中心 Eureka 只能通过任务定时剔除无效的服务。

image-20220311191432543

微服务配置中心

Nacos —— 以中心化、外部化和动态化的方式管理所有环境的应用配置和服务配置;消除了配置变更时重新部署应用和服务的需要,让配置管理变得更加高效和敏捷

项目使用 Nacos 作为微服务配置中心对微服务的项目进行线上动态部署,可以避免在 application.yml 中对隐私信息进行明文配置;可以动态更改配置(例如用户默认密码)而无需重启微服务;可以在多个微服务间共享 Redis、SMS 之间的配置信息

image-20220311213551402

微服务网关

Spring Cloud Gateway —— 为微服务架构提供一种简单有效的统一的 API 路由管理方式

项目使用基于高性能通信框架 Netty 的 Spring Cloud Gateway,配合 Spring Security 进行用户角色权限认证管理,配合 JWT 进行用户登录信息管理,全局进行 CORS 跨域配置。

在這裡插入圖片描述

微服务负载均衡

Spring Cloud Load Balancer Reactive —— 响应式的负载均衡组件

项目使用响应式的负载均衡组件,而非 Ribbon 的阻塞式负载均衡。

微服务通信组件(HTTP)

WebFlux WebClient —— 响应式 HTTP 客户端

项目曾尝试使用阻塞式微服务通信组件 Spring Cloud OpenFeign 与其响应式的组件 Playtika/feign-reactive,但是出现了服务阻塞的情况,于是使用了 Spring WebFlux 提供的响应式 WebClient 进行微服务调用。

微服务通信组件(消息队列)

阿里云 AMQP(RabbitMQ)—— 高性能消息队列实现

项目通过 RabbitMQ 消息队列组件进行微服务间通信,例如在短信业务上使用消息队列进行异步业务操作。

image-20220311221214311

对象存储服务

阿里云 OSS 服务 —— 海量、安全、低成本、高可靠的云存储服务,提供99.9999999999%(12个9)的数据持久性,99.995%的数据可用性

项目使用云上存储服务,避免了在 HTTP 连接上直接传输文件(PDF、ZIP、XLSX)所带来的传输开销,且方便了资源文件的管理。

image-20220311221823658

短信服务

腾讯云 SMS 服务 —— 高可靠性的短信发送服务,保证 99% 一秒内到达

项目使用腾讯云 SMS 服务以支持用户使用手机号与短信验证码进行登录操作。

image-20220311222339578

前端

对 Web 标准的理解(结构、表现、行为)、渲染原理、依赖管理、兼容性、CSS 语法、层次关系,常用属性、布局、选择器、权重、盒模型、CSS 3、Flexbox、深度选择器、JavaScript(数据类型、运算、对象、Function、继承、闭包、作用域、事件、Prototype、RegExp、JSON、Ajax、异步请求、模板引擎、模块化、Flux、同构、算法、ECMAScript6)

主流 MVVM 框架 —— Vue 用于构建用户界面的渐进式框架,使用 Element-UI 视图组件辅助前端开发。

XLSX 库:对用户上传的 Excel 文件进行解析并获取数据

Golang
  • 使用 Go 语言进行导出模块的编写;
  • 使用 lute-pdf 库生成 PDF 文件;
  • 使用 excelize 库生成 XLSX 文件。

Technical Highlights

高可扩展性的统一返回体枚举接口

在项目开发中,Result 类被放在共用的工具库(common)中。

然而,每个不同的微服务业务可能都有自己的状态码,甚至每个接口都可能会自己的状态码,而每次调用 Result.success(...) 都会造成一段极为冗长的代码

例如:Result.success(businessDataModle, LOGIN_BY_PHONE_RESULT_ENUM.getCode(), LOGIN_BY_PHONE_RESULT_ENUM.getMessage())

观察到每次代码调用都要调用枚举类型的 getCode()getMessage() 方法,故将这两个方法抽离成接口(extract interface —— 《重构——改善既有代码的设计》):

public interface ResultEnumerable {
    
    
    Integer getCode();

    String getMessage();

    @Override
    String toString();  /*  可选  */
}

并根据依赖倒置原则(Dependency Inversion Principle)为 Result 类增加面向接口的多态接口:

public class Result<T> implements Serializable {
    
    
    private static final Long serialVersionUID = 9192910608408209894L;
    
    private final T data;
    private final Integer code;
    private final String message;

    private Result(T data, Integer code, String message) {
    
    
        this.data = data;
        this.code = code;
        this.message = message;
    }

    // 增加方法,参数类型为 ResultEnumerable 接口
    public static <T> Result<T> success(T data, ResultEnumerable resultEnum) {
    
    
        return new Result<>(data, resultEnum.getCode(), resultEnum.getMessage());
    }

    public static <T> Result<T> success(T data, int code, String message) {
    
    
        return new Result<>(data, code, message);
    }
        
    // 增加方法,参数类型为 ResultEnumerable 接口
    public static <T> Result<T> error(T data, ResultEnumerable resultEnum) {
    
    
        return new Result<>(data, resultEnum.getCode(), resultEnum.getMessage());
    }

    public static <T> Result<T> error(T data, int code, String message) {
    
    
        return new Result<>(data, code, message);
    }
}

此时,我们只需要让业务结果枚举类实现 ResultEnumerable 接口即可:

public enum ResultEnum implements ResultEnumerable {
    
    
    SUCCESS(0, "Success"), // 请求成功
    ERROR(1, "Error"); // 请求失败

    private final Integer code;

    private final String message;

    ResultEnum(Integer code, String message) {
    
     
        this.code = code; 
        this.message = message; 
    }

    @Override
    public Integer getCode() {
    
     return code; }

    @Override
    public String getMessage() {
    
     return message; }
}

此时,只需要将枚举对象传入方法即可:

class Person {
    
    
    private String name;
    private Integer age;
    
    public Person(String name, Integer age) {
    
    
        this.name = name;
        this.age = age;
    }
    
    // 省略构造器、getter/setter、toString 等方法
}

@RestController
public class TestController {
    
    
    @GetMapping("/the_most_handsome_person")
 	public Result<?> theMostHandsomePerson() {
    
    
        Person zhangsan = new Person("张三", 20);
        
        return Result.success(zhangsan, ResultEnum.SUCCESS); // 使用枚举即可
    }   
}
易用性极高

在实际开发中,只需要将 Result 类与 ResultEnumerable 接口放在自己的项目代码中即可,无其他依赖。

可扩展性高

在自己的实际业务中,只需要实现 ResultEnumerable 接口,就可以实现自己的枚举类!

可读性高

命名清晰的枚举可以清晰地表述实际信息(例如 USER_NOT_FOUND):

import static com.example.demo.result.UserResultEnum.*;

@RestController
public class TestController {
    
    
    @GetMapping("/the_most_handsome_person")
 	public Result<?> theMostHandsomePerson() {
    
    
        Person zhangsan = new Person("张三", 20);
        
        // 代码内容:Result.success(zhangsan, SUCCESS)
        // 实际含义: 返回    成功     张三       成功
        // return Result.success(zhangsan, SUCCESS);
        
        // 代码内容:Result.error(null, USER_NOT_FOUND)
        // 实际含义: 返回    失败          没有该用户
        return Result.error(null, USER_NOT_FOUND);
    }   
}
微服务数据动态刷新

项目使用 Spring Cloud 与 Nacos 进行数据配置的动态刷新(用户账号的默认密码、阿里云 RabbitMQ 连接配置)。

例如在进行用户数据的批量导入操作时,会给用户一个默认的密码(例如 123456),如果有需求需要更改默认密码时,可以直接在 Nacos 配置中心中进行数据的配置,而不需要重启整个服务项目:

/**
 * @author tzq0301
 * @version 1.0
 */
@Service
@RefreshScope
public class UserService {
    
    
    private final UserInfrastructure userInfrastructure;

    private final UserManager userManager;

    private final PasswordEncoder passwordEncoder;

    @Value("${auth.password}")
    private String defaultPassword;

    public UserService(
            UserInfrastructure userInfrastructure,
            UserManager userManager,
            PasswordEncoder passwordEncoder) {
    
    
        this.userInfrastructure = userInfrastructure;
        this.userManager = userManager;
        this.passwordEncoder = passwordEncoder;
    }

    public Mono<List<User>> saveUsers(final Flux<ImportStudentInfo> studentInfos) {
    
    
        return studentInfos
                .flatMap(userInfo -> {
    
    
                    String birthday = userInfo.getIdentity().substring(6, 14); // 取身份证的出生日期部分
                    return userInfrastructure.saveUser(Users.newUser(
                            userInfo.getId(), userInfo.getUsername(),
                            passwordEncoder.encode(defaultPassword), // 使用默认密码(动态配置)并加密
                            userInfo.getRole(), userInfo.getSex(), DateUtils.stringToLocalDate(birthday),
                            userInfo.getPhone(), userInfo.getEmail(), userInfo.getIdentity()));
                })
                .collectList();
    }
}
请求路径参数 & JWT保证业务层面的接口权限

作为极度注重用户隐私信息的心理咨询系统平台,我们非常注重用户的隐私。

注意到,Spring Security 的接口访问权限是以用户角色为基本粒度的,一些用户接口中会包含操作对象的 id(例如 GET /user_id/{user_id}/info),如果有一个具有同样访问权限的用户在 URL 中输入了另一个用户的 id,Spring Security 不会对这种**“违规请求”**进行拦截,然而,这实际上是不允许的。

故项目对在进行业务操作之前对请求路径中的用户 ID 与从 JWT 中抽取的 ID 进行对比,以保证对目标数据进行操作的是本人,从而实现了以用户个体为基本粒度的访问权限控制体系,更好地保护了用户的隐私信息。

用户体验极高的登录业务
通过账号密码登录
  1. 【客户端】通过 http://host:port/test/authorization 接口向【 pcs-gateway 微服务 】发起登录凭证 JWT 是否依然有效的测试请求
    1. 如果有效,则直接跳转至首页
    2. 否则,继续执行登录操作
  2. 【 客户端 】通过 http://host:port/login/account/{account}/password/{password} 向【 pcs-gateway 微服务 】发起登录请求
  3. 【 pcs-gateway 微服务 】中的 JWT Filter 判断该请求是登录请求,放行
  4. 【 pcs-gateway 微服务 】中的 Security Filter 判断该请求是登录请求,放行
  5. 【 pcs-gateway 微服务 】向【 pcs-auth 微服务 】发起请求
    1. 如果账号密码错误,则返回 401 UnAuthorization 错误
    2. 如果正确,则继续执行
  6. 【 pcs-gateway 微服务 】基于用户的 ID 与角色(role)生成 JWT
  7. 【 pcs-gateway 微服务 】将生成的 JWT 发送到【 Redis 】中,并设置过期时间
  8. 【 pcs-gateway 微服务 】向【客户端】进行响应
通过手机号与短信验证码登录
  1. 【客户端】通过 http://host:port/test/authorization 接口向【 pcs-gateway 微服务 】发起登录凭证 JWT 是否依然有效的测试请求
    1. 如果有效,则直接跳转至首页
    2. 否则,继续执行登录操作
  2. 【 客户端 】通过 http:host:port/phone/{phone}/code 向【 pcs-gateway 微服务 】发起发送短信验证码请求
  3. 【 pcs-gateway 微服务 】中的 JWT Filter 判断该请求是申请短信验证码请求,放行
  4. 【 pcs-gateway 微服务 】中的 Security Filter 判断该请求是申请短信验证码请求,放行
  5. 【 pcs-gateway 微服务 】向【 pcs-auth 微服务 】发起请求
    1. 如果手机号错误或者非用户手机号,则返回错误
    2. 如果正确,则继续执行
  6. 【 pcs-gateway 微服务 】将手机号发送至【 RabbitMQ
  7. 【 pcs-message 微服务 】监听【 RabbitMQ 】中的指定队列,获取手机号,并自定义算法生成六位数字验证码
  8. 【 pcs-message 微服务 】使用【 腾讯云 SMS 服务 】向手机号发送验证码
  9. 【 pcs-message 微服务 】将手机号与验证码作为键值对存放入【 Redis 】中,并设置过期时间
  10. 【 客户端 】收到短信后,调用 http:host:port/login/phone/{phone}/code/{code} 向【 pcs-gateway 微服务 】发起登录请求
  11. 【 pcs-gateway 微服务 】向【 Redis 】查看是否存在相应的键值对
    1. 如果不存在,则返回 401 UnAuthorization 错误
    2. 如果正确,则继续执行
  12. 【 pcs-gateway 微服务 】基于用户的 ID 与角色(role)生成 JWT
  13. 【 pcs-gateway 微服务 】将生成的 JWT 发送到【 Redis 】中,并设置过期时间
  14. 【 pcs-gateway 微服务 】向【客户端】进行响应
简单工厂模式

项目将业务实体类的构造器访问可见性设置为 default(仅包内可见),使用简单工厂模式负责对业务实体类的创建。

image-20220312100226140

充血模型

借鉴 DDD 的思想,在项目中讲业务模型构造成充血模型,赋予其真正的业务性能。

image-20220312100449331

Java 8 函数式接口封装数据分页工具

使用卢老师教的 PredicateFunction 等函数式接口进行数据分页工具的封装。

public static <T, U> Mono<List<U>> pagingFlux(Flux<T> flux, Predicate<T> predicate, int offset, int limit, Function<T, U> function) {
    
    
    return flux.collectList()
            .map(list -> list.stream()
                    .filter(predicate)
                    .map(function)
                    .skip(offset)
                    .limit(limit)
                    .collect(Collectors.toList()));
}
一键热部署

编写脚本进行 Golang 项目的自动代码编译并上传可执行文件至云服务器,并替换正在运行的旧服务为新服务:

58C81187436D3AD8D4B6D7F621E38960

下面的脚本实现了热部署:

  1. 热部署成功的前提 Golang 程序中为提供 HTTP 服务的监听套接字设置了 SO_REUSEPORT 选项,可以保证新旧两个服务器程序可以同时监听端口并提供服务,这使得旧服务器程序被终止后不会出现服务中断

  2. 使用 Linux 的 nohup 命令执行新服务器程序的启动(使用 nohup 以避免 Session 退出时程序被终止);

  3. 使用 kill 命令终止旧的服务器程序,只保留 pid 值最大的服务器进程(即最新的服务器进程 )。

18DC619DD7C024AE783F19C01D08893B

Axios 封装

自定义拦截器以在 HTTP 请求发送前对请求内容进行一定处理:

  • 统一在 Authorization Header 中加 Json Web Token
  • 在收到响应后进行统一结果的结果处理

image-20220312093034275

信息隐私化处理

阿里云 OSS 提供以下 API 来认证身份:

func New(endpoint, accessKeyID, accessKeySecret string, options ...ClientOption) (*Client, error) {
    
     ... }

Spring Cloud Alibaba Nacos 通过在 bootstrap.yml 中进行以下配置来进行服务注册与服务配置:

spring:
  cloud:
    nacos:
      discovery:
        server-addr: host:port
      config:
        server-addr: host:port
        file-extension: yaml  

其中 endpointaccessKeyIDaccessKeySecretspring.cloud.Nacos.discovery.server-addrspring.cloud.nacos.config.server-addr 都是敏感信息,既不可直接硬编码在项目源码中,也不可明文保存在项目的配置文件中,思索多种方案(比如使用 JWT + 鉴权服务器)后,决定通过命令行参数在程序启动时传递敏感信息,并使用 go.flag 包与 Spring Boot 的配置解析命令行参数。

7D3F02F82FDBBFFCFD47552CE1260EB0

Problem & Solution

自定义 Redis 序列化

在对 BSON 的 ObjectId 类型与 Java 的 LocalDate 类型进行序列化与反序列化时,Redis 出现了无法对 ObjectIdLocalDate 进行序列化的报错

于是决定在 Redis 的配置类中ReactiveRedisTemplate<String, Object> 进行定制,在阅读源码后发现 ReactiveRedisTemplate 的构造函数中可以传入一个 RedisSerializationContext<String, Object> 对象,对 Redis 序列化方式进行定制:

@Bean
public ReactiveRedisTemplate<String, Object> reactiveRedisTemplate(
        ReactiveRedisConnectionFactory reactiveRedisConnectionFactory,
        RedisSerializationContext<String, Object> redisSerializationContext) {
    
     // 配置 RedisSerializationContext
    return new ReactiveRedisTemplate<>(reactiveRedisConnectionFactory, redisSerializationContext);
}

查看 RedisSerializationContext<K, V> 接口对源码后,发现其内部有一个静态接口 RedisSerializationContextBuilder<K, V>,专门用于构建 RedisSerializationContext<K, V> 对象,其 key()value()hashKey()hashValue() 等方法支持对序列化方式进行定制化:

/**
 * Builder for {@link RedisSerializationContext}.
 *
 * @author Mark Paluch
 * @author Christoph Strobl
 */
interface RedisSerializationContextBuilder<K, V> {
    
     ... }

于是RedisSerializationContext<String, Object> 进行定制,使用自定义的序列化方式对 Redis 的 value 与 hashValue 进行特制的序列化:

@Bean
public RedisSerializationContext<String, Object> redisSerializationContext() {
    
    
    RedisSerializationContext.RedisSerializationContextBuilder<String, Object> builder =         
        RedisSerializationContext.newSerializationContext();

    builder.key(StringRedisSerializer.UTF_8);
    builder.value(serializer()); // 设置自定义序列化

    builder.hashKey(StringRedisSerializer.UTF_8);
    builder.hashValue(serializer()); // 设置自定义序列化

    return builder.build();
}

发现 RedisSerializationContext.RedisSerializationContextBuilder<String, Object> 接口的 value(...)hashValue(...) 方法需要传入一个 RedisSerializer 对象,根据之前的项目经验,决定对其实现类 Jackson2JsonRedisSerializer 进行定制

Jackson2JsonRedisSerializer 类中,发现有一个 ObjectMapper 类型的字段,根据其在 deserialize 方法与 serialize 方法中被调用的方式,不难猜出其负责对象的映射,故选择对 ObjectMapper 也进行定制化:

@SuppressWarnings("unchecked")
public T deserialize(@Nullable byte[] bytes) throws SerializationException {
    
    

   if (SerializationUtils.isEmpty(bytes)) {
    
    
      return null;
   }
   try {
    
    
      return (T) this.objectMapper.readValue(bytes, 0, bytes.length, javaType);
   } catch (Exception ex) {
    
    
      throw new SerializationException("Could not read JSON: " + ex.getMessage(), ex);
   }
}

@Override
public byte[] serialize(@Nullable Object t) throws SerializationException {
    
    

   if (t == null) {
    
    
      return SerializationUtils.EMPTY_ARRAY;
   }
   try {
    
    
      return this.objectMapper.writeValueAsBytes(t);
   } catch (Exception ex) {
    
    
      throw new SerializationException("Could not write JSON: " + ex.getMessage(), ex);
   }
}

ObjectMapper 中发现了一个 registerModule 方法,支持定制序列化器与反序列化器

/**
 * Method for registering a module that can extend functionality
 * provided by this mapper; for example, by adding providers for
 * custom serializers and deserializers.
 * 
 * @param module Module to register
 */
public ObjectMapper registerModule(Module module) {
    
     ... }

Module 是一个 abstruct class,在其子类中,找到了 JavaTimeModule 类,其支持对 LocalDate 等时间类进行序列化,故将 JavaTimeModule 对象注册进 ObjectMapper 对象:

objectMapper.registerModule(new JavaTimeModule());

但是未找到对 BSON 的 ObjectId 类型进行序列化的 Module,于是构造一个 SimpleModule 对象调用 addSerializer(...)addDeserializer(...) 方法进行序列化与反序列化的定制(在此使用 ObjectId 的十六进制形式的字符串进行序列化),并将其注册进 ObjectMapper 对象:

SimpleModule objectIdModule = new SimpleModule("ObjectIdModule");
objectIdModule.addSerializer(ObjectId.class, new JsonSerializer<ObjectId>() {
    
    
    @Override
    public void serialize(ObjectId objectId, JsonGenerator jsonGenerator, 
                          SerializerProvider serializerProvider) throws IOException {
    
    
        jsonGenerator.writeString(objectId.toString());
    }
});
objectIdModule.addDeserializer(ObjectId.class, new JsonDeserializer<ObjectId>() {
    
    
    @Override
    public ObjectId deserialize(JsonParser jsonParser, 
                                DeserializationContext deserializationContext) throws IOException {
    
    
        return new ObjectId(jsonParser.readValueAs(String.class));
    }
});
objectMapper.registerModule(objectIdModule);

至此,完成 Redis 对 ObjectIdLocalDate 类进行定制的工作。

OSS 文件重名问题

在实现“将咨询报告的详细细心导出为 PDF 文件”业务时,初版 Golang 代码采取以下方案上传单个 PDF 文件:

  1. 调用 ioutil.TempFile 创建一个临时文件(记作 a.pdf,且其保证不与文件夹内其他文件重名);
  2. 将所需数据写入到 a.pdf 文件中;
  3. 调用阿里云 OSS SDK 接口将 a.pdf 上传到指定路径中(例如 <OSS地址>/pcs/pdf/),生成 URI 为 <OSS地址>/pcs/pdf/a.pdf 网络资源文件;
  4. 调用 os.Remove 删除本地的临时文件(a.pdf)。

问题在于,虽然 ioutil.TempFile 保证创建的临时文件不会与同一个文件夹内的其他文件重名,但是其可能会与 OSS 上的文件重名(即本次使用 ioutil.TempFile 创建出来的临时文件可能会与之前使用 ioutil.TempFile 穿件出来的临时文件重名)。

解决方案:在 PDF 文件名前添加 UNIX 时间戳

说明:因为服务无法达到 1 1 1 1 0 6 10^6 106 次的调用频次,故本方案可在绝大多数场景下保证文件名的唯一性。

1AE7BF77CD4219C32F918EE6F6499C88

前端导入 Excel 表获取用户上传数据
  1. 使用 npm 安装 xlsx 依赖;

  2. 让用户点击页面中的按钮,选择电脑中文件后缀名为 .xlsx 和 .xls 的文件;

    image-20220312093452246

  3. 拿到表格数据后,我们需要进行如下解析:handle 函数中的 ev 指的是我们选择之后触发的事件返回的一个事件源,事件源里面会有一个 ev.raw 事件;upload 是我们封装的一个事件,作用就是将数据从 Excel 表格中解析出来,我们使用 xlsx 库,将解析出来的文件编译出来,只要点击这个 String 就可以拿到数据了(SheetNames 就是叶卡,map 对数据名称进行映射)

    image-20220312094114452

  4. 最后对数据进行发送

ELK 崩了

在项目搭建之初,我们使用 Docker Compose 在腾讯云服务器上搭建了 ELK 集群,主要使用 Log4j2 Socket + LogStash 进行日志收集,使用 Kibana 进行日志数据的可视化显示。

image-20220312091211220

但是 ELK 占据内存过高,多次导致云服务器卡顿,甚至 ELK 自行崩溃,经过多次尝试后决定下线 ELK 服务。

image-20220312091112365

腾讯云服务器被利用

在项目进行之际,腾讯云服务器被恶意利用于攻击他人服务器的 6379 端口,腾讯云多次发送警告,无奈只能多次重装系统,浪费大量开发时间。

腾讯云 SMS 服务很难申请

在开发使用手机短信验证码进行登录的业务时,向腾讯云 SMS 服务进行签名与模板的申请,但 SMS 服务的请求难度已今非昔比,我们共被拒绝了 5 次有余,最终使用曾经上线过的微信小程序的名称来进行申请。

因为没有图片,前端的布局排班、背景内容空洞参考较少

用磨刀简单地制作了排版布置,使用 PhotoShop 简单制作了一些网页请求错误的响应,锻炼审美,不断修改,运用 Element-UI 的各种组件。

样式穿透,深度选择器

Scoped CSS 规范 Web 组件产生不污染其他组件,也不被其他组件污染,在打包的时候会生成一个独一无二 hash 值,父组件的样式就不会影响到子组件了,尽管 Element-UI 给出了众多的修改属性和方法,但除此之外封装的比较严格。

想要对其样式进行修改,查看页面源码,可以找到组件更深层次的结构,然后使用了一些 /deep/ ::v-deep选择器,进行一些样式的修改(意识到调用组件的一些弊端,多做 css 的样式修改)。

生命周期问题

更多使用组件嵌套,将表单等嵌套在抽屉等,没有使用表格,使用了描述列表要自己书写界面自主更新的逻辑,用props进行传值,$emit监听子组件点击响应,父组件调用刷新方法。对于父子组件的生命周期,一开始理解并不深刻,在收到父组件的props数据后,在子组件的created或mounted加载,当父组件中点击按钮运行自己写的更新函数传值到子组件后,子组件没有响应更新;或者在props数据时传入控制。

解决办法:在层次较深的时候把方法处理放在了父组件中,传值给子组件。

逻辑算法

我们对值班表的数据模型比较特殊,在关于时间的处理上在前端做了很多的调整获取当前时间,setInterval()做一个实时更新的时间,prototype原型解决各种格式转换,时间排序,获得最近的工作,已完成,未完成等。主要是考虑值班的逻辑,如何获取这一年排班,获取年份,获取多少天,Date.parse() 方法解析一个表示某个日期的字符串,并返回从1970-1-1 00:00:00 UTC 到该日期对象(该日期对象的UTC时间)的毫秒数,从第一天开始重新插入流程逻辑,通过各种状态找出正确约束,什么时候可以提交,什么时候不能提交,提交查看完成有越界的情况等,流程逻辑要严谨。

猜你喜欢

转载自blog.csdn.net/m0_46261993/article/details/123684846