项目作者: keypup-io

项目描述 :
Cross-application messaging for Ruby and Rails using Google Cloud Pub/Sub
高级语言: Ruby
项目地址: git://github.com/keypup-io/cloudenvoy.git
创建时间: 2020-09-04T15:34:56Z
项目社区:https://github.com/keypup-io/cloudenvoy

开源协议:MIT License

下载


Build Status Gem Version

Cloudenvoy

Cross-application messaging framework for GCP Pub/Sub.

Cloudenvoy provides an easy to use interface to GCP Pub/Sub. Using Cloudenvoy you can simplify cross-application event messaging by using a publish/subscribe approach. Pub/Sub is particularly suited for micro-service architectures where a great number of components need to be aware of other components’ activities. In these architectures using point to point communication via API can quickly become messy and hard to maintain due to the number of interconnections to maintain.

Pub/Sub solves that event distribution problem by allowing developers to define topics, publishers and subscribers to distribute and process event messages. Cloudenvoy furthers simplifies the process of setting up Pub/Sub by giving developers an object-oriented way of managing publishers and subscribers.

Cloudenvoy works with the local pub/sub emulator as well, meaning that you can work offline without access to GCP.

Summary

  1. Installation
  2. Get started with Rails
  3. Configuring Cloudenvoy
    1. Pub/Sub authentication & permissions
    2. Cloudenvoy initializer
  4. Creating topics and subscriptions
    1. Sending messages
    2. Publisher implementation layout
  5. Receiving messages
  6. Error Handling
  7. Testing
    1. Test helper setup
    2. In-memory queues
    3. Unit tests

Installation

Add this line to your application’s Gemfile:

  1. gem 'cloudenvoy'

And then execute:

  1. $ bundle install

Or install it yourself as:

  1. $ gem install cloudenvoy

Get started with Rails

Cloudenvoy is pre-integrated with Rails. Follow the steps below to get started.

Install the pub/sub local emulator

  1. gcloud components install pubsub-emulator
  2. gcloud components update

Add the following initializer

  1. # config/initializers/cloudenvoy.rb
  2. Cloudenvoy.configure do |config|
  3. #
  4. # GCP Configuration
  5. #
  6. config.gcp_project_id = 'some-project'
  7. config.gcp_sub_prefix = 'my-app'
  8. #
  9. # Adapt the server port to be the one used by your Rails web process
  10. #
  11. config.processor_host = 'http://localhost:3000'
  12. #
  13. # If you do not have any Rails secret_key_base defined, uncomment the following
  14. # This secret is used to authenticate messages sent to the processing endpoint
  15. # of your application.
  16. #
  17. # config.secret = 'some-long-token'
  18. end

Define a publisher or use generator: rails generate cloudenvoy:publisher Dummy

  1. # app/publishers/dummy_publisher.rb
  2. class DummyPublisher
  3. include Cloudenvoy::Publisher
  4. cloudenvoy_options topic: 'test-msgs'
  5. # Format the message payload. The payload can be a hash
  6. # or a string.
  7. def payload(msg)
  8. {
  9. type: 'message',
  10. content: msg
  11. }
  12. end
  13. end

Define a subscriber or use generator: rails generate cloudenvoy:subscriber Dummy

  1. # app/subscribers/dummy_subscriber.rb
  2. class HelloSubscriber
  3. include Cloudenvoy::Subscriber
  4. cloudenvoy_options topics: ['test-msgs']
  5. # Do something with the message
  6. def process(message)
  7. logger.info("Received message #{message.payload.dig('content')}")
  8. end
  9. end

Launch the pub/sub emulator:

  1. gcloud beta emulators pubsub start

Use cloudenvoy to setup your topic and subscription

  1. bundle exec rake cloudenvoy:setup

Launch Rails

  1. rails s -p 3000

Open a Rails console and send a message

  1. # One message at a time
  2. DummyPublisher.publish('Hello pub/sub')
  3. # Publish multiple messages in one batch
  4. # Only available since v0.6.rc1
  5. DummyPublisher.publish_all(['Hello pub/sub', 'Hello again!', 'Hello again and again!'])

