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

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


  • 首页

  • 归档

  • 搜索

盘点 AOP AOP 的初始化

发表于 2021-06-28

这是我参与更文挑战的第17天,活动详情查看: 更文挑战

首先分享之前的所有文章 , 欢迎点赞收藏转发三连下次一定 >>>> 😜😜😜

文章合集 : 🎁 juejin.cn/post/694164…

Github : 👉 github.com/black-ant

当前案例源码 : 👉 Github AOP Source

一 . 前言

之前盘点了 IOC 的相关流程 , 这一篇终于来说一说 AOP 了 , 整个系列会从 :

  • AOP 的初始化
  • AOP 代理类的创建
  • AOP 的拦截
  • AOP 的代理方法调用

以上几个章节分别进行阐述

二 . AOP 的调用入口

Aop 的初始化是指在哪个环节开始 Aop 的配置 :

结合之前 IOC 了解到的 , Aop 的核心还是通过 postProcess 来完成 , 分为 Before 和 After 2个流程

  • applyBeanPostProcessorsBeforeInstantiation (createBean 创建)
  • applyBeanPostProcessorsAfterInitialization (initializeBean 是处理)

// 路线一 : 处理 Aware , 可以通过 getCustomTargetSource 提前生成代理

  • C- AbstractAutowireCapableBeanFactory # createBean
  • C- AbstractAutowireCapableBeanFactory # resolveBeforeInstantiation
  • C- AbstractAutowireCapableBeanFactory # applyBeanPostProcessorsBeforeInstantiation
  • C- AnnotationAwareAspectJAutoProxyCreator # postProcessBeforeInstantiation

// 路线二 : 处理 BeanPostProcessor , 正常代理创建逻辑

  • C- AbstractAutowireCapableBeanFactory # createBean
  • C- AbstractAutowireCapableBeanFactory # doCreateBean
  • C- AbstractAutowireCapableBeanFactory # initializeBean
  • C- AbstractAutowireCapableBeanFactory # applyBeanPostProcessorsAfterInitialization
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
java复制代码// Step 1 : 在 createBean 中对 applyBeanPostProcessorsBeforeInstantiation 进行处理
protected Object createBean(String beanName, RootBeanDefinition mbd, @Nullable Object[] args)
throws BeanCreationException {

// .... 省略部分逻辑 , 此处初始化代理类
Object bean = resolveBeforeInstantiation(beanName, mbdToUse);

// 此内将会调用 Step 2 创建逻辑 , 同时处理代理类
Object beanInstance = doCreateBean(beanName, mbdToUse, args);

//........
return beanInstance;

}


// Step 2 : 在 IOC 流程 initializeBean 中 , 会对 PostProcessors 进行处理
protected Object initializeBean(final String beanName, final Object bean, @Nullable RootBeanDefinition mbd) {

//..........
invokeInitMethods(beanName, wrappedBean, mbd);

// 调用 applyBeanPostProcessorsAfterInitialization 处理 AfterPostProcessors
if (mbd == null || !mbd.isSynthetic()) {
wrappedBean = applyBeanPostProcessorsAfterInitialization(wrappedBean, beanName);
}

return wrappedBean;
}

处理后 , 在调用bean本身之前将委托给指定的拦截器 , 拦截器分为2种 :

  • common : 为它创建的所有代理共享
  • specific : 每个bean实例唯一

代理的限制 :
子类可以应用任何策略来决定一个bean是否要被代理,例如根据类型、名称、定义细节等

为什么有 getCustomTargetSource 提前生成 ?

getCustomTargetSource 是通过 TargetSourceCreator 对象 ,在 Bean 实例化之前 , 为其生成代理对象 .

该模式需要自行定制 , 详见 附录 : 创建 customTargetSource

2.1 applyBeanPostProcessorsBeforeInstantiation

Step 1 : 调用 PostProcessorsBefore C- AbstractAutowireCapableBeanFactory

此处主要会调用 org.springframework.aop.aspectj.annotation.AnnotationAwareAspectJAutoProxyCreator 进行处理

1
2
3
4
5
6
7
8
java复制代码for (BeanPostProcessor bp : getBeanPostProcessors()) {
if (bp instanceof InstantiationAwareBeanPostProcessor) {
// idp -> org.springframework.aop.aspectj.annotation.AnnotationAwareAspectJAutoProxyCreator
InstantiationAwareBeanPostProcessor ibp = (InstantiationAwareBeanPostProcessor) bp;
Object result = ibp.postProcessBeforeInstantiation(beanClass, beanName);
//..........
}
}

Step 2 : 配置前置条件 C- AbstractAutoProxyCreator

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
java复制代码public Object postProcessBeforeInstantiation(Class<?> beanClass, String beanName) {
Object cacheKey = getCacheKey(beanClass, beanName);

if (!StringUtils.hasLength(beanName) || !this.targetSourcedBeans.contains(beanName)) {
// 如果已经处理 , 此处直接返回
if (this.advisedBeans.containsKey(cacheKey)) {
return null;
}
// isInfrastructureClass -> 不应该被代理的基础结构类
// shouldSkip -> 注意 , 子类重写 shouldSkip , 用于跳过自动代理
if (isInfrastructureClass(beanClass) || shouldSkip(beanClass, beanName)) {
// PRO21001 -> advisedBeans 的作用
this.advisedBeans.put(cacheKey, Boolean.FALSE);
return null;
}
}

// 如果有一个自定义的TargetSource,那么在这里创建代理
// TargetSource将以自定义的方式处理目标实例
TargetSource targetSource = getCustomTargetSource(beanClass, beanName);
if (targetSource != null) {
// 自行定制 CustomTargetSource 逻辑后 , 即会在此处创建代理
if (StringUtils.hasLength(beanName)) {
this.targetSourcedBeans.add(beanName);
}
Object[] specificInterceptors = getAdvicesAndAdvisorsForBean(beanClass, beanName, targetSource);
Object proxy = createProxy(beanClass, beanName, specificInterceptors, targetSource);
this.proxyTypes.put(cacheKey, proxy.getClass());
return proxy;
}

return null;
}

Step 2-1 : 返回目标实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码
C- getCustomTargetSource : 为bean实例创建目标源。如果设置,使用任何TargetSourceCreators
protected TargetSource getCustomTargetSource(Class<?> beanClass, String beanName) {
// 此处要求存在创建者, 需要在执行定制
if (this.customTargetSourceCreators != null &&
this.beanFactory != null && this.beanFactory.containsBean(beanName)) {
for (TargetSourceCreator tsc : this.customTargetSourceCreators) {
// 通过创建者回去目标对象代理类
TargetSource ts = tsc.getTargetSource(beanClass, beanName);
if (ts != null) {
return ts;
}
}
}

// 未定义 customTargetSource 会在此处直接返回
return null;
}

2.2 applyBeanPostProcessorsAfterInitialization

前面说的是在 Bean 实例化之前创建代理 ,但是通常情况下 , 是在 Bean 实例化之后进行代理逻辑的创建

Step 1 : IOC initializeBean 入口

IOC initializeBean 时 , 调用 applyBeanPostProcessorsAfterInitialization 逻辑

1
2
3
java复制代码if (mbd == null || !mbd.isSynthetic()) {
wrappedBean = applyBeanPostProcessorsAfterInitialization(wrappedBean, beanName);
}

Step 2 : applyBeanPostProcessorsAfterInitialization 中循环 BeanPostProcessor 进行处理

1
2
3
4
5
java复制代码for (BeanPostProcessor processor : getBeanPostProcessors()) {
// BeanPostProcessor -> org.springframework.aop.aspectj.annotation.AnnotationAwareAspectJAutoProxyCreator
Object current = processor.postProcessAfterInitialization(result, beanName);
//........
}

PS : 可以看到 , 这里拿到的Bean 的 PostProcessor 就是 AnnotationAwareAspectJAutoProxyCreator

Step 3 : AbstractAutoProxyCreator # postProcessAfterInitialization 构建代理对象

如果bean被标识为代理,则使用配置的拦截器创建代理

1
2
3
4
5
6
7
8
9
10
java复制代码public Object postProcessAfterInitialization(@Nullable Object bean, String beanName) {
if (bean != null) {
// cacheKey 用来标识
Object cacheKey = getCacheKey(bean.getClass(), beanName);
if (this.earlyProxyReferences.remove(cacheKey) != bean) {
return wrapIfNecessary(bean, beanName, cacheKey);
}
}
return bean;
}

Step 4 : wrapIfNecessary 正式创建代理类 C- AbstractAutoProxyCreator

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
java复制代码
protected Object wrapIfNecessary(Object bean, String beanName, Object cacheKey) {
//
if (StringUtils.hasLength(beanName) && this.targetSourcedBeans.contains(beanName)) {
return bean;
}
if (Boolean.FALSE.equals(this.advisedBeans.get(cacheKey))) {
return bean;
}
if (isInfrastructureClass(bean.getClass()) || shouldSkip(bean.getClass(), beanName)) {
this.advisedBeans.put(cacheKey, Boolean.FALSE);
return bean;
}

// 如果配置了通知 ,则创建Proxy 代理
Object[] specificInterceptors = getAdvicesAndAdvisorsForBean(bean.getClass(), beanName, null);
if (specificInterceptors != DO_NOT_PROXY) {
this.advisedBeans.put(cacheKey, Boolean.TRUE);
// 核心逻辑 , 创建代理 -> 2.2 Aop 代理类的创建
// 可以看到 , 这里构建了一个 SingletonTargetSource -> PRO21002 : TargetSource 是什么 ?
Object proxy = createProxy(bean.getClass(), beanName, specificInterceptors, new SingletonTargetSource(bean));
this.proxyTypes.put(cacheKey, proxy.getClass());
return proxy;
}

this.advisedBeans.put(cacheKey, Boolean.FALSE);
return bean;
}

PRO21001 -> advisedBeans 的作用

1
2
3
4
5
6
java复制代码// advisedBeans 用于标识当前 Bean 的代理状态 , 该对象 key 为 getCacheKey 获取
Map<Object, Boolean> advisedBeans = new ConcurrentHashMap<>(256);

- 如果无需代理 -> advisedBeans.put(cacheKey, Boolean.FALSE)
- 如果已经代理 -> advisedBeans.put(cacheKey, Boolean.TRUE)
- 最终无操作(没用注解标注) -> advisedBeans.put(cacheKey, Boolean.FALSE)

PRO21002 : TargetSource 是什么 ?

可以看到 , 这里实际上和下面的 applyBeanPostProcessorsAfterInitialization 类似 , 那么上面是处理什么?

TargetSource用于获取AOP调用的当前”目标” , 在Spring代理目标bean的时候,其并不是直接创建一个目标bean的对象实例的,而是通过一个TargetSource类型的对象将目标bean进行封装 , 然后通过 getTarget 获取目标对象

TargetSource 是一个接口 , 可以通过下图看到其本身的体系结构 :

TargetSource-system.png

三 . 补充点 : Advisors 详解之 getAdvicesAndAdvisorsForBean 流程

此部分对 getAdvicesAndAdvisorsForBean 的流程进行详细的浏览

Step 1 : 筛选 Advices C- AbstractAutoProxyCreator

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码M- getAdvicesAndAdvisorsForBean
Object[] getAdvicesAndAdvisorsForBean(Class<?> beanClass, String beanName,TargetSource customTargetSource)
?- 返回给定的bean是否要被代理,要应用哪些附加通知(例如AOP Alliance拦截器)和建议器

// 该类主要有2个实现类 : AbstractAdvisorAutoProxyCreator , BeanNameAutoProxyCreator

protected Object[] getAdvicesAndAdvisorsForBean(
Class<?> beanClass, String beanName, @Nullable TargetSource targetSource) {

List<Advisor> advisors = findEligibleAdvisors(beanClass, beanName);
if (advisors.isEmpty()) {
return DO_NOT_PROXY;
}
return advisors.toArray();
}

Step 2 : 为自动代理这个类找到所有符合条件的advisor

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码protected List<Advisor> findEligibleAdvisors(Class<?> beanClass, String beanName) {
// Step 2-1 : 查询切面对应的所有的通知
List<Advisor> candidateAdvisors = findCandidateAdvisors();
// Step 2-2 : 筛选与类对应的织入切点
List<Advisor> eligibleAdvisors = findAdvisorsThatCanApply(candidateAdvisors, beanClass, beanName);
// 空对象, 待实现
extendAdvisors(eligibleAdvisors);
if (!eligibleAdvisors.isEmpty()) {
eligibleAdvisors = sortAdvisors(eligibleAdvisors);
}
return eligibleAdvisors;
}

Step 2-1 : BeanFactoryAdvisorRetrievalHelper 获取通知对象

2-1-1 : 先调用具体实现类 (AnnotationAwareAspectJAutoProxyCreator)

👉该过程中对切面进行了扫描 ,并且缓存了所有的通知点

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
java复制代码protected List<Advisor> findCandidateAdvisors() {
// 根据超类规则添加所有找到的Spring 通知
List<Advisor> advisors = super.findCandidateAdvisors();
// 为bean工厂中的所有AspectJ方面构建 通知
if (this.aspectJAdvisorsBuilder != null) {
// 2-1-1-1
advisors.addAll(this.aspectJAdvisorsBuilder.buildAspectJAdvisors());
}
return advisors;
}

// 2-1-1-1 : 构建通知者
public List<Advisor> buildAspectJAdvisors() {
List<String> aspectNames = this.aspectBeanNames;

if (aspectNames == null) {
synchronized (this) {
aspectNames = this.aspectBeanNames;
if (aspectNames == null) {
List<Advisor> advisors = new ArrayList<>();
aspectNames = new ArrayList<>();
String[] beanNames = BeanFactoryUtils.beanNamesForTypeIncludingAncestors(
this.beanFactory, Object.class, true, false);
for (String beanName : beanNames) {
if (!isEligibleBean(beanName)) {
continue;
}
// 需要在实例化缓存之前被处理
Class<?> beanType = this.beanFactory.getType(beanName);
if (beanType == null) {
continue;
}
// 过滤出切面 Bean , debug 过程中会拿到我自己定义的切面
if (this.advisorFactory.isAspect(beanType)) {
aspectNames.add(beanName);
// 获取切面元数据 -> PIC0001
AspectMetadata amd = new AspectMetadata(beanType, beanName);
if (amd.getAjType().getPerClause().getKind() == PerClauseKind.SINGLETON) {
// AspectInstanceFactory的子接口,它返回与aspectj注释类关联的AspectMetadata
MetadataAwareAspectInstanceFactory factory =
new BeanFactoryAspectInstanceFactory(this.beanFactory, beanName);
// 获得所有得通知点 -> PIC0002
List<Advisor> classAdvisors = this.advisorFactory.getAdvisors(factory);
if (this.beanFactory.isSingleton(beanName)) {
// 此处成功放入缓存中
this.advisorsCache.put(beanName, classAdvisors);
}
else {
this.aspectFactoryCache.put(beanName, factory);
}
advisors.addAll(classAdvisors);
} else {
// Per target or per this.
if (this.beanFactory.isSingleton(beanName)) {
throw new IllegalArgumentException("Bean with name '" + beanName +
"' is a singleton, but aspect instantiation model is not singleton");
}
MetadataAwareAspectInstanceFactory factory =
new PrototypeAspectInstanceFactory(this.beanFactory, beanName);
this.aspectFactoryCache.put(beanName, factory);
advisors.addAll(this.advisorFactory.getAdvisors(factory));
}
}
}
this.aspectBeanNames = aspectNames;
return advisors;
}
}
}

if (aspectNames.isEmpty()) {
return Collections.emptyList();
}

// 循环 aspect 切面 , 添加通知
List<Advisor> advisors = new ArrayList<>();
for (String aspectName : aspectNames) {
List<Advisor> cachedAdvisors = this.advisorsCache.get(aspectName);
if (cachedAdvisors != null) {
advisors.addAll(cachedAdvisors);
} else {
MetadataAwareAspectInstanceFactory factory = this.aspectFactoryCache.get(aspectName);
advisors.addAll(this.advisorFactory.getAdvisors(factory));
}
}
return advisors;
}

