C#でのMongoDB操作
C#のMongoDB Driverを使ってDB操作をしています。
その際に得られた知見を残しておきます。
可視化ツールのMetabaseでMongoDBクエリを書くもよかったらみてください。
概要
今のところ私としては次の方針でコードを書いています。
- BsonDocumentよりクラスにマッピングして使用する
- 他プログラムのドキュメントなら、未定義のフィールドに注意する
-
[BsonIgnoreExtraElements]
で無視したり -
[BsonExtraElements]
を使用して受け取ったり
-
- 自プログラムのドキュメントでも、部分取得するときは
[BsonIgnoreExtraElements]
を使って専用のクラスを作る
- 他プログラムのドキュメントなら、未定義のフィールドに注意する
- デフォルト値はBSON/JSONにシリアライズしない
- そのために
BsonIgnoreIfStaticAttribute
、DefaultValueIsStaticAttribute
を作成
- そのために
- できるだけnull許容型を使わない
- そのために
BsonStringFromMayBeNullSerializer
、BsonDateTimeFromMayBeNullSerializer
など(intやdoubleも)を作成 - クラス型にも作りたかったが断念して、
private
メンバを作って回避
- そのために
- MongoDBとの通信回数を減らして高速化
- そのために
BulkWriteAsync
でまとめて更新する(順番保証なしのIsOrdered = false
を指定) - ただし挿入は順番保証したいので
InsertManyAsync
で先に挿入しておく
- そのために
基本の操作
// 接続(本当はパスワードを生で持ちたくないところです)
var mongoClient = new MongoCLient("mongodb://username:password@localhost:27017");
// 取得(クラスにマッピング)
using var reportModels = await mongoClient
.GetDatabase("DatabaseName")
.GetCollection<ReportModel>("CollectionName")
.FindAsync(cancellationToken: cancellationToken);
// C#のリストに変換
var reports = await reportModels.ToListAsync(cancellationToken: cancellationToken);
// 書き込み(クラスにマッピング)
var report = reports.First();
report.status = "Resolved";
await mongoClient
.GetDatabase("DatabaseName")
.GetCollection<ReportModel>("CollectionName")
.UpdateOneAsync(
Builders<ReportModel>.Filter.Eq(nameof(ReportModel._id), report._id),
report,
cancellationToken: cancellationToken
);
取得のあれこれ
public abstract Task<IAsyncCursor<TProjection>> FindAsync<TProjection>(
FilterDefinition<TDocument> filter,
FindOptions<TDocument, TProjection> options = null,
CancellationToken cancellationToken = null
)
filter
filter
はBuilders<TDocument>.Filter...
で指定できます。 指定できるものは、FilterDefinitionBuilder Class [document]に書かれています。
私が良く使った例をあげると、
// フィールドは文字列で指定もできますが、タイポやリファクタリングのしやすさからnameof()を利用しています。
// == 判定
Builders<TDocument>.Filter.Eq(nameof(TDocument._id), id);
// Contains 判定 ({a, b, c}.Contains(TDocument._id))
var checkArray = {a, b, c};
Builders<TDocument>.Filter.In(nameof(TDocument._id), checkArray);
// Not Contains 判定
Builders<TDocument>.Filter.Nin(nameof(TDocument._id), checkArray);
// フィールド存在 判定
Builders<TDocument>.Filter.Exists(nameof(TDocument.status));
// Not 判定
Builders<TDocument>.Filter.Not(
Builders<TDocument>.Filter.Eq(nameof(TDocument._id), id)
);
// <= 判定
Builders<TDocument>.Filter.Gte(nameof(TDocument.timestamp), timestamp);
// && 判定
Builders<TDocument>.Filter.And(
Builders<TDocument>.Filter.Exists(nameof(TDocument.status)),
Builders<TDocument>.Filter.Eq(nameof(TDocument.type), "Root")
);
// OR 判定
Builders<TDocument>.Filter.Or(
Builders<TDocument>.Filter.Eq(nameof(TDocument._id), id),
Builders<TDocument>.Filter.Eq(nameof(TDocument.type), "Root"),
Builders<TDocument>.Filter.Eq(nameof(TDocument.status), "Unresolved")
)
JSON filter
filter
はJSONでの指定もできます。
指定できるものはQuery and Projection Operators [document]に書かれています。
var filters = new Dictionary<string, dynamic> { // キーは文字列、値はなんでもあり
{ nameof(ReportModel.type), "Root" },
{ nameof(ReportModel.status), "Unresolved" } // 複数指定した場合は$and相当
{ nameof(ReportModel.timestamp),
new Dictionary<string, dynamic> { "$gte",
// 日付は$dateだし、ObjectIdは$oidを使う必要がある
new Dictionary<string, dynamic> { "$date", DateTime.UtcNow.Date.AddDay(-7) }
}
}
};
var json = JsonConvert.SerializeObject(filters); // Newtonsoft.Jsonを使用しています
var queryFilter = new JsonFilterDefinition<ReportModel>(json); // JSONからfilterを生成
IASyncEnumerable での取得
データが少なければ、まとめて取得してしまう方がシンプルですが大量のデータがある場合はそうもいきません。
その場合IAsyncEnumerable Interface [document]で返すと手軽に非同期逐次処理ができて便利です。
public async IASyncEnumerable<ReportModel> GetAllAsync(
[EnumeratorCancellation] CancellationToken cancellationToken = default // 非同期キャンセル機構
)
{
using var cursor = await m_mongoClient
.GetDatabase("DatabaseName")
.GetCollection<ReportModel>("CollectionName")
.Find() // ここはAsyncではない
.ToCursorAsync(cancellationToken); // ここでAsync
while (await cursor.MoveNextAsync(cancellationToken)) // 次がある間繰り返す
{
foreach (var report in cursor.Current) // ある程度まとめて取得されるので、2重ループ
{
yield return report; // yieldで返す
}
}
}
//使う側
await foreach (var report in GetAllAsync(cancellationToken)) // forにawaitが付く
{
//...
}
SkipとLimit指定
データの一部を取得したときは、Skip
とLimit
を使用します。
通常は_id
順(だと思う)ですが、必要なら先に順番を並び替える必要があります。
Sortは使ったことないけど、[stackoverlow]によると、SetSortOrder
、SortBy
、Builders<TDocument>.Sort...
などの指定があるようです。
using var cursor = await m_mongoClient
.GetDatabase("DatabaseName")
.GetCollection<ReportModel>("CollectionName")
.Find()
//.SortBy(d => d.timestamp)
.Skip(skipOffset) // スキップ
.Limit(limitSize) // リミット
.ToCursorAsync(cancellationToken);
一部フィールドのみ取得
ドキュメントが大きい時は処理に必要なフィールドだけ取得したい事があります。
私は次の2つの方法を使ってます。
通常はパターン化されているので、必要なフィールドだけ取り出した小さいクラスを定義しています。
稀にユーザー操作など動的に変えたかったりクラス定義が面倒な時はProjectを使用してBsonDocumentでの取得を使います。
// 必要なフィールだけ取り出した小さいクラスを定義
using var cursor = await m_mongoClient
.GetDatabase("DatabaseName")
// ReportModelを小さくしたReportCacheを使用
.GetCollection<ReportCache>("CollectionName")
.Find()
.Skip(skipOffset)
.Limit(limitSize)
.ToCursorAsync(cancellationToken);
// Projectの使用 (BsonDocumentでの取得)
// 文字列などから必要なフィールドリストを作成して
var fields = { nameof(ReportModel._id), nameof(ReportModel.type), nameof(ReportModel.status) };
var projection = Builders<ReportModel>.Projection.Combine( // Combineで組み合わせる
// SelectでフィールドリストをProjectionに変換
fields.Select(s => Builders<ReportModel>.Projection.Include(s))
);
using var cursor = await m_mongoClient
.GetDatabase("DatabaseName")
.GetCollection<ReportModel>("CollectionName")
.Find()
.Skip(skipOffset)
.Limit(limitSize)
// Projectでフィールド指定し、結果をBsonDocumentで取得
.Project<BsonDocument>(projection)
.ToCursorAsync(cancellationToken);
書き込みのあれこれ
書き込みにはよく次の関数を使用しています。特にBulkWriteAsyncが万能です。
InsertOneAsync: 1つ挿入
InsertOneAsync Method [document]
Task InsertOneAsync(
TDocument document,
InsertOneOptions options = null,
CancellationToken cancellationToken = null
)
たまにしか挿入しないときに使用。
// 挿入したいドキュメントを作って
var report = new ReportModel {
_id = ObjectId.GenerateNewId(),
status = "Unresolved",
timestamp = DateTime.NowUtc,
type = "Root"
}
// 投げるだけ
await mongoClient
.GetDatabase("DatabaseName")
.GetCollection<ReportModel>("CollectionName")
.InsertOneAsync(
report,
cancellationToken: cancellationToken
);
InsertManyAsync: 複数同時に挿入
InsertManyAsync Method [document]
Task InsertManyAsync(
IEnumerable<TDocument> documents,
InsertManyOptions options = null,
CancellationToken cancellationToken = null
)
複数まとめて挿入したいときに使用。
// 挿入したいドキュメントが複数ある場合
var reports = new List<ReportModel> {
new() {
_id = ObjectId.GenerateNewId(),
status = "Unresolved",
timestamp = DateTime.NowUtc,
type = "Root"
},
new() {
_id = ObjectId.GenerateNewId(),
status = "Unresolved",
timestamp = DateTime.NowUtc,
type = "Leaf"
}
}
// まとめて送って通信回数を減らす
await mongoClient
.GetDatabase("DatabaseName")
.GetCollection<ReportModel>("CollectionName")
.InsertManyAsync(
reports,
cancellationToken: cancellationToken
);
UpdateOneAsync: 1つのデータを更新
UpdateOneAsync Method [document]
Task<UpdateResult> UpdateOneAsync(
FilterDefinition<TDocument> filter,
UpdateDefinition<TDocument> update,
UpdateOptions options = null,
CancellationToken cancellationToken = null
)
たまにしか更新しないときに使用。
条件に一致するものが複数ある場合でも最初の一つのみ更新されるので注意が必要。
report.status = "Resolved";
var opts = new UpdateOptions {
IsUpsert = true // 存在しなかったら挿入として扱うときに指定
};
await mongoClient
.GetDatabase("DatabaseName")
.GetCollection<ReportModel>("CollectionName")
.UpdateOneAsync(
// 通常は_idで一致判定する
Builders<ReportModel>.Filter.Eq(nameof(ReportModel._id), report._id),
report,
opts,
cancellationToken: cancellationToken
);
UpdateManyAsync: 1つのFilterで複数のデータを更新
UpdateManyAsync Method [document]
Task<UpdateResult> UpdateManyAsync(
FilterDefinition<TDocument> filter,
UpdateDefinition<TDocument> update,
UpdateOptions options = null,
CancellationToken cancellationToken = null
)
同じ更新を複数に適用したいときに使用。
一つの条件しか指定できないので使い所は少ないかも。
await mongoClient
.GetDatabase("DatabaseName")
.GetCollection<ReportModel>("CollectionName")
.UpdateOneAsync(
// 複数に一致する条件
Builders<ReportModel>.Filter.Lt(nameof(ReportModel.timestamp), DateTime.UtcNow.Date.AddDay(-7)),
// 一致する全てに同じ更新を行う
Builders<ReportModel>.Update.Set(nameof(ReportModel.status), "Resolved"),
cancellationToken: cancellationToken
);
BulkWriteAsync: FilterとUpdateの組み合わせを複数同時に書き込み
BulkWriteAsync Method [document]
Task<BulkWriteResult<TDocument>> BulkWriteAsync(
IEnumerable<WriteModel<TDocument>> requests,
BulkWriteOptions options = null,
CancellationToken cancellationToken = null
)
複数の更新をまとめたいときに使用。
// 条件と更新内容の組み合わせリスト
var requests = new List<WriteModel<ReportModel>>();
requests.Add(
// 挿入したり
new InsertOneModel<ReportModel>(
report[0]
)
)
requests.Add(
// 一つだけ更新したり
new UpdateOneModel<ReportModel>(
Builders<ReportModel>.Filter.Eq(nameof(ReportModel._id), report[1]._id),
report[1]
) {
IsUpsert = true // 存在しなかったら挿入として扱うときに指定
}
);
requests.Add(
// 複数に同じ更新したり
new UpdateManyModel<ReportModel>(
Builders<ReportModel>.Filter.Lt(nameof(ReportModel.timestamp), DateTime.UtcNow.Date.AddDay(-7)),
Builders<ReportModel>.Update.Set(nameof(ReportModel.status), "Resolved"),
)
)
var opts = new BulkWriteOptions {
IsOrdered = false // 順番はどうでもいいときに指定
}
await mongoClient
.GetDatabase("DatabaseName")
.GetCollection<ReportModel>("CollectionName")
.BulkWriteAsync(
requests,
opts,
cancellationToken: cancellationToken
);
update
update
はBuilders<TDocument>.Update...
で指定できます。指定できるものは、UpdateDefinitionBuilder Class [document]に書かれています。
私が良く使った例をあげると、
// フィールドは文字列で指定もできますが、タイポやリファクタリングのしやすさからnameof()を利用しています。
// 代入
var updates = Builders<TDocument>.Update.Set(nameof(TDocument.status), "Resolved");
// 加算(BulkWriteを使って同じフィールドに2重で更新することもあるならSetよりIncの方が安全)
updates = update.Inc(nameof(TDocument.count), 1);
// 削除(デフォルト値は省略するなどフィールドを消す時に使用)
updates = update.Unset(nameof(TDocument.count));
JSON update
update
はJSONでの指定もできます。指定できるものはUpdate Operators [document]に書かれています。
var updates = new Dictionary<string, dynamic> {
{ "$set",
// 同じOperatorのものはまとめて指定する
new Dictionary<string, dynamic> {
{ nameof(ReportModel.status), "Unresolved" }
{ nameof(ReportModel.timestamp),
new Dictionary<string, dynamic> { "$date", DateTime.UtcNow }
}
}
},
{ "$inc", new Dictionary<string, dynamic> {
{ nameof(ReportModel.count), 1 }
}
};
var json = JsonConvert.SerializeObject(update); // Newtonsoft.Jsonを使用しています
var queryUpdate = new JsonUpdateDefinition<ReportModel>(json); // JSONからupdateを生成
クラス定義のフィールドアトリビュートあれこれ
データモデルのクラスを定義するときにアトリビュートでフィールドの情報を付与します。
Serialization Tutorial [document]の情報が役立つでしょう。
[BsonIgnoreExtraElements] // 未定義のフィールドを無視したい場合に指定する(これがないと無視せずにエラーになる)
public class ReportModel
{
public static readonly ReportModel Empty = new(); // staticはBSONのフィールドにはならない
[BsonId] // BSONのIDであることを指定
[BsonRequired] // 必須のフィールドであることを指定
[JsonSchemaType(typeof(string))] // これはBSONでなく、JSONシリアライズ時に使用する型を指定
public ObjectId _id { get; init; } // BsonIdは_idとアンダースコア付きだと、クエリが楽になる
// idだとJSONクエリの書き方が分からなかった、だったかな?
[BsonIgnoreIfDefault] // デフォルト値の場合にBSONにシリアライズせず省略する時に指定
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] // JSONシリアライズ時にデフォルト値を省略する時に指定
public int count { get; init; }
[BsonIgnoreIfStatic("Empty")] // 自作アトリビュートで、デフォルト値にクラスの静的変数を指定
[DefaultValueIsStatic("Empty", typeof(string))] // 自作アトリビュートで、[DefaultValue]の静的変数指定版
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
[BsonSerializer(typeof(BsonStringFromMayBeNullSerializer))] // 自作シリアライザで、nullが来たらデフォルト値として扱う
public string type { get; init; } = string.Empty; // null許容型?を使わないときは初期値が必要
// なるべくnull判定しなくていいようにしたいので、アトリビュートを自作している
[BsonIgnoreIfStatic("StatsList", typeof(EmptyHolder))] // string以外にも、自作クラスもデフォルト値を使えるようにした
[DefaultValueIsStatic("StatsList", typeof(EmptyHolder))]
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
public IReadOnlyCollection<ReportStats> weekly_stats {
get = _weekly_stats;
init => _weekly_stats = value ?? EmptyHolder.StatsList; // null回避(BsonClassFromMayBeNullSerializerの自作を諦めた形跡あり)
}
private IReadOnlyCollection<ReportStats> _weekly_stats = EmptyHolder.StatsList; // privateはBSONのフィールドにならない
}
public class ReportStats
{
public static readonly ReportStats Empty = new();
[BsonIgnoreIfStatic("MinValue")]
[DefaultValueIfStatic("MinValue"), typeof(string)] // JSONでは文字列で保持するのでstring型指定
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
[BsonDateTimeOption(Kind = DateTimeKind.UTC, DateOnly = true)] // タイムゾーンを指定と時間を消し日付のみにする
[JsonConverter(typeof(DateFormatConverter), "yyyy-MM-dd")] // 自作コンバーターで日付のみにする
[BsonSerializer(typeof(BsonDateTimeFromMayBeNullSerializer))]
public DateTime changed_date { get; init; } = DateTime.MinValue;
[BsonIgnoreIfStatic("Empty")]
[DefaultValueIsStatic("Empty", typeof(string))]
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
[BsonSerializer(typeof(BsonStringFromMayBeNullSerializer))]
public string status { get; init; } = string.Empty;
[BsonExtraElements] // 未指定のフィールドを受け取るものに指定(BsonDocument?型)
[BsonIgnoreIfDefault]
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
[JsonConverter(typeof(BsonDocumentToJsonConverter))] // BsonDocumentをJSONにシリアライズするとエラーが出たのでコンバーター自作
[JsonSchemaIgnore] // JSONにシリアライズする時に含めない
public BsonDocument? CatchAll { get; init; }
}
public static class EmptyHolder
{
// デフォルト値として使用するので、値が変更されないようにIReadOnlyを使用
public static readonly IReadOnlyCollection<ReportStats> StatsList = new List<ReportStats>();
// Dictionary版
public static readonly IReadOnlyDictionary<string, int> Hoge = new Dictionary<string, int>();
}
// Bsonシリアライズ時に静的変数をデフォルト値とみなし無視する
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
public class BsonIgnoreIfStaticAttribute : Attribute, IBsonMemberMapAttribute
{
public string m_memberName { get; }
public Type? m_memberContainingType { get; }
public BsonIgnoreIfStaticAttribute(string member, Type? type = null)
{
m_memberName = member
m_memberContainingType = type;
}
public void Apply(BsonMemberMap memberMap)
{
var type = m_memberContainingType ?? memberMap.MemberType;
var defaultValue = type.GetField(m_memberName)?.GetValue(null);
if (defaultValue == null)
{
defaultValue = type.GetProperty(m_memberName)?.GetValue(null);
}
if (defaultValue != null)
{
memberMap.SetDefaultValue(defaultValue);
if (memberMap.MemberInfo.MemberType == MemberTypes.Property)
{
var pi = memberMap.ClassMap.ClassType.GetProperty(memberMap.ElementName);
if (pi != null)
{
memberMap.SetShouldSerializeMethod(obj => !defaultValue.Equals(pi.GetValue(obj)));
}
}
else
{
var fi = memberMap.ClassMap.ClassType.GetField(memberMap.ElementName);
if (fi != null)
{
memberMap.SetShouldSerializerMethod(obj => !defaultValue.Equals(fi.GetValue(obj)));
}
}
}
else
{
memberMap.SetIgnoreIfNull(true);
}
}
}
// Jsonシリアライズ時にデフォルト値とみなし無視するために、静的変数ををデフォルト値に設定
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
public class DefaultValueIsStaticAttribute : DefaultValueAttribute
{
public DefaultValueIsStaticAttribute(string member, Type type)
{
var defaultValue = type.GetField(member)?.GetValue(null);
if (defaultValue == null)
{
defaultValue = type.GetProperty(member)?.GetValue(null);
}
if (defaultValue != null)
{
SetValue(defaultValue);
}
}
}
// BsonDocumentをNewtonsoft.Jsonにシリアライズするコンバーター、なぜか標準のだとエラーが出たので自作。
public class BsonDocumentToJsonConverter : JsonConverter<BsonDocument>
{
public override BsonDocument ReadJson(JsonReader reader,
Type objectType,
[AllowNull] BsonDocument existingValue,
JsonSerializer serializer)
{
return BsonDocument.Parse(reader.ReadAsString());
}
public override void WriteJson(JsonWriter writer,
[AllowNull] BsonDocument value,
JsonSerializer serializer)
{
if (value != null)
{
writer.WriteStartObject();
var json = value.ToJson();
writer.WriteRaw(json[1..^1]); // 先頭の`{`と末尾の`}`を除く
writer.WriteEndObject();
}
}
}
// Jsonシリアライズ時に時間を消して日付のみにする
public class DateFormatConverter : IsoDateTimeConverter
{
public DateFormatConverter(string format)
{
DateTimeFormat = format;
}
}
// Bsonデシアライズ時にnullを空文字にするシリアライザ
public class BsonStringFromMayBeNullSerializer : StringSerializer
{
public override string Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args)
{
var type = context.Reader.GetCurrentBsonType();
if (type == BsonType.String)
{
return context.Reader.ReadString();
}
else if (type == BsonType.Null)
{
context.Reader.ReadNull();
return string.Empty;
}
return base.Deserialize(context, args);
}
}
// Bsonでシリアライズ時にnullをDateTime.MinValueにするシリアライザ
public class BsonDateTimeFromMayBeNullSerializer : DateTimeSerializer
{
public override DateTime Deserialize(BsonDeserializationContext context, BsonDeserializationASrgs args)
{
var type = context.Reader.GetCurrentBsonType();
if (type == BsonType.DateTime)
{
return DateTimeOffset.FromUnixTimeMilliseconds(context.Reader.ReadDateTime()).DateTime;
}
else if (type == BsonType.Null)
{
context.Reader.ReadNull();
return DateTime.MinValue;
}
return base.Deserialize(context, args);
}
}
// Bsonでシリアライズ時にnullを指定クラスにするシリアライザ(を作りたかった形跡。)
public class BsonClassFromMayBeNullSerializer<TValue> : ClassSerializerBase<TValue> where TValue : class, new()
{
public TValue m_defaultValue { get; init; }
private Dictionary<string, PropertyInfo> propertyMap { get; init; }
public BsonClassFromMeyBeNullSerializer()
{
var member = "Empty"; // TODO: Any member support
var type = typeof(TValue);
var defaultValue = type.GetField(member)?.GetValue(null);
if (defaultValue == null)
{
defaultValue = type.GetProperty(member)?.GetValue(null);
}
if (defaultValue == null)
{
throw new NullReferenceException();
}
m_defaultValue = (TValue)defaultValue;
propertyMap = new Dictionary<string, PropertyInfo>();
foreach (var pi in type.GetProperties(BindingFlags.Public))
{
propertyMap[pi.Name] = pi; // TODO: Any json property name support
}
}
}
まとめ
今のところ私としては次の方針でコードを書いています。
- BsonDocumentよりクラスにマッピングして使用する
- 他プログラムのドキュメントなら、未定義のフィールドに注意する
-
[BsonIgnoreExtraElements]
で無視したり -
[BsonExtraElements]
を使用して受け取ったり
-
- 自プログラムのドキュメントでも、部分取得するときは
[BsonIgnoreExtraElements]
を使って専用のクラスを作る
- 他プログラムのドキュメントなら、未定義のフィールドに注意する
- デフォルト値はBSON/JSONにシリアライズしない
- そのために
BsonIgnoreIfStaticAttribute
、DefaultValueIsStaticAttribute
を作成
- そのために
- できるだけnull許容型を使わない
- そのために
BsonStringFromMayBeNullSerializer
、BsonDateTimeFromMayBeNullSerializer
など(intやdoubleも)を作成 - クラス型にも作りたかったが断念して、
private
メンバを作って回避
- そのために
- MongoDBとの通信回数を減らして高速化
- そのために
BulkWriteAsync
でまとめて更新する(順番保証なしのIsOrdered = false
を指定) - ただし挿入は順番保証したいので
InsertManyAsync
で先に挿入しておく
- そのために
Discussion