PHP7特性概览(一)

了解PHP7的一些特性,搭建PHP7源码编译环境,并运行官网这些新特性的代码。


在64位平台支持64位integer

在64位平台支持64位integer,长度为2^64-1字符串。

更详细查看

抽象语法树

抽象语法树是语法分析之后产物,忽略了语法细节,是解释前端和后端的中间媒介。新增抽象语法树,解耦语法分析和编译,简化开发维护,并为以后新增一些特性,如添加抽象语法树编译hook,深入到语言级别实现功能。

更详细查看

闭包this绑定

新增Closure::call,优化Closure::bindTo(JavaScript中bind,apply也是一样的应用场景)。

1
2
3
4
5
<?php
class Foo { private $x = 3; }
$foo = new Foo;
$foobar = function () { var_dump($this->x); };
$foobar->call($foo); // prints int(3)

2-3行新建Foo的对象,第4行创建了一个foobar的闭包,第5行调用闭包的call方法,将闭包体中的$this动态绑定到$foo并执行。

同时官网上进行性能测试,Closure::call的性能优于Closure::bindTo

更详细查看Closure::call

简化isset的语法糖

从使用者角度来说,比较贴心的一个语法糖,减少了不必要的重复代码,使用情景如:

1
2
3
4
5
<?php
// PHP 5.5.14
$username = isset($_GET['username']) ? $_GET['username'] : 'nobody';
// PHP 7
$username = $_GET['username'] ?? 'nobody';

在服务器端想获取$_GET中的变量时,若是PHP5语法,需要使用?:操作符,每次要重写一遍$_GET['username'],而在PHP7就可以使用这个贴心的语法糖,省略这个重复的表达式。

更详细查看isset_ternary

yield from

允许Generator方法代理Traversable的对象和数组的操作。这个语法允许把yield语句分解成更小的概念单元,正如利用分解类方法简化面向对象代码。
例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php
function g1() {
yield 2;
yield 3;
yield 4;
}

function g2() {
yield 1;
yield from g1();
yield 5;
}

$g = g2();
foreach ($g as $yielded) {
print($yielded);
}
// output:
// 12345

yield from后能跟随GeneratorArray,或Traversable的对象。

更详细查看generator delegation

匿名类

1
2
3
4
5
6
<?php
class Foo {}

$child = new class extends Foo {};

var_dump($child instanceof Foo); // true

更详细查看anonymous class

标量类型声明

1
2
3
4
5
6
7
8
9
10
11
12
<?php
declare(strict_types=1);
function add(int $a, int $b): int {
return $a + $b;
}

var_dump(add(1, 2)); // int(3)
// floats are truncated by default
var_dump(add(1.5, 2.5)); // int(3)

//strings convert if there's a number part
var_dump(add("1", "2")); // int(3)

更详细查看

返回值类型声明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
function get_config(): array {
return [1,2,3];
}
var_dump(get_config());

function &get_arr(array &$arr): array {
return $arr;
}
$arr = [1,2,3];
$arr1 = get_arr($arr);
$arr[] = 4;
// $arr1[] = 4;
var_dump($arr1 === $arr);

更详细查看return_types

3路比较

一个语法糖,用来简化比较操作符,常应用于需要使用比较函数的排序,消除用户自己写比较函数可能出现的错误。分为3种情况,大于(1),等于(0),小于(-1)。

%}
1
2
3
4
5
6
7
<?php
function order_func($a, $b) {
return $a <=> $b;
}
echo order_func(2, 2); // 0
echo order_func(3, 2); // 1
echo order_func(1, 2); // -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
<?php
import shorthand
Current use syntax:

use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ChoiceQuestion as Choice;
use Symfony\Component\Console\Question\ConfirmationQuestion;

// Proposed group use syntax:

use Symfony\Component\Console\{
Helper\Table,
Input\ArrayInput,
Input\InputInterface,
Output\NullOutput,
Output\OutputInterface,
Question\Question,
Question\ChoiceQuestion as Choice,
Question\ConfirmationQuestion,
};

更详细查看

下一步用武的目标

用武的出发点

