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

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


  • 首页

  • 归档

  • 搜索

Laravel核心代码学习 -- Model增删改查底层实现

发表于 2018-05-31

上篇文章我们讲了Database的查询构建器Query Builder, 学习了Query Builder为构建生成SQL语句而提供的Fluent Api的代码实现。这篇文章我们来学习Laravel Database地另外一个重要的部分: Eloquent Model。

Eloquent Model把数据表的属性、关联关系等抽象到了每个Model类中,所以Model类是对数据表的抽象,而Model对象则是对表中单条记录的抽象。Eloquent Model以上文讲到的Query Builder为基础提供了Eloquent Builder与数据库进行交互,此外还提供了模型关联优雅地解决了多个数据表之间的关联关系。

加载Eloquent Builder

Eloquent Builder是在上文说到的Query Builder的基础上实现的,我们还是通过具体的例子来看,上文用到的:

1
复制代码DB::table('user')->where('name', 'James')->where('age', 27)->get();

把它改写为使用Model的方式后就变成了

1
复制代码User::where('name', 'James')->where('age', 27)->get();

在Model类文件里我们并没有找到where、find、first这些常用的查询方法,我们都知道当调用一个不存在的类方法时PHP会触发魔术方法__callStatic, 调用不存在的实例方法会触发__call, 很容易就猜到上面这些方法就是通过这两个魔术方法来动态调用的,下面让我们看一下源码。

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
复制代码namespace Illuminate\Database\Eloquent;
abstract class Model implements ...
{
public function __call($method, $parameters)
{
if (in_array($method, ['increment', 'decrement'])) {
return $this->$method(...$parameters);
}

return $this->newQuery()->$method(...$parameters);
}

public static function __callStatic($method, $parameters)
{
return (new static)->$method(...$parameters);
}

// new Eloquent Builder
public function newQuery()
{
return $this->registerGlobalScopes($this->newQueryWithoutScopes());
}

public function newQueryWithoutScopes()
{
$builder = $this->newEloquentBuilder($this->newBaseQueryBuilder());

//设置builder的Model实例,这样在构建和执行query时就能使用model中的信息了
return $builder->setModel($this)
->with($this->with)
->withCount($this->withCount);
}

//创建数据库连接的QueryBuilder
protected function newBaseQueryBuilder()
{
$connection = $this->getConnection();

return new QueryBuilder(
$connection, $connection->getQueryGrammar(), $connection->getPostProcessor()
);
}

}

Model查询

通过上面的那些代码我们可以看到对Model调用的这些查询相关的方法最后都会通过__call转而去调用Eloquent Builder实例的这些方法,Eloquent Builder与底层数据库交互的部分都是依赖Query Builder来实现的,我们看到在实例化Eloquent Builder的时候把数据库连接的QueryBuilder对象传给了它的构造方法, 下面就去看一下Eloquent Builder的源码。

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
复制代码namespace Illuminate\Database\Eloquent;
class Builder
{
public function __construct(QueryBuilder $query)
{
$this->query = $query;
}

public function where($column, $operator = null, $value = null, $boolean = 'and')
{
if ($column instanceof Closure) {
$query = $this->model->newQueryWithoutScopes();

$column($query);

$this->query->addNestedWhereQuery($query->getQuery(), $boolean);
} else {
$this->query->where(...func_get_args());
}

return $this;
}

public function get($columns = ['*'])
{
$builder = $this->applyScopes();

//如果获取到了model还会load要预加载的模型关联,避免运行n+1次查询
if (count($models = $builder->getModels($columns)) > 0) {
$models = $builder->eagerLoadRelations($models);
}

return $builder->getModel()->newCollection($models);
}

public function getModels($columns = ['*'])
{
return $this->model->hydrate(
$this->query->get($columns)->all()
)->all();
}

//将查询出来的结果转换成Model对象组成的Collection
public function hydrate(array $items)
{
//新建一个model实例
$instance = $this->newModelInstance();

return $instance->newCollection(array_map(function ($item) use ($instance) {
return $instance->newFromBuilder($item);
}, $items));
}

//first 方法就是应用limit 1,get返回的集合后用Arr::first()从集合中取出model对象
public function first($columns = ['*'])
{
return $this->take(1)->get($columns)->first();
}
}

//newModelInstance newFromBuilder 定义在\Illuminate\Database\EloquentModel类文件里

public function newFromBuilder($attributes = [], $connection = null)
{
//新建实例,并且把它的exists属性设成true, save时会根据这个属性判断是insert还是update
$model = $this->newInstance([], true);

$model->setRawAttributes((array) $attributes, true);

$model->setConnection($connection ?: $this->getConnectionName());

$model->fireModelEvent('retrieved', false);

return $model;
}

代码里Eloquent Builder的where方法在接到调用请求后直接把请求转给来Query Builder的where方法,然后get方法也是先通过Query Builder的get方法执行查询拿到结果数组后再通过newFromBuilder方法把结果数组转换成Model对象构成的集合,而另外一个比较常用的方法first也是在get方法的基础上实现的,对query应用limit 1,再从get方法返回的集合中用 Arr::first()取出model对象返回给调用者。

Model更新

看完了Model查询的实现我们再来看一下update、create和delete的实现,还是从一开始的查询例子继续扩展:

1
复制代码$user = User::where('name', 'James')->where('age', 27)->first();

现在通过Model查询我们获取里一个User Model的实例,我们现在要把这个用户的age改成28岁:

1
2
复制代码$user->age = 28;
$user->save();

我们知道model的属性对应的是数据表的字段,在上面get方法返回Model实例集合时我们看到过把数据记录的字段和字段值都赋值给了Model实例的$attributes属性, Model实例访问和设置这些字段对应的属性时是通过__get和__set魔术方法动态获取和设置这些属性值的。

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
复制代码abstract class Model implements ...
{
public function __get($key)
{
return $this->getAttribute($key);
}

public function __set($key, $value)
{
$this->setAttribute($key, $value);
}

public function getAttribute($key)
{
if (! $key) {
return;
}

//如果attributes数组的index里有$key或者$key对应一个属性访问器`'get' . $key . 'Attribute'` 则从这里取出$key对应的值
//否则就尝试去获取模型关联的值
if (array_key_exists($key, $this->attributes) ||
$this->hasGetMutator($key)) {
return $this->getAttributeValue($key);
}

if (method_exists(self::class, $key)) {
return;
}
//获取模型关联的值
return $this->getRelationValue($key);
}

public function getAttributeValue($key)
{
$value = $this->getAttributeFromArray($key);

if ($this->hasGetMutator($key)) {
return $this->mutateAttribute($key, $value);
}

if ($this->hasCast($key)) {
return $this->castAttribute($key, $value);
}

if (in_array($key, $this->getDates()) &&
! is_null($value)) {
return $this->asDateTime($value);
}

return $value;
}

protected function getAttributeFromArray($key)
{
if (isset($this->attributes[$key])) {
return $this->attributes[$key];
}
}

public function setAttribute($key, $value)
{
  //如果$key存在属性修改器则去调用$key地属性修改器`'set' . $key . 'Attribute'` 比如`setNameAttribute`
       if ($this->hasSetMutator($key)) {
$method = 'set'.Str::studly($key).'Attribute';

return $this->{$method}($value);
}

elseif ($value && $this->isDateAttribute($key)) {
$value = $this->fromDateTime($value);
}

if ($this->isJsonCastable($key) && ! is_null($value)) {
$value = $this->castAttributeAsJson($key, $value);
}

if (Str::contains($key, '->')) {
return $this->fillJsonAttribute($key, $value);
}

$this->attributes[$key] = $value;

return $this;
}
}

如果Model定义的属性修改器那么在设置属性的时候会去执行修改器,在我们的例子中并没有用到属性修改器。当执行$user->age = 28时, User Model实例里$attributes属性会变成

1
2
3
4
5
复制代码protected $attributes = [
...
'age' => 28,
...
]

设置好属性新的值之后执行Eloquent Model的save方法就会更新数据库里对应的记录,下面我们看看save方法里的逻辑:

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
复制代码abstract class Model implements ...
{
public function save(array $options = [])
{
$query = $this->newQueryWithoutScopes();

if ($this->fireModelEvent('saving') === false) {
return false;
}
//查询出来的Model实例的exists属性都是true
if ($this->exists) {
$saved = $this->isDirty() ?
$this->performUpdate($query) : true;
}

else {
$saved = $this->performInsert($query);

if (! $this->getConnectionName() &&
$connection = $query->getConnection()) {
$this->setConnection($connection->getName());
}
}

if ($saved) {
$this->finishSave($options);
}

return $saved;
}

//判断对字段是否有更改
public function isDirty($attributes = null)
{
return $this->hasChanges(
$this->getDirty(), is_array($attributes) ? $attributes : func_get_args()
);
}

   //数据表字段会保存在$attributes和$original两个属性里,update前通过比对两个数组里各字段的值找出被更改的字段
public function getDirty()
{
$dirty = [];

foreach ($this->getAttributes() as $key => $value) {
if (! $this->originalIsEquivalent($key, $value)) {
$dirty[$key] = $value;
}
}

return $dirty;
}

protected function performUpdate(Builder $query)
{
if ($this->fireModelEvent('updating') === false) {
return false;
}

if ($this->usesTimestamps()) {
$this->updateTimestamps();
}

$dirty = $this->getDirty();

if (count($dirty) > 0) {
$this->setKeysForSaveQuery($query)->update($dirty);

$this->fireModelEvent('updated', false);

$this->syncChanges();
}

return true;
}

//为查询设置where primary key = xxx
protected function setKeysForSaveQuery(Builder $query)
{
$query->where($this->getKeyName(), '=', $this->getKeyForSaveQuery());

return $query;
}
}

在save里会根据Model实例的exists属性来判断是执行update还是insert, 这里我们用的这个例子是update,在update时程序通过比对$attributes和$original两个array属性里各字段的字段值找被更改的字段(获取Model对象时会把数据表字段会保存在$attributes和$original两个属性),如果没有被更改的字段那么update到这里就结束了,有更改那么就继续去执行performUpdate方法,performUpdate方法会执行Eloquent Builder的update方法, 而Eloquent Builder依赖的还是数据库连接的Query Builder实例去最后执行的数据库update。

Model写入

刚才说通过Eloquent Model获取模型时(在newFromBuilder方法里)会把Model实例的exists属性设置为true,那么对于新建的Model实例这个属性的值是false,在执行save方法时就会去执行performInsert方法

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
复制代码protected function performInsert(Builder $query)
{
if ($this->fireModelEvent('creating') === false) {
return false;
}
//设置created_at和updated_at属性
if ($this->usesTimestamps()) {
$this->updateTimestamps();
}

$attributes = $this->attributes;
//如果表的主键自增insert数据并把新记录的id设置到属性里
if ($this->getIncrementing()) {
$this->insertAndSetId($query, $attributes);
}
//否则直接简单的insert
else {
if (empty($attributes)) {
return true;
}

$query->insert($attributes);
}

// 把exists设置成true, 下次在save就会去执行update了
$this->exists = true;

$this->wasRecentlyCreated = true;
//触发created事件
$this->fireModelEvent('created', false);

return true;
}

performInsert里如果表是主键自增的,那么在insert后会设置新记录主键ID的值到Model实例的属性里,同时还会帮我们维护时间字段和exists属性。

Model删除

Eloquent Model的delete操作也是一样, 通过Eloquent Builder去执行数据库连接的Query Builder里的delete方法删除数据库记录:

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
复制代码//Eloquent Model
public function delete()
{
if (is_null($this->getKeyName())) {
throw new Exception('No primary key defined on model.');
}

if (! $this->exists) {
return;
}

if ($this->fireModelEvent('deleting') === false) {
return false;
}

$this->touchOwners();

$this->performDeleteOnModel();

$this->fireModelEvent('deleted', false);

return true;
}

protected function performDeleteOnModel()
{
$this->setKeysForSaveQuery($this->newQueryWithoutScopes())->delete();

$this->exists = false;
}

//Eloquent Builder
public function delete()
{
if (isset($this->onDelete)) {
return call_user_func($this->onDelete, $this);
}

return $this->toBase()->delete();
}

//Query Builder
public function delete($id = null)
{
if (! is_null($id)) {
$this->where($this->from.'.id', '=', $id);
}

return $this->connection->delete(
$this->grammar->compileDelete($this), $this->cleanBindings(
$this->grammar->prepareBindingsForDelete($this->bindings)
)
);
}

Query Builder的实现细节我们在上一篇文章里已经说过了这里不再赘述,如果好奇Query Builder是怎么执行SQL操作的可以回去翻看上一篇文章。

总结

本文我们详细地看了Eloquent Model是怎么执行CRUD的,就像开头说的Eloquent Model通过Eloquent Builder来完成数据库操作,而Eloquent Builder是在Query Builder的基础上做了进一步封装, Eloquent Builder会把这些CRUD方法的调用转给Query Builder里对应的方法来完成操作,所以在Query Builder里能使用的方法到Eloquent Model中同样都能使用。

除了对数据表、基本的CRUD的抽象外,模型另外的一个重要的特点是模型关联,它帮助我们优雅的解决了数据表间的关联关系。我们在之后的文章再来详细看模型关联部分的实现。

本文已经收录在系列文章Laravel核心代码学习里,欢迎访问阅读。

本文转载自: 掘金

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

Laravel核心代码学习 -- Database Quer

发表于 2018-05-30

Database 查询构建器

前面的文章我们介绍了Laravel Database的基础组件,这篇文章详细地介绍一下Database里非常重要的基础组件查询构建器 文章里我就直接用QueryBuilder来指代查询构建器了。

上文我们说到执行DB::table('users')->get()是由Connection对象执行table方法返回了一个QueryBuilder对象,QueryBuilder提供了一个方便的接口来创建及运行数据库查询语句,开发者在开发时使用QueryBuilder不需要写一行SQL语句就能操作数据库了,使得书写的代码更加的面向对象,更加的优雅。

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
复制代码class MySqlConnection extends Connection
{
......
}

class Connection implements ConnectionInterface
{
public function __construct($pdo, $database = '', $tablePrefix = '', array $config = [])
{
$this->pdo = $pdo;

$this->database = $database;

$this->tablePrefix = $tablePrefix;

$this->config = $config;

$this->useDefaultQueryGrammar();

$this->useDefaultPostProcessor();
}
......
public function table($table)
{
return $this->query()->from($table);
}
......

public function query()
{
return new QueryBuilder(
$this, $this->getQueryGrammar(), $this->getPostProcessor()
);
}
......
public function useDefaultQueryGrammar()
{
$this->queryGrammar = $this->getDefaultQueryGrammar();
}

protected function getDefaultQueryGrammar()
{
return new QueryGrammar;
}

public function useDefaultPostProcessor()
{
$this->postProcessor = $this->getDefaultPostProcessor();
}

protected function getDefaultPostProcessor()
{
return new Processor;
}


}

通过上面的代码段可以看到Connection类的构造方法里出了注入了Connector数据库连接器(就是参数里的$pdo),还加载了两个重要的组件Illuminate\Database\Query\Grammars\Grammar: SQL语法编译器 和 Illuminate\Database\Query\Processors\Processor: SQL结果处理器。 我们看一下Connection的table方法,它返回了一个QueryBuilder实例, 其在实例化的时候Connection实例、Grammer实例和Processor实例会被作为参数传人QueryBuilder的构造方法中。

接下我们到QueryBuilder类文件\Illuminate\Database\Query\Builder.php里看看它里面的源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
复制代码namespace Illuminate\Database\Query;

class Builder
{
public function __construct(ConnectionInterface $connection,
Grammar $grammar = null,
Processor $processor = null)
{
$this->connection = $connection;
$this->grammar = $grammar ?: $connection->getQueryGrammar();
$this->processor = $processor ?: $connection->getPostProcessor();
}

//设置query目标的table并返回builder实例自身
public function from($table)
{
$this->from = $table;

return $this;
}

}

QueryBuilder构建SQL参数

下面再来看看where方法里都执行里什么, 为了方便阅读我们假定执行条件where('name', '=', 'James')

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
复制代码//class \Illuminate\Database\Query\Builder
public function where($column, $operator = null, $value = null, $boolean = 'and')
{
//where的参数可以是一维数组或者二维数组
//应用一个条件一维数组['name' => 'James']
//应用多个条件用二维数组[['name' => 'James'], ['age' => '28']]
if (is_array($column)) {
return $this->addArrayOfWheres($column, $boolean);
}

//当这样使用where('name', 'James')时,会在这里把$operator赋值为"="
list($value, $operator) = $this->prepareValueAndOperator(
$value, $operator, func_num_args() == 2 // func_num_args()为3,3个参数
);

// where()也可以传闭包作为参数
if ($column instanceof Closure) {
return $this->whereNested($column, $boolean);
}

// 如果$operator不合法会默认用户是想省略"="操作符,然后把原来的$operator赋值给$value
if ($this->invalidOperator($operator)) {
list($value, $operator) = [$operator, '='];
}

// $value是闭包时,会生成子查询
if ($value instanceof Closure) {
return $this->whereSub($column, $operator, $value, $boolean);
}

// where('name')相当于'name' = null作为过滤条件
if (is_null($value)) {
return $this->whereNull($column, $boolean, $operator != '=');
}

// $column没有包含'->'字符
if (Str::contains($column, '->') && is_bool($value)) {
$value = new Expression($value ? 'true' : 'false');
}
$type = 'Basic';

//每次调用where、whereIn、orWhere等方法时都会把column operator和value以及对应的type组成一个数组append到$wheres属性中去
//['type' => 'basic', 'column' => 'name', 'operator' => '=', 'value' => 'James', 'boolean' => 'and']
$this->wheres[] = compact('type', 'column', 'operator', 'value', 'boolean');

if (! $value instanceof Expression) {
// 这里是把$value添加到where的绑定值中
$this->addBinding($value, 'where');
}

return $this;
}

