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

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


  • 首页

  • 归档

  • 搜索

DDD 中的那些模式 — CQRS

发表于 2020-03-24

DDD 作为一种系统分析的方法论,最大的问题是如何在项目中实践。而在实践过程中必然会面临许多的问题,「模式」是系统架构领域中一种常见的手段,能够帮助开发人员与架构师在遭遇某种较为棘手,或是陌生的问题时,参考已有的成熟经验与解决方案,从而优雅的解决自己项目中的问题。

从本期开始,我会开始介绍 DDD 中一些常见的模式,包括这些模式的背景,作用,优缺点,以及在使用过程中需要注意的地方。而本次的主角就是 CQRS,中文名为命令查询职责分离。

为何要使用?

毋庸置疑「领域」在 DDD 中占据了核心的地位,DDD 通过领域对象之间的交互实现业务逻辑与流程,并通过分层的方式将业务逻辑剥离出来,单独进行维护,从而控制业务本身的复杂度。

但是作为一个业务系统,「查询」的相关功能也是不可或缺的。在实现各式各样的查询功能时,往往会发现很难用领域模型来实现。假设在用户需要一个订单相关信息的查询功能,展现的是查询结果的列表。列表中的数据来自于「订单」,「商品」,「品类」,「送货地址」等多个领域对象中的某几个字段。这样的场景如果还是通过领域对象来封装就显的很麻烦,其次与领域知识也没有太紧密的关系。

此时 CQRS 作为一种模式可以很好的解决以上的问题,那么具体什么是 CQRS 呢?又如何实现呢?

什么是 CQRS?

CQRS — Command Query Responsibility Segregation,故名思义是将 command 与 query 分离的一种模式。query 很好理解,就是我们之前提到的「查询」,那么 command 即命令又是什么呢?

CQRS 将系统中的操作分为两类,即「命令」(Command) 与「查询」(Query)。命令则是对会引起数据发生变化操作的总称,即我们常说的新增,更新,删除这些操作,都是命令。而查询则和字面意思一样,即不会对数据产生变化的操作,只是按照某些条件查找数据。

CQRS 的核心思想是将这两类不同的操作进行分离,然后在两个独立的「服务」中实现。这里的「服务」一般是指两个独立部署的应用。在某些特殊情况下,也可以部署在同一个应用内的不同接口上。

Command 与 Query 对应的数据源也应该是互相独立的,即更新操作在一个数据源,而查询操作在另一个数据源上。看到这里,你可能想到一个问题,既然数据源进行了分离,如何做到数据之间的同步呢?让我们接着往下看。

实现 CQRS

让我们先看一下 CQRS 的架构图:

从图上可以看到,当 command 系统完成数据更新的操作后,会通过「领域事件」的方式通知 query 系统。query 系统在接受到事件之后更新自己的数据源。所有的查询操作都通过 query 系统暴露的接口完成。

从架构图上来看,CQRS 的实现似乎并不难,许多开发者觉得无非是「增删改」一套系统一个数据库,「查询」一个系统一个数据库而已,有点类似「读写分离」,并没有什么特别的地方。但是真正要使用 CQRS 是有许多问题与细节要解决的。

CQRS 带来的问题

事务

其实仔细的思考一下,你应该很快会发现 CQRS 需要面临的一个最大的问题: 事务。在原本单一进程,单一数据源的系统中,依靠关系型数据库的事务特性能够很好的保证数据的完整性。但是在 CQRS 中这一切都发生了变化。

当 command 端完成数据更新后,需要通过事件的形式通知 query 端系统,这就存在着一定的时间差,如果你的业务对于数据完整的实时性非常高,那么可能 CQRS 不一定适合你。

其次一个 command 触发的事件在 query 端可能需要更新数个数据模型,而这也是有可能失败的。一旦更新失败那么数据就会长时间的处于不一致状态,需要外部的介入。这也是在使用 CQRS 之前就需要考虑的。

从事务的角度来看 CQRS,你需要面对的是问题从根本来说是个最终一致性的问题,所以如果你的团队在这块没有太多经验的话,那么需要提前学习并积累一定的经验。

基础设施与技术能力的挑战

CQRS的另一个问题是没有一个成熟易用的框架,Axon 可能算一个,但是 Axon 本身是一个重量级且依赖性较高的框架。为了 CQRS 而引入 Axon 有点舍本逐末的意思,因此大部分时间你不得不自己动手实现 CQRS。

一个成熟可靠的 CQRS 系统对于基础设施有一定的要求,例如为了实现领域事件,一个可靠的消息中间件是不可或缺的。不然频繁丢失事件造成数据不一致的情况会让运维人员焦头烂额。之前提到的分布式事务与最终一致性的问题也需要专门的中间件或是框架的支持,这些不仅仅提升了对基础设施的要求,对于开发,运维也提出了更高的要求。

开发过程中需要加入对于事件的支持,系统设计的思路也同样需要一定的转变。在定义 command 时需要设计对应的事件,设计事件的类型与数据结构,所以在这方面也对开发团队提出了新的要求。

因此在开始使用 CQRS 之前不妨对自己团队的基础设施以及开发能力做一次全面的评估,尽早的识别出短板,并进行有目的的改进与强化,避免在开发过程中别某些问题卡住。

查询模型的设计

虽然 CQRS 为我们分离了领域模型和服务于查询功能的数据模型,但这意味着我们需要设计另一套针对查询功能的数据模型。一般比较简单的做法是按照查询功能所需的数据进行设计,即针对每一个查询接口设计一个数据视图,当收到领域事件时更新有关联的数据视图。

但是这种简单做法带来的问题就是当查询接口越来越多时就会难以管理,仍然需要按照 DDD 中划分 BC 的思路将属于一个 BC 的查询集中管理作为整个查询系统的一个上下文,或是干脆独立出来做一个微服务。所以即使引入了 CQRS,我们依然需要使用领域驱动的思路设计查询接口。

与 Event Sourcing 的关系

Event Sourcing是由 Martin Fowler 提出的一个企业架构模式。简单的来说它会将系统所有产生业务行为以 append-only 的形式存储起来,通俗的说就是「流水账」。它的优点是可以「回溯」,因为记录了每一次数据变动的信息,所以当出现 bug 或是需要排查业务数据问题时就非常的方便。但是它的缺点同样明显,就是当需要查询最新状态的数据时需要做大量的计算,例如账户余额这样的数据。

许多讨论 CQRS 的文章中都会谈及 Event Sourcing,认为这是两个需要配套使用的模式。但是从我实际使用的角度而言,这两个模式其实并没有什么必然的联系。Command 端只需要关心领域模型的更新成功与否,同时使用 Aggregate 这样的领域对象保证数据的完整性,而 Query 端关心的是接收到领域事件后更新对应的数据模型,对于「回溯」这样的特性并没有强制的要求。的确 Event Sourcing 可以帮助我们构建更为稳定,功能更为强大的 CQRS 系统,但是 Event Sourcing 本身的复杂性可能比 CQRS 有过之而无不及,所以在没有特殊需要的情况下,CQRS 与 Event Sourcing 不需要绑在一起。

不同类型的数据存储引擎

这一点其实不能算是问题,更多的是一项挑战或是优势。由于分离了领域模型与数据模型,因此意味着我们可以在 Query 端使用与查询需求更为贴近的数据存储引擎,例如 NoSQL,ElasticSearch 等。

比较常见的情况是 Command 端依然使用传统的关系型数据库,但是对于那些比较特殊的查询则使用专门的数据存储。例如在一些基于关键字进行全文检索的场景,如果依然使用关系型数据库,通过 like 这样的 SQL 查询,很容易遇到性能问题。此时则可以将数据存储换为 ElasticSearch 这样的检索引擎,通过反向索引提取关键字查询,在性能方面会得到非常明显的提升。在另一些需要非结构化数据查询的场景,Json 是一种不错的存储格式,虽然现在比较新版本的关系型数据库都提供了 Json 格式的存储与查询,但是 MongoDB 这样的文档型数据库显得更为简单高效,此时 Query 端灵活的优势就更为明显。

小结

CQRS 在 DDD 中是一种常常被提及的模式,它的用途在于将领域模型与查询功能进行分离,让一些复杂的查询摆脱领域模型的限制,以更为简单的 DTO 形式展现查询结果。同时分离了不同的数据存储结构,让开发者按照查询的功能与要求更加自由的选择数据存储引擎。

同样的,CQRS 在带来架构自由与便利的同时也不可避免的引入了额外的复杂性与技能要求,例如对于分布式事务,消息中间件的管理,数据模型的设计等等,所以在引入 CQRS 之前需要对团队能力与现有架构做仔细的分析,对短板进行必要的提升。如果现有系统逻辑较为简单,只是一些 CRUD,那么并不建议使用 CQRS。但是如果你的业务系统已经非常庞大,业务流程庞杂,逻辑繁琐,那么不妨尝试使用 CQRS 将 Command 与 Query 进行拆分,将领域模型与数据模型的边界划分的更清晰些。

欢迎关注我的微信号「且把金针度与人」,获取更多高质量文章

本文转载自: 掘金

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

使用Redis+AOP优化权限管理功能,这波操作贼爽!

发表于 2020-03-24

SpringBoot实战电商项目mall(30k+star)地址:github.com/macrozheng/…

摘要

之前有很多朋友提过,mall项目中的权限管理功能有性能问题,因为每次访问接口进行权限校验时都会从数据库中去查询用户信息。最近对这个问题进行了优化,通过Redis+AOP解决了该问题,下面来讲下我的优化思路。

前置知识

学习本文需要一些Spring Data Redis的知识,不了解的朋友可以看下《Spring Data Redis 最佳实践!》。
还需要一些Spring AOP的知识,不了解的朋友可以看下《SpringBoot应用中使用AOP记录接口访问日志》。

问题重现

在mall-security模块中有一个过滤器,当用户登录后,请求会带着token经过这个过滤器。这个过滤器会根据用户携带的token进行类似免密登录的操作,其中有一步会从数据库中查询登录用户信息,下面是这个过滤器类的代码。

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
复制代码/**
* JWT登录授权过滤器
* Created by macro on 2018/4/26.
*/
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
private static final Logger LOGGER = LoggerFactory.getLogger(JwtAuthenticationTokenFilter.class);
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private JwtTokenUtil jwtTokenUtil;
@Value("${jwt.tokenHeader}")
private String tokenHeader;
@Value("${jwt.tokenHead}")
private String tokenHead;

@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String authHeader = request.getHeader(this.tokenHeader);
if (authHeader != null && authHeader.startsWith(this.tokenHead)) {
String authToken = authHeader.substring(this.tokenHead.length());// The part after "Bearer "
String username = jwtTokenUtil.getUserNameFromToken(authToken);
LOGGER.info("checking username:{}", username);
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
//此处会从数据库中获取登录用户信息
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
if (jwtTokenUtil.validateToken(authToken, userDetails)) {
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
LOGGER.info("authenticated user:{}", username);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
}
chain.doFilter(request, response);
}
}

当我们登录后访问任意接口时,控制台会打印如下日志,表示会从数据库中查询用户信息和用户所拥有的资源信息,每次访问接口都触发这种操作,有的时候会带来一定的性能问题。

1
2
3
4
5
6
复制代码2020-03-17 16:13:02.623 DEBUG 4544 --- [nio-8081-exec-2] c.m.m.m.UmsAdminMapper.selectByExample   : ==>  Preparing: select id, username, password, icon, email, nick_name, note, create_time, login_time, status from ums_admin WHERE ( username = ? ) 
2020-03-17 16:13:02.624 DEBUG 4544 --- [nio-8081-exec-2] c.m.m.m.UmsAdminMapper.selectByExample : ==> Parameters: admin(String)
2020-03-17 16:13:02.625 DEBUG 4544 --- [nio-8081-exec-2] c.m.m.m.UmsAdminMapper.selectByExample : <== Total: 1
2020-03-17 16:13:02.628 DEBUG 4544 --- [nio-8081-exec-2] c.macro.mall.dao.UmsRoleDao.getMenuList : ==> Preparing: SELECT m.id id, m.parent_id parentId, m.create_time createTime, m.title title, m.level level, m.sort sort, m.name name, m.icon icon, m.hidden hidden FROM ums_admin_role_relation arr LEFT JOIN ums_role r ON arr.role_id = r.id LEFT JOIN ums_role_menu_relation rmr ON r.id = rmr.role_id LEFT JOIN ums_menu m ON rmr.menu_id = m.id WHERE arr.admin_id = ? AND m.id IS NOT NULL GROUP BY m.id
2020-03-17 16:13:02.628 DEBUG 4544 --- [nio-8081-exec-2] c.macro.mall.dao.UmsRoleDao.getMenuList : ==> Parameters: 3(Long)
2020-03-17 16:13:02.632 DEBUG 4544 --- [nio-8081-exec-2] c.macro.mall.dao.UmsRoleDao.getMenuList : <== Total: 24

使用Redis作为缓存

对于上面的问题,最容易想到的就是把用户信息和用户资源信息存入到Redis中去,避免频繁查询数据库,本文的优化思路大体也是这样的。

首先我们需要对Spring Security中获取用户信息的方法添加缓存,我们先来看下这个方法执行了哪些数据库查询操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复制代码/**
* UmsAdminService实现类
* Created by macro on 2018/4/26.
*/
@Service
public class UmsAdminServiceImpl implements UmsAdminService {
@Override
public UserDetails loadUserByUsername(String username){
//获取用户信息
UmsAdmin admin = getAdminByUsername(username);
if (admin != null) {
//获取用户的资源信息
List<UmsResource> resourceList = getResourceList(admin.getId());
return new AdminUserDetails(admin,resourceList);
}
throw new UsernameNotFoundException("用户名或密码错误");
}
}

主要是获取用户信息和获取用户的资源信息这两个操作,接下来我们需要给这两个操作添加缓存操作,这里使用的是RedisTemple的操作方式。当查询数据时,先去Redis缓存中查询,如果Redis中没有,再从数据库查询,查询到以后在把数据存储到Redis中去。

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
复制代码/**
* UmsAdminService实现类
* Created by macro on 2018/4/26.
*/
@Service
public class UmsAdminServiceImpl implements UmsAdminService {
//专门用来操作Redis缓存的业务类
@Autowired
private UmsAdminCacheService adminCacheService;
@Override
public UmsAdmin getAdminByUsername(String username) {
//先从缓存中获取数据
UmsAdmin admin = adminCacheService.getAdmin(username);
if(admin!=null) return admin;
//缓存中没有从数据库中获取
UmsAdminExample example = new UmsAdminExample();
example.createCriteria().andUsernameEqualTo(username);
List<UmsAdmin> adminList = adminMapper.selectByExample(example);
if (adminList != null && adminList.size() > 0) {
admin = adminList.get(0);
//将数据库中的数据存入缓存中
adminCacheService.setAdmin(admin);
return admin;
}
return null;
}
@Override
public List<UmsResource> getResourceList(Long adminId) {
//先从缓存中获取数据
List<UmsResource> resourceList = adminCacheService.getResourceList(adminId);
if(CollUtil.isNotEmpty(resourceList)){
return resourceList;
}
//缓存中没有从数据库中获取
resourceList = adminRoleRelationDao.getResourceList(adminId);
if(CollUtil.isNotEmpty(resourceList)){
//将数据库中的数据存入缓存中
adminCacheService.setResourceList(adminId,resourceList);
}
return resourceList;
}
}

上面这种查询操作其实用Spring Cache来操作更简单,直接使用@Cacheable即可实现,为什么还要使用RedisTemplate来直接操作呢?因为作为缓存,我们所希望的是,如果Redis宕机了,我们的业务逻辑不会有影响,而使用Spring Cache来实现的话,当Redis宕机以后,用户的登录等种种操作就会都无法进行了。

由于我们把用户信息和用户资源信息都缓存到了Redis中,所以当我们修改用户信息和资源信息时都需要删除缓存中的数据,具体什么时候删除,查看缓存业务类的注释即可。

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
复制代码/**
* 后台用户缓存操作类
* Created by macro on 2020/3/13.
*/
public interface UmsAdminCacheService {
/**
* 删除后台用户缓存
*/
void delAdmin(Long adminId);

/**
* 删除后台用户资源列表缓存
*/
void delResourceList(Long adminId);

/**
* 当角色相关资源信息改变时删除相关后台用户缓存
*/
void delResourceListByRole(Long roleId);

/**
* 当角色相关资源信息改变时删除相关后台用户缓存
*/
void delResourceListByRoleIds(List<Long> roleIds);

/**
* 当资源信息改变时,删除资源项目后台用户缓存
*/
void delResourceListByResource(Long resourceId);
}

经过上面的一系列优化之后,性能问题解决了。但是引入新的技术之后,新的问题也会产生,比如说当Redis宕机以后,我们直接就无法登录了,下面我们使用AOP来解决这个问题。

使用AOP处理缓存操作异常

