dream

一个菜鸟程序员的成长历程

0%

spring

AOP切面

spring

IOC控制反转

这里先说一下IOC,再说IOCspring框架中的使用。

IOC的概念

IOC这个缩写有很多意思,比如

  • 智慧城市智能运行中心(IOC)
  • 奥林匹克运动的领导机构

但是呢,我们这里说的是面向对象编程中的一种设计原则。他的全称是Inversion Of Control即控制反转。这里有两个单词控制反转。这两个单词单独拿出来会发现,都缺少主语。比如

  • 谁控制了谁?
  • 什么东西发生反转了呢?

控制

这里说一下第一个问题,谁控制了谁呢?看下面的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

public class main{

public static void main(String[] args) {
Person person = new Person();
person.setName("tony");
System.out.println(person.getName());
}
}

class Person{
private String name;

public void setName(String name) {
this.name = name;
}

public String getName() {
return this.name;
}
}

很显然,在这里,Person类的person对象的一切都控制在main函数里面。main函数创建它,使用它,销毁它。所以在当前上下文中,main控制了person。

反转

上面的写法main函数控制了person对象,这是一种紧耦合的关系,如果person发生了改变,我们就需要改变main。反转是控制权的反转。现在person的控制权在main这里,我们将它反转一下。不在用main控制它。那么我们加一个简单工厂看一下呢。

1
2
3
4
5
6
7
8
9
10
11
12
13

class Factory{
public static Person createPerson() {
return new Person();
}
}

public static void main(String[] args) {
Person person = Factory.createPerson();
person.setName("tony");
System.out.println(person.getName());
}

这里可以看到person的控制权转交给了Factory工厂,而main只有使用权了。当然了作为示例代码,这里的控制只做了创建。

现在,由main控制person改为了Factory控制person。如果person发生了改变,我们只需要改变Factory,而不需要动业务逻辑。

当然了,这种程度的解耦依然不够,因为main还是和Factory有耦合关系,他还控制了Factory。我们可以扩大这个简单工厂,扩大后的简单工厂就不再是工厂了,而叫做容器。我们把所有的控制权都交给容器,让容器控制所有的类,对象。而在使用的时候我们去告诉容器我们要使用哪个对象,让容器给我们提供就可以了。

DI

依赖注入 DI全称Dependency Injection就可以实现我们告诉容器我们需要的对象,然后容器把对象注入给我们的功能。

具体的依赖注入实现方式每个语言,每个框架可能都不一样,这里以spring为例

1
2
3
4
5
6
7
8
9
10
11
public class main{

@Autowired
private Person person;

public static void main(String[] args) {
person.setName("tony");
System.out.println(person.getName());
}
}

可以看到,我们通过@Autowired注解告诉容器,我们需要一个Person类型的对象,然后让容器把这个对象注入到我们的person属性中。这里仅做示例使用,具体情况请以实际开发中为准,实际开发中应使用接口类型。

容器中的实现方式就类似刚才工厂中的,假设你需要Person类型的对象,就new Person返回,当然了,要更加复杂,比如可以根据名称来反射创建对象,可以更好的管理对象的生命周期,可以实现单例对象等等。

spring中的IOC

Spring中的IOC实现也是通过容器,但是怎么把类注入到容器中呢,也就是怎么告诉容器,你需要实例化哪些类呢?有两种方式,一种是XML配置方式,一种是注解方式

XML配置方式

先创建一个XML文件,比如:

touch bean.xml

接下来编辑它。

vim bean.xml

无参数构造方式

重点在这个配置,通过bean这个标签来告诉容器我要把哪些类注册到容器中,其中id就是注册后的唯一标识,我们获取的时候也可以通过指定id来从容器中获取对象。而class是告诉容器,我们具体要注入的类的路径。但是这时候没有指定参数,也就是说类似于Person person = new Person()这样的注册,需要有无参构造器。

1
2
3
4
5
6
7
8
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">

<bean id="person" class="com.test.java.Person"></bean>

</beans>

使用的时候需要借助于Spring提供的容器获取,可以看一下效果是一样的。

1
2
3
4
5
6
7
8
9
10
11
12
public class main{

public static void main(String[] args) {
//这里先加载我们的XML配置文件
BeanFactory ac = new ClassPathXmlApplicationContext("bean.xml");
//通过容器获取一个对象,第一个参数也就是配置的id,第二个就是类
Person person = ac.getBean("person", Person.class);
person.setName("tony");
System.out.println(person.getName());
}
}

构造器构造参数方式

同样在上面的XML文件中进行修改,但这次我们需要加上参数,并且告诉容器是通过构造器来构造参数的,而不是set的方式。

我们只需要在原来的bean标签中,加入我们要传给构造器的参数就可以了。

使用constructor-arg标签,name就是参数名,value是对应的值。

1
2
3
4
5
6
7
8
9
10
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">

<bean id="person" class="com.test.java.Person">
<constructor-arg name="name" value="tony"></constructor-arg>
</bean>

</beans>

我们还需要改造一下原来的Person类,增加一个构造函数.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Person{
private String name;

public Person(String name) {
this.name = name;
}

public void setName(String name) {
this.name = name;
}

public String getName() {
return this.name;
}
}

看一下现在的main函数

1
2
3
4
5
6
7
8
9
10
11
12
13
public class main{

public static void main(String[] args) {
//这里先加载我们的XML配置文件
BeanFactory ac = new ClassPathXmlApplicationContext("bean.xml");
//通过容器获取一个对象,第一个参数也就是配置的id,第二个就是类
Person person = ac.getBean("person", Person.class);
// 这里不需要setName了,因为通过构造器注入参数了
// person.setName("tony");
System.out.println(person.getName());
}
}

但是如果我们需要构造一个其他类的对象作为参数该怎么配置呢,毕竟我们总不能在value上面写new Class()吧哈哈。但是我们知道一个bean就是一个对象,那我们可以传一个bean进来就可以了。

来看一下配置方式.不再使用value了,因为value只能传基本类型这些,而其他的对象需要使用ref来传参。ref的值就是其他beanid

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">

<bean id="avatar" class="com.test.java.Avatar">
<constructor-arg name="url" value="http://baidu.com"></constructor-arg>
</bean>

<bean id="person" class="com.test.java.Person">
<constructor-arg name="name" value="tony"></constructor-arg>
<constructor-arg name="avatar" ref="avatar"></constructor-arg>
</bean>

</beans>

同样需要改造一下构造函数,增加一个avatar参数。

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
class Avatar{
private String url;
public Avatar(String url) {
this.url = url;
}
}

class Person{
private String name;

private Avatar avatar;

public Person(String name, Avatar avatar) {
this.name = name;
this.avatar = avatar;
}

public void setName(String name) {
this.name = name;
}

public String getName() {
return this.name;
}

public String getAvatar() {
return this.avatar.url;
}
}

执行一下main函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class main{

public static void main(String[] args) {
//这里先加载我们的XML配置文件
BeanFactory ac = new ClassPathXmlApplicationContext("bean.xml");
//通过容器获取一个对象,第一个参数也就是配置的id,第二个就是类
Person person = ac.getBean("person", Person.class);
// 这里不需要setName了,因为通过构造器注入参数了
// person.setName("tony");
System.out.println(person.getName());
System.out.println(person.getAvatar());
}
}

set方式传参

除了通过构造器传参,我们还可以写set函数来传参,比如setNamesetAvatar

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
class Avatar{
private String url;
// public Avatar(String url) {
// this.url = url;
// }

//增加set函数
public void setUrl(String url) {
this.url = url;
}
}

class Person{
private String name;

private Avatar avatar;

// public Person(String name, Avatar avatar) {
// this.name = name;
// this.avatar = avatar;
// }

public void setName(String name) {
this.name = name;
}

public String getName() {
return this.name;
}

public void setAvatar(Avatar avatar) {
this.avatar = avatar;
}

public String getAvatar() {
return this.avatar.url;
}
}

修改XML配置文件,不在使用constructor-arg标签,而是换成property。不过除了标签名变了,其他的属性name,value,ref都是不变的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">

<bean id="avatar" class="com.test.java.Avatar">
<property name="url" value="http://baidu.com"></property>
</bean>

<bean id="person" class="com.test.java.Person">
<property name="name" value="tony"></property>
<property name="avatar" ref="avatar"></property>
</bean>

</beans>

执行一下main函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class main{

public static void main(String[] args) {
//这里先加载我们的XML配置文件
BeanFactory ac = new ClassPathXmlApplicationContext("bean.xml");
//通过容器获取一个对象,第一个参数也就是配置的id,第二个就是类
Person person = ac.getBean("person", Person.class);
// 这里不需要setName了,因为通过构造器注入参数了
// person.setName("tony");
System.out.println(person.getName());
System.out.println(person.getAvatar());
}
}