protected function addArrayOfWheres($column, $boolean, $method = 'where')
{
return $this->whereNested(function ($query) use ($column, $method, $boolean) {
foreach ($column as $key => $value) {
//上面where方法的$column参数为二维数组时这里会去递归调用where方法
if (is_numeric($key) && is_array($value)) {
$query->{$method}(...array_values($value));
} else {
$query->$method($key, '=', $value, $boolean);
}
}
}, $boolean);
}

public function whereNested(Closure $callback, $boolean = 'and')
{
call_user_func($callback, $query = $this->forNestedWhere());

return $this->addNestedWhereQuery($query, $boolean);
}

//添加执行query时要绑定到query里的值
public function addBinding($value, $type = 'where')
{
if (! array_key_exists($type, $this->bindings)) {
throw new InvalidArgumentException("Invalid binding type: {$type}.");
}

if (is_array($value)) {
$this->bindings[$type] = array_values(array_merge($this->bindings[$type], $value));
} else {
$this->bindings[$type][] = $value;
}

return $this;
}

所以上面DB::table(‘users’)->where(‘name’, ‘=’, ‘James’)执行后QueryBuilder对象里的几个属性分别有了一下变化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
复制代码public $from = 'users';

public $wheres = [
['type' => 'basic', 'column' => 'name', 'operator' => '=', 'value' => 'James', 'boolean' => 'and']
]

public $bindings = [
'select' => [],
'join' => [],
'where' => ['James'],
'having' => [],
'order' => [],
'union' => [],
];

通过bindings属性里数组的key大家应该都能猜到如果执行select、orderBy等方法,那么这些方法就会把要绑定的值分别append到select和order这些数组里了,这些代码我就不贴在这里了,大家看源码的时候可以自己去看一下,下面我们主要来看一下get方法里都做了什么。

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
复制代码//class \Illuminate\Database\Query\Builder
public function get($columns = ['*'])
{
$original = $this->columns;

if (is_null($original)) {
$this->columns = $columns;
}

$results = $this->processor->processSelect($this, $this->runSelect());

$this->columns = $original;

return collect($results);
}

protected function runSelect()
{
return $this->connection->select(
$this->toSql(), $this->getBindings(), ! $this->useWritePdo
);
}

public function toSql()
{
return $this->grammar->compileSelect($this);
}

//将bindings属性的值转换为一维数组
public function getBindings()
{
return Arr::flatten($this->bindings);
}

在执行get方法后,QueryBuilder首先会利用grammar实例编译SQL语句并执行,然后利用Processor实例处理结果集,最后返回经过处理后的结果集。 我们接下来看下这两个流程。

Grammar将构建的SQL参数编译成SQL语句

我们接着从toSql()方法开始接着往下看Grammar类

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
复制代码public function toSql()
{
return $this->grammar->compileSelect($this);
}

/**
* 将Select查询编译成SQL语句
* @param \Illuminate\Database\Query\Builder $query
* @return string
*/
public function compileSelect(Builder $query)
{

$original = $query->columns;
//如果没有QueryBuilder里没制定查询字段,那么默认将*设置到查询字段的位置
if (is_null($query->columns)) {
$query->columns = ['*'];
}
//遍历查询的每一部份,如果存在就执行对应的编译器来编译出那部份的SQL语句
$sql = trim($this->concatenate(
$this->compileComponents($query))
);

$query->columns = $original;

return $sql;
}

/**
* 编译Select查询语句的各个部分
* @param \Illuminate\Database\Query\Builder $query
* @return array
*/
protected function compileComponents(Builder $query)
{
$sql = [];

foreach ($this->selectComponents as $component) {
//遍历查询的每一部份,如果存在就执行对应的编译器来编译出那部份的SQL语句
if (! is_null($query->$component)) {
$method = 'compile'.ucfirst($component);

$sql[$component] = $this->$method($query, $query->$component);
}
}

return $sql;
}

/**
* 构成SELECT语句的各个部分
* @var array
*/
protected $selectComponents = [
'aggregate',
'columns',
'from',
'joins',
'wheres',
'groups',
'havings',
'orders',
'limit',
'offset',
'unions',
'lock',
];

在Grammar中,将SELECT语句分成来很多单独的部分放在了$selectComponents属性里,执行compileSelect时程序会检查QueryBuilder设置了$selectComponents里的哪些属性,然后执行已设置属性的编译器编译出每一部分的SQL来。
还是用我们之前的例子DB::table('users')->where('name', 'James')->get(),在这个例子中QueryBuilder分别设置了cloums(默认*)、from、wheres属性,那么我们见先来看看这三个属性的编译器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
复制代码/**
* 编译Select * 部分的SQL
* @param \Illuminate\Database\Query\Builder $query
* @param array $columns
* @return string|null
*/
protected function compileColumns(Builder $query, $columns)
{
// 如果SQL中有聚合,那么SELECT部分的编译教给aggregate部分的编译器去处理
if (! is_null($query->aggregate)) {
return;
}

$select = $query->distinct ? 'select distinct ' : 'select ';

return $select.$this->columnize($columns);
}

//将QueryBuilder $columns字段数组转换为字符串
public function columnize(array $columns)
{
//为每个字段调用Grammar的wrap方法
return implode(', ', array_map([$this, 'wrap'], $columns));
}

compileColumns执行完后compileComponents里的变量$sql的值会变成['columns' => 'select * '] 接下来看看from和wheres部分

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
复制代码protected function compileFrom(Builder $query, $table)
{
return 'from '.$this->wrapTable($table);
}

/**
* Compile the "where" portions of the query.
*
* @param \Illuminate\Database\Query\Builder $query
* @return string
*/
protected function compileWheres(Builder $query)
{
if (is_null($query->wheres)) {
return '';
}
//每一种where查询都有它自己的编译器函数来创建SQL语句,这帮助保持里代码的整洁和可维护性
if (count($sql = $this->compileWheresToArray($query)) > 0) {
return $this->concatenateWhereClauses($query, $sql);
}

return '';
}

protected function compileWheresToArray($query)
{
return collect($query->wheres)->map(function ($where) use ($query) {
//对于我们的例子来说是 'and ' . $this->whereBasic($query, $where)
return $where['boolean'].' '.$this->{"where{$where['type']}"}($query, $where);
})->all();
}

每一种where查询(orWhere, WhereIn……)都有它自己的编译器函数来创建SQL语句,这帮助保持里代码的整洁和可维护性. 上面我们说过在执行DB::table('users')->where('name', 'James')->get()时$wheres属性里的值是:

1
2
3
复制代码public $wheres = [
['type' => 'basic', 'column' => 'name', 'operator' => '=', 'value' => 'James', 'boolean' => 'and']
]

在compileWheresToArray方法里会用$wheres中的每个数组元素去回调执行闭包,在闭包里:

1
复制代码$where = ['type' => 'basic', 'column' => 'name', 'operator' => '=', 'value' => 'James', 'boolean' => 'and']

然后根据type值把$where和QeueryBuilder作为参数去调用了Grammar的whereBasic方法:

1
2
3
4
5
6
7
8
9
10
11
复制代码protected function whereBasic(Builder $query, $where)
{
$value = $this->parameter($where['value']);

return $this->wrap($where['column']).' '.$where['operator'].' '.$value;
}

public function parameter($value)
{
return $this->isExpression($value) ? $this->getValue($value) : '?';
}

whereBasic的返回为字符串'where name = ?', compileWheresToArray方法的返回值为:

1
复制代码['and where name = ?']

然后通过concatenateWhereClauses方法将compileWheresToArray返回的数组拼接成where语句'where name = ?'

1
2
3
4
5
6
复制代码protected function concatenateWhereClauses($query, $sql)
{
$conjunction = $query instanceof JoinClause ? 'on' : 'where';
//removeLeadingBoolean 会去掉SQL里首个where条件前面的逻辑运算符(and 或者 or)
return $conjunction.' '.$this->removeLeadingBoolean(implode(' ', $sql));
}

所以编译完from和wheres部分后compileComponents方法里返回的$sql的值会变成

1
复制代码['columns' => 'select * ', 'from' => 'users', 'wheres' => 'where name = ?']

然后在compileSelect方法里将这个由查查询语句里每部份组成的数组转换成真正的SQL语句:

1
2
3
4
5
6
复制代码protected function concatenate($segments)
{
return implode(' ', array_filter($segments, function ($value) {
return (string) $value !== '';
}));
}

得到'select * from uses where name = ?'. toSql执行完了流程再回到QueryBuilder的runSelect里:

1
2
3
4
5
6
复制代码protected function runSelect()
{
return $this->connection->select(
$this->toSql(), $this->getBindings(), ! $this->useWritePdo
);
}

Connection执行SQL语句

$this->getBindings()会获取要绑定到SQL语句里的值, 然后通过Connection实例的select方法去执行这条最终的SQL

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
复制代码public function select($query, $bindings = [], $useReadPdo = true)
{
return $this->run($query, $bindings, function ($query, $bindings) use ($useReadPdo) {
if ($this->pretending()) {
return [];
}

$statement = $this->prepared($this->getPdoForSelect($useReadPdo)
->prepare($query));

$this->bindValues($statement, $this->prepareBindings($bindings));

$statement->execute();

return $statement->fetchAll();
});
}

protected function run($query, $bindings, Closure $callback)
{
$this->reconnectIfMissingConnection();

$start = microtime(true);

try {
$result = $this->runQueryCallback($query, $bindings, $callback);
} catch (QueryException $e) {
//捕获到QueryException试着重连数据库再执行一次SQL
$result = $this->handleQueryException(
$e, $query, $bindings, $callback
);
}
//记录SQL执行的细节
$this->logQuery(
$query, $bindings, $this->getElapsedTime($start)
);

return $result;
}

protected function runQueryCallback($query, $bindings, Closure $callback)
{
try {
$result = $callback($query, $bindings);
}

//如果执行错误抛出QueryException异常, 异常会包含SQL和绑定信息
catch (Exception $e) {
throw new QueryException(
$query, $this->prepareBindings($bindings), $e
);
}

return $result;
}

在Connection的select方法里会把sql语句和绑定值传入一个闭包并执行这个闭包:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
复制代码function ($query, $bindings) use ($useReadPdo) {
if ($this->pretending()) {
return [];
}

$statement = $this->prepared($this->getPdoForSelect($useReadPdo)
->prepare($query));

$this->bindValues($statement, $this->prepareBindings($bindings));

$statement->execute();

return $statement->fetchAll();
});

直到getPdoForSelect这个阶段Laravel才会连接上Mysql数据库:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
复制代码protected function getPdoForSelect($useReadPdo = true)
{
return $useReadPdo ? $this->getReadPdo() : $this->getPdo();
}

public function getPdo()
{
//如果还没有连接数据库,先调用闭包连接上数据库
if ($this->pdo instanceof Closure) {
return $this->pdo = call_user_func($this->pdo);
}

return $this->pdo;
}

我们在上一篇文章里讲过构造方法里$this->pdo = $pdo;这个$pdo参数是一个包装里Connector的闭包:

1
2
3
复制代码function () use ($config) {
return $this->createConnector($config)->connect($config);
};

所以在getPdo阶段才会执行这个闭包根据数据库配置创建连接器来连接上数据库并返回PDO实例。接下来的prepare、bindValues以及最后的execute和fetchAll返回结果集实际上都是通过PHP原生的PDO和PDOStatement实例来完成的。

通过梳理流程我们知道:

  1. Laravel是在第一次执行SQL前去连接数据库的,之所以$pdo一开始是一个闭包因为闭包会保存创建闭包时的上下文里传递给闭包的变量,这样就能延迟加载,在用到连接数据库的时候再去执行这个闭包连上数据库。
  2. 在程序中判断SQL是否执行成功最准确的方法是通过捕获QueryException异常

Processor后置处理结果集

processor是用来对SQL执行结果进行后置处理的,默认的processor的processSelect方法只是简单的返回了结果集:

1
2
3
4
复制代码public function processSelect(Builder $query, $results)
{
return $results;
}

之后在QueryBuilder的get方法里将结果集转换成了Collection对象返回给了调用者.

到这里QueryBuilder大体的流程就梳理完了,虽然我们只看了select一种操作但其实其他的update、insert、delete也是一样先由QueryBuilder编译完成SQL最后由Connection实例去执行然后返回结果,在编译的过程中QueryBuilder也会帮助我们进行防SQL注入。

本文已经收录在系列文章Laravel核心代码学习里,欢迎访问阅读。

本文转载自: 掘金

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

Python 抖音机器人,论如何在抖音上找到漂亮小姐姐?😍

发表于 2018-05-28

如何在抖音上找到漂亮小姐姐—-抖音机器人

最近沉迷于抖音无法自拔,常常花好几个小时在抖音漂亮小姐姐身上。

为了高效、直接地找到漂亮小姐姐,我用 Python + ADB 做了一个 Python 抖音机器人 Douyin-Bot。

特性

  • 自动翻页
  • 颜值检测
  • 人脸识别
  • 自动点赞
  • 自动关注
  • 随机防 Ban
  • 自动回复

原理

  • 打开《抖音短视频》APP,进入主界面
  • 获取手机截图,并对截图进行压缩 (Size < 1MB);
  • 请求 人脸识别 API;
  • 解析返回的人脸 Json 信息,对人脸检测切割;
  • 当颜值大于门限值 BEAUTY_THRESHOLD时,点赞并关注;
  • 下一页,返回第一步;

使用教程

  • 相关软件工具安装和使用步骤请参考 wechat_jump_game 和 Android 和 iOS 操作步骤
  1. 获取源码:git clone https://github.com/wangshub/Douyin-Bot.git
  2. 进入源码目录: cd Douyin-Bot
  3. 安装依赖: pip install -r requirements.txt
  4. 运行程序:python douyin-bot.py

注意

  • 目前暂时只适配了 一加5(1920x1080 分辨率),如果手机不是该分辨率,请修改 config/ 文件夹下面的配置文件;

脸部截取

LICENSE

MIT

欢迎 Star 和 Fork ~

项目地址:github.com/wangshub/Do…

本文转载自: 掘金

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

Axios源码深度剖析 - AJAX新王者

发表于 2018-05-28

axios源码分析 - XHR篇

文章源码托管在github上,欢迎fork指正!

axios 是一个基于 Promise 的http请求库,可以用在浏览器和node.js中,目前在github上有 42K 的star数

备注:

  1. 每一小节都会从两个方面介绍:如何使用 -> 源码分析
  2. [工具方法简单介绍]一节可先跳过,后面用到了再过来查看
  3. axios最核心的技术点是如何拦截请求响应并修改请求参数修改响应数据 和 axios是如何用promise搭起基于xhr的异步桥梁的

axios项目目录结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
复制代码
├── /dist/ # 项目输出目录
├── /lib/ # 项目源码目录
│ ├── /cancel/ # 定义取消功能
│ ├── /core/ # 一些核心功能
│ │ ├── Axios.js # axios的核心主类
│ │ ├── dispatchRequest.js # 用来调用http请求适配器方法发送请求
│ │ ├── InterceptorManager.js # 拦截器构造函数
│ │ └── settle.js # 根据http响应状态,改变Promise的状态
│ ├── /helpers/ # 一些辅助方法
│ ├── /adapters/ # 定义请求的适配器 xhr、http
│ │ ├── http.js # 实现http适配器
│ │ └── xhr.js # 实现xhr适配器
│ ├── axios.js # 对外暴露接口
│ ├── defaults.js # 默认配置
│ └── utils.js # 公用工具
├── package.json # 项目信息
├── index.d.ts # 配置TypeScript的声明文件
└── index.js # 入口文件

注:因为我们需要要看的代码都是/lib/目录下的文件,所以以下所有涉及到文件路径的地方,
我们都会在/lib/下进行查找

名词解释

  • 拦截器 interceptors

(如果你熟悉中间件,那么就很好理解了,因为它起到的就是基于promise的中间件的作用)

拦截器分为请求拦截器和响应拦截器,顾名思义:
请求拦截器(interceptors.request)是指可以拦截住每次或指定http请求,并可修改配置项
响应拦截器(interceptors.response)可以在每次http请求后拦截住每次或指定http请求,并可修改返回结果项。

这里先简单说明,后面会做详细的介绍如何拦截请求响应并修改请求参数修改响应数据。

  • 数据转换器 (其实就是对数据进行转换,比如将对象转换为JSON字符串)

数据转换器分为请求转换器和响应转换器,顾名思义:
请求转换器(transformRequest)是指在请求前对数据进行转换,
响应转换器(transformResponse)主要对请求响应后的响应体做数据转换。

  • http请求适配器(其实就是一个方法)

在axios项目里,http请求适配器主要指两种:XHR、http。
XHR的核心是浏览器端的XMLHttpRequest对象,
http核心是node的http[s].request方法

当然,axios也留给了用户通过config自行配置适配器的接口的,
不过,一般情况下,这两种适配器就能够满足从浏览器端向服务端发请求或者从node的http客户端向服务端发请求的需求。

本次分享主要围绕XHR。

  • config配置项 (其实就是一个对象)

此处我们说的config,在项目内不是真的都叫config这个变量名,这个名字是我根据它的用途起的一个名字,方便大家理解。

在axios项目中的,设置\读取config时,
有的地方叫它defaults(/lib/defaults.js),这儿是默认配置项,
有的地方叫它config,如Axios.prototype.request的参数,再如xhrAdapterhttp请求适配器方法的参数。

config在axios项目里的是非常重要的一条链,是用户跟axios项目内部“通信”的主要桥梁。

