整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

自动化测试实战项目(一)12306火车票网站自动登录工具

记得2011年春运,12306火车票预订网站经常崩溃无法登录吗。 今天我们就开发一个12306网站自动登录软件。 帮助您轻松订票


Web的原理就是,浏览器发送一个Request给Web服务器,Web服务器处理完这个请求之后发送一个HTTP Response给浏览器。

如果我们用自己写的程序来发送Request给Web服务器,然后再捕获Web服务器发回来的Response. 检查它与期望值值是否一致。 这不就是Web自动化测试吗? 这样的自动化测试叫做请求响应测试, 别小看这样的自动化测试。 什么开心农场助手,自动投票机。 网络爬虫都是用这个做的。

阅读目录

  1. .NET中提供的类来发送HTTP Request
  2. WebClient类的用法
  3. 模拟“GET” 方法
  4. Cookie 的处理
  5. 如何跟HTTPS的网站交互
  6. 模拟"POST" 方法
  7. 客户端发送给服务端的数据进行UrlEncode处理
  8. 实例:12306火车票网站登录工具
  9. 源代码下载

.NET中提供的类来发送HTTP Request

在System.Net命名工具下, .NET提供了5种方法 可以发送HTTP Request和取回HTTP Response. 它们分别是

WebClient:

WebRequent-WebResponse:

HttpWebRequest-HttpWebResponse:

TcpClient:

Socket:

这5个类中,最适合的就是HttpWebRequest-HttpWebResponse了, 这个才能满足我们需要的功能。 顺便说下WebClient类的用法

WebClient类的用法

WebClient的用法极其简单, 主要用于下载文件,或者单纯获取Response. 这个类不能模拟“POST” 的Http Request, 功能很少。

实例如下:

        static void Main(string[] args)
        {
            // 博客园首页地址
            string uri = "http://www.cnblogs.com";

            WebClient MyWebClient = new WebClient();
            Stream st = MyWebClient.OpenRead(uri);
            StreamReader sr = new StreamReader(st);
            string html = sr.ReadToEnd();
            sr.Close();
            st.Close();

            Console.Write(html);
        }

模拟“GET” 方法

我们主要是使用HttpWebRequest-HttpWebResponse 这两个类来做自动化测试.

先看看如何模拟“GET”方法,来打开博客园首页, 在下面的例子中,设置了一些 HttpWebRequest的一些属性

        static void Main(string[] args)
        {
            // 博客园首页地址
            string uri = "http://www.cnblogs.com";

            HttpWebRequest Req = (HttpWebRequest)WebRequest.Create(uri);
            // 浏览器和服务器交互的方法
            Req.Method = "GET";
            // 浏览器的类型,IE或者Firefox
            Req.UserAgent = "Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 5.1;)";
            // 是否允许自动重定向(自动跳转)
            Req.AllowAutoRedirect = true;
            // 自动跳转的次数
            Req.MaximumAutomaticRedirections = 3;
            // 超时时间50000=50秒
            Req.Timeout = 50000;
            //  是否建立TCP持久连接
            Req.KeepAlive = true;

            HttpWebResponse response = (HttpWebResponse)Req.GetResponse();
            Stream stream = response.GetResponseStream();
            Encoding myEncoding = Encoding.GetEncoding("UTF-8");
            StreamReader streamReader = new StreamReader(stream, myEncoding);
            string html = streamReader.ReadToEnd();

            Console.Write(html);
        }


Cookie 的处理

还有一个很重要的问题是,我们如何处理Cookie? 程序和Web服务器的交互中, 程序需要把Cookie发送给Web服务器, Web服务器也会给程序发送新的Cookie. 我们怎么模拟这个呢?

C#提供了 CookieContainer 对象。 HttpWebRequest发送Request时会使用CookieContainer 中的Cookie. HttpWebResponse返回Response后,会自动修改CookieContainer 对象的Cookie. 这样的话,Cookie就不用我们操心了。 用法非常简单

            CookieContainer MyCookieContainer = new CookieContainer();
            HttpWebRequest Req = (HttpWebRequest)WebRequest.Create(uri);
            Req.CookieContainer = MyCookieContainer;

如何跟HTTPS的网站交互

我们用浏览器打开HTTPS的网站,如果我们没有安装证书,通常页面会显示 "此网站的安全证书有问题",我们必须再次点"继续浏览此网站(不推荐)"才能查看页面信息. 如下图所示

那么我们的程序,如何忽略HTTPS证书错误呢?

只要在程序中加入下面这段代码,就可以忽略HTTPS证书错误,让我们的程序能和HTTPS网站正确的交互了.

                System.Net.ServicePointManager.ServerCertificateValidationCallback += (se, cert, chain, sslerror) =>
                {
                    return true;
                };

模拟"POST" 方法