产生于我自己比较失败的工作经历的一个发泄, 之所以说是失败, 有以下一些原因:

  1. 日益增加的技术渴求和没有匹配的日常工作之间的矛盾
  2. 工作口味一直没有被满足, 对工作的几个要求也是如此, 造成不能沉下心来做事
  3. 恶性循环, 导致离自己心里的追求越来越远

可能有这样那样的原因导致你的工作并不如意, 这可能包括你的技术、业务、薪资、人际等各种期待,与真实的工作并不合拍.

所以, 对我而言, 无论英雄, 都需要有用武之地.

我们更需要的是**过来人对工作选择的经验, 以及获得更多更详细的职位内核, 包括每日所干的事, 和其它类似职位有何不同, 做一个程序员能参与的评价和比较,
更有利于有诉求的程序员
根据自己的需求**, 以及对职位的更深入了解, 而不是通过千篇一律的职位描述, 或者其它官方的回答去揣测一份工作, 尽量减少错误挑选工作的概率, 减少走弯路的概率.

而我希望用武能提供这样的一个平台, 给程序员更多的机会去用自己经历和心得给这些来自互联网的职位进行评论以及评分, 或者打小标签等, 当然是基于实名的负责任地评价优劣, 降低程序员踩坑的次数.

用武的下一步方向

由于我是一个it从业人员, 可能对于这行的朋友的诉求更了解些, 因为自己就是这样很”作”, 所以优先收录这些互联网的职位, 比如国内的大型互联网职位发布网站, 如拉勾网, 内推网…

初步功能

  1. 收录更多的职位信息
    现在版本只收录了450个拉勾网上的职位信息, 是非常少的部分, 其次需要对于其它网站进行收录, 归纳各个网站的不同点, 规约一份公共的职位信息部分, 以及各自的特色.
  2. 尽量实时收录职位信息
    可能设置一个合适的频率去检测新增的职位.
  3. 更准确的关键词搜索
    现在的搜索引擎使用的是solr, 面对不完整的关键字PH, 搜索出的结果为空, 显然并不合理, 需要进行更精确的配置.
  4. github第三方登陆及用户注册
    既然是为了程序员的职业, 那么首先选择github作为第三方登陆模块, 为了以后大家可以使用程序员的名义负责任地进行职位的评分和评论.
  5. 更nice的前端, 职位块的显示更清晰, 显示出重点
    能够已更清晰简洁的排版展示出职位关键信息.

欲查看用武情况, 可访问用武

PHP7数组实现(画图版)

主要介绍一下PHP7对于数组的实现。


预备知识

PHP的数据类型

php_types

zend_long

php中的long类型, 在64位机上是64位有符号整数, 不然是32位符号整数。

1
2
3
4
5
6
7
8
9
10
11
// zend_long.h
/* This is the heart of the whole int64 enablement in zval. */
#if defined(__x86_64__) || defined(__LP64__) || defined(_LP64) || defined(_WIN64)
# define ZEND_ENABLE_ZVAL_LONG64 1
#endif

#ifdef ZEND_ENABLE_ZVAL_LONG64
typedef int64_t zend_long;
#else
typedef int32_t zend_long;
#endif

double

双精度浮点数

zend_refcounted

引用计数类型, 记录引用次数, 以及u用于存储元素类型信息type字段(如is_string, is_array, is_object等), 标明对象是否调用过free, destructor函数的flags, 记录GC root number (or 0) and color的gc_info字段(详情见php的zend_gc.h及zend_gc.c).

1
2
3
4
5
6
7
8
9
10
11
12
13
// zend_types.h
struct _zend_refcounted {
uint32_t refcount; /* reference counter 32-bit */
union {
struct {
ZEND_ENDIAN_LOHI_3(
zend_uchar type,
zend_uchar flags, /* used for strings & objects */
uint16_t gc_info) /* keeps GC root number (or 0) and color */
} v;
uint32_t type_info;
} u;
};

zend_string

字符串类型, 带有引用计数, 和string的hash值h, 避免每次需要计算, 字段长度len, 以及val用来索引字符串, 这里tricky地避免了2次malloc内存.