axios内部的运作流程图

工具方法简单介绍

(注:本节可先跳过,后面用到了再过来查看)

有一些方法在项目中多处使用,简单介绍下这些方法

  1. bind: 给某个函数指定上下文,也就是this指向
1
2
复制代码
bind(fn, context);

实现效果同Function.prototype.bind方法: fn.bind(context)

  1. forEach:遍历数组或对象
1
2
3
4
5
6
7
8
9
复制代码
var utils = require('./utils');
var forEach = utils.forEach;

// 数组
utils.forEach([], (value, index, array) => {})

// 对象
utils.forEach({}, (value, key, object) => {})
  1. merge:深度合并多个对象为一个对象
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复制代码
var utils = require('./utils');
var merge = utils.merge;

var obj1 = {
a: 1,
b: {
bb: 11,
bbb: 111,
}
};
var obj2 = {
a: 2,
b: {
bb: 22,
}
};
var mergedObj = merge(obj1, obj2);

mergedObj对象是:

1
2
3
4
5
6
7
8
复制代码
{
a: 2,
b: {
bb: 22,
bbb: 111
}
}
  1. extend:将一个对象的方法和属性扩展到另外一个对象上,并指定上下文
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
复制代码
var utils = require('./utils');
var extend = utils.extend;

var context = {
a: 4,
};
var target = {
k: 'k1',
fn(){
console.log(this.a + 1)
}
};
var source = {
k: 'k2',
fn(){
console.log(this.a - 1)
}
};
let extendObj = extend(target, source, context);

extendObj对象是:

1
2
3
4
5
复制代码
{
k: 'k2',
fn: source.fn.bind(context),
}

执行extendObj.fn();, 打印3

axios为何会有多种使用方式

如何使用

1
2
3
复制代码
// 首先将axios包引进来
import axios from 'axios'

第1种使用方式:axios(option)

1
2
3
4
5
6
复制代码
axios({
url,
method,
headers,
})

第2种使用方式:axios(url[, option])

1
2
3
4
5
复制代码
axios(url, {
method,
headers,
})

第3种使用方式(对于get、delete等方法):axios[method](url[, option])

1
2
3
4
复制代码
axios.get(url, {
headers,
})

第4种使用方式(对于post、put等方法):axios[method](url[, data[, option]])

1
2
3
4
复制代码
axios.post(url, data, {
headers,
})

第5种使用方式:axios.request(option)

1
2
3
4
5
6
复制代码
axios.request({
url,
method,
headers,
})

源码分析

作为axios项目的入口文件,我们先来看下axios.js的源码
能够实现axios的多种使用方式的核心是createInstance方法:

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
复制代码
// /lib/axios.js
function createInstance(defaultConfig) {
// 创建一个Axios实例
var context = new Axios(defaultConfig);

// 以下代码也可以这样实现:var instance = Axios.prototype.request.bind(context);
// 这样instance就指向了request方法,且上下文指向context,所以可以直接以 instance(option) 方式调用
// Axios.prototype.request 内对第一个参数的数据类型判断,使我们能够以 instance(url, option) 方式调用
var instance = bind(Axios.prototype.request, context);

// 把Axios.prototype上的方法扩展到instance对象上,
// 这样 instance 就有了 get、post、put等方法
// 并指定上下文为context,这样执行Axios原型链上的方法时,this会指向context
utils.extend(instance, Axios.prototype, context);

// 把context对象上的自身属性和方法扩展到instance上
// 注:因为extend内部使用的forEach方法对对象做for in 遍历时,只遍历对象本身的属性,而不会遍历原型链上的属性
// 这样,instance 就有了 defaults、interceptors 属性。(这两个属性后面我们会介绍)
utils.extend(instance, context);

return instance;
}

// 接收默认配置项作为参数(后面会介绍配置项),创建一个Axios实例,最终会被作为对象导出
var axios = createInstance(defaults);

以上代码看上去很绕,其实createInstance最终是希望拿到一个Function,这个Function指向Axios.prototype.request,这个Function还会有Axios.prototype上的每个方法作为静态方法,且这些方法的上下文都是指向同一个对象。

那么在来看看Axios、Axios.prototype.request的源码是怎样的?

Axios是axios包的核心,一个Axios实例就是一个axios应用,其他方法都是对Axios内容的扩展
而Axios构造函数的核心方法是request方法,各种axios的调用方式最终都是通过request方法发请求的

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
复制代码
// /lib/core/Axios.js
function Axios(instanceConfig) {
this.defaults = instanceConfig;
this.interceptors = {
request: new InterceptorManager(),
response: new InterceptorManager()
};
}

Axios.prototype.request = function request(config) {
// ...省略代码
};

// 为支持的请求方法提供别名
utils.forEach(['delete', 'get', 'head', 'options'], function forEachMethodNoData(method) {
Axios.prototype[method] = function(url, config) {
return this.request(utils.merge(config || {}, {
method: method,
url: url
}));
};
});
utils.forEach(['post', 'put', 'patch'], function forEachMethodWithData(method) {
Axios.prototype[method] = function(url, data, config) {
return this.request(utils.merge(config || {}, {
method: method,
url: url,
data: data
}));
};
});

通过以上代码,我们就可以以多种方式发起http请求了: axios()、axios.get()、axios.post()

一般情况,项目使用默认导出的axios实例就可以满足需求了,
如果不满足需求需要创建新的axios实例,axios包也预留了接口,
看下面的代码:

1
2
3
4
5
6
复制代码
// /lib/axios.js - 31行
axios.Axios = Axios;
axios.create = function create(instanceConfig) {
return createInstance(utils.merge(defaults, instanceConfig));
};

说完axios为什么会有这么多种使用方式,可能你心中会有一个疑问:
使用axios时,无论get方法还是post方法,最终都是调用的Axios.prototype.request方法,那么这个方法是怎么根据我们的config配置发请求的呢?

在开始说Axios.prototype.request之前,我们先来捋一捋在axios项目中,用户配置的config是怎么起作用的?

用户配置的config是怎么起作用的

这里说的config,指的是贯穿整个项目的配置项对象,
通过这个对象,可以设置:

http请求适配器、请求地址、请求方法、请求头header、 请求数据、请求或响应数据的转换、请求进度、http状态码验证规则、超时、取消请求等

可以发现,几乎axios所有的功能都是通过这个对象进行配置和传递的,
既是axios项目内部的沟通桥梁,也是用户跟axios进行沟通的桥梁。

首先我们看看,用户能以什么方式定义配置项:

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

// 第1种:直接修改Axios实例上defaults属性,主要用来设置通用配置
axios.defaults[configName] = value;

// 第2种:发起请求时最终会调用Axios.prototype.request方法,然后传入配置项,主要用来设置“个例”配置
axios({
url,
method,
headers,
})

// 第3种:新建一个Axios实例,传入配置项,此处设置的是通用配置
let newAxiosInstance = axios.create({
[configName]: value,
})

看下 Axios.prototype.request 方法里的一行代码: (/lib/core/Axios.js - 第35行)

1
2
复制代码
config = utils.merge(defaults, {method: 'get'}, this.defaults, config);

可以发现此处将默认配置对象defaults(/lib/defaults.js)、Axios实例属性this.defaults、request请求的参数config进行了合并。

由此得出,多处配置的优先级由低到高是:
—> 默认配置对象defaults(/lib/defaults.js)

—> { method: ‘get’ }

—> Axios实例属性this.defaults

—> request请求的参数config

留给大家思考一个问题: defaults 和 this.defaults 什么时候配置是相同的,什么时候是不同的?

至此,我们已经得到了将多处merge后的config对象,那么这个对象在项目中又是怎样传递的呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
复制代码
Axios.prototype.request = function request(config) {
// ...
config = utils.merge(defaults, {method: 'get'}, this.defaults, config);

var chain = [dispatchRequest, undefined];
// 将config对象当作参数传给Primise.resolve方法
var promise = Promise.resolve(config);

// ...省略代码

while (chain.length) {
// config会按序通过 请求拦截器 - dispatchRequest方法 - 响应拦截器
// 关于拦截器 和 dispatchRequest方法,下面会作为一个专门的小节来介绍。
promise = promise.then(chain.shift(), chain.shift());
}

return promise;
};

至此,config走完了它传奇的一生 -_-
下一节就要说到重头戏了: Axios.prototype.request

axios.prototype.request

这里面的代码比较复杂,一些方法需要追根溯源才能搞清楚,
所以只需对chain数组有个简单的了解就好,涉及到的拦截器、[dispatchRequest]后面都会详细介绍

chain数组是用来盛放拦截器方法和dispatchRequest方法的,
通过promise从chain数组里按序取出回调函数逐一执行,最后将处理后的新的promise在Axios.prototype.request方法里返回出去,
并将response或error传送出去,这就是Axios.prototype.request的使命了。

查看源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
复制代码
// /lib/core/Axios.js
Axios.prototype.request = function request(config) {
// ...
var chain = [dispatchRequest, undefined];
var promise = Promise.resolve(config);

this.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {
chain.unshift(interceptor.fulfilled, interceptor.rejected);
});
this.interceptors.response.forEach(function pushResponseInterceptors(interceptor) {
chain.push(interceptor.fulfilled, interceptor.rejected);
});

while (chain.length) {
promise = promise.then(chain.shift(), chain.shift());
}

return promise;
};

此时,你一定对拦截器充满了好奇,这个拦截器到底是个什么家伙,下一节就让我们一探究竟吧

如何拦截请求响应并修改请求参数修改响应数据

如何使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
复制代码
// 添加请求拦截器
const myRequestInterceptor = axios.interceptors.request.use(config => {
// 在发送http请求之前做些什么
return config; // 有且必须有一个config对象被返回
}, error => {
// 对请求错误做些什么
return Promise.reject(error);
});

// 添加响应拦截器
axios.interceptors.response.use(response => {
// 对响应数据做点什么
return response; // 有且必须有一个response对象被返回
}, error => {
// 对响应错误做点什么
return Promise.reject(error);
});

// 移除某次拦截器
axios.interceptors.request.eject(myRequestInterceptor);

思考

  1. 是否可以直接 return error?
1
2
3
4
5
复制代码
axios.interceptors.request.use(config => config, error => {
// 是否可以直接 return error ?
return Promise.reject(error);
});
  1. 如何实现promise的链式调用
1
2
3
4
5
复制代码
new People('whr').sleep(3000).eat('apple').sleep(5000).eat('durian');

// 打印结果
// (等待3s)--> 'whr eat apple' -(等待5s)--> 'whr eat durian'

源码分析

关于拦截器,名词解释一节已经做过简单说明。

每个axios实例都有一个interceptors实例属性,
interceptors对象上有两个属性request、response。

1
2
3
4
5
6
7
8
复制代码
function Axios(instanceConfig) {
// ...
this.interceptors = {
request: new InterceptorManager(),
response: new InterceptorManager()
};
}

这两个属性都是一个InterceptorManager实例,而这个InterceptorManager构造函数就是用来管理拦截器的。

我们先来看看InterceptorManager构造函数:

InterceptorManager构造函数就是用来实现拦截器的,这个构造函数原型上有3个方法:use、eject、forEach。
关于源码,其实是比较简单的,都是用来操作该构造函数的handlers实例属性的。

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
复制代码
// /lib/core/InterceptorManager.js

function InterceptorManager() {
this.handlers = []; // 存放拦截器方法,数组内每一项都是有两个属性的对象,两个属性分别对应成功和失败后执行的函数。
}

// 往拦截器里添加拦截方法
InterceptorManager.prototype.use = function use(fulfilled, rejected) {
this.handlers.push({
fulfilled: fulfilled,
rejected: rejected
});
return this.handlers.length - 1;
};

// 用来注销指定的拦截器
InterceptorManager.prototype.eject = function eject(id) {
if (this.handlers[id]) {
this.handlers[id] = null;
}
};

// 遍历this.handlers,并将this.handlers里的每一项作为参数传给fn执行
InterceptorManager.prototype.forEach = function forEach(fn) {
utils.forEach(this.handlers, function forEachHandler(h) {
if (h !== null) {
fn(h);
}
});
};

那么当我们通过axios.interceptors.request.use添加拦截器后,
axios内部又是怎么让这些拦截器能够在请求前、请求后拿到我们想要的数据的呢?

先看下代码:

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
复制代码
// /lib/core/Axios.js
Axios.prototype.request = function request(config) {
// ...
var chain = [dispatchRequest, undefined];

// 初始化一个promise对象,状态微resolved,接收到的参数微config对象
var promise = Promise.resolve(config);

// 注意:interceptor.fulfilled 或 interceptor.rejected 是可能为undefined
this.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {
chain.unshift(interceptor.fulfilled, interceptor.rejected);
});

this.interceptors.response.forEach(function pushResponseInterceptors(interceptor) {
chain.push(interceptor.fulfilled, interceptor.rejected);
});

// 添加了拦截器后的chain数组大概会是这样的:
// [
// requestFulfilledFn, requestRejectedFn, ...,
// dispatchRequest, undefined,
// responseFulfilledFn, responseRejectedFn, ....,
// ]

// 只要chain数组长度不为0,就一直执行while循环
while (chain.length) {
// 数组的 shift() 方法用于把数组的第一个元素从其中删除,并返回第一个元素的值。
// 每次执行while循环,从chain数组里按序取出两项,并分别作为promise.then方法的第一个和第二个参数

// 按照我们使用InterceptorManager.prototype.use添加拦截器的规则,正好每次添加的就是我们通过InterceptorManager.prototype.use方法添加的成功和失败回调

// 通过InterceptorManager.prototype.use往拦截器数组里添加拦截器时使用的数组的push方法,
// 对于请求拦截器,从拦截器数组按序读到后是通过unshift方法往chain数组数里添加的,又通过shift方法从chain数组里取出的,所以得出结论:对于请求拦截器,先添加的拦截器会后执行
// 对于响应拦截器,从拦截器数组按序读到后是通过push方法往chain数组里添加的,又通过shift方法从chain数组里取出的,所以得出结论:对于响应拦截器,添加的拦截器先执行

// 第一个请求拦截器的fulfilled函数会接收到promise对象初始化时传入的config对象,而请求拦截器又规定用户写的fulfilled函数必须返回一个config对象,所以通过promise实现链式调用时,每个请求拦截器的fulfilled函数都会接收到一个config对象

// 第一个响应拦截器的fulfilled函数会接受到dispatchRequest(也就是我们的请求方法)请求到的数据(也就是response对象),而响应拦截器又规定用户写的fulfilled函数必须返回一个response对象,所以通过promise实现链式调用时,每个响应拦截器的fulfilled函数都会接收到一个response对象

// 任何一个拦截器的抛出的错误,都会被下一个拦截器的rejected函数收到,所以dispatchRequest抛出的错误才会被响应拦截器接收到。

// 因为axios是通过promise实现的链式调用,所以我们可以在拦截器里进行异步操作,而拦截器的执行顺序还是会按照我们上面说的顺序执行,也就是 dispatchRequest 方法一定会等待所有的请求拦截器执行完后再开始执行,响应拦截器一定会等待 dispatchRequest 执行完后再开始执行。

promise = promise.then(chain.shift(), chain.shift());

}

return promise;
};

现在,你应该已经清楚了拦截器是怎么回事,以及拦截器是如何在Axios.prototype.request方法里发挥作用的了,
那么处于”中游位置”的dispatchRequest是如何发送http请求的呢?

dispatchrequest都做了哪些事

dispatchRequest主要做了3件事:
1,拿到config对象,对config进行传给http请求适配器前的最后处理;
2,http请求适配器根据config配置,发起请求
3,http请求适配器请求完成后,如果成功则根据header、data、和config.transformResponse(关于transformResponse,下面的数据转换器会进行讲解)拿到数据转换后的response,并return。

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
复制代码
// /lib/core/dispatchRequest.js
module.exports = function dispatchRequest(config) {
throwIfCancellationRequested(config);

// Support baseURL config
if (config.baseURL && !isAbsoluteURL(config.url)) {
config.url = combineURLs(config.baseURL, config.url);
}

// Ensure headers exist
config.headers = config.headers || {};

// 对请求data进行转换
config.data = transformData(
config.data,
config.headers,
config.transformRequest
);

// 对header进行合并处理
config.headers = utils.merge(
config.headers.common || {},
config.headers[config.method] || {},
config.headers || {}
);

// 删除header属性里无用的属性
utils.forEach(
['delete', 'get', 'head', 'post', 'put', 'patch', 'common'],
function cleanHeaderConfig(method) {
delete config.headers[method];
}
);

// http请求适配器会优先使用config上自定义的适配器,没有配置时才会使用默认的XHR或http适配器,不过大部分时候,axios提供的默认适配器是能够满足我们的
var adapter = config.adapter || defaults.adapter;

return adapter(config).then(/**/);
};

好了,看到这里,我们是时候梳理一下:axios是如何用promise搭起基于xhr的异步桥梁的?

axios是如何用promise搭起基于xhr的异步桥梁的

axios是如何通过Promise进行异步处理的?

如何使用

1
2
3
4
5
6
7
8
9
10
复制代码
import axios from 'axios'

axios.get(/**/)
.then(data => {
// 此处可以拿到向服务端请求回的数据
})
.catch(error => {
// 此处可以拿到请求失败或取消或其他处理失败的错误对象
})

源码分析

先来一个图简单的了解下axios项目里,http请求完成后到达用户的顺序流:

通过axios为何会有多种使用方式我们知道,
用户无论以什么方式调用axios,最终都是调用的Axios.prototype.request方法,
这个方法最终返回的是一个Promise对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码
Axios.prototype.request = function request(config) {
// ...
var chain = [dispatchRequest, undefined];
// 将config对象当作参数传给Primise.resolve方法
var promise = Promise.resolve(config);

while (chain.length) {
promise = promise.then(chain.shift(), chain.shift());
}

return promise;
};

