在Go中使用 HTTP/2 Server Push

写在前面

本文来自Golang的官方博客,由 Jaana Burcu Dogan, Tom Bergan 发表于2017年3月24日。

近来看到此篇,觉得不错,非常适合用来学习Go中的 Server Push ,于是决定翻译一下,水平有限,如有不足不恰当的地方还请提出宝贵意见。

HTTP/2旨在解决HTTP/1.x的很多问题。现代网页通常包含很多资源:HTML,css,js,图片等等。在 HTTP/1.x 中,必须明确地请求这些资源。这是一个缓慢的过程。浏览器必须从获取HTML开始,然后在解析页面时获取更多资源。由于服务器必须等待浏览器发起请求,网络通常处于空闲,没有充分利用。

为了改善延迟,HTTP/2引入了 Server Push ,这允许服务器在明确的请求之前将资源推送到浏览器。服务器通常会知道一个页面所需要的额外的资源,并且可以在响应初始请求时开始推送这些资源。这就允许服务器充分利用空闲的网络来改善加载时间。

server push示意

在协议层,HTTP/2 Server Push 由 PUSH_PROMISE 帧发起。PUSH_PROMISE 表明了服务器向客户端推送资源的意图。一旦浏览器接收到PUSH_PROMISE,它就会知道服务器会推送资源。如果浏览器后来发现需要这个资源,它会等待推送完成,而不是发起一个新的请求。这减少了浏览器在网络上等待的时间。

net/http 包中的 Server Push

Go1.8 引入了 http.Server 对 push 响应的支持,如果运行的服务器是 HTTP/2 服务器,并且请求连接使用了 HTTP/2,则可以使用此功能。在任何HTTP处理程序中,可以通过检查 http.ResponseWriter 是否实现了 http.Pusher 接口来判断是否支持 Server Push。

例如,如果服务器知道一个页面中包含 app.js,处理程序可以初始化一个 http.Pusher。

1
2
3
4
5
6
7
8
9
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if push, ok := w.(http.Pusher); ok {
//支持Push
if err := pusher.Push("/app.js", nil); err != nil {
log.Printf("Failed to push: %v", err)
}
}
// ...
})

Push会为 app.js 创建一个 “合成请求”,将该请求合成到 PUSH_PROMISE 帧中,然后将请求转发到服务器的请求处理程序,请求处理程序会生成响应。Push的第二个参数指定了包含在 PUSH_PROMISE 中的附加header头,例如,如果对 app.js 的响应在 Accept-Encoding 上不同,则 PUSH_PROMISE 应包含 Accept-Encoding 的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if pusher, ok := w.(http.Pusher); ok {
//支持Push
options := &http.PushOptions{
Header: http.Header{
"Accept-Encoding": r.Header["Accept-Encoding"],
},
}
if err := pusher.Pusher("/app.js", options); err != nil {
log.Printf("Failed to push: %v", err)
}
}
// ...
})

完整示例见:

1
$ go get golang.org/x/blog/content/h2push/server

启动服务,然后打开 https://localhost:8080,浏览器开发工具应该会显示服务器推送了 app.js 和 style.css 。

网络请求耗时

在响应前开始Push

在发送响应之前调用Push是个好主意,否则可能会意外产生重复的响应,例如,假设下边是HTML响应的一部分:

1
2
3
<html>
<head>
<link rel="stylesheet" href="a.cs">...

然后调用 Push(“a.css”, nil) ,浏览器可能会在接收到 PUSH_PROMISE 之前就开始解析这段 HTM L了,这种情况下,浏览器除了接收到 PUSH_PROMISE 之外,还会发起一个 a.css 的请求,那么服务器就会为 a.css 生成两个请求。而在写入响应之前调用PUSH则避免了这种可能性。

何时使用Server Push

应该考虑在任何网络连接空闲时使用 Server Push 。刚完成为 web app 发送 HTML ?不要浪费时间等待请求,开始推送客户端需要用到的资源,你有没有过将资源嵌入到 HTML 文件中以减少延迟?替换掉内联,尝试使用推送。重定向是另一个使用推送的好时机,因为客户端在这个过程中几乎把时间全浪费在请求的往返上。有很多情况适合使用 Push ,我们才刚刚开始。

如果没有提到以下几个注意事项,将是我们的失职。

​ 首先,只能推送当前服务器上的资源-这意味着无法推送托管在第三方服务器或CDN上的资源。

​ 第二,除非能确定客户端确实需要,否则不要推送,这样会浪费带宽。当浏览器已经缓存了某些资源时, 必须要避免推送。

​ 第三,天真的把所有资源推送到页面会使性能更糟糕。

以下链接可以作为补充阅读:

结尾

Go1.8 标准库为 HTTP/2 Server Push 提供了开箱即用的支持,为优化 Web 应用程序提供更多灵活性。

转到HTTP/2 Server Push演示页面查看实际效果。