Your Rails logs should display the following:

  1. Started POST "/cloudenvoy/receive?token=1234" for 66.102.6.140 at 2020-09-16 11:12:47 +0200
  2. Processing by Cloudenvoy::SubscriberController#receive as JSON
  3. Parameters: {"message"=>{"attributes"=>{"kind"=>"hello"}, "data"=>"eyJ0eXBlIjoibWVzc2FnZSIsImNvbnRlbnQiOiJIZWxsbyBmcmllbmQifQ==", "messageId"=>"1501653492745522", "message_id"=>"1501653492745522", "publishTime"=>"2020-09-16T09:12:45.214Z", "publish_time"=>"2020-09-16T09:12:45.214Z"}, "subscription"=>"projects/keypup-dev/subscriptions/my-app.hello_subscriber.test-msgs", "token"=>"eyJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE2MDAyNDc0OTh9.5SbVsDCZLcyoFXseCpPuvE7KY7WXqIQtO6ceoFXcrdw", "subscriber"=>{"message"=>{"attributes"=>{"kind"=>"hello"}, "data"=>"eyJ0eXBlIjoibWVzc2FnZSIsImNvbnRlbnQiOiJIZWxsbyBmcmllbmQifQ==", "messageId"=>"1501653492745522", "message_id"=>"1501653492745522", "publishTime"=>"2020-09-16T09:12:45.214Z", "publish_time"=>"2020-09-16T09:12:45.214Z"}, "subscription"=>"projects/keypup-dev/subscriptions/my-app.hello_subscriber.test-msgs"}}
  4. [Cloudenvoy][HelloSubscriber][1501653492745522] Processing message... -- {:id=>"1501653492745522", :metadata=>{}, :topic=>"test-msgs"}
  5. [Cloudenvoy][HelloSubscriber][1501653492745522] Received message Hello pub/sub -- {:id=>"1501653492745522", :metadata=>{}, :topic=>"test-msgs"}
  6. [Cloudenvoy][HelloSubscriber][1501653492745522] Processing done after 0.001s -- {:id=>"1501653492745522", :metadata=>{}, :topic=>"test-msgs", :duration=>0.001}
  7. Completed 204 No Content in 1ms (ActiveRecord: 0.0ms | Allocations: 500)

Hurray! Your published message was immediately processed by the subscriber.

Configuring Cloudenvoy

Pub/Sub authentication & permissions

The Google Cloud library authenticates via the Google Cloud SDK by default. If you do not have it setup then we recommend you install it.

Other options are available such as using a service account. You can see all authentication options in the Google Cloud Authentication guide.

In order to function properly Cloudenvoy requires the authenticated account to have the following IAM permissions:

  • pubsub.subscriptions.create
  • pubsub.subscriptions.get
  • pubsub.topics.create
  • pubsub.topics.get
  • pubsub.topics.publish

To get started quickly you can add the roles/pubsub.admin role to your account via the IAM Console. This is not required if your account is a project admin account.

Cloudenvoy initializer

The gem can be configured through an initializer. See below all the available configuration options.

  1. # config/initializers/cloudenvoy.rb
  2. Cloudenvoy.configure do |config|
  3. #
  4. # If you do not have any Rails secret_key_base defined, uncomment the following.
  5. # This secret is used to authenticate messages sent to the processing endpoint
  6. # of your application.
  7. #
  8. # Default with Rails: Rails.application.credentials.secret_key_base
  9. #
  10. # config.secret = 'some-long-token'
  11. #
  12. # GCP Configuration
  13. #
  14. config.gcp_project_id = 'some-project'
  15. #
  16. # Specify the namespace for your subscriptions
  17. #
  18. # The gem attempts to keep GCP subscriptions organized by
  19. # properly namespacing them. Each subscription has the following
  20. # format:
  21. # > projects/<gcp_project_id>/subscriptions/<gcp_sub_prefix>.<subscriber_class>.<topic>
  22. #
  23. config.gcp_sub_prefix = 'my-app'
  24. #
  25. # Specify the publicly accessible host for your application
  26. #
  27. # > E.g. in development, using the pub/sub local emulator
  28. # config.processor_host = 'http://localhost:3000'
  29. #
  30. # > E.g. in development, using `config.mode = :production` and ngrok
  31. # config.processor_host = 'https://111111.ngrok.io'
  32. #
  33. config.processor_host = 'https://app.mydomain.com'
  34. #
  35. # Specify the mode of operation:
  36. # - :development => messages will be pushed to the local pub/sub emulator
  37. # - :production => messages will be pushed to Google Cloud Pub/Sub. Requires a publicly accessible domain.
  38. #
  39. # Defaults to :development unless CLOUDENVOY_ENV or RAILS_ENV or RACK_ENV is set to something else.
  40. #
  41. # config.mode = Rails.env.production? || Rails.env.my_other_env? ? :production : :development
  42. #
  43. # Specify the logger to use
  44. #
  45. # Default with Rails: Rails.logger
  46. # Default without Rails: Logger.new(STDOUT)
  47. #
  48. # config.logger = MyLogger.new(STDOUT)
  49. end

