پیاده‌سازی Git در جاوااسکریپت

این مقاله نشان می‌دهد که چطور Gitfred ساخته شد. کتابخانه‌ای که تجربه‌ای مثل گیت را برای ذخیره‌سازی محتوا در جاوااسکریپت ارائه می‌دهد. این مطلب برخی ایده‌های پشت پرده Gitfred را معرفی می‌کند.

پیاده‌سازی Git در جاوااسکریپت

معرفی

Git بسیار دوست‌داشتنی می‌باشد و هیجان کار با آن خیلی بیشتر از SVN است. Git یک قطعه فوق‌العاده نرم‌افزاری است که باعث می‌شود نوشتن کد ساده‌تر شود. احتمالا اکثر مردم نمی‌دانند که چقدر زندگی ما به خاطر این ابزار آسان خواهد شد.

جهت یادگیری گیت میتوانید آموزش git از صفر را در سایت تاپ لرن مشاهده کنید .

من (Krasimir Tsonev) دو ماه را برای ساخت وب‌سایتی صرف کردم و یکی از مشکلاتی که با آن مواجه بودم مدیریت داده‌ها و انتقال به back-end بود. این سایت این اجازه را به ما می‌دهد تا این تجربه تعاملی را در جایی که کد را در مرورگر می‌نویسیم داشته باشیم و فورا می‌بینیم که کار می‌کند. همه این موارد خوب است. با این وجود مشکل من با آن‌ها عدم تاریخچه تغییرات است. به ویژه وقتی در مورد نوشتن فنی صحبت می‌کنیم. من می‌خواهم نمونه‌ای را بسازم و به خواننده نشان دهم چگونه از طریق آن پیشرفت می‌کنیم. و گیت کاملا برای این ایده مناسب است. تصور کنید چگونه در حال نوشتن کد، کامیت‌ها را ایجاد می‌کنیم و سپس این کامیت‌ها چگونه بخشی از کل Story  می‌شوند. این چیزی است که سایتی که من بر روی آن کار می‌کردم در مورد آن است.

این مساله جالب است اما برای ایجاد آن مدام باید هر تغییر را در پایگاه داده ذخیره کنم. این یعنی میزان دائمی درخواست‌ها برای API. چنین برنامه‌هایی ممکن است از لحاظ انتقال داده و ذخیره‌سازی واقعا گران باشند.

بنابراین من تصمیم گرفتم مشکل را با شیوه‌ای مشابه حل کنم و سایت را با گیت طراحی کنم.

بیایید با اصول اولیه شروع کنیم. گیت دارای سه حالت است و ما باید آن‌ها را در پیاده‌سازی‌مان نشان دهیم.

Commited: به این معناست که تغییرات ما در پایگاه داده محلی ذخیره می‌شوند.

Modified: به این معناست که ما برخی تغییرات را انجام می‌دهیم اما هنوز در پایگاه داده وجود ندارند.

Staged: به این معناست که تغییرات ما مشخص شده‌اند تا به کامیت بعدی برویم.

const data = {
  working: {},
  staging: {},
  commits: {}
}

Working وضعیت modified و staging وضعیت staged را نشان می‌دهد، و commits نقش پایگاه داده محلی را بازی خواهد کرد.

در گیت ما مفهوم HEAD را داریم که به سادگی به branch فعلی ما اشاره می‌کند. در موردی که ما با آن مواجه هستیم HEAD به کامیت خاصی در فیلد commits اشاره خواهد کرد. هر کامیت باید یک identifier (تعین‌کننده هویت) خاص نیز داشته باشد که ما در گیت آن را به عنوان hash تعریف می‌کنیم. آن را به صورت ساده می‌گیریم و از یک متغیر i استفاده می‌کنیم، و با این دو مورد آن را به پایان می‌رسانیم همانند آبجکت data زیر:

const data = {
  i: 0,
  head: null,
  working: {},
  staging: {},
  commits: {}
}

با قرار دادن آن در یک تابع ادامه می‌دهیم. تابعی که یک آبجکت git  را با چند متد برمی‌گرداند.

const createGit = function () {
 	const data = {
    i: 0,
    head: null,
    working: {},
    staging: {},
    commits: {}
  }
  
  return {
    save(filepath, content) {},
    get() {},
    add() {},
    commit(message) {},
    checkout(hash) {}
  }
}

const git = createGit();