POST和GET的区别在于, POST会把数据放在Body里面发送给Web服务器. 代码如下

        public static string GetResponse(string url, string method, string data)
        {
            try
            {
                HttpWebRequest req = (HttpWebRequest)WebRequest.Create(url);
                req.KeepAlive = true;
                req.Method = method.ToUpper();
                req.AllowAutoRedirect = true;
                req.CookieContainer = CookieContainers;
                req.ContentType = "application/x-www-form-urlencoded";

                req.UserAgent = IE7;
                req.Accept = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8";
                req.Timeout = 50000;

                if (method.ToUpper() == "POST" && data != null)
                {
                    ASCIIEncoding encoding = new ASCIIEncoding();
                    byte[] postBytes = encoding.GetBytes(data); ;
                    req.ContentLength = postBytes.Length;
                    Stream st = req.GetRequestStream();
                    st.Write(postBytes, 0, postBytes.Length);
                    st.Close();
                }

                System.Net.ServicePointManager.ServerCertificateValidationCallback += (se, cert, chain, sslerror) =>
                {
                    return true;
                };

                Encoding myEncoding = Encoding.GetEncoding("UTF-8");

                HttpWebResponse res = (HttpWebResponse)req.GetResponse();
                Stream resst = res.GetResponseStream();
                StreamReader sr = new StreamReader(resst, myEncoding);
                string str = sr.ReadToEnd();

                return str;
            }
            catch (Exception)
            {
                return string.Empty;
            }
        }

客户端发送给服务端的数据进行UrlEncode处理

需要注意的是Web客户端发给Web服务端的数据如果包含空格和特殊字符(比如:汉字) 就要进行UrlEncode处理。

解决这个问题很简单。

在C#中Add reference 添加System.Web 组件

添加System.Web命名空间, 然后调用HttpUtility.UrlEncode()方法就可以进行编码了

实例:12306火车票网站登录工具

2011年铁道部推出了12306火车票预订网站, 可是因为访问者太多,经常崩溃。根本登录不了。网站访问高峰的时候,根本没办法登录成功, 一直会报错(如下图)

下面我们就运用上面的知识,来开发一个自动登录的工具

首先我们用浏览器去打开12306网站去登录, 同时打开Fiddler去抓包分析,看看浏览器是如何和Web服务器交互的。

通过抓包分析,我们发现登录其实很简单。就是把用户名,密码和验证码通过"POST"方法提交给服务器。如下图所示

在Fiddler中我们点击Inspectors tab->TextView Tab下, 能看到提交给Web服务器的数据是

string data="loginUser.user_name=thisisuserName&nameErrorFocus=&user.password=thispassword&passwordErrorFocus=&randCode=CF99&randErrorFocus=";

我们把用户名,密码,验证码换成变量,然后Post给Web服务器就可以了。

登录的时候需要输入验证码。 很幸运的是12306网站在这里有个bug, 当验证码图片没有主动刷新的时候,老的验证码一直可以用。 这样的话我们的工具用老的验证码不停地给服务器发送登录的HttpRequest,直到登录成功。

登录的核心代码如下, 实际的代码比这个复杂,要写成循环调用,还要写成多线程,完整的请参考源代码

具体代码为

string data = "loginUser.user_name=" + userName + "&nameErrorFocus=&user.password=" + password 
              + "&passwordErrorFocus=&randCode=" + code + "&randErrorFocus=focus";
string loginUrl = "https://dynamic.12306.cn/otsweb/loginAction.do?method=login";
string afterLogin = HttpHelper.GetResponse(loginUrl, "POST", data);

源代码下载

运行后效果如下

完整的代码请留言

看着春节步步临近,一二线城市返乡、外出游玩的人们几乎都面临着一个问题:抢火车票!

虽然现在大多数情况下都能订到票,但是放票瞬间即无票的场景,相信大家都深有体会。

尤其是春节期间,大家不仅使用 12306,还会考虑“智行”和其他的抢票软件,全国上下几亿人在这段时间都在抢票。

“12306 服务”承受着这个世界上任何秒杀系统都无法超越的 QPS,上百万的并发再正常不过了!

老男孩老师研究了一下“12306”的服务端架构,学习到了其系统设计上很多亮点,在这里和大家分享一下并模拟一个例子:如何在 100 万人同时抢 1 万张火车票时,系统提供正常、稳定的服务。

大型高并发系统架构

高并发的系统架构都会采用分布式集群部署,服务上层有着层层负载均衡,并提供各种容灾手段(双火机房、节点容错、服务器灾备等)保证系统的高可用,流量也会根据不同的负载能力和配置策略均衡到不同的服务器上。

下边是一个简单的示意图:

负载均衡简介

上图中描述了用户请求到服务器经历了三层的负载均衡,下边分别简单介绍一下这三种负载均衡。


①OSPF(开放式最短链路优先)是一个内部网关协议(Interior Gateway Protocol,简称 IGP)


OSPF 通过路由器之间通告网络接口的状态来建立链路状态数据库,生成最短路径树,OSPF 会自动计算路由接口上的 Cost 值,但也可以通过手工指定该接口的 Cost 值,手工指定的优先于自动计算的值。

OSPF 计算的 Cost,同样是和接口带宽成反比,带宽越高,Cost 值越小。到达目标相同 Cost 值的路径,可以执行负载均衡,最多 6 条链路同时执行负载均衡。


②LVS (Linux Virtual Server)

它是一种集群(Cluster)技术,采用 IP 负载均衡技术和基于内容请求分发技术。

调度器具有很好的吞吐率,将请求均衡地转移到不同的服务器上执行,且调度器自动屏蔽掉服务器的故障,从而将一组服务器构成一个高性能的、高可用的虚拟服务器。

③Nginx

想必大家都很熟悉了,是一款非常高性能的 HTTP 代理/反向代理服务器,服务开发中也经常使用它来做负载均衡。

