Skip to content

A .NET library for generating and working with Universally Unique Lexicographically Sortable Identifiers (ULIDs), designed to be globally unique, sortable, human-readable, and AoT compatible, making them ideal for use in distributed systems and databases.

License

Notifications You must be signed in to change notification settings

ByteAether/Ulid

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

59 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ULID from ByteAether

License NuGet Version NuGet Downloads GitHub Build Status GitHub Security

A high-performance, fully compliant .NET implementation of ULIDs (Universally Unique Lexicographically Sortable Identifiers), adhering to the official ULID specification.

Table of Contents

Introduction

ULIDs are universally unique, lexicographically sortable identifiers, ideal for distributed systems and time-ordered data due to their sortability and human-readability—advantages GUIDs lack. This library offers a robust, fully compliant .NET implementation, addressing limitations found in other ULID solutions.

This implementation addresses a potential OverflowException that can occur when generating multiple ULIDs within the same millisecond due to the "random" part overflowing. To ensure dependable, unique ULID generation, our solution increments the timestamp component upon random part overflow, eliminating such exceptions. This behavior aligns with discussions in ULID specification issue #39.

This library uniquely addresses the predictability of monotonic ULIDs generated within the same millisecond by allowing random increments to the random component. This mitigates enumeration attack vulnerabilities, as discussed in ULID specification issue #105. You can configure the random increment with a random value ranging from 1-byte (1–256) to 4-bytes (1–4,294,967,296), enhancing randomness while preserving lexicographical sortability.

For most modern systems, ULIDs offer a superior alternative to both GUIDs and integer IDs. While GUIDs provide uniqueness, they lack sortability and readability, impacting indexing and querying efficiency. Integer IDs are sortable but not universally unique, leading to potential conflicts in distributed environments. ULIDs combine universal uniqueness with lexicographical sortability, making them the optimal choice for scalable and efficient identifier generation in modern applications. This library provides a robust, reliable, and compliant ULID implementation, enabling your application to leverage these benefits without compromising performance or adherence to the official specification.

Features

.NET 10.0 .NET 9.0 .NET 8.0 .NET 7.0 .NET 6.0 .NET 5.0 .NET Standard 2.1 .NET Standard 2.0

  • Universally Unique: Ensures global uniqueness across systems.
  • Sortable: Lexicographically ordered for time-based sorting.
  • Fast and Efficient: Optimized for high performance and low memory usage.
  • Specification-Compliant: Fully adheres to the ULID specification.
  • Interoperable: Includes conversion methods to and from GUIDs, Crockford's Base32 strings, and byte arrays.
  • Ahead-of-Time (AoT) Compilation Compatible: Fully compatible with AoT compilation for improved startup performance and smaller binary sizes.
  • Error-Free Generation: Prevents OverflowException by incrementing the timestamp component when the random part overflows, ensuring continuous unique ULID generation.

These features collectively make ByteAether.Ulid a robust and efficient choice for managing unique identifiers in your .NET applications.

Installation

Install the latest stable package via NuGet:

dotnet add package ByteAether.Ulid

To install a specific preview version, use the --version option:

dotnet add package ByteAether.Ulid --version <VERSION_NUMBER>

Usage

Here is a basic example of how to use the ULID implementation:

using System;
using ByteAether.Ulid;

// Create a new ULID
var ulid = Ulid.New();

// Convert to byte array and back
byte[] byteArray = ulid.ToByteArray();
var ulidFromByteArray = Ulid.New(byteArray);

// Convert to GUID and back
Guid guid = ulid.ToGuid();
var ulidFromGuid = Ulid.New(guid);

// Convert to string and back
string ulidString = ulid.ToString();
var ulidFromString = Ulid.Parse(ulidString);

Console.WriteLine($"ULID: {ulid}, GUID: {guid}, String: {ulidString}");

Advanced Generation

You can customize ULID generation by providing GenerationOptions. This allows you to control monotonicity and the source of randomness.

Example: Monotonic ULID with Random Increments

