云同步程序开发围绕Placeholder进行的!
这个微软官方定义占位符文件
生成支持占位符文件的云同步引擎 - Win32 apps | Microsoft Learn
- 同步引擎可以创建只占用 1 KB 存储空间用于文件系统标头的占位符文件,并在正常使用条件下自动转变为完整文件。 占位符文件在 Windows Shell 中以典型文件的形式呈现给应用程序和最终用户。
- 占位符文件从 Windows 内核垂直集成到 Windows Shell,并且与占位符文件的应用兼容性通常不是问题。 无论你是使用文件系统 API、命令提示符还是桌面或 UWP 应用来访问占位符文件,文件都会解除冻结,而无需进行其他代码更改,并且该应用可以正常使用该文件。
- 文件可以存在于三种状态:
- 占位符文件(Placeholder):文件的空表示形式,仅当同步服务可用时才可用。
- 完整文件(Full):文件已隐式冻结,如果需要空间,系统可能会解除冻结。
- 固定的完整文件(Full Pinned):该文件已由用户通过文件资源管理器下载,并保证可脱机使用。
下图演示了文件资源管理器中如何显示占位符、完整和固定的完整文件状态。
在真正开始写FetchData 回调、流式下载、同步状态更新之前,必须先理解一个核心问题:
CFAPI 为什么一定要用“占位符(Placeholder)”?
如果不理解这一点,后面几乎所有行为——
Explorer 显示、CreateFile 行为、OnFetchData 触发时机、同步状态图标——都会让人困惑。
一、CFAPI 的本质目标
CFAPI(Cloud Files API)的目标不是“做一个网盘下载器”,而是:
让远端数据,在本地看起来就像真实存在的文件系统对象
也就是说:
| 用户视角 | 系统视角 |
|---|---|
| 文件已经在磁盘上 | 实际数据可能完全不在本地 |
| 能看到文件名、大小、时间 | 仅存在元数据 |
| 双击才下载 | 按需拉取数据 |
占位符(Placeholder)正是实现这一点的基础设施。
二、什么是占位符(Placeholder)
一句话定义:
占位符是一个“没有真实文件数据,但具备完整文件系统元信息的文件 / 目录对象”
它存在于 NTFS 中,但内容是“空的”或“未实体化的”。
占位符具备什么?
✔ 文件名
✔ 路径
✔ 文件大小
✔ 创建 / 修改时间
✔ 文件属性
✔ 同步状态(云、已下载、部分下载)
占位符缺少什么?
✘ 实际文件数据(Data Stream)
三、没有占位符会发生什么?
假设你不用占位符,而是“真正下载后才创建文件”。
1️⃣ Explorer 无法显示远端文件
文件不在磁盘上
Explorer 没有任何对象可枚举
用户看到的是一个“空目录”
👉用户体验直接失败
2️⃣ CreateFile 无法被拦截
CFAPI 的核心是:
当用户或程序访问文件时,系统回调给 Provider
但前提是:
这个文件对象已经存在于 NTFS
没有占位符:
CreateFile("cloud.txt") → ERROR_FILE_NOT_FOUNDFetchData 根本不会触发
3️⃣ Windows 无法管理同步状态
这些状态图标:
☁ 云端
✔ 本地
⬇ 正在下载
全部依赖于:
占位符 + CFAPI 状态机没有占位符,Windows 不知道:
这个文件是不是云文件
是否允许按需下载
是否可以释放本地数据
四、占位符在 CFAPI 中的地位
你可以把占位符理解为:
Cloud Files 的“合同文件”
CFAPI 的工作流程(简化)
远端元数据 ↓ CfCreatePlaceholders ↓ 占位符(NTFS 对象) ↓ 用户访问(CreateFile / Read) ↓ OnFetchData 回调 ↓ 下载真实数据 ↓ CfWriteFile / CfCompleteFetchData👉没有占位符,就没有后续所有步骤
五、占位符 ≠ 空文件(非常关键)
这是新手最容易犯的错误。
| 项目 | 占位符 | 空文件 |
|---|---|---|
| NTFS 对象 | 有 | 有 |
| 文件大小 | 可为真实大小 | 通常为 0 |
| 数据是否存在 | ❌ | ✅ |
| 触发 FetchData | ✅ | ❌ |
| 支持按需下载 | ✅ | ❌ |
⚠空文件一旦存在真实数据流,CFAPI 就认为“你已经有数据了”
这也是你之前遇到的:
CreateFile 直接触发下载
或不触发 FetchData
或 ERROR_CLOUD_FILE_INVALID_REQUEST
的根本原因之一。
六、为什么 CFAPI 要“先占位、后取数”
1️⃣ 性能
百万级文件列表
秒级展示目录结构
不下载任何内容
2️⃣ 资源管理
磁盘空间可控
CfDehydratePlaceholder 可释放本地数据
系统自动回收
3️⃣ 系统一致性
Explorer
CMD / PowerShell
第三方程序(CreateFile)
全部通过 NTFS 统一入口
七、结合你当前开发中的几个关键点
✔ 为什么只是CreateFile就触发OnFetchData
因为:
打开的是占位符
访问方式涉及数据访问
系统认为需要实体化
✔ 为什么获取句柄要小心 Flags
FILE_FLAG_OPEN_REPARSE_POINT避免系统认为你要读数据
只是“操作占位符元信息”
✔ 为什么 CfOpenFileWithOplock 很重要
它明确告诉系统:
这是 Provider 操作,不是用户访问避免错误触发 FetchData
八、一句话总结
占位符不是“可选项”,而是 CFAPI 的根基。
没有占位符:
Explorer 不可见
FetchData 不触发
同步状态失效
CFAPI 退化成普通文件 IO
九、占位符(Placeholder)操作类
使用 C# .NET8
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>net8.0-windows10.0.17763.0</TargetFramework> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> </PropertyGroup> <ItemGroup> <PackageReference Include="Microsoft.Windows.CsWinRT" Version="2.2.0" /> <PackageReference Include="Serilog" Version="4.3.0" /> <PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" /> <PackageReference Include="Vanara.PInvoke.CldApi" Version="4.2.1" /> <PackageReference Include="Vanara.PInvoke.SearchApi" Version="4.2.1" /> </ItemGroup> </Project>using Serilog; using System.ComponentModel; using System.Runtime.InteropServices; using Vanara.PInvoke; using static Vanara.PInvoke.CldApi; using static Vanara.PInvoke.Kernel32; namespace CfapiSync { public class Placeholder { public static bool Create(string fileId , string baseDirectoryPath, string relativeFileName, CF_FS_METADATA fsMetadata) { bool isDirectory = fsMetadata.BasicInfo.FileAttributes.HasFlag(FileFlagsAndAttributes.FILE_ATTRIBUTE_DIRECTORY); var cloudEntry = new CF_PLACEHOLDER_CREATE_INFO { FileIdentity = Marshal.StringToCoTaskMemUni(fileId), FileIdentityLength = (uint)(fileId.Length * Marshal.SizeOf(typeof(char))), RelativeFileName = relativeFileName, FsMetadata = fsMetadata, Flags = CF_PLACEHOLDER_CREATE_FLAGS.CF_PLACEHOLDER_CREATE_FLAG_MARK_IN_SYNC, }; if (isDirectory) { cloudEntry.Flags |= CF_PLACEHOLDER_CREATE_FLAGS.CF_PLACEHOLDER_CREATE_FLAG_DISABLE_ON_DEMAND_POPULATION; cloudEntry.FsMetadata.FileSize = 0; } var entitys = new CF_PLACEHOLDER_CREATE_INFO[] { cloudEntry }; var res = CfCreatePlaceholders(baseDirectoryPath, entitys, 1, CF_CREATE_FLAGS.CF_CREATE_FLAG_NONE, out uint entresProcessed); if (res.Succeeded) { Log.Information($"Placeholder-> Create : {relativeFileName} "); return true; } if(res.Code == 183) { Log.Warning($"Placeholder-> Create -> 已经存在 {relativeFileName} "); Convert(fileId, System.IO.Path.Combine(baseDirectoryPath, relativeFileName)); return false; } if (!res.Succeeded) { Log.Error($"Placeholder-> Create : {relativeFileName} 创建失败! {res}"); return false; } return true; } public static bool Convert(string fileId, string filePath) { var hFile = CreateFile(filePath, Kernel32.FileAccess.FILE_GENERIC_READ | Kernel32.FileAccess.FILE_GENERIC_WRITE, FileShare.ReadWrite | FileShare.Delete, null, FileMode.Open, FileFlagsAndAttributes.FILE_FLAG_OPEN_REPARSE_POINT | FileFlagsAndAttributes.FILE_FLAG_BACKUP_SEMANTICS, IntPtr.Zero); if (hFile.IsInvalid) { Log.Warning("Placeholder-> Convert: 文件无效!"); return false; } var fileIdentity = Marshal.StringToCoTaskMemUni(fileId); var fileIdentityLength = (uint)(fileId.Length * Marshal.SizeOf(typeof(char))); // CF_CONVERT_FLAG_NONE 什么都不显示 // CF_CONVERT_FLAG_MARK_IN_SYNC 与云同步 绿色对号, // CF_CONVERT_FLAG_DEHYDRATE 文件操作后 变为云文件 var res = CfConvertToPlaceholder(hFile, fileIdentity, fileIdentityLength, CF_CONVERT_FLAGS.CF_CONVERT_FLAG_MARK_IN_SYNC, out long ConvertUsn, 0); hFile.Dispose(); if (res.Succeeded) { Log.Information($"Placeholder-> Convert : {filePath} "); return true; } else { Log.Error($"Placeholder-> Convert : {filePath} 转换失败! {res}"); return false; } } // 将占位符恢复为常规文件,去除所有特殊特征,例如重分析标记、文件标识等。 public static bool Revert( string filePath) { var hFile = CreateFile(filePath, Kernel32.FileAccess.FILE_GENERIC_READ | Kernel32.FileAccess.FILE_GENERIC_WRITE, FileShare.ReadWrite | FileShare.Delete, null, FileMode.Open, FileFlagsAndAttributes.FILE_FLAG_OPEN_REPARSE_POINT | FileFlagsAndAttributes.FILE_FLAG_BACKUP_SEMANTICS, IntPtr.Zero); var res = CfRevertPlaceholder(hFile, CF_REVERT_FLAGS.CF_REVERT_FLAG_NONE,0); if (res.Succeeded) { Log.Information($"Placeholder-> Revert : {filePath} "); } else { Log.Error($"Placeholder-> Revert : {filePath} 转换失败! {res}"); } return true; } public static bool Update(string filePath, CF_FS_METADATA fsMetadata) { var hFile = CreateFile(filePath, Kernel32.FileAccess.FILE_GENERIC_READ | Kernel32.FileAccess.FILE_GENERIC_WRITE, FileShare.ReadWrite | FileShare.Delete, null, FileMode.Open, FileFlagsAndAttributes.FILE_FLAG_OPEN_REPARSE_POINT | FileFlagsAndAttributes.FILE_FLAG_BACKUP_SEMANTICS, IntPtr.Zero); // HRESULT hr = CfOpenFileWithOplock(filePath,CF_OPEN_FILE_FLAGS.CF_OPEN_FILE_FLAG_DELETE_ACCESS, out SafeHCFFILE hFile); //if (hr.Failed) //{ // Log.Error($"Placeholder-> Update : {filePath} 更新失败!"); // return false; //} IntPtr infoBuffer = IntPtr.Zero; try { CF_PLACEHOLDER_INFO_CLASS infoClass = CF_PLACEHOLDER_INFO_CLASS.CF_PLACEHOLDER_INFO_STANDARD; uint bufferLength = (uint)Marshal.SizeOf<CF_PLACEHOLDER_STANDARD_INFO>() + 4096; infoBuffer = Marshal.AllocHGlobal((int)bufferLength); var fileHandle = hFile.DangerousGetHandle(); var hr = CfGetPlaceholderInfo( fileHandle, infoClass, infoBuffer, bufferLength, out uint returnedLength); if (hr.Failed) { Log.Error($"Placeholder-> Update : {filePath} 更新失败!"); return false; } var standardInfo = Marshal.PtrToStructure<CF_PLACEHOLDER_STANDARD_INFO>(infoBuffer); // 2. FileIdentity 必须有效 if (standardInfo.FileIdentity == null || standardInfo.FileIdentity.Length == 0 || standardInfo.FileIdentityLength == 0) { throw new InvalidOperationException("Invalid FileIdentity."); } GCHandle handle = GCHandle.Alloc(standardInfo.FileIdentity, GCHandleType.Pinned); try { IntPtr fileIdentityPtr = handle.AddrOfPinnedObject(); System.Int64 usn =0; hr = CfUpdatePlaceholder( fileHandle, // 句柄无效 fsMetadata, fileIdentityPtr, standardInfo.FileIdentityLength, null, 0, CF_UPDATE_FLAGS.CF_UPDATE_FLAG_VERIFY_IN_SYNC, ref usn ); if (hr.Failed) { Log.Error($"Placeholder-> Update : {filePath} 更新失败!{hr}"); return false; } } finally { handle.Free(); } Log.Information($"Placeholder-> Update : {filePath}"); return true; } finally { if (infoBuffer != IntPtr.Zero) Marshal.FreeHGlobal(infoBuffer); hFile.Dispose(); } } /// <summary> /// 判断路径否是 Cloud Placeholder,以及它的脱水与同步状态 /// </summary> public static CF_PLACEHOLDER_STATE GetStatus(string filePath, out bool isPlaceholder, out bool isDehydrated ,out bool isSynced ) { isPlaceholder = false; isDehydrated = false; isSynced = false; var hFile = CreateFile( filePath, 0, FileShare.ReadWrite | FileShare.Delete, null, FileMode.Open, FileFlagsAndAttributes.FILE_FLAG_OPEN_REPARSE_POINT | FileFlagsAndAttributes.FILE_FLAG_BACKUP_SEMANTICS, IntPtr.Zero); if (hFile.IsInvalid) { Log.Error($"Placeholder-> GetStatus : {filePath},文件无效!"); return CF_PLACEHOLDER_STATE.CF_PLACEHOLDER_STATE_INVALID; } try { int size = Marshal.SizeOf<FILE_ATTRIBUTE_TAG_INFO>(); IntPtr infoBuffer = Marshal.AllocHGlobal(size); try { bool ok = GetFileInformationByHandleEx(hFile, FILE_INFO_BY_HANDLE_CLASS.FileAttributeTagInfo, infoBuffer, (uint)size); if (!ok) { Log.Error($"Placeholder-> GetStatus -> GetFileInformationByHandleEx -> {filePath} !"); return CF_PLACEHOLDER_STATE.CF_PLACEHOLDER_STATE_INVALID; } CF_PLACEHOLDER_STATE state = CfGetPlaceholderStateFromFileInfo(infoBuffer, FILE_INFO_BY_HANDLE_CLASS.FileAttributeTagInfo); if (state == CF_PLACEHOLDER_STATE.CF_PLACEHOLDER_STATE_INVALID) throw new InvalidOperationException("Invalid placeholder state"); isPlaceholder = (state & CF_PLACEHOLDER_STATE.CF_PLACEHOLDER_STATE_PLACEHOLDER) != 0; isDehydrated = (state & CF_PLACEHOLDER_STATE.CF_PLACEHOLDER_STATE_PLACEHOLDER) != 0 && (state & CF_PLACEHOLDER_STATE.CF_PLACEHOLDER_STATE_PARTIALLY_ON_DISK) != 0; isSynced = (state & CF_PLACEHOLDER_STATE.CF_PLACEHOLDER_STATE_IN_SYNC) != 0; Log.Information($"Placeholder-> GetStatus : {filePath},state->{state}!"); return state; } finally { Marshal.FreeHGlobal(infoBuffer); } } finally { hFile.Dispose(); } } /// <summary> /// 设置占位符文件或文件夹的同步状态。 /// </summary> /// <param name="filePath">文件地址</param> /// <param name="syncOngoing">同步进行中</param> /// <returns></returns> public static bool SetStatus(string filePath, bool syncOngoing) { var hFile = CreateFile( filePath, 0, FileShare.ReadWrite | FileShare.Delete, null, FileMode.Open, FileFlagsAndAttributes.FILE_FLAG_OPEN_REPARSE_POINT | FileFlagsAndAttributes.FILE_FLAG_BACKUP_SEMANTICS, IntPtr.Zero); if (hFile.IsInvalid) { Log.Error($"Placeholder-> GetStatus : {filePath},文件无效!"); return false; } CF_IN_SYNC_STATE inSyncState = syncOngoing ? CF_IN_SYNC_STATE.CF_IN_SYNC_STATE_IN_SYNC : CF_IN_SYNC_STATE.CF_IN_SYNC_STATE_NOT_IN_SYNC; CF_SET_IN_SYNC_FLAGS inSyncFlags = CF_SET_IN_SYNC_FLAGS.CF_SET_IN_SYNC_FLAG_NONE; long inSyncUsn = 0; var res = CfSetInSyncState(hFile, inSyncState, inSyncFlags, ref inSyncUsn); hFile.Dispose(); if (res.Succeeded) { Log.Information($"Placeholder-> SetStatus : {filePath},state->{syncOngoing}!"); return true; } else { Log.Error($"Placeholder-> SetStatus : {filePath},state->{syncOngoing}!"); return false; } } /// <summary> /// 获取占位符信息 /// </summary> public static CF_PLACEHOLDER_STANDARD_INFO GetInfo( string filePath) { var hFile = CreateFile( filePath, 0, FileShare.ReadWrite | FileShare.Delete, null, FileMode.Open, FileFlagsAndAttributes.FILE_FLAG_OPEN_REPARSE_POINT | FileFlagsAndAttributes.FILE_FLAG_BACKUP_SEMANTICS, IntPtr.Zero); if (hFile.IsInvalid) { Log.Error($"Placeholder-> GetInfo : {filePath},文件无效!"); throw new Win32Exception(Marshal.GetLastWin32Error()); } try { var infoClass = CF_PLACEHOLDER_INFO_CLASS.CF_PLACEHOLDER_INFO_STANDARD; var length = 4096; var buffer = Marshal.AllocHGlobal( length); // 正式获取信息 HRESULT hr = CfGetPlaceholderInfo( hFile, infoClass, buffer, (uint)length, out uint returnedLength); if (hr.Failed) { Log.Error($"Placeholder-> GetInfo : 获取失败 {hr}"); Marshal.ThrowExceptionForHR(hr.Code); } var info = Marshal.PtrToStructure<CF_PLACEHOLDER_STANDARD_INFO>(buffer); Log.Information($"Placeholder-> GetInfo : 获取成功{info}"); Marshal.FreeHGlobal(buffer); return info; } finally { hFile.Dispose(); } } //CF_PIN_STATE_PINNED 固定文件,用户本地一定有完整文件内容 //CF_PIN_STATE_UNPINNED 取消固定,文件不需要始终保留本地内容,可以节省磁盘空间 //CF_PIN_STATE_EXCLUDED 排除同步,占位符永远不被同步到云端 //CF_PIN_STATE_INHERIT 继承, 状态继承自父目录 //CF_PIN_STATE_UNSPECIFIED 未指定, 系统自由管理占位符内容 public static bool SetPinState(string path, CF_PIN_STATE state) { var hFile = CreateFile(path, Kernel32.FileAccess.FILE_GENERIC_READ | Kernel32.FileAccess.FILE_GENERIC_WRITE, FileShare.ReadWrite | FileShare.Delete, null, FileMode.Open, FileFlagsAndAttributes.FILE_FLAG_OPEN_REPARSE_POINT |FileFlagsAndAttributes.FILE_FLAG_BACKUP_SEMANTICS, IntPtr.Zero); if (hFile.IsInvalid) { Console.WriteLine("Failed to open file."); return false; } var res = CfSetPinState(hFile, state, CF_SET_PIN_FLAGS.CF_SET_PIN_FLAG_NONE, IntPtr.Zero); hFile.Dispose(); if (res.Succeeded) { Log.Information($"Placeholder-> SetPinState({state}) : {path} "); return true; } else { Log.Warning($"Placeholder-> SetPinState({state}) : {path} , 错误 {res} "); return false; } } } }Create —— 创建占位符
根据远端文件或目录的元数据,在本地 NTFS 中创建一个云文件占位符。
创建后文件立即可见但不包含实际数据,不占用磁盘空间。
Convert —— 转换为占位符
将一个已存在的普通文件或目录转换为云同步占位符。
常用于接管本地已有文件,使其纳入云同步体系。
Revert —— 恢复为普通文件
移除文件的云占位符特性,将其还原为标准 NTFS 文件或目录。
用于解除云同步绑定或回滚云文件状态。
Update —— 更新占位符元数据
更新占位符的文件大小、时间戳、属性等元数据信息。
用于同步远端变更,不涉及文件数据读写。
GetStatus —— 获取占位符状态
判断指定路径是否为云占位符,并获取其实体化与同步状态。
用于决定是否需要下载、脱水或更新同步标识。
SetStatus —— 设置同步状态
显式设置占位符的“已同步 / 未同步”状态。
用于控制 Explorer 中的同步完成图标显示。
GetInfo —— 获取占位符详细信息
读取占位符的标准 CFAPI 信息结构,包括标识、状态和标志位。
主要用于调试、审计或高级同步逻辑判断。
SetPinState —— 固定 / 取消固定文件
设置占位符文件的固定策略,控制是否必须常驻本地数据。
用于支持“始终保留本地副本”或“按需下载”的用户行为。