PIC0001 : 切面元数据

image.png

PIC0002 : 待通知的切点
image.png

2-1-2 : 调用父类

1
2
3
java复制代码protected List<Advisor> findCandidateAdvisors() {
return this.advisorRetrievalHelper.findAdvisorBeans();
}

2-1-3 : 实际方法

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
java复制代码public List<Advisor> findAdvisorBeans() {
// 确定advisor bean名称列表
String[] advisorNames = this.cachedAdvisorBeanNames;
if (advisorNames == null) {
// Do not initialize FactoryBeans here: We need to leave all regular beans
// uninitialized to let the auto-proxy creator apply to them!
advisorNames = BeanFactoryUtils.beanNamesForTypeIncludingAncestors(
this.beanFactory, Advisor.class, true, false);
this.cachedAdvisorBeanNames = advisorNames;
}
if (advisorNames.length == 0) {
return new ArrayList<>();
}

List<Advisor> advisors = new ArrayList<>();
for (String name : advisorNames) {
if (isEligibleBean(name)) {
if (this.beanFactory.isCurrentlyInCreation(name)) {
// log 省略
} else {
try {
advisors.add(this.beanFactory.getBean(name, Advisor.class));
} catch (BeanCreationException ex) {
Throwable rootCause = ex.getMostSpecificCause();
if (rootCause instanceof BeanCurrentlyInCreationException) {
BeanCreationException bce = (BeanCreationException) rootCause;
String bceBeanName = bce.getBeanName();
if (bceBeanName != null && this.beanFactory.isCurrentlyInCreation(bceBeanName)) {
// Ignore: indicates a reference back to the bean we're trying to advise.
// We want to find advisors other than the currently created bean itself.
continue;
}
}
throw ex;
}
}
}
}
return advisors;
}

Step 2-2 : 搜索给定的候选advisor以找到适用于指定bean的所有advisor

1
2
3
4
5
6
7
8
9
10
11
java复制代码
protected List<Advisor> findAdvisorsThatCanApply(
List<Advisor> candidateAdvisors, Class<?> beanClass, String beanName) {

ProxyCreationContext.setCurrentProxiedBeanName(beanName);
try {
return AopUtils.findAdvisorsThatCanApply(candidateAdvisors, beanClass);
} finally {
ProxyCreationContext.setCurrentProxiedBeanName(null);
}
}

Step 2-3 : sort 排序

1
2
3
4
5
6
7
java复制代码protected List<Advisor> sortAdvisors(List<Advisor> advisors) {
AnnotationAwareOrderComparator.sort(advisors);
return advisors;
}

// [Pro] : AnnotationAwareOrderComparator 是什么 ?
AnnotationAwareOrderComparator是OrderComparator的扩展,它支持Spring的Ordered接口以及Order和Priority注释

总结

AOP 初始化阶段基本上就完成了 , 后续再来看一下代理类的创建 . 来总结一下这篇文档得所有概念 :

  • 由 initializeBean 开启 AOP 的主处理逻辑
  • AbstractAutowireCapableBeanFactory 调用 applyBeanPostProcessorsBeforeInstantiation 创建自定义代理类
  • AbstractAutowireCapableBeanFactory 调用 applyBeanPostProcessorsAfterInitialization 筛选通知器

附录 : 创建 customTargetSource

参考地址 (原文这一块写的更加详细) @ blog.csdn.net/qq_39002724…

Step 1 :准备 CustomTargetSource

1
2
3
4
5
6
7
8
9
10
java复制代码public class DefaultCustomTargetSource extends AbstractBeanFactoryBasedTargetSource {

private Logger logger = LoggerFactory.getLogger(this.getClass());

@Override
public Object getTarget() throws Exception {
logger.info("------> 进入 [DefaultCustomTargetSource] , 返回当前目标对象 <-------");
return getBeanFactory().getBean(getTargetBeanName());
}
}

Step 2 : 准备创建者

该对象用于返回创建的 CustomTargetSource

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码public class DefaultCustomerTargetSourceCreator extends AbstractBeanFactoryBasedTargetSourceCreator {

private Logger logger = LoggerFactory.getLogger(this.getClass());

@Override
protected AbstractBeanFactoryBasedTargetSource createBeanFactoryBasedTargetSource(Class<?> beanClass, String beanName) {
logger.info("------> CustomerTargetSourceCreator build : [进入流程创建过程 , 返回默认资源类 DefaultCustomTargetSource] <-------");
if (getBeanFactory() instanceof ConfigurableListableBeanFactory) {
if (beanClass.isAssignableFrom(OtherService.class)) {
return new DefaultCustomTargetSource();
}
}
return null;
}
}

Step 3 : CustomTargetSource 资源配置

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
java复制代码@Component
public class CustomTargetSourceCreatorConfig implements BeanPostProcessor, PriorityOrdered, BeanFactoryAware {

private Logger logger = LoggerFactory.getLogger(this.getClass());

private BeanFactory beanFactory;

@Override
public int getOrder() {
return 45;
}

@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
logger.info("------> 进入 CustomTargetSourceCreatorConfig 初始化加载逻辑 , 返回默认创建者 <-------");
if (bean instanceof AnnotationAwareAspectJAutoProxyCreator) {
AnnotationAwareAspectJAutoProxyCreator annotationAwareAspectJAutoProxyCreator = (AnnotationAwareAspectJAutoProxyCreator) bean;
DefaultCustomerTargetSourceCreator customTargetSourceCreator = new DefaultCustomerTargetSourceCreator();
customTargetSourceCreator.setBeanFactory(beanFactory);
annotationAwareAspectJAutoProxyCreator.setCustomTargetSourceCreators(customTargetSourceCreator);
}
return bean;
}

@Override
public void setBeanFactory(BeanFactory beanFactory) throws BeansException {
this.beanFactory = beanFactory;
}
}

本文转载自: 掘金

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

SpringBoot24x整合Mybatis-Plus3

发表于 2021-06-28

Mybatis和MybatisPlus的区别与联系

Mybatis-Plus是一个Mybatis的增强工具,只是在Mybatis的基础上做了增强却不做改变,MyBatis-Plus支持所有Mybatis原生的特性,所以引入Mybatis-Plus不会对现有的Mybatis构架产生任何影。Mybatis-Plus又简称(MP)是为简化开发,提高开发效率而生正如官网所说的,

image.png
点击这里进入学官网学习

快速与SpringBoot整合基础入门

导入必须依赖

  1. MybatisPlus整合SpringBoot的场景启动器jar
1
2
3
4
5
xml复制代码 <dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.3</version>
</dependency>
  1. 连接mysql的驱动jar
1
2
3
4
5
xml复制代码<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>

注意这里没有指定mysql-connector-javajar包版本时SpringBoot会默认为我们指定一个版本

配置数据源

1
2
3
4
5
6
7
8
9
xml复制代码#---------------数据库连接配置--------------
# 用户名
spring.datasource.username=root
# 密码
spring.datasource.password=root
# 连接url
spring.datasource.url=jdbc:mysql://localhost:3306/school?zeroDateTimeBehavior=convertToNull&useUnicode=true&characterEncoding=UTF-8&serverTimezone=UTC&autoReconnect=true
# 驱动名称
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

简单CRUD

编写实体类与数据库映射

实体类Student与数据库表student对应

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
JAVA复制代码package cn.soboys.springbootmybatisplus.bean;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.activerecord.Model;
import lombok.Data;

/**
* @author kenx
* @version 1.0
* @date 2021/6/25 10:08
*/

@TableName("student")
public class Student extends Model {
@TableId(value = "student_id",type = IdType.AUTO)
private Long studentId;
@TableField("student_name")
private String studentName;
@TableField("age")
private int age;
@TableField("phone")
private String phone;
@TableField("addr")
private String addr;


public Long getStudentId() {
return studentId;
}

public void setStudentId(Long studentId) {
this.studentId = studentId;
}

public String getStudentName() {
return studentName;
}

public void setStudentName(String studentName) {
this.studentName = studentName;
}

public int getAge() {
return age;
}

public void setAge(int age) {
this.age = age;
}

public String getPhone() {
return phone;
}

public void setPhone(String phone) {
this.phone = phone;
}

public String getAddr() {
return addr;
}

public void setAddr(String addr) {
this.addr = addr;
}
}

编写mapper(dao)与数据库交互

接口StudentMapper具体实现由mybatis代理实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码package cn.soboys.springbootmybatisplus.mapper;

import cn.soboys.springbootmybatisplus.bean.Student;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.mybatis.spring.annotation.MapperScan;

/**
* @author kenx
* @version 1.0
* @date 2021/6/25 10:53
*/
@Mapper
public interface StudentMapper extends BaseMapper<Student> {


}

编写service实现具体业务

  1. 接口IStudentService
1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码package cn.soboys.springbootmybatisplus.service;

import cn.soboys.springbootmybatisplus.bean.Student;
import com.baomidou.mybatisplus.extension.service.IService;
import org.springframework.stereotype.Service;

/**
* @author kenx
* @version 1.0
* @date 2021/6/25 10:59
*/

public interface IStudentService extends IService<Student> {
}
  1. 实现类StudentServiceImpl
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码package cn.soboys.springbootmybatisplus.service.impl;

import cn.soboys.springbootmybatisplus.bean.Student;
import cn.soboys.springbootmybatisplus.mapper.StudentMapper;
import cn.soboys.springbootmybatisplus.service.IStudentService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;

/**
* @author kenx
* @version 1.0
* @date 2021/6/25 13:35
*/
@Service
public class StudentServiceImpl extends ServiceImpl<StudentMapper,Student> implements IStudentService {
}

编写controller主程序

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
java复制代码package cn.soboys.springbootmybatisplus.controller;

import cn.soboys.springbootmybatisplus.bean.Student;

import cn.soboys.springbootmybatisplus.service.IStudentService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import java.util.List;

/**
* @author kenx
* @version 1.0
* @date 2021/6/25 11:09
*/
@RestController
@RequestMapping("/student")
public class StudentController {

@Autowired
private IStudentService studentService;

/**
* 添加学生
*
* @param student
* @return
*/
@PostMapping("/add")
public boolean addStudent(@RequestBody Student student) {
boolean flag = studentService.save(student);
return flag;
}

/**
* 根据id更新学生信息
*
* @param student
* @return
*/
@PutMapping("/update")
public boolean updateStudent(@RequestBody Student student) {
//根据学生id更新学生
boolean flag = studentService.updateById(student);
return flag;
}


/**
* 查找所有学生信息
*
* @return
*/
@GetMapping("/list")
public List<Student> list() {
return studentService.list();
}

/**
* 根据id删除学生信息
*
* @param studentId
* @return
*/
@DeleteMapping("/del/{studentId}")
public boolean del(@PathVariable long studentId) {
boolean flag = studentService.removeById(studentId);
return flag;
}

/**
* 根据id获取学生信息
* @param studentId
* @return
*/
@GetMapping("{studentId}")
public Student getStudentInfo(@PathVariable long studentId){
return studentService.getById(studentId);
}


}

向数据库里添加一个学生

image.png
我们看到返回结果是true 代表添加成功

根据学生id修改刚刚添加学生信息

image.png
我们看到也修改成功返回true,注意这里修改时候多传一个studentId 参数,就是通过学生id找到对应的学生在进行修改。

查询所有的学生信息

image.png

根据学生id删除学生信息

image.png
我们看到返回true代表删除成功

根据id获取某个学生信息

image.png

到这里单张表最基本的crud功能都可以正常使用

SpringBoot整合进阶使用

我们看到上面完成了最基本整合使用很多地方还可以进一步优化

简化实体bean

我们看到上面整合方式Student实体类包含很多getter,setter方法,和一些不非必要的映射注解可以适当的简化

  1. 导入jar包lombok 这里没有写版本默认用springboot给我们指定的一个版本
1
2
3
4
5
java复制代码<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>

lombok可以通过注解@Data帮我们自动生成getter,setter 方法我们只需要在对应实体类上添加上这个注解就可以去掉代码中冗余getter,setter 方法。

简化实体bean与数据库映射注解

我们看到上面整合方式Student实体类包含@TableName 注解和很多@TableField注解其实遵守MybatisPlus中java实体类与数据库映射规则可以适当简化默认MybatisPlus会把大驼峰命名法(帕斯卡命名法)转换为数据库对应下划线命名方法

例如实体类名为:OwnUser,会给你对应到数据库中的own_user表
字段StudentId ,会给你对应数据库表中student_id 的字段

所以最终实体类可以简化成如下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
java复制代码package cn.soboys.springbootmybatisplus.bean;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.activerecord.Model;
import lombok.Data;

/**
* @author kenx
* @version 1.0
* @date 2021/6/25 10:08
*/

@TableName
@Data
public class Student extends Model {
@TableId(type = IdType.AUTO)
private Long studentId;
private String studentName;
private int age;
private String phone;
private String addr;
}

简化mapper扫描

上面整合方式会在每个mapper接口类中添加@Mapper注解进行扫描这样会很麻烦造成冗余,我们可以直接在SpringBoot启动类上添加@MapperScan批量扫描mapper 包,当然我们也可以在其他任意配置类上添加@MapperScan批量扫描mapper 包,但一般会在SpringBoot启动类上添加(本质SpringBoot启动类也是配置类),
这样配置比较集中,有意义,也不需要额外去写一个无意的配置类

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
java复制代码package cn.soboys.springbootmybatisplus;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ApplicationContext;

