在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。

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 的值。

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)
    }
  }
  // ...
})

完整示例见:

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

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

网络请求耗时

在响应前开始Push

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

<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官方的文档中摘取出来。

创建如下数据表:

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 ;

填充数据:

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条数据,可以这样:

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中:

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

在main函数中加入:

var FilterRequestID = func(ctx *context.Context) {
	requestId := uuid.NewV4().String()
	ctx.Input.SetData("requestId", requestId)
}

beego.InsertFilter("/*", beego.BeforeRouter, FilterRequestID)

在需要使用的地方,如

// @router /requestid [get]
func (this *MyController) Requestid() {
	//读取requestId
	rid := this.Ctx.Input.GetData("requestId").(string)

	fmt.Println("requestId:",rid)
}

或者

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服务器都不需要。

下边就边写边说明。
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,即生成字符串的最短位数。
const (
	hdSalt        = "mysalt"
	hdMinLength   = 5
	defaultDomain = "http://localhost:8000/"
)
定义redis和MySQL的配置信息
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端口,可以自行修改。

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
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,再生成一个字符串,然后返回给调用方。

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,不存在则跳转到默认地址
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状态
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 会影响生成结果,确定后不要改动
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 会影响生成结果,确定后不要改动
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查找
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查找
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
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
}

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

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

分析接口返回结构

{
	"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。

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

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

只需要文章链接就够了。

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

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

分析文章结构

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

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

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

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

正菜

0. 接收参数

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

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

每个循环开始时重置 hasmore 。

1. 循环请求接口

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. 处理请求结果

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. 处理文章

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。

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

go run main.go 美女 模特

等着看图吧。

github地址:toutiaoSpider,欢迎star。

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

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

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

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

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

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。

将会输出:

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

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

我选择了 gin 框架。

修改一下代码。

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": "糗百热门"
	})
}

可以看到,

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

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

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

{% 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 %}

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

for pos, char := range str {
...
}

一样。

完整的模板代码:

{% 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 %}{{{% endraw %}“的问题,生成一直失败,一直拖到现在。

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

Mac brew php7.1环境下安装Yaf

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

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

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 后会显示,具体路径可能会不一样。

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

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

[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。

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的所有镜像见这里

构建

docker build -t app .

运行

docker run -d -p 8080:80 app

访问

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

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

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

假设原内容是:

$result = [
    'item1',
    'item2',
    'item3',
    'item4',
    'item5',
    'item6',
];

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

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

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

$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条记录的分页,可以这样做:

//获取当前页码
$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());

区别

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

最后

在视图里依然使用

{!! $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数据为:

<?xml version="1.0" encoding="GBK" ?>
<response>
    <status>200</status>
</response>

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

导入包

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

所以先导入这个包。

import "encoding/xml"

定义struct

定义一个自定义类型的Response

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

定义一个Response类型的变量

var result Response

偷懒转格式

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

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包

//解析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 时的解决办法,应该还有更简单更合适的方案。仅供参考。

完整代码:

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),再看这段代码,发现跟另一个程序里有些不一样。

另一个程序里是这样的:

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

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

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处理