자산 관리 #

다음 예시는 자산 관리 시스템으로 Nakama를 사용하는 방법에 대한 설명을 통해 클라이언트가 클라우드 호스트(Amazon S3) 자산에 임시로 액세스할 수 있도록 합니다. 이 방법은 여러 상황에서 매우 유용합니다. 예를 들어, 개인적인 Amazon S3 버킷에서 게임 자산을 호스트하도록 선택하고 클라이언트가 런타임 시 안전하게 자산을 다운로드하도록 할 수 있습니다.

자산 관리 시스템을 사용하는 다양한 이점은 다음과 같습니다:

  • 자산은 개인적으로 유지되며 수요 기반의 시간 제한적 방식으로 제공됩니다.
  • 클라이언트에 다양한 자산(예: 베타 액세스 자산) 제공
  • 자산 목록 보기 등 클라우드 저장소 제공자에서 왕복 작업을 절감함
  • 사용자가 컨텐츠를 생성하고 자산에 대해 유효성 검사를 진행할 수 있음

이 시스템에는 크게 3개의 부분이 있습니다:

  • Nakama 저장소 엔진을 통해 분류되고 저장되는 자산 매니페스트
  • 바이너리 데이터를 얻어서 클라우드에 업로드하고 해당 자산 매니페스트를 저장하는 자산 업로드 RPC
  • 요청된 자산에 사전 서명된 임시 다운로드 URL을 생성하는 자산 다운로드 RPC

이 시스템은 클라이언트 중심적으로 설계되었지만, 이 가이드의 마지막 부분에서는 안전한 자산 전달 시스템을 실행하여 개인적인 S3 버킷에서 Unity Addressable 자산으로 직접 런타임 액세스를 제공하는 방법에 대해서 살펴봅니다.

자산 매니페스트 #

Nakama 저장소 엔진을 사용하여 업로드된 자산에 정보를 보관할 수 있는 자산 매니페스트를 저장할 수 있습니다.

  • 이름
  • 카테고리
  • 소유자
  • md5 체크섬
  • 생성된 타임스탬프

Nakama에서 자산 매니페스트를 저장하면 클라이언트가 클라우드 저장소 제공자에 왕복 작업 없이도 개별 레코드 또는 레코드 목록(예: 카테고리별)을 회수할 수 있습니다.

표준 구조를 사용하거나 아래의 프로토콜 버퍼와 특정 메시지 형식을 사용하여 자산 매니페스트와 관련 메시지를 정의할 수 있습니다:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
syntax = "proto3";

package api;

option go_package = "heroiclabs.com/nakama-amazon-s3/api";

message RpcAssetUploadRequest {
  string category = 1;
  string name = 2;
  bytes data = 3;
}

message RpcAssetUploadResponse {
  AssetManifest manifest = 1;
}

message RpcAssetDownloadRequest {
  string category = 1;
  string name = 2;
}

message RpcAssetDownloadResponse {
  string name = 1;
  string download_uri = 2;
}

message RpcAllAssetsDownloadResponse {
  repeated RpcAssetDownloadResponse assets = 1;
}

message AssetManifest {
  string category = 1;
  string name = 2;
  string owner = 3;
  string md5 = 4;
  int64 created_at = 5;
}
프로토콜 버퍼를 사용하고 있는 경우, protoc 도구를 사용하여 .proto 파일에서 프로그래밍 언어에 맞는 파일을 생성해야 합니다. 자세한 내용은 프로토콜 버퍼에 대한 기본: Go 문서를 참조하십시오.

서버 런타임에 대한 부트스트랩 #

자산 업로드/다운로드 RPC를 실행하기 전에 프로토콜 버퍼 마샬과 S3 서비스 개체 및 업로더를 초기화해야 합니다.

runtime.env 섹션에 있는 local.yml 구성 파일을 통해 환경 변수를 Nakama로 전달할 수 있습니다. 자세한 내용은 구성 문서를 참조하십시오.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
func InitModule(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, initializer runtime.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 {
    return errors.New("missing AWS_ACCESS_KEY_ID in runtime env")
  }
  awsSecretAccessKey, ok := env["AWS_SECRET_ACCESS_KEY"]
  if !ok {
    return errors.New("missing AWS_SECRET_ACCESS_KEY in runtime env")
  }
  awsRegion, ok := env["AWS_REGION"]
  if !ok {
    return errors.New("missing AWS_REGION in runtime env")
  }
  awsBucket, ok := env["AWS_BUCKET"]
  if !ok {
    return errors.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())
  return nil
}

자산 업로드 RPC #