@SpringBootApplication
@MapperScan({"cn.soboys.springbootmybatisplus.mapper"})
public class SpringbootMybatisplusApplication {

private static ApplicationContext applicationContext;

public static void main(String[] args) {
applicationContext = SpringApplication.run(SpringbootMybatisplusApplication.class, args);
//displayAllBeans();
}

/**
* 打印所以装载的bean
*/
public static void displayAllBeans() {
String[] allBeanNames = applicationContext.getBeanDefinitionNames();
for (String beanName : allBeanNames) {
System.out.println(beanName);
}
}
}

这样就可以不用单独在每个mapper接口类添加@Mapper注解进行扫描

数据库连接池配置

上面我们只是进行了简单的数据库连接配置,但是在真正实际应用中都会使用数据库连接池提高数据连接效率,减少不必要数据库资源开销 具体配置如下

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
xml复制代码#---------------数据库连接配置--------------
#数据源类型
spring.datasource.type=com.zaxxer.hikari.HikariDataSource
# 用户名
spring.datasource.username=root
# 密码
spring.datasource.password=root
# 连接url
spring.datasource.url=jdbc:mysql://localhost:3306/school?zeroDateTimeBehavior=convertToNull&useUnicode=true&characterEncoding=UTF-8&serverTimezone=UTC&autoReconnect=true
# 驱动名称
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
#---------------数据库连接池HikariCP配置--------------
#最小空闲连接,默认值10,小于0或大于maximum-pool-size,都会重置为maximum-pool-size
spring.datasource.hikari.minimum-idle=10
#最大连接数,小于等于0会被重置为默认值10;大于零小于1会被重置为minimum-idle的值
spring.datasource.hikari.maximum-pool-size=20
#空闲连接超时时间,默认值600000单位毫秒(10分钟),
# 大于等于max-lifetime且max-lifetime>0,会被重置为0;不等于0且小于10秒,会被重置为10秒。
spring.datasource.hikari.idle-timeout=500000
#连接最大存活时间,不等于0且小于30秒,会被重置为默认值30分钟.设置应该比mysql设置的超时时间短
spring.datasource.hikari.max-lifetime=540000
#连接超时时间:毫秒,小于250毫秒,否则被重置为默认值30秒
spring.datasource.hikari.connection-timeout=60000
#用于测试连接是否可用的查询语句
spring.datasource.hikari.connection-test-query=SELECT 1

MybatisPlus配置

我们看到上面整合只是简单crud,单张表的操作,我们的service,mapper 没有写任何方法只是继承了MybatisPlus 的通用 Mapper,BaseMapper,通用service接口IService以及实现ServiceImpl就具备了基础的crud方法,但是当遇到多表复杂条件查询时候,就需要单独写sql,这时候就需要单独配置了mapper.xml 文件了

1
2
3
4
5
6
7
xml复制代码#--------------------mybatisPlus配置------------------
#mapper.xml 文件位置
mybatis-plus.mapper-locations=classpath:mapper/*.xml
#别名包扫描路径,通过该属性可以给包中的类注册别名
mybatis-plus.type-aliases-package=cn.soboys.springbootmybatisplus.bean
#控制台打印mybatisPlus LOGO
mybatis-plus.global-config.banner=true

MybatisPlus更多详细配置

分页插件使用

在MybatisPlus中也为我们分页做了相关处理我们要做相关配置才能正常使用

SpringBoot配置类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
java复制代码package cn.soboys.springbootmybatisplus.config;

import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.context.annotation.Bean;

/**
* @author kenx
* @version 1.0
* @date 2021/6/28 10:19
*/
@SpringBootConfiguration
public class MyBatisCfg {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}

StudentMapper类

1
2
3
4
5
6
7
java复制代码/**
* 分页查询每个班级学生信息
* @param page 分页对象,xml中可以从里面进行取值,传递参数 Page 即自动分页,必须放在第一位(你可以继承Page实现自己的分页对象)
* @param gradeId 班级id
* @return 分页对象
*/
IPage<Student> findStudentPage(Page<?> page, long gradeId);

studentMapper.xml

1
2
3
4
xml复制代码<!-- 等同于编写一个普通 list 查询,mybatis-plus 自动替你分页-->
<select id="findStudentPage" resultType="Student">
select * from student ,grade g where g.grade_id=#{gradeId}
</select>

IStudentService 接口

1
2
3
4
5
6
7
java复制代码/**
* 分页查询
* @param page
* @param gradeId 班级id
* @return
*/
IPage<Student> getStudentPage(Page<Student> page,long gradeId);

StudentServiceImpl 实现类

1
2
3
4
5
6
7
8
9
java复制代码@Override
public IPage<Student> getStudentPage(Page<Student> page,long gradeId) {
// 不进行 count sql 优化,解决 MP 无法自动优化 SQL 问题,这时候你需要自己查询 count 部分
// page.setOptimizeCountSql(false);
// 当 total 为小于 0 或者设置 setSearchCount(false) 分页插件不会进行 count 查询
// 要点!! 分页返回的对象与传入的对象是同一个

return this.baseMapper.findStudentPage(page,gradeId);
}

StudentController 主程序调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码 /**
* 分页获取学生详细信息
*
* @return
*/
@GetMapping("listDetailPage")
public IPage<Student> getStudentDetailPage(PageRequest request) {
Page<Student> page = new Page<>();
//设置每页显示几条
page.setSize(request.getPageSize());
//设置第几页
page.setCurrent(request.getPageNum());
return studentService.getStudentPage(page, 1);
}

这里需要传递分页等相关信息,可以自己封装一个分页查询通用对象PageRequest

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
java复制代码package cn.soboys.springbootmybatisplus;

import lombok.Data;

import java.io.Serializable;

/**
* @author kenx
* @version 1.0
* @date 2021/6/28 10:41
* 分页查询
*/
@Data
public class PageRequest implements Serializable {
private static final long serialVersionUID = -4869594085374385813L;

/**
* 当前页面数据量
*/
private int pageSize = 10;

/**
* 当前页码
*/
private int pageNum = 1;

/**
* 排序字段
*/
private String field;

/**
* 排序规则,asc升序,desc降序
*/
private String order;
}

image.png
我们看到正常分页查询每页显示2条第1页

image.png
第2页

代码生成器

我们知道Mybatis可以通过配置生成基础的实体映射bean,简化我们开发时间,不必要写繁琐的映射bean包括一堆属性,MybatisPlus也有自己的代码生成器AutoGenerator,通过简单配置,我们可以快速生成完整的model,service,mapper,不需要自己去写然后继承通用mapper,service官网原话如下

image.png

添加依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
xml复制代码 <!--生成器依赖-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-generator</artifactId>
<version>3.4.1</version>
</dependency>
<!--MyBatis-Plus 从 3.0.3 之后移除了代码生成器与模板引擎的默认依赖,需要手动添加相关依赖:-->
<!--添加 模板引擎 依赖,MyBatis-Plus 支持 Velocity(默认)、
Freemarker、Beetl,用户可以选择自己熟悉的模板引擎,
如果都不满足您的要求,可以采用自定义模板引擎。-->
<dependency>
<groupId>org.freemarker</groupId>
<artifactId>freemarker</artifactId>
<version>2.3.31</version>
</dependency>

自定义生成器代码

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
java复制代码package cn.soboys.springbootmybatisplus;


import com.baomidou.mybatisplus.core.exceptions.MybatisPlusException;
import com.baomidou.mybatisplus.core.toolkit.StringPool;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import com.baomidou.mybatisplus.generator.AutoGenerator;
import com.baomidou.mybatisplus.generator.InjectionConfig;
import com.baomidou.mybatisplus.generator.config.*;
import com.baomidou.mybatisplus.generator.config.po.TableInfo;
import com.baomidou.mybatisplus.generator.config.rules.NamingStrategy;
import com.baomidou.mybatisplus.generator.engine.FreemarkerTemplateEngine;

import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;

/**
* @author kenx
* @version 1.0
* @date 2021/6/28 11:31
* 自动代码生成器
*/
public class MyBatisGeneratorCode {
/**
* <p>
* 读取控制台内容
* </p>
*/
public static String scanner(String tip) {
Scanner scanner = new Scanner(System.in);
StringBuilder help = new StringBuilder();
help.append("请输入" + tip + ":");
System.out.println(help.toString());
if (scanner.hasNext()) {
String ipt = scanner.next();
if (StringUtils.isNotBlank(ipt)) {
return ipt;
}
}
throw new MybatisPlusException("请输入正确的" + tip + "!");
}

public static void main(String[] args) {
// 代码生成器
AutoGenerator mpg = new AutoGenerator();

// 全局配置
GlobalConfig gc = new GlobalConfig();
String projectPath = System.getProperty("user.dir");
gc.setOutputDir(projectPath + "/src/main/java");
gc.setAuthor("kenx");
gc.setOpen(false);
// gc.setSwagger2(true); 实体属性 Swagger2 注解
mpg.setGlobalConfig(gc);

// 数据源配置
DataSourceConfig dsc = new DataSourceConfig();
dsc.setUrl("jdbc:mysql://localhost:3306/school?useUnicode=true&useSSL=false&characterEncoding=utf8");
// dsc.setSchemaName("public");
dsc.setDriverName("com.mysql.jdbc.Driver");
dsc.setUsername("root");
dsc.setPassword("root");
mpg.setDataSource(dsc);

// 包配置
PackageConfig pc = new PackageConfig();
pc.setModuleName(scanner("模块名"));
pc.setParent("cn.soboys.springbootmybatisplus.generator");
mpg.setPackageInfo(pc);

// 自定义配置
InjectionConfig cfg = new InjectionConfig() {
@Override
public void initMap() {
// to do nothing
}
};

// 如果模板引擎是 freemarker
String templatePath = "/templates/mapper.xml.ftl";
// 如果模板引擎是 velocity
// String templatePath = "/templates/mapper.xml.vm";

// 自定义输出配置
List<FileOutConfig> focList = new ArrayList<>();
// 自定义配置会被优先输出
focList.add(new FileOutConfig(templatePath) {
@Override
public String outputFile(TableInfo tableInfo) {
// 自定义输出文件名 , 如果你 Entity 设置了前后缀、此处注意 xml 的名称会跟着发生变化!!
return projectPath + "/src/main/resources/mapper/generator/" + pc.getModuleName()
+ "/" + tableInfo.getEntityName() + "Mapper" + StringPool.DOT_XML;
}
});
/*
cfg.setFileCreate(new IFileCreate() {
@Override
public boolean isCreate(ConfigBuilder configBuilder, FileType fileType, String filePath) {
// 判断自定义文件夹是否需要创建
checkDir("调用默认方法创建的目录,自定义目录用");
if (fileType == FileType.MAPPER) {
// 已经生成 mapper 文件判断存在,不想重新生成返回 false
return !new File(filePath).exists();
}
// 允许生成模板文件
return true;
}
});
*/
cfg.setFileOutConfigList(focList);
mpg.setCfg(cfg);

// 配置模板
TemplateConfig templateConfig = new TemplateConfig();

// 配置自定义输出模板
//指定自定义模板路径,注意不要带上.ftl/.vm, 会根据使用的模板引擎自动识别
// templateConfig.setEntity("templates/entity2.java");
// templateConfig.setService();
// templateConfig.setController();

templateConfig.setXml(null);
mpg.setTemplate(templateConfig);

// 策略配置
StrategyConfig strategy = new StrategyConfig();
strategy.setNaming(NamingStrategy.underline_to_camel);
strategy.setColumnNaming(NamingStrategy.underline_to_camel);
//strategy.setSuperEntityClass("你自己的父类实体,没有就不用设置!");
strategy.setEntityLombokModel(true);
strategy.setRestControllerStyle(true);
// 公共父类
//strategy.setSuperControllerClass("你自己的父类控制器,没有就不用设置!");
// 写于父类中的公共字段
strategy.setSuperEntityColumns("id");
strategy.setInclude(scanner("表名,多个英文逗号分割").split(","));
strategy.setControllerMappingHyphenStyle(true);
strategy.setTablePrefix(pc.getModuleName() + "_");
mpg.setStrategy(strategy);
mpg.setTemplateEngine(new FreemarkerTemplateEngine());
mpg.execute();
}
}

运行后的结果

image.png
我们看到运行成功已经正常生成我们需要的目录结构

image.png

image.png
这个模版是通用模版只需要,稍微改一下自己需要生成数据库,包,目录就可以使用了

更多内容参考官网

SpringBoot整合高阶使用

调试打印应sql

在开发中我们常常需要调试代码,需要看看生成sql是否正确,这个时候就需要在控制台打印sql

导入依赖

1
2
3
4
5
6
pom复制代码<!-- https://mvnrepository.com/artifact/p6spy/p6spy -->
<dependency>
<groupId>p6spy</groupId>
<artifactId>p6spy</artifactId>
<version>3.9.0</version>
</dependency>

配置文件配置

1
2
3
4
5
properties复制代码#url 改为p6spy开头的连接url
spring.datasource.url=jdbc:p6spy:mysql://localhost:3306/school?zeroDateTimeBehavior=convertToNull&useUnicode=true&characterEncoding=UTF-8&serverTimezone=UTC&autoReconnect=true
# 驱动名称
# 需要调试拦截打印sql 驱动改为p6spy 拦截
spring.datasource.driver-class-name=com.p6spy.engine.spy.P6SpyDriver

spy配置文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
properties复制代码#3.2.1以上使用
modulelist=com.baomidou.mybatisplus.extension.p6spy.MybatisPlusLogFactory,com.p6spy.engine.outage.P6OutageFactory
#3.2.1以下使用或者不配置
#modulelist=com.p6spy.engine.logging.P6LogFactory,com.p6spy.engine.outage.P6OutageFactory
# 自定义日志打印
logMessageFormat=com.baomidou.mybatisplus.extension.p6spy.P6SpyLogger
#日志输出到控制台
appender=com.baomidou.mybatisplus.extension.p6spy.StdoutLogger
# 使用日志系统记录 sql
#appender=com.p6spy.engine.spy.appender.Slf4JLogger
# 设置 p6spy driver 代理
deregisterdrivers=true
# 取消JDBC URL前缀
useprefix=true
# 配置记录 Log 例外,可去掉的结果集有error,info,batch,debug,statement,commit,rollback,result,resultset.
excludecategories=info,debug,result,commit,resultset
# 日期格式
dateformat=yyyy-MM-dd HH:mm:ss
# 实际驱动可多个
#driverlist=org.h2.Driver
# 是否开启慢SQL记录
outagedetection=true
# 慢SQL记录标准 2 秒
outagedetectioninterval=2

我们看到通过简单配置控制台成功打印出运行sql

image.png

GitHub项目源码

本文转载自: 掘金

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