Nginx 实现负载均衡的方式主要有三种:

  • 轮询
  • 加权轮询
  • IP Hash 轮询

下面我们就针对 Nginx 的加权轮询做专门的配置和测试。

Nginx 加权轮询的演示

Nginx 实现负载均衡通过 Upstream 模块实现,其中加权轮询的配置是可以给相关的服务加上一个权重值,配置的时候可能根据服务器的性能、负载能力设置相应的负载。


下面是一个加权轮询负载的配置,我将在本地的监听 3001-3004 端口,分别配置 1,2,3,4 的权重:

#配置负载均衡
 upstream load_rule {
 server 127.0.0.1:3001 weight=1;
 server 127.0.0.1:3002 weight=2;
 server 127.0.0.1:3003 weight=3;
 server 127.0.0.1:3004 weight=4;
 }
 ...
 server {
 listen 80;
 server_name load_balance.com www.load_balance.com;
 location / {
 proxy_pass http://load_rule;
 }
}

我在本地 /etc/hosts 目录下配置了 www.load_balance.com 的虚拟域名地址。

接下来使用 Go 语言开启四个 HTTP 端口监听服务,下面是监听在 3001 端口的 Go 程序,其他几个只需要修改端口即可:

package main

import (
 "net/http"
 "os"
 "strings"
)

func main() {
 http.HandleFunc("/buy/ticket", handleReq)
 http.ListenAndServe(":3001", nil)
}

//处理请求函数,根据请求将响应结果信息写入日志
func handleReq(w http.ResponseWriter, r *http.Request) {
 failedMsg := "handle in port:"
 writeLog(failedMsg, "./stat.log")
}

//写入日志
func writeLog(msg string, logPath string) {
 fd, _ := os.OpenFile(logPath, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0644)
 defer fd.Close()
 content := strings.Join([]string{msg, "rn"}, "3001")
 buf := []byte(content)
 fd.Write(buf)
}

我将请求的端口日志信息写到了 ./stat.log 文件当中,然后使用 AB 压测工具做压测:

ab -n 1000 -c 100 http://www.load_balance.com/buy/ticket

统计日志中的结果,3001-3004 端口分别得到了 100、200、300、400 的请求量。

这和我在 Nginx 中配置的权重占比很好的吻合在了一起,并且负载后的流量非常的均匀、随机。

秒杀抢购系统选型

回到我们最初提到的问题中来:火车票秒杀系统如何在高并发情况下提供正常、稳定的服务呢?


从上面的介绍我们知道用户秒杀流量通过层层的负载均衡,均匀到了不同的服务器上,即使如此,集群中的单机所承受的 QPS 也是非常高的。如何将单机性能优化到极致呢?


要解决这个问题,我们就要想明白一件事:通常订票系统要处理生成订单、减扣库存、用户支付这三个基本的阶段。

我们系统要做的事情是要保证火车票订单不超卖、不少卖,每张售卖的车票都必须支付才有效,还要保证系统承受极高的并发。

这三个阶段的先后顺序该怎么分配才更加合理呢?我们来分析一下:

下单减库存


当用户并发请求到达服务端时,首先创建订单,然后扣除库存,等待用户支付。


这种顺序是我们一般人首先会想到的解决方案,这种情况下也能保证订单不会超卖,因为创建订单之后就会减库存,这是一个原子操作。

但是这样也会产生一些问题:

  • 在极限并发情况下,任何一个内存操作的细节都至关影响性能,尤其像创建订单这种逻辑,一般都需要存储到磁盘数据库的,对数据库的压力是可想而知的。
  • 如果用户存在恶意下单的情况,只下单不支付这样库存就会变少,会少卖很多订单,虽然服务端可以限制 IP 和用户的购买订单数量,这也不算是一个好方法。

支付减库存


如果等待用户支付了订单在减库存,第一感觉就是不会少卖。但是这是并发架构的大忌,因为在极限并发情况下,用户可能会创建很多订单。


当库存减为零的时候很多用户发现抢到的订单支付不了了,这也就是所谓的“超卖”。也不能避免并发操作数据库磁盘 IO。

预扣库存


从上边两种方案的考虑,我们可以得出结论:只要创建订单,就要频繁操作数据库 IO。

那么有没有一种不需要直接操作数据库 IO 的方案呢,这就是预扣库存。先扣除了库存,保证不超卖,然后异步生成用户订单,这样响应给用户的速度就会快很多;那么怎么保证不少卖呢?用户拿到了订单,不支付怎么办?

我们都知道现在订单都有有效期,比如说用户五分钟内不支付,订单就失效了,订单一旦失效,就会加入新的库存,这也是现在很多网上零售企业保证商品不少卖采用的方案。

订单的生成是异步的,一般都会放到 MQ、Kafka 这样的即时消费队列中处理,订单量比较少的情况下,生成订单非常快,用户几乎不用排队。

扣库存的艺术


从上面的分析可知,显然预扣库存的方案最合理。我们进一步分析扣库存的细节,这里还有很大的优化空间,库存存在哪里?怎样保证高并发下,正确的扣库存,还能快速的响应用户请求?

在单机低并发情况下,我们实现扣库存通常是这样的:

为了保证扣库存和生成订单的原子性,需要采用事务处理,然后取库存判断、减库存,最后提交事务,整个流程有很多 IO,对数据库的操作又是阻塞的。