Creating topics and subscriptions

Topics and subscriptions can be created using the provided Rake tasks:

  1. # Setup publishers (topics) and subscribers (subscriptions) in one go
  2. bundle exec rake cloudenvoy:setup
  3. # Or set them up individually
  4. bundle exec rake cloudenvoy:setup_publishers
  5. bundle exec rake cloudenvoy:setup_subscribers

For non-rails applications you can run the following in a console to setup your publishers and subscribers:

  1. DummyPublisher.setup
  2. DummySubscriber.setup

Publishing messages

Sending messages

Note: The publish_all method is only available since v0.6.rc1

Cloudenvoy provides a helper method to publish arbitrary messages to any topic.

  1. # Publish a single message
  2. Cloudenvoy.publish('my-topic', { 'some' => 'payload' }, { 'optional' => 'message attribute' })
  3. # Publish multiple messages in one batch
  4. # Only available since v0.6.rc1
  5. Cloudenvoy.publish_all('my-topic', [
  6. # Message 1
  7. [{ 'some' => 'msg1 payload' }, { 'optional' => 'msg1 attribute' }],
  8. # Message 2
  9. [{ 'some' => 'msg2 payload' }, { 'optional' => 'msg2 attribute' }]
  10. ])

This helper is useful for sending basic messages however it is not the preferred way of sending messages as you will quickly clutter your application with message formatting logic over time.

Cloudenvoy provides an object-oriented way of sending messages allowing developers to separate their core business logic from any kind of message formatting logic. These are called Publishers.

The example below shows you how to publish new users to a topic using Cloudenvoy publishers:

  1. # app/publishers/user_publisher.rb
  2. # The publisher is responsible for configuring and formatting
  3. # the pub/sub message.
  4. class UserPublisher
  5. include Cloudenvoy::Publisher
  6. cloudenvoy_options topic: 'system-users'
  7. # Publishers must at least implement the `payload` method,
  8. # which specifies how the message should be formatted.
  9. def payload(user)
  10. {
  11. id: user.id,
  12. name: user.name,
  13. email: user.email
  14. }
  15. end
  16. end

Then in your user model you can do the following:

  1. # app/users/user_publisher.rb
  2. class User < ApplicationRecord
  3. after_create :publish_user
  4. # Example: publish multiple messages in one batch
  5. # Only available since v0.6.rc1
  6. #
  7. #
  8. # Set user status to 'active' without involving callbacks
  9. # then manually publish the new state of users in one batch
  10. def self.enable_all_users
  11. User.update_all(status: 'active', updated_at: Time.current)
  12. UserPublisher.publish_all(User.all)
  13. end
  14. private
  15. # Example: publish one message at a time
  16. #
  17. # Publish users after they have been created
  18. def publish_user
  19. UserPublisher.publish(self)
  20. end
  21. end

Publisher implementation layout

A full publisher implementation looks like this:

  1. class MyPublisher
  2. include Cloudenvoy::Publisher
  3. # The topic option defines the default topic messages will be
  4. # sent to. The publishing topic can be overriden on a per message
  5. # basis. See the #topic method below.
  6. cloudenvoy_options topic: 'my-topic'
  7. # Evaluate the topic at runtime based on publishing arguments.
  8. # Returning `nil` makes the publisher use the default topic
  9. # defined via cloudenvoy_options.
  10. #
  11. # Note: runtime topics do not get created by the rake tasks. You
  12. # must create them manually at this stage.
  13. def topic(arg1, arg2)
  14. arg1 == 'other' ? 'some-other-topic' : nil
  15. end
  16. # Attach pub/sub attributes to the message. Pub/sub attributes
  17. # can be used for message filtering.
  18. def metadata(arg1, arg2)
  19. { reference: "#{arg1}_#{arg2}" }
  20. end
  21. # Publishers must at least implement the `payload` method,
  22. # which specifies how arguments should be transformed into
  23. # a message payload (Hash or String).
  24. def payload(arg1, arg2)
  25. {
  26. foo: arg1,
  27. bar: arg2
  28. }
  29. end
  30. # This hook is invoked when the message fails to be formatted and published.
  31. # If something wrong happens in the methods above, this hook will be triggered.
  32. def on_error(error)
  33. logger.error("Oops! Something wrong happened!")
  34. end
  35. end

Receiving messages