自动注入

可以通过配置XML文件来使用自动注入,就不需要手动增加<property name="url" ref="avatar"></property><constructor-arg name="avatar" ref="avatar"></constructor-arg>的标签了,只需要配置一个属性autowire就可以了。但是这种的只适用于注入其他的bean

autowire只有两个值,一个是byName,是通过bean的名称进行注入,比如你的属性名是avatar,就会查找id=avatar这个类。还有一个是byType,是通过bean的类型进行注入,比如类型是Avatar,那么就会查找class=Avatar的bean进行注入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">

<bean id="avatar" class="com.test.java.Avatar">
<property name="url" value="http://baidu.com"></property>
</bean>

<bean id="person" class="com.test.java.Person" autowire="byName">
<property name="name" value="tony"></property>
<!-- <property name="avatar" ref="avatar"></property> -->
</bean>

</beans>

XML读取外部配置文件

通过XML可以读取外部的配置文件,这样的话像数据库,redis连接这些就可以把host,name,password这些写到外部的配置文件中。

配置文件使用.properties后缀。比如spring.properties

增加一个配置文件spring.properties

1
spring.person.name=tony

修改XML直接从配置中读取person.name来注入。读取的时候还需要在XML中增加context命名空间。并通过context命名空间来读取配置文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?xml version="1.0" encoding="UTF-8"?>
<!-- 增加context命名空间 -->
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:context="http://www.springframework.org/schema/context"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/context http://www.springframework.org/schema/beans/spring-context.xsd">

<!-- 读取配置文件 -->
<context:property-placeholder location="classpath:spring.properties">

<bean id="avatar" class="com.test.java.Avatar">
<property name="url" value="http://baidu.com"></property>
</bean>

<bean id="person" class="com.test.java.Person" autowire="byName">
<!-- 读取配置文件的值 -->
<property name="name" value="${spring.person.name}"></property>
<!-- <property name="avatar" ref="avatar"></property> -->
</bean>

</beans>

注解方式

注解方式要比XML方式简单的多,其中原理就是不再手动配置,而是通过注解告诉Spring我是一个bean。快来注册我吧。

主要是这4个注解告诉Spring

  • @Component 单纯的说我是一个bean
  • @Service 和上面的一样,不过一般用在service类中,更加语义化
  • @Controller 和上面的一样,一般用在controller类中
  • @Repository 我也是一个bean

接下来我们告诉Spring,你需要扫描出所有带上面注解的类,把他们注册到容器中。这一步需要修改XML文件,需要配置<context:component-scan>标签,并且通过base-package属性告诉Spring我们要扫描哪个目录

1
2
3
4
5
6
7
8
9
10
11

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:context="http://www.springframework.org/schema/context"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context https://www.springframework.org/schema/context/spring-context.xsd">

<context:component-scan base-package="com.test.java"></context:component-scan>

</beans>

在类上面增加注解

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

@Component
class Avatar{
private String url;
// public Avatar(String url) {
// this.url = url;
// }

//增加set函数
public void setUrl(String url) {
this.url = url;
}
}

@Component
class Person{
private String name;

private Avatar avatar;

// public Person(String name, Avatar avatar) {
// this.name = name;
// this.avatar = avatar;
// }

public void setName(String name) {
this.name = name;
}

public String getName() {
return this.name;
}

public void setAvatar(Avatar avatar) {
this.avatar = avatar;
}

public String getAvatar() {
return this.avatar.url;
}
}

还可以自己指定扫描哪些注解,通过context:include-filter标签来指定。type类型写注解,expression指定扫描哪个注解。把标签放在context:component-scan这个里面就可以了。还需要在context:component-scan标签中指定,禁用默认的扫描方式。指定use-default-filters的属性为false.

1
2
3
4
5
6
7
8
9
10
11
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:context="http://www.springframework.org/schema/context"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context https://www.springframework.org/schema/context/spring-context.xsd">

<context:component-scan base-package="com.test.java" use-default-filters="false">
<context:include-filter type="annotation" expression="org.springframework.stereotype.Component" />
</context:component-scan>

</beans>

还可以排除一些注解不进行扫描,通过context:exclude-filter标签来指定。type同样写注解,expression指定排除的注解。

1
2
3
4
5
6
7
8
9
10
11
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:context="http://www.springframework.org/schema/context"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context https://www.springframework.org/schema/context/spring-context.xsd">

<context:component-scan base-package="com.test.java" use-default-filters="false">
<context:exclude-filter type="annotation" expression="org.springframework.stereotype.Component" />
</context:component-scan>

</beans>

把类注册到容器中以后,我们还需要在使用的时候告诉容器,我们需要从容器中获取这个类,有5个注解

  • @Autowired Spring提供的,基于类型注入的,可以放在setter方法上
  • @Qualifier Spring提供的,基于名称注入的,一般和@Autowired配合使用来通过value参数指定名称
  • @Resource Java提供的,可以基于类型或名称注入的,可以通过name参数来指定名称,可以放在setter方法上
  • @RequiredArgsConstructor lombok提供的,基于类型注入,通过增加一个构造函数来注入。
  • @Value Spring提供的,注入基本类型的注解,一般用来从配置文件取值。

@RequiredArgsConstructor是lombok提供的,兼容性较差,像写单元测试的时候就用不了,它会给你的类增加一个构造方法,而且只会给final类型的属性进行注入。

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

@Component
//增加注解
@RequiredArgsConstructor
class Person{
private String name;

//错误使用,因为没有final
// private Avatar avatar;

//正确使用,加上final
private final Avatar avatar;

// public Person(String name, Avatar avatar) {
// this.name = name;
// this.avatar = avatar;
// }

public void setName(String name) {
this.name = name;
}

public String getName() {
return this.name;
}

public void setAvatar(Avatar avatar) {
this.avatar = avatar;
}

public String getAvatar() {
return this.avatar.url;
}
}

这个时候可以编译完以后查看.class文件,看到的是这样的

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

@Component
// 注解没有了
// @RequiredArgsConstructor
class Person{
private String name;

//错误使用,因为没有final
// private Avatar avatar;

//正确使用,加上final
private final Avatar avatar;

//增加了一个构造函数
public Person(final Avatar avatar) {
this.avatar = avatar;
}

public void setName(String name) {
this.name = name;
}

public String getName() {
return this.name;
}

public void setAvatar(Avatar avatar) {
this.avatar = avatar;
}

public String getAvatar() {
return this.avatar.url;
}
}

@Autowired是spring提供的,在spring中不管是写业务还是写单元测试都可以使用,它可以放在要注入的属性上面,也可以放在setter方法上面。使用他的时候不需要final修饰。

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

@Component
class Person{
private String name;

//在需要的属性上面增加这个注解,不需要final修饰
@Autowired
private Avatar avatar;

//错误使用,加上final
// @Autowired
// private final Avatar avatar;

// public Person(String name, Avatar avatar) {
// this.name = name;
// this.avatar = avatar;
// }

public void setName(String name) {
this.name = name;
}

public String getName() {
return this.name;
}

//也可以加在setter方法上面
@Autowired
public void setAvatar(Avatar avatar) {
this.avatar = avatar;
}

public String getAvatar() {
return this.avatar.url;
}
}

@Qualifier注解配合@Autowired使用,比如我们有一个头像的接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

interface IAvatar {
String getUrl();
}

class maleAvatar implements IAvatar{
public String getUrl() {
return "male avatar";
}
}

class femaleAvatar implements IAvatar{
public String getUrl() {
return "female avatar";
}
}

这个时候我们在注入的时候如果只根据IAvatar来注入,容器就不知道我们需要哪个实现类了,所以我们需要指定类名.

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

@Component
class Person{
private String name;

//在需要的属性上面增加这个注解,不需要final修饰
@Autowired
//指定要注入的实现类
@Qualifier(value="maleAvatar")
private IAvatar avatar;

//错误使用,加上final
// @Autowired
// private final Avatar avatar;

// public Person(String name, Avatar avatar) {
// this.name = name;
// this.avatar = avatar;
// }

public void setName(String name) {
this.name = name;
}

public String getName() {
return this.name;
}

//也可以加在setter方法上面
@Autowired
@Qualifier(value="maleAvatar")
public void setAvatar(IAvatar avatar) {
this.avatar = avatar;
}

public String getAvatar() {
return this.avatar.url;
}
}

@Resource更像是上面两个的合体,并且是由java提供的。也是可以放在属性和setter上面,并且不需要final修饰。

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