1
2
3
4
5
6
7
8
// zend_types.h
typedef struct _zend_string zend_string;
struct _zend_string {
zend_refcounted gc;
zend_ulong h; /* hash value */
size_t len;
char val[1];
};

zend_object

1
2
3
4
5
6
7
8
9
typedef struct _zend_object     zend_object;
struct _zend_object {
zend_refcounted gc;
uint32_t handle; // TODO: may be removed ???
zend_class_entry *ce;
const zend_object_handlers *handlers;
HashTable *properties;
zval properties_table[1];
};

zend_resource

1
2
3
4
5
6
struct _zend_resource {
zend_refcounted gc;
int handle; // TODO: may be removed ???
int type;
void *ptr;
};

zend_reference

1
2
3
4
struct _zend_reference {
zend_refcounted gc;
zval val;
};

zend_array

详细见下文具体实现.

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
// zend_types.h
typedef struct _zend_array zend_array;

typedef struct _zend_array HashTable;

struct _zend_array {
zend_refcounted gc;
union {
struct {
ZEND_ENDIAN_LOHI_4(
zend_uchar flags,
zend_uchar nApplyCount,
zend_uchar nIteratorsCount,
zend_uchar reserve)
} v;
uint32_t flags;
} u;
uint32_t nTableMask;
Bucket *arData;
uint32_t nNumUsed;
uint32_t nNumOfElements;
uint32_t nTableSize;
uint32_t nInternalPointer;
zend_long nNextFreeElement;
dtor_func_t pDestructor;
};

位运算

给出一个上限Max, 利用位运算求出指定N的表达式-(Max-(N % Max))的值.

1
2
3
4
uint32_t max = 2;
uint32_t mask = (uint32_t)-max; // 二进制表示: 11111111 11111111 11111111 11111110
uint32_t n = 12345678;
int32_t answser = (int32_t)(n | mask); // -2

散列表

散列表原理维基百科

局部性原理

空间局部性, 时间局部性

具体实现

初始化

1
2
<?php
$arr = array();

php_init

arData指针指向的内存, 是真实数据的起始地址, 在邻近的低址内存是存储hash值到真实数据地址的映射表, 这个映射表是uint32_t类型的数组, 大小相同于nTableSize, 这样最好情况下, 每个hash值都能映射一个真实数据.

插入

插入key为$key1, $key1.hash为0, 值为10的元素

1
2
<?php
$arr[$key1] = 10;

php_insert

上文提到的位运算就是应用在插入元素场景, 由于arData指向的是真实数据的起始地址, 而索引信息(即存储hash值到真实数据的映射)处于arData更低地址, 那么要更新索引信息, 就需要计算出-(nTableSize-(nHash % nTableSize)), nHash就是键的hash值, 例如向大小为2的数组, 插入hash值为0的元素, 那么索引到hash值为0的区域就是((uint32_t*)arData)-(2-(0%2)), 如图, 将hash值为0的数据偏移0*sizeof(Bucket)存储到了((uint32_t*)arData)-2.

哈希冲突

插入key为$key2($key2 != $key1), $key2.hash为0, 值为20的元素, 造成哈希冲突

1
2
<?php
$arr[$key2] = 20;

php_collide

数组拓容

插入key为$key3, $key3.hash为1, 值为30的元素, 造成数组的load factor过高, 触发拓容

1
2
<?php
$arr[$key3] = 30;

php_extend

删除

删除key为$key2的元素

1
2
<?php
delete $arr[$key2];

php_del

遍历

1
2
3
4
5
6
7
8
9
<?php
foreach ($arr as $v) {
print $v . "\n";
}

// output
// 10
// 30

直接遍历arData, 最大边界为arData+nNumUsed, 跳过被UNDEF的元素.

与历史版本比较

