Fork me on GitHub

分类 开源 下的文章

实战 APISIX 服务发现

APISIX 使用小记 中,我们通过 APISIX 官方提供的入门示例学习了 APISIX 的基本概念,并使用 Admin API 和 Dashboard 两种方法创建路由。在创建路由时,我们必须明确地知道服务的 IP 和端口,这给运维人员带来了一定的负担,因为服务的重启或扩缩容都可能会导致服务的 IP 和端口发生变化,当服务数量非常多的时候,维护成本将急剧升高。

APISIX 集成了多种服务发现机制来解决这个问题,通过服务注册中心,APISIX 可以动态地获取服务的实例信息,这样我们就不用在路由中写死固定的 IP 和端口了。

如下图所示,一个标准的服务发现流程大致包含了三大部分:

discovery-cn.png

  1. 服务启动时将自身的一些信息,比如服务名、IP、端口等信息上报到注册中心;各个服务与注册中心使用一定机制(例如心跳)通信,如果注册中心与服务长时间无法通信,就会注销该实例;当服务下线时,会删除注册中心的实例信息;
  2. 网关会准实时地从注册中心获取服务实例信息;
  3. 当用户通过网关请求服务时,网关从注册中心获取的实例列表中选择一个进行代理;

目前市面上流行着很多注册中心,比如 Eureka、Nacos、Consul 等,APISIX 内置了下面这些服务发现机制:

基于 Eureka 的服务发现

Eureka 是 Netflix 开源的一款注册中心服务,它也被称为 Spring Cloud Netflix,是 Spring Cloud 全家桶中的核心成员。本节将演示如何让 APISIX 通过 Eureka 来实现服务发现,动态地获取下游服务信息。

启动 Eureka Server

我们可以直接运行官方的示例代码 spring-cloud-samples/eureka 来启动一个 Eureka Server:

$ git clone https://github.com/spring-cloud-samples/eureka.git
$ cd eureka && ./gradlew bootRun

或者也可以直接使用官方制作好的镜像:

$ docker run -d -p 8761:8761 springcloud/eureka

启动之后访问 http://localhost:8761/ 看看 Eureka Server 是否已正常运行。

启动 Eureka Client

如果一切顺利,我们再准备一个简单的 Spring Boot 客户端程序,引入 spring-cloud-starter-netflix-eureka-client 依赖,再通过 @EnableEurekaClient 注解将服务信息注册到 Eureka Server:

@EnableEurekaClient
@SpringBootApplication
@RestController
public class EurekaApplication {

    public static void main(String[] args) {
        SpringApplication.run(EurekaApplication.class, args);
    }

    @RequestMapping("/")
    public String home() {
        return String.format("Hello, I'm eureka client.");
    }
}

在配置文件中设置服务名称和服务端口:

spring.application.name=eureka-client
server.port=8081

默认注册的 Eureka Server 地址是 http://localhost:8761/eureka/,可以通过下面的参数修改:

eureka.client.serviceUrl.defaultZone=http://localhost:8761/eureka/

默认情况下,Eureka Client 是将该服务所在的主机名注册到 Eureka Server,这在某些情况下可能会导致其他服务调不通该服务,我们可以通过下面的参数,让 Eureka Client 注册 IP 地址:

eureka.instance.prefer-ip-address=true
eureka.instance.ip-address=192.168.1.40

启动后,在 Eureka 页面的实例中可以看到我们注册的服务:

eureka-instances.png

APISIX 集成 Eureka 服务发现

接下来,我们要让 APISIX 通过 Eureka Server 找到我们的服务。首先,在 APISIX 的配置文件 config.yaml 中添加如下内容:

discovery:
  eureka:
    host:
      - "http://192.168.1.40:8761"
    prefix: /eureka/

然后重启 APISIX,接着向 APISIX 中添加如下路由:

$ curl -X PUT http://127.0.0.1:9180/apisix/admin/routes/11 \
    -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -i -d '
{
    "methods": ["GET"],
    "uri": "/eureka",
    "plugins": {
        "proxy-rewrite" : {
            "regex_uri": ["/eureka", "/"]
        }
    },
    "upstream": {
        "type": "roundrobin",
        "discovery_type": "eureka",
        "service_name": "EUREKA-CLIENT"
    }
}'

之前创建路由时,我们在 upstream 中通过 nodes 参数表示上游服务器节点,这里我们不再需要写死服务器节点信息,而是通过 "discovery_type": "eureka""service_name": "EUREKA-CLIENT" 来让 APISIX 使用 eureka 服务发现机制,上游的服务名称为 EUREKA-CLIENT

值得注意的是,虽然上面的 Eureka Client 的 spring.application.name 是小写,但是注册到 Eureka Server 的服务名称是大写,所以这里的 service_name 参数必须是大写。此外,这里我们还使用了 proxy-rewrite 插件,它相当于 Nginx 中的路径重写功能,当多个上游服务的接口地址相同时,通过路径重写可以将它们区分开来。

访问 APISIX 的 /eureka 地址验证一下:

$ curl http://127.0.0.1:9080/eureka
Hello, I'm eureka client.

我们成功通过 APISIX 访问到了我们的服务。

关于 APISIX 集成 Eureka 的更多信息,可以参考官方文档 集成服务发现注册中心 和官方博客 API 网关 Apache APISIX 集成 Eureka 作为服务发现

基于 Nacos 的服务发现

Nacos 是阿里开源的一款集服务发现、配置管理和服务管理于一体的管理平台,APISIX 同样支持 Nacos 的服务发现机制。

启动 Nacos Server

首先,我们需要准备一个 Nacos Server,Nacos 官网提供了多种部署方式,可以 通过源码或安装包安装通过 Docker 安装通过 Kubernetes 安装,我们这里直接使用 docker 命令启动一个本地模式的 Nacos Server:

$ docker run -e MODE=standalone -p 8848:8848 -p 9848:9848 -d nacos/nacos-server:v2.2.0

不知道为什么,有时候启动会报这样的错误:com.alibaba.nacos.api.exception.runtime.NacosRuntimeException: errCode: 500, errMsg: load derby-schema.sql error,多启动几次又可以了。

启动成功后,访问 http://localhost:8848/nacos/ 进入 Nacos 管理页面,默认用户名和密码为 nacos/nacos

nacos-home.png

启动 Nacos Client

接下来,我们再准备一个简单的 Spring Boot 客户端程序,引入 nacos-discovery-spring-boot-starter 依赖,并通过它提供的 NameService 将服务信息注册到 Nacos Server:

@SpringBootApplication
@RestController
public class NacosApplication implements CommandLineRunner {

    @Value("${spring.application.name}")
    private String applicationName;

    @Value("${server.port}")
    private Integer serverPort;
    
    @NacosInjected
    private NamingService namingService;
    
    public static void main(String[] args) {
        SpringApplication.run(NacosApplication.class, args);
    }

    @Override
    public void run(String... args) throws Exception {
        namingService.registerInstance(applicationName, "192.168.1.40", serverPort);
    }

    @RequestMapping("/")
    public String home() {
        return String.format("Hello, I'm nacos client.");
    }
}

在配置文件中设置服务名称和服务端口:

spring.application.name=nacos-client
server.port=8082

以及 Nacos Server 的地址:

nacos.discovery.server-addr=127.0.0.1:8848

启动后,在 Nacos 的服务管理页面中就可以看到我们注册的服务了:

nacos-service-management.png

APISIX 集成 Nacos 服务发现

接下来,我们要让 APISIX 通过 Nacos Server 找到我们的服务。首先,在 APISIX 的配置文件 config.yaml 中添加如下内容:

discovery:
  nacos:
    host:
      - "http://192.168.1.40:8848"
    prefix: "/nacos/v1/"

然后重启 APISIX,接着向 APISIX 中添加如下路由:

$ curl -X PUT http://127.0.0.1:9180/apisix/admin/routes/22 \
    -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -i -d '
{
    "methods": ["GET"],
    "uri": "/nacos",
    "plugins": {
        "proxy-rewrite" : {
            "regex_uri": ["/nacos", "/"]
        }
    },
    "upstream": {
        "type": "roundrobin",
        "discovery_type": "nacos",
        "service_name": "nacos-client"
    }
}'

和上面 Eureka 服务发现的例子一样,我们也使用 proxy-rewrite 插件实现了路径重写功能,访问 APISIX 的 /nacos 地址验证一下:

$ curl http://127.0.0.1:9080/nacos
Hello, I'm nacos client.

我们成功通过 APISIX 访问到了我们的服务。

关于 APISIX 集成 Nacos 的更多信息,可以参考官方文档 基于 Nacos 的服务发现 和官方博客 Nacos 在 API 网关中的服务发现实践