@Component
class Person{
private String name;

//在需要的属性上面增加这个注解,不需要final修饰
@Resource
private Avatar avatar;

//错误使用,加上final
// @Resource
// private final Avatar avatar;

public void setName(String name) {
this.name = name;
}

public String getName() {
return this.name;
}

//也可以加在setter方法上面
@Resource
public void setAvatar(Avatar avatar) {
this.avatar = avatar;
}

public String getAvatar() {
return this.avatar.url;
}
}

同样的,如果我们有多个实现类,需要指定可以通过它的name参数来指定。比如

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

@Component
class Person{
private String name;

//在需要的属性上面增加这个注解,不需要final修饰
@Resource(name="femaleAvatar")
private IAvatar avatar;

//错误使用,加上final
// @Resource
// private final Avatar avatar;

public void setName(String name) {
this.name = name;
}

public String getName() {
return this.name;
}

//也可以加在setter方法上面
@Resource(name="femaleAvatar")
public void setAvatar(IAvatar avatar) {
this.avatar = avatar;
}

public String getAvatar() {
return this.avatar.url;
}
}

@Value可以注入基本类型,比如字符串这种,但是更多的是从配置文件中取值。比如

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
@Component
class Person{
//直接注入tony字符串到name中
@Value("tony")
private String name;

//从配置文件中取值
//person:
// name: tony
@Value("${person.name}")
private String nameFromConfig;

//在需要的属性上面增加这个注解,不需要final修饰
@Resource(name="femaleAvatar")
private IAvatar avatar;

//错误使用,加上final
// @Resource
// private final Avatar avatar;

public void setName(String name) {
this.name = name;
}

public String getName() {
return this.name;
}

//也可以加在setter方法上面
@Resource(name="femaleAvatar")
public void setAvatar(IAvatar avatar) {
this.avatar = avatar;
}

public String getAvatar() {
return this.avatar.url;
}
}

高等数学笔记

$y = x^(m/n) 相当于 y^n = x^m$

三角函数

  • sinx,tanx,cotx,cscx是奇函数
  • cosx,secx是偶函数
  • tanx = sinx/cosx
  • cotx = cosx/sinx
  • $cos^2x + sin^2x = 1$
  • $1 + tan^2x = sec^2x$
  • $cot^2x + 1 = csc^2x$
  • $cos^2x = (1 + cosx) / 2$
  • $sin^2x = (1 - cosx) / 2$
1
2
3
4
sin(a+b) = sina cosb + cosa sinb
cos(a+b) = cosa cosb - sina sinb
sin2x = 2sinxcosx
cos2x = cosx^2 - sinx^2

单位圆上

1
2
3
4
5
6
7
sinx = y/r
cosx = x/r
tanx = y/x

余割 cscx = r/y
正割 secx = r/x
余切 cotx = x/y

余弦定理

$$c^2 = a^2 + b^2 -2ab * cosx$$

反函数

  • $sec^-1 X = cos^-1 (1/X)$
  • $csc^-1 X = sin^-1 (1/x)$
  • $cot^-1 X = π/2 - tan^-1X$

对数

  • ln(a * b) = lna + lnb
  • ln(a / b) = lna - lnb
  • lnx^n = n * lnx
  • ln(ⁿ√x)=lnx/n
  • lne = 1
  • ln1 = 0
  • logab = logcb / logca
  • a^x = logaX = e^xlna
  • e^lnx = x
  • lne^x = x
1
2
3
4
5
6
7
8
lnx = 3t+5
e^lnx = e^3t+5
x = e^3t+5

e^2x = 10
ln e^2x = ln 10
2x = ln10
x = 1/2 * ln10

幂函数

  • a^m * a^n = a^m+n
  • a^m / a^n = a^m-n
  • (a^m)^n = a^mn
  • (a^m * a^n)^p = a^mp * b^np

求切线方程

公式 y-y0=m(x-x0),m为斜率,也就是导数。代入点到x0,y0处求方程,比如y=2^x在(0,1)点的切线方程

1
2
3
4
y = 2^x的导数为ln2 * 2^x,x = 0代入为ln2
在点(0,1)处代入 y - 1 = ln2 (x - 0)
y - 1 = ln2x - 0
y - ln2x - 1 = 0

导数

四则运算

1
2
3
4
5
d/dx (a+b) = d/dx a + d/dx b
d/dx (a*b) = d/dxa * b + a * d/dx b
d/dx (a/b) = (d/dxa * b - a * d/dx b) / b^2
d/dx (ca) = c * d/dx a, c为常数
d/dx (1/v) = -v^-2 * d/dx v

链式法则

d/dx f(g(x)) = d/dx f(g) * d/dx g(x)

隐函数微分法

对于不像y=2x这种直接的函数。比如x^2 + y^2 = 1这种函数,可以直接对每一项求导。在使用链式法则就可以得到y的导数

1
2
3
4
5
x^2 + y^2 = 1 对每一项求导后 x^2 = 2x, y^=2y , 1 = 0,因为y是函数,在对y使用链式法则,得:
2x + 2y * d/dx y = 0
2y * d/dx y = -2x
d/dx y = -2x / 2y
d/dx y = -x/y

对数微分法

常见导数

  • 常数导数为0
  • $sinx = cosx$
  • $cosx = -sinx$
  • $tanx = sec^2x$
  • $1/x = -1/x^2$
  • $x^a = a*x^a-1$
  • $a^x = lna(a^x)$
  • $lnx = 1/x$

线性近似

f(x) = f(x) + d/dx f(x) (x - x0)

当x=0时:
f(x) = f(0) + d/dx f(0) * x

lnx的线性近似,当x = 1时:
lnx = ln1 + d/dx ln1 (x - 1)
lnx = 0 + 1 * (x - 1) = x - 1

当x = 0时:

1
2
3
4
5
lnx = ln(1 + x) = 1 + x - 1 = x
sinx = 0 + cos0 * x = x
cosx = 1 + 0 * x = 1
e^x = 1 + 1 * x = 1 + x
(1 + x)^r = 1 + rx

二阶近似

f(x) = f(x) + d/dx f(x) (x - x0) + f(x)’’/2 * (x - x0)^2

当x=0时:
f(x) = f(0) + f(0)’ * x + f(0)’’/2 * x^2

1
2
3
4
5
sinx = x
cosx = 1 - 1/2 * x^2
e^x = 1 + x + 1/2 * x^2
ln(1 + x) = x - 1/2 * x^2
(1 + x)^r = 1 + rx + r(r-1)/2 * x^2

曲线构图

  • if f’ > 0, f 是递增的
  • if f’ < 0, f 是递减的
  • if f’’ > 0, f’ 是递增的
  • if f’’ < 0, f’ 是递减的

if f(x0)’ = 0, 则 x0 为临界点, y0 = f(x0) 为临界点值 。

if f(x0)’’ = 0,则 x0为 拐点。

画图

  • 描点
    • 找出不连续的点
    • 找出最远端的点
    • 找出一些简单的点
  • 求出导数为0的点
    • 标出临界点的值
  • 判断f’在每个区间的正负性
  • 判断f’’的正负性,以判断凹凸性
    • 求出f0’’,算出拐点
  • 组合所有信息

最大最小值

只需要求出临界点,最远端的点和不连续的点就可以找出最大最小值

牛顿迭代法

用来求函数f(x)在x轴上的交点x,对y点做一切线,切线交于X轴的点为X1,求出X1点,并对X1点的y点做切线交于X轴为X2点,不断重复,求出X点

Xn+1 = Xn - f(Xn)/f’(Xn)

x^2 = 5

x = 根号5

X1 = X0 - (X0^2 - 5/2X0)
X1 = X0 - 1/2 * X0 + 5/2X0
X1 = 1/2 * X0 + 5/2X0

X点的误差在
E1 = |X - X1|
E2 = |X - X2|

En = |根号5 - Xn-1|

E2 约等于 E1^2

f’不能太小 f’’不能太大并且X0要在X的附近

中值定理

(f(b) - f(a)) / (b - a) = f(c)’ 要求x在a < x < b 之间可微,在a <= x <= b之间连续

比如:一辆车从北京到上海,在路上,一定有一段时间的速度等于平均速度

如果f’ > 0 则 f 增长
如果f’ < 0 则 f 递减
如果f’ = 0 则 f 是常数

重要不等式

e^x > 1+x

e^x > 1+x+1/2*x^2

微分

y = f(x) 的微分记作 dy = f(x)’dx

下面的例子,求解出来是fx = y + dy,其实就是线性近似 fx = fa + f’(x - a), x - a其实就是dx,f’ * dx就是dy,fa就是y

