个人主页:https://tzq0301.cn
09:15 ~ 12:15
14:15 ~ 17:15
URL: wiki.suncaper.net
负责导出模块编写:
所有导出文件都被上传到 OSS,响应消息中只有对应文件的 URL。
负责导出模块的部署和维护,负责接口测试与集成测试工作。
负责前端的“初访员”“心理助理”以及“咨询师”界面的编写。
负责前端的“管理员”“登录页面”“学生”界面以及“主界面框架”的编写。
Spring WebFlux —— 响应式的、异步非阻塞的 Web 框架,依托于异步 IO 框架 Netty,使用一种基于数据流(Data Stream)和变化传递(Propagation of Change)的声明式(Declarative)的编程范式
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。
Redis —— 响应速度快;并发安全;支持集群部署
项目使用 Docker Compose 编排了**“一主二从”三节点的高可用 Redis 集群**,继“五个一百精品项目申报”的分布式一致 ID 生成后,本项目使用 Redis 进行 JWT 令牌与手机验证码的数据存储与时长失效管理,并对“高读取低写入”数据进行缓存服务,并使用命名空间对不同业务的数据进行分隔。
Spring Security —— 基于 Spring 的企业应用系统提供声明式的安全访问控制解决方案的安全框架
JWT —— 用于作为 JSON 对象在各方之间安全地传输信息
项目使用 Spring Security + JWT 作为用户认证与授权的解决方案,使用 Spring Security 并以用户角色为粒度进行接口权限的访问控制,并使用无状态的 JWT 在客户端对用户登录状态、用户信息等进行存储,保存少量有效信息以减少对数据库的访问,且无需进行 session 的多机数据共享配置。
Spring Cloud —— 服务治理平台,是若干个框架的集合,提供了全套的分布式系统解决方案。
项目基于 Spring Cloud 对 Spring Cloud Alibaba、Spring Cloud Gateway、Spring Cloud Load Balancer 等框架进行集成。
Nacos —— 支持基于 DNS 和基于 RPC 的服务发现:服务实例在启动时注册到服务注册表,并在关闭时注销。
项目使用 Nacos 作为微服务注册中心对项目的微服务进行服务注册以便可以只使用服务名即可对相应微服务进行访问,无需手动维护所有的服务访问 ip 地址列表,无需手动构建多服务的负载均衡策略;Nacos 在自动或手动下线服务,使用消息机制通知客户端,服务实例的修改很快响应,而传统的服务注册中心 Eureka 只能通过任务定时剔除无效的服务。
Nacos —— 以中心化、外部化和动态化的方式管理所有环境的应用配置和服务配置;消除了配置变更时重新部署应用和服务的需要,让配置管理变得更加高效和敏捷
项目使用 Nacos 作为微服务配置中心对微服务的项目进行线上动态部署,可以避免在 application.yml 中对隐私信息进行明文配置;可以动态更改配置(例如用户默认密码)而无需重启微服务;可以在多个微服务间共享 Redis、SMS 之间的配置信息。
Spring Cloud Gateway —— 为微服务架构提供一种简单有效的统一的 API 路由管理方式
项目使用基于高性能通信框架 Netty 的 Spring Cloud Gateway,配合 Spring Security 进行用户角色权限认证管理,配合 JWT 进行用户登录信息管理,全局进行 CORS 跨域配置。
Spring Cloud Load Balancer Reactive —— 响应式的负载均衡组件
项目使用响应式的负载均衡组件,而非 Ribbon 的阻塞式负载均衡。
WebFlux WebClient —— 响应式 HTTP 客户端
项目曾尝试使用阻塞式微服务通信组件 Spring Cloud OpenFeign 与其响应式的组件 Playtika/feign-reactive,但是出现了服务阻塞的情况,于是使用了 Spring WebFlux 提供的响应式 WebClient 进行微服务调用。
阿里云 AMQP(RabbitMQ)—— 高性能消息队列实现
项目通过 RabbitMQ 消息队列组件进行微服务间通信,例如在短信业务上使用消息队列进行异步业务操作。
阿里云 OSS 服务 —— 海量、安全、低成本、高可靠的云存储服务,提供99.9999999999%(12个9)的数据持久性,99.995%的数据可用性
项目使用云上存储服务,避免了在 HTTP 连接上直接传输文件(PDF、ZIP、XLSX)所带来的传输开销,且方便了资源文件的管理。
腾讯云 SMS 服务 —— 高可靠性的短信发送服务,保证 99% 一秒内到达
项目使用腾讯云 SMS 服务以支持用户使用手机号与短信验证码进行登录操作。
对 Web 标准的理解(结构、表现、行为)、渲染原理、依赖管理、兼容性、CSS 语法、层次关系,常用属性、布局、选择器、权重、盒模型、CSS 3、Flexbox、深度选择器、JavaScript(数据类型、运算、对象、Function、继承、闭包、作用域、事件、Prototype、RegExp、JSON、Ajax、异步请求、模板引擎、模块化、Flux、同构、算法、ECMAScript6)
主流 MVVM 框架 —— Vue 用于构建用户界面的渐进式框架,使用 Element-UI 视图组件辅助前端开发。
XLSX 库:对用户上传的 Excel 文件进行解析并获取数据
在项目开发中,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();
}
}
作为极度注重用户隐私信息的心理咨询系统平台,我们非常注重用户的隐私。
注意到,Spring Security 的接口访问权限是以用户角色为基本粒度的,一些用户接口中会包含操作对象的 id(例如 GET /user_id/{user_id}/info
),如果有一个具有同样访问权限的用户在 URL 中输入了另一个用户的 id,Spring Security 不会对这种**“违规请求”**进行拦截,然而,这实际上是不允许的。
故项目对在进行业务操作之前对请求路径中的用户 ID 与从 JWT 中抽取的 ID 进行对比,以保证对目标数据进行操作的是本人,从而实现了以用户个体为基本粒度的访问权限控制体系,更好地保护了用户的隐私信息。
http://host:port/test/authorization
接口向【 pcs-gateway 微服务 】发起登录凭证 JWT 是否依然有效的测试请求
http://host:port/login/account/{account}/password/{password}
向【 pcs-gateway 微服务 】发起登录请求http://host:port/test/authorization
接口向【 pcs-gateway 微服务 】发起登录凭证 JWT 是否依然有效的测试请求
http:host:port/phone/{phone}/code
向【 pcs-gateway 微服务 】发起发送短信验证码请求http:host:port/login/phone/{phone}/code/{code}
向【 pcs-gateway 微服务 】发起登录请求项目将业务实体类的构造器访问可见性设置为 default(仅包内可见),使用简单工厂模式负责对业务实体类的创建。
借鉴 DDD 的思想,在项目中讲业务模型构造成充血模型,赋予其真正的业务性能。
使用卢老师教的 Predicate
、Function
等函数式接口进行数据分页工具的封装。
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 项目的自动代码编译并上传可执行文件至云服务器,并替换正在运行的旧服务为新服务:
下面的脚本实现了热部署:
热部署成功的前提 Golang 程序中为提供 HTTP 服务的监听套接字设置了 SO_REUSEPORT 选项,可以保证新旧两个服务器程序可以同时监听端口并提供服务,这使得旧服务器程序被终止后不会出现服务中断;
使用 Linux 的 nohup 命令执行新服务器程序的启动(使用 nohup 以避免 Session 退出时程序被终止);
使用 kill 命令终止旧的服务器程序,只保留 pid 值最大的服务器进程(即最新的服务器进程 )。
自定义拦截器以在 HTTP 请求发送前对请求内容进行一定处理:
阿里云 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
其中 endpoint
、accessKeyID
、accessKeySecret
、spring.cloud.Nacos.discovery.server-addr
、spring.cloud.nacos.config.server-addr
都是敏感信息,既不可直接硬编码在项目源码中,也不可明文保存在项目的配置文件中,思索多种方案(比如使用 JWT + 鉴权服务器)后,决定通过命令行参数在程序启动时传递敏感信息,并使用 go.flag 包与 Spring Boot 的配置解析命令行参数。
在对 BSON 的 ObjectId
类型与 Java 的 LocalDate
类型进行序列化与反序列化时,Redis 出现了无法对 ObjectId
、LocalDate
进行序列化的报错。
于是决定在 Redis 的配置类中对 ReactiveRedisTemplate
进行定制,在阅读源码后发现 ReactiveRedisTemplate
的构造函数中可以传入一个 RedisSerializationContext
对象,对 Redis 序列化方式进行定制:
@Bean
public ReactiveRedisTemplate<String, Object> reactiveRedisTemplate(
ReactiveRedisConnectionFactory reactiveRedisConnectionFactory,
RedisSerializationContext<String, Object> redisSerializationContext) { // 配置 RedisSerializationContext
return new ReactiveRedisTemplate<>(reactiveRedisConnectionFactory, redisSerializationContext);
}
查看 RedisSerializationContext
接口对源码后,发现其内部有一个静态接口 RedisSerializationContextBuilder
,专门用于构建 RedisSerializationContext
对象,其 key()
、value()
、hashKey()
、hashValue()
等方法支持对序列化方式进行定制化:
/**
* Builder for {@link RedisSerializationContext}.
*
* @author Mark Paluch
* @author Christoph Strobl
*/
interface RedisSerializationContextBuilder<K, V> { ... }
于是对 RedisSerializationContext
进行定制,使用自定义的序列化方式对 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
接口的 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 对 ObjectId
与 LocalDate
类进行定制的工作。
在实现“将咨询报告的详细细心导出为 PDF 文件”业务时,初版 Golang 代码采取以下方案上传单个 PDF 文件:
ioutil.TempFile
创建一个临时文件(记作 a.pdf
,且其保证不与文件夹内其他文件重名);a.pdf
文件中;a.pdf
上传到指定路径中(例如 /pcs/pdf/
),生成 URI 为 /pcs/pdf/a.pdf
网络资源文件;os.Remove
删除本地的临时文件(a.pdf
)。问题在于,虽然 ioutil.TempFile
保证创建的临时文件不会与同一个文件夹内的其他文件重名,但是其可能会与 OSS 上的文件重名(即本次使用 ioutil.TempFile
创建出来的临时文件可能会与之前使用 ioutil.TempFile
穿件出来的临时文件重名)。
解决方案:在 PDF 文件名前添加 UNIX 时间戳
说明:因为服务无法达到 1 1 1 秒 1 0 6 10^6 106 次的调用频次,故本方案可在绝大多数场景下保证文件名的唯一性。
使用 npm 安装 xlsx 依赖;
让用户点击页面中的按钮,选择电脑中文件后缀名为 .xlsx 和 .xls 的文件;
拿到表格数据后,我们需要进行如下解析:handle
函数中的 ev
指的是我们选择之后触发的事件返回的一个事件源,事件源里面会有一个 ev.raw
事件;upload
是我们封装的一个事件,作用就是将数据从 Excel 表格中解析出来,我们使用 xlsx 库,将解析出来的文件编译出来,只要点击这个 String
就可以拿到数据了(SheetNames
就是叶卡,map
对数据名称进行映射)
最后对数据进行发送
在项目搭建之初,我们使用 Docker Compose 在腾讯云服务器上搭建了 ELK 集群,主要使用 Log4j2 Socket + LogStash 进行日志收集,使用 Kibana 进行日志数据的可视化显示。
但是 ELK 占据内存过高,多次导致云服务器卡顿,甚至 ELK 自行崩溃,经过多次尝试后决定下线 ELK 服务。
在项目进行之际,腾讯云服务器被恶意利用于攻击他人服务器的 6379 端口,腾讯云多次发送警告,无奈只能多次重装系统,浪费大量开发时间。
在开发使用手机短信验证码进行登录的业务时,向腾讯云 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时间)的毫秒数,从第一天开始重新插入流程逻辑,通过各种状态找出正确约束,什么时候可以提交,什么时候不能提交,提交查看完成有越界的情况等,流程逻辑要严谨。