改进

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// zend_hash.h
typedef struct _hashtable {
uint nTableSize;
uint nTableMask;
uint nNumOfElements;
ulong nNextFreeElement;
Bucket *pInternalPointer; /* Used for element traversal */
Bucket *pListHead;
Bucket *pListTail;
Bucket **arBuckets;
dtor_func_t pDestructor;
zend_bool persistent;
unsigned char nApplyCount;
zend_bool bApplyProtection;
#if ZEND_DEBUG
int inconsistent;
#endif
} HashTable;
  1. 连续的内存, 更高的效率
    通过简单地观察数据结构, 可以发现5.3.23版本是使用Bucket **arBuckets分配一块二维Bucket数组存放键值, 与7.0的预分配连续内存的做法不同, 每次需要插入元素都要申请一块sizeof(Bucket)的内存, 更容易造成内存碎片, 以及低效率;
  2. 更小的数据结构, 更优美的实现
    此外, 废弃了Bucket *pListHead以及Bucket *pListTail这两个头尾指针, 本来是为了实现数组特性, 实现正反序遍历等功能, 而7.0既然已经是连续的一块内存, 那么直接从Bucket *arData下标0处, 到达边界arData+nNumUsed就可以实现这个功能.
  3. 良好的局部性
    遍历数组有更好的局部性, 相较于5.3.23的链表遍历, 使得遍历时cache能更准确地加载数据, 拥有更好的时间空间局部性.

可以说这个7.0版本对PHP数组的优化是非常成功的, 除此之外, 对于其他的数据结构7.0也是有”瘦身”优化, 对于整个效率和内存占用有比较明显的改善.

PHP数组的内存布局

PHP数组的内存布局

内存布局

它的内存结构,目前的实现方案是分配一块连续的内存区间,用来存储hash元数据和具体数据。
连续的内存,上面部分是存储hash值到值地址的映射,而下面部分就是值的存储区域,如下图(摘自php-src/Zend/zend_types.h):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/*
* HashTable Data Layout
* =====================
*
* +=============================+
* | HT_HASH(ht, ht->nTableMask) |
* | ... |
* | HT_HASH(ht, -1) |
* +-----------------------------+
* ht->arData ---> | Bucket[0] |
* | ... |
* | Bucket[ht->nTableSize-1] |
* +=============================+
*/

新建数组

初始化一块内存,大小为HT_SIZE(ht) = HT_HASH_SIZE(ht) + HT_DATA_SIZE(ht),如果你查看zend_types.h,能看到这三个宏定义:

1
2
3
4
5
6
7
8
#define HT_SIZE(ht) \
(HT_HASH_SIZE(ht) + HT_DATA_SIZE(ht))

#define HT_DATA_SIZE(ht) \
((size_t)(ht)->nTableSize * sizeof(Bucket))

#define HT_HASH_SIZE(ht) \
(((size_t)(uint32_t)-(int32_t)(ht)->nTableMask) * sizeof(uint32_t))

这里要知道nTableMask是什么,从字面意思是掩码,它的类型是uint32_t,值是(uint32_t)(-nTableSize),nTableSize最小为2,那么此时nTableMask为4294967294,表示成二进制,11111111 11111111 11111111 11111110,这个值在之后计算对应hash所在的下标值会用到。
下面就是这块内存最开始的状态,假设数组大小为2,nNumUsed即已有元素为0,则下面是初始状态:

1
2
3
4
5
6
7
8
9
10
11
12
/*
* HashTable Data Layout
* =====================
* ht->nNumUsed = 0
* +=============================+
* 0 | |
* 1 | |
* +-----------------------------+
* ht->arData ---> | |
* | |
* +=============================+
*/

插入key的hash值为12345678的元素E

事实上对于这块内存,我们仅仅是根据arData这个指针去使用,而它是Bucket* 类型,上半部分的类型是uint32_t* ,怎么在不超出上半部分边界的情况下进行索引,这里用到了刚才提到的nTableMask,下标为(int32_t)(HASH | nTableMask),计算出为-2,那么((uint32_t*)arData)[-2]所指向的就是下标为0处的内存地址,通过nTableMask保证不会越界。
E元素首先根据nNumUsed找到存储自身的位置,即ht->arData[ht->nNumUsed],并且修改hash元数据部分,0位置的值指向存储区域,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
/*
* HashTable Data Layout
* =====================
* ht->nNumUsed = 1
* +=============================+
* 0 | 0 |
* 1 | |
* +-----------------------------+
* ht->arData ---> | E | next->HT_INVALID_IDX
* | |
* +=============================+
*/

