Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

C#: Mark GetOption API as obsolete and expose the "GetOptions()" method on descriptors instead #7434

Merged

Conversation

jtattermusch
Copy link
Contributor

The currently existing new API for getting custom options (introduced in #5350 as part of proto2 extensions support) has some serious issues

  • it is incomplete: it doesn't allow distinguishing between the custom option not being present at all VS being present and having the default value.
  • the GetOption API basically just mimics the existing GetExtension API (with repeated / non-repeated overloads), creating unnecessary duplication (which then leads to problems such as C#: MethodDescriptor.GetOption throws NullReferenceException #7127)
  • the new API offers no way of accessing the "standard" (non-custom) options at all.
  • the GetOption API is not consistent with what other protobuf implementations (C++, java, python) do to access custom options.

This PR removes the unnecessary (and partially broken) GetOption API and replaces it by a more straightforward approach:

  • expose Options property for each descriptor that allows one to access the corresponding "options" message (but use a defensive copy because C# messages are inherently mutable).
  • the custom options are retrieved just as any other extensions, using the standard extension API (HasExtension, GetExtension). This is the same approach that is used in C++, Java and Python

(the GetOption API has been marked as "experimental and subject to change" when releasing the C# proto2 support, so removing it if fine to do).

@jtattermusch
Copy link
Contributor Author

jtattermusch commented Apr 28, 2020

CC @ObsidianMinor @JamesNK @jskeet

@jtattermusch
Copy link
Contributor Author

Alternative approaches are in PRs https://github.com/protocolbuffers/protobuf/pull/7130/files
https://github.com/protocolbuffers/protobuf/pull/7282/files (this PR tries to find a simple solution to the problems encountered when reviewing those PRs).

@jskeet
Copy link
Contributor

jskeet commented Apr 28, 2020

I'm fine with deprecating the existing API, but we shouldn't make breaking changes by removing the existing one unless you're planning on creating a major version bump (which I'd strongly discourage).

I'm uncomfortable with the idea of APIs being labelled as "experimental" in general - particularly when that designation is only visible if you happen to read the right documentation.

I'm similarly nervous about my own #7429 PR, but at least that's in the generated code rather than the support library. (You only get broken when you regenerate, and then it's at compile time.) This change could easily mean that a minor version bump in which support library is consumed ends up breaking a deployed application.

@jtattermusch
Copy link
Contributor Author

I'm fine with deprecating the existing API, but we shouldn't make breaking changes by removing the existing one unless you're planning on creating a major version bump (which I'd strongly discourage).

I'm uncomfortable with the idea of APIs being labelled as "experimental" in general - particularly when that designation is only visible if you happen to read the right documentation.

I'm similarly nervous about my own #7429 PR, but at least that's in the generated code rather than the support library. (You only get broken when you regenerate, and then it's at compile time.) This change could easily mean that a minor version bump in which support library is consumed ends up breaking a deployed application.

I'm fine with both approaches (removing the GetOption method or only marking it as deprecated).
Some arguments why just removing GetOption API might be fine:

@jskeet
Copy link
Contributor

jskeet commented Apr 28, 2020

If the option had only been available for Proto2-based code, I'd be at least more comfortable - but we've been using it (in API generation) for proto3-based options. Again, I don't think marking something as experimental just in release notes is a good way of excusing breaking changes. If we want to be able to make breaking changes in the support library itself, I really think we need a better way of doing so. (Marking it as deprecated would, somewhat oddly, be a reasonable starting point - something that brings it to the attention of users.)

And yes, it needed fixing to start with - but it was fixed. It's not great in terms of API surface, but it works for at least some scenarios (e.g. how we're using it in https://github.com/googleapis/gapic-generator-csharp)

I tend to think that the benefits of violating semver have to be really, really huge to counter the downsides, which can include lack of trust in future releases. Sometimes it's the best-of-a-bad-bunch option, but in this case I think using deprecation is better.

@jtattermusch
Copy link
Contributor Author

If the option had only been available for Proto2-based code, I'd be at least more comfortable - but we've been using it (in API generation) for proto3-based options. Again, I don't think marking something as experimental just in release notes is a good way of excusing breaking changes. If we want to be able to make breaking changes in the support library itself, I really think we need a better way of doing so. (Marking it as deprecated would, somewhat oddly, be a reasonable starting point - something that brings it to the attention of users.)

And yes, it needed fixing to start with - but it was fixed. It's not great in terms of API surface, but it works for at least some scenarios (e.g. how we're using it in https://github.com/googleapis/gapic-generator-csharp)

I tend to think that the benefits of violating semver have to be really, really huge to counter the downsides, which can include lack of trust in future releases. Sometimes it's the best-of-a-bad-bunch option, but in this case I think using deprecation is better.

Fair enough, I'll add back the GetOption methods and mark them as obsolete.

@jskeet
Copy link
Contributor

jskeet commented Apr 28, 2020

Fab, thanks. I can then have a look at the "new" API without being distracted ;)

@jtattermusch
Copy link
Contributor Author

If the option had only been available for Proto2-based code, I'd be at least more comfortable - but we've been using it (in API generation) for proto3-based options. Again, I don't think marking something as experimental just in release notes is a good way of excusing breaking changes. If we want to be able to make breaking changes in the support library itself, I really think we need a better way of doing so. (Marking it as deprecated would, somewhat oddly, be a reasonable starting point - something that brings it to the attention of users.)
And yes, it needed fixing to start with - but it was fixed. It's not great in terms of API surface, but it works for at least some scenarios (e.g. how we're using it in https://github.com/googleapis/gapic-generator-csharp)
I tend to think that the benefits of violating semver have to be really, really huge to counter the downsides, which can include lack of trust in future releases. Sometimes it's the best-of-a-bad-bunch option, but in this case I think using deprecation is better.

Fair enough, I'll add back the GetOption methods and mark them as obsolete.

Done.

@haberman
Copy link
Member

Note that custom options can be self-referential. Here is valid .proto file that we should ensure works properly in C#:

syntax = "proto2";

import "google/protobuf/descriptor.proto";

message FooOptions {
  // Custom field option used in definition of the custom option's message.
  optional int32 foo = 1 [(foo_options) = {foo: 1234}];
}

extend google.protobuf.FieldOptions {
  // Custom field option used on the definition of that field option.
  optional int32 bar_options = 1000 [(bar_options) = 1234];

  optional FooOptions foo_options = 1001;
}

@ObsidianMinor
Copy link
Contributor

I'm still unsure how I feel about doing defensive copies but that may just be the Rust developer in me that's worried and not the C# developer. It will make the API simpler in the long run and will be easier to maintain so it should be ok.

@jtattermusch
Copy link
Contributor Author

I discussed the defensive copy with @haberman and we agreed that at least at the first glance it seemed to be a reasonable solution. I think @haberman is the ultimate authority here so I'm gonna go with his opinion.

@jskeet
Copy link
Contributor

jskeet commented Apr 28, 2020

Yes, I'm pretty sure we need the defensive copy. It would be awful if reflection could modify the descriptors globally.

/// Custom options can be retrieved as extensions of the returned message.
/// NOTE: A defensive copy is created each time this property is retrieved.
/// </summary>
public EnumOptions Options => (Proto.Options as IDeepCloneable<EnumOptions>)?.Clone();
Copy link
Contributor

@JamesNK JamesNK Apr 28, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can Proto.Options on an EnumDescriptor ever not be IDeepCloneable<EnumOptions>? Same question for all the other as casts. I prefer to do a regular cast unless there is a reason.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, removed the cast.

public CustomOptions CustomOptions => new CustomOptions(Proto.Options?._extensions?.ValuesByNumber);

/// <summary>
Copy link
Contributor

@JamesNK JamesNK Apr 28, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can null ever be returned?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Improved the docs.

@haberman
Copy link
Member

Yes I agree that it would be better to avoid the copy if possible. But without const message instances like we have in C++ I don't see a way to do it.

@haberman
Copy link
Member

By the way, here's an even more diabolical example of custom options.

syntax = "proto2";

import "google/protobuf/descriptor.proto";

message FooOptions {
  // Custom field option used in definition of the extension message.
  optional int32 int_opt = 1 [(foo_options) = {
             int_opt: 1
             [foo_int_opt]: 2
             [foo_foo_opt]: {
               int_opt: 3
             }
  }];
  extensions 1000 to max;
}

extend google.protobuf.FieldOptions {
  // Custom field option used on the definition of that field option.
  optional int32 bar_options = 1000 [(bar_options) = 1234];

  optional FooOptions foo_options = 1001;
}

extend FooOptions {
  optional int32 foo_int_opt = 1000;
  optional FooOptions foo_foo_opt = 1001;
}

@jtattermusch
Copy link
Contributor Author

jtattermusch commented Apr 29, 2020

because Options creates a defensive copy each time you request it, it might be better to do var options = methodDescriptor.Options; first and then use that instance repeatedly (but it doesn't affect correctness, only efficiency).

You should consider a method instead of a property. Properties are assumed to be free/cheap to access. Methods indicate there is a cost and the value should be assigned to a local variable. I don't know what name you would choose. GetOptions is already taken.

looks like GetOption() is taken, GetOptions() is not.

Not sure if Options property or GetOptions method is better (the clone is actually going to be pretty cheap for most normal cases).

@jtattermusch
Copy link
Contributor Author

By the way, here's an even more diabolical example of custom options.

syntax = "proto2";

import "google/protobuf/descriptor.proto";

message FooOptions {
  // Custom field option used in definition of the extension message.
  optional int32 int_opt = 1 [(foo_options) = {
             int_opt: 1
             [foo_int_opt]: 2
             [foo_foo_opt]: {
               int_opt: 3
             }
  }];
  extensions 1000 to max;
}

extend google.protobuf.FieldOptions {
  // Custom field option used on the definition of that field option.
  optional int32 bar_options = 1000 [(bar_options) = 1234];

  optional FooOptions foo_options = 1001;
}

extend FooOptions {
  optional int32 foo_int_opt = 1000;
  optional FooOptions foo_foo_opt = 1001;
}

Do these proto definitions already exist somewhere in the protobuf codebase? I'd expect so because presumably other languages already have tests for these cases?

@jtattermusch
Copy link
Contributor Author

Friendly ping, can we get this reviewed and merged?

@jskeet
Copy link
Contributor

jskeet commented May 4, 2020

It wasn't clear to me that it was ready for another review given the nature of the last few comments. I'll have another look tomorrow.

@jtattermusch
Copy link
Contributor Author

It wasn't clear to me that it was ready for another review given the nature of the last few comments. I'll have another look tomorrow.

I think the only undecided topic is whether to expose Options property (even though it actually created a copy of the options) or whether to have GetOptions() method instead (as @JamesNK has suggested)
I think I'm fine with the current approach (a property) because the copy will be pretty lightweight in most normal cases (options are handwritten in the .proto file so usually there will only be a few of them present) and IMHO using a property feels more natural. That said, I don't feel strongly about this and I'd be happy to be convinced otherwise.

public CustomOptions CustomOptions => new CustomOptions(Proto.Options?._extensions?.ValuesByNumber);

/// <summary>
/// The <c>EnumOptions</c>, defined in <c>descriptor.proto</c>.
/// If the options message is not present (=there are no options), <c>null</c> is returned.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// If the options message is not present (=there are no options), <c>null</c> is returned.
/// If the options message is not present (i.e. there are no options), <c>null</c> is returned.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Ditto in all options, of course.)

@haberman
Copy link
Member

haberman commented May 5, 2020

If it is making a copy, putting it behind a method like GetOptions() seems less surprising to me.

@jtattermusch
Copy link
Contributor Author

If it is making a copy, putting it behind a method like GetOptions() seems less surprising to me.

@jskeet do you have a preference? I still think using a property is fine.

@jskeet
Copy link
Contributor

jskeet commented May 6, 2020

I'd just about prefer a method here - it really does make it clearer - but I'm not massively bothered either way.

@jtattermusch
Copy link
Contributor Author

I'd just about prefer a method here - it really does make it clearer - but I'm not massively bothered either way.

Ok, looks like I've been outvoted then. I'll turn this into a GetOptions() method.

@jtattermusch
Copy link
Contributor Author

@haberman all feedback addressed.

@jtattermusch jtattermusch changed the title C#: Get rid of broken GetOption API and expose the "Options" property on descriptors instead C#: Get rid of broken GetOption API and expose the "GetOptions()" property on descriptors instead May 7, 2020
@jtattermusch jtattermusch changed the title C#: Get rid of broken GetOption API and expose the "GetOptions()" property on descriptors instead C#: Get rid of broken GetOption API and expose the "GetOptions()" method on descriptors instead May 7, 2020
@jtattermusch
Copy link
Contributor Author

Test failures seem unrelated, merging.

@jtattermusch jtattermusch merged commit bf3eef9 into protocolbuffers:master May 11, 2020
@jtattermusch jtattermusch changed the title C#: Get rid of broken GetOption API and expose the "GetOptions()" method on descriptors instead C#: Mark GetOption API as obsolete and expose the "GetOptions()" method on descriptors instead May 12, 2020
@jtattermusch
Copy link
Contributor Author

@haberman I think we should cherry-pick this for 3.12.x branch (It's a fix).

haberman pushed a commit to haberman/protobuf that referenced this pull request May 12, 2020
…se_options

C#: Get rid of broken GetOption API and expose the "GetOptions()" method on descriptors instead
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

7 participants