본문으로 건너뛰기

중첩 필드

문서 목록으로

컬럼 여러 개를 레코드 하나, 또는 레코드의 배열로 접는 기능의 설계입니다. 왜 필요한지는 그 레이아웃 분석에 있습니다.


1. 문제

지금 시트에서 배열을 만드는 방법은 둘이고, 둘 다 원소가 스칼라입니다.

방법시트나오는 것
serial fieldText1 Text2string[], 길이 = 컬럼 수
배열 타입Costs : int[]int[], 길이 = 로우마다

원소가 여러 값을 묶은 것일 때 적을 방법이 없습니다. 실제 데이터에서 흔한 형태인데도 Slot1Id Slot1Count Slot2Id Slot2Count처럼 평평하게 펼쳐 적고, 읽는 쪽에서 다시 묶는 수밖에 없습니다. 생성된 코드가 시트의 의도와 다른 형태가 됩니다.

2. 표기 — Group.Member

결정. 멤버 이름을 .으로 붙이고, 배열은 기존 연번 규칙을 그대로 씁니다.

시트의 컬럼 이름나오는 것
Pos.X Pos.YPos — 레코드 하나. record.Pos.X
Slot1.Id Slot1.Count Slot2.Id Slot2.CountSlot — 레코드의 배열, 길이 2. record.Slot[0].Id
Slot1 Slot2스칼라 배열 (지금 그대로)

타입·주석·target-side는 멤버 컬럼마다 따로 적습니다. 멤버는 서로 다른 타입일 수 있고, 그것이 레코드를 쓰는 이유입니다.

왜 이 표기인가.

  • 연번 규칙을 새로 만들지 않습니다. Text1/Text2를 이미 쓰는 사람이 Slot1.Id/Slot2.Id를 적게 됩니다. 배열 길이를 컬럼 수가 정하는 것도, 그룹의 컬럼이 시트에서 붙어 있지 않아도 되는 것도 기존 폴딩의 성질을 그대로 물려받습니다.
  • .은 지금 비어 있는 문법입니다. 필드 이름은 RequiresIdentifier를 지나므로 .이 든 이름은 현재 오류입니다. 그래서 기존 시트와 모호해질 여지가 없습니다 — 새 뜻을 얻는 것이 아니라 오류였던 것이 뜻을 갖습니다.
  • 다른 레이아웃의 표기도 이 모델로 번역됩니다. character[0]["Id"]Character1.Id와 같은 것을 가리키므로, 그 레이아웃의 파서는 두 번째 모델이 아니라 번역입니다. 모델이 하나면 두 레이아웃이 「이 컬럼이 무엇인가」에 다른 결과를 낼 수 없습니다.

고르지 않은 것.

대안왜 아닌가
character[0]["Id"] (다른 레이아웃의 표기)엑셀 헤더에 따옴표를 넣는 것이 편집에 불편하고, 인덱스 표기가 연번 규칙과 이중이 됩니다. 우리 레이아웃에는 이미 연번이 있습니다
Slot[0].Id.만으로 충분한데 대괄호를 더합니다. 연번과 대괄호 둘 중 하나만 쓰는 게 낫고, 기존 규칙과 이어지는 쪽은 연번입니다
Slot1_Id (밑줄)_는 이름 정규화에서 지워집니다Slot1_IdSlot1Id가 같은 이름이 되므로 구분이 성립하지 않습니다

3. 모델 — SerialField 원소 타입의 확장

익스포터와 모든 생성기가 보는 단위는 Field가 아니라 SerialField — 「컬럼들을 배열 하나로 접은 그룹」입니다. 중첩 필드는 새 개념이 아니라 그 그룹의 원소가 스칼라에서 레코드로 넓어진 것입니다.

SerialField
Name "Slot"
Kind Scalar | Record ← 신규
Members [ { Name="Id", Fields=[Slot1.Id, Slot2.Id] }, ← 신규. Kind=Record일 때만
{ Name="Count", Fields=[Slot1.Count, Slot2.Count] } ]
Fields (Kind=Scalar일 때 지금 그대로)