插入key的hash值为12345678的元素F

使用链表法解决冲突。

1
2
3
4
5
6
7
8
9
10
11
12
/*
* HashTable Data Layout
* =====================
* ht->nNumUsed = 2
* +=============================+
* 0 | 1*sizeof(Bucket) |
* 1 | |
* +-----------------------------+
* ht->arData ---> | E | next->HT_INVALID_IDX
* | F | next->0
* +=============================+
*/

哈希拓容,rehash

扩大数组大小,将数据转存到新数组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* HashTable Data Layout
* =====================
* ht->nNumUsed = 2
* +=============================+
* 0 | 1*sizeof(Bucket) |
* 1 | |
* 2 | |
* 3 | |
* +-----------------------------+
* ht->arData ---> | E | next->HT_INVALID_IDX
* | F | next->0
* | |
* | |
* +=============================+
*/

PHP面试+怀旧

面试

今天去面试了一家公司, 跟自己说面完要写个小结, 记录这次面试的前后历程.

这次面试的缘起是因为团队有较大变动, 也开始重新审视自己, 正是在这个时候, 发现自己所处的环境还是非常“危险”的, 是指的自己处于一种不温不火的酱油状态, 整天游离在完成工作和无聊消沉的独处中, 并且成为了温吞吞的人, 于是发现现在的环境实在不是特别适合自己当初要进来的初衷, 突然渴望那种每天都在蜕变, 每天都在痛并快乐着的成长日子, 所以准备找面试机会, 看看是否能找到适合自己想法的机会.

XX公司给了个面试机会, 自己还是非常重视的, 恰逢清明节不回家, 于是在上海的小房子里进行一波准备.

Read more

拒绝二手资料

不扯远了, 缘起是我玩了这么久计算机, 做了这么久的web开发, 竟然tm的没有真正看过http的文档, 不仅是http, 其它的一些, 像写sheme的时候竟然也没有看过scheme的规格文档, 真是服了自己, 这些关于http,scheme,java,php等都是通过间接从别人消化了之后的陈述获得, 这样速度固然快, 但是难免不能完整的经历一个技术的发展思想, 缘何来, 向何处去, 这样子的不完整性是非常不利于深入领域, 钻研核心的. 所以我给自己定下规矩, 凡事准备严肃对待的事物都要去学习它的第一手资料, 了解它们来龙去脉, 不然绝不说自己是真正会这些东西.

今天是张国荣先生离开第12年, 感谢他和他的作品给我带来的那么多的正能量.

Mit分布式系统课程-Lab2-PartB

前两天删了在github上的6.824, 因为课程里说了不要把源码放在公共的地方, 之前没删一直心有歉意, 还好进度没这么快, 应该对别人也不会造成影响.

言归正传, 这星期空余时间一直在看Lab2的Part B, 今天因为身体不适, 请假一天在家, 开始实现思路, 并记录一下自己遇到的一些大小问题.

目标

实现基于viewservice(Lab1实现的视图服务器, 用来记录主副服务器的地址)的键值服务器, 分别为primary(主服务器),Backup(备份服务器), 客户端通过viewservice获得primary的地址, 发送键值的存取请求, primary在响应请求的同时, 将数据备份到backup服务器. 实现完成后, 通过所有的unit test.

架构

2个kv服务器, viewservice以及客户端

  1. viewservice
    • 存储并提供primary以及backup的信息
    • 接受primary, backup的定时ping请求, 更新或保持两个服务器的位置
    • 提供查询当前服务器状态
  2. primary
    • 接受client的请求
    • 定期向viewservice报备自己的状态
    • 在backup服务器更换时负责将自身全部数据同步过去
  3. backup
    • 作为primary的备份存储
    • 定期向viewservice报备自己的状态
    • 在primary挂了之后, 提升为primary, 负责client的请求
  4. client
    • 向primary发送数据存储,查询的请求
    • 若primary返回”错误的服务器”时, 询问viewservice, 更新primary

遇到的挑战

  1. 实现时有些golang的特性不是特别了解, 一边打开golang的官方文档, 一边poc.
  2. 决定在什么时候需要容错? 是否可恢复? 在反复重试时, 是否要定最大重试次数, 以及失败的处理.

