开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

Spring-boot中实现通用Auth认证,有哪几种方式?

发表于 2021-07-12

前言

最近一直被无尽的业务需求淹没,没时间喘息,终于接到一个能让我突破代码舒适区的活儿,解决它的过程非常曲折,一度让我怀疑人生,不过收获也很大,代码方面不明显,但感觉自己抹掉了 java、Tomcat、Spring 一直挡在我眼前的一层纱。对它们的理解上了一个新的层次。

好久没输出了,于是挑一个方面总结一下,希望在梳理过程中再了解一些其他的东西。由于 Java 繁荣的生态,下面每一个模块都有大量的文章专门讲述。所以我选了另外一个角度,从实际问题出发,将这些分散的知识串联起来,各位可以作为一个综述来看。各个模块的极致详细介绍,大家可以去翻官方文档或看网络上的其他博客。另外关注:码猿技术专栏,在后台回复:“面试宝典”可以获取,高清PDF最新版3625页互联网大厂面试题。

需求很简单清晰,跟产品们提的妖艳需求一点也不一样:在我们的 web 框架里添加一个通用的 appkey 白名单校验功能,希望它的扩展性更好一些。

这个 web 框架是部门前驱者基于 spring-boot 实现的,介于业务和 Spring 框架之间,做一些偏向于业务的通用性功能,如 日志输出、功能开关、通用参数解析等。平常是对业务透明的,最近一直忙于把需求做好,代码写好,甚至从没注意过它的存在。

传统AOP


对于这种需求,首先想到的当然是 Spring-boot 提供的 AOP 接口,只需要在 Controller 方法前添加切点,然后再对切点进行处理即可。

实现

其使用步骤如下:

  1. 使用 @Aspect 声明一下切面类 WhitelistAspect;
  2. 在切面类内添加一个切点 whitelistPointcut(),为了实现此切点灵活可装配的能力,这里不使用 execution 全部拦截,而是添加一个注解 @Whitelist,被注解的方法才会校验白名单。
  3. 在切面类中使用 spring 的 AOP 注解 @Before 声明一个通知方法 checkWhitelist() 在 Controller 方法被执行之前校验白名单。

切面类伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码 @Aspect
public class WhitelistAspect {

@Before(value = "whitelistPointcut() && @annotation(whitelist)")
public void checkAppkeyWhitelist(JoinPoint joinPoint, Whitelist whitelist) {
checkWhitelist();
// 可使用 joinPoint.getArgs() 获取Controller方法的参数
// 可以使用 whitelist 变量获取注解参数
}

@Pointcut("@annotation(com.zhenbianshu.Whitelist)")
public void whitelistPointCut() {
}
}
  1. 在Controller方法上添加 @Whitelist 注解实现功能。

扩展

本例中使用了 注解 来声明切点,并且我实现了通过注解参数来声明要校验的白名单,如果之后还需要添加其他白名单的话,如通过 UID 来校验,则可以为此注解添加 uid() 等方法,实现自定义校验。

此外,spring 的 AOP 还支持 execution(执行方法) 、bean(匹配特定名称的 Bean 对象的执行方法)等切点声明方法和 @Around(在目标函数执行中执行) 、@After(方法执行后) 等通知方法。

如此,功能已经实现了,但领导并不满意=_=,原因是项目中 AOP 用得太多了,都用滥了,建议我换一种方式。嗯,只好搞起。另外关注:码猿技术专栏,在后台回复:“面试宝典”可以获取,高清PDF最新版3625页互联网大厂面试题。

Interceptor


Spring 的 拦截器(Interceptor) 实现这个功能也非常合适。顾名思义,拦截器用于在 Controller 内 Action 被执行前通过一些参数判断是否要执行此方法,要实现一个拦截器,可以实现 Spring 的 HandlerInterceptor 接口。

实现

实现步骤如下:

  1. 定义拦截器类 AppkeyInterceptor 类并实现 HandlerInterceptor 接口。
  2. 实现其 preHandle() 方法;
  3. 在 preHandle 方法内通过注解和参数判断是否需要拦截请求,拦截请求时接口返回 false;
  4. 在自定义的 WebMvcConfigurerAdapter 类内注册此拦截器;

AppkeyInterceptor 类如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
java复制代码@Component
public class WhitelistInterceptor implements HandlerInterceptor {

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
Whitelist whitelist = ((HandlerMethod) handler).getMethodAnnotation(Whitelist.class);
// whitelist.values(); 通过 request 获取请求参数,通过 whitelist 变量获取注解参数
return true;
}

@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
// 方法在Controller方法执行结束后执行
}

@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 在view视图渲染完成后执行
}
}

扩展

要启用 拦截器还要显式配置它启用,这里我们使用 WebMvcConfigurerAdapter 对它进行配置。需要注意,继承它的的 MvcConfiguration 需要在 ComponentScan 路径下。

1
2
3
4
5
6
7
8
java复制代码@Configuration
public class MvcConfiguration extends WebMvcConfigurerAdapter {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new WhitelistInterceptor()).addPathPatterns("/*").order(1);
// 这里可以配置拦截器启用的 path 的顺序,在有多个拦截器存在时,任一拦截器返回 false 都会使后续的请求方法不再执行
}
}

还需要注意,拦截器执行成功后响应码为 200,但响应数据为空。

当使用拦截器实现功能后,领导终于祭出大招了:我们已经有一个 Auth 参数了,appkey 可以从 Auth 参数里取到,可以把在不在白名单作为 Auth 的一种方式,为什么不在 Auth 时校验?emmm… 吐血中。

ArgumentResolver


参数解析器是 Spring 提供的用于解析自定义参数的工具,我们常用的 @RequestParam 注解就有它的影子,使用它,我们可以将参数在进入Controller Action之前就组合成我们想要的样子。Spring 会维护一个 ResolverList, 在请求到达时,Spring 发现有自定义类型参数(非基本类型), 会依次尝试这些 Resolver,直到有一个 Resolver 能解析需要的参数。要实现一个参数解析器,需要实现 HandlerMethodArgumentResolver 接口。

实现

  1. 定义自定义参数类型 AuthParam,类内有 appkey 相关字段;
  2. 定义 AuthParamResolver 并实现 HandlerMethodArgumentResolver 接口;
  3. 实现 supportsParameter() 接口方法将 AuthParam 与 AuthParamResolver 适配起来;
  4. 实现 resolveArgument() 接口方法解析 reqest 对象生成 AuthParam 对象,并在此校验 AuthParam ,确认 appkey 是否在白名单内;
  5. 在 Controller Action 方法上签名内添加 AuthParam 参数以启用此 Resolver;

实现的 AuthParamResolver 类如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码@Component
public class AuthParamResolver implements HandlerMethodArgumentResolver {

@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.getParameterType().equals(AuthParam.class);
}

@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
Whitelist whitelist = parameter.getMethodAnnotation(Whitelist.class);
// 通过 webRequest 和 whitelist 校验白名单
return new AuthParam();
}
}

扩展

当然,使用参数解析器也需要单独配置,我们同样在 WebMvcConfigurerAdapter内配置:

1
2
3
4
5
6
7
8
java复制代码@Configuration
public class MvcConfiguration extends WebMvcConfigurerAdapter {

@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
argumentResolvers.add(new AuthParamResolver());
}
}

这次实现完了,我还有些不放心,于是在网上查找是否还有其他方式可以实现此功能,发现常见的还有 Filter。

Filter


Filter 并不是 Spring 提供的,它是在 Servlet 规范中定义的,是 Servlet 容器支持的。被 Filter 过滤的请求,不会派发到 Spring 容器中。它的实现也比较简单,实现 javax.servlet.Filter接口即可。

由于不在 Spring 容器中,Filter 获取不到 Spring 容器的资源,只能使用原生 Java 的 ServletRequest 和 ServletResponse 来获取请求参数。

另外,在一个 Filter 中要显示调用 FilterChain 的 doFilter 方法,不然认为请求被拦截。实现类似:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码public class WhitelistFilter implements javax.servlet.Filter {

@Override
public void init(FilterConfig filterConfig) throws ServletException {
// 初始化后被调用一次
}

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// 判断是否需要拦截
chain.doFilter(request, response); // 请求通过要显示调用
}

@Override
public void destroy() {
// 被销毁时调用一次
}
}

扩展

Filter 也需要显示配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码@Configuration
public class FilterConfiguration {

@Bean
public FilterRegistrationBean someFilterRegistration() {
FilterRegistrationBean registration = new FilterRegistrationBean();
registration.setFilter(new WhitelistFilter());
registration.addUrlPatterns("/*");
registration.setName("whitelistFilter");
registration.setOrder(1); // 设置过滤器被调用的顺序
return registration;
}
}

小结


四种实现方式都有其适合的场景,那么它们之间的调用顺序如何呢?

Filter 是 Servlet 实现的,自然是最先被调用,后续被调用的是 Interceptor 被拦截了自然不需要后续再进行处理,然后是 参数解析器,最后才是 切面的切点。我将四种方式在一个项目内全部实现后,输出日志也证明了这个结论。