To generate ULIDs that are monotonically increasing with a random increment, you can specify the Monotonicity option.

using System;
using ByteAether.Ulid;
using static ByteAether.Ulid.Ulid.GenerationOptions;

// Configure options for a 2-byte random increment
var options = new Ulid.GenerationOptions
{
	Monotonicity = MonotonicityOptions.MonotonicRandom2Byte
};

// Generate a ULID with the specified options
var ulid = Ulid.New(options);

Console.WriteLine($"ULID with random increment: {ulid}");

Example: Setting Default Generation Options

You can set default generation options for the entire application. This is useful for consistently applying specific behaviors, such as prioritizing performance over cryptographic security.

using System;
using ByteAether.Ulid;
using static ByteAether.Ulid.Ulid.GenerationOptions;

// Set default generation options for the entire application
Ulid.DefaultGenerationOptions = new()
{
	Monotonicity = MonotonicityOptions.MonotonicIncrement,
	InitialRandomSource = new PseudoRandomProvider(),
	IncrementRandomSource = new PseudoRandomProvider()
};

// Now, any subsequent call to Ulid.New() will use these options
var ulid = Ulid.New();

Console.WriteLine($"ULID from pseudo-random source: {ulid}");

API

The Ulid implementation provides the following properties and methods:

Creation

  • Ulid.New(GenerationOptions? options = null)
    Generates a new ULID using default generation options. Accepts an optional GenerationOptions parameter to customize the generation behavior.
  • Ulid.New(DateTimeOffset dateTimeOffset, GenerationOptions? options = null)
    Generates a new ULID using the specified DateTimeOffset and default generation options. Accepts an optional GenerationOptions parameter to customize the generation behavior.
  • Ulid.New(long timestamp, GenerationOptions? options = null)
    Generates a new ULID using the specified Unix timestamp in milliseconds (long) and default generation options. Accepts an optional GenerationOptions parameter to customize the generation behavior.
  • Ulid.New(DateTimeOffset dateTimeOffset, ReadOnlySpan<byte> random)
    Generates a new ULID using the specified DateTimeOffset and a pre-existing random byte array.
  • Ulid.New(long timestamp, ReadOnlySpan<byte> random)
    Generates a new ULID using the specified Unix timestamp in milliseconds (long) and a pre-existing random byte array.
  • Ulid.New(ReadOnlySpan<byte> bytes)
    Creates a ULID from an existing byte array.
  • Ulid.New(Guid guid)
    Create from existing Guid.

Checking Validity

  • Ulid.IsValid(string ulidString)
    Validates if the given string is a valid ULID.
  • Ulid.IsValid(ReadOnlySpan<char> ulidString)
    Validates if the given span of characters is a valid ULID.
  • Ulid.IsValid(ReadOnlySpan<byte> ulidBytes)
    Validates if the given byte array represents a valid ULID.

Parsing

  • Ulid.Parse(ReadOnlySpan<char> chars, IFormatProvider? provider = null)
    Parses a ULID from a character span in canonical format. The IFormatProvider is ignored.
  • Ulid.TryParse(ReadOnlySpan<char> s, IFormatProvider? provider, out Ulid result)
    Tries to parse a ULID from a character span in canonical format. Returns true if successful.
  • Ulid.Parse(string s, IFormatProvider? provider = null)
    Parses a ULID from a string in canonical format. The IFormatProvider is ignored.
  • Ulid.TryParse(string? s, IFormatProvider? provider, out Ulid result)
    Tries to parse a ULID from a string in canonical format. Returns true if successful.

Properties

  • Ulid.Empty
    Represents an empty ULID, equivalent to default(Ulid) and Ulid.New(new byte[16]).
  • Ulid.DefaultGenerationOptions
    Default configuration for ULID generation when no options are provided by the Ulid.New(...) call.
  • .Time
    Gets the timestamp component of the ULID as a DateTimeOffset.
  • .TimeBytes
    Gets the time component of the ULID as a ReadOnlySpan<byte>.
  • .Random
    Gets the random component of the ULID as a ReadOnlySpan<byte>.

