Kaynak gizli dosya sistemi

Dosya Sistemi Standardı, kaynak özel dosya sistemini (OPFS) sayfanın kaynağına özel bir depolama uç noktası olarak sunar. Bu sistemde, performans açısından yüksek düzeyde optimize edilmiş özel bir dosya türüne isteğe bağlı erişim sağlar ve sayfanın kaynağına özel depolama uç noktası olarak kullanıcı tarafından görülmez.

Tarayıcı desteği

Kaynak özel dosya sistemi, modern tarayıcılar tarafından desteklenir ve Dosya Sistemi Yaşam Standardı'ndaki Web Hypertext Uygulama Teknolojisi Çalışma Grubu (WHATWG) tarafından standartlaştırılmıştır.

Tarayıcı Desteği

  • 86
  • 86
  • 111
  • 15,2

Kaynak

Motivasyon

Bilgisayarınızdaki dosyaları düşündüğünüzde büyük olasılıkla bir dosya hiyerarşisini hatırlarsınız. Bu hiyerarşi, klasörler halinde düzenlenen dosyalardır ve işletim sisteminizin dosya gezginiyle keşfedebileceğiniz dosyalardır. Örneğin, Windows'da Tarık adlı kullanıcının Yapılacaklar listesi C:\Users\Tom\Documents\ToDo.txt dahilinde olabilir. Bu örnekte ToDo.txt dosya adı, Users, Tom ve Documents ise klasör adlarıdır. Windows'daki "C:", sürücünün kök dizinini temsil eder.

Web'deki dosyalarla çalışmanın geleneksel yolu

Bir web uygulamasında Yapılacaklar listesini düzenlemek için olağan akış şu şekildedir:

  1. Kullanıcı, dosyayı bir sunucuya yükler veya <input type="file"> ile istemcide açır.
  2. Kullanıcı, değişiklik yapar ve oluşturulan dosyayı JavaScript aracılığıyla click() programlı bir şekilde, yerleştirilen bir <a download="ToDo.txt> ile indirir.
  3. Klasörleri açmak için <input type="file" webkitdirectory> ürününde özel bir özellik kullanırsınız. Bu özellik, kendisine ait olsa da neredeyse evrensel tarayıcı desteğine sahiptir.

Web'deki dosyalarla çalışmanın modern yolu

Bu akış, kullanıcıların dosya düzenlemeyle ilgili düşüncelerini yansıtmaz ve kullanıcıların giriş dosyalarının kopyalarını indirdikleri anlamına gelir. Bu nedenle File System Access API, showOpenFilePicker(), showSaveFilePicker() ve showDirectoryPicker() adlı üç seçici yöntemini kullanıma sundu. Bu yöntemler, adlarından da anlaşılacağı gibi kullanıldı. Bunlar aşağıdaki gibi bir akışı etkinleştirir:

  1. ToDo.txt öğesini showOpenFilePicker() ile açın ve bir FileSystemFileHandle nesnesi alın.
  2. FileSystemFileHandle nesnesinden, dosya tanıtıcısının getFile() yöntemini çağırarak bir File alın.
  3. Dosyayı değiştirip herkese açık kullanıcı adında requestPermission({mode: 'readwrite'}) komutunu çağırın.
  4. Kullanıcı izin isteğini kabul ederse değişiklikleri orijinal dosyaya kaydedin.
  5. Alternatif olarak, showSaveFilePicker() numaralı telefonu arayıp kullanıcının yeni bir dosya seçmesine izin verebilirsiniz. (Kullanıcı daha önce açılmış bir dosyayı seçerse içindekiler üzerine yazılır.) Tekrarlanan kaydetme işlemleri için dosya tutma yerini koruyabilirsiniz. Böylece dosya kaydetme iletişim kutusunu tekrar göstermek zorunda kalmazsınız.

Web'deki dosyalarla çalışmaya ilişkin kısıtlamalar

