From 1c463369f500e99df21d34f630e796e92d919b17 Mon Sep 17 00:00:00 2001 From: yusukemisa Date: Sat, 12 May 2018 14:50:36 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[add]=20=E8=AA=B2=E9=A1=8C=E6=8F=90?= =?UTF-8?q?=E5=87=BA=EF=BC=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- kadai3/yusukemisa/goIria/README.md | 53 ++++++ kadai3/yusukemisa/goIria/iria/downloader.go | 172 ++++++++++++++++++ .../yusukemisa/goIria/iria/downloader_test.go | 117 ++++++++++++ kadai3/yusukemisa/goIria/iria/iria.go | 53 ++++++ kadai3/yusukemisa/goIria/iria/iria_test.go | 101 ++++++++++ kadai3/yusukemisa/goIria/main.go | 20 ++ 6 files changed, 516 insertions(+) create mode 100644 kadai3/yusukemisa/goIria/README.md create mode 100644 kadai3/yusukemisa/goIria/iria/downloader.go create mode 100644 kadai3/yusukemisa/goIria/iria/downloader_test.go create mode 100644 kadai3/yusukemisa/goIria/iria/iria.go create mode 100644 kadai3/yusukemisa/goIria/iria/iria_test.go create mode 100644 kadai3/yusukemisa/goIria/main.go diff --git a/kadai3/yusukemisa/goIria/README.md b/kadai3/yusukemisa/goIria/README.md new file mode 100644 index 0000000..d9b7194 --- /dev/null +++ b/kadai3/yusukemisa/goIria/README.md @@ -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 +``` diff --git a/kadai3/yusukemisa/goIria/iria/downloader.go b/kadai3/yusukemisa/goIria/iria/downloader.go new file mode 100644 index 0000000..01b1f37 --- /dev/null +++ b/kadai3/yusukemisa/goIria/iria/downloader.go @@ -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 +} diff --git a/kadai3/yusukemisa/goIria/iria/downloader_test.go b/kadai3/yusukemisa/goIria/iria/downloader_test.go new file mode 100644 index 0000000..f4f83ff --- /dev/null +++ b/kadai3/yusukemisa/goIria/iria/downloader_test.go @@ -0,0 +1,117 @@ +package iria_test + +import ( + "context" + "fmt" + "net/http" + "reflect" + "runtime" + "strconv" + "testing" + + "github.com/yusukemisa/goIria/iria" +) + +type TestDownloader struct { + StatusCode int + ContentLength int64 + *iria.Downloader +} + +func (d *TestDownloader) Head(ctx context.Context) (*http.Response, error) { + return &http.Response{ + ContentLength: 9999, + }, nil +} + +/* + downloader.GetContentLength + 正常系ケース定義 +*/ +var getContentLengthNomalCases = []NewTestCase{ + { + name: "正常系_有効URL", + in: []string{"206", "99999"}, + out: &iria.Downloader{ + URL: "http://localhost:0", + SplitNum: runtime.NumCPU(), + }, + }, +} + +/* + downloader.GetContentLength + 異常系ケース定義 +*/ +var getContentLengthErrCases = []NewTestCase{ + { + name: "異常系 Status Not OK", + in: []string{"451", "0"}, + out: "取得対象とするURLを1つ指定してください", + }, +} + +/* + Test Suite Run + サブ実行:go test -v ./iria -run TestNew/New_正常系 +*/ +func TestGetContentLength(t *testing.T) { + t.Run("GetContentLength_正常系", func(t *testing.T) { + for _, target := range getContentLengthNomalCases { + fmt.Println(target.name) + testGetContentLengthNormal(t, target) + } + }) + t.Run("GetContentLength_異常系", func(t *testing.T) { + for _, target := range getContentLengthErrCases { + fmt.Println(target.name) + testGetContentLengthError(t, target) + } + }) +} + +//正常系テストコード +func testGetContentLengthNormal(t *testing.T, target NewTestCase) { + t.Helper() + + status, err := strconv.Atoi(target.in[0]) + if err != nil { + t.Fail() + } + length, err := strconv.Atoi(target.in[1]) + if err != nil { + t.Fail() + } + + d := &TestDownloader{ + StatusCode: status, + ContentLength: int64(length), + Downloader: &iria.Downloader{ + URL: "http://localhost:0", + SplitNum: runtime.NumCPU(), + }, + } + actual, err := d.GetContentLength() + if err != nil { + t.Errorf("err expected nil: %v", err.Error()) + } + if actual == 0 { + 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 testGetContentLengthError(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()) + } +} diff --git a/kadai3/yusukemisa/goIria/iria/iria.go b/kadai3/yusukemisa/goIria/iria/iria.go new file mode 100644 index 0000000..7489eeb --- /dev/null +++ b/kadai3/yusukemisa/goIria/iria/iria.go @@ -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) +} diff --git a/kadai3/yusukemisa/goIria/iria/iria_test.go b/kadai3/yusukemisa/goIria/iria/iria_test.go new file mode 100644 index 0000000..6aaa5a4 --- /dev/null +++ b/kadai3/yusukemisa/goIria/iria/iria_test.go @@ -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()) + } +} diff --git a/kadai3/yusukemisa/goIria/main.go b/kadai3/yusukemisa/goIria/main.go new file mode 100644 index 0000000..6205874 --- /dev/null +++ b/kadai3/yusukemisa/goIria/main.go @@ -0,0 +1,20 @@ +package main + +import ( + "log" + "os" + + "github.com/yusukemisa/goIria/iria" +) + +//RFC 7233 — HTTP/1.1: Range Requests +//go run main.go https://beauty.hotpepper.jp/CSP/c_common/ALL/IMG/cam_cm_327_98.jpg +func main() { + downloader, err := iria.New(os.Args) + if err != nil { + log.Fatalln(err.Error()) + } + if err := downloader.Execute(); err != nil { + log.Fatalln(err.Error()) + } +} From e0c8a3c3bb69f1cd1e5df305fc252ccb828079ad Mon Sep 17 00:00:00 2001 From: yusukemisa Date: Sat, 12 May 2018 15:25:45 +0900 Subject: [PATCH 2/2] =?UTF-8?q?[delete]=E3=82=88=E3=81=B6=E3=82=93?= =?UTF-8?q?=E3=81=AB=E3=82=B3=E3=83=9F=E3=83=83=E3=83=88=E3=81=97=E3=81=9F?= =?UTF-8?q?=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB=E5=89=8A=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yusukemisa/goIria/iria/downloader_test.go | 117 ------------------ 1 file changed, 117 deletions(-) delete mode 100644 kadai3/yusukemisa/goIria/iria/downloader_test.go diff --git a/kadai3/yusukemisa/goIria/iria/downloader_test.go b/kadai3/yusukemisa/goIria/iria/downloader_test.go deleted file mode 100644 index f4f83ff..0000000 --- a/kadai3/yusukemisa/goIria/iria/downloader_test.go +++ /dev/null @@ -1,117 +0,0 @@ -package iria_test - -import ( - "context" - "fmt" - "net/http" - "reflect" - "runtime" - "strconv" - "testing" - - "github.com/yusukemisa/goIria/iria" -) - -type TestDownloader struct { - StatusCode int - ContentLength int64 - *iria.Downloader -} - -func (d *TestDownloader) Head(ctx context.Context) (*http.Response, error) { - return &http.Response{ - ContentLength: 9999, - }, nil -} - -/* - downloader.GetContentLength - 正常系ケース定義 -*/ -var getContentLengthNomalCases = []NewTestCase{ - { - name: "正常系_有効URL", - in: []string{"206", "99999"}, - out: &iria.Downloader{ - URL: "http://localhost:0", - SplitNum: runtime.NumCPU(), - }, - }, -} - -/* - downloader.GetContentLength - 異常系ケース定義 -*/ -var getContentLengthErrCases = []NewTestCase{ - { - name: "異常系 Status Not OK", - in: []string{"451", "0"}, - out: "取得対象とするURLを1つ指定してください", - }, -} - -/* - Test Suite Run - サブ実行:go test -v ./iria -run TestNew/New_正常系 -*/ -func TestGetContentLength(t *testing.T) { - t.Run("GetContentLength_正常系", func(t *testing.T) { - for _, target := range getContentLengthNomalCases { - fmt.Println(target.name) - testGetContentLengthNormal(t, target) - } - }) - t.Run("GetContentLength_異常系", func(t *testing.T) { - for _, target := range getContentLengthErrCases { - fmt.Println(target.name) - testGetContentLengthError(t, target) - } - }) -} - -//正常系テストコード -func testGetContentLengthNormal(t *testing.T, target NewTestCase) { - t.Helper() - - status, err := strconv.Atoi(target.in[0]) - if err != nil { - t.Fail() - } - length, err := strconv.Atoi(target.in[1]) - if err != nil { - t.Fail() - } - - d := &TestDownloader{ - StatusCode: status, - ContentLength: int64(length), - Downloader: &iria.Downloader{ - URL: "http://localhost:0", - SplitNum: runtime.NumCPU(), - }, - } - actual, err := d.GetContentLength() - if err != nil { - t.Errorf("err expected nil: %v", err.Error()) - } - if actual == 0 { - 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 testGetContentLengthError(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()) - } -}