0%

六出飞花入户时, 坐看青竹变琼枝

在 Web 应用中,很多时候应用的效率瓶颈会出现在『数据库系统』中,所以作为一个合格的 Web 开发工程师,我们要严格控制好我们的数据库开销。之前我们已经对一些不需要频繁修改的数据做了缓存,如『活跃用户』数据和『资源推荐』数据,以此减少数据库读取的压力。然而,在数据库中操作中,『写入』对数据库造成的压力,要远比『读取』压力高得多。

想要准确地跟踪用户的最后活跃时间,就必须在用户每一次请求服务器时都做记录,我们使用的主数据是 MySQL,也就是说每当用户访问一个页面,我们都将 MySQL 数据库里的 users 表写入数据。当我们有很多用户频繁访问站点时,这将会是数据库的一笔巨大开销。

我们可以使用 Redis 来记录用户的访问时间,Redis 运行在机器的内存上,读写效率都极快。不过为了保证数据的完整性,我们需要定期将 Redis 数据同步到数据库中,否则一旦 Redis 出问题或者执行了 Redis 清理操作,用户的『最后活跃时间』将会丢失。

基本思路如下:

  • 记录 - 通过中间件过滤用户所有请求,记录用户访问时间到 Redis 按日期区分的哈希表;
  • 同步 - 新建命令,计划任务每天运行一次此命令,将昨日哈希表里的数据同步到数据库中,并删除;
  • 读取 - 优先读取当日哈希表里 Redis 里的数据,无数据则使用数据库中的值。

记录时间

Laravel 中间件提供了一种方便的机制来过滤进入应用的 HTTP 请求。例如,Laravel 内置了一个中间件来验证用户的身份认证。如果用户没有通过身份认证,中间件会将用户重定向到登录界面。但是,如果用户被认证,中间件将允许该请求进一步进入该应用。这就是我们在控制器的构造方法中使用 auth 中间件:

1
2
3
4
public function __construct()
{
$this->middleware('auth', ['except' => ['index', 'show']]);
}

当然,除了身份认证以外,中间件还可以用来执行各种任务。例如:CORS中间件可以负责为所有离开应用的响应添加合适的头部信息;日志中间件可以记录所有传入应用的请求。Laravel 自带了一些中间件,包括身份验证、CSRF 保护等。所有这些中间件都位于 app/Http/Middleware 目录中。

Laravel 的中间件从执行时机上分『前置中间件』和『后置中间件』,前置中间件是应用初始化完成以后立刻执行,此时控制器路由还未分配、控制器还未执行、视图还未渲染。后置中间件是即将离开应用的响应,此时控制器已将渲染好的视图返回,我们可以在后置中间件里修改响应。两者的区别在于书写方式的不同:

  • 前置中间件:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    <?php

    namespace App\Http\Middleware;

    use Closure;

    class BeforeMiddleware
    {
    public function handle($request, Closure $next)
    {
    // 这是前置中间件,在还未进入 $next 之前调用

    return $next($request);
    }
    }
  • 后置中间件:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    <?php

    namespace App\Http\Middleware;

    use Closure;

    class AfterMiddleware
    {
    public function handle($request, Closure $next)
    {
    $response = $next($request);

    // 这是后置中间件,$next 已经执行完毕并返回响应 $response,
    // 我们可以在此处对响应进行修改。

    return $response;
    }
    }

    注意他们的区别在于 $next($request) 的执行位置,而非类的命名或者其他。

我们将选择『前置中间件』来实现用户登录时间的记录。

第一步. 创建中间件

运行以下命令,生成中间件类文件:

1
$ php artisan make:middleware RecordLastActivedTime

第二步. 注册中间件

想让中间件在应用的每个 HTTP 请求期间运行,我们还需要在 app/Http/Kernel.php类中对中间件进行注册。

为了方便大家理解,我将 Kernel.php 里的代码做了注释,同时在 Web 中间件组中注册了 RecordLastActivedTime 中间件,这样每一次 Web 请求都会运行我们的 RecordLastActivedTime 类里的 handle() 方法:

app/Http/Kernel.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
<?php

namespace App\Http;

use Illuminate\Foundation\Http\Kernel as HttpKernel;

class Kernel extends HttpKernel
{
// 全局中间件,最先调用
protected $middleware = [

// 检测是否应用是否进入『维护模式』
// 见:https://d.laravel-china.org/docs/5.5/configuration#maintenance-mode
\Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode::class,

// 检测请求的数据是否过大
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,

// 对提交的请求参数进行 PHP 函数 `trim()` 处理
\App\Http\Middleware\TrimStrings::class,

// 将提交请求参数中空子串转换为 null
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,

// 修正代理服务器后的服务器参数
\App\Http\Middleware\TrustProxies::class,
];

// 定义中间件组
protected $middlewareGroups = [

// Web 中间件组,应用于 routes/web.php 路由文件
'web' => [
// Cookie 加密解密
\App\Http\Middleware\EncryptCookies::class,

// 将 Cookie 添加到响应中
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,

// 开启会话
\Illuminate\Session\Middleware\StartSession::class,

// 认证用户,此中间件以后 Auth 类才能生效
// 见:https://d.laravel-china.org/docs/5.5/authentication
\Illuminate\Session\Middleware\AuthenticateSession::class,

// 将系统的错误数据注入到视图变量 $errors 中
\Illuminate\View\Middleware\ShareErrorsFromSession::class,

// 检验 CSRF ,防止跨站请求伪造的安全威胁
// 见:https://d.laravel-china.org/docs/5.5/csrf
\App\Http\Middleware\VerifyCsrfToken::class,

// 处理路由绑定
// 见:https://d.laravel-china.org/docs/5.5/routing#route-model-binding
\Illuminate\Routing\Middleware\SubstituteBindings::class,

// 记录用户最后活跃时间
\App\Http\Middleware\RecordLastActivedTime::class,
],

// API 中间件组,应用于 routes/api.php 路由文件
'api' => [
// 使用别名来调用中间件
// 请见:https://d.laravel-china.org/docs/5.5/middleware#为路由分配中间件
'throttle:60,1',
'bindings',
],
];

// 中间件别名设置,允许你使用别名调用中间件,例如上面的 api 中间件组调用
protected $routeMiddleware = [

// 只有登录用户才能访问,我们在控制器的构造方法中大量使用
'auth' => \Illuminate\Auth\Middleware\Authenticate::class,

// HTTP Basic Auth 认证
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,

// 处理路由绑定
// 见:https://d.laravel-china.org/docs/5.5/routing#route-model-binding
'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class,

// 用户授权功能
'can' => \Illuminate\Auth\Middleware\Authorize::class,

// 只有游客才能访问,在 register 和 login 请求中使用,只有未登录用户才能访问这些页面
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,

// 访问节流,类似于 『1 分钟只能请求 10 次』的需求,一般在 API 中使用
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
];
}