另外,作者已经完成了两个专栏的文章Mybatis进阶、Spring Boot 进阶 ,已经将专栏文章整理成书,有需要的公众号回复关键词Mybatis 进阶、Spring Boot 进阶免费获取。

跳出具体实现,转身来看这些实现,其实都有一些面向切面的影子。由于之前自己的编程方式更偏向于面向过程编程,在使用 Java 面向对象后对比 AOP 和 面向过程中的勾子,有些感悟,改日写文整理一下。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

延迟执行与不可变,系统讲解JavaStream数据处理

发表于 2021-07-12

本文为掘金社区首发签约文章,未获授权禁止转载。

最近在公司写业务的时候,忽然想不起来Stream中的累加应该怎么写?

无奈只能面向谷歌编程,花费了我宝贵的三分钟之后,学会了,很简单。

自从我用上JDK8以后,Stream就是我最常用的特性,各种流式操作用的飞起,然而这次事以后我忽然觉得Stream对我真的很陌生。

可能大家都一样,对最常用到的东西,也最容易将其忽略,哪怕你要准备面试估计也肯定想不起来要看一下Stream这种东西。

不过我既然注意到了,就要重新梳理一遍它,也算是对我的整体知识体系的查漏补缺。

花了很多功夫来写这篇Stream,希望大家和我一块重新认识并学习一下Stream,了解API也好,了解内部特性也罢,怕什么真理无穷,进一步有进一步的欢喜。

在本文中我将Stream的内容分为以下几个部分:

初看这个导图大家可能对转换流操作和终结流操作这两个名词有点蒙,其实这是我将Stream中的所有API分成两类,每一类起了一个对应的名字(参考自Java8相关书籍,见文末):

  • 转换流操作 :例如filter和map方法,将一个Stream转换成另一个Stream,返回值都是Stream。
  • 终结流操作 :例如count和collect方法,将一个Stream汇总为我们需要的结果,返回值都不是Stream。

其中转换流操作的API我也分了两类,文中会有详细例子说明,这里先看一下定义,有一个大概印象:

  1. 无状态 :即此方法的执行无需依赖前面方法执行的结果集。
  2. 有状态 :即此方法的执行需要依赖前面方法执行的结果集。

由于Stream内容过多,所以我将Stream拆成了上下两篇,本篇是第一篇,内容翔实,用例简单且丰富。

第二篇的主题虽然只有一个终结操作,但是终结操作API比较复杂,所以内容也翔实,用例也简单且丰富,从篇幅上来看两者差不多,敬请期待。


注 :由于我本机的电脑是JDK11,而且写的时候忘了切换到JDK8,所以在用例中大量出现的List.of()在JDK8是没有的,它等同于JDK8中的Arrays.asList()。

注 :写作过程中翻读了大量Stream源码和Java8书籍(文末),创作不易,点赞过百,马上出第二篇。

  1. 为什么要使用Stream?

一切还要源于JDK8的发布,在那个函数式编程语言如火如荼的时代,Java由于它的臃肿而饱受诟病(强面向对象),社区迫切需要Java能加入函数式语言特点改善这种情况,终于在2014年Java发布了JDK8。

在JDK8中,我认为最大的新特性就是加入了函数式接口和lambda表达式,这两个特性取自函数式编程。

这两个特点的加入使Java变得更加简单与优雅,用函数式对抗函数式,巩固Java老大哥的地位,简直是师夷长技以制夷。

而Stream,就是JDK8又依托于上面的两个特性为集合类库做的 一个类库,它能让我们通过lambda表达式更简明扼要的以流水线的方式去处理集合内的数据,可以很轻松的完成诸如:过滤、分组、收集、归约这类操作,所以我愿将Stream称为函数式接口的最佳实践。

1.1 更清晰的代码结构

Stream拥有更清晰的代码结构,为了更好的讲解Stream怎么就让代码变清晰了,这里假设我们有一个非常简单的需求:在一个集合中找到所有大于2的元素 。

先来看看没使用Stream之前:

1
2
3
4
5
6
7
8
9
10
11
java复制代码        List<Integer> list = List.of(1, 2, 3);

List<Integer> filterList = new ArrayList<>();

for (Integer i : list) {
if (i > 2) {
filterList.add(i);
}
}

System.out.println(filterList);

上面的代码很好理解,我就不过多解释了,其实也还好了,因为我们的需求比较简单,如果需求再多点呢?

每多一个要求,那么if里面就又要加一个条件了,而我们开发中往往对象上都有很多字段,那么条件可能有四五个,最后可能会变成这样:

1
2
3
4
5
6
7
8
9
10
11
java复制代码        List<Integer> list = List.of(1, 2, 3);

List<Integer> filterList = new ArrayList<>();

for (Integer i : list) {
if (i > 2 && i < 10 && (i % 2 == 0)) {
filterList.add(i);
}
}

System.out.println(filterList);

if里面塞了很多条件,看起来就变得乱糟糟了,其实这也还好,最要命的是项目中往往有很多类似的需求,它们之间的区别只是某个条件不一样,那么你就需要复制一大坨代码,改吧改吧就上线了,这就导致代码里有大量重复的代码。

如果你Stream,一切都会变得清晰易懂:

1
2
3
4
5
java复制代码        List<Integer> list = List.of(1, 2, 3).stream()
.filter(i -> i > 2)
.filter(i -> i < 10)
.filter(i -> i % 2 == 0)
.collect(toList());

这段代码你只需要关注我们最关注的东西:筛选条件就够了,filter这个方法名能让你清楚的知道它是个过滤条件,collect这个方法名也能看出来它是一个收集器,将最终结果收集到一个List里面去。

同时你可能发现了,为什么上面的代码中不用写循环?

因为Stream会帮助我们进行隐式的循环,这被称为:内部迭代,与之对应的就是我们常见的外部迭代了。

所以就算你不写循环,它也会进行一遍循环。

1.2 不必关心变量状态

Stream在设计之初就被设计为不可变的,它的不可变有两重含义:

  1. 由于每次Stream操作都会生成一个新的Stream,所以Stream是不可变的,就像String。
  2. 在Stream中只保存原集合的引用,所以在进行一些会修改元素的操作时,是通过原元素生成一份新的新元素,所以Stream 的任何操作都不会影响到原对象。

第一个含义可以帮助我们进行链式调用,实际上我们使用Stream的过程中往往会使用链式调用,而第二个含义则是函数式编程中的一大特点:不修改状态。

无论对Stream做怎么样的操作,它最终都不会影响到原集合,它的返回值也是在原集合的基础上进行计算得来的。

所以在Stream中我们不必关心操作原对象集合带来的种种副作用,用就完了。

关于函数式编程可以查阅阮一峰的函数式编程初探。

1.3 延迟执行与优化

Stream只在遇到终结操作的时候才会执行,比如:

1
2
3
java复制代码        List.of(1, 2, 3).stream()
.filter(i -> i > 2)
.peek(System.out::println);

这么一段代码是不会执行的,peek方法可以看作是forEach,这里我用它来打印Stream中的元素。

因为filter方法和peek方法都是转换流方法,所以不会触发执行。

如果我们在后面加入一个count方法就能正常执行:

1
2
3
4
java复制代码        List.of(1, 2, 3).stream()
.filter(i -> i > 2)
.peek(System.out::println)
.count();

count方法是一个终结操作,用于计算出Stream中有多少个元素,它的返回值是一个long型。

Stream的这种没有终结操作就不会执行的特性被称为延迟执行。

与此同时,Stream还会对API中的无状态方法进行名为循环合并的优化,具体例子详见第三节。

  1. 创建Stream

为了文章的完整性,我思来想去还是加上了创建Stream这一节,这一节主要介绍一些创建Stream的常用方式,Stream的创建一般可以分为两种情况:

  1. 使用Steam接口创建
  2. 通过集合类库创建

同时还会讲一讲Stream的并行流与连接,都是创建Stream,却具有不同的特点。

2.1 通过Stream接口创建

Stream作为一个接口,它在接口中定义了定义了几个静态方法为我们提供创建Stream的API:

1
2
3
java复制代码    public static<T> Stream<T> of(T... values) {
return Arrays.stream(values);
}

首先是of方法,它提供了一个泛型可变参数,为我们创建了带有泛型的Stream流,同时在如果你的参数是基本类型的情况下会使用自动包装对基本类型进行包装:

1
2
3
4
5
java复制代码        Stream<Integer> integerStream = Stream.of(1, 2, 3);

Stream<Double> doubleStream = Stream.of(1.1d, 2.2d, 3.3d);

Stream<String> stringStream = Stream.of("1", "2", "3");

当然,你也可以直接创建一个空的Stream,只需要调用另一个静态方法——empty(),它的泛型是一个Object:

1
java复制代码        Stream<Object> empty = Stream.empty();

以上都是我们让我们易于理解的创建方式,还有一种方式可以创建一个无限制元素数量的Stream——generate():

