Ruby 中的友好属性模式
Friendly attributes pattern in Ruby

原始链接: https://brunosutic.com/blog/ruby-friendly-attributes-pattern

## 友好的属性:一种用于简洁数据定义的 Ruby 模式 RailsBilling 的作者,一个用于 Stripe 集成的 Rails gem,详细介绍了一种名为“友好的属性”的新模式,它源于在定义订阅计划时重复代码的挫败感。 最初,创建具有不同名称、间隔和金额的计划需要多行代码,作者使用基于哈希的结构将此过程简化为更易读的一行:`Billing::Plan.find_or_create_all_by_attrs!(1.month => {standard: 10, pro: 50, enterprise: 100}, 1.year => {standard: 100, pro: 500, enterprise: 1000})`。 这种模式通过智能地将各种结构(哈希、数组、单个值)转换为标准的键值属性来简化数据输入。 类型用于推断含义——整数变为金额,符号变为名称,持续时间变为间隔。 它具有灵活性,允许以任何顺序排列参数,甚至允许部分定义(例如,`billing_plan :pro`)。 除了计划创建之外,“友好的属性”在测试和控制台交互中也证明了其用处。 虽然不适合像 JSON 这样的数据存储格式,但它在需要手动数据输入或代码简洁性的场景中表现出色,体现了 Ruby 专注于人类可读、令人愉悦的代码。 作者分享了一个将该模式应用于物联网应用程序访问控制的概念性示例,突出了其在计费之外的潜力。

Hacker News 新闻 | 过去 | 评论 | 提问 | 展示 | 招聘 | 提交 登录 Ruby 中友好的属性模式 (brunosutic.com) 12 分,作者 brunosutic,2 小时前 | 隐藏 | 过去 | 收藏 | 讨论 考虑申请 YC 2026 冬季批次!申请截止日期为 11 月 10 日 指南 | 常见问题 | 列表 | API | 安全 | 法律 | 申请 YC | 联系 搜索:
相关文章

原文

I run RailsBilling, a paid gem for fast Stripe subscription integrations with Rails.

During development I manually create a lot of subscription plans. Here's what creating standard, pro and enterprise plans with monthly and yearly intervals looks like:

Billing::Plan::Factory.find_or_create_by!(
  name: :standard,
  interval: 1.month,
  amount: 10
)
Billing::Plan::Factory.find_or_create_by!(
  name: :pro,
  interval: 1.month,
  amount: 50
)
Billing::Plan::Factory.find_or_create_by!(
  name: :enterprise,
  interval: 1.month,
  amount: 100
)
Billing::Plan::Factory.find_or_create_by!(
  name: :standard,
  interval: 1.year,
  amount: 100
)
Billing::Plan::Factory.find_or_create_by!(
  name: :pro,
  interval: 1.year,
  amount: 500
)
Billing::Plan::Factory.find_or_create_by!(
  name: :enterprise,
  interval: 1.year,
  amount: 1000
)

This code works well and has some nice properties, like being idempotent both locally and in Stripe. But there are some issues:

  • It feels bulky and eats more than half my laptop screen.
  • My customers usually start by creating plans, and kicking off with this doesn't exactly feel premium.
  • After typing, copying, and tweaking this dozens of times a day, day after day, it began to wear me down.

One day I snapped and decided this is the new way it should be done:

Billing::Plan.find_or_create_all_by_attrs!(
  1.month => {standard: 10, pro: 50, enterprise: 100},
  1.year => {standard: 100, pro: 500, enterprise: 1000}
)

This snippet does exactly the same thing as the previous example.

The new attribute schema is easier to type, easier to read, and uses far fewer lines of code. All attribute keys like :name, :interval, and :amount are considered redundant and were removed.

I'm calling this Friendly Attributes Pattern.

The new attributes also clearly model a mental image of a standard pricing page:

  • Interval toggle at the top.
  • Plan columns contain names, followed by prices.

Here's a screenshot of a standard pricing page for reference.

Standard pricing page

This post uses real, tested examples from RailsBilling. That said, the pattern isn't limited to billing subscriptions domain, and I share another example toward the end.

Use cases

After I got my Friendly Attributes example to work, new use cases popped up immediately.

I use it in tests to fetch existing plans:

billing_plans(1.month => [:standard, :pro, :enterprise])

I use it with minitest assertions, and although I'm an RSpec user, I must concede this reads really nice:

assert_billing_pricing_plans 1.month => [:standard, :pro, :enterprise]

Fetching a single plan in Rails console:

Billing::Plan.find_sole_by_attrs(:pro, 1.month)

Notice that the order of :pro and 1.month args follows the way it's said out loud: "pro monthly plan". It simply sounds more natural than "monthly pro plan".

However, I learned that in French they flip the order of "pro" and "monthly", and say "Mensuel Pro". Luckily, Friendly Attributes allows passing args in any order, so now I pride myself for knowing to code in French:

Billing::Plan.find_sole_by_attrs(1.month, :pro) 

And lastly, you can use just a single attribute. Here's the example test helper:

billing_plan :pro

It may seem unusual to put a standalone value :pro in the same bucket as an elaborate hash 1.month => {standard: 10, pro: 50, enterprise: 100}, and claim it's the same thing, same pattern. The next section clarifies this, and explains how it all works under the hood.

Implementation

Conversion

Friendly Attributes' job is to convert various structures (arrays, hashes, values) into standard, key-value attributes.

You pass it an input [:pro, 50, 1.month], and you get standard attrs on the output: {name: :pro, amount: 50, interval: 1.month}. This is all it does.

This output can conveniently be passed to various finder, query, or factory methods. But what you do with the output is a separate concern, and outside the scope of Friendly Attributes as a concept.

