英文:
How can I use Golang structs to perform CRUD in an API when some fields are readonly and others are nullable?
问题
我正在编写一个用于执行基本的CRUD操作(结构体 <=> MySQL表)的API。这是一个映射到我的数据库表的示例结构体。我在字段中使用指针,以便我可以将nil
视为NULL/不存在:
type Foo struct {
Id *int32
Name *string
Description *string
CreateDate *string
}
Id
字段是一个自增字段,应该由数据库分配。Name
字段是可写的且必填的。Description
字段是可写的且可为空的。CreateDate
字段在插入时由MySQL分配,不应该被写入。
当用户POST一个新的Foo进行创建时,请求体在JSON中的格式如下:
POST /Foo
{"Name": "test name", "Description": "test description"}
可以很容易地解码并填充一个Foo结构体:
foo := Foo{}
json.NewDecoder(requestBody).Decode(&foo)
我正在使用https://github.com/coopernurse/gorp库来简化插入/更新/删除操作,但即使我使用反射在字段上进行通用的查询创建,我的问题仍然存在。
gorpDbMap.Insert(&foo)
我的第一个问题出现在用户提供一个只读字段时。如果这个请求体被POST,结构体会快乐地接受Id
,当我执行插入操作时,它会覆盖自增的值。我知道这在某种程度上是我的问题,因为我使用了ORM而不是手动编写SQL的insert
语句,但我希望在填充结构体时能够强制只解码那些可写字段,忽略任何其他字段(或导致错误)。
POST /Foo
{"Id": 1, "Name": "test name"}
除了手动检查填充的结构体并取消设置任何我不希望用户提供的只读字段之外,我找不到其他简单的方法。
我的第二个问题是确定用户是取消设置一个值(将字段的值设为NULL
)还是未提供该值。这是RESTful术语中的部分更新/PATCH。
例如,假设存在一个id为1的Foo。现在用户希望将Name
从test name
更新为new name
,将Description
从test description
更新为NULL
。
PATCH /Foo/1
{"Name": "new name", "Description": NULL}
由于我的结构体使用指针作为字段,如果foo.Description == nil
,我可以确定在创建时Description
应该设置为null。但在这个部分更新中,我如何区分Description
未提供的情况(因此应该保持不变)和上述情况,即调用者希望将Description
的值设置为NULL
?
我知道有一些方法可以通过编写大量的自定义代码来解决这个问题,但我希望有一个通用的解决方案,不需要那么多样板代码。我也看到过一些采用不同的PATCH请求体格式的解决方案,但我必须遵守现有的契约,所以不能采用不同的格式进行部分更新。
我正在考虑几个选项,但都不令我满意。
-
使用
interface
类型的映射,并编写代码来检查每个字段并根据需要断言类型。这样我就可以确定字段和NULL
之间的关系,而不是根本没有提供。这似乎是很多工作。 -
为每种情况定义多个结构体。这感觉更清晰一些,但也有点冗余。而且它只解决了我遇到的两个问题之一(强制只读),而不能确定可为空字段在部分更新时是实际设置为null还是未提供。
例如:
type FooWrite struct {
Name *string
Description *string
}
type FooRead struct {
FooWrite
Id int32
CreateDate string
}
这篇文章解决了部分问题,并让我有了进展,但没有解决我现在遇到的两个问题:https://willnorris.com/2014/05/go-rest-apis-and-pointers
我看到的大多数建议都围绕着改变我的模式设计和避免使用NULL,但我无法修改它,因为其他消费者已经在使用它。
英文:
I am writing an API that is used to perform basic CRUD operations (struct <=> mysql table basically). Here is an example struct that maps to a table in my database. I use pointers for the fields so that I can treat nil
as NULL/absent:
type Foo struct {
Id *int32
Name *string
Description *string
CreateDate *string
}
The Id
field is an autoincrement field that should be assigned by the database. The Name
field is writable and required. The Description
field is writable and nullable. The CreateDate
field is assigned by MySQL on insert and should not be written to.
When the user POSTs a new Foo to create, the request body looks like this in JSON:
POST /Foo
{"Name": "test name", "Description": "test description"}
It's easy to decode this and hydrate a Foo struct using a
foo := Foo{}
json.NewDecoder(requestBody).Decode(&foo)
I'm using the https://github.com/coopernurse/gorp library to simplify inserts/updates/deletes, but my issue still holds even if I'm writing raw sql if I wish to generalize the query creation using reflection on fields.
gorpDbMap.Insert(&foo)
My first problem arises if the user provides an read-only field. If this request body is POSTed, the struct happily accepts Id
and when I do the insert it overrides the autoincrement value. I know this is somewhat my fault for using an ORM rather than manually writing a SQL insert
but my hope was that I could in some way enforce when hydrating the struct that only those writable fields should be decoded and any others ignored (or causing an error):
POST /Foo
{"Id": 1, "Name": "test name"}
I cannot find a simple way other than manually examining the hydrated struct and unsetting any read-only fields that I didn't want the user to provide.
The second problem I am experiencing is determining when a user is unsetting a value (passing NULL
for a field to update) vs when the value was not provided. This is a partial update/PATCH in RESTful terminology.
For example, suppose a Foo with id=1 exists. The user now wishes to update the Name
from test name
to new name
and the Description
from test description
to NULL
.
PATCH /Foo/1
{"Name": "new name", "Description": NULL}
Since my struct uses pointers for its fields I can determine if the Description
should be set to null on create if foo.Description == nil
. But in this partial update, how can I differentiate between the case where Description
was not provided (and should thus be left as-is) and the case above where the caller wishes to set the value of Description
to NULL
?
I know there are ways to solve this by writing a lot of custom code around each struct I define, but I was hoping for a general solution that doesn't require so much boilerplate. I've also seen solutions that adopt a different body format for PATCH requests, but I have to meet the existing contract so I cannot adopt a different format for partial updates.
I'm considering a couple options but neither satisfy me.
-
Use
interface
-typed maps and write code to examine each field and assert types as necessary. This way I can determine if a field andNULL
vs not provided at all. Seems like a lot of work. -
Define multiple structs for each scenario. This feels a little cleaner, but also a little unnecessarily verbose. And it only resolves one of the two problems I have (enforcing read-only) but not determining when a NULLable field is actually nulled out on partial update or just not provided.
e.g.
type FooWrite struct {
Name *string
Description *string
}
type FooRead struct {
FooWrite
Id int32
CreateDate string
}
This article addresses part of the issue and got me this far, but doesn't address the two problems I'm having now: https://willnorris.com/2014/05/go-rest-apis-and-pointers
Most suggestions I've seen revolve around changing the design of my schema and avoiding NULLs, but I do not have the ability to modify that as it is already in use by other consumers.
答案1
得分: 2
这里有一个选项,可以使用自定义类型来特殊处理JSON编组。例如,如果你想要一个在JSON中只读的整数,可以像这样做:
type JsonReadOnlyInt int32
func (i JsonReadOnlyInt) MarshalJSON() ([]byte, error) {
return json.Marshal(int32(i))
}
func (i *JsonReadOnlyInt) UnmarshalJSON([]byte) error {
return nil // 忽略设置的尝试
}
如果你在结构体中使用这个类型,该整数将能够被编组为JSON,但在反向方向上将被忽略:示例代码
不过,要使这个类型与 GORP 协同工作需要更多的工作。看起来该包使用了标准库的数据库转换接口,所以你需要实现 database/sql
中的 Scanner
接口 和 database/sql/driver
中的 Valuer
接口。可以像这样实现:
func (i *JsonReadOnlyInt) Scan(value interface{}) error {
// 可能还需要处理字符串/[]byte 的情况,具体取决于驱动程序
v, ok := value.(int64)
if !ok {
return errors.New("无法扫描")
}
*i = JsonReadOnlyInt(v)
return nil
}
func (i JsonReadOnlyInt) Value() (driver.Value, error) {
return int64(i), nil
}
现在,你应该能够将这种类型的值往返于数据库。
至于补丁问题,你可以尝试两种选项:
-
只解码到一个包含记录旧值的结构体中。任何在JSON对象中缺失的字段都不会被更新,并且可以使用上述策略来保护只读字段。
-
使用自定义结构体类型来表示字段,而不是像上面那样使用简单的整数。将其零值对应为未设置,并使其
UnmarshalJSON
方法设置一个标志来表示已设置。
哪种方法更合适可能取决于你的其余代码。
英文:
One option here would be to use a custom type that special cases JSON marshalling. For instance, if you want an integer that is read only in JSON, you could do something like this:
type JsonReadOnlyInt int32
func (i JsonReadOnlyInt) MarshalJSON() ([]byte, error) {
return json.Marshal(int32(i))
}
func (i *JsonReadOnlyInt) UnmarshalJSON([]byte) error {
return nil // ignore attempts to set
}
If you use this type in one of your structures, the integer will be able to be marshalled to JSON but will be ignored in the reverse direction: http://play.golang.org/p/lW7xuXR6y0
It will require a bit more work to make this type work with GORP though. It looks like that package uses the standard library database conversion interfaces, so you would need to implement Scanner
from database/sql
and Valuer
from database/sql/driver
. Something like this:
func (i *JsonReadOnlyInt) Scan(value interface{}) error {
// And maybe also cases for string/[]byte, depending on the driver
v, ok := value.(int64)
if !ok {
return errors.New("Could not scan")
}
*i = JsonReadOnlyInt(v)
return nil
}
func (i JsonReadOnlyInt) Value() (driver.Value, error) {
return int64(i), nil
}
Now you should be able to round trip values of this type to the database.
As far as the patch question goes, there are two options you could try:
-
Just decode into a struct holding the old values for the record. Any fields missing from the JSON object will not be updated, and your read only fields can be protected using the above strategy.
-
Use a custom struct type to represent your field rather than a simple integer like above. Make its zero value correspond to unset, and make its
UnmarshalJSON
method set a flag to say that it has been set.
Which one is more appropriate will probably depend on the rest of your code.
通过集体智慧和协作来改善编程学习和解决问题的方式。致力于成为全球开发者共同参与的知识库,让每个人都能够通过互相帮助和分享经验来进步。
评论