1
2
3
4
5
Java复制代码    public static<T> Stream<T> generate(Supplier<? extends T> s) {
Objects.requireNonNull(s);
return StreamSupport.stream(
new StreamSpliterators.InfiniteSupplyingSpliterator.OfRef<>(Long.MAX_VALUE, s), false);
}

从方法参数上来看,它接受一个函数式接口——Supplier作为参数,这个函数式接口是用来创建对象的接口,你可以将其类比为对象的创建工厂,Stream将从此工厂中创建的对象放入Stream中:

1
2
3
java复制代码        Stream<String> generate = Stream.generate(() -> "Supplier");

Stream<Integer> generateInteger = Stream.generate(() -> 123);

我这里是为了方便直接使用Lamdba构造了一个Supplier对象,你也可以直接传入一个Supplier对象,它会通过Supplier接口的get() 方法来构造对象。

2.2 通过集合类库进行创建

相较于上面一种来说,第二种方式更较为常用,我们常常对集合就行Stream流操作而非手动构建一个Stream:

1
2
3
java复制代码        Stream<Integer> integerStreamList = List.of(1, 2, 3).stream();

Stream<String> stringStreamList = List.of("1", "2", "3").stream();

在Java8中,集合的顶层接口Collection被加入了一个新的接口默认方法——stream(),通过这个方法我们可以方便的对所有集合子类进行创建Stream的操作:

1
2
3
java复制代码        Stream<Integer> listStream = List.of(1, 2, 3).stream();

Stream<Integer> setStream = Set.of(1, 2, 3).stream();

通过查阅源码,可以发先 stream() 方法本质上还是通过调用一个Stream工具类来创建Stream:

1
2
3
java复制代码    default Stream<E> stream() {
return StreamSupport.stream(spliterator(), false);
}

2.3 创建并行流

在以上的示例中所有的Stream都是串行流,在某些场景下,为了最大化压榨多核CPU的性能,我们可以使用并行流,它通过JDK7中引入的fork/join框架来执行并行操作,我们可以通过如下方式创建并行流:

1
2
3
4
5
6
7
java复制代码        Stream<Integer> integerParallelStream = Stream.of(1, 2, 3).parallel();

Stream<String> stringParallelStream = Stream.of("1", "2", "3").parallel();

Stream<Integer> integerParallelStreamList = List.of(1, 2, 3).parallelStream();

Stream<String> stringParallelStreamList = List.of("1", "2", "3").parallelStream();

是的,在Stream的静态方法中没有直接创建并行流的方法,我们需要在构造Stream后再调用一次parallel()方法才能创建并行流,因为调用parallel()方法并不会重新创建一个并行流对象,而是在原有的Stream对象上面设置了一个并行参数。

当然,我们还可以看到,Collection接口中可以直接创建并行流,只需要调用与stream() 对应的parallelStream()方法,就像我刚才讲到的,他们之间其实只有参数的不同:

1
2
3
4
5
6
7
java复制代码    default Stream<E> stream() {
return StreamSupport.stream(spliterator(), false);
}

default Stream<E> parallelStream() {
return StreamSupport.stream(spliterator(), true);
}

不过一般情况下我们并不需要用到并行流,在Stream中元素不过千的情况下性能并不会有太大提升,因为将元素分散到不同的CPU进行计算也是有成本的。

并行的好处是充分利用多核CPU的性能,但是使用中往往要对数据进行分割,然后分散到各个CPU上去处理,如果我们使用的数据是数组结构则可以很轻易的进行分割,但是如果是链表结构的数据或者Hash结构的数据则分割起来很明显不如数组结构方便。

所以只有当Stream中元素过万甚至更大时,选用并行流才能带给你更明显的性能提升。

最后,当你有一个并行流的时候,你也可以通过sequential() 将其方便的转换成串行流:

1
java复制代码        Stream.of(1, 2, 3).parallel().sequential();

2.4 连接Stream

如果你在两处构造了两个Stream,在使用的时候希望组合在一起使用,可以使用concat():

1
2
java复制代码        Stream<Integer> concat = Stream
.concat(Stream.of(1, 2, 3), Stream.of(4, 5, 6));

如果是两种不同的泛型流进行组合,自动推断会自动的推断出两种类型相同的父类:

1
2
3
4
5
java复制代码        Stream<Integer> integerStream = Stream.of(1, 2, 3);

Stream<String> stringStream = Stream.of("1", "2", "3");

Stream<? extends Serializable> stream = Stream.concat(integerStream, stringStream);
  1. Stream转换操作之无状态方法

无状态方法:即此方法的执行无需依赖前面方法执行的结果集。

在Stream中无状态的API我们常用的大概有以下三个:

  1. map()方法:此方法的参数是一个Function对象,它可以使你对集合中的元素做自定义操作,并保留操作后的元素。
  2. filter()方法:此方法的参数是一个Predicate对象,Predicate的执行结果是一个Boolean类型,所以此方法只保留返回值为true的元素,正如其名我们可以使用此方法做一些筛选操作。
  3. flatMap()方法:此方法和map()方法一样参数是一个Function对象,但是此Function的返回值要求是一个Stream,该方法可以将多个Stream中的元素聚合在一起进行返回。

先来看看一个map()方法的示例:

1
2
3
java复制代码        Stream<Integer> integerStreamList = List.of(1, 2, 3).stream();

Stream<Integer> mapStream = integerStreamList.map(i -> i * 10);

我们拥有一个List,想要对其中的每个元素进行乘10 的操作,就可以采用如上写法,其中的i是对List中元素的变量名,→ 后面的逻辑则是要对此元素进行的操作,以一种非常简洁明了的方式传入一段代码逻辑执行,这段代码最后会返回一个包含操作结果的新Stream。

这里为了更好的帮助大家理解,我画了一个简图:


接下来是filter()方法示例:

1
2
3
java复制代码        Stream<Integer> integerStreamList = List.of(1, 2, 3).stream();

Stream<Integer> filterStream = integerStreamList.filter(i -> i >= 20);

在这段代码中会执行i >= 20 这段逻辑,然后将返回值为true的结果保存在一个新的Stream中并返回。

这里我也有一个简单的图示:


flatMap() 方法的描述在上文我已经描述过,但是有点过于抽象,我在学习此方法中也是搜索了很多示例才有了较好的理解。

根据官方文档的说法,此方法是为了进行一对多元素的平展操作:

1
2
3
4
java复制代码        List<Order> orders = List.of(new Order(), new Order());

Stream<Item> itemStream = orders.stream()
.flatMap(order -> order.getItemList().stream());

这里我通过一个订单示例来说明此方法,我们的每个订单中都包含了一个商品List,如果我想要将两个订单中所有商品List组成一个新的商品List,就需要用到flatMap()方法。

在上面的代码示例中可以看到每个订单都返回了一个商品List的Stream,我们在本例中只有两个订单,所以也就是最终会返回两个商品List的Stream,flatMap()方法的作用就是将这两个Stream中元素提取出来然后放到一个新的Stream中。

老规矩,放一个简单的图示来说明:

图例中我使用青色代表Stream,在最终的输出中可以看到flatMap()将两个流变成了一个流进行输出,这在某些场景中非常有用,比如我上面的订单例子。


还有一个很不常用的无状态方法peek():

1
java复制代码    Stream<T> peek(Consumer<? super T> action);

peek方法接受一个Consumer对象做参数,这是一个无返回值的参数,我们可以通过peek方法做些打印元素之类的操作:

1
java复制代码        Stream<Integer> peekStream = integerStreamList.peek(i -> System.out.println(i));

然而如果你不太熟悉的话,不建议使用,某些情况下它并不会生效,比如:

1
2
3
4
java复制代码        List.of(1, 2, 3).stream()
.map(i -> i * 10)
.peek(System.out::println)
.count();

API文档上面也注明了此方法是用于Debug,通过我的经验,只有当Stream最终需要重新生产元素时,peek才会执行。

上面的例子中,count只需要返回元素个数,所以peek没有执行,如果换成collect方法就会执行。

或者如果Stream中存在过滤方法如filter方法和match相关方法,它也会执行。

3.1 基础类型Stream

上一节提到了三个Stream中最常用的三个无状态方法,在Stream的无状态方法中还有几个和map()与flatMap()对应的方法,它们分别是:

  1. mapToInt
  2. mapToLong
  3. mapToDouble
  4. flatMapToInt
  5. flatMapToLong
  6. flatMapToDouble

这六个方法首先从方法名中就可以看出来,它们只是在map()或者flatMap()的基础上对返回值进行转换操作,按理说没必要单拎出来做成一个方法,实际上它们的关键在于返回值:

  1. mapToInt返回值为IntStream
  2. mapToLong返回值为LongStream
  3. mapToDouble返回值为DoubleStream
  4. flatMapToInt返回值为IntStream
  5. flatMapToLong返回值为LongStream
  6. flatMapToDouble返回值为DoubleStream

在JDK5中为了使Java更加的面向对象,引入了包装类的概念,八大基础数据类型都对应着一个包装类,这使你在使用基础类型时可以无感的进行自动拆箱/装箱,也就是自动使用包装类的转换方法。

比如,在最前文的示例中,我用了这样一个例子:

1
java复制代码        Stream<Integer> integerStream = Stream.of(1, 2, 3);

我在创建Stream中使用了基本数据类型参数,其泛型则被自动包装成了Integer,但是我们有时可能忽略自动拆装箱也是有代价的,如果我们想在使用Stream中忽略这个代价则可以使用Stream中转为基础数据类型设计的Stream:

  1. IntStream:对应 基础数据类型中的int、short、char、boolean
  2. LongStream:对应基础数据类型中的long
  3. DoubleStream:对应基础数据类型中的double和float

在这些接口中都可以和上文的例子一样通过of方法构造Stream,且不会自动拆装箱。

所以上文中提到的那六个方法实际上就是将普通流转换成这种基础类型流,在我们需要的时候可以拥有更高的效率。

基础类型流在API方面拥有Stream一样的API,所以在使用方面只要明白了Stream,基础类型流也都是一样的。

注 :IntStream、LongStream和DoubleStream都是接口,但并非继承自Stream接口。

3.2 无状态方法的循环合并

说完无状态的这几个方法我们来看一个前文中的例子:

1
2
3
4
5
java复制代码        List<Integer> list = List.of(1, 2, 3).stream()
.filter(i -> i > 2)
.filter(i -> i < 10)
.filter(i -> i % 2 == 0)
.collect(toList());

在这个例子中我用了三次filter方法,那么大家觉得Stream会循环三次进行过滤吗?

如果换掉其中一个filter为map,大家觉得会循环几次?

1
2
3
4
5
java复制代码        List<Integer> list = List.of(1, 2, 3).stream()
.map(i -> i * 10)
.filter(i -> i < 10)
.filter(i -> i % 2 == 0)
.collect(toList());

从我们的直觉来看,需要先使用map方法对所有元素做处理,然后再使用filter方法做过滤,所以需要执行三次循环。

但回顾无状态方法的定义,你可以发现其他这三个条件可以放在一个循环里面做,因为filter只依赖map的计算结果,而不必依赖map执行完后的结果集,所以只要保证先操作map再操作filter,它们就可以在一次循环内完成,这种优化方式被称为循环合并。

所有的无状态方法都可以放在同一个循环内执行,它们也可以方便的使用并行流在多个CPU上执行。

  1. Stream转换操作之有状态方法

前面说完了无状态方法,有状态方法就比较简单了,只看名字就可以知道它的作用:

方法名 方法结果
distinct() 元素去重。
sorted() 元素排序,重载的两个方法,需要的时候可以传入一个排序对象。
limit(long maxSize) 传入一个数字,代表只取前X个元素。
skip(long n) 传入一个数字,代表跳过X个元素,取后面的元素。
takeWhile(Predicate predicate) JDK9新增,传入一个断言参数当第一次断言为false时停止,返回前面断言为true的元素。
dropWhile(Predicate predicate) JDK9新增,传入一个断言参数当第一次断言为false时停止,删除前面断言为true的元素。

以上就是所有的有状态方法,它们的方法执行都必须依赖前面方法执行的结果集才能执行,比如排序方法就需要依赖前面方法的结果集才能进行排序。

同时limit方法和takeWhile是两个短路操作方法,这意味效率更高,因为可能内部循环还没有走完时就已经选出了我们想要的元素。

所以有状态的方法不像无状态方法那样可以在一个循环内执行,每个有状态方法都要经历一个单独的内部循环,所以编写代码时的顺序会影响到程序的执行结果以及性能,希望各位读者在开发过程中注意。

  1. 总结

本文主要是对Stream做了一个概览,并讲述了Stream的两大特点:

  1. 不可变:不影响原集合,每次调用都返回一个新的Stream。
  2. 延迟执行:在遇到终结操作之前,Stream不会执行。

同时也将Stream的API分成了转换操作和终结操作两类,并讲解了所有常用的转换操作,下一章的主要内容将是终结操作。

在看Stream源码的过程中发现了一个有趣的事情,在ReferencePipeline类中(Stream的实现类),它的方法顺序从上往下正好是:无状态方法 → 有状态方法 → 聚合方法。

好了,学完本篇后,我想大家对Stream的整体已经很清晰了,同时对转换操作的API应该也已经掌握了,毕竟也不多😂,Java8还有很多强大的特性,我们下次接着聊~


同时,本文在写作过程中也参考了以下书籍:

  • 写给大忙人看的Java SE 8
  • Java 8 函数式编程
  • Java 8 实战

这三本书都非常好,第一本是Java核心技术的作者写的,如果你想全面的了解JDK8的升级可以看这本。

第二本可以说是一个小册子,只有一百多页很短,主要讲了一些函数式的思想。

如果你只能看一本,那么我这里推荐第三本,豆瓣评分高达9.2,内容和质量都当属上乘。


最后,创作不易,如果对大家有所帮助,希望大家点赞支持,有什么问题也可以在评论区里讨论😄~

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Linux系统安装JDK环境

发表于 2021-07-12

Linux版本的JDK下载

JDK下载官网:www.oracle.com/java/techno…

在这里插入图片描述我们可以直接访问JDK的官方网站,去下载Linux版本对应的JDK安装包。
Linux系统直接下载tar.gz的压缩包即可。

Linux环境安装JDK

alt+p快捷键进入sftp传输方式,使用put命令将JDK上传至Linux的root目录下。

  • Liunx环境下的sftp传输命令可以参考上一篇博客:Linux常用命令的sftp传输。
1
powershell复制代码mv jdk-8u241-linux-x64.tar.gz /usr/local/

使用mv剪切命令,将root目录下的JDK安装包剪切到/usr/local/目录下。

1
powershell复制代码tar –zxvf jdk-8u241-linux-x64.tar.gz

使用tar命令,将tar.gz压缩包解压缩在/usr/local/目录下。

1
powershell复制代码rpm -qa | grep java

查询安装的jdk的信息。

1
powershell复制代码rm -rf jdk-8u241-linux-x64.tar.gz

删除JDK的压缩包。

配置JDK

1
powershell复制代码vi /etc/profile

编辑/etc/下的profile文件。

1
2
3
4
5
powershell复制代码#set java environment
JAVA_HOME=/usr/local/jdk1.8.0_241
CLASSPATH=.:$JAVA_HOME/lib/tools.jar
PATH=$JAVA_HOME/bin:$PATH
export JAVA_HOME CLASSPATH PATH

在profile文件中添加上面的配置,保存并退出。

1
powershell复制代码source /etc/profile

加载文件,使配置生效。

1
powershell复制代码java –version

查看JDK版本。

卸载JDK

1
powershell复制代码rpm -e --nodeps jdk1.8.0_241

卸载JDK。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

聊一聊关于聊天记录的存储

发表于 2021-07-12

背景

即时通讯(Instant Messaging),也就是我们常说的 IM,其实在很多业务场景上都会有或多或少的应用,有的会是核心,有的会是辅助。

既然是聊天,那么必然就会产生聊天记录,而且聊天记录随着人数的增加和时间的推移,很容易出现爆炸式的增长,这个对存储其实压力是很大的。

举个大家都很熟悉的例子,一个群聊,几分钟不看,再打开就是 99+ 的未读消息。

把即时通讯这个技术,放到医疗环境下,也是同样适用的。

患者去线下医院看病,肯定离不开和医生的问答,这些问答,对系统来说,其实都是聊天记录。

如果把这个场景放到线上进行,也就是正常的和我们在微信聊天那样了。

说了那么多有的没的,也算是把大背景交代了一下,那么接下来就看看这个聊天记录存储的选型吧。

技术选型

既然要存储,那么肯定就会有很多选择,关系型数据库,非关系型数据库等。

当然这个很大程度上是和具体业务场景挂钩的,离开了业务场景,基本就是在空谈。

在医患关系里面的聊天记录,是一个十分十分核心的内容,并且必需要长期保存,不能丢,可查询。

并且这些聊天记录是和某次问诊强关联的,所以单独拿出几条聊天记录出来,是没有意义的,因为他们没有关联,串联不起来。

按照以往的经验看,一次问诊,医生和患者之间的聊天记录在 50 条之内的占据了大部分,超过50条的,占少数。

这个和其他的 IM 情景可能不太一样。

MySQL

业务开始,大概率就是选用 MySQL 去存了这些数据了,单库单表,但是这种情况下很容易达到单表千万和上亿的级别。

后面面临的基本就是分库分表的操作了。

分库分表,基本就是根据问诊号去进行哈希,然后放到不同的库不同的表。

这里会有一个不确定因素,分多少个库和分多少张表?

分的多,囊中羞涩;分的少,再一次达到量级的时候又要重新分,大动干戈,这个时候最怕的就是动到了哈希的规则。

所以选 MySQL 的话,到了中后期确实还是会有一点力不从心。线性扩展对这一块还是非常重要的。

如果想改动小,避免分库分表,或许可以试试 TiDB,但是它要的配置,也不是中小企业所能接受的,80% 以上的概率会被 Pass 掉。

docs.pingcap.com/zh/tidb/sta…

Cassandra

