Рад снова всех вас приветствовать! На этой лекции мы узнаем как использовать клиентский потоковый RPC для загрузки файла изображения на сервер по частям. В первой части лекции реализовывать эту задачу будем на языке Golang.
Итак, давайте начнём! Сначала мы определим RPC в файле laptop_service.proto
.
Нам нужно будет сообщение UploadImageRequest
. Идея заключается в том, чтобы
разделить файл изображения на несколько частей и посылать их по очереди серверу
в каждом сообщении-запросе. Я буду использовать здесь поле oneof
, поскольку
первый запрос будет содержать только метаданные или некоторую базовую
информацию об изображении, а следующие запросы будет содержать фрагменты данных
изображения. Сообщение ImageInfo
будет иметь два поля: идентификатор ноутбука
и тип изображения, например, ".jpg" или ".png".
message UploadImageRequest {
oneof data {
ImageInfo info = 1;
bytes chunk_data = 2;
}
}
message ImageInfo {
string laptop_id = 1;
string image_type = 2;
}
Затем мы определим сообщение UploadImageResponse
, которое будет возвращено
клиенту как только сервер получит все фрагменты изображения. Оно будет состоять
из идентификатора изображения, сгенерированного сервером, и общего размера
загруженного изображения в байтах. Теперь мы зададим UploadImage
RPC в
LaptopService
. Он принимает на вход поток UploadImageRequest
и возвращает
один, единственный ответ от сервера UploadImageResponse
.
message UploadImageResponse {
string id = 1;
string size = 2;
}
service LaptopService {
rpc CreateLaptop(CreateLaptopRequest) returns (CreateLaptopResponse) {};
rpc SearchLaptop(SearchLaptopRequest) returns (stream SearchLaptopResponse) {};
rpc UploadImage(stream UploadImageRequest) returns (UploadImageResponse) {};
}
Теперь давайте выполним команду make gen
, чтобы сгенерировать код.
make gen
Код успешно сгенерирован и если мы закомментируем строку
pb.UnimplementedLaptopServiceServer
в файле laptop_server.go
,
type LaptopServer struct {
Store LaptopStore
pb.UnimplementedLaptopServiceServer
}
то увидим ошибку в строке
pb.RegisterLaptopServiceServer(grpcServer, laptopServer)
, поскольку в
LaptopServer
не реализован метод UploadImage
, который нужен, чтобы
структура удовлетворяла интерфейсу LaptopServiceServer
.
Итак, давайте откроем файл laptop_Server.go
и добавим метод UploadImage()
в структуру LaptopServer
. Мы можем легко сигнатуру этого метода внутри
сгенерированного файла laptop_service_grpc.pb.go
. Просто скопируйте и
вставьте её сюда. Пусть пока она возвращает nil
.
func (server *LaptopServer) UploadImage(stream pb.LaptopService_UploadImageServer) error {
return nil
}
После этого появившаяся ошибка пропадёт. Мы вернемся к этому методу и напишем код для его чуть позже.
Сначала нам нужно реализовать новое хранилище изображений. Интерфейс
ImageStore
будет содержать единственный метод для сохранения изображения
ноутбука. Он будет принимать три входных параметра: идентификатор ноутбука,
тип изображения и данные изображения и возвращать идентификатор сохраненного
изображения или ошибку.
type ImageStore interface {
// Метод Save сохраняет новое изображение ноутбука в хранилище
Save(laptopID string, imageType string, imageData bytes.Buffer) (string, error)
}
Затем мы реализуем хранилище DiskImageStore
, которое будет сохранять файл
изображения на диск, а информацию о нём в память. Как и для хранилища ноутбуков
нам понадобится мьютекс для защиты доступа к ресурсу в многопоточном
приложении. Затем нам нужен будет путь к папке для сохранения изображений
ноутбука и, наконец, карта, где ключом будет идентификатор изображения, а
значением — некоторая информация об изображении.
type DiskImageStore struct {
mutex sync.RWMutex
imageFolder string
images map[string]*ImageInfo
}
Структура ImageInfo
будет состоять из трёх полей: идентификатор ноутбука,
тип изображения (или расширения файла) и пути к файлу изображения на диске.
type ImageInfo struct {
LaptopID string
Type string
Path string
}
Давайте напишем метод для создания нового DiskImageStore
. На вход он
принимает только один входной аргумент — папку с изображениями - а внутри мы
просто инициализируем карту.
func NewDiskImageStore(imageFolder string) *DiskImageStore {
return &DiskImageStore{
imageFolder: imageFolder,
images: make(map[string]*ImageInfo),
}
}
Теперь нам нужно реализовать метод Save
, чтобы структура удовлетворяла
интерфейсу ImageStore
. Сначала нам нужно сгенерировать новый случайный UUID
для изображения. Если произошла ошибка, то оборачиваем её и возвращаем. В
противном случае создаём путь для хранения изображений, склеив папку для
изображений, идентификатор и тип изображения. Затем мы вызываем os.Create
для создания файла. Если произошла ошибка, то оборачиваем её и возвращаем.
Иначе записываем данные изображения в созданный файл. Оборачиваем ошибку и
возвращаем её, если она возникла. Если файл успешно записан, нам нужно
сохранить информацию о нём в карту. Таким образом, нужно осуществить блокировку
на запись хранилища. После этого мы сохраняем информацию об изображении в
карту, где ключом будет идентификатор изображения, а значением структура,
состоящая из идентификатора ноутбука, типа изображения и пути к файлу. Наконец,
мы возвращаем идентификатор изображения и nil
, что означает отсутствие
ошибок. На этом реализация хранилища изображений завершена.
func (store *DiskImageStore) Save(
laptopID string,
imageType string,
imageData bytes.Buffer,
) (string, error) {
imageID, err := uuid.NewRandom()
if err != nil {
return "", fmt.Errorf("cannot generate image id: %w", err)
}
imagePath := fmt.Sprintf("%s/%s%s", store.imageFolder, imageID, imageType)
file, err := os.Create(imagePath)
if err != nil {
return "", fmt.Errorf("cannot create image file: %w", err)
}
_, err = imageData.WriteTo(file)
if err != nil {
return "", fmt.Errorf("cannot write image to file: %w", err)
}
store.mutex.Lock()
defer store.mutex.Unlock()
store.images[imageID.String()] = &ImageInfo{
LaptopID: laptopID,
Type: imageType,
Path: imagePath,
}
return imageID.String(), nil
}
Теперь вернемся нереализованному методу на сервере.
Нам нужно добавить новое хранилище изображений в структуру LaptopServer
,
поэтому я изменю название поля для хранилища ноутбуков на laptopStore
и
добавлю imageStore
в качестве второго параметра метода NewLaptopServer
.
type LaptopServer struct {
laptopStore LaptopStore
imageStore ImageStore
pb.UnimplementedLaptopServiceServer
}
// NewLaptopServer возвращает новый LaptopServer
func NewLaptopServer(laptopStore LaptopStore, imageStore ImageStore) *LaptopServer {
return &LaptopServer{
laptopStore: laptopStore,
imageStore: imageStore,
}
}
Теперь из-за этого изменения появились новые ошибки. Давайте откроем файл
laptop_client_test.go
. Во-первых, публичное поле Store
было изменено на
laptopStore
. Поэтому давайте вынесем это новое хранилище ноутбуков в памяти
в отдельную переменную и будем вызывать метод Find
с помощью неё. Теперь нам
больше не понадобится объект laptopServer
, поэтому давайте удалим его из
функции startTestLaptopServer
и добавим imageStore
в качестве входного
параметра. Теперь мы можем передать два хранилища в функцию
NewLaptopServer()
. В конечном счёте startTestLaptopServer
возвращает только
адрес сервера. Поэтому в тесте TestClientCreateLaptop
мы просто передаём
nil
вместо хранилища изображений, поскольку в нём оно не используется.
func TestClientCreateLaptop(t *testing.T) {
// ...
laptopStore := service.NewInMemoryLaptopStore()
serverAddress := startTestLaptopServer(t, laptopStore, nil)
// ...
other, err := laptopStore.Find(res.Id)
}
func startTestLaptopServer(t *testing.T, laptopStore service.LaptopStore, imageStore service.ImageStore) string {
laptopServer := service.NewLaptopServer(laptopStore, imageStore)
// ...
return listener.Addr().String()
}
По аналогии осуществим замены для теста поиска ноутбука. В этом файле больше не должно быть ошибок.
func TestClientSearchLaptop(t *testing.T) {
// ...
laptopStore := service.NewInMemoryLaptopStore()
expectedIDs := make(map[string]bool)
for i := 0; i < 6; i++ {
// ...
err := laptopStore.Save(laptop)
require.NoError(t, err)
}
serverAddress := startTestLaptopServer(t, laptopStore, nil)
// ...
}
Осуществим аналогичные действия для файла laptop_server_test.go
.
func TestServerCreateLaptop(t *testing.T) {
// ...
server := service.NewLaptopServer(tc.store, nil)
res, err := server.CreateLaptop(context.Background(), req)
// ...
}
Наконец, в файле laptop_server.go
мы просто изменим вызов с Store
на
laptopStore
и все ошибки пропадут.
func (server *LaptopServer) CreateLaptop(
ctx context.Context,
req *pb.CreateLaptopRequest,
) (*pb.CreateLaptopResponse, error) {
// ...
// сохраняем ноутбук в хранилище
err := server.laptopStore.Save(laptop)
// ...
}
Чтобы убедиться в этом, я запущу unit тесты для этого пакета.
cd service
go test
Тесты успешно пройдены, т. е. ничего не сломалось. Теперь в файле main.go
сервера нам также нужно передавать два хранилища в метод NewLaptopServer
.
Сначала хранилище ноутбуков, а затем — хранилище изображений. Я создам новую
папку "img" для сохранения загруженных изображений.
func main() {
// ...
laptopStore := service.NewInMemoryLaptopStore()
imageStore := service.NewDiskImageStore("img")
laptopServer := service.NewLaptopServer(laptopStore, imageStore)
// ...
}
Вроде бы в файле ошибок больше нет. Давайте реализуем метод UploadImage
на
сервере. Сначала вызовем stream.Recv()
, чтобы получить первый запрос, в
котором содержится информация об изображении. Если произошла ошибка, мы пишем
её в лог и возвращаем клиенту код состояния Unknown
.
func (server *LaptopServer) UploadImage(stream pb.LaptopService_UploadImageServer) error {
req, err := stream.Recv()
if err != nil {
log.Print("cannot receive image info", err)
return status.Errorf(codes.Unknown, "cannot receive image info")
}
return nil
}
Этот кусок кода достаточно длинный и часто повторяется, поэтому я вынесу его в
отдельную функцию logError()
, которая будет писать ошибку в лог и возвращать
её. Функция добавляет ошибку в лог только в случае, если она не равна nil
и
всегда возвращает ошибку вызвавшему.
func logError(err error) error {
if err != nil {
log.Print(err)
}
return err
}
Теперь используя эту функцию, мы можем упростить блок обработки ошибок следующим образом.
func (server *LaptopServer) UploadImage(stream pb.LaptopService_UploadImageServer) error {
// ...
if err != nil {
return logError(status.Errorf(codes.Unknown, "cannot receive image info"))
}
// ...
}
Если ошибок не возникло, мы можем получить идентификатор ноутбука, а также тип изображения из запроса. Давайте добавим сообщение в лог о том, что мы получили запрос upload-image с соответствующим идентификатором ноутбука и типом изображения.
func (server *LaptopServer) UploadImage(stream pb.LaptopService_UploadImageServer) error {
// ...
laptopID := req.GetInfo().GetLaptopId()
imageType := req.GetInfo().GetImageType()
log.Printf("receive an upload-image request for laptop %s with image type %s", laptopID, imageType)
// ...
}
Затем мы должны убедиться, что существует ноутбук с таким идентификатором.
Поэтому мы вызываем server.laptopStore.Find()
, чтобы найти ноутбук по его
идентификатору. Если мы получили ошибку, просто выведем её в лог и вернем с
кодом состояния Internal
. Если значение переменной laptop
равно nil
, т.
е. ноутбука с таким идентификатор в хранилище нет, то мы пишем ошибку в лог и
возвращаем код состояния ошибки InvalidArgument
. Также вы можете использовать
код NotFound
, если хотите.
func (server *LaptopServer) UploadImage(stream pb.LaptopService_UploadImageServer) error {
// ...
laptop, err := server.laptopStore.Find(laptopID)
if err != nil {
return logError(status.Errorf(codes.Internal, "cannot find laptop: %v", err))
}
if laptop == nil {
return logError(status.Errorf(codes.InvalidArgument, "laptop %s doesn't exists", laptopID))
}
// ...
}
Теперь, если до этого всё прошло без ошибок и ноутбук найден, мы можем
начать получать данные о фрагментах изображения. Давайте создадим новый байтовый
буфер для их хранения, а также переменную для отслеживания общего размера
изображения. Поскольку мы собираемся получать множество сообщений из потока,
я буду использовать здесь цикл for
. Внутри него давайте добавим сообщение в
лог о том, что мы ожидаем фрагмент с данными. Как и раньше, мы вызываем
stream.Recv()
, чтобы получить запрос. Но в этот раз мы сначала проверяем
произошла или нет ошибка EOF
. Если да, то это означает, что данные больше не
будут отправляться, и мы можем не опасаясь потери данных выйти из цикла. Если
возникла ошибка и она не равна nil
, то мы возвращаем её клиенту с кодом
состояния Unknown
. Если ошибки нет, мы можем получить фрагмент данных из
запроса и его размер, используя функцию len()
. Затем добавим этот размер к
общему размеру изображения.
func (server *LaptopServer) UploadImage(stream pb.LaptopService_UploadImageServer) error {
// ...
imageData := bytes.Buffer{}
imageSize := 0
for {
log.Print("waiting to receive more data")
req, err := stream.Recv()
if err == io.EOF {
log.Print("no more data")
break
}
if err != nil {
return logError(status.Errorf(codes.Unknown, "cannot receive data: %v", err))
}
chunk := req.GetChunkData()
size := len(chunk)
imageSize += size
}
// ...
}
Допустим, что мы не хотим, чтобы клиент отправлял слишком большое изображение, поэтому мы проверим, что размер изображения не превышает максимальный. Я определю константу для максимального размера изображения равной 1 мегабайту.
// максимум в 1 мегабайт
const maxImageSize = 1 << 20
Теперь при превышении максимального размера, мы можем вернуть ошибку с кодом
состояния InvalidArgument
и сообщение о том, что изображение слишком
большое. В противном случае мы можем добавить фрагмент к данным изображения
с помощью функции Write()
. Также добавим сообщение в лог и вернём код статуса
Internal
, если возникла ошибка при записи.
func (server *LaptopServer) UploadImage(stream pb.LaptopService_UploadImageServer) error {
// ...
for {
// ...
if imageSize > maxImageSize {
return logError(status.Errorf(codes.InvalidArgument, "image is too large %d > %d", imageSize, maxImageSize))
}
_, err = imageData.Write(chunk)
if err != nil {
return logError(status.Errorf(codes.Internal, "cannot write chunk data: %v", err))
}
}
// ...
}
После цикла for
мы собрали все данные изображения в буфере. Теперь мы можем
вызвать метод imageStore.Save
, чтобы сохранить данные изображения в хранилище
и получить идентификатор изображения. Если возникла ошибка, мы добавляем её в
лог и возвращаем её с кодом состояния Internal
. Если изображение успешно
сохранено, мы создаём объект-ответ от сервера с идентификатором и размером
изображения. Затем вызываем stream.SendAndClose()
, чтобы послать ответ
клиенту. При возникновении ошибки выдаём её с кодом состояния Unknown
. И,
наконец, если всё прошло без ошибок, мы можем записать сообщение в лог о том,
что изображение успешно сохранено с соответствующим идентификатором и размером.
Реализация логики работы сервера на этом завершена.
func (server *LaptopServer) UploadImage(stream pb.LaptopService_UploadImageServer) error {
// ...
imageID, err := server.imageStore.Save(laptopID, imageType, imageData)
if err != nil {
return logError(status.Errorf(codes.Internal, "cannot save image to the store: %v", err))
}
res := &pb.UploadImageResponse{
Id: imageID,
Size: uint32(imageSize),
}
err = stream.SendAndClose(res)
if err != nil {
return logError(status.Errorf(codes.Unknown, "cannot send response: %v", err))
}
log.Printf("saved image with id: %s, size: %d", imageID, imageSize)
// ...
}
Теперь давайте реализуем логику работы клиента.
Сначала я немного реорганизую код. Давайте сделаем ноутбук параметром функции
createLaptop
cmd/client/main.go
func createLaptop(laptopClient pb.LaptopServiceClient, laptop *pb.Laptop) {
laptop.Id = ""
req := &pb.CreateLaptopRequest{
Laptop: laptop,
}
// ...
}
и будем отправлять в неё ноутбук, как показано в этом цикле for
.
cmd/client/main.go
func main() {
// ...
for i := 0; i < 10; i++ {
createLaptop(laptopClient, sample.NewLaptop())
}
// ...
}
Затем я создам отдельную функцию для тестирования RPC, предназначенного для поиска ноутбука, который мы написали на прошлой лекции. Давайте скопируем этот фрагмент кода
for i := 0; i < 10; i++ {
createLaptop(laptopClient, sample.NewLaptop())
}
filter := &pb.Filter{
MaxPriceUsd: 3000,
MinCpuCores: 4,
MinCpuGhz: 2.5,
MinRam: &pb.Memory{Value: 8, Unit: pb.Memory_GIGABYTE},
}
searchLaptop(laptopClient, filter)
и вставим его в функцию.
func testSearchLaptop(laptopClient pb.LaptopServiceClient) {
for i := 0; i < 10; i++ {
createLaptop(laptopClient, sample.NewLaptop())
}
filter := &pb.Filter{
MaxPriceUsd: 3000,
MinCpuCores: 4,
MinCpuGhz: 2.5,
MinRam: &pb.Memory{Value: 8, Unit: pb.Memory_GIGABYTE},
}
searchLaptop(laptopClient, filter)
}
Давайте добавим ещё одну функцию для тестирования RPC, предназначенного для создания ноутбука.
func testCreateLaptop(laptopClient pb.LaptopServiceClient) {
createLaptop(laptopClient, sample.NewLaptop())
}
Теперь мы напишем новую функцию для тестирования RPC, предназначенного для
загрузки изображений и вызовем его из функции main
.
func testUploadImage(laptopClient pb.LaptopServiceClient) {
}
func main() {
// ...
laptopClient := pb.NewLaptopServiceClient(conn)
testUploadImage(laptopClient)
}
В этой функции testUploadImage()
мы сначала генерируем случайный ноутбук и
вызываем createLaptop()
, чтобы создать его на сервере. Затем мы напишем новую
функцию uploadImage()
для загрузки изображения этого ноутбука на сервер.
func testUploadImage(laptopClient pb.LaptopServiceClient) {
laptop := sample.NewLaptop()
createLaptop(laptopClient, laptop)
uploadImage(laptopClient, laptop.GetId(), "tmp/laptop.jpg")
}
Эта функция будет иметь три входных параметра: клиент, идентификатор ноутбука
и путь к изображению ноутбука. Сначала мы вызываем os.Open()
, чтобы открыть
файл изображения. Если возникла ошибка, мы пишем её в лог и аварийно завершаем
работу. В противном случае мы используем ключевое слово defer
, чтобы закрыть
файл после завершения функции main
. Затем мы создаём контекст с таймаутом в
5 секунд и вызываем метод laptopClient.UploadImage()
с этим контекстом. Он
возвращает объект-поток и ошибку. Если ошибка не равна nil
, мы пишем её в лог
и аварийно завершаем работу. Иначе создаём первый запрос, в котором отправляем
на сервер определенную информацию об изображении, а именно, идентификатор
ноутбука и тип изображения или расширение файла изображения.
func uploadImage(laptopClient pb.LaptopServiceClient, laptopID string, imagePath string) {
file, err := os.Open(imagePath)
if err != nil {
log.Fatal("cannot open image file: ", err)
}
defer file.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
stream, err := laptopClient.UploadImage(ctx)
if err != nil {
log.Fatal("cannot upload image", err)
}
req := &pb.UploadImageRequest{
Data: &pb.UploadImageRequest_Info{
Info: &pb.ImageInfo{
LaptopId: laptopID,
ImageType: filepath.Ext(imagePath),
},
},
}
}
Отлично, теперь мы вызываем stream.Send()
, чтобы послать первый запрос на
сервер. Если мы получим ошибку, пишем её в лог и аварийно завершаем работу.
Иначе создаём буфер для считывания содержимого файла изображения по частям.
Пусть размер каждой части будет равен 1 килобайту или 1024 байт. Считаем
фрагменты данных изображения в цикле for
. Для того, чтобы добавить данные в
буфер достаточно вызвать reader.Read()
. Метод вернёт число прочитанных байтов
и ошибку. Если ошибка равна EOF, то был достигнут конец файла и мы можем выйти
из цикла. Если ошибка не равна nil
, пишем её в лог и аварийно завершаем
работу. В противном случае создаём новый запрос с данными фрагмента. Важно,
чтобы фрагмент содержал только первые n
байтов буфера. Затем опять вызываем
stream.Send()
, чтобы послать запрос на сервер. Опять пишем сообщение в лог и
аварийно завершаем работу, если произойдёт ошибка.
func uploadImage(laptopClient pb.LaptopServiceClient, laptopID string, imagePath string) {
// ...
err = stream.Send(req)
if err != nil {
log.Fatal("cannot send image info: ", err)
}
reader := bufio.NewReader(file)
buffer := make([]byte, 1024)
for {
n, err := reader.Read(buffer)
if err == io.EOF {
break
}
if err != nil {
log.Fatal("cannot read chunk to buffer: ", err)
}
req := &pb.UploadImageRequest{
Data: &pb.UploadImageRequest_ChunkData{
ChunkData: buffer[:n],
},
}
err = stream.Send(req)
if err != nil {
log.Fatal("cannot send chunk to server: ", err)
}
}
}
Наконец, после цикла for
мы вызываем метод stream.CloseAndRecv()
для
получения ответа от сервера. Если произошла ошибка, пишем её в лог и аварийно
завершаем работу. В противном случае пишем сообщение в лог о том, что
изображение успешно загружено с соответствующим идентификатором и размером.
На этом всё.
func uploadImage(laptopClient pb.LaptopServiceClient, laptopID string, imagePath string) {
res, err := stream.CloseAndRecv()
if err != nil {
log.Fatal("cannot receive response: ", err)
}
log.Printf("image uploaded with id: %s, size: %d", res.GetId(), res.GetSize())
}
Клиент готов к работе.
Теперь давайте запустим сервер
make server
и клиент.
make client
Возникла ошибка: cannot open image file laptop.jpg
(не удается открыть файл
изображения laptop.jpg
). Она произошла, потому что я забыл добавить файл в
папку tmp
. Давайте сделаем это. У меня уже скачан файл с изображением
ноутбука в папке Dowload
. Я перетащу его в папку tmp
. После того как файл
будет успешно скопирован, давайте перезапустим клиент. Мы получили другую
ошибку:
2021/04/09 19:05:00 cannot send chunk to server: EOF
От этого сообщение об ошибке мало пользы, поскольку не ясна причина, из-за
которой фрагмент не может быть послан на сервер. Итак, давайте посмотрим на код
клиента. Мы знаем, что сообщение об ошибке выводится благодаря этой строчке
кода log.Fatal("cannot send chunk to server: ", err)
.
func uploadImage(laptopClient pb.LaptopServiceClient, laptopID string, imagePath string) {
// ...
for {
// ...
err = stream.Send(req)
if err != nil {
log.Fatal("cannot send chunk to server: ", err)
}
}
// ...
}
Ошибка равна EOF, поскольку когда она возникает, сервер закрывает поток и,
таким образом, клиент не сможет отправить ему больше данных. Чтобы получить
ошибку с gRPC код состояния, мы должны вызвать stream.RecvMsg()
с параметром
nil
. Теперь мы можем также вывести и эту ошибку.
func uploadImage(laptopClient pb.LaptopServiceClient, laptopID string, imagePath string) {
// ...
for {
// ...
err = stream.Send(req)
if err != nil {
err2 := stream.RecvMsg(nil)
log.Fatal("cannot send chunk to server: ", err, err2)
}
}
// ...
}
Перезапустите клиент и посмотрите что произойдёт.
2021/04/09 19:14:24 cannot send chunk to server: EOF rpc error: code = InvalidArgument desc = laptop doesn't exists
Теперь мы видим, что возникла на самом деле ошибка InvalidArgument, laptop doesn't exists
. Причина в том, что идентификатор ноутбука равен пустой строке.
Теперь давайте немного изменим сообщение об ошибке и будем писать в лог сразу
stream.RecvMsg(nil)
.
func uploadImage(laptopClient pb.LaptopServiceClient, laptopID string, imagePath string) {
// ...
err = stream.Send(req)
if err != nil {
log.Fatal("cannot send image info: ", err, stream.RecvMsg(nil))
}
// ...
for {
// ...
err = stream.Send(req)
if err != nil {
err2 := stream.RecvMsg(nil)
log.Fatal("cannot send chunk to server: ", err, err2)
}
}
// ...
}
Идентификатор ноутбука пуст, потому что он так задан в функции
createLaptop()
. Поэтому давайте удалим эту строку
func createLaptop(laptopClient pb.LaptopServiceClient, laptop *pb.Laptop) {
laptop.Id = "" // удалите эту строку
req := &pb.CreateLaptopRequest{
Laptop: laptop,
}
// ...
}
и перезапустим клиент. На этот раз все работает.
2021/04/09 19:26:13 image uploaded with id: f3b2a396-2765-475f-9d28-f5e8d9614093, size: 87635
Изображение успешно загружено. На стороне сервера мы видим множество сообщений типа: waiting to receive more data (ожидаю получения следующих данных). Чего именно ожидает сервер не сразу понятно, поэтому давайте добавим здесь ещё одно сообщение в лог о том, что мы получили новый фрагмент с данными и укажем их размер.
service/laptop_server.go
func (server *LaptopServer) UploadImage(stream pb.LaptopService_UploadImageServer) error {
// ...
for {
// ...
chunk := req.GetChunkData()
size := len(chunk)
log.Printf("received a chunk with size: %d", size)
imageSize += size
if imageSize > maxImageSize {
return logError(status.Errorf(codes.InvalidArgument, "image is too large %d > %d", imageSize, maxImageSize))
}
// ...
}
// ...
}
Отлично, теперь если мы откроем папку img
, то увидим, что изображение
ноутбука было сохранено в ней. Превосходно! Теперь давайте посмотрим, что
случится, если произойдет таймаут. Предположим, что по какой-то причине сервер
очень медленно записывает данные. Чтобы смоделировать это поведение, я добавлю
задержку в 1 секунду перед записью фрагмента в буфер.
func (server *LaptopServer) UploadImage(stream pb.LaptopService_UploadImageServer) error {
// ...
for {
// ...
imageSize += size
if imageSize > maxImageSize {
return logError(status.Errorf(codes.InvalidArgument, "image is too large %d > %d", imageSize, maxImageSize))
}
// имитируем медленную запись
time.Sleep(time.Second)
// ...
}
// ...
}
Давайте попробуем запустить сервер и клиент. Через 5 секунд, мы видим сообщение об ошибке в логах на сервере.
2021/04/09 19:39:52 rpc error: code = Unknown desc = cannot receive data: rpc error: code = DeadlineExceeded desc = context deadline exceeded
Но код состояния равен Unknown
и кроме того он содержит другую ошибку
DeadlineExceeded
, что может сбивать с толку. Поэтому давайте исправим это,
проверив ошибку контекста перед тем как получить данные из потока. Я вырежу
фрагмент кода, где происходит проверка контекста из RPC CreateLaptop
и помещу
его в отдельную функцию. Давайте воспользуемся здесь оператором switch-case,
чтобы сократить размер кода и упростить его чтение. Если ошибка контекста
равна Cancelled
, мы пишем её в лог и возвращаем ошибку. Для случая
DeadlineExceeded
, делаем то же самое. В случае по умолчанию просто
возвращаем nil.
func (server *LaptopServer) CreateLaptop(
ctx context.Context,
req *pb.CreateLaptopRequest,
) (*pb.CreateLaptopResponse, error) {
// ...
// имитируем сложные вычисления
// time.Sleep(6 * time.Second)
if err := contextError(ctx); err != nil {
return nil, err
}
// ...
}
func contextError(ctx context.Context) error {
switch ctx.Err() {
case context.Canceled:
return logError(status.Error(codes.Canceled, "request is cancelled"))
case context.DeadlineExceeded:
return logError(status.Error(codes.DeadlineExceeded, "deadline is exceeded"))
default:
return nil
}
}
Теперь вернемся к нашему циклу for
. Здесь мы вызываем функцию
contextError()
, передавая ей контекст потока. Если ошибка не равна nil
, мы
сразу же возвращаем её.
func (server *LaptopServer) UploadImage(stream pb.LaptopService_UploadImageServer) error {
// ...
for {
// check context error
if err := contextError(stream.Context()); err != nil {
return err
}
// ...
}
// ...
}
Давайте запустим север и клиент. Теперь на стороне сервера мы увидим более
понятное сообщение в логе с кодом состояния DeadlineExceeded
.
2021/04/09 20:13:37 rpc error: code = DeadlineExceeded desc = deadline is exceeded
Отлично! Давайте рассмотрим случай, когда размер загружаемого изображения больше максимально допустимого. Я изменю значение константы с 1 мегабайта на 1 килобайт.
// максимум в 1 килобайт
const maxImageSize = 1 << 10
Затем перезапустите сервер и клиент. В этот раз мы получили ошибку
InvalidArgument
: image is too large (изображение слишком большое). На стороне
сервера он получил только 2 фрагмента данных прежде чем выдал ту же ошибку.
Таким образом, ограничение на размер загружаемого изображения работает! Я
отменю внесенные изменения и оставлю максимальный размер изображения равным 1
мегабайту. Также закомментируйте эту строку time.Sleep(time.Second)
.
func (server *LaptopServer) UploadImage(stream pb.LaptopService_UploadImageServer) error {
// ...
for {
// ...
imageSize += size
if imageSize > maxImageSize {
return logError(status.Errorf(codes.InvalidArgument, "image is too large %d > %d", imageSize, maxImageSize))
}
// имитируем медленную запись
// time.Sleep(time.Second)
// ...
}
// ...
}
Теперь давайте узнаем как написать тест для этого клиентского потокового RPC.
Назовём функцию TestClientUploadImage
. Для этого теста я буду использовать
папку tmp
в качестве папки с изображениями. Прежде всего нам нужно создать
новое хранилище в памяти для ноутбуков, а затем новое хранилище изображений на
диске, используя папку tmp
. Мы сгенерируем тестовый ноутбук и сохраним его в
хранилище для ноутбуков.
func TestClientUploadImage(t *testing.T) {
t.Parallel()
testImageFolder := "../tmp"
laptopStore := service.NewInMemoryLaptopStore()
imageStore := service.NewDiskImageStore(testImageFolder)
laptop := sample.NewLaptop()
err := laptopStore.Save(laptop)
require.NoError(t, err)
}
Затем мы запускаем тестовый сервер и создаём новый клиент. Изображение, которое
мы будем загружать - это файл laptop.jpg
в папке tmp
. Итак, давайте
откроем файл, проверим что при этом не возникло ошибок и с помощью ключевого
слова defer
закроем файл после выполнения функции. Затем мы вызываем
laptopClient.UploadImage
, чтобы получить поток. После этого мы получаем тип
изображения из расширения файла.
func TestClientUploadImage(t *testing.T) {
// ...
serverAddress := startTestLaptopServer(t, laptopStore, imageStore)
laptopClient := newTestLaptopClient(t, serverAddress)
imagePath := fmt.Sprintf("%s/laptop.jpg", testImageFolder)
file, err := os.Open(imagePath)
require.NoError(t, err)
defer file.Close()
stream, err := laptopClient.UploadImage(context.Background())
require.NoError(t, err)
imageType := filepath.Ext(imagePath)
}
Оставшаяся часть теста очень похожа на то, что мы делали в файле main.go
клиента. Поэтому я просто скопирую и вставлю код, чтобы сэкономить время.
laptopID
нужно заменить на laptop.GetId()
, а тип изображения взять из
переменной imageType
. Мы заменяем блок проверки ошибок на
require.NoError()
. Аналогичную замену осуществим для ошибки в цикле. Мы
также хотим отслеживать общий размер изображения, поэтому давайте определим
здесь переменную size
и будем прибавлять значение n
к size
в цикле.
Заменим оставшиеся блоки проверки ошибок в функции на require.NoError()
.
func TestClientUploadImage(t *testing.T) {
// ...
imageType := filepath.Ext(imagePath)
req := &pb.UploadImageRequest{
Data: &pb.UploadImageRequest_Info{
Info: &pb.ImageInfo{
LaptopId: laptop.GetId(),
ImageType: imageType,
},
},
}
err = stream.Send(req)
require.NoError(t, err)
reader := bufio.NewReader(file)
buffer := make([]byte, 1024)
size := 0
for {
n, err := reader.Read(buffer)
if err == io.EOF {
break
}
require.NoError(t, err)
size += n
req := &pb.UploadImageRequest{
Data: &pb.UploadImageRequest_ChunkData{
ChunkData: buffer[:n],
},
}
err = stream.Send(req)
require.NoError(t, err)
}
res, err := stream.CloseAndRecv()
require.NoError(t, err)
}
Теперь проверим, что возвращаемый идентификатор не равен нулю и изображение,
сохраненное в переменную res, имеет тот же размер, что и переменная size
.
Мы также хотим убедиться, что изображение сохраняется в правильную папку на
сервере. Оно должно находиться в папке tmp
. Причем имя файла должно быть
равно идентификатору изображения, а расширение — типу изображения. Мы можем
использовать require.FileExists()
для проверки. И, наконец, нам нужно удалить
файл в конце теста.
func TestClientUploadImage(t *testing.T) {
// ...
res, err := stream.CloseAndRecv()
require.NoError(t, err)
require.NotZero(t, res.GetId())
require.EqualValues(t, size, res.GetSize())
savedImagePath := fmt.Sprintf("%s/%s%s", testImageFolder, res.GetId(), imageType)
require.FileExists(t, savedImagePath)
require.NoError(t, os.Remove(savedImagePath))
}
Итак, давайте запустим его. Тест пройден! Давайте запустим все тесты в пакете.
make test
Превосходно! Все тесты успешно пройдены!
На этом закончим сегодняшнюю лекцию о клиентском потоковом RPC. На следующей лекции мы узнаем как реализовать его на Java. Спасибо за потраченное на чтение время и до новых встреч!