Jiahonzheng's Blog

SWAPI 个人项目总结

字数统计: 2.2k阅读时长: 10 min
2019/12/09 Share

团队地址:github.com/Just-for-Service-Computing

本人承担的工作

SWAPI 项目中,我主要负责数据爬取数据库构建搭建 gin 脚手架的工作。

数据定义

由于我们决定使用 Golang 实现 https://swapi.co 的数据爬取,因此我们需要定义相关的数据结构体定义。以下是 Film 结构体的定义,我们为其添加了 tag 字段,目的是为了实现结构体的序列化反序列化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type Film struct {
Title string `json:"title"`
EpisodeID int `json:"episode_id"`
OpeningCrawl string `json:"opening_crawl"`
Director string `json:"director"`
Producer string `json:"producer"`
CharacterURLs []string `json:"characters"`
PlanetURLs []string `json:"planets"`
StarshipURLs []string `json:"starships"`
VehicleURLs []string `json:"vehicles"`
SpeciesURLs []string `json:"species"`
Created string `json:"created"`
Edited string `json:"edited"`
URL string `json:"url"`
}

数据爬取

我们使用 Go 实现 SWAPI 的数据爬取工具。在 crawler 包中,我们定义了 Agent 结构体,其内含 http.Client 成员,用于发起 HTTP 请求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package crawler

import "net/http"

// 定义爬虫实例的具体类型。
type Agent struct {
http *http.Client
}

// 默认的爬虫实例。
var DefaultAgent = NewAgent(nil)

// 返回新的爬虫实例
func NewAgent(c *http.Client) *Agent {
if c == nil {
c = http.DefaultClient
}
return &Agent{
http: c,
}
}

我们实现了 AgentNewRequest 方法,用于构造 HTTP GET 请求,具体实现代码如下。在 NewRequest 函数中,我们先拼接请求 URL 的相对路径,随后设置 Query 参数,再与 BaseURL 进行拼接,最后调用 http.NewRequest 函数构造 GET 请求,并设置 User-Agent 的值。

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
// 构造 GET 请求。
func (a *Agent) NewRequest(s string) (*http.Request, error) {
// 拼接请求 URL 的相对路径。
ref, err := url.Parse("/api/" + s)
if err != nil {
return nil, err
}
// 设置 Query 参数。
q := ref.Query()
q.Set("format", "json")
ref.RawQuery = q.Encode()
// 拼接 BaseURL 。
baseUrl := url.URL{
Scheme: "http",
Host: "swapi.co",
}
u := baseUrl.ResolveReference(ref).String()
log.Printf("GET %s\n", u)
req, err := http.NewRequest("GET", u, nil)
if err != nil {
return nil, err
}
// 设置 User-Agent 。
req.Header.Add("User-Agent", "swapi.go")
return req, nil
}

在构造完 GET 请求后,我们需要进行发出请求,才可实现真正地请求远程资源。我们在函数 Do 中,实现发出请求功能:调用 http.Client.Do 方法发起 GET 请求,最后使用 Decode JSON 反序列化函数对响应进行解析。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 发出 GET 请求。
func (a *Agent) Do(req *http.Request, v interface{}) (*http.Response, error) {
req.Close = true
res, err := a.http.Do(req)
if err != nil {
return nil, err
}
defer func() {
if err := res.Body.Close(); err != nil {
log.Printf("fail to close body: %s\n", err)
}
}()
// 反序列化响应数据。
err = json.NewDecoder(res.Body).Decode(v)
if err != nil {
return nil, fmt.Errorf("fail to decode response: %s %s %s\n", req.Method, req.URL.RequestURI(), err)
}
return res, nil
}

在完成 NewRequestDo 函数实现后,我们即可在其基础上完成 Film 、Person、Planet、Species、Starship、Vehicle 数据的爬取函数。

获取指定 ID 的 Film接口为例,我们实现了 Agent.Film 函数,在该函数中,先构造了 GET 请求,然后发起请求,最终将反序列化后的 Film 返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 爬取指定 ID 的 Film 。
func (a *Agent) Film(id int) (model.Film, error) {
// 构造请求。
req, err := a.NewRequest(fmt.Sprintf("films/%d", id))
if err != nil {
return model.Film{}, err
}
var film model.Film
// 发起请求。
if _, err := a.Do(req, &film); err != nil {
return model.Film{}, err
}
return film, nil
}