멤버 하나가 원소 인덱스 순서의 Field 목록을 들고, 그 목록의 길이가 배열 길이입니다. Pos.X처럼 연번이 없으면 길이 1이고 배열이 아닌 레코드 하나로 냅니다.

4. 와이어 — 변경 없음

컬럼 지향 형식이므로 레코드의 배열은 「멤버마다 고정 배열 컬럼 하나」입니다.

Slot1.Id Slot2.Id → 컬럼 Slot.Id : KindFixedArray, 길이 2
Slot1.Cnt Slot2.Cnt → 컬럼 Slot.Count : KindFixedArray, 길이 2

API는 AoS, 파일은 SoA입니다. 얻는 것이 셋입니다.

  • KindFixedArray가 이미 그것이라 형식에 새 kind가 없고, 버전을 올리지 않습니다. 스펙과 스큐 게이트가 그대로입니다.
  • 멤버마다 별도 컬럼이라 컬럼 인코딩이 멤버 단위로 걸립니다. 같은 값이 이어지는 멤버는 런렝스가, 값 종류가 적은 멤버는 딕셔너리가 그대로 듣습니다. 레코드를 한 덩어리로 저장하면 그 둘이 전부 무력해집니다.
  • 멤버 추가가 컬럼 태그 하나 늘는 가산적 변경입니다. 기존 리더는 모르는 태그를 건너뜁니다.

lib/의 리더 13개도 그대로입니다. 생성되는 읽기 루프는 컬럼을 태그로 분기해 record.<필드>[j]에 대입하는 형태이고, 중첩은 대입 대상이 record.<그룹>[j].<멤버>가 되는 것이 전부입니다 — 디코드 원시연산은 건드리지 않습니다.

실제 파일에서 확인된 것

nested 픽스처의 Loadout 테이블(3행)이 만든 .tcb 183바이트의 디스크립터입니다. 그룹 6개가 와이어 컬럼 8개로 나갔습니다.

태그컬럼elementkindcount바이트
1indexi32scalar13
2Namestringscalar119
3Pos.Xf32scalar112
4Pos.Yf32scalar112
5Slot.Idi32fixedArray224
6Slot.LabelstringfixedArray225
7Notestringscalar17
8TagstringfixedArray210

읽을 것이 셋입니다.

  • Slot이 컬럼 둘입니다Slot.Idi32 고정 배열(길이 2), Slot.Labelstring 고정 배열(길이 2). 레코드 하나를 한 덩어리로 만든 것이 아니라 멤버별로 갈랐습니다.
  • 원소가 하나인 레코드는 스칼라입니다Pos.X/Pos.Ycount 1의 scalar입니다. KindFixedArray로 만들지 않는 것이 맞습니다. 배열이 아닌 것을 배열로 적으면 리더가 배열을 만듭니다.
  • version이 그대로 102이고 새 kind가 없습니다. 옆의 Tag(기존 serial field)와 Slot.Id같은 fixedArray입니다 — 리더 입장에서 구분할 것이 없다는 뜻이고, 그래서 lib/의 13개를 건드리지 않습니다.

이 바이트는 골든에 들어가 있습니다. 그리고 기존 .tcb 골든은 한 바이트도 바뀌지 않았습니다 — 와이어 컬럼 단위로 리팩터링한 것이 레코드 없는 테이블의 출력을 보존하였다는 확인입니다.

태그는 멤버마다 하나

「멤버가 컬럼」의 직접적인 결과입니다. serial field는 컬럼이 몇 개든 와이어 컬럼 하나이므로 태그가 첫 멤버에만 붙는데, 레코드 그룹은 와이어 컬럼이 멤버 수만큼이므로 멤버마다 하나입니다.

Text1 Text2 → 와이어 컬럼 1개, 태그 1개
Slot1.Id Slot1.Label
Slot2.Id Slot2.Label → 와이어 컬럼 2개(Slot.Id, Slot.Label), 태그 2개

@N을 직접 다는 경우 Slot1.Id@7 Slot1.Label@8처럼 각 멤버의 첫 원소에 답니다. 같은 멤버의 나머지 원소(Slot2.Id)에 달면 오류입니다 — serial field의 두 번째 컬럼에 다는 것과 같은 이유입니다.