这种方式根本不适合高并发的秒杀系统。接下来我们对单机扣库存的方案做优化:本地扣库存。

我们把一定的库存量分配到本地机器,直接在内存中减库存,然后按照之前的逻辑异步创建订单。

改进过之后的单机系统是这样的:

这样就避免了对数据库频繁的 IO 操作,只在内存中做运算,极大的提高了单机抗并发的能力。

但是百万的用户请求量单机是无论如何也抗不住的,虽然 Nginx 处理网络请求使用 Epoll 模型,c10k 的问题在业界早已得到了解决。

但是 Linux 系统下,一切资源皆文件,网络请求也是这样,大量的文件描述符会使操作系统瞬间失去响应。

上面我们提到了 Nginx 的加权均衡策略,我们不妨假设将 100W 的用户请求量平均均衡到 100 台服务器上,这样单机所承受的并发量就小了很多。

然后我们每台机器本地库存 100 张火车票,100 台服务器上的总库存还是 1 万,这样保证了库存订单不超卖,下面是我们描述的集群架构:

问题接踵而至,在高并发情况下,现在我们还无法保证系统的高可用,假如这 100 台服务器上有两三台机器因为扛不住并发的流量或者其他的原因宕机了。那么这些服务器上的订单就卖不出去了,这就造成了订单的少卖。

要解决这个问题,我们需要对总订单量做统一的管理,这就是接下来的容错方案。服务器不仅要在本地减库存,另外要远程统一减库存。

有了远程统一减库存的操作,我们就可以根据机器负载情况,为每台机器分配一些多余的“Buffer 库存”用来防止机器中有机器宕机的情况。

我们结合下面架构图具体分析一下:

我们采用 Redis 存储统一库存,因为 Redis 的性能非常高,号称单机 QPS 能抗 10W 的并发。

在本地减库存以后,如果本地有订单,我们再去请求 Redis 远程减库存,本地减库存和远程减库存都成功了,才返回给用户抢票成功的提示,这样也能有效的保证订单不会超卖。


机器中有机器宕机时,因为每个机器上有预留的 Buffer 余票,所以宕机机器上的余票依然能够在其他机器上得到弥补,保证了不少卖。

Buffer 余票设置多少合适呢,理论上 Buffer 设置的越多,系统容忍宕机的机器数量就越多,但是 Buffer 设置的太大也会对 Redis 造成一定的影响。

虽然 Redis 内存数据库抗并发能力非常高,请求依然会走一次网络 IO,其实抢票过程中对 Redis 的请求次数是本地库存和 Buffer 库存的总量。

因为当本地库存不足时,系统直接返回用户“已售罄”的信息提示,就不会再走统一扣库存的逻辑。

这在一定程度上也避免了巨大的网络请求量把 Redis 压跨,所以 Buffer 值设置多少,需要架构师对系统的负载能力做认真的考量。

代码演示

Go 语言原生为并发设计,我采用 Go 语言给大家演示一下单机抢票的具体流程。

始化工作

Go 包中的 Init 函数先于 Main 函数执行,在这个阶段主要做一些准备性工作。

我们系统需要做的准备工作有:初始化本地库存、初始化远程 Redis 存储统一库存的 Hash 键值、初始化 Redis 连接池。

另外还需要初始化一个大小为 1 的 Int 类型 Chan,目的是实现分布式锁的功能。

也可以直接使用读写锁或者使用 Redis 等其他的方式避免资源竞争,但使用 Channel 更加高效,这就是 Go 语言的哲学:不要通过共享内存来通信,而要通过通信来共享内存。

Redis 库使用的是 Redigo,下面是代码实现:

...
//localSpike包结构体定义
package localSpike

type LocalSpike struct {
 LocalInStock int64
 LocalSalesVolume int64
}
...
//remoteSpike对hash结构的定义和redis连接池
package remoteSpike
//远程订单存储健值
type RemoteSpikeKeys struct {
 SpikeOrderHashKey string //redis中秒杀订单hash结构key
 TotalInventoryKey string //hash结构中总订单库存key
 QuantityOfOrderKey string //hash结构中已有订单数量key
}

//初始化redis连接池
func NewPool() *redis.Pool {
 return &redis.Pool{
 MaxIdle: 10000,
 MaxActive: 12000, // max number of connections
 Dial: func() (redis.Conn, error) {
 c, err := redis.Dial("tcp", ":6379")
 if err != nil {
 panic(err.Error())
 }
 return c, err
 },
 }
}
...
func init() {
 localSpike = localSpike2.LocalSpike{
 LocalInStock: 150,
 LocalSalesVolume: 0,
 }
 remoteSpike = remoteSpike2.RemoteSpikeKeys{
 SpikeOrderHashKey: "ticket_hash_key",
 TotalInventoryKey: "ticket_total_nums",
 QuantityOfOrderKey: "ticket_sold_nums",
 }
 redisPool = remoteSpike2.NewPool()
 done = make(chan int, 1)
 done <- 1
 }

本地扣库存和统一扣库存

本地扣库存逻辑非常简单,用户请求过来,添加销量,然后对比销量是否大于本地库存,返回 Bool 值:

package localSpike
//本地扣库存,返回bool值
func (spike *LocalSpike) LocalDeductionStock() bool{
 spike.LocalSalesVolume = spike.LocalSalesVolume + 1
 return spike.LocalSalesVolume < spike.LocalInStock
}