为什么要用AOP来解决这个问题呢?因为我们的缓存业务类UmsAdminCacheService已经写好了,要保证缓存业务类中的方法执行不影响正常的业务逻辑,就需要在所有方法中添加try catch逻辑。使用AOP,我们可以在一个地方写上try catch逻辑,然后应用到所有方法上去。试想下,我们如果又多了几个缓存业务类,只要配置下切面即可,这波操作多方便!

首先我们先定义一个切面,在相关缓存业务类上面应用,在它的环绕通知中直接处理掉异常,保障后续操作能执行。

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
复制代码/**
* Redis缓存切面,防止Redis宕机影响正常业务逻辑
* Created by macro on 2020/3/17.
*/
@Aspect
@Component
@Order(2)
public class RedisCacheAspect {
private static Logger LOGGER = LoggerFactory.getLogger(RedisCacheAspect.class);

@Pointcut("execution(public * com.macro.mall.portal.service.*CacheService.*(..)) || execution(public * com.macro.mall.service.*CacheService.*(..))")
public void cacheAspect() {
}

@Around("cacheAspect()")
public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {
Object result = null;
try {
result = joinPoint.proceed();
} catch (Throwable throwable) {
LOGGER.error(throwable.getMessage());
}
return result;
}

}

这样处理之后,就算我们的Redis宕机了,我们的业务逻辑也能正常执行。

不过并不是所有的方法都需要处理异常的,比如我们的验证码存储,如果我们的Redis宕机了,我们的验证码存储接口需要的是报错,而不是返回执行成功。

对于上面这种需求我们可以通过自定义注解来完成,首先我们自定义一个CacheException注解,如果方法上面有这个注解,发生异常则直接抛出。

1
2
3
4
5
6
7
8
复制代码/**
* 自定义注解,有该注解的缓存方法会抛出异常
*/
@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CacheException {
}

之后需要改造下我们的切面类,对于有@CacheException注解的方法,如果发生异常直接抛出。

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
复制代码/**
* Redis缓存切面,防止Redis宕机影响正常业务逻辑
* Created by macro on 2020/3/17.
*/
@Aspect
@Component
@Order(2)
public class RedisCacheAspect {
private static Logger LOGGER = LoggerFactory.getLogger(RedisCacheAspect.class);

@Pointcut("execution(public * com.macro.mall.portal.service.*CacheService.*(..)) || execution(public * com.macro.mall.service.*CacheService.*(..))")
public void cacheAspect() {
}

@Around("cacheAspect()")
public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {
Signature signature = joinPoint.getSignature();
MethodSignature methodSignature = (MethodSignature) signature;
Method method = methodSignature.getMethod();
Object result = null;
try {
result = joinPoint.proceed();
} catch (Throwable throwable) {
//有CacheException注解的方法需要抛出异常
if (method.isAnnotationPresent(CacheException.class)) {
throw throwable;
} else {
LOGGER.error(throwable.getMessage());
}
}
return result;
}

}

接下来我们需要把@CacheException注解应用到存储和获取验证码的方法上去,这里需要注意的是要应用在实现类上而不是接口上,因为isAnnotationPresent方法只能获取到当前方法上的注解,而不能获取到它实现接口方法上的注解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
复制代码/**
* UmsMemberCacheService实现类
* Created by macro on 2020/3/14.
*/
@Service
public class UmsMemberCacheServiceImpl implements UmsMemberCacheService {
@Autowired
private RedisService redisService;

@CacheException
@Override
public void setAuthCode(String telephone, String authCode) {
String key = REDIS_DATABASE + ":" + REDIS_KEY_AUTH_CODE + ":" + telephone;
redisService.set(key,authCode,REDIS_EXPIRE_AUTH_CODE);
}

@CacheException
@Override
public String getAuthCode(String telephone) {
String key = REDIS_DATABASE + ":" + REDIS_KEY_AUTH_CODE + ":" + telephone;
return (String) redisService.get(key);
}
}

总结

对于影响性能的,频繁查询数据库的操作,我们可以通过Redis作为缓存来优化。缓存操作不该影响正常业务逻辑,我们可以使用AOP来统一处理缓存操作中的异常。

项目源码地址

github.com/macrozheng/…

公众号

mall项目全套学习教程连载中,关注公众号第一时间获取。

公众号图片

本文转载自: 掘金

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

【译】kotlin 协程官方文档(4)-协程上下文和调度器

发表于 2020-03-23

公众号:字节数组

希望对你有所帮助 🤣🤣

最近一直在了解关于kotlin协程的知识,那最好的学习资料自然是官方提供的学习文档了,看了看后我就萌生了翻译官方文档的想法。前后花了要接近一个月时间,一共九篇文章,在这里也分享出来,希望对读者有所帮助。个人知识所限,有些翻译得不是太顺畅,也希望读者能提出意见

协程官方文档:coroutines-guide

协程官方文档中文翻译:coroutines-cn-guide

协程总是在由 Kotlin 标准库中定义的 CoroutineContext 表示的某个上下文中执行

协程上下文包含多种子元素。主要的元素是协程作业(Job,我们之前见过),以及它的调度器(Dispatche,本节将介绍)

一、调度器和线程

协程上下文(coroutine context)包含一个协程调度器(参阅 CoroutineDispatcher),协程调度器 用于确定执行协程的目标载体,即运行于哪个线程,包含一个还是多个线程。协程调度器可以将协程的执行操作限制在特定线程上,也可以将其分派到线程池中,或者让它无限制地运行

所有协程构造器(如 launch 和 async)都接受一个可选参数,即 CoroutineContext ,该参数可用于显式指定要创建的协程和其它上下文元素所要使用的调度器

请尝试以下示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
kotlin复制代码import kotlinx.coroutines.*

fun main() = runBlocking<Unit> {
//sampleStart
launch { // context of the parent, main runBlocking coroutine
println("main runBlocking : I'm working in thread ${Thread.currentThread().name}")
}
launch(Dispatchers.Unconfined) { // not confined -- will work with main thread
println("Unconfined : I'm working in thread ${Thread.currentThread().name}")
}
launch(Dispatchers.Default) { // will get dispatched to DefaultDispatcher
println("Default : I'm working in thread ${Thread.currentThread().name}")
}
launch(newSingleThreadContext("MyOwnThread")) { // will get its own new thread
println("newSingleThreadContext: I'm working in thread ${Thread.currentThread().name}")
}
//sampleEnd
}

运行结果如下所示,日志的输出顺序可能不同

1
2
3
4
kotlin复制代码Unconfined            : I'm working in thread main
Default : I'm working in thread DefaultDispatcher-worker-1
newSingleThreadContext: I'm working in thread MyOwnThread
main runBlocking : I'm working in thread main

当 launch {...} 在不带参数的情况下使用时,它从外部的协程作用域继承上下文和调度器。在本例中,它继承于在主线程中中运行的 runBlocking 协程的上下文

Dispatchers.Unconfined 是一个特殊的调度器,看起来似乎也在主线程中运行,但实际上它是一种不同的机制,稍后将进行解释

在 GlobalScope 中启动协程时默认使用的调度器是 Dispatchers.default,并使用共享的后台线程池,因此 launch(Dispatchers.default){...} 与 GlobalScope.launch{...} 是使用相同的调度器

newSingleThreadContext 用于为协程专门创建一个新的线程来运行。专用线程是非常昂贵的资源。在实际的应用程序中,它必须在不再需要时使用 close 函数释放掉,或者存储在顶级变量中以此实现在整个应用程序中重用

二、Unconfined vs confined dispatcher

Dispatchers.Unconfined 调度器在调用者线程中启动一个协程,但它仅仅只是运行到第一个挂起点。在挂起之后,它将恢复线程中的协程,该协程完全由调用的挂起函数决定。Unconfined 调度器适用于既不消耗CPU时间和不更新任何受限于特定线程的共享数据(如UI)的协程

另一方面,调度器是默认继承于外部的协程作用域的。尤其是 runBlocking 启动的协程的调度器只能是调用者所在的线程,因此继承 runBlocking 的结果是在此线程上的调度执行操作是可预测的 FIFO

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
kotlin复制代码import kotlinx.coroutines.*

fun main() = runBlocking<Unit> {
//sampleStart
launch(Dispatchers.Unconfined) { // not confined -- will work with main thread
println("Unconfined : I'm working in thread ${Thread.currentThread().name}")
delay(500)
println("Unconfined : After delay in thread ${Thread.currentThread().name}")
}
launch { // context of the parent, main runBlocking coroutine
println("main runBlocking: I'm working in thread ${Thread.currentThread().name}")
delay(1000)
println("main runBlocking: After delay in thread ${Thread.currentThread().name}")
}
//sampleEnd
}

运行结果:

1
2
3
4
kotlin复制代码Unconfined      : I'm working in thread main
main runBlocking: I'm working in thread main
Unconfined : After delay in thread kotlinx.coroutines.DefaultExecutor
main runBlocking: After delay in thread main

因此,从 runBlocking{...} 继承了上下文的协程继续在主线程中执行,而调度器是 unconfined 的协程,在 delay 函数之后的代码则默认运行于 delay 函数所使用的运行线程

unconfined 调度器是一种高级机制,可以在某些极端情况下提供帮助而不需要调度协程以便稍后执行或产生不希望的副作用, 因为某些操作必须立即在协程中执行。 非受限调度器不应该在一般的代码中使用

三、调试协程和线程

协程可以在一个线程上挂起,在另一个线程上继续运行。即使使用单线程的调度器,也可能很难明确知道协程当前在做什么、在哪里、处于什么状态。调试线程的常用方法是在在日志文件中为每条日志语句加上线程名,日志框架普遍支持此功能。当使用协程时,线程名本身没有提供太多的上下文信息,因此 kotlinx.coroutines 包含了调试工具以便使协程调试起来更加容易

开启 JVM 的 -Dkotlinx.coroutines.debug 配置后运行以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
kotlin复制代码import kotlinx.coroutines.*

fun log(msg: String) = println("[${Thread.currentThread().name}] $msg")

fun main() = runBlocking<Unit> {
//sampleStart
val a = async {
log("I'm computing a piece of the answer")
6
}
val b = async {
log("I'm computing another piece of the answer")
7
}
log("The answer is ${a.await() * b.await()}")
//sampleEnd
}

