🐈‍⬛

ローカルネットワーク内で気軽にファイル転送してぇ

2023/10/20に公開

この記事では Go言語 でファイル転送CLIツールを作成した知識を共有する

ローカルネットワーク内でファイル転送してぇ

最近業務で mac から windows に 1ファイルだけ転送する必要がありました。その時に思ったのですが、あれ?これどうすればいいんだ?と
mac同士であれば、AirDrop で転送できますが、相手は windows です。その時は、クラウドストレージで対応したのですが、CLI とかで簡単にできればなぁと思い、たまたま、以下の記事を読んでいたので、gRPCを用いてファイル転送CLIを作ろう!と思った次第です。

https://zenn.dev/hsaki/books/golang-grpc-starting

たぶん、調べれば他のツールが既にあると思いますが、自分用のファイル転送CLIがあると愛着湧きそうですしね。

作ったものはGithubにあげています。
https://github.com/fukurose/sam

どのようにファイル転送するか考察

容量が大きいファイルの転送も考慮すると、Server streaming RPC でやりとりするのが良さそうです。また、CLIなので、カッコよくプログレスバーも表示したいですし、複数ファイル転送もしたいですよね。

最初に思いついたのは、以下のような転送でした。

ただ、この場合、クライアント側ではチャンクを受け取った時にどのファイルのデータチャンクか判断がつかないので、チャンクと一緒にメタ情報(ファイル名やファイル容量)を送る必要がある。という問題があります。1つのファイルで3回データを送るとして、毎回メタ情報も一緒に送るのは、効率が良いとはいえません。

そこで以下のような構成を考えました。

クライアントはまず、サーバーから転送予定のファイル情報群をもらい、それをもとに1ファイル1ストリームでやりとりする方法です。やり取りは増えますが、1ファイル1ストリームにすることで、並行で処理できるようになりますし、最初にファイル一覧をもらうことで、フィルター機能とかも実現できます。(今回はフィルターは実装しないですが)

最初の一歩のprotoファイル

構成は決まりましたので、protoファイルを作成していきます。

ファイル一覧のストリーム(ListSegmentStream)とファイル取得のストリーム(OrderStream)を定義して、ファイル一覧の方は対象のディレクトリパスを渡せば、そのディレクトリにあるファイル名とファイル容量を返す、ファイル取得の方は、ファイルパスを渡すと、データチャンクを返すようにします。表にすると以下のとおりです。

名称 役目 リクエスト レスポンス
ListSegmentStream ファイル一覧を取得する パス ファイルパス、ファイル容量
OrderStream ファイルを取得する ファイルパス データチャンク

protoファイルです。

syntax = "proto3";

package grpc;
option go_package = "grpc/";

service PorterService {
	rpc ListSegmentStream (LSRequest) returns (stream LSResponse);
	rpc OrderStream (OrderRequest) returns (stream OrderResponse);
}

message LSRequest {
	string path = 1;
}

message LSResponse {
	string path = 1;
	int64 size = 2;
}

message OrderRequest {
	string file_path = 1;
}

message OrderResponse {
	bytes data = 1;
}

それでは、コードを自動作成しましょう。便利なものです。

ファイル一覧情報のやりとり

それでは、ここから自動作成されたコードを用いて、実装していきます。まずは、クライアントとサーバーでファイル一覧のやり取りができることを目指します。

サーバー側

まずはサーバー側の実装ですが、サーバー側はリクエストでディレクトリパスが指定されるので、そのディレクトリにあるファイル一覧を返す処理となります。対象ディレクトリ階層下のファイルを全て返すようにします。

server/main.go
type porterServer struct {
	porter.UnimplementedPorterServiceServer
}

func (s *porterServer) ListSegmentStream(req *porter.LSRequest, stream porter.PorterService_ListSegmentStreamServer) error {
	// リクエストのパスに対して、階層を辿ってファイルを検索する
	err := filepath.Walk(req.GetPath(), func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}

		// 対象がファイルであれば、クライアントに情報を送る
		if !info.IsDir() {
			f, err := os.Open(path)
			if err != nil {
				return err
			}
			defer f.Close()

			if err := stream.Send(&porter.LSResponse{
				Path: path,
				Size: info.Size(),
			}); err != nil {
				return err
			}
		}

		return nil
	})

	if err != nil {
		fmt.Println(err)
		return err
	}

	return nil
}

あとは、main関数でgRPCサーバーを起動しておきます。

server/main.go
func main() {
	port := 8080
	listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
	if err != nil {
		panic(err)
	}

	s := grpc.NewServer()
	porter.RegisterPorterServiceServer(s, &porterServer{})
	reflection.Register(s)

	go func() {
		log.Printf("start gRPC server port: %v", port)
		s.Serve(listener)
	}()

	quit := make(chan os.Signal, 1)
	signal.Notify(quit, os.Interrupt)
	<-quit
	log.Println("stopping gRPC server...")
	s.GracefulStop()
}

クライアント側

次はクライアントです。
クライアントは簡単で、先ほど実装した ListSegmentStream を呼び出して、ファイル名とファイル容量をもらうだけです。

client/main.go
package main

var (
	client porter.PorterServiceClient
)

func TransPort() {
	path := "." // パスはカレントディレクトリを指定
	req := &porter.LSRequest{
		Path: path,
	}

	// サーバーからファイル一覧を受け取る
	ls, err := client.ListSegmentStream(context.Background(), req)
	if err != nil {
		fmt.Println(err)
		return
	}
	for {
		res, err := ls.Recv()
		if errors.Is(err, io.EOF) {
			break
		}

		if err != nil {
			fmt.Println(err)
		}
		
		// 実際のファイルパスは path からの相対パスとする
		rel, err := filepath.Rel(path, res.GetPath())
		if err != nil {
			fmt.Println(err)
		}
		// ファイルパスとファイル容量を表
		fmt.Println("file_path:", rel)
		fmt.Println("file_size:", res.GetSize())
	}
}

最後に main 関数でgRPCサーバーに接続する処理を記載します。

client/main.go
func main() {
	address := "localhost:8080"
	conn, err := grpc.Dial(
		address,

		grpc.WithTransportCredentials(insecure.NewCredentials()),
		grpc.WithBlock(),
	)
	if err != nil {
		log.Fatal("Connection failed.")
		return
	}
	defer conn.Close()

	client = porter.NewPorterServiceClient(conn)
	TransPort()
}

完成です。サーバー側で gRPC を起動して、クライアント側でファイル情報を取得してみましょう。
サーバー側のカレントディクレトリのファイル情報がコンソールに表示されていればOKです。クライアントの実装で、ディレクトリパスと指定している箇所を変えても試してみましょう。

ファイルの転送

ファイル情報は取得できたので、本日のメインとなるファイル転送を実装していきます。

サーバー側

ファイル転送のストリーム処理(OrderStream)は、リクエストとしてファイルパスをもらい、そのファイルのデータチャンクを送信するというものでした。そちらを実装していきます。
ちなみに、gRPCでは一度に送信できるデフォルト容量は4MBとなっています。

server/main.go
func (s *porterServer) OrderStream(req *porter.OrderRequest, stream porter.PorterService_OrderStreamServer) error {
	// リクエストでもらったファイルをオープン
	f, err := os.Open(req.GetFilePath())
	if err != nil {
		return err
	}
	defer f.Close()

	// 約4MBを区切りとして、データを読み込みクライアントに送信
	buf := make([]byte, 4194304) // Limit 4MB
	for {
		n, err := f.Read(buf)
		if err == io.EOF {
			break
		}
		if err != nil {
			return err
		}

		if err := stream.Send(&porter.OrderResponse{
			Data: buf[:n],
		}); err != nil {
			return err
		}
	}

	return nil
}

クライアント側

クライアント側では、サーバー側からもらったファイル情報をもとに、先ほど実装した OrderStream を呼び出し、データチャンクを書き込む処理にします。処理は以下となります。

client/main.go
func Order(file_info *porter.LSResponse, toPath string) {
	req := &porter.OrderRequest{
		FilePath: file_info.GetPath(),
	}

	fmt.Println("転送開始: ", toPath)
	stream, err := client.OrderStream(context.Background(), req)
	if err != nil {
		fmt.Println(err)
		return
	}

	for {
		res, err := stream.Recv()
		if errors.Is(err, io.EOF) {
			fmt.Println("転送完了: ", toPath)
			break
		}
		if err != nil {
			fmt.Println(err)
			break
		}
		// TODO: ファイル情報に権限も付与して、その権限でファイルを作成した方がよい
		err = os.MkdirAll(filepath.Dir(toPath), 0750)
		if err != nil {
			fmt.Println(err)
			break
		}

		file, err := os.Create(toPath)
		if err != nil {
			fmt.Println(err)
			break
		}
		defer file.Close()

		// サーバーから受け取ったデータを書き込み
		_, err = file.Write(res.GetData())
		if err != nil {
			fmt.Println(err)
			break
		}
	}
}

では、前回ではファイル情報を表示していた箇所を、ファイル取得関数を呼び出すように変えます。

client/main.go
func TransPort() {
	// 略
-       // ファイルパスとファイル容量を表示
-	fmt.Println("file_path:", rel)
-	fmt.Println("file_size:", res.GetSize())
+	Order(res, rel)

こちらで完了です!!
もし実行する場合は、サーバー側の main.go ファイルが クライアント側の main.go を上書きする可能性があるので、クライアント側の実行は

mkdir tmp
cd tmp
go run ../main.go

とかで確認してみて下さい。もちろん、サーバー側の起動場所を変えてもいいですし、カレントディレクトリではなくて、別のパスを指定して実行しても大丈夫です。

ファイルが転送されたでしょうか。

折角のGo言語なので..

ファイル転送はできたのですが、複数ファイルを並行で処理したいですよね。そこだけ最後にやっていきましょう。
対応はクライアント側のみとなります。ファイル一覧を取得して、実際に取得する処理(Order関数)を並行で実行します。
つまり、Order関数はサブのゴールーチンで実施し、メインではそれらが終わるのを待つ。となります。
実装は以下のとおりです。

client/main.go
func TransPort() {
	// 略
+	var wg sync.WaitGroup
	for {
		res, err := ls.Recv()
		if errors.Is(err, io.EOF) {
			break
		}

		if err != nil {
			fmt.Println(err)
		}

+		wg.Add(1)
+		go func() {
+			defer wg.Done()
			Order(res)
+		}()
	}
+	wg.Wait()
}

こちらでgRPCでのファイル転送部分は完了です。サンプルでは localhost で実施していますが、IPアドレスを指定すれば、ローカルネットワーク内の違うPCとかにも接続できます。

長くなったので、あとは省略しますが、CLIに落とし込んだり、プログレスバーをつけたりなど、自分のファイル転送CLIを作成頂ければと思います。

余談ですが、私は DEATH STRANDING というゲームをやっているので、CLIの名称をsam(ゲームの主人公で配達人)にしました

お疲れ様でした。

Discussion