Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

kadai3-2 by yusukemisawa #37

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions kadai3/yusukemisa/goIria/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
[![CircleCI](https://circleci.com/gh/yusukemisa/goIria/tree/master.svg?style=svg)](https://circleci.com/gh/yusukemisa/goIria/tree/master)
[![codecov](https://codecov.io/gh/yusukemisa/goIria/branch/master/graph/badge.svg)](https://codecov.io/gh/yusukemisa/goIria)
## goIria
Goによる分割ダウンロード実装

## Features
- [x] Rangeアクセスを用いる
- [x] いくつかのゴルーチンでダウンロードしてマージする
- [x] エラー処理を工夫する
- [x] golang.org/x/sync/errgourpパッケージなどを使ってみる
- [x] キャンセルが発生した場合の実装を行う

## How to use
```
$ go get github.com/yusukemisa/goIria

$ go install github.com/yusukemisa/goIria

$ goIria https://dl.google.com/go/go1.10.1.src.tar.gz
```

## 分割ダウンロード方針
- [x] Headリクエストでファイルサイズを調べる
- [x] Headリクエストのタイムアウトを設定する(5秒)
- [x] 取得ファイルがrangeに対応してない場合は終了
- [x] CPUコア数で分割リクエストするときのrangeヘッダ付与時に指定するサイズを計算する
- [x] リクエストヘッダにrangeを付加してgoルーチンでリクエスト→取得した塊を一時ファイルに保存
- [x] 取得したファイルを合体して復元する
- [x] 分割ダウンロード中のgorutineでエラーが発生した時はerrgourpを使用する

## 分割ダウンロードのUT
- [x] とりあえず1ケース
- [x] Circle CIで自動実行
- [x] カバレッジの測定
- [x] 失敗するパティーンの作成



### curlでやる場合
```
$ curl -I -r 0-50 https://beauty.hotpepper.jp/CSP/c_common/ALL/IMG/cam_cm_327_98.jpg
HTTP/1.1 206 Partial Content
Date: Sun, 29 Apr 2018 08:33:45 GMT
Server: Apache
Set-Cookie: GalileoCookie=WuWDaawaLscAAGyE-x8AAADl; path=/; expires=Thu, 26-Apr-29 08:33:45 GMT
Last-Modified: Fri, 20 Apr 2018 02:26:42 GMT
ETag: "d1a9074-13eb0-56a3e6b2a3c80"
Accept-Ranges: bytes
Content-Length: 51
P3P: CP="NON DSP COR CURa ADMa DEVa TAIa PSDo OUR BUS UNI COM NAV STA"
Content-Range: bytes 0-50/81584
Content-Type: image/jpeg
```
172 changes: 172 additions & 0 deletions kadai3/yusukemisa/goIria/iria/downloader.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package iria

import (
"context"
"fmt"
"io"
"log"
"net/http"
"net/http/httputil"
"os"
"path/filepath"
"time"

"golang.org/x/net/context/ctxhttp"
"golang.org/x/sync/errgroup"
)

//ParallelDownloader is interface
type ParallelDownloader interface {
Execute() error
GetContentLength() error
Head(ctx context.Context) (*http.Response, error)
SplitDownload(part int, rangeString string) error
MargeChunk() error
CleanUp() error
}

//Downloader implemants ParallelDownloader
type Downloader struct {
URL string //取得対象URL
SplitNum int //ダウンロード分割数
}

//ダウンロード用一時ファイル part1~part{splitNum}
const tmpFile = "part"

//Execute はDownloaderメイン処理
func (d *Downloader) Execute() error {
eg, ctx := errgroup.WithContext(context.Background())
//取得対象リソースサイズ取得
contentLength, err := d.GetContentLength()
if err != nil {
return err
}
//gorutineで分割ダウンロード
for i, v := range getByteRange(contentLength, d.SplitNum) {
part := i + 1
rangeString := v
log.Printf("splitDownload part%v start %v\n", i+1, v)
//goルーチンで動かす関数や処理はforループが回りきってから動き始める(引数も回りきった後の状態)ので
//goルーチン内でAdd(1)するとWaitされない場合がある
eg.Go(func() error {
return d.SplitDownload(ctx, part, rangeString)
})
}
defer d.CleanUp()
//分割ダウンロードが終わるまでブロック
if err := eg.Wait(); err != nil {
return err
}
//分割ダウンロードしたファイル合体
margeFile, err := os.Create(filepath.Base(d.URL))
if err != nil {
return err
}
defer margeFile.Close()

return d.MargeChunk(margeFile)
}

//GetContentLength は取得対象リソースのサイズを取得する
func (d *Downloader) GetContentLength() (int64, error) {
//ファイルのサイズを取得
//Content-TypeとContent-Length
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()

res, err := d.Head(ctx)
if err != nil {
return 0, err
}
if http.StatusOK != res.StatusCode {
return 0, fmt.Errorf("URL:%v Status:%v", d.URL, res.StatusCode)
}
if "bytes" != res.Header.Get("Accept-Ranges") {
return 0, fmt.Errorf("目的のリソースがrange request未対応でした:%v", d.URL)
}
return res.ContentLength, nil
}

//Head はテストで差し替えたいのでメソッド化
func (d *Downloader) Head(ctx context.Context) (*http.Response, error) {
return ctxhttp.Head(ctx, http.DefaultClient, d.URL)
}

//SplitDownload gorutineで並列ダウンロード
func (d *Downloader) SplitDownload(ctx context.Context, part int, rangeString string) error {
//ファイル作成
file, err := os.Create(fmt.Sprintf("part%v", part))
if err != nil {
return err
}
defer file.Close()
//部分ダウンロードして外部ファイルに保存
return partialRequest(d.URL, part, rangeString, file)
}

//分割ダウンロード
func partialRequest(url string, part int, rangeString string, w io.Writer) error {
//リクエスト作成
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return err
}
req.Header.Set("Range",
fmt.Sprintf("bytes=%v", rangeString))

//デバッグ用リクエストヘッダ出力
dump, err := httputil.DumpRequestOut(req, false)
if err != nil {
return err
}
fmt.Printf("%s", dump)

res, err := http.DefaultClient.Do(req)
if err != nil {
log.Printf("http.DefaultClient.Do(req) err:%v", err.Error())
return err
}
//デバッグ用レスポンスヘッダ出力
dumpResp, _ := httputil.DumpResponse(res, false)
fmt.Println(string(dumpResp))

if _, err := io.Copy(w, res.Body); err != nil {
return err
}
log.Printf("partialRequest %v done", part)
return nil
}

//MargeChunk 分割ダウンロードしたファイルを合体して復元する
//defer で定義した関数の返却値ってどうなる?
func (d *Downloader) MargeChunk(w io.Writer) error {
for i := 0; i < d.SplitNum; i++ {
file, err := os.Open(fmt.Sprintf("%v%v", tmpFile, i+1))
if err != nil {
return err
}
//ファイルに追記
if _, err = io.Copy(w, file); err != nil {
return err
}
if err = file.Close(); err != nil {
return err
}
}
return nil
}

//CleanUp はダウンロード用に作成した一時ファイルがあれば削除します
func (d *Downloader) CleanUp() error {
for i := 0; i < d.SplitNum; i++ {
target := fmt.Sprintf("%v%v", tmpFile, i+1)
if !exists(target) {
continue
}
if err := os.Remove(target); err != nil {
return err
}
}
return nil
}
53 changes: 53 additions & 0 deletions kadai3/yusukemisa/goIria/iria/iria.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package iria

import (
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
)

//New create Downloader
func New(args []string) (*Downloader, error) {
if len(args) != 2 {
return nil, errors.New("取得対象とするURLを1つ指定してください")
}
//取得対象ファイルと同名のファイルが既にある場合を許さない
targetFileName := filepath.Base(args[1])
if exists(targetFileName) {
return nil, fmt.Errorf("取得対象のファイルが既に存在しています:%v", targetFileName)
}
return &Downloader{
URL: args[1],
SplitNum: runtime.NumCPU(), //CPUコア数だけダウンロードを分割する
}, nil
}

//rangeヘッダに指定する値を算出する
//@return []string rangeヘッダ指定値 {"0-N","N+1-M",..."M-contentLength"}
func getByteRange(contentLength int64, splitNum int) (rangeArr []string) {
var from, to int64
chunkLength := contentLength / int64(splitNum)
for i := 0; i < splitNum; i++ {
switch i {
case 0:
from = 0
to = chunkLength
case splitNum - 1:
from = to + 1
to = contentLength
default:
from = to + 1
to += chunkLength
}
rangeArr = append(rangeArr, fmt.Sprintf("%v-%v", from, to))
}
return rangeArr
}

//ファイル存在チェック
func exists(name string) bool {
_, err := os.Stat(name)
return !os.IsNotExist(err)
}
101 changes: 101 additions & 0 deletions kadai3/yusukemisa/goIria/iria/iria_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package iria_test

import (
"fmt"
"reflect"
"runtime"
"testing"

"github.com/yusukemisa/goIria/iria"
)

type NewTestCase struct {
name string //ケース名
in []string //前提条件
out interface{} //合格条件
}

/*
iria.New
正常系ケース定義
*/
var newNomalCases = []NewTestCase{
{
name: "正常系_有効URL",
in: []string{"goIria", "http://localhost:0"},
out: &iria.Downloader{
URL: "http://localhost:0",
SplitNum: runtime.NumCPU(),
},
},
}

/*
iria.New
異常系ケース定義
*/
var newErrCases = []NewTestCase{
{
name: "異常系_引数なし",
in: []string{"goIria"},
out: "取得対象とするURLを1つ指定してください",
},
{
name: "異常系_引数多すぎ",
in: []string{"goIria", "test", "ヘテロヘテロ", "地固めがすごい"},
out: "取得対象とするURLを1つ指定してください",
},
{
name: "異常系_取得ファイル重複",
in: []string{"goIria", "."},
out: "取得対象のファイルが既に存在しています:.",
},
}

/*
Test Suite Run
サブ実行:go test -v ./iria -run TestNew/New_正常系
*/
func TestNew(t *testing.T) {
t.Run("New_正常系", func(t *testing.T) {
for _, target := range newNomalCases {
fmt.Println(target.name)
testNewNormal(t, target)
}
})
t.Run("New_異常系", func(t *testing.T) {
for _, target := range newErrCases {
fmt.Println(target.name)
testNewError(t, target)
}
})
}

//正常系テストコード
func testNewNormal(t *testing.T, target NewTestCase) {
t.Helper()
actual, err := iria.New(target.in)

if err != nil {
t.Errorf("err expected nil: %v", err.Error())
}
if actual == nil {
t.Error("New expected Nonnil")
}
//構造体の中身ごと一致するか比較
if !reflect.DeepEqual(actual, target.out) {
t.Errorf("case:%v => %q, want %v ,actual %v", target.name, target.in, target.out, actual)
}
}

//異常系テストコード
func testNewError(t *testing.T, target NewTestCase) {
t.Helper()
_, err := iria.New(target.in)
if err == nil {
t.Error("error expected non nil")
}
if err.Error() != target.out {
t.Errorf("case:%v => %q, want %q ,actual %q", target.name, target.in, target.out, err.Error())
}
}
Loading