Cassandra 是一个分布式、无中心、弹性可扩展的 NoSQL 数据库,基于 Amazon Dynamo 的分布式设计和 Google Bigtable 的数据模型。

cassandra.apache.org/doc/latest/…

为什么考虑选型 Cassandra 呢? 对上面说的医患场景,严格上是属于写多读少的,查询基本只会基于问诊号去查询,这个是相对比较明确的。

Discord 在 2017 年的时候有一篇博客讲述了他们是怎么存储数十亿消息记录的 ,说的比较详细了。

blog.discord.com/how-discord…

其实他们选数据库的诉求,也是符合大部分涉及 IM 这一块的。

老黄也是从这里受到了启发,认识了这个数据库。

经常拿来比较的话,应该是 HBase,一个在国内火,一个在国外受欢迎。

可以看看这个对比,了解一下两者的异同: www.scnsoft.com/blog/cassan…

如果选择要用 Cassandra, 那么数据模型的设计,一定是所有环节中最为重要的一步,如果这一步没有做好的话,那后面基本上会是灾难级别,基本不能愉快的玩耍。

那么对医患关系里面的这个聊天模型其实比较简单。

1
2
3
4
5
6
7
8
9
sql复制代码CREATE TABLE IF NOT EXISTS messages(
inq_id text,
send_time bigint,
sender_id text,
sender_role tinyint,
msg_type tinyint,
msg_body text,
PRIMARY KEY (inq_id, send_time)
) WITH CLUSTERING ORDER BY (send_time ASC)

对照正常的 IM 群聊, 这个问诊号 (inq_id) 就可以认为是一个群聊,一个频道。

为什么没有消息Id这样的字段呢?多来源,非自研,无实际意义。

下面再来看看如何在 C# 里面进行操作, 这里用的是 DataStax 提供的 CassandraCSharpDriver 客户端。

写入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
csharp复制代码var cluster = Cassandra.Cluster.Builder()
.AddContactPoints("127.0.0.1")
.WithDefaultKeyspace("messaging")
.Build();

var inqId = "xxxxxx";
var sendTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var senderId = "xxxx";
var senderRole = 1;
var msgType = 1;
var msgBody = "zzzz";

string INSERT_SQL = @" INSERT INTO messages(inq_id, send_time, sender_id, sender_role, msg_type, msg_body)
VALUES (?, ?, ?, ?, ?, ?) ";

var session = cluster.Connect();

var stmt = session.Prepare(INSERT_SQL).Bind(inqId, sendTime, senderId, senderRole, msgType, msgBody));

session.Execute(stmt);

读取:

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
csharp复制代码var cluster = Cassandra.Cluster.Builder()
.AddContactPoints("127.0.0.1")
.WithDefaultKeyspace("messaging")
.Build();

var inqId = "xxxxxx";

string GET_MSG_SQL = @" SELECT * FROM messages WHERE inq_id = ? ";

var session = cluster.Connect();

var stmt = session.Prepare(GET_MSG_SQL).Bind(inqId);

var rowset = session.Execute(stmt);

Console.WriteLine("患者\t\t\t\t\t医生");

foreach (var row in rowset)
{
// 解析从 cassandra 中返回的行
var msg_body = row.GetValue<string>("msg_body");
var sender_role = row.GetValue<sbyte>("sender_role");

if (sender_role == 0)
{
Console.WriteLine($"{msg_body}\t\t\t\t\t");
}
else
{
Console.WriteLine($"\t\t\t\t\t{msg_body}");
}
}

写在最后

存储的选择其实还是有点门道的,根据不同的应用场景,找出比较适合当前场景的几个方案,再选择一个成本没这么高的。

Cassandra 对聊天记录这个场景的存储还是有一定优势的,可以应对高速的数据增长,而不用在业务代码层做过多的适配;部署相对简单,无特殊依赖,运维成本相对较低。

关注我的公众号「不才老黄」,第一时间和您分享老黄的所见所闻。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

【redis前传】zset如何解决内部链表查找效率低下 跳表

发表于 2021-07-12

「本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!」

zset作为有序集合,内部基于跳表或者说索引的方式实现了数据的快速查找。解决了链表查询效率低下的痛点

往期回顾

【redis前传】redis字典快速映射+hash釜底抽薪 | 单线程不影响后台工作之渐进式rehash

【redis正传】redis淘汰+过期双向保证高可用 | 单线程如何做到快速响应

【redis前传】 redis五大天王值list基本数据如何成长 | 由内之外深入学习

【redis前传】自己手写一个LRU策略 | 抓住时间的尾巴

基于redis实现分布式锁

前言

  • 紧接前文我们学习了Redis中Hash结构。在里面我们梳理了字典这个重要的内部结构并分析了hash结构rehash的流程从而解释了为什么redis单线程还是那么快
  • 本章节我们将视角下推,继续学习Redis五大天王中的zset数据结构 ; zset是有序不重复集合其内部元素唯一且是有序的,他的排序标准是根据其内部score维度进行排序的。

zset结构

image-20210705144222654

基本单元

  • 关于zset结构很简单,一个是我们之前学习的字典结构(简单理解成Hash结构),另外一个是跳跃表结构 ; 关于字典我们上一章节已经详细解说了其内部的构造及其如何进行数据扩容等操作!剩下的且符合今天我们学习主旨的自然就是这个熟悉又陌生的zskiplist。
  • 我们根据上面zset的结构图也能够看出来,zskiplist实际上就是一个链表。

zskiplist

image-20210705145402021

  • 我们查看源码不难看出其内部结构是对zset中链表的一个抽象描述。zskiplist首先会对这个链表记录其头结点、尾结点方便通过zskiplist进行遍历操作。剩下的length自然就是对内部的这个链表数量的统计。比较抽象的是这个level的理解。在上面我们也看到了zskiplist那个链表实际上会有分层的概念。笔者这里通过不同的颜色进行表述不同层级的概念。
  • 笔者这里针对上述描述的跳跃表内部的zskiplist绘画了一张内部数据图

image-20210705145952498

  • 在对zskiplist结构描述和数据描述中我将他们拆开理解,觉得这样更容易理解结构关系。下面是整个图示

image-20210705150306846

  • 细心的读者应该能够发现,我好想漏掉了链表重要组成部分zskiplistNode这个重要的节点说明。实际上他就是我们右侧那个链表中节点。换句话说链表中每个点就是zskiplistNode 。

level

  • 跳跃表的重要特性类型与树结构可以避免逐个遍历的苦恼。那么他是如何实现这种跳跃性质的访问的呢?还有一点为什么redis会这么设计。首先我们先回答下为什么这么设计。在链表中插入、删除等操作是很快速的只需要改变指针指向就可以完成。但是对于查询来说他需要遍历整个链表才能完成操作。针对链表的这个弊端redis设计了跳表的数据结构。
  • 下面就是针对如何实现来简单梳理下。上述zskiplistNode节点对象结构中我们也可以看到有个level属性。redis就是通过这个属性来实现跳跃的特性。在每个节点生成的时候回随机生成这个level值。他就表示这个节点所在层的范围。
  • 关于这个level为什么说是随机。这有牵涉到其内部的幂等性算法。这个算法保证数字越大生成的概览越小。在redis内部level最大值32.
  • 比如说level随机生成5 。 表示当前节点node在level1~level5这五层中。上面的图示中所示的三个节点生成的level值分别是level3、level2、level7。注意在实际存储中level索引时从0开始。

forward

  • 在level中还有两个属性分别是前进指针、跨越长度。根据字面意思我们能够理解前进指针是想链表后端方向推进的指针。其跨度就是表示当前节点距离前进指针处节点的距离。这个距离的是参考最底层的距离的。

image-20210705152058003

双链表

  • 在zskiplist中每一层都是一个单向链表。在level中通过forwar指针指向我们表尾。那么为什么我说是双链表呢?这里的双链表不是严格意义的双链表。但是我们可以借助这些层级的单链表实现我们双向自由路由。

image-20210705153433365

随机层

image-20210705152812968

  • 上面我们已经解释过level的定义了。那么为什么这里还有再提一遍呢?因为上面我们简单提到了幂等性算法。这里我们就详细解释下什么是幂等性。
  • 首先根据level的定义我们可以总结如下几点关于level的特性。
  • ①、一个节点如果在level[i]中,那么他一定在level[i]以下的层中
  • ②、越高层元素跨度越大,这个跨度是不定的。取决于生成节点时的随机算法
  • ③、每一层都是一个链表

image-20210705153545606

  • 这是redis中源码部分。关于这个随机level的算法其实不是很难理解。笔者这里将上述代码进行流程化梳理

image-20210705162650582

  • 就是一个不断重试的机制。其中p和maxLevel都是代码中的固定值。在这个算法机制下我们就可以尽可能的保证在数据量小的情况下保证level不会特别的高。
  • 换句话说我们的level就不会显得特别的突兀。如果是纯粹的随机生成的话就有可能有的节点level很低,有的level很高。这样会造成资源不必要的浪费。