그래서 태그를 배정하는 단위는 「그룹」이 아니라 「와이어 컬럼」입니다. 순서 모드에서도 위치가 와이어 컬럼 순서로 매겨지므로, 멤버를 하나 추가하면 그 뒤 태그가 밀립니다@N을 달지 않은 테이블에 「끝에 추가하는 것만 안전하다」는 기존 규칙이 그대로 적용됩니다.

그리고 이 단위를 WireColumn 한 곳에만 두었습니다. 와이어 컬럼이 무엇인지 판단하는 곳이 셋(writer · 태그 배정 · 베이스라인 검사)이면 셋이 어긋납니다 — 실제로 어긋났습니다. 태그 배정이 「그룹당 태그 하나」를 가정하고 있었고, 그것은 레코드가 없던 모든 테이블에 대해 맞는 가정이라 드러날 이유가 없었습니다. 이제 3개 다 Table.WireColumns를 읽습니다.

베이스라인 검사가 특히 그렇습니다. 그쪽은 태그를 키로 컬럼을 기록하므로, 그룹 단위로 읽으면 레코드 그룹 전체가 첫 멤버의 태그 하나로 기록되고 나머지 멤버는 「사라진 컬럼」으로 보고됩니다.

5. 깊이의 제약과 남은 제약

깊이는 제한하지 않습니다. 멤버는 스칼라이거나 다시 그룹이고, 컬럼 이름이 적은 만큼 들어갑니다 — Star1.Position.X가 그것입니다. 폴딩이 경로를 따라 내려가므로 레벨 수를 세는 곳이 없고, RecordMember.IsLeaf가 「여기가 컬럼인가」를 나타냅니다.

이 선은 한 번 다른 자리에 있었습니다. 처음에는 깊이 1까지였고 근거는 실측이었습니다 — 615개 테이블에서 깊이 1의 세 형태가 리프의 99.5%였기 때문입니다. 그 판단이 뒤집힌 이유는 비율이 바뀐 것이 아니라, 깊이가 모델의 상한일 이유가 없다는 쪽이 맞았기 때문입니다. 다중 중첩에 그 경과가 있습니다.

남은 제약은 깊이가 아니라 아래 셋입니다.

무엇제약
번호가 붙는 레벨한 그룹에 하나. 예외는 안쪽에 이름이 없는 경우(Grid1.2)뿐입니다리프 하나가 와이어 컬럼 하나이고, 원소 수는 그 한 레벨이 정합니다. 두 레벨이 번호를 가지면 조합마다 컬럼이 생기는데, 형식은 표현할 수 있지만 아직 생성기가 없습니다
자르기·옵셔널최상위 배열만presence와 길이는 그룹 하나에 대한 것입니다. 깊은 레벨마다 두려면 형식이 바뀝니다 (로드맵)
깊은 그룹의 원소 번호최상위 레벨에만. Pos.Sub.X1처럼 안쪽 레벨에 번호가 붙으면서 중첩하는 것은 거부합니다와이어는 담을 수 있지만 배열이 레코드 아래로 들어가 생성기 13개의 경우가 곱해집니다. 실측에 없는 형태라 일부 언어에서만 되는 것보다 그 이름과 함께 거부하는 편이 낫습니다
코드 생성모든 언어 + json·binary. 깊이 제한이 없습니다타깃마다 SupportsDeepNestedFields로 나타냅니다. 14번째 타깃은 이 플래그를 먼저 만나고, 지원하기 전에는 자기 이름과 함께 거부합니다

6. 생성기 — 선언은 그룹 단위, 읽기는 와이어 컬럼 단위

모든 생성기가 전부 이 형태입니다. C#의 뷰를 읽고 확정하였고, 나머지 12개가 그것을 따랐습니다.

지금 생성기의 필드 뷰는 와이어 컬럼 하나를 전제하고 있습니다 — Tag · ColumnCheck · CursorOpen · ElementRead · RunCall을 각각 하나씩 듭니다. 레코드 그룹은 와이어 컬럼이 멤버 수만큼이므로 그 전제가 깨집니다.

해법은 뷰를 둘로 가르는 것입니다.