BoltDB

根据作业要求,我们需要实现 BoltDB 相关的数据访问接口。

数据存储

首先,我们实现了 Put 方法,用于存储指定数据,其具体代码如下。

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
// 存储数据至指定 bucket 中。
func Put(bucketName, key string, value []byte) error {
// 创建数据库连接。
db, err := bolt.Open("db/swapi.db", 0600, nil)
if err != nil {
log.Printf("fail to open db: %s\n", err)
return err
}
// 关闭数据库连接。
defer func() {
if err := db.Close(); err != nil {
log.Printf("fail to close db: %s\n", err)
}
}()
name := stringutil.StringToBytes(bucketName)
err = db.Update(func(tx *bolt.Tx) error {
// 若无对应 bucket ,则创建。
if _, err := tx.CreateBucketIfNotExists(name); err != nil {
log.Printf("CreateBucketIfNotExists: %s\n", err)
return err
}
b := tx.Bucket(name)
// 存储对应数据。
return b.Put(stringutil.StringToBytes(key), value)
})
if err != nil {
log.Printf("fail to update db: %s\n", err)
return err
}
return nil
}

在实现完 Put 方法后,我们即可实现对爬取数据的落地。在 cmd/crawler/main.go 文件中,我们完成了爬虫的完整实现代码,我们充分利用 Golang 的并发技术,对多种数据进行并发爬取,我们使用 sync.WaitGroup 来管理多个 goroutine 。

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
func main() {
wg := sync.WaitGroup{}
wg.Add(6)
// 爬取 7 条 Film 数据。
go func() {
h := http.Client{}
a := crawler.NewAgent(&h)
for i := 1; i <= 7; i++ {
f, err := a.Film(i)
if err != nil {
log.Printf("fail to fetch film %d: %s\n", i, err)
continue
}
b, err := json.Marshal(f)
if err != nil {
log.Printf("fail to marshal film %d: %s\n", i, err)
continue
}
_ = db.Put("film", strconv.Itoa(i), b)
}
wg.Done()
}()
// 爬取 87 条 Person 数据。
go func() {
// ......
wg.Done()
}()
// 爬取 61 条 Planet 数据。
go func() {
// ......
wg.Done()
}()
// 爬取 37 条 Species 数据。
go func() {
// ......
wg.Done()
}()
// 爬取 39 条 Starship 数据。
go func() {
// ......
wg.Done()
}()
// 爬取 39 条 Vehicle 数据。
go func() {
// ......
wg.Done()
}()
// 等待各部分的数据爬取完毕。
wg.Wait()
}

数据读取

除了实现用于数据存储的 Put 方法外,我还实现了用于读取数据Get 方法。

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
// 获取指定 bucket 中的指定 key 的内容。
func Get(bucketName, key string) ([]byte, error) {
// 创建数据库连接。
db, err := bolt.Open("db/swapi.db", 0600, nil)
if err != nil {
log.Printf("fail to open db: %s\n", err)
return nil, err
}
// 关闭数据库连接。
defer func() {
if err := db.Close(); err != nil {
log.Printf("fail to close db: %s\n", err)
}
}()
var val []byte
err = db.View(func(tx *bolt.Tx) error {
b := tx.Bucket(stringutil.StringToBytes(bucketName))
// 获取 key 对应的内容。
v := b.Get(stringutil.StringToBytes(key))
// 关键!
val = make([]byte, len(v))
// 复制 []byte 。
copy(val, v)
return nil
})
if err != nil {
log.Printf("fail to select db: %s\n", err)
return nil, err
}
return val, nil
}

在实现过程中,我实现了 StringToBytes 函数,可低开销地转换 string[]byte 类型,具体实现代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import (
"reflect"
"unsafe"
)

// 低开销转换 string 至 []byte 类型。
func StringToBytes(s string) []byte {
strHeader := (*reflect.StringHeader)(unsafe.Pointer(&s))
bytesHeader := reflect.SliceHeader{
Data: strHeader.Data,
Len: strHeader.Len,
Cap: strHeader.Len,
}
return *(*[]byte)(unsafe.Pointer(&bytesHeader))
}