Conversion Methods

  • .AsByteSpan()
    Provides a ReadOnlySpan<byte> representing the contents of the ULID.
  • .ToByteArray()
    Converts the ULID to a byte array.
  • .ToGuid()
    Converts the ULID to a Guid.
  • .ToString(string? format = null, IFormatProvider? formatProvider = null)
    Converts the ULID to a canonical string representation. Format arguments are ignored.

Comparison Operators

  • Supports all comparison operators:
    ==, !=, <, <=, >, >=.
  • Implements standard comparison and equality methods:
    CompareTo, Equals, GetHashCode.
  • Provides implicit operators to and from Guid.

GenerationOptions

The GenerationOptions class provides detailed configuration for ULID generation, with the following key properties:

  • Monotonicity
    Controls the behavior of ULID generation when multiple identifiers are created within the same millisecond. It determines whether ULIDs are strictly increasing or allow for random ordering within that millisecond. Available options include: NonMonotonic, MonotonicIncrement (default), MonotonicRandom1Byte, MonotonicRandom2Byte, MonotonicRandom3Byte, MonotonicRandom4Byte.

  • InitialRandomSource
    An IRandomProvider for generating the random bytes of a ULID. The default CryptographicallySecureRandomProvider ensures robust, unpredictable ULIDs using a cryptographically secure generator.

  • IncrementRandomSource
    An IRandomProvider that provides randomness for monotonic random increments. The default PseudoRandomProvider is a faster, non-cryptographically secure source optimized for this specific purpose.

This library comes with two default IRandomProvider implementations:

  • CryptographicallySecureRandomProvider
    Utilizes System.Security.Cryptography.RandomNumberGenerator for high-quality, cryptographically secure random data.
  • PseudoRandomProvider
    A faster, non-cryptographically secure option based on System.Random, ideal for performance-critical scenarios where cryptographic security is not required for random increments.

Custom IRandomProvider implementations can also be created.

Integration with Other Libraries

ASP.NET Core

Supports seamless integration as a route or query parameter with built-in TypeConverter.

System.Text.Json (.NET 5.0+)

Includes a JsonConverter for easy serialization and deserialization.

EF Core Integration

To use ULIDs as primary keys or properties in Entity Framework Core, you can create a custom ValueConverter to handle the conversion between Ulid and byte[]. Here's how to do it:

1. Create a custom ValueConverter to convert Ulid to byte[] and vice versa:

public class UlidToBytesConverter : ValueConverter<Ulid, byte[]>
{
	private static readonly ConverterMappingHints DefaultHints = new(size: 16);

	public UlidToBytesConverter() : this(defaultHints) { }

	public UlidToBytesConverter(ConverterMappingHints? mappingHints = null) : base(
		convertToProviderExpression: x => x.ToByteArray(),
		convertFromProviderExpression: x => Ulid.New(x),
		mappingHints: defaultHints.With(mappingHints)
	)
	{ }
}

2. Register the Converter in ConfigureConventions

Once the converter is created, you need to register it in your DbContext's ConfigureConventions virtual method to apply it to Ulid properties:

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
	// ...
	configurationBuilder
		.Properties<Ulid>()
		.HaveConversion<UlidToBytesConverter>();
	// ...
}

Dapper Integration

To use ULIDs with Dapper, you can create a custom TypeHandler to convert between Ulid and byte[]. Here's how to set it up:

1. Create the ULID Type Handler

using Dapper;
using System.Data;

public class UlidTypeHandler : SqlMapper.TypeHandler<Ulid>
{
	public override void SetValue(IDbDataParameter parameter, Ulid value)
	{
		parameter.Value = value.ToByteArray();
	}

	public override Ulid Parse(object value)
	{
		return Ulid.New((byte[])value);
	}
}

2. Register the Type Handler

After creating the UlidTypeHandler, you need to register it with Dapper. You can do this during application startup (e.g., in the Main method or ConfigureServices for ASP.NET Core).

Dapper.SqlMapper.AddTypeHandler(new UlidTypeHandler());

