Python에는 beautifulsoup 패키지가 존재해 Go에서도 없을까 찾아보던 도중 유사한 패키지를 찾았습니다. 해당 패키지를 사용하여 웹 크롤러를 만들거나 html에서 필요한 정보를 검색할 때, 편리하게 코딩할 수 있습니다.

설치

$ go get github.com/PuerkitoBio/goquery

사용법

아래는 현재 최신버전인 1.5 버전으로 설명합니다. 1.4이하 버전에서는 NewDocument()가 존재했지만 1.4버전에서 더이상 사용하지 않으므로 언급하지 않습니다.

package main

import (
  "fmt"
  "log"
  "net/http"

  "github.com/PuerkitoBio/goquery"
)

func ExampleScrape() {
  // Request the HTML page.
  res, err := http.Get("http://metalsucks.net")
  if err != nil {
    log.Fatal(err)
  }
  defer res.Body.Close()
  if res.StatusCode != 200 {
    log.Fatalf("status code error: %d %s", res.StatusCode, res.Status)
  }

  // Load the HTML document
  doc, err := goquery.NewDocumentFromReader(res.Body)
  if err != nil {
    log.Fatal(err)
  }

  // Find the review items
  doc.Find(".sidebar-reviews article .content-block").Each(func(i int, s *goquery.Selection) {
    // For each item found, get the band and title
    band := s.Find("a").Text()
    title := s.Find("i").Text()
    fmt.Printf("Review %d: %s - %s\n", i, band, title)
  })
}

func main() {
  ExampleScrape()
}


위는 타겟 사이트를 호출하여 원하는 데이터를 찾아가는 예시입니다. 아래에서는 test html을 생성 후 기능을 하나씩 설명한 예제입니다.

test html 생성 후 원하는 태그 찾기

검색은 html을 찾는 방식과 동일합니다.

태그 .클래스명 #아이디명

예시)

div 안에 있는 모든 p태그 찾기

  • div p

class=name 명 찾기

  • .name

id=err 명 찾기

  • #err

<div class=name> 찾기

  • div.name

<div class=name> <p>test</p> </div> 에서 p태그 내의 내용찾기

  • div.name p


아래는 a 태그를 찾는 예시입니다.

package main

import (
	"fmt"
	"strings"

	"github.com/PuerkitoBio/goquery"
)

func ExampleScrape() {
	// HTML 요청
	html := `
	<!DOCTYPE html>
<html>

<head>
   <title>Page title</title>
</head>

<body>
   <div>
      <p>a</p>
      <p>b</p>
      <p>c</p>
   </div>
   <div class="ex_class">
      <p>d</p>
      <p>e</p>
      <p>f</p>
   </div>
   <div id="ex_id">
      <p>g</p>
      <p>h</p>
      <p>i</p>
   </div>
   <h1>This is a heading</h1>
   <p>This is a paragraph.</p>
   <p>This is another paragraph.</p>
   <a href="http://brownbears.tistory.com" class="a"/>
</body>

</html>
	`
	rHtml := strings.NewReader(string(html))

	// HTML 문서 불러오기
	doc, err := goquery.NewDocumentFromReader(rHtml)
	if err != nil {
		panic(err)
	}

	// Find the review items
	doc.Find("a").Each(func(i int, s *goquery.Selection) {
		// 코드
		// fmt.Println(s.Text())
		
	})
}

func main() {
	ExampleScrape()
}

태그 사이에 있는 내용 출력

package main

import (
	"fmt"
	"strings"

	"github.com/PuerkitoBio/goquery"
)

func ExampleScrape() {
	// HTML 요청
	html := `
	<!DOCTYPE html>
<html>

<head>
   <title>Page title</title>
</head>

<body>
   <div>
      <p>a</p>
      <p>b</p>
      <p>c</p>
   </div>
   <div class="ex_class">
      <p>d</p>
      <p>e</p>
      <p>f</p>
   </div>
   <div id="ex_id">
      <p>g</p>
      <p>h</p>
      <p>i</p>
   </div>
   <h1>This is a heading</h1>
   <p>This is a paragraph.</p>
   <p>This is another paragraph.</p>
   <a href="http://brownbears.tistory.com" class="a"/>
</body>

</html>
	`
	rHtml := strings.NewReader(string(html))

	// HTML 문서 불러오기
	doc, err := goquery.NewDocumentFromReader(rHtml)
	if err != nil {
		panic(err)
	}

	// Find the review items
	doc.Find("h1").Each(func(i int, s *goquery.Selection) {
		fmt.Println(s.Text())

	})
}

func main() {
	ExampleScrape()
}

// This is a heading

태그 내의 속성값 출력

package main