leetcode每日一题系列-公交路线 leetcode-8

发表于 2021-06-28

leetcode-815-公交路线

这是我参与更文挑战的第6天,活动详情查看: 更文挑战

[博客链接]

菜🐔的学习之路

掘金首页

[题目描述]

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
css复制代码给你一个数组 routes ,表示一系列公交线路,其中每个 routes[i] 表示一条公交线路,第 i 辆公交车将会在上面循环行驶。 


例如,路线 routes[0] = [1, 5, 7] 表示第 0 辆公交车会一直按序列 1 -> 5 -> 7 -> 1 -> 5 -> 7 -> 1
-> ... 这样的车站路线行驶。


现在从 source 车站出发(初始时不在公交车上),要前往 target 车站。 期间仅可乘坐公交车。

求出 最少乘坐的公交车数量 。如果不可能到达终点车站,返回 -1 。



示例 1:


输入:routes = [[1,2,7],[3,6,7]], source = 1, target = 6
输出:2
解释:最优策略是先乘坐第一辆公交车到达车站 7 , 然后换乘第二辆公交车到车站 6 。


示例 2:


输入:routes = [[7,12],[4,5,15],[6],[15,19],[9,12,13]], source = 15, target = 12
输出:-1




提示:


1 <= routes.length <= 500.
1 <= routes[i].length <= 105
routes[i] 中的所有值 互不相同
sum(routes[i].length) <= 105
0 <= routes[i][j] < 106
0 <= source, target < 106

Related Topics 广度优先搜索 数组 哈希表
👍 146 👎 0

[题目链接]

leetcode题目链接

[github地址]

代码链接

[思路介绍]

思路一:BFS

  • 分析题意可以很快的想到DFS或者BFS解决,因为本题只需要考虑选择的公交车线路数量,不需要考虑站点路线,所以用广度优先即可,最先遍历到的一定是最短的
  • 考虑题解的时候有如下问题:
    • 因为最开始不在公交车上且在1站台,需要考虑是否routes[0]包含1站台,当然这只是一个前提条件并不影响后续的解题过程,不失一般性的我们假设第一条线路一定包含起始站台
  • 首先确认链路关系,将所有线路和线路中的包含的站台存入map,key为线路索引,value为站台组成的set
  • 然后依次遍历所有map,当有线路包含source站台的时候存入deque作为BFS遍历的跟节点
  • 定义另一个map存入转移到当前线路的step,key为当前线路索引,value为step
  • 接下来就是常规的BFS解析了
  • 需要注意的是本题有两个边界情况
    • 起始和终点在一条线路上,直接返回0,虽然我觉得这个边界和题目描述的初始不在公交车上有冲突
    • 所有线路没有起点站台
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
java复制代码public int numBusesToDestination(int[][] routes, int source, int target) {

//确立所有线路的包含关系
Map<Integer, Set<Integer>> map = new HashMap<>();
for (int i = 0; i < routes.length; i++) {
Set<Integer> set = new HashSet<>();
for (int j = 0; j < routes[i].length; j++) {
set.add(routes[i][j]);
}
map.put(i, set);
}

//存储线路索引
Deque<Integer> deque = new LinkedList<>();
//记录到当前站点的线路数量
Map<Integer, Integer> res = new HashMap<>();
//确立初始站台
for (Integer i : map.keySet()) {
if (map.get(i).contains(source)) {
deque.add(i);
res.put(i, 1);
}
}
//corner case
if (deque.isEmpty()){
return -1;
}
if (target==source){
return 0;
}
while (!deque.isEmpty()) {
int temp = deque.poll();
int step = res.get(temp);
for (int i : map.get(temp)) {
if (i == target) {
return step;
}
for (int line : map.keySet()) {
//包含当前节点的线路全部加入到deque中继续广度优先遍历
if (res.containsKey(line)) {
continue;
}
if (map.get(line).contains(i)) {
deque.add(line);
res.put(line, step + 1);
}
}

}
}
return -1;

}

时间复杂度O(∑0nroutes+n2\sum_{0}^{n}routes+n^2∑0n​routes+n2)

n为线路数,求和公式为每条线路上站台的数量和

本文转载自: 掘金

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

Spring Boot 2x基础教程:使用Redis的发布

发表于 2021-06-28

通过前面一篇集中式缓存的使用教程,我们已经了解了Redis的核心功能:作为K、V存储的高性能缓存。

接下来我们会分几篇来继续讲讲Redis的一些其他强大用法!如果你对此感兴趣,一定要关注收藏我哦!

发布订阅模式

如果你看过之前我写的关于MQ的相关文章,那么对于发布订阅功能应该不会陌生。如果没看过,那也不要紧,这里先做一个简单介绍,已经了解的可以跳过直接看下一节内容。

什么是发布订阅模式?

在发布订阅模式中有个重要的角色,一个是发布者Publisher,另一个订阅者Subscriber。本质上来说,发布订阅模式就是一种生产者消费者模式,Publisher负责生产消息,而Subscriber则负责消费它所订阅的消息。这种模式被广泛的应用于软硬件的系统设计中。比如:配置中心的一个配置修改之后,就是通过发布订阅的方式传递给订阅这个配置的订阅者来实现自动刷新的。

不就是观察者模式吗?

看到这里,学过设计模式的同学可能很容易将它与设计模式中的观察者模式联系起来,你会觉得发布订阅模式中的两个概念与观察者模式中的两个概念似乎干的是一样的事情?所以:Publisher就是观察者模式中的Subject?Subscriber就是观察者模式中的Observer?

重要区别在哪里?

从这两种模式的角色任务来说确实是非常相似的,但从实现架构上来说有一个核心不同点!

我们通过下面的图示来理解,就很清晰了:

观察者模式

发布订阅模式

可以看到这里有一个非常大的区别就是:发布订阅模式在两个角色中间是一个中间角色来过渡的,发布者并不直接与订阅者产生交互。

回想一下生产者消费者模式,这个中间过渡区域对应的就是是缓冲区。因为这个缓冲区的存在,发布者与订阅者的工作就可以实现更大程度的解耦。发布者不会因为订阅者处理速度慢,而影响自己的发布任务,它只需要快速生产即可。而订阅者也不用太担心一时来不及处理,因为有缓冲区在,可以一点点排队来完成(也就是我们常说的“削峰填谷”效果)。

而我们所熟知的RabbitMQ、Kafka、RocketMQ这些中间件的本质其实就是实现发布订阅模式中的这个中间缓冲区。而Redis也提供了简单的发布订阅实现,当我们有一些简单需求的时候,也是可以一用的!如果你已经理解了这个概念,那么就进入下一节,一起来做个例子吧!

动手试一试

下面的动手任务,我们将在Spring Boot应用中,通过接口的方式实现一个消息发布者的角色,然后再写一个Service来实现消息的订阅(把接口传过来的消息内容打印处理)。

第一步:创建一个基础的Spring Boot应用,如果还不会点这里

第二步:pom.xml中加入必须的几个依赖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
xml复制代码<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>

第三步:创建一个接口,用来发送消息。

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
java复制代码@SpringBootApplication
public class Chapter55Application {

private static String CHANNEL = "didispace";

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

@RestController
static class RedisController {

private RedisTemplate<String, String> redisTemplate;

public RedisController(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}

@GetMapping("/publish")
public void publish(@RequestParam String message) {
// 发送消息
redisTemplate.convertAndSend(CHANNEL, message);
}

}

}

这里为了简单实现,公用CHANNEL名称字段,我都写在了应用主类里。

第四步:继续应用主类里实现消息订阅,打接收到的消息打印处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码    @Slf4j
@Service
static class MessageSubscriber {

public MessageSubscriber(RedisTemplate redisTemplate) {
RedisConnection redisConnection = redisTemplate.getConnectionFactory().getConnection();
redisConnection.subscribe(new MessageListener() {
@Override
public void onMessage(Message message, byte[] bytes) {
// 收到消息的处理逻辑
log.info("Receive message : " + message);
}
}, CHANNEL.getBytes(StandardCharsets.UTF_8));

}

}

第六步:验证结果

  1. 启动应用Spring Boot主类
  2. 通过curl或其他工具调用接口curl localhost:8080/publish?message=hello
  3. 观察控制台,可以看到打印了收到的message参数
1
yaml复制代码2021-06-19 16:22:30.935  INFO 34351 --- [ioEventLoop-4-2] .c.Chapter55Application$MessageSubscribe : Receive message : hello

好了,今天的内容到这里结束了。如果你对本系列教程《Spring Boot 2.x基础教程》感兴趣,可以点击直达!。学习过程中如遇困难,建议加入Spring技术交流群,参与交流与讨论,更好的学习与进步!

代码示例

本文的完整工程可以查看下面仓库中的chapter5-5目录:

  • Github:github.com/dyc87112/Sp…
  • Gitee:gitee.com/didispace/S…

如果您觉得本文不错,欢迎Star支持,您的关注是我坚持的动力!更多本系列免费教程连载「点击进入汇总目录」

欢迎关注我的公众号:程序猿DD,分享外面看不到的干货与思考!

本文转载自: 掘金

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

合肥某小公司面试题:Spring基础

发表于 2021-06-28

《对线面试官》系列目前已经连载25篇啦!有深度风趣的系列!

  • 【对线面试官】Java注解
  • 【对线面试官】Java泛型
  • 【对线面试官】 Java NIO
  • 【对线面试官】Java反射 && 动态代理
  • 【对线面试官】多线程基础
  • 【对线面试官】 CAS
  • 【对线面试官】synchronized
  • 【对线面试官】AQS&&ReentrantLock
  • 【对线面试官】线程池
  • 【对线面试官】ThreadLocal
  • 【对线面试官】CountDownLatch和CyclicBarrier
  • 【对线面试官】为什么需要Java内存模型?
  • 【对线面试官】List
  • 【对线面试官】Map
  • 【对线面试官】SpringMVC
  • 【对线面试官】Spring基础
  • 【对线面试官】SpringBean生命周期
  • 【对线面试官】Redis基础
  • 【对线面试官】Redis持久化
  • 【对线面试官】Kafka基础
  • 【对线面试官】使用Kafka会考虑什么问题?
  • 【对线面试官】MySQL索引
  • 【对线面试官】MySQL 事务&&锁机制&&MVCC
  • 【对线面试官】MySQL调优

文章以纯面试的角度去讲解,所以有很多的细节是未铺垫的。

鉴于很多同学反馈没看懂【对线面试官】系列,基础相关的知识我确实写过文章讲解过啦,但有的同学就是不爱去翻。

为了让大家有更好的体验,我把基础文章也找出来(重要的知识点我还整理过电子书,比如说像多线程、集合、Spring这种面试必考的早就已经转成PDF格式啦)

我把这些上传到网盘,你们有需要直接下载就好了。做到这份上了,不会还想白嫖吧?点赞和转发又不用钱。

链接:pan.baidu.com/s/1pQTuKBYs… 密码:3wom

欢迎关注我的微信公众号【Java3y】来聊聊Java面试

本文转载自: 掘金

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

《蹲坑也能进大厂》多线程系列-Lock源码分析

发表于 2021-06-27

这是我参与更文挑战的第 16 天,活动详情查看:更文挑战

作者:花Gie

微信公众号:Java开发零到壹

前言

多线程系列我们前面已经更新过多个章节,强烈建议小伙伴按照顺序学习:

《蹲坑也能进大厂》多线程系列文章目录

前面几篇我们学习了synchronized同步代码块,了解了java的锁概念,以及对应的原理和适用场景机制。在大多数情况下,内置锁都能很好的工作,但它在功能上存在一些局限性,例如无法实现非阻塞结构的加锁规则等。

正文

Lock定义

1
2
3
4
5
6
7
8
9
10
11
java复制代码public interface Lock {
void lock();
void lockInterruptibly() throws InterruptedException;

boolean tryLock();
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;

void unlock();

Condition newCondition();
}

获取锁的方法

Lock接口定义了四种获取锁的方式:

  • lock()
    • 阻塞式获取,在没有获取到锁时,当前线程将会休眠,不会参与线程调度,直到获取到锁为止,获取锁的过程中不响应中断。
  • lockInterruptibly()
    • 阻塞式获取,并且可中断,该方法将在以下两种情况之一发生的情况下抛出InterruptedException
      • 在调用该方法时,线程的中断标志位已经被设为true了
      • 在获取锁的过程中,线程被中断了,并且锁的获取实现会响应这个中断
    • 在InterruptedException抛出后,当前线程的中断标志位将会被清除
  • tryLock()
    • 非阻塞式获取,从名字中也可以看出,try就是试一试的意思,无论成功与否,该方法都是立即返回的
    • 相比前面两种阻塞式获取的方式,该方法是有返回值的,获取锁成功了则返回true,获取锁失败了则返回false
  • tryLock(long time, TimeUnit unit)
    • 带超时机制,并且可中断
    • 如果可以获取带锁,则立即返回true
    • 如果获取不到锁,则当前线程将会休眠,不会参与线程调度,直到以下三个条件之一被满足:
      • 当前线程获取到了锁
      • 其它线程中断了当前线程
      • 设定的超时时间到了
    • 该方法将在以下两种情况之一发生的情况下抛出InterruptedException
      • 在调用该方法时,线程的中断标志位已经被设为true了
      • 在获取锁的过程中,线程被中断了,并且锁的获取实现会响应这个中断
    • 在InterruptedException抛出后,当前线程的中断标志位将会被清除
    • 如果超时时间到了,当前线程还没有获得锁,则会直接返回false(注意,这里并没有抛出超时异常)

其实,tryLock(long time, TimeUnit unit)更像是阻塞式与非阻塞式的结合体,即在一定条件下(超时时间内,没有中断发生)阻塞,不满足这个条件则立即返回(非阻塞)。

这里把四种锁的获取方式总结如下:
锁的获取

锁的释放

相对于锁的获取,锁的释放的方法就简单的多,只有一个

1
csharp复制代码void unlock();

值得注意的是,只有拥有的锁的线程才能释放锁,并且,必须显式地释放锁,这一点和离开同步代码块就自动被释放的监视器锁是不同的。

Lock接口还定义了一个newCondition方法:

1
csharp复制代码Condition newCondition();

该方法将创建一个绑定在当前Lock对象上的Condition对象,这说明Condition对象和Lock对象是对应的,一个Lock对象可以创建多个Condition对象,它们是一个对多的关系。

Condition 接口

上面我们说道,Lock接口中定义了newCondition方法,它返回一个关联在当前Lock对象上的Condition对象,下面我们来看看这个Condition对象是个啥。

每一个新工具的出现总是为了解决一定的问题,Condition接口的出现也不例外。
如果说Lock接口的出现是为了拓展现有的监视器锁,那么Condition接口的出现就是为了拓展同步代码块中的wait, notify机制。