MessagePack Integration

To use ULIDs with MessagePack, you can create a custom MessagePackResolver to handle the serialization and deserialization of Ulid as byte[]. Here's how to set it up:

1. Create the Custom Formatter

First, create a custom formatter for Ulid to handle its conversion to and from byte[]:

using MessagePack;
using MessagePack.Formatters;

public class UlidFormatter : IMessagePackFormatter<Ulid>
{
	public Ulid Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
	{
		var bytes = reader.ReadByteArray();
		return Ulid.New(bytes);
	}

	public void Serialize(ref MessagePackWriter writer, Ulid value, MessagePackSerializerOptions options)
	{
		writer.Write(value.ToByteArray());
	}
}

2. Register the Formatter

Once the UlidFormatter is created, you need to register it with the MessagePackSerializer to handle the Ulid type.

MessagePack.Resolvers.CompositeResolver.Register(
	new IMessagePackFormatter[] { new UlidFormatter() },
	MessagePack.Resolvers.StandardResolver.GetFormatterWithVerify<Ulid>()
);

Alternatively, you can register the formatter globally when configuring MessagePack options:

MessagePackSerializer.DefaultOptions = MessagePackSerializer.DefaultOptions
	.WithResolver(MessagePack.Resolvers.CompositeResolver.Create(
		new IMessagePackFormatter[] { new UlidFormatter() },
		MessagePack.Resolvers.StandardResolver.Instance
	));

Newtonsoft.Json Integration

To use ULIDs with Newtonsoft.Json, you need to create a custom JsonConverter to handle the serialization and deserialization of ULID values. Here's how to set it up:

1. Create the Custom JsonConverter

First, create a custom JsonConverter for Ulid to serialize and deserialize it as a string:

using Newtonsoft.Json;
using System;

public class UlidJsonConverter : JsonConverter<Ulid>
{
	public override Ulid ReadJson(JsonReader reader, Type objectType, Ulid existingValue, bool hasExistingValue, JsonSerializer serializer)
	{
		var value = (string)reader.Value;
		return Ulid.Parse(value);
	}

	public override void WriteJson(JsonWriter writer, Ulid value, JsonSerializer serializer)
	{
		writer.WriteValue(value.ToString());
	}
}

2. Register the JsonConverter

Once the UlidJsonConverter is created, you need to register it with Newtonsoft.Json to handle Ulid serialization and deserialization. You can register the converter globally when configuring your JSON settings:

using Newtonsoft.Json;
using System.Collections.Generic;

JsonConvert.DefaultSettings = () => new JsonSerializerSettings
{
	Converters = new List<JsonConverter> { new UlidJsonConverter() }
};

Alternatively, you can specify the converter explicitly in individual serialization or deserialization calls:

var settings = new JsonSerializerSettings();
settings.Converters.Add(new UlidJsonConverter());

var json = JsonConvert.SerializeObject(myObject, settings);
var deserializedObject = JsonConvert.DeserializeObject<MyObject>(json, settings);

Benchmarking

Benchmarking was performed using BenchmarkDotNet to demonstrate the performance and efficiency of this ULID implementation. Comparisons include NetUlid 2.1.0, Ulid 1.4.1, NUlid 1.7.3, and Guid for overlapping functionalities like creation, parsing, and byte conversions.

Benchmark scenarios also include comparisons against Guid, where functionality overlaps, such as creation, parsing, and byte conversions.

Note:

  • ByteAetherUlidR1Bc & ByteAetherUlidR4Bc are configured to use a cryptographically secure random increment of 1 byte and 4 bytes, respectively, during monotonic ULID generation.
  • ByteAetherUlidR1Bp & ByteAetherUlidR4Bp are configured to use a pseudo-random increment of 1 byte and 4 bytes, respectively, during monotonic ULID generation.
  • ByteAetherUlidP is configured to use a pseudo-random source for the random component during non-monotonic ULID generation.

The following benchmarks were performed:

BenchmarkDotNet v0.15.7, Windows 10 (10.0.19044.6575/21H2/November2021Update)
AMD Ryzen 7 3700X 3.60GHz, 1 CPU, 12 logical and 6 physical cores
.NET SDK 10.0.100
  [Host]     : .NET 10.0.0 (10.0.0, 10.0.25.52411), X64 RyuJIT x86-64-v3
  DefaultJob : .NET 10.0.0 (10.0.0, 10.0.25.52411), X64 RyuJIT x86-64-v3

Job=DefaultJob

| Type            | Method             | Mean        | Error     | Gen0   | Allocated |
|---------------- |------------------- |------------:|----------:|-------:|----------:|
| Generate        | ByteAetherUlid     |  46.1867 ns | 0.0914 ns |      - |         - |
| Generate        | ByteAetherUlidR1Bp |  51.5996 ns | 0.1552 ns |      - |         - |
| Generate        | ByteAetherUlidR4Bp |  56.5170 ns | 0.1043 ns |      - |         - |
| Generate        | ByteAetherUlidR1Bc |  94.8500 ns | 0.2545 ns |      - |         - |
| Generate        | ByteAetherUlidR4Bc | 100.9761 ns | 0.3672 ns |      - |         - |
| Generate        | NetUlid *(1)       | 159.2965 ns | 1.3950 ns | 0.0095 |      80 B |
| Generate        | NUlid *(2)         |  49.2036 ns | 0.1911 ns |      - |         - |

| GenerateNonMono | ByteAetherUlid     |  91.3682 ns | 0.2455 ns |      - |         - |
| GenerateNonMono | ByteAetherUlidP    |  42.5785 ns | 0.1397 ns |      - |         - |
| GenerateNonMono | Ulid *(3,4)        |  39.1454 ns | 0.0491 ns |      - |         - |
| GenerateNonMono | NUlid              |  91.0387 ns | 0.2620 ns |      - |         - |
| GenerateNonMono | Guid *(5)          |  48.1872 ns | 0.1581 ns |      - |         - |
| GenerateNonMono | GuidV7 *(3,5)      |  77.2375 ns | 0.2567 ns |      - |         - |

| FromByteArray   | ByteAetherUlid     |   0.0302 ns | 0.0045 ns |      - |         - |
| FromByteArray   | NetUlid            |   0.7515 ns | 0.0083 ns |      - |         - |
| FromByteArray   | Ulid               |   0.0263 ns | 0.0036 ns |      - |         - |
| FromByteArray   | NUlid              |   0.0439 ns | 0.0130 ns |      - |         - |
| FromByteArray   | Guid               |   0.0397 ns | 0.0131 ns |      - |         - |

| FromGuid        | ByteAetherUlid     |   0.0533 ns | 0.0216 ns |      - |         - |
| FromGuid        | NetUlid            |   3.2410 ns | 0.0616 ns |      - |         - |
| FromGuid        | Ulid               |   1.7144 ns | 0.0092 ns |      - |         - |
| FromGuid        | NUlid              |   0.5250 ns | 0.0085 ns |      - |         - |

| FromString      | ByteAetherUlid     |  14.7203 ns | 0.0557 ns |      - |         - |
| FromString      | NetUlid            |  26.7763 ns | 0.1008 ns |      - |         - |
| FromString      | Ulid               |  14.6891 ns | 0.0547 ns |      - |         - |
| FromString      | NUlid              |  51.7824 ns | 0.1617 ns | 0.0086 |      72 B |
| FromString      | Guid               |  20.0958 ns | 0.1317 ns |      - |         - |

| ToByteArray     | ByteAetherUlid     |   4.1367 ns | 0.1012 ns | 0.0048 |      40 B |
| ToByteArray     | NetUlid            |   9.4660 ns | 0.1082 ns | 0.0048 |      40 B |
| ToByteArray     | Ulid               |   4.0211 ns | 0.0809 ns | 0.0048 |      40 B |
| ToByteArray     | NUlid              |   4.2021 ns | 0.0903 ns | 0.0048 |      40 B |