第三步. 书写中间件类

app/Http/Middleware/RecordLastActivedTime.php

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

namespace App\Http\Middleware;

use Closure;
use Auth;

class RecordLastActivedTime
{
public function handle($request, Closure $next)
{
// 如果是登录用户的话
if (Auth::check()) {
// 记录最后登录时间
Auth::user()->recordLastActivedAt();
}

return $next($request);
}
}

我们将业务逻辑封装与 User 类中,事实上,我们要将记录和读取的相关逻辑放置于单独的 Trait 中:

app/Models/Traits/LastActivedAtHelper.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<?php

namespace App\Models\Traits;

use Redis;
use Carbon\Carbon;

trait LastActivedAtHelper
{
// 缓存相关
protected $hash_prefix = 'larabbs_last_actived_at_';
protected $field_prefix = 'user_';

public function recordLastActivedAt()
{
// 获取今天的日期
$date = Carbon::now()->toDateString();

// Redis 哈希表的命名,如:larabbs_last_actived_at_2017-10-21
$hash = $this->hash_prefix . $date;

// 字段名称,如:user_1
$field = $this->field_prefix . $this->id;

// 当前时间,如:2017-10-21 08:35:15
$now = Carbon::now()->toDateTimeString();

// 数据写入 Redis ,字段已存在会被更新
Redis::hSet($hash, $field, $now);
}
}

然后在 User 模型引用:

app/Models/User.php

1
2
3
4
5
6
7
8
9
10
11
12
<?php
.
.
.
class User extends Authenticatable
{
use Traits\LastActivedAtHelper;

.
.
.
}

测试一下

如果我们的代码可用的话,当我们处于登录状态时,每一次访问网站都会将当前的时间,记录在 Redis 的哈希表里。
接下来修改 recordLastActivedAt 方法,在得到哈希表名称后,使用 hGetAll 获取哈希表里的全部数据,并使用 Laravel 自带的调试方法 dd() 打印出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public function recordLastActivedAt()
{
// 获取今天的日期
$date = Carbon::now()->toDateString();

// Redis 哈希表的命名,如:larabbs_last_actived_at_2017-10-21
$hash = $this->hash_prefix . $date;

// 字段名称,如:user_1
$field = $this->field_prefix . $this->id;

dd(Redis::hGetAll($hash));

// 当前时间,如:2017-10-21 08:35:15
$now = Carbon::now()->toDateTimeString();

// 数据写入 Redis ,字段已存在会被更新
Redis::hSet($hash, $field, $now);
}

刷新页面即可看到,我们已经能正常记录用户的访问时间
测试完成后删除 dd(Redis::hGetAll($hash)); 调用。

同步到数据库

接下来把记录下来的时间同步到数据库中。我们将设置一个 24 小时运行一次的计划任务,此任务负责将 Redis 中用户最后登录时间同步到数据库中。

第一步. 数据库添加字段

首先我们需要在 users 表中新建字段,用以存储

1
$ php artisan make:migration add_last_actived_at_to_users_table --table=users

代码迁移内容:

database/migrations/timestamp_add_last_actived_at_to_users_table.php

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

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class AddLastActivedAtToUsersTable extends Migration
{
public function up()
{
Schema::table('users', function (Blueprint $table) {
$table->timestamp('last_actived_at')->nullable();
});
}

public function down()
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('last_actived_at');
});
}
}

生效到数据库中:

1
$ php artisan migrate

第二步. 新建 Artisan 命令

我们需要新建命令,以供计划任务调用:

1
$ php artisan make:command SyncUserActivedAt --command=larabbs:sync-user-actived-at

编辑命令类:

app/Console/Commands/SyncUserActivedAt.php

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

namespace App\Console\Commands;

use Illuminate\Console\Command;
use App\Models\User;

class SyncUserActivedAt extends Command
{
protected $signature = 'larabbs:sync-user-actived-at';
protected $description = '将用户最后登录时间从 Redis 同步到数据库中';

public function handle(User $user)
{
$user->syncUserActivedAt();
$this->info("同步成功!");
}
}

为方便代码管理,我们将具体的业务代码放置于 LastActivedAtHelper Trait 中:

app/Models/Traits/LastActivedAtHelper.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<?php
.
.
.

trait LastActivedAtHelper
{
.
.
.

public function syncUserActivedAt()
{
// 获取昨天的日期,格式如:2017-10-21
$yesterday_date = Carbon::yesterday()->toDateString();

// Redis 哈希表的命名,如:larabbs_last_actived_at_2017-10-21
$hash = $this->hash_prefix . $yesterday_date;

// 从 Redis 中获取所有哈希表里的数据
$dates = Redis::hGetAll($hash);

// 遍历,并同步到数据库中
foreach ($dates as $user_id => $actived_at) {
// 会将 `user_1` 转换为 1
$user_id = str_replace($this->field_prefix, '', $user_id);

// 只有当用户存在时才更新到数据库中
if ($user = $this->find($user_id)) {
$user->last_actived_at = $actived_at;
$user->save();
}
}

// 以数据库为中心的存储,既已同步,即可删除
Redis::del($hash);
}
}

第三步. 测试 Artisan 命令

接下来我们需测试下代码,已确保 Redis 中的数据能正常同步到数据库中。在开始之前,我们获取的是昨日的数据,不方便测试,我们需将以下这一行代码:

1
$yesterday_date = Carbon::yesterday()->toDateString();

临时修改为今天的日期,这样就能将我们的上一个测试制造的数据获取到:

1
$yesterday_date = Carbon::now()->toDateString();

修改完成后,命令行执行:

1
$ php artisan larabbs:sync-user-actived-at

第四步. 任务调度

我们还需要在每天零时 00:00 自动对数据进行同步。Laravel 任务调度可以轻松帮我们实现此功能。

在前面开发活跃用户章节中我们已经做了 Cron 设置,此处我们只需在 Kernel.php 的 schedule() 方法中新增任务调度即可:

app/Console/Kernel.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php
.
.
.
class Kernel extends ConsoleKernel
{
.
.
.
protected function schedule(Schedule $schedule)
{
// $schedule->command('inspire')
// ->hourly();
// 每隔一个小时执行一遍
$schedule->command('larabbs:calculate-active-user')->hourly();
// 每日零时执行一次
$schedule->command('larabbs:sync-user-actived-at')->dailyAt('00:00');
}
.
.
.
}

数据读取

接下来我们要将记录下来的数据在用户的个人空间里显示出来。

第一步. 配置访问器

我们将使用 Eloquent 的 访问器 来实现此功能。当我们从实例中获取某些属性值的时候,访问器允许我们对 Eloquent 属性值进行动态修改。

访问器的命名规范与修改器类似,只是将 set 换成 get 而已:

app/Models/Traits/LastActivedAtHelper.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<?php
.
.
.

trait LastActivedAtHelper
{
.
.
.

public function getLastActivedAtAttribute($value)
{
// 获取今天的日期
$date = Carbon::now()->toDateString();

// Redis 哈希表的命名,如:larabbs_last_actived_at_2017-10-21
$hash = $this->hash_prefix . $date;

// 字段名称,如:user_1
$field = $this->field_prefix . $this->id;

// 三元运算符,优先选择 Redis 的数据,否则使用数据库中
$datetime = Redis::hGet($hash, $field) ? : $value;

// 如果存在的话,返回时间对应的 Carbon 实体
if ($datetime) {
return new Carbon($datetime);
} else {
// 否则使用用户注册时间
return $this->created_at;
}
}
}

此时意识到一个问题,我们一直在重复哈希表和哈希字段的命名代码,为了提高代码的可维护性,我们需将其抽出为可复用的方法 getHashFromDateString 和 getHashField ,并重构整个 LastActivedAtHelper 类:

app/Models/Traits/LastActivedAtHelper.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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
<?php

namespace App\Models\Traits;

use Redis;
use Carbon\Carbon;

trait LastActivedAtHelper
{
// 缓存相关
protected $hash_prefix = 'larabbs_last_actived_at_';
protected $field_prefix = 'user_';

public function recordLastActivedAt()
{
// 获取今日 Redis 哈希表名称,如:larabbs_last_actived_at_2017-10-21
$hash = $this->getHashFromDateString(Carbon::now()->toDateString());

// 字段名称,如:user_1
$field = $this->getHashField();

// 当前时间,如:2017-10-21 08:35:15
$now = Carbon::now()->toDateTimeString();

// 数据写入 Redis ,字段已存在会被更新
Redis::hSet($hash, $field, $now);
}

public function syncUserActivedAt()
{
// 获取昨日的哈希表名称,如:larabbs_last_actived_at_2017-10-21
$hash = $this->getHashFromDateString(Carbon::yesterday()->toDateString());

// 从 Redis 中获取所有哈希表里的数据
$dates = Redis::hGetAll($hash);

// 遍历,并同步到数据库中
foreach ($dates as $user_id => $actived_at) {
// 会将 `user_1` 转换为 1
$user_id = str_replace($this->field_prefix, '', $user_id);

// 只有当用户存在时才更新到数据库中
if ($user = $this->find($user_id)) {
$user->last_actived_at = $actived_at;
$user->save();
}
}

// 以数据库为中心的存储,既已同步,即可删除
Redis::del($hash);
}

public function getLastActivedAtAttribute($value)
{
// 获取今日对应的哈希表名称
$hash = $this->getHashFromDateString(Carbon::now()->toDateString());

// 字段名称,如:user_1
$field = $this->getHashField();

// 三元运算符,优先选择 Redis 的数据,否则使用数据库中
$datetime = Redis::hGet($hash, $field) ? : $value;

// 如果存在的话,返回时间对应的 Carbon 实体
if ($datetime) {
return new Carbon($datetime);
} else {
// 否则使用用户注册时间
return $this->created_at;
}
}

public function getHashFromDateString($date)
{
// Redis 哈希表的命名,如:larabbs_last_actived_at_2017-10-21
return $this->hash_prefix . $date;
}

public function getHashField()
{
// 字段名称,如:user_1
return $this->field_prefix . $this->id;
}
}

第二步. 页面显示

在 『注册于』 区块下新增 『最后活跃』,因返回的值为 Carbon 实体,故我们可用 diffForHumans() 来输出用户友好的时间:

resources/views/users/show.blade.php

1
2
3
4
5
<h4><strong>注册于</strong></h4>
<p>{{ $user->created_at->diffForHumans() }}</p>
<hr>
<h4><strong>最后活跃</strong></h4>
<p title="{{ $user->last_actived_at }}">{{ $user->last_actived_at->diffForHumans() }}</p>

倚楼听风雨,淡看江湖路

什么叫“多任务”呢?简单地说,就是操作系统可以同时运行多个任务。打个比方,你一边在用浏览器上网,一边在听MP3,一边在用Word赶作业,这就是多任务,至少同时有3个任务正在运行。还有很多任务悄悄地在后台同时运行着,只是桌面上没有显示而已。

现在,多核CPU已经非常普及了,但是,即使过去的单核CPU,也可以执行多任务。由于CPU执行代码都是顺序执行的,那么,单核CPU是怎么执行多任务的呢?

答案就是操作系统轮流让各个任务交替执行,任务1执行0.01秒,切换到任务2,任务2执行0.01秒,再切换到任务3,执行0.01秒……这样反复执行下去。表面上看,每个任务都是交替执行的,但是,由于CPU的执行速度实在是太快了,我们感觉就像所有任务都在同时执行一样。