基于 Consul 的服务发现

Consul 是由 HashiCorp 开源的一套分布式系统的解决方案,同时也可以作为一套服务网格解决方案,提供了丰富的控制平面功能,包括:服务发现、健康检查、键值存储、安全服务通信、多数据中心等。

启动 Consul Server

Consul 使用 Go 语言编写,安装和部署都非常简单,官方提供了 Consul 的多种安装方式,包括 二进制安装Kubernetes 安装HCP 安装。这里我们使用最简单的二进制安装方式,这种方式只需要执行一个可执行文件即可,首先,我们从 Install Consul 页面找到对应操作系统的安装包并下载:

$ curl -LO https://releases.hashicorp.com/consul/1.15.1/consul_1.15.1_linux_amd64.zip
$ unzip consul_1.15.1_linux_amd64.zip

下载并解压之后,Consul 就算安装成功了,使用 consul version 命令进行验证:

$ ./consul version
Consul v1.15.1
Revision 7c04b6a0
Build Date 2023-03-07T20:35:33Z
Protocol 2 spoken by default, understands 2 to 3 (agent will automatically use protocol >2 when speaking to compatible agents)

Consul 安装完成后,就可以启动 Consul Agent 了,Consul Agent 有 -server-client 两种模式,-client 一般用于服务网格等场景,这里我们通过 -server 模式启动:

$ ./consul agent -server -ui -bootstrap-expect=1 -node=agent-one -bind=127.0.0.1 -client=0.0.0.0 -data-dir=./data_dir
==> Starting Consul agent...
              Version: '1.15.1'
           Build Date: '2023-03-07 20:35:33 +0000 UTC'
              Node ID: '8c1ccd5a-69b3-4c95-34c1-f915c19a3d08'
            Node name: 'agent-one'
           Datacenter: 'dc1' (Segment: '<all>')
               Server: true (Bootstrap: true)
          Client Addr: [0.0.0.0] (HTTP: 8500, HTTPS: -1, gRPC: -1, gRPC-TLS: 8503, DNS: 8600)
         Cluster Addr: 127.0.0.1 (LAN: 8301, WAN: 8302)
    Gossip Encryption: false
     Auto-Encrypt-TLS: false
            HTTPS TLS: Verify Incoming: false, Verify Outgoing: false, Min Version: TLSv1_2
             gRPC TLS: Verify Incoming: false, Min Version: TLSv1_2
     Internal RPC TLS: Verify Incoming: false, Verify Outgoing: false (Verify Hostname: false), Min Version: TLSv1_2

==> Log data will now stream in as it occurs:

其中 -ui 表示开启内置的 Web UI 管理界面,-bootstrap-expect=1 表示服务器希望以 bootstrap 模式启动,-node=agent-one 用于指定节点名称,-bind=127.0.0.1 这个地址用于 Consul 集群内通信,-client=0.0.0.0 这个地址用于 Consul 和客户端之间的通信,包括 HTTP 和 DNS 两种通信方式,-data-dir 参数用于设置数据目录。关于 consul agent 更多的命令行参数,可以参考 Agents OverviewAgents Command-line Reference

简单起见,我们也可以使用 -dev 参数以开发模式启动 Consul Agent:

$ ./consul agent -dev

如果 Consul Agent 启动成功,访问 http://localhost:8500/ 进入 Consul 的管理页面,在服务列表可以看到 consul 这个服务:

consul-services.png

在节点列表可以看到 agent-one 这个节点:

consul-nodes.png

启动 Consul Client

让我们继续编写 Consul Client 程序,引入 spring-cloud-starter-consul-discovery 依赖,并通过 @EnableDiscoveryClient 注解将服务信息注册到 Consul Server:

@EnableDiscoveryClient
@SpringBootApplication
@RestController
public class ConsulApplication {
    
    public static void main(String[] args) {
        SpringApplication.run(ConsulApplication.class, args);
    }

    @RequestMapping("/")
    public String home() {
        return String.format("Hello, I'm consul client.");
    }
}

可以看到和 Eureka Client 的代码几乎是完全一样的,不过有一点要注意,我们还需要在 pom.xml 文件中引入 spring-boot-starter-actuator 依赖,开启 Actuator 端点,因为 Consul 默认是通过 /actuator/health 接口来对程序做健康检查的。

在配置文件中设置服务名称和服务端口:

spring.application.name=consul-client
server.port=8083

以及 Consul 相关的配置:

spring.cloud.consul.host=127.0.0.1
spring.cloud.consul.port=8500
spring.cloud.consul.discovery.service-name=${spring.application.name}
spring.cloud.consul.discovery.prefer-ip-address=true
spring.cloud.consul.discovery.ip-address=192.168.1.40

启动后,在 Consul 的服务管理页面中就可以看到我们注册的服务了:

consul-service-detail.png

APISIX 集成 Consul 服务发现

接下来,我们要让 APISIX 通过 Consul Server 找到我们的服务。首先,在 APISIX 的配置文件 config.yaml 中添加如下内容:

discovery:
  consul:
    servers:
      - "http://192.168.1.40:8500"

然后重启 APISIX,接着向 APISIX 中添加如下路由:

$ curl -X PUT http://127.0.0.1:9180/apisix/admin/routes/33 \
    -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -i -d '
{
    "methods": ["GET"],
    "uri": "/consul",
    "plugins": {
        "proxy-rewrite" : {
            "regex_uri": ["/consul", "/"]
        }
    },
    "upstream": {
        "type": "roundrobin",
        "discovery_type": "consul",
        "service_name": "consul-client"
    }
}'

访问 APISIX 的 /consul 地址验证一下:

$ curl http://127.0.0.1:9080/consul
Hello, I'm consul client.

关于 APISIX 集成 Consul 的更多信息,可以参考官方文档 基于 Consul 的服务发现

基于 Consul KV 的服务发现

Consul 还提供了分布式键值数据库的功能,这个功能和 Etcd、ZooKeeper 类似,主要用于存储配置参数和元数据。基于 Consul KV 我们也可以实现服务发现的功能。

首先准备 consul-kv-client 客户端程序,它的地址为 192.168.1.40:8084,我们通过 Consul KV 的 HTTP API 手工注册服务:

$ curl -X PUT http://127.0.0.1:8500/v1/kv/upstreams/consul-kv-client/192.168.1.40:8084 -d ' {"weight": 1, "max_fails": 2, "fail_timeout": 1}'

其中,/v1/kv/ 后的路径按照 {Prefix}/{Service Name}/{IP}:{Port} 的格式构成。可以在 Consul 的 Key/Value 管理页面看到我们注册的服务:

consul-key-value.png

然后在 APISIX 的配置文件 config.yaml 中添加如下内容:

discovery:
  consul_kv:
    servers:
      - "http://192.168.1.40:8500"
    prefix: "upstreams"

然后重启 APISIX,接着向 APISIX 中添加如下路由:

$ curl -X PUT http://127.0.0.1:9180/apisix/admin/routes/44 \
    -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -i -d '
{
    "methods": ["GET"],
    "uri": "/consul_kv",
    "plugins": {
        "proxy-rewrite" : {
            "regex_uri": ["/consul_kv", "/"]
        }
    },
    "upstream": {
        "type": "roundrobin",
        "discovery_type": "consul_kv",
        "service_name": "http://192.168.1.40:8500/v1/kv/upstreams/consul-kv-client/"
    }
}'

注意这里的 service_name 参数需要设置为 KV 的 URL 路径,访问 APISIX 的 /consul_kv 地址验证一下:

$ curl http://127.0.0.1:9080/consul_kv
Hello, I'm consul_kv client.

另一点需要注意的是,这种方式注册的服务没有健康检查机制,服务退出后需要手工删除对应的 KV:

$ curl -X DELETE http://127.0.0.1:8500/v1/kv/upstreams/consul-kv-client/192.168.1.40:8084

关于 APISIX 集成 Consul KV 的更多信息,可以参考官方文档 基于 Consul KV 的服务发现 和官方博客 Apache APISIX 集成 Consul KV,服务发现能力再升级

基于 DNS 的服务发现

Consul 不仅支持 HTTP API,而且还支持 DNS API,它内置了一个小型的 DNS 服务器,默认端口为 8600,我们以上面的 consul-client 为例,介绍如何在 APISIX 中集成 DNS 的服务发现。

注册到 Consul 中的服务默认会在 Consul DNS 中添加一条 <服务名>.service.consul 这样的域名记录,使用 dig 命令可以查询该域名的信息:

$ dig @192.168.1.40 -p 8600 consul-client.service.consul