Here's the example interface:

FriendlyAttrs.new(attrs).resolve 

Types

The main idea behind Friendly Attributes is to use types to convert standalone values into regular attributes.

Each domain has its own rules. Here are the ones used for plan attributes from this post:

Integers are amounts
50 converts to {amount: 50}
Symbols or strings are plan names
:pro converts to {name: :pro}
ActiveSupport::Duration objects are intervals
1.month converts to {interval: 1.month}

Value lookup

You can go a step further and parse standalone strings or symbols for further distinction. For example, :usd can be recognized as a currency and converted to {currency: :usd}.

For my specific case, I decided not to use this. In 99% cases currency is configured globally and does not need to be specified.

So the value :usd resolves to {name: :usd} and becomes a plan name.

Mixing

You can mix Friendly Attributes with standard attributes.

[1.month, {name: :pro, amount: 50}]

This resolves to {interval: 1.month, name: :pro, amount: 50}.

For this to work you have to keep a list of known attribute names so it's clear that :amount is an attribute key, not a symbol representing plan name.

Superset

Friendly Attributes are a superset of standard attributes.

This hash {interval: 1.month, name: :pro, amount: 50} is a valid input, and it just returns the same value on the output.

This property keeps everything backward compatible. If Friendly Attributes approach doesn't click with you, you can still use all helper methods with standard attributes.

Object tree

At one point I had this code working:

[1.month, :standard, 10],
[1.month, :pro, 50],
[1.month, :enterprise, 100]

You see how 1.month keeps repeating? In order to reduce repetition I decided to use an object tree. The above set of arrays can now be written like this:

{1.month => {standard: 10, pro: 50, enterprise: 100}}

Here's a visualization of this hash as a tree:

      10         50        100         # leaves
      |          |          |
      |          |          |
  :standard    :pro    :enterprise
      |          |          |
       `---------+---------/
                 |
              1.month                  # root
      

The key to understanding how the hash relates to the tree is to read the hash from left to right, and the tree from the bottom up.

The next steps are:

  • Start from the leaf nodes.
  • For each leaf, follow its path up to the root and collect the values into an array.

Here's the result of that operation:

[10, :standard, 1.month],
[50, :pro, 1.month],
[100, :enterprise, 1.month]

So, we're back to a set of arrays, but this is now an internal state. The arrays are easily converted to standard attributes using previous guidelines.

Flexibility

Friendly Attributes allow for very flexible inputs. Here's an example where each input line produces the same output:

{1.month => {pro: 50}}
{50 => {pro: 1.month}
[1.month, :pro, 50]
[:pro, {1.month => 50}]

The choice of which line you use comes down to readability. And as we've seen, what you find most readable depends on whether you "think" in English, French, or maybe Greek.

Additionally, you don't always have to specify all the attributes. For example, input :pro converts to {name: :pro}. This is not "just theoretical", I use it actively, and I'm delighted every time I get to use billing_plan :pro in tests.

Going too far

You can also do some silly things:

billing_plan 50

This fetches a plan with attrs {amount: 50}. It's a valid example, but it's unusual and not recommended.

Here's another working example that only Yoda would find readable:

{{name: :standard} => {1.month => 10}}

Is flexibility good or bad?

In Ruby you can write beautiful code, and convoluted one-liners. The same applies to Friendly Attributes.

I believe the flexibility benefits outweigh the potential downsides. So let's follow Ruby's lead and aim to write elegant code.

Other use cases

Look, I genuinely like, and feel excited about this idea. But to be honest, I can't find many good examples that reap the benefits I'm describing here.

Friendly Attributes is a nice idea, but let's not force it into every app or model.

Example

This example is just a thought exercise. While all other snippets in this post are real, tested, and working, this one is purely conceptual.

I worked at an IoT company that builds smart door locks. The main part of their app handled who could access which door or floor, and when.

Here's how Friendly Attributes could be applied to this domain:

{
  "[email protected]": :entrance,      
  "[email protected]": [1, 2],           
  "[email protected]": :all,           
  "[email protected]": [1, :mailroom]   
  "[email protected]": {"9am-5pm": {[:mon, :tue, :wed, :thu, :fri] => :entrance}}
}

Here are the rough implementation guidelines:

Strings that match email regexp become user records
"[email protected]" converts to {user: User.find_or_create_by!(email: "[email protected]")}
Strings that match interval regexp become intervals
"9am-5pm" converts to {start_time: 9, end_time: 17}
Integers perform a floor lookup
1 converts to {access: account.floors.find_by!(number: 1)}
Symbol :all is a special case
:all converts to {access: account.accesses.all}
Symbols representing days are also special cases
:mon converts to {day: 1}
Non-special symbols perform a door lookup
:entrance converts to {door: account.doors.find_by!(name: :entrance)}

Use with JSON, YAML?

Building an API or storing data in a way that's based on Friendly Attributes Pattern is a bad idea.

Friendly Attributes are made primarily for humans. This pattern shines when you have to manually type in attributes, or you want to make a specific part of the code succinct and pretty.

The repetition of attributes and slight verbosity in popular data formats, like JSON or YAML is not a real problem for computers. If you really need something faster go for existing binary data formats like Protocol Buffers.

Conclusion

Friendly Attributes took me a couple hours to implement, and the results have been great! Hopefully this post gives you pointers and ideas if you encounter a similar problem in your work.

The idea is extracted from RailsBilling. Check it out, it's full of gems like this.

Friendly Attributes embodies the spirit of Ruby. It's about reading and writing joyful code - made for humans, typed by humans! If you ever get to use it, I hope you enjoy it as much as I do.

Happy hacking with Friendly Attributes!

联系我们 contact @ memedata.com