Axios.prototype.request方法会调用dispatchRequest方法,而dispatchRequest方法会调用xhrAdapter方法,xhrAdapter方法返回的是还一个Promise对象

1
2
3
4
5
6
7
复制代码
// /lib/adapters/xhr.js
function xhrAdapter(config) {
return new Promise(function dispatchXhrRequest(resolve, reject) {
// ... 省略代码
});
};

xhrAdapter内的XHR发送请求成功后会执行这个Promise对象的resolve方法,并将请求的数据传出去,
反之则执行reject方法,并将错误信息作为参数传出去。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
复制代码
// /lib/adapters/xhr.js
var request = new XMLHttpRequest();
var loadEvent = 'onreadystatechange';

request[loadEvent] = function handleLoad() {
// ...
// 往下走有settle的源码
settle(resolve, reject, response);
// ...
};
request.onerror = function handleError() {
reject(/**/);
request = null;
};
request.ontimeout = function handleTimeout() {
reject(/**/);
request = null;
};

验证服务端的返回结果是否通过验证:

1
2
3
4
5
6
7
8
9
10
复制代码
// /lib/core/settle.js
function settle(resolve, reject, response) {
var validateStatus = response.config.validateStatus;
if (!response.status || !validateStatus || validateStatus(response.status)) {
resolve(response);
} else {
reject(/**/);
}
};

回到dispatchRequest方法内,首先得到xhrAdapter方法返回的Promise对象,
然后通过.then方法,对xhrAdapter返回的Promise对象的成功或失败结果再次加工,
成功的话,则将处理后的response返回,
失败的话,则返回一个状态为rejected的Promise对象,

1
2
3
4
5
6
7
8
9
复制代码
return adapter(config).then(function onAdapterResolution(response) {
// ...
return response;
}, function onAdapterRejection(reason) {
// ...
return Promise.reject(reason);
});
};

那么至此,用户调用axios()方法时,就可以直接调用Promise的.then或.catch进行业务处理了。

回过头来,我们在介绍dispatchRequest一节时说到的数据转换,而axios官方也将数据转换专门作为一个亮点来介绍的,那么数据转换到底能在使用axios发挥什么功效呢?

数据转换器-转换请求与响应数据

如何使用

  1. 修改全局的转换器
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
复制代码
import axios from 'axios'

// 往现有的请求转换器里增加转换方法
axios.defaults.transformRequest.push((data, headers) => {
// ...处理data
return data;
});

// 重写请求转换器
axios.defaults.transformRequest = [(data, headers) => {
// ...处理data
return data;
}];

// 往现有的响应转换器里增加转换方法
axios.defaults.transformResponse.push((data, headers) => {
// ...处理data
return data;
});

// 重写响应转换器
axios.defaults.transformResponse = [(data, headers) => {
// ...处理data
return data;
}];
  1. 修改某次axios请求的转换器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
复制代码
import axios from 'axios'

// 往已经存在的转换器里增加转换方法
axios.get(url, {
// ...
transformRequest: [
...axios.defaults.transformRequest, // 去掉这行代码就等于重写请求转换器了
(data, headers) => {
// ...处理data
return data;
}
],
transformResponse: [
...axios.defaults.transformResponse, // 去掉这行代码就等于重写响应转换器了
(data, headers) => {
// ...处理data
return data;
}
],
})

源码分析

默认的defaults配置项里已经自定义了一个请求转换器和一个响应转换器,
看下源码:

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
复制代码
// /lib/defaults.js
var defaults = {

transformRequest: [function transformRequest(data, headers) {
normalizeHeaderName(headers, 'Content-Type');
// ...
if (utils.isArrayBufferView(data)) {
return data.buffer;
}
if (utils.isURLSearchParams(data)) {
setContentTypeIfUnset(headers, 'application/x-www-form-urlencoded;charset=utf-8');
return data.toString();
}
if (utils.isObject(data)) {
setContentTypeIfUnset(headers, 'application/json;charset=utf-8');
return JSON.stringify(data);
}
return data;
}],

transformResponse: [function transformResponse(data) {
if (typeof data === 'string') {
try {
data = JSON.parse(data);
} catch (e) { /* Ignore */ }
}
return data;
}],

};

那么在axios项目里,是在什么地方使用了转换器呢?

请求转换器的使用地方是http请求前,使用请求转换器对请求数据做处理,
然后传给http请求适配器使用。

1
2
3
4
5
6
7
8
9
10
11
12
复制代码
// /lib/core/dispatchRequest.js
function dispatchRequest(config) {

config.data = transformData(
config.data,
config.headers,
config.transformRequest
);

return adapter(config).then(/* ... */);
};

看下transformData方法的代码,
主要遍历转换器数组,分别执行每一个转换器,根据data和headers参数,返回新的data。

1
2
3
4
5
6
7
8
复制代码
// /lib/core/transformData.js
function transformData(data, headers, fns) {
utils.forEach(fns, function transform(fn) {
data = fn(data, headers);
});
return data;
};

响应转换器的使用地方是在http请求完成后,根据http请求适配器的返回值做数据转换处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
复制代码
// /lib/core/dispatchRequest.js
return adapter(config).then(function onAdapterResolution(response) {
// ...
response.data = transformData(
response.data,
response.headers,
config.transformResponse
);

return response;
}, function onAdapterRejection(reason) {
if (!isCancel(reason)) {
// ...
if (reason && reason.response) {
reason.response.data = transformData(
reason.response.data,
reason.response.headers,
config.transformResponse
);
}
}

return Promise.reject(reason);
});

转换器和拦截器的关系?

拦截器同样可以实现转换请求和响应数据的需求,但根据作者的设计和综合代码可以看出,
在请求时,拦截器主要负责修改config配置项,数据转换器主要负责转换请求体,比如转换对象为字符串
在请求响应后,拦截器可以拿到response,数据转换器主要负责处理响应体,比如转换字符串为对象。

axios官方是将”自动转换为JSON数据”作为一个独立的亮点来介绍的,那么数据转换器是如何完成这个功能的呢?
其实非常简单,我们一起看下吧。

自动转换json数据

在默认情况下,axios将会自动的将传入的data对象序列化为JSON字符串,将响应数据中的JSON字符串转换为JavaScript对象

源码分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
复制代码
// 请求时,将data数据转换为JSON 字符串
// /lib/defaults.js
transformRequest: [function transformRequest(data, headers) {
// ...
if (utils.isObject(data)) {
setContentTypeIfUnset(headers, 'application/json;charset=utf-8');
return JSON.stringify(data);
}
return data;
}]

// 得到响应后,将请求到的数据转换为JSON对象
// /lib/defaults.js
transformResponse: [function transformResponse(data) {
if (typeof data === 'string') {
try {
data = JSON.parse(data);
} catch (e) { /* Ignore */ }
}
return data;
}]

至此,axios项目的运作流程已经介绍完毕,是不是已经打通了任督二脉了呢
接下来我们一起看下axios还带给了我们哪些好用的技能点吧。

header设置

如何使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码
import axios from 'axios'

// 设置通用header
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; // xhr标识

// 设置某种请求的header
axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded;charset=utf-8';

// 设置某次请求的header
axios.get(url, {
headers: {
'Authorization': 'whr1',
},
})

源码分析

1
2
3
4
5
6
7
8
复制代码
// /lib/core/dispatchRequest.js - 44行

config.headers = utils.merge(
config.headers.common || {},
config.headers[config.method] || {},
config.headers || {}
);

如何取消已经发送的请求

如何使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
复制代码
import axios from 'axios'

// 第一种取消方法
axios.get(url, {
cancelToken: new axios.CancelToken(cancel => {
if (/* 取消条件 */) {
cancel('取消日志');
}
})
});

// 第二种取消方法
const CancelToken = axios.CancelToken;
const source = CancelToken.source();
axios.get(url, {
cancelToken: source.token
});
source.cancel('取消日志');

源码分析

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
复制代码
// /cancel/CancelToken.js - 11行
function CancelToken(executor) {

var resolvePromise;
this.promise = new Promise(function promiseExecutor(resolve) {
resolvePromise = resolve;
});
var token = this;
executor(function cancel(message) {
if (token.reason) {
return;
}
token.reason = new Cancel(message);
resolvePromise(token.reason);
});
}

// /lib/adapters/xhr.js - 159行
if (config.cancelToken) {
config.cancelToken.promise.then(function onCanceled(cancel) {
if (!request) {
return;
}
request.abort();
reject(cancel);
request = null;
});
}

取消功能的核心是通过CancelToken内的this.promise = new Promise(resolve => resolvePromise = resolve),
得到实例属性promise,此时该promise的状态为pending
通过这个属性,在/lib/adapters/xhr.js文件中继续给这个promise实例添加.then方法
(xhr.js文件的159行config.cancelToken.promise.then(message => request.abort()));

在CancelToken外界,通过executor参数拿到对cancel方法的控制权,
这样当执行cancel方法时就可以改变实例的promise属性的状态为rejected,
从而执行request.abort()方法达到取消请求的目的。

上面第二种写法可以看作是对第一种写法的完善,
因为很多是时候我们取消请求的方法是用在本次请求方法外,
例如,发送A、B两个请求,当B请求成功后,取消A请求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
复制代码
// 第1种写法:
let source;
axios.get(Aurl, {
cancelToken: new axios.CancelToken(cancel => {
source = cancel;
})
});
axios.get(Burl)
.then(() => source('B请求成功了'));

// 第2种写法:
const CancelToken = axios.CancelToken;
const source = CancelToken.source();
axios.get(Aurl, {
cancelToken: source.token
});
axios.get(Burl)
.then(() => source.cancel('B请求成功了'));

相对来说,我更推崇第1种写法,因为第2种写法太隐蔽了,不如第一种直观好理解。

发现的问题
  1. /lib/adapters/xhr.js文件中,onCanceled方法的参数不应该叫message么,为什么叫cancel?
  2. /lib/adapters/xhr.js文件中,onCanceled方法里,reject里应该将config信息也传出来

跨域携带cookie

如何使用

1
2
3
4
复制代码
import axios from 'axios'

axios.defaults.withCredentials = true;

源码分析

我们在用户配置的config是怎么起作用的一节已经介绍了config在axios项目里的传递过程,
由此得出,我们通过axios.defaults.withCredentials = true做的配置,
在/lib/adapters/xhr.js里是可以取到的,然后通过以下代码配置到xhr对象项。

1
2
3
4
5
6
7
复制代码
var request = new XMLHttpRequest();

// /lib/adapters/xhr.js
if (config.withCredentials) {
request.withCredentials = true;
}

超时配置及处理

如何使用

1
2
3
4
复制代码
import axios from 'axios'

axios.defaults.timeout = 3000;

源码分析

1
2
3
4
5
6
7
8
9
10
复制代码
// /adapters/xhr.js
request.timeout = config.timeout;

// /adapters/xhr.js
// 通过createError方法,将错误信息合为一个字符串
request.ontimeout = function handleTimeout() {
reject(createError('timeout of ' + config.timeout + 'ms exceeded',
config, 'ECONNABORTED', request));
};
  • axios库外如何添加超时后的处理
1
2
3
4
5
6
7
复制代码
axios().catch(error => {
const { message } = error;
if (message.indexOf('timeout') > -1){
// 超时处理
}
})

改写验证成功或失败的规则validatestatus

自定义http状态码的成功、失败范围

如何使用

1
2
3
4
复制代码
import axios from 'axios'

axios.defaults.validateStatus = status => status >= 200 && status < 300;

源码分析

在默认配置中,定义了默认的http状态码验证规则,
所以自定义validateStatus其实是对此处方法的重写

1
2
3
4
5
6
7
8
9
复制代码
// `/lib/defaults.js`
var defaults = {
// ...
validateStatus: function validateStatus(status) {
return status >= 200 && status < 300;
},
// ...
}

axios是何时开始验证http状态码的?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
复制代码
// /lib/adapters/xhr.js
var request = new XMLHttpRequest();
var loadEvent = 'onreadystatechange';