查找

  • 好了,同学们到了这里我们已经学习了关于zset的基本结构。 简单回顾下内部就是字典+跳表的结合。下面我们针对这两种数据结构来简单梳理下关于zset的常用的一些操作!
  • 首先就是我们的查找。上面说了那么多内部结构。纸上谈兵终觉浅,我们还需要实战操作一下。

分数定位

image-20210705164350328

  • 上述的命令基本都是通过分数定位然后在做自己的业务处理。

image-20210705165800181

  • 图示中已经说明了我们过程。首先是在最高层中寻找因为高层最稀疏。当高层没有发现时我们就会下推层级。此时我们来到level中的节点1.然后在通过forwar指针进行前移。最终定位节点5。
  • 还有一点补充说明:节点中通过obj指针指向实际内容,score存储分支;笔者这里为什么演示方便直接在节点中标注了分数。其他部分并未进行标注!!!

成员定位

  • 笔者在整理相关逻辑的时候也是经过百度、视频、书籍等方式翻阅后作出的结论。原谅我的能力无法直接阅读源码!但是在查阅资料的过程中。发现很少有说明是如何进行成员定位的。因为zset中除了分数的相关命令以外还有不少是基于成员定位的。

image-20210705170513847

  • 上述命令部分是基于成员进行定位的。在zset结构中实际节点是有基于score进行排序的。在obj中没有顺序可言。我们无法按照我们上述通过分数进行逐层定位元素!这就牵扯到我们另外一个重要的角色【字典(hash)】了。

image-20210705171027960

  • 上图是我们上一章节的关于字典的说明。在通过成员定位的时候我们就是多了一步先从字典中定位到分数,然后在重复上面的步骤进行定位!

image-20210705171201050

  • 了解结构自然就能很容易理解相关的操作。站在巨人的肩膀我们虽然不需要在重复的造轮子了。但是我们得知道当初前辈们造轮子的过程!吃水不忘挖井人!

命令内部理解

  • 了解结构就能快速掌握命令,否则就算死记硬背命令过一阵子又会忘记了。但是牢记结构后我们就会知道有命令可以实现我们的需求然后根据手册就可以得心应手。下面我们看看一下四个命令是如何实现的吧。

zcard

  • 通过zskiplist中length属性

zcount

  • 通过分数定位边界,然后遍历底层链表最终得到统计数量

zlecount

  • 通过字典定位分数,在执行zcount操作

zrank

  • 返回有序集合中指定成员的索引 , 先定位成员,定位过程中通过span可以确定排名

总结

  • zset是一种有序链表。为了解决链表查询低下从而redis构建了跳表的数据结构。大大提高了效率!
  • 关于zset的数据结构我们实际好多案例可以通过它来实现;延时队列、内部LRU、热点数据等等

点赞、关注不迷路哦

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

一款让你优雅、高效实现文件预览的解决方案

发表于 2021-07-12

本文已参与好文召集令活动,点击查看 : 后端、大前端双赛道投稿,2万元奖池等你挑战!

前言

项目开发中,很多系统都会涉及到文件操作,比如常见的上传、下载等,这些也都是司空见惯的事情,也比较容易实现。但是有些情况下,需要支持文件的在线预览功能,由于文件格式众多,不同的文件处理方式不同,使得预览显得比较复杂,实现起来也相对困难。本文结合自己的开发实战情况,总结下目前常用的三种模式及利弊关系,重点讲述下公司实现预览的功能代码及服务的搭建过程。

常用三种实现方式及优缺点

  • 方式一 使用officeapps的在线链接方式实现
    具体为”view.officeapps.live.com/op/view.asp…, 此处的url代表文件的外网访问路径。注意:该url必须是外网可以访问的路径,否则无法预览。参考代码如下:
1
ruby复制代码https://view.officeapps.live.com/op/view.aspx?src=https://demo.jeesite.com/js/a/file/download/1148195356402933760.doc

预览效果:

微信图片_20210708224951.png

优点:实现简单,不需要添加单独搭建预览服务,页面中可以通过声明方法的形式添加预览按钮,然后对点击事件追加如上所示的代码块中的路径即可实现预览功能

缺点:url路径必须为外网可以访问的文件路径,如果文件所存储的服务器无法通过外网访问,该该方式无法实现预览,而且由于预览时服务会从服务器读取文件信息,所以会存在安全隐患,对于一些涉及隐私的文件来说,此种方式显示不可取。另外,由于是第三方在线服务,如果officeapps服务关闭的情况下,项目中的功能会受到影响。

  • 方式二 自己搭建office-online服务
    搭建方式可以参考链接:使用Office Online Server在线预览Office

优点:服务的开启和关闭权掌握在自己手中,不用担心服务关停的情况。另外,服务也不需要外网访问,将文件和office online服务存放到同一台服务器即可。

缺点:服务体量较大,搭建过程比较繁琐,对硬件系统也都有要求(安装环境必须为两台Windows Server 2012 R2 或 Windows Server 2016服务器,一台安装office online服务,一台安装域控服务),还要安装对应的依赖环境,且额外增加了服务器成本开销,操作起来也相对繁琐,所以不太推荐该方法。

  • 方式三 使用开源服务kkFileView对应的jar包手动搭建预览服务(windows、linux环境均可实现)

优点:第三方jar包,不需要额外增加开发成本,下载部署即可使用,操作简单,使用方便

缺点:jar包中有些文档格式不支持(不过能满足平常涉及的大部分文件类型),而且表格有些预览样式不友好,可能需要手动修改jar包源码,修改方式可以参考我的上一篇文章: 手动实现第三方jar包修改并重新打包

综上所述,使用kkFileView对应的jar包无疑是性价比最好的一种实现方式。
接下来重点讲述kkFileView搭建预览服务的过程,主要分为两部分,windows版本和linux版本服务搭建。

1 window版本服务搭建

jar包下载路径:网址,
提取码:yqsy

搭建流程:

1. 1 将下载的.zip包解压

1. 2 进入到bin目录,双击.bat文件运行即可

1. 3 vue页面中需要添加预览按钮,引入方法,进行路径拼接调用,代码如下,url为文件完整物理路径,如果使用nginx代理的话,为网络代理路径,其中localhost:8012为预览服务的访问路径,个人建议尽量将localhost换成ip,并且将http://localhost:8012/onlinePreview?url= 做成可配置的路径配置到前端所在的文件中动态读取

1
2
3
4
javascript复制代码fileView: function(url) {
let Base64 = require('js-base64').Base64;
window.open('http://localhost:8012/onlinePreview?url='+encodeURIComponent(Base64.encode(url)));
},

注意: 由于方法使用了base64进行加密url路径,所以vue框架要引入base64组件,安装命令如下:

1
css复制代码npm install --save js-base64 或者cnpm install --save js-base64

1. 4 服务访问
网址输入localhost:8012或ip:8012,回车,如果看到如下界面,代表成功

微信图片_20210708235357.png

然后点击选择文件–上传–预览,即可看到效果,然后在项目中点击自己的预览按钮可以查看具体效果。

截图如下:

微信图片_20210708235826.png

微信图片_20210708235836.png

2 linux版本服务搭建

jar包下载路径:网址,
提取码:yqsy

搭建流程:

2. 1 将下载的.tar.gz包解压

2. 2 进入到bin目录,运行.sh脚本文件

2. 3 由于linux系统下缺少中文字体,文件预览时可能出现乱码情况,所以需要安装相应字体,首先下载字体包, 网址,提取码:yqsy,然后将文件解压完整拷贝到Linux下的 /usr/share/fonts目录安装,如图

微信图片_20210709001510.png

完成执行以下三个命令使字体生效

微信图片_20210709001226.png

1. 4 服务访问
网址输入localhost:8012或ip:8012,回车,如果看到如下界面,代表成功

微信图片_20210708235357.png

预览同window下操作。

备注:文件下载有效期为30天,如果出现不能下载的情况,可以给小编留言评论获取新的链接。

好了,今天的分享就到这里,如果对你有所帮助的话,记得给小编点赞哦!如果您有更好的实现方式,也可以下方评论留言,一起探讨!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

五年java转Go学习之路 (一) 环境详解 前言 Go简介

发表于 2021-07-11

前言

monkey换工作了,新公司使用Go作为开发语言,迫于生计只能放弃了用了五年的java。然而作为一名立志深耕技术的程序员,还是记录一下转语言的得与失吧。毕竟风水轮流转,今天Go比较火,大家一窝蜂的转型,明天说不准新语言就横空出世了,真是活到老学到老。我也是边学边写,如有谬误,请大家指正。

Go简介

每一门新语言的诞生都是为了解决现有语言的不足,Go项目是在Google公司维护超级复杂的几个软件系统遇到的一些问题的反思。

image.png

这是摘自Go语言圣经的一张图,可以看出Go继承了c的基因,包括像基础数据类型、指针等思想,所以我觉得如果之前有c的基础,学Go会非常轻松。还有包括来自Pascal语言系的包处理相关概念,而csp对于管道的处理更为go带来了区别于其他语言的重要特性。