const createGit = function () {
 	const data = {
    i: 0,
    head: null,
    working: {},
    staging: {},
    commits: {}
  }
  
  return {
    save(filepath, content) {},
    get() {},
    add() {},
    commit(message) {},
    checkout(hash) {}
  }
}

const git = createGit();

Save برخی چیزها را به دایرکتوری working ما اضافه می‌کند. get محتوای همان فیلد را برمی‌گرداند. add تغییرات ما را stage می‌کند. در گیت ممکن است فقط برخی تغییرات stage شوند اما در اینجا فرض می‌کنیم توسعه‌دهنده می‌خواهد هر چیزی را stage کند. commit آنچه را که در فیلد staging است را دریافت خواهد کرد و کامیتی را خواهد ساخت که نقشه commits را ذخیره خواهد کرد. در نهایت checkout به ما اجازه می‌دهد تا با گرفتن محتوای کامیت به رکورد خاصی برویم و موقعیت آن را به فیلد working__ برگردانیم تا بتوانیم از get استفاده کرده و آن را بخوانیم.

ذخیره و بازیابی فیلدها از دایرکتوری working

از آنجا که ما تصمیم گرفتیم از یک آبجکت به عنوان فیلد دایرکتوری working استفاده کنیم، از filepath به عنوان کلید و از content به عنوان مقدار استفاده خواهیم کرد.

save(filepath, content) {
  data.working[filepath] = content;
}

خواندن فقط بازگشت data.working است:

get() {
  return data.working;
}

و ما می‌توانیم این تغییرات را با مثال زیر امتحان کنیم:

git.save('app.js', 'const answer = 42;');
console.log(JSON.stringify(git.get(), null, 2));
/* 
  results in:
  {
    "app.js": "const answer = 42;"
  }
*/

Stage کردن تغییرات‌مان

برای راحتی کار متد دیگری به نام export تعریف می‌کنیم. این متد تمام آبجکت data را باز می‌گرداند تا بتوانیم بر آنچه که از بیرون انجام می‌شود نظارت داشته باشیم.

export() {
  return data;
}

همان طور که در بالا گفتیم فرآیند stage ما هر آنچه که در دایرکتوری working است را گرفته و آن را در قسمت staging کپی می‌کند.

add() {
  data.staging = JSON.parse(JSON.stringify(data.working));
}

ما از سریع‌ترین روش برای clone کردن (گرفتن نمونه‌ای از آن) از آبجکت در جاوااسکریپت استفاده می‌کنیم، JSON.stringify و سپس JSON.parse. حالا اگر ما مثال خود را کمی گسترش دهیم، تاثیرات را خواهیم دید.

git.save('app.js', 'const answer = 42;');
git.add();
console.log(JSON.stringify(git.export(), null, 2));

نتیجه مانند زیر است:

{
  "i": 0,
  "head": null,
  "working": {
    "app.js": "const answer = 42;"
  },
  "staging": {
    "app.js": "const answer = 42;"
  },
  "commits": {}
}

همان فایل با همان محتوا در حال حاضر در دو مکان وجود دارد.

کامیت کردن به پایگاه داده محلی ما

چند مورد در اینجا وجود دارد که باید در اینجا رخ دهد. اولین کار تولید hash منحصربه‌فرد برای کامیت‌مان است. دومین مورد این است که باید محتوای بخش staging را دریافت کرده و همراه با پیام کامیت آن را در فیلد commits ذخیره کنیم. همچنین باید بخش staging را خالی کنیم، بنابراین در موقعیت خوبی برای تغییرات آتی هستیم و می‌فهمیم که گیت در حال انجام چه کاری است. درنهایت head باید به آن کامیت جدید اشاره کند.

commit(message) {
  const hash = '_' + (++data.i);

  data.commits[hash] = {
    content: data.staging,
    message
  };
  data.staging = {};
}

بیایید در مثال‌مان از متد commit استفاده کنیم و بیبینیم آبجکت data بعدا چگونه می‌شود:

git.save('app.js', 'const answer = 42;');
git.add();
git.commit('first commit');

و نتیجه این‌گونه است:

{
  "i": 1,
  "head": "_1",
  "working": {
    "app.js": "const answer = 42;"
  },
  "staging": {},
  "commits": {
    "_1": {
      "content": {
        "app.js": "const answer = 42;"
      },
      "message": "first commit"
    }
  }
}

توجه داشته باشید که چگونه شمارنده i به 1 افزایش یافته است که بدین معناست که کامیت دوم hashی 2- خواهد داشت. Staging دوباره خالی است و یک کامیت ثبت شده است. head به محل مناسب نیز اشاره می‌کند. بیایید به سراغ متد شگفت‌انگیز checkout برویم.

