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/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()) + } +}