监视器锁的 wait/notify 机制的弊端

通常情况下,我们调用wait方法,主要是因为一定的条件没有满足,我们把需要满足的事件或条件称作条件谓词。

而另一方面,由前面几篇介绍synchronized原理的文章我们知道,所有调用了wait方法的线程,都会在同一个监视器锁的wait set中等待,这看上去很合理,但是却是该机制的短板所在——所有的线程都等待在同一个notify方法上(notify方法指notify()和notifyAll()两个方法,下同)。每一个调用wait方法的线程可能等待在不同的条件谓词上,但是有时候即使自己等待的条件并没有满足,线程也有可能被“别的线程的”notify方法唤醒,因为大家用的是同一个监视器锁。这就好比一个班上有几个重名的同学(使用相同的监视器锁),老师喊了这个名字(notify方法),结果这几个同学全都站起来了(等待在监视器锁上的线程都被唤醒了)。

这样以来,即使自己被唤醒后,抢到了监视器锁,发现其实条件还是不满足,还是得调用wait方法挂起,就导致了很多无意义的时间和CPU资源的浪费。

这一切的根源就在于我们在调用wait方法时没有办法来指明究竟是在等待什么样的条件谓词上,因此唤醒时,也不知道该唤醒谁,只能把所有的线程都唤醒了。

因此,最好的方式是,我们在挂起时就指明了在什么样的条件谓词上挂起,同时,在等待的事件发生后,只唤醒等待在这个事件上的线程,而实现了这个思路的就是Condition接口。

有了Condition接口,我们就可以在同一个锁上创建不同的唤醒条件,从而在一定条件谓词满足后,有针对性的唤醒特定的线程,而不是一股脑的将所有等待的线程都唤醒。

Condition的 await/signal 机制

既然前面说了Condition接口的出现是为了拓展现有的wait/notify机制,那我们就先来看看现有的wait/notify机制有哪些方法:

1
2
3
4
5
6
7
8
9
10
11
java复制代码public class Object {
public final void wait() throws InterruptedException {
wait(0);
}
public final native void wait(long timeout) throws InterruptedException;
public final void wait(long timeout, int nanos) throws InterruptedException {
// 这里省略方法的实现
}
public final native void notify();
public final native void notifyAll();
}

接下来我们再看看Condition接口有哪些方法:

1
2
3
4
5
6
7
8
9
10
11
java复制代码public interface Condition {
void await() throws InterruptedException;
long awaitNanos(long nanosTimeout) throws InterruptedException;
boolean await(long time, TimeUnit unit) throws InterruptedException;

void awaitUninterruptibly();
boolean awaitUntil(Date deadline) throws InterruptedException;

void signal();
void signalAll();
}

对比发现,这里存在明显的对应关系:

Object 方法 Condition 方法 区别
void wait() void await()
void wait(long timeout) long awaitNanos(long nanosTimeout) 时间单位,返回值
void wait(long timeout, int nanos) boolean await(long time, TimeUnit unit) 时间单位,参数类型,返回值
void notify() void signal()
void notifyAll() void signalAll()
- void awaitUninterruptibly() Condition独有
- boolean awaitUntil(Date deadline) Condition独有

它们在接口的规范上都是差不多的,只不过wait/notify机制针对的是所有在监视器锁的wait set中的线程,而await/signal机制针对的是所有等待在该Condition上的线程。

这里多说一句,在接口的规范中,wait(long timeout)的时间单位是毫秒(milliseconds), 而awaitNanos(long nanosTimeout)的时间单位是纳秒(nanoseconds), 就这一点而言,awaitNanos这个方法名其实语义上更清晰,并且相对于wait(long timeout, int nanos)这个略显鸡肋的方法,await(long time, TimeUnit unit)这个方法就显得更加直观和有效。

另外一点值得注意的是,awaitNanos(long nanosTimeout)是有返回值的,它返回了剩余等待的时间;await(long time, TimeUnit unit)也是有返回值的,如果该方法是因为超时时间到了而返回的,则该方法返回false, 否则返回true。

大家有没有觉的奇怪,同样是带超时时间的等待,为什么wait方式没有返回值,await方式有返回值呢。
存在即合理,既然多加了返回值,自然是有它的用意,那么这个多加的返回值有什么用呢?

我们知道,当一个线程从带有超时时间的wait/await方法返回时,必然是发生了以下4种情况之一:

  1. 其他线程调用了notify/signal方法,并且当前线程恰好是被选中来唤醒的那一个
  2. 其他线程调用了notifyAll/signalAll方法
  3. 其他线程中断了当前线程
  4. 超时时间到了

其中,第三条会抛出InterruptedException,是比较容易分辨的;除去这个,当wait方法返回后,我们其实无法区分它是因为超时时间到了返回了,还是被notify返回的。但是对于await方法,因为它是有返回值的,我们就能够通过返回值来区分:

  • 如果awaitNanos(long nanosTimeout)的返回值大于0,说明超时时间还没到,则该返回是由signal行为导致的
  • 如果await(long time, TimeUnit unit)返回true, 说明超时时间还没到,则该返回是由signal行为导致的

源码的注释也说了,await(long time, TimeUnit unit)相当于调用awaitNanos(unit.toNanos(time)) > 0

所以,它们的返回值能够帮助我们弄清楚方法返回的原因。

Condition接口中还有两个在Object中找不到对应的方法:

1
2
java复制代码void awaitUninterruptibly();
boolean awaitUntil(Date deadline) throws InterruptedException;

前面说的所有的wait/await方法,它们方法的签名中都抛出了InterruptedException,说明他们在等待的过程中都是响应中断的,awaitUninterruptibly方法从名字中就可以看出,它在等待锁的过程中是不响应中断的,所以没有InterruptedException抛出。也就是说,它会一直阻塞,直到signal/signalAll被调用。如果在这过程中线程被中断了,它并不响应这个中断,只是在该方法返回的时候,该线程的中断标志位将是true, 调用者可以检测这个中断标志位以辅助判断在等待过程中是否发生了中断,以此决定要不要做额外的处理。

boolean awaitUntil(Date deadline)和boolean await(long time, TimeUnit unit) 其实作用是差不多的,返回值代表的含义也一样,只不过一个是相对时间,一个是绝对时间,awaitUntil方法的参数是Date,表示了一个绝对的时间,即截止日期,在这个日期之前,该方法会一直等待,除非被signal或者被中断。

总结

至此,Lock接口和Condition接口我们就分析完了。接下来会对ReentrantLock、CountDownLatch、Semaphore等源码源进行分析。

点关注,防走丢

以上就是本期全部内容,如有纰漏之处,请留言指教,非常感谢。我是花GieGie ,有问题大家随时留言讨论 ,我们下期见🦮。

文章持续更新,可以微信搜一搜 Java开发零到壹 第一时间阅读,并且可以获取面试资料学习视频等,有兴趣的小伙伴欢迎关注,一起学习,一起哈🐮🥃。

原创不易,你怎忍心白嫖,如果你觉得这篇文章对你有点用的话,感谢老铁为本文点个赞、评论或转发一下,因为这将是我输出更多优质文章的动力,感谢!

本文转载自: 掘金

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

测试平台系列(9) 与前端联调注册/登录接口(part 2)

发表于 2021-06-27

与前端联调注册/登录接口(part 2)

回顾

上篇我们说啥来着,噢对说要跟前端联调来着。这期呢,咱们就开始着手写这块内容。

这里如果前端不太擅长的呢,代码你就别仔细看了,因为你肯定也不太理解(虽然我还是会讲讲)。

你只需要搬个小板凳,看看具体解决了哪些问题就行,不需要想着怎么去写前端代码,直接copy一下就完事了!(好家伙,我直呼好家伙!)

前端IDE配置

这里我推荐Webstorm,至于vscode本人确实不太感冒,虽然会轻量级一点。(我就是喜欢jetbrains全家桶)

如果没有的话,可以用Pycharm下载Webstorm插件进行开发。

建立配置文件

首先我们第一步先建立配置文件: pityWeb\src\consts\config.js

1
2
3
4
5
6
7
8
JavaScript复制代码export const CONFIG = {
URL: 'http://localhost:7777',
ROLE: {
0: 'user',
1: 'admin',
2: 'superAdmin'
}
}

存储我们的后端地址和用户角色信息。

查看Antd pro的登录model(pityWeb\src\models\login.js)

可以看到默认采用的fakeAccountLogin方法(这里被我注释掉了),我们找到他:

改造登录接口(pityWeb\src\services\login.js)

代码很简单,由于antd pro给我们封装好了基本的Request方法,所以我们可以直接按部就班,依葫芦画瓢写出请求auth/login的程序,完整代码:

1
2
3
4
5
6
7
8
JavaScript复制代码import { CONFIG } from '@/consts/config';

export async function login(params) {
return request(`${CONFIG.URL}/auth/login`, {
method: 'POST',
data: params
})
}

修改用户的登录状态

仔细看model中的login方法,它做了2步:

  1. 登录(mock)
  2. 将登录成功/失败的信息传入changeLoginStatus方法

我们看看changeLoginStatus方法做了什么:

  1. 先设置用户的权限,这里我把ROLE已经带入进来
  2. 设置当前状态: ok代表登录成功 error代表未成功,由于咱们用的不是status状态判断用户是否登录成功,所以需要修改为三元表达式: payload.code === 0 ? 'ok': 'error'

上述具体返回信息可以从下图中看到

这里代码我们进行相应调整:

1
2
3
4
5
JavaScript复制代码    changeLoginStatus(state, { payload }) {
// setAuthority(payload.currentAuthority);
setAuthority(CONFIG.ROLE[payload.user.role]);
return { ...state, status: payload.code === 0 ? 'ok': 'error', type: payload.type };
},

注意这里也从mock替换成了真实接口

尝试一下

提示网络错误

别慌,我们F12(或者右键打开开发者工具)可以看到:

没错,我们遇到了跨域问题。

跨域问题具体可以去百度一下,这里简单介绍一下,是说浏览器出于安全考虑,对于前后端域名不一致的情况下,会默认进行拦截,阻止你们进行交互。这多见于前后端分离的项目中出现。

可以从前端也可以从后端的角度来处理,我们为了方便,直接改造我们的Flask。

使Flask支持跨域

我们需要先安装一个包: pip3 install flask-cors

然后进行如下改造:

pity/app/init.py

注意这个是后端的项目哈,不要搞错了,前后端分离就是前端也搞后端也搞,需要切换。

1
2
3
4
5
6
7
8
9
python复制代码from flask import Flask
from flask_cors import CORS

from config import Config

pity = Flask(__name__)
CORS(pity, supports_credentials=True)

pity.config.from_object(Config)

贴心的我还是贴出了源代码。

再次尝试

重启下后端服务哦记得

输入错误的用户名密码,可以看到登录按钮转了一圈又回到了原点。但是登录接口又是正常响应的:

仔细查看登录块的代码,原来是没有输出错误信息。我们继续进行改造:

加个else即可

message.error可以弹出错误信息: 用户名或密码错误

目的达到了,接下来我们来试试正确的用户名和密码:

可以看到提示成功后又报错了

我们去找到api/currentUser接口的调用地方:

可以看到是这个方法在作祟,因为antd pro会mock一个用户,它有姓名等信息,但这些我们需要真实的,所以需要给他进行一次改造:

改造获取当前用户信息

还记得我们的登录接口返回的数据吗?我们每次登录后,把它写入浏览器缓存中,这样当用户登录以后,我们从浏览器缓存取出用户信息即可。

  1. 先将用户信息和token设置到缓存

localStorage可以操作浏览器缓存,但是只接受string和string的键值对,所以我们需要把user对象序列化为string。

  1. 从缓存中取出数据

记得数据需要反序列化

  1. 修改登录判断条件,这个坑找了我很久,因为咱们的字段是id不是userId,所以我刚才卡在一直登录了自动被退出:

保存后再次尝试

一次点亮

好了,今天的内容就到这了。前端代码已上传~

后端代码地址: github.com/wuranxu/pit…

前端代码地址: github.com/wuranxu/pit…

觉得有用的话可以帮忙点个Star哦QAQ

本文转载自: 掘金

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

比B+Tree更快的查询结构!!!

发表于 2021-06-27

导读

我们都知道MySQL中的B+Tree索引结构,对于根据某个条件查找记录是非常快的。那么,在不断追求极致的驱动下,你有没有想过MySQL会不会有比B+Tree更快的数据结构,来加速查找记录的性能呢?答案是有的,MySQL为了让我们更快地获取自己想查找的记录,在InnoDB中,将查询频繁的条件和索引树结果做了一个Hash映射,这样,一个查询就不需要每次搜索B+Tree去定位结果了,这个Hash映射就叫做AHI,全称Adaptive Hash Index,自适应哈希索引。

一听这名字,你或许已经猜出个一二了。没错!它其实就是一个HashTable,在大学学习《数据结构》的时候,我们都知道Hash Table在查找其中的一个节点的数据是非常快的,算法时间复杂度O(1),所以,相比B+Tree而言,它的查找性能一定是更快的。

但是,有个问题:为什么这个Hash Table叫做自适应哈希索引呢,这个“自适应”是什么概念?

今天,小k就从下面这个案例开始,详细讲解AHI,逐步带你明白AHI这个自适应是怎么一回事?

假设我们交友平台有个功能:筛选出年龄在15到23之间的用户。那么,通常我们会用下面这条SQL实现:

1
sql复制代码SELECT id, age, sex FROM user WHERE age >= 15 AND age <= 23

同时,我们给user表建了一个索引index_age_sex(age,sex),那么,现在我们再来看看这条SQL是如何使用AHI的?

AHI

既然AHI也是一个HashTable,那首先,你肯定会关心,它的Key是什么样的,Value又是什么样的?那么,我们就先来看看AHI的Key和Value。

我们看到《导读》中的语句的查询条件为age >= 15 AND age <= 23,按照上面我说的AHI的含义:将某一个查询条件和其结果做了一个Hash映射,那么,我们想象中的这个HashTable就类似下面这样:

image.png

图中上面的age >= 15 AND age <= 23代表查询条件,也就Key,下面为索引index_age_sex中满足查询条件的4条记录,也就是Value。其中,每条记录的结构为[age,sex,id]。

Key

但是,从上面的图来看,如果查询条件的字段名很长,那么,Key存储的空间也就变得很大,对于MySQL这种内存敏感的系统而言肯定是不能接受的,因此,MySQL设计了下面的这种结构来存放Key:

image.png