真正的并行执行多任务只能在多核CPU上实现,但是,由于任务数量远远多于CPU的核心数量,所以,操作系统也会自动把很多任务轮流调度到每个核心上执行。

对于操作系统来说,一个任务就是一个进程(Process),比如打开一个浏览器就是启动一个浏览器进程,打开一个记事本就启动了一个记事本进程,打开两个记事本就启动了两个记事本进程,打开一个Word就启动了一个Word进程。

有些进程还不止同时干一件事,比如Word,它可以同时进行打字、拼写检查、打印等事情。在一个进程内部,要同时干多件事,就需要同时运行多个“子任务”,我们把进程内的这些“子任务”称为线程(Thread)。

由于每个进程至少要干一件事,所以,一个进程至少有一个线程。当然,像Word这种复杂的进程可以有多个线程,多个线程可以同时执行,多线程的执行方式和多进程是一样的,也是由操作系统在多个线程之间快速切换,让每个线程都短暂地交替运行,看起来就像同时执行一样。当然,真正地同时执行多线程需要多核CPU才可能实现。

https://www.liaoxuefeng.com/wiki/0014316089557264a6b348958f449949df42a6d3a2e542c000/0014319272686365ec7ceaeca33428c914edf8f70cca383000

药炉烟里,支枕听河流。

哈希函数

  • 第一种解释
    哈希函数就是能将任意长度的数据映射为固定长度的数据的函数。哈希函数返回的值被叫做哈希值、哈希码、散列,或者直接叫做哈希。一个使用场景就是哈希表,哈希表被广泛用于快速搜索数据。

  • 另一种解释
    在记录的关键字与记录的存储地址之间建立的一种对应关系叫哈希函数。哈希函数就是一种映射,是从关键字到存储地址的映射。
    通常,包含哈希函数的算法的算法复杂度都假设为O(1),这就是为什么在哈希表中搜索数据的时间复杂度会被认为是”平均为O(1)的复杂度”.

常用的加密哈希算法

  • MD5
  • SHA-1
  • SHA-2
  • RIPEMD-160

哈希表

哈希表是一种通过哈希函数,将特定的键映射到特定值的一种数据结构,它维护键和值之间一一对应关系。

辅助理解

解释一

比如这里有一万首歌,给你一首新的歌X,要求你确认这首歌是否在那一万首歌之内。无疑,将一万首歌一个一个比对非常慢。但如果存在一种方式,能将一万首歌的每首数据浓缩到一个数字(称为哈希码)中,于是得到一万个数字,那么用同样的算法计算新的歌X的编码,看看歌X的编码是否在之前那一万个数字中,就能知道歌X是否在那一万首歌中。作为例子,如果要你组织那一万首歌,一个简单的哈希算法就是让歌曲所占硬盘的字节数作为哈希码。这样的话,你可以让一万首歌“按照大小排序”,然后遇到一首新的歌,只要看看新的歌的字节数是否和已有的一万首歌中的某一首的字节数相同,就知道新的歌是否在那一万首歌之内了。当然这个简单的哈希算法很容易出现两者同样大小的歌曲,这就是发送了碰撞。而好的哈希算法发生碰撞的几率非常小。

解释二

HASH算法是密码学的基础,比较常用的有MD5和SHA,最重要的两条性质,就是不可逆和无冲突。

  • 不可逆,就是当你知道x的HASH值,无法求出x;
  • 无冲突,就是当你知道x,无法求出一个y, 使x与y的HASH值相同。

这两条性质在数学上都是不成立的。因为一个函数必然可逆,且由于HASH函数的值域有限,理论上会有无穷多个不同的原始值,它们的hash值都相同。MD5和SHA做到的,是求逆和求冲突在计算上不可能,也就是正向计算很容易,而反向计算即使穷尽人类所有的计算资源都做不到。

PHP中的hash

Hash Table是PHP的核心,这话一点都不过分.PHP的数组,关联数组,对象属性,函数表,符号表,等等都是用HashTable来做为容器的.PHP的HashTable采用的拉链法来解决冲突,

插入65536个经过构造的键值的元素到PHP数组, 会需要耗时30秒以上? 而一般的这个过程仅仅需要0.1秒..

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php
$size = pow(2, 16);

$startTime = microtime(true);
$array = array();
for ($key = 0, $maxKey = ($size - 1) * $size; $key <= $maxKey; $key += $size) {
$array[$key] = 0;
}
$endTime = microtime(true);
echo '插入 ', $size, ' 个恶意的元素需要 ', $endTime - $startTime, ' 秒', "\n";

$startTime = microtime(true);
$array = array();
for ($key = 0, $maxKey = $size - 1; $key <= $maxKey; ++$key) {
$array[$key] = 0;
}
$endTime = microtime(true);
echo '插入 ', $size, ' 个普通元素需要 ', $endTime - $startTime, ' 秒', "\n";

在我的机器上

1
2
插入 65536 个恶意的元素需要 10.187582969666
插入 65536 个普通元素需要 0.003000020980835

经过特殊构造的键值, 使得PHP每一次插入都会造成Hash冲突, 从而使得PHP中array的底层Hash表退化成链表

https://www.zhihu.com/question/20820286
http://www.laruence.com/2009/07/23/994.html
http://www.laruence.com/2011/12/30/2435.html

柔情似水,佳期如梦,忍顾鹊桥归路! 两情若是久长时,又岂在、朝朝暮暮!

缓存穿透

出现场景

查询一个一定不存在的数据,由于缓存是不命中时被动写的,并且出于容错考虑,如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。当在流量较大时,出现这样的情况,一直请求DB,很容易导致服务挂掉。

处理方法

  • 在封装的缓存SET和GET部分增加个步骤,如果查询一个KEY不存在,就已这个KEY为前缀设定一个标识KEY;以后再查询该KEY的时候,先查询标识KEY,如果标识KEY存在,就返回一个协定好的非false或者NULL值,然后APP做相应的处理,这样缓存层就不会被穿透。当然这个验证KEY的失效时间不能太长。
  • 如果一个查询返回的数据为空(不管是数据不存在,还是系统故障),我们仍然把这个空结果进行缓存,但它的过期时间会很短,一般只有几分钟。
  • 采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被这个bitmap拦截掉,从而避免了对底层存储系统的查询压力。