// /lib/adapters/xhr.js
// 每当 readyState 改变时,就会触发 onreadystatechange 事件
request[loadEvent] = function handleLoad() {
if (!request || (request.readyState !== 4 && !xDomain)) {
return;
}
// ...省略代码
var response = {
// ...
// IE sends 1223 instead of 204 (https://github.com/axios/axios/issues/201)
status: request.status === 1223 ? 204 : request.status,
config: config,
};
settle(resolve, reject, response);
// ...省略代码
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复制代码
// /lib/core/settle.js
function settle(resolve, reject, response) {
// 如果我们往上捣一捣就会发现,config对象的validateStatus就是我们自定义的validateStatus方法或默认的validateStatus方法
var validateStatus = response.config.validateStatus;
// validateStatus验证通过,就会触发resolve方法
if (!response.status || !validateStatus || validateStatus(response.status)) {
resolve(response);
} else {
reject(createError(
'Request failed with status code ' + response.status,
response.config,
null,
response.request,
response
));
}
};

总结

axios这个项目里,有很多对JS使用很巧妙的地方,比如对promise的串联操作(当然你也可以说这块是借鉴很多异步中间件的处理方式),让我们可以很方便对请求前后的各种处理方法的流程进行控制;很多实用的小优化,比如请求前后的数据处理,省了程序员一遍一遍去写JSON.xxx了;同时支持了浏览器和node两种环境,对使用node的项目来说无疑是极好的。

总之,这个能够在github斩获42K+(截止2018.05.27)的star,实力绝不是盖的,值得好好交交心!

本文转载自: 掘金

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

学习 Spring Boot 知识看这一篇就够了

发表于 2018-05-28

从2016年因为工作原因开始研究 Spring Boot ,先后写了很多关于 Spring Boot 的文章,发表在技术社区、我的博客和我的公号内。粗略的统计了一下总共的文章加起来大概有六十多篇了,其中一部分是在技术社区做的系列课程。

我在写文章的时候将文章中的示例提取出来,作为开源代码分享了出来让大家以更方便的方式去学习(https://github.com/ityouknow/spring-boot-examples),现在在 Gihub 上面也将近有4000个 Star,按照 Spring Boot 的标签来筛选也能排到第六位,前面都是 Cas、Spring Boot Admin 这种牛逼呼呼的开源组织。

利用业余时间还做了一个 Spring Boot 中文索引 (http://springboot.fun/)分享 Spring Boot 的学习资源和开源软件,来帮助大家寻找优秀的 Spring Boot 学习案例。今天我将分享过的文章整理出来方便大家统一查看。

在学习 Spring Boot 之前我一直建议大家先看这三篇文章,了解一下微服务的启蒙。

  • 【译】微服务 - Martin Fowler
  • 微服务架构的理论基础 - 康威定律
  • 微服务那点事

如果你想直接实践,我总计写了二十篇关于基于 Spring Boot 1.X 系列文章:

  • springboot(一):入门篇
  • springboot(二):web综合开发
  • springboot(三):Spring boot中Redis的使用
  • springboot(四):thymeleaf使用详解
  • springboot(五):spring data jpa的使用
  • springboot(六):如何优雅的使用mybatis
  • springboot(七):springboot+mybatis多数据源最简解决方案
  • springboot(八):RabbitMQ详解
  • springboot(九):定时任务
  • springboot(十):邮件服务
  • springboot(十一):Spring boot中mongodb的使用
  • springboot(十二):springboot如何测试打包部署
  • springboot(十三):springboot小技巧
  • springboot(十四):整合shiro-登录认证和权限管理
  • springboot(十五):springboot+jpa+thymeleaf增删改查示例
  • springboot(十六):使用Jenkins部署Spring Boot
  • springboot(十七):使用Spring Boot上传文件
  • springboot(十八):使用Spring Boot集成FastDFS
  • springboot(十九):使用Spring Boot Actuator监控应用
  • springboot(二十):使用Spring-boot-admin对Spring-boot服务进行监控

我们在学习 Spring Boot 的时候还带着团队做了一款 Spring Boot 的开源软件云收藏(www.favorites.ren),现在已经将云收藏升级到到了 Spring Boot 2.0 性能明显得到提升,大家可以登录网站去试试。

Spring Boot实战:我们的第一款开源软件

改天写文章给大家分享以下 Spring Boot 1.0 升级到 2.0 所踩的那些坑。Spring Boot 2.0 的文章目前写了近7篇,后续还会持续更新:

  • Spring Boot 2.0(一)【重磅】Spring Boot 2.0权威发布
  • Spring Boot 2.0(二):Spring Boot 2.0尝鲜-动态Banner
  • Spring Boot 2.0(三):Spring Boot开源软件都有哪些?
  • Spring Boot 2.0(四):使用 Docker 部署 Spring Boot
  • Spring Boot 2.0 (五):Docker Compose+Spring Boot+Nginx+Mysql 实践
  • Spring Boot 2.0 (六):使用 Docker 部署Spring Boot开源软件云收藏
  • Spring Boot 2.0 (七):Spring Boot 如何解决项目启动时初始化资源

除过系列文章外,还有一些综合性的文章和转载过一些 Spring Boot 优秀的文章。

  • 一文读懂 Spring Boot、微服务架构和大数据治理三者之间的故事
  • 这一篇文章带你感受微服务的生和死,Spring Boot是生和死的主旋律。
  • Java 9和Spring Boot 2.0 纷纷宣布支持的HTTP/2到底是什么?
  • Spring Boot 面试题
  • Service Mesh:下一代微服务?

为了大家更方便的学习 Spring Boot ,我将写过的 Spring Boot 文章整理成了一本电子书,分为三个阶段写完分享给大家,方便大家使用电子设备来查阅,在公号内回复:springboot 即可免费获取。

同时为了大家更加全面的了解 Spring Boot ,六月我在 gitchat 会有一场线上的分享,大家有兴趣可以选择参加。chat 内容大概如下:

Spring Boot 2.0 的推出又激起了一阵学习 Spring Boot 热,就单从我们个人的博客的访问量大幅增加就可以感受到大家对学习 Spring Boot 的热情,那么在这么多人热衷于学习 Spring Boot 之时,我们自己也在思考: Spring Boot 为什么带来了这么大的变革。它诞生的背景是什么?Spring 企业又是基于什么样的考虑创建的 Spring Boot? 传统企业使用 Spring Boot 会给我们带来什么样变化。

点击阅读原文即可参加,偷偷告诉你参加之后分享有返利。

本文转载自: 掘金

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

Python Matplotlib 绘图使用指南 (附代码)

发表于 2018-05-22

雷锋网按:本文为雷锋字幕组编译的技术博客,原标题 Matplotlib Plotting Guide, 作者为 Prince Grover。

翻译 | 李振 于志鹏 整理 | 凡江

Python Matplotlib 绘图使用指南 (附代码)

大多数人不会花大量时间去学 matplotlib 库,仍然可以实现绘图需求,因为已经有人在 stackoverflow、github 等开源平台上提供了绝大多数画图问题的解决方案。我们通常会使用 google 来完成绘图需求。至少我是这样。

那学 matplotlib 库有什么用?答案是:可以节约搜索时间。掌握 matplotlib 的速查表并了解其基本接口,根据个性需求从众多资源中编辑我们的绘图,从长期来看会节约很多的时间。

大部分内容取自以下 2 个链接,建议也去阅读一下。

realpython.com/python-matp…

s3.amazonaws.com/assets.data…

Python Matplotlib 绘图使用指南 (附代码)

matplotlib 是一个基于 Python 的 2D 绘图库,其可以在跨平台的在各种硬拷贝格式和交互式环境中绘制出高图形。

  • 一个有趣的现象。为什么引用库总采用 import matplotlib.pyplot as plt 的方式呢?

因为使用例如 pylab import * 或者 %pylab 是一个非常不好的方式,matplotlib 官方不建议这样使用,具体原因如下:

由于历史原因,from pylab import * 仍然存在,但是强烈建议不要这样使用。这样做会遮蔽 Python 的内置函数进而占用命名空间,导致难以追踪的 bugs。想要实现零输入获得 IPython 集成,推荐使用 %matplotlib 命令。来源:matplotlib.org/users/shell…

使用 matplotlib 绘制不同类型的图像是很容易的,有很多文档和教程。最重要的是,了解最佳的绘图方式。如何使用 axes,subplots 等。这篇文章主要针对这些问题。

1.内联绘图和 % matplotlib

%matplotlib 命令可以在当前的 Notebook 中启用绘图。这个命令提供一个可选参数,指定使用哪个 matplotlib 后端。绝大多数情况下,Notebook 中都是使用 inline 后台,它可以在 Notebook 中嵌入绘图。另一个选项是 qt 后台,它在侧窗口打中打开 Matplotlib 交互 UI 。

Matlibplot 提供了多种绘图 UI ,可进行如下分类 :

  • 弹出窗口和交互界面: %matplotlib qt 和 %matplot tk
  • 非交互式内联绘图: %matplotlib inline
  • 交互式内联绘图: %matplotlib notebook-->别用这个,它会让开关变得困难。

2.理解 matplotlib 对象结构

pyplot 是一个 matplotlib 面向对象的函数接口。

plt.gca()

它返回当前 plot() 关联的轴

Python Matplotlib 绘图使用指南 (附代码)

如果不使用 plt.close(),则会显示出空的图形。因为在开始时使用了 inline 命令。

Python Matplotlib 绘图使用指南 (附代码)

axis_id 仍然是相同的,但是当我们移动到另一个 Notebook 块时,plt.gca() 会发生变化。

Setter 和 Getter

Getter 和 Setter 方法用于捕获当前或任意 axies 以及对其进行修改。我们可能需要修改标题、颜色、图列、字体等。有两种方法:

  1. 使用 fig.axes[i] 指定要抓取的 axes,使用 setter 的 getter 对 axies 对象进行调用。在上面的例子中,只有一个 axes,所以我们调用 axes[0]。
  1. 我们可以直接使用 plt.bla()调用当前 axis(其中,bla 可以是 title(),legend(),xlabel()等)。这是 matlibplot 面向对象的一种函数。这个函数让修改当前的 axes 变得容易。比 1 的方法更常用。

Python Matplotlib 绘图使用指南 (附代码)

当我们使用 axes[i] 时,我们可以调用任何之前的代码块中的任何 axes 对象,但是调用 plt.bla(),会在每个代码块中创建新的 axes 对象,并只调用当前对象。因此,上面例子中,只在 plt.title() 被调用时,才创建新 plt 对象。

重要观察:我们通常在当前 axis 对象上调用 plt.bla(),这种语法使得每个代码块中的 axis 对象都是新创建的。但是通过调用 fig.axes[0],我们也可以从任何代码块中处理之前的 axes 对象。

这是 stateless(object oriented) 方法,并可以自定义,当图像变得复杂时,这样做很方便。

所以,我建议是使用 fig,ax = plt.subplots(_) 先解压 axes 和 figure,并给它们分配给一个新的变量。然后,可以对这些变量使用 Getter 和 Setter 方法进行绘图中的更改。此外,这使得我们能够在多个 axes 上做工作,而不是只在一个当前 axes 上。pyplot 使用 1 次创建子图,然后使用 OO 方法。

结论:从现在开始,我使用 plt.subpots() 来完成不同的绘图。(如果有人认为这个观点是错误的,请纠正我)

3.matplotlib 图像剖析

Python Matplotlib 绘图使用指南 (附代码)

来自: matplotlib.org/faq/usage_f…

4.绘图的基本例子

如何作图的基本例子,涵盖面向对象绘图的各个方面。请仔细阅读。

Python Matplotlib 绘图使用指南 (附代码)

Python Matplotlib 绘图使用指南 (附代码)

总结上面的例子:

  • 我们创建 1 行和 2 列的图形。即,1 行和 2 列中的 2 个 axes 对象。
  • 我们分别自定义 ax1 和 ax2。可以看到,我们可以将 Y-ticks 移动到右边的第二图形中。

5.二维网格的绘制

subplot2grid

需要做什么?

观察下面的绘图格式。

Python Matplotlib 绘图使用指南 (附代码)

思路是把上面的图形考虑成为 2x4 网格。然后将多个网格分配给单个图以容纳所需的图形。

Python Matplotlib 绘图使用指南 (附代码)

Python Matplotlib 绘图使用指南 (附代码)

重点:

  • 我们可以使用 subplot2grid 定制我们的绘图布局。
  • 我们可以用 plt.figure() 创建无 axes 对象的图形,然后手动添加 axes 对象。
  • 我们可以使用 fig.suptitle() 来设置整个图形的总标题。

6.颜色,颜色条,RGB 数组和颜色图谱

我们已经介绍了 ax.plot(),ax.scatter(),ax.bar() 和 ax.hist() 等基本图形操作,另一个更常用的函数是 ax.imshow(),它用来显示彩色图或图像/RGB 数组。

Python Matplotlib 绘图使用指南 (附代码)

Python Matplotlib 绘图使用指南 (附代码)

Python Matplotlib 绘图使用指南 (附代码)

7.线条样式和线条宽度

改变线条宽度、颜色或风格。

Python Matplotlib 绘图使用指南 (附代码)

Python Matplotlib 绘图使用指南 (附代码)

8.基本的数据分布

EDA 过程中的必要操作。

Python Matplotlib 绘图使用指南 (附代码)

9.二维数组的等高线图和颜色网格图

热像图(颜色网格图)和等高线图在很多情况下都有助于可视化 2D 数据。

Python Matplotlib 绘图使用指南 (附代码)

10.图像的调整、修改边缘坐标和标度

最后调整细节,让绘图变得更好看。

11.标度的限制和自动调整

Python Matplotlib 绘图使用指南 (附代码)

Python Matplotlib 绘图使用指南 (附代码)

需要注意的事情:

  • 填充(padding)自动设置 X 轴或 Y 轴网格标度
  • 我们可以使用 xlim,ylim 设置 x,y 的刻度限制

12.技巧

Python Matplotlib 绘图使用指南 (附代码)

Python Matplotlib 绘图使用指南 (附代码)

13.轴线

Python Matplotlib 绘图使用指南 (附代码)

14.结束

博客原址: www.kaggle.com/grroverpr/m…

Python Matplotlib 绘图使用指南 (附代码)

雷锋网雷锋网(公众号:雷锋网)

雷锋网原创文章,未经授权禁止转载。详情见转载须知。

Python Matplotlib 绘图使用指南 (附代码)

本文转载自: 掘金

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

基于BPMN20的工单系统架构设计(上)

发表于 2018-05-22

『工单系统』从宏观上看,是一些状态流的转换,笔者认为,工单系统的实现即是对工作流(workflow)的实现,典型的应用有企业OA系统,各类CRM,ERP等。

对于工单系统的实现,其实可以结合实际业务去编写相应的业务代码,这样做的最大的好处是定制化程度高,运行业务流程高度自定义化。然而,物极必反,高度定制的业务流程将会失去一定的灵活性。

那么问题来了:如何权衡?

答曰:基于业内标准实现。

标准=权威

当我们发现设计出来的业务流程不符合标准,就不得不怀疑了。


是时候要祭出工作流的“杀手锏”了:BPMN2.0

至于BPMN2.0的基础介绍,我在此不再赘述,用一句话概括:BPMN2.0是IBM制定的一套完善的工作流开发标准,它包含了诸如事件,网关,顺序流,任务等各类基本元素。

BPMN2.0毕竟只是一套标准,还需要付诸实现,业内知名的工作流开发框架当属Activiti了,使用的编程语言是受众较多的Java,如果实际工作中有用到工作流,可以使用此开发框架,如果主流开发框架不是Java,只能自造轮子了。

如果准备进入工作流的开发,无论是使用框架,还是自研,建议阅读一下这份文档,对实际开发工作非常有帮助。

再回到文章开头提到的“工单系统”,说说笔者启动此项目前的一些个人想法。

工单的流程推动本身就是工作流的一种体现,所以理所当然是使用了BPMN2.0的标准。大概花了一个工作日去研读了上述提到的参考文档,愈发觉得BPMN2.0标准的精妙之处,要囊括现有的业务需求,那是绰绰有余。

当然,在做系统架构的时候,必定是要结合实际业务需求的。仔细分析了自身的业务需求,发现都是一些“短流程”,工单任务不会超过2个,工单任务类型也只会有一种(即为userTask),流程分支也不多,总而言之,笔者面临的都是一些较为简单的工单流程。

如果直接使用Acviti这类庞大复杂的框架,一方面是实际的工单流程不算复杂,二来有大材小用之嫌,再者团队成员都不熟悉此标准,理解并对接起来有难度。

因此,笔者走上了基于BPMN2.0标准自研的道路。

倒不是有重造一个Activiti的雄心壮志,而其实质是对Activiti进行功能裁剪,只实现了一些必要的标准。

关于此工单系统的架构设计将会分三篇文章讲解,此篇文章将着重介绍我用到了哪些BPMN2.0标准元素。

一、工作流定义语言(WDL)

Workflow Definition Language(以下简称WDL),意思是工作流定义语言,属于个人自创,并非官方术语,只是想在团队内统一语言而已。

沿袭Activiti的设计实现,WDL也是基于XML的。

在工单系统里面,笔者实现了以下8种基本元素:


下面对WDL的组成部分进行介绍:

1、根节点,由一对标签组成:

1
复制代码<definitions id="def" name="工作流程配置"> </definitions>

2、流程定义节点,由一对标签表示,与其下属子节点组成一个完整的流程。

值得一提的是,BPMN2.0标准中是允许subProcess(子流程)存在的,这个feature在此工单系统里并未实现。

1
复制代码<process id="verify_work" name="用户审核流程"> </process>

3、空开始事件节点,通常表示一个流程的开始。

1
复制代码<startEvent id="start" name="开始事件"/>

4、定时事件定义,不可单独存在,其效果是在其他事件的基础上加了一个定时器,典型的应用是下面将提到的定时边界事件。

1
2
3
复制代码<timerEventDefinition>     
<timeDuration>${duration}</timeDuration> <!-- 时间点 或者 cron表达式 -->
</timerEventDefinition>

5、定时边界事件。

边界事件都是捕获事件,它会附在一个节点上,当节点运行时,事件会监听对应的触发类型。 当边界事件被捕获,节点就会中断运行,同时执行事件的后续流程。

定时边界事件可以理解为一个暂停等待警告的时钟。当流程执行到绑定了边界事件的环节, 会启动一个定时器。

当定时器触发时,环节就会中断,并沿着定时边界事件的外出连线继续执行。

1
2
3
4
5
6
复制代码<boundaryEvent id="escalationTimer" cancelActivity="true" attachedToRef="userTask">
<!-- cancelActivity表示是否会中断边界事件所依附的任务 -->
<timerEventDefinition>
<timeDuration>0 15 10 * * ? *</timeDuration>
</timerEventDefinition>
</boundaryEvent>

6、消息(边界)事件,消息的接收和发送要在应用或架构的一层实现的,流程引擎则内嵌其中。

这个元素相较于标准,还是有改动的。消息事件在工单系统中被界定为是一种回调通知的手段,通知的类型有REST和MQ两种方式,通知所携带的参数在params中可被定义,name是参数名。

标签是唯一一个与标签同级的标签,message就好比全局变量,可以被WDL中多个元素引用。

以下定义了一个消息体,并在消息边界事件中引用该消息体。

1
2
3
4
5
6
7
8
9
10
复制代码<message id="newInvoice" name="newInvoiceMessage" type="REST | MQ">
<params>
<param name="target">${target}</param>
<param name="a">${a}</param>
<param name="x">${x}</param>
</params>
</message>
<boundaryEvent id="boundary" attachedToRef="task" cancelActivity="true">
<messageEventDefinition messageRef="newInvoice"/>
</boundaryEvent>

7、顺序流程节点,在表现形式上是一个单向箭头,因此需要定义两端的元素。起始元素用sourceRef属性定义,指向元素用targetRef属性定义,其值都是元素的id属性值。由此也可以看出,顺序流上的基本元素通常都需要有id属性进行标识的,而且最好不要重复,避免混淆。

1
复制代码<sequenceFlow id="flow1" sourceRef="ss" targetRef="tt" />

8、 条件流程节点,意思满足某种条件才通向对应的顺序流。

1
2
3
复制代码<sequenceFlow id="flow1" sourceRef="exclusiveGw" targetRef="task1">
<conditionExpression>${condition}</conditionExpression>
</sequenceFlow>

9、用户任务,表示需要工作人员实际操作推动的任务节点。

BPMN2.0标准中有很丰富的任务类型,诸如脚本任务,Java服务任务,邮件任务等等,Activiti也扩展出来了Mule任务,Camel任务等。

而在工单系统中,只需要用到用户任务,搭配其他事件,即可很好的满足业务需求。

1
复制代码<userTask id="task" name="verify_task"/>

10、排他性网关节点,就像程序的if-else的判断,连接排他性网关的众多分支,最终只会走向其中一个分支。

1
复制代码<exclusiveGateway id="xgid" name="Request approved" default="sf"/>  <!-- default表示默认流程 -->

11、并行性网关节点,和排他性网关是对立的,连接并行性网关的众多分支将会同时执行。

1
复制代码<parallelGateway id="pgid" name="gname"/>

12、空结束事件节点,通常作为一个流程的结束。

1
复制代码<endEvent id="end" name="结束事件"/>

补充说明:

在上述的定时事件,消息定义,顺序流等元素均用到了同一种取值方式,即我们常见的${value}形式,并非用到了spring相关的解析手段,而是受到Activiti的启发,使用的是JUEL工具对表达式进行解析和执行。

在WDL中,不需要太复杂的表达式,支持简单的取值和逻辑运算即可,如:${name},${approved==true}。

二、实例讲解

为了加深对上述元素的理解,笔者挑了5个具有代表性的实例,展示其WDL的内容,实例配图均来自这份文档。

需要注意的是,definitions下面的子节点不讲究先后顺序,不一定要按流程走向书写WDL。个人习惯是先定义节点元素,然后用顺序流(sequenceFlow)进行连接。


这种情况比较简单,只有一个开始节点和一个任务节点。

1
2
3
4
5
6
7
8
9
复制代码<definitions id="def" name="工作流程配置"> 
<process id="verifyCredit" name="verify credit">
<startEvent id="start" name="开始"/>
<userTask id="unkown" name=""/>
<sequenceFlow id="flow1" sourceRef="start" targetRef="unkown">
<conditionExpression>${condition}</conditionExpression>
</sequenceFlow>
</process>
</definitions>


这是一个典型的单支顺序流程。

1
2
3
4
5
6
7
8
9
10
11
复制代码<definitions id="def" name="工作流程配置"> 
<process id="pid" name="my process">
<startEvent id="start" name="开始"/>
<userTask id="write" name="Write monthly financial report"/>
<userTask id="verify" name="Verify monthly financial report"/>
<sequenceFlow id="flow1" sourceRef="start" targetRef="write"/>
<sequenceFlow id="flow2" sourceRef="write" targetRef="verify"/>
<sequenceFlow id="flow2" sourceRef="verify" targetRef="end"/>
<endEvent id="end"/>
</process>
</definitions>


此处出现了多分支情况,并且定义了一个defaultFlow,类似于:

1
2
3
4
5
6
7
复制代码if (conditionA) {
doTask1
} else if (conditionB) {
doTask3
} else {
doTask2
}

不难看出,排他性网关一般都会伴随一个defaultFlow,以下是WDL内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码<definitions id="def" name="工作流程配置"> 
<process id="pid" name="my process">
<startEvent id="start" name="开始"/>
<userTask id="t1" name="Task1"/>
<userTask id="t2" name="Task2"/>
<userTask id="t3" name="Task3"/>
<exclusiveGateway id="xgid" name="Exclusive Gateway" default="t2"/>
<sequenceFlow id="flow1" sourceRef="xgid" targetRef="t1">
<conditionExpression>${conditionA}</conditionExpression>
</sequenceFlow>
<sequenceFlow id="flow2" sourceRef="xgid" targetRef="t2"/>
<sequenceFlow id="flow1" sourceRef="xgid" targetRef="t3">
<conditionExpression>${conditionB}</conditionExpression>
</sequenceFlow>
</process>
</definitions>


到此处为止,算是可以看到一个完整的流程定义了,有开始和结束,各个任务节点,以及分支。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
复制代码<definitions id="def" name="工作流程配置"> 
<process id="verifyCredit" name="verify credit">
<startEvent id="start" name="开始"/>
<userTask id="verifyCreditHistory" name="Verify credit history"/>
<sequenceFlow id="verify_flow" sourceRef="start" targetRef="verifyCreditHistory"/>
<exclusiveGateway id="approve" name="approve or not"/>
<sequenceFlow id="end_flow" sourceRef="verifyCreditHistory" targetRef="approve">
<userTask id="contact" name="Contact customer for further information"/>
<sequenceFlow id="disapprove_flow" sourceRef="approve" targetRef="contact">
<conditionExpress>${approve==false}</conditionExpress>
</sequenceFlow>
<sequenceFlow id="end_flow" sourceRef="contact" targetRef="end1">
<endEvent id="end1"/>
<sequenceFlow id="approve_flow" sourceRef="contact" targetRef="end2">
<conditionExpress>${approve==true}</conditionExpress>
</sequenceFlow>
<endEvent id="end2"/>
</process>
</definitions>


这种情况是包含了一个定时边界事件,如果cancelActivity=”false”,那么情况就变得较为复杂了,因为有两处结束节点,cancelActivity=”true”的时候,则只在一处结束。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复制代码<definitions id="def" name="工作流程配置"> 
<process id="verifyCredit" name="verify credit">
<startEvent id="start" name="开始"/>
<userTask id="firstLineSupport" name="First line support"/>
<boundaryEvent id="escalationTimer" cancelActivity="true" attachedToRef="firstLineSupport">
<timerEventDefinition>
<timeDuration>2017-02-12 12:00:00</timeDuration>
</timerEventDefinition>
</boundaryEvent>
<sequenceFlow id="flow1" sourceRef="start" targetRef="firstLineSupport"/>
<sequenceFlow id="flow2" sourceRef="firstLineSupport" targetRef="end1"/>
<endEvent id="end1"/>
<userTask id="secondLineSupport" name="Second line support"/>
<sequenceFlow id="flow3" sourceRef="firstLineSupport" targetRef="secondLineSupport"/>
<sequenceFlow id="flow4" sourceRef="secondLineSupport" targetRef="end2"/>
<endEvent id="end2"/>
</process>
</definitions>

到此,工单系统所需的基础知识就讲解完毕了。整体感觉,BPMN2.0还是简单易懂的,并且能覆盖到绝大多数工单流程,其能成为业内标准,也是自有一番道理的。

本文转载自: 掘金

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

SpringMVC常用注解标签详解 1、Controlle

发表于 2018-05-21

1、@Controller

在SpringMVC 中,控制器Controller 负责处理由DispatcherServlet 分发的请求,它把用户请求的数据经过业务处理层处理之后封装成一个Model ,然后再把该Model 返回给对应的View 进行展示。在SpringMVC 中提供了一个非常简便的定义Controller 的方法,你无需继承特定的类或实现特定的接口,只需使用@Controller 标记一个类是Controller ,然后使用@RequestMapping 和@RequestParam 等一些注解用以定义URL 请求和Controller 方法之间的映射,这样的Controller 就能被外界访问到。此外Controller 不会直接依赖于HttpServletRequest 和HttpServletResponse 等HttpServlet 对象,它们可以通过Controller 的方法参数灵活的获取到。

@Controller 用于标记在一个类上,使用它标记的类就是一个SpringMVC Controller 对象。分发处理器将会扫描使用了该注解的类的方法,并检测该方法是否使用了@RequestMapping 注解。@Controller 只是定义了一个控制器类,而使用@RequestMapping 注解的方法才是真正处理请求的处理器。单单使用@Controller 标记在一个类上还不能真正意义上的说它就是SpringMVC 的一个控制器类,因为这个时候Spring 还不认识它。那么要如何做Spring 才能认识它呢?这个时候就需要我们把这个控制器类交给Spring 来管理。有两种方式:

(1)在SpringMVC 的配置文件中定义MyController 的bean 对象。

(2)在SpringMVC 的配置文件中告诉Spring 该到哪里去找标记为@Controller 的Controller 控制器。

1
2
3
4
复制代码<!--方式一-->
<bean class="com.host.app.web.controller.MyController"/>
<!--方式二-->
< context:component-scan base-package = "com.host.app.web" />//路径写到controller的上一层(扫描包详解见下面浅析)

2、@RequestMapping

RequestMapping是一个用来处理请求地址映射的注解,可用于类或方法上。用于类上,表示类中的所有响应请求的方法都是以该地址作为父路径。

RequestMapping注解有六个属性,下面我们把她分成三类进行说明(下面有相应示例)。

①. value, method;

value: 指定请求的实际地址,指定的地址可以是URI Template 模式(后面将会说明);

method: 指定请求的method类型, GET、POST、PUT、DELETE等;

②. consumes,produces

consumes: 指定处理请求的提交内容类型(Content-Type),例如application/json, text/html;

produces: 指定返回的内容类型,仅当request请求头中的(Accept)类型中包含该指定类型才返回;

③. params,headers

params: 指定request中必须包含某些参数值是,才让该方法处理。

headers: 指定request中必须包含某些指定的header值,才能让该方法处理请求。

3、@Resource和@Autowired

@Resource和@Autowired都是做bean的注入时使用,其实@Resource并不是Spring的注解,它的包是javax.annotation.Resource,需要导入,但是Spring支持该注解的注入。

共同点

两者都可以写在字段和setter方法上。两者如果都写在字段上,那么就不需要再写setter方法。

不同点

(1)@Autowired

@Autowired为Spring提供的注解,需要导入包org.springframework.beans.factory.annotation.Autowired;只按照byType注入。

1
2
3
4
5
6
7
8
9
10
复制代码public class TestServiceImpl {
// 下面两种@Autowired只要使用一种即可
@Autowired
private UserDao userDao; // 用于字段上

@Autowired
public void setUserDao(UserDao userDao) { // 用于属性的方法上
this.userDao = userDao;
}
}

@Autowired注解是按照类型(byType)装配依赖对象,默认情况下它要求依赖对象必须存在,如果允许null值,可以设置它的required属性为false。如果我们想使用按照名称(byName)来装配,可以结合@Qualifier注解一起使用。如下:

1
2
3
4
5
复制代码public class TestServiceImpl {
@Autowired
@Qualifier("userDao")
private UserDao userDao;
}

(2)@Resource

@Resource默认按照ByName自动注入,由J2EE提供,需要导入包javax.annotation.Resource。@Resource有两个重要的属性:name和type,而Spring将@Resource注解的name属性解析为bean的名字,而type属性则解析为bean的类型。所以,如果使用name属性,则使用byName的自动注入策略,而使用type属性时则使用byType自动注入策略。如果既不制定name也不制定type属性,这时将通过反射机制使用byName自动注入策略。

1
2
3
4
5
6
7
8
9
10
复制代码public class TestServiceImpl {
// 下面两种@Resource只要使用一种即可
@Resource(name="userDao")
private UserDao userDao; // 用于字段上

@Resource(name="userDao")
public void setUserDao(UserDao userDao) { // 用于属性的setter方法上
this.userDao = userDao;
}
}

注:最好是将@Resource放在setter方法上,因为这样更符合面向对象的思想,通过set、get去操作属性,而不是直接去操作属性。

@Resource装配顺序:

①如果同时指定了name和type,则从Spring上下文中找到唯一匹配的bean进行装配,找不到则抛出异常。

②如果指定了name,则从上下文中查找名称(id)匹配的bean进行装配,找不到则抛出异常。

③如果指定了type,则从上下文中找到类似匹配的唯一bean进行装配,找不到或是找到多个,都会抛出异常。

④如果既没有指定name,又没有指定type,则自动按照byName方式进行装配;如果没有匹配,则回退为一个原始类型进行匹配,如果匹配则自动装配。

@Resource的作用相当于@Autowired,只不过@Autowired按照byType自动注入。

4、@ModelAttribute和 @SessionAttributes

代表的是:该Controller的所有方法在调用前,先执行此@ModelAttribute方法,可用于注解和方法参数中,可以把这个@ModelAttribute特性,应用在BaseController当中,所有的Controller继承BaseController,即可实现在调用Controller时,先执行@ModelAttribute方法。

@SessionAttributes即将值放到session作用域中,写在class上面。

具体示例参见下面:使用 @ModelAttribute 和 @SessionAttributes 传递和保存数据

5、@PathVariable

用于将请求URL中的模板变量映射到功能处理方法的参数上,即取出uri模板中的变量作为参数。如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
复制代码@Controller  
public class TestController {
@RequestMapping(value="/user/{userId}/roles/{roleId}",method = RequestMethod.GET)
public String getLogin(@PathVariable("userId") String userId,
@PathVariable("roleId") String roleId){
System.out.println("User Id : " + userId);
System.out.println("Role Id : " + roleId);
return "hello";
}
@RequestMapping(value="/product/{productId}",method = RequestMethod.GET)
public String getProduct(@PathVariable("productId") String productId){
System.out.println("Product Id : " + productId);
return "hello";
}
@RequestMapping(value="/javabeat/{regexp1:[a-z-]+}",
method = RequestMethod.GET)
public String getRegExp(@PathVariable("regexp1") String regexp1){
System.out.println("URI Part 1 : " + regexp1);
return "hello";
}
}

6、@requestParam

@requestParam主要用于在SpringMVC后台控制层获取参数,类似一种是request.getParameter(“name”),它有三个常用参数:defaultValue = “0”, required = false, value = “isApp”;defaultValue 表示设置默认值,required 铜过boolean设置是否是必须要传入的参数,value 值表示接受的传入的参数类型。

7、@ResponseBody

作用: 该注解用于将Controller的方法返回的对象,通过适当的HttpMessageConverter转换为指定格式后,写入到Response对象的body数据区。

使用时机:返回的数据不是html标签的页面,而是其他某种格式的数据时(如json、xml等)使用;

8、@Component

相当于通用的注解,当不知道一些类归到哪个层时使用,但是不建议。

9、@Repository

用于注解dao层,在daoImpl类上面注解。

注:

1、使用 @RequestMapping 来映射 Request 请求与处理器

方式一、通过常见的类路径和方法路径结合访问controller方法

方式二、使用uri模板

1
2
3
4
5
6
7
8
9
10
11
12
复制代码@Controller
@RequestMapping ( "/test/{variable1}" )
public class MyController {

@RequestMapping ( "/showView/{variable2}" )
public ModelAndView showView( @PathVariable String variable1, @PathVariable ( "variable2" ) int variable2) {
ModelAndView modelAndView = new ModelAndView();
modelAndView.setViewName( "viewName" );
modelAndView.addObject( " 需要放到 model 中的属性名称 " , " 对应的属性值,它是一个对象 " );
return modelAndView;
}
}

URI 模板就是在URI 中给定一个变量,然后在映射的时候动态的给该变量赋值。如URI 模板http://localhost/app/{variable1}/index.html ,这个模板里面包含一个变量variable1 ,那么当我们请求http://localhost/app/hello/index.html 的时候,该URL 就跟模板相匹配,只是把模板中的variable1 用hello 来取代。这个变量在SpringMVC 中是使用@PathVariable 来标记的。在SpringMVC 中,我们可以使用@PathVariable 来标记一个Controller 的处理方法参数,表示该参数的值将使用URI 模板中对应的变量的值来赋值。

代码中我们定义了两个URI 变量,一个是控制器类上的variable1 ,一个是showView 方法上的variable2 ,然后在showView 方法的参数里面使用@PathVariable 标记使用了这两个变量。所以当我们使用/test/hello/showView/2.do 来请求的时候就可以访问到MyController 的showView 方法,这个时候variable1 就被赋予值hello ,variable2 就被赋予值2 ,然后我们在showView 方法参数里面标注了参数variable1 和variable2 是来自访问路径的path 变量,这样方法参数variable1 和variable2 就被分别赋予hello 和2 。方法参数variable1 是定义为String 类型,variable2 是定义为int 类型,像这种简单类型在进行赋值的时候Spring 是会帮我们自动转换的。

在上面的代码中我们可以看到在标记variable1 为path 变量的时候我们使用的是@PathVariable ,而在标记variable2 的时候使用的是@PathVariable(“variable2”) 。这两者有什么区别呢?第一种情况就默认去URI 模板中找跟参数名相同的变量,但是这种情况只有在使用debug 模式进行编译的时候才可以,而第二种情况是明确规定使用的就是URI 模板中的variable2 变量。当不是使用debug 模式进行编译,或者是所需要使用的变量名跟参数名不相同的时候,就要使用第二种方式明确指出使用的是URI 模板中的哪个变量。

除了在请求路径中使用URI 模板,定义变量之外,@RequestMapping 中还支持通配符“* ”。如下面的代码我就可以使用/myTest/whatever/wildcard.do 访问到Controller 的testWildcard 方法。如:

1
2
3
4
5
6
7
8
9
复制代码@Controller
@RequestMapping ( "/myTest" )
public class MyController {
@RequestMapping ( "*/wildcard" )
public String testWildcard() {
System. out .println( "wildcard------------" );
return "wildcard" ;
}
}

当@RequestParam中没有指定参数名称时,Spring 在代码是debug 编译的情况下会默认取更方法参数同名的参数,如果不是debug 编译的就会报错。

2、使用 @RequestMapping 的一些高级用法

(1)params属性

1
2
3
4
5
复制代码@RequestMapping (value= "testParams" , params={ "param1=value1" , "param2" , "!param3" })
public String testParams() {
System. out .println( "test Params..........." );
return "testParams" ;
}

用@RequestMapping 的params 属性指定了三个参数,这些参数都是针对请求参数而言的,它们分别表示参数param1 的值必须等于value1 ,参数param2 必须存在,值无所谓,参数param3 必须不存在,只有当请求/testParams.do 并且满足指定的三个参数条件的时候才能访问到该方法。所以当请求/testParams.do?param1=value1¶m2=value2 的时候能够正确访问到该testParams 方法,当请求/testParams.do?param1=value1¶m2=value2¶m3=value3 的时候就不能够正常的访问到该方法,因为在@RequestMapping 的params 参数里面指定了参数param3 是不能存在的。

(2)method属性

1
2
3
4
复制代码@RequestMapping (value= "testMethod" , method={RequestMethod. GET , RequestMethod. DELETE })
public String testMethod() {
return "method" ;
}

在上面的代码中就使用method 参数限制了以GET 或DELETE 方法请求/testMethod 的时候才能访问到该Controller 的testMethod 方法。

(3)headers属性

1
2
3
4
复制代码@RequestMapping (value= "testHeaders" , headers={ "host=localhost" , "Accept" })
public String testHeaders() {
return "headers" ;
}

headers 属性的用法和功能与params 属性相似。在上面的代码中当请求/testHeaders.do 的时候只有当请求头包含Accept 信息,且请求的host 为localhost 的时候才能正确的访问到testHeaders 方法。

3、 @RequestMapping 标记的处理器方法支持的方法参数和返回类型

支持的方法参数类型:

(1 )HttpServlet 对象,主要包括HttpServletRequest 、HttpServletResponse 和HttpSession 对象。 这些参数Spring 在调用处理器方法的时候会自动给它们赋值,所以当在处理器方法中需要使用到这些对象的时候,可以直接在方法上给定一个方法参数的申明,然后在方法体里面直接用就可以了。但是有一点需要注意的是在使用HttpSession 对象的时候,如果此时HttpSession 对象还没有建立起来的话就会有问题。

(2 )Spring 自己的WebRequest 对象。 使用该对象可以访问到存放在HttpServletRequest 和HttpSession 中的属性值。

(3 )InputStream 、OutputStream 、Reader 和Writer 。 InputStream 和Reader 是针对HttpServletRequest 而言的,可以从里面取数据;OutputStream 和Writer 是针对HttpServletResponse 而言的,可以往里面写数据。

(4 )使用@PathVariable 、@RequestParam 、@CookieValue 和@RequestHeader 标记的参数。

(5 )使用@ModelAttribute 标记的参数。

(6 )java.util.Map 、Spring 封装的Model 和ModelMap 。 这些都可以用来封装模型数据,用来给视图做展示。

(7 )实体类。 可以用来接收上传的参数。

(8 )Spring 封装的MultipartFile 。 用来接收上传文件的。

(9 )Spring 封装的Errors 和BindingResult 对象。 这两个对象参数必须紧接在需要验证的实体对象参数之后,它里面包含了实体对象的验证结果。

支持的返回类型

(1 )一个包含模型和视图的ModelAndView 对象。

(2 )一个模型对象,这主要包括Spring 封装好的Model 和ModelMap ,以及java.util.Map ,当没有视图返回的时候视图名称将由RequestToViewNameTranslator 来决定。

(3 )一个View 对象。这个时候如果在渲染视图的过程中模型的话就可以给处理器方法定义一个模型参数,然后在方法体里面往模型中添加值。

(4 )一个String 字符串。这往往代表的是一个视图名称。这个时候如果需要在渲染视图的过程中需要模型的话就可以给处理器方法一个模型参数,然后在方法体里面往模型中添加值就可以了。

(5 )返回值是void 。这种情况一般是我们直接把返回结果写到HttpServletResponse 中了,如果没有写的话,那么Spring 将会利用RequestToViewNameTranslator 来返回一个对应的视图名称。如果视图中需要模型的话,处理方法与返回字符串的情况相同。

(6 )如果处理器方法被注解@ResponseBody 标记的话,那么处理器方法的任何返回类型都会通过HttpMessageConverters 转换之后写到HttpServletResponse 中,而不会像上面的那些情况一样当做视图或者模型来处理。

(7 )除以上几种情况之外的其他任何返回类型都会被当做模型中的一个属性来处理,而返回的视图还是由RequestToViewNameTranslator 来决定,添加到模型中的属性名称可以在该方法上用@ModelAttribute(“attributeName”) 来定义,否则将使用返回类型的类名称的首字母小写形式来表示。使用@ModelAttribute 标记的方法会在@RequestMapping 标记的方法执行之前执行。

4、使用 @ModelAttribute 和 @SessionAttributes 传递和保存数据

SpringMVC 支持使用 @ModelAttribute 和 @SessionAttributes 在不同的模型(model)和控制器之间共享数据。 @ModelAttribute主要有两种使用方式,一种是标注在方法上,一种是标注在 Controller 方法参数上。

当 @ModelAttribute 标记在方法上的时候,该方法将在处理器方法执行之前执行,然后把返回的对象存放在 session 或模型属性中,属性名称可以使用 @ModelAttribute(“attributeName”) 在标记方法的时候指定,若未指定,则使用返回类型的类名称(首字母小写)作为属性名称。关于 @ModelAttribute 标记在方法上时对应的属性是存放在 session 中还是存放在模型中,我们来做一个实验,看下面一段代码。

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
复制代码@Controller
@RequestMapping ( "/myTest" )
public class MyController {

@ModelAttribute ( "hello" )
public String getModel() {
System. out .println( "-------------Hello---------" );
return "world" ;
}

@ModelAttribute ( "intValue" )
public int getInteger() {
System. out .println( "-------------intValue---------------" );
return 10;
}

@RequestMapping ( "sayHello" )
public void sayHello( @ModelAttribute ( "hello" ) String hello, @ModelAttribute ( "intValue" ) int num, @ModelAttribute ( "user2" ) User user, Writer writer, HttpSession session) throws IOException {
writer.write( "Hello " + hello + " , Hello " + user.getUsername() + num);
writer.write( "\r" );
Enumeration enume = session.getAttributeNames();
while (enume.hasMoreElements())
writer.write(enume.nextElement() + "\r" );
}

@ModelAttribute ( "user2" )
public User getUser(){
System. out .println( "---------getUser-------------" );
return new User(3, "user2" );
}
}

当我们请求 /myTest/sayHello.do 的时候使用 @ModelAttribute 标记的方法会先执行,然后把它们返回的对象存放到模型中。最终访问到 sayHello 方法的时候,使用 @ModelAttribute 标记的方法参数都能被正确的注入值。执行结果如下所示:

Hello world,Hello user210

由执行结果我们可以看出来,此时 session 中没有包含任何属性,也就是说上面的那些对象都是存放在模型属性中,而不是存放在 session 属性中。那要如何才能存放在 session 属性中呢?这个时候我们先引入一个新的概念 @SessionAttributes ,它的用法会在讲完 @ModelAttribute 之后介绍,这里我们就先拿来用一下。我们在 MyController 类上加上 @SessionAttributes 属性标记哪些是需要存放到 session 中的。看下面的代码:

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
复制代码@Controller
@RequestMapping ( "/myTest" )
@SessionAttributes (value={ "intValue" , "stringValue" }, types={User. class })
public class MyController {

@ModelAttribute ( "hello" )
public String getModel() {
System. out .println( "-------------Hello---------" );
return "world" ;
}

@ModelAttribute ( "intValue" )
public int getInteger() {
System. out .println( "-------------intValue---------------" );
return 10;
}

@RequestMapping ( "sayHello" )
public void sayHello(Map<String, Object> map, @ModelAttribute ( "hello" ) String hello, @ModelAttribute ( "intValue" ) int num, @ModelAttribute ( "user2" ) User user, Writer writer, HttpServletRequest request) throws IOException {
map.put( "stringValue" , "String" );
writer.write( "Hello " + hello + " , Hello " + user.getUsername() + num);
writer.write( "\r" );
HttpSession session = request.getSession();
Enumeration enume = session.getAttributeNames();
while (enume.hasMoreElements())
writer.write(enume.nextElement() + "\r" );
System. out .println(session);
}

@ModelAttribute ( "user2" )
public User getUser() {
System. out .println( "---------getUser-------------" );
return new User(3, "user2" );
}
}

在上面代码中我们指定了属性为 intValue 或 stringValue 或者类型为 User 的都会放到 Session中,利用上面的代码当我们访问 /myTest/sayHello.do 的时候,结果如下:

Hello world,Hello user210

仍然没有打印出任何 session 属性,这是怎么回事呢?怎么定义了把模型中属性名为 intValue 的对象和类型为 User 的对象存到 session 中,而实际上没有加进去呢?难道我们错啦?我们当然没有错,只是在第一次访问 /myTest/sayHello.do 的时候 @SessionAttributes 定义了需要存放到 session 中的属性,而且这个模型中也有对应的属性,但是这个时候还没有加到 session 中,所以 session 中不会有任何属性,等处理器方法执行完成后 Spring 才会把模型中对应的属性添加到 session 中。所以当请求第二次的时候就会出现如下结果:

Hello world,Hello user210

user2

intValue

stringValue

当 @ModelAttribute 标记在处理器方法参数上的时候,表示该参数的值将从模型或者 Session 中取对应名称的属性值,该名称可以通过 @ModelAttribute(“attributeName”) 来指定,若未指定,则使用参数类型的类名称(首字母小写)作为属性名称。

5、@PathVariable和@RequestParam的区别

请求路径上有个id的变量值,可以通过@PathVariable来获取 @RequestMapping(value = “/page/{id}”, method = RequestMethod.GET)
@RequestParam用来获得静态的URL请求入参 spring注解时action里用到。

简介:

handler method 参数绑定常用的注解,我们根据他们处理的Request的不同内容部分分为四类:(主要讲解常用类型)

A、处理requet uri部分(这里指uri template中variable,不含queryString部分)的注解: @PathVariable;

B、处理request header部分的注解: @RequestHeader, @CookieValue;

C、处理request body部分的注解:@RequestParam, @RequestBody;

D、处理attribute类型是注解: @SessionAttributes, @ModelAttribute;

(1)、@PathVariable

当使用@RequestMapping URI template 样式映射时, 即 someUrl/{paramId}, 这时的paramId可通过 @Pathvariable注解绑定它传过来的值到方法的参数上。

示例代码:

上面代码把URI template 中变量 ownerId的值和petId的值,绑定到方法的参数上。若方法参数名称和需要绑定的uri template中变量名称不一致,需要在@PathVariable(“name”)指定uri template中的名称。

(2)、 @RequestHeader、@CookieValue

@RequestHeader 注解,可以把Request请求header部分的值绑定到方法的参数上。

示例代码:

1
2
3
4
5
6
7
8
9
复制代码@Controller  
@RequestMapping("/owners/{ownerId}")
public class RelativePathUriTemplateController {

@RequestMapping("/pets/{petId}")
public void findPet(@PathVariable String ownerId, @PathVariable String petId, Model model) {
// implementation omitted
}
}

上面代码把URI template 中变量 ownerId的值和petId的值,绑定到方法的参数上。若方法参数名称和需要绑定的uri template中变量名称不一致,需要在@PathVariable(“name”)指定uri template中的名称。

(2)、 @RequestHeader、@CookieValue

@RequestHeader 注解,可以把Request请求header部分的值绑定到方法的参数上。

示例代码:

这是一个Request 的header部分:

1
2
3
4
5
6
7
8
9
10
11
复制代码Host                    localhost:8080  
Accept text/html,application/xhtml+xml,application/xml;q=0.9
Accept-Language fr,en-gb;q=0.7,en;q=0.3
Accept-Encoding gzip,deflate
Accept-Charset ISO-8859-1,utf-8;q=0.7,*;q=0.7
Keep-Alive 300
@RequestMapping("/displayHeaderInfo.do")

public void displayHeaderInfo(@RequestHeader("Accept-Encoding") String encoding,
@RequestHeader("Keep-Alive") long keepAlive) {
}

上面的代码,把request header部分的 Accept-Encoding的值,绑定到参数encoding上了, Keep-Alive header的值绑定到参数keepAlive上。

@CookieValue 可以把Request header中关于cookie的值绑定到方法的参数上。

例如有如下Cookie值:

JSESSIONID=415A4AC178C59DACE0B2C9CA727CDD84

1
2
3
复制代码@RequestMapping("/displayHeaderInfo.do")  
public void displayHeaderInfo(@CookieValue("JSESSIONID") String cookie) {
}

即把JSESSIONID的值绑定到参数cookie上。

(3)、@RequestParam, @RequestBody

@RequestParam

A) 常用来处理简单类型的绑定,通过Request.getParameter() 获取的String可直接转换为简单类型的情况( String–> 简单类型的转换操作由ConversionService配置的转换器来完成);因为使用request.getParameter()方式获取参数,所以可以处理get 方式中queryString的值,也可以处理post方式中 body data的值;

B)用来处理Content-Type: 为 application/x-www-form-urlencoded编码的内容,提交方式GET、POST;

C) 该注解有两个属性: value、required; value用来指定要传入值的id名称,required用来指示参数是否必须绑定;