如上图为查找索引index_age_sex时,查询条件为age >= 15 AND age <= 23的结构:

  • search_info::n_fields:MySQL使用n_fields来表达查询索引使用到的字段,图中1表示查询条件使用到了索引index_age_sex中的第一个字段,即age。(PS:如果n_fields=2表示查询条件使用到了索引index_age_sex中的age和sex两个字段)。这样做的好处是我们在内存中存储数字就可以表达一个查询条件使用到的索引字段了,更节省存储空间。
  • dtuple_t:由于查询条件是一个范围查询,所以,MySQL使用两个dtuple_t结构来表示条件中的两个边界值。如上图,右边第一个dtuple_t中的15表示查询条件左边界值15,第二个dtuple_t中的23表示查询条件右边界值23。

最终,MySQL通过两个search_info::n_fields和dtuple_t的组合来表达查询条件age >= 15 AND age <= 23。如上图中的两个箭头表示的就是这种组合。

讲完Key,我们再来看看MySQL是如何设计HashTable的Value的?

Value

当然,如果按照上面的HashTable的结构,我们肯定认为查询条件age >= 15 AND age <= 23,其在HashTable中的Value就是上面图中1-1中的下面的记录。但是,我们现在来看下面一个场景:

假设现在我将查询条件变为age >= 15 AND age < 16,那么,这个HashTable就变成这样:

image.png

图中上面的age >= 15 AND age < 16代表查询条件,下面为索引index_age_sex中满足查询条件的2条记录,其中,每条记录的结构为[age,sex,id]。

通过对比1-1和1-2-1上面两张图,我们发现2个查询条件对应的查询结果中有重复记录15,0,2和15,0,5。现在只有2个查询条件会出现重复记录,那么,如果将来有几十个,甚至上百个查询条件都包含重复记录,那么,如果每个条件和对应结果都存一份HashTable,是不是在存储空间上就很浪费了?

因此,为了节省查询结果的存储空间,我们可以将上面2个查询HashTable合并,变成下面这样的结构:

image.png

图中MySQL将条件age >= 15 AND age <= 23和条件age >= 15 AND age < 16对应的记录合并为4条:

  • 条件age >= 15 AND age <= 23映射前两条记录。如上图绿色箭头。
  • 条件age >= 15 AND age < 16映射后两条记录。如上图红色箭头。

但是,在讲述Key的结构时,我说了MySQL真实设计的Key结构如图1-1-1,对应到图1-2-2,显然图1-2-2中的Key不是MySQL真实存储的结构。那么,结合1-2-2的HashTable概念图,我们来看下MySQL到底是如何设计AHI的Key和Value的映射的?

image.png

如上图,是MySQL完整的AHI的存储结构。其中,Value上面的部分就是Key,上面我已经讲解过Key的结构,这里我就不再重述了。我们主要看下Value部分:

  • Cell:在AHI中叫做hash_cell_t,hash_cell_tuple的缩写。也就是图中的cell这部分。它是一个数组,如上图是一个包含2个cell的数组。每个查询条件的边界值通过hash运算可以定位到某一个cell。比如:
+ 图中条件`age >= 15 AND age <= 23`中的左边界值15,通过hash运算定位到了第1个cell。
+ 图中条件`age >= 15 AND age < 16`中的左边界值15,通过hash运算也定位到了第1个cell。
+ 图中条件`age >= 15 AND age <= 23`中的右边界值23,通过hash运算也定位到了第1个cell。
+ 图中条件`age >= 15 AND age < 16`中的右边界值16,通过hash运算定位到了第2个cell。
  • Node:在AHI中叫做ha_node_t,hash_node_tuple的缩写。一个cell下可以包含多个node,也就是说多个查询条件边界值通过hash运算可以定位到一个cell,该cell下就存放了每个边界值对应的node记录,该cell下的每个node还组成了一个单向链表。
+ 比如,图中查询条件`age >= 15 AND age <= 23`中的左边界值15,通过hash运算,定位到第1个cell,该cell下的第1个node保存了15对应的记录`(15,0,2)`相关信息。
+ 同理,图中查询条件`age >= 15 AND age <= 23`中的右边界值23,通过hash运算,也定位到第1个cell,该cell下的第2个node保存了23对应的记录相关信息。
+ 同理,图中查询条件`age >= 15 AND age < 16`中的右边界值16,通过hash运算,定位到第2个cell,该cell下的第1个node保存了16对应的记录`(16,0,3)`相关信息。
+ 这两个node组成了一个单向链表。**Node**核心元素主要是3个:


+ **block**:存储hash映射的结果对应的相关信息。其中,核心元素包含`left_side`和`page`。


    - 比如,图中左边block里的`curr_left_side = true`,表示该node中的记录`<15,0,2>`是查询条件`age >= 15 AND age <= 23`和`age >= 15 AND age < 16`的最左边界记录。
    - 比如,图中左边block中的`page(10)`表示该node中的记录`<15,0,2>`在索引树`index_age_sex`的10号叶子节点内。
    - 比如,图中右边block里的`curr_left_side = false`,表示该node中的记录`<16,0,3>`是查询条件`age >= 15 AND age < 16`的最右边界记录。
    - 比如,图中右边block中的`page(20)`表示该node中的记录`<16,0,3>`在索引树`index_age_sex`的20号叶子节点内。
+ **data**:hash映射的结果。


    - 比如,图中第1个node中的`<15,0,2>`为条件`age >= 15 AND age <= 23`和`age >= 15 AND age < 16`左边界值15对应的记录。
    - 比如,图中第3个node中的`<16,0,3>`为条件`age >= 15 AND age < 16`右边界值16对应的记录。

现在我们知道了AHI的完整结构,通过这个结构,我们发现MySQL没有直接将查询条件和结果做了映射,而是通过cell将条件和结果关联起来,这样做的好处就是相同条件边界值对应的node在内存中可以共享,节省了存储空间。

查询AHI

说了那么多,是不是发现好像这个AHI结构并没有完整存储查询条件对应的所有结果记录,(毕竟我要的可是4条满足条件的记录哦!),那MySQL又是怎么通过AHI找到所有满足条件的记录呢?下面我们就以age >= 15 AND age < 16这个查询条件为例,来看一下这个查找过程:

image.png

  1. 根据条件左边界值15,做hash运算,计算得到一个fold值,通过该值定位到第1个cell。
  2. 遍历第1个cell下的node,找到第1个node为边界值15对应的node。
  3. 根据第1个node找到对应的记录<15,0,2>、page(10)和curr_left_side=true。
  4. 根据上一步得到的page编号10和记录<15,0,2>,在索引树index_age_sex中的10号叶子节点中找匹配<15,0,2>的记录<15,0,2>。
  5. 根据条件右边界值16,做hash运算,计算得到一个fold值,通过该值定位到第2个cell。
  6. 遍历第2个cell下的node,找到第1个node为边界值16对应的node。
  7. 根据第1个node找到对应的记录<16,0,3>、page(11)和curr_left_side=false。
  8. 根据上一步得到的page编号11和记录<16,0,3>,在索引树index_age_sex的11号叶子节点中找到匹配<16,0,3>的记录<16,0,3>。
  9. 由于第3步中记录<15,0,2>所在node中curr_left_side=true,说明记录<15,0,2>为查询条件最左记录,因此,从索引树index_age_sex的10号叶子节点内<15,0,2>记录开始,向后遍历其他记录。
  10. 由于第7步中记录<16,0,3>所在node中curr_left_side=false,说明记录<16,0,3>为查询条件最右记录,故上一步遍历到记录<16,0,3>结束。
  11. 最终,在索引树index_age_sex中找到所有满足条件age >= 15 AND age < 16的记录。

其中,第4,8 ~ 11步的细节过程,可以参考文章《InnoDB是顺序查找B+Tree叶子节点的吗?》

构建AHI时机

现在我们知道了MySQL如何通过AHI找到满足条件的记录了,那么,这个AHI又是在什么时候创建的,如何创建的呢?

在《导读》中我讲过,MySQL对使用频繁的查询条件才构建AHI,即条件与结果的映射关系。因此,我们就要看看MySQL是如何判断这个查询条件是否频繁使用的?

为了统计一个条件使用的频率,MySQL设计了下面这样一种结构。

image.png

是不是有点眼熟?其实,图中search_info就是查询信息的结构,在图1-1-1中,我讲了search_info中的一个属性n_fields,现在,我再讲另一个属性hash_analysis。

当一次查询成功后,MySQL通过累加该属性,记录该次查询成功的次数。比如,初始hash_analysis=0,那么,条件age >= 15 AND age < 16查询成功一次,hash_analysis + 1 = 1,再成功一次,hash_analysis + 1 = 2,依次类推,成功多少次,hash_analysis就是多少。

当hash_analysis值超过17时,MySQL就会对该查询构建AHI。

但是,查询成功,就一定能够构建AHI吗?答案是不一定!我们来看下面这个场景:

1
sql复制代码SELECT age, sex FROM user WHERE age >= 15 AND age <= 18

上面这条语句,MySQL在索引index_age_sex中的叶子节点找到满足条件的记录为下面4条:

<15,0,2>、<16,0,3>、<18,0,4>、<18,0,5>

这时候,我们再看下这个条件查找AHI的过程:

image.png

  1. 根据条件左边界值15,做hash运算,计算得到一个fold值,通过该值定位到第1个cell。
  2. 遍历第1个cell下的node,找到第1个node为边界值15对应的node。
  3. 根据得到的node找到对应的记录<15,0>、page(10)和curr_left_side=true。
  4. 根据上一步得到的page编号10和记录,在索引树index_age_sex中10号叶子节点中找到匹配node记录<15,0>的第一条记录<15,0,2>。
  5. 根据条件右边界值18,做hash运算,计算得到一个fold值,通过该值定位到第2个cell。
  6. 遍历第2个cell下的node,找到第1个node为边界值18对应的node。
  7. 根据得到的node找到对应的记录<18,0>、page(11)和curr_left_side=false。
  8. 根据上一步得到的page编号11和记录,在索引树index_age_sex11号叶子节点中找到匹配node记录<18,0>的第一条记录<18,0,4>。
  9. 由于第3步中记录<15,0>所在node中curr_left_side=true,说明记录<15,0,2>为查询条件最左记录,因此,从索引树index_age_sex的10号叶子节点内<15,0,2>记录开始,向后遍历其他记录。
  10. 由于第7步中记录<18,0>所在node中curr_left_side=false,说明记录<18,0,4>为查询条件最右记录,故上一步遍历到记录<18,0,4>结束。

其中,第4,8 ~ 10步的细节过程,可以参考文章《InnoDB是顺序查找B+Tree叶子节点的吗?》

从上面的过程,我们发现一个问题:明明11号叶子节点中的记录<18,0,5>也满足条件age >= 15 AND age <= 18,但是,AHI查询却忽略这条记录。如上图,虚线标出的记录。PS:上面第9步,现在就2条件满足右边界值18的记录,如果满足的记录超过1k、1w条呢?让MySQL遍历1k、1w次叶子节点记录来定位最大记录嘛?这个性能可想而知。。。

因此,我们发现这类查询是不能支持AHI的,我们不能简单地认为只要查询成功,就等于可以构建AHI。

为此,MySQL在search_info中引入了一个新的属性,我们来看下:

image.png

如上图中的n_hash_potential就是这个新属性,它表示一次查询潜在可以成功构建AHI的次数。用它来解决上面那个场景的问题:

只有查询得到的结果中,最大的记录中的select字段值(比如:select age,sex)唯一,n_hash_potential才会累加。

这样一来,MySQL就在真正构建AHI之前做了两次拦截:

  • 通过hash_analysis将该属性值小于17的查询拦截,只有该属性值大于等17,这次查询才能构建AHI
  • 如果hash_analysis大于等于17,那么,再检查n_hash_potential属性,如果该属性值小于100,查询拦截,反之,这次查询才能构建AHI

那么,下一个问题来了:既然我都已经知道上面那个场景是不可能构建AHI的,我为什么还要让查询处理进入上面两次拦截检查呢?

因此,为了避免进入上面的拦截检查,MySQL又在search_info中引入了一个属性:

image.png

图中last_hash_succ属性,它表示上一次是否成功构建AHI。

有了这个属性,MySQL只要发现上面这个场景压根er不能构建AHI,因此,直接就设置last_hash_succ=false,那么,在下次相同查询进来后,直接发现last_hash_succ=false,就不再进行后面两次的拦截检查。

通过上面的分析,我们就得出了一次查询触发AHI构建的检查过程:

  1. 如果last_hash_succ=false,该查询不能构建AHI,反之进入下一步检查
  2. 如果hash_analysis < 17,该查询不能构建AHI,反之进入下一步检查
  3. 如果n_hash_potential < 100,该查询不能构建AHI,反之可以构建AHI

构建AHI

讲完AHI构建的触发条件,我们最后来看看MySQL是如何构建AHI的?

通过《查询AHI》部分的讲解,我们知道查询AHI的过程中,AHI中的Node中包含几个核心元素block、left_side和page,因此,我们只要知道这几个核心元素是如何构建的,也就能够描述清楚AHI的构建过程了。

我以下面这条语句为例,看下AHI构建的过程:

1
sql复制代码SELECT id, age, sex FROM user WHERE age >= 15 AND age <= 18

image.png

关注图中红线部分:

  1. 根据条件左边界值15,在索引树index_age_sex中的10号叶子节点中找到满足边界值的第一条记录<15,0,2>。
  2. 由于找到满足左边界值15的记录只有一条,因此,MySQL将up_match + 1 = 1,表示只有一条记录满足左边界值。由于up_match > low_match,因此,search_info中的left_side设置为true。
  3. 根据条件左边界值15,对其做hash运算,定位到AHI中的第1个cell。
  4. 发现cell中没有node节点,创建一个node。即图中灰色的node节点。
  5. 在node中创建一个block。如上图浅蓝色的block。
  6. 将索引树index_age_sex中的10号叶子节点信息写入block中的page属性。
  7. 将第2步得到的left_side写入block中的curr_left_side。
  8. 将第1步得到的记录<15,0,2>写入node。
  9. 同理,条件右边界值18构建AHI的过程相同。

AHI锁

了解完AHI的构建过程后,我们进一步会想,如果并发构建AHI,会出现node覆盖的问题。因此,为了解决这个问题,MySQL就必须给AHI加一把锁,避免并发构建时产生node覆盖的问题。

当然,我们不能给整个AHI加全局锁吧,因为这样会非常影响查询的性能,因此,MySQL是这样设计锁的。

image.png

MySQL在启动时,从innodb_buffer_pool中划分出若干个Hash Table,作为AHI,图中,我画了2个HashTable:HashTable[0]和HashTable[1]。假设MySQL通过4个查询条件hash运算得到4个fold,如上图4个fold值分别为1、2、9和17。

那么,MySQL对AHI加锁的方式为fold % 8取模:

  • 1和9取模后,得到0,因此,这两个fold对应的查询在HashTable[0]中构建AHI,同时,加同一把锁Lock0。
  • 2和17取模后,得到1,因此,这两个fold对应的查询在HashTable[1]中构建AHI,同时,加同一把锁Lock1。