1
2
3
4
5
6
7
8
9
10
11
12
13
14
例子1
求 64.1的1/3次方
令 y = x的1/3次方
dy = 1/3X^-2/3 * dx
当x=64的时候, y = 64^1/3 = 4
dy = 1/3 * 64^-2/3 * dx
= 1/3 * 1/16 * dx
= 1/48 * dx
如果x=64,求64.1,则dx = 0.1
求 64.1的1/3次方, y = 64的1/3次方,那么
64.1的1/3次方 = y + dy = 4 + (1/48 * dx)
= 4 + (1/48 * 1/10)
= 4 + 1/480
约等于 4.002

反导数(不定积分)

一阶导数微分的解就是函数,二阶导数微分的解就是一阶导数。式子 f’ = f + C

G(x) = 积分 g(x) dx, Gx 就是 gx 的反导数

积分sinx dx = -cosx,因为 -cosX的一阶导数是 sinX所以 积分sinX * dx = -cosX

不定积分的不定就是可以在后面加上一个常数C,也就是

1
Gx = 积分 sinX * dx = -cosX + C也成立

重要积分

  • x^a的不定积分 = (1/a+1 * X ^ a+1) + C 当 a 不等于 - 1时成立,因为a = -1分母为0
  • 1/X的不定积分 = (ln|X|) + C
  • sec2X 的不定积分 = tanX + C
  • 1/根号 1-X^2 的不定积分 = sin-1X + C
  • 1/1+X^2的不定积分 = tan-1X + C

积分换元法

1
2
3
4
5
6
7
8
9
10
例子1
求解 X^3 * (X^4 + 2)^5 * dx 的积分

令 u = x^4 + 2, 则 du = u' + dx = 4x^3 * dx

x^3 * (x^4 + 2)^5 * dx
= u^5 * x^3 * dx
= u^5 * 1/4 * du
= 1/24 * u^6 + C
= 1/24 * (x^4 + 2)^6 + C

提前猜测

例子2

1
2
3
4
5
6
求解 e^6x 的积分

e^6x的导数是 6*e^6x
他的导数乘以 1/6就是 e^6x
所以 积分就是 1/6 * e^6x + C

例子3

1
2
3
4
求 x * e^-x^2的积分

猜测 e^-x^2,求导 = e^-x^2 * -2x
所以 积分 = 1/2 * e^-x^2 + C

例子4

1
2
3
4
5
6
7
8
9
10
求 sinx cosx的积分

猜测 sinX^2, 求导 = 2sinxcosx
所以 积分 = 1/2sinx^2 + C

也可以猜测 cosx^2,求导 = -2sinxcosx
所以 积分 = -1/2cosx^2 + C

两个都成立,两者可以相减 1/2 sinx^2 - (-1/2 cosx^2) = 1/2 所以 两个 C 相差 1/2

高级猜测

例子4 求 (d/dx + x) * y = 0

1
2
3
4
5
6
7
8
9
10
11
12
(d/dx + x) * y = 0
dy/dx + xy = 0
dy/dx = -xy
dy = -xy * dx
dy/y = -x * dx 把y和x各放到一边
积分 dy/y = 积分 -x * dx 对两边同时积分
因为 lny的导数是1/y 所以 积分 dy/y = lny
因为 -x^2/2 的导数是 -x 所以 积分 -x * dx = -x^2/2
则 : lny = -x^2/2 + C y > 0
e^lny = e^-x^2/2 + C 对两边同时取对数
y = A * e^-x^2/2 (A = e^c)

分离变量法

1
2
3
4
5
6
7
dy/dx = f(x) * g(y) = -x * y
dy/g(y) = f(x) * dx
G(y) = 积分 dy/g(y)
F(x) = 积分 f(x) * dx
G(y) = F(x) + C
上面是隐式方程,为了变成显式方程还需要求逆
y = G^-1(F(x) + C)

定积分

几何意义是求函数曲线下的面积

  1. 划分成多个矩形 所有矩形的底边一样长,都是b/n

例子1 y = x^2 的定积分 a = 0, b = n

1
2
3
4
5
6
7
8
9
划分成多个矩形后,第一个矩形的面积 = 底 * 高 = b/n * f(x) = b/n * (b/n)^2
第二个矩形的面积 = 底 * 高 = b/n * f(x) = b/n * f(2 * b/n) = b/n * (2b/n)^2
第n个矩形的面积 = b/n * (nb/n)^2
矩形面积的和 提取公因子 (b/n)^3 * (1^2 + 2^2 + .... + n^2)
(1^2 + 2^2 + .... + n^2) 想成金字塔,其体积最小是 1/3 * n^3 ,体积最大是 1/3 * (n+1)^3
而矩形面积的和 = (b/n)^3 * (1^2 + 2^2 + .... + n^2) = b^3 * (1^2 + 2^2 + .... + n^2) / n^3
1/3 < (1^2 + 2^2 + .... + n^2) / n^3 < 1/3 * (n+1)^3 / n^3
根据夹逼定理,左边的极限是 1/3 右边 1/3 * (n+1)^3 / n^3 = 1/3 * (n+1/n)^3 = 1/3 * (1 + 1/n)^3 当n趋于无穷的极限也是1/3所以中间的极限是1/3
矩形面积的和 = b^3 * (1^2 + 2^2 + .... + n^2) / n^3 = b^3 * 1/3

定积分

  • x^2 = b^3/3
  • x = b^2/2
  • 1 = b

微积分第一基本定理

if F(x)’ = f(x) , than 从a到b f(x) dx的定积分 = F(b) - F(a) = b的积分 - a的积分

例子1 x^2

1
2
从a到b x^2 dx 的定积分 = F(b) - F(a) = b^3/3 - a^3/3
当a = 0,则 b^3/3 - 0/3 = b^3/3

运算法则

  • 积分(fx + gx) = 积分fx + 积分gx
  • 积分(c * fx) = c * 积分fx
  • 如果 a < b < c 则 a到b的积分 + b到c的积分 = a到c的积分
  • a到a的积分 = 0
  • a到b的积分 = -(b到a的积分)
  • 如果 fx <= gx,那么从a到b fx的积分 <= gx的积分 a < b

微积分第二基本定理

if f 是连续的函数,并且 G(x) = 从a到x的积分 f(t) dt, than G(x)’ = f(x)

平均公式

1/(b - a) * 从a到b的积分f(x) dx

加权平均公式

从a到b的积分 f(x) w(x) dx / 从a到b的积分 w(x) dx

圆盘法

先求一个圆盘的体积,也就是 面积 * 高 = πr^2 * dx,然后积分

壳层法

先求竖着的圆柱的体积,绕一圈在展开变成长方体,求体积就是 长 * 宽 * 高 = 圆的周长 * dx * f(x),然后积分

数值积分

三角替换

  • tanx的积分 = ln(cosx) + C
  • secx的积分 = ln(secx + tanx) + C

例题1

求secX的4次方的积分

1
2
3
4
因为 sexX^2 = 1 + tanX^2,所以secx^4 dx的积分 = (1 + tanx^2)sexX^2 dx的积分

令 u = tanx, du = secX^2 dx,则 = (1 + u^2) du的积分 = u + u^3/3 + C
= tanx + tanx^3/3 + C

例题2

1/(x^2根号下1+x^2)的积分

例题3

tan (arc cscx) = 1/ (根号x^2 - 1)

被积函数 三角替换 结果
根号下a^2 - x^2 x = acosx or y = asinx asinx or acosx
根号下a^2 + x^2 x = atanx asecx
根号下x^2 - a^2 x = asecx atanx

例题4

dx/根号x^2+4x

部分分式

如果分子项数 < 分母项数,可以用掩盖法

  1. 对分母因式分解成 x/(x+1)(x-2) 的形式
  2. 设置未知数 变成 A/x+1 和 B/x-2
  3. 掩盖法解A,B,先同乘以一个分母 比如 x+1 则变成 A + B/(x-2) * (x + 1),令x = -1则 B这项为0从而解出A,同理解出B

如果分子项数 >= 分母项数, 用直接除法变成 分子项数 < 分母项数的形式

常用积分

被积函数 结果
lnx的积分 xlnx - x + C
(lnx)^2 x(lnx)^2 - 2(xlnx - x) + C
tanx的积分 ln(cosx) + C
cotx的积分 ln(sinx) + C
secx的积分 ln(secx + tanx) + C

php实现归并排序算法

归并排序算法的复杂度是O(nlogn)。

代码如下,完整代码在github上面,只需要clone下来执行composer install然后执行 php artisan test:mergeSort 就可以看到结果了

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
/**
* 归并排序把数据逐步分解,然后对分解后的数据进行排序,最后合并到一起
*
* @return mixed
*/
public function handle()
{
$this->a = [3,70,4,38,5,6,8,4,7,10,6,10,34,4];
dump($this->a);
$a = $this->mergeSort($this->a, 0, count($this->a));
dd($a);
}