示例代码:

1
2
3
4
5
6
7
8
9
10
11
复制代码@Controller  
@RequestMapping("/pets")
@SessionAttributes("pet")
public class EditPetForm {
@RequestMapping(method = RequestMethod.GET)
public String setupForm(@RequestParam("petId") int petId, ModelMap model) {
Pet pet = this.clinic.loadPet(petId);
model.addAttribute("pet", pet);
return "petForm";
}
}

@RequestBody

该注解常用来处理Content-Type: 不是application/x-www-form-urlencoded编码的内容,例如application/json, application/xml等;

它是通过使用HandlerAdapter 配置的HttpMessageConverters来解析post data body,然后绑定到相应的bean上的。

因为配置有FormHttpMessageConverter,所以也可以用来处理 application/x-www-form-urlencoded的内容,处理完的结果放在一个MultiValueMap<String, String>里,这种情况在某些特殊需求下使用,详情查看FormHttpMessageConverter api;

示例代码:

1
2
3
4
复制代码@RequestMapping(value = "/something", method = RequestMethod.PUT)  
public void handle(@RequestBody String body, Writer writer) throws IOException {
writer.write(body);
}

(4)、@SessionAttributes, @ModelAttribute

@SessionAttributes:

该注解用来绑定HttpSession中的attribute对象的值,便于在方法中的参数里使用。