共有三个协程。runBlocking 中的主协程(#1)和两个计算延迟值a(#2)和b(#3)的协程。它们都在 runBlocking 的上下文中执行,并且仅限于主线程。此代码的输出为:

1
2
3
kotlin复制代码[main @coroutine#2] I'm computing a piece of the answer
[main @coroutine#3] I'm computing another piece of the answer
[main @coroutine#1] The answer is 42

log 函数在方括号中打印线程名,可以看到协程都运行于主线程,线程名后附有有当前正在执行的协程的标识符。当调试模式打开时,此标识符将连续分配给所有创建的协程

当使用 -ea 选项运行 JVM 时,调试模式也将打开,可以在 DEBUG_PROPERTY_NAME 属性文档中阅读有关调试工具的更多信息

四、在线程间切换

开启 JVM 的 -Dkotlinx.coroutines.debug 配置后运行以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
kotlin复制代码import kotlinx.coroutines.*

fun log(msg: String) = println("[${Thread.currentThread().name}] $msg")

fun main() {
//sampleStart
newSingleThreadContext("Ctx1").use { ctx1 ->
newSingleThreadContext("Ctx2").use { ctx2 ->
runBlocking(ctx1) {
log("Started in ctx1")
withContext(ctx2) {
log("Working in ctx2")
}
log("Back to ctx1")
}
}
}
//sampleEnd
}

这里演示了几种新技巧。一个是对显示指定的上下文使用 runBlocking,另一个是使用 withContext 函数更改协程的上下文并同时仍然保持在另一个协程中,如你在下面的输出中所看到的:

1
2
3
kotlin复制代码[Ctx1 @coroutine#1] Started in ctx1
[Ctx2 @coroutine#1] Working in ctx2
[Ctx1 @coroutine#1] Back to ctx1

注意,本例还使用了 kotlin 标准库中的 use 函数用来在不再需要时释放 newSingleThreadContext 所创建的线程

五、上下文中的 Job

协程中的 Job 是其上下文中的一部分,可以通过 coroutineContext[Job] 表达式从上下文中获取到

1
2
3
4
5
6
7
kotlin复制代码import kotlinx.coroutines.*

fun main() = runBlocking<Unit> {
//sampleStart
println("My job is ${coroutineContext[Job]}")
//sampleEnd
}

在 debug 模式下,输出结果类似于:

1
kotlin复制代码My job is "coroutine#1":BlockingCoroutine{Active}@6d311334

注意,CoroutineScope 的 isActive 属性只是 coroutineContext[Job]?.isActive == true 的一种简便写法

1
2
kotlin复制代码public val CoroutineScope.isActive: Boolean
get() = coroutineContext[Job]?.isActive ?: true

六、子协程

当一个协程在另外一个协程的协程作用域中启动时,它将通过 CoroutineScope.coroutineContext 继承其上下文,新启动的协程的 Job 将成为父协程的 Job 的子 Job。当父协程被取消时,它的所有子协程也会递归地被取消

但是,当使用 GlobalScope 启动协程时,协程的 Job 没有父级。因此,它不受其启动的作用域和独立运作范围的限制

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
kotlin复制代码import kotlinx.coroutines.*

fun main() = runBlocking<Unit> {
//sampleStart
// launch a coroutine to process some kind of incoming request
val request = launch {
// it spawns two other jobs, one with GlobalScope
GlobalScope.launch {
println("job1: I run in GlobalScope and execute independently!")
delay(1000)
println("job1: I am not affected by cancellation of the request")
}
// and the other inherits the parent context
launch {
delay(100)
println("job2: I am a child of the request coroutine")
delay(1000)
println("job2: I will not execute this line if my parent request is cancelled")
}
}
delay(500)
request.cancel() // cancel processing of the request
delay(1000) // delay a second to see what happens
println("main: Who has survived request cancellation?")
//sampleEnd
}

运行结果是:

1
2
3
4
kotlin复制代码job1: I run in GlobalScope and execute independently!
job2: I am a child of the request coroutine
job1: I am not affected by cancellation of the request
main: Who has survived request cancellation?

七、父协程的职责

父协程总是会等待其所有子协程完成。父协程不必显式跟踪它启动的所有子协程,也不必使用 Job.join 在末尾等待子协程完成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
kotlin复制代码import kotlinx.coroutines.*

fun main() = runBlocking<Unit> {
//sampleStart
// launch a coroutine to process some kind of incoming request
val request = launch {
repeat(3) { i -> // launch a few children jobs
launch {
delay((i + 1) * 200L) // variable delay 200ms, 400ms, 600ms
println("Coroutine $i is done")
}
}
println("request: I'm done and I don't explicitly join my children that are still active")
}
request.join() // wait for completion of the request, including all its children
println("Now processing of the request is complete")
//sampleEnd
}

运行结果:

1
2
3
4
5
kotlin复制代码request: I'm done and I don't explicitly join my children that are still active
Coroutine 0 is done
Coroutine 1 is done
Coroutine 2 is done
Now processing of the request is complete

八、为协程命名以便调试

当协程经常需要进行日志调试时,协程自动分配到的 ID 是很有用处的,你只需要关联来自同一协程的日志记录。但是,当一个协程绑定到一个特定请求的处理或者执行某个特定的后台任务时,最好显式地为它命名,以便进行调试。CoroutineName 上下文元素的作用与线程名相同,它包含在启用调试模式时执行此协程的线程名中

以下代码展示了此概念:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
kotlin复制代码import kotlinx.coroutines.*

fun log(msg: String) = println("[${Thread.currentThread().name}] $msg")

fun main() = runBlocking(CoroutineName("main")) {
//sampleStart
log("Started main coroutine")
// run two background value computations
val v1 = async(CoroutineName("v1coroutine")) {
delay(500)
log("Computing v1")
252
}
val v2 = async(CoroutineName("v2coroutine")) {
delay(1000)
log("Computing v2")
6
}
log("The answer for v1 / v2 = ${v1.await() / v2.await()}")
//sampleEnd
}

开启 -Dkotlinx.coroutines.debug JVM 配置后的输出结果类似于:

1
2
3
4
kotlin复制代码[main @main#1] Started main coroutine
[main @v1coroutine#2] Computing v1
[main @v2coroutine#3] Computing v2
[main @main#1] The answer for v1 / v2 = 42

九、组合上下文元素

有时我们需要为协程上下文定义多个元素。我们可以用 + 运算符。例如,我们可以同时使用显式指定的调度器和显式指定的名称来启动协程

1
2
3
4
5
6
7
8
9
kotlin复制代码import kotlinx.coroutines.*

fun main() = runBlocking<Unit> {
//sampleStart
launch(Dispatchers.Default + CoroutineName("test")) {
println("I'm working in thread ${Thread.currentThread().name}")
}
//sampleEnd
}

开启 -Dkotlinx.coroutines.debug JVM 配置后的输出结果是:

1
kotlin复制代码I'm working in thread DefaultDispatcher-worker-1 @test#2

十、协程作用域

让我们把关于作用域、子元素和 Job 的知识点放在一起。假设我们的应用程序有一个具有生命周期的对象,但该对象不是协程。例如,我们正在编写一个Android应用程序,并在Android Activity中启动各种协程,以执行异步操作来获取和更新数据、指定动画等。当 Activity 销毁时,必须取消所有协程以避免内存泄漏。当然,我们可以手动操作上下文和 Job 来绑定 Activity 和协程的生命周期。但是,kotlinx.coroutines 提供了一个抽象封装:CoroutineScope。你应该已经对协程作用域很熟悉了,因为所有的协程构造器都被声明为它的扩展函数

我们通过创建与 Activity 生命周期相关联的协程作用域的实例来管理协程的生命周期。CoroutineScope 的实例可以通过 CoroutineScope() 或 MainScope() 的工厂函数来构建。前者创建通用作用域,后者创建 UI 应用程序的作用域并使用 Dispatchers.Main 作为默认的调度器

1
2
3
4
5
6
7
8
kotlin复制代码class Activity {
private val mainScope = MainScope()

fun destroy() {
mainScope.cancel()
}
// to be continued ...
}

或者,我们可以在这个 Activity 类中实现 CoroutineScope 接口。最好的实现方式是对默认工厂函数使用委托。我们还可以将所需的调度器(在本例中使用Dispatchers.Default)与作用域结合起来:

1
2
kotlin复制代码    class Activity : CoroutineScope by CoroutineScope(Dispatchers.Default) {
// to be continued ...

现在,我们可以在这个 Activity 内启动协程,而不必显示地指定它们的上下文。为了演示,我们启动了十个分别延时不同时间的协程:

1
2
3
4
5
6
7
8
9
10
11
kotlin复制代码    // class Activity continues
fun doSomething() {
// launch ten coroutines for a demo, each working for a different time
repeat(10) { i ->
launch {
delay((i + 1) * 200L) // variable delay 200ms, 400ms, ... etc
println("Coroutine $i is done")
}
}
}
} // class Activity ends

在主函数中,我们创建 Activity 对象,调用测试 doSomething 函数,并在500毫秒后销毁该活动。这将取消从 doSomething 中启动的所有协程。我们可以看到这一点,因为在销毁 activity 对象后,即使我们再等待一会儿,也不会再打印消息

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
kotlin复制代码import kotlin.coroutines.*
import kotlinx.coroutines.*

class Activity : CoroutineScope by CoroutineScope(Dispatchers.Default) {

fun destroy() {
cancel() // Extension on CoroutineScope
}
// to be continued ...

// class Activity continues
fun doSomething() {
// launch ten coroutines for a demo, each working for a different time
repeat(10) { i ->
launch {
delay((i + 1) * 200L) // variable delay 200ms, 400ms, ... etc
println("Coroutine $i is done")
}
}
}
} // class Activity ends

fun main() = runBlocking<Unit> {
//sampleStart
val activity = Activity()
activity.doSomething() // run test function
println("Launched coroutines")
delay(500L) // delay for half a second
println("Destroying activity!")
activity.destroy() // cancels all coroutines
delay(1000) // visually confirm that they don't work
//sampleEnd
}

输出结果:

1
2
3
4
kotlin复制代码Launched coroutines
Coroutine 0 is done
Coroutine 1 is done
Destroying activity!

如你所见,只有前两个协程会打印一条消息,其它的则会被 Activity.destroy() 中的 job.cancel() 所取消

十一、线程局部数据

有时,将一些线程局部数据传递到协程或在协程之间传递是有实际用途的。但是,由于协程没有绑定到任何特定的线程,如果手动完成,这可能会导致模板代码

对于 ThreadLocal,扩展函数 asContextElement 可用于解决这个问题。它创建一个附加的上下文元素,该元素保持 ThreadLocal 给定的值,并在每次协程切换其上下文时恢复该值

很容易在实践中证明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
kotlin复制代码import kotlinx.coroutines.*

val threadLocal = ThreadLocal<String?>() // declare thread-local variable

fun main() = runBlocking<Unit> {
//sampleStart
threadLocal.set("main")
println("Pre-main, current thread: ${Thread.currentThread()}, thread local value: '${threadLocal.get()}'")
val job = launch(Dispatchers.Default + threadLocal.asContextElement(value = "launch")) {
println("Launch start, current thread: ${Thread.currentThread()}, thread local value: '${threadLocal.get()}'")
yield()
println("After yield, current thread: ${Thread.currentThread()}, thread local value: '${threadLocal.get()}'")
}
job.join()
println("Post-main, current thread: ${Thread.currentThread()}, thread local value: '${threadLocal.get()}'")
//sampleEnd
}

在本例中,我们使用 Dispatchers.Default 在后台线程池中启动一个新的协程,因为它可以在线程池中不同的线程之间来回切换工作,但它仍然具有我们使用 threadLocal.asContextElement(value="launch")指定的线程局部变量的值,无论协程在哪个线程上执行。因此,输出结果是(开启了调试模式):

1
2
3
4
kotlin复制代码Pre-main, current thread: Thread[main @coroutine#1,5,main], thread local value: 'main'
Launch start, current thread: Thread[DefaultDispatcher-worker-1 @coroutine#2,5,main], thread local value: 'launch'
After yield, current thread: Thread[DefaultDispatcher-worker-2 @coroutine#2,5,main], thread local value: 'launch'
Post-main, current thread: Thread[main @coroutine#1,5,main], thread local value: 'main'

我们很容易就忘记设置相应的上下文元素。如果运行协程的线程会有多个,则从协程访问的线程局部变量可能会有意外值。为了避免这种情况,建议使用 ensurePresent 方法,并在使用不当时可以快速失败

ThreadLocal 具备一等支持,可以与任何基础 kotlinx.coroutines 一起使用。不过,它有一个关键限制:当线程局部变量发生变化时,新值不会传导到协程调用方(因为上下文元素无法跟踪所有的线程本地对象引用)。并且更新的值在下次挂起时丢失。使用 withContext 更新协程中线程的局部值,有关详细信息,请参阅 asContextElement

或者,值可以存储在一个可变的类计数器中(var i: Int),而类计数器又存储在一个线程局部变量中,但是,在这种情况下,您完全有责任同步此计数器中变量的潜在并发修改

有关高级用法,比如与 logging MDC, transactional contexts或其它在内部使用线程局部变量传递数据的库集成,请参阅实现了 ThreadContextElement 接口的文档

本文转载自: 掘金

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

【译】kotlin 协程官方文档(3)-组合挂起函数 一、默

发表于 2020-03-23

公众号:字节数组

希望对你有所帮助 🤣🤣

最近一直在了解关于kotlin协程的知识,那最好的学习资料自然是官方提供的学习文档了,看了看后我就萌生了翻译官方文档的想法。前后花了要接近一个月时间,一共九篇文章,在这里也分享出来,希望对读者有所帮助。个人知识所限,有些翻译得不是太顺畅,也希望读者能提出意见

协程官方文档:coroutines-guide

协程官方文档中文翻译:coroutines-cn-guide

本节来介绍构成挂起函数的各种方法

一、默认顺序

假设我们有两个定义于其它位置的挂起函数,它们用于执行一些有用操作,比如某种远程服务调用或者是计算操作。我们假设这两个函数是有实际意义的,但实际上它们只是为了模拟情况而延迟了一秒钟

1
2
3
4
5
6
7
8
9
kotlin复制代码suspend fun doSomethingUsefulOne(): Int {
delay(1000L) // pretend we are doing something useful here
return 13
}

suspend fun doSomethingUsefulTwo(): Int {
delay(1000L) // pretend we are doing something useful here, too
return 29
}

在实践中,如果我们需要依靠第一个函数的运行结果来决定是否需要调用或者如何调用第二个函数,此时我们就需要按顺序来运行这两个函数

我们使用默认顺序来调用这两个函数,因为协程中的代码和常规代码一样,在默认情况下是顺序的执行的。下面来计算两个挂起函数运行所需的总时间

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
kotlin复制代码import kotlinx.coroutines.*
import kotlin.system.*

fun main() = runBlocking<Unit> {
//sampleStart
val time = measureTimeMillis {
val one = doSomethingUsefulOne()
val two = doSomethingUsefulTwo()
println("The answer is ${one + two}")
}
println("Completed in $time ms")
//sampleEnd
}

suspend fun doSomethingUsefulOne(): Int {
delay(1000L) // pretend we are doing something useful here
return 13
}

suspend fun doSomethingUsefulTwo(): Int {
delay(1000L) // pretend we are doing something useful here, too
return 29
}

将得到类似于下边这样的输出,可以看出函数是按顺序先后执行的

1
2
kotlin复制代码The answer is 42
Completed in 2007 ms

二、使用 async 并发

如果 doSomethingUsefulOne() 和 doSomethingUsefulTwo() 这两个函数之间没有依赖关系,并且我们希望通过同时执行这两个操作(并发)以便更快地得到答案,此时就需要用到 async 了

从概念上讲,async 就类似于 launch。async 启动一个单独的协程,这是一个与所有其它协程同时工作的轻量级协程。不同之处在于,launch 返回 Job 对象并且不携带任何运行结果值。而 async 返回一个轻量级非阻塞的 Deferred 对象,可用于在之后取出返回值,可以通过调用 Deferred 的 await() 方法来获取最终结果。此外,Deferred 也实现了 Job 接口,所以也可以根据需要来取消它

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
kotlin复制代码import kotlinx.coroutines.*
import kotlin.system.*

fun main() = runBlocking<Unit> {
//sampleStart
val time = measureTimeMillis {
val one = async { doSomethingUsefulOne() }
val two = async { doSomethingUsefulTwo() }
println("The answer is ${one.await() + two.await()}")
}
println("Completed in $time ms")
//sampleEnd
}

suspend fun doSomethingUsefulOne(): Int {
delay(1000L) // pretend we are doing something useful here
return 13
}

suspend fun doSomethingUsefulTwo(): Int {
delay(1000L) // pretend we are doing something useful here, too
return 29
}

运行结果类似于以下所示

1
2
kotlin复制代码The answer is 42
Completed in 1014 ms

可以看到运行耗时几乎是减半了,因为这两个协程是同时运行,总的耗时时间可以说是取决于耗时最长的任务。需要注意,协程的并发总是显式的

三、惰性启动 async

可选的,可以将 async 的 start 参数设置为 CoroutineStart.lazy 使其变为懒加载模式。在这种模式下,只有在主动调用 Deferred 的 await() 或者 start() 方法时才会启动协程。运行以下示例:

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
kotlin复制代码import kotlinx.coroutines.*
import kotlin.system.*

fun main() = runBlocking<Unit> {
//sampleStart
val time = measureTimeMillis {
val one = async(start = CoroutineStart.LAZY) { doSomethingUsefulOne() }
val two = async(start = CoroutineStart.LAZY) { doSomethingUsefulTwo() }
// some computation
one.start() // start the first one
two.start() // start the second one
println("The answer is ${one.await() + two.await()}")
}
println("Completed in $time ms")
//sampleEnd
}

suspend fun doSomethingUsefulOne(): Int {
delay(1000L) // pretend we are doing something useful here
return 13
}

suspend fun doSomethingUsefulTwo(): Int {
delay(1000L) // pretend we are doing something useful here, too
return 29
}

将得到以下类似输出:

1
2
kotlin复制代码The answer is 42
Completed in 1016 ms

以上定义了两个协程,但没有像前面的例子那样直接执行,而是将控制权交给了开发者,由开发者通过调用 start() 函数来确切地开始执行。首先启动了协程 one,然后启动了协程 two,然后再等待协程运行结束

注意,如果只是在 println 中调用了 await() 而不首先调用 start() ,这将形成顺序行为,因为 await() 会启动协程并等待其完成,这不是 lazy 模式的预期结果。async(start=CoroutineStart.LAZY) 的用例是标准标准库中的 lazy 函数的替代品,用于在值的计算涉及挂起函数的情况下

四、异步风格的函数

我们可以定义异步风格的函数,使用带有显式 GlobalScope 引用的异步协程生成器来调用 doSomethingUsefulOne 和 doSomethingUsefulTwo 函数。用 “…Async” 后缀来命名这些函数,以此来强调它们用来启动异步计算,并且需要通过其返回的延迟值来获取结果

1
2
3
4
5
6
7
8
9
kotlin复制代码// The result type of somethingUsefulOneAsync is Deferred<Int>
fun somethingUsefulOneAsync() = GlobalScope.async {
doSomethingUsefulOne()
}

// The result type of somethingUsefulTwoAsync is Deferred<Int>
fun somethingUsefulTwoAsync() = GlobalScope.async {
doSomethingUsefulTwo()
}

注意,这些 xxxAsync 函数不是挂起函数,它们可以从任何地方调用。但是,调用这些函数意味着是要用异步形式来执行操作

以下示例展示了它们在协程之外的使用:

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
kotlin复制代码import kotlinx.coroutines.*
import kotlin.system.*

//sampleStart
// note that we don't have `runBlocking` to the right of `main` in this example
fun main() {
val time = measureTimeMillis {
// we can initiate async actions outside of a coroutine
val one = somethingUsefulOneAsync()
val two = somethingUsefulTwoAsync()
// but waiting for a result must involve either suspending or blocking.
// here we use `runBlocking { ... }` to block the main thread while waiting for the result
runBlocking {
println("The answer is ${one.await() + two.await()}")
}
}
println("Completed in $time ms")
}
//sampleEnd

fun somethingUsefulOneAsync() = GlobalScope.async {
doSomethingUsefulOne()
}

fun somethingUsefulTwoAsync() = GlobalScope.async {
doSomethingUsefulTwo()
}

suspend fun doSomethingUsefulOne(): Int {
delay(1000L) // pretend we are doing something useful here
return 13
}

suspend fun doSomethingUsefulTwo(): Int {
delay(1000L) // pretend we are doing something useful here, too
return 29
}

这里展示的带有异步函数的编程样式仅供说明,因为它是其它编程语言中的流行样式。强烈建议不要将此样式与 kotlin 协程一起使用,因为如下所述

想象一下,如果在 val one = somethingUsefulOneAsync() 和 one.await() 这两行代码之间存在逻辑错误,导致程序抛出异常,正在执行的操作也被中止,此时会发生什么情况?通常,全局的错误处理者可以捕获此异常,为开发人员记录并报告错误,但是程序可以继续执行其它操作。但是这里 somethingUsefulOneAsync() 函数仍然还在后台运行(因为其协程作用域是 GlobalScope),即使其启动者已经被中止了。这个问题不会在结构化并发中出现,如下一节所示

五、使用 async 的结构化并发

让我们以 Concurrent using async 章节为例,提取一个同时执行 doSomethingUsefulOne() 和 doSomethingUsefulTwo() 并返回其结果之和的函数。因为 async 函数被定义为 CoroutineScope 上的一个扩展函数,所以我们需要将它放在 CoroutineScope 中,这就是 coroutineScope 函数提供的功能:

1
2
3
4
5
kotlin复制代码suspend fun concurrentSum(): Int = coroutineScope {
val one = async { doSomethingUsefulOne() }
val two = async { doSomethingUsefulTwo() }
one.await() + two.await()
}

这样,如果 concurrentSum() 函数发生错误并引发异常,则在其作用域中启动的所有协程都将被取消

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
kotlin复制代码import kotlinx.coroutines.*
import kotlin.system.*

fun main() = runBlocking<Unit> {
//sampleStart
val time = measureTimeMillis {
println("The answer is ${concurrentSum()}")
}
println("Completed in $time ms")
//sampleEnd
}

suspend fun concurrentSum(): Int = coroutineScope {
val one = async { doSomethingUsefulOne() }
val two = async { doSomethingUsefulTwo() }
one.await() + two.await()
}

suspend fun doSomethingUsefulOne(): Int {
delay(1000L) // pretend we are doing something useful here
return 13
}

suspend fun doSomethingUsefulTwo(): Int {
delay(1000L) // pretend we are doing something useful here, too
return 29
}

从 main 函数的输出结果来看,两个操作仍然是同时执行的

1
2
kotlin复制代码The answer is 42
Completed in 1017 ms

取消操作始终通过协程的层次结构来进行传播

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
kotlin复制代码fun main() = runBlocking<Unit> {
try {
failedConcurrentSum()
} catch(e: ArithmeticException) {
println("Computation failed with ArithmeticException")
}
}

suspend fun failedConcurrentSum(): Int = coroutineScope {
val one = async<Int> {
try {
delay(Long.MAX_VALUE) // Emulates very long computation
42
} finally {
println("First child was cancelled")
}
}
val two = async<Int> {
println("Second child throws an exception")
throw ArithmeticException()
}
one.await() + two.await()
}

需要注意协程 one 和正在等待的父级是如何在协程 two 失败时取消的

1
2
3
kotlin复制代码Second child throws an exception
First child was cancelled
Computation failed with ArithmeticException

本文转载自: 掘金

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

【万字长文,建议收藏】关于Synchronized锁升级,你

发表于 2020-03-23

前言

毫无疑问,synchronized是我们用过的第一个并发关键字,很多博文都在讲解这个技术。不过大多数讲解还停留在对synchronized的使用层面,其底层的很多原理和优化,很多人可能并不知晓。因此本文将通过对synchronized的大量C源码分析,让大家对他的了解更加透彻点。

本篇将从为什么要引入synchronized,常见的使用方式,存在的问题以及优化部分这四个方面描述,话不多说,开始表演。

可见性问题及解决

概念描述

指一个线程对共享变量进行修改,另一个能立刻获取到修改后的最新值。

代码展示

类:

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
复制代码public class Example1 {
//1.创建共享变量
private static boolean flag = true;

public static void main(String[] args) throws Exception {
//2.t1空循环,如果flag为true,不退出
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
while (true) {
if(!flag){
System.out.println("进入if");
break;
}
}
}
});
t1.start();

Thread.sleep(2000L);
//2.t2修改flag为false
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
flag = false;
System.out.println("修改了");
}
});

t2.start();
}
}

运行结果:

分析

这边先要了解下Java的内存模式,不明白的可点击传送门,todo。

下图线程t1,t2从主内存分别获取flag=true,t1空循环,直到flag为false的时候退出循环。t2拿到flag的值,将其改为false,并写入到主内存。此时主内存和线程t2的工作内存中flag均为false,但是线程t1工作内存中的flag还是true,所以一直退不了循环,程序将一直执行。

synchronized如何解决可见性

首先我们尝试在t1线程中加一行打印语句,看看效果。

代码:

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
复制代码public class Example1 {
//1.创建共享变量
private static boolean flag = true;

public static void main(String[] args) throws Exception {
//2.t1空循环,如果flag为true,不退出
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
while (true) {
//新增的打印语句
System.out.println(flag);
if(!flag){
System.out.println("进入if");
break;
}
}
}
});
t1.start();

Thread.sleep(2000L);
//2.t2修改flag为false
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
flag = false;
System.out.println("修改了");
}
});

t2.start();
}
}

运行结果:

我们发现if里面的语句已经打印出来了,线程1已经感知到线程2对flag的修改,即这条打印语句已经影响了可见性。这是为啥?

答案就是println中,我们看下源码:

println有个上锁的过程,即操作如下:

1.获取同步锁。

2.清空自己工作内存上的变量。

3.从主内存获取最新值,并加载到工作内存中。

4.打印并输出。

所以这里解释了为什么线程t1加了打印语句之后,t1立刻能感知t2对flag的修改。因为每次打印的时候其都从主内存上获取了最新值,当t2修改的时候,t1立刻从主内存获取了值,所以进入了if语句,并最终能跳出循环。

synchronized的原理就是清空自己工作内存上的值,通过将主内存最新值刷新到工作内存中,让各个线程能互相感知修改。

原子性问题及解决

概念描述

在一次或多个操作中,要不所有操作都执行,要不所有操作都不执行。

代码展示

类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
复制代码public class Example2 {
//1.定义全局变量number
private static int number = 0;

public static void main(String[] args) throws Exception {
Runnable runnable = () -> {
for (int i = 0; i < 10000; i++) {
number++;
}
};
//2.t1让其自增10000
Thread t1 = new Thread(runnable);
t1.start();

//3.t2让其自增10000
Thread t2 = new Thread(runnable);
t2.start();

//4.等待t1,t2运行结束
t1.join();
t2.join();
System.out.println("number=" + number);
}
}

运行结果:

分析

每个线程执行的逻辑是循环1万次,每次加1,那我们希望的结果是2万,但是实际上结果是不足2万的。我们先用javap命令反汇编,我们看到很多代码,但是number++涉及的指令有四句,具体看第二张图。

如果有多条线程执行这段number++代码,当前number为0,线程1先执行到iconst_1指令,即将执行iadd操作,而线程2执行到getstatic指令,这个时候number值还没有改变,所以线程2获取到的静态字段是0,线程1执行完iadd操作,number变为1,线程2执行完iadd操作,number还是1。这个时候就发现问题了,做了两次number++操作,但是number只增加了1。

并发编程时,会出现原子性问题,当一个线程对共享变量操作到一半的时候,另外一个线程也有可能来操作共享变量,这个时候就出现了问题。

synchronized如何解决原子性问题

在上面的分析中,我们已经知道发生问题的原因,number++是由四条指令组成,没有保证原子操作。所以,我们只要将number++作为一个整体就行,即保证他的原子性。具体代码如下:

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
复制代码public class Example2 {
//1.定义全局变量number
private static int number = 0;
//新增一个静态变量object
private static Object object = new Object();

public static void main(String[] args) throws Exception {
Runnable runnable = () -> {
for (int i = 0; i < 10000; i++) {
//将number++的操作用object对象锁住
synchronized (object) {
number++;
}
}
};
//2.t1让其自增10000
Thread t1 = new Thread(runnable);
t1.start();

//3.t2让其自增10000
Thread t2 = new Thread(runnable);
t2.start();

//4.等待t1,t2运行结束
t1.join();
t2.join();
System.out.println("number=" + number);
}
}

我们看到最终number为20000,那为什么要加上synchronized,结果就正确了?我们再反编译下Example2,可以看到在四行指令前后分别有monitorenter和monitorexist,线程1在执行中间指令时,其他线程不可以进入monitorenter,需要等线程1执行完monitorexist,其他进程才能继续monitorenter,进行自增操作。

有序性问题及解决

概念描述

代码中程序执行的顺序,Java在编译和运行时会对代码进行优化,这样会导致我们最终的执行顺序并不是我们编写代码的书写顺序。

代码展示

咱先来看一个概念,重排序,也就是语句的执行顺序会被重新安排。其主要分为三种:

1.编译器优化的重排序:可以重新安排语句的执行顺序。

2.指令级并行的重排序:现代处理器采用指令级并行技术,将多条指令重叠执行。

3.内存系统的重排序:由于处理器使用缓存和读写缓冲区,所以看上去可能是乱序的。

上面代码中的a = new A();可能被被JVM分解成如下代码:

1
2
3
4
复制代码// 可以分解为以下三个步骤
1 memory=allocate();// 分配内存 相当于c的malloc
2 ctorInstanc(memory) //初始化对象
3 s=memory //设置s指向刚分配的地址复制代码
1
2
3
4
复制代码 // 上述三个步骤可能会被重排序为 1-3-2,也就是:
1 memory=allocate();// 分配内存 相当于c的malloc
3 s=memory //设置s指向刚分配的地址
2 ctorInstanc(memory) //初始化对象 复制代码

一旦假设发生了这样的重排序,比如线程A在执行了步骤1和步骤3,但是步骤2还没有执行完。这个时候线程B进入了第一个语句,它会判断a不为空,即直接返回了a。其实这是一个未初始化完成的a,即会出现问题。

synchronized如何解决有序性问题

给上面的三个步骤加上一个synchronized关键字,即使发生重排序也不会出现问题。线程A在执行步骤1和步骤3时,线程B因为没法获取到锁,所以也不能进入第一个语句。只有线程A都执行完,释放锁,线程B才能重新获取锁,再执行相关操作。

synchronized的常见使用方式

修饰代码块(同步代码块)

1
2
3
复制代码synchronized (object) {
//具体代码
}

修饰方法

1
2
3
复制代码synchronized void test(){
//具体代码
}

synchronized不能继承?(插曲)

父类A:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码public class A {
synchronized void test() throws Exception {
try {
System.out.println("main 下一步 sleep begin threadName="
+ Thread.currentThread().getName() + " time="
+ System.currentTimeMillis());
Thread.sleep(5000);
System.out.println("main 下一步 sleep end threadName="
+ Thread.currentThread().getName() + " time="
+ System.currentTimeMillis());
} catch (Exception e) {
e.printStackTrace();
}
}
}

子类B:(未重写test方法)

1
2
3
复制代码public class B extends A {

}

子类C:(重写test方法)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
复制代码public class C extends A {

@Override
void test() throws Exception{
try {
System.out.println("sub 下一步 sleep begin threadName="
+ Thread.currentThread().getName() + " time="
+ System.currentTimeMillis());
Thread.sleep(5000);
System.out.println("sub 下一步 sleep end threadName="
+ Thread.currentThread().getName() + " time="
+ System.currentTimeMillis());
} catch (Exception e) {
e.printStackTrace();
}
}
}

线程A:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码public class ThreadA extends Thread {
private A a;

public void setter (A a) {
this.a = a;
}

@Override
public void run() {
try{
a.test();
}catch (Exception e){

}
}
}

线程B:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码public class ThreadB extends Thread {
private B b;
public void setB(B b){
this.b=b;
}

@Override
public void run() {
try{
b.test();
}catch (Exception e){

}
}
}

线程C:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码public class ThreadC extends Thread{
private C c;
public void setC(C c){
this.c=c;
}

@Override
public void run() {
try{
c.test();
}catch (Exception e){

}
}
}

测试类test:

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
复制代码public class test {
public static void main(String[] args) throws Exception {
A a = new A();
ThreadA A1 = new ThreadA();
A1.setter(a);
A1.setName("A1");
A1.start();
ThreadA A2 = new ThreadA();
A2.setter(a);
A2.setName("A2");
A2.start();
A1.join();
A2.join();

System.out.println("=============");
B b = new B();
ThreadB B1 = new ThreadB();
B1.setB(b);
B1.setName("B1");
B1.start();
ThreadB B2 = new ThreadB();
B2.setB(b);
B2.setName("B2");
B2.start();
B1.join();
B2.join();
System.out.println("=============");

C c = new C();
ThreadC C1 = new ThreadC();
C1.setName("C1");
C1.setC(c);
C1.start();
ThreadC C2 = new ThreadC();
C2.setName("C2");
C2.setC(c);
C2.start();
C1.join();
C2.join();
}
}

运行结果:

子类B继承了父类A,但是没有重写test方法,ThreadB仍然是同步的。子类C继承了父类A,也重写了test方法,但是未明确写上synchronized,所以这个方法并不是同步方法。只有显式的写上synchronized关键字,才是同步方法。

所以synchronized不能继承这句话有歧义,我们只要记住子类如果想要重写父类的同步方法,synchronized关键字一定要显示写出,否则无效。

修饰静态方法

1
2
3
复制代码synchronized static void test(){
//具体代码
}

修饰类

1
2
3
复制代码 synchronized (Example2.class) {
//具体代码
}

Java对象 Mark Word

在JVM中,对象在内存中的布局分为三块区域:对象头,实例数据和对齐数据,如下图:

其中Mark Word值在不同锁状态下的展示如下:(重点看线程id,是否为偏向锁,锁标志位信息)

在64位系统中,Mark Word占了8个字节,类型指针占了8个字节,一共是16个字节。Talk is cheap. Show me the code. 咱来看代码。

  • 我们想要看Java对象的Mark Word,先要加载一个jar包,在pom.xml添加即可。
1
2
3
4
5
复制代码<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>
  • 新建一个对象A,拥有初始值为666的变量x。
1
2
3
复制代码public class A {
private int x=666;
}
  • 新建一个测试类test,这涉及到刚才加载的jar,我们打印Java对象。
1
2
3
4
5
6
7
8
复制代码import org.openjdk.jol.info.ClassLayout;

public class test {
public static void main(String[] args) {
A a=new A();
System.out.println( ClassLayout.parseInstance(a).toPrintable());
}
}
  • 我们发现对象头(object header)占了12个字节,为啥和上面说的16个字节不一样。

  • 其实上是默认开启了指针压缩,我们需要关闭指针压缩,也就是添加-XX:-UseCompressedOops配置。

  • 再次执行,发现对象头为16个字节。

偏向锁

什么是偏向锁

JDK1.6之前锁为重量级锁(待会说,只要知道他和内核交互,消耗资源),1.6之后Java设计人员发现很多情况下并不存在多个线程竞争的关系,所以为了资源问题引入了无锁,偏向锁,轻量级锁,重量级锁的概念。先说偏向锁,他是偏心,偏袒的意思,这个锁会偏向于第一个获取他的线程。

偏向锁演示

  • 创建并启动一个线程,run方法里面用了synchronized关键字,功能是打印this的Java对象。
1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码public class test {
public static void main(String[] args) {
Thread thread=new Thread(new Runnable() {
@Override
public void run() {
synchronized (this){
System.out.println(ClassLayout.parseInstance(this).toPrintable());
}
}
});
thread.start();
}
}

标红的地方为000,根据之前Mark Word在不同状态下的标志,得此为无锁状态。理论上一个线程使用synchronized关键字,应为偏向锁。

  • 实际上偏向锁在JDK1.6之后是默认开启的,但是启动时间有延迟,所以需要添加参数-XX:BiasedLockingStartupDelay=0,让其在程序启动时立刻启动。

  • 重新运行下代码,发现标红地方101,对比Mark Word在不同状态下的标志,得此状态为偏向锁。

偏向锁原理图解

  • 在线程的run方法中,刚执行到synchronized,会判断当前对象是否为偏向锁和锁标志,没有任何线程执行该对象,我们可以看到是否为偏向锁为0,锁标志位01,即无锁状态。

  • 线程会将自己的id赋值给markword,即将原来的hashcode值改为线程id,是否是偏向锁改为1,表示线程拥有对象锁,可以执行下面的业务逻辑。如果synchronized执行完,对象还是偏向锁状态;如果线程结束之后,会撤销偏向锁,将该对象还原成无锁状态。

  • 如果同一个线程中又对该对象进行加锁操作,我们只要对比对象的线程id是否与线程id相同,如果相同即为线程锁重入问题。

优势

加锁和解锁不需要额外的消耗,和执行非同步方法相比只有纳秒级的差距。

白话翻译

线程1锁定对象this,他发现对象为无锁状态,所以将线程id赋值给对象的Mark Word字段,表示对象为线程1专用,即使他退出了同步代码,其他线程也不能使用该对象。

同学A去自习教室C,他发现教室无人,所以在门口写了个名字,表示当前教室有人在使用,这样即使他出去吃了饭,其他同学也不能使用这个房间。

轻量锁

什么是轻量级锁

在多线程交替同步代码块的情况下,线程间没有竞争,使用轻量级锁可以避免重量级锁引入的性能消耗。

轻量级图解

  • 在刚才偏向锁的基础上,如果有另外一个线程也想错峰使用该资源,通过对比线程id是否相同,Java内存会立刻撤销偏向锁(需要等待全局安全点),进行锁升级的操作。

  • 撤销完轻量级锁,会在线程1的方法栈中新增一个锁记录,对象的Mark Word与锁记录交换。

优势

线程不竞争的时候,避免直接使用重量级锁,提高了程序的响应速度。

白话翻译

在刚才偏向锁的基础上,另外一个线程也想要获取资源,所以线程1需要撤销偏向锁,升级为轻量锁。

同学A在使用自习教室外面写了自己的名字,所以同学B来也想要使用自习教室,他需要提醒同学A,不能使用偏向锁,同学A将自习教室门口的名字擦掉,换成了一个书包,里面是自己的书籍。这样在同学A不使用自习教室的时候,同学B也能使用自习教室,只需要将自己的书包也挂在外面即可。这样下次来使用的同学就能知道已经有人占用了该教室。

重量级锁

什么是重量级锁

当多线程之间发生竞争,Java内存会申请一个Monitor对象来实现。

重量级锁原理图解

在刚才的轻量级锁的基础上,线程2也想要申请资源,发现锁的标志位为00,即为轻量级锁,所以向内存申请一个Monitor,让对象的MarkWord指向Monitor地址,并将ower指针指向线程1的地址,线程2放在等待队列里面,等线程1指向完毕,释放锁资源。

Monitor源码分析

环境搭建

我们去官网http://openjdk.java.net/找下open源码,也可以通过其他途径下载。源码是C实现的,可以通过DEV C++工具打开,效果如下图:

构造函数

我们先看下\hotspot\src\share\vm\runtime\ObjectMonitor.hpp,以.hpp结尾的文件是导入的一些包和一些声明,之后可以被.cpp文件导入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
复制代码 ObjectMonitor() {
_header = NULL;
_count = 0;
_waiters = 0,
_recursions = 0;//线程重入次数
_object = NULL;//存储该monitor的对象
_owner = NULL;//标识拥有该monitor的线程
_WaitSet = NULL;//处于wait状态的线程,会加入到_waitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;//多线程竞争锁时的单项列表
FreeNext = NULL ;
_EntryList = NULL ;//处于等待锁lock状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
_previous_owner_tid = 0;
}

锁竞争的过程

我们先看下\hotspot\src\share\vm\interpreter\interpreterRuntime.cpp,IRT_ENTRY_NO_ASYNC即为锁竞争过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
复制代码//%note monitor_1
IRT_ENTRY_NO_ASYNC(void, InterpreterRuntime::monitorenter(JavaThread* thread, BasicObjectLock* elem))
#ifdef ASSERT
thread->last_frame().interpreter_frame_verify_monitor(elem);
#endif
if (PrintBiasedLockingStatistics) {
Atomic::inc(BiasedLocking::slow_path_entry_count_addr());
}
Handle h_obj(thread, elem->obj());
assert(Universe::heap()->is_in_reserved_or_null(h_obj()),
"must be NULL or an object");
//是否使用偏向锁,可加参数进行设置 if (UseBiasedLocking) { //如果可以使用偏向锁,即进入fast_enter
// Retry fast entry if bias is revoked to avoid unnecessary inflation
ObjectSynchronizer::fast_enter(h_obj, elem->lock(), true, CHECK);
} else {//如果不可以使用偏向锁,即进行slow_enter
ObjectSynchronizer::slow_enter(h_obj, elem->lock(), CHECK);
}
assert(Universe::heap()->is_in_reserved_or_null(elem->obj()),
"must be NULL or an object");
#ifdef ASSERT
thread->last_frame().interpreter_frame_verify_monitor(elem);
#endif
IRT_END

slow_enter实际上调用的ObjectMonitor.cpp的enter 方法

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
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
复制代码void ATTR ObjectMonitor::enter(TRAPS) {
// The following code is ordered to check the most common cases first
// and to reduce RTS->RTO cache line upgrades on SPARC and IA32 processors.
Thread * const Self = THREAD ;
void * cur ;

//通过CAS操作尝试将monitor的_owner设置为当前线程
cur = Atomic::cmpxchg_ptr (Self, &_owner, NULL) ;
//如果设置不成功,直接返回
if (cur == NULL) {
// Either ASSERT _recursions == 0 or explicitly set _recursions = 0.
assert (_recursions == 0 , "invariant") ;
assert (_owner == Self, "invariant") ;
// CONSIDER: set or assert OwnerIsThread == 1
return ;
}
//如果_owner等于当前线程,重入数_recursions加1,直接返回
if (cur == Self) {
// TODO-FIXME: check for integer overflow! BUGID 6557169.
_recursions ++ ;
return ;
}

//如果当前线程第一次进入该monitor,设置重入数_recursions为1,_owner为当前线程,返回
if (Self->is_lock_owned ((address)cur)) {
assert (_recursions == 0, "internal state error");
_recursions = 1 ;
// Commute owner from a thread-specific on-stack BasicLockObject address to
// a full-fledged "Thread *".
_owner = Self ;
OwnerIsThread = 1 ;
return ;
}

//如果未抢到锁,则进行自旋优化,如果还未获取锁,则放入到list里面
// We've encountered genuine contention.
assert (Self->_Stalled == 0, "invariant") ;
Self->_Stalled = intptr_t(this) ;

// Try one round of spinning *before* enqueueing Self
// and before going through the awkward and expensive state
// transitions. The following spin is strictly optional ...
// Note that if we acquire the monitor from an initial spin
// we forgo posting JVMTI events and firing DTRACE probes.
if (Knob_SpinEarly && TrySpin (Self) > 0) {
assert (_owner == Self , "invariant") ;
assert (_recursions == 0 , "invariant") ;
assert (((oop)(object()))->mark() == markOopDesc::encode(this), "invariant") ;
Self->_Stalled = 0 ;
return ;
}

assert (_owner != Self , "invariant") ;
assert (_succ != Self , "invariant") ;
assert (Self->is_Java_thread() , "invariant") ;
JavaThread * jt = (JavaThread *) Self ;
assert (!SafepointSynchronize::is_at_safepoint(), "invariant") ;
assert (jt->thread_state() != _thread_blocked , "invariant") ;
assert (this->object() != NULL , "invariant") ;
assert (_count >= 0, "invariant") ;

// Prevent deflation at STW-time. See deflate_idle_monitors() and is_busy().
// Ensure the object-monitor relationship remains stable while there's contention.
Atomic::inc_ptr(&_count);

EventJavaMonitorEnter event;

{ // Change java thread status to indicate blocked on monitor enter.
JavaThreadBlockedOnMonitorEnterState jtbmes(jt, this);

DTRACE_MONITOR_PROBE(contended__enter, this, object(), jt);
if (JvmtiExport::should_post_monitor_contended_enter()) {
JvmtiExport::post_monitor_contended_enter(jt, this);
}

OSThreadContendState osts(Self->osthread());
ThreadBlockInVM tbivm(jt);

Self->set_current_pending_monitor(this);

// TODO-FIXME: change the following for(;;) loop to straight-line code.
for (;;) {
jt->set_suspend_equivalent();
// cleared by handle_special_suspend_equivalent_condition()
// or java_suspend_self()

EnterI (THREAD) ;

if (!ExitSuspendEquivalent(jt)) break ;

//
// We have acquired the contended monitor, but while we were
// waiting another thread suspended us. We don't want to enter
// the monitor while suspended because that would surprise the
// thread that suspended us.
//
_recursions = 0 ;
_succ = NULL ;
exit (false, Self) ;

jt->java_suspend_self();
}
Self->set_current_pending_monitor(NULL);
}

Atomic::dec_ptr(&_count);
assert (_count >= 0, "invariant") ;
Self->_Stalled = 0 ;

// Must either set _recursions = 0 or ASSERT _recursions == 0.
assert (_recursions == 0 , "invariant") ;
assert (_owner == Self , "invariant") ;
assert (_succ != Self , "invariant") ;
assert (((oop)(object()))->mark() == markOopDesc::encode(this), "invariant") ;

// The thread -- now the owner -- is back in vm mode.
// Report the glorious news via TI,DTrace and jvmstat.
// The probe effect is non-trivial. All the reportage occurs
// while we hold the monitor, increasing the length of the critical
// section. Amdahl's parallel speedup law comes vividly into play.
//
// Another option might be to aggregate the events (thread local or
// per-monitor aggregation) and defer reporting until a more opportune
// time -- such as next time some thread encounters contention but has
// yet to acquire the lock. While spinning that thread could
// spinning we could increment JVMStat counters, etc.

DTRACE_MONITOR_PROBE(contended__entered, this, object(), jt);
if (JvmtiExport::should_post_monitor_contended_entered()) {
JvmtiExport::post_monitor_contended_entered(jt, this);
}

if (event.should_commit()) {
event.set_klass(((oop)this->object())->klass());
event.set_previousOwner((TYPE_JAVALANGTHREAD)_previous_owner_tid);
event.set_address((TYPE_ADDRESS)(uintptr_t)(this->object_addr()));
event.commit();
}

if (ObjectMonitor::_sync_ContendedLockAttempts != NULL) {
ObjectMonitor::_sync_ContendedLockAttempts->inc() ;
}
}

白话翻译

同学A在使用自习教室的时候,同学B在同一时刻也想使用自习教室,那就发生了竞争关系。所以同学B在A运行过程中,加入等待队列。如果此时同学C也要使用该教室,也会加入等待队列。等同学A使用结束,同学B和C将竞争自习教室。

自旋优化

自旋优化比较简单,如果将其他线程加入等待队列,那之后唤醒并运行线程需要消耗资源,所以设计人员让其空转一会,看看线程能不能一会结束了,这样就不要在加入等待队列。

白话来说,如果同学A在使用自习教室,同学B可以回宿舍,等A使用结束再来,但是B回宿舍再来的过程需要1个小时,而A只要10分钟就结束了。所以B可以先不回宿舍,而是在门口等个10分钟,以防止来回时间的浪费。

结语

唉呀妈呀,终于结束了,累死了。终于将synchronized写完了,如果有不正确的地方,还需要各位指正。如果觉得写得还行,麻烦帮我点赞,评论哈。

求个关注

参考资料

Java中System.out.println()为何会影响内存可见性

别再问什么是Java内存模型了,看这里!

JVM—汇编指令集

Java中Synchronized的使用

synchronized同步方法(08)同步不具有继承性

Thread–synchronized不能被继承?!?!!!

本文转载自: 掘金

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

都2020年了 还要学JSP吗?

发表于 2020-03-23

前言

2020年了,还需要学JSP吗?我相信现在还是在大学的同学肯定会有这个疑问。

其实我在18年的时候已经见过类似的问题了「JSP还应该学习吗」。我在18年发了几篇JSP的文章,已经有不少的开发者评论『这不是上个世纪的东西了吗』『梦回几年前』『这么老的的东西,怎么还有人学』

现在问题来了,JSP放在2020年,是真的老了吗?对,是真的老了

现在问题又来了,为什么在几年前已经被定义『老』的技术,到2020年了还是有热度,每年还是有人在问:『还需要学习JSP吗』。我认为理由也很简单:JSP在之前用的是真的多!

在我初学Java的时候,就经常听到:JSP和PHP是能够写动态网页的—《我的老师》。

当我们去找相关的学习资料时,发现到处都是JSP的身影,会给我一种感觉:好像不懂JSP就压根没法继续往下学习一样。

如果你是新手,如果你还没学习JSP,我建议还是可以了解一下,不需要深入去学习JSP的各种内容,但可以了解一下。至少别人说起JSP的时候,你能知道什么是JSP,能看懂JSP的代码。

额外说一句:你去到公司,可能还能看到JSP的代码。虽然JSP是『老东西』,但我们去到公司可能就是维护老的项目。JSP可能不用你自己去写,但至少能看得懂,对不对。

问题又来了,那JSP如果是『老东西』,那被什么替代了呢?要么就是用常见的模板引擎『freemarker』『Thymeleaf』『Velocity』,用法其实跟『JSP』差不太多,只是它们的性能会更好。要么前后端分离,后端只需要返回JSON给前端,页面完全不需要后端管。

说了这么多,我想说的是:“JSP还是有必要了解一下,不需要花很多时间,知道即可,这篇文章我就能带你认识JSP”

什么是JSP?

JSP全名为Java Server Pages,java服务器页面。JSP是一种基于文本的程序,其特点就是HTML和Java代码共同存在!JSP是为了简化Servlet的工作出现的替代品,Servlet输出HTML非常困难,JSP就是替代Servlet输出HTML的。

在Tomcat博客中我提到过:Tomcat访问任何的资源都是在访问Servlet!,当然了,JSP也不例外!JSP本身就是一种Servlet。为什么我说JSP本身就是一种Servlet呢?其实JSP在第一次被访问的时候会被编译为HttpJspPage类(该类是HttpServlet的一个子类)

比如我随便找一个JSP,编译后的JSP长这个样:

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
79
80
复制代码
package org.apache.jsp;

import javax.servlet.*;
import javax.servlet.http.*;
import javax.servlet.jsp.*;
import java.util.Date;

public final class _1_jsp extends org.apache.jasper.runtime.HttpJspBase
implements org.apache.jasper.runtime.JspSourceDependent {

private static final JspFactory _jspxFactory = JspFactory.getDefaultFactory();

private static java.util.List<String> _jspx_dependants;

private javax.el.ExpressionFactory _el_expressionfactory;
private org.apache.tomcat.InstanceManager _jsp_instancemanager;

public java.util.List<String> getDependants() {
return _jspx_dependants;
}

public void _jspInit() {
_el_expressionfactory = _jspxFactory.getJspApplicationContext(getServletConfig().getServletContext()).getExpressionFactory();
_jsp_instancemanager = org.apache.jasper.runtime.InstanceManagerFactory.getInstanceManager(getServletConfig());
}

public void _jspDestroy() {
}

public void _jspService(final HttpServletRequest request, final HttpServletResponse response)
throws java.io.IOException, ServletException {

final PageContext pageContext;
HttpSession session = null;
final ServletContext application;
final ServletConfig config;
JspWriter out = null;
final Object page = this;
JspWriter _jspx_out = null;
PageContext _jspx_page_context = null;


try {
response.setContentType("text/html;charset=UTF-8");
pageContext = _jspxFactory.getPageContext(this, request, response,
null, true, 8192, true);
_jspx_page_context = pageContext;
application = pageContext.getServletContext();
config = pageContext.getServletConfig();
session = pageContext.getSession();
out = pageContext.getOut();
_jspx_out = out;

out.write("\r\n");
out.write("\r\n");
out.write("<html>\r\n");
out.write("<head>\r\n");
out.write(" <title>简单使用JSP</title>\r\n");
out.write("</head>\r\n");
out.write("<body>\r\n");

String s = "HelloWorda";
out.println(s);

out.write("\r\n");
out.write("</body>\r\n");
out.write("</html>\r\n");
} catch (Throwable t) {
if (!(t instanceof SkipPageException)){
out = _jspx_out;
if (out != null && out.getBufferSize() != 0)
try { out.clearBuffer(); } catch (java.io.IOException e) {}
if (_jspx_page_context != null) _jspx_page_context.handlePageException(t);
}
} finally {
_jspxFactory.releasePageContext(_jspx_page_context);
}
}
}

编译过程是这样子的:浏览器第一次请求1.jsp时,Tomcat会将1.jsp转化成1_jsp.java这么一个类,并将该文件编译成class文件。编译完毕后再运行class文件来响应浏览器的请求。

以后访问1.jsp就不再重新编译jsp文件了,直接调用class文件来响应浏览器。当然了,如果Tomcat检测到JSP页面改动了的话,会重新编译的。

既然JSP是一个Servlet,那JSP页面中的HTML排版标签是怎么样被发送到浏览器的?我们来看下上面1_jsp.java的源码就知道了。原来就是用write()出去的罢了。说到底,JSP就是封装了Servlet的java程序罢了。

1
2
3
4
5
6
7
复制代码out.write("\r\n");
out.write("\r\n");
out.write("<html>\r\n");
out.write("<head>\r\n");
out.write(" <title>简单使用JSP</title>\r\n");
out.write("</head>\r\n");
out.write("<body>\r\n");

有人可能也会问:JSP页面的代码服务器是怎么执行的?再看回1_jsp.java文件,java代码就直接在类中的service()中。

1
2
复制代码String s = "HelloWorda";
out.println(s);

**JSP内置了9个对象!**内置对象有:out、session、response、request、config、page、application、pageContext、exception。

重要要记住的是:JSP的本质其实就是Servlet。只是JSP当初设计的目的是为了简化Servlet输出HTML代码。

什么时候用JSP

重复一句:JSP的本质其实就是Servlet。只是JSP当初设计的目的是为了简化Servlet输出HTML代码。

我们的Java代码还是写在Servlet上的,不会写在JSP上。在知乎曾经看到一个问题:“如何使用JSP连接JDBC”。显然,我们可以这样做,但是没必要。

JSP看起来就像是一个HTML,再往里边增加大量的Java代码,这是不正常,不容易阅读的。

所以,我们一般的模式是:在Servlet处理好的数据,转发到JSP,JSP只管对小部分的数据处理以及JSP本身写好的页面。

例如,下面的Servlet处理好表单的数据,放在request对象,转发到JSP

1
2
3
4
5
6
7
8
复制代码//验证表单的数据是否合法,如果不合法就跳转回去注册的页面
if(formBean.validate()==false){

//在跳转之前,把formbean对象传递给注册页面
request.setAttribute("formbean", formBean);
request.getRequestDispatcher("/WEB-INF/register.jsp").forward(request, response);
return;
}

JSP拿到Servlet处理好的数据,做显示使用:

JSP需要学什么

JSP我们要学的其实两块就够了:JSTL和EL表达式

EL表达式

**表达式语言(Expression Language,EL),EL表达式是用${}括起来的脚本,用来更方便的读取对象!**EL表达式主要用来读取数据,进行内容的显示!

为什么要使用EL表达式?我们先来看一下没有EL表达式是怎么样读取对象数据的吧!在1.jsp中设置了Session属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码<%@ page language="java" contentType="text/html" pageEncoding="UTF-8"%>
<html>
<head>
<title>向session设置一个属性</title>
</head>
<body>

<%
//向session设置一个属性
session.setAttribute("name", "aaa");
System.out.println("向session设置了一个属性");
%>

</body>
</html>

在2.jsp中获取Session设置的属性

1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<title></title>
</head>
<body>

<%
String value = (String) session.getAttribute("name");
out.write(value);
%>
</body>
</html>

效果:

上面看起来,也没有多复杂呀,那我们试试EL表达式的!

在2.jsp中读取Session设置的属性

1
2
3
4
5
6
7
8
9
10
11
复制代码<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<title></title>
</head>
<body>

${name}

</body>
</html>

只用了简简单单的几个字母就能输出Session设置的属性了!并且输出在浏览器上!

使用EL表达式可以方便地读取对象中的属性、提交的参数、JavaBean、甚至集合!

JSTL

JSTL全称为 JSP Standard Tag Library 即JSP标准标签库。JSTL作为最基本的标签库,提供了一系列的JSP标签,实现了基本的功能:集合的遍历、数据的输出、字符串的处理、数据的格式化等等!

为什么要使用JSTL?

EL表达式不够完美,需要JSTL的支持!在JSP中,我们前面已经用到了EL表达式,体会到了EL表达式的强大功能:**使用EL表达式可以很方便地引用一些JavaBean以及其属性,不会抛出NullPointerException之类的错误!**但是,EL表达式非常有限,它不能遍历集合,做逻辑的控制。这时,就需要JSTL的支持了!

**Scriptlet的可读性,维护性,重用性都十分差!**JSTL与HTML代码十分类似,遵循着XML标签语法,使用JSTL让JSP页面显得整洁,可读性非常好,重用性非常高,可以完成复杂的功能!

之前我们在使用EL表达式获取到集合的数据,遍历集合都是用scriptlet代码循环,现在我们学了forEach标签就可以舍弃scriptlet代码了。

向Session中设置属性,属性的类型是List集合

1
2
3
4
5
6
7
8
复制代码	<%
List list = new ArrayList<>();
list.add("zhongfucheng");
list.add("ouzicheng");
list.add("xiaoming");

session.setAttribute("list", list);
%>

遍历session属性中的List集合,items:即将要迭代的集合。var:当前迭代到的元素

1
2
3
复制代码	<c:forEach  var="list" items="${list}" >
${list}<br>
</c:forEach>

效果:

生成结果

放干货

现在已经工作有一段时间了,为什么还来写JSP呢,原因有以下几个:

  • 我是一个对排版有追求的人,如果早期关注我的同学可能会发现,我的GitHub、文章导航的read.me会经常更换。现在的GitHub导航也不合我心意了(太长了),并且早期的文章,说实话排版也不太行,我决定重新搞一波。
  • 我的文章会分发好几个平台,但文章发完了可能就没人看了,并且图床很可能因为平台的防盗链就挂掉了。又因为有很多的读者问我:”你能不能把你的文章转成PDF啊?“
  • 我写过很多系列级的文章,这些文章就几乎不会有太大的改动了,就非常适合把它们给”持久化“。

基于上面的原因,我决定把我的系列文章汇总成一个PDF/HTML/WORD文档。说实话,打造这么一个文档花了我不少的时间。为了防止白嫖,关注我的公众号回复「888」即可获取。

PDF的内容非常非常长,干货非常非常的硬,有兴趣的同学可以浏览一波。记住:JSP我们只需要了解即可,不需要深入去学习每个知识点,因为在现实开发中很可能用不上。

文档的内容均为手打,有任何的不懂都可以直接来问我(公众号有我的联系方式)。

上一期的「排序和数据结构」的PDF在公众号反响还是挺不错的,目标是180个在看,超出了预期,所以我提早更新了。

如果这次点赞超过180,那下周再肝一个系列出来。想要看什么,可以留言告诉我

img

涵盖Java后端所有知识点的开源项目(已有6 K star):github.com/ZhongFuChen…

如果大家想要实时关注我更新的文章以及分享的干货的话,微信搜索Java3y。

PDF文档的内容均为手打,有任何的不懂都可以直接来问我(公众号有我的联系方式)。

本文转载自: 掘金

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

使用IDEA搭建第一个Spring Cloud项目(图解)

发表于 2020-03-22

需求

构建一个Eureka服务器,两个服务。两个服务都注册到Eureka服务器中。服务一提供的服务是:接收一个参数并返回给用户。服务二:调用服务一中的服务。

微信截图_20200311231647

构建过程

1.创建普通maven项目(springcloud-01-test)

微信截图_20200312003551

2.选择项目鼠标右键新增模块(springcloud-server)

(角色:Eureka服务器)

微信截图_20200312003753

微信截图_20200312003851

填写好GroupId(项目的目录结构),ArtifactId(项目名)

微信截图_20200312003910

该模块作为Eureka服务器。需要的依赖有Spring Web模块以及Eureka server

微信截图_20200312004015

Eureka服务器代码

①启动类中添加一个@EnableEurekaServer注解

1
2
3
4
5
6
7
8
9
复制代码@SpringBootApplication
@EnableEurekaServer
public class SpringcloudServerApplication {

public static void main(String[] args) {
SpringApplication.run(SpringcloudServerApplication.class, args);
}

}

②配置application.properties配置文件,配置如下。

1
2
3
4
复制代码server.port=8761

eureka.client.register-with-eureka=false
eureka.client.fetch-registry=false

启动该模块。打开浏览器输入http://localhost:8761

微信图片_20200314220916.png

如图所示,启动成功。但是目前没有发现任何的服务。

3.选择项目鼠标右键新增模块(springcloud-provider)

(角色:服务一:接收一个参数并返回给用户)

新增模块步骤与第2步类似,但是依赖选择Spring Web和Eureka Discovery Client

微信截图_20200312004050

服务一代码

①在启动类中添加一个@EnableEurekaClient注解

1
2
3
4
5
6
7
8
9
复制代码@SpringBootApplication
@EnableEurekaClient
public class SpringcloudProviderApplication {

public static void main(String[] args) {
SpringApplication.run(SpringcloudProviderApplication.class, args);
}

}

②在与启动类统一目录下新建一个ProviderController.java控制器类,代码如下。

1
2
3
4
5
6
7
8
复制代码@RestController
public class ProviderController {

@RequestMapping(value = "/person/{name}",method = RequestMethod.GET)
public String findName(@PathVariable("name") String name){
return name;
}
}

③配置application.properties配置文件,配置如下。

1
2
3
复制代码spring.application.name=service-provider
eureka.instance.hostname=localhost
eureka.client.service-url.defaultZone=http://localhost:8761/eureka

启动该模块,刷新一下浏览器。

微信图片_20200314222547.png

服务一注册成功!!

4.选择项目鼠标右键新增模块(springcloud-invoker)

(角色:服务二:调用服务一中的方法)

新建模块步骤和依赖与第3步一样。

服务二代码

①在启动类中添加一个@EnableDiscoveryClient注解

1
2
3
4
5
6
7
8
9
复制代码@SpringBootApplication
@EnableDiscoveryClient
public class SpringcloudInvokerApplication {

public static void main(String[] args) {
SpringApplication.run(SpringcloudInvokerApplication.class, args);
}

}

②在与启动类统一目录下新建一个InvokerController.java控制器类,代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复制代码@RestController
@Configuration
public class InvokerController {

@Bean
@LoadBalanced
public RestTemplate getRestTemplate(){
return new RestTemplate();
}

@RequestMapping(value = "/router",method = RequestMethod.GET)
public String router(){
RestTemplate restTemplate = getRestTemplate();
String name = restTemplate.getForObject("http://service-provider/person/chonor",String.class);
return name;
}

}

③配置application.properties配置文件,配置如下。

1
2
3
4
复制代码server.port=9000
spring.application.name=service-invoker
eureka.instance.hostname=localhost
eureka.client.service-url.defaultZone=http://localhost:8761/eureka

启动该模块,刷新一下浏览器。

微信截图_20200314224150.png

服务一,服务二均注册成功!!

测试

浏览器输入http://localhost:9000/router

微信图片_20200314224533.png

原文

原文在我的个人博客中:使用IDEA搭建第一个Spring Cloud项目(图解)

本文转载自: 掘金

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

【译】kotlin 协程官方文档(2)-取消和超时 一、取消

发表于 2020-03-21

公众号:字节数组

希望对你有所帮助 🤣🤣

最近一直在了解关于kotlin协程的知识,那最好的学习资料自然是官方提供的学习文档了,看了看后我就萌生了翻译官方文档的想法。前后花了要接近一个月时间,一共九篇文章,在这里也分享出来,希望对读者有所帮助。个人知识所限,有些翻译得不是太顺畅,也希望读者能提出意见

协程官方文档:coroutines-guide

协程官方文档中文翻译:coroutines-cn-guide

本节讨论协程的取消和超时

一、取消协程执行

在一个长时间运行的应用程序中,我们可能需要对协程进行细粒度控制。例如,用户可能关闭了启动了协程的页面,现在不再需要其运行结果,此时就应该主动取消协程。launch 函数的返回值 Job 对象就可用于取消正在运行的协程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
kotlin复制代码import kotlinx.coroutines.*

fun main() = runBlocking {
//sampleStart
val job = launch {
repeat(1000) { i ->
println("job: I'm sleeping $i ...")
delay(500L)
}
}
delay(1300L) // delay a bit
println("main: I'm tired of waiting!")
job.cancel() // cancels the job
job.join() // waits for job's completion
println("main: Now I can quit.")
//sampleEnd
}

运行结果

1
2
3
4
5
kotlin复制代码job: I'm sleeping 0 ...
job: I'm sleeping 1 ...
job: I'm sleeping 2 ...
main: I'm tired of waiting!
main: Now I can quit.

只要 main 函数调用了 job.cancel,我们就看不到 job 协程的任何输出了,因为它已被取消。还有一个 Job 的扩展函数 cancelAndJoin ,它结合了 cancel 和 join 的调用。

cancel() 函数用于取消协程,join() 函数用于阻塞等待协程执行结束。之所以连续调用这两个方法,是因为 cancel() 函数调用后会马上返回而不是等待协程结束后再返回,所以此时协程不一定是马上就停止了,为了确保协程执行结束后再执行后续代码,此时就需要调用 join() 方法来阻塞等待。可以通过调用 Job 的扩展函数 cancelAndJoin() 来完成相同操作

1
2
3
4
kotlin复制代码public suspend fun Job.cancelAndJoin() {
cancel()
return join()
}

二、取消操作是协作完成的

协程的取消操作是协作(cooperative)完成的,协程必须协作才能取消。kotlinx.coroutines 中的所有挂起函数都是可取消的,它们在运行时会检查协程是否被取消了,并在取消时抛出 CancellationException 。但是,如果协程正在执行计算任务,并且未检查是否已处于取消状态的话,则无法取消协程,如以下示例所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
kotlin复制代码import kotlinx.coroutines.*

fun main() = runBlocking {
//sampleStart
val startTime = System.currentTimeMillis()
val job = launch(Dispatchers.Default) {
var nextPrintTime = startTime
var i = 0
while (i < 5) { // computation loop, just wastes CPU
// print a message twice a second
if (System.currentTimeMillis() >= nextPrintTime) {
println("job: I'm sleeping ${i++} ...")
nextPrintTime += 500L
}
}
}
delay(1300L) // delay a bit
println("main: I'm tired of waiting!")
job.cancelAndJoin() // cancels the job and waits for its completion
println("main: Now I can quit.")
//sampleEnd
}

运行代码可以看到即使在 cancel 之后协程 job 也会继续打印 “I’m sleeping” ,直到 Job 在迭代五次后(运行条件不再成立)自行结束

1
2
3
4
5
6
7
kotlin复制代码job: I'm sleeping 0 ...
job: I'm sleeping 1 ...
job: I'm sleeping 2 ...
main: I'm tired of waiting!
job: I'm sleeping 3 ...
job: I'm sleeping 4 ...
main: Now I can quit.

三、使计算代码可取消

有两种方法可以使计算类型的代码可以被取消。第一种方法是定期调用一个挂起函数来检查取消操作,yieid() 函数是一个很好的选择。另一个方法是显示检查取消操作。让我们来试试后一种方法

使用 while (isActive) 替换前面例子中的 while (i < 5)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
kotlin复制代码import kotlinx.coroutines.*

fun main() = runBlocking {
//sampleStart
val startTime = System.currentTimeMillis()
val job = launch(Dispatchers.Default) {
var nextPrintTime = startTime
var i = 0
while (isActive) { // cancellable computation loop
// print a message twice a second
if (System.currentTimeMillis() >= nextPrintTime) {
println("job: I'm sleeping ${i++} ...")
nextPrintTime += 500L
}
}
}
delay(1300L) // delay a bit
println("main: I'm tired of waiting!")
job.cancelAndJoin() // cancels the job and waits for its completion
println("main: Now I can quit.")
//sampleEnd
}

如你所见,现在这个循环被取消了。isActive 是一个可通过 CoroutineScope 对象在协程内部使用的扩展属性

1
2
3
4
5
kotlin复制代码job: I'm sleeping 0 ...
job: I'm sleeping 1 ...
job: I'm sleeping 2 ...
main: I'm tired of waiting!
main: Now I can quit.

四、用 finally 关闭资源

可取消的挂起函数在取消时会抛出 CancellationException,可以用常用的方式来处理这种情况。例如,try {...} finally {...} 表达式和 kotlin 的 use 函数都可用于在取消协程时执行回收操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
kotlin复制代码import kotlinx.coroutines.*

fun main() = runBlocking {
//sampleStart
val job = launch {
try {
repeat(1000) { i ->
println("job: I'm sleeping $i ...")
delay(500L)
}
} finally {
println("job: I'm running finally")
}
}
delay(1300L) // delay a bit
println("main: I'm tired of waiting!")
job.cancelAndJoin() // cancels the job and waits for its completion
println("main: Now I can quit.")
//sampleEnd
}

join() 和 cancelAndJoin() 两个函数都会等待所有回收操作完成后再继续执行之后的代码,因此上面的示例生成以下输出:

1
2
3
4
5
6
kotlin复制代码job: I'm sleeping 0 ...
job: I'm sleeping 1 ...
job: I'm sleeping 2 ...
main: I'm tired of waiting!
job: I'm running finally
main: Now I can quit.

五、运行不可取消的代码块

如果在上一个示例中的 finally 块中使用挂起函数,将会导致抛出 CancellationException,因为此时协程已经被取消了(例如,在 finally 中先调用 delay(1000L) 函数,将导致之后的输出语句不执行)。通常这并不是什么问题,因为所有性能良好的关闭操作(关闭文件、取消作业、关闭任何类型的通信通道等)通常都是非阻塞的,且不涉及任何挂起函数。但是,在极少数情况下,当需要在取消的协程中调用挂起函数时,可以使用 withContext 函数和 NonCancellable 上下文将相应的代码包装在 withContext(NonCancellable) {...} 代码块中,如下例所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
kotlin复制代码import kotlinx.coroutines.*

fun main() = runBlocking {
//sampleStart
val job = launch {
try {
repeat(1000) { i ->
println("job: I'm sleeping $i ...")
delay(500L)
}
} finally {
withContext(NonCancellable) {
println("job: I'm running finally")
delay(1000L)
println("job: And I've just delayed for 1 sec because I'm non-cancellable")
}
}
}
delay(1300L) // delay a bit
println("main: I'm tired of waiting!")
job.cancelAndJoin() // cancels the job and waits for its completion
println("main: Now I can quit.")
//sampleEnd
}

此时,即使在 finally 代码块中调用了挂起函数,其也将正常生效,且之后的输出语句也会正常输出

1
2
3
4
5
6
7
kotlin复制代码job: I'm sleeping 0 ...
job: I'm sleeping 1 ...
job: I'm sleeping 2 ...
main: I'm tired of waiting!
job: I'm running finally
job: And I've just delayed for 1 sec because I'm non-cancellable
main: Now I can quit.

六、超时

大多数情况下,我们会主动取消协程的原因是由于其执行时间已超出预估的最长时间。虽然我们可以手动跟踪对相应 Job 的引用,并在超时后取消 Job,但官方也提供了 withTimeout 函数来完成此类操作。看一下示例:

1
2
3
4
5
6
7
8
9
10
11
12
kotlin复制代码import kotlinx.coroutines.*

fun main() = runBlocking {
//sampleStart
withTimeout(1300L) {
repeat(1000) { i ->
println("I'm sleeping $i ...")
delay(500L)
}
}
//sampleEnd
}

运行结果:

1
2
3
4
kotlin复制代码I'm sleeping 0 ...
I'm sleeping 1 ...
I'm sleeping 2 ...
Exception in thread "main" kotlinx.coroutines.TimeoutCancellationException: Timed out waiting for 1300 ms

withTimeout 引发的 TimeoutCancellationException 是 CancellationException 的子类。之前我们从未在控制台上看过 CancellationException 这类异常的堆栈信息。这是因为对于一个已取消的协程来说,CancellationException 被认为是触发协程结束的正常原因。但是,在这个例子中,我们在主函数中使用了 withTimeout 函数,该函数会主动抛出 TimeoutCancellationException

你可以通过使用 try{...}catch(e:TimeoutCancellationException){...} 代码块来对任何情况下的超时操作执行某些特定的附加操作,或者通过使用 withTimeoutOrNull 函数以便在超时时返回 null 而不是抛出异常

1
2
3
4
5
6
7
8
9
10
11
12
13
14
kotlin复制代码import kotlinx.coroutines.*

fun main() = runBlocking {
//sampleStart
val result = withTimeoutOrNull(1300L) {
repeat(1000) { i ->
println("I'm sleeping $i ...")
delay(500L)
}
"Done" // will get cancelled before it produces this result
}
println("Result is $result")
//sampleEnd
}

此时将不会打印出异常信息

1
2
3
4
kotlin复制代码I'm sleeping 0 ...
I'm sleeping 1 ...
I'm sleeping 2 ...
Result is null

本文转载自: 掘金

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

【译】kotlin 协程官方文档(1)-协程基础 一、你的第

发表于 2020-03-21

公众号:字节数组

希望对你有所帮助 🤣🤣

最近一直在了解关于kotlin协程的知识,那最好的学习资料自然是官方提供的学习文档了,看了看后我就萌生了翻译官方文档的想法。前后花了要接近一个月时间,一共九篇文章,在这里也分享出来,希望对读者有所帮助。个人知识所限,有些翻译得不是太顺畅,也希望读者能提出意见

协程官方文档:coroutines-guide

协程官方文档中文翻译:coroutines-cn-guide

此章节涵盖了协程的基本概念

一、你的第一个协程程序

运行以下代码:

1
2
3
4
5
6
7
8
9
10
kotlin复制代码import kotlinx.coroutines.*

fun main() {
GlobalScope.launch { // 在后台启动一个新协程,并继续执行之后的代码
delay(1000L) // 非阻塞式地延迟一秒
println("World!") // 延迟结束后打印
}
println("Hello,") //主线程继续执行,不受协程 delay 所影响
Thread.sleep(2000L) // 主线程阻塞式睡眠2秒,以此来保证JVM存活
}

输出结果

1
2
kotlin复制代码Hello,
World!

本质上,协程可以称为轻量级线程。协程在 CoroutineScope (协程作用域)的上下文中通过 launch、async 等协程构造器(coroutine builder)来启动。在上面的例子中,在 GlobalScope ,即全局作用域内启动了一个新的协程,这意味着该协程的生命周期只受整个应用程序的生命周期的限制,即只要整个应用程序还在运行中,只要协程的任务还未结束,该协程就可以一直运行

可以将以上的协程改写为常用的 thread 形式,可以获得相同的结果

1
2
3
4
5
6
7
8
kotlin复制代码fun main() {
thread {
Thread.sleep(1000L)
println("World!")
}
println("Hello,")
Thread.sleep(2000L)
}

但是如果仅仅是将 GlobalScope.launch 替换为 thread 的话,编译器将提示错误:

1
kotlin复制代码Suspend function 'delay' should be called only from a coroutine or another suspend function

这是由于 delay() 是一个挂起函数(suspending function),挂起函数只能由协程或者其它挂起函数进行调度。挂起函数不会阻塞线程,而是会将协程挂起,在特定的时候才再继续运行

开发者需要明白,协程是运行于线程上的,一个线程可以运行多个(可以是几千上万个)协程。线程的调度行为是由 OS 来操纵的,而协程的调度行为是可以由开发者来指定并由编译器来实现的。当协程 A 调用 delay(1000L) 函数来指定延迟1秒后再运行时,协程 A 所在的线程只是会转而去执行协程 B,等到1秒后再把协程 A 加入到可调度队列里。所以说,线程并不会因为协程的延时而阻塞,这样可以极大地提高线程的并发灵活度

二、桥接阻塞与非阻塞的世界

在第一个协程程序里,混用了非阻塞代码 delay() 与阻塞代码 Thread.sleep() ,使得我们很容易就搞混当前程序是否是阻塞的。可以改用 runBlocking 来明确这种情形

1
2
3
4
5
6
7
8
9
10
11
12
kotlin复制代码import kotlinx.coroutines.*

fun main() {
GlobalScope.launch { // launch a new coroutine in background and continue
delay(1000L)
println("World!")
}
println("Hello,") // main thread continues here immediately
runBlocking { // but this expression blocks the main thread
delay(2000L) // ... while we delay for 2 seconds to keep JVM alive
}
}

运行结果和第一个程序是一样的,但是这段代码只使用了非阻塞延迟。主线程调用了 runBlocking 函数,直到 runBlocking 内部的所有协程执行完成后,之后的代码才会继续执行

可以将以上代码用更喜欢的方式来重写,使用 runBlocking 来包装 main 函数的执行体:

1
2
3
4
5
6
7
8
9
10
kotlin复制代码import kotlinx.coroutines.*

fun main() = runBlocking<Unit> { // start main coroutine
GlobalScope.launch { // launch a new coroutine in background and continue
delay(1000L)
println("World!")
}
println("Hello,") // main coroutine continues here immediately
delay(2000L) // delaying for 2 seconds to keep JVM alive
}

这里 runBlocking<Unit> { ... } 作为用于启动顶层主协程的适配器。我们显式地指定它的返回类型 Unit,因为 kotlin 中 main 函数必须返回 Unit 类型,但一般我们都可以省略类型声明,因为编译器可以自动推导(这需要代码块的最后一行代码语句没有返回值或者返回值为 Unit)

这也是为挂起函数编写单元测试的一种方法:

1
2
3
4
5
6
kotlin复制代码class MyTest {
@Test
fun testMySuspendingFunction() = runBlocking<Unit> {
// here we can use suspending functions using any assertion style that we like
}
}

需要注意的是,runBlocking 代码块默认运行于其声明所在的线程,而 launch 代码块默认运行于线程池中,可以通过打印当前线程名来进行区分

三、等待作业

延迟一段时间来等待另一个协程运行并不是一个好的选择,可以显式(非阻塞的方式)地等待协程执行完成

1
2
3
4
5
6
7
8
9
10
11
12
kotlin复制代码import kotlinx.coroutines.*

fun main() = runBlocking {
//sampleStart
val job = GlobalScope.launch { // launch a new coroutine and keep a reference to its Job
delay(1000L)
println("World!")
}
println("Hello,")
job.join() // wait until child coroutine completes
//sampleEnd
}

现在,代码的运行结果仍然是相同的,但是主协程与后台作业的持续时间没有任何关系,这样好多了

四、结构化并发

以上对于协程的使用还有一些需要改进的地方。GlobalScope.launch 会创建一个顶级协程。尽管它很轻量级,但在运行时还是会消耗一些内存资源。如果开发者忘记保留对该协程的引用,它将可以一直运行直到整个应用程序停止。我们会遇到一些比较麻烦的情形,比如协程中的代码被挂起(比如错误地延迟了太多时间),或者启动了太多协程导致内存不足。此时我们需要手动保留对所有已启动协程的引用以便在需要的时候停止协程,但这很容易出错

kotlin 提供了更好的解决方案。我们可以在代码中使用结构化并发。正如我们通常使用线程那样(线程总是全局的),我们可以在特定的范围内来启动协程

在上面的示例中,我们通过 runBlocking 将 main() 函数转为协程。每个协程构造器(包括 runBlocking)都会将 CoroutineScope 的实例添加到其代码块的作用域中。我们可以在这个作用域中启动协程,而不必显式地 join,因为外部协程(示例代码中的 runBlocking)在其作用域中启动的所有协程完成之前不会结束。因此,我们可以简化示例代码:

1
2
3
4
5
6
7
8
9
kotlin复制代码import kotlinx.coroutines.*

fun main() = runBlocking { // this: CoroutineScope
launch { // launch a new coroutine in the scope of runBlocking
delay(1000L)
println("World!")
}
println("Hello,")
}

launch 函数是 CoroutineScope 的扩展函数,而 runBlocking 的函数体参数也是被声明为 CoroutineScope 的扩展函数,所以 launch 函数就隐式持有了和 runBlocking 相同的协程作用域。此时即使 delay 再久, println("World!") 也一定会被执行

五、作用域构建器

除了使用官方的几个协程构建器所提供的协程作用域之外,还可以使用 coroutineScope 来声明自己的作用域。coroutineScope 用于创建一个协程作用域,直到所有启动的子协程都完成后才结束

runBlocking 和 coroutineScope 看起来很像,因为它们都需要等待其内部所有相同作用域的子协程结束后才会结束自己。两者的主要区别在于 runBlocking 方法会阻塞当前线程,而 coroutineScope 只是挂起并释放底层线程以供其它协程使用。由于这个差别,所以 runBlocking 是一个普通函数,而 coroutineScope 是一个挂起函数

可以通过以下示例来演示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
kotlin复制代码import kotlinx.coroutines.*

fun main() = runBlocking { // this: CoroutineScope
launch {
delay(200L)
println("Task from runBlocking")
}

coroutineScope { // Creates a coroutine scope
launch {
delay(500L)
println("Task from nested launch")
}

delay(100L)
println("Task from coroutine scope") // This line will be printed before the nested launch
}

println("Coroutine scope is over") // This line is not printed until the nested launch completes
}

运行结果:

1
2
3
4
kotlin复制代码Task from coroutine scope
Task from runBlocking
Task from nested launch
Coroutine scope is over

注意,在 “Task from coroutine scope” 消息打印之后,在等待 launch 执行完之前 ,将执行并打印“Task from runBlocking”,尽管此时 coroutineScope 尚未完成

六、提取函数并重构

抽取 launch 内部的代码块为一个独立的函数,需要将之声明为挂起函数。挂起函数可以像常规函数一样在协程中使用,但它们的额外特性是:可以依次使用其它挂起函数(如 delay 函数)来使协程挂起

1
2
3
4
5
6
7
8
9
10
11
12
kotlin复制代码import kotlinx.coroutines.*

fun main() = runBlocking {
launch { doWorld() }
println("Hello,")
}

// this is your first suspending function
suspend fun doWorld() {
delay(1000L)
println("World!")
}

但是如果提取出的函数包含一个在当前作用域中调用的协程构建器的话该怎么办? 在这种情况下,所提取函数上只有 suspend 修饰符是不够的。为 CoroutineScope 写一个扩展函数 doWorld 是其中一种解决方案,但这可能并非总是适用的,因为它并没有使 API 更加清晰。 常用的解决方案是要么显式将 CoroutineScope 作为包含该函数的类的一个字段, 要么当外部类实现了 CoroutineScope 时隐式取得。 作为最后的手段,可以使用 CoroutineScope(coroutineContext),不过这种方法结构上并不安全, 因为你不能再控制该方法执行的作用域。只有私有 API 才能使用这个构建器。

七、协程是轻量级的

运行以下代码:

1
2
3
4
5
6
7
8
9
10
kotlin复制代码import kotlinx.coroutines.*

fun main() = runBlocking {
repeat(100_000) { // launch a lot of coroutines
launch {
delay(1000L)
print(".")
}
}
}

以上代码启动了10万个协程,每个协程延时一秒后都会打印输出。如果改用线程来完成的话,很大可能会发生内存不足异常,但用协程来完成的话就可以轻松胜任

八、全局协程类似于守护线程

以下代码在 GlobalScope 中启动了一个会长时间运行的协程,它每秒打印两次 “I’m sleeping” ,然后在延迟一段时间后从 main 函数返回

1
2
3
4
5
6
7
8
9
10
11
12
13
kotlin复制代码import kotlinx.coroutines.*

fun main() = runBlocking {
//sampleStart
GlobalScope.launch {
repeat(1000) { i ->
println("I'm sleeping $i ...")
delay(500L)
}
}
delay(1300L) // just quit after delay
//sampleEnd
}

你可以运行代码并看到它打印了三行后终止运行:

1
2
3
kotlin复制代码I'm sleeping 0 ...
I'm sleeping 1 ...
I'm sleeping 2 ...

这是由于 launch 函数依附的协程作用域是 GlobalScope,而非 runBlocking 所隐含的作用域。在 GlobalScope 中启动的协程无法使进程保持活动状态,它们就像守护线程(当主线程消亡时,守护线程也将消亡)

本文转载自: 掘金

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

Nestjs 从零到壹系列(四):使用中间件、拦截器、过滤

发表于 2020-03-21

前言

上一篇介绍了如何使用 JWT 进行单点登录,接下来,要完善一下后端项目的一些基础功能。

首先,一个良好的服务端,应该有较完善的日志收集功能,这样才能在生产环境发生异常时,能够从日志中复盘,找出 Bug 所在。

其次,要针对项目中抛出的异常进行归类,并将信息反映在接口或日志中。

最后,请求接口的参数也应该被记录,以便统计分析(主要用于大数据和恶意攻击分析)。

GitHub 项目地址,欢迎各位大佬 Star。

一、日志系统

这里使用的是 log4js,前身是 log4j,如果有写过 Java 的大佬应该不会陌生。

已经有大佬总结了 log4js 的用法,就不在赘述了:

《Node.js 之 log4js 完全讲解》

1. 配置

先安装依赖包

1
复制代码$ yarn add log4js stacktrace-js -S

在 config 目录下新建一个文件 log4js.ts,用于编写配置文件:

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
复制代码// config/log4js.ts

import * as path from 'path';
const baseLogPath = path.resolve(__dirname, '../../logs'); // 日志要写入哪个目录

const log4jsConfig = {
appenders: {
console: {
type: 'console', // 会打印到控制台
},
access: {
type: 'dateFile', // 会写入文件,并按照日期分类
filename: `${baseLogPath}/access/access.log`, // 日志文件名,会命名为:access.20200320.log
alwaysIncludePattern: true,
pattern: 'yyyyMMdd',
daysToKeep: 60,
numBackups: 3,
category: 'http',
keepFileExt: true, // 是否保留文件后缀
},
app: {
type: 'dateFile',
filename: `${baseLogPath}/app-out/app.log`,
alwaysIncludePattern: true,
layout: {
type: 'pattern',
pattern: '{"date":"%d","level":"%p","category":"%c","host":"%h","pid":"%z","data":\'%m\'}',
},
// 日志文件按日期(天)切割
pattern: 'yyyyMMdd',
daysToKeep: 60,
// maxLogSize: 10485760,
numBackups: 3,
keepFileExt: true,
},
errorFile: {
type: 'dateFile',
filename: `${baseLogPath}/errors/error.log`,
alwaysIncludePattern: true,
layout: {
type: 'pattern',
pattern: '{"date":"%d","level":"%p","category":"%c","host":"%h","pid":"%z","data":\'%m\'}',
},
// 日志文件按日期(天)切割
pattern: 'yyyyMMdd',
daysToKeep: 60,
// maxLogSize: 10485760,
numBackups: 3,
keepFileExt: true,
},
errors: {
type: 'logLevelFilter',
level: 'ERROR',
appender: 'errorFile',
},
},
categories: {
default: {
appenders: ['console', 'app', 'errors'],
level: 'DEBUG',
},
info: { appenders: ['console', 'app', 'errors'], level: 'info' },
access: { appenders: ['console', 'app', 'errors'], level: 'info' },
http: { appenders: ['access'], level: 'DEBUG' },
},
pm2: true, // 使用 pm2 来管理项目时,打开
pm2InstanceVar: 'INSTANCE_ID', // 会根据 pm2 分配的 id 进行区分,以免各进程在写日志时造成冲突
};

export default log4jsConfig;

上面贴出了我的配置,并标注了一些简单的注释,请配合 《Node.js 之 log4js 完全讲解》 一起食用。

2. 实例化

有了配置,就可以着手写 log4js 的实例以及一些工具函数了。

在 src/utils 下新建 log4js.ts:

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
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
复制代码// src/utils/log4js.ts
import * as Path from 'path';
import * as Log4js from 'log4js';
import * as Util from 'util';
import * as Moment from 'moment'; // 处理时间的工具
import * as StackTrace from 'stacktrace-js';
import Chalk from 'chalk';
import config from '../../config/log4js';

// 日志级别
export enum LoggerLevel {
ALL = 'ALL',
MARK = 'MARK',
TRACE = 'TRACE',
DEBUG = 'DEBUG',
INFO = 'INFO',
WARN = 'WARN',
ERROR = 'ERROR',
FATAL = 'FATAL',
OFF = 'OFF',
}

// 内容跟踪类
export class ContextTrace {
constructor(
public readonly context: string,
public readonly path?: string,
public readonly lineNumber?: number,
public readonly columnNumber?: number,
) {}
}

Log4js.addLayout('Awesome-nest', (logConfig: any) => {
return (logEvent: Log4js.LoggingEvent): string => {
let moduleName: string = '';
let position: string = '';

// 日志组装
const messageList: string[] = [];
logEvent.data.forEach((value: any) => {
if (value instanceof ContextTrace) {
moduleName = value.context;
// 显示触发日志的坐标(行,列)
if (value.lineNumber && value.columnNumber) {
position = `${value.lineNumber}, ${value.columnNumber}`;
}
return;
}

if (typeof value !== 'string') {
value = Util.inspect(value, false, 3, true);
}

messageList.push(value);
});

// 日志组成部分
const messageOutput: string = messageList.join(' ');
const positionOutput: string = position ? ` [${position}]` : '';
const typeOutput: string = `[${logConfig.type}] ${logEvent.pid.toString()} - `;
const dateOutput: string = `${Moment(logEvent.startTime).format('YYYY-MM-DD HH:mm:ss')}`;
const moduleOutput: string = moduleName ? `[${moduleName}] ` : '[LoggerService] ';
let levelOutput: string = `[${logEvent.level}] ${messageOutput}`;

// 根据日志级别,用不同颜色区分
switch (logEvent.level.toString()) {
case LoggerLevel.DEBUG:
levelOutput = Chalk.green(levelOutput);
break;
case LoggerLevel.INFO:
levelOutput = Chalk.cyan(levelOutput);
break;
case LoggerLevel.WARN:
levelOutput = Chalk.yellow(levelOutput);
break;
case LoggerLevel.ERROR:
levelOutput = Chalk.red(levelOutput);
break;
case LoggerLevel.FATAL:
levelOutput = Chalk.hex('#DD4C35')(levelOutput);
break;
default:
levelOutput = Chalk.grey(levelOutput);
break;
}

return `${Chalk.green(typeOutput)}${dateOutput} ${Chalk.yellow(moduleOutput)}${levelOutput}${positionOutput}`;
};
});

// 注入配置
Log4js.configure(config);

// 实例化
const logger = Log4js.getLogger();
logger.level = LoggerLevel.TRACE;

export class Logger {
static trace(...args) {
logger.trace(Logger.getStackTrace(), ...args);
}

static debug(...args) {
logger.debug(Logger.getStackTrace(), ...args);
}

static log(...args) {
logger.info(Logger.getStackTrace(), ...args);
}

static info(...args) {
logger.info(Logger.getStackTrace(), ...args);
}

static warn(...args) {
logger.warn(Logger.getStackTrace(), ...args);
}

static warning(...args) {
logger.warn(Logger.getStackTrace(), ...args);
}

static error(...args) {
logger.error(Logger.getStackTrace(), ...args);
}

static fatal(...args) {
logger.fatal(Logger.getStackTrace(), ...args);
}

static access(...args) {
const loggerCustom = Log4js.getLogger('http');
loggerCustom.info(Logger.getStackTrace(), ...args);
}

// 日志追踪,可以追溯到哪个文件、第几行第几列
static getStackTrace(deep: number = 2): string {
const stackList: StackTrace.StackFrame[] = StackTrace.getSync();
const stackInfo: StackTrace.StackFrame = stackList[deep];

const lineNumber: number = stackInfo.lineNumber;
const columnNumber: number = stackInfo.columnNumber;
const fileName: string = stackInfo.fileName;
const basename: string = Path.basename(fileName);
return `${basename}(line: ${lineNumber}, column: ${columnNumber}): \n`;
}
}

上面贴出了我实例化 log4js 的过程,主要是处理日志的组成部分(包含了时间、类型,调用文件以及调用的坐标),还可以根据日志的不同级别,在控制台中用不同的颜色显示。

这个文件,不但可以单独调用,也可以做成中间件使用。

3. 制作中间件

我们希望每次用户请求接口的时候,自动记录请求的路由、IP、参数等信息,如果每个路由都写,那就太傻了,所以需要借助中间件来实现。

Nest 中间件实际上等价于 express 中间件。

中间件函数可以执行以下任务:

  • 执行任何代码;
  • 对请求和响应对象进行更改;
  • 结束请求-响应周期;
  • 调用堆栈中的下一个中间件函数;
  • 如果当前的中间件函数没有【结束请求】或【响应周期】, 它必须调用 next() 将控制传递给下一个中间件函数。否则,请求将被挂起;

执行下列命令,创建中间件文件:

1
复制代码$ nest g middleware logger middleware

然后,src 目录下,就多出了一个 middleware 的文件夹,里面的 logger.middleware.ts 就是接下来的主角,Nest 预设的中间件模板长这样:

1
2
3
4
5
6
7
8
9
复制代码// src/middleware/logger.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';

@Injectable()
export class LoggerMiddleware implements NestMiddleware {
use(req: any, res: any, next: () => void) {
next();
}
}

这里只是实现了 NestMiddleware 接口,它接收 3 个参数:

  • req:即 Request,请求信息;
  • res:即 Response ,响应信息;
  • next:将控制传递到下一个中间件,写过 Vue、Koa 的应该不会陌生;

接下来,我们将日志功能写入中间件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
复制代码// src/middleware/logger.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response } from 'express';
import { Logger } from '../utils/log4js';

@Injectable()
export class LoggerMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: () => void) {
const code = res.statusCode; // 响应状态码
next();
// 组装日志信息
const logFormat = `Method: ${req.method} \n Request original url: ${req.originalUrl} \n IP: ${req.ip} \n Status code: ${code} \n`;
// 根据状态码,进行日志类型区分
if (code >= 500) {
Logger.error(logFormat);
} else if (code >= 400) {
Logger.warn(logFormat);
} else {
Logger.access(logFormat);
Logger.log(logFormat);
}
}
}