Bu yöntemler aracılığıyla erişilebilen dosya ve klasörler, kullanıcı tarafından görülebilen dosya sisteminde bulunur. Web'den kaydedilen dosyalar ve özellikle yürütülebilir dosyalar web işaretiyle işaretlenir. Bu yüzden, tehlikeli olabilecek bir dosya yürütülmeden önce işletim sisteminin gösterebileceği ek bir uyarı bulunur. Ek bir güvenlik özelliği olarak, web'den alınan dosyalar Güvenli Tarama ile de korunur. Güvenli Tarama özelliği, kolaylık sağlamak ve bu makale bağlamında bulut tabanlı bir virüs taraması olarak düşünülebilir. File System Access API'yi kullanarak bir dosyaya veri yazdığınızda yazma işlemleri mevcut değildir ancak geçici bir dosya kullanılır. Dosya tüm bu güvenlik kontrollerini geçmediği sürece değiştirilmez. Tahmin edebileceğiniz gibi bu çalışma, mümkün olduğunda (örneğin, macOS'te) iyileştirmeler yapılmasına rağmen dosya işlemlerini nispeten yavaş hale getirir. Yine de her write() çağrısı bağımsız olduğundan, arka planda dosyayı açar, belirtilen ofseti arar ve son olarak verileri yazar.

İşlemenin temeli olarak dosyalar

Aynı zamanda, dosyalar veri kaydetmek için mükemmel bir yoldur. Örneğin SQLite, veritabanlarının tamamını tek bir dosyada depolar. Başka bir örnek de resim işlemede kullanılan mipmaps'tir. Mipmaps, önceden hesaplanmış, optimize edilmiş görüntü dizileridir. Her bir resim, bir öncekinin giderek daha düşük çözünürlüklü gösterimi olduğundan yakınlaştırma gibi birçok işlemi daha hızlı gerçekleştirir. Peki web uygulamaları, web tabanlı dosya işlemenin performans maliyetleri olmadan, dosyaların sunduğu avantajlardan nasıl yararlanabilir? Yanıt, kaynak gizli dosya sistemidir.

Kullanıcı tarafından görülebilen dosya sistemi ile kaynak gizli dosya sistemi arasındaki fark

İşletim sisteminin dosya gezgini kullanılarak göz atılan kullanıcı tarafından görülebilen dosya sisteminin aksine; okuyabileceğiniz, yazabileceğiniz, taşıyabileceğiniz ve yeniden adlandırabileceğiniz dosyalar ve klasörlere sahip olan kaynak gizli dosya sisteminin kullanıcılar tarafından görülmesi amaçlanmamıştır. Adından da anlaşılacağı gibi kaynak özel dosya sistemindeki dosyalar ve klasörler, gizlidir ve daha somut bir ifadeyle sitenin kaynak sayfasına özeldir. Geliştirici Araçları Konsolu'nda location.origin yazarak bir sayfanın kaynağını bulun. Örneğin, https://developer.chrome.com/articles/ sayfasının kaynağı https://developer.chrome.com şeklindedir (/articles kısmı, kaynağın parçası değildir). Köken teorisi hakkında daha fazla bilgiyi "aynı siteyi" ve "aynı kaynak"ı anlama bölümünde bulabilirsiniz. Aynı kaynağı paylaşan tüm sayfalar, aynı kaynak gizli dosya sistemi verilerini görebilir. Dolayısıyla, https://developer.chrome.com/docs/extensions/mv3/getstarted/extensions-101/ bir önceki örnekle aynı ayrıntıları görebilir. Her kaynağın kendi bağımsız kaynak özel dosya sistemi vardır. Diğer bir deyişle, https://developer.chrome.com kaynağının özel dosya sistemi, https://web.dev örneğinden tamamen farklıdır. Windows'da, kullanıcının görebildiği dosya sisteminin kök dizini C:\\ şeklindedir. Kaynak özel dosya sisteminin eşdeğeri, eşzamansız yöntemi navigator.storage.getDirectory() çağırarak erişilen kaynak başına başlangıçta boş bir kök dizindir. Kullanıcı tarafından görülebilen dosya sistemi ile kaynak gizli dosya sisteminin karşılaştırması için aşağıdaki şemaya bakın. Bu diyagram, kök dizin dışındaki her şeyin kavramsal olarak aynı olduğunu göstermektedir; verilerin ve depolama ihtiyaçlarının gerektirdiği şekilde düzenleme ve düzenlenecek dosya ve klasör hiyerarşisi vardır.

Kullanıcı tarafından görülebilen dosya sistemi ile kaynak özel dosya sisteminin iki örnek dosya hiyerarşisi içeren şeması. Kullanıcı tarafından görülebilen dosya sisteminin giriş noktası sembolik bir sabit disktir; kaynak özel dosya sisteminin giriş noktası &quot;navigator.storage.getDirectory&quot; yöntemini çağırır.