무엇단위들고 있는 것
필드 뷰그룹프로퍼티 이름, 타입(또는 생성될 레코드 타입 이름), 선언 형태, 원소 개수, 참조 정보
컬럼 뷰와이어 컬럼Tag · ColumnCheck · CursorOpen · ElementRead · RunCall · RunRead

템플릿에서는 선언부가 필드 뷰를 돌고, 읽기 switch가 컬럼 뷰를 돕니다. 지금은 둘 다 같은 목록을 도는데, 그것이 「그룹 = 컬럼」이라는 가정이 템플릿에 고정되어 있는 자리입니다. 가르면 레코드 지원이 분기 추가가 아니라 목록 교체가 됩니다.

읽기 대상 표현식만 달라집니다. 지금은 record.<필드> 또는 record.<필드>[j]이고, 레코드 멤버는 record.<그룹>[j].<멤버>입니다 — ElementReadLines가 이미 target 문자열을 받아 쓰는 구조라 그 문자열을 만드는 곳만 바뀝니다.

단계를 둘로 나누는 것이 안전합니다.

  1. 읽기를 와이어 컬럼 단위로 재구성 — 레코드 없는 테이블에서는 동작이 같습니다(그룹 하나 = 컬럼 하나). 골든 무변경이 합격 기준입니다. 바이너리 writer를 이 순서로 작성하였고 실제로 그렇게 산출되었습니다.
  2. 그 위에 레코드 지원 — 원소 타입 선언 + record.<그룹>[j].<멤버> 대입.

TypeScript만 다른 점

다음 언어로 TypeScript를 고른 이유는 왕복 테스트입니다 — 같은 테이블을 JSON과 바이너리 양쪽에서 읽어 필드 단위로 비교하므로, 중첩의 JSON 형태와 바이너리 레이아웃이 서로 맞는지를 다른 언어로는 못 하는 방식으로 확인해 줍니다.

대신 JSON을 읽는 유일한 언어라서 경로가 둘 더 있습니다.

경로레코드에서 필요한 것
named 행this._slot = dataRow.slot.map(e => ({ id: e.id, label: e.label })). 레코드 하나면 map 없이
compact 행멤버마다 slice로 N개를 집고, 그것들을 zip해서 객체 배열을 만듭니다

compact 경로는 컬럼 순서 수정이 선행 조건이었습니다. 그전에는 멤버의 항목이 행에 흩어져 있어서 slice로 집을 수가 없었습니다. 지금은 와이어 컬럼 순서라 멤버마다 붙어 있습니다.

const _slot_id = dataRow.slice(offset, offset + 2); offset += 2
const _slot_label = dataRow.slice(offset, offset + 2); offset += 2
this._slot = _slot_id.map((v, k) => ({ id: v, label: _slot_label[k] }))

바이너리 switch는 C#과 같습니다 — fields 대신 columns를 돌고, 멤버 컬럼은 배열을 새로 만들지 않고 record._slot[j].id에 대입합니다. TS의 템플릿은 read_kind가 따로 없고 field.kind로 분기하므로, 컬럼 뷰에 kind를 두는 것이 C#과 유일하게 다른 점입니다.

7. 단계 — 언어를 하나씩

13개 템플릿을 한 번에 고치지 않았습니다. 중첩을 아직 모르는 타깃은 그 사실을 적는 오류를 내게 하고, 언어를 하나씩 늘렸습니다.

Target `html` does not support nested fields yet.
Table `Item` field `Slot` is a record group.
at sheets/data.xlsx : Items : F3

조용히 평평하게 펼치는 것보다 낫습니다 — 그러면 13개 산출물 중 하나만 다른 형태가 되고, 어느 것이 맞는지 아무도 모릅니다.

전부가 전부 지원하므로 이 오류를 내는 타깃은 이제 없습니다. 검사는 ITarget에 남아 있고, 14번째 타깃이 지원하기 전에 만나는 것이 그것입니다.