同时,Nest 也支持【函数式中间件】,我们将上面的功能用函数式实现一下:

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
复制代码// src/middleware/logger.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response } from 'express';
import { Logger } from '../utils/log4js';

@Injectable()
export class LoggerMiddleware implements NestMiddleware {
...
}

// 函数式中间件
export function logger(req: Request, res: Response, next: () => any) {
const code = res.statusCode; // 响应状态码
next();
// 组装日志信息
const logFormat = ` >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
Request original url: ${req.originalUrl}
Method: ${req.method}
IP: ${req.ip}
Status code: ${code}
Parmas: ${JSON.stringify(req.params)}
Query: ${JSON.stringify(req.query)}
Body: ${JSON.stringify(req.body)} \n >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
`;
// 根据状态码,进行日志类型区分
if (code >= 500) {
Logger.error(logFormat);
} else if (code >= 400) {
Logger.warn(logFormat);
} else {
Logger.access(logFormat);
Logger.log(logFormat);
}
}

上面的日志格式进行了一些改动,主要是为了方便查看。

至于使用 Nest 提供的还是函数式中间件,可以视需求决定。当然,Nest 原生的中间件高级玩法会更多一些。

4. 应用中间件

做好中间件后,我们只需要将中间件引入 main.ts 中就好了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
复制代码// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