; <<>> DiG 9.11.3-1ubuntu1.17-Ubuntu <<>> @192.168.1.40 -p 8600 consul-client.service.consul
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 32989
;; flags: qr aa rd; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1
;; WARNING: recursion requested but not available

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 4096
;; QUESTION SECTION:
;consul-client.service.consul.  IN      A

;; ANSWER SECTION:
consul-client.service.consul. 0 IN      A       192.168.1.40

;; Query time: 4 msec
;; SERVER: 192.168.1.40#8600(192.168.1.40)
;; WHEN: Tue Mar 21 07:17:40 CST 2023
;; MSG SIZE  rcvd: 73

上面的查询结果中只包含 A 记录,A 记录中只有 IP 地址,没有服务端口,如果用 A 记录来做服务发现,服务的端口必须得固定;好在 Consul 还支持 SRV 记录,SRV 记录中包含了服务的 IP 和端口信息:

$ dig @192.168.1.40 -p 8600 consul-client.service.consul SRV

; <<>> DiG 9.11.3-1ubuntu1.17-Ubuntu <<>> @192.168.1.40 -p 8600 consul-client.service.consul SRV
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 41141
;; flags: qr aa rd; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 3
;; WARNING: recursion requested but not available

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 4096
;; QUESTION SECTION:
;consul-client.service.consul.  IN      SRV

;; ANSWER SECTION:
consul-client.service.consul. 0 IN      SRV     1 1 8083 c0a80128.addr.dc1.consul.

;; ADDITIONAL SECTION:
c0a80128.addr.dc1.consul. 0     IN      A       192.168.1.40
agent-one.node.dc1.consul. 0    IN      TXT     "consul-network-segment="

;; Query time: 3 msec
;; SERVER: 192.168.1.40#8600(192.168.1.40)
;; WHEN: Tue Mar 21 07:18:22 CST 2023
;; MSG SIZE  rcvd: 168

我们在 APISIX 的配置文件 config.yaml 中添加如下内容:

discovery:
  dns:
    servers:
      - "192.168.1.40:8600"

然后重启 APISIX,接着向 APISIX 中添加如下路由:

$ curl -X PUT http://127.0.0.1:9180/apisix/admin/routes/55 \
    -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -i -d '
{
    "methods": ["GET"],
    "uri": "/dns",
    "plugins": {
        "proxy-rewrite" : {
            "regex_uri": ["/dns", "/"]
        }
    },
    "upstream": {
        "type": "roundrobin",
        "discovery_type": "dns",
        "service_name": "consul-client.service.consul"
    }
}'

访问 APISIX 的 /dns 地址验证一下:

$ curl http://127.0.0.1:9080/dns
Hello, I'm consul client.

关于 Consul DNS 的更多信息,可以参考官方文档 Discover services with DNS,除了 Consul DNS,我们也可以使用其他的 DNS 服务器来做服务发现,比如 CoreDNS 就是 Kubernetes 环境下的服务发现默认实现。

关于 APISIX 集成 DNS 的更多信息,可以参考官方文档 基于 DNS 的服务发现 和官方博客 API 网关 Apache APISIX 携手 CoreDNS 打开服务发现新大门

基于 APISIX-Seed 架构的控制面服务发现

上面所介绍的所有服务发现机制都是在 APISIX 上进行的,我们需要修改 APISIX 的配置,并重启 APISIX 才能生效,这种直接在网关上实现的服务发现也被称为 数据面服务发现,APISIX 还支持另一种服务发现机制,称为 控制面服务发现

控制面服务发现不直接对 APISIX 进行修改,而是将服务发现结果保存到 Etcd 中,APISIX 实时监听 Etcd 的数据变化,从而实现服务发现:

control-plane-service-discovery.png

APISIX 通过 APISIX-Seed 项目实现了控制面服务发现,这样做有下面几个好处:

  • 简化了 APISIX 的网络拓扑,APISIX 只需要关注 Etcd 的数据变化即可,不再和每个注册中心保持网络连接;
  • APISIX 不用额外存储注册中心的服务数据,减小内存占用;
  • APISIX 的配置变得简单,更容易管理;

虽然如此,目前 APISIX-Seed 还只是一个实验性的项目,从 GitHub 上的活跃度来看,官方似乎对它的投入并不是很高,目前只支持 ZooKeeperNacos 两种服务发现,而且官方也没有提供 APISIX-Seed 安装包的下载,需要我们自己通过源码来构建:

$ git clone https://github.com/api7/apisix-seed.git
$ make build

构建完成后,可以得到一个 apisix-seed 可执行文件,然后我们以上面的 Nacos 为例,介绍如何通过 APISIX-Seed 来实现控制面服务发现。

首先,我们将 APISIX 的配置文件中所有服务发现相关的配置都删掉,并重启 APISIX,接着打开 conf/conf.yaml 配置文件,文件中已经提前配置好了 Etcd、ZooKeeper、Nacos 等相关的配置,我们对其做一点裁剪,只保留下面这些信息:

etcd:
  host:
    - "http://127.0.0.1:2379"
  prefix: /apisix
  timeout: 30
    
log:
  level: warn
  path: apisix-seed.log
  maxage: 168h
  maxsize: 104857600
  rotation_time: 1h

discovery:
  nacos:
    host:
      - "http://127.0.0.1:8848"
    prefix: /nacos

然后启动 apisix-seed

$ ./apisix-seed
panic: no discoverer with key: dns

goroutine 15 [running]:
github.com/api7/apisix-seed/internal/discoverer.GetDiscoverer(...)
        D:/code/apisix-seed/internal/discoverer/discovererhub.go:42
        D:/code/apisix-seed/internal/core/components/watcher.go:84 +0x1d4
created by github.com/api7/apisix-seed/internal/core/components.(*Watcher).Init
        D:/code/apisix-seed/internal/core/components/watcher.go:48 +0x2b6
panic: no discoverer with key: consul

goroutine 13 [running]:
github.com/api7/apisix-seed/internal/discoverer.GetDiscoverer(...)
        D:/code/apisix-seed/internal/discoverer/discovererhub.go:42
github.com/api7/apisix-seed/internal/core/components.(*Watcher).handleQuery(0x0?, 0xc000091200, 0x0?)
        D:/code/apisix-seed/internal/core/components/watcher.go:84 +0x1d4
created by github.com/api7/apisix-seed/internal/core/components.(*Watcher).Init
        D:/code/apisix-seed/internal/core/components/watcher.go:48 +0x2b6

不过由于上面我们在路由中添加了 dns、consul 这些服务发现类型,这些 APISIX-Seed 是不支持的,所以启动会报错,我们需要将这些路由删掉:

$ curl -X DELETE http://127.0.0.1:9180/apisix/admin/routes/11 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1'
$ curl -X DELETE http://127.0.0.1:9180/apisix/admin/routes/33 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1'
$ curl -X DELETE http://127.0.0.1:9180/apisix/admin/routes/44 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1'
$ curl -X DELETE http://127.0.0.1:9180/apisix/admin/routes/55 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1'

只保留一条 /nacos 的路由,然后重启 apisix-seed 即可:

$ ./apisix-seed
2023-03-22T07:49:53.849+0800    INFO    naming_client/push_receiver.go:80       udp server start, port: 55038

访问 APISIX 的 /nacos 地址验证一下:

$ curl http://127.0.0.1:9080/nacos
Hello, I'm nacos client.

关于 APISIX-Seed 的更多信息,可以参考官方文档 基于 APISIX-Seed 架构的控制面服务发现APISIX-Seed 项目文档

基于 Kubernetes 的服务发现

我们的服务还可能部署在 Kubernetes 集群中,这时,不用依赖外部的服务注册中心也可以实现服务发现,因为 Kubernetes 提供了强大而丰富的监听资源的接口,我们可以通过监听 Kubernetes 集群中 Services 或 Endpoints 等资源的实时变化来实现服务发现,APISIX 就是这样做的。

我们以 Kubernetes 使用小记 中的 kubernetes-bootcamp 为例,体验一下 APISIX 基于 Kubernetes 的服务发现。

首先在 Kubernetes 集群中创建对应的 Deployment 和 Service:

$ kubectl create deployment kubernetes-bootcamp --image=jocatalin/kubernetes-bootcamp:v1
deployment.apps/kubernetes-bootcamp created

$ kubectl expose deployment/kubernetes-bootcamp --type="NodePort" --port 8080
service/kubernetes-bootcamp exposed

通过 kubectl get svc 获取 NodePort 端口,并验证服务能正常访问:

$ kubectl get svc
NAME                  TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)          AGE
kubernetes            ClusterIP   10.96.0.1       <none>        443/TCP          115d
kubernetes-bootcamp   NodePort    10.101.31.128   <none>        8080:32277/TCP   59s

