标签归档:php

php

PHP MongoDB Replica Set应用

MongoDB是个面向文档管理的NoSQL数据库,能够直接存取JSON数据,支持海量数据。公司内部的一个单点登录系统使用MongoDB来存储用户的session,以便在不同应用,服务器之间共享登录信息,解决了服务器切换用户状态丢失(被登出)的问题。单点登录系统前端采用PHP,与LDAP交互认证用户信息,提供登录界面,API等。MongoDB则采用Replica Set模式以便支持高可用。
在CentOS 6.5上安装MongoDB,首先添加源仓库

$ suod vim /etc/yum.repos.d/mongodb.repo
[mongodb]
name=MongoDB Repository
baseurl=http://downloads-distro.mongodb.org/repo/redhat/os/x86_64/
gpgcheck=0
enabled=0

安装,启动,连接MongoDB

$ sudo yum --enablerepo=mongodb install mongodb-org
$ sudo /sbin/chkconfig --levels 235 mongod on
$ sudo mongod --port 27017 --dbpath /data/db1
$ mongo --port 27017

创建用户SSOReplUser

> use admin 
> db.createUser( { user: "SSOReplUser", pwd: "<password>", roles: [ { role: "root", db: "admin" } ] });

创建集群要用到的key

$ sudo openssl rand -base64 741 > /home/sso/mongodb-keyfile 
$ sudo chmod 600 /home/sso/mongodb-keyfile

编辑MongoDB配置文件

$ sudo vim /etc/mongod.conf
# Replication Options 
# in replicated mongo databases, specify the replica set name here 
replSet=SSOReplSet 
# maximum size in megabytes for replication operation log 
#oplogSize=1024 
# path to a key file storing authentication info for connections # between replica set members keyFile=/home/sso/mongodb-keyfile

重启mongod服务

$ sudo /etc/init.d/mongod stop   
$ sudo /usr/bin/mongod -f /etc/mongod.conf

使用SSOReplUser登录

> use admin 
> db.auth("SSOReplUser", "<password>");

初始化集群

> rs.initiate()
> rs.conf()   
{
  "_id": "SSOReplSet",
  "version": 1,
  "members": [
    {
      "_id": 1,
      "host": "192.168.33.10:27017"
    }
  ]
}

在其他机器上按照上面步骤安装MongoDB,更改配置,复制对应的mongodb-keyfile并启动。
回到192.168.33.10并添加机器

SSOReplSet:PRIMARY> rs.add("192.168.33.11:27017") 
SSOReplSet:PRIMARY> rs.add("192.168.33.12:27017") 
SSOReplSet:PRIMARY> rs.add("192.168.33.13:27017")
#SSOReplSet:PRIMARY> rs.remove("192.168.33.13:27017")

在从机上认证查看

SSOReplSet:SECONDARY> use admin 
SSOReplSet:SECONDARY> db.auth("SSOReplUser", "<password>");   
SSOReplSet:SECONDARY> rs.status()

MongoDB的Replica set模式最少要求3台机器,以便在PRIMARY机器故障时,能够自我选举出新的PRIMARY,但需要n/2+1的机器投票同意。例如刚才配置了4台机器,那么需要4/2+1=3台机器投票,能够承受一台机器故障。如果是集群有5台机器,则能够承受2台机器故障。因此再配置一台不存储数据的Arbiter机器比较合理。
按照上面的步骤安装,复制mongodb-keyfile文件,但需要更改mongod.conf

$sudo vim /etc/mongod.conf
nojournal=true

然后启动mongod服务并在PRIMARY机器上将这台机器加入机器

SSOReplSet:PRIMARY> use admin   
SSOReplSet:PRIMARY> db.auth("SSOReplUser", "<password>");   
SSOReplSet:PRIMARY> rs.addArb("<ip of arbiter>");

PHP的mongo扩展安装比较简单,下载对应版本,启用即可。然而在应用过程中发现登录有点慢,启用看PHP mongo扩展profiling功能,查看PHP日志

<?php
\MongoLog::setLevel(\MongoLog::ALL);
\MongoLog::setModule(\MongoLog::ALL);
try{
    echo  microtime(true) . PHP_EOL;
    echo "aaa01(primary)" . PHP_EOL;
    $m = new \MongoClient("mongodb://admin:XXXXXXXXX@192.168.33.10:27017/?replicaSet=SSOReplSet ");
    echo  microtime(true) . PHP_EOL;
    echo "aaa01(primay), bbb01(secondary),ccc01(secondary),ddd01(secondary)" . PHP_EOL;
    $m = new \MongoClient("mongodb://admin:XXXXXXXXX@192.168.33.10:27017,192.168.33.11:27017,192.168.33.12:27017,192.168.33.13:27017/?replicaSet=SSOReplSet ");
    echo  microtime(true) . PHP_EOL;
} catch (\MongoConnectionException $e) {
    var_dump($e);
}

发现PHP连接MongoDB集群是这样子的