import { logger } from './middleware/logger.middleware';

async function bootstrap() {
const app = await NestFactory.create(AppModule);
// 监听所有的请求路由,并打印日志
app.use(logger);
app.setGlobalPrefix('nest-zero-to-one');
await app.listen(3000);
}
bootstrap();

保存代码后,就会发现,项目目录下就多了几个文件:

这就是之前 config/log4js.ts 中配置的成果

接下来,我们试着请求一下登录接口:

发现虽然是打印了,但是没有请求参数信息。

于是,我们还要做一部操作,将请求参数处理一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import * as express from 'express';
import { logger } from './middleware/logger.middleware';

async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.use(express.json()); // For parsing application/json
app.use(express.urlencoded({ extended: true })); // For parsing application/x-www-form-urlencoded
// 监听所有的请求路由,并打印日志
app.use(logger);
app.setGlobalPrefix('nest-zero-to-one');
await app.listen(3000);
}
bootstrap();

再请求一次,发现参数已经出来了:

上面的打印信息,IP 为 ::1 是因为我所有的东西都跑在本地,正常情况下,会打印对方的 IP 的。

再去看看 logs/ 文件夹下:

上图可以看到日志已经写入文件了。

5. 初探拦截器

前面已经示范了怎么打印入参,但是光有入参信息,没有出参信息肯定不行的,不然怎么定位 Bug 呢。

