Fork me on GitHub

分类 工具技巧 下的文章

新技术学习笔记:ZooKeeper

第一次接触 ZooKeeper 是在使用 Dubbo 服务框架的时候,当时对 ZooKeeper 只是停留在知道和了解的层面,公司的 ZooKeeper 都是由运维统一安装和管理,对于我们开发人员来说就是在程序的配置文件中加一行注册中心的地址而已。后来又在另一个分布式的项目中使用了 ZooKeeper 来进行配置的管理,可还是对其一知半解,从来没有深入学习过 ZooKeeper 的知识。最近在工作中接触了 IaaS 和 PaaS,被各种新技术转的晕乎不已,在做技术决策的时候,之前学过的东西都太肤浅了,根本没办法对各种技术方案做横向对比。所以决定花一点时间好好的学习和总结下这些技术,今天就从 ZooKeeper 开始。

ZooKeeper 由 Apache Hadoop 的子项目发展而来,并且在 2010 年 11 月正式成为了 Apache 的顶级项目。关于 ZooKeeper 的命名很有意思,动物园管理员,显然管理着一园的动物,比如:Hadoop(大象)、Hive(蜜蜂)、Pig(小猪)等等。

zookeeper-name.png

根据官网的介绍ZooKeeper is a high-performance coordination service for distributed applications,它是为分布式应用提供的一种高性能协调服务。基于对 ZAB 协议(ZooKeeper Atomic Broadcast,ZooKeeper 原子消息广播协议)的实现,它能够很好地保证分布式环境中数据的一致性。也正是基于这样的特性,使得 ZooKeeper 成为了解决分布式数据一致性问题的利器。利用 ZooKeeper,可以很容易的在分布式环境下实现统一命名服务、配置管理、锁和队列、状态同步、集群管理等功能。

一、ZooKeeper 的安装

ZooKeeper 的安装分单机模式和集群模式两种。单机模式非常简单,直接从 Apache ZooKeeper™ Releases 下载最新版本到本地并解压,就可以在 bin 目录下找到 ZooKeeper 的服务端(zkServer)和客户端(zkCli),在 Windows 环境对应 .cmd 后缀的文件,在 Linux 环境对应 .sh 后缀的文件。在运行之前,还需要做两步配置:

  1. 配置 JAVA_HOME 环境变量
  2. 修改配置文件,将 conf/zoo_sample.cfg 修改为 conf/zoo.cfg

准备就绪后,直接运行 zkServer 文件,如果看到下面的显示,就说明 ZooKeeper 服务已经启动好了。

2018-08-04 10:06:22,525 [myid:] - INFO  [main:ZooKeeperServer@829] - tickTime set to 2000
2018-08-04 10:06:22,525 [myid:] - INFO  [main:ZooKeeperServer@838] - minSessionTimeout set to -1
2018-08-04 10:06:22,527 [myid:] - INFO  [main:ZooKeeperServer@847] - maxSessionTimeout set to -1
2018-08-04 10:06:23,149 [myid:] - INFO  [main:NIOServerCnxnFactory@89] - binding to port 0.0.0.0/0.0.0.0:2181

为了保证服务的稳定和可靠,生产环境大多是部署 ZooKeeper 的集群模式,集群模式和单机模式相比,有两点不同:

  1. 配置文件中要指定集群中所有机器的信息,形如:server.id=host:port1:port2
  2. dataDir 目录下要配置一个 myid 文件

一个典型的 ZooKeeper 配置文件如下:

#常规配置
tickTime=2000
initLimit=10
syncLimit=5
clientPort=2181
dataDir=/zookeeper/data
dataLogDir=/zookeeper/logs
 
# 集群配置
server.1=192.168.0.101:2888:3888
server.2=192.168.0.102:2888:3888
server.3=192.168.0.103:2888:3888

关于 ZooKeeper 集群模式的部署和各参数的意思,可以参考 Zookeeper集群部署

二、ZooKeeper 核心概念

在安装好 ZooKeeper 服务之后,我们就可以进行体验了。但是在体验它之前,我们还需要了解相关的几个核心概念,比如它的数据模型,四种不同类型的节点,节点监听等等。

ZooKeeper 的数据模型是一个类似文件系统的树形结构,树的每一个节点叫做 znode,它像一个小型文件一样,可以存储少量的数据(一般不多于 1M,这是因为 ZooKeeper 的设计目标并不是传统的数据库,而是用来存储协同数据的),但它并不是一个文件,因为每个节点还可以有多个子节点,看上去又好像是一个文件夹一样。和文件系统一样,ZooKeeper 的根节点名字为 /,并使用节点的路径来唯一标识一个节点,比如 /app1/p_1。另外,还提供了命令 getset 来读写节点内容,命令 ls 来获取子节点列表,命令 createdelete 来创建和删除节点。但是要注意的是 ZooKeeper 中的路径只有绝对路径,没有相对路径,所以路径 ../data 是不合法的,也不存在 cd 这样的命令。下图是 ZooKeeper 数据模型的示意图(图片来源):

zookeeper-znodes.jpg

另外,一共有四种不同类型的节点:

  • 持久节点(PERSISTENT):默认的节点类型,节点一旦创建,除非显式的删除,否则一直存在;
  • 临时节点(EPHEMERAL):ZooKeeper 的客户端和服务器之间是采用长连接方式进行通信的,并通过心跳来保持连接,这个连接状态称为 session,客户端在创建临时节点之后,如果一直保持连接则这个节点有效,一旦连接断开,该节点就会被自动删除;注意,临时节点不能有子节点;
  • 持久顺序节点(PERSISTENT_SEQUENTIAL):默认情况下,ZooKeeper 是不允许创建同名节点的,如果该节点是顺序节点,ZooKeeper 就会自动在节点路径末尾添加递增的序号;
  • 临时顺序节点(EPHEMERAL_SEQUENTIAL):顺序节点,但是只有在客户端连接有效时存在;

准确来说,节点的类型只有持久和临时两种,顺序节点是指在创建节点时可以指定一个顺序标志,让节点名称添加一个递增的序号,但是节点一旦创建好了,它要么是持久的,要么是临时的,只有这两种类型。这几种类型的节点虽然看上去很平常,但是它们正是实现 ZooKeeper 分布式协调服务的关键,如果再加上节点监听的特性,可以说是无所不能。节点监听(Watch)可以用于监听节点的变化,包括节点数据的修改或者子节点的增删变化,一旦发生变化,可以立即通知注册该 Watch 的客户端。我们在后面的例子中将会看出这些特性结合在一起的强大威力。譬如我们在执行 get 命令查询节点数据时指定一个 Watch,那么当该节点内容发生变动时,就会触发该 Watch,要注意的是 Watch 只能被触发一次,如果要一直获得该节点数据变动的通知,那么就需要在触发 Watch 时重新指定一个 Watch。只有节点的读操作(例如:getlsstat)可以注册 Watch,写操作(例如:setcreatedelete)会触发 Watch 事件。

三、使用 ZooKeeper 客户端

接下来我们使用 ZooKeeper 客户端来体验下 ZooKeeper 的基本功能。如果是访问本地环境的 ZooKeeper 服务,直接运行 zkCli 脚本即可。如果是访问远程的 ZooKeeper 服务,则使用 -server 参数:

$ zkCli.sh -server 192.168.0.101:2181

如果成功连接,客户端会出现类似下面的命令提示符:

[zk: localhost:2181(CONNECTED) 0] 

这时你就可以执行 ZooKeeper 命令了,譬如使用 help 查看可用命令列表:

[zk: localhost:2181(CONNECTED) 0] help
ZooKeeper -server host:port cmd args
        stat path [watch]
        set path data [version]
        ls path [watch]
        delquota [-n|-b] path
        ls2 path [watch]
        setAcl path acl
        setquota -n|-b val path
        history
        redo cmdno
        printwatches on|off
        delete path [version]
        sync path
        listquota path
        rmr path
        get path [watch]
        create [-s] [-e] path data acl
        addauth scheme auth
        quit
        getAcl path
        close
        connect host:port

从这个列表中,我们可以看到上面提到的几个基本命令:getsetlscreatedelete 等。譬如我们通过 ls 命令查看根节点 / 的子节点:

[zk: localhost:2181(CONNECTED) 1] ls /
[zookeeper]

通过 create 命令创建新节点:

[zk: localhost:2181(CONNECTED) 2] create /data Hello
Created /data
[zk: localhost:2181(CONNECTED) 3] get /data
Hello
cZxid = 0xa
ctime = Sat Aug 04 14:03:51 CST 2018
mZxid = 0xa
mtime = Sat Aug 04 14:03:51 CST 2018
pZxid = 0xa
cversion = 0
dataVersion = 0
aclVersion = 0
ephemeralOwner = 0x0
dataLength = 5
numChildren = 0

create 默认创建的是持久节点,可以指定参数 -e 创建临时节点或 -s 创建顺序节点。ZooKeeper 的基本命令都很简单,可以参考 ZooKeeper命令行操作

使用客户端命令行管理 ZooKeeper 节点和数据只是 ZooKeeper 客户端的一种方式,实际上,ZooKeeper 还提供了另一种客户端交互方式,可以直接使用 telnetnc 向 ZooKeeper 发送命令,用来获取 ZooKeeper 服务当前的状态信息。这些命令都是由四个字母组成,因此又叫做 四字命令(The Four Letter Words)

譬如,下面通过 ruok(Are you OK?) 命令查询 ZooKeeper 服务是否正常,ZooKeeper 返回 imok(I’m OK)表示服务状态正常。

$ echo ruok | nc localhost 2181
imok

四字命令按功能可以划分为四类:

  • 服务状态相关:ruok、conf、envi、srvr、stat、srst、isro
  • 客户连接相关:dump、cons、crst
  • 节点监听相关:wchs、wchc、wchp
  • 监控相关:mntr

关于四字命令的详细信息可以参考 ZooKeeper 官网手册 ZooKeeper Administrator's Guide

四、ZooKeeper 常见功能实现

如果只是使用命令行对 ZooKeeper 上的数据做些增删改查,还不足以说明 ZooKeeper 有什么特别的,无非就是一个小型的文件系统而已,只有把它用于我们的分布式项目中,才能看出它真正的作用。

4.1 第一个 ZooKeeper 应用

我们先从最简单的代码开始,连接 ZooKeeper 并创建一个节点:

public static void main(String[] args) throws Exception {
    ZooKeeper zookeeper = new ZooKeeper("localhost:2181", 5000, null);
    zookeeper.create("/data", "Hello world".getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
    zookeeper.close();
}

上面的代码首先创建一个连接,连接超时时间设置为 5 秒,然后创建一个名为 /data 的持久节点(PERSISTENT),并写入数据 "Hello World",最后关闭连接。上面的代码和 zkCli -server localhost:2181 create /data "Hello world" 命令是一样的。

实际上,这里的代码虽然简单的不能再简单了,但是却存在着一个 BUG,因为 new ZooKeeper() 只是向服务端发起连接,此时连接并没有创建成功,如果在连接创建之前调用了 zookeeper.create(),由于超时时间是 5 秒,如果在 5 秒内和服务端的连接还没有创建完成,此时就会抛出 ConnectionLossException

Caused by: org.apache.zookeeper.KeeperException$ConnectionLossException: KeeperErrorCode = ConnectionLoss for /data

这里正确的做法是使用 ZooKeeper 提供的 Watch 机制。上面在创建连接时 new ZooKeeper("localhost:2181", 5000, null),这里的第三个参数可以指定一个实现 Watcher 接口的对象,Watcher 接口只有一个方法 void process(WatchedEvent watchedEvent),这个方法会在连接创建成功的时候被调用。所以我们可以在 new ZooKeeper() 时设置一个 Watcher,然后通过 CountDownLatch.await() 阻塞程序执行,直到连接创建成功时,Watcher 的 process() 方法调用 CountDownLatch.countDown() 才开始执行下面的 create() 操作。下面是示例代码:

public class Simple implements Watcher {
    private CountDownLatch connectedSignal = new CountDownLatch(1);
    @Override
    public void process(WatchedEvent watchedEvent) {
        if (watchedEvent.getState() == Watcher.Event.KeeperState.SyncConnected) {
            connectedSignal.countDown();
        }
    }
    public void createNode() throws Exception {
        ZooKeeper zookeeper = new ZooKeeper("localhost:2181", 1000, this);
        connectedSignal.await();
        zookeeper.create("/data", "Hello world".getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
        zookeeper.close();
    }
    public static void main(String[] args) throws Exception {
        Simple simple = new Simple();
        simple.createNode();
    }
}

至此,我们可以成功连接 ZooKeeper 并创建节点了,对代码稍加改造就可以实现更多功能,譬如创建子节点,删除节点,修改节点数据等等。有了这些基础,接下来,就让我们来看看 ZooKeeper 可以实现哪些分布式高阶技巧。

4.2 统一命名服务(Name Service)

所谓命名服务,就是帮助我们对资源进行统一命名的服务,通常需要有一套完整的命名规则,既能够产生唯一的名称又便于人们识别和记住,通常情况下用树形的名称结构是一个理想的选择,树形的名称结构是一个有层次的目录结构,既对人友好又不会重复。使用命名服务可以更方便的对资源进行定位,比如计算机地址、应用提供的服务地址或者远程对象等。

想象一下 DNS,它就是一种命名服务,可以将域名转换为 IP 地址,这里的域名就是全局唯一的名称,方便人们记忆,而 IP 地址就是该名称对应的资源。再想象一下 JNDI,这也是一种命名服务,JNDI 的全称为 Java Naming and Directory Interface(Java 命名和目录接口),它是 J2EE 中重要的规范之一,标准的 J2EE 容器都提供了对 JNDI 规范的实现,它也是将有层次的目录结构关联到一定资源上。譬如我们在配置数据源时一般会在 JDBC 连接字符串中指定数据库的 IP 、端口、数据库名、用户名和密码等信息,这些信息如果散落在分布式应用的各个地方,不仅会给资源管理带来麻烦,比如当数据库 IP 发生变动时要对各个系统进行修改,而且数据库的用户名密码暴露在外,也存在安全隐患。使用 JNDI 可以方便的解决这两方面的问题。

在 ZooKeeper 中创建的所有节点都具有一个全局唯一的路径,其对应的节点可以保存一定量的信息,这个特性和命名服务不谋而合。所以如果你在分布式应用中需要用到自己的命名服务,使用 ZooKeeper 是个比较合适的选择。

4.3 配置管理(Configuration Management)

正如上面所说的数据库配置一样,在应用程序中一般还会用到很多其他的配置,这些配置往往都是写在某个配置文件中,程序在运行时从配置文件中读取。如果程序是单机应用,并且配置文件数量不多,变动也不频繁,这种做法倒没有什么大问题。但是在分布式系统中,每个系统都有大量的配置文件,而且某些配置项是相同的,如果这些配置项发生变动时,让运维人员在每台服务器挨个修改配置文件,这样的维护成本就太高了,不仅麻烦也容易出错。

配置管理(Configuration Management)在分布式系统中很常见,一般也叫做 发布与订阅,我们将所有的配置项统一放置在一个集中的地方,所有的系统都从这里获取相应的配置项,如果配置项发生变动,运维人员只需要在一个地方修改,其他系统都可以从这里获取变更。在 ZooKeeper 中可以创建一个节点,比如:/configuration,并将配置信息放在这个节点里,在应用启动的时候通过 getData() 方法,获取该节点的数据(也就是配置信息),并且在节点上注册一个 Watch,以后每次配置变动时,应用都会实时得到通知,应用程序获取最新数据并更新配置信息即可。

要实现配置管理的管理,我们首先实现配置数据的发布:

public class ConfigWriter {
    private ZooKeeper zookeeper;
    private String configPath;
    public ConfigWriter(ZooKeeper zookeeper, String configPath) {
        this.zookeeper = zookeeper;
        this.configPath = configPath;
    }
    public void writeConfig(String configData) throws KeeperException, InterruptedException {
        Stat stat = zookeeper.exists(configPath, false);
        if (stat == null) {
            zookeeper.create(configPath, configData.getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
        } else {
            zookeeper.setData(configPath, configData.getBytes(), -1);
        }
    }
    public static void main(String[] args) throws Exception {
        ZooKeeper zookeeper = new ZooKeeper("localhost:2181", 30000, null);
        ConfigWriter writer = new ConfigWriter(zookeeper, "/configuration");
        writer.writeConfig("Hello");
        zookeeper.close();
    }
}

先通过 exists() 方法判断 /configuration 节点是否存在,如果不存在,就使用 create() 方法创建一个并写入配置数据,如果已经存在,直接修改该节点的数据即可。每次配置变更时,我们就调用一次 updateConfig(zk, "/configuration", configData) 方法。然后我们再实现配置数据的订阅:

public class ConfigReader implements Watcher {
    private ZooKeeper zookeeper;
    private String configPath;
    public ConfigReader(ZooKeeper zookeeper, String configPath) {
        this.zookeeper = zookeeper;
        this.configPath = configPath;
    }
    @Override
    public void process(WatchedEvent watchedEvent) {
        if (watchedEvent.getType() == Watcher.Event.EventType.NodeDataChanged) {
            readConfig();
        }
    }
    public void readConfig() {
        try {
            byte[] data = zookeeper.getData(configPath, this, null/*stat*/);
            System.out.println(new String(data));
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    public static void main(String[] args) throws Exception {
        ZooKeeper zookeeper = new ZooKeeper("localhost:2181", 30000, null);
        ConfigReader reader = new ConfigReader(zookeeper, "/configuration");
        reader.readConfig();
        Thread.sleep(Long.MAX_VALUE);
    }
}

和上面的创建 ZooKeeper 连接一样,我们的 ConfigReader 类实现了 Watcher 接口,并在调用 getData() 方法获取配置数据时注册这个 Watch,这样可以在节点数据发生变动时得到通知,得到通知之后,我们重新获取配置数据,并重新注册 Watch。

4.4 集群管理(Group Membership)

在分布式系统中,我们常常需要将多台服务器组成一个集群,这时,我们就需要对这个集群中的服务器进行管理,譬如:我们需要知道当前集群中有多少台服务器,当集群中某台服务器下线时需要及时知道,能方便的向集群中添加服务器。利用 Zookeeper 可以很容易的实现集群管理的功能,实现方法很简单,首先我们创建一个目录节点 /groups,用于管理所有集群中的服务器,然后每个服务器在启动时在 /groups 节点下创建一个 EPHEMERAL 类型的子节点,譬如 /member-1member-2 等,并在父节点 /groups 上调用 getChildren() 方法并设置 Watch,这个 Watch 会在 /groups 节点的子节点发生变化(增加或删除)时触发通知,由于每个服务器创建的子节点是 EPHEMERAL 类型的,当创建它的服务器下线时,这个子节点也会随之被删除,从而触发 Watch 通知,这样其它的所有服务器就知道集群中少了一台服务器,可以使用 getChildren() 方法获取集群的最新服务器列表,并重新注册 Watch。

我们实现一个最简单的集群管理程序:

public class GroupMember implements Watcher {
    private ZooKeeper zookeeper;
    private String groupPath;
    public GroupMember(ZooKeeper zookeeper, String groupPath) {
        this.zookeeper = zookeeper;
        this.groupPath = groupPath;
    }
    @Override
    public void process(WatchedEvent watchedEvent) {
        if (watchedEvent.getType() == Event.EventType.NodeChildrenChanged) {
            this.list();
        }
    }
    public void list() {
        try {
            List<String> members = zookeeper.getChildren(this.groupPath, this);
            System.out.println("Members: " + String.join(",", members));
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    public void join(String memberName) {
        try {
            String path = zookeeper.create(
                    this.groupPath + "/" + memberName, null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL);
            System.out.println("Created: " + path);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    public static void main(String[] args) throws Exception {
        ZooKeeper zookeeper = new ZooKeeper("localhost:2181", 30000, null);
        GroupMember member = new GroupMember(zookeeper, "/groups");
        member.join("member-" + new Random().nextInt(1000));
        member.list();
        Thread.sleep(Long.MAX_VALUE);
    }
}

程序启动时首先加入 /groups 集群,加入集群的方法是在 /groups 节点下创建一个 CreateMode.EPHEMERAL 类型的子节点。然后再获取该集群中的成员列表,同时我们注册了一个 Watch。我们每启动一个 GroupMember 实例,都会在 /groups 集群中添加一个成员,这将触发一个 NodeChildrenChanged 类型的事件,我们在 list() 方法中重新获取成员列表并注册 Watch。这样不仅可以监测到集群中有新成员加入,而且也可以对集群中成员的下线做监控。这里有一点要注意的是,当集群中有新成员加入时,Watch 可以及时通知,但有成员下线时,并不会及时通知,因为我们这里 new ZooKeeper() 时指定了连接的超时时间是 30 秒,ZooKeeper 只有在 30 秒超时之后才会触发 Watch 通知。

4.5 集群选主(Leader Election)

在上面的集群管理一节,我们看到了可以使用 EPHEMERAL 类型的节点,对集群中的成员进行管理和监控,其实集群管理除了成员的管理和监控功能之外,还有另一个功能,那就是:集群选主(Leader Election),也叫做 Leader 选举或 Master 选举。这个功能在分布式系统中往往很有用,比如,应用程序部署在不同的服务器上,它们都运行着相同的业务,如果我们希望某个业务逻辑只在集群中的某一台服务器上运行,就需要选择一台服务器出来作为主服务器。一般情况下,在一个集群中只有一台主服务器(Master 或 Leader),其他的都是从服务器(Slave 或 Follower)。我们刚刚已经在目录节点 /groups 下创建出一堆的成员节点 /member-1member-2 了,那么怎么知道哪个节点才是 Master 呢?

实现方法很简单,和前面一样,我们还是为每个集群成员创建一个 EPHEMERAL 节点,不同的是,它还是一个 SEQUENTIAL 节点,这样我们就可以给每个成员编号,然后选择编号最小的成员作为主服务器。这样做的好处是,如果主服务器下线,这个编号的节点也会被删除,然后通知集群中所有的成员,这些成员中又会出现一个编号是最小的,继而被选择当作新的主服务器。

我们把集群管理的代码稍微改造一下,就可以实现集群选主的功能:

    public void list() {
        try {
            List<String> members = zookeeper.getChildren(this.groupPath, this);
            System.out.println("Members: " + String.join(",", members));
            members.sort(Comparator.naturalOrder());
            if (this.currentNode.equals(this.groupPath + "/" + members.get(0))) {
                System.out.println("I'm the master");
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    public void join(String memberName) {
        try {
            this.currentNode = zookeeper.create(
                    this.groupPath + "/" + memberName, null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL);
            System.out.println("Created: " + this.currentNode);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

我们在创建节点时,选择 CreateMode.EPHEMERAL_SEQUENTIAL 模式,并将创建的节点名称保存下来。使用 getChildren() 方法获取集群成员列表时,按序号排序,取序号最小的一个成员,如果和自己的节点名称一样,则可以认为自己就是主服务器。

上面介绍的这个方法可以动态的选出集群中的主服务器,所以又叫 动态选主,实际上,还有一种 静态选主 的方法,这个方法利用了 ZooKeeper 节点的全局唯一性,如果多个服务器同时创建 /master 节点,最终一定只有一个服务器创建成功,利用这个特性,谁创建成功,谁就是主服务器。这种方法非常简单粗暴,如果对可靠性要求不高,不需要考虑主服务器下线问题,可以考虑采用这种方法。

4.6 分布式锁(Locks)

在单个应用中,锁可以防止多个线程同时访问同一个资源,常用的编程语言都提供了锁机制很容易实现,但是在分布式系统中,要防止多个服务器同时访问同一个资源,就不好实现了。不过在上一节中,我们刚刚介绍了如何使用 ZooKeeper 来做集群选主,可以在多个服务器中选择一个服务器作为主服务器,这和分布式锁要求的多个服务器中只有一个服务器可以访问资源的概念是完全一样的。

我们介绍了两种集群选主的方法,刚好对应锁服务的两种类型:静态选主方法是让所有的服务器同时创建一个相同的节点 lock,最终只有一个服务器创建成功,那么创建成功的这个服务器就相当于获取了一个独占锁。动态选主方法是在某个目录节点 locks 下创建 EPHEMERAL_SEQUENTIAL 类型的子节点,譬如,lock-1lock-2 等,然后调用 getChildren() 方法获取子节点列表,将这些子节点按序号排序,编号最小的即获得锁,同时监听目录节点变化;释放锁就是将该子节点删除即可,那么其他所有服务器都会收到通知,每个服务器检查自己创建的节点是不是序号最小的,序号最小的服务器再次获取锁,依次反复。

我们假设有 100 台服务器试图获取锁,这些服务器都会在目录节点 locks 上监听变化,每次锁的释放和获取,也就是子节点的删除和新增,都会触发节点监听,所有的服务器都会得到通知,但是节点新增并不会发生锁变化,节点删除也只有序号最小的那个节点可以获取锁,其他节点都不会发生锁变化,像这种有大量的服务器得到通知而只有很小的一部分服务器对通知做出响应的现象,有时候又被称为 羊群效应(Herd Effect),这无疑对 ZooKeeper 服务器造成了很大的压力。

为了解决这个问题,我们可以不用关注 locks 目录节点下的子节点变化(删除和新增),也就是说不使用 getChildren() 方法注册节点监听,而是只关注比自己节点小的那个节点的变化,我们通过使用 exists() 方法注册节点监听,这里有一副流程图说明了整个加锁的过程(图片来源):

zookeeper-locks.png

下面是关键代码,完整代码参见这里

    public void lock() {
        try {
            String currentNode = zookeeper.create(
                    this.lockPath + "/lock-", null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL);
            List<String> members = zookeeper.getChildren(this.lockPath, false);
            members.sort(Comparator.naturalOrder());

            // 当前节点序号最小,成功获取锁
            String lowestNode = this.lockPath + "/" + members.get(0);
            if (currentNode.equals(lowestNode)) {
                return;
            }

            // 取序号比自己稍小一点的节点,对该节点注册监听,当该节点删除时获取锁
            String lowerNode = null;
            for (int i = 1; i < members.size(); i++) {
                String node = this.lockPath + "/" + members.get(i);
                if (currentNode.equals(node)) {
                    lowerNode = this.lockPath + "/" + members.get(i-1);
                    break;
                }
            }
            if (lowerNode != null && zookeeper.exists(lowerNode, this) != null) {
                latch.await();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

4.7 栅栏和双栅栏(Barrier & Double Barrier)

栅栏(Barrier) 是用于阻塞一组线程执行的一种同步机制,只有当这组线程全部都准备就绪时,才开始继续执行,就好像赛马比赛,先要等所有的赛马都来到起跑线前准备就绪,然后才能开始比赛。如下图所示:

zookeeper-barrier.png

双栅栏的意思不言而喻,就是两道栅栏,第一道栅栏用于同步一组线程的开始动作,让一组线程同时开始,第二道栅栏用于同步一组线程的结束动作,让它们同时结束,这就好像在赛马比赛中,要等所有的赛马都跑到终点比赛才真正结束一样。

使用 ZooKeeper 实现栅栏很简单,和上面的集群选主和分布式锁类似,都是先创建一个目录节点 /barrier,然后每个线程挨个在这个节点下创建 EPHEMERAL_SEQUENTIAL 类型的子节点,譬如 node-1node-2 等,表示这个线程已经准备就绪,然后调用 getChildren() 方法获取子节点的个数,并设置节点监听,如果节点个数大于等于所有的线程个数,则表明所有的线程都已经准备就绪,然后开始执行后续逻辑。Barrier 的实现可以参考 ZooKeeper 官方的开发者文档

实际上这个算法还可以优化,使用 getChildren() 监听节点存在上文提到的羊群效应(Herd Effect)问题,我们可以在创建子节点时,根据子节点个数是否达到所有线程个数,来单独创建一个节点,譬如 /barrier/enter,表示所有线程都准备就绪,没达到的话就调用 exists() 方法监听 /barrier/enter 节点。这样只有在 /barrier/enter 节点创建时才需要通知所有线程,而不需要每加入一个节点都通知一次。双栅栏的算法可以采用同样的方法增加一个 /barrier/leave 节点来实现。

4.8 队列(Queue)

队列是一种满足 FIFO 规则的数据结构,在分布式应用中,队列经常用于实现生产者和消费者模型。使用 ZooKeeper 实现队列的思路是这样的:首先创建目录节点 /queue,然后生产者线程往该节点下写入 SEQUENTIAL 类型的子节点,比如 node-1node-2 等,由于是顺序节点,ZooKeeper 可以保证创建的子节点是按顺序递增的。消费者线程则是一直通过 getChildren() 方法读取 /queue 节点的子节点,取序号最小的节点(也就是最先入队的节点)进行消费。这里我们要注意的是,消费者首先需要调用 delete() 删除该节点,如果有多个线程同时删除该节点,ZooKeeper 的一致性可以保证只会有一个线程删除成功,删除成功的线程才可以消费该节点,而删除失败的线程通过 getChildren() 的节点监听继续等待队列中新元素。

总结

通过这篇文章我们学习了 ZooKeeper 的基本知识,可以使用命令行对 ZooKeeper 进行管理和监控,并实现了 ZooKeeper 一些常见的功能。实际上 ZooKeeper 提供的机制非常灵活,除了本文介绍的几种常用应用场景,ZooKeeper 能实现的功能还有很多,可以参考 ZooKeeper Recipes and SolutionsApache Curator Recipes

本文介绍的 ZooKeeper 功能都是基于官方提供的原生 API org.apache.zookeeper 来实现的,但是原生的 API 有一个问题,就是太底层了,不方便使用,而且很容易出错。因此 Netflix 的 Jordan Zimmerman 开发了 Curator 项目,并在 GitHub 上采用 Apache 2.0 协议开源了。在生产环境推荐直接使用 Curator 而不是原生的 API,可以大大简化 ZooKeeper 的开发流程,可以参考 Apache Curator Getting Started

本文偏重 ZooKeeper 的实践,通过本文的学习,对工作中遇到的常见场景应该基本能应付了。不过这篇文章缺少对其原理的深入分析,比如 ZooKeeper 的一致性是如何保证的,ZAB 协议和 Paxos 协议,恢复模式(选主)和广播模式(同步)是如何工作的等等,这些后面还需要继续学习。

参考

  1. Apache ZooKeeper documentation
  2. Zookeeper集群部署
  3. ZooKeeper: Because Coordinating Distributed Systems is a Zoo
  4. Programming with ZooKeeper - A basic tutorial
  5. ZooKeeper Recipes and Solutions
  6. 分布式服务框架 Zookeeper -- 管理分布式环境中的数据
  7. ZooKeeper介绍及典型使用场景
  8. 中小型研发团队架构实践:分布式协调服务ZooKeeper
  9. ZooKeeper基础知识
  10. Zookeeper 入门
  11. ZooKeeper命令行操作
  12. 关于命名服务的知识点都在这里了
  13. 分布式系统阅读笔记(十三)-----命名服务
扫描二维码,在手机上阅读!

学习 Java 的调试技术

在软件开发的过程中,可以说调试是一项基本技能。调试的英文单词为 debug ,顾名思义,就是去除 bug 的意思。俗话说的好,编程就是制造 bug 的过程,所以 debug 的重要性毋庸置疑,如果能熟练掌握调试技能,也就可以很快的定位出代码中的 bug。要知道,看的懂代码不一定写的出代码,写的出代码不一定能调试好代码,为了能写出没有 bug 的代码,我们必须得掌握一些基本的调试技巧。

工欲善其事,必先利其器。无论你的开发工具是 IntelliJ IDEA 还是 Eclipse ,调试器都是标配。在遇到有问题的程序时,合理的利用调试器的跟踪和断点技巧,可以很快的定位出问题原因。虽然说合理利用日志也可以方便定位线上问题,但是日志并不是调试工具,不要在开发环境把 System.out.println 当作调试手段,掌握调试器自带的调试技能才是正道。

一、实战 IDEA 调试技巧

如果你是做 Java 开发的,相信你不会没有听过 IntelliJ IDEA ,和大多数 Java 开发者一样,我一开始的时候也是用 Eclipse 来进行开发,但是自从换了 IDEA 之后,就再也离不开它了,彻底变成了 IDEA 的忠实粉丝(不好意思,打一波广告。。)。不得不说,JetBrains 这家来自捷克的软件公司真的是良心企业,所出产品皆是精品,除了 IDEA,还有 WebStorm,PhpStorm,PyCharm 等,风格都是很类似的,一些类似的快捷键包括调试技巧也是通用的。

打开 IDEA 的调试面板,如下图所示,可以大致的将其分成五个部分:

  • 单步跟踪
  • 断点管理
  • 求值表达式
  • 堆栈和线程
  • 变量观察

idea-debugging.png

1.1 单步跟踪

说起调试,估计很多人第一反应就是对程序进行一步一步的跟踪分析,其实 IDEA 提供了很多快捷键来帮助我们跟踪程序,大抵可以列出下面几个技巧:

  • Show Execution Point

调试时往往需要浏览代码,对代码进行分析,有时候在浏览若干个源文件之后就找不到当前执行到哪了,可能很多人会使用 Navigate Back 来返回,虽然也可以返回去,但可能需要点多次返回按钮,相对来说使用这个技巧快速定位到当前调试器正在执行的代码行要更简便。

  • Step Over

这是最基本的单步命令,每一次都是执行一行代码,如果该行代码有方法会直接跳过,可以说真的是一步一个脚印。

  • Step In / Force Step In

Step Over 会跳过方法的执行,可以观察方法的返回值,但如果需要进到方法里面,观察方法的执行细节,则需要使用 Step In 命令了。另外,Step In 命令也会跳过 jdk 自带的系统方法,如果要跟踪系统方法的执行细节,需要使用 Force Step In 命令。关于单步的时候忽略哪些系统方法,可以在 IDEA 的配置项 Settings -> Build, Execution, Deployment -> Debugger -> Stepping 中进行配置,如下图所示。

idea-step-skip.png

  • Step Out

当使用 Step In 命令跟踪到一个方法的内部时,如果发现自己不想继续调这个方法了,可以直接把这个方法执行完,并停在调用该方法的下一行位置,这就是 Step Out 命令。

  • Drop to Frame

这一招可以说是调试器的一大杀器。在单步调试的时候,如果由于粗心导致单步过了头,没有看到关键代码的执行情况,譬如想定位下某个中间变量的值,这个时候如果能回到那行关键代码再执行一遍就好了,Drop to Frame 就提供了我们这个能力,它可以回到方法调用的地方(跟 Step Out 不一样,Step Out 是回到方法调用的下一行),让我们可以再调试一次这个方法,这一次可不要再粗心了。

Drop to frame 的原理其实也非常简单,顾名思义,它将堆栈的最上面一个栈帧删除(也就是当前正在执行的方法),让程序回到上一个栈帧(父方法),可以想见,这只会恢复堆栈中的局部变量,全局变量无法恢复,如果方法中有对全局变量进行操作的地方,是没有办法再来一遍的。

  • Run to Cursor / Force Run to Cursor

这两个命令在需要临时断点时非常有用,譬如已经知道自己想分析哪一行代码了,但又不需要下很多无谓的断点,可以直接使用该命令执行到某行,Force Run to Cursor 甚至可以无视所有断点,直接到达我们想分析的地方。

1.2 断点管理

断点是调试器的基础功能之一,可以让程序暂停在需要的地方,帮助我们进行分析程序的运行过程。在 IDEA 中断点管理如下图所示,合理使用断点技巧可以快速让程序停在我们想停的地方:

idea-breakpoints.png

可以将断点分成两种类型:行断点指的是在特定的某行代码上暂停下来,而全局断点是在某个条件满足时停下来,并不限于停在固定的某一行,譬如当出现异常时暂停程序。

1.2.1 行断点

  • Suspend (All / Thread)
  • Condition

条件断点。这应该也是每个使用调试器的开发者都应该掌握的一个技巧,当遇到遍历比较大的 List 或 Map 对象时,譬如有 1000 个 Person 对象,你不可能每个对象都调一遍,你可能只想在 person.name = 'Zhangsan' 的时候让断点断下来,就可以使用条件断点,如下图所示:

idea-breakpoints-condition.png

  • Log message to console
  • Evaluate and log

当看到上面的 Suspend 这个选项的时候有的人可能会感到奇怪,我下一个断点不就是为了让程序停下来吗?还需要这个选项干什么?是不是有点多余?难道你下个断点却不想让程序停下来?在发现 Evaluate and log 这个技巧之前,我对这一点也感觉很奇怪,直到有一天我突然发现 Suspend Off + Evaluate and log 的配合真的是太有用了。前面有讲过,不要把 System.out.println 当作调试手段,因为你完全可以用这个技巧来打印所有你想打印的信息,而不需要修改你的源代码。

  • Remove once hit

一次性断点。上面介绍的 Run to Cursor 就是一次性断点的例子。

  • Instance filters
  • Class filters
  • Pass count

这几个我用的不是很多,但应该也是非常有用的技巧可以先记下来。在 IDEA 里每个对象都有一个实例ID,Instance filters 就是用于当断点处代码所处的实例和设定ID匹配则断下来。Pass count 则是在断点执行到第几次的时候暂停下来。

1.2.2 全局断点

  • Exception breakpoints
  • Method breakpoints
  • Field watchpoints

个人感觉这几个技巧都不是很常用,感兴趣的同学自己实验一把吧。

1.3 求值表达式

在一堆单步跟踪的按钮旁边,有一个不显眼的按钮,这个按钮就是 “求值表达式”。它在调试的时候很有用,可以查看某个变量的值,也可以计算某个表达式的值,甚至还可以计算自己的一段代码的值,这分别对应下面两种不同的模式:

  • 表达式模式(Expression Mode)
  • 代码片段模式(Code Fragment Mode)

这两个模式类似于 Eclipse 里面的 Expression View 和 Display View。在 Display View 里也可以编写一段代码来执行,确实非常强大,但是要注意的是,这里只能写代码片段,不能自定义方法,如下图:

idea-evaluate-code-fragment.png

1.4 堆栈和线程

这个没什么好说的,一个视图可以查看当前的所有线程,另一个视图可以查看当前的函数堆栈。在线程视图里可以进行 Thread dump,分析每个线程当前正在做什么;堆栈视图里可以切换栈帧,结合右边的变量观察区,可以方便的查看每个函数里的局部变量和参数。

  • 线程视图
  • 堆栈视图

idea-threads.png

1.5 变量观察

变量区和观察区可以合并在一起,也可以分开来显示(如下图所示),我比较喜欢分开来显示,这样局部变量、参数以及静态变量显示在变量区,要观察的表达式显示在观察区。观察区类似于求值表达式中的 Expression mode,你可以添加需要观察的表达式,在调试的时候可以实时的看到表达式的值。变量区的内容相对是固定的,随着左边的栈帧调整,值也会变得不同。在这里还可以修改变量原有的值。

idea-variables.png

二、使用 jdb 命令行调试

相信很多人都听过 gdb,这可以说是调试界的鼻祖,以前在学习 C/C++ 的时候,就是使用它来调试程序的。和 gdb 一样,jdb 也是一个命令行版的调试器,用于调试 Java 程序。而且 jdb 不需要安装下载,它是 JDK 自带的工具(在 JDK 的 bin 目录中,JRE 中没有)。

每研究一项新技术,我总是会看看有没有命令行版本的工具可以替代,在命令行下进行操作给人一种踏实的感觉,每一个指令,每一个参数,都清清楚楚的摆在那里,这相比较于图形界面的工具,可以学习更深层的知识,而不是把技术细节隐藏在图形界面之后,你可以发现命令行下的每一个参数,每一个配置,都是可以学习的点。

2.1 jdb 基本命令

在 jdb 中调试 Java 程序如下图所示,直接使用 jdb Test 命令加载程序即可。

jdb-debugging.png

运行完 jdb Test 命令之后,程序这时并没有运行起来,而是停在那里等待进一步的命令。这个时候我们可以想好在哪里下个断点,譬如在 main() 函数处下个断点,然后再使用 run 命令运行程序:

> stop in Test.main
正在延迟断点Test.main。
将在加载类后设置。
> run
运行Test
设置未捕获的 java.lang.Throwable
设置延迟的未捕获的 java.lang.Throwable
>
VM 已启动:设置延迟的断点:Test.main

可以看出在执行 run 命令之前,程序都还没有开始运行,这个时候的断点叫做“延迟断点”,当程序真正运行起来时,也就是 JVM 启动的时候,才将断点设置上。除了 stop in Class.Method 命令,还可以使用 stop at Class:LineNumber 的方式来设置断点。

main[1] stop at Test:25

在 jdb 中下断点,就没有 IDEA 中那么多名堂了,什么条件断点,什么 Instance filters 都不支持,只能乖乖的一步一步来。在断点处,可以使用 list 命令查看断点附近的代码,或者用 step 命令单步执行,print 或者 dump 打印变量或表达式的值,locals 命令查看当前方法中的所有变量,cont 命令继续执行代码。还有一些其他的命令就不多做介绍了,可以使用 help 查看所有的命令清单,或者参考 jdb 的官方文档

2.2 探索 class 文件结构

在 jdb 中调试 Java 程序时,有可能源代码文件和 class 文件不在一起,这个时候需要指定源码位置:

# jdb -sourcepath path/to/source Test

如果不指定源码位置,在使用 list 命令时会提示找不到源码文件,如果真的没有源码文件,那么在 jdb 里完全就是瞎调了。我们知道 Java 代码在执行的时候,是以字节码的形式运行在 JVM 里的,可以猜测到,class 文件中必然有着和源码相关联的一些信息,类似于 C/C++ 语言的 obj 文件一样,要不然 list 命令怎么可以显示出当前正在执行的代码是哪一行呢?我们可以使用开源的 jclasslib 软件查看 class 文件里的内容,一个标准的 class 文件包含了下面这些信息:

  • 基本信息
  • 常量池
  • 接口
  • 属性
  • 父类
  • 字段
  • 方法

    • Code 属性

      • 行号属性
      • 局部变量表

如下图所示,其中最重要的一个部分就是 Code 属性,Code 属性下面有行号属性 LineNumberTable,这个 LineNumberTable 就是调试器用来关联字节码和源代码的关键。关于 class 文件,可以参考这篇文章《深入理解 Java 虚拟机》的学习笔记

dis-class-file.png

题外话:没有源码时如何调试?

如果没有源码,虽然在 jdb 里也可以用 step 来单步,但是没有办法显示当前正在运行的代码,这简直就是盲调。这个时候只能使用字节码调试工具了,常见的字节码调试器有:Bytecode VisualizerJSwat DebuggerJava ByteCode Debugger (JBCD) 等等,参考这里

三、关于远程调试

通过对 jdb 的学习,我们已经越来越接近 Java 调试器的真相了,但是还差最后一步。让我们先看看 Java 程序在 IDEA 里是如何被调试的,如果你有很强的好奇心,那么在 IDEA 里调试程序的时候可能已经发现了下面的秘密:

idea-debug-java.png

或者在调试 tomcat 的时候,也有类似的一串仿佛魔咒一般的参数:

idea-debug-tomcat.png

这串魔咒般的参数像下面这样,一旦你理解了这串参数,你也就打破了 Java 调试器的魔咒,然后才能认识到 Java 调试器真正的面目:

"C:\Program Files\Java\jdk1.8.0_111\bin\java" -agentlib:jdwp=transport=dt_socket,address=127.0.0.1:20060,suspend=y,server=n Foo
Connected to the target VM, address: '127.0.0.1:20060', transport: 'socket'

这里面有两个关键点:

  1. Java 程序在运行的时候带着 -agentlib 参数,这个参数用于指示 JVM 在启动时额外加载的动态库文件,-agentlib 参数和 -javaagent 不一样,这个库文件应该是由 C/C++ 编写的原生程序(JNI),类似于这里的 jdwp,在 Windows 上对应一个 jdwp.dll 库文件,在 Linux 上对应 jdwp.so 文件。那么这个 jdwp 库文件到底是做什么的呢?它后面的一串参数又是什么意思?
  2. jdwp 的参数里貌似提到了 socket,并有 address=127.0.0.1:20060 这样的 IP 地址和端口号,而且下面的 Connected to the target VM 也似乎表示调试器连接到了这么一个网络地址,那么这个地址到底是什么呢?由于这里是本地调试,所以 IP 地址是 127.0.0.1 ,那么如果是远程调试的话,是不是这里也是支持的呢?

在 IDEA 的 Run/Debug Configuration 配置页面,你也可以添加一个远程调试,界面如下图,可以发现上面那串魔咒参数又出现了:

idea-remote-debugging.png

在真正开始远程调试之前,我们不妨带着这些疑问,来学习 Java 调试器的基本原理。

四、Java 调试原理及 JPDA 简介

在武侠世界里,天下武功可以分为两种:一种讲究招式新奇,出招时能出其不意,善于利用兵器的特性和自身的高妙手法攻敌不备;另一种讲究内功心法,就算是最最普通的招式,结合自身的深厚内力,出招时也能有雷霆之势。其实在技术的世界里,武功也可以分为两种:技巧和原理。上面讲的那么多,无论你是掌握了 IDEA 的所有调试技巧也好,还是记熟了 jdb 的所有命令也好,都只是招式上的变化,万变不离其宗,我们接下来就看看调试器的内功心法。

4.1 JPDA

我们知道,Java 程序都是运行在 JVM 上的,我们要调试 Java 程序,事实上就需要向 JVM 请求当前运行态的状态,并对 JVM 发出一定的指令,或者接受 JVM 的回调。为了实现 Java 程序的调试,JVM 提供了一整套用于调试的工具和接口,这套接口合在一起我们就把它叫做 JPDA(Java Platform Debugger Architecture,Java 平台调试体系)。这个体系为开发人员提供了一整套用于调试 Java 程序的 API,是一套用于开发 Java 调试工具的接口和协议。本质上说,它是我们通向虚拟机,考察虚拟机运行态的一个通道,一套工具。

JPDA 由三个相对独立的层次共同组成,而且规定了它们三者之间的交互方式。这三个层次由低到高分别是 Java 虚拟机工具接口(JVMTI),Java 调试线协议(JDWP)以及 Java 调试接口(JDI),如下图所示(图片来自 IBM developerWorks):

jpda.jpg

这三个模块把调试过程分解成几个很自然的概念:调试者(debugger)和被调试者(debuggee),以及他们中间的通信器。被调试者运行于我们想调试的 Java 虚拟机之上,它可以通过 JVMTI 这个标准接口,监控当前虚拟机的信息;调试者定义了用户可使用的调试接口,通过这些接口,用户可以对被调试虚拟机发送调试命令,同时调试者接受并显示调试结果。在调试者和被调试者之间,调试命令和调试结果,都是通过 JDWP 的通讯协议传输的。所有的命令被封装成 JDWP 命令包,通过传输层发送给被调试者,被调试者接收到 JDWP 命令包后,解析这个命令并转化为 JVMTI 的调用,在被调试者上运行。类似的,JVMTI 的运行结果,被格式化成 JDWP 数据包,发送给调试者并返回给 JDI 调用。而调试器开发人员就是通过 JDI 得到数据,发出指令。详细的内容,可以参考 IBM developerWorks 上的一篇系列文章 《深入 Java 调试体系》

4.2 Connectors & Transport

到这里,我们已经知道了 jdwp 是调试器和被调试程序之间的一种通信协议。不过命令行参数 -agentlib:jdwp=transport=dt_socket,address=127.0.0.1:20060,suspend=y,server=n 中的 jdwp 貌似还不止于此。事实上,这个地方上 jdwp.dll 库文件把 JDI,JDWP,JVMTI 三部分串联成了一个整体,它不仅能调用本地 JVMTI 提供的调试能力,还实现了 JDWP 通信协议来满足 JVMTI 与 JDI 之间的通信。想要完全理解这串参数的意思,我们还需要学习两个概念:Connectors(连接器)和 Transport(传输)。

常见的连接器有下面 5 种,它指的是 JDWP 建立连接的方式:

  • Socket-attaching connector
  • Shared-memory attaching connector
  • Socket-listening connector
  • Shared-memory listening connector
  • Command-line launching connector

其中 attaching connector 和 listening connector 的区别在于到底是调试器作为服务端,还是被调试程序作为服务端。
传输指的是 JDWP 的通信方式,一旦调试器和被调试程序之间建立起了连接,他们之间就需要开始通信,目前有两种通信方式:Socket(套接字) 和 Shared-memory(共享内存,只用在 Windows 平台)。

4.3 实战远程调试

通过上面的学习我们了解到,Java 调试器和被调试程序是以 C/S 架构的形式运行的,首先必须有一端以服务器的形式启动起来,然后另一段以客户端连接上去。如果被调试程序以服务端运行,必须加上下面的命令行参数(关于 jdwp 参数,可以参考 JavaSE 的文档):

# java -agentlib:jdwp=transport=dt_socket,server=y,address=5005 Test
# java -agentlib:jdwp=transport=dt_shmem,server=y,address=javadebug Test

第一句是以 socket 通信方式 来启动程序,第二句是以 共享内存 的方式来启动程序,socket 方式需要指定一个端口号,调试器通过该端口号来连接它,共享内存方式需要指定一个连接名,而不是端口号。在程序运行起来之后,可以使用 jdb 的 -attach 参数将调试器和被调试程序连接起来:

# jdb -attach 5005
# jdb -attach javadebug

在 Windows 平台上,第一条命令会报这样的错:java.io.IOException: shmemBase_attach failed: The system cannot find the file specified,这是由于 jdb -attach 使用系统默认的传输来建立连接,而在 Windows 上默认的传输是 共享内存 。要想在 Windows 上使用 socket 方式来连接,要使用 jdb -connect 命令,只是命令的参数在写法上不太好记忆:

# jdb -connect com.sun.jdi.SocketAttach:hostname=localhost,port=5005
# jdb -connect com.sun.jdi.SharedMemoryAttach:name=javadebug

如果反过来,想让调试器以服务端运行,执行下面的命令:

# jdb -listen javadebug

然后 Java 程序通过下面的参数来连接调试器:

# java -agentlib:jdwp=transport=dt_shmem,address=javadebug, suspend=y Test
# java -agentlib:jdwp=transport=dt_socket,address=127.0.0.1:5005,suspend=y,server=n

最后我们再回过头来看一看 IDEA 打印出来的这串魔咒参数,可以大胆的猜测,IDEA 在调试的时候,首先以服务器形式启动调试器,并在 20060 端口监听,然后 Java 程序以 socket 通信方式连接该端口,并将 JVM 暂停等待调试。

"C:\Program Files\Java\jdk1.8.0_111\bin\java" -agentlib:jdwp=transport=dt_socket,address=127.0.0.1:20060,suspend=y,server=n Foo
Connected to the target VM, address: '127.0.0.1:20060', transport: 'socket'

如果在 IDEA 下进行远程调试,可以参考 IBM developerWorks 上的另一篇与调试相关的主题:使用 Eclipse 远程调试 Java 应用程序

总结

这篇文章首先介绍了 IDEA 的一些常用调试技巧,然后通过使用 jdb 进行 Java 程序的调试,学习了 jdb 的常用命令,最后通过远程调试引出调试器原理的话题,对 JPDA、JVMTI、JDWP、JDI 等概念有了一个初步的认识。从招式到心法,由技巧到原理,逐步揭开了 Java 调试器的神秘面纱。对于开发人员来说,如果只懂招式,只会一些奇淫技巧,那么他只是把工具用得更得心应手而已,很难在技术上得到质的突破;而如果只懂心法,只沉浸于基本原理和理论,那么他只能做一个眼高手低的学院派,空有满腹大道理却无用武之地。我们更应该内外兼修,把招式和心法结合起来,融会贯通,方能成正果。

最后的最后,关于调试的话题不得不补充一句:调试程序是一个费时费力的过程,一旦需要调试来定位问题,说明代码的逻辑性和清晰性有问题,最好的代码是不需要调试的。所以,少一点调试,多一点单元测试,多一点重构,将代码写的更清晰才是最好的编程方式。

番外篇:关于调试器的测不准效应

在量子物理学中,有一个名词叫 测不准原理,又叫 不确定性原理,讲的是粒子的位置与动量不可同时被确定,位置的不确定性越小,则动量的不确定性越大,反之亦然。说白点就是,你如果要很准确的测量粒子的位置,那么就不能准确的测量粒子的动量;如果要很准确的测量粒子的动量,那么粒子的位置就测不准;正是由于测量本身,会导致系统受影响。

把这个现象套在调试器领域里,也有着类似的效果。由于调试器本身的干扰,程序已经不是以前的程序了。所以问题来了,在调试器下运行出来的结果,真的可信吗?下面是我想出来的一个有趣的例子,假设我们在第 4 行下一个断点,程序最后输出结果会是什么呢?

debugger-reflactor.png

参考

  1. IntelliJ IDEA 13 debug调试细节
  2. 你所不知道的Android Studio调试技巧
  3. Eclipse 的 Debug 介绍与技巧
  4. 使用Eclipse调试Java程序的10个技巧
  5. JDB 的简单使用
  6. 《深入理解Java虚拟机》读书笔记4:类文件结构
  7. 使用 Eclipse 平台进行调试
  8. Java .class bytecode debugger
  9. Java调试——回归基础
  10. JVM源码分析之javaagent原理完全解读
  11. 使用 Eclipse 远程调试 Java 应用程序
  12. 深入 Java 调试体系,第 1 部分,JPDA 体系概览
  13. 深入 Java 调试体系,第 2 部分,JVMTI 和 Agent 实现
  14. 深入 Java 调试体系,第 3 部分,JDWP 协议及实现
  15. 深入 Java 调试体系,第 4 部分,Java 调试接口(JDI)
  16. Java Tool Tutorials - Herong's Tutorial Notes
  17. Java调试那点事
  18. 如何编写属于自己的Java / Scala的调试器
  19. jdb fails to connect to running java application over sockets
  20. Connection and Invocation Details
  21. Attach Intellij-IDEA debugger to a running java process
扫描二维码,在手机上阅读!

修改 GRUB 文本模式的分辨率

最近将系统升级到了 Ubuntu 15.04 ,它的开机过程由 GRUB2 引导,我很喜欢将开机过程设置成文本模式,这样可以很清楚的看到它开机的时候都在干什么。设置文本模式其实很简单,只需打开 /etc/default/grub 文件,修改 GRUB_CMDLINE_LINUX_DEFAULT 参数为 text 即可:

#GRUB_CMDLINE_LINUX_DEFAULT="quiet splash"
GRUB_CMDLINE_LINUX_DEFAULT="text"

修改完后记得使用 update-grub2 更新:

sudo update-grub2

但是在重启的时候我们会发现文本模式的分辨率非常低(看上去应该是 640x480),字体显得非常大,看起来很不爽。Google 后发现很多人讲到这个问题,但是网上的很多信息已经过时了甚至是错误的,特此记录一下。

其实 GRUB 是支持设置文本分辨率的,也就是所谓的 framebuffer resolution ,这篇文章介绍了如何设置 framebuffer resolution 的技巧。设置方法是在 /etc/default/grub 文件中添加如下两行代码:

GRUB_GFXMODE=1024x768x32
GRUB_GFXPAYLOAD_LINUX=keep

注意的是,显卡需要支持 VBE (VESA BIOS Extensions),设置的分辨率必须在 VBE 允许的范围内。在老版本的 Ubuntu 中可以通过 hwinfo 来查看显卡支持的分辨率,但是 Ubuntu 15.04 中貌似已经废弃掉该工具了。可以通过重启机器,长按 Shift 键进入 GRUB 菜单,而后按 C 键进入 GRUB 命令行,使用 GRUB 自带的 vbeinfo 命令来查看你的显卡所有支持的分辨率。

另外网上有些资料说通过设置 GRUB_GFXMODEGRUB_GFXPAYLOAD 两个参数来修改分辨率,但是经过我的测试似乎没有效果, GRUB_GFXPAYLOAD 这个参数在 Ubuntu 15.04 中也已经弃用了,所以应该使用 GRUB_GFXPAYLOAD_LINUX 参数。

参考

  1. How do I increase console-mode resolution?
  2. How to set the resolution in text consoles (troubleshoot when any `vga=…` fails)
  3. GRUB/Tips and tricks
扫描二维码,在手机上阅读!

在 Windows 命令行下显示目录的大小

我们知道在 Linux 系统下使用 du 命令可以很方便的查看某个目录的大小,甚至也可以列出某个目录下的所有子目录的大小。这在查找大文件时非常方便,因为有时候我们会遇到这种情况,譬如,磁盘空间快满了,我们知道 /home/apps 目录非常大,而这个目录下面又有着几十个不同的子目录,我们希望能知道每个子目录的大小以方便我们找到是哪个目录最占空间,那么怎么能快速找到最占空间的子目录呢?

在 Linux 系统下,我们使用下面的 du 命令显示当前目录的总大小:

du -sh .

也可以像下面这样,显示当前目录下的所有一级子目录的大小:

du -h --max-depth=0 .

可以看到在 Linux 下是非常方便的,而在 Windows 下就没有原生的工具可以很方便的实现这一点了。Windows Sysinternals Suite 提供了一个类似于 Linux 下的 du 命令的小工具 Disk Usage,命令的语法稍微有些不同,你可以查看下这个工具的使用文档。

借助外界的工具肯定是可以实现这个功能的,但是也可以直接在 Windows 命令行下不依赖于第三方工具来实现,譬如,使用下面的 PowerShell 命令:

Get-ChildItem -Recurse | Measure-Object -Sum Length

Get-ChildItem 命令用于遍历目录下的所有子目录和文件,类似于 dir 命令,使用 -Recurse 参数可以实现递归遍历。
Measure-Object 命令常作用于管道,对管道的结果进行统计操作,譬如:计数、求和、平均数、最大数、最小数等等。

PowerShell 的命令总给人一种怪怪的感觉,不过它也提供了简写的语法:

ls -r | measure -s Length

看起来比上面的要舒服多了。或者直接在命令行 cmd 下执行:

powershell -noprofile -command "ls -r | measure -s Length"

如果不习惯 PowerShell 这种重量级的命令,也可以直接在命令行 cmd 下使用 for 命令实现,不过要借助一个中间变量,譬如将下面的代码复制到一个批处理文件中:

@echo off
set size=0
for /r %%x in (folder\*) do set /a size+=%%~zx
echo %size% Bytes

在 Windows 命令行下,for绝对是最复杂的命令,没有之一。让我们来解析下上面的那句命令:

for /r 表示递归的遍历一个目录下的所有文件。它的语法是这样:FOR /R [[drive:]path] %%parameter IN (set) DO command,所以其中的 %%x 是我们定义的一个参数,表示目录下的某个文件。注意,在批处理文件中必须要使用两个%%,如果是在命令行下尝试该命令的话,则只需要一个%就可以了。
do 之后的部分是我们针对每个参数(在这里也就是对每个文件)执行的操作。set 命令可以用于显示、设置或删除某个变量的值,set /a 用于对变量进行数学表达式运算(arithmetic expressions),在这里我们使用 += 来对文件大小进行累加。
最后一个是 %%~zx ,这里的 %%x 是就是上面的 x 参数,但是中间添加了 ~z 这样的特殊符号,这被称为 参数扩展(Parameter Extensions),表示对应的文件大小,另外还有很多其他有用的扩展,如 ~n 表示不带扩展的文件名,~x 表示文件的扩展名,~t 表示文件的时间 等等。和参数扩展类似的,还有两个与字符串变量相关的操作:字符串替换(Variable Replace) 和 字符串截取(Variable Substring),在 Windows 批处理中经常会遇到,也可以一起了解下。

不过要特别注意的是,在 Windows 的 cmd 下面,数字类型为 32 位的符号整型,所以最多支持到 2GB 大小的目录,超出 2GB 的结果可能会变成负数。所以最好的做法还是使用上面的 PowerShell 命令。

参考

  1. du 命令
  2. Windows command line get folder size
  3. CMD命令行高级教程精选合编
  4. For - Looping commands | Windows CMD
  5. For /R - Loop through sub-folders | Windows CMD
  6. Set - Environment Variable | Windows CMD
  7. Parameters / Arguments | Windows CMD
  8. Variable substring | Windows CMD
  9. CMD Variable edit replace | Windows CMD
扫描二维码,在手机上阅读!

git clone 太慢怎么办?

Git 和 GitHub 的出现打开了开源世界的另一扇大门,版本控制变得更强大(也更复杂),项目的管理变得更加容易,项目的开发也变得更容易进行多人协作。GitHub 无疑是程序员的 Facebook ,在这里汇聚了无数世界顶级的项目以及顶级的程序员,你可以为你感兴趣的项目加星(Star),可以关注任何人(Follow)以及他们的项目(Watch),而且更赞的是,你可以复制一份别人项目的副本(Fork),来进行自己的修改,如果你愿意的话,你还可以向项目的原作者发起请求(Pull Request),将你做的修改合并到原项目中。这样无论你是什么人,来自不同的国家,拥有不同的技能,都可以对所有开源的项目作出贡献。

尽管上面描述的开源世界如此美好,但是在大天朝,在墙内,你却完全无法领略。因为当你访问 GitHub 时,或者使用 git clone 兴致勃勃的下载你感兴趣的项目时,巨慢的速度将彻底击毁你的信心,最终只好放弃表示玩不起。

git-slower.png

强大的长城技术对 GitHub 网开一面,没有像 Google 或 Facebook 这样直接斩尽杀绝,但是对它做了严格的限速,这种折磨比直接毙了更痛苦(有网友表示,有些地区速度很快,有些地区速度很慢,也有可能是和网络运营商有关)。如上图所示,git clone 的下载速度从来没有超过 10KiB/s ,这也就意味着一个 100MiB 的项目,需要近三个小时才能下完,而且由于网络的不稳定性,下载过程中偶尔会出现断开连接的情况,由于 git clone 不支持断点续传,这让几个小时的下载时间白白浪费掉,只能重新开始。

这篇文章将介绍几种方法来快速从 GitHub 上下载代码。

一、git shallow clone

git clone 默认会下载项目的完整历史版本,如果你只关心最新版的代码,而不关心之前的历史信息,可以使用 git 的浅复制功能:

$ git clone --depth=1 https://github.com/bcit-ci/CodeIgniter.git

--depth=1 表示只下载最近一次的版本,使用浅复制可以大大减少下载的数据量,例如,CodeIgniter 项目完整下载有近 100MiB ,而使用浅复制只有 5MiB 多,这样即使在恶劣的网络环境下,也可以快速的获得代码。如果之后又想获取完整历史信息,可以使用下面的命令:

$ git fetch --unshallow

或者,如果你只是想下载最新的代码看看,你也可以直接从 GitHub 上下载打包好的 ZIP 文件,这比浅复制更快,因为它只包含了最新的代码文件,而且是经过 ZIP 压缩的。但是很显然,浅复制要更灵活一点。

二、GUI 工具 + 代理

如果很有幸你正在使用代理,懂得如何翻墙的话,那么访问 GitHub 对你来说应该不在话下。下载 GitHub 上项目的最简单的方法就是使用一款图形化界面(GUI)的 Git 工具,这样的工具现在比比皆是。使用 GUI 工具方便的地方在于,可以在设置中配置是否要使用代理,将你翻墙所使用的代理 IP 拿过来配置上就 OK 了,或者更直接的,将代理配置为系统代理。

三、git + http.proxy

如果你跟我一样,喜欢使用原生的 git 命令,喜欢在命令行下操作的那种感觉,那么也可以在命令行下直接配置 git 使用代理,当然前提一样是,你懂得如何翻墙。

$ git config --global http.proxy http://proxyuser:proxypwd@proxy.server.com:8080
$ git config --global https.proxy https://proxyuser:proxypwd@proxy.server.com:8080

使用上面的命令配置完之后,会在 ~/.gitconfig 文件中多出几行:

[http]
    proxy = http://proxyuser:proxypwd@proxy.server.com:8080
[https]
    proxy = https://proxyuser:proxypwd@proxy.server.com:8080

你也可以使用下面的命令检查配置是否生效:

$ git config --global --get http.proxy
$ git config --global --get https.proxy

另外,如果你想取消该设置,可以:

$ git config --global --unset http.proxy
$ git config --global --unset https.proxy

配置完成后,重新 clone 一遍,可以看到速度得到了极大的提升!

git-faster.png

题外话:在命令行中如何使用代理?

要注意的是使用 git config --global 配置的代理只能供 git 程序使用,如果你希望让命令行中的其他命令也能自动使用代理,譬如 curl 和 wget 等,可以使用下面的方法:

$ export http_proxy=http://proxyuser:proxypwd@proxy.server.com:8080
$ export https_proxy=https://proxyuser:proxypwd@proxy.server.com:8080

这样配置完成后,所有命令行中的 HTTP 和 HTTPS 请求都会自动通过代理来访问了。如果要取消代理设置,可以:

$ unset http_proxy
$ unset https_proxy

还有一点要注意的是,使用 http_proxy 和 https_proxy 只对 HTTP 和 HTTPS 请求有效,所以当你 ping www.google.com 的时候如果 ping 不通的话,也就没什么大惊小怪的了。

题外话:如何使用 PAC 文件?

有时候我们会使用 git 访问不同的 git 仓库,譬如 GitHub,或者 Git@OSC, 或者你自建的 Git 服务器,但是只想访问 GitHub 的时候使用代理,访问其他的仓库不要使用代理。这时候我们似乎可以使用 PAC 来解决这个问题。PAC (代理自动配置)正是用于浏览器来根据不同的 URL 自动采用不同的代理的一项技术,该文件包含一个 FindProxyForURL Javascript 函数,用于根据 URL 来返回不同的代理。

但是遗憾的是,目前 git 似乎还不支持 PAC 文件,但我们可以打开 PAC 文件找到代理的地址,然后通过上面的方法来配置或取消配置,只是有些繁琐。也许可以写个脚本来解析 PAC 文件,并将 git 包装下,来实现自动切换代理,有机会尝试下。

四、其他方法

网上还提供了很多其他的方法,但是我暂未尝试过,且记录一下:

  • ssh tunnel 或者 shadowsocks
  • ss + proxychains
  • 使用境外的 VPS
  • powerpac
  • VPN

参考

  1. git clone 太慢怎么办?
  2. How do I pull from a Git repository through an HTTP proxy?
  3. Getting git to work with a proxy server
  4. 代理自动配置 - 维基百科
扫描二维码,在手机上阅读!

我的第二个Chrome扩展:JSONView增强版

JSONView是一款非常棒的查看JSON格式数据的Chrome扩展,可以从Chrome的WebStore下载,地址在这里。该扩展一开始是在FireFox中流行起来,支持JSON和JSONP两种格式,并能使用JSONLint对JSON数据格式进行校验。官方效果图如下:

jsonview.png

尽管这个扩展小巧强大,很Sexy,不过也有些不足。个人感觉最大的不足就是无法处理用户自己输入的JSON数据,由于这个扩展是后台静默运行,所以对于很多第一次使用的人来说可能根本就不知道怎么用,看WebStore上的评论就可以看出这一点,很多用户提问说不知道要在哪里输入JSON,并给了一星。殊不知这扩展并不是这么用的,它是专门用来处理Web浏览器接受到的JSON数据,而不是处理用户自己输入的JSON数据。

但是对于Web开发人员来说,很多时候确实不是光看看浏览器返回的JSON数据这么简单,况且Chrome浏览器的Network面板已经可以预览JSON数据了。我们大多数时候需要处理自己手上的一些JSON数据,格式化,格式验证,实时编辑等等。

幸好JSONView的作者gildas将这款扩展的源码在GitHub上开源了,我们可以从这里将代码下下来自己添加新的功能。于是我利用周末的时间改造了下JSONView,给它添加了处理用户自定义JSON的功能。本来打算研读JSONView的代码让它无缝支持这个功能,但是后来我发现了GitHub上josdejong开源的jsoneditor项目,完胜我自己写的撇脚代码。于是直接将jsoneditor整合进JSONView,三分钟就完成了这强大的功能。

因为JSONView是后台运行的,没有browser_action,所以首先我们在manifest.json文件中添加如下代码(关于manifest.json和browser_action的说明可以参考我另一篇博客《我的第一个Chrome扩展:Search-faster》):

  "browser_action": {
    "default_icon" : "jsonview16.png",
    "default_title" : "JSONView"
  },

这样我们就可以在浏览器右上侧看到JSONView的图标了,如下:

jsonview_browser_action.png

但是这时图标还不能点,我们需要在background.js中添加browser_action的点击处理事件:

// click on browser action
chrome.browserAction.onClicked.addListener(function(tab) {
    chrome.tabs.create({
        url: chrome.extension.getURL("viewer/index.html")
    });
});

我们直接在background.js的最后一行添加上上面的代码,这样当用户点击图标时直接打开一个新页面viewer/index.html。这个页面是我从jsoneditor上的examples里扒出来的,和JSONView已经无关了。我们将jsoneditor下下来,其中有一个example正是我们需要的:03_switch_mode.html,将关联的文件都拷到viewer目录下就搞定了。

最后我们打开Chrome扩展的管理页面:chrome://extensions/,并勾选上“开发者模式”,点击“加载正在开发的扩展程序...”,然后选择JSONView的目录,就可以预览并调试我们编写的扩展了。

chrome-ext-dev.png

调试的过程中如果修改了代码,只需要点击“重新加载”即可,非常方便。下面是我们的成果,看上去效果还不错:

jsonview_enhence.png

最后的最后,我们使用Chrome自带的“打包扩展程序...”功能将程序打包成crx文件,你可以点击这里下载。另外,完整的源代码在这里

扫描二维码,在手机上阅读!

Nodepad++小技巧:中英双语字幕转换为英文字幕

最近在看美剧《罗马》,在网上找了很久都没有找到中英双语字幕的片源,网上流传的版本大多是人人影视(YYeTs)的中文字幕,下下来看了两集发现没有英文字幕感觉非常不爽,于是直接去下载字幕文件,又发现没有纯英文的字幕,只有纯中文和中英双语的字幕文件。

双语的字幕文件下下来之后,本来打算找个小工具来转换成纯英文字幕,后来一想没必要,直接自己手工编辑也能搞定,于是拿起Notepad++折腾了半个小时。

一、Notepad++的宏功能

字幕文件是个有一定格式的文本文件,一般情况下格式都具有某种固定的模式,譬如下面的字幕文件:

srt.png

格式都是这样的模式:序号,时间,中文,英文,再加上一行空行。我们发现中文的位置都是一致的,如果我们手工来做的话,我们会这样操作:删除第3行,然后光标下移5行到第8行,删除第8行,然后光标下移到13行,以此类推,一直操作到文件末尾。

这样有着固定模式的重复操作正是Notepad++宏的用武之地,使用宏删除中文的具体步骤如下:

  1. 首先光标定位到第3行;
  2. 点击菜单项“宏” -> “开始录制”;
  3. Ctrl+L快捷键删除第3行;
  4. 按4下向下的箭头移动光标到第7行(因为删掉了一行,所以本来第8行变成了第7行);
  5. 点击菜单项“宏” -> “停止录制”,这样我们的宏就做好了;
  6. 保持光标所在位置不要动,最后点击菜单项“宏” -> “重复运行宏...”,然后选择“运行到文件尾”,点击“运行”,等待宏运行结束,如果一切顺利,就轻松完成任务了

run-macro.png

二、Notepad++的正则匹配功能

其实使用上面介绍的方法就足够应付大多数的字幕文件了,只要字幕文件的格式标准统一,只需要一次运行宏就能搞定。但有时字幕文件并不一定是格式一致的,譬如下面的字幕:

format.png

遇到这种情况时,宏会不问青红皂白接着往下处理,把英文字幕都删了,甚至破坏了字幕格式。如果这种情况不多,手工处理一下还能接受,但是一旦多起来还是很头疼的。于是想找一种通用的方法来处理。

其实问题很简单:找到文件中的所有中文,并删除所在行。

我们知道Notepad++中有匹配正则表达式的功能,而且我们知道匹配中文字符的正则是[\u4e00-\u9fa5],于是我们使用Notepad++的Mark功能把所有中文所在行标记出来。按Ctrl+F快捷键弹出查找窗口,切换到Mark选项卡,“查找模式”选择“正则表达式”,并勾选上“标记所在行”,输入正则表达式:

mark.png

点击“查找全部”,按理说应该会把所有中文行标记出来的。结果却是中文一个没标记上,英文行全标记上了。Google之才知道原来是正则表达式的问题,匹配中文的正则是[\u4e00-\u9fa5]没错,但是Notepad++使用的是PCRE引擎,正则的语法应该是[\x{4e00}-\x{9fa5}]

修改正则的语法后就可以标记出所有的中文行了。最后,我们拿出杀手锏:

delete-mark.png

选择 “搜索” -> "书签" -> "删除书签行",所有中文行都删除掉了,这个小技巧估计很多人都不知道,但是这个小技巧在移除某些特定行时非常有用。这整个过程总结起来就两步:

  1. 使用正则表达式[\x{4e00}-\x{9fa5}]标记出所有中文行;
  2. 删除书签行;

比起上面宏的做法这种方法要更简洁,而且更不容易出错。至此,我们的英文字幕就做好了,使用视频播放器加载英文字幕,调整下字幕的位置,虽然视频自带了中文字幕,但是看起来就跟双语字幕一样了!哈哈,搞定,继续看电视去了。

movie.png

后记

在使用Notepad++利用正则表达式匹配中文时,要特别注意一点的是:文件的格式一定要是UTF-8格式,而不是ANSI格式,否则匹配不到中文。
另外一点除了使用正则表达式[\x{4e00}-\x{9fa5}]匹配中文之外,还有其他的几种写法:

  1. 直接用中文字符来写正则也可以匹配:[一-龥!-~]
  2. Notepad++内置的匹配Unicode的写法:[[:unicode:]]

参考

  1. Anyone know how to use Regex in notepad++ to find Arabic characters?
  2. 正则表达式如何匹配中文字符?如何在一段中英混合的文本中找出中文字符?
  3. 怎么使用正则表达式表示汉字,目的是要在notepad++筛选出所有汉字?
扫描二维码,在手机上阅读!

关于 .Net 逆向的那些工具:反编译篇

在项目开发过程中,估计也有人和我遇到过同样的经历:生产环境出现了重大Bug亟需解决,而偏偏就在这时仓库中的代码却不是最新的。在这种情况下,我们不能直接在当前的代码中修改这个Bug然后发布,这会导致更严重的问题,因为相当于版本回退了。即使我们眼睁睁的看着这个Bug两行代码就能搞定,在我们的代码没更新到最新版本之前,都不敢轻举妄动。但是客户的呼声让人抵挡不住,客户声称的分分钟多少多少的经济损失我们也承受不起。这时如果你是做PHP开发的,你会庆幸,因为你可以直接去生产环境修复掉这个Bug让客户先闭嘴然后再慢慢折腾你那出问题的代码管理工具;而如果你做是.Net抑或C/C++开发的,就没这么轻松了。面对服务器上拷下来的有着重大Bug的dll或exe,你很难直接去修改它里面的代码逻辑,只能利用一些逆向的技巧和工具了。

由于我这里是.Net的环境,所以我决定在这篇博客里介绍下如何利用逆向工具来修改生产上的.Net程序集。但是就在我决定写这篇博客的时候我突然发现,其实,如果你只是单纯的修改一个.Net程序集中的某个方法或功能,而且这个程序集还是出自于你自己或你所在团队之手,这实在是一件非常容易的事情,这和破解别人的程序完全不同,你不会遇到无法破解的加密算法,也不会遇到让人恶心的加壳混淆。利用搜索引擎可以搜到大量这样的教学文章,所以我改变了下主意,决定不在这篇博客中重复造轮子,而是把已有的轮子一个个的列出来总结一下。

这篇博客主要汇总一些.Net反编译相关的工具。

一、ilasm & ildasm

ilasmildasm 都是微软官方提供的.Net编译与反编译工具,可谓是.Net逆向中的瑞士军刀。这两个工具的位置分别位于.Net Framework目录和Microsoft SDK目录中:

C:\Windows\Microsoft.NET\Framework\v2.0.50727\ilasm.exe
C:\Program Files\Microsoft SDKs\Windows\v7.0A\bin\ildasm.exe

这里有一篇文章详细介绍了如何通过ilasm 和 ildasm 修改.Net程序,归结起来就下面几个步骤:

  1. 使用工具ildasm打开要逆向的.Net程序,并另存为IL文件;
    ildasm程序有命令行和图形界面两种运行模式,一般情况下双击ildasm即可启动图形界面的主程序,然后通过菜单项 File -> Dump ,选择UTF-8编码,即可导出到IL文件中。如果是以命令行模式运行的话,可以打开Visual Studio自带的开发环境命令行工具(Developer Command Prompt for VS2012),这样可以不用关心ildasm所处的目录,直接运行下面的命令:ildasm test.exe /out:test.il

如果程序含资源文件的话,除了生成一个IL文件,可能还会有其他的*.res文件等。

  1. 打开IL文件阅读IL源码并定位到需要修改的代码处,对IL代码进行修改;
    使用ilasm 和 ildasm 反编译.Net程序需要了解一点MSIL的语法,这样无论阅读还是修改IL文件都要方便的多,好在MSIL的语法并不是很复杂,花一天的时间研究下还是值得的。这里有一篇不错的MSIL教程

如果真的对MSIL不熟悉不会编写MSIL的话,其实也没有大碍,只要你会大概的看懂MSIL源码和会编写C#程序也可以。可以参考这篇文章,具体的方法是:用C#编写你需要修改的方法,然后编译成exe/dll文件,再通过ildasm反编译成IL文件,从这个IL文件中复制出需要的IL源码覆盖掉之前那个需要修改的IL文件中的相关代码,这样你就算不会MSIL,也能修改IL文件了,确实有点偷梁换柱的味道。

  1. 使用ilasm将IL文件重新编译成.Net程序。
    最后,使用ilasm程序重新编译IL文件:ilasm test.il /output:test-mod.exe,再使用PEVerify执行校验确保文件无误:PEVerify test-mod.exe。如果一切顺利的话,将test-mod.exe替换掉老的test.exe即可。

默认情况下,ilasm将生成exe文件,如果需要生成dll文件,可以使用下面的命令:ilasm test.il /dll /output:test-mod.dll,如果需要集成资源,则需要指定/resource参数:ilasm test.il /resource:test.res /output:test-mod.exe

二、.Net Reflector & Reflexil

虽说ilasm 和 ildasm是.Net逆向中的瑞士军刀,但是一提起.Net逆向,其实很多人第一反应都是Reflector这款神器,而对微软提供的这两个官方工具知之甚少。这一方面是由于Reflector良好的用户体验和强大的插件功能,另一方面要归功于Reflector堪称完美的智能反编译能力,使用它不仅能看到反编译后的IL源码甚至能直接反编译出C#源码,而且和编写时的代码几无二致,如果需要还可以直接另存为工程文件用Visual Studio打开。
Reflector是RedGate开发的.Net逆向工具,单纯的Reflector程序只有反编译功能,可以查看IL或C#源码,以及导出源码。并不能修改.Net程序。幸好我们有sailro编写的Reflexil插件,Reflexil基于Mono.Cecil,是一个强大的程序集编辑器。Reflector + Reflexil 可谓是强强联合,和 ilasm + ildasm 这个组合比起来简直是大巫见小巫。

如果是第一次加载Reflexil插件,打开Reflector,在 Tools -> Add-Ins -> Add -> 选择Reflexil.dll,以后就可以直接在Reflector的Tools菜单中打开了。用Reflector打开test.exe,选择某个函数可以发现IL代码显示在下方的Reflexil窗口中,可以点击右键Edit,Delete,Create等操作。还可以修改类或方法的访问权限等,比如将private改成public。
另外,在编辑IL的操作中还有一个Replace all with code选项,通过这个可以直接用C#代码来对程序进行修改,无需你熟悉MSIL语法,类似于上一节介绍的“偷梁换柱”的方法,只不过集成在Reflexil中让我们的操作更方便了。如下图所示:

reflexil.png

Reflexil的作者在codeproject上写了一长篇Reflexil的各种实用技巧,可以去这里看看。Reflexil唯一的缺憾是并没有和Reflector无缝结合,使用Reflexil修改完IL源码或类的属性后,上面Reflector中显示的IL或C#源码并没有立即更新,必须保存修改后使用Reflector重新打开才能看到所做的修改。这多少有点让人感觉不爽,但比起 ilasm + ildasm 这种纯手工逆向工具来讲已经好太多了。

还有一点要说明的是:.Net Reflector很早就转向收费软件了,而且价格不菲,普通版95刀每用户,专业版甚至要199刀。对于我们大多数开发人员来说逆向并不是我们的日常工作,可能只是偶尔好奇而为之,这样为了偶尔的好奇而需要支付这么多的money实在是让人有点舍不得。于是一部分人走上了破解和盗版的路,而另一部分人走上了开源的路。这也是下一节将要介绍的ILSpy工具的由来。

三、ILSpy

ILSpy 是为了完全替代收费的Reflector而生,它是由 iCSharpCode 团队出品,这个团队开发了著名的 SharpDevelop 。ILSpy 完全开源,目前还处于开发阶段,很多功能还不够完善,但是具有强大的反编译功能对于我们来说已经足够了。对于ILSpy的使用和上面的Reflector完全类似,此处不再赘述。

在我写这篇博客的时候,ILSpy最新的发布版本为3/9/2015 Version 2.3,还没有和Reflexil具有类似功能的代码修改插件。但是在ILSpy的Further Down the Road可以看到,这样的功能也已经在计划之中了。

ilspy-down-road.png

虽然官方的发布版本还没有提供Reflexil的功能,但是Reflexil的作者sailro很早就在项目描述中介绍了对ILSpy的支持:

Reflexil is an assembly editor and runs as a plug-in for Red Gate's Reflector, ILSpy and Telerik's JustDecompile. Reflexil is using Mono.Cecil, written by Jb Evain and is able to manipulate IL code and save the modified assemblies to disk. Reflexil also supports C#/VB.NET code injection.

得益于ILSpyReflexil都是开源的,我们从GitHub上把最新的代码Clone下来并进行编译,Reflexil\Plugins\Reflexil.ILSpy这个目录下是Reflexil插件的源码,我们将编译后的所有dll文件拷贝到ILSpy的bin目录,运行ILSpy就能在View选项中看到Reflexil了,如下图:

ilspy-view.png

关于ILSpy和Reflexil的操作和上面介绍的Reflector几乎一样,不过有一点很让人振奋,ILSpy提供了更新对象模型的功能,这样Reflexil插件就可以在修改完代码后直接更新ILSpy的代码了,而不用像Reflector那样需要重新加载才能看到所做的修改。如下图所示,点击“Update ILSpy object model”,上面ILSpy的代码会立即更新:

ilspy-reflexil.png

四、Just Decompile

相信不少人都听过 Telerik 公司,该公司非常关注于.Net平台下的控件研发,并且发布了很多著名的开发工具,例如:Fiddler 和 JustDecompile。JustDecompile 正是 Telerik 的一款.Net反编译工具,和ILSpy不同的是,它并不是完全开源的,但是它有着商业化的技术支持,这一点非常难得。不仅如此,Telerik 也开源了JustDecompile的引擎部分:JustDecompile Engine,这也是非常不错的。
JustDecompile在使用上和其他的反编译工具差不多,而且它也具有插件系统,官方目前提供了三个插件,如下:

jd-plugins.png

其中Assembly Editor正是Reflexil。从菜单Plugins中调出Reflexil,enjoy it!

jd-reflexil.png

五、dotPeek

JetBrains是捷克的一家软件开发公司,出品了大量著名的开发工具,包括:IntelliJ IDEA、PHPStorm、ReSharper、TeamCity、YouTrack等等,每一款产品都如雷贯耳。dotPeek 是 JetBrains 开发的一款.Net反编译工具,是.Net工具套件中的一个,其他的还有dotTrace、dotCover 和 dotMemory。相比于前面几款工具来说,dotPeek算比较小众的一款。个人感觉它最大的特色就是Visual Studio风格,这对于那些长期在Visual Studio下进行开发的人来说应该更亲切一点。

dotPeek.png

不过dotPeek目前好像还没有类似于Reflexil这样的编辑插件,本身也并没有编辑功能。如果我们需要修改程序集的话,可以另存为工程文件,使用Visual Studio打开直接修改源码重新编译。

六、更多选择

实际上,利用上面介绍的这些工具已经完全能够满足你的需求了。但是我们总是有更多选择(等有时间的时候再玩这些吧):

另外,本文中的一些工具可以在此下载

TODO:Mono.Cecil

这篇博客的主要目的本来只是在无源码的情况下修改.Net程序集,但到这里已经完全演变成了.Net反编译工具的罗列清单。Never mind,关于.Net程序集的修改,上面最耀眼的非Reflexil莫属。而Reflexil依赖于Jb Evain所写的Mono.Cecil,Cecil 是一个对于Mono项目具有战略意义的函数库。它为很多项目(包括:Mono Debugger、Gendarme 和 MoMA 等)提供了内部处理的能力,而且 Cecil 也能操作编译好的CIL,并把修改后的程序集保存到磁盘里。

作为一个.Net程序员,修改.Net程序集最终极的方法莫过于自己动手写出修改程序集的代码,利用Mono.Cecil可以轻松实现这个目的。暂且给自己挖个坑,以后填上吧。

参考

  1. 操作步骤:用ildasm/ilasm修改IL代码 - dudu - 博客园
  2. Ilasm.exe(IL 汇编程序)
  3. Ildasm.exe(IL 反汇编程序)
  4. MSIL Tutorial
  5. 通过学习反编译和修改IL,阅读高人的代码,提高自身的水平。 - 辰 - 博客园
  6. 初识Ildasm.exe——IL反编译的实用工具 - Youngman - 博客园
  7. Reflector 已经out了,试试ILSpy - James Li - 博客园
  8. c#:Reflector+Reflexil 修改编译后的dll/exe文件 - 菩提树下的杨过 - 博客园
  9. 几款 .Net Reflector 的替代品 - 张志敏 - 博客园
  10. Assembly Manipulation and C# / VB.NET Code Injection - CodeProject
扫描二维码,在手机上阅读!

我的第一个Chrome扩展:Search-faster

花了两周左右的时间将Chrome扩展的开发文档看了一遍,把所有官方的例子也都顺便一个个的安装玩了一遍,真心感觉Chrome浏览器的博大精深。Chrome浏览器的现有功能已经足够强大,再配合Chrome扩展几乎可以说是“只有想不到,没有做不到”。于是利用业余时间做了一个简单的扩展Search-faster,可以加快Google的搜索速度,算是对近一段时间学习的总结。

一、Chrome扩展综述

Chrome扩展有两种不同的表现形式:扩展(Extension)和应用(WebApp),我们这里不讨论WebApp,但是扩展的大多数技巧对于WebApp来说也是适用的。Chrome扩展实际上就是压缩在一起的一组文件,包括HTML,CSS,Javascript,图片,还有其它任何需要的文件。它从本质上来说就是一个Web页面,可以使用所有的浏览器提供的API,可以与Web页面交互,或者通过content script或cross-origin XMLHttpRequests与服务器交互。还可以访问浏览器提供的内部功能,例如标签或书签等。
扩展在Chrome浏览器中又有着两种不同的表现形式:browser_action和page_action,browser_action在工具栏右侧添加一个图标,page_action在URL输入栏右侧添加一个图标,如下图所示。这两个action唯一的区别在于:当你的扩展是否显示取决于单个页面时,该使用page_action,page_action默认是不显示的。

extensions

1.1 manifest.json

每一个Chrome扩展都有一个清单文件包含了这个扩展的所有重要信息,这个文件的名称固定为manifest.json,文件内容为JSON格式。下面是一个manifest.json文件的实例(来自JSONView扩展)

{
   "background": {
      "scripts": [ "background.js" ]
   },
   "content_scripts": [ {
      "all_frames": true,
      "js": [ "content.js" ],
      "matches": [ "http://*/*", "https://*/*", "ftp://*/*", "file:///*" ],
      "run_at": "document_end"
   } ],
   "description": "Validate and view JSON documents",
   "icons": {
      "128": "jsonview128.png",
      "16": "jsonview16.png",
      "48": "jsonview48.png"
   },
   "key": "...",
   "manifest_version": 2,
   "name": "JSONView",
   "options_page": "options.html",
   "permissions": [ "clipboardWrite", "http://*/", "contextMenus", "https://*/", "ftp://*/" ],
   "update_url": "https://clients2.google.com/service/update2/crx",
   "version": "0.0.32.2",
   "web_accessible_resources": [ 
      "jsonview.css", "jsonview-core.css", "content_error.css", 
      "options.png", "close_icon.gif", "error.gif" 
   ]
}

其中nameversionmanifest_version三个字段是必选的,每个字段的含义显而易见,另外在当前版本下manifest_version的值推荐为2,版本1已经被弃用。
除这三个字段之外,description为对扩展的一句描述,虽然是可选的,但是建议使用。
icons为扩展的图标,一般情况下需要提供三种不同尺寸的图标:16*16的图标用于扩展的favicon,在查看扩展的option页面时可以看到;48*48的图标在扩展的管理页面可以看到;128*128的图标用于WebApp。这三种图标分别如下所示:

icons

图标建议都使用png格式,因为png对透明的支持最好。要注意的是:icons里的图标和browser_actionpage_action里的default_icon可能是不一样的,default_icon显示在工具栏或URL输入栏右侧,建议采用19*19的图标。
key字段为扩展的唯一标识,这个字段是浏览器在安装.crx文件时自动生成的,通常不需要手工指定。
permissions为扩展所需要的权限列表,列表中的每一项要么是一个已知的权限名称,要么是一个URL匹配模式。一些常见的权限名称有background、bookmarks、contextMenus、cookies、experimental、geolocation、history、idle、management、notifications、tabs、unlimitedStorage等;URL匹配模式用于指定访问特定的主机权限,譬如:"http://*.google.com/"、"http://www.baidu.com/"。关于permissions字段可以参考这里的文档
update_url用于扩展的自动升级,默认情况下Chrome浏览器会每隔一小时检测一次是否需要升级,也可以点击扩展管理页面的“立即更新”按钮强制升级。
另外backgroundcontent_scriptsoptions_page这三字段,还有这个例子里没包含的browser_action/page_action字段是构成Chrome扩展的核心元素。下面分别进行介绍。

1.2 background

背景页通常是Javascript脚本,是一个在扩展进程中一直保持运行的页面。它在你的扩展的整个生命周期都存在,在同一时间只有一个实例处于活动状态。在manifest.json中像下面这样使用scripts字段注册背景页:

{
  "name": "My extension",
  // ...
  "background": {
    "scripts": ["background.js"]
  },
  // ...
}

也可以使用page字段注册HTML页面:

{
  "name": "My extension",
  // ...
  "background": {
    "page": ["background.html"]
  },
  // ...
}

背景页和browser_action/page_action是运行在同一个环境下的,可以通过chrome.extension.getBackgroundPage()chrome.extension.getViews()进行两者之间的互相通信。
背景页也常常需要和content_scripts之间进行通信,要特别注意的是背景页和content_scripts是运行在两个独立的上下文环境中的,只能通过messages机制来通信,这个通信可以是双向的,首先写下消息的监听方:

chrome.extension.onRequest.addListener(function(request, sender, callback) {
   console.log(JSON.stringify(request));
   // deal with the request...
   sendResponse({success: true});
});

然后写下消息的发送方:

chrome.tabs.sendRequest(tabId, cron, function(response) {
   if (response.success) {
      // deal with the response...
   }
});

1.3 content_scripts

content scripts是一个很酷的东西,它可以让我们在Web页面上运行我们自定义的Javascript脚本。content scripts可以访问或操作Web页面上的DOM元素,从而实现和Web页面的交互。但是要注意的是,它不能访问Web页面中的Javascript变量或函数,content scripts是运行在一个独立的上下文环境中的,类似于沙盒技术,这样不仅可以确保安全性,而且不会导致页面上的脚本冲突(譬如Web页面上使用了jquery 1.9版本,而content scripts中使用了jquery 2.0版本,这两个版本的jquery其实运行在两个独立的上下文环境中互不影响)。content scripts除了不能访问Web页面中Javascript变量和函数外,还有其他的一些限制:

  • 不能使用除了chrome.extension之外的chrome.* 的接口
  • 不能访问它所在扩展中定义的函数和变量
  • 不能做cross-site XMLHttpRequests

但这些限制其实并不影响content scripts实现其强大功能,因为可以使用Chrome扩展的messages机制来和其所在的扩展进行通信,从而间接的实现上面的功能;而且,content scripts甚至可以通过操作DOM来间接的和Web页面进行通信。
使用content scripts在Web页面注入自定义脚本可以通过两种方法来实现:第一种方法是在manifest.json文件中使用content_scripts字段来指定,还有一种方法是通过编程的方式调用chrome.tabs.executeScript()函数动态的注入。这里有详细的介绍。

1.4 options_page

当你的扩展拥有众多参数可供用户选择时,可以通过选项页来实现。选项页就是一个单纯的HTML文件,可以引用脚本,CSS,图片等其他资源。这在Web开发中是家常便饭,只要你会制作网页,那么制作一个选项页肯定也没问题,这并没有什么好说的。但是,如果我们仔细想一想,当用户在选项页点击保存修改后,修改后的配置信息保存在哪儿呢?如何做到选项页中的配置在重启浏览器后甚至是清除浏览器数据后仍然存在呢?这就需要我们将配置信息保存到硬盘上的某个文件中,而浏览器Web脚本中的Javascript代码很显然是不能访问物理文件的。
这就是chrome.storage.local的由来,chrome.storage.local是Chrome浏览器提供的存储API,这个接口用来将扩展中需要保存的数据写入本地磁盘。Chrome提供的存储API可以说是对localStorage的改进,它与localStorage相比有以下区别:

  • 如果储存区域指定为sync,数据可以自动同步
  • 在隐身模式下仍然可以读出之前存储的数据
  • 读写速度更快
  • 用户数据可以以对象的类型保存
  • 清除浏览器数据后仍然可以访问

1.5 browser_action vs. page_action

上面已经说过,browser_action和page_action是扩展在Chrome浏览器中的两种不同的表现形式,browser_action显示在工具栏右侧,page_action显示在URL输入栏右侧。下面的代码示例说明了如何注册一个browser_action(page_action的注册方法类似,只要将browser_action替换成page_action即可):

{
  "name": "My extension",
  // ...
  "browser_action": {
    "default_icon": "images/icon19.png", // optional 
    "default_title": "Google Mail",      // optional; shown in tooltip 
    "default_popup": "popup.html"        // optional 
  },
  // ...
}

一个browser_action可以拥有一个icon,一个tooltip,一个badge和一个popup,page_action没有badge,也可以拥有一个icon,一个tooltip和一个popupicon是action的图标,一般情况下是一个19*19的png图片,也可以是HTML5中的一个canvas元素可以实现任意的自定义图片;tooltip是提示信息,当鼠标移到action图标上时会显示出来;popup是当用户点击action图标时弹出的窗口;badge是写在图标上文字,譬如下图中显示在RSS Feed Reader这个扩展图标上的67就是一个badge,由于badge空间有限,一般不会超过4个字符,超出部分会被截断。

actions

如果在default_popup字段中指定了popup.html,当用户点击图标时就会弹出来。这也是个简单的HTML文件,可以包含自己的脚本,样式和图片文件。如果没有指定popup.html,点击图标时会触发action的onClicked事件。如果你需要处理该事件可以在背景页background.js中使用类似于下面的代码:

chrome.browserAction.onClicked.addListener(function(tab) {
   // ...
});

browser_action默认总是显示,除非你在扩展管理里选择了隐藏按钮,而page_action默认是不显示的,需要使用函数chrome.pageAction.show()chrome.pageAction.hide()来控制page_action的显示。下面是一个简单的示例,只有当URL是www.baidu.com才会显示page_action:

function update(tabId) {
  if (location.host.indexOf('www.baidu.com') == -1) {
    chrome.pageAction.hide(tabId);
  }
  else {
    chrome.pageAction.show(tabId);
  }
}

chrome.tabs.onUpdated.addListener(function(tabId, change, tab) {
  if (change.status == "complete") {
    update(tabId);
  }
});

chrome.tabs.onSelectionChanged.addListener(function(tabId, info) {
  update(tabId);
});

chrome.tabs.query({active: true, currentWindow: true}, function(tabs) {
  update(tabs[0].id);
});

二、如何调试Chrome扩展

调试永远是软件开发中至关重要的一步,无论是桌面应用还是Web应用都是如此。Chrome浏览器的开发者工具提供给开发人员一个近乎完美的Web调试器,不仅可以查看Web页面的HTML源码,分析和修改DOM树,调试CSS样式,查看每一个网络请求进行性能分析,以及强大的Javascript脚本调试器。Chrome扩展和Web应用别无二致,自然也可以使用相同的调试方法对其进行调试,只不过要在几点不同之处特别注意一下。

2.1 popup的调试

如果你的扩展使用了popup,无论是browser_action还是page_action,都可以通过下面的方法调试popup:首先在扩展的图标上点击右键,然后选择“审查弹出内容”,如下图所示:

popup_dbg

这时会弹出开发者工具的窗口,我们选择Sources选项卡,在左侧就可以看到所有popup相关的HTML、Javascript以及CSS了。如下图:

popup_dbg2

查看Javascript代码找到我们感兴趣的地方,在该处下个断点,然后就可以进行调试了。如果断点处的代码是弹出窗口时就已经执行过了,那么可以切换到Console选项卡,输入location.reload(true)执行后popup会重新加载,并断在断点处,如下图:

popup_dbg3

2.2 background的调试

尽管背景页和popup是属于同一个执行环境下,但是点击“审查弹出内容”时并不能看到背景页的代码,也不能对其进行调试。要调试背景页,首先需要打开扩展管理页面,找到要调试的扩展,如果该扩展有背景页,会显示类似于如下图所示的“检查视图:background.html”字样,用户点击background.html弹出开发者工具既可以进行调试。

bg_dbg

和popup一样,也可以使用在Console选项卡中执行location.reload(true)这个小技巧来重新加载背景页。

2.3 content_scripts的调试

content_scripts是注入到Web页面中的Javascript代码,所以调试content_scripts和调试Web页面是完全一样的。我们直接按F12调出开发者工具,然后切换到Sources选项卡,在下面的左侧可以看到又有几个小的选项卡:Sources、Content scripts、Snippets。我们选择Content scripts就可以找到已经注入到这个页面的所有content_scripts。如下图:

content_dbg

可以看出一个页面可以被注入多个content_scripts,每一个content_scripts都有着他们自己独立的运行空间。找到感兴趣的代码下断点,然后就可以调试了。如果代码已经运行过,刷新下页面即可(当然,如果你在Console选项卡中执行location.reload(true)也是完全可以的,但这哪有F5方便呢:-))。

2.4 option.html的调试

选项页就是一个静态的HTML页面,和调试Web页面完全一样。没什么好说的了。

三、Search-faster的实现

通过上面的学习,我们基本上已经了解到了开发一个Chrome扩展所需要的基本知识了。现在我们通过实现一个最最简单的Chrome扩展来对学到的内容进行巩固。Search-faster非常简单,写它的目的也非常简单:加快我们在搜索引擎上的搜索速度。听起来很高大上,其实很简单,我们知道在我们搜索的时候,很多搜索引擎搜出来的结果并不是直接跳转到原网页,而且先跳转到搜索引擎自身,然后再跳转到原网页。如下图所示,百度搜索就是这样做的:

baidu_link

当然Google也是这样:

google_link

这样做搜索引擎可以对每个搜索结果进行统计分析然后优化,但是对于我们用户来说,多做一次跳转显然会降低我们的速度。而且在我们大天朝,通过代理访问Google本来就已经够慢的了,点击每个搜索结果再跳转一次到Google实在是有点让人受不了。
我本来打算对百度、Google和Bing做统一处理的,后来发现百度的跳转链接是经过加密后的,一时破解不了,而Bing的搜索结果并没有跳转而是直接到原网页。于是这个Search-faster其实就变成了Google-search-faster了。

首先我们确定我们的扩展类型,因为只有在访问Google搜索时才需要显示,所以我们采用page_action而不是browser_action。然后我们确定需要哪些文件,因为要访问Google搜索的结果页面,所以肯定需要一个content_scripts,content_scripts的内容是遍历Google搜索结果页面上的所有跳转链接,获取每个链接的原链接,然后在每个链接上添加一个click事件,当用户点击该链接时直接跳转到原链接。(本来是打算直接修改链接为原链接的,但是发现Google的代码中有检测功能,会自动将跳转链接替换回来,所以使用click事件的方法最为保险)。最后我们需要一个背景页,检测浏览器选项卡的变动,当用户切换选项卡或选项卡有变动时执行content_scripts。
manifest.json的代码如下:

{
  "name": "Search-faster",
  "version": "1.0",
  "description": "Replace the search engine redirect url to direct url when searching baidu, google, etc.",
  "background": { "scripts": ["background.js"] },
  "content_scripts": [{ 
    "matches": ["http://*/*", "https://*/*"], 
    "js": ["jquery.min.js", "content_script.js"] 
  }],
  "page_action": {
    "default_icon" : "icons/google.png",
    "default_title" : "It works!"
  },
  "permissions" : ["tabs"],
  "manifest_version": 2
}

content_scripts的关键代码如下:

chrome.extension.onRequest.addListener(function(req, sender, sendResponse) {
    var response = doReplace();
    sendResponse(response);
});

/**
 * replace the redirect url to direct url
 */
function doReplace() {

    // url not match
    if(location.host.indexOf('www.google.com') == -1) {
        return null;
    }

    // get the keyword
    var lstib = $('#lst-ib');
    if(lstib.length == 0) {
        return null;
    }

    // get the links &amp; add a click event
    var links = $('.srg .g h3.r a');
    links.on('click', function(e) {
        var href = $(e.target).attr('data-href');
        window.open(href);
        return false;
    });

    return {
        keyword: lstib[0].value,
        replace_cnt: links.length
    };
}

background的关键代码如下:

function updateSearch(tabId) {
  chrome.tabs.sendRequest(tabId, {}, function(search) {
    searches[tabId] = search;
    if (!search) {
      chrome.pageAction.hide(tabId);
    } else {
      chrome.pageAction.show(tabId);
      if (selectedId == tabId) {
        updateSelected(tabId);
      }
    }
  });
}

chrome.tabs.onUpdated.addListener(function(tabId, change, tab) {
  if (change.status == "complete") {
    updateSearch(tabId);
  }
});

chrome.tabs.onSelectionChanged.addListener(function(tabId, info) {
  selectedId = tabId;
  updateSelected(tabId);
});

chrome.tabs.query({active: true, currentWindow: true}, function(tabs) {
  updateSearch(tabs[0].id);
});

完整的代码在这里

参考

  1. 综述--扩展开发文档
  2. Chrome 扩展程序、应用开发文档(非官方中文版)
  3. Sample Extensions - Google Chrome
  4. Chrome插件(Extensions)开发攻略
  5. 手把手教你开发chrome扩展一:开发Chrome Extenstion其实很简单
  6. 手把手教你开发Chrome扩展二:为html添加行为
  7. 手把手教你开发Chrome扩展三:关于本地存储数据
  8. Chrome.storage和HTML5中localStorage的差异
扫描二维码,在手机上阅读!

Windows下搭建PHP本地开发环境

一直以来,我都是使用新浪的SAE作为我本地的PHP开发环境。SAE使用起来非常方便,是个纯绿色的软件包,可以从这里下载,使用之前首先配置sae.conf文件中的DocumentRoot参数为网站根目录(也就是你PHP开发的目录),然后运行init.cmd脚本即可(Win7以上系统可能需要以管理员身份运行)。 我将SAE作为我的本地开发环境,官方的SAE只包含了PHP、Apache和Redis,而我的生产环境是PHP + Nginx + MySQL。这样不仅需要安装MySQL,而且不能和生产环境的Nginx保持一致,导致很多生产上的Nginx重写规则,在本地要再写一份Apache版的,虽然差异不大,但是依然感觉很麻烦。所以就想打造一个自己的PHP开发环境,像SAE一样绿色版,并且支持Apache和Nginx两个服务器。

一、搭建步骤

我们按PHP、Apache和Nginx的顺序来搭建PHP的本地开发环境,MySQL没有包含其中,以后有机会再做。这次的重点是学习Apache和Nginx的配置以及PHP是怎么和这两大Web服务器交互的。

1.1 PHP

首先,我们在这里下载PHP的Windows版本,我们直接下载编译好的Zip包即可。这里要特别注意的是,PHP for Windows有多种不同的版本,VC9和VC11,TS和NTS,x86和x64等。

  • VC9 vs VC11:代表使用哪个VS版本编译的,VC9表示Visual Studio 2008,VC11表示Visual Studio 2012
  • TS vs NTS:TS代表线程安全,也就是说支持多线程,PHP以模块形式加载到Web服务器中需要使用TS;NTS代表只支持单线程,在CLI/CGI/FastCGI等模式下建议使用NTS
  • x86 vs x64:使用32位还是64位,x64目前是实验版本
    综上所述,这里我们选择PHP 5.6 (5.6.9) VC11 x86 Thread Safe

1.2 Apache

Apache官网上并没有提供Windows上的可执行文件供下载,而是只提供了源码和编译安装步骤。我们这里省去编译可能会带来的问题和麻烦,直接使用第三方编译好的可执行文件。我们有很多不同的选择,下面是Apache官方提供的选择列表:

这里我们使用Apache Lounge提供的httpd-2.4.12-win32-VC11.zip,这是因为上面提到的PHP中一些SAPI就是使用Apache Lounge提供的二进制文件来编译的。注意选择和上面PHP一样的VC11和32位版本。

1.3 Nginx

比较流行的Nginx for Windows有两种不同的版本,一种是官方提供的使用native Win32 API的原生版本,另一种是利用Cygwin模拟编译出来的版本。这里我们选择原生版本

二、PHP的两种配置方式

将可执行文件都下下来之后,我们就需要对其进行配置了。首先我们以下图所示结构对刚下载的软件包进行组织:

structure

webserver目录用于存放Web服务器的二进制文件,譬如Apache和Nginx,之后可以添加lighttpd,IIS等;database目录用于存放数据库相关文件,譬如MySQL、Redis和Memcached等;interpreter目录用于存放脚本解析文件,如PHP、Perl、Python等。 PHP一般有两种配置方式:一种方式是以Apache的模块mod_php运行在Apache服务器中,这种方式最传统最著名,历史也最为悠久,在Nginx服务器没流行起来的时代,Apache统治了Web服务器的绝大部分,当时几乎都是以mod_php方式来配置的。这种配置方式虽然简单而且运行效率很高,但是这种配置不够灵活,导致Web服务器臃肿,也导致Apache服务器会占用更多的内存。于是出现了第二种配置方式CGI/FastCGI,CGI由于其效率低下已经基本上被FastCGI替代了。这种配置方式将Web服务器和PHP解析器解耦分离开来,PHP和Web服务器使用两个进程,甚至可以运行在两个不同服务器上,两者之间通过CGI/FastCGI协议进行通信。关于CGI/FastCGI/PHP-FPM的资料网上有很多,可以参考这里这里这里了解下。关于PHP的两种配置方式可以参考这里这里这里。 下面我们就以这两种方式分别来配置我们刚下载的Apache和Nginx。

2.1 mod_php

Apache支持以模块的方式来配置PHP,这个模块就是mod_php5.so,要注意的是mod_php是Linux下的名称,在Windows下的名称是php5apache2_4.dllphp5apache2_4.dll这个文件只包含在TS版的PHP打包中,如果你下载的是NTS版的可能找不到这个文件,因为Apache以模块形式载入PHP需要保证其是线程安全的。Nginx暂时还不支持这种配置方式。 参考PHP官方文档,我们在webserver\Apache-2.4\conf\httpd.conf文件中加入下面的代码来完成配置:

#
# mod_php
#
LoadModule php5_module "full-path-of-php5apache2_4.dll"
PHPIniDir "full-path-of-php.ini-dir"
AddType application/x-httpd-php .php

确保配置文件中的ServerRootDocumentRoot路径正确,然后运行webserver\Apache-2.4\bin\httpd.exe即可。可以在htdocs目录下新建一个PHP文件,使用phpinfo()方法来确保PHP和Apache已经正确运行。

2.2 CGI / FastCGI / PHP-FPM

在Windows环境下通过CGI方式解析PHP脚本一般使用的是PHP自带的php-cgi.exe程序。该程序运行方式如下:

php-cgi.exe -b 127.0.0.1:9000 -c path/to/php.ini

一旦按上面的方式运行php-cgi.exe程序之后就可以通过9000端口使用CGI协议来和它进行通信解析PHP脚本了。然后我们参考这里,在Nginx的配置文件中配上下面的代码(Nginx的配置文件位于webservernginx-1.9.0confnginx.conf):

#
# pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
#
location ~ \.php$ {
    root           html;
    fastcgi_pass   127.0.0.1:9000;
    fastcgi_index  index.php;
    fastcgi_param  SCRIPT_FILENAME  $document_root$fastcgi_script_name;
    include        fastcgi_params;
}

配置好了以后,运行webserver\nginx-1.9.0\nginx.exe即可。在html目录下新建一个PHP文件,使用phpinfo()方法确保PHP和Nginx正确运行。可以看到mod_php方式配置简单,而且只要运行Web服务器即可,FastCGI方式不仅要运行Web服务器,还要运行FastCGI解析程序。 当然不仅仅是Nginx支持FastCGI这种配置方式,几乎所有其他的Web服务器都支持这种配置方式,譬如:Apache、IIS和lighttpd等。如果你想在Apache上使用这种配置,可以使用mod_fcgid扩展模块,在httpd.conf配置文件中添加如下代码:

#
# fast_cgi
#
LoadModule fcgid_module modules/mod_fcgid.so
FcgidInitialEnv PHPRC        "c:/php" 
FcgidWrapper "c:/php/php-cgi.exe" .php  
AddHandler fcgid-script .php

另外要提出的一点是,这里不管是mod_php方式还是FastCGI方式,我们都使用的是PHP的线程安全(TS)版本。而一般推荐做法是mod_php使用多线程(TS)版本,FastCGI使用单线程(NTS)版本,使用NTS版本不仅可以提高性能,也是为了和PHP的扩展(有很多PHP扩展是非线程安全的)保持兼容。

2.3 总结

为了方便,我写了两个批处理程序来启动Apache和Nginx。完整的版本可以看这里start-apache.bat文件:

@echo off
set APACHEROOT="path/to/webserver/Apache-2.4"
set PHPROOT="path/to/interpreter/php-5.6.9"
echo Starting Apache...
copy "%APACHEROOT:~1,-1%\conf\httpd.conf.bak" "%APACHEROOT:~1,-1%\conf\httpd.conf" /Y
utils\fnr.exe --cl --dir "%APACHEROOT:~1,-1%\conf" --fileMask "httpd.conf" --find "

start-nginx.bat文件:

@echo off
set NGINXROOT="path/to/webserver/nginx-1.9.0"
set PHPROOT="path/to/interpreter/php-5.6.9"
echo Starting PHP FastCGI...
utils\RunHiddenConsole "%PHPROOT:~1,-1%\php-cgi.exe" -b 9000 -c "%PHPROOT:~1,-1%\php.ini-development"
echo Starting Nginx...
utils\RunHiddenConsole "%NGINXROOT:~1,-1%\nginx.exe" -p "%NGINXROOT:~1,-1%"
pause

三、PHP SAPI

上面介绍了PHP的两种不同的配置方式,这两种配置都可以使PHP工作,实现这一点其实要归功于PHP的SAPI。SAPI(Server Application Programming Interface)是服务器应用编程接口的缩写,PHP通过SAPI提供了一组接口供应用和PHP内核之间进行数据交互。PHP提供很多种形式的接口,包括apache、apache2filter、apache2handler、cgi 、cgi-fcgi、cli、cli-server、embed、isapi、litespeed等等。但是常用的只有5种形式,CLI/CGI(单进程)、Multiprocess(多进程,PHP可以编译成Apache下的prefork MPM模式和APXS模块)、Multithreaded(多线程,Apache2的Worker MPM模块)、FastCGIEmbedded(内嵌)。可以使用PHP的php_sapi_name()函数获取当前的SAPI接口类型。参考这里这里了解更多。另外在简明现代魔法上有一个系列的文章对SAPI进行了详细的介绍:

参考

  1. PHP For Windows
  2. Using Apache HTTP Server on Microsoft Windows
  3. Apache Lounge
  4. nginx for Windows
  5. 什么是CGI、FastCGI、PHP-CGI、PHP-FPM、Spawn-FCGI?
  6. 概念了解:CGI,FastCGI,PHP-CGI与PHP-FPM
  7. 搞不清FastCgi与PHP-fpm之间是个什么样的关系
  8. mod_php vs FastCGI vs PHP-FPM
  9. [[好文]mod_php和mod_fastcgi和php-fpm的介绍,对比,和性能数据](http://wenku.baidu.com/view/887de969561252d380eb6e92.html)
  10. mod_php和mod_fastcgi(待整理)
  11. Microsoft Windows 下的 Apache 2.x
  12. php-fcgi on Windows
  13. windows下配置nginx+php环境
  14. Thread Safe PHP with FastCGI on IIS 7 or not?
  15. 【问底】王帅:深入PHP内核(二)——SAPI探究
  16. 第二章 » 第二节 SAPI概述 | TIPI: 深入理解PHP内核
扫描二维码,在手机上阅读!