即:

  • 1)在连接里面配置了几个MongoDB连接服务器,PHP每个都会创建连接去查询
  • 2)从每台服务器上查询出整个集群的服务器列表,再分别ping和连接这些服务器,如果连接不存在或不匹配则创建,无效的则销毁
  • 3)汇总所有服务器返回集群列表
  • 4)选择离自己最近的服务器并使用该与该服务器的连接
  • MongoDB 的写操作默认在Primary节点上操作完成即返回成功;读操作默认是也是从Primary上读取,所以需要去查询服务器。由于SSO应用部署在多个数据中心,网络抖动会造成较大影响,跨数据中心的查询并不会很快,如果每次都去连接并查询整个列表是比较耗时。另外如果在php里面配置的是IP而MongoDB Replica Set里面配置的是域名,则连接名会出现不匹配,而创建新的连接并销毁旧连接,也耗时。如果配置的是域名,则需要DNS解析。由于每台PHP服务器均已配置HA检测,最终每个应用只配置了一台服务器,并统一配置MongoDB集群为IP。而连接最快的是在Primary机器上,可以根据本机是否Primary来做HA:

    #!/usr/bin/env bash
    count=`ps -fe |grep "mongod" | grep -v "grep" | wc -l`
    FILE="/home/scripts-bits/master.conf"
    SERVER=`hostname`
    
    if [ $count -lt 1 ]; then
        rm -f $FILE
    else
        PRIMAY=`/usr/bin/mongo ${SERVER}:27017 --quiet --eval 'printjson(db.isMaster().ismaster);'`
        if [ "$PRIMAY" == "true" ]; then
        	if [ ! -f "$FILE" ]; then
        		touch "$FILE"
        	fi
        	REMOVE=`/usr/bin/mongo ${SERVER}:27017/admin --quiet /home/scripts-bits/mongo_status.js`
        fi
    fi
    

    删除故障节点(not reachable/healthy)的脚本mongo_status.js:

    db.auth('admin','<password>');
    conf=rs.status();
    members=conf["members"];
    for(i in members){
    	if(members[i]["state"] == 8){
    		rs.remove(members[i]["name"]);
    	}
    }
    

    这中间也出现过因为网络/机器故障,导致PHP等待连接超时的情况的,将该机器从集群中移除即可。然而当服务器是启动的(可以到达)情况下,MongoDB故障,则不会超时。可以更改connectTimeoutMS,以便减少等待。
    MongoDB的日志默认都是记录在/var/log/mongodb/下面并且会越来越大。创建Python脚本来定时清除它:

    #!/bin/env python
    import commands
    import datetime,time
    
    def rotate_log(path, expire = 30):
        str_now = time.strftime("%Y-%m-%d")
        dat_now = time.strptime(str_now, "%Y-%m-%d")
        array_dat_now = datetime.datetime(dat_now[0], dat_now[1], dat_now[2])
        lns = commands.getoutput("/bin/ls --full-time %s|awk '{print $6, $9}'" % path)
        for ln in lns.split('\n'):
            ws = ln.split()
            if len(ws) != 2:
                continue
            ws1 = time.strptime(ws[0], "%Y-%m-%d")
            ws2 = datetime.datetime(ws1[0], ws1[1], ws1[2])
            if (array_dat_now - ws2).days > expire:
                v_del = commands.getoutput("/bin/rm -rf %s/%s" % (path, ws[1]))
    
    
    def rotate_mongo():
        # get mongo pid
        mongo_pid = commands.getoutput("/sbin/pidof mongod")
        #print mongo_pid
        # send Sig to mongo
        if mongo_pid != '':
            cmd = "/bin/kill -USR1 %s" % (mongo_pid)
            # print cmd
            mongo_rotate = commands.getoutput(cmd)
        else:
            print "mongod is not running..."
    
    if __name__ == "__main__":
        log_path = "/var/log/mongodb/"
        expire = 30
        rotate_mongo()
        rotate_log(log_path, expire)
    
    

    加入到crontab里面去执行

    10 1 * * * /usr/bin/python /home/sso/mongo_rotate.py > /dev/null 2>&1
    

    参考连接
    mongodb与mysql相比的优缺点
    PHP MongoDB 复制集合
    MongoDB Replication
    MongoDB Enable Auth
    MongoDB Rotate Log Files
    Blocking connect() leads to cumulative timeouts for multiple inaccessible servers
    How Raft consensus algorithm will make replication even better in MongoDB 3.2
    Write Concern for Replica Sets
    MongoDB Read Preference

    日志系统设计

    日志系统是项目开发/运维当中非常重要的一部分,提供产品使用情况,跟踪调试信息等。以前都是各个项目各自实现日志记录,比如PHP、JAVA各自实现一套,如果要跨项目/服务器进行查询/跟踪/统计,则比较麻烦,比如Web后台–>业务模块–>基础组件,客户端–>公共接口–>业务模块等等。
    目前的项目都是将日志写入本地,首先需要定义统一的规范:

    格式
    DateTime | ServerIP | ClientIP | PID | RequestID|  Type | Level | Message|  Code
    
    解释
    DateTime:记录日志的当前时间戳
    ServerIP:当前服务器IP
    ClientIP:客户端IP
    PID:进程ID
    RequestID:交易号,即请求的唯一标识,使用uniqid(+UserCode)或UUID或,一次请求会有多条日志,,用于关联本次请求内的相关日志
    Type:日志类型,比如统计,操作(审计),业务等
    Level:日志等级
    Message:日志内容,当为值为数组或者对象时转为JSON
    Type为RUNTIME时,表示运行日志,属性:自定义
    Type为HTTP时,表示来源请求,属性:Url,Method(Get/Post),Params,RemoteIP,UserAgent,ReferUrl[,Action,Method]
    Type为REST时,表示外部调用,属性:Type(Http/Https),Url,Port,RequestParams,Response,RunTime;
    Type为SQL时,表示SQL执行,属性:Sql,RunTime
    Code:标识码,记录错误码、响应码、统计码、版本、自定义信息,方便统计
    

    这里的RequestID由入口处自动产生,用于标识一次请求,关联所产生的所有日志,需要在各个项目之间传递。为了减少日志,Level通常为Info级别,避免产生过多日志。为了方便调试追踪,RequestID和Level也可以在其他参数中指明,比如HTTP头里面附加。
    然后是日志收集:客户端收走日志系统,发送给日志系统服务端。
    然后分析处理呈现:服务端将接收到的日志,发给处理其他组件分析处理,提供Web界面的查询系统。研发人员,可以错误信息,定位问题;获悉程序运行情况进行调优;大数据分析日志,得出产品使用情况;运维平台则可以进行业务报警。

    日志产生由个语言依照规范自行实现,收集、保持则由FlumeKafka实现。Flume是一个分布式的日志收集、合并、移动系统,能够监控文件变化,将变化部分传输出去。Kafka是一个分布式的发布/订阅的消息流平台,包括Broker,Consumer,Producer都支持分布式,依赖Zookeeper实现。

    在PHP上面的实现,一开始使用log4php,看起来很美好,但是性能很差,对业务影响较大。决定再次简化,砍掉不必要的东西(Socket,邮件等等),在C语言开发的PHP日志扩展SeasLog基础上在做开发,将日志文件保存在本地。为了减少日志所占用内存,每超过一定的大小的日志即进行保存,否则在最后进行保存,利用了Nginx的fastcgi_finish_request特性。生产上发现,每天产生的文件日志太大了,需要控制日志信息大小、等级,并及时清理。
    对于Web后台,还结合FirePHP,将日志直接输出到浏览器,方便边运行变调试。

    参考链接:
    最佳日志实践
    Optimal Logging
    Flume+Kafka收集Docker容器内分布式日志应用实践
    基于Flume的美团日志收集系统(一)架构和设计
    Twitter是如何构建高性能分布式日志的
    开源日志系统比较
    Kafka剖析(一):Kafka背景及架构介绍
    基于Flume的野狗实时日志系统的演进和优化
    EVODelavega/phpkafka
    有赞统一日志平台初探
    RabbitMQ和kafka从几个角度简单的对比
    Flume-ng的原理和使用
    利用flume+kafka+storm+mysql构建大数据实时系统

    使用Docker Compose管理Docker容器

    上一篇创建了一个PHP的运行环境,现在需要一个MySQL服务,直接运行:

    root@thinkpad:~# docker run --name rocket-mysql -v /home/rocketfish/mysql/data:/var/lib/mysql -e MYSQL_ROOT_PASSWORD=root -d mysql:5.7
    

    就自动下载并得到一个mysql版本为5.7的服务镜像。
    这时候,要运行一个Web服务我们需要运行两次的docker run才可以,如果还有更多的Web容器或其他的服务容器呢?
    Docker官方提倡一个容器仅提供一个服务,多个服务/容器可以使用docker-compose来管理。
    docker-compose本身是一个Python写的工具,可以直接通过pip安装:

    root@thinkpad:~# sudo pip install --upgrade pip
    

    如果你本地并没有Python环境,也可以采用docker-compose的docker镜像来运行:

    root@thinkpad:/home/compose-web# curl -L https://github.com/docker/compose/releases/download/1.8.0/run.sh > /usr/local/bin/docker-compose
    root@thinkpad:/home/compose-web# docker-compose --version
    #开始下载镜像,建议用第一种
    

    查看帮助

    root@thinkpad:~# docker-compose -h
    Define and run multi-container applications with Docker.
    
    Usage:
      docker-compose [-f <arg>...] [options] [COMMAND] [ARGS...]
      docker-compose -h|--help
    
    Options:
      -f, --file FILE             Specify an alternate compose file (default: docker-compose.yml)
      -p, --project-name NAME     Specify an alternate project name (default: directory name)
      --verbose                   Show more output
      -v, --version               Print version and exit
      -H, --host HOST             Daemon socket to connect to
    
      --tls                       Use TLS; implied by --tlsverify
      --tlscacert CA_PATH         Trust certs signed only by this CA
      --tlscert CLIENT_CERT_PATH  Path to TLS certificate file
      --tlskey TLS_KEY_PATH       Path to TLS key file
      --tlsverify                 Use TLS and verify the remote
      --skip-hostname-check       Don't check the daemon's hostname against the name specified
                                  in the client certificate (for example if your docker host
                                  is an IP address)
    
    Commands:
      build              Build or rebuild services
      bundle             Generate a Docker bundle from the Compose file
      config             Validate and view the compose file
      create             Create services
      down               Stop and remove containers, networks, images, and volumes
      events             Receive real time events from containers
      exec               Execute a command in a running container
      help               Get help on a command
      kill               Kill containers
      logs               View output from containers
      pause              Pause services
      port               Print the public port for a port binding
      ps                 List containers
      pull               Pulls service images
      push               Push service images
      restart            Restart services
      rm                 Remove stopped containers
      run                Run a one-off command
      scale              Set number of containers for a service
      start              Start services
      stop               Stop services
      unpause            Unpause services
      up                 Create and start containers
      version            Show the Docker-Compose version information
    

    这些命令包括了创建/启动/停止/暂停/继续运行容器。
    首先要创建docker-compose.yml,这是一个YAML格式的文档

    mkdir docker
    cd docker
    mkdir web
    mkdir db
    vim docker-compose.yml
    

    内容如下:

    version: '2'
    services:
      db:
        image: mysql:5.7
        ports:
        - "3306:3306"
        restart: always
        environment:
          MYSQL_ROOT_PASSWORD: root
          MYSQL_DATABASE: web
          MYSQL_USER: root
          MYSQL_PASSWORD: rootweb
        volumes:
        - ./db/data:/var/lib/mysql
      web:
        depends_on:
          - db
        image: nginx-php-fpm:phalcon
        ports:
        - "80:80"
        restart: always
        environment:
          WEB_DB_HOST: db:3306
          WEB_DB_PASSWORD: root
        volumes:
        - ./web/html:/var/www/html/
        links:
        - db
    

    这里的environment定义了环境变量,比如host的MAC,就可以在docker容器的系统变量里面得到,各个编程语言都有获取系统环境变量的方法。这些变量也可以组织起来放在文件里面加载进去,参考这里
    这里的volumes定义了要映射进容器的文件或文件夹或者数据容器,可以参考这里。注意,多个容器共享同一个目录,会出现写冲突,比如MySQL,多个实例,数据目录需要分开。所以设计好你的程序,哪一部分是需要只读的(可以共享),哪一部分是需要写的;写的部分是否可以放到各自临时目录,或者其他公共服务里面。
    运行并查看

    root@thinkpad:/home/compose-web# docker-compose up -d
    Creating network "composeweb_default" with the default driver
    Creating composeweb_db_1
    Creating composeweb_web_1
    root@thinkpad:/home/compose-web# docker-compose ps
          Name                   Command             State              Ports            
    ------------------------------------------------------------------------------------
    composeweb_db_1    docker-entrypoint.sh mysqld   Up      0.0.0.0:3306->3306/tcp      
    composeweb_web_1   /start.sh                     Up      443/tcp, 0.0.0.0:80->80/tcp 
    #也可以使用原来的命令
    root@thinkpad:/home/compose-web# docker ps
    CONTAINER ID        IMAGE                   COMMAND                  CREATED             STATUS              PORTS                         NAMES
    efbdaf257748        nginx-php-fpm:phalcon   "/start.sh"              13 seconds ago      Up 11 seconds       0.0.0.0:80->80/tcp, 443/tcp   composeweb_web_1
    a6935d20911e        mysql:5.7               "docker-entrypoint.sh"   14 seconds ago      Up 13 seconds       0.0.0.0:3306->3306/tcp        composeweb_db_1
    
    

    docker-compose up -d ,将会在后台启动并运行所有的容器。
    docker-compose仅仅提供了对多个docker服务的管理,仍然可以在这些容器上运行原来的docker命令。检查下web容器的IP:

    root@thinkpad:/home/compose-web# docker inspect composeweb_web_1 | grep IPAddress
                "SecondaryIPAddresses": null,
                "IPAddress": "",
                        "IPAddress": "172.18.0.3",
    

    然后访问:http://172.18.0.3/ 就可以看到web页面了。
    如果想要停止服务,可以用docker-compose stop:

    root@thinkpad:/home/compose-web# docker-compose stop db
    Stopping composeweb_db_1 ... done
    root@thinkpad:/home/compose-web# docker-compose stop web
    Stopping composeweb_web_1 ... done
    

    再次启动,得到相同名称的服务:

    root@thinkpad:/home/compose-web# docker-compose up -d
    Starting composeweb_db_1
    Starting composeweb_web_1
    

    各个容器的名称也可以在配置文件里面通过参数container_name来指定。
    docker-compose的配置还有很多其他的参数,可以参考这里。比如刚才我们通过docker inspect来查找容器IP,也可以配置成静态IP:

    version: '2'
    
    services:
      app:
        image: nginx-php-fpm:phalcon
        networks:
          app_net:
            ipv4_address: 172.18.0.10
            ipv6_address: 2001:3984:3989::10
    
    networks:
      app_net:
        driver: bridge
        driver_opts:
          com.docker.network.enable_ipv6: "true"
        ipam:
          driver: default
          config:
          - subnet: 172.18.0.0/24
            gateway: 172.18.0.1
          - subnet: 2001:3984:3989::/64
            gateway: 2001:3984:3989::1
    

    也可以自定义网络
    刚才我们的配置文件里面直接指定了镜像,也可以指定Dockerfile镜像构建,假如Dockerfile在web目录下面:

    build:
      context: ./web
    

    或者直接指明Dockerfile:

    build:
      context: .
      dockerfile: Dockerfile-alternate
    

    实际上,docker-compose的配置已经覆盖了Dockerfile的配置项,也可以直接用于构建多个docker服务,比如指定运行的命令

    command: [/bin/bash]
    

    这里的入口程序,如果不能持续运行的话,运行完成后,docker就会退出。所以如果你需要可以后台运行的docker容器,那么入口程序,就必须保持一个程序在前台运行,不退出,比如运行crontab的镜像,入口程序可以是:

    cron && bash
    

    cron这个程序是在后台运行的,如果不配合前台程序bash,容器执行玩cron就会退出了。其他运行crontab的容器,大都是强制一个程序在前台不退出,比如这个

    cron && tail -f /var/log/cron.log
    

    这里需要注意一下,通过docker设置的environment values在容器内的cli下面执行时取不到的,比如printenv,使用cron后台运行时取不到外部设置的变量,但是手动执行时又可以。这些外部环境变量只能在ENTRYPOINT或者CMD所执行的shell里面去设置,比如在CMD执行start.sh:

    printenv | grep -E "^HOST" > /root/env.conf && cron && bash
    

    Docker compose还可以跟Docker Swarm结合。

    参考链接:
    Install Docker Compose
    Quickstart: Docker Compose and WordPress
    YAML 模板文件
    Introduction to Docker Compose Tool for Multi-Container Applications
    Dockerfile基本结构
    How to Get Environment Variables Passed Through docker-compose to the Containers
    Access environment variables from crontab into a docker container
    How can I access Docker set Environment Variables From a Cron Job
    Dockerfile里指定执行命令用ENTRYPOING和用CMD有何不同?
    What is the difference between CMD and ENTRYPOINT in a Dockerfile?
    Docker difference between run, cmd, entrypoint commands

    基于Docker的Nginx + PHP-FPM + Phalcon镜像

    上一篇简单介绍了Docker的安装,运行,这一篇来构建一个基于Nginx和PHP-FPM的Phalcon镜像。在官方找了以下,单独的Nginx和PHP镜像更加流行,混合的反倒不是很受欢迎。其实官方并不提倡在一个容器里面运行多个服务,最好是一个容器只对外提供一个服务:一个容器启动时仅仅运行一个命令(其实里面可以包含多个),也方便部署扩展升级。多个服务之间可以使用Docker Compose来管理。但是Docker并不阻止创建包含多个服务器的镜像,为了方便,所以我们仍然可以自己构建。
    构建镜像可以有好几种方式,比如基于Alpine Linuxphusion/baseimage-docker构建,或者基于Ubuntu,CentOS等构建,又或者在PHP,Nginx的基础镜像上构建。注意:如果要采用Ubuntu或者CentOS构建,可能需要一些额外的工作,以便保持镜像轻量稳定运行。
    这里采用已有的richarvey/nginx-php-fpm来构建,它是一个基于Nginx官方镜像来构建的。
    Github上拉取相关文件从Dockerfile构建:

    $ sudo git clone https://github.com/ngineered/nginx-php-fpm
    $ sudo docker build -t nginx-php-fpm:latest .
    

    关于Dockerfile的相关解释,可以参考这里。当然也可以直接拉取镜像使用

    $ sudo docker pull richarvey/nginx-php-fpm
    # 也可以直接运行,会自动拉取
    #$ sudo docker run -d richarvey/nginx-php-fpm
    

    查看本地的镜像,连单独的nginx也来了:

    root@thinkpad:~# docker images
    REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
    nginx-php-fpm       latest              4fc9ac9f2945        7 hours ago         228.5 MB
    nginx               mainline-alpine     00bc1e841a8f        5 days ago          54.21 MB
    

    这里的mainline-alpine是指基于Alpine Linux构建的。Alpine Linux是一个仅有5M大小的linux系统,采用apk add/search来安装/查找相应软件,有许多镜像都是基于它构建的,官方PHP镜像也有基于它构建的Docker镜像。
    然后运行nginx-php-fpm:

    root@thinkpad:~# docker run --name web -d richarvey/nginx-php-fpm
    

    docker inspect命令用来查看容器的相关信息,查看下分配的IP:

    root@thinkpad:~# docker inspect web | grep IPAddress
                "SecondaryIPAddresses": null,
                "IPAddress": "172.17.0.2",
                        "IPAddress": "172.17.0.2",
    

    然后在浏览器里面访问:http://172.17.0.2/就可以看到phpinfo的页面。到这里,Nginx + PHP的web容器就已经运行起来了,对应的Nginx和PHP进程可以在宿主机器上直接查看:

    root@thinkpad:~# ps aux | grep nginx
    root     18167  0.0  0.0  13696  4300 pts/6    S    01:47   0:00 nginx: master process /usr/sbin/nginx
    systemd+ 18168  0.0  0.0  14144  1868 pts/6    S    01:47   0:00 nginx: worker process
    systemd+ 18169  0.0  0.0  14144  1868 pts/6    S    01:47   0:00 nginx: worker process
    systemd+ 18170  0.0  0.0  14144  1868 pts/6    S    01:47   0:00 nginx: worker process
    systemd+ 18171  0.0  0.0  14144  1868 pts/6    S    01:47   0:00 nginx: worker process
    systemd+ 18172  0.0  0.0  14144  1868 pts/6    S    01:47   0:00 nginx: worker process
    root     18190  0.0  0.0  21292  1012 pts/18   S+   01:47   0:00 grep --color=auto nginx
    root@thinkpad:~# ps aux | grep php-fpm
    root     18166  0.0  0.2 167880 23364 pts/6    S    01:47   0:00 php-fpm: master process (/etc/php5/php-fpm.conf)
    systemd+ 18173  0.0  0.1 167880  8620 pts/6    S    01:47   0:00 php-fpm: pool www
    systemd+ 18174  0.0  0.1 167880  8620 pts/6    S    01:47   0:00 php-fpm: pool www
    systemd+ 18175  0.0  0.1 167880  8620 pts/6    S    01:47   0:00 php-fpm: pool www
    root     18192  0.0  0.0  21292  1032 pts/18   S+   01:47   0:00 grep --color=auto php-fpm
    

    接下来要为这个容器添加Phalcon扩展。首先要进入容器里面,使用docker attach命令进入:

    root@thinkpad:~# docker attach web
    
    
    
    

    结果在这里等了半天进不去。。。。查看下当前镜像入口程序:

    root@thinkpad:~# docker ps
    CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS               NAMES
    94176348a939        nginx-php-fpm       "/start.sh"         6 seconds ago       Up 5 seconds        80/tcp, 443/tcp     web
    

    这个容器启动的时候运行的是start.sh这个脚本,这个脚本运行了Supervisor工具。于是重新启动容器,运行/bin/bash

    #终止容器运行
    root@thinkpad:~# docker stop web
    web
    #删除容器
    root@thinkpad:~# docker rm web
    web
    #重新运行
    root@thinkpad:~# docker run --name web -d -t -i nginx-php-fpm /bin/bash
    ea21e10df702644a83ed75930b30c7764a786c4feabdf17cd868f86640137c47
    root@thinkpad:~# docker ps
    CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS               NAMES
    ea21e10df702        nginx-php-fpm       "/bin/bash"         6 seconds ago       Up 5 seconds        80/tcp, 443/tcp     web
    root@thinkpad:~# docker attach web
    #进来了
    bash-4.3# ls
    bin       etc       lib       media     proc      run       srv       sys       usr
    dev       home      linuxrc   mnt       root      sbin      start.sh  tmp       var
    

    就可以进去了。
    先安装编译相关工具包:

    bash-4.3# apk --no-cache add php5-dev
    bash-4.3# apk --no-cache add gcc
    bash-4.3# apk --no-cache add make
    bash-4.3# apk --no-cache add autoconf
    bash-4.3# apk --no-cache add libc-dev
    fetch http://dl-cdn.alpinelinux.org/alpine/v3.4/main/x86_64/APKINDEX.tar.gz
    fetch http://dl-cdn.alpinelinux.org/alpine/v3.4/community/x86_64/APKINDEX.tar.gz
    (1/2) Installing musl-dev (1.1.14-r12)
    (2/2) Installing libc-dev (0.7-r0)
    OK: 334 MiB in 106 packages
    

    编译安装Phalcon:

    bash-4.3# cd /home
    bash-4.3# git clone --depth=1 git://github.com/phalcon/cphalcon.git
    bash-4.3# cd cphalcon/build
    bash-4.3# ./install
    bash-4.3# ls -la /usr/lib/php5/modules/ | grep phalcon
    -rwxr-xr-x    1 root     root       5045264 Sep 28 17:34 phalcon.so
    

    更改PHP扩展的配置:

    bash-4.3# cd /etc/php5/conf.d/
    bash-4.3# vi phalcon.ini
    #添加以下内容
    #extension=phalcon.so
    
    #检查扩展是否加载成功
    bash-4.3# php -i | grep phalcon
    /etc/php5/conf.d/phalcon.ini,
    phalcon
    phalcon => enabled
    phalcon.db.escape_identifiers => On => On
    phalcon.db.force_casting => Off => Off
    phalcon.orm.cast_on_hydrate => Off => Off
    phalcon.orm.column_renaming => On => On
    phalcon.orm.enable_implicit_joins => On => On
    phalcon.orm.enable_literals => On => On
    phalcon.orm.events => On => On
    phalcon.orm.exception_on_failed_save => Off => Off
    phalcon.orm.ignore_unknown_columns => Off => Off
    phalcon.orm.late_state_binding => Off => Off
    phalcon.orm.not_null_validations => On => On
    phalcon.orm.virtual_foreign_keys => On => On
    OLDPWD => /home/cphalcon/build
    _SERVER["OLDPWD"] => /home/cphalcon/build
    _ENV["OLDPWD"] => /home/cphalcon/build
    

    加载成功了,需要保持本次镜像变更。首先退出容器:

    bash-4.3# cd /home
    #删除各种不必要的东西,比如gcc
    bash-4.3# rm -rf cphalcon/
    bash-4.3# exit
    exit
    

    然后查看版本并提交变更:

    root@thinkpad:~# docker ps -l
    CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS                     PORTS               NAMES
    ea21e10df702        nginx-php-fpm       "/bin/bash"         31 minutes ago      Exited (0) 6 seconds ago                       web
    root@thinkpad:~# docker commit ea2 nginx-php-fpm:phalcon
    sha256:bb388df328ecc33fac02dba69759d5c992a145f650a0e5b20ca29a4b122fa933
    

    docker commit命令可以用来提交变更,ea2是container id的前三位,也可以写全;然后跟的是要提交的镜像。这里提交到phalcon这个标签下,以便与原来的区分开。查看所有镜像,发现有两个不同的标签:

    root@thinkpad:~# docker images
    REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
    nginx-php-fpm       phalcon             bb388df328ec        11 seconds ago      364.4 MB
    nginx-php-fpm       latest              4fc9ac9f2945        4 hours ago         228.5 MB
    

    采用新镜像来运行,这里要讲程序运行入口改回/start.sh,以便能正常启动Nginx和PHP-FPM:

    root@thinkpad:~# docker rm web
    web
    root@thinkpad:~# docker run --name web -d -t -i nginx-php-fpm:phalcon /start.sh
    deecb19467cda2676b24248e3f55970a2481255c6022a80ffbf5087792ccb559
    root@thinkpad:~# docker ps
    CONTAINER ID        IMAGE                   COMMAND             CREATED             STATUS              PORTS               NAMES
    deecb19467cd        nginx-php-fpm:phalcon   "/start.sh"         4 seconds ago       Up 3 seconds        80/tcp, 443/tcp     web
    

    入口程序改变了,需要再提交一次变更:

    root@thinkpad:~# docker stop web
    web
    root@thinkpad:~# docker ps -l
    CONTAINER ID        IMAGE                   COMMAND             CREATED             STATUS                       PORTS               NAMES
    c7600e62733d        nginx-php-fpm:phalcon   "/start.sh"         34 seconds ago      Exited (137) 8 seconds ago                       web
    root@thinkpad:~# docker commit c76 nginx-php-fpm:phalcon
    sha256:1c97ee169a551dd8441f42b40beafd102c71f3e887e2317dc11ce0ef136ceaf0
    

    运行最终的镜像:

    root@thinkpad:~# docker rm web
    web
    root@thinkpad:~# docker run --name web -d -t -i nginx-php-fpm:phalcon
    cb5b0c9e55913a538539e46c53ac7905b21def84a05eb00ef81c4b500853576c
    root@thinkpad:~# docker ps
    CONTAINER ID        IMAGE                   COMMAND             CREATED             STATUS              PORTS               NAMES
    cb5b0c9e5591        nginx-php-fpm:phalcon   "/start.sh"         4 seconds ago       Up 3 seconds        80/tcp, 443/tcp     web
    

    访问http://172.17.0.2/,便可以在页面找到phalcon扩展。
    通常我们会将程序和数据分开,挂载外部文件目录到容器里面去:

    root@thinkpad:~# docker stop web
    web
    root@thinkpad:~# docker rm web
    web
    root@thinkpad:~# docker run --name web -d -t -i -v /home/docker/nginx-php-fpm/src:/var/www/html/ nginx-php-fpm:phalcon
    ffd64793fe8e7a2a95b68f514e221b7ec3b6cadfe668c016f55a7bb6d48bc702
    

    -v参数可以用来挂载目录或者文件,可以又多个-v参数。
    刚才容器里面做的那些已经添加到Dockerfile里面去,你直接使用它来构建。
    至此Nginx + PHP-FPM + Phalcon镜像构建完成,介绍绍了如何进入容器,提交变更,网络访问和文件挂载。

    参考链接:
    A minimal Ubuntu base image modified for Docker-friendliness
    eboraas/phalcon
    基于Docker的PHP开发环境
    Docker for PHP Developers
    Docker在PHP项目开发环境中的应用
    使用 Supervisor 来管理进程
    PHP C扩展框架Phalcon
    Alpine Linux,一个只有5M的Docker镜像

    PHP 处理行结束符

    我们平台有个功能支持导入一行一个编号的文本,进行批量处理,类似CSV文件,代码是这样的:

    $arrLine = file($strFilePath);
    foreach($arrLine as $k => $v){
    }
    

    一直用都用的好好的,今天有同事导入却不正常了。用记事本打开要导入的文件来看,发现文件里面的内容都是只有一行;用EditPlus打开来看却又是正常的,不过显示是Mac平台的文件,猜测应该是换行符不一样导致的。Google了一下,发现果然如此,不同操作系统的行结束符是不一样的:

    • Windows\DOS:\r\n <\li>
    • Unix\Linux:\n <\li>
    • Macintosh:\r <\li>

    另外,TCP/IP协议传输文本结束符也是\r\n。PHP的文件系统函数file和fgets默认不检测行结束符,所以不能正确处理。可以将这些换行符处理成其他的,再来断行

    $strFileContent = str_replace(array("/r/n", "/r", "/n"), ",", $strFileContent); 
    $arrLine = explode(',' , $strFileContent);
    

    PHP官网也提供了相应的解决方法,更改php.ini配置,或者ini_set(‘auto_detect_line_endings’, ‘On’)即可。

    Note: 在读取在 Macintosh 电脑中或由其创建的文件时, 如果 PHP 不能正确的识别行结束符,启用运行时配置可选项 auto_detect_line_endings 也许可以解决此问题。

    auto_detect_line_endings boolean
    当设为 On 时,PHP 将检查通过 fgets() 和 file() 取得的数据中的行结束符号是符合 Unix,MS-DOS,还是 Macintosh 的习惯。

    这使得 PHP 可以和 Macintosh 系统交互操作,但是默认值是 Off,因为在检测第一行的 EOL 习惯时会有很小的性能损失,而且在 Unix 系统下使用回车符号作为项目分隔符的人们会遭遇向下不兼容的行为。

    PHP从4.3开始使用常量PHP_EOL来代替不同平台的换行符,在代码中也应该使用PHP_EOL而不是写\r\n,否则会造成不必要的平台环境问题。

    参考链接:
    Difference between \n and \r?
    what is the difference between windows csv and mac csv?
    PHP Fucntion file
    PHP auto-detect-line-endings
    When do I use the PHP constant “PHP_EOL”?

    PHP C扩展框架Phalcon

    Phalcon是一个C语言写的高性能PHP框架,相比PHP写的框架,它作为PHP的扩展在进程开启时便加载了,节省了每一次请求时的类库加载、MVC框架分派的开销。前面提到的使用Zephir来开发PHP扩展也是这个项目贡献的。
    在CentOS 上安装Phalcon,因为之前安装过zephire了,所以直接下载cphalcon来编译安装就可以了,可以参照这里安装所需其他lib

    git clone --depth=1 git://github.com/phalcon/cphalcon.git
    cd cphalcon/build/
    sudo ./install
    
    sudo vim /etc/php.d/phalcon.ini
    

    创建phalcon.ini增加以下内容

    [phalcon]
    extension=phalcon.so
    

    这里单独把phalcon.ini单独配置,是因为Phalcon扩展加载要在PDO扩展之后,而我的PDO也是单独配置的。如果不是的话会提示这个错误:

    $ php -m | grep phalcon
    PHP Warning:  PHP Startup: Unable to load dynamic library '/usr/lib64/php/modules/phalcon.so' - /usr/lib64/php/modules/phalcon.so: undefined symbol: php_pdo_get_dbh_ce in Unknown on line 0
    

    Zend Framwork类似,Phalcon也提供开发工具用于快速生成项目骨架

    git clone https://github.com/phalcon/phalcon-devtools.git
    cd phalcon-devtools
    ./phalcon.sh
    sudo ln -s ~/phalcon-devtools/phalcon.php /usr/bin/phalcon
    sudo chmod ugo+x /usr/bin/phalcon
    
    phalcon commands
    

    注意,这个开发工具也是需要依赖Phalcon扩展的,否则会提示

    $ phalcon command
    PHP Fatal error:  Class 'Phalcon\Script' not found in /home/vagrant/phalcon-devtools/phalcon.php on line 40
    PHP Stack trace:
    PHP   1. {main}() /home/vagrant/phalcon-devtools/phalcon.php:0
    

    生成一个测试项目store

    phalcon scaffold store
    

    按照官方的Nginx说明配置

    server {
    
        listen   8005;
        server_name store.localhost;
    
        index index.php index.html index.htm;
        set $root_path '/usr/share/nginx/html/tutorial/store/public';
        root $root_path;
    
        try_files $uri $uri/ @rewrite;
    
        location @rewrite {
            rewrite ^/(.*)$ /index.php?_url=/$1;
        }
    
        location ~ \.php {
            fastcgi_pass unix:/tmp/php5-fpm.sock;
            fastcgi_index /index.php;
    
            include fastcgi.conf;
    
            fastcgi_split_path_info       ^(.+\.php)(/.+)$;
            fastcgi_param PATH_INFO       $fastcgi_path_info;
            fastcgi_param PATH_TRANSLATED $document_root$fastcgi_path_info;
            fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        }
    
        location ~* ^/(css|img|js|flv|swf|download)/(.+)$ {
            root $root_path;
        }
    
        location ~ /\.ht {
            deny all;
        }
    }
    
    

    访问一下192.168.33.14:8005,提示:Access denied。。。查看Nginx错误日志

    [root@vagrant nginx]# tail -n 20 error.log
    2015/07/28 03:41:42 [error] 7343#0: *1 FastCGI sent in stderr: "Access to the script '/usr/share/nginx/html/tutorial/store/public' has been denied (see security.limit_extensions)" while reading response header from upstream, client: 192.168.33.1, server: store.localhost, request: "GET / HTTP/1.1", upstream: "fastcgi://unix:/tmp/php5-fpm.sock:", host: "192.168.33.14:8005"
    

    一开始还以为是PHP-FPM配置security.limit_extensions引起的,后来发现这个这个参数默认就是注释掉的,没限制。 最后在这里找到答案:原来是之前安装Nginx时配置了:cgi.fix_pathinfo=0,将它改回1(默认值)就可以了。当这个参数为1时,会把 PATH_TRANSLATED 转换为 SCRIPT_FILENAME。当然把这个参数去掉也是可以的

            ;fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    

    重启Nginx

    sudo service nginx restart
    

    与其他PHP框架一样,Phalcon 支持MVC应用开发,提供了诸多组件如,数据库,缓存,队列等。
    Phalcon\DI 作为全局的服务管理,用于服务注册,比如数据库,缓存,文件等。将这些资源统一起来管理,以便其他地方可以调用,各个需要的地方不再需要是new 一个对象,解除耦合关系。
    Phalcon\Events\Manager作为全局的事件管理,用于事件注册,触发,以便自定义消息通知,如数据库查询事件,初始化事件。
    Phalcon还有一个比较有特色的地方支持注释解析: Phalcon\Annotations 。官方教程举例如何使用注释做输出缓存。首先在Dispatcher服务中注册一个监听dispactch事件的缓存插件

    <?php
    
    $di['dispatcher'] = function () {
    
        $eventsManager = new \Phalcon\Events\Manager();
    
        // 添加插件到dispatch事件中
        $eventsManager->attach('dispatch', new CacheEnablerPlugin());
    
        $dispatcher = new \Phalcon\Mvc\Dispatcher();
        $dispatcher->setEventsManager($eventsManager);
        return $dispatcher;
    };
    

    然后实现监听的缓存插件

    <?php
    
    /**
     * 为视图启动缓存,如果被执行的action带有@Cache 注释单元。
     *
     */
    class CacheEnablerPlugin extends \Phalcon\Mvc\User\Plugin
    {
    
        /**
         * 这个事件在dispatcher中的每个路由被执行前执行
         *
         */
        public function beforeExecuteRoute($event, $dispatcher)
        {
    
            // 解析目前访问的控制的方法的注释
            $annotations = $this->annotations->getMethod(
                $dispatcher->getActiveController(),
                $dispatcher->getActiveMethod()
            );
    
            // 检查是否方法中带有注释名称‘Cache’的注释单元
            if ($annotations->has('Cache')) {
    
                // 这个方法带有‘Cache’注释单元
                $annotation = $annotations->get('Cache');
    
                // 获取注释单元的‘lifetime’参数
                $lifetime = $annotation->getNamedParameter('lifetime');
    
                $options = array('lifetime' => $lifetime);
    
                // 检查注释单元中是否有用户定义的‘key’参数
                if ($annotation->hasNamedParameter('key')) {
                    $options['key'] = $annotation->getNamedParameter('key');
                }
    
                // 为当前dispatcher访问的方法开启cache
                $this->view->cache($options);
            }
    
        }
    
    }
    

    现在就可以使用注释来进行缓存了

    <?php
    
    class NewsController extends \Phalcon\Mvc\Controller
    {
    
        public function indexAction()
        {
    
        }
    
        /**
         * This is a comment
         *
         * @Cache(lifetime=86400)
         */
        public function showAllAction()
        {
            $this->view->article = Articles::find();
        }
    
        /**
         * This is a comment
         *
         * @Cache(key="my-key", lifetime=86400)
         */
        public function showAction($slug)
        {
            $this->view->article = Articles::findFirstByTitle($slug);
        }
    
    }
    

    为Action增加了缓存,但对Action的代码改动为0(无侵入)并且非常简洁。数据模型里面也可以使用注释来对字段进行处理

    <?php
    
    use Phalcon\Mvc\Model;
    
    class Robots extends Model
    {
        /**
         * @Primary
         * @Identity
         * @Column(type="integer", nullable=false)
         */
        public $id;
    
        /**
         * @Column(type="string", length=70, nullable=false)
         */
        public $name;
    
        /**
         * @Column(type="string", length=32, nullable=false)
         */
        public $type;
    
        /**
         * @Column(type="integer", nullable=false)
         */
        public $year;
    }
    

    路由解析也可以使用RouterAnnotations反射来实现

    ?php
    
    /**
     * @RoutePrefix("/api/products")
     */
    class ProductsController
    {
    
        /**
         * @Get("/")
         */
        public function indexAction()
        {
    
        }
    
        /**
         * @Get("/edit/{id:[0-9]+}", name="edit-robot")
         */
        public function editAction($id)
        {
    
        }
    
        /**
         * @Route("/save", methods={"POST", "PUT"}, name="save-robot")
         */
        public function saveAction()
        {
    
        }
    
        /**
         * @Route("/delete/{id:[0-9]+}", methods="DELETE",
         *      conversors={id="MyConversors::checkId"})
         */
        public function deleteAction($id)
        {
    
        }
    
        public function infoAction($id)
        {
    
        }
    
    }
    

    是不是跟Java annotation有点像?之前介绍的PHPUnit,Zend Framework,还有Symfony 2 Doctrine 2已经在PHP层面上支持,使用ReflectionClass便可实现。
    Phalcon也支持创建命令行应用,可以将PHP应用打包成Phar,直接运行,也可以结合命令行颜色进行输出。

    参考链接:
    Nginx 安装说明(Nginx Installation Notes)
    Can’t upgrade to Phalcon 1.3.0
    Nginx/PHP-FPM “Access denied.” error
    Access denied (403) for PHP files with Nginx + PHP-FPM
    Phalcon 开发工具(Phalcon Developer Tools)
    IDE autocomplete for PhalconPHP
    Annotations in PHP: They Exist
    PHP Annotations Are a Horrible Idea
    PHP CLI Colors – PHP Class Command Line Colors (bash)

    使用Phar打包发布PHP程序

    上一遍介绍PHPUnit时,下载的PHPUnit是个Phar格式的文件并且可以独立运行。Phar归档是PHP 5.3增加的新特性,借鉴了JAVA中的JAR归档,可以将整个目录下的文件打包成单个可执行文件。虽然单个PHP文件也是可执行(Composer的install就是单个PHP文件执行创建对应的Phar),但是显得不方便。
    Phar的创建、引用、解压、转换均可以在PHP中完成。要创建Phar需要更改php.ini如下

    ;phar.readonly = On
    phar.readonly = Off
    

    首先在swoole\server目录下创建一个需要打包的文件swoole.php

    <?php
    $serv = new swoole_server("127.0.0.1", 9002);
    $serv->set(array(
        'worker_num' => 8,   //工作进程数量
        'daemonize' => true, //是否作为守护进程
    ));
    $serv->on('connect', function ($serv, $fd){
        echo "Client:Connect.\n";
    });
    $serv->on('receive', function ($serv, $fd, $from_id, $data) {
        $serv->send($fd, 'Swoole: '.$data);
        $serv->close($fd);
    });
    $serv->on('close', function ($serv, $fd) {
        echo "Client: Close.\n";
    });
    $serv->start();
    

    在swoole目录下面创建index.php

    <?php
    require 'server/swoole.php';
    

    测试一下是否能正常运行

    [root@vagrant-centos64 swoole]php index.php
    [root@vagrant-centos64 swoole]# netstat  -apn | grep 9002
    tcp        0      0 127.0.0.1:9002              0.0.0.0:*                   LISTEN      8436/php
    

    在swoole目录下创建执行打包操作的文件build.php

    <?php
    $dir = __DIR__;             // 需要打包的目录
    $file = 'swoole.phar';      // 包的名称, 注意它不仅仅是一个文件名, 在stub中也会作为入口前缀
    $phar = new Phar(__DIR__ . '/' . $file, FilesystemIterator::CURRENT_AS_FILEINFO | FilesystemIterator::KEY_AS_FILENAME, $file);
    // 开始打包
    $phar->startBuffering();
    $phar->buildFromDirectory($dir);
    $phar->delete('build.php');
    // 设置入口
    $phar->setStub("<?php
    Phar::mapPhar('{$file}');
    require 'phar://{$file}/index.php';
    __HALT_COMPILER();
    ?>");
    $phar->stopBuffering();
    // 打包完成
    echo "Finished {$file}\n";
    

    进行打包

    [root@vagrant-centos64 swoole]$ php build.php
    Finished swoole.phar
    

    测试执行一下

    [root@vagrant-centos64 swoole]$ php swoole.phar
    [root@vagrant-centos64 swoole]# netstat  -apn | grep 9002
    tcp        0      0 127.0.0.1:9002              0.0.0.0:*                   LISTEN      8635/php
    

    到这里swoole.phar可以单独发布执行力。也可以在其他项目(比如WEB)里面引用,例如

    <?php
    include 'swoole.phar';
    //引用里面的文件
    //include 'phar://swoole.phar/server/swoole.php';
    

    SegmentFault便是将PHP打包成Phar进行发布的。
    打包完成后,也可以将Phar转成Zip格式或解压出来,比如解压PHPUnit

    <?php
    $phar = new Phar('phpunit.phar');
    $phar->convertToExecutable(Phar::ZIP);
    //$phar->extractTo('phpunit'); 
    

    刚才我们这样运行的:php swoole.phar,就是还需要在PharP包前面还要加一个php。如果想做成直接可运行的Phar包,可以像单个PHP可执行文件那样在文件开头加上:#!/usr/bin/env php

    $phar->setStub("#!/usr/bin/env php
    <?php
    Phar::mapPhar('{$file}');
    require 'phar://{$file}/index.php';
    __HALT_COMPILER();
    ?>");
    

    运行一下

    ./swoole.phar
    

    注意如果你在window下面编辑刚才段代码的话,可能会报错:-bash: ./swoole.phar: /usr/bin/env: bad interpreter: No such file or directory。解决办法便是更改换行符为Unix格式,参见这里

    GitHub上有个项目:phar-composer可以利用Composer来打包相应的项目为单个可执行文件。

    参考链接:
    使用phar上线你的代码包
    PHP的Phar简介
    使用Phar来打包发布PHP程序
    Packaging Your Apps with Phar
    PHP V5.3 中的新特性,第 4 部分: 创建并使用 Phar 归档
    rpm打包利器rpm_create简介

    PHP测试框架初探:PHPUnit

    最近开始一个新的项目,涉及到了网络通信的客户/端服务端,刚开始都是边写边调试,完成之后觉得有必要留下相关的测试示例,以便日后使用调试。一些PHP项目会把示例写在文件开头的注释里面,但这样又没办法验证是否能正确运行。
    workerman是在文件最后留下的运行代码,如RpcClient.php

    // ==以下调用示例==
    if(PHP_SAPI == 'cli' && isset($argv[0]) && $argv[0] == basename(__FILE__))
    {
        //这里是RpcClient的测试用例
    }
    

    PHPUnit则是这样的

    #!/usr/bin/env php
    <?php
    if (__FILE__ == realpath($GLOBALS['_SERVER']['SCRIPT_NAME'])) {
        $phar    = realpath($GLOBALS['_SERVER']['SCRIPT_NAME']);
        $execute = true;
    } else {
        $files   = get_included_files();
        $phar    = $files[0];
        $execute = false;
    }
    

    这有点像python

    if __name__ == "__main__":
        //这里运行相关调用
    

    这样就可以对单个文件的代码边看边测试,但是这会在相关的代码里面混入不必要的测试用例代码,当有依赖于外部文件又不是很方便,于是便想把这部分测例代码独立出来。
    PHPUnit是xUnit测试家族的一员,可以方便对测试代码进行管理,还可以结合其他扩展。依照官网步骤开始:

    wget https://phar.phpunit.de/phpunit.phar
    chmod +x phpunit.phar
    sudo mv phpunit.phar /usr/local/bin/phpunit
    phpunit --version
    #PHPUnit 4.7.7 by Sebastian Bergmann and contributors.
    

    写一个测试代码(PHPUnit也可以自动生成对应的测试代码)

    <?php
    namespace Test\Client;
    use Client\RPC;
    class RPCTest extends \PHPUnit_Framework_TestCase{
    	public $pClient = null;
    	public $nMatchID = 567;
    	public function __construct(){
    		parent::__construct();
    		
    		$arrAddress = array(
    				'tcp://127.0.0.1:9001'
    		);
    		// 配置服务端列表
    		RPC::config($arrAddress);
    		$this->nMatchID = 567;
    		$this->pClient = RPC::instance('\Prize\Service');
    	}
    	public function testGet(){
    		$mxRes = $this->pClient->get($this->nMatchID);
    		$this->assertTrue(is_array($mxRes));
    		$this->assertGreaterThan(0, count($mxRes));
    	}
    	public function testSendGet(){
    		$this->assertTrue($this->pClient->send_get($this->nMatchID));
    	}
    	/**
    	 * 使用依赖,以便保证在同一个socket链接之内
    	 * @depends testSendGet
    	 */
    	public function testRecvGet(){
    		$mxRes = $this->pClient->recv_get($this->nMatchID);
    		$this->assertTrue(is_array($mxRes));
    		$this->assertGreaterThan(0, count($mxRes));
    	}
    }
    

    这边的使用了外部类RPC,于是在Bootstrap.php里面定义相关的自动加载规则,以便测试时自动加载

    <?php
    define('STIE_PATH', getcwd().'/');
    define('ROOT_PATH', STIE_PATH);
    define('LIB_PATH', ROOT_PATH.'Lib/');
    define('TEST_PATH',	ROOT_PATH.'Test/');
    
    spl_autoload_register(function($p_strClassName){
    	$arrClassName = explode('\\', trim($p_strClassName, '\\'));
    	if(!empty($arrClassName)){
    		if($arrClassName[0] == 'Test'){
    			$strPath = ROOT_PATH.implode(DIRECTORY_SEPARATOR, $arrClassName).'.php';
    		}
    		else{
    			$strPath = LIB_PATH.implode(DIRECTORY_SEPARATOR, $arrClassName).'.php';
    		}
    	}
    	if(is_file($strPath)){
    		include $strPath;
    	}
    	else{
    		throw new Exception("File not find : ".$strPath);
    	}
    });
    

    现在运行测试代码

    phpunit --bootstrap Test/Bootstrap.php Test/
    

    出现了一些警报和错误。其中一个提示找不到PHPUnit_Extensions_Story_TestCase文件:

    PHP Warning:  include(/usr/share/nginx/html/tutorial/mars/Lib/PHPUnit_Extensions_Story_TestCase.php): failed to open stream: No such file or directory in /usr/share/nginx/html/tutorial/mars/Test/Bootstrap.php on line 20
    

    这文件是一个PHPUnit的基于故事的行为驱动开发(BDD)扩展,于是上网找了下想安装。

    PHPUnit现在只支持Composer安装,如果用PEAR安装会提示失败

    sudo pear channel-discover pear.phpunit.de
    Discovering channel pear.phpunit.de over http:// failed with message: channel-add: Cannot open "http://pear.phpunit.de/channel.xml" (File http://pear.phpunit.de:80/channel.xml not valid (received: HTTP/1.1 410 Gone
    ))
    Trying to discover channel pear.phpunit.de over https:// instead
    Discovery of channel "pear.phpunit.de" failed (channel-add: Cannot open "https://pear.phpunit.de/channel.xml" (File https://pear.phpunit.de:443/channel.xml not valid (received: HTTP/1.1 410 Gone
    )))
    

    先安装Composer

    curl -sS https://getcomposer.org/installer | php
    mv composer.phar /usr/local/bin/composer
    composer -V
    

    创建一个composer.json,以便自动下载相关的库

    {
        "require-dev": {
            "phpunit/phpunit": "4.7.*",
            "phpunit/phpunit-selenium": ">=1.4",
            "phpunit/dbunit": ">=1.3",
            "phpunit/phpunit-story": "*",
            "phpunit/php-invoker": "*"
        },
        "autoload": {
            "psr-0": {"": "src"}
        },
        "config": {
            "bin-dir": "bin/"
        }
    }
    

    运行composer,注意:composer在国内慢的要死,可以参照这里

    composer install --dev
    

    便会在同级目录下面生成一个vendor目录,各自的依赖库都下载在里面,并且生成了autolaod.php,用于加载相关的类库。编辑Bootstarp.php增加一行

    include ROOT_PATH."vendor/autoload.php";
    

    再次运行PHPUnit

    [vagrant@vagrant-centos64 mars]$ phpunit --bootstrap Test/Bootstrap.php Test/KM/Client/RPCTest
    PHPUnit 4.7.7 by Sebastian Bergmann and contributors.
    
    EES
    
    Time: 540 ms, Memory: 14.25Mb
    
    There were 2 errors:
    
    1) Test\KM\Client\RPCTest::testGet
    stream_socket_client(): unable to connect to tcp://127.0.0.1:9001 (Connection refused)
    
    /usr/share/nginx/html/tutorial/mars/Lib/Client/Stream.php:19
    /usr/share/nginx/html/tutorial/mars/Lib/Client/Stream.php:16
    /usr/share/nginx/html/tutorial/mars/Lib/Client/RPC.php:174
    /usr/share/nginx/html/tutorial/mars/Lib/Client/RPC.php:141
    /usr/share/nginx/html/tutorial/mars/Lib/Client/RPC.php:130
    /usr/share/nginx/html/tutorial/mars/Test/Client/RPCTest.php:21
    /usr/share/nginx/html/tutorial/mars/Test/Client/RPCTest.php:21
    
    2) Test\KM\Client\RPCTest::testSendGet
    stream_socket_client(): unable to connect to tcp://127.0.0.1:9001 (Connection refused)
    
    /usr/share/nginx/html/tutorial/mars/Lib/Client/Stream.php:19
    /usr/share/nginx/html/tutorial/mars/Lib/Client/Stream.php:16
    /usr/share/nginx/html/tutorial/mars/Lib/Client/RPC.php:174
    /usr/share/nginx/html/tutorial/mars/Lib/Client/RPC.php:141
    /usr/share/nginx/html/tutorial/mars/Lib/Client/RPC.php:116
    /usr/share/nginx/html/tutorial/mars/Test/Client/RPCTest.php:26
    /usr/share/nginx/html/tutorial/mars/Test/Client/RPCTest.php:26
    
    FAILURES!
    Tests: 2, Assertions: 0, Errors: 2, Skipped: 1.
    

    这下子没有警报了,只有详细的测试错误报告。刚才的测试代码里面testRecvGet方法依赖于testSendGet,一旦后者失败了,前者便会被跳过。
    启动服务端再次运行测试代码

    [vagrant@vagrant-centos64 mars]$ phpunit --colors --bootstrap Test/Bootstrap.php Test/
    PHPUnit 4.7.7 by Sebastian Bergmann and contributors.
    
    ...
    
    Time: 496 ms, Memory: 14.25Mb
    
    OK (3 tests, 5 assertions)
    

    这下子全部测试通过了。

    前面运行测试是这样的:phpunit –bootstrap Test/Bootstrap.php Test/。–bootstrap是指在运行测试时首先加载的文件可以在这里面定义一些配置,自动加载规则,最后一个参数Test/是指要测试的目录,也可以指明运行某个测试用例,比如Test/Client/RPCTest,指运行RPCTest这个测试用例。

    PHPUnit还有许多其他的命令行参数选项,如果每次这么输入也挺麻烦的,可以做成配置文件,加载运行就好了,比如phpunit.xml.dist

    <?xml version="1.0" encoding="UTF-8"?>
    <phpunit backupGlobals="false"
             backupStaticAttributes="false"
             colors="true"
             convertErrorsToExceptions="true"
             convertNoticesToExceptions="true"
             convertWarningsToExceptions="true"
             processIsolation="false"
             stopOnFailure="false"
             syntaxCheck="false"
             bootstrap="Test/Bootstrap.php"
    >
        <testsuites>
            <testsuite name="Lib Test Suite">
                <directory>./Test/</directory>
            </testsuite>
        </testsuites>
    </phpunit>
    

    然后运行:phpunit -c phpunit.xml.dist 就可以了,更多XML配置参考这里。还可以在这基础上更改执行配置,比如phpunit -c phpunit.xml.dist Test/Client/RPCTest,便是依照配置仅运行RPCTest。

    以前挺纠结要不要写测试代码,一方面有测试代码的话,每次变更可以自动检测是否通过,另一方面,详细的测试用例需要大量时间去考虑,并且多数情况下都是服务模块之间的互相调用,难以预估结果,超出单元测试范围。现在看来写的这部门测试代码除了验证程序是否准确外,还可以作为示例文档。

    参考链接:
    PHPUnit: Failed opening required `PHPUnit_Extensions_Story_TestCase.php`
    Issues while installing pear.phpunit.de/PHPUnit
    单元测试
    “单元测试要做多细?”
    我讨厌单元测试:滕振宇谈如何进行单元测试
    自动化单元测试实践之路
    Unit Testing Tutorial Part I: Introduction to PHPUnit

    MySQL秒杀优化

    今天学习了楼方鑫先生《基于SQL的秒杀解决方案》,讲解了如何定位和优化秒杀业务中问题。
    首先介绍了库存业务,库存可以分为前端库存,后端库存,实体库存。秒杀时,存在的主要问题

    • 库存数据不准确,下单、付款后,得知零库存;超卖或少卖
    • 废单较多,只下单不付款,转化率低
    • 热点商品,拖垮整个站

    秒杀过程中,需要解决的技术点包括

    • 余额减一
    • 操作明细,方便追溯对账,防止一个帐号多次参与
    • 完整事务,保障记录明细与扣减库存同时完成
    • 数据落地,内存数据不可靠

    针对库存技术要求,做了多个库存解决方案,比如Mysql + Read /Write Cache 。Read Cache方案不足是读有延迟影响用户体验;Write Cache方案存在多个APP写数据不一致性。Mysql + Cache + NoSQL方案则太复杂未实现。

    于是又重新回到优化Mysql上。Mysql优势在于事务机制成熟,程序稳定。存在技术难点:单行并发,热点商品,瞬间压力,前一分钟,千万用户,容易堵塞,拖垮网站。于是从以下几个方面进行优化

    • 事务优化,单行更新
    • 并发优化,最大并发数
    • 排队优化,抢同一商品

    分析秒杀时的处理逻辑,扫描系统代码,发现大部分程序都在等待确认Update记录数,才提交事务。

    • 开启事务
    • Insert库存明细
    • Update库存余额
    • 提交事务

    在良好设计下,Mysql的Insert操作,不使用自增列是不会阻塞请求。但是Mysql的Update同一条记录是串行的,需要等远程客户端发送提交命令后才能释放锁,让其他会话继续。简单的更新操作,不考虑IO和锁冲突,一条语句执行的时间大约是0.1ms,一般条件下的网络时延为0.4-0.8ms,即等待事务提交通知的时间比真正SQL执行的时间长数倍。
    于是扩展了SQL语法(OneSQL),指定在Update执行完后自动提交,不需要等待客户端发送提交命令,从而节约这一个网络来回的事务等待时间,提升网络性能。

    秒杀时如果遇到大量请求需要进行排队,以免太多的请求拖垮Mysql

    • 在应用层排队的缺点,应用需要改造,使用统一框架(需要考虑跨语言),应用集群扩容时,控制不准确(连接数分配)
    • 在Mysql排队的优点,应用改造极少,只需修改少量SQL语句,无需统一框架,排队精确,发挥InnoDB性能。

    于是开发了兼容Mysql的分布式数据访问层(OneProxy),为并发请求进行排队。

    另外,还对热点商品进行独立数据库拆分和优化。目前,双十一前商品便已挂出,用户可以收藏或预购,对于商家而言可以准备更多商品;对于平台而言可以预先发现热点商品做优化。

    总结,对于业务优化,需要循序渐进,深入了解业务逻辑和技术点,比较不同的解决方案,就算是平常的update操作也有优化空间;同时需要从其他方面进行特定优化,如高并发排队,热点数据分离等。

    除了后端数据库优化,对于秒杀抽奖业务,问题的解决核心就是控制单位时间内的流量,使其不超过后端的处理能力。前端的做法包括

    • 分批次(少量多次)进行秒杀
    • 先玩游戏再抢购,如抽奖
    • 随机过滤掉部分请求,仅部分进入系统,如1/10
    • 阈值控制,一旦达到阈值,不再接收新请求
    • 预约排号,未排号用户返回失败(用户分类)
    • 验证码验证

    另外,OneProxy 提供的连接池功能对于PHP非常有用。PHP运行在CGI下面,每一个请求到来便需要重新创建一个数据库连接与Mysql进行交互,并发量大量的情况下便会出现:too many connetion,乃至拖垮数据库:mysql server has gone away,影响其他业务。因此Mysql连接池,对于PHP显得非常重要。

    更新:小米网在开发抢购系统的时候,最早使用PHP + Mysql碰到了一些问题,例如并发性能,数据一致性,在OneSQL上面都已经做了改进优化,只是小米自己使用Go语言重构,开发大秒系统(BigTap)。

    参考链接:
    限量秒杀等高并发活动的正确性如何保证?
    MySQL 5.6.17/Percona5.6.16/MariaDB 10.0.11/OneSQL 5.6.16 TpmC测试
    由12306.cn谈谈网站性能技术
    “米粉节”背后的故事——小米网抢购系统开发实践
    Web系统大规模并发——电商秒杀与抢购
    OneProxy : 如何给PHP页面以及其他Ruby/Python/Go程序添加连接池功能?
    基于Swoole实现的Mysql连接池