Kaynak özel dosya sisteminin bilgileri

Tarayıcıdaki diğer depolama mekanizmalarında (örneğin, localStorage veya IndexedDB) olduğu gibi, kaynak özel dosya sistemi de tarayıcı kota kısıtlamalarına tabidir. Kullanıcı, tüm tarama verilerini veya tüm site verilerini temizlediğinde kaynak gizli dosya sistemi de silinir. navigator.storage.estimate() çağrısı yapın ve sonuçta gösterilen yanıt nesnesinde, uygulamanızın halihazırda ne kadar depolama alanı kullandığını görmek için usage girişine bakın. Bu girişin, özellikle fileSystem girişine bakmak istediğiniz usageDetails nesnesindeki depolama mekanizmasına göre dökümü verilmiştir. Kullanıcı, kaynak gizli dosya sistemini göremediği için izin istemi ve Güvenli Tarama kontrolü yoktur.

Kök dizine erişim elde etme

Kök dizine erişmek için aşağıdaki komutu çalıştırın. Boş bir dizin tutamacı (daha açık belirtmek gerekirse bir FileSystemDirectoryHandle) elde edersiniz.

const opfsRoot = await navigator.storage.getDirectory();
// A FileSystemDirectoryHandle whose type is "directory"
// and whose name is "".
console.log(opfsRoot);

Ana iş parçacığı veya Web Çalışanı

Kaynak gizli dosya sistemini, ana iş parçacığı veya Web Çalışanı olmak üzere iki şekilde kullanabilirsiniz. Web İşçileri ana iş parçacığını engelleyemez. Bu, bu bağlamda API'lerin eşzamanlı olabileceği anlamına gelir. Bir kalıp, ana iş parçacığında genellikle izin verilmez. Eşzamanlı API'ler vaatlerle uğraşmak zorunda kalmadıkları için daha hızlı olabilir. Dosya işlemleri ise WebAssembly'de derlenebilen C gibi dillerde genellikle eşzamanlıdır.

// This is synchronous C code.
FILE *f;
f = fopen("example.txt", "w+");
fputs("Some text\n", f);
fclose(f);

Mümkün olan en hızlı dosya işlemlerine ihtiyacınız varsa veya WebAssembly işleriyle uğraşıyorsanız Web Çalışanı'nda kaynak özel dosya sistemini kullanma bölümüne geçin. Yoksa okumaya devam edebilirsiniz.

Ana iş parçacığında kaynak gizli dosya sistemini kullan

Yeni dosya ve klasörler oluşturma

Bir kök klasörünüz olduğunda, sırasıyla getFileHandle() ve getDirectoryHandle() yöntemlerini kullanarak dosya ve klasör oluşturun. {create: true} iletildiğinde dosya veya klasör yoksa oluşturulur. Yeni oluşturulan bir dizini başlangıç noktası olarak kullanıp bu işlevleri çağırarak bir dosya hiyerarşisi oluşturun.

const fileHandle = await opfsRoot
    .getFileHandle('my first file', {create: true});
const directoryHandle = await opfsRoot
    .getDirectoryHandle('my first folder', {create: true});
const nestedFileHandle = await directoryHandle
    .getFileHandle('my first nested file', {create: true});
const nestedDirectoryHandle = await directoryHandle
    .getDirectoryHandle('my first nested folder', {create: true});

Önceki kod örneğinden elde edilen dosya hiyerarşisi.

Mevcut dosya ve klasörlere erişme

Adlarını biliyorsanız getFileHandle() veya getDirectoryHandle() yöntemlerini çağırarak ve dosya ya da klasörün adını ileterek daha önce oluşturulan dosya ve klasörlere erişin.

const existingFileHandle = await opfsRoot.getFileHandle('my first file');
const existingDirectoryHandle = await opfsRoot
    .getDirectoryHandle('my first folder');

Dosyanın okunması için bir dosya tanıtıcısıyla ilişkilendirilmesini sağlama