import (
	"fmt"
	"strings"

	"github.com/PuerkitoBio/goquery"
)

func ExampleScrape() {
	// HTML 요청
	html := `
	<!DOCTYPE html>
<html>

<head>
   <title>Page title</title>
</head>

<body>
   <div>
      <p>a</p>
      <p>b</p>
      <p>c</p>
   </div>
   <div class="ex_class">
      <p>d</p>
      <p>e</p>
      <p>f</p>
   </div>
   <div id="ex_id">
      <p>g</p>
      <p>h</p>
      <p>i</p>
   </div>
   <h1>This is a heading</h1>
   <p>This is a paragraph.</p>
   <p>This is another paragraph.</p>
   <a href="http://brownbears.tistory.com" class="a"/>
</body>

</html>
	`
	rHtml := strings.NewReader(string(html))

	// HTML 문서 불러오기
	doc, err := goquery.NewDocumentFromReader(rHtml)
	if err != nil {
		panic(err)
	}

	// Find the review items
	doc.Find("a").Each(func(i int, s *goquery.Selection) {
		value, isExist := s.Attr("href")
		fmt.Println(value, isExist)

	})
}

func main() {
	ExampleScrape()
}


// http://brownbears.tistory.com true

복잡한 검색

아래는 ex_class라는 클래스명을 가진 div태그 내 p태그들의 내용을 찾는 쿼리입니다.

package main

import (
	"fmt"
	"strings"

	"github.com/PuerkitoBio/goquery"
)

func ExampleScrape() {
	// HTML 요청
	html := `
	<!DOCTYPE html>
<html>

<head>
   <title>Page title</title>
</head>

<body>
   <div>
      <p>a</p>
      <p>b</p>
      <p>c</p>
   </div>
   <div class="ex_class">
      <p>d</p>
      <p>e</p>
      <p>f</p>
   </div>
   <div id="ex_id">
      <p>g</p>
      <p>h</p>
      <p>i</p>
   </div>
   <h1>This is a heading</h1>
   <p>This is a paragraph.</p>
   <p>This is another paragraph.</p>
   <a href="http://brownbears.tistory.com" class="a"/>
</body>

</html>
	`
	rHtml := strings.NewReader(string(html))

	// HTML 문서 불러오기
	doc, err := goquery.NewDocumentFromReader(rHtml)
	if err != nil {
		panic(err)
	}

	// Find the review items
	doc.Find("div.ex_class p").Each(func(i int, s *goquery.Selection) {
		fmt.Println(s.Text())

	})
}

func main() {
	ExampleScrape()
}


만약 id명이 ex_id를 가진 div태그 내의 모든 p태그들에 대한 내용을 가지고 오고 싶을 경우, div.ex_class p 가 아닌 div#ex_id p 로 검색하면 됩니다.


더 많은 정보는 https://github.com/PuerkitoBio/goquery 에서 확인할 수 있습니다.

Go언어에서 날짜계산이나 포맷변경은 극악입니다. 실제로 검색해보면 욕 한바가지 써놓은 것을 쉽게 볼 수 있습니다. 

현재날짜 가져오기

기본 현재날짜, utc, unix 별로 가져오는 로직입니다.

package main

import (
	"fmt"
	"time"
)

func main() {
	now := time.Now()
	nowUTC := time.Now().UTC()
	nowUNIX := time.Now().Unix()
	fmt.Println(now)
	fmt.Println(nowUTC)
	fmt.Println(nowUNIX)
}


// 결과


// 2018-12-19 20:42:08.219845 +0900 KST m=+0.000394187
// 2018-12-19 11:42:08.219846 +0000 UTC
// 1545219728

날짜포맷 변경하기

여기서 욕이 제일 많이 볼 수 있고 많이 나옵니다. GO언어는 다른 언어와 달리 날짜 포맷형식이 이상합니다.

아래는 time 패키지에서 제공해주는 constant입니다.

const (
        ANSIC       = "Mon Jan _2 15:04:05 2006"
        UnixDate    = "Mon Jan _2 15:04:05 MST 2006"
        RubyDate    = "Mon Jan 02 15:04:05 -0700 2006"
        RFC822      = "02 Jan 06 15:04 MST"
        RFC822Z     = "02 Jan 06 15:04 -0700" // RFC822 with numeric zone
        RFC850      = "Monday, 02-Jan-06 15:04:05 MST"
        RFC1123     = "Mon, 02 Jan 2006 15:04:05 MST"
        RFC1123Z    = "Mon, 02 Jan 2006 15:04:05 -0700" // RFC1123 with numeric zone
        RFC3339     = "2006-01-02T15:04:05Z07:00"
        RFC3339Nano = "2006-01-02T15:04:05.999999999Z07:00"
        Kitchen     = "3:04PM"
        // Handy time stamps.
        Stamp      = "Jan _2 15:04:05"
        StampMilli = "Jan _2 15:04:05.000"
        StampMicro = "Jan _2 15:04:05.000000"
        StampNano  = "Jan _2 15:04:05.000000000"
)