Nest 提供了一种叫做 Interceptors(拦截器) 的东东,你可以理解为关卡,除非遇到关羽这样的可以过五关斩六将,否则所有的参数都会经过这里进行处理,正所谓雁过拔毛。

详细的使用方法会在后面的教程进行讲解,这里只是先大致介绍一下怎么使用:

执行下列指令,创建 transform文件

1
复制代码$ nest g interceptor transform interceptor

然后编写出参打印逻辑,intercept 接受两个参数,当前的上下文和传递函数,这里还使用了 pipe(管道),用于传递响应数据:

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
复制代码// src/interceptor/transform.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { Logger } from '../utils/log4js';

@Injectable()
export class TransformInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const req = context.getArgByIndex(1).req;
return next.handle().pipe(
map(data => {
const logFormat = ` <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
Request original url: ${req.originalUrl}
Method: ${req.method}
IP: ${req.ip}
User: ${JSON.stringify(req.user)}
Response data:\n ${JSON.stringify(data.data)}
<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<`;
Logger.info(logFormat);
Logger.access(logFormat);
return data;
}),
);
}
}

保存文件,然后在 main.ts 中引入,使用 useGlobalInterceptors 调用全局拦截器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复制代码import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import * as express from 'express';
import { logger } from './middleware/logger.middleware';
import { TransformInterceptor } from './interceptor/transform.interceptor';