注意这里对共享数据 LocalSalesVolume 的操作是要使用锁来实现的,但是因为本地扣库存和统一扣库存是一个原子性操作,所以在最上层使用 Channel 来实现,这块后边会讲。

统一扣库存操作 Redis,因为 Redis 是单线程的,而我们要实现从中取数据,写数据并计算一些列步骤,我们要配合 Lua 脚本打包命令,保证操作的原子性:

package remoteSpike
......
const LuaScript = `
 local ticket_key = KEYS[1]
 local ticket_total_key = ARGV[1]
 local ticket_sold_key = ARGV[2]
 local ticket_total_nums = tonumber(redis.call('HGET', ticket_key, ticket_total_key))
 local ticket_sold_nums = tonumber(redis.call('HGET', ticket_key, ticket_sold_key))
 -- 查看是否还有余票,增加订单数量,返回结果值
 if(ticket_total_nums >= ticket_sold_nums) then
 return redis.call('HINCRBY', ticket_key, ticket_sold_key, 1)
 end
 return 0
`
//远端统一扣库存
func (RemoteSpikeKeys *RemoteSpikeKeys) RemoteDeductionStock(conn redis.Conn) bool {
 lua := redis.NewScript(1, LuaScript)
 result, err := redis.Int(lua.Do(conn, RemoteSpikeKeys.SpikeOrderHashKey, RemoteSpikeKeys.TotalInventoryKey, RemoteSpikeKeys.QuantityOfOrderKey))
 if err != nil {
 return false
 }
 return result != 0
}

我们使用 Hash 结构存储总库存和总销量的信息,用户请求过来时,判断总销量是否大于库存,然后返回相关的 Bool 值。

在启动服务之前,我们需要初始化 Redis 的初始库存信息:

hmset ticket_hash_key "ticket_total_nums" 10000 "ticket_sold_nums" 0

响应用户信息

我们开启一个 HTTP 服务,监听在一个端口上:

package main
...
func main() {
 http.HandleFunc("/buy/ticket", handleReq)
 http.ListenAndServe(":3005", nil)
}

上面我们做完了所有的初始化工作,接下来 handleReq 的逻辑非常清晰,判断是否抢票成功,返回给用户信息就可以了。

package main
//处理请求函数,根据请求将响应结果信息写入日志
func handleReq(w http.ResponseWriter, r *http.Request) {
 redisConn := redisPool.Get()
 LogMsg := ""
 <-done
 //全局读写锁
 if localSpike.LocalDeductionStock() && remoteSpike.RemoteDeductionStock(redisConn) {
 util.RespJson(w, 1, "抢票成功", nil)
 LogMsg = LogMsg + "result:1,localSales:" + strconv.FormatInt(localSpike.LocalSalesVolume, 10)
 } else {
 util.RespJson(w, -1, "已售罄", nil)
 LogMsg = LogMsg + "result:0,localSales:" + strconv.FormatInt(localSpike.LocalSalesVolume, 10)
 }
 done <- 1

 //将抢票状态写入到log中
 writeLog(LogMsg, "./stat.log")
}