通过这种方式,MySQL就可以将锁分散加在不同的HashTable上,尽可能减少并发导致的HashTable构建锁死造成的性能问题。

AHI调优

MySQL是默认开启AHI的,既然我们知道MySQL通过fold取模将AHI锁打散到多个HashTable上,也就意味着打散后的HashTable越多,AHI锁就打得更散,锁的粒度就更细,并发查询后构建AHI的性能就更好。

因此,MySQL给我们留了一个参数,用来将锁粒度打得更细,这个参数叫做innodb_adaptive_hash_index_parts:HashTable分片个数,默认为8。

我们只需要执行下面的语句就可以调大这个参数,将锁粒度打散得更细:

1
sql复制代码set global innodb_adaptive_hash_index_parts=16;

小结

本章中,小k详细讲解AHI的结构、查询、构建、加锁等原理,同时,提供了AHI参数调优的方法。

现在回答文章开头的问题:为什么MySQL把这个HashTable叫做自适应哈希索引呢?

通过AHI构建的过程,我们发现,多个查询构建cell中的node,是可以变大或缩小的,正是这个原因,MySQL才把这样一个HashTable叫做AHI,即自适应哈希索引。

思考题

最后留一个思考题:在文中《构建AHI》部分中,我有提到up_match和low_match属性,我们明明可以通过查询条件是>或<来判断left_side为true还是false,为什么还要通过up_match和low_match来判断呢?

提示:结合索引多列查询场景思考一下。

最后,小k努力把艰深的技术讲透讲俗,哪怕你不是这个领域的,也希望你有所收获,如果你觉得文章还不错,记得点赞、关注哦~~

如果有看不懂的地方,欢迎在评论区提问哦~

本文转载自: 掘金

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

Flask-SQLAlchemy简单封装(三) 前言 方案

发表于 2021-06-27

这是我参与更文挑战的第27天,活动详情查看: 更文挑战

前言

上文完成了CRUD方法的全部封装

也简单介绍了json和Flask-SQLAlchemy查询结果对象之间的相互转换

但其实还存在一些小小的问题:

  1. 如何返回json结果
  2. 日期格式如何统一
  3. 分页查询的返回结果不优雅

本文将会详细解答这三个问题

方案

如何返回json结果

在Flask中, 为我们提供了一个名为jsonify的函数, 用于将数据内容转换为json字符串

所以我们的接口大致可以写成如下样式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
python复制代码from flask import request, jsonify
from flask_restx import Namespace, Resource

from .model import User

auth = Namespace('auth', '认证管理')

@auth.route('/login')
class Login(Resource):
def post(self):
params = request.get_json()
user = User.get_by_username(params['username'])
if not check_password_hash(user.password, data['password']):
raise BusinessException('密码错误')
return jsonify(user)

日期格式如何统一

按照一般的思路, 我们都会将后台的日期转换为时间戳传递给前端, 有两种简单的方案:

  1. 保存为时间戳
  2. 手动进行转换处理

第一种显然是不显示的, 在数据库中提供了日期字段, 保存为时间戳有点矫枉过正了

第二种显然是理论上正确的, 但不需要手动这两个字

我们显然是希望在每次转换json的过程中, 代码可以自动处理日期格式化的问题, 比如Java中的某些注解

其实Flask为我们提供了支持, 即自定义json编码器:

1
2
3
4
5
6
7
python复制代码from flask import Flask

from .encoders import MyJSONEncoder


app = Flask(__name__)
app.json_encoder = MyJSONEncoder

完成json编码器的替换后, 就来看看我们的编码器是如何处理时间的:

1
2
3
4
5
6
7
8
9
10
11
python复制代码from datetime import datetime

from flask.json import JSONEncoder


class MyJSONEncoder(JSONEncoder):
def default(self, o):
if isinstance(o, datetime):
return str(round(o.timestamp()))
else:
return super().default(o)

是不是兄弟们一下就有了很多思路了呢?

分页查询的返回结果不优雅

json编码器都可以自定义, 这个问题会难道我们吗?

其实在分页中, 比较难处理的就是如何将结果对象转换为字典, 且将不能序列化的query属性去掉

知道如何修改后, 我们可以继续调整json编码器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
python复制代码from datetime import datetime

from flask.json import JSONEncoder
from flask_sqlalchemy import Pagination


class KoalaJSONEncoder(JSONEncoder):
def default(self, o):
if isinstance(o, datetime):
return str(round(o.timestamp()))
if isinstance(o, Pagination):
temp = o.__dict__
del temp['query']
temp['pages'] = o.pages
return temp
else:
return super().default(o)

总结

至此为止, 对于Flask-SQLAlchemy的封装已基本完成

当然, 笔者在后续还对这些进行了更加深度一些的封装, 之后有时间会更详细的为各位新人兄弟们进行讲解

本文转载自: 掘金

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

数仓建模分层理论

发表于 2021-06-27

什么是数仓

从字面上来看,数据仓库就是一个存放数据的仓库,它里面存放了各种各样的数据,而这些数据需要按照一些结构、规则来组织和存放。这里我们会遇到一个问题就是同样是存放数据的仓库,那数据库和数据仓库是一样的吗?

数据库 VS 数据仓库

数据库就是我们常用的关系型数据库(MySQL、Oracle、PostgreSQL…),还有什么非关系型数据库,它主要存放业务数据,那数据仓库有有些什么数据呢?说到他们的区别,我们一般会提到OLTP和OLAP。

  • OLTP:on-line transaction processing,联机事务处理,主要是业务数据,需要考虑高并发、考虑事务
  • OLAP:On-Line Analytical Processing,联机分析处理,重点主要是面向分析,会产生大量的查询,一般很少涉及增删改

图片

他们的区别,面试时也会提到,主要从几个点谈谈就行。

数据仓库其实是一套体系,他不是一门什么技术,而是整合了很多已有的技术,来更好地组织和管理数据。传统数仓的话,主要是基于关系型数据库,后面还有一些分布式的数据库像Greenplum,还有很多公司会提供基于硬件的一整套解决方案。在传统数仓开发时,由于硬件的性能有限,所以有很多的要求,而随着硬件价格的下降、云服务器的广泛使用,还有大数据技术的成熟发展,数仓的很多场景都变了,有些规则都不需要去严格遵守了,这样也可以剩下很多的成本。

再往前几年,数仓这个东西是有点儿神秘的,感觉很高大上,而现在,起码在互联网公司来说,谁都知道数仓,谁都知道数据平台,谁都可以来说两句,已经大众化了。记得以前面数仓的话,总有几个必备的面试题:

  • 什么是数仓?
  • 数仓的几个特点是什么?
  • 什么是OLAP?什么是OLTP?区别是什么?
  • 拉链表是什么?怎么实现拉链表?
  • 同步又哪几种方式?
  • 为什么要做增量?怎么做增量?
  • 什么是ETL?

就目前互联网数仓这一岗位,感觉更加偏重业务+建模思想,面试不太好考察这些内容的,去年招聘的时候,就是问些基本问题,聊聊以往主要的工作内容,还会问问SQL题,真的想了解下建模的话,还是找本书借鉴性的看看,还是很有益处的。

传统数仓与互联网数仓

图片

图片

在传统数据平台要背后有一个完整数据仓库团队去服务业务方,业务方嗷嗷待哺的等待被动方式去满足。中低层数据基本不会对业务方开放,所以不管数据模型采用何种建模方式,主要满足当时数据架构规划即可。

互联网业务的快速发展使得大家已经从经营、分析的诉求重点转为数据化的精细运营上,如何做好精细化运营问题上来,当资源不够时用户就叫喊,甚至有的业务方会挽起袖子来自己参与到从数据整理、加工、分析阶段。

此时呢,原有建设数据平台的多个角色(数据开发、模型设计)可能转为对其它非专业使用数据方,做培训、咨询与落地,写更加适合当前企业数据应用的一些方案与开发些数据产品等。

在互联网数据平台由于数据平台变为自由开放,大家使用数据的人也参与到数据的体系建设时,基本会因为不专业性,导致数据质量问题、重复对分数据浪费存储与资源、口径多样化、编码不统一、命名问题等等原因。数据质量逐渐变成一个特别突出的问题。

图片

数仓架构

现在说数仓,更多的会和数据平台或者基础架构搭上,已经融合到整个基础设施的搭建上。这里呢,我们不说Hadoop各种组件之间的配合,我们就简单说下数仓的分层架构。

说到数仓建模,就得提下经典的2套理论:

范式建模

Inmon提出的集线器的自上而下(EDW-DM)的数据仓库架构。

维度建模

Kimball提出的总线式的自下而上(DM-DW)的数据仓库架构。

数仓的建模或者分层,其实都是为了更好的去组织、管理、维护数据,实际开发时会整合2种方式去使用,当然,还有些其他的,像Data Vault模型、Anchor模型,暂时还没有应用过,就不说了。

维度建模,一般都会提到星型模型、雪花模型,星型模型做OLAP分析很方便。

数仓分层

简单点儿,直接ODS+DM就可以了,将所有数据同步过来,然后直接开发些应用层的报表,这是最简单的了;当DM层的内容多了以后,想要重用,就会再拆分一个公共层出来,变成3层架构,最近看了本阿里的书,《大数据之路》,里面有很多数仓相关的内容,很不错,参考后,目前使用的分层模式如下:

图片

按照这种分层方式,我们的开发重心就在dwd层,就是明细数据层,这里主要是一些宽表,存储的还是明细数据;到了dws层,我们就会针对不同的维度,对数据进行聚合了,按道理说,dws层算是集市层,这里一般按照主题进行划分,属于维度建模的范畴;ads就是偏应用层,各种报表的输出了。

指标字典

前面我们说过,数仓是一套体系,一个建设过程,它整合了很多的方法论,并不是一门新的技术。这里我们说说数仓中的指标体系,指标也不是数仓或者数据平台中特有的, 很多场景都会有指标这个概念。

这里我们说的指标,其实就是KPI(Key Performance Indicator),关键绩效指标。

企业关键绩效指标(KPI:Key Performance Indicator)是通过对组织内部流程的输入端、输出端的关键参数进行设置、取样、计算、分析,衡量流程绩效的一种目标式量化管理指标,是把企业的战略目标分解为可操作的工作目标的工具,是企业绩效管理的基础。KPI可以使部门主管明确部门的主要责任,并以此为基础,明确部门人员的业绩衡量指标。

数据平台的作用是为分析、决策提供支持,来时刻关注企业的运营情况的。那我们怎样来看公司的运营情况呢?就是看KPI,公司层面有公司最关注的KPI,比如:日活、GMV、订单量等等;不同的部门又有不同的关注KPI,比如:新用户数、复够人数等等,有了KPI,我们就可以根据KPI来考察部门的表现,也就是绩效。这也是数字化转型嘛,所有的管理、绩效都数字化。

就数据平台来说,指标算是元数据的一种,指标的维护和管理是有套路的,下面就简单分享下关于指标的管理-指标字典。

指标字典

指标字典,其实就是对指标的管理,指标多了以后,为了共享和统一修改和维护,我们会在Excel中维护所有的指标。当然,Excel对于共享和版本控制也不是很方便,有条件的话,可以开发个简单的指标管理系统,再配合上血缘关系,就更方便追踪数据流转了。

指标编码

为了方便查找和管理,我们会对指标定义一套编码

指标类型

基础指标:不能再进一步拆解的指标,可以直接计算出来的指标,如“订单数”、“交易额” 衍生指标:在基础指标的基础上,通过某个特殊维度计算出的指标,如“微信订单数”、“支付宝订单数” 计算指标:通过若干个基础指标计算得来的指标,在业务角度无法再拆解的指标,如“售罄率”、“复购率”

业务口径

指标最重要的就是,明确指标的统计口径,就是这个指标是怎么算出来的,口径统一了,才不会产生歧义

指标模板

除了上面,我们说到的几点,还有一些基本的,像“指标名称”、计算公式,就组成了指标的模板

图片

以前的话,我们还会有责任部门,就是说这个指标是哪个部门负责维护的,这个KPI是哪个部门来关注和承担。说到指标,就离不开维度,我们后面会说说维度的故事。

指标的梳理和管理

一开始指标的梳理是很麻烦的,因为要统一一个口径,需要和不同的部门去沟通协调;还有可能会有各种各样的指标出现,需要去判断是否真的需要这个指标,是否可以用其他指标来替代;指标与指标之间的关系也需要理清楚。

而且第一版指标梳理好之后,需要进行推广和维护,不断地迭代,持续推动,让公司所有部门都统一站在一个视角关注问题。

最重要的维度之日期维度

日期维度是我们最常用的维度,平台初始,最先初始化的可能就是日期维度,这里我们就简单介绍下日期维度。

什么是日期维度

我们日常生活,数据的产生都和日期有关,每一分、每一秒都会产生数据,数据分析也离不开日期。

日期维度就是一张固化的日历,一年365天,每一天都有,我们打开电脑中的日历:

图片

这里面有的,我们都可以固化下来,像周几、农历、年、月、日、节假日,我们都可以固化下来,方面我们分析的时候使用。

日期维度的结构

日期维度可以尽可能多的包含日期详细信息,这样在分析的时候可以直接使用,还要结合公司的一些特殊情况,像一些特殊展示的日期格式。

  • 基本的年季度月周日信息

图片

  • 拓展信息

除了上面的基本的日期,平时用的还有有些拓展信息

图片

可能还有些农历信息、农历年份等,公司自定义周的开始日期、结束日期等,和日期相关的所有内容都可以加进来进行维护。

  • 维度初始化

数据初始化,我们可以使用Java、Python或者SQL,通过常用的日期函数基本可以满足我们的数据需求,用SQL初始化,需要使用有循环控制语句的,如:MySQL、PG都行,Hive的话要结合Shell或者Python来使用。

一般不需要初始化太多年的数据,只要覆盖公司业务数据就好了,还有节假日信息每年都需要结合国务院发布的信息就行维护。

  • 关于小时

平时我们还会分析小时数据,一般不会把他放在日期表中,而是会单独放在一张小时维度表里,需要的时候一起使用就行了。

命名规范

话说,没有规矩不成方圆。在搭建数据平台的时候,在数据组内部,一定要先制定好各种规范,越早越好,并且不断的监督大家是否按照约定执行。一旦让大家自由发挥,后期想要统一或者重构,会浪费很大的人力成本和时间成本,记住,这都是坑。

这里以我目前公司的一些经验,分享下。

关于项目