单元测试

当我运行go test来验证逻辑的时候, 实在不得不赞叹老师们详细的测试用例, 有几个case让我挠头.

  • TestAtMostOnce
    模拟服务器返回报文丢失的情况, 利用puthash-get验证, puthash是put操作的一种变体, 它要存储的值是先前的值和当前值的hash结果, 所以这个地方引入了存储状态, 我通过每次请求要求客户端发送一个unique id来记录此次操作, 尽管报文丢失, 客户端重试时还是能用这个id找回上次通信的内容.
  • TestConcurrentSame
    验证primary服务器只有在backup也处理成功的情况才算成功.
  • TestRepeatedCrash
    模拟存储服务器间断崩溃的情况, 就是要保证客户端的每个请求都有重试的可能, primary和backup的通信过程中, backup随时可能崩溃, 这个测试使用put-get验证.
  • TestRepeatedCrashUnreliable
    模拟存储服务器间断崩溃的同时, 网络也不稳定, 服务器的返回报文会丢失. 这个case的难点在它是使用puthash-get来验证的, 就是说, 你要在服务器不时崩溃的情况下保证以前的值不仅在primary服务器上存在, 也要在backup服务器上保存, 以免primary下一秒挂了.
  • TestPartition1
    模拟primary已经过期的情况下, 向他发送的请求, 能被它拒绝.

POC

backup服务器更换时, primary需要同步当前数据库所有数据到backup, 我不想用foreach来每条数据进行同步, 所以想既然rpc能传递这么多类型的参数, 那直接将map传递过去不就不用那么麻烦了?所以进行了一次poc.

server.go
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
import "net"
import "net/rpc"
import "os"
import "fmt"

type Server struct {
db map[string]string
}

func (server *Server) Sync(database map[string]string, reply *int) error {
fmt.Println("Start syncing...")
server.db = database
for k, v := range server.db {
fmt.Println(k, " => ", v)
}
*reply = 1
fmt.Println("Finish syncing...")
return nil
}

func main() {
server := new(Server)
server.db = make(map[string]string)
rpcs := rpc.NewServer()
rpcs.Register(server)
me := "/var/tmp/test1122"
os.Remove(me)
l, e := net.Listen("unix", me)
if e != nil {
fmt.Println("listen error")
}

for true {
conn, e := l.Accept()
if e != nil {
fmt.Println("accept error")
conn.Close()
return
}
go func() {
rpcs.ServeConn(conn)
}()
}
}
client.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package main
import "net/rpc"
import "fmt"

func main() {
me := "/var/tmp/test1122"
c, e := rpc.Dial("unix", me)
if e != nil {
fmt.Println("dial error")
return
}
defer c.Close()

db := make(map[string]string)
db["name"] = "srg"
db["age"] = "25"
reply := 1
e = c.Call("Server.Sync", db, &reply)
if e != nil {
fmt.Println("sync error")
return
}
}

开两个terminal, 在$GOPATH目录先运行server.go, 再运行client.go, 最后server.go运行后的输出, 看来是可以的

1
2
3
4
5
$ go run server.go                                                             
Start syncing...
name => srg
age => 25
Finish syncing...

在我把这页的hint也看完的时候, 发现里面提到这个问题, 建议直接把这个map作为参数传递.

最后贴上过关纪念

微信本地测试

微信本地测试

项目里要测试微信公众号, 但又不愿意忍受每次改下代码就提交到版本库, 再发布到生产这个琐碎的流程, 所以必须有一个本地能测试的方法, 到知乎上找了一个方法(引用知乎操晓峰的回答).

原理是HTTP请求重定向, 将微信中要访问的url转发到你设置的代理服务器, 代理服务器转发到你的开发服务器.

有趣的是, 在公司一直没有完全成功, 总有一个页面是刷不出来, 查看Charles发现传输速度极慢, 真想吐槽公司的网络. 回家后重试了一次, 完全成功.

Your browser is out-of-date!

Update your browser to view this website correctly.&npsb;Update my browser now

×