func writeLog(msg string, logPath string) {
 fd, _ := os.OpenFile(logPath, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0644)
 defer fd.Close()
 content := strings.Join([]string{msg, "rn"}, "")
 buf := []byte(content)
 fd.Write(buf)

前边提到我们扣库存时要考虑竞态条件,我们这里是使用 Channel 避免并发的读写,保证了请求的高效顺序执行。我们将接口的返回信息写入到了 ./stat.log 文件方便做压测统计。

单机服务压测

开启服务,我们使用 AB 压测工具进行测试:

ab -n 10000 -c 100 http://127.0.0.1:3005/buy/ticket

下面是我本地低配 Mac 的压测信息:

This is ApacheBench, Version 2.3 <$revision: 1826891="">
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking 127.0.0.1 (be patient)
Completed 1000 requests
Completed 2000 requests
Completed 3000 requests
Completed 4000 requests
Completed 5000 requests
Completed 6000 requests
Completed 7000 requests
Completed 8000 requests
Completed 9000 requests
Completed 10000 requests
Finished 10000 requests


Server Software:
Server Hostname: 127.0.0.1
Server Port: 3005

Document Path: /buy/ticket
Document Length: 29 bytes

Concurrency Level: 100
Time taken for tests: 2.339 seconds
Complete requests: 10000
Failed requests: 0
Total transferred: 1370000 bytes
HTML transferred: 290000 bytes
Requests per second: 4275.96 [#/sec] (mean)
Time per request: 23.387 [ms] (mean)
Time per request: 0.234 [ms] (mean, across all concurrent requests)
Transfer rate: 572.08 [Kbytes/sec] received

Connection Times (ms)
 min mean[+/-sd] median max
Connect: 0 8 14.7 6 223
Processing: 2 15 17.6 11 232
Waiting: 1 11 13.5 8 225
Total: 7 23 22.8 18 239

Percentage of the requests served within a certain time (ms)
 50% 18
 66% 24
 75% 26
 80% 28
 90% 33
 95% 39
 98% 45
 99% 54
 100% 239 (longest request)

根据指标显示,我单机每秒就能处理 4000+ 的请求,正常服务器都是多核配置,处理 1W+ 的请求根本没有问题。

而且查看日志发现整个服务过程中,请求都很正常,流量均匀,Redis 也很正常:

//stat.log
...
result:1,localSales:145
result:1,localSales:146
result:1,localSales:147
result:1,localSales:148
result:1,localSales:149
result:1,localSales:150
result:0,localSales:151
result:0,localSales:152
result:0,localSales:153
result:0,localSales:154
result:0,localSales:156
...

总结回顾

总体来说,秒杀系统是非常复杂的。我们这里只是简单介绍模拟了一下单机如何优化到高性能,集群如何避免单点故障,保证订单不超卖、不少卖的一些策略。

完整的订单系统还有订单进度的查看,每台服务器上都有一个任务,定时的从总库存同步余票和库存信息展示给用户,还有用户在订单有效期内不支付,释放订单,补充到库存等等。

我们实现了高并发抢票的核心逻辑,可以说系统设计的非常的巧妙,巧妙的避开了对 DB 数据库 IO 的操作。

对 Redis 网络 IO 的高并发请求,几乎所有的计算都是在内存中完成的,而且有效的保证了不超卖、不少卖,还能够容忍部分机器的宕机。

我觉得其中有两点特别值得学习总结:

①负载均衡,分而治之

通过负载均衡,将不同的流量划分到不同的机器上,每台机器处理好自己的请求,将自己的性能发挥到极致。

这样系统的整体也就能承受极高的并发了,就像工作的一个团队,每个人都将自己的价值发挥到了极致,团队成长自然是很大的。

②合理的使用并发和异步

自 Epoll 网络架构模型解决了 c10k 问题以来,异步越来越被服务端开发人员所接受,能够用异步来做的工作,就用异步来做,在功能拆解上能达到意想不到的效果。

这点在 Nginx、Node.JS、Redis 上都能体现,他们处理网络请求使用的 Epoll 模型,用实践告诉了我们单线程依然可以发挥强大的威力。

服务器已经进入了多核时代,Go 语言这种天生为并发而生的语言,完美的发挥了服务器多核优势,很多可以并发处理的任务都可以使用并发来解决,比如 Go 处理 HTTP 请求时每个请求都会在一个 Goroutine 中执行。

总之,怎样合理的压榨 CPU,让其发挥出应有的价值,是我们一直需要探索学习的方向。

老男孩IT,致力于最有用的IT干货分享。期待与您一同进步!后台私信1可获取5000G珍藏大礼包(培训视频、精选软件、内部资料)~让我们一起让IT学习更简单!

s

查询车票接口被更换了,就是多了一个O而已,不知道啥时候又要换成什么样?我tm能说是开发后台的那个逼输错单词了不https://kyfw.12306.cn/otn/leftTicket/queryO?leftTicketDTO.train_date=2018-10-31&leftTicketDTO.from_station=GIW&leftTicketDTO.to_station=ZIW&purpose_codes=ADULT

前言

最近学习Python,所以呢?跟大家一样,都是看看官网,看看教程,然后就准备搞一个小东西来试试,那么我使用的例子是实验楼中的12306火车票查询例子。但是那个是2.7版本的,并且那个实验楼的ubuntu系统老是一些包装不上,没办法就在我电脑上搞好了。

结果展示:

我在window上运行的结果

下面这一段说明我是抄的,哈哈,因为我自己再怎么写还不是同样的内容。

让我们先给这个小应用起个名字吧,既然及查询票务信息,那就tickets,其实 大家随意了,需要发布就需要起一个更好的名字,不然只要自己玩儿的懂,但是要有程序的特点,所以还是tickets相关的吧。方便阅读和自己记忆。

我们希望用户只要输入出发站,到达站以及日期就让就能获得想要的信息,比如要查看10月31号贵阳-遵义西的火车余票, 我们只需输入:

python3.5 lnlr.py 北京 济南 2018-10-31

注意:上面的日期(包括后面的)是笔者写文章时确定的日期,当你在做这个项目的时候可能要根据当前时间做适当调整。

转化为程序语言就是:

python3.5 lnlr.py from to date

另外,火车有各种类型,高铁、动车、特快、快速和直达,我们希望可以提供选项只查询特定的一种或几种的火车,所以,我们应该有下面这些选项:

-g 高铁

-d 动车

-t 特快

-k 快速

-z 直达

这几个选项应该能被组合使用,所以,最终我们的接口应该是这个样子的:

python3.5 lnlr.py [options] from to date

接口已经确定好了,剩下的就是实现它了。

环境

Centos 7 linux 系统

Python3.5.2

使用到的库

docopt------>命令行解释器(把我玩儿死)

colorama--->一个文本着色器

requests --->爬虫必备,http请求库

prettytable->表格显示

安装库

  1. 未安装之前


  1. Linux上面目前的库

安装库:

pip3.5 install requests colorama docopt prettytble

  1. 安装之后


  1. 安装完成之后的库列表

ok,我们环境有了,库有了,那么应该干啥呢?

爬虫个人分析:

  1. 制定爬取内容
  2. 选取目标
  3. 准备环境,上面就提前说了,因为这个本来就是在搞爬虫,所以...
  4. 分析该网站的html结构,得到url
  5. 爬取数据
  6. 分析数据
  7. 封装数据(组装数据),弄成自己想要的样子
  8. coding......

那么我们开始吧

第一步

当然是打开12306的官网了,然后进行一个余票查询,当然首先你得按一下f12,打开控制台面板哦。

我是查询的是:贵阳--遵义西 10-31号的车票

f12之后,查票页面

我先埋下一个伏笔:

我看到的贵阳-遵义 10-31的车次一共是11个班次。

第二步

首先在控制台找到Network按钮,点击。然后选择XHR ---》找到请求到后台的接口,

请求接口

那么我们先看一个三个接口的请求和返回的数据:

1.https://ad.12306.cn/sdk/webservice/rest/appService/getAdAppInfo.json?placementNo=0004&clientType=2&billMaterialsId=28e783cd2ec048ee8575cc3e502292c2

查看返回的结果:

  1. 貌似没有什么有用的信息。https://kyfw.12306.cn/otn/leftTicket/log?leftTicketDTO.train_date=2017-10-31&leftTicketDTO.from_station=GIW&leftTicketDTO.to_station=ZIW&purpose_codes=ADULT
  2. 好像也看不到关于贵阳-遵义的任何信息

3.https://kyfw.12306.cn/otn/leftTicket/query?leftTicketDTO.train_date=2017-10-31&leftTicketDTO.from_station=GIW&leftTicketDTO.to_station=ZIW&purpose_codes=ADULT

第三个接口返回的数据

哈哈,在里面我看到了贵阳,遵义,那么大胆的猜测这个接口有可能就是我们需要的。

我们继续点开看看有什么东西,这些数据都是以json的格式传递过来的。

展开结果

到这里我相信,聪明的人已经知道了这个就是我们所需要的接口了,而这些数据就绝对是车次信息的数据。

由上面的我f12查看到的数据是11条,那么你们就没有点小激动么?

说明这个接口就是我们所需要的接口无误。那么现在我们就要得到它的url咯。

靠,双十一快到了,被女朋友抓去看了一会儿衣服,可能今天就不写了,明天接着写。

那么这样:我就明天开始分析url,然后就coding

请求的接口URL:

https://kyfw.12306.cn/otn/leftTicket/query?leftTicketDTO.train_date=2017-10-31&leftTicketDTO.from_station=GIW&leftTicketDTO.to_station=ZIW&purpose_codes=ADULT
分析:
1. 查看请求方式 POST/GET(常用的),这里使用的是GET请求
2. 肯定带有参数了,并且GET请求是拼接到url之后的,我们查询的时候是输入了起始地点,目的地点,出发时间。那么url上也应该带有这三个。
3. 得到参数名称:leftTicketDTO.train_date=2017-10-31,leftTicketDTO.from_station=GIW,leftTicketDTO.to_station=ZIW
还有一个参数:purpose_codes=ADULT 根据ADULT的意思(成人,成年)大胆猜测这就是学生票和成人票。
4.得到了四个参数,但是我们还不知道其中有两个GIW,ZIW是什么意思。
因为我输入的是中文,但是出现的是字母代号。做过前后台交互的同学应该觉得这种是很常见的。目的是避免了中文传输导致的问题。

分析参数的获取

leftTicketDTO.train_date=2017-10-31 时间

leftTicketDTO.from_station=GIW 出发地

leftTicketDTO.to_station=ZIW 目的地

同学们请注意:我们输入的是中文,出来的是地点代码,说明中间有一层转换,那么在常规的网站中,只有两种三种方式能这样处理?

  1. 将这个地点-地点代码字典写入js中,这个地方有可能,因为国内的地点太多,需要手动维护。
  2. 将地点-地点代码字典写入本地文件文件,做好缓存,每次读取文件,然后使用。
  3. 从远端服务器进行获取,在这里也没必要,因为每次都要去请求后台,增加服务器的压力,这个是没必要的,因为这个字典的话是基本不会变化的。

ok经过上面的分析,我们就能很清楚的知道这个字典绝对是从js获取的,那么我们就也一样的使用发f12来检查到资源,找到该页面所引用的所有js,然后进行分析。

该页面所加载的js文件

上图中的矩形中的就是当前页面中的所有js,当然每个js的作用我就不一一的说了,各位需要帮助的可以email(leihfein@gmail.com)我,或者在下面留言。

那么我直接查看各个的内容,发现有一个是:

包含了地点,地点代码js

这样我们就得到了一个js的请求地址哦。https://kyfw.12306.cn/otn/resources/js/framework/station_name.js?station_version=1.9028

浏览器中输入该URL看到的结果

我们还可以来一个测试:

我在查询的时候是输入了,贵阳-遵义,搜索一下看看吧。

搜索

同学们,看到这个你们觉得爽不爽,说明这个文件就是我们所需要的。

ok,到这里,编码前期准备工作,所有的都昨晚了,我从一步一步的分析,然后截图。给大家思路,方法,步骤。希望大家能够更明白,更多的是学习到其中的分析思路哈。

codingwars

  1. 首先爬取地点-代码code字典。
#!/usr/bin/env python3
# coding: utf-8
import requests
import re
from pprint import pprint
"""
 获取到地点-地点code字典
"""
def get_station():
 url = 'https://kyfw.12306.cn/otn/resources/js/framework/station_name.js?station_version=1.9028'
 response = requests.get(url,verify=False)
 station = re.findall(u'([\u4e00-\u95fa5]+)\|([A-Z]+)',response.text)
 pprint(dict(station),indent=4)
if __name__=="__main__":
 get_station()
在最后,我是使用pprint输出到屏幕的,大家可以进行拷贝,或者是使用Linux的重定向进行输出到别的文件 。
python3.5 get_station_code.py > stations.py
并且需要在stations.py文件中增加一个名字哦,因为输出来时也没有名字的。见下图
在windows中就只能拷贝了,

新增一个stations字典名称

好的,到这里我们的地点-地点代码就得到了,那么我们就应该写那个爬取车次信息的py了。

处理输出的代码:

from prettytable import PrettyTable
from stations import stations
from colorama import init, Fore
"""
 处理爬取出来的车次信息,并进行表格输出
"""
init()
class TrickCollection(object):
 def __init__(self, available_trains, options):
 self.header = ('车次 车站 时间 历时 特等座 一等 二等 高级软卧 软卧 动卧 硬卧 '
 + '软座 硬座 无座 备注').split()
 self.available_trains = available_trains
 self.options = options
 # 将历时转化为小时和分钟的形式
 def get_duration(self, raw_train):
 duration = raw_train[10].replace(':', '小时') + '分'
 if duration.startswith('00'):
 return duration[4:]
 if duration.startswith('0'):
 return duration[1:]
 return duration
 # 返回每个车次的基本信息
 def trains(self):
 for raw_train in self.available_trains:
 # 列车号
 train_no = raw_train[3]
 # 得到什么列车并小写
 initial = train_no[0].lower()
 # 反转station所对应的字典
 stations_re = dict(zip(stations.values(), stations.keys()))
 if not self.options or initial in self.options:
 # 将车次的信息保存到列表中
 # train 出发地
 begin_station = stations_re.get(raw_train[4])
 # train 目的地
 end_station = stations_re.get(raw_train[5])
 # your 出发地
 from_station = stations_re.get(raw_train[6])
 # your 目的地
 to_station = stations_re.get(raw_train[7])
 # 判断是起始还是经过
 begin_flag = self.__check_equals(begin_station, from_station)
 end_flag = self.__check_equals(end_station, to_station)
 train = [
 train_no,
 '\n'.join([begin_flag + ' ' + self.__get_color(Fore.GREEN, from_station),
 end_flag + ' ' + self.__get_color(Fore.RED, to_station)]),
 '\n'.join([self.__get_color(Fore.GREEN, raw_train[8]),
 self.__get_color(Fore.RED, raw_train[9])]),
 # 时间
 self.get_duration(raw_train),
 # 历时
 raw_train[32],
 # 特等座
 self.__show_color(raw_train[31]),
 # 一等
 self.__show_color(raw_train[30]),
 # 二等
 self.__show_color(raw_train[22]),
 # 高级软卧
 self.__show_color(raw_train[23]),
 # 软卧
 self.__show_color(raw_train[33]),
 # 硬卧
 self.__show_color(raw_train[28]),
 # 软座
 self.__show_color(raw_train[24]),
 # 硬座
 self.__show_color(raw_train[29]),
 # 无座
 self.__show_color(raw_train[26]),
 # 备注
 self.__show_color(raw_train[1])
 ]
 # 更改不运行车次的时间和历时
 if raw_train[14] == 'null':
 train[2] = '--\n--'
 train[3] = '--'
 # 将空字符串转化为‘--’
 for i, item in enumerate(train):
 if not item:
 train[i] = '--'
 yield train
 def __check_equals(self, from_station, to_station):
 """
 检查是否是始、过
 检查你的起始站是否为该车次的起始站
 检查你的终止站是否为该车次的终止站
 :param from_station: 出发位置
 :param to_station: 结束位置
 :return: 决定了是使用‘始' 还是 ’过‘
 """
 if from_station == to_station:
 return '始'
 else:
 return '过'
 def __get_color(self, color, content):
 """
 返回颜色内容组合,并且清除该内容之后的颜色
 :param color: 传递的颜色
 :param content: 内容
 :return: 返回值为拼接上颜色
 """
 return color + content + Fore.RESET
 def __show_color(self, content):
 """
 对内容进行颜色显示,并且只显示有,其余不上色
 :param content: 需要颜色显示的内容
 :return: 返回设置结果
 """
 if content == '有':
 return Fore.GREEN + content + Fore.RESET
 else:
 return content
 def pretty_print(self):
 """
 显示内容
 :return:
 """
 pt = PrettyTable()
 pt._set_field_names(self.header)
 for train in self.trains():
 pt.add_row(train)
 print(pt)

成果展示:

但是我在远端上使用xshell没有颜色,这个是什么鬼。最后发现是自己设置xshell而已。

好的,下面就是我们的程序执行的结果。

如果程序输入的地点在字典中查询不到,那么就不能查票。所以我们需要随时更新stations.py文件(地点-code字典表)

成果展示

ok,到这里的话,我们的所有的爬虫就抓取成功,并且输出成我们想要的结果了。我准备下一步就是搞那个抢票。试试吧。

github地址

12306火车票查看器git地址

阅读到本教程的童鞋,喜欢请点个喜欢,不求赞赏,只求喜欢,顶上去。现在网上很多都是以前的接口的例子,已经跑不起来了,所以我希望让更多的人看到,能帮助更多还在py的起步阶段,又没有人练手项目的人。

更多精彩,请关注我!