FileSystemFileHandle, dosya sistemindeki bir dosyayı temsil eder. İlişkilendirilen File verisini edinmek için getFile() yöntemini kullanın. File nesnesi belirli bir Blob türüdür ve Blob ile kullanılabilen her bağlamda kullanılabilir. Özellikle FileReader, URL.createObjectURL(), createImageBitmap() ve XMLHttpRequest.send() Blobs ile Files koşullarını kabul ediyor. Bunu yaparsanız, bir FileSystemFileHandle öğesinden File almak verileri "serbest bırakır", böylece verilere erişebilir ve onu kullanıcının görebileceği dosya sisteminin kullanımına sunabilirsiniz.

const file = await fileHandle.getFile();
console.log(await file.text());

Dosyaya akış yoluyla yazma

createWritable() yöntemini çağırarak bir dosyaya veri akışı sağlayabilirsiniz. Bu işlem bir FileSystemWritableFileStream oluşturur, ardından içeriği write(). Son olarak akışı close() yapmanız gerekir.

const contents = 'Some text';
// Get a writable stream.
const writable = await fileHandle.createWritable();
// Write the contents of the file to the stream.
await writable.write(contents);
// Close the stream, which persists the contents.
await writable.close();

Dosya ve klasör silme

Dosyaları ve klasörleri, dosya veya dizin işleyicisinin belirli remove() yöntemini çağırarak silin. Tüm alt klasörleri içeren bir klasörü silmek için {recursive: true} seçeneğini iletin.

await fileHandle.remove();
await directoryHandle.remove({recursive: true});

Alternatif olarak, bir dizindeki silinecek dosyanın veya klasörün adını biliyorsanız removeEntry() yöntemini kullanın.

directoryHandle.removeEntry('my first nested file');

Dosya ve klasörleri taşıma ve yeniden adlandırma

move() yöntemini kullanarak dosya ve klasörleri yeniden adlandırabilir ve taşıyabilirsiniz. Taşıma ve yeniden adlandırma işlemleri birlikte veya birbirinden ayrı olarak gerçekleştirilebilir.

// Rename a file.
await fileHandle.move('my first renamed file');
// Move a file to another directory.
await fileHandle.move(nestedDirectoryHandle);
// Move a file to another directory and rename it.
await fileHandle
    .move(nestedDirectoryHandle, 'my first renamed and now nested file');

Dosya veya klasörün yolunu çözümleme

Belirli bir dosyanın veya klasörün, bir referans diziniyle ilişkili olarak nerede olduğunu öğrenmek için resolve() yöntemini kullanın ve bağımsız değişken olarak FileSystemHandle iletin. Kaynak özel dosya sistemindeki bir dosyanın veya klasörün tam yolunu elde etmek için, navigator.storage.getDirectory() aracılığıyla elde edilen referans dizini olarak kök dizini kullanın.

const relativePath = await opfsRoot.resolve(nestedDirectoryHandle);
// `relativePath` is `['my first folder', 'my first nested folder']`.

İki dosya veya klasör tutma yerinin aynı dosyayı ya da klasörü işaret edip etmediğini kontrol etme

Bazen iki herkese açık tutma yeri aynı dosyayı veya klasörü gösterip göstermediklerini bilemezsiniz. Durumun bu olup olmadığını kontrol etmek için isSameEntry() yöntemini kullanın.

fileHandle.isSameEntry(nestedFileHandle);
// Returns `false`.

Klasör içeriğini listeleme

FileSystemDirectoryHandle, for await…of döngüsüyle yinelediğiniz bir eşzamansız yinelemedir. Ayrıca, eşzamansız yinelemeci olarak, ihtiyacınız olan bilgilere bağlı olarak seçebileceğiniz entries(), values() ve keys() yöntemlerini destekler:

for await (let [name, handle] of directoryHandle) {}
for await (let [name, handle] of directoryHandle.entries()) {}
for await (let handle of directoryHandle.values()) {}
for await (let name of directoryHandle.keys()) {}

Bir klasörün ve tüm alt klasörlerin içeriğini yinelemeli olarak listeleme