private function mergeSort($a, $lo, $hi) {
if (($hi - $lo) < 2) return [$a[$lo]];
$mi = ($lo + $hi) >> 1;
//把中点左边的进行归并
$b = $this->mergeSort($a, $lo, $mi);
dump('$b:',$b);
//把中点右边的进行归并
$c = $this->mergeSort($a, $mi, $hi);
dump('$c:',$c);
//把所有数据进行排序
return $this->merge($b, $c, $lo,$mi,$hi);
}

/**
* 假设有一个数组$a分成了两个数组[3,4] [2,8]
* 逐一比较,3and2,取出来2然后3and8取出来3然后4and8取出来4,最后取出来8
*
* @param [type] $lo
* @param [type] $mi
* @param [type] $hi
* @return void
*/
private function merge($b, $c, $lo, $mi, $hi) {
$lb = $mi - $lo; //$b数组的边界
$lc = $hi - $mi; //$c数组的边界
$res = [];
//$i表示合并后数组的下标 $ib是b数组的下标 $ic是c数组的下标
for($i = 0,$ib=0,$ic=0;$ib<$lb || $ic < $lc;){
//ib 下标没有越界 && c的数组已经空了也就是$ic >= $lc || 比较两个数组首位的大小 如果b的首元素 < c的首元素,那么取出来b的首元素
if ($ib < $lb && ( $ic >= $lc || $b[$ib] <= $c[$ic])) {
$res[$i++] = $b[$ib++];
}
//k 下标没有越界 && b的数组已经空了也就是$ib >= $lb || 如果c的首元素 < b的首元素,那么取出来c的首元素
if ($ic < $lc && ($ib >= $lb || $b[$ib] > $c[$ic])) {
$res[$i++] = $c[$ic++];
}
}
return $res;
}

归并排序原理

归并排序和快排刚好相反,是先将整个数组左右打散,然后在逐一合并进行排序,最终完成整个数组的排序,排序示意图如下:

mergesort1

首先将整个数组左右打散,变成单个元素,因为单个元素可以被认为是有序的。
对应代码

1
2
3
4
5
6
7
8
if (($hi - $lo) < 2) return [$a[$lo]];
$mi = ($lo + $hi) >> 1;
//把中点左边的进行归并
$b = $this->mergeSort($a, $lo, $mi);
dump('$b:',$b);
//把中点右边的进行归并
$c = $this->mergeSort($a, $mi, $hi);
dump('$c:',$c);

接下来对左右两个有序数组进行排序,假设有一个数组$a分成了两个数组[3,4] [2,8],逐一比较,3and2,取出来2然后3and8取出来3然后4and8取出来4,最后取出来8,对应代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$lb = $mi - $lo; //$b数组的边界
$lc = $hi - $mi; //$c数组的边界
$res = [];
//$i表示合并后数组的下标 $ib是b数组的下标 $ic是c数组的下标
for($i = 0,$ib=0,$ic=0;$ib<$lb || $ic < $lc;){
//ib 下标没有越界 && c的数组已经空了也就是$ic >= $lc || 比较两个数组首位的大小 如果b的首元素 < c的首元素,那么取出来b的首元素
if ($ib < $lb && ( $ic >= $lc || $b[$ib] <= $c[$ic])) {
$res[$i++] = $b[$ib++];
}
//k 下标没有越界 && b的数组已经空了也就是$ib >= $lb || 如果c的首元素 < b的首元素,那么取出来c的首元素
if ($ic < $lc && ($ib >= $lb || $b[$ib] > $c[$ic])) {
$res[$i++] = $c[$ic++];
}
}
return $res;

示意图如下:

mergesort2

无序数组去重算法

无序数组去重算法的复杂度是O(n2)。

代码如下,首先进行外层循环,复杂度O(n),然后查找这个元素之前的元素中有没有重复的,复杂度O(n),如果有就删除,复杂度O(1),没有就下一个元素,复杂度O(1)。加起来复杂度O(n2)。

完整代码在github上面,只需要clone下来执行composer install然后执行 php artisan test:unsortDeduplicate 就可以看到结果了

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
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$a = [4,5,4,3,8,6,6,10,34,10,4];
dump($a);
$i = 1;
$len = count($a);
dump("长度:".$len);
while ($i < $len) { //循环全部数据
//在整个数组中寻找这个值,如果找到了就删除他,如果没找到就下一个
$preIndex = $this->find($i, $a);
if ($preIndex!==false) {unset($a[$preIndex]);}
else $i++;
}
dd($a);
}

private function find($i, array $a) {
$index = 0;
//循环从0到这个下标
while ($index < $i) {
//不存在说明被删除了
if (!array_key_exists($index, $a)) {$index++;continue;}
//如果找到了返回下标
if ($a[$i] == $a[$index]) return $index;
else $index++;
}
return false;
}

有序数组去重算法

有序数组去重算法的复杂度是O(n)。

代码如下,只进行一次循环,复杂度O(n)

完整代码在github上面,只需要clone下来执行composer install然后执行 php artisan test:sortDeduplicate 就可以看到结果了

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
/**
* 因为是有序数组,为了提高去重效率,取一个元素往后一直比对,如果相邻的相等表示是重复的
* 继续往后,直到不相等,也就是遇到一个不重复的为止,将这个不重复的元素移动到该元素的下一个
* 然后用这个不重复的元素为起始,重复上述操作。直到最后
* 删除最后一个不重复元素后面所有的元素
* 因为只循环一遍,复杂度O(n)
*
* @return mixed
*/
public function handle()
{
$a = [3,4,4,4,5,6,6,8,10,10,34];
dump($a);
//分别代表第一个数据,和要比对的数据
$i = 0;$j=0;
$len = count($a);
dump("长度:".$len);
while ((++$j) < $len) { //循环全部数据
//从$i往后寻找,如果相邻的相等表示是重复的,继续往后,直到不相等,也就是遇到一个不重复的为止,将这个不重复的元素移动到该元素的下一个
if($a[$i] != $a[$j]) {
$a[++$i] = $a[$j];
}
}
//截取前面去重过的数据
$a = array_slice($a, 0, ++$i);
dd($a);
}

安装

直接使用 composer 安装 ES 包就可以了,这里使用官方的 elasticsearch/elasticsearch 这个包。

1
composer require elasticsearch/elasticsearch

安装好以后,创建一个客户端。hosts如果是多个节点的集群,那么可以配置一个二维数组。

1
2
3
4
5
6
7
8
9
10
$hosts = [
'host' => '127.0.0.1',
'port' => '9200',
'scheme' => 'http',
'user' => '',
'pass' => ''
];
$client = ClientBuilder::create() //创建客户端
->setHosts($hosts) //hosts连接地址
->build();

如果想跳过ssl证书校验,可以添加一些curl的参数放进客户端

1
2
3
4
5
6
7
8
$curlParams = [//不校验ssl
CURLOPT_SSL_VERIFYPEER => 0,
CURLOPT_SSL_VERIFYHOST => 0,
];
$client = ClientBuilder::create()
->setConnectionParams(['client' => ['curl' => $curlParams]]) //设置curl参数
->setHosts($hosts)
->build();

分词