$ curl http://192.168.1.40:32277
Hello Kubernetes bootcamp! | Running on: kubernetes-bootcamp-857b45f5bb-jtzs4 | v=1

接下来,为了让 APISIX 能查询和监听 Kubernetes 的 Endpoints 资源变动,我们需要创建一个 ServiceAccount

kind: ServiceAccount
apiVersion: v1
metadata:
 name: apisix-test
 namespace: default

以及一个具有集群级查询和监听 Endpoints 资源权限的 ClusterRole

kind: ClusterRole
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: apisix-test
rules:
- apiGroups: [ "" ]
  resources: [ endpoints ]
  verbs: [ get,list,watch ]

再将这个 ServiceAccountClusterRole 关联起来:

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: apisix-test
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: apisix-test
subjects:
  - kind: ServiceAccount
    name: apisix-test
    namespace: default

然后我们需要获取这个 ServiceAccount 的 token 值,如果 Kubernetes 是 v1.24 之前的版本,可以通过下面的方法获取 token 值:

$ kubectl get secrets | grep apisix-test
$ kubectl get secret apisix-test-token-c64cv -o jsonpath={.data.token} | base64 -d

Kubernetes 从 v1.24 版本开始,不能再通过 kubectl get secret 获取 token 了,需要使用 TokenRequest API 来获取,首先开启代理:

$ kubectl proxy --port=8001
Starting to serve on 127.0.0.1:8001

然后调用 TokenRequest API 生成一个 token:

$ curl 'http://127.0.0.1:8001/api/v1/namespaces/default/serviceaccounts/apisix-test/token' \
  -H "Content-Type:application/json" -X POST -d '{}'
{
  "kind": "TokenRequest",
  "apiVersion": "authentication.k8s.io/v1",
  "metadata": {
    "name": "apisix-test",
    "namespace": "default",
    "creationTimestamp": "2023-03-22T23:57:20Z",
    "managedFields": [
      {
        "manager": "curl",
        "operation": "Update",
        "apiVersion": "authentication.k8s.io/v1",
        "time": "2023-03-22T23:57:20Z",
        "fieldsType": "FieldsV1",
        "fieldsV1": {
          "f:spec": {
            "f:expirationSeconds": {}
          }
        },
        "subresource": "token"
      }
    ]
  },
  "spec": {
    "audiences": [
      "https://kubernetes.default.svc.cluster.local"
    ],
    "expirationSeconds": 3600,
    "boundObjectRef": null
  },
  "status": {
    "token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtLdHRyVzFmNTRHWGFVUjVRS3hrLVJMSElNaXM4aENLMnpfSGk1SUJhbVkifQ.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiXSwiZXhwIjoxNjc5NTMzMDQwLCJpYXQiOjE2Nzk1Mjk0NDAsImlzcyI6Imh0dHBzOi8va3ViZXJuZXRlcy5kZWZhdWx0LnN2Yy5jbHVzdGVyLmxvY2FsIiwia3ViZXJuZXRlcy5pbyI6eyJuYW1lc3BhY2UiOiJkZWZhdWx0Iiwic2VydmljZWFjY291bnQiOnsibmFtZSI6ImFwaXNpeC10ZXN0IiwidWlkIjoiMzVjZWJkYTEtNGZjNC00N2JlLWIxN2QtZDA4NWJlNzU5ODRlIn19LCJuYmYiOjE2Nzk1Mjk0NDAsInN1YiI6InN5c3RlbTpzZXJ2aWNlYWNjb3VudDpkZWZhdWx0OmFwaXNpeC10ZXN0In0.YexM_VoumpdwZNbSkwh6IbEu59PCtZrG1lkTnCqG24G-TC0U1sGxgbXf6AnUQ5ybh-CHWbJ7oewhkg_J4j7FiSAnV_yCcEygLkaCveGIQbWldB3phDlcJ52f8YDpHFtN2vdyVTm79ECwEInDsqKhn4n9tPY4pgTodI6D9j-lcK0ywUdbdlL5VHiOw9jlnS7b60fKWBwCPyW2uohX5X43gnUr3E1Wekgpo47vx8lahTZQqnORahTdl7bsPsu_apf7LMw40FLpspVO6wih-30Ke8CNBxjpORtX2n3oteE1fi2vxYHoyJSeh1Pro_Oykauch0InFUNyEVI4kJQ720glOw",
    "expirationTimestamp": "2023-03-23T00:57:20Z"
  }
}

默认的 token 有效期只有一个小时,可以通过参数改为一年:

$ curl 'http://127.0.0.1:8001/api/v1/namespaces/default/serviceaccounts/apisix-test/token' \
  -H "Content-Type:application/json" -X POST \
  -d '{"kind":"TokenRequest","apiVersion":"authentication.k8s.io/v1","metadata":{"name":"apisix-test","namespace":"default"},"spec":{"audiences":["https://kubernetes.default.svc.cluster.local"],"expirationSeconds":31536000}}'

我们在 APISIX 的配置文件 config.yaml 中添加如下内容( 将上面生成的 token 填写到 token 字段 ):

discovery:
  kubernetes:
    service:
      schema: https
      host: 127.0.0.1
      port: "6443"
    client:
      token: ...

这里有一个比较坑的地方,port 必须是字符串,否则会导致 APISIX 启动报错:

invalid discovery kubernetes configuration: object matches none of the required

然后重启 APISIX,接着向 APISIX 中添加如下路由:

$ curl -X PUT http://127.0.0.1:9180/apisix/admin/routes/66 \
    -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -i -d '
{
    "methods": ["GET"],
    "uri": "/kubernetes",
    "plugins": {
        "proxy-rewrite" : {
            "regex_uri": ["/kubernetes", "/"]
        }
    },
    "upstream": {
        "type": "roundrobin",
        "discovery_type": "kubernetes",
        "service_name": "kubernetes-bootcamp"
    }
}'

访问 APISIX 的 /kubernetes 地址验证一下:

$ curl http://127.0.0.1:9080/kubernetes

不过,如果你的 APISIX 运行在 Kubernetes 集群之外,大概率是访问不通的,因为 APISIX 监听的 Endpoints 地址是 Kubernetes 集群内的 Pod 地址:

$ kubectl describe endpoints/kubernetes-bootcamp
Name:         kubernetes-bootcamp
Namespace:    default
Labels:       app=kubernetes-bootcamp
Annotations:  endpoints.kubernetes.io/last-change-trigger-time: 2023-03-25T00:31:43Z
Subsets:
  Addresses:          10.1.5.12
  NotReadyAddresses:  <none>
  Ports:
    Name     Port  Protocol
    ----     ----  --------
    <unset>  8080  TCP

Events:  <none>

所以想使用基于 Kubernetes 的服务发现,最佳做法是将 APISIX 部署在 Kubernetes 中,或者使用 APISIX Ingress,关于 APISIX 集成 Kubernetes 的更多信息,可以参考官方文档 基于 Kubernetes 的服务发现 和官方博客 借助 APISIX Ingress,实现与注册中心的无缝集成

参考

更多

实现自定义服务发现

https://apisix.apache.org/zh/docs/apisix/discovery/

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

使用 GitHub Actions 跟踪 GitHub 趋势项目

GitHub Actions 是 GitHub 于 2018 年 10 月推出的一款 CI/CD 服务。一个标准的 CI/CD 流程通常是一个工作流(workflow),工作流中包含了一个或多个作业(job),而每个作业都是由多个执行步骤(step)组成。

GitHub Actions 的创新之处在于它将 CI/CD 中的每个执行步骤划分成一个个原子的操作(action),这些操作可以是编译代码、调用某个接口、执行代码检查或是部署服务等。很显然这些原子操作是可以在不同的 CI/CD 流程中复用的,于是 GitHub 允许开发者将这些操作编写成脚本存在放 GitHub 仓库里,供其他人使用。GitHub 提供了一些 官方的 actions,比如 actions/setup-python 用于初始化 Python 环境,actions/checkout 用于签出某个代码仓库。由于每个 action 都对应一个 GitHub 仓库,所以也可以像下面这样引用 action 的某个分支、某个标签甚至某个提交记录:

actions/setup-node@master  # 指向一个分支
actions/setup-node@v1.0    # 指向一个标签
actions/setup-node@74bc508 # 指向一个 commit

你可以在 GitHub Marketplace 中搜索你想使用的 action,另外,还有一份关于 GitHub Actions 的 awesome 清单 sdras/awesome-actions,也可以找到不少的 action。

GitHub Actions 入门示例

这一节我们将通过一个最简单的入门示例了解 GitHub Actions 的基本概念。首先我们在 GitHub 上创建一个 demo 项目 aneasystone/github-actions-demo(也可以直接使用已有的项目),然后打开 Actions 选项卡:

get-started-with-github-actions.png

我们可以在这里手工创建工作流(workflow),也可以直接使用 GitHub Actions 提供的入门工作流,GitHub Actions 提供的工作流大体分为四种类型:

  • Continuous integration - 包含了各种编程语言的编译、打包、测试等流程
  • Deployment - 支持将应用部署到各种不同的云平台
  • Security - 对仓库进行代码规范检查或安全扫描
  • Automation - 一些自动化脚本

这些工作流的源码都可以在 actions/starter-workflows 这里找到。

GitHub 会自动分析代码并显示出可能适用于你的项目的工作流。由于是示例项目,这里我们直接使用一个最简单的工作流来进行测试,选择 Simple workflow 这个工作流,会在 .github/workflows 目录下创建一个 blank.yml 文件,文件内容如下:

# This is a basic workflow to help you get started with Actions

name: CI

# Controls when the workflow will run
on:
  # Triggers the workflow on push or pull request events but only for the "main" branch
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]

  # Allows you to run this workflow manually from the Actions tab
  workflow_dispatch:

# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
  # This workflow contains a single job called "build"
  build:
    # The type of runner that the job will run on
    runs-on: ubuntu-latest

    # Steps represent a sequence of tasks that will be executed as part of the job
    steps:
      # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
      - uses: actions/checkout@v3

      # Runs a single command using the runners shell
      - name: Run a one-line script
        run: echo Hello, world!

      # Runs a set of commands using the runners shell
      - name: Run a multi-line script
        run: |
          echo Add other actions to build,
          echo test, and deploy your project.

这个工作流没有任何用处,只是使用 echo 命令输出一行 Hello, world! 以及其他几行日志而已。

然后点击 Start commit 按钮提交文件即可:

simple-workflow.png

由于这里我们指定了工作流在 push 的时候触发,所以提交完文件之后,这个工作流应该就开始执行了。重新打开 Actions 选项卡:

all-workflows.png

这里显示了项目中所有的工作流列表,我们可以在一个项目中创建多个工作流。可以看到我们已经成功创建了一个名为 CI 的工作流,并在右侧显示了该工作流的运行情况。点击查看详细信息:

all-workflow-jobs.png

这里是工作流包含的所有作业(job)的执行情况,我们这个示例中只使用了一个名为 build 的作业。点击作业,可以查看作业的执行日志:

all-workflow-job-logs.png

详解 workflow 文件

在上一节中,我们通过在 .github/workflows 目录下新建一个 YAML 文件,创建了一个最简单的 GitHub Actions 工作流。这个 YAML 的文件名可以任意,但文件内容必须符合 GitHub Actions 的工作流程语法。下面是一些基本字段的解释。

name

出现在 GitHub 仓库的 Actions 选项卡中的工作流程名称。如果省略该字段,默认为当前 workflow 的文件名。

on

指定此工作流程的触发器。GitHub 支持多种触发事件,您可以配置工作流程在 GitHub 上发生特定活动时运行、在预定的时间运行,或者在 GitHub 外部的事件发生时运行。参见 官方文档 了解触发工作流程的所有事件。

在示例项目中,我们使用了几个最常用的触发事件。比如当 main 分支有 pushpull_request 时触发:

on:
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]

或者开启手工触发工作流:

on:
  workflow_dispatch:

这时会在工作流页面出现一个手工执行的按钮:

run-workflow-manually.png

也可以使用定时任务来触发工作流:

on:
  schedule:
    - cron: "0 2 * * *"

jobs

一个工作流可以包含一个或多个作业,这些作业可以顺序执行或并发执行。下面定义了一个 ID 为 build 的作业:

jobs:
  build:
    ...

jobs.<job-id>.runs-on

为作业指定运行器(runner),运行器可以使用 GitHub 托管的(GitHub-hosted runners),也可以是 自托管的(self-hosted runners)。GitHub 托管的运行器包括 Windows Server、Ubuntu、macOS 等操作系统,下面的例子将作业配置为在最新版本的 Ubuntu Linux 运行器上运行:

runs-on: ubuntu-latest

jobs.<job-id>.steps

作业中运行的所有步骤,步骤可以是一个 Shell 脚本,也可以是一个操作(action)。在我们的示例中一共包含了三个步骤,第一步使用了一个官方的操作 actions/checkout@v3

# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- uses: actions/checkout@v3

这个操作将代码仓库签出到运行器上,这样你就可以对代码运行脚本或其他操作,比如编译、测试或构建打包等。

第二步,使用 echo 命令输出一句 Hello, world!

# Runs a single command using the runners shell
- name: Run a one-line script
  run: echo Hello, world!

第三步,继续执行多条 echo 命令:

# Runs a set of commands using the runners shell
- name: Run a multi-line script
  run: |
    echo Add other actions to build,
    echo test, and deploy your project.

跟踪 GitHub 趋势项目

学习了 GitHub Actions 的基本知识后,我们就可以开始使用它了。除了常见的 CI/CD 任务,如 自动构建和测试打包和发布部署 等,还可以使用它来做很多有趣的事情。

GitHub 有一个 Trending 页面,可以在这里发现 GitHub 上每天、每周或每月最热门的项目,不过这个页面没有归档功能,无法追溯历史。如果我们能用爬虫每天自动爬取这个页面上的内容,并将结果保存下来,那么查阅起来就更方便了。要实现这个功能,必须满足三个条件:

  1. 能定时执行:可以使用 on:schedule 定时触发 GitHub Actions 工作流;
  2. 爬虫脚本:在工作流中可以执行任意的脚本,另外还可以通过 actions 安装各种语言的环境,比如使用 actions/setup-python 安装 Python 环境,使用 Python 来写爬虫最适合不过;
  3. 能将结果保存下来:GitHub 仓库天生就是一个数据库,可以用来存储数据,我们可以将爬虫爬下来的数据提交并保存到 GitHub 仓库。

可以看到,使用 GitHub Actions 完全可以实现这个功能,这个想法的灵感来自 bonfy/github-trending 项目,不过我在这个项目的基础上做了一些改进,比如将每天爬取的结果合并在同一个文件里,并且对重复的结果进行去重。

首先我们创建一个仓库 aneasystone/github-trending,然后和之前的示例项目一样,在 .github/workflows 目录下创建一个流水线文件,内容如下:

# This workflow will scrap GitHub trending projects daily.

name: Daily Github Trending

on:
  schedule:
    - cron: "0 2 * * *"

jobs:
  build:

    runs-on: ubuntu-latest

    steps:
    - name: Checkout
      uses: actions/checkout@v2
      
    - name: Set up Python 3.8
      uses: actions/setup-python@v2
      with:
        python-version: 3.8
    
    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip
        pip install -r requirements.txt
        
    - name: Run Scraper
      run: |
        python scraper.py
    # Runs a set of commands using the runners shell
    - name: Push to origin master
      run: |
        echo start push
        git config --global user.name "aneasystone"
        git config --global user.email "aneasystone@gmail.com"
        
        git add -A
        git commit -m $(date '+%Y-%m-%d')
        git push

在这里我们使用了 on.schedule.cron: "0 2 * * *" 来定时触发工作流,这个 cron 表达式需符合 POSIX cron 语法,可以在 crontab guru 页面上对 cron 表达式进行调试。不过要注意的是,这里的时间为 UTC 时间,所以 0 2 * * * 对应的是北京时间 10 点整。

注:在实际运行的时候,我发现工作流并不是每天早上 10 点执行,而是到 11 点才执行,起初我以为是定时任务出现了延迟,但是后来我才意识到,现在正好是夏天,大多数北美洲、欧洲以及部分中东地区都在实施 夏令时,所以他们的时间要比我们早一个小时。

工作流的各个步骤是比较清晰的,首先通过 actions/checkout@v2 签出仓库代码,然后使用 actions/setup-python@v2 安装 Python 环境,然后执行 pip install 安装 Python 依赖。环境准备就绪后,执行 python scraper.py,这就是我们的爬虫脚本,它会将 GitHub Trending 页面的内容爬取下来并更新到 README.md 文件中,我们可以根据参数爬取不同编程语言的项目清单:

languages = ['', 'java', 'python', 'javascript', 'go', 'c', 'c++', 'c#', 'html', 'css', 'unknown']
for lang in languages:
    results = scrape_lang(lang)
    write_markdown(lang, results)

数据爬取成功后,我们在工作流的最后通过 git commit & git push 将代码提交到 GitHub 仓库保存下来。你可以在这里 aneasystone/github-trending 查看完整的代码。

参考

更多

其他示例