Eş zamansız döngüler ve yinelemeyle eşleştirilmiş işlevlerle uğraşmak kolaydır. Aşağıdaki fonksiyon, bir klasörün ve tüm alt klasörlerinin (tüm dosyalar ve boyutları dahil olmak üzere) içeriğini listelemek için bir başlangıç noktası olarak kullanılabilir. Dosya boyutlarına ihtiyacınız yoksa directoryEntryPromises.push ifadesini kullanarak (handle.getFile() sözünü değil, doğrudan handle) işlevi basitleştirebilirsiniz.

  const getDirectoryEntriesRecursive = async (
    directoryHandle,
    relativePath = '.',
  ) => {
    const fileHandles = [];
    const directoryHandles = [];
    const entries = {};
    // Get an iterator of the files and folders in the directory.
    const directoryIterator = directoryHandle.values();
    const directoryEntryPromises = [];
    for await (const handle of directoryIterator) {
      const nestedPath = `${relativePath}/${handle.name}`;
      if (handle.kind === 'file') {
        fileHandles.push({ handle, nestedPath });
        directoryEntryPromises.push(
          handle.getFile().then((file) => {
            return {
              name: handle.name,
              kind: handle.kind,
              size: file.size,
              type: file.type,
              lastModified: file.lastModified,
              relativePath: nestedPath,
              handle
            };
          }),
        );
      } else if (handle.kind === 'directory') {
        directoryHandles.push({ handle, nestedPath });
        directoryEntryPromises.push(
          (async () => {
            return {
              name: handle.name,
              kind: handle.kind,
              relativePath: nestedPath,
              entries:
                  await getDirectoryEntriesRecursive(handle, nestedPath),
              handle,
            };
          })(),
        );
      }
    }
    const directoryEntries = await Promise.all(directoryEntryPromises);
    directoryEntries.forEach((directoryEntry) => {
      entries[directoryEntry.name] = directoryEntry;
    });
    return entries;
  };

Bir Web Çalışanında kaynak özel dosya sistemini kullanma

Daha önce belirtildiği gibi, Web İşçileri ana ileti dizisini engelleyemez; bu nedenle bu bağlamda eşzamanlı yöntemlere izin verilir.

Eşzamanlı erişim herkese açık kullanıcı adı alma

Olası en hızlı dosya işlemlerinin giriş noktası, createSyncAccessHandle() çağrısı yapılarak normal bir FileSystemFileHandledan alınan FileSystemSyncAccessHandle'tır.

const fileHandle = await opfsRoot
    .getFileHandle('my highspeed file.txt', {create: true});
const syncAccessHandle = await fileHandle.createSyncAccessHandle();

Eşzamanlı yerinde dosya yöntemleri

Bir eşzamanlı erişim tutamacınız olduğunda, tümü eşzamanlı olan, yerinde dosya içi hızlı yöntemlere erişim elde edersiniz.

  • getSize(): Dosyanın boyutunu bayt cinsinden döndürür.
  • write(): Arabelleğin içeriğini dosyaya isteğe bağlı olarak belirli bir ofsette yazar ve yazılan bayt sayısını döndürür. Döndürülen yazılı bayt sayısının kontrol edilmesi, arayanların hataları ve kısmi yazma işlemlerini algılayıp işlemesine olanak tanır.
  • read(): Dosyanın içeriğini, isteğe bağlı olarak belirli bir ofsette bir arabelleğe okur.
  • truncate(): Dosyayı belirtilen boyuta göre yeniden boyutlandırır.
  • flush(): Dosya içeriğinin, write() yoluyla yapılan tüm değişiklikleri içermesini sağlar.
  • close(): Erişim işleyicisini kapatır.

Yukarıda belirtilen tüm yöntemlerin kullanıldığı bir örneği burada bulabilirsiniz.

const opfsRoot = await navigator.storage.getDirectory();
const fileHandle = await opfsRoot.getFileHandle('fast', {create: true});
const accessHandle = await fileHandle.createSyncAccessHandle();

const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();

// Initialize this variable for the size of the file.
let size;
// The current size of the file, initially `0`.
size = accessHandle.getSize();
// Encode content to write to the file.
const content = textEncoder.encode('Some text');
// Write the content at the beginning of the file.
accessHandle.write(content, {at: size});
// Flush the changes.
accessHandle.flush();
// The current size of the file, now `9` (the length of "Some text").
size = accessHandle.getSize();

// Encode more content to write to the file.
const moreContent = textEncoder.encode('More content');
// Write the content at the end of the file.
accessHandle.write(moreContent, {at: size});
// Flush the changes.
accessHandle.flush();
// The current size of the file, now `21` (the length of
// "Some textMore content").
size = accessHandle.getSize();

// Prepare a data view of the length of the file.
const dataView = new DataView(new ArrayBuffer(size));