缓存雪崩

出现场景

引起这个原因的主要因素是高并发下,我们一般设定一个缓存的过期时间时,可能有一些会设置5分钟啊,10分钟这些;并发很高时可能会出在某一个时间同时生成了很多的缓存,并且过期时间在同一时刻,这个时候就可能引发——当过期时间到后,这些缓存同时失效,请求全部转发到DB,DB可能会压力过重。

处理方法

一个简单方案就是将缓存失效时间分散开,不要所以缓存时间长度都设置成5分钟或者10分钟;比如我们可以在原有的失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。 缓存失效时产生的雪崩效应,将所有请求全部放在数据库上,这样很容易就达到数据库的瓶颈,导致服务无法正常提供。尽量避免这种场景的发生。

https://zhuanlan.zhihu.com/p/35060009?group_id=962606647398658048

何时杖尔看南雪, 我与梅花两白头

max_execution_time

1
max_execution_time = 30

影响脚本本身执行的时间, 默认值是30S, 在CLI命令行被硬编码为0, 即没有执行时间的限制, 函数set_time_limit(),在相关脚本执行之前执行该函数可以改变这个系统设置, 如果被设置成

1
set_time_limit(0)

则表示脚本不受执行时间的限制

sql.safe_mode

1
sql.safe_mode = Off

如果打开,指定默认值的数据库连接函数将使用这些值代替提供的参数。也就是说像mysql_connect()mysql_pconnect()就忽视传送给它们的任何变量,第三方开源应用程序(如WordPress)及其他应用程序可能根本运行不了。

post_max_size

1
post_max_size=10240K

限制PHP将处理的POST请求的最大大小

upload_max_filesize

1
upload_max_filesize=1M

该设置限制了PHP允许通过上传的文件的最大值

max_input_vars

1
max_input_vars = 1000

提交表单字段数量的最大值

allow_url_fopen

1
allow_url_fopen = On

如果启用则允许PHP的文件函数——如file_get_contents()include语句和require语句——可以从远程地方(如ftp或网站)获取数据。

allow_url_include

1
allow_url_include = off

出于安全原因,建议禁用, 不过PHP 已经将改配置默认禁用, 如果开启, 就有可能造成远程文件执行漏洞, 像下面的代码, 虽然是txt文件, 但是里面包含了php代码, 服务器还是会按照PHP文件执行.

1
include 'http://yangzie.qiniudn.com/test.txt';

memory_limit

1
memory_limit = 128M

即限制 PHP 进程对于内存的使用, 可以通过下面的代码对此配置进行更改, memory_limit 主要是为了防止程序 bug, 或者死循环占用大量的内存,导致系统宕机。在引入大量三方插件,或者代码时,进行内存限制就非常有必要了。

1
2
3
ini_set("memory_limit", "128M");
memory_get_usage(); // 获取PHP脚本所用的内存大小
memory_get_peak_usage(); //返回当前脚本到目前位置所占用的内存峰值。

我自己说不出口的,总希望别人能问一问。

相关配置

  • 根配置文件_config.yml

    1
    filename_case: 1  # url case
  • git配置文件
    进入到博客项目中 .deploy_git文件夹 .git 下的 config 文件

    1
    ignorecase=false

后续操作

删除博客项目中 .deploy_git 文件夹下的所有文件,并 push 到 Github 上, 这一步是清空你的 github.io 项目中所有文件。

1
2
3
git rm -rf *
git commit -m 'clean all file'
git push

使用 Hexo 再次生成及部署

1
2
3
4
cd ..
hexo clean
hexo deploy -generate

我发现,你在我心中的位置会膨胀。

小程序端获取code