아래는 time 패키지에서 제공하는 포맷형식을 사용하는 방법과 흔히 사용되는 yyyy-mm-dd hh:mm:ss 형식으로 포맷한 방법입니다.

package main

import (
	"fmt"
	"time"
)

func main() {
	now := time.Now()
	custom := now.Format("2006-01-02 15:04:05")
	ansic := now.Format(time.ANSIC)
	fmt.Println(custom)
	fmt.Println(ansic)
}


// 결과
// 2018-12-19 20:41:21
// Wed Dec 19 20:41:21 2018


위 코드에서 2006-01-02 15:04:05 는 날짜를 나타내는 것이 아닌 yyyy-mm-dd hh:mm:ss 를 의미합니다. 해당 날짜는 아무 무작위로 한 것처럼 보이지만 뜻이 숨어 있습니다.

Mon Jan 2 15:04:05 -0700 MST 2006
0   1   2  3  4  5              6

위와 같이 각 순서라고 생각하시면 되지만 자주 사용되는 yyyy-mm-dd 를 사용안하고 복잡한 2006-01-02 와 같은 형식을 선택한 것은 의문입니다.

현재시간에서 시, 분 차감하기

import (
  "time"
)


func main() {
	now := time.Now()

	convMinutes, _ := time.ParseDuration("10m")
	convHours, _ := time.ParseDuration("1h")
	diffMinutes := now.Add(-convMinutes).Format("2006-01-02 15:04:05")
	diffHours := now.Add(-convHours).Format("2006-01-02 15:04:05")
	
	fmt.Println(now)
	fmt.Println(diffMinutes)
	fmt.Println(diffHours)
}


// 결과


// 2018-12-19 20:52:35.100418 +0900 KST m=+0.000385834
// 2018-12-19 20:42:35
// 2018-12-19 19:52:35

ParseeDuration() 함수를 호출하여 계산하고자 하는 시간을 Duration 타입으로 변경해야 합니다. 위의 1h, 10m 과 같이 시, 분, 초를 명시해야 값이 정확하게 전달됩니다. Go에서 제공하는 시간단위는 "ns", "us" (or "µs"), "ms", "s", "m", "h" 입니다.

현재시간을 가져온 다음 Add() 함수를 통해 위에서 구한 값을 빼줍니다. 만약 -부호가 없으면 현재시간에서 더한 시간이 반환됩니다.

현재날짜에서 년,월,일 차감하기

import (
  "time"
)

func main() {
	now := time.Now()

	convDays := 1
	convMonths := 1
	convYears := 1

	diffDays := now.AddDate(0, 0, -convDays).Format("2006-01-02 15:04:05")
	diffMonths := now.AddDate(0, -convMonths, 0).Format("2006-01-02 15:04:05")
	diffYears := now.AddDate(-convYears, 0, 0).Format("2006-01-02 15:04:05")


	fmt.Println(now)
	fmt.Println(diffDays)
	fmt.Println(diffMonths)
	fmt.Println(diffYears)
}


// 결과
// 2018-12-19 21:00:23.506858 +0900 KST m=+0.000362007
// 2018-12-18 21:00:23
// 2018-11-19 21:00:23
// 2017-12-19 21:00:23

위의 시간계산과 유사한 형태입니다. 날짜는 AddDate() 함수를 호출하며 AddDate(year, month, day) 와 같이 값을 넣어 주면 됩니다. 

struct 성질을 까먹고 한참 삽질하다가 정리하던 도중 중요한 부분을 기억했습니다...........

GO에서 struct를 선언하는 방식은 여러가지가 있습니다. struct 성질을 먼저 복기한 후, struct를 리스트로 반환하는 코드를 설명하겠습니다.

1. 빈 struct 객체 먼저 생성 후 데이터 채우기

Article이란 빈 객체를 생성 후 데이터를 삽입하는 코드입니다.

type Article struct {
    Title      string
}


func main() {
	article := Article{}
	article.Title = "test"
}

2. GO 내장함수 new() 사용

GO 내장함수인 new() 메소드를 사용하여 생성합니다.

type Article struct {
    Title      string
}


func main() {
	article := new(Article)
	article.Title = "test"
}