async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.use(express.json()); // For parsing application/json
app.use(express.urlencoded({ extended: true })); // For parsing application/x-www-form-urlencoded
// 监听所有的请求路由,并打印日志
app.use(logger);
// 使用全局拦截器打印出参
app.useGlobalInterceptors(new TransformInterceptor());
app.setGlobalPrefix('nest-zero-to-one');
await app.listen(3000);
}
bootstrap();

我们再试一次登录接口:

可以看到,出参的日志已经出来了,User 为 undefiend 是因为登录接口没有使用 JWT 守卫,若路由加了 @UseGuards(AuthGuard('jwt')),则会把用户信息绑定在 req 上,具体操作可回顾上一篇教程。

二、异常处理

在开发的过程中,难免会写出各式各样的“八阿哥”,不然程序员就要失业了。一个富有爱心的程序员应该在输出代码的同时创造出3个岗位(手动狗头)。

回归正题,光有入参出参日志还不够,异常的捕获和抛出也需要记录。

接下来,我们先故意写错语法,看看控制台打印什么:

如图,只会记录入参以及控制台默认的报错信息,而默认的报错信息,是不会写入日志文件的。

再看看请求的返回数据:

如图,这里只会看到 “Internal server error”,其他什么信息都没有。

这样就会有隐患了,用户在使用过程中报错了,但是日志没有记录报错的原因,就无法统计影响范围,如果是简单的报错还好,如果涉及数据库各种事务或者并发问题,就很难追踪定位了,总不能一直看着控制台吧。