자산 매니페스트가 정의된 상태에서 RPC 기능을 실행하여 바이너리 데이터를 base64 문자열로 인코딩하고, 자산 매니페스트를 생성하고 저장하며, 바이너리 파일을 개인 Amazon S3 버킷에 업로드합니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
func UploadAssetToS3(marshaler *jsonpb.Marshaler, unmarshaler *jsonpb.Unmarshaler, uploader *s3manager.Uploader, bucketName string) func(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
  return func(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
    // Check if s3 storage client is configured or return error
    if uploader == nil {
      return "", errors.New("s3 uploader not configured")
    }

    if len(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)

    if err := 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
    if request.Category == "" {
      return "", errors.New("asset Category required")
    }

    if request.Name == "" {
      return "", errors.New("asset Name required")
    }

    if len(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),
    })
    if err != 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{}
    if err := 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{}
    if err := marshaler.Marshal(buf, response); err != nil {
      logger.WithField("error", err).Error("error encoding response")
      return "", errors.New("error encoding response")
    }

    return buf.String(), nil
  }
}

그런 다음 InitModule 기능에 RPC를 등록해야 합니다.

1
2
3
if err := initializer.RegisterRpc("upload_asset", UploadAssetToS3(marshaler, unmarshaler, uploader, awsBucket)); err != nil {
  return err
}

자산 다운로드 RPC #

자산 다운로드 RPC는 요청된 파일을 기존의 자산 매니페스트에서 확인합니다. 파일을 찾은 후에 Amazon S3에 전달되고 클라이언트로 전달되는 자산에 대해서 사전 서명된 임시 다운로드 URL을 생성합니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
func GetAssetDownloadUri(marshaler *jsonpb.Marshaler, unmarshaler *jsonpb.Unmarshaler, svc *s3.S3, bucketName string) func(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
  return func(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (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{}
    if err := 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
    if request.Category == "" {
      return "", errors.New("asset Category required")
    }

    if request.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
    }})

    if err != nil {
      logger.WithField("error", err).Error("error reading asset manifest")
      return "", errors.New("error reading asset manifest")
    }

    // Check if the object exists
    if len(objects) == 0 {
      return "", errors.New("asset not found")
    }

    // Get the asset manifest
    manifest := &api.AssetManifest{}
    if err := 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)
    if err != nil {
      logger.WithField("error", err).Error(err.Error())
      return "", err
    }

    // Prepare response to client
    buf := &bytes.Buffer{}
    if err := 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())

    return buf.String(), nil
  }
}

func getAssetDownloadUri(key string, svc *s3.S3, bucketName string) (*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)
  if err != nil {
    return nil, errors.New("error signing request")
  }

  response := &api.RpcAssetDownloadResponse{
    Name:        key,
    DownloadUri: uri,
  }

  return response, nil
}

그런 다음 InitModule 기능에 RPC를 등록해야 합니다.

1
2
3
if err := initializer.RegisterRpc("get_asset_download_uri", GetAssetDownloadUri(marshaler, unmarshaler, svc, awsBucket)); err != nil {
  return err
}

카테고리 RPC별 자산 다운로드 #