After you have subscribed to a topic, Pub/Sub sends messages to your application via webhook on the /cloudenvoy/receive endpoint. Cloudenvoy then automatically dispatches the message to the right subscriber for processing.

Following up on the previous user publishing example, you might define the following subscriber in another Rails application:

  1. # app/subscribers/user_subscriber.rb
  2. class UserSubscriber
  3. include Cloudenvoy::Subscriber
  4. # Subscribers can subscribe to multiple topics
  5. #
  6. # You can subscribe to multiple topics:
  7. # > cloudenvoy_options topics: ['system-users', 'events']
  8. #
  9. # You can specify subscription options for each topic
  10. # by passing a hash (target: v0.2.0)
  11. #
  12. # > cloudenvoy_options topics: ['system-users', { name: 'events', retain_acked: true }]
  13. #
  14. # See the Pub/Sub documentation of the list of available subscription options:
  15. # https://googleapis.dev/ruby/google-cloud-pubsub/latest/Google/Cloud/PubSub/Topic.html#subscribe-instance_method
  16. #
  17. cloudenvoy_options topic: 'system-users'
  18. # Create the user locally if it does not exist already
  19. #
  20. # A message has the following attributes:
  21. # id: the pub/sub message id
  22. # payload: the content of the message (String or Hash)
  23. # metadata: the pub/sub message attributes
  24. # sub_uri: the pub/sub subscription URI
  25. # topic: the topic the message comes from
  26. #
  27. def process(message)
  28. payload = message.payload
  29. User.create_or_find_by(system_id: payload['id']) do |u|
  30. u.first_name = payload['name']
  31. u.email = payload['email']
  32. end
  33. end
  34. # This hook will be invoked if the message processing fails
  35. def on_error(error)
  36. logger.error("The following error happened: #{error}")
  37. end
  38. end

Logging

There are several options available to configure logging and logging context.

Configuring a logger

Cloudenvoy uses Rails.logger if Rails is available and falls back on a plain ruby logger Logger.new(STDOUT) if not.

It is also possible to configure your own logger. For example you can setup Cloudenvoy with semantic_logger by doing the following in your initializer:

  1. # config/initializers/cloudenvoy.rb
  2. Cloudenvoy.configure do |config|
  3. config.logger = SemanticLogger[Cloudenvoy]
  4. end

Logging context

Cloudenvoy provides publisher/subscriber contextual information to the logger methods.

For example:

  1. # app/subscribers/dummy_subscriber.rb
  2. class DummySubscriber
  3. include Cloudenvoy::Subscriber
  4. cloudenvoy_options topics: ['my-topic']
  5. def process(message)
  6. logger.info("Subscriber processed with #{message.inspect}. This is working!")
  7. end
  8. end

Will generate the following log with context {:id=>..., :metadata=>..., :topic=>...}

  1. [Cloudenvoy][DummySubscriber][1501678353930997] Subscriber processed with ###. This is working! -- {:id=>"1501678353930997", :metadata=>{"some"=>"meta"}, :topic=>"my-topic"}

The way contextual information is displayed depends on the logger itself. For example with semantic_logger contextual information might not appear in the log message but show up as payload data on the log entry itself (e.g. using the fluentd adapter).

Contextual information can be customised globally and locally using a log context_processor. By default the loggers are configured this way:

  1. # Publishers
  2. Cloudenvoy::PublisherLogger.log_context_processor = ->(publisher) { publisher.message&.to_h&.slice(:id, :metadata, :topic) || {} }
  3. # Subscribers
  4. Cloudenvoy::SubscriberLogger.log_context_processor = ->(subscriber) { subscriber.message.to_h.slice(:id, :metadata, :topic) }

You can decide to add a global identifier for your publisher logs using the following:

  1. # config/initializers/cloudenvoy.rb
  2. Cloudenvoy::PublisherLogger.log_context_processor = lambda { |publisher|
  3. publisher.message.to_h.slice(:id, :metadata, :topic).merge(app: 'my-app')
  4. }

You could also decide to log all available context - including the message payload - for specific subscribers only:

  1. # app/subscribers/full_context_subscriber.rb
  2. class FullContextSubscriber
  3. include Cloudenvoy::Subscriber
  4. cloudenvoy_options topics: ['my-topic'], log_context_processor: ->(s) { s.message.to_h }
  5. def process(message)
  6. logger.info("This log entry will have full context!")
  7. end
  8. end

See the Cloudenvoy::Publisher, Cloudenvoy::Subscriber and Cloudenvoy::Message for more information on attributes available to be logged in your log_context_processor proc.

Error Handling