该注解有value、types两个属性,可以通过名字和类型指定要使用的attribute 对象;

示例代码:

1
2
3
4
5
6
复制代码@Controller  
@RequestMapping("/editPet.do")
@SessionAttributes("pet")
public class EditPetForm {
// ...
}

@ModelAttribute

该注解有两个用法,一个是用于方法上,一个是用于参数上;

用于方法上时: 通常用来在处理@RequestMapping之前,为请求绑定需要从后台查询的model;

用于参数上时: 用来通过名称对应,把相应名称的值绑定到注解的参数bean上;要绑定的值来源于:

A) @SessionAttributes 启用的attribute 对象上;

B) @ModelAttribute 用于方法上时指定的model对象;

C) 上述两种情况都没有时,new一个需要绑定的bean对象,然后把request中按名称对应的方式把值绑定到bean中。

用到方法上@ModelAttribute的示例代码:

1
2
3
4
复制代码@ModelAttribute  
public Account addAccount(@RequestParam String number) {
return accountManager.findAccount(number);
}

这种方式实际的效果就是在调用@RequestMapping的方法之前,为request对象的model里put(“account”, Account)。

用在参数上的@ModelAttribute示例代码:

1
2
3
4
复制代码@RequestMapping(value="/owners/{ownerId}/pets/{petId}/edit", method = RequestMethod.POST)  
public String processSubmit(@ModelAttribute Pet pet) {

}

首先查询 @SessionAttributes有无绑定的Pet对象,若没有则查询@ModelAttribute方法层面上是否绑定了Pet对象,若没有则将URI template中的值按对应的名称绑定到Pet对象的各属性上。

6、< context:component-scan base-package = “” />浅析

component-scan 默认扫描的注解类型是 @Component,不过,在 @Component 语义基础上细化后的 @Repository, @Service 和 @Controller 也同样可以获得 component-scan 的青睐

有了context:component-scan,另一个context:annotation-config/标签根本可以移除掉,因为已经被包含进去了

另外context:annotation-config/还提供了两个子标签

  1. context:include-filter //指定扫描的路径
  1. context:exclude-filter //排除扫描的路径

context:component-scan有一个use-default-filters属性,属性默认为true,表示会扫描指定包下的全部的标有@Component的类,并注册成bean.也就是@Component的子注解@Service,@Reposity等。

这种扫描的粒度有点太大,如果你只想扫描指定包下面的Controller或其他内容则设置use-default-filters属性为false,表示不再按照scan指定的包扫描,而是按照context:include-filter指定的包扫描,示例:

1
2
3
复制代码<context:component-scan base-package="com.tan" use-default-filters="false">
<context:include-filter type="regex" expression="com.tan.*"/>//注意后面要写.*
</context:component-scan>

当没有设置use-default-filters属性或者属性为true时,表示基于base-packge包下指定扫描的具体路径

1
2
3
4
5
复制代码<context:component-scan base-package="com.tan" >
<context:include-filter type="regex" expression=".controller.*"/>
<context:include-filter type="regex" expression=".service.*"/>
<context:include-filter type="regex" expression=".dao.*"/>
</context:component-scan>

效果相当于:

1
2
3
复制代码<context:component-scan base-package="com.tan" >
<context:exclude-filter type="regex" expression=".model.*"/>
</context:component-scan>

注意:本人尝试时无论哪种情况context:include-filter和context:exclude-filter都不能同时存在

我有一个微信公众号,经常会分享一些Java技术相关的干货;如果你喜欢我的分享,可以用微信搜索“Java团长”或者“javatuanzhang”关注。

本文转载自: 掘金

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

互联网公司数据安全保护新探索

发表于 2018-05-21

近年来,数据安全形势越发严峻,各种数据安全事件层出不穷。在当前形势下,互联网公司也基本达成了一个共识:虽然无法完全阻止攻击,但底线是敏感数据不能泄漏。也即是说,服务器可以被挂马,但敏感数据不能被拖走。服务器对于互联网公司来说,是可以接受的损失,但敏感数据泄漏,则会对公司产生重大声誉、经济影响。

在互联网公司的数据安全领域,无论是传统理论提出的数据安全生命周期,还是安全厂商提供的解决方案,都面临着落地困难的问题。其核心点在于对海量数据、复杂应用环境下的可操作性不佳。

例如数据安全生命周期提出,首先要对数据进行分类分级,然后才是保护。但互联网公司基本上都是野蛮生长,发展壮大以后才发现数据安全的问题。但存量数据已经形成,日以万计的数据表在增长,这种情况下如何实现数据分类分级?人工梳理显然不现实,梳理的速度赶不上数据增长速度。

再例如安全厂商提供的数据审计解决方案,也都是基于传统关系型数据库的硬件盒子。Hadoop环境下的数据审计方案是什么?面对海量数据,很多厂商也买不起这么多硬件盒子啊。

因此,互联网公司迫切需要一些符合自身特点的手段,来进行数据安全保障。为此,美团点评信息安全中心进行了一些具体层面的探索。这些探索映射到IT的层面,主要包括应用系统和数据仓库,接下来我们分别阐述。

一、应用系统

应用系统分为两块,一是对抗外部攻击,是多数公司都有的安全意识,但意识不等于能力,这是一个负责任企业的基本功。传统问题包括越权、遍历、SQL注入、安全配置、低版本漏洞等,这一类在OWASP的Top10风险都有提到,在实践中主要考虑SDL、安全运维、红蓝对抗等手段,且以产品化的形式来解决主要问题。这里不做重点介绍。

1.1 扫号及爬虫

新的形势下,还面临扫号、爬虫问题。扫号是指撞库或弱口令:撞库是用已经泄漏的账号密码来试探,成功后轻则窃取用户数据,重则盗取用户资金;弱口令则是简单密码问题。对于这类问题,业界不断的探索新方法,包括设备指纹技术、复杂验证码、人机识别、IP信誉度,试图多管齐下来缓解,但黑产也在不断升级对抗技术,包括一键新机、模拟器、IP代理、人类行为模仿,因此这是个不断的对抗过程。

举个例子,有公司在用户登录时,判断加速等传感器的变化,因为用户在手机屏幕点击时,必然会带来角度、重力的变化。如果用户点击过程中这些传感器没有任何变化,则有使用脚本的嫌疑。再加上一个维度去判断用户近期电量变化,就可以确认这是一台人类在用的手机,还是黑产工作室的手机。黑产在对抗中发现公司用了这一类的策略,则很轻易的进行了化解,一切数据都可以伪造出来,在某宝上可以看到大量的此类技术工具在出售。

