mirror of
https://github.com/rails/rails.git
synced 2022-11-09 12:12:34 -05:00
Merge pull request #30941 from toptal/introduce-custom-serializers-to-activejob-arguments
Introduce custom serializers to ActiveJob arguments
This commit is contained in:
commit
fa9e791e01
16 changed files with 454 additions and 14 deletions
|
@ -1,3 +1,6 @@
|
|||
* Add support to define custom argument serializers.
|
||||
|
||||
*Evgenii Pecherkin*, *Rafael Mendonça França*
|
||||
|
||||
|
||||
Please check [5-2-stable](https://github.com/rails/rails/blob/5-2-stable/activejob/CHANGELOG.md) for previous changes.
|
||||
|
|
|
@ -33,6 +33,7 @@ module ActiveJob
|
|||
|
||||
autoload :Base
|
||||
autoload :QueueAdapters
|
||||
autoload :Serializers
|
||||
autoload :ConfiguredJob
|
||||
autoload :TestCase
|
||||
autoload :TestHelper
|
||||
|
|
|
@ -44,13 +44,24 @@ module ActiveJob
|
|||
end
|
||||
|
||||
private
|
||||
|
||||
# :nodoc:
|
||||
GLOBALID_KEY = "_aj_globalid".freeze
|
||||
# :nodoc:
|
||||
SYMBOL_KEYS_KEY = "_aj_symbol_keys".freeze
|
||||
# :nodoc:
|
||||
WITH_INDIFFERENT_ACCESS_KEY = "_aj_hash_with_indifferent_access".freeze
|
||||
private_constant :GLOBALID_KEY, :SYMBOL_KEYS_KEY, :WITH_INDIFFERENT_ACCESS_KEY
|
||||
# :nodoc:
|
||||
OBJECT_SERIALIZER_KEY = "_aj_serialized"
|
||||
|
||||
# :nodoc:
|
||||
RESERVED_KEYS = [
|
||||
GLOBALID_KEY, GLOBALID_KEY.to_sym,
|
||||
SYMBOL_KEYS_KEY, SYMBOL_KEYS_KEY.to_sym,
|
||||
OBJECT_SERIALIZER_KEY, OBJECT_SERIALIZER_KEY.to_sym,
|
||||
WITH_INDIFFERENT_ACCESS_KEY, WITH_INDIFFERENT_ACCESS_KEY.to_sym,
|
||||
]
|
||||
private_constant :RESERVED_KEYS
|
||||
|
||||
def serialize_argument(argument)
|
||||
case argument
|
||||
|
@ -70,7 +81,7 @@ module ActiveJob
|
|||
result[SYMBOL_KEYS_KEY] = symbol_keys
|
||||
result
|
||||
else
|
||||
raise SerializationError.new("Unsupported argument type: #{argument.class.name}")
|
||||
Serializers.serialize(argument)
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -85,6 +96,8 @@ module ActiveJob
|
|||
when Hash
|
||||
if serialized_global_id?(argument)
|
||||
deserialize_global_id argument
|
||||
elsif custom_serialized?(argument)
|
||||
Serializers.deserialize(argument)
|
||||
else
|
||||
deserialize_hash(argument)
|
||||
end
|
||||
|
@ -101,6 +114,10 @@ module ActiveJob
|
|||
GlobalID::Locator.locate hash[GLOBALID_KEY]
|
||||
end
|
||||
|
||||
def custom_serialized?(hash)
|
||||
hash.key?(OBJECT_SERIALIZER_KEY)
|
||||
end
|
||||
|
||||
def serialize_hash(argument)
|
||||
argument.each_with_object({}) do |(key, value), hash|
|
||||
hash[serialize_hash_key(key)] = serialize_argument(value)
|
||||
|
@ -117,14 +134,6 @@ module ActiveJob
|
|||
result
|
||||
end
|
||||
|
||||
# :nodoc:
|
||||
RESERVED_KEYS = [
|
||||
GLOBALID_KEY, GLOBALID_KEY.to_sym,
|
||||
SYMBOL_KEYS_KEY, SYMBOL_KEYS_KEY.to_sym,
|
||||
WITH_INDIFFERENT_ACCESS_KEY, WITH_INDIFFERENT_ACCESS_KEY.to_sym,
|
||||
]
|
||||
private_constant :RESERVED_KEYS
|
||||
|
||||
def serialize_hash_key(key)
|
||||
case key
|
||||
when *RESERVED_KEYS
|
||||
|
|
|
@ -59,6 +59,7 @@ module ActiveJob #:nodoc:
|
|||
# * SerializationError - Error class for serialization errors.
|
||||
class Base
|
||||
include Core
|
||||
include Serializers
|
||||
include QueueAdapter
|
||||
include QueueName
|
||||
include QueuePriority
|
||||
|
|
|
@ -7,11 +7,17 @@ module ActiveJob
|
|||
# = Active Job Railtie
|
||||
class Railtie < Rails::Railtie # :nodoc:
|
||||
config.active_job = ActiveSupport::OrderedOptions.new
|
||||
config.active_job.custom_serializers = []
|
||||
|
||||
initializer "active_job.logger" do
|
||||
ActiveSupport.on_load(:active_job) { self.logger = ::Rails.logger }
|
||||
end
|
||||
|
||||
initializer "active_job.custom_serializers" do |app|
|
||||
custom_serializers = app.config.active_job.delete(:custom_serializers)
|
||||
ActiveJob::Serializers.add_serializers custom_serializers
|
||||
end
|
||||
|
||||
initializer "active_job.set_configs" do |app|
|
||||
options = app.config.active_job
|
||||
options.queue_adapter ||= :async
|
||||
|
|
62
activejob/lib/active_job/serializers.rb
Normal file
62
activejob/lib/active_job/serializers.rb
Normal file
|
@ -0,0 +1,62 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "set"
|
||||
|
||||
module ActiveJob
|
||||
# The <tt>ActiveJob::Serializers</tt> module is used to store a list of known serializers
|
||||
# and to add new ones. It also has helpers to serialize/deserialize objects
|
||||
module Serializers
|
||||
extend ActiveSupport::Autoload
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
autoload :ObjectSerializer
|
||||
autoload :SymbolSerializer
|
||||
autoload :DurationSerializer
|
||||
autoload :DateSerializer
|
||||
autoload :TimeSerializer
|
||||
autoload :DateTimeSerializer
|
||||
|
||||
mattr_accessor :_additional_serializers
|
||||
self._additional_serializers = Set.new
|
||||
|
||||
class << self
|
||||
# Returns serialized representative of the passed object.
|
||||
# Will look up through all known serializers.
|
||||
# Raises `ActiveJob::SerializationError` if it can't find a proper serializer.
|
||||
def serialize(argument)
|
||||
serializer = serializers.detect { |s| s.serialize?(argument) }
|
||||
raise SerializationError.new("Unsupported argument type: #{argument.class.name}") unless serializer
|
||||
serializer.serialize(argument)
|
||||
end
|
||||
|
||||
# Returns deserialized object.
|
||||
# Will look up through all known serializers.
|
||||
# If no serializers found will raise `ArgumentError`
|
||||
def deserialize(argument)
|
||||
serializer_name = argument[Arguments::OBJECT_SERIALIZER_KEY]
|
||||
raise ArgumentError, "Serializer name is not present in the argument: #{argument.inspect}" unless serializer_name
|
||||
|
||||
serializer = serializer_name.safe_constantize
|
||||
raise ArgumentError, "Serializer #{serializer_name} is not know" unless serializer
|
||||
|
||||
serializer.deserialize(argument)
|
||||
end
|
||||
|
||||
# Returns list of known serializers
|
||||
def serializers
|
||||
self._additional_serializers
|
||||
end
|
||||
|
||||
# Adds a new serializer to a list of known serializers
|
||||
def add_serializers(*new_serializers)
|
||||
self._additional_serializers += new_serializers
|
||||
end
|
||||
end
|
||||
|
||||
add_serializers SymbolSerializer,
|
||||
DurationSerializer,
|
||||
DateTimeSerializer,
|
||||
DateSerializer,
|
||||
TimeSerializer
|
||||
end
|
||||
end
|
21
activejob/lib/active_job/serializers/date_serializer.rb
Normal file
21
activejob/lib/active_job/serializers/date_serializer.rb
Normal file
|
@ -0,0 +1,21 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module ActiveJob
|
||||
module Serializers
|
||||
class DateSerializer < ObjectSerializer # :nodoc:
|
||||
def serialize(date)
|
||||
super("value" => date.iso8601)
|
||||
end
|
||||
|
||||
def deserialize(hash)
|
||||
Date.iso8601(hash["value"])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def klass
|
||||
Date
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
21
activejob/lib/active_job/serializers/date_time_serializer.rb
Normal file
21
activejob/lib/active_job/serializers/date_time_serializer.rb
Normal file
|
@ -0,0 +1,21 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module ActiveJob
|
||||
module Serializers
|
||||
class DateTimeSerializer < ObjectSerializer # :nodoc:
|
||||
def serialize(time)
|
||||
super("value" => time.iso8601)
|
||||
end
|
||||
|
||||
def deserialize(hash)
|
||||
DateTime.iso8601(hash["value"])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def klass
|
||||
DateTime
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
24
activejob/lib/active_job/serializers/duration_serializer.rb
Normal file
24
activejob/lib/active_job/serializers/duration_serializer.rb
Normal file
|
@ -0,0 +1,24 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module ActiveJob
|
||||
module Serializers
|
||||
class DurationSerializer < ObjectSerializer # :nodoc:
|
||||
def serialize(duration)
|
||||
super("value" => duration.value, "parts" => Arguments.serialize(duration.parts))
|
||||
end
|
||||
|
||||
def deserialize(hash)
|
||||
value = hash["value"]
|
||||
parts = Arguments.deserialize(hash["parts"])
|
||||
|
||||
klass.new(value, parts)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def klass
|
||||
ActiveSupport::Duration
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
54
activejob/lib/active_job/serializers/object_serializer.rb
Normal file
54
activejob/lib/active_job/serializers/object_serializer.rb
Normal file
|
@ -0,0 +1,54 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module ActiveJob
|
||||
module Serializers
|
||||
# Base class for serializing and deserializing custom times.
|
||||
#
|
||||
# Example
|
||||
#
|
||||
# class MoneySerializer < ActiveJob::Serializers::ObjectSerializer
|
||||
# def serialize(money)
|
||||
# super("cents" => money.cents, "currency" => money.currency)
|
||||
# end
|
||||
#
|
||||
# def deserialize(hash)
|
||||
# Money.new(hash["cents"], hash["currency"])
|
||||
# end
|
||||
#
|
||||
# private
|
||||
#
|
||||
# def klass
|
||||
# Money
|
||||
# end
|
||||
# end
|
||||
class ObjectSerializer
|
||||
include Singleton
|
||||
|
||||
class << self
|
||||
delegate :serialize?, :serialize, :deserialize, to: :instance
|
||||
end
|
||||
|
||||
# Determines if an argument should be serialized by a serializer.
|
||||
def serialize?(argument)
|
||||
argument.is_a?(klass)
|
||||
end
|
||||
|
||||
# Serializes an argument to a JSON primitive type.
|
||||
def serialize(hash)
|
||||
{ Arguments::OBJECT_SERIALIZER_KEY => self.class.name }.merge!(hash)
|
||||
end
|
||||
|
||||
# Deserilizes an argument form a JSON primiteve type.
|
||||
def deserialize(_argument)
|
||||
raise NotImplementedError
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
# The class of the object that will be serialized.
|
||||
def klass
|
||||
raise NotImplementedError
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
21
activejob/lib/active_job/serializers/symbol_serializer.rb
Normal file
21
activejob/lib/active_job/serializers/symbol_serializer.rb
Normal file
|
@ -0,0 +1,21 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module ActiveJob
|
||||
module Serializers
|
||||
class SymbolSerializer < ObjectSerializer # :nodoc:
|
||||
def serialize(argument)
|
||||
super("value" => argument.to_s)
|
||||
end
|
||||
|
||||
def deserialize(argument)
|
||||
argument["value"].to_sym
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def klass
|
||||
Symbol
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
21
activejob/lib/active_job/serializers/time_serializer.rb
Normal file
21
activejob/lib/active_job/serializers/time_serializer.rb
Normal file
|
@ -0,0 +1,21 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module ActiveJob
|
||||
module Serializers
|
||||
class TimeSerializer < ObjectSerializer # :nodoc:
|
||||
def serialize(time)
|
||||
super("value" => time.iso8601)
|
||||
end
|
||||
|
||||
def deserialize(hash)
|
||||
Time.iso8601(hash["value"])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def klass
|
||||
Time
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -12,7 +12,10 @@ class ArgumentSerializationTest < ActiveSupport::TestCase
|
|||
end
|
||||
|
||||
[ nil, 1, 1.0, 1_000_000_000_000_000_000_000,
|
||||
"a", true, false, BigDecimal(5),
|
||||
"a", true, false, BigDecimal.new(5),
|
||||
:a, 1.day, Date.new(2001, 2, 3), Time.new(2002, 10, 31, 2, 2, 2, "+02:00"),
|
||||
DateTime.new(2001, 2, 3, 4, 5, 6, "+03:00"),
|
||||
ActiveSupport::TimeWithZone.new(Time.utc(1999, 12, 31, 23, 59, 59), ActiveSupport::TimeZone["UTC"]),
|
||||
[ 1, "a" ],
|
||||
{ "a" => 1 }
|
||||
].each do |arg|
|
||||
|
@ -21,7 +24,7 @@ class ArgumentSerializationTest < ActiveSupport::TestCase
|
|||
end
|
||||
end
|
||||
|
||||
[ :a, Object.new, self, Person.find("5").to_gid ].each do |arg|
|
||||
[ Object.new, self, Person.find("5").to_gid ].each do |arg|
|
||||
test "does not serialize #{arg.class}" do
|
||||
assert_raises ActiveJob::SerializationError do
|
||||
ActiveJob::Arguments.serialize [ arg ]
|
||||
|
@ -46,6 +49,49 @@ class ArgumentSerializationTest < ActiveSupport::TestCase
|
|||
assert_arguments_roundtrip([a: 1, "b" => 2])
|
||||
end
|
||||
|
||||
test "serialize a hash" do
|
||||
symbol_key = { a: 1 }
|
||||
string_key = { "a" => 1 }
|
||||
indifferent_access = { a: 1 }.with_indifferent_access
|
||||
|
||||
assert_equal(
|
||||
{ "a" => 1, "_aj_symbol_keys" => ["a"] },
|
||||
ActiveJob::Arguments.serialize([symbol_key]).first
|
||||
)
|
||||
assert_equal(
|
||||
{ "a" => 1, "_aj_symbol_keys" => [] },
|
||||
ActiveJob::Arguments.serialize([string_key]).first
|
||||
)
|
||||
assert_equal(
|
||||
{ "a" => 1, "_aj_hash_with_indifferent_access" => true },
|
||||
ActiveJob::Arguments.serialize([indifferent_access]).first
|
||||
)
|
||||
end
|
||||
|
||||
test "deserialize a hash" do
|
||||
symbol_key = { "a" => 1, "_aj_symbol_keys" => ["a"] }
|
||||
string_key = { "a" => 1, "_aj_symbol_keys" => [] }
|
||||
another_string_key = { "a" => 1 }
|
||||
indifferent_access = { "a" => 1, "_aj_hash_with_indifferent_access" => true }
|
||||
|
||||
assert_equal(
|
||||
{ a: 1 },
|
||||
ActiveJob::Arguments.deserialize([symbol_key]).first
|
||||
)
|
||||
assert_equal(
|
||||
{ "a" => 1 },
|
||||
ActiveJob::Arguments.deserialize([string_key]).first
|
||||
)
|
||||
assert_equal(
|
||||
{ "a" => 1 },
|
||||
ActiveJob::Arguments.deserialize([another_string_key]).first
|
||||
)
|
||||
assert_equal(
|
||||
{ "a" => 1 },
|
||||
ActiveJob::Arguments.deserialize([indifferent_access]).first
|
||||
)
|
||||
end
|
||||
|
||||
test "should maintain hash with indifferent access" do
|
||||
symbol_key = { a: 1 }
|
||||
string_key = { "a" => 1 }
|
||||
|
|
98
activejob/test/cases/serializers_test.rb
Normal file
98
activejob/test/cases/serializers_test.rb
Normal file
|
@ -0,0 +1,98 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "helper"
|
||||
require "active_job/serializers"
|
||||
|
||||
class SerializersTest < ActiveSupport::TestCase
|
||||
class DummyValueObject
|
||||
attr_accessor :value
|
||||
|
||||
def initialize(value)
|
||||
@value = value
|
||||
end
|
||||
|
||||
def ==(other)
|
||||
self.value == other.value
|
||||
end
|
||||
end
|
||||
|
||||
class DummySerializer < ActiveJob::Serializers::ObjectSerializer
|
||||
def serialize(object)
|
||||
super({ "value" => object.value })
|
||||
end
|
||||
|
||||
def deserialize(hash)
|
||||
DummyValueObject.new(hash["value"])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def klass
|
||||
DummyValueObject
|
||||
end
|
||||
end
|
||||
|
||||
setup do
|
||||
@value_object = DummyValueObject.new 123
|
||||
@original_serializers = ActiveJob::Serializers.serializers
|
||||
end
|
||||
|
||||
teardown do
|
||||
ActiveJob::Serializers._additional_serializers = @original_serializers
|
||||
end
|
||||
|
||||
test "can't serialize unknown object" do
|
||||
assert_raises ActiveJob::SerializationError do
|
||||
ActiveJob::Serializers.serialize @value_object
|
||||
end
|
||||
end
|
||||
|
||||
test "will serialize objects with serializers registered" do
|
||||
ActiveJob::Serializers.add_serializers DummySerializer
|
||||
|
||||
assert_equal(
|
||||
{ "_aj_serialized" => "SerializersTest::DummySerializer", "value" => 123 },
|
||||
ActiveJob::Serializers.serialize(@value_object)
|
||||
)
|
||||
end
|
||||
|
||||
test "won't deserialize unknown hash" do
|
||||
hash = { "_dummy_serializer" => 123, "_aj_symbol_keys" => [] }
|
||||
error = assert_raises(ArgumentError) do
|
||||
ActiveJob::Serializers.deserialize(hash)
|
||||
end
|
||||
assert_equal(
|
||||
'Serializer name is not present in the argument: {"_dummy_serializer"=>123, "_aj_symbol_keys"=>[]}',
|
||||
error.message
|
||||
)
|
||||
end
|
||||
|
||||
test "won't deserialize unknown serializer" do
|
||||
hash = { "_aj_serialized" => "DoNotExist", "value" => 123 }
|
||||
error = assert_raises(ArgumentError) do
|
||||
ActiveJob::Serializers.deserialize(hash)
|
||||
end
|
||||
assert_equal(
|
||||
"Serializer DoNotExist is not know",
|
||||
error.message
|
||||
)
|
||||
end
|
||||
|
||||
test "will deserialize know serialized objects" do
|
||||
ActiveJob::Serializers.add_serializers DummySerializer
|
||||
hash = { "_aj_serialized" => "SerializersTest::DummySerializer", "value" => 123 }
|
||||
assert_equal DummyValueObject.new(123), ActiveJob::Serializers.deserialize(hash)
|
||||
end
|
||||
|
||||
test "adds new serializer" do
|
||||
ActiveJob::Serializers.add_serializers DummySerializer
|
||||
assert ActiveJob::Serializers.serializers.include?(DummySerializer)
|
||||
end
|
||||
|
||||
test "can't add serializer with the same key twice" do
|
||||
ActiveJob::Serializers.add_serializers DummySerializer
|
||||
assert_no_difference(-> { ActiveJob::Serializers.serializers.size }) do
|
||||
ActiveJob::Serializers.add_serializers DummySerializer
|
||||
end
|
||||
end
|
||||
end
|
|
@ -339,8 +339,23 @@ UserMailer.welcome(@user).deliver_later # Email will be localized to Esperanto.
|
|||
```
|
||||
|
||||
|
||||
GlobalID
|
||||
--------
|
||||
Supported types for arguments
|
||||
----------------------------
|
||||
|
||||
ActiveJob supports the following types of arguments by default:
|
||||
|
||||
- Basic types (`NilClass`, `String`, `Integer`, `Fixnum`, `Bignum`, `Float`, `BigDecimal`, `TrueClass`, `FalseClass`)
|
||||
- `Symbol
|
||||
- `ActiveSupport::Duration`
|
||||
- `Date`
|
||||
- `Time`
|
||||
- `DateTime`
|
||||
- `ActiveSupport::TimeWithZone`
|
||||
- `Hash`. Keys should be of `String` or `Symbol` type
|
||||
- `ActiveSupport::HashWithIndifferentAccess`
|
||||
- `Array`
|
||||
|
||||
### GlobalID
|
||||
|
||||
Active Job supports GlobalID for parameters. This makes it possible to pass live
|
||||
Active Record objects to your job instead of class/id pairs, which you then have
|
||||
|
@ -368,6 +383,41 @@ end
|
|||
This works with any class that mixes in `GlobalID::Identification`, which
|
||||
by default has been mixed into Active Record classes.
|
||||
|
||||
### Serializers
|
||||
|
||||
You can extend list of supported types for arguments. You just need to define your own serializer.
|
||||
|
||||
```ruby
|
||||
class MoneySerializer < ActiveJob::Serializers::ObjectSerializer
|
||||
# Check if this object should be serialized using this serializer.
|
||||
def serialize?(argument)
|
||||
argument.is_a? Money
|
||||
end
|
||||
|
||||
# Convert an object to a simpler representative using supported object types.
|
||||
# The recommended representative is a Hash with a specific key. Keys can be of basic types only.
|
||||
# You should call `super` to add the custom serializer type to the hash
|
||||
def serialize(object)
|
||||
super(
|
||||
"cents" => object.cents,
|
||||
"currency" => object.currency
|
||||
)
|
||||
end
|
||||
|
||||
# Convert serialized value into a proper object
|
||||
def deserialize(hash)
|
||||
Money.new hash["cents"], hash["currency"]
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
And now you just need to add this serializer to a list:
|
||||
|
||||
```ruby
|
||||
Rails.application.config.active_job.custom_serializers << MySpecialSerializer
|
||||
```
|
||||
|
||||
|
||||
Exceptions
|
||||
----------
|
||||
|
|
|
@ -741,6 +741,8 @@ There are a few configuration options available in Active Support:
|
|||
|
||||
* `config.active_job.logger` accepts a logger conforming to the interface of Log4r or the default Ruby Logger class, which is then used to log information from Active Job. You can retrieve this logger by calling `logger` on either an Active Job class or an Active Job instance. Set to `nil` to disable logging.
|
||||
|
||||
* `config.active_job.custom_serializers` allows to set custom argument serializers. Defaults to `[]`.
|
||||
|
||||
### Configuring Action Cable
|
||||
|
||||
* `config.action_cable.url` accepts a string for the URL for where
|
||||
|
|
Loading…
Reference in a new issue