Message failures will return an HTTP error to Pub/Sub and trigger a retry at a later time. By default Pub/Sub will retry sending the message until the acknowledgment deadline expires. A number of retries can be explicitly configured by setting up a dead-letter queue.

HTTP Error codes

When Cloudenvoy fails to process a message it returns the following HTTP error code to Pub/Sub, based on the actual reason:

Code Description
204 The message was processed successfully
404 The message subscriber does not exist.
422 An error occured during the processing of the message (process method)

Error callbacks

Publishers and subscribers can implement the on_error(error) callback to do things when a message fails to be published or received:

E.g.

  1. # app/publisher/handle_error_publisher.rb
  2. class HandleErrorPublisher
  3. include Cloudenvoy::Publisher
  4. cloudenvoy_options topic: 'my-topic'
  5. def payload(arg)
  6. raise(ArgumentError)
  7. end
  8. # The runtime error is passed as an argument.
  9. def on_error(error)
  10. logger.error("The following error occured: #{error}")
  11. end
  12. end

Testing

Cloudenvoy provides several options to test your publishers and subscribers.

Test helper setup

Require cloudenvoy/testing in your rails_helper.rb (Rspec Rails) or spec_helper.rb (Rspec) or test unit helper file then enable one of the two modes:

  1. require 'cloudenvoy/testing'
  2. # Mode 1 (default): Push messages to GCP Pub/Sub (env != development)
  3. Cloudenvoy::Testing.enable!
  4. # Mode 2: Push message to in-memory queues. You will be responsible for clearing the
  5. # topic queues using `Cloudenvoy::Testing.clear_all` or `Cloudenvoy::Testing.clear('my-topic')`
  6. Cloudenvoy::Testing.fake!

You can query the current testing mode with:

  1. Cloudenvoy::Testing.enabled?
  2. Cloudenvoy::Testing.fake?

Each testing mode accepts a block argument to temporarily switch to it:

  1. # Enable fake mode for all tests
  2. Cloudenvoy::Testing.fake!
  3. # Enable real mode temporarily for a given test
  4. Cloudenvoy.enable! do
  5. MyPublisher.publish(1,2)
  6. end

Note that extension middlewares - if any has been registered - run in test mode. You can disable middlewares in your tests by adding the following to your test helper:

  1. # Remove all middlewares
  2. Cloudenvoy.configure do |c|
  3. c.publisher_middleware.clear
  4. c.subscriber_middleware.clear
  5. end
  6. # Remove all specific middlewares
  7. Cloudenvoy.configure do |c|
  8. c.publisher_middleware.remove(MyMiddleware::Publisher)
  9. c.subscriber_middleware.remove(MyMiddleware::Subscriber)
  10. end

In-memory queues

The fake! modes uses in-memory queues for topics, which can be queried and controlled using the following methods:

  1. # Clear all messages across all topics
  2. Cloudenvoy::Testing.clear_all
  3. # Remove all messages in a given topic
  4. Cloudenvoy::Testing.clear('my-top')
  5. # Get all messages for a given topic
  6. Cloudenvoy::Testing.queue('my-top')

Unit tests

Below are examples of rspec tests. It is assumed that Cloudenvoy::Testing.fake! has been set in the test helper.

Example 1: Testing publishers

  1. describe 'message publishing'
  2. subject(:publish_message) { MyPublisher.publish(1,2) }
  3. let(:queue) { Cloudenvoy::Testing.queue('my-topic') }
  4. it { expect { publish_message }.to change(queue, :size).by(1) }
  5. it { is_expected.to have_attributes(payload: { 'foo' => 'bar' }) }
  6. end

Example 2: Testing subscribers

  1. describe 'message processing'
  2. subject { VerifyDataViaApiSubscriber.new(message: message).execute } }
  3. let(:message) { Cloudenvoy::Message.new(payload: { 'some' => 'payload' }) }
  4. before { expect(MyApi).to receive(:fetch).and_return([]) }
  5. it { is_expected.to be_truthy }
  6. end

Development

After checking out the repo, run bin/setup to install dependencies.

For tests, run rake to run the tests. Note that Rails is not in context by default, which means Rails-specific test will not run.
For tests including Rails-specific tests, run bundle exec appraisal rails-7.0 rake
For all context-specific tests (incl. Rails), run the appraisal tests using bundle exec appraisal rake.

You can run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install.

To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and tags, and push the .gem file to rubygems.org.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/keypup-io/cloudenvoy. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.

License

The gem is available as open source under the terms of the MIT License.

Code of Conduct

Everyone interacting in the Cloudenvoy project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.