一些资料

HTTP/2 简介

HTTP/2 Server Push 详解:

原文:

A Comprehensive Guide To HTTP/2 Server Push

译文:

HTTP/2 Server Push 详解(上)

HTTP/2 Server Push 详解(下)

MySQL实现按经纬度做距离排序


题图来自网络

工作中某些业务需要用到按距离排序返回结果,之前的方式是根据前端传过来来的经纬度,和指定范围的距离,算出一个坐标区间,再用这个区间的值去MySQL中查找,类似“where lat between (lat1, lat2) and lng between (lng1,lng2)”,查出数据后,再遍历数据计算每一条数据到这个经纬度的距离,然后根据得出的距离排序返回。低效,麻烦,不方便分页。

于是决定直接从MySQL中算出距离后返回,省事,方便,还可以直接分页了。

查资料后发现还挺简单的,下方的示例是从Google官方的文档中摘取出来。

创建如下数据表:

1
2
3
4
5
6
7
CREATE TABLE `markers` (
`id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY ,
`name` VARCHAR( 60 ) NOT NULL ,
`address` VARCHAR( 80 ) NOT NULL ,
`lat` FLOAT( 10, 6 ) NOT NULL ,
`lng` FLOAT( 10, 6 ) NOT NULL
) ENGINE = MYISAM ;

填充数据:

1
2
3
4
5
6
INSERT INTO `markers` (`name`, `address`, `lat`, `lng`) VALUES ('Frankie Johnnie & Luigo Too','939 W El Camino Real, Mountain View, CA','37.386339','-122.085823');
INSERT INTO `markers` (`name`, `address`, `lat`, `lng`) VALUES ('Amici\'s East Coast Pizzeria','790 Castro St, Mountain View, CA','37.38714','-122.083235');
INSERT INTO `markers` (`name`, `address`, `lat`, `lng`) VALUES ('Kapp\'s Pizza Bar & Grill','191 Castro St, Mountain View, CA','37.393885','-122.078916');
INSERT INTO `markers` (`name`, `address`, `lat`, `lng`) VALUES ('Round Table Pizza: Mountain View','570 N Shoreline Blvd, Mountain View, CA','37.402653','-122.079354');
INSERT INTO `markers` (`name`, `address`, `lat`, `lng`) VALUES ('Tony & Alba\'s Pizza & Pasta','619 Escuela Ave, Mountain View, CA','37.394011','-122.095528');
INSERT INTO `markers` (`name`, `address`, `lat`, `lng`) VALUES ('Oregano\'s Wood-Fired Pizza','4546 El Camino Real, Los Altos, CA','37.401724','-122.114646');

下面,开始从表中查询数据。

根据latitude,longitude值,基于Haversine公式从表中查询数据。

假设我们要查询latitude=37.38714,longitude=-122.083235,范围在25英里内的前20条数据,可以这样:

1
SELECT id, ( 3959 * acos( cos( radians('37.38714') ) * cos( radians( lat ) ) * cos( radians( lng ) - radians('-122.083235') ) + sin( radians('37.38714') ) * sin( radians( lat ) ) ) ) AS distance FROM markers HAVING distance < 25 ORDER BY distance LIMIT 0, 20;

如果想使用“公里”代替“英里”,将3959换成6371即可。

特别简单。

参考资料:

Creating a Store Locator with PHP, MySQL & Google Maps

Geo/Spatial Search with MySQL

Beego中使用过滤器

为了方便调试和排错,决定在现有的beego程序里加上requestID。

查了些资料发现写的并不是特别清楚和详细,在此总结一下,也算是加深下印象。

astaxie说可以用过滤器实现,就是在Beego运行时在特定的步骤前加入。而由于我的需求比较简单,就选在了BeforeRouter。

在main.go中:

1
2
import "github.com/astaxie/beego/context"
import "github.com/satori/go.uuid"

在main函数中加入:

1
2
3
4
5
6
var FilterRequestID = func(ctx *context.Context) {
requestId := uuid.NewV4().String()
ctx.Input.SetData("requestId", requestId)
}
beego.InsertFilter("/*", beego.BeforeRouter, FilterRequestID)

在需要使用的地方,如

1
2
3
4
5
6
7
// @router /requestid [get]
func (this *MyController) Requestid() {
//读取requestId
rid := this.Ctx.Input.GetData("requestId").(string)
fmt.Println("requestId:",rid)
}

或者

1
2
3
4
5
func (m *MyController) Requestid() {
rid := m.Ctx.Input.GetData("requestId").(string)
fmt.Println("requestId:",rid)
}

其实很简单,但是文档和查到的资料中都没有明确的说需要引用 “github.com/astaxie/beego/context”,导致写的时候浪费了一些时间。

参考资料:

过滤器
beego log中增加request id的一种方式

Go语言写的一个短网址服务

题图来自http://www.dwtricks.com/

“缩址,又称短址、短网址、网址缩短、缩短网址、URL缩短等,指的是一种互联网上的技术与服务。此服务可以提供一个非常短小的URL以代替原来的可能较长的URL,将长的URL地址缩短。
用户访问缩短后的URL时,通常将会重定向到原来的URL。”

– Wikipedia

虽然短网址早已不再那么受广泛关注。但是不妨拿来练手。

根据公开可以搜索到的资料,短网址一般是将一个ID转换到一串字母,生成短的网址用于传播,实际访问会重定向到原网址。如上所述。

那么使用Go来写这个有什么优势呢,优势之一当然是,Go部署简单,只需要copy执行文件即可。执行速度也快,甚至连HTTP服务器都不需要。

下边就边写边说明。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main
import (
"fmt"
"strings"
"time"
"net/http"
"database/sql"
"github.com/gin-gonic/gin"
"github.com/garyburd/redigo/redis"
_ "github.com/go-sql-driver/mysql"
"github.com/speps/go-hashids"
)
定义hashid包需要的salt,即生成字符串的最短位数。
1
2
3
4
5
const (
hdSalt = "mysalt"
hdMinLength = 5
defaultDomain = "http://localhost:8000/"
)
定义redis和MySQL的配置信息
1
2
3
4
5
6
7
8
9
10
11
12
var (
RedisClient *redis.Pool
RedisHost = "127.0.0.1:6379"
RedisDb = 0
RedisPwd = ""
db *sql.DB
DB_HOST = "tcp(127.0.0.1:3306)"
DB_NAME = "short"
DB_USER = "root"
DB_PASS = ""
)
main函数,首先连接redis和MySQL。定义如下路由:
  • 访问首页
  • 访问hash
  • 访问短网址信息页
  • 生成短网址接口

熟悉的朋友应该都知道,访问短网址服务的首页一般会跳转到一个固定的网址,比如渣浪微博会跳转到微博首页,Twitter则是给出“Twitter uses the t.co domain as part of a service to protect users from harmful activity”的提示。这里我们也让它跳转到一个指定的网页。

最后,以8080端口运行,实际线上会使用80端口,可以自行修改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func main() {
initRedis()
initMysql()
gin.SetMode(gin.DebugMode)
r := gin.Default()
r.GET("/", func(c *gin.Context) {
//http code can be StatusFound or StatusMovedPermanently
c.Redirect(http.StatusFound, defaultDomain)
})
r.GET("/:hash", expandUrl)
r.GET("/:hash/info", expandUrlApi)
r.POST("/short", shortUrl)
r.Run(":8000")
}
连接redis和MySQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
func initRedis() {
// 建立连接池
RedisClient = &redis.Pool{
MaxIdle: 1,
MaxActive: 10,
IdleTimeout: 180 * time.Second,
Dial: func() (redis.Conn, error) {
c, err := redis.Dial("tcp", RedisHost)
if err != nil {
return nil, err
}
if _, err := c.Do("AUTH", RedisPwd); err != nil {
c.Close()
return nil, err
}
c.Do("SELECT", RedisDb)
return c, nil
},
}
}
func initMysql() {
dsn := DB_USER + ":" + DB_PASS + "@" + DB_HOST + "/" + DB_NAME + "?charset=utf8"
db, _ = sql.Open("mysql", dsn)
db.SetMaxOpenConns(5)
db.SetMaxIdleConns(20)
db.Ping()
}
生成短网址的接口函数。

根据传入的URL参数,进行简单的验证后,写入数据库。根据写入后生成的ID,再生成一个字符串,然后返回给调用方。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func shortUrl(c *gin.Context) {
longUrl := c.PostForm("url")
if longUrl == "" {
c.JSON(200, gin.H{
"status": 500,
"message": "请传入网址",
})
return
}
if !strings.HasPrefix(longUrl, "http") {
longUrl = "http://" + longUrl
}
if hash, ok := insert(longUrl); ok {
c.JSON(200, gin.H{
"status": 200,
"message": "ok",
"short": defaultDomain + hash,
})
}
}
根据HASH解析并跳转到对应的长URL,不存在则跳转到默认地址
1
2
3
4
5
6
7
8
9
10
11
func expandUrl(c *gin.Context) {
hash := c.Param("hash")
if url, ok := findByHash(hash); ok {
c.Redirect(http.StatusMovedPermanently, url)
}
// 注意:
// 实际中,此应用的运行域名可能与默认域名不同,如a.com运行此程序,默认域名为b.com
// 当访问一个不存在的HASH或a.com时,可以跳转到任意域名,即defaultDomain
c.Redirect(http.StatusMovedPermanently, defaultDomain)
}
根据HASH在redis中查找并返回结果,不存在则返回404状态
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func expandUrlApi(c *gin.Context) {
hash := c.Param("hash")
if url, ok := findByHash(hash); ok {
c.JSON(200, gin.H{
"status": 200,
"message": "ok",
"data": url,
})
return
}
// 此处可以尝试在MySQL中再次查询
c.JSON(200, gin.H{
"status": 404,
"message": "url of hash is not exist",
})
}
将ID转换成对应的HASH值,hdSalt与hdMinLength 会影响生成结果,确定后不要改动
1
2
3
4
5
6
7
8
9
10
func shortenURL(id int) string {
hd := hashids.NewData()
hd.Salt = hdSalt
hd.MinLength = hdMinLength
h := hashids.NewWithData(hd)
e, _ := h.Encode([]int{id})
return e
}
根据HASH解析出对应的ID值, hdSalt与hdMinLength 会影响生成结果,确定后不要改动
1
2
3
4
5
6
7
8
9
10
func expand(hash string) int {
hd := hashids.NewData()
hd.Salt = hdSalt
hd.MinLength = hdMinLength
h := hashids.NewWithData(hd)
d, _ := h.DecodeWithError(hash)
return d[0]
}
数据库中根据ID查找
1
2
3
4
5
6
7
8
9
func find(id int) (string, bool) {
var url string
err := db.QueryRow("SELECT url FROM url WHERE id = ?", id).Scan(&url)
if err == nil {
return url, true
} else {
return "", false
}
}
在redis中根据HASH查找
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func findByHash(h string) (string, bool) {
rc := RedisClient.Get()
defer rc.Close()
url, _ := redis.String(rc.Do("GET", "URL:"+h))
if url != "" {
return url, true
}
id := expand(h)
if urldb, ok := find(id); ok {
return urldb, true
}
return "", false
}
将长网址插入到数据库中,并把返回的ID生成HASH和长网址存入redis
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func insert(url string) (string, bool) {
stmt, _ := db.Prepare(`INSERT INTO url (url) values (?)`)
res, err := stmt.Exec(url)
checkErr(err)
id, _ := res.LastInsertId()
rc := RedisClient.Get()
defer rc.Close()
hash := shortenURL(int(id))
rc.Do("SET", "URL:"+hash, url)
return hash, true
}

打印方法,和检查错误的方法

1
2
3
4
5
6
7
8
9
func Log(v ...interface{}) {
fmt.Println(v...)
}
func checkErr(err error) {
if err != nil {
panic(err)
}
}

有些地方还需修改,就算是抛砖引玉吧。

感谢hashids

Github地址 : shortme

相关资料:

URL Toolbox: 90+ URL Shortening Services
TinyURL

Go 福利小爬虫 爬取今日头条美女图

写完爬取糗百热门后没几天,又开始写了爬取今日头条图片的工具

灵感来源于Python 福利小爬虫,爬取今日头条街拍美女图,作者很详细的分析了今日头条一个搜索接口,并列出了步骤。

而我用Go写的,稍稍做了改动,加入了可以自定义爬取标签的功能,并在写本文前完成了以 “标签/文章名/图片名” 结构存储图片的功能。

分析网页依然使用goquery

分析接口返回结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
{
"count": 30,
"action_label": "click_search",
"return_count": 0,
"has_more": 0,
"page_id": "/search/",
"cur_tab": 1,
"offset": 150,
"action_label_web": "click_search",
"show_tabs": 1,
"data": [
{
"play_effective_count": "6412",
"media_name": "开物志",
"repin_count": 49,
"ban_comment": 0,
"show_play_effective_count": 1,
"abstract": "",
"display_title": "",
"datetime": "2016-12-13 21:35",
"article_type": 0,
"more_mode": false,
"create_time": 1481636117,
"has_m3u8_video": 0,
"keywords": "",
"video_duration": 161,
"has_mp4_video": 0,
"favorite_count": 49,
"aggr_type": 0,
"article_sub_type": 0,
"bury_count": 2,
"title": "沃尔沃Tier 4 Final大型引擎的工作原理揭秘",
"has_video": true,
"share_url": "http://toutiao.com/group/6363577276176531969/?iid=0&app=news_article",
"id": 6363577276176532000,
"source": "开物志",
"comment_count": 4,
"article_url": "http://toutiao.com/group/6363577276176531969/",
"image_url": "http://p3.pstatp.com/list/12f0000909de79ceeabc",
"middle_mode": true,
"large_mode": false,
"item_source_url": "/group/6363577276176531969/",
"media_url": "http://toutiao.com/m6643043415/",
"display_time": 1481635793,
"publish_time": 1481635793,
"go_detail_count": 2290,
"image_list": [],
"item_seo_url": "/group/6363577276176531969/",
"video_duration_str": "02:41",
"source_url": "/group/6363577276176531969/",
"tag_id": 6363577276176532000,
"natant_level": 0,
"seo_url": "/group/6363577276176531969/",
"display_url": "http://toutiao.com/group/6363577276176531969/",
"url": "http://toutiao.com/group/6363577276176531969/",
"level": 0,
"digg_count": 4,
"behot_time": 1481635793,
"tag": "news_car",
"has_gallery": false,
"has_image": false,
"highlight": {
"source": [],
"abstract": [],
"title": []
},
"group_id": 6363577276176532000,
"middle_image": "http://p3.pstatp.com/list/12f0000909de79ceeabc"
},
],
"message": "success",
"action_label_pgc": "click_search"
}

嗯,特别多,其实只需要 data 里的内容就可以了。

所以

构造一个请求结果的struct。

1
2
3
4
type ApiData struct {
Has_more int `json:"has_more"`
Data []Data `json:"data"`
}

再看下data里,嗯,没用的又一大堆。

只需要文章链接就够了。

1
2
3
type Data struct {
Article_url string `json:"article_url"`
}

有了文章链接,那就好说了,啥都好商量。

分析文章结构

id=”J_content” 下是文章的主要内容,class=”article-title”是文章标题,class=”article-content”里是文章内容,只需要article-content里所有img元素就可以了。

1
2
3
type Img struct {
Src string `json:"src"`
}

由于需要一直更改查询接口的offset参数,所以直接把接口地址拿到外边做了全局变量。并且默认存在下一页。tag用来表示当前爬取的标签的名称。

1
2
3
4
5
var (
host string = "http://www.toutiao.com/search_content/?format=json&keyword=%s&count=30&offset=%d"
hasmore bool = true
tag string
)

正菜

0. 接收参数

首先,接收并遍历命令行中传入的标签。

1
2
3
4
5
6
7
func main() {
for _, tag = range os.Args[1:] {
hasmore = true
getByTag()
}
log.Println("全部抓取完毕")
}

每个循环开始时重置 hasmore 。

1. 循环请求接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func getByTag() {
i, offset := 1, 0
for {
if hasmore {
log.Printf("标签: '%s',第 '%d' 页, OFFSET: '%d' \n", tag, i, offset)
tmpUrl := fmt.Sprintf(host, tag, offset)
getResFromApi(tmpUrl)
offset += 30
i++
time.Sleep(500 * time.Millisecond)
} else {
break
}
}
log.Printf("标签: '%s', 共 %v 页,爬取完毕\n", tag, i-1)
}

重置当前页,和当前offset。页数从第一页开始,主要是显示进度看起来更人性化一些。但是程序员的世界是从0开始。。。想改成0就改成0吧。

hasmore = true 表示存在下一页,使用fmt包的Sprintf方法格式化请求链接。然后对offset+30,对当前页i+1。再之后停顿了500毫秒。

这里其实有个问题,如果实际内容以每页30请求,可能恰好有150条,即每页数量的整数倍,但是这个时候接口返回的has_more依然等于1,即服务端认为还有下一页。。。但是其实没有了,所以会有一次空循环。

2. 处理请求结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func getResFromApi(url string) {
resp, err := http.Get(url)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
log.Fatal(err)
}
var res ApiData
json.Unmarshal([]byte(string(body)), &res)
for _, item := range res.Data {
getImgByPage(item.Article_url)
}
if res.Has_more == 0 {
hasmore = false
}
}

没啥说的,拿到每一个请求接口的链接后打开,把结果数组中的data解析到ApiData中,于是就拿到了文章链接,然后遍历处理。

遍历完后要看下has_more的值,如果为0表示没有下一页了,修改全局变量hasmore的值,结束最外层的循环。

3. 处理文章

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func getImgByPage(url string) {
//部分请求结果中包含其他网站的链接,会导致下面的query出现问题
if strings.Contains(url, "toutiao.com") {
doc, err := goquery.NewDocument(url)
if err != nil {
log.Fatal(err)
}
title := doc.Find("#article-main .article-title").Text()
title = strings.Replace(title, "/", "", -1)
os.MkdirAll(tag+"/"+title, 0777)
doc.Find("#J_content .article-content img").Each(func(i int, s *goquery.Selection) {
src, _ := s.Attr("src")
log.Println(title, src)
getImgAndSave(src, title)
})
}
}

最外层加了判断,是因为有一部分结果的链接是其他网站的。。。。

虽然这个判断很low,但是也够用了。

然后终于该用上goquery了,拿到标题,然后遍历文章内容中的img标签,就拿到了每一篇文章的每一张图片。

4. 保存图片

在上一步把图片地址和文章名称传递给了getImgAndSave。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func getImgAndSave(url string, dirname string) {
path := strings.Split(url, "/")
var name string
if len(path) > 1 {
name = path[len(path)-1]
}
resp, err := http.Get(url)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Fatal("请求失败", err)
return
}
contents, err := ioutil.ReadAll(resp.Body)
defer func() {
if x := recover(); x != nil {
return
}
}()
err = ioutil.WriteFile("./"+tag+"/"+dirname+"/"+name+".jpg", contents, 0644)
if err != nil {
log.Fatal("写入文件失败", err)
}
}

先分割图片链接,把最后一个”/“后的内容当成文件名。

后边get图片内容,但是有时候会出现对方服务器出错的情况,http状态码为500,所以加了判断请求是否成功的判断。

然后就是读取内容,保存到文件中了。

这里使用了WriteFile方式,查资料的时候还看到有闲Create文件,然后io.Copy写入的。

到这里就结束了。

RUN

1
go run main.go 美女 模特

等着看图吧。

github地址:toutiaoSpider,欢迎star。

Go语言写爬取糗百热门帖子

闲来无事,想着也用Go来写个爬虫之类的东西,我并不知道这算不算严格意义上的爬虫。

思前想后,觉得写个爬糗百热门的脚本吧,一来足够简单,二来大概熟悉下流程。

首先,选了goquery这个包来解析HTML,声称与jquery相似的用法,事实上也确实是这样,非常方便。

定个目标,只爬取列表页的帖子内容,作者和回帖都不管。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package main
import (
"github.com/PuerkitoBio/goquery"
"log"
)
//定义结构体
type Qb struct {
Id int `json:"id"`
Content string `json:"content"`
}
func main() {
var url = "http://www.qiushibaike.com/hot"
doc, err := goquery.NewDocument(url)
if err != nil {
log.Fatal(err)
}
var qb []Qb
doc.Find("#content-left .article").Each(func(i int, s *goquery.Selection) {
//s即为当前的 .article 元素,查找下级中的span元素的内容。
content := s.Find(".content span").Text()
qb = append(qb, Qb{Id: i, Content: content})
})
log.Println(qb)
}

“#content-left .article” 即每一条帖子作为元素的class。

将会输出:

1
2
3
4
5
6
[
{0 结婚十三周年那天,老婆望着一大桌子菜不禁泪流满面。我帮她拭去泪水:瞧你,都激动的哭了!老婆却说:我激动个屁!想想这十三年跟着你受的罪,我实在忍不住啊!}
{1 前几天天冷,就给妹妹买了条围巾,然后她说谢谢哥,本人本着组织精神说你应该谢谢你嫂子,她惊讶的对我说:哥,你谈女朋友了。我说:没有,你应该感谢她一直到现在都没出现,哥才有钱给你买东西}
{2 跟哥们去理发,剪头的是个妹纸。。妹纸:“你有女朋友么?”哥们一听,突然兴奋的说:“没有!”妹纸:“我是个实习生,本来想给你换大工的,看你没有女朋友,我就随意剪了!”哥们你别看我,我就是一口水没忍住,喷你脸上了而已!}
{3 老妈比较胖,小时候每次打我我都是撒腿就跑,老妈没一次抓到我的。直到老妈学会骑自行车以后,那鞭子挥得………真像套马杆的汉子,威武雄壮……}
]

那么如何展示到页面中呢。

我选择了 gin 框架。

修改一下代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func main() {
r := gin.Default()
r.LoadHTMLGlob("public/*")
r.GET("/", Index)
r.Run()
}
func Index(c *gin.Context) {
var url = "http://www.qiushibaike.com/hot"
doc, err := goquery.NewDocument(url)
if err != nil {
log.Fatal(err)
}
var result []Qb
doc.Find("#content-left .article").Each(func(i int, s *goquery.Selection) {
content := s.Find(".content span").Text()
result = append(result, Qb{Id: i, Content: content})
})
c.HTML(http.StatusOK, "index.html", gin.H{
"items": result,
"title": "糗百热门"
})
}

可以看到,

1
2
3
r := gin.Default()
r.LoadHTMLGlob("public/*")
r.GET("/", Index)

这里加载了public目录中的模板,然后下一行,表示,接收到 “/“ 的请求时,调用Index方法去处理。

到这里,文档的抓取,解析,构造数据就已经完成,下一步,看一下怎么显示到页面中。

1
2
3
4
5
6
7
8
9
10
11
12
{% raw %}
<div class="col-md-12">
<h2>{{ .title }}</h2>
<table class="table table-striped table-bordered table-hover">
{{ range $item := .items }}
<tr>
<td>{{ $item.Content }}</td>
</tr>
{{ end }}
</table>
</div>
{% endraw %}

使用 “{{ }}“ 输出后端发送过来的数据。使用 range 迭代数据。与

1
2
3
for pos, char := range str {
...
}

一样。

完整的模板代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
{% raw %}
<!-- public/index.html -->
<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>糗百</title>
<link rel="stylesheet" href="https://cdn.bootcss.com/bootstrap/3.3.6/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.bootcss.com/font-awesome/4.6.3/css/font-awesome.min.css">
</head>
<body>
<div class="container">
<div class="row">
<div class="col-md-12">
<h2>{{ .title }}</h2>
<table class="table table-striped table-bordered table-hover">
{{ range $item := .items }}
<tr>
<td>{{ $item.Content }}</td>
</tr>
{{ end }}
</table>
</div>
</div>
</div>
</body>
</html>
{% endraw %}

这样,运行一下,就可以了。

gin框架默认使用8080端口,打开 http://localhost:8080 就可以看到一个极简版的糗百热门了。

问题来了,怎么增加一个分页呢?

完整代码见:

Github地址

后记

其实早就写完了这篇,但是hexo生成的时候由于 “{{“的问题,生成一直失败,一直拖到现在。

实际代码中需要去掉 “{ % raw % }” 相关部分。

Mac brew php7.1环境下安装Yaf

开发机一直使用brew来安装PHP及其他的环境,今天把PHP升到7.1,由于7.1版本下还没有yaf的源,所以无法使用brew安装,只能编译安装了。

首先下载yaf,解压,进入目录。

1
2
3
4
5
6
7
8
9
git clone git@github.com:laruence/yaf.git
$(brew --prefix homebrew/php/php71)/bin/phpize
./configure --with-php-config=$(brew --prefix homebrew/php/php71)/bin/php-config
make && make install
make test

$(brew –prefix homebrew/php/php71) 即 brew info php71结果中的path值。

由于brew安装PHP会在php.ini同级目录创建conf.d目录,并把扩展的配置文件写在这里,一目了然知道都安装了哪些扩展,所以也以同样方式在此目录创建ext-yaf.ini。

make install 后会显示,具体路径可能会不一样。

1
Installing shared extensions: /usr/local/Cellar/php71/7.1.0-rc.5_9/lib/php/extensions/no-debug-non-zts-20160303/

这个目录即扩展.so的存放目录。下边会用到。

1
2
3
4
[yaf]
extension="/usr/local/opt/php71/lib/php/extensions/no-debug-non-zts-20160303/yaf.so"
yaf.environ="dev"
;yaf.use_namespace = 1

至此,重启php-fpm就可以了。

####
图片来自:Emergence of PHP7

用Docker部署Golang Beego框架应用

Docker是什么就不说了。
Golang是什么也不说了。
Beego是什么就更不用说了。

最近Beego项目完成,研究怎么部署。因为Docker部署起来更简单更快速,所以就说下怎么在docker里部署beego应用。

写在前面

假设你的应用路径为 /go/app;
假设已配置好docker的相关东西。
假设使用 godep 作为依赖管理工具
示例中开放端口为80,需要与app.conf中的端口一致,可以自行修改。

配置

在 /go/app 目录新建Dockerfile。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
FROM golang:1.7.1-alpine
MAINTAINER youremail <youremail@xxx.com>
RUN apk add --update go git
ADD ./ /go/src/app
RUN cd /go/src/app \
&& go get github.com/astaxie/beego \
&& go get github.com/tools/godep \
&& godep update -goversion \
&& godep get \
&& godep save \
&& go build
EXPOSE 80
EXTRYPOINT /go/src/app/app

本例使用 golang:1.7.1-alpine 作为基础镜像。golang的所有镜像见这里

构建

1
docker build -t app .

运行

1
docker run -d -p 8080:80 app

访问

使用nginx反向代理访问docker中的go应用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
server {
listen 80;
server_name app.com;
charset utf-8;
access_log logs/app.access.log;
location / {
try_files /_not_exists_ @backend;
}
if (!-e $request_filename) {
return 404;
}
location @backend {
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header Host $http_host;
proxy_pass http://192.168.99.100:8080; // 192.168.99.100为docker machine的ip,8080为 docker run 时指定的本地端口。
}
}

相关资料

如何使用Docker快速部署go-web应用程序
Deploying Go servers with Docker
如何使用Docker部署Go Web应用程序
The Easiest Way to Develop with Go — Introducing a Docker Based Go Tool
How To Deploy a Go Web Application with Docker
nginx 部署

Laravel 手动创建分页

有些情况下会从接口读取数据,数据较多时会用到分页,Laravel为这种需求提供了很方便的方法。

官方文档里几句略过,并没有详细说明,经过查找资料,发现如下方法可行。

首先use LengthAwarePaginator

1
2
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;

假设原内容是:

1
2
3
4
5
6
7
8
$result = [
'item1',
'item2',
'item3',
'item4',
'item5',
'item6',
];

对于一个列表来说,item一般会是个array,这里忽略。

情况1,已知总数,只有部分数据

由于本人所使用的接口有页码和每页数量的参数,所以每次查询返回的其实就是每一页的内容了,而接口又返回了符合条件的总数count,所以使用如下方式即可:

1
2
3
4
5
6
7
8
9
10
11
$perPage = 10;
$count = 100;//假设这里是接口返回的总数
//创建collection
$collection = new Collection($data);
$currentPageResults = $collection->all();
//生成分页
$data = new LengthAwarePaginator($currentPageResults, $count, $perPage);
//设置分页的链接
$data->setPath($request->url());

情况2,未知总数,有全部数据

而如果$data是全部数据呢,比如100条数据全部返回,然后要生成一个每页10条记录的分页,可以这样做:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//获取当前页码
$currentPage = LengthAwarePaginator::resolveCurrentPage();
//从数组创建一个laravel collection
$collection = new Collection($searchResults);
//设置每页数量
$perPage = 10;
//从collection分割数据
$currentPageSearchResults = $collection->slice($currentPage * $perPage, $perPage)->all();
//生成分页
$paginatedSearchResults= new LengthAwarePaginator($currentPageSearchResults, count($collection), $perPage);
//设置分页的链接
$data->setPath($request->url());

区别

其实两者只是相差了一次分割数据。

最后

在视图里依然使用

1
{!! $data->render() !!}

输出分页组件。

看起来还挺简单的。

参考链接:

官方文档pagination

Custom data pagination with Laravel 5

CUSTOM PAGINATION VIEW IN LARAVEL 5 WITH ARRAYS

Golang XMl 转 JSON

起因

某个上古时代的API,依然在返回XML格式的数据,更奇葩的是,GBK格式的。

用Go顺利的写到了发送数据,接收数据,然后取值有点麻烦啊。。。。

各种Google后,终于解决,但是不保证是唯一,正确,最合适的答案。

说在前边

本文假设要解析的XMl数据为:

1
2
3
4
<?xml version="1.0" encoding="GBK" ?>
<response>
<status>200</status>
</response>

要解决的问题是取出“200”这个状态值。

导入包

解析XML使用了”encoding/xml”这个包。

所以先导入这个包。

1
import "encoding/xml"

定义struct

定义一个自定义类型的Response

1
2
3
type Response struct {
Status int `xml:"status" json:"status"`
}

定义一个Response类型的变量

1
var result Response

偷懒转格式

因为”encoding/xml”不支持GBK格式的XML,而返回的内容又固定标明了编码是GBK,所以这里偷懒,直接把GBK替换成UTF-8,本例中不影响结果。

1
2
3
4
5
6
xmlstr := `?xml version="1.0" encoding="GBK" ?>
<response>
<status>200</status>
</response>
`
xmlstr = strings.Replace(xmlstr, "GBK", "UTF-8", -1)

使用strings包,替换“GBK”,相信根据参数顺序能看出各个参数的意义,最后一个参数:-1,为替换全部,即字符串中所有出现的第二个参数全部替换。

解析,转换,取值

使用encoding/jon,go-simplejson包

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//解析XML
err := xml.Unmarshal([]byte(xmlstr), &result)
if nil != err {
log.Fatal(err)
}
log.Printf("XML:%v \n", result)
//转换成JSON
res, err := json.Marshal(result)
if nil != err {
log.Fatal(err)
}
log.Printf("JSON:%s \n", res)
js, err := simplejson.NewJson([]byte(res))
if nil != err {
log.Fatal(err)
}
status, err := js.Get("status").Int()
log.Printf("STATUS:%v \n", status)

以上是本人在处理XML 转 JSON 时的解决办法,应该还有更简单更合适的方案。仅供参考。

完整代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
package main
import (
"encoding/xml"
"encoding/json"
"log"
"strings"
simplejson "github.com/bitly/go-simplejson"
)
type Response struct {
Status int `xml:"status" json:"status"`
}
func main() {
var result Response
//多行字符串,使用反引号`
xmlstr := `<?xml version="1.0" encoding="GBK" ?>
<response>
<status>200</status>
</response>`
xmlstr = strings.Replace(xmlstr, "GBK", "UTF-8", -1)
err := xml.Unmarshal([]byte(xmlstr), &result)
if err != nil {
log.Fatal(err)
}
log.Printf("XML:%v",result)
r, err := json.Marshal(result)
if nil != err {
log.Fatal(err)
}
log.Printf("JSON:%s", r)
js, err := simplejson.NewJson([]byte(r))
if nil != err {
log.Fatal(err)
}
status, err := js.Get("status").Int()
log.Printf("VALUE:%v",status)
}

发现问题:

今天(2016-09-18),再看这段代码,发现跟另一个程序里有些不一样。

另一个程序里是这样的:

1
2
3
type Response struct {
Status int `xml:"status"
}

也可以正常返回值,但是在本文中的示例却不能正常输出status值,而是会输出空,看了半天发现,使用 log 时:

1
log.Printf("VALUE:%v",status)

如果struct没有写
“json:”status””,就不能输出,如果换成fmt,struct就可以不写“json:”status”。结果是一样的,其中的原因还要再查资料研究下。

参考文章:

标准库—XML处理(一)

https://play.golang.org/p/7HNLEUnX-m

https://play.golang.org/p/m99B12RaLe

XML处理