另外GO的简洁编程哲学我个人非常认同,软件的复杂性是乘法级相关的,写出复杂的逻辑来解决复杂的事情难度并不高,但是通过简单的逻辑完成复杂问题的处理是非常考验程序员技术水平的一件事,但这对于以后的运维和持续优化又非常重要。

环境搭建

学习一门语言要做的第一件事,都是根据自己的计算架构以及操作系统从官网下载对应的二进制包。

对于Go,我们可以使用的安装包下载地址是:golang.org/dl/ 。如果打不开可以使用这个官方镜像地址:golang.google.cn/dl/ 。

以我用的mac为例,将下载的二进制包解压至 /usr/local目录并且配置相应的环境变量。或者直接下载可执行文件版,这个网上教程非常多,这里不再赘述了。安装完成后可以打开命令行输入go version查看是否安装成功。

环境配置

在这个过程中,我们还需要配置3个环境变量,也就是 GOROOT、GOPATH 和 GOBIN。今天主要针对这三个环境变量进行讲解:

GOROOT:GO 语言的安装路径。

GOPATH:我们自己定义的工作空间。

GOBIN: GO 程序生成的可执行文件(executable file)的路径。

1. GO的项目结构

这三个变量跟GO语言的项目结构紧密相关:

在进行Go语言开发的时候,我们的代码总是会保存在GOPATH/src目录下。在工程经过go build、go install或go get等指令后,会将下载的第三方包源代码文件放在GOPATH/src目录下, 产生的二进制可执行文件放在GOPATH/bin目录下,生成的中间缓存文件会被保存在 GOPATH/pkg 下。

GOPATH.jpg

你可以把 GOPATH 简单理解成 Go 语言的工作目录,它的值是一个目录的路径,也可以是多个目录路径,每个目录都代表 Go 语言的一个工作区(workspace)。

而与许多编程语言一样,Go 语言的源码也是以代码包为基本组织单位的。在文件系统中,这些代码包其实是与目录一一对应的。由于目录可以有子目录,所以代码包也可以有子包。

所以说,Go 语言源码的组织方式就是以环境变量 GOPATH、工作区、src 目录和代码包为主线的。

2. 工作区

GOPAT的值可以是多个目录路径,Linux下用冒号分割,Windows使用分号分割。这时如果使用go get 会默认安装到第一个GOPATH路径

这里有两个问题需要注意:

  1. Go 语言在多个工作区中查找依赖包的时候是以怎样的顺序进行的?

实际上这里是我们设置的GOPATH的值决定了这个顺序,Go会按照设置的工作区从左到右的进行遍历。

  1. 如果在多个工作区中都存在导入路径相同的代码包会产生冲突吗?

不会产生冲突,因为代码包的查找是按照已给定的顺序逐一地在多个工作区中进行的。

3. 构建和安装GO程序

GOPATH下的src目录就是接下来开发程序的主要目录,所有的源码都是放在这个目录下面,那么一般我们的做法就是一个目录一个项目

1
2
3
4
5
6
7
8
9
10
Go复制代码// $GOPATH/src/mymath/sqrt.go源码如下:
package mymath

func Sqrt(x float64) float64 {
z := 0.0
for i := 0; i < 1000; i++ {
z -= (z*z - x) / (2 * x)
}
return z
}

这样我的应用包目录和代码已经新建完毕,注意:一般建议package的名称和目录名保持一致

构建使用命令go build,安装使用命令go install。构建和安装代码包的时候都会执行编译、打包等操作,并且,这些操作生成的任何文件都会先被保存到某个临时的目录中。

如果构建的是库文件,那么操作后产生的结果文件只会存在于临时目录中。这里的构建的主要意义在于检查和验证。

如果构建的是命令文件(就是mian包带有main函数的文件),那么操作的结果文件会被搬运到源码文件所在的目录中。

安装操作会先执行构建,然后还会进行链接操作,并且把结果文件搬运到指定目录。

进一步说,如果安装的是库文件,那么结果文件会被搬运到它所在工作区的 pkg 目录下的某个子目录中。

有两种方式可以进行安装

1、只要进入对应的应用包目录,然后执行go install,就可以安装了

2、在任意的目录执行如下代码go install mymath

安装完之后,我们可以进入如下目录:

1
2
3
Go复制代码cd $GOPATH/pkg/${GOOS}_${GOARCH}
//可以看到如下文件
mymath.a

如果安装的是命令文件,那么结果文件会被搬运到它所在工作区的 bin 目录中,或者环境变量GOBIN指向的目录中。

这个.a文件是应用包,那么我们如何进行调用呢?

接下来我们新建一个应用程序来调用这个应用包

新建应用包mathapp

1
2
3
4
5
6
7
8
9
10
Go复制代码package main

import (
"mymath"
"fmt"
)

func main() {
fmt.Printf("Hello, world. Sqrt(2) = %v\n", mymath.Sqrt(2))
}

可以看到这个的package是main,import里面调用的包是mymath,这个就是相对于GOPATH/src的路径,如果是多级目录,就在import里面引入多级目录,如果你有多个GOPATH,也是一样,Go会自动在多个$GOPATH/src中寻找。

如何编译程序呢?进入该应用目录,然后执行go build,那么在该目录下面会生成一个mathapp的可执行文件

1
GO复制代码./mathapp

输出如下内容

1
Go复制代码Hello, world.  Sqrt(2) = 1.414213562373095

如何安装该应用?

进入该目录执行go install,那么在GOPATH/bin/下增加了一个可执行文件mathapp, 我们把GOPATH/bin加到我们的PATH里,这样可以在命令行输入如下命令就可以执行

1
复制代码mathapp

也是输出如下内容

1
scss复制代码Hello, world.  Sqrt(2) = 1.414213562373095

总结

工作区和 GOPATH 的概念和含义是每个 Go 工程师都需要了解的。虽然它们都比较简单,但是说它们是 Go 程序开发的核心知识并不为过。

相关推荐

GO语言圣经 shouce.jb51.net/gopl-zh/ind…

GO语言核心36讲 time.geekbang.org/column/intr…

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

你真的了解String吗?一道面试题引发的思考 前言 不可变

发表于 2021-07-11

这是我参与新手入门的第3篇文章

前言

首先,先来和大家看一道熟悉的面试题,判断以下输出的结果分别是什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码public class Test {
public static void main(String[] args) {
String str1 = "hello";
String str2 = "hello";
String str3 = new String("hello");
String str4 = new String("hello");

System.out.println(str1==str2);
System.out.println(str1==str3);
System.out.println(str3==str4);

System.out.println(str1.equals(str2));
System.out.println(str1.equals(str3));
System.out.println(str3.equals(str4));
}
}

相信聪明的小伙伴们一定都知道正确输出是什么,没错,结果就是你们想的那样

1
2
3
4
5
6
java复制代码true
false
false
true
true
true

至于原因,因为equals比较的是两个对象的内容是否相等,显而易见,上面4个的内容都是hello,所以结果都是true。
而==比较的则是对象的地址是否一致,我们知道,第一种方式String str1 = "hello",此种创建方式下,编译器首先检查常量池中是否含有此字符串常量,没有则创建,然后JVM会开辟一块堆内存存放一个隐式对象指向该字符串常量,接着在栈空间里创建str1变量,str1变量指向该块堆内存首地址。
第二种方式String str3 = new String("hello"),此种创建方式下,直接在堆内存new一个对象,并在常量池中创建“hello”字符串,该对象指向常量池中的“hello”字符串,然后创建str3变量,并将str3变量指向该块堆内存首地址。所以其实它们在内存中的分布是这样的
image.png
相信大家看到这里对上面的面试题应该就能理解了,接下来我们再聊聊String这个类的一些特性。

不可变?

打开String类的源码,可以看到这样一句话
image.png
这里已经明确说明,String类一旦创建就不会改变,原因也很简单,因为类被final修饰了
image.png
再来看下面一段代码

1
2
3
4
5
6
7
8
java复制代码public class Test {
public static void main(String[] args) {
String a1 = new String("Hello");
String a2 = new String("World");
a1 += a2;
System.out.println(a1);
}
}

此时输出的结果是HelloWorld,等等,不是刚说了String类是final修饰的,是不可变的么?怎么刚说完就又能变了呢?其实此段代码执行时,首先在内存中分配一块空间,它的大小为a1和a2空间大小之和,然后将a1和a2的内容复制到该内存空间相应位置,最后将变量a1指向该内存空间,此时的a1其实已经是一个新的String对象了。我们在阅读String中的方法是可以看出,所有的改变字符串的方法其实都是返回了一个新的对象return new String。

原因

那么问题来了,为什么String会被设计成不可变的呢?主要原因有如下几点:

  1. 在Java程序中String类型是使用最多的,这就牵扯到大量的增删改查,每次增删改差之前其实jvm需要检查一下这个String对象的安全性,就是通过hashcode,当设计成不可变对象时候,就保证了每次增删改查的hashcode的唯一性,也就可以放心的操作。
  2. 网络连接地址URL,文件路径path通常情况下都是以String类型保存, 假若String不是固定不变的,将会引起各种安全隐患。就好比我们的密码不能以String的类型保存,,如果你将密码以明文的形式保存成字符串,那么它将一直留在内存中,直到垃圾收集器把它清除。而由于字符串被放在字符串缓冲池中以方便重复使用,所以它就可能在内存中被保留很长时间,而这将导致安全隐患
  3. 字符串值是被保留在常量池中的,也就是说假若字符串对象允许改变,那么将会导致各种逻辑错误。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