단계내용상태
1이름 파싱 (Group.Member → 그룹·멤버·원소 서수). 순수 함수, 단위 테스트
2모델 (SerialField.Kind·Members) 과 폴딩, 와이어 컬럼 단위 태그 배정
3nested 픽스처 + JSON 익스포터 + 골든. 모르는 타깃은 그 이름과 함께 거부
4바이너리 writer — WireColumn을 1급 개념으로. 위의 디스크립터가 결과
5C# 생성기 + 템플릿(§6의 구조로). 시트 → .tcb → 생성 코드로 되읽기까지 확인
6aTypeScript. JSON·바이너리 두 경로 왕복 확인
6b나머지 언어. 하나씩, 적합성 코퍼스에 형태 추가 — 전부
7HTML·DB 익스포터, 쇼케이스 재생성
8다른 레이아웃 파서에서 이 모델로 번역

5단계에서 생성되는 것과, 그것이 실제로 읽어낸 값입니다.

public struct SlotEntry { public int Id; public string Label; }

public SlotEntry[] Slot => _slot;
internal const int Slot_N = 2; // 공개하지 않습니다 — 아래 참조

internal SlotEntry[] _slot = NewSlotEntryArray(); // 원소별 할당 없음
case 5: // Loadout.Slot.Id — 멤버 하나가 컬럼 하나
for (int i = 0; i < count; i++) {
var record = records[i];
for (int j = 0; j < Record.Slot_N; ++j)
record._slot[j].Id = reader.ReadI32As(column.Element);
}
pos = {"X":1.5,"Y":-2.5}
slot = [{"Id":10,"Label":"sword"}, {"Id":11,"Label":"shield"}]

JSON 익스포트와 값이 같습니다. 그리고 이 코드는 C# 8 · netstandard2.1로 경고 없이 컴파일됩니다 — 유니티 2020.3이 받는 수준입니다.

길이 상수는 공개 API가 아닙니다. 여기 적히는 수는 이 코드를 생성한 시점의 시트가 가졌던 것이고, 소비하는 쪽이 그것을 붙들면 데이터가 더 이상 동의하지 않아도 되는 수를 붙드는 것이 됩니다 — 배열의 Length가 답이고, 그것은 낡지 않습니다. 레코드 그룹에서 상수가 남는 것은 컬럼 여럿이 배열 하나를 채우기 때문입니다. 어느 컬럼도 그 배열을 혼자 갖지 않으므로 길이는 컬럼들이 합의한 수이고, 그것은 생성된 형태의 일부입니다. 컬럼 하나가 배열 하나를 갖는 형태 — Tag1·Tag2를 접은 스칼라 배열 — 에서는 상수가 아예 없고, 길이를 그 컬럼의 디스크립터에서 읽습니다 (nullable 배열 원소).

원소 타입을 struct로 한 이유가 둘입니다. 배열이 원소별 할당을 하지 않고 (21,000행 × 17원소 테이블이면 357,000개 객체를 만들지 않는다는 뜻입니다), array[j].Member = x가 구조체 배열에서 합법이라 멤버 컬럼의 읽기가 그대로 대입이 됩니다.

대가가 하나 있었습니다 — 구조체는 필드 초기화자를 쓸 수 없습니다(C# 10 기능이고 명시적 무인자 생성자가 필요합니다). 그런데 문자열 멤버는 null이 아니라 ""로 시작해야 합니다. 그 멤버가 생기기 전에 쓰인 파일에는 컬럼이 없어서 아무도 그 필드를 쓰지 않고, null은 한 필드 뒤에서 터지기 때문입니다. 그래서 정적 팩토리가 채웁니다.

3단계까지의 결과로 표기가 실제로 동작하는 것을 JSON에서 확인할 수 있습니다.

{ "index": 1, "name": "first",
"pos": { "x": 1.5, "y": -2.5 },
"slot": [ { "id": 10, "label": "sword" }, { "id": 11, "label": "shield" } ],
"note": "n1",
"tagArray": [ "a", "b" ] }

slot의 멤버가 intstring으로 서로 다른 것이 요점입니다 — 배열로는 표현할 수 없고, 그래서 레코드가 필요합니다. 옆의 tagArray는 기존 serial field가 그대로 접힌 것입니다.

JSON의 compact 형식은 중첩 없이 남습니다. 컬럼 위치로 인덱싱하는 형식이고 레코드의 멤버는 보통 컬럼이므로, 그것이 compact의 정의에 맞습니다. 두 형식 다 골든이 확인하고 있습니다.