爬虫对抗则是另一个新问题,之前有文章说,某些公司的数据访问流量75%以上都是爬虫。爬虫不带来任何业务价值,而且还要为此付出大量资源,同时还面临数据泄漏的问题。

在互联网金融兴起后,爬虫又产生了新的变化,从原来的未授权爬取数据,变成了用户授权爬取数据。举例来说,小张缺钱,在互联网金融公司网站申请小额贷款,而互联网金融公司并不知道小张能不能贷,还款能力如何,因此要求小张提供在购物网站、邮箱或其他应用的账号密码,爬取小张的日常消费数据,作为信用评分参考。小张为了获取贷款,提供了账号密码,则构成了授权爬取。这和以往的未授权爬取产生了很大的变化,互联网金融公司可以进来获取更多敏感信息,不但加重了资源负担,还存在用户密码泄漏的可能。

对爬虫的对抗,也是一个综合课题,不存在一个技术解决所有问题的方案。解决思路上除了之前的设备指纹、IP信誉等手段之外,还包括了各种机器学习的算法模型,以区分出正常行为和异常行为,也可以从关联模型等方向入手。但这也是个对抗过程,黑产也在逐渐摸索试探,从而模拟出人类行为。未来会形成机器与机器的对抗,而决定输赢的,则是成本。

1.2 水印

近年来业界也出现了一些将内部敏感文件,截图外发的事件。有些事件引起了媒体的炒作,对公司造成了舆论影响,这就需要能够对这种外发行为进行溯源。而水印在技术上要解决的抗鲁棒性问题,针对图片的水印技术包括空间滤波、傅立叶变换、几何变形等,简单的说是将信息经过变换,在恶劣条件下还原的技术。

1.3 数据蜜罐

是指制作一个假的数据集合,来捕获访问者,从而发现攻击行为。国外已经有公司做出了对应的产品,其实现可以粗暴地理解为,在一个数据文件上加入了一个“木马”,所有的访问者再打开后,会把对应记录发回服务器。通过这个“木马”,可以追踪到攻击者细节信息。我们也曾做过类似的事情,遗憾的是,这个数据文件放在那里很久,都无人访问。无人访问和我我们对蜜罐的定位有关,现阶段我们更愿意把它作为一个实验性的小玩意,而不是大规模采用,因为“木马”本身,可能带有一定的风险。

1.4 大数据行为审计

大数据的出现,为关联审计提供了更多的可能性,可以通过各种数据关联起来分析异常行为。这方面,传统安全审计厂商做了一些尝试,但从客观的角度来看,还比较基础,无法应对大型互联网公司复杂情况下的行为审计,当然这不能苛求传统安全审计厂商,这与生意有关,生意是要追求利润的。这种情况下,互联网公司就要自己做更多的事情。

例如防范内鬼,可以通过多种数据关联分析,通过“与坏人共用过一个设备”规则,来发现内鬼。举一反三,则可以通过信息流、物流、资金流等几个大的方向衍生出更多符合自身数据特点的抓内鬼规则。

除此之外,还可以通过UEBA(用户与实体行为分析)来发现异常,这需要在各个环节去埋点采集数据,后端则需要对应的规则引擎系统、数据平台、算法平台来支撑。

例如常见的聚类算法:某些人与大多数人行为不一致,则这些人可能有异常。具体场景可以是:正常用户行为首先是打开页面,选择产品,然后才是登录、下单。而异常行为可以是:先登录,然后修改密码,最后下单选了一个新开的店,使用了一个大额优惠券。这里每一个数据字段,都可以衍生出各种变量,通过这些变量,最后可以有一个异常判断。

再例如关联模型,一个坏人团伙,通常是有联系的。这些维度可以包括IP、设备、WiFi MAC地址、GPS位置、物流地址、资金流等若干维度,再结合自己的其他数据,可以关联出一个团伙。而团伙中如果有一个人标记为黑,则关系圈则会根据关系强弱进行信誉打分降级。

UEBA的基础是有足够的数据支撑,数据可以是外部的数据供应商。例如腾讯、阿里都提供一些对外数据服务,包括对IP信誉的判断等,使用这些数据,可以起到联防联控的效果。也可以是内部的,互联网公司总会有若干条业务线服务一个客户,这就要看安全人员的数据敏感度了,哪些数据能为自己所用。

1.5 数据脱敏

在应用系统中,总会有很多用户敏感数据。应用系统分为对内和对外,对外的系统脱敏,主要是防止撞号和爬虫。对内的系统脱敏,主要是防止内部人员泄漏信息。

对外系统的脱敏保护,可以分层来对待。默认情况下,对于银行卡号、身份证、手机号、地址等关键信息,强制脱敏,以****替换关键位置,这样即使被撞库或者爬虫,也获取不到相关信息,从而保护用户数据安全。但总有客户需要看到自己或修改自己的完整信息,这时就需要分层保护,主要是根据常用设备来判断,如果是常用设备,则可以无障碍的点击后显示。如果非常用设备,则推送一个强验证。

在日常业务中,美团点评还有一个特点。外卖骑手与买家的联系,骑手可能找不到具体位置,需要和买家进行沟通,这时至少包括了地址、手机号两条信息暴露。而对于买家信息的保护,我们也进行了摸索试探。手机号码信息,我们通过一个“小号”的机制来解决,骑手得到的是一个临时中转号码,用这个号码与买家联系,而真实号码则是不可见的。地址信息,我们在系统中使用了图片显示,在订单完成之后,地址信息则不可见。

对内系统的脱敏保护,实践中可以分为几个步骤走。首先是检测内部系统中的敏感信息,这里可以选择从Log中获取,或者从JS前端获取,两个方案各有优劣。从Log中获取,要看公司整体上对日志的规范,不然每个系统一种日志,对接周期长工作量大。从前端JS获取,方案比较轻量化,但要考虑性能对业务的影响。

检测的目的是持续发现敏感信息变化,因为在内部复杂环境中,系统会不断的改造升级,如果缺少持续监控的手段,会变成运动式工程,无法保证持续性。

检测之后要做的事情,则是进行脱敏处理。脱敏过程需要与业务方沟通明确好,哪些字段必须强制完全脱敏,哪些是半脱敏。应用系统权限建设比较规范的情况下,可以考虑基于角色进行脱敏,例如风控案件人员,是一定需要用户的银行卡完整信息的,这时候可以根据角色赋予免疫权限。但客服人员则不需要查看完整信息,则进行强制脱敏。在免疫和脱敏之间,还有一层叫做半脱敏,是指在需要的时候,可以点击查看完整号码,点击动作则会被记录。

就脱敏整体而言,应该有一个全局视图。每天有多少用户敏感信息被访问到,有多少信息脱敏,未脱敏的原因是什么。这样可以整体追踪变化,目标是不断降低敏感信息访问率,当视图出现异常波动,则代表业务产生了变化,需要追踪事件原因。

二、数据仓库

数据仓库是公司数据的核心,这里出了问题则面临巨大风险。而数据仓库的治理,是一个长期渐进的建设过程,其中安全环节只是其中一小部分,更多的则是数据治理层面。本文主要谈及安全环节中的一些工具性建设,包括数据脱敏、隐私保护、大数据行为审计、资产地图、数据扫描器。

2.1 数据脱敏

数据仓库的脱敏是指对敏感数据进行变形,从而起到保护敏感数据的目的,主要用于数据分析人员和开发人员对未知数据进行探索。脱敏在实践过程中有若干种形式,包括对数据的混淆、替换,在不改变数据本身表述的情况下进行数据使用。但数据混淆也好,替换也好,实际上都是有成本的,在大型互联网公司的海量数据情况下,这种数据混淆替换代价非常高昂,
实践中常用的方式,则是较为简单的部分遮盖,例如对手机号的遮盖,139****0011来展示,这种方法规则简单,能起到一定程度上的保护效果。

但有些场景下,简单的遮盖是不能满足业务要求的,这时就需要考虑其他手段,例如针对信用卡号码的的Tokenization,针对范围数据的分段,针对病例的多样性,甚至针对图片的base64遮盖。因此需要根据不同场景提供不同服务,是成本、效率和使用的考量结果,

数据遮盖要考虑原始表和脱敏后的表。原始数据一定要有一份,在这个基础上是另外复制出一张脱敏表还是在原始数据上做视觉脱敏,是两种不同成本的方案。另外复制一张表脱敏,是比较彻底的方式,但等于每张敏感数据表都要复制出来一份,对存储是个成本问题。而视觉脱敏,则是通过规则,动态的对数据展现进行脱敏,可以较低成本的实现脱敏效果,但存在被绕过的可能性。

2.2 隐私保护

隐私保护上学术界也提出了一些方法,包括K匿名、边匿名、差分隐私等方法,其目的是解决数据聚合情况下的隐私保护。例如有的公司,拿出来一部分去除敏感信息后的数据公开,进行算法比赛。这个时候就要考虑不同的数据聚合后,可以关联出某个人的个人标志。目前看到业界在生产上应用的是Google的DLP API,但其使用也较为复杂,针对场景比较单一。隐私保护的方法,关键是要能够进行大规模工程化,在大数据时代的背景下,这些还都是新课题,目前并不存在一个完整的方法来解决隐私保护所有对抗问题。

2.3 大数据资产地图

是指对大数据平台的数据资产进行分析、数据可视化展现的平台。最常见的诉求是,A部门申请B部门的数据,B作为数据的Owner,当然想知道数据给到A以后,他是怎么用的,有没有再传给其他人使用。这时候则需要有一个资产地图,能够跟踪数据资产的流向、使用情况。换个角度,对于安全部门来说,需要知道当前数据平台上有哪些高敏感数据资产,资产的使用情况,以及平台上哪些人拥有什么权限。因此,通过元数据、血缘关系、操作日志,形成了一个可视化的资产地图。形成地图并不够,延伸下来,还需要能够及时预警、回收权限等干预措施。

2.4 数据库扫描器

是指对大数据平台的数据扫描,其意义在于发现大数据平台上的敏感数据,从而进行对应的保护机制。一个大型互联网公司的数据表,每天可能直接产生多达几万张,通过这些表衍生出来更多的表。按照传统数据安全的定义,数据安全第一步是要分类分级,但这一步就很难进行下去。在海量存量表的情况下,该怎样进行分类分级?人工梳理显然是不现实的,梳理的速度还赶不上新增的速度。这时候就需要一些自动化的工具来对数据进行打标定级。因此,数据库扫描器可以通过正则表达式,发现一些基础的高敏感数据,例如手机号、银行卡等这些规整字段。对于非规整字段,则需要通过机器学习+人工标签的方法来确认。

综上,数据安全在业务发展到一定程度后,其重要性越发突出。微观层面的工具建设是一个支撑,在尽量减少对业务的打扰同时提高效率。宏观层面,除了自身体系内的数据安全,合作方、投资后的公司、物流、骑手、商家、外包等各类组织的数据安全情况,也会影响到自身安全,可谓“唇亡齿寒”。而在当前各类组织安全水平参差不齐的情况下,就要求已经发展起来的互联网公司承担更多的责任,帮助合作方提高安全水平,联防共建。

作者简介

鹏飞,美团点评集团安全部数据安全负责人,负责集团旗下全线业务的数据安全与隐私保护。

团队介绍

美团点评集团安全部汇集国内多名尖端安全专家及诸多优秀技术人才,坚持打造“专业、运营和服务”的理念,共同为集团全线业务的高速发展保驾护航。团队致力于构建一套基于海量 IDC 环境下横跨网络层、虚拟化层、Server 软件层(内核态/用户态)、语言执行虚拟机层(JVM/Zend/JavaScript V8)、Web应用层、数据访问层(DAL)的基于大数据+机器学习的全自动安全事件感知系统并努力打造内置式安全架构和纵深防御体系,借助广阔平台及良机,深度发展,注重企业安全建设方面的实践,向安全团队最佳发展方向努力前行。

安利个小广告

美团点评集团安全部正在招募Web&二进制攻防、后台&系统开发、机器学习&算法等各路小伙伴,对在安全和工程技术领域有所追求的同学来说应该是一个很好的机会。

如果你想加入我们,欢迎简历请发至邮箱zhaoyan17#meituan.com

具体职位信息可参考链接:FreeBuf招聘站

美团点评 SRC主页:美团点评安全应急响应中心

敬请关注我们的企业安全系列文章——面向实操的大型互联网安全解决方案

《从Google白皮书看企业安全最佳实践》

《互联网企业安全之端口监控》

Coming Soon

《个人信息保护关键点识别与思考》

《美团点评千亿量级WAF是如何打造的》

《海量IDC下的分布式入侵感知系统设计与实现》

《大型互联网安全体系成熟度度量》

如果对我们团队感兴趣,可以关注我们的专栏。

本文转载自: 掘金

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

Python爬虫:人人影视追剧脚本

发表于 2018-05-20
      • 抓包分析
        * [搜索页面](#搜索页面)
        * [影视资源页面](#影视资源页面)
        * [获取百度云,电驴等连接](#获取百度云电驴等连接)
        • 代码实现
          • 所需第三方库
          • 搜索页面
          • 解析搜索页链接
          • 获取下载页跳转链接
          • 获取下载链接
        • 结果

最近追剧时发现找资源挺繁琐的,网页切换来切换去耗时也多,所以就想写个脚本代替。于是今下午花了点时间,先对人人影视进行了资源爬取。

抓包分析

搜索页面

打开工作台,选择NETWORK,刷新之后,如图:
这里写图片描述

图中指出的就是搜索接口‘ http ://www.zimuzu.tv/search/index?keyword=西部世界&search\_type=resource ’ 就两参数:keywd和search_type(搜索类型)

影视资源页面

点进西部世界的资源面,我们接着要获取什么呢?当然是下载链接了。
这里写图片描述

没错,就是蓝色的资源下载页,再次抓包分析。

这里写图片描述
没错就是图中箭头所指的tv,这就是接口,

Request URL:www.zimuzu.tv/resource/in…
分析多个页面后,发现只有33701是变化的,它就在西部世界url中(www.zimuzu.tv/resource/33…)

看他的返回值,是一个类似json格式的数据,但不规范。如图:
这里写图片描述
把鼠标移到跳转下载链接上,发现是http ://zmz003.com/v5ta03 ,搜索一下 v5ta03 ,就能找到了。如图:

这里写图片描述

获取百度云,电驴等连接

接下来就简单了,静态页面,有点经验就OK的。如图:
这里写图片描述

代码实现

所需第三方库

1
2
3
4
复制代码import requests
from lxml import html
import re
import json

搜索页面

1
2
3
4
5
6
7
8
复制代码#获取搜索页面资源
def get_html(keywd,url):
param={'User-Agent':'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36 SE 2.X MetaSr 1.0',

}#cookie相带就带
Url=url%keywd
html=requests.get(Url,params=param).content.decode('utf8')
return html

解析搜索页链接

1
2
3
4
5
6
7
8
复制代码def get_movielink(text):
tree=html.fromstring(text)
ctree = tree.xpath('//div[@class="clearfix search-item"]')
link=[]
for item in ctree:
print(item.xpath('div[2]/div/a/strong/text()')[0],':',item.xpath('div[2]/div/a/@href')[0])
link.append(item.xpath('div[2]/div/a/@href')[0])
return link

获取下载页跳转链接

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码def get_downloadlink(link):
from_url='http://www.zimuzu.tv/resource/index_json/rid/%s/channel/tv'%link.split('/')[-1]
param = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36 SE 2.X MetaSr 1.0',
#‘cookie’可以有
'Referer':'http://www.zimuzu.tv%s'%link,
}
data=requests.get(from_url,params=param).content.decode('utf8')
data=''.join(data.split('=')[1:])
print(data)
# pattern='<h3><a href="(.*?)" target'
pattern='<h3><a href(.*?) target'
# print(re.findall(pattern,data)[0].replace('\\',''))
url=re.findall(pattern,data)[0].replace('\\','').replace('"','').strip()
return url #获取的跳转到百度云等下载资源页面链接

获取下载链接

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
复制代码def get_download(keywd,url):

#电驴在div[id="tab-g1-MP4"]/ul/li/ul/li[2]/a/@href下,磁力是第三个
#百度云在div[id="tab-g1-APP"]/ul/li/div/ul/li[2]/a/@href

if 'zmz' not in url: #资源页面还包含一种跳转到种子站的链接,如https://rarbg.is/torrents.php?searchBattlestar%20Galactica%3A%20Blood%20and%20Chrome%20
print('非下载页面:',url)
pass
param={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36 SE 2.X MetaSr 1.0',
}
res=requests.get(url,params=param).content.decode('utf-8')
tree=html.fromstring(res)
tree1=tree.xpath('//div[@class="tab-content info-content"]//div[@class="tab-content info-content"]')
if tree1:
downloadList=[]
for item in tree1:
ed2k=item.xpath('div[2]//ul[@class="down-links"]/li[2]/a/@href')#电驴
name=item.xpath('div[1]//div[@class="title"]/span[1]/text()')#name
bdy=item.xpath('div[1]//div[@class="title"]/ul/li[2]/a/@href')#百度云
for i,j,k in zip(name,bdy,ed2k):
downloadList.append(dict(name=i,bdy=j,ed2k=k))
with open(keywd+'.json','a+',encoding='utf-8')as f:
json.dump(downloadList,f) #这里保存为json文件

结果

大致就是这样的。
这里写图片描述

这里写图片描述

思路代码大致就这样,没做异常处理(因为我想追的剧没报错,o( ̄︶ ̄)o,可能有错,也没优化代码,你可以改善一下,比如异常处理,多线程多进程并发等

下次再找个时间,做一下自动添加百度云离线下载、或是fdm下载的脚本吧。

本文转载自: 掘金

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

1…891892893…956

开发者博客

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