结合 GitHub Actions 的自动化功能,我们可以做很多有趣的事情。比如官方文档中还提供了 其他几个示例,用于检测仓库中失效的链接。

另外,阮一峰在他的 入门教程 中介绍了一个示例,用于将 React 应用发布到 GitHub Pages。

在本地运行 GitHub Actions

https://github.com/nektos/act

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

Typecho 文章二维码插件

15 年的时候,我曾经写过一个 Typecho 文章二维码的插件,发布在 Typecho 的论坛里,自那以后,由于时间和精力有限(其实是由于懒癌和拖延症晚期),这个插件一直没有更新过。当时写这个插件时是基于 jeromeetienne/jquery-qrcode 实现的,不过由于 jquery-qrcode 依赖于 jquery,如果你的站点上有其他插件也依赖 jquery,很可能会出现版本冲突的情况,譬如如果你用到了 jquery 2.x,而 jquery-qrcode 用的是 jquery 1.9,就会导致插件不可用。在这期间,也有不少用户给我反馈过这个插件在他们的站点上不能用,一直想着重新弄一个,趁着周末重新写了个新版本,这个版本基于 davidshimjs/qrcodejs 实现,它不依赖任何其他库。虽然也可以使用其他手段,譬如 同时兼容 jquery 的多个版本,不过实现起来略嫌麻烦。

这个版本发布在 我的 Github 上,欢迎大家试用。

  1. 下载插件的最新版本 并解压,将 QRCode 目录放到 Typecho 的插件目录 /usr/plugins 下;
  2. 进入 Typecho 控制台 -- 插件管理,在 禁用的插件 列表中,应该会出现 QRCode 插件,启用该插件;
  3. 启动的插件 列表中找到 QRCode 插件,点击 设置 按钮,可以设置二维码的尺寸等参数;
  4. 访问你的任意一篇文章,如果一切设置 OK,在文章的最下面,评论的上方,应该可以看到为这篇文章自动生成的二维码;
  5. 使用手机扫描二维码,检查是否可以在手机上访问这篇文章;

如果有任何问题,欢迎给我提交 Issues 或 PR。

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

CodeIgniter 3.0 中文文档

有一个月左右的时间都没有写博客了,这是因为这个月整个人的心思全都花在对 CodeIgniter 3.0 文档的翻译工作上。从我的 GitHub 主页上也可以看到从7月12号开始到今天8月4号,连续24天提交一直没有间断过。

streak.png

一、序

翻译 CodoIgniter 的文档纯属一个偶然的想法,由于在网上经常会看到有人问 3.0 的中文文档什么时候出?而 CodeIgniter 3.0.0 从5月份发布到现在确实也过去快3个月了,而中文文档在这点上确实有着严重的滞后。正好这段时间比较清闲,打算将之前2.2版本的一个项目升级到3.0,而升级的过程中也遇到了不少的问题,于是就决定将 CodeIgniter 3.0.0 的文档从头到尾仔细看一遍。刚好又在网上看到有人问中文文档的事,于是想着为啥我不自己来试着翻译下呢,就这样一边啃着 3.0.0 的英文文档,一边对照着 CodeIgniter 中国的 2.2.2 版本的中文文档,悄悄的就过了一个月了。

经过这一个月的努力,我发现翻译工作确实非常辛苦,在这里不得不对那些从事技术书籍翻译的人表示敬意,也对网络上那些对技术文档提供中文翻译的大牛表示敬意。同时也对中文技术文档的缺少和滞后感到无力,不止是 CodeIgniter,只要你去搜你就会发现无论是 PHP 的文档也好,MySQL 的文档也好,最新的中文文档都要落后几个版本。希望能有更多的人加入到技术文档的翻译工作中来。如果有哪位朋友正在翻译,欢迎联系我,我愿尽薄力。

二、问题反馈

由于本人能力和精力有限,翻译之中难免犯错,欢迎大家批评指正。你可以通过三种途径来反馈问题:

  1. 直接在我的博客上留言;
  2. 在 GitHub 上直接提交 Issues ;
  3. 或者 Fork 我的项目作出修改然后向我提交 Pull Request 。

我会尽快修复文档中的错误。

三、构建你自己的文档

所有 CodeIgniter 文档的源文件都可以从我的 GitHub 上获取,地址是:https://github.com/aneasystone/ci-doc-chinese。你首先需要使用 git clone 将它获取下来:

$ git clone https://github.com/aneasystone/ci-doc-chinese

CodeIgniter 的文档是采用 ReStructured Text 格式编写的,所以如果你想自己添加或修改文档的话,可以先熟悉下 ReStructured Text 的语法,如果你对 Markdown 的语法有所了解,相信你能很快上手的。

另外,你的电脑上需要安装 Python 和 Sphinx,Sphinx 是一个非常强大的文档生成工具,使用它不仅能生成漂亮美观的 HTML 文档,还可以生成其他的各种格式,包括:PDF,EPUB,LaTeX 等等等等。这里有一篇 IBM 的文章介绍了如何通过 Sphinx 制作文档,你可以简单的看一看。如果你想完整的学习它,这里是有一份 Sphinx 的中文文档,也可以去官网看最新的英文文档

除了 Sphinx ,还需要安装 Sphinx 的扩展 sphinxcontrib-phpdomain 和 CodeIgniter 自带的一个程序 cilexer ,cilexer 实际上是 Pygments 的一个插件,用于文档中的代码高亮。具体的安装步骤中文文档里有详细的说明。

一切准备就绪后,你就可以直接使用 make html 来生成 HTML 文档了。

make-html.png

当然,你也可以使用 make epubmake pdf 来生成其他格式的文档,具体的用法可以看下 Makefile 文件。

四、资源

如果你不想自己折腾,我已经将编译好的 HTML 文件发布到我自己的网站上了,你可以直接在线查看,你也可以和 CodeIgniter 2.2.2 的中文文档(已升级)以及官方的英文版本对照阅读。另外,文末我也提供了离线版下载,你也可以下载到你的电脑上查看,Enjoy!

另外,CI 中国也推出了 3.0 的中文文档,建议以该文档的最新版本为准,可以在上面与其他网友进行讨论!

CodeIgniter 3.0.0 中文文档(CI 中国版)

CodeIgniter 3.0.0 中文文档(本站备份)

CodeIgniter 3.0.0 官方英文文档

CodeIgniter 3.0.0 中文文档下载

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

我的第二个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文件,你可以点击这里下载。另外,完整的源代码在这里

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

博客正式从WordPress转为Typecho

最近深深的迷上了Markdown,无论是编写代码说明文档还是记日记写博客,都能用Markdown快速轻松的书写。使用过GitHub的人应该都知道Markdown,Markdown让人沉浸在写作之中,而不用关心排版问题,只需几个简单的标记就能完成文档的自动排版。而且由于它是纯文本,具有极强的兼容性,可以方便的转换为各种格式。

于是我开始寻找WordPress上支持Markdown的编辑器,虽然有一些但都不是很好。便又开始寻找支持Markdown的博客系统,在这个开源遍地开花的年代,这样的博客系统还真是多如牛毛,让人眼花缭乱。有系统庞大功能性非常复杂的,也有小巧轻盈扩展性非常完美的。在这么多的博客系统中,最终我选择了Typecho,不仅是因为Typecho的轻量高效稳定简洁的特性,而且这款博客系统完全是由国人团队开发,选择使用Typecho也算是对国内的开源事业做出的一点点支持吧。

Typecho仅仅使用了7张数据表和不足400KB的代码,就实现了完整的插件与模板机制,打造了一个和WordPress几无二致的博客系统,而且Typecho和WordPress兼容,很容易从WordPress转到Typecho。不过,虽说如此,但是我在安装Typecho以及转换的过程中还是遇到了不少的问题和挫折,在鼓捣了一周左右的时间后,终于大功告成,仅以此文纪念之。

一、安装Typecho

Typecho的安装非常方便,按照官方的安装步骤,首先从这里下载最新的稳定版本(写这篇博客时,最新版本为1.0),然后解压并上传到服务器Web目录,然后使用浏览器打开你的博客地址,根据Typecho安装程序的提示一步步的安装即可。

当然这是理想情况下的安装步骤,实际上我的安装过程并没有这么顺利,在填完我的配置信息提交之后,出现了下面的错误:

安装程序捕捉到以下错误:"". 程序被终止, 请检查您的配置信息.

install_fail.png

对的你没看错,以下错误是什么错误?是个空字符串!不得不说这真的是一个非常坑爹的错误提示。幸亏Typecho是个开源的博客程序,在install.php文件中搜索字符串“安装程序捕捉到以下错误”,于是便找到了这个异常代码的所在之处(无关代码已省略):