| ToGuid          | ByteAetherUlid     |   0.2625 ns | 0.0051 ns |      - |         - |
| ToGuid          | NetUlid            |  10.3348 ns | 0.0326 ns |      - |         - |
| ToGuid          | Ulid               |   0.7462 ns | 0.0117 ns |      - |         - |
| ToGuid          | NUlid              |   0.2691 ns | 0.0070 ns |      - |         - |

| ToString        | ByteAetherUlid     |  12.2227 ns | 0.2543 ns | 0.0096 |      80 B |
| ToString        | NetUlid            |  23.7706 ns | 0.2422 ns | 0.0095 |      80 B |
| ToString        | Ulid               |  11.1126 ns | 0.2121 ns | 0.0096 |      80 B |
| ToString        | NUlid              |  28.9672 ns | 0.1506 ns | 0.0095 |      80 B |
| ToString        | Guid               |   7.2446 ns | 0.0341 ns | 0.0115 |      96 B |

| CompareTo       | ByteAetherUlid     |   0.0007 ns | 0.0022 ns |      - |         - |
| CompareTo       | NetUlid            |   3.6812 ns | 0.0298 ns |      - |         - |
| CompareTo       | Ulid               |   0.0002 ns | 0.0006 ns |      - |         - |
| CompareTo       | NUlid              |   0.4122 ns | 0.0062 ns |      - |         - |

| Equals          | ByteAetherUlid     |   0.0016 ns | 0.0028 ns |      - |         - |
| Equals          | NetUlid            |   1.0102 ns | 0.0059 ns |      - |         - |
| Equals          | Ulid               |   0.0011 ns | 0.0020 ns |      - |         - |
| Equals          | NUlid              |   0.0000 ns | 0.0000 ns |      - |         - |
| Equals          | Guid               |   0.0010 ns | 0.0023 ns |      - |         - |

| GetHashCode     | ByteAetherUlid     |   0.0008 ns | 0.0018 ns |      - |         - |
| GetHashCode     | NetUlid            |   9.8270 ns | 0.0271 ns |      - |         - |
| GetHashCode     | Ulid               |   0.0032 ns | 0.0032 ns |      - |         - |
| GetHashCode     | NUlid              |   5.7843 ns | 0.0235 ns |      - |         - |
| GetHashCode     | Guid               |   0.0016 ns | 0.0028 ns |      - |         - |

Existing competitive libraries exhibit various deviations from the official ULID specification or present drawbacks:

  1. NetUlid: Only supports monotonicity within a single thread.
  2. NUlid: Requires custom wrappers and state management for monotonic generation.
  3. Ulid & GuidV7: Do not implement monotonicity.
  4. Ulid: Utilizes a cryptographically non-secure XOR-Shift for random value generation, with only the initial seed being cryptographically secure.
  5. Guid & GuidV7: The Guid documentation explicitly states that its random component may not be generated using a cryptographically secure random number generator (RNG), and that Guid values should not be used for cryptographic purposes.

Furthermore, both NetUlid and NUlid, despite offering monotonicity, are susceptible to OverflowException due to random-part overflow.

This implementation demonstrates performance comparable to or exceeding its closest competitors. Crucially, it provides the most complete adherence to the official ULID specification, ensuring superior reliability and robustness for your applications compared to other libraries.

Prior Art

Much of this implementation is either based on or inspired by existing works. This library is standing on the shoulders of giants.

Contributing

We welcome all contributions! You can:

  • Open a Pull Request: Fork the repository, create a branch, make your changes, and submit a pull request to the main branch.
  • Report Issues: Found a bug or have a suggestion? Open an issue with details.

Thank you for helping improve the project!

License

This project is licensed under the MIT License. See the LICENSE file for details.

About

A .NET library for generating and working with Universally Unique Lexicographically Sortable Identifiers (ULIDs), designed to be globally unique, sortable, human-readable, and AoT compatible, making them ideal for use in distributed systems and databases.

Topics

Resources

License

Code of conduct

Stars

Watchers

Forks

Contributors 3

  •  
  •  
  •  

Languages