JVM开篇

发表于 2021-07-11

JDK的组成

image.png

JVM的组成

JVM (5).jpg

运行时数据区就是JVM的内存模型。

虚拟机栈

image.png

本地方法栈和虚拟机栈差不多,区别以后再说。

方法区

  • 用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据。
    (1)线程共享的;
    (2)运行时常量池;
  • 存放编译期生成的各种字面量和符号引用。
  • Class文件中除了存有类的版本、字段、方法、接口等描述信息。

static 差不多都在里面。

大致宏观上

  • 栈中的栈帧里的局部变量可能是对象,就会有指向堆中对应对象的引用。
  • 方法区的静态变量也可能是对象,就会有指向堆中对应对象的引用。
  • 程序计数器存有指向方法区指令执行到哪的引用(地址)。
  • 程序执行引擎根据程序计数器执行指令代码。
  • 众所周知,对象在堆中。

image.png

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

为什么说并发场景不要乱用syncmap map 本身并发不

发表于 2021-07-11

「本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!」

map 本身并发不安全的

我们都知道go的map是并发不安全的,当几个goruotine同时对一个map进行读写操作时,就会出现并发写问题fatal error: concurrent map writes

carbon-7.png

  1. 在程序一开始我们初始化一个map
  2. 子goroutine对m[a]赋值
  3. 主goroutine对m[a]赋值
    理论上只要在多核cpu下,如果子goroutine和主gouroutine同时在运行,就会出现问题。我们不妨用go自带的-race 来检测下,可以运行 go run -race main.go

carbon-8.png
通过检测,我们可以发现,存在data race,即数据竞争问题。有人说这简单,加锁解决,加锁固然可以解决,但是你懂的,锁的开销问题。

撇开数据竞争的问题,我们可以通过看个例子来了解下锁的开销:

carbon-9.png

  1. BenchmarkAddMapWithUnLock 是测试无锁的
  2. BenchmarkAddMapWithLock 是测试有锁的
    通过go test -bench .来跑测试,得出的结果如下:

carbon (22).png
可以发现无锁的平均耗时约6.6 ms,带锁的平均耗时约7.0 ms,虽说相差无几,但也反应加锁的开销。在一些复杂的案例中,可能会更明显。

sync.map

有人说,既然锁开销大,那么就用go内置的方法sync.map,它可以解决并发问题。sync.map确实可以解决并发map问题,但是它在读多写少的情况下,比较适合,可以保证并发安全,同时又不需要锁的开销,在写多读少的情况下反而可能会更差,主要是因为它的设计,我们从源码分析看看:

结构

carbon (23).png

  1. mutex锁,当涉及到脏数据(dirty)操作时候,需要使用这个锁
  2. read,读不需要加锁,就是从read中读的,read是atomic.Value类型,具体结构如下:

carbon (25).png
read的数据存在readOnly.m中,也是个map,value是个entry的指针,entry是个结构体具体类型如下:

carbon (26).png
里面就一个p,当我们设置一个key的value时,可以理解为p就是指向这个value的指针(p就是value的地址)。

当readOnly.amended = true的时候,表示read的数据不是最新的,dirty里面包含一些read没有的新key。

  1. Map的dirty也是map类型,从命名来看它是脏的,可以理解某些场景新加kv的时候,会先加到dirty中,它比read要新。

  2. Map的misses,当从read中没读到数据,且amended=true的时候,会尝试从dirty中读取,并且misses会加1,当misssed数量大于等于dirty的长度的时候,就会把dirty赋给read,同时重置missed和dirty。

举个例子

sync.map的核心思想就是空间换时间。

假设现在有个画展对外展示(read)n幅画,一群人来看,大家在这个画展上想看什么就看什么,不用等待、不用排队。这时上了副新画,但是由于画展现在在工作时间,不能直接挂上去,而且新画可能还要保养什么,暂时不放在画展(read)上,于是就先放在备份的仓库中(dirty),如果真有人要看这幅新画,那么只能领他到仓库中(dirty)中去看,假设这时来了个新画,此时仓库中有n+1副画了,这时有人来问:有没有这幅新画呀,经理说:有,你和我到仓库中去看下。这时又有人来问:有没有这幅新画呀,经理说:有,你和我到仓库中去看下。当问有没有这幅新画的次数达到了n+1的时候,这时画展的老板发现这幅新画要看的人还不少。于是对经理说:你去看下,等下没人看画展(read)的时候,把画展(read)的画全部下掉,把仓库(dirty)里面的画全部换上。当经理全部换结束后,此时画展(read)上已经是最全最新的画了。

sync.map的原理大概就类似上面的例子,在少量人对新画(新的k、v)感兴趣的时候,就带他去仓库(dirty)看,此时因为经理只有一个,所以每次只能带一个人(加锁),效率低,其他的画,在画展(read)上,随便看,效率高。

Store (新增或者更新一个kv)

carbon (27).png

  1. 当key存在read的时候,那么此时就是更新value,尝试去直接更新value,更新成功了就返回,不需要加锁。这里面有个tryStore:

carbon (29).png
tryStore里面有判断p == expunged就返回false。p有三种类型:nil(read中的key被delete的时候其实软删除,只是把p设置成nil)、expunged(被删除的key(p==nil)会在read copy 到 dirty的时候再被设置成expunged)、其他正常的value的地址,这里如果是expunged就不选择更新value。

  1. 加锁,接下来都是线程安全的。
  2. 加锁的过程可能原本不存在的key,加完锁有了,所以要再check下,如果read中存在,且本来被dirty删除了,那么在dirty中还原下key,最后设置value。
  3. 如果read中没有key,但是dirty中有,那么直接修改value
  4. 如果read和dirty中都没有这个key,且dirty为nil的时候,尝试把read中未删除的copy到dirty中去,(read中删除不是真的删除,会把entry.p设置为nil,简单理解就是把key的value的地址设置为nil),这些都是在dirtyLocked中完成的:

carbon (30).png
然后在dirty中设置新的k、v。(这里可以发现新的k、v都是先加在dirty的map中的,read是没有的)。

  1. 现在dirty是比较干净的数据了(已经清空了nil或expunged的key),设置amended=true(说明此时dirty不为空,且dirty中有新数据)

  2. 解锁

总结:

  1. 可以发现对于更新,read和dirty因为value是指针,底层是一个value,这样都会被更新
  2. 对于新增的,会先加在dirty中,read中并不会新增
  3. 对于新增是要加锁的,所以假设存在一种极端的case:一直加新key,那么每次都是要加锁的,何况中间还有if else的分支判断。整体肯定是比常规map加锁性能要差的。

Load(获取一个kv)

carbon (28).png

  1. 当read中不存在这个key,且amenbed=true的时候(通过上面的store,说明此时dirty有新数据),加锁(dirty不是线程安全的)
  2. 因为加锁的过程,可能read发生变化,所以再次check下
  3. 去dirty中获取数据
  4. 通过misslock,不管有没有,先对misses +1,如果miss次数>=len(dirty),那么就把dirty copy给read,这样read的数据就是最新的了
  5. 重制dirty和misses。

carbon (31).png
6. 如果没有对应的key,就返回nil,有的话,就返回对应的value

总结:

  1. 如果read中有key,就不用加锁,直接返回,效率高,读多的场景友好
  2. 如果dirty有key的话,通过记录miss次数来反转read,忍受一段miss的带来的lock时间,对于新key最终还是读read。

Delete(删除一个k)

carbon (32).png

  1. 当read不存在这个key,且dirty有新数据的时候,加锁
  2. 因为加锁的过程,可能read发生变化,所以再次check下
  3. dirty中有新数据的时候,直接删除dirty中的k
  4. 如果read有,那么就软删除,设置p为nil

carbon (33).png
总结:当删除的key在read中,可以通过软删除来标记,这样本身read对应的map不会因为频繁删除而触发等量扩容,关于map的扩容规则可以参考map原理。

回到题目

通过分析了sync.map我们发现,在读多写少的情况下,还是比较优秀的,相比常规map加锁那种肯定是更好的,但是写多读少的情况下,并不适合,因为还是涉及到频繁的加锁、read和dirty交换等开销,搞不好还比常规的map加锁性能更差。我们还是通过一个极端的例子来看:

carbon (34).png

  1. BenchmarkAddMapWithUnLock 是测试无锁的
  2. BenchmarkAddMapWithLock 是测试有锁的
  3. BenchmarkAddMapWithSyncMap 是测试sync.map
    3个方法都是对一个map加10w条数据。

通过go test -bench .来跑测试,得出的结果如下:

carbon (35).png
可以看出sync.map的耗时是其他的两个的5倍左右。sync.map是个好东西,但是场景用错,反而适得其反。

image.png

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…612613614…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%