1
2
3
4
5
6
wx.login({
success: function (login_res) {
if (login_res.code) {
console.log("微信登录请求返回code:" + login_res.code);

......

带上code访问服务器接口

服务器接口根据code获取Token

路由设计

1
Route::post('api/:version/token/user', 'api/:version.Tokens/getToken');

控制器方法

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

namespace app\api\controller\v1;

use app\api\service\UserToken;
use app\api\validate\TokenGet;

class Tokens
{
public function getToken($code)
{
(new TokenGet())->goCheck(); // code 格式验证
$ut = new UserToken($code);
$token = $ut->get();
return ['token' => $token];
}
}

Service服务方法
tp5\application\api\service\UserToken.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
<?php

namespace app\api\service;

use app\api\model\User;
use app\lib\enum\ScopeEnum;
use app\lib\exception\TokenException;
use app\lib\exception\WeChatException;
use think\Exception;

class UserToken extends Token
{

protected $code;
protected $wxLoginUrl;
protected $wxAppID;
protected $wxAppSecret;

/**
* 初始化接口
* @param $code
*/
public function __construct($code)
{
$this->code = $code;
$this->wxAppID = config('wx.app_id');
$this->wxAppSecret = config('wx.app_secret');
$this->wxLoginUrl = sprintf(config('wx.login_url'), $this->wxAppID, $this->wxAppSecret, $this->code);
}

/**
* 请求微信接口, 返回openid和sessionKey
*
* @return string
* @throws Exception
*/
public function get()
{
$res = curl_get($this->wxLoginUrl);
$wxResult = json_decode($res, true);
if (empty($wxResult)) {
throw new Exception('微信内部错误');
} else {
$loginFail = array_key_exists('errcode', $wxResult);
if ($loginFail) {
$this->processLoginError($wxResult);
} else {
return $this->grantToken($wxResult);
}
}
}


/**
* 使用 openid 作为区分用户的主键, 有则返回, 无责创建用户后再返回
*
* @param $wxResult
* @return string
*/
private function grantToken($wxResult)
{
$openid = $wxResult['openid'];
$user = User::getByOpenID($openid);
if (!$user) {
$uid = $this->newUser($openid);
} else {
$uid = $user->id;
}

$cachedValue = $this->prepareCachedValue($wxResult, $uid);
$token = $this->saveToCache($cachedValue);
return $token;
}

/**
* 生成一个唯一的token作为key, 使用 @prepareCachedValue() 函数生成的值作为 值,生成缓存,存储在redis中
* 并设置一个过期时间, 我们可以参照微信的接口过期时间, 7200s, 也就是2个小时
*
* @param $wxResult
* @return string
* @throws TokenException
*/
private function saveToCache($wxResult)
{
$key = self::generateToken();
$value = json_encode($wxResult);
$expire_in = config('setting.token_expire_in');
$result = cache($key, $value, $expire_in);

if (!$result){
throw new TokenException(['msg' => '服务器缓存异常', 'errorCode' => 10005]);
}
return $key;
}

/**
* 将微信接口返回的数据, 用户ID, 用户权限 作为 缓存的值
*
* @param $wxResult
* @param $uid
* @return mixed
*/
private function prepareCachedValue($wxResult, $uid)
{
$cachedValue = $wxResult;
$cachedValue['uid'] = $uid;
$cachedValue['scope'] = ScopeEnum::User;
return $cachedValue;
}

/**
* 使用 openid 在 user表中创建一条新的记录, 并返回Id
*
* @param $openid
* @return mixed
*
*/
private function newUser($openid)
{
$user = User::create(['openid' => $openid]);
return $user->id;
}


/**
* 微信登录错误抛出异常
*
* @param $wxResult
* @throws WeChatException
*/
private function processLoginError($wxResult)
{
throw new WeChatException(
[
'msg' => $wxResult['errmsg'],
'errorCode' => $wxResult['errcode']
]);
}
}

生成token函数

1
2
3
4
5
6
7
8
 // 生成令牌
public static function generateToken()
{
$randChar = getRandChar(32);
$timestamp = $_SERVER['REQUEST_TIME_FLOAT'];
$tokenSalt = config('secure.token_salt');
return md5($randChar . $timestamp . $tokenSalt);
}

接口返回

接口正确返回token, 我们需要存储在客户端,以后的每次请求, 都需要在header头中带上token信息

1
2
3
4
wx.setStorage({
key:"token",
data:"value"
})

请求新的接口

  • 接口地址
    http://tp5.me/api/v1/order
  • 请求方式
    POST
  • 请求参数
    header头信息
    1
    {"token" : "b19d0f4e05a6f3898d52b5d181a867da"}
    body参数信息
    1
    2
    3
    4
    5
    6
    {
    "products": [
    {"product_id": 1, "count": 1},
    {"product_id": 2, "count": 1}
    ]
    }

接口权限验证

我们使用ThinkPHP 5 的前置方法在下单前检测用户的身份权限
tp5\application\api\controller\v1\Orders.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Orders extends BaseController
{
protected $beforeActionList = [
'checkExclusiveScope' => ['only' => 'placeOrder']
];

/**
* 下单
* @url /order
* @HTTP POST
*/
public function placeOrder()
{
(new OrderPlace())->goCheck();
$products = input('post.products/a');
$uid = Token::getCurrentUid();
$order = new OrderService();
$status = $order->place($uid, $products);
return $status;
}

tp5\application\api\controller\BaseController.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<?php

namespace app\api\controller;

use app\api\service\Token;
use think\Controller;

class BaseController extends Controller
{
/**
* 用户专有权限检测
*/
protected function checkExclusiveScope()
{
Token::needExclusiveScope();
}

/**
* 最基本权限, token是否有效检测
*/
protected function checkPrimaryScope()
{
Token::needPrimaryScope();
}

/**
* 超级管理员权限检测
*/
protected function checkSuperScope()
{
Token::needSuperScope();
}
}

tp5\application\api\service\Token.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
<?php

namespace app\api\service;

use app\lib\enum\ScopeEnum;
use app\lib\exception\ForbiddenException;
use app\lib\exception\ParameterException;
use app\lib\exception\TokenException;
use think\Cache;
use think\Exception;
use think\Request;

class Token
{

/**
* 生成令牌, 发送给客户端
*
* @return string
*/
public static function generateToken()
{
$randChar = getRandChar(32);
$timestamp = $_SERVER['REQUEST_TIME_FLOAT'];
$tokenSalt = config('secure.token_salt');
return md5($randChar . $timestamp . $tokenSalt);
}

/**
* 根据请求header 头的 token值,去缓存中寻找对应的记录
*
* @param $key
* @return mixed
* @throws Exception
* @throws TokenException
*/
public static function getCurrentTokenVar($key)
{
$token = Request::instance()->header('token');

$vars = Cache::get($token);

if (!$vars) {
throw new TokenException();
} else {
if(!is_array($vars)) {
$vars = json_decode($vars, true);
}

if (array_key_exists($key, $vars)) {
return $vars[$key];
} else{
throw new Exception('尝试获取的Token变量并不存在');
}
}
}

/**
* 在缓存中根据token取出当前用户的id
*
* @return mixed
* @throws ParameterException
*/
public static function getCurrentUid()
{
$uid = self::getCurrentTokenVar('uid');

// 获取权限
$scope = self::getCurrentTokenVar('scope');

if ($scope == ScopeEnum::Super) {
// 只有Super权限才可以自己传入uid
// 且必须在get参数中,post不接受任何uid字段
$userID = input('get.uid');
if (!$userID) {
throw new ParameterException(
[
'msg' => '没有指定需要操作的用户对象'
]);
}
return $userID;
}
else {
return $uid;
}
}


/**
* 验证基本权限
*
* @return bool
* @throws ForbiddenException
* @throws TokenException
*/
public static function needPrimaryScope()
{
$scope = self::getCurrentTokenVar('scope');
if ($scope) {
if ($scope >= ScopeEnum::User) {
return true;
}
else{
throw new ForbiddenException();
}
} else {
throw new TokenException();
}
}

/**
* 用户专有权限
*
* @return bool
* @throws ForbiddenException
* @throws TokenException
*/
public static function needExclusiveScope()
{
$scope = self::getCurrentTokenVar('scope');
if ($scope ){
if ($scope == ScopeEnum::User) {
return true;
} else {
throw new ForbiddenException();
}
} else {
throw new TokenException();
}
}

/**
* 管理员权限
*
* @return bool
* @throws ForbiddenException
* @throws TokenException
*/
public static function needSuperScope()
{
$scope = self::getCurrentTokenVar('scope');
if ($scope){
if ($scope == ScopeEnum::Super) {
return true;
} else {
throw new ForbiddenException();
}
} else {
throw new TokenException();
}
}

}

就算人生终有一散,也别辜负相遇

异常的分类

  • 用户行为异常
    提交了错误的参数
    没有查询到结果
    通常不需要记录日志, 需要给用户返回具体的错误信息, 而且一般情况下这些异常是可以预见的;

  • 系统异常
    代码自己的错误
    需要记录日志, 不应该向客户返回具体的信息,一般无法预见

自定义异常捕捉类

修改配置文件tp5\application\config.php

1
'exception_handle'       => 'app\lib\exception\ExceptionHandler'

tp5\application\lib\exception\ExceptionHandler.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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
<?php
namespace app\lib\exception;

use think\exception\Handle;
use think\Log;
use think\Request;

class ExceptionHandler extends Handle
{
public $code;

public $msg;

public $errorCode;

public function render(\Exception $e)
{
if ($e instanceof BaseException) {
// 自定义异常
$this->code = $e->code;
$this->msg = $e->msg;
$this->errorCode = $e->errorCode;
} else {
// 调试环境使用tp自己的异常类, 便于调试
if (config('app_debug')) {
return parent::render($e);
}
$this->code = 500;
$this->msg = '服务器内部错误';
$this->errorCode = 999;
// 异常
$this->recordErrorLog($e);
}

$request = Request::instance();

$res = [
'msg' => $this->msg,
'error_code' => $this->errorCode,
'request_url' => $request->url()
];

// 以 json 的结构返回异常
return json($res, $this->code);

}

/*
* 将异常写入日志
*/
private function recordErrorLog(\Exception $e)
{
Log::init([
'type' => 'File',
'path' => LOG_PATH,
'level' => ['error']
]);

Log::record($e->getMessage(),'error');
}
}

控制器

在结果为空的时候抛出一个自定义的异常
tp5\application\api\controller\v1\Products.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use app\lib\exception\ProductException;

public function getRecent($count = 15)
{
(new Count())->goCheck();

$res = Product::getMostRecent($count);

if ($res->isEmpty()) {
throw new ProductException();
}

$res = $res->hidden(['summary']);

return $res;
}

异常基类

tp5\application\lib\exception\BaseException.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<?php

namespace app\lib\exception;

use think\Exception;

class BaseException extends Exception
{
public $code = 500;

public $msg = '请求错误';

public $errorCode = 10000;

public function __construct($params = [])
{
if(!is_array($params)) {
return;
}
if(array_key_exists('code',$params)) {
$this->code = $params['code'];
}
if(array_key_exists('msg',$params)) {
$this->msg = $params['msg'];
}
if(array_key_exists('errorCode',$params)) {
$this->errorCode = $params['errorCode'];
}
}
}

自定义异常类

覆盖基类的几个基本参数, 实现不同的异常返回不同的参数

1
2
3
4
5
6
7
8
9
10
<?php

namespace app\lib\exception;

class ProductException extends BaseException
{
public $code = 404;
public $msg = '指定商品不存在,请检查商品ID';
public $errorCode = 20000;
}

人活在世上,无非是面对两大世界,身外的大千世界和自己的内心世界。

一般的验证方式

新建验证文件
tp5\application\api\validate\TestValidate.php

1
2
3
4
5
6
7
8
9
10
11
namespace app\api\validate;

use think\Validate;

class TestValidate extends Validate
{
protected $rule = [
'name' => 'require|max:10',
'email' => 'email'
];
}

控制器中调用验证

1
2
3
4
5
6
7
8
9
10
11
12
13
public function getBanner()
{
$data = [
'name' => 'yangzie',
'email' => 'yangzie@@.com'
];

$validate = new TestValidate();

$result = $validate->batch()->check($data); // true

var_dump($validate->getError());
}

控制器调用

这里需要验证传入的ID必须是一个正整数;
tp5\application\api\controller\v1\Products.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
use app\api\validate\IDMustBePositiveInt;

public function getAllInCategory($id)
{
(new IDMustBePositiveInt())->goCheck();

$res = Product::getProductsByCategoryID($id);

if ($res->isEmpty()) {
throw new ProductException();
}

return $res;
}

验证基类

这里包含了所有验证类需要调用的一些公共方法
tp5\application\api\validate\BaseValidate.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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
<?php

namespace app\api\validate;

use app\lib\exception\ParameterException;
use think\Request;
use think\Validate;

class BaseValidate extends Validate
{
public function goCheck()
{
$request = Request::instance();
$params = $request->param();

if (!$this->batch()->check($params)) {

$e = new ParameterException([
'msg' => is_array($this->error) ? implode(';', $this->error) : $this->error,
]);

throw $e;
}
return true;
}

protected function isPositiveInteger($value, $rule='', $data='', $field='')
{
if (is_numeric($value) && is_int($value + 0) && ($value + 0) > 0) {
return true;
}
return false;
}

protected function isNotEmpty($value, $rule='', $data='', $field='')
{
if (empty($value)) {
return $field . '不允许为空';
} else {
return true;
}
}

public function getDataByRule($arrays)
{
if (array_key_exists('user_id', $arrays) | array_key_exists('uid', $arrays)) {
// 不允许包含user_id或者uid,防止恶意覆盖user_id外键
throw new ParameterException([
'msg' => '参数中包含有非法的参数名user_id或者uid'
]);
}

$newArray = [];
foreach ($this->rule as $key => $value) {
$newArray[$key] = $arrays[$key];
}
return $newArray;
}

protected function isMobile($value)
{
$rule = '^1(3|4|5|7|8)[0-9]\d{8}$^';
$result = preg_match($rule, $value);
if ($result) {
return true;
} else {
return false;
}
}
}

ID 验证类

tp5\application\api\validate\IDMustBePositiveInt.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php

namespace app\api\validate;

class IDMustBePositiveInt extends BaseValidate
{
protected $rule = [
'id' => 'require|isPositiveInteger',
];

protected $message = [
'id' => 'ID必须是正整数'
];
}

我最不喜欢等,因为这个期限永远都是个未知数。

1.概述

在浏览器中,window对象(注意,w为小写)指当前的浏览器窗口。它也是所有对象的顶层对象。
“顶层对象”指的是最高一层的对象,所有其他对象都是它的下属。JavaScript规定,浏览器环境的所有全局变量,都是window对象的属性。

1
2
var a = 1;
window.a // 1

上面代码中,变量a是一个全局变量,但是实质上它是window对象的属性。声明一个全局变量,就是为window对象的同名属性赋值。

2.window对象的属性

window.window,window.name

window对象的window属性指向自身。

1
window.window === this // true

window.name属性用于设置当前浏览器窗口的名字。

1
2
3
window.name = 'Hello World!';
console.log(window.name)
// "Hello World!"

各个浏览器对这个值的储存容量有所不同,但是一般来说,可以高达几MB。
该属性只能保存字符串,且当浏览器窗口关闭后,所保存的值就会消失。因此局限性比较大,但是与<iframe>窗口通信时,非常有用。

window.location

window.location返回一个location对象,用于获取窗口当前的URL信息。它等同于document.location对象。

1
window.location === document.location // true

3.window对象的方法

window.open(), window.close()

window.open方法用于新建另一个浏览器窗口,并且返回该窗口对象。

1
var popup = window.open('somefile.html');

上面代码会让浏览器弹出一个新建窗口,网址是当前域名下的somefile.html。

open方法一共可以接受四个参数。

  • 第一个参数:字符串,表示新窗口的网址。如果省略,默认网址就是about:blank。
  • 第二个参数:字符串,表示新窗口的名字。如果该名字的窗口已经存在,则跳到该窗口,不再新建窗口。如果省略,就默认使用_blank,表示新建一个没有名字的窗口。
  • 第三个参数:字符串,内容为逗号分隔的键值对,表示新窗口的参数,比如有没有提示栏、工具条等等。如果省略,则默认打开一个完整UI的新窗口。
  • 第四个参数:布尔值,表示第一个参数指定的网址,是否应该替换history对象之中的当前网址记录,默认值为false。显然,这个参数只有在第二个参数指向已经存在的窗口时,才有意义。

window.getSelection()

window.getSelection方法返回一个Selection对象,表示用户现在选中的文本。

1
var selObj = window.getSelection();

使用Selction对象的toString方法可以得到选中的文本。

1
var selectedText = selObj.toString();

4.多窗口操作

窗口的引用

各个窗口之中的脚本,可以引用其他窗口。浏览器提供了一些特殊变量,用来返回其他窗口。

  • top:顶层窗口,即最上层的那个窗口
  • parent:父窗口
  • self:当前窗口,即自身

下面代码可以判断,当前窗口是否为顶层窗口。

1
2
3
4
top === self

// 更好的写法
window.top === window.self

下面的代码让父窗口的访问历史后退一次。

1
parent.history.back();

与这些变量对应,浏览器还提供一些特殊的窗口名,供open方法、<a>标签、<form>标签等引用。

  • _top:顶层窗口
  • _parent:父窗口
  • _blank:新窗口

下面代码就表示在顶层窗口打开链接。

1
<a href="somepage.html" target="_top">Link</a>

iframe标签

对于iframe嵌入的窗口,document.getElementById方法可以拿到该窗口的DOM节点,然后使用contentWindow属性获得iframe节点包含的window对象,或者使用contentDocument属性获得包含的document对象。

1
2
3
4
5
6
7
8
9
10
11
var frame = document.getElementById('theFrame');
var frameWindow = frame.contentWindow;

// 等同于 frame.contentWindow.document
var frameDoc = frame.contentDocument;

// 获取子窗口的变量和属性
frameWindow.function()

// 获取子窗口的标题
frameWindow.title

iframe元素遵守同源政策,只有当父页面与框架页面来自同一个域名,两者之间才可以用脚本通信,否则只有使用window.postMessage方法。

iframe窗口内部,使用window.parent引用父窗口。如果当前页面没有父窗口,则window.parent属性返回自身。因此,可以通过window.parent是否等于window.self,判断当前窗口是否为iframe窗口。

1
2
3
if (window.parent !== window.self) {
// 当前窗口是子窗口
}

iframe嵌入窗口的window对象,有一个frameElement属性,返回它在父窗口中的DOM节点。对于那么非嵌入的窗口,该属性等于null

1
2
3
4
var f1Element = document.getElementById('f1');
var fiWindow = f1Element.contentWindow;
f1Window.frameElement === f1Element // true
window.frameElement === null // true

frames属性

window对象的frames属性返回一个类似数组的对象,成员是所有子窗口的window对象。可以使用这个属性,实现窗口之间的互相引用。比如,frames[0]返回第一个子窗口,frames[1].frames[2]返回第二个子窗口内部的第三个子窗口,parent.frames[1]返回父窗口的第二个子窗口。

需要注意的是,window.frames每个成员的值,是框架内的窗口(即框架的window对象),而不是iframe标签在父窗口的DOM节点。如果要获取每个框架内部的DOM树,需要使用window.frames[0].document的写法。

另外,如果iframe元素设置了nameid属性,那么属性值会自动成为全局变量,并且可以通过window.frames属性引用,返回子窗口的window对象。

1
2
3
// HTML代码为<iframe id="myFrame">
myFrame // [HTMLIFrameElement]
frames.myframe === myFrame // true

另外,name属性的值会自动成为子窗口的名称,可以用在window.open方法的第二个参数,或者<a><frame>标签的target属性。

事件

error 事件和 onerror 属性

浏览器脚本发生错误时,会触发window对象的error事件。我们可以通过window.onerror属性对该事件指定回调函数。

1
2
3
window.onerror = function (message, filename, lineno, colno, error) {
console.log("出错了!--> %s", error.stack);
};

由于历史原因,window的error事件的回调函数不接受错误对象作为参数,而是一共可以接受五个参数,它们的含义依次如下。

  • 出错信息
  • 出错脚本的网址
  • 行号
  • 列号
  • 错误对象

老式浏览器只支持前三个参数。

参考链接

http://javascript.ruanyifeng.com/bom/window.html
https://danlimerick.wordpress.com/2014/01/18/how-to-catch-javascript-errors-with-window-onerror-even-on-chrome-and-firefox/