// Read the entire file into the data view.
accessHandle.read(dataView);
// Logs `"Some textMore content"`.
console.log(textDecoder.decode(dataView));

// Read starting at offset 9 into the data view.
accessHandle.read(dataView, {at: 9});
// Logs `"More content"`.
console.log(textDecoder.decode(dataView));

// Truncate the file after 4 bytes.
accessHandle.truncate(4);

Kaynak gizli dosya sistemindeki bir dosyayı kullanıcının görebildiği dosya sistemine kopyalama

Yukarıda belirtildiği gibi, dosyaları kaynak gizli dosya sisteminden kullanıcı tarafından görülebilen dosya sistemine taşımak mümkün değildir ancak dosyaları kopyalayabilirsiniz. showSaveFilePicker() yalnızca ana iş parçacığında gösterildiği ve Çalışan iş parçacığında gösterilmediği için kodu burada çalıştırdığınızdan emin olun.

// On the main thread, not in the Worker. This assumes
// `fileHandle` is the `FileSystemFileHandle` you obtained
// the `FileSystemSyncAccessHandle` from in the Worker
// thread. Be sure to close the file in the Worker thread first.
const fileHandle = await opfsRoot.getFileHandle('fast');
try {
  // Obtain a file handle to a new file in the user-visible file system
  // with the same name as the file in the origin private file system.
  const saveHandle = await showSaveFilePicker({
    suggestedName: fileHandle.name || ''
  });
  const writable = await saveHandle.createWritable();
  await writable.write(await fileHandle.getFile());
  await writable.close();
} catch (err) {
  console.error(err.name, err.message);
}

Kaynak özel dosya sisteminde hata ayıkla

Yerleşik Geliştirici Araçları desteği eklenene kadar (crbug/1284595 adresine bakın), kaynağa özel dosya sistemindeki hataları ayıklamak için OPFS Explorer Chrome uzantısını kullanın. Bu arada, yukarıdaki Yeni dosya ve klasör oluşturma bölümünde yer alan ekran görüntüsü doğrudan uzantıdan alınmıştır.

Chrome Web Mağazası&#39;ndaki OPFS Explorer Chrome Geliştirici Araçları uzantısı.

Uzantıyı yükledikten sonra Chrome Geliştirici Araçları'nı açın, OPFS Gezgini sekmesini seçin. Böylece dosya hiyerarşisini incelemeye hazır olursunuz. Dosyaları, kaynak gizli dosya sisteminden kullanıcının görebildiği dosya sistemine kaydetmek için dosya adını tıklayın ve çöp kutusu simgesini tıklayarak dosyaları ve klasörleri silin.

Demo

Kaynak özel dosya sistemini, WebAssembly'de derlenen bir SQLite veritabanı için arka uç olarak kullanan bir demoda çalışırken (OPFS Explorer uzantısını yüklerseniz) görün. Glitch'teki kaynak koduna göz atmayı unutmayın. Aşağıdaki yerleştirilmiş sürümün, kaynak özel dosya sistemi arka ucunu kullanmadığına (çünkü iframe'in çapraz kaynak olması nedeniyle) ancak demoyu ayrı bir sekmede açtığınızda nasıl kullandığına dikkat edin.

Sonuçlar

WHATWG tarafından belirtildiği gibi kaynak gizli dosya sistemi, web'deki dosyaları kullanma ve dosyalarla etkileşim kurma şeklimizi şekillendirmiştir. Kullanıcının görebildiği dosya sistemiyle ulaşılması imkansız olan yeni kullanım alanlarını mümkün kıldı. Önde gelen tüm tarayıcı tedarikçileri (Apple, Mozilla ve Google) iş birliği içinde ve ortak bir vizyona sahip. Kaynak gizli dosya sisteminin geliştirilmesi büyük ölçüde ortak bir çaba gerektiriyor ve geliştiriciler ile kullanıcılardan gelen geri bildirimler, sistemin ilerlemesi açısından büyük önem taşıyor. Standardı hassaslaştırmaya ve iyileştirmeye devam ederken, whatwg/fs deposu ile ilgili sorunlar veya çekme istekleri şeklinde gönderilen geri bildirimler kabul edilmektedir.

Teşekkür

Bu makale Austin Sully, Etienne Noël ve Rachel Andrew tarafından incelenmiştir. Christina Rumpf'un Unsplash'teki lokomotif resmi.