因此,我们需要捕获代码中未捕获的异常,并记录日志到 logs/errors 里,方便登录线上服务器,对错误日志进行筛选、排查。

1. 初探过滤器

Nest 不光提供了拦截器,也提供了过滤器,就代码结构而言,和拦截器很相似。

内置的异常层负责处理整个应用程序中的所有抛出的异常。当捕获到未处理的异常时,最终用户将收到友好的响应。

我们先新建一个 http-exception.filter 试试:

1
复制代码$ nest g filter http-exception filter

打开文件,默认代码长这样:

1
2
3
4
5
6
7
复制代码// src/filter/http-exception.filter.ts
import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common';

@Catch()
export class HttpExceptionFilter<T> implements ExceptionFilter {
catch(exception: T, host: ArgumentsHost) {}
}

可以看到,和拦截器的结构大同小异,也是接收 2 个参数,只不过用了 @Catch() 来修饰。

2. HTTP 错误的捕获

Nest提供了一个内置的 HttpException 类,它从 @nestjs/common 包中导入。对于典型的基于 HTTP REST/GraphQL API 的应用程序,最佳实践是在发生某些错误情况时发送标准 HTTP 响应对象。

HttpException 构造函数有两个必要的参数来决定响应:

  • response 参数定义 JSON 响应体。它可以是 string 或 object,如下所述。
  • status参数定义HTTP状态代码。

默认情况下,JSON 响应主体包含两个属性:

  • statusCode:默认为 status 参数中提供的 HTTP 状态代码
  • message:基于状态的 HTTP 错误的简短描述

我们先来编写捕获打印的逻辑:

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
复制代码// src/filter/http-exception.filter.ts
import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common';
import { Request, Response } from 'express';
import { Logger } from '../utils/log4js';

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status = exception.getStatus();

const logFormat = ` <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
Request original url: ${request.originalUrl}
Method: ${request.method}
IP: ${request.ip}
Status code: ${status}
Response: ${exception.toString()} \n <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
`;
Logger.info(logFormat);
response.status(status).json({
statusCode: status,
error: exception.message,
msg: `${status >= 500 ? 'Service Error' : 'Client Error'}`,
});
}
}

上面代码表示如何捕获 HTTP 异常,并组装成更友好的信息返回给用户。

我们测试一下,先把注册接口的 Token 去掉,请求:

上图是还没有加过滤器的请求结果。

我们在 main.ts 中引入 http-exception:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
复制代码// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import * as express from 'express';
import { logger } from './middleware/logger.middleware';
import { TransformInterceptor } from './interceptor/transform.interceptor';
import { HttpExceptionFilter } from './filter/http-exception.filter';

async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.use(express.json()); // For parsing application/json
app.use(express.urlencoded({ extended: true })); // For parsing application/x-www-form-urlencoded
// 监听所有的请求路由,并打印日志
app.use(logger);
// 使用拦截器打印出参
app.useGlobalInterceptors(new TransformInterceptor());
app.setGlobalPrefix('nest-zero-to-one');
// 过滤处理 HTTP 异常
app.useGlobalFilters(new HttpExceptionFilter());
await app.listen(3000);
}
bootstrap();

使用全局过滤器 useGlobalFilters 调用 http-exception,再请求:

再看控制台打印:

如此一来,就可以看到未带 Token 请求的结果了,具体信息的组装,可以根据个人喜好进行修改。

3. 内置HTTP异常

为了减少样板代码,Nest 提供了一系列继承自核心异常 HttpException 的可用异常。所有这些都可以在 @nestjs/common包中找到:

  • BadRequestException
  • UnauthorizedException
  • NotFoundException
  • ForbiddenException
  • NotAcceptableException
  • RequestTimeoutException
  • ConflictException
  • GoneException
  • PayloadTooLargeException
  • UnsupportedMediaTypeException
  • UnprocessableException
  • InternalServerErrorException
  • NotImplementedException
  • BadGatewayException
  • ServiceUnavailableException
  • GatewayTimeoutException

结合这些,可以自定义抛出的异常类型,比如后面的教程说到权限管理的时候,就可以抛出 ForbiddenException 异常了。

4. 其他错误的捕获

除了 HTTP 相关的异常,还可以捕获项目中出现的所有异常,我们新建 any-exception.filter:

1
复制代码$ nest g filter any-exception filter

一样的套路:

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
复制代码// src/filter/any-exception.filter.ts
/**
* 捕获所有异常
*/
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';
import { Logger } from '../utils/log4js';

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const request = ctx.getRequest();

const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;

const logFormat = ` <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
Request original url: ${request.originalUrl}
Method: ${request.method}
IP: ${request.ip}
Status code: ${status}
Response: ${exception} \n <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
`;
Logger.error(logFormat);
response.status(status).json({
statusCode: status,
msg: `Service Error: ${exception}`,
});
}
}

和 http-exception 的唯一区别就是 exception 的类型是 unknown

我们将 any-exception 引入 main.ts:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
复制代码// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import * as express from 'express';
import { logger } from './middleware/logger.middleware';
import { TransformInterceptor } from './interceptor/transform.interceptor';
import { HttpExceptionFilter } from './filter/http-exception.filter';
import { AllExceptionsFilter } from './filter/any-exception.filter';

async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.use(express.json()); // For parsing application/json
app.use(express.urlencoded({ extended: true })); // For parsing application/x-www-form-urlencoded
// 监听所有的请求路由,并打印日志
app.use(logger);
// 使用拦截器打印出参
app.useGlobalInterceptors(new TransformInterceptor());
app.setGlobalPrefix('nest-zero-to-one');
app.useGlobalFilters(new AllExceptionsFilter());
app.useGlobalFilters(new HttpExceptionFilter());
await app.listen(3000);
}
bootstrap();

注意:AllExceptionsFilter 要在 HttpExceptionFilter 的上面,否则 HttpExceptionFilter 就不生效了,全被 AllExceptionsFilter 捕获了。

然后,我们带上 Token (为了跳过 401 报错)再请求一次:

再看看控制台:

已经有了明显的区别,再看看 errors.log,也写进了日志中:

如此一来,代码中未捕获的错误也能从日志中查到了。

总结

本篇介绍了如何使用 log4js 来管理日志,制作中间件和拦截器对入参出参进行记录,以及使用过滤器对异常进行处理。

文中日志的打印格式可以按照自己喜好进行排版,不一定局限于此。

良好的日志管理能帮我们快速排查 Bug,减少加班,不做资本家的奴隶,把有限的精力投入到无限的可能上。

下一篇将介绍如何使用 DTO 对参数进行验证,解脱各种 if - else。

本篇收录于NestJS 实战教程,更多文章敬请关注。

参考资料:

Nest.js 官方文档

Nest.js 中文文档

《Node.js 之 log4js 完全讲解》

`

本文转载自: 掘金

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

1…826827828…956

开发者博客

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