简单的增删改查在 elasticsearch 的文档中有介绍了,就不说了,可以看(https://www.elastic.co/guide/cn/elasticsearch/php/current/_quickstart.html)[文档]。

分词的话隐藏的比较深,文档中没有介绍,他放在了indices这个 namespace 下面。如果看源码,可以在Endpoints/Indices 目录下面发现 Analyze.php 文件,当然了,除了分词,这里面还有其他功能,可以自己看。

这个文件也很简单啊,只有几个函数,就是设置请求的API地址,参数这些。

使用起来是这样的,我们用上面创建好的客户端。

1
2
3
4
5
6
7
$parmas['index'] = 'test' //这个是分词的index,也可以不加,加了请求的API就是 $index/_analyze
//请求体 这个就和你直接写DSL没区别了,参数啥的都一样,可以在 kibana里面试试参数
$parmas['body'] = [
'text' => 'php开发', //要分词的文字
'analyzer' => 'ik_smart', //分词器,可以不写
];
$client->indices()->analyze($params);

扩展

indices函数返回的就是 indices 文件夹的 namespace,对应文件在namespace/IndicesNamespace.php,然后后面的函数就相当于文件名,她会拼接在 indices 后面,像我们上面请求的文件就是indices/Analyze.php。具体的拼接就是在 namespace/IndicesNamespace.php 这个里面做的,有一个函数如下

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
/**
* $params['index'] = (string) The name of the index to scope the operation
* $params['body'] = (array) Define analyzer/tokenizer parameters and the text on which the analysis should be performed
*
* @param array $params Associative array of parameters
* @return array
* @see https://www.elastic.co/guide/en/elasticsearch/reference/master/indices-analyze.html
*/
public function analyze(array $params = [])
{
//获取$params['index'], $params['body']
$index = $this->extractArgument($params, 'index');
$body = $this->extractArgument($params, 'body');

//设置endpoints处理类
$endpointBuilder = $this->endpoints;
//$endpoint 就相当于 indices/Analyze.php 这个文件了
$endpoint = $endpointBuilder('Indices\Analyze');
//设置参数,index, 请求体
$endpoint->setParams($params);
$endpoint->setIndex($index);
$endpoint->setBody($body);
//发起请求
return $this->performRequest($endpoint);
}

$this->endpoints 是一个函数,外面传进来的,函数内容如下

1
2
3
4
5
6
7
8
9
10
11
12
13
$this->endpoint = function ($class) use ($serializer) {
//拼接处理类
$fullPath = '\\Elasticsearch\\Endpoints\\' . $class;
//反射获取
$reflection = new ReflectionClass($fullPath);
$constructor = $reflection->getConstructor();
//执行
if ($constructor && $constructor->getParameters()) {
return new $fullPath($serializer);
} else {
return new $fullPath();
}
};

go学习第一章

在go现在的版本里面可以使用go mod来管理依赖了

使用 go mod 意味着不需要设置多个 GOPATH

go mod 对应的环境变量 GO111MODULE 有三个值,默认auto

  • on 模块支持,go命令行会使用modules,而一点也不会去GOPATH目录下查找
  • off 无模块支持,go命令行将不会支持module功能,寻找依赖包的方式将会沿用旧版本那种通过vendor目录或者GOPATH模式来查找。
  • auto 只要当前目录或者父目录有go.mod文件,那么就以on的形式工作。

标准输入输出

使用fmt包进行标准输出

1
2
3
4
5
6
7
package main

import "fmt"

func main() {
fmt.Println("hello")
}

使用flag 包进行标准输入

有两种方式

  • 一种是传入变量地址的StringVar,第二个参数是输入的变量名,第三个是默认值,第四个是描述
  • 还有一种是String,除了第一个参数不同,是通过返回值接收参数,其他都一样
1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import (
"fmt"
"flag"
)
func main() {
var name string
flag.StringVar(&name, "name", "default", "desc")
name = flag.String("name","default","desc")
flag.Parse()
fmt.Println(name)
}

使用的时候go run hello.go -name=123

-name就是你输入的名称,前面要加- 或者 -- 没有或者有三个都是不行的。每个输入默认自带-h 来获取命令帮助,帮助里面会显示描述和默认值

go mod 下的本地包拆分

如果我们想把代码分散到不同的文件中怎么做呢?

假设现在的目录如下

  • main.go
  • test
    • test.go

我们想要在main.go引用test.go的代码

首先在go里面通过首字母大小写决定访问权限首字母大写 = public首字母小写 = protected or private

假设test.go代码如下

1
2
3
4
5
package test //包名和目录保持一致

func Test() {

}

main.go代码如下

1
2
3
4
5
package main

func main() {

}

我们想使用test这个包里面的东西需要使用go mod

执行go mod init name,name 表示当前包的名称,可以随便取

这个时候会生成go.mod文件,假设刚才执行的是go mod init hello,那么我们的go.mod文件如下

1
2
3
module hello

go 1.17

这个时候我们就可以在main.go引入test.go了,main.go代码如下

1
2
3
4
5
6
7
package main

import "hello/test" //引入hello这个包下面的test包

func main() {
test.Test() //这里可以使用test包的Test方法了
}

当然了,包名test也可以和目录test不一致,比如test.go文件内容如下

1
2
3
4
5
package test5 //包名和目录不一致

func Test() {

}

我们在main.go依然可以引入,但是需要更改一下调用,或者加个别名

1
2
3
4
5
6
7
package main

import test5 "hello/test" //别名,不用也可以,不过有的会报错

func main() {
test5.Test() //这里可以使用test5包的Test方法
}

变量

变量的声明有两种方式

  • var 可以使用在任何地方,不可以重复声明
    • var [name] [type]
    • type在变量声明的时候如果有赋值,那么可以省略
    • var [name] = 1
    • var 在外面的声明,可以在使用之后声明
    • var 在局部声明,必须在使用之前声明
  • := 只能使用在函数等代码块里面,当有新的参数在左边声明的时候可以重声明

比如

1
2
3
4
5
var a string
var b = a //b也是string
var a = "123" // 报错,因为a已经声明过了
a, c := "123","456" //不会报错,因为:=可以重声明,但是必须有一个新变量,比如c
a, c := "456", "123" //报错,因为a,c都已经声明过了,都是旧变量

简单指针变量

比如声明一个变量

1
var a string

那么会产生一个内存块

内存地址 变量内容
001

这个时候可以通过&操作符取地址

1
2
println(a) //打印变量内容 空
println(&a) //打印变量内存地址 001

如果我们声明一个指针变量

1
2
var b *string
b = &a //指针变量只能存储内存地址

那么内存块

内存地址 变量内容
001
002 001

这个时候输出,可以通过*操作符取指针的值

  • 第一步先找出指针变量的内容001
  • 第二步将001作为内存地址查询对应地址的内容
1
2
3
println(b)  //输出的是变量b的内容001
println(*b) //输出的是变量b的内容001作为内存地址的内容空
println(&b) //输出的是变量b的内存地址002

变量类型转换和类型断言

可以使用 type 创建新的类型和声明类型的别名

1
type astring = string  //声明一个string类型的别名

这个时候astringstring 这两个类型是完全一样的,没有任何区别

1
2
3
4
5
6
7
8
var a string
func main() {
a = "1234"
as = a //可以赋值
as = astring(a) //可以类型转换
as, ok := interface{}(a).(astring) //可以类型断言
fmt.Println(as, ok, as == a) //可以比较
}

如果是创建新的类型

1
type astring string

这个时候这两个类型没啥关系了,但是因为底层都是string 还是可以进行类型转换的,如果底层类型不是string,那么连转换都不行

1
2
3
4
5
6
7
8
var a string
func main() {
a = "1234"
as = a //不可以赋值
as = astring(a) //可以类型转换
as, ok := interface{}(a).(astring) //类型断言失败
fmt.Println(as, ok, as == a) //不可以比较
}

类型转换

类型转换,比如int8转成int16,但是这种转换只适用于int和int之间,string和string之间转换,还有上面的别名和新类型之间底层类型一致的转换

1
2
3
var b int8
b = 1
int16(b) //直接转换

如果是高类型像低类型转换,那么直接取后面的位数,高位会舍弃,比如

1
2
3
var a int16 //
a = 3000 //这个时候a在计算机存储的二进制 = 0000 1011 1011 1000‬
b := uint8(a) //如果转换成8位int,那么是取后面的8位 1011 1000‬ b = 184

当然了,一个int也是可以转成string的,但是会把int值当成一个Unicode值,如果不是一个Unicode能表示的,那么会显示成乱码,比如

1
2
b := 69
t := string(b) //t = E

类型断言

类型断言,想要判断一个变量是什么类型,就可以使用类型断言。使用之前需要先转成interface类型,interface是所有类型的爸爸。
返回两个值,第一个是断言并转换后的值,第二个值表示是否是这个类型,如果ok = true,那么v=转换后的值,如果ok = false, 那么v = nil(空值)

1
2
var a astring
v, ok := interface{}(a).(astring) //判断a是不是一个astring类型

数组和切片

切片的底层是一个数组,切片是对数组的引用。

  • 数组 [len]string 数组长度固定不可变
  • 切片 []string 切片长度可变,可以看做可变长度的数组

数组和切片都有长度length容量cap的属性

  • 数组的长度和容量都是一样的
  • 切片的长度表示现在数据的长度,容量表示底层数组的长度也就是切片的最大长度

比如下面,可以看到只修改c[0]的值,但是其他的值也变了,因为是修改了底层数组a的值,所以底层数组和其他引用的值都变了。

1
2
3
4
5
6
7
a := [3]int{1,2,3} //3长度的数组
b := a[0,2] //2长度 3容量的切片
c := a[0,1] //1长度 3容量的切片
d := b[0,1] //1长度 3容量的切片
// b c d的底层都是数组a
c[0] = 100
fmt.Println(a,b,c,d) //a [100 2 3] b [100 2] c [100] d [100]

切片的扩容

切片的容量变化,如果切片b现在变成一个5长度的会怎么样呢,底层会进行一个扩容,会创建一个新的底层数组,然后一个新的切片,返回这个新的切片给b。

扩容以后,容量如果小于1024,每次容量会乘以2,比如b的容量3乘以2变成6,如果大于1024,那么每次会乘以1.25,但是计算完以后还会进行一个内存对齐的操作。

字典map

字典是一个hash表,声明方式如下,有着hash的优势,比如key-value是O(1)的复杂度,但是map是无序的,每次遍历的顺序不一定。

1
2
3
4
var m1 map[int]string //key是int,value是string的map,但是这样声明的map值是nil,并且不能赋值
m1[1] = "2" //报错
m2 := map[int]int{1:1,2:2} //key是int,value也是int的map
m3 := make(map[int]int, 5) //创建一个key是int,value也是int,长度为5的map

channel

channel是一个并发安全的类型。channel分为带缓冲区的和不带缓冲区的。声明方式如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

import (
"fmt"
)

func main() {
// 示例1。
go func() {
fmt.Println(123)
var ch1 chan int //里面可以传输int的channel,默认值nil,如果这样声明会造成一个永久阻塞的channel,后面的代码不会执行
fmt.Printf("ch1:%s", ch1)
}()

// ch1 <- 1
ch2 := make(chan int, 1) //声明有一个缓冲区的channel
ch3 := make(chan int, 0) //声明不带缓冲区的channel
ch2 <- 1
// ch2 <- 2
// m1["2"] = 3
fmt.Println(<-ch2, ch3)
}

把数据传给channel,使用ch2 <- 数据的方式把数据传给ch2这个channel,channel的数据传递全部都是浅拷贝,下面的例子可以发现,修改s的值会使得s1的值也被修改。

1
2
3
4
5
6
7
8
   ch2 := make(chan []string, 1)
s1 := []string{"1", "2"}
ch2 <- s1
// ch2 <- 2
// m1["2"] = 3
s := <-ch2
s[1] = "34"
fmt.Println(s, s1)

数据接收使用变量 := <- ch2 来接收ch2的数据到一个变量中

1
s := <- ch2

单向channel

单向channel可以限制函数的行为,比如chan<-类型的只能发送数据到channel中,<-chan类型的只能从channel中获取数据。

1
2
3
4
5
6
7
func getChan(ch <-chan) {
//函数里面只能从 ch 这个 channel中获取数据而无法发送数据,这样限制了这个函数里面的行为
}

func setChan(ch chan<-) {
//函数里面只能往 ch 这个 channel中发送数据而无法获取数据
}

函数

go中的函数是一等公民可以作为type类型,可以作为参数,可以作为返回值,可以赋值给变量,可以和nil做比较等等

函数的声明

1
2
3
func name(arg1 int, arg2 int) (r int, err error) {
//....
}

函数的类型,声明一个类型 afunc afunc的底层类型是一个接受一个string参数,返回一个int参数和一个error类型参数的函数,函数签名是函数的参数列表和返回值列表,如果参数列表的类型一致并且返回值列表的参数类型一致就可以认为是一样的函数。

1
2
3
4
type afunc func(string) (int, error) //声明一个类型 afunc afunc的底层类型是一个接受一个string参数,返回一个int参数和一个error类型参数的函数
var a afunc //可以声明一个变量,类型是 afunc 的变量
a = name //可以把函数签名一致的函数赋值给这个函数变量
a(1,2) //等于 name(1,2)

结构体

结构体的声明

1
2
3
4
5
6
7
8
type as struct{ //声明一个名称叫 as 的结构体
a string //as 有 一个string的属性 a
b int //一个int的属性b
}
//声明一个属于as结构体的方法String
func(this as) String() string{
return fmt.Sprintf(this.a) //访问as的属性a
}

从上面的声明可以看出来,可以把struct简单的类比成class,这个as的结构体有两个属性,一个方法

使用结构体

1
2
3
4
5
6
7
8
func main() {
as1 := as{ //初始化as这个结构体
a: "1",
b: 1,
}
as2 := as{} //也可以不初始化
fmt.Println(as1, as2)
}

结构体的组合,也可以类比成class的继承,不过组合比继承更有优势。组合进来以后,asT结构体就拥有了as类的属性和方法,但是由于asT有a,as也有a属性,asT的就把as的覆盖了

1
2
3
4
5
6
7
8
9
10
11
12
13
type asT struct {
a string //覆盖了as的a属性
as //把as组合,嵌入进asT结构体,可以类比成 asT类继承了as类
}
as2 := asT{}
as2.a = "3" //修改的是as2的a属性
as2.as.a = "2" //修改的是as2.as.a属性 可以类比成修改了父类的a属性
as2.String() //可以调用as2.String方法,因为as有这个方法,他组合进来也拥有了这个方法
//这样可以定义asT的String方法,这样的话上面的代码就会访问这个方法了,可以类比成重写了String方法
func(this asT) String() string{
return fmt.Sprintf(this.a) //访问asT的属性a
}
as2.as.String() //就算覆盖了,依然可以这样调用as的String方法

结构体可以组合多个结构体,也可以类比成多继承。但是这样有一个问题,比如组合的两个结构体内有同样名称的属性或者方法就会报错。声明的时候不会报错,只有使用的时候会报错,因为不知道使用哪个,如果指定相应的结构体进行使用就不会报错了。

1
2
3
4
5
6
7
type asD struct{
as
asT
}
as3 := asD{}
as3.a := "3" //报错
as3.as.a := "4" //正常

还有一种方法,比如在新的结构体中定义一个同名的属性,就会覆盖其他的,所以就不会报错了

1
2
3
4
5
6
7
type asD struct{
a int
as
asT
}
as3 := asD{}
as3.a := 3 //正常

还有结构体中的覆盖是通过名称来判断覆盖的,跟数据类型没有关系,方法的覆盖也是一样,跟参数列表和返回值没有关系

结构体中指针的使用

1
2
3
4
5
6
7
8
9
func(this asT) String() string{
this.a = 5 //这里不可以赋值,因为this是一个值类型,这个赋值并不会真正的改变asT结构体的值,只是会改变当前this变量的值而已
return fmt.Sprintf(this.a) //访问asT的属性a
}

func(this *asT) String() string{
this.a = 5 //这里可以赋值,因为this是一个指针类型
return fmt.Sprintf(this.a) //访问asT的属性a
}

这个是有定义的时候有区别,因为在调用的时候go会自动转换,比如

1
asT.String() //如果接受的是一个*asT类型的值,这里go会转换成(&asT).String()的调用

接口

接口是interface和一般语言的接口没啥区别,但是go的接口是一种无侵入式实现,比如下面的代码我们声明了一个ai的接口,声明了一个as的结构体,这个结构体的方法和ai的方法一样,那么就算实现了ai的接口,可以赋值给ai接口类型的变量。

这里需要方法名称方法签名这两个全部一致才算实现了这个接口,还有要注意,*as代表SetName是 *as的方法而不是as的方法,所以我们只能把&as1赋值过去,如果赋值as1会报错。因为as1只有一个GetName方法

接口变量具有三个属性

  • 静态类型 ai
  • 动态类型 赋值时候确定,比如赋值了&as1,那么动态类型就是*as
  • 动态值 也就是&as1
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
package main

import "fmt"

type ai interface { //声明一个名称叫 ai 的接口
SetName(string) //该接口有一个SetName方法
GetName() string //有一个GetName方法
}

type as struct { //声明一个名称叫 as 的结构体
a string //as 有 一个string的属性 a
b int //一个int的属性b
}

//声明一个属于as结构体的方法 GetName
func (this as) GetName() string {
return fmt.Sprintf(this.a) //访问as的属性a
}

//声明一个属于as结构体的方法 SetName
func (this *as) SetName(name string) {
this.a = name
}

func main() {
var ai1 ai
as1 := as{}
ai1 = &as1
fmt.Println(as1, ai1)
}

接口类型的nil值,接口只有声明的时候和赋值nil字面量的时候才是真正的nil值,看下面,输出结果a1是2,因为ai1不是真正的nil,ai1的动态值是nil,但是动态类型是*as,所以ai1不是nil

1
2
3
4
5
6
7
8
9
   var as2 *as //as2是nil
ai1 = as2
var a1 int
if ai1 == nil { //ai1不是nil
a1 = 1
} else {
a1 = 2
}
fmt.Println(as1, ai1, a1)

goroutine

goroutine 是一个 go的用户级线程,也叫协程。

使用的话就是下面这样 go 后面跟上协程需要执行的函数代码。首先启动go程序的时候,会启动一个主进程。然后主进程生成一个主线程来执行go程序的main函数。执行的时候是一个for循环。

  • 执行第一次循环的时候i = 0
  • 然后执行到了go func代码
  • 由go的runtime查找是否有空闲的协程。如果没有那么创建一个协程。
  • 然后把go func的代码放入创建好的协程。
  • 最后把这个包含了go func代码的协程放入协程的等待队列中
  • 直到有空闲的线程,从等待队列中取出一个协程,执行这个协程的代码

可以看到下面这段代码的执行结果,是什么也不会发生。

1
2
3
4
5
6
7
func main() {
for i := 0; i < 10; i++ {
go func() {
fmt.Println(i)
}()
}
}

因为在for循环执行以后goroutine的代码还没有得到执行机会的时候,主线程main函数执行完了。那么这个时候系统的主线程就会关闭了,主进程也会关闭了。所以协程并没有执行。

看下面的代码,增加了定时器,这个时候会输出10个10,因为主线程执行到定时器的时候线程挂起,然后协程就有执行的时间了,但是协程开始执行的时候,for循环已经执行完了,这个时候变量i的值是10,所以10个协程打印出来的变量i的值都是10

1
2
3
4
5
6
7
8
func main() {
for i := 0; i < 10; i++ {
go func() {
fmt.Println(i)
}()
}
time.Sleep(time.Millisecond * 500)
}

看下面的代码,go func(i int){}(i),增加了入参i int类型,并且在调用的时候把变量i传入了进去,那这个时候呢,执行的结果就是输出0-9的乱序,因为我们无法保证协程的执行顺序,但是由于传了当时的变量i,而go是浅拷贝,所以协程中的变量i的值被固定了。

1
2
3
4
5
6
7
8
func main() {
for i := 0; i < 10; i++ {
go func(i int) {
fmt.Println(i)
}(i)
}
time.Sleep(time.Millisecond * 500)
}

for循环

for循环和别的语言一样

1
2
3
for i:=0; i< 10; i++ {
//...
}

还有 range 可以循环数组,切片,map。这里range会复制一个 numbers3 来进行循环,也就是说如果numbers3是数组,那么修改numbers3[0] 的值不会影响到 numbers3[0] 的值,因为数组是值类型。如果是切片那么会影响到。

1
2
3
4
5
6
7
8
9
10
11
12
13
   numbers2 := []int{1, 2, 3, 4, 5, 6}
numbers3 := numbers2[0:len(numbers2)]
maxIndex2 := len(numbers2) - 1
// i v对应key value,也可以只有一个key,没有value for i := range numbers3 {}
for i, v := range numbers3 { //range 后面跟一个切片或者数组,map
if i == maxIndex2 {
numbers3[0] += v
} else {
numbers3[i+1] += v
}
v = 1 //这里的v因为是int类型,所以修改他的值不会影响到numbers3切片里面的值
}
fmt.Println(numbers2, numbers3)

switch

switch语句不需要使用 break 了,因为go的switch只执行一个case,并且case后面可以跟多个结果,用逗号分隔,只要命中一个结果就执行这个case,所以case后面的结果也不能重复。

1
2
3
4
5
6
7
8
   var a int
switch 3 {
case 1, 2:
a = 1
case 3, 4:
a = 2
}
fmt.Println(a) //输出2

如果case后面重复,那么会报错

1
2
3
4
5
6
   switch 3 {
case 1, 2:
a = 1
case 2, 3, 4: //报错
a = 2
}

因为switch结果和case结果会进行判等的,所以他们两个的类型要是一样的,不然也会报错

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
   switch 3 {
case 1, 2:
a = 1
case "21", 3, 4: //报错
a = 2
}

var a int
var b uint8 = 3
switch b {
case 1, 2:
a = 1
case 3, 40000: //报错,因为40000 不是uint8能表示的
a = 2
}

var b uint8 = 3
switch 40000 {
case 1, 2:
a = 1
case 3, b: //位置换了也是报错
a = 2
}

bool查询

一个bool查询里面可以包含多个查询子句

must 必须匹配,贡献算分
should 选择性匹配,算分
must_not filter context 查询子句,必须不能匹配,不算分
filter filter context 必须匹配,但是不算分
  • 子查询可以任意顺序出现
  • 可以嵌套多个查询
  • 如果bool查询中,没有must条件,should中必须满足一个查询
  • should是一个数组

查询语句的结构,会对相关度算分产生影响

  • 同一层级下的竞争字段,具有相同的权重
  • 通过嵌套bool查询,可以改变对算分的影响

单字符串 多字段查询

可以通过 boolshould 来实现

1
2
3
4
5
6
7
8
9
10
11
POST test_home/_search
{
"query":{
"bool":{
"should":[
{"match":{"title":"php"}}
{"match":{"body":"php"}}
]
}
}
}

但是这样实现的算分过程可能不是我们想要的,算分过程如下

  • 查询 should 语句中的两个查询
  • 加和两个查询的评分
  • 乘以匹配语句的总数
  • 除以所有语句的总数

我们还可以使用最高算分 dis_max- queries

1
2
3
4
5
6
7
8
9
10
11
POST test_home/_search
{
"query":{
"dis_max":{
"queries":[
{"match":{"title":"php"}}
{"match":{"body":"php"}}
]
}
}
}

但是有的时候,最高评分是一样的,那怎么办呢,可以通过tie_breaker参数来调整

  • 获得最佳匹配语句的评分
  • 将其他匹配语句的评分与 tie_breaker相乘
  • 对以上评分求和并规范化
  • tie_breaker 是一个 0 - 1的浮点数,0代表最佳匹配,1代表所有语句同等重要。

三种场景

  • 最佳字段 best_fields 相当于 dis_max 查询
    • 当字段之间相互竞争,又相互关联。例如 title 和 body 这样的,评分来自最佳字段
  • 多数字段 most_fields
    • 处理英文内容时:一种常见的手段是,在主字段,抽取词干,加入同义词,以匹配更多的文档。相同的文本,加入子字段,以提供更加精确的匹配。其他字段作为匹配文档提高相关度的信号。匹配字段越多则越好
  • 混合字段 cross field
    • 对于某些实体,例如人名,地址,图书,需要在多个字段中确定信息,单个字段只能作为整体的一部分。希望在任何这些列出的字段中找到尽可能多的词

multi_match

极客时间 ES 学习笔记

搜索相关性算分

ES 会对搜索结果的相关性进行一个算分,算分结果放到_score 字段中。

算分是为了排序,ES5之前使用TF-IDF算法进行算分,之后使用BM25算法

词频 (TF)

  • term frequency: 检索词在一篇文档中出现的频率

    • 检索词出现的次数除以文档的总字数
  • 相关性:简单将搜索中每一个词的TF进行想加

    • TF(区块链) + TF(的) + TF(作用)
  • stop word: 没什么作用的词

    • 可能会出现多次,但是他对于相关性并没有什么作用,应该不考虑他的词频, 可以作为一个 stop word

逆文档频率 (IDF)

  • DF:检索词在所有文档中出现的频率

    • 区块链 在相对较少的文档中出现
    • 作用 在相对较多的文档
    • 的 作为 stop word 在大量的文档中出现
  • inverse document frequency: 简单说 = log(存储的全部文档数/检索词出现过的文档数)

  • TF-IDF 就是将 TF 求和变成了加权求和

比如,你ES里总共存储了10万条职位信息, 你检索 php,出现 php 的职位数量是1万,那么IDF = log(10万/1万) = log(10) = 1

  • TF-IDF 就是算出分词后所有词的TF和 IDF 并进行处理,比如区块链的作用 = TF(区块链) * IDF(区块链) + TF(的) * IDF(的) + TF(作用) * IDF(作用)

BM25

BM25对之前的TF-IDF 算法进行了一个优化,当TF的词出现的越来越多的时候,如果是TF-IDF 算法,那么分值会增加很多,而如果是 BM25 算法,则会趋于一个极限。

boosting

boosting可以对算分结果进行影响

正常搜索php首席的结果

1
2
3
4
5
6
7
8
PHP首席架构师
PHP架构师
PHP架构师001
首席软件架构师
首席软件架构师
首席科学家(科研副总经理)
首席科学家(工业传动技术)
商家端资深软件开发工程师(Go/PHP)

如果我们想降低首席这个词在搜索结果中的算分占比,可以使用boosting

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
GET job_ik/_search
{
"query": {
"boosting": { //boosting 关键字
"positive": { //positive 关键字
"match": {
"job_name": "php"
}
},
"negative": { //negative 关键字
"match": {
"job_name": "首席"
}
},
"negative_boost": 0.2 // 0 - 1,降低首席排序,1-100,提高首席排序
}
}
}

降低后的结果

1
2
3
4
PHP架构师
PHP架构师001
商家端资深软件开发工程师(Go/PHP)
PHP首席架构师

如果把negative_boost提升为2,那么结果如下

1
2
3
4
PHP首席架构师
PHP架构师
PHP架构师001
商家端资深软件开发工程师(Go/PHP)

极客时间 ES 学习笔记