두 방식이 동일해 보이지만 반환되는 값에 아주 큰 차이가 있습니다. 1번의 경우 value가 넘어오지만 2번은 객체의 pointer가 넘어오게 됩니다. 이 부분을 간과하고 struct 를 리스트로 하려다 혼돈에 빠졌었습니다. 

만약 struct 객체를 생성한 다음, 다른 함수에 넘겨주는 일이 없다면 1번을 사용해도 되지만 해당 struct를 다른 함수에 넘겨 사용하는 일이 있으면 2번처럼 선언하여 사용하는 것이 좋습니다.

struct 리스트로 반환

struct를 리스트로 만드는 것은 아주 간단합니다. 해당 코드에 맞게 struct를 잘 선언해서 사용하면 됩니다.

type Article struct {
    Title      string
}

// list: value, list data: value
func InitArticleList1() []Article {
    articleList := []Article{}
    return articleList
}
// list: pointer, list data: value
func InitArticleList2() *[]Article {
    articleList := []Article{}
    return &articleList
}

// list: pointer, list data: pointer
func InitArticleList3() *[]*Article {
    articleList := []*Article{}
    return &articleList
}

func appendList1(articleList *[]Article) {
    article := Article{"test"}
    *articleList=append(*articleList, article)
}

func appendList2(articleList *[]*Article) {
    article := new(Article)
		article.Title = "test"
    *articleList=append(*articleList, article)
}

func main() {

    articleList1 := InitArticleList1()
    appendList1(&articleList1)
		articleList2 := InitArticleList2()
    appendList1(articleList2)
    articleList3 := InitArticleList3()
    appendList2(articleList3)

    fmt.Println(articleList1)
    fmt.Println(articleList2)
	fmt.Println(articleList3)
}


// 결과
// [{test}]
// &[{test}]
// &[0xc42000e1f0]


일단 WSGI는 파이썬에만 해당이 됩니다. Go에는 3가지 옵션이 있습니다. (실제로는 4가지지만 일반적으로 CGI는 높은 부하로 인해 뺐습니다.)

Go의 표준 라이브러리에 내장 된 HTTP 서비스 기능.

이 경우 앱은 독립 실행형 서버입니다. 가장 간단한 설정 일 수도 있지만 다음과 같은 문제가 있을 수 있습니다. 

  • 다운그레이드 권한으로 권한이 부여된 포트(1024 아래의 포트, 예를들어 80포트)를 가진 앱을 실행하려면, 특별한 wrapper나 POSIX 기능을 사용해야합니다.
  • 연결을 끊지 않고 재배포를 하려면 goagain과 같은 다른 wrapper가 필요합니다.

웹 서버 형태의 reverse HTTP proxy 문제가 존재

대부분 독립 실행형의 다양한 문제를 해결하지만, 그래도 전체 HTTP 트래픽을 이리저리 전달하는 오버 헤드가 존재합니다.

FastCGI는 적절한 웹 서버를 통해 제공

FastCGI는  Nginx와 Apache와 같은 웹서버도 연결이 가능합니다. FCGI 클라이언트 구현은 Go 표준 라이브러리에서 사용할 수 있습니다. 


독립 실행형 서버 실행의 문제가 없는 것 이외로 효율적인 데이터 교환 프로토콜을 구현합니다. 또 다른 보너스는 Go 서버가 reverse HTTP proxy과 관련된 TCP 소켓보다 전송 비용이 낮은 Unix 파이프를 사용하여 front-end 웹 서버와 통신 할 수 있다는 것입니다. 결론적으로 WSGI나 CGI를 생각하지 말고 FCGI를 구현하는 것이 좋습니다.


많이 사용하는 라이브러리, ORM, 웹 프레임워크 등 간단한 설명과 github 주소가 링크되어 있는 사이트

https://awesome-go.com/

CORS 정책상 Access-Control-Allow-Origin은 1개만 등록이 가능합니다. Origin을 *로 할 경우 전 URL에 대해 허용이 가능하지만 아래와 같은 경우면 특정 URL을 지정해야 합니다.

c.Header("Access-Control-Allow-Credentials", "true")
c.Header("Access-Control-Allow-Origin", "*") // 에러 - Credentials이 true일 경우 특정 URL 1개만 허용


필자가 사용한 방법은 허용할 URL 리스트를 만든 다음, 요청된 URL이 해당 리스트에 맞는지 체크하는 형식으로 문제를 해결했습니다.

allowUrlList := []string{"http://foo.com", "http://bar.com", "http://zoo.com"}
var allowUrl string
for _, url := range allowUrlList {
if c.Request.Header.Get("Origin") == url {
allowUrl = url
break
}
}

c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, Origin")
c.Header("Access-Control-Allow-Credentials", "true")
c.Header("Access-Control-Allow-Origin", allowUrl)


+ Random Posts