Checking out

برای نشان دادن اینکه متد checkout چه کاری انجام می‌دهد ما حداقل باید دو کامیت داشته باشیم. پس اجازه دهید فایل دیگری به نام foo.js را به پایگاه داده اضافه کنیم و ببینم وضعیت نهایی آبجکت data چیست.

git.save('app.js', 'const answer = 42;');
git.add();
git.commit('first commit');
git.save('foo.js', 'const bar = "zar";');
git.add();
git.commit('second commit');
console.log(JSON.stringify(git.export(), null, 2));

ما باید دو کامیت با hashهای 1- و 2- داشته باشیم که محتوای آن‌ها app.js و foo.js است و در واقع، این همان چیزی است که اگر از داده‌ها پرینت بگیریم خواهیم دید:

{
  "i": 2,
  "head": "_2",
  "working": {
    "app.js": "const answer = 42;",
    "foo.js": "const bar = \"zar\";"
  },
  "staging": {},
  "commits": {
    "_1": {
      "content": {
        "app.js": "const answer = 42;"
      },
      "message": "first commit"
    },
    "_2": {
      "content": {
        "app.js": "const answer = 42;",
        "foo.js": "const bar = \"zar\";"
      },
      "message": "second commit"
    }
  }
}

در این مرحله head به آخرین کامیت اشاره می‌کند که مقدار آن 2- است. Checking out در ابتدا به معنی آپدیت مقدار head است، اما دایرکتوری working را نیز آپدیت می‌کند.

checkout(hash) {
  data.head = hash;
  data.working = JSON.parse(JSON.stringify(data.commits[hash].content));
}

در اینجا ما باید دوباره آن را clone کنیم زیرا در این صورت هر ذخیره‌سازی برای دایرکتوری working کامیت را در فیلد commits بهبود می‌بخشد. با انجام این کار برای پیاده‌سازی آماده می‌شویم. اکنون ما قادر به ذخیره اطلاعات، بازیابی آن، ایجاد تاریخچه تغییرات و سفر از طریق آن‌ها هستیم. اگر git.checkout('_1') را فراخونی کنیم متد export موارد زیر را نشان می‌دهد:

{
  "i": 2,
  "head": "_1",
  "working": {
    "app.js": "const answer = 42;"
  },
  "staging": {},
  "commits": {
    "_1": {
      "content": {
        "app.js": "const answer = 42;"
      },
      "message": "first commit"
    },
    "_2": {
      "content": {
        "app.js": "const answer = 42;",
        "foo.js": "const bar = \"zar\";"
      },
      "message": "second commit"
    }
  }
}

موارد بیشتر

اگر سورس کد Gitfred را باز کنید می‌بینید که دارای تعداد خطوط کد زیادی است. برای اینکه کتابخانه قابل استفاده باشد من مجبور شدم از گروهی از ویژگی‌هایی که در اینجا وجود دارد استفاده کنم. بیشتر بخش‌ها از کار گیت تقلید می‌کنند. با این حال یک چیز جالب و قابل توجه وجود دارد؛ مقیاس‌پذیری راه‌حل‌ها. تصور کنید ده‌ها فایل داریم و بعد از هر تغییر کامیت‌ها را ارسال می‌کنیم. این بدان معناست که مجموعه فایل‌هایی که چندین بار کپی شده است را داریم و این قطعا مقیاس‌پذیر نیست. ما نمی‌توانیم همه فایل‌ها را در هر کامیت نگه داریم زیرا payload  خیلی بزرگ می‌شود. آنچه در نهایت استفاده می‌کنم کتابخانه diff-match-patch گوگل است که یک پیاده‌سازی کوچک جاوااسکریپت از الگوریتم Myer's diff algorithm است، و به من اجازه داد فقط تغییرات را بین کامیت‌ها ذخیره کنم و به طور قابل توجهی داده‌های ذخیره شده در پایگاه داده سایت مورد نظرم را کاهش دهم.

در اینجا مثال ساده‌ای از دو رشته توسط diff-match-patch وجود  دارد و diff این‌گونه است:

const str1 = 'Hello world';
const str2 = 'Goodbye world';

var dmp = new diff_match_patch();
var diff = dmp.diff_main(str1, str2);
dmp.diff_cleanupSemantic(diff);
console.log(diff);
// outputs: -1,Hello,1,Goodbye,0, world