try {
    $installDb = new Typecho_Db($config['adapter'], $config['prefix']);
    $installDb->addServer($config, Typecho_Db::READ | Typecho_Db::WRITE);

    /** 初始化数据库结构 */
    // ...

    $scripts = explode(';', $scripts);
    foreach ($scripts as $script) {
        $script = trim($script);
        if ($script) {
            $installDb->query($script, Typecho_Db::WRITE);  // 这里抛出异常
        }
    }
} catch (Typecho_Db_Exception $e) {
    $success = false;
    $code = $e->getCode();

    if (...) {
        // ...
    } else {
        echo '<p class="message error">' 
         . _t('安装程序捕捉到以下错误: "%s". 程序被终止, 请检查您的配置信息.',$e->getMessage()) . '</p>';
    }
}

抛出异常的正是上面注释的那句$installDb->query($script, Typecho_Db::WRITE),而$installDb是这样定义的:

$installDb = new Typecho_Db($config['adapter'], $config['prefix']);

Typecho_Db类根据用户选择的数据库适配器进行数据库操作,安装时配置页有两个可供选择:Mysql原生的和Pdo驱动的,如下图的下拉列表所示:

db_config.png

而在这里我选择了默认的Mysql原生函数适配器,对应的数据库处理代码在/var/Typecho/Db/Adapter/Mysql.php文件中,看到下面的代码我瞬间明白了为什么会有这么奇怪的错误提示了:

    /**
     * 数据库连接函数
     *
     * @param Typecho_Config $config 数据库配置
     * @throws Typecho_Db_Exception
     * @return resource
     */
    public function connect(Typecho_Config $config)
    {
        if ($this->_dbLink = @mysql_connect($config->host . (empty($config->port) ? '' : ':' . $config->port),
        $config->user, $config->password, true)) {
            if (@mysql_select_db($config->database, $this->_dbLink)) {
                if ($config->charset) {
                    mysql_query("SET NAMES '{$config->charset}'", $this->_dbLink);
                }
                return $this->_dbLink;
            }
        }

        /** 数据库异常 */
        throw new Typecho_Db_Adapter_Exception(@mysql_error($this->_dbLink));
    }

原因就在于上面代码中的@mysql_connect@mysql_select_db@符号是php中特有的一种错误控制符(error-control operator),意味着如果这个函数抛出异常将被完全忽略掉,没有errorCode,没有errorMessage。所以上面的错误提示信息使用$e->getMessage()方法获取的是个空字符串。

安装失败的原因现在已经定位到了是数据库导致的,接下来就看看为什么数据库配置会失败。这时候我注意到上面图片中红色高亮的提示信息:

系统将为您自动匹配 SAE 环境的安装选项

其实在安装的过程中我就很纳闷,为什么都没有提示我输入数据库的用户名密码这些配置信息,原来是Typecho检测到了我的系统为SAE环境,直接使用SAE里的数据库配置。而我的环境确实是SAE不假,只不过我使用的是SAE的本地开发环境,而且我从来没有做过SAE的数据库配置。按F12查看页面源码发现数据库的配置确实都是空。

sae_cfg.png

于是打开SAE的配置文件sae.conf

DocumentRoot path/to/website

http_port 80
https_port 443
redis_port 6379
domain sinaapp

mysql_user 
mysql_pass 
mysql_host 
mysql_port 

proxy_host  
proxy_port 80
proxy_username 
proxy_password 

open_xdebug 0
autoupgrade 1

其中mysql_user, mysql_pass, mysql_host, mysql_port分别对应数据库的用户名、密码、主机地址和端口号。于是马上填好这些配置参数,重启SAE,重新安装Typecho,这时这些参数都OK了,但还是差了一个参数dbDatabase,这个值一直是app_:

<input type="hidden" name="dbDatabase" value="app_">

这个app_其实是SAE_MYSQL_DB的值,于是我们查看SAE的代码,在文件sae/emulation/loadsae.php中找到了它的定义:

define('SAE_MYSQL_DB', 'app_'.$_SERVER['HTTP_APPNAME']);

原来app_是数据库的前缀,后面还有一个叫做HTTP_APPNAME$_SERVER变量。从变量名可以推断出这应该是SAE应用的名称,所以可能还需要在SAE中创建应用,但是具体操作我就没深究了,而是采用了一个取巧的方法,直接修改下面的这两行HTML源码:

<input type="hidden" name="config" value="array (
    'host'      =>  SAE_MYSQL_HOST_M,
    'user'      =>  SAE_MYSQL_USER,
    'password'  =>  SAE_MYSQL_PASS,
    'charset'   =>  'utf8',
    'port'      =>  SAE_MYSQL_PORT,
    'database'  =>  'typecho_db'
)">
<input type="hidden" name="dbDatabase" value="typecho_db">

然后点击“确认”按钮提交,终于看到了安装成功的页面!

二、将WordPress数据导出到Typecho

接下来,我便马不停蹄的开始了我的Typecho体验之旅。首先第一件事便是将之前WordPress上的博文导到Typecho上来,好消息是Typecho官方已经提供这样的插件了,我们去这里下载wordpresstotypecho然后遵循步骤解压上传到插件目录并在后台的插件管理里启动该插件。(虽然官网上有提示信息说:仅适用于wordpress2.7,但在我的4.2版本下也转换成功了)

就在我兴高采烈的去后台的插件管理里点击“启动插件”链接满心期待着我的WordPress华丽变身Typecho时,一个白底的页面并伴随着一个Fatal error吓到了我:

Fatal error: Call to undefined function mb_regex_encoding() in typechovarTypechoCommon.php on line 810

WTF!一百只草泥马从眼前飞过有木有!哎,没办法,继续看代码看看哪里出错了吧,谁叫我们是程序猿呢。搜索函数mb_regex_encoding,还好,只有Common.php中用到了一次,代码如下:

/**
 * 生成缩略名
 */
public static function slugName($str, $default = NULL, $maxLength = 128)
{
    // ...
    
    if (__TYPECHO_MB_SUPPORTED__) {
        mb_regex_encoding(self::$charset);
        mb_ereg_search_init($str, "[\w" . preg_quote('_-') . "]+");
        $result = mb_ereg_search();
        // ...
    } else if ('UTF-8' == strtoupper(self::$charset)) {
        // ...
    } else {
        // ...
    }

    $str = trim($str, '-_');
    $str = !strlen($str) ? $default : $str;
    return substr($str, 0, $maxLength);
}

这段代码的用途是根据字符串自动生成缩略名(slugName),其中使用到的mb_打头的函数都是php提供的针对多字节字符串处理的函数。但是mb_函数并不是所有的php版本都支持,所以在使用时需要先确定系统是否支持,Typecho通过下面的方法来判断系统是否支持mb_函数的:

define('__TYPECHO_MB_SUPPORTED__', function_exists('mb_get_info'));

可见,Typecho认为只要系统支持mb_get_info函数,应该也支持mb_regex_encoding这些mb_函数。而事实却并不是这样,我尝试了几个不同版本的Windows上的PHP,发现都是支持mb_get_info而不支持mb_regex_encoding,我不知道是不是Windows版本的PHP都会存在这个问题,Google之并没有得到我需要的答案,有人说可能是权限问题,有人说是配置问题,还有人说需要使用--enable-mbstring参数重新编译下PHP。哦,在Windows下重新编译PHP?实在是太酷了!但是,等一下,我好像只是想装个博客系统而已。

让我们回到现实,不要走的太远。slugName这个函数也不是什么核心功能,而且我们看代码可以知道,如果不支持mb_函数后面照样能走通,既然如此,我们暂且就认为我们的系统不支持mb_函数好了,这又能怎么样呢?于是稍微改了下代码:

define('__TYPECHO_MB_SUPPORTED__', function_exists('mb_regex_encoding'));

再点“启用插件”链接,OK,顺利进来了。按照页面提示,需要填写WordPress的数据库配置信息,然后进入“从Wordpress导入数据”页面,点击“开始数据转换”按钮。轻松几秒,转换完成,去“管理文章”页面看下,是不是非常熟悉的文章列表页面又回来了?

三、折腾Markdown

好了,现在博客已经搭建完毕,可以在Typecho下尽情的使用我最喜爱的Markdown语法进行写作了,这就结束了吗?哦,当然不,我们的Typecho之旅才刚刚开始!谁让我们程序猿都是爱折腾的人呢!

我们随便编辑一篇文章,Typecho会弹出下面的提示文字:

这篇文章不是由Markdown语法创建的, 继续使用Markdown编辑它吗?

选择“是”即可使用Markdown语法书写,这样文章的格式就是Html和Markdown混合格式。这多多少少有些让人感觉不爽,特别是看到类似下面的列表代码时:

<ul>
    <li>blah ...</li>
    <li>blah blah ...</li>
    <li>blah blah blah ...</li>
</ul>

对于我们这些有着“洁癖”和“强迫症”的程序猿来说,是不是就有种强烈的冲动想把它改成下面的Markdown代码:

- blah ...
- blah blah ...
- blah blah blah ...

怎么办呢?难道我们要手动把之前的所有博客都转换成Markdown语法么?Of course, No. 我们程序猿的特点就是这样:就算是能够在一个小时内手工完成的工作,也要写上六个小时的代码,然后再调试六个小时,最后花三秒钟运行自动完成!

Google上搜索 “Html to Markdown” 得到大量结果,说明这种工作已经有大把大把的人做过了,我们要做的就是找个靠谱点的工具直接用就好了。最终我把目光锁定在了html2text这个开源的Python项目上,并且打算写段Python脚本自动将我之前的所有的博客转换为Markdown语法。代码非常简单:

def main():
    
    posts = get_all_posts()
    posts = convert_posts_to_markdown(posts)
    save_posts(posts)

    return

get_all_posts函数从MySQL数据库中获取Typecho所有文章列表(也就是typecho_contents表),convert_posts_to_markdown直接调用函数html2text.html2text()将文章转换为Markdown语法,然后再调用save_posts将转换后的文章保存到数据库。

理论上这短短三行代码就能瞬间搞定,嗯,理论上。

事实上,在我的很多文章中有很多的代码块,而我为了让我的代码块看起来好看一点,使用了WordPress上提供的一个代码高亮插件:SyntaxHighlighter Evolved。它的语法类似于下面这样:

[code lang='javascript']
function sayHello() {
    console.log('hello');
}
[/code]

在使用html2text.html2text()函数转换这样的代码时,会把它视为普通的段落文本,这样我所有的代码里的空格换行全被剔除了,变成了一团乱麻。于是稍微修改了下程序的逻辑,在转换之前先用正则将文章中所有[code...]...[/code]这种格式的代码替换成唯一的uuid,然后再转换之后再将uuid替换为类似下面的代码:

function sayHello() {

console.log('hello');

}

完整的脚本可以参考这里,你可能需要根据你的插件或其他的一些不同的语法做些调整。

四、折腾主题样式

现在所有的文章都已经转换成Markdown语法了,去主页上随便逛逛,看着自己的劳动成果即将面世还是有些成就感的。接下来便是换个靠谱的主题了。

我个人是比较推崇单列布局的宽屏主题,这样不仅显得页面很干净清爽,让人看文章时能保持专注不至于被侧边栏的内容分心,而且在宽屏下代码可以完整的显示出来,如果页面很窄的话,代码过长就会导致出现大量横向的滚动条,如果设置成不显示滚动条又会导致代码出现大量的自动换行,看起来非常不舒服,完全没有在IDE中看代码的感觉。使用WordPress时我使用的就是这样的主题:Decode,而且自己做了点调整,将内容的宽度调大了一点。于是,我还要找一个类似这样的Typecho主题。

最终,我选择了Typecho论坛上一个非常火的主题:Navy,虽然没有找到这个主题比较官方的链接,但是可以直接从论坛里下载使用。Navy主题清爽简洁,唯一不足的一点是:右侧栏展开后只能显示“最新文章”和部分的“分类”,下面剩下的“分类”部分和“归档”都未显示,并且也不能用鼠标滚轮向下滚动。于是修改了下样式文件navy/style.css,对侧边栏增加了overflow-y: auto;

#secondary {
    height: 100%;
    width: 300px;
    top: 0;
    right: -330px;
    position: fixed;
    z-index: 100;
    box-shadow: -3px 0 8px rgba(0, 0, 0, 0.1);
    font-size: 13px;
    background-color: #303538;
    color: #f8f8f8;
    overflow-y: auto;  /* 右侧栏显示垂直滚动条 */
}

这样修改之后,侧边栏右边会出现一个滚动条,这个滚动条和浏览器的滚动条并行显示,虽然看起来比较ugly,但也勉强够用了。另外对Navy主题的样式还做了些其他的定制(不感兴趣的同学请直接忽略):

.container {
    width:1150px;  /* 增大内容宽度 */
    padding:0 60px;
    margin:0 auto;
}

a, button.submit {
    color:#009BCD;  /* 链接颜色改为淡蓝色 */
    text-decoration:none;
    -webkit-transition:all .1s ease-in;
    -moz-transition:all .1s ease-in;
    -o-transition:all .1s ease-in;
    transition:all .1s ease-in;
}

pre, code {
    background: #F0F0EC;
    color: #C13;  /* 代码的背景为灰色,字体为暗红色 */
    font-family:Menlo, Monaco, Consolas, "Lucida Console", "Courier New", monospace;
    font-size:.92857em;
    /*display: block;*/  /* 代码块内联显示 */
}

.post-content img {
    max-width:100%;  /* 防止图片太宽撑出去 */
    margin-left: 0;
}

blockquote,.stressed {
    -moz-box-sizing: border-box;
    box-sizing: border-box;
    margin: 1.8em 0 1.8em 2.2em;  /* 引用文字改为向内缩进,太突出感觉怪怪的 */
    padding: 0 3.8em 0 1.6em;
    border-left: #4a4a4a 0.4em solid;
    /* font-style: italic; */  /* 去掉引用文字的斜体 */
    color: #888;
}

.post-content ul {
    overflow: auto;
    /*padding: .5em 2.4em;*/
    border-radius: 3px;
    /*margin:1.8em 0;*/  /* 列表的内外边距都太大了 */
}

五、折腾代码高亮

到这里,博客基本上已经打造的差不多了,但是还差最后一步,Typecho默认的代码样式简直让人想起了上个世纪在记事本里写代码的年代。确实,在如今的开发环境中,代码高亮几乎已成为标配,没有高亮的代码无论是给人阅读,还是书写都让人感到非常不爽。

所以,最后一步,我来为我的Typecho加上代码高亮的功能,而这样的插件也直接就已经有现成的了,我这里还是使用著名的SyntaxHighlighter插件来实现代码高亮,它的Typecho版是:SyntaxHighlighter-For-Typecho。从GitHub上直接"Download ZIP",然后解压并上传到服务器上的Typecho插件目录(要特别注意的是:插件的目录名和插件类名必须保持一致,譬如这里的类名为SyntaxHighlighter_Plugin,所以目录名必须是SyntaxHighlighter,要不然会出现500 Server Error,这又是一个非常非常坑爹的错误提示!我认为Typecho在出错时的提示信息上面还可以做的更好)

六、Markdown标记小结

Typecho实现了最基本的一些Markdown语法,我这里总结下。

6.1 字体加粗

语法:**粗体**
结果:粗体

6.2 斜体

语法:*斜体*
结果:斜体

6.3 链接

语法:[百度](http://www.baidu.com)
结果:百度

6.4 图片

语法:![image text](https://www.baidu.com/img/bdlogo.png)
结果:

image text

6.5 标题

语法:

# This is <h1>
## This is <h2>
### This is <h3>

结果:

This is <h1>

This is <h2>

This is <h3>

6.6 列表

语法:

1. 有序列表One
2. 有序列表Two
3. 有序列表Three

- 无序列表One
- 无序列表Two
- 无序列表Three

结果:

  1. 有序列表One
  2. 有序列表Two
  3. 有序列表Three
  • 无序列表One
  • 无序列表Two
  • 无序列表Three

6.7 引用

语法:> 这是引用文字
结果:

这是引用文字

6.8 行内代码

语法: `var i = 0;`
结果:
var i = 0;

6.9 代码块

语法:
```
function sayHello() {
}
```
结果:

function sayHello() {
}

Typecho支持的Markdown语法有限,其实,还有很多其他的语法在特定的场景下也会比较有用,譬如绘制表格,删除线,表情,LaTeX格式,流程图,序列图等等。可以看下作业部落的Cmd Markdown编辑器,其华丽程度简直让人目瞪口呆。然后这里有一份比较规矩还不错的语法说明,另外GitHub也有Markdown的一份指导文档

参考

  1. 开始用 Markdown 写博客
  2. Typecho文档站点
  3. php - @mysql_connect and mysql_connect - Stack Overflow
  4. PHP手册 > 函数参考 > 国际化与字符编码支持 > 多字节字符串
  5. Cmd Markdown 编辑阅读器 - 作业部落出品
  6. Markdown 语法说明 (简体中文版)
  7. Mastering Markdown · GitHub Guides
扫描二维码,在手机上阅读!

我的第一个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的差异
扫描二维码,在手机上阅读!