常规来说,数仓的建设是按照数仓分层模型开发的。也有会按照业务线来分层,在各自业务线下重新分层,单独开发的。我这里使用的是阿里云的MaxCompute,这是阿里提供的数据平台,一整套开发环境,用起来还是很方便的,省去了自建平台的麻烦。MaxCompute里面有一个项目的概念,一开始本来打算直接根据分层模型的设计来创建项目,但是由于某种原因,改成了按照业务线来创建项目。对于这个项目名,一定要想好,不管根据什么来设计,都需要想清楚,想明白,定了以后就不要再改了,也没法改。

关于词根

我忘记是不是叫“词根”了,先写着,后面找本书确认下。词根属于数仓建设中的规范,属于元数据管理的范畴。哦,现在都把这个划到数据治理的一部分。

正常来说,完整的数仓建设是包含数据治理的,只是现在谈到数仓偏向于数据建模,而谈到数据治理,更多的是关于数据规范、数据管理。

接着说我们的主角-词根。

我们学习英语的时候应该有了解过词根这个东西,它就是最细粒度的最简单的一个词语,我们主要用来规范中文和英文的映射关系。我们公司一部分业务是关于货架的,英文名是:rack,rack就是一个词根,那我们就在所有的表、字段等用到的地方都叫rack,不要叫成别的什么。这就是词根的作用,用来统一命名,表达同一个含义。指标体系中有很多“率”的指标,都可以拆解成XXX+率,率可以叫rate,那我们所有的指标都叫做XXX+rate。词根可以用来统一表名、字段名、主题域名等等。

表名

表名需要见名知意,通过表名就可以知道它是哪个业务域,干嘛用的,什么粒度的数据。

  • 常规表

常规表是我们需要固化的表,是正式使用的表,是目前一段时间内需要去维护去完善的表。规范:分层前缀[dwd|dws|ads|bi]_业务域_主题域_XXX_粒度 业务域、主题域我们都可以用词根的方式枚举清楚,不断完善,粒度也是同样的,主要的是时间粒度、日、月、年、周等,使用词根定义好简称。

  • 中间表

中间表一般出现在Job中,是Job中临时存储的中间数据的表,中间表的作用域只限于当前Job执行过程中,Job一旦执行完成,该中间表的使命就完成了,是可以删除的(按照自己公司的场景自由选择,以前公司会保留几天的中间表数据,用来排查问题)。规范:mid_table_name_ [0~9|dim] table_name是我们任务中目标表的名字,通常来说一个任务只有一个目标表。这里加上表名,是为了防止自由发挥的时候表名冲突,而末尾大家可以选择自由发挥,起一些有意义的名字,或者简单粗暴,使用数字代替,各有优劣吧,谨慎选择。通常会遇到需要补全维度的表,这里我喜欢使用dim结尾。

中间表在创建时,请加上 ,如果要保留历史的中间表,可以加上日期或者时间戳

1
2
sql复制代码drop table if exists table_name;
create table_name as xxx;
  • 临时表

临时表是临时测试的表,是临时使用一次的表,就是暂时保存下数据看看,后续一般不再使用的表,是可以随时删除的表。规范:tmp_xxx 只要加上tmp开头即可,其他名字随意, 注意tmp开头的表不要用来实际使用,只是测试验证而已。

  • 维度表

维度表是基于底层数据,抽象出来的描述类的表。维度表可以自动从底层表抽象出来,也可以手工来维护。规范:dim_xxx 维度表,统一以dim开头,后面加上,对该指标的描述,可以自由发挥。

  • 手工表

手工表是手工维护的表,手工初始化一次之后,一般不会自动改变,后面变更,也是手工来维护。一般来说,手工的数据粒度是偏细的,所以,暂时我们统一放在dwd层,后面如果有目标值或者其他类型手工数据,再根据实际情况分层。规范:dwd _ 业务域_manual_ xxx 手工表,增加特殊的主题域,manual,表示手工维护表

指标

指标的命名也参考词根,避免出现同一个指标,10个人有10个命名方法。

具体操作结合公司实际情况,规范及早制定。

数据治理

广义数据仓库的建设包含很多的解决方案,其中就包含数据治理,数据治理也是贯穿整个项目始终的,是一件长久的事情。现在很多人都把数据仓库简单的理解成数据建模了。

数据治理包含很多的事情,我也没做过,所以在网上找些资料分享下。

为什么要做数据治理

随着数据量越来越大,数据成为一种资产,我们需要更好地管理这些数据,更好地体现数据的价值,这就需要数据治理。其实在搭建数据平台的时候,我们遇到的一系列问题都可以通过数据治理来解决:

  • 数据质量越来越差,问题发现严重滞后
  • 缺少数据标准,各个部门标准不统一
  • 数据变更对下游的影响不清晰,无法确认影响范围

数据治理(Data Governance),是一套持续改善管理机制,通常包括了数据架构组织、数据模型、政策及体系制定、技术工具、数据标准、数据质量、影响度分析、作业流程、监督及考核流程等内容。

简单来说就是有很多流程和标准,像“元数据管理”、“主数据管理”、“数据质量”都包含其中。

通过数据治理来解决我们使用数据的过程中遇到的问题。

这部分内容你可以参考:《所谓数据治理》

关于增量

很多初学者或者没有做个ETL这件事儿的同学对这个增量是有误解的,尤其是在和业务开发同学对接的时候,他们对这个增量的理解也是有偏差的。

先来说说他们以为的增量是什么。他们以为“增量,就是按照时间增量去拿就好了,增量同步,你就把增量后的数据给我好了,不要总是全量同步。” 按道理说,这么做思路是对的,但是不严谨,而且会出错,下面我们就一步一步看看。

1.什么是增量

增量是相对于全量来说的,它们都是处于“同步数据”这个场景下的,比如说业务系统的数据同步到数仓,数仓的数据同步给业务系统,都会使用同步的方式,这都是相对于我们开发来说的,从数据库级也是可以同步的,这里我们就不介绍了。

全量同步,就是说把数据全部同步过去,100条就同步100条,1万条就同步1万条,1亿条就同步1亿条,大家也应该会发现这种方式存在的问题,在数据量小的时候,全量同步简单方便易执行,而当数据量大了以后,尤其是历史数据不会经常变化的时候,全量同步就会浪费大量的资源和时间,严重影响同步效率。

1
2
3
4
5
sql复制代码--全量同步一般先delete,然后insert
delete from tmp_a;
insert into tmp_a xxx;
-- 或者直接 insert overwrite
insert overwrite table tmp_a xxx;

SQL语法可能不太一样,差不多就是这个意思,哈哈

记住一定要删除或者覆盖插入,不然数据可就越来越多了。

选择增量同步的几个场景:

  • 数据量很大,而且历史数据不会频繁变化
  • 只需要增量数据

使用增量同步,对表有一些要求,比如,需要有create_time,update_time字段 create_time表示记录创建时间,update_time表示记录更新时间,增量的话,只需要把变化的数据拿过来就行了(使用update_time),注意:这里还需要有一个主键,主键是用来覆盖数据的。

这里和不同的业务场景有关系,有的记录创建后不会再更新,类似于流水数据,这种数据直接增量拿过来就好,可以不进行删除操作;但是有的数据是会更新的,当已经同步过来的数据发生了变化,数仓侧也是需要同步发生变化的。

2. 怎么做增量

增量同步也是要做一次初始化的,初始化是全量来的。

假设我们有这样一张表:

1
2
3
4
5
sql复制代码create table tmp_a(
id bigint,
create_time datetime,
update_time datetime
);

一般离线场景下,都会选择在业务量最少的时候去做同步操作,而这个时间大部分都是在半夜凌晨的时候,所以大部分同步都是从0点以后开始,同步昨天的数据,也就是常说的T+1了。

假设3月1号创建了如下4条记录,数仓会在2号凌晨进行同步

图片

2号的时候,新增了1条记录,并且有一条记录更新了,按照增量规则,我们会拿到两条记录

图片

拿到增量数据之后,我们需要将增量的数据合并到我们数仓的表中

图片

新增的数据,可以直接插入,但是更新的数据,我们需要把原纪录更新掉,或者先删除再插入,以前我们还会记录一个数据插入的状态,如果是更新的,就记一个“update”,如果是插入的就记一个“insert”,到了这里,应该知道为啥需要有主键了吧,如果没有主键,你咋知道这条记录到底变没变过。

使用增量,一般需要两套表,一套表用来存增量数据,一套用来存完整的全量数据。

3. etl_insert_time

不管是增量还是全量,我都比较喜欢加一个时间戳字段,用来标识记录的插入时间,这个尤其是在对比增量数据的时候,排查数据问题很有用。

4. 我们公司的同步机制

我们呢,一创业公司,数据量不算多,使用的都是阿里云的工具,一开始为了方便,所有的数据,都是全量来的,刚看了眼数据量又10几T吧,其中很多是历史数据。

虽然我们是全量来的,但是为了捕捉记录数据的变化,用的是pt(分区)的方式,每天都是一个全量快照,这也是现在存储便宜的一种处理方法,简单粗暴。我刚来的时候,就提过搞成增量,被拒绝了,后来也没有人来搞这个,表太多了,修改起来成本太高。

5. 基于Hive的增量

Hive现在也算是标配了,上面说的增量方案,可能还是基于关系型数据库的,在Hive上,由于运算能力更强大,可以不考虑数据量的问题,所以衍生出来几种方案。主要原因还是Hive上对于delete操作的支持问题,尽量不要有delete。

  • 排序(row_number)

我们依然每天获取增量数据,然后将增量数据插入到每个分区中,每个分区都是当天的增量数据,当然数据变化的话,同一个主键的记录会出现在多个分区中,所以如果我们要获取最新的完整版数据,可以使用row_number根据主键和时间排序,获取最新版本的全量数据

  • full join

使用full join的方式,将增量数据和历史全量数据,进行关联,然后取出最新完整版数据

  • left join + union all

这个和full join的方式类似,感觉这个更美观严谨一些,以前在GP上面做增量也用的这种方式。

6. 拉链表

说到增量,也需要提一下拉链表,拉链表以前用的多一些,感觉在互联网公司用的很少,基本都使用分区的方式处理掉了。拉链表其实就是记录数据的每一次变化,处理起来稍微有点儿麻烦,这个以前好像写过,等我找找贴过来。

上下游约定

由于数仓的特性和定位,它就需要强依赖上游的业务系统,当然也会有一些下游系统,所以定好上下游的规范,变更的通知机制是非常有必要的。

感觉好像写过上下游的事情,刚才没找到,这里就再重新写写。

上游

这里说的主要是基于小公司,类似我目前所在的创业公司为例,像发展成熟的大公司,各种流程规定、容错监控类的机制都很完善,对于这些场景,我说的可能就不适用了。

对于数仓来说,最重要的就是数据了,数仓中的数据,主要来源是业务系统,就是公司各种业务数据,所以数仓需要不断的将业务系统数据同步到自身平台来,所以一旦上游业务系统发生变化,数仓也要同步变化,不然,这种同步操作很可能失败。

  • 表结构变更

上游的表结构经常会发生变化,新增字段、修改字段、删除字段(除非真的不用这个字段了,通常会选择标识为弃用)。表结构最好要维护清楚,表名、字段名、字段类型、字段描述,都整理清楚,不使用的字段要么删除,要么备注好,当业务频繁发生变化或者迭代优化的时候,很容易出现,我写了半天的代码,最后发现表用的不对,字段用的不对,这就尴尬了。

对于这种变化,人工处理的话,就是手动在数仓对应的表中增加、修改字段,然后修改同步任务;这个最好可以搞成自动化的,比如,自动监控上游表结构的变更,变化后,自动去修改数仓中的表结构,自动修改同步任务。

  • 枚举值

业务系统中会有很多的常量,用来标识一些状态或者类型,这种值经常会新增,数仓中会对这些值做些处理,比如转换成维度,会翻译成对应的中文,而实际上这种映射关系,我们是不知道的,只有业务开发才知道,所以最好可以让他们维护一张枚举值表,我们去同步这张表。

  • create_time & update_time

正常来说,create_time,当这条记录插入后,就不会再变了,但是某种情况下,哈哈,开发同学会去更新它;update_time,当这条记录变化后,这个时间也要变,有的开发同学不去更新它……

所以在做增量操作的时候,一定和开发说好这两个字段的定义和使用场景。

  • is_delete & is_valid

有些场景下,我们需要删除某些数据,一般不会物理删除,会通过一个字段来做逻辑删除,请和开发同学沟通好,使用固定的一个字段,并确认该字段双方的理解是一致的,不然后面又很多坑。

下游

说完了上游,我们说说下游,对于数仓来说,一般的邮件、报表、可视化平台都是下游,所以当我们在数仓中进行某些重构、优化操作的时候,也需要通知他们。

主要就是对数仓模型做好维护,表的使用场景、字段描述等。

对上游的要求,自己也要做好,因为自己也是上游。

任务注释

这一篇说说注释,注释总是让人又爱又恨。

没有注释,谁知道你这些代码是用来干嘛的,从代码角度来看,你想做的是A,而实际上需求确是B,具体干啥得靠猜;代码有注释,也不一定就可以高枕无忧,注释可能是最初版的需求,改了几版后,代码早就变了,注释没有变,注释和代码不匹配,谁知道该以哪个为准啊。

我们的数仓都是基于阿里云的,使用了它的DataWorks作为离线工具,所有的代码都在这上面,所以这里简单介绍下,在阿里云上的任务,几点注释规范。

1
2
3
4
5
6
7
8
9
diff复制代码--    @name p_dwd_rack_machine
-- @description 货架宽表
-- @target rack.dwd_rack_machine

-- @source owo_ods.kylin__machine_release_his
-- @source owo_ods.kylin__machine_device_his

-- @author yuguiyang 2017-12-25
-- @modify

@name:任务的名字,我们的任务名一般都是以 p_目标表名,后来阿里的DataWorks升级后,推荐是任务名和表名保持一致。

@description:任务描述,该任务的主要内容 @target:目标表名,一般一个任务只输出一个目标表

@source:来源表,就是任务中使用的底层表,这里也可以省略,从血缘关系中可以直接看到,而且很容易漏更新

@author:创建者,和创建日期, @modify:内容变更记录,变更人,变更日期,变更原因 ,这个从版本控制中也可以找到,但是这些这里更直观一些。

《硬刚Presto|Presto原理&调优&面试&实战全面升级版》

《硬刚Apache Iceberg | 技术调研&在各大公司的实践应用大总结》

《硬刚ClickHouse | 4万字长文ClickHouse基础&实践&调优全视角解析》

《硬刚数据仓库|SQL Boy的福音之数据仓库体系建模&实施&注意事项小总结》

《硬刚Hive | 4万字基础调优面试小总结》

《硬刚用户画像(一) | 标签体系下的用户画像建设小指南》

《硬刚用户画像(二) | 基于大数据的用户画像构建小百科全书》

本文转载自: 掘金

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

1…629630631…956

开发者博客

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