Gin 脚手架

在本项目中,我们使用了 Gin 框架进行开发,项目目录如下。

1
2
3
4
5
6
7
8
9
├── cmd						// 服务入口
├──── server
├────── main.go // 启动文件
├────── route.go // 路由文件
├── controller // 业务逻辑控制器
├── db // 数据存储层
├── dto // 定义业务数据传递对象
├── model // 数据结构层
└── service // 业务逻辑

Service

service 包中,我们定义了 SWAPI 结构体作为业务服务的载体。在这里,我们实现了 SWAPI.GetPeopleByID 方法,用于获取指定 ID 的 People 数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package service

type SWAPI struct {
}

// 获取指定 ID 的 People 。
func (s *SWAPI) GetPeopleByID(id string) (*model.Person, error) {
var person model.Person
b, err := db.Get("person", id)
if err != nil {
return &person, err
}
// 反序列化。
if err := json.NewDecoder(bytes.NewBuffer(b)).Decode(&person); err != nil {
return &person, err
}
return &person, nil
}

DTO

我们在 dto 包中定义了业务数据传递对象(DTO),规范了响应格式。我们约定:HTTP 请求以 http.StatusOK 状态码返回,至于业务是否执行成功通过响应中的 code 字段来判定,当其为 200 时,表示业务执行成功,否则执行失败。当执行成功时,data 存放业务的响应数据;当执行失败时,msg 存放着业务的错误信息。

1
2
3
4
5
6
// 定义通用响应格式。
type Response struct {
Code int `json:"code,omitempty"`
Msg string `json:"msg,omitempty"`
Data interface{} `json:"data,omitempty"`
}

Controller

controller 包中,我们实现了 Controller 结构体,其封装了 SendOKSendErr 方法,用于规范响应格式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package controller

type Controller struct {
}

func (controller *Controller) SendOK(c *gin.Context, data interface{}) {
c.JSON(http.StatusOK, dto.Response{
Code: 200,
Msg: "",
Data: data,
})
}

func (controller *Controller) SendErr(c *gin.Context, status int, msg string) {
// 方便前端处理,总是返回 200 OK
c.JSON(http.StatusOK, dto.Response{
Code: status,
Msg: msg,
})
}

有了上述 Controller 结构体,我们即可在业务控制器 SWAPI 中将 Controller 作为匿名成员引入,因此 SWAPI 也具备 Controller 的数据成员和方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package controlelr

type SWAPI struct {
*Controller
}

var (
swapiService = new(service.SWAPI)
)

// GET /people/:id
func (controller *SWAPI) GetPeopleByID(c *gin.Context) {
id := c.Param("id")
p, err := swapiService.GetPeopleByID(id)
if err != nil {
controller.SendErr(c, http.StatusBadRequest, err.Error())
return
}
controller.SendOK(c, p)
}

路由注册

我们在 cmd/server 包中的 initRouter 函数中,我们实现了服务路由的注册

1
2
3
4
5
6
7
8
9
10
11
var (
swapiController = new(controller.SWAPI)
)

func initRouter(e *gin.Engine) {
g := e.Group("/api/v1")
// SWAPI.
{
g.GET("/people/:id", swapiController.GetPeopleByID)
}
}

服务入口

最后,我们在 cmd/server 包中的 main 函数中,实现了业务服务的启动。

1
2
3
4
5
6
7
8
9
10
11

func main() {
router := gin.Default()

// 初始化 API 路由。
initRouter(router)

if err := router.Run(":8080"); err != nil {
log.Fatalf("fail to serve: %s\n", err)
}
}
CATALOG
  1. 1. 本人承担的工作
  2. 2. 数据定义
  3. 3. 数据爬取
  4. 4. BoltDB
    1. 4.1. 数据存储
    2. 4.2. 数据读取
  5. 5. Gin 脚手架
    1. 5.1. Service
    2. 5.2. DTO
    3. 5.3. Controller
    4. 5.4. 路由注册
    5. 5.5. 服务入口