다운로드하려고 하는 자산 카테고리를 제공하여 자산 다운로드 URL 목록을 받는 것이 유용할 수 있습니다. 아래에서는 새로운 RPC를 통해 카테고리가 지정된 자산에 대해서 사전 서명된 다운로드 URL에 대한 목록을 생성합니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
func GetAllAssetDownloadUris(marshaler *jsonpb.Marshaler, unmarshaler *jsonpb.Unmarshaler, svc *s3.S3, bucketName string) func(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
  return func(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (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{}
    if err := 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
    if request.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, "")
    if err != nil {
      logger.WithField("error", err).Error("error reading asset manifest")
      return "", errors.New("error reading asset manifest")
    }

    // Check if any objects exist
    if len(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 := range objects {
      assetDownload, err := getAssetDownloadUri(object.Key, svc, bucketName)
      if err != nil {
        logger.WithField("error", err).Error(err.Error())
        return "", err
      }

      response.Assets = append(response.Assets, assetDownload)
    }

    // Marshal the response
    buf := &bytes.Buffer{}
    if err := marshaler.Marshal(buf, response); err != nil {
      logger.WithField("error", err).Error("error encoding response")
      return "", errors.New("error encoding response")
    }

    return buf.String(), nil
  }
}

그런 다음 InitModule 기능에 RPC를 등록해야 합니다.

1
2
3
if err := initializer.RegisterRpc("get_all_asset_download_uris_by_category", GetAllAssetDownloadUris(marshaler, unmarshaler, svc, awsBucket)); err != nil {
  return err
}

예: 자산 관리 시스템을 Unity Addressable과 통합하기 #

위의 자산 관리 시스템은 Unity의 Addressable 기능과 같은 원격 자산 전달 파이프라인과 통합될 수 있습니다. Unity Addressable를 사용하는 경우, 자산을 패키지로 구성하고, 원격으로 호스트하여 런타임 시 해당 자산을 로드할 수 있습니다.

Unity Addressable은 Amazon S3, Google 클라우드 파일 저장소 또는 디지털 오션 객체 저장소 등 공용 클라우드 저장소를 사용합니다. 아쉽게도, 기본으로 제공되는 개인 클라우드 저장소를 지원하지 않습니다. 하지만, 몇 단계 과정을 통해 Nakama 자산 관리 시스템을 통합할 수 있습니다.

먼저, 아래에 보이는 것처럼 Addressable 자산 구성을 원격 경로에서 Build and Loan으로 설정합니다. 원격 카탈로그 빌드 옵션을 선택합니다.

Addressable을 Build and Loan으로 원격 구성하기
Addressable을 Build and Loan으로 원격 구성하기

Addressable 프로필 창에서 Addressable 자산의 정확한 원격 로드 경로를 구성할 수 있습니다. 런타임 시 나중에 추가되는 코드로 대체할 것이기 때문에 http(s)으로 시작되는 모든 주소를 입력할 수 있습니다.

Addressables 원격 로드 경로 구성하기
Addressables 원격 로드 경로 구성하기

Addressable 시스템이 구성된 상태에서 Addressable 그룹 창에서 Build 버튼을 클릭하여 Addressable 자산 번들을 빌드합니다. 자산을 처음으로 빌드하는 경우, 새 빌드를 선택합니다. 그렇지 않을 경우, 이전 빌드 업데이트를 선택합니다. (이렇게 하면 같은 카탈로그 파일을 사용하여 클라이언트를 업데이트하지 않아도 최신 자산을 다운로드할 수 있습니다.) 이 가이드에서 앞서 설명한 asset_upload RPC를 사용하여 Nakama 서버에 자산을 업로드할 수 있습니다. 업로드하는 방법은 사용자가 선택할 사항이지만, 자산 업로드를 실행하기 위해서 편집기 스크립트를 작성하는 것이 좋습니다. 예시는 아래에서 설명합니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
var directoryInfo = new DirectoryInfo("<PathToAssetBundle>/StandaloneWindows64");
foreach (var fileInfo in directoryInfo.GetFiles())
{
  var fileBytes = UnityEngine.Windows.File.ReadAllBytes(fileInfo.FullName);
  var request = new RpcAssetUploadRequest
  {
    category = "StandaloneWindows64",
    name = fileInfo.Name,
    data = Convert.ToBase64String(fileBytes)
  };
  await Client.RpcAsync(Session, "upload_asset", JsonUtility.ToJson(request));
}

Unity를 사용하여 Nakama 자산 관리 시스템을 실행하려면 프로젝트에 다음의 클래스 정의를 추가합니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
[Serializable]
public class RpcAssetUploadRequest
{
  public string category;
  public string name;
  public string data;
}

[Serializable]
public class RpcAssetDownloadRequest
{
  public string category;
  public string name;
}

[Serializable]
public class RpcAssetDownloadResponse
{
  public string name;
  public string downloadUri;
}

[Serializable]
public class RpcGetAllAssetsDownloadResponse
{
  public RpcAssetDownloadResponse[] assets;
}

그런 다음, AddressablesManager이라고 하는 스크립트를 생성하고 Nakama에서 자산 번들뿐만 아니라 (현재 플랫폼과) 연관된 Unity 자산 카탈로그(해시/JSON)를 회수합니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
private IDictionary<string, string> assetPresignedUris = new Dictionary<string, string>();

private async void Start()
{
  await FetchPresignedAssets("StandaloneWindows64");
}

private async Task FetchPresignedAssets(string buildTarget)
{
  var payload = new RpcAssetDownloadRequest { category = buildTarget };
  var response = await Client.RpcAsync(Session, "get_all_asset_download_uris_by_category", JsonUtility.ToJson(payload));
  var result = JsonUtility.FromJson<RpcGetAllAssetsDownloadResponse>(response.Payload);

  foreach (var asset in result.assets)
  {
    assetPresignedUris.Add(asset.name, asset.downloadUri);
  }
}

다음으로, 내부 자산 ID로 사전 서명된 자산 다운로드 URL을 전환하는 Start 메서드에서 Addressable 시스템의 ResourceManager.InternalIdTransformFunc에 새로운 변환 함수를 지정합니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private async void Start()
{
  // ...
  Addressables.ResourceManager.InternalIdTransformFunc += TransformFunc;
}

private string TransformFunc(IResourceLocation location)
{
  // 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.InternalId
  var regex = new Regex("https?.+/([^?]+)");
  var match = regex.Match(location.InternalId);
  if (match.Success)
  {
    var key = match.Groups[1].Value;
    if (assetPresignedUris.ContainsKey(key))
    {
      return assetPresignedUris[key];
    }
  }

  return location.InternalId;
}

마지막으로, 정상 작업과 마찬가지로 주소 지정 가능한 자산을 인스턴스화합니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
private async void Start()
{
  // ...
  Addressables.InitializeAsync().Completed += OnInitializeAsyncCompleted;
}

private void OnInitializeAsyncCompleted(AsyncOperationHandle<IResourceLocator> obj)
{
  // e.g.
  // addressablePrefabReference.InstantiateAsync().Completed += go =>
  // {
  //  Instantiate(go.Result);
  // };
}

이제 애플리케이션을 빌드하고 실행할 수 있으며, 개인적으로 호스트된 주소 지정 가능한 자산이 로드되었습니다.

런타임 시 개인적으로 호스트된 주소 지정 가능한 자산
런타임 시 개인적으로 호스트된 주소 지정 가능한 자산