다음 예시는 자산 관리 시스템으로 Nakama를 사용하는 방법에 대한 설명을 통해 클라이언트가 클라우드 호스트(Amazon S3) 자산에 임시로 액세스할 수 있도록 합니다.
이 방법은 여러 상황에서 매우 유용합니다. 예를 들어, 개인적인 Amazon S3 버킷에서 게임 자산을 호스트하도록 선택하고 클라이언트가 런타임 시 안전하게 자산을 다운로드하도록 할 수 있습니다.
자산 관리 시스템을 사용하는 다양한 이점은 다음과 같습니다:
자산은 개인적으로 유지되며 수요 기반의 시간 제한적 방식으로 제공됩니다.
클라이언트에 다양한 자산(예: 베타 액세스 자산) 제공
자산 목록 보기 등 클라우드 저장소 제공자에서 왕복 작업을 절감함
사용자가 컨텐츠를 생성하고 자산에 대해 유효성 검사를 진행할 수 있음
이 시스템에는 크게 3개의 부분이 있습니다:
Nakama 저장소 엔진을 통해 분류되고 저장되는 자산 매니페스트
바이너리 데이터를 얻어서 클라우드에 업로드하고 해당 자산 매니페스트를 저장하는 자산 업로드 RPC
요청된 자산에 사전 서명된 임시 다운로드 URL을 생성하는 자산 다운로드 RPC
이 시스템은 클라이언트 중심적으로 설계되었지만, 이 가이드의 마지막 부분에서는 안전한 자산 전달 시스템을 실행하여 개인적인 S3 버킷에서 Unity Addressable 자산으로 직접 런타임 액세스를 제공하는 방법에 대해서 살펴봅니다.
funcInitModule(ctxcontext.Context,loggerruntime.Logger,db*sql.DB,nkruntime.NakamaModule,initializerruntime.Initializer)error{initStart:=time.Now()env:=ctx.Value(runtime.RUNTIME_CTX_ENV).(map[string]string)// Ensure AWS environment vars have been configured
awsAccessKeyID,ok:=env["AWS_ACCESS_KEY_ID"]if!ok{returnerrors.New("missing AWS_ACCESS_KEY_ID in runtime env")}awsSecretAccessKey,ok:=env["AWS_SECRET_ACCESS_KEY"]if!ok{returnerrors.New("missing AWS_SECRET_ACCESS_KEY in runtime env")}awsRegion,ok:=env["AWS_REGION"]if!ok{returnerrors.New("missing AWS_REGION in runtime env")}awsBucket,ok:=env["AWS_BUCKET"]if!ok{returnerrors.New("missing AWS_BUCKET in runtime env")}// Create protobuf marshalers
marshaler:=&jsonpb.Marshaler{EnumsAsInts:true,}unmarshaler:=&jsonpb.Unmarshaler{AllowUnknownFields:false,}// Create an S3 service object and uploader
sess:=session.Must(session.NewSession(&aws.Config{Region:aws.String(awsRegion),Credentials:credentials.NewStaticCredentials(awsAccessKeyID,awsSecretAccessKey,""),}))svc:=s3.New(sess)uploader:=s3manager.NewUploader(sess)// Register RPCs here...
logger.Info("Plugin loaded in '%d' msec.",time.Since(initStart).Milliseconds())returnnil}
funcUploadAssetToS3(marshaler*jsonpb.Marshaler,unmarshaler*jsonpb.Unmarshaler,uploader*s3manager.Uploader,bucketNamestring)func(ctxcontext.Context,loggerruntime.Logger,db*sql.DB,nkruntime.NakamaModule,payloadstring)(string,error){returnfunc(ctxcontext.Context,loggerruntime.Logger,db*sql.DB,nkruntime.NakamaModule,payloadstring)(string,error){// Check if s3 storage client is configured or return error
ifuploader==nil{return"",errors.New("s3 uploader not configured")}iflen(bucketName)==0{return"",errors.New("no s3 bucket name provided")}// Check user that's making the request
userID,ok:=ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string)if!ok{return"",errors.New("user ID not found")}// Unmarshal the upload request input
request:=&api.RpcAssetUploadRequest{}logger.Info("got payload: %q",payload)iferr:=unmarshaler.Unmarshal(bytes.NewReader([]byte(payload)),request);err!=nil{logger.WithField("error",err).Error("error unmarshalling RpcAssetUploadRequest")return"",errors.New("error unmarshalling RpcAssetUploadRequest")}// Check that the input fields are valid
ifrequest.Category==""{return"",errors.New("asset Category required")}ifrequest.Name==""{return"",errors.New("asset Name required")}iflen(request.Data)==0{return"",errors.New("asset Data required")}// Upload the asset to s3 bucket
result,err:=uploader.Upload(&s3manager.UploadInput{Bucket:aws.String(bucketName),Key:aws.String(request.Name),Body:bytes.NewReader(request.Data),})iferr!=nil{logger.WithField("error",err).Error("failed to upload file")return"",errors.New("failed to upload file")}// Prepare the asset manifest
md5:=strings.ReplaceAll(*result.ETag,"\"","")manifest:=&api.AssetManifest{Category:request.Category,Name:request.Name,Owner:userID,Md5:md5,CreatedAt:time.Now().Unix(),}buf:=&bytes.Buffer{}iferr:=marshaler.Marshal(buf,manifest);err!=nil{logger.WithField("error",err).Error("error encoding asset manifest")return"",errors.New("error encoding asset manifest")}// Write the asset manifest to nakama storage
if_,err:=nk.StorageWrite(ctx,[]*runtime.StorageWrite{{Collection:fmt.Sprintf("asset_%s",request.Category),Key:request.Name,UserID:"",// Will be owned by the system user
Value:buf.String(),PermissionRead:2,// Clients can read directly
PermissionWrite:0,// Only server can write
}});err!=nil{logger.WithField("error",err).Error("error writing asset manifest")return"",errors.New("error writing asset manifest")}// Prepare response to the client
response:=&api.RpcAssetUploadResponse{Manifest:manifest,}buf=&bytes.Buffer{}iferr:=marshaler.Marshal(buf,response);err!=nil{logger.WithField("error",err).Error("error encoding response")return"",errors.New("error encoding response")}returnbuf.String(),nil}}
funcGetAssetDownloadUri(marshaler*jsonpb.Marshaler,unmarshaler*jsonpb.Unmarshaler,svc*s3.S3,bucketNamestring)func(ctxcontext.Context,loggerruntime.Logger,db*sql.DB,nkruntime.NakamaModule,payloadstring)(string,error){returnfunc(ctxcontext.Context,loggerruntime.Logger,db*sql.DB,nkruntime.NakamaModule,payloadstring)(string,error){// Check user that's making the request
_,ok:=ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string)if!ok{// Would happen if calling this RPC server-to-server. See: https://heroiclabs.com/docs/nakama/server-framework/introduction/#server-to-server
// We're choosing not to allow this here, downloads are expected to be done by a user.
return"",errors.New("user ID not found")}// Unmarshal the input
request:=&api.RpcAssetDownloadRequest{}iferr:=unmarshaler.Unmarshal(bytes.NewReader([]byte(payload)),request);err!=nil{logger.WithField("error",err).Error("error unmarshalling request")return"",errors.New("error unmarshalling request")}// Check input fields are valid
ifrequest.Category==""{return"",errors.New("asset Category required")}ifrequest.Name==""{return"",errors.New("asset Name required")}// Look up the asset in the storage manifest
objects,err:=nk.StorageRead(ctx,[]*runtime.StorageRead{{Collection:fmt.Sprintf("asset_%s",request.Category),Key:request.Name,UserID:"",// Owned by the system user
}})iferr!=nil{logger.WithField("error",err).Error("error reading asset manifest")return"",errors.New("error reading asset manifest")}// Check if the object exists
iflen(objects)==0{return"",errors.New("asset not found")}// Get the asset manifest
manifest:=&api.AssetManifest{}iferr:=unmarshaler.Unmarshal(bytes.NewReader([]byte(objects[0].Value)),manifest);err!=nil{logger.WithField("error",err).Error("error unmarshaling manifest")return"",errors.New("error unmarshaling manifest")}// Prepare a short-lived asset download link
assetDownload,err:=getAssetDownloadUri(request.Name,svc,bucketName)iferr!=nil{logger.WithField("error",err).Error(err.Error())return"",err}// Prepare response to client
buf:=&bytes.Buffer{}iferr:=marshaler.Marshal(buf,assetDownload);err!=nil{logger.WithField("error",err).Error("error encoding response")return"",errors.New("error encoding response")}logger.Info("Responding with:\n%q",buf.String())returnbuf.String(),nil}}funcgetAssetDownloadUri(keystring,svc*s3.S3,bucketNamestring)(*api.RpcAssetDownloadResponse,error){// Prepare a short-lived asset download link
req,_:=svc.GetObjectRequest(&s3.GetObjectInput{Bucket:aws.String(bucketName),Key:aws.String(key),})uri,err:=req.Presign(24*time.Hour)iferr!=nil{returnnil,errors.New("error signing request")}response:=&api.RpcAssetDownloadResponse{Name:key,DownloadUri:uri,}returnresponse,nil}
funcGetAllAssetDownloadUris(marshaler*jsonpb.Marshaler,unmarshaler*jsonpb.Unmarshaler,svc*s3.S3,bucketNamestring)func(ctxcontext.Context,loggerruntime.Logger,db*sql.DB,nkruntime.NakamaModule,payloadstring)(string,error){returnfunc(ctxcontext.Context,loggerruntime.Logger,db*sql.DB,nkruntime.NakamaModule,payloadstring)(string,error){// Check user that's making the request
_,ok:=ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string)if!ok{// Would happen if calling this RPC server-to-server. See: https://heroiclabs.com/docs/nakama/server-framework/introduction/#server-to-server
// We're choosing not to allow this here, downloads are expected to be done by a user.
return"",errors.New("user ID not found")}// Unmarshal the input
request:=&api.RpcAssetDownloadRequest{}iferr:=unmarshaler.Unmarshal(bytes.NewReader([]byte(payload)),request);err!=nil{logger.WithField("error",err).Error("error unmarshalling request")return"",errors.New("error unmarshalling request")}// Check input fields are valid
ifrequest.Category==""{return"",errors.New("asset Category required")}// Look up the asset in the storage manifest
objects,_,err:=nk.StorageList(ctx,"",fmt.Sprintf("asset_%s",request.Category),100,"")iferr!=nil{logger.WithField("error",err).Error("error reading asset manifest")return"",errors.New("error reading asset manifest")}// Check if any objects exist
iflen(objects)==0{return"",errors.New("no assets found")}// Create a response object
response:=&api.RpcAllAssetsDownloadResponse{}// Loop through all assets and get a download uri
for_,object:=rangeobjects{assetDownload,err:=getAssetDownloadUri(object.Key,svc,bucketName)iferr!=nil{logger.WithField("error",err).Error(err.Error())return"",err}response.Assets=append(response.Assets,assetDownload)}// Marshal the response
buf:=&bytes.Buffer{}iferr:=marshaler.Marshal(buf,response);err!=nil{logger.WithField("error",err).Error("error encoding response")return"",errors.New("error encoding response")}returnbuf.String(),nil}}
위의 자산 관리 시스템은 Unity의 Addressable 기능과 같은 원격 자산 전달 파이프라인과 통합될 수 있습니다.
Unity Addressable를 사용하는 경우, 자산을 패키지로 구성하고, 원격으로 호스트하여 런타임 시 해당 자산을 로드할 수 있습니다.
Unity Addressable은 Amazon S3, Google 클라우드 파일 저장소 또는 디지털 오션 객체 저장소 등 공용 클라우드 저장소를 사용합니다. 아쉽게도, 기본으로 제공되는 개인 클라우드 저장소를 지원하지 않습니다. 하지만, 몇 단계 과정을 통해 Nakama 자산 관리 시스템을 통합할 수 있습니다.
먼저, 아래에 보이는 것처럼 Addressable 자산 구성을 원격 경로에서 Build and Loan으로 설정합니다. 원격 카탈로그 빌드 옵션을 선택합니다.
Addressable을 Build and Loan으로 원격 구성하기
Addressable 프로필 창에서 Addressable 자산의 정확한 원격 로드 경로를 구성할 수 있습니다. 런타임 시 나중에 추가되는 코드로 대체할 것이기 때문에 http(s)으로 시작되는 모든 주소를 입력할 수 있습니다.
Addressables 원격 로드 경로 구성하기
Addressable 시스템이 구성된 상태에서 Addressable 그룹 창에서 Build 버튼을 클릭하여 Addressable 자산 번들을 빌드합니다. 자산을 처음으로 빌드하는 경우, 새 빌드를 선택합니다. 그렇지 않을 경우, 이전 빌드 업데이트를 선택합니다. (이렇게 하면 같은 카탈로그 파일을 사용하여 클라이언트를 업데이트하지 않아도 최신 자산을 다운로드할 수 있습니다.) 이 가이드에서 앞서 설명한 asset_upload RPC를 사용하여 Nakama 서버에 자산을 업로드할 수 있습니다. 업로드하는 방법은 사용자가 선택할 사항이지만, 자산 업로드를 실행하기 위해서 편집기 스크립트를 작성하는 것이 좋습니다. 예시는 아래에서 설명합니다.
privateasyncvoidStart(){// ...Addressables.ResourceManager.InternalIdTransformFunc+=TransformFunc;}privatestringTransformFunc(IResourceLocationlocation){// If we're trying to load a remote asset over http(s):// - grab the file name from the url path// - look up the pre-signed url and return that if available; otherwise just return the same location.InternalIdvarregex=newRegex("https?.+/([^?]+)");varmatch=regex.Match(location.InternalId);if(match.Success){varkey=match.Groups[1].Value;if(assetPresignedUris.ContainsKey(key)){returnassetPresignedUris[key];}}returnlocation.InternalId;}
마지막으로, 정상 작업과 마찬가지로 주소 지정 가능한 자산을 인스턴스화합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
privateasyncvoidStart(){// ...Addressables.InitializeAsync().Completed+=OnInitializeAsyncCompleted;}privatevoidOnInitializeAsyncCompleted(AsyncOperationHandle<IResourceLocator>obj){// e.g.// addressablePrefabReference.InstantiateAsync().Completed += go =>// {// Instantiate(go.Result);// };}
이제 애플리케이션을 빌드하고 실행할 수 있으며, 개인적으로 호